pi-web-ui 0.96.1 → 0.98.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 (88) hide show
  1. package/CHANGELOG.md +144 -2
  2. package/README.md +29 -3
  3. package/README.zh-CN.md +28 -3
  4. package/bin/pi-web-ui.mjs +75 -25
  5. package/dist/server/agent-service.js +1829 -262
  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 +296 -11
  11. package/dist/server/compact-context-tool.js +3 -1
  12. package/dist/server/compacted-history.js +148 -0
  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 +143 -0
  16. package/dist/server/delegate-task.js +10 -10
  17. package/dist/server/dsh/dsh-agent-service.js +66 -31
  18. package/dist/server/dsh/dsh-client.js +48 -10
  19. package/dist/server/dsh/dsh-serialize.js +12 -10
  20. package/dist/server/dsh/dsh-sessions.js +20 -6
  21. package/dist/server/dsh/runtime/goal-rpc.mjs +14 -1
  22. package/dist/server/dsh/runtime/runtime-root.mjs +1 -1
  23. package/dist/server/ensure-bash.js +3 -5
  24. package/dist/server/eval-tool.js +11 -5
  25. package/dist/server/goal-review-gate.js +250 -0
  26. package/dist/server/goal-service.js +1272 -448
  27. package/dist/server/index.js +118 -16
  28. package/dist/server/launch-origin.js +0 -4
  29. package/dist/server/locales.js +3 -5
  30. package/dist/server/lsp-tool.js +123 -24
  31. package/dist/server/markers/builtins/notify.js +4 -2
  32. package/dist/server/markers/builtins/rename.js +0 -1
  33. package/dist/server/markers/builtins/todo.js +6 -28
  34. package/dist/server/markers/marker.js +0 -15
  35. package/dist/server/markers/registry.js +0 -3
  36. package/dist/server/markers/store.js +0 -3
  37. package/dist/server/mcp-bridge.js +13 -0
  38. package/dist/server/mcp-hot-reload.js +12 -5
  39. package/dist/server/model-admin.js +19 -18
  40. package/dist/server/model-enrich.js +0 -3
  41. package/dist/server/patch-tool.js +22 -6
  42. package/dist/server/patch-turn-end-boundary.js +77 -0
  43. package/dist/server/permission-preset.js +69 -0
  44. package/dist/server/plan-manager.js +150 -5
  45. package/dist/server/plan-mode.js +414 -0
  46. package/dist/server/plugin-api-catalog.js +2 -0
  47. package/dist/server/plugin-catalog.js +6 -6
  48. package/dist/server/plugin-dom.js +3 -4
  49. package/dist/server/plugin-facilities.js +39 -8
  50. package/dist/server/plugin-grants.js +5 -8
  51. package/dist/server/plugin-permissions.js +4 -6
  52. package/dist/server/plugin-schedule.js +4 -7
  53. package/dist/server/plugin-updater.js +151 -38
  54. package/dist/server/plugins.js +22 -5
  55. package/dist/server/prompt-composer.js +12 -3
  56. package/dist/server/protocol-version.js +1 -1
  57. package/dist/server/resolve-global-sdk.js +8 -0
  58. package/dist/server/schedule-agent-tool.js +5 -1
  59. package/dist/server/scheduler-tasks.js +46 -27
  60. package/dist/server/sdk-origin.js +27 -0
  61. package/dist/server/serialize.js +33 -9
  62. package/dist/server/settings-service.js +18 -6
  63. package/dist/server/slash-commands.js +16 -0
  64. package/dist/server/subagent-templates.js +4 -10
  65. package/dist/server/subagents.js +379 -416
  66. package/dist/server/terminals.js +11 -6
  67. package/dist/server/text-quote.js +59 -0
  68. package/dist/server/text-sniff.js +15 -9
  69. package/dist/server/tool-manager.js +60 -7
  70. package/dist/server/tool-overrides.js +24 -0
  71. package/dist/server/update-check.js +80 -28
  72. package/dist/server/wait-subscription-scan.js +5 -1
  73. package/extensions/webui.ts +15 -1
  74. package/package.json +33 -21
  75. package/plugin-sdk/client-utils.mjs +92 -0
  76. package/plugin-sdk/index.d.ts +21 -0
  77. package/plugin-sdk/index.mjs +2 -0
  78. package/plugins/catalog.json +7 -0
  79. package/themes/zhupi-dark.css +5 -30
  80. package/themes/zhupi.css +5 -30
  81. package/web/dist/assets/{TerminalPanel-O3GyFIGG.js → TerminalPanel-C88r3r3Q.js} +1 -1
  82. package/web/dist/assets/index-2jDtjHvW.css +1 -0
  83. package/web/dist/assets/index-BjdK7s1M.js +368 -0
  84. package/web/dist/index.html +2 -2
  85. package/web/dist/sw.js +2 -1
  86. package/web/public/sw.js +2 -1
  87. package/web/dist/assets/index-B7Zp23Vc.css +0 -1
  88. package/web/dist/assets/index-ByfoaDwp.js +0 -366
@@ -13,8 +13,9 @@
13
13
  * - 只有真正发生变更的写才落盘(临时文件 + rename 原子写);写失败 best-effort
14
14
  * (内存态仍生效,本次会话可用)。
15
15
  */
16
- import { readFileSync, renameSync, writeFileSync } from "node:fs";
16
+ import { readFileSync } from "node:fs";
17
17
  import { join } from "node:path";
18
+ import { writeJsonAtomicSync } from "./atomic-file.js";
18
19
  const FILE = "plugin-dom.json";
19
20
  /** manifest.permissions 条目 → 是否请求特权 DOM(只看 `:` 前的族名,与宿主 can() 同口径)。
20
21
  * 例外:`dom:anchor` 只是客户端锚点挂载点的范围 DOM(免用户授权),不触发 bundle 403;
@@ -71,9 +72,7 @@ export class PluginDomConsent {
71
72
  else
72
73
  this.granted.delete(id);
73
74
  try {
74
- const tmp = join(this.dataDir, `${FILE}.tmp.${process.pid}`);
75
- writeFileSync(tmp, JSON.stringify({ v: 1, granted: this.list() }), "utf8");
76
- renameSync(tmp, join(this.dataDir, FILE));
75
+ writeJsonAtomicSync(join(this.dataDir, FILE), { v: 1, granted: this.list() });
77
76
  }
78
77
  catch {
79
78
  /* 写失败 best-effort:内存态仍生效,本次会话可用 */
@@ -16,9 +16,10 @@
16
16
  * 不能防同一用户账号下的完整进程妥协——本地个人工具的合理折衷。
17
17
  */
18
18
  import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
19
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
19
+ import { chmodSync, existsSync, readFileSync, statSync } from "node:fs";
20
20
  import { readFile as fspReadFile, readdir as fspReaddir, rm as fspRm, mkdir as fspMkdir, writeFile as fspWriteFile, appendFile as fspAppendFile, stat as fspStat, } from "node:fs/promises";
21
21
  import { dirname, join, relative, resolve } from "node:path";
22
+ import { writeAtomicSync } from "./atomic-file.js";
22
23
  import { createRequire } from "node:module";
23
24
  import { spawnSync } from "node:child_process";
24
25
  // 与 plugin-project 共用同一套 realpath 越界复核(isInsideRoot / realPathOfNearest),
@@ -26,10 +27,7 @@ import { spawnSync } from "node:child_process";
26
27
  import { isInsideRoot, realPathOfNearest } from "./plugin-project.js";
27
28
  /** tmp+rename 原子写(错误由调用方隔离——插件设施的 IO 一律尽力而为)。 */
28
29
  function atomicWrite(file, data) {
29
- mkdirSync(dirname(file), { recursive: true });
30
- const tmp = `${file}.tmp-${process.pid}`;
31
- writeFileSync(tmp, data);
32
- renameSync(tmp, file);
30
+ writeAtomicSync(file, data);
33
31
  }
34
32
  // ---------------------------------------------------------------------------
35
33
  // storage.json 的进程内「读-改-写」互斥
@@ -303,14 +301,47 @@ export function depName(spec) {
303
301
  function str(v) {
304
302
  return typeof v === "string" ? v.trim() : "";
305
303
  }
306
- /** 从插件目录出发能否解析到这个模块(模拟插件自身 import() 的查找链)。 */
304
+ /** 纯包名(`foo` / `@scope/foo`):能按 `node_modules/<name>/package.json` 判盘。
305
+ * `node:path`、URL、相对/绝对路径、git spec 一律 false(交给 resolve 判)。 */
306
+ function isPlainPackageName(name) {
307
+ return /^(?:@[a-z\d](?:[a-z\d._-]*[a-z\d])?\/)?[a-z\d](?:[a-z\d._-]*[a-z\d])?$/i.test(name);
308
+ }
309
+ /** 从 fromDir 沿目录树向上找 `node_modules/<name>/package.json` 是否已落盘。 */
310
+ function depOnDisk(fromDir, name) {
311
+ let dir = fromDir;
312
+ for (;;) {
313
+ try {
314
+ if (existsSync(join(dir, "node_modules", ...name.split("/"), "package.json")))
315
+ return true;
316
+ }
317
+ catch {
318
+ /* 权限/坏路径:当作这一层没有,继续往上找 */
319
+ }
320
+ const up = dirname(dir);
321
+ if (!up || up === dir)
322
+ return false;
323
+ dir = up;
324
+ }
325
+ }
326
+ /** 从插件目录出发能否解析到这个模块(模拟插件自身 import() 的查找链)。
327
+ *
328
+ * resolve 失败后再看一眼「文件是否已落盘」,这不是冗余判据而是**修 bug**(issue #383):
329
+ * Node 的 CJS 解析会把 `node_modules/<pkg>/package.json` 不存在的**负结果**缓存在
330
+ * 进程内存里(packageJsonReader 的路径缓存)。于是「探测缺失 → npm install 成功 →
331
+ * 复查」这组三连里,最后一次复查永远命中那条负缓存,恒抛
332
+ * `Cannot find module`,用户看到的是「装成功了却报 npm install 没跑通」,
333
+ * 且非重启整个进程不能恢复。落盘判据不碰 CJS 缓存,天然免疫。
334
+ *
335
+ * 顺序保持「先 resolve 后判盘」:解析成功仍然是最强的信号(含 exports 映射等
336
+ * 盘上判据覆盖不到的形状),判盘只是给解析的假阴性兜底,不会放宽真缺失。 */
307
337
  export function isDepAvailable(pluginDir, spec) {
338
+ const name = depName(spec);
308
339
  try {
309
- createRequire(join(pluginDir, "index.mjs")).resolve(depName(spec));
340
+ createRequire(join(pluginDir, "index.mjs")).resolve(name);
310
341
  return true;
311
342
  }
312
343
  catch {
313
- return false;
344
+ return isPlainPackageName(name) && depOnDisk(pluginDir, name);
314
345
  }
315
346
  }
316
347
  const depInstallLocks = new Map();
@@ -36,8 +36,9 @@
36
36
  * 与尾部分隔符(Windows 文件系统不区分大小写),但**存储保持用户写入时的形式**
37
37
  * (不 lowerCase),否则设置面板里 `C:\Users\Foo` 会显示成 `c:\users\foo`。
38
38
  */
39
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
40
- import { dirname, isAbsolute, resolve, sep } from "node:path";
39
+ import { readFileSync } from "node:fs";
40
+ import { isAbsolute, resolve, sep } from "node:path";
41
+ import { writeJsonAtomicSync } from "./atomic-file.js";
41
42
  /** 合法插件 id(与 server/plugins.ts 的 ID_RE 一致,防路径穿越)。 */
42
43
  const ID_RE = /^[A-Za-z0-9_-]+$/;
43
44
  /** 去掉尾部分隔符(保留根:「C:\」/「/」不能退化)。 */
@@ -93,13 +94,9 @@ function isSameOrInside(targetKey, ancestorKey) {
93
94
  const prefix = ancestorKey.endsWith(sep) ? ancestorKey : ancestorKey + sep;
94
95
  return targetKey.startsWith(prefix);
95
96
  }
96
- /** 原子写:临时文件 + rename(同 server/plugin-catalog.ts 的写法;本文件不 import
97
- * 它,避免为一个 3 行函数制造模块耦合)。 */
97
+ /** 原子写:临时文件 + rename。 */
98
98
  function atomicWriteJson(filePath, data) {
99
- mkdirSync(dirname(filePath), { recursive: true });
100
- const tmp = `${filePath}.tmp-${process.pid}`;
101
- writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
102
- renameSync(tmp, filePath);
99
+ writeJsonAtomicSync(filePath, data);
103
100
  }
104
101
  /** 读文件并净化为内存表:任何异常(不存在 / 权限 / JSON 坏 / 形状不对)→ 空表。
105
102
  * 条目逐个过 normalizeGrantPath(顺手兼容手工写入的相对路径、重复项、坏 id)。 */
@@ -13,8 +13,9 @@
13
13
  * - 内存 session 授权(仅本次运行,进程结束即失)。
14
14
  * 读坏/形状不对当空表,且不在读路径回写;只有真正变更的写才落盘(tmp+rename 原子写)。
15
15
  */
16
- import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
17
- import { dirname, join } from "node:path";
16
+ import { readFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { writeJsonAtomicSync } from "./atomic-file.js";
18
19
  /** 主机匹配(与 manifest netAllowlist 同口径):全等或点号后缀。纯函数,单测覆盖。 */
19
20
  export function permissionHostMatches(hostname, entry) {
20
21
  const h = String(hostname ?? "").toLowerCase();
@@ -63,10 +64,7 @@ export class PluginPermissionStore {
63
64
  }
64
65
  write(grants) {
65
66
  try {
66
- mkdirSync(dirname(this.file()), { recursive: true });
67
- const tmp = `${this.file()}.tmp-${process.pid}`;
68
- writeFileSync(tmp, JSON.stringify({ v: 1, grants }));
69
- renameSync(tmp, this.file());
67
+ writeJsonAtomicSync(this.file(), { v: 1, grants });
70
68
  }
71
69
  catch (err) {
72
70
  console.error(`[plugin-permissions] persist failed (${this.file()}):`, err);
@@ -10,8 +10,9 @@
10
10
  * 上次触发 lastRun;fn 回调永远由插件代码在 activate 里重给(重启后重建)。
11
11
  * 坏文件当空表(不回写,避免一次磁盘抖动清空全部定时)。
12
12
  */
13
- import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
14
- import { dirname, join } from "node:path";
13
+ import { readFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { writeJsonAtomicSync } from "./atomic-file.js";
15
16
  const MONTH_NAMES = {
16
17
  jan: 1,
17
18
  feb: 2,
@@ -224,11 +225,7 @@ export function loadScheduleRecords(pluginDir) {
224
225
  /** 写持久记录(tmp+rename 原子写;失败只记日志,内存态本次会话仍生效)。 */
225
226
  export function saveScheduleRecords(pluginDir, records) {
226
227
  try {
227
- mkdirSync(pluginDir, { recursive: true });
228
- const file = scheduleFile(pluginDir);
229
- const tmp = `${file}.tmp-${process.pid}`;
230
- writeFileSync(tmp, JSON.stringify({ v: 1, schedules: records }));
231
- renameSync(tmp, file);
228
+ writeJsonAtomicSync(scheduleFile(pluginDir), { v: 1, schedules: records });
232
229
  }
233
230
  catch (err) {
234
231
  console.error(`[plugin-schedule] persist failed (${pluginDir}):`, err);
@@ -8,13 +8,29 @@
8
8
  * <dataDir>/plugin-backups/<id>-<ts>/ 覆盖安装前的旧版本快照(保留最近 N 份)
9
9
  */
10
10
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, cpSync, writeFileSync } from "node:fs";
11
- import { join } from "node:path";
11
+ import { join, resolve, dirname } from "node:path";
12
+ import { fileURLToPath } from "node:url";
12
13
  import { execFile } from "node:child_process";
13
14
  import { pick } from "./i18n.js";
14
15
  import { parseInstallSpec, manifestCandidateUrls } from "./plugin-install-spec.js";
15
16
  const PLUGIN_ID_RE = /^[A-Za-z0-9_-]+$/;
16
17
  /** 保留的备份份数(超出删除最旧的)。 */
17
18
  export const BACKUP_KEEP = 3;
19
+ const here = dirname(fileURLToPath(import.meta.url));
20
+ /**
21
+ * 解析宿主随包根目录(优先环境变量 PI_WEB_PKG_ROOT,次之向上查找包含 package.json 的祖先目录)。
22
+ */
23
+ export function resolveDefaultPkgRoot() {
24
+ if (process.env.PI_WEB_PKG_ROOT && existsSync(process.env.PI_WEB_PKG_ROOT)) {
25
+ return process.env.PI_WEB_PKG_ROOT;
26
+ }
27
+ const candidates = [resolve(here, ".."), resolve(here, "..", ".."), resolve(here, "..", "..", "..")];
28
+ for (const c of candidates) {
29
+ if (existsSync(join(c, "package.json")))
30
+ return c;
31
+ }
32
+ return undefined;
33
+ }
18
34
  /** 默认执行器:execFile 直跑 git(不经 shell),15s 超时。 */
19
35
  export const execGit = (cmd, args) => new Promise((resolve) => {
20
36
  execFile(cmd, args, { timeout: 15_000, encoding: "utf8" }, (err, stdout, stderr) => {
@@ -151,25 +167,98 @@ export async function resolveRemoteSha(spec, exec = execGit) {
151
167
  const sha = res.stdout.match(/^([0-9a-f]{40,64})\s+HEAD/m)?.[1] ?? null;
152
168
  return sha ? sha.slice(0, 12) : null;
153
169
  }
154
- /** 简易数字 semver 比较:>0 代表 a 比 b 新。 */
170
+ /**
171
+ * 语义化版本(SemVer)比较器:
172
+ * - 剥离前缀 `v` / `V` 及首尾空白;
173
+ * - 剥离 `+` 后的构建元数据(build metadata 在优先级比较中被忽略);
174
+ * - 分离主要数字版本与预发布标识(prerelease,如 -beta.1);
175
+ * - 支持任意段数的数字比对;
176
+ * - 符合 SemVer 规范:正式版 > 预发布版(例如 1.0.0 > 1.0.0-beta.1);
177
+ * - 返回值:>0 代表 a 比 b 新;<0 代表 a 比 b 旧;0 代表版本一致。
178
+ */
155
179
  export function compareVersions(a, b) {
156
- const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
157
- const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
158
- for (let i = 0; i < 3; i++) {
159
- const x = pa[i] ?? 0;
160
- const y = pb[i] ?? 0;
180
+ const clean = (s) => String(s ?? "")
181
+ .trim()
182
+ .replace(/^[vV]/, "")
183
+ .split("+")[0]
184
+ .trim();
185
+ const cleanA = clean(a);
186
+ const cleanB = clean(b);
187
+ if (cleanA === cleanB)
188
+ return 0;
189
+ if (!cleanA && !cleanB)
190
+ return 0;
191
+ if (!cleanA)
192
+ return -1;
193
+ if (!cleanB)
194
+ return 1;
195
+ // 拆分主版本段与 prerelease 段
196
+ const dashA = cleanA.indexOf("-");
197
+ const mainAStr = dashA >= 0 ? cleanA.slice(0, dashA) : cleanA;
198
+ const preA = dashA >= 0 ? cleanA.slice(dashA + 1) : null;
199
+ const dashB = cleanB.indexOf("-");
200
+ const mainBStr = dashB >= 0 ? cleanB.slice(0, dashB) : cleanB;
201
+ const preB = dashB >= 0 ? cleanB.slice(dashB + 1) : null;
202
+ // 比较主版本数字段
203
+ const segsA = mainAStr.split(".").map((n) => parseInt(n, 10) || 0);
204
+ const segsB = mainBStr.split(".").map((n) => parseInt(n, 10) || 0);
205
+ const maxLen = Math.max(segsA.length, segsB.length);
206
+ for (let i = 0; i < maxLen; i++) {
207
+ const x = segsA[i] ?? 0;
208
+ const y = segsB[i] ?? 0;
161
209
  if (x !== y)
162
210
  return x - y;
163
211
  }
212
+ // 主版本数字段全相同时,比较 prerelease(正式版高于任何 prerelease 版)
213
+ if (preA === null && preB !== null)
214
+ return 1;
215
+ if (preA !== null && preB === null)
216
+ return -1;
217
+ if (preA !== null && preB !== null) {
218
+ const partsA = preA.split(".");
219
+ const partsB = preB.split(".");
220
+ const maxPreLen = Math.max(partsA.length, partsB.length);
221
+ for (let i = 0; i < maxPreLen; i++) {
222
+ const pa = partsA[i];
223
+ const pb = partsB[i];
224
+ if (pa === undefined)
225
+ return -1;
226
+ if (pb === undefined)
227
+ return 1;
228
+ if (pa === pb)
229
+ continue;
230
+ const isNumA = /^\d+$/.test(pa);
231
+ const isNumB = /^\d+$/.test(pb);
232
+ if (isNumA && isNumB) {
233
+ const diff = parseInt(pa, 10) - parseInt(pb, 10);
234
+ if (diff !== 0)
235
+ return diff;
236
+ }
237
+ else if (isNumA && !isNumB) {
238
+ return -1; // 数字标识优先级低于非数字标识
239
+ }
240
+ else if (!isNumA && isNumB) {
241
+ return 1;
242
+ }
243
+ else {
244
+ return pa.localeCompare(pb);
245
+ }
246
+ }
247
+ }
164
248
  return 0;
165
249
  }
166
250
  /** 判断某个插件是否属于随包维护的内置插件(官方插件)。 */
167
251
  export function isBuiltinPlugin(id, source, builtinCatalogPath) {
168
252
  if (source && /xing-shuyin\/pi-web-ui\/plugins\//i.test(source))
169
253
  return true;
170
- if (builtinCatalogPath && existsSync(builtinCatalogPath)) {
254
+ const catPath = builtinCatalogPath ??
255
+ (() => {
256
+ const pkgRoot = resolveDefaultPkgRoot();
257
+ return pkgRoot ? join(pkgRoot, "plugins", "catalog.json") : undefined;
258
+ })();
259
+ if (catPath && existsSync(catPath)) {
171
260
  try {
172
- const raw = JSON.parse(readFileSync(builtinCatalogPath, "utf8"));
261
+ const raw = JSON.parse(readFileSync(catPath, "utf8"));
173
262
  if (Array.isArray(raw)) {
174
263
  return raw.some((e) => e?.id === id);
175
264
  }
@@ -180,9 +269,23 @@ export function isBuiltinPlugin(id, source, builtinCatalogPath) {
180
269
  }
181
270
  return false;
182
271
  }
183
- /** 探测远端 manifest 中的版本号(通过 raw.githubusercontent.com 或注入的 fetcher)。 */
272
+ /** 探测远端或本地源 manifest 中的版本号(本地路径 / raw.githubusercontent.com / 注入的 fetcher)。 */
184
273
  async function fetchRemoteVersion(source, fetcher) {
185
274
  const spec = parseInstallSpec(source);
275
+ if (spec.kind === "path" && spec.normalized) {
276
+ const manifestFile = join(spec.normalized, "manifest.json");
277
+ if (existsSync(manifestFile)) {
278
+ try {
279
+ const data = JSON.parse(readFileSync(manifestFile, "utf8"));
280
+ if (typeof data?.version === "string" && data.version.trim()) {
281
+ return data.version.trim();
282
+ }
283
+ }
284
+ catch {
285
+ /* ignore */
286
+ }
287
+ }
288
+ }
186
289
  const urls = manifestCandidateUrls(spec);
187
290
  if (urls.length === 0)
188
291
  return null;
@@ -212,6 +315,8 @@ export async function checkPluginUpdates(dataDir, exec = execGit,
212
315
  /** error 字段文案语言(默认英文);调用方可传 () => getLang() 实现跟随。 */
213
316
  lang, opts) {
214
317
  const l = lang?.() ?? "en";
318
+ const effectivePkgRoot = opts?.pkgRoot ?? resolveDefaultPkgRoot();
319
+ const effectiveCatalogPath = opts?.builtinCatalogPath ?? (effectivePkgRoot ? join(effectivePkgRoot, "plugins", "catalog.json") : undefined);
215
320
  const pluginsDir = join(dataDir, "plugins");
216
321
  let names = [];
217
322
  try {
@@ -235,23 +340,27 @@ lang, opts) {
235
340
  localSha = readFileSync(join(dir, ".pi-git-sha"), "utf8").trim() || null;
236
341
  }
237
342
  catch {
238
- localSha = null; // 无 sha 记录 → 保守认为可更新(不知道装了哪个版本)
343
+ localSha = null; // 无 sha 记录
239
344
  }
240
345
  let name;
241
346
  let version;
242
347
  try {
243
348
  const m = JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8"));
244
349
  name = m.name;
245
- version = m.version;
350
+ if (typeof m.version === "string" && m.version.trim()) {
351
+ version = m.version.trim();
352
+ }
246
353
  }
247
354
  catch {
248
355
  /* 坏 manifest:仍报告 */
249
356
  }
250
- const builtin = isBuiltinPlugin(n, source, opts?.builtinCatalogPath);
357
+ const parsedSpec = parseInstallSpec(source);
358
+ const isSubpath = Boolean(parsedSpec.subpath);
359
+ const builtin = isBuiltinPlugin(n, source, effectiveCatalogPath);
251
360
  let latestVersion = null;
252
- // 本地开发模式下,如果宿主包自带 plugins/<id>/manifest.json,可直接读本地最新版本号
253
- if (opts?.pkgRoot) {
254
- const localPkgManifest = join(opts.pkgRoot, "plugins", n, "manifest.json");
361
+ // 随包或本地开发模式:若宿主包自带 plugins/<id>/manifest.json,可直接读随包最新版本号
362
+ if (effectivePkgRoot) {
363
+ const localPkgManifest = join(effectivePkgRoot, "plugins", n, "manifest.json");
255
364
  if (existsSync(localPkgManifest)) {
256
365
  try {
257
366
  const rawPkg = JSON.parse(readFileSync(localPkgManifest, "utf8"));
@@ -264,15 +373,18 @@ lang, opts) {
264
373
  }
265
374
  }
266
375
  }
267
- // 若本地未取到最新版本号,则尝试通过 fetcher 探测远端 manifest.json
268
- if (!latestVersion) {
269
- try {
270
- latestVersion = await fetchRemoteVersion(source, opts?.fetcher);
271
- }
272
- catch {
273
- latestVersion = null;
376
+ // 尝试通过 fetcher 探测远端 manifest.json,若拿到了更高版本则采用更高者
377
+ try {
378
+ const remoteVer = await fetchRemoteVersion(source, opts?.fetcher);
379
+ if (remoteVer) {
380
+ if (!latestVersion || compareVersions(remoteVer, latestVersion) > 0) {
381
+ latestVersion = remoteVer;
382
+ }
274
383
  }
275
384
  }
385
+ catch {
386
+ /* ignore */
387
+ }
276
388
  let remoteSha = null;
277
389
  let error;
278
390
  try {
@@ -282,27 +394,28 @@ lang, opts) {
282
394
  error = err instanceof Error ? err.message : String(err);
283
395
  remoteSha = null;
284
396
  }
285
- if (!remoteSha && !latestVersion && !error)
286
- error = pick(l, "无法检查(非 git 源或 git 不可用)", "Cannot check (non-git source or git unavailable)", "pluginupdate.cannot.check");
287
397
  let updatable = false;
288
398
  if (latestVersion && version) {
289
- const cmp = compareVersions(latestVersion, version);
290
- if (cmp > 0) {
291
- updatable = true;
292
- }
293
- else if (cmp === 0 && remoteSha && localSha) {
294
- updatable = localSha !== remoteSha;
295
- }
296
- else if (cmp === 0 && remoteSha && !localSha) {
297
- updatable = true;
298
- }
299
- }
300
- else if (remoteSha) {
301
- updatable = !localSha || localSha !== remoteSha;
399
+ // 两端都有版本号:版本号是唯一权威事实源。
400
+ // 严格大于才判定为有更新(cmp > 0)。
401
+ // 相同或更低(cmp <= 0)坚决判定为无更新,彻底避免主仓库无关 commit 触发的假阳性误报。
402
+ updatable = compareVersions(latestVersion, version) > 0;
302
403
  }
303
404
  else if (latestVersion && !version) {
405
+ // 远端有版本号但本地无版本号:视为可更新
304
406
  updatable = true;
305
407
  }
408
+ else if (!isSubpath && remoteSha) {
409
+ // 仅在未能获取最新版本号且为独立仓库源(非子目录)时,才回退到基于 git commit SHA 比对
410
+ updatable = !localSha || localSha !== remoteSha;
411
+ }
412
+ if (!updatable && !latestVersion && !remoteSha && !error) {
413
+ error = pick(l, "无法检查(非 git 源或 git 不可用)", "Cannot check (non-git source or git unavailable)", "pluginupdate.cannot.check");
414
+ }
415
+ else if (!updatable && isSubpath && !latestVersion && !error) {
416
+ // 子目录源插件未能获取远端清单时,不凭仓库根 SHA 误报,而是提示未能获取清单
417
+ error = pick(l, "无法检查(未能获取远端插件清单)", "Cannot check (failed to fetch remote plugin manifest)", "pluginupdate.subpath.manifest.failed");
418
+ }
306
419
  out.push({
307
420
  id: n,
308
421
  name,
@@ -275,6 +275,8 @@ export const UI_SLOTS = new Set([
275
275
  "goalbar.actions",
276
276
  "notice.actions",
277
277
  "modal.dialog",
278
+ "sidebar.left",
279
+ "sidebar.right",
278
280
  ]);
279
281
  /** manifest 里可以写更自然的简写(作者少踩坑):解析时映射到完整 slot 名。 */
280
282
  export const UI_SLOT_ALIASES = {
@@ -285,6 +287,11 @@ export const UI_SLOT_ALIASES = {
285
287
  rightpanel: "rightpanel.tabs",
286
288
  settings: "settings.pages",
287
289
  modal: "modal.dialog",
290
+ sidebar: "sidebar.left",
291
+ "sidebar.left": "sidebar.left",
292
+ "sidebar.right": "sidebar.right",
293
+ leftbar: "sidebar.left",
294
+ rightbar: "sidebar.right",
288
295
  };
289
296
  /** 合法的条目种类(缺省 action;settings.pages 缺省 page)。 */
290
297
  export const UI_KINDS = new Set([
@@ -1473,7 +1480,11 @@ export class PluginManager {
1473
1480
  params: freezeParams(req.params),
1474
1481
  conversationId: req.conversationId,
1475
1482
  };
1476
- for (const p of this.loaded.values()) {
1483
+ // #489:对 this.loaded 取快照再迭代——下面的 withGuardTimeout 是 await 点,
1484
+ // 期间插件 reload/uninstall 会原地 clear 同一个 Map(实例不变),Map 迭代器
1485
+ // 立即 done → 剩余插件的 pre 守卫一次都不被求值 → fail-open 直接放行。
1486
+ const loadedSnapshot = [...this.loaded.values()];
1487
+ for (const p of loadedSnapshot) {
1477
1488
  for (const h of p.preGuards ?? []) {
1478
1489
  let raw;
1479
1490
  try {
@@ -1510,7 +1521,10 @@ export class PluginManager {
1510
1521
  result: req.result,
1511
1522
  conversationId: req.conversationId,
1512
1523
  };
1513
- for (const p of this.loaded.values()) {
1524
+ // #489:同 evaluateToolPre——await 点期间 reload 原地 clear Map 会让迭代器
1525
+ // 提前终止,取快照迭代保证本轮请求遍历的是发起时的插件集合。
1526
+ const postLoadedSnapshot = [...this.loaded.values()];
1527
+ for (const p of postLoadedSnapshot) {
1514
1528
  for (const h of p.postGuards ?? []) {
1515
1529
  let raw;
1516
1530
  try {
@@ -1763,11 +1777,14 @@ export class PluginManager {
1763
1777
  }
1764
1778
  return out;
1765
1779
  }
1766
- /** 当前全部插件注册的 AI 工具(扁平化,按插件 id 稳定排序)。 */
1780
+ /** 当前全部插件注册的 AI 工具(扁平化,按插件 id 稳定排序,附带所属 pluginId)。 */
1767
1781
  getAgentTools() {
1768
1782
  const out = [];
1769
- for (const table of [...this.agentTools.values()].sort())
1770
- out.push(...table.values());
1783
+ for (const [pid, table] of [...this.agentTools.entries()].sort(([a], [b]) => a.localeCompare(b))) {
1784
+ for (const tool of table.values()) {
1785
+ out.push({ ...tool, pluginId: pid });
1786
+ }
1787
+ }
1771
1788
  return out;
1772
1789
  }
1773
1790
  /** 注册一个供 AI 调用的工具;重名拒绝并返回空操作注销函数。
@@ -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 = 20;
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();