aiterm-mcp 0.46.1 → 0.47.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,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.47.1] - 2026-10-03
11
+
12
+ ### 修正
13
+
14
+ - 混んだ端末で、agentの画面が描かれる前に入力受付の待ち(30秒)が切れ、`agent_launch`が`reason=unrecognized_screen`で初手を送らなかった。画面が起動コマンドの表示のままの時だけ、起動から50秒まで待つ。描かれた後の画面の扱いと、50秒でも描かれない時の未送信の返し方は変わらない。
15
+ - Codex・Claude Code・Cursorを親にしたMCP processが、5秒おきに全processの一覧を2回ずつ取っていた(POSIXは`ps -axww`、WindowsはPowerShell)。processの多い端末でCPUを使い続け、その間MCPの応答も止まっていた。5秒おきの確認は他の持ち主のpidの存在だけを見て、開始時刻の照合は初めて見た持ち主と60秒に1回だけ、対象のpidに絞って行う。配送記録を引き継ぐ条件は変わらない。終了直後に同じpidが再利用された時だけ、引き継ぎが最長60秒遅れる。
16
+
17
+ ## [0.47.0] - 2026-10-03
18
+
19
+ ### 追加
20
+
21
+ - `aiterm-setup --hooks-only`。Claude Code・Cursorの親配送hookだけを登録し、依存準備、端末の実動作確認、MCP登録、Codex Steerには触れない。MCP登録を利用者や他の製品が管理する環境向け。結果は`aiterm.parent-hooks-result.v1`で返し、登録済みなら設定を書き換えない。
22
+ - `diagnostics`が2つ目のtextで親配送hookの状態(`aiterm-mcp.parent-delivery-diagnostics.v1`)を返す。Claude Code・Cursorそれぞれの`status`と`reason_code`、呼出元のclientに当たる`caller_status`を持つ。hookが無いと`CLAUDE_PARENT_HOOK_UNAVAILABLE`で送信が拒否されるのに、診断は`ready`だけを返していた。1つ目のfactory向けJSON(`aiterm-mcp.factory-diagnostics.v1`)は項目も`overall`の意味も変えていない。
23
+
24
+ ### 修正
25
+
26
+ - npmのprefixを利用者ごとの場所へ向けた環境(`npm_config_prefix`など)で、共通の場所へ導入した`aiterm-setup`が`global_package_failed`で失敗していた。npmの現在のglobal rootに加え、実行中のNodeの既定のglobal rootにある当packageも導入先と認める。npm一時cacheとsource checkoutは引き続き登録しない。
27
+ - `aiterm-setup`(`aiterm-update`からの実行を含む)が、CodexとGrokの登録を毎回公式CLIで作り直し、利用者が足した項目(`tool_timeout_sec`など)を落としていた。登録が同じなら書き直さない。
28
+
10
29
  ## [0.46.1] - 2026-10-02
11
30
 
12
31
  ### 修正
@@ -1964,7 +1983,9 @@ prototype (preserved under `prototype/python/` as the porting source and referen
1964
1983
  `ubuntu-latest` for Node 18/20/22, publishing to npm on `v*` tags with
1965
1984
  provenance.
1966
1985
 
1967
- [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.46.1...HEAD
1986
+ [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.47.1...HEAD
1987
+ [0.47.1]: https://github.com/kitepon/aiterm-mcp/compare/v0.47.0...v0.47.1
1988
+ [0.47.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.46.1...v0.47.0
1968
1989
  [0.46.1]: https://github.com/kitepon/aiterm-mcp/compare/v0.46.0...v0.46.1
1969
1990
  [0.46.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.45.2...v0.46.0
1970
1991
  [0.45.2]: https://github.com/kitepon/aiterm-mcp/compare/v0.45.1...v0.45.2
package/README.ja.md CHANGED
@@ -42,11 +42,17 @@ Ubuntu/Debianはsudoとaptでtmuxを準備する。必要な公式package mana
42
42
  既存設定の他サーバーを保持し、JSON設定は変更前の`.aiterm-backup`を残す。
43
43
  結果の`status`は`ready`/`unsupported`/`failed`/`restart_required`。未検出のAIは`not_detected`とし、全AI未検出は成功にしない。
44
44
  登録先はglobal packageのNodeとMCP入口の絶対パスで、npm一時cacheやsource checkoutは登録しない。
45
+ global packageは、npmの現在のglobal rootか、実行中のNodeの既定のglobal rootにあるものを指す。npmのprefixを利用者ごとの場所へ向けた環境でも、共通の場所へ導入したAitermを登録できる。
46
+ CodexとGrokは、登録が同じなら公式CLIで作り直さず、利用者が足した項目(待ち時間など)を保つ。
45
47
  HomebrewのNodeは更新後も有効な`opt`のパスをMCP登録とCodexのhookに使う。旧版の登録でNode更新後に起動できなくなった場合も、更新後の`aiterm-setup --json`で修復できる。
46
48
  更新後も同じ入口を実行し、MCP clientを再起動する。npm install自体はユーザー設定を変更しない。
47
49
  公開JSONは`schema: "aiterm.setup-result.v1"`、全体の`status`、端末の`backend`、
48
50
  AI別の`integrations`と選択機能の`codex_steer`を持つ。失敗時は`reason_code`を付け、終了コードはreadyなら0、再起動待ちは3、それ以外は2となる。
49
51
 
52
+ MCP登録を利用者や他の製品が管理する環境では、`aiterm-setup --hooks-only`でClaude Code・Cursorの親配送hookだけを登録できる。
53
+ 依存準備、端末の実動作確認、MCP登録、Codex Steerには触れない。登録済みなら設定を書き換えない。
54
+ 結果は`schema: "aiterm.parent-hooks-result.v1"`、全体の`status`(`ready`/`unsupported`/`failed`)、AI別の`hooks`(`configured`/`unchanged`/`not_detected`/`failed`)を持つ。終了コードはreadyなら0、それ以外は2となる。
55
+
50
56
 
51
57
  ### CodexへSteerを有効にする(macOS・Windows・Linux)
52
58
 
@@ -203,7 +209,7 @@ runtime-error store は canonical dotagents config の `collection.enabled: true
203
209
  場合だけ収集し、既定OFF、network送信は行いません。tag起点CIのnpm provenance(OIDC Trusted
204
210
  Publishing)で公開し、GitHub Release が Official MCP Registry を再登録します。
205
211
 
206
- **状態:** 開発継続中 · 現行公開版 **v0.46.1** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
212
+ **状態:** 開発継続中 · 現行公開版 **v0.47.1** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
207
213
 
208
214
  ### 更新と巻き戻し
209
215
 
@@ -508,6 +514,8 @@ aiterm は同じ核心の洞察——端末を出会いの場にする——を
508
514
  進め、入力受付とharness生存を確認して`startup.status="ready"`を返す。指定なしのpromptなし起動は`not_checked`。
509
515
  Claude Code初回起動の文字表示テーマ選択では、画面で選択済みの項目を確定して起動を続ける。
510
516
  続いてログイン方式の選択が出た場合は`vendor_onboarding_required`を返す。公式の対話型Claude Code CLIで初回設定を完了する。
517
+ 入力受付の待ちは30秒。混んだ端末でagentの画面がまだ描かれていない間に切れた時だけ、起動から50秒まで待つ。
518
+ それでも描かれなければ、今までと同じく未送信(`initial_prompt=not_sent`)で返り、sessionは残る。
511
519
  WindowsのCodexもhook確認を認識し、npm shim経由の起動を一つのharnessとして識別する。
512
520
  初手の`initial_prompt.status`は`not_requested`/`not_sent`/`submitted_unconfirmed`/`started`を区別する。
513
521
  未送信・未確認の失敗もsession付きstructuredContentを保持する。未確認のpromptを再送せず、返ったcursorで観測する。
@@ -538,6 +546,8 @@ Claudeの相関済み承認は既存の`claude_approval`を使う。
538
546
 
539
547
  `diagnostics` は PTY やエージェントを起動しない。パッケージ版、MCP 呼出 readiness、read-only な PTY 一覧要約、bounded runtime-error-store status、任意 vendor launcher の可用性だけを返す。path・環境値・認証情報・コマンド本文・PTY 出力・raw log は意図的に返さない。通常未設定の任意依存は `not_applicable`、安全に確定できない状態は `unverified` と表す。
540
548
 
549
+ 結果のtextは2つある。1つ目は上の内容で、項目を固定したfactory向けのJSON(`aiterm-mcp.factory-diagnostics.v1`)。2つ目は親配送hookの状態(`aiterm-mcp.parent-delivery-diagnostics.v1`)で、Claude Code・Cursorそれぞれの`status`(`ready`/`setup_required`/`not_applicable`/`unverified`)と`reason_code`、呼出元のclientに当たる`caller_status`を返す。`caller_status`が`setup_required`なら、そのclientからのagent送信は拒否されるので`aiterm-setup`を実行する。hookの状態は1つ目の`overall`には含めない。
550
+
541
551
  ### ローカル runtime error snapshot
542
552
 
543
553
  `aiterm-runtime-errors snapshot` は dotagents factory adapter 向けに、製品所有のローカル snapshot を機械可読 JSON で返す。canonical dotagents factory-reporter config が schema-exact、host profile が実行 OS と一致し、`collection.enabled` が JSON boolean `true` の時だけ収集する。reporting field は schema 検証するが endpoint/credential file へ接続・読取せず network I/O も行わない。観測 API は core owner layer の固定3 code(PTY dependency・persistence・任意 vendor launcher)だけを受け、保存するのも固定 template と aggregate metadata(SHA-256 fingerprint、count、first/last、status、monotonic sequence)だけ。exception、stderr/stdout、stack、prompt、PTY/transcript/event body、path、任意 context は受け付けない。保存済み JSON も top/record exact・固定定義一致・fingerprint 再計算を通し、明示 DTO だけを返す。
package/README.md CHANGED
@@ -42,11 +42,17 @@ Ubuntu/Debianはsudoとaptでtmuxを準備する。必要な公式package mana
42
42
  既存設定の他サーバーを保持し、JSON設定は変更前の`.aiterm-backup`を残す。
43
43
  結果の`status`は`ready`/`unsupported`/`failed`/`restart_required`。未検出のAIは`not_detected`とし、全AI未検出は成功にしない。
44
44
  登録先はglobal packageのNodeとMCP入口の絶対パスで、npm一時cacheやsource checkoutは登録しない。
45
+ global packageは、npmの現在のglobal rootか、実行中のNodeの既定のglobal rootにあるものを指す。npmのprefixを利用者ごとの場所へ向けた環境でも、共通の場所へ導入したAitermを登録できる。
46
+ CodexとGrokは、登録が同じなら公式CLIで作り直さず、利用者が足した項目(待ち時間など)を保つ。
45
47
  HomebrewのNodeは更新後も有効な`opt`のパスをMCP登録とCodexのhookに使う。旧版の登録でNode更新後に起動できなくなった場合も、更新後の`aiterm-setup --json`で修復できる。
46
48
  更新後も同じ入口を実行し、MCP clientを再起動する。npm install自体はユーザー設定を変更しない。
47
49
  公開JSONは`schema: "aiterm.setup-result.v1"`、全体の`status`、端末の`backend`、
48
50
  AI別の`integrations`と選択機能の`codex_steer`を持つ。失敗時は`reason_code`を付け、終了コードはreadyなら0、再起動待ちは3、それ以外は2となる。
49
51
 
52
+ MCP登録を利用者や他の製品が管理する環境では、`aiterm-setup --hooks-only`でClaude Code・Cursorの親配送hookだけを登録できる。
53
+ 依存準備、端末の実動作確認、MCP登録、Codex Steerには触れない。登録済みなら設定を書き換えない。
54
+ 結果は`schema: "aiterm.parent-hooks-result.v1"`、全体の`status`(`ready`/`unsupported`/`failed`)、AI別の`hooks`(`configured`/`unchanged`/`not_detected`/`failed`)を持つ。終了コードはreadyなら0、それ以外は2となる。
55
+
50
56
 
51
57
  ### CodexへSteerを有効にする(macOS・Windows・Linux)
52
58
 
@@ -217,7 +223,7 @@ collection is off by default and performs no network I/O. It ships via
217
223
  tag-triggered CI with npm provenance (OIDC Trusted Publishing); the GitHub
218
224
  Release re-registers the Official MCP Registry entry.
219
225
 
220
- **Status:** actively maintained · current public release **v0.46.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).
226
+ **Status:** actively maintained · current public release **v0.47.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).
221
227
 
222
228
  ### Update and rollback
223
229
 
@@ -540,6 +546,8 @@ displayed token count or null. Callers do not need raw argv or pane-text parsing
540
546
  consent even without a prompt, then verifies input readiness and harness liveness before returning `startup.status="ready"`.
541
547
  For Claude Code's first-run text-style menu, it confirms the item already selected on screen before continuing startup.
542
548
  If the CLI then requests an account login method, the launch reports `vendor_onboarding_required`; complete that choice in the official interactive Claude Code CLI.
549
+ The input-readiness wait is 30 seconds. Only when it expires while the agent has not drawn anything yet (a busy host), the launch
550
+ waits up to 50 seconds from startup. If nothing is drawn by then, it still returns `initial_prompt=not_sent` and keeps the session.
543
551
  A prompt-free launch without this option retains `startup.status="not_checked"`. `initial_prompt.status` distinguishes
544
552
  `not_requested`, `not_sent`, `submitted_unconfirmed`, and `started`. Failure responses retain structured session information.
545
553
  WindowsのCodexもhook確認を認識し、npm shim経由の起動を一つのharnessとして識別する。
@@ -571,6 +579,8 @@ continue to use `claude_approval`.
571
579
 
572
580
  `diagnostics` never starts a PTY or agent. It reports package version, MCP call readiness, a read-only PTY-list summary, bounded runtime-error-store status, and optional vendor-launcher availability. It deliberately excludes paths, environment values, credentials, command text, PTY output, and raw logs; normal unset optional dependencies are `not_applicable`, while an indeterminate probe is `unverified`.
573
581
 
582
+ The result carries two text items. The first is the factory JSON described above (`aiterm-mcp.factory-diagnostics.v1`), whose fields are fixed. The second reports the parent-delivery hooks (`aiterm-mcp.parent-delivery-diagnostics.v1`): a `status` (`ready` / `setup_required` / `not_applicable` / `unverified`) and `reason_code` for Claude Code and Cursor, plus `caller_status` for the calling client. When `caller_status` is `setup_required`, agent dispatch from that client is rejected; run `aiterm-setup`. Hook status is not folded into `overall` in the first item.
583
+
574
584
  ### Local runtime error snapshot
575
585
 
576
586
  snapshotの`product_version`は各recordの最終実発生時の版を表す。store v2は旧v1を読み取り、単発記録の版を保持し、複数回の旧集約の版は`unknown`にする。読取りでは状態JSONを書き戻さず、次のロック内更新でv2を保存する。consumerを先に更新し、旧writerの終了後に新writerを使う。v2保存後の旧版への切替は、製品のバックアップ復元を伴う。
package/dist/core.js CHANGED
@@ -51,6 +51,11 @@ const AGENT_DONE_SCREEN_SETTLE_MIN_SAMPLES = 3;
51
51
  const AGENT_SUBMIT_DELAY_MS = 250;
52
52
  const AGENT_METADATA_NEGATIVE_CACHE_TTL_MS = 2_000;
53
53
  const AGENT_TUI_READY_TIMEOUT_MS = 30_000;
54
+ // 画面が起動コマンドの表示のまま(agentがまだ何も描いていない)で上の待ちが切れた時に、起動から待つ上限。
55
+ // Codex親の既定のtool timeout(60秒)の内側に収める。越えると親には時間切れに見えたまま子へ初手が送られる。
56
+ const AGENT_TUI_FIRST_DRAW_TIMEOUT_MS = 50_000;
57
+ const LAUNCH_ECHO_TAIL_CHARS = 24;
58
+ const LAUNCH_ECHO_MIN_CHARS = 8;
54
59
  const AGENT_TUI_READY_POLL_MS = 500;
55
60
  const AGENT_TUI_READY_STABLE_SAMPLES = 11;
56
61
  const AGENT_TUI_READY_LINES = 45;
@@ -1272,6 +1277,7 @@ function closeSessionInternal(name, observeDependency = true) {
1272
1277
  }
1273
1278
  }
1274
1279
  cleanupAgentState(name);
1280
+ agentLaunchLines.delete(name);
1275
1281
  return `closed ${name}`;
1276
1282
  }
1277
1283
  export function closeSession(name) {
@@ -2829,11 +2835,32 @@ function isClaudeBypassPermissionsScreen(screen) {
2829
2835
  function isClaudeManagedLaunchConfirmation(screen) {
2830
2836
  return isClaudeWorkspaceTrustScreen(screen) || isClaudeBypassPermissionsScreen(screen);
2831
2837
  }
2838
+ /**
2839
+ * 画面の末尾が、Aitermが打った起動コマンドの表示のままかを見る(純粋関数)。
2840
+ * 混んだ端末ではagentのTUIが描かれる前にready待ちが切れる。画面の最後の文字列が起動コマンドの一部なら、
2841
+ * agentはまだ何も描いていない。空白と行頭の継続prompt(`> `/`>> `)は端末の折返しとshellの表示なので比べない。
2842
+ */
2843
+ export function launchEchoIsLastOnScreen(screen, launchLine) {
2844
+ const command = launchLine.replace(/\s+/g, "");
2845
+ const lines = screen.split("\n");
2846
+ let tail = "";
2847
+ for (let i = lines.length - 1; i >= 0 && tail.length < LAUNCH_ECHO_TAIL_CHARS; i--) {
2848
+ tail = lines[i].replace(/^\s*>{1,2}(?:\s|$)/, "").replace(/\s+/g, "") + tail;
2849
+ }
2850
+ tail = tail.slice(-LAUNCH_ECHO_TAIL_CHARS);
2851
+ return tail.length >= LAUNCH_ECHO_MIN_CHARS && command.includes(tail);
2852
+ }
2853
+ // 起動コマンドはopenAgentだけが知っている。同じprocess内のready待ちが、TUI未描画の判定に使う。
2854
+ const agentLaunchLines = new Map();
2832
2855
  async function waitAgentTuiReadyImpl(kind, sample, sleepFn, opts = {}) {
2833
2856
  const timeoutMs = opts.timeoutMs ?? AGENT_TUI_READY_TIMEOUT_MS;
2834
2857
  const pollMs = opts.pollMs ?? AGENT_TUI_READY_POLL_MS;
2835
2858
  const stableSamples = opts.stableSamples ?? agentTuiReadyStableSamplesTestOverride ?? AGENT_TUI_READY_STABLE_SAMPLES;
2836
- const deadline = performance.now() + timeoutMs;
2859
+ const now = opts.now ?? (() => performance.now());
2860
+ const start = now();
2861
+ let deadline = start + timeoutMs;
2862
+ // 呼ぶ側が既定より短い待ちを指定した時は、その待ちを守る(延ばさない)。
2863
+ const firstDrawDeadline = timeoutMs >= AGENT_TUI_READY_TIMEOUT_MS ? start + AGENT_TUI_FIRST_DRAW_TIMEOUT_MS : deadline;
2837
2864
  let samples = 0;
2838
2865
  let readyStreak = 0;
2839
2866
  let lastScreen = "";
@@ -2852,13 +2879,21 @@ async function waitAgentTuiReadyImpl(kind, sample, sleepFn, opts = {}) {
2852
2879
  if (isAgentTuiActionRequired(kind, lastScreen))
2853
2880
  return { ready: false, samples, lastScreen };
2854
2881
  }
2855
- if (performance.now() >= deadline)
2856
- return { ready: false, samples, lastScreen };
2882
+ if (now() >= deadline) {
2883
+ // TUIが一度も描かれていない間に切れた時だけ、決めた上限まで待ちを延ばす。描かれた後の画面は今までどおり扱う。
2884
+ if (deadline >= firstDrawDeadline || opts.launchLine === undefined
2885
+ || !launchEchoIsLastOnScreen(lastScreen, opts.launchLine))
2886
+ return { ready: false, samples, lastScreen };
2887
+ deadline = firstDrawDeadline;
2888
+ }
2857
2889
  await sleepFn(pollMs);
2858
2890
  }
2859
2891
  }
2860
2892
  async function waitAgentTuiReady(name, meta, timeoutMs = AGENT_TUI_READY_TIMEOUT_MS) {
2861
- return waitAgentTuiReadyImpl(meta.kind, () => captureScreen(name, AGENT_TUI_READY_LINES), sleep, { timeoutMs });
2893
+ const result = await waitAgentTuiReadyImpl(meta.kind, () => captureScreen(name, AGENT_TUI_READY_LINES), sleep, { timeoutMs, launchLine: agentLaunchLines.get(name) });
2894
+ if (result.ready)
2895
+ agentLaunchLines.delete(name);
2896
+ return result;
2862
2897
  }
2863
2898
  async function waitAgentTuiReadyByKind(name, kind, timeoutMs = AGENT_TUI_READY_TIMEOUT_MS) {
2864
2899
  return waitAgentTuiReadyImpl(kind, () => captureScreen(name, AGENT_TUI_READY_LINES), sleep, { timeoutMs });
@@ -3036,9 +3071,13 @@ export async function __testWaitAgentTuiReady(kind, samples, opts = {}) {
3036
3071
  throw new AitermError("agent ready test samples が空です", 2);
3037
3072
  let i = 0;
3038
3073
  const sleeps = [];
3074
+ // virtualClockは待った分だけ進む時計。実時間を使わずに締切の扱いを確かめる。
3075
+ let clock = 0;
3076
+ const { virtualClock, ...waitOpts } = opts;
3039
3077
  const result = await waitAgentTuiReadyImpl(kind, () => samples[Math.min(i++, samples.length - 1)], async (ms) => {
3040
3078
  sleeps.push(ms);
3041
- }, opts);
3079
+ clock += ms;
3080
+ }, virtualClock ? { ...waitOpts, now: () => clock } : waitOpts);
3042
3081
  return { ...result, sleeps };
3043
3082
  }
3044
3083
  export function __testIsAgentTuiIdleReady(kind, screen) {
@@ -4357,6 +4396,8 @@ export function openAgent(kind, opts = {}) {
4357
4396
  const envPrefix = agentEnvPrefix(meta, sid, envVars);
4358
4397
  full = cwdForCmd ? `cd ${shq(cwdForCmd)} && ${envPrefix}${cmd}` : `${envPrefix}${cmd}`;
4359
4398
  }
4399
+ if (meta)
4400
+ agentLaunchLines.set(sid, full);
4360
4401
  // force:true はagent sessionへの手動介入を表す。起動コマンド自体はAitermが組み立てて素送信する。
4361
4402
  send(sid, full, {
4362
4403
  enter: true,
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { ParentDeliveryManager, deliveryKey } from "./parent-delivery.js";
20
20
  import { acceptRemote, callRemoteTool, observeRemoteAgentDone, remoteInputDescription, remoteInputSchema, remoteLabel, remoteWaitProcess } from "./remote.js";
21
21
  import { codexParentFromRequest } from "./codex-parent-receiver.js";
22
22
  import { claudeParentFromRequest } from "./claude-parent-receiver.js";
23
+ import { parentDeliveryDiagnostic } from "./parent-hook-diagnostic.js";
23
24
  import { cursorParentFromRequest, isCursorMcpClient } from "./cursor-parent-receiver.js";
24
25
  import { waitProcessCommandLine } from "./cursor-parent-receive.js";
25
26
  import { INTERIM_RESULT_META_KEY, interimRequestFromMeta } from "./interim-words.js";
@@ -234,9 +235,15 @@ async function remoteCompletionWait(delivery, target, sessionId, eventCursor) {
234
235
  return { wait_process: waitProcess, wait_command: `ssh ${remoteLabel(target)} aiterm-wait --session ${sessionId} --cursor ${eventCursor}` };
235
236
  }
236
237
  server.registerTool("diagnostics", {
237
- description: "Factory 向け read-only 診断。安全な状態語彙だけを機械可読 JSON で返す(PTY 内容・認証情報・path・環境値は返さない)。",
238
+ description: "Factory 向け read-only 診断。安全な状態語彙だけを機械可読 JSON で返す(PTY 内容・認証情報・path・環境値は返さない)。" +
239
+ "2つ目のtextは親配送hook(Claude Code・Cursor)の登録状態で、caller_status が setup_required なら呼出元からのagent送信は拒否される。aiterm-setup を実行する。",
238
240
  inputSchema: {},
239
- }, async () => ok(await factoryDiagnostics()));
241
+ },
242
+ // 1つ目は項目を固定したfactory向けの契約。親配送hookの状態は別のtextに分け、既存の読み手の形を変えない。
243
+ async () => ({ content: [
244
+ { type: "text", text: await factoryDiagnostics() },
245
+ { type: "text", text: JSON.stringify(parentDeliveryDiagnostic(deliveryParentKind(server.server.getClientVersion()?.name))) },
246
+ ] }));
240
247
  const DEFAULT_PTY_SHELL = process.platform === "win32" ? "pwsh" : "bash";
241
248
  registerRemoteAwareTool("pty_open", {
242
249
  description: "ローカル永続端末(POSIXはtmux、Windows nativeはpsmux 3.3.8以上)を1個開き、session_id を返す。backend server常駐ゆえ本サーバや " +
@@ -4,7 +4,7 @@ import * as path from "node:path";
4
4
  import { randomUUID, createHash } from "node:crypto";
5
5
  import { z } from "zod";
6
6
  import { ensureStateRoot, writeJson0600 } from "./agent-shared.js";
7
- import { readRuntimeProcesses } from "./process-runtime.js";
7
+ import { readProcessIdentities } from "./process-runtime.js";
8
8
  import { CodexDeliveryError, submitCodexParentAnswer, verifyCodexParent } from "./codex-parent-receiver.js";
9
9
  import { claudeParentSchema, ClaudeDeliveryError, bindClaudeParentDelivery, submitClaudeParentAnswer, verifyClaudeParent } from "./claude-parent-receiver.js";
10
10
  import { cursorParentSchema, CursorDeliveryError, prepareCursorDelivery, submitCursorParentAnswer, verifyCursorParent } from "./cursor-parent-receiver.js";
@@ -57,6 +57,22 @@ async function submitParentAnswer(parent, deliveryId, text) {
57
57
  return submitCursorParentAnswer(parent, deliveryId, text);
58
58
  return submitCodexParentAnswer(parent, deliveryId, text);
59
59
  }
60
+ // 開始時刻の照会はprocessの起動を伴い、processの多い端末では1回が重い(macOSのpsは-pでも全processを歩く)。
61
+ // 5秒おきの確認はpidの存在だけを見て、開始時刻は初めて見た持ち主とこの間隔でだけ照合する。
62
+ // 持ち主が終了した直後に同じpidが再利用された時だけ、回収が最長でこの間隔だけ遅れる。生きている持ち主の記録は引き取らない。
63
+ const OWNER_IDENTITY_RECHECK_MS = 60_000;
64
+ function processExists(pid) {
65
+ // 0以下はprocess groupを指す。持ち主のpidにはならない。
66
+ if (pid <= 0)
67
+ return false;
68
+ try {
69
+ process.kill(pid, 0);
70
+ return true;
71
+ }
72
+ catch (error) {
73
+ return error.code === "EPERM";
74
+ }
75
+ }
60
76
  function readRecord(file) {
61
77
  try {
62
78
  const record = recordSchema.parse(JSON.parse(fs.readFileSync(file, "utf8")));
@@ -98,11 +114,14 @@ export class ParentDeliveryManager {
98
114
  registering = new Map();
99
115
  timer;
100
116
  recovering = null;
117
+ // 他の持ち主の開始時刻を最後に照合した時刻(持ち主の保存場所ごと)。
118
+ verifiedOwners = new Map();
101
119
  serviceError = null;
102
120
  closing = false;
103
121
  constructor(options = {}) {
104
122
  this.deps = { observe: observeAnywhere, answer: answerAnywhere, submit: submitParentAnswer,
105
- verify: verifyParent, processes: readRuntimeProcesses, ...options.dependencies };
123
+ verify: verifyParent, processes: readProcessIdentities, exists: processExists,
124
+ now: () => performance.now(), ...options.dependencies };
106
125
  const stateRoot = ensureStateRoot();
107
126
  // 別端末の子の記録はremote-で始まる保存場所へ分け、boundary.remoteを知らない旧版のreaderへ渡さない。
108
127
  const prefix = options.remote ? "remote-" : "";
@@ -118,7 +137,7 @@ export class ParentDeliveryManager {
118
137
  this.active = path.join(this.root, "active");
119
138
  this.results = path.join(this.root, "results");
120
139
  this.claims = path.join(options.root ?? path.join(stateRoot, prefix + "parent-deliveries"), "claims");
121
- const ownProcess = this.deps.processes().find((entry) => entry.pid === process.pid);
140
+ const ownProcess = this.deps.processes([process.pid]).find((entry) => entry.pid === process.pid);
122
141
  if (!ownProcess)
123
142
  throw new AitermError("PARENT_DELIVERY_OWNER_UNKNOWN: 配送processを識別できません", 2);
124
143
  this.owner = { pid: process.pid, started_identity: ownProcess.started_identity, closed: false };
@@ -375,13 +394,49 @@ export class ParentDeliveryManager {
375
394
  return this.recovering;
376
395
  }
377
396
  async recoverOrphans() {
378
- const processes = this.deps.processes();
379
- for (const directory of fs.readdirSync(this.active, { withFileTypes: true })) {
380
- if (!directory.isDirectory())
397
+ // 生存を確かめる相手は、closeしていない他の持ち主だけ。居なければOSへ問い合わせない。
398
+ // この処理は5秒おきに全MCP processで回る。全processの一覧を取ると、processの多い端末で負荷になる。
399
+ const directories = fs.readdirSync(this.active, { withFileTypes: true })
400
+ .filter((directory) => directory.isDirectory() && path.join(this.active, directory.name) !== this.ownerDir);
401
+ const now = this.deps.now();
402
+ const alive = new Map();
403
+ const unverified = [];
404
+ for (const directory of directories) {
405
+ let owner;
406
+ try {
407
+ owner = JSON.parse(fs.readFileSync(path.join(this.active, directory.name, "owner.json"), "utf8"));
408
+ }
409
+ catch {
410
+ continue; /* 形式の検査と失敗の扱いは下の回収で行う */
411
+ }
412
+ if (owner?.closed !== false || !Number.isSafeInteger(owner.pid) || typeof owner.started_identity !== "string")
381
413
  continue;
382
- const oldDir = path.join(this.active, directory.name);
383
- if (oldDir === this.ownerDir)
414
+ const pid = owner.pid;
415
+ if (!this.deps.exists(pid)) {
416
+ alive.set(directory.name, false);
384
417
  continue;
418
+ }
419
+ const verified = this.verifiedOwners.get(directory.name);
420
+ if (verified && verified.pid === pid && verified.started_identity === owner.started_identity
421
+ && now - verified.at < OWNER_IDENTITY_RECHECK_MS)
422
+ alive.set(directory.name, true);
423
+ else
424
+ unverified.push({ name: directory.name, pid, started_identity: owner.started_identity });
425
+ }
426
+ if (unverified.length > 0) {
427
+ const processes = this.deps.processes([...new Set(unverified.map((entry) => entry.pid))]);
428
+ for (const entry of unverified) {
429
+ const same = processes.some((row) => row.pid === entry.pid && row.started_identity === entry.started_identity);
430
+ alive.set(entry.name, same);
431
+ if (same)
432
+ this.verifiedOwners.set(entry.name, { pid: entry.pid, started_identity: entry.started_identity, at: now });
433
+ }
434
+ }
435
+ for (const name of this.verifiedOwners.keys())
436
+ if (alive.get(name) !== true)
437
+ this.verifiedOwners.delete(name);
438
+ for (const directory of directories) {
439
+ const oldDir = path.join(this.active, directory.name);
385
440
  let owner;
386
441
  try {
387
442
  owner = JSON.parse(fs.readFileSync(path.join(oldDir, "owner.json"), "utf8"));
@@ -394,7 +449,8 @@ export class ParentDeliveryManager {
394
449
  if (!Number.isSafeInteger(owner.pid) || typeof owner.started_identity !== "string" || typeof owner.closed !== "boolean") {
395
450
  throw new AitermError("PARENT_DELIVERY_OWNER_INVALID: 配送所有者の形式が不正です", 2);
396
451
  }
397
- if (!owner.closed && processes.some((entry) => entry.pid === owner.pid && entry.started_identity === owner.started_identity))
452
+ // 生死を確かめた後に現れた持ち主は、次の回で扱う。生きている持ち主の記録は引き取らない。
453
+ if (!owner.closed && alive.get(directory.name) !== false)
398
454
  continue;
399
455
  for (const name of fs.readdirSync(oldDir).filter((name) => name !== "owner.json" && name.endsWith(".json"))) {
400
456
  const oldFile = path.join(oldDir, name);
@@ -0,0 +1,83 @@
1
+ // 親配送hook(Claude Code・Cursor)の登録状態を、利用者設定の読取りだけで要約する。
2
+ // 設定の本文、path、環境値は返さない。書込みと修復はaiterm-setupが所有する。
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import * as steer from "aiterm-steer-delivery";
7
+ import { resolveAgentBin } from "./agent-resolver.js";
8
+ import { AITERM_PROFILE } from "./steer-profile.js";
9
+ const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
10
+ const ready = { status: "ready", reason_code: null };
11
+ const required = (reason_code) => ({ status: "setup_required", reason_code });
12
+ const unreadable = { status: "unverified", reason_code: "settings_unreadable" };
13
+ function readSettings(file) {
14
+ if (!existsSync(file))
15
+ return null;
16
+ try {
17
+ const value = JSON.parse(readFileSync(file, "utf8").replace(/^/u, ""));
18
+ return record(value) ? value : undefined;
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ }
24
+ function detected(kind, home) {
25
+ try {
26
+ if (resolveAgentBin(kind))
27
+ return true;
28
+ }
29
+ catch { /* 解決できないCLIは未検出として扱う */ }
30
+ return kind === "cursor" && existsSync(join(home, ".cursor"));
31
+ }
32
+ /** aiterm-setupが登録するeventのすべてに、当製品のhook入口があり、その入口が実在するか。 */
33
+ export function claudeParentHookDiagnostic(file) {
34
+ const settings = readSettings(file);
35
+ if (settings === undefined)
36
+ return unreadable;
37
+ if (settings === null || settings.hooks === undefined)
38
+ return required("hooks_not_registered");
39
+ if (!record(settings.hooks))
40
+ return unreadable;
41
+ if (settings.disableAllHooks === true)
42
+ return required("hooks_disabled");
43
+ const entry = AITERM_PROFILE.hooks.claude;
44
+ const owned = (value) => value === entry || value.endsWith(`/${entry}`) || value.endsWith(`\\${entry}`);
45
+ const scripts = [];
46
+ for (const event of Object.keys(steer.claudeParentHookEntries(AITERM_PROFILE, { command: "", script: entry }))) {
47
+ const groups = settings.hooks[event];
48
+ if (groups !== undefined && !Array.isArray(groups))
49
+ return unreadable;
50
+ const found = (groups ?? []).flatMap(group => record(group) && Array.isArray(group.hooks) ? group.hooks : [])
51
+ .filter(hook => record(hook) && hook.type === "command" && Array.isArray(hook.args) && typeof hook.args[0] === "string" && owned(hook.args[0]))
52
+ .map(hook => hook.args[0]);
53
+ if (found.length === 0)
54
+ return required("hooks_not_registered");
55
+ scripts.push(...found);
56
+ }
57
+ return scripts.every(script => existsSync(script)) ? ready : required("hook_script_missing");
58
+ }
59
+ export function cursorParentHookDiagnostic(file) {
60
+ const document = readSettings(file);
61
+ if (document === undefined)
62
+ return unreadable;
63
+ return document !== null && steer.cursorParentHooksRegistered(AITERM_PROFILE, document) ? ready : required("hooks_not_registered");
64
+ }
65
+ /** 呼出元がそのclient自身なら、CLIを解決できなくても設定を読む。それ以外の未検出clientは対象外とする。 */
66
+ export function parentDeliveryDiagnostic(caller, options = {}) {
67
+ const home = options.home ?? process.env.HOME ?? homedir();
68
+ const detect = options.detect ?? ((kind) => detected(kind, home));
69
+ const absent = { status: "not_applicable", reason_code: null };
70
+ const hooks = {
71
+ claude: caller === "claude" || detect("claude")
72
+ ? claudeParentHookDiagnostic(options.claudeSettings ?? join(process.env.CLAUDE_CONFIG_DIR ?? join(home, ".claude"), "settings.json")) : absent,
73
+ cursor: caller === "cursor" || detect("cursor")
74
+ ? cursorParentHookDiagnostic(options.cursorHooks ?? steer.cursorHooksFile(home)) : absent,
75
+ };
76
+ return {
77
+ diagnostic_schema: "aiterm-mcp.parent-delivery-diagnostics.v1",
78
+ caller: caller ?? "other",
79
+ caller_status: caller ? hooks[caller].status : "not_applicable",
80
+ setup_command: AITERM_PROFILE.setup_command,
81
+ hooks,
82
+ };
83
+ }
@@ -75,6 +75,60 @@ export function readRuntimeProcesses() {
75
75
  };
76
76
  });
77
77
  }
78
+ /**
79
+ * 指定したpidの開始時刻だけを引く。readRuntimeProcessesと同じ開始時刻を返し、存在しないpidは結果に含めない。
80
+ * 全processのargvを読む一覧取得は、processの多い端末で重い(定期実行から呼ばない)。
81
+ */
82
+ export function readProcessIdentities(pids) {
83
+ const wanted = [...new Set(pids)];
84
+ if (wanted.length === 0)
85
+ return [];
86
+ if (wanted.some(pid => !Number.isSafeInteger(pid) || pid < 0))
87
+ throw new AitermError("process照会のpidが不正です", 2);
88
+ if (!isWin) {
89
+ const result = spawnSync("/bin/ps", ["-o", "pid=,lstart=", "-p", wanted.join(",")], {
90
+ encoding: "utf8", env: { ...process.env, LC_ALL: "C" }, timeout: 10000,
91
+ });
92
+ // 該当するprocessが一つも無い時、psは何も出さずstatus 1で終わる。
93
+ if (result.error || (result.status !== 0 && !(result.status === 1 && !result.stdout.trim()))) {
94
+ throw new AitermError("OSのprocess一覧を取得できません", 2);
95
+ }
96
+ return result.stdout.split("\n").filter(line => line.trim()).map(line => {
97
+ const match = /^\s*(\d+)\s+(\S+\s+\S+\s+\d+\s+\S+\s+\d+)\s*$/.exec(line);
98
+ if (!match)
99
+ throw new AitermError("process一覧の形式を認識できません", 2);
100
+ return { pid: Number(match[1]), started_identity: match[2].trim() };
101
+ });
102
+ }
103
+ const script = [
104
+ "$ErrorActionPreference='Stop'",
105
+ "[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)",
106
+ `$rows=@(Get-CimInstance Win32_Process -Filter "${wanted.map(pid => `ProcessId=${pid}`).join(" OR ")}" | Where-Object { $null -ne $_.CreationDate -and $null -ne $_.CommandLine } | ForEach-Object {`,
107
+ "[ordered]@{ pid=[int]$_.ProcessId; started_identity=$_.CreationDate.ToUniversalTime().ToString('o') }",
108
+ "})",
109
+ "ConvertTo-Json -Compress -InputObject $rows",
110
+ ].join("\n");
111
+ const result = spawnSync(resolveWindowsPowerShell7(), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], {
112
+ encoding: "utf8", timeout: 15000, windowsHide: true,
113
+ });
114
+ if (result.error || result.status !== 0)
115
+ throw new AitermError("Windowsのnative process一覧を取得できません", 2);
116
+ let rows;
117
+ try {
118
+ rows = JSON.parse(result.stdout);
119
+ }
120
+ catch {
121
+ throw new AitermError("Windows process一覧のJSONを読めません", 2);
122
+ }
123
+ if (!Array.isArray(rows))
124
+ throw new AitermError("Windows process一覧が配列ではありません", 2);
125
+ return rows.map(row => {
126
+ if (!row || !Number.isSafeInteger(row.pid) || typeof row.started_identity !== "string"
127
+ || !Number.isFinite(Date.parse(row.started_identity)))
128
+ throw new AitermError("Windows process一覧のfieldが不正です", 2);
129
+ return { pid: row.pid, started_identity: new Date(row.started_identity).toISOString() };
130
+ });
131
+ }
78
132
  // Windowsの親PIDは親の終了後も残り、別processへ再利用される。子より後に始まったprocessは
79
133
  // 本当の親ではないので、親子関係として辿らない(辿ると循環や無関係なprocessの混入が起きる)。
80
134
  export function parentProcess(row, byPid) {
package/dist/setup-cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { runSetup } from "./setup.js";
2
+ import { runSetup, runParentHooksSetup } from "./setup.js";
3
3
  import { removeClaudeParentHooks, removeCursorParentHooks } from "./setup-integrations.js";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
@@ -26,8 +26,20 @@ else if (args.length === 1 && args[0] === "--remove-cursor-parent-hooks") {
26
26
  process.exitCode = 2;
27
27
  }
28
28
  }
29
+ else if (args.includes("--hooks-only")) {
30
+ // 利用者や他製品がMCP登録を所有する環境向け。hookの登録だけを行い、stdoutへは結果のJSONだけを出す。
31
+ if (args.some(arg => !["--hooks-only", "--json"].includes(arg)) || new Set(args).size !== args.length) {
32
+ process.stderr.write("aiterm-setup: 使い方: aiterm-setup --hooks-only [--json]\n");
33
+ process.exitCode = 2;
34
+ }
35
+ else {
36
+ const result = runParentHooksSetup();
37
+ process.stdout.write(`${JSON.stringify(result)}\n`);
38
+ process.exitCode = result.status === "ready" ? 0 : 2;
39
+ }
40
+ }
29
41
  else if (args.length === 1 && ["--help", "-h"].includes(args[0])) {
30
- process.stdout.write("使い方: aiterm-setup [--json] [--codex-steer enable|disable|status]\n依存準備、AIへの登録、MCPと端末の実動作確認を行います。対話実行ではAiterm単品かCodex Desktop Steer付きかを選べます。SteerはmacOS・Windows対応で、初回はCodexの再起動が必要です。disableは専用hookを解除し、statusは公式hookの登録・承認と再起動の必要性を確認します。旧中継はhook導入後に解除します。--jsonは対話せず、Steerの選択を維持します。\n旧版へ戻す前のClaude専用hook解除: --remove-claude-parent-hooks\n旧版へ戻す前のCursor専用hook解除: --remove-cursor-parent-hooks\n");
42
+ process.stdout.write("使い方: aiterm-setup [--json] [--codex-steer enable|disable|status]\n依存準備、AIへの登録、MCPと端末の実動作確認を行います。対話実行ではAiterm単品かCodex Desktop Steer付きかを選べます。SteerはmacOS・Windows対応で、初回はCodexの再起動が必要です。disableは専用hookを解除し、statusは公式hookの登録・承認と再起動の必要性を確認します。旧中継はhook導入後に解除します。--jsonは対話せず、Steerの選択を維持します。\nClaude Code・Cursorの親配送hookだけの登録(MCP登録・依存準備・Codex Steerには触れない): --hooks-only\n旧版へ戻す前のClaude専用hook解除: --remove-claude-parent-hooks\n旧版へ戻す前のCursor専用hook解除: --remove-cursor-parent-hooks\n");
31
43
  }
32
44
  else {
33
45
  try {
@@ -81,6 +81,34 @@ export function mergeCursorParentHooks(file, registration) {
81
81
  export function removeCursorParentHooks(file) {
82
82
  return steer.removeCursorParentHooks(AITERM_PROFILE, file);
83
83
  }
84
+ function registerClaudeParentHooks(home, registration, run, executable) {
85
+ const version = /\b(\d+)\.(\d+)\.(\d+)\b/.exec(run(executable, ["--version"]));
86
+ if (!version || Number(version[1]) < 2 || (Number(version[1]) === 2 && (Number(version[2]) < 1 || (Number(version[2]) === 1 && Number(version[3]) < 259)))) {
87
+ throw new SetupError("claude_parent_delivery_unavailable", "自動配送にはClaude Code 2.1.259以上が必要です。公式CLIを更新してください");
88
+ }
89
+ return mergeClaudeParentHooks(join(process.env.CLAUDE_CONFIG_DIR ?? join(home, ".claude"), "settings.json"), registration);
90
+ }
91
+ /** 親配送hookだけの登録。clientの検出条件とhookの中身は通常のsetupと同じで、MCP登録は読みも書きもしない。 */
92
+ export function configureParentHooks(home, registration, run = runSetupCommand, resolveClient = resolveAgentBin) {
93
+ const results = {};
94
+ for (const client of ["claude", "cursor"]) {
95
+ try {
96
+ const executable = resolveClient(client);
97
+ if (!executable && !(client === "cursor" && existsSync(join(home, ".cursor")))) {
98
+ results[client] = { status: "not_detected" };
99
+ continue;
100
+ }
101
+ results[client] = { status: client === "claude"
102
+ ? registerClaudeParentHooks(home, registration, run, executable)
103
+ : mergeCursorParentHooks(join(home, ".cursor", "hooks.json"), registration) };
104
+ }
105
+ catch (error) {
106
+ process.stderr.write(`aiterm-setup: ${client}: ${error instanceof Error ? error.message : String(error)}\n`);
107
+ results[client] = { status: "failed", reason_code: error instanceof SetupError ? error.code : "hook_registration_failed" };
108
+ }
109
+ }
110
+ return results;
111
+ }
84
112
  export function configureIntegrations(home, registration, run = runSetupCommand, resolveClient = resolveAgentBin) {
85
113
  const results = {};
86
114
  for (const client of ["claude", "codex", "grok", "cursor"]) {
@@ -97,11 +125,7 @@ export function configureIntegrations(home, registration, run = runSetupCommand,
97
125
  if (client === "cursor")
98
126
  mergeCursorParentHooks(join(home, ".cursor", "hooks.json"), registration);
99
127
  if (client === "claude") {
100
- const version = /\b(\d+)\.(\d+)\.(\d+)\b/.exec(run(executable, ["--version"]));
101
- if (!version || Number(version[1]) < 2 || (Number(version[1]) === 2 && (Number(version[2]) < 1 || (Number(version[2]) === 1 && Number(version[3]) < 259)))) {
102
- throw new SetupError("claude_parent_delivery_unavailable", "自動配送にはClaude Code 2.1.259以上が必要です。公式CLIを更新してください");
103
- }
104
- mergeClaudeParentHooks(join(process.env.CLAUDE_CONFIG_DIR ?? join(home, ".claude"), "settings.json"), registration);
128
+ registerClaudeParentHooks(home, registration, run, executable);
105
129
  }
106
130
  }
107
131
  else if (client === "codex") {
@@ -120,11 +144,14 @@ export function configureIntegrations(home, registration, run = runSetupCommand,
120
144
  if (!Array.isArray(servers))
121
145
  throw new SetupError("config_readback_failed", "CodexのMCP一覧形式を確認できません");
122
146
  const existing = servers.find((entry) => entry.name === "aiterm");
123
- const envArgs = Object.entries(existing?.transport?.env ?? {}).flatMap(([key, value]) => ["--env", `${key}=${value}`]);
124
- run(executable, ["mcp", "add", "aiterm", ...envArgs, "--", registration.command, ...registration.args]);
125
- const value = JSON.parse(run(executable, ["mcp", "get", "aiterm", "--json"]));
126
- if (value.transport?.command !== registration.command || !isDeepStrictEqual(value.transport?.args, registration.args)) {
127
- throw new SetupError("config_readback_failed", "Codexのaiterm登録が一致しません");
147
+ // 公式CLIの追加は登録を作り直し、利用者が足した項目(待ち時間など)を落とす。同じ登録なら書き直さない。
148
+ if (existing?.transport?.command !== registration.command || !isDeepStrictEqual(existing?.transport?.args, registration.args)) {
149
+ const envArgs = Object.entries(existing?.transport?.env ?? {}).flatMap(([key, value]) => ["--env", `${key}=${value}`]);
150
+ run(executable, ["mcp", "add", "aiterm", ...envArgs, "--", registration.command, ...registration.args]);
151
+ const value = JSON.parse(run(executable, ["mcp", "get", "aiterm", "--json"]));
152
+ if (value.transport?.command !== registration.command || !isDeepStrictEqual(value.transport?.args, registration.args)) {
153
+ throw new SetupError("config_readback_failed", "Codexのaiterm登録が一致しません");
154
+ }
128
155
  }
129
156
  }
130
157
  else {
@@ -132,13 +159,16 @@ export function configureIntegrations(home, registration, run = runSetupCommand,
132
159
  if (!Array.isArray(servers))
133
160
  throw new SetupError("config_readback_failed", "GrokのMCP一覧形式を確認できません");
134
161
  const existing = servers.find((entry) => entry.name === "aiterm" && entry.scope === "user");
135
- const envArgs = Object.entries(existing?.env ?? {}).flatMap(([key, value]) => ["--env", `${key}=${value}`]);
136
- run(executable, ["mcp", "add", "--scope", "user", "aiterm", ...envArgs, "--", registration.command, ...registration.args]);
137
- const value = JSON.parse(run(executable, ["mcp", "list", "--json"]));
138
- // 公開CLIのJSON応答を照合する。未知schemaを成功へ丸めない。
139
- const item = Array.isArray(value) ? value.find((entry) => entry.name === "aiterm") : null;
140
- if (!item || item.command !== registration.command || !isDeepStrictEqual(item.args, registration.args)) {
141
- throw new SetupError("config_readback_failed", "Grokのaiterm登録が一致しません");
162
+ // Codexと同じく、同じ登録なら書き直さない。
163
+ if (existing?.command !== registration.command || !isDeepStrictEqual(existing?.args, registration.args)) {
164
+ const envArgs = Object.entries(existing?.env ?? {}).flatMap(([key, value]) => ["--env", `${key}=${value}`]);
165
+ run(executable, ["mcp", "add", "--scope", "user", "aiterm", ...envArgs, "--", registration.command, ...registration.args]);
166
+ const value = JSON.parse(run(executable, ["mcp", "list", "--json"]));
167
+ // 公開CLIのJSON応答を照合する。未知schemaを成功へ丸めない。
168
+ const item = Array.isArray(value) ? value.find((entry) => entry.name === "aiterm") : null;
169
+ if (!item || item.command !== registration.command || !isDeepStrictEqual(item.args, registration.args)) {
170
+ throw new SetupError("config_readback_failed", "Grokのaiterm登録が一致しません");
171
+ }
142
172
  }
143
173
  }
144
174
  results[client] = { status: "ready" };
package/dist/setup.js CHANGED
@@ -7,16 +7,33 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
7
7
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
8
8
  import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
9
9
  import { prepareBackend, runSetupCommand, SetupError } from "./setup-platform.js";
10
- import { configureIntegrations } from "./setup-integrations.js";
10
+ import { configureIntegrations, configureParentHooks } from "./setup-integrations.js";
11
11
  import { configureCodexSteer, codexSteerSelected } from "./setup-codex-hooks.js";
12
12
  import { setupNodeExecutable } from "./setup-node.js";
13
- export function globalRegistration(run = runSetupCommand) {
13
+ /** 実行中のNodeに付属するnpmの既定のglobal root。npmのprefixを環境変数や設定で別の場所へ向けていても変わらない。 */
14
+ export function nodeDefaultGlobalRoot(execPath = process.execPath, platform = process.platform) {
15
+ return platform === "win32" ? join(dirname(execPath), "node_modules") : join(dirname(dirname(execPath)), "lib", "node_modules");
16
+ }
17
+ /**
18
+ * 登録するのはglobal導入した当packageだけ。npmの現在のglobal rootに加え、実行中のNodeの既定のglobal rootも導入先と認める。
19
+ * npmのprefixを利用者ごとの場所へ向けた環境では、PATH上のaiterm-setupが後者に属する(ADR 0077)。
20
+ */
21
+ export function globalRegistration(run = runSetupCommand, options = {}) {
14
22
  const root = run(process.platform === "win32" ? "npm.cmd" : "npm", ["root", "-g"]).trim();
15
23
  if (!isAbsolute(root) || /[\r\n]/u.test(root))
16
24
  throw new SetupError("global_package_required", "npm global rootを確認できません");
17
- const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
18
- const installedRoot = join(root, "aiterm-mcp");
19
- if (realpathSync(installedRoot) !== realpathSync(packageRoot)) {
25
+ const packageRoot = options.packageRoot ?? dirname(dirname(fileURLToPath(import.meta.url)));
26
+ const current = join(root, "aiterm-mcp");
27
+ const same = (candidate) => { try {
28
+ return realpathSync(candidate) === realpathSync(packageRoot);
29
+ }
30
+ catch {
31
+ return false;
32
+ } };
33
+ const installedRoot = [current, join(options.defaultRoot ?? nodeDefaultGlobalRoot(), "aiterm-mcp")].find(same);
34
+ if (!installedRoot) {
35
+ // どちらにも当packageが無い時の失敗は従来のまま(導入先が無ければここで読めずに落ちる)。
36
+ realpathSync(current);
20
37
  throw new SetupError("global_package_required", "npm install -g aiterm-mcp後にaiterm-setupを実行してください。一時npm cacheやsource checkoutは登録しません");
21
38
  }
22
39
  return { command: setupNodeExecutable(), args: [join(installedRoot, "dist", "index.js")] };
@@ -100,3 +117,28 @@ export async function runSetup(options = {}) {
100
117
  }
101
118
  return result;
102
119
  }
120
+ /** Claude Code・Cursorの親配送hookだけを登録する。依存準備、端末の実動作確認、MCP登録、Codex Steerには触れない。 */
121
+ export function runParentHooksSetup(options = {}) {
122
+ const result = { schema: "aiterm.parent-hooks-result.v1", status: "failed", hooks: {} };
123
+ const progress = options.progress ?? ((message) => process.stderr.write(`aiterm-setup: ${message}\n`));
124
+ let stage = "global_package";
125
+ try {
126
+ const registration = (options.registration ?? globalRegistration)();
127
+ stage = "hooks";
128
+ progress("検出したAI clientの親配送hookを登録して確認します");
129
+ result.hooks = (options.configure ?? configureParentHooks)(process.env.HOME ?? homedir(), registration);
130
+ if (Object.values(result.hooks).some((item) => item.status === "failed"))
131
+ result.reason_code = "hook_registration_failed";
132
+ else if (Object.values(result.hooks).every((item) => item.status === "not_detected")) {
133
+ result.status = "unsupported";
134
+ result.reason_code = "clients_not_detected";
135
+ }
136
+ else
137
+ result.status = "ready";
138
+ }
139
+ catch (error) {
140
+ result.reason_code = error instanceof SetupError ? error.code : `${stage}_failed`;
141
+ progress(error instanceof Error ? error.message : String(error));
142
+ }
143
+ return result;
144
+ }
package/docs/DESIGN.md CHANGED
@@ -15,6 +15,9 @@ Aitermは、AIがローカルshell、SSH、container、REPL、別agentの対話T
15
15
  Claude/CursorのJSONは参照先を原子的に更新して変更前backupを残す。Codex/Grokは公式CLIで登録・確認する。
16
16
  各AIの読戻しは登録内容の確認であり、端末の実動作はその前の公開MCP試験で確認する。
17
17
  失敗は理由付きJSONと非ゼロ終了で返す。対応外の自動導入と全AI未検出を成功扱いしない。
18
+ global packageは、npmの現在のglobal rootか、実行中のNodeの既定のglobal rootにあるものを指す。
19
+ CodexとGrokは登録が同じなら公式CLIで作り直さない。`aiterm-setup --hooks-only`はClaude Code・Cursorの親配送hookだけを登録し、
20
+ 依存準備、端末の実動作確認、MCP登録、Codex Steerに触れない。判断はADR 0077。
18
21
 
19
22
  ## Terminal model
20
23
 
@@ -133,6 +136,8 @@ Codexの配送記録と本文はAiterm stateの`parent-deliveries`へ保存す
133
136
  再接続後は終了したownerの記録だけを原子的に引き継ぐ。`waiting`は同じ境界から観測を再開し、
134
137
  `ready`は保存した本文を送る。送信中断は`unknown`として本文を残し、自動再送しない。
135
138
  受信口が明示拒否した場合は`failed`、子の異常終了はそのoutcomeを配送する。
139
+ 生存の確認は5秒おきにpidの存在だけをOSへ聞き、開始識別子の照合は初めて見たownerと60秒に1回だけ行う。
140
+ 全processの一覧は取らない。終了直後に同じpidが再利用された時だけ、引き継ぎが最長60秒遅れる(ADR 0078)。
136
141
  このstateは既存のPTY/harness stateと独立し、旧版は配送を再開しない。
137
142
 
138
143
  ### Claude Code親への自動配送
@@ -192,6 +197,8 @@ Claude Codeの初回テーマ選択は選択済みの項目を確定し、後続
192
197
  ログイン方式の選択はユーザーのアカウント設定として扱い、composerと誤認せず`vendor_onboarding_required`で止める。
193
198
  promptなしでも入力受付とharness生存を確認して`startup.ready`を返す。指定なしのpromptなし起動は
194
199
  従来どおり`startup.not_checked`で返す。初手receiptは未要求・未送信・送信済み未確認・開始確認を分ける。
200
+ 入力受付の待ちは30秒。画面が起動コマンドの表示のまま(agentがまだ何も描いていない)で切れた時だけ、
201
+ 起動から50秒まで待つ。Codex親の既定のtool timeout(60秒)の内側に収める(ADR 0078)。
195
202
  開始の証拠は送信後の実行表示、実行中の既知承認、または同じcursor以降の完了だけとし、残存なしでは代用しない。
196
203
  Codexの設定エラー等でCLIが終了した場合は、残った画面へ送らず未送信で止める。
197
204
 
@@ -316,6 +323,8 @@ privacy案内は現在の枠付きcomposerとmodel footerが見える場合だ
316
323
  製品所有のlocal stateに固定codeと集約metadataだけを保存し、network I/Oを持たない。工場reporterとの
317
324
  連携は明示opt-inの任意adapterであり、未設定時もAiterm本体は単独動作する。raw error、prompt、出力、
318
325
  transcript、path、credentialを保存・公開しない。
326
+ 親配送hook(Claude Code・Cursor)の登録状態は`src/parent-hook-diagnostic.ts`が利用者設定の読取りだけで要約し、
327
+ 2つ目のtextとして返す。1つ目のfactory向けJSONは項目も`overall`の意味も変えない(ADR 0077)。
319
328
 
320
329
  ## 変更条件
321
330
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiterm-mcp",
3
- "version": "0.46.1",
3
+ "version": "0.47.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": [