aiterm-mcp 0.39.1 → 0.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.40.1] - 2026-09-27
11
+
12
+ ### 修正
13
+
14
+ - Cursorの送信前hookがpromptを拒否した時に、`pty_send`が成功receiptを返していた。Cursorはpromptを捨てて入力欄を空に戻すため、入力欄の残留検査では見分けられず、親は来ない完了を待ち続けた。起動時promptは理由の無い`submitted_unconfirmed`になっていた。拒否の表示(`Hook blocked with message:`)を見分け、起動時promptは`initial_prompt=failed`、`pty_send`はエラーとし、どちらも`USER_HOOK_BLOCKED`とhookの出力を返す。確認時間より後の拒否は、完了待ちが`outcome=error`で返す。`pty_observe`は`blocked`/`user_hook_blocked`を返す。
15
+ - Cursorの送信前hookが3秒の確認時間より長く動くと、拒否されても起動時promptは理由の無い`submitted_unconfirmed`で返っていた。Windowsではhookごとの起動が遅く、数本続くと3秒を超える。hookの実行中の画面(「Working」だけで`ctrl+c to stop`が無い)が続く間は、最長60秒まで結果を待つ。
16
+
17
+ ## [0.40.0] - 2026-09-27
18
+
19
+ ### 変更(互換性なし)
20
+
21
+ - `agent_steer`を廃止し、agent sessionへの送信を`pty_send`に一本化する。子のturnが実行中かどうかは呼び出し側に選ばせず、Aitermが送る時点の画面で見て、実行中なら現在のturnへ差し込み(`mode=agent_steer`)、それ以外は新しいturnとしてdispatchする(`mode=agent_dispatch`)。これまでは状態を知らない呼び出し側が入口を選ぶ必要があり、idleの子への`agent_steer`は何も送らず`idle`を返し、実行中の子への`pty_send`はCodex/Grok/Cursorで入力受付を30秒待って失敗、Claude Codeでは来ない完了を待つ新しいturnとして記録していた。差し込みでは新しい`event_cursor`と回答配送を作らず、完了は元の依頼へ1回だけ届く。公開toolは17になる。
22
+ - Claude Codeは、Aitermが送ったturnの印(Stop hookで消える)が残っている時だけ実行中と数える。Stop hookの実行中は画面が実行中の表示のままなので、回答直後に送った文を差し込みと取り違え、誰も完了を待たない新しいturnにしていた。
23
+
10
24
  ## [0.39.1] - 2026-09-26
11
25
 
12
26
  ### 修正
@@ -1778,7 +1792,9 @@ prototype (preserved under `prototype/python/` as the porting source and referen
1778
1792
  `ubuntu-latest` for Node 18/20/22, publishing to npm on `v*` tags with
1779
1793
  provenance.
1780
1794
 
1781
- [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.39.1...HEAD
1795
+ [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.40.1...HEAD
1796
+ [0.40.1]: https://github.com/kitepon/aiterm-mcp/compare/v0.40.0...v0.40.1
1797
+ [0.40.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.39.1...v0.40.0
1782
1798
  [0.39.1]: https://github.com/kitepon/aiterm-mcp/compare/v0.39.0...v0.39.1
1783
1799
  [0.39.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.38.2...v0.39.0
1784
1800
  [0.38.2]: https://github.com/kitepon/aiterm-mcp/compare/v0.38.1...v0.38.2
package/README.ja.md CHANGED
@@ -143,7 +143,7 @@ diagnostics、recovery、update、releaseを所有します。このREADMEと[
143
143
 
144
144
  **言葉でなく実測で:** 記録済み203テストのベンチマークでは、`pty_read` はコンテキストに載るトークンを生ログの **約 7.1 分の 1** に減らす。しかも pass/fail の判定は畳んでも残る。→ [組み込みシェルツールとの使い分け](#組み込みシェルツールとの使い分け)
145
145
 
146
- 18ツール: 7つのPTYツール、正規のagent起動入口`agent_launch`、実行中のClaude/Codex/Grok/Cursorを誘導する`agent_steer`、移行用の旧4alias、`agent_configure`、`agent_approval`、`claude_turn`、`claude_approval`、`diagnostics`。backendはPOSIXのtmux/Windows nativeのpsmuxなので、MCPサーバやAIクライアントが再起動してもsessionは生き残る。
146
+ 17ツール: 7つのPTYツール、正規のagent起動入口`agent_launch`、移行用の旧4alias、`agent_configure`、`agent_approval`、`claude_turn`、`claude_approval`、`diagnostics`。backendはPOSIXのtmux/Windows nativeのpsmuxなので、MCPサーバやAIクライアントが再起動してもsessionは生き残る。
147
147
 
148
148
  **v0.28.0では実行基盤harnessとmodelを分離した。** harnessはagent loop・認証・hook・session・transcriptを所有し、modelはその上で選ぶ。Cursor Agent CLIでGPT/Claude/Grokを選んでも完了契約はCursor方式のまま。Composerは別harnessではなく、`harness:"grok-cli", model:"grok-composer-2.5-fast"`で表す。旧4起動ツールは同じ実装へ流れる互換alias。
149
149
 
@@ -202,7 +202,7 @@ runtime-error store は canonical dotagents config の `collection.enabled: true
202
202
  場合だけ収集し、既定OFF、network送信は行いません。tag起点CIのnpm provenance(OIDC Trusted
203
203
  Publishing)で公開し、GitHub Release が Official MCP Registry を再登録します。
204
204
 
205
- **状態:** 開発継続中 · 現行公開版 **v0.39.1** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
205
+ **状態:** 開発継続中 · 現行公開版 **v0.40.1** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
206
206
 
207
207
  ### 更新と巻き戻し
208
208
 
@@ -248,9 +248,9 @@ pty_read(id, { wait: true }) → 削減済みの出力を読む(完了
248
248
 
249
249
  同じprimitiveが別エージェントのTUIを宿す。`agent_launch`の`harness`はagent loop・認証・hook・session・transcriptを所有する実行基盤、`model`は独立した選択。起動processは直接CLIと同じproject/user環境を使い、通常config、MCP、plugin、skill、permission、trust、memory、historyをcopy・filter・置換しない。
250
250
 
251
- `aiterm.agent-launch-result.v1`は正規`harness`を返し、旧`provider`は互換fieldとして残す。同じ`harness`はagent dispatch、`aiterm-wait`、`agent_configure`、`pty_list`のagent行にも載り、旧vendor/provider/agent fieldは互換用に残る。Codexは通常rollout、Grok CLIは通常session event、Claudeはlaunch固有Stop hook、Cursorは通常agent transcript末尾の`turn_ended`を完了正本に使う。`pty_send`は非ブロックdispatchで、vendor別完了境界を表すopaqueな整数`event_cursor`を返す。Codex親は選択に応じて公式Steerまたは受信キュー、Claude Code親は公式非同期hookで回答本文を自動受信する。それ以外の親は`aiterm-wait`を親のターンを塞がない別processで受ける。Cursorのsubmitキーはadapterが現行CLIのextended keyboard protocolへ変換する。送信textがCursorのcomposerへ残った場合は成功receiptを返さず失敗する。
251
+ `aiterm.agent-launch-result.v1`は正規`harness`を返し、旧`provider`は互換fieldとして残す。同じ`harness`はagent dispatch、`aiterm-wait`、`agent_configure`、`pty_list`のagent行にも載り、旧vendor/provider/agent fieldは互換用に残る。Codexは通常rollout、Grok CLIは通常session event、Claudeはlaunch固有Stop hook、Cursorは通常agent transcript末尾の`turn_ended`を完了正本に使う。agent sessionへの送信は`pty_send`だけで行い、子の状態はAitermが送る時点の画面で見て振り分ける。子のturnが実行中なら各harness標準の操作で現在のturnへ差し込み(`mode=agent_steer`)、完了は差し込み後の作業の終わりに元の依頼へ1回だけ届く。新しい`event_cursor`と配送は作らない。それ以外は非ブロックdispatch(`mode=agent_dispatch`)で、vendor別完了境界を表すopaqueな整数`event_cursor`を返す。Codex親は選択に応じて公式Steerまたは受信キュー、Claude Code親は公式非同期hookで回答本文を自動受信する。それ以外の親は`aiterm-wait`を親のターンを塞がない別processで受ける。Cursorのsubmitキーはadapterが現行CLIのextended keyboard protocolへ変換する。送信textがCursorのcomposerへ残った場合は成功receiptを返さず失敗する。
252
252
 
253
- `agent_launch`・`pty_send`(agent dispatch)・`agent_steer`は任意の`image`(画像ファイルの絶対パスの配列。png/jpg/jpeg/gif/webp)を受ける。aitermが本文末尾へ添付行を付け、どのharnessも自分のfile読取toolでそのpathを画像として開く。呼出し側はharness別の添付手順を覚えない。不正なpathは送信前に拒否する。
253
+ `agent_launch`・`pty_send`(agent session宛て)は任意の`image`(画像ファイルの絶対パスの配列。png/jpg/jpeg/gif/webp)を受ける。aitermが本文末尾へ添付行を付け、どのharnessも自分のfile読取toolでそのpathを画像として開く。呼出し側はharness別の添付手順を覚えない。不正なpathは送信前に拒否する。
254
254
 
255
255
  `agent_launch`は任意の`write_scope`も受ける。Codex/Grokのread-onlyは`--sandbox read-only`、Cursorは公式`--mode ask`で実効化する。path説明は同等CLI引数がないためdeclaration-only。
256
256
 
@@ -258,6 +258,8 @@ Grok/Composerの無人起動は公式`--trust`で指定された作業フォ
258
258
 
259
259
  Grok/Composerで終了済みターンのweekly-limitパネルが残っている場合、次の通常`pty_send`が`Shift+X`で一度閉じ、入力受付を確認して今回の本文を送る。同じsessionと会話を保ち、receiptの`pane_input_recovery`に`grok_rate_limit_dialog_dismissed`を記録する。ターン未終了・harness不在は`GROK_RATE_LIMIT_RECOVERY_BLOCKED`、解除後の入力受付失敗は`GROK_RATE_LIMIT_RECOVERY_FAILED`となり、本文は未送信。上限の継続は`rate_limited`として返し、過去promptは再送しない。Grokの上限観測には現在の画面だけを使う。
260
260
 
261
+ Cursorの送信前hook(`beforeSubmitPrompt`と、互換読込するClaude Codeの`UserPromptSubmit`)がpromptを拒否すると、Cursorはpromptを捨て、turnも完了も起きない。Aitermはこの拒否の表示を見分け、起動時promptは`initial_prompt=failed`、`pty_send`は成功receiptを返さず、どちらも`USER_HOOK_BLOCKED`とhookの出力を返す。確認時間(3秒)より後の拒否は、完了待ちが`outcome=error`(`aiterm-wait`はexit 7)で返す。
262
+
261
263
  Grok/Composerがread-only sandboxの適用を拒否した場合、prompt送信時に`GROK_SANDBOX_STARTUP_FAILED`とCLIの原因を返す。例えばhookのパスにシンボリックリンクがあるとGrok CLIは起動を拒否する。設定の管理元で原因を修正し、対象sessionを`pty_close`して起動し直す。Aitermはsandboxを解除したりhookをコピーしたりしない。
262
264
 
263
265
  この判定はGrok専用アダプターが所有し、同じCLIを使うComposerにも適用する。初回prompt付きの`agent_launch`と通常の`pty_send`で、入力受付待ち中に拒否を検出すると未送信のエラーを返す。promptなし・`trust_project`指定なしの起動応答は入力受付を保証しない。`trust_project:true`では入力受付まで確認し、`startup.status`を返す。Grokのprivacy notice起動設定も同アダプターが所有する。実装の責務分担は[DESIGN](docs/DESIGN.md#failure-and-recovery)を参照。
@@ -403,7 +405,7 @@ MCP クライアントが aiterm を stdio 越しにプログラムから駆動
403
405
 
404
406
  ```mermaid
405
407
  flowchart LR
406
- AI["AI / MCP client<br/>(the orchestrator)"] -->|"pty_send · pty_observe · agent_launch · agent_steer · agent_configure · agent_approval · claude_turn · claude_approval<br/>旧launcher alias · diagnostics"| S["aiterm-mcp<br/>stdio MCP · 18 tools"]
408
+ AI["AI / MCP client<br/>(the orchestrator)"] -->|"pty_send · pty_observe · agent_launch · agent_configure · agent_approval · claude_turn · claude_approval<br/>旧launcher alias · diagnostics"| S["aiterm-mcp<br/>stdio MCP · 17 tools"]
407
409
  S -->|"pty_read<br/>token-reduced"| AI
408
410
  S -->|"tmux / psmux<br/>send · capture"| P["persistent PTYs<br/>再起動を跨ぐ"]
409
411
  P -->|"ssh · docker · repl"| R["nested<br/>remote · container · REPL"]
@@ -512,7 +514,7 @@ Claudeの相関済み承認は既存の`claude_approval`を使う。
512
514
  | ツール | 役割 | 主な引数 |
513
515
  | --- | --- | --- |
514
516
  | `pty_open` | 端末を1個開き`session_id`を返す | `name?`, `shell?`, `env_vars?` |
515
- | `pty_send` | テキストを送る。agent sessionでは非ブロックdispatchとして`event_cursor`を返す | `session_id`, `text`, `enter=true`, `mark`, `force`, `rtk`, `raw` |
517
+ | `pty_send` | テキストを送る。agent sessionでは子のturnが実行中なら現在のturnへ差し込み(`agent_steer`)、それ以外は非ブロックdispatchとして`event_cursor`を返す(`agent_dispatch`)。差し込みでGrokが待ち行列へ入れない時とCursorの入力欄に残った時は失敗する | `session_id`, `text`, `enter=true`, `mark`, `force`, `rtk`, `raw` |
516
518
  | `pty_read` | 出力を削減して読む(既定は増分) | `session_id`, `wait`, `until`, `until_regex`, `timeout`, `screen`, `full`, `lines`, `line_range`, `raw`, `rtk`, `agent_transcript`, `operation_id` |
517
519
  | `pty_key` | 制御キーを送る | `session_id`, `key`(`C-c`/`Enter`/`Up`…) |
518
520
  | `pty_close` | 冪等に閉じ、`closed` / `already_closed`を返す | `session_id` |
@@ -520,7 +522,6 @@ Claudeの相関済み承認は既存の`claude_approval`を使う。
520
522
  | `pty_observe` | pane/harnessの生存、native process identity、状態と活動 | `session_id`, `cursor?` |
521
523
  | `agent_launch` | harnessとmodelを別軸で選ぶ正規agent起動入口 | `harness`, `prompt?`, `model?`, `reasoning_effort?`, `cwd?`, `write_scope?`, `trust_project?`, `env_vars?`, `throughline_source_session?`, `throughline_supplement_file?` |
522
524
  | `agent_approval` | Codexの現在の承認を検査し、単発許可・拒否を送る | `action`, `session_id`, `approval_choice?`, `observed_prompt_digest?` |
523
- | `agent_steer` | 実行中のClaude/Codex/Grok/Cursor turnへ、各harness標準の操作でtextを差し込む。差し込み後の作業の完了は1回だけ届く。idleなら送信せず`idle`を返す。Grokが待ち行列へ入れない時とCursorの入力欄に残った時は`steered`を返さず失敗する | `session_id`, `text` |
524
525
  | `claude_agent` / `codex_agent` / `grok_agent` / `composer_agent` | deprecated互換alias | 旧launcher引数 |
525
526
  | `agent_configure` | 起動中のClaude/Codex/Grok/Composer/Cursorを再起動せずmodel/effort変更 | `session_id`, `model?`, `reasoning_effort?` |
526
527
  | `claude_turn` | 相関済みClaude operationをdispatch(issue)または回収(recover) | `action`, `session_id`, `operation_id`, `text?` |
@@ -594,7 +595,7 @@ agent_launch({ "harness": "codex-cli", "remote": { "host": "rabbit" }, "cwd": "/
594
595
  - `remote`は`host`、`user`、`port`、`identity_file`、`passphrase`または`passphrase_env`、`ssh_options`(`Key=Value`)を受け取る。`host`だけなら`~/.ssh/config`の接続名として使う。Aitermは接続先を保存・管理しない。どこへどの鍵で入るかは呼び出し側が持つ。
595
596
  - 平文の`passphrase`は呼び出したAIの会話記録に残る。ssh-agentか`passphrase_env`(環境変数名)を推奨する。受け取ったパスフレーズはMCP processのメモリにだけ置き、`SSH_ASKPASS`でsshへ渡す。状態ファイルやログには書かない。
596
597
  - 現地には`aiterm-mcp`、tmux(Windowsはpsmux)、使うharnessのCLIが要る。接続先のshell(POSIX系、PowerShell、cmd)は最初の接続で見分ける。POSIX系ではログインshellからPATHだけを受け取るので、`~/.local/bin`、Homebrew、nvmなどに置いたCLIも使える。WindowsはユーザーのPATHのまま`aiterm-mcp`を起動する。
597
- - 以後の`pty_send`、`pty_read`、`pty_close`、`pty_observe`、`agent_steer`などにも同じ`remote`を付ける。session名は現地のもので、この端末の同名sessionとは別に扱う。
598
+ - 以後の`pty_send`、`pty_read`、`pty_close`、`pty_observe`などにも同じ`remote`を付ける。session名は現地のもので、この端末の同名sessionとは別に扱う。
598
599
  - Codex/Claude Code/Cursor親には、この端末の子と同じく回答本文が自動で届く。完了は`ssh <host> aiterm-wait`で観測し、SSHが切れても同じcursorでつなぎ直す。それ以外の親には、sshを使う`wait_process`を返す。
599
600
  - 同じ接続先への呼び出しはControlMasterで1本のSSHに相乗りする。`remote`付きの`image`添付と`claude_turn issue`は未対応。
600
601
 
package/README.md CHANGED
@@ -145,7 +145,7 @@ Aiterm and is not a runtime dependency.
145
145
 
146
146
  **Measured, not claimed:** in the recorded 203-test benchmark, a `pty_read` puts **~7.1× fewer tokens** in your context than the raw log — and the pass/fail verdict survives the fold. → [When to reach for it vs. the built-in shell](#when-to-reach-for-it-vs-the-built-in-shell)
147
147
 
148
- Eighteen tools: seven **PTY tools** — `pty_open` / `pty_send` / `pty_read` / `pty_key` / `pty_close` / `pty_list` / `pty_observe` — to open, drive, read, and observe one persistent terminal; one canonical **agent launcher**, `agent_launch`, which selects `claude-code`, `codex-cli`, `grok-cli`, or `cursor-cli` as the execution harness; `agent_steer` for an active Claude, Codex, Grok, or Cursor turn; four deprecated launcher aliases kept for migration; `agent_configure`; `agent_approval`; `claude_turn`; `claude_approval`; and `diagnostics`. The backend is **tmux on POSIX and psmux on native Windows**, so sessions survive even if the MCP server or the AI client restarts.
148
+ Seventeen tools: seven **PTY tools** — `pty_open` / `pty_send` / `pty_read` / `pty_key` / `pty_close` / `pty_list` / `pty_observe` — to open, drive, read, and observe one persistent terminal; one canonical **agent launcher**, `agent_launch`, which selects `claude-code`, `codex-cli`, `grok-cli`, or `cursor-cli` as the execution harness; four deprecated launcher aliases kept for migration; `agent_configure`; `agent_approval`; `claude_turn`; `claude_approval`; and `diagnostics`. The backend is **tmux on POSIX and psmux on native Windows**, so sessions survive even if the MCP server or the AI client restarts.
149
149
 
150
150
  **v0.28.0 separates the execution harness from the model.** The harness owns the agent loop, authentication, hooks, session, and transcript; `model` is what that harness runs. Cursor Agent CLI can therefore select GPT, Claude, or Grok without changing the completion contract from Cursor hooks to another harness's. Grok Composer is a Grok CLI model preset, not another harness: use `harness: "grok-cli", model: "grok-composer-2.5-fast"`. The old four launcher tools are thin compatibility aliases over the same implementation.
151
151
 
@@ -216,7 +216,7 @@ collection is off by default and performs no network I/O. It ships via
216
216
  tag-triggered CI with npm provenance (OIDC Trusted Publishing); the GitHub
217
217
  Release re-registers the Official MCP Registry entry.
218
218
 
219
- **Status:** actively maintained · current public release **v0.39.1** · runs on Linux · WSL2 · macOS · native Windows (tmux on POSIX, the tmux-CLI-compatible [psmux](https://github.com/psmux/psmux) on native Windows — no WSL required) · MIT · see the [CHANGELOG](CHANGELOG.md).
219
+ **Status:** actively maintained · current public release **v0.40.1** · runs on Linux · WSL2 · macOS · native Windows (tmux on POSIX, the tmux-CLI-compatible [psmux](https://github.com/psmux/psmux) on native Windows — no WSL required) · MIT · see the [CHANGELOG](CHANGELOG.md).
220
220
 
221
221
  ### Update and rollback
222
222
 
@@ -269,9 +269,9 @@ pty_read(id, { wait: true }) → read the token-reduced output, completion
269
269
 
270
270
  The same primitive hosts another agent's TUI. `agent_launch` starts a selected execution harness inside a fresh persistent terminal and returns a `session_id`. `harness` names the component that owns the agent loop, authentication, hooks, session, and transcript; `model` remains an independent choice. The launched process sees the same project and user environment as a direct CLI invocation: normal configuration, MCPs, plugins, skills, permissions, trust decisions, memory, and history are not copied, filtered, or replaced. Aiterm adds only completion correlation and a non-user sub-agent context containing `role=subagent`, the parent session, delegation depth, lineage, and `delegation_allowed=true`.
271
271
 
272
- 起動結果には正規`harness`を含む`aiterm.agent-launch-result.v1`が付き、旧`provider`は互換fieldとして残る。同じ`harness`はagent dispatch、`aiterm-wait`、`agent_configure`、`pty_list`にも載る。Codexは通常rollout、Grokは通常session event、Claudeはlaunch固有Stop hook、Cursorは通常agent transcriptの`turn_ended`を完了正本に使う。agentへの送信は非ブロックdispatchで、harnessごとの完了境界を表す整数`event_cursor`を返す。Codex親は選択に応じて公式Steerまたはqueue、Claude Code親は公式非同期hookで本文を自動受信する。他の親は[`aiterm-wait`](#completion-push-for-parent-agents-aiterm-wait)を使う。CursorのsubmitはadapterがCLIのextended keyboard protocolへ変換し、送信本文がcomposerへ残る場合は明示errorにする。
272
+ 起動結果には正規`harness`を含む`aiterm.agent-launch-result.v1`が付き、旧`provider`は互換fieldとして残る。同じ`harness`はagent dispatch、`aiterm-wait`、`agent_configure`、`pty_list`にも載る。Codexは通常rollout、Grokは通常session event、Claudeはlaunch固有Stop hook、Cursorは通常agent transcriptの`turn_ended`を完了正本に使う。agentへの送信は`pty_send`だけで行い、Aitermが送る時点で子の状態を見て振り分ける。実行中のturnへは差し込み(`mode=agent_steer`、新しい`event_cursor`と配送は作らない)、それ以外は非ブロックdispatch(`mode=agent_dispatch`)で、harnessごとの完了境界を表す整数`event_cursor`を返す。Codex親は選択に応じて公式Steerまたはqueue、Claude Code親は公式非同期hookで本文を自動受信する。他の親は[`aiterm-wait`](#completion-push-for-parent-agents-aiterm-wait)を使う。CursorのsubmitはadapterがCLIのextended keyboard protocolへ変換し、送信本文がcomposerへ残る場合は明示errorにする。
273
273
 
274
- `agent_launch`, `pty_send` (agent dispatch), and `agent_steer` accept an optional `image`: an array of absolute paths to image files (png/jpg/jpeg/gif/webp). Aiterm appends an attachment block to the prompt, and every harness opens the path with its own file-reading tool and sees the image; the caller never learns harness-specific attachment tricks. Invalid paths are rejected before anything is sent.
274
+ `agent_launch` and `pty_send` (to an agent session) accept an optional `image`: an array of absolute paths to image files (png/jpg/jpeg/gif/webp). Aiterm appends an attachment block to the prompt, and every harness opens the path with its own file-reading tool and sees the image; the caller never learns harness-specific attachment tricks. Invalid paths are rejected before anything is sent.
275
275
 
276
276
  `agent_launch` accepts an optional `write_scope`: either `"read-only"` or a human-readable description of writable paths. Codex/Grok use `--sandbox read-only`; Cursor uses its official read-only `--mode ask`. A path description remains declaration-only because these CLI launch surfaces provide no equivalent path allowlist flag.
277
277
 
@@ -279,6 +279,8 @@ Grok/Composerの無人起動は公式`--trust`で指定された作業フォ
279
279
 
280
280
  Grok/Composerで終了済みターンのweekly-limitパネルが残っている場合、次の通常`pty_send`が`Shift+X`で一度閉じ、入力受付を確認して今回の本文を送る。同じsessionと会話を保ち、receiptの`pane_input_recovery`に`grok_rate_limit_dialog_dismissed`を記録する。ターン未終了・harness不在は`GROK_RATE_LIMIT_RECOVERY_BLOCKED`、解除後の入力受付失敗は`GROK_RATE_LIMIT_RECOVERY_FAILED`となり、本文は未送信。上限の継続は`rate_limited`として返し、過去promptは再送しない。Grokの上限観測には現在の画面だけを使う。
281
281
 
282
+ When a Cursor pre-submit hook (`beforeSubmitPrompt`, or a Claude Code `UserPromptSubmit` hook that Cursor loads for compatibility) rejects the prompt, Cursor drops it and no turn or completion follows. Aiterm recognizes the rejection: an initial prompt returns `initial_prompt=failed`, and `pty_send` returns an error instead of a success receipt, both with `USER_HOOK_BLOCKED` and the hook's output. A rejection that comes after the 3-second start check is reported by the completion wait as `outcome=error` (`aiterm-wait` exit 7).
283
+
282
284
  Grok/Composerがread-only sandboxの適用を拒否した場合、prompt送信時に`GROK_SANDBOX_STARTUP_FAILED`とCLIの原因を返す。hookパスのシンボリックリンクなど、CLIが示した原因を設定の管理元で修正し、対象sessionを`pty_close`して起動し直す。Aitermはsandboxを解除したりhookをコピーしたりしない。
283
285
 
284
286
  この判定はGrok専用アダプターが所有し、同じCLIを使うComposerにも適用する。初回prompt付きの`agent_launch`と通常の`pty_send`で、入力受付待ち中に拒否を検出すると未送信のエラーを返す。promptなし・`trust_project`指定なしの起動応答は入力受付を保証しない。`trust_project:true`では入力受付まで確認し、`startup.status`を返す。Grokのprivacy notice起動設定も同アダプターが所有する。実装の責務分担は[DESIGN](docs/DESIGN.md#failure-and-recovery)を参照。
@@ -391,7 +393,7 @@ The only edits to the captures above are the two `⋮` lines (a long head/tail r
391
393
  `aiterm-setup --json`が`ready`になったら、利用するMCP clientを再起動して接続を確認する。Claude Codeの場合:
392
394
 
393
395
  ```bash
394
- /mcp # aiterm should show as connected, exposing 18 tools
396
+ /mcp # aiterm should show as connected, exposing 17 tools
395
397
  ```
396
398
 
397
399
  Your first session — four calls, one persistent terminal:
@@ -432,7 +434,7 @@ The terminal is real and shared, so a human *can* jump in ([A human can watch](#
432
434
 
433
435
  ```mermaid
434
436
  flowchart LR
435
- AI["AI / MCP client<br/>(the orchestrator)"] -->|"pty_send · pty_observe · agent_launch · agent_steer · agent_configure · agent_approval · claude_turn · claude_approval<br/>legacy launcher aliases · diagnostics"| S["aiterm-mcp<br/>stdio MCP · 18 tools"]
437
+ AI["AI / MCP client<br/>(the orchestrator)"] -->|"pty_send · pty_observe · agent_launch · agent_configure · agent_approval · claude_turn · claude_approval<br/>legacy launcher aliases · diagnostics"| S["aiterm-mcp<br/>stdio MCP · 17 tools"]
436
438
  S -->|"pty_read<br/>token-reduced"| AI
437
439
  S -->|"tmux / psmux<br/>send · capture"| P["persistent PTYs<br/>survive restarts"]
438
440
  P -->|"ssh · docker · repl"| R["nested<br/>remote · container · REPL"]
@@ -545,7 +547,7 @@ continue to use `claude_approval`.
545
547
  | Tool | Role | Key args |
546
548
  | --- | --- | --- |
547
549
  | `pty_open` | Open one terminal and return a `session_id` | `name?`, `shell?`, `env_vars?` |
548
- | `pty_send` | Send text; on an agent session this is a non-blocking **dispatch** returning an `event_cursor` | `session_id`, `text`, `enter=true`, `mark`, `force`, `rtk`, `raw` |
550
+ | `pty_send` | Send text. On an agent session Aiterm picks the route when it sends: if the child's turn is running, it steers the text into that turn (`agent_steer`); otherwise it is a non-blocking **dispatch** returning an `event_cursor` (`agent_dispatch`). Steering fails when Grok does not queue the text or Cursor leaves it in the composer | `session_id`, `text`, `enter=true`, `mark`, `force`, `rtk`, `raw` |
549
551
  | `pty_read` | Read output, token-reduced (incremental by default) | `session_id`, `wait`, `until`, `until_regex`, `timeout`, `screen`, `full`, `lines`, `line_range`, `raw`, `rtk`, `agent_transcript`, `operation_id` |
550
552
  | `pty_key` | Send a control key | `session_id`, `key` (`C-c`/`Enter`/`Up`…) |
551
553
  | `pty_close` | Close idempotently; return `closed` / `already_closed` | `session_id` |
@@ -553,7 +555,6 @@ continue to use `claude_approval`.
553
555
  | `pty_observe` | Pane/harness liveness, native process identity, state, and activity | `session_id`, `cursor?` |
554
556
  | `agent_launch` | Canonical agent launch; harness and model are independent | `harness`, `prompt?`, `model?`, `reasoning_effort?`, `cwd?`, `write_scope?`, `trust_project?`, `env_vars?`, `throughline_source_session?`, `throughline_supplement_file?` |
555
557
  | `agent_approval` | Inspect a Codex approval and submit a one-time approval or denial | `action`, `session_id`, `approval_choice?`, `observed_prompt_digest?` |
556
- | `agent_steer` | Inject text into the active Claude, Codex, Grok, or Cursor turn with each harness's own steering control, so the steered work still ends in one completion; return `idle` without sending when no turn is active, and fail instead of returning `steered` when Grok does not queue the text or Cursor leaves it in the composer | `session_id`, `text` |
557
558
  | `claude_agent` / `codex_agent` / `grok_agent` / `composer_agent` | Deprecated compatibility aliases | legacy launcher arguments |
558
559
  | `agent_configure` | Change model/effort in a running Claude, Codex, Grok, Composer, or Cursor session without restarting it | `session_id`, `model?`, `reasoning_effort?` |
559
560
  | `claude_turn` | Issue (dispatch-only) or recover one correlated Claude operation | `action`, `session_id`, `operation_id`, `text?` |
@@ -620,7 +621,7 @@ Claudeをリンク経由の`cwd`から起動した場合も、実体パスに対
620
621
 
621
622
  **For other parent hosts**, dispatch and start the receipt's waiter in a separate process:
622
623
 
623
- 1. Launch the child with `agent_launch({ harness: ... })`; every launch shares the normal project/user environment and adds only completion correlation plus lineage. Send a turn with plain `pty_send` (or `claude_turn issue` for durable Claude operations). The call returns immediately with an `event_cursor` in its structured receipt.
624
+ 1. Launch the child with `agent_launch({ harness: ... })`; every launch shares the normal project/user environment and adds only completion correlation plus lineage. Send a turn with plain `pty_send` (or `claude_turn issue` for durable Claude operations). The call returns immediately with an `event_cursor` in its structured receipt. If the child is still working when you send, Aiterm steers the text into the running turn instead (`mode: "agent_steer"`); the original request's completion then covers it, so no new cursor or delivery is created.
624
625
  2. Pass the receipt's `wait_process.executable` and `wait_process.args` unchanged to a true argv process API. PowerShell 7's `Start-Process` is the exception because it joins `-ArgumentList` arrays; pass `windows_start_process_argument_list` as its one ready-made argument string instead. This invokes the bundled waiter through the exact Node runtime that is already running aiterm, including on native Windows where npm's human-facing bin is a PowerShell script shim and install paths may contain spaces. `wait_command` remains a compatibility display string for humans. The waiter observes the harness-owned completion source, plus Claude's additive launch hook, as a **pure reader** and exits with a one-line `aiterm.agent-wait-result.v1` receipt. **Exit ≠ done**: the receipt's `outcome` is authoritative (`0` = `done`, `3` = `timeout`, `4` = `closed`, `1` = error).
625
626
  3. **親自身のforegroundでwaiterを実行しない。** receiptのprocess起動情報を、そのhostが持つバックグラウンドprocess APIへ渡す。親は別作業へ進むかturnを終え、process終了の通知で続行する。
626
627
  4. Collect the result exactly as before: `pty_read(agent_transcript: true)`, or `claude_turn recover` for durable Claude operations. The waiter carries the signal, never the payload.
@@ -640,7 +641,7 @@ agent_launch({ "harness": "codex-cli", "remote": { "host": "rabbit" }, "cwd": "/
640
641
  - `remote` takes `host`, `user`, `port`, `identity_file`, `passphrase` or `passphrase_env`, and `ssh_options` (`Key=Value`). A bare `host` is used as an `~/.ssh/config` alias. Aiterm does not store or manage destinations; the caller owns where to connect and with which key.
641
642
  - A plain `passphrase` stays in the calling AI's conversation log. Prefer ssh-agent or `passphrase_env` (an environment variable name). A received passphrase lives only in the MCP process memory and reaches ssh through `SSH_ASKPASS`; it is never written to state files or logs.
642
643
  - The remote machine needs `aiterm-mcp`, tmux (psmux on Windows), and the harness CLI. The remote shell family (POSIX, PowerShell, or cmd) is detected on first contact. On POSIX machines Aiterm takes only PATH from the login shell, so CLIs under `~/.local/bin`, Homebrew, or nvm are found; on Windows it starts `aiterm-mcp` with the user's PATH as is.
643
- - Pass the same `remote` to later `pty_send`, `pty_read`, `pty_close`, `pty_observe`, `agent_steer`, and so on. Session names belong to the remote machine and never collide with local sessions of the same name.
644
+ - Pass the same `remote` to later `pty_send`, `pty_read`, `pty_close`, `pty_observe`, and so on. Session names belong to the remote machine and never collide with local sessions of the same name.
644
645
  - Codex, Claude Code, and Cursor parents receive the answer automatically, as with a local child. Completion is observed with `ssh <host> aiterm-wait`, reconnecting at the same cursor if SSH drops. Other parents get an ssh-based `wait_process`.
645
646
  - Calls to the same destination share one SSH connection through ControlMaster. `image` attachments and `claude_turn issue` are not yet supported with `remote`.
646
647
 
package/dist/core.js CHANGED
@@ -21,7 +21,7 @@ import { sleep, currentUid, runtimeStateBase, safeStatSize, readFileRange, write
21
21
  import { GROK_MODEL_DEFAULTS, realGrokHome, resolveAndValidateGrokAuth, assertGrokModelAvailable, grokEventsTranscript, latestGrokCompletion, observeGrokDone, buildGrokAgentCmd, grokLaunchNote, grokEnvTokens, grokTuiReady, grokTuiBusy, grokPaneObservation, grokRateLimitDialog, grokStartupAction, grokLaunchBlockingDialog, assertGrokSandboxNotRejected, GROK_COMPOSER_MARKER_RE, grokFooterHasConfiguration, grokTranscriptText, createGrokAgentMetadata, } from "./harnesses/grok.js";
22
22
  import { bindCodexTranscriptSession, latestCodexCompletion, observeCodexDone, buildCodexAgentCmd, codexLaunchNote, codexTuiReady, codexPaneObservation, codexRateLimitModelSwitchDialog, codexApprovalDialog, codexStartupAction, CODEX_COMPOSER_MARKER_RE, codexModelChoice, codexEffortChoice, codexMoreReasoningChoice, codexTranscriptText, createCodexAgentMetadata, } from "./harnesses/codex.js";
23
23
  import { OPERATION_ID_RE, CLAUDE_RESULT_MAX_BYTES, CLAUDE_EFFORTS, agentManagedClaudeSettingsPath, agentClaudeResultPath, agentClaudeOperationPath, agentClaudeApprovalReceiptPath, agentClaudeDispatchReceiptPath, validateOperationId, readClaudeResultText, assertClaudeAuthenticationReady, buildClaudeAgentCmd, claudeLaunchNote, claudeTuiReady, claudePaneObservation, claudeStartupAction, claudeLoginMethodMenu, CLAUDE_COMPOSER_MARKER_RE, createClaudeAgentMetadata, claudeSessionTranscriptPath, claudeApiErrorFromLine, } from "./harnesses/claude.js";
24
- import { bindCursorTranscriptSession, cursorTurnBoundary, latestCursorCompletion, observeCursorDone, cursorTranscriptText, assertCursorAuthenticationReady, assertCursorModelAvailable, buildCursorAgentCmd, cursorAgentArgv, cursorPwshLaunchLine, cursorPromptWithLineage, createCursorAgentMetadata, cursorLaunchNote, cursorEffortNavigation, cursorTuiReady, cursorPaneObservation, cursorUsageLimit, CURSOR_SUBMIT_SEQUENCE, CURSOR_COMPOSER_CONTENT_MARKER_RE, validateCursorModelEffort, } from "./harnesses/cursor.js";
24
+ import { bindCursorTranscriptSession, cursorTurnBoundary, latestCursorCompletion, observeCursorDone, cursorTranscriptText, assertCursorAuthenticationReady, assertCursorModelAvailable, buildCursorAgentCmd, cursorAgentArgv, cursorPwshLaunchLine, cursorPromptWithLineage, createCursorAgentMetadata, cursorLaunchNote, cursorEffortNavigation, cursorTuiReady, cursorPaneObservation, cursorPromptHooksRunning, cursorUsageLimit, CURSOR_SUBMIT_SEQUENCE, CURSOR_COMPOSER_CONTENT_MARKER_RE, validateCursorModelEffort, } from "./harnesses/cursor.js";
25
25
  import { resolveAgentBin, resolveThroughlineBin, runThroughlineHandoffContext, isWindowsNativeExecutable, agentBinForPaneShell, resolveWinPaneShell } from "./agent-resolver.js";
26
26
  export { AitermError } from "./errors.js";
27
27
  export { tmuxSpawnEnv } from "./tmux-runtime.js";
@@ -2669,7 +2669,7 @@ export async function observeAgentDone(name, o = {}) {
2669
2669
  return observeCodexDone(meta, timeout, o.cursor, detectAgentRateLimit, o.signal);
2670
2670
  }
2671
2671
  if (meta.kind === "cursor" && meta.completion_route === "cursor_transcript") {
2672
- return observeCursorDone(meta, timeout, o.cursor, detectAgentRateLimit, o.signal);
2672
+ return observeCursorDone(meta, timeout, o.cursor, detectAgentRateLimit, (session) => captureScreen(session, 0), o.signal);
2673
2673
  }
2674
2674
  if ((meta.kind === "grok" || meta.kind === "composer") && meta.completion_route === "grok_transcript") {
2675
2675
  return observeGrokDone(meta, timeout, o.cursor, detectAgentRateLimit, o.signal);
@@ -3213,8 +3213,10 @@ export async function sendInitialAgentPrompt(name, text, o = {}) {
3213
3213
  }
3214
3214
  let delivery = { status: "submitted_unconfirmed", reason: "start_unconfirmed", turn_started: null };
3215
3215
  const deadline = performance.now() + 3000;
3216
+ const hookDeadline = performance.now() + CURSOR_PROMPT_HOOK_WAIT_MS;
3217
+ let screen = "";
3216
3218
  do {
3217
- const screen = captureScreen(name, AGENT_TUI_READY_LINES);
3219
+ screen = captureScreen(name, AGENT_TUI_READY_LINES);
3218
3220
  const state = meta.kind === "grok" || meta.kind === "composer" ? grokPaneObservation(screen)
3219
3221
  : meta.kind === "codex" ? codexPaneObservation(screen)
3220
3222
  : meta.kind === "claude" ? claudePaneObservation(screen) : cursorPaneObservation(screen);
@@ -3226,6 +3228,11 @@ export async function sendInitialAgentPrompt(name, text, o = {}) {
3226
3228
  delivery = { status: "started", reason: "approval_required", turn_started: true };
3227
3229
  break;
3228
3230
  }
3231
+ if (state.state === "blocked" && state.reason === "user_hook_blocked") {
3232
+ setInitialDelivery(meta, { status: "submitted_unconfirmed", reason: "user_hook_blocked", turn_started: false }, startOffset);
3233
+ setInitialPromptState(meta, "failed");
3234
+ throw new AitermError(userHookBlockedMessage(`initial_prompt=failed vendor=${meta.kind} harness=${agentHarness(meta.kind)}`, "起動時prompt", state.detail), 2);
3235
+ }
3229
3236
  const completed = await observeAgentDone(name, { cursor: startOffset, timeout: 0 });
3230
3237
  if (completed.outcome === "done") {
3231
3238
  delivery = { status: "started", reason: "turn_completed", turn_started: true };
@@ -3236,7 +3243,7 @@ export async function sendInitialAgentPrompt(name, text, o = {}) {
3236
3243
  break;
3237
3244
  }
3238
3245
  await sleep(100);
3239
- } while (performance.now() < deadline);
3246
+ } while (performance.now() < deadline || (meta.kind === "cursor" && performance.now() < hookDeadline && cursorPromptHooksRunning(screen)));
3240
3247
  setInitialDelivery(meta, delivery, startOffset);
3241
3248
  return {
3242
3249
  text: `initial_prompt=pending vendor=${meta.kind} event_cursor=${startOffset} harness=${agentHarness(meta.kind)}\n` +
@@ -3510,6 +3517,32 @@ export function attachImages(text, images) {
3510
3517
  const body = text.trim().length > 0 ? text : "添付画像を確認してください。";
3511
3518
  return `${body}\n\n${lines.join("\n")}\n添付画像は上のファイルを読んで確認する。`;
3512
3519
  }
3520
+ function userHookBlockedMessage(head, what, detail) {
3521
+ return `${head}\nUSER_HOOK_BLOCKED: 利用者のhookが${what}を拒否したため、turnは始まっていません。完了通知も来ません。` +
3522
+ `hookを直すか外してから送り直してください。hookの出力: ${detail ?? "(不明)"}`;
3523
+ }
3524
+ const CURSOR_START_CONFIRM_MS = 3000;
3525
+ // 送信前hookが動いている間は、確認時間を過ぎても結果を待つ。Windowsではhookごとの起動が遅く、数本続くと3秒を超える
3526
+ // (fox実測 2026-09-27)。上限は利用者のhookに付く最長のtimeout(60秒)に合わせる。
3527
+ const CURSOR_PROMPT_HOOK_WAIT_MS = 60_000;
3528
+ // Cursorは送信前hookの実行中も「Working」だけを出し、拒否されると入力欄を空に戻す。入力欄の残留検査では
3529
+ // 拒否を見分けられず、成功receiptを返すと親は来ない完了を待ち続ける。turnの開始か拒否の表示を確かめる。
3530
+ // hookが上限を過ぎてから拒否した時は、完了待ち(observeCursorDone)がoutcome=errorで返す。
3531
+ async function assertCursorPromptNotHookBlocked(name) {
3532
+ const deadline = performance.now() + CURSOR_START_CONFIRM_MS;
3533
+ const hookDeadline = performance.now() + CURSOR_PROMPT_HOOK_WAIT_MS;
3534
+ let screen = "";
3535
+ do {
3536
+ screen = captureScreen(name, AGENT_TUI_READY_LINES);
3537
+ const state = cursorPaneObservation(screen);
3538
+ if (state.state === "busy")
3539
+ return;
3540
+ if (state.state === "blocked" && state.reason === "user_hook_blocked") {
3541
+ throw new AitermError(userHookBlockedMessage(`vendor=cursor session=${name}`, "送信した文", state.detail), 2);
3542
+ }
3543
+ await sleep(100);
3544
+ } while (performance.now() < deadline || (performance.now() < hookDeadline && cursorPromptHooksRunning(screen)));
3545
+ }
3513
3546
  export async function dispatchAgentTurn(name, text, o = {}) {
3514
3547
  assertSessionName(name);
3515
3548
  const meta = loadAgentMetadata(name);
@@ -3528,7 +3561,7 @@ export async function dispatchAgentTurn(name, text, o = {}) {
3528
3561
  const claudeColdStart = meta.kind === "claude"
3529
3562
  && agentCompletionCursor(meta) === 0
3530
3563
  && readClaudeOperationMarker(meta) === null;
3531
- const paneInputRecovery = await ensureAgentOwnsPaneInput(name, meta.kind);
3564
+ const paneInputRecovery = o.pane_input_recovery ?? await ensureAgentOwnsPaneInput(name, meta.kind);
3532
3565
  let codexRateLimitModelSwitch = false;
3533
3566
  const limitDialog = meta.kind === "grok" || meta.kind === "composer"
3534
3567
  ? grokRateLimitDialog(captureScreen(name, 0)) : null;
@@ -3608,6 +3641,8 @@ export async function dispatchAgentTurn(name, text, o = {}) {
3608
3641
  // 陽性観測した場合だけ同じEnterを一度再送し、再検査後も残る時は失敗として返す。
3609
3642
  residue = await retryCursorSubmitIfResidue(name, meta.kind, dispatchText, residue);
3610
3643
  assertAgentSubmitDelivered(name, meta.kind, residue);
3644
+ if (meta.kind === "cursor")
3645
+ await assertCursorPromptNotHookBlocked(name);
3611
3646
  return {
3612
3647
  schema: "aiterm.agent-dispatch.v1",
3613
3648
  session_id: meta.aiterm_session,
@@ -3640,6 +3675,26 @@ async function waitSteerScreen(name, predicate) {
3640
3675
  export function __testSteerQueued(kind, screen) {
3641
3676
  return kind === "cursor" ? cursorSteerQueued(screen) : grokSteerQueued(screen);
3642
3677
  }
3678
+ /**
3679
+ * agent sessionへの唯一の送信口。呼び出し側は子の状態を知らないまま呼び、Aitermがこの時点の画面で振り分ける。
3680
+ * 実行中なら現在のturnへ差し込み(steer)、そうでなければ新しいturnとしてdispatchする。
3681
+ * 振り分けを呼び出し側へ任せると、状態を見てから呼ぶまでの間に子のturnが変わり、選んだ入口が外れる。
3682
+ */
3683
+ export async function sendAgentMessage(name, text, o = {}) {
3684
+ assertSessionName(name);
3685
+ const meta = loadAgentMetadata(name);
3686
+ // 前面回復は busy 判定より先(bash 前面のままだと画面の実行中マーカーを読んでも打鍵が届かない)。
3687
+ const paneInputRecovery = await ensureAgentOwnsPaneInput(name, meta.kind);
3688
+ let running = isAgentTuiBusy(meta.kind, captureScreen(name, AGENT_TUI_READY_LINES));
3689
+ // Claude CodeはStop hookの実行中も実行中の表示を続ける。Aitermのturnの印はStopで消えるので、
3690
+ // 印が無ければturnは終わっている。ここで差し込むと新しいturnとして始まり、誰もその完了を待たない。
3691
+ if (meta.kind === "claude" && readClaudeOperationMarker(meta) === null)
3692
+ running = false;
3693
+ if (running) {
3694
+ return steerRunningTurn(name, meta, text, { raw: o.raw, pane_input_recovery: paneInputRecovery });
3695
+ }
3696
+ return dispatchAgentTurn(name, text, { raw: o.raw, before_send: o.before_send, pane_input_recovery: paneInputRecovery });
3697
+ }
3643
3698
  /**
3644
3699
  * 実行中turnへ追加の指示を渡す。各harnessの標準操作だけを使い、完了境界は1つに保つ。
3645
3700
  * - Codex: 次のtool呼出し後に同じturnへ入る(rolloutのturn_idが同じ)。
@@ -3648,28 +3703,22 @@ export function __testSteerQueued(kind, screen) {
3648
3703
  * - Grok: 待ち行列へ入れた後に「send now」を押す。旧turnは`cancelled`(trigger=send_now)で閉じ、
3649
3704
  * 新turnが作業を継ぐ。完了判定はこの継ぎ目を完了と数えない(grokCompletionEvent)。
3650
3705
  */
3651
- export async function steerAgentTurn(name, text) {
3652
- assertSessionName(name);
3653
- const meta = loadAgentMetadata(name);
3706
+ async function steerRunningTurn(name, meta, text, o) {
3654
3707
  const receipt = {
3655
3708
  schema: "aiterm.agent-steer.v1",
3656
3709
  session_id: meta.aiterm_session,
3657
3710
  launch_id: meta.launch_id,
3658
3711
  vendor: meta.kind,
3659
3712
  harness: agentHarness(meta.kind),
3713
+ pane_input_recovery: o.pane_input_recovery,
3660
3714
  };
3661
- // 前面回復は busy 判定より先(bash 前面のままだと画面の実行中マーカーを読んでも打鍵が届かない)。
3662
- const paneInputRecovery = await ensureAgentOwnsPaneInput(name, meta.kind);
3663
- if (!isAgentTuiBusy(meta.kind, captureScreen(name, AGENT_TUI_READY_LINES))) {
3664
- return { ...receipt, delivery: "idle", pane_input_recovery: paneInputRecovery };
3665
- }
3666
- prepareSendText(text, { raw: false });
3715
+ prepareSendText(text, { raw: o.raw });
3667
3716
  // Claudeのoperation相関は変えない。差し込みは実行中turnの一部であり、新しいturnを予約しない。
3668
3717
  const preserveAgentOperation = meta.kind === "claude";
3669
3718
  send(name, text, {
3670
3719
  enter: false,
3671
3720
  force: true,
3672
- raw: false,
3721
+ raw: o.raw,
3673
3722
  mark: false,
3674
3723
  rtk: false,
3675
3724
  preserveAgentOperation,
@@ -3693,7 +3742,8 @@ export async function steerAgentTurn(name, text) {
3693
3742
  const label = meta.kind === "cursor" ? "Cursor" : "Grok";
3694
3743
  if (!await waitSteerScreen(name, queued)) {
3695
3744
  throw new AitermError(`STEER_NOT_QUEUED vendor=${meta.kind} session=${name}\n` +
3696
- `差し込む文が${label}の待ち行列へ入ったことを確認できません。pty_read(screen:true)で入力欄を確かめてください。`, 2);
3745
+ `差し込む文が${label}の待ち行列へ入ったことを確認できません。子のturnが送る直前に終わっていた時は、` +
3746
+ `文は新しいturnとして始まっており、その完了は通知されません。再送する前にpty_read(screen:true)で確かめてください。`, 2);
3697
3747
  }
3698
3748
  sendKey(name, "Enter");
3699
3749
  if (!await waitSteerScreen(name, screen => !queued(screen))) {
@@ -3711,7 +3761,7 @@ export async function steerAgentTurn(name, text) {
3711
3761
  "差し込む文がCursorの入力欄に残っており、実行中のturnへ渡せませんでした。", 2);
3712
3762
  }
3713
3763
  }
3714
- return { ...receipt, delivery: "steered", pane_input_recovery: paneInputRecovery };
3764
+ return receipt;
3715
3765
  }
3716
3766
  export async function runClaudeOperation({ session_id: name, action, operation_id: operationIdInput, text, before_send, }) {
3717
3767
  assertSessionName(name);
@@ -183,13 +183,13 @@ export function latestCursorCompletion(meta, readTranscriptLines) {
183
183
  ? cursorCompletionEvent(meta, harnessSessionId, state.terminalRecord, `cursor:${state.userTurns}`)
184
184
  : null;
185
185
  }
186
- export async function observeCursorDone(meta, timeout, requestedCursor, detectRateLimit, signal) {
186
+ export async function observeCursorDone(meta, timeout, requestedCursor, detectRateLimit, readScreen, signal) {
187
187
  const metadataFile = agentMetadataPath(meta.aiterm_session, meta.launch_id);
188
188
  let transcript = cursorTranscript(meta);
189
189
  const startBoundary = requestedCursor ?? (transcript ? cursorTranscriptState(transcript).userTurns : 0);
190
190
  let malformedEvents = 0;
191
191
  const deadline = performance.now() + timeout * 1000;
192
- const observation = (outcome, ev = null, rateLimit = null) => ({
192
+ const observation = (outcome, ev = null, rateLimit = null, error = null) => ({
193
193
  schema: "aiterm.agent-wait-result.v1",
194
194
  session_id: meta.aiterm_session,
195
195
  launch_id: meta.launch_id,
@@ -202,7 +202,7 @@ export async function observeCursorDone(meta, timeout, requestedCursor, detectRa
202
202
  malformed_events: malformedEvents,
203
203
  at: ev?.at ?? null,
204
204
  rate_limit: rateLimit,
205
- error: null,
205
+ error,
206
206
  });
207
207
  for (;;) {
208
208
  signal?.throwIfAborted();
@@ -222,6 +222,10 @@ export async function observeCursorDone(meta, timeout, requestedCursor, detectRa
222
222
  const limited = detectRateLimit(meta.kind, meta.aiterm_session);
223
223
  if (limited)
224
224
  return observation("rate_limited", null, limited);
225
+ // 拒否されたpromptのturnは始まらず、完了も来ない。拒否の表示は数秒で消えるので、見えている間に終わらせる。
226
+ const hookBlocked = cursorHookBlocked(readScreen(meta.aiterm_session));
227
+ if (hookBlocked)
228
+ return observation("error", null, null, `USER_HOOK_BLOCKED: ${hookBlocked.message}`);
225
229
  if (performance.now() >= deadline)
226
230
  return observation(timeout === 0 ? "running" : "timeout");
227
231
  await sleep(AGENT_DONE_POLL_MS);
@@ -452,10 +456,44 @@ export function cursorUsageLimit(screen) {
452
456
  }
453
457
  return { message: [`${heading[1]}.`, ...detail].join(" ") };
454
458
  }
459
+ // Cursor Agentはpromptを送る前のhook(beforeSubmitPromptと、互換読込したClaude CodeのUserPromptSubmit)が
460
+ // 拒否すると、promptを捨てて入力欄を空に戻し、その下に「Hook blocked with message:」を数秒だけ出す。turnは始まらず、
461
+ // transcriptにもuser turnは残らない(v2026.09.26、Windows実機採取 2026-09-27)。
462
+ const CURSOR_HOOK_BLOCKED_RE = /^[ \t]*Hook blocked with message:[ \t]*(.*)$/gim;
463
+ const CURSOR_HOOK_BLOCKED_MESSAGE_LIMIT = 600;
464
+ export function cursorHookBlocked(screen) {
465
+ const clean = screen.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
466
+ const hit = [...clean.matchAll(CURSOR_HOOK_BLOCKED_RE)].at(-1);
467
+ if (!hit)
468
+ return null;
469
+ const after = clean.slice(hit.index + hit[0].length);
470
+ // 拒否の後に新しいturnが動いている画面は、古い表示の名残として数えない。
471
+ if (/ctrl\+c to stop/i.test(after) || CURSOR_FOLLOWUP_MARKER_RE.test(after))
472
+ return null;
473
+ const lines = [hit[1].trim()];
474
+ for (const line of after.split("\n").slice(1)) {
475
+ if (!line.trim())
476
+ break;
477
+ lines.push(line.trim());
478
+ }
479
+ // hookを動かしたNode自身の警告は拒否の理由ではない。
480
+ const message = lines.filter(line => line && !/ExperimentalWarning|--trace-warnings|^any time$/.test(line)).join(" ");
481
+ return { message: (message || "(hookは理由を出していません)").slice(0, CURSOR_HOOK_BLOCKED_MESSAGE_LIMIT) };
482
+ }
483
+ // 送信前hookの実行中、Cursorは入力欄の上に「Working」の回転表示だけを出し、入力欄はまだ「Plan, search, build anything」の
484
+ // ままで「ctrl+c to stop」も無い(v2026.09.26、Windows実機採取 2026-09-27)。turnはまだ始まっておらず、hookが拒否すればここで終わる。
485
+ const CURSOR_SPINNER_WORKING_RE = /^[ \t]*[⠀-⣿]+[ \t]+Working\b/m;
486
+ export function cursorPromptHooksRunning(screen) {
487
+ const tail = screen.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").split("\n").slice(-32).join("\n");
488
+ return CURSOR_SPINNER_WORKING_RE.test(tail) && CURSOR_START_PROMPT_MARKER_RE.test(tail) && !/ctrl\+c to stop/i.test(tail);
489
+ }
455
490
  export function cursorPaneObservation(screen) {
456
491
  const tail = screen.split("\n").slice(-32).join("\n");
457
492
  if (cursorUsageLimit(tail))
458
493
  return { state: "blocked", reason: "rate_limited" };
494
+ const hookBlocked = cursorHookBlocked(tail);
495
+ if (hookBlocked)
496
+ return { state: "blocked", reason: "user_hook_blocked", detail: hookBlocked.message };
459
497
  if (/ctrl\+c to stop/i.test(tail))
460
498
  return { state: "busy", reason: "turn_running" };
461
499
  if (cursorTuiReady(tail))
@@ -84,7 +84,7 @@ export function grokCompletionEvent(meta, record) {
84
84
  record?.type !== "turn_ended" ||
85
85
  (record?.outcome !== "completed" && record?.outcome !== "cancelled" && record?.outcome !== "error"))
86
86
  return null;
87
- // agent_steerの「send now」は旧turnを閉じて同じ作業を新turnで続ける継ぎ目であり、完了ではない
87
+ // 実行中turnへの差し込み(pty_send)の「send now」は旧turnを閉じて同じ作業を新turnで続ける継ぎ目であり、完了ではない
88
88
  // (実測 grok 1.0.41: outcome=cancelled, cancellation_context.trigger=send_now の直後にturn_started)。
89
89
  if (record.outcome === "cancelled" && record.cancellation_context?.trigger === "send_now")
90
90
  return null;
package/dist/index.js CHANGED
@@ -187,15 +187,18 @@ async function sendRemote(target, args, extra) {
187
187
  throw new core.AitermError("REMOTE_IMAGE_UNSUPPORTED: 別端末への画像添付には未対応です", 2);
188
188
  const key = deliveryKey(args.session_id, target);
189
189
  const delivery = args.force ? null : await deliveryForRequest(extra, true);
190
- // 前の依頼の回答を確保してから次を送る。送った後では取り消せない。
190
+ // 終わった依頼の回答は送る前に保存する。実行中の依頼は残る。現地が差し込みを選べばその依頼が回答を受け取る。
191
191
  if (delivery)
192
- await remoteParentDelivery.beforeChange(key, true);
192
+ await remoteParentDelivery.beforeChange(key);
193
193
  const result = await callRemoteTool(target, "pty_send", args);
194
194
  const structured = result.structuredContent;
195
195
  if (result.isError || !structured || structured.mode !== "agent_dispatch") {
196
196
  return { content: [...result.content, remoteNote(target, result)], ...(structured ? { structuredContent: structured } : {}), ...(result.isError ? { isError: true } : {}) };
197
197
  }
198
198
  try {
199
+ // 現地がdispatchを選んだのは子がidleだった時なので、前の依頼はもう保存できる。残っていれば登録しない。
200
+ if (delivery)
201
+ await remoteParentDelivery.beforeChange(key, true);
199
202
  if (delivery && structured.event_cursor !== null)
200
203
  await registerRemoteDelivery(delivery, target, structured.session_id, structured.event_cursor);
201
204
  }
@@ -248,7 +251,10 @@ registerRemoteAwareTool("pty_open", {
248
251
  });
249
252
  registerRemoteAwareTool("pty_send", {
250
253
  description: "セッションへテキストを送る。通常PTYへは送信のみ(出力は pty_read で取得)。" +
251
- "agent session(launcher起動)への send は自動で dispatch になる: TUI の ready gate と submit 分離を通して即返り、" +
254
+ "agent session(launcher起動)への送信はこのtoolだけで行い、子の状態はAitermが送る時点で見て振り分ける。" +
255
+ "子のturnが実行中なら各harness標準の操作で現在のturnへ差し込み(mode=agent_steer)、完了は差し込み後の作業の終わりに" +
256
+ "元の依頼への1回だけ届く。新しいevent_cursorと配送は作らない。" +
257
+ "それ以外は新しいturnとしてdispatchし(mode=agent_dispatch)、TUI の ready gate と submit 分離を通して即返り、" +
252
258
  "receipt の event_cursor を返す。" +
253
259
  NON_BLOCKING_RULE +
254
260
  "自動配送以外の結果回収は pty_read(agent_transcript:true)、Claude の durable turn は claude_turn を使う。" +
@@ -280,7 +286,7 @@ registerRemoteAwareTool("pty_send", {
280
286
  },
281
287
  outputSchema: {
282
288
  schema: z.literal("aiterm.pty-send-result.v1"),
283
- mode: z.enum(["sent", "agent_dispatch"]),
289
+ mode: z.enum(["sent", "agent_dispatch", "agent_steer"]),
284
290
  session_id: z.string(),
285
291
  event_cursor: z.number().int().nullable(),
286
292
  wait_process: waitProcessOutputSchema,
@@ -305,7 +311,30 @@ registerRemoteAwareTool("pty_send", {
305
311
  if (rtk)
306
312
  throw new Error("agent session への dispatch は rtk:true と併用できません");
307
313
  delivery = await deliveryForRequest(extra);
308
- const receipt = await core.dispatchAgentTurn(session_id, core.attachImages(text, image), { raw, before_send: delivery?.before_send });
314
+ const receipt = await core.sendAgentMessage(session_id, core.attachImages(text, image), { raw, before_send: delivery?.before_send });
315
+ if (receipt.schema === "aiterm.agent-steer.v1") {
316
+ return {
317
+ content: [
318
+ {
319
+ type: "text",
320
+ text: `実行中のturnへ差し込んだ(harness=${receipt.harness}, vendor=${receipt.vendor})。` +
321
+ "完了は差し込み後の作業の終わりに、元の依頼への通知として1回だけ届く。",
322
+ },
323
+ ],
324
+ structuredContent: {
325
+ schema: "aiterm.pty-send-result.v1",
326
+ mode: "agent_steer",
327
+ session_id: receipt.session_id,
328
+ event_cursor: null,
329
+ wait_process: null,
330
+ launch_id: receipt.launch_id,
331
+ vendor: receipt.vendor,
332
+ harness: receipt.harness,
333
+ submit_residue: null,
334
+ pane_input_recovery: receipt.pane_input_recovery,
335
+ },
336
+ };
337
+ }
309
338
  const waited = completionWait(delivery, receipt.session_id, receipt.event_cursor);
310
339
  return {
311
340
  content: [
@@ -356,35 +385,6 @@ registerRemoteAwareTool("pty_send", {
356
385
  return fail(e);
357
386
  }
358
387
  });
359
- registerRemoteAwareTool("agent_steer", {
360
- description: "実行中のClaude/Codex/Grok/Cursor agentへ追加メッセージを差し込み、現在のターンを誘導する。" +
361
- "完了は差し込み後の作業の終わりに1回だけ届く。独立した次ターンを始める用途ではなく、idle時は文字を送らずdelivery=idleを返す。" +
362
- "Grokが待ち行列へ入れない時とCursorの入力欄に残った時は、steeredを返さずエラーにする。",
363
- inputSchema: {
364
- session_id: z.string(),
365
- text: z.string().describe("現在のターンへ追加する文字列。UTF-8で最大64KiB"),
366
- image: z.array(z.string()).optional().describe("添付する画像ファイルの絶対パス(png/jpg/jpeg/gif/webp)"),
367
- },
368
- outputSchema: {
369
- schema: z.literal("aiterm.agent-steer.v1"),
370
- session_id: z.string(),
371
- launch_id: z.string(),
372
- vendor: z.enum(["claude", "codex", "grok", "composer", "cursor"]),
373
- harness: z.enum(["claude-code", "codex-cli", "grok-cli", "cursor-cli"]),
374
- delivery: z.enum(["steered", "idle"]),
375
- },
376
- }, async ({ session_id, text, image }) => {
377
- try {
378
- const receipt = await core.steerAgentTurn(session_id, core.attachImages(text, image));
379
- return {
380
- content: [{ type: "text", text: `${receipt.delivery} ${receipt.session_id}` }],
381
- structuredContent: receipt,
382
- };
383
- }
384
- catch (e) {
385
- return fail(e);
386
- }
387
- });
388
388
  registerRemoteAwareTool("pty_read", {
389
389
  description: "セッションの出力をトークン削減して読む(既定は前回読取位置からの増分)。" +
390
390
  "削減: 制御文字除去 / 反復圧縮 / head+tail 折りたたみ+復元ヒント+メタ併記。" +
package/docs/DESIGN.md CHANGED
@@ -64,7 +64,11 @@ waiterは純readerで、親のforeground turnを塞がない。
64
64
  Grok/Composerの記録先はCLIと同じOS絶対パスへcwdを正規化して導出し、完了通知と回答で同じ関数を使う。
65
65
  配送用のGrok回答は`turn_ended.ts`から同じturnの`turn_started.turn_number`を取得し、
66
66
  `chat_history.jsonl`の`user.prompt_index`と相関する。次turnが既に始まっていても対象回答だけを回収する。
67
- `agent_steer`は実行中のturnへ追加textを差し込み、idleなら送信せず状態を返す。完了境界は差し込み後も1つに保つ。
67
+ agent sessionへの送信口は`pty_send`だけとする。子の状態は呼び出し側に選ばせず、Aitermが送る時点の画面で振り分ける。
68
+ 状態を見てから呼ぶまでの間に子のturnが変わるため、呼び出し側が入口を選ぶ形では外れる。
69
+ 実行中なら現在のturnへ追加textを差し込み、新しい完了境界と配送は作らない。完了境界は差し込み後も1つに保つ。
70
+ Claude CodeはStop hookの実行中も実行中の表示を続けるため、Aitermのturnの印(Stopで消える)が無ければ実行中と数えない。
71
+ それ以外は新しいturnとしてdispatchする。
68
72
  CodexとClaude Codeは次のtool境界で同じturnへ取り込む。Cursorは「follow-ups」枠へ入った文を「enter steer」で現在turnへ移し、`turn_ended`は最後に1回書く。
69
73
  Grokは待ち行列へ入れた後に「send now」を押す。旧turnは`cancelled`(`cancellation_context.trigger=send_now`)で閉じ、
70
74
  新turnが作業を継ぐので、完了判定はこの継ぎ目を完了と数えない。Grokが待ち行列へ入れない時とCursorの入力欄に本文が残る時は`steered`を返さない。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiterm-mcp",
3
- "version": "0.39.1",
3
+ "version": "0.40.1",
4
4
  "mcpName": "io.github.kitepon/aiterm-mcp",
5
5
  "description": "Persistent terminal MCP with one harness-based launcher for Claude Code, Codex CLI, Grok CLI, and Cursor Agent CLI, plus durable PTYs for SSH, containers, and REPLs.",
6
6
  "keywords": [