pi-web-ui 0.97.0 → 0.99.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 +126 -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 +645 -147
  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 +30 -5
  24. package/dist/server/goal-review-gate.js +7 -20
  25. package/dist/server/goal-service.js +77 -10
  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 +2 -3
  39. package/dist/server/patch-tool.js +78 -28
  40. package/dist/server/permission-preset.js +25 -0
  41. package/dist/server/plan-manager.js +196 -10
  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 +24 -12
  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 -40
  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-NerrJlTS.js} +1 -1
  74. package/web/dist/assets/index-BdMaGCcs.css +1 -0
  75. package/web/dist/assets/index-ByCBN4Kt.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
@@ -532,7 +532,9 @@ export class GoalService {
532
532
  conv.goalGeneration += 1;
533
533
  // 上一轮目标可能还留着常驻执行者子代理(委托执行):先停掉再开新的,
534
534
  // 否则旧循环会在新目标上继续派活(代次已作废,但角色对话得收)。
535
- this.stopDelegated(conv);
535
+ // await 到 dismiss 完全落定:后面的 spawn 名额检查才不会数到还在收尾的
536
+ // 旧执行者而误报上限(issue #464;stopDelegated 自身已按会话串行化)。
537
+ await this.stopDelegated(conv);
536
538
  const goal = conv.goal;
537
539
  goal.reviewing = false;
538
540
  goal.conversationId = goalConversationId;
@@ -817,10 +819,15 @@ export class GoalService {
817
819
  aborted = true;
818
820
  };
819
821
  ac.signal.addEventListener("abort", onAbort, { once: true });
820
- const choose = isChoice ? ctx.ui.select(qTitle, params.options) : ctx.ui.input(qTitle);
821
- const ans = (await choose);
822
- ac.signal.removeEventListener("abort", onAbort);
823
- clearIdle();
822
+ let ans;
823
+ try {
824
+ const choose = isChoice ? ctx.ui.select(qTitle, params.options) : ctx.ui.input(qTitle);
825
+ ans = (await choose);
826
+ }
827
+ finally {
828
+ ac.signal.removeEventListener("abort", onAbort);
829
+ clearIdle();
830
+ }
824
831
  if (aborted || ac.signal.aborted) {
825
832
  return {
826
833
  content: [
@@ -1120,7 +1127,8 @@ export class GoalService {
1120
1127
  const conv = this.host.activeConv();
1121
1128
  conv.goalGeneration += 1;
1122
1129
  // 委托执行:停掉在飞的执行者(循环靠代次作废 + 代次守卫退出)。
1123
- this.stopDelegated(conv);
1130
+ // await 落定,保证旧执行者已移出会话表,后续重设目标不再误报名额(issue #464)。
1131
+ await this.stopDelegated(conv);
1124
1132
  this.resetLoopCounters(conv);
1125
1133
  this.clearGoalFields(conv);
1126
1134
  this.emitGoalStatus();
@@ -1154,14 +1162,15 @@ export class GoalService {
1154
1162
  */
1155
1163
  async stopAllGoals() {
1156
1164
  let stopped = 0;
1157
- // 直接迭代:stopDelegated 只发 fire-and-forget 的停/收请求,不会同步改这张表
1158
- // (循环退出是异步的,靠代次守卫),无需快照。
1159
- for (const convId of this.delegatedLoops.keys()) {
1165
+ // 快照迭代:await stopDelegated 期间,循环退出的回调可能增删这张表。
1166
+ for (const convId of [...this.delegatedLoops.keys()]) {
1160
1167
  const conv = this.host.getConv(convId);
1161
1168
  if (!conv)
1162
1169
  continue;
1163
1170
  conv.goalGeneration += 1; // 作废在飞回调(循环靠代次守卫退出)
1164
- this.stopDelegated(conv); // 停执行者 + 唤醒 verdict 等待者 + 清 roles/phase
1171
+ // 逐个 await 停/收落定(stopDelegated 已按会话串行化,issue #464):
1172
+ // 停执行者 + 唤醒 verdict 等待者 + 清 roles/phase
1173
+ await this.stopDelegated(conv);
1165
1174
  this.resetLoopCounters(conv);
1166
1175
  this.clearGoalFields(conv);
1167
1176
  stopped++;
@@ -1205,6 +1214,8 @@ export class GoalService {
1205
1214
  }
1206
1215
  conv.goalGeneration += 1;
1207
1216
  // 委托执行:手动停止也要把在飞的执行者停掉(否则它还在后台改工作区)。
1217
+ // 本钩子是同步上下文,fire-and-forget 即可:stopDelegated 已按会话
1218
+ // 串行化(issue #464),重设/清除目标的一方会先等这次 dismiss 落定。
1208
1219
  this.stopDelegated(conv);
1209
1220
  this.resetLoopCounters(conv);
1210
1221
  g.conversationId = null;
@@ -1398,7 +1409,24 @@ export class GoalService {
1398
1409
  conv.goal.roles = {};
1399
1410
  return exec?.convId;
1400
1411
  }
1412
+ stopInflight = new Map();
1413
+ /**
1414
+ * 停掉委托执行者(stop + dismiss + 唤醒等待者)。同一会话并发调用时按序串行:
1415
+ * 上一次 stop/dismiss 未落定前,下一次先等它 —— 否则重设目标时旧执行者尚未
1416
+ * 移出会话表,随后 spawn 的名额检查会数到它而误报容量上限(issue #464)。
1417
+ * 同步上下文(onAgentEnd 等钩子)可以继续 fire-and-forget,串行化由这里兜住。
1418
+ */
1401
1419
  stopDelegated(conv) {
1420
+ const prev = this.stopInflight.get(conv.id);
1421
+ const run = prev ? prev.catch(() => { }).then(() => this.stopDelegatedNow(conv)) : this.stopDelegatedNow(conv);
1422
+ this.stopInflight.set(conv.id, run);
1423
+ void run.finally(() => {
1424
+ if (this.stopInflight.get(conv.id) === run)
1425
+ this.stopInflight.delete(conv.id);
1426
+ });
1427
+ return run;
1428
+ }
1429
+ async stopDelegatedNow(conv) {
1402
1430
  // 排队的启动请求一并作废:setGoal 会在后面按新代次重新排,clearGoal 则不需要。
1403
1431
  this.delegatedPending.delete(conv.id);
1404
1432
  conv.awaitingVerdict = undefined;
@@ -1411,6 +1439,30 @@ export class GoalService {
1411
1439
  settle("gone");
1412
1440
  }
1413
1441
  const execId = this.detachRoleExec(conv);
1442
+ if (execId) {
1443
+ try {
1444
+ await this.host.stopRoleAgent?.(execId);
1445
+ }
1446
+ catch { }
1447
+ try {
1448
+ // issue #464: 必须等待执行者完全 dismiss 移出会话表,避免立即重设目标时误报容量超限
1449
+ await this.host.dismissRoleAgent?.(execId);
1450
+ }
1451
+ catch { }
1452
+ }
1453
+ conv.goal.phase = "idle";
1454
+ }
1455
+ /** 会话过户时清理目标审查态并唤醒等待者(issue #457)。 */
1456
+ notifyTakeover(conv) {
1457
+ this.delegatedPending.delete(conv.id);
1458
+ conv.awaitingVerdict = undefined;
1459
+ void this.flushDeferredPrompts(conv);
1460
+ const settle = this.verdictWaiters.get(conv.id);
1461
+ if (settle) {
1462
+ this.verdictWaiters.delete(conv.id);
1463
+ settle("gone");
1464
+ }
1465
+ const execId = this.detachRoleExec(conv);
1414
1466
  if (execId) {
1415
1467
  void this.host.stopRoleAgent?.(execId).catch(() => { });
1416
1468
  void this.host.dismissRoleAgent?.(execId).catch(() => { });
@@ -1581,6 +1633,14 @@ export class GoalService {
1581
1633
  // 落盘对话在左栏/历史里没有「子代理」微标,用带前缀的标题保持可辨识。
1582
1634
  title: this.roleConvTitle(goalText),
1583
1635
  });
1636
+ // #490/案1:spawn 的长 await 期间目标可能被重设/清除(goalGeneration 已变,
1637
+ // stopDelegated 当时无可停执行者)——老循环不得把带着老目标首轮 prompt 的
1638
+ // 执行者写回 conv.roleExec,否则新循环会把它当现役执行者 steer(上下文被
1639
+ // 老目标污染,新目标首轮产出混入老目标工作)。就地停掉并让老循环按代次退出。
1640
+ if (conv.goalGeneration !== goalGeneration) {
1641
+ void host.stopRoleAgent?.(convId).catch(() => { });
1642
+ return undefined;
1643
+ }
1584
1644
  conv.roleExec = { convId, generation: (conv.roleExec?.generation ?? 0) + 1 };
1585
1645
  g.roles = { ...g.roles, executor: { convId, spawned: true } };
1586
1646
  this.beginRoundVitals(conv, convId);
@@ -1762,6 +1822,13 @@ export class GoalService {
1762
1822
  phase: "start",
1763
1823
  round,
1764
1824
  });
1825
+ // #490/案2:pushReviewCard 的 await 窗口里 setGoal/clearGoal 可能已清掉
1826
+ // awaitingVerdict(此刻 verdictWaiters 尚未登记,stopDelegated 的 settle 落空)
1827
+ // ——不复检就会继续登记 waiter 并把老目标的审查指令投进主对话:主对话白烧
1828
+ // 一轮审查、老循环干等 20 分钟超时,新目标的 pending 循环全程无法启动。
1829
+ if (!conv.awaitingVerdict || conv.awaitingVerdict.round !== round) {
1830
+ return "gone";
1831
+ }
1765
1832
  const verdict = await new Promise((resolve) => {
1766
1833
  let settled = false;
1767
1834
  let timer;
@@ -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
  }