pi-web-ui 0.97.0 → 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 (80) hide show
  1. package/CHANGELOG.md +97 -1
  2. package/README.md +3 -3
  3. package/README.zh-CN.md +3 -3
  4. package/bin/pi-web-ui.mjs +64 -23
  5. package/dist/server/agent-service.js +637 -145
  6. package/dist/server/approval-rules.js +5 -10
  7. package/dist/server/atomic-file.js +40 -0
  8. package/dist/server/attachments.js +22 -0
  9. package/dist/server/claim-store.js +5 -11
  10. package/dist/server/client-state.js +220 -23
  11. package/dist/server/compact-context-tool.js +3 -1
  12. package/dist/server/compacted-history.js +7 -5
  13. package/dist/server/composer-drafts.js +4 -6
  14. package/dist/server/control-socket.js +0 -40
  15. package/dist/server/delegate-mode.js +6 -12
  16. package/dist/server/dsh/dsh-agent-service.js +32 -30
  17. package/dist/server/dsh/dsh-client.js +45 -9
  18. package/dist/server/dsh/dsh-serialize.js +12 -10
  19. package/dist/server/dsh/dsh-sessions.js +20 -6
  20. package/dist/server/dsh/runtime/goal-rpc.mjs +14 -1
  21. package/dist/server/dsh/runtime/runtime-root.mjs +1 -1
  22. package/dist/server/ensure-bash.js +3 -5
  23. package/dist/server/eval-tool.js +8 -4
  24. package/dist/server/goal-review-gate.js +7 -20
  25. package/dist/server/goal-service.js +49 -5
  26. package/dist/server/index.js +83 -15
  27. package/dist/server/launch-origin.js +0 -4
  28. package/dist/server/locales.js +3 -5
  29. package/dist/server/lsp-tool.js +110 -14
  30. package/dist/server/markers/builtins/notify.js +4 -2
  31. package/dist/server/markers/builtins/rename.js +0 -1
  32. package/dist/server/markers/builtins/todo.js +6 -26
  33. package/dist/server/markers/marker.js +0 -15
  34. package/dist/server/markers/registry.js +0 -3
  35. package/dist/server/markers/store.js +0 -3
  36. package/dist/server/mcp-hot-reload.js +12 -5
  37. package/dist/server/model-admin.js +19 -18
  38. package/dist/server/model-enrich.js +0 -3
  39. package/dist/server/patch-tool.js +21 -6
  40. package/dist/server/permission-preset.js +25 -0
  41. package/dist/server/plan-manager.js +150 -5
  42. package/dist/server/plan-mode.js +58 -20
  43. package/dist/server/plugin-api-catalog.js +2 -0
  44. package/dist/server/plugin-catalog.js +6 -6
  45. package/dist/server/plugin-dom.js +3 -4
  46. package/dist/server/plugin-facilities.js +3 -5
  47. package/dist/server/plugin-grants.js +5 -8
  48. package/dist/server/plugin-permissions.js +4 -6
  49. package/dist/server/plugin-schedule.js +4 -7
  50. package/dist/server/plugin-updater.js +151 -38
  51. package/dist/server/plugins.js +16 -2
  52. package/dist/server/prompt-composer.js +12 -3
  53. package/dist/server/protocol-version.js +1 -1
  54. package/dist/server/resolve-global-sdk.js +8 -0
  55. package/dist/server/schedule-agent-tool.js +5 -1
  56. package/dist/server/scheduler-tasks.js +46 -27
  57. package/dist/server/sdk-origin.js +27 -0
  58. package/dist/server/serialize.js +22 -9
  59. package/dist/server/slash-commands.js +16 -0
  60. package/dist/server/subagent-templates.js +4 -10
  61. package/dist/server/subagents.js +22 -11
  62. package/dist/server/terminals.js +11 -6
  63. package/dist/server/text-quote.js +59 -0
  64. package/dist/server/tool-manager.js +12 -3
  65. package/dist/server/tool-overrides.js +24 -0
  66. package/dist/server/update-check.js +69 -25
  67. package/extensions/webui.ts +15 -1
  68. package/package.json +31 -19
  69. package/plugin-sdk/client-utils.mjs +92 -0
  70. package/plugin-sdk/index.d.ts +21 -0
  71. package/plugin-sdk/index.mjs +2 -0
  72. package/plugins/catalog.json +7 -0
  73. package/web/dist/assets/{TerminalPanel-CITBqQrb.js → TerminalPanel-C88r3r3Q.js} +1 -1
  74. package/web/dist/assets/index-2jDtjHvW.css +1 -0
  75. package/web/dist/assets/index-BjdK7s1M.js +368 -0
  76. package/web/dist/index.html +2 -2
  77. package/web/dist/sw.js +2 -1
  78. package/web/public/sw.js +2 -1
  79. package/web/dist/assets/index-DwToli0J.css +0 -1
  80. package/web/dist/assets/index-oI7UKOqJ.js +0 -368
@@ -1,6 +1,7 @@
1
- const TEXT_CAP = 200_000;
2
- const TOOL_OUTPUT_CAP = 100_000;
3
- const ARGS_CAP = 20_000;
1
+ import { splitQuotedPrompt } from "./text-quote.js";
2
+ export const TEXT_CAP = 200_000;
3
+ export const TOOL_OUTPUT_CAP = 100_000;
4
+ export const ARGS_CAP = 20_000;
4
5
  /**
5
6
  * toolResult 里单张图片进快照的上限(dataUrl 字符数 ≈ 1.5MB 二进制)。
6
7
  * 视口/元素截图随便过;超大整页截图回落成占位文本(模型侧不受影响 —— 图它
@@ -17,10 +18,11 @@ const TOOL_RESULT_IMAGE_MAX = 8;
17
18
  * 前端还得写容错);工具作者应自己封顶(见 present-files-tool.ts 的摘录预算)。
18
19
  */
19
20
  const TOOL_DETAILS_CAP = 64_000;
20
- function truncate(s, cap) {
21
- if (s.length <= cap)
22
- return { text: s, truncated: false };
23
- return { text: `${s.slice(0, cap)}\n\n… [truncated]`, truncated: true };
21
+ export function truncate(s, cap) {
22
+ const str = typeof s === "string" ? s : String(s ?? "");
23
+ if (str.length <= cap)
24
+ return { text: str, truncated: false };
25
+ return { text: `${str.slice(0, cap)}\n\n… [truncated]`, truncated: true };
24
26
  }
25
27
  /**
26
28
  * SDK/工具的图片块 → 前端可直接 <img> 的 UiImageBlock。
@@ -204,13 +206,24 @@ export function serializeMessage(m, seq) {
204
206
  }
205
207
  const id = uiMessageId(m, seq);
206
208
  switch (m.role) {
207
- case "user":
209
+ case "user": {
210
+ const quotes = [];
211
+ const raw = typeof m.content === "string" ? [{ type: "text", text: m.content }] : m.content;
212
+ const content = raw.map((block) => {
213
+ if (block.type !== "text")
214
+ return block;
215
+ const parsed = splitQuotedPrompt(block.text);
216
+ quotes.push(...parsed.quotes);
217
+ return { ...block, text: parsed.text };
218
+ });
208
219
  return {
209
220
  id,
210
221
  role: "user",
211
- content: serializeUserContent(m.content),
222
+ content: serializeUserContent(content),
223
+ ...(quotes.length ? { details: { quotes } } : {}),
212
224
  timestamp: m.timestamp,
213
225
  };
226
+ }
214
227
  case "assistant":
215
228
  return {
216
229
  id,
@@ -38,6 +38,13 @@ export const NATIVE_COMMANDS = [
38
38
  argumentHint: "<路径>",
39
39
  argumentHintEn: "<path>",
40
40
  },
41
+ {
42
+ name: "plan",
43
+ description: "开启或关闭计划模式(/plan [on|off])",
44
+ descriptionEn: "Toggle plan mode (/plan [on|off])",
45
+ argumentHint: "[on|off]",
46
+ argumentHintEn: "[on|off]",
47
+ },
41
48
  {
42
49
  name: "thinking",
43
50
  description: "设置思考强度",
@@ -249,6 +256,15 @@ export class SlashCommandsService {
249
256
  await this.host.setCwd(args);
250
257
  }
251
258
  return true;
259
+ case "plan": {
260
+ const trimmed = args.trim().toLowerCase();
261
+ const current = this.host.getPlanMode ? this.host.getPlanMode() : false;
262
+ const next = trimmed === "on" ? true : trimmed === "off" ? false : !current;
263
+ if (this.host.setPlanMode) {
264
+ await this.host.setPlanMode(next);
265
+ }
266
+ return true;
267
+ }
252
268
  case "thinking": {
253
269
  const ALIAS = {
254
270
  off: "off",
@@ -19,8 +19,8 @@
19
19
  * systemPromptEn 为英文(en 用,缺失回落中文);调用方用 pickTemplateDescription/
20
20
  * pickTemplatePrompt 按语言选用。
21
21
  */
22
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
23
- import { dirname } from "node:path";
22
+ import { readFileSync } from "node:fs";
23
+ import { writeJsonAtomicSync } from "./atomic-file.js";
24
24
  /**
25
25
  * 思考强度取值:与 SDK 的 THINKING_LEVEL_OPTIONS(前端 ModelThinking 的 THINKING_VALUES)
26
26
  * 一致。SDK 未从包根导出该常量,这里照抄一份作输入校验(写错的值一律当未配置)。
@@ -608,10 +608,7 @@ export class SubagentTemplatesStore {
608
608
  }
609
609
  persist() {
610
610
  try {
611
- mkdirSync(dirname(this.filePath), { recursive: true });
612
- const tmp = `${this.filePath}.${process.pid}.tmp`;
613
- writeFileSync(tmp, JSON.stringify(this.templates, null, 2) + "\n");
614
- renameSync(tmp, this.filePath);
611
+ writeJsonAtomicSync(this.filePath, this.templates);
615
612
  }
616
613
  catch {
617
614
  // best effort
@@ -644,10 +641,7 @@ export class SubagentTemplatesStore {
644
641
  saveSeeded(names) {
645
642
  this.seededNames = names;
646
643
  try {
647
- mkdirSync(dirname(this.seededPath()), { recursive: true });
648
- const tmp = `${this.seededPath()}.tmp`;
649
- writeFileSync(tmp, JSON.stringify([...names].sort(), null, 2));
650
- renameSync(tmp, this.seededPath());
644
+ writeJsonAtomicSync(this.seededPath(), [...names].sort());
651
645
  }
652
646
  catch {
653
647
  // best effort
@@ -31,14 +31,22 @@ import { pick } from "./i18n.js";
31
31
  /**
32
32
  * subagent_wait_all 的最长阻塞时间:必须短暂低于工具看门狗(默认 20 分钟,
33
33
  * PI_WEB_TOOL_TIMEOUT_MS 可调),否则看门狗会先中止整个会话而不是让 wait
34
- * 干净地超时返回。取 0.8×看门狗并以 60s 封顶、5s 兜底:看门狗调小(如 <75s)
35
- * 时 60s 下限会反超看门狗造成本末倒置,此时随看门狗缩短,但不低于 5s。
34
+ * 干净地超时返回。取 0.8×看门狗(默认 ~16min,支持 PI_WEB_SUBAGENT_WAIT_CAP_SECONDS 覆盖),
35
+ * 兜底不低于 5s。
36
36
  */
37
- const WAIT_CAP_MS = (() => {
38
- const v = Number(process.env.PI_WEB_TOOL_TIMEOUT_MS);
39
- const watchdog = Number.isFinite(v) && v > 0 ? v : 20 * 60_000;
40
- return Math.max(Math.min(60_000, Math.floor(watchdog * 0.8)), 5_000);
41
- })();
37
+ export function computeWaitCapMs(watchdogMs) {
38
+ const customSecs = Number(process.env.PI_WEB_SUBAGENT_WAIT_CAP_SECONDS);
39
+ if (Number.isFinite(customSecs) && customSecs > 0) {
40
+ return Math.max(Math.floor(customSecs * 1000), 5_000);
41
+ }
42
+ const envWatchdog = Number(process.env.PI_WEB_TOOL_TIMEOUT_MS);
43
+ const watchdog = typeof watchdogMs === "number" && Number.isFinite(watchdogMs) && watchdogMs > 0
44
+ ? watchdogMs
45
+ : Number.isFinite(envWatchdog) && envWatchdog > 0
46
+ ? envWatchdog
47
+ : 20 * 60_000;
48
+ return Math.max(Math.floor(watchdog * 0.8), 5_000);
49
+ }
42
50
  /** 子代理是否已到终态(运行结束、被中止或出错)。wait 工具据此判断
43
51
  * 是否可以取结果;streaming=false 即可(error/canceled 都在快照里带标记)。 */
44
52
  export function isSubagentTerminal(r) {
@@ -93,8 +101,8 @@ export function makeSubagentTool(host, lang, selfConvId) {
93
101
  details,
94
102
  });
95
103
  const currentWaitCapMs = typeof host.getWatchdogTimeoutMs === "function"
96
- ? Math.max(Math.min(60_000, Math.floor(host.getWatchdogTimeoutMs() * 0.8)), 5_000)
97
- : WAIT_CAP_MS;
104
+ ? computeWaitCapMs(host.getWatchdogTimeoutMs())
105
+ : computeWaitCapMs();
98
106
  const capSeconds = Math.floor(currentWaitCapMs / 1000);
99
107
  return defineTool({
100
108
  name: SUBAGENT_TOOL_NAME,
@@ -361,7 +369,10 @@ export function makeSubagentTool(host, lang, selfConvId) {
361
369
  }
362
370
  const allDone = Array.from(wanted).every((id) => {
363
371
  const r = host.getSubagent(id);
364
- return isSubagentTerminal(r);
372
+ // #488:getSubagent 返回 undefined = 已被移出(强行关闭/收尾清理)
373
+ // ——视为终态,与下方报告段「已移出或不存在」口径一致;否则等待
374
+ // 集合含被移出子代理时 allDone 恒 false,必然白等满 timeoutMs。
375
+ return r === undefined || isSubagentTerminal(r);
365
376
  });
366
377
  if (allDone)
367
378
  break;
@@ -534,5 +545,5 @@ function promptRemaining(remaining, lang = "en") {
534
545
  if (remaining.length === 0)
535
546
  return "";
536
547
  const pendingIds = remaining.map(shortId).join(", ");
537
- return pick(lang, `\n未完成:${pendingIds}。可再次调用 subagent_wait_all(或 subagent_steer 补充指令 / subagent_stop 中止)。`, `\nPending: ${pendingIds}. You may call subagent_wait_all again (or subagent_steer to add instructions / subagent_stop to abort).`, "subagents.wait.pending", { pendingIds: pendingIds });
548
+ return pick(lang, `\n未完成:${pendingIds}。可再次调用 subagent(action="wait_all")继续等待耗时任务,或使用 steer 补充指令 / stop 中止。`, `\nPending: ${pendingIds}. You may call subagent (action="wait_all") again to continue waiting for long tasks, or use steer to add instructions / stop to abort.`, "subagents.wait.pending", { pendingIds: pendingIds });
538
549
  }
@@ -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
+ }
@@ -345,10 +345,19 @@ export function applyAgentToolsGating(session, disabled, preset) {
345
345
  const allNames = session.getAllTools().map((t) => t.name);
346
346
  const names = new Set(allNames);
347
347
  for (const t of AGENT_TOOL_CATALOG) {
348
- if (off.has(t.name))
349
- names.delete(t.name);
350
- else
348
+ if (off.has(t.name)) {
349
+ // issue #481: 若第三方扩展注册了同名工具(如 nicobailon/pi-subagents 的 subagent),
350
+ // 关闭内置工具时允许扩展工具透传,不从活跃名单删除
351
+ const hasExtensionTool = Boolean(session?.extensionRunner
352
+ ?.getAllRegisteredTools?.()
353
+ ?.some((tool) => tool.definition?.name === t.name));
354
+ if (!hasExtensionTool) {
355
+ names.delete(t.name);
356
+ }
357
+ }
358
+ else {
351
359
  names.add(t.name);
360
+ }
352
361
  }
353
362
  for (const core of CORE_BUILTIN_TOOL_NAMES) {
354
363
  if (off.has(core))
@@ -57,3 +57,27 @@ export function installToolOverrides(session, specs) {
57
57
  session._refreshToolRegistry();
58
58
  return [...names];
59
59
  }
60
+ /**
61
+ * 同步 subagent 工具与第三方扩展的同名工具共存(issue #481):
62
+ * 当第三方扩展注册了同名 subagent 时,若用户在设置中关闭/禁用了内置 subagent,
63
+ * 则自动从 session._customTools 中移除内置 subagent,让扩展的同名工具透传生效;
64
+ * 重新启用时则重新注入内置 subagent。
65
+ */
66
+ export function syncSubagentOverride(session, subagentDisabled, builtinSubagentTool) {
67
+ if (!Array.isArray(session._customTools) || typeof session._refreshToolRegistry !== "function")
68
+ return;
69
+ const hasExtensionTool = Boolean(extensionToolDefinition(session, "subagent"));
70
+ if (!hasExtensionTool)
71
+ return;
72
+ const idx = session._customTools.findIndex((t) => t.name === "subagent");
73
+ if (subagentDisabled) {
74
+ if (idx !== -1) {
75
+ session._customTools = session._customTools.filter((t) => t.name !== "subagent");
76
+ session._refreshToolRegistry();
77
+ }
78
+ }
79
+ else if (idx === -1 && builtinSubagentTool) {
80
+ session._customTools = [builtinSubagentTool(), ...session._customTools];
81
+ session._refreshToolRegistry();
82
+ }
83
+ }
@@ -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
@@ -485,13 +510,16 @@ export function isHostProvidedPackage(name) {
485
510
  export function collectTargets(agentDir, webuiVersion, probePiCore = defaultProbePiCore, opts) {
486
511
  const targets = [{ name: "pi-web-ui", version: webuiVersion, kind: "webui" }];
487
512
  const coreVersion = probePiCore() ?? readVendoredPiCore(agentDir);
488
- if (coreVersion) {
489
- targets.push({
490
- name: PI_CORE_PACKAGE,
491
- version: coreVersion,
492
- kind: "pi-core",
493
- });
494
- }
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
+ });
495
523
  targets.push(...listInstalledPackages(agentDir).filter((pkg) => !isHostProvidedPackage(pkg.name)));
496
524
  targets.push(...listGitExtensions(agentDir, opts?.projectCwd));
497
525
  return targets;
@@ -636,6 +664,22 @@ gitCheck = defaultCheckGitExtension) {
636
664
  while (cursor < targets.length) {
637
665
  const i = cursor++;
638
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
+ }
639
683
  if (t.kind === "git-extension") {
640
684
  try {
641
685
  if (!t.installDir)
@@ -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.97.0",
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
86
  "@deepseek-ai/dsh-sdk-jsonrpc-server": "^0.1.5-rc.3",
87
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",