aiterm-mcp 0.53.2 → 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,26 @@ 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
+
10
30
  ## [0.53.2] - 2026-10-05
11
31
 
12
32
  ### 修正
@@ -2215,7 +2235,8 @@ prototype (preserved under `prototype/python/` as the porting source and referen
2215
2235
  `ubuntu-latest` for Node 18/20/22, publishing to npm on `v*` tags with
2216
2236
  provenance.
2217
2237
 
2218
- [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.53.2...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
2219
2240
  [0.53.2]: https://github.com/kitepon/aiterm-mcp/compare/v0.53.1...v0.53.2
2220
2241
  [0.53.1]: https://github.com/kitepon/aiterm-mcp/compare/v0.53.0...v0.53.1
2221
2242
  [0.53.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.52.3...v0.53.0
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.2** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
212
+ **状態:** 開発継続中 · 現行公開版 **v0.54.0** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
213
213
 
214
214
  ### 更新と巻き戻し
215
215
 
@@ -648,7 +648,7 @@ handoff contextを前置きできる。この任意経路は`throughline >= 0.9.
648
648
  `THROUGHLINE_BIN`、次に`PATH`から解決し、不在・不正・空のexportはPTY作成前に明示失敗する。
649
649
  任意の`throughline_supplement_file`はThroughline 0.10.8以降と`throughline_source_session`を必要とし、Aitermは内容を解釈せずThroughlineへ渡す。
650
650
 
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を回収する。
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`で返すので、回答を読めなかった時と見分けられる。回答の場所を記録から見つけられない時だけ誤りにする。
652
652
 
653
653
  ### 完了検出(5 層)
654
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.2** · 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
 
@@ -689,7 +689,7 @@ unchanged. Optional `throughline_supplement_file` is passed unchanged to Through
689
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
690
690
  export fails before the PTY exists instead of silently launching clean.
691
691
 
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. 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.
693
693
 
694
694
  ### Completion detection (5 layers)
695
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
  }
@@ -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 ?? "子のセッションが閉じられたため、回答はありません。";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiterm-mcp",
3
- "version": "0.53.2",
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": [