pi-web-ui 0.59.0 → 0.60.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.
@@ -7,7 +7,7 @@
7
7
  * protocol defined in protocol.ts.
8
8
  *
9
9
  * Env:
10
- * PI_WEB_PORT HTTP port (default 8787; legacy PORT also honored)
10
+ * PI_WEB_PORT HTTP port (default 8787)
11
11
  * PI_WEB_CWD workspace the agent operates in (default: process.cwd())
12
12
  * PI_WEB_DATA_DIR where per-client UI state is stored (client-state.json,
13
13
  * default: <home>/.pi-web). Chat sessions are NOT stored here — they live
@@ -37,13 +37,27 @@ import { ensureWindowsBash, windowsBashDir } from "./ensure-bash.js";
37
37
  import { listThemes, resolveThemeFile } from "./themes.js";
38
38
  import { PluginManager, resolvePluginClientFile } from "./plugins.js";
39
39
  import { McpBridge } from "./mcp-bridge.js";
40
- const PORT = Number(process.env.PI_WEB_PORT ?? process.env.PORT ?? 8787);
41
- const CWD = resolve(process.env.PI_WEB_CWD ?? process.cwd());
42
- const DATA_DIR = resolve(process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web"));
40
+ /** CLI 参数中取 flag 值:支持 --flag value 与 --flag=value 两种写法。
41
+ * 让 `node dist/server/index.js --host 0.0.0.0 --port 9000` 这类直接启动也能生效,
42
+ * 而不只是经由 bin/pi-web-ui.mjs env 转发。bin 仍是主入口,此处仅作兜底。 */
43
+ function cliFlag(name) {
44
+ const eq = `${name}=`;
45
+ for (let i = 2; i < process.argv.length; i++) {
46
+ const a = process.argv[i];
47
+ if (a === name && i + 1 < process.argv.length)
48
+ return process.argv[i + 1];
49
+ if (a.startsWith(eq))
50
+ return a.slice(eq.length);
51
+ }
52
+ return undefined;
53
+ }
54
+ const PORT = Number(cliFlag("--port") ?? process.env.PI_WEB_PORT ?? 8787);
55
+ const CWD = resolve(cliFlag("--cwd") ?? process.env.PI_WEB_CWD ?? process.cwd());
56
+ const DATA_DIR = resolve(cliFlag("--data-dir") ?? process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web"));
43
57
  /** Bind address. Default is loopback ONLY — the service is a local personal
44
58
  * tool and should not be reachable from the network unless explicitly asked
45
59
  * (e.g. PI_WEB_HOST=0.0.0.0 for LAN access / Docker port mapping). */
46
- const HOST = process.env.PI_WEB_HOST ?? "127.0.0.1";
60
+ const HOST = cliFlag("--host") ?? process.env.PI_WEB_HOST ?? "127.0.0.1";
47
61
  /** Optional strict hostname allowlist (comma-separated) — only used when set.
48
62
  * Origin / Host same-authority matching happens regardless. */
49
63
  const ALLOW_HOSTS = (process.env.PI_WEB_ALLOW_HOSTS ?? "")
@@ -105,13 +119,16 @@ function tokenOk(req) {
105
119
  return requestTokens(req).includes(AUTH_TOKEN);
106
120
  }
107
121
  if (AUTH_TOKEN) {
108
- // /api/health 保持开放:无敏感信息,容器/监控探针需要它
122
+ // /api/health 保持开放:无敏感信息,容器/监控探针需要它。
123
+ // 但绝不能因命中 /api/health 就反射下发真实 token cookie(安全漏洞)。
109
124
  app.use((req, res, next) => {
110
- if (req.path === "/api/health" || tokenOk(req)) {
111
- // 浏览器经 ?token= 首次进入后下发 HttpOnly cookie,后续导航/资源请求免带参数
112
- if (!req.headers.cookie?.includes("pi_web_token=")) {
113
- res.setHeader("Set-Cookie", `pi_web_token=${encodeURIComponent(AUTH_TOKEN)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000`);
114
- }
125
+ const ok = tokenOk(req);
126
+ // 浏览器经 ?token= 首次进入后下发 HttpOnly cookie,后续导航/资源请求免带参数;
127
+ // 只有请求确实携带着有效 token 时才下发——匿名命中 /api/health 不触发。
128
+ if (ok && !req.headers.cookie?.includes("pi_web_token=")) {
129
+ res.setHeader("Set-Cookie", `pi_web_token=${encodeURIComponent(AUTH_TOKEN)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000`);
130
+ }
131
+ if (req.path === "/api/health" || ok) {
115
132
  next();
116
133
  return;
117
134
  }
@@ -124,8 +124,8 @@ export function terminalIdleNotifyMs() {
124
124
  const raw = Number(process.env.PI_WEB_TERMINAL_IDLE_MS);
125
125
  return Number.isFinite(raw) && raw >= 0 ? raw : 15_000;
126
126
  }
127
- /** 静默反馈附带的最新输出行数(PI_WEB_TERMINAL_IDLE_LINES 覆盖;默认 10)。
128
- * 每次调用时读取(测试可注入)。 */
127
+ /** 静默解阻时默认回送的最近输出行数(仅用于把已有输出截到可读量)。
128
+ * PI_WEB_TERMINAL_IDLE_LINES 覆盖;0 = 不截;限制在 1..500。每次调用时读取。 */
129
129
  export function terminalIdleNotifyLines() {
130
130
  const raw = Number(process.env.PI_WEB_TERMINAL_IDLE_LINES);
131
131
  const n = Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 10;
@@ -137,8 +137,6 @@ export function terminalIdleNotifyLines() {
137
137
  /** 哨兵行:命令执行完后由 shell 打印,携带真实退出码。正则只匹配数字,
138
138
  * 因此不会误匹配回显里的 printf 格式串 `[pi-exit:%s]`。 */
139
139
  const BASH_SENTINEL_RE = /\[pi-exit:(\d+)\]/g;
140
- /** 一次性 bash 终端的 id 计数器(全局单调递增,跨会话不重号)。 */
141
- let bashCommandSeq = 0;
142
140
  /**
143
141
  * 把任意命令(含多行脚本)构造成「一行」交互 shell 命令:执行 + 捕获退出码。
144
142
  *
@@ -159,11 +157,10 @@ export function buildTerminalBashLine(command, tailFile) {
159
157
  }
160
158
  // 退出码取【第一个】命令(真正干活的那个)而非管道末尾命令:`head`/`grep`/`tail`
161
159
  // 在管道末尾会把退出码吞成自己的(head 恒 0、grep 无命恒 1)。`${PIPESTATUS:-$?}`
162
- // 在 bash 里取 PIPESTATUS[0](首命令),busybox ash/dash 无 PIPESTATUS 时退化为 `$?`
163
- // (不崩溃、只回到末尾命令)。
164
- // SIGPIPE(141) 0:`cmd | head` 里 head 读到 N 行就主动关管道,把首命令“截断杀掉”
165
- // 留下的 141 不是真失败,而是“按要求截断=成功”。只有恰好 141 才转 0,真失败(1/2/127…)照报。
166
- // tailFile:`cmd > log 2>&1 | tail -N` —— 拆掉 tail 后 stdout 进文件、终端为空;
160
+ // 在 bash 里取 PIPESTATUS[0](首命令),busybox ash/dash 无 PIPESTATUS 时退化为 `$?`。
161
+ // SIGPIPE(141) 归 0:`cmd | head` 里 head 读够 N 行就主动关管道,把首命令“截断杀掉”
162
+ // 留下的 141 不是真失败,而是“按要求截断=成功”。只有恰好 141 才转 0,真失败照报。
163
+ // tailFile:`cmd > log 2>&1 | tail -N`——拆掉 tail 后 stdout 进文件、终端为空;
167
164
  // 在哨兵前补一个 `tail -N log` 让模型看到日志尾部,退出码仍是底层命令的。
168
165
  const rcGuard = `__pi_rc=\${PIPESTATUS:-\$?}; [ "$__pi_rc" -eq 141 ] && __pi_rc=0`;
169
166
  const tailPart = tailFile
@@ -218,8 +215,7 @@ function parseTailLines(rest) {
218
215
  * 末尾 N 行(tail)或全部输出。
219
216
  *
220
217
  * - **只拆末尾单个纯“限输出/透传”**;`| grep`/`| head`/`| awk`/`| sed`/`| sort`/`| uniq`
221
- * (真过滤/变换,拆掉会丢语义或崩出海量未过滤输出)与 `| tee`(写文件副作用)都**不拆**,
222
- * 交给 prompt 引导模型不用。`| head` 也因 `yes | head -5` 靠 SIGPIPE 早停而**不拆**。
218
+ * (真过滤/变换,拆掉会丢语义或崩出海量未过滤输出)与 `| tee`(写文件副作用)都**不拆**。
223
219
  * - `tail -f` / `tail --follow`(长驻观察)也不拆。
224
220
  */
225
221
  export function detectTrailingLimiter(command) {
@@ -407,10 +403,6 @@ function shellEnv() {
407
403
  const env = {
408
404
  ...process.env,
409
405
  TERM: "xterm-256color",
410
- // 禁用分页器:stdout 是 tty 时 git log / git diff / man / … 会开 less 并挂住等按键。
411
- // agent 工具需要完整输出而非分页,故强制 cat 透传(防挂死)。
412
- GIT_PAGER: process.env.GIT_PAGER || "cat",
413
- PAGER: process.env.PAGER || "cat",
414
406
  };
415
407
  if (!env.LANG && !env.LC_ALL)
416
408
  env.LANG = "en_US.UTF-8";
@@ -619,7 +611,7 @@ export class TerminalManager {
619
611
  // a NEW live PTY needs a free slot under the cap. Reusing an exited name
620
612
  // starts a fresh PTY and discards its old history — but only after the
621
613
  // slot check, so a rejected request keeps its retained output.
622
- if (!this.ensureSpawnAllowed(id))
614
+ if (!this.ensureSpawnAllowed(id, opts?.agentBash))
623
615
  return null;
624
616
  this.history.delete(id);
625
617
  const safeCwd = this.safeCwd(cwd || fallbackCwd);
@@ -627,7 +619,7 @@ export class TerminalManager {
627
619
  this.fail(id, "终端工作目录必须位于当前工作区内");
628
620
  return null;
629
621
  }
630
- if (this.spawnShell(id, safeCwd, cols, rows, title || `终端 ${++this.seq}`, undefined, opts?.forceBash, opts?.runLine)) {
622
+ if (this.spawnShell(id, safeCwd, cols, rows, title || `终端 ${++this.seq}`, undefined, opts?.forceBash, opts?.agentBash)) {
631
623
  this.maybeEmitTccHint(id);
632
624
  this.emitList();
633
625
  return this.info(this.terms.get(id));
@@ -706,7 +698,7 @@ export class TerminalManager {
706
698
  this.input(id, command + "\r");
707
699
  }
708
700
  /** Spawn the user's shell as a PTY. Returns false when the spawn failed. */
709
- spawnShell(id, cwd, cols, rows, title, command, forceBash, runLine) {
701
+ spawnShell(id, cwd, cols, rows, title, command, forceBash, agentBash = false) {
710
702
  let abs = cwd;
711
703
  if (!abs)
712
704
  abs = homedir();
@@ -728,12 +720,7 @@ export class TerminalManager {
728
720
  let pty;
729
721
  try {
730
722
  const { shell, args } = forceBash ? resolveBashShell() : resolveShell();
731
- // runLine != null → 以 `bash -c <runLine>` 非交互启动:无命令回显、无提示符,
732
- // 退出后缓冲即干净输出(供一次性 bash 终端的事后查询/搜索)。
733
- const spawnArgs = runLine !== undefined
734
- ? ["-c", runLine]
735
- : args;
736
- pty = spawn(shell, spawnArgs, {
723
+ pty = spawn(shell, args, {
737
724
  name: "xterm-256color",
738
725
  cols: Math.max(2, Math.floor(cols) || 80),
739
726
  rows: Math.max(2, Math.floor(rows) || 24),
@@ -767,6 +754,7 @@ export class TerminalManager {
767
754
  lastActivityAt: Date.now(),
768
755
  idleTimer: null,
769
756
  watches: [],
757
+ agentBash,
770
758
  };
771
759
  this.terms.set(id, entry);
772
760
  // The closures capture `entry`: after a restart the map points at the
@@ -825,7 +813,7 @@ export class TerminalManager {
825
813
  // 一次性:触发后解除武装,直到下次 agent 触碰。
826
814
  entry.agentTouched = false;
827
815
  // 附带最近 N 行输出(可配),便于 AI 直接看到终端当前状态。
828
- const lastLines = this.query(entry.id, { tail: terminalIdleNotifyLines() })?.text ?? "";
816
+ const lastLines = queryTerminalOutput(entry.output, { tail: terminalIdleNotifyLines() })?.text ?? "";
829
817
  this.onAgentIdle?.(entry.id, Date.now() - entry.lastActivityAt, entry.title, lastLines);
830
818
  }, delay);
831
819
  entry.idleTimer.unref?.();
@@ -903,11 +891,16 @@ export class TerminalManager {
903
891
  * Restarting an id that is ALREADY live is always allowed (no extra slot).
904
892
  * History entries (exited terminals) do not reserve a slot — re-spawning
905
893
  * one while at the cap is rejected with the standard error feedback.
894
+ * 终端接管 bash(ai-bash,agentBash=true)永远放行:它由 API 自动创建且
895
+ * 常驻,不占用户的名额,也不受用户已开满上限影响。
906
896
  */
907
- ensureSpawnAllowed(id) {
897
+ ensureSpawnAllowed(id, agentBash = false) {
908
898
  if (this.terms.has(id))
909
899
  return true;
910
- if (this.terms.size >= MAX_TERMINALS) {
900
+ if (agentBash)
901
+ return true;
902
+ const liveUser = [...this.terms.values()].filter((t) => !t.agentBash).length;
903
+ if (liveUser >= MAX_TERMINALS) {
911
904
  this.fail(id, `终端数量已达上限(${MAX_TERMINALS})`);
912
905
  return false;
913
906
  }
@@ -937,6 +930,7 @@ export class TerminalManager {
937
930
  running: !entry.exited,
938
931
  exitCode: entry.exitCode,
939
932
  command: entry.command,
933
+ agentBash: entry.agentBash,
940
934
  };
941
935
  }
942
936
  has(id) {
@@ -966,13 +960,6 @@ export class TerminalManager {
966
960
  const end = Math.min(start + Math.max(1, Math.floor(maxBytes) || 20_000), entry.outputOffset + entry.output.length);
967
961
  return { data: entry.output.slice(start - entry.outputOffset, end - entry.outputOffset), cursor: end, running: !entry.exited, exitCode: entry.exitCode };
968
962
  }
969
- /** 对终端(含已退出的 history 项)的输出缓冲做快照查询:head/tail/search+context。 */
970
- query(id, q) {
971
- const entry = this.find(id);
972
- if (!entry)
973
- return null;
974
- return queryTerminalOutput(entry.output, q, !entry.exited, entry.exitCode);
975
- }
976
963
  async waitForOutput(id, cursor, timeoutMs, signal) {
977
964
  const current = this.read(id, cursor, 1);
978
965
  if (!current || current.cursor > cursor || !current.running)
@@ -1258,59 +1245,115 @@ function cleanBashOutput(raw) {
1258
1245
  return truncateMiddle(lines.join("\n").trim());
1259
1246
  }
1260
1247
  /**
1261
- * 终端接管的 bash 工具:模型看到的参数与 SDK bash 完全一致(command + 可选
1262
- * timeout 秒),但执行体是往持久终端写命令并等哨兵行拿到真实退出码。
1248
+ * 终端接管的 bash 工具:覆盖 SDK 内置 bash,执行体把命令写进可见终端并等哨兵
1249
+ * 行拿到真实退出码。参数除 SDK 的 command + timeout 外,还多出 persist / head /
1250
+ * tail 三个可选参数。
1251
+ *
1252
+ * persist=false(默认,一次性):每次新建一个终端(agentBash、单独归「AI bash」
1253
+ * 分组、不占用户名额),命令结束 shell 用 exit 退场(进程结束),输出保留在
1254
+ * history 供查阅;阻塞到命令结束,不做静默解阻。
1263
1255
  *
1264
- * 行为语义:
1256
+ * persist=true(复用 'ai-bash' 持久终端):
1265
1257
  * - 默认阻塞:等到命令结束才返回完整输出(ANSI 已清理)+ 真实退出码;
1266
1258
  * - 静默解阻:连续 idleMs 毫秒无新输出且未结束 → 立即返回「仍在运行」+ 已有
1267
1259
  * 输出,命令留在终端里继续跑,并注册完成观察器——结束后由宿主
1268
1260
  * notifyBackgroundDone 主动通知 AI(流式中 steer / 空闲时 nextTurn);
1269
- * - abort_bash 支持:AbortController 注册进 kills 集合,abort 时向 PTY
1270
- * Ctrl+C 杀前台进程,对话继续(与 makeKillableBashTool 同一套集合);
1271
- * - shell 状态跨调用保留(cd / venv activate / ssh 会话)——这是原生 bash
1272
- * 工具做不到的。
1261
+ * - shell 状态跨调用保留(cd / venv activate / ssh 会话)——这是原生一次性的
1262
+ * bash 工具做不到的。
1263
+ *
1264
+ * 两者都支持 abort(AbortController 注册进 kills 集合,abort 向 PTY 发 Ctrl+C)、
1265
+ * timeout,以及 head / tail 参数(只返回前/后 N 行,替代 `| head` / `| tail` 管道)。
1273
1266
  */
1267
+ /** 一次性 bash 终端序号:非持久调用每次新建一个终端,进程结束后退出、输出
1268
+ * 保留在 history 供查阅——故每调用独立 id,避免复用覆盖旧输出。 */
1269
+ let oneShotBashSeq = 0;
1270
+ /** 应用 head / tail 参数到输出顶层行(替代 `| head` / `| tail` 管道——管道会
1271
+ * 缓冲输出、让可见终端全程哑火,还容易白白触发静默解阻)。两者同时给时先
1272
+ * 截头再截尾。 */
1273
+ export function applyHeadTail(text, head, tail) {
1274
+ // 只对真实数据行切片;省略提示行单独存,最后再包回输出,避免提示行在
1275
+ // head+tail 组合时被当成数据行参与第二次截取(导致尾部少截一行)。
1276
+ let data = text.split("\n");
1277
+ let headNote = null;
1278
+ let tailNote = null;
1279
+ if (head && head > 0 && data.length > head) {
1280
+ headNote = `…(后 ${data.length - head} 行已省略)`;
1281
+ data = data.slice(0, head);
1282
+ }
1283
+ if (tail && tail > 0 && data.length > tail) {
1284
+ tailNote = `…(前 ${data.length - tail} 行已省略)`;
1285
+ data = data.slice(-tail);
1286
+ }
1287
+ const parts = [];
1288
+ if (tailNote)
1289
+ parts.push(tailNote);
1290
+ parts.push(...data);
1291
+ if (headNote)
1292
+ parts.push(headNote);
1293
+ return parts.join("\n");
1294
+ }
1274
1295
  export function makeTerminalBashTool(terminals, opts) {
1296
+ const PERSIST_ID = "ai-bash";
1275
1297
  return defineTool({
1276
1298
  name: "bash",
1277
1299
  label: "Run bash command",
1278
- description: "Run a shell command and return its full output plus exit code. Commands run in a VISIBLE terminal (its id is the first line of the result). By default the terminal is one-shot: a fresh terminal is torn down after the command so shell state is NOT shared between calls; its output stays queryable afterward via terminal_read (head/tail/search) by id. Pass persistent:true to reuse one terminal (id shown) and retain shell state (cd / venv / ssh). PURPOSEFUL DESIGN — use the tool's OWN parameters instead of shell pipes: return only the last N lines with `tail` (the tool keeps streaming live to the visible terminal) rather than `| tail`; and don't pipe through `| head | grep | less | more | cat | sort | awk` to trim/filter output. Those pipes buffer, hide live progress, mask the real exit code (the pipe's last command, not the command you cared about, decides the result) and make a long-running/failed command hang until timeout. If the command is a long-running server, watcher or interactive program, prefer the persistent terminal tools (terminal_create + terminal_read / terminal_input / terminal_key + terminal_wait) instead of bash. If a bash 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, or terminal_read on that id to observe/interact with it afterward.",
1279
- promptSnippet: "run commands in a visible terminal (id returned; persistent:true retains shell state)",
1300
+ description: "Run a shell command and return its full output plus exit code. Commands run in a visible terminal.\n" +
1301
+ "persist=false (default, one-shot): a fresh terminal is created per call, run to completion, then the shell exits (the process ends) while its output stays in the terminal list for later review — like a normal bash call, but each command also leaves a viewable terminal record.\n" +
1302
+ "persist=true: commands run in the PERSISTENT visible terminal 'ai-bash' — shell state such as cd, venv activation or ssh sessions is retained across calls; you can use terminal_wait to re-block on a backgrounded command, or terminal_read / terminal_input / terminal_key on 'ai-bash' to observe or interact anytime.\n" +
1303
+ "Run the bare command — do NOT pipe through head/tail/more/less (output is returned complete anyway, and pipes hide live progress in the terminal). Use the head/tail parameters instead to trim the returned output. For interactive commands (REPLs, prompts, installers asking y/n) set persist=true and drive them with terminal_input / terminal_key.",
1304
+ promptSnippet: "run shell commands (persist=true keeps the terminal alive across calls)",
1280
1305
  parameters: Type.Object({
1281
1306
  command: Type.String({ description: "The shell command to run" }),
1282
1307
  timeout: Type.Optional(Type.Number({ description: "Optional timeout in seconds" })),
1283
- tail: Type.Optional(Type.Integer({
1308
+ persist: Type.Optional(Type.Boolean({
1309
+ description: "Keep the terminal alive after the command (default: false → a one-shot terminal that exits when the command finishes while its output is retained for review). true runs in the persistent 'ai-bash' terminal so shell state (cd/venv/ssh) is retained across calls and the terminal stays interactive.",
1310
+ })),
1311
+ head: Type.Optional(Type.Integer({
1284
1312
  minimum: 1,
1285
1313
  maximum: 5000,
1286
- 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.",
1314
+ description: "Only return the FIRST N lines of output (like `| head -N`). Use this for verbose commands instead of piping through head.",
1287
1315
  })),
1288
- persistent: Type.Optional(Type.Boolean({
1289
- description: "Reuse one terminal across calls, retaining shell state (cd / venv / ssh sessions). Default false = one-shot terminal per call (state not shared, output left queryable by id).",
1316
+ tail: Type.Optional(Type.Integer({
1317
+ minimum: 1,
1318
+ maximum: 5000,
1319
+ description: "Only return the LAST N lines of output (like `| tail -N`). Use this for verbose commands instead of piping through tail.",
1290
1320
  })),
1291
1321
  }),
1292
1322
  execute: async (_id, p, signal) => {
1293
- // 一次性(默认):每次新鲜终端 id,命令跑完 shell exit——输出进 history
1294
- // 缓冲(可事后按 id 查询/搜索),但不跨调用保留 shell 状态。
1295
- // 持久(persistent:true):复用固定 ai-bash,保留 cd/venv/ssh 状态。
1296
- const persistent = p.persistent === true;
1297
- let termId;
1298
- if (persistent) {
1299
- termId = "ai-bash";
1300
- }
1301
- else {
1302
- do {
1303
- termId = `bash-${++bashCommandSeq}`;
1304
- } while (terminals.has(termId));
1323
+ const persist = p.persist ?? opts.defaultPersist();
1324
+ // create() 对已存活的同名终端原样返回、对已退出的原地重启。
1325
+ // forceBash:该终端永远跑 bash(而非用户登录 shell),模型写的
1326
+ // bash 语法(数组/read -p/process substitution…)不会踩 zsh 差异。
1327
+ // agentBash:标记为终端接管 bash,前端单独归到「AI bash」分组,
1328
+ // 也不计入终端数量上限。
1329
+ const termId = persist ? PERSIST_ID : `ai-bash-${++oneShotBashSeq}`;
1330
+ const title = persist ? "AI bash" : `AI bash ${oneShotBashSeq}`;
1331
+ if (terminals.create(termId, opts.cwd, 120, 40, opts.cwd, title, {
1332
+ forceBash: true,
1333
+ agentBash: true,
1334
+ }) === null) {
1335
+ throw new Error(`无法打开 AI bash 终端(${termId})`);
1305
1336
  }
1306
- // 拆掉模型常写的尾部输出限制/过滤管道(`| tail -N` / `| less` / `| more` / `| cat` / `| grep`):
1307
- // 这类管道在持久终端里 ①缓冲输出——可见终端全程哑火、无法感知实时进度;
1308
- // ②吞掉真实退出码——退出码取管道最后一个命令(tail/grep 0/1),掩盖真实失败;
1309
- // ③需 stdin 的管道(尤其长驻命令)出错后可能一直挂到超时。拆掉后底层命令直跑
1310
- // (实时可见 + 真实退出码),只在返回给模型时取末尾 N 行或全部。
1337
+ // 阻塞等待期间挂起活力提醒(我们自己在检测静默,避免双重通知)。
1338
+ terminals.suspendIdleWatch(termId);
1339
+ const start = terminals.endCursor(termId);
1340
+ const ac = new AbortController();
1341
+ opts.kills.add(ac);
1342
+ // 一键退出一次性终端:命令结束后 shell 用 exit 退场(进程结束),
1343
+ // 输出保留在 history 供查阅;持久终端则保留 shell 状态跨调用。
1344
+ const closeOneShot = () => {
1345
+ if (!persist)
1346
+ void terminals.inputChecked(termId, "exit\r");
1347
+ };
1348
+ const idleMs = Math.max(0, opts.idleMs());
1349
+ const deadline = p.timeout && p.timeout > 0 ? Date.now() + p.timeout * 1000 : null;
1350
+ // 拆掉模型常写的尾部输出限制/过滤管道(`| tail -N` / `| less` / `| more` / `| cat`):
1351
+ // 这类管道在终端里 ①缓冲输出——可见终端全程哑火、无法感知实时进度;②吞掉真实退出码——
1352
+ // 退出码取管道最后一个命令(tail 恒 0),掩盖真实失败;③需 stdin 的管道出错后可能一直挂到超时。
1353
+ // 拆掉后底层命令直跑(实时可见 + 真实退出码),只在返回给模型时取末尾 N 行或全部。
1311
1354
  const limiter = detectTrailingLimiter(p.command);
1312
1355
  const stripped = limiter !== null;
1313
- const effectiveTail = p.tail ?? (limiter?.kind === "tail" ? limiter.lines : undefined);
1356
+ const effectiveTail = p.tail ?? (limiter?.kind === "tail" ? (limiter.lines ?? undefined) : undefined);
1314
1357
  const runCommand = stripped ? limiter.base : p.command;
1315
1358
  // `cmd > log 2>&1 | tail -N`:拆掉 tail 后 stdout 进文件、终端为空 → 补一个
1316
1359
  // tail 文件让模型看到日志尾部与真实退出码(否则输出为空)。
@@ -1319,49 +1362,23 @@ export function makeTerminalBashTool(terminals, opts) {
1319
1362
  ? { file: redirect.file, lines: limiter.lines ?? 10 }
1320
1363
  : undefined;
1321
1364
  const limiterNote = stripped
1322
- ? `\n[注:检测到你带了 ${limiter.segment}——这类管道在持久终端里会缓冲输出、吞掉真实退出码,命令出错时还可能一直挂到超时;已让底层命令直跑。${limiter.kind === "tail" ? (tailFile ? `输出已重定向到 ${redirect.file},改为 tail 该文件返回末尾 ${limiter.lines} 行。` : `仍只返回末尾 ${limiter.lines} 行。`) : "本次返回全部输出。"} 后续直接用 bash(command, tail=N) 参数限输出,别再套管道。]`
1365
+ ? "\n[注:检测到你带了「" + limiter.segment + "」这类限输出/过滤管道——已在终端里直跑底层命令(实时可见 + 真实退出码),只按参数返回片段。" + (limiter.kind === "tail" ? "本次返回末尾 " + (limiter.lines ?? 10) + " 行。" : "本次返回全部输出。") + " 后续直接用 bash(command, tail=N) 参数限输出。]"
1323
1366
  : "";
1324
- const bashLine = buildTerminalBashLine(runCommand, tailFile);
1325
- const idLine = `[终端: ${termId}]`;
1326
- // 一次性:以 `bash -c <line>` 非交互启动(无命令回显/提示符,缓冲=干净输出,
1327
- // 便于事后按 id 查询/搜索),跑完自动退出。持久:交互 shell,命令经 stdin 写入
1328
- //(回显/提示符属交互终端本身),保留 cd/venv/ssh 状态。forceBash:永远跑 bash。
1329
- if (terminals.create(termId, opts.cwd, 120, 40, opts.cwd, persistent ? "AI bash" : `AI bash ${termId}`, persistent ? { forceBash: true } : { forceBash: true, runLine: bashLine }) === null) {
1330
- throw new Error(`无法打开 AI bash 终端(${termId})`);
1331
- }
1332
- // 阻塞等待期间挂起活力提醒(我们自己在检测静默,避免双重通知)。
1333
- terminals.suspendIdleWatch(termId);
1334
- const start = terminals.endCursor(termId);
1335
- const ac = new AbortController();
1336
- opts.kills.add(ac);
1337
- const idleMs = Math.max(0, opts.idleMs());
1338
- const deadline = p.timeout && p.timeout > 0 ? Date.now() + p.timeout * 1000 : null;
1339
- // tail 参数(或检测到的行数):只返回末尾 N 行(替代 `| tail -N` 管道)。
1340
- const applyTail = (t) => {
1341
- if (!effectiveTail || effectiveTail <= 0)
1342
- return t;
1343
- const lines = t.split("\n");
1344
- return lines.length > effectiveTail
1345
- ? `…(前 ${lines.length - effectiveTail} 行已省略)\n${lines.slice(-effectiveTail).join("\n")}`
1346
- : t;
1347
- };
1348
1367
  try {
1349
1368
  let collected = "";
1350
1369
  let cursor = start;
1351
1370
  let lastDataAt = Date.now();
1352
- // 一次性:命令已在 spawn 时经 `bash -c` 跑起(无 stdin 回显);持久:经 stdin 写入。
1353
- const inputErr = persistent
1354
- ? terminals.inputChecked(termId, bashLine + "\r")
1355
- : null;
1356
- if (inputErr)
1357
- throw new Error(inputErr);
1358
1371
  // 标记「有哨兵命令在跑」:terminal_wait 据此区分等待与空闲。
1359
1372
  terminals.setSentinelPending(termId, true);
1373
+ const inputErr = terminals.inputChecked(termId, buildTerminalBashLine(runCommand, tailFile) + "\r");
1374
+ if (inputErr)
1375
+ throw new Error(inputErr);
1360
1376
  for (;;) {
1361
1377
  if (ac.signal.aborted || signal?.aborted) {
1362
- // Ctrl+C 杀前台进程;终端本身保留(会话状态还在)。
1378
+ // Ctrl+C 杀前台进程;一次性终端随之退出,持久终端保留(会话状态还在)。
1363
1379
  terminals.setSentinelPending(termId, false);
1364
1380
  terminals.inputChecked(termId, "\x03");
1381
+ closeOneShot();
1365
1382
  throw new Error("Command aborted");
1366
1383
  }
1367
1384
  await sleep(60);
@@ -1374,29 +1391,27 @@ export function makeTerminalBashTool(terminals, opts) {
1374
1391
  const m = lastSentinel(collected);
1375
1392
  if (m) {
1376
1393
  terminals.setSentinelPending(termId, false);
1377
- const text = applyTail(cleanBashOutput(collected));
1394
+ closeOneShot();
1395
+ const text = applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail);
1378
1396
  return {
1379
1397
  content: [
1380
1398
  {
1381
1399
  type: "text",
1382
- text: `${idLine}\n${text}${text ? "\n" : ""}${limiterNote}[exit:${m[1]}]`,
1400
+ text: `${text}${text ? "\n" : ""}${limiterNote}[exit:${m[1]}]`,
1383
1401
  },
1384
1402
  ],
1385
- details: {
1386
- exitCode: Number(m[1]),
1387
- output: text,
1388
- terminalId: termId,
1389
- },
1403
+ details: { exitCode: Number(m[1]), output: text },
1390
1404
  };
1391
1405
  }
1392
1406
  if (deadline !== null && Date.now() > deadline) {
1393
1407
  terminals.setSentinelPending(termId, false);
1394
1408
  terminals.inputChecked(termId, "\x03");
1409
+ closeOneShot();
1395
1410
  throw new Error(`Command timed out after ${p.timeout}s(已发 Ctrl+C;已有输出:${truncateMiddle(stripAnsi(collected), 4000)})`);
1396
1411
  }
1397
- // 静默解阻:转后台 + 注册完成观察器,立即把控制权还给模型。
1398
- if (idleMs > 0 && Date.now() - lastDataAt >= idleMs) {
1399
- return backgroundResult(terminals, opts, termId, runCommand, applyTail(cleanBashOutput(collected)), Math.round((Date.now() - lastDataAt) / 1000), limiterNote, idLine);
1412
+ // 静默解阻(仅持久终端):转后台 + 注册完成观察器,立即把控制权还给模型。
1413
+ if (persist && idleMs > 0 && Date.now() - lastDataAt >= idleMs) {
1414
+ return backgroundResult(terminals, opts, runCommand, applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail), Math.round((Date.now() - lastDataAt) / 1000));
1400
1415
  }
1401
1416
  }
1402
1417
  }
@@ -1407,12 +1422,12 @@ export function makeTerminalBashTool(terminals, opts) {
1407
1422
  });
1408
1423
  }
1409
1424
  /** 静默解阻路径:注册完成观察器后立即返回「仍在后台运行」。 */
1410
- function backgroundResult(terminals, opts, terminalId, command, partialText, silentSeconds, note = "", idLine = "") {
1411
- terminals.watchOutput(terminalId, BASH_SENTINEL_RE, (m) => {
1425
+ function backgroundResult(terminals, opts, command, partialText, silentSeconds) {
1426
+ terminals.watchOutput("ai-bash", BASH_SENTINEL_RE, (m) => {
1412
1427
  // 后台命令最终结束(或终端被关)→ 清除待决标记,terminal_wait 不再适用。
1413
- terminals.setSentinelPending(terminalId, false);
1428
+ terminals.setSentinelPending("ai-bash", false);
1414
1429
  opts.notifyBackgroundDone({
1415
- terminalId,
1430
+ terminalId: "ai-bash",
1416
1431
  command,
1417
1432
  exitCode: m ? Number(m[1]) : null,
1418
1433
  });
@@ -1423,14 +1438,13 @@ function backgroundResult(terminals, opts, terminalId, command, partialText, sil
1423
1438
  content: [
1424
1439
  {
1425
1440
  type: "text",
1426
- text: `${idLine}\n命令仍在终端 ${terminalId} 中运行(已连续 ${silentSeconds} 秒无输出,未结束)。` +
1441
+ text: `命令仍在持久终端 ai-bash 中运行(已连续 ${silentSeconds} 秒无输出,未结束)。` +
1427
1442
  `本次调用不阻塞——命令继续在后台执行,结束时你会收到自动通知。\n` +
1428
1443
  `已有输出:\n${partial || "(暂无输出)"}\n` +
1429
- `要重新阻塞等它结束就用 terminal_wait(terminalId="${terminalId}")(无需反复轮询);需要交互用 terminal_input / terminal_key(Ctrl+C 可终止)。` +
1430
- note,
1444
+ `要重新阻塞等它结束就用 terminal_wait(terminalId="ai-bash")(无需反复轮询);需要交互用 terminal_input / terminal_key(Ctrl+C 可终止)。`,
1431
1445
  },
1432
1446
  ],
1433
- details: { running: true, terminalId, silentSeconds },
1447
+ details: { running: true, terminalId: "ai-bash", silentSeconds },
1434
1448
  };
1435
1449
  }
1436
1450
  /** Names of the agent-facing persistent-terminal tools(设置开关门控用)。 */
@@ -1446,12 +1460,11 @@ export const TERMINAL_TOOL_NAMES = [
1446
1460
  /** System-prompt guidance teaching the model WHEN to prefer the terminal tools
1447
1461
  * over one-shot bash. Without it models almost never pick them — bash returns
1448
1462
  * complete output in a single call, so it always wins on convenience. */
1449
- 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:
1450
- - The program is interactive or TUI-based (REPLs like python/node, vim/htop, installers asking y/n, anything waiting on stdin).
1451
- - 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).
1452
- - The user explicitly asks you to work in the visible terminal panel.
1453
- 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.
1454
- Do NOT use them for simple one-shot commands; bash remains cheaper and simpler there.`;
1463
+ 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 bash tool stays the DEFAULT for ordinary commands - it runs in a visible terminal and returns the full output (persist=false, one-shot terminal that exits when the command finishes). Switch to the bash tool's persist=true, or to the terminal tools, when:
1464
+ - The program is interactive or TUI-based (REPLs like python/node, vim/htop, installers asking y/n, anything waiting on stdin). For these, prefer bash({ persist: true }) which runs it in the persistent 'ai-bash' terminal and returns immediately; then drive it with terminal_input / terminal_key (and terminal_read) on terminalId='ai-bash'.
1465
+ - 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).
1466
+ - The user explicitly asks you to work in the visible terminal panel.
1467
+ Use head/tail on bash to trim verbose output instead of piping through head/tail. 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.`;
1455
1468
  /** Build the agent-facing persistent terminal tools for one conversation. */
1456
1469
  export function makePersistentTerminalTools(terminals, cwd) {
1457
1470
  const result = (text, details = {}) => ({ content: [{ type: "text", text }], details });
@@ -1535,30 +1548,14 @@ export function makePersistentTerminalTools(terminals, cwd) {
1535
1548
  defineTool({
1536
1549
  name: "terminal_read",
1537
1550
  label: "Read terminal output",
1538
- description: "Read output from a persistent PTY (by id) either incrementally or as a snapshot query. INCREMENTAL (default): pass the cursor from the last read to get only new output (each read keeps its own cursor); optionally waitMs for new output or process exit. SNAPSHOT QUERY (give one of head/tail/search): view the retained buffer by 1-based line number — head=N (first N lines), tail=N (last N lines), or search=keyword with context=N (lines around each match). This works on terminals that already finished too (output is retained), so you can inspect an earlier one-shot bash terminal by its id.",
1551
+ description: "Read incremental output from a persistent PTY. Keep the returned cursor and pass it on the next read; optionally wait for new output or process exit.",
1539
1552
  parameters: Type.Object({
1540
1553
  terminalId: Type.String(),
1541
1554
  cursor: Type.Optional(Type.Integer({ minimum: 0 })),
1542
1555
  maxBytes: Type.Optional(Type.Integer({ minimum: 1, maximum: 100000 })),
1543
1556
  waitMs: Type.Optional(Type.Integer({ minimum: 0, maximum: 120000 })),
1544
- head: Type.Optional(Type.Integer({ minimum: 1, maximum: 10000, description: "Return the first N lines (snapshot query)" })),
1545
- tail: Type.Optional(Type.Integer({ minimum: 1, maximum: 10000, description: "Return the last N lines (snapshot query)" })),
1546
- search: Type.Optional(Type.String({ description: "Search this keyword (case-insensitive) in the retained buffer (snapshot query)" })),
1547
- context: Type.Optional(Type.Integer({ minimum: 0, maximum: 100, description: "Lines of context around each search match (default 3)" })),
1548
1557
  }),
1549
1558
  execute: async (_id, p, signal) => {
1550
- // 快照查询模式:head / tail / search 任一给出即按行号返回整段缓冲。
1551
- if (p.head !== undefined || p.tail !== undefined || p.search !== undefined) {
1552
- const q = terminals.query(p.terminalId, {
1553
- head: p.head,
1554
- tail: p.tail,
1555
- search: p.search,
1556
- context: p.context,
1557
- });
1558
- if (!q)
1559
- throw new Error(`终端不存在:${p.terminalId}`);
1560
- return result(JSON.stringify(q), q);
1561
- }
1562
1559
  const cursor = p.cursor ?? 0;
1563
1560
  if (p.waitMs)
1564
1561
  await terminals.waitForOutput(p.terminalId, cursor, p.waitMs, signal);