aiterm-mcp 0.47.0 → 0.48.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,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.48.0] - 2026-10-03
11
+
12
+ ### 追加
13
+
14
+ - 実行時エラーをBugHubへ報告する仕組み。既定では無効で、何も送らない。`aiterm-runtime-errors reporting enable`で明示して有効にし、かつBugHubの持ち主が置いた合鍵のファイルがある端末だけが送る。宛先は合鍵のファイルから読む。送るのは別processで、きっかけはエラーの記録・解決や開き直し・MCPの起動(未報告がある時だけ、多くても1時間に1回)と`aiterm-runtime-errors report`。応答の署名まで確かめた時だけ受け取り済みにする。`aiterm-runtime-errors reporting status`で状態を見られる。
15
+ - 報告を有効にした端末は、工場の収集設定が無くても実行時エラーの記録が有効になる。
16
+
17
+ ### 修正
18
+
19
+ - 試験が、わざと起こした失敗を利用者の実行時エラーの記録へ書いていた(CIを走らせる端末で、試験のたびに1件増えていた)。試験の記録先を一時フォルダへ向けた。製品の動きは変わらない。
20
+
21
+ ## [0.47.1] - 2026-10-03
22
+
23
+ ### 修正
24
+
25
+ - 混んだ端末で、agentの画面が描かれる前に入力受付の待ち(30秒)が切れ、`agent_launch`が`reason=unrecognized_screen`で初手を送らなかった。画面が起動コマンドの表示のままの時だけ、起動から50秒まで待つ。描かれた後の画面の扱いと、50秒でも描かれない時の未送信の返し方は変わらない。
26
+ - Codex・Claude Code・Cursorを親にしたMCP processが、5秒おきに全processの一覧を2回ずつ取っていた(POSIXは`ps -axww`、WindowsはPowerShell)。processの多い端末でCPUを使い続け、その間MCPの応答も止まっていた。5秒おきの確認は他の持ち主のpidの存在だけを見て、開始時刻の照合は初めて見た持ち主と60秒に1回だけ、対象のpidに絞って行う。配送記録を引き継ぐ条件は変わらない。終了直後に同じpidが再利用された時だけ、引き継ぎが最長60秒遅れる。
27
+
10
28
  ## [0.47.0] - 2026-10-03
11
29
 
12
30
  ### 追加
@@ -1976,7 +1994,9 @@ prototype (preserved under `prototype/python/` as the porting source and referen
1976
1994
  `ubuntu-latest` for Node 18/20/22, publishing to npm on `v*` tags with
1977
1995
  provenance.
1978
1996
 
1979
- [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.47.0...HEAD
1997
+ [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.48.0...HEAD
1998
+ [0.48.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.47.1...v0.48.0
1999
+ [0.47.1]: https://github.com/kitepon/aiterm-mcp/compare/v0.47.0...v0.47.1
1980
2000
  [0.47.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.46.1...v0.47.0
1981
2001
  [0.46.1]: https://github.com/kitepon/aiterm-mcp/compare/v0.46.0...v0.46.1
1982
2002
  [0.46.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.45.2...v0.46.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.47.0** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
212
+ **状態:** 開発継続中 · 現行公開版 **v0.48.0** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
213
213
 
214
214
  ### 更新と巻き戻し
215
215
 
@@ -514,6 +514,8 @@ aiterm は同じ核心の洞察——端末を出会いの場にする——を
514
514
  進め、入力受付とharness生存を確認して`startup.status="ready"`を返す。指定なしのpromptなし起動は`not_checked`。
515
515
  Claude Code初回起動の文字表示テーマ選択では、画面で選択済みの項目を確定して起動を続ける。
516
516
  続いてログイン方式の選択が出た場合は`vendor_onboarding_required`を返す。公式の対話型Claude Code CLIで初回設定を完了する。
517
+ 入力受付の待ちは30秒。混んだ端末でagentの画面がまだ描かれていない間に切れた時だけ、起動から50秒まで待つ。
518
+ それでも描かれなければ、今までと同じく未送信(`initial_prompt=not_sent`)で返り、sessionは残る。
517
519
  WindowsのCodexもhook確認を認識し、npm shim経由の起動を一つのharnessとして識別する。
518
520
  初手の`initial_prompt.status`は`not_requested`/`not_sent`/`submitted_unconfirmed`/`started`を区別する。
519
521
  未送信・未確認の失敗もsession付きstructuredContentを保持する。未確認のpromptを再送せず、返ったcursorで観測する。
@@ -552,6 +554,17 @@ Claudeの相関済み承認は既存の`claude_approval`を使う。
552
554
 
553
555
  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 の純粋テストだけであり、新しい実機統合成功は主張しない。
554
556
 
557
+ ### BugHubへの報告(既定では無効)
558
+
559
+ Aitermは、既定では実行時エラーをどこへも送らない。送るのは、次の2つがそろった端末だけ。
560
+
561
+ 1. `aiterm-runtime-errors reporting enable` で明示して有効にした。
562
+ 2. BugHubの持ち主が置いた合鍵のファイルがある(POSIXは `~/.config/bughub/product-credentials/aiterm-mcp.json`、Windowsは `%LOCALAPPDATA%\bughub\product-credentials\aiterm-mcp.json`)。本人だけが読める通常のファイルでなければ読まない。
563
+
564
+ 宛先は合鍵のファイルから読む。送る中身は、エラーのコード・回数・最初と最後の時刻・版・重さ・解決の印だけで、prompt・path・stackは保存も送信もしない。送るのは別processで、MCP processは通信しない。きっかけは、エラーを記録した時・解決や開き直しをした時・MCPの起動時(未報告がある時だけ、多くても1時間に1回)と、`aiterm-runtime-errors report`(その場で1回、1分に1回まで)。応答の署名まで確かめた時だけ受け取り済みにし、確かめられない時は次の機会に送り直す。
565
+
566
+ `aiterm-runtime-errors reporting status` は、有効・無効、合鍵の状態(`ready`/`missing`/`rejected`)、未報告の件数、最後の送信の結果を返す。止める時は `aiterm-runtime-errors reporting disable`。報告を有効にした端末は、工場の収集設定が無くても記録が有効になる。
567
+
555
568
  ### 対話エージェントharness
556
569
 
557
570
  `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.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).
226
+ **Status:** actively maintained · current public release **v0.48.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
 
@@ -546,6 +546,8 @@ displayed token count or null. Callers do not need raw argv or pane-text parsing
546
546
  consent even without a prompt, then verifies input readiness and harness liveness before returning `startup.status="ready"`.
547
547
  For Claude Code's first-run text-style menu, it confirms the item already selected on screen before continuing startup.
548
548
  If the CLI then requests an account login method, the launch reports `vendor_onboarding_required`; complete that choice in the official interactive Claude Code CLI.
549
+ The input-readiness wait is 30 seconds. Only when it expires while the agent has not drawn anything yet (a busy host), the launch
550
+ waits up to 50 seconds from startup. If nothing is drawn by then, it still returns `initial_prompt=not_sent` and keeps the session.
549
551
  A prompt-free launch without this option retains `startup.status="not_checked"`. `initial_prompt.status` distinguishes
550
552
  `not_requested`, `not_sent`, `submitted_unconfirmed`, and `started`. Failure responses retain structured session information.
551
553
  WindowsのCodexもhook確認を認識し、npm shim経由の起動を一つのharnessとして識別する。
@@ -587,6 +589,18 @@ snapshotの`product_version`は各recordの最終実発生時の版を表す。s
587
589
 
588
590
  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.
589
591
 
592
+ **Reporting to BugHub is off by default.** Aiterm sends runtime errors nowhere unless both hold: the user ran
593
+ `aiterm-runtime-errors reporting enable`, and a credential file placed by the BugHub owner exists
594
+ (`~/.config/bughub/product-credentials/aiterm-mcp.json`, or `%LOCALAPPDATA%\bughub\product-credentials\aiterm-mcp.json` on Windows;
595
+ 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
596
+ cumulative error codes, counts, first/last timestamps, versions, severity, and resolution marks; prompts, paths, and stacks are neither
597
+ 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
598
+ is recorded, when a record is resolved or reopened, and when the MCP server starts (only while something is unreported, at most once per
599
+ hour), or on `aiterm-runtime-errors report` (once, at most once per minute). A record becomes acknowledged only after the response
600
+ signature is verified; otherwise the next attempt resends the then-current totals. `aiterm-runtime-errors reporting status` shows the
601
+ switch, the credential state, the unreported count, and the last attempt; `reporting disable` turns it off. Enabling reporting also
602
+ enables collection on that host.
603
+
590
604
  ### Interactive agent harnesses
591
605
 
592
606
  `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
@@ -51,6 +51,11 @@ const AGENT_DONE_SCREEN_SETTLE_MIN_SAMPLES = 3;
51
51
  const AGENT_SUBMIT_DELAY_MS = 250;
52
52
  const AGENT_METADATA_NEGATIVE_CACHE_TTL_MS = 2_000;
53
53
  const AGENT_TUI_READY_TIMEOUT_MS = 30_000;
54
+ // 画面が起動コマンドの表示のまま(agentがまだ何も描いていない)で上の待ちが切れた時に、起動から待つ上限。
55
+ // Codex親の既定のtool timeout(60秒)の内側に収める。越えると親には時間切れに見えたまま子へ初手が送られる。
56
+ const AGENT_TUI_FIRST_DRAW_TIMEOUT_MS = 50_000;
57
+ const LAUNCH_ECHO_TAIL_CHARS = 24;
58
+ const LAUNCH_ECHO_MIN_CHARS = 8;
54
59
  const AGENT_TUI_READY_POLL_MS = 500;
55
60
  const AGENT_TUI_READY_STABLE_SAMPLES = 11;
56
61
  const AGENT_TUI_READY_LINES = 45;
@@ -1272,6 +1277,7 @@ function closeSessionInternal(name, observeDependency = true) {
1272
1277
  }
1273
1278
  }
1274
1279
  cleanupAgentState(name);
1280
+ agentLaunchLines.delete(name);
1275
1281
  return `closed ${name}`;
1276
1282
  }
1277
1283
  export function closeSession(name) {
@@ -2829,11 +2835,32 @@ function isClaudeBypassPermissionsScreen(screen) {
2829
2835
  function isClaudeManagedLaunchConfirmation(screen) {
2830
2836
  return isClaudeWorkspaceTrustScreen(screen) || isClaudeBypassPermissionsScreen(screen);
2831
2837
  }
2838
+ /**
2839
+ * 画面の末尾が、Aitermが打った起動コマンドの表示のままかを見る(純粋関数)。
2840
+ * 混んだ端末ではagentのTUIが描かれる前にready待ちが切れる。画面の最後の文字列が起動コマンドの一部なら、
2841
+ * agentはまだ何も描いていない。空白と行頭の継続prompt(`> `/`>> `)は端末の折返しとshellの表示なので比べない。
2842
+ */
2843
+ export function launchEchoIsLastOnScreen(screen, launchLine) {
2844
+ const command = launchLine.replace(/\s+/g, "");
2845
+ const lines = screen.split("\n");
2846
+ let tail = "";
2847
+ for (let i = lines.length - 1; i >= 0 && tail.length < LAUNCH_ECHO_TAIL_CHARS; i--) {
2848
+ tail = lines[i].replace(/^\s*>{1,2}(?:\s|$)/, "").replace(/\s+/g, "") + tail;
2849
+ }
2850
+ tail = tail.slice(-LAUNCH_ECHO_TAIL_CHARS);
2851
+ return tail.length >= LAUNCH_ECHO_MIN_CHARS && command.includes(tail);
2852
+ }
2853
+ // 起動コマンドはopenAgentだけが知っている。同じprocess内のready待ちが、TUI未描画の判定に使う。
2854
+ const agentLaunchLines = new Map();
2832
2855
  async function waitAgentTuiReadyImpl(kind, sample, sleepFn, opts = {}) {
2833
2856
  const timeoutMs = opts.timeoutMs ?? AGENT_TUI_READY_TIMEOUT_MS;
2834
2857
  const pollMs = opts.pollMs ?? AGENT_TUI_READY_POLL_MS;
2835
2858
  const stableSamples = opts.stableSamples ?? agentTuiReadyStableSamplesTestOverride ?? AGENT_TUI_READY_STABLE_SAMPLES;
2836
- const deadline = performance.now() + timeoutMs;
2859
+ const now = opts.now ?? (() => performance.now());
2860
+ const start = now();
2861
+ let deadline = start + timeoutMs;
2862
+ // 呼ぶ側が既定より短い待ちを指定した時は、その待ちを守る(延ばさない)。
2863
+ const firstDrawDeadline = timeoutMs >= AGENT_TUI_READY_TIMEOUT_MS ? start + AGENT_TUI_FIRST_DRAW_TIMEOUT_MS : deadline;
2837
2864
  let samples = 0;
2838
2865
  let readyStreak = 0;
2839
2866
  let lastScreen = "";
@@ -2852,13 +2879,21 @@ async function waitAgentTuiReadyImpl(kind, sample, sleepFn, opts = {}) {
2852
2879
  if (isAgentTuiActionRequired(kind, lastScreen))
2853
2880
  return { ready: false, samples, lastScreen };
2854
2881
  }
2855
- if (performance.now() >= deadline)
2856
- return { ready: false, samples, lastScreen };
2882
+ if (now() >= deadline) {
2883
+ // TUIが一度も描かれていない間に切れた時だけ、決めた上限まで待ちを延ばす。描かれた後の画面は今までどおり扱う。
2884
+ if (deadline >= firstDrawDeadline || opts.launchLine === undefined
2885
+ || !launchEchoIsLastOnScreen(lastScreen, opts.launchLine))
2886
+ return { ready: false, samples, lastScreen };
2887
+ deadline = firstDrawDeadline;
2888
+ }
2857
2889
  await sleepFn(pollMs);
2858
2890
  }
2859
2891
  }
2860
2892
  async function waitAgentTuiReady(name, meta, timeoutMs = AGENT_TUI_READY_TIMEOUT_MS) {
2861
- return waitAgentTuiReadyImpl(meta.kind, () => captureScreen(name, AGENT_TUI_READY_LINES), sleep, { timeoutMs });
2893
+ const result = await waitAgentTuiReadyImpl(meta.kind, () => captureScreen(name, AGENT_TUI_READY_LINES), sleep, { timeoutMs, launchLine: agentLaunchLines.get(name) });
2894
+ if (result.ready)
2895
+ agentLaunchLines.delete(name);
2896
+ return result;
2862
2897
  }
2863
2898
  async function waitAgentTuiReadyByKind(name, kind, timeoutMs = AGENT_TUI_READY_TIMEOUT_MS) {
2864
2899
  return waitAgentTuiReadyImpl(kind, () => captureScreen(name, AGENT_TUI_READY_LINES), sleep, { timeoutMs });
@@ -3036,9 +3071,13 @@ export async function __testWaitAgentTuiReady(kind, samples, opts = {}) {
3036
3071
  throw new AitermError("agent ready test samples が空です", 2);
3037
3072
  let i = 0;
3038
3073
  const sleeps = [];
3074
+ // virtualClockは待った分だけ進む時計。実時間を使わずに締切の扱いを確かめる。
3075
+ let clock = 0;
3076
+ const { virtualClock, ...waitOpts } = opts;
3039
3077
  const result = await waitAgentTuiReadyImpl(kind, () => samples[Math.min(i++, samples.length - 1)], async (ms) => {
3040
3078
  sleeps.push(ms);
3041
- }, opts);
3079
+ clock += ms;
3080
+ }, virtualClock ? { ...waitOpts, now: () => clock } : waitOpts);
3042
3081
  return { ...result, sleeps };
3043
3082
  }
3044
3083
  export function __testIsAgentTuiIdleReady(kind, screen) {
@@ -4357,6 +4396,8 @@ export function openAgent(kind, opts = {}) {
4357
4396
  const envPrefix = agentEnvPrefix(meta, sid, envVars);
4358
4397
  full = cwdForCmd ? `cd ${shq(cwdForCmd)} && ${envPrefix}${cmd}` : `${envPrefix}${cmd}`;
4359
4398
  }
4399
+ if (meta)
4400
+ agentLaunchLines.set(sid, full);
4360
4401
  // force:true はagent sessionへの手動介入を表す。起動コマンド自体はAitermが組み立てて素送信する。
4361
4402
  send(sid, full, {
4362
4403
  enter: true,
package/dist/index.js CHANGED
@@ -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";
@@ -1120,6 +1121,8 @@ async function main() {
1120
1121
  };
1121
1122
  const transport = new StdioServerTransport();
1122
1123
  await server.connect(transport);
1124
+ // 受け口がまだ受け取っていない実行時エラーがあれば、別processで送る(明示して有効にした端末だけ。既定では何も起動しない)。
1125
+ triggerRuntimeErrorReport();
1123
1126
  }
1124
1127
  main().catch((e) => {
1125
1128
  console.error("aiterm-mcp fatal:", e);
@@ -4,7 +4,7 @@ import * as path from "node:path";
4
4
  import { randomUUID, createHash } from "node:crypto";
5
5
  import { z } from "zod";
6
6
  import { ensureStateRoot, writeJson0600 } from "./agent-shared.js";
7
- import { readRuntimeProcesses } from "./process-runtime.js";
7
+ import { readProcessIdentities } from "./process-runtime.js";
8
8
  import { CodexDeliveryError, submitCodexParentAnswer, verifyCodexParent } from "./codex-parent-receiver.js";
9
9
  import { claudeParentSchema, ClaudeDeliveryError, bindClaudeParentDelivery, submitClaudeParentAnswer, verifyClaudeParent } from "./claude-parent-receiver.js";
10
10
  import { cursorParentSchema, CursorDeliveryError, prepareCursorDelivery, submitCursorParentAnswer, verifyCursorParent } from "./cursor-parent-receiver.js";
@@ -57,6 +57,22 @@ async function submitParentAnswer(parent, deliveryId, text) {
57
57
  return submitCursorParentAnswer(parent, deliveryId, text);
58
58
  return submitCodexParentAnswer(parent, deliveryId, text);
59
59
  }
60
+ // 開始時刻の照会はprocessの起動を伴い、processの多い端末では1回が重い(macOSのpsは-pでも全processを歩く)。
61
+ // 5秒おきの確認はpidの存在だけを見て、開始時刻は初めて見た持ち主とこの間隔でだけ照合する。
62
+ // 持ち主が終了した直後に同じpidが再利用された時だけ、回収が最長でこの間隔だけ遅れる。生きている持ち主の記録は引き取らない。
63
+ const OWNER_IDENTITY_RECHECK_MS = 60_000;
64
+ function processExists(pid) {
65
+ // 0以下はprocess groupを指す。持ち主のpidにはならない。
66
+ if (pid <= 0)
67
+ return false;
68
+ try {
69
+ process.kill(pid, 0);
70
+ return true;
71
+ }
72
+ catch (error) {
73
+ return error.code === "EPERM";
74
+ }
75
+ }
60
76
  function readRecord(file) {
61
77
  try {
62
78
  const record = recordSchema.parse(JSON.parse(fs.readFileSync(file, "utf8")));
@@ -98,11 +114,14 @@ export class ParentDeliveryManager {
98
114
  registering = new Map();
99
115
  timer;
100
116
  recovering = null;
117
+ // 他の持ち主の開始時刻を最後に照合した時刻(持ち主の保存場所ごと)。
118
+ verifiedOwners = new Map();
101
119
  serviceError = null;
102
120
  closing = false;
103
121
  constructor(options = {}) {
104
122
  this.deps = { observe: observeAnywhere, answer: answerAnywhere, submit: submitParentAnswer,
105
- verify: verifyParent, processes: readRuntimeProcesses, ...options.dependencies };
123
+ verify: verifyParent, processes: readProcessIdentities, exists: processExists,
124
+ now: () => performance.now(), ...options.dependencies };
106
125
  const stateRoot = ensureStateRoot();
107
126
  // 別端末の子の記録はremote-で始まる保存場所へ分け、boundary.remoteを知らない旧版のreaderへ渡さない。
108
127
  const prefix = options.remote ? "remote-" : "";
@@ -118,7 +137,7 @@ export class ParentDeliveryManager {
118
137
  this.active = path.join(this.root, "active");
119
138
  this.results = path.join(this.root, "results");
120
139
  this.claims = path.join(options.root ?? path.join(stateRoot, prefix + "parent-deliveries"), "claims");
121
- const ownProcess = this.deps.processes().find((entry) => entry.pid === process.pid);
140
+ const ownProcess = this.deps.processes([process.pid]).find((entry) => entry.pid === process.pid);
122
141
  if (!ownProcess)
123
142
  throw new AitermError("PARENT_DELIVERY_OWNER_UNKNOWN: 配送processを識別できません", 2);
124
143
  this.owner = { pid: process.pid, started_identity: ownProcess.started_identity, closed: false };
@@ -375,13 +394,49 @@ export class ParentDeliveryManager {
375
394
  return this.recovering;
376
395
  }
377
396
  async recoverOrphans() {
378
- const processes = this.deps.processes();
379
- for (const directory of fs.readdirSync(this.active, { withFileTypes: true })) {
380
- if (!directory.isDirectory())
397
+ // 生存を確かめる相手は、closeしていない他の持ち主だけ。居なければOSへ問い合わせない。
398
+ // この処理は5秒おきに全MCP processで回る。全processの一覧を取ると、processの多い端末で負荷になる。
399
+ const directories = fs.readdirSync(this.active, { withFileTypes: true })
400
+ .filter((directory) => directory.isDirectory() && path.join(this.active, directory.name) !== this.ownerDir);
401
+ const now = this.deps.now();
402
+ const alive = new Map();
403
+ const unverified = [];
404
+ for (const directory of directories) {
405
+ let owner;
406
+ try {
407
+ owner = JSON.parse(fs.readFileSync(path.join(this.active, directory.name, "owner.json"), "utf8"));
408
+ }
409
+ catch {
410
+ continue; /* 形式の検査と失敗の扱いは下の回収で行う */
411
+ }
412
+ if (owner?.closed !== false || !Number.isSafeInteger(owner.pid) || typeof owner.started_identity !== "string")
381
413
  continue;
382
- const oldDir = path.join(this.active, directory.name);
383
- if (oldDir === this.ownerDir)
414
+ const pid = owner.pid;
415
+ if (!this.deps.exists(pid)) {
416
+ alive.set(directory.name, false);
384
417
  continue;
418
+ }
419
+ const verified = this.verifiedOwners.get(directory.name);
420
+ if (verified && verified.pid === pid && verified.started_identity === owner.started_identity
421
+ && now - verified.at < OWNER_IDENTITY_RECHECK_MS)
422
+ alive.set(directory.name, true);
423
+ else
424
+ unverified.push({ name: directory.name, pid, started_identity: owner.started_identity });
425
+ }
426
+ if (unverified.length > 0) {
427
+ const processes = this.deps.processes([...new Set(unverified.map((entry) => entry.pid))]);
428
+ for (const entry of unverified) {
429
+ const same = processes.some((row) => row.pid === entry.pid && row.started_identity === entry.started_identity);
430
+ alive.set(entry.name, same);
431
+ if (same)
432
+ this.verifiedOwners.set(entry.name, { pid: entry.pid, started_identity: entry.started_identity, at: now });
433
+ }
434
+ }
435
+ for (const name of this.verifiedOwners.keys())
436
+ if (alive.get(name) !== true)
437
+ this.verifiedOwners.delete(name);
438
+ for (const directory of directories) {
439
+ const oldDir = path.join(this.active, directory.name);
385
440
  let owner;
386
441
  try {
387
442
  owner = JSON.parse(fs.readFileSync(path.join(oldDir, "owner.json"), "utf8"));
@@ -394,7 +449,8 @@ export class ParentDeliveryManager {
394
449
  if (!Number.isSafeInteger(owner.pid) || typeof owner.started_identity !== "string" || typeof owner.closed !== "boolean") {
395
450
  throw new AitermError("PARENT_DELIVERY_OWNER_INVALID: 配送所有者の形式が不正です", 2);
396
451
  }
397
- if (!owner.closed && processes.some((entry) => entry.pid === owner.pid && entry.started_identity === owner.started_identity))
452
+ // 生死を確かめた後に現れた持ち主は、次の回で扱う。生きている持ち主の記録は引き取らない。
453
+ if (!owner.closed && alive.get(directory.name) !== false)
398
454
  continue;
399
455
  for (const name of fs.readdirSync(oldDir).filter((name) => name !== "owner.json" && name.endsWith(".json"))) {
400
456
  const oldFile = path.join(oldDir, name);
@@ -75,6 +75,60 @@ export function readRuntimeProcesses() {
75
75
  };
76
76
  });
77
77
  }
78
+ /**
79
+ * 指定したpidの開始時刻だけを引く。readRuntimeProcessesと同じ開始時刻を返し、存在しないpidは結果に含めない。
80
+ * 全processのargvを読む一覧取得は、processの多い端末で重い(定期実行から呼ばない)。
81
+ */
82
+ export function readProcessIdentities(pids) {
83
+ const wanted = [...new Set(pids)];
84
+ if (wanted.length === 0)
85
+ return [];
86
+ if (wanted.some(pid => !Number.isSafeInteger(pid) || pid < 0))
87
+ throw new AitermError("process照会のpidが不正です", 2);
88
+ if (!isWin) {
89
+ const result = spawnSync("/bin/ps", ["-o", "pid=,lstart=", "-p", wanted.join(",")], {
90
+ encoding: "utf8", env: { ...process.env, LC_ALL: "C" }, timeout: 10000,
91
+ });
92
+ // 該当するprocessが一つも無い時、psは何も出さずstatus 1で終わる。
93
+ if (result.error || (result.status !== 0 && !(result.status === 1 && !result.stdout.trim()))) {
94
+ throw new AitermError("OSのprocess一覧を取得できません", 2);
95
+ }
96
+ return result.stdout.split("\n").filter(line => line.trim()).map(line => {
97
+ const match = /^\s*(\d+)\s+(\S+\s+\S+\s+\d+\s+\S+\s+\d+)\s*$/.exec(line);
98
+ if (!match)
99
+ throw new AitermError("process一覧の形式を認識できません", 2);
100
+ return { pid: Number(match[1]), started_identity: match[2].trim() };
101
+ });
102
+ }
103
+ const script = [
104
+ "$ErrorActionPreference='Stop'",
105
+ "[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)",
106
+ `$rows=@(Get-CimInstance Win32_Process -Filter "${wanted.map(pid => `ProcessId=${pid}`).join(" OR ")}" | Where-Object { $null -ne $_.CreationDate -and $null -ne $_.CommandLine } | ForEach-Object {`,
107
+ "[ordered]@{ pid=[int]$_.ProcessId; started_identity=$_.CreationDate.ToUniversalTime().ToString('o') }",
108
+ "})",
109
+ "ConvertTo-Json -Compress -InputObject $rows",
110
+ ].join("\n");
111
+ const result = spawnSync(resolveWindowsPowerShell7(), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], {
112
+ encoding: "utf8", timeout: 15000, windowsHide: true,
113
+ });
114
+ if (result.error || result.status !== 0)
115
+ throw new AitermError("Windowsのnative process一覧を取得できません", 2);
116
+ let rows;
117
+ try {
118
+ rows = JSON.parse(result.stdout);
119
+ }
120
+ catch {
121
+ throw new AitermError("Windows process一覧のJSONを読めません", 2);
122
+ }
123
+ if (!Array.isArray(rows))
124
+ throw new AitermError("Windows process一覧が配列ではありません", 2);
125
+ return rows.map(row => {
126
+ if (!row || !Number.isSafeInteger(row.pid) || typeof row.started_identity !== "string"
127
+ || !Number.isFinite(Date.parse(row.started_identity)))
128
+ throw new AitermError("Windows process一覧のfieldが不正です", 2);
129
+ return { pid: row.pid, started_identity: new Date(row.started_identity).toISOString() };
130
+ });
131
+ }
78
132
  // Windowsの親PIDは親の終了後も残り、別processへ再利用される。子より後に始まったprocessは
79
133
  // 本当の親ではないので、親子関係として辿らない(辿ると循環や無関係なprocessの混入が起きる)。
80
134
  export function parentProcess(row, byPid) {
@@ -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,260 @@
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
+ const ts = String(Math.floor(started.getTime() / 1000));
182
+ const reportId = randomUUID();
183
+ const report = buildRuntimeErrorReport(snapshot.records, {
184
+ installedVersion: options.installedVersion ?? pkg.version, reportId,
185
+ observedAt: new Date(Number(ts) * 1000).toISOString(),
186
+ });
187
+ const body = Buffer.from(JSON.stringify(report), "utf8");
188
+ if (body.byteLength > MAX_REPORT_BYTES)
189
+ throw new Error("reportが大きすぎます");
190
+ const finish = (status, httpStatus, acknowledged = null) => {
191
+ writeReportState(paths.reportStatePath, {
192
+ ...state, last_attempt_at: started.toISOString(), last_status: status, last_http_status: httpStatus,
193
+ last_accepted_at: status === "accepted" ? started.toISOString() : state.last_accepted_at,
194
+ halted_credential_mtime_ms: status === "halted" ? credentialMtime : null,
195
+ });
196
+ return result(status, { http_status: httpStatus, reported_records: report.runtime_errors.length, acknowledged_cursor: acknowledged });
197
+ };
198
+ let response;
199
+ let text;
200
+ try {
201
+ response = await (options.fetch ?? fetch)(credential.credential.url, {
202
+ method: "POST", redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
203
+ headers: {
204
+ "content-type": "application/json",
205
+ authorization: reportAuthorization(credential.credential.key_id, ts, signReport(credential.credential.secret, ts, body)),
206
+ },
207
+ body,
208
+ });
209
+ text = (await response.text()).slice(0, MAX_RESPONSE_BYTES);
210
+ }
211
+ catch {
212
+ return finish("delivery_unknown", null);
213
+ }
214
+ if (response.status === 200) {
215
+ let parsed = null;
216
+ try {
217
+ parsed = JSON.parse(text);
218
+ }
219
+ catch { /* 署名を確かめられない200は受領にしない */ }
220
+ if (!verifyReportResponse(credential.credential.secret, reportId, parsed))
221
+ return finish("delivery_unknown", 200);
222
+ store.acknowledge(snapshot.cursor);
223
+ return finish("accepted", 200, snapshot.cursor);
224
+ }
225
+ // 合鍵が無効な間は、同じファイルのまま自動で送り続けない。時刻のずれ(401 timestamp_skew)は合鍵の問題ではない。
226
+ if ((response.status === 401 && !/skew/i.test(text)) || response.status === 403)
227
+ return finish("halted", response.status);
228
+ if (response.status === 429)
229
+ return finish("rate_limited", 429);
230
+ if (response.status >= 500)
231
+ return finish("delivery_unknown", response.status);
232
+ return finish("rejected", response.status);
233
+ }
234
+ finally {
235
+ try {
236
+ fs.rmdirSync(lock);
237
+ }
238
+ catch { /* 古いlockとして引き取られた */ }
239
+ }
240
+ }
241
+ /**
242
+ * 自動の送信を別processへ頼む。呼ぶ側(MCP process・記録のworker・CLI)は通信しない。
243
+ * 有効でない端末では何も起動しない。node:testの子で動く時も起動しない(試験が、その端末の本物の記録を
244
+ * 開発中の版の名前で送らないように)。
245
+ */
246
+ export function triggerRuntimeErrorReport(env = process.env) {
247
+ if (env.NODE_TEST_CONTEXT)
248
+ return false;
249
+ try {
250
+ if (runtimeErrorReportingStatus(defaultRuntimeErrorReportPaths().reportingConfigPath) !== "enabled")
251
+ return false;
252
+ const child = spawn(process.execPath, [WORKER, "report"], { stdio: "ignore", detached: true, windowsHide: true, env });
253
+ child.once("error", () => { });
254
+ child.unref();
255
+ return true;
256
+ }
257
+ catch {
258
+ return false;
259
+ }
260
+ }
@@ -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
@@ -136,6 +136,8 @@ Codexの配送記録と本文はAiterm stateの`parent-deliveries`へ保存す
136
136
  再接続後は終了したownerの記録だけを原子的に引き継ぐ。`waiting`は同じ境界から観測を再開し、
137
137
  `ready`は保存した本文を送る。送信中断は`unknown`として本文を残し、自動再送しない。
138
138
  受信口が明示拒否した場合は`failed`、子の異常終了はそのoutcomeを配送する。
139
+ 生存の確認は5秒おきにpidの存在だけをOSへ聞き、開始識別子の照合は初めて見たownerと60秒に1回だけ行う。
140
+ 全processの一覧は取らない。終了直後に同じpidが再利用された時だけ、引き継ぎが最長60秒遅れる(ADR 0078)。
139
141
  このstateは既存のPTY/harness stateと独立し、旧版は配送を再開しない。
140
142
 
141
143
  ### Claude Code親への自動配送
@@ -195,6 +197,8 @@ Claude Codeの初回テーマ選択は選択済みの項目を確定し、後続
195
197
  ログイン方式の選択はユーザーのアカウント設定として扱い、composerと誤認せず`vendor_onboarding_required`で止める。
196
198
  promptなしでも入力受付とharness生存を確認して`startup.ready`を返す。指定なしのpromptなし起動は
197
199
  従来どおり`startup.not_checked`で返す。初手receiptは未要求・未送信・送信済み未確認・開始確認を分ける。
200
+ 入力受付の待ちは30秒。画面が起動コマンドの表示のまま(agentがまだ何も描いていない)で切れた時だけ、
201
+ 起動から50秒まで待つ。Codex親の既定のtool timeout(60秒)の内側に収める(ADR 0078)。
198
202
  開始の証拠は送信後の実行表示、実行中の既知承認、または同じcursor以降の完了だけとし、残存なしでは代用しない。
199
203
  Codexの設定エラー等でCLIが終了した場合は、残った画面へ送らず未送信で止める。
200
204
 
@@ -316,7 +320,9 @@ privacy案内は現在の枠付きcomposerとmodel footerが見える場合だ
316
320
  ## Diagnostics and local error state
317
321
 
318
322
  `diagnostics`はread-onlyで、PTY backendとagent dependencyを検査する。runtime error aggregateは
319
- 製品所有のlocal stateに固定codeと集約metadataだけを保存し、network I/Oを持たない。工場reporterとの
323
+ 製品所有のlocal stateに固定codeと集約metadataだけを保存し、既定ではnetwork I/Oを持たない。
324
+ BugHubへの報告は、利用者が明示して有効にし、合鍵のファイルがある端末だけが、別processで行う。
325
+ きっかけは記録・解決・起動・手動の4つで、定期的な見張りは置かない。受け取り済みにするのは応答の署名まで確かめた時だけ(ADR 0079)。工場reporterとの
320
326
  連携は明示opt-inの任意adapterであり、未設定時もAiterm本体は単独動作する。raw error、prompt、出力、
321
327
  transcript、path、credentialを保存・公開しない。
322
328
  親配送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.0",
3
+ "version": "0.48.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": [