aiterm-mcp 0.4.1 → 0.8.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 +148 -62
- package/README.md +147 -61
- package/dist/core.js +398 -43
- package/dist/index.js +69 -7
- package/dist/rtk.js +53 -18
- package/package.json +5 -2
package/dist/core.js
CHANGED
|
@@ -5,13 +5,13 @@
|
|
|
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
|
-
import { createHash } from "node:crypto";
|
|
14
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
15
15
|
import * as rtk from "./rtk.js";
|
|
16
16
|
// Windows ネイティブには tmux が無い。その場合だけ全 tmux 呼び出しを WSL 経由へ橋渡しする
|
|
17
17
|
// (POSIX = Linux/WSL2/macOS は従来どおり tmux を直接叩く)。
|
|
@@ -31,14 +31,26 @@ export const DEFAULT_TIMEOUT = 10.0;
|
|
|
31
31
|
const POLL = 0.25;
|
|
32
32
|
const STABLE_POLLS = 2; // 連続でログサイズ不変ならば静止とみなす回数
|
|
33
33
|
const SHELLS = new Set(["bash", "sh", "zsh", "fish", "dash"]);
|
|
34
|
+
// mark の sentinel は POSIX シェル構文(; と "$?")に依存する。これらの非 POSIX 対話シェルが
|
|
35
|
+
// 前面のときは "$?" が正しく展開されず sentinel が壊れるので mark を拒否する(B8)。ssh/docker で
|
|
36
|
+
// リモート POSIX シェルに入っている場合は前面が "ssh"/"docker" 等で本集合に含まれず=許可される。
|
|
37
|
+
const NON_POSIX_MARK_SHELLS = new Set(["fish", "csh", "tcsh"]);
|
|
38
|
+
// mark:true が付ける完了 sentinel の検出正規表現。printf の実出力は rc=<数字>、コマンド行の
|
|
39
|
+
// エコーは rc=%d(リテラル)。数字アンカーでエコーに免疫化し、部分一致による早期誤完了を防ぐ(B1)。
|
|
40
|
+
// send の printf 書式(`rc=%d`)と対で保守すること。
|
|
41
|
+
const MARK_DONE_RE = /<<<AITERM_DONE rc=[0-9]+>>>/;
|
|
34
42
|
// 出力削減(RTK の CAP 思想を移植)
|
|
35
43
|
const MAX_LINES_BEFORE_ELIDE = 60;
|
|
36
44
|
const HEAD_LINES = 30;
|
|
37
45
|
const TAIL_LINES = 20;
|
|
38
46
|
const DEDUP_MIN_RUN = 3; // 同一行がこれ以上連続したら 1 行+件数に畳む
|
|
47
|
+
const MAX_FULL_BYTES = 8 * 1024 * 1024; // full/range 読取で一度にメモリへ載せる上限(B7)
|
|
39
48
|
// 安全: send 前に弾く破壊的コマンド(外部システム境界の防御)
|
|
40
49
|
const DESTRUCTIVE = [
|
|
41
|
-
|
|
50
|
+
// rm -rf の危険な対象形(best-effort・force で越えられる)。先頭の任意クオート ['"]? で
|
|
51
|
+
// `rm -rf "/"` / `'/'` / `"~"` を捕捉。`\.\/\*`=./*(相対 glob)、`\.\.?\/?\s*$`=. / .. / ./ / ../
|
|
52
|
+
// (カレント/親そのもの)。`./build` 等の相対サブディレクトリは末尾でないので非該当(過剰ブロック回避)。
|
|
53
|
+
/\brm\s+-[rfRF]*[rf][rfRF]*\s+['"]?(\/|~|\$HOME|\.\/\*|\.\.?\/?\s*$|\*\s*$)/i,
|
|
42
54
|
/\bmkfs(\.\w+)?\b/i,
|
|
43
55
|
/\bdd\b[^\n]*\bof=\/dev\//i,
|
|
44
56
|
/>\s*\/dev\/(sd|nvme|hd|mmcblk)/i,
|
|
@@ -50,7 +62,8 @@ const DESTRUCTIVE = [
|
|
|
50
62
|
/\bgit\s+reset\s+--hard\b/i,
|
|
51
63
|
];
|
|
52
64
|
// CSI/OSC/ESC エスケープ・制御文字
|
|
53
|
-
|
|
65
|
+
// CSI / OSC(BEL or ST 終端) / DCS・PM・APC・SOS(ESC P/^/_/X … BEL or ST 終端。ペイロード本文ごと除去=B10) / 残る2文字エスケープ
|
|
66
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[ -\/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[P^_X][\s\S]*?(?:\x07|\x1b\\)|\x1b[@-_]/g;
|
|
54
67
|
const CTRL_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g; // \t(09) \n(0a) は残す
|
|
55
68
|
const PASTE_MARKERS_RE = /\x1b\[20[01]~/g;
|
|
56
69
|
// よく使う制御キーの別名(tmux のキー名へ)
|
|
@@ -189,6 +202,10 @@ function offsetpath(name) {
|
|
|
189
202
|
function lastcmdpath(name) {
|
|
190
203
|
return path.join(SOCKDIR, name + ".lastcmd");
|
|
191
204
|
}
|
|
205
|
+
// mark:true 送信中フラグ。存在すれば waitCompletion が sentinel 完了検出(MARK_DONE_RE)を有効化する。
|
|
206
|
+
function markpath(name) {
|
|
207
|
+
return path.join(SOCKDIR, name + ".mark");
|
|
208
|
+
}
|
|
192
209
|
function readOffset(name) {
|
|
193
210
|
try {
|
|
194
211
|
const n = parseInt(fs.readFileSync(offsetpath(name), "utf8").trim(), 10);
|
|
@@ -305,6 +322,11 @@ function autoName() {
|
|
|
305
322
|
i++;
|
|
306
323
|
return `t${i}`;
|
|
307
324
|
}
|
|
325
|
+
// 衝突リトライ用の乱数名。線形 t{i} は高並行だと全員が同じ「最小の空き番号」を見て衝突し続ける
|
|
326
|
+
// (TOCTOU)ため、2回目以降は 1600万通りの nonce 名に切り替えてリトライ上限内で実質確実に確保する。
|
|
327
|
+
function nonceName() {
|
|
328
|
+
return `t-${randomBytes(3).toString("hex")}`;
|
|
329
|
+
}
|
|
308
330
|
function captureScreen(name, lines) {
|
|
309
331
|
const args = ["capture-pane", "-p", "-J", "-t", name];
|
|
310
332
|
if (lines)
|
|
@@ -332,16 +354,35 @@ async function settleWinLog(name) {
|
|
|
332
354
|
await sleep(POLL * 1000);
|
|
333
355
|
}
|
|
334
356
|
}
|
|
335
|
-
/** 完了境界: dead / until 一致 / (出力静止 ∧ シェル復帰)=quiescent / (ネスト中+until無しで出力静止)=nested(未確定・早期返却) / timeout。 */
|
|
336
|
-
async function waitCompletion(name,
|
|
357
|
+
/** 完了境界: dead / mark sentinel 一致 / until 一致 / (出力静止 ∧ シェル復帰)=quiescent / (ネスト中+until/mark無しで出力静止)=nested(未確定・早期返却) / timeout。 */
|
|
358
|
+
async function waitCompletion(name, untilStr, untilRegex, timeout) {
|
|
337
359
|
// 締切は単調時計で測る。Date.now() は NTP 補正やサスペンドで巻き戻り、長時間待ちで誤判定する(Python は time.monotonic)。
|
|
338
360
|
const deadline = performance.now() + timeout * 1000;
|
|
339
361
|
const start = readOffset(name);
|
|
340
362
|
let lastSize = null;
|
|
341
363
|
let stable = 0;
|
|
342
|
-
|
|
364
|
+
// until は既定でリテラル部分一致(`$ ` や `[sudo]` 等がメタ化して永遠に待つ footgun を避ける・B4)。
|
|
365
|
+
// untilRegex:true のときだけ正規表現として解釈し、不正パターンは明示エラーにする(B13 の until 部分)。
|
|
366
|
+
let until = null;
|
|
367
|
+
if (untilStr) {
|
|
368
|
+
if (untilRegex) {
|
|
369
|
+
let re;
|
|
370
|
+
try {
|
|
371
|
+
re = new RegExp(untilStr);
|
|
372
|
+
}
|
|
373
|
+
catch (e) {
|
|
374
|
+
throw new AitermError(`until 正規表現が不正です: ${JSON.stringify(untilStr)}(${e.message})`, 2);
|
|
375
|
+
}
|
|
376
|
+
until = (s) => re.test(s);
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
until = (s) => s.includes(untilStr);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// mark:true 送信中なら sentinel 完了検出を有効化する。エコー(rc=%d)でなく実出力(rc=<数字>)だけに
|
|
383
|
+
// 一致する数字アンカー MARK_DONE_RE を使うため、until のようにエコー部分一致で早期誤完了しない(B1)。
|
|
384
|
+
const markActive = fs.existsSync(markpath(name));
|
|
343
385
|
for (;;) {
|
|
344
|
-
const alive = sessionExists(name);
|
|
345
386
|
let size = 0;
|
|
346
387
|
try {
|
|
347
388
|
size = fs.statSync(logpath(name)).size;
|
|
@@ -349,13 +390,49 @@ async function waitCompletion(name, untilRe, timeout) {
|
|
|
349
390
|
catch {
|
|
350
391
|
size = 0;
|
|
351
392
|
}
|
|
352
|
-
if (until && size > start) {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
393
|
+
if ((until || markActive) && size > start) {
|
|
394
|
+
// 増分 [start, size] だけを fd で読む(毎 poll で全ファイルを読む O(n^2) を避ける・B6)。
|
|
395
|
+
let neu = "";
|
|
396
|
+
let fd;
|
|
397
|
+
try {
|
|
398
|
+
fd = fs.openSync(logpath(name), "r");
|
|
399
|
+
const len = size - start;
|
|
400
|
+
const buf = Buffer.alloc(len);
|
|
401
|
+
const n = fs.readSync(fd, buf, 0, len, start);
|
|
402
|
+
neu = stripControl(buf.subarray(0, Math.max(0, n)).toString("utf8"));
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
neu = ""; // close 等でログが消えた: 次周回の !alive/statSync で決着させる
|
|
406
|
+
}
|
|
407
|
+
finally {
|
|
408
|
+
if (fd !== undefined) {
|
|
409
|
+
try {
|
|
410
|
+
fs.closeSync(fd);
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
/* noop */
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// mark を until より先に判定(sentinel は確証つき完了。until はユーザ指定でエコー誤爆余地あり)。
|
|
418
|
+
if (markActive && MARK_DONE_RE.test(neu)) {
|
|
419
|
+
try {
|
|
420
|
+
fs.unlinkSync(markpath(name)); // 同一 sentinel での再発火を防ぐ
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
/* noop */
|
|
424
|
+
}
|
|
425
|
+
if (isWin)
|
|
426
|
+
await settleWinLog(name);
|
|
427
|
+
return [true, "mark"];
|
|
428
|
+
}
|
|
429
|
+
if (until && until(neu))
|
|
356
430
|
return [true, "until"];
|
|
357
431
|
}
|
|
358
|
-
|
|
432
|
+
// 出力が伸びている間は生存確認(tmux has-session の spawn)を省く=伸長は生存の証(B6)。
|
|
433
|
+
// 静止時のみ has-session を叩いて dead を判定する。dead 検出は最大 1 poll 遅れるだけ。
|
|
434
|
+
const growing = lastSize !== null && size > lastSize;
|
|
435
|
+
if (!growing && !sessionExists(name)) {
|
|
359
436
|
if (isWin)
|
|
360
437
|
await settleWinLog(name);
|
|
361
438
|
return [true, "dead"];
|
|
@@ -370,10 +447,11 @@ async function waitCompletion(name, untilRe, timeout) {
|
|
|
370
447
|
return [true, "quiescent"]; // 出力静止 ∧ シェル復帰 = 確証つき完了
|
|
371
448
|
}
|
|
372
449
|
// ネスト中(前面が ssh/docker/REPL 等でシェル集合外)は quiescence の「シェル復帰」条件を
|
|
373
|
-
// 原理的に満たせない。until
|
|
374
|
-
//
|
|
450
|
+
// 原理的に満たせない。until も mark も無ければこれ以上待っても確証は増えない(until/dead/
|
|
451
|
+
// quiescent/mark のいずれも発火し得ない)ので、出力静止時点で「未確定」のまま早期返却する。
|
|
452
|
+
// markActive のときは sentinel を待つべく早期返却せず、非シェル前面(sleep 等)でも待ち続ける。
|
|
375
453
|
// fg==="" は前面コマンド取得失敗=ネスト断定不可なので早期返却せず従来どおり timeout まで待つ。
|
|
376
|
-
if (!until && fg !== "") {
|
|
454
|
+
if (!until && !markActive && fg !== "") {
|
|
377
455
|
if (isWin)
|
|
378
456
|
await settleWinLog(name);
|
|
379
457
|
return [false, "nested"];
|
|
@@ -389,10 +467,10 @@ async function waitCompletion(name, untilRe, timeout) {
|
|
|
389
467
|
await sleep(POLL * 1000);
|
|
390
468
|
}
|
|
391
469
|
}
|
|
392
|
-
// 完了ステータス → is_complete 表記。確証のある層のみ True(until/dead/quiescent)。
|
|
470
|
+
// 完了ステータス → is_complete 表記。確証のある層のみ True(mark/until/dead/quiescent)。
|
|
393
471
|
// timeout と nested(ネスト中・出力静止だが確証なし)は False。nested は until/mark を促す注記を添える。
|
|
394
472
|
function completionSuffix(status) {
|
|
395
|
-
const complete = status === "until" || status === "dead" || status === "quiescent";
|
|
473
|
+
const complete = status === "mark" || status === "until" || status === "dead" || status === "quiescent";
|
|
396
474
|
let s = ` [is_complete=${complete ? "True" : "False"} via ${status}]`;
|
|
397
475
|
if (status === "nested")
|
|
398
476
|
s +=
|
|
@@ -417,26 +495,66 @@ function rtkRewrite(text) {
|
|
|
417
495
|
// ---------------------------------------------------------------- 操作(return で返す / 失敗は AitermError)
|
|
418
496
|
export function openSession(name, shell = "bash") {
|
|
419
497
|
fs.mkdirSync(SOCKDIR, { recursive: true });
|
|
420
|
-
const nm = name || autoName();
|
|
421
|
-
assertSessionName(nm);
|
|
422
|
-
if (sessionExists(nm))
|
|
423
|
-
throw new AitermError(`session '${nm}' は既に存在します(list で確認)`, 2);
|
|
424
498
|
// macOS の /bin/bash は 3.2 で、起動時に zsh 移行バナーを出して最初の read を汚す。darwin かつ bash の
|
|
425
499
|
// ときだけ -e で環境変数を渡して抑止する。-e は tmux>=3.2 が必要だが macOS の Homebrew tmux は常に該当。
|
|
426
500
|
// (古い tmux<3.2 が残る Linux/WSL で -e を渡すと new-session が落ちるため、darwin 限定にする。)
|
|
427
501
|
const banner = process.platform === "darwin" && path.basename(shell) === "bash"
|
|
428
502
|
? ["-e", "BASH_SILENCE_DEPRECATION_WARNING=1"]
|
|
429
503
|
: [];
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
504
|
+
// 並行性対策: 複数エージェントが同時に名前なし open すると autoName が同じ t{i} を返し得る(TOCTOU)。
|
|
505
|
+
// 自動採番は初回のみ読みやすい t{i}、衝突したら乱数 nonce 名でリトライする(線形リトライは高並行で
|
|
506
|
+
// 全員が同じ空き番号に殺到してスケールしない)。明示名は既存ならエラー(呼び手責任=意図的共有と区別)。
|
|
507
|
+
const explicit = !!name;
|
|
508
|
+
let nm = name || autoName();
|
|
509
|
+
assertSessionName(nm);
|
|
510
|
+
for (let attempt = 0;; attempt++) {
|
|
511
|
+
if (attempt >= 20)
|
|
512
|
+
throw new AitermError("openSession: 自動採番のリトライ上限(tmux new-session が重複以外で失敗し続けている可能性)", 2);
|
|
513
|
+
if (sessionExists(nm)) {
|
|
514
|
+
if (explicit)
|
|
515
|
+
throw new AitermError(`session '${nm}' は既に存在します(list で確認)`, 2);
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
const r = tmux("new-session", "-d", "-s", nm, ...banner, "-f", "/dev/null", shell);
|
|
519
|
+
if (r.code === 0)
|
|
520
|
+
break;
|
|
521
|
+
// 自動採番かつ「重複名」由来の失敗(他エージェントが同名を先に取った)なら次名でリトライ。
|
|
522
|
+
const dup = /duplicate|already exists/i.test(r.stderr);
|
|
523
|
+
if (explicit || !dup)
|
|
524
|
+
throw new AitermError("tmux new-session 失敗: " + r.stderr.trim(), 2);
|
|
525
|
+
}
|
|
526
|
+
nm = nonceName();
|
|
527
|
+
assertSessionName(nm);
|
|
528
|
+
}
|
|
529
|
+
// 新規セッションの .log は必ず truncate する。"a"(追記)だと外部 kill / killAll / クラッシュで
|
|
530
|
+
// 同名 session だけ消えて .log が残った場合、offset=0 と相まって旧出力を新規として返す(B5)。
|
|
531
|
+
// break は new-session 成功後にのみ到達=作りたての空 session ゆえ切り詰めは安全。lastcmd/mark 残骸も掃除。
|
|
532
|
+
fs.writeFileSync(logpath(nm), "");
|
|
533
|
+
for (const p of [lastcmdpath(nm), markpath(nm)]) {
|
|
534
|
+
try {
|
|
535
|
+
fs.unlinkSync(p);
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
/* noop */
|
|
539
|
+
}
|
|
540
|
+
}
|
|
434
541
|
// pipe-pane の引数は tmux 内部の /bin/sh -c で再解釈される(argv ではない)。パスは単一引用符で包み、
|
|
435
542
|
// パス自身の ' は '\'' イディオムでエスケープする(名前は検証済みだが、Windows ユーザー名 O'Brien 等が
|
|
436
543
|
// 一時パスに ' を持ち込み redirect を壊すのを防ぐ。空白対策も兼ねる)。Windows は WSL から見える /mnt/c 形へ。
|
|
437
544
|
const pipeTarget = isWin ? toWslPath(logpath(nm)) : logpath(nm);
|
|
438
545
|
const quoted = `'${pipeTarget.replace(/'/g, "'\\''")}'`;
|
|
439
|
-
tmux("pipe-pane", "-t", nm, "-o", `cat >> ${quoted}`);
|
|
546
|
+
const pr = tmux("pipe-pane", "-t", nm, "-o", `cat >> ${quoted}`);
|
|
547
|
+
if (pr.code !== 0) {
|
|
548
|
+
// 配管に失敗した session は pty_read が永遠に空を返す=成功を装わない。作った session を片付けて明示エラー。
|
|
549
|
+
tmux("kill-session", "-t", nm);
|
|
550
|
+
try {
|
|
551
|
+
fs.unlinkSync(logpath(nm)); // B14: 直前に作った空 .log も残さない
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
/* noop */
|
|
555
|
+
}
|
|
556
|
+
throw new AitermError("tmux pipe-pane 失敗(出力ログを配管できないため session を破棄): " + pr.stderr.trim(), 2);
|
|
557
|
+
}
|
|
440
558
|
writeOffset(nm, 0);
|
|
441
559
|
return [nm, attachHint(nm)];
|
|
442
560
|
}
|
|
@@ -456,11 +574,37 @@ export function send(name, text, o = {}) {
|
|
|
456
574
|
}
|
|
457
575
|
}
|
|
458
576
|
}
|
|
577
|
+
if (o.mark) {
|
|
578
|
+
// mark の sentinel は POSIX シェル構文。前面が fish/csh/tcsh 等の非 POSIX 対話シェルだと "$?" が
|
|
579
|
+
// 壊れて sentinel が成立しない。黙って壊れた完了検出を作らず、明示エラーで until を促す(B8)。
|
|
580
|
+
const fg = paneCurrentCommand(name);
|
|
581
|
+
if (NON_POSIX_MARK_SHELLS.has(fg)) {
|
|
582
|
+
throw new AitermError(`mark は POSIX シェル(bash/sh/zsh/dash)前提です。前面が ${fg} のため sentinel の "$?" が` +
|
|
583
|
+
`正しく展開されません。until で完了パターンを指定してください。`, 2);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
459
586
|
writeLastcmd(name, text); // read rtk の reducer 分類用(書換/mark 前の素のコマンド)
|
|
460
587
|
if (o.rtk)
|
|
461
588
|
text = rtkRewrite(text);
|
|
462
|
-
if (o.mark)
|
|
589
|
+
if (o.mark) {
|
|
590
|
+
// 実出力は rc=<数字>、この行のエコーは rc=%d(リテラル)。MARK_DONE_RE は数字アンカーで後者に免疫。
|
|
463
591
|
text = text + `; printf '\\n<<<AITERM_DONE rc=%d>>>\\n' "$?"`;
|
|
592
|
+
try {
|
|
593
|
+
fs.writeFileSync(markpath(name), "1"); // waitCompletion に sentinel 完了検出を有効化させる
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
/* noop(フラグ書けなくても until/quiescence 経路は生きる) */
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
// 非 mark 送信は、未消化の古い mark 完了待ちを無効化する(前コマンドの sentinel を待ち続けない)。
|
|
601
|
+
try {
|
|
602
|
+
fs.unlinkSync(markpath(name));
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
/* noop */
|
|
606
|
+
}
|
|
607
|
+
}
|
|
464
608
|
tmux("send-keys", "-t", name, "-l", "--", text);
|
|
465
609
|
if (enter)
|
|
466
610
|
tmux("send-keys", "-t", name, "Enter");
|
|
@@ -475,50 +619,121 @@ export function sendKey(name, key) {
|
|
|
475
619
|
tmux("send-keys", "-t", name, k);
|
|
476
620
|
return `sent key ${k} to ${name}`;
|
|
477
621
|
}
|
|
622
|
+
// end 以下で最大の UTF-8 文字境界を返す(B3)。末尾が不完全なマルチバイト列なら、その開始位置まで
|
|
623
|
+
// 戻す。pipe-pane が多バイト文字の途中でフラッシュした瞬間の増分読みで先頭/末尾が U+FFFD 化するのを防ぐ。
|
|
624
|
+
export function utf8SafeEnd(buf, end) {
|
|
625
|
+
if (end <= 0)
|
|
626
|
+
return 0;
|
|
627
|
+
const isCont = (b) => (b & 0xc0) === 0x80; // 継続バイト 10xxxxxx
|
|
628
|
+
let i = end - 1;
|
|
629
|
+
let steps = 0;
|
|
630
|
+
while (i >= 0 && isCont(buf[i]) && steps < 3) {
|
|
631
|
+
i--;
|
|
632
|
+
steps++;
|
|
633
|
+
}
|
|
634
|
+
if (i < 0)
|
|
635
|
+
return end; // 継続バイトのみの異常列: そのまま
|
|
636
|
+
const lead = buf[i];
|
|
637
|
+
let need;
|
|
638
|
+
if (lead < 0x80)
|
|
639
|
+
need = 1;
|
|
640
|
+
else if ((lead & 0xe0) === 0xc0)
|
|
641
|
+
need = 2;
|
|
642
|
+
else if ((lead & 0xf0) === 0xe0)
|
|
643
|
+
need = 3;
|
|
644
|
+
else if ((lead & 0xf8) === 0xf0)
|
|
645
|
+
need = 4;
|
|
646
|
+
else
|
|
647
|
+
return end; // 不正な先行バイト: そのまま(stripControl 等に委ねる)
|
|
648
|
+
return end - i >= need ? end : i; // 末尾文字が完結していれば end、不完全なら開始位置まで戻す
|
|
649
|
+
}
|
|
478
650
|
export async function readOutput(name, o = {}) {
|
|
479
651
|
assertSessionName(name);
|
|
480
652
|
const timeout = o.timeout ?? DEFAULT_TIMEOUT;
|
|
481
653
|
if (!sessionExists(name) && !fs.existsSync(logpath(name)))
|
|
482
654
|
throw new AitermError(`session '${name}' が無い`, 2);
|
|
655
|
+
// wait は screen より先に処理する。従来 screen は wait ブロックの手前で return し、screen+wait で
|
|
656
|
+
// 完了検出が黙殺されていた(B11)。先に待ってから最終スクリーンを撮る=TUI の描画完了後に読める。
|
|
657
|
+
let status = null;
|
|
658
|
+
if (o.wait) {
|
|
659
|
+
const [, st] = await waitCompletion(name, o.until ?? null, o.untilRegex ?? false, timeout);
|
|
660
|
+
status = st;
|
|
661
|
+
}
|
|
483
662
|
if (o.screen) {
|
|
484
663
|
const rawTxt = captureScreen(name, o.lines || 0);
|
|
485
664
|
if (o.raw)
|
|
486
665
|
return rawTxt;
|
|
487
666
|
const [body, meta] = reduceOutput(rawTxt, name, true);
|
|
488
|
-
return body + "\n" + meta;
|
|
667
|
+
return body + "\n" + meta + (status ? completionSuffix(status) : "");
|
|
489
668
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
const [, st] = await waitCompletion(name, o.until ?? null, timeout);
|
|
493
|
-
status = st;
|
|
494
|
-
}
|
|
495
|
-
let data;
|
|
669
|
+
// ログ全体を毎回メモリに載せず、必要な範囲だけ fd で読む(B7)。size は statSync で取る。
|
|
670
|
+
let size = 0;
|
|
496
671
|
try {
|
|
497
|
-
|
|
672
|
+
size = fs.statSync(logpath(name)).size;
|
|
498
673
|
}
|
|
499
674
|
catch {
|
|
500
|
-
|
|
675
|
+
size = 0;
|
|
501
676
|
}
|
|
677
|
+
const readRange = (from, to) => {
|
|
678
|
+
const len = Math.max(0, to - from);
|
|
679
|
+
if (len === 0)
|
|
680
|
+
return Buffer.alloc(0);
|
|
681
|
+
let fd;
|
|
682
|
+
try {
|
|
683
|
+
fd = fs.openSync(logpath(name), "r");
|
|
684
|
+
const buf = Buffer.alloc(len);
|
|
685
|
+
const n = fs.readSync(fd, buf, 0, len, from);
|
|
686
|
+
return n === len ? buf : buf.subarray(0, Math.max(0, n));
|
|
687
|
+
}
|
|
688
|
+
catch {
|
|
689
|
+
return Buffer.alloc(0);
|
|
690
|
+
}
|
|
691
|
+
finally {
|
|
692
|
+
if (fd !== undefined) {
|
|
693
|
+
try {
|
|
694
|
+
fs.closeSync(fd);
|
|
695
|
+
}
|
|
696
|
+
catch {
|
|
697
|
+
/* noop */
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
};
|
|
502
702
|
let text;
|
|
703
|
+
let nextOffset = size; // 既定/full は offset を末尾へ
|
|
503
704
|
if (o.full || o.range) {
|
|
504
|
-
|
|
705
|
+
// full/range は全体が対象だが、巨大ログは末尾 MAX_FULL_BYTES に制限してメモリを守る(B7)。
|
|
706
|
+
let from = 0;
|
|
707
|
+
if (size > MAX_FULL_BYTES)
|
|
708
|
+
from = size - MAX_FULL_BYTES;
|
|
709
|
+
text = readRange(from, size).toString("utf8");
|
|
710
|
+
if (from > 0)
|
|
711
|
+
text = `[… 先頭 ${from} バイトを省略(ログがサイズ上限を超過。close で破棄されます)…]\n` + text;
|
|
505
712
|
if (o.range) {
|
|
506
713
|
const [lo, hi] = o.range;
|
|
507
714
|
text = text.split("\n").slice(lo, hi ?? undefined).join("\n");
|
|
508
715
|
}
|
|
716
|
+
else if (o.lines) {
|
|
717
|
+
// full + lines は末尾 N 行にする(従来は full 経路で lines を黙殺していた footgun・B11)。
|
|
718
|
+
text = text.split("\n").slice(-o.lines).join("\n");
|
|
719
|
+
}
|
|
509
720
|
}
|
|
510
721
|
else {
|
|
511
722
|
let off = readOffset(name);
|
|
512
723
|
// WSL 再起動等でログが作り直されると、Windows 側に残った旧 offset が新ログ長を超え、
|
|
513
|
-
//
|
|
514
|
-
if (off >
|
|
724
|
+
// 空を返して「何も読めない」状態になる。末尾越えは先頭から読み直す(POSIX では no-op)。
|
|
725
|
+
if (off > size)
|
|
515
726
|
off = 0;
|
|
516
|
-
|
|
727
|
+
const buf = readRange(off, size); // 増分のみをメモリに載せる
|
|
728
|
+
// 末尾の不完全マルチバイト列は次回へ持ち越す(B3)。off は前回この分岐が safeEnd を書くので先頭は割れない。
|
|
729
|
+
const safeLen = utf8SafeEnd(buf, buf.length);
|
|
730
|
+
text = buf.subarray(0, safeLen).toString("utf8");
|
|
731
|
+
nextOffset = off + safeLen;
|
|
517
732
|
if (o.lines)
|
|
518
733
|
text = text.split("\n").slice(-o.lines).join("\n");
|
|
519
734
|
}
|
|
520
735
|
if (!o.range)
|
|
521
|
-
writeOffset(name,
|
|
736
|
+
writeOffset(name, nextOffset);
|
|
522
737
|
if (o.raw)
|
|
523
738
|
return text.endsWith("\n") ? text : text + "\n";
|
|
524
739
|
if (o.rtk) {
|
|
@@ -550,7 +765,7 @@ export function listSessions() {
|
|
|
550
765
|
export function closeSession(name) {
|
|
551
766
|
assertSessionName(name);
|
|
552
767
|
tmux("kill-session", "-t", name);
|
|
553
|
-
for (const p of [logpath(name), offsetpath(name), lastcmdpath(name)]) {
|
|
768
|
+
for (const p of [logpath(name), offsetpath(name), lastcmdpath(name), markpath(name)]) {
|
|
554
769
|
try {
|
|
555
770
|
fs.unlinkSync(p);
|
|
556
771
|
}
|
|
@@ -562,5 +777,145 @@ export function closeSession(name) {
|
|
|
562
777
|
}
|
|
563
778
|
export function killAll() {
|
|
564
779
|
tmux("kill-server");
|
|
780
|
+
// B9: SOCKDIR 内の .log/.offset/.lastcmd/.mark 残骸も掃除する(残すと B5 の stale-log 復活の温床)。
|
|
781
|
+
try {
|
|
782
|
+
for (const f of fs.readdirSync(SOCKDIR)) {
|
|
783
|
+
if (/\.(log|offset|lastcmd|mark)$/.test(f)) {
|
|
784
|
+
try {
|
|
785
|
+
fs.unlinkSync(path.join(SOCKDIR, f));
|
|
786
|
+
}
|
|
787
|
+
catch {
|
|
788
|
+
/* noop */
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
catch {
|
|
794
|
+
/* SOCKDIR 不在等は無視 */
|
|
795
|
+
}
|
|
565
796
|
return "killed all sessions on this socket";
|
|
566
797
|
}
|
|
798
|
+
function resolveAgentBin(kind) {
|
|
799
|
+
const home = process.env.HOME ?? os.homedir();
|
|
800
|
+
const [envVar, rel, name] = kind === "codex"
|
|
801
|
+
? ["CODEX_BIN", [".local", "bin", "codex"], "codex"]
|
|
802
|
+
: ["GROK_BIN", [".grok", "bin", "grok"], "grok"];
|
|
803
|
+
const fromEnv = process.env[envVar];
|
|
804
|
+
if (fromEnv) {
|
|
805
|
+
// 明示指定 env は実在を検証する。存在しないパスを黙って返すと、session を作って
|
|
806
|
+
// `'/typo' ...` を送信し bash が command not found を出すだけで openAgent は「起動した」と
|
|
807
|
+
// 偽成功を返す(既定パス/PATH 経路は検証するのに env だけ無検証だった非対称の解消・A3)。
|
|
808
|
+
if (fs.existsSync(fromEnv))
|
|
809
|
+
return fromEnv;
|
|
810
|
+
throw new AitermError(`${envVar} に指定された ${name} が存在しません: ${fromEnv}`, 2);
|
|
811
|
+
}
|
|
812
|
+
const cand = path.join(home, ...rel);
|
|
813
|
+
if (fs.existsSync(cand))
|
|
814
|
+
return cand;
|
|
815
|
+
const w = spawnSync(isWin ? "where" : "which", [name], {
|
|
816
|
+
encoding: "utf8",
|
|
817
|
+
timeout: 5000,
|
|
818
|
+
});
|
|
819
|
+
if (w.status === 0 && (w.stdout ?? "").trim())
|
|
820
|
+
return w.stdout.trim().split(/\r?\n/)[0];
|
|
821
|
+
return null;
|
|
822
|
+
}
|
|
823
|
+
// 単一引用符で安全に包む(' は '\'' で脱出)。send は raw:true で送るため自前で quote する。
|
|
824
|
+
function shq(s) {
|
|
825
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
826
|
+
}
|
|
827
|
+
function buildAgentCmd(kind, bin, effort, prompt) {
|
|
828
|
+
const parts = [shq(bin)];
|
|
829
|
+
if (kind === "codex") {
|
|
830
|
+
// codex は config override で reasoning effort(例: low/medium/high)を渡す。
|
|
831
|
+
if (effort)
|
|
832
|
+
parts.push("-c", `model_reasoning_effort=${shq(effort)}`);
|
|
833
|
+
}
|
|
834
|
+
else {
|
|
835
|
+
// grok / composer は同じ grok CLI をモデル違いで起動。effort は low/medium/high/xhigh/max。
|
|
836
|
+
parts.push("--model", kind === "composer" ? "grok-composer-2.5-fast" : "grok-build");
|
|
837
|
+
if (effort)
|
|
838
|
+
parts.push("--effort", shq(effort));
|
|
839
|
+
}
|
|
840
|
+
if (prompt)
|
|
841
|
+
parts.push(shq(prompt)); // 初手プロンプト(任意)
|
|
842
|
+
return parts.join(" ");
|
|
843
|
+
}
|
|
844
|
+
function agentLabel(kind) {
|
|
845
|
+
return kind === "composer"
|
|
846
|
+
? "Grok Build(Composer)"
|
|
847
|
+
: kind === "grok"
|
|
848
|
+
? "Grok Build(Grok)"
|
|
849
|
+
: "Codex";
|
|
850
|
+
}
|
|
851
|
+
// grok CLI の --effort が受ける値集合(codex は CLI 側の値集合が版で変わるため縛らない)。
|
|
852
|
+
const GROK_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
|
853
|
+
export function openAgent(kind, opts = {}) {
|
|
854
|
+
const label = agentLabel(kind);
|
|
855
|
+
// 前提検証は session を作る前に全部済ませる(失敗の残骸 session を作らない)。
|
|
856
|
+
// effort → bin → cwd の順: effort 検証は CLI 不在の端末でも同じ結果になる(テスト可能性)。
|
|
857
|
+
const effort = opts.reasoning_effort ?? null;
|
|
858
|
+
if (effort && kind !== "codex" && !GROK_EFFORTS.has(effort)) {
|
|
859
|
+
throw new AitermError(`reasoning_effort '${effort}' は不正です(${label} は low/medium/high/xhigh/max)`, 2);
|
|
860
|
+
}
|
|
861
|
+
const bin = resolveAgentBin(kind);
|
|
862
|
+
if (!bin) {
|
|
863
|
+
const where = kind === "codex" ? "~/.local/bin/codex" : "~/.grok/bin/grok";
|
|
864
|
+
throw new AitermError(`${label} の CLI が見つかりません(${where} か PATH が必要)`, 2);
|
|
865
|
+
}
|
|
866
|
+
// cwd 検証(session を作る前に。cd 失敗はシェル内で静かに死に「起動した」と偽成功を返すため)。
|
|
867
|
+
let cwd = null;
|
|
868
|
+
if (opts.cwd != null) {
|
|
869
|
+
if (!opts.cwd.trim()) {
|
|
870
|
+
throw new AitermError("cwd が空文字です(省略するか有効なディレクトリを指定してください)", 2); // A6
|
|
871
|
+
}
|
|
872
|
+
if (opts.cwd.startsWith("~")) {
|
|
873
|
+
// statSync は ~ を展開しない。「存在しません」でなく展開されない旨を正直に伝える(A6)。
|
|
874
|
+
throw new AitermError(`cwd の ~ は展開されません。絶対パスで指定してください: ${opts.cwd}`, 2);
|
|
875
|
+
}
|
|
876
|
+
let st = null;
|
|
877
|
+
try {
|
|
878
|
+
st = fs.statSync(opts.cwd);
|
|
879
|
+
}
|
|
880
|
+
catch {
|
|
881
|
+
st = null;
|
|
882
|
+
}
|
|
883
|
+
if (!st || !st.isDirectory()) {
|
|
884
|
+
throw new AitermError(`cwd '${opts.cwd}' がディレクトリとして存在しません`, 2);
|
|
885
|
+
}
|
|
886
|
+
cwd = opts.cwd;
|
|
887
|
+
}
|
|
888
|
+
// Windows は起動コマンドが WSL 内 bash で走る(tmux ブリッジ)。bin/cwd を /mnt/c/... 形へ変換して
|
|
889
|
+
// 渡す(ログの toWslPath と対称・A1)。前提: Windows 側に CLI を導入(resolveAgentBin が Windows
|
|
890
|
+
// パスで解決)。toWslPath は session を作る前に呼ぶ=変換失敗(非ドライブパス)で残骸 session を残さない。
|
|
891
|
+
// 未検証リスク: npm グローバル導入の codex.cmd/.bat シムや WSL interop 上の対話 TUI 描画は実 Windows
|
|
892
|
+
// でしか確認できない(CI 非対象。docs/03_audit-sweep-2026-07.md 参照)。
|
|
893
|
+
const binForCmd = isWin ? toWslPath(bin) : bin;
|
|
894
|
+
const cwdForCmd = cwd && isWin ? toWslPath(cwd) : cwd;
|
|
895
|
+
const [sid, hint] = openSession(opts.session_name ?? null, "bash");
|
|
896
|
+
const cmd = buildAgentCmd(kind, binForCmd, effort, opts.prompt ?? null);
|
|
897
|
+
const full = cwdForCmd ? `cd ${shq(cwdForCmd)} && ${cmd}` : cmd;
|
|
898
|
+
try {
|
|
899
|
+
// force:true で送る。起動骨格は `bin '...'` の固定形で、prompt/cwd/effort は shq でクオート済みの
|
|
900
|
+
// 引数=シェルは決して破壊コマンドとして実行しない。破壊ゲート(生シェルコマンド想定)を prompt に
|
|
901
|
+
// 掛けるのは純誤検知で、`codex 'rm -rf / を説明して'` 等の正当な起動を塞いでしまう(A4)。
|
|
902
|
+
send(sid, full, { enter: true, mark: false, force: true, rtk: false, raw: true });
|
|
903
|
+
}
|
|
904
|
+
catch (e) {
|
|
905
|
+
// 起動コマンドを投入できなかった session は空のまま残る=残骸を作らない。片付けてから元エラーを伝える。
|
|
906
|
+
try {
|
|
907
|
+
closeSession(sid);
|
|
908
|
+
}
|
|
909
|
+
catch {
|
|
910
|
+
/* 片付け失敗より元エラーの伝達を優先 */
|
|
911
|
+
}
|
|
912
|
+
throw e;
|
|
913
|
+
}
|
|
914
|
+
return [
|
|
915
|
+
sid,
|
|
916
|
+
`${label} を session ${sid} で起動した。\n${hint}\n` +
|
|
917
|
+
`TUI の描画には数秒かかる。少し置いてから pty_read(${sid}, screen:true) で画面を読み、` +
|
|
918
|
+
`pty_send(${sid}, "...") で入力・pty_key(${sid}, "Enter"/"Up"/"C-c" 等) で操作する(対話)。` +
|
|
919
|
+
`起動直後に増分 pty_read すると空/半描画になり得るので screen:true を使う。`,
|
|
920
|
+
];
|
|
921
|
+
}
|