aiterm-mcp 0.47.1 → 0.49.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,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.49.0] - 2026-10-03
11
+
12
+ ### 追加
13
+
14
+ - `pty_observe`に、agent sessionを閉じると失うものを数える2項目を足した(ADR 0080)。`activity.post_startup_process_count`は、起動準備が完了した時点に居なかったprocessの数(裏で動かしている作業。起動直後の1分に始めたものも数える)。`pending_child_deliveries`は、そのsessionが親として待っている、まだ届け終えていない子の結果の数。数えられない時はnullで、0と区別する。
15
+
16
+ ### 修正
17
+
18
+ - 会話が長いClaude Codeのsessionが、入力待ちでも`pty_observe`で`unknown / unrecognized_screen`と出ていた。起動時の見出し「Claude Code」が画面の取得範囲から流れ出ると読めなかった。見出しが無い時は入力欄の形で読む。
19
+ - `aiterm-setup`(`aiterm-update`の中でも走る)と`--remove-cursor-parent-hooks`が、gpt-connectorのCursor hookを`~/.cursor/hooks.json`から消していた。自分のhookを名前の部分一致で探し、`cursor-parent-hook.js`が`gpt-connector-cursor-parent-hook.js`にも当たっていた。file名の境目まで見る(aiterm-steer-delivery 0.1.10)。
20
+ - `aiterm-setup`が、Claude Codeの`settings.json`を中身が同じでも書き換えることがあった。他の道具が後からhookを足していると、Aitermのentryを末尾へ移していた。同じ中身で登録済みなら並びを保つ(aiterm-steer-delivery 0.1.10)。
21
+ - `pty_send`が「TUI が入力受付状態になりません」で断る時、その時の画面の読み(`state=… reason=…`)を文の末尾へ付ける。承認や確認のmodalの時は待たずに断るが、どの画面だったかが残らなかった。
22
+ - BugHubへの報告で、`observed_at`を秒へ切り捨てていた。記録の時刻はミリ秒まで持つので、エラーを記録した直後(同じ秒の中)の自動送信は、受け口に「記録が観測より後」として断られる作りだった(記録は失われず、次の送信の機会まで届くのが遅れる)。切り捨てず、載せる記録のどの時刻よりも前にしない。
23
+ - 実行中のCursor・Grokへ文を差し込む時、待ち行列の表示が5秒より遅い端末で`STEER_NOT_QUEUED`になっていた(Windowsの実機で、送ってから表示まで8〜9秒)。文は待ち行列に入っていて、今のturnの後に別のturnとして動いていた。子のturnが続いている間は、表示を30秒まで待つ。
24
+
25
+ ## [0.48.0] - 2026-10-03
26
+
27
+ ### 追加
28
+
29
+ - 実行時エラーをBugHubへ報告する仕組み。既定では無効で、何も送らない。`aiterm-runtime-errors reporting enable`で明示して有効にし、かつBugHubの持ち主が置いた合鍵のファイルがある端末だけが送る。宛先は合鍵のファイルから読む。送るのは別processで、きっかけはエラーの記録・解決や開き直し・MCPの起動(未報告がある時だけ、多くても1時間に1回)と`aiterm-runtime-errors report`。応答の署名まで確かめた時だけ受け取り済みにする。`aiterm-runtime-errors reporting status`で状態を見られる。
30
+ - 報告を有効にした端末は、工場の収集設定が無くても実行時エラーの記録が有効になる。
31
+
32
+ ### 修正
33
+
34
+ - 試験が、わざと起こした失敗を利用者の実行時エラーの記録へ書いていた(CIを走らせる端末で、試験のたびに1件増えていた)。試験の記録先を一時フォルダへ向けた。製品の動きは変わらない。
35
+
10
36
  ## [0.47.1] - 2026-10-03
11
37
 
12
38
  ### 修正
@@ -1983,7 +2009,9 @@ prototype (preserved under `prototype/python/` as the porting source and referen
1983
2009
  `ubuntu-latest` for Node 18/20/22, publishing to npm on `v*` tags with
1984
2010
  provenance.
1985
2011
 
1986
- [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.47.1...HEAD
2012
+ [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.49.0...HEAD
2013
+ [0.49.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.48.0...v0.49.0
2014
+ [0.48.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.47.1...v0.48.0
1987
2015
  [0.47.1]: https://github.com/kitepon/aiterm-mcp/compare/v0.47.0...v0.47.1
1988
2016
  [0.47.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.46.1...v0.47.0
1989
2017
  [0.46.1]: https://github.com/kitepon/aiterm-mcp/compare/v0.46.0...v0.46.1
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.47.1** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
212
+ **状態:** 開発継続中 · 現行公開版 **v0.49.0** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
213
213
 
214
214
  ### 更新と巻き戻し
215
215
 
@@ -510,6 +510,12 @@ aiterm は同じ核心の洞察——端末を出会いの場にする——を
510
510
  `background_cpu_delta_complete`はpane開始から60秒以降に生成された子孫だけの同じ観測で、起動時MCPを除外する。
511
511
  `token_hint`は画面の直近token表示値またはnull。画面本文や生argvを解析する必要はない。
512
512
 
513
+ agent sessionを閉じると失うものは、次の2項目で分かる。`activity.post_startup_process_count`は、`agent_launch`の起動準備が
514
+ 完了した時点(初手を送る前)に居なかったprocessの数。起動直後の1分に始めた裏の作業も数える。Codex自身の補助process
515
+ (`codex-code-mode-host`)は数えない。`pending_child_deliveries`は、そのsessionが親として待っていて、まだ届け終えていない
516
+ 子の結果の数。呼び出した側が誰でも付く。どちらも、分からない時はnull(通常PTY、harnessのprocessを特定できない時。
517
+ processの数は0.48.0以前が起動したagentも)。別端末が旧版の時は項目ごと無い。nullと項目なしは「分からない」で、0ではない。
518
+
513
519
  `agent_launch({ harness, cwd, trust_project: true })`はpromptなしでも既知のworkspace・project hooks・MCP初期同意を
514
520
  進め、入力受付とharness生存を確認して`startup.status="ready"`を返す。指定なしのpromptなし起動は`not_checked`。
515
521
  Claude Code初回起動の文字表示テーマ選択では、画面で選択済みの項目を確定して起動を続ける。
@@ -554,6 +560,17 @@ Claudeの相関済み承認は既存の`claude_approval`を使う。
554
560
 
555
561
  consumer は `aiterm-runtime-errors snapshot` を読み、durable ingestion 後に `aiterm-runtime-errors ack --cursor N` を呼ぶ。運用上の明示操作は `resolve|reopen --fingerprint SHA256`。MCP からの収集・diagnostic read は timeout 付き child process に隔離し、FIFOや停止 filesystem が端末本体を止めない。store mutation は期限付き bakery ticket queue で直列化する。各waiterは PID+process start identity+owner token を持つ再利用されない固有ticketを所有するため、死んだownerだけを固有名で除去でき、固定path回収のABAを作らない。queueの期限は正常な前任者を含む総待ち時間ではなく、同じ先頭ownerが進まない時間を測る。通常pollはprocessの生存確認だけを行い、process start identityはblockerがstallした時に照合する。POSIX state は `$XDG_STATE_HOME/aiterm-mcp/`(既定 `~/.local/state/aiterm-mcp/`)へ atomic replacement で置き、every read で owner/mode を再検証する。Windows native は `%LOCALAPPDATA%\aiterm-mcp\` で current SID の非継承 FullControl ACE 1件だけへ DACL を再構築し readback する。今回 Windows は path/DACL/timeout の純粋テストだけであり、新しい実機統合成功は主張しない。
556
562
 
563
+ ### BugHubへの報告(既定では無効)
564
+
565
+ Aitermは、既定では実行時エラーをどこへも送らない。送るのは、次の2つがそろった端末だけ。
566
+
567
+ 1. `aiterm-runtime-errors reporting enable` で明示して有効にした。
568
+ 2. BugHubの持ち主が置いた合鍵のファイルがある(POSIXは `~/.config/bughub/product-credentials/aiterm-mcp.json`、Windowsは `%LOCALAPPDATA%\bughub\product-credentials\aiterm-mcp.json`)。本人だけが読める通常のファイルでなければ読まない。
569
+
570
+ 宛先は合鍵のファイルから読む。送る中身は、エラーのコード・回数・最初と最後の時刻・版・重さ・解決の印だけで、prompt・path・stackは保存も送信もしない。送るのは別processで、MCP processは通信しない。きっかけは、エラーを記録した時・解決や開き直しをした時・MCPの起動時(未報告がある時だけ、多くても1時間に1回)と、`aiterm-runtime-errors report`(その場で1回、1分に1回まで)。応答の署名まで確かめた時だけ受け取り済みにし、確かめられない時は次の機会に送り直す。
571
+
572
+ `aiterm-runtime-errors reporting status` は、有効・無効、合鍵の状態(`ready`/`missing`/`rejected`)、未報告の件数、最後の送信の結果を返す。止める時は `aiterm-runtime-errors reporting disable`。報告を有効にした端末は、工場の収集設定が無くても記録が有効になる。
573
+
557
574
  ### 対話エージェントharness
558
575
 
559
576
  `agent_launch`は選んだharnessの対話TUIを新しい永続PTYに起動し、`session_id`を返す。harnessはagent loop・認証・hook・session・transcriptを所有し、modelは独立。以後は他sessionと同じ`pty_read`/`pty_send`で操作する。
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.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).
226
+ **Status:** actively maintained · current public release **v0.49.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
 
@@ -542,6 +542,13 @@ between observations, the delta covers only observed increments and `cpu_delta_c
542
542
  only to descendants created at least 60 seconds after the pane, excluding startup MCP processes. `token_hint` is the latest
543
543
  displayed token count or null. Callers do not need raw argv or pane-text parsing.
544
544
 
545
+ Two fields tell a caller what closing an agent session would lose. `activity.post_startup_process_count` is the number of
546
+ processes in the session that did not exist when `agent_launch` finished startup (before the first prompt), so background work
547
+ started in the first minute is counted too; Codex's own `codex-code-mode-host` helper is not counted. `pending_child_deliveries`
548
+ is the number of sub-agent results this session is still waiting for as a parent, whoever the caller is. Both are null when
549
+ Aiterm cannot tell (ordinary terminals, agents launched by 0.48.0 or earlier for the process count, or an unresolved harness
550
+ process), and may be absent when a remote host runs an older Aiterm. Treat null or absent as unknown, not as zero.
551
+
545
552
  `agent_launch({ harness, cwd, trust_project: true })` completes known workspace, project-hook, and project-MCP startup
546
553
  consent even without a prompt, then verifies input readiness and harness liveness before returning `startup.status="ready"`.
547
554
  For Claude Code's first-run text-style menu, it confirms the item already selected on screen before continuing startup.
@@ -589,6 +596,18 @@ snapshotの`product_version`は各recordの最終実発生時の版を表す。s
589
596
 
590
597
  Consumer flow is `aiterm-runtime-errors snapshot`, then `aiterm-runtime-errors ack --cursor N` after durable ingestion. Operators can use `resolve|reopen --fingerprint SHA256`. MCP collection and diagnostic reads run in timeout-bounded child processes, so a FIFO or stalled filesystem cannot block terminal work; child failure emits only the fixed store diagnostic. Store mutation uses a bounded bakery ticket queue: every waiter owns a never-reused ticket containing PID, process-start identity, and an owner token, so dead owners are removed by unique filename without fixed-path reclaim ABA. The queue deadline measures lack of progress by the same head owner, not total wait behind healthy predecessors; normal polling uses the native process-liveness check and validates process-start identity only when a blocker stalls. Worker deadlines use forced termination so a SIGTERM-ignoring child cannot mutate state after timeout. POSIX state is atomically replaced under `$XDG_STATE_HOME/aiterm-mcp/` (default `~/.local/state/aiterm-mcp/`) with owner/mode rechecked on every read. Windows native uses `%LOCALAPPDATA%\aiterm-mcp\`; each DACL is rebuilt and read back as one non-inherited FullControl ACE for the current SID. Windows path/DACL/timeout behavior is covered by pure tests in this change; no new Windows integration success is claimed.
591
598
 
599
+ **Reporting to BugHub is off by default.** Aiterm sends runtime errors nowhere unless both hold: the user ran
600
+ `aiterm-runtime-errors reporting enable`, and a credential file placed by the BugHub owner exists
601
+ (`~/.config/bughub/product-credentials/aiterm-mcp.json`, or `%LOCALAPPDATA%\bughub\product-credentials\aiterm-mcp.json` on Windows;
602
+ it is read only when it is a regular file readable by its owner alone). The destination comes from that file. The payload is the
603
+ cumulative error codes, counts, first/last timestamps, versions, severity, and resolution marks; prompts, paths, and stacks are neither
604
+ stored nor sent. A separate process does the sending, never the MCP process, and there is no polling: a report is attempted when an error
605
+ is recorded, when a record is resolved or reopened, and when the MCP server starts (only while something is unreported, at most once per
606
+ hour), or on `aiterm-runtime-errors report` (once, at most once per minute). A record becomes acknowledged only after the response
607
+ signature is verified; otherwise the next attempt resends the then-current totals. `aiterm-runtime-errors reporting status` shows the
608
+ switch, the credential state, the unreported count, and the last attempt; `reporting disable` turns it off. Enabling reporting also
609
+ enables collection on that host.
610
+
592
611
  ### Interactive agent harnesses
593
612
 
594
613
  `agent_launch` starts a selected harness's interactive coding-agent TUI inside a fresh persistent PTY and returns its `session_id`. The harness owns the agent loop, authentication, hooks, session, and transcript; `model` is independent. The TUI is a full-screen app, so read it with `pty_read({ screen: true })` for the rendered view.
package/dist/core.js CHANGED
@@ -15,13 +15,14 @@ import { fileURLToPath } from "node:url";
15
15
  import * as rtk from "./rtk.js";
16
16
  import { isCursorMcpClient } from "aiterm-steer-delivery";
17
17
  import { paneTokenHint } from "./harnesses/pane-tokens.js";
18
+ import { unfinishedDeliveriesOwnedBy } from "./parent-delivery-owners.js";
18
19
  import { readRuntimeProcesses, processSubtree, parentProcess, processIdentity, backgroundProcesses } from "./process-runtime.js";
19
20
  import { AitermError, telemetryOwnedFailure, ownTelemetryFailure } from "./errors.js";
20
21
  import { isWin, SOCKDIR, tmuxCommand, sendPsmuxPayload, loadPtyBufferChunk, pasteBufferBaseArgs, TMUX_EMPTY_CONFIG, attachCommand, normalizePaneCommand, atomicShellMultiline, appendMarkSentinel, markShellCommand, settlePaneLog, paneCwdArgument, sessionEnvironmentLaunch, } from "./tmux-runtime.js";
21
22
  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";
22
23
  import { catalogUnavailable } from "./model-catalog.js";
23
24
  import { grokAuthPlan, grokAuthStatus, grokAuthPane, realGrokHome, resolveAndValidateGrokAuth, assertGrokModelAvailable, grokModelChoices, grokEventsTranscript, latestGrokCompletion, observeGrokDone, buildGrokAgentCmd, grokLaunchNote, grokEnvTokens, grokTuiReady, grokTuiBusy, grokPaneObservation, grokRateLimitDialog, grokStartupAction, grokLaunchBlockingDialog, assertGrokSandboxNotRejected, GROK_COMPOSER_MARKER_RE, grokFooterHasConfiguration, grokTranscriptText, createGrokAgentMetadata, } from "./harnesses/grok.js";
24
- import { codexAuthPlan, codexAuthStatus, codexAuthPane, bindCodexTranscriptSession, latestCodexCompletion, observeCodexDone, buildCodexAgentCmd, codexLaunchNote, codexTuiReady, codexPaneObservation, codexRateLimitModelSwitchDialog, codexApprovalDialog, codexStartupAction, CODEX_COMPOSER_MARKER_RE, codexModelChoice, codexEffortChoice, codexMoreReasoningChoice, codexTranscriptText, createCodexAgentMetadata, codexModelChoices, } from "./harnesses/codex.js";
25
+ import { codexAuthPlan, codexAuthStatus, codexAuthPane, bindCodexTranscriptSession, latestCodexCompletion, observeCodexDone, buildCodexAgentCmd, codexLaunchNote, codexTuiReady, codexPaneObservation, codexHelperProcess, codexRateLimitModelSwitchDialog, codexApprovalDialog, codexStartupAction, CODEX_COMPOSER_MARKER_RE, codexModelChoice, codexEffortChoice, codexMoreReasoningChoice, codexTranscriptText, createCodexAgentMetadata, codexModelChoices, } from "./harnesses/codex.js";
25
26
  import { claudeAuthPlan, claudeAuthStatus, claudeAuthPane, OPERATION_ID_RE, CLAUDE_RESULT_MAX_BYTES, CLAUDE_EFFORTS, agentManagedClaudeSettingsPath, agentClaudeResultPath, agentClaudeOperationPath, agentClaudeApprovalReceiptPath, agentClaudeDispatchReceiptPath, validateOperationId, readClaudeResultText, assertClaudeAuthenticationReady, claudeModelChoices, buildClaudeAgentCmd, claudeLaunchNote, claudeTuiReady, claudePaneObservation, claudeUsageLimit, claudeStartupAction, claudeLoginMethodMenu, CLAUDE_COMPOSER_MARKER_RE, createClaudeAgentMetadata, claudeSessionTranscriptPath, claudeApiErrorFromLine, claudeApiErrorAfter, } from "./harnesses/claude.js";
26
27
  import { cursorAuthPlan, cursorAuthStatus, cursorAuthPane, bindCursorTranscriptSession, cursorTurnBoundary, latestCursorCompletion, observeCursorDone, cursorTranscriptText, assertCursorAuthenticationReady, assertCursorModelAvailable, cursorModelChoices, buildCursorAgentCmd, cursorAgentArgv, cursorPwshLaunchLine, cursorPromptWithLineage, createCursorAgentMetadata, cursorLaunchNote, cursorEffortNavigation, cursorTuiReady, cursorPaneObservation, cursorPromptHooksRunning, cursorUsageLimit, CURSOR_SUBMIT_SEQUENCE, CURSOR_COMPOSER_CONTENT_MARKER_RE, validateCursorModelEffort, } from "./harnesses/cursor.js";
27
28
  import { readInterimWords, recordInterimBoundary } from "./interim-words.js";
@@ -1085,9 +1086,10 @@ export function observeSession(name, cursor) {
1085
1086
  schema: "aiterm.pty-observe-result.v1", session_id: name, observed_at: new Date().toISOString(),
1086
1087
  exists: !!listed, harness: null, launch_id: null, state: "missing", reason: "session_missing",
1087
1088
  pane_alive: false, harness_alive: null, pane_process: null, harness_process: null, process_identity: null,
1088
- token_hint: null,
1089
+ token_hint: null, pending_child_deliveries: null,
1089
1090
  activity: { cursor: null, output_changed: null, cpu_seconds: null, cpu_delta_seconds: null, cpu_delta_complete: null,
1090
- background_cpu_seconds: null, background_cpu_delta_seconds: null, background_cpu_delta_complete: null },
1091
+ background_cpu_seconds: null, background_cpu_delta_seconds: null, background_cpu_delta_complete: null,
1092
+ post_startup_process_count: null },
1091
1093
  };
1092
1094
  if (!listed)
1093
1095
  return result;
@@ -1164,6 +1166,10 @@ export function observeSession(name, cursor) {
1164
1166
  screen_digest: createHash("sha256").update(screen).digest("hex"), processes, background_processes: backgroundCpu,
1165
1167
  };
1166
1168
  const comparable = previous?.pane_identity === sample.pane_identity;
1169
+ // 起動完了の控えが無いsession(通常PTY、控える前の版で起動したagent)とharnessを特定できない時は数えない。
1170
+ const startup = agent && Array.isArray(meta?.startup_processes) ? new Set(meta.startup_processes) : null;
1171
+ // このsessionが親として待っている子の結果。持ち主はharnessの下で動くAitermのMCP process。
1172
+ result.pending_child_deliveries = agent ? unfinishedDeliveriesOwnedBy(activityRows) : null;
1167
1173
  result.activity = {
1168
1174
  cursor: Buffer.from(JSON.stringify(sample)).toString("base64url"),
1169
1175
  output_changed: comparable ? previous.screen_digest !== sample.screen_digest : null,
@@ -1173,6 +1179,9 @@ export function observeSession(name, cursor) {
1173
1179
  background_cpu_seconds: Object.values(backgroundCpu).reduce((sum, cpu) => sum + cpu, 0),
1174
1180
  background_cpu_delta_seconds: comparable ? Object.entries(backgroundCpu).reduce((sum, [identity, cpu]) => sum + cpu - (previous.background_processes[identity] ?? 0), 0) : null,
1175
1181
  background_cpu_delta_complete: comparable ? Object.keys(previous.background_processes).every(identity => identity in backgroundCpu) : null,
1182
+ post_startup_process_count: startup ? new Set(activityRows
1183
+ .filter(row => !(meta?.kind === "codex" && codexHelperProcess(row.command)))
1184
+ .map(row => `${row.pid}:${row.started_identity}`).filter(identity => !startup.has(identity))).size : null,
1176
1185
  };
1177
1186
  return result;
1178
1187
  }
@@ -1989,6 +1998,8 @@ function loadAgentMetadata(name) {
1989
1998
  initial_prompt_delivery: m.initial_prompt_delivery, initial_prompt_cursor: m.initial_prompt_cursor,
1990
1999
  };
1991
2000
  const executableFields = m.agent_executable === undefined ? {} : { agent_executable: m.agent_executable };
2001
+ const startupFields = Array.isArray(m.startup_processes) && m.startup_processes.every((identity) => typeof identity === "string")
2002
+ ? { startup_processes: m.startup_processes } : {};
1992
2003
  if (m.kind === "claude") {
1993
2004
  const expectedSettings = agentManagedClaudeSettingsPath(name, m.launch_id);
1994
2005
  const expectedResult = agentClaudeResultPath(name, m.launch_id);
@@ -2016,6 +2027,7 @@ function loadAgentMetadata(name) {
2016
2027
  initial_prompt: normalizeInitialPromptState(m.initial_prompt),
2017
2028
  ...deliveryFields,
2018
2029
  ...executableFields,
2030
+ ...startupFields,
2019
2031
  launch_operation_id: launchOperationId,
2020
2032
  launch_request_digest: launchRequestDigest,
2021
2033
  hook_route: "shared_claude_settings",
@@ -2047,6 +2059,7 @@ function loadAgentMetadata(name) {
2047
2059
  initial_prompt: normalizeInitialPromptState(m.initial_prompt),
2048
2060
  ...deliveryFields,
2049
2061
  ...executableFields,
2062
+ ...startupFields,
2050
2063
  hook_route: "shared_codex_home",
2051
2064
  completion_route: "codex_transcript",
2052
2065
  ...loadAgentLineageFields(m, true),
@@ -2074,6 +2087,7 @@ function loadAgentMetadata(name) {
2074
2087
  initial_prompt: normalizeInitialPromptState(m.initial_prompt),
2075
2088
  ...deliveryFields,
2076
2089
  ...executableFields,
2090
+ ...startupFields,
2077
2091
  hook_route: "shared_cursor_home",
2078
2092
  completion_route: "cursor_transcript",
2079
2093
  ...loadAgentLineageFields(m, true),
@@ -2109,6 +2123,7 @@ function loadAgentMetadata(name) {
2109
2123
  initial_prompt: normalizeInitialPromptState(m.initial_prompt),
2110
2124
  ...deliveryFields,
2111
2125
  ...executableFields,
2126
+ ...startupFields,
2112
2127
  hook_route: "shared_grok_home",
2113
2128
  completion_route: "grok_transcript",
2114
2129
  ...loadAgentLineageFields(m, true),
@@ -3202,6 +3217,11 @@ async function prepareAgentInput(name, meta, options) {
3202
3217
  const live = observeSession(name);
3203
3218
  if (live.harness_alive !== true || live.harness_process === null || live.state !== "idle")
3204
3219
  return { status: "blocked", reason: live.reason };
3220
+ // ここまでに居るprocessは起動時の足場(harness・MCP・起動時hook)。初手を送る前に控える。
3221
+ if (live.activity.cursor !== null) {
3222
+ meta.startup_processes = Object.keys(decodeActivityCursor(live.activity.cursor, name).processes);
3223
+ writeAgentMetadata(meta);
3224
+ }
3205
3225
  return { status: "ready", reason: "composer_ready" };
3206
3226
  }
3207
3227
  export async function sendInitialAgentPrompt(name, text, o = {}) {
@@ -3849,8 +3869,13 @@ export async function dispatchAgentTurn(name, text, o = {}) {
3849
3869
  const reason = grokPaneObservation(ready.lastScreen).reason;
3850
3870
  throw new AitermError(`GROK_RATE_LIMIT_RECOVERY_FAILED: ${reason}。上限パネル解除後の入力受付を確認できません。今回の文字列は送信していません。`, 2);
3851
3871
  }
3872
+ // 待たずに断るのは、応答が要る既知の画面(承認・確認のmodal)の時。どの画面で断ったかを残す。
3873
+ const state = meta.kind === "grok" ? grokPaneObservation(ready.lastScreen)
3874
+ : meta.kind === "codex" ? codexPaneObservation(ready.lastScreen)
3875
+ : meta.kind === "claude" ? claudePaneObservation(ready.lastScreen) : cursorPaneObservation(ready.lastScreen);
3852
3876
  throw new AitermError(`agent session '${name}' の ${agentLabel(meta.kind)} TUI が入力受付状態になりません。文字列は送信していません。` +
3853
- "少し後で pty_read(screen:true) を確認し、TUI が起動済みなら再度 pty_send してください。", 2);
3877
+ "少し後で pty_read(screen:true) を確認し、TUI が起動済みなら再度 pty_send してください。" +
3878
+ `\nstate=${state.state} reason=${state.reason}`, 2);
3854
3879
  }
3855
3880
  }
3856
3881
  if (codexRateLimitModelSwitch)
@@ -3928,10 +3953,17 @@ const CURSOR_FOLLOW_UP_STEER_RE = /enter steer · /;
3928
3953
  const cursorSteerQueued = (screen) => CURSOR_FOLLOW_UP_STEER_RE.test(screen.split("\n").filter(line => line.trim()).slice(-30).join("\n"));
3929
3954
  const STEER_SCREEN_POLL_MS = 100;
3930
3955
  const STEER_SCREEN_MAX_SAMPLES = 50;
3931
- async function waitSteerScreen(name, predicate) {
3932
- for (let i = 0; i < STEER_SCREEN_MAX_SAMPLES; i++) {
3956
+ // 待ち行列の表示が遅い端末がある(fox 2026-10-03: Cursorへ送ってから表示まで8〜9秒。5秒で打ち切って3回とも失敗した)。
3957
+ // 子のturnが続いている間は、文は待ち行列へ入るしかないので、ここまで待つ。
3958
+ const STEER_SCREEN_RUNNING_MAX_SAMPLES = 300;
3959
+ const STEER_RUNNING_CHECK_SAMPLES = 10;
3960
+ async function waitSteerScreen(name, predicate, stillRunning) {
3961
+ for (let i = 0; i < STEER_SCREEN_RUNNING_MAX_SAMPLES; i++) {
3933
3962
  if (predicate(captureScreen(name, AGENT_TUI_READY_LINES)))
3934
3963
  return true;
3964
+ // 短い待ちを過ぎたら、子のturnが続いている間だけ待ちを延ばす。終わっていれば、これ以上待っても表示は変わらない。
3965
+ if (i >= STEER_SCREEN_MAX_SAMPLES && i % STEER_RUNNING_CHECK_SAMPLES === 0 && !stillRunning())
3966
+ return false;
3935
3967
  await sleep(STEER_SCREEN_POLL_MS);
3936
3968
  }
3937
3969
  return false;
@@ -4012,13 +4044,17 @@ async function steerRunningTurn(name, meta, text, o) {
4012
4044
  : meta.kind === "cursor" ? cursorSteerQueued : null;
4013
4045
  if (queued) {
4014
4046
  const label = meta.kind === "cursor" ? "Cursor" : "Grok";
4015
- if (!await waitSteerScreen(name, queued)) {
4047
+ // Cursorはturn_endedを記録へ書いた後も画面にbusy表示を残すので、記録で見る。Grokは画面で見る。
4048
+ const stillRunning = () => meta.kind === "cursor"
4049
+ ? latestCursorCompletion(meta, readTranscriptLines) === null
4050
+ : isAgentTuiBusy(meta.kind, captureScreen(name, AGENT_TUI_READY_LINES));
4051
+ if (!await waitSteerScreen(name, queued, stillRunning)) {
4016
4052
  throw new AitermError(`STEER_NOT_QUEUED vendor=${meta.kind} session=${name}\n` +
4017
4053
  `差し込む文が${label}の待ち行列へ入ったことを確認できません。子のturnが送る直前に終わっていた時は、` +
4018
4054
  `文は新しいturnとして始まっており、その完了は通知されません。再送する前にpty_read(screen:true)で確かめてください。`, 2);
4019
4055
  }
4020
4056
  sendKey(name, "Enter");
4021
- if (!await waitSteerScreen(name, screen => !queued(screen))) {
4057
+ if (!await waitSteerScreen(name, screen => !queued(screen), stillRunning)) {
4022
4058
  throw new AitermError(`STEER_STILL_QUEUED vendor=${meta.kind} session=${name}\n` +
4023
4059
  `差し込む文が${label}の待ち行列に残っています。現在のturnが終わると別turnとして実行されます。`, 2);
4024
4060
  }
@@ -258,8 +258,18 @@ export function claudeLaunchNote(model, effort, meta) {
258
258
  const writeScopeNote = writeScopeLaunchNote("claude", meta?.write_scope);
259
259
  return `起動設定: model=${model ?? "CLI既定"} effort=${effort ?? "CLI既定"}。${writeScopeNote}`;
260
260
  }
261
+ // 会話が長くなると、起動時の見出し「Claude Code」は取得範囲から流れ出る。入力欄は❯行のすぐ上と下を罫線で挟む形で、
262
+ // 起動時の確認画面や通常shellの❯とは見分けられる。見出しが無い時はこの形でClaude CodeのTUIと判断する。
263
+ function claudeComposerBox(screen) {
264
+ const lines = screen.split(/\r?\n/u);
265
+ const rule = /^\s*─{8,}\s*$/u;
266
+ let marker = lines.length - 1;
267
+ while (marker >= 0 && !/^\s*❯/u.test(lines[marker]))
268
+ marker--;
269
+ return marker > 0 && rule.test(lines[marker - 1]) && lines.slice(marker + 1).some(line => rule.test(line));
270
+ }
261
271
  export function claudeTuiReady(screen) {
262
- if (!screen.includes("Claude Code"))
272
+ if (!screen.includes("Claude Code") && !claudeComposerBox(screen))
263
273
  return false;
264
274
  if (claudeLoginMethodMenu(screen))
265
275
  return false;
@@ -427,6 +427,12 @@ function currentCodexDialog(screen) {
427
427
  const heading = [...current.matchAll(CODEX_DIALOG_HEADING)].at(-1);
428
428
  return heading ? current.slice(heading.index) : current;
429
429
  }
430
+ // Codexは道具を初めて使う時に補助process(code-mode-host)を立て、sessionの終わりまで残す。
431
+ // harnessの一部であって利用者の作業ではない。この下で動くprocessは作業として数える。
432
+ export function codexHelperProcess(command) {
433
+ const first = /^(?:"([^"]+)"|(\S+))/.exec(command);
434
+ return path.posix.basename((first?.[1] ?? first?.[2] ?? "").replace(/\\/g, "/")).replace(/\.exe$/i, "") === "codex-code-mode-host";
435
+ }
430
436
  export function codexPaneObservation(screen) {
431
437
  const failure = codexStartupFailure(screen);
432
438
  if (failure)
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { z } from "zod";
15
15
  import { agentModelsResult } from "./model-catalog.js";
16
16
  import * as core from "./core.js";
17
17
  import { runtimeErrorStoreDiagnostic } from "./runtime-error-store.js";
18
+ import { triggerRuntimeErrorReport } from "./runtime-error-report.js";
18
19
  import { createRequire } from "node:module";
19
20
  import { ParentDeliveryManager, deliveryKey } from "./parent-delivery.js";
20
21
  import { acceptRemote, callRemoteTool, observeRemoteAgentDone, remoteInputDescription, remoteInputSchema, remoteLabel, remoteWaitProcess } from "./remote.js";
@@ -596,10 +597,12 @@ registerRemoteAwareTool("pty_observe", {
596
597
  pane_process: nativeProcessIdentitySchema.nullable(), harness_process: nativeProcessIdentitySchema.nullable(),
597
598
  process_identity: nativeProcessIdentitySchema.nullable(),
598
599
  token_hint: z.number().nullable(),
600
+ pending_child_deliveries: z.number().nullable().optional(),
599
601
  parent_deliveries: z.array(parentDeliveryOutputSchema).optional(),
600
602
  activity: z.object({ cursor: z.string().nullable(), output_changed: z.boolean().nullable(),
601
603
  cpu_seconds: z.number().nullable(), cpu_delta_seconds: z.number().nullable(), cpu_delta_complete: z.boolean().nullable(),
602
- background_cpu_seconds: z.number().nullable(), background_cpu_delta_seconds: z.number().nullable(), background_cpu_delta_complete: z.boolean().nullable() }),
604
+ background_cpu_seconds: z.number().nullable(), background_cpu_delta_seconds: z.number().nullable(), background_cpu_delta_complete: z.boolean().nullable(),
605
+ post_startup_process_count: z.number().nullable().optional() }),
603
606
  },
604
607
  }, async ({ session_id, cursor }, extra) => {
605
608
  try {
@@ -1120,6 +1123,8 @@ async function main() {
1120
1123
  };
1121
1124
  const transport = new StdioServerTransport();
1122
1125
  await server.connect(transport);
1126
+ // 受け口がまだ受け取っていない実行時エラーがあれば、別processで送る(明示して有効にした端末だけ。既定では何も起動しない)。
1127
+ triggerRuntimeErrorReport();
1123
1128
  }
1124
1129
  main().catch((e) => {
1125
1130
  console.error("aiterm-mcp fatal:", e);
@@ -0,0 +1,42 @@
1
+ // 親配送の記録を、持ち主(登録したMCP process)から数える。
2
+ // parent-delivery.tsはcoreに依存するので、coreのpty_observeが使う分だけをここへ分ける。
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import { createHash } from "node:crypto";
6
+ import { ensureStateRoot } from "./agent-shared.js";
7
+ const DELIVERY_DIRECTORIES = ["parent-deliveries", "claude-parent-deliveries", "cursor-parent-deliveries"];
8
+ /** 持ち主の保存場所の名前の先頭。pidの使い回しと取り違えないよう、開始時刻も入れる。 */
9
+ export function deliveryOwnerPrefix(owner) {
10
+ return `${owner.pid}-${createHash("sha256").update(owner.started_identity).digest("hex").slice(0, 16)}-`;
11
+ }
12
+ /** 渡したprocessのどれかが持ち主で、まだ親へ届け終えていない配送の数。届け終えた記録はactiveから出ている。 */
13
+ export function unfinishedDeliveriesOwnedBy(processes, stateRoot = ensureStateRoot()) {
14
+ const prefixes = processes.map(deliveryOwnerPrefix);
15
+ let count = 0;
16
+ for (const prefix of ["", "remote-"]) {
17
+ for (const directory of DELIVERY_DIRECTORIES) {
18
+ const active = path.join(stateRoot, prefix + directory, "active");
19
+ let owners;
20
+ try {
21
+ owners = fs.readdirSync(active, { withFileTypes: true });
22
+ }
23
+ catch (error) {
24
+ if (error.code === "ENOENT")
25
+ continue;
26
+ throw error;
27
+ }
28
+ for (const owner of owners) {
29
+ if (!owner.isDirectory() || !prefixes.some(candidate => owner.name.startsWith(candidate)))
30
+ continue;
31
+ try {
32
+ count += fs.readdirSync(path.join(active, owner.name)).filter(name => name !== "owner.json" && name.endsWith(".json")).length;
33
+ }
34
+ catch (error) {
35
+ if (error.code !== "ENOENT")
36
+ throw error;
37
+ }
38
+ }
39
+ }
40
+ }
41
+ return count;
42
+ }
@@ -1,10 +1,11 @@
1
1
  // Aitermが所有する、子の完了観測・回答本文の保存・親への配送。
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
- import { randomUUID, createHash } from "node:crypto";
4
+ import { randomUUID } from "node:crypto";
5
5
  import { z } from "zod";
6
6
  import { ensureStateRoot, writeJson0600 } from "./agent-shared.js";
7
7
  import { readProcessIdentities } from "./process-runtime.js";
8
+ import { deliveryOwnerPrefix } from "./parent-delivery-owners.js";
8
9
  import { CodexDeliveryError, submitCodexParentAnswer, verifyCodexParent } from "./codex-parent-receiver.js";
9
10
  import { claudeParentSchema, ClaudeDeliveryError, bindClaudeParentDelivery, submitClaudeParentAnswer, verifyClaudeParent } from "./claude-parent-receiver.js";
10
11
  import { cursorParentSchema, CursorDeliveryError, prepareCursorDelivery, submitCursorParentAnswer, verifyCursorParent } from "./cursor-parent-receiver.js";
@@ -141,8 +142,7 @@ export class ParentDeliveryManager {
141
142
  if (!ownProcess)
142
143
  throw new AitermError("PARENT_DELIVERY_OWNER_UNKNOWN: 配送processを識別できません", 2);
143
144
  this.owner = { pid: process.pid, started_identity: ownProcess.started_identity, closed: false };
144
- const identity = createHash("sha256").update(ownProcess.started_identity).digest("hex").slice(0, 16);
145
- this.ownerDir = path.join(this.active, `${process.pid}-${identity}-${randomUUID()}`);
145
+ this.ownerDir = path.join(this.active, `${deliveryOwnerPrefix(this.owner)}${randomUUID()}`);
146
146
  fs.mkdirSync(this.ownerDir, { recursive: true, mode: 0o700 });
147
147
  fs.mkdirSync(this.results, { recursive: true, mode: 0o700 });
148
148
  fs.mkdirSync(this.claims, { recursive: true, mode: 0o700 });
@@ -23,6 +23,50 @@ export function defaultRuntimeErrorPaths(options = {}) {
23
23
  storePath: path.join(stateHome, "aiterm-mcp", "runtime-errors.json"),
24
24
  };
25
25
  }
26
+ // BugHubへの報告で使う場所。有効スイッチはAiterm自身の設定、合鍵はBugHubの持ち主が端末へ置くファイル。
27
+ export function defaultRuntimeErrorReportPaths(options = {}) {
28
+ const platform = options.platform ?? process.platform;
29
+ const home = options.home ?? os.homedir();
30
+ if (platform === "win32") {
31
+ const base = options.localAppData ?? process.env.LOCALAPPDATA ?? path.win32.join(home, "AppData", "Local");
32
+ return {
33
+ reportingConfigPath: path.win32.join(base, "aiterm-mcp", "runtime-error-reporting.json"),
34
+ credentialPath: path.win32.join(base, "bughub", "product-credentials", "aiterm-mcp.json"),
35
+ reportStatePath: path.win32.join(base, "aiterm-mcp", "runtime-errors-report.json"),
36
+ };
37
+ }
38
+ const configHome = options.xdgConfigHome ?? process.env.XDG_CONFIG_HOME ?? path.join(home, ".config");
39
+ const stateHome = options.xdgStateHome ?? process.env.XDG_STATE_HOME ?? path.join(home, ".local", "state");
40
+ return {
41
+ reportingConfigPath: path.join(configHome, "aiterm-mcp", "runtime-error-reporting.json"),
42
+ credentialPath: path.join(configHome, "bughub", "product-credentials", "aiterm-mcp.json"),
43
+ reportStatePath: path.join(stateHome, "aiterm-mcp", "runtime-errors-report.json"),
44
+ };
45
+ }
46
+ const MAX_REPORTING_CONFIG_BYTES = 4 * 1024;
47
+ /** 報告の有効スイッチの状態。ファイルが無ければ無効(既定)。形が違うファイルは有効とみなさない。 */
48
+ export function runtimeErrorReportingStatus(file, platform = process.platform) {
49
+ let text;
50
+ try {
51
+ text = readBoundedFile(file, MAX_REPORTING_CONFIG_BYTES, platform, false);
52
+ }
53
+ catch (error) {
54
+ return error.code === "ENOENT" ? "disabled" : "malformed";
55
+ }
56
+ try {
57
+ const config = JSON.parse(text);
58
+ if (typeof config !== "object" || config === null || Array.isArray(config))
59
+ return "malformed";
60
+ const keys = Object.keys(config);
61
+ const value = config;
62
+ if (keys.length !== 2 || value.schema_version !== "1.0" || typeof value.enabled !== "boolean")
63
+ return "malformed";
64
+ return value.enabled ? "enabled" : "disabled";
65
+ }
66
+ catch {
67
+ return "malformed";
68
+ }
69
+ }
26
70
  const WINDOWS_DACL_VERIFY_SCRIPT = String.raw `
27
71
  $ErrorActionPreference='Stop'
28
72
  $target=$env:AITERMMCP_ACL_PATH; $kind=$env:AITERMMCP_ACL_KIND
@@ -0,0 +1,263 @@
1
+ // BugHubへの実行時エラーの報告。明示して有効にした端末だけが、合鍵のファイルにある宛先へ累計を送る。
2
+ // 既定では通信しない。送るのは常に別processで、MCP processは通信しない。判断はADR 0079。
3
+ import { spawn } from "node:child_process";
4
+ import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
5
+ import * as fs from "node:fs";
6
+ import { createRequire } from "node:module";
7
+ import * as path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { defaultRuntimeErrorReportPaths, readBoundedFile, runtimeErrorReportingStatus } from "./runtime-error-os.js";
10
+ import { RuntimeErrorStore } from "./runtime-error-store.js";
11
+ const pkg = createRequire(import.meta.url)("../package.json");
12
+ const WORKER = fileURLToPath(new URL("./runtime-error-worker.js", import.meta.url));
13
+ const REPORT_SCHEMA = "1.0";
14
+ const REPORT_STATE_SCHEMA = "1.0";
15
+ const MAX_REPORT_RECORDS = 500;
16
+ const MAX_REPORT_BYTES = 512 * 1024;
17
+ const MAX_CREDENTIAL_BYTES = 8 * 1024;
18
+ const MAX_RESPONSE_BYTES = 64 * 1024;
19
+ const REQUEST_TIMEOUT_MS = 10_000;
20
+ // 受け口の上限は端末×製品ごとに1分に1回。自動の送信は、未受領がある時だけ、多くても1時間に1回。
21
+ const MANUAL_MIN_INTERVAL_MS = 60_000;
22
+ const AUTO_MIN_INTERVAL_MS = 60 * 60_000;
23
+ const LOCK_STALE_MS = 2 * 60_000;
24
+ /** 契約にある項目だけを載せる。端末名・OS・arch・保存用の連番は送らない(端末は合鍵から決まる)。 */
25
+ export function buildRuntimeErrorReport(records, options) {
26
+ if (records.length > MAX_REPORT_RECORDS)
27
+ throw new Error(`1回のreportは${MAX_REPORT_RECORDS}件までです`);
28
+ return {
29
+ schema_version: REPORT_SCHEMA,
30
+ report_id: options.reportId,
31
+ product_id: "aiterm-mcp",
32
+ installed_version: options.installedVersion,
33
+ observed_at: options.observedAt,
34
+ runtime_errors: records.map((record) => ({
35
+ fingerprint: record.fingerprint, error_code: record.error_code, component: record.component,
36
+ message_template: record.message_template, severity: record.severity, status: record.status,
37
+ occurrence_count: record.occurrence_count, first_seen: record.first_seen, last_seen: record.last_seen,
38
+ product_version: record.product_version, state_schema_version: record.state_schema_version,
39
+ })),
40
+ resolutions: records.filter((record) => record.status === "resolved" && record.resolved_at !== null && record.reason_code !== null)
41
+ .map((record) => ({ fingerprint: record.fingerprint, resolved_at: record.resolved_at, reason_code: record.reason_code })),
42
+ };
43
+ }
44
+ export function reportBodyDigest(body) {
45
+ return createHash("sha256").update(body).digest("hex");
46
+ }
47
+ /** sig = HMAC-SHA256(secret, ts + "\n" + 送ったバイト列のSHA-256の16進)。secretは文字列をそのままUTF-8で鍵にする。 */
48
+ export function signReport(secret, ts, body) {
49
+ return createHmac("sha256", Buffer.from(secret, "utf8")).update(`${ts}\n${reportBodyDigest(body)}`).digest("hex");
50
+ }
51
+ export function reportAuthorization(keyId, ts, sig) {
52
+ return `BugHub-HMAC-SHA256 key_id=${keyId}, ts=${ts}, sig=${sig}`;
53
+ }
54
+ /** 受領とみなすのは、accepted・report_idの一致・応答の署名がそろった時だけ。別の機器が返した200を受領にしない。 */
55
+ export function verifyReportResponse(secret, reportId, response) {
56
+ if (typeof response !== "object" || response === null || Array.isArray(response))
57
+ return false;
58
+ const value = response;
59
+ if (value.accepted !== true || value.report_id !== reportId)
60
+ return false;
61
+ if (typeof value.received_at !== "string" || typeof value.sig !== "string" || !/^[0-9a-f]{64}$/.test(value.sig))
62
+ return false;
63
+ const expected = createHmac("sha256", Buffer.from(secret, "utf8")).update(`${reportId}\n${value.received_at}`).digest();
64
+ return timingSafeEqual(expected, Buffer.from(value.sig, "hex"));
65
+ }
66
+ /** 合鍵のファイルは、本人だけが読める通常のファイルだけを読む。無い端末は「無い」と返し、失敗にしない。 */
67
+ export function readProductCredential(file, platform = process.platform) {
68
+ let text;
69
+ try {
70
+ text = readBoundedFile(file, MAX_CREDENTIAL_BYTES, platform, true);
71
+ }
72
+ catch (error) {
73
+ return error.code === "ENOENT" ? { status: "missing" } : { status: "rejected", reason: "unsafe_file" };
74
+ }
75
+ try {
76
+ const value = JSON.parse(text);
77
+ if (typeof value !== "object" || value === null || Array.isArray(value))
78
+ return { status: "rejected", reason: "malformed" };
79
+ const credential = value;
80
+ if (Object.keys(credential).sort().join(",") !== "key_id,secret,url")
81
+ return { status: "rejected", reason: "malformed" };
82
+ if (typeof credential.url !== "string" || typeof credential.key_id !== "string" || typeof credential.secret !== "string"
83
+ || !/^[A-Za-z0-9._-]{1,128}$/.test(credential.key_id) || credential.secret.length < 1 || credential.secret.length > 1024) {
84
+ return { status: "rejected", reason: "malformed" };
85
+ }
86
+ const url = new URL(credential.url);
87
+ if (url.protocol !== "http:" && url.protocol !== "https:")
88
+ return { status: "rejected", reason: "malformed" };
89
+ return { status: "ready", credential: { url: credential.url, key_id: credential.key_id, secret: credential.secret } };
90
+ }
91
+ catch {
92
+ return { status: "rejected", reason: "malformed" };
93
+ }
94
+ }
95
+ export function setRuntimeErrorReporting(file, enabled) {
96
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
97
+ const temp = `${file}.${process.pid}.tmp`;
98
+ fs.writeFileSync(temp, `${JSON.stringify({ schema_version: "1.0", enabled })}\n`, { mode: 0o600 });
99
+ fs.renameSync(temp, file);
100
+ }
101
+ const EMPTY_REPORT_STATE = () => ({
102
+ schema_version: REPORT_STATE_SCHEMA, last_attempt_at: null, last_status: null, last_http_status: null,
103
+ last_accepted_at: null, halted_credential_mtime_ms: null,
104
+ });
105
+ export function readReportState(file) {
106
+ try {
107
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
108
+ if (value?.schema_version !== REPORT_STATE_SCHEMA)
109
+ return EMPTY_REPORT_STATE();
110
+ return { ...EMPTY_REPORT_STATE(), ...value };
111
+ }
112
+ catch {
113
+ return EMPTY_REPORT_STATE();
114
+ }
115
+ }
116
+ function writeReportState(file, state) {
117
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
118
+ const temp = `${file}.${process.pid}.tmp`;
119
+ fs.writeFileSync(temp, `${JSON.stringify(state)}\n`, { mode: 0o600 });
120
+ fs.renameSync(temp, file);
121
+ }
122
+ /**
123
+ * 未受領の記録がある時に、その時点の累計を1回送る。受領を確かめられた時だけ受け取り済みにする。
124
+ * 届いたか分からない時は何も進めない。次の機会に、その時点の累計を新しいreport_idで送り直す。
125
+ */
126
+ export async function reportRuntimeErrors(options) {
127
+ const platform = options.platform ?? process.platform;
128
+ const paths = options.paths ?? defaultRuntimeErrorReportPaths({ platform });
129
+ const now = options.now ?? (() => new Date());
130
+ const result = (status, extra = {}) => ({ status, http_status: null, reported_records: 0, acknowledged_cursor: null, ...extra });
131
+ if (runtimeErrorReportingStatus(paths.reportingConfigPath, platform) !== "enabled")
132
+ return result("disabled");
133
+ const credential = readProductCredential(paths.credentialPath, platform);
134
+ if (credential.status === "missing")
135
+ return result("no_credential");
136
+ if (credential.status === "rejected")
137
+ return result("credential_rejected");
138
+ const store = options.store ?? new RuntimeErrorStore();
139
+ const snapshot = store.snapshot();
140
+ if (snapshot.collection !== "enabled" || snapshot.cursor <= snapshot.acknowledged_cursor)
141
+ return result("nothing_to_report");
142
+ const lock = `${paths.reportStatePath}.lock`;
143
+ fs.mkdirSync(path.dirname(lock), { recursive: true, mode: 0o700 });
144
+ try {
145
+ fs.mkdirSync(lock);
146
+ }
147
+ catch (error) {
148
+ if (error.code !== "EEXIST")
149
+ throw error;
150
+ // 別のprocessが送っている。途中で落ちたprocessの残りだけ、時間を置いて引き取る。
151
+ let stale = false;
152
+ try {
153
+ stale = now().getTime() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS;
154
+ }
155
+ catch { /* 相手が外した */ }
156
+ if (!stale)
157
+ return result("deferred");
158
+ try {
159
+ fs.rmdirSync(lock);
160
+ fs.mkdirSync(lock);
161
+ }
162
+ catch {
163
+ return result("deferred");
164
+ }
165
+ }
166
+ try {
167
+ const state = readReportState(paths.reportStatePath);
168
+ const started = now();
169
+ const sinceLast = state.last_attempt_at === null ? Infinity : started.getTime() - Date.parse(state.last_attempt_at);
170
+ if (sinceLast < (options.trigger === "auto" ? AUTO_MIN_INTERVAL_MS : MANUAL_MIN_INTERVAL_MS))
171
+ return result("deferred");
172
+ let credentialMtime = null;
173
+ try {
174
+ credentialMtime = fs.statSync(paths.credentialPath).mtimeMs;
175
+ }
176
+ catch { /* 読めた直後に消えた時は下の送信結果に任せる */ }
177
+ if (options.trigger === "auto" && state.halted_credential_mtime_ms !== null && state.halted_credential_mtime_ms === credentialMtime) {
178
+ return result("halted");
179
+ }
180
+ // tsとobserved_atは同じ時刻から作る(受け口は両者が10分より離れたreportを断る)。
181
+ // observed_atは秒へ切り捨てず、載せる記録のどの時刻よりも前にしない。受け口は、記録や解決が観測より後のreportを
182
+ // 422で断る。記録の時刻はミリ秒まで持つので、切り捨てると、記録の直後(同じ秒の中)の自動送信が断られていた。
183
+ const ts = String(Math.floor(started.getTime() / 1000));
184
+ const observed = Math.max(started.getTime(), ...snapshot.records.flatMap((record) => [Date.parse(record.last_seen), record.resolved_at === null ? 0 : Date.parse(record.resolved_at)]));
185
+ const reportId = randomUUID();
186
+ const report = buildRuntimeErrorReport(snapshot.records, {
187
+ installedVersion: options.installedVersion ?? pkg.version, reportId,
188
+ observedAt: new Date(observed).toISOString(),
189
+ });
190
+ const body = Buffer.from(JSON.stringify(report), "utf8");
191
+ if (body.byteLength > MAX_REPORT_BYTES)
192
+ throw new Error("reportが大きすぎます");
193
+ const finish = (status, httpStatus, acknowledged = null) => {
194
+ writeReportState(paths.reportStatePath, {
195
+ ...state, last_attempt_at: started.toISOString(), last_status: status, last_http_status: httpStatus,
196
+ last_accepted_at: status === "accepted" ? started.toISOString() : state.last_accepted_at,
197
+ halted_credential_mtime_ms: status === "halted" ? credentialMtime : null,
198
+ });
199
+ return result(status, { http_status: httpStatus, reported_records: report.runtime_errors.length, acknowledged_cursor: acknowledged });
200
+ };
201
+ let response;
202
+ let text;
203
+ try {
204
+ response = await (options.fetch ?? fetch)(credential.credential.url, {
205
+ method: "POST", redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
206
+ headers: {
207
+ "content-type": "application/json",
208
+ authorization: reportAuthorization(credential.credential.key_id, ts, signReport(credential.credential.secret, ts, body)),
209
+ },
210
+ body,
211
+ });
212
+ text = (await response.text()).slice(0, MAX_RESPONSE_BYTES);
213
+ }
214
+ catch {
215
+ return finish("delivery_unknown", null);
216
+ }
217
+ if (response.status === 200) {
218
+ let parsed = null;
219
+ try {
220
+ parsed = JSON.parse(text);
221
+ }
222
+ catch { /* 署名を確かめられない200は受領にしない */ }
223
+ if (!verifyReportResponse(credential.credential.secret, reportId, parsed))
224
+ return finish("delivery_unknown", 200);
225
+ store.acknowledge(snapshot.cursor);
226
+ return finish("accepted", 200, snapshot.cursor);
227
+ }
228
+ // 合鍵が無効な間は、同じファイルのまま自動で送り続けない。時刻のずれ(401 timestamp_skew)は合鍵の問題ではない。
229
+ if ((response.status === 401 && !/skew/i.test(text)) || response.status === 403)
230
+ return finish("halted", response.status);
231
+ if (response.status === 429)
232
+ return finish("rate_limited", 429);
233
+ if (response.status >= 500)
234
+ return finish("delivery_unknown", response.status);
235
+ return finish("rejected", response.status);
236
+ }
237
+ finally {
238
+ try {
239
+ fs.rmdirSync(lock);
240
+ }
241
+ catch { /* 古いlockとして引き取られた */ }
242
+ }
243
+ }
244
+ /**
245
+ * 自動の送信を別processへ頼む。呼ぶ側(MCP process・記録のworker・CLI)は通信しない。
246
+ * 有効でない端末では何も起動しない。node:testの子で動く時も起動しない(試験が、その端末の本物の記録を
247
+ * 開発中の版の名前で送らないように)。
248
+ */
249
+ export function triggerRuntimeErrorReport(env = process.env) {
250
+ if (env.NODE_TEST_CONTEXT)
251
+ return false;
252
+ try {
253
+ if (runtimeErrorReportingStatus(defaultRuntimeErrorReportPaths().reportingConfigPath) !== "enabled")
254
+ return false;
255
+ const child = spawn(process.execPath, [WORKER, "report"], { stdio: "ignore", detached: true, windowsHide: true, env });
256
+ child.once("error", () => { });
257
+ child.unref();
258
+ return true;
259
+ }
260
+ catch {
261
+ return false;
262
+ }
263
+ }
@@ -3,6 +3,7 @@ import { spawn, spawnSync } from "node:child_process";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { createRequire } from "node:module";
6
+ import { defaultRuntimeErrorReportPaths, runtimeErrorReportingStatus } from "./runtime-error-os.js";
6
7
  import { defaultRuntimeErrorPaths, windowsPrivateDaclCommand, expectedHostProfiles, readBoundedFile, processStartIdentity, forceKill } from "./runtime-error-os.js";
7
8
  export { defaultRuntimeErrorPaths, windowsPrivateDaclCommand, windowsPrivateDaclVerifyCommand } from "./runtime-error-os.js";
8
9
  import { fileURLToPath } from "node:url";
@@ -183,6 +184,7 @@ function validateState(value, maxRecords = 256) {
183
184
  export class RuntimeErrorStore {
184
185
  configPath;
185
186
  storePath;
187
+ reportingConfigPath;
186
188
  platform;
187
189
  arch;
188
190
  productVersion;
@@ -194,6 +196,9 @@ export class RuntimeErrorStore {
194
196
  const defaults = defaultRuntimeErrorPaths(options);
195
197
  this.configPath = options.configPath ?? defaults.configPath;
196
198
  this.storePath = options.storePath ?? defaults.storePath;
199
+ this.reportingConfigPath = options.reportingConfigPath !== undefined ? options.reportingConfigPath
200
+ : options.configPath !== undefined || options.storePath !== undefined ? null
201
+ : defaultRuntimeErrorReportPaths(options).reportingConfigPath;
197
202
  this.platform = options.platform ?? process.platform;
198
203
  if (!ARCHES.has(options.arch ?? process.arch))
199
204
  throw new Error("arch が不正です");
@@ -206,7 +211,12 @@ export class RuntimeErrorStore {
206
211
  if (!Number.isInteger(this.maxRecords) || this.maxRecords < 1 || this.maxRecords > 256)
207
212
  throw new Error("maxRecords は 1..256 必須です");
208
213
  }
209
- collectionStatus() { return collectionStatus(this.configPath, this.platform); }
214
+ // 報告を有効にした端末は、記録も有効。工場の収集設定が無くても、製品が自分の記録を持てる。
215
+ collectionStatus() {
216
+ if (this.reportingConfigPath !== null && runtimeErrorReportingStatus(this.reportingConfigPath, this.platform) === "enabled")
217
+ return "enabled";
218
+ return collectionStatus(this.configPath, this.platform);
219
+ }
210
220
  assertPrivateDirectory(dir) {
211
221
  const info = fs.lstatSync(dir);
212
222
  if (!info.isDirectory() || info.isSymbolicLink())
@@ -1,13 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  import process from "node:process";
3
3
  import { RuntimeErrorStore, validateRuntimeObservation } from "./runtime-error-store.js";
4
- function main() {
4
+ import { reportRuntimeErrors, triggerRuntimeErrorReport } from "./runtime-error-report.js";
5
+ async function main() {
5
6
  const [action, code, ...rest] = process.argv.slice(2);
6
7
  if (rest.length > 0)
7
8
  throw new Error("invalid args");
9
+ if (action === "report" && code === undefined) {
10
+ await reportRuntimeErrors({ trigger: "auto" });
11
+ return;
12
+ }
8
13
  const store = new RuntimeErrorStore();
9
14
  if (action === "record" && code) {
10
- store.record(validateRuntimeObservation({ code }));
15
+ // 記録できた時だけ、受け口への報告を別processへ頼む(有効にした端末だけ)。このworkerは通信しない。
16
+ if (store.record(validateRuntimeObservation({ code })))
17
+ triggerRuntimeErrorReport();
11
18
  return;
12
19
  }
13
20
  if (action === "diagnostic" && code === undefined) {
@@ -16,9 +23,4 @@ function main() {
16
23
  }
17
24
  throw new Error("invalid action");
18
25
  }
19
- try {
20
- main();
21
- }
22
- catch {
23
- process.exitCode = 1;
24
- }
26
+ main().catch(() => { process.exitCode = 1; });
@@ -3,6 +3,8 @@ import process from "node:process";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { RuntimeErrorStore } from "./runtime-error-store.js";
6
+ import { defaultRuntimeErrorReportPaths, runtimeErrorReportingStatus } from "./runtime-error-os.js";
7
+ import { readProductCredential, readReportState, reportRuntimeErrors, setRuntimeErrorReporting, triggerRuntimeErrorReport, } from "./runtime-error-report.js";
6
8
  function parseArgs(argv) {
7
9
  const [name, flag, value, ...rest] = argv;
8
10
  if (rest.length > 0)
@@ -18,13 +20,46 @@ function parseArgs(argv) {
18
20
  if (/^[0-9a-f]{64}$/.test(value))
19
21
  return { name, fingerprint: value };
20
22
  }
21
- throw new Error("使い方: aiterm-runtime-errors snapshot | ack --cursor N | resolve|reopen --fingerprint SHA256");
23
+ if (name === "report" && flag === undefined)
24
+ return { name };
25
+ if (name === "reporting" && (flag === "enable" || flag === "disable" || flag === "status") && value === undefined) {
26
+ return { name, action: flag };
27
+ }
28
+ throw new Error("使い方: aiterm-runtime-errors snapshot | ack --cursor N | resolve|reopen --fingerprint SHA256 | report | reporting enable|disable|status");
22
29
  }
23
30
  function emit(value) {
24
31
  process.stdout.write(`${JSON.stringify(value)}\n`);
25
32
  }
26
- export function main(argv = process.argv.slice(2)) {
33
+ // 報告の状態。宛先・合鍵・pathは出さない。
34
+ function reportingStatus() {
35
+ const paths = defaultRuntimeErrorReportPaths();
36
+ const snapshot = new RuntimeErrorStore().snapshot();
37
+ const state = readReportState(paths.reportStatePath);
38
+ return {
39
+ reporting: runtimeErrorReportingStatus(paths.reportingConfigPath),
40
+ credential: readProductCredential(paths.credentialPath).status,
41
+ collection: snapshot.collection,
42
+ unreported: Math.max(0, snapshot.cursor - snapshot.acknowledged_cursor),
43
+ last_attempt_at: state.last_attempt_at, last_status: state.last_status,
44
+ last_http_status: state.last_http_status, last_accepted_at: state.last_accepted_at,
45
+ };
46
+ }
47
+ export async function main(argv = process.argv.slice(2)) {
27
48
  const command = parseArgs(argv);
49
+ if (command.name === "report") {
50
+ const result = await reportRuntimeErrors({ trigger: "manual" });
51
+ emit({ ok: result.status === "accepted" || result.status === "nothing_to_report", command: command.name, result });
52
+ if (result.status !== "accepted" && result.status !== "nothing_to_report")
53
+ process.exitCode = 1;
54
+ return;
55
+ }
56
+ if (command.name === "reporting") {
57
+ if (command.action !== "status") {
58
+ setRuntimeErrorReporting(defaultRuntimeErrorReportPaths().reportingConfigPath, command.action === "enable");
59
+ }
60
+ emit({ ok: true, command: command.name, action: command.action, status: reportingStatus() });
61
+ return;
62
+ }
28
63
  const store = new RuntimeErrorStore();
29
64
  if (command.name === "snapshot") {
30
65
  emit({ ok: true, command: command.name, snapshot: store.snapshot() });
@@ -38,6 +73,9 @@ export function main(argv = process.argv.slice(2)) {
38
73
  ? store.resolve(command.fingerprint)
39
74
  : store.reopen(command.fingerprint);
40
75
  emit({ ok: true, command: command.name, changed, snapshot: store.snapshot() });
76
+ // 解決・開き直しも受け口へ知らせる(有効にした端末だけ。送るのは別process)。
77
+ if (changed)
78
+ triggerRuntimeErrorReport();
41
79
  }
42
80
  function isDirectExecution() {
43
81
  if (!process.argv[1])
@@ -54,13 +92,10 @@ function isDirectExecution() {
54
92
  }
55
93
  }
56
94
  if (isDirectExecution()) {
57
- try {
58
- main();
59
- }
60
- catch {
95
+ main().catch(() => {
61
96
  // CLI も privacy allowlist を守り、store/config の生例外や path を stdout/stderr に反射しない。
62
97
  process.stderr.write("aiterm-runtime-errors: operation failed\n");
63
98
  emit({ ok: false, code: "AITERM_RUNTIME_ERROR_STORE_OPERATION_FAILED" });
64
99
  process.exitCode = 1;
65
- }
100
+ });
66
101
  }
package/docs/DESIGN.md CHANGED
@@ -46,6 +46,9 @@ PIDは開始識別子・argv digestと組にし、paneとharnessを同一視し
46
46
  同じlaunchに属するnpm shimとnative本体は、中間の非候補processも含めた祖先関係から一つの起動として扱う。
47
47
  祖先を共有しない候補は別々に残し、複数候補を一つと推測しない。
48
48
  POSIXの停止状態はOSのprocess表から取得し、SIGSTOP中は残画面より優先して`blocked/harness_stopped`を返す。
49
+ sessionを閉じると失うものは数で返す(ADR 0080)。`activity.post_startup_process_count`は起動完了の時点に居なかったprocessの数、
50
+ `pending_child_deliveries`はそのsessionが親として待つ未配送の数。数えられない時はnullで、0と区別する。
51
+ Claude Codeの入力待ちは、起動時の見出しが取得範囲から流れ出た後も、入力欄の形(`❯`行の上下の罫線)で読む。
49
52
  画面本文とargv本文は返さず、活動cursorには画面digestとprocess別CPUだけを持たせる。
50
53
  初回とpane再作成後の差分はnull。区間中にprocessが消えた時は観測できたCPU増分だけを返し、
51
54
  `cpu_delta_complete=false`を付ける。background活動はpane開始から60秒以降に生成された子孫だけを数える。
@@ -73,6 +76,7 @@ agent sessionへの送信口は`pty_send`だけとする。子の状態は呼び
73
76
  Claude Codeはtool処理中に画面の実行中表示が消え、Stop hookの実行中には表示が残る。turnの印を実行中判定の正本とする。Stopが発火しないAPIエラー終了では、次の送信時に印の作成後の会話記録にある`isApiErrorMessage`を確認し、終了したturnの印だけを解除する。過去のエラーで新しい印を解除しない。waiterは印を変更せず、読取専用のままエラーを返す。
74
77
  それ以外は新しいturnとしてdispatchする。
75
78
  CodexとClaude Codeは次のtool境界で同じturnへ取り込む。Cursorは「follow-ups」枠へ入った文を「enter steer」で現在turnへ移し、`turn_ended`は最後に1回書く。
79
+ 待ち行列の表示は5秒待ち、子のturnが続いている間だけ30秒まで延ばす(表示が遅い端末がある。turnが終わっていれば、文は新しいturnとして始まっている)。
76
80
  Grokは待ち行列へ入れた後に「send now」を押す。旧turnは`cancelled`(`cancellation_context.trigger=send_now`)で閉じ、
77
81
  新turnが作業を継ぐので、完了判定はこの継ぎ目を完了と数えない。Grokが待ち行列へ入れない時とCursorの入力欄に本文が残る時は`steered`を返さない。
78
82
  Cursorのsubmitはadapterがextended keyboard protocolのEnterへ変換し、呼び出し側は通常のdispatchだけを使う。
@@ -320,7 +324,9 @@ privacy案内は現在の枠付きcomposerとmodel footerが見える場合だ
320
324
  ## Diagnostics and local error state
321
325
 
322
326
  `diagnostics`はread-onlyで、PTY backendとagent dependencyを検査する。runtime error aggregateは
323
- 製品所有のlocal stateに固定codeと集約metadataだけを保存し、network I/Oを持たない。工場reporterとの
327
+ 製品所有のlocal stateに固定codeと集約metadataだけを保存し、既定ではnetwork I/Oを持たない。
328
+ BugHubへの報告は、利用者が明示して有効にし、合鍵のファイルがある端末だけが、別processで行う。
329
+ きっかけは記録・解決・起動・手動の4つで、定期的な見張りは置かない。受け取り済みにするのは応答の署名まで確かめた時だけ(ADR 0079)。工場reporterとの
324
330
  連携は明示opt-inの任意adapterであり、未設定時もAiterm本体は単独動作する。raw error、prompt、出力、
325
331
  transcript、path、credentialを保存・公開しない。
326
332
  親配送hook(Claude Code・Cursor)の登録状態は`src/parent-hook-diagnostic.ts`が利用者設定の読取りだけで要約し、
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiterm-mcp",
3
- "version": "0.47.1",
3
+ "version": "0.49.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": [
@@ -70,7 +70,7 @@
70
70
  },
71
71
  "dependencies": {
72
72
  "@modelcontextprotocol/sdk": "^1.29.0",
73
- "aiterm-steer-delivery": "^0.1.8",
73
+ "aiterm-steer-delivery": "^0.1.10",
74
74
  "ws": "8.21.3",
75
75
  "zod": "^4.4.3"
76
76
  },