aiterm-mcp 0.12.1 → 0.12.3

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/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";
@@ -53,6 +54,7 @@ const AGENT_METADATA_NEGATIVE_CACHE_TTL_MS = 2_000;
53
54
  const AGENT_TUI_READY_TIMEOUT_MS = 30_000;
54
55
  const AGENT_TUI_READY_POLL_MS = 500;
55
56
  const AGENT_TUI_READY_LINES = 45;
57
+ const GROK_AUTH_MAX_BYTES = 64 * 1024;
56
58
  // 出力削減(RTK の CAP 思想を移植)
57
59
  const MAX_LINES_BEFORE_ELIDE = 60;
58
60
  const HEAD_LINES = 30;
@@ -62,6 +64,12 @@ const LINE_HEAD_CHARS = 1200;
62
64
  const LINE_TAIL_CHARS = 600;
63
65
  const DEDUP_MIN_RUN = 3; // 同一行がこれ以上連続したら 1 行+件数に畳む
64
66
  const MAX_FULL_BYTES = 8 * 1024 * 1024; // full/range 読取で一度にメモリへ載せる上限(B7)
67
+ const MAX_SEND_BYTES = 64 * 1024;
68
+ // macOSのPTY入力queueは、tmuxが長文を1回で流すと後半を落とすことがある。
69
+ // UTF-8境界を守って小さいtmux client roundtripに分け、各回にserver event loopがPTYへdrainできる境界を作る。
70
+ const PTY_PASTE_CHUNK_BYTES = process.platform === "darwin" ? 256 : MAX_SEND_BYTES;
71
+ const SESSION_SEND_LOCK_WAIT_MS = 10_000;
72
+ const SESSION_SEND_LOCK_POLL_MS = 25;
65
73
  // 安全: send 前に弾く破壊的コマンド(外部システム境界の防御)
66
74
  const DESTRUCTIVE = [
67
75
  // rm -rf の危険な対象形(best-effort・force で越えられる)。先頭の任意クオート ['"]? で
@@ -98,6 +106,33 @@ export class AitermError extends Error {
98
106
  this.code = code;
99
107
  }
100
108
  }
109
+ class TelemetryOwnedError extends AitermError {
110
+ telemetryCode;
111
+ constructor(message, code, telemetryCode, cause) {
112
+ super(message, code);
113
+ this.name = "TelemetryOwnedError";
114
+ this.telemetryCode = telemetryCode;
115
+ if (cause !== undefined)
116
+ this.cause = cause;
117
+ }
118
+ }
119
+ function telemetryOwnedFailure(telemetryCode, error, fallbackCode = 1) {
120
+ if (error instanceof TelemetryOwnedError)
121
+ return error;
122
+ recordRuntimeError(telemetryCode);
123
+ const message = error instanceof Error ? error.message : String(error);
124
+ const code = error instanceof AitermError ? error.code : fallbackCode;
125
+ return new TelemetryOwnedError(message, code, telemetryCode, error);
126
+ }
127
+ function ownTelemetryFailure(telemetryCode, error, fallbackCode = 1) {
128
+ throw telemetryOwnedFailure(telemetryCode, error, fallbackCode);
129
+ }
130
+ function ptyDependencyError(message, observe = true) {
131
+ const error = new AitermError(message, 2);
132
+ if (observe)
133
+ ownTelemetryFailure("AITERM.PTY_DEPENDENCY_UNAVAILABLE", error, 2);
134
+ throw error;
135
+ }
101
136
  // Windows のドライブパス (C:\a\b) を WSL から見える /mnt/c/a/b へ変換する。
102
137
  // 一時領域は常にドライブ直下なので UNC は想定外=弾く(黙って壊れた //server パスを作らない)。
103
138
  export function toWslPath(p) {
@@ -109,22 +144,22 @@ export function toWslPath(p) {
109
144
  // Windows で最初の tmux 呼び出し前に一度だけ WSL+tmux の可用性を確かめ、失敗は原因別に投げる。
110
145
  // -e(ログインシェル非経由)+短い timeout で、初回セットアップ未完了の wsl によるハングも防ぐ。
111
146
  let winBridgeOk = false;
112
- function ensureWinBridge() {
147
+ function ensureWinBridge(observe = true) {
113
148
  if (winBridgeOk)
114
149
  return;
115
150
  const r = spawnSync("wsl.exe", ["-e", "tmux", "-V"], { encoding: "utf8", timeout: 10000 });
116
151
  if (r.error) {
117
152
  const code = r.error.code;
118
153
  if (code === "ETIMEDOUT")
119
- throw new AitermError("WSL が応答しません(初回セットアップ未完了の可能性)。一度 `wsl` を起動してから再実行してください。", 2);
154
+ ptyDependencyError("WSL が応答しません(初回セットアップ未完了の可能性)。一度 `wsl` を起動してから再実行してください。", observe);
120
155
  if (code === "ENOENT")
121
- throw new AitermError("wsl.exe が見つかりません。Windows では WSL 上の tmux 経由で動作します。WSL と tmux を導入してください。", 2);
122
- throw new AitermError(`wsl.exe を起動できませんでした(${code ?? "unknown"})。`, 2);
156
+ ptyDependencyError("wsl.exe が見つかりません。Windows では WSL 上の tmux 経由で動作します。WSL と tmux を導入してください。", observe);
157
+ ptyDependencyError(`wsl.exe を起動できませんでした(${code ?? "unknown"})。`, observe);
123
158
  }
124
159
  // wsl.exe は System32 にあるので「起動」は成功するが、ディストリ未導入や distro 内に tmux が無いと
125
160
  // 非ゼロで終わる。両方を区別せず(wsl の出力は UTF-16 で文字化けし得るため)正直に表す。
126
161
  if (r.status !== 0)
127
- throw new AitermError("WSL 経由で tmux を起動できませんでした。WSL のディストリ未導入、または distro 内に tmux が無い可能性があります。`wsl tmux -V` が通るか確認してください(tmux 導入例: sudo apt install tmux)。", 2);
162
+ ptyDependencyError("WSL 経由で tmux を起動できませんでした。WSL のディストリ未導入、または distro 内に tmux が無い可能性があります。`wsl tmux -V` が通るか確認してください(tmux 導入例: sudo apt install tmux)。", observe);
128
163
  winBridgeOk = true;
129
164
  }
130
165
  // tmux が見つからないときの説明。macOS は tmux を同梱せず、Homebrew の bin は GUI 起動時の PATH に
@@ -143,7 +178,7 @@ function tmuxMissingMessage() {
143
178
  // 解決順: AITERM_TMUX(明示指定)→ PATH 上の tmux → Homebrew 既定パス。一度だけ実行しキャッシュする。
144
179
  // 見つからなければ tmuxMissingMessage で投げる(黙ってフォールバックせず、原因が見えるようにする)。
145
180
  let tmuxBin = null;
146
- function resolveTmux() {
181
+ function resolveTmux(observe = true) {
147
182
  if (tmuxBin)
148
183
  return tmuxBin;
149
184
  const override = process.env.AITERM_TMUX;
@@ -151,7 +186,7 @@ function resolveTmux() {
151
186
  const r = spawnSync(override, ["-V"], { encoding: "utf8", timeout: 5000 });
152
187
  if (!r.error && r.status === 0)
153
188
  return (tmuxBin = override);
154
- throw new AitermError(`AITERM_TMUX に指定された tmux を起動できません: ${override}(\`${override} -V\` が通りません)`, 2);
189
+ ptyDependencyError(`AITERM_TMUX に指定された tmux を起動できません: ${override}(\`${override} -V\` が通りません)`, observe);
155
190
  }
156
191
  // CLI/開発時は PATH 上の tmux をそのまま使う(最優先)。
157
192
  const onPath = spawnSync("tmux", ["-V"], { encoding: "utf8", timeout: 5000 });
@@ -169,20 +204,21 @@ function resolveTmux() {
169
204
  /* 次の候補へ */
170
205
  }
171
206
  }
172
- throw new AitermError(tmuxMissingMessage(), 2);
207
+ ptyDependencyError(tmuxMissingMessage(), observe);
173
208
  }
174
- function tmux(...args) {
209
+ function tmuxCommandWithInput(observe, input, ...args) {
175
210
  // maxBuffer は既定 1MiB。capture-pane(大きなスクロールバック)や多セッションの list-sessions で
176
211
  // 頭打ちになり stdout が切れる/空になる。Python の subprocess.run は無制限だったので 64MiB へ広げる。
177
212
  // Windows は同じ tmux を WSL 経由(-e でログインシェル非経由=$ 展開やクオート崩れを防ぐ)で叩く。
178
213
  let r;
214
+ const spawnOpts = { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, input };
179
215
  if (isWin) {
180
- ensureWinBridge();
181
- r = spawnSync("wsl.exe", ["-e", "tmux", "-S", SOCK, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
216
+ ensureWinBridge(observe);
217
+ r = spawnSync("wsl.exe", ["-e", "tmux", "-S", SOCK, ...args], spawnOpts);
182
218
  }
183
219
  else {
184
220
  // resolveTmux() は tmux を解決できなければ明確な AitermError を投げる(POSIX 版の事前確認)。
185
- r = spawnSync(resolveTmux(), ["-S", SOCK, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
221
+ r = spawnSync(resolveTmux(observe), ["-S", SOCK, ...args], spawnOpts);
186
222
  }
187
223
  // ENOBUFS(出力が 64MiB 超)を「code=1 の失敗」へ握り潰すと部分/空 stdout を正常扱いしてしまう。区別して投げる。
188
224
  // EXPECTED-FAILURE: 外部システム境界(tmux 出力過大)
@@ -193,10 +229,22 @@ function tmux(...args) {
193
229
  // 通常は resolveTmux() が事前に弾くため発火しないが、mid-run 消滅に対する正直な防御。
194
230
  if (!isWin && r.error && r.error.code === "ENOENT") {
195
231
  tmuxBin = null; // 次回 resolveTmux で再解決を許す
196
- throw new AitermError(tmuxMissingMessage(), 2);
232
+ ptyDependencyError(tmuxMissingMessage(), observe);
197
233
  }
198
234
  return { code: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
199
235
  }
236
+ function tmuxCommand(observe, ...args) {
237
+ return tmuxCommandWithInput(observe, undefined, ...args);
238
+ }
239
+ function tmux(...args) {
240
+ return tmuxCommand(true, ...args);
241
+ }
242
+ function tmuxWithInput(input, ...args) {
243
+ return tmuxCommandWithInput(true, input, ...args);
244
+ }
245
+ function tmuxCleanup(...args) {
246
+ return tmuxCommand(false, ...args);
247
+ }
200
248
  function sessionExists(name) {
201
249
  return tmux("has-session", "-t", name).code === 0;
202
250
  }
@@ -204,6 +252,42 @@ function paneCurrentCommand(name) {
204
252
  const r = tmux("display-message", "-p", "-t", name, "#{pane_current_command}");
205
253
  return r.code === 0 ? r.stdout.trim() : "";
206
254
  }
255
+ function pasteBufferSupportsNoSanitizeFlag() {
256
+ const listed = tmux("list-commands");
257
+ if (listed.code !== 0) {
258
+ throw new AitermError(`tmux paste-buffer 能力の確認に失敗しました: ${listed.stderr.trim() || `code=${listed.code}`}`, 2);
259
+ }
260
+ const usage = listed.stdout.split("\n").find((line) => line.startsWith("paste-buffer "));
261
+ if (!usage)
262
+ throw new AitermError("tmux list-commands にpaste-bufferがありません", 2);
263
+ // tmux 3.4は制御文字を無変換でpasteし、-S自体が無い。3.7は既定でvis(3)変換し、
264
+ // -Sが無変換を選ぶ。version文字比較で推測せず、実際のcommand usageにflagがあるかを見る。
265
+ return /\[-[^\]]*S[^\]]*\]/.test(usage);
266
+ }
267
+ function splitPtyText(text) {
268
+ const chunks = [];
269
+ let chunk = "";
270
+ let chunkBytes = 0;
271
+ for (const codePoint of text) {
272
+ const bytes = Buffer.byteLength(codePoint, "utf8");
273
+ if (chunk && chunkBytes + bytes > PTY_PASTE_CHUNK_BYTES) {
274
+ chunks.push(chunk);
275
+ chunk = "";
276
+ chunkBytes = 0;
277
+ }
278
+ chunk += codePoint;
279
+ chunkBytes += bytes;
280
+ }
281
+ if (chunk)
282
+ chunks.push(chunk);
283
+ return chunks;
284
+ }
285
+ function assertSendTextSize(text, context = "送信文字列") {
286
+ const bytes = Buffer.byteLength(text, "utf8");
287
+ if (bytes > MAX_SEND_BYTES) {
288
+ throw new AitermError(`${context}が${MAX_SEND_BYTES} bytesを超えています(${bytes} bytes)`, 2);
289
+ }
290
+ }
207
291
  // session 名はファイルパス(logpath 等)と pipe-pane の /bin/sh 文字列へ流れる。英数 _ - のみ・64字に
208
292
  // 限定し、パストラバーサル(../)とシェルインジェクション(' でのクオート破り・$・; 等)を全入口で断つ。
209
293
  function assertSessionName(name) {
@@ -359,6 +443,10 @@ function lastcmdpath(name) {
359
443
  function markpath(name) {
360
444
  return path.join(SOCKDIR, name + ".mark");
361
445
  }
446
+ function sendLockPath(name) {
447
+ assertSessionName(name);
448
+ return path.join(SOCKDIR, name + ".send.lock");
449
+ }
362
450
  function readOffset(name) {
363
451
  try {
364
452
  const n = parseInt(fs.readFileSync(offsetpath(name), "utf8").trim(), 10);
@@ -685,7 +773,12 @@ function assertNotDestructive(text, code, context = "") {
685
773
  }
686
774
  // ---------------------------------------------------------------- 操作(return で返す / 失敗は AitermError)
687
775
  export function openSession(name, shell = "bash") {
688
- fs.mkdirSync(SOCKDIR, { recursive: true });
776
+ try {
777
+ fs.mkdirSync(SOCKDIR, { recursive: true });
778
+ }
779
+ catch (error) {
780
+ ownTelemetryFailure("AITERM.PERSISTENCE_WRITE_FAILED", error);
781
+ }
689
782
  // macOS の /bin/bash は 3.2 で、起動時に zsh 移行バナーを出して最初の read を汚す。darwin かつ bash の
690
783
  // ときだけ -e で環境変数を渡して抑止する。-e は tmux>=3.2 が必要だが macOS の Homebrew tmux は常に該当。
691
784
  // (古い tmux<3.2 が残る Linux/WSL で -e を渡すと new-session が落ちるため、darwin 限定にする。)
@@ -720,7 +813,12 @@ export function openSession(name, shell = "bash") {
720
813
  // 新規セッションの .log は必ず truncate する。"a"(追記)だと外部 kill / killAll / クラッシュで
721
814
  // 同名 session だけ消えて .log が残った場合、offset=0 と相まって旧出力を新規として返す(B5)。
722
815
  // break は new-session 成功後にのみ到達=作りたての空 session ゆえ切り詰めは安全。lastcmd/mark 残骸も掃除。
723
- fs.writeFileSync(logpath(nm), "");
816
+ try {
817
+ fs.writeFileSync(logpath(nm), "");
818
+ }
819
+ catch (error) {
820
+ ownTelemetryFailure("AITERM.PERSISTENCE_WRITE_FAILED", error);
821
+ }
724
822
  for (const p of [lastcmdpath(nm), markpath(nm)]) {
725
823
  try {
726
824
  fs.unlinkSync(p);
@@ -738,16 +836,24 @@ export function openSession(name, shell = "bash") {
738
836
  const pr = tmux("pipe-pane", "-t", nm, "-o", `cat >> ${quoted}`);
739
837
  if (pr.code !== 0) {
740
838
  // 配管に失敗した session は pty_read が永遠に空を返す=成功を装わない。作った session を片付けて明示エラー。
741
- tmux("kill-session", "-t", nm);
839
+ try {
840
+ tmuxCleanup("kill-session", "-t", nm);
841
+ }
842
+ catch { /* cleanup failure is not a second observation */ }
742
843
  try {
743
844
  fs.unlinkSync(logpath(nm)); // B14: 直前に作った空 .log も残さない
744
845
  }
745
846
  catch {
746
847
  /* noop */
747
848
  }
748
- throw new AitermError("tmux pipe-pane 失敗(出力ログを配管できないため session を破棄): " + pr.stderr.trim(), 2);
849
+ ownTelemetryFailure("AITERM.PERSISTENCE_WRITE_FAILED", new AitermError("tmux pipe-pane 失敗(出力ログを配管できないため session を破棄): " + pr.stderr.trim(), 2), 2);
850
+ }
851
+ try {
852
+ writeOffset(nm, 0);
853
+ }
854
+ catch (error) {
855
+ ownTelemetryFailure("AITERM.PERSISTENCE_WRITE_FAILED", error);
749
856
  }
750
- writeOffset(nm, 0);
751
857
  return [nm, attachHint(nm)];
752
858
  }
753
859
  export function send(name, text, o = {}) {
@@ -770,35 +876,78 @@ export function send(name, text, o = {}) {
770
876
  `正しく展開されません。until で完了パターンを指定してください。`, 2);
771
877
  }
772
878
  }
773
- writeLastcmd(name, text); // read rtk の reducer 分類用(書換/mark 前の素のコマンド)
774
- if (o.rtk)
775
- text = rtkRewrite(text);
776
- if (o.rtk && !o.force)
777
- assertNotDestructive(text, 3, "rtk 変換後: ");
778
- if (o.mark) {
779
- // 実出力は rc=<数字>、この行のエコーは rc=%d(リテラル)。MARK_DONE_RE は数字アンカーで後者に免疫。
780
- text = text + `; printf '\\n<<<AITERM_DONE rc=%d>>>\\n' "$?"`;
781
- try {
782
- fs.writeFileSync(markpath(name), "1"); // waitCompletion sentinel 完了検出を有効化させる
879
+ assertSendTextSize(text);
880
+ const releaseSendLock = acquireSessionSendFileLock(name);
881
+ try {
882
+ writeLastcmd(name, text); // read rtk reducer 分類用(書換/mark 前の素のコマンド)
883
+ if (o.rtk)
884
+ text = rtkRewrite(text);
885
+ if (o.rtk && !o.force)
886
+ assertNotDestructive(text, 3, "rtk 変換後: ");
887
+ if (o.mark)
888
+ text = text + `; printf '\\n<<<AITERM_DONE rc=%d>>>\\n' "$?"`;
889
+ assertSendTextSize(text, o.rtk || o.mark ? "変換後の送信文字列" : "送信文字列");
890
+ if (o.mark) {
891
+ try {
892
+ fs.writeFileSync(markpath(name), "1"); // waitCompletion に sentinel 完了検出を有効化させる
893
+ }
894
+ catch {
895
+ /* noop(フラグ書けなくても until/quiescence 経路は生きる) */
896
+ }
783
897
  }
784
- catch {
785
- /* noop(フラグ書けなくても until/quiescence 経路は生きる) */
898
+ else {
899
+ // mark 送信は、未消化の古い mark 完了待ちを無効化する(前コマンドの sentinel を待ち続けない)。
900
+ try {
901
+ fs.unlinkSync(markpath(name));
902
+ }
903
+ catch {
904
+ /* noop */
905
+ }
786
906
  }
787
- }
788
- else {
789
- // mark 送信は、未消化の古い mark 完了待ちを無効化する(前コマンドの sentinel を待ち続けない)。
790
- try {
791
- fs.unlinkSync(markpath(name));
907
+ // `send-keys -l`と単発`paste-buffer`は、長文をPTY入力queueへ一度に流し、macOS CIで
908
+ // 途中以降が欠落しても tmux 自体は code=0 を返した。macOSだけUTF-8を壊さない256byte以下に分け、
909
+ // Linux/WSLは一括のままとする。全chunk+Enterはsession単位のcross-process lock内で直列化する。
910
+ const pasteSupportsNoSanitize = pasteBufferSupportsNoSanitizeFlag();
911
+ const chunks = splitPtyText(text);
912
+ const bufferBase = `aiterm-${process.pid}-${randomBytes(8).toString("hex")}`;
913
+ for (let i = 0; i < chunks.length; i += 1) {
914
+ const bufferName = `${bufferBase}-${i}`;
915
+ const partial = i > 0
916
+ ? " 先行chunkはPTYに入力済みでEnterは未送信です。再送前に入力を確認・消去してください。"
917
+ : "";
918
+ const loaded = tmuxWithInput(chunks[i], "load-buffer", "-b", bufferName, "-");
919
+ if (loaded.code !== 0) {
920
+ tmuxCleanup("delete-buffer", "-b", bufferName);
921
+ throw new AitermError(`tmux bufferへの送信準備に失敗しました` +
922
+ `(chunk ${i + 1}/${chunks.length}): ${loaded.stderr.trim() || `code=${loaded.code}`}.${partial}`, 2);
923
+ }
924
+ // -r: LF→CR 置換を無効化。-Sは対応新版だけでvis(3)制御文字変換を無効化する。
925
+ // send 自身の raw/sanitize 契約だけを真実とし、tmux 側で黙って再変換させない。
926
+ const pasteArgs = ["paste-buffer", "-d", "-r"];
927
+ if (pasteSupportsNoSanitize)
928
+ pasteArgs.push("-S");
929
+ pasteArgs.push("-b", bufferName, "-t", name);
930
+ const pasted = tmux(...pasteArgs);
931
+ if (pasted.code !== 0) {
932
+ // paste 失敗時は -d で消えないbufferを明示的に掃除する。元エラーを優先する。
933
+ tmuxCleanup("delete-buffer", "-b", bufferName);
934
+ throw new AitermError(`tmux bufferのPTY送信に失敗しました` +
935
+ `(chunk ${i + 1}/${chunks.length}): ${pasted.stderr.trim() || `code=${pasted.code}`}.${partial}`, 2);
936
+ }
792
937
  }
793
- catch {
794
- /* noop */
938
+ if (enter) {
939
+ const entered = tmux("send-keys", "-t", name, "Enter");
940
+ if (entered.code !== 0) {
941
+ throw new AitermError(`文字列はPTYに入力済みですがtmuxへEnterを送れませんでした: ` +
942
+ `${entered.stderr.trim() || `code=${entered.code}`}。再送前に入力を確認・消去してください`, 2);
943
+ }
795
944
  }
945
+ // コードポイント数で数える(JS の .length は UTF-16 単位で絵文字等がズレる。Python は len()=コードポイント)。
946
+ return `sent ${[...text].length} chars to ${name}` + (enter ? " (+Enter)" : "");
947
+ }
948
+ finally {
949
+ releaseSendLock();
796
950
  }
797
- tmux("send-keys", "-t", name, "-l", "--", text);
798
- if (enter)
799
- tmux("send-keys", "-t", name, "Enter");
800
- // コードポイント数で数える(JS の .length は UTF-16 単位で絵文字等がズレる。Python は len()=コードポイント)。
801
- return `sent ${[...text].length} chars to ${name}` + (enter ? " (+Enter)" : "");
802
951
  }
803
952
  export function sendKey(name, key) {
804
953
  assertSessionName(name);
@@ -979,7 +1128,29 @@ export function listSessions() {
979
1128
  }
980
1129
  return "(セッション無し)";
981
1130
  }
982
- export function closeSession(name) {
1131
+ /**
1132
+ * `pty_list` 相当を read-only に照会し、内容を返さず session 件数だけを返す。
1133
+ * session 名・前面コマンド・PTY 出力を診断応答へ持ち出さないため、factory が安全に readiness を
1134
+ * 見られる。socket 不在は「セッション未設定」であって障害ではない。
1135
+ */
1136
+ export function readOnlyPtyListDiagnostic() {
1137
+ try {
1138
+ const r = tmux("list-sessions", "-F", "#{session_name}");
1139
+ if (r.code === 0) {
1140
+ return { status: "ready", session_count: r.stdout.split(/\r?\n/).filter(Boolean).length };
1141
+ }
1142
+ // tmux は専用 socket に server がいない通常状態を exit 1 で返す。その他の失敗を「空」と
1143
+ // 偽装しないため、メッセージを公開せず unverified に留める。
1144
+ if (/no server running|failed to connect/i.test(r.stderr)) {
1145
+ return { status: "not_applicable", session_count: 0 };
1146
+ }
1147
+ }
1148
+ catch {
1149
+ // tmux 未導入・WSL bridge 不全等。絶対 path や生 stderr を診断 JSON に出さない。
1150
+ }
1151
+ return { status: "unverified", session_count: null };
1152
+ }
1153
+ function closeSessionInternal(name, observeDependency = true) {
983
1154
  assertSessionName(name);
984
1155
  if (agentWaitLocks.has(name)) {
985
1156
  throw new AitermError(`agent session '${name}' は agent_done 待機中のため close できません`, 2);
@@ -992,8 +1163,15 @@ export function closeSession(name) {
992
1163
  throw new AitermError(`agent session '${name}' は別プロセス${d.pid != null ? `(pid ${d.pid})` : ""}の agent_done 待機中のため close できません`, 2);
993
1164
  }
994
1165
  }
995
- tmux("kill-session", "-t", name);
996
- for (const p of [logpath(name), offsetpath(name), lastcmdpath(name), markpath(name)]) {
1166
+ {
1167
+ const sending = liveSendLocks(name);
1168
+ if (sending.length > 0) {
1169
+ const d = sending[0];
1170
+ throw new AitermError(`session '${name}' は別プロセス${d.pid != null ? `(pid ${d.pid})` : ""}の送信中のため close できません`, 2);
1171
+ }
1172
+ }
1173
+ (observeDependency ? tmux : tmuxCleanup)("kill-session", "-t", name);
1174
+ for (const p of [logpath(name), offsetpath(name), lastcmdpath(name), markpath(name), sendLockPath(name)]) {
997
1175
  try {
998
1176
  fs.unlinkSync(p);
999
1177
  }
@@ -1004,6 +1182,9 @@ export function closeSession(name) {
1004
1182
  cleanupAgentState(name);
1005
1183
  return `closed ${name}`;
1006
1184
  }
1185
+ export function closeSession(name) {
1186
+ return closeSessionInternal(name, true);
1187
+ }
1007
1188
  export function killAll() {
1008
1189
  if (agentWaitLocks.size > 0) {
1009
1190
  throw new AitermError(`agent_done 待機中の session があるため killAll できません: ${Array.from(agentWaitLocks).join(",")}`, 2);
@@ -1016,11 +1197,18 @@ export function killAll() {
1016
1197
  throw new AitermError(`agent_done 待機中の session があるため killAll できません: ${list}`, 2);
1017
1198
  }
1018
1199
  }
1200
+ {
1201
+ const sending = liveSendLocks(null);
1202
+ if (sending.length > 0) {
1203
+ const list = sending.map((d) => `${d.session}${d.pid != null ? `(pid ${d.pid})` : ""}`).join(",");
1204
+ throw new AitermError(`送信中の session があるため killAll できません: ${list}`, 2);
1205
+ }
1206
+ }
1019
1207
  tmux("kill-server");
1020
- // B9: SOCKDIR 内の .log/.offset/.lastcmd/.mark 残骸も掃除する(残すと B5 の stale-log 復活の温床)。
1208
+ // B9: SOCKDIR 内の .log/.offset/.lastcmd/.mark/.send.lock 残骸も掃除する。
1021
1209
  try {
1022
1210
  for (const f of fs.readdirSync(SOCKDIR)) {
1023
- if (/\.(log|offset|lastcmd|mark)$/.test(f)) {
1211
+ if (/\.(log|offset|lastcmd|mark)$/.test(f) || f.endsWith(".send.lock")) {
1024
1212
  try {
1025
1213
  fs.unlinkSync(path.join(SOCKDIR, f));
1026
1214
  }
@@ -1303,70 +1491,61 @@ function createManagedCodexHome(name, launchId, overrides = {}) {
1303
1491
  return managedHome;
1304
1492
  }
1305
1493
  function realGrokHome() {
1306
- return process.env.GROK_HOME || path.join(process.env.HOME ?? os.homedir(), ".grok");
1307
- }
1308
- function validateGrokAuthLock(lockPath) {
1309
- let st;
1310
- try {
1311
- st = fs.lstatSync(lockPath);
1312
- }
1313
- catch (e) {
1314
- throw e;
1315
- }
1316
- if (st.isSymbolicLink())
1317
- throw new AitermError(`Grok auth lock が symlink です: ${lockPath}`, 2);
1318
- if (!st.isFile())
1319
- throw new AitermError(`Grok auth lock が通常ファイルではありません: ${lockPath}`, 2);
1320
- if (st.nlink !== 1)
1321
- throw new AitermError(`Grok auth lock が hard link です: ${lockPath}`, 2);
1322
- try {
1323
- fs.chmodSync(lockPath, 0o600);
1324
- }
1325
- catch {
1326
- /* lock file permission tightening is best-effort */
1327
- }
1328
- }
1329
- function ensureGrokAuthLock(srcHome) {
1330
- const lockPath = path.join(srcHome, "auth.json.lock");
1331
- try {
1332
- validateGrokAuthLock(lockPath);
1333
- return lockPath;
1334
- }
1335
- catch (e) {
1336
- if (e.code !== "ENOENT")
1337
- throw e;
1338
- }
1339
- try {
1340
- createEmpty0600NoFollow(lockPath);
1341
- }
1342
- catch (e) {
1343
- if (e.code !== "EEXIST")
1344
- throw e;
1345
- }
1346
- validateGrokAuthLock(lockPath);
1347
- return lockPath;
1348
- }
1349
- function linkGrokOauthFiles(srcHome, grokHome) {
1350
- const srcAuth = path.join(srcHome, "auth.json");
1351
- let authExists = false;
1494
+ return path.resolve(process.env.GROK_HOME || path.join(process.env.HOME ?? os.homedir(), ".grok"));
1495
+ }
1496
+ function resolveAndValidateGrokAuth(srcHome) {
1497
+ const inheritedSet = Object.prototype.hasOwnProperty.call(process.env, "GROK_AUTH_PATH");
1498
+ const inherited = process.env.GROK_AUTH_PATH;
1499
+ if (inheritedSet && (!inherited || !path.isAbsolute(inherited)))
1500
+ throw new AitermError("GROK_AUTH_PATH は空でない絶対パスで指定してください", 2);
1501
+ const authPath = inherited ?? path.join(srcHome, "auth.json");
1502
+ let fd;
1352
1503
  try {
1353
- const authSt = fs.statSync(srcAuth);
1354
- if (!authSt.isFile())
1355
- throw new AitermError(`Grok auth.json が通常ファイルではありません: ${srcAuth}`, 2);
1356
- authExists = true;
1504
+ fd = fs.openSync(authPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
1505
+ const st = fs.fstatSync(fd);
1506
+ if (!st.isFile() || st.nlink !== 1 || st.uid !== currentUid() || (st.mode & 0o077) !== 0 || st.size > GROK_AUTH_MAX_BYTES) {
1507
+ throw new AitermError("Grok 認証正本の安全検証に失敗しました", 2);
1508
+ }
1509
+ const value = JSON.parse(fs.readFileSync(fd, "utf8"));
1510
+ if (!value || typeof value !== "object" || Array.isArray(value))
1511
+ throw new AitermError("Grok 認証正本のJSONが不正です", 2);
1512
+ // auth file 自体は O_NOFOLLOW で開いているが、中間 directory の symlink は辿り得る。
1513
+ // vendor に渡す正本を path swap の入口にしないため、字句正規化した絶対 path と realpath を
1514
+ // 一致させ、canonical な祖先も root まで検証する。same-UID race の排他は vendor lock の責務。
1515
+ const lexicalPath = path.resolve(authPath);
1516
+ const canonicalPath = fs.realpathSync(authPath);
1517
+ if (lexicalPath !== canonicalPath)
1518
+ throw new AitermError("Grok 認証正本の path に symlink を含められません", 2);
1519
+ for (let dir = path.dirname(canonicalPath);; dir = path.dirname(dir)) {
1520
+ const dirSt = fs.lstatSync(dir);
1521
+ // /tmp のような root 所有 + sticky の共有 directory は、本人所有の private な
1522
+ // 直下 directory を他 UID が rename/unlink できないため許可する。sticky 無しの
1523
+ // group/other writable 祖先は path swap が可能なので従来どおり拒否する。
1524
+ const writableByOthers = (dirSt.mode & 0o022) !== 0;
1525
+ const protectedSharedRoot = dirSt.uid === 0 && (dirSt.mode & 0o1000) !== 0;
1526
+ if (!dirSt.isDirectory()
1527
+ || dirSt.isSymbolicLink()
1528
+ || (dirSt.uid !== currentUid() && dirSt.uid !== 0)
1529
+ || (writableByOthers && !protectedSharedRoot)) {
1530
+ throw new AitermError("Grok 認証正本の祖先 directory が安全ではありません", 2);
1531
+ }
1532
+ const parent = path.dirname(dir);
1533
+ if (parent === dir)
1534
+ break;
1535
+ }
1536
+ return canonicalPath;
1357
1537
  }
1358
1538
  catch (e) {
1359
- if (e.code !== "ENOENT")
1539
+ if (e.code === "ENOENT" && !inheritedSet && process.env.XAI_API_KEY)
1540
+ return null;
1541
+ if (e instanceof AitermError)
1360
1542
  throw e;
1543
+ throw new AitermError(e.code === "ENOENT" ? "Grok 認証正本が見つかりません。先に grok login が必要です" : "Grok 認証正本を安全に開けません", 2);
1361
1544
  }
1362
- if (!authExists) {
1363
- if (process.env.XAI_API_KEY)
1364
- return;
1365
- throw new AitermError(`Grok auth.json が見つかりません。先に grok login が必要です: ${srcHome}`, 2);
1545
+ finally {
1546
+ if (fd !== undefined)
1547
+ fs.closeSync(fd);
1366
1548
  }
1367
- const srcLock = ensureGrokAuthLock(srcHome);
1368
- fs.symlinkSync(srcAuth, path.join(grokHome, "auth.json"));
1369
- fs.symlinkSync(srcLock, path.join(grokHome, "auth.json.lock"));
1370
1549
  }
1371
1550
  function writeManagedGrokConfig(grokHome) {
1372
1551
  fs.mkdirSync(path.join(grokHome, "hooks"), { recursive: false, mode: 0o700 });
@@ -1394,17 +1573,7 @@ function writeManagedGrokConfig(grokHome) {
1394
1573
  "",
1395
1574
  ].join("\n"));
1396
1575
  }
1397
- function createManagedGrokHome(name, launchId) {
1398
- const srcHome = realGrokHome();
1399
- let srcSt;
1400
- try {
1401
- srcSt = fs.statSync(srcHome);
1402
- }
1403
- catch {
1404
- throw new AitermError(`Grok home が見つかりません: ${srcHome}`, 2);
1405
- }
1406
- if (!srcSt.isDirectory())
1407
- throw new AitermError(`Grok home が directory ではありません: ${srcHome}`, 2);
1576
+ function createManagedGrokHome(name, launchId, authPath) {
1408
1577
  const grokHome = agentManagedGrokHomePath(name, launchId);
1409
1578
  const fakeHome = agentManagedGrokUserHomePath(name, launchId);
1410
1579
  fs.mkdirSync(grokHome, { recursive: false, mode: 0o700 });
@@ -1412,9 +1581,6 @@ function createManagedGrokHome(name, launchId) {
1412
1581
  fs.chmodSync(grokHome, 0o700);
1413
1582
  fs.chmodSync(fakeHome, 0o700);
1414
1583
  fs.symlinkSync(grokHome, path.join(fakeHome, ".grok"));
1415
- // OAuth refresh token rotation は auth.json だけでなく vendor lock と同じ実体を共有させる。
1416
- // per-launch GROK_HOME 隔離は維持し、通常 Grok home の hook/config/session は読ませない。
1417
- linkGrokOauthFiles(srcHome, grokHome);
1418
1584
  const hookScript = grokHookScriptPath();
1419
1585
  if (!fs.existsSync(hookScript)) {
1420
1586
  throw new AitermError(`Grok Stop hook wrapper が見つかりません。npm run build を実行してください: ${hookScript}`, 2);
@@ -1437,7 +1603,7 @@ function createManagedGrokHome(name, launchId) {
1437
1603
  });
1438
1604
  // Grok 0.2.87 は compat false でも ~/.claude/plugins の hook file を拾う。HOME を一時化して
1439
1605
  // plugin/hook source を完全に 0 にする。実 HOME は必要なら hook/agent 側で参照できるよう env で渡す。
1440
- return { grokHome, home: fakeHome };
1606
+ return { grokHome, home: fakeHome, authPath };
1441
1607
  }
1442
1608
  function writeAgentMetadata(meta) {
1443
1609
  writeJson0600(agentMetadataPath(meta.aiterm_session, meta.launch_id), meta);
@@ -1504,13 +1670,101 @@ function probeWaitLock(p) {
1504
1670
  function unlinkStaleWaitLock(p) {
1505
1671
  try {
1506
1672
  const st = fs.lstatSync(p);
1507
- if (st.isFile() && !st.isSymbolicLink() && st.uid === currentUid())
1673
+ const uid = typeof process.getuid === "function" ? process.getuid() : null;
1674
+ if (st.isFile() && !st.isSymbolicLink() && (uid == null || st.uid === uid))
1508
1675
  fs.unlinkSync(p);
1509
1676
  }
1510
1677
  catch {
1511
1678
  /* noop */
1512
1679
  }
1513
1680
  }
1681
+ function liveSendLocks(name) {
1682
+ let files;
1683
+ try {
1684
+ files = fs.readdirSync(SOCKDIR);
1685
+ }
1686
+ catch {
1687
+ return [];
1688
+ }
1689
+ const out = [];
1690
+ for (const f of files) {
1691
+ if (!f.endsWith(".send.lock"))
1692
+ continue;
1693
+ const session = f.slice(0, -".send.lock".length);
1694
+ if (name != null && session !== name)
1695
+ continue;
1696
+ const p = path.join(SOCKDIR, f);
1697
+ const probe = probeWaitLock(p);
1698
+ if (probe.live)
1699
+ out.push({ session, pid: probe.pid, at: probe.at });
1700
+ // dead send lockはここでunlinkしない。probe後に別processが同pathへ新しいlive lockを作る
1701
+ // ABAが起きると、そのlive lockを消して二重owner化できる。close/killAllがsessionを止めた後に掃除する。
1702
+ }
1703
+ return out;
1704
+ }
1705
+ function acquireSessionSendFileLock(name) {
1706
+ const p = sendLockPath(name);
1707
+ const token = randomBytes(16).toString("hex");
1708
+ const nofollow = fs.constants.O_NOFOLLOW ?? 0;
1709
+ const deadline = Date.now() + SESSION_SEND_LOCK_WAIT_MS;
1710
+ let fd = null;
1711
+ let lastProbe = { pid: null, at: null, live: true };
1712
+ while (fd == null) {
1713
+ try {
1714
+ fd = fs.openSync(p, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | nofollow, 0o600);
1715
+ }
1716
+ catch (e) {
1717
+ if (e.code !== "EEXIST")
1718
+ throw e;
1719
+ lastProbe = probeWaitLock(p);
1720
+ if (!lastProbe.live) {
1721
+ const detail = lastProbe.pid != null ? `pid ${lastProbe.pid}` : "owner不明";
1722
+ throw new AitermError(`session '${name}' に前回送信のlock残骸があります(${detail})。` +
1723
+ `自動回収は並行送信の混線を招くため行いません。pty_closeでsessionを閉じてから再作成するか、` +
1724
+ `不要な全sessionをpty_kill_allで停止して残骸を掃除してください`, 2);
1725
+ }
1726
+ if (Date.now() >= deadline) {
1727
+ const detail = lastProbe.pid != null ? `pid ${lastProbe.pid}` : "owner不明";
1728
+ throw new AitermError(`session '${name}' は別プロセスの送信中です(${detail})。${SESSION_SEND_LOCK_WAIT_MS}ms待ってもlockを取得できませんでした`, 2);
1729
+ }
1730
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, SESSION_SEND_LOCK_POLL_MS);
1731
+ }
1732
+ }
1733
+ try {
1734
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, at: new Date().toISOString(), token }) + "\n", "utf8");
1735
+ }
1736
+ catch (error) {
1737
+ try {
1738
+ fs.closeSync(fd);
1739
+ }
1740
+ catch {
1741
+ /* noop */
1742
+ }
1743
+ // pathを再確認せずunlinkすると、外部置換後のlockを消し得る。書込失敗は残骸としてfail closedし、
1744
+ // close/killAllのsession停止後cleanupへ委ねる。
1745
+ throw error;
1746
+ }
1747
+ fs.closeSync(fd);
1748
+ try {
1749
+ fs.chmodSync(p, 0o600);
1750
+ }
1751
+ catch {
1752
+ /* Windows等でmode強制できなくてもOS user temp境界とO_EXCLは維持される */
1753
+ }
1754
+ return () => {
1755
+ try {
1756
+ const st = fs.lstatSync(p);
1757
+ if (!st.isFile() || st.isSymbolicLink())
1758
+ return;
1759
+ const current = JSON.parse(fs.readFileSync(p, "utf8").split("\n", 1)[0]);
1760
+ if (current.token === token)
1761
+ fs.unlinkSync(p);
1762
+ }
1763
+ catch {
1764
+ /* 別ownerのlockや置換済みpathは消さない */
1765
+ }
1766
+ };
1767
+ }
1514
1768
  // close/killAll 用: 生きた別プロセス待機の wait lock を列挙する(stale 残骸は数えない)。
1515
1769
  function liveWaitLocks(name) {
1516
1770
  const dir = existingAgentsDir();
@@ -1606,11 +1860,11 @@ function createCodexAgentMetadata(name, cwd, initialPrompt, overrides = {}) {
1606
1860
  writeAgentMetadata(meta);
1607
1861
  return meta;
1608
1862
  }
1609
- function createGrokAgentMetadata(kind, name, cwd, initialPrompt) {
1863
+ function createGrokAgentMetadata(kind, name, cwd, initialPrompt, authPath) {
1610
1864
  const launchId = randomBytes(16).toString("hex");
1611
1865
  const eventFile = agentEventPath(name, launchId);
1612
1866
  createEmpty0600NoFollow(eventFile);
1613
- const managed = createManagedGrokHome(name, launchId);
1867
+ const managed = createManagedGrokHome(name, launchId, authPath);
1614
1868
  const meta = {
1615
1869
  kind,
1616
1870
  aiterm_session: name,
@@ -1624,6 +1878,7 @@ function createGrokAgentMetadata(kind, name, cwd, initialPrompt) {
1624
1878
  node_platform: process.platform,
1625
1879
  grok_home: managed.grokHome,
1626
1880
  home: managed.home,
1881
+ grok_auth_path: managed.authPath,
1627
1882
  };
1628
1883
  writeAgentMetadata(meta);
1629
1884
  return meta;
@@ -1682,6 +1937,10 @@ function loadAgentMetadata(name) {
1682
1937
  if (m.hook_route !== "managed_grok_home" || m.grok_home !== expectedGrokHome || m.home !== expectedHome) {
1683
1938
  throw new AitermError("agent metadata の path が現在の secure state root と一致しません", 2);
1684
1939
  }
1940
+ const expectedAuthPath = resolveAndValidateGrokAuth(realGrokHome());
1941
+ if ((typeof m.grok_auth_path === "string" ? m.grok_auth_path : null) !== expectedAuthPath) {
1942
+ throw new AitermError("agent metadata の認証正本が現在の設定と一致しません", 2);
1943
+ }
1685
1944
  return {
1686
1945
  kind: m.kind,
1687
1946
  aiterm_session: name,
@@ -1695,6 +1954,7 @@ function loadAgentMetadata(name) {
1695
1954
  node_platform: process.platform,
1696
1955
  grok_home: expectedGrokHome,
1697
1956
  home: expectedHome,
1957
+ grok_auth_path: expectedAuthPath,
1698
1958
  };
1699
1959
  }
1700
1960
  function parseAgentDoneEvent(line, meta) {
@@ -2288,21 +2548,46 @@ function resolveAgentBin(kind) {
2288
2548
  // 明示指定 env は実在を検証する。存在しないパスを黙って返すと、session を作って
2289
2549
  // `'/typo' ...` を送信し bash が command not found を出すだけで openAgent は「起動した」と
2290
2550
  // 偽成功を返す(既定パス/PATH 経路は検証するのに env だけ無検証だった非対称の解消・A3)。
2291
- if (fs.existsSync(fromEnv))
2551
+ if (isUsableExecutableFile(fromEnv))
2292
2552
  return fromEnv;
2293
2553
  throw new AitermError(`${envVar} に指定された ${name} が存在しません: ${fromEnv}`, 2);
2294
2554
  }
2295
2555
  const cand = path.join(home, ...rel);
2296
- if (fs.existsSync(cand))
2556
+ if (isUsableExecutableFile(cand))
2297
2557
  return cand;
2298
2558
  const w = spawnSync(isWin ? "where" : "which", [name], {
2299
2559
  encoding: "utf8",
2300
2560
  timeout: 5000,
2301
2561
  });
2302
- if (w.status === 0 && (w.stdout ?? "").trim())
2303
- return w.stdout.trim().split(/\r?\n/)[0];
2562
+ if (w.status === 0 && (w.stdout ?? "").trim()) {
2563
+ const resolved = w.stdout.trim().split(/\r?\n/)[0];
2564
+ if (isUsableExecutableFile(resolved))
2565
+ return resolved;
2566
+ }
2304
2567
  return null;
2305
2568
  }
2569
+ function isUsableExecutableFile(candidate) {
2570
+ try {
2571
+ if (!fs.statSync(candidate).isFile())
2572
+ return false;
2573
+ if (isWin)
2574
+ return /\.(?:exe|cmd|bat|com)$/i.test(candidate);
2575
+ fs.accessSync(candidate, fs.constants.X_OK);
2576
+ return true;
2577
+ }
2578
+ catch {
2579
+ return false;
2580
+ }
2581
+ }
2582
+ /** vendor CLI の存在だけを安全に要約する。認証状態・実行出力・解決先 path は返さない。 */
2583
+ export function vendorLauncherDiagnostic(kind) {
2584
+ try {
2585
+ return resolveAgentBin(kind) ? "ready" : "not_applicable";
2586
+ }
2587
+ catch {
2588
+ return "unverified";
2589
+ }
2590
+ }
2306
2591
  // 単一引用符で安全に包む(' は '\'' で脱出)。send は raw:true で送るため自前で quote する。
2307
2592
  function shq(s) {
2308
2593
  return `'${s.replace(/'/g, "'\\''")}'`;
@@ -2354,6 +2639,7 @@ function agentEnvPrefix(meta, sid) {
2354
2639
  return [
2355
2640
  `HOME=${shq(meta.home ?? "")}`,
2356
2641
  `GROK_HOME=${shq(meta.grok_home ?? "")}`,
2642
+ ...(meta.grok_auth_path ? [`GROK_AUTH_PATH=${shq(meta.grok_auth_path)}`] : []),
2357
2643
  "GROK_DISABLE_AUTOUPDATER=1",
2358
2644
  "GROK_CLAUDE_HOOKS_ENABLED=false",
2359
2645
  "GROK_CURSOR_HOOKS_ENABLED=false",
@@ -2427,10 +2713,16 @@ export function openAgent(kind, opts = {}) {
2427
2713
  "(モデルカタログ supports_reasoning_effort=false)", 2);
2428
2714
  }
2429
2715
  const agentDone = !!opts.agent_done;
2430
- const bin = resolveAgentBin(kind);
2716
+ let bin;
2717
+ try {
2718
+ bin = resolveAgentBin(kind);
2719
+ }
2720
+ catch (error) {
2721
+ ownTelemetryFailure("AITERM.VENDOR_LAUNCHER_FAILED", error, 2);
2722
+ }
2431
2723
  if (!bin) {
2432
2724
  const where = kind === "codex" ? "~/.local/bin/codex" : "~/.grok/bin/grok";
2433
- throw new AitermError(`${label} の CLI が見つかりません(${where} か PATH が必要)`, 2);
2725
+ ownTelemetryFailure("AITERM.VENDOR_LAUNCHER_FAILED", new AitermError(`${label} の CLI が見つかりません(${where} か PATH が必要)`, 2), 2);
2434
2726
  }
2435
2727
  // cwd 検証(session を作る前に。cd 失敗はシェル内で静かに死に「起動した」と偽成功を返すため)。
2436
2728
  let cwd = null;
@@ -2461,13 +2753,14 @@ export function openAgent(kind, opts = {}) {
2461
2753
  // でしか確認できない(CI 非対象。docs/03_audit-sweep-2026-07.md 参照)。
2462
2754
  const binForCmd = isWin ? toWslPath(bin) : bin;
2463
2755
  const cwdForCmd = cwd && isWin ? toWslPath(cwd) : cwd;
2756
+ const grokAuthPath = agentDone && kind !== "codex" ? resolveAndValidateGrokAuth(realGrokHome()) : null;
2464
2757
  const [sid, hint] = openSession(opts.session_name ?? null, "bash");
2465
2758
  let launchNote = "";
2466
2759
  try {
2467
2760
  const meta = agentDone
2468
2761
  ? kind === "codex"
2469
2762
  ? createCodexAgentMetadata(sid, cwd, opts.prompt ? "pending" : "none", { model, effort })
2470
- : createGrokAgentMetadata(kind, sid, cwd, opts.prompt ? "pending" : "none")
2763
+ : createGrokAgentMetadata(kind, sid, cwd, opts.prompt ? "pending" : "none", grokAuthPath)
2471
2764
  : null;
2472
2765
  if (meta)
2473
2766
  agentMetadataNegativeCache.delete(sid);
@@ -2481,14 +2774,15 @@ export function openAgent(kind, opts = {}) {
2481
2774
  send(sid, full, { enter: true, mark: false, force: true, rtk: false, raw: true });
2482
2775
  }
2483
2776
  catch (e) {
2777
+ const failure = telemetryOwnedFailure("AITERM.VENDOR_LAUNCHER_FAILED", e);
2484
2778
  // 起動コマンドを投入できなかった session は空のまま残る=残骸を作らない。片付けてから元エラーを伝える。
2485
2779
  try {
2486
- closeSession(sid);
2780
+ closeSessionInternal(sid, false);
2487
2781
  }
2488
2782
  catch {
2489
2783
  /* 片付け失敗より元エラーの伝達を優先 */
2490
2784
  }
2491
- throw e;
2785
+ throw failure;
2492
2786
  }
2493
2787
  return [
2494
2788
  sid,