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/README.ja.md +31 -23
- package/README.md +63 -22
- package/dist/aiterm-wait-cli.js +94 -0
- package/dist/claude-stop-hook.js +237 -0
- package/dist/core.js +1027 -149
- package/dist/index.js +133 -28
- package/dist/runtime-error-store.js +15 -7
- package/package.json +4 -3
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
|
|
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],
|
|
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],
|
|
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") ||
|
|
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
|
-
|
|
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
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
|
|
837
|
-
|
|
942
|
+
else {
|
|
943
|
+
// 非 mark 送信は、未消化の古い mark 完了待ちを無効化する(前コマンドの sentinel を待ち続けない)。
|
|
944
|
+
try {
|
|
945
|
+
fs.unlinkSync(markpath(name));
|
|
946
|
+
}
|
|
947
|
+
catch {
|
|
948
|
+
/* noop */
|
|
949
|
+
}
|
|
838
950
|
}
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
//
|
|
842
|
-
|
|
843
|
-
|
|
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
|
-
|
|
846
|
-
|
|
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
|
|
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
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
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
|
-
|
|
1424
|
-
|
|
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
|
|
1427
|
-
|
|
1428
|
-
|
|
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
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
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
|
|
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
|
-
|
|
1440
|
-
if (
|
|
1441
|
-
|
|
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
|
-
|
|
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 === "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 === "
|
|
2361
|
-
? ["
|
|
2362
|
-
:
|
|
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 === "
|
|
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 === "
|
|
2476
|
-
? "
|
|
2477
|
-
: kind === "
|
|
2478
|
-
? "Grok Build(
|
|
2479
|
-
: "
|
|
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
|
|
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 === "
|
|
2577
|
-
?
|
|
2578
|
-
:
|
|
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, {
|
|
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
|
|
2606
|
-
|
|
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
|