pi-web-ui 0.95.0 → 0.96.1

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 (75) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/bin/pi-web-ui.mjs +159 -31
  3. package/dist/server/agent-service.js +880 -240
  4. package/dist/server/approval-rules.js +56 -10
  5. package/dist/server/attachment-store.js +21 -4
  6. package/dist/server/attachments.js +30 -3
  7. package/dist/server/bg-servers.js +45 -7
  8. package/dist/server/claim-files-tool.js +4 -8
  9. package/dist/server/claim-store.js +4 -2
  10. package/dist/server/client-state.js +63 -14
  11. package/dist/server/compact-context-tool.js +6 -5
  12. package/dist/server/composer-drafts.js +9 -0
  13. package/dist/server/control-socket.js +57 -28
  14. package/dist/server/conversation-read-tool.js +7 -17
  15. package/dist/server/dangling-tools.js +72 -5
  16. package/dist/server/delegate-task.js +25 -28
  17. package/dist/server/dsh/dsh-agent-service.js +19 -6
  18. package/dist/server/edit-soft-tool.js +14 -11
  19. package/dist/server/eval-tool.js +67 -14
  20. package/dist/server/file-archives.js +16 -5
  21. package/dist/server/files-service.js +30 -11
  22. package/dist/server/goal-service.js +141 -19
  23. package/dist/server/hashline-engine.js +59 -21
  24. package/dist/server/host-guard.js +94 -0
  25. package/dist/server/i18n.js +3 -3
  26. package/dist/server/index.js +331 -78
  27. package/dist/server/lsp-tool.js +752 -60
  28. package/dist/server/mcp-bridge.js +47 -3
  29. package/dist/server/model-admin.js +94 -34
  30. package/dist/server/office-parse.js +96 -30
  31. package/dist/server/patch-tool.js +9 -15
  32. package/dist/server/permission-preset.js +25 -0
  33. package/dist/server/plan-manager.js +11 -1
  34. package/dist/server/plugin-catalog-sync.js +36 -19
  35. package/dist/server/plugin-catalog.js +11 -4
  36. package/dist/server/plugin-facilities.js +92 -21
  37. package/dist/server/plugin-installer.js +30 -0
  38. package/dist/server/plugin-llm.js +71 -65
  39. package/dist/server/plugin-project.js +70 -2
  40. package/dist/server/plugin-updater.js +115 -12
  41. package/dist/server/plugins.js +205 -44
  42. package/dist/server/present-files-tool.js +9 -12
  43. package/dist/server/process-utils.js +4 -0
  44. package/dist/server/prompt-composer.js +9 -0
  45. package/dist/server/protocol-version.js +1 -1
  46. package/dist/server/read-tool.js +69 -30
  47. package/dist/server/resolve-global-sdk.js +30 -16
  48. package/dist/server/schedule-agent-tool.js +12 -15
  49. package/dist/server/sdk-origin.js +18 -2
  50. package/dist/server/serialize.js +108 -21
  51. package/dist/server/settings-service.js +4 -2
  52. package/dist/server/skill-tool.js +5 -7
  53. package/dist/server/subagent-templates.js +51 -0
  54. package/dist/server/subagents.js +48 -72
  55. package/dist/server/terminals.js +181 -69
  56. package/dist/server/tool-manager.js +24 -7
  57. package/dist/server/tool-overrides.js +59 -0
  58. package/dist/server/update-check.js +28 -1
  59. package/dist/server/uploads.js +17 -2
  60. package/dist/server/wait-subscription-scan.js +18 -21
  61. package/dist/server/ws-client-id.js +29 -0
  62. package/dist/server/ws-pending-queue.js +60 -0
  63. package/extensions/webui.ts +60 -2
  64. package/package.json +4 -2
  65. package/plugins/catalog.json +20 -0
  66. package/themes/aetheris.css +457 -0
  67. package/themes/zhupi-dark.css +718 -0
  68. package/themes/zhupi.css +771 -0
  69. package/web/dist/assets/{TerminalPanel-DlGBF2MD.js → TerminalPanel-O3GyFIGG.js} +1 -1
  70. package/web/dist/assets/index-B7Zp23Vc.css +1 -0
  71. package/web/dist/assets/index-ByfoaDwp.js +366 -0
  72. package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
  73. package/web/dist/index.html +3 -3
  74. package/web/dist/assets/index-BXnCQL9p.js +0 -364
  75. package/web/dist/assets/index-RpIGFyi_.css +0 -1
@@ -12,11 +12,16 @@
12
12
  * 与 SDK ls 完全一致);其余情况(文件、图片、路径不存在、读取报错)原样
13
13
  * 转发基底,行为与内置完全一致。
14
14
  *
15
+ * 基底也可能是**第三方扩展注册的 read**:`customTools` 恒胜、会把它顶掉(见
16
+ * tool-overrides.ts),所以那种情况下改走 `withReadDirSupport()` —— 把扩展的实现
17
+ * 整个当基底叠目录能力,它的 schema/描述/prompt 指引/渲染原样保留(扩展独有的参数
18
+ * 照旧可用),只有目录分支归 pi-web-ui。
19
+ *
15
20
  * 开关:`readDirEnabled`(设置面板「工具」页,默认开)。**行为开关**不是
16
21
  * ActiveSet 开关(read 本体不可关,关了 agent 就残了),因此不进
17
22
  * tool-manager 的 AGENT_TOOL_CATALOG;每次调用实时读设置,改动即时生效。
18
23
  *
19
- * 双语约定(issue #91):definition 走 bilingual(en, zh) 内联双语;per-call
24
+ * 文案约定:工具 definition(description/promptSnippet/promptGuidelines)为纯英文;per-call
20
25
  * 返回文本(目录头)按 lang 取 pick(lang, zh, en, key),缺表回落英文内联。
21
26
  *
22
27
  * DSH 引擎无 customTool 注册面(工具来自 shipped preset),本覆盖只服务 pi 引擎。
@@ -26,7 +31,7 @@ import { homedir } from "node:os";
26
31
  import { isAbsolute, join, resolve as nodeResolve } from "node:path";
27
32
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, createLsToolDefinition, createReadToolDefinition, defineTool, } from "@earendil-works/pi-coding-agent";
28
33
  import { Type } from "typebox";
29
- import { bilingual, pick } from "./i18n.js";
34
+ import { pick } from "./i18n.js";
30
35
  const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
31
36
  /**
32
37
  * 目录判定用的路径归一:~ / @ 前缀、Unicode 空格、相对 → 绝对(对齐 SDK
@@ -56,16 +61,16 @@ export async function isDirectoryPath(absolutePath) {
56
61
  /** 覆盖定义的参数 schema:内置的 path/offset/limit + file_path 别名。 */
57
62
  const readDirSchema = Type.Object({
58
63
  path: Type.String({
59
- description: bilingual("Path to the file (or directory) to read (relative or absolute)", "要读取的文件(或目录)路径(相对或绝对)"),
64
+ description: "Path to the file (or directory) to read (relative or absolute)",
60
65
  }),
61
66
  file_path: Type.Optional(Type.String({
62
- description: bilingual("Alias of `path` — some clients/models emit file_path; if both are given, `path` wins", "`path` 的别名 —— 部分客户端/模型习惯发 file_path;两者都给时以 `path` 为准"),
67
+ description: "Alias of `path` — some clients/models emit file_path; if both are given, `path` wins",
63
68
  })),
64
69
  offset: Type.Optional(Type.Number({
65
- description: bilingual("Line number to start reading from (1-indexed)", "从第几行开始读(从 1 起算)"),
70
+ description: "Line number to start reading from (1-indexed)",
66
71
  })),
67
72
  limit: Type.Optional(Type.Number({
68
- description: bilingual("Maximum number of lines to read (for a directory path: maximum number of entries)", "最多读多少行(路径是目录时 = 最多列多少条目)"),
73
+ description: "Maximum number of lines to read (for a directory path: maximum number of entries)",
69
74
  })),
70
75
  }, {});
71
76
  /**
@@ -81,9 +86,59 @@ export function prepareReadArguments(raw) {
81
86
  // 两者都没给时这里仍缺 path(静态类型是谎,运行时交给 schema 校验报错)。
82
87
  return args;
83
88
  }
89
+ /** Directory support note appended to any read definition (definitions are English-only). */
90
+ const DIR_DESCRIPTION_NOTE = "Also accepts a directory path: its entries are then listed instead of file contents (one entry per line, directories suffixed with '/'); in that case `limit` caps the number of entries and `offset` is ignored.";
91
+ const DIR_GUIDELINE = "Use read on a directory to list its entries — no need to shell out to `ls`";
92
+ /**
93
+ * 两条路(内置基底 / 扩展基底)共用的执行体:先判「路径是不是目录」—— 是就复用 SDK 的
94
+ * ls 列条目(排序/`/` 后缀/截断提示口径一致),否则把请求转发给基底实现。
95
+ *
96
+ * `normalizePath`:只给了 `file_path` 别名时补出 `path` 再转发(SDK 内置实现需要);
97
+ * 扩展基底传 false —— 它自带 `prepareArguments`,参数原样交给它,免得我们这边把扩展
98
+ * 独有的字段(如 better-edit 的 `windows`)吃掉。
99
+ */
100
+ async function dirAwareExecute(base, ls, fallbackCwd, dirEnabled, getLang, normalizePath, toolCallId, params, signal, onUpdate, ctx) {
101
+ const input = (params ?? {});
102
+ // 兜底(不依赖 prepareArguments 一定跑过):path 缺省/空时用 file_path。
103
+ const rawPath = typeof input.path === "string" && input.path.trim() ? input.path : input.file_path;
104
+ const path = typeof rawPath === "string" ? rawPath : "";
105
+ if (path && dirEnabled()) {
106
+ const cwd = typeof ctx?.cwd === "string" ? ctx.cwd : fallbackCwd;
107
+ if (await isDirectoryPath(resolvePathForDirCheck(path, cwd))) {
108
+ const limit = typeof input.limit === "number" && input.limit > 0 ? Math.floor(input.limit) : undefined;
109
+ // 列目录本体完全复用 SDK 的 ls。
110
+ const listed = (await ls.execute(toolCallId, { path, ...(limit !== undefined ? { limit } : {}) }, signal, onUpdate, ctx));
111
+ const header = pick(getLang(), `[目录:${path}]`, `[Directory: ${path}]`, "read.dir.header", { path });
112
+ // 只取列出来的正文:截断/条目上限提示已在正文末尾,read 卡片的
113
+ // details 不需要 ls 的字段。
114
+ const content = listed.content.map((part, index) => index === 0 && part.type === "text" ? { ...part, text: `${header}\n${part.text}` } : part);
115
+ return { content, details: undefined };
116
+ }
117
+ }
118
+ return base.execute(toolCallId, (normalizePath ? { ...input, path } : params), signal, onUpdate, ctx);
119
+ }
120
+ /**
121
+ * 在**任意** read 实现(SDK 内置,或第三方扩展 `registerTool` 注册的同名工具)之上叠加
122
+ * 「路径是目录时列出条目」。基底的 name/label/描述/参数 schema/prepareArguments/render*
123
+ * 全部原样保留,只补一句目录说明与一条目录指引 —— 于是扩展的锚协议、独有参数、渲染都不丢。
124
+ */
125
+ export function withReadDirSupport(base, fallbackCwd, options = {}) {
126
+ const dirEnabled = options.dirEnabled ?? (() => true);
127
+ const getLang = options.getLang ?? (() => "en");
128
+ const ls = createLsToolDefinition(fallbackCwd);
129
+ return defineTool({
130
+ ...base,
131
+ description: `${base.description} ${DIR_DESCRIPTION_NOTE}`,
132
+ promptGuidelines: [...(base.promptGuidelines ?? []), DIR_GUIDELINE],
133
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
134
+ return dirAwareExecute(base, ls, fallbackCwd, dirEnabled, getLang, false, toolCallId, params, signal, onUpdate, ctx);
135
+ },
136
+ });
137
+ }
84
138
  /**
85
- * 生成「read 读目录」覆盖定义。cwd 仅供创建时固定;执行时优先 ctx.cwd
86
- * (会话工作区)。
139
+ * 生成「read 读目录」覆盖定义(**没有**扩展同名工具时的完整实现:内置基底 + 英文描述 +
140
+ * `file_path` 别名)。cwd 仅供创建时固定;执行时优先 ctx.cwd(会话工作区)。
141
+ * 有扩展同名工具时改用 `withReadDirSupport` 组合它的实现(见 tool-overrides.ts)。
87
142
  */
88
143
  export function makeReadDirTool(fallbackCwd, options = {}) {
89
144
  const base = createReadToolDefinition(fallbackCwd);
@@ -92,34 +147,18 @@ export function makeReadDirTool(fallbackCwd, options = {}) {
92
147
  const getLang = options.getLang ?? (() => "en");
93
148
  return defineTool({
94
149
  ...base,
95
- description: bilingual(`${base.description} Also accepts \`file_path\` as an alias of \`path\`. If the path is a directory, its entries are listed instead of file contents (one entry per line, directories suffixed with '/'); in that case \`limit\` caps the number of entries and \`offset\` is ignored.`, `读取文件内容。支持文本文件与图片(jpg, png, gif, webp, bmp),图片作为附件发出。文本输出截断到 ${DEFAULT_MAX_LINES} 行或 ${DEFAULT_MAX_BYTES / 1024}KB(先到者为准),大文件用 offset/limit 续读。路径也可用 \`file_path\` 传(path 的别名,两者都给时以 path 为准)。路径是目录时改为列出目录条目(一行一项,目录带 '/' 后缀;此时 limit 是条目上限,offset 忽略)。`),
96
- promptSnippet: bilingual("Read file contents (a directory path lists its entries)", "读取文件内容(传目录则列出其条目)"),
150
+ description: `${base.description} Also accepts \`file_path\` as an alias of \`path\`. ` +
151
+ "If the path is a directory, its entries are listed instead (one per line, directories suffixed with '/'); `limit` caps the entries and `offset` is ignored.",
152
+ promptSnippet: "Read file contents (a directory path lists its entries)",
97
153
  promptGuidelines: [
98
154
  ...(base.promptGuidelines ?? []),
99
- bilingual("Use read on a directory to list its entries — no need to shell out to `ls`", "要看目录内容直接把目录路径交给 read,不必再走 bash 的 ls"),
155
+ "Use read on a directory to list its entries — no need to shell out to `ls`",
100
156
  ],
101
157
  parameters: readDirSchema,
102
158
  prepareArguments: prepareReadArguments,
103
159
  async execute(toolCallId, params, signal, onUpdate, ctx) {
104
- const input = (params ?? {});
105
- // 兜底(不依赖 prepareArguments 一定跑过):path 缺省/空时用 file_path。
106
- const rawPath = typeof input.path === "string" && input.path.trim() ? input.path : input.file_path;
107
- const path = typeof rawPath === "string" ? rawPath : "";
108
- if (path && dirEnabled()) {
109
- const cwd = typeof ctx?.cwd === "string" ? ctx.cwd : fallbackCwd;
110
- if (await isDirectoryPath(resolvePathForDirCheck(path, cwd))) {
111
- const limit = typeof input.limit === "number" && input.limit > 0 ? Math.floor(input.limit) : undefined;
112
- // 列目录本体完全复用 SDK 的 ls(排序/`/` 后缀/截断提示口径一致)。
113
- const listed = await ls.execute(toolCallId, { path, ...(limit !== undefined ? { limit } : {}) }, signal, onUpdate, ctx);
114
- const header = pick(getLang(), `[目录:${path}]`, `[Directory: ${path}]`, "read.dir.header", { path });
115
- // 只取列出来的正文:截断/条目上限提示已在正文末尾,read 卡片的
116
- // details 不需要 ls 的字段。
117
- const content = listed.content.map((part, index) => index === 0 && part.type === "text" ? { ...part, text: `${header}\n${part.text}` } : part);
118
- return { content, details: undefined };
119
- }
120
- }
121
- // 转发内置实现时带上归一后的 path(模型可能只给了 file_path)。
122
- return base.execute(toolCallId, { ...input, path }, signal, onUpdate, ctx);
160
+ // 目录分支与转发都在共用执行体里(normalizePath=true:只给了 file_path 时补出 path)。
161
+ return dirAwareExecute(base, ls, fallbackCwd, dirEnabled, getLang, true, toolCallId, params, signal, onUpdate, ctx);
123
162
  },
124
163
  });
125
164
  }
@@ -1,22 +1,25 @@
1
1
  /**
2
- * resolve-global-sdk — 可选的「优先用全局/祖先那份 pi SDK」解析钩子(issue #260)。
2
+ * resolve-global-sdk — pi SDK 副本选择钩子(issue #260;#321 起默认启用)。
3
3
  *
4
4
  * 背景:pi-web-ui 依赖 `@earendil-works/pi-coding-agent`,而 npm 全局安装会把依赖
5
5
  * **嵌在** `<npm root -g>/pi-web-ui/node_modules/`(不 hoist,实测),Node 又「嵌套优先于
6
6
  * 祖先」—— 于是用户 `npm i -g @earendil-works/pi-coding-agent@latest` 改的是全局那份,
7
7
  * 服务加载的仍是自带那份,表现为「升了 0.86.1,横幅和 /api/health 还显示 0.85.1」。
8
+ * 这个割裂在 #321 里反复咬人(界面此前完全看不见),所以解析顺序默认反转:
8
9
  *
9
- * 本模块把解析顺序反过来,但**只在显式开启时**:
10
+ * 缺省 / global / auto → 机器上有**更新**的 pi 副本(祖先链,如全局 pi CLI)就用它,
11
+ * 多份取版本最高的;没有或都更旧 → 自带副本兜底。
12
+ * bundled / 0 / off /
13
+ * false / no → 强制自带副本(可复现、CI 覆盖的那份;报 bug 请用它复现)。
10
14
  *
11
- * PI_WEB_SDK=global → 祖先链上存在一份版本**更新**的 SDK 时,把裸标识符重定向到它;
12
- * 否则原样不动(同版本也不折腾 —— 没意义)。
13
- * (缺省 / 其它值) → 什么都不做,用自带副本。
15
+ * 为什么「绝不降级」:祖先副本必须严格比自带的新才采用(同版本继续用自带那份,
16
+ * 少一次重定向);绝不为了「用全局」而跑更旧的版本。
14
17
  *
15
- * 为什么默认关:服务运行的 SDK 版本会变成「用户机器上装了什么」,同一个 pi-web-ui 版本在
16
- * 不同机器上跑不同 SDK,报 bug 时无法复现;自带副本可复现、且是 CI 覆盖的那一份。
17
- *
18
- * 为什么只做「必须更新」这一道门:仓库没有 semver 依赖,手写完整范围解析容易出错,
19
- * 「祖先那份比自带那份新」是保守且够用的判据(旧的 / 同版本的都不采用)。
18
+ * 为什么默认跟随(#321):用户升级全局 pi 后界面「显示没更新」,每个版本都有人踩,
19
+ * 每次都要人肉排查。可复现性的代价用两条路补回来:界面更新面板与 /api/health 的
20
+ * `piSdkCopies` 都如实报出实际加载的那份;`PI_WEB_SDK=bundled` 随时显式换回自带。
21
+ * 「全局 SDK 比宿主新」的兼容风险是被接受的:pi 与 pi-web-ui 同步演进,跟随全局
22
+ * 正是用户升级 pi 的本意。
20
23
  *
21
24
  * 安全约定:整个注册流程包在 try/catch 里,任何异常都退回默认解析 —— 这个模块**绝不允许**
22
25
  * 让服务起不来。它必须在任何 SDK 静态 import **之前**被加载(`--import <本文件>`,
@@ -26,18 +29,28 @@ import { registerHooks } from "node:module";
26
29
  import { pathToFileURL } from "node:url";
27
30
  import { compareVersions, sdkCopies } from "./sdk-origin.js";
28
31
  const PKG = "@earendil-works/pi-coding-agent";
32
+ /** 显式强制自带副本的取值(旧默认;可复现、CI 覆盖的那份)。 */
33
+ const BUNDLED_MODES = new Set(["bundled", "0", "off", "false", "no"]);
29
34
  /**
30
35
  * 该用哪一份对外解析(纯函数,便于单测)。
31
- * `copies` 按 Node 的解析顺序([0] = 自带的、实际会被用到的),返回 null = 保持默认。
36
+ * `copies` 按 Node 的解析顺序([0] = 自带的、无钩子时实际会被用到的),
37
+ * 返回 null = 保持默认(用自带副本兜底)。
32
38
  */
33
39
  export function pickGlobalSdk(copies, mode) {
34
- if ((mode ?? "bundled").trim().toLowerCase() !== "global")
40
+ if (BUNDLED_MODES.has((mode ?? "global").trim().toLowerCase()))
35
41
  return null;
36
42
  const bundled = copies[0];
37
- // 只考虑祖先链上的副本,且必须是**更新**的(旧的或同版本都不折腾)。
38
- return copies.slice(1).find((c) => !bundled || compareVersions(c.version, bundled.version) > 0) ?? null;
43
+ // 只考虑祖先链上**严格更新**的副本,多份取版本最高的(旧的/同版本的都不折腾)。
44
+ let best = null;
45
+ for (const copy of copies.slice(1)) {
46
+ if ((!bundled || compareVersions(copy.version, bundled.version) > 0) &&
47
+ (!best || compareVersions(copy.version, best.version) > 0)) {
48
+ best = copy;
49
+ }
50
+ }
51
+ return best;
39
52
  }
40
- /** 注册钩子;返回实际选中的副本(null = 没启用 / 没找到可用的)。 */
53
+ /** 注册钩子;返回实际选中的副本(null = 用自带副本)。 */
41
54
  export function registerGlobalSdkPreference(mode = process.env.PI_WEB_SDK, fromFile = import.meta.url) {
42
55
  try {
43
56
  const copies = sdkCopies(fromFile);
@@ -68,5 +81,6 @@ export function registerGlobalSdkPreference(mode = process.env.PI_WEB_SDK, fromF
68
81
  // ---- 副作用:被 `--import` 加载时立刻生效 --------------------------------
69
82
  const used = registerGlobalSdkPreference();
70
83
  if (used) {
71
- console.log(`[pi-web-ui] PI_WEB_SDK=global → using the pi SDK at ${used.path} (v${used.version})`);
84
+ console.log(`[pi-web-ui] following a newer pi SDK on this machine: ${used.path} (v${used.version}; ` +
85
+ "set PI_WEB_SDK=bundled to pin the bundled copy)");
72
86
  }
@@ -13,12 +13,12 @@
13
13
  // 到期执行走 index.ts 的 executor:目标对话还在 → steer 语义唤醒它(不切用户
14
14
  // 当前对话);不在了(关闭/重启)→ 回落原有无头执行;单次任务触发后自动删除。
15
15
  //
16
- // 双语约定(issue #91):definition 走 bilingual(en, zh) 内联双语;per-call
16
+ // 文案约定:工具 definition(description/promptSnippet/promptGuidelines)为纯英文;per-call
17
17
  // 返回文本按 lang 取 pick(lang, zh, en, key, vars),缺表回落英文内联。
18
18
  // ---------------------------------------------------------------------------
19
19
  import { defineTool } from "@earendil-works/pi-coding-agent";
20
20
  import { Type } from "typebox";
21
- import { bilingual, pick } from "./i18n.js";
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
23
  import { computeNextFire, describeIntervalMs, SCHEDULER_MIN_INTERVAL_MS, } from "./scheduler-tasks.js";
24
24
  import { parseCronSpec } from "./plugin-schedule.js";
@@ -107,17 +107,11 @@ export function makeScheduleTools(host, ownerConversationId, lang) {
107
107
  const taskTool = defineTool({
108
108
  name: SCHEDULE_TASK_TOOL_NAME,
109
109
  label: "Schedule a wake-up in this conversation",
110
- description: bilingual("Create a scheduled wake-up in the CURRENT conversation: at the given cron time or after the given delay, the scheduler automatically delivers your prompt back into this conversation so you continue the work and report to the user. " +
111
- "Use it when the user asks for periodic checks, delayed reminders, or scheduled summaries — do NOT fake it with sleep loops (a sleeping script can never push a message back). " +
112
- 'Schedule accepts a 5-field cron ("0 * * * *" = hourly) or a relative delay ("in 30m", "in 1h", "30m"); minimum interval is 60s. ' +
113
- "One-shot by default (recurring=false auto-deletes the task after it fires); pass recurring=true for repeats. " +
114
- "The wake-up binds the originating conversation AND its persisted session file: after context compaction or a restart the scheduler re-binds to the same session automatically; if the original conversation is gone it first falls back to the project's active conversation (and visibly reports the move), and only runs headless (report in panel history) when no live conversation exists for the project. " +
115
- "Manage with schedule_list / schedule_cancel; the user can also cancel from the background-tasks panel.", "在**当前对话**里创建一个定时唤醒:到指定 cron 时间或延迟后,调度器自动把你的 prompt 投回本对话,你继续执行并向用户汇报。" +
116
- "用户要求定时巡检、延时提醒、定时汇总时用它 —— 不要用 sleep 死循环假装答应(休眠的脚本推不回任何消息)。" +
117
- "时间写法收 5 字段 cron(“0 * * * *”=每小时)或相对延迟(“in 30m”“in 1h”“30m”);最短间隔 60s。" +
118
- "默认单次(recurring=false 触发后自动删除);传 recurring=true 做周期任务。" +
119
- "唤醒绑定发起对话与其落盘会话文件:上下文压缩或重启后自动重绑到同一会话;原对话不在时先回落到同项目的活跃对话(并明确提示迁移),同项目无存活对话才无头执行,报告留在调度面板历史里。" +
120
- "用 schedule_list / schedule_cancel 管理;用户也可从后台任务面板取消。"),
110
+ description: "Create a scheduled wake-up in the CURRENT conversation: at the cron time or after the delay, your prompt is delivered back into this conversation so you continue and report. " +
111
+ "Use for periodic checks, delayed reminders, scheduled summaries — never sleep loops (they cannot push messages back). " +
112
+ 'schedule: 5-field cron ("0 * * * *" hourly) or delay ("in 30m"); minimum 60s. One-shot by default; recurring=true repeats. ' +
113
+ "Re-binds to the same session after compaction/restart; if the conversation is gone it falls back to the project's active conversation (visibly reported), headless only when none exists. " +
114
+ "Manage with schedule_list / schedule_cancel.",
121
115
  promptSnippet: "schedule a wake-up in this conversation (cron or delay), auto-report on fire",
122
116
  parameters: Type.Object({
123
117
  schedule: Type.String({
@@ -210,7 +204,9 @@ export function makeScheduleTools(host, ownerConversationId, lang) {
210
204
  const listTool = defineTool({
211
205
  name: SCHEDULE_LIST_TOOL_NAME,
212
206
  label: "List scheduled tasks",
213
- description: bilingual("List all built-in scheduled tasks (all projects): id, name, cron/interval, on/paused, one-shot, target conversation, next fire, last run. Use it to inspect before cancelling, or to answer the user about what is scheduled.", "列出全部内置定时任务(跨项目):id、名称、cron/间隔、启用/暂停、单次、目标对话、下次触发、上次结果。取消前先看,或回答用户“排了什么”时用。"),
207
+ description: "List all built-in scheduled tasks (all projects): " +
208
+ "id, name, cron/interval, on/paused, one-shot, target conversation, next fire, last run. " +
209
+ "Use it to inspect before cancelling, or to answer the user about what is scheduled.",
214
210
  promptSnippet: "list scheduled wake-up tasks",
215
211
  parameters: Type.Object({}),
216
212
  execute: async () => {
@@ -235,7 +231,8 @@ export function makeScheduleTools(host, ownerConversationId, lang) {
235
231
  const cancelTool = defineTool({
236
232
  name: SCHEDULE_CANCEL_TOOL_NAME,
237
233
  label: "Cancel a scheduled task",
238
- description: bilingual("Delete a scheduled task by id (see schedule_list). Deleting stops all future fires; use it when the user says the schedule is no longer needed.", "按 id 删除定时任务(id 见 schedule_list)。删除后不再触发;用户说不用排了时用它。"),
234
+ description: "Delete a scheduled task by id (see schedule_list). " +
235
+ "Deleting stops all future fires; use it when the user says the schedule is no longer needed.",
239
236
  promptSnippet: "cancel a scheduled task by id",
240
237
  parameters: Type.Object({
241
238
  id: Type.String({ description: "Task id from schedule_list." }),
@@ -52,13 +52,29 @@ export function compareVersions(a, b) {
52
52
  return 0;
53
53
  }
54
54
  /**
55
- * 启动横幅要打印的一行(不含前缀),以及是否需要提示「你升的不是服务在用的那份」。
55
+ * 本进程实际加载的是否为**随包自带**的那份(未被钩子重定向到祖先链副本)。
56
+ * 判据:自带副本是解析顺序上的第一份,且钩子只在祖先副本**严格更新**时才重定向
57
+ * (同版本继续用自带),所以 running == copies[0] ⟺ 自带在用。copies 为空
58
+ * (如部分桌面/容器布局)时按自带算 —— 那本来就是唯一能加载的份。
59
+ * UI 用它决定是否亮「安装全局引擎并切换」入口(issue #321)。
60
+ */
61
+ export function isBundledInUse(copies, effectiveVersion) {
62
+ return copies.length === 0 || effectiveVersion === copies[0].version;
63
+ }
64
+ /**
65
+ * 启动横幅要打印的一行(不含前缀),以及是否需要提示「本进程在用的不是最新的那份」。
56
66
  * `effective` 为空时(没找到副本)返回空串,调用方照旧只打印版本号。
67
+ *
68
+ * #321 起默认会自动跟随更新的那份(resolve-global-sdk),所以这条提示出现即意味着:
69
+ * 进程启动时没跟上(升级发生在启动后 / 旧构建没注入钩子),或用户显式
70
+ * PI_WEB_SDK=bundled 钉死了自带副本 —— 文案把两种出路都写明。
57
71
  */
58
72
  export function sdkOriginNote(copies, effectiveVersion) {
59
73
  const shadowed = copies.slice(1);
60
74
  const newer = shadowed.filter((c) => compareVersions(c.version, effectiveVersion) > 0);
61
75
  if (newer.length === 0)
62
76
  return null;
63
- return `a newer pi SDK is installed elsewhere (${newer.map((c) => `v${c.version}`).join(", ")}) but pi-web-ui runs its own bundled copy (v${effectiveVersion}).`;
77
+ return (`a newer pi SDK is installed on this machine (${newer.map((c) => `v${c.version}`).join(", ")}) ` +
78
+ `but this process runs v${effectiveVersion} — restart to follow it (default), ` +
79
+ `or set PI_WEB_SDK=bundled to pin the bundled copy.`);
64
80
  }
@@ -65,7 +65,11 @@ function serializeAssistantContent(content) {
65
65
  return { type: "text", text, truncated };
66
66
  }
67
67
  if (b.type === "thinking") {
68
- return { type: "thinking", thinking: b.thinking };
68
+ // thinking 也走 TEXT_CAP:思维链没有长度保证(长任务能刷出远超正文的
69
+ // 体量),不截断会把快照推送撑爆。UiThinkingBlock 没有 truncated 字段
70
+ // (protocol 不动),截断语义靠 truncate 自带的 "… [truncated]" 尾标。
71
+ const { text } = truncate(b.thinking, TEXT_CAP);
72
+ return { type: "thinking", thinking: text };
69
73
  }
70
74
  if (b.type === "toolCall") {
71
75
  if (b.arguments === undefined) {
@@ -107,18 +111,98 @@ export function stripTransientRetryErrors(messages, retryActive) {
107
111
  }
108
112
  return end === messages.length ? messages : messages.slice(0, end);
109
113
  }
114
+ /**
115
+ * Single source for rendered message ids. Both serializeMessage (下发) and
116
+ * resolveMessageEntry (解析) must derive ids through this function — recomputing
117
+ * the format anywhere else is how the two sides drifted apart and fork/rollback
118
+ * on assistant bubbles stopped resolving (issue #381).
119
+ */
120
+ export function uiMessageId(m, seq) {
121
+ switch (m.role) {
122
+ case "user":
123
+ return `u-${m.timestamp}-${seq}`;
124
+ case "assistant":
125
+ return `a-${m.timestamp}-${seq}`;
126
+ case "toolResult":
127
+ return `t-${m.toolCallId}`;
128
+ case "bashExecution":
129
+ return `b-${m.timestamp}-${seq}`;
130
+ case "custom":
131
+ return `c-${m.timestamp}-${seq}`;
132
+ case "branchSummary":
133
+ return `bs-${m.timestamp}-${seq}`;
134
+ case "compactionSummary":
135
+ return `cs-${m.timestamp}-${seq}`;
136
+ default:
137
+ return `x-${seq}`;
138
+ }
139
+ }
140
+ /**
141
+ * Find the session entry a rendered message id points at, by re-deriving each
142
+ * entry's rendered id through uiMessageId() — the same function serializeMessage
143
+ * used to hand ids to the browser. `seqOf` supplies the per-message seq; the
144
+ * caller injects its counter there (agent-service passes uiMessageKey().n, which
145
+ * is exactly the counter serialization drew from — issue #381).
146
+ */
147
+ export function findEntryByUiId(entries, messageId, seqOf) {
148
+ const userSeqByTs = new Map();
149
+ for (const entry of entries) {
150
+ if (entry.id === messageId)
151
+ return entry;
152
+ if (entry.type === "message" && entry.message) {
153
+ const m = entry.message;
154
+ let seq;
155
+ if (m.role === "user") {
156
+ // User ids count messages sharing a timestamp — mirrors the
157
+ // special-case in serializeCachedFor() (see its comment).
158
+ const ts = m.timestamp ?? 0;
159
+ seq = (userSeqByTs.get(ts) ?? 0) + 1;
160
+ userSeqByTs.set(ts, seq);
161
+ }
162
+ else {
163
+ seq = seqOf(m);
164
+ }
165
+ if (uiMessageId(m, seq) === messageId)
166
+ return entry;
167
+ }
168
+ else if (entry.type === "custom_message" && entry.display !== false) {
169
+ // Custom messages carry content/timestamp on the entry itself (no
170
+ // message object); rebuild the shape uiMessageId()/seqOf() key on —
171
+ // the same conversion createCustomMessage() uses on reload.
172
+ const m = {
173
+ role: "custom",
174
+ content: entry.content,
175
+ timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : 0,
176
+ };
177
+ if (uiMessageId(m, seqOf(m)) === messageId)
178
+ return entry;
179
+ }
180
+ }
181
+ return null;
182
+ }
110
183
  export function serializeMessage(m, seq) {
184
+ // SDK 的 system 消息是 prompt sections 的内部差量
185
+ // (content 空串 + sections 结构化内存)、compaction 的
186
+ // systemMessage 等——从来不面向用户。不过滤的话
187
+ // 会掉进 default 分支被序列化成 content: []
188
+ // 的空气泡、前端顶着 system 标题白显示一条
189
+ // (对话结束后底部冒出的空 SYSTEM 气泡就是它)。
190
+ // LLM 上下文不受影响——这里只决定浏览器看到什么。
191
+ if (m.role === "system") {
192
+ return null;
193
+ }
194
+ const id = uiMessageId(m, seq);
111
195
  switch (m.role) {
112
196
  case "user":
113
197
  return {
114
- id: `u-${m.timestamp}-${seq}`,
198
+ id,
115
199
  role: "user",
116
200
  content: serializeUserContent(m.content),
117
201
  timestamp: m.timestamp,
118
202
  };
119
203
  case "assistant":
120
204
  return {
121
- id: `a-${m.timestamp}-${seq}`,
205
+ id,
122
206
  role: "assistant",
123
207
  content: serializeAssistantContent(m.content),
124
208
  timestamp: m.timestamp,
@@ -152,7 +236,7 @@ export function serializeMessage(m, seq) {
152
236
  const { text, truncated } = truncate(raw, TOOL_OUTPUT_CAP);
153
237
  const content = raw || images.length === 0 ? [{ type: "text", text, truncated }, ...images] : [...images];
154
238
  const msg = {
155
- id: `t-${m.toolCallId}`,
239
+ id,
156
240
  role: "toolResult",
157
241
  content,
158
242
  toolCallId: m.toolCallId,
@@ -177,7 +261,7 @@ export function serializeMessage(m, seq) {
177
261
  case "bashExecution": {
178
262
  const { text, truncated } = truncate(m.output, TOOL_OUTPUT_CAP);
179
263
  return {
180
- id: `b-${m.timestamp}-${seq}`,
264
+ id,
181
265
  role: "bashExecution",
182
266
  content: [
183
267
  {
@@ -199,19 +283,32 @@ export function serializeMessage(m, seq) {
199
283
  return null;
200
284
  }
201
285
  const content = serializeUserContent(m.content);
202
- return {
203
- id: `c-${m.timestamp}-${seq}`,
286
+ const msg = {
287
+ id,
204
288
  role: "custom",
205
289
  content,
206
290
  customType: m.customType,
207
- details: m.details,
208
291
  timestamp: m.timestamp,
209
292
  };
293
+ // custom details 与 toolResult 的 details 同一闸门(TOOL_DETAILS_CAP):
294
+ // details 随每 60ms 一发的快照推送,扩展塞进来的大对象不能无节制;
295
+ // 超限/序列化失败整丢(截断后的 JSON 不可解析,前端还得写容错)。
296
+ const rawDetails = m.details;
297
+ if (rawDetails !== undefined) {
298
+ try {
299
+ if (JSON.stringify(rawDetails).length <= TOOL_DETAILS_CAP)
300
+ msg.details = rawDetails;
301
+ }
302
+ catch {
303
+ // 循环引用等序列化不了的值:details 是附加信息,丢掉不影响消息本体。
304
+ }
305
+ }
306
+ return msg;
210
307
  }
211
308
  case "branchSummary": {
212
309
  const { text, truncated } = truncate(m.summary, TEXT_CAP);
213
310
  return {
214
- id: `bs-${m.timestamp}-${seq}`,
311
+ id,
215
312
  role: "branchSummary",
216
313
  content: [{ type: "text", text, truncated }],
217
314
  timestamp: m.timestamp,
@@ -220,26 +317,16 @@ export function serializeMessage(m, seq) {
220
317
  case "compactionSummary": {
221
318
  const { text, truncated } = truncate(m.summary, TEXT_CAP);
222
319
  return {
223
- id: `cs-${m.timestamp}-${seq}`,
320
+ id,
224
321
  role: "compactionSummary",
225
322
  content: [{ type: "text", text, truncated }],
226
323
  timestamp: m.timestamp,
227
324
  tokensBefore: m.tokensBefore,
228
325
  };
229
326
  }
230
- case "system": {
231
- // SDK 的 system 消息是 prompt sections 的内部差量
232
- // (content 空串 + sections 结构化内存)、compaction 的
233
- // systemMessage 等——从来不面向用户。不过滤的话
234
- // 会掉进 default 分支被序列化成 content: []
235
- // 的空气泡、前端顶着 system 标题白显示一条
236
- // (对话结束后底部冒出的空 SYSTEM 气泡就是它)。
237
- // LLM 上下文不受影响——这里只决定浏览器看到什么。
238
- return null;
239
- }
240
327
  default:
241
328
  return {
242
- id: `x-${seq}`,
329
+ id,
243
330
  role: String(m.role ?? "unknown"),
244
331
  content: [],
245
332
  timestamp: m.timestamp,
@@ -569,11 +569,13 @@ export class SettingsService {
569
569
  if (partial.quickPhrasesEnabled !== undefined) {
570
570
  this.settings.quickPhrasesEnabled = partial.quickPhrasesEnabled;
571
571
  }
572
- this.host.stateStore.saveSettings(this.host.clientId, this.settings);
573
- this.push();
574
572
  // 统一工具开关 live 生效(ActiveSet 加减;失败静默,下次创建/reload 重放)。
573
+ // 必须在 this.push() 前生效,保证 promptSnapshot 收集到的是最新的活跃工具集与完整提示词!
575
574
  if (toolGatingChanged)
576
575
  this.host.applyToolGating();
576
+ this.host.stateStore.saveSettings(this.host.clientId, this.settings);
577
+ this.push();
578
+ this.host.flushSnapshot();
577
579
  if (needsReload)
578
580
  await this.applyRuntime();
579
581
  }
@@ -14,13 +14,13 @@
14
14
  // 查不到被禁用的技能;
15
15
  // - 与 SDK 同名工具撞名时 customTools 按 name 覆盖(bash 本体是先例)。
16
16
  //
17
- // 双语约定(issue #91):definition 走 bilingual(en, zh) 内联双语;per-call
17
+ // 文案约定:工具 definition(description/promptSnippet/promptGuidelines)为纯英文;per-call
18
18
  // 返回文本按 lang 取 pick(lang, zh, en, key),缺表回落英文内联。
19
19
  // ---------------------------------------------------------------------------
20
20
  import { statSync, readFileSync } from "node:fs";
21
21
  import { defineTool } from "@earendil-works/pi-coding-agent";
22
22
  import { Type } from "typebox";
23
- import { bilingual, pick } from "./i18n.js";
23
+ import { pick } from "./i18n.js";
24
24
  import { SKILL_TOOL_NAME } from "./tool-manager.js";
25
25
  import { decodeText } from "./text-sniff.js";
26
26
  /** 单文件正文上限(与 agent-service fillSkillContents 同口径:8KB)。 */
@@ -61,12 +61,10 @@ export function makeSkillTool(host, lang) {
61
61
  return defineTool({
62
62
  name: SKILL_TOOL_NAME,
63
63
  label: "Load a skill",
64
- description: bilingual("Load a skill's full text by its exact name (see the <available_skills> catalog in the system prompt). " +
64
+ description: "Load a skill's full text by its exact name (see the <available_skills> catalog in the system prompt). " +
65
65
  "Prefer this over reading the skill file with the read tool — no path guessing needed. " +
66
- "Call without a name to list the current catalog.", "按技能精确名加载其全文(名录见系统提示词里的 <available_skills>)。" +
67
- "优先用它,不要自己拼路径调 read 工具读技能文件。" +
68
- "不给名则返回当前名录。"),
69
- promptSnippet: bilingual("load a skill's full text by name (skill tool, preferred over read)", "按名加载技能全文(skill 工具,优先于 read)"),
66
+ "Call without a name to list the current catalog.",
67
+ promptSnippet: "load a skill's full text by name (skill tool, preferred over read)",
70
68
  parameters: Type.Object({
71
69
  name: Type.Optional(Type.String({
72
70
  description: "Exact skill name from the catalog. Omit to list the current catalog.",
@@ -42,6 +42,53 @@ export function pickTemplatePrompt(t, lang) {
42
42
  }
43
43
  /** 名字去空白折叠后非空且 ≤ 60 字符(工具参数可读,允许中文)。 */
44
44
  const NAME_MAX = 60;
45
+ /**
46
+ * 模板体积上限(保存期拒绝,不静默截断):模板会整体进入设置面板快照并被
47
+ * subagent_templates 工具列给 AI,systemPrompt 还会拼进子代理上下文,无上限
48
+ * 的单条模板能把快照推送与子代理首响都拖爆。对齐常见上下文预算的保守值。
49
+ */
50
+ export const TEMPLATE_LIMITS = {
51
+ /** 模板系统提示词上限(systemPrompt / systemPromptEn 各自计)。 */
52
+ systemPrompt: 32_768,
53
+ /** 模板简介上限(description / descriptionEn 各自计)。 */
54
+ description: 2_000,
55
+ /** 技能/扩展白名单各自最多条目数。 */
56
+ whitelistEntries: 64,
57
+ /** 白名单单条字符上限。 */
58
+ whitelistEntryLength: 200,
59
+ };
60
+ /**
61
+ * 保存期体积校验:超限返回明确错误文本(让设置面板能原样提示),null = 通过。
62
+ * 只拦新保存,不拦读盘老数据(向后兼容,与 replace 空提示词的拦截口径一致);
63
+ * 拒绝而非截断——静默截断会让用户以为保存的是完整内容。
64
+ */
65
+ export function validateTemplateLimits(t) {
66
+ if (t.systemPrompt.length > TEMPLATE_LIMITS.systemPrompt) {
67
+ return `模板 "${t.name}" 的系统提示词超长(${t.systemPrompt.length} > ${TEMPLATE_LIMITS.systemPrompt} 字符),请精简后再保存`;
68
+ }
69
+ if ((t.systemPromptEn ?? "").length > TEMPLATE_LIMITS.systemPrompt) {
70
+ return `模板 "${t.name}" 的英文系统提示词超长(${(t.systemPromptEn ?? "").length} > ${TEMPLATE_LIMITS.systemPrompt} 字符),请精简后再保存`;
71
+ }
72
+ if (t.description.length > TEMPLATE_LIMITS.description) {
73
+ return `模板 "${t.name}" 的简介超长(${t.description.length} > ${TEMPLATE_LIMITS.description} 字符),请精简后再保存`;
74
+ }
75
+ if ((t.descriptionEn ?? "").length > TEMPLATE_LIMITS.description) {
76
+ return `模板 "${t.name}" 的英文简介超长(${(t.descriptionEn ?? "").length} > ${TEMPLATE_LIMITS.description} 字符),请精简后再保存`;
77
+ }
78
+ for (const [label, list] of [
79
+ ["技能白名单", t.enabledSkills],
80
+ ["扩展白名单", t.enabledExtensions],
81
+ ]) {
82
+ if (list.length > TEMPLATE_LIMITS.whitelistEntries) {
83
+ return `模板 "${t.name}" 的${label}条目过多(${list.length} > ${TEMPLATE_LIMITS.whitelistEntries} 条)`;
84
+ }
85
+ const over = list.find((entry) => entry.length > TEMPLATE_LIMITS.whitelistEntryLength);
86
+ if (over) {
87
+ return `模板 "${t.name}" 的${label}有超长条目(${over.length} > ${TEMPLATE_LIMITS.whitelistEntryLength} 字符)`;
88
+ }
89
+ }
90
+ return null;
91
+ }
45
92
  /**
46
93
  * 内置默认模板(第一次运行时种子进列表;用户改动后以 <dataDir> 文件为准)。
47
94
  * 文案改编自 pi-subagents 社区项目(tintinweb / nicobailon)的角色提示词,
@@ -629,6 +676,10 @@ export class SubagentTemplatesStore {
629
676
  if (t.promptMode === "replace" && !t.systemPrompt.trim() && !(t.systemPromptEn ?? "").trim()) {
630
677
  return `模板 "${t.name}" 用了 replace 模式但系统提示词为空:replace 会整体替换子代理 persona,空提示词等于模板没生效。请填写提示词(中文或英文至少一个),只想限定技能/扩展白名单请用 append 模式`;
631
678
  }
679
+ // 体积上限同样只在保存期拦:超限明确报错,绝不静默截断。
680
+ const limitError = validateTemplateLimits(t);
681
+ if (limitError)
682
+ return limitError;
632
683
  const list = this.load();
633
684
  const i = list.findIndex((x) => x.name === t.name);
634
685
  if (i >= 0)