pi-web-ui 0.94.1 → 0.96.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 (100) hide show
  1. package/CHANGELOG.md +91 -2
  2. package/README.md +5 -6
  3. package/README.zh-CN.md +4 -5
  4. package/bin/pi-web-ui.mjs +910 -237
  5. package/dist/server/agent-service.js +2655 -231
  6. package/dist/server/approval-rules.js +596 -0
  7. package/dist/server/attachment-store.js +129 -0
  8. package/dist/server/attachments.js +92 -174
  9. package/dist/server/bg-servers.js +45 -7
  10. package/dist/server/claim-files-tool.js +4 -8
  11. package/dist/server/claim-store.js +4 -2
  12. package/dist/server/client-state.js +67 -14
  13. package/dist/server/compact-context-tool.js +126 -0
  14. package/dist/server/composer-drafts.js +9 -0
  15. package/dist/server/context-budget.js +317 -0
  16. package/dist/server/control-socket.js +57 -28
  17. package/dist/server/conversation-read-tool.js +7 -17
  18. package/dist/server/dangling-tools.js +229 -0
  19. package/dist/server/delegate-task.js +25 -28
  20. package/dist/server/dsh/dsh-agent-service.js +69 -66
  21. package/dist/server/edit-soft-tool.js +84 -16
  22. package/dist/server/eval-tool.js +588 -0
  23. package/dist/server/file-archives.js +16 -5
  24. package/dist/server/files-service.js +65 -18
  25. package/dist/server/goal-service.js +371 -77
  26. package/dist/server/hashline-engine.js +703 -0
  27. package/dist/server/host-guard.js +94 -0
  28. package/dist/server/host-metrics.js +26 -2
  29. package/dist/server/i18n.js +3 -3
  30. package/dist/server/index.js +456 -80
  31. package/dist/server/lsp-tool.js +1371 -0
  32. package/dist/server/mcp-bridge.js +47 -3
  33. package/dist/server/model-admin.js +112 -32
  34. package/dist/server/office-parse.js +375 -0
  35. package/dist/server/patch-tool.js +85 -0
  36. package/dist/server/permission-preset.js +25 -0
  37. package/dist/server/plan-manager.js +114 -0
  38. package/dist/server/plugin-api-catalog.js +296 -0
  39. package/dist/server/plugin-catalog-sync.js +36 -19
  40. package/dist/server/plugin-catalog.js +11 -4
  41. package/dist/server/plugin-facilities.js +92 -21
  42. package/dist/server/plugin-install-spec.js +196 -0
  43. package/dist/server/plugin-installer.js +73 -0
  44. package/dist/server/plugin-llm.js +71 -65
  45. package/dist/server/plugin-manifest-validate.js +305 -0
  46. package/dist/server/plugin-project.js +117 -13
  47. package/dist/server/plugin-tool-guard.js +120 -0
  48. package/dist/server/plugin-updater.js +115 -12
  49. package/dist/server/plugins.js +950 -205
  50. package/dist/server/present-files-tool.js +9 -12
  51. package/dist/server/process-utils.js +16 -6
  52. package/dist/server/prompt-composer.js +9 -0
  53. package/dist/server/protocol-version.js +1 -1
  54. package/dist/server/read-tool.js +69 -30
  55. package/dist/server/resolve-global-sdk.js +30 -16
  56. package/dist/server/schedule-agent-tool.js +12 -15
  57. package/dist/server/scheduler-tasks.js +6 -0
  58. package/dist/server/sdk-origin.js +18 -2
  59. package/dist/server/serialize.js +111 -14
  60. package/dist/server/settings-service.js +112 -3
  61. package/dist/server/skill-tool.js +5 -7
  62. package/dist/server/subagent-templates.js +51 -0
  63. package/dist/server/subagents.js +83 -66
  64. package/dist/server/terminals.js +248 -81
  65. package/dist/server/tool-approval.js +84 -0
  66. package/dist/server/tool-manager.js +237 -12
  67. package/dist/server/tool-overrides.js +59 -0
  68. package/dist/server/update-check.js +28 -1
  69. package/dist/server/uploads.js +17 -2
  70. package/dist/server/wait-subscription-scan.js +18 -21
  71. package/dist/server/workspace-snapshot.js +113 -0
  72. package/dist/server/ws-client-id.js +29 -0
  73. package/dist/server/ws-pending-queue.js +60 -0
  74. package/extensions/webui.ts +60 -2
  75. package/package.json +5 -3
  76. package/plugin-sdk/README.md +25 -0
  77. package/plugin-sdk/index.d.ts +31 -38
  78. package/plugin-sdk/index.mjs +35 -19
  79. package/plugins/catalog.json +40 -0
  80. package/themes/aetheris.css +457 -0
  81. package/themes/ayu-light.css +6 -6
  82. package/themes/catppuccin-latte.css +6 -6
  83. package/themes/claude-code-dark.css +144 -0
  84. package/themes/codex.css +6 -6
  85. package/themes/everforest-light.css +6 -6
  86. package/themes/geist.css +6 -6
  87. package/themes/gruvbox-light.css +6 -6
  88. package/themes/kanagawa-lotus.css +6 -6
  89. package/themes/rose-pine-dawn.css +6 -6
  90. package/themes/solarized-light.css +6 -6
  91. package/themes/vs-code-dark.css +146 -0
  92. package/themes/zhupi-dark.css +658 -0
  93. package/themes/zhupi.css +711 -0
  94. package/web/dist/assets/{TerminalPanel-MVoxpJOA.js → TerminalPanel-DDChcYOh.js} +1 -1
  95. package/web/dist/assets/index-Do9RgJC3.js +366 -0
  96. package/web/dist/assets/index-DryOsILO.css +1 -0
  97. package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
  98. package/web/dist/index.html +3 -3
  99. package/web/dist/assets/index-B3S9MxnN.css +0 -1
  100. package/web/dist/assets/index-DVLrHI2E.js +0 -364
@@ -19,7 +19,7 @@
19
19
  * resolvePathForDirCheck);回传的 `path` 是线形绝对路径("C:/…" / "/…"),
20
20
  * 前端拿它直接打 /api/file、file_reveal、file_open_default,不受会话 cwd 影响。
21
21
  *
22
- * 双语约定(issue #91):definition 走 bilingual(en, zh) 内联双语;per-call
22
+ * 文案约定:工具 definition(description/promptSnippet/promptGuidelines)为纯英文;per-call
23
23
  * 结果文本走 pick(lang, zh, en, key, vars),缺表回落英文内联。
24
24
  *
25
25
  * DSH 引擎无 customTool 注册面(工具来自 shipped preset),本工具只服务 pi 引擎。
@@ -28,7 +28,7 @@ import { open, stat } from "node:fs/promises";
28
28
  import { basename, extname, sep } from "node:path";
29
29
  import { defineTool } from "@earendil-works/pi-coding-agent";
30
30
  import { Type } from "typebox";
31
- import { bilingual, pick } from "./i18n.js";
31
+ import { pick } from "./i18n.js";
32
32
  import { resolvePathForDirCheck } from "./read-tool.js";
33
33
  import { decodeText, isAudioFile, looksLikeText, previewKind } from "./text-sniff.js";
34
34
  import { PRESENT_FILES_TOOL_NAME } from "./tool-manager.js";
@@ -239,17 +239,14 @@ export function makePresentFilesTool(fallbackCwd, options = {}) {
239
239
  return defineTool({
240
240
  name: PRESENT_FILES_TOOL_NAME,
241
241
  label: "Show files to the user",
242
- description: bilingual("Show files to the user as preview cards in the chat. Each item renders as a card: images/videos/audio are displayed inline, text/markdown/HTML can be opened in the preview dialog, and every card carries buttons to open the file locally, reveal it in the file manager, download it or copy its path. " +
243
- "Use it whenever the user should LOOK at an artifact you produced or changed: a screenshot, chart, diagram, generated video/audio, report, log, build output. " +
244
- "Give workspace-relative paths (or absolute ones); up to 12 items per call; `title` and `note` are shown above the cards, `caption` under the file name, and `focus: true` makes the client open that item in the preview dialog right away. " +
245
- "Do not use it for files you merely read while reasoning, and do not repeat the file contents in your reply afterwards.", "把文件作为预览卡片展示给用户。每个条目渲染成一张卡片:图片/视频/音频直接在对话里显示,文本/markdown/HTML 可一键打开预览弹窗,每张卡片都带「本地打开 / 在文件管理器中显示 / 下载 / 复制路径」按钮。" +
246
- "适合用户**应该看一眼**的产物:截图、图表、示意图、生成的视频音频、报告、日志、构建产物。" +
247
- "路径写工作区相对路径(或绝对路径);单次最多 12 条;`title`/`note` 显示在卡片上方,`caption` 显示在文件名旁,`focus: true` 让客户端立刻用预览弹窗打开该条目。" +
248
- "只是自己读文件来推理时不要调用它,调用后也不要在回复里把文件内容再贴一遍。"),
249
- promptSnippet: bilingual("show images/videos/text files to the user as preview cards", "把图片/视频/文本文件作为预览卡片展示给用户"),
242
+ description: "Show files to the user as preview cards in the chat: images/videos/audio inline, text/markdown/HTML in a preview dialog; each card can open locally, reveal in file manager, download, or copy path. " +
243
+ "Use whenever the user should LOOK at an artifact you produced or changed (screenshot, chart, diagram, video/audio, report, log, build output). " +
244
+ "Give workspace-relative paths (max 12 items); `title`/`note` show above the cards, `caption` next to the file name, `focus: true` opens that item in the preview dialog immediately. " +
245
+ "Do not use for files you merely read while reasoning, and do not repeat file contents in your reply.",
246
+ promptSnippet: "show images/videos/text files to the user as preview cards",
250
247
  promptGuidelines: [
251
- bilingual("After producing something visual or user-facing (screenshot, chart, video, report, log, build output), call present_files so the user can actually see it instead of only printing the path", "产出可视化或面向用户的文件后(截图、图表、视频、报告、日志、构建产物),调 present_files 让用户真的看到,而不是只打印一行路径"),
252
- bilingual("Do not call present_files for ordinary source edits the user did not ask to see, and never call it twice for the same file in one turn", "用户没要求看的普通源码改动不要用 present_files;同一轮里不要为同一个文件调两次"),
248
+ "After producing something visual or user-facing (screenshot, chart, video, report, log, build output), call present_files so the user can actually see it instead of only printing the path",
249
+ "Do not call present_files for ordinary source edits the user did not ask to see, and never call it twice for the same file in one turn",
253
250
  ],
254
251
  parameters: Type.Object({
255
252
  title: Type.Optional(Type.String({
@@ -86,16 +86,26 @@ export async function snapshotProcessParents() {
86
86
  return undefined;
87
87
  }
88
88
  }
89
- /** Kill a pid and its whole process tree (cross-platform). */
90
- export function killPidTree(pid) {
89
+ /** Kill a pid and its whole process tree (cross-platform).
90
+ *
91
+ * The returned promise is only meaningful on Windows, where taskkill is itself
92
+ * asynchronous. Existing callers may continue to ignore it for best-effort
93
+ * background cleanup; callers that own a deadline can await completion. */
94
+ export async function killPidTree(pid) {
95
+ // pid 下界防护:pid<=1(init / Windows System 级 pid)或自身 pid 绝不能杀 ——
96
+ // POSIX 的 -pid 是进程组语义,误传 0/1/自身 pid 会杀掉整组无辜进程甚至本进程。
97
+ if (!(pid > 1 && pid !== process.pid))
98
+ return;
91
99
  try {
92
100
  if (process.platform === "win32") {
93
- void import("node:child_process").then(({ spawn }) => {
94
- spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
101
+ const { spawn } = await import("node:child_process");
102
+ await new Promise((resolve) => {
103
+ const killer = spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
95
104
  stdio: "ignore",
96
- detached: true,
97
105
  windowsHide: true,
98
- }).unref();
106
+ });
107
+ killer.once("error", () => resolve());
108
+ killer.once("close", () => resolve());
99
109
  });
100
110
  }
101
111
  else {
@@ -249,3 +249,12 @@ export function renderPromptTemplate(template, texts, overrides) {
249
249
  export function renderDefaultPrompt(texts) {
250
250
  return renderPromptTemplate(DEFAULT_PROMPT_TEMPLATE, texts, undefined);
251
251
  }
252
+ const CJK_RE = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]/gu;
253
+ /** 粗略 token 估算(设置面板预览用,非精确分词,无 tokenizer 依赖):
254
+ * CJK 字符(汉字/假名/谚文)≈ 1 token/字,其余字符 ≈ 1 token / 4 字符。 */
255
+ export function estimatePromptTokens(text) {
256
+ if (!text)
257
+ return 0;
258
+ const cjk = text.match(CJK_RE)?.length ?? 0;
259
+ return Math.ceil(cjk + (text.length - cjk) / 4);
260
+ }
@@ -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 = 19;
11
+ export const PROTOCOL_VERSION = 20;
@@ -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." }),
@@ -347,6 +347,12 @@ export class SchedulerStore {
347
347
  if (ok) {
348
348
  this.save();
349
349
  this.changed();
350
+ try {
351
+ this.opts.onTaskRemoved?.(id);
352
+ }
353
+ catch {
354
+ /* 回调失败不影响删除 */
355
+ }
350
356
  }
351
357
  return ok;
352
358
  }
@@ -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
  }