aiterm-mcp 0.12.0 → 0.12.2

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/README.ja.md CHANGED
@@ -22,7 +22,12 @@
22
22
 
23
23
  **言葉でなく実測で:** このリポジトリ自身の 203 テストで、`pty_read` はコンテキストに載るトークンを生ログの **約 7.1 分の 1** に減らす。しかも pass/fail の判定は畳んでも残る。→ [組み込みシェルツールとの使い分け](#組み込みシェルツールとの使い分け)
24
24
 
25
- 9 ツール: 6 つの **PTY ツール**(`pty_open` / `pty_send` / `pty_read` / `pty_key` / `pty_close` / `pty_list`)で 1 本の永続端末を開き・操作し・読む。加えて 3 つの **エージェント起動ツール**(`codex_agent` / `grok_agent` / `composer_agent`)が、別のコーディングエージェントの TUI を新しい端末の中に起動する。バックエンドは **tmux** なので、MCP サーバや AI クライアントが再起動してもセッションは生き残る。
25
+ 10 ツール: 6 つの **PTY ツール**(`pty_open` / `pty_send` / `pty_read` / `pty_key` / `pty_close` / `pty_list`)で 1 本の永続端末を開き・操作し・読む。加えて 3 つの **エージェント起動ツール**(`codex_agent` / `grok_agent` / `composer_agent`)が、別のコーディングエージェントの TUI を新しい端末の中に起動し、`diagnostics` が安全な factory readiness を返す。バックエンドは **tmux** なので、MCP サーバや AI クライアントが再起動してもセッションは生き残る。
26
+
27
+ **v0.12.2 は release candidate(公開待ち)です。** factory diagnostics と local
28
+ runtime-error store は canonical dotagents config の `collection.enabled: true` が明示された
29
+ 場合だけ収集し、既定OFF、network送信は行いません。npm公開、tag、CI、registry登録、registry由来install
30
+ の確認は未実施です。
26
31
 
27
32
  **状態:** 開発継続中 · この分野では新参で、別の形に賭けている([既存手段との比較](#既存手段との比較)参照)· 動作対象は Linux · WSL2 · macOS · Windows ネイティブ(core PTY ツール。`agent_done` は現時点では POSIX/WSL/macOS のみ)· MIT · [変更履歴](CHANGELOG.md)。
28
33
 
@@ -129,7 +134,7 @@ claude mcp add --scope user --transport stdio aiterm -- npx -y aiterm-mcp
129
134
  Claude Code を再起動して、接続を確認:
130
135
 
131
136
  ```bash
132
- /mcp # aiterm が connected・9 ツール公開、と出る
137
+ /mcp # aiterm が connected・10 ツール公開、と出る
133
138
  ```
134
139
 
135
140
  最初のセッション——4 回の呼び出しで、1 個の永続端末:
@@ -167,7 +172,7 @@ MCP クライアントが aiterm を stdio 越しにプログラムから駆動
167
172
 
168
173
  ```mermaid
169
174
  flowchart LR
170
- AI["AI / MCP client<br/>(the orchestrator)"] -->|"pty_send · codex_agent<br/>grok_agent · composer_agent"| S["aiterm-mcp<br/>stdio MCP · 9 tools"]
175
+ AI["AI / MCP client<br/>(the orchestrator)"] -->|"pty_send · codex_agent<br/>grok_agent · composer_agent · diagnostics"| S["aiterm-mcp<br/>stdio MCP · 10 tools"]
171
176
  S -->|"pty_read<br/>token-reduced"| AI
172
177
  S -->|"tmux send-keys<br/>capture-pane"| P["persistent PTYs<br/>tmux · survive restarts"]
173
178
  P -->|"ssh · docker · repl"| R["nested<br/>remote · container · REPL"]
@@ -248,6 +253,15 @@ aiterm は同じ核心の洞察——端末を出会いの場にする——を
248
253
  | `pty_key` | 制御キーを送る | `session_id`, `key`(`C-c`/`Enter`/`Up`…) |
249
254
  | `pty_close` | セッションを閉じる | `session_id` |
250
255
  | `pty_list` | セッション一覧 | (なし) |
256
+ | `diagnostics` | 機械可読 JSON による read-only factory readiness | (なし) |
257
+
258
+ `diagnostics` は PTY やエージェントを起動しない。パッケージ版、MCP 呼出 readiness、read-only な PTY 一覧要約、bounded runtime-error-store status、任意 vendor launcher の可用性だけを返す。path・環境値・認証情報・コマンド本文・PTY 出力・raw log は意図的に返さない。通常未設定の任意依存は `not_applicable`、安全に確定できない状態は `unverified` と表す。
259
+
260
+ ### ローカル runtime error snapshot
261
+
262
+ `aiterm-runtime-errors snapshot` は dotagents factory adapter 向けに、製品所有のローカル snapshot を機械可読 JSON で返す。canonical dotagents factory-reporter config が schema-exact、host profile が実行 OS と一致し、`collection.enabled` が JSON boolean `true` の時だけ収集する。reporting field は schema 検証するが endpoint/credential file へ接続・読取せず network I/O も行わない。観測 API は core owner layer の固定3 code(PTY dependency・persistence・任意 vendor launcher)だけを受け、保存するのも固定 template と aggregate metadata(SHA-256 fingerprint、count、first/last、status、monotonic sequence)だけ。exception、stderr/stdout、stack、prompt、PTY/transcript/event body、path、任意 context は受け付けない。保存済み JSON も top/record exact・固定定義一致・fingerprint 再計算を通し、明示 DTO だけを返す。
263
+
264
+ 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を作らない。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 の純粋テストだけであり、新しい実機統合成功は主張しない。
251
265
 
252
266
  ### 対話エージェント起動ツール
253
267
 
package/README.md CHANGED
@@ -22,7 +22,13 @@
22
22
 
23
23
  **Measured, not claimed:** on this repo's own 203-test suite, a `pty_read` puts **~7.1× fewer tokens** in your context than the raw log — and the pass/fail verdict survives the fold. → [When to reach for it vs. the built-in shell](#when-to-reach-for-it-vs-the-built-in-shell)
24
24
 
25
- Nine tools: six **PTY tools** — `pty_open` / `pty_send` / `pty_read` / `pty_key` / `pty_close` / `pty_list` — to open, drive, and read one persistent terminal, plus three **agent launchers** — `codex_agent` / `grok_agent` / `composer_agent` — that each start another coding agent's TUI inside a fresh one. The backend is **tmux**, so sessions survive even if the MCP server or the AI client restarts.
25
+ Ten tools: six **PTY tools** — `pty_open` / `pty_send` / `pty_read` / `pty_key` / `pty_close` / `pty_list` — to open, drive, and read one persistent terminal, three **agent launchers** — `codex_agent` / `grok_agent` / `composer_agent` — that each start another coding agent's TUI inside a fresh one, and `diagnostics` for safe factory readiness. The backend is **tmux**, so sessions survive even if the MCP server or the AI client restarts.
26
+
27
+ **v0.12.2 is a release candidate (pending).** Factory diagnostics and the local
28
+ runtime-error store collect only when canonical dotagents config explicitly sets
29
+ `collection.enabled: true`; collection is off by default and performs no network
30
+ I/O. Publication, tag, CI, registry registration, and registry-install verification
31
+ are pending.
26
32
 
27
33
  **Status:** actively maintained · the newcomer here, betting on a different shape (see [vs. the alternatives](#vs-the-alternatives)) · runs on Linux · WSL2 · macOS · native Windows for the core PTY tools (`agent_done` is POSIX/WSL/macOS only for now) · MIT · see the [CHANGELOG](CHANGELOG.md).
28
34
 
@@ -129,7 +135,7 @@ claude mcp add --scope user --transport stdio aiterm -- npx -y aiterm-mcp
129
135
  Restart Claude Code, then verify the connection:
130
136
 
131
137
  ```bash
132
- /mcp # aiterm should show as connected, exposing 9 tools
138
+ /mcp # aiterm should show as connected, exposing 10 tools
133
139
  ```
134
140
 
135
141
  Your first session — four calls, one persistent terminal:
@@ -167,7 +173,7 @@ The terminal is real and shared, so a human *can* jump in ([A human can watch](#
167
173
 
168
174
  ```mermaid
169
175
  flowchart LR
170
- AI["AI / MCP client<br/>(the orchestrator)"] -->|"pty_send · codex_agent<br/>grok_agent · composer_agent"| S["aiterm-mcp<br/>stdio MCP · 9 tools"]
176
+ AI["AI / MCP client<br/>(the orchestrator)"] -->|"pty_send · codex_agent<br/>grok_agent · composer_agent · diagnostics"| S["aiterm-mcp<br/>stdio MCP · 10 tools"]
171
177
  S -->|"pty_read<br/>token-reduced"| AI
172
178
  S -->|"tmux send-keys<br/>capture-pane"| P["persistent PTYs<br/>tmux · survive restarts"]
173
179
  P -->|"ssh · docker · repl"| R["nested<br/>remote · container · REPL"]
@@ -250,6 +256,15 @@ On top of that sits a productized layer a raw tmux bridge doesn't have: **token-
250
256
  | `pty_key` | Send a control key | `session_id`, `key` (`C-c`/`Enter`/`Up`…) |
251
257
  | `pty_close` | Close a session | `session_id` |
252
258
  | `pty_list` | List sessions (agent rows carry `agent=<kind>` metadata) | (none) |
259
+ | `diagnostics` | Read-only factory readiness as machine-readable JSON | (none) |
260
+
261
+ `diagnostics` never starts a PTY or agent. It reports package version, MCP call readiness, a read-only PTY-list summary, bounded runtime-error-store status, and optional vendor-launcher availability. It deliberately excludes paths, environment values, credentials, command text, PTY output, and raw logs; normal unset optional dependencies are `not_applicable`, while an indeterminate probe is `unverified`.
262
+
263
+ ### Local runtime error snapshot
264
+
265
+ `aiterm-runtime-errors snapshot` exposes a machine-readable, product-owned local snapshot for the dotagents factory adapter. Collection is fail-closed unless the canonical dotagents factory-reporter config is schema-exact, its host profile matches the executing OS, and it contains the JSON boolean `collection.enabled: true`; reporting fields are schema-validated but endpoints and credential files are never contacted, and the store performs no network I/O. The only accepted observations are three fixed codes owned by the core boundary (PTY dependency, persistence, and optional vendor launcher). Stored data is limited to fixed templates and aggregate metadata (SHA-256 fingerprint, count, first/last seen, status, and monotonic sequence); exceptions, stderr/stdout, stacks, prompts, terminal/transcript/event bodies, paths, and arbitrary context cannot enter the API. Persisted JSON is revalidated with exact top/record fields and a recomputed fingerprint before explicit DTO projection.
266
+
267
+ 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. 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.
253
268
 
254
269
  ### Interactive agent launchers
255
270
 
@@ -77,7 +77,11 @@ function appendEvent(file, event) {
77
77
  if (!st.isFile() || st.uid !== uid() || st.nlink !== 1 || (st.mode & 0o077) !== 0) {
78
78
  fail(`event file が安全ではありません: ${file}`);
79
79
  }
80
- fs.writeSync(fd, line, undefined, "utf8");
80
+ const written = fs.writeSync(fd, line, undefined, "utf8");
81
+ if (written < Buffer.byteLength(line, "utf8")) {
82
+ fs.ftruncateSync(fd, st.size);
83
+ fail(`event file への書込みが途中で終了しました: ${file}`);
84
+ }
81
85
  }
82
86
  finally {
83
87
  fs.closeSync(fd);
package/dist/core.js CHANGED
@@ -14,6 +14,7 @@ import * as os from "node:os";
14
14
  import { createHash, randomBytes } from "node:crypto";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import * as rtk from "./rtk.js";
17
+ import { recordRuntimeError } from "./runtime-error-store.js";
17
18
  // Windows ネイティブには tmux が無い。その場合だけ全 tmux 呼び出しを WSL 経由へ橋渡しする
18
19
  // (POSIX = Linux/WSL2/macOS は従来どおり tmux を直接叩く)。
19
20
  const isWin = process.platform === "win32";
@@ -48,6 +49,8 @@ const AGENT_DONE_SCREEN_SETTLE_MAX_POLLS = 5;
48
49
  const AGENT_DONE_SCREEN_SETTLE_MIN_SAMPLES = 3;
49
50
  const AGENT_SUBMIT_DELAY_MS = 250;
50
51
  const AGENT_EVENT_MAX_BYTES = 1024 * 1024;
52
+ const AGENT_EVENT_TAIL_BYTES = 64 * 1024;
53
+ const AGENT_METADATA_NEGATIVE_CACHE_TTL_MS = 2_000;
51
54
  const AGENT_TUI_READY_TIMEOUT_MS = 30_000;
52
55
  const AGENT_TUI_READY_POLL_MS = 500;
53
56
  const AGENT_TUI_READY_LINES = 45;
@@ -96,6 +99,33 @@ export class AitermError extends Error {
96
99
  this.code = code;
97
100
  }
98
101
  }
102
+ class TelemetryOwnedError extends AitermError {
103
+ telemetryCode;
104
+ constructor(message, code, telemetryCode, cause) {
105
+ super(message, code);
106
+ this.name = "TelemetryOwnedError";
107
+ this.telemetryCode = telemetryCode;
108
+ if (cause !== undefined)
109
+ this.cause = cause;
110
+ }
111
+ }
112
+ function telemetryOwnedFailure(telemetryCode, error, fallbackCode = 1) {
113
+ if (error instanceof TelemetryOwnedError)
114
+ return error;
115
+ recordRuntimeError(telemetryCode);
116
+ const message = error instanceof Error ? error.message : String(error);
117
+ const code = error instanceof AitermError ? error.code : fallbackCode;
118
+ return new TelemetryOwnedError(message, code, telemetryCode, error);
119
+ }
120
+ function ownTelemetryFailure(telemetryCode, error, fallbackCode = 1) {
121
+ throw telemetryOwnedFailure(telemetryCode, error, fallbackCode);
122
+ }
123
+ function ptyDependencyError(message, observe = true) {
124
+ const error = new AitermError(message, 2);
125
+ if (observe)
126
+ ownTelemetryFailure("AITERM.PTY_DEPENDENCY_UNAVAILABLE", error, 2);
127
+ throw error;
128
+ }
99
129
  // Windows のドライブパス (C:\a\b) を WSL から見える /mnt/c/a/b へ変換する。
100
130
  // 一時領域は常にドライブ直下なので UNC は想定外=弾く(黙って壊れた //server パスを作らない)。
101
131
  export function toWslPath(p) {
@@ -107,22 +137,22 @@ export function toWslPath(p) {
107
137
  // Windows で最初の tmux 呼び出し前に一度だけ WSL+tmux の可用性を確かめ、失敗は原因別に投げる。
108
138
  // -e(ログインシェル非経由)+短い timeout で、初回セットアップ未完了の wsl によるハングも防ぐ。
109
139
  let winBridgeOk = false;
110
- function ensureWinBridge() {
140
+ function ensureWinBridge(observe = true) {
111
141
  if (winBridgeOk)
112
142
  return;
113
143
  const r = spawnSync("wsl.exe", ["-e", "tmux", "-V"], { encoding: "utf8", timeout: 10000 });
114
144
  if (r.error) {
115
145
  const code = r.error.code;
116
146
  if (code === "ETIMEDOUT")
117
- throw new AitermError("WSL が応答しません(初回セットアップ未完了の可能性)。一度 `wsl` を起動してから再実行してください。", 2);
147
+ ptyDependencyError("WSL が応答しません(初回セットアップ未完了の可能性)。一度 `wsl` を起動してから再実行してください。", observe);
118
148
  if (code === "ENOENT")
119
- throw new AitermError("wsl.exe が見つかりません。Windows では WSL 上の tmux 経由で動作します。WSL と tmux を導入してください。", 2);
120
- throw new AitermError(`wsl.exe を起動できませんでした(${code ?? "unknown"})。`, 2);
149
+ ptyDependencyError("wsl.exe が見つかりません。Windows では WSL 上の tmux 経由で動作します。WSL と tmux を導入してください。", observe);
150
+ ptyDependencyError(`wsl.exe を起動できませんでした(${code ?? "unknown"})。`, observe);
121
151
  }
122
152
  // wsl.exe は System32 にあるので「起動」は成功するが、ディストリ未導入や distro 内に tmux が無いと
123
153
  // 非ゼロで終わる。両方を区別せず(wsl の出力は UTF-16 で文字化けし得るため)正直に表す。
124
154
  if (r.status !== 0)
125
- throw new AitermError("WSL 経由で tmux を起動できませんでした。WSL のディストリ未導入、または distro 内に tmux が無い可能性があります。`wsl tmux -V` が通るか確認してください(tmux 導入例: sudo apt install tmux)。", 2);
155
+ ptyDependencyError("WSL 経由で tmux を起動できませんでした。WSL のディストリ未導入、または distro 内に tmux が無い可能性があります。`wsl tmux -V` が通るか確認してください(tmux 導入例: sudo apt install tmux)。", observe);
126
156
  winBridgeOk = true;
127
157
  }
128
158
  // tmux が見つからないときの説明。macOS は tmux を同梱せず、Homebrew の bin は GUI 起動時の PATH に
@@ -141,7 +171,7 @@ function tmuxMissingMessage() {
141
171
  // 解決順: AITERM_TMUX(明示指定)→ PATH 上の tmux → Homebrew 既定パス。一度だけ実行しキャッシュする。
142
172
  // 見つからなければ tmuxMissingMessage で投げる(黙ってフォールバックせず、原因が見えるようにする)。
143
173
  let tmuxBin = null;
144
- function resolveTmux() {
174
+ function resolveTmux(observe = true) {
145
175
  if (tmuxBin)
146
176
  return tmuxBin;
147
177
  const override = process.env.AITERM_TMUX;
@@ -149,7 +179,7 @@ function resolveTmux() {
149
179
  const r = spawnSync(override, ["-V"], { encoding: "utf8", timeout: 5000 });
150
180
  if (!r.error && r.status === 0)
151
181
  return (tmuxBin = override);
152
- throw new AitermError(`AITERM_TMUX に指定された tmux を起動できません: ${override}(\`${override} -V\` が通りません)`, 2);
182
+ ptyDependencyError(`AITERM_TMUX に指定された tmux を起動できません: ${override}(\`${override} -V\` が通りません)`, observe);
153
183
  }
154
184
  // CLI/開発時は PATH 上の tmux をそのまま使う(最優先)。
155
185
  const onPath = spawnSync("tmux", ["-V"], { encoding: "utf8", timeout: 5000 });
@@ -167,20 +197,20 @@ function resolveTmux() {
167
197
  /* 次の候補へ */
168
198
  }
169
199
  }
170
- throw new AitermError(tmuxMissingMessage(), 2);
200
+ ptyDependencyError(tmuxMissingMessage(), observe);
171
201
  }
172
- function tmux(...args) {
202
+ function tmuxCommand(observe, ...args) {
173
203
  // maxBuffer は既定 1MiB。capture-pane(大きなスクロールバック)や多セッションの list-sessions で
174
204
  // 頭打ちになり stdout が切れる/空になる。Python の subprocess.run は無制限だったので 64MiB へ広げる。
175
205
  // Windows は同じ tmux を WSL 経由(-e でログインシェル非経由=$ 展開やクオート崩れを防ぐ)で叩く。
176
206
  let r;
177
207
  if (isWin) {
178
- ensureWinBridge();
208
+ ensureWinBridge(observe);
179
209
  r = spawnSync("wsl.exe", ["-e", "tmux", "-S", SOCK, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
180
210
  }
181
211
  else {
182
212
  // resolveTmux() は tmux を解決できなければ明確な AitermError を投げる(POSIX 版の事前確認)。
183
- r = spawnSync(resolveTmux(), ["-S", SOCK, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
213
+ r = spawnSync(resolveTmux(observe), ["-S", SOCK, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
184
214
  }
185
215
  // ENOBUFS(出力が 64MiB 超)を「code=1 の失敗」へ握り潰すと部分/空 stdout を正常扱いしてしまう。区別して投げる。
186
216
  // EXPECTED-FAILURE: 外部システム境界(tmux 出力過大)
@@ -191,10 +221,16 @@ function tmux(...args) {
191
221
  // 通常は resolveTmux() が事前に弾くため発火しないが、mid-run 消滅に対する正直な防御。
192
222
  if (!isWin && r.error && r.error.code === "ENOENT") {
193
223
  tmuxBin = null; // 次回 resolveTmux で再解決を許す
194
- throw new AitermError(tmuxMissingMessage(), 2);
224
+ ptyDependencyError(tmuxMissingMessage(), observe);
195
225
  }
196
226
  return { code: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
197
227
  }
228
+ function tmux(...args) {
229
+ return tmuxCommand(true, ...args);
230
+ }
231
+ function tmuxCleanup(...args) {
232
+ return tmuxCommand(false, ...args);
233
+ }
198
234
  function sessionExists(name) {
199
235
  return tmux("has-session", "-t", name).code === 0;
200
236
  }
@@ -318,6 +354,7 @@ function existingAgentsDir() {
318
354
  }
319
355
  function cleanupAgentState(name) {
320
356
  assertSessionName(name);
357
+ agentMetadataNegativeCache.delete(name);
321
358
  const dir = existingAgentsDir();
322
359
  if (!dir)
323
360
  return;
@@ -682,7 +719,12 @@ function assertNotDestructive(text, code, context = "") {
682
719
  }
683
720
  // ---------------------------------------------------------------- 操作(return で返す / 失敗は AitermError)
684
721
  export function openSession(name, shell = "bash") {
685
- fs.mkdirSync(SOCKDIR, { recursive: true });
722
+ try {
723
+ fs.mkdirSync(SOCKDIR, { recursive: true });
724
+ }
725
+ catch (error) {
726
+ ownTelemetryFailure("AITERM.PERSISTENCE_WRITE_FAILED", error);
727
+ }
686
728
  // macOS の /bin/bash は 3.2 で、起動時に zsh 移行バナーを出して最初の read を汚す。darwin かつ bash の
687
729
  // ときだけ -e で環境変数を渡して抑止する。-e は tmux>=3.2 が必要だが macOS の Homebrew tmux は常に該当。
688
730
  // (古い tmux<3.2 が残る Linux/WSL で -e を渡すと new-session が落ちるため、darwin 限定にする。)
@@ -717,7 +759,12 @@ export function openSession(name, shell = "bash") {
717
759
  // 新規セッションの .log は必ず truncate する。"a"(追記)だと外部 kill / killAll / クラッシュで
718
760
  // 同名 session だけ消えて .log が残った場合、offset=0 と相まって旧出力を新規として返す(B5)。
719
761
  // break は new-session 成功後にのみ到達=作りたての空 session ゆえ切り詰めは安全。lastcmd/mark 残骸も掃除。
720
- fs.writeFileSync(logpath(nm), "");
762
+ try {
763
+ fs.writeFileSync(logpath(nm), "");
764
+ }
765
+ catch (error) {
766
+ ownTelemetryFailure("AITERM.PERSISTENCE_WRITE_FAILED", error);
767
+ }
721
768
  for (const p of [lastcmdpath(nm), markpath(nm)]) {
722
769
  try {
723
770
  fs.unlinkSync(p);
@@ -735,16 +782,24 @@ export function openSession(name, shell = "bash") {
735
782
  const pr = tmux("pipe-pane", "-t", nm, "-o", `cat >> ${quoted}`);
736
783
  if (pr.code !== 0) {
737
784
  // 配管に失敗した session は pty_read が永遠に空を返す=成功を装わない。作った session を片付けて明示エラー。
738
- tmux("kill-session", "-t", nm);
785
+ try {
786
+ tmuxCleanup("kill-session", "-t", nm);
787
+ }
788
+ catch { /* cleanup failure is not a second observation */ }
739
789
  try {
740
790
  fs.unlinkSync(logpath(nm)); // B14: 直前に作った空 .log も残さない
741
791
  }
742
792
  catch {
743
793
  /* noop */
744
794
  }
745
- throw new AitermError("tmux pipe-pane 失敗(出力ログを配管できないため session を破棄): " + pr.stderr.trim(), 2);
795
+ ownTelemetryFailure("AITERM.PERSISTENCE_WRITE_FAILED", new AitermError("tmux pipe-pane 失敗(出力ログを配管できないため session を破棄): " + pr.stderr.trim(), 2), 2);
796
+ }
797
+ try {
798
+ writeOffset(nm, 0);
799
+ }
800
+ catch (error) {
801
+ ownTelemetryFailure("AITERM.PERSISTENCE_WRITE_FAILED", error);
746
802
  }
747
- writeOffset(nm, 0);
748
803
  return [nm, attachHint(nm)];
749
804
  }
750
805
  export function send(name, text, o = {}) {
@@ -976,7 +1031,29 @@ export function listSessions() {
976
1031
  }
977
1032
  return "(セッション無し)";
978
1033
  }
979
- export function closeSession(name) {
1034
+ /**
1035
+ * `pty_list` 相当を read-only に照会し、内容を返さず session 件数だけを返す。
1036
+ * session 名・前面コマンド・PTY 出力を診断応答へ持ち出さないため、factory が安全に readiness を
1037
+ * 見られる。socket 不在は「セッション未設定」であって障害ではない。
1038
+ */
1039
+ export function readOnlyPtyListDiagnostic() {
1040
+ try {
1041
+ const r = tmux("list-sessions", "-F", "#{session_name}");
1042
+ if (r.code === 0) {
1043
+ return { status: "ready", session_count: r.stdout.split(/\r?\n/).filter(Boolean).length };
1044
+ }
1045
+ // tmux は専用 socket に server がいない通常状態を exit 1 で返す。その他の失敗を「空」と
1046
+ // 偽装しないため、メッセージを公開せず unverified に留める。
1047
+ if (/no server running|failed to connect/i.test(r.stderr)) {
1048
+ return { status: "not_applicable", session_count: 0 };
1049
+ }
1050
+ }
1051
+ catch {
1052
+ // tmux 未導入・WSL bridge 不全等。絶対 path や生 stderr を診断 JSON に出さない。
1053
+ }
1054
+ return { status: "unverified", session_count: null };
1055
+ }
1056
+ function closeSessionInternal(name, observeDependency = true) {
980
1057
  assertSessionName(name);
981
1058
  if (agentWaitLocks.has(name)) {
982
1059
  throw new AitermError(`agent session '${name}' は agent_done 待機中のため close できません`, 2);
@@ -989,7 +1066,7 @@ export function closeSession(name) {
989
1066
  throw new AitermError(`agent session '${name}' は別プロセス${d.pid != null ? `(pid ${d.pid})` : ""}の agent_done 待機中のため close できません`, 2);
990
1067
  }
991
1068
  }
992
- tmux("kill-session", "-t", name);
1069
+ (observeDependency ? tmux : tmuxCleanup)("kill-session", "-t", name);
993
1070
  for (const p of [logpath(name), offsetpath(name), lastcmdpath(name), markpath(name)]) {
994
1071
  try {
995
1072
  fs.unlinkSync(p);
@@ -1001,6 +1078,9 @@ export function closeSession(name) {
1001
1078
  cleanupAgentState(name);
1002
1079
  return `closed ${name}`;
1003
1080
  }
1081
+ export function closeSession(name) {
1082
+ return closeSessionInternal(name, true);
1083
+ }
1004
1084
  export function killAll() {
1005
1085
  if (agentWaitLocks.size > 0) {
1006
1086
  throw new AitermError(`agent_done 待機中の session があるため killAll できません: ${Array.from(agentWaitLocks).join(",")}`, 2);
@@ -1053,10 +1133,12 @@ export function killAll() {
1053
1133
  /* agent state dir 不在等は無視 */
1054
1134
  }
1055
1135
  }
1136
+ agentMetadataNegativeCache.clear();
1056
1137
  return "killed all sessions on this socket";
1057
1138
  }
1058
1139
  const DEFAULT_AGENT_DONE_TIMEOUT = 600;
1059
1140
  const agentWaitLocks = new Set();
1141
+ const agentMetadataNegativeCache = new Map();
1060
1142
  function normalizeAgentLauncherWait(v) {
1061
1143
  if (v == null || v === "none")
1062
1144
  return "none";
@@ -1798,9 +1880,16 @@ function tryLoadAgentMetadata(name) {
1798
1880
  }
1799
1881
  function latestAgentDoneEvent(meta) {
1800
1882
  const size = safeStatSize(meta.event_file);
1801
- if (size === 0 || size > AGENT_EVENT_MAX_BYTES)
1883
+ if (size === 0)
1802
1884
  return null;
1803
- const text = readFileRange(meta.event_file, 0, size).toString("utf8");
1885
+ const isTailRead = size > AGENT_EVENT_TAIL_BYTES;
1886
+ let text = readFileRange(meta.event_file, isTailRead ? size - AGENT_EVENT_TAIL_BYTES : 0, size).toString("utf8");
1887
+ if (isTailRead) {
1888
+ const firstNewline = text.indexOf("\n");
1889
+ if (firstNewline === -1)
1890
+ return null;
1891
+ text = text.slice(firstNewline + 1);
1892
+ }
1804
1893
  let latest = null;
1805
1894
  for (const line of text.split("\n")) {
1806
1895
  if (!line.trim() || Buffer.byteLength(line, "utf8") > 64 * 1024)
@@ -1966,9 +2055,21 @@ function inferAgentFrontend(name, meta, screen) {
1966
2055
  return "unknown";
1967
2056
  }
1968
2057
  function agentReadMetadataSuffix(name, screen) {
1969
- const meta = tryLoadAgentMetadata(name);
1970
- if (!meta)
2058
+ const now = Date.now();
2059
+ const negativeCacheUntil = agentMetadataNegativeCache.get(name);
2060
+ if (negativeCacheUntil && negativeCacheUntil > now)
1971
2061
  return "";
2062
+ let meta;
2063
+ try {
2064
+ meta = loadAgentMetadata(name);
2065
+ }
2066
+ catch (e) {
2067
+ if (e instanceof AitermError && e.message.includes("agent_done 管理セッションではありません")) {
2068
+ agentMetadataNegativeCache.set(name, now + AGENT_METADATA_NEGATIVE_CACHE_TTL_MS);
2069
+ }
2070
+ return "";
2071
+ }
2072
+ agentMetadataNegativeCache.delete(name);
1972
2073
  const ev = latestAgentDoneEvent(meta);
1973
2074
  const bits = [
1974
2075
  "agent",
@@ -2264,21 +2365,46 @@ function resolveAgentBin(kind) {
2264
2365
  // 明示指定 env は実在を検証する。存在しないパスを黙って返すと、session を作って
2265
2366
  // `'/typo' ...` を送信し bash が command not found を出すだけで openAgent は「起動した」と
2266
2367
  // 偽成功を返す(既定パス/PATH 経路は検証するのに env だけ無検証だった非対称の解消・A3)。
2267
- if (fs.existsSync(fromEnv))
2368
+ if (isUsableExecutableFile(fromEnv))
2268
2369
  return fromEnv;
2269
2370
  throw new AitermError(`${envVar} に指定された ${name} が存在しません: ${fromEnv}`, 2);
2270
2371
  }
2271
2372
  const cand = path.join(home, ...rel);
2272
- if (fs.existsSync(cand))
2373
+ if (isUsableExecutableFile(cand))
2273
2374
  return cand;
2274
2375
  const w = spawnSync(isWin ? "where" : "which", [name], {
2275
2376
  encoding: "utf8",
2276
2377
  timeout: 5000,
2277
2378
  });
2278
- if (w.status === 0 && (w.stdout ?? "").trim())
2279
- return w.stdout.trim().split(/\r?\n/)[0];
2379
+ if (w.status === 0 && (w.stdout ?? "").trim()) {
2380
+ const resolved = w.stdout.trim().split(/\r?\n/)[0];
2381
+ if (isUsableExecutableFile(resolved))
2382
+ return resolved;
2383
+ }
2280
2384
  return null;
2281
2385
  }
2386
+ function isUsableExecutableFile(candidate) {
2387
+ try {
2388
+ if (!fs.statSync(candidate).isFile())
2389
+ return false;
2390
+ if (isWin)
2391
+ return /\.(?:exe|cmd|bat|com)$/i.test(candidate);
2392
+ fs.accessSync(candidate, fs.constants.X_OK);
2393
+ return true;
2394
+ }
2395
+ catch {
2396
+ return false;
2397
+ }
2398
+ }
2399
+ /** vendor CLI の存在だけを安全に要約する。認証状態・実行出力・解決先 path は返さない。 */
2400
+ export function vendorLauncherDiagnostic(kind) {
2401
+ try {
2402
+ return resolveAgentBin(kind) ? "ready" : "not_applicable";
2403
+ }
2404
+ catch {
2405
+ return "unverified";
2406
+ }
2407
+ }
2282
2408
  // 単一引用符で安全に包む(' は '\'' で脱出)。send は raw:true で送るため自前で quote する。
2283
2409
  function shq(s) {
2284
2410
  return `'${s.replace(/'/g, "'\\''")}'`;
@@ -2403,10 +2529,16 @@ export function openAgent(kind, opts = {}) {
2403
2529
  "(モデルカタログ supports_reasoning_effort=false)", 2);
2404
2530
  }
2405
2531
  const agentDone = !!opts.agent_done;
2406
- const bin = resolveAgentBin(kind);
2532
+ let bin;
2533
+ try {
2534
+ bin = resolveAgentBin(kind);
2535
+ }
2536
+ catch (error) {
2537
+ ownTelemetryFailure("AITERM.VENDOR_LAUNCHER_FAILED", error, 2);
2538
+ }
2407
2539
  if (!bin) {
2408
2540
  const where = kind === "codex" ? "~/.local/bin/codex" : "~/.grok/bin/grok";
2409
- throw new AitermError(`${label} の CLI が見つかりません(${where} か PATH が必要)`, 2);
2541
+ ownTelemetryFailure("AITERM.VENDOR_LAUNCHER_FAILED", new AitermError(`${label} の CLI が見つかりません(${where} か PATH が必要)`, 2), 2);
2410
2542
  }
2411
2543
  // cwd 検証(session を作る前に。cd 失敗はシェル内で静かに死に「起動した」と偽成功を返すため)。
2412
2544
  let cwd = null;
@@ -2445,6 +2577,8 @@ export function openAgent(kind, opts = {}) {
2445
2577
  ? createCodexAgentMetadata(sid, cwd, opts.prompt ? "pending" : "none", { model, effort })
2446
2578
  : createGrokAgentMetadata(kind, sid, cwd, opts.prompt ? "pending" : "none")
2447
2579
  : null;
2580
+ if (meta)
2581
+ agentMetadataNegativeCache.delete(sid);
2448
2582
  launchNote = buildAgentLaunchNote(kind, model, effort, meta);
2449
2583
  const cmd = buildAgentCmd(kind, binForCmd, model, effort, opts.prompt ?? null, meta);
2450
2584
  const envPrefix = agentEnvPrefix(meta, sid);
@@ -2455,14 +2589,15 @@ export function openAgent(kind, opts = {}) {
2455
2589
  send(sid, full, { enter: true, mark: false, force: true, rtk: false, raw: true });
2456
2590
  }
2457
2591
  catch (e) {
2592
+ const failure = telemetryOwnedFailure("AITERM.VENDOR_LAUNCHER_FAILED", e);
2458
2593
  // 起動コマンドを投入できなかった session は空のまま残る=残骸を作らない。片付けてから元エラーを伝える。
2459
2594
  try {
2460
- closeSession(sid);
2595
+ closeSessionInternal(sid, false);
2461
2596
  }
2462
2597
  catch {
2463
2598
  /* 片付け失敗より元エラーの伝達を優先 */
2464
2599
  }
2465
- throw e;
2600
+ throw failure;
2466
2601
  }
2467
2602
  return [
2468
2603
  sid,
@@ -75,7 +75,11 @@ function appendEvent(file, event) {
75
75
  if (!st.isFile() || st.uid !== uid() || st.nlink !== 1 || (st.mode & 0o077) !== 0) {
76
76
  fail(`event file が安全ではありません: ${file}`);
77
77
  }
78
- fs.writeSync(fd, line, undefined, "utf8");
78
+ const written = fs.writeSync(fd, line, undefined, "utf8");
79
+ if (written < Buffer.byteLength(line, "utf8")) {
80
+ fs.ftruncateSync(fd, st.size);
81
+ fail(`event file への書込みが途中で終了しました: ${file}`);
82
+ }
79
83
  }
80
84
  finally {
81
85
  fs.closeSync(fd);
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
12
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
13
  import { z } from "zod";
14
14
  import * as core from "./core.js";
15
+ import { runtimeErrorStoreDiagnostic } from "./runtime-error-store.js";
15
16
  import { createRequire } from "node:module";
16
17
  // package.json の version を実行時に読み、MCP initialize で配るサーバ版と一致させる。
17
18
  // createRequire を使うのは、import 属性 `with { type: "json" }` が Node 18.20+ 限定で
@@ -26,6 +27,41 @@ function fail(e) {
26
27
  const msg = e instanceof Error ? e.message : String(e);
27
28
  return { content: [{ type: "text", text: "aiterm: " + msg }], isError: true };
28
29
  }
30
+ /**
31
+ * Factory 向けの read-only 診断。JSON の field は意図的に allowlist 制で、絶対 path、環境変数、
32
+ * token、コマンド本文、PTY 出力、raw log、認証状態を公開しない。
33
+ */
34
+ async function factoryDiagnostics() {
35
+ const ptyList = core.readOnlyPtyListDiagnostic();
36
+ const codex = core.vendorLauncherDiagnostic("codex");
37
+ const grok = core.vendorLauncherDiagnostic("grok");
38
+ const runtimeErrors = await runtimeErrorStoreDiagnostic();
39
+ const overall = ptyList.status === "unverified" || runtimeErrors.status === "unverified" ? "unverified" : "ready";
40
+ return JSON.stringify({
41
+ diagnostic_schema: "aiterm-mcp.factory-diagnostics.v1",
42
+ version: pkg.version,
43
+ overall,
44
+ mcp: { transport: "stdio", initialize: "ready", tool_call: "ready" },
45
+ pty_list: { access: "read_only", ...ptyList },
46
+ runtime_error_store: runtimeErrors,
47
+ vendor_dependencies: {
48
+ codex: {
49
+ status: codex,
50
+ optional: true,
51
+ required_for: ["codex_agent"],
52
+ },
53
+ grok: {
54
+ status: grok,
55
+ optional: true,
56
+ required_for: ["grok_agent", "composer_agent"],
57
+ },
58
+ },
59
+ });
60
+ }
61
+ server.registerTool("diagnostics", {
62
+ description: "Factory 向け read-only 診断。安全な状態語彙だけを機械可読 JSON で返す(PTY 内容・認証情報・path・環境値は返さない)。",
63
+ inputSchema: {},
64
+ }, async () => ok(await factoryDiagnostics()));
29
65
  server.registerTool("pty_open", {
30
66
  description: "ローカル永続端末(tmux セッション)を1個開き、session_id を返す。tmux サーバ常駐ゆえ本サーバや " +
31
67
  "クライアントが再起動してもセッションは生存する。リモート操作は専用ツールにせず、開いた端末の中で " +