pi-web-ui 0.94.1 → 0.96.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 (100) hide show
  1. package/CHANGELOG.md +91 -2
  2. package/README.md +5 -6
  3. package/README.zh-CN.md +4 -5
  4. package/bin/pi-web-ui.mjs +910 -237
  5. package/dist/server/agent-service.js +2655 -231
  6. package/dist/server/approval-rules.js +596 -0
  7. package/dist/server/attachment-store.js +129 -0
  8. package/dist/server/attachments.js +92 -174
  9. package/dist/server/bg-servers.js +45 -7
  10. package/dist/server/claim-files-tool.js +4 -8
  11. package/dist/server/claim-store.js +4 -2
  12. package/dist/server/client-state.js +67 -14
  13. package/dist/server/compact-context-tool.js +126 -0
  14. package/dist/server/composer-drafts.js +9 -0
  15. package/dist/server/context-budget.js +317 -0
  16. package/dist/server/control-socket.js +57 -28
  17. package/dist/server/conversation-read-tool.js +7 -17
  18. package/dist/server/dangling-tools.js +229 -0
  19. package/dist/server/delegate-task.js +25 -28
  20. package/dist/server/dsh/dsh-agent-service.js +69 -66
  21. package/dist/server/edit-soft-tool.js +84 -16
  22. package/dist/server/eval-tool.js +588 -0
  23. package/dist/server/file-archives.js +16 -5
  24. package/dist/server/files-service.js +65 -18
  25. package/dist/server/goal-service.js +371 -77
  26. package/dist/server/hashline-engine.js +703 -0
  27. package/dist/server/host-guard.js +94 -0
  28. package/dist/server/host-metrics.js +26 -2
  29. package/dist/server/i18n.js +3 -3
  30. package/dist/server/index.js +456 -80
  31. package/dist/server/lsp-tool.js +1371 -0
  32. package/dist/server/mcp-bridge.js +47 -3
  33. package/dist/server/model-admin.js +112 -32
  34. package/dist/server/office-parse.js +375 -0
  35. package/dist/server/patch-tool.js +85 -0
  36. package/dist/server/permission-preset.js +25 -0
  37. package/dist/server/plan-manager.js +114 -0
  38. package/dist/server/plugin-api-catalog.js +296 -0
  39. package/dist/server/plugin-catalog-sync.js +36 -19
  40. package/dist/server/plugin-catalog.js +11 -4
  41. package/dist/server/plugin-facilities.js +92 -21
  42. package/dist/server/plugin-install-spec.js +196 -0
  43. package/dist/server/plugin-installer.js +73 -0
  44. package/dist/server/plugin-llm.js +71 -65
  45. package/dist/server/plugin-manifest-validate.js +305 -0
  46. package/dist/server/plugin-project.js +117 -13
  47. package/dist/server/plugin-tool-guard.js +120 -0
  48. package/dist/server/plugin-updater.js +115 -12
  49. package/dist/server/plugins.js +950 -205
  50. package/dist/server/present-files-tool.js +9 -12
  51. package/dist/server/process-utils.js +16 -6
  52. package/dist/server/prompt-composer.js +9 -0
  53. package/dist/server/protocol-version.js +1 -1
  54. package/dist/server/read-tool.js +69 -30
  55. package/dist/server/resolve-global-sdk.js +30 -16
  56. package/dist/server/schedule-agent-tool.js +12 -15
  57. package/dist/server/scheduler-tasks.js +6 -0
  58. package/dist/server/sdk-origin.js +18 -2
  59. package/dist/server/serialize.js +111 -14
  60. package/dist/server/settings-service.js +112 -3
  61. package/dist/server/skill-tool.js +5 -7
  62. package/dist/server/subagent-templates.js +51 -0
  63. package/dist/server/subagents.js +83 -66
  64. package/dist/server/terminals.js +248 -81
  65. package/dist/server/tool-approval.js +84 -0
  66. package/dist/server/tool-manager.js +237 -12
  67. package/dist/server/tool-overrides.js +59 -0
  68. package/dist/server/update-check.js +28 -1
  69. package/dist/server/uploads.js +17 -2
  70. package/dist/server/wait-subscription-scan.js +18 -21
  71. package/dist/server/workspace-snapshot.js +113 -0
  72. package/dist/server/ws-client-id.js +29 -0
  73. package/dist/server/ws-pending-queue.js +60 -0
  74. package/extensions/webui.ts +60 -2
  75. package/package.json +5 -3
  76. package/plugin-sdk/README.md +25 -0
  77. package/plugin-sdk/index.d.ts +31 -38
  78. package/plugin-sdk/index.mjs +35 -19
  79. package/plugins/catalog.json +40 -0
  80. package/themes/aetheris.css +457 -0
  81. package/themes/ayu-light.css +6 -6
  82. package/themes/catppuccin-latte.css +6 -6
  83. package/themes/claude-code-dark.css +144 -0
  84. package/themes/codex.css +6 -6
  85. package/themes/everforest-light.css +6 -6
  86. package/themes/geist.css +6 -6
  87. package/themes/gruvbox-light.css +6 -6
  88. package/themes/kanagawa-lotus.css +6 -6
  89. package/themes/rose-pine-dawn.css +6 -6
  90. package/themes/solarized-light.css +6 -6
  91. package/themes/vs-code-dark.css +146 -0
  92. package/themes/zhupi-dark.css +658 -0
  93. package/themes/zhupi.css +711 -0
  94. package/web/dist/assets/{TerminalPanel-MVoxpJOA.js → TerminalPanel-DDChcYOh.js} +1 -1
  95. package/web/dist/assets/index-Do9RgJC3.js +366 -0
  96. package/web/dist/assets/index-DryOsILO.css +1 -0
  97. package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
  98. package/web/dist/index.html +3 -3
  99. package/web/dist/assets/index-B3S9MxnN.css +0 -1
  100. package/web/dist/assets/index-DVLrHI2E.js +0 -364
@@ -23,7 +23,7 @@ import { createConnection } from "node:net";
23
23
  import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
24
24
  import { homedir } from "node:os";
25
25
  import { fileURLToPath } from "node:url";
26
- import { randomUUID } from "node:crypto";
26
+ import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
27
27
  import express from "express";
28
28
  import compression from "compression";
29
29
  import { WebSocket, WebSocketServer } from "ws";
@@ -32,7 +32,9 @@ import { sdkCopies, sdkOriginNote } from "./sdk-origin.js";
32
32
  import { PROTOCOL_VERSION } from "./protocol-version.js";
33
33
  import { AgentService, workspacePath, QuiesceRejectedError } from "./agent-service.js";
34
34
  import { WS_MAX_PAYLOAD_BYTES, isAbsoluteWirePath, wireToAbs } from "./files-service.js";
35
+ import { httpHostAllowed } from "./host-guard.js";
35
36
  import { registerFileTransferRoutes } from "./file-transfer-routes.js";
37
+ import { initAttachmentStore, readAttachment } from "./attachment-store.js";
36
38
  import { isAudioFile, previewKind } from "./text-sniff.js";
37
39
  import { startControlServer } from "./control-socket.js";
38
40
  import { scheduleUploadCleanup } from "./uploads.js";
@@ -42,15 +44,18 @@ import { isManaged, managedRefusal } from "./managed.js";
42
44
  import { launchOrigin, toServiceInfo } from "./launch-origin.js";
43
45
  import { parseTabs, tabsRefusal } from "./tabs.js";
44
46
  import { recordUnknownWsType } from "./ws-unknown-types.js";
47
+ import { validateClientId } from "./ws-client-id.js";
48
+ import { PendingCommandQueue } from "./ws-pending-queue.js";
45
49
  import { installPack, isKnownPack, listPacks, loadServerStrings, readPackFile, removePack, unloadServerStrings, } from "./locales.js";
46
50
  import { PluginManager, resolvePluginClientFile, } from "./plugins.js";
47
- import { PluginInstaller } from "./plugin-installer.js";
51
+ import { buildPluginJobArgs, inspectInstallSpec, PluginInstaller } from "./plugin-installer.js";
48
52
  import { syncPluginCatalog } from "./plugin-catalog-sync.js";
49
53
  import { McpBridge } from "./mcp-bridge.js";
50
54
  import { createMcpHotReload } from "./mcp-hot-reload.js";
51
55
  import { createHostMetricsSampler } from "./host-metrics.js";
52
56
  import { SchedulerStore } from "./scheduler-tasks.js";
53
57
  import { initHttpProxy } from "./http-proxy.js";
58
+ import { globalLspPool } from "./lsp-tool.js";
54
59
  import { buildPiWebTokenCookie, decodeCookieToken, isTlsRequest } from "./auth-cookie.js";
55
60
  /** Strip npm-injected env (`npm start` exports `npm_config_*` / `npm_package_*` /
56
61
  * `npm_lifecycle_*` into every child). Anything this server spawns — shells,
@@ -95,6 +100,19 @@ try {
95
100
  catch (err) {
96
101
  console.warn(`[data] 无法创建数据目录 ${DATA_DIR}: ${err.message}`);
97
102
  }
103
+ // issue #295:工作区直接就是家目录时,SDK 初始化期的同步目录扫描会落在 $HOME 上
104
+ // (iCloud 占位符/外部卷坏挂载 → scandir/open 内核挂起 → 事件循环假死,hello 后无
105
+ // ready)。新装服务默认已是 ~/pi-web-ui(见 bin/pi-web-ui.mjs serviceOptions);
106
+ // 老服务若仍指着家目录,在此提示一次,`server install` 重装即迁移。
107
+ try {
108
+ if (resolve(CWD) === resolve(homedir())) {
109
+ console.warn(`[ws] 工作区为用户主目录 ${CWD}:目录扫描可能因外部卷/同步盘挂载而长时间阻塞,` +
110
+ `建议用 \`pi-web-ui server install --cwd <项目目录>\` 重装迁移。`);
111
+ }
112
+ }
113
+ catch {
114
+ /* 路径比较失败不影响启动 */
115
+ }
98
116
  // Dev-no-cache setting read without a ClientStateStore instance (the index.html
99
117
  // route runs before any client attaches). Reads the same global settings blob.
100
118
  function readDevNoCacheSetting() {
@@ -188,6 +206,19 @@ if (process.platform === "win32") {
188
206
  void ensureWindowsBash();
189
207
  }
190
208
  const app = express();
209
+ // Host 白名单(防 DNS rebinding,审查 #352):无 token 部署下 HTTP 路由此前
210
+ // 不校验 Host,恶意网页让自己的域名解析到 127.0.0.1 即可打满全部 API。
211
+ // 显式白名单走 PI_WEB_ALLOW_HOSTS(与 WS 侧同 env);设置了 PI_WEB_TOKEN 则
212
+ // 由 token 鉴权兜底,不再限制 Host。WS 升级侧同规则见 originAllowed()。
213
+ app.use((req, res, next) => {
214
+ const hostHeader = req.headers.host;
215
+ if (typeof hostHeader === "string" &&
216
+ !httpHostAllowed(hostHeader, { allowHosts: ALLOW_HOSTS, hasAuthToken: Boolean(AUTH_TOKEN) })) {
217
+ res.status(403).end("host not allowed");
218
+ return;
219
+ }
220
+ next();
221
+ });
191
222
  app.use(express.json({ limit: "10mb" }));
192
223
  /** 从请求中提取候选 token:头 / 查询参数 / cookie(浏览器导航场景靠 cookie 续命)。 */
193
224
  function requestTokens(req) {
@@ -225,8 +256,15 @@ function requestTokens(req) {
225
256
  }
226
257
  return out.filter(Boolean);
227
258
  }
259
+ /** 口令比较用常时时间:先哈希到定长再 timingSafeEqual(长度差异被摘要抹平),
260
+ * 消除逐字节短路比较的时序侧信道(审查 #352:纵深防御,远程可利用性低)。 */
261
+ function sameSecret(candidate) {
262
+ const a = createHash("sha256").update(candidate).digest();
263
+ const b = createHash("sha256").update(AUTH_TOKEN).digest();
264
+ return timingSafeEqual(a, b);
265
+ }
228
266
  function tokenOk(req) {
229
- return requestTokens(req).includes(AUTH_TOKEN);
267
+ return requestTokens(req).some(sameSecret);
230
268
  }
231
269
  /** 请求携带的 pi_web_token cookie 的**口令值**(未带/损坏时为空串)。
232
270
  * 已解码:下发时是 `encodeURIComponent` 过的(issue #261),所以这里拿到的是
@@ -268,7 +306,7 @@ if (AUTH_TOKEN) {
268
306
  // cookieToken 已解码成原文(issue #261),所以直接和原始口令比 ——
269
307
  // 以前拿 `encodeURIComponent(AUTH_TOKEN)` 比,含 `=` / 非 ASCII 的口令
270
308
  // 永远不相等(于是每个请求都重发 cookie,且带 cookie 的请求反而 401)。
271
- if (cookie !== AUTH_TOKEN) {
309
+ if (!sameSecret(cookie)) {
272
310
  res.setHeader("Set-Cookie", buildPiWebTokenCookie(encodeURIComponent(AUTH_TOKEN), 31536000, secure));
273
311
  }
274
312
  }
@@ -305,17 +343,47 @@ const SERVICE_INFO = toServiceInfo(ORIGIN);
305
343
  const TABS = parseTabs();
306
344
  registerFileTransferRoutes(app, (clientId) => service.get(clientId)?.cwd);
307
345
  app.get("/api/health", (_req, res) => {
346
+ // 审查 #352:该端点对未鉴权开放(监控探针需要),故只保留版本/引擎与 SDK
347
+ // 副本诊断信息,不再暴露 cwd(工作区路径披露)与 pid(指纹/信息收集面)。
308
348
  res.json({
309
349
  ok: true,
310
350
  piVersion: VERSION,
311
351
  // issue #260:服务实际加载的是自带副本,不是全局 pi CLI 那份。这里把两份都报出来,
312
352
  // 用户就不用猜「为什么升了全局 SDK 不生效」。(纯新增字段,piVersion 语义不变。)
313
353
  piSdkCopies: sdkCopies(),
314
- cwd: CWD,
315
- pid: process.pid,
316
354
  engine: ENGINE,
317
355
  });
318
356
  });
357
+ /**
358
+ * 基于 SHA-256 内容寻址的附件静态服务:
359
+ * 永久强缓存(immutable),支持图片和文件读取。
360
+ */
361
+ app.get("/api/attachment/:hash", async (req, res) => {
362
+ try {
363
+ const hash = String(req.params.hash ?? "").trim();
364
+ const hit = await readAttachment(hash);
365
+ if (!hit) {
366
+ res.status(404).end("attachment not found");
367
+ return;
368
+ }
369
+ res.setHeader("Content-Type", hit.mimeType);
370
+ res.setHeader("Content-Length", hit.buffer.length);
371
+ res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
372
+ if (hit.mimeType === "image/svg+xml") {
373
+ // SVG 以文档形态打开(顶层导航/iframe)时内嵌 <script> 会在本应用
374
+ // origin 执行——附件内容可来自剪贴板/工作区文件,等同存储型 XSS
375
+ // (审查 #352 P0-1)。sandbox CSP 让文档形态降级到不透明 origin;
376
+ // <img> 引用不受影响(子资源响应的 CSP 不作用于引用页,且
377
+ // SVG-as-image 本就不执行脚本)。
378
+ res.setHeader("Content-Security-Policy", "sandbox");
379
+ res.setHeader("X-Content-Type-Options", "nosniff");
380
+ }
381
+ res.end(hit.buffer);
382
+ }
383
+ catch (err) {
384
+ res.status(500).end(err.message);
385
+ }
386
+ });
319
387
  /**
320
388
  * Stream a workspace file over HTTP.
321
389
  *
@@ -376,6 +444,8 @@ app.get("/api/file", async (req, res) => {
376
444
  // dotfiles: allow — issue #223:Express 5 的 send 默认 dotfiles=ignore,
377
445
  // 工作区/数据目录常位于隐藏目录下(如 ~/.pi-web),绝对路径含点号段会被判 404。
378
446
  // 路径已由上方的 workspacePath/isAbsoluteWirePath 做工作区 containment 校验,放行安全。
447
+ // 注:绝对 wire 路径分支是机器浏览设计(whole-machine browsing),
448
+ // 不受工作区约束——别被注释误导(审查 #352)。
379
449
  res.download(abs, name, { dotfiles: "allow" });
380
450
  }
381
451
  else {
@@ -393,6 +463,12 @@ app.get("/api/file", async (req, res) => {
393
463
  res.setHeader("Content-Security-Policy", allowJs ? "sandbox allow-scripts" : "sandbox");
394
464
  res.setHeader("X-Content-Type-Options", "nosniff");
395
465
  }
466
+ else if (lower.endsWith(".svg")) {
467
+ // 与 /api/attachment 同理:SVG 文档形态的 <script> 沙箱化
468
+ // (审查 #352 P0-1),<img> 内嵌用法不受影响。
469
+ res.setHeader("Content-Security-Policy", "sandbox");
470
+ res.setHeader("X-Content-Type-Options", "nosniff");
471
+ }
396
472
  res.sendFile(abs, { dotfiles: "allow" });
397
473
  }
398
474
  }
@@ -457,6 +533,10 @@ app.get("/api/preview/*splat", async (req, res) => {
457
533
  const allowJs = req.query.allowJs === "1";
458
534
  res.setHeader("Content-Security-Policy", allowJs ? "sandbox allow-scripts" : "sandbox");
459
535
  }
536
+ else if (lower.endsWith(".svg")) {
537
+ // 与 /api/attachment 同理:SVG 文档形态的 <script> 沙箱化(审查 #352 P0-1)。
538
+ res.setHeader("Content-Security-Policy", "sandbox");
539
+ }
460
540
  res.sendFile(abs, { dotfiles: "allow" });
461
541
  }
462
542
  catch {
@@ -774,8 +854,13 @@ function parseAuthority(a) {
774
854
  }
775
855
  }
776
856
  function originAllowed(req) {
777
- const hostHeader = (req.headers.host ?? "").toLowerCase();
778
- const host = parseAuthority(hostHeader);
857
+ const hostHeader = req.headers.host ?? "";
858
+ // Host 白名单与 HTTP 侧同规则(审查 #352):rebinding 下 Origin 会与
859
+ // 攻击者 Host 自比相等,必须先把非本机/私网的 Host 挡掉。
860
+ if (!httpHostAllowed(hostHeader, { allowHosts: ALLOW_HOSTS, hasAuthToken: Boolean(AUTH_TOKEN) })) {
861
+ return false;
862
+ }
863
+ const host = parseAuthority(hostHeader.toLowerCase());
779
864
  if (ALLOW_HOSTS.length > 0 && !ALLOW_HOSTS.includes(host.hostname)) {
780
865
  return false;
781
866
  }
@@ -918,6 +1003,7 @@ const service = ENGINE === "dsh"
918
1003
  join(DATA_DIR, "client-state.json"));
919
1004
  // Server-string tables (issue #91 v2): packs' `serverStrings` sections feed
920
1005
  // pick() lookup for non-zh/en UI languages (missing key → English fallback).
1006
+ initAttachmentStore(DATA_DIR);
921
1007
  loadServerStrings(DATA_DIR);
922
1008
  // Optional UI plugins (<dataDir>/plugins/<id>/): scanned on every client
923
1009
  // attach so freshly dropped plugins appear without a server restart.
@@ -953,7 +1039,9 @@ pluginMgr.pathAccessRequester = (pluginId, dir, reason) => new Promise((resolve)
953
1039
  pendingPathRequests.delete(id);
954
1040
  resolve(false);
955
1041
  }, 120_000);
956
- pendingPathRequests.set(id, { resolve, timer });
1042
+ // 广播前先记下在线端集合:应答来源绑定用(防没见过弹窗的连接代答)。
1043
+ const recipients = new Set(pluginMgr.onlineClientIds());
1044
+ pendingPathRequests.set(id, { resolve, timer, recipients });
957
1045
  const payload = JSON.stringify({
958
1046
  type: "plugin_path_request",
959
1047
  id,
@@ -1006,7 +1094,9 @@ pluginMgr.permissionRequester = (pluginId, req) => new Promise((resolve) => {
1006
1094
  }
1007
1095
  resolve({ ok: false, remember: false });
1008
1096
  }, 120_000);
1009
- pendingPermissionRequests.set(id, { resolve, timer });
1097
+ // 广播前先记下在线端集合:应答来源绑定用(防没见过弹窗的连接代答)。
1098
+ const recipients = new Set(pluginMgr.onlineClientIds());
1099
+ pendingPermissionRequests.set(id, { resolve, timer, recipients });
1010
1100
  const ask = JSON.stringify({
1011
1101
  type: "plugin_permission_request",
1012
1102
  id,
@@ -1028,6 +1118,34 @@ pluginMgr.permissionRequester = (pluginId, req) => new Promise((resolve) => {
1028
1118
  }
1029
1119
  });
1030
1120
  pluginMgr.onPermGrantsChanged = () => pushPluginPermissions();
1121
+ /** key = pluginId:wantsDom 插件集合有限 + 120s 自动过期,规模天然有界。 */
1122
+ const pendingDomConsents = new Map();
1123
+ /** 按 id 反查在途 consent 请求(应答只有 id;表很小,线性扫即可)。 */
1124
+ function findDomConsentById(id) {
1125
+ for (const p of pendingDomConsents.values())
1126
+ if (p.id === id)
1127
+ return p;
1128
+ return undefined;
1129
+ }
1130
+ // ---------------------------------------------------------------------------
1131
+ // 插件安装的用户确认门(P0):plugin_catalog_sync 的 install:true 与 plugin_job
1132
+ // 的 install/update 在真正动安装器之前必须拿到用户确认。第三方页面脚本可以直发
1133
+ // 这两类消息,没有这道门就能借 WS 静默安装任意插件。复用 permissionRequester
1134
+ // 的同一条「plugin_permission_request 弹窗 + 120s 超时视为拒绝」管线 —— 不新增
1135
+ // 协议消息,将安装清单放在 reason 里展示;这里直接调 requester(不写授权表,
1136
+ // remember 标志被忽略)。拒绝 / 超时 / 未接弹窗设施(无头 DSH)一律 fail-closed。
1137
+ // ---------------------------------------------------------------------------
1138
+ async function confirmPluginInstall(items) {
1139
+ const ask = pluginMgr.permissionRequester;
1140
+ if (!ask)
1141
+ return false;
1142
+ const list = items.map((x) => `${x.id} ← ${x.source}`).join("\n");
1143
+ const ans = await ask("plugin-installer", {
1144
+ family: "net",
1145
+ reason: `安装确认:将安装/更新以下插件(id ← source):\n${list}\n拒绝或 120 秒未确认则不安装。`,
1146
+ });
1147
+ return ans.ok === true;
1148
+ }
1031
1149
  // 内置定时任务(issue #184):全局 <dataDir>/scheduler-tasks.json,TTL 与
1032
1150
  // client-state 同级;Agent 工具建的任务优先唤醒发起对话(issue #193:
1033
1151
  // wakeConversation steer 投递,不切用户当前对话),原对话不在先回落同项目
@@ -1139,6 +1257,17 @@ const scheduler = new SchedulerStore(DATA_DIR, {
1139
1257
  },
1140
1258
  onChange: () => pushSchedulerTasks(),
1141
1259
  notify: (level, text, textEn) => pushNoticeToAll(level, text, textEn ?? text),
1260
+ // issue #291:任务删除(含单次任务跑完自删)后回收其伪客户端,
1261
+ // 否则 scheduler:<taskId> 会话会永久占据其他客户端的 elsewhere 列表。
1262
+ onTaskRemoved: (taskId) => {
1263
+ const svc = service;
1264
+ try {
1265
+ svc.releaseSchedulerClient?.(taskId);
1266
+ }
1267
+ catch {
1268
+ // 回收失败不影响任务删除
1269
+ }
1270
+ },
1142
1271
  });
1143
1272
  scheduler.start();
1144
1273
  /** 把调度器任务列表推给所有在线客户端(设置面板展示 + 变更后刷新)。 */
@@ -1194,6 +1323,12 @@ void mcpBridge.load().then(() => {
1194
1323
  });
1195
1324
  // 插件扩展点:SDK 工具执行事件(bash/读文件等 start+end)转发给已注册的插件。
1196
1325
  service.onToolEvent = (ev) => pluginMgr.emitToolEvent(ev);
1326
+ // 插件扩展点(P1-5):bash/read 执行前后的拦截(pre 拒/问即拦、post 脱敏补上下文;
1327
+ // 只覆盖已接管的这两处,DSH 引擎无 customTool 注册面不接)。
1328
+ service.toolGuard = {
1329
+ pre: (req, lang) => pluginMgr.evaluateToolPre(req, lang),
1330
+ post: (req, lang) => pluginMgr.evaluateToolPost(req, lang),
1331
+ };
1197
1332
  service.onRunEvent = (ev) => pluginMgr.emitRunEvent(ev);
1198
1333
  // 插件扩展点:对话切换通知(轨迹视图切会话后即重拉;dsh 引擎暂无)。
1199
1334
  service.onConversationChanged = () => pluginMgr.emitConversationChanged();
@@ -1384,6 +1519,10 @@ const SNAPSHOT_BACKPRESSURE_FACTOR = 3;
1384
1519
  const SNAPSHOT_BACKPRESSURE_MIN_BYTES = 262_144;
1385
1520
  /** 背压丢弃后的延迟重发间隔。 */
1386
1521
  const SNAPSHOT_RETRY_MS = 250;
1522
+ /** issue #295:attach(会话初始化)超过此时长未完成,先给浏览器一句可见提示,
1523
+ * 避免界面永久停在「正在连接」而用户不知发生了什么。attach 本体继续等(不取消),
1524
+ * 完成后照常走快照流程。 */
1525
+ const ATTACH_SLOW_NOTICE_MS = 8_000;
1387
1526
  /**
1388
1527
  * Multi-tab serialization sharing: emit() hands the SAME message object to
1389
1528
  * every socket of a client, but each send() used to JSON.stringify it
@@ -1408,8 +1547,13 @@ wss.on("connection", (ws) => {
1408
1547
  let closed = false;
1409
1548
  /** 最近一份全量 snapshot 的估算字节数(UTF-16 ×2),供背压相对阈值用(issue #11)。 */
1410
1549
  let lastSnapshotBytes = 0;
1411
- /** Commands received while the session is still being created — replayed after attach. */
1412
- let pending = [];
1550
+ /** Commands received while the session is still being created — replayed after attach.
1551
+ * 带上限(256 条):attach 挂死/失败保活期间队列不再无界增长,超限丢最旧并告警。 */
1552
+ const pending = new PendingCommandQueue();
1553
+ /** attach 完成(含插件链 + 首快照)前一律排队(见 hello 分支的 replayQueued):
1554
+ * ready 先行后,ready 只代表传输通,插件命令目录/首快照都还没好,直接分发
1555
+ * 会撞「未知命令」/ rev 链断裂。 */
1556
+ let attachDone = false;
1413
1557
  /** 背压丢快照后的延迟重发定时器(去重:一次只排一个)。 */
1414
1558
  let snapshotRetryTimer = null;
1415
1559
  // 协议层错误(非法帧/未 masked 帧等):不注册 handler 会作为 uncaught
@@ -1460,14 +1604,30 @@ wss.on("connection", (ws) => {
1460
1604
  // Plugins broadcast to every open socket; unregister on close below. The
1461
1605
  // cid getter lets plugins target THIS socket via host.sendTo(clientId).
1462
1606
  const removePluginSender = pluginMgr.addSender(send, () => clientId);
1607
+ /** DOM 授权落盘 + notice 反馈(两步握手的 grant 与单步 revoke 共用收尾)。
1608
+ * cs 缺席(未 attach 完成)时只落盘不提示——消息本来就会在 attach 前排队。 */
1609
+ const applyDomConsent = (pluginId, granted) => {
1610
+ void pluginMgr
1611
+ .setDomConsent(pluginId, granted)
1612
+ .then((r) => {
1613
+ const cs2 = clientId ? service.get(clientId) : undefined;
1614
+ if (r.error)
1615
+ cs2?.emitNotice("warning", `DOM 授权失败:${r.error}`, `DOM consent failed: ${r.error}`);
1616
+ else if (r.changed)
1617
+ cs2?.emitNotice("info", granted ? `已授权插件「${pluginId}」完全 DOM 访问` : `已撤销插件「${pluginId}」完全 DOM 访问`, granted
1618
+ ? `Granted full DOM access to plugin "${pluginId}"`
1619
+ : `Revoked full DOM access from plugin "${pluginId}"`);
1620
+ })
1621
+ .catch(() => { });
1622
+ };
1463
1623
  const dispatch = (msg) => {
1464
1624
  if (!clientId) {
1465
1625
  pending.push(msg);
1466
1626
  return;
1467
1627
  }
1468
1628
  const cs = service.get(clientId);
1469
- if (!cs) {
1470
- // Session not ready yet (hello processing) — hold the command.
1629
+ if (!cs || !attachDone) {
1630
+ // Session not ready yet (hello processing / plugin chain) — hold the command.
1471
1631
  pending.push(msg);
1472
1632
  return;
1473
1633
  }
@@ -1509,11 +1669,17 @@ wss.on("connection", (ws) => {
1509
1669
  void cs.listBgServers();
1510
1670
  break;
1511
1671
  case "new_chat":
1512
- void cs.newChat(msg.preset);
1672
+ void cs.newChat(msg.preset, msg.ephemeral);
1513
1673
  break;
1514
1674
  case "edit_message":
1515
1675
  void cs.editMessage(msg.messageId, msg.text, msg.attachments);
1516
1676
  break;
1677
+ case "fork_session":
1678
+ void cs.forkSession?.(msg.messageId, msg.position, msg.conversationId);
1679
+ break;
1680
+ case "rollback_session":
1681
+ void cs.rollbackSession?.(msg.messageId, msg.conversationId, msg.restoreWorkspace);
1682
+ break;
1517
1683
  case "cycle_model":
1518
1684
  void cs.cycleModel();
1519
1685
  break;
@@ -1568,6 +1734,9 @@ wss.on("connection", (ws) => {
1568
1734
  case "dismiss_finished_subagents":
1569
1735
  void cs.dismissFinishedSubagents(msg.parentId);
1570
1736
  break;
1737
+ case "subagent_handoff":
1738
+ void cs.handoffSubagent?.(msg.fromRunId, msg.toRunId, msg.payload);
1739
+ break;
1571
1740
  case "switch_session":
1572
1741
  void cs.switchSession(msg.path);
1573
1742
  break;
@@ -1704,6 +1873,11 @@ wss.on("connection", (ws) => {
1704
1873
  case "check_updates_all":
1705
1874
  void cs.checkUpdatesAll(msg.force === true);
1706
1875
  break;
1876
+ case "check_plugin_updates":
1877
+ if (typeof cs.checkPluginUpdates === "function") {
1878
+ void cs.checkPluginUpdates(true);
1879
+ }
1880
+ break;
1707
1881
  case "restart_service": {
1708
1882
  // Same effect as `pi-web-ui server restart`: this process exits and its
1709
1883
  // supervisor brings it back (launchd/systemd immediately, the Windows
@@ -1780,7 +1954,10 @@ wss.on("connection", (ws) => {
1780
1954
  void cs.listProviders();
1781
1955
  break;
1782
1956
  case "fetch_models":
1783
- void cs.fetchModelsList(msg.reqId, msg.baseUrl, msg.apiKey, msg.authHeader, msg.api);
1957
+ void cs.fetchModelsList(msg.reqId, msg.baseUrl, msg.apiKey, msg.authHeader, msg.api, msg.providerId);
1958
+ break;
1959
+ case "test_model_connection":
1960
+ void cs.testModelConnection?.(msg.reqId, msg.baseUrl, msg.apiKey, msg.authHeader, msg.api, msg.providerId);
1784
1961
  break;
1785
1962
  case "refresh_provider_models":
1786
1963
  void cs.refreshProviderModels(msg.providerId, msg.reqId);
@@ -1887,8 +2064,10 @@ wss.on("connection", (ws) => {
1887
2064
  terminalToolsEnabled: msg.terminalToolsEnabled,
1888
2065
  terminalBash: msg.terminalBash,
1889
2066
  terminalBashIdleMs: msg.terminalBashIdleMs,
2067
+ terminalBashMaxForegroundMs: msg.terminalBashMaxForegroundMs,
1890
2068
  toolWatchdogTimeoutMs: msg.toolWatchdogTimeoutMs,
1891
2069
  readDirEnabled: msg.readDirEnabled,
2070
+ toolApprovalEnabled: msg.toolApprovalEnabled,
1892
2071
  editSoftEnabled: msg.editSoftEnabled,
1893
2072
  questionnaireEnabled: msg.questionnaireEnabled,
1894
2073
  parallelReminderEnabled: msg.parallelReminderEnabled,
@@ -1963,49 +2142,112 @@ wss.on("connection", (ws) => {
1963
2142
  const jobLang = () => cs?.getLang() ?? "en";
1964
2143
  const jobId = String(msg.jobId ?? "");
1965
2144
  const pluginId = String(msg.id ?? "");
1966
- const started = pluginInstaller.start({
2145
+ // function 声明会提升、TS 对 msg 判别联合的收窄进不了闭包 —— 先拍平成常量。
2146
+ const jobAction = msg.action;
2147
+ const jobSpec = {
1967
2148
  jobId,
1968
- action: msg.action,
2149
+ action: jobAction,
1969
2150
  id: pluginId,
1970
2151
  source: msg.source,
1971
2152
  build: msg.build === true,
1972
2153
  noBuild: msg.noBuild === true,
1973
- }, {
1974
- lang: jobLang,
1975
- emit: (m) => send(m),
1976
- done: async (ok, info) => {
1977
- if (ok) {
1978
- await reloadPluginsAndPush(jobLang);
1979
- }
1980
- else if (info.error) {
1981
- cs?.emitNotice("error", `插件操作失败:${info.error}`, `Plugin operation failed: ${info.error}`);
1982
- }
1983
- },
1984
- });
1985
- if (!started.ok) {
1986
- // 被拒(忙 / 托管实例 / 参数非法)也要回一条 done,让面板上的作业就地结束。
2154
+ };
2155
+ const jobDone = (ok, error) => {
1987
2156
  send({
1988
2157
  type: "plugin_job",
1989
2158
  jobId,
1990
- action: msg.action,
2159
+ action: jobAction,
1991
2160
  pluginId,
1992
2161
  phase: "done",
1993
- ok: false,
1994
- error: started.error,
2162
+ ok,
2163
+ ...(error ? { error } : {}),
1995
2164
  output: "",
1996
2165
  });
2166
+ };
2167
+ // 参数静态校验:参数非法直接拒绝,避免向客户端发起无意义/恶意的确认弹窗。
2168
+ const argCheck = buildPluginJobArgs(jobSpec, DATA_DIR, jobLang);
2169
+ if ("error" in argCheck) {
2170
+ jobDone(false, argCheck.error);
2171
+ break;
2172
+ }
2173
+ // 安装确认门(P0):install/update 先经用户确认。第三方页面脚本可以直发
2174
+ // plugin_job;拒绝/超时直接回一条 done,让面板上的作业就地结束(不占
2175
+ // 安装锁、不弹「失败」之外的噪音)。卸载不在本门范围内(由面板本身发起)。
2176
+ if (jobAction === "install" || jobAction === "update") {
2177
+ void confirmPluginInstall([{ id: pluginId, source: String(msg.source ?? "") }])
2178
+ .then((confirmed) => {
2179
+ if (!confirmed) {
2180
+ jobDone(false, "用户未确认安装(拒绝或 120 秒超时)");
2181
+ return;
2182
+ }
2183
+ startPluginJob();
2184
+ })
2185
+ .catch(() => jobDone(false, "安装确认流程异常"));
2186
+ break;
2187
+ }
2188
+ startPluginJob();
2189
+ // 真正派发作业(确认门通过后走这里):被拒(忙 / 托管实例 / 参数非法)也要回
2190
+ // 一条 done,让面板上的作业就地结束。
2191
+ function startPluginJob() {
2192
+ const started = pluginInstaller.start(jobSpec, {
2193
+ lang: jobLang,
2194
+ emit: (m) => send(m),
2195
+ done: async (ok, info) => {
2196
+ if (ok) {
2197
+ await reloadPluginsAndPush(jobLang);
2198
+ }
2199
+ else if (info.error) {
2200
+ cs?.emitNotice("error", `插件操作失败:${info.error}`, `Plugin operation failed: ${info.error}`);
2201
+ }
2202
+ },
2203
+ });
2204
+ if (!started.ok)
2205
+ jobDone(false, started.error);
1997
2206
  }
1998
2207
  break;
1999
2208
  }
2000
2209
  case "plugin_job_cancel":
2001
2210
  pluginInstaller.cancel(String(msg.jobId ?? ""));
2002
2211
  break;
2212
+ // 注册面目录(DSH P2-7):只读装配(slot/工具/宿主方法表+当前占用者),按需拉取。
2213
+ case "plugin_api_catalog": {
2214
+ const requestId = String(msg.requestId ?? "");
2215
+ send({ type: "plugin_api_catalog_result", requestId, catalog: pluginMgr.getApiCatalog() });
2216
+ break;
2217
+ }
2218
+ // 安装前先读 spec(DSH P0-3):不联网也能查(形状/已装),GitHub 源再探一次
2219
+ // raw manifest。结果只作引导(UI 把 problem 渲染成输入框下的一句话),不阻塞安装。
2220
+ case "plugin_install_inspect": {
2221
+ const requestId = String(msg.requestId ?? "");
2222
+ const source = String(msg.source ?? "");
2223
+ void inspectInstallSpec(source, {
2224
+ pluginsDir: pluginMgr.pluginsDir,
2225
+ ...(typeof msg.explicitId === "string" && msg.explicitId.trim() ? { explicitId: msg.explicitId.trim() } : {}),
2226
+ force: msg.force === true,
2227
+ }).then((r) => {
2228
+ send({
2229
+ type: "plugin_install_inspect_result",
2230
+ requestId,
2231
+ source,
2232
+ kind: r.spec.kind,
2233
+ suggestedId: r.suggestedId,
2234
+ installed: r.installed,
2235
+ ...(r.problem ? { problem: r.problem } : {}),
2236
+ ...(r.detail ? { detail: r.detail } : {}),
2237
+ ...(r.manifest ? { manifest: r.manifest } : {}),
2238
+ });
2239
+ });
2240
+ break;
2241
+ }
2003
2242
  // -- 插件目录授权(issue #146)------------------------------------------
2004
2243
  case "plugin_path_response": {
2005
- const pending = pendingPathRequests.get(String(msg.id ?? ""));
2006
- if (pending) {
2244
+ const id = String(msg.id ?? "");
2245
+ const pending = pendingPathRequests.get(id);
2246
+ // 来源绑定:应答必须来自弹窗广播时在线的 clientId——广播后才连上的
2247
+ // 端没见过弹窗,忽略其代答(pending 保留,真正的弹窗端仍可答复)。
2248
+ if (pending && clientId && pending.recipients.has(clientId)) {
2007
2249
  clearTimeout(pending.timer);
2008
- pendingPathRequests.delete(String(msg.id ?? ""));
2250
+ pendingPathRequests.delete(id);
2009
2251
  pending.resolve(msg.ok === true);
2010
2252
  }
2011
2253
  break;
@@ -2014,7 +2256,8 @@ wss.on("connection", (ws) => {
2014
2256
  case "plugin_permission_response": {
2015
2257
  const id = String(msg.id ?? "");
2016
2258
  const pending = pendingPermissionRequests.get(id);
2017
- if (pending) {
2259
+ // 来源绑定:同 plugin_path_response——只有收到弹窗广播的端可代答。
2260
+ if (pending && clientId && pending.recipients.has(clientId)) {
2018
2261
  clearTimeout(pending.timer);
2019
2262
  pendingPermissionRequests.delete(id);
2020
2263
  // 先答复者胜:通知其它在线端收起同一条请求(与目录授权不同,这里要显式 resolved)。
@@ -2034,19 +2277,49 @@ wss.on("connection", (ws) => {
2034
2277
  break;
2035
2278
  }
2036
2279
  case "plugin_dom_consent": {
2037
- void pluginMgr
2038
- .setDomConsent(msg.pluginId, msg.granted === true)
2039
- .then((r) => {
2040
- if (r.error)
2041
- cs?.emitNotice("warning", `DOM 授权失败:${r.error}`, `DOM consent failed: ${r.error}`);
2042
- else if (r.changed)
2043
- cs?.emitNotice("info", msg.granted === true
2044
- ? `已授权插件「${msg.pluginId}」完全 DOM 访问`
2045
- : `已撤销插件「${msg.pluginId}」完全 DOM 访问`, msg.granted === true
2046
- ? `Granted full DOM access to plugin "${msg.pluginId}"`
2047
- : `Revoked full DOM access from plugin "${msg.pluginId}"`);
2048
- })
2049
- .catch(() => { });
2280
+ // grant 是提权方向:走两步握手——只生成在途 consent 请求并广播,不在此
2281
+ // 处落盘;收到绑定来源的 plugin_dom_consent_response 后才 setDomConsent。
2282
+ // 同 pluginId 已有在途请求时忽略(首个优先,120s 超时自动失效)。
2283
+ if (msg.granted === true) {
2284
+ const pid = typeof msg.pluginId === "string" ? msg.pluginId.trim() : "";
2285
+ if (!pid || !pluginMgr.isDomPlugin(pid) || pendingDomConsents.has(pid))
2286
+ break;
2287
+ const id = randomUUID();
2288
+ const timer = setTimeout(() => {
2289
+ pendingDomConsents.delete(pid);
2290
+ }, 120_000);
2291
+ pendingDomConsents.set(pid, { id, pluginId: pid, recipients: new Set(pluginMgr.onlineClientIds()), timer });
2292
+ // from = 发起端 clientId:前端只自动应答自己发起的授权(设置面板点击)。
2293
+ const payload = JSON.stringify({ type: "plugin_dom_consent_request", id, pluginId: pid, from: clientId });
2294
+ for (const client of wss.clients) {
2295
+ if (client.readyState === WebSocket.OPEN) {
2296
+ try {
2297
+ client.send(payload);
2298
+ }
2299
+ catch {
2300
+ /* 死连接 */
2301
+ }
2302
+ }
2303
+ }
2304
+ break;
2305
+ }
2306
+ // revoke 是降权方向:单步直达(不在途等待),pluginId 校验交给 setDomConsent。
2307
+ applyDomConsent(msg.pluginId, false);
2308
+ break;
2309
+ }
2310
+ // -- DOM 授权两步握手的应答:按 id 回查在途请求,且应答连接必须在弹窗
2311
+ // 广播时的在线端集合里,才允许把 grant 持久写盘。
2312
+ case "plugin_dom_consent_response": {
2313
+ const id = typeof msg.id === "string" ? msg.id : "";
2314
+ const pendingConsent = findDomConsentById(id);
2315
+ if (pendingConsent && clientId && pendingConsent.recipients.has(clientId)) {
2316
+ clearTimeout(pendingConsent.timer);
2317
+ pendingDomConsents.delete(pendingConsent.pluginId);
2318
+ // ok=false = 显式拒绝:只清在途请求不落盘(当前前端自动应答只发
2319
+ // true,这里守住协议语义,将来接拒绝按钮不用动服务端)。
2320
+ if (msg.ok === true)
2321
+ applyDomConsent(pendingConsent.pluginId, true);
2322
+ }
2050
2323
  break;
2051
2324
  }
2052
2325
  case "plugin_path_revoke": {
@@ -2075,9 +2348,17 @@ wss.on("connection", (ws) => {
2075
2348
  customCatalogPath: pluginMgr.customCatalogPath,
2076
2349
  pluginsDir: join(DATA_DIR, "plugins"),
2077
2350
  installer: pluginInstaller,
2078
- afterWrite: () => reloadPluginsAndPush(syncLang),
2351
+ // 只更新市场列表时无需重启已激活插件,避免重复广播工作目录。
2352
+ afterWrite: () => (msg.install === true ? reloadPluginsAndPush(syncLang) : pluginMgr.pushCatalog()),
2079
2353
  lang: syncLang,
2354
+ // 本地文件来源只允许工作区内(防任意路径文件探测 oracle)。
2355
+ workspaceRoot: cs?.cwd ?? CWD,
2356
+ // 安装确认门(P0):拒绝/超时只写目录不安装。
2357
+ confirmInstall: (items) => confirmPluginInstall(items),
2080
2358
  }).then((r) => {
2359
+ if (r.installRefused) {
2360
+ cs?.emitNotice("warning", "目录已同步,但安装未获用户确认(拒绝或超时),未安装任何插件", "Catalog synced, but installation was not confirmed (denied or timed out) — nothing was installed");
2361
+ }
2081
2362
  send({
2082
2363
  type: "plugin_catalog_sync_result",
2083
2364
  requestId,
@@ -2129,6 +2410,19 @@ wss.on("connection", (ws) => {
2129
2410
  void cs.answerQuestion?.(msg.id, msg.answers, msg.cancelled);
2130
2411
  }
2131
2412
  break;
2413
+ case "tool_approval_response":
2414
+ cs.resolveToolApproval?.(msg.id, msg.decision, msg.editedParams, msg.reason, msg.scope);
2415
+ break;
2416
+ case "set_approval_policy":
2417
+ cs.setApprovalPolicy?.({
2418
+ conversationId: msg.conversationId,
2419
+ allowAll: msg.allowAll,
2420
+ categories: msg.categories,
2421
+ });
2422
+ break;
2423
+ case "plan_update":
2424
+ cs.updatePlan?.(msg.steps, msg.activeStepId, msg.conversationId);
2425
+ break;
2132
2426
  case "page_response":
2133
2427
  // 浏览器(page-picker 扩展经前端)对 browser_page 的回包:恢复挂起的
2134
2428
  // pageCall;id 不匹配(超时后迟到/页面刷新)由 resolvePageCall 静默忽略。
@@ -2143,6 +2437,18 @@ wss.on("connection", (ws) => {
2143
2437
  case "delete_subagent_template":
2144
2438
  void cs.deleteSubagentTemplate(msg.name);
2145
2439
  break;
2440
+ case "save_approval_rule":
2441
+ void cs.saveApprovalRule?.(msg.rule);
2442
+ break;
2443
+ case "save_approval_rules":
2444
+ void cs.saveApprovalRules?.(msg.rules);
2445
+ break;
2446
+ case "delete_approval_rule":
2447
+ void cs.deleteApprovalRule?.(msg.id);
2448
+ break;
2449
+ case "reset_builtin_approval_rule":
2450
+ void cs.resetBuiltinApprovalRule?.(msg.id);
2451
+ break;
2146
2452
  case "apply_preset":
2147
2453
  void cs.applyPreset(msg.name);
2148
2454
  break;
@@ -2218,6 +2524,22 @@ wss.on("connection", (ws) => {
2218
2524
  }
2219
2525
  }
2220
2526
  };
2527
+ /** ready 握手帧:首连与重复 hello 的幂等回包共用同一构造,避免两处漂移。 */
2528
+ const readyMsg = (cid) => ({
2529
+ type: "ready",
2530
+ clientId: cid,
2531
+ serverVersion: VERSION,
2532
+ protocolVersion: PROTOCOL_VERSION,
2533
+ engine: ENGINE,
2534
+ // This package's own version. `serverVersion` is the pi SDK's,
2535
+ // and the client used to learn ours from the update check —
2536
+ // which a managed instance never runs.
2537
+ appVersion: appVersion(),
2538
+ buildId: buildId(),
2539
+ managed: MANAGED,
2540
+ tabs: TABS ? [...TABS] : undefined,
2541
+ service: SERVICE_INFO ?? undefined,
2542
+ });
2221
2543
  ws.on("message", (data) => {
2222
2544
  let msg;
2223
2545
  try {
@@ -2227,28 +2549,43 @@ wss.on("connection", (ws) => {
2227
2549
  return;
2228
2550
  }
2229
2551
  if (msg.type === "hello") {
2230
- const cid = msg.clientId || randomUUID();
2552
+ // 重放守卫:一条连接只允许 attach 一次。clientId 已赋值说明 hello 处理过
2553
+ //(或进行中),再来的 hello 幂等回一条 ready 即可——否则重复 hello 会把
2554
+ // service.attach 整个再跑一遍:重型 ClientSession 重复创建 + 旧 send sink
2555
+ // 永久泄漏(removePluginSender 只在 close 时清一次)。
2556
+ if (clientId) {
2557
+ if (!closed)
2558
+ send(readyMsg(clientId));
2559
+ return;
2560
+ }
2561
+ // clientId 校验:外部输入,类型/长度/字符集不合格直接换 randomUUID()——
2562
+ // 它随后成为 ClientSession key 与上传落盘目录名(uploads/<clientId>/,
2563
+ // saveUpload 的两条路径都从这里来,入口统一拦一次即可)。
2564
+ const cid = validateClientId(msg.clientId) ?? randomUUID();
2231
2565
  clientId = cid;
2566
+ // issue #295:ready 先行 —— 传输握手不等待会话初始化。attach 会进 SDK 的
2567
+ // resourceLoader.reload 等同步目录扫描,坏挂载/家目录下可能阻塞数十秒;
2568
+ // ready 在握手里先发,前端立刻离开「正在连接」(快照随后到)。
2569
+ if (!closed) {
2570
+ send(readyMsg(cid));
2571
+ }
2572
+ // attach 慢提示:本体继续等,不取消;完成后照常走快照流程。
2573
+ const slowTimer = setTimeout(() => {
2574
+ if (closed)
2575
+ return;
2576
+ send({
2577
+ type: "notice",
2578
+ level: "warning",
2579
+ text: "会话初始化耗时较长(可能在扫描工作区目录),请稍候…",
2580
+ textEn: "Session init is taking a while (possibly scanning the workspace) — hang on…",
2581
+ });
2582
+ }, ATTACH_SLOW_NOTICE_MS);
2232
2583
  service
2233
2584
  .attach(cid, send)
2234
2585
  .then((cs) => {
2586
+ clearTimeout(slowTimer);
2235
2587
  if (closed)
2236
2588
  return;
2237
- send({
2238
- type: "ready",
2239
- clientId: cid,
2240
- serverVersion: VERSION,
2241
- protocolVersion: PROTOCOL_VERSION,
2242
- engine: ENGINE,
2243
- // This package's own version. `serverVersion` is the pi SDK's,
2244
- // and the client used to learn ours from the update check —
2245
- // which a managed instance never runs.
2246
- appVersion: appVersion(),
2247
- buildId: buildId(),
2248
- managed: MANAGED,
2249
- tabs: TABS ? [...TABS] : undefined,
2250
- service: SERVICE_INFO ?? undefined,
2251
- });
2252
2589
  // Plugin catalog: re-scan + activate new dirs on every attach so
2253
2590
  // freshly dropped plugins show up without a server restart.
2254
2591
  pluginMgr
@@ -2284,6 +2621,7 @@ wss.on("connection", (ws) => {
2284
2621
  catch {
2285
2622
  /* 推送失败不挡快照 */
2286
2623
  }
2624
+ replayQueued();
2287
2625
  })
2288
2626
  .catch(() => {
2289
2627
  if (closed)
@@ -2291,31 +2629,66 @@ wss.on("connection", (ws) => {
2291
2629
  // ensureLoaded 失败(如磁盘读错)不能卡死快照——前端 30s 无消息
2292
2630
  // 会重连,重连又失败会陷入循环。至少把状态推下去。
2293
2631
  cs.flushSnapshot();
2632
+ replayQueued();
2294
2633
  });
2295
2634
  // hello may carry the UI locale — persist it before replaying
2296
2635
  // anything queued during startup (issue #91).
2297
2636
  if (msg.locale)
2298
2637
  void service.setLocale(cid, msg.locale);
2299
- // Replay anything that arrived while the session was starting.
2300
- const queued = pending;
2301
- pending = [];
2302
- for (const m of queued)
2303
- dispatch(m);
2638
+ // attach 期间收到的命令先排队(dispatch 里的 pending),必须等插件链
2639
+ // 就绪后再重放:prompt 里可能是插件命令(/probe-grant 等),目录由
2640
+ // 下面的 applyPluginCommandCatalog 同步;提前重放会撞上「未知命令」。
2641
+ // ready 先行(issue #295)之前,客户端收到 ready 时 attach 已完成,
2642
+ // 首条命令天然落在插件加载之后;现在 ready 与 attach 脱钩,不等就重放
2643
+ // 等于把竞态窗口从一个 RTT 放大到整个插件扫描期(CI 必现 grant 超时)。
2644
+ const replayQueued = () => {
2645
+ attachDone = true;
2646
+ for (const m of pending.drain())
2647
+ dispatch(m);
2648
+ };
2304
2649
  })
2305
2650
  .catch((err) => {
2306
2651
  // Admission refused (quiesce): close the socket so the browser
2307
2652
  // reconnect loop keeps retrying until admission reopens. Do NOT
2308
2653
  // leave a half-alive connection that can only show an error.
2654
+ clearTimeout(slowTimer);
2309
2655
  if (err instanceof QuiesceRejectedError) {
2310
2656
  closed = true;
2311
2657
  if (ws.readyState === WebSocket.OPEN) {
2312
- ws.close(4403, "quiesced");
2658
+ // 先发 4403 关闭帧,等它刷出去再真正撕连接:close 后立刻
2659
+ // terminate 会把关闭握手一起掐掉,客户端只能看到 1006
2660
+ // (quiesce-test 的 brand-new client 断言 4403)。
2661
+ try {
2662
+ ws.close(4403, "quiesced");
2663
+ }
2664
+ catch {
2665
+ /* already closing */
2666
+ }
2667
+ setTimeout(() => {
2668
+ try {
2669
+ if (ws.readyState !== WebSocket.CLOSED)
2670
+ ws.terminate?.();
2671
+ }
2672
+ catch {
2673
+ /* ignore */
2674
+ }
2675
+ }, 500);
2676
+ }
2677
+ else {
2678
+ try {
2679
+ ws.terminate?.();
2680
+ }
2681
+ catch {
2682
+ /* ignore */
2683
+ }
2313
2684
  }
2314
- ws.terminate?.();
2315
2685
  return;
2316
2686
  }
2317
2687
  // Real init failure (bad agent dir etc.) — keep the connection
2318
2688
  // open so the user can see the error and fix it.
2689
+ // 排队的命令此时无会话可服务,直接丢弃(否则 attachDone 永 false,
2690
+ // 队列越积越深;用户修好后重连会重发 get_state)。
2691
+ pending.clear();
2319
2692
  send({
2320
2693
  type: "notice",
2321
2694
  level: "error",
@@ -2330,7 +2703,7 @@ wss.on("connection", (ws) => {
2330
2703
  ws.on("close", () => {
2331
2704
  service.noteSocketClose();
2332
2705
  closed = true;
2333
- pending = [];
2706
+ pending.clear();
2334
2707
  removePluginSender();
2335
2708
  if (snapshotRetryTimer) {
2336
2709
  clearTimeout(snapshotRetryTimer);
@@ -2371,11 +2744,12 @@ httpServer.listen(PORT, HOST, () => {
2371
2744
  console.log(` session dir : ${SESSION_DIR_ROOT}`);
2372
2745
  console.log(` pi SDK : v${VERSION}`);
2373
2746
  // issue #260:全局那份 pi SDK 不是服务在用的那份(自带副本赢在 Node 解析顺序上)。
2374
- // 不提示的话,用户会以为 `npm i -g @earendil-works/pi-coding-agent@latest` 生效了。
2747
+ // #321 起默认已反转(resolve-global-sdk 自动跟随更新的那份),这条提示只在
2748
+ // 「进程没跟上」(升级发生在启动后 / 旧构建没注入钩子)或显式 PI_WEB_SDK=bundled
2749
+ // 钉死自带副本时出现。
2375
2750
  const sdkNote = sdkOriginNote(sdkCopies(), VERSION);
2376
2751
  if (sdkNote) {
2377
2752
  console.log(` pi SDK note : ${sdkNote}`);
2378
- console.log(` Upgrading the global pi CLI does not change this server — upgrade pi-web-ui instead.`);
2379
2753
  }
2380
2754
  console.log(` bind : ${HOST}:${PORT}`);
2381
2755
  console.log("");
@@ -2398,7 +2772,8 @@ if (!bootCatalogDisabled) {
2398
2772
  customCatalogPath: pluginMgr.customCatalogPath,
2399
2773
  pluginsDir: join(DATA_DIR, "plugins"),
2400
2774
  installer: pluginInstaller,
2401
- afterWrite: () => reloadPluginsAndPush(),
2775
+ // 默认仅同步市场列表;重载插件会重复触发其激活广播。
2776
+ afterWrite: () => (autoInstall ? reloadPluginsAndPush() : pluginMgr.pushCatalog()),
2402
2777
  }).then((r) => {
2403
2778
  if (!r.ok) {
2404
2779
  console.warn(`[catalog] 插件目录预同步失败(不阻断启动): ${r.error}`);
@@ -2485,6 +2860,7 @@ async function shutdown(signal = "SIGINT") {
2485
2860
  pluginInstaller.dispose();
2486
2861
  mcpHotReload.dispose();
2487
2862
  mcpBridge.dispose();
2863
+ await globalLspPool.shutdownAll();
2488
2864
  await service.disposeAll();
2489
2865
  // Don't let dead browsers hold the exit open: half-open WebSocket /
2490
2866
  // keep-alive HTTP connections (e.g. test clients killed without