AIエージェントが叩くとシェルが止まる・壊れる4つの原因

端末では動くスクリプトが、Claude CodeなどのAIエージェントから叩くと返ってこない。原因は端末が無いこと、人間向けの設定、pkill -f、zsh。5分止まった実例と、人間向けの設定をopt-inにする方法を解説する。

AIエージェントが叩くとシェルが止まる・壊れる4つの原因

本記事はプロモーションを含みます。

はじめに:端末では動くのに、エージェントから叩くと返ってこない

AI エージェントから叩くとシェルが止まる・壊れる 4 つの原因: 入力待ち、stdin の読み待ち、自分を殺す pkill、zsh の書き方の違い

自分の端末で打てば一瞬で終わるコマンドが、Claude Code のような AI エージェントに打たせると返ってこない。あるいは、途中のコマンドがエラーも出さずに走らない。筆者は自分の dotfiles のセットアップスクリプトを Claude Code に dry-run させたとき、5 分止まったまま返ってこなかった。

結論から書くと、原因は次の 4 つに分けられる。どれも「エージェントのシェルには、キーボードの前に人がいない」ことから来ている。

原因何が起きるか対処
人間向けの設定が入力を待つcd や cp、git commit がプロンプトやエディタで止まるエディタとページャをすぐ返るものにし、人間向けの設定は opt-in にする
自作ヘルパが stdin を読む端末でなければ cat で読む作りが、閉じない stdin で止まるpipe とファイルのときだけ読む
pkill -fコマンド全文を持つ自分のシェルまで殺す$! で控えた PID で kill する
シェルが zshbash のつもりの書き方で PATH が壊れる、ループが 1 回で終わる込み入った処理は bash に渡す

元記事の要点

dotfiles を AI agent のために作り変えた (tellme.tokyo、2026-10-01) の要点は次のとおり。

  • ターミナルを叩く主役は AI に変わったのに、dotfiles は人間向けの設定のままで、cd の入力待ちや git commit のエディタ起動が AI を止めていた
  • 設計を「デフォルトは AI、人間は opt-in」に逆転し、TTY の有無と環境変数で人間かどうかを判定する
  • 結果として、スナップショットは 14,284 行から 339 行、AI 用シェルの起動は 0.5 秒から 0.01 秒になった

後半では、この「シェルが起動したら AI だと思え」という設定の組み方を扱う。

cron などの無人実行で踏む罠は 自動実行のbashが突然落ちる・失敗を見逃す4つの罠 にまとめている。この記事はそのエージェント版だ。

なぜ手元の端末では再現しないのか

端末から叩くと stdin は tty だが、エージェントから叩くと stdin は端末ではない。Claude Code の Bash ツールでは閉じない socket だった

端末から叩いたコマンドは、stdin も stdout も端末 (tty) につながっている。エディタが開けば人が書いて閉じ、確認のプロンプトには人が y を打つ。

エージェントのシェルは違う。筆者の環境で Claude Code の Bash ツールから stdin を確かめると、/proc/self/fd/0 は端末ではなく socket:[...] を指していた。しかも、この socket は相手側から閉じられない。

  • 入力を待つコマンドは、誰も答えないので待ち続ける
  • stdin を最後まで読むコマンドは、EOF が来ないので戻らない

端末から試すと stdin は tty なので、どちらの経路にも入らない。手元で再現しないのはこのためだ。

原因1:人間向けの設定が入力を待つ

fzf の cd、cp -i、EDITOR=vim の git commit は、エージェントから叩くと誰も答えない入力待ちで止まる

元記事の筆者は、gh を打った回数を数えると自分が 80 回、AI が 1,339 回だったそうだ。ターミナルの主な利用者が AI に変わっているのに、10 年以上育てた dotfiles は人間向けの設定の塊になっていた。元記事が挙げている、AI の邪魔をした設定は次のとおり。

  • cd を fzf で行き先を選ぶ enhancd にしていたので、AI の cd .. が入力待ちで返ってこない
  • cp が cp -i のエイリアスなので、上書きのたびに確認を求める
  • ls が exa のエイリアスなので、ls -1t が Flag -t needs a value で落ちる
  • EDITOR=vim なので、AI が引数なしで git commit を打つとエディタが開いて止まる

元記事によると、Claude Code はセッションの開始時に .zshrc を読み、定義されたエイリアスや関数をスナップショット (~/.claude/shell-snapshots/) に書き出して、コマンドのたびに読み込む。そのため、対話シェルでなくても .zshrc のエイリアスが AI のコマンドに効く。

対処:誰も答えられない入力は、すぐ失敗させる

AI のシェルでは EDITOR=true、PAGER=cat、GIT_TERMINAL_PROMPT=0 にすると、エディタもページャもパスワードの入力も待たずに返る

元記事では、人間でないと判定したシェルで次の値を入れている。エディタやページャが起動しても、何も待たずに返る。

# Nobody can answer an editor, pager or password prompt: fail fast
export EDITOR=true VISUAL=true GIT_EDITOR=true GIT_SEQUENCE_EDITOR=true
export PAGER=cat GIT_PAGER=cat MANPAGER=cat
export GIT_TERMINAL_PROMPT=0

true は何もせず成功で終わるコマンドだ。GIT_EDITOR=true git commit は、メッセージを書かずに閉じたことになり、Aborting commit due to empty commit message. で終了コード 1 を返す。止まり続けるよりは、失敗が返ってくる方がエージェントは次の手を打てる。

原因2:自作ヘルパが stdin を読んで止まる

[[ ! -t 0 ]] は socket でも真になり、続く cat が来ない EOF を待ち続ける

筆者のスクリプトが 5 分止まった原因は、色付きで文字を出す自作のヘルパだった。引数でも pipe でも文字を受け取れるように、次の分岐を持っていた。

if [[ ! -t 0 ]]; then
  text=$(cat)   # 端末でなければ stdin から読む
fi

-t 0 が見ているのは「stdin が端末か」だけだ。pipe もファイルも /dev/null も socket も、端末でなければ同じ扱いになる。Claude Code の Bash ツールでは stdin が閉じない socket なので、cat が EOF を待ち続けていた。止まったプロセスを調べると、cat が socket の読み込み (unix_stream_read) で待っていた。

相手が閉じない socket を stdin に渡すと、端末が無くても再現できる。

python3 -c 'import socket,subprocess; a,b=socket.socketpair(); subprocess.run(["bash","-c","[[ ! -t 0 ]] && cat; echo done"], stdin=b, timeout=3)'
# => TimeoutExpired (cat が戻らない)

エージェントのほか、CI や systemd、プロセス管理ツールから呼ぶときにも、stdin は端末ではない。「引数で受けるか stdin から読むか」を -t 0 で切り替えるヘルパは、同じ止まり方をする可能性がある。

2 つの直し方を比べた

引数があれば読まない案は echo x | ink green と両立しにくいので見送り、pipe かファイルのときだけ読む案を選んだ

直し方は 2 つ考えた。

  • 案 A: 引数があれば stdin を読まない
  • 案 B: stdin が pipe か通常のファイルのときだけ読む

案 A は、echo x | ink green のように「色を引数で、文字を pipe で」渡す呼び方と両立させにくいので見送った。

案 B は、呼び方を変えずに socket と /dev/null だけを読まなくできる。

if [[ -p /dev/stdin || -f /dev/stdin ]]; then
  text=$(cat)
fi

同じヘルパがコピーされていた 3 か所を直すと、5 分止まっていたスクリプトは 0.09 秒で終わった。pipe、引数、here-string、ファイルの 4 通りで、出力が直す前と変わらないことも確かめた。呼び出し側で </dev/null を付けても止まらないが、それだと呼ぶ箇所すべてに要る。

シェルのリダイレクトや標準入力の扱いを基礎から整理しておきたいなら、bash を前提にシェルスクリプトの書き方を一通り解説している本が手元にあると便利だ。

新しいシェルプログラミングの教科書 (三宅英明、電子書籍) 画像: 楽天市場

新しいシェルプログラミングの教科書 (三宅英明、電子書籍)

入力の種類ごとの判定結果

-t 0 は端末以外をすべて同じに扱う。-p か -f で判定すれば socket と /dev/null は読まない

bash 5.3 で、入力の種類ごとに 3 つの判定を比べた結果 (y が真)。

stdin-t 0-p /dev/stdin-f /dev/stdin
pipenyn
< filenny
heredoc (小さい)nyn
heredoc (200KB)nny
here-stringnyn
/dev/nullnnn
socketnnn

/dev/null は -c (キャラクタデバイス)、socket は -S で真になる。各判定の意味は Bash Reference Manual: Bash Conditional Expressions にある。

heredoc は大きさで pipe とファイルが切り替わる

bash 5.1 以降の heredoc は、pipe バッファより小さければ pipe、超えると一時ファイルで渡される

表の heredoc の行が 2 つに分かれているのは、大きさで渡し方が変わるからだ。bash 付属の NEWS の bash-5.1 の項に、heredoc と here-string は pipe バッファより小さければ pipe を使い、大きければ一時ファイルに戻る、と書かれている。5.0 までは常に一時ファイルだった。

probe() { [[ -p /dev/stdin ]] && echo pipe; [[ -f /dev/stdin ]] && echo file; }
probe <<EOF
small
EOF
# => pipe
big=$(head -c 200000 /dev/zero | tr '\0' a)
probe <<EOF
$big
EOF
# => file

小さな入力で試すと -p だけで足りるように見える。そのまま -f を外すと、< file と大きな heredoc を渡した日に読まれなくなる。pipe と一時ファイルの両方を拾うには -p || -f と書く。

原因3:pkill -f が自分のシェルを殺す

bash -c の文字列に pkill のパターンが含まれるので、pkill -f は呼び出し元のシェルも殺し、終了コードは 143 になる

後片付けで pkill -f を打たせると、後続のコマンドが走らなくなることがある。

bash -c 'sleep 301 & pkill -f "sleep 301"; echo reached'
# reached は出ず、終了コードは 143 (128 + SIGTERM)

-f はプロセス名ではなく、コマンドライン全体とパターンを照らし合わせる (pgrep(1))。除外されるのは pkill 自身だけだ。bash -c '...' のシェルは引数に sleep 301 という文字列を持っているので、このシェルも一致して殺される。[s]leep のような書き方でも防げない。呼び出し元の bash -c の文字列そのものに sleep 301 が入っているからだ。

エージェントのシェル実行は、まさにこの「コマンド全文を持ったシェル」で動く。直し方は、起動したときの PID で止めることだ。

bash -c 'sleep 301 & pid=$!; kill "$pid"; echo reached'
# reached

名前で止めたいなら、pkill -x <名前> でプロセス名の完全一致にする。事前に pgrep -af <パターン> で一致するものを並べると、実行用のシェル自体が一覧に出てくるのが見える。

原因4:エージェントのシェルが zsh だと、bash の書き方が変わる

zsh では path への代入、引用符なしの変数、一致しないグロブ、${R^^} の 4 つが bash と違う結果になる

エージェントは、学習した知識どおりに bash の書き方でワンライナーを打ちがちだ。元記事によると、ログインシェルが zsh の環境では、Claude Code は zsh -c '...' のように 1 コマンドずつ zsh で実行する。zsh 5.9.2 を zsh -f (設定ファイルを読まない) で試すと、bash の書き方のまま結果が変わる箇所が 4 つあった。

書き方bashzsh
path=/tmp/xただの変数PATH と連動する配列なので PATH が壊れ、以後の ls が見つからない (127)
v="a b c"; for x in $v3 回回る単語分割しないので 1 回だけ回る
何にも一致しないグロブそのままの文字列が渡るno matches found でコマンドが実行されない
${R^^} (大文字化)動くbad substitution。zsh では ${(U)R}

とくに変数名 path は気付きにくく、以後のコマンドがすべて not found になる。それぞれの仕様は zsh のマニュアルの Parameters、Options (SH_WORD_SPLIT と NOMATCH)、Expansion にある。

込み入った処理は bash script.sh のように bash に渡すと、この差を気にせずに済む。スクリプトの先頭の #!/usr/bin/env bash は、ファイルとして実行したときにしか効かない。

根本の対処:人間向けの設定を opt-in にする

is_human で人間と判定したときだけ人間向けの設定を読む。スナップショットは 14,284 行から 339 行、起動は 0.5 秒から 0.01 秒になった

原因 1 の設定を 1 つずつ外していくと、外し忘れたときに困るのは AI の方だ。元記事はこれを逆にして、シェルが起動したら AI だと考え、人間向けの設定は人間と判定できたときだけ読むようにしている。設定に問題があっても困るのは人間で、人間ならすぐ気付いて直せる、という理由だ。

判定は .zshenv に書く。stdin と stdout が端末で、エージェントが立てる環境変数が無いときだけ人間とみなす。

is_human() {
    [[ -t 0 && -t 1 ]] || return 1
    [[ -z $CLAUDECODE$CODEX_SANDBOX$GEMINI_CLI$CURSOR_AGENT$AI_AGENT ]]
}

元記事の筆者は最初 $TERM で判定しようとして、TERM は端末からそのまま引き継がれるので使えなかったそうだ。そして .zshrc の冒頭で、人間でなければ抜ける。

is_human || return 0
# エイリアス、プラグイン、プロンプト、キーバインド、setopt はこれより下

Claude Code がスナップショットを作るときに .zshrc を読んでも、ここで抜けるので、人間向けの設定はスナップショットに入らない。AI のシェルでは cp は cp、cd は組み込みの cd のまま動く。

何が減るか

元記事の環境では、この形にしてスナップショットが 14,284 行から 339 行に減り、AI 用のシェルの起動は 0.5 秒から 0.01 秒になった。人間用のプラグインの関数 1,445 個が、AI のシェルに丸ごと載っていたことになる。起動はエージェントがコマンドを打つたびに走るので、1 回あたりの差は小さくても回数分だけ効く。

ほかにも、元記事では次のように AI 側を整えている。

  • AI がよく使う rg、fd、jq、yq、shellcheck などを先に入れておく。command not found から代わりの手段を探す寄り道が無くなる
  • rm はゴミ箱に送る gomi に置き換え、AI のシェルにも効かせる。rm -rf の形をそのまま受け付けるので AI は気付かず、誤って消しても戻せる
  • やらせたくないことは Claude Code の permissions.deny で止める。例えば Bash(pip:*) を拒否すると、AI は断られたことに気付いて uv を使うようになる (Claude Code settings)

まとめ:エージェントのシェルには、答える人がいない

  • エージェントのシェルは stdin が端末ではない。Claude Code の Bash ツールでは閉じない socket だった
  • 入力を待つ設定はエージェントを止める。EDITOR=true などで、すぐ失敗が返るようにする
  • 「端末でなければ stdin を読む」ヘルパは止まる。[[ -p /dev/stdin || -f /dev/stdin ]] で pipe とファイルのときだけ読む
  • pkill -f は自分を起動したシェルにも一致する。$! の PID で kill する
  • ログインシェルが zsh なら、bash の書き方が 4 か所で変わる。込み入った処理は bash に渡す
  • 人間向けの設定は、人間と判定できたときだけ読む

手元の端末で試して通っても、エージェントから叩いたときの確認にはならない。止まったら、まず ls -l /proc/self/fd/0 で stdin が何につながっているかを見ると、どの経路に入ったかの見当がつく。

参考