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
@@ -16,6 +16,9 @@
16
16
  * all share one conversation list per project.
17
17
  * PI_CODING_AGENT_DIR pi config dir (auth/models/skills) — passed to the SDK
18
18
  */
19
+ // MUST be imported before the SDK is loaded so on-disk patches apply cleanly.
20
+ import "./patch-remote-catalog.js";
21
+ import "./patch-turn-end-boundary.js";
19
22
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
20
23
  import { stat } from "node:fs/promises";
21
24
  import { createServer, request as proxyRequest } from "node:http";
@@ -53,7 +56,7 @@ import { syncPluginCatalog } from "./plugin-catalog-sync.js";
53
56
  import { McpBridge } from "./mcp-bridge.js";
54
57
  import { createMcpHotReload } from "./mcp-hot-reload.js";
55
58
  import { createHostMetricsSampler } from "./host-metrics.js";
56
- import { SchedulerStore } from "./scheduler-tasks.js";
59
+ import { SchedulerStore, SchedulerValidationError } from "./scheduler-tasks.js";
57
60
  import { initHttpProxy } from "./http-proxy.js";
58
61
  import { globalLspPool } from "./lsp-tool.js";
59
62
  import { buildPiWebTokenCookie, decodeCookieToken, isTlsRequest } from "./auth-cookie.js";
@@ -86,6 +89,13 @@ function cliFlag(name) {
86
89
  return undefined;
87
90
  }
88
91
  const PORT = Number(cliFlag("--port") ?? process.env.PI_WEB_PORT ?? 8787);
92
+ // #506:直启路径与 bin 层对齐做端口校验——`--port abc` / `PI_WEB_PORT=abc` 之前会
93
+ // listen(NaN) 被 Node 当 0 绑到随机端口,且日志打印 http://localhost:NaN。
94
+ if (!Number.isInteger(PORT) || PORT <= 0 || PORT > 65535) {
95
+ console.error(`无效端口: ${cliFlag("--port") ?? process.env.PI_WEB_PORT} (--port / PI_WEB_PORT)`);
96
+ console.error(`Invalid port: ${cliFlag("--port") ?? process.env.PI_WEB_PORT} (--port / PI_WEB_PORT)`);
97
+ process.exit(1);
98
+ }
89
99
  const CWD = resolve(cliFlag("--cwd") ?? process.env.PI_WEB_CWD ?? process.cwd());
90
100
  const DATA_DIR = resolve(cliFlag("--data-dir") ?? process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web"));
91
101
  // The data dir is where the control socket, client state, plugins, themes and
@@ -973,6 +983,8 @@ httpServer.on("upgrade", (req, socket, head) => {
973
983
  // closing sockets, sleep/wake, network partitions). Also broadcasts lightweight
974
984
  // server host metrics (CPU and memory usage) sampled every ~2s.
975
985
  const HEARTBEAT_INTERVAL_MS = 2_000;
986
+ /** 全局广播与心跳推送背压阈值(256KB):socket 积压时丢弃广播消息,防用户态无界堆积(issue #460)。 */
987
+ const BROADCAST_BACKPRESSURE_MAX_BYTES = 262_144;
976
988
  const sampleHostMetrics = createHostMetricsSampler();
977
989
  const heartbeatTimer = setInterval(() => {
978
990
  let message = { type: "heartbeat" };
@@ -985,8 +997,40 @@ const heartbeatTimer = setInterval(() => {
985
997
  if (wss.clients.size > 0) {
986
998
  const payload = JSON.stringify(message);
987
999
  for (const ws of wss.clients) {
988
- if (ws.readyState === WebSocket.OPEN) {
989
- ws.send(payload);
1000
+ if (ws.readyState !== WebSocket.OPEN)
1001
+ continue;
1002
+ const tracked = ws;
1003
+ // 半开连接死连接回收(issue #460):连续无活动/无 pong 则 terminate
1004
+ if (tracked.isAlive === false) {
1005
+ tracked.missedPings = (tracked.missedPings || 0) + 1;
1006
+ if (tracked.missedPings >= 3) {
1007
+ try {
1008
+ ws.terminate?.();
1009
+ }
1010
+ catch { }
1011
+ continue;
1012
+ }
1013
+ }
1014
+ else {
1015
+ tracked.missedPings = 0;
1016
+ }
1017
+ tracked.isAlive = false;
1018
+ try {
1019
+ ws.ping();
1020
+ }
1021
+ catch {
1022
+ try {
1023
+ ws.terminate?.();
1024
+ }
1025
+ catch { }
1026
+ continue;
1027
+ }
1028
+ // 广播背压:缓冲积压严重时跳过心跳推送
1029
+ if (ws.bufferedAmount <= BROADCAST_BACKPRESSURE_MAX_BYTES) {
1030
+ try {
1031
+ ws.send(payload);
1032
+ }
1033
+ catch { }
990
1034
  }
991
1035
  }
992
1036
  }
@@ -1269,6 +1313,8 @@ function pushSchedulerTasks() {
1269
1313
  for (const client of wss.clients) {
1270
1314
  if (client.readyState !== WebSocket.OPEN)
1271
1315
  continue;
1316
+ if (client.bufferedAmount > BROADCAST_BACKPRESSURE_MAX_BYTES)
1317
+ continue;
1272
1318
  try {
1273
1319
  client.send(payload);
1274
1320
  }
@@ -1287,6 +1333,8 @@ function pushNoticeToAll(level, text, textEn) {
1287
1333
  for (const client of wss.clients) {
1288
1334
  if (client.readyState !== WebSocket.OPEN)
1289
1335
  continue;
1336
+ if (client.bufferedAmount > BROADCAST_BACKPRESSURE_MAX_BYTES)
1337
+ continue;
1290
1338
  try {
1291
1339
  client.send(payload);
1292
1340
  }
@@ -1532,6 +1580,13 @@ function serializeShared(msg) {
1532
1580
  return s;
1533
1581
  }
1534
1582
  wss.on("connection", (ws) => {
1583
+ const tracked = ws;
1584
+ tracked.isAlive = true;
1585
+ tracked.missedPings = 0;
1586
+ ws.on("pong", () => {
1587
+ tracked.isAlive = true;
1588
+ tracked.missedPings = 0;
1589
+ });
1535
1590
  // Count attached sockets (the control socket reports REAL sockets, not
1536
1591
  // cached client-session objects).
1537
1592
  service.noteSocketOpen();
@@ -1633,11 +1688,24 @@ wss.on("connection", (ws) => {
1633
1688
  return;
1634
1689
  }
1635
1690
  switch (msg.type) {
1636
- case "prompt":
1691
+ case "prompt": {
1692
+ const hasAttach = Boolean(msg.attachments && msg.attachments.length > 0);
1693
+ if (!msg.text?.trim() && !hasAttach) {
1694
+ send({
1695
+ type: "notice",
1696
+ level: "warning",
1697
+ text: "发送已忽略:提示词为空且未附带文件或上下文引用。",
1698
+ textEn: "Prompt ignored: text is empty and no attachments were provided.",
1699
+ });
1700
+ break;
1701
+ }
1637
1702
  void cs.prompt(msg.text, msg.attachments, msg.queue);
1638
1703
  break;
1704
+ }
1639
1705
  case "queue_remove":
1640
- cs.removeQueued(msg.kind, msg.text, msg.index);
1706
+ // #491:removeQueued 是 async——dispatch 是 fire-and-forget,缺 void 时
1707
+ // 内部抛错即 unhandledRejection,而全仓没有兜底 handler,Node ≥15 直接崩进程。
1708
+ void cs.removeQueued(msg.kind, msg.text, msg.index);
1641
1709
  break;
1642
1710
  case "draft_update":
1643
1711
  cs.saveDraft?.(msg.sessionId, msg.text, msg.ts);
@@ -1672,12 +1740,6 @@ wss.on("connection", (ws) => {
1672
1740
  case "rollback_session":
1673
1741
  void cs.rollbackSession?.(msg.messageId, msg.conversationId, msg.restoreWorkspace);
1674
1742
  break;
1675
- case "cycle_model":
1676
- void cs.cycleModel();
1677
- break;
1678
- case "cycle_thinking":
1679
- cs.cycleThinking();
1680
- break;
1681
1743
  case "get_state":
1682
1744
  // Always a FULL snapshot: the client is (re)connecting or detected
1683
1745
  // a rev/seq gap — it needs an authoritative state to rebuild from.
@@ -1697,6 +1759,9 @@ wss.on("connection", (ws) => {
1697
1759
  send({ type: "tool_info", name: msg.name, found: false, unsupported: true });
1698
1760
  }
1699
1761
  break;
1762
+ case "get_compacted_messages":
1763
+ void cs.getCompactedMessages?.(msg.compactionMessageId, msg.conversationId);
1764
+ break;
1700
1765
  case "list_sessions":
1701
1766
  void cs.refreshSessions();
1702
1767
  break;
@@ -1723,12 +1788,19 @@ wss.on("connection", (ws) => {
1723
1788
  void cs.persistConversation(msg.id);
1724
1789
  }
1725
1790
  break;
1791
+ case "pin_conversation":
1792
+ if (typeof cs.setConversationPinned === "function") {
1793
+ void cs.setConversationPinned(msg.id, msg.pinned);
1794
+ }
1795
+ break;
1796
+ case "pin_session":
1797
+ if (typeof cs.pinSession === "function") {
1798
+ void cs.pinSession(msg.path, msg.pinned);
1799
+ }
1800
+ break;
1726
1801
  case "dismiss_finished_subagents":
1727
1802
  void cs.dismissFinishedSubagents(msg.parentId);
1728
1803
  break;
1729
- case "subagent_handoff":
1730
- void cs.handoffSubagent?.(msg.fromRunId, msg.toRunId, msg.payload);
1731
- break;
1732
1804
  case "switch_session":
1733
1805
  void cs.switchSession(msg.path);
1734
1806
  break;
@@ -2019,6 +2091,7 @@ wss.on("connection", (ws) => {
2019
2091
  reviewModel: msg.reviewModel,
2020
2092
  maxRounds: msg.maxRounds,
2021
2093
  locked: msg.locked,
2094
+ execModel: msg.execModel,
2022
2095
  });
2023
2096
  break;
2024
2097
  case "clear_goal":
@@ -2036,6 +2109,7 @@ wss.on("connection", (ws) => {
2036
2109
  reviewModel: msg.reviewModel,
2037
2110
  maxRounds: msg.maxRounds,
2038
2111
  locked: msg.locked,
2112
+ execModel: msg.execModel,
2039
2113
  });
2040
2114
  break;
2041
2115
  case "get_settings":
@@ -2207,6 +2281,9 @@ wss.on("connection", (ws) => {
2207
2281
  done: async (ok, info) => {
2208
2282
  if (ok) {
2209
2283
  await reloadPluginsAndPush(jobLang);
2284
+ if (typeof cs?.checkPluginUpdates === "function") {
2285
+ void cs.checkPluginUpdates(false);
2286
+ }
2210
2287
  const isZh = jobLang() === "zh";
2211
2288
  const actionLabel = jobAction === "uninstall"
2212
2289
  ? isZh
@@ -2448,6 +2525,28 @@ wss.on("connection", (ws) => {
2448
2525
  case "plan_update":
2449
2526
  cs.updatePlan?.(msg.steps, msg.activeStepId, msg.conversationId);
2450
2527
  break;
2528
+ case "plan_step_update":
2529
+ cs.updatePlanStep?.(msg.stepId, msg.patch, msg.conversationId);
2530
+ break;
2531
+ case "plan_step_delete":
2532
+ cs.deletePlanStep?.(msg.stepId, msg.conversationId);
2533
+ break;
2534
+ case "plan_step_add":
2535
+ cs.addPlanStep?.(msg.step, msg.afterStepId, msg.conversationId);
2536
+ break;
2537
+ case "plan_clean_handoff":
2538
+ void cs.planCleanHandoff?.(msg.steps, msg.prompt);
2539
+ break;
2540
+ case "set_plan_mode":
2541
+ // 计划模式(只规划不实施):会话级开关,热生效(提示词重建 + 工具硬闸门)。
2542
+ void cs.setPlanMode?.(msg.enabled, msg.conversationId);
2543
+ break;
2544
+ case "set_delegate_mode":
2545
+ // 审查者模式(自动委派):会话级开关,默认关。开启后本对话只审阅,
2546
+ // 用户 prompt 由服务端转给常驻落盘执行对话。DSH 引擎的实现在
2547
+ // setDelegateMode 里直接拒(无 customTools 注册面 → 闸门无处可挂)。
2548
+ void cs.setDelegateMode?.(msg.enabled, msg.conversationId);
2549
+ break;
2451
2550
  case "page_response":
2452
2551
  // 浏览器(page-picker 扩展经前端)对 browser_page 的回包:恢复挂起的
2453
2552
  // pageCall;id 不匹配(超时后迟到/页面刷新)由 resolvePageCall 静默忽略。
@@ -2498,11 +2597,12 @@ wss.on("connection", (ws) => {
2498
2597
  scheduler.upsert(msg.task);
2499
2598
  }
2500
2599
  catch (err) {
2600
+ const isVal = err instanceof SchedulerValidationError;
2501
2601
  send({
2502
2602
  type: "notice",
2503
2603
  level: "error",
2504
- text: `保存定时任务失败:${err.message}`,
2505
- textEn: `Failed to save scheduled task: ${err.message}`,
2604
+ text: `保存定时任务失败:${isVal ? err.messageZh : err.message}`,
2605
+ textEn: `Failed to save scheduled task: ${isVal ? err.messageEn : err.message}`,
2506
2606
  });
2507
2607
  }
2508
2608
  break;
@@ -2566,6 +2666,8 @@ wss.on("connection", (ws) => {
2566
2666
  service: SERVICE_INFO ?? undefined,
2567
2667
  });
2568
2668
  ws.on("message", (data) => {
2669
+ tracked.isAlive = true;
2670
+ tracked.missedPings = 0;
2569
2671
  let msg;
2570
2672
  try {
2571
2673
  msg = JSON.parse(data.toString());
@@ -136,10 +136,6 @@ export function launchOrigin() {
136
136
  cached ??= detectLaunchOrigin();
137
137
  return cached;
138
138
  }
139
- /** 仅测试用:清掉缓存。 */
140
- export function resetLaunchOriginCache() {
141
- cached = null;
142
- }
143
139
  /** 下发给浏览器的服务信息(null = 没有 supervisor,界面不提供「重启服务」)。 */
144
140
  export function toServiceInfo(origin) {
145
141
  if (!origin.supervisor)
@@ -13,9 +13,10 @@
13
13
  * tolerable. Manually dropped <dataDir>/locales/*.json files work too
14
14
  * (offline installs) — the list is read from disk.
15
15
  */
16
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
16
+ import { existsSync, readFileSync, readdirSync, rmSync } from "node:fs";
17
17
  import { join } from "node:path";
18
18
  import { extractServerStrings, registerServerStrings, unregisterServerStrings } from "./i18n.js";
19
+ import { writeJsonAtomicSync } from "./atomic-file.js";
19
20
  /** Packs available for download (alphabetical by code). */
20
21
  export const LOCALE_PACKS = [
21
22
  { code: "de", nativeName: "Deutsch" },
@@ -188,11 +189,8 @@ export async function installPack(dataDir, code, opts = {}) {
188
189
  const v = validatePack(data, code);
189
190
  if (!v.ok)
190
191
  throw new Error(`invalid pack ${code}: ${v.error}`);
191
- mkdirSync(join(dataDir, "locales"), { recursive: true });
192
192
  const file = packPath(dataDir, code);
193
- const tmp = `${file}.${process.pid}.tmp`;
194
- writeFileSync(tmp, JSON.stringify(v.pack));
195
- renameSync(tmp, file);
193
+ writeJsonAtomicSync(file, v.pack, 0);
196
194
  return { code, nativeName: v.pack.nativeName, version: v.pack.version };
197
195
  }
198
196
  catch (e) {
@@ -19,6 +19,7 @@ import { delimiter, extname, isAbsolute, join, relative, resolve, sep } from "no
19
19
  import { pathToFileURL, fileURLToPath } from "node:url";
20
20
  import { defineTool } from "@earendil-works/pi-coding-agent";
21
21
  import { Type } from "typebox";
22
+ import { pick } from "./i18n.js";
22
23
  export const LSP_TOOL_NAME = "lsp";
23
24
  export const LSP_SYMBOL_KINDS = {
24
25
  1: "File",
@@ -694,13 +695,22 @@ export async function getLiveLspDiagnostics(absPath, cwd) {
694
695
  });
695
696
  return `⚠️ Post-edit Diagnostics (${errors.length} error${errors.length > 1 ? "s" : ""}):\n${lines.join("\n")}`;
696
697
  }
698
+ /** 文本大纲与 details.symbols 共用的展平符号数上限(顶层与子级一并计数)。 */
699
+ const DOCUMENT_SYMBOL_OUTLINE_CAP = 300;
700
+ /**
701
+ * details.symbols 的序列化体积预算(字符数)。details 随会话持久且整体 ≤64KB,
702
+ * 超限会被整条丢弃(见 serialize.ts 的 TOOL_DETAILS_CAP);符号名/签名极长的
703
+ * 极端文件(生成的 .d.ts、protobuf 产物)下 300 个符号也可能撑爆,故按实际
704
+ * 序列化体积逐步收紧上限(预算留足余量给 count/truncated 等包装字段)。
705
+ */
706
+ const DOCUMENT_SYMBOL_DETAILS_CHAR_BUDGET = 60_000;
697
707
  /**
698
708
  * 递归格式化 DocumentSymbol 列表为缩进的符号大纲树(做条数上限保护)
699
709
  */
700
710
  function formatDocumentSymbols(symbols, indent = "", lines = []) {
701
711
  for (const sym of symbols) {
702
- if (lines.length >= 300) {
703
- lines.push(`${indent}• ... [Truncated: outline exceeds 300 symbols]`);
712
+ if (lines.length >= DOCUMENT_SYMBOL_OUTLINE_CAP) {
713
+ lines.push(`${indent}• ... [Truncated: outline exceeds ${DOCUMENT_SYMBOL_OUTLINE_CAP} symbols]`);
704
714
  break;
705
715
  }
706
716
  const kind = LSP_SYMBOL_KINDS[sym.kind] || `Kind(${sym.kind})`;
@@ -716,6 +726,56 @@ function formatDocumentSymbols(symbols, indent = "", lines = []) {
716
726
  }
717
727
  return lines;
718
728
  }
729
+ /**
730
+ * 与 formatDocumentSymbols 同口径的结构化裁剪:按展平后的符号数(先序遍历,
731
+ * 顶层与子级一并计数)截断到 cap 个。只在展平序列的边界上裁剪,保留符号的
732
+ * 父子结构保持一致 —— 父被裁掉则整棵子树不再出现;有符号被裁时 truncated 置真。
733
+ * 未触发截断时原样返回(不拷贝,避免大对象无谓重建)。
734
+ */
735
+ function capDocumentSymbolTree(symbols, cap) {
736
+ let count = 0;
737
+ let truncated = false;
738
+ const walk = (list) => {
739
+ const kept = [];
740
+ let changed = false;
741
+ for (const sym of list) {
742
+ if (count >= cap) {
743
+ truncated = true;
744
+ break;
745
+ }
746
+ count += 1;
747
+ if (Array.isArray(sym.children) && sym.children.length > 0) {
748
+ const sub = walk(sym.children);
749
+ if (sub.changed || sub.kept.length !== sym.children.length) {
750
+ changed = true;
751
+ kept.push({ ...sym, children: sub.kept });
752
+ continue;
753
+ }
754
+ }
755
+ kept.push(sym);
756
+ }
757
+ return { kept, changed };
758
+ };
759
+ const { kept } = walk(symbols);
760
+ return { capped: truncated ? kept : symbols, truncated };
761
+ }
762
+ /**
763
+ * details.symbols 的最终裁剪:先与文本大纲同口径按展平计数截到 300;再用实际
764
+ * 序列化体积校验 —— details 随会话持久且整体 ≤64KB,超限整条丢弃(见
765
+ * serialize.ts 的 TOOL_DETAILS_CAP),符号名/签名极长的极端文件下按半数逐步
766
+ * 收紧展平上限,保证结构化大纲真的能进快照而不是被整条丢掉。
767
+ */
768
+ function capDocumentSymbolsForDetails(symbols) {
769
+ let cap = DOCUMENT_SYMBOL_OUTLINE_CAP;
770
+ let res = capDocumentSymbolTree(symbols, cap);
771
+ let size = JSON.stringify(res.capped).length;
772
+ while (size > DOCUMENT_SYMBOL_DETAILS_CHAR_BUDGET && cap > 1) {
773
+ cap = Math.floor(cap / 2);
774
+ res = capDocumentSymbolTree(symbols, cap);
775
+ size = JSON.stringify(res.capped).length;
776
+ }
777
+ return res;
778
+ }
719
779
  /**
720
780
  * 递归单趟查找符号(两遍扫描:先严格精确匹配,未命中再执行大小写忽略回退,避免遮蔽后续精确符号;支持 containerName 点分路径)
721
781
  */
@@ -793,8 +853,10 @@ function findDefaultSourceFileForLsp(cwd) {
793
853
  }
794
854
  export function makeLspTool(options) {
795
855
  const cwd = options.cwd;
856
+ const getLang = options.lang ?? (() => "en");
796
857
  return defineTool({
797
858
  name: LSP_TOOL_NAME,
859
+ promptSnippet: "IDE-grade semantic analysis (LSP) across the workspace: definition, references, hover, diagnostics, symbols, impact check",
798
860
  label: "LSP code intelligence",
799
861
  description: `IDE-grade semantic analysis (LSP) across the workspace. Actions:
800
862
  - \`definition\`: definition of the symbol at \`line\`/\`character\` in \`path\` (file, line, snippet).
@@ -807,16 +869,18 @@ export function makeLspTool(options) {
807
869
  - \`cascade\`: impact check for \`path\` — report diagnostics of files referencing it.
808
870
  Lines are 1-indexed.`,
809
871
  parameters: Type.Object({
810
- action: Type.Union([
811
- Type.Literal("definition"),
812
- Type.Literal("references"),
813
- Type.Literal("hover"),
814
- Type.Literal("diagnostics"),
815
- Type.Literal("documentSymbol"),
816
- Type.Literal("read_symbol"),
817
- Type.Literal("workspaceSymbol"),
818
- Type.Literal("cascade"),
819
- ], {
872
+ action: Type.Unsafe({
873
+ type: "string",
874
+ enum: [
875
+ "definition",
876
+ "references",
877
+ "hover",
878
+ "diagnostics",
879
+ "documentSymbol",
880
+ "read_symbol",
881
+ "workspaceSymbol",
882
+ "cascade",
883
+ ],
820
884
  description: "The LSP operation to perform.",
821
885
  }),
822
886
  path: Type.Optional(Type.String({
@@ -835,13 +899,16 @@ Lines are 1-indexed.`,
835
899
  description: "1-indexed column/character position (defaults to 1).",
836
900
  })),
837
901
  timeout: Type.Optional(Type.Number({
838
- description: "Timeout in seconds (defaults to 15).",
902
+ minimum: 1,
903
+ maximum: 120,
904
+ description: "Timeout in seconds (1-120, defaults to 15).",
839
905
  })),
840
906
  allowInstall: Type.Optional(Type.Boolean({
841
907
  description: "Install the missing language server into ~/.pi-web/lsp-servers (user-space, no sudo). Default false: the tool returns an installHint instead.",
842
908
  })),
843
909
  }),
844
910
  async execute(_callId, params, _signal, _onUpdate, _ctx) {
911
+ const L = getLang();
845
912
  const action = params.action;
846
913
  let targetPath = params.path;
847
914
  if (!targetPath && action === "workspaceSymbol") {
@@ -851,7 +918,7 @@ Lines are 1-indexed.`,
851
918
  content: [
852
919
  {
853
920
  type: "text",
854
- text: `Error: Could not automatically detect a primary project source file to route language server. Please provide 'path' (pointing to any source file in the project, e.g. path='src/index.ts') to select the language server.`,
921
+ text: pick(L, "错误:无法自动检测项目主要源码文件以路由语言服务器。请提供 'path' 参数(指向项目中任意源码文件,例如 path='src/index.ts')。", "Error: Could not automatically detect a primary project source file to route language server. Please provide 'path' (pointing to any source file in the project, e.g. path='src/index.ts') to select the language server."),
855
922
  },
856
923
  ],
857
924
  details: { ok: false, error: "Missing path: cannot route language server" },
@@ -860,14 +927,19 @@ Lines are 1-indexed.`,
860
927
  }
861
928
  if (!targetPath) {
862
929
  return {
863
- content: [{ type: "text", text: `Error: 'path' parameter is required for action '${action}'.` }],
930
+ content: [
931
+ {
932
+ type: "text",
933
+ text: pick(L, `错误:执行操作 '${action}' 时必须提供 'path' 参数。`, `Error: 'path' parameter is required for action '${action}'.`),
934
+ },
935
+ ],
864
936
  details: { ok: false, error: "Missing path parameter" },
865
937
  };
866
938
  }
867
939
  const absPath = isAbsolute(targetPath) ? targetPath : resolve(cwd, targetPath);
868
940
  const line = typeof params.line === "number" ? Math.max(1, params.line) : 1;
869
941
  const character = typeof params.character === "number" ? Math.max(1, params.character) : 1;
870
- const timeoutMs = (params.timeout ?? 15) * 1000;
942
+ const timeoutMs = Math.max(1000, Math.min(120_000, Math.floor((params.timeout ?? 15) * 1000)));
871
943
  const rel = relative(cwd, absPath);
872
944
  if (rel === ".." ||
873
945
  rel.startsWith(".." + sep) ||
@@ -875,13 +947,23 @@ Lines are 1-indexed.`,
875
947
  rel.startsWith("..\\") ||
876
948
  isAbsolute(rel)) {
877
949
  return {
878
- content: [{ type: "text", text: `Error: Path traversal denied: ${targetPath} is outside workspace.` }],
950
+ content: [
951
+ {
952
+ type: "text",
953
+ text: pick(L, `错误:路径穿越已被拒绝:${targetPath} 超出了工作区范围。`, `Error: Path traversal denied: ${targetPath} is outside workspace.`),
954
+ },
955
+ ],
879
956
  details: { ok: false, error: "Path traversal denied" },
880
957
  };
881
958
  }
882
959
  if (!existsSync(absPath)) {
883
960
  return {
884
- content: [{ type: "text", text: `Error: File not found: ${targetPath}` }],
961
+ content: [
962
+ {
963
+ type: "text",
964
+ text: pick(L, `错误:文件不存在:${targetPath}`, `Error: File not found: ${targetPath}`),
965
+ },
966
+ ],
885
967
  details: { ok: false, error: "File not found" },
886
968
  };
887
969
  }
@@ -890,7 +972,7 @@ Lines are 1-indexed.`,
890
972
  content: [
891
973
  {
892
974
  type: "text",
893
- text: `Error: 'symbol' parameter is required for 'read_symbol' action (e.g. symbol="parseConfig" or "ClassName.methodName").`,
975
+ text: pick(L, `错误:执行 'read_symbol' 操作必须提供 'symbol' 参数(例如 symbol="parseConfig" 或 "ClassName.methodName")。`, `Error: 'symbol' parameter is required for 'read_symbol' action (e.g. symbol="parseConfig" or "ClassName.methodName").`),
894
976
  },
895
977
  ],
896
978
  details: { ok: false, error: "Missing symbol parameter" },
@@ -901,7 +983,7 @@ Lines are 1-indexed.`,
901
983
  content: [
902
984
  {
903
985
  type: "text",
904
- text: `Error: 'query' parameter cannot be empty for 'workspaceSymbol' action. Please provide a search term (e.g. query='User' or 'Router').`,
986
+ text: pick(L, `错误:执行 'workspaceSymbol' 操作时 'query' 参数不能为空。请提供搜索词(例如 query='User' 或 'Router')。`, `Error: 'query' parameter cannot be empty for 'workspaceSymbol' action. Please provide a search term (e.g. query='User' or 'Router').`),
905
987
  },
906
988
  ],
907
989
  details: { ok: false, error: "Empty query parameter" },
@@ -1062,8 +1144,10 @@ Lines are 1-indexed.`,
1062
1144
  };
1063
1145
  }
1064
1146
  const lines = formatDocumentSymbols(symbols);
1065
- // details 随会话持久且整体 ≤64KB(超限整条丢弃):symbols 与文本大纲同口径截断
1066
- const detailsSymbols = symbols.length > 300 ? symbols.slice(0, 300) : symbols;
1147
+ // details 随会话持久且整体 ≤64KB(超限整条丢弃):symbols 与文本大纲同口径
1148
+ // 截断 —— 按展平后的符号数(先序遍历,顶层与子级一并计数)截到 300,保留
1149
+ // 符号维持父子结构;再按序列化体积兜底收紧,极端文件下保证 details 进得了快照。
1150
+ const { capped: detailsSymbols, truncated: symbolsTruncated } = capDocumentSymbolsForDetails(symbols);
1067
1151
  return {
1068
1152
  content: [
1069
1153
  {
@@ -1071,7 +1155,7 @@ Lines are 1-indexed.`,
1071
1155
  text: `Symbols in ${targetPath} (${symbols.length} top-level):\n${lines.join("\n")}`,
1072
1156
  },
1073
1157
  ],
1074
- details: { ok: true, count: symbols.length, symbols: detailsSymbols },
1158
+ details: { ok: true, count: symbols.length, symbols: detailsSymbols, truncated: symbolsTruncated },
1075
1159
  };
1076
1160
  }
1077
1161
  if (action === "read_symbol") {
@@ -1216,12 +1300,14 @@ Lines are 1-indexed.`,
1216
1300
  const normalizePath = (p) => (process.platform === "win32" ? p.toLowerCase() : p);
1217
1301
  // 1. 收集种子位置:给了 line/character 就只查那个符号;否则查全部顶层符号。
1218
1302
  const seeds = [];
1303
+ let seedTotal = 0; // 顶层符号总数(截断前),超出 MAX_SEEDS 时在输出尾部标明
1219
1304
  if (typeof params.line === "number") {
1220
1305
  seeds.push({ line: line - 1, character: character - 1 });
1221
1306
  }
1222
1307
  else {
1223
1308
  const symResult = await client.request("textDocument/documentSymbol", { textDocument: { uri } }, timeoutMs);
1224
1309
  const topSymbols = Array.isArray(symResult) ? symResult : [];
1310
+ seedTotal = topSymbols.length;
1225
1311
  for (const sym of topSymbols.slice(0, MAX_SEEDS)) {
1226
1312
  const pos = sym.selectionRange?.start ?? sym.range?.start ?? sym.location?.range?.start;
1227
1313
  if (pos && typeof pos.line === "number") {
@@ -1264,6 +1350,10 @@ Lines are 1-indexed.`,
1264
1350
  depPaths.add(refPath);
1265
1351
  }
1266
1352
  }
1353
+ // 只分析排序后的前 MAX_DEPENDENTS 个引用方,但计数必须如实上报:文本与
1354
+ // details 都带截断前的真实总数,超出部分用尾注标明(对照 references 动作
1355
+ // 的 "... and N more references" 口径),避免模型误以为清单是完整的。
1356
+ const totalReferencing = depPaths.size;
1267
1357
  const dependents = [...depPaths].sort().slice(0, MAX_DEPENDENTS);
1268
1358
  if (dependents.length === 0) {
1269
1359
  return {
@@ -1324,7 +1414,7 @@ Lines are 1-indexed.`,
1324
1414
  }
1325
1415
  impacted.sort((a, b) => b.errors - a.errors || b.warnings - a.warnings);
1326
1416
  const out = [];
1327
- out.push(`Impact cascade for ${targetPath}: ${dependents.length} referencing file(s), ${impacted.length} with findings.`);
1417
+ out.push(`Impact cascade for ${targetPath}: ${totalReferencing} referencing file(s), ${impacted.length} with findings.`);
1328
1418
  for (const item of impacted) {
1329
1419
  out.push(`• ${item.path} — ${item.errors} error(s), ${item.warnings} warning(s)`);
1330
1420
  for (const d of item.diagnostics.slice(0, 5)) {
@@ -1344,6 +1434,14 @@ Lines are 1-indexed.`,
1344
1434
  if (notReported.length > 0) {
1345
1435
  out.push(`Diagnostics not reported in time (${notReported.length}, server may still be analyzing): ${notReported.slice(0, 5).join(", ")}${notReported.length > 5 ? ", ..." : ""}`);
1346
1436
  }
1437
+ // 截断尾注:与 references 动作的 "... and N more references" 同口径,
1438
+ // 让模型知道被丢弃的引用方/种子符号既不在 clean 也不在 notReported 里。
1439
+ if (totalReferencing > dependents.length) {
1440
+ out.push(`... and ${totalReferencing - dependents.length} more not analyzed`);
1441
+ }
1442
+ if (seedTotal > MAX_SEEDS) {
1443
+ out.push(`... and ${seedTotal - MAX_SEEDS} more top-level symbols not analyzed`);
1444
+ }
1347
1445
  return {
1348
1446
  content: [{ type: "text", text: out.join("\n") }],
1349
1447
  details: {
@@ -1352,6 +1450,7 @@ Lines are 1-indexed.`,
1352
1450
  clean,
1353
1451
  notReported,
1354
1452
  referencedFiles: dependents.map((p) => relative(cwd, p).replace(/\\/g, "/")),
1453
+ referencedFilesTotal: totalReferencing,
1355
1454
  },
1356
1455
  };
1357
1456
  }
@@ -3,10 +3,12 @@
3
3
  */
4
4
  import { getServerBlock, pick } from "../../i18n.js";
5
5
  const NOTIFY_GUIDANCE_ZH = [
6
- "- [[notify:<级别>:<内容>]] 仅向用户显示一个非打断性提醒,不会进入正文。级别为 info|warning|success|error。",
6
+ // #499:guidance 只能承诺协议真实支持的级别——success 从未存在于 notice 协议
7
+ // (protocol.ts 仅 info|warning|error),按指引用 success 会被静默降级成 info。
8
+ "- [[notify:<级别>:<内容>]] 仅向用户显示一个非打断性提醒,不会进入正文。级别为 info|warning|error。",
7
9
  ];
8
10
  const NOTIFY_GUIDANCE_EN = [
9
- "- [[notify:<level>:<message>]] shows a non-interruptive notice to the user only; it never enters the reply text. Levels: info|warning|success|error.",
11
+ "- [[notify:<level>:<message>]] shows a non-interruptive notice to the user only; it never enters the reply text. Levels: info|warning|error.",
10
12
  ];
11
13
  /** 语言感知的 notify guidance(issue #91):en 用英译、zh 用中文,默认英文。 */
12
14
  export function getNotifyGuidance(lang = "en") {
@@ -10,7 +10,6 @@
10
10
  * 不需要额外状态。
11
11
  */
12
12
  import { getServerBlock, pick } from "../../i18n.js";
13
- export const RENAME_NAMESPACE = "conv";
14
13
  function extractTitle(token) {
15
14
  // args[0] 是主标题;kwargs 兼容 text/name/title
16
15
  const fromArgs = token.args.join(" ").trim();