aiterm-mcp 0.33.1 → 0.35.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/CHANGELOG.md CHANGED
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.35.0] - 2026-09-10
11
+
12
+ ### Added
13
+
14
+ - Claude Code親への回答自動配送を追加した。公式`asyncRewake` hookが子の回答を受け取り、idle中の親も再開する。待機中も親は別作業と次のturnへ進める。
15
+ - `aiterm-setup`がClaude Codeの専用hookを登録し、MCP要求とhookの実際の会話を照合する。親のwaiter・回答回収と子への返送指示は不要。
16
+ - `/clear`等の会話終了後は未送信の旧回答を新しい会話へ出さず、本文を保持して配送失敗を明示する。hookの出力中断は結果不明とし、自動再送しない。
17
+
18
+ ### Fixed
19
+
20
+ - Grok/Composerで追加メッセージが直ちに次turnを開始した場合も、完了したturnの回答を指定して回収する。最新turnの切替で前の回答の自動配送が失敗する競合を修正した。
21
+
22
+ ### Compatibility
23
+
24
+ - Claude Code親は2.1.259以上の対話sessionと有効なcommand hookを必要とする。Claude Desktopチャット・Web・`agent_id`付きの会話(`--agent`起動とnative subagent)は対象外。起動時のChannels flagは不要。
25
+ - Claude用配送記録を分け、旧版のCodex readerとの互換性を維持する。旧版へ戻す前に`aiterm-setup --remove-claude-parent-hooks`でAiterm専用hookだけを解除する。
26
+
27
+ ## [0.34.0] - 2026-09-10
28
+
29
+ ### Added
30
+
31
+ - Codex親への子の回答自動配送を追加した。起動・通常dispatch・Claude durable turnの完了をAitermが観測し、回答本文を公式受信キューへ送る。親の待機コマンドと回答回収、子への送信指示は不要になった。
32
+ - 配送IDと状態をreceipt/`pty_observe`へ追加した。再接続後は未送信の記録を再開し、送信結果が不明な場合は本文を保持して自動再送しない。
33
+ - `aiterm-setup`はCodexの公式queue入口を確認する。各dispatchでMCP要求の親threadを確認し、未対応の受信口へ子を送らない。
34
+
35
+ ### Compatibility
36
+
37
+ - Codex親は公式の`_meta.threadId`と`thread/queue` APIを提供する環境が必要。Codex CLI 0.154.0で確認した。native sub-agentを親とする外部queue入力はCodexの制約により未対応。
38
+ - Claude等の親の既存waiter契約は維持する。旧版へ戻すと新しい配送記録は処理されないが、既存PTY/harness stateの形式は変わらない。
39
+
10
40
  ## [0.33.1] - 2026-09-09
11
41
 
12
42
  ### Fixed
@@ -1604,7 +1634,9 @@ prototype (preserved under `prototype/python/` as the porting source and referen
1604
1634
  `ubuntu-latest` for Node 18/20/22, publishing to npm on `v*` tags with
1605
1635
  provenance.
1606
1636
 
1607
- [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.33.1...HEAD
1637
+ [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.35.0...HEAD
1638
+ [0.35.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.34.0...v0.35.0
1639
+ [0.34.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.33.1...v0.34.0
1608
1640
  [0.33.1]: https://github.com/kitepon/aiterm-mcp/compare/v0.33.0...v0.33.1
1609
1641
  [0.33.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.32.0...v0.33.0
1610
1642
  [0.32.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.31.2...v0.32.0
package/README.ja.md CHANGED
@@ -163,7 +163,7 @@ v0.20では、待たずに一度だけ観測する
163
163
  `running`(exit 5)で返すようにしました。v0.19系では相関済みClaude approval中継を追加し、
164
164
  複数行shell配送を維持し、native Windowsのfactory diagnosticsを拡張しました。
165
165
  v0.16/0.17以来、親エージェントはaiterm上で一切ブロックしません:
166
- agent session への send は常に非ブロック dispatch になり、完了待ちは `aiterm-wait` 一本
166
+ agent session への send は常に非ブロック dispatch になり、Codex/Claude Code親には回答本文を自動配送し、それ以外の親の完了待ちは `aiterm-wait`
167
167
  (exit code が receipt の outcome を映す: 0=done / 3=timeout=未完了 /
168
168
  4=closed / 5=running=待たない観測)、初回 prompt 付き
169
169
  launch は structured receipt にコピペ可能な `wait_command` を含む。factory diagnostics と local
@@ -171,7 +171,7 @@ runtime-error store は canonical dotagents config の `collection.enabled: true
171
171
  場合だけ収集し、既定OFF、network送信は行いません。tag起点CIのnpm provenance(OIDC Trusted
172
172
  Publishing)で公開し、GitHub Release が Official MCP Registry を再登録します。
173
173
 
174
- **状態:** 開発継続中 · 現行公開版 **v0.33.1** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
174
+ **状態:** 開発継続中 · 現行公開版 **v0.35.0** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
175
175
 
176
176
  ### 更新と巻き戻し
177
177
 
@@ -205,7 +205,7 @@ pty_read(id, { wait: true }) → 削減済みの出力を読む(完了
205
205
 
206
206
  同じ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・置換しない。
207
207
 
208
- `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`を返し、完了通知は`aiterm-wait`を親のターンを塞がない別processで受ける。Cursorのsubmitキーはadapterが現行CLIのextended keyboard protocolへ変換する。送信textがCursorのcomposerへ残った場合は成功receiptを返さず失敗する。
208
+ `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親は公式受信キュー、Claude Code親は公式非同期hookで回答本文を自動受信する。それ以外の親は`aiterm-wait`を親のターンを塞がない別processで受ける。Cursorのsubmitキーはadapterが現行CLIのextended keyboard protocolへ変換する。送信textがCursorのcomposerへ残った場合は成功receiptを返さず失敗する。
209
209
 
210
210
  `agent_launch`・`pty_send`(agent dispatch)・`agent_steer`は任意の`image`(画像ファイルの絶対パスの配列。png/jpg/jpeg/gif/webp)を受ける。aitermが本文末尾へ添付行を付け、どのharnessも自分のfile読取toolでそのpathを画像として開く。呼出し側はharness別の添付手順を覚えない。不正なpathは送信前に拒否する。
211
211
 
@@ -225,6 +225,7 @@ agent_launch({ harness: "codex-cli", session_name: "codex1", cwd: "/repo",
225
225
  → { session_id: "codex1", … } # Codex が永続端末で稼働開始
226
226
  pty_read("codex1", { screen: true }) → 何をしているか読む(トークン削減)
227
227
  pty_send("codex1", "also fix the imports it broke") # 非ブロックdispatch=event_cursor入りreceipt
228
+ # Codex/Claude Code親には回答が自動で届く。それ以外の親:
228
229
  $ aiterm-wait --session codex1 --cursor <event_cursor> # exit 0=done / 3=timeout(未完了) / 4=closed / 7=error(APIエラー等でturn打ち切り)。回収は pty_read(agent_transcript:true)
229
230
  → 操舵し、Codex の次の入力境界で返る
230
231
  ```
@@ -513,6 +514,21 @@ SSH先がPowerShellの場合、`mark:true`は現在の標準`PS ...>`プロン
513
514
 
514
515
  `pty_read({ wait: true })`は通常PTYを、process終了/`mark:true` sentinel/`until`一致/shell復帰を伴う出力静止/timeoutの5層で判定する。agent sessionは第6の正確な層を使い、Codexは通常rollout、Grokは通常session event、Claudeはlaunch相関Stop event、Cursorは通常agent transcriptの`turn_ended`を`aiterm-wait --cursor`が観測する。親はブロックもポーリングもしない。
515
516
 
517
+ ### Codex/Claude Code親への回答自動配送
518
+
519
+ Codex/Claude Codeから子を起動・通常dispatchした後は、別作業へ進むか親のturnを終了するだけでよい。Aitermが完了を観測し、加工前の回答を保存して親へ届ける。waiter、`pty_read`による回答回収、子への送信指示は不要。子は全対応harnessから選べる。
520
+
521
+ 自動配送時はreceiptに`parent_delivery`が付き、`wait_process`/`wait_command`はnullになる。`pty_observe`の`parent_deliveries`で`waiting`、`ready`、`sending`、`submitted`、`failed`、`unknown`を確認できる。`submitted`はCodexのキュー受付またはClaudeのhookへの本文出力を示し、modelの読了ではない。MCP再接続後は未送信の記録を再開し、送信中に接続が切れて結果が分からない場合は本文を保持して`unknown`とする。自動再送はしない。
522
+
523
+ CodexにはMCPの`_meta.threadId`と公式`thread/queue` APIが必要で、Codex CLI 0.154.0で確認している。`aiterm-setup`はインストールされた公式queue入口を確認し、各dispatchでは実際の親threadの受入可否を確認する。Codexのnative sub-agentは外部からのqueue入力を拒否するため、自動配送の親としては未対応。
524
+
525
+ Claude Codeは2.1.259以上の対話sessionに対応する。`aiterm-setup`が専用の`PreToolUse`、`PostToolUse`、`SessionEnd`を登録するため、Channelsの起動flagは不要。公式`asyncRewake` hookだけが裏で待ち、親はその間も次のturnへ進める。回答は`Stop hook feedback`として届く。hookのexit 2は親の再開信号であり、子の成功・失敗は本文の`outcome`で区別する。
526
+
527
+ `/clear`などで会話を終了すると未送信の旧回答の配送を止め、本文は保存する。受信hookの上限は24時間で、終了や出力失敗を成功扱いしない。hookが無効な場合は送信前に明示errorにし、waiterへ黙って切り替えない。Claude Desktopのチャット、Web、`agent_id`付きの会話(`--agent`起動とnative subagent)はこの受信契約に含めない。
528
+
529
+ hookを持たない旧版へ戻す時は、install前に`aiterm-setup --remove-claude-parent-hooks`を実行する。Aiterm専用hookだけを解除し、他製品のhookと設定は保持する。
530
+
531
+
516
532
  ### トークン削減
517
533
 
518
534
  - `pty_read` は既定で制御文字除去・連続重複圧縮・head+tail 折りたたみ(+復元ヒント・メタ併記)をかける。
package/README.md CHANGED
@@ -176,18 +176,16 @@ a non-blocking `aiterm-wait --timeout 0` observation (`running`, exit 5) from a
176
176
  wait. The v0.19 line added the correlated Claude approval relay,
177
177
  preserved multiline shell delivery, and extended factory diagnostics on native
178
178
  Windows. As of v0.16/0.17 a parent agent never blocks on aiterm:
179
- every send to an agent session is a non-blocking dispatch, completion is one
180
- universal `aiterm-wait` waiter whose exit codes mirror the receipt outcome
181
- (`0`=done / `3`=timeout, not finished / `4`=closed / `5`=running for a
182
- zero-time observation), and a launch with an
183
- initial prompt returns a ready-made `wait_command` in its structured receipt.
179
+ agent sessionへの送信は非ブロックdispatchであり、Codex/Claude Code親には回答本文を自動配送する。
180
+ それ以外の親はreceiptのprocess起動情報で`aiterm-wait`を実行する。
181
+ 終了コードは`0`=done、`3`=timeout、`4`=closed、待機しない照会の`5`=runningを表す。
184
182
  Factory diagnostics and the local runtime-error store collect only when
185
183
  canonical dotagents config explicitly sets `collection.enabled: true`;
186
184
  collection is off by default and performs no network I/O. It ships via
187
185
  tag-triggered CI with npm provenance (OIDC Trusted Publishing); the GitHub
188
186
  Release re-registers the Official MCP Registry entry.
189
187
 
190
- **Status:** actively maintained · current public release **v0.33.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).
188
+ **Status:** actively maintained · current public release **v0.35.0** · 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).
191
189
 
192
190
  ### Update and rollback
193
191
 
@@ -227,7 +225,7 @@ pty_read(id, { wait: true }) → read the token-reduced output, completion
227
225
 
228
226
  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`.
229
227
 
230
- The human-readable launch text is accompanied by an `aiterm.agent-launch-result.v1` structured receipt containing the canonical `harness`; the old `provider` field remains for compatibility. The same `harness` is carried by agent dispatch, `aiterm-wait`, `agent_configure`, and agent rows in `pty_list`, while their old vendor/provider/agent fields remain compatibility fields. Codex completion comes from its normal durable rollout transcript, Grok CLI from its normal session events, Claude Code from a launch-specific Stop hook settings addition, and Cursor from its normal agent transcript's terminal `turn_ended` record. Sending to any agent session is a non-blocking **dispatch** — the call returns immediately with an opaque, harness-specific integer `event_cursor`, and completion arrives via [`aiterm-wait`](#completion-push-for-parent-agents-aiterm-wait). The Cursor adapter translates submit into the current CLI's extended keyboard protocol. If submitted text remains in Cursor's composer, its dispatch fails instead of returning a successful receipt.
228
+ 起動結果には正規`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親は公式queue、Claude Code親は公式非同期hookで本文を自動受信する。他の親は[`aiterm-wait`](#completion-push-for-parent-agents-aiterm-wait)を使う。CursorのsubmitはadapterがCLIのextended keyboard protocolへ変換し、送信本文がcomposerへ残る場合は明示errorにする。
231
229
 
232
230
  `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.
233
231
 
@@ -250,6 +248,7 @@ agent_launch({ harness: "codex-cli", session_name: "codex1", cwd: "/repo",
250
248
  pty_read("codex1", { screen: true }) → read what it's doing (token-reduced)
251
249
  pty_send("codex1", "also fix the imports it broke")
252
250
  → non-blocking dispatch; receipt carries event_cursor
251
+ # Codex/Claude Code親には回答が自動で届く。それ以外の親:
253
252
  $ aiterm-wait --session codex1 --cursor <event_cursor> # never in the parent's foreground; exit 0=done, 3=timeout (not done), 4=closed, 7=error (turn aborted by an API error)
254
253
  pty_read("codex1", { agent_transcript: true }) → collect the full answer
255
254
  ```
@@ -550,11 +549,23 @@ For PowerShell over SSH, `mark:true` recognizes the current standard `PS ...>` p
550
549
 
551
550
  ### Completion push for parent agents (`aiterm-wait`)
552
551
 
553
- 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:
552
+ **Codex/Claude Code親には子の回答本文が自動で届く。** 子を起動・dispatchした後は、別作業へ進むか親のturnを終える。Aitermが完了を観測し、加工前の本文を保存して親へ渡す。waiter、`pty_read`による回答回収、子への返送指示は不要。子は全対応harnessから選べる。
553
+
554
+ 自動配送のreceiptには`parent_delivery`が付き、`wait_process`/`wait_command`はnullになる。`pty_observe`の`parent_deliveries`で`waiting`、`ready`、`sending`、`submitted`、`failed`、`unknown`を確認できる。`submitted`はCodexのキュー受付またはClaudeのhookへの本文出力を示し、modelの読了ではない。MCP再接続後は未送信の記録を再開し、出力中断で結果が分からない場合は本文を保持して`unknown`とする。自動再送はしない。
555
+
556
+ Use a Codex runtime that supplies MCP `_meta.threadId` and the official `thread/queue` API (verified with Codex CLI 0.154.0). `aiterm-setup` checks the installed queue entry point; Aiterm verifies the requesting thread before each dispatch. Codex native sub-agents reject external queue input and cannot be automatic-delivery parents. Ordinary CLI and Desktop parents use the same supported route.
557
+
558
+ Claude Codeは2.1.259以上の対話sessionに対応する。`aiterm-setup`が専用の`PreToolUse`、`PostToolUse`、`SessionEnd`を登録するため、Channelsの起動flagは不要。公式`asyncRewake` hookだけが裏で待ち、親はその間も次のturnへ進める。回答は`Stop hook feedback`として届く。hookのexit 2は親の再開信号であり、子の成功・失敗は本文の`outcome`で区別する。
559
+
560
+ `/clear`等の会話終了後は未送信の旧回答を送らず、本文を保存する。受信hookの上限は24時間。hookの終了・出力失敗・無効化を成功扱いせず、別の待機経路へ黙って切り替えない。Claude Desktopのチャット、Web、`agent_id`付きの会話(`--agent`起動とnative subagent)はこの受信契約に含めない。
561
+
562
+ hookを持たない旧版へ戻す時は、install前に`aiterm-setup --remove-claude-parent-hooks`を実行する。Aiterm専用hookだけを解除し、他製品のhookと設定は保持する。
563
+
564
+ **For other parent hosts**, dispatch and start the receipt's waiter in a separate process:
554
565
 
555
566
  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.
556
567
  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).
557
- 3. **The parent never runs the waiter in its own foreground.** Waiting is correct — but the waiter is a separate process, not the parent's turn. 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. So that this is not left to interpretation, aiterm reads `clientInfo.name` from the MCP `initialize` handshake and its receipts name the concrete invocation for the detected host — for Claude Code, literally `Bash(command: "aiterm-wait …", run_in_background: true)`. Unknown or undeclared hosts get the generic "start it as a process that does not block the parent's turn" wording; nothing else about the contract changes. Every receipt leads with the same rule: dispatch and let go, then go do something else or end the turn.
568
+ 3. **親自身のforegroundでwaiterを実行しない。** receiptのprocess起動情報を、そのhostが持つバックグラウンドprocess APIへ渡す。親は別作業へ進むかturnを終え、process終了の通知で続行する。
558
569
  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.
559
570
 
560
571
  **If your host has no completion push** (no mechanism that re-invokes the agent when a background process exits), `--timeout 0` is a one-shot check instead of a wait: it scans the event file once and returns `running` (exit `5`) when the turn is still in flight, `done` (exit `0`) when it finished, `closed` (exit `4`) when the session is gone. It is deliberately absent from the receipts and tool descriptions — a host that *does* get pushed should be woken, not poll. An unknown session name is an error, never `running`, so a typo cannot masquerade as a child that is still working.
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ // Claude Codeが直接起動する公式hook。MCP stdioとは別processで本文をstderrへ返す。
3
+ import { prepareClaudeHookRequest, runClaudeResultHook, closeClaudeParentSession } from "./claude-parent-receiver.js";
4
+ async function main() {
5
+ let input = "";
6
+ process.stdin.setEncoding("utf8");
7
+ for await (const chunk of process.stdin)
8
+ input += chunk;
9
+ const event = JSON.parse(input);
10
+ switch (event.hook_event_name) {
11
+ case "PreToolUse":
12
+ prepareClaudeHookRequest(event);
13
+ break;
14
+ case "PostToolUse":
15
+ process.exitCode = await runClaudeResultHook(event, text => new Promise((resolve, reject) => {
16
+ process.stderr.write(text, error => error ? reject(error) : resolve());
17
+ }));
18
+ break;
19
+ case "SessionEnd":
20
+ closeClaudeParentSession(event);
21
+ break;
22
+ default: throw new Error("CLAUDE_PARENT_HOOK_EVENT_INVALID: 未対応のhookです");
23
+ }
24
+ }
25
+ main().catch(error => {
26
+ process.stderr.write(`${error instanceof Error ? error.message : "CLAUDE_PARENT_HOOK_FAILED"}\n`);
27
+ process.exitCode = 2;
28
+ });
@@ -0,0 +1,214 @@
1
+ // Claude Codeの公式hookを受信口にする。待機processはharnessが所有し、親のturnを止めない。
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { z } from "zod";
5
+ import { ensureStateRoot, writeJson0600 } from "./agent-shared.js";
6
+ import { readRuntimeProcesses } from "./process-runtime.js";
7
+ import { AitermError } from "./errors.js";
8
+ const requestId = z.string().regex(/^[A-Za-z0-9_-]{1,160}$/);
9
+ const invocationSchema = z.object({
10
+ request_id: requestId, session_id: z.uuid(), agent_id: z.string().nullable(),
11
+ parent_pid: z.number().int().positive(), parent_started_identity: z.string(),
12
+ }).strict();
13
+ export const claudeParentSchema = z.object({
14
+ kind: z.literal("claude"), request_id: requestId, session_id: z.uuid(), hook_root: z.string(),
15
+ }).strict();
16
+ export class ClaudeDeliveryError extends AitermError {
17
+ delivery_code;
18
+ outcome_unknown;
19
+ constructor(delivery_code, message, outcome_unknown = false) {
20
+ super(`${delivery_code}: ${message}`, 2);
21
+ this.delivery_code = delivery_code;
22
+ this.outcome_unknown = outcome_unknown;
23
+ }
24
+ }
25
+ function defaultRoot() { return path.join(ensureStateRoot(), "claude-parent-hooks"); }
26
+ function processIdentity(pid) { return readRuntimeProcesses().find(entry => entry.pid === pid)?.started_identity; }
27
+ function directory(parent) { return path.join(parent.hook_root, parent.request_id); }
28
+ function readInvocation(parent) {
29
+ let value;
30
+ try {
31
+ value = invocationSchema.parse(JSON.parse(fs.readFileSync(path.join(directory(parent), "request.json"), "utf8")));
32
+ }
33
+ catch {
34
+ throw new ClaudeDeliveryError("CLAUDE_PARENT_HOOK_UNAVAILABLE", "親のhook記録がありません。aiterm-setupを実行し、Claude Codeのhookを有効にしてください");
35
+ }
36
+ if (value.request_id !== parent.request_id || value.session_id !== parent.session_id) {
37
+ throw new ClaudeDeliveryError("CLAUDE_PARENT_SESSION_MISMATCH", "要求とhookの会話が一致しません");
38
+ }
39
+ return value;
40
+ }
41
+ function assertOpen(parent) {
42
+ if (fs.existsSync(path.join(directory(parent), "closed.json"))) {
43
+ throw new ClaudeDeliveryError("CLAUDE_PARENT_SESSION_CLOSED", "依頼元の会話は終了しました。回答を別の会話へ送っていません");
44
+ }
45
+ }
46
+ export function prepareClaudeHookRequest(input, root = defaultRoot()) {
47
+ const event = z.object({ session_id: z.uuid(), tool_use_id: requestId, agent_id: z.string().optional() }).parse(input);
48
+ const identity = processIdentity(process.ppid);
49
+ if (!identity)
50
+ throw new ClaudeDeliveryError("CLAUDE_PARENT_PROCESS_UNAVAILABLE", "hookを起動した親processを確認できません");
51
+ const dir = path.join(root, event.tool_use_id);
52
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
53
+ writeJson0600(path.join(dir, "request.json"), {
54
+ request_id: event.tool_use_id, session_id: event.session_id, agent_id: event.agent_id ?? null,
55
+ parent_pid: process.ppid, parent_started_identity: identity,
56
+ });
57
+ }
58
+ /** 起動時envのsession IDは/clearで古くなるため、実際のPreToolUseとの相関だけを使う。 */
59
+ export function claudeParentFromRequest(clientName, metadata, root = defaultRoot()) {
60
+ if (clientName !== "claude-code")
61
+ return null;
62
+ const parsed = requestId.safeParse(metadata?.["claudecode/toolUseId"]);
63
+ if (!parsed.success)
64
+ throw new ClaudeDeliveryError("CLAUDE_PARENT_ID_UNAVAILABLE", "MCP要求にtoolUseIdがありません。対応するClaude Codeへ更新してください");
65
+ let invocation;
66
+ try {
67
+ invocation = invocationSchema.parse(JSON.parse(fs.readFileSync(path.join(root, parsed.data, "request.json"), "utf8")));
68
+ }
69
+ catch {
70
+ throw new ClaudeDeliveryError("CLAUDE_PARENT_HOOK_UNAVAILABLE", "親のPreToolUse hookを確認できません。aiterm-setupを実行し、hookを有効にしてください");
71
+ }
72
+ const parent = { kind: "claude", request_id: parsed.data, session_id: invocation.session_id, hook_root: root };
73
+ verifyClaudeParent(parent);
74
+ return parent;
75
+ }
76
+ export function verifyClaudeParent(parent) {
77
+ const invocation = readInvocation(parent);
78
+ if (invocation.agent_id)
79
+ throw new ClaudeDeliveryError("CLAUDE_PARENT_SUBAGENT_UNSUPPORTED", "agent_id付きのClaude会話(--agent起動またはnative subagent)への自動配送には対応していません");
80
+ assertOpen(parent);
81
+ }
82
+ export function bindClaudeParentDelivery(parent, deliveryId) {
83
+ verifyClaudeParent(parent);
84
+ writeJson0600(path.join(directory(parent), "delivery.json"), { delivery_id: z.uuid().parse(deliveryId) });
85
+ }
86
+ /** SessionEndはその時点の依頼だけを終了する。同じ会話をresumeした新規依頼は別requestになる。 */
87
+ export function closeClaudeParentSession(input, root = defaultRoot()) {
88
+ const { session_id } = z.object({ session_id: z.uuid() }).parse(input);
89
+ if (!fs.existsSync(root))
90
+ return;
91
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
92
+ if (!entry.isDirectory())
93
+ continue;
94
+ const file = path.join(root, entry.name, "request.json");
95
+ if (!fs.existsSync(file))
96
+ continue;
97
+ const invocation = invocationSchema.parse(JSON.parse(fs.readFileSync(file, "utf8")));
98
+ if (invocation.session_id === session_id)
99
+ writeJson0600(path.join(root, entry.name, "closed.json"), { session_id });
100
+ }
101
+ }
102
+ // filesystemの通知を先に登録してから状態を読む。producerの終了は低頻度のprocess照合でも検出する。
103
+ function waitForFileState(dir, inspect) {
104
+ return new Promise((resolve, reject) => {
105
+ let finished = false;
106
+ let timer;
107
+ const watcher = fs.watch(dir, () => check());
108
+ const finish = (error, value) => {
109
+ if (finished)
110
+ return;
111
+ finished = true;
112
+ watcher.close();
113
+ if (timer)
114
+ clearInterval(timer);
115
+ if (error)
116
+ reject(error);
117
+ else
118
+ resolve(value);
119
+ };
120
+ const check = () => {
121
+ if (finished)
122
+ return;
123
+ try {
124
+ const value = inspect();
125
+ if (value !== undefined)
126
+ finish(null, value);
127
+ }
128
+ catch (error) {
129
+ finish(error);
130
+ }
131
+ };
132
+ watcher.on("error", error => finish(error));
133
+ timer = setInterval(check, 5000);
134
+ check();
135
+ });
136
+ }
137
+ function assertParentAlive(invocation) {
138
+ if (processIdentity(invocation.parent_pid) !== invocation.parent_started_identity) {
139
+ throw new ClaudeDeliveryError("CLAUDE_PARENT_PROCESS_CLOSED", "依頼元のClaude processは終了しました。回答は保存したままです");
140
+ }
141
+ }
142
+ export async function submitClaudeParentAnswer(parent, deliveryId, text) {
143
+ const invocation = readInvocation(parent);
144
+ const dir = directory(parent);
145
+ // 会話終了でも確定本文を失わない。終了判定より先に保存する。
146
+ writeJson0600(path.join(dir, "answer.json"), { delivery_id: deliveryId, text });
147
+ await waitForFileState(dir, () => {
148
+ const emitted = path.join(dir, "emitted.json");
149
+ if (fs.existsSync(emitted)) {
150
+ const value = JSON.parse(fs.readFileSync(emitted, "utf8"));
151
+ if (value.delivery_id !== deliveryId)
152
+ throw new ClaudeDeliveryError("CLAUDE_PARENT_DELIVERY_MISMATCH", "hookの配送IDが一致しません", true);
153
+ return true;
154
+ }
155
+ assertOpen(parent);
156
+ const failed = path.join(dir, "failed.json");
157
+ if (fs.existsSync(failed)) {
158
+ const value = JSON.parse(fs.readFileSync(failed, "utf8"));
159
+ throw new ClaudeDeliveryError("CLAUDE_PARENT_HOOK_FAILED", "親へのhook出力が失敗しました。回答は保存したままです", value.outcome_unknown === true);
160
+ }
161
+ const hookFile = path.join(dir, "hook.json");
162
+ if (fs.existsSync(hookFile)) {
163
+ const hook = z.object({ pid: z.number().int().positive(), started_identity: z.string() }).parse(JSON.parse(fs.readFileSync(hookFile, "utf8")));
164
+ if (processIdentity(hook.pid) !== hook.started_identity) {
165
+ throw new ClaudeDeliveryError("CLAUDE_PARENT_HOOK_CLOSED", "親の受信hookが終了しました。自動再送はしていません", fs.existsSync(path.join(dir, "sending.json")));
166
+ }
167
+ }
168
+ assertParentAlive(invocation);
169
+ return undefined;
170
+ });
171
+ return { queued_submission_id: null };
172
+ }
173
+ export async function runClaudeResultHook(input, emit, root = defaultRoot()) {
174
+ const event = z.object({ session_id: z.uuid(), tool_use_id: requestId }).parse(input);
175
+ const parent = { kind: "claude", request_id: event.tool_use_id, session_id: event.session_id, hook_root: root };
176
+ const invocation = readInvocation(parent);
177
+ const dir = directory(parent);
178
+ const binding = path.join(dir, "delivery.json");
179
+ if (!fs.existsSync(binding)) {
180
+ fs.rmSync(dir, { recursive: true });
181
+ return 0;
182
+ }
183
+ const deliveryId = z.uuid().parse(JSON.parse(fs.readFileSync(binding, "utf8")).delivery_id);
184
+ const identity = processIdentity(process.pid);
185
+ if (!identity)
186
+ throw new ClaudeDeliveryError("CLAUDE_PARENT_HOOK_UNAVAILABLE", "受信hookのprocessを確認できません");
187
+ writeJson0600(path.join(dir, "hook.json"), { pid: process.pid, started_identity: identity });
188
+ try {
189
+ const answer = await waitForFileState(dir, () => {
190
+ if (fs.existsSync(path.join(dir, "closed.json")))
191
+ return null;
192
+ assertParentAlive(invocation);
193
+ const file = path.join(dir, "answer.json");
194
+ if (!fs.existsSync(file))
195
+ return undefined;
196
+ const value = z.object({ delivery_id: z.uuid(), text: z.string() }).strict().parse(JSON.parse(fs.readFileSync(file, "utf8")));
197
+ if (value.delivery_id !== deliveryId)
198
+ throw new ClaudeDeliveryError("CLAUDE_PARENT_DELIVERY_MISMATCH", "保存された回答の配送IDが一致しません");
199
+ return value;
200
+ });
201
+ if (!answer)
202
+ return 0;
203
+ assertOpen(parent);
204
+ writeJson0600(path.join(dir, "sending.json"), { delivery_id: deliveryId });
205
+ await emit(answer.text);
206
+ writeJson0600(path.join(dir, "emitted.json"), { delivery_id: deliveryId });
207
+ // Claudeが定めるasyncRewakeの再開信号。子の成功/失敗は本文のoutcomeで区別する。
208
+ return 2;
209
+ }
210
+ catch (error) {
211
+ writeJson0600(path.join(dir, "failed.json"), { outcome_unknown: fs.existsSync(path.join(dir, "sending.json")) });
212
+ throw error;
213
+ }
214
+ }
@@ -0,0 +1,135 @@
1
+ // Codex親の公式受信キューへの接続。親threadのload/resumeやDesktop固有通信は行わない。
2
+ import { spawn } from "node:child_process";
3
+ import { createInterface } from "node:readline";
4
+ import * as path from "node:path";
5
+ import { resolveAgentBin } from "./agent-resolver.js";
6
+ import { realCodexHome } from "./harnesses/codex.js";
7
+ import { AitermError } from "./errors.js";
8
+ export class CodexDeliveryError extends AitermError {
9
+ delivery_code;
10
+ outcome_unknown;
11
+ constructor(delivery_code, message, outcome_unknown = false) {
12
+ super(`${delivery_code}: ${message}`, 2);
13
+ this.delivery_code = delivery_code;
14
+ this.outcome_unknown = outcome_unknown;
15
+ }
16
+ }
17
+ /** modelの引数ではなく、CodexがMCP要求へ付けるmetadataだけを宛先にする。 */
18
+ export function codexParentFromRequest(clientName, metadata) {
19
+ if (clientName !== "codex-mcp-client")
20
+ return null;
21
+ const threadId = metadata?.threadId;
22
+ if (typeof threadId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(threadId)) {
23
+ throw new CodexDeliveryError("CODEX_PARENT_ID_UNAVAILABLE", "MCP要求に親のthreadIdがありません。対応するCodexへ更新してください");
24
+ }
25
+ return { thread_id: threadId, codex_home: path.resolve(realCodexHome()) };
26
+ }
27
+ async function withCodexReceiver(parent, action, runtime = {}) {
28
+ const executable = runtime.executable ?? resolveAgentBin("codex");
29
+ if (!executable)
30
+ throw new CodexDeliveryError("CODEX_RECEIVER_UNAVAILABLE", "Codexの実行ファイルを確認できません");
31
+ const child = spawn(executable, runtime.args ?? ["app-server", "--listen", "stdio://"], {
32
+ stdio: ["pipe", "pipe", "ignore"],
33
+ env: { ...process.env, CODEX_HOME: parent.codex_home },
34
+ windowsHide: true,
35
+ });
36
+ const pending = new Map();
37
+ let sequence = 0;
38
+ let stopped = false;
39
+ let transportError = null;
40
+ const failTransport = (message) => {
41
+ transportError = message;
42
+ for (const item of pending.values()) {
43
+ clearTimeout(item.timer);
44
+ item.reject(new CodexDeliveryError("CODEX_RECEIVER_TRANSPORT_FAILED", message, item.method === "thread/queue/add"));
45
+ }
46
+ pending.clear();
47
+ };
48
+ child.on("error", (error) => failTransport(`Codexを起動できません(${error.code ?? "unknown"})`));
49
+ child.stdin.on("error", (error) => failTransport(`Codexへの書込みに失敗しました(${error.code ?? "unknown"})`));
50
+ const exited = new Promise((resolve) => child.once("close", (code, signal) => {
51
+ if (!stopped)
52
+ failTransport(`Codexの接続が終了しました(exit=${code}, signal=${signal})`);
53
+ resolve();
54
+ }));
55
+ const reader = createInterface({ input: child.stdout });
56
+ reader.on("line", (line) => {
57
+ let value;
58
+ try {
59
+ value = JSON.parse(line);
60
+ }
61
+ catch {
62
+ failTransport("Codexが不正なJSON応答を返しました");
63
+ return;
64
+ }
65
+ const item = pending.get(value?.id);
66
+ if (!item)
67
+ return;
68
+ clearTimeout(item.timer);
69
+ pending.delete(value.id);
70
+ if (value.error) {
71
+ item.reject(new CodexDeliveryError("CODEX_RECEIVER_REJECTED", typeof value.error.message === "string" ? value.error.message : "公式受信口が要求を拒否しました"));
72
+ }
73
+ else if ("result" in value)
74
+ item.resolve(value.result);
75
+ else
76
+ item.reject(new CodexDeliveryError("CODEX_RECEIVER_INVALID_RESPONSE", "公式受信口の応答にresultがありません", item.method === "thread/queue/add"));
77
+ });
78
+ const request = (method, params) => new Promise((resolve, reject) => {
79
+ if (transportError) {
80
+ reject(new CodexDeliveryError("CODEX_RECEIVER_TRANSPORT_FAILED", transportError));
81
+ return;
82
+ }
83
+ const id = ++sequence;
84
+ const timer = setTimeout(() => {
85
+ pending.delete(id);
86
+ reject(new CodexDeliveryError("CODEX_RECEIVER_TIMEOUT", `${method}の応答を確認できません`, method === "thread/queue/add"));
87
+ }, runtime.timeout_ms ?? 15_000);
88
+ pending.set(id, { resolve, reject, timer, method });
89
+ child.stdin.write(JSON.stringify({ id, method, params }) + "\n");
90
+ });
91
+ try {
92
+ await request("initialize", { clientInfo: { name: "aiterm_parent_delivery", version: "1" }, capabilities: { experimentalApi: true } });
93
+ child.stdin.write(JSON.stringify({ method: "initialized" }) + "\n");
94
+ return await action(request);
95
+ }
96
+ finally {
97
+ stopped = true;
98
+ for (const item of pending.values())
99
+ clearTimeout(item.timer);
100
+ pending.clear();
101
+ child.stdin.end();
102
+ // stdio終了を公式processへ伝える。終了しない外部processだけを明示的に停止する。
103
+ const terminate = setTimeout(() => child.kill("SIGKILL"), 2_000);
104
+ await exited;
105
+ clearTimeout(terminate);
106
+ reader.close();
107
+ }
108
+ }
109
+ /** 子へ送る前に、同じstoreの宛先と公式キューの対応を確認する。本文は保存・表示しない。 */
110
+ export async function verifyCodexParent(parent, runtime) {
111
+ await withCodexReceiver(parent, async (request) => {
112
+ const response = await request("thread/read", { threadId: parent.thread_id, includeTurns: false });
113
+ if (response?.thread?.id !== parent.thread_id) {
114
+ throw new CodexDeliveryError("CODEX_PARENT_UNAVAILABLE", "同じCodex環境で親threadを確認できません");
115
+ }
116
+ const subagent = response.thread.source?.subAgent;
117
+ if (subagent && typeof subagent === "object" && "thread_spawn" in subagent) {
118
+ throw new CodexDeliveryError("CODEX_PARENT_UNSUPPORTED", "Codexのnative sub-agentは外部processからのキュー入力を受け付けません");
119
+ }
120
+ await request("thread/queue/list", { threadId: parent.thread_id, limit: 1 });
121
+ }, runtime);
122
+ }
123
+ export async function submitCodexParentAnswer(parent, deliveryId, text, runtime) {
124
+ return withCodexReceiver(parent, async (request) => {
125
+ const result = await request("thread/queue/add", {
126
+ threadId: parent.thread_id,
127
+ input: [{ type: "text", text, text_elements: [] }],
128
+ clientUserMessageId: deliveryId,
129
+ });
130
+ if (typeof result?.queuedSubmission?.id !== "string") {
131
+ throw new CodexDeliveryError("CODEX_RECEIVER_INVALID_RESPONSE", "キューの受付IDを確認できません", true);
132
+ }
133
+ return { queued_submission_id: result.queuedSubmission.id };
134
+ }, runtime);
135
+ }