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.
@@ -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) or send keys to it later (e.g. interrupt via terminal_key with Ctrl+c).
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
  }