aiterm-mcp 0.41.2 → 0.42.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,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.42.0] - 2026-09-28
11
+
12
+ ### 追加
13
+
14
+ - BellTeamとの連携用に、`pty_observe`がBellTeamからの要求にだけ、実行中のturnで書かれた作業の合間の言葉を返せるようにした。ほかの呼び手に対するツールの説明、引数、結果は変わらない。判断はrepositoryの`docs/adr/0071-bellteam-interim-words.md`。
15
+
16
+ ### 修正
17
+
18
+ - Cursorは記録にturnの終了を書いたあとも、しばらく画面に実行中の表示を残す。完了通知の直後に`pty_send`すると差し込みと判定し、`STEER_NOT_QUEUED`で失敗していた(手元で3回中2回)。記録の末尾がturnの終了なら新しいturnとして送る。
19
+
20
+ ## [0.41.3] - 2026-09-28
21
+
22
+ ### 修正
23
+
24
+ - Claude CodeがAPIエラーや安全判定の拒否でターンを終了し、Stop hookが発火しなかった場合、実行中の印が残り、次の`pty_send`を差し込みと誤判定していた。送信時に現在の印の作成後に記録された`isApiErrorMessage`を確認し、終了したターンの印だけを解除して新しいターンを開始する。過去のエラーで次のターンの印を解除せず、waiterは引き続き読取専用とする。上流APIの拒否はそのままエラーとして返す。
25
+
10
26
  ## [0.41.2] - 2026-09-27
11
27
 
12
28
  ### 修正
@@ -1822,7 +1838,9 @@ prototype (preserved under `prototype/python/` as the porting source and referen
1822
1838
  `ubuntu-latest` for Node 18/20/22, publishing to npm on `v*` tags with
1823
1839
  provenance.
1824
1840
 
1825
- [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.41.2...HEAD
1841
+ [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.42.0...HEAD
1842
+ [0.42.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.41.3...v0.42.0
1843
+ [0.41.3]: https://github.com/kitepon/aiterm-mcp/compare/v0.41.2...v0.41.3
1826
1844
  [0.41.2]: https://github.com/kitepon/aiterm-mcp/compare/v0.41.1...v0.41.2
1827
1845
  [0.41.1]: https://github.com/kitepon/aiterm-mcp/compare/v0.41.0...v0.41.1
1828
1846
  [0.41.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.40.3...v0.41.0
package/README.ja.md CHANGED
@@ -203,7 +203,7 @@ runtime-error store は canonical dotagents config の `collection.enabled: true
203
203
  場合だけ収集し、既定OFF、network送信は行いません。tag起点CIのnpm provenance(OIDC Trusted
204
204
  Publishing)で公開し、GitHub Release が Official MCP Registry を再登録します。
205
205
 
206
- **状態:** 開発継続中 · 現行公開版 **v0.41.2** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
206
+ **状態:** 開発継続中 · 現行公開版 **v0.42.0** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
207
207
 
208
208
  ### 更新と巻き戻し
209
209
 
@@ -251,6 +251,8 @@ pty_read(id, { wait: true }) → 削減済みの出力を読む(完了
251
251
 
252
252
  `aiterm.agent-launch-result.v1`は正規`harness`を返し、旧`provider`は互換fieldとして残す。同じ`harness`はagent dispatch、`aiterm-wait`、`agent_configure`、`pty_list`のagent行にも載り、旧vendor/provider/agent fieldは互換用に残る。Codexは通常rollout、Grok CLIは通常session event、Claudeはlaunch固有Stop hook、Cursorは通常agent transcript末尾の`turn_ended`を完了正本に使う。agent sessionへの送信は`pty_send`だけで行い、子の状態はAitermが送る時点の画面で見て振り分ける。Claudeは画面の実行中表示ではなくStopまで残るturnの印で判定する。子のturnが実行中なら各harness標準の操作で現在のturnへ差し込み(`mode=agent_steer`)、完了は差し込み後の作業の終わりに元の依頼へ1回だけ届く。新しい`event_cursor`と配送は作らない。それ以外は非ブロックdispatch(`mode=agent_dispatch`)で、vendor別完了境界を表すopaqueな整数`event_cursor`を返す。Codex親は選択に応じて公式Steerまたは受信キュー、Claude Code親は公式非同期hookで回答本文を自動受信する。それ以外の親は`aiterm-wait`を親のターンを塞がない別processで受ける。Cursorのsubmitキーはadapterが現行CLIのextended keyboard protocolへ変換する。送信textがCursorのcomposerへ残った場合は成功receiptを返さず失敗する。
253
253
 
254
+ ClaudeがAPIエラーや安全判定の拒否で終了した時は、Stop hookが発火しなくても次の`pty_send`を新しいturnとして扱う。現在のturn開始後のエラー記録だけを確認し、過去のエラーで実行中のturnを解除しない。上流の拒否はエラーのまま返す。
255
+
254
256
  `agent_launch`・`pty_send`(agent session宛て)は任意の`image`(画像ファイルの絶対パスの配列。png/jpg/jpeg/gif/webp)を受ける。aitermが本文末尾へ添付行を付け、どのharnessも自分のfile読取toolでそのpathを画像として開く。呼出し側はharness別の添付手順を覚えない。不正なpathは送信前に拒否する。
255
257
 
256
258
  `agent_launch`は任意の`write_scope`も受ける。Codex/Grokのread-onlyは`--sandbox read-only`、Cursorは公式`--mode ask`で実効化する。path説明は同等CLI引数がないためdeclaration-only。
package/README.md CHANGED
@@ -217,7 +217,7 @@ collection is off by default and performs no network I/O. It ships via
217
217
  tag-triggered CI with npm provenance (OIDC Trusted Publishing); the GitHub
218
218
  Release re-registers the Official MCP Registry entry.
219
219
 
220
- **Status:** actively maintained · current public release **v0.41.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).
220
+ **Status:** actively maintained · current public release **v0.42.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).
221
221
 
222
222
  ### Update and rollback
223
223
 
@@ -251,6 +251,8 @@ aiterm predates Build Week, so the event work is kept visible in dated commits.
251
251
 
252
252
  I used **Codex with GPT-5.6** as an engineering collaborator: it inspected the implementation, challenged the API and recovery contracts, generated focused regression cases, and helped verify race, security, timeout, and malformed-event paths. I reviewed the diffs and test evidence and retained the final product and architecture decisions. At that Build Week checkpoint, the regression suite contained 262 tests covering normal operation as well as failure and recovery behavior; current release receipts live in the [CHANGELOG](CHANGELOG.md) and release ADRs.
253
253
 
254
+ ClaudeがAPIエラーや安全判定の拒否で終了した時は、Stop hookが発火しなくても次の`pty_send`を新しいturnとして扱う。現在のturn開始後のエラー記録だけを確認し、過去のエラーで実行中のturnを解除しない。上流の拒否はエラーのまま返す。
255
+
254
256
  ## Two ways to use it
255
257
 
256
258
  ### 1. Drive SSH, containers, and REPLs in one persistent terminal — the primitive
package/dist/core.js CHANGED
@@ -20,8 +20,9 @@ import { isWin, SOCKDIR, tmuxCommand, sendPsmuxPayload, loadPtyBufferChunk, past
20
20
  import { sleep, currentUid, runtimeStateBase, safeStatSize, readFileRange, writeJson0600, createEmpty0600, shq, LAUNCH_ID_RE, AGENT_DONE_POLL_MS, AGENT_EVENT_MAX_BYTES, assertSessionName, agentsDir, agentEventPath, agentMetadataPath, writeAgentMetadata, AGENT_EVENT_TAIL_BYTES, agentLabel, agentHarness, subagentInstruction, agentLineageFields, } from "./agent-shared.js";
21
21
  import { realGrokHome, resolveAndValidateGrokAuth, assertGrokModelAvailable, grokEventsTranscript, latestGrokCompletion, observeGrokDone, buildGrokAgentCmd, grokLaunchNote, grokEnvTokens, grokTuiReady, grokTuiBusy, grokPaneObservation, grokRateLimitDialog, grokStartupAction, grokLaunchBlockingDialog, assertGrokSandboxNotRejected, GROK_COMPOSER_MARKER_RE, grokFooterHasConfiguration, grokTranscriptText, createGrokAgentMetadata, } from "./harnesses/grok.js";
22
22
  import { bindCodexTranscriptSession, latestCodexCompletion, observeCodexDone, buildCodexAgentCmd, codexLaunchNote, codexTuiReady, codexPaneObservation, codexRateLimitModelSwitchDialog, codexApprovalDialog, codexStartupAction, CODEX_COMPOSER_MARKER_RE, codexModelChoice, codexEffortChoice, codexMoreReasoningChoice, codexTranscriptText, createCodexAgentMetadata, } from "./harnesses/codex.js";
23
- import { OPERATION_ID_RE, CLAUDE_RESULT_MAX_BYTES, CLAUDE_EFFORTS, agentManagedClaudeSettingsPath, agentClaudeResultPath, agentClaudeOperationPath, agentClaudeApprovalReceiptPath, agentClaudeDispatchReceiptPath, validateOperationId, readClaudeResultText, assertClaudeAuthenticationReady, buildClaudeAgentCmd, claudeLaunchNote, claudeTuiReady, claudePaneObservation, claudeStartupAction, claudeLoginMethodMenu, CLAUDE_COMPOSER_MARKER_RE, createClaudeAgentMetadata, claudeSessionTranscriptPath, claudeApiErrorFromLine, } from "./harnesses/claude.js";
23
+ import { OPERATION_ID_RE, CLAUDE_RESULT_MAX_BYTES, CLAUDE_EFFORTS, agentManagedClaudeSettingsPath, agentClaudeResultPath, agentClaudeOperationPath, agentClaudeApprovalReceiptPath, agentClaudeDispatchReceiptPath, validateOperationId, readClaudeResultText, assertClaudeAuthenticationReady, buildClaudeAgentCmd, claudeLaunchNote, claudeTuiReady, claudePaneObservation, claudeStartupAction, claudeLoginMethodMenu, CLAUDE_COMPOSER_MARKER_RE, createClaudeAgentMetadata, claudeSessionTranscriptPath, claudeApiErrorFromLine, claudeApiErrorAfter, } from "./harnesses/claude.js";
24
24
  import { bindCursorTranscriptSession, cursorTurnBoundary, latestCursorCompletion, observeCursorDone, cursorTranscriptText, assertCursorAuthenticationReady, assertCursorModelAvailable, buildCursorAgentCmd, cursorAgentArgv, cursorPwshLaunchLine, cursorPromptWithLineage, createCursorAgentMetadata, cursorLaunchNote, cursorEffortNavigation, cursorTuiReady, cursorPaneObservation, cursorPromptHooksRunning, cursorUsageLimit, CURSOR_SUBMIT_SEQUENCE, CURSOR_COMPOSER_CONTENT_MARKER_RE, validateCursorModelEffort, } from "./harnesses/cursor.js";
25
+ import { readInterimWords, recordInterimBoundary } from "./interim-words.js";
25
26
  import { resolveAgentBin, resolveThroughlineBin, runThroughlineHandoffContext, isWindowsNativeExecutable, agentBinForPaneShell, resolveWinPaneShell } from "./agent-resolver.js";
26
27
  export { AitermError } from "./errors.js";
27
28
  export { tmuxSpawnEnv } from "./tmux-runtime.js";
@@ -306,7 +307,8 @@ function cleanupAgentState(name) {
306
307
  f.endsWith(".cursor-result.json") ||
307
308
  f.endsWith(".claude-operation.json") ||
308
309
  f.endsWith(".claude-approval.json") ||
309
- f.endsWith(".claude-dispatch"))
310
+ f.endsWith(".claude-dispatch") ||
311
+ f.endsWith(".interim.json"))
310
312
  fs.unlinkSync(p);
311
313
  else if (f.endsWith(".codex-home") || f.endsWith(".grok-home") || f.endsWith(".cursor-plugin") || f.endsWith(".home")) {
312
314
  fs.rmSync(p, { recursive: true, force: true });
@@ -1333,6 +1335,7 @@ export function killAll() {
1333
1335
  f.endsWith(".claude-result.json") ||
1334
1336
  f.endsWith(".claude-operation.json") ||
1335
1337
  f.endsWith(".claude-dispatch") ||
1338
+ f.endsWith(".interim.json") ||
1336
1339
  f.endsWith(".codex-home") ||
1337
1340
  f.endsWith(".grok-home") ||
1338
1341
  f.endsWith(".home")) {
@@ -1562,6 +1565,30 @@ function managedClaudeOperation(name) {
1562
1565
  return undefined;
1563
1566
  return readClaudeOperationMarker(meta);
1564
1567
  }
1568
+ function clearClaudeApiErrorOperation(meta) {
1569
+ const file = agentClaudeOperationPath(meta.aiterm_session, meta.launch_id);
1570
+ let marker;
1571
+ try {
1572
+ marker = fs.statSync(file);
1573
+ }
1574
+ catch (error) {
1575
+ if (error.code === "ENOENT")
1576
+ return;
1577
+ throw error;
1578
+ }
1579
+ if (!claudeApiErrorAfter(meta, marker.mtimeMs))
1580
+ return;
1581
+ // 読取中にStopや次のturnが印を置換した場合は、その新しい印を消さない。
1582
+ try {
1583
+ const current = fs.statSync(file);
1584
+ if (current.dev === marker.dev && current.ino === marker.ino)
1585
+ fs.unlinkSync(file);
1586
+ }
1587
+ catch (error) {
1588
+ if (error.code !== "ENOENT")
1589
+ throw error;
1590
+ }
1591
+ }
1565
1592
  function canonicalClaudeApprovalScreen(screen) {
1566
1593
  return stripControl(screen)
1567
1594
  .split("\n")
@@ -2382,6 +2409,14 @@ async function settlePublishedClaudeCompletionMarker(meta, marker) {
2382
2409
  }
2383
2410
  return active;
2384
2411
  }
2412
+ /** BellTeamのため: agent sessionの現在(または直前)のturnの合間の言葉。agent sessionでなければnull。 */
2413
+ export function readSessionInterimWords(name, after) {
2414
+ assertSessionName(name);
2415
+ const dir = existingAgentsDir();
2416
+ if (!dir || !fs.readdirSync(dir).some((f) => f.startsWith(`${name}.`) && f.endsWith(".agent.json")))
2417
+ return null;
2418
+ return readInterimWords(loadAgentMetadata(name), after);
2419
+ }
2385
2420
  /** agent harness の構造化 transcript から直近完了ターンの最終回答と相関情報を読む。 */
2386
2421
  export async function readAgentTranscriptResult(name, o = {}) {
2387
2422
  const meta = loadAgentMetadata(name);
@@ -3602,6 +3637,7 @@ export async function dispatchAgentTurn(name, text, o = {}) {
3602
3637
  prepareSendText(dispatchText, { raw: o.raw });
3603
3638
  await o.before_send?.({ session_id: name, launch_id: meta.launch_id, vendor: meta.kind,
3604
3639
  harness: agentHarness(meta.kind), event_cursor: startOffset, operation_id: operationId });
3640
+ recordInterimBoundary(meta, startOffset, operationId);
3605
3641
  if (meta.kind === "claude") {
3606
3642
  // durable/anonymousを分岐する前に同じsend preflightを通す。拒否されるpromptの
3607
3643
  // receipt/active markerだけを残して、来ないStopを待つ状態を作らない。
@@ -3686,9 +3722,17 @@ export async function sendAgentMessage(name, text, o = {}) {
3686
3722
  const paneInputRecovery = await ensureAgentOwnsPaneInput(name, meta.kind);
3687
3723
  let running = isAgentTuiBusy(meta.kind, captureScreen(name, AGENT_TUI_READY_LINES));
3688
3724
  // Claudeのtool処理中は画面のbusy表示が消えることがある。Stopまで保持するturnの印を正とし、
3689
- // 印がある間は差し込み、Stop後は画面にbusy表示が残っても新しいturnとして送る。
3690
- if (meta.kind === "claude")
3725
+ // APIエラーでStopが来なかった印は、会話記録の終了時刻を確認して消す。
3726
+ // 有効な印がある間は差し込み、終了後は画面にbusy表示が残っても新しいturnとして送る。
3727
+ if (meta.kind === "claude") {
3728
+ clearClaudeApiErrorOperation(meta);
3691
3729
  running = readClaudeOperationMarker(meta) !== null;
3730
+ }
3731
+ // Cursorはturn_endedを記録へ書いたあとも、しばらく画面にbusy表示を残す。その間に差し込むと待ち行列へ入らず
3732
+ // STEER_NOT_QUEUEDで失敗していた(実測 2026-09-28、完了通知の直後の送信で3回中2回)。記録の末尾がturn_endedなら
3733
+ // turnは終わっているので新しいturnとして送る。dispatchは入力を受け付けるまで待ってから送る。
3734
+ if (running && meta.kind === "cursor" && latestCursorCompletion(meta, readTranscriptLines) !== null)
3735
+ running = false;
3692
3736
  if (running) {
3693
3737
  return steerRunningTurn(name, meta, text, { raw: o.raw, pane_input_recovery: paneInputRecovery });
3694
3738
  }
@@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url";
9
9
  import { AitermError } from "../errors.js";
10
10
  import { modeBitsWorldAccessible } from "../tmux-runtime.js";
11
11
  import { spawnAgentControlCommand } from "../agent-resolver.js";
12
- import { currentUid, writeJson0600, shq, subagentInstruction, writeScopeLaunchNote, agentEventPath, createEmpty0600, writeAgentMetadata, agentLineageFields, assertSessionName, agentsDir, LAUNCH_ID_RE, } from "../agent-shared.js";
12
+ import { currentUid, writeJson0600, shq, subagentInstruction, writeScopeLaunchNote, agentEventPath, createEmpty0600, writeAgentMetadata, agentLineageFields, assertSessionName, agentsDir, LAUNCH_ID_RE, AGENT_EVENT_TAIL_BYTES, readFileRange, } from "../agent-shared.js";
13
13
  export const OPERATION_ID_RE = /^sha256:[0-9a-f]{64}$/;
14
14
  export const CLAUDE_RESULT_MAX_BYTES = 4 * 1024 * 1024;
15
15
  export const CLAUDE_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
@@ -310,3 +310,26 @@ export function claudeApiErrorFromLine(line) {
310
310
  at: typeof record?.timestamp === "string" ? record.timestamp : null,
311
311
  };
312
312
  }
313
+ // Stopが発火しないエラー終了を、現在のturn開始後の会話記録だけから判定する。
314
+ export function claudeApiErrorAfter(meta, startedAtMs) {
315
+ const file = claudeSessionTranscriptPath(meta);
316
+ if (!file)
317
+ return null;
318
+ let size;
319
+ try {
320
+ size = fs.statSync(file).size;
321
+ }
322
+ catch (error) {
323
+ if (error.code === "ENOENT")
324
+ return null;
325
+ throw error;
326
+ }
327
+ const lines = readFileRange(file, Math.max(0, size - AGENT_EVENT_TAIL_BYTES), size).toString("utf8").split("\n");
328
+ lines.pop(); // 改行まで書かれた記録だけを扱う。
329
+ for (const line of lines.reverse()) {
330
+ const error = claudeApiErrorFromLine(line);
331
+ if (error?.at && Date.parse(error.at) >= startedAtMs)
332
+ return error;
333
+ }
334
+ return null;
335
+ }
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { acceptRemote, callRemoteTool, observeRemoteAgentDone, remoteInputDescri
20
20
  import { codexParentFromRequest } from "./codex-parent-receiver.js";
21
21
  import { claudeParentFromRequest } from "./claude-parent-receiver.js";
22
22
  import { cursorParentFromRequest, isCursorMcpClient } from "./cursor-parent-receiver.js";
23
+ import { INTERIM_RESULT_META_KEY, interimRequestFromMeta } from "./interim-words.js";
23
24
  // package.json の version を実行時に読み、MCP initialize で配るサーバ版と一致させる。
24
25
  // createRequire を使うのは、import 属性 `with { type: "json" }` が Node 18.20+ 限定で
25
26
  // engines "node >=18"(18.0〜18.19)を SyntaxError で壊し、旧 `assert` 構文は逆に Node 22 で
@@ -587,16 +588,30 @@ registerRemoteAwareTool("pty_observe", {
587
588
  cpu_seconds: z.number().nullable(), cpu_delta_seconds: z.number().nullable(), cpu_delta_complete: z.boolean().nullable(),
588
589
  background_cpu_seconds: z.number().nullable(), background_cpu_delta_seconds: z.number().nullable(), background_cpu_delta_complete: z.boolean().nullable() }),
589
590
  },
590
- }, async ({ session_id, cursor }) => {
591
+ }, async ({ session_id, cursor }, extra) => {
591
592
  try {
592
593
  const observation = core.observeSession(session_id, cursor);
593
594
  const result = { ...observation, ...(parentDelivery ? { parent_deliveries: parentDelivery.status(session_id) } : {}) };
594
- return { ...ok(JSON.stringify(result)), structuredContent: { ...result } };
595
+ return { ...ok(JSON.stringify(result)), structuredContent: { ...result }, ...interimWordsMeta(session_id, extra._meta) };
595
596
  }
596
597
  catch (e) {
597
598
  return fail(e);
598
599
  }
599
600
  });
601
+ // BellTeamのため: 要求の_metaに aiterm/caller="BellTeam" がある時だけ、結果の_metaへ合間の言葉を載せる。
602
+ // 説明と引数には出さない(interim-words.ts の冒頭)。それ以外の呼び手には何も足さない。
603
+ function interimWordsMeta(sessionId, requestMeta) {
604
+ const request = interimRequestFromMeta(requestMeta);
605
+ if (!request)
606
+ return {};
607
+ try {
608
+ const words = core.readSessionInterimWords(sessionId, request.after);
609
+ return words ? { _meta: { [INTERIM_RESULT_META_KEY]: words } } : {};
610
+ }
611
+ catch (e) {
612
+ return { _meta: { [INTERIM_RESULT_META_KEY]: { schema: "aiterm.interim-words.v1", error: e instanceof Error ? e.message : String(e) } } };
613
+ }
614
+ }
600
615
  registerRemoteAwareTool("agent_approval", {
601
616
  description: "Codexの現在の承認をinspectし、digestへ束縛した単発許可または拒否をrespondする。恒久許可は選ばない。Claudeは既存claude_approvalを使う。未知dialogはblockedのtyped errorで返す。",
602
617
  inputSchema: {
@@ -0,0 +1,256 @@
1
+ // 合間の言葉: agent sessionがturnの途中で書き、そのあと道具を使った言葉。
2
+ // BellTeamのためにある。BellTeamは実行中のBotが何をしているかを会話画面へ出すため、
3
+ // pty_observeのMCP要求の_metaに caller="BellTeam" を入れて呼び、結果の_metaでこれを受け取る。
4
+ // 利用者向けのツール説明と引数には出さない(BellTeam以外のAIに指定させないため)。要らないものと見て消さないこと。
5
+ //
6
+ // 最後の回答と思考は含めない。APIエラーやsession limitの知らせは、記録に書かれていれば含める。
7
+ // ハーネスごとの見分け方(2026-09-28 記録で確認):
8
+ // Claude Code: text block で message.stop_reason="tool_use"。isApiErrorMessage の行は知らせ。
9
+ // Codex: assistant message の phase="commentary"。
10
+ // Grok: content が空でなく tool_calls を持つ assistant。
11
+ // Cursor: assistant の text のあとに tool_use が続くもの(Cursorは道具が終わってから行を書くので遅れて届く)。
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import { AitermError } from "./errors.js";
15
+ import { agentHarness, agentsDir, assertSessionName, LAUNCH_ID_RE, readFileRange, safeStatSize, writeJson0600, } from "./agent-shared.js";
16
+ import { claudeSessionTranscriptPath } from "./harnesses/claude.js";
17
+ import { codexRootTranscript } from "./harnesses/codex.js";
18
+ import { grokSessionDirectory } from "./harnesses/grok.js";
19
+ import { cursorTranscript } from "./harnesses/cursor.js";
20
+ /** BellTeamが要求の_metaへ入れる呼び手の印。値が "BellTeam" の時だけ合間の言葉を返す。 */
21
+ export const INTERIM_CALLER_META_KEY = "aiterm/caller";
22
+ export const INTERIM_CALLER = "BellTeam";
23
+ /** 前回受け取った最後のseq。これより後の言葉だけを返す。 */
24
+ export const INTERIM_AFTER_META_KEY = "aiterm/interim_after";
25
+ /** 結果の_metaで返すキー。 */
26
+ export const INTERIM_RESULT_META_KEY = "aiterm/interim_words";
27
+ export function agentInterimBoundaryPath(name, launchId) {
28
+ assertSessionName(name);
29
+ if (!LAUNCH_ID_RE.test(launchId))
30
+ throw new AitermError(`launch_id が不正です: ${launchId}`, 2);
31
+ return path.join(agentsDir(), `${name}.${launchId}.interim.json`);
32
+ }
33
+ /** 要求の_metaがBellTeamの印を持つ時だけ、前回のseqを返す。それ以外はnull(何も足さない)。 */
34
+ export function interimRequestFromMeta(meta) {
35
+ if (!meta || typeof meta !== "object")
36
+ return null;
37
+ const record = meta;
38
+ if (record[INTERIM_CALLER_META_KEY] !== INTERIM_CALLER)
39
+ return null;
40
+ const after = record[INTERIM_AFTER_META_KEY];
41
+ return { after: typeof after === "number" && Number.isSafeInteger(after) && after > 0 ? after : 0 };
42
+ }
43
+ function parse(line) {
44
+ if (!line.trim())
45
+ return null;
46
+ try {
47
+ return JSON.parse(line);
48
+ }
49
+ catch {
50
+ // 書込み途中の末尾行と、byte境界で切れた先頭行は読まない。
51
+ return null;
52
+ }
53
+ }
54
+ // ---------------------------------------------------------------- Claude Code
55
+ function claudeText(content) {
56
+ if (typeof content === "string")
57
+ return content;
58
+ if (!Array.isArray(content))
59
+ return "";
60
+ return content.map((part) => (part?.type === "text" && typeof part.text === "string" ? part.text : "")).join("");
61
+ }
62
+ export function claudeInterimWords(lines) {
63
+ const words = [];
64
+ for (const line of lines) {
65
+ const record = parse(line);
66
+ if (record?.type !== "assistant" || record.isSidechain === true)
67
+ continue;
68
+ const at = typeof record.timestamp === "string" ? record.timestamp : null;
69
+ if (record.isApiErrorMessage === true) {
70
+ const text = claudeText(record.message?.content).trim();
71
+ if (text)
72
+ words.push({ text, kind: "error", at, turn_id: null });
73
+ continue;
74
+ }
75
+ if (record.message?.stop_reason !== "tool_use")
76
+ continue;
77
+ const text = claudeText(record.message?.content).trim();
78
+ if (text)
79
+ words.push({ text, kind: "interim", at, turn_id: null });
80
+ }
81
+ return words;
82
+ }
83
+ // ---------------------------------------------------------------- Codex
84
+ export function codexInterimWords(lines) {
85
+ const words = [];
86
+ for (const line of lines) {
87
+ const record = parse(line);
88
+ const payload = record?.payload;
89
+ if (record?.type !== "response_item" || payload?.type !== "message" || payload?.role !== "assistant"
90
+ || payload?.phase !== "commentary" || !Array.isArray(payload?.content))
91
+ continue;
92
+ const text = payload.content
93
+ .map((item) => (item?.type === "output_text" && typeof item.text === "string" ? item.text : ""))
94
+ .join("")
95
+ .trim();
96
+ if (!text)
97
+ continue;
98
+ const turnId = payload.internal_chat_message_metadata_passthrough?.turn_id;
99
+ words.push({
100
+ text,
101
+ kind: "interim",
102
+ at: typeof record.timestamp === "string" ? record.timestamp : null,
103
+ turn_id: typeof turnId === "string" && turnId ? turnId : null,
104
+ });
105
+ }
106
+ return words;
107
+ }
108
+ // ---------------------------------------------------------------- Grok
109
+ function grokRealUser(record) {
110
+ return record?.type === "user" && !("synthetic_reason" in record);
111
+ }
112
+ export function grokUserCount(lines) {
113
+ let count = 0;
114
+ for (const line of lines)
115
+ if (grokRealUser(parse(line)))
116
+ count++;
117
+ return count;
118
+ }
119
+ /** boundary+1番目のuser発話(このturnの依頼)以後のassistantのうち、言葉と道具の呼び出しを両方持つもの。 */
120
+ export function grokInterimWords(lines, boundary) {
121
+ const words = [];
122
+ let users = 0;
123
+ for (const line of lines) {
124
+ const record = parse(line);
125
+ if (grokRealUser(record))
126
+ users++;
127
+ if (users <= boundary || record?.type !== "assistant")
128
+ continue;
129
+ if (!Array.isArray(record.tool_calls) || record.tool_calls.length === 0)
130
+ continue;
131
+ const text = typeof record.content === "string" ? record.content.trim() : "";
132
+ if (text)
133
+ words.push({ text, kind: "interim", at: null, turn_id: null });
134
+ }
135
+ return words;
136
+ }
137
+ // ---------------------------------------------------------------- Cursor
138
+ /**
139
+ * boundary+1番目のuser発話(このturnの依頼)以後で、あとにtool_useが続いたtextの塊。
140
+ * 次にuser発話かturn_endedが来た塊はそのturnの最後の回答なので含めない。末尾で行き先が決まっていない塊も待つ。
141
+ */
142
+ export function cursorInterimWords(lines, boundary) {
143
+ const words = [];
144
+ let users = 0;
145
+ let pending = [];
146
+ for (const line of lines) {
147
+ const record = parse(line);
148
+ if (!record)
149
+ continue;
150
+ if (record.role === "user" || record.type === "turn_ended") {
151
+ if (record.role === "user")
152
+ users++;
153
+ pending = [];
154
+ continue;
155
+ }
156
+ if (record.role !== "assistant" || !Array.isArray(record.message?.content))
157
+ continue;
158
+ for (const part of record.message.content) {
159
+ if (part?.type === "text" && typeof part.text === "string")
160
+ pending.push(part.text);
161
+ else if (part?.type === "tool_use") {
162
+ const text = pending.join("\n").trim();
163
+ if (text && users > boundary)
164
+ words.push({ text, kind: "interim", at: null, turn_id: null });
165
+ pending = [];
166
+ }
167
+ }
168
+ }
169
+ return words;
170
+ }
171
+ // ---------------------------------------------------------------- 境界と読み取り
172
+ function claudeTranscriptSize(meta) {
173
+ const file = claudeSessionTranscriptPath(meta);
174
+ return file ? safeStatSize(file) : 0;
175
+ }
176
+ function grokChatHistory(meta) {
177
+ const dir = grokSessionDirectory(meta);
178
+ return dir ? path.join(dir, "chat_history.jsonl") : null;
179
+ }
180
+ function readLines(file, from = 0) {
181
+ if (!file)
182
+ return [];
183
+ const size = safeStatSize(file);
184
+ if (size <= from)
185
+ return [];
186
+ return readFileRange(file, from, size).toString("utf8").split("\n");
187
+ }
188
+ /**
189
+ * dispatchの送信直前に、このturnの言葉が記録のどこから始まるかを残す。
190
+ * eventCursorはdispatch receiptのevent_cursor(Codex・Cursorでは境界そのもの)。
191
+ * 失敗してもdispatchは止めない。境界が古いままでも、呼び手はevent_cursorの不一致で気づける。
192
+ */
193
+ export function recordInterimBoundary(meta, eventCursor, operationId) {
194
+ try {
195
+ let boundary;
196
+ if (meta.kind === "claude")
197
+ boundary = claudeTranscriptSize(meta);
198
+ else if (meta.kind === "grok")
199
+ boundary = grokUserCount(readLines(grokChatHistory(meta)));
200
+ else
201
+ boundary = eventCursor;
202
+ const value = {
203
+ schema: "aiterm.interim-boundary.v1",
204
+ event_cursor: eventCursor,
205
+ operation_id: operationId,
206
+ boundary,
207
+ dispatched_at: new Date().toISOString(),
208
+ };
209
+ writeJson0600(agentInterimBoundaryPath(meta.aiterm_session, meta.launch_id), value);
210
+ }
211
+ catch {
212
+ /* 合間の言葉は表示のためだけにある。送信を妨げない。 */
213
+ }
214
+ }
215
+ function readInterimBoundary(meta) {
216
+ let value;
217
+ try {
218
+ value = JSON.parse(fs.readFileSync(agentInterimBoundaryPath(meta.aiterm_session, meta.launch_id), "utf8"));
219
+ }
220
+ catch {
221
+ return null;
222
+ }
223
+ if (value?.schema !== "aiterm.interim-boundary.v1" || !Number.isSafeInteger(value.boundary) || value.boundary < 0
224
+ || !Number.isSafeInteger(value.event_cursor))
225
+ return null;
226
+ return value;
227
+ }
228
+ /** 現在(または直前)のturnで書かれた合間の言葉のうち、seqがafterより後のもの。 */
229
+ export function readInterimWords(meta, after) {
230
+ const recorded = readInterimBoundary(meta);
231
+ // dispatchの記録が無ければ、起動時promptのturnだけを記録の先頭から読む。promptなしで起動してまだ送っていない時は無い。
232
+ const launchTurn = !recorded && meta.initial_prompt !== "none";
233
+ let found = [];
234
+ if (recorded || launchTurn) {
235
+ const boundary = recorded?.boundary ?? 0;
236
+ if (meta.kind === "claude")
237
+ found = claudeInterimWords(readLines(claudeSessionTranscriptPath(meta), boundary));
238
+ else if (meta.kind === "codex")
239
+ found = codexInterimWords(readLines(codexRootTranscript(meta), boundary));
240
+ else if (meta.kind === "grok")
241
+ found = grokInterimWords(readLines(grokChatHistory(meta)), boundary);
242
+ else
243
+ found = cursorInterimWords(readLines(cursorTranscript(meta)), boundary);
244
+ }
245
+ const words = found.map((word, index) => ({ seq: index + 1, ...word }));
246
+ return {
247
+ schema: "aiterm.interim-words.v1",
248
+ session_id: meta.aiterm_session,
249
+ launch_id: meta.launch_id,
250
+ harness: agentHarness(meta.kind),
251
+ event_cursor: recorded?.event_cursor ?? null,
252
+ operation_id: recorded?.operation_id ?? null,
253
+ last_seq: words.length,
254
+ words: words.filter((word) => word.seq > after),
255
+ };
256
+ }
package/docs/DESIGN.md CHANGED
@@ -67,7 +67,7 @@ Grokの記録先はCLIと同じOS絶対パスへcwdを正規化して導出し
67
67
  agent sessionへの送信口は`pty_send`だけとする。子の状態は呼び出し側に選ばせず、Aitermが送る時点の画面で振り分ける。
68
68
  状態を見てから呼ぶまでの間に子のturnが変わるため、呼び出し側が入口を選ぶ形では外れる。
69
69
  実行中なら現在のturnへ追加textを差し込み、新しい完了境界と配送は作らない。完了境界は差し込み後も1つに保つ。
70
- Claude Codeはtool処理中に画面の実行中表示が消え、Stop hookの実行中には表示が残る。Stopで消えるturnの印だけを実行中判定の正本とする。
70
+ Claude Codeはtool処理中に画面の実行中表示が消え、Stop hookの実行中には表示が残る。turnの印を実行中判定の正本とする。Stopが発火しないAPIエラー終了では、次の送信時に印の作成後の会話記録にある`isApiErrorMessage`を確認し、終了したturnの印だけを解除する。過去のエラーで新しい印を解除しない。waiterは印を変更せず、読取専用のままエラーを返す。
71
71
  それ以外は新しいturnとしてdispatchする。
72
72
  CodexとClaude Codeは次のtool境界で同じturnへ取り込む。Cursorは「follow-ups」枠へ入った文を「enter steer」で現在turnへ移し、`turn_ended`は最後に1回書く。
73
73
  Grokは待ち行列へ入れた後に「send now」を押す。旧turnは`cancelled`(`cancellation_context.trigger=send_now`)で閉じ、
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiterm-mcp",
3
- "version": "0.41.2",
3
+ "version": "0.42.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": [