pi-web-ui 0.32.0 → 0.34.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/LICENSE +21 -21
- package/README.md +295 -295
- package/README.zh-CN.md +279 -279
- package/bin/pi-web-ui.mjs +291 -2
- package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
- package/deploy/nginx-subpath.conf +88 -88
- package/deploy/pi-web-ui-task.xml +71 -71
- package/deploy/pi-web-ui.service +31 -31
- package/dist/server/agent-service.js +247 -15
- package/dist/server/attachments.js +3 -3
- package/dist/server/client-state.js +29 -0
- package/dist/server/index.js +61 -0
- package/dist/server/plugins.js +368 -0
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/settings-service.js +19 -0
- package/dist/server/terminals.js +487 -8
- package/extensions/webui.ts +192 -192
- package/package.json +96 -96
- package/themes/md-preview.css +7026 -6911
- package/themes/white.css +7087 -6972
- package/web/dist/assets/{TerminalPanel-D9N7NOQl.js → TerminalPanel-DXxlbXLf.js} +1 -1
- package/web/dist/assets/index-8y0w43os.css +10 -0
- package/web/dist/assets/index-CNrY4Azz.js +19 -0
- package/web/dist/favicon.svg +8 -8
- package/web/dist/index.html +15 -15
- package/web/public/favicon.svg +8 -8
- package/web/dist/assets/index-C9LigvHH.css +0 -10
- package/web/dist/assets/index-vcrKbPaa.js +0 -19
package/dist/server/terminals.js
CHANGED
|
@@ -115,6 +115,58 @@ const MAX_TERMINAL_HISTORY = 32;
|
|
|
115
115
|
const MAX_OUTPUT = 200_000;
|
|
116
116
|
const MAX_INPUT = 64 * 1024;
|
|
117
117
|
const MAX_ID = 80;
|
|
118
|
+
/**
|
|
119
|
+
* 终端活力检测阈值:agent 触碰过的终端连续静默这么久且该对话正在运行时,
|
|
120
|
+
* 通过 onAgentIdle 回调通知宿主(宿主注入一条 steer 消息唤醒 AI 去检查)。
|
|
121
|
+
* PI_WEB_TERMINAL_IDLE_MS 覆盖;0 = 关闭检测。每次调用时读取(测试可注入)。
|
|
122
|
+
*/
|
|
123
|
+
export function terminalIdleNotifyMs() {
|
|
124
|
+
const raw = Number(process.env.PI_WEB_TERMINAL_IDLE_MS);
|
|
125
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : 15_000;
|
|
126
|
+
}
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// 终端接管 bash(terminal-backed bash tool)
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
/** 哨兵行:命令执行完后由 shell 打印,携带真实退出码。正则只匹配数字,
|
|
131
|
+
* 因此不会误匹配回显里的 printf 格式串 `[pi-exit:%s]`。 */
|
|
132
|
+
const BASH_SENTINEL_RE = /\[pi-exit:(\d+)\]/g;
|
|
133
|
+
/**
|
|
134
|
+
* 把任意命令(含多行脚本)构造成「一行」交互 shell 命令:执行 + 捕获退出码。
|
|
135
|
+
*
|
|
136
|
+
* 单行很关键:整行先被 shell 完整解析再执行,命令中途读 stdin 也不会吃掉
|
|
137
|
+
* 后续哨兵;也避开交互 shell 的 bracketed-paste 对多行输入的特殊处理。
|
|
138
|
+
* 多行脚本用 `$'...'` ANSI-C 引号转义后交给 eval(bash/zsh/busybox ash 都支持)。
|
|
139
|
+
*/
|
|
140
|
+
export function buildTerminalBashLine(command) {
|
|
141
|
+
const trimmed = command.replace(/\s+$/, "");
|
|
142
|
+
let body = trimmed;
|
|
143
|
+
if (trimmed.includes("\n")) {
|
|
144
|
+
body = `eval $'${trimmed
|
|
145
|
+
.replace(/\\/g, "\\\\")
|
|
146
|
+
.replace(/'/g, "\\'")
|
|
147
|
+
.replace(/\r/g, "\\r")
|
|
148
|
+
.replace(/\n/g, "\\n")
|
|
149
|
+
.replace(/\t/g, "\\t")}'`;
|
|
150
|
+
}
|
|
151
|
+
return `${body}; __pi_rc=$?; printf '\\n[pi-exit:%s]\\n' "$__pi_rc"`;
|
|
152
|
+
}
|
|
153
|
+
/** 去掉 ANSI 转义序列(OSC/CSI/其余 ESC 序列)与孤立 CR(进度条重绘),
|
|
154
|
+
* 让 PTY 回显变成 bash 工具风格的纯文本。 */
|
|
155
|
+
export function stripAnsi(s) {
|
|
156
|
+
return s
|
|
157
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "") // OSC(标题/超链接等)
|
|
158
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "") // CSI(颜色/光标/清屏等)
|
|
159
|
+
.replace(/\x1b[@-_]/g, "") // 其余单字符 ESC 序列
|
|
160
|
+
.replace(/\r(?!\n)/g, ""); // 孤立 CR(进度条原地重绘)
|
|
161
|
+
}
|
|
162
|
+
/** 截断过长的工具结果:保留头尾,中间省略。 */
|
|
163
|
+
function truncateMiddle(text, max = 30_000) {
|
|
164
|
+
if (text.length <= max)
|
|
165
|
+
return text;
|
|
166
|
+
const head = Math.floor(max * 0.3);
|
|
167
|
+
const tail = max - head;
|
|
168
|
+
return `${text.slice(0, head)}\n…(中间省略 ${text.length - max} 字符)…\n${text.slice(-tail)}`;
|
|
169
|
+
}
|
|
118
170
|
/** `-i` makes bash interactive; cmd.exe / powershell.exe are interactive on their own. */
|
|
119
171
|
function bashArgs(shell) {
|
|
120
172
|
return /[\\/]bash(\.exe)?$/i.test(shell) ? ["-i"] : [];
|
|
@@ -159,6 +211,33 @@ function resolveShell() {
|
|
|
159
211
|
}
|
|
160
212
|
return { shell: process.env.SHELL || "bash", args: ["-i"] };
|
|
161
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* Shell for the terminal-backed bash tool ('ai-bash'): ALWAYS bash, never the
|
|
216
|
+
* user's login shell (often zsh on macOS, whose `read -p` etc. diverge from
|
|
217
|
+
* the bash semantics models write). Windows already prefers Git Bash/busybox
|
|
218
|
+
* bash; on posix pick $SHELL when it is bash, else plain `bash`.
|
|
219
|
+
*/
|
|
220
|
+
function resolveBashShell() {
|
|
221
|
+
if (isWindows) {
|
|
222
|
+
const pf = process.env.ProgramFiles;
|
|
223
|
+
const pf86 = process.env["ProgramFiles(x86)"];
|
|
224
|
+
for (const cand of [
|
|
225
|
+
pf ? join(pf, "Git", "bin", "bash.exe") : "",
|
|
226
|
+
pf86 ? join(pf86, "Git", "bin", "bash.exe") : "",
|
|
227
|
+
]) {
|
|
228
|
+
if (cand && existsSync(cand))
|
|
229
|
+
return { shell: cand, args: ["-i"] };
|
|
230
|
+
}
|
|
231
|
+
const busybox = join(homedir(), ".pi-web", "bin", "bash.exe");
|
|
232
|
+
if (existsSync(busybox))
|
|
233
|
+
return { shell: busybox, args: ["-i"] };
|
|
234
|
+
}
|
|
235
|
+
const she = process.env.SHELL;
|
|
236
|
+
if (she && she.endsWith("bash") && existsSync(she)) {
|
|
237
|
+
return { shell: she, args: ["-i"] };
|
|
238
|
+
}
|
|
239
|
+
return { shell: "bash", args: ["-i"] };
|
|
240
|
+
}
|
|
162
241
|
/**
|
|
163
242
|
* Environment for spawned shells. System services (launchd/systemd) run with
|
|
164
243
|
* no locale variables, which puts the shell in the C locale: its line editor
|
|
@@ -358,12 +437,15 @@ export class TerminalManager {
|
|
|
358
437
|
history = new Map();
|
|
359
438
|
seq = 0;
|
|
360
439
|
tccHintShown = false;
|
|
440
|
+
/** 宿主回调:AI 触碰过的终端静默 ≥ 阈值时触发(一次性/纪元语义见
|
|
441
|
+
* noteAgentActivity)。宿主自行判断会话是否在运行并决定是否注入。 */
|
|
442
|
+
onAgentIdle = null;
|
|
361
443
|
constructor(emit, workspaceRoot) {
|
|
362
444
|
this.emit = emit;
|
|
363
445
|
this.workspaceRoot = workspaceRoot;
|
|
364
446
|
}
|
|
365
447
|
/** Start a plain interactive shell in the given directory. */
|
|
366
|
-
create(id, cwd, cols, rows, fallbackCwd, title) {
|
|
448
|
+
create(id, cwd, cols, rows, fallbackCwd, title, opts) {
|
|
367
449
|
const valid = this.validateId(id);
|
|
368
450
|
if (valid) {
|
|
369
451
|
this.fail(id, valid);
|
|
@@ -383,7 +465,7 @@ export class TerminalManager {
|
|
|
383
465
|
this.fail(id, "终端工作目录必须位于当前工作区内");
|
|
384
466
|
return null;
|
|
385
467
|
}
|
|
386
|
-
if (this.spawnShell(id, safeCwd, cols, rows, title || `终端 ${++this.seq}
|
|
468
|
+
if (this.spawnShell(id, safeCwd, cols, rows, title || `终端 ${++this.seq}`, undefined, opts?.forceBash)) {
|
|
387
469
|
this.maybeEmitTccHint(id);
|
|
388
470
|
this.emitList();
|
|
389
471
|
return this.info(this.terms.get(id));
|
|
@@ -462,7 +544,7 @@ export class TerminalManager {
|
|
|
462
544
|
this.input(id, command + "\r");
|
|
463
545
|
}
|
|
464
546
|
/** Spawn the user's shell as a PTY. Returns false when the spawn failed. */
|
|
465
|
-
spawnShell(id, cwd, cols, rows, title, command) {
|
|
547
|
+
spawnShell(id, cwd, cols, rows, title, command, forceBash) {
|
|
466
548
|
let abs = cwd;
|
|
467
549
|
if (!abs)
|
|
468
550
|
abs = homedir();
|
|
@@ -483,7 +565,7 @@ export class TerminalManager {
|
|
|
483
565
|
repairSpawnHelperPermissions();
|
|
484
566
|
let pty;
|
|
485
567
|
try {
|
|
486
|
-
const { shell, args } = resolveShell();
|
|
568
|
+
const { shell, args } = forceBash ? resolveBashShell() : resolveShell();
|
|
487
569
|
pty = spawn(shell, args, {
|
|
488
570
|
name: "xterm-256color",
|
|
489
571
|
cols: Math.max(2, Math.floor(cols) || 80),
|
|
@@ -514,6 +596,10 @@ export class TerminalManager {
|
|
|
514
596
|
waiters: new Set(),
|
|
515
597
|
pendingOut: "",
|
|
516
598
|
flushTimer: null,
|
|
599
|
+
agentTouched: false,
|
|
600
|
+
lastActivityAt: Date.now(),
|
|
601
|
+
idleTimer: null,
|
|
602
|
+
watches: [],
|
|
517
603
|
};
|
|
518
604
|
this.terms.set(id, entry);
|
|
519
605
|
// The closures capture `entry`: after a restart the map points at the
|
|
@@ -537,6 +623,44 @@ export class TerminalManager {
|
|
|
537
623
|
});
|
|
538
624
|
return true;
|
|
539
625
|
}
|
|
626
|
+
/**
|
|
627
|
+
* 记录一次 agent 工具触碰并启动一个新的静默纪元(terminal_create /
|
|
628
|
+
* terminal_input / terminal_key 的工具包装层调用——浏览器路径绝不调用,
|
|
629
|
+
* 用户自己开的终端永远不会收到静默提醒)。
|
|
630
|
+
*
|
|
631
|
+
* 纪元语义(防骚扰):agentTouched 同时是「纪元武装」标志。看门狗触发
|
|
632
|
+
* 一次后即解除武装,之后无论静默多久都不再提醒,直到 agent 再次触碰
|
|
633
|
+
* (再发输入 = AI 又在等结果了)。纪元内的每一段输出都重置倒计时。
|
|
634
|
+
*/
|
|
635
|
+
noteAgentActivity(id) {
|
|
636
|
+
const entry = this.terms.get(id);
|
|
637
|
+
if (!entry || entry.exited)
|
|
638
|
+
return;
|
|
639
|
+
entry.agentTouched = true;
|
|
640
|
+
entry.lastActivityAt = Date.now();
|
|
641
|
+
this.armIdleWatch(entry);
|
|
642
|
+
}
|
|
643
|
+
/** 武装(或按当前 lastActivityAt 重置)静默看门狗。 */
|
|
644
|
+
armIdleWatch(entry) {
|
|
645
|
+
if (entry.idleTimer) {
|
|
646
|
+
clearTimeout(entry.idleTimer);
|
|
647
|
+
entry.idleTimer = null;
|
|
648
|
+
}
|
|
649
|
+
const idleMs = terminalIdleNotifyMs();
|
|
650
|
+
if (!entry.agentTouched || idleMs <= 0)
|
|
651
|
+
return;
|
|
652
|
+
const delay = Math.max(0, idleMs - (Date.now() - entry.lastActivityAt));
|
|
653
|
+
entry.idleTimer = setTimeout(() => {
|
|
654
|
+
entry.idleTimer = null;
|
|
655
|
+
// 原地重启/退出后旧 entry 的事件必须忽略(与 onData/onExit 同款守卫)。
|
|
656
|
+
if (this.terms.get(entry.id) !== entry || entry.exited)
|
|
657
|
+
return;
|
|
658
|
+
// 一次性:触发后解除武装,直到下次 agent 触碰。
|
|
659
|
+
entry.agentTouched = false;
|
|
660
|
+
this.onAgentIdle?.(entry.id, Date.now() - entry.lastActivityAt, entry.title);
|
|
661
|
+
}, delay);
|
|
662
|
+
entry.idleTimer.unref?.();
|
|
663
|
+
}
|
|
540
664
|
/** Emit output immediately, bypassing the coalescing window (rare paths:
|
|
541
665
|
* one-shot hints/banners — not per-chunk data). */
|
|
542
666
|
writeOut(id, data) {
|
|
@@ -574,6 +698,29 @@ export class TerminalManager {
|
|
|
574
698
|
for (const wake of entry.waiters)
|
|
575
699
|
wake();
|
|
576
700
|
entry.waiters.clear();
|
|
701
|
+
// 纪元内的输出重置静默倒计时。
|
|
702
|
+
entry.lastActivityAt = Date.now();
|
|
703
|
+
if (entry.idleTimer)
|
|
704
|
+
this.armIdleWatch(entry);
|
|
705
|
+
// 输出观察器:累积匹配,命中一次即移除(终端接管 bash 的完成检测)。
|
|
706
|
+
if (entry.watches.length > 0) {
|
|
707
|
+
const remaining = [];
|
|
708
|
+
const hits = [];
|
|
709
|
+
for (const w of entry.watches) {
|
|
710
|
+
w.buf += data;
|
|
711
|
+
if (w.buf.length > 64_000)
|
|
712
|
+
w.buf = w.buf.slice(-32_000);
|
|
713
|
+
w.re.lastIndex = 0;
|
|
714
|
+
const m = w.re.exec(w.buf);
|
|
715
|
+
if (m)
|
|
716
|
+
hits.push({ w, m }); // 命中 → 移出(cb 在下面统一触发)
|
|
717
|
+
else
|
|
718
|
+
remaining.push(w);
|
|
719
|
+
}
|
|
720
|
+
entry.watches = remaining;
|
|
721
|
+
for (const { w, m } of hits)
|
|
722
|
+
w.cb(m);
|
|
723
|
+
}
|
|
577
724
|
}
|
|
578
725
|
validateId(id) {
|
|
579
726
|
if (!id || id.length > MAX_ID || !/^[A-Za-z0-9._:-]+$/.test(id)) {
|
|
@@ -677,6 +824,10 @@ export class TerminalManager {
|
|
|
677
824
|
const entry = this.terms.get(id);
|
|
678
825
|
if (!entry || entry.exited)
|
|
679
826
|
return "终端不存在或进程已退出";
|
|
827
|
+
// 已武装的纪元里任何人(含用户手动敲键盘)写了输入都算新活动,重置倒计时。
|
|
828
|
+
entry.lastActivityAt = Date.now();
|
|
829
|
+
if (entry.idleTimer)
|
|
830
|
+
this.armIdleWatch(entry);
|
|
680
831
|
entry.pty.write(data);
|
|
681
832
|
return null;
|
|
682
833
|
}
|
|
@@ -686,6 +837,107 @@ export class TerminalManager {
|
|
|
686
837
|
return encoded.error;
|
|
687
838
|
return this.inputChecked(id, encoded.data);
|
|
688
839
|
}
|
|
840
|
+
/** 解除静默看门狗(退出/关闭/全部停止时)。 */
|
|
841
|
+
disarmIdleWatch(entry) {
|
|
842
|
+
if (entry.idleTimer) {
|
|
843
|
+
clearTimeout(entry.idleTimer);
|
|
844
|
+
entry.idleTimer = null;
|
|
845
|
+
}
|
|
846
|
+
entry.agentTouched = false;
|
|
847
|
+
}
|
|
848
|
+
/** 只拆钟不清标记(终端接管 bash 阻塞期间挂起活力提醒,避免双重通知)。 */
|
|
849
|
+
suspendIdleWatch(id) {
|
|
850
|
+
const entry = this.terms.get(id);
|
|
851
|
+
if (!entry || !entry.idleTimer)
|
|
852
|
+
return;
|
|
853
|
+
clearTimeout(entry.idleTimer);
|
|
854
|
+
entry.idleTimer = null;
|
|
855
|
+
}
|
|
856
|
+
/** 输出末尾的绝对 cursor(terminal-backed bash 的读取起点)。 */
|
|
857
|
+
endCursor(id) {
|
|
858
|
+
const entry = this.find(id);
|
|
859
|
+
if (!entry)
|
|
860
|
+
return null;
|
|
861
|
+
return entry.outputOffset + entry.output.length;
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* 阻塞等待当前前台命令结束(哨兵行出现或终端退出)。terminal_wait 工具
|
|
865
|
+
* 用它在静默解阻后「重新加入等待」——AI 不必反复 terminal_read 轮询。
|
|
866
|
+
*
|
|
867
|
+
* @param afterCursor 只认该绝对偏移之后的哨兵(排除上一条命令残留的旧标记;
|
|
868
|
+
* 调用方传 endCursor() 即表示「等我调用之后才出现的结束」)
|
|
869
|
+
* @returns finished=false 表示超时/中止(命令仍在跑),可再次调用继续等
|
|
870
|
+
*/
|
|
871
|
+
async waitForCompletion(id, timeoutMs, signal, afterCursor = 0) {
|
|
872
|
+
const entry = this.find(id);
|
|
873
|
+
if (!entry)
|
|
874
|
+
return { finished: false, exitCode: null };
|
|
875
|
+
return new Promise((resolve) => {
|
|
876
|
+
// 命令可能在调用前就已结束:先扫 afterCursor 之后的存量缓冲。
|
|
877
|
+
const relStart = Math.max(0, afterCursor - entry.outputOffset);
|
|
878
|
+
const segment = entry.output.slice(relStart);
|
|
879
|
+
const scan = new RegExp(BASH_SENTINEL_RE.source, BASH_SENTINEL_RE.flags);
|
|
880
|
+
scan.lastIndex = 0;
|
|
881
|
+
const existing = [...segment.matchAll(scan)].pop();
|
|
882
|
+
if (existing) {
|
|
883
|
+
resolve({ finished: true, exitCode: Number(existing[1]) });
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
if (entry.exited) {
|
|
887
|
+
resolve({ finished: true, exitCode: entry.exitCode });
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
let timer;
|
|
891
|
+
let settled = false;
|
|
892
|
+
let unwatch = () => { };
|
|
893
|
+
const onAbort = () => done({ finished: false, exitCode: null });
|
|
894
|
+
const done = (r) => {
|
|
895
|
+
if (settled)
|
|
896
|
+
return;
|
|
897
|
+
settled = true;
|
|
898
|
+
if (timer)
|
|
899
|
+
clearTimeout(timer);
|
|
900
|
+
signal?.removeEventListener("abort", onAbort);
|
|
901
|
+
unwatch();
|
|
902
|
+
resolve(r);
|
|
903
|
+
};
|
|
904
|
+
unwatch = this.watchOutput(id, BASH_SENTINEL_RE, (m) => {
|
|
905
|
+
// m=null = 终端被关闭/退出 → 命令肯定结束了(退出码未知)。
|
|
906
|
+
done({ finished: true, exitCode: m ? Number(m[1]) : null });
|
|
907
|
+
});
|
|
908
|
+
timer = setTimeout(() => done({ finished: false, exitCode: null }), Math.max(1, Math.min(timeoutMs, 600_000)));
|
|
909
|
+
timer.unref?.();
|
|
910
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
/** 标记/清除「哨兵待决」状态(终端接管 bash 工具专用)。 */
|
|
914
|
+
setSentinelPending(id, pending) {
|
|
915
|
+
const entry = this.find(id);
|
|
916
|
+
if (entry)
|
|
917
|
+
entry.sentinelPending = pending;
|
|
918
|
+
}
|
|
919
|
+
/** 是否有带哨兵的命令尚未结束(terminal_wait 的适用性判断)。 */
|
|
920
|
+
isSentinelPending(id) {
|
|
921
|
+
return this.find(id)?.sentinelPending === true;
|
|
922
|
+
}
|
|
923
|
+
/** 注册一次性输出观察器:命中 re 或终端退出时回调一次。返回注销函数。 */
|
|
924
|
+
watchOutput(id, re, cb) {
|
|
925
|
+
const entry = this.find(id);
|
|
926
|
+
if (!entry) {
|
|
927
|
+
cb(null);
|
|
928
|
+
return () => { };
|
|
929
|
+
}
|
|
930
|
+
// 每个观察器独立 regex 实例(global 正则的 lastIndex 是共享可变状态)。
|
|
931
|
+
const own = new RegExp(re.source, re.flags);
|
|
932
|
+
const watch = { re: own, buf: "", cb };
|
|
933
|
+
entry.watches.push(watch);
|
|
934
|
+
return () => {
|
|
935
|
+
const cur = this.find(id);
|
|
936
|
+
if (!cur)
|
|
937
|
+
return;
|
|
938
|
+
cur.watches = cur.watches.filter((w) => w !== watch);
|
|
939
|
+
};
|
|
940
|
+
}
|
|
689
941
|
/** Emit a terminal failure (bad cwd, spawn error) and mark the terminal dead. */
|
|
690
942
|
fail(id, text) {
|
|
691
943
|
this.emit({ type: "notice", level: "error", text });
|
|
@@ -700,12 +952,18 @@ export class TerminalManager {
|
|
|
700
952
|
const entry = this.terms.get(id);
|
|
701
953
|
if (!entry || entry.exited)
|
|
702
954
|
return;
|
|
955
|
+
this.disarmIdleWatch(entry);
|
|
703
956
|
// Flush queued output BEFORE the exit banner so ordering is preserved.
|
|
704
957
|
this.flushPending(entry);
|
|
705
958
|
const banner = `\r\n\x1b[90m[进程已退出,退出码 ${exitCode}]\x1b[0m\r\n`;
|
|
706
959
|
this.appendOutput(entry, banner);
|
|
707
960
|
this.emit({ type: "terminal_output", terminalId: id, data: banner });
|
|
708
961
|
entry.exited = true;
|
|
962
|
+
// 终端退出 → 未命中的输出观察器以 null 回调(宿主可据此通知「终端已关闭」)。
|
|
963
|
+
const pendingWatches = entry.watches;
|
|
964
|
+
entry.watches = [];
|
|
965
|
+
for (const w of pendingWatches)
|
|
966
|
+
w.cb(null);
|
|
709
967
|
entry.exitCode = exitCode;
|
|
710
968
|
this.terms.delete(id);
|
|
711
969
|
while (this.history.size >= MAX_TERMINAL_HISTORY) {
|
|
@@ -739,6 +997,11 @@ export class TerminalManager {
|
|
|
739
997
|
kill(id) {
|
|
740
998
|
const entry = this.terms.get(id);
|
|
741
999
|
if (entry) {
|
|
1000
|
+
this.disarmIdleWatch(entry);
|
|
1001
|
+
const killedWatches = entry.watches;
|
|
1002
|
+
entry.watches = [];
|
|
1003
|
+
for (const w of killedWatches)
|
|
1004
|
+
w.cb(null);
|
|
742
1005
|
this.flushPending(entry);
|
|
743
1006
|
entry.exited = true;
|
|
744
1007
|
try {
|
|
@@ -758,6 +1021,7 @@ export class TerminalManager {
|
|
|
758
1021
|
/** Kill every terminal owned by this conversation. */
|
|
759
1022
|
killAll() {
|
|
760
1023
|
for (const entry of this.terms.values()) {
|
|
1024
|
+
this.disarmIdleWatch(entry);
|
|
761
1025
|
if (entry.exited)
|
|
762
1026
|
continue;
|
|
763
1027
|
entry.exited = true;
|
|
@@ -772,12 +1036,187 @@ export class TerminalManager {
|
|
|
772
1036
|
for (const wake of entry.waiters)
|
|
773
1037
|
wake();
|
|
774
1038
|
entry.waiters.clear();
|
|
1039
|
+
for (const w of entry.watches)
|
|
1040
|
+
w.cb(null);
|
|
1041
|
+
entry.watches = [];
|
|
775
1042
|
}
|
|
776
1043
|
this.terms.clear();
|
|
777
1044
|
this.history.clear();
|
|
778
1045
|
this.emitList();
|
|
779
1046
|
}
|
|
780
1047
|
}
|
|
1048
|
+
// ---------------------------------------------------------------------------
|
|
1049
|
+
// 终端接管 bash:bash 风格工具跑在持久可见终端里
|
|
1050
|
+
// ---------------------------------------------------------------------------
|
|
1051
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1052
|
+
/** 取 collected 尾部里最后一个哨兵匹配(哨兵只可能出现在新输出的尾部)。 */
|
|
1053
|
+
function lastSentinel(collected) {
|
|
1054
|
+
const tail = collected.length > 8000 ? collected.slice(-8000) : collected;
|
|
1055
|
+
BASH_SENTINEL_RE.lastIndex = 0;
|
|
1056
|
+
return [...tail.matchAll(BASH_SENTINEL_RE)].pop() ?? null;
|
|
1057
|
+
}
|
|
1058
|
+
/** 去掉输入回显、哨兵及其后的 shell 提示符垃圾与 ANSI 序列,还原 bash 风格纯文本。 */
|
|
1059
|
+
function cleanBashOutput(raw) {
|
|
1060
|
+
let text = stripAnsi(raw).replace(/\r\n/g, "\n");
|
|
1061
|
+
// 回显的命令行可能被 readline 折行拆成多行,按行剥不可靠——改为锚定
|
|
1062
|
+
// printf 格式串字面量 [pi-exit:%s](真哨兵是数字版),连同其所在整行丢弃。
|
|
1063
|
+
// 注意:同一命令会被回显两次(PTY 输入回显 + readline 提示符回显),需循环。
|
|
1064
|
+
for (;;) {
|
|
1065
|
+
const fmtIdx = text.indexOf("[pi-exit:%s]");
|
|
1066
|
+
if (fmtIdx < 0)
|
|
1067
|
+
break;
|
|
1068
|
+
const nl = text.indexOf("\n", fmtIdx);
|
|
1069
|
+
text = nl >= 0 ? text.slice(nl + 1) : "";
|
|
1070
|
+
}
|
|
1071
|
+
// 最后一个哨兵之后的内容全是 shell 新提示符——整段截掉。
|
|
1072
|
+
BASH_SENTINEL_RE.lastIndex = 0;
|
|
1073
|
+
let last = null;
|
|
1074
|
+
for (let m = BASH_SENTINEL_RE.exec(text); m; m = BASH_SENTINEL_RE.exec(text)) {
|
|
1075
|
+
last = m;
|
|
1076
|
+
}
|
|
1077
|
+
if (last)
|
|
1078
|
+
text = text.slice(0, last.index);
|
|
1079
|
+
const lines = text.split("\n");
|
|
1080
|
+
while (lines.length > 0 && !lines[lines.length - 1].trim())
|
|
1081
|
+
lines.pop();
|
|
1082
|
+
return truncateMiddle(lines.join("\n").trim());
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* 终端接管的 bash 工具:模型看到的参数与 SDK bash 完全一致(command + 可选
|
|
1086
|
+
* timeout 秒),但执行体是往持久终端写命令并等哨兵行拿到真实退出码。
|
|
1087
|
+
*
|
|
1088
|
+
* 行为语义:
|
|
1089
|
+
* - 默认阻塞:等到命令结束才返回完整输出(ANSI 已清理)+ 真实退出码;
|
|
1090
|
+
* - 静默解阻:连续 idleMs 毫秒无新输出且未结束 → 立即返回「仍在运行」+ 已有
|
|
1091
|
+
* 输出,命令留在终端里继续跑,并注册完成观察器——结束后由宿主
|
|
1092
|
+
* notifyBackgroundDone 主动通知 AI(流式中 steer / 空闲时 nextTurn);
|
|
1093
|
+
* - abort_bash 支持:AbortController 注册进 kills 集合,abort 时向 PTY 发
|
|
1094
|
+
* Ctrl+C 杀前台进程,对话继续(与 makeKillableBashTool 同一套集合);
|
|
1095
|
+
* - shell 状态跨调用保留(cd / venv activate / ssh 会话)——这是原生 bash
|
|
1096
|
+
* 工具做不到的。
|
|
1097
|
+
*/
|
|
1098
|
+
export function makeTerminalBashTool(terminals, opts) {
|
|
1099
|
+
const TERM_ID = "ai-bash";
|
|
1100
|
+
return defineTool({
|
|
1101
|
+
name: "bash",
|
|
1102
|
+
label: "Run bash command",
|
|
1103
|
+
description: "Run a shell command and return its full output plus exit code. Commands execute in a PERSISTENT visible terminal ('ai-bash'): shell state such as cd, venv activation or ssh sessions is retained across calls. Run the bare command — do NOT pipe through tail/head/more/less (output is returned complete anyway, and pipes hide live progress in the visible terminal). If a command stays silent for a while it keeps running in the background and you get an automatic notice when it finishes; use terminal_wait to re-block until it finishes, or terminal_read / terminal_input / terminal_key on 'ai-bash' to observe or interact anytime.",
|
|
1104
|
+
promptSnippet: "run commands in the persistent visible terminal (state retained across calls)",
|
|
1105
|
+
parameters: Type.Object({
|
|
1106
|
+
command: Type.String({ description: "The shell command to run" }),
|
|
1107
|
+
timeout: Type.Optional(Type.Number({ description: "Optional timeout in seconds" })),
|
|
1108
|
+
tail: Type.Optional(Type.Integer({
|
|
1109
|
+
minimum: 1,
|
|
1110
|
+
maximum: 5000,
|
|
1111
|
+
description: "Only return the LAST N lines of output (like `| tail -N`). Use this for verbose commands instead of piping through tail — the command keeps streaming live to the visible terminal while you only get the tail back.",
|
|
1112
|
+
})),
|
|
1113
|
+
}),
|
|
1114
|
+
execute: async (_id, p, signal) => {
|
|
1115
|
+
// create() 对已存活的同名终端原样返回、对已退出的原地重启。
|
|
1116
|
+
// forceBash:该终端永远跑 bash(而非用户登录 shell),模型写的
|
|
1117
|
+
// bash 语法(数组/read -p/process substitution…)不会踩 zsh 差异。
|
|
1118
|
+
if (terminals.create(TERM_ID, opts.cwd, 120, 40, opts.cwd, "AI bash", {
|
|
1119
|
+
forceBash: true,
|
|
1120
|
+
}) === null) {
|
|
1121
|
+
throw new Error(`无法打开 AI bash 终端(${TERM_ID})`);
|
|
1122
|
+
}
|
|
1123
|
+
// 阻塞等待期间挂起活力提醒(我们自己在检测静默,避免双重通知)。
|
|
1124
|
+
terminals.suspendIdleWatch(TERM_ID);
|
|
1125
|
+
const start = terminals.endCursor(TERM_ID);
|
|
1126
|
+
const ac = new AbortController();
|
|
1127
|
+
opts.kills.add(ac);
|
|
1128
|
+
const idleMs = Math.max(0, opts.idleMs());
|
|
1129
|
+
const deadline = p.timeout && p.timeout > 0 ? Date.now() + p.timeout * 1000 : null;
|
|
1130
|
+
// tail 参数:只返回末尾 N 行(替代 `| tail -N` 管道——管道会缓冲输出、
|
|
1131
|
+
// 让可见终端全程哑火,还容易白白触发静默解阻)。
|
|
1132
|
+
const applyTail = (t) => {
|
|
1133
|
+
if (!p.tail || p.tail <= 0)
|
|
1134
|
+
return t;
|
|
1135
|
+
const lines = t.split("\n");
|
|
1136
|
+
return lines.length > p.tail
|
|
1137
|
+
? `…(前 ${lines.length - p.tail} 行已省略)\n${lines.slice(-p.tail).join("\n")}`
|
|
1138
|
+
: t;
|
|
1139
|
+
};
|
|
1140
|
+
try {
|
|
1141
|
+
let collected = "";
|
|
1142
|
+
let cursor = start;
|
|
1143
|
+
let lastDataAt = Date.now();
|
|
1144
|
+
// 标记「有哨兵命令在跑」:terminal_wait 据此区分等待与空闲。
|
|
1145
|
+
terminals.setSentinelPending(TERM_ID, true);
|
|
1146
|
+
const inputErr = terminals.inputChecked(TERM_ID, buildTerminalBashLine(p.command) + "\r");
|
|
1147
|
+
if (inputErr)
|
|
1148
|
+
throw new Error(inputErr);
|
|
1149
|
+
for (;;) {
|
|
1150
|
+
if (ac.signal.aborted || signal?.aborted) {
|
|
1151
|
+
// Ctrl+C 杀前台进程;终端本身保留(会话状态还在)。
|
|
1152
|
+
terminals.setSentinelPending(TERM_ID, false);
|
|
1153
|
+
terminals.inputChecked(TERM_ID, "\x03");
|
|
1154
|
+
throw new Error("Command aborted");
|
|
1155
|
+
}
|
|
1156
|
+
await sleep(60);
|
|
1157
|
+
const read = terminals.read(TERM_ID, cursor);
|
|
1158
|
+
if (read?.data) {
|
|
1159
|
+
collected += read.data;
|
|
1160
|
+
cursor = read.cursor;
|
|
1161
|
+
lastDataAt = Date.now();
|
|
1162
|
+
}
|
|
1163
|
+
const m = lastSentinel(collected);
|
|
1164
|
+
if (m) {
|
|
1165
|
+
terminals.setSentinelPending(TERM_ID, false);
|
|
1166
|
+
const text = applyTail(cleanBashOutput(collected));
|
|
1167
|
+
return {
|
|
1168
|
+
content: [
|
|
1169
|
+
{
|
|
1170
|
+
type: "text",
|
|
1171
|
+
text: `${text}${text ? "\n" : ""}[exit:${m[1]}]`,
|
|
1172
|
+
},
|
|
1173
|
+
],
|
|
1174
|
+
details: { exitCode: Number(m[1]), output: text },
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
if (deadline !== null && Date.now() > deadline) {
|
|
1178
|
+
terminals.setSentinelPending(TERM_ID, false);
|
|
1179
|
+
terminals.inputChecked(TERM_ID, "\x03");
|
|
1180
|
+
throw new Error(`Command timed out after ${p.timeout}s(已发 Ctrl+C;已有输出:${truncateMiddle(stripAnsi(collected), 4000)})`);
|
|
1181
|
+
}
|
|
1182
|
+
// 静默解阻:转后台 + 注册完成观察器,立即把控制权还给模型。
|
|
1183
|
+
if (idleMs > 0 && Date.now() - lastDataAt >= idleMs) {
|
|
1184
|
+
return backgroundResult(terminals, opts, p.command, applyTail(cleanBashOutput(collected)), Math.round((Date.now() - lastDataAt) / 1000));
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
finally {
|
|
1189
|
+
opts.kills.delete(ac);
|
|
1190
|
+
}
|
|
1191
|
+
},
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
/** 静默解阻路径:注册完成观察器后立即返回「仍在后台运行」。 */
|
|
1195
|
+
function backgroundResult(terminals, opts, command, partialText, silentSeconds) {
|
|
1196
|
+
terminals.watchOutput("ai-bash", BASH_SENTINEL_RE, (m) => {
|
|
1197
|
+
// 后台命令最终结束(或终端被关)→ 清除待决标记,terminal_wait 不再适用。
|
|
1198
|
+
terminals.setSentinelPending("ai-bash", false);
|
|
1199
|
+
opts.notifyBackgroundDone({
|
|
1200
|
+
terminalId: "ai-bash",
|
|
1201
|
+
command,
|
|
1202
|
+
exitCode: m ? Number(m[1]) : null,
|
|
1203
|
+
});
|
|
1204
|
+
});
|
|
1205
|
+
// partialText 已在调用方做过 cleanBashOutput + applyTail。
|
|
1206
|
+
const partial = truncateMiddle(partialText, 6000);
|
|
1207
|
+
return {
|
|
1208
|
+
content: [
|
|
1209
|
+
{
|
|
1210
|
+
type: "text",
|
|
1211
|
+
text: `命令仍在持久终端 ai-bash 中运行(已连续 ${silentSeconds} 秒无输出,未结束)。` +
|
|
1212
|
+
`本次调用不阻塞——命令继续在后台执行,结束时你会收到自动通知。\n` +
|
|
1213
|
+
`已有输出:\n${partial || "(暂无输出)"}\n` +
|
|
1214
|
+
`要重新阻塞等它结束就用 terminal_wait(terminalId="ai-bash")(无需反复轮询);需要交互用 terminal_input / terminal_key(Ctrl+C 可终止)。`,
|
|
1215
|
+
},
|
|
1216
|
+
],
|
|
1217
|
+
details: { running: true, terminalId: "ai-bash", silentSeconds },
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
781
1220
|
/** Names of the agent-facing persistent-terminal tools(设置开关门控用)。 */
|
|
782
1221
|
export const TERMINAL_TOOL_NAMES = [
|
|
783
1222
|
"terminal_create",
|
|
@@ -786,14 +1225,16 @@ export const TERMINAL_TOOL_NAMES = [
|
|
|
786
1225
|
"terminal_input",
|
|
787
1226
|
"terminal_key",
|
|
788
1227
|
"terminal_read",
|
|
1228
|
+
"terminal_wait",
|
|
789
1229
|
];
|
|
790
1230
|
/** System-prompt guidance teaching the model WHEN to prefer the terminal tools
|
|
791
1231
|
* over one-shot bash. Without it models almost never pick them — bash returns
|
|
792
1232
|
* complete output in a single call, so it always wins on convenience. */
|
|
793
|
-
export const TERMINAL_TOOLS_GUIDANCE = `Persistent interactive terminal tools are available (terminal_create / terminal_list / terminal_close / terminal_input / terminal_key / terminal_read). The one-shot bash tool stays the DEFAULT for ordinary commands - it runs once and returns the full output. Switch to the terminal tools only when:
|
|
794
|
-
- The program is interactive or TUI-based (REPLs like python/node, vim/htop, installers asking y/n, anything waiting on stdin).
|
|
795
|
-
- You start a long-running server or watcher and want to keep watching its output (terminal_read with waitMs)
|
|
796
|
-
- The user explicitly asks you to work in the visible terminal panel.
|
|
1233
|
+
export const TERMINAL_TOOLS_GUIDANCE = `Persistent interactive terminal tools are available (terminal_create / terminal_list / terminal_close / terminal_input / terminal_key / terminal_read / terminal_wait). The one-shot bash tool stays the DEFAULT for ordinary commands - it runs once and returns the full output. Switch to the terminal tools only when:
|
|
1234
|
+
- The program is interactive or TUI-based (REPLs like python/node, vim/htop, installers asking y/n, anything waiting on stdin).
|
|
1235
|
+
- You start a long-running server or watcher and want to keep watching its output (terminal_read with waitMs), send keys to it later (e.g. interrupt via terminal_key with Ctrl+c), or block until a backgrounded command finishes without polling (terminal_wait).
|
|
1236
|
+
- The user explicitly asks you to work in the visible terminal panel.
|
|
1237
|
+
Liveness watchdog: terminals you touched (create/input/key) are monitored - if one goes silent with no new output while you are working (default 15s), an automatic system reminder is injected into the conversation. Treat it as a prompt to check that terminal (terminal_read), respond to an input prompt (terminal_input / terminal_key), or close it (terminal_close) if it is no longer needed.
|
|
797
1238
|
Do NOT use them for simple one-shot commands; bash remains cheaper and simpler there.`;
|
|
798
1239
|
/** Build the agent-facing persistent terminal tools for one conversation. */
|
|
799
1240
|
export function makePersistentTerminalTools(terminals, cwd) {
|
|
@@ -818,6 +1259,8 @@ export function makePersistentTerminalTools(terminals, cwd) {
|
|
|
818
1259
|
const info = terminals.create(p.terminalId, p.cwd ?? cwd, p.cols ?? 120, p.rows ?? 40, cwd, p.terminalId);
|
|
819
1260
|
if (!info)
|
|
820
1261
|
throw new Error(`创建终端失败:${p.terminalId}`);
|
|
1262
|
+
// AI 创建 → 启动活力检测纪元(静默提醒只针对 agent 触碰过的终端)。
|
|
1263
|
+
terminals.noteAgentActivity(p.terminalId);
|
|
821
1264
|
return result(`终端已创建:${JSON.stringify(info)}`, info);
|
|
822
1265
|
},
|
|
823
1266
|
}),
|
|
@@ -848,6 +1291,8 @@ export function makePersistentTerminalTools(terminals, cwd) {
|
|
|
848
1291
|
parameters: Type.Object({ terminalId: Type.String(), data: Type.String() }),
|
|
849
1292
|
execute: async (_id, p) => {
|
|
850
1293
|
failIf(terminals.inputChecked(p.terminalId, p.data));
|
|
1294
|
+
// AI 发了输入 = 在等结果,重开一个静默纪元。
|
|
1295
|
+
terminals.noteAgentActivity(p.terminalId);
|
|
851
1296
|
return result(`已发送 ${p.data.length} 个字符到 ${p.terminalId}`);
|
|
852
1297
|
},
|
|
853
1298
|
}),
|
|
@@ -866,6 +1311,8 @@ export function makePersistentTerminalTools(terminals, cwd) {
|
|
|
866
1311
|
}),
|
|
867
1312
|
execute: async (_id, p) => {
|
|
868
1313
|
failIf(terminals.key(p.terminalId, p.key, p.modifiers));
|
|
1314
|
+
// 同 terminal_input:AI 主动交互后重新计时。
|
|
1315
|
+
terminals.noteAgentActivity(p.terminalId);
|
|
869
1316
|
return result(`已发送按键 ${p.key} 到 ${p.terminalId}`);
|
|
870
1317
|
},
|
|
871
1318
|
}),
|
|
@@ -889,5 +1336,37 @@ export function makePersistentTerminalTools(terminals, cwd) {
|
|
|
889
1336
|
return result(JSON.stringify(read), read);
|
|
890
1337
|
},
|
|
891
1338
|
}),
|
|
1339
|
+
defineTool({
|
|
1340
|
+
name: "terminal_wait",
|
|
1341
|
+
label: "Wait for terminal command",
|
|
1342
|
+
description: "Block until a command started THROUGH THE BASH TOOL finishes (its exit marker appears) or the timeout expires — no polling needed. Only applies to terminals with a pending bash-tool command; terminals driven manually via terminal_input (e.g. interactive programs) have no completion marker — use terminal_read(waitMs=…) to observe those instead. Returns {finished, exitCode} plus the output produced while waiting; finished=false means it is STILL running (call again to keep waiting).",
|
|
1343
|
+
promptSnippet: "block until a terminal's current command finishes (no polling)",
|
|
1344
|
+
parameters: Type.Object({
|
|
1345
|
+
terminalId: Type.String(),
|
|
1346
|
+
cursor: Type.Optional(Type.Integer({ minimum: 0, description: "Ignore exit markers before this absolute offset (default: now)" })),
|
|
1347
|
+
maxWaitMs: Type.Optional(Type.Integer({ minimum: 100, maximum: 600000, description: "Max wait in ms (default 300000)" })),
|
|
1348
|
+
}),
|
|
1349
|
+
execute: async (_id, p, signal) => {
|
|
1350
|
+
if (!terminals.has(p.terminalId)) {
|
|
1351
|
+
throw new Error(`终端不存在:${p.terminalId}(可能已被关闭或会话重置,请先 terminal_create)`);
|
|
1352
|
+
}
|
|
1353
|
+
// 没有带哨兵的待决命令:shell 空闲在提示符,或该终端的命令是经
|
|
1354
|
+
// terminal_input 手动发的(无完成标记)——等哨兵永远等不到,直接
|
|
1355
|
+
// 说明并引导改用 terminal_read,避免 AI 无限重试。(显式传 cursor
|
|
1356
|
+
// 的调用是有目的的追溯查询,不拦。)
|
|
1357
|
+
if (p.cursor === undefined && !terminals.isSentinelPending(p.terminalId)) {
|
|
1358
|
+
const why = `终端 ${p.terminalId} 当前没有正在等待完成的 bash 工具命令(shell 空闲,或该命令是通过 terminal_input 发出的、没有完成标记)。terminal_wait 不适用;要观察输出请用 terminal_read(terminalId="${p.terminalId}", waitMs=…)。`;
|
|
1359
|
+
return result(JSON.stringify({ applicable: false, reason: why }), { applicable: false });
|
|
1360
|
+
}
|
|
1361
|
+
const cursor = p.cursor ?? terminals.endCursor(p.terminalId) ?? 0;
|
|
1362
|
+
const wait = await terminals.waitForCompletion(p.terminalId, p.maxWaitMs ?? 300_000, signal, cursor);
|
|
1363
|
+
const read = terminals.read(p.terminalId, cursor, 20_000);
|
|
1364
|
+
const outputTail = read?.data ? stripAnsi(read.data).slice(-4000) : "";
|
|
1365
|
+
return result(JSON.stringify({ ...wait, outputTail }), {
|
|
1366
|
+
...wait,
|
|
1367
|
+
outputTail,
|
|
1368
|
+
});
|
|
1369
|
+
},
|
|
1370
|
+
}),
|
|
892
1371
|
];
|
|
893
1372
|
}
|