Max Nardit
← 記事
15 mindev

tmux で動かす Claude Code: send-keys には権限プロンプトが見えず、claude agents --json には見える

tmux のペインは Claude Code エージェントを置いておく場所としては優秀ですが、エージェントを操作するインターフェースとしては不向きです。私のテストでは、権限プロンプトを表示中のペインに打ち込んだタスクは消え、続けて送った Enter が保留中のコマンドを承認しました。ステータスも、待機の理由も、終了イベントも、今は Claude Code 自身が報告してくれます。画面は、人間が見るためのものに戻せます。

目次

複数の Claude Code エージェントを動かす定番のやり方は、エージェントごとに tmux セッションを 1 つ用意し、スクリプトから話しかける方法です。タスクを打ち込むには tmux send-keys、終わったかどうかを読むには tmux capture-pane を使います。今年の大半、私はこの方法でエージェントを動かしてきました。その裏にある送信ヘルパーは何度もコミットで修正を重ねたので、git log がそのまま「ターミナルがスクリプトに嘘をつく方法」の一覧のようになっています。

同じ数か月のあいだに、Claude Code はそのヘルパーが推測していた情報を自分で報告するようになりました。claude agents --json は動いているすべてのセッションをステータス付きで一覧し、待機中なら何を待っているかも示します。セッション同士は、互いに文字を打ち込まなくてもメッセージをやり取りできます。ターンが終わったときや Claude が権限を求めたときにはフックが発火します。この記事は、判断を画面から引き離すための手順書です。画面に判断させると何が起きるかを示す実験も載せています。以下のテストは Linux 上の Claude Code 2.1.306 と tmux 3.4 で実行しました。ドキュメントだけを根拠にしている箇所は、そう明記しています。

tmux の send-keys で Claude Code を操作するには?

テキストはリテラルとして送り、Enter は別の呼び出しで押し、ペインは厳密なターゲットで指定します。3 つとも、日付の付いたコミットから生まれたルールです。

shell
# exact session match, literal text, Enter as its own keystroke
tmux send-keys -t '=api-worker:' -l 'Run the test suite and fix the first failure.'
sleep 0.3
tmux send-keys -t '=api-worker:' Enter

= は見た目以上に重要です。tmux は -t のターゲットを、まず完全一致、次に前方一致、次にパターンで解決します。そのため api という名前のセッションが存在しないと、-t api は黙って api-worker に当たります。7 月にはこの前方一致がすでに私の別のツールで事故を起こしていて、ヘルパーにも同じ修正を入れました。すべてのターゲットを =name: の形で固定し、末尾のコロンで capture-pane や send-keys のようなペイン単位のコマンドでも使えるようにしています。2 つ目のルールは 2 月に加えたものです。テキストと Enter を素早く続けて送ると、Claude Code はそれを送信ではなく入力欄内の改行として読みました。そこでテキストは -l で送り、Enter は少し間を置いてから送ります。2.1.306 のアイドル状態の入力欄では、この現象を再現できませんでした。短い 1 行も 1,471 文字のブロックも、テキストの直後に Enter を送るだけで送信されました。それでもヘルパーのメモには、Claude が busy またはコンパクション中のときに届いた Enter が改行になった事例が今月の記録として残っています。また、10 月にこの現象のせいだと考えた 2 件は、実際には入力欄に薄く表示された Claude の次のプロンプト候補が原因でした。そのため分割は残しています。コストは 0.3 秒です。

3 つ目のルールはもっと分かりにくいものです。=api-worker: は「そのセッションで今アクティブなペイン」を意味します。途中で誰かがペインを切り替えると、キャプチャは 1 つのペインを読み、Enter は別のペインに届くことがあります。ペイン ID を一度だけ解決し、1 回の操作のすべての呼び出しでそれを使ってください。

shell
PANE=$(tmux display-message -p -t '=api-worker:' '#{pane_id}') || exit 1
[[ $PANE == %[0-9]* ]] || exit 1   # an empty target means "the current pane" to tmux
tmux capture-pane -p -t "$PANE" | tail -5
tmux send-keys -t "$PANE" -l 'Next task.'

tmux の send-keys は Claude Code の権限プロンプトに何をするのか?

プロンプトに答えてしまいます。デタッチしたペインで Claude Code を手動の権限モードで起動し、ディレクトリ一覧をファイルに書き出すよう頼み、Bash の権限ダイアログが表示されるまで待ちました。そのうえで、素朴なオーケストレーターと同じやり方で次のタスクを送りました。テキスト、間、Enter です。

打ち込んだテキストはどこにも表示されませんでした。ダイアログのカーソルはデフォルトで 1. Yes にあるので、Enter が保留中のコマンドを承認し、listing.txt が書き出され、セッションは idle に戻りました。私が「送った」タスク(README の要約)は、送信側にも Claude 側にもエラーを出さずに消えただけでした。セッションが busy になってから idle に戻るのを見て成功を判定するスクリプトなら、タスクは届いたと記録したはずです。

準備のできたペインを busy と読み違えても、失うのは時間だけです。busy やブロック中のペインを準備完了と読み違えると、誰も出していない承認や、ダイアログに打ち込まれたタスクという代償を払います。フォルダの信頼ダイアログはさらに厄介です。デフォルトの選択肢が No, exit なので、迷い込んだ Enter で Claude が終了します。ペインが入力欄で待っていることを確認するまでは、Claude Code のペインで Enter を押さないでください。

capture-pane で Claude Code の画面を読む方法が壊れ続けるのはなぜか?

画面は人間向けの Claude Code のインターフェースであり、人間向けのデザインが変わるたびに変わるからです。私のペイン分類器はキャプチャした画面を ready、busy、dialog、素のシェル、unknown に振り分けます。そのヘッダーは日付付きの破損の一覧です。8 月のオンボーディングプロンプト、9 月下旬のプレースホルダーのヒント、10 月に「esc to interrupt」の文字が消えたスピナー、選択肢の番号がなくなった信頼ダイアログ、2.1.302 で入力欄の上枠を埋めた長いセッションラベル。10 月の修正の 1 つは、スマートフォン幅の 34 列のペイン向けでした。入力欄が大きく折り返したせいで、ヘルパーが一度も Enter を押さなかったのです。

どれも昨日の画面に対しては正しいパーサーでした。今の分類器は、曖昧な場合はすべて「ready ではない」側に倒します。呼び出し側は ready に対して破壊的な操作をするからです。ペインに打ち込む、セッションを再起動する、そのリポジトリをコミットする、といった操作です。遅延の側に倒すのは正しい判断ですが、私はそれを、画面では「準備できているか」という問いに答えられないという告白だと受け取っています。プログラムが自分自身について報告するステータスなら答えられます。

claude agents —json は tmux 上の Claude Code セッションについて何を報告するのか?

Agent view のドキュメントによると、自分で tmux 上に起動した interactive なセッションも含め、動いているすべてのセッションを出力します。status は busy、waiting、idle のいずれかで、待機中なら waitingFor に理由が入ります。ペイン分類器がキャプチャしたテキストから再構成しようとしていたのは、まさにこのシグナルです。テスト用のセッションは Claude のセッション名を付けて起動しました。JSON と @ メンションで使われるのはこの名前です。tmux のセッション名は tmux の中でしか使われない別物ですが、ここでは両者をそろえました。

shell
tmux new-session -d -s api-worker -c ~/src/demo-repo 'claude -n api-worker'

権限ダイアログが開いていたときの行を、一部省略し、パスを変えて示します。

json
[
  {
    "pid": 3922645,
    "cwd": "/home/me/src/demo-repo",
    "kind": "interactive",
    "startedAt": 1792553808437,
    "sessionId": "d3b95339-dde6-4641-8a03-211db4633a12",
    "name": "api-worker",
    "status": "waiting",
    "waitingFor": "permission prompt"
  }
]

1 秒ごとにポーリングすると、プロンプトから 2 秒以内に permission prompt 付きの waiting になり、承認後は busy を経て 2 秒以内に idle になりました。ドキュメントには、ほかの待機理由として input needed、sandbox request、worker request、dialog open が挙がっています。オーケストレーター用のゲートは数行で書けます。ただし、判断がつかないときは閉じる側(fail closed)に倒す必要があります。

shell
claude agents --json | python3 -c '
import json, sys
rows = [s for s in json.load(sys.stdin) if s.get("name") == "api-worker"]
print(rows[0].get("status", "unknown") if len(rows) == 1 else "absent" if not rows else "ambiguous")'

送るのは idle のときだけです。absent は idle ではありません。waiting なら、waitingFor の理由を send-keys ではなく人間に回してください。そして idle はロックではなく観測結果として扱ってください。チェックから Enter までのあいだに、メッセージ、スケジュールされたタスク、あるいは人間がターンを開始することがあります。キーを押す直前にもう一度確認してください。この隙間は完全には閉じません。そしてそれこそが、後で紹介するネイティブな経路を使うべき一番の理由だと私は考えています。

見落としやすい点が 3 つあります。同じページには、ほかのターミナルの interactive なセッションは「バックグラウンドにするまで表示されない」とありますが、この一文は JSON ではなく Agent view の画面についてのものです。2.1.306 では、JSON に tmux 上の interactive なセッションがそれぞれ kind: "interactive" 付きで表示されました。フォルダの信頼ダイアログで止まっているセッションはまったく表示されないので、「行がない」は「消えた」ではなく「起動中でブロックされている」の可能性があります。そして busy は存在のシグナルであって、進捗のシグナルではありません。止まったストリームで固まったセッションも busy のままです。これは watchdog の記事で書いた障害です。私の環境では、アイドルなセッションを終了させるリーパーが 5 月から claude agents --json で Claude のプロセスを探しています。それでも、タスクセッションを終了させるのは、数秒間隔の 2 回のキャプチャですべてのペインが ready か素のシェルと判定され、30 秒間何も出力されていないときだけです。

send-keys を使わずに別の Claude Code セッションへ仕事を渡すには?

セッション同士にメッセージをやり取りさせます。セッション間メッセージは macOS と Linux で v2.1.224 からデフォルトで有効です(@ メンションには v2.1.232、アイドル通知には両方のセッションで v2.1.236 が必要です)。これを使うと、ある Claude が ListAgents でローカルのセッションを一覧し、マシンの外に出ないセッションごとのソケット経由で SendMessage によってテキストを届けられます。依頼は普通の言葉で書くか、@ メンションで宛先を指定します。

Let @api-worker know the schema migration finished and it can rebase on main.

これが使えるのは、オーケストレーター自体が Claude のセッションである場合だけです。SendMessage は Claude が呼び出すツールであって、スクリプトから実行できるコマンドではありません。オーケストレーターがスクリプトなら、ネイティブな経路は新しい claude --bg セッションです。既存の interactive なセッションに対しては、上の JSON ステータスでゲートしたうえで、今も send-keys を使います。Claude 同士の場合、メッセージの届き方に send-keys にはなかった保証があります。busy な受信側はツール呼び出しの合間にメッセージを読むので、実行中のツールは中断されません。idle な受信側はそのメッセージで新しいターンを始めます。受信側の Claude には、メッセージがあなたからではなく別のセッションから来たことが伝えられます。そのため保留中の権限プロンプトには答えられません。また、別のセッションに頼まれたからといって権限、CLAUDE.md、その他の設定を変えてはいけないと指示されています。設定に関する部分はモデルへの指示にすぎず、転送の仕組みがそれを強制しているわけではありません。また、送信側はポーリングする代わりに、SendMessage の notify_when_idle 入力で、宛先が次に idle になったときに一度だけ通知を受け取れます。

権限プロンプトをオフにしてエージェントを動かしている人は、あるデフォルトに引っかかります。crossSessionInbound を設定していない場合、bypassPermissions モードの受信側は、権限プロンプトを出すモードのセッションからのメッセージをすべて承認ダイアログの後ろで保留します。このダイアログは dialogExpiry(デフォルトで 5 分)が過ぎると期限切れになり、メッセージは破棄されます。アタッチされていないバックグラウンドセッションでは、誰かがアタッチするまでダイアログが開いたままになります。逆も同じで、権限プロンプトを出すモードの受信側は、プロンプトをバイパスする送信側からのメッセージを保留します。ヘッドレスの claude -p ワーカーはそのダイアログをそもそも表示できません。無人でメッセージを受け取らせたい場合は、--settings の値で crossSessionInbound を accept にして起動するようドキュメントに書かれています。--bare で起動したセッションはソケットをバインドせず、一覧にも表示されません。

エージェントが終わったことや人間を必要としていることを知らせる Claude Code のフックは?

Stop は Claude がターンを終えたときに発火し、ターンが API エラーで終わった場合は代わりに StopFailure が発火します。Notification は permission_prompt や idle_prompt などの notification_type 付きで発火します。これらを組み合わせると、見張るべき画面の代わりにイベントログが手に入ります。次のスクリプトを ~/.claude/hooks/agent-event.py として保存し、chmod +x してください。イベントごとに 1 行を追記します。

python
#!/usr/bin/env python3
# Append one line per Claude Code lifecycle event to a log your orchestrator reads.
import json, os, sys, time
 
event = json.load(sys.stdin)
record = {
    "at": time.time(),
    "event": event.get("hook_event_name"),
    "session": event.get("session_id"),
    "cwd": event.get("cwd"),
    "type": event.get("notification_type") or event.get("error"),
    "last": (event.get("last_assistant_message") or "")[:200],
}
path = os.path.expanduser("~/.claude/agent-events.jsonl")
with open(path, "a") as f:
    f.write(json.dumps(record) + "\n")

~/.claude/settings.json で 3 つのイベントすべてにつなぐか、1 つのセッションだけなら同じ JSON を --settings で渡します。

json
{
  "hooks": {
    "Stop": [{ "hooks": [{ "type": "command", "command": "~/.claude/hooks/agent-event.py" }] }],
    "StopFailure": [{ "hooks": [{ "type": "command", "command": "~/.claude/hooks/agent-event.py" }] }],
    "Notification": [
      {
        "matcher": "permission_prompt|idle_prompt",
        "hooks": [{ "type": "command", "command": "~/.claude/hooks/agent-event.py" }]
      }
    ]
  }
}

私のテストでは、claude -p の実行が最終応答のテキストを含む Stop の行を書き、tmux 上の interactive なセッションはダイアログが開いているあいだに permission_prompt 型の Notification の行を書きました。最初のバージョンでは入力を jq にパイプしていましたが、このマシンには jq が入っていませんでした。そのフックは終了コード 127 で終わり、-p の実行ではエラーが --debug-file のログにしか出ていませんでした。ログを信用する前に、サンプルのイベントを標準入力に渡してスクリプトを手で実行してください。

ここではタイミングが重要です。フックのリファレンスによると、permission_prompt はプロンプトが入力のないまま約 6 秒待ったあとにだけ発火し、キーを押すたびに先送りされます。Claude が尋ねた瞬間にシグナルがほしいなら、別に PermissionRequest イベントがあり、許可や拒否もできます(それでも deny ルールが優先されます)。私の環境では permission_prompt の通知が、待っていた 10 秒以内に届いたので、ドキュメントとテストは一致しています。

素の Stop も成功の証明にはなりません。これは応答の終わりを示すもので、仕事の終わりを示すものではありません。入力には background_tasks と session_crons が含まれるので、フックは「完了」と「バックグラウンドの作業が起こすまで一時停止」を区別できますが、作業そのものは別に確認する必要があります。API エラーでは発火しないので、設定に StopFailure を入れています。ユーザーによる中断ではどちらも発火しません。最終テキストはトランスクリプトファイルではなく last_assistant_message から読んでください。ドキュメントによると、トランスクリプトは非同期で書き込まれ、遅れることがあります。そして idle_prompt は Claude が終わってから約 60 秒後に、その後誰も入力しておらず、バックグラウンドのエージェントも動いていない場合にだけ届きます。つまり完了イベントではなく、「これを忘れていませんか」という催促です。スマートフォンに届けたい通知は permission_prompt で、残りはログに入れるべきものです。これはキー入力ではなく判断のときに起こしてほしいで主張した分け方です。

Claude Code エージェントを tmux ではなく —bg で動かすべきなのはいつか?

打ち込む様子を誰も見る必要がないときです。claude --bg は、Claude Code が必要に応じて起動するスーパーバイザーの下でセッションを始め、短い ID を返してすぐに戻ります。

$ claude --bg --name docs-check "Reply with the single word: done"
backgrounded · 4313e449 · docs-check
  claude agents             list sessions
  claude attach 4313e449    open in this terminal
  claude logs 4313e449      show recent output
  claude stop 4313e449      stop this session

claude agents --json のバックグラウンドの行には、id と state(working、blocked、done、failed、stopped)が加わります。state と status は別の問いに答えます。state はジョブについて、status と pid はプロセスについてで、後者はプロセスが生きているあいだだけ表示されます。私のセッションは数秒で working から done になりました。終わったバックグラウンドセッションはデフォルトの一覧から消えるので、完了を見張るには claude agents --json --all を使います。claude logs はカーソル制御コードやスピナーのフレームも含めて生のターミナルストリームを出力するので、人間が目で見るためのものです。スクリプトは JSON を読むべきです。claude stop と claude rm のあと、--bg の呼び出しで起動したスーパーバイザーは自分で終了しました。

トレードオフはドキュメントに書かれているもので、ここでは検証していません。終わった、またはアイドルのバックグラウンドセッションが約 1 時間アタッチされないまま放置されると、スーパーバイザーがそのプロセスを止めます(会話はディスクに残ります。動かし続けたいなら Ctrl+T でピン留めします)。--bg で起動したセッションは、ファイルを編集する前に .claude/worktrees/ の下の専用の git worktree に移ります。ただし、すでに worktree の中にいる場合や、ディレクトリが git リポジトリでない場合は移りません。← や /background でバックグラウンドにしたセッションは、元の場所で編集を続けます。worktree.bgIsolation を "none" にすると、この移動はオフになります。Agent view はリサーチプレビュー段階です。そして --bg と -p の組み合わせは拒否されるので、スクリプトからの単発実行は claude -p のままです。

tmux をホストとして使い続けたい場合は、Claude Code がセットアップしてくれます。この部分はドキュメントと claude --help から得た情報で、検証はしていません。claude -w feature-x --tmux は git worktree とそのための tmux セッションを作ります(--tmux には --worktree が必要で、--tmux=classic を渡さない限り、可能なら iTerm2 のペインを使います)。エージェントチームでは、--teammate-mode tmux で各チームメイトを専用のペインに置けます。チームには CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 が必要で、--teammate-mode は claude --help には出てきません。また、分割ペインは VS Code のターミナル、Windows Terminal、Ghostty ではサポートされていません。並列 worktree のパターン自体は、Codex CLI が管理する Git worktree で Codex を題材に比較したものと同じです。

Claude Code にとって tmux が今も役立つのは何か?

ホスティングと、人間の目です。どのターミナルからでもアタッチでき、SSH が切れても生き残り、誰かの肩越しに読めるペインには今も価値があります。tmux で起動したセッションについては、claude attach はその代わりになりません。変わったのは、ペインが制御チャネルである必要がなくなったことです。判断は claude agents --json から行い、配送は SendMessage か新しい claude --bg で行い、終了はフックで知り、send-keys はどうせ人間が手で打ち込む場面だけに残しておきます。

私自身の環境はまだ道半ばです。リーパーは JSON でセッションを見つけますが、判定は今も画面で行っています。送信の経路は最初から最後まで画面を読んでいて、バグが出続けているのもその画面です。

01 / ディスカッション

コメント欄はありません。議論は X で行っています。

Max Nardit@mnardit

02 / ほかの記事

  1. Claude Code の /auto-mode-setup: auto mode の分類器に環境を教え、実際にどのモデルが判定しているかを確かめる

    このセットアップは、リポジトリとエージェントの最近のコマンドから信頼リストの下書きを作ります。そしてその下書きは、同梱の environment テキストに追記されず、それを置き換えます。デフォルトとの差分として読んでください。モデル選びに時間をかける前に、アクションを判定しているのがどのモデルかも確認しましょう。

  2. LLM ゲートウェイ経由の Claude Code:誰が支払うかはクレデンシャルで決まり、クレデンシャルがなければプロキシに claude.ai のログイン情報が渡る

    サブスクリプションでログインしていればリクエストごとに 12 個、ゲートウェイトークンなら 10 個の beta 値が付きます。消える 2 つの値は、ドキュメントの表よりも多くを語ります。ゲートウェイがそのまま通すべきもの、気づかないうちに高くつくデフォルト、そして allowedProviders で固定すると開発者が自分のデバッグ用リスナーまで使えなくなる理由を扱います。

  3. claude -p での Claude Code フォークサブエージェント:2.1.302 がヘッドレス実行で変えたこと

    提供されることと使われることは別です。ヘッドレス実行のモデルはフォークを一度も選ばないかもしれず、公式ドキュメントはまだ古いデフォルトを説明しています。パイプラインがどのサブエージェントを使ったかの確認方法、バックグラウンドのフォークがパーサーに2つの回答を渡す理由、そして無効にする価値がある場面を解説します。