aiterm-mcp 0.53.1 → 0.54.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,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.54.0] - 2026-10-06
11
+
12
+ ### 変更
13
+
14
+ - `pty_read(agent_transcript:true)`は、回答が空で終わったturnを誤りにせず、`text`が空文字列、`answer_empty`が`true`の
15
+ 結果で返す(ADR 0092)。`aiterm.pty-read-result.v1`へ`answer_empty`を足した(回答がある時は`false`、terminalの読み取りは`null`)。
16
+ 今までは、回答を空で終えただけのturnと、記録の形が変わって回答を読めなくなった時が、同じ
17
+ 「最終 assistant メッセージを特定できませんでした」の誤りになり、呼び出し側が見分けられなかった。
18
+ Codex(0.160.1)は、道具で用を済ませて回答を空で終えたturnを、`text`が空の`final_answer`として記録に残す。
19
+ 誤りにするのは、回答の場所を記録から見つけられない時だけにした(文面は同じ)。
20
+
21
+ ### 修正
22
+
23
+ - 親への自動配送で、Codexの子が回答を空で終えると、親へ`AGENT_RESULT_UNAVAILABLE: transcript がまだありません。
24
+ ターン完了後に再取得してください`が`outcome=error`で届いていた。turnは終わっているので、
25
+ 「子のターンは完了しましたが、回答の本文は空でした。」を`outcome=done`で届ける。Claude Code・Grok・Cursorの子も同じ。
26
+ - Codexの回答の読み取りは、turn ID付きの本文が空の時に、turn IDの無い旧形式の本文(前のturnの物かもしれない)へ
27
+ 落とす作りだった。turn ID付きの本文が記録にあれば、空でもそれを回答とする。今のCodexは旧形式を書かず、
28
+ 実記録で前のturnの回答が返った例は確認していない。
29
+
30
+ ## [0.53.2] - 2026-10-05
31
+
32
+ ### 修正
33
+
34
+ - 引数なしの`aiterm-setup`が、中継(`mcp-lazy`)で包んだAitermの登録を直結へ書き戻していた(ADR 0091)。
35
+ 連携元が全席の登録を中継つきにした後、1席がsetupを流すだけで、Claude Code・Codex・Grok・Cursorの4つの設定が直結へ戻り、
36
+ 本体を眠らせておく節約が次の書き直しまで消えた。Codexは`mcp add`で作り直されるので、待ち時間(`startup_timeout_sec`・
37
+ `tool_timeout_sec`)と`env_vars`も一緒に消えた。
38
+ commandが`mcp-lazy`で始まる名前の実行ファイルで、argsが直結の登録のcommand+args(中継のflagを`--`の前に置いた形も含む)なら、
39
+ 同じ登録と数えて書き直さない。包んでいる本体のpathが違う時は、今までどおり直結の登録へ書き直す。
40
+
10
41
  ## [0.53.1] - 2026-10-05
11
42
 
12
43
  ### 修正
@@ -2204,7 +2235,9 @@ prototype (preserved under `prototype/python/` as the porting source and referen
2204
2235
  `ubuntu-latest` for Node 18/20/22, publishing to npm on `v*` tags with
2205
2236
  provenance.
2206
2237
 
2207
- [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.53.1...HEAD
2238
+ [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.54.0...HEAD
2239
+ [0.54.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.53.2...v0.54.0
2240
+ [0.53.2]: https://github.com/kitepon/aiterm-mcp/compare/v0.53.1...v0.53.2
2208
2241
  [0.53.1]: https://github.com/kitepon/aiterm-mcp/compare/v0.53.0...v0.53.1
2209
2242
  [0.53.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.52.3...v0.53.0
2210
2243
  [0.52.3]: https://github.com/kitepon/aiterm-mcp/compare/v0.52.2...v0.52.3
package/README.ja.md CHANGED
@@ -209,7 +209,7 @@ runtime-error store は canonical dotagents config の `collection.enabled: true
209
209
  場合だけ収集し、既定OFF、network送信は行いません。tag起点CIのnpm provenance(OIDC Trusted
210
210
  Publishing)で公開し、GitHub Release が Official MCP Registry を再登録します。
211
211
 
212
- **状態:** 開発継続中 · 現行公開版 **v0.53.1** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
212
+ **状態:** 開発継続中 · 現行公開版 **v0.54.0** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
213
213
 
214
214
  ### 更新と巻き戻し
215
215
 
@@ -536,6 +536,8 @@ MCPの本体を初めて使う時まで起こさない中継の後ろにAiterm
536
536
  `MCP_LAZY_WAKE_COMMAND='["/absolute/path/to/node","/absolute/path/to/aiterm-mcp/dist/delivery-wake-cli.js","--parent","claude"]'`
537
537
  持ち主の開始時刻はLinuxでは`/proc`で照合する。それ以外のOSでは、pidがあれば生きている持ち主として扱う。
538
538
 
539
+ `aiterm-setup`は、中継(`mcp-lazy`)で包んだ同じ登録を作り直さない。commandが`mcp-lazy`で始まる名前の実行ファイルで、argsが直結の登録のcommand+args(中継のflagを`--`の前に置いた形も含む)なら、Claude Code・Codex・Grok・Cursorの登録をそのまま残す。包んでいる本体のpathが違う時は、直結の登録へ書き直す。
540
+
539
541
  `agent_launch({ harness, cwd, trust_project: true })`はpromptなしでも既知のworkspace・project hooks・MCP初期同意を
540
542
  進め、入力受付とharness生存を確認して`startup.status="ready"`を返す。指定なしのpromptなし起動は`not_checked`。
541
543
  Claude Code初回起動の文字表示テーマ選択では、画面で選択済みの項目を確定して起動を続ける。
@@ -646,7 +648,7 @@ handoff contextを前置きできる。この任意経路は`throughline >= 0.9.
646
648
  `THROUGHLINE_BIN`、次に`PATH`から解決し、不在・不正・空のexportはPTY作成前に明示失敗する。
647
649
  任意の`throughline_supplement_file`はThroughline 0.10.8以降と`throughline_source_session`を必要とし、Aitermは内容を解釈せずThroughlineへ渡す。
648
650
 
649
- エージェントの回答が画面tailより長ければ、`pty_read({ agent_transcript:true })`で再promptなしに全文回収する。既存の人間向けcontentは診断suffixを維持し、機械呼出し側は`aiterm.pty-read-result.v1`の`structuredContent.text`から回答本文だけを取得する。Claudeはlaunch相関Stop hook、Codexは通常rollout、Grokは最後の実user行以後の最後の空でないassistantメッセージだけ、Cursorはlaunch IDでbindした通常agent transcriptから同じturnを回収する。
651
+ エージェントの回答が画面tailより長ければ、`pty_read({ agent_transcript:true })`で再promptなしに全文回収する。既存の人間向けcontentは診断suffixを維持し、機械呼出し側は`aiterm.pty-read-result.v1`の`structuredContent.text`から回答本文だけを取得する。Claudeはlaunch相関Stop hook、Codexは通常rollout、Grokは最後の実user行以後の最後の空でないassistantメッセージだけ、Cursorはlaunch IDでbindした通常agent transcriptから同じturnを回収する。回答を空で終えたturnは誤りにせず、`text`が空文字列、`answer_empty`が`true`で返すので、回答を読めなかった時と見分けられる。回答の場所を記録から見つけられない時だけ誤りにする。
650
652
 
651
653
  ### 完了検出(5 層)
652
654
 
package/README.md CHANGED
@@ -223,7 +223,7 @@ collection is off by default and performs no network I/O. It ships via
223
223
  tag-triggered CI with npm provenance (OIDC Trusted Publishing); the GitHub
224
224
  Release re-registers the Official MCP Registry entry.
225
225
 
226
- **Status:** actively maintained · current public release **v0.53.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.54.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).
227
227
 
228
228
  ### Update and rollback
229
229
 
@@ -572,6 +572,8 @@ same environment as the server:
572
572
  `MCP_LAZY_WAKE_COMMAND='["/absolute/path/to/node","/absolute/path/to/aiterm-mcp/dist/delivery-wake-cli.js","--parent","claude"]'`.
573
573
  Owner start times are checked through `/proc` on Linux; elsewhere an existing PID is treated as a live owner.
574
574
 
575
+ `aiterm-setup` does not rewrite a registration that wraps the same server in the `mcp-lazy` relay. When the registered command is an executable whose name starts with `mcp-lazy` and its args are the direct registration's command followed by its args (relay flags before `--` are allowed), the Claude Code, Codex, Grok, and Cursor registrations are left as they are. If the wrapped server path differs, setup writes the direct registration.
576
+
575
577
  `agent_launch({ harness, cwd, trust_project: true })` completes known workspace, project-hook, and project-MCP startup
576
578
  consent even without a prompt, then verifies input readiness and harness liveness before returning `startup.status="ready"`.
577
579
  For Claude Code's first-run text-style menu, it confirms the item already selected on screen before continuing startup.
@@ -687,7 +689,7 @@ unchanged. Optional `throughline_supplement_file` is passed unchanged to Through
687
689
  `throughline_source_session` and Throughline 0.10.8 or later; Aiterm does not read or classify the supplement. Throughline is resolved through `THROUGHLINE_BIN` and then `PATH`; a missing or invalid
688
690
  export fails before the PTY exists instead of silently launching clean.
689
691
 
690
- When an agent's answer is longer than the on-screen tail (pane height ≈ 24 lines), callers recover it in full with `pty_read({ agent_transcript: true })`. It returns the most recently completed turn's final assistant message in plain text with no re-prompting. The existing human-readable content keeps its diagnostic suffix; machine callers read the answer alone from `structuredContent.text` in `aiterm.pty-read-result.v1`. Claude reads the bounded owner-only result captured by the launch-correlated Stop hook and verifies its digest/byte count; it never reads Claude's private transcript. Durable machine callers should use `claude_turn`: `issue` sends once, `recover` never sends, `pending` is distinct from unsafe or malformed state, and only `completed` carries the exact verified `raw_output`. Codex uses the normal rollout transcript's `task_complete.turn_id`; Grok returns the last non-empty assistant message after the last real user row, excluding tool-use preambles; Cursor uses the normal agent transcript bound to the launch ID and current turn. Missing or ambiguous attribution remains an explicit error.
692
+ When an agent's answer is longer than the on-screen tail (pane height ≈ 24 lines), callers recover it in full with `pty_read({ agent_transcript: true })`. It returns the most recently completed turn's final assistant message in plain text with no re-prompting. The existing human-readable content keeps its diagnostic suffix; machine callers read the answer alone from `structuredContent.text` in `aiterm.pty-read-result.v1`. Claude reads the bounded owner-only result captured by the launch-correlated Stop hook and verifies its digest/byte count; it never reads Claude's private transcript. Durable machine callers should use `claude_turn`: `issue` sends once, `recover` never sends, `pending` is distinct from unsafe or malformed state, and only `completed` carries the exact verified `raw_output`. Codex uses the normal rollout transcript's `task_complete.turn_id`; Grok returns the last non-empty assistant message after the last real user row, excluding tool-use preambles; Cursor uses the normal agent transcript bound to the launch ID and current turn. A turn that completed with an empty answer is not an error: `text` is the empty string and `answer_empty` is `true`, so callers can tell it apart from an answer that could not be read. Missing or ambiguous attribution, including an answer that cannot be located in the record, remains an explicit error.
691
693
 
692
694
  ### Completion detection (5 layers)
693
695
 
package/dist/core.js CHANGED
@@ -2681,6 +2681,8 @@ function agentCompletionCursor(meta) {
2681
2681
  function transcriptUnavailable() {
2682
2682
  throw new AitermError(`transcript がまだありません。ターン完了後に再取得してください。${agentWaitGuide()}`, 2);
2683
2683
  }
2684
+ /** 完了したturnの回答が空だった時に、人とAIが読む表示へ出す文。機械はstructuredContentのanswer_emptyを見る。 */
2685
+ const AGENT_ANSWER_EMPTY_NOTICE = "このターンは完了しましたが、回答の本文は空でした。";
2684
2686
  function transcriptNotFound(vendor) {
2685
2687
  throw new AitermError(`最終 assistant メッセージを特定できませんでした(harness=${agentHarness(vendor)}, vendor=${vendor})。screen で確認してください。`, 2);
2686
2688
  }
@@ -2763,7 +2765,8 @@ export async function readAgentTranscriptResult(name, o = {}) {
2763
2765
  throw new AitermError(`operation ${operationId} はまだ完了していません。同じoperation_idで後から再取得してください。${agentWaitGuide(name)}`, 2);
2764
2766
  }
2765
2767
  const turnId = o.completion?.turn_id ?? done?.turn_id ?? null;
2766
- let text = "";
2768
+ // 各harnessの読み取りは、回答の場所を見つけたら文字列(空でもよい)、見つからなければnullを返す。
2769
+ let text;
2767
2770
  if (meta.kind === "claude") {
2768
2771
  if (!done)
2769
2772
  transcriptUnavailable();
@@ -2780,12 +2783,16 @@ export async function readAgentTranscriptResult(name, o = {}) {
2780
2783
  transcriptUnavailable();
2781
2784
  text = grokTranscriptText(meta, readTranscriptLines, transcriptUnavailable, o.completion?.turn_id);
2782
2785
  }
2783
- if (!text.trim())
2786
+ // 回答の場所が見つからない時だけ誤りにする。場所があって中身が空白だけのturnは「回答が空」として返す。
2787
+ if (text === null)
2784
2788
  transcriptNotFound(meta.kind);
2785
- if (o.lines != null)
2789
+ const answerEmpty = !text.trim();
2790
+ if (answerEmpty)
2791
+ text = "";
2792
+ else if (o.lines != null)
2786
2793
  text = text.split("\n").slice(-o.lines).join("\n");
2787
2794
  const rawChars = text.length;
2788
- const [body, outputMeta] = o.raw ? [text, ""] : reduceOutput(text, name, true);
2795
+ const [body, outputMeta] = answerEmpty || o.raw ? [text, ""] : reduceOutput(text, name, true);
2789
2796
  const transcriptMeta = [
2790
2797
  "agent_transcript",
2791
2798
  `vendor=${meta.kind}`,
@@ -2793,14 +2800,16 @@ export async function readAgentTranscriptResult(name, o = {}) {
2793
2800
  `harness=${agentHarness(meta.kind)}`,
2794
2801
  done?.operation_id ? `operation_id=${done.operation_id}` : null,
2795
2802
  `raw_chars=${rawChars}`,
2803
+ answerEmpty ? "answer_empty=true" : null,
2796
2804
  ].filter(Boolean).join(" ");
2797
2805
  return {
2798
2806
  text: body,
2799
- display: `${body}\n${outputMeta} [${transcriptMeta}]`,
2807
+ display: `${answerEmpty ? AGENT_ANSWER_EMPTY_NOTICE : body}\n${outputMeta} [${transcriptMeta}]`,
2800
2808
  vendor: meta.kind,
2801
2809
  turn_id: turnId,
2802
2810
  harness: agentHarness(meta.kind),
2803
2811
  raw_chars: rawChars,
2812
+ answer_empty: answerEmpty,
2804
2813
  };
2805
2814
  }
2806
2815
  /** 人間向け互換表示を維持する。機械利用は readAgentTranscriptResult の text を使う。 */
@@ -618,6 +618,9 @@ export function codexMoreReasoningChoice(screen) {
618
618
  return null;
619
619
  }
620
620
  // 回収対象turnの最終assistantメッセージをroot rollout transcriptから抽出する。
621
+ // 回答の場所が記録にあれば、中身が空でも文字列を返す。場所が見つからない時(記録の形が変わった時を含む)はnull。
622
+ // Codexは回答を空で終えたturnも、textが空のfinal_answerをturn ID付きで残す(0.160.1の実記録 2026-10-06。
623
+ // task_completeのlast_agent_messageはnull)。
621
624
  export function codexTranscriptText(meta, turnId, readTranscriptLines, transcriptUnavailable, exactCompletion = false) {
622
625
  if (!meta.codex_home || !meta.vendor_session_id)
623
626
  transcriptUnavailable();
@@ -642,7 +645,7 @@ export function codexTranscriptText(meta, turnId, readTranscriptLines, transcrip
642
645
  if (typeof payload.last_agent_message === "string")
643
646
  return payload.last_agent_message;
644
647
  const text = matching.text();
645
- if (text)
648
+ if (text !== null)
646
649
  return text;
647
650
  transcriptUnavailable();
648
651
  }
@@ -650,7 +653,7 @@ export function codexTranscriptText(meta, turnId, readTranscriptLines, transcrip
650
653
  transcriptUnavailable();
651
654
  }
652
655
  const matching = codexTurnAnswer();
653
- let finalAnswer = "";
656
+ let finalAnswer = null;
654
657
  for (const line of lines) {
655
658
  if (!line.trim())
656
659
  continue;
@@ -670,7 +673,9 @@ export function codexTranscriptText(meta, turnId, readTranscriptLines, transcrip
670
673
  finalAnswer = payload.message;
671
674
  }
672
675
  }
673
- return matching.text() || finalAnswer;
676
+ // turnの本文が記録にあれば、空でもそれが回答。agent_message(turn IDの無い旧形式)は別turnの物かもしれないので、
677
+ // turnの本文が見つからず、かつ空でない時だけ使う。
678
+ return matching.text() ?? (finalAnswer?.trim() ? finalAnswer : null);
674
679
  }
675
680
  // turnのassistant本文を集める。Codexは作業途中の報告をphase=commentary、回答をphase=final_answerで残すので、
676
681
  // final_answerがあるturnは途中の報告を回答に含めない。phaseの無い旧形式はturnの本文を全部つなぐ。
@@ -691,7 +696,10 @@ function codexTurnAnswer() {
691
696
  finals.push(item.text);
692
697
  }
693
698
  },
699
+ // turnのassistant本文が1つも無ければnull(回答の場所が見つからない)。
694
700
  text() {
701
+ if (all.length === 0)
702
+ return null;
695
703
  return (finals.length > 0 ? finals : all).join("\n");
696
704
  },
697
705
  };
@@ -245,6 +245,10 @@ export function cursorTranscriptText(meta, readTranscriptLines, transcriptUnavai
245
245
  transcriptUnavailable();
246
246
  // tool_useで区切った本文の塊を持つ。道具を呼ぶ前の文は作業途中の報告なので、回答は最後の塊だけにする。
247
247
  let current = [[]];
248
+ // 回答の場所が分かるturnか。assistant行があり、そのpartがtextとtool_useだけの時に限る(実記録にあるのはこの2種類)。
249
+ // 知らないpartがあるturnは、本文がそこへ移ったのかもしれないので「回答が空」とは言わない。
250
+ let assistantRows = 0;
251
+ let unknownParts = false;
248
252
  let completed = null;
249
253
  for (const line of readTranscriptLines(transcript)) {
250
254
  if (!line.trim())
@@ -256,22 +260,34 @@ export function cursorTranscriptText(meta, readTranscriptLines, transcriptUnavai
256
260
  catch {
257
261
  continue;
258
262
  }
259
- if (record?.role === "user")
263
+ if (record?.role === "user") {
260
264
  current = [[]];
265
+ assistantRows = 0;
266
+ unknownParts = false;
267
+ }
261
268
  if (record?.role === "assistant" && Array.isArray(record?.message?.content)) {
269
+ assistantRows += 1;
262
270
  for (const part of record.message.content) {
263
271
  if (part?.type === "text" && typeof part?.text === "string")
264
272
  current.at(-1).push(part.text);
265
- else if (part?.type === "tool_use" && current.at(-1).length > 0)
266
- current.push([]);
273
+ else if (part?.type === "tool_use") {
274
+ if (current.at(-1).length > 0)
275
+ current.push([]);
276
+ }
277
+ else
278
+ unknownParts = true;
267
279
  }
268
280
  }
269
- if (record?.type === "turn_ended")
270
- completed = current.map((chunk) => [...chunk]);
281
+ if (record?.type === "turn_ended") {
282
+ completed = { chunks: current.map((chunk) => [...chunk]), located: assistantRows > 0 && !unknownParts };
283
+ }
271
284
  }
272
285
  if (completed === null)
273
286
  transcriptUnavailable();
274
- return (completed.filter((chunk) => chunk.length > 0).at(-1) ?? []).join("\n");
287
+ const text = (completed.chunks.filter((chunk) => chunk.length > 0).at(-1) ?? []).join("\n");
288
+ if (text.trim())
289
+ return text;
290
+ return completed.located ? "" : null;
275
291
  }
276
292
  export function createCursorAgentMetadata(name, cwd, initialPrompt, writeScope, lineage) {
277
293
  const launchId = randomBytes(16).toString("hex");
@@ -500,9 +500,12 @@ export function grokTranscriptText(meta, readTranscriptLines, transcriptUnavaila
500
500
  const replies = records
501
501
  .slice(lastUser + 1, end)
502
502
  .filter((record) => record?.type === "assistant" && typeof record?.content === "string")
503
- .map((record) => record.content.trim())
504
- .filter(Boolean);
505
- return replies.at(-1) ?? "";
503
+ .map((record) => record.content.trim());
504
+ // contentが文字列のassistant行が1つも無ければnull(回答の場所が見つからない)。
505
+ // 道具を呼ぶだけの行はcontentが空文字列で残るので、そういう行だけのturnは「回答が空」になる。
506
+ if (replies.length === 0)
507
+ return null;
508
+ return replies.filter(Boolean).at(-1) ?? "";
506
509
  }
507
510
  export function createGrokAgentMetadata(kind, name, cwd, initialPrompt, authPath, writeScope, lineageContext) {
508
511
  const launchId = randomBytes(16).toString("hex");
package/dist/index.js CHANGED
@@ -413,6 +413,7 @@ registerRemoteAwareTool("pty_read", {
413
413
  description: "セッションの出力をトークン削減して読む(既定は前回読取位置からの増分)。" +
414
414
  "削減: 制御文字除去 / 反復圧縮 / head+tail 折りたたみ+復元ヒント+メタ併記。" +
415
415
  "agent_transcript:true は agent session の直近完了ターンの最終 assistant メッセージを公開されたharness記録から平文で返す。" +
416
+ "回答が空で終わったターンは誤りにせず、空のtextとanswer_empty:trueを返す。" +
416
417
  "長い回答が screen tail で切れた時の回収用。",
417
418
  inputSchema: {
418
419
  session_id: z.string(),
@@ -454,6 +455,12 @@ registerRemoteAwareTool("pty_read", {
454
455
  turn_id: z.string().nullable(),
455
456
  harness: z.enum(["claude-code", "codex-cli", "grok-cli", "cursor-cli"]).nullable(),
456
457
  raw_chars: z.number().int().nonnegative().nullable(),
458
+ // remote付きは現地の結果をそのまま返す。この項目が無い旧版(0.53.2以前)の結果も通すのでoptional。
459
+ answer_empty: z
460
+ .boolean()
461
+ .nullable()
462
+ .optional()
463
+ .describe("agent_transcriptで、完了したturnの回答が空だった時だけtrue(textは空文字列)。回答を読めなかった時は誤りを返す。terminalではnull"),
457
464
  },
458
465
  }, async ({ session_id, wait, until, until_regex, timeout, screen, full, lines, line_range, raw, rtk, agent_transcript, operation_id }) => {
459
466
  try {
@@ -478,6 +485,7 @@ registerRemoteAwareTool("pty_read", {
478
485
  turn_id: result.turn_id,
479
486
  harness: result.harness,
480
487
  raw_chars: result.raw_chars,
488
+ answer_empty: result.answer_empty,
481
489
  },
482
490
  };
483
491
  }
@@ -521,6 +529,7 @@ registerRemoteAwareTool("pty_read", {
521
529
  turn_id: null,
522
530
  harness: null,
523
531
  raw_chars: null,
532
+ answer_empty: null,
524
533
  },
525
534
  };
526
535
  }
@@ -0,0 +1,8 @@
1
+ // 中継(mcp-lazy)の見分け。取り決めはmcp-lazyのREADMEにある(実行ファイルの名前が`mcp-lazy`で始まる)。
2
+ // process表の読み手(process-runtime)と、登録の読み手(setup-integrations)が同じ決まりを使う。
3
+ // setup-integrationsは連携元のprocessへ直接読み込まれるので、ここはnode:pathだけに依存する。
4
+ import path from "node:path";
5
+ /** 実行ファイルのpathが中継(mcp-lazy)か。名前(basename)が`mcp-lazy`で始まる物。 */
6
+ export function lazyRelayExecutable(file) {
7
+ return path.posix.basename(file.replace(/\\/g, "/")).toLowerCase().startsWith("mcp-lazy");
8
+ }
@@ -300,7 +300,8 @@ export class ParentDeliveryManager {
300
300
  ...(record.boundary.remote ? { remote: record.boundary.remote } : {}) });
301
301
  if (record.state !== "waiting")
302
302
  return;
303
- record.text = answer.text;
303
+ // 回答を空で終えたturnも完了として届ける。親が完了を知る手段はこの配送だけなので、本文の代わりに知らせを入れる。
304
+ record.text = answer.text.trim() ? answer.text : "子のターンは完了しましたが、回答の本文は空でした。";
304
305
  }
305
306
  else {
306
307
  record.text = completion.error ?? completion.rate_limit ?? "子のセッションが閉じられたため、回答はありません。";
@@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process";
3
3
  import { createHash } from "node:crypto";
4
4
  import path from "node:path";
5
5
  import { AitermError } from "./errors.js";
6
+ import { lazyRelayExecutable } from "./lazy-relay.js";
6
7
  import { isWin } from "./tmux-runtime.js";
7
8
  import { resolveWindowsPowerShell7 } from "./windows-powershell.js";
8
9
  export function processIdentity(process) {
@@ -223,7 +224,7 @@ export function processSubtree(rows, rootPid) {
223
224
  // 見分けるのは起動した実行ファイルの名前(argvの先頭のbasenameが`mcp-lazy`で始まる)。取り決めはmcp-lazyのREADMEにある。
224
225
  export function lazyRelayProcess(command) {
225
226
  const first = /^(?:"([^"]+)"|(\S+))/.exec(command);
226
- return path.posix.basename((first?.[1] ?? first?.[2] ?? "").replace(/\\/g, "/")).toLowerCase().startsWith("mcp-lazy");
227
+ return lazyRelayExecutable(first?.[1] ?? first?.[2] ?? "");
227
228
  }
228
229
  export function backgroundProcesses(rows, root) {
229
230
  // 起動時の足場processを除く既存契約。pane開始から60秒以上後に生成された子孫だけを集計する。
@@ -5,10 +5,29 @@ import { randomUUID } from "node:crypto";
5
5
  import { isDeepStrictEqual } from "node:util";
6
6
  import * as steer from "aiterm-steer-delivery";
7
7
  import { resolveAgentBin } from "./agent-resolver.js";
8
+ import { lazyRelayExecutable } from "./lazy-relay.js";
8
9
  import { SetupError, runSetupCommand } from "./setup-platform.js";
9
10
  import { AITERM_PROFILE } from "./steer-profile.js";
10
11
  export { powershellInvocation } from "./setup-platform.js";
11
12
  const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
13
+ /**
14
+ * 既にある登録が、今から書く登録と同じ本体を指しているか。
15
+ * 直結(commandとargsが同じ)に加えて、中継(mcp-lazy)で包んだ同じ登録も同じと数える。
16
+ * 中継つきの形は、commandが`mcp-lazy`で始まる名前の実行ファイルで、argsが直結のcommand+args
17
+ * (中継のflagを`--`の前に置いた形も含む)。中継は本体を道具が呼ばれるまで起こさない。
18
+ * 連携元が全席の登録を中継つきで書いた後、1席が引数なしのsetupを流しても、直結へ戻さない。
19
+ */
20
+ export function sameRegistration(command, args, registration) {
21
+ if (typeof command !== "string" || !Array.isArray(args))
22
+ return false;
23
+ if (command === registration.command && isDeepStrictEqual(args, registration.args))
24
+ return true;
25
+ if (!lazyRelayExecutable(command))
26
+ return false;
27
+ const direct = [registration.command, ...registration.args];
28
+ const separator = args.indexOf("--");
29
+ return isDeepStrictEqual(args, direct) || (separator >= 0 && isDeepStrictEqual(args.slice(separator + 1), direct));
30
+ }
12
31
  export function mergeJsonMcp(file, registration) {
13
32
  let current = {};
14
33
  let target = file;
@@ -36,6 +55,9 @@ export function mergeJsonMcp(file, registration) {
36
55
  }
37
56
  const servers = (current.mcpServers ?? {});
38
57
  const previous = record(servers.aiterm) ? servers.aiterm : {};
58
+ // 中継で包んだ同じ登録は、そのまま残す(直結へ書き戻さない)。
59
+ if (previous.command !== registration.command && sameRegistration(previous.command, previous.args, registration))
60
+ return "unchanged";
39
61
  const updated = { ...previous, ...registration };
40
62
  if (isDeepStrictEqual(servers.aiterm, updated))
41
63
  return "unchanged";
@@ -144,8 +166,8 @@ export function configureIntegrations(home, registration, run = runSetupCommand,
144
166
  if (!Array.isArray(servers))
145
167
  throw new SetupError("config_readback_failed", "CodexのMCP一覧形式を確認できません");
146
168
  const existing = servers.find((entry) => entry.name === "aiterm");
147
- // 公式CLIの追加は登録を作り直し、利用者が足した項目(待ち時間など)を落とす。同じ登録なら書き直さない。
148
- if (existing?.transport?.command !== registration.command || !isDeepStrictEqual(existing?.transport?.args, registration.args)) {
169
+ // 公式CLIの追加は登録を作り直し、利用者が足した項目(待ち時間など)を落とす。同じ登録(中継で包んだ物を含む)なら書き直さない。
170
+ if (!sameRegistration(existing?.transport?.command, existing?.transport?.args, registration)) {
149
171
  const envArgs = Object.entries(existing?.transport?.env ?? {}).flatMap(([key, value]) => ["--env", `${key}=${value}`]);
150
172
  run(executable, ["mcp", "add", "aiterm", ...envArgs, "--", registration.command, ...registration.args]);
151
173
  const value = JSON.parse(run(executable, ["mcp", "get", "aiterm", "--json"]));
@@ -160,7 +182,7 @@ export function configureIntegrations(home, registration, run = runSetupCommand,
160
182
  throw new SetupError("config_readback_failed", "GrokのMCP一覧形式を確認できません");
161
183
  const existing = servers.find((entry) => entry.name === "aiterm" && entry.scope === "user");
162
184
  // Codexと同じく、同じ登録なら書き直さない。
163
- if (existing?.command !== registration.command || !isDeepStrictEqual(existing?.args, registration.args)) {
185
+ if (!sameRegistration(existing?.command, existing?.args, registration)) {
164
186
  const envArgs = Object.entries(existing?.env ?? {}).flatMap(([key, value]) => ["--env", `${key}=${value}`]);
165
187
  run(executable, ["mcp", "add", "--scope", "user", "aiterm", ...envArgs, "--", registration.command, ...registration.args]);
166
188
  const value = JSON.parse(run(executable, ["mcp", "list", "--json"]));
package/docs/DESIGN.md CHANGED
@@ -16,7 +16,7 @@ Claude/CursorのJSONは参照先を原子的に更新して変更前backupを
16
16
  各AIの読戻しは登録内容の確認であり、端末の実動作はその前の公開MCP試験で確認する。
17
17
  失敗は理由付きJSONと非ゼロ終了で返す。対応外の自動導入と全AI未検出を成功扱いしない。
18
18
  global packageは、npmの現在のglobal rootか、実行中のNodeの既定のglobal rootにあるものを指す。
19
- CodexとGrokは登録が同じなら公式CLIで作り直さない。`aiterm-setup --hooks-only`はClaude Code・Cursorの親配送hookだけを登録し、
19
+ CodexとGrokは登録が同じなら公式CLIで作り直さない。中継(`mcp-lazy`)で包んだ同じ登録も「同じ」と数え、4つのclientとも直結へ書き戻さない(ADR 0091)。`aiterm-setup --hooks-only`はClaude Code・Cursorの親配送hookだけを登録し、
20
20
  依存準備、端末の実動作確認、MCP登録、Codex Steerに触れない。判断はADR 0077。
21
21
 
22
22
  ## Terminal model
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiterm-mcp",
3
- "version": "0.53.1",
3
+ "version": "0.54.0",
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": [