aiterm-mcp 0.17.0 → 0.18.0

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/README.ja.md CHANGED
@@ -24,7 +24,13 @@
24
24
 
25
25
  12 ツール: 6 つの **PTY ツール**(`pty_open` / `pty_send` / `pty_read` / `pty_key` / `pty_close` / `pty_list`)で 1 本の永続端末を開き・操作し・読む。加えて 4 つの **エージェント起動ツール**(`claude_agent` / `codex_agent` / `grok_agent` / `composer_agent`)が別のコーディングエージェントの TUI を新しい端末の中に起動し、`claude_turn`がdurable caller向けの構造化issue/recoveryを、`diagnostics`が安全なfactory readinessを返す。バックエンドは **tmux** なので、MCP サーバや AI クライアントが再起動してもセッションは生き残る。
26
26
 
27
- **v0.17.0 を 2026-07-18 に公開。** 親エージェントは aiterm 上で一切ブロックしない:
27
+ **v0.18.0 を 2026-07-18 に公開。** 実運用障害の還流による agent dispatch の hardening:
28
+ 全 dispatch/launch receipt に **submit 座礁観測** `submit_residue` を追加
29
+ (送信 prompt が vendor TUI の composer に未 submit のまま残存していないかを有界に観測して報告する。
30
+ 陽性証拠のみ・auto-retry なし)。agent への prompt paste は tmux **bracketed paste**(pane ごとの
31
+ negotiation)で包み、語中の文字化けと Enter 取り落としを抑制。初回 prompt 前の ready gate は
32
+ busy 表示中(esc to interrupt)の Codex/Claude を ready と数えない。
33
+ v0.16/0.17 以来、親エージェントは aiterm 上で一切ブロックしない:
28
34
  agent session への send は常に非ブロック dispatch になり、完了待ちは `aiterm-wait` 一本
29
35
  (exit code が receipt の outcome を映す: 0=done / 3=timeout=未完了 / 4=closed)、初回 prompt 付き
30
36
  launch は structured receipt にコピペ可能な `wait_command` を含む。factory diagnostics と local
@@ -55,7 +61,7 @@ pty_read(id, { wait: true }) → 削減済みの出力を読む(完了
55
61
 
56
62
  ### 2. その端末の中に他のコーディングエージェントを起動する — オーケストレーションの旗艦
57
63
 
58
- 同じ primitive が別エージェントの TUI を宿す。4 つの起動ツールが、Claude/Codex/Grok/Composer の対話 TUI を新しい永続端末の中に起動し、`session_id` を返す。既存の人間向けtextに加えて`aiterm.agent-launch-result.v1` structured receiptも返すため、durable callerは表示文字列を解析せずsession handleを取得できる。以後は同じ `pty_read` / `pty_send` で継続操作する。**起動は常に managed**(aiterm 所有の Stop hook 付き)で、agent session への `pty_send` は非ブロックの **dispatch** になり `event_cursor` 入り receipt を即返す。完了通知は `aiterm-wait --session <id> --cursor <event_cursor>` をホストのバックグラウンドタスクとして実行し、exit 時に receipt の `outcome` で判定する(exit 0=done / 3=timeout=未完了・既定600秒 / 4=closed。親はブロックもポーリングもしない)。起動時 `prompt` を渡した launch は structured receipt にコピペ可能な `wait_command` と `event_cursor` を含む。durable machine callerは`claude_turn({ action:"issue"|"recover", session_id, operation_id, ... })`を使い、人間向けerror文字列を解析せず`accepted`/`pending`/`completed`/`unknown`を判定できる。recoveryは再送せず、検証済み完了だけがexact `raw_output`を持つ。通常の`pty_send`/`pty_read`は対話callerと人間向けに維持する。`C-c`後もmarkerを保持し、Stopが来なければsessionをcloseする。`claude_agent` と `codex_agent` の初回 `prompt` は ready gate 経由で送信して待たずに返る(Grok/Composer は argv 渡し)。手動でキー操作したい場合は `pty_open` で素の端末を開き vendor CLI を自分で起動する。
64
+ 同じ primitive が別エージェントの TUI を宿す。4 つの起動ツールが、Claude/Codex/Grok/Composer の対話 TUI を新しい永続端末の中に起動し、`session_id` を返す。既存の人間向けtextに加えて`aiterm.agent-launch-result.v1` structured receiptも返すため、durable callerは表示文字列を解析せずsession handleを取得できる。以後は同じ `pty_read` / `pty_send` で継続操作する。**起動は常に managed**(aiterm 所有の Stop hook 付き)で、agent session への `pty_send` は非ブロックの **dispatch** になり `event_cursor` 入り receipt を即返す。完了通知は `aiterm-wait --session <id> --cursor <event_cursor>` をホストのバックグラウンドタスクとして実行し、exit 時に receipt の `outcome` で判定する(exit 0=done / 3=timeout=未完了・既定600秒 / 4=closed。親はブロックもポーリングもしない)。起動時 `prompt` を渡した launch は structured receipt にコピペ可能な `wait_command` と `event_cursor`、そして `submit_residue` 観測を含む(true=prompt が composer に未 submit で残存している疑い=案内に従い画面確認から復旧 / false=残存観測せず・成立の保証ではない / null=対象外)。dispatch receipt にも同じ観測が付く。durable machine callerは`claude_turn({ action:"issue"|"recover", session_id, operation_id, ... })`を使い、人間向けerror文字列を解析せず`accepted`/`pending`/`completed`/`unknown`を判定できる。recoveryは再送せず、検証済み完了だけがexact `raw_output`を持つ。通常の`pty_send`/`pty_read`は対話callerと人間向けに維持する。`C-c`後もmarkerを保持し、Stopが来なければsessionをcloseする。`claude_agent` と `codex_agent` の初回 `prompt` は ready gate 経由で送信して待たずに返る(Grok/Composer は argv 渡し)。手動でキー操作したい場合は `pty_open` で素の端末を開き vendor CLI を自分で起動する。
59
65
 
60
66
  ```text
61
67
  codex_agent({ session_name: "codex1", cwd: "/repo",
@@ -301,7 +307,7 @@ consumer は `aiterm-runtime-errors snapshot` を読み、durable ingestion 後
301
307
 
302
308
  `pty_send` は送信前に破壊的コマンド(`rm -rf /`, `mkfs`, `dd of=/dev/…`, `DROP TABLE` 等)を遮断し(`force: true` で越える)、ESC・ブラケットペースト終端などをサニタイズする。`pty_read` は既定で制御文字を無害化して返す(`raw: true` はバイトをそのまま返す)。これは**サンドボックスではなく tripwire**([既知の制約](#既知の制約バグではなく仕様)参照)。
303
309
 
304
- 1回の `pty_send` が受理する本文はUTF-8で最大64KiB。同一sessionへの送信はaiterm processをまたいで直列化し、chunk同士の混線を防ぐ。macOSでは長いPTY入力の欠落を避けるためUTF-8境界を壊さない256-byte単位でtmux pasteし、Linux/WSLでは上限内を1回でpasteする。途中chunkが失敗した場合は部分送信済みであることを明示し、自動でEnterを押さない。送信processの異常終了でlockが残った場合は送信前にfail-closedする。そのsessionを `pty_close` して作り直すか、全sessionを破棄できる場合だけ `pty_kill_all` で安全に掃除する。
310
+ 1回の `pty_send` が受理する本文はUTF-8で最大64KiB。同一sessionへの送信はaiterm processをまたいで直列化し、chunk同士の混線を防ぐ。macOSでは長いPTY入力の欠落を避けるためUTF-8境界を壊さない256-byte単位でtmux pasteし、Linux/WSLでは上限内を1回でpasteする。agent dispatch の paste はさらに tmux bracketed paste(`paste-buffer -p`)を使う: bracketed paste mode を要求している pane(vendor TUI)へは各 chunk を `ESC[200~/201~` で包んで届け、チャンク投入中のキー解釈による語中文字化け・submit 取り落としを抑える。通常の shell 送信は不変。途中chunkが失敗した場合は部分送信済みであることを明示し、自動でEnterを押さない。送信processの異常終了でlockが残った場合は送信前にfail-closedする。そのsessionを `pty_close` して作り直すか、全sessionを破棄できる場合だけ `pty_kill_all` で安全に掃除する。
305
311
 
306
312
  ## 人が覗く
307
313
 
package/README.md CHANGED
@@ -24,11 +24,19 @@
24
24
 
25
25
  Twelve tools: six **PTY tools** — `pty_open` / `pty_send` / `pty_read` / `pty_key` / `pty_close` / `pty_list` — to open, drive, and read one persistent terminal, four **agent launchers** — `claude_agent` / `codex_agent` / `grok_agent` / `composer_agent` — that each start another coding agent's TUI inside a fresh one, `claude_turn` for durable structured issue/recovery, and `diagnostics` for safe factory readiness. The backend is **tmux**, so sessions survive even if the MCP server or the AI client restarts.
26
26
 
27
- **v0.17.0 was published on 2026-07-18.** A parent agent never blocks on aiterm:
28
- every send to an agent session is a non-blocking dispatch, completion is one
29
- universal `aiterm-wait` waiter whose exit codes mirror the receipt outcome
30
- (`0`=done / `3`=timeout, not finished / `4`=closed), and a launch with an
31
- initial prompt returns a ready-made `wait_command` in its structured receipt.
27
+ **v0.18.0 was published on 2026-07-18.** Field-failure hardening for agent
28
+ dispatch: every dispatch/launch receipt now carries a **submit-strand
29
+ observation** `submit_residue` (a bounded screen poll that reports when the
30
+ sent prompt is still sitting unsubmitted in the vendor TUI's composer —
31
+ positive evidence only, no auto-retry), agent prompt pastes are wrapped in
32
+ tmux **bracketed paste** (negotiated per pane) against mid-word corruption and
33
+ dropped submits, and the pre-prompt ready gate no longer counts a busy
34
+ Codex/Claude screen ("esc to interrupt") as ready. As of v0.16/0.17 a parent
35
+ agent never blocks on aiterm: every send to an agent session is a non-blocking
36
+ dispatch, completion is one universal `aiterm-wait` waiter whose exit codes
37
+ mirror the receipt outcome (`0`=done / `3`=timeout, not finished /
38
+ `4`=closed), and a launch with an initial prompt returns a ready-made
39
+ `wait_command` in its structured receipt.
32
40
  Factory diagnostics and the local runtime-error store collect only when
33
41
  canonical dotagents config explicitly sets `collection.enabled: true`;
34
42
  collection is off by default and performs no network I/O. It ships via
@@ -64,7 +72,7 @@ pty_read(id, { wait: true }) → read the token-reduced output, completion
64
72
 
65
73
  ### 2. Launch other coding agents into that terminal — the orchestration flagship
66
74
 
67
- The same primitive hosts another agent's TUI. Four launchers each start one vendor's interactive coding-agent TUI inside a fresh persistent terminal and return a `session_id`. Their existing human-readable text is accompanied by an `aiterm.agent-launch-result.v1` structured receipt, so durable callers never parse display text for the session handle; when the launch carries an initial `prompt`, the receipt also includes the `event_cursor` and a ready-made `wait_command` for the completion waiter. From there you drive it with the same `pty_read` / `pty_send` you'd use on any shell: read its output token-reduced, send it the next step. (The TUIs are full-screen apps, so `pty_read({ screen: true })` gives you the rendered view.) Every launch is **managed**: aiterm installs its own Stop hook, so turn completion is a first-class event. Sending to an agent session is a non-blocking **dispatch** — the call returns immediately with an `event_cursor`, and completion arrives via [`aiterm-wait`](#completion-push-for-parent-agents-aiterm-wait). Durable machine callers use `claude_turn({ action: "issue" | "recover", session_id, operation_id, ... })`: it returns fixed `accepted` / `pending` / `completed` / `unknown` states without parsing human-facing errors, never resends during recovery, and includes exact `raw_output` only for a verified completion. The same operation ID is carried through the dispatch receipt, active marker, Stop event, and result. The ordinary `pty_send` / `pty_read` surface remains available for interactive callers and humans. `C-c` keeps the marker for a delayed Stop; if no Stop arrives, close the session. An initial `prompt` on `claude_agent`/`codex_agent` is submitted through the same ready gate and the launcher returns without waiting; on Grok/Composer it is passed on the CLI's argv. This needs the vendor's own CLI installed and authenticated — see [Requirements](#requirements).
75
+ The same primitive hosts another agent's TUI. Four launchers each start one vendor's interactive coding-agent TUI inside a fresh persistent terminal and return a `session_id`. Their existing human-readable text is accompanied by an `aiterm.agent-launch-result.v1` structured receipt, so durable callers never parse display text for the session handle; when the launch carries an initial `prompt`, the receipt also includes the `event_cursor`, a ready-made `wait_command` for the completion waiter, and a `submit_residue` observation (`true` = the prompt is likely still sitting unsubmitted in the composer — the hint explains recovery; `false` = no residue observed, not a proof of submission; `null` = not applicable). From there you drive it with the same `pty_read` / `pty_send` you'd use on any shell: read its output token-reduced, send it the next step. (The TUIs are full-screen apps, so `pty_read({ screen: true })` gives you the rendered view.) Every launch is **managed**: aiterm installs its own Stop hook, so turn completion is a first-class event. Sending to an agent session is a non-blocking **dispatch** — the call returns immediately with an `event_cursor`, and completion arrives via [`aiterm-wait`](#completion-push-for-parent-agents-aiterm-wait). Durable machine callers use `claude_turn({ action: "issue" | "recover", session_id, operation_id, ... })`: it returns fixed `accepted` / `pending` / `completed` / `unknown` states without parsing human-facing errors, never resends during recovery, and includes exact `raw_output` only for a verified completion. The same operation ID is carried through the dispatch receipt, active marker, Stop event, and result. The ordinary `pty_send` / `pty_read` surface remains available for interactive callers and humans. `C-c` keeps the marker for a delayed Stop; if no Stop arrives, close the session. An initial `prompt` on `claude_agent`/`codex_agent` is submitted through the same ready gate and the launcher returns without waiting; on Grok/Composer it is passed on the CLI's argv. This needs the vendor's own CLI installed and authenticated — see [Requirements](#requirements).
68
76
 
69
77
  ```text
70
78
  codex_agent({ session_name: "codex1", cwd: "/repo",
@@ -307,7 +315,7 @@ When an agent's answer is longer than the on-screen tail (pane height ≈ 24 lin
307
315
 
308
316
  As of v0.16 a parent agent **never blocks** on aiterm — there is no wait parameter anywhere (v0.17 makes the waiter's exit codes mirror its outcome). The whole flow is dispatch + one universal waiter:
309
317
 
310
- 1. Launch the child (`claude_agent` / `codex_agent` / ...; every launch is managed). Send a turn with plain `pty_send` (or `claude_turn issue` for durable Claude operations). The call passes the TUI ready gate, submits, and returns immediately with an `event_cursor` in its structured receipt.
318
+ 1. Launch the child (`claude_agent` / `codex_agent` / ...; every launch is managed). Send a turn with plain `pty_send` (or `claude_turn issue` for durable Claude operations). The call passes the TUI ready gate, submits, and returns immediately with an `event_cursor` in its structured receipt — plus a `submit_residue` observation: `true` means the sent text still lingered in the composer after submit (likely stranded; inspect the screen before re-pressing Enter), `false` means no residue was observed (not a proof of submission), `null` means not applicable.
311
319
  2. Run `aiterm-wait --session <id> --cursor <event_cursor> [--operation sha256:<64hex>] [--timeout <sec>]` (a launch with an initial `prompt` returns this command ready-made as `wait_command` in its structured receipt). It observes the vendor Stop-hook completion event as a **pure reader** and exits with a one-line `aiterm.agent-wait-result.v1` receipt. **Exit ≠ done**: the receipt's `outcome` is authoritative, and the exit code mirrors it — `0` = `done`, `3` = `timeout` (the turn is **not** finished; default `--timeout` is 600 s), `4` = `closed`, `1` = error. On `timeout` just re-run the waiter with the same cursor. The `--cursor` boundary makes it start-order independent: no completion can slip past even if the waiter starts late.
312
320
  3. Host integration picks the invocation style: a harness that re-invokes its agent when a background task exits (Claude Code) runs the waiter **in the background** and gets woken with zero polling; a host without that mechanism runs it as a foreground shell command. Either way the aiterm MCP call itself never blocks.
313
321
  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.
@@ -324,7 +332,7 @@ As of v0.16 a parent agent **never blocks** on aiterm — there is no wait param
324
332
 
325
333
  Before sending, `pty_send` blocks destructive commands (`rm -rf /`, `mkfs`, `dd of=/dev/…`, `DROP TABLE`, …) — pass `force: true` to override — and sanitizes ESC / bracketed-paste terminators. `pty_read` neutralizes control characters in what it returns by default (`raw: true` returns the bytes verbatim). This is a **tripwire, not a sandbox** (see [Known constraints](#known-constraints-by-design-not-bugs)).
326
334
 
327
- Each `pty_send` accepts at most 64 KiB of UTF-8 text. Sends to the same session are serialized across aiterm processes so chunks cannot interleave. On macOS, text is pasted through tmux in UTF-8-safe 256-byte chunks to avoid the platform PTY truncation observed with long input; Linux and WSL use one bounded paste. If a later chunk fails, aiterm reports the partial-send state and does not press Enter automatically. A lock left by a terminated sender fails closed before sending; close and recreate that session (or use `pty_kill_all` when every session is disposable) to clean it up safely.
335
+ Each `pty_send` accepts at most 64 KiB of UTF-8 text. Sends to the same session are serialized across aiterm processes so chunks cannot interleave. On macOS, text is pasted through tmux in UTF-8-safe 256-byte chunks to avoid the platform PTY truncation observed with long input; Linux and WSL use one bounded paste. Agent dispatches additionally paste with tmux bracketed paste (`paste-buffer -p`): panes that requested bracketed-paste mode (the vendor TUIs) receive each chunk wrapped in `ESC[200~/201~`, hardening prompt injection against mid-word key-interpretation corruption and dropped submits; ordinary shell sends are unchanged. If a later chunk fails, aiterm reports the partial-send state and does not press Enter automatically. A lock left by a terminated sender fails closed before sending; close and recreate that session (or use `pty_kill_all` when every session is disposable) to clean it up safely.
328
336
 
329
337
  ## A human can watch
330
338
 
package/dist/core.js CHANGED
@@ -56,6 +56,12 @@ const AGENT_TUI_READY_TIMEOUT_MS = 30_000;
56
56
  const AGENT_TUI_READY_POLL_MS = 500;
57
57
  const AGENT_TUI_READY_STABLE_SAMPLES = 11;
58
58
  const AGENT_TUI_READY_LINES = 45;
59
+ // submit座礁観測(dispatch後にcomposerへ送信textが残存していないかの有界チェック)
60
+ const AGENT_SUBMIT_RESIDUE_DELAY_MS = 250;
61
+ const AGENT_SUBMIT_RESIDUE_POLL_MS = 300;
62
+ const AGENT_SUBMIT_RESIDUE_MAX_SAMPLES = 5;
63
+ const AGENT_SUBMIT_RESIDUE_TAIL_CHARS = 32;
64
+ const AGENT_SUBMIT_RESIDUE_MIN_TAIL_CHARS = 8;
59
65
  const GROK_AUTH_MAX_BYTES = 64 * 1024;
60
66
  const CLAUDE_RESULT_MAX_BYTES = 4 * 1024 * 1024;
61
67
  // 出力削減(RTK の CAP 思想を移植)
@@ -936,7 +942,7 @@ export function send(name, text, o = {}) {
936
942
  // sentinelを待つため、公開上の拒否は副作用ゼロでなければならない。
937
943
  if (!o.preserveAgentOperation && managedClaudeOperation(name) !== undefined) {
938
944
  throw new AitermError("managed Claude agent sessionへの通常送信はturn境界を失うため拒否します。" +
939
- 'pty_send(wait:"agent_done")を使うか、通常対話へ切り替えるならsessionをcloseしてagent_done:falseで起動し直してください。', 2);
945
+ "pty_send(forceなし=自動dispatch)を使うか、通常対話へ切り替えるならsessionをcloseしてpty_openから手動起動し直してください。", 2);
940
946
  }
941
947
  writeLastcmd(name, text); // read rtk の reducer 分類用(書換/mark 前の素のコマンド)
942
948
  if (o.rtk)
@@ -983,6 +989,8 @@ export function send(name, text, o = {}) {
983
989
  // -r: LF→CR 置換を無効化。-Sは対応新版だけでvis(3)制御文字変換を無効化する。
984
990
  // send 自身の raw/sanitize 契約だけを真実とし、tmux 側で黙って再変換させない。
985
991
  const pasteArgs = ["paste-buffer", "-d", "-r"];
992
+ if (o.bracketedPaste)
993
+ pasteArgs.push("-p");
986
994
  if (pasteSupportsNoSanitize)
987
995
  pasteArgs.push("-S");
988
996
  pasteArgs.push("-b", bufferName, "-t", name);
@@ -1015,7 +1023,7 @@ export function sendKey(name, key, o = {}) {
1015
1023
  const k = KEYMAP[key.toLowerCase()] ?? key;
1016
1024
  if (!o.preserveAgentOperation && managedClaudeOperation(name) !== undefined && k !== "C-c") {
1017
1025
  throw new AitermError("managed Claude agent sessionではturn相関を壊さないC-cだけをpty_keyで送れます。" +
1018
- "他の対話操作はpty_send(wait:\"agent_done\")、終了はpty_closeを使ってください。", 2);
1026
+ "他の対話操作はpty_send(自動dispatch)、終了はpty_closeを使ってください。", 2);
1019
1027
  }
1020
1028
  tmux("send-keys", "-t", name, k);
1021
1029
  return `sent key ${k} to ${name}`;
@@ -2284,6 +2292,9 @@ function bindAgentVendorSession(meta, ev) {
2284
2292
  meta.vendor_session_id = ev.vendor_session_id;
2285
2293
  }
2286
2294
  }
2295
+ // 復旧案内の aiterm-wait は --cursor 0 を明示する: cursor 省略時の既定は waiter 起動時 EOF のため、
2296
+ // 案内表示〜実行の間に done event が書かれていると読み飛ばして timeout まで座る。event file は
2297
+ // per-launch 新規作成+launch_id フィルタ付き走査なので、0 起点は取りこぼしゼロかつ安全。
2287
2298
  function bindCompletedInitialPrompt(meta) {
2288
2299
  if (meta.initial_prompt !== "pending" && meta.initial_prompt !== "sent")
2289
2300
  return;
@@ -2293,7 +2304,7 @@ function bindCompletedInitialPrompt(meta) {
2293
2304
  }
2294
2305
  const size = safeStatSize(meta.event_file);
2295
2306
  if (size === 0) {
2296
- throw new AitermError(`agent session '${meta.aiterm_session}' は起動時 prompt の完了待ちです。初回応答完了後に再度 pty_send(wait:"agent_done") してください。`, 2);
2307
+ throw new AitermError(`agent session '${meta.aiterm_session}' は起動時 prompt の完了待ちです。aiterm-wait --session ${meta.aiterm_session} --cursor 0 で完了(outcome=done)を確認してから再度操作してください。`, 2);
2297
2308
  }
2298
2309
  const text = readFileRange(meta.event_file, 0, size).toString("utf8");
2299
2310
  const lines = text.split("\n");
@@ -2305,7 +2316,7 @@ function bindCompletedInitialPrompt(meta) {
2305
2316
  if (!scanned.event) {
2306
2317
  const malformed = scanned.malformedEvents ? ` malformed_events=${scanned.malformedEvents}` : "";
2307
2318
  const partial = tail.trim() ? " partial_event=true" : "";
2308
- throw new AitermError(`agent session '${meta.aiterm_session}' は起動時 prompt の完了 event をまだ確認できません。初回応答完了後に再度 pty_send(wait:"agent_done") してください。${malformed}${partial}`, 2);
2319
+ throw new AitermError(`agent session '${meta.aiterm_session}' は起動時 prompt の完了 event をまだ確認できません。aiterm-wait --session ${meta.aiterm_session} --cursor 0 で完了(outcome=done)を確認してから再度操作してください。${malformed}${partial}`, 2);
2309
2320
  }
2310
2321
  bindAgentVendorSession(meta, scanned.event);
2311
2322
  setInitialPromptState(meta, "done");
@@ -2649,7 +2660,7 @@ function assertInitialPromptNotPendingForSend(name, force) {
2649
2660
  if (meta.initial_prompt !== "pending" && meta.initial_prompt !== "sent")
2650
2661
  return;
2651
2662
  throw new AitermError(`agent session '${name}' は起動時 prompt の完了待ちです。通常 pty_send は混入防止のため送信しません。` +
2652
- `完了後に pty_send(wait:"agent_done") するか、手動介入が必要な場合だけ force:true を明示してください。`, 2);
2663
+ `aiterm-wait --session ${name} --cursor 0 で完了(outcome=done)を確認してから再度 pty_send するか、手動介入が必要な場合だけ force:true を明示してください。`, 2);
2653
2664
  }
2654
2665
  // aiterm-wait の exit 契約(CLI と各所の案内文で共有する正)。exit≠完了: outcome が done の時だけ完了。
2655
2666
  export const AITERM_WAIT_OUTCOME_NOTE = `exit 0=done / 3=timeout(既定${DEFAULT_AGENT_DONE_TIMEOUT}秒・未完了) / 4=closed。receiptのoutcomeが正で、done以外は未完了`;
@@ -2724,6 +2735,18 @@ function isAgentTuiReady(kind, screen) {
2724
2735
  }
2725
2736
  return screen.includes("Grok Build") && /(^|\n|\s)❯/.test(screen);
2726
2737
  }
2738
+ // Codex/Claude は実行中に「(esc to interrupt)」を表示する(実機採取)。startup 側の処理
2739
+ // (MCP initialize 等)が走ったまま composer だけ描画されている画面は入力受付とみなさない。
2740
+ // Grok/Composer は busy 表示文字列の実機根拠が未採取のため対象外(誤ブロックで起動不能にしない)。
2741
+ const AGENT_TUI_BUSY_KINDS = new Set(["codex", "claude"]);
2742
+ const AGENT_TUI_BUSY_RE = /esc to interrupt/i;
2743
+ // ready gate 用: 入力欄マーカーがあっても busy 表示中は ready と数えない。
2744
+ // frontend 推定(inferAgentFrontend)は「agent TUI が前面か」を見るだけなので isAgentTuiReady のまま。
2745
+ function isAgentTuiIdleReady(kind, screen) {
2746
+ if (!isAgentTuiReady(kind, screen))
2747
+ return false;
2748
+ return !(AGENT_TUI_BUSY_KINDS.has(kind) && AGENT_TUI_BUSY_RE.test(screen));
2749
+ }
2727
2750
  async function waitAgentTuiReadyImpl(kind, sample, sleepFn, opts = {}) {
2728
2751
  const timeoutMs = opts.timeoutMs ?? AGENT_TUI_READY_TIMEOUT_MS;
2729
2752
  const pollMs = opts.pollMs ?? AGENT_TUI_READY_POLL_MS;
@@ -2735,7 +2758,7 @@ async function waitAgentTuiReadyImpl(kind, sample, sleepFn, opts = {}) {
2735
2758
  for (;;) {
2736
2759
  lastScreen = sample();
2737
2760
  samples++;
2738
- if (isAgentTuiReady(kind, lastScreen)) {
2761
+ if (isAgentTuiIdleReady(kind, lastScreen)) {
2739
2762
  readyStreak++;
2740
2763
  if (readyStreak >= stableSamples)
2741
2764
  return { ready: true, samples, lastScreen };
@@ -2754,6 +2777,68 @@ async function waitAgentTuiReady(name, meta, timeoutMs = AGENT_TUI_READY_TIMEOUT
2754
2777
  async function waitAgentTuiReadyByKind(name, kind, timeoutMs = AGENT_TUI_READY_TIMEOUT_MS) {
2755
2778
  return waitAgentTuiReadyImpl(kind, () => captureScreen(name, AGENT_TUI_READY_LINES), sleep, { timeoutMs });
2756
2779
  }
2780
+ function normalizeResidueText(s) {
2781
+ return s.replace(/\s+/g, "");
2782
+ }
2783
+ function agentSubmitResidueTail(text) {
2784
+ // 行単位でなく text 全体の正規化末尾から取る: 最終行が短い prompt(「以上」等の締め行)でも
2785
+ // 直前行の内容を含む末尾 32 codepoint で観測できる。composer は末尾(カーソル位置)を表示し、
2786
+ // submit 済みの transcript echo は長文では先頭側を表示するため、末尾一致は座礁側に偏る。
2787
+ const cps = [...normalizeResidueText(text)];
2788
+ if (cps.length < AGENT_SUBMIT_RESIDUE_MIN_TAIL_CHARS)
2789
+ return null;
2790
+ return cps.slice(-AGENT_SUBMIT_RESIDUE_TAIL_CHARS).join("");
2791
+ }
2792
+ function agentSubmitResidueOnScreen(kind, screen, tail) {
2793
+ const lines = screen.split("\n");
2794
+ // 入力欄マーカーは ready 判定と同じ記号を行頭基準で探す。submit 済みの transcript echo は
2795
+ // マーカー行より上に出るため、最後のマーカー行以降だけを composer 領域として見る。
2796
+ const markerRe = kind === "codex" ? /^\s*[›>]/ : kind === "claude" ? /^\s*❯/ : /(^|\s)❯/;
2797
+ let markerIdx = -1;
2798
+ for (let i = lines.length - 1; i >= 0; i--) {
2799
+ if (markerRe.test(lines[i])) {
2800
+ markerIdx = i;
2801
+ break;
2802
+ }
2803
+ }
2804
+ if (markerIdx < 0)
2805
+ return null;
2806
+ return normalizeResidueText(lines.slice(markerIdx).join("")).includes(tail);
2807
+ }
2808
+ async function detectAgentSubmitResidueImpl(kind, text, sample, sleepFn, opts = {}) {
2809
+ const tail = agentSubmitResidueTail(text);
2810
+ if (!tail)
2811
+ return { residue: null, samples: 0 };
2812
+ const delayMs = opts.delayMs ?? AGENT_SUBMIT_RESIDUE_DELAY_MS;
2813
+ const pollMs = opts.pollMs ?? AGENT_SUBMIT_RESIDUE_POLL_MS;
2814
+ const maxSamples = opts.maxSamples ?? AGENT_SUBMIT_RESIDUE_MAX_SAMPLES;
2815
+ if (delayMs > 0)
2816
+ await sleepFn(delayMs);
2817
+ let samples = 0;
2818
+ let last = null;
2819
+ for (let i = 0; i < maxSamples; i++) {
2820
+ last = agentSubmitResidueOnScreen(kind, sample(), tail);
2821
+ samples++;
2822
+ // 残存が消えた(または判定不能になった)時点で確定。true だけは描画遅延と区別するため
2823
+ // 全サンプル持続した場合にのみ報告する。
2824
+ if (last !== true)
2825
+ return { residue: last, samples };
2826
+ if (i < maxSamples - 1)
2827
+ await sleepFn(pollMs);
2828
+ }
2829
+ return { residue: true, samples };
2830
+ }
2831
+ async function detectAgentSubmitResidue(name, kind, text) {
2832
+ return detectAgentSubmitResidueImpl(kind, text, () => captureScreen(name, AGENT_TUI_READY_LINES), sleep);
2833
+ }
2834
+ export function agentSubmitResidueWarning(name, residue) {
2835
+ if (residue !== true)
2836
+ return "";
2837
+ return (`\n警告: submit_residue=true=送信 text が composer に残存しており submit 未成立の疑いがある` +
2838
+ `(実行中 turn への queued message が表示されている可能性もある)。` +
2839
+ `pty_read(${name}, screen:true) で状態を確認してから、座礁していれば pty_key(${name}, "Enter") で再 submit、` +
2840
+ `破棄するなら pty_key(${name}, "Escape") を使う。盲目的に Enter を送らない(queued だった場合の二重 submit 防止)。`);
2841
+ }
2757
2842
  async function settleAgentDoneScreenImpl(sample, sleepFn, opts = {}) {
2758
2843
  const minDelayMs = opts.minDelayMs ?? AGENT_DONE_SETTLE_MIN_MS;
2759
2844
  const pollMs = opts.pollMs ?? AGENT_DONE_SCREEN_SETTLE_POLL_MS;
@@ -2804,6 +2889,17 @@ export async function __testWaitAgentTuiReady(kind, samples, opts = {}) {
2804
2889
  }, opts);
2805
2890
  return { ...result, sleeps };
2806
2891
  }
2892
+ export function __testIsAgentTuiIdleReady(kind, screen) {
2893
+ return isAgentTuiIdleReady(kind, screen);
2894
+ }
2895
+ export async function __testDetectAgentSubmitResidue(kind, text, samples, opts = {}) {
2896
+ let i = 0;
2897
+ const sleeps = [];
2898
+ const result = await detectAgentSubmitResidueImpl(kind, text, () => samples[Math.min(i++, samples.length - 1)], async (ms) => {
2899
+ sleeps.push(ms);
2900
+ }, opts);
2901
+ return { ...result, sleeps };
2902
+ }
2807
2903
  export function __testSetAgentTuiReadyStableSamples(value) {
2808
2904
  if (value !== null && (!Number.isInteger(value) || value < 1 || value > 1000)) {
2809
2905
  throw new AitermError("test ready stable samplesが不正です", 2);
@@ -2818,6 +2914,7 @@ async function sendAgentPromptText(name, text) {
2818
2914
  mark: false,
2819
2915
  rtk: false,
2820
2916
  preserveAgentOperation: true,
2917
+ bracketedPaste: true,
2821
2918
  });
2822
2919
  await sleep(AGENT_SUBMIT_DELAY_MS);
2823
2920
  sendKey(name, "Enter", { preserveAgentOperation: true });
@@ -2838,6 +2935,7 @@ export async function sendInitialAgentPrompt(name, text, o = {}) {
2838
2935
  text: `initial_prompt=not_sent vendor=${meta.kind} ready=false samples=${ready.samples}\n` +
2839
2936
  `agent session '${name}' の ${agentLabel(meta.kind)} TUI が入力受付状態になりません。prompt は送信していません。`,
2840
2937
  event_cursor: null,
2938
+ submit_residue: null,
2841
2939
  };
2842
2940
  }
2843
2941
  const startOffset = safeStatSize(meta.event_file);
@@ -2853,11 +2951,14 @@ export async function sendInitialAgentPrompt(name, text, o = {}) {
2853
2951
  setInitialPromptState(meta, "failed");
2854
2952
  throw e;
2855
2953
  }
2954
+ const residue = await detectAgentSubmitResidue(name, meta.kind, text);
2856
2955
  return {
2857
2956
  text: `initial_prompt=pending vendor=${meta.kind} event_cursor=${startOffset}\n` +
2858
2957
  `起動時 prompt を送信した。完了通知は aiterm-wait --session ${name} --cursor ${startOffset} をホストのバックグラウンドタスクとして実行し、` +
2859
- `exit 時に receipt の outcome で判定する(${AITERM_WAIT_OUTCOME_NOTE})。回収は pty_read(agent_transcript:true) を使う。`,
2958
+ `exit 時に receipt の outcome で判定する(${AITERM_WAIT_OUTCOME_NOTE})。回収は pty_read(agent_transcript:true) を使う。` +
2959
+ agentSubmitResidueWarning(name, residue.residue),
2860
2960
  event_cursor: startOffset,
2961
+ submit_residue: residue.residue,
2861
2962
  };
2862
2963
  }
2863
2964
  export function isAgentSession(name) {
@@ -2900,10 +3001,12 @@ export async function dispatchAgentTurn(name, text, o = {}) {
2900
3001
  mark: false,
2901
3002
  rtk: false,
2902
3003
  preserveAgentOperation: meta.kind === "claude",
3004
+ bracketedPaste: true,
2903
3005
  });
2904
3006
  // Codex TUI は literal text 投入直後の Enter を取り落とすことがある。agent 経路だけ submit を分離する。
2905
3007
  await sleep(AGENT_SUBMIT_DELAY_MS);
2906
3008
  sendKey(name, "Enter", { preserveAgentOperation: meta.kind === "claude" });
3009
+ const residue = await detectAgentSubmitResidue(name, meta.kind, text);
2907
3010
  return {
2908
3011
  schema: "aiterm.agent-dispatch.v1",
2909
3012
  session_id: meta.aiterm_session,
@@ -2911,6 +3014,7 @@ export async function dispatchAgentTurn(name, text, o = {}) {
2911
3014
  vendor: meta.kind,
2912
3015
  event_cursor: startOffset,
2913
3016
  operation_id: operationId,
3017
+ submit_residue: residue.residue,
2914
3018
  };
2915
3019
  }
2916
3020
  export async function runClaudeOperation({ session_id: name, action, operation_id: operationIdInput, text, }) {
@@ -2922,22 +3026,25 @@ export async function runClaudeOperation({ session_id: name, action, operation_i
2922
3026
  const meta = loadAgentMetadata(name);
2923
3027
  if (meta.kind !== "claude")
2924
3028
  throw new AitermError("claude_turnはmanaged Claude agent sessionだけで使用できます", 2);
3029
+ let dispatchReceipt = null;
2925
3030
  if (action === "issue") {
2926
3031
  if (typeof text !== "string" || text.length === 0) {
2927
3032
  throw new AitermError("claude_turn issueには空でないtextが必要です", 2);
2928
3033
  }
2929
3034
  // v0.16.0: issue は dispatch-only。完了通知は aiterm-wait --operation、回収は recover が担う。
2930
- await dispatchAgentTurn(name, text, { operation_id: operationId });
3035
+ dispatchReceipt = await dispatchAgentTurn(name, text, { operation_id: operationId });
2931
3036
  }
2932
3037
  else {
2933
3038
  if (text != null)
2934
3039
  throw new AitermError("claude_turn recoverにtextは指定できません", 2);
2935
3040
  }
2936
3041
  const inspected = inspectClaudeOperation(meta, operationId, action);
2937
- if (action === "issue" && inspected.status === "pending") {
2938
- return { ...inspected, status: "accepted" };
3042
+ // issue は dispatch の submit 座礁観測を捨てずに返す(観測を払ったのに信号を返さない契約矛盾を作らない)。
3043
+ const result = dispatchReceipt ? { ...inspected, submit_residue: dispatchReceipt.submit_residue } : inspected;
3044
+ if (action === "issue" && result.status === "pending") {
3045
+ return { ...result, status: "accepted" };
2939
3046
  }
2940
- return inspected;
3047
+ return result;
2941
3048
  }
2942
3049
  function inspectClaudeOperation(meta, operationId, action) {
2943
3050
  const base = {
@@ -2945,6 +3052,7 @@ function inspectClaudeOperation(meta, operationId, action) {
2945
3052
  action,
2946
3053
  session_id: meta.aiterm_session,
2947
3054
  operation_id: operationId,
3055
+ submit_residue: null,
2948
3056
  };
2949
3057
  const active = readClaudeOperationMarker(meta);
2950
3058
  if (active) {
@@ -3321,7 +3429,7 @@ export function openAgent(kind, opts = {}) {
3321
3429
  }
3322
3430
  const driveHint = agentDone && kind === "claude"
3323
3431
  ? `TUI の描画には数秒かかる。少し置いてから pty_read(${sid}, screen:true) で画面を読み、` +
3324
- `turnはpty_send(${sid}, "...", wait:"agent_done")で送る。中断はpty_key(${sid}, "C-c")、` +
3432
+ `turnはpty_send(${sid}, "...")で送る(自動で非ブロックdispatch・完了通知はaiterm-wait)。中断はpty_key(${sid}, "C-c")、` +
3325
3433
  `Stopが来ない場合の解除はpty_close(${sid})を使う。`
3326
3434
  : `TUI の描画には数秒かかる。少し置いてから pty_read(${sid}, screen:true) で画面を読み、` +
3327
3435
  `pty_send(${sid}, "...") で入力・pty_key(${sid}, "Enter"/"Up"/"C-c" 等) で操作する(対話)。`;
@@ -3352,7 +3460,8 @@ export async function openAgentWithInitialPrompt(kind, opts = {}) {
3352
3460
  agent_done: true,
3353
3461
  launch_operation_id: opts.launch_operation_id ?? null,
3354
3462
  });
3355
- return [sid, hint, prompt ? 0 : null];
3463
+ // argv prompt(grok/composer)は composer を経由しないため submit 座礁観測の対象外。
3464
+ return [sid, hint, prompt ? 0 : null, null];
3356
3465
  }
3357
3466
  const [sid, hint] = openAgent(kind, {
3358
3467
  session_name: opts.session_name ?? null,
@@ -3367,7 +3476,7 @@ export async function openAgentWithInitialPrompt(kind, opts = {}) {
3367
3476
  const initial = await sendInitialAgentPrompt(sid, prompt, {
3368
3477
  ready_timeout: opts.ready_timeout ?? undefined,
3369
3478
  });
3370
- return [sid, `${hint}\n${initial.text}`, initial.event_cursor];
3479
+ return [sid, `${hint}\n${initial.text}`, initial.event_cursor, initial.submit_residue];
3371
3480
  }
3372
3481
  catch (e) {
3373
3482
  const code = e instanceof AitermError ? e.code : 1;
package/dist/index.js CHANGED
@@ -119,6 +119,9 @@ server.registerTool("pty_send", {
119
119
  event_cursor: z.number().int().nullable(),
120
120
  launch_id: z.string().nullable(),
121
121
  vendor: z.enum(["claude", "codex", "grok", "composer"]).nullable(),
122
+ // dispatch後のsubmit座礁観測(additive)。true=composerに送信textの残存を確認(submit未成立の疑い)/
123
+ // false=残存を観測せず(成立の保証ではない)/ null=通常送信・判定不能。
124
+ submit_residue: z.boolean().nullable(),
122
125
  },
123
126
  }, async ({ session_id, text, enter, mark, force, rtk, raw }) => {
124
127
  try {
@@ -136,7 +139,8 @@ server.registerTool("pty_send", {
136
139
  type: "text",
137
140
  text: `dispatchした(vendor=${receipt.vendor})。完了通知: aiterm-wait --session ${receipt.session_id} --cursor ${receipt.event_cursor} を` +
138
141
  `ホストのバックグラウンドタスクとして実行し、exit時にreceiptのoutcomeで判定(${core.AITERM_WAIT_OUTCOME_NOTE})。` +
139
- "回収: pty_read(agent_transcript:true)",
142
+ "回収: pty_read(agent_transcript:true)" +
143
+ core.agentSubmitResidueWarning(receipt.session_id, receipt.submit_residue),
140
144
  },
141
145
  ],
142
146
  structuredContent: {
@@ -146,6 +150,7 @@ server.registerTool("pty_send", {
146
150
  event_cursor: receipt.event_cursor,
147
151
  launch_id: receipt.launch_id,
148
152
  vendor: receipt.vendor,
153
+ submit_residue: receipt.submit_residue,
149
154
  },
150
155
  };
151
156
  }
@@ -159,6 +164,7 @@ server.registerTool("pty_send", {
159
164
  event_cursor: null,
160
165
  launch_id: null,
161
166
  vendor: null,
167
+ submit_residue: null,
162
168
  },
163
169
  };
164
170
  }
@@ -311,6 +317,9 @@ server.registerTool("claude_turn", {
311
317
  operation_id: z.string().regex(/^sha256:[0-9a-f]{64}$/),
312
318
  raw_output: z.string().nullable(),
313
319
  reason: z.enum(["operation_not_found", "result_unknown"]).nullable(),
320
+ // issue時のみdispatch由来のsubmit座礁観測(additive)。true=composerに残存を確認(submit未成立の疑い)/
321
+ // false=残存を観測せず(成立の保証ではない)/ recover等はnull。
322
+ submit_residue: z.boolean().nullable(),
314
323
  },
315
324
  }, async ({ action, session_id, operation_id, text }) => {
316
325
  try {
@@ -381,10 +390,13 @@ function registerAgentTool(toolName, kind, desc) {
381
390
  // バックグラウンドタスクとして実行できる完了待ちコマンド。
382
391
  event_cursor: z.number().int().nullable(),
383
392
  wait_command: z.string().nullable(),
393
+ // 初回prompt dispatch後のsubmit座礁観測(additive)。true=composerに残存を確認(submit未成立の疑い)/
394
+ // false=残存を観測せず(submit成立の保証ではない)/ null=promptなし・argv prompt・判定不能。
395
+ submit_residue: z.boolean().nullable(),
384
396
  },
385
397
  }, async ({ prompt, model, reasoning_effort, cwd, session_name, launch_operation_id }) => {
386
398
  try {
387
- const [sid, hint, eventCursor] = await core.openAgentWithInitialPrompt(kind, {
399
+ const [sid, hint, eventCursor, submitResidue] = await core.openAgentWithInitialPrompt(kind, {
388
400
  prompt: prompt ?? undefined,
389
401
  model: model ?? undefined,
390
402
  reasoning_effort: reasoning_effort ?? undefined,
@@ -399,6 +411,7 @@ function registerAgentTool(toolName, kind, desc) {
399
411
  managed_completion: true,
400
412
  event_cursor: eventCursor,
401
413
  wait_command: eventCursor === null ? null : `aiterm-wait --session ${sid} --cursor ${eventCursor}`,
414
+ submit_residue: submitResidue,
402
415
  };
403
416
  return {
404
417
  content: [{ type: "text", text: `session_id: ${sid}\n${hint}` }],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiterm-mcp",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "mcpName": "io.github.kitepon-rgb/aiterm-mcp",
5
5
  "description": "AI-driven persistent terminal as a local stdio MCP server (tmux-backed). Holds one local PTY; SSH and containers are just commands you send into it. Also launches interactive Claude/Codex/Grok/Composer agent TUIs in a persistent terminal. Token-reducing reads.",
6
6
  "keywords": [