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

本記事はプロモーションを含みます。
自分の端末で打てば一瞬で終わるコマンドが、Claude Code のような AI エージェントに打たせると返ってこない。あるいは、途中のコマンドがエラーも出さずに走らない。筆者は自分の dotfiles のセットアップスクリプトを Claude Code に dry-run させたとき、5 分止まったまま返ってこなかった。
結論から書くと、原因は次の 4 つに分けられる。どれも「エージェントのシェルには、キーボードの前に人がいない」ことから来ている。
| 原因 | 何が起きるか | 対処 |
|---|---|---|
| 人間向けの設定が入力を待つ | cd や cp、git commit がプロンプトやエディタで止まる | エディタとページャをすぐ返るものにし、人間向けの設定は opt-in にする |
| 自作ヘルパが stdin を読む | 端末でなければ cat で読む作りが、閉じない stdin で止まる | pipe とファイルのときだけ読む |
pkill -f | コマンド全文を持つ自分のシェルまで殺す | $! で控えた PID で kill する |
| シェルが zsh | bash のつもりの書き方で PATH が壊れる、ループが 1 回で終わる | 込み入った処理は bash に渡す |
dotfiles を AI agent のために作り変えた (tellme.tokyo、2026-10-01) の要点は次のとおり。
cd の入力待ちや git commit のエディタ起動が AI を止めていた後半では、この「シェルが起動したら AI だと思え」という設定の組み方を扱う。
cron などの無人実行で踏む罠は 自動実行のbashが突然落ちる・失敗を見逃す4つの罠 にまとめている。この記事はそのエージェント版だ。
端末から叩いたコマンドは、stdin も stdout も端末 (tty) につながっている。エディタが開けば人が書いて閉じ、確認のプロンプトには人が y を打つ。
エージェントのシェルは違う。筆者の環境で Claude Code の Bash ツールから stdin を確かめると、/proc/self/fd/0 は端末ではなく socket:[...] を指していた。しかも、この socket は相手側から閉じられない。
端末から試すと stdin は tty なので、どちらの経路にも入らない。手元で再現しないのはこのためだ。
元記事の筆者は、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 のコマンドに効く。
元記事では、人間でないと判定したシェルで次の値を入れている。エディタやページャが起動しても、何も待たずに返る。
# 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 を返す。止まり続けるよりは、失敗が返ってくる方がエージェントは次の手を打てる。
筆者のスクリプトが 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 つ考えた。
案 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 を前提にシェルスクリプトの書き方を一通り解説している本が手元にあると便利だ。
新しいシェルプログラミングの教科書 (三宅英明、電子書籍)
bash 5.3 で、入力の種類ごとに 3 つの判定を比べた結果 (y が真)。
| stdin | -t 0 | -p /dev/stdin | -f /dev/stdin |
|---|---|---|---|
| pipe | n | y | n |
< file | n | n | y |
| heredoc (小さい) | n | y | n |
| heredoc (200KB) | n | n | y |
| here-string | n | y | n |
/dev/null | n | n | n |
| socket | n | n | n |
/dev/null は -c (キャラクタデバイス)、socket は -S で真になる。各判定の意味は Bash Reference Manual: Bash Conditional Expressions にある。
表の 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 と書く。
後片付けで 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 <パターン> で一致するものを並べると、実行用のシェル自体が一覧に出てくるのが見える。
エージェントは、学習した知識どおりに bash の書き方でワンライナーを打ちがちだ。元記事によると、ログインシェルが zsh の環境では、Claude Code は zsh -c '...' のように 1 コマンドずつ zsh で実行する。zsh 5.9.2 を zsh -f (設定ファイルを読まない) で試すと、bash の書き方のまま結果が変わる箇所が 4 つあった。
| 書き方 | bash | zsh |
|---|---|---|
path=/tmp/x | ただの変数 | PATH と連動する配列なので PATH が壊れ、以後の ls が見つからない (127) |
v="a b c"; for x in $v | 3 回回る | 単語分割しないので 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 は、ファイルとして実行したときにしか効かない。
原因 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 側を整えている。
rg、fd、jq、yq、shellcheck などを先に入れておく。command not found から代わりの手段を探す寄り道が無くなるrm はゴミ箱に送る gomi に置き換え、AI のシェルにも効かせる。rm -rf の形をそのまま受け付けるので AI は気付かず、誤って消しても戻せるpermissions.deny で止める。例えば Bash(pip:*) を拒否すると、AI は断られたことに気付いて uv を使うようになる (Claude Code settings)EDITOR=true などで、すぐ失敗が返るようにする[[ -p /dev/stdin || -f /dev/stdin ]] で pipe とファイルのときだけ読むpkill -f は自分を起動したシェルにも一致する。$! の PID で kill する手元の端末で試して通っても、エージェントから叩いたときの確認にはならない。止まったら、まず ls -l /proc/self/fd/0 で stdin が何につながっているかを見ると、どの経路に入ったかの見当がつく。