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
@@ -1889,7 +1889,9 @@ lang, options) {
1889
1889
  }),
1890
1890
  execute: async (_id, p) => {
1891
1891
  const lang = getLang();
1892
- const info = terminals.create(p.terminalId, p.cwd ?? cwd, p.cols ?? 120, p.rows ?? 40, cwd, p.terminalId);
1892
+ const safeCols = Math.max(2, Math.min(500, Math.floor(p.cols ?? 120)));
1893
+ const safeRows = Math.max(2, Math.min(200, Math.floor(p.rows ?? 40)));
1894
+ const info = terminals.create(p.terminalId, p.cwd ?? cwd, safeCols, safeRows, cwd, p.terminalId);
1893
1895
  const infoJson = JSON.stringify(info);
1894
1896
  if (!info)
1895
1897
  throw new Error(pick(lang, `创建终端失败:${p.terminalId}`, `Failed to create terminal: ${p.terminalId}`, "terminals.create.failed", { "p.terminalId": p.terminalId }));
@@ -1977,10 +1979,12 @@ lang, options) {
1977
1979
  }),
1978
1980
  execute: async (_id, p, signal) => {
1979
1981
  const lang = getLang();
1980
- const cursor = p.cursor ?? 0;
1981
- if (p.waitMs)
1982
- await terminals.waitForOutput(p.terminalId, cursor, p.waitMs, signal);
1983
- const read = terminals.read(p.terminalId, cursor, p.maxBytes ?? 20000);
1982
+ const cursor = Math.max(0, Math.floor(p.cursor ?? 0));
1983
+ const waitMs = p.waitMs !== undefined ? Math.max(0, Math.min(120_000, Math.floor(p.waitMs))) : undefined;
1984
+ const maxBytes = Math.max(1, Math.min(100_000, Math.floor(p.maxBytes ?? 20000)));
1985
+ if (waitMs)
1986
+ await terminals.waitForOutput(p.terminalId, cursor, waitMs, signal);
1987
+ const read = terminals.read(p.terminalId, cursor, maxBytes);
1984
1988
  if (!read)
1985
1989
  throw new Error(pick(lang, `终端不存在:${p.terminalId}`, `Terminal not found: ${p.terminalId}`, "terminals.read.not.found", { "p.terminalId": p.terminalId }));
1986
1990
  return result(JSON.stringify(read), read);
@@ -2019,7 +2023,8 @@ lang, options) {
2019
2023
  return result(JSON.stringify({ applicable: false, reason: why }), { applicable: false });
2020
2024
  }
2021
2025
  const cursor = p.cursor ?? terminals.endCursor(p.terminalId) ?? 0;
2022
- const wait = await terminals.waitForCompletion(p.terminalId, p.maxWaitMs ?? 300_000, signal, cursor);
2026
+ const maxWait = Math.max(100, Math.min(600_000, Math.floor(p.maxWaitMs ?? 300_000)));
2027
+ const wait = await terminals.waitForCompletion(p.terminalId, maxWait, signal, cursor);
2023
2028
  const read = terminals.read(p.terminalId, cursor, 20_000);
2024
2029
  const outputTail = read?.data ? stripAnsi(read.data).slice(-4000) : "";
2025
2030
  return result(JSON.stringify({ ...wait, outputTail }), {
@@ -0,0 +1,59 @@
1
+ /** 校验引用元数据,保留原文的换行和缩进。 */
2
+ export function readTextQuote(value) {
3
+ if (!value || typeof value !== "object")
4
+ return null;
5
+ const q = value;
6
+ if (typeof q.text !== "string" ||
7
+ !q.text.trim() ||
8
+ typeof q.messageId !== "string" ||
9
+ !q.messageId ||
10
+ typeof q.role !== "string" ||
11
+ !q.role)
12
+ return null;
13
+ return {
14
+ text: q.text,
15
+ messageId: q.messageId,
16
+ role: q.role,
17
+ ...(typeof q.sessionId === "string" && q.sessionId ? { sessionId: q.sessionId } : {}),
18
+ };
19
+ }
20
+ /** JSON 字符串保留原文边界,引用中的换行不参与外层标记解析。 */
21
+ export function formatTextQuote(quote) {
22
+ return `<quoted-text>\n${JSON.stringify(quote)}\n</quoted-text>`;
23
+ }
24
+ export function parseTextQuote(text) {
25
+ const match = /^<quoted-text>\n([^\n]*)\n<\/quoted-text>$/.exec(text);
26
+ if (!match)
27
+ return null;
28
+ try {
29
+ return readTextQuote(JSON.parse(match[1]));
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ export function formatQuotedPrompt(text, quotes) {
36
+ return [text, ...quotes.map(formatTextQuote)].join("\n\n");
37
+ }
38
+ /** 排队项把引用与提问一起保存;展示时只拆分末尾的完整引用块。 */
39
+ export function splitQuotedPrompt(value) {
40
+ let text = value;
41
+ const quotes = [];
42
+ while (true) {
43
+ const match = /(?:^|\n\n)(<quoted-text>\n[^\n]*\n<\/quoted-text>)$/.exec(text);
44
+ const quote = match ? parseTextQuote(match[1]) : null;
45
+ if (!match || !quote)
46
+ break;
47
+ quotes.unshift(quote);
48
+ text = text.slice(0, match.index);
49
+ }
50
+ return { text, quotes };
51
+ }
52
+ export function messageTextWithQuotes(message) {
53
+ const text = message.content
54
+ .map((block) => (block.type === "text" && typeof block.text === "string" ? block.text : ""))
55
+ .join("\n");
56
+ const values = message.details?.quotes;
57
+ const quotes = Array.isArray(values) ? values.map(readTextQuote).filter((q) => q !== null) : [];
58
+ return formatQuotedPrompt(text, quotes);
59
+ }
@@ -187,21 +187,27 @@ export function looksLikeText(buf) {
187
187
  }
188
188
  return control / Math.max(text.length, 1) < 0.02;
189
189
  }
190
- /** Decode bytes: strict UTF-8 first, falling back to GBK (Windows legacy
191
- * Chinese files), then latin1 as a last resort — so previews and inline
192
- * attachments never show mojibake for GBK/GB2312 encoded files. */
190
+ /** Decode bytes: strict UTF-8 first (retrying with up to 3 truncated tail
191
+ * bytes removed for excerpt/preview buffers cut mid-character), falling back
192
+ * to GBK (Windows legacy Chinese files), then latin1 as a last resort — so
193
+ * previews and inline attachments never show mojibake for GBK/GB2312 files
194
+ * nor for truncated UTF-8 files (issue #392). */
193
195
  export function decodeText(buf) {
194
- try {
195
- return new TextDecoder("utf-8", { fatal: true }).decode(buf);
196
- }
197
- catch {
196
+ for (let cut = 0; cut < 4; cut++) {
197
+ const slice = cut === 0 ? buf : buf.subarray(0, Math.max(0, buf.length - cut));
198
198
  try {
199
- return new TextDecoder("gbk").decode(buf);
199
+ return new TextDecoder("utf-8", { fatal: true }).decode(slice);
200
200
  }
201
201
  catch {
202
- return buf.toString("latin1");
202
+ // Tail byte might be cut mid-character; try trimming up to 3 bytes.
203
203
  }
204
204
  }
205
+ try {
206
+ return new TextDecoder("gbk").decode(buf);
207
+ }
208
+ catch {
209
+ return buf.toString("latin1");
210
+ }
205
211
  }
206
212
  /** Sniff an image MIME type from magic bytes (extension is only a hint).
207
213
  * Returns null when the bytes don't look like a known raster format —
@@ -25,8 +25,11 @@ export const TERMINAL_TOOL_NAMES = [
25
25
  "terminal_read",
26
26
  "terminal_wait",
27
27
  ];
28
- /** 第一方子代理工具(定义见 subagents.ts,逐个可关)。 */
29
- export const SUBAGENT_TOOL_NAMES = [
28
+ /** 第一方子代理工具(定义见 subagents.ts,统一为单 action 工具)。 */
29
+ export const SUBAGENT_TOOL_NAME = "subagent";
30
+ export const SUBAGENT_TOOL_NAMES = [SUBAGENT_TOOL_NAME];
31
+ /** 旧版 8 个独立子代理工具名(持久化配置迁移用)。 */
32
+ export const LEGACY_SUBAGENT_TOOL_NAMES = [
30
33
  "subagent_spawn",
31
34
  "subagent_get_result",
32
35
  "subagent_steer",
@@ -251,7 +254,7 @@ export const AGENT_TOOL_CATALOG = [
251
254
  },
252
255
  ];
253
256
  /** SDK 核心内置工具名(可被门控显式禁用或被预设白名单过滤)。 */
254
- export const CORE_BUILTIN_TOOL_NAMES = ["bash", "read", "edit", "write"];
257
+ export const CORE_BUILTIN_TOOL_NAMES = ["bash", "read", "edit", "write", "powershell", "ls", "grep", "find"];
255
258
  export function isCoreBuiltinTool(name) {
256
259
  return CORE_BUILTIN_TOOL_NAMES.includes(name);
257
260
  }
@@ -268,7 +271,11 @@ export function normalizeDisabledAgentTools(v) {
268
271
  const out = [];
269
272
  for (const x of v) {
270
273
  // 旧名迁移:markers_list → todo_list(改名前已关闭的用户保持关闭)。
271
- const name = x === LEGACY_MARKERS_LIST_TOOL_NAME ? MARKERS_LIST_TOOL_NAME : x;
274
+ let name = x === LEGACY_MARKERS_LIST_TOOL_NAME ? MARKERS_LIST_TOOL_NAME : x;
275
+ // 旧版子代理工具迁移:任意旧 subagent_* 关闭均迁移为关闭 subagent 工具。
276
+ if (typeof name === "string" && LEGACY_SUBAGENT_TOOL_NAMES.includes(name)) {
277
+ name = SUBAGENT_TOOL_NAME;
278
+ }
272
279
  if (typeof name === "string" && (KNOWN_NAMES.has(name) || isCoreBuiltinTool(name)) && !out.includes(name)) {
273
280
  out.push(name);
274
281
  }
@@ -338,10 +345,19 @@ export function applyAgentToolsGating(session, disabled, preset) {
338
345
  const allNames = session.getAllTools().map((t) => t.name);
339
346
  const names = new Set(allNames);
340
347
  for (const t of AGENT_TOOL_CATALOG) {
341
- if (off.has(t.name))
342
- names.delete(t.name);
343
- else
348
+ if (off.has(t.name)) {
349
+ // issue #481: 若第三方扩展注册了同名工具(如 nicobailon/pi-subagents 的 subagent),
350
+ // 关闭内置工具时允许扩展工具透传,不从活跃名单删除
351
+ const hasExtensionTool = Boolean(session?.extensionRunner
352
+ ?.getAllRegisteredTools?.()
353
+ ?.some((tool) => tool.definition?.name === t.name));
354
+ if (!hasExtensionTool) {
355
+ names.delete(t.name);
356
+ }
357
+ }
358
+ else {
344
359
  names.add(t.name);
360
+ }
345
361
  }
346
362
  for (const core of CORE_BUILTIN_TOOL_NAMES) {
347
363
  if (off.has(core))
@@ -363,7 +379,9 @@ export const PI_AGENT_PRESETS = [
363
379
  trust: "system",
364
380
  isDefault: true,
365
381
  name: "全功能",
382
+ nameEn: "Full access",
366
383
  description: "提供全部可用工具与扩展能力(默认)",
384
+ descriptionEn: "All available tools and extension capabilities (default)",
367
385
  order: 0,
368
386
  },
369
387
  {
@@ -371,7 +389,9 @@ export const PI_AGENT_PRESETS = [
371
389
  trust: "system",
372
390
  isDefault: false,
373
391
  name: "极简模式",
392
+ nameEn: "Minimal",
374
393
  description: "仅保留 bash 与 read;插件工具、技能名录与终端引导同步隐藏",
394
+ descriptionEn: "Keeps only bash and read; plugin tools, skill catalog and terminal guidance are hidden too",
375
395
  order: 1,
376
396
  },
377
397
  {
@@ -379,7 +399,9 @@ export const PI_AGENT_PRESETS = [
379
399
  trust: "system",
380
400
  isDefault: false,
381
401
  name: "代码开发",
402
+ nameEn: "Code development",
382
403
  description: "专注于代码读写与执行(bash, read, edit, write, edit_soft);插件工具与技能名录同步隐藏",
404
+ descriptionEn: "Focused on reading, writing and running code (bash, read, edit, write, edit_soft); plugin tools and skill catalog are hidden too",
383
405
  order: 2,
384
406
  },
385
407
  {
@@ -387,7 +409,9 @@ export const PI_AGENT_PRESETS = [
387
409
  trust: "system",
388
410
  isDefault: false,
389
411
  name: "只读分析",
412
+ nameEn: "Read-only analysis",
390
413
  description: "仅保留只读工具,禁止写操作;插件工具同步隐藏(读写未知,保守处理)",
414
+ descriptionEn: "Keeps only read-only tools and forbids writes; plugin tools are hidden too (read/write unknown, handled conservatively)",
391
415
  order: 3,
392
416
  },
393
417
  {
@@ -395,7 +419,9 @@ export const PI_AGENT_PRESETS = [
395
419
  trust: "system",
396
420
  isDefault: false,
397
421
  name: "纯对话",
422
+ nameEn: "Chat only",
398
423
  description: "无工具问答模式,模型不调用任何工具;插件工具与技能名录同步隐藏",
424
+ descriptionEn: "No-tools Q&A: the model never calls a tool; plugin tools and skill catalog are hidden too",
399
425
  order: 4,
400
426
  },
401
427
  ];
@@ -404,19 +430,45 @@ export const PI_PERMISSION_OPTIONS = [
404
430
  {
405
431
  value: "read-only",
406
432
  name: "只读模式",
433
+ nameEn: "Read Only",
407
434
  description: "禁止所有文件修改(write/edit/edit_soft)及任何非只读操作",
435
+ descriptionEn: "Forbids all file modification (write/edit/edit_soft) and any non-read-only operation",
408
436
  },
409
437
  {
410
438
  value: "workspace-write-never",
411
439
  name: "工作区内修改",
440
+ nameEn: "Workspace Write",
412
441
  description: "仅允许在当前工作区目录下修改文件,工作区外写操作一律拒绝",
442
+ descriptionEn: "Only files under the current workspace may be modified; writes outside it are always denied",
413
443
  },
414
444
  {
415
445
  value: "danger-full-access",
416
446
  name: "完全权限",
447
+ nameEn: "Full access",
417
448
  description: "允许修改任意目录文件及执行全量操作(需要二次确认)",
449
+ descriptionEn: "Allows modifying files anywhere and running any operation (needs a second confirmation)",
418
450
  },
419
451
  ];
452
+ /**
453
+ * 预设/权限文案随界面语言落定:中文界面用服务端默认文案,其它语言用
454
+ * nameEn ?? name(与审批规则的 labelEn/reasonEn、插件的 label/labelEn 同一约定;
455
+ * 其它语言包同理回落英文)。服务端 notice 的 textEn 也走这里,否则英文界面会看到
456
+ * `Switched to preset "全功能"`。
457
+ *
458
+ * lang 是 ServerLang(resolveServerLang 的产物):只有 "zh" 算中文。
459
+ * 本文件保持零依赖,故直接比字符串而不 import server/i18n.js。
460
+ */
461
+ export function localizedName(item, lang, fallback = "") {
462
+ if (!item)
463
+ return fallback;
464
+ return lang === "zh" ? (item.name ?? item.nameEn ?? fallback) : (item.nameEn ?? item.name ?? fallback);
465
+ }
466
+ /** localizedName 的描述版本(description 两边都可缺省 → undefined)。 */
467
+ export function localizedDescription(item, lang) {
468
+ if (!item)
469
+ return undefined;
470
+ return lang === "zh" ? (item.description ?? item.descriptionEn) : (item.descriptionEn ?? item.description);
471
+ }
420
472
  /** 按预设过滤活跃工具名。 */
421
473
  export function filterToolsByPreset(tools, preset) {
422
474
  const all = Array.from(tools);
@@ -437,6 +489,7 @@ export function filterToolsByPreset(tools, preset) {
437
489
  "edit",
438
490
  "edit_soft",
439
491
  "bash",
492
+ "powershell",
440
493
  "terminal_create",
441
494
  "terminal_input",
442
495
  "terminal_close",
@@ -57,3 +57,27 @@ export function installToolOverrides(session, specs) {
57
57
  session._refreshToolRegistry();
58
58
  return [...names];
59
59
  }
60
+ /**
61
+ * 同步 subagent 工具与第三方扩展的同名工具共存(issue #481):
62
+ * 当第三方扩展注册了同名 subagent 时,若用户在设置中关闭/禁用了内置 subagent,
63
+ * 则自动从 session._customTools 中移除内置 subagent,让扩展的同名工具透传生效;
64
+ * 重新启用时则重新注入内置 subagent。
65
+ */
66
+ export function syncSubagentOverride(session, subagentDisabled, builtinSubagentTool) {
67
+ if (!Array.isArray(session._customTools) || typeof session._refreshToolRegistry !== "function")
68
+ return;
69
+ const hasExtensionTool = Boolean(extensionToolDefinition(session, "subagent"));
70
+ if (!hasExtensionTool)
71
+ return;
72
+ const idx = session._customTools.findIndex((t) => t.name === "subagent");
73
+ if (subagentDisabled) {
74
+ if (idx !== -1) {
75
+ session._customTools = session._customTools.filter((t) => t.name !== "subagent");
76
+ session._refreshToolRegistry();
77
+ }
78
+ }
79
+ else if (idx === -1 && builtinSubagentTool) {
80
+ session._customTools = [builtinSubagentTool(), ...session._customTools];
81
+ session._refreshToolRegistry();
82
+ }
83
+ }
@@ -375,29 +375,43 @@ function piCliOnPath() {
375
375
  }
376
376
  return null;
377
377
  }
378
+ /** Read a pi-core package.json's version, or null when absent, not ours, or
379
+ * unreadable. Shared by both resolution strategies below. */
380
+ function readPiCoreVersionAt(pkgPath) {
381
+ if (!existsSync(pkgPath))
382
+ return null;
383
+ try {
384
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
385
+ if (pkg.name === PI_CORE_PACKAGE && pkg.version)
386
+ return pkg.version;
387
+ }
388
+ catch {
389
+ /* unreadable package.json */
390
+ }
391
+ return null;
392
+ }
378
393
  /**
379
- * Read the pi core version from disk: resolve the `pi` bin (typically a
380
- * symlink into <global>/node_modules/<pkg>/dist/bundle/cli.js) and walk up to
381
- * its package.json. FORK-FREE by design — see the note on defaultProbePiCore.
394
+ * <binDir>/node_modules/<pkg>/package.json — the concrete layout a global npm
395
+ * install always uses. This is the FIRST candidate because it is the only one
396
+ * that works on Windows: npm's cmd-shim writes a regular `pi` shell script
397
+ * (not a symlink into the package), so the realpath walk below would start
398
+ * from <prefix> itself and never see the package one level down (issue #533).
382
399
  */
383
- function readPiCoreVersionFromDisk() {
384
- const bin = piCliOnPath();
385
- if (!bin)
386
- return null;
400
+ function versionFromSiblingNodeModules(bin) {
401
+ return readPiCoreVersionAt(join(dirname(bin), "node_modules", ...PI_CORE_PACKAGE.split("/"), "package.json"));
402
+ }
403
+ /**
404
+ * POSIX: `pi` is a symlink into <prefix>/node_modules/<pkg>/dist/bundle/cli.js
405
+ * — realpath it and walk up (≤8 levels) until a package.json names our
406
+ * package. Functionally unchanged from the original probe.
407
+ */
408
+ function versionFromSymlinkWalk(bin) {
387
409
  try {
388
410
  let dir = dirname(realpathSync(bin));
389
411
  for (let i = 0; i < 8; i++) {
390
- const pkgPath = join(dir, "package.json");
391
- if (existsSync(pkgPath)) {
392
- try {
393
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
394
- if (pkg.name === PI_CORE_PACKAGE && pkg.version)
395
- return pkg.version;
396
- }
397
- catch {
398
- /* unreadable package.json — keep walking */
399
- }
400
- }
412
+ const version = readPiCoreVersionAt(join(dir, "package.json"));
413
+ if (version)
414
+ return version;
401
415
  const parent = dirname(dir);
402
416
  if (parent === dir)
403
417
  break;
@@ -409,6 +423,17 @@ function readPiCoreVersionFromDisk() {
409
423
  }
410
424
  return null;
411
425
  }
426
+ /**
427
+ * Read the pi core version from disk: resolve the `pi` bin on PATH, then try
428
+ * the concrete global-npm layout first (Windows) and the POSIX symlink walk
429
+ * second. Exported for unit tests — defaultProbePiCore memoizes its result.
430
+ */
431
+ export function readPiCoreVersionFromDisk() {
432
+ const bin = piCliOnPath();
433
+ if (!bin)
434
+ return null;
435
+ return versionFromSiblingNodeModules(bin) ?? versionFromSymlinkWalk(bin);
436
+ }
412
437
  /**
413
438
  * Default pi core probe: run the globally installed `pi --version`, memoized
414
439
  * machine-wide for PI_PROBE_TTL_MS so repeated collectTargets calls never
@@ -466,25 +491,36 @@ function readVendoredPiCore(agentDir) {
466
491
  return null;
467
492
  }
468
493
  }
494
+ /**
495
+ * 宿主 SDK 及其 peer/内部包(@earendil-works/* / @mariozechner/*):
496
+ * 属于宿主引擎随核心一同分发或由别名提供的 SDK 内部构件,非用户安装的独立扩展组件;
497
+ * 既不应在组件列表重复展示,也不能被 `pi update npm:<name>` 更新(会报 No matching package found,issue #393)。
498
+ */
499
+ export function isHostProvidedPackage(name) {
500
+ return name === PI_CORE_PACKAGE || name.startsWith("@earendil-works/") || name.startsWith("@mariozechner/");
501
+ }
469
502
  /**
470
503
  * Build the full local target list: webui + the pi core + installed packages
471
504
  * + git-source extensions (issue #178) from global and project settings.
472
505
  * The pi core version comes from the CLI probe (injectable for tests), falling
473
506
  * back to the vendored copy under <agentDir>/npm/node_modules. Packages
474
- * listing the core directly are filtered out so the pi-core row wins — never
475
- * two rows for the same package.
507
+ * listing the core directly or host-provided SDK peers are filtered out so the
508
+ * pi-core row wins and host peers are not listed as updatable (issue #393).
476
509
  */
477
510
  export function collectTargets(agentDir, webuiVersion, probePiCore = defaultProbePiCore, opts) {
478
511
  const targets = [{ name: "pi-web-ui", version: webuiVersion, kind: "webui" }];
479
512
  const coreVersion = probePiCore() ?? readVendoredPiCore(agentDir);
480
- if (coreVersion) {
481
- targets.push({
482
- name: PI_CORE_PACKAGE,
483
- version: coreVersion,
484
- kind: "pi-core",
485
- });
486
- }
487
- targets.push(...listInstalledPackages(agentDir).filter((pkg) => pkg.name !== PI_CORE_PACKAGE));
513
+ // issue #533: keep the pi-core row even when the version is undetectable
514
+ // (e.g. unreadable global install) instead of silently dropping it — an
515
+ // honest "could not detect" row beats a missing one. checkAll resolves the
516
+ // key into the item's localized `error`.
517
+ targets.push({
518
+ name: PI_CORE_PACKAGE,
519
+ version: coreVersion ?? "unknown",
520
+ kind: "pi-core",
521
+ ...(coreVersion ? {} : { error: "pluginupdate.piCore.unknown" }),
522
+ });
523
+ targets.push(...listInstalledPackages(agentDir).filter((pkg) => !isHostProvidedPackage(pkg.name)));
488
524
  targets.push(...listGitExtensions(agentDir, opts?.projectCwd));
489
525
  return targets;
490
526
  }
@@ -628,6 +664,22 @@ gitCheck = defaultCheckGitExtension) {
628
664
  while (cursor < targets.length) {
629
665
  const i = cursor++;
630
666
  const t = targets[i];
667
+ if (t.error) {
668
+ // Pre-flagged target (issue #533, e.g. undetectable pi-core
669
+ // version): `error` carries an i18n key resolved here so
670
+ // collectTargets stays language-free. No registry lookup.
671
+ results[i] = {
672
+ name: t.name,
673
+ kind: t.kind,
674
+ current: t.version,
675
+ latest: null,
676
+ latestPublishedAt: null,
677
+ upToDate: false,
678
+ ...(t.source ? { source: t.source } : {}),
679
+ error: pick(l, "无法检测已安装的引擎版本(可能未装全局 pi,或安装方式不标准),可手动检查。", "Could not detect the installed engine version (no global pi, or a non-standard install); check manually.", t.error),
680
+ };
681
+ continue;
682
+ }
631
683
  if (t.kind === "git-extension") {
632
684
  try {
633
685
  if (!t.installDir)
@@ -239,12 +239,16 @@ export function hasPendingWaitSubscription(options) {
239
239
  /**
240
240
  * 纯函数版置换决策:true = 保留(不得 dispose),false = 调用方可释放。
241
241
  * Pure decision core of displaceActive(): true = retain, false = may dispose.
242
- * 顺序与 displaceActive 保持一致:review/wizard → streaming → terminals →
242
+ * 顺序与 displaceActive 保持一致:pin → review/wizard → streaming → terminals →
243
243
  * active subagent run → pending wake → listed+continued(「打开后继续过」的会话也保留)。
244
244
  */
245
245
  export function shouldRetainActive(input) {
246
+ if (input.pinned)
247
+ return true;
246
248
  if (input.reviewing || input.wizardRunning)
247
249
  return true;
250
+ if (input.promptInFlight)
251
+ return true;
248
252
  if (input.streaming || input.compacting)
249
253
  return true;
250
254
  if (input.openTerminals > 0)
@@ -215,14 +215,28 @@ export default function (pi: ExtensionAPI): void {
215
215
  const cwd = opts.cwd ?? ctx.cwd;
216
216
  const url = `http://localhost:${port}`;
217
217
 
218
+ // 探测并向子进程显式透传宿主 Pi SDK 路径(issue #482)
219
+ let hostSdkDir: string | undefined;
220
+ try {
221
+ const resolved = import.meta.resolve?.("@earendil-works/pi-coding-agent");
222
+ if (resolved) {
223
+ let p = resolved.startsWith("file:") ? fileURLToPath(resolved) : resolved;
224
+ if (p.endsWith("index.js") || p.endsWith("index.mjs")) p = dirname(p);
225
+ if (existsSync(join(p, "package.json"))) hostSdkDir = p;
226
+ }
227
+ } catch {}
228
+
218
229
  const env = {
219
230
  ...process.env,
220
231
  PORT: String(port),
221
232
  PI_WEB_PORT: String(port), // server/index.js 读取 PI_WEB_PORT
222
233
  PI_WEB_CWD: cwd,
234
+ ...(hostSdkDir ? { PI_WEB_SDK_DIR: hostSdkDir } : {}),
223
235
  ...(process.env.PI_WEB_DATA_DIR ? {} : { PI_WEB_DATA_DIR: join(cwd, ".pi-web") }),
224
236
  };
225
- const proc = spawn(NODE, [SERVER_ENTRY], {
237
+ const hookPath = join(PKG_ROOT, "dist", "server", "resolve-global-sdk.js");
238
+ const nodeArgs = existsSync(hookPath) ? ["--import", hookPath, SERVER_ENTRY] : [SERVER_ENTRY];
239
+ const proc = spawn(NODE, nodeArgs, {
226
240
  cwd,
227
241
  env,
228
242
  stdio: "ignore",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.96.1",
3
+ "version": "0.98.0",
4
4
  "description": "Just open your browser to get all your work done — browser cockpit for AI coding agents (chat, code, files, terminal, Git in one tab)",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -79,39 +79,35 @@
79
79
  "desktop:dist": "npm run build && npm run build:desktop && electron-builder -c desktop/electron-builder.yml",
80
80
  "format": "prettier --write server/ web/src/ extensions/ scripts/ bin/ desktop/ tests/*.mjs tests/unit/ *.mjs",
81
81
  "format:check": "prettier --check server/ web/src/ extensions/ scripts/ bin/ desktop/ tests/*.mjs tests/unit/ *.mjs",
82
- "lint": "oxlint server/ web/src/ extensions/ scripts/ desktop/",
83
- "lint:fix": "oxlint --fix server/ web/src/ extensions/ scripts/ desktop/"
82
+ "lint": "oxlint server/ web/src/ extensions/ scripts/ bin/ desktop/",
83
+ "lint:fix": "oxlint --fix server/ web/src/ extensions/ scripts/ bin/ desktop/"
84
84
  },
85
85
  "dependencies": {
86
- "@deepseek-ai/dsh-sdk-jsonrpc-server": "^0.1.1-rc.2",
87
- "@deepseek-ai/dsh-sdk-protocol": "^0.1.1-rc.2",
86
+ "@deepseek-ai/dsh-sdk-jsonrpc-server": "^0.1.5-rc.3",
87
+ "@deepseek-ai/dsh-sdk-protocol": "^0.1.5-rc.3",
88
88
  "@earendil-works/pi-coding-agent": ">=0.85.1",
89
- "@xterm/addon-fit": "^0.11.0",
90
- "@xterm/xterm": "^6.0.0",
91
89
  "compression": "^1.8.2",
92
90
  "electron-updater": "^6.8.9",
93
91
  "express": "^5.2.1",
94
- "highlight.js": "^11.12.0",
95
- "html-to-image": "^1.11.13",
96
- "katex": "^0.18.7",
97
92
  "node-pty": "^1.1.0",
98
- "react": "^19.3.0",
99
- "react-dom": "^19.3.0",
100
- "react-icons": "^5.7.0",
101
- "react-markdown": "^10.1.0",
102
- "rehype-highlight": "^7.0.2",
103
- "rehype-katex": "^7.0.1",
104
- "rehype-raw": "^7.0.0",
105
- "rehype-sanitize": "^6.0.0",
106
- "remark-breaks": "^4.0.0",
107
- "remark-gfm": "^4.0.1",
108
- "remark-math": "^6.0.0",
109
93
  "tar": "^7.5.22",
110
94
  "typebox": "^1.3.34",
111
95
  "undici": "^8.9.0",
112
96
  "ws": "^8.21.3",
113
97
  "yauzl": "^3.4.0"
114
98
  },
99
+ "peerDependencies": {
100
+ "@earendil-works/pi-coding-agent": "*",
101
+ "typebox": "*"
102
+ },
103
+ "peerDependenciesMeta": {
104
+ "@earendil-works/pi-coding-agent": {
105
+ "optional": true
106
+ },
107
+ "typebox": {
108
+ "optional": true
109
+ }
110
+ },
115
111
  "devDependencies": {
116
112
  "@types/compression": "^1.8.1",
117
113
  "@types/express": "^5.0.6",
@@ -121,16 +117,32 @@
121
117
  "@types/ws": "^8.18.1",
122
118
  "@types/yauzl": "^3.4.0",
123
119
  "@vitejs/plugin-react": "^6.1.1",
120
+ "@xterm/addon-fit": "^0.11.0",
121
+ "@xterm/xterm": "^6.0.0",
124
122
  "concurrently": "^10.0.5",
125
123
  "cross-env": "^10.1.0",
126
124
  "electron": "^44.4.2",
127
125
  "electron-builder": "^26.15.3",
128
126
  "esbuild": "^0.28.2",
127
+ "highlight.js": "^11.12.0",
128
+ "html-to-image": "^1.11.13",
129
129
  "jsdom": "^30.1.0",
130
+ "katex": "^0.18.7",
130
131
  "mermaid": "^12.0.0",
131
132
  "oxlint": "^1.83.0",
132
133
  "playwright-core": "^1.63.0",
133
134
  "prettier": "^3.9.8",
135
+ "react": "^19.3.0",
136
+ "react-dom": "^19.3.0",
137
+ "react-icons": "^5.7.0",
138
+ "react-markdown": "^10.1.0",
139
+ "rehype-highlight": "^7.0.2",
140
+ "rehype-katex": "^7.0.1",
141
+ "rehype-raw": "^7.0.0",
142
+ "rehype-sanitize": "^6.0.0",
143
+ "remark-breaks": "^4.0.0",
144
+ "remark-gfm": "^4.0.1",
145
+ "remark-math": "^6.0.0",
134
146
  "tsx": "^4.23.13",
135
147
  "typescript": "^7.0.2",
136
148
  "vis-timeline": "^8.5.4",