aiterm-mcp 0.7.1 → 0.9.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 +141 -66
- package/README.md +140 -65
- package/dist/codex-stop-hook.js +124 -0
- package/dist/core.js +1197 -45
- package/dist/grok-stop-hook.js +120 -0
- package/dist/index.js +58 -10
- package/dist/rtk.js +53 -18
- package/package.json +5 -2
package/dist/core.js
CHANGED
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
* トークン削減して受け取る。SSH/docker は専用機能にせず send(id, "ssh host") で中に入る(ネスト)。
|
|
6
6
|
* セッションは tmux サーバ常駐ゆえ、本プロセスが毎回終了しても次回 read で再接続できる。
|
|
7
7
|
*
|
|
8
|
-
* 設計: docs/
|
|
8
|
+
* 設計: docs/01_design-plan.md / docs/02_mcp-plan.md。出力削減は rag/ の RTK を移植。
|
|
9
9
|
*/
|
|
10
10
|
import { spawnSync } from "node:child_process";
|
|
11
11
|
import * as fs from "node:fs";
|
|
12
12
|
import * as path from "node:path";
|
|
13
13
|
import * as os from "node:os";
|
|
14
14
|
import { createHash, randomBytes } from "node:crypto";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
15
16
|
import * as rtk from "./rtk.js";
|
|
16
17
|
// Windows ネイティブには tmux が無い。その場合だけ全 tmux 呼び出しを WSL 経由へ橋渡しする
|
|
17
18
|
// (POSIX = Linux/WSL2/macOS は従来どおり tmux を直接叩く)。
|
|
@@ -31,14 +32,37 @@ export const DEFAULT_TIMEOUT = 10.0;
|
|
|
31
32
|
const POLL = 0.25;
|
|
32
33
|
const STABLE_POLLS = 2; // 連続でログサイズ不変ならば静止とみなす回数
|
|
33
34
|
const SHELLS = new Set(["bash", "sh", "zsh", "fish", "dash"]);
|
|
35
|
+
// mark の sentinel は POSIX シェル構文(; と "$?")に依存する。これらの非 POSIX 対話シェルが
|
|
36
|
+
// 前面のときは "$?" が正しく展開されず sentinel が壊れるので mark を拒否する(B8)。ssh/docker で
|
|
37
|
+
// リモート POSIX シェルに入っている場合は前面が "ssh"/"docker" 等で本集合に含まれず=許可される。
|
|
38
|
+
const NON_POSIX_MARK_SHELLS = new Set(["fish", "csh", "tcsh"]);
|
|
39
|
+
// mark:true が付ける完了 sentinel の検出正規表現。printf の実出力は rc=<数字>、コマンド行の
|
|
40
|
+
// エコーは rc=%d(リテラル)。数字アンカーでエコーに免疫化し、部分一致による早期誤完了を防ぐ(B1)。
|
|
41
|
+
// send の printf 書式(`rc=%d`)と対で保守すること。
|
|
42
|
+
const MARK_DONE_RE = /<<<AITERM_DONE rc=[0-9]+>>>/;
|
|
43
|
+
const LAUNCH_ID_RE = /^[0-9a-f]{32}$/;
|
|
44
|
+
const AGENT_DONE_POLL_MS = 100;
|
|
45
|
+
const AGENT_DONE_SETTLE_MIN_MS = 250;
|
|
46
|
+
const AGENT_DONE_SCREEN_SETTLE_POLL_MS = 100;
|
|
47
|
+
const AGENT_DONE_SCREEN_SETTLE_MAX_POLLS = 5;
|
|
48
|
+
const AGENT_DONE_SCREEN_SETTLE_MIN_SAMPLES = 3;
|
|
49
|
+
const AGENT_SUBMIT_DELAY_MS = 250;
|
|
50
|
+
const AGENT_EVENT_MAX_BYTES = 1024 * 1024;
|
|
51
|
+
const AGENT_TUI_READY_TIMEOUT_MS = 30_000;
|
|
52
|
+
const AGENT_TUI_READY_POLL_MS = 500;
|
|
53
|
+
const AGENT_TUI_READY_LINES = 45;
|
|
34
54
|
// 出力削減(RTK の CAP 思想を移植)
|
|
35
55
|
const MAX_LINES_BEFORE_ELIDE = 60;
|
|
36
56
|
const HEAD_LINES = 30;
|
|
37
57
|
const TAIL_LINES = 20;
|
|
38
58
|
const DEDUP_MIN_RUN = 3; // 同一行がこれ以上連続したら 1 行+件数に畳む
|
|
59
|
+
const MAX_FULL_BYTES = 8 * 1024 * 1024; // full/range 読取で一度にメモリへ載せる上限(B7)
|
|
39
60
|
// 安全: send 前に弾く破壊的コマンド(外部システム境界の防御)
|
|
40
61
|
const DESTRUCTIVE = [
|
|
41
|
-
|
|
62
|
+
// rm -rf の危険な対象形(best-effort・force で越えられる)。先頭の任意クオート ['"]? で
|
|
63
|
+
// `rm -rf "/"` / `'/'` / `"~"` を捕捉。`\.\/\*`=./*(相対 glob)、`\.\.?\/?\s*$`=. / .. / ./ / ../
|
|
64
|
+
// (カレント/親そのもの)。`./build` 等の相対サブディレクトリは末尾でないので非該当(過剰ブロック回避)。
|
|
65
|
+
/\brm\s+-[rfRF]*[rf][rfRF]*\s+['"]?(\/|~|\$HOME|\.\/\*|\.\.?\/?\s*$|\*\s*$)/i,
|
|
42
66
|
/\bmkfs(\.\w+)?\b/i,
|
|
43
67
|
/\bdd\b[^\n]*\bof=\/dev\//i,
|
|
44
68
|
/>\s*\/dev\/(sd|nvme|hd|mmcblk)/i,
|
|
@@ -50,7 +74,8 @@ const DESTRUCTIVE = [
|
|
|
50
74
|
/\bgit\s+reset\s+--hard\b/i,
|
|
51
75
|
];
|
|
52
76
|
// CSI/OSC/ESC エスケープ・制御文字
|
|
53
|
-
|
|
77
|
+
// CSI / OSC(BEL or ST 終端) / DCS・PM・APC・SOS(ESC P/^/_/X … BEL or ST 終端。ペイロード本文ごと除去=B10) / 残る2文字エスケープ
|
|
78
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[ -\/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[P^_X][\s\S]*?(?:\x07|\x1b\\)|\x1b[@-_]/g;
|
|
54
79
|
const CTRL_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g; // \t(09) \n(0a) は残す
|
|
55
80
|
const PASTE_MARKERS_RE = /\x1b\[20[01]~/g;
|
|
56
81
|
// よく使う制御キーの別名(tmux のキー名へ)
|
|
@@ -180,6 +205,141 @@ function assertSessionName(name) {
|
|
|
180
205
|
if (!/^[A-Za-z0-9_-]{1,64}$/.test(name))
|
|
181
206
|
throw new AitermError(`session 名は英数字と _ - のみ・64文字以内にしてください: ${JSON.stringify(name)}`, 2);
|
|
182
207
|
}
|
|
208
|
+
function currentUid() {
|
|
209
|
+
if (typeof process.getuid !== "function") {
|
|
210
|
+
throw new AitermError("agent_done は POSIX/macOS/Linux のみ対応です(native Windows は未対応)", 2);
|
|
211
|
+
}
|
|
212
|
+
return process.getuid();
|
|
213
|
+
}
|
|
214
|
+
function runtimeStateBase() {
|
|
215
|
+
const xdg = process.env.XDG_RUNTIME_DIR;
|
|
216
|
+
if (xdg) {
|
|
217
|
+
try {
|
|
218
|
+
if (fs.statSync(xdg).isDirectory())
|
|
219
|
+
return xdg;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
/* XDG_RUNTIME_DIR が壊れている CI/非 login 環境では os.tmpdir() に戻す */
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return os.tmpdir();
|
|
226
|
+
}
|
|
227
|
+
function stateRoot() {
|
|
228
|
+
const uid = currentUid();
|
|
229
|
+
const base = runtimeStateBase();
|
|
230
|
+
return path.join(base, `aiterm-mcp-${uid}`);
|
|
231
|
+
}
|
|
232
|
+
function ensureSecureStateRoot() {
|
|
233
|
+
const root = stateRoot();
|
|
234
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
235
|
+
const st = fs.lstatSync(root);
|
|
236
|
+
if (!st.isDirectory() || st.isSymbolicLink()) {
|
|
237
|
+
throw new AitermError(`agent state root が安全な directory ではありません: ${root}`, 2);
|
|
238
|
+
}
|
|
239
|
+
if (st.uid !== currentUid()) {
|
|
240
|
+
throw new AitermError(`agent state root の owner が現在ユーザーではありません: ${root}`, 2);
|
|
241
|
+
}
|
|
242
|
+
if ((st.mode & 0o077) !== 0) {
|
|
243
|
+
fs.chmodSync(root, 0o700);
|
|
244
|
+
}
|
|
245
|
+
const agents = path.join(root, "agents");
|
|
246
|
+
fs.mkdirSync(agents, { recursive: true, mode: 0o700 });
|
|
247
|
+
const ast = fs.lstatSync(agents);
|
|
248
|
+
if (!ast.isDirectory() || ast.isSymbolicLink() || ast.uid !== currentUid()) {
|
|
249
|
+
throw new AitermError(`agent state dir が安全な directory ではありません: ${agents}`, 2);
|
|
250
|
+
}
|
|
251
|
+
if ((ast.mode & 0o077) !== 0)
|
|
252
|
+
fs.chmodSync(agents, 0o700);
|
|
253
|
+
return root;
|
|
254
|
+
}
|
|
255
|
+
function agentsDir() {
|
|
256
|
+
return path.join(ensureSecureStateRoot(), "agents");
|
|
257
|
+
}
|
|
258
|
+
function agentEventPath(name, launchId) {
|
|
259
|
+
assertSessionName(name);
|
|
260
|
+
if (!LAUNCH_ID_RE.test(launchId))
|
|
261
|
+
throw new AitermError(`launch_id が不正です: ${launchId}`, 2);
|
|
262
|
+
return path.join(agentsDir(), `${name}.${launchId}.events.jsonl`);
|
|
263
|
+
}
|
|
264
|
+
function agentMetadataPath(name, launchId) {
|
|
265
|
+
assertSessionName(name);
|
|
266
|
+
if (!LAUNCH_ID_RE.test(launchId))
|
|
267
|
+
throw new AitermError(`launch_id が不正です: ${launchId}`, 2);
|
|
268
|
+
return path.join(agentsDir(), `${name}.${launchId}.agent.json`);
|
|
269
|
+
}
|
|
270
|
+
function agentWaitLockPath(name, launchId) {
|
|
271
|
+
assertSessionName(name);
|
|
272
|
+
if (!LAUNCH_ID_RE.test(launchId))
|
|
273
|
+
throw new AitermError(`launch_id が不正です: ${launchId}`, 2);
|
|
274
|
+
return path.join(agentsDir(), `${name}.${launchId}.wait.lock`);
|
|
275
|
+
}
|
|
276
|
+
function agentManagedCodexHomePath(name, launchId) {
|
|
277
|
+
assertSessionName(name);
|
|
278
|
+
if (!LAUNCH_ID_RE.test(launchId))
|
|
279
|
+
throw new AitermError(`launch_id が不正です: ${launchId}`, 2);
|
|
280
|
+
return path.join(agentsDir(), `${name}.${launchId}.codex-home`);
|
|
281
|
+
}
|
|
282
|
+
function agentManagedGrokHomePath(name, launchId) {
|
|
283
|
+
assertSessionName(name);
|
|
284
|
+
if (!LAUNCH_ID_RE.test(launchId))
|
|
285
|
+
throw new AitermError(`launch_id が不正です: ${launchId}`, 2);
|
|
286
|
+
return path.join(agentsDir(), `${name}.${launchId}.grok-home`);
|
|
287
|
+
}
|
|
288
|
+
function agentManagedGrokUserHomePath(name, launchId) {
|
|
289
|
+
assertSessionName(name);
|
|
290
|
+
if (!LAUNCH_ID_RE.test(launchId))
|
|
291
|
+
throw new AitermError(`launch_id が不正です: ${launchId}`, 2);
|
|
292
|
+
return path.join(agentsDir(), `${name}.${launchId}.home`);
|
|
293
|
+
}
|
|
294
|
+
function existingAgentsDir() {
|
|
295
|
+
if (typeof process.getuid !== "function")
|
|
296
|
+
return null;
|
|
297
|
+
const root = path.join(runtimeStateBase(), `aiterm-mcp-${process.getuid()}`);
|
|
298
|
+
const dir = path.join(root, "agents");
|
|
299
|
+
try {
|
|
300
|
+
const rst = fs.lstatSync(root);
|
|
301
|
+
if (!rst.isDirectory() || rst.isSymbolicLink() || rst.uid !== process.getuid())
|
|
302
|
+
return null;
|
|
303
|
+
if ((rst.mode & 0o077) !== 0)
|
|
304
|
+
fs.chmodSync(root, 0o700);
|
|
305
|
+
const st = fs.lstatSync(dir);
|
|
306
|
+
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid())
|
|
307
|
+
return null;
|
|
308
|
+
if ((st.mode & 0o077) !== 0)
|
|
309
|
+
fs.chmodSync(dir, 0o700);
|
|
310
|
+
return dir;
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function cleanupAgentState(name) {
|
|
317
|
+
assertSessionName(name);
|
|
318
|
+
const dir = existingAgentsDir();
|
|
319
|
+
if (!dir)
|
|
320
|
+
return;
|
|
321
|
+
const prefix = `${name}.`;
|
|
322
|
+
try {
|
|
323
|
+
for (const f of fs.readdirSync(dir)) {
|
|
324
|
+
if (!f.startsWith(prefix))
|
|
325
|
+
continue;
|
|
326
|
+
const p = path.join(dir, f);
|
|
327
|
+
try {
|
|
328
|
+
if (f.endsWith(".agent.json") || f.endsWith(".events.jsonl") || f.endsWith(".wait.lock"))
|
|
329
|
+
fs.unlinkSync(p);
|
|
330
|
+
else if (f.endsWith(".codex-home") || f.endsWith(".grok-home") || f.endsWith(".home")) {
|
|
331
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
/* stale agent state cleanup is best-effort */
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
/* stale agent state cleanup is best-effort */
|
|
341
|
+
}
|
|
342
|
+
}
|
|
183
343
|
function logpath(name) {
|
|
184
344
|
return path.join(SOCKDIR, name + ".log");
|
|
185
345
|
}
|
|
@@ -189,6 +349,10 @@ function offsetpath(name) {
|
|
|
189
349
|
function lastcmdpath(name) {
|
|
190
350
|
return path.join(SOCKDIR, name + ".lastcmd");
|
|
191
351
|
}
|
|
352
|
+
// mark:true 送信中フラグ。存在すれば waitCompletion が sentinel 完了検出(MARK_DONE_RE)を有効化する。
|
|
353
|
+
function markpath(name) {
|
|
354
|
+
return path.join(SOCKDIR, name + ".mark");
|
|
355
|
+
}
|
|
192
356
|
function readOffset(name) {
|
|
193
357
|
try {
|
|
194
358
|
const n = parseInt(fs.readFileSync(offsetpath(name), "utf8").trim(), 10);
|
|
@@ -337,16 +501,35 @@ async function settleWinLog(name) {
|
|
|
337
501
|
await sleep(POLL * 1000);
|
|
338
502
|
}
|
|
339
503
|
}
|
|
340
|
-
/** 完了境界: dead / until 一致 / (出力静止 ∧ シェル復帰)=quiescent / (ネスト中+until無しで出力静止)=nested(未確定・早期返却) / timeout。 */
|
|
341
|
-
async function waitCompletion(name,
|
|
504
|
+
/** 完了境界: dead / mark sentinel 一致 / until 一致 / (出力静止 ∧ シェル復帰)=quiescent / (ネスト中+until/mark無しで出力静止)=nested(未確定・早期返却) / timeout。 */
|
|
505
|
+
async function waitCompletion(name, untilStr, untilRegex, timeout) {
|
|
342
506
|
// 締切は単調時計で測る。Date.now() は NTP 補正やサスペンドで巻き戻り、長時間待ちで誤判定する(Python は time.monotonic)。
|
|
343
507
|
const deadline = performance.now() + timeout * 1000;
|
|
344
508
|
const start = readOffset(name);
|
|
345
509
|
let lastSize = null;
|
|
346
510
|
let stable = 0;
|
|
347
|
-
|
|
511
|
+
// until は既定でリテラル部分一致(`$ ` や `[sudo]` 等がメタ化して永遠に待つ footgun を避ける・B4)。
|
|
512
|
+
// untilRegex:true のときだけ正規表現として解釈し、不正パターンは明示エラーにする(B13 の until 部分)。
|
|
513
|
+
let until = null;
|
|
514
|
+
if (untilStr) {
|
|
515
|
+
if (untilRegex) {
|
|
516
|
+
let re;
|
|
517
|
+
try {
|
|
518
|
+
re = new RegExp(untilStr);
|
|
519
|
+
}
|
|
520
|
+
catch (e) {
|
|
521
|
+
throw new AitermError(`until 正規表現が不正です: ${JSON.stringify(untilStr)}(${e.message})`, 2);
|
|
522
|
+
}
|
|
523
|
+
until = (s) => re.test(s);
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
until = (s) => s.includes(untilStr);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
// mark:true 送信中なら sentinel 完了検出を有効化する。エコー(rc=%d)でなく実出力(rc=<数字>)だけに
|
|
530
|
+
// 一致する数字アンカー MARK_DONE_RE を使うため、until のようにエコー部分一致で早期誤完了しない(B1)。
|
|
531
|
+
const markActive = fs.existsSync(markpath(name));
|
|
348
532
|
for (;;) {
|
|
349
|
-
const alive = sessionExists(name);
|
|
350
533
|
let size = 0;
|
|
351
534
|
try {
|
|
352
535
|
size = fs.statSync(logpath(name)).size;
|
|
@@ -354,13 +537,49 @@ async function waitCompletion(name, untilRe, timeout) {
|
|
|
354
537
|
catch {
|
|
355
538
|
size = 0;
|
|
356
539
|
}
|
|
357
|
-
if (until && size > start) {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
540
|
+
if ((until || markActive) && size > start) {
|
|
541
|
+
// 増分 [start, size] だけを fd で読む(毎 poll で全ファイルを読む O(n^2) を避ける・B6)。
|
|
542
|
+
let neu = "";
|
|
543
|
+
let fd;
|
|
544
|
+
try {
|
|
545
|
+
fd = fs.openSync(logpath(name), "r");
|
|
546
|
+
const len = size - start;
|
|
547
|
+
const buf = Buffer.alloc(len);
|
|
548
|
+
const n = fs.readSync(fd, buf, 0, len, start);
|
|
549
|
+
neu = stripControl(buf.subarray(0, Math.max(0, n)).toString("utf8"));
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
neu = ""; // close 等でログが消えた: 次周回の !alive/statSync で決着させる
|
|
553
|
+
}
|
|
554
|
+
finally {
|
|
555
|
+
if (fd !== undefined) {
|
|
556
|
+
try {
|
|
557
|
+
fs.closeSync(fd);
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
/* noop */
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
// mark を until より先に判定(sentinel は確証つき完了。until はユーザ指定でエコー誤爆余地あり)。
|
|
565
|
+
if (markActive && MARK_DONE_RE.test(neu)) {
|
|
566
|
+
try {
|
|
567
|
+
fs.unlinkSync(markpath(name)); // 同一 sentinel での再発火を防ぐ
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
/* noop */
|
|
571
|
+
}
|
|
572
|
+
if (isWin)
|
|
573
|
+
await settleWinLog(name);
|
|
574
|
+
return [true, "mark"];
|
|
575
|
+
}
|
|
576
|
+
if (until && until(neu))
|
|
361
577
|
return [true, "until"];
|
|
362
578
|
}
|
|
363
|
-
|
|
579
|
+
// 出力が伸びている間は生存確認(tmux has-session の spawn)を省く=伸長は生存の証(B6)。
|
|
580
|
+
// 静止時のみ has-session を叩いて dead を判定する。dead 検出は最大 1 poll 遅れるだけ。
|
|
581
|
+
const growing = lastSize !== null && size > lastSize;
|
|
582
|
+
if (!growing && !sessionExists(name)) {
|
|
364
583
|
if (isWin)
|
|
365
584
|
await settleWinLog(name);
|
|
366
585
|
return [true, "dead"];
|
|
@@ -375,10 +594,11 @@ async function waitCompletion(name, untilRe, timeout) {
|
|
|
375
594
|
return [true, "quiescent"]; // 出力静止 ∧ シェル復帰 = 確証つき完了
|
|
376
595
|
}
|
|
377
596
|
// ネスト中(前面が ssh/docker/REPL 等でシェル集合外)は quiescence の「シェル復帰」条件を
|
|
378
|
-
// 原理的に満たせない。until
|
|
379
|
-
//
|
|
597
|
+
// 原理的に満たせない。until も mark も無ければこれ以上待っても確証は増えない(until/dead/
|
|
598
|
+
// quiescent/mark のいずれも発火し得ない)ので、出力静止時点で「未確定」のまま早期返却する。
|
|
599
|
+
// markActive のときは sentinel を待つべく早期返却せず、非シェル前面(sleep 等)でも待ち続ける。
|
|
380
600
|
// fg==="" は前面コマンド取得失敗=ネスト断定不可なので早期返却せず従来どおり timeout まで待つ。
|
|
381
|
-
if (!until && fg !== "") {
|
|
601
|
+
if (!until && !markActive && fg !== "") {
|
|
382
602
|
if (isWin)
|
|
383
603
|
await settleWinLog(name);
|
|
384
604
|
return [false, "nested"];
|
|
@@ -394,10 +614,10 @@ async function waitCompletion(name, untilRe, timeout) {
|
|
|
394
614
|
await sleep(POLL * 1000);
|
|
395
615
|
}
|
|
396
616
|
}
|
|
397
|
-
// 完了ステータス → is_complete 表記。確証のある層のみ True(until/dead/quiescent)。
|
|
617
|
+
// 完了ステータス → is_complete 表記。確証のある層のみ True(mark/until/dead/quiescent)。
|
|
398
618
|
// timeout と nested(ネスト中・出力静止だが確証なし)は False。nested は until/mark を促す注記を添える。
|
|
399
619
|
function completionSuffix(status) {
|
|
400
|
-
const complete = status === "until" || status === "dead" || status === "quiescent";
|
|
620
|
+
const complete = status === "mark" || status === "until" || status === "dead" || status === "quiescent";
|
|
401
621
|
let s = ` [is_complete=${complete ? "True" : "False"} via ${status}]`;
|
|
402
622
|
if (status === "nested")
|
|
403
623
|
s +=
|
|
@@ -453,7 +673,19 @@ export function openSession(name, shell = "bash") {
|
|
|
453
673
|
nm = nonceName();
|
|
454
674
|
assertSessionName(nm);
|
|
455
675
|
}
|
|
456
|
-
|
|
676
|
+
// 新規セッションの .log は必ず truncate する。"a"(追記)だと外部 kill / killAll / クラッシュで
|
|
677
|
+
// 同名 session だけ消えて .log が残った場合、offset=0 と相まって旧出力を新規として返す(B5)。
|
|
678
|
+
// break は new-session 成功後にのみ到達=作りたての空 session ゆえ切り詰めは安全。lastcmd/mark 残骸も掃除。
|
|
679
|
+
fs.writeFileSync(logpath(nm), "");
|
|
680
|
+
for (const p of [lastcmdpath(nm), markpath(nm)]) {
|
|
681
|
+
try {
|
|
682
|
+
fs.unlinkSync(p);
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
/* noop */
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
cleanupAgentState(nm);
|
|
457
689
|
// pipe-pane の引数は tmux 内部の /bin/sh -c で再解釈される(argv ではない)。パスは単一引用符で包み、
|
|
458
690
|
// パス自身の ' は '\'' イディオムでエスケープする(名前は検証済みだが、Windows ユーザー名 O'Brien 等が
|
|
459
691
|
// 一時パスに ' を持ち込み redirect を壊すのを防ぐ。空白対策も兼ねる)。Windows は WSL から見える /mnt/c 形へ。
|
|
@@ -463,6 +695,12 @@ export function openSession(name, shell = "bash") {
|
|
|
463
695
|
if (pr.code !== 0) {
|
|
464
696
|
// 配管に失敗した session は pty_read が永遠に空を返す=成功を装わない。作った session を片付けて明示エラー。
|
|
465
697
|
tmux("kill-session", "-t", nm);
|
|
698
|
+
try {
|
|
699
|
+
fs.unlinkSync(logpath(nm)); // B14: 直前に作った空 .log も残さない
|
|
700
|
+
}
|
|
701
|
+
catch {
|
|
702
|
+
/* noop */
|
|
703
|
+
}
|
|
466
704
|
throw new AitermError("tmux pipe-pane 失敗(出力ログを配管できないため session を破棄): " + pr.stderr.trim(), 2);
|
|
467
705
|
}
|
|
468
706
|
writeOffset(nm, 0);
|
|
@@ -484,11 +722,37 @@ export function send(name, text, o = {}) {
|
|
|
484
722
|
}
|
|
485
723
|
}
|
|
486
724
|
}
|
|
725
|
+
if (o.mark) {
|
|
726
|
+
// mark の sentinel は POSIX シェル構文。前面が fish/csh/tcsh 等の非 POSIX 対話シェルだと "$?" が
|
|
727
|
+
// 壊れて sentinel が成立しない。黙って壊れた完了検出を作らず、明示エラーで until を促す(B8)。
|
|
728
|
+
const fg = paneCurrentCommand(name);
|
|
729
|
+
if (NON_POSIX_MARK_SHELLS.has(fg)) {
|
|
730
|
+
throw new AitermError(`mark は POSIX シェル(bash/sh/zsh/dash)前提です。前面が ${fg} のため sentinel の "$?" が` +
|
|
731
|
+
`正しく展開されません。until で完了パターンを指定してください。`, 2);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
487
734
|
writeLastcmd(name, text); // read rtk の reducer 分類用(書換/mark 前の素のコマンド)
|
|
488
735
|
if (o.rtk)
|
|
489
736
|
text = rtkRewrite(text);
|
|
490
|
-
if (o.mark)
|
|
737
|
+
if (o.mark) {
|
|
738
|
+
// 実出力は rc=<数字>、この行のエコーは rc=%d(リテラル)。MARK_DONE_RE は数字アンカーで後者に免疫。
|
|
491
739
|
text = text + `; printf '\\n<<<AITERM_DONE rc=%d>>>\\n' "$?"`;
|
|
740
|
+
try {
|
|
741
|
+
fs.writeFileSync(markpath(name), "1"); // waitCompletion に sentinel 完了検出を有効化させる
|
|
742
|
+
}
|
|
743
|
+
catch {
|
|
744
|
+
/* noop(フラグ書けなくても until/quiescence 経路は生きる) */
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
else {
|
|
748
|
+
// 非 mark 送信は、未消化の古い mark 完了待ちを無効化する(前コマンドの sentinel を待ち続けない)。
|
|
749
|
+
try {
|
|
750
|
+
fs.unlinkSync(markpath(name));
|
|
751
|
+
}
|
|
752
|
+
catch {
|
|
753
|
+
/* noop */
|
|
754
|
+
}
|
|
755
|
+
}
|
|
492
756
|
tmux("send-keys", "-t", name, "-l", "--", text);
|
|
493
757
|
if (enter)
|
|
494
758
|
tmux("send-keys", "-t", name, "Enter");
|
|
@@ -503,50 +767,121 @@ export function sendKey(name, key) {
|
|
|
503
767
|
tmux("send-keys", "-t", name, k);
|
|
504
768
|
return `sent key ${k} to ${name}`;
|
|
505
769
|
}
|
|
770
|
+
// end 以下で最大の UTF-8 文字境界を返す(B3)。末尾が不完全なマルチバイト列なら、その開始位置まで
|
|
771
|
+
// 戻す。pipe-pane が多バイト文字の途中でフラッシュした瞬間の増分読みで先頭/末尾が U+FFFD 化するのを防ぐ。
|
|
772
|
+
export function utf8SafeEnd(buf, end) {
|
|
773
|
+
if (end <= 0)
|
|
774
|
+
return 0;
|
|
775
|
+
const isCont = (b) => (b & 0xc0) === 0x80; // 継続バイト 10xxxxxx
|
|
776
|
+
let i = end - 1;
|
|
777
|
+
let steps = 0;
|
|
778
|
+
while (i >= 0 && isCont(buf[i]) && steps < 3) {
|
|
779
|
+
i--;
|
|
780
|
+
steps++;
|
|
781
|
+
}
|
|
782
|
+
if (i < 0)
|
|
783
|
+
return end; // 継続バイトのみの異常列: そのまま
|
|
784
|
+
const lead = buf[i];
|
|
785
|
+
let need;
|
|
786
|
+
if (lead < 0x80)
|
|
787
|
+
need = 1;
|
|
788
|
+
else if ((lead & 0xe0) === 0xc0)
|
|
789
|
+
need = 2;
|
|
790
|
+
else if ((lead & 0xf0) === 0xe0)
|
|
791
|
+
need = 3;
|
|
792
|
+
else if ((lead & 0xf8) === 0xf0)
|
|
793
|
+
need = 4;
|
|
794
|
+
else
|
|
795
|
+
return end; // 不正な先行バイト: そのまま(stripControl 等に委ねる)
|
|
796
|
+
return end - i >= need ? end : i; // 末尾文字が完結していれば end、不完全なら開始位置まで戻す
|
|
797
|
+
}
|
|
506
798
|
export async function readOutput(name, o = {}) {
|
|
507
799
|
assertSessionName(name);
|
|
508
800
|
const timeout = o.timeout ?? DEFAULT_TIMEOUT;
|
|
509
801
|
if (!sessionExists(name) && !fs.existsSync(logpath(name)))
|
|
510
802
|
throw new AitermError(`session '${name}' が無い`, 2);
|
|
803
|
+
// wait は screen より先に処理する。従来 screen は wait ブロックの手前で return し、screen+wait で
|
|
804
|
+
// 完了検出が黙殺されていた(B11)。先に待ってから最終スクリーンを撮る=TUI の描画完了後に読める。
|
|
805
|
+
let status = null;
|
|
806
|
+
if (o.wait) {
|
|
807
|
+
const [, st] = await waitCompletion(name, o.until ?? null, o.untilRegex ?? false, timeout);
|
|
808
|
+
status = st;
|
|
809
|
+
}
|
|
511
810
|
if (o.screen) {
|
|
512
811
|
const rawTxt = captureScreen(name, o.lines || 0);
|
|
513
812
|
if (o.raw)
|
|
514
813
|
return rawTxt;
|
|
515
814
|
const [body, meta] = reduceOutput(rawTxt, name, true);
|
|
516
|
-
return body + "\n" + meta;
|
|
517
|
-
}
|
|
518
|
-
let status = null;
|
|
519
|
-
if (o.wait) {
|
|
520
|
-
const [, st] = await waitCompletion(name, o.until ?? null, timeout);
|
|
521
|
-
status = st;
|
|
815
|
+
return body + "\n" + meta + (status ? completionSuffix(status) : "");
|
|
522
816
|
}
|
|
523
|
-
|
|
817
|
+
// ログ全体を毎回メモリに載せず、必要な範囲だけ fd で読む(B7)。size は statSync で取る。
|
|
818
|
+
let size = 0;
|
|
524
819
|
try {
|
|
525
|
-
|
|
820
|
+
size = fs.statSync(logpath(name)).size;
|
|
526
821
|
}
|
|
527
822
|
catch {
|
|
528
|
-
|
|
823
|
+
size = 0;
|
|
529
824
|
}
|
|
825
|
+
const readRange = (from, to) => {
|
|
826
|
+
const len = Math.max(0, to - from);
|
|
827
|
+
if (len === 0)
|
|
828
|
+
return Buffer.alloc(0);
|
|
829
|
+
let fd;
|
|
830
|
+
try {
|
|
831
|
+
fd = fs.openSync(logpath(name), "r");
|
|
832
|
+
const buf = Buffer.alloc(len);
|
|
833
|
+
const n = fs.readSync(fd, buf, 0, len, from);
|
|
834
|
+
return n === len ? buf : buf.subarray(0, Math.max(0, n));
|
|
835
|
+
}
|
|
836
|
+
catch {
|
|
837
|
+
return Buffer.alloc(0);
|
|
838
|
+
}
|
|
839
|
+
finally {
|
|
840
|
+
if (fd !== undefined) {
|
|
841
|
+
try {
|
|
842
|
+
fs.closeSync(fd);
|
|
843
|
+
}
|
|
844
|
+
catch {
|
|
845
|
+
/* noop */
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
};
|
|
530
850
|
let text;
|
|
851
|
+
let nextOffset = size; // 既定/full は offset を末尾へ
|
|
531
852
|
if (o.full || o.range) {
|
|
532
|
-
|
|
853
|
+
// full/range は全体が対象だが、巨大ログは末尾 MAX_FULL_BYTES に制限してメモリを守る(B7)。
|
|
854
|
+
let from = 0;
|
|
855
|
+
if (size > MAX_FULL_BYTES)
|
|
856
|
+
from = size - MAX_FULL_BYTES;
|
|
857
|
+
text = readRange(from, size).toString("utf8");
|
|
858
|
+
if (from > 0)
|
|
859
|
+
text = `[… 先頭 ${from} バイトを省略(ログがサイズ上限を超過。close で破棄されます)…]\n` + text;
|
|
533
860
|
if (o.range) {
|
|
534
861
|
const [lo, hi] = o.range;
|
|
535
862
|
text = text.split("\n").slice(lo, hi ?? undefined).join("\n");
|
|
536
863
|
}
|
|
864
|
+
else if (o.lines) {
|
|
865
|
+
// full + lines は末尾 N 行にする(従来は full 経路で lines を黙殺していた footgun・B11)。
|
|
866
|
+
text = text.split("\n").slice(-o.lines).join("\n");
|
|
867
|
+
}
|
|
537
868
|
}
|
|
538
869
|
else {
|
|
539
870
|
let off = readOffset(name);
|
|
540
871
|
// WSL 再起動等でログが作り直されると、Windows 側に残った旧 offset が新ログ長を超え、
|
|
541
|
-
//
|
|
542
|
-
if (off >
|
|
872
|
+
// 空を返して「何も読めない」状態になる。末尾越えは先頭から読み直す(POSIX では no-op)。
|
|
873
|
+
if (off > size)
|
|
543
874
|
off = 0;
|
|
544
|
-
|
|
875
|
+
const buf = readRange(off, size); // 増分のみをメモリに載せる
|
|
876
|
+
// 末尾の不完全マルチバイト列は次回へ持ち越す(B3)。off は前回この分岐が safeEnd を書くので先頭は割れない。
|
|
877
|
+
const safeLen = utf8SafeEnd(buf, buf.length);
|
|
878
|
+
text = buf.subarray(0, safeLen).toString("utf8");
|
|
879
|
+
nextOffset = off + safeLen;
|
|
545
880
|
if (o.lines)
|
|
546
881
|
text = text.split("\n").slice(-o.lines).join("\n");
|
|
547
882
|
}
|
|
548
883
|
if (!o.range)
|
|
549
|
-
writeOffset(name,
|
|
884
|
+
writeOffset(name, nextOffset);
|
|
550
885
|
if (o.raw)
|
|
551
886
|
return text.endsWith("\n") ? text : text + "\n";
|
|
552
887
|
if (o.rtk) {
|
|
@@ -577,8 +912,11 @@ export function listSessions() {
|
|
|
577
912
|
}
|
|
578
913
|
export function closeSession(name) {
|
|
579
914
|
assertSessionName(name);
|
|
915
|
+
if (agentWaitLocks.has(name)) {
|
|
916
|
+
throw new AitermError(`agent session '${name}' は agent_done 待機中のため close できません`, 2);
|
|
917
|
+
}
|
|
580
918
|
tmux("kill-session", "-t", name);
|
|
581
|
-
for (const p of [logpath(name), offsetpath(name), lastcmdpath(name)]) {
|
|
919
|
+
for (const p of [logpath(name), offsetpath(name), lastcmdpath(name), markpath(name)]) {
|
|
582
920
|
try {
|
|
583
921
|
fs.unlinkSync(p);
|
|
584
922
|
}
|
|
@@ -586,20 +924,769 @@ export function closeSession(name) {
|
|
|
586
924
|
/* noop */
|
|
587
925
|
}
|
|
588
926
|
}
|
|
927
|
+
cleanupAgentState(name);
|
|
589
928
|
return `closed ${name}`;
|
|
590
929
|
}
|
|
591
930
|
export function killAll() {
|
|
931
|
+
if (agentWaitLocks.size > 0) {
|
|
932
|
+
throw new AitermError(`agent_done 待機中の session があるため killAll できません: ${Array.from(agentWaitLocks).join(",")}`, 2);
|
|
933
|
+
}
|
|
592
934
|
tmux("kill-server");
|
|
935
|
+
// B9: SOCKDIR 内の .log/.offset/.lastcmd/.mark 残骸も掃除する(残すと B5 の stale-log 復活の温床)。
|
|
936
|
+
try {
|
|
937
|
+
for (const f of fs.readdirSync(SOCKDIR)) {
|
|
938
|
+
if (/\.(log|offset|lastcmd|mark)$/.test(f)) {
|
|
939
|
+
try {
|
|
940
|
+
fs.unlinkSync(path.join(SOCKDIR, f));
|
|
941
|
+
}
|
|
942
|
+
catch {
|
|
943
|
+
/* noop */
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
catch {
|
|
949
|
+
/* SOCKDIR 不在等は無視 */
|
|
950
|
+
}
|
|
951
|
+
const adir = existingAgentsDir();
|
|
952
|
+
if (adir) {
|
|
953
|
+
try {
|
|
954
|
+
for (const f of fs.readdirSync(adir)) {
|
|
955
|
+
if (f.endsWith(".agent.json") ||
|
|
956
|
+
f.endsWith(".events.jsonl") ||
|
|
957
|
+
f.endsWith(".wait.lock") ||
|
|
958
|
+
f.endsWith(".codex-home") ||
|
|
959
|
+
f.endsWith(".grok-home") ||
|
|
960
|
+
f.endsWith(".home")) {
|
|
961
|
+
try {
|
|
962
|
+
fs.rmSync(path.join(adir, f), { recursive: true, force: true });
|
|
963
|
+
}
|
|
964
|
+
catch {
|
|
965
|
+
/* noop */
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
catch {
|
|
971
|
+
/* agent state dir 不在等は無視 */
|
|
972
|
+
}
|
|
973
|
+
}
|
|
593
974
|
return "killed all sessions on this socket";
|
|
594
975
|
}
|
|
976
|
+
const DEFAULT_AGENT_DONE_TIMEOUT = 600;
|
|
977
|
+
const agentWaitLocks = new Set();
|
|
978
|
+
function codexHookScriptPath() {
|
|
979
|
+
return path.join(path.dirname(fileURLToPath(import.meta.url)), "codex-stop-hook.js");
|
|
980
|
+
}
|
|
981
|
+
function grokHookScriptPath() {
|
|
982
|
+
return path.join(path.dirname(fileURLToPath(import.meta.url)), "grok-stop-hook.js");
|
|
983
|
+
}
|
|
984
|
+
function safeStatSize(p) {
|
|
985
|
+
try {
|
|
986
|
+
return fs.statSync(p).size;
|
|
987
|
+
}
|
|
988
|
+
catch {
|
|
989
|
+
return 0;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
function readFileRange(p, from, to) {
|
|
993
|
+
const len = Math.max(0, to - from);
|
|
994
|
+
if (len === 0)
|
|
995
|
+
return Buffer.alloc(0);
|
|
996
|
+
let fd;
|
|
997
|
+
try {
|
|
998
|
+
fd = fs.openSync(p, "r");
|
|
999
|
+
const buf = Buffer.alloc(len);
|
|
1000
|
+
const n = fs.readSync(fd, buf, 0, len, from);
|
|
1001
|
+
return n === len ? buf : buf.subarray(0, Math.max(0, n));
|
|
1002
|
+
}
|
|
1003
|
+
catch {
|
|
1004
|
+
return Buffer.alloc(0);
|
|
1005
|
+
}
|
|
1006
|
+
finally {
|
|
1007
|
+
if (fd !== undefined) {
|
|
1008
|
+
try {
|
|
1009
|
+
fs.closeSync(fd);
|
|
1010
|
+
}
|
|
1011
|
+
catch {
|
|
1012
|
+
/* noop */
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
function writeJson0600(p, v) {
|
|
1018
|
+
fs.writeFileSync(p, JSON.stringify(v, null, 2) + "\n", { mode: 0o600 });
|
|
1019
|
+
try {
|
|
1020
|
+
fs.chmodSync(p, 0o600);
|
|
1021
|
+
}
|
|
1022
|
+
catch {
|
|
1023
|
+
/* noop */
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
function writeText0600(p, text) {
|
|
1027
|
+
fs.writeFileSync(p, text, { mode: 0o600 });
|
|
1028
|
+
try {
|
|
1029
|
+
fs.chmodSync(p, 0o600);
|
|
1030
|
+
}
|
|
1031
|
+
catch {
|
|
1032
|
+
/* noop */
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
function createEmpty0600NoFollow(p) {
|
|
1036
|
+
const nofollow = fs.constants.O_NOFOLLOW ?? 0;
|
|
1037
|
+
const fd = fs.openSync(p, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | nofollow, 0o600);
|
|
1038
|
+
fs.closeSync(fd);
|
|
1039
|
+
}
|
|
1040
|
+
function realCodexHome() {
|
|
1041
|
+
return process.env.CODEX_HOME || path.join(process.env.HOME ?? os.homedir(), ".codex");
|
|
1042
|
+
}
|
|
1043
|
+
function createManagedCodexHome(name, launchId) {
|
|
1044
|
+
const srcHome = realCodexHome();
|
|
1045
|
+
let srcSt;
|
|
1046
|
+
try {
|
|
1047
|
+
srcSt = fs.statSync(srcHome);
|
|
1048
|
+
}
|
|
1049
|
+
catch {
|
|
1050
|
+
throw new AitermError(`Codex home が見つかりません: ${srcHome}`, 2);
|
|
1051
|
+
}
|
|
1052
|
+
if (!srcSt.isDirectory())
|
|
1053
|
+
throw new AitermError(`Codex home が directory ではありません: ${srcHome}`, 2);
|
|
1054
|
+
const managedHome = agentManagedCodexHomePath(name, launchId);
|
|
1055
|
+
fs.mkdirSync(managedHome, { recursive: false, mode: 0o700 });
|
|
1056
|
+
fs.chmodSync(managedHome, 0o700);
|
|
1057
|
+
let authLinked = false;
|
|
1058
|
+
for (const entry of fs.readdirSync(srcHome)) {
|
|
1059
|
+
if (entry === "hooks.json" || entry.startsWith("hooks.json."))
|
|
1060
|
+
continue;
|
|
1061
|
+
const src = path.join(srcHome, entry);
|
|
1062
|
+
const dst = path.join(managedHome, entry);
|
|
1063
|
+
if (entry === "auth.json") {
|
|
1064
|
+
const st = fs.statSync(src);
|
|
1065
|
+
if (!st.isFile())
|
|
1066
|
+
throw new AitermError(`Codex auth.json が通常ファイルではありません: ${src}`, 2);
|
|
1067
|
+
fs.symlinkSync(src, dst);
|
|
1068
|
+
authLinked = true;
|
|
1069
|
+
}
|
|
1070
|
+
else if (entry === "config.toml") {
|
|
1071
|
+
const st = fs.statSync(src);
|
|
1072
|
+
if (st.isFile()) {
|
|
1073
|
+
fs.copyFileSync(src, dst);
|
|
1074
|
+
fs.chmodSync(dst, 0o600);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
else {
|
|
1078
|
+
fs.symlinkSync(src, dst);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
if (!authLinked)
|
|
1082
|
+
throw new AitermError(`Codex auth.json が見つかりません。先に codex login が必要です: ${srcHome}`, 2);
|
|
1083
|
+
const hookScript = codexHookScriptPath();
|
|
1084
|
+
if (!fs.existsSync(hookScript)) {
|
|
1085
|
+
throw new AitermError(`Codex Stop hook wrapper が見つかりません。npm run build を実行してください: ${hookScript}`, 2);
|
|
1086
|
+
}
|
|
1087
|
+
writeJson0600(path.join(managedHome, "hooks.json"), {
|
|
1088
|
+
hooks: {
|
|
1089
|
+
Stop: [
|
|
1090
|
+
{
|
|
1091
|
+
hooks: [
|
|
1092
|
+
{
|
|
1093
|
+
type: "command",
|
|
1094
|
+
command: `${shq(process.execPath)} ${shq(hookScript)}`,
|
|
1095
|
+
timeoutSec: 10,
|
|
1096
|
+
async: false,
|
|
1097
|
+
statusMessage: null,
|
|
1098
|
+
},
|
|
1099
|
+
],
|
|
1100
|
+
},
|
|
1101
|
+
],
|
|
1102
|
+
},
|
|
1103
|
+
});
|
|
1104
|
+
return managedHome;
|
|
1105
|
+
}
|
|
1106
|
+
function realGrokHome() {
|
|
1107
|
+
return process.env.GROK_HOME || path.join(process.env.HOME ?? os.homedir(), ".grok");
|
|
1108
|
+
}
|
|
1109
|
+
function validateGrokAuthLock(lockPath) {
|
|
1110
|
+
let st;
|
|
1111
|
+
try {
|
|
1112
|
+
st = fs.lstatSync(lockPath);
|
|
1113
|
+
}
|
|
1114
|
+
catch (e) {
|
|
1115
|
+
throw e;
|
|
1116
|
+
}
|
|
1117
|
+
if (st.isSymbolicLink())
|
|
1118
|
+
throw new AitermError(`Grok auth lock が symlink です: ${lockPath}`, 2);
|
|
1119
|
+
if (!st.isFile())
|
|
1120
|
+
throw new AitermError(`Grok auth lock が通常ファイルではありません: ${lockPath}`, 2);
|
|
1121
|
+
if (st.nlink !== 1)
|
|
1122
|
+
throw new AitermError(`Grok auth lock が hard link です: ${lockPath}`, 2);
|
|
1123
|
+
try {
|
|
1124
|
+
fs.chmodSync(lockPath, 0o600);
|
|
1125
|
+
}
|
|
1126
|
+
catch {
|
|
1127
|
+
/* lock file permission tightening is best-effort */
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
function ensureGrokAuthLock(srcHome) {
|
|
1131
|
+
const lockPath = path.join(srcHome, "auth.json.lock");
|
|
1132
|
+
try {
|
|
1133
|
+
validateGrokAuthLock(lockPath);
|
|
1134
|
+
return lockPath;
|
|
1135
|
+
}
|
|
1136
|
+
catch (e) {
|
|
1137
|
+
if (e.code !== "ENOENT")
|
|
1138
|
+
throw e;
|
|
1139
|
+
}
|
|
1140
|
+
try {
|
|
1141
|
+
createEmpty0600NoFollow(lockPath);
|
|
1142
|
+
}
|
|
1143
|
+
catch (e) {
|
|
1144
|
+
if (e.code !== "EEXIST")
|
|
1145
|
+
throw e;
|
|
1146
|
+
}
|
|
1147
|
+
validateGrokAuthLock(lockPath);
|
|
1148
|
+
return lockPath;
|
|
1149
|
+
}
|
|
1150
|
+
function linkGrokOauthFiles(srcHome, grokHome) {
|
|
1151
|
+
const srcAuth = path.join(srcHome, "auth.json");
|
|
1152
|
+
let authExists = false;
|
|
1153
|
+
try {
|
|
1154
|
+
const authSt = fs.statSync(srcAuth);
|
|
1155
|
+
if (!authSt.isFile())
|
|
1156
|
+
throw new AitermError(`Grok auth.json が通常ファイルではありません: ${srcAuth}`, 2);
|
|
1157
|
+
authExists = true;
|
|
1158
|
+
}
|
|
1159
|
+
catch (e) {
|
|
1160
|
+
if (e.code !== "ENOENT")
|
|
1161
|
+
throw e;
|
|
1162
|
+
}
|
|
1163
|
+
if (!authExists) {
|
|
1164
|
+
if (process.env.XAI_API_KEY)
|
|
1165
|
+
return;
|
|
1166
|
+
throw new AitermError(`Grok auth.json が見つかりません。先に grok login が必要です: ${srcHome}`, 2);
|
|
1167
|
+
}
|
|
1168
|
+
const srcLock = ensureGrokAuthLock(srcHome);
|
|
1169
|
+
fs.symlinkSync(srcAuth, path.join(grokHome, "auth.json"));
|
|
1170
|
+
fs.symlinkSync(srcLock, path.join(grokHome, "auth.json.lock"));
|
|
1171
|
+
}
|
|
1172
|
+
function writeManagedGrokConfig(grokHome) {
|
|
1173
|
+
fs.mkdirSync(path.join(grokHome, "hooks"), { recursive: false, mode: 0o700 });
|
|
1174
|
+
writeText0600(path.join(grokHome, "config.toml"), [
|
|
1175
|
+
"[cli]",
|
|
1176
|
+
"auto_update = false",
|
|
1177
|
+
"",
|
|
1178
|
+
"[features]",
|
|
1179
|
+
"remote_fetch = false",
|
|
1180
|
+
"managed_config = false",
|
|
1181
|
+
"",
|
|
1182
|
+
"[compat.claude]",
|
|
1183
|
+
"skills = false",
|
|
1184
|
+
"rules = false",
|
|
1185
|
+
"agents = false",
|
|
1186
|
+
"mcps = false",
|
|
1187
|
+
"hooks = false",
|
|
1188
|
+
"",
|
|
1189
|
+
"[compat.cursor]",
|
|
1190
|
+
"skills = false",
|
|
1191
|
+
"rules = false",
|
|
1192
|
+
"agents = false",
|
|
1193
|
+
"mcps = false",
|
|
1194
|
+
"hooks = false",
|
|
1195
|
+
"",
|
|
1196
|
+
].join("\n"));
|
|
1197
|
+
}
|
|
1198
|
+
function createManagedGrokHome(name, launchId) {
|
|
1199
|
+
const srcHome = realGrokHome();
|
|
1200
|
+
let srcSt;
|
|
1201
|
+
try {
|
|
1202
|
+
srcSt = fs.statSync(srcHome);
|
|
1203
|
+
}
|
|
1204
|
+
catch {
|
|
1205
|
+
throw new AitermError(`Grok home が見つかりません: ${srcHome}`, 2);
|
|
1206
|
+
}
|
|
1207
|
+
if (!srcSt.isDirectory())
|
|
1208
|
+
throw new AitermError(`Grok home が directory ではありません: ${srcHome}`, 2);
|
|
1209
|
+
const grokHome = agentManagedGrokHomePath(name, launchId);
|
|
1210
|
+
const fakeHome = agentManagedGrokUserHomePath(name, launchId);
|
|
1211
|
+
fs.mkdirSync(grokHome, { recursive: false, mode: 0o700 });
|
|
1212
|
+
fs.mkdirSync(fakeHome, { recursive: false, mode: 0o700 });
|
|
1213
|
+
fs.chmodSync(grokHome, 0o700);
|
|
1214
|
+
fs.chmodSync(fakeHome, 0o700);
|
|
1215
|
+
fs.symlinkSync(grokHome, path.join(fakeHome, ".grok"));
|
|
1216
|
+
// OAuth refresh token rotation は auth.json だけでなく vendor lock と同じ実体を共有させる。
|
|
1217
|
+
// per-launch GROK_HOME 隔離は維持し、通常 Grok home の hook/config/session は読ませない。
|
|
1218
|
+
linkGrokOauthFiles(srcHome, grokHome);
|
|
1219
|
+
const hookScript = grokHookScriptPath();
|
|
1220
|
+
if (!fs.existsSync(hookScript)) {
|
|
1221
|
+
throw new AitermError(`Grok Stop hook wrapper が見つかりません。npm run build を実行してください: ${hookScript}`, 2);
|
|
1222
|
+
}
|
|
1223
|
+
writeManagedGrokConfig(grokHome);
|
|
1224
|
+
writeJson0600(path.join(grokHome, "hooks", "aiterm-stop.json"), {
|
|
1225
|
+
hooks: {
|
|
1226
|
+
Stop: [
|
|
1227
|
+
{
|
|
1228
|
+
hooks: [
|
|
1229
|
+
{
|
|
1230
|
+
type: "command",
|
|
1231
|
+
command: `${shq(process.execPath)} ${shq(hookScript)}`,
|
|
1232
|
+
timeout: 10,
|
|
1233
|
+
},
|
|
1234
|
+
],
|
|
1235
|
+
},
|
|
1236
|
+
],
|
|
1237
|
+
},
|
|
1238
|
+
});
|
|
1239
|
+
// Grok 0.2.87 は compat false でも ~/.claude/plugins の hook file を拾う。HOME を一時化して
|
|
1240
|
+
// plugin/hook source を完全に 0 にする。実 HOME は必要なら hook/agent 側で参照できるよう env で渡す。
|
|
1241
|
+
return { grokHome, home: fakeHome };
|
|
1242
|
+
}
|
|
1243
|
+
function writeAgentMetadata(meta) {
|
|
1244
|
+
writeJson0600(agentMetadataPath(meta.aiterm_session, meta.launch_id), meta);
|
|
1245
|
+
}
|
|
1246
|
+
function acquireAgentWaitFileLock(meta) {
|
|
1247
|
+
const p = agentWaitLockPath(meta.aiterm_session, meta.launch_id);
|
|
1248
|
+
const nofollow = fs.constants.O_NOFOLLOW ?? 0;
|
|
1249
|
+
let fd;
|
|
1250
|
+
try {
|
|
1251
|
+
fd = fs.openSync(p, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | nofollow, 0o600);
|
|
1252
|
+
}
|
|
1253
|
+
catch (e) {
|
|
1254
|
+
if (e.code === "EEXIST") {
|
|
1255
|
+
throw new AitermError(`agent session '${meta.aiterm_session}' は別プロセスの agent_done 待機中です`, 2);
|
|
1256
|
+
}
|
|
1257
|
+
throw e;
|
|
1258
|
+
}
|
|
1259
|
+
try {
|
|
1260
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: new Date().toISOString() }) + "\n", undefined, "utf8");
|
|
1261
|
+
}
|
|
1262
|
+
finally {
|
|
1263
|
+
fs.closeSync(fd);
|
|
1264
|
+
}
|
|
1265
|
+
try {
|
|
1266
|
+
fs.chmodSync(p, 0o600);
|
|
1267
|
+
}
|
|
1268
|
+
catch {
|
|
1269
|
+
/* noop */
|
|
1270
|
+
}
|
|
1271
|
+
return () => {
|
|
1272
|
+
try {
|
|
1273
|
+
const st = fs.lstatSync(p);
|
|
1274
|
+
if (st.isFile() && !st.isSymbolicLink() && st.uid === currentUid())
|
|
1275
|
+
fs.unlinkSync(p);
|
|
1276
|
+
}
|
|
1277
|
+
catch {
|
|
1278
|
+
/* noop */
|
|
1279
|
+
}
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
function createCodexAgentMetadata(name, cwd, initialPrompt) {
|
|
1283
|
+
const launchId = randomBytes(16).toString("hex");
|
|
1284
|
+
const eventFile = agentEventPath(name, launchId);
|
|
1285
|
+
createEmpty0600NoFollow(eventFile);
|
|
1286
|
+
const codexHome = createManagedCodexHome(name, launchId);
|
|
1287
|
+
const meta = {
|
|
1288
|
+
kind: "codex",
|
|
1289
|
+
aiterm_session: name,
|
|
1290
|
+
launch_id: launchId,
|
|
1291
|
+
event_file: eventFile,
|
|
1292
|
+
created_at: new Date().toISOString(),
|
|
1293
|
+
cwd,
|
|
1294
|
+
vendor_session_id: null,
|
|
1295
|
+
initial_prompt: initialPrompt,
|
|
1296
|
+
hook_route: "managed_codex_home",
|
|
1297
|
+
node_platform: process.platform,
|
|
1298
|
+
codex_home: codexHome,
|
|
1299
|
+
};
|
|
1300
|
+
writeAgentMetadata(meta);
|
|
1301
|
+
return meta;
|
|
1302
|
+
}
|
|
1303
|
+
function createGrokAgentMetadata(kind, name, cwd, initialPrompt) {
|
|
1304
|
+
const launchId = randomBytes(16).toString("hex");
|
|
1305
|
+
const eventFile = agentEventPath(name, launchId);
|
|
1306
|
+
createEmpty0600NoFollow(eventFile);
|
|
1307
|
+
const managed = createManagedGrokHome(name, launchId);
|
|
1308
|
+
const meta = {
|
|
1309
|
+
kind,
|
|
1310
|
+
aiterm_session: name,
|
|
1311
|
+
launch_id: launchId,
|
|
1312
|
+
event_file: eventFile,
|
|
1313
|
+
created_at: new Date().toISOString(),
|
|
1314
|
+
cwd,
|
|
1315
|
+
vendor_session_id: null,
|
|
1316
|
+
initial_prompt: initialPrompt,
|
|
1317
|
+
hook_route: "managed_grok_home",
|
|
1318
|
+
node_platform: process.platform,
|
|
1319
|
+
grok_home: managed.grokHome,
|
|
1320
|
+
home: managed.home,
|
|
1321
|
+
};
|
|
1322
|
+
writeAgentMetadata(meta);
|
|
1323
|
+
return meta;
|
|
1324
|
+
}
|
|
1325
|
+
function loadAgentMetadata(name) {
|
|
1326
|
+
assertSessionName(name);
|
|
1327
|
+
const dir = agentsDir();
|
|
1328
|
+
const files = fs
|
|
1329
|
+
.readdirSync(dir)
|
|
1330
|
+
.filter((f) => f.startsWith(`${name}.`) && f.endsWith(".agent.json"));
|
|
1331
|
+
if (files.length === 0) {
|
|
1332
|
+
throw new AitermError(`session '${name}' は agent_done 管理セッションではありません。codex_agent(agent_done:true) で起動してください。`, 2);
|
|
1333
|
+
}
|
|
1334
|
+
if (files.length !== 1) {
|
|
1335
|
+
throw new AitermError(`session '${name}' の agent metadata が複数あります。閉じて起動し直してください。`, 2);
|
|
1336
|
+
}
|
|
1337
|
+
let raw;
|
|
1338
|
+
try {
|
|
1339
|
+
raw = JSON.parse(fs.readFileSync(path.join(dir, files[0]), "utf8"));
|
|
1340
|
+
}
|
|
1341
|
+
catch (e) {
|
|
1342
|
+
throw new AitermError(`agent metadata を読めません: ${e.message}`, 2);
|
|
1343
|
+
}
|
|
1344
|
+
const m = raw;
|
|
1345
|
+
if ((m.kind !== "codex" && m.kind !== "grok" && m.kind !== "composer") ||
|
|
1346
|
+
m.aiterm_session !== name ||
|
|
1347
|
+
typeof m.launch_id !== "string" ||
|
|
1348
|
+
!LAUNCH_ID_RE.test(m.launch_id)) {
|
|
1349
|
+
throw new AitermError(`agent metadata が不正です: ${files[0]}`, 2);
|
|
1350
|
+
}
|
|
1351
|
+
const expectedEvent = agentEventPath(name, m.launch_id);
|
|
1352
|
+
if (m.event_file !== expectedEvent) {
|
|
1353
|
+
throw new AitermError("agent metadata の path が現在の secure state root と一致しません", 2);
|
|
1354
|
+
}
|
|
1355
|
+
if (m.kind === "codex") {
|
|
1356
|
+
const expectedHome = agentManagedCodexHomePath(name, m.launch_id);
|
|
1357
|
+
if (m.hook_route !== "managed_codex_home" || m.codex_home !== expectedHome) {
|
|
1358
|
+
throw new AitermError("agent metadata の path が現在の secure state root と一致しません", 2);
|
|
1359
|
+
}
|
|
1360
|
+
return {
|
|
1361
|
+
kind: "codex",
|
|
1362
|
+
aiterm_session: name,
|
|
1363
|
+
launch_id: m.launch_id,
|
|
1364
|
+
event_file: expectedEvent,
|
|
1365
|
+
created_at: typeof m.created_at === "string" ? m.created_at : "",
|
|
1366
|
+
cwd: typeof m.cwd === "string" ? m.cwd : null,
|
|
1367
|
+
vendor_session_id: typeof m.vendor_session_id === "string" ? m.vendor_session_id : null,
|
|
1368
|
+
initial_prompt: m.initial_prompt === true,
|
|
1369
|
+
hook_route: "managed_codex_home",
|
|
1370
|
+
node_platform: process.platform,
|
|
1371
|
+
codex_home: expectedHome,
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
const expectedGrokHome = agentManagedGrokHomePath(name, m.launch_id);
|
|
1375
|
+
const expectedHome = agentManagedGrokUserHomePath(name, m.launch_id);
|
|
1376
|
+
if (m.hook_route !== "managed_grok_home" || m.grok_home !== expectedGrokHome || m.home !== expectedHome) {
|
|
1377
|
+
throw new AitermError("agent metadata の path が現在の secure state root と一致しません", 2);
|
|
1378
|
+
}
|
|
1379
|
+
return {
|
|
1380
|
+
kind: m.kind,
|
|
1381
|
+
aiterm_session: name,
|
|
1382
|
+
launch_id: m.launch_id,
|
|
1383
|
+
event_file: expectedEvent,
|
|
1384
|
+
created_at: typeof m.created_at === "string" ? m.created_at : "",
|
|
1385
|
+
cwd: typeof m.cwd === "string" ? m.cwd : null,
|
|
1386
|
+
vendor_session_id: typeof m.vendor_session_id === "string" ? m.vendor_session_id : null,
|
|
1387
|
+
initial_prompt: m.initial_prompt === true,
|
|
1388
|
+
hook_route: "managed_grok_home",
|
|
1389
|
+
node_platform: process.platform,
|
|
1390
|
+
grok_home: expectedGrokHome,
|
|
1391
|
+
home: expectedHome,
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
function parseAgentDoneEvent(line, meta) {
|
|
1395
|
+
let obj;
|
|
1396
|
+
try {
|
|
1397
|
+
obj = JSON.parse(line);
|
|
1398
|
+
}
|
|
1399
|
+
catch {
|
|
1400
|
+
return { event: null, malformed: true };
|
|
1401
|
+
}
|
|
1402
|
+
const ev = obj;
|
|
1403
|
+
if (ev.type !== "agent_done" ||
|
|
1404
|
+
ev.vendor !== meta.kind ||
|
|
1405
|
+
ev.aiterm_session !== meta.aiterm_session ||
|
|
1406
|
+
ev.launch_id !== meta.launch_id ||
|
|
1407
|
+
ev.done_status !== "turn_done") {
|
|
1408
|
+
return { event: null, malformed: false };
|
|
1409
|
+
}
|
|
1410
|
+
if (meta.vendor_session_id && ev.vendor_session_id !== meta.vendor_session_id) {
|
|
1411
|
+
return { event: null, malformed: false };
|
|
1412
|
+
}
|
|
1413
|
+
return {
|
|
1414
|
+
event: {
|
|
1415
|
+
type: "agent_done",
|
|
1416
|
+
vendor: meta.kind,
|
|
1417
|
+
aiterm_session: meta.aiterm_session,
|
|
1418
|
+
launch_id: meta.launch_id,
|
|
1419
|
+
vendor_session_id: typeof ev.vendor_session_id === "string" ? ev.vendor_session_id : null,
|
|
1420
|
+
turn_id: typeof ev.turn_id === "string" ? ev.turn_id : null,
|
|
1421
|
+
reason: typeof ev.reason === "string" ? ev.reason : "Stop",
|
|
1422
|
+
done_status: "turn_done",
|
|
1423
|
+
stop_hook_active: !!ev.stop_hook_active,
|
|
1424
|
+
at: typeof ev.at === "string" ? ev.at : new Date().toISOString(),
|
|
1425
|
+
},
|
|
1426
|
+
malformed: false,
|
|
1427
|
+
};
|
|
1428
|
+
}
|
|
1429
|
+
function scanAgentDoneLines(lines, meta) {
|
|
1430
|
+
let malformedEvents = 0;
|
|
1431
|
+
let candidate = null;
|
|
1432
|
+
for (const line of lines) {
|
|
1433
|
+
if (!line.trim())
|
|
1434
|
+
continue;
|
|
1435
|
+
if (Buffer.byteLength(line, "utf8") > 64 * 1024) {
|
|
1436
|
+
malformedEvents++;
|
|
1437
|
+
continue;
|
|
1438
|
+
}
|
|
1439
|
+
const parsed = parseAgentDoneEvent(line, meta);
|
|
1440
|
+
if (parsed.malformed) {
|
|
1441
|
+
malformedEvents++;
|
|
1442
|
+
continue;
|
|
1443
|
+
}
|
|
1444
|
+
const ev = parsed.event;
|
|
1445
|
+
if (!ev)
|
|
1446
|
+
continue;
|
|
1447
|
+
if (meta.vendor_session_id)
|
|
1448
|
+
return { event: ev, malformedEvents, ambiguousVendorSession: false };
|
|
1449
|
+
if (candidate?.vendor_session_id &&
|
|
1450
|
+
ev.vendor_session_id &&
|
|
1451
|
+
candidate.vendor_session_id !== ev.vendor_session_id) {
|
|
1452
|
+
return { event: null, malformedEvents, ambiguousVendorSession: true };
|
|
1453
|
+
}
|
|
1454
|
+
if (!candidate)
|
|
1455
|
+
candidate = ev;
|
|
1456
|
+
}
|
|
1457
|
+
return { event: candidate, malformedEvents, ambiguousVendorSession: false };
|
|
1458
|
+
}
|
|
1459
|
+
function bindAgentVendorSession(meta, ev) {
|
|
1460
|
+
if (!meta.vendor_session_id && ev.vendor_session_id) {
|
|
1461
|
+
meta.vendor_session_id = ev.vendor_session_id;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
function bindCompletedInitialPrompt(meta) {
|
|
1465
|
+
if (!meta.initial_prompt || meta.vendor_session_id)
|
|
1466
|
+
return;
|
|
1467
|
+
const size = safeStatSize(meta.event_file);
|
|
1468
|
+
if (size === 0) {
|
|
1469
|
+
throw new AitermError(`agent session '${meta.aiterm_session}' は起動時 prompt の完了待ちです。初回応答完了後に再度 pty_send(wait:"agent_done") してください。`, 2);
|
|
1470
|
+
}
|
|
1471
|
+
const text = readFileRange(meta.event_file, 0, size).toString("utf8");
|
|
1472
|
+
const lines = text.split("\n");
|
|
1473
|
+
const tail = lines.pop() ?? "";
|
|
1474
|
+
const scanned = scanAgentDoneLines(lines, meta);
|
|
1475
|
+
if (scanned.ambiguousVendorSession) {
|
|
1476
|
+
throw new AitermError("agent event file に複数の vendor_session_id が混在しています。該当セッションを閉じて起動し直してください。", 2);
|
|
1477
|
+
}
|
|
1478
|
+
if (!scanned.event) {
|
|
1479
|
+
const malformed = scanned.malformedEvents ? ` malformed_events=${scanned.malformedEvents}` : "";
|
|
1480
|
+
const partial = tail.trim() ? " partial_event=true" : "";
|
|
1481
|
+
throw new AitermError(`agent session '${meta.aiterm_session}' は起動時 prompt の完了 event をまだ確認できません。初回応答完了後に再度 pty_send(wait:"agent_done") してください。${malformed}${partial}`, 2);
|
|
1482
|
+
}
|
|
1483
|
+
bindAgentVendorSession(meta, scanned.event);
|
|
1484
|
+
meta.initial_prompt = false;
|
|
1485
|
+
writeAgentMetadata(meta);
|
|
1486
|
+
}
|
|
1487
|
+
async function waitAgentDoneEvent(meta, startOffset, timeout) {
|
|
1488
|
+
const deadline = performance.now() + timeout * 1000;
|
|
1489
|
+
let cursor = startOffset;
|
|
1490
|
+
let carry = "";
|
|
1491
|
+
let malformedEvents = 0;
|
|
1492
|
+
for (;;) {
|
|
1493
|
+
const size = safeStatSize(meta.event_file);
|
|
1494
|
+
if (size < cursor) {
|
|
1495
|
+
cursor = 0;
|
|
1496
|
+
carry = "";
|
|
1497
|
+
}
|
|
1498
|
+
if (size > cursor) {
|
|
1499
|
+
if (size - cursor > AGENT_EVENT_MAX_BYTES) {
|
|
1500
|
+
throw new AitermError("agent event file の増分が大きすぎます。該当セッションを閉じて起動し直してください。", 2);
|
|
1501
|
+
}
|
|
1502
|
+
carry += readFileRange(meta.event_file, cursor, size).toString("utf8");
|
|
1503
|
+
cursor = size;
|
|
1504
|
+
const parts = carry.split("\n");
|
|
1505
|
+
carry = parts.pop() ?? "";
|
|
1506
|
+
const scanned = scanAgentDoneLines(parts, meta);
|
|
1507
|
+
malformedEvents += scanned.malformedEvents;
|
|
1508
|
+
if (scanned.ambiguousVendorSession) {
|
|
1509
|
+
throw new AitermError("agent event file に複数の vendor_session_id が混在しています。該当セッションを閉じて起動し直してください。", 2);
|
|
1510
|
+
}
|
|
1511
|
+
if (scanned.event) {
|
|
1512
|
+
bindAgentVendorSession(meta, scanned.event);
|
|
1513
|
+
if (meta.vendor_session_id)
|
|
1514
|
+
writeAgentMetadata(meta);
|
|
1515
|
+
return { event: scanned.event, malformedEvents };
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
if (performance.now() >= deadline)
|
|
1519
|
+
return { event: null, malformedEvents };
|
|
1520
|
+
await sleep(AGENT_DONE_POLL_MS);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
function agentDoneSuffix(wait, vendor) {
|
|
1524
|
+
const ev = wait.event;
|
|
1525
|
+
if (!ev) {
|
|
1526
|
+
const malformed = wait.malformedEvents ? ` malformed_events=${wait.malformedEvents}` : "";
|
|
1527
|
+
return ` [is_complete=False via agent_timeout vendor=${vendor}${malformed}]`;
|
|
1528
|
+
}
|
|
1529
|
+
const bits = [
|
|
1530
|
+
"is_complete=True",
|
|
1531
|
+
"via agent_done",
|
|
1532
|
+
`vendor=${ev.vendor}`,
|
|
1533
|
+
ev.turn_id ? `turn_id=${ev.turn_id}` : null,
|
|
1534
|
+
ev.vendor_session_id ? `vendor_session_id=${ev.vendor_session_id}` : null,
|
|
1535
|
+
`done_status=${ev.done_status}`,
|
|
1536
|
+
].filter(Boolean);
|
|
1537
|
+
return ` [${bits.join(" ")}]`;
|
|
1538
|
+
}
|
|
1539
|
+
function isAgentTuiReady(kind, screen) {
|
|
1540
|
+
if (kind === "codex") {
|
|
1541
|
+
return screen.includes("OpenAI Codex") && /(^|\n)\s*[›>]/.test(screen);
|
|
1542
|
+
}
|
|
1543
|
+
return screen.includes("Grok Build") && /(^|\n|\s)❯/.test(screen);
|
|
1544
|
+
}
|
|
1545
|
+
async function waitAgentTuiReadyImpl(kind, sample, sleepFn, opts = {}) {
|
|
1546
|
+
const timeoutMs = opts.timeoutMs ?? AGENT_TUI_READY_TIMEOUT_MS;
|
|
1547
|
+
const pollMs = opts.pollMs ?? AGENT_TUI_READY_POLL_MS;
|
|
1548
|
+
const deadline = performance.now() + timeoutMs;
|
|
1549
|
+
let samples = 0;
|
|
1550
|
+
let lastScreen = "";
|
|
1551
|
+
for (;;) {
|
|
1552
|
+
lastScreen = sample();
|
|
1553
|
+
samples++;
|
|
1554
|
+
if (isAgentTuiReady(kind, lastScreen))
|
|
1555
|
+
return { ready: true, samples, lastScreen };
|
|
1556
|
+
if (performance.now() >= deadline)
|
|
1557
|
+
return { ready: false, samples, lastScreen };
|
|
1558
|
+
await sleepFn(pollMs);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
async function waitAgentTuiReady(name, meta, timeoutMs = AGENT_TUI_READY_TIMEOUT_MS) {
|
|
1562
|
+
return waitAgentTuiReadyImpl(meta.kind, () => captureScreen(name, AGENT_TUI_READY_LINES), sleep, { timeoutMs });
|
|
1563
|
+
}
|
|
1564
|
+
async function settleAgentDoneScreenImpl(sample, sleepFn, opts = {}) {
|
|
1565
|
+
const minDelayMs = opts.minDelayMs ?? AGENT_DONE_SETTLE_MIN_MS;
|
|
1566
|
+
const pollMs = opts.pollMs ?? AGENT_DONE_SCREEN_SETTLE_POLL_MS;
|
|
1567
|
+
const maxPolls = opts.maxPolls ?? AGENT_DONE_SCREEN_SETTLE_MAX_POLLS;
|
|
1568
|
+
const minSamples = opts.minSamples ?? AGENT_DONE_SCREEN_SETTLE_MIN_SAMPLES;
|
|
1569
|
+
if (minDelayMs > 0)
|
|
1570
|
+
await sleepFn(minDelayMs);
|
|
1571
|
+
let prev = sample();
|
|
1572
|
+
let samples = 1;
|
|
1573
|
+
let stableStreak = 1;
|
|
1574
|
+
for (let i = 0; i < maxPolls; i++) {
|
|
1575
|
+
if (pollMs > 0)
|
|
1576
|
+
await sleepFn(pollMs);
|
|
1577
|
+
const current = sample();
|
|
1578
|
+
samples++;
|
|
1579
|
+
if (current.screen === prev.screen && current.logSize === prev.logSize) {
|
|
1580
|
+
stableStreak++;
|
|
1581
|
+
if (samples >= minSamples && stableStreak >= 2)
|
|
1582
|
+
return { unstable: false, samples };
|
|
1583
|
+
}
|
|
1584
|
+
else {
|
|
1585
|
+
stableStreak = 1;
|
|
1586
|
+
}
|
|
1587
|
+
prev = current;
|
|
1588
|
+
}
|
|
1589
|
+
return { unstable: true, samples };
|
|
1590
|
+
}
|
|
1591
|
+
async function settleAgentDoneScreen(name, lines) {
|
|
1592
|
+
return settleAgentDoneScreenImpl(() => ({
|
|
1593
|
+
screen: captureScreen(name, lines),
|
|
1594
|
+
logSize: safeStatSize(logpath(name)),
|
|
1595
|
+
}), sleep);
|
|
1596
|
+
}
|
|
1597
|
+
export async function __testSettleAgentDoneScreen(samples, opts = {}) {
|
|
1598
|
+
if (samples.length === 0)
|
|
1599
|
+
throw new AitermError("screen settle test samples が空です", 2);
|
|
1600
|
+
let i = 0;
|
|
1601
|
+
const sleeps = [];
|
|
1602
|
+
const result = await settleAgentDoneScreenImpl(() => samples[Math.min(i++, samples.length - 1)], async (ms) => {
|
|
1603
|
+
sleeps.push(ms);
|
|
1604
|
+
}, opts);
|
|
1605
|
+
return { ...result, sleeps };
|
|
1606
|
+
}
|
|
1607
|
+
export function __testIsAgentTuiReady(kind, screen) {
|
|
1608
|
+
return isAgentTuiReady(kind, screen);
|
|
1609
|
+
}
|
|
1610
|
+
export async function __testWaitAgentTuiReady(kind, samples, opts = {}) {
|
|
1611
|
+
if (samples.length === 0)
|
|
1612
|
+
throw new AitermError("agent ready test samples が空です", 2);
|
|
1613
|
+
let i = 0;
|
|
1614
|
+
const sleeps = [];
|
|
1615
|
+
const result = await waitAgentTuiReadyImpl(kind, () => samples[Math.min(i++, samples.length - 1)], async (ms) => {
|
|
1616
|
+
sleeps.push(ms);
|
|
1617
|
+
}, opts);
|
|
1618
|
+
return { ...result, sleeps };
|
|
1619
|
+
}
|
|
1620
|
+
export async function sendAndWaitAgentDone(name, text, o = {}) {
|
|
1621
|
+
assertSessionName(name);
|
|
1622
|
+
if (o.enter === false)
|
|
1623
|
+
throw new AitermError('wait:"agent_done" は enter:false と併用できません', 2);
|
|
1624
|
+
if (o.mark)
|
|
1625
|
+
throw new AitermError('wait:"agent_done" と mark:true は併用できません', 2);
|
|
1626
|
+
if (o.rtk)
|
|
1627
|
+
throw new AitermError('wait:"agent_done" と rtk:true は併用できません', 2);
|
|
1628
|
+
const meta = loadAgentMetadata(name);
|
|
1629
|
+
if (agentWaitLocks.has(name))
|
|
1630
|
+
throw new AitermError(`agent session '${name}' は別の agent_done 待機中です`, 2);
|
|
1631
|
+
const releaseFileLock = acquireAgentWaitFileLock(meta);
|
|
1632
|
+
const timeout = o.timeout ?? DEFAULT_AGENT_DONE_TIMEOUT;
|
|
1633
|
+
const screen = o.screen ?? true;
|
|
1634
|
+
agentWaitLocks.add(name);
|
|
1635
|
+
try {
|
|
1636
|
+
bindCompletedInitialPrompt(meta);
|
|
1637
|
+
if (!meta.vendor_session_id) {
|
|
1638
|
+
const ready = await waitAgentTuiReady(name, meta, o.ready_timeout ?? AGENT_TUI_READY_TIMEOUT_MS);
|
|
1639
|
+
if (!ready.ready) {
|
|
1640
|
+
throw new AitermError(`agent session '${name}' の ${agentLabel(meta.kind)} TUI が入力受付状態になりません。文字列は送信していません。` +
|
|
1641
|
+
"少し後で pty_read(screen:true) を確認し、TUI が起動済みなら再度 pty_send(wait:\"agent_done\") してください。", 2);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
const startOffset = safeStatSize(meta.event_file);
|
|
1645
|
+
send(name, text, {
|
|
1646
|
+
enter: false,
|
|
1647
|
+
force: o.force,
|
|
1648
|
+
raw: o.raw,
|
|
1649
|
+
mark: false,
|
|
1650
|
+
rtk: false,
|
|
1651
|
+
});
|
|
1652
|
+
// Codex TUI は literal text 投入直後の Enter を取り落とすことがある。agent 経路だけ submit を分離する。
|
|
1653
|
+
await sleep(AGENT_SUBMIT_DELAY_MS);
|
|
1654
|
+
sendKey(name, "Enter");
|
|
1655
|
+
const wait = await waitAgentDoneEvent(meta, startOffset, timeout);
|
|
1656
|
+
const settled = wait.event
|
|
1657
|
+
? await settleAgentDoneScreen(name, o.lines ?? 0)
|
|
1658
|
+
: { unstable: false, samples: 0 };
|
|
1659
|
+
const out = await readOutput(name, {
|
|
1660
|
+
screen,
|
|
1661
|
+
lines: o.lines ?? null,
|
|
1662
|
+
timeout: 0,
|
|
1663
|
+
});
|
|
1664
|
+
writeOffset(name, safeStatSize(logpath(name)));
|
|
1665
|
+
return out + agentDoneSuffix(wait, meta.kind) + (settled.unstable ? " [agent_done_but_screen_unstable]" : "");
|
|
1666
|
+
}
|
|
1667
|
+
finally {
|
|
1668
|
+
agentWaitLocks.delete(name);
|
|
1669
|
+
releaseFileLock();
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
// ── 対話型エージェント起動(Codex / Grok Build(Grok) / Grok Build(Composer))──────
|
|
1673
|
+
// aiterm の永続端末に、指定モデルの対話エージェント TUI を起動する。以後は pty_read で画面を
|
|
1674
|
+
// 読み、pty_send で操作する=aiterm の対話パラダイムそのもの。モデルはツールごとに固定し、
|
|
1675
|
+
// reasoning effort は引数で渡す。CLI 未導入環境は明示エラー(動くフリをしない)。
|
|
595
1676
|
function resolveAgentBin(kind) {
|
|
596
1677
|
const home = process.env.HOME ?? os.homedir();
|
|
597
1678
|
const [envVar, rel, name] = kind === "codex"
|
|
598
1679
|
? ["CODEX_BIN", [".local", "bin", "codex"], "codex"]
|
|
599
1680
|
: ["GROK_BIN", [".grok", "bin", "grok"], "grok"];
|
|
600
1681
|
const fromEnv = process.env[envVar];
|
|
601
|
-
if (fromEnv)
|
|
602
|
-
|
|
1682
|
+
if (fromEnv) {
|
|
1683
|
+
// 明示指定 env は実在を検証する。存在しないパスを黙って返すと、session を作って
|
|
1684
|
+
// `'/typo' ...` を送信し bash が command not found を出すだけで openAgent は「起動した」と
|
|
1685
|
+
// 偽成功を返す(既定パス/PATH 経路は検証するのに env だけ無検証だった非対称の解消・A3)。
|
|
1686
|
+
if (fs.existsSync(fromEnv))
|
|
1687
|
+
return fromEnv;
|
|
1688
|
+
throw new AitermError(`${envVar} に指定された ${name} が存在しません: ${fromEnv}`, 2);
|
|
1689
|
+
}
|
|
603
1690
|
const cand = path.join(home, ...rel);
|
|
604
1691
|
if (fs.existsSync(cand))
|
|
605
1692
|
return cand;
|
|
@@ -615,23 +1702,60 @@ function resolveAgentBin(kind) {
|
|
|
615
1702
|
function shq(s) {
|
|
616
1703
|
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
617
1704
|
}
|
|
618
|
-
function buildAgentCmd(kind, bin, effort, prompt) {
|
|
1705
|
+
function buildAgentCmd(kind, bin, effort, prompt, meta = null) {
|
|
619
1706
|
const parts = [shq(bin)];
|
|
620
1707
|
if (kind === "codex") {
|
|
1708
|
+
if (meta?.kind === "codex")
|
|
1709
|
+
parts.push("--dangerously-bypass-hook-trust");
|
|
621
1710
|
// codex は config override で reasoning effort(例: low/medium/high)を渡す。
|
|
622
1711
|
if (effort)
|
|
623
1712
|
parts.push("-c", `model_reasoning_effort=${shq(effort)}`);
|
|
624
1713
|
}
|
|
625
1714
|
else {
|
|
626
1715
|
// grok / composer は同じ grok CLI をモデル違いで起動。effort は low/medium/high/xhigh/max。
|
|
1716
|
+
parts.push("--no-auto-update");
|
|
1717
|
+
if (meta?.kind === "grok" || meta?.kind === "composer")
|
|
1718
|
+
parts.push("--no-alt-screen");
|
|
627
1719
|
parts.push("--model", kind === "composer" ? "grok-composer-2.5-fast" : "grok-build");
|
|
628
1720
|
if (effort)
|
|
629
1721
|
parts.push("--effort", shq(effort));
|
|
1722
|
+
if ((meta?.kind === "grok" || meta?.kind === "composer") && prompt)
|
|
1723
|
+
parts.push("--verbatim");
|
|
630
1724
|
}
|
|
631
1725
|
if (prompt)
|
|
632
1726
|
parts.push(shq(prompt)); // 初手プロンプト(任意)
|
|
633
1727
|
return parts.join(" ");
|
|
634
1728
|
}
|
|
1729
|
+
function agentEnvPrefix(meta, sid) {
|
|
1730
|
+
if (!meta)
|
|
1731
|
+
return "";
|
|
1732
|
+
const common = [
|
|
1733
|
+
`AITERM_AGENT_KIND=${shq(meta.kind)}`,
|
|
1734
|
+
`AITERM_SESSION_ID=${shq(sid)}`,
|
|
1735
|
+
`AITERM_AGENT_SESSION_ID=${shq(sid)}`,
|
|
1736
|
+
`AITERM_AGENT_LAUNCH_ID=${shq(meta.launch_id)}`,
|
|
1737
|
+
];
|
|
1738
|
+
if (meta.kind === "codex") {
|
|
1739
|
+
return [`CODEX_HOME=${shq(meta.codex_home ?? "")}`, ...common].join(" ") + " ";
|
|
1740
|
+
}
|
|
1741
|
+
return [
|
|
1742
|
+
`HOME=${shq(meta.home ?? "")}`,
|
|
1743
|
+
`GROK_HOME=${shq(meta.grok_home ?? "")}`,
|
|
1744
|
+
"GROK_DISABLE_AUTOUPDATER=1",
|
|
1745
|
+
"GROK_CLAUDE_HOOKS_ENABLED=false",
|
|
1746
|
+
"GROK_CURSOR_HOOKS_ENABLED=false",
|
|
1747
|
+
"GROK_CLAUDE_SKILLS_ENABLED=false",
|
|
1748
|
+
"GROK_CURSOR_SKILLS_ENABLED=false",
|
|
1749
|
+
"GROK_CLAUDE_RULES_ENABLED=false",
|
|
1750
|
+
"GROK_CURSOR_RULES_ENABLED=false",
|
|
1751
|
+
"GROK_CLAUDE_AGENTS_ENABLED=false",
|
|
1752
|
+
"GROK_CURSOR_AGENTS_ENABLED=false",
|
|
1753
|
+
"GROK_CLAUDE_MCPS_ENABLED=false",
|
|
1754
|
+
"GROK_CURSOR_MCPS_ENABLED=false",
|
|
1755
|
+
`AITERM_REAL_HOME=${shq(process.env.HOME ?? os.homedir())}`,
|
|
1756
|
+
...common,
|
|
1757
|
+
].join(" ") + " ";
|
|
1758
|
+
}
|
|
635
1759
|
function agentLabel(kind) {
|
|
636
1760
|
return kind === "composer"
|
|
637
1761
|
? "Grok Build(Composer)"
|
|
@@ -649,14 +1773,22 @@ export function openAgent(kind, opts = {}) {
|
|
|
649
1773
|
if (effort && kind !== "codex" && !GROK_EFFORTS.has(effort)) {
|
|
650
1774
|
throw new AitermError(`reasoning_effort '${effort}' は不正です(${label} は low/medium/high/xhigh/max)`, 2);
|
|
651
1775
|
}
|
|
1776
|
+
const agentDone = !!opts.agent_done;
|
|
652
1777
|
const bin = resolveAgentBin(kind);
|
|
653
1778
|
if (!bin) {
|
|
654
1779
|
const where = kind === "codex" ? "~/.local/bin/codex" : "~/.grok/bin/grok";
|
|
655
1780
|
throw new AitermError(`${label} の CLI が見つかりません(${where} か PATH が必要)`, 2);
|
|
656
1781
|
}
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
1782
|
+
// cwd 検証(session を作る前に。cd 失敗はシェル内で静かに死に「起動した」と偽成功を返すため)。
|
|
1783
|
+
let cwd = null;
|
|
1784
|
+
if (opts.cwd != null) {
|
|
1785
|
+
if (!opts.cwd.trim()) {
|
|
1786
|
+
throw new AitermError("cwd が空文字です(省略するか有効なディレクトリを指定してください)", 2); // A6
|
|
1787
|
+
}
|
|
1788
|
+
if (opts.cwd.startsWith("~")) {
|
|
1789
|
+
// statSync は ~ を展開しない。「存在しません」でなく展開されない旨を正直に伝える(A6)。
|
|
1790
|
+
throw new AitermError(`cwd の ~ は展開されません。絶対パスで指定してください: ${opts.cwd}`, 2);
|
|
1791
|
+
}
|
|
660
1792
|
let st = null;
|
|
661
1793
|
try {
|
|
662
1794
|
st = fs.statSync(opts.cwd);
|
|
@@ -667,12 +1799,29 @@ export function openAgent(kind, opts = {}) {
|
|
|
667
1799
|
if (!st || !st.isDirectory()) {
|
|
668
1800
|
throw new AitermError(`cwd '${opts.cwd}' がディレクトリとして存在しません`, 2);
|
|
669
1801
|
}
|
|
1802
|
+
cwd = opts.cwd;
|
|
670
1803
|
}
|
|
1804
|
+
// Windows は起動コマンドが WSL 内 bash で走る(tmux ブリッジ)。bin/cwd を /mnt/c/... 形へ変換して
|
|
1805
|
+
// 渡す(ログの toWslPath と対称・A1)。前提: Windows 側に CLI を導入(resolveAgentBin が Windows
|
|
1806
|
+
// パスで解決)。toWslPath は session を作る前に呼ぶ=変換失敗(非ドライブパス)で残骸 session を残さない。
|
|
1807
|
+
// 未検証リスク: npm グローバル導入の codex.cmd/.bat シムや WSL interop 上の対話 TUI 描画は実 Windows
|
|
1808
|
+
// でしか確認できない(CI 非対象。docs/03_audit-sweep-2026-07.md 参照)。
|
|
1809
|
+
const binForCmd = isWin ? toWslPath(bin) : bin;
|
|
1810
|
+
const cwdForCmd = cwd && isWin ? toWslPath(cwd) : cwd;
|
|
671
1811
|
const [sid, hint] = openSession(opts.session_name ?? null, "bash");
|
|
672
|
-
const cmd = buildAgentCmd(kind, bin, effort, opts.prompt ?? null);
|
|
673
|
-
const full = opts.cwd ? `cd ${shq(opts.cwd)} && ${cmd}` : cmd;
|
|
674
1812
|
try {
|
|
675
|
-
|
|
1813
|
+
const meta = agentDone
|
|
1814
|
+
? kind === "codex"
|
|
1815
|
+
? createCodexAgentMetadata(sid, cwd, !!opts.prompt)
|
|
1816
|
+
: createGrokAgentMetadata(kind, sid, cwd, !!opts.prompt)
|
|
1817
|
+
: null;
|
|
1818
|
+
const cmd = buildAgentCmd(kind, binForCmd, effort, opts.prompt ?? null, meta);
|
|
1819
|
+
const envPrefix = agentEnvPrefix(meta, sid);
|
|
1820
|
+
const full = cwdForCmd ? `cd ${shq(cwdForCmd)} && ${envPrefix}${cmd}` : `${envPrefix}${cmd}`;
|
|
1821
|
+
// force:true で送る。起動骨格は `bin '...'` の固定形で、prompt/cwd/effort は shq でクオート済みの
|
|
1822
|
+
// 引数=シェルは決して破壊コマンドとして実行しない。破壊ゲート(生シェルコマンド想定)を prompt に
|
|
1823
|
+
// 掛けるのは純誤検知で、`codex 'rm -rf / を説明して'` 等の正当な起動を塞いでしまう(A4)。
|
|
1824
|
+
send(sid, full, { enter: true, mark: false, force: true, rtk: false, raw: true });
|
|
676
1825
|
}
|
|
677
1826
|
catch (e) {
|
|
678
1827
|
// 起動コマンドを投入できなかった session は空のまま残る=残骸を作らない。片付けてから元エラーを伝える。
|
|
@@ -686,7 +1835,10 @@ export function openAgent(kind, opts = {}) {
|
|
|
686
1835
|
}
|
|
687
1836
|
return [
|
|
688
1837
|
sid,
|
|
689
|
-
`${label} を session ${sid}
|
|
690
|
-
|
|
1838
|
+
`${label} を session ${sid} で起動した。${agentDone ? "agent_done 待機が有効。" : ""}` +
|
|
1839
|
+
`${agentDone && kind !== "codex" ? " hook 汚染防止のため Grok 実行中は一時 HOME を使う。" : ""}\n${hint}\n` +
|
|
1840
|
+
`TUI の描画には数秒かかる。少し置いてから pty_read(${sid}, screen:true) で画面を読み、` +
|
|
1841
|
+
`pty_send(${sid}, "...") で入力・pty_key(${sid}, "Enter"/"Up"/"C-c" 等) で操作する(対話)。` +
|
|
1842
|
+
`起動直後に増分 pty_read すると空/半描画になり得るので screen:true を使う。`,
|
|
691
1843
|
];
|
|
692
1844
|
}
|