pi-web-ui 0.97.0 → 0.99.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 (80) hide show
  1. package/CHANGELOG.md +126 -1
  2. package/README.md +3 -3
  3. package/README.zh-CN.md +3 -3
  4. package/bin/pi-web-ui.mjs +64 -23
  5. package/dist/server/agent-service.js +645 -147
  6. package/dist/server/approval-rules.js +5 -10
  7. package/dist/server/atomic-file.js +40 -0
  8. package/dist/server/attachments.js +22 -0
  9. package/dist/server/claim-store.js +5 -11
  10. package/dist/server/client-state.js +220 -23
  11. package/dist/server/compact-context-tool.js +3 -1
  12. package/dist/server/compacted-history.js +7 -5
  13. package/dist/server/composer-drafts.js +4 -6
  14. package/dist/server/control-socket.js +0 -40
  15. package/dist/server/delegate-mode.js +6 -12
  16. package/dist/server/dsh/dsh-agent-service.js +32 -30
  17. package/dist/server/dsh/dsh-client.js +45 -9
  18. package/dist/server/dsh/dsh-serialize.js +12 -10
  19. package/dist/server/dsh/dsh-sessions.js +20 -6
  20. package/dist/server/dsh/runtime/goal-rpc.mjs +14 -1
  21. package/dist/server/dsh/runtime/runtime-root.mjs +1 -1
  22. package/dist/server/ensure-bash.js +3 -5
  23. package/dist/server/eval-tool.js +30 -5
  24. package/dist/server/goal-review-gate.js +7 -20
  25. package/dist/server/goal-service.js +77 -10
  26. package/dist/server/index.js +83 -15
  27. package/dist/server/launch-origin.js +0 -4
  28. package/dist/server/locales.js +3 -5
  29. package/dist/server/lsp-tool.js +110 -14
  30. package/dist/server/markers/builtins/notify.js +4 -2
  31. package/dist/server/markers/builtins/rename.js +0 -1
  32. package/dist/server/markers/builtins/todo.js +6 -26
  33. package/dist/server/markers/marker.js +0 -15
  34. package/dist/server/markers/registry.js +0 -3
  35. package/dist/server/markers/store.js +0 -3
  36. package/dist/server/mcp-hot-reload.js +12 -5
  37. package/dist/server/model-admin.js +19 -18
  38. package/dist/server/model-enrich.js +2 -3
  39. package/dist/server/patch-tool.js +78 -28
  40. package/dist/server/permission-preset.js +25 -0
  41. package/dist/server/plan-manager.js +196 -10
  42. package/dist/server/plan-mode.js +58 -20
  43. package/dist/server/plugin-api-catalog.js +2 -0
  44. package/dist/server/plugin-catalog.js +6 -6
  45. package/dist/server/plugin-dom.js +3 -4
  46. package/dist/server/plugin-facilities.js +3 -5
  47. package/dist/server/plugin-grants.js +5 -8
  48. package/dist/server/plugin-permissions.js +4 -6
  49. package/dist/server/plugin-schedule.js +4 -7
  50. package/dist/server/plugin-updater.js +151 -38
  51. package/dist/server/plugins.js +16 -2
  52. package/dist/server/prompt-composer.js +12 -3
  53. package/dist/server/protocol-version.js +1 -1
  54. package/dist/server/resolve-global-sdk.js +8 -0
  55. package/dist/server/schedule-agent-tool.js +5 -1
  56. package/dist/server/scheduler-tasks.js +46 -27
  57. package/dist/server/sdk-origin.js +27 -0
  58. package/dist/server/serialize.js +22 -9
  59. package/dist/server/slash-commands.js +16 -0
  60. package/dist/server/subagent-templates.js +4 -10
  61. package/dist/server/subagents.js +22 -11
  62. package/dist/server/terminals.js +24 -12
  63. package/dist/server/text-quote.js +59 -0
  64. package/dist/server/tool-manager.js +12 -3
  65. package/dist/server/tool-overrides.js +24 -0
  66. package/dist/server/update-check.js +69 -40
  67. package/extensions/webui.ts +15 -1
  68. package/package.json +31 -19
  69. package/plugin-sdk/client-utils.mjs +92 -0
  70. package/plugin-sdk/index.d.ts +21 -0
  71. package/plugin-sdk/index.mjs +2 -0
  72. package/plugins/catalog.json +7 -0
  73. package/web/dist/assets/{TerminalPanel-CITBqQrb.js → TerminalPanel-NerrJlTS.js} +1 -1
  74. package/web/dist/assets/index-BdMaGCcs.css +1 -0
  75. package/web/dist/assets/index-ByCBN4Kt.js +368 -0
  76. package/web/dist/index.html +2 -2
  77. package/web/dist/sw.js +2 -1
  78. package/web/public/sw.js +2 -1
  79. package/web/dist/assets/index-DwToli0J.css +0 -1
  80. package/web/dist/assets/index-oI7UKOqJ.js +0 -368
@@ -55,9 +55,6 @@ export function isReadonlyPromptSource(token) {
55
55
  * SDK 默认拼装的完整提示词。 */
56
56
  export const DEFAULT_PROMPT_TEMPLATE = PROMPT_TOKENS.map((t) => `{{${t}}}`).join("\n\n");
57
57
  const TOKEN_RE = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
58
- export function isKnownToken(name) {
59
- return PROMPT_TOKENS.includes(name);
60
- }
61
58
  /** 模板里出现的全部 token(含未知名,供 UI 提示)。 */
62
59
  export function collectTemplateTokens(template) {
63
60
  const out = [];
@@ -258,3 +255,15 @@ export function estimatePromptTokens(text) {
258
255
  const cjk = text.match(CJK_RE)?.length ?? 0;
259
256
  return Math.ceil(cjk + (text.length - cjk) / 4);
260
257
  }
258
+ /** 把「早前扩展改过的提示词」拆成 SDK 原始提示词 + 扩展的首尾增补。
259
+ * before_agent_start 是链式的:我们是最后一个 handler,返回 systemPrompt 等于
260
+ * forceSystemPrompt 整体替换,会把别的扩展(如注入 <invoked_skill> 的)写进去的
261
+ * 内容一起丢掉。只要原始提示词还原样嵌在里面(纯前置/后置增补),就能把增补摘出来
262
+ * 重新套回我们的渲染结果上。摘不出来(中间插入 / 被整体替换)时返回 pre/post 为空、
263
+ * core 为当前文本 —— 调用方退回今天的行为,绝不把整段提示词翻倍。 */
264
+ export function splitExtensionWrap(baseline, current) {
265
+ const at = baseline ? current.indexOf(baseline) : -1;
266
+ if (at === -1)
267
+ return { pre: "", core: current, post: "" };
268
+ return { pre: current.slice(0, at), core: baseline, post: current.slice(at + baseline.length) };
269
+ }
@@ -8,4 +8,4 @@
8
8
  * its own copy in web/src/protocol-version.ts; scripts/check-protocol-sync.mjs
9
9
  * verifies the two never drift.
10
10
  */
11
- export const PROTOCOL_VERSION = 21;
11
+ export const PROTOCOL_VERSION = 22;
@@ -26,6 +26,7 @@
26
26
  * 或前台启动时在 import 服务入口之前先 import 它)。
27
27
  */
28
28
  import { registerHooks } from "node:module";
29
+ import { dirname } from "node:path";
29
30
  import { pathToFileURL } from "node:url";
30
31
  import { compareVersions, sdkCopies } from "./sdk-origin.js";
31
32
  const PKG = "@earendil-works/pi-coding-agent";
@@ -40,6 +41,13 @@ export function pickGlobalSdk(copies, mode) {
40
41
  if (BUNDLED_MODES.has((mode ?? "global").trim().toLowerCase()))
41
42
  return null;
42
43
  const bundled = copies[0];
44
+ // 显式透传的宿主 SDK 路径(如 extensions/webui.ts 宿主传递,issue #482):若不降级优先选用
45
+ if (process.env.PI_WEB_SDK_DIR) {
46
+ const hostCopy = copies.find((c) => dirname(c.path) === process.env.PI_WEB_SDK_DIR);
47
+ if (hostCopy && (!bundled || compareVersions(hostCopy.version, bundled.version) >= 0)) {
48
+ return hostCopy;
49
+ }
50
+ }
43
51
  // 只考虑祖先链上**严格更新**的副本,多份取版本最高的(旧的/同版本的都不折腾)。
44
52
  let best = null;
45
53
  for (const copy of copies.slice(1)) {
@@ -20,7 +20,7 @@ import { defineTool } from "@earendil-works/pi-coding-agent";
20
20
  import { Type } from "typebox";
21
21
  import { pick } from "./i18n.js";
22
22
  import { SCHEDULE_CANCEL_TOOL_NAME, SCHEDULE_LIST_TOOL_NAME, SCHEDULE_TASK_TOOL_NAME } from "./tool-manager.js";
23
- import { computeNextFire, describeIntervalMs, SCHEDULER_MIN_INTERVAL_MS, } from "./scheduler-tasks.js";
23
+ import { computeNextFire, describeIntervalMs, SCHEDULER_MIN_INTERVAL_MS, SchedulerValidationError, } from "./scheduler-tasks.js";
24
24
  import { parseCronSpec } from "./plugin-schedule.js";
25
25
  /** 用户写法 → 调度规格。纯函数,可单测。
26
26
  * 收:5 字段 cron("0 * * * *")、相对时间("in 30m" / "in 1h" / "in 2d")、
@@ -180,6 +180,10 @@ export function makeScheduleTools(host, ownerConversationId, lang) {
180
180
  });
181
181
  }
182
182
  catch (err) {
183
+ const L = getLang();
184
+ if (err instanceof SchedulerValidationError) {
185
+ return text(L === "zh" ? err.messageZh : err.messageEn);
186
+ }
183
187
  return text(String(err?.message ?? err));
184
188
  }
185
189
  const L = getLang();
@@ -10,9 +10,10 @@
10
10
  * 定时策略:单个 10s ticker 检查到期任务(cron 下次触发 / 间隔毫秒),
11
11
  * running 集合防重叠;catchUp "once" 在启动时补跑一次漏掉的触发。
12
12
  */
13
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
13
+ import { readFileSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
  import { nextCronFire, parseCronSpec } from "./plugin-schedule.js";
16
+ import { writeJsonAtomicSync } from "./atomic-file.js";
16
17
  export const SCHEDULER_FILE = "scheduler-tasks.json";
17
18
  /** 持久间隔底线 60s(与 host.schedule 持久任务同口径,防 token 烧穿)。 */
18
19
  export const SCHEDULER_MIN_INTERVAL_MS = 60_000;
@@ -30,50 +31,72 @@ function randomTaskId() {
30
31
  s += abc[Math.floor(Math.random() * abc.length)];
31
32
  return `task-${s}`;
32
33
  }
33
- /** 归一化 + 校验用户输入;非法抛 Error(中文信息,直接面向 UI)。纯函数,可单测。 */
34
+ export class SchedulerValidationError extends Error {
35
+ code;
36
+ messageZh;
37
+ messageEn;
38
+ constructor(code, messageZh, messageEn) {
39
+ super(messageZh);
40
+ this.code = code;
41
+ this.messageZh = messageZh;
42
+ this.messageEn = messageEn;
43
+ this.name = "SchedulerValidationError";
44
+ }
45
+ }
46
+ /** 归一化 + 校验用户输入;非法抛 SchedulerValidationError。纯函数,可单测。 */
34
47
  export function normalizeSchedulerInput(input, now = Date.now()) {
35
48
  const idRaw = String(input.id ?? "").trim();
36
49
  const id = idRaw || randomTaskId();
37
- if (!SCHEDULER_ID_RE.test(id))
38
- throw new Error("任务 id 非法(只收字母/数字/下划线/连字符,≤64 字)");
50
+ if (!SCHEDULER_ID_RE.test(id)) {
51
+ throw new SchedulerValidationError("invalid_id", "任务 id 非法(只收字母/数字/下划线/连字符,≤64 字)", "Invalid task id (letters/numbers/underscores/hyphens only, <= 64 chars)");
52
+ }
39
53
  const name = String(input.name ?? "")
40
54
  .trim()
41
55
  .slice(0, 80);
42
- if (!name)
43
- throw new Error("任务名称不能为空");
56
+ if (!name) {
57
+ throw new SchedulerValidationError("empty_name", "任务名称不能为空", "Task name cannot be empty");
58
+ }
44
59
  const description = String(input.description ?? "")
45
60
  .trim()
46
61
  .slice(0, 500);
47
62
  const cwd = String(input.cwd ?? "").trim();
48
- if (!cwd)
49
- throw new Error("执行目标项目(cwd)不能为空");
63
+ if (!cwd) {
64
+ throw new SchedulerValidationError("empty_cwd", "执行目标项目(cwd)不能为空", "Target project directory (cwd) cannot be empty");
65
+ }
50
66
  const kind = input.kind === "interval" ? "interval" : "cron";
51
67
  let spec;
52
68
  if (kind === "cron") {
53
69
  spec = String(input.spec ?? "")
54
70
  .trim()
55
71
  .replace(/\s+/g, " ");
56
- if (!parseCronSpec(spec))
57
- throw new Error("cron 表达式非法(要 5 字段:分 时 日 月 周,如 0 9 * * *)");
72
+ if (!parseCronSpec(spec)) {
73
+ throw new SchedulerValidationError("invalid_cron", "cron 表达式非法(要 5 字段:分 时 日 月 周,如 0 9 * * *)", "Invalid cron expression (requires 5 fields: min hour day month weekday, e.g. 0 9 * * *)");
74
+ }
58
75
  }
59
76
  else {
60
77
  const ms = Math.floor(Number(input.spec));
61
- if (!Number.isFinite(ms) || ms <= 0)
62
- throw new Error("间隔毫秒数非法");
63
- if (ms < SCHEDULER_MIN_INTERVAL_MS)
64
- throw new Error(`间隔太短(最短 ${SCHEDULER_MIN_INTERVAL_MS / 1000}s,防 token 烧穿)`);
65
- if (ms > SCHEDULER_MAX_INTERVAL_MS)
66
- throw new Error("间隔太长(最长 30 天)");
78
+ if (!Number.isFinite(ms) || ms <= 0) {
79
+ throw new SchedulerValidationError("invalid_interval", "间隔毫秒数非法", "Invalid interval milliseconds");
80
+ }
81
+ if (ms < SCHEDULER_MIN_INTERVAL_MS) {
82
+ throw new SchedulerValidationError("interval_too_short", `间隔太短(最短 ${SCHEDULER_MIN_INTERVAL_MS / 1000}s,防 token 烧穿)`, `Interval too short (minimum ${SCHEDULER_MIN_INTERVAL_MS / 1000}s)`);
83
+ }
84
+ if (ms > SCHEDULER_MAX_INTERVAL_MS) {
85
+ throw new SchedulerValidationError("interval_too_long", "间隔太长(最长 30 天)", "Interval too long (maximum 30 days)");
86
+ }
67
87
  spec = String(ms);
68
88
  }
69
89
  const prompt = String(input.prompt ?? "").trim();
70
- if (!prompt)
71
- throw new Error("触发指令(prompt)不能为空");
72
- if (prompt.length > 8000)
73
- throw new Error("触发指令超长(>8000 字),请裁剪后重试");
90
+ if (!prompt) {
91
+ throw new SchedulerValidationError("empty_prompt", "触发指令(prompt)不能为空", "Trigger instruction (prompt) cannot be empty");
92
+ }
93
+ if (prompt.length > 8000) {
94
+ throw new SchedulerValidationError("prompt_too_long", "触发指令超长(>8000 字),请裁剪后重试", "Trigger instruction too long (>8000 characters), please shorten and retry");
95
+ }
74
96
  const model = String(input.model ?? "").trim();
75
- if (model && !model.includes("/"))
76
- throw new Error("模型格式非法(应为 provider/id)");
97
+ if (model && !model.includes("/")) {
98
+ throw new SchedulerValidationError("invalid_model", "模型格式非法(应为 provider/id)", "Invalid model format (expected provider/id)");
99
+ }
77
100
  const thinkingLevel = String(input.thinkingLevel ?? "").trim();
78
101
  const catchUp = input.catchUp === "once" ? "once" : "skip";
79
102
  const conversationId = String(input.conversationId ?? "")
@@ -267,14 +290,10 @@ export class SchedulerStore {
267
290
  }
268
291
  save() {
269
292
  try {
270
- mkdirSync(this.dataDir, { recursive: true });
271
- const file = schedulerFile(this.dataDir);
272
- const tmp = `${file}.tmp-${process.pid}`;
273
293
  const tasks = {};
274
294
  for (const [id, t] of this.tasks)
275
295
  tasks[id] = t;
276
- writeFileSync(tmp, JSON.stringify({ v: 1, tasks }));
277
- renameSync(tmp, file);
296
+ writeJsonAtomicSync(schedulerFile(this.dataDir), { v: 1, tasks });
278
297
  }
279
298
  catch (err) {
280
299
  console.error("[scheduler] persist failed:", err);
@@ -37,6 +37,33 @@ export function sdkCopies(fromFile = import.meta.url) {
37
37
  break;
38
38
  dir = parent;
39
39
  }
40
+ // 显式透传的宿主 SDK 路径(如 extensions/webui.ts 宿主传递,issue #482)
41
+ if (process.env.PI_WEB_SDK_DIR) {
42
+ const pj = join(process.env.PI_WEB_SDK_DIR, "package.json");
43
+ if (existsSync(pj) && !out.some((c) => c.path === pj)) {
44
+ try {
45
+ const version = JSON.parse(readFileSync(pj, "utf8")).version;
46
+ if (typeof version === "string" && version) {
47
+ out.push({ path: pj, version });
48
+ }
49
+ }
50
+ catch { }
51
+ }
52
+ }
53
+ // 宿主 Pi Node 路径探测(~/.local/share/pi-node/current/...,issue #482)
54
+ const home = process.env.HOME || process.env.USERPROFILE;
55
+ if (home) {
56
+ const hostPj = join(home, ".local", "share", "pi-node", "current", "lib", "node_modules", PKG, "package.json");
57
+ if (existsSync(hostPj) && !out.some((c) => c.path === hostPj)) {
58
+ try {
59
+ const version = JSON.parse(readFileSync(hostPj, "utf8")).version;
60
+ if (typeof version === "string" && version) {
61
+ out.push({ path: hostPj, version });
62
+ }
63
+ }
64
+ catch { }
65
+ }
66
+ }
40
67
  return out;
41
68
  }
42
69
  /** 点分版本号比较(够用即可:只比数字段,缺位当 0)。 */
@@ -1,6 +1,7 @@
1
- const TEXT_CAP = 200_000;
2
- const TOOL_OUTPUT_CAP = 100_000;
3
- const ARGS_CAP = 20_000;
1
+ import { splitQuotedPrompt } from "./text-quote.js";
2
+ export const TEXT_CAP = 200_000;
3
+ export const TOOL_OUTPUT_CAP = 100_000;
4
+ export const ARGS_CAP = 20_000;
4
5
  /**
5
6
  * toolResult 里单张图片进快照的上限(dataUrl 字符数 ≈ 1.5MB 二进制)。
6
7
  * 视口/元素截图随便过;超大整页截图回落成占位文本(模型侧不受影响 —— 图它
@@ -17,10 +18,11 @@ const TOOL_RESULT_IMAGE_MAX = 8;
17
18
  * 前端还得写容错);工具作者应自己封顶(见 present-files-tool.ts 的摘录预算)。
18
19
  */
19
20
  const TOOL_DETAILS_CAP = 64_000;
20
- function truncate(s, cap) {
21
- if (s.length <= cap)
22
- return { text: s, truncated: false };
23
- return { text: `${s.slice(0, cap)}\n\n… [truncated]`, truncated: true };
21
+ export function truncate(s, cap) {
22
+ const str = typeof s === "string" ? s : String(s ?? "");
23
+ if (str.length <= cap)
24
+ return { text: str, truncated: false };
25
+ return { text: `${str.slice(0, cap)}\n\n… [truncated]`, truncated: true };
24
26
  }
25
27
  /**
26
28
  * SDK/工具的图片块 → 前端可直接 <img> 的 UiImageBlock。
@@ -204,13 +206,24 @@ export function serializeMessage(m, seq) {
204
206
  }
205
207
  const id = uiMessageId(m, seq);
206
208
  switch (m.role) {
207
- case "user":
209
+ case "user": {
210
+ const quotes = [];
211
+ const raw = typeof m.content === "string" ? [{ type: "text", text: m.content }] : m.content;
212
+ const content = raw.map((block) => {
213
+ if (block.type !== "text")
214
+ return block;
215
+ const parsed = splitQuotedPrompt(block.text);
216
+ quotes.push(...parsed.quotes);
217
+ return { ...block, text: parsed.text };
218
+ });
208
219
  return {
209
220
  id,
210
221
  role: "user",
211
- content: serializeUserContent(m.content),
222
+ content: serializeUserContent(content),
223
+ ...(quotes.length ? { details: { quotes } } : {}),
212
224
  timestamp: m.timestamp,
213
225
  };
226
+ }
214
227
  case "assistant":
215
228
  return {
216
229
  id,
@@ -38,6 +38,13 @@ export const NATIVE_COMMANDS = [
38
38
  argumentHint: "<路径>",
39
39
  argumentHintEn: "<path>",
40
40
  },
41
+ {
42
+ name: "plan",
43
+ description: "开启或关闭计划模式(/plan [on|off])",
44
+ descriptionEn: "Toggle plan mode (/plan [on|off])",
45
+ argumentHint: "[on|off]",
46
+ argumentHintEn: "[on|off]",
47
+ },
41
48
  {
42
49
  name: "thinking",
43
50
  description: "设置思考强度",
@@ -249,6 +256,15 @@ export class SlashCommandsService {
249
256
  await this.host.setCwd(args);
250
257
  }
251
258
  return true;
259
+ case "plan": {
260
+ const trimmed = args.trim().toLowerCase();
261
+ const current = this.host.getPlanMode ? this.host.getPlanMode() : false;
262
+ const next = trimmed === "on" ? true : trimmed === "off" ? false : !current;
263
+ if (this.host.setPlanMode) {
264
+ await this.host.setPlanMode(next);
265
+ }
266
+ return true;
267
+ }
252
268
  case "thinking": {
253
269
  const ALIAS = {
254
270
  off: "off",
@@ -19,8 +19,8 @@
19
19
  * systemPromptEn 为英文(en 用,缺失回落中文);调用方用 pickTemplateDescription/
20
20
  * pickTemplatePrompt 按语言选用。
21
21
  */
22
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
23
- import { dirname } from "node:path";
22
+ import { readFileSync } from "node:fs";
23
+ import { writeJsonAtomicSync } from "./atomic-file.js";
24
24
  /**
25
25
  * 思考强度取值:与 SDK 的 THINKING_LEVEL_OPTIONS(前端 ModelThinking 的 THINKING_VALUES)
26
26
  * 一致。SDK 未从包根导出该常量,这里照抄一份作输入校验(写错的值一律当未配置)。
@@ -608,10 +608,7 @@ export class SubagentTemplatesStore {
608
608
  }
609
609
  persist() {
610
610
  try {
611
- mkdirSync(dirname(this.filePath), { recursive: true });
612
- const tmp = `${this.filePath}.${process.pid}.tmp`;
613
- writeFileSync(tmp, JSON.stringify(this.templates, null, 2) + "\n");
614
- renameSync(tmp, this.filePath);
611
+ writeJsonAtomicSync(this.filePath, this.templates);
615
612
  }
616
613
  catch {
617
614
  // best effort
@@ -644,10 +641,7 @@ export class SubagentTemplatesStore {
644
641
  saveSeeded(names) {
645
642
  this.seededNames = names;
646
643
  try {
647
- mkdirSync(dirname(this.seededPath()), { recursive: true });
648
- const tmp = `${this.seededPath()}.tmp`;
649
- writeFileSync(tmp, JSON.stringify([...names].sort(), null, 2));
650
- renameSync(tmp, this.seededPath());
644
+ writeJsonAtomicSync(this.seededPath(), [...names].sort());
651
645
  }
652
646
  catch {
653
647
  // best effort
@@ -31,14 +31,22 @@ import { pick } from "./i18n.js";
31
31
  /**
32
32
  * subagent_wait_all 的最长阻塞时间:必须短暂低于工具看门狗(默认 20 分钟,
33
33
  * PI_WEB_TOOL_TIMEOUT_MS 可调),否则看门狗会先中止整个会话而不是让 wait
34
- * 干净地超时返回。取 0.8×看门狗并以 60s 封顶、5s 兜底:看门狗调小(如 <75s)
35
- * 时 60s 下限会反超看门狗造成本末倒置,此时随看门狗缩短,但不低于 5s。
34
+ * 干净地超时返回。取 0.8×看门狗(默认 ~16min,支持 PI_WEB_SUBAGENT_WAIT_CAP_SECONDS 覆盖),
35
+ * 兜底不低于 5s。
36
36
  */
37
- const WAIT_CAP_MS = (() => {
38
- const v = Number(process.env.PI_WEB_TOOL_TIMEOUT_MS);
39
- const watchdog = Number.isFinite(v) && v > 0 ? v : 20 * 60_000;
40
- return Math.max(Math.min(60_000, Math.floor(watchdog * 0.8)), 5_000);
41
- })();
37
+ export function computeWaitCapMs(watchdogMs) {
38
+ const customSecs = Number(process.env.PI_WEB_SUBAGENT_WAIT_CAP_SECONDS);
39
+ if (Number.isFinite(customSecs) && customSecs > 0) {
40
+ return Math.max(Math.floor(customSecs * 1000), 5_000);
41
+ }
42
+ const envWatchdog = Number(process.env.PI_WEB_TOOL_TIMEOUT_MS);
43
+ const watchdog = typeof watchdogMs === "number" && Number.isFinite(watchdogMs) && watchdogMs > 0
44
+ ? watchdogMs
45
+ : Number.isFinite(envWatchdog) && envWatchdog > 0
46
+ ? envWatchdog
47
+ : 20 * 60_000;
48
+ return Math.max(Math.floor(watchdog * 0.8), 5_000);
49
+ }
42
50
  /** 子代理是否已到终态(运行结束、被中止或出错)。wait 工具据此判断
43
51
  * 是否可以取结果;streaming=false 即可(error/canceled 都在快照里带标记)。 */
44
52
  export function isSubagentTerminal(r) {
@@ -93,8 +101,8 @@ export function makeSubagentTool(host, lang, selfConvId) {
93
101
  details,
94
102
  });
95
103
  const currentWaitCapMs = typeof host.getWatchdogTimeoutMs === "function"
96
- ? Math.max(Math.min(60_000, Math.floor(host.getWatchdogTimeoutMs() * 0.8)), 5_000)
97
- : WAIT_CAP_MS;
104
+ ? computeWaitCapMs(host.getWatchdogTimeoutMs())
105
+ : computeWaitCapMs();
98
106
  const capSeconds = Math.floor(currentWaitCapMs / 1000);
99
107
  return defineTool({
100
108
  name: SUBAGENT_TOOL_NAME,
@@ -361,7 +369,10 @@ export function makeSubagentTool(host, lang, selfConvId) {
361
369
  }
362
370
  const allDone = Array.from(wanted).every((id) => {
363
371
  const r = host.getSubagent(id);
364
- return isSubagentTerminal(r);
372
+ // #488:getSubagent 返回 undefined = 已被移出(强行关闭/收尾清理)
373
+ // ——视为终态,与下方报告段「已移出或不存在」口径一致;否则等待
374
+ // 集合含被移出子代理时 allDone 恒 false,必然白等满 timeoutMs。
375
+ return r === undefined || isSubagentTerminal(r);
365
376
  });
366
377
  if (allDone)
367
378
  break;
@@ -534,5 +545,5 @@ function promptRemaining(remaining, lang = "en") {
534
545
  if (remaining.length === 0)
535
546
  return "";
536
547
  const pendingIds = remaining.map(shortId).join(", ");
537
- return pick(lang, `\n未完成:${pendingIds}。可再次调用 subagent_wait_all(或 subagent_steer 补充指令 / subagent_stop 中止)。`, `\nPending: ${pendingIds}. You may call subagent_wait_all again (or subagent_steer to add instructions / subagent_stop to abort).`, "subagents.wait.pending", { pendingIds: pendingIds });
548
+ return pick(lang, `\n未完成:${pendingIds}。可再次调用 subagent(action="wait_all")继续等待耗时任务,或使用 steer 补充指令 / stop 中止。`, `\nPending: ${pendingIds}. You may call subagent (action="wait_all") again to continue waiting for long tasks, or use steer to add instructions / stop to abort.`, "subagents.wait.pending", { pendingIds: pendingIds });
538
549
  }
@@ -1580,28 +1580,35 @@ function cleanBashOutput(raw) {
1580
1580
  /** 一次性 bash 终端序号:非持久调用每次新建一个终端,进程结束后退出、输出
1581
1581
  * 保留在 history 供查阅——故每调用独立 id,避免复用覆盖旧输出。 */
1582
1582
  let oneShotBashSeq = 0;
1583
+ /** head / tail 的运行时上限,与 bash 工具参数 schema 的 maximum 5000 一致
1584
+ * (agent-service.ts)——schema 只影响提示不拦运行时(issue #462)。 */
1585
+ const MAX_HEAD_TAIL_LINES = 5000;
1583
1586
  /** 应用 head / tail 参数到输出顶层行(替代 `| head` / `| tail` 管道——管道会
1584
1587
  * 缓冲输出、让可见终端全程哑火,还容易白白触发静默解阻)。两者同时给时先
1585
1588
  * 截头再截尾。 */
1586
1589
  export function applyHeadTail(text, head, tail, lang = "en") {
1590
+ // 入口钳到 schema 上限:此前只判 >0,传 1e9 会把整段缓冲灌进单条工具结果
1591
+ // 并持久化进转录。
1592
+ const headN = head && head > 0 ? Math.min(Math.floor(head), MAX_HEAD_TAIL_LINES) : 0;
1593
+ const tailN = tail && tail > 0 ? Math.min(Math.floor(tail), MAX_HEAD_TAIL_LINES) : 0;
1587
1594
  // 只对真实数据行切片;省略提示行单独存,最后再包回输出,避免提示行在
1588
1595
  // head+tail 组合时被当成数据行参与第二次截取(导致尾部少截一行)。
1589
1596
  let data = text.split("\n");
1590
1597
  let headNote = null;
1591
1598
  let tailNote = null;
1592
- if (head && head > 0 && data.length > head) {
1593
- const n = data.length - head;
1599
+ if (headN && data.length > headN) {
1600
+ const n = data.length - headN;
1594
1601
  headNote = pick(lang, `…(后 ${n} 行已省略)`, `…[${n} lines omitted below]…`, "terminals.headtail.omitted.below", {
1595
1602
  n,
1596
1603
  });
1597
- data = data.slice(0, head);
1604
+ data = data.slice(0, headN);
1598
1605
  }
1599
- if (tail && tail > 0 && data.length > tail) {
1600
- const n = data.length - tail;
1606
+ if (tailN && data.length > tailN) {
1607
+ const n = data.length - tailN;
1601
1608
  tailNote = pick(lang, `…(前 ${n} 行已省略)`, `…[${n} lines omitted above]…`, "terminals.headtail.omitted.above", {
1602
1609
  n,
1603
1610
  });
1604
- data = data.slice(-tail);
1611
+ data = data.slice(-tailN);
1605
1612
  }
1606
1613
  const parts = [];
1607
1614
  if (tailNote)
@@ -1889,7 +1896,9 @@ lang, options) {
1889
1896
  }),
1890
1897
  execute: async (_id, p) => {
1891
1898
  const lang = getLang();
1892
- const info = terminals.create(p.terminalId, p.cwd ?? cwd, p.cols ?? 120, p.rows ?? 40, cwd, p.terminalId);
1899
+ const safeCols = Math.max(2, Math.min(500, Math.floor(p.cols ?? 120)));
1900
+ const safeRows = Math.max(2, Math.min(200, Math.floor(p.rows ?? 40)));
1901
+ const info = terminals.create(p.terminalId, p.cwd ?? cwd, safeCols, safeRows, cwd, p.terminalId);
1893
1902
  const infoJson = JSON.stringify(info);
1894
1903
  if (!info)
1895
1904
  throw new Error(pick(lang, `创建终端失败:${p.terminalId}`, `Failed to create terminal: ${p.terminalId}`, "terminals.create.failed", { "p.terminalId": p.terminalId }));
@@ -1977,10 +1986,12 @@ lang, options) {
1977
1986
  }),
1978
1987
  execute: async (_id, p, signal) => {
1979
1988
  const lang = getLang();
1980
- const cursor = p.cursor ?? 0;
1981
- if (p.waitMs)
1982
- await terminals.waitForOutput(p.terminalId, cursor, p.waitMs, signal);
1983
- const read = terminals.read(p.terminalId, cursor, p.maxBytes ?? 20000);
1989
+ const cursor = Math.max(0, Math.floor(p.cursor ?? 0));
1990
+ const waitMs = p.waitMs !== undefined ? Math.max(0, Math.min(120_000, Math.floor(p.waitMs))) : undefined;
1991
+ const maxBytes = Math.max(1, Math.min(100_000, Math.floor(p.maxBytes ?? 20000)));
1992
+ if (waitMs)
1993
+ await terminals.waitForOutput(p.terminalId, cursor, waitMs, signal);
1994
+ const read = terminals.read(p.terminalId, cursor, maxBytes);
1984
1995
  if (!read)
1985
1996
  throw new Error(pick(lang, `终端不存在:${p.terminalId}`, `Terminal not found: ${p.terminalId}`, "terminals.read.not.found", { "p.terminalId": p.terminalId }));
1986
1997
  return result(JSON.stringify(read), read);
@@ -2019,7 +2030,8 @@ lang, options) {
2019
2030
  return result(JSON.stringify({ applicable: false, reason: why }), { applicable: false });
2020
2031
  }
2021
2032
  const cursor = p.cursor ?? terminals.endCursor(p.terminalId) ?? 0;
2022
- const wait = await terminals.waitForCompletion(p.terminalId, p.maxWaitMs ?? 300_000, signal, cursor);
2033
+ const maxWait = Math.max(100, Math.min(600_000, Math.floor(p.maxWaitMs ?? 300_000)));
2034
+ const wait = await terminals.waitForCompletion(p.terminalId, maxWait, signal, cursor);
2023
2035
  const read = terminals.read(p.terminalId, cursor, 20_000);
2024
2036
  const outputTail = read?.data ? stripAnsi(read.data).slice(-4000) : "";
2025
2037
  return result(JSON.stringify({ ...wait, outputTail }), {
@@ -0,0 +1,59 @@
1
+ /** 校验引用元数据,保留原文的换行和缩进。 */
2
+ export function readTextQuote(value) {
3
+ if (!value || typeof value !== "object")
4
+ return null;
5
+ const q = value;
6
+ if (typeof q.text !== "string" ||
7
+ !q.text.trim() ||
8
+ typeof q.messageId !== "string" ||
9
+ !q.messageId ||
10
+ typeof q.role !== "string" ||
11
+ !q.role)
12
+ return null;
13
+ return {
14
+ text: q.text,
15
+ messageId: q.messageId,
16
+ role: q.role,
17
+ ...(typeof q.sessionId === "string" && q.sessionId ? { sessionId: q.sessionId } : {}),
18
+ };
19
+ }
20
+ /** JSON 字符串保留原文边界,引用中的换行不参与外层标记解析。 */
21
+ export function formatTextQuote(quote) {
22
+ return `<quoted-text>\n${JSON.stringify(quote)}\n</quoted-text>`;
23
+ }
24
+ export function parseTextQuote(text) {
25
+ const match = /^<quoted-text>\n([^\n]*)\n<\/quoted-text>$/.exec(text);
26
+ if (!match)
27
+ return null;
28
+ try {
29
+ return readTextQuote(JSON.parse(match[1]));
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ export function formatQuotedPrompt(text, quotes) {
36
+ return [text, ...quotes.map(formatTextQuote)].join("\n\n");
37
+ }
38
+ /** 排队项把引用与提问一起保存;展示时只拆分末尾的完整引用块。 */
39
+ export function splitQuotedPrompt(value) {
40
+ let text = value;
41
+ const quotes = [];
42
+ while (true) {
43
+ const match = /(?:^|\n\n)(<quoted-text>\n[^\n]*\n<\/quoted-text>)$/.exec(text);
44
+ const quote = match ? parseTextQuote(match[1]) : null;
45
+ if (!match || !quote)
46
+ break;
47
+ quotes.unshift(quote);
48
+ text = text.slice(0, match.index);
49
+ }
50
+ return { text, quotes };
51
+ }
52
+ export function messageTextWithQuotes(message) {
53
+ const text = message.content
54
+ .map((block) => (block.type === "text" && typeof block.text === "string" ? block.text : ""))
55
+ .join("\n");
56
+ const values = message.details?.quotes;
57
+ const quotes = Array.isArray(values) ? values.map(readTextQuote).filter((q) => q !== null) : [];
58
+ return formatQuotedPrompt(text, quotes);
59
+ }
@@ -345,10 +345,19 @@ export function applyAgentToolsGating(session, disabled, preset) {
345
345
  const allNames = session.getAllTools().map((t) => t.name);
346
346
  const names = new Set(allNames);
347
347
  for (const t of AGENT_TOOL_CATALOG) {
348
- if (off.has(t.name))
349
- names.delete(t.name);
350
- else
348
+ if (off.has(t.name)) {
349
+ // issue #481: 若第三方扩展注册了同名工具(如 nicobailon/pi-subagents 的 subagent),
350
+ // 关闭内置工具时允许扩展工具透传,不从活跃名单删除
351
+ const hasExtensionTool = Boolean(session?.extensionRunner
352
+ ?.getAllRegisteredTools?.()
353
+ ?.some((tool) => tool.definition?.name === t.name));
354
+ if (!hasExtensionTool) {
355
+ names.delete(t.name);
356
+ }
357
+ }
358
+ else {
351
359
  names.add(t.name);
360
+ }
352
361
  }
353
362
  for (const core of CORE_BUILTIN_TOOL_NAMES) {
354
363
  if (off.has(core))
@@ -57,3 +57,27 @@ export function installToolOverrides(session, specs) {
57
57
  session._refreshToolRegistry();
58
58
  return [...names];
59
59
  }
60
+ /**
61
+ * 同步 subagent 工具与第三方扩展的同名工具共存(issue #481):
62
+ * 当第三方扩展注册了同名 subagent 时,若用户在设置中关闭/禁用了内置 subagent,
63
+ * 则自动从 session._customTools 中移除内置 subagent,让扩展的同名工具透传生效;
64
+ * 重新启用时则重新注入内置 subagent。
65
+ */
66
+ export function syncSubagentOverride(session, subagentDisabled, builtinSubagentTool) {
67
+ if (!Array.isArray(session._customTools) || typeof session._refreshToolRegistry !== "function")
68
+ return;
69
+ const hasExtensionTool = Boolean(extensionToolDefinition(session, "subagent"));
70
+ if (!hasExtensionTool)
71
+ return;
72
+ const idx = session._customTools.findIndex((t) => t.name === "subagent");
73
+ if (subagentDisabled) {
74
+ if (idx !== -1) {
75
+ session._customTools = session._customTools.filter((t) => t.name !== "subagent");
76
+ session._refreshToolRegistry();
77
+ }
78
+ }
79
+ else if (idx === -1 && builtinSubagentTool) {
80
+ session._customTools = [builtinSubagentTool(), ...session._customTools];
81
+ session._refreshToolRegistry();
82
+ }
83
+ }