pi-web-ui 0.62.0 → 0.63.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.
Files changed (56) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +504 -497
  3. package/README.zh-CN.md +427 -422
  4. package/bin/pi-web-ui.mjs +1809 -1881
  5. package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
  6. package/deploy/nginx-subpath.conf +88 -88
  7. package/deploy/pi-web-ui-task.xml +54 -54
  8. package/deploy/pi-web-ui.service +31 -31
  9. package/dist/server/agent-service.js +318 -88
  10. package/dist/server/attachments.js +37 -28
  11. package/dist/server/bg-servers.js +6 -1
  12. package/dist/server/client-state.js +15 -7
  13. package/dist/server/control-socket.js +1 -5
  14. package/dist/server/dsh/dsh-agent-service.js +454 -118
  15. package/dist/server/dsh/dsh-client.js +1 -3
  16. package/dist/server/dsh/dsh-sessions.js +4 -14
  17. package/dist/server/dsh/runtime/cordis.yml +1 -1
  18. package/dist/server/dsh/runtime/goal-rpc.mjs +645 -662
  19. package/dist/server/dsh/runtime/launcher.mjs +8 -18
  20. package/dist/server/dsh/runtime/override.patch.yml +9 -9
  21. package/dist/server/dsh/runtime/runtime-root.mjs +7 -11
  22. package/dist/server/ensure-bash.js +2 -5
  23. package/dist/server/files-service.js +42 -36
  24. package/dist/server/goal-service.js +59 -30
  25. package/dist/server/index.js +17 -10
  26. package/dist/server/mcp-bridge.js +11 -5
  27. package/dist/server/model-admin.js +78 -72
  28. package/dist/server/plugin-facilities.js +2 -4
  29. package/dist/server/plugin-updater.js +1 -1
  30. package/dist/server/plugins.js +26 -8
  31. package/dist/server/process-utils.js +4 -4
  32. package/dist/server/protocol-version.js +1 -1
  33. package/dist/server/scm.js +27 -21
  34. package/dist/server/serialize.js +1 -3
  35. package/dist/server/settings-service.js +63 -21
  36. package/dist/server/slash-commands.js +29 -3
  37. package/dist/server/subagent-templates.js +249 -0
  38. package/dist/server/subagents.js +150 -0
  39. package/dist/server/terminals.js +101 -40
  40. package/dist/server/text-sniff.js +4 -24
  41. package/dist/server/update-check.js +2 -4
  42. package/dist/server/uploads.js +1 -3
  43. package/dist/server/vision-bridge.js +2 -7
  44. package/dist/server/wait-subscription-scan.js +102 -21
  45. package/dist/server/webui-context.js +1 -1
  46. package/extensions/webui.ts +190 -192
  47. package/package.json +109 -103
  48. package/web/dist/assets/TerminalPanel-B9vcI7-_.js +2 -0
  49. package/web/dist/assets/index-B8Wv29p5.css +10 -0
  50. package/web/dist/assets/index-CWUWD0rq.js +323 -0
  51. package/web/dist/favicon.svg +8 -8
  52. package/web/dist/index.html +15 -15
  53. package/web/public/favicon.svg +8 -8
  54. package/web/dist/assets/TerminalPanel-BkCWDsIG.js +0 -2
  55. package/web/dist/assets/index-BRVKed7W.js +0 -321
  56. package/web/dist/assets/index-ORTpR6ZQ.css +0 -10
@@ -0,0 +1,150 @@
1
+ // ---------------------------------------------------------------------------
2
+ // subagents.ts — 第一方轻量子代理:工具定义与运行态模型
3
+ // ---------------------------------------------------------------------------
4
+ // 架构(相对参考项目的大幅简化):
5
+ //
6
+ // 参考项目(tintinweb / nicobailon 的 pi-subagents)把子代理做成「完整子会话」
7
+ // —— 独立 SessionManager / SettingsManager / 模型 / 工具隔离 / resume /
8
+ // steer / 并发池 / 工作区隔离,复杂度来自「要独立支撑一个完整会话」。
9
+ //
10
+ // pi-web-ui 的 ClientSession 天生就是多会话并发的:一个 conversation 就有
11
+ // 一个独立 AgentSessionRuntime + TerminalManager,所有 conversation 共享
12
+ // 同一个 modelRuntime,创建走 createAgentSessionServices + FromServices(已
13
+ // 封装好)。因此本模块把子代理定义为:
14
+ //
15
+ // 子代理 = 一个标记了 isSubagent 的普通 Conversation(inMemory session,
16
+ // 不落盘、不进历史/resume 列表)
17
+ // - 出现在左栏「运行的对话」列表,带「子代理」徽标
18
+ // - 用户可以像普通对话一样:点开查看实时消息流、输入补充(= steer)、
19
+ // 中止(= abort)、完成后移出(= dismiss)
20
+ // - 运行态经现有快照/消息管线推送,不需要单独的可视化桥
21
+ //
22
+ // 本文件只定义:运行态快照类型、host 接口(由 ClientSession 实现,操作的是
23
+ // 它的 conversation 体系)、以及注册给每个会话的 subagent_* 工具。真正创建
24
+ // conversation / 跑 prompt 全部在 agent-service.ts 的 spawnSubagent 里完成。
25
+ // ---------------------------------------------------------------------------
26
+ import { defineTool } from "@earendil-works/pi-coding-agent";
27
+ import { Type } from "typebox";
28
+ /** 从 prompt 取首行作为标题(截断 40 字符)。 */
29
+ export function subagentTitle(prompt) {
30
+ const line = prompt.split("\n")[0]?.trim() ?? "";
31
+ return line.length > 40 ? `${line.slice(0, 40)}…` : line;
32
+ }
33
+ /**
34
+ * 子代理工具集(注册进每个会话的 customTools,供主 agent 驱动子代理)。
35
+ * 用 `subagent_*` 前缀命名,避免与第三方 pi-subagents 的
36
+ * `Agent`/`get_subagent_result`/`steer_subagent` 冲突。
37
+ */
38
+ export function makeSubagentTools(host) {
39
+ const text = (t, details = {}) => ({
40
+ content: [{ type: "text", text: t }],
41
+ details,
42
+ });
43
+ return [
44
+ defineTool({
45
+ name: "subagent_spawn",
46
+ label: "Spawn subagent",
47
+ description: "在后台启动一个独立的子代理对话,用一个明确的指令去完成一项可独立交付的工作(调研/实现/审查等)。" +
48
+ "子代理会出现在左栏「运行的对话」列表(带子代理标识),用户可点开查看、补充、中止。主 agent 可并行派发多个:" +
49
+ "用 subagent_list 查看运行态、subagent_get_result 取结果、subagent_steer 中途改向、subagent_stop 停止。" +
50
+ "适合:长耗时探索、并行调研、独立子任务委派。可选 template 参数:使用设置面板配置的子代理模板" +
51
+ "(角色系统提示词 + 技能/扩展白名单);用 subagent_templates 查看可用清单,不传 = 不用模板。",
52
+ promptSnippet: "spawn an independent background subagent for a deliverable task (parallel work)",
53
+ parameters: Type.Object({
54
+ prompt: Type.String({ description: "交给子代理的完整指令(要达成的目标 + 约束 + 期望产出)。" }),
55
+ type: Type.Optional(Type.String({ description: "子代理类型/角色名(如 explore/implement/review),用于展示。默认 general。" })),
56
+ template: Type.Optional(Type.String({
57
+ description: "可选:子代理模板名(设置面板「子代理模板」配置的预设,见 subagent_templates 工具)。" +
58
+ "模板 = 角色系统提示词 + 技能/扩展白名单;不传 = 不使用模板,按主会话默认配置运行。",
59
+ })),
60
+ cwd: Type.Optional(Type.String({ description: "子代理工作目录(相对/绝对)。默认继承主会话工作目录。" })),
61
+ }),
62
+ execute: async (_id, p, _signal, _onUpdate, ctx) => {
63
+ if (p.template && !host.isTemplateUsable(p.template)) {
64
+ return text(`子代理模板不可用:${p.template}(不存在或已停用)。用 subagent_templates 查看当前可用模板清单;不传 template 则按默认配置运行。`);
65
+ }
66
+ const convId = await host.spawnSubagent(p.prompt, p.type ?? "general", p.cwd ?? ctx.cwd, p.template);
67
+ return text(`子代理已启动(运行列表可见):${convId}\n类型:${p.type ?? "general"} · 标题:${subagentTitle(p.prompt)}` +
68
+ (p.template ? `\n模板:${p.template}` : "") +
69
+ `\n用 subagent_get_result 取结果,subagent_list 看运行态,subagent_steer 改向,subagent_stop 停止。`, { convId, template: p.template });
70
+ },
71
+ }),
72
+ defineTool({
73
+ name: "subagent_get_result",
74
+ label: "Get subagent result",
75
+ description: "取一个子代理的结果或当前运行态。若尚未完成,返回当前状态与已产出的文本。",
76
+ promptSnippet: "fetch a subagent's result / current progress",
77
+ parameters: Type.Object({
78
+ runId: Type.String({ description: "subagent_spawn 返回的 convId。左栏点击同名对话可直接查看。" }),
79
+ }),
80
+ execute: async (_id, p) => {
81
+ const r = host.getSubagent(p.runId);
82
+ if (!r)
83
+ return text(`未找到子代理 ${shortId(p.runId)}(可能已移出)。`, undefined);
84
+ if (r.streaming || r.state === "running") {
85
+ return text(`子代理 ${shortId(r.convId)}(${r.type})仍在运行(状态 ${r.state})。\n当前输出:\n${r.output || "(暂无输出)"}`, r);
86
+ }
87
+ return text(`子代理 ${shortId(r.convId)}(${r.type})状态:${r.state}\n${r.output || "(无结果)"}`, r);
88
+ },
89
+ }),
90
+ defineTool({
91
+ name: "subagent_steer",
92
+ label: "Steer subagent",
93
+ description: "向一个子代理注入一条消息,重定向/补充它的工作方向(等同用户在它的对话里发消息)。",
94
+ promptSnippet: "inject a message into a running subagent to redirect its work",
95
+ parameters: Type.Object({
96
+ runId: Type.String({ description: "目标子代理 convId。" }),
97
+ message: Type.String({ description: "要注入的方向调整/补充信息。" }),
98
+ }),
99
+ execute: async (_id, p) => {
100
+ await host.steerSubagent(p.runId, p.message);
101
+ return text(`已向子代理 ${shortId(p.runId)} 注入消息。`);
102
+ },
103
+ }),
104
+ defineTool({
105
+ name: "subagent_list",
106
+ label: "List subagents",
107
+ description: "列出全部子代理的运行态:convId、类型、状态、标题、消息数。",
108
+ promptSnippet: "list all subagents and their live status",
109
+ parameters: Type.Object({}),
110
+ execute: async () => {
111
+ const list = host.listSubagents();
112
+ if (list.length === 0)
113
+ return text("当前没有子代理。");
114
+ const lines = list.map((r) => `- ${r.convId} · ${r.type} · ${r.state} · ${r.title}(msg: ${r.messageCount})`);
115
+ return text(lines.join("\n"));
116
+ },
117
+ }),
118
+ defineTool({
119
+ name: "subagent_stop",
120
+ label: "Stop subagent",
121
+ description: "停止一个运行中的子代理(等同用户在它的对话里点中止)。已完成的不受影响。",
122
+ promptSnippet: "stop a running subagent",
123
+ parameters: Type.Object({ runId: Type.String({ description: "目标子代理 convId。" }) }),
124
+ execute: async (_id, p) => {
125
+ await host.stopSubagent(p.runId);
126
+ return text(`已请求停止子代理 ${shortId(p.runId)}。`);
127
+ },
128
+ }),
129
+ defineTool({
130
+ name: "subagent_templates",
131
+ label: "List subagent templates",
132
+ description: "列出设置面板「子代理模板」配置的可用模板(角色系统提示词 + 技能/扩展白名单的组合预设)," +
133
+ "供 subagent_spawn 的 template 参数选用。已停用的模板不会出现在这里。list 为空 = 未配置模板,子代理按默认配置运行。",
134
+ promptSnippet: "list configurable subagent templates (role prompt + skills/extensions whitelist presets)",
135
+ parameters: Type.Object({}),
136
+ execute: async () => {
137
+ const list = host.listTemplates();
138
+ if (list.length === 0) {
139
+ return text("当前没有可用的子代理模板(设置面板 → 子代理模板 添加后可用)。子代理默认按主会话配置运行。");
140
+ }
141
+ const lines = list.map((t) => `- ${t.name}${t.description ? `:${t.description}` : ""}`);
142
+ return text(`可用的子代理模板(subagent_spawn 的 template 参数传名字,如 subagent_spawn(template="${list[0]?.name}")):\n${lines.join("\n")}`);
143
+ },
144
+ }),
145
+ ];
146
+ }
147
+ /** 短 id 前缀(前端展示/日志用)。 */
148
+ function shortId(id) {
149
+ return id.slice(0, 8);
150
+ }
@@ -22,7 +22,7 @@ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
22
22
  // module itself for details).
23
23
  import "./patch-node-pty.js";
24
24
  import { spawn } from "node-pty";
25
- import { defineTool, } from "@earendil-works/pi-coding-agent";
25
+ import { defineTool } from "@earendil-works/pi-coding-agent";
26
26
  import { Type } from "typebox";
27
27
  /** Location of the command list for a project: <workspaceRoot>/.pi/commands.json */
28
28
  export function commandsFilePath(workspaceRoot) {
@@ -162,6 +162,7 @@ export function buildTerminalBashLine(command, tailFile) {
162
162
  // 留下的 141 不是真失败,而是“按要求截断=成功”。只有恰好 141 才转 0,真失败照报。
163
163
  // tailFile:`cmd > log 2>&1 | tail -N`——拆掉 tail 后 stdout 进文件、终端为空;
164
164
  // 在哨兵前补一个 `tail -N log` 让模型看到日志尾部,退出码仍是底层命令的。
165
+ // eslint-disable-next-line no-useless-escape -- \$? must reach the shell literally
165
166
  const rcGuard = `__pi_rc=\${PIPESTATUS:-\$?}; [ "$__pi_rc" -eq 141 ] && __pi_rc=0`;
166
167
  const tailPart = tailFile
167
168
  ? `; tail -n ${Math.max(1, Math.floor(tailFile.lines))} -- '${tailFile.file.replace(/'/g, `'\\''`)}'`
@@ -234,10 +235,26 @@ export function detectTrailingLimiter(command) {
234
235
  if (/(?:^|\s)(?:-f|--follow(?:=|\b))/.test(rest))
235
236
  return null;
236
237
  const lines = parseTailLines(rest) ?? 10; // 裸 `| tail` 默认 10 行
237
- return { base: parts.slice(0, -1).map((s) => s.trim()).join(" | "), kind, lines, segment: last };
238
+ return {
239
+ base: parts
240
+ .slice(0, -1)
241
+ .map((s) => s.trim())
242
+ .join(" | "),
243
+ kind,
244
+ lines,
245
+ segment: last,
246
+ };
238
247
  }
239
248
  // less / more / cat:纯透传,拆掉只为修正退出码 + 避免交互分页器在 PTY 里挂起。
240
- return { base: parts.slice(0, -1).map((s) => s.trim()).join(" | "), kind, lines: null, segment: last };
249
+ return {
250
+ base: parts
251
+ .slice(0, -1)
252
+ .map((s) => s.trim())
253
+ .join(" | "),
254
+ kind,
255
+ lines: null,
256
+ segment: last,
257
+ };
241
258
  }
242
259
  /** 识别命令里把 stdout 重定向到文件的 `> file` / `>> file`(忽略 2>N / >&N / /dev/null)。
243
260
  * 用于 `cmd > log 2>&1 | tail -N`:拆掉 tail 后输出进文件,终端为空——补一个 tail 文件
@@ -260,7 +277,10 @@ export function queryTerminalOutput(output, q, running = false, exitCode = null)
260
277
  .split("\n")
261
278
  .filter((l) => {
262
279
  const t = l.trim();
263
- return !/^\[pi-exit:\d+\]$/.test(t) && !/^\[进程已退出/.test(t);
280
+ return (!/^\[pi-exit:\d+\]$/.test(t) &&
281
+ !t.startsWith("[进程已退出") &&
282
+ !t.startsWith("[Process exited") &&
283
+ !/^\[.*(已退出|exited)/.test(t));
264
284
  });
265
285
  while (lines.length > 0 && !lines[lines.length - 1].trim())
266
286
  lines.pop();
@@ -529,11 +549,29 @@ function launchdSpawnedOnMac() {
529
549
  */
530
550
  export function encodeTerminalKey(key, modifiers = {}) {
531
551
  const named = {
532
- Enter: "\r", Return: "\r", Tab: "\t", Backspace: "\x7f", Escape: "\x1b",
533
- Up: "\x1b[A", ArrowUp: "\x1b[A", Down: "\x1b[B", ArrowDown: "\x1b[B",
534
- Left: "\x1b[D", ArrowLeft: "\x1b[D", Right: "\x1b[C", ArrowRight: "\x1b[C",
535
- Home: "\x1b[H", End: "\x1b[F", Delete: "\x1b[3~", Insert: "\x1b[2~",
536
- PageUp: "\x1b[5~", PageDown: "\x1b[6~", F1: "\x1bOP", F2: "\x1bOQ", F3: "\x1bOR", F4: "\x1bOS",
552
+ Enter: "\r",
553
+ Return: "\r",
554
+ Tab: "\t",
555
+ Backspace: "\x7f",
556
+ Escape: "\x1b",
557
+ Up: "\x1b[A",
558
+ ArrowUp: "\x1b[A",
559
+ Down: "\x1b[B",
560
+ ArrowDown: "\x1b[B",
561
+ Left: "\x1b[D",
562
+ ArrowLeft: "\x1b[D",
563
+ Right: "\x1b[C",
564
+ ArrowRight: "\x1b[C",
565
+ Home: "\x1b[H",
566
+ End: "\x1b[F",
567
+ Delete: "\x1b[3~",
568
+ Insert: "\x1b[2~",
569
+ PageUp: "\x1b[5~",
570
+ PageDown: "\x1b[6~",
571
+ F1: "\x1bOP",
572
+ F2: "\x1bOQ",
573
+ F3: "\x1bOR",
574
+ F4: "\x1bOS",
537
575
  };
538
576
  let data = named[key] ?? (key.length === 1 ? key : "");
539
577
  if (!data)
@@ -544,8 +582,17 @@ export function encodeTerminalKey(key, modifiers = {}) {
544
582
  const arrow = /^\x1b\[([A-DHF])$/.exec(data);
545
583
  const functionKey = /^\x1bO([P-S])$/.exec(data);
546
584
  const namedCode = {
547
- Enter: 13, Return: 13, Tab: 9, Backspace: 127, Escape: 27,
548
- Insert: 2, Delete: 3, Home: 1, End: 4, PageUp: 5, PageDown: 6,
585
+ Enter: 13,
586
+ Return: 13,
587
+ Tab: 9,
588
+ Backspace: 127,
589
+ Escape: 27,
590
+ Insert: 2,
591
+ Delete: 3,
592
+ Home: 1,
593
+ End: 4,
594
+ PageUp: 5,
595
+ PageDown: 6,
549
596
  };
550
597
  if (arrow && modifier !== 1) {
551
598
  data = `\x1b[1;${modifier}${arrow[1]}`;
@@ -602,7 +649,7 @@ export class TerminalManager {
602
649
  create(id, cwd, cols, rows, fallbackCwd, title, opts) {
603
650
  const valid = this.validateId(id);
604
651
  if (valid) {
605
- this.fail(id, valid);
652
+ this.fail(id, valid.text, valid.textEn);
606
653
  return null;
607
654
  }
608
655
  if (this.terms.has(id))
@@ -616,10 +663,10 @@ export class TerminalManager {
616
663
  this.history.delete(id);
617
664
  const safeCwd = this.safeCwd(cwd || fallbackCwd);
618
665
  if (!safeCwd) {
619
- this.fail(id, "终端工作目录必须位于当前工作区内");
666
+ this.fail(id, "终端工作目录必须位于当前工作区内", "Terminal cwd must be inside the current workspace");
620
667
  return null;
621
668
  }
622
- if (this.spawnShell(id, safeCwd, cols, rows, title || `终端 ${++this.seq}`, undefined, opts?.forceBash, opts?.agentBash)) {
669
+ if (this.spawnShell(id, safeCwd, cols, rows, title || `终端 ${++this.seq}`, undefined, opts?.forceBash, opts?.agentBash, opts?.locale)) {
623
670
  this.maybeEmitTccHint(id);
624
671
  this.emitList();
625
672
  return this.info(this.terms.get(id));
@@ -640,10 +687,10 @@ export class TerminalManager {
640
687
  * running process is killed and a fresh shell runs the command again in the
641
688
  * same terminal (used when re-running a command by clicking its entry).
642
689
  */
643
- runCommand(id, def, cols, rows, pwd) {
690
+ runCommand(id, def, cols, rows, pwd, locale) {
644
691
  const invalidId = this.validateId(id);
645
692
  if (invalidId) {
646
- this.fail(id, invalidId);
693
+ this.fail(id, invalidId.text, invalidId.textEn);
647
694
  return;
648
695
  }
649
696
  const existing = this.terms.get(id);
@@ -653,13 +700,12 @@ export class TerminalManager {
653
700
  // the cap could push the live count past MAX_TERMINALS.
654
701
  if (!existing && !this.ensureSpawnAllowed(id))
655
702
  return;
656
- const hasHistory = this.history.has(id);
657
703
  const rawDir = resolveCommandCwd(def.cwd, pwd);
658
704
  const dir = this.safeCwd(rawDir);
659
705
  const command = expandPwd(def.command.trim(), pwd);
660
706
  const title = def.name || command || `终端 ${++this.seq}`;
661
707
  if (!dir) {
662
- this.fail(id, "终端工作目录必须位于当前工作区内");
708
+ this.fail(id, "终端工作目录必须位于当前工作区内", "Terminal cwd must be inside the current workspace");
663
709
  return;
664
710
  }
665
711
  if (existing) {
@@ -681,14 +727,14 @@ export class TerminalManager {
681
727
  this.terms.delete(id);
682
728
  }
683
729
  this.history.delete(id);
684
- const ok = this.spawnShell(id, dir, cols, rows, title, def);
730
+ const keepLocale = locale ?? existing?.locale;
731
+ const ok = this.spawnShell(id, dir, cols, rows, title, def, undefined, false, keepLocale);
685
732
  if (!ok)
686
733
  return;
687
734
  this.emitList();
688
735
  // Clear the previous run's output, then show a banner and run the command
689
736
  // (the PTY input buffer holds it until the shell is ready).
690
- const banner = "\x1b[2J\x1b[3J\x1b[H" +
691
- `\x1b[90m> ${command}\x1b[0m \x1b[90m(${dir})\x1b[0m\r\n`;
737
+ const banner = "\x1b[2J\x1b[3J\x1b[H" + `\x1b[90m> ${command}\x1b[0m \x1b[90m(${dir})\x1b[0m\r\n`;
692
738
  const fresh = this.terms.get(id);
693
739
  if (fresh)
694
740
  this.appendOutput(fresh, banner);
@@ -698,7 +744,7 @@ export class TerminalManager {
698
744
  this.input(id, command + "\r");
699
745
  }
700
746
  /** Spawn the user's shell as a PTY. Returns false when the spawn failed. */
701
- spawnShell(id, cwd, cols, rows, title, command, forceBash, agentBash = false) {
747
+ spawnShell(id, cwd, cols, rows, title, command, forceBash, agentBash = false, locale) {
702
748
  let abs = cwd;
703
749
  if (!abs)
704
750
  abs = homedir();
@@ -706,12 +752,12 @@ export class TerminalManager {
706
752
  abs = resolve(abs);
707
753
  try {
708
754
  if (!existsSync(abs) || !statSync(abs).isDirectory()) {
709
- this.fail(id, `目录不存在或不是目录:${abs}`);
755
+ this.fail(id, `目录不存在或不是目录:${abs}`, `Directory does not exist or is not a directory: ${abs}`);
710
756
  return false;
711
757
  }
712
758
  }
713
759
  catch {
714
- this.fail(id, `无法访问终端目录:${abs}`);
760
+ this.fail(id, `无法访问终端目录:${abs}`, `Cannot access terminal directory: ${abs}`);
715
761
  return false;
716
762
  }
717
763
  // node-pty's spawn-helper may have lost its +x bit since the last repair
@@ -732,7 +778,9 @@ export class TerminalManager {
732
778
  const helper = brokenSpawnHelper();
733
779
  this.fail(id, helper
734
780
  ? `启动终端失败:${err.message}(node-pty 的 spawn-helper 缺少执行权限,请运行:chmod +x "${helper}")`
735
- : `启动终端失败:${err.message}`);
781
+ : `启动终端失败:${err.message}`, helper
782
+ ? `Failed to start terminal: ${err.message} (node-pty spawn-helper is not executable, run: chmod +x "${helper}")`
783
+ : `Failed to start terminal: ${err.message}`);
736
784
  return false;
737
785
  }
738
786
  const entry = {
@@ -755,6 +803,7 @@ export class TerminalManager {
755
803
  idleTimer: null,
756
804
  watches: [],
757
805
  agentBash,
806
+ locale,
758
807
  };
759
808
  this.terms.set(id, entry);
760
809
  // The closures capture `entry`: after a restart the map points at the
@@ -881,7 +930,10 @@ export class TerminalManager {
881
930
  }
882
931
  validateId(id) {
883
932
  if (!id || id.length > MAX_ID || !/^[A-Za-z0-9._:-]+$/.test(id)) {
884
- return "终端名称无效:只能使用字母、数字、.-、_ 或 :(最长 80 字符)";
933
+ return {
934
+ text: "终端名称无效:只能使用字母、数字、.-、_ 或 :(最长 80 字符)",
935
+ textEn: "Invalid terminal name: use letters, digits, .-_ or : (max 80 chars)",
936
+ };
885
937
  }
886
938
  return null;
887
939
  }
@@ -901,7 +953,7 @@ export class TerminalManager {
901
953
  return true;
902
954
  const liveUser = [...this.terms.values()].filter((t) => !t.agentBash).length;
903
955
  if (liveUser >= MAX_TERMINALS) {
904
- this.fail(id, `终端数量已达上限(${MAX_TERMINALS})`);
956
+ this.fail(id, `终端数量已达上限(${MAX_TERMINALS})`, `Terminal limit reached (${MAX_TERMINALS})`);
905
957
  return false;
906
958
  }
907
959
  return true;
@@ -958,7 +1010,12 @@ export class TerminalManager {
958
1010
  return null;
959
1011
  const start = Math.max(entry.outputOffset, Math.min(cursor, entry.outputOffset + entry.output.length));
960
1012
  const end = Math.min(start + Math.max(1, Math.floor(maxBytes) || 20_000), entry.outputOffset + entry.output.length);
961
- return { data: entry.output.slice(start - entry.outputOffset, end - entry.outputOffset), cursor: end, running: !entry.exited, exitCode: entry.exitCode };
1013
+ return {
1014
+ data: entry.output.slice(start - entry.outputOffset, end - entry.outputOffset),
1015
+ cursor: end,
1016
+ running: !entry.exited,
1017
+ exitCode: entry.exitCode,
1018
+ };
962
1019
  }
963
1020
  async waitForOutput(id, cursor, timeoutMs, signal) {
964
1021
  const current = this.read(id, cursor, 1);
@@ -983,7 +1040,7 @@ export class TerminalManager {
983
1040
  }
984
1041
  inputChecked(id, data) {
985
1042
  if (data.length > MAX_INPUT)
986
- return `输入过长(上限 ${MAX_INPUT} 字符)`;
1043
+ return `输入过长(上限 ${MAX_INPUT} 字符) Input too long (max ${MAX_INPUT} chars)`;
987
1044
  const entry = this.terms.get(id);
988
1045
  if (!entry || entry.exited)
989
1046
  return "终端不存在或进程已退出";
@@ -1102,8 +1159,8 @@ export class TerminalManager {
1102
1159
  };
1103
1160
  }
1104
1161
  /** Emit a terminal failure (bad cwd, spawn error) and mark the terminal dead. */
1105
- fail(id, text) {
1106
- this.emit({ type: "notice", level: "error", text });
1162
+ fail(id, text, textEn) {
1163
+ this.emit({ type: "notice", level: "error", text, textEn });
1107
1164
  this.emit({
1108
1165
  type: "terminal_output",
1109
1166
  terminalId: id,
@@ -1118,7 +1175,9 @@ export class TerminalManager {
1118
1175
  this.disarmIdleWatch(entry);
1119
1176
  // Flush queued output BEFORE the exit banner so ordering is preserved.
1120
1177
  this.flushPending(entry);
1121
- const banner = `\r\n\x1b[90m[进程已退出,退出码 ${exitCode}]\x1b[0m\r\n`;
1178
+ const banner = entry.locale === "en"
1179
+ ? `\r\n\x1b[90m[Process exited with code ${exitCode}]\x1b[0m\r\n`
1180
+ : `\r\n\x1b[90m[进程已退出,退出码 ${exitCode}]\x1b[0m\r\n`;
1122
1181
  this.appendOutput(entry, banner);
1123
1182
  this.emit({ type: "terminal_output", terminalId: id, data: banner });
1124
1183
  entry.exited = true;
@@ -1358,11 +1417,13 @@ export function makeTerminalBashTool(terminals, opts) {
1358
1417
  // `cmd > log 2>&1 | tail -N`:拆掉 tail 后 stdout 进文件、终端为空 → 补一个
1359
1418
  // tail 文件让模型看到日志尾部与真实退出码(否则输出为空)。
1360
1419
  const redirect = stripped && limiter.kind === "tail" ? detectStdoutRedirect(runCommand) : null;
1361
- const tailFile = redirect
1362
- ? { file: redirect.file, lines: limiter.lines ?? 10 }
1363
- : undefined;
1420
+ const tailFile = redirect ? { file: redirect.file, lines: limiter.lines ?? 10 } : undefined;
1364
1421
  const limiterNote = stripped
1365
- ? "\n[注:检测到你带了「" + limiter.segment + "」这类限输出/过滤管道——已在终端里直跑底层命令(实时可见 + 真实退出码),只按参数返回片段。" + (limiter.kind === "tail" ? "本次返回末尾 " + (limiter.lines ?? 10) + " 行。" : "本次返回全部输出。") + " 后续直接用 bash(command, tail=N) 参数限输出。]"
1422
+ ? "\n[注:检测到你带了「" +
1423
+ limiter.segment +
1424
+ "」这类限输出/过滤管道——已在终端里直跑底层命令(实时可见 + 真实退出码),只按参数返回片段。" +
1425
+ (limiter.kind === "tail" ? "本次返回末尾 " + (limiter.lines ?? 10) + " 行。" : "本次返回全部输出。") +
1426
+ " 后续直接用 bash(command, tail=N) 参数限输出。]"
1366
1427
  : "";
1367
1428
  try {
1368
1429
  let collected = "";
@@ -1460,10 +1521,10 @@ export const TERMINAL_TOOL_NAMES = [
1460
1521
  /** System-prompt guidance teaching the model WHEN to prefer the terminal tools
1461
1522
  * over one-shot bash. Without it models almost never pick them — bash returns
1462
1523
  * complete output in a single call, so it always wins on convenience. */
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.
1524
+ 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:
1525
+ - 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'.
1526
+ - 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).
1527
+ - The user explicitly asks you to work in the visible terminal panel.
1467
1528
  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.`;
1468
1529
  /** Build the agent-facing persistent terminal tools for one conversation. */
1469
1530
  export function makePersistentTerminalTools(terminals, cwd) {
@@ -19,19 +19,7 @@ const PREVIEW_IMAGE_EXTS = new Set([
19
19
  "tif",
20
20
  "tiff",
21
21
  ]);
22
- const PREVIEW_VIDEO_EXTS = new Set([
23
- "mp4",
24
- "webm",
25
- "mov",
26
- "mkv",
27
- "avi",
28
- "m4v",
29
- "ogv",
30
- "mpg",
31
- "mpeg",
32
- "wmv",
33
- "flv",
34
- ]);
22
+ const PREVIEW_VIDEO_EXTS = new Set(["mp4", "webm", "mov", "mkv", "avi", "m4v", "ogv", "mpg", "mpeg", "wmv", "flv"]);
35
23
  const PREVIEW_TEXT_EXTS = new Set([
36
24
  // code
37
25
  "ts",
@@ -204,11 +192,7 @@ export function decodeText(buf) {
204
192
  * Returns null when the bytes don't look like a known raster format —
205
193
  * callers keep such files as plain path references. */
206
194
  export function sniffImageMime(buf, ext) {
207
- if (buf.length >= 8 &&
208
- buf[0] === 0x89 &&
209
- buf[1] === 0x50 &&
210
- buf[2] === 0x4e &&
211
- buf[3] === 0x47) {
195
+ if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
212
196
  return "image/png";
213
197
  }
214
198
  if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
@@ -242,12 +226,8 @@ export function hexDump(buf, maxBytes = 4096) {
242
226
  const rows = [];
243
227
  for (let off = 0; off < data.length; off += 16) {
244
228
  const chunk = data.subarray(off, off + 16);
245
- const hex = [...chunk]
246
- .map((b) => b.toString(16).padStart(2, "0"))
247
- .join(" ");
248
- const ascii = [...chunk]
249
- .map((b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : "."))
250
- .join("");
229
+ const hex = [...chunk].map((b) => b.toString(16).padStart(2, "0")).join(" ");
230
+ const ascii = [...chunk].map((b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : ".")).join("");
251
231
  rows.push(`${off.toString(16).padStart(8, "0")} ${hex.padEnd(47, " ")} ${ascii}`);
252
232
  }
253
233
  return rows.join("\n");
@@ -203,9 +203,7 @@ function readVendoredPiCore(agentDir) {
203
203
  * two rows for the same package.
204
204
  */
205
205
  export function collectTargets(agentDir, webuiVersion, probePiCore = defaultProbePiCore) {
206
- const targets = [
207
- { name: "pi-web-ui", version: webuiVersion, kind: "webui" },
208
- ];
206
+ const targets = [{ name: "pi-web-ui", version: webuiVersion, kind: "webui" }];
209
207
  const coreVersion = probePiCore() ?? readVendoredPiCore(agentDir);
210
208
  if (coreVersion) {
211
209
  targets.push({
@@ -239,7 +237,7 @@ export async function fetchLatest(fetcher, name) {
239
237
  * input order. Bounded concurrency (CONCURRENCY) keeps registry load polite.
240
238
  */
241
239
  export async function checkAll(targets, fetcher = defaultFetcher) {
242
- const results = new Array(targets.length);
240
+ const results = Array.from({ length: targets.length });
243
241
  let cursor = 0;
244
242
  async function worker() {
245
243
  while (cursor < targets.length) {
@@ -27,9 +27,7 @@ export function uploadRetentionDays() {
27
27
  export function saveUpload(clientId, name, buf, dataDir = resolveDataDir()) {
28
28
  const dir = join(uploadsRoot(dataDir), clientId);
29
29
  mkdirSync(dir, { recursive: true });
30
- const displayName = name
31
- .replace(/[\\/:*?"<>|\x00-\x1f]/g, "_")
32
- .slice(0, 80) || "file";
30
+ const displayName = name.replace(/[\\/:*?"<>|\x00-\x1f]/g, "_").slice(0, 80) || "file";
33
31
  const abs = join(dir, `${Date.now()}-${displayName}`);
34
32
  writeFileSync(abs, buf);
35
33
  return { abs, displayName };
@@ -90,9 +90,7 @@ export async function transcribeImages(runtime, images, options = {}) {
90
90
  const imageBlocks = images.map((img) => ({
91
91
  type: "image",
92
92
  data: img.data.replace(/^data:[^;]*;base64,/, ""),
93
- mimeType: img.mimeType?.startsWith("image/")
94
- ? img.mimeType
95
- : "image/png",
93
+ mimeType: img.mimeType?.startsWith("image/") ? img.mimeType : "image/png",
96
94
  }));
97
95
  const context = {
98
96
  systemPrompt: options.systemPrompt ?? SYSTEM_PROMPT,
@@ -100,10 +98,7 @@ export async function transcribeImages(runtime, images, options = {}) {
100
98
  {
101
99
  role: "user",
102
100
  timestamp: Date.now(),
103
- content: [
104
- ...imageBlocks,
105
- { type: "text", text: buildUserPrompt(images.length) },
106
- ],
101
+ content: [...imageBlocks, { type: "text", text: buildUserPrompt(images.length) }],
107
102
  },
108
103
  ],
109
104
  };