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
@@ -56,7 +56,7 @@ import { syncPluginCatalog } from "./plugin-catalog-sync.js";
56
56
  import { McpBridge } from "./mcp-bridge.js";
57
57
  import { createMcpHotReload } from "./mcp-hot-reload.js";
58
58
  import { createHostMetricsSampler } from "./host-metrics.js";
59
- import { SchedulerStore } from "./scheduler-tasks.js";
59
+ import { SchedulerStore, SchedulerValidationError } from "./scheduler-tasks.js";
60
60
  import { initHttpProxy } from "./http-proxy.js";
61
61
  import { globalLspPool } from "./lsp-tool.js";
62
62
  import { buildPiWebTokenCookie, decodeCookieToken, isTlsRequest } from "./auth-cookie.js";
@@ -89,6 +89,13 @@ function cliFlag(name) {
89
89
  return undefined;
90
90
  }
91
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
+ }
92
99
  const CWD = resolve(cliFlag("--cwd") ?? process.env.PI_WEB_CWD ?? process.cwd());
93
100
  const DATA_DIR = resolve(cliFlag("--data-dir") ?? process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web"));
94
101
  // The data dir is where the control socket, client state, plugins, themes and
@@ -976,6 +983,8 @@ httpServer.on("upgrade", (req, socket, head) => {
976
983
  // closing sockets, sleep/wake, network partitions). Also broadcasts lightweight
977
984
  // server host metrics (CPU and memory usage) sampled every ~2s.
978
985
  const HEARTBEAT_INTERVAL_MS = 2_000;
986
+ /** 全局广播与心跳推送背压阈值(256KB):socket 积压时丢弃广播消息,防用户态无界堆积(issue #460)。 */
987
+ const BROADCAST_BACKPRESSURE_MAX_BYTES = 262_144;
979
988
  const sampleHostMetrics = createHostMetricsSampler();
980
989
  const heartbeatTimer = setInterval(() => {
981
990
  let message = { type: "heartbeat" };
@@ -988,8 +997,40 @@ const heartbeatTimer = setInterval(() => {
988
997
  if (wss.clients.size > 0) {
989
998
  const payload = JSON.stringify(message);
990
999
  for (const ws of wss.clients) {
991
- if (ws.readyState === WebSocket.OPEN) {
992
- 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 { }
993
1034
  }
994
1035
  }
995
1036
  }
@@ -1272,6 +1313,8 @@ function pushSchedulerTasks() {
1272
1313
  for (const client of wss.clients) {
1273
1314
  if (client.readyState !== WebSocket.OPEN)
1274
1315
  continue;
1316
+ if (client.bufferedAmount > BROADCAST_BACKPRESSURE_MAX_BYTES)
1317
+ continue;
1275
1318
  try {
1276
1319
  client.send(payload);
1277
1320
  }
@@ -1290,6 +1333,8 @@ function pushNoticeToAll(level, text, textEn) {
1290
1333
  for (const client of wss.clients) {
1291
1334
  if (client.readyState !== WebSocket.OPEN)
1292
1335
  continue;
1336
+ if (client.bufferedAmount > BROADCAST_BACKPRESSURE_MAX_BYTES)
1337
+ continue;
1293
1338
  try {
1294
1339
  client.send(payload);
1295
1340
  }
@@ -1535,6 +1580,13 @@ function serializeShared(msg) {
1535
1580
  return s;
1536
1581
  }
1537
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
+ });
1538
1590
  // Count attached sockets (the control socket reports REAL sockets, not
1539
1591
  // cached client-session objects).
1540
1592
  service.noteSocketOpen();
@@ -1651,7 +1703,9 @@ wss.on("connection", (ws) => {
1651
1703
  break;
1652
1704
  }
1653
1705
  case "queue_remove":
1654
- 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);
1655
1709
  break;
1656
1710
  case "draft_update":
1657
1711
  cs.saveDraft?.(msg.sessionId, msg.text, msg.ts);
@@ -1686,12 +1740,6 @@ wss.on("connection", (ws) => {
1686
1740
  case "rollback_session":
1687
1741
  void cs.rollbackSession?.(msg.messageId, msg.conversationId, msg.restoreWorkspace);
1688
1742
  break;
1689
- case "cycle_model":
1690
- void cs.cycleModel();
1691
- break;
1692
- case "cycle_thinking":
1693
- cs.cycleThinking();
1694
- break;
1695
1743
  case "get_state":
1696
1744
  // Always a FULL snapshot: the client is (re)connecting or detected
1697
1745
  // a rev/seq gap — it needs an authoritative state to rebuild from.
@@ -1745,12 +1793,14 @@ wss.on("connection", (ws) => {
1745
1793
  void cs.setConversationPinned(msg.id, msg.pinned);
1746
1794
  }
1747
1795
  break;
1796
+ case "pin_session":
1797
+ if (typeof cs.pinSession === "function") {
1798
+ void cs.pinSession(msg.path, msg.pinned);
1799
+ }
1800
+ break;
1748
1801
  case "dismiss_finished_subagents":
1749
1802
  void cs.dismissFinishedSubagents(msg.parentId);
1750
1803
  break;
1751
- case "subagent_handoff":
1752
- void cs.handoffSubagent?.(msg.fromRunId, msg.toRunId, msg.payload);
1753
- break;
1754
1804
  case "switch_session":
1755
1805
  void cs.switchSession(msg.path);
1756
1806
  break;
@@ -2231,6 +2281,9 @@ wss.on("connection", (ws) => {
2231
2281
  done: async (ok, info) => {
2232
2282
  if (ok) {
2233
2283
  await reloadPluginsAndPush(jobLang);
2284
+ if (typeof cs?.checkPluginUpdates === "function") {
2285
+ void cs.checkPluginUpdates(false);
2286
+ }
2234
2287
  const isZh = jobLang() === "zh";
2235
2288
  const actionLabel = jobAction === "uninstall"
2236
2289
  ? isZh
@@ -2472,6 +2525,18 @@ wss.on("connection", (ws) => {
2472
2525
  case "plan_update":
2473
2526
  cs.updatePlan?.(msg.steps, msg.activeStepId, msg.conversationId);
2474
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;
2475
2540
  case "set_plan_mode":
2476
2541
  // 计划模式(只规划不实施):会话级开关,热生效(提示词重建 + 工具硬闸门)。
2477
2542
  void cs.setPlanMode?.(msg.enabled, msg.conversationId);
@@ -2532,11 +2597,12 @@ wss.on("connection", (ws) => {
2532
2597
  scheduler.upsert(msg.task);
2533
2598
  }
2534
2599
  catch (err) {
2600
+ const isVal = err instanceof SchedulerValidationError;
2535
2601
  send({
2536
2602
  type: "notice",
2537
2603
  level: "error",
2538
- text: `保存定时任务失败:${err.message}`,
2539
- 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}`,
2540
2606
  });
2541
2607
  }
2542
2608
  break;
@@ -2600,6 +2666,8 @@ wss.on("connection", (ws) => {
2600
2666
  service: SERVICE_INFO ?? undefined,
2601
2667
  });
2602
2668
  ws.on("message", (data) => {
2669
+ tracked.isAlive = true;
2670
+ tracked.missedPings = 0;
2603
2671
  let msg;
2604
2672
  try {
2605
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,6 +853,7 @@ 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,
798
859
  promptSnippet: "IDE-grade semantic analysis (LSP) across the workspace: definition, references, hover, diagnostics, symbols, impact check",
@@ -838,13 +899,16 @@ Lines are 1-indexed.`,
838
899
  description: "1-indexed column/character position (defaults to 1).",
839
900
  })),
840
901
  timeout: Type.Optional(Type.Number({
841
- description: "Timeout in seconds (defaults to 15).",
902
+ minimum: 1,
903
+ maximum: 120,
904
+ description: "Timeout in seconds (1-120, defaults to 15).",
842
905
  })),
843
906
  allowInstall: Type.Optional(Type.Boolean({
844
907
  description: "Install the missing language server into ~/.pi-web/lsp-servers (user-space, no sudo). Default false: the tool returns an installHint instead.",
845
908
  })),
846
909
  }),
847
910
  async execute(_callId, params, _signal, _onUpdate, _ctx) {
911
+ const L = getLang();
848
912
  const action = params.action;
849
913
  let targetPath = params.path;
850
914
  if (!targetPath && action === "workspaceSymbol") {
@@ -854,7 +918,7 @@ Lines are 1-indexed.`,
854
918
  content: [
855
919
  {
856
920
  type: "text",
857
- 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."),
858
922
  },
859
923
  ],
860
924
  details: { ok: false, error: "Missing path: cannot route language server" },
@@ -863,14 +927,19 @@ Lines are 1-indexed.`,
863
927
  }
864
928
  if (!targetPath) {
865
929
  return {
866
- 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
+ ],
867
936
  details: { ok: false, error: "Missing path parameter" },
868
937
  };
869
938
  }
870
939
  const absPath = isAbsolute(targetPath) ? targetPath : resolve(cwd, targetPath);
871
940
  const line = typeof params.line === "number" ? Math.max(1, params.line) : 1;
872
941
  const character = typeof params.character === "number" ? Math.max(1, params.character) : 1;
873
- const timeoutMs = (params.timeout ?? 15) * 1000;
942
+ const timeoutMs = Math.max(1000, Math.min(120_000, Math.floor((params.timeout ?? 15) * 1000)));
874
943
  const rel = relative(cwd, absPath);
875
944
  if (rel === ".." ||
876
945
  rel.startsWith(".." + sep) ||
@@ -878,13 +947,23 @@ Lines are 1-indexed.`,
878
947
  rel.startsWith("..\\") ||
879
948
  isAbsolute(rel)) {
880
949
  return {
881
- 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
+ ],
882
956
  details: { ok: false, error: "Path traversal denied" },
883
957
  };
884
958
  }
885
959
  if (!existsSync(absPath)) {
886
960
  return {
887
- 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
+ ],
888
967
  details: { ok: false, error: "File not found" },
889
968
  };
890
969
  }
@@ -893,7 +972,7 @@ Lines are 1-indexed.`,
893
972
  content: [
894
973
  {
895
974
  type: "text",
896
- 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").`),
897
976
  },
898
977
  ],
899
978
  details: { ok: false, error: "Missing symbol parameter" },
@@ -904,7 +983,7 @@ Lines are 1-indexed.`,
904
983
  content: [
905
984
  {
906
985
  type: "text",
907
- 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').`),
908
987
  },
909
988
  ],
910
989
  details: { ok: false, error: "Empty query parameter" },
@@ -1065,8 +1144,10 @@ Lines are 1-indexed.`,
1065
1144
  };
1066
1145
  }
1067
1146
  const lines = formatDocumentSymbols(symbols);
1068
- // details 随会话持久且整体 ≤64KB(超限整条丢弃):symbols 与文本大纲同口径截断
1069
- 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);
1070
1151
  return {
1071
1152
  content: [
1072
1153
  {
@@ -1074,7 +1155,7 @@ Lines are 1-indexed.`,
1074
1155
  text: `Symbols in ${targetPath} (${symbols.length} top-level):\n${lines.join("\n")}`,
1075
1156
  },
1076
1157
  ],
1077
- details: { ok: true, count: symbols.length, symbols: detailsSymbols },
1158
+ details: { ok: true, count: symbols.length, symbols: detailsSymbols, truncated: symbolsTruncated },
1078
1159
  };
1079
1160
  }
1080
1161
  if (action === "read_symbol") {
@@ -1219,12 +1300,14 @@ Lines are 1-indexed.`,
1219
1300
  const normalizePath = (p) => (process.platform === "win32" ? p.toLowerCase() : p);
1220
1301
  // 1. 收集种子位置:给了 line/character 就只查那个符号;否则查全部顶层符号。
1221
1302
  const seeds = [];
1303
+ let seedTotal = 0; // 顶层符号总数(截断前),超出 MAX_SEEDS 时在输出尾部标明
1222
1304
  if (typeof params.line === "number") {
1223
1305
  seeds.push({ line: line - 1, character: character - 1 });
1224
1306
  }
1225
1307
  else {
1226
1308
  const symResult = await client.request("textDocument/documentSymbol", { textDocument: { uri } }, timeoutMs);
1227
1309
  const topSymbols = Array.isArray(symResult) ? symResult : [];
1310
+ seedTotal = topSymbols.length;
1228
1311
  for (const sym of topSymbols.slice(0, MAX_SEEDS)) {
1229
1312
  const pos = sym.selectionRange?.start ?? sym.range?.start ?? sym.location?.range?.start;
1230
1313
  if (pos && typeof pos.line === "number") {
@@ -1267,6 +1350,10 @@ Lines are 1-indexed.`,
1267
1350
  depPaths.add(refPath);
1268
1351
  }
1269
1352
  }
1353
+ // 只分析排序后的前 MAX_DEPENDENTS 个引用方,但计数必须如实上报:文本与
1354
+ // details 都带截断前的真实总数,超出部分用尾注标明(对照 references 动作
1355
+ // 的 "... and N more references" 口径),避免模型误以为清单是完整的。
1356
+ const totalReferencing = depPaths.size;
1270
1357
  const dependents = [...depPaths].sort().slice(0, MAX_DEPENDENTS);
1271
1358
  if (dependents.length === 0) {
1272
1359
  return {
@@ -1327,7 +1414,7 @@ Lines are 1-indexed.`,
1327
1414
  }
1328
1415
  impacted.sort((a, b) => b.errors - a.errors || b.warnings - a.warnings);
1329
1416
  const out = [];
1330
- 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.`);
1331
1418
  for (const item of impacted) {
1332
1419
  out.push(`• ${item.path} — ${item.errors} error(s), ${item.warnings} warning(s)`);
1333
1420
  for (const d of item.diagnostics.slice(0, 5)) {
@@ -1347,6 +1434,14 @@ Lines are 1-indexed.`,
1347
1434
  if (notReported.length > 0) {
1348
1435
  out.push(`Diagnostics not reported in time (${notReported.length}, server may still be analyzing): ${notReported.slice(0, 5).join(", ")}${notReported.length > 5 ? ", ..." : ""}`);
1349
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
+ }
1350
1445
  return {
1351
1446
  content: [{ type: "text", text: out.join("\n") }],
1352
1447
  details: {
@@ -1355,6 +1450,7 @@ Lines are 1-indexed.`,
1355
1450
  clean,
1356
1451
  notReported,
1357
1452
  referencedFiles: dependents.map((p) => relative(cwd, p).replace(/\\/g, "/")),
1453
+ referencedFilesTotal: totalReferencing,
1358
1454
  },
1359
1455
  };
1360
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();
@@ -15,30 +15,6 @@ function parseId(raw) {
15
15
  const n = Number(raw);
16
16
  return Number.isInteger(n) && n > 0 ? n : null;
17
17
  }
18
- function formatStatus(s) {
19
- switch (s) {
20
- case "pending":
21
- return "pending";
22
- case "in_progress":
23
- return "in_progress";
24
- case "completed":
25
- return "completed";
26
- default:
27
- return s;
28
- }
29
- }
30
- export function describeTodos(state, includeDeleted = false, lang = "en") {
31
- const visible = state.tasks.filter((t) => includeDeleted || t.status !== "deleted");
32
- if (visible.length === 0)
33
- return pick(lang, "[todo] (空)", "[todo] (empty)", "markers.todo.list.empty");
34
- return visible
35
- .map((t) => {
36
- const form = t.status === "in_progress" && t.activeForm ? ` (${t.activeForm})` : "";
37
- const deps = t.blockedBy.length ? ` ⛓ ${t.blockedBy.join(",")}` : "";
38
- return `[${formatStatus(t.status)}] #${t.id} ${t.subject}${form}${deps}`;
39
- })
40
- .join("\n");
41
- }
42
18
  const TODO_GUIDANCE_ZH = [
43
19
  "- 标记语法:[[todo:new:<主题>]] 新建;[[todo:set:<id>,completed|in_progress|pending]] 状态;[[todo:remove:<id>]] 删除;[[todo:dep:<id>,blocks=<依赖id,逗号分隔>]] 设依赖。",
44
20
  "- 状态变化全部用上面的 [[todo:...]] 内联标记表达,不会中断回答,无需等待返回。",
@@ -64,7 +40,9 @@ export const todoMarker = {
64
40
  const op = token.op;
65
41
  switch (op) {
66
42
  case "new": {
67
- const subject = token.args[0]?.trim();
43
+ // #499:subject 是唯一「整段自由文本」参数——args.join 还原含逗号的主题,
44
+ // 否则 `[[todo:new:修复登录页,同步改注册页]]` 的后半段被静默丢弃。
45
+ const subject = token.args.join(",").trim();
68
46
  if (!subject)
69
47
  return {
70
48
  applied: false,
@@ -179,7 +157,9 @@ export const todoMarker = {
179
157
  const depsText = deps.length ? deps.join(",") : lang === "zh" ? "(无)" : "(none)";
180
158
  return {
181
159
  applied: true,
182
- feedback: pick(lang, `#${id} 依赖:${depsText}`, `#${id} blocks: ${depsText}`, "markers.todo.dep.blocks.updated", { id: id, depsText: depsText }),
160
+ feedback: pick(lang, `#${id} 依赖:${depsText}`,
161
+ // #499:实现是 blockedBy(deps 先完成)——原英文 "blocks" 把依赖方向说反了。
162
+ `#${id} is blocked by: ${depsText}`, "markers.todo.dep.blocks.updated", { id: id, depsText: depsText }),
183
163
  };
184
164
  }
185
165
  default:
@@ -2,8 +2,6 @@
2
2
  * marker.ts — 通用内联标记核心抽象(内置版)。
3
3
  * 复刻自 pi-marker-tools,保持相同解析语义,便于 AI 无缝迁移。
4
4
  */
5
- export const MARKER_OPEN = "[[";
6
- export const MARKER_CLOSE = "]]";
7
5
  // ---------------------------------------------------------------------------
8
6
  // 解析器
9
7
  // ---------------------------------------------------------------------------
@@ -38,16 +36,3 @@ export function parseMarkers(text) {
38
36
  }
39
37
  return tokens;
40
38
  }
41
- export function stripMarkers(text) {
42
- return text.replace(TOKEN_RE, () => "");
43
- }
44
- export function replaceToken(text, raw, replacement) {
45
- return text.split(raw).join(replacement);
46
- }
47
- export function serializeToken(token) {
48
- const parts = [token.tool, token.op, ...token.args];
49
- const kwargs = Object.entries(token.kwargs)
50
- .sort(([a], [b]) => (a < b ? -1 : 1))
51
- .map(([k, v]) => `${k}=${v}`);
52
- return `${MARKER_OPEN}${[...parts, ...kwargs].join(":")}${MARKER_CLOSE}`;
53
- }
@@ -11,9 +11,6 @@ export function getMarker(name) {
11
11
  export function allMarkers() {
12
12
  return [...registry.values()];
13
13
  }
14
- export function lookupToken(name) {
15
- return registry.get(name);
16
- }
17
14
  export function collectGuidance(disabled = new Set(), lang = "en") {
18
15
  const out = [];
19
16
  for (const m of allMarkers()) {
@@ -23,9 +23,6 @@ export function loadStateFromBranch(branch, namespace) {
23
23
  }
24
24
  return latest?.state;
25
25
  }
26
- export function hasStateInBranch(branch, namespace) {
27
- return loadStateFromBranch(branch, namespace) !== undefined;
28
- }
29
26
  /** 追加快照:优先走 sessionManager.appendCustomEntry,否则回退到回调。 */
30
27
  export function appendSnapshot(mgr, namespace, state, fallbackSave) {
31
28
  const snapshot = {