aiterm-mcp 0.12.2 → 0.15.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/dist/core.js CHANGED
@@ -42,6 +42,7 @@ const NON_POSIX_MARK_SHELLS = new Set(["fish", "csh", "tcsh"]);
42
42
  // send の printf 書式(`rc=%d`)と対で保守すること。
43
43
  const MARK_DONE_RE = /<<<AITERM_DONE rc=[0-9]+>>>/;
44
44
  const LAUNCH_ID_RE = /^[0-9a-f]{32}$/;
45
+ const OPERATION_ID_RE = /^sha256:[0-9a-f]{64}$/;
45
46
  const AGENT_DONE_POLL_MS = 100;
46
47
  const AGENT_DONE_SETTLE_MIN_MS = 250;
47
48
  const AGENT_DONE_SCREEN_SETTLE_POLL_MS = 100;
@@ -53,7 +54,10 @@ const AGENT_EVENT_TAIL_BYTES = 64 * 1024;
53
54
  const AGENT_METADATA_NEGATIVE_CACHE_TTL_MS = 2_000;
54
55
  const AGENT_TUI_READY_TIMEOUT_MS = 30_000;
55
56
  const AGENT_TUI_READY_POLL_MS = 500;
57
+ const AGENT_TUI_READY_STABLE_SAMPLES = 11;
56
58
  const AGENT_TUI_READY_LINES = 45;
59
+ const GROK_AUTH_MAX_BYTES = 64 * 1024;
60
+ const CLAUDE_RESULT_MAX_BYTES = 4 * 1024 * 1024;
57
61
  // 出力削減(RTK の CAP 思想を移植)
58
62
  const MAX_LINES_BEFORE_ELIDE = 60;
59
63
  const HEAD_LINES = 30;
@@ -63,6 +67,12 @@ const LINE_HEAD_CHARS = 1200;
63
67
  const LINE_TAIL_CHARS = 600;
64
68
  const DEDUP_MIN_RUN = 3; // 同一行がこれ以上連続したら 1 行+件数に畳む
65
69
  const MAX_FULL_BYTES = 8 * 1024 * 1024; // full/range 読取で一度にメモリへ載せる上限(B7)
70
+ const MAX_SEND_BYTES = 64 * 1024;
71
+ // macOSのPTY入力queueは、tmuxが長文を1回で流すと後半を落とすことがある。
72
+ // UTF-8境界を守って小さいtmux client roundtripに分け、各回にserver event loopがPTYへdrainできる境界を作る。
73
+ const PTY_PASTE_CHUNK_BYTES = process.platform === "darwin" ? 256 : MAX_SEND_BYTES;
74
+ const SESSION_SEND_LOCK_WAIT_MS = 10_000;
75
+ const SESSION_SEND_LOCK_POLL_MS = 25;
66
76
  // 安全: send 前に弾く破壊的コマンド(外部システム境界の防御)
67
77
  const DESTRUCTIVE = [
68
78
  // rm -rf の危険な対象形(best-effort・force で越えられる)。先頭の任意クオート ['"]? で
@@ -199,18 +209,19 @@ function resolveTmux(observe = true) {
199
209
  }
200
210
  ptyDependencyError(tmuxMissingMessage(), observe);
201
211
  }
202
- function tmuxCommand(observe, ...args) {
212
+ function tmuxCommandWithInput(observe, input, ...args) {
203
213
  // maxBuffer は既定 1MiB。capture-pane(大きなスクロールバック)や多セッションの list-sessions で
204
214
  // 頭打ちになり stdout が切れる/空になる。Python の subprocess.run は無制限だったので 64MiB へ広げる。
205
215
  // Windows は同じ tmux を WSL 経由(-e でログインシェル非経由=$ 展開やクオート崩れを防ぐ)で叩く。
206
216
  let r;
217
+ const spawnOpts = { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, input };
207
218
  if (isWin) {
208
219
  ensureWinBridge(observe);
209
- r = spawnSync("wsl.exe", ["-e", "tmux", "-S", SOCK, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
220
+ r = spawnSync("wsl.exe", ["-e", "tmux", "-S", SOCK, ...args], spawnOpts);
210
221
  }
211
222
  else {
212
223
  // resolveTmux() は tmux を解決できなければ明確な AitermError を投げる(POSIX 版の事前確認)。
213
- r = spawnSync(resolveTmux(observe), ["-S", SOCK, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
224
+ r = spawnSync(resolveTmux(observe), ["-S", SOCK, ...args], spawnOpts);
214
225
  }
215
226
  // ENOBUFS(出力が 64MiB 超)を「code=1 の失敗」へ握り潰すと部分/空 stdout を正常扱いしてしまう。区別して投げる。
216
227
  // EXPECTED-FAILURE: 外部システム境界(tmux 出力過大)
@@ -225,9 +236,15 @@ function tmuxCommand(observe, ...args) {
225
236
  }
226
237
  return { code: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
227
238
  }
239
+ function tmuxCommand(observe, ...args) {
240
+ return tmuxCommandWithInput(observe, undefined, ...args);
241
+ }
228
242
  function tmux(...args) {
229
243
  return tmuxCommand(true, ...args);
230
244
  }
245
+ function tmuxWithInput(input, ...args) {
246
+ return tmuxCommandWithInput(true, input, ...args);
247
+ }
231
248
  function tmuxCleanup(...args) {
232
249
  return tmuxCommand(false, ...args);
233
250
  }
@@ -238,6 +255,42 @@ function paneCurrentCommand(name) {
238
255
  const r = tmux("display-message", "-p", "-t", name, "#{pane_current_command}");
239
256
  return r.code === 0 ? r.stdout.trim() : "";
240
257
  }
258
+ function pasteBufferSupportsNoSanitizeFlag() {
259
+ const listed = tmux("list-commands");
260
+ if (listed.code !== 0) {
261
+ throw new AitermError(`tmux paste-buffer 能力の確認に失敗しました: ${listed.stderr.trim() || `code=${listed.code}`}`, 2);
262
+ }
263
+ const usage = listed.stdout.split("\n").find((line) => line.startsWith("paste-buffer "));
264
+ if (!usage)
265
+ throw new AitermError("tmux list-commands にpaste-bufferがありません", 2);
266
+ // tmux 3.4は制御文字を無変換でpasteし、-S自体が無い。3.7は既定でvis(3)変換し、
267
+ // -Sが無変換を選ぶ。version文字比較で推測せず、実際のcommand usageにflagがあるかを見る。
268
+ return /\[-[^\]]*S[^\]]*\]/.test(usage);
269
+ }
270
+ function splitPtyText(text) {
271
+ const chunks = [];
272
+ let chunk = "";
273
+ let chunkBytes = 0;
274
+ for (const codePoint of text) {
275
+ const bytes = Buffer.byteLength(codePoint, "utf8");
276
+ if (chunk && chunkBytes + bytes > PTY_PASTE_CHUNK_BYTES) {
277
+ chunks.push(chunk);
278
+ chunk = "";
279
+ chunkBytes = 0;
280
+ }
281
+ chunk += codePoint;
282
+ chunkBytes += bytes;
283
+ }
284
+ if (chunk)
285
+ chunks.push(chunk);
286
+ return chunks;
287
+ }
288
+ function assertSendTextSize(text, context = "送信文字列") {
289
+ const bytes = Buffer.byteLength(text, "utf8");
290
+ if (bytes > MAX_SEND_BYTES) {
291
+ throw new AitermError(`${context}が${MAX_SEND_BYTES} bytesを超えています(${bytes} bytes)`, 2);
292
+ }
293
+ }
241
294
  // session 名はファイルパス(logpath 等)と pipe-pane の /bin/sh 文字列へ流れる。英数 _ - のみ・64字に
242
295
  // 限定し、パストラバーサル(../)とシェルインジェクション(' でのクオート破り・$・; 等)を全入口で断つ。
243
296
  function assertSessionName(name) {
@@ -312,6 +365,31 @@ function agentWaitLockPath(name, launchId) {
312
365
  throw new AitermError(`launch_id が不正です: ${launchId}`, 2);
313
366
  return path.join(agentsDir(), `${name}.${launchId}.wait.lock`);
314
367
  }
368
+ function agentManagedClaudeSettingsPath(name, launchId) {
369
+ assertSessionName(name);
370
+ if (!LAUNCH_ID_RE.test(launchId))
371
+ throw new AitermError(`launch_id が不正です: ${launchId}`, 2);
372
+ return path.join(agentsDir(), `${name}.${launchId}.claude-settings.json`);
373
+ }
374
+ function agentClaudeResultPath(name, launchId) {
375
+ assertSessionName(name);
376
+ if (!LAUNCH_ID_RE.test(launchId))
377
+ throw new AitermError(`launch_id が不正です: ${launchId}`, 2);
378
+ return path.join(agentsDir(), `${name}.${launchId}.claude-result.json`);
379
+ }
380
+ function agentClaudeOperationPath(name, launchId) {
381
+ assertSessionName(name);
382
+ if (!LAUNCH_ID_RE.test(launchId))
383
+ throw new AitermError(`launch_id が不正です: ${launchId}`, 2);
384
+ return path.join(agentsDir(), `${name}.${launchId}.claude-operation.json`);
385
+ }
386
+ function agentClaudeDispatchReceiptPath(name, launchId, operationId) {
387
+ assertSessionName(name);
388
+ if (!LAUNCH_ID_RE.test(launchId))
389
+ throw new AitermError(`launch_id が不正です: ${launchId}`, 2);
390
+ const validated = validateOperationId(operationId);
391
+ return path.join(agentsDir(), `${name}.${launchId}.${validated.slice("sha256:".length)}.claude-dispatch`);
392
+ }
315
393
  function agentManagedCodexHomePath(name, launchId) {
316
394
  assertSessionName(name);
317
395
  if (!LAUNCH_ID_RE.test(launchId))
@@ -365,7 +443,13 @@ function cleanupAgentState(name) {
365
443
  continue;
366
444
  const p = path.join(dir, f);
367
445
  try {
368
- if (f.endsWith(".agent.json") || f.endsWith(".events.jsonl") || f.endsWith(".wait.lock"))
446
+ if (f.endsWith(".agent.json") ||
447
+ f.endsWith(".events.jsonl") ||
448
+ f.endsWith(".wait.lock") ||
449
+ f.endsWith(".claude-settings.json") ||
450
+ f.endsWith(".claude-result.json") ||
451
+ f.endsWith(".claude-operation.json") ||
452
+ f.endsWith(".claude-dispatch"))
369
453
  fs.unlinkSync(p);
370
454
  else if (f.endsWith(".codex-home") || f.endsWith(".grok-home") || f.endsWith(".home")) {
371
455
  fs.rmSync(p, { recursive: true, force: true });
@@ -393,6 +477,10 @@ function lastcmdpath(name) {
393
477
  function markpath(name) {
394
478
  return path.join(SOCKDIR, name + ".mark");
395
479
  }
480
+ function sendLockPath(name) {
481
+ assertSessionName(name);
482
+ return path.join(SOCKDIR, name + ".send.lock");
483
+ }
396
484
  function readOffset(name) {
397
485
  try {
398
486
  const n = parseInt(fs.readFileSync(offsetpath(name), "utf8").trim(), 10);
@@ -802,17 +890,21 @@ export function openSession(name, shell = "bash") {
802
890
  }
803
891
  return [nm, attachHint(nm)];
804
892
  }
893
+ function prepareSendText(text, o) {
894
+ if (!o.raw)
895
+ text = text.replace(PASTE_MARKERS_RE, "").replace(ANSI_RE, "").replace(CTRL_RE, "");
896
+ if (!o.force)
897
+ assertNotDestructive(text, 3);
898
+ assertSendTextSize(text);
899
+ return text;
900
+ }
805
901
  export function send(name, text, o = {}) {
806
902
  assertSessionName(name);
807
903
  const enter = o.enter ?? true;
808
904
  if (!sessionExists(name))
809
905
  throw new AitermError(`session '${name}' が無い(open してください)`, 2);
810
906
  assertInitialPromptNotPendingForSend(name, !!o.force);
811
- if (!o.raw) {
812
- text = text.replace(PASTE_MARKERS_RE, "").replace(ANSI_RE, "").replace(CTRL_RE, "");
813
- }
814
- if (!o.force)
815
- assertNotDestructive(text, 3);
907
+ text = prepareSendText(text, o);
816
908
  if (o.mark) {
817
909
  // mark の sentinel は POSIX シェル構文。前面が fish/csh/tcsh 等の非 POSIX 対話シェルだと "$?" が
818
910
  // 壊れて sentinel が成立しない。黙って壊れた完了検出を作らず、明示エラーで until を促す(B8)。
@@ -822,41 +914,94 @@ export function send(name, text, o = {}) {
822
914
  `正しく展開されません。until で完了パターンを指定してください。`, 2);
823
915
  }
824
916
  }
825
- writeLastcmd(name, text); // read rtk の reducer 分類用(書換/mark 前の素のコマンド)
826
- if (o.rtk)
827
- text = rtkRewrite(text);
828
- if (o.rtk && !o.force)
829
- assertNotDestructive(text, 3, "rtk 変換後: ");
830
- if (o.mark) {
831
- // 実出力は rc=<数字>、この行のエコーは rc=%d(リテラル)。MARK_DONE_RE は数字アンカーで後者に免疫。
832
- text = text + `; printf '\\n<<<AITERM_DONE rc=%d>>>\\n' "$?"`;
833
- try {
834
- fs.writeFileSync(markpath(name), "1"); // waitCompletion に sentinel 完了検出を有効化させる
917
+ const releaseSendLock = acquireSessionSendFileLock(name);
918
+ try {
919
+ // managed Claudeの通常送信は、lastcmd/mark/PTYのどれにも触れる前に拒否する。
920
+ // 拒否した呼び出しが古いmarkを消したり偽のmarkを残すと、後続readが存在しない
921
+ // sentinelを待つため、公開上の拒否は副作用ゼロでなければならない。
922
+ if (!o.preserveAgentOperation && managedClaudeOperation(name) !== undefined) {
923
+ throw new AitermError("managed Claude agent sessionへの通常送信はturn境界を失うため拒否します。" +
924
+ 'pty_send(wait:"agent_done")を使うか、通常対話へ切り替えるならsessionをcloseしてagent_done:falseで起動し直してください。', 2);
925
+ }
926
+ writeLastcmd(name, text); // read rtk の reducer 分類用(書換/mark 前の素のコマンド)
927
+ if (o.rtk)
928
+ text = rtkRewrite(text);
929
+ if (o.rtk && !o.force)
930
+ assertNotDestructive(text, 3, "rtk 変換後: ");
931
+ if (o.mark)
932
+ text = text + `; printf '\\n<<<AITERM_DONE rc=%d>>>\\n' "$?"`;
933
+ assertSendTextSize(text, o.rtk || o.mark ? "変換後の送信文字列" : "送信文字列");
934
+ if (o.mark) {
935
+ try {
936
+ fs.writeFileSync(markpath(name), "1"); // waitCompletion に sentinel 完了検出を有効化させる
937
+ }
938
+ catch {
939
+ /* noop(フラグ書けなくても until/quiescence 経路は生きる) */
940
+ }
835
941
  }
836
- catch {
837
- /* noop(フラグ書けなくても until/quiescence 経路は生きる) */
942
+ else {
943
+ // 非 mark 送信は、未消化の古い mark 完了待ちを無効化する(前コマンドの sentinel を待ち続けない)。
944
+ try {
945
+ fs.unlinkSync(markpath(name));
946
+ }
947
+ catch {
948
+ /* noop */
949
+ }
838
950
  }
839
- }
840
- else {
841
- // 非 mark 送信は、未消化の古い mark 完了待ちを無効化する(前コマンドの sentinel を待ち続けない)。
842
- try {
843
- fs.unlinkSync(markpath(name));
951
+ // `send-keys -l`と単発`paste-buffer`は、長文をPTY入力queueへ一度に流し、macOS CIで
952
+ // 途中以降が欠落しても tmux 自体は code=0 を返した。macOSだけUTF-8を壊さない256byte以下に分け、
953
+ // Linux/WSLは一括のままとする。全chunk+Enterはsession単位のcross-process lock内で直列化する。
954
+ const pasteSupportsNoSanitize = pasteBufferSupportsNoSanitizeFlag();
955
+ const chunks = splitPtyText(text);
956
+ const bufferBase = `aiterm-${process.pid}-${randomBytes(8).toString("hex")}`;
957
+ for (let i = 0; i < chunks.length; i += 1) {
958
+ const bufferName = `${bufferBase}-${i}`;
959
+ const partial = i > 0
960
+ ? " 先行chunkはPTYに入力済みでEnterは未送信です。再送前に入力を確認・消去してください。"
961
+ : "";
962
+ const loaded = tmuxWithInput(chunks[i], "load-buffer", "-b", bufferName, "-");
963
+ if (loaded.code !== 0) {
964
+ tmuxCleanup("delete-buffer", "-b", bufferName);
965
+ throw new AitermError(`tmux bufferへの送信準備に失敗しました` +
966
+ `(chunk ${i + 1}/${chunks.length}): ${loaded.stderr.trim() || `code=${loaded.code}`}.${partial}`, 2);
967
+ }
968
+ // -r: LF→CR 置換を無効化。-Sは対応新版だけでvis(3)制御文字変換を無効化する。
969
+ // send 自身の raw/sanitize 契約だけを真実とし、tmux 側で黙って再変換させない。
970
+ const pasteArgs = ["paste-buffer", "-d", "-r"];
971
+ if (pasteSupportsNoSanitize)
972
+ pasteArgs.push("-S");
973
+ pasteArgs.push("-b", bufferName, "-t", name);
974
+ const pasted = tmux(...pasteArgs);
975
+ if (pasted.code !== 0) {
976
+ // paste 失敗時は -d で消えないbufferを明示的に掃除する。元エラーを優先する。
977
+ tmuxCleanup("delete-buffer", "-b", bufferName);
978
+ throw new AitermError(`tmux bufferのPTY送信に失敗しました` +
979
+ `(chunk ${i + 1}/${chunks.length}): ${pasted.stderr.trim() || `code=${pasted.code}`}.${partial}`, 2);
980
+ }
844
981
  }
845
- catch {
846
- /* noop */
982
+ if (enter) {
983
+ const entered = tmux("send-keys", "-t", name, "Enter");
984
+ if (entered.code !== 0) {
985
+ throw new AitermError(`文字列はPTYに入力済みですがtmuxへEnterを送れませんでした: ` +
986
+ `${entered.stderr.trim() || `code=${entered.code}`}。再送前に入力を確認・消去してください`, 2);
987
+ }
847
988
  }
989
+ // コードポイント数で数える(JS の .length は UTF-16 単位で絵文字等がズレる。Python は len()=コードポイント)。
990
+ return `sent ${[...text].length} chars to ${name}` + (enter ? " (+Enter)" : "");
991
+ }
992
+ finally {
993
+ releaseSendLock();
848
994
  }
849
- tmux("send-keys", "-t", name, "-l", "--", text);
850
- if (enter)
851
- tmux("send-keys", "-t", name, "Enter");
852
- // コードポイント数で数える(JS の .length は UTF-16 単位で絵文字等がズレる。Python は len()=コードポイント)。
853
- return `sent ${[...text].length} chars to ${name}` + (enter ? " (+Enter)" : "");
854
995
  }
855
- export function sendKey(name, key) {
996
+ export function sendKey(name, key, o = {}) {
856
997
  assertSessionName(name);
857
998
  if (!sessionExists(name))
858
999
  throw new AitermError(`session '${name}' が無い`, 2);
859
1000
  const k = KEYMAP[key.toLowerCase()] ?? key;
1001
+ if (!o.preserveAgentOperation && managedClaudeOperation(name) !== undefined && k !== "C-c") {
1002
+ throw new AitermError("managed Claude agent sessionではturn相関を壊さないC-cだけをpty_keyで送れます。" +
1003
+ "他の対話操作はpty_send(wait:\"agent_done\")、終了はpty_closeを使ってください。", 2);
1004
+ }
860
1005
  tmux("send-keys", "-t", name, k);
861
1006
  return `sent key ${k} to ${name}`;
862
1007
  }
@@ -1066,8 +1211,15 @@ function closeSessionInternal(name, observeDependency = true) {
1066
1211
  throw new AitermError(`agent session '${name}' は別プロセス${d.pid != null ? `(pid ${d.pid})` : ""}の agent_done 待機中のため close できません`, 2);
1067
1212
  }
1068
1213
  }
1214
+ {
1215
+ const sending = liveSendLocks(name);
1216
+ if (sending.length > 0) {
1217
+ const d = sending[0];
1218
+ throw new AitermError(`session '${name}' は別プロセス${d.pid != null ? `(pid ${d.pid})` : ""}の送信中のため close できません`, 2);
1219
+ }
1220
+ }
1069
1221
  (observeDependency ? tmux : tmuxCleanup)("kill-session", "-t", name);
1070
- for (const p of [logpath(name), offsetpath(name), lastcmdpath(name), markpath(name)]) {
1222
+ for (const p of [logpath(name), offsetpath(name), lastcmdpath(name), markpath(name), sendLockPath(name)]) {
1071
1223
  try {
1072
1224
  fs.unlinkSync(p);
1073
1225
  }
@@ -1081,6 +1233,23 @@ function closeSessionInternal(name, observeDependency = true) {
1081
1233
  export function closeSession(name) {
1082
1234
  return closeSessionInternal(name, true);
1083
1235
  }
1236
+ /**
1237
+ * Durable caller向けのclose receipt。
1238
+ *
1239
+ * closeSessionInternalのidempotent cleanupは維持し、呼出時点でtmux sessionが
1240
+ * 存在したかだけを固定語彙で返す。MCP response loss後も同じsession IDで再試行すれば
1241
+ * already_closedとなり、close完了を文字列解析なしで確定できる。
1242
+ */
1243
+ export function closeSessionResult(name) {
1244
+ assertSessionName(name);
1245
+ const existed = sessionExists(name);
1246
+ closeSessionInternal(name, true);
1247
+ return {
1248
+ schema: "aiterm.pty-close-result.v1",
1249
+ session_id: name,
1250
+ outcome: existed ? "closed" : "already_closed",
1251
+ };
1252
+ }
1084
1253
  export function killAll() {
1085
1254
  if (agentWaitLocks.size > 0) {
1086
1255
  throw new AitermError(`agent_done 待機中の session があるため killAll できません: ${Array.from(agentWaitLocks).join(",")}`, 2);
@@ -1093,11 +1262,18 @@ export function killAll() {
1093
1262
  throw new AitermError(`agent_done 待機中の session があるため killAll できません: ${list}`, 2);
1094
1263
  }
1095
1264
  }
1265
+ {
1266
+ const sending = liveSendLocks(null);
1267
+ if (sending.length > 0) {
1268
+ const list = sending.map((d) => `${d.session}${d.pid != null ? `(pid ${d.pid})` : ""}`).join(",");
1269
+ throw new AitermError(`送信中の session があるため killAll できません: ${list}`, 2);
1270
+ }
1271
+ }
1096
1272
  tmux("kill-server");
1097
- // B9: SOCKDIR 内の .log/.offset/.lastcmd/.mark 残骸も掃除する(残すと B5 の stale-log 復活の温床)。
1273
+ // B9: SOCKDIR 内の .log/.offset/.lastcmd/.mark/.send.lock 残骸も掃除する。
1098
1274
  try {
1099
1275
  for (const f of fs.readdirSync(SOCKDIR)) {
1100
- if (/\.(log|offset|lastcmd|mark)$/.test(f)) {
1276
+ if (/\.(log|offset|lastcmd|mark)$/.test(f) || f.endsWith(".send.lock")) {
1101
1277
  try {
1102
1278
  fs.unlinkSync(path.join(SOCKDIR, f));
1103
1279
  }
@@ -1117,6 +1293,10 @@ export function killAll() {
1117
1293
  if (f.endsWith(".agent.json") ||
1118
1294
  f.endsWith(".events.jsonl") ||
1119
1295
  f.endsWith(".wait.lock") ||
1296
+ f.endsWith(".claude-settings.json") ||
1297
+ f.endsWith(".claude-result.json") ||
1298
+ f.endsWith(".claude-operation.json") ||
1299
+ f.endsWith(".claude-dispatch") ||
1120
1300
  f.endsWith(".codex-home") ||
1121
1301
  f.endsWith(".grok-home") ||
1122
1302
  f.endsWith(".home")) {
@@ -1139,6 +1319,7 @@ export function killAll() {
1139
1319
  const DEFAULT_AGENT_DONE_TIMEOUT = 600;
1140
1320
  const agentWaitLocks = new Set();
1141
1321
  const agentMetadataNegativeCache = new Map();
1322
+ let agentTuiReadyStableSamplesTestOverride = null;
1142
1323
  function normalizeAgentLauncherWait(v) {
1143
1324
  if (v == null || v === "none")
1144
1325
  return "none";
@@ -1152,6 +1333,9 @@ function codexHookScriptPath() {
1152
1333
  function grokHookScriptPath() {
1153
1334
  return path.join(path.dirname(fileURLToPath(import.meta.url)), "grok-stop-hook.js");
1154
1335
  }
1336
+ function claudeHookScriptPath() {
1337
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "claude-stop-hook.js");
1338
+ }
1155
1339
  function safeStatSize(p) {
1156
1340
  try {
1157
1341
  return fs.statSync(p).size;
@@ -1379,71 +1563,85 @@ function createManagedCodexHome(name, launchId, overrides = {}) {
1379
1563
  });
1380
1564
  return managedHome;
1381
1565
  }
1382
- function realGrokHome() {
1383
- return process.env.GROK_HOME || path.join(process.env.HOME ?? os.homedir(), ".grok");
1384
- }
1385
- function validateGrokAuthLock(lockPath) {
1386
- let st;
1387
- try {
1388
- st = fs.lstatSync(lockPath);
1389
- }
1390
- catch (e) {
1391
- throw e;
1392
- }
1393
- if (st.isSymbolicLink())
1394
- throw new AitermError(`Grok auth lock が symlink です: ${lockPath}`, 2);
1395
- if (!st.isFile())
1396
- throw new AitermError(`Grok auth lock が通常ファイルではありません: ${lockPath}`, 2);
1397
- if (st.nlink !== 1)
1398
- throw new AitermError(`Grok auth lock が hard link です: ${lockPath}`, 2);
1399
- try {
1400
- fs.chmodSync(lockPath, 0o600);
1401
- }
1402
- catch {
1403
- /* lock file permission tightening is best-effort */
1404
- }
1405
- }
1406
- function ensureGrokAuthLock(srcHome) {
1407
- const lockPath = path.join(srcHome, "auth.json.lock");
1408
- try {
1409
- validateGrokAuthLock(lockPath);
1410
- return lockPath;
1411
- }
1412
- catch (e) {
1413
- if (e.code !== "ENOENT")
1414
- throw e;
1415
- }
1416
- try {
1417
- createEmpty0600NoFollow(lockPath);
1418
- }
1419
- catch (e) {
1420
- if (e.code !== "EEXIST")
1421
- throw e;
1566
+ function createManagedClaudeSettings(name, launchId) {
1567
+ const hookScript = claudeHookScriptPath();
1568
+ if (!fs.existsSync(hookScript)) {
1569
+ throw new AitermError(`Claude Stop hook wrapper が見つかりません。npm run build を実行してください: ${hookScript}`, 2);
1422
1570
  }
1423
- validateGrokAuthLock(lockPath);
1424
- return lockPath;
1571
+ const settings = agentManagedClaudeSettingsPath(name, launchId);
1572
+ writeJson0600(settings, {
1573
+ hooks: {
1574
+ Stop: [
1575
+ {
1576
+ hooks: [
1577
+ {
1578
+ type: "command",
1579
+ command: `${shq(process.execPath)} ${shq(hookScript)}`,
1580
+ timeout: 10,
1581
+ },
1582
+ ],
1583
+ },
1584
+ ],
1585
+ },
1586
+ });
1587
+ return settings;
1425
1588
  }
1426
- function linkGrokOauthFiles(srcHome, grokHome) {
1427
- const srcAuth = path.join(srcHome, "auth.json");
1428
- let authExists = false;
1589
+ function realGrokHome() {
1590
+ return path.resolve(process.env.GROK_HOME || path.join(process.env.HOME ?? os.homedir(), ".grok"));
1591
+ }
1592
+ function resolveAndValidateGrokAuth(srcHome) {
1593
+ const inheritedSet = Object.prototype.hasOwnProperty.call(process.env, "GROK_AUTH_PATH");
1594
+ const inherited = process.env.GROK_AUTH_PATH;
1595
+ if (inheritedSet && (!inherited || !path.isAbsolute(inherited)))
1596
+ throw new AitermError("GROK_AUTH_PATH は空でない絶対パスで指定してください", 2);
1597
+ const authPath = inherited ?? path.join(srcHome, "auth.json");
1598
+ let fd;
1429
1599
  try {
1430
- const authSt = fs.statSync(srcAuth);
1431
- if (!authSt.isFile())
1432
- throw new AitermError(`Grok auth.json が通常ファイルではありません: ${srcAuth}`, 2);
1433
- authExists = true;
1600
+ fd = fs.openSync(authPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
1601
+ const st = fs.fstatSync(fd);
1602
+ if (!st.isFile() || st.nlink !== 1 || st.uid !== currentUid() || (st.mode & 0o077) !== 0 || st.size > GROK_AUTH_MAX_BYTES) {
1603
+ throw new AitermError("Grok 認証正本の安全検証に失敗しました", 2);
1604
+ }
1605
+ const value = JSON.parse(fs.readFileSync(fd, "utf8"));
1606
+ if (!value || typeof value !== "object" || Array.isArray(value))
1607
+ throw new AitermError("Grok 認証正本のJSONが不正です", 2);
1608
+ // auth file 自体は O_NOFOLLOW で開いているが、中間 directory の symlink は辿り得る。
1609
+ // vendor に渡す正本を path swap の入口にしないため、字句正規化した絶対 path と realpath を
1610
+ // 一致させ、canonical な祖先も root まで検証する。same-UID race の排他は vendor lock の責務。
1611
+ const lexicalPath = path.resolve(authPath);
1612
+ const canonicalPath = fs.realpathSync(authPath);
1613
+ if (lexicalPath !== canonicalPath)
1614
+ throw new AitermError("Grok 認証正本の path に symlink を含められません", 2);
1615
+ for (let dir = path.dirname(canonicalPath);; dir = path.dirname(dir)) {
1616
+ const dirSt = fs.lstatSync(dir);
1617
+ // /tmp のような root 所有 + sticky の共有 directory は、本人所有の private な
1618
+ // 直下 directory を他 UID が rename/unlink できないため許可する。sticky 無しの
1619
+ // group/other writable 祖先は path swap が可能なので従来どおり拒否する。
1620
+ const writableByOthers = (dirSt.mode & 0o022) !== 0;
1621
+ const protectedSharedRoot = dirSt.uid === 0 && (dirSt.mode & 0o1000) !== 0;
1622
+ if (!dirSt.isDirectory()
1623
+ || dirSt.isSymbolicLink()
1624
+ || (dirSt.uid !== currentUid() && dirSt.uid !== 0)
1625
+ || (writableByOthers && !protectedSharedRoot)) {
1626
+ throw new AitermError("Grok 認証正本の祖先 directory が安全ではありません", 2);
1627
+ }
1628
+ const parent = path.dirname(dir);
1629
+ if (parent === dir)
1630
+ break;
1631
+ }
1632
+ return canonicalPath;
1434
1633
  }
1435
1634
  catch (e) {
1436
- if (e.code !== "ENOENT")
1635
+ if (e.code === "ENOENT" && !inheritedSet && process.env.XAI_API_KEY)
1636
+ return null;
1637
+ if (e instanceof AitermError)
1437
1638
  throw e;
1639
+ throw new AitermError(e.code === "ENOENT" ? "Grok 認証正本が見つかりません。先に grok login が必要です" : "Grok 認証正本を安全に開けません", 2);
1438
1640
  }
1439
- if (!authExists) {
1440
- if (process.env.XAI_API_KEY)
1441
- return;
1442
- throw new AitermError(`Grok auth.json が見つかりません。先に grok login が必要です: ${srcHome}`, 2);
1641
+ finally {
1642
+ if (fd !== undefined)
1643
+ fs.closeSync(fd);
1443
1644
  }
1444
- const srcLock = ensureGrokAuthLock(srcHome);
1445
- fs.symlinkSync(srcAuth, path.join(grokHome, "auth.json"));
1446
- fs.symlinkSync(srcLock, path.join(grokHome, "auth.json.lock"));
1447
1645
  }
1448
1646
  function writeManagedGrokConfig(grokHome) {
1449
1647
  fs.mkdirSync(path.join(grokHome, "hooks"), { recursive: false, mode: 0o700 });
@@ -1471,17 +1669,7 @@ function writeManagedGrokConfig(grokHome) {
1471
1669
  "",
1472
1670
  ].join("\n"));
1473
1671
  }
1474
- function createManagedGrokHome(name, launchId) {
1475
- const srcHome = realGrokHome();
1476
- let srcSt;
1477
- try {
1478
- srcSt = fs.statSync(srcHome);
1479
- }
1480
- catch {
1481
- throw new AitermError(`Grok home が見つかりません: ${srcHome}`, 2);
1482
- }
1483
- if (!srcSt.isDirectory())
1484
- throw new AitermError(`Grok home が directory ではありません: ${srcHome}`, 2);
1672
+ function createManagedGrokHome(name, launchId, authPath) {
1485
1673
  const grokHome = agentManagedGrokHomePath(name, launchId);
1486
1674
  const fakeHome = agentManagedGrokUserHomePath(name, launchId);
1487
1675
  fs.mkdirSync(grokHome, { recursive: false, mode: 0o700 });
@@ -1489,9 +1677,6 @@ function createManagedGrokHome(name, launchId) {
1489
1677
  fs.chmodSync(grokHome, 0o700);
1490
1678
  fs.chmodSync(fakeHome, 0o700);
1491
1679
  fs.symlinkSync(grokHome, path.join(fakeHome, ".grok"));
1492
- // OAuth refresh token rotation は auth.json だけでなく vendor lock と同じ実体を共有させる。
1493
- // per-launch GROK_HOME 隔離は維持し、通常 Grok home の hook/config/session は読ませない。
1494
- linkGrokOauthFiles(srcHome, grokHome);
1495
1680
  const hookScript = grokHookScriptPath();
1496
1681
  if (!fs.existsSync(hookScript)) {
1497
1682
  throw new AitermError(`Grok Stop hook wrapper が見つかりません。npm run build を実行してください: ${hookScript}`, 2);
@@ -1514,11 +1699,147 @@ function createManagedGrokHome(name, launchId) {
1514
1699
  });
1515
1700
  // Grok 0.2.87 は compat false でも ~/.claude/plugins の hook file を拾う。HOME を一時化して
1516
1701
  // plugin/hook source を完全に 0 にする。実 HOME は必要なら hook/agent 側で参照できるよう env で渡す。
1517
- return { grokHome, home: fakeHome };
1702
+ return { grokHome, home: fakeHome, authPath };
1518
1703
  }
1519
1704
  function writeAgentMetadata(meta) {
1520
1705
  writeJson0600(agentMetadataPath(meta.aiterm_session, meta.launch_id), meta);
1521
1706
  }
1707
+ function validateOperationId(operationId) {
1708
+ if (typeof operationId !== "string" || !OPERATION_ID_RE.test(operationId)) {
1709
+ throw new AitermError("operation_id は sha256:<64 lowercase hex> で指定してください", 2);
1710
+ }
1711
+ return operationId;
1712
+ }
1713
+ function writeClaudeOperationMarker(meta, operationId) {
1714
+ if (meta.kind !== "claude")
1715
+ throw new AitermError("operation_id はClaude agent sessionだけで使用できます", 2);
1716
+ writeJson0600(agentClaudeOperationPath(meta.aiterm_session, meta.launch_id), {
1717
+ schema: "aiterm.claude-operation-marker.v1",
1718
+ operation_id: operationId === null ? null : validateOperationId(operationId),
1719
+ });
1720
+ }
1721
+ function readClaudeOperationMarker(meta) {
1722
+ if (meta.kind !== "claude")
1723
+ return null;
1724
+ const file = agentClaudeOperationPath(meta.aiterm_session, meta.launch_id);
1725
+ const nofollow = fs.constants.O_NOFOLLOW ?? 0;
1726
+ let fd;
1727
+ try {
1728
+ fd = fs.openSync(file, fs.constants.O_RDONLY | nofollow);
1729
+ }
1730
+ catch (error) {
1731
+ if (error.code === "ENOENT")
1732
+ return null;
1733
+ throw new AitermError(`Claude operation markerを確認できません: ${error.message}`, 2);
1734
+ }
1735
+ try {
1736
+ const st = fs.fstatSync(fd);
1737
+ if (!st.isFile() || st.uid !== currentUid() || st.nlink !== 1 || (st.mode & 0o077) !== 0 || st.size > 1024) {
1738
+ throw new AitermError("Claude operation markerの安全検証に失敗しました", 2);
1739
+ }
1740
+ let value;
1741
+ try {
1742
+ value = JSON.parse(fs.readFileSync(fd, "utf8"));
1743
+ }
1744
+ catch {
1745
+ throw new AitermError("Claude operation markerを読めません", 2);
1746
+ }
1747
+ const keys = value && typeof value === "object" && !Array.isArray(value) ? Object.keys(value).sort() : [];
1748
+ if (keys.join(",") !== "operation_id,schema" ||
1749
+ value.schema !== "aiterm.claude-operation-marker.v1" ||
1750
+ (value.operation_id !== null &&
1751
+ (typeof value.operation_id !== "string" || !OPERATION_ID_RE.test(value.operation_id)))) {
1752
+ throw new AitermError("Claude operation markerが不正です", 2);
1753
+ }
1754
+ return { operationId: value.operation_id };
1755
+ }
1756
+ finally {
1757
+ fs.closeSync(fd);
1758
+ }
1759
+ }
1760
+ function reserveClaudeOperation(meta, operationId) {
1761
+ const validated = validateOperationId(operationId);
1762
+ const active = readClaudeOperationMarker(meta);
1763
+ if (active?.operationId === validated) {
1764
+ throw new AitermError(`operation ${validated} は既にdispatch済みです。再送しません。pty_read(agent_transcript:true, operation_id:...)で回収してください。`, 2);
1765
+ }
1766
+ if (active) {
1767
+ throw new AitermError(`${active.operationId ? `別のoperation ${active.operationId}` : "operation_idなしのClaude turn"} が未解決です。` +
1768
+ "Stop結果を回収するか、C-c後もStopが来なければsessionをcloseしてから次のoperationを送ってください。", 2);
1769
+ }
1770
+ const receipt = agentClaudeDispatchReceiptPath(meta.aiterm_session, meta.launch_id, validated);
1771
+ try {
1772
+ createEmpty0600NoFollow(receipt);
1773
+ }
1774
+ catch (error) {
1775
+ if (error.code !== "EEXIST")
1776
+ throw error;
1777
+ let st;
1778
+ try {
1779
+ st = fs.lstatSync(receipt);
1780
+ }
1781
+ catch {
1782
+ throw new AitermError("Claude dispatch receiptを確認できません", 2);
1783
+ }
1784
+ if (!st.isFile() || st.isSymbolicLink() || st.uid !== currentUid() || st.nlink !== 1 || (st.mode & 0o077) !== 0) {
1785
+ throw new AitermError("Claude dispatch receiptの安全検証に失敗しました", 2);
1786
+ }
1787
+ throw new AitermError(`operation ${validated} は既にdispatch済みです。再送しません。`, 2);
1788
+ }
1789
+ try {
1790
+ writeClaudeOperationMarker(meta, validated);
1791
+ }
1792
+ catch (error) {
1793
+ try {
1794
+ fs.unlinkSync(receipt);
1795
+ }
1796
+ catch { /* marker未作成時のrollback失敗は元エラーを優先 */ }
1797
+ throw error;
1798
+ }
1799
+ }
1800
+ function reserveAnonymousClaudeTurn(meta) {
1801
+ const active = readClaudeOperationMarker(meta);
1802
+ if (active) {
1803
+ throw new AitermError(`${active.operationId ? `operation ${active.operationId}` : "operation_idなしのClaude turn"} が未解決です。` +
1804
+ "Stop結果を回収するかsessionをcloseするまで次のturnを送れません。", 2);
1805
+ }
1806
+ writeClaudeOperationMarker(meta, null);
1807
+ }
1808
+ function managedClaudeOperation(name) {
1809
+ let meta;
1810
+ try {
1811
+ meta = loadAgentMetadata(name);
1812
+ }
1813
+ catch (error) {
1814
+ if (error instanceof AitermError && error.message.includes("agent_done 管理セッションではありません"))
1815
+ return undefined;
1816
+ throw error;
1817
+ }
1818
+ if (meta.kind !== "claude")
1819
+ return undefined;
1820
+ return readClaudeOperationMarker(meta);
1821
+ }
1822
+ function hasClaudeDispatchReceipt(meta, operationId) {
1823
+ const file = agentClaudeDispatchReceiptPath(meta.aiterm_session, meta.launch_id, operationId);
1824
+ let st;
1825
+ try {
1826
+ st = fs.lstatSync(file);
1827
+ }
1828
+ catch (error) {
1829
+ if (error.code === "ENOENT")
1830
+ return false;
1831
+ throw new AitermError(`Claude dispatch receiptを確認できません: ${error.message}`, 2);
1832
+ }
1833
+ if (!st.isFile() ||
1834
+ st.isSymbolicLink() ||
1835
+ st.uid !== currentUid() ||
1836
+ st.nlink !== 1 ||
1837
+ (st.mode & 0o077) !== 0 ||
1838
+ st.size !== 0) {
1839
+ throw new AitermError("Claude dispatch receiptの安全検証に失敗しました", 2);
1840
+ }
1841
+ return true;
1842
+ }
1522
1843
  function normalizeInitialPromptState(v) {
1523
1844
  if (v === true)
1524
1845
  return "pending";
@@ -1581,13 +1902,101 @@ function probeWaitLock(p) {
1581
1902
  function unlinkStaleWaitLock(p) {
1582
1903
  try {
1583
1904
  const st = fs.lstatSync(p);
1584
- if (st.isFile() && !st.isSymbolicLink() && st.uid === currentUid())
1905
+ const uid = typeof process.getuid === "function" ? process.getuid() : null;
1906
+ if (st.isFile() && !st.isSymbolicLink() && (uid == null || st.uid === uid))
1585
1907
  fs.unlinkSync(p);
1586
1908
  }
1587
1909
  catch {
1588
1910
  /* noop */
1589
1911
  }
1590
1912
  }
1913
+ function liveSendLocks(name) {
1914
+ let files;
1915
+ try {
1916
+ files = fs.readdirSync(SOCKDIR);
1917
+ }
1918
+ catch {
1919
+ return [];
1920
+ }
1921
+ const out = [];
1922
+ for (const f of files) {
1923
+ if (!f.endsWith(".send.lock"))
1924
+ continue;
1925
+ const session = f.slice(0, -".send.lock".length);
1926
+ if (name != null && session !== name)
1927
+ continue;
1928
+ const p = path.join(SOCKDIR, f);
1929
+ const probe = probeWaitLock(p);
1930
+ if (probe.live)
1931
+ out.push({ session, pid: probe.pid, at: probe.at });
1932
+ // dead send lockはここでunlinkしない。probe後に別processが同pathへ新しいlive lockを作る
1933
+ // ABAが起きると、そのlive lockを消して二重owner化できる。close/killAllがsessionを止めた後に掃除する。
1934
+ }
1935
+ return out;
1936
+ }
1937
+ function acquireSessionSendFileLock(name) {
1938
+ const p = sendLockPath(name);
1939
+ const token = randomBytes(16).toString("hex");
1940
+ const nofollow = fs.constants.O_NOFOLLOW ?? 0;
1941
+ const deadline = Date.now() + SESSION_SEND_LOCK_WAIT_MS;
1942
+ let fd = null;
1943
+ let lastProbe = { pid: null, at: null, live: true };
1944
+ while (fd == null) {
1945
+ try {
1946
+ fd = fs.openSync(p, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | nofollow, 0o600);
1947
+ }
1948
+ catch (e) {
1949
+ if (e.code !== "EEXIST")
1950
+ throw e;
1951
+ lastProbe = probeWaitLock(p);
1952
+ if (!lastProbe.live) {
1953
+ const detail = lastProbe.pid != null ? `pid ${lastProbe.pid}` : "owner不明";
1954
+ throw new AitermError(`session '${name}' に前回送信のlock残骸があります(${detail})。` +
1955
+ `自動回収は並行送信の混線を招くため行いません。pty_closeでsessionを閉じてから再作成するか、` +
1956
+ `不要な全sessionをpty_kill_allで停止して残骸を掃除してください`, 2);
1957
+ }
1958
+ if (Date.now() >= deadline) {
1959
+ const detail = lastProbe.pid != null ? `pid ${lastProbe.pid}` : "owner不明";
1960
+ throw new AitermError(`session '${name}' は別プロセスの送信中です(${detail})。${SESSION_SEND_LOCK_WAIT_MS}ms待ってもlockを取得できませんでした`, 2);
1961
+ }
1962
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, SESSION_SEND_LOCK_POLL_MS);
1963
+ }
1964
+ }
1965
+ try {
1966
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, at: new Date().toISOString(), token }) + "\n", "utf8");
1967
+ }
1968
+ catch (error) {
1969
+ try {
1970
+ fs.closeSync(fd);
1971
+ }
1972
+ catch {
1973
+ /* noop */
1974
+ }
1975
+ // pathを再確認せずunlinkすると、外部置換後のlockを消し得る。書込失敗は残骸としてfail closedし、
1976
+ // close/killAllのsession停止後cleanupへ委ねる。
1977
+ throw error;
1978
+ }
1979
+ fs.closeSync(fd);
1980
+ try {
1981
+ fs.chmodSync(p, 0o600);
1982
+ }
1983
+ catch {
1984
+ /* Windows等でmode強制できなくてもOS user temp境界とO_EXCLは維持される */
1985
+ }
1986
+ return () => {
1987
+ try {
1988
+ const st = fs.lstatSync(p);
1989
+ if (!st.isFile() || st.isSymbolicLink())
1990
+ return;
1991
+ const current = JSON.parse(fs.readFileSync(p, "utf8").split("\n", 1)[0]);
1992
+ if (current.token === token)
1993
+ fs.unlinkSync(p);
1994
+ }
1995
+ catch {
1996
+ /* 別ownerのlockや置換済みpathは消さない */
1997
+ }
1998
+ };
1999
+ }
1591
2000
  // close/killAll 用: 生きた別プロセス待機の wait lock を列挙する(stale 残骸は数えない)。
1592
2001
  function liveWaitLocks(name) {
1593
2002
  const dir = existingAgentsDir();
@@ -1662,6 +2071,32 @@ function acquireAgentWaitFileLock(meta) {
1662
2071
  }
1663
2072
  };
1664
2073
  }
2074
+ function createClaudeAgentMetadata(name, cwd, initialPrompt, launchOperationId, launchRequestDigest) {
2075
+ const launchId = randomBytes(16).toString("hex");
2076
+ const eventFile = agentEventPath(name, launchId);
2077
+ const resultFile = agentClaudeResultPath(name, launchId);
2078
+ createEmpty0600NoFollow(eventFile);
2079
+ createEmpty0600NoFollow(resultFile);
2080
+ const claudeSettings = createManagedClaudeSettings(name, launchId);
2081
+ const meta = {
2082
+ kind: "claude",
2083
+ aiterm_session: name,
2084
+ launch_id: launchId,
2085
+ event_file: eventFile,
2086
+ created_at: new Date().toISOString(),
2087
+ cwd,
2088
+ vendor_session_id: null,
2089
+ initial_prompt: initialPrompt,
2090
+ launch_operation_id: launchOperationId,
2091
+ launch_request_digest: launchRequestDigest,
2092
+ hook_route: "managed_claude_settings",
2093
+ node_platform: process.platform,
2094
+ claude_settings: claudeSettings,
2095
+ result_file: resultFile,
2096
+ };
2097
+ writeAgentMetadata(meta);
2098
+ return meta;
2099
+ }
1665
2100
  function createCodexAgentMetadata(name, cwd, initialPrompt, overrides = {}) {
1666
2101
  const launchId = randomBytes(16).toString("hex");
1667
2102
  const eventFile = agentEventPath(name, launchId);
@@ -1683,11 +2118,11 @@ function createCodexAgentMetadata(name, cwd, initialPrompt, overrides = {}) {
1683
2118
  writeAgentMetadata(meta);
1684
2119
  return meta;
1685
2120
  }
1686
- function createGrokAgentMetadata(kind, name, cwd, initialPrompt) {
2121
+ function createGrokAgentMetadata(kind, name, cwd, initialPrompt, authPath) {
1687
2122
  const launchId = randomBytes(16).toString("hex");
1688
2123
  const eventFile = agentEventPath(name, launchId);
1689
2124
  createEmpty0600NoFollow(eventFile);
1690
- const managed = createManagedGrokHome(name, launchId);
2125
+ const managed = createManagedGrokHome(name, launchId, authPath);
1691
2126
  const meta = {
1692
2127
  kind,
1693
2128
  aiterm_session: name,
@@ -1701,6 +2136,7 @@ function createGrokAgentMetadata(kind, name, cwd, initialPrompt) {
1701
2136
  node_platform: process.platform,
1702
2137
  grok_home: managed.grokHome,
1703
2138
  home: managed.home,
2139
+ grok_auth_path: managed.authPath,
1704
2140
  };
1705
2141
  writeAgentMetadata(meta);
1706
2142
  return meta;
@@ -1725,7 +2161,7 @@ function loadAgentMetadata(name) {
1725
2161
  throw new AitermError(`agent metadata を読めません: ${e.message}`, 2);
1726
2162
  }
1727
2163
  const m = raw;
1728
- if ((m.kind !== "codex" && m.kind !== "grok" && m.kind !== "composer") ||
2164
+ if ((m.kind !== "claude" && m.kind !== "codex" && m.kind !== "grok" && m.kind !== "composer") ||
1729
2165
  m.aiterm_session !== name ||
1730
2166
  typeof m.launch_id !== "string" ||
1731
2167
  !LAUNCH_ID_RE.test(m.launch_id)) {
@@ -1735,6 +2171,36 @@ function loadAgentMetadata(name) {
1735
2171
  if (m.event_file !== expectedEvent) {
1736
2172
  throw new AitermError("agent metadata の path が現在の secure state root と一致しません", 2);
1737
2173
  }
2174
+ if (m.kind === "claude") {
2175
+ const expectedSettings = agentManagedClaudeSettingsPath(name, m.launch_id);
2176
+ const expectedResult = agentClaudeResultPath(name, m.launch_id);
2177
+ const launchOperationId = m.launch_operation_id ?? null;
2178
+ const launchRequestDigest = m.launch_request_digest ?? null;
2179
+ if (m.hook_route !== "managed_claude_settings" ||
2180
+ m.claude_settings !== expectedSettings ||
2181
+ m.result_file !== expectedResult ||
2182
+ ((launchOperationId === null) !== (launchRequestDigest === null)) ||
2183
+ (launchOperationId !== null && !OPERATION_ID_RE.test(launchOperationId)) ||
2184
+ (launchRequestDigest !== null && !OPERATION_ID_RE.test(launchRequestDigest))) {
2185
+ throw new AitermError("agent metadata の path が現在の secure state root と一致しません", 2);
2186
+ }
2187
+ return {
2188
+ kind: "claude",
2189
+ aiterm_session: name,
2190
+ launch_id: m.launch_id,
2191
+ event_file: expectedEvent,
2192
+ created_at: typeof m.created_at === "string" ? m.created_at : "",
2193
+ cwd: typeof m.cwd === "string" ? m.cwd : null,
2194
+ vendor_session_id: typeof m.vendor_session_id === "string" ? m.vendor_session_id : null,
2195
+ initial_prompt: normalizeInitialPromptState(m.initial_prompt),
2196
+ launch_operation_id: launchOperationId,
2197
+ launch_request_digest: launchRequestDigest,
2198
+ hook_route: "managed_claude_settings",
2199
+ node_platform: process.platform,
2200
+ claude_settings: expectedSettings,
2201
+ result_file: expectedResult,
2202
+ };
2203
+ }
1738
2204
  if (m.kind === "codex") {
1739
2205
  const expectedHome = agentManagedCodexHomePath(name, m.launch_id);
1740
2206
  if (m.hook_route !== "managed_codex_home" || m.codex_home !== expectedHome) {
@@ -1759,6 +2225,10 @@ function loadAgentMetadata(name) {
1759
2225
  if (m.hook_route !== "managed_grok_home" || m.grok_home !== expectedGrokHome || m.home !== expectedHome) {
1760
2226
  throw new AitermError("agent metadata の path が現在の secure state root と一致しません", 2);
1761
2227
  }
2228
+ const expectedAuthPath = resolveAndValidateGrokAuth(realGrokHome());
2229
+ if ((typeof m.grok_auth_path === "string" ? m.grok_auth_path : null) !== expectedAuthPath) {
2230
+ throw new AitermError("agent metadata の認証正本が現在の設定と一致しません", 2);
2231
+ }
1762
2232
  return {
1763
2233
  kind: m.kind,
1764
2234
  aiterm_session: name,
@@ -1772,6 +2242,7 @@ function loadAgentMetadata(name) {
1772
2242
  node_platform: process.platform,
1773
2243
  grok_home: expectedGrokHome,
1774
2244
  home: expectedHome,
2245
+ grok_auth_path: expectedAuthPath,
1775
2246
  };
1776
2247
  }
1777
2248
  function parseAgentDoneEvent(line, meta) {
@@ -1793,6 +2264,18 @@ function parseAgentDoneEvent(line, meta) {
1793
2264
  if (meta.vendor_session_id && ev.vendor_session_id !== meta.vendor_session_id) {
1794
2265
  return { event: null, malformed: false };
1795
2266
  }
2267
+ if (meta.kind === "claude" &&
2268
+ (typeof ev.vendor_session_id !== "string" ||
2269
+ !ev.vendor_session_id ||
2270
+ (ev.operation_id != null &&
2271
+ (typeof ev.operation_id !== "string" || !OPERATION_ID_RE.test(ev.operation_id))) ||
2272
+ typeof ev.result_digest !== "string" ||
2273
+ !/^[0-9a-f]{64}$/.test(ev.result_digest) ||
2274
+ !Number.isInteger(ev.result_bytes) ||
2275
+ ev.result_bytes < 0 ||
2276
+ ev.result_bytes > CLAUDE_RESULT_MAX_BYTES)) {
2277
+ return { event: null, malformed: true };
2278
+ }
1796
2279
  return {
1797
2280
  event: {
1798
2281
  type: "agent_done",
@@ -1801,15 +2284,18 @@ function parseAgentDoneEvent(line, meta) {
1801
2284
  launch_id: meta.launch_id,
1802
2285
  vendor_session_id: typeof ev.vendor_session_id === "string" ? ev.vendor_session_id : null,
1803
2286
  turn_id: typeof ev.turn_id === "string" ? ev.turn_id : null,
2287
+ operation_id: typeof ev.operation_id === "string" && OPERATION_ID_RE.test(ev.operation_id) ? ev.operation_id : null,
1804
2288
  reason: typeof ev.reason === "string" ? ev.reason : "Stop",
1805
2289
  done_status: "turn_done",
1806
2290
  stop_hook_active: !!ev.stop_hook_active,
2291
+ result_digest: typeof ev.result_digest === "string" && /^[0-9a-f]{64}$/.test(ev.result_digest) ? ev.result_digest : undefined,
2292
+ result_bytes: Number.isInteger(ev.result_bytes) && ev.result_bytes >= 0 ? ev.result_bytes : undefined,
1807
2293
  at: typeof ev.at === "string" ? ev.at : new Date().toISOString(),
1808
2294
  },
1809
2295
  malformed: false,
1810
2296
  };
1811
2297
  }
1812
- function scanAgentDoneLines(lines, meta) {
2298
+ function scanAgentDoneLines(lines, meta, expectedOperationId = null) {
1813
2299
  let malformedEvents = 0;
1814
2300
  let candidate = null;
1815
2301
  for (const line of lines) {
@@ -1827,6 +2313,8 @@ function scanAgentDoneLines(lines, meta) {
1827
2313
  const ev = parsed.event;
1828
2314
  if (!ev)
1829
2315
  continue;
2316
+ if (expectedOperationId && ev.operation_id !== expectedOperationId)
2317
+ continue;
1830
2318
  if (meta.vendor_session_id)
1831
2319
  return { event: ev, malformedEvents, ambiguousVendorSession: false };
1832
2320
  if (candidate?.vendor_session_id &&
@@ -1878,7 +2366,7 @@ function tryLoadAgentMetadata(name) {
1878
2366
  return null;
1879
2367
  }
1880
2368
  }
1881
- function latestAgentDoneEvent(meta) {
2369
+ function latestAgentDoneEvent(meta, expectedOperationId = null) {
1882
2370
  const size = safeStatSize(meta.event_file);
1883
2371
  if (size === 0)
1884
2372
  return null;
@@ -1895,11 +2383,62 @@ function latestAgentDoneEvent(meta) {
1895
2383
  if (!line.trim() || Buffer.byteLength(line, "utf8") > 64 * 1024)
1896
2384
  continue;
1897
2385
  const parsed = parseAgentDoneEvent(line, meta);
1898
- if (parsed.event)
2386
+ if (parsed.event && (!expectedOperationId || parsed.event.operation_id === expectedOperationId))
1899
2387
  latest = parsed.event;
1900
2388
  }
1901
2389
  return latest;
1902
2390
  }
2391
+ function completedClaudeOperationEvent(meta, operationId) {
2392
+ const size = safeStatSize(meta.event_file);
2393
+ if (size === 0)
2394
+ return null;
2395
+ if (size > AGENT_EVENT_MAX_BYTES) {
2396
+ throw new AitermError("agent event file が大きすぎるためClaude operationを安全に回収できません", 2);
2397
+ }
2398
+ const text = readFileRange(meta.event_file, 0, size).toString("utf8");
2399
+ const lines = text.split("\n");
2400
+ const tail = lines.pop() ?? "";
2401
+ if (tail.length > 0)
2402
+ throw new AitermError("Claude operation event fileに未完結lineがあります", 2);
2403
+ let match = null;
2404
+ for (const line of lines) {
2405
+ if (!line.trim())
2406
+ continue;
2407
+ if (Buffer.byteLength(line, "utf8") > 64 * 1024) {
2408
+ throw new AitermError("Claude operation event lineが上限を超えています", 2);
2409
+ }
2410
+ const parsed = parseAgentDoneEvent(line, meta);
2411
+ if (parsed.malformed)
2412
+ throw new AitermError("Claude operation eventが不正です", 2);
2413
+ if (!parsed.event || parsed.event.operation_id !== operationId)
2414
+ continue;
2415
+ if (match !== null)
2416
+ throw new AitermError("Claude operation completion eventが重複しています", 2);
2417
+ match = parsed.event;
2418
+ }
2419
+ return match;
2420
+ }
2421
+ function recoverAgentVendorSession(meta) {
2422
+ if (meta.vendor_session_id)
2423
+ return;
2424
+ const size = safeStatSize(meta.event_file);
2425
+ if (size === 0)
2426
+ return;
2427
+ if (size > AGENT_EVENT_MAX_BYTES) {
2428
+ throw new AitermError("agent event file が大きすぎるため timeout 後のsessionを安全に回収できません。該当セッションを閉じて起動し直してください。", 2);
2429
+ }
2430
+ const text = readFileRange(meta.event_file, 0, size).toString("utf8");
2431
+ const lines = text.split("\n");
2432
+ lines.pop(); // hook は newline 完結eventだけを確定済みとして扱う。
2433
+ const scanned = scanAgentDoneLines(lines, meta);
2434
+ if (scanned.ambiguousVendorSession) {
2435
+ throw new AitermError("agent event file に複数の vendor_session_id が混在しています。該当セッションを閉じて起動し直してください。", 2);
2436
+ }
2437
+ if (!scanned.event?.vendor_session_id)
2438
+ return;
2439
+ bindAgentVendorSession(meta, scanned.event);
2440
+ writeAgentMetadata(meta);
2441
+ }
1903
2442
  function findLatestCodexTranscript(codexHome, vendorSessionId) {
1904
2443
  const sessionsDir = path.join(codexHome, "sessions");
1905
2444
  let latestFile = null;
@@ -1951,16 +2490,79 @@ function readTranscriptLines(file) {
1951
2490
  throw new AitermError(`transcript を読めません: ${e.message}`, 2);
1952
2491
  }
1953
2492
  }
2493
+ function readClaudeResultText(meta, done, operationId) {
2494
+ if (meta.kind !== "claude" || !meta.result_file || !done.result_digest || done.result_bytes == null) {
2495
+ transcriptUnavailable();
2496
+ }
2497
+ let st;
2498
+ try {
2499
+ st = fs.lstatSync(meta.result_file);
2500
+ }
2501
+ catch {
2502
+ transcriptUnavailable();
2503
+ }
2504
+ if (!st.isFile() ||
2505
+ st.isSymbolicLink() ||
2506
+ st.uid !== currentUid() ||
2507
+ st.nlink !== 1 ||
2508
+ (st.mode & 0o077) !== 0 ||
2509
+ st.size > CLAUDE_RESULT_MAX_BYTES + 4096) {
2510
+ throw new AitermError("Claude result file の安全検証に失敗しました", 2);
2511
+ }
2512
+ let result;
2513
+ try {
2514
+ result = JSON.parse(fs.readFileSync(meta.result_file, "utf8"));
2515
+ }
2516
+ catch {
2517
+ throw new AitermError("Claude result file を読めません", 2);
2518
+ }
2519
+ const keys = result && typeof result === "object" && !Array.isArray(result) ? Object.keys(result).sort() : [];
2520
+ if (keys.join(",") !== "operation_id,result_bytes,result_digest,schema,text,vendor_session_id" ||
2521
+ result.schema !== "aiterm.claude-turn-result.v2" ||
2522
+ result.operation_id !== done.operation_id ||
2523
+ (operationId !== null && result.operation_id !== operationId) ||
2524
+ result.vendor_session_id !== meta.vendor_session_id ||
2525
+ result.result_digest !== done.result_digest ||
2526
+ result.result_bytes !== done.result_bytes ||
2527
+ typeof result.text !== "string" ||
2528
+ Buffer.byteLength(result.text, "utf8") !== done.result_bytes ||
2529
+ createHash("sha256").update(result.text, "utf8").digest("hex") !== done.result_digest) {
2530
+ throw new AitermError("Claude result file が完了eventと一致しません", 2);
2531
+ }
2532
+ return result.text;
2533
+ }
1954
2534
  /** agent vendor の構造化 transcript から直近完了ターンの最終回答を読む。 */
1955
2535
  export async function readAgentTranscript(name, o = {}) {
1956
2536
  const meta = loadAgentMetadata(name);
2537
+ const operationId = o.operation_id == null ? null : validateOperationId(o.operation_id);
2538
+ if (operationId && meta.kind !== "claude") {
2539
+ throw new AitermError("operation_id付き回収はClaude agent sessionだけで使用できます", 2);
2540
+ }
2541
+ if (meta.kind === "claude") {
2542
+ const active = readClaudeOperationMarker(meta);
2543
+ if (active) {
2544
+ const label = active.operationId ? `operation ${active.operationId}` : "operation_idなしのClaude turn";
2545
+ throw new AitermError(`${label} はまだ完了していません。Stop完了後に同じsessionから再取得してください。`, 2);
2546
+ }
2547
+ }
2548
+ // wait timeout は「失敗」ではなく状態不明。後着した同一launchの完了eventから
2549
+ // vendor session をbindし、promptを再送せず結果だけ回収できるようにする。
2550
+ recoverAgentVendorSession(meta);
1957
2551
  if (!meta.vendor_session_id) {
1958
2552
  throw new AitermError(`agent session '${name}' はまだターンが完了していません。agent_done 完了後に再取得してください。`, 2);
1959
2553
  }
1960
- const done = latestAgentDoneEvent(meta);
2554
+ const done = latestAgentDoneEvent(meta, operationId);
2555
+ if (operationId && !done) {
2556
+ throw new AitermError(`operation ${operationId} はまだ完了していません。同じoperation_idで後から再取得してください。`, 2);
2557
+ }
1961
2558
  const turnId = done?.turn_id ?? null;
1962
2559
  let text = "";
1963
- if (meta.kind === "codex") {
2560
+ if (meta.kind === "claude") {
2561
+ if (!done)
2562
+ transcriptUnavailable();
2563
+ text = readClaudeResultText(meta, done, operationId);
2564
+ }
2565
+ else if (meta.kind === "codex") {
1964
2566
  if (!meta.codex_home)
1965
2567
  transcriptUnavailable();
1966
2568
  const transcript = findLatestCodexTranscript(meta.codex_home, meta.vendor_session_id);
@@ -2038,8 +2640,9 @@ export async function readAgentTranscript(name, o = {}) {
2038
2640
  "agent_transcript",
2039
2641
  `vendor=${meta.kind}`,
2040
2642
  `turn_id=${turnId ?? "unknown"}`,
2643
+ done?.operation_id ? `operation_id=${done.operation_id}` : null,
2041
2644
  `raw_chars=${rawChars}`,
2042
- ].join(" ");
2645
+ ].filter(Boolean).join(" ");
2043
2646
  return `${body}\n${outputMeta} [${transcriptMeta}]`;
2044
2647
  }
2045
2648
  function inferAgentFrontend(name, meta, screen) {
@@ -2093,7 +2696,7 @@ function assertInitialPromptNotPendingForSend(name, force) {
2093
2696
  throw new AitermError(`agent session '${name}' は起動時 prompt の完了待ちです。通常 pty_send は混入防止のため送信しません。` +
2094
2697
  `完了後に pty_send(wait:"agent_done") するか、手動介入が必要な場合だけ force:true を明示してください。`, 2);
2095
2698
  }
2096
- async function waitAgentDoneEvent(meta, startOffset, timeout) {
2699
+ async function waitAgentDoneEvent(meta, startOffset, timeout, expectedOperationId = null) {
2097
2700
  const deadline = performance.now() + timeout * 1000;
2098
2701
  let cursor = startOffset;
2099
2702
  let carry = "";
@@ -2112,7 +2715,7 @@ async function waitAgentDoneEvent(meta, startOffset, timeout) {
2112
2715
  cursor = size;
2113
2716
  const parts = carry.split("\n");
2114
2717
  carry = parts.pop() ?? "";
2115
- const scanned = scanAgentDoneLines(parts, meta);
2718
+ const scanned = scanAgentDoneLines(parts, meta, expectedOperationId);
2116
2719
  malformedEvents += scanned.malformedEvents;
2117
2720
  if (scanned.ambiguousVendorSession) {
2118
2721
  throw new AitermError("agent event file に複数の vendor_session_id が混在しています。該当セッションを閉じて起動し直してください。", 2);
@@ -2129,11 +2732,71 @@ async function waitAgentDoneEvent(meta, startOffset, timeout) {
2129
2732
  await sleep(AGENT_DONE_POLL_MS);
2130
2733
  }
2131
2734
  }
2132
- function agentDoneSuffix(wait, vendor) {
2735
+ // 外部waiterプロセス用の純リーダー観測。lock・PTY・metadata書込・dispatch状態には一切触れない。
2736
+ // event fileのtail規律(未終端行保持・増分上限)はwaitAgentDoneEventと同一だが、
2737
+ // vendor_session_idのbind永続化を行わない点だけ意図的に異なる(waiterは観測者であって所有者でない)。
2738
+ export async function observeAgentDone(name, o = {}) {
2739
+ const meta = loadAgentMetadata(name);
2740
+ const operationId = o.operation_id == null ? null : validateOperationId(o.operation_id);
2741
+ if (operationId && meta.kind !== "claude") {
2742
+ throw new AitermError("operation_id はClaude agent sessionだけで使用できます", 2);
2743
+ }
2744
+ const timeout = o.timeout ?? DEFAULT_AGENT_DONE_TIMEOUT;
2745
+ const metadataFile = agentMetadataPath(meta.aiterm_session, meta.launch_id);
2746
+ // operation相関があるならoperation_idの一意性で誤帰属を防げるため先頭から全走査できる
2747
+ // (waiter起動がdispatchより遅れても取りこぼさない)。相関なしはwaiter起動時EOFを境界にする。
2748
+ const startOffset = operationId ? 0 : safeStatSize(meta.event_file);
2749
+ const deadline = performance.now() + timeout * 1000;
2750
+ let cursor = startOffset;
2751
+ let carry = "";
2752
+ let malformedEvents = 0;
2753
+ const observation = (outcome, ev = null) => ({
2754
+ schema: "aiterm.agent-wait-result.v1",
2755
+ session_id: meta.aiterm_session,
2756
+ launch_id: meta.launch_id,
2757
+ vendor: meta.kind,
2758
+ outcome,
2759
+ operation_id: ev?.operation_id ?? operationId,
2760
+ vendor_session_id: ev?.vendor_session_id ?? meta.vendor_session_id ?? null,
2761
+ turn_id: ev?.turn_id ?? null,
2762
+ malformed_events: malformedEvents,
2763
+ at: ev?.at ?? null,
2764
+ });
2765
+ for (;;) {
2766
+ if (!fs.existsSync(metadataFile))
2767
+ return observation("closed");
2768
+ const size = safeStatSize(meta.event_file);
2769
+ if (size < cursor) {
2770
+ cursor = 0;
2771
+ carry = "";
2772
+ }
2773
+ if (size > cursor) {
2774
+ if (size - cursor > AGENT_EVENT_MAX_BYTES) {
2775
+ throw new AitermError("agent event file の増分が大きすぎます。該当セッションを閉じて起動し直してください。", 2);
2776
+ }
2777
+ carry += readFileRange(meta.event_file, cursor, size).toString("utf8");
2778
+ cursor = size;
2779
+ const parts = carry.split("\n");
2780
+ carry = parts.pop() ?? "";
2781
+ const scanned = scanAgentDoneLines(parts, meta, operationId);
2782
+ malformedEvents += scanned.malformedEvents;
2783
+ if (scanned.ambiguousVendorSession) {
2784
+ throw new AitermError("agent event file に複数の vendor_session_id が混在しています。該当セッションを閉じて起動し直してください。", 2);
2785
+ }
2786
+ if (scanned.event)
2787
+ return observation("done", scanned.event);
2788
+ }
2789
+ if (performance.now() >= deadline)
2790
+ return observation("timeout");
2791
+ await sleep(AGENT_DONE_POLL_MS);
2792
+ }
2793
+ }
2794
+ function agentDoneSuffix(wait, vendor, operationId = null) {
2133
2795
  const ev = wait.event;
2134
2796
  if (!ev) {
2135
2797
  const malformed = wait.malformedEvents ? ` malformed_events=${wait.malformedEvents}` : "";
2136
- return ` [is_complete=False via agent_timeout vendor=${vendor}${malformed}]`;
2798
+ const operation = operationId ? ` operation_id=${operationId}` : "";
2799
+ return ` [is_complete=False via agent_timeout vendor=${vendor}${operation}${malformed}]`;
2137
2800
  }
2138
2801
  const bits = [
2139
2802
  "is_complete=True",
@@ -2141,11 +2804,15 @@ function agentDoneSuffix(wait, vendor) {
2141
2804
  `vendor=${ev.vendor}`,
2142
2805
  ev.turn_id ? `turn_id=${ev.turn_id}` : null,
2143
2806
  ev.vendor_session_id ? `vendor_session_id=${ev.vendor_session_id}` : null,
2807
+ ev.operation_id ? `operation_id=${ev.operation_id}` : null,
2144
2808
  `done_status=${ev.done_status}`,
2145
2809
  ].filter(Boolean);
2146
2810
  return ` [${bits.join(" ")}]`;
2147
2811
  }
2148
2812
  function isAgentTuiReady(kind, screen) {
2813
+ if (kind === "claude") {
2814
+ return screen.includes("Claude Code") && /(^|\n)\s*❯/.test(screen);
2815
+ }
2149
2816
  if (kind === "codex") {
2150
2817
  return screen.includes("OpenAI Codex") && /(^|\n)\s*[›>]/.test(screen);
2151
2818
  }
@@ -2154,14 +2821,22 @@ function isAgentTuiReady(kind, screen) {
2154
2821
  async function waitAgentTuiReadyImpl(kind, sample, sleepFn, opts = {}) {
2155
2822
  const timeoutMs = opts.timeoutMs ?? AGENT_TUI_READY_TIMEOUT_MS;
2156
2823
  const pollMs = opts.pollMs ?? AGENT_TUI_READY_POLL_MS;
2824
+ const stableSamples = opts.stableSamples ?? agentTuiReadyStableSamplesTestOverride ?? AGENT_TUI_READY_STABLE_SAMPLES;
2157
2825
  const deadline = performance.now() + timeoutMs;
2158
2826
  let samples = 0;
2827
+ let readyStreak = 0;
2159
2828
  let lastScreen = "";
2160
2829
  for (;;) {
2161
2830
  lastScreen = sample();
2162
2831
  samples++;
2163
- if (isAgentTuiReady(kind, lastScreen))
2164
- return { ready: true, samples, lastScreen };
2832
+ if (isAgentTuiReady(kind, lastScreen)) {
2833
+ readyStreak++;
2834
+ if (readyStreak >= stableSamples)
2835
+ return { ready: true, samples, lastScreen };
2836
+ }
2837
+ else {
2838
+ readyStreak = 0;
2839
+ }
2165
2840
  if (performance.now() >= deadline)
2166
2841
  return { ready: false, samples, lastScreen };
2167
2842
  await sleepFn(pollMs);
@@ -2229,6 +2904,12 @@ export async function __testWaitAgentTuiReady(kind, samples, opts = {}) {
2229
2904
  }, opts);
2230
2905
  return { ...result, sleeps };
2231
2906
  }
2907
+ export function __testSetAgentTuiReadyStableSamples(value) {
2908
+ if (value !== null && (!Number.isInteger(value) || value < 1 || value > 1000)) {
2909
+ throw new AitermError("test ready stable samplesが不正です", 2);
2910
+ }
2911
+ agentTuiReadyStableSamplesTestOverride = value;
2912
+ }
2232
2913
  async function sendAgentPromptText(name, text) {
2233
2914
  send(name, text, {
2234
2915
  enter: false,
@@ -2236,9 +2917,10 @@ async function sendAgentPromptText(name, text) {
2236
2917
  raw: false,
2237
2918
  mark: false,
2238
2919
  rtk: false,
2920
+ preserveAgentOperation: true,
2239
2921
  });
2240
2922
  await sleep(AGENT_SUBMIT_DELAY_MS);
2241
- sendKey(name, "Enter");
2923
+ sendKey(name, "Enter", { preserveAgentOperation: true });
2242
2924
  }
2243
2925
  export async function sendInitialAgentPrompt(name, text, o = {}) {
2244
2926
  assertSessionName(name);
@@ -2265,6 +2947,10 @@ export async function sendInitialAgentPrompt(name, text, o = {}) {
2265
2947
  }
2266
2948
  const startOffset = safeStatSize(meta.event_file);
2267
2949
  try {
2950
+ if (meta.kind === "claude") {
2951
+ prepareSendText(text, { raw: false, force: true });
2952
+ reserveAnonymousClaudeTurn(meta);
2953
+ }
2268
2954
  await sendAgentPromptText(name, text);
2269
2955
  setInitialPromptState(meta, "pending");
2270
2956
  }
@@ -2308,6 +2994,10 @@ export async function sendAndWaitAgentDone(name, text, o = {}) {
2308
2994
  if (o.rtk)
2309
2995
  throw new AitermError('wait:"agent_done" と rtk:true は併用できません', 2);
2310
2996
  const meta = loadAgentMetadata(name);
2997
+ const operationId = o.operation_id == null ? null : validateOperationId(o.operation_id);
2998
+ if (operationId && meta.kind !== "claude") {
2999
+ throw new AitermError("operation_id はClaude agent sessionだけで使用できます", 2);
3000
+ }
2311
3001
  if (agentWaitLocks.has(name))
2312
3002
  throw new AitermError(`agent session '${name}' は別の agent_done 待機中です`, 2);
2313
3003
  const releaseFileLock = acquireAgentWaitFileLock(meta);
@@ -2324,17 +3014,28 @@ export async function sendAndWaitAgentDone(name, text, o = {}) {
2324
3014
  }
2325
3015
  }
2326
3016
  const startOffset = safeStatSize(meta.event_file);
3017
+ if (meta.kind === "claude") {
3018
+ // durable/anonymousを分岐する前に同じsend preflightを通す。拒否されるpromptの
3019
+ // receipt/active markerだけを残して、来ないStopを待つ状態を作らない。
3020
+ prepareSendText(text, { raw: o.raw, force: o.force });
3021
+ if (operationId) {
3022
+ reserveClaudeOperation(meta, operationId);
3023
+ }
3024
+ else
3025
+ reserveAnonymousClaudeTurn(meta);
3026
+ }
2327
3027
  send(name, text, {
2328
3028
  enter: false,
2329
3029
  force: o.force,
2330
3030
  raw: o.raw,
2331
3031
  mark: false,
2332
3032
  rtk: false,
3033
+ preserveAgentOperation: meta.kind === "claude",
2333
3034
  });
2334
3035
  // Codex TUI は literal text 投入直後の Enter を取り落とすことがある。agent 経路だけ submit を分離する。
2335
3036
  await sleep(AGENT_SUBMIT_DELAY_MS);
2336
- sendKey(name, "Enter");
2337
- const wait = await waitAgentDoneEvent(meta, startOffset, timeout);
3037
+ sendKey(name, "Enter", { preserveAgentOperation: meta.kind === "claude" });
3038
+ const wait = await waitAgentDoneEvent(meta, startOffset, timeout, operationId);
2338
3039
  const settled = wait.event
2339
3040
  ? await settleAgentDoneScreen(name, o.lines ?? 0)
2340
3041
  : { unstable: false, samples: 0 };
@@ -2344,22 +3045,87 @@ export async function sendAndWaitAgentDone(name, text, o = {}) {
2344
3045
  timeout: 0,
2345
3046
  });
2346
3047
  writeOffset(name, safeStatSize(logpath(name)));
2347
- return out + agentDoneSuffix(wait, meta.kind) + (settled.unstable ? " [agent_done_but_screen_unstable]" : "");
3048
+ return out + agentDoneSuffix(wait, meta.kind, operationId) + (settled.unstable ? " [agent_done_but_screen_unstable]" : "");
2348
3049
  }
2349
3050
  finally {
2350
3051
  agentWaitLocks.delete(name);
2351
3052
  releaseFileLock();
2352
3053
  }
2353
3054
  }
2354
- // ── 対話型エージェント起動(Codex / Grok Build(Grok) / Grok Build(Composer))──────
3055
+ export async function runClaudeOperation({ session_id: name, action, operation_id: operationIdInput, text, timeout, }) {
3056
+ assertSessionName(name);
3057
+ if (action !== "issue" && action !== "recover") {
3058
+ throw new AitermError('action は "issue" または "recover" を指定してください', 2);
3059
+ }
3060
+ const operationId = validateOperationId(operationIdInput);
3061
+ const meta = loadAgentMetadata(name);
3062
+ if (meta.kind !== "claude")
3063
+ throw new AitermError("claude_turnはmanaged Claude agent sessionだけで使用できます", 2);
3064
+ if (action === "issue") {
3065
+ if (typeof text !== "string" || text.length === 0) {
3066
+ throw new AitermError("claude_turn issueには空でないtextが必要です", 2);
3067
+ }
3068
+ const waitTimeout = timeout ?? DEFAULT_AGENT_DONE_TIMEOUT;
3069
+ if (!Number.isFinite(waitTimeout) || waitTimeout < 0 || waitTimeout > 3600) {
3070
+ throw new AitermError("claude_turn timeoutは0〜3600秒で指定してください", 2);
3071
+ }
3072
+ await sendAndWaitAgentDone(name, text, {
3073
+ operation_id: operationId,
3074
+ timeout: waitTimeout,
3075
+ screen: false,
3076
+ lines: 0,
3077
+ });
3078
+ }
3079
+ else {
3080
+ if (text != null)
3081
+ throw new AitermError("claude_turn recoverにtextは指定できません", 2);
3082
+ if (timeout != null)
3083
+ throw new AitermError("claude_turn recoverにtimeoutは指定できません", 2);
3084
+ }
3085
+ const inspected = inspectClaudeOperation(meta, operationId, action);
3086
+ if (action === "issue" && inspected.status === "pending") {
3087
+ return { ...inspected, status: "accepted" };
3088
+ }
3089
+ return inspected;
3090
+ }
3091
+ function inspectClaudeOperation(meta, operationId, action) {
3092
+ const base = {
3093
+ schema: "aiterm.claude-operation-result.v1",
3094
+ action,
3095
+ session_id: meta.aiterm_session,
3096
+ operation_id: operationId,
3097
+ };
3098
+ const active = readClaudeOperationMarker(meta);
3099
+ if (active) {
3100
+ if (active.operationId !== operationId) {
3101
+ throw new AitermError(`${active.operationId ? `別のoperation ${active.operationId}` : "operation_idなしのClaude turn"} が未解決です`, 2);
3102
+ }
3103
+ return { ...base, status: "pending", raw_output: null, reason: null };
3104
+ }
3105
+ if (!hasClaudeDispatchReceipt(meta, operationId)) {
3106
+ return { ...base, status: "unknown", raw_output: null, reason: "operation_not_found" };
3107
+ }
3108
+ const done = completedClaudeOperationEvent(meta, operationId);
3109
+ if (!done)
3110
+ return { ...base, status: "unknown", raw_output: null, reason: "result_unknown" };
3111
+ if (!meta.vendor_session_id && done.vendor_session_id) {
3112
+ bindAgentVendorSession(meta, done);
3113
+ writeAgentMetadata(meta);
3114
+ }
3115
+ const rawOutput = readClaudeResultText(meta, done, operationId);
3116
+ return { ...base, status: "completed", raw_output: rawOutput, reason: null };
3117
+ }
3118
+ // ── 対話型エージェント起動(Claude / Codex / Grok Build(Grok) / Grok Build(Composer))──────
2355
3119
  // aiterm の永続端末に、指定モデルの対話エージェント TUI を起動する。以後は pty_read で画面を
2356
3120
  // 読み、pty_send で操作する=aiterm の対話パラダイムそのもの。モデルはツールごとに固定し、
2357
3121
  // reasoning effort は引数で渡す。CLI 未導入環境は明示エラー(動くフリをしない)。
2358
3122
  function resolveAgentBin(kind) {
2359
3123
  const home = process.env.HOME ?? os.homedir();
2360
- const [envVar, rel, name] = kind === "codex"
2361
- ? ["CODEX_BIN", [".local", "bin", "codex"], "codex"]
2362
- : ["GROK_BIN", [".grok", "bin", "grok"], "grok"];
3124
+ const [envVar, rel, name] = kind === "claude"
3125
+ ? ["CLAUDE_BIN", [".local", "bin", "claude"], "claude"]
3126
+ : kind === "codex"
3127
+ ? ["CODEX_BIN", [".local", "bin", "codex"], "codex"]
3128
+ : ["GROK_BIN", [".grok", "bin", "grok"], "grok"];
2363
3129
  const fromEnv = process.env[envVar];
2364
3130
  if (fromEnv) {
2365
3131
  // 明示指定 env は実在を検証する。存在しないパスを黙って返すと、session を作って
@@ -2415,9 +3181,19 @@ const GROK_MODEL_DEFAULTS = {
2415
3181
  grok: "grok-4.5",
2416
3182
  composer: "grok-composer-2.5-fast",
2417
3183
  };
3184
+ const CLAUDE_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
2418
3185
  function buildAgentCmd(kind, bin, model, effort, prompt, meta = null) {
2419
3186
  const parts = [shq(bin)];
2420
- if (kind === "codex") {
3187
+ if (kind === "claude") {
3188
+ if (meta?.kind === "claude") {
3189
+ parts.push("--setting-sources", shq(""), "--settings", shq(meta.claude_settings ?? ""));
3190
+ }
3191
+ if (model)
3192
+ parts.push("--model", shq(model));
3193
+ if (effort)
3194
+ parts.push("--effort", shq(effort));
3195
+ }
3196
+ else if (kind === "codex") {
2421
3197
  if (meta?.kind === "codex")
2422
3198
  parts.push("--dangerously-bypass-hook-trust");
2423
3199
  // model/effort は CLI 引数で明示(config 継承より優先)。agent_done 時は managed home 側
@@ -2450,12 +3226,16 @@ function agentEnvPrefix(meta, sid) {
2450
3226
  `AITERM_AGENT_SESSION_ID=${shq(sid)}`,
2451
3227
  `AITERM_AGENT_LAUNCH_ID=${shq(meta.launch_id)}`,
2452
3228
  ];
3229
+ if (meta.kind === "claude") {
3230
+ return common.join(" ") + " ";
3231
+ }
2453
3232
  if (meta.kind === "codex") {
2454
3233
  return [`CODEX_HOME=${shq(meta.codex_home ?? "")}`, ...common].join(" ") + " ";
2455
3234
  }
2456
3235
  return [
2457
3236
  `HOME=${shq(meta.home ?? "")}`,
2458
3237
  `GROK_HOME=${shq(meta.grok_home ?? "")}`,
3238
+ ...(meta.grok_auth_path ? [`GROK_AUTH_PATH=${shq(meta.grok_auth_path)}`] : []),
2459
3239
  "GROK_DISABLE_AUTOUPDATER=1",
2460
3240
  "GROK_CLAUDE_HOOKS_ENABLED=false",
2461
3241
  "GROK_CURSOR_HOOKS_ENABLED=false",
@@ -2472,16 +3252,21 @@ function agentEnvPrefix(meta, sid) {
2472
3252
  ].join(" ") + " ";
2473
3253
  }
2474
3254
  function agentLabel(kind) {
2475
- return kind === "composer"
2476
- ? "Grok Build(Composer)"
2477
- : kind === "grok"
2478
- ? "Grok Build(Grok)"
2479
- : "Codex";
3255
+ return kind === "claude"
3256
+ ? "Claude Code"
3257
+ : kind === "composer"
3258
+ ? "Grok Build(Composer)"
3259
+ : kind === "grok"
3260
+ ? "Grok Build(Grok)"
3261
+ : "Codex";
2480
3262
  }
2481
3263
  // 起動応答にモデル/effort の実効値と出所を明示する。codex は端末 config のピン(model /
2482
3264
  // model_reasoning_effort)が対話子へ波及する構造のため、引数・端末config継承・CLI既定の
2483
3265
  // どれで起動したかを起動時点で可視化し、実効 effort=ultra は proactive 自動委譲 ON を警告する。
2484
3266
  function buildAgentLaunchNote(kind, model, effort, meta) {
3267
+ if (kind === "claude") {
3268
+ return `起動設定: model=${model ?? "CLI既定"} effort=${effort ?? "CLI既定"}。`;
3269
+ }
2485
3270
  if (kind !== "codex") {
2486
3271
  return (`起動設定: model=${model ?? GROK_MODEL_DEFAULTS[kind]}(${model ? "引数" : "ツール既定"})。` +
2487
3272
  "reasoning effort は対話 TUI 非対応=未指定で起動。");
@@ -2505,6 +3290,35 @@ function buildAgentLaunchNote(kind, model, effort, meta) {
2505
3290
  const summary = meta?.kind === "codex" && meta.codex_home ? managedCodexConfigSummary(configPath, true) : "";
2506
3291
  return summary ? `${launch}\n${summary}\n` : launch;
2507
3292
  }
3293
+ function claudeLaunchRequestDigest({ sessionName, model, effort, cwd, agentDone, }) {
3294
+ const canonical = JSON.stringify({
3295
+ schema: "aiterm.claude-agent-launch-request.v1",
3296
+ provider: "claude",
3297
+ session_name: sessionName,
3298
+ model,
3299
+ reasoning_effort: effort,
3300
+ cwd,
3301
+ managed_completion: agentDone,
3302
+ });
3303
+ return `sha256:${createHash("sha256").update(canonical, "utf8").digest("hex")}`;
3304
+ }
3305
+ function requireMatchingClaudeLaunch(name, operationId, requestDigest) {
3306
+ const meta = loadAgentMetadata(name);
3307
+ if (meta.kind !== "claude") {
3308
+ throw new AitermError(`session '${name}' は相関対象のClaude agent sessionではありません`, 2);
3309
+ }
3310
+ if (meta.launch_operation_id !== operationId || meta.launch_request_digest !== requestDigest) {
3311
+ throw new AitermError(`session '${name}' のClaude launch identityが一致しません。既存sessionをcloseするまで再利用できません`, 2);
3312
+ }
3313
+ return meta;
3314
+ }
3315
+ function existingAgentLaunchResult(kind, sid, model, effort) {
3316
+ return [
3317
+ sid,
3318
+ `${agentLabel(kind)} の相関済みlaunchを既存session ${sid} から回収した。` +
3319
+ `${buildAgentLaunchNote(kind, model, effort, null)}CLIは再送していません。\n${attachHint(sid)}`,
3320
+ ];
3321
+ }
2508
3322
  export function openAgent(kind, opts = {}) {
2509
3323
  const label = agentLabel(kind);
2510
3324
  // 前提検証は session を作る前に全部済ませる(失敗の残骸 session を作らない)。
@@ -2516,6 +3330,9 @@ export function openAgent(kind, opts = {}) {
2516
3330
  throw new AitermError("model が空文字です(省略するか有効なモデル名を指定してください)", 2);
2517
3331
  }
2518
3332
  const effort = opts.reasoning_effort ?? null;
3333
+ if (effort && kind === "claude" && !CLAUDE_EFFORTS.has(effort)) {
3334
+ throw new AitermError("Claude Code の reasoning_effort は low/medium/high/xhigh/max のいずれかです", 2);
3335
+ }
2519
3336
  // grok CLI の --effort は headless(grok -p)専用で、対話 TUI では警告の上無視される。
2520
3337
  // 黙って no-op の引数を受けない=起動前に明示エラーで拒否する(codex は CLI 側の値集合が
2521
3338
  // 版で変わるため縛らず送信まで通す)。
@@ -2529,6 +3346,24 @@ export function openAgent(kind, opts = {}) {
2529
3346
  "(モデルカタログ supports_reasoning_effort=false)", 2);
2530
3347
  }
2531
3348
  const agentDone = !!opts.agent_done;
3349
+ const launchOperationId = opts.launch_operation_id == null
3350
+ ? null
3351
+ : validateOperationId(opts.launch_operation_id);
3352
+ if (launchOperationId !== null) {
3353
+ if (kind !== "claude") {
3354
+ throw new AitermError("launch_operation_idはClaude agentだけで指定できます", 2);
3355
+ }
3356
+ if (!opts.session_name) {
3357
+ throw new AitermError("launch_operation_idには明示session_nameが必要です", 2);
3358
+ }
3359
+ assertSessionName(opts.session_name);
3360
+ if (!agentDone) {
3361
+ throw new AitermError("launch_operation_idにはagent_done:trueが必要です", 2);
3362
+ }
3363
+ if (opts.prompt != null) {
3364
+ throw new AitermError("launch_operation_id付きlaunchにpromptは指定できません", 2);
3365
+ }
3366
+ }
2532
3367
  let bin;
2533
3368
  try {
2534
3369
  bin = resolveAgentBin(kind);
@@ -2537,7 +3372,7 @@ export function openAgent(kind, opts = {}) {
2537
3372
  ownTelemetryFailure("AITERM.VENDOR_LAUNCHER_FAILED", error, 2);
2538
3373
  }
2539
3374
  if (!bin) {
2540
- const where = kind === "codex" ? "~/.local/bin/codex" : "~/.grok/bin/grok";
3375
+ const where = kind === "claude" ? "~/.local/bin/claude" : kind === "codex" ? "~/.local/bin/codex" : "~/.grok/bin/grok";
2541
3376
  ownTelemetryFailure("AITERM.VENDOR_LAUNCHER_FAILED", new AitermError(`${label} の CLI が見つかりません(${where} か PATH が必要)`, 2), 2);
2542
3377
  }
2543
3378
  // cwd 検証(session を作る前に。cd 失敗はシェル内で静かに死に「起動した」と偽成功を返すため)。
@@ -2562,6 +3397,19 @@ export function openAgent(kind, opts = {}) {
2562
3397
  }
2563
3398
  cwd = opts.cwd;
2564
3399
  }
3400
+ const launchRequestDigest = launchOperationId === null
3401
+ ? null
3402
+ : claudeLaunchRequestDigest({
3403
+ sessionName: opts.session_name,
3404
+ model,
3405
+ effort,
3406
+ cwd,
3407
+ agentDone,
3408
+ });
3409
+ if (launchOperationId !== null && sessionExists(opts.session_name)) {
3410
+ requireMatchingClaudeLaunch(opts.session_name, launchOperationId, launchRequestDigest);
3411
+ return existingAgentLaunchResult("claude", opts.session_name, model, effort);
3412
+ }
2565
3413
  // Windows は起動コマンドが WSL 内 bash で走る(tmux ブリッジ)。bin/cwd を /mnt/c/... 形へ変換して
2566
3414
  // 渡す(ログの toWslPath と対称・A1)。前提: Windows 側に CLI を導入(resolveAgentBin が Windows
2567
3415
  // パスで解決)。toWslPath は session を作る前に呼ぶ=変換失敗(非ドライブパス)で残骸 session を残さない。
@@ -2569,13 +3417,27 @@ export function openAgent(kind, opts = {}) {
2569
3417
  // でしか確認できない(CI 非対象。docs/03_audit-sweep-2026-07.md 参照)。
2570
3418
  const binForCmd = isWin ? toWslPath(bin) : bin;
2571
3419
  const cwdForCmd = cwd && isWin ? toWslPath(cwd) : cwd;
2572
- const [sid, hint] = openSession(opts.session_name ?? null, "bash");
3420
+ const grokAuthPath = agentDone && (kind === "grok" || kind === "composer") ? resolveAndValidateGrokAuth(realGrokHome()) : null;
3421
+ let sid;
3422
+ let hint;
3423
+ try {
3424
+ [sid, hint] = openSession(opts.session_name ?? null, "bash");
3425
+ }
3426
+ catch (error) {
3427
+ if (launchOperationId !== null && sessionExists(opts.session_name)) {
3428
+ requireMatchingClaudeLaunch(opts.session_name, launchOperationId, launchRequestDigest);
3429
+ return existingAgentLaunchResult("claude", opts.session_name, model, effort);
3430
+ }
3431
+ throw error;
3432
+ }
2573
3433
  let launchNote = "";
2574
3434
  try {
2575
3435
  const meta = agentDone
2576
- ? kind === "codex"
2577
- ? createCodexAgentMetadata(sid, cwd, opts.prompt ? "pending" : "none", { model, effort })
2578
- : createGrokAgentMetadata(kind, sid, cwd, opts.prompt ? "pending" : "none")
3436
+ ? kind === "claude"
3437
+ ? createClaudeAgentMetadata(sid, cwd, opts.prompt ? "pending" : "none", launchOperationId, launchRequestDigest)
3438
+ : kind === "codex"
3439
+ ? createCodexAgentMetadata(sid, cwd, opts.prompt ? "pending" : "none", { model, effort })
3440
+ : createGrokAgentMetadata(kind, sid, cwd, opts.prompt ? "pending" : "none", grokAuthPath)
2579
3441
  : null;
2580
3442
  if (meta)
2581
3443
  agentMetadataNegativeCache.delete(sid);
@@ -2586,7 +3448,14 @@ export function openAgent(kind, opts = {}) {
2586
3448
  // force:true で送る。起動骨格は `bin '...'` の固定形で、prompt/cwd/effort は shq でクオート済みの
2587
3449
  // 引数=シェルは決して破壊コマンドとして実行しない。破壊ゲート(生シェルコマンド想定)を prompt に
2588
3450
  // 掛けるのは純誤検知で、`codex 'rm -rf / を説明して'` 等の正当な起動を塞いでしまう(A4)。
2589
- send(sid, full, { enter: true, mark: false, force: true, rtk: false, raw: true });
3451
+ send(sid, full, {
3452
+ enter: true,
3453
+ mark: false,
3454
+ force: true,
3455
+ rtk: false,
3456
+ raw: true,
3457
+ preserveAgentOperation: meta?.kind === "claude",
3458
+ });
2590
3459
  }
2591
3460
  catch (e) {
2592
3461
  const failure = telemetryOwnedFailure("AITERM.VENDOR_LAUNCHER_FAILED", e);
@@ -2599,12 +3468,17 @@ export function openAgent(kind, opts = {}) {
2599
3468
  }
2600
3469
  throw failure;
2601
3470
  }
3471
+ const driveHint = agentDone && kind === "claude"
3472
+ ? `TUI の描画には数秒かかる。少し置いてから pty_read(${sid}, screen:true) で画面を読み、` +
3473
+ `turnはpty_send(${sid}, "...", wait:"agent_done")で送る。中断はpty_key(${sid}, "C-c")、` +
3474
+ `Stopが来ない場合の解除はpty_close(${sid})を使う。`
3475
+ : `TUI の描画には数秒かかる。少し置いてから pty_read(${sid}, screen:true) で画面を読み、` +
3476
+ `pty_send(${sid}, "...") で入力・pty_key(${sid}, "Enter"/"Up"/"C-c" 等) で操作する(対話)。`;
2602
3477
  return [
2603
3478
  sid,
2604
3479
  `${label} を session ${sid} で起動した。${launchNote}${agentDone ? "agent_done 待機が有効。" : ""}` +
2605
- `${agentDone && kind !== "codex" ? " hook 汚染防止のため Grok 実行中は一時 HOME を使う。" : ""}\n${hint}\n` +
2606
- `TUI の描画には数秒かかる。少し置いてから pty_read(${sid}, screen:true) で画面を読み、` +
2607
- `pty_send(${sid}, "...") で入力・pty_key(${sid}, "Enter"/"Up"/"C-c" 等) で操作する(対話)。` +
3480
+ `${agentDone && (kind === "grok" || kind === "composer") ? " hook 汚染防止のため Grok 実行中は一時 HOME を使う。" : ""}\n${hint}\n` +
3481
+ driveHint +
2608
3482
  `起動直後に増分 pty_read すると空/半描画になり得るので screen:true を使う。`,
2609
3483
  ];
2610
3484
  }
@@ -2621,6 +3495,9 @@ export async function openAgentWithInitialPrompt(kind, opts = {}) {
2621
3495
  const waitMode = normalizeAgentLauncherWait(opts.wait);
2622
3496
  const prompt = opts.prompt ?? null;
2623
3497
  const agentDone = !!opts.agent_done;
3498
+ if (opts.launch_operation_id != null && (prompt !== null || waitMode !== "none")) {
3499
+ throw new AitermError('launch_operation_idはpromptなし・wait:"none"のmanaged Claude launchだけで指定できます', 2);
3500
+ }
2624
3501
  if (waitMode === "agent_done" && !prompt) {
2625
3502
  throw new AitermError('wait:"agent_done" は prompt 指定時だけ使えます', 2);
2626
3503
  }
@@ -2630,7 +3507,7 @@ export async function openAgentWithInitialPrompt(kind, opts = {}) {
2630
3507
  if (!prompt) {
2631
3508
  return openAgent(kind, opts);
2632
3509
  }
2633
- if (kind !== "codex") {
3510
+ if (kind !== "codex" && kind !== "claude") {
2634
3511
  if (waitMode === "agent_done") {
2635
3512
  throw new AitermError(`${agentLabel(kind)} の起動時 prompt wait は未対応です。` +
2636
3513
  `agent_done:true で prompt なし起動後、TUI のログイン/ready を確認してから pty_send(wait:"agent_done") を使ってください。`, 2);
@@ -2644,6 +3521,7 @@ export async function openAgentWithInitialPrompt(kind, opts = {}) {
2644
3521
  cwd: opts.cwd ?? null,
2645
3522
  prompt: null,
2646
3523
  agent_done: agentDone,
3524
+ launch_operation_id: opts.launch_operation_id ?? null,
2647
3525
  });
2648
3526
  try {
2649
3527
  const initial = agentDone