pi-web-ui 0.58.0 → 0.59.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.
@@ -124,12 +124,21 @@ 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
+ * 每次调用时读取(测试可注入)。 */
129
+ export function terminalIdleNotifyLines() {
130
+ const raw = Number(process.env.PI_WEB_TERMINAL_IDLE_LINES);
131
+ const n = Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 10;
132
+ return Math.max(1, Math.min(n, 500));
133
+ }
127
134
  // ---------------------------------------------------------------------------
128
135
  // 终端接管 bash(terminal-backed bash tool)
129
136
  // ---------------------------------------------------------------------------
130
137
  /** 哨兵行:命令执行完后由 shell 打印,携带真实退出码。正则只匹配数字,
131
138
  * 因此不会误匹配回显里的 printf 格式串 `[pi-exit:%s]`。 */
132
139
  const BASH_SENTINEL_RE = /\[pi-exit:(\d+)\]/g;
140
+ /** 一次性 bash 终端的 id 计数器(全局单调递增,跨会话不重号)。 */
141
+ let bashCommandSeq = 0;
133
142
  /**
134
143
  * 把任意命令(含多行脚本)构造成「一行」交互 shell 命令:执行 + 捕获退出码。
135
144
  *
@@ -137,7 +146,7 @@ const BASH_SENTINEL_RE = /\[pi-exit:(\d+)\]/g;
137
146
  * 后续哨兵;也避开交互 shell 的 bracketed-paste 对多行输入的特殊处理。
138
147
  * 多行脚本用 `$'...'` ANSI-C 引号转义后交给 eval(bash/zsh/busybox ash 都支持)。
139
148
  */
140
- export function buildTerminalBashLine(command) {
149
+ export function buildTerminalBashLine(command, tailFile) {
141
150
  const trimmed = command.replace(/\s+$/, "");
142
151
  let body = trimmed;
143
152
  if (trimmed.includes("\n")) {
@@ -148,7 +157,156 @@ export function buildTerminalBashLine(command) {
148
157
  .replace(/\n/g, "\\n")
149
158
  .replace(/\t/g, "\\t")}'`;
150
159
  }
151
- return `${body}; __pi_rc=$?; printf '\\n[pi-exit:%s]\\n' "$__pi_rc"`;
160
+ // 退出码取【第一个】命令(真正干活的那个)而非管道末尾命令:`head`/`grep`/`tail`
161
+ // 在管道末尾会把退出码吞成自己的(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 进文件、终端为空;
167
+ // 在哨兵前补一个 `tail -N log` 让模型看到日志尾部,退出码仍是底层命令的。
168
+ const rcGuard = `__pi_rc=\${PIPESTATUS:-\$?}; [ "$__pi_rc" -eq 141 ] && __pi_rc=0`;
169
+ const tailPart = tailFile
170
+ ? `; tail -n ${Math.max(1, Math.floor(tailFile.lines))} -- '${tailFile.file.replace(/'/g, `'\\''`)}'`
171
+ : "";
172
+ return `${body}; ${rcGuard}${tailPart}; printf '\\n[pi-exit:%s]\\n' "$__pi_rc"`;
173
+ }
174
+ /** 顶层(引号/反引号/转义外)按 `|` 拆分的管道元素。 */
175
+ export function splitTopLevelPipes(cmd) {
176
+ const parts = [];
177
+ let cur = "";
178
+ let quote = null;
179
+ for (let i = 0; i < cmd.length; i++) {
180
+ const ch = cmd[i];
181
+ if (quote) {
182
+ cur += ch;
183
+ if (ch === "\\" && quote !== "'" && quote !== "`" && i + 1 < cmd.length)
184
+ cur += cmd[++i];
185
+ else if (ch === quote)
186
+ quote = null;
187
+ continue;
188
+ }
189
+ if (ch === "'" || ch === '"' || ch === "`") {
190
+ quote = ch;
191
+ cur += ch;
192
+ continue;
193
+ }
194
+ if (ch === "|") {
195
+ parts.push(cur.trimEnd());
196
+ cur = "";
197
+ continue;
198
+ }
199
+ cur += ch;
200
+ }
201
+ parts.push(cur.trimEnd());
202
+ return parts;
203
+ }
204
+ /** 从 tail 参数里解析行数:`-n 15` / `-n15` / `-15` / `--lines=15` / `--lines 15`。 */
205
+ function parseTailLines(rest) {
206
+ const m = rest.match(/--lines(?:=|\s+)(\d+)|-n\s*(\d+)|(?:^|\s)-(\d+)/);
207
+ if (!m)
208
+ return null;
209
+ return Number(m[1] ?? m[2] ?? m[3]);
210
+ }
211
+ /**
212
+ * 识别命令**末尾**用来「限制输出量」的管道:`| tail [-n N|-N]`(不含 -f)、`| less`、
213
+ * `| more`、`| cat`。这类管道会 ①缓冲输出(可见终端全程哑火、无法感知实时进度)②把
214
+ * 退出码变成**管道最后一个命令**(tail 恒 0)——掩盖底层真实失败 ③需 stdin 的管道
215
+ * (尤其长驻命令)出错后可能一直挂到超时。
216
+ *
217
+ * 识别后由调用方拆掉它:底层命令直跑(实时可见 + 真实退出码),只在返回给模型时取
218
+ * 末尾 N 行(tail)或全部输出。
219
+ *
220
+ * - **只拆末尾单个纯“限输出/透传”**;`| grep`/`| head`/`| awk`/`| sed`/`| sort`/`| uniq`
221
+ * (真过滤/变换,拆掉会丢语义或崩出海量未过滤输出)与 `| tee`(写文件副作用)都**不拆**,
222
+ * 交给 prompt 引导模型不用。`| head` 也因 `yes | head -5` 靠 SIGPIPE 早停而**不拆**。
223
+ * - `tail -f` / `tail --follow`(长驻观察)也不拆。
224
+ */
225
+ export function detectTrailingLimiter(command) {
226
+ const parts = splitTopLevelPipes(command);
227
+ if (parts.length < 2)
228
+ return null;
229
+ // 管道分隔处可能在 `|` 后留前导空白(`| tail`),trim 掉再匹配。
230
+ const last = parts[parts.length - 1].trim();
231
+ const m = last.match(/^(tail|less|more|cat)\b(.*)$/i);
232
+ if (!m)
233
+ return null;
234
+ const kind = m[1].toLowerCase();
235
+ const rest = m[2].trim();
236
+ if (kind === "tail") {
237
+ // 长驻观察:tail -f / tail --follow —— 不拆。
238
+ if (/(?:^|\s)(?:-f|--follow(?:=|\b))/.test(rest))
239
+ return null;
240
+ const lines = parseTailLines(rest) ?? 10; // 裸 `| tail` 默认 10 行
241
+ return { base: parts.slice(0, -1).map((s) => s.trim()).join(" | "), kind, lines, segment: last };
242
+ }
243
+ // less / more / cat:纯透传,拆掉只为修正退出码 + 避免交互分页器在 PTY 里挂起。
244
+ return { base: parts.slice(0, -1).map((s) => s.trim()).join(" | "), kind, lines: null, segment: last };
245
+ }
246
+ /** 识别命令里把 stdout 重定向到文件的 `> file` / `>> file`(忽略 2>N / >&N / /dev/null)。
247
+ * 用于 `cmd > log 2>&1 | tail -N`:拆掉 tail 后输出进文件,终端为空——补一个 tail 文件
248
+ * 让模型能看到日志尾部与真实退出码。返回文件路径;复杂/带引号目标暂不处理。 */
249
+ export function detectStdoutRedirect(command) {
250
+ const all = [...command.matchAll(/(?:^|[\s;&|])(>>|>)\s*([^\s;|&]+)/g)];
251
+ for (let i = all.length - 1; i >= 0; i--) {
252
+ const file = all[i][2];
253
+ if (/^(?:&[0-9]+|[0-9]+)$/.test(file) || file === "/dev/null" || /["'`]/.test(file))
254
+ return null;
255
+ return { file };
256
+ }
257
+ return null;
258
+ }
259
+ /** 对终端输出缓冲做快照查询:head / tail / search(+context)。返回带 1-based 行号的
260
+ * 人类可读文本,便于 AI 定位后继续按行查看/搜索。纯函数(单测可覆盖)。 */
261
+ export function queryTerminalOutput(output, q, running = false, exitCode = null) {
262
+ const lines = stripAnsi(output)
263
+ .replace(/\r(?!\n)/g, "")
264
+ .split("\n")
265
+ .filter((l) => {
266
+ const t = l.trim();
267
+ return !/^\[pi-exit:\d+\]$/.test(t) && !/^\[进程已退出/.test(t);
268
+ });
269
+ while (lines.length > 0 && !lines[lines.length - 1].trim())
270
+ lines.pop();
271
+ const numbered = (arr, start) => arr.map((l, i) => `${start + i + 1}: ${l}`).join("\n");
272
+ if (q.search) {
273
+ const ctx = Math.max(0, q.context ?? 3);
274
+ const needle = q.search.toLowerCase();
275
+ const matches = [];
276
+ const out = [];
277
+ let i = 0;
278
+ while (i < lines.length) {
279
+ if (lines[i].toLowerCase().includes(needle)) {
280
+ matches.push({ line: i + 1, text: lines[i] });
281
+ const s = Math.max(0, i - ctx);
282
+ const e = Math.min(lines.length - 1, i + ctx);
283
+ out.push(`── 匹配行 ${i + 1} ──`);
284
+ for (let j = s; j <= e; j++)
285
+ out.push(`${j + 1}: ${lines[j]}`);
286
+ out.push("");
287
+ i = e + 1;
288
+ }
289
+ else {
290
+ i++;
291
+ }
292
+ }
293
+ return {
294
+ text: out.join("\n").trim() || "(无匹配)",
295
+ running,
296
+ exitCode,
297
+ matches: matches.length ? matches : undefined,
298
+ };
299
+ }
300
+ if (q.head !== undefined && q.head >= 0) {
301
+ const arr = lines.slice(0, q.head);
302
+ return { text: numbered(arr, 0), running, exitCode };
303
+ }
304
+ if (q.tail !== undefined && q.tail >= 0) {
305
+ const n = Math.max(1, q.tail);
306
+ const arr = lines.slice(-n);
307
+ return { text: numbered(arr, lines.length - arr.length), running, exitCode };
308
+ }
309
+ return { text: numbered(lines, 0), running, exitCode };
152
310
  }
153
311
  /** 去掉 ANSI 转义序列(OSC/CSI/其余 ESC 序列)与孤立 CR(进度条重绘),
154
312
  * 让 PTY 回显变成 bash 工具风格的纯文本。 */
@@ -249,6 +407,10 @@ function shellEnv() {
249
407
  const env = {
250
408
  ...process.env,
251
409
  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",
252
414
  };
253
415
  if (!env.LANG && !env.LC_ALL)
254
416
  env.LANG = "en_US.UTF-8";
@@ -465,7 +627,7 @@ export class TerminalManager {
465
627
  this.fail(id, "终端工作目录必须位于当前工作区内");
466
628
  return null;
467
629
  }
468
- if (this.spawnShell(id, safeCwd, cols, rows, title || `终端 ${++this.seq}`, undefined, opts?.forceBash)) {
630
+ if (this.spawnShell(id, safeCwd, cols, rows, title || `终端 ${++this.seq}`, undefined, opts?.forceBash, opts?.runLine)) {
469
631
  this.maybeEmitTccHint(id);
470
632
  this.emitList();
471
633
  return this.info(this.terms.get(id));
@@ -544,7 +706,7 @@ export class TerminalManager {
544
706
  this.input(id, command + "\r");
545
707
  }
546
708
  /** Spawn the user's shell as a PTY. Returns false when the spawn failed. */
547
- spawnShell(id, cwd, cols, rows, title, command, forceBash) {
709
+ spawnShell(id, cwd, cols, rows, title, command, forceBash, runLine) {
548
710
  let abs = cwd;
549
711
  if (!abs)
550
712
  abs = homedir();
@@ -566,7 +728,12 @@ export class TerminalManager {
566
728
  let pty;
567
729
  try {
568
730
  const { shell, args } = forceBash ? resolveBashShell() : resolveShell();
569
- pty = spawn(shell, args, {
731
+ // runLine != null → 以 `bash -c <runLine>` 非交互启动:无命令回显、无提示符,
732
+ // 退出后缓冲即干净输出(供一次性 bash 终端的事后查询/搜索)。
733
+ const spawnArgs = runLine !== undefined
734
+ ? ["-c", runLine]
735
+ : args;
736
+ pty = spawn(shell, spawnArgs, {
570
737
  name: "xterm-256color",
571
738
  cols: Math.max(2, Math.floor(cols) || 80),
572
739
  rows: Math.max(2, Math.floor(rows) || 24),
@@ -657,7 +824,9 @@ export class TerminalManager {
657
824
  return;
658
825
  // 一次性:触发后解除武装,直到下次 agent 触碰。
659
826
  entry.agentTouched = false;
660
- this.onAgentIdle?.(entry.id, Date.now() - entry.lastActivityAt, entry.title);
827
+ // 附带最近 N 行输出(可配),便于 AI 直接看到终端当前状态。
828
+ const lastLines = this.query(entry.id, { tail: terminalIdleNotifyLines() })?.text ?? "";
829
+ this.onAgentIdle?.(entry.id, Date.now() - entry.lastActivityAt, entry.title, lastLines);
661
830
  }, delay);
662
831
  entry.idleTimer.unref?.();
663
832
  }
@@ -797,6 +966,13 @@ export class TerminalManager {
797
966
  const end = Math.min(start + Math.max(1, Math.floor(maxBytes) || 20_000), entry.outputOffset + entry.output.length);
798
967
  return { data: entry.output.slice(start - entry.outputOffset, end - entry.outputOffset), cursor: end, running: !entry.exited, exitCode: entry.exitCode };
799
968
  }
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
+ }
800
976
  async waitForOutput(id, cursor, timeoutMs, signal) {
801
977
  const current = this.read(id, cursor, 1);
802
978
  if (!current || current.cursor > cursor || !current.running)
@@ -1096,12 +1272,11 @@ function cleanBashOutput(raw) {
1096
1272
  * 工具做不到的。
1097
1273
  */
1098
1274
  export function makeTerminalBashTool(terminals, opts) {
1099
- const TERM_ID = "ai-bash";
1100
1275
  return defineTool({
1101
1276
  name: "bash",
1102
1277
  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)",
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)",
1105
1280
  parameters: Type.Object({
1106
1281
  command: Type.String({ description: "The shell command to run" }),
1107
1282
  timeout: Type.Optional(Type.Number({ description: "Optional timeout in seconds" })),
@@ -1110,51 +1285,87 @@ export function makeTerminalBashTool(terminals, opts) {
1110
1285
  maximum: 5000,
1111
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.",
1112
1287
  })),
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).",
1290
+ })),
1113
1291
  }),
1114
1292
  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})`);
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));
1305
+ }
1306
+ // 拆掉模型常写的尾部输出限制/过滤管道(`| tail -N` / `| less` / `| more` / `| cat` / `| grep`):
1307
+ // 这类管道在持久终端里 ①缓冲输出——可见终端全程哑火、无法感知实时进度;
1308
+ // ②吞掉真实退出码——退出码取管道最后一个命令(tail/grep 恒 0/1),掩盖真实失败;
1309
+ // ③需 stdin 的管道(尤其长驻命令)出错后可能一直挂到超时。拆掉后底层命令直跑
1310
+ // (实时可见 + 真实退出码),只在返回给模型时取末尾 N 行或全部。
1311
+ const limiter = detectTrailingLimiter(p.command);
1312
+ const stripped = limiter !== null;
1313
+ const effectiveTail = p.tail ?? (limiter?.kind === "tail" ? limiter.lines : undefined);
1314
+ const runCommand = stripped ? limiter.base : p.command;
1315
+ // `cmd > log 2>&1 | tail -N`:拆掉 tail 后 stdout 进文件、终端为空 → 补一个
1316
+ // tail 文件让模型看到日志尾部与真实退出码(否则输出为空)。
1317
+ const redirect = stripped && limiter.kind === "tail" ? detectStdoutRedirect(runCommand) : null;
1318
+ const tailFile = redirect
1319
+ ? { file: redirect.file, lines: limiter.lines ?? 10 }
1320
+ : undefined;
1321
+ const limiterNote = stripped
1322
+ ? `\n[注:检测到你带了 ${limiter.segment}——这类管道在持久终端里会缓冲输出、吞掉真实退出码,命令出错时还可能一直挂到超时;已让底层命令直跑。${limiter.kind === "tail" ? (tailFile ? `输出已重定向到 ${redirect.file},改为 tail 该文件返回末尾 ${limiter.lines} 行。` : `仍只返回末尾 ${limiter.lines} 行。`) : "本次返回全部输出。"} 后续直接用 bash(command, tail=N) 参数限输出,别再套管道。]`
1323
+ : "";
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})`);
1122
1331
  }
1123
1332
  // 阻塞等待期间挂起活力提醒(我们自己在检测静默,避免双重通知)。
1124
- terminals.suspendIdleWatch(TERM_ID);
1125
- const start = terminals.endCursor(TERM_ID);
1333
+ terminals.suspendIdleWatch(termId);
1334
+ const start = terminals.endCursor(termId);
1126
1335
  const ac = new AbortController();
1127
1336
  opts.kills.add(ac);
1128
1337
  const idleMs = Math.max(0, opts.idleMs());
1129
1338
  const deadline = p.timeout && p.timeout > 0 ? Date.now() + p.timeout * 1000 : null;
1130
- // tail 参数:只返回末尾 N 行(替代 `| tail -N` 管道——管道会缓冲输出、
1131
- // 让可见终端全程哑火,还容易白白触发静默解阻)。
1339
+ // tail 参数(或检测到的行数):只返回末尾 N 行(替代 `| tail -N` 管道)。
1132
1340
  const applyTail = (t) => {
1133
- if (!p.tail || p.tail <= 0)
1341
+ if (!effectiveTail || effectiveTail <= 0)
1134
1342
  return t;
1135
1343
  const lines = t.split("\n");
1136
- return lines.length > p.tail
1137
- ? `…(前 ${lines.length - p.tail} 行已省略)\n${lines.slice(-p.tail).join("\n")}`
1344
+ return lines.length > effectiveTail
1345
+ ? `…(前 ${lines.length - effectiveTail} 行已省略)\n${lines.slice(-effectiveTail).join("\n")}`
1138
1346
  : t;
1139
1347
  };
1140
1348
  try {
1141
1349
  let collected = "";
1142
1350
  let cursor = start;
1143
1351
  let lastDataAt = Date.now();
1144
- // 标记「有哨兵命令在跑」:terminal_wait 据此区分等待与空闲。
1145
- terminals.setSentinelPending(TERM_ID, true);
1146
- const inputErr = terminals.inputChecked(TERM_ID, buildTerminalBashLine(p.command) + "\r");
1352
+ // 一次性:命令已在 spawn 时经 `bash -c` 跑起(无 stdin 回显);持久:经 stdin 写入。
1353
+ const inputErr = persistent
1354
+ ? terminals.inputChecked(termId, bashLine + "\r")
1355
+ : null;
1147
1356
  if (inputErr)
1148
1357
  throw new Error(inputErr);
1358
+ // 标记「有哨兵命令在跑」:terminal_wait 据此区分等待与空闲。
1359
+ terminals.setSentinelPending(termId, true);
1149
1360
  for (;;) {
1150
1361
  if (ac.signal.aborted || signal?.aborted) {
1151
1362
  // Ctrl+C 杀前台进程;终端本身保留(会话状态还在)。
1152
- terminals.setSentinelPending(TERM_ID, false);
1153
- terminals.inputChecked(TERM_ID, "\x03");
1363
+ terminals.setSentinelPending(termId, false);
1364
+ terminals.inputChecked(termId, "\x03");
1154
1365
  throw new Error("Command aborted");
1155
1366
  }
1156
1367
  await sleep(60);
1157
- const read = terminals.read(TERM_ID, cursor);
1368
+ const read = terminals.read(termId, cursor);
1158
1369
  if (read?.data) {
1159
1370
  collected += read.data;
1160
1371
  cursor = read.cursor;
@@ -1162,26 +1373,30 @@ export function makeTerminalBashTool(terminals, opts) {
1162
1373
  }
1163
1374
  const m = lastSentinel(collected);
1164
1375
  if (m) {
1165
- terminals.setSentinelPending(TERM_ID, false);
1376
+ terminals.setSentinelPending(termId, false);
1166
1377
  const text = applyTail(cleanBashOutput(collected));
1167
1378
  return {
1168
1379
  content: [
1169
1380
  {
1170
1381
  type: "text",
1171
- text: `${text}${text ? "\n" : ""}[exit:${m[1]}]`,
1382
+ text: `${idLine}\n${text}${text ? "\n" : ""}${limiterNote}[exit:${m[1]}]`,
1172
1383
  },
1173
1384
  ],
1174
- details: { exitCode: Number(m[1]), output: text },
1385
+ details: {
1386
+ exitCode: Number(m[1]),
1387
+ output: text,
1388
+ terminalId: termId,
1389
+ },
1175
1390
  };
1176
1391
  }
1177
1392
  if (deadline !== null && Date.now() > deadline) {
1178
- terminals.setSentinelPending(TERM_ID, false);
1179
- terminals.inputChecked(TERM_ID, "\x03");
1393
+ terminals.setSentinelPending(termId, false);
1394
+ terminals.inputChecked(termId, "\x03");
1180
1395
  throw new Error(`Command timed out after ${p.timeout}s(已发 Ctrl+C;已有输出:${truncateMiddle(stripAnsi(collected), 4000)})`);
1181
1396
  }
1182
1397
  // 静默解阻:转后台 + 注册完成观察器,立即把控制权还给模型。
1183
1398
  if (idleMs > 0 && Date.now() - lastDataAt >= idleMs) {
1184
- return backgroundResult(terminals, opts, p.command, applyTail(cleanBashOutput(collected)), Math.round((Date.now() - lastDataAt) / 1000));
1399
+ return backgroundResult(terminals, opts, termId, runCommand, applyTail(cleanBashOutput(collected)), Math.round((Date.now() - lastDataAt) / 1000), limiterNote, idLine);
1185
1400
  }
1186
1401
  }
1187
1402
  }
@@ -1192,12 +1407,12 @@ export function makeTerminalBashTool(terminals, opts) {
1192
1407
  });
1193
1408
  }
1194
1409
  /** 静默解阻路径:注册完成观察器后立即返回「仍在后台运行」。 */
1195
- function backgroundResult(terminals, opts, command, partialText, silentSeconds) {
1196
- terminals.watchOutput("ai-bash", BASH_SENTINEL_RE, (m) => {
1410
+ function backgroundResult(terminals, opts, terminalId, command, partialText, silentSeconds, note = "", idLine = "") {
1411
+ terminals.watchOutput(terminalId, BASH_SENTINEL_RE, (m) => {
1197
1412
  // 后台命令最终结束(或终端被关)→ 清除待决标记,terminal_wait 不再适用。
1198
- terminals.setSentinelPending("ai-bash", false);
1413
+ terminals.setSentinelPending(terminalId, false);
1199
1414
  opts.notifyBackgroundDone({
1200
- terminalId: "ai-bash",
1415
+ terminalId,
1201
1416
  command,
1202
1417
  exitCode: m ? Number(m[1]) : null,
1203
1418
  });
@@ -1208,13 +1423,14 @@ function backgroundResult(terminals, opts, command, partialText, silentSeconds)
1208
1423
  content: [
1209
1424
  {
1210
1425
  type: "text",
1211
- text: `命令仍在持久终端 ai-bash 中运行(已连续 ${silentSeconds} 秒无输出,未结束)。` +
1426
+ text: `${idLine}\n命令仍在终端 ${terminalId} 中运行(已连续 ${silentSeconds} 秒无输出,未结束)。` +
1212
1427
  `本次调用不阻塞——命令继续在后台执行,结束时你会收到自动通知。\n` +
1213
1428
  `已有输出:\n${partial || "(暂无输出)"}\n` +
1214
- `要重新阻塞等它结束就用 terminal_wait(terminalId="ai-bash")(无需反复轮询);需要交互用 terminal_input / terminal_key(Ctrl+C 可终止)。`,
1429
+ `要重新阻塞等它结束就用 terminal_wait(terminalId="${terminalId}")(无需反复轮询);需要交互用 terminal_input / terminal_key(Ctrl+C 可终止)。` +
1430
+ note,
1215
1431
  },
1216
1432
  ],
1217
- details: { running: true, terminalId: "ai-bash", silentSeconds },
1433
+ details: { running: true, terminalId, silentSeconds },
1218
1434
  };
1219
1435
  }
1220
1436
  /** Names of the agent-facing persistent-terminal tools(设置开关门控用)。 */
@@ -1319,14 +1535,30 @@ export function makePersistentTerminalTools(terminals, cwd) {
1319
1535
  defineTool({
1320
1536
  name: "terminal_read",
1321
1537
  label: "Read terminal output",
1322
- 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.",
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.",
1323
1539
  parameters: Type.Object({
1324
1540
  terminalId: Type.String(),
1325
1541
  cursor: Type.Optional(Type.Integer({ minimum: 0 })),
1326
1542
  maxBytes: Type.Optional(Type.Integer({ minimum: 1, maximum: 100000 })),
1327
1543
  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)" })),
1328
1548
  }),
1329
1549
  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
+ }
1330
1562
  const cursor = p.cursor ?? 0;
1331
1563
  if (p.waitMs)
1332
1564
  await terminals.waitForOutput(p.terminalId, cursor, p.waitMs, signal);
@@ -115,8 +115,6 @@ export class WebUIContext {
115
115
  });
116
116
  }
117
117
  // -- notifications --------------------------------------------------------
118
- // Instance arrows so these survive the SDK's wrapUIPromptContext `{ ...ui }`
119
- // (object spread copies own properties only, not class prototype methods).
120
118
  notify = (message, type) => {
121
119
  this.emit({ type: "notice", level: type ?? "info", text: message });
122
120
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.58.0",
3
+ "version": "0.59.0",
4
4
  "description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -50,9 +50,10 @@
50
50
  "dev": "concurrently -k -n server,web -c blue,green \"npm:dev:server\" \"npm:dev:web\"",
51
51
  "dev:server": "cross-env PI_WEB_PORT=8788 PI_WEB_ALLOW_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 node --watch --import tsx server/index.ts",
52
52
  "dev:web": "vite --config web/vite.config.ts",
53
- "build": "npm run build:web && npm run build:server",
53
+ "build": "npm run build:web && npm run build:server && npm run build:dsh-runtime",
54
54
  "build:web": "vite build --config web/vite.config.ts",
55
55
  "build:server": "tsc -p tsconfig.server.json",
56
+ "build:dsh-runtime": "node scripts/copy-dsh-runtime.mjs",
56
57
  "typecheck": "tsc -p tsconfig.server.json --noEmit && tsc -p web/tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit",
57
58
  "test": "vitest run",
58
59
  "test:unit": "vitest run",
@@ -62,6 +63,8 @@
62
63
  "start": "node dist/server/index.js"
63
64
  },
64
65
  "dependencies": {
66
+ "@deepseek-ai/dsh-sdk-jsonrpc-server": "^0.1.1-rc.2",
67
+ "@deepseek-ai/dsh-sdk-protocol": "^0.1.1-rc.2",
65
68
  "@earendil-works/pi-coding-agent": "^0.84.4",
66
69
  "@xterm/addon-fit": "^0.11.0",
67
70
  "@xterm/xterm": "^6.0.0",
@@ -0,0 +1,2 @@
1
+ import{a as d,j as n}from"./markdown-DRBrS2Nf.js";import{b as $,T as R,u as O,F as P,a as A,c as q,d as L,e as B,f as J,g as X,h as G,r as Q}from"./index-MeifpZzi.js";import{D as U,o as V}from"./xterm-D1D2FVe3.js";import"./react-C9ovnpIm.js";function W({conversationId:s,terminalId:r,command:x,cwd:a,active:p,send:m,register:v}){const w=d.useRef(null),N=d.useRef(null),b=x?JSON.stringify(x):"";return d.useEffect(()=>{const f=w.current;if(!f)return;const t=new U({theme:$(),fontFamily:'"SF Mono", "JetBrains Mono", ui-monospace, Menlo, Consolas, monospace',fontSize:13,cursorBlink:!0,scrollback:8e3}),c=new V;t.loadAddon(c),t.open(f),N.current={term:t,fit:c},p&&t.focus();const y=()=>{t.options.theme=$()};window.addEventListener(R,y),t.attachCustomKeyEventHandler(o=>{var F;if(o.type!=="keydown")return!0;const E=(F=o.key)==null?void 0:F.toLowerCase();if((o.ctrlKey||o.metaKey)&&E==="v")return!1;if(o.ctrlKey&&!o.shiftKey&&!o.altKey&&E==="c"&&t.hasSelection()){const T=t.textarea;return T&&(T.value=t.getSelection(),T.select()),!1}return!0});const g=v(s,r,{write:o=>t.write(o),dispose:()=>t.dispose()}),C=()=>{try{c.fit(),m({type:"terminal_resize",terminalId:r,conversationId:s,cols:t.cols,rows:t.rows})}catch{}},j=requestAnimationFrame(()=>{try{c.fit()}catch{}m(x?{type:"run_command",terminalId:r,conversationId:s,command:x,cols:t.cols,rows:t.rows}:{type:"terminal_create",terminalId:r,conversationId:s,cwd:a,cols:t.cols,rows:t.rows})}),k=t.onData(o=>{m({type:"terminal_input",terminalId:r,conversationId:s,data:o})});let h=null;return typeof ResizeObserver<"u"&&(h=new ResizeObserver(()=>{f.offsetWidth>0&&f.offsetHeight>0&&C()}),h.observe(f)),()=>{cancelAnimationFrame(j),k.dispose(),window.removeEventListener(R,y),h==null||h.disconnect(),g(),t.dispose(),N.current=null}},[s,r,b,m,v]),d.useEffect(()=>{if(!p)return;const f=requestAnimationFrame(()=>{const t=N.current;if(t){try{t.fit.fit(),m({type:"terminal_resize",terminalId:r,conversationId:s,cols:t.term.cols,rows:t.term.rows})}catch{}t.term.focus()}});return()=>cancelAnimationFrame(f)},[p]),n.jsx("div",{ref:w,className:`term-xterm ${p?"":"hidden"}`})}const I={name:"",command:"",cwd:"${pwd}"};function se({chat:s,send:r,terminal:x}){const a=O(),[p,m]=d.useState(null),[v,w]=d.useState(!1),[N,b]=d.useState(!1),[f,t]=d.useState(null),[c,y]=d.useState(I),[g,C]=d.useState(null),j=d.useRef(null);d.useEffect(()=>{s.terminals.length===0?m(null):s.terminals.some(e=>e.id===p)||m(s.terminals[s.terminals.length-1].id)},[s.terminals,p]),d.useEffect(()=>{s.terminalActiveId&&(m(s.terminalActiveId),w(!1))},[s.terminalActiveId]),d.useEffect(()=>()=>{j.current&&clearTimeout(j.current)},[]);const k=e=>{var u;if(!s.ready)return;const i=Q(),l=s.activeConversationId||((u=s.state)==null?void 0:u.conversationId)||"";x.create({...e,id:i,conversationId:l,cols:e.cols??80,rows:e.rows??24,running:!0,exitCode:null}),m(i),w(!1)},h=()=>{var e;return k({title:a("terminalTitle",{n:s.terminals.length+1}),cwd:((e=s.state)==null?void 0:e.cwd)??""})},o=e=>{var u;const i=e.name||e.command,l=s.terminals.find(_=>_.title===i);if(l){x.restart(l.id),m(l.id),r({type:"run_command",terminalId:l.id,conversationId:l.conversationId,command:e,cols:80,rows:24});return}k({title:i,cwd:((u=s.state)==null?void 0:u.cwd)??"",command:e})},E=e=>{const i=s.terminals.find(l=>l.id===e);if(i&&r({type:"terminal_kill",terminalId:e,conversationId:i.conversationId}),x.close(e),p===e){const l=s.terminals.filter(u=>u.id!==e);m(l.length>0?l[l.length-1].id:null)}},F=()=>{b(!0),t(null),y(I)},T=e=>{const i=s.commands[e];i&&(b(!1),t(e),y({name:i.name,command:i.command,cwd:i.cwd??""}))},D=()=>{b(!1),t(null)},S=()=>{const e=c.name.trim(),i=c.command.trim();if(!e||!i)return;const l=c.cwd.trim(),u={name:e,command:i,cwd:l||void 0},_=N?[...s.commands,u]:f!==null?s.commands.map((z,H)=>H===f?u:z):s.commands;r({type:"save_commands",commands:_}),D()},K=e=>{if(g===e){const i=s.commands.filter((l,u)=>u!==e);r({type:"save_commands",commands:i}),C(null),j.current&&clearTimeout(j.current)}else C(e),j.current&&clearTimeout(j.current),j.current=setTimeout(()=>C(null),2500)},M=N||f!==null;return n.jsxs("div",{className:"terminal-view",children:[n.jsxs("aside",{className:`term-side term-commands ${v?"open":""}`,children:[n.jsxs("div",{className:"panel-header",children:[n.jsx("span",{className:"panel-title",children:a("commands")}),n.jsxs("div",{className:"panel-header-actions",children:[n.jsx("button",{type:"button",className:"panel-refresh",title:a("rerun"),onClick:()=>r({type:"list_commands"}),children:n.jsx(P,{})}),n.jsx("button",{type:"button",className:"panel-new",title:a("newCommand"),onClick:F,children:n.jsx(A,{})})]})]}),n.jsx("div",{className:"panel-body",children:M?n.jsxs("div",{className:"cmd-form",children:[n.jsx("label",{htmlFor:"cmd-name",children:a("name")}),n.jsx("input",{id:"cmd-name",className:"cmd-input",value:c.name,placeholder:a("exampleName"),autoFocus:!0,onChange:e=>y({...c,name:e.target.value})}),n.jsx("label",{htmlFor:"cmd-command",children:a("command")}),n.jsx("input",{id:"cmd-command",className:"cmd-input",value:c.command,placeholder:a("exampleCommand"),onChange:e=>y({...c,command:e.target.value}),onKeyDown:e=>{e.key==="Enter"&&!e.nativeEvent.isComposing&&S()}}),n.jsxs("label",{htmlFor:"cmd-cwd",children:[a("directory")," ",n.jsx("span",{className:"cmd-hint",children:a("cwdHint")})]}),n.jsx("input",{id:"cmd-cwd",className:"cmd-input",value:c.cwd,placeholder:"${pwd}",onChange:e=>y({...c,cwd:e.target.value}),onKeyDown:e=>{e.key==="Enter"&&!e.nativeEvent.isComposing&&S()}}),n.jsxs("div",{className:"cmd-form-actions",children:[n.jsx("button",{type:"button",className:"btn",onClick:D,children:a("cancel")}),n.jsx("button",{type:"button",className:"btn primary",disabled:!c.name.trim()||!c.command.trim(),onClick:S,children:a("save")})]})]}):n.jsxs(n.Fragment,{children:[s.commands.length===0&&n.jsx("div",{className:"panel-empty",children:a("noCommands")}),s.commands.map((e,i)=>n.jsxs("div",{className:"cmd-item",children:[n.jsx("button",{type:"button",className:"cmd-run",title:a("clickToRun"),onClick:()=>o(e),children:n.jsx(q,{})}),n.jsxs("button",{type:"button",className:"cmd-main",title:a("clickToRun"),onClick:()=>o(e),children:[n.jsx("span",{className:"cmd-name",children:e.name}),n.jsx("span",{className:"cmd-command",children:e.command}),e.cwd&&n.jsx("span",{className:"cmd-cwd",children:e.cwd})]}),n.jsx("button",{type:"button",className:"cmd-act",title:a("edit"),onClick:()=>T(i),children:n.jsx(L,{})}),n.jsx("button",{type:"button",className:`cmd-act del ${g===i?"confirm":""}`,title:a("delete"),onClick:()=>K(i),children:g===i?a("confirmQ"):n.jsx(B,{})})]},i))]})}),n.jsxs("div",{className:"term-tabs-block",children:[n.jsxs("div",{className:"panel-header",children:[n.jsx("span",{className:"panel-title",children:a("terminal")}),n.jsx("button",{type:"button",className:"panel-new",title:a("newTerminal"),onClick:h,children:n.jsx(A,{})})]}),n.jsxs("div",{className:"panel-body",children:[s.terminals.length===0&&n.jsx("div",{className:"panel-empty",children:a("noTerminal")}),s.terminals.map(e=>n.jsxs("div",{className:`term-tab ${e.id===p?"active":""}`,children:[n.jsxs("button",{type:"button",className:"term-tab-main",title:`${e.cwd}${e.command?`
2
+ > ${e.command.command}`:""}`,onClick:()=>{m(e.id),w(!1)},children:[n.jsx("span",{className:`term-tab-dot ${e.running?"run":"exit"}`}),n.jsxs("span",{className:"term-tab-title",children:[e.title,!e.running&&n.jsx("span",{className:"term-tab-exit",children:a("exited",{code:e.exitCode===null?"":` ${e.exitCode}`})})]})]}),n.jsx("button",{type:"button",className:"term-tab-close",title:a("closeTerminal"),onClick:()=>E(e.id),children:n.jsx(J,{})})]},e.id))]})]})]}),n.jsxs("div",{className:"term-main",children:[v&&n.jsx("div",{className:"drawer-backdrop",onClick:()=>w(!1)}),n.jsx("button",{type:"button",className:"term-side-toggle",title:a("commands"),onClick:()=>w(e=>!e),children:n.jsx(X,{})}),s.terminals.length===0?n.jsxs("div",{className:"term-empty",children:[n.jsx(G,{className:"term-empty-icon"}),n.jsx("div",{className:"term-empty-title",children:a("builtinTerminal")}),n.jsx("div",{className:"term-empty-sub",children:a("termEmptySub")})]}):s.terminals.map(e=>n.jsx(W,{conversationId:e.conversationId,terminalId:e.id,command:e.command,cwd:e.cwd,active:e.id===p,send:r,register:x.register},`${e.conversationId}:${e.id}`))]})]})}export{se as TerminalPanel};