@youweichen/pi-web-ui 0.45.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 (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/bin/pi-web-ui.mjs +1818 -0
  4. package/deploy/com.xingshuyin.pi-web-ui.plist +48 -0
  5. package/deploy/nginx-subpath.conf +88 -0
  6. package/deploy/pi-web-ui-task.xml +71 -0
  7. package/deploy/pi-web-ui.service +31 -0
  8. package/dist/server/agent-service.js +2975 -0
  9. package/dist/server/attachments.js +672 -0
  10. package/dist/server/bg-servers.js +150 -0
  11. package/dist/server/client-state.js +234 -0
  12. package/dist/server/control-socket.js +177 -0
  13. package/dist/server/ensure-bash.js +80 -0
  14. package/dist/server/files-service.js +709 -0
  15. package/dist/server/goal-service.js +869 -0
  16. package/dist/server/index.js +879 -0
  17. package/dist/server/mcp-bridge.js +268 -0
  18. package/dist/server/model-admin.js +821 -0
  19. package/dist/server/patch-node-pty.js +80 -0
  20. package/dist/server/plugin-facilities.js +299 -0
  21. package/dist/server/plugin-updater.js +226 -0
  22. package/dist/server/plugins.js +893 -0
  23. package/dist/server/process-utils.js +112 -0
  24. package/dist/server/protocol-version.js +11 -0
  25. package/dist/server/protocol.js +6 -0
  26. package/dist/server/scm.js +298 -0
  27. package/dist/server/serialize.js +172 -0
  28. package/dist/server/settings-service.js +395 -0
  29. package/dist/server/slash-commands.js +263 -0
  30. package/dist/server/terminals.js +1372 -0
  31. package/dist/server/text-sniff.js +268 -0
  32. package/dist/server/uploads.js +107 -0
  33. package/dist/server/vision-bridge.js +131 -0
  34. package/dist/server/webui-context.js +222 -0
  35. package/extensions/webui.ts +192 -0
  36. package/package.json +99 -0
  37. package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +32 -0
  38. package/web/dist/assets/TerminalPanel-D4AktsEn.js +2 -0
  39. package/web/dist/assets/abnfDiagram-VCTEODGH-Ct5StJwt.js +1 -0
  40. package/web/dist/assets/arc-pkuxEN8A.js +1 -0
  41. package/web/dist/assets/architectureDiagram-5GKGNRK7-C6P6yn4y.js +36 -0
  42. package/web/dist/assets/blockDiagram-I7D4REHJ-BmgfH1jt.js +129 -0
  43. package/web/dist/assets/c4Diagram-7LVT6UL2-CgmwTFsK.js +38 -0
  44. package/web/dist/assets/channel-0Y__6sq2.js +1 -0
  45. package/web/dist/assets/chunk-2Q5K7J3B-DX_NqAiH.js +1 -0
  46. package/web/dist/assets/chunk-5VM5RSS4-gLeuQuYv.js +15 -0
  47. package/web/dist/assets/chunk-F27PBJKO-D4WX4mhI.js +1 -0
  48. package/web/dist/assets/chunk-IMKFNOWR-DBpDxGQD.js +231 -0
  49. package/web/dist/assets/chunk-JWPE2WC7-DIQ8iZN5.js +1 -0
  50. package/web/dist/assets/chunk-POPQ4Y6H-F_YTNoGz.js +1 -0
  51. package/web/dist/assets/chunk-SVP7TREG-CmDIvX-r.js +88 -0
  52. package/web/dist/assets/chunk-TICWLB2K-BQUzHQyS.js +206 -0
  53. package/web/dist/assets/chunk-XXDRQBXY-DqR9iffH.js +1 -0
  54. package/web/dist/assets/classDiagram-ZZMXUADV-BEfGIpzu.js +1 -0
  55. package/web/dist/assets/classDiagram-v2-VYDZK3BY-BEfGIpzu.js +1 -0
  56. package/web/dist/assets/cose-bilkent-JH36ORCC-Dl67W4Dx.js +1 -0
  57. package/web/dist/assets/cynefin-OW5HDTMX-fWdqaCpX.js +178 -0
  58. package/web/dist/assets/cynefinDiagram-5FMLGOSQ-CSUAXbaP.js +62 -0
  59. package/web/dist/assets/cytoscape.esm-Bch-eiPH.js +331 -0
  60. package/web/dist/assets/dagre-GXQ25YYZ-CqUVwu1X.js +4 -0
  61. package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  62. package/web/dist/assets/diagram-S7CK7UJ4-bRce-WYV.js +30 -0
  63. package/web/dist/assets/diagram-UQ7AKVKN-CiqPtPBE.js +41 -0
  64. package/web/dist/assets/diagram-VSXAHHWV-CbEA05LX.js +3 -0
  65. package/web/dist/assets/diagram-VX7I27RA-CRcY9MD-.js +24 -0
  66. package/web/dist/assets/diagram-Z3DM3KII-DmTwvZdS.js +24 -0
  67. package/web/dist/assets/ebnfDiagram-PWID7BFC-DbwmiGVj.js +1 -0
  68. package/web/dist/assets/erDiagram-RLTQ6QDP-Cxiaf87y.js +99 -0
  69. package/web/dist/assets/flowDiagram-HODETNUW-BQiecP1I.js +168 -0
  70. package/web/dist/assets/ganttDiagram-EL5Y4UJY-CSr8RRNw.js +292 -0
  71. package/web/dist/assets/gitGraphDiagram-WWUBYQGX-C4Tdis3g.js +106 -0
  72. package/web/dist/assets/index-CniHcFxT.js +22 -0
  73. package/web/dist/assets/index-DYz2iP_B.css +10 -0
  74. package/web/dist/assets/infoDiagram-27XIBGKW-ij_5A0pY.js +2 -0
  75. package/web/dist/assets/init-Gi6I4Gst.js +1 -0
  76. package/web/dist/assets/ishikawaDiagram-5VMMS53U-C8-tm2LV.js +70 -0
  77. package/web/dist/assets/journeyDiagram-3NMN7TZE-ZJedkNel.js +139 -0
  78. package/web/dist/assets/kanban-definition-UXKFOSKX-BEuoM53n.js +89 -0
  79. package/web/dist/assets/katex-C5jXJg4s.js +257 -0
  80. package/web/dist/assets/layout-zq_9DigZ.js +1 -0
  81. package/web/dist/assets/linear-D-QXJ5Wc.js +1 -0
  82. package/web/dist/assets/markdown-CUPbftfV.js +51 -0
  83. package/web/dist/assets/mermaid.core-CCiG-bOm.js +312 -0
  84. package/web/dist/assets/mindmap-definition-YA3MSWOX-VaAisUIe.js +96 -0
  85. package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  86. package/web/dist/assets/pegDiagram-XKGWAZYB-D5vmRtkn.js +1 -0
  87. package/web/dist/assets/pieDiagram-E7YTZNPT-loD6V8fn.js +39 -0
  88. package/web/dist/assets/quadrantDiagram-AXDQQJYC-B7E4mH1-.js +7 -0
  89. package/web/dist/assets/railroadDiagram-O6MQD6OU-DTALHvDa.js +1 -0
  90. package/web/dist/assets/react-ZkKJMDQQ.js +24 -0
  91. package/web/dist/assets/requirementDiagram-BXWQKSXE-ZjJKuEcR.js +84 -0
  92. package/web/dist/assets/sankeyDiagram-P5KCCOFB-DufTudDP.js +40 -0
  93. package/web/dist/assets/sequenceDiagram-WJ2MYXX4-B-Zk4alI.js +162 -0
  94. package/web/dist/assets/sizeCapture-INFHLROL-B1pjlxXN.js +1 -0
  95. package/web/dist/assets/stateDiagram-D77RDMKH-BeWg6PIR.js +1 -0
  96. package/web/dist/assets/stateDiagram-v2-MP3YSRHH-DagWhIFJ.js +1 -0
  97. package/web/dist/assets/swimlanes-42K2YHIH-4J4jmMmO.js +1 -0
  98. package/web/dist/assets/swimlanesDiagram-VR7AAH4N-Dsi9x540.js +8 -0
  99. package/web/dist/assets/timeline-definition-24CTP7MA-ByqLfxqe.js +120 -0
  100. package/web/dist/assets/vennDiagram-4TSXK5OY-B_EqVW4R.js +34 -0
  101. package/web/dist/assets/wardleyDiagram-VM6X3IG4-Bu7sA6F8.js +78 -0
  102. package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
  103. package/web/dist/assets/xychartDiagram-S5SC5T6Z-w1KPVGKk.js +7 -0
  104. package/web/dist/favicon.svg +8 -0
  105. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  106. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  107. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  108. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  109. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  110. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
  111. package/web/dist/fonts/inter/inter-latin-400-normal.woff2 +0 -0
  112. package/web/dist/fonts/inter/inter-latin-500-normal.woff2 +0 -0
  113. package/web/dist/fonts/inter/inter-latin-600-normal.woff2 +0 -0
  114. package/web/dist/fonts/inter/inter-latin-700-normal.woff2 +0 -0
  115. package/web/dist/fonts/inter/inter-latin-800-normal.woff2 +0 -0
  116. package/web/dist/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
  117. package/web/dist/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
  118. package/web/dist/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
  119. package/web/dist/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
  120. package/web/dist/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
  121. package/web/dist/icon.ico +0 -0
  122. package/web/dist/index.html +17 -0
  123. package/web/public/favicon.svg +8 -0
  124. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  125. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  126. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  127. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  128. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  129. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
  130. package/web/public/fonts/inter/inter-latin-400-normal.woff2 +0 -0
  131. package/web/public/fonts/inter/inter-latin-500-normal.woff2 +0 -0
  132. package/web/public/fonts/inter/inter-latin-600-normal.woff2 +0 -0
  133. package/web/public/fonts/inter/inter-latin-700-normal.woff2 +0 -0
  134. package/web/public/fonts/inter/inter-latin-800-normal.woff2 +0 -0
  135. package/web/public/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
  136. package/web/public/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
  137. package/web/public/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
  138. package/web/public/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
  139. package/web/public/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
  140. package/web/public/icon.ico +0 -0
@@ -0,0 +1,879 @@
1
+ /**
2
+ * pi-web-ui server entry.
3
+ *
4
+ * - Serves the built frontend (web/dist) in production; in dev, Vite serves it
5
+ * on :5173 and proxies /ws to this server.
6
+ * - Exposes /api/health and a WebSocket endpoint at /ws carrying the chat
7
+ * protocol defined in protocol.ts.
8
+ *
9
+ * Env:
10
+ * PORT HTTP port (default 8787)
11
+ * PI_WEB_CWD workspace the agent operates in (default: process.cwd())
12
+ * PI_WEB_DATA_DIR where per-client UI state is stored (client-state.json,
13
+ * default: <home>/.pi-web). Chat sessions are NOT stored here — they live
14
+ * in the pi agent's global TUI session dir (~/.pi/agent/sessions/--<cwd>--/)
15
+ * via the SDK default, so this web UI, the dev instance, and the pi CLI/TUI
16
+ * all share one conversation list per project.
17
+ * PI_CODING_AGENT_DIR pi config dir (auth/models/skills) — passed to the SDK
18
+ */
19
+ import { existsSync } from "node:fs";
20
+ import { stat } from "node:fs/promises";
21
+ import { createServer } from "node:http";
22
+ import { createConnection } from "node:net";
23
+ import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
24
+ import { homedir } from "node:os";
25
+ import { fileURLToPath } from "node:url";
26
+ import { randomUUID } from "node:crypto";
27
+ import express from "express";
28
+ import compression from "compression";
29
+ import { WebSocket, WebSocketServer } from "ws";
30
+ import { VERSION, getAgentDir } from "@earendil-works/pi-coding-agent";
31
+ import { PROTOCOL_VERSION } from "./protocol-version.js";
32
+ import { AgentService, workspacePath, QuiesceRejectedError, } from "./agent-service.js";
33
+ import { previewKind } from "./text-sniff.js";
34
+ import { startControlServer } from "./control-socket.js";
35
+ import { scheduleUploadCleanup } from "./uploads.js";
36
+ import { ensureWindowsBash, windowsBashDir } from "./ensure-bash.js";
37
+ import { PluginManager, resolvePluginClientFile } from "./plugins.js";
38
+ import { McpBridge } from "./mcp-bridge.js";
39
+ const PORT = Number(process.env.PORT ?? 8787);
40
+ const CWD = resolve(process.env.PI_WEB_CWD ?? process.cwd());
41
+ const DATA_DIR = resolve(process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web"));
42
+ /** Bind address. Default is loopback ONLY — the service is a local personal
43
+ * tool and should not be reachable from the network unless explicitly asked
44
+ * (e.g. PI_WEB_HOST=0.0.0.0 for LAN access / Docker port mapping). */
45
+ const HOST = process.env.PI_WEB_HOST ?? "127.0.0.1";
46
+ /** Optional strict hostname allowlist (comma-separated) — only used when set.
47
+ * Origin / Host same-authority matching happens regardless. */
48
+ const ALLOW_HOSTS = (process.env.PI_WEB_ALLOW_HOSTS ?? "")
49
+ .split(",")
50
+ .map((s) => s.trim().toLowerCase())
51
+ .filter(Boolean);
52
+ /** Optional extra Origins allowed through the same-authority check (comma-
53
+ * separated, e.g. reverse-proxy setups where the browser origin differs
54
+ * from the Host the backend sees). */
55
+ const ALLOW_ORIGINS = (process.env.PI_WEB_ALLOW_ORIGINS ?? "")
56
+ .split(",")
57
+ .map((s) => s.trim().toLowerCase())
58
+ .filter(Boolean);
59
+ /** 可选共享口令(PI_WEB_TOKEN):设置后所有 HTTP/WS 请求必须携带——
60
+ * Authorization: Bearer / X-PI-Token 头、?token= 查询参数或 pi_web_token cookie
61
+ * 任一匹配即可;供 0.0.0.0 / 反代等暴露场景兜底,未设置则行为不变。 */
62
+ const AUTH_TOKEN = process.env.PI_WEB_TOKEN?.trim() ?? "";
63
+ // Root of the SDK default per-project session dirs — chat transcripts live in
64
+ // <SESSION_DIR_ROOT>/--<cwd>--/, shared with the pi CLI/TUI (getAgentDir
65
+ // honors PI_CODING_AGENT_DIR).
66
+ const SESSION_DIR_ROOT = join(getAgentDir(), "sessions");
67
+ // Windows 轻量 bash 兜底:把 <home>/.pi-web/bin 前置到 PATH(SDK 的 bash 工具经
68
+ // findBashOnPath 会找到其中的 bash.exe),并在无 Git Bash 时后台下载 busybox-w32。
69
+ // 终端面板的 shell 探测链也已包含该目录(见 terminals.ts resolveShell)。
70
+ if (process.platform === "win32") {
71
+ process.env.PATH = `${windowsBashDir()}${delimiter}${process.env.PATH ?? ""}`;
72
+ void ensureWindowsBash();
73
+ }
74
+ const app = express();
75
+ app.use(express.json({ limit: "10mb" }));
76
+ /** 从请求中提取候选 token:头 / 查询参数 / cookie(浏览器导航场景靠 cookie 续命)。 */
77
+ function requestTokens(req) {
78
+ const out = [];
79
+ const auth = req.headers.authorization;
80
+ if (typeof auth === "string" && auth.startsWith("Bearer "))
81
+ out.push(auth.slice(7).trim());
82
+ const header = req.headers["x-pi-token"];
83
+ if (typeof header === "string")
84
+ out.push(header.trim());
85
+ try {
86
+ const q = new URL(req.url ?? "/", "http://localhost").searchParams.get("token");
87
+ if (q)
88
+ out.push(q.trim());
89
+ }
90
+ catch {
91
+ /* ignore malformed url */
92
+ }
93
+ const cookie = req.headers.cookie;
94
+ if (typeof cookie === "string") {
95
+ for (const part of cookie.split(";")) {
96
+ const [k, ...rest] = part.trim().split("=");
97
+ if (k === "pi_web_token")
98
+ out.push(rest.join("=").trim());
99
+ }
100
+ }
101
+ return out.filter(Boolean);
102
+ }
103
+ function tokenOk(req) {
104
+ return requestTokens(req).includes(AUTH_TOKEN);
105
+ }
106
+ if (AUTH_TOKEN) {
107
+ // /api/health 保持开放:无敏感信息,容器/监控探针需要它
108
+ app.use((req, res, next) => {
109
+ if (req.path === "/api/health" || tokenOk(req)) {
110
+ // 浏览器经 ?token= 首次进入后下发 HttpOnly cookie,后续导航/资源请求免带参数
111
+ if (!req.headers.cookie?.includes("pi_web_token=")) {
112
+ res.setHeader("Set-Cookie", `pi_web_token=${encodeURIComponent(AUTH_TOKEN)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000`);
113
+ }
114
+ next();
115
+ return;
116
+ }
117
+ res.status(401).send("unauthorized: PI_WEB_TOKEN required (?token=…)");
118
+ });
119
+ }
120
+ app.get("/api/health", (_req, res) => {
121
+ res.json({ ok: true, piVersion: VERSION, cwd: CWD, pid: process.pid });
122
+ });
123
+ /**
124
+ * Stream a workspace file over HTTP.
125
+ *
126
+ * Media preview (no download param): only image/video kinds are served —
127
+ * text goes over the WebSocket, and exe/jar/etc. are never exposed here.
128
+ * express's sendFile handles Range requests, so video seeking works.
129
+ *
130
+ * Download (?download=1): any file kind is served with
131
+ * Content-Disposition: attachment so the browser saves it instead of
132
+ * rendering. Path is validated against the workspace root either way.
133
+ */
134
+ app.get("/api/file", async (req, res) => {
135
+ try {
136
+ const raw = typeof req.query.path === "string" ? req.query.path : "";
137
+ // Resolve against the requesting client's workspace (the opened
138
+ // project), not the server's startup cwd — they can differ when the
139
+ // client switched projects or restored a previous workspace. Fall
140
+ // back to the server cwd for requests without a known client.
141
+ const cid = typeof req.query.clientId === "string" ? req.query.clientId : "";
142
+ const cs = cid ? service.get(cid) : undefined;
143
+ const wp = workspacePath(cs?.cwd ?? CWD, raw);
144
+ if (!wp) {
145
+ res.status(400).end("path outside workspace");
146
+ return;
147
+ }
148
+ const abs = wp.abs;
149
+ const name = basename(abs);
150
+ const kind = previewKind(name);
151
+ const isDownload = req.query.download === "1";
152
+ if (!isDownload && kind !== "image" && kind !== "video") {
153
+ res.status(400).end("not a previewable media file");
154
+ return;
155
+ }
156
+ const st = await stat(abs);
157
+ if (!st.isFile()) {
158
+ res.status(400).end("not a file");
159
+ return;
160
+ }
161
+ if (isDownload) {
162
+ // res.download sets Content-Disposition: attachment and RFC 5987
163
+ // filename* encoding for non-ASCII names.
164
+ res.download(abs, name);
165
+ }
166
+ else {
167
+ res.sendFile(abs);
168
+ }
169
+ }
170
+ catch {
171
+ res.status(404).end("not found");
172
+ }
173
+ });
174
+ // Production: serve the built frontend from web/dist. Resolve relative to this
175
+ // module so it works when installed as a package (global/npx/Docker), not just
176
+ // from the repo root. In dev, Vite serves the UI on :5173 and proxies /ws.
177
+ const here = dirname(fileURLToPath(import.meta.url)); // <pkg>/dist/server or <pkg>/server
178
+ // Resolve the package root robustly: dev runs from <repo>/server (tsx), prod
179
+ // from <pkg>/dist/server — the ancestor that actually has package.json wins.
180
+ function resolvePkgRoot() {
181
+ // PI_WEB_PKG_ROOT: Electron 桌面版打包后,server 子进程从 extraResources 目录
182
+ //(process.resourcesPath)加载 web/dist 和 themes。通过这个 env var 告诉
183
+ // server 去哪里找 pkgRoot,避免 resolvePkgRoot 的候选路径找不到 package.json。
184
+ if (process.env.PI_WEB_PKG_ROOT)
185
+ return process.env.PI_WEB_PKG_ROOT;
186
+ const candidates = [
187
+ resolve(here, ".."),
188
+ resolve(here, "..", ".."),
189
+ resolve(here, "..", "..", ".."),
190
+ ];
191
+ for (const c of candidates) {
192
+ if (existsSync(join(c, "package.json")))
193
+ return c;
194
+ }
195
+ return candidates[0];
196
+ }
197
+ const pkgRoot = resolvePkgRoot();
198
+ // Plugin client bundles: <dataDir>/plugins/<id>/client/* served at
199
+ // /plugins/<id>/client/* so the frontend can import() plugin views. Only the
200
+ // client/ subtree is exposed — manifest.json and the server-side index.mjs
201
+ // (which may hold credentials) never leave the machine. Registered BEFORE the
202
+ // SPA catch-all below.
203
+ const PLUGINS_DIR = join(DATA_DIR, "plugins");
204
+ // 插件 HTTP 路由挂载点:host.route("GET", "/inbox") 实际暴露为
205
+ // /plugins-api/<id>/inbox。PI_WEB_TOKEN 鉴权(上方 app.use)自动覆盖;
206
+ // 响应已在前面过了 express.json。注意不要在此 catch-all 里消费 body。
207
+ app.all(["/plugins-api/:id/*", "/plugins-api/:id"], (req, res) => {
208
+ const rest = String(req.params[0] ?? "");
209
+ pluginMgr.handleHttp(String(req.params.id ?? ""), req.method, rest, req, res);
210
+ });
211
+ app.get("/plugins/:id/client/*", (req, res) => {
212
+ // express 4 的通配参数在运行时落在 params[0],但类型声明里没有 —— 显式取
213
+ const rest = String(req.params[0] ?? "");
214
+ const abs = resolvePluginClientFile(PLUGINS_DIR, req.params.id, rest);
215
+ if (!abs) {
216
+ res.status(404).end("plugin not found");
217
+ return;
218
+ }
219
+ // .mjs 常不在老 mime 表里,手动定 Content-Type 保证 import() 可用
220
+ if (/\.(mjs|js)$/.test(abs)) {
221
+ res.setHeader("Content-Type", "text/javascript; charset=utf-8");
222
+ }
223
+ res.setHeader("Cache-Control", "no-cache"); // 开发期改文件即生效
224
+ res.sendFile(abs, (err) => {
225
+ if (err && !res.headersSent)
226
+ res.status(err.statusCode === 404 ? 404 : 500).end("not found");
227
+ });
228
+ });
229
+ /** Set in the env of the replacement child spawned by a self-update restart. */
230
+ const RESTART_CHILD_ENV = "PI_WEB_RESTART_CHILD";
231
+ const webDist = join(pkgRoot, "web", "dist");
232
+ if (existsSync(webDist)) {
233
+ // gzip/deflate 响应压缩:前端 bundle ~1MB,局域网/反代场景传输量降到 ~1/4;
234
+ // 对 API JSON 同样生效,WS 升级不受影响
235
+ app.use(compression());
236
+ app.use(express.static(webDist, {
237
+ // Vite 产物文件名带内容 hash,可永久强缓存——业务发版后 hash 变化自然失效,
238
+ // index.html 由下方 catch-all 处理(sendFile 不走这里)
239
+ setHeaders(res, filePath) {
240
+ if (filePath.includes(`${sep}assets${sep}`)) {
241
+ res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
242
+ }
243
+ },
244
+ }));
245
+ app.get(/^\/(?!api\/|ws).*/, (_req, res) => {
246
+ // Callback form: a failed stat here (npm i -g is mid-replacement of the
247
+ // package dir) responds 503 instead of crashing the request pipeline
248
+ // with an unhandled ENOENT stack trace.
249
+ res.sendFile(join(webDist, "index.html"), (err) => {
250
+ if (err && !res.headersSent) {
251
+ res.status(503).send("正在更新 pi-web-ui,请稍后刷新…");
252
+ }
253
+ });
254
+ });
255
+ }
256
+ else if (process.env[RESTART_CHILD_ENV]) {
257
+ // Auto-restart replacement of a self-update whose npm install did not
258
+ // complete (Windows: locked files / rollback can leave the global package
259
+ // without web/dist). Fail loudly with a repair hint instead of serving a
260
+ // UI-less 404 with no explanation.
261
+ console.error("✖ 更新后的安装不完整(缺少 web/dist/index.html)。\n" +
262
+ " 请手动执行 npm i -g pi-web-ui@latest 修复后重新启动。");
263
+ process.exit(1);
264
+ }
265
+ const httpServer = createServer(app);
266
+ const wss = new WebSocketServer({
267
+ noServer: true,
268
+ // Per-message deflate: big-session snapshots serialize to multi-MB JSON
269
+ // strings; wire-level compression cuts that several-fold. threshold keeps
270
+ // tiny messages (notices/heartbeats) uncompressed to save CPU.
271
+ perMessageDeflate: { threshold: 16 * 1024 },
272
+ });
273
+ // ---------------------------------------------------------------------------
274
+ // Origin / Host admission for WebSocket upgrades.
275
+ //
276
+ // Browsers attach an Origin header; non-browser clients (curl, ws scripts)
277
+ // usually don't — they're admitted by the network layer / reverse proxy.
278
+ // Rules (checked in order):
279
+ // 4. No Origin header → admit (non-browser client).
280
+ // 5. Anything else → 403 + close.
281
+ //
282
+ // Dev-mode note: the Vite dev server (:5173) proxies /ws to the backend on
283
+ // :8788, so their authorities differ — the dev:server script sets
284
+ // PI_WEB_ALLOW_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 for that.
285
+ // LAN / reverse-proxy setups add their own origin the same way.
286
+ // ---------------------------------------------------------------------------
287
+ /** "host" or "host:port" → { hostname, port }. */
288
+ function parseAuthority(a) {
289
+ try {
290
+ const u = new URL(`http://${a}`);
291
+ return { hostname: u.hostname.toLowerCase(), port: u.port || "80" };
292
+ }
293
+ catch {
294
+ return { hostname: "", port: "" };
295
+ }
296
+ }
297
+ function originAllowed(req) {
298
+ const hostHeader = (req.headers.host ?? "").toLowerCase();
299
+ const host = parseAuthority(hostHeader);
300
+ if (ALLOW_HOSTS.length > 0 && !ALLOW_HOSTS.includes(host.hostname)) {
301
+ return false;
302
+ }
303
+ const origin = req.headers.origin;
304
+ if (!origin)
305
+ return true; // non-browser client
306
+ const o = origin.toLowerCase();
307
+ if (ALLOW_ORIGINS.includes(o))
308
+ return true;
309
+ if (o === "null")
310
+ return false; // file:// pages etc. are not trusted
311
+ const ori = parseAuthority(o.replace(/^[a-z]+:\/\//, ""));
312
+ if (ori.hostname === host.hostname && ori.port === host.port)
313
+ return true;
314
+ // Browsers treat host:port pairs on the SAME host as different origins —
315
+ // do not accept them. (Dev-mode proxying is handled by PI_WEB_ALLOW_ORIGINS
316
+ // set in the dev:server script; LAN/reverse-proxy setups add their origin.)
317
+ return false;
318
+ }
319
+ httpServer.on("upgrade", (req, socket, head) => {
320
+ let pathname = "/";
321
+ try {
322
+ pathname = new URL(req.url ?? "/", "http://localhost").pathname;
323
+ }
324
+ catch {
325
+ /* fall through to the path check below */
326
+ }
327
+ if (pathname !== "/ws") {
328
+ socket.destroy();
329
+ return;
330
+ }
331
+ if (!originAllowed(req)) {
332
+ // Reject cross-origin browser pages outright. The browser sees a failed
333
+ // WS connect; the page's own reconnect loop then backs off and retries.
334
+ socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
335
+ socket.destroy();
336
+ return;
337
+ }
338
+ if (AUTH_TOKEN && !tokenOk(req)) {
339
+ socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
340
+ socket.destroy();
341
+ return;
342
+ }
343
+ wss.handleUpgrade(req, socket, head, (ws) => {
344
+ wss.emit("connection", ws, req);
345
+ });
346
+ });
347
+ // Heartbeat: lets clients detect half-open connections (server killed without
348
+ // closing sockets, sleep/wake, network partitions). Idle connections otherwise
349
+ // carry no traffic and TCP keepalive defaults are far too slow (~2h).
350
+ const heartbeatTimer = setInterval(() => {
351
+ for (const ws of wss.clients) {
352
+ if (ws.readyState === WebSocket.OPEN) {
353
+ ws.send(JSON.stringify({ type: "heartbeat" }));
354
+ }
355
+ }
356
+ }, 10_000);
357
+ const service = new AgentService(CWD,
358
+ // Per-client persisted UI state: last-used workspace + recent projects.
359
+ join(DATA_DIR, "client-state.json"));
360
+ // Optional UI plugins (<dataDir>/plugins/<id>/): scanned on every client
361
+ // attach so freshly dropped plugins appear without a server restart.
362
+ const pluginMgr = new PluginManager(DATA_DIR, CWD);
363
+ // MCP 工具桥:读取 <dataDir>/mcp.json 启动外部 MCP 服务器(stdio),把它们的
364
+ // 工具并入与插件工具相同的 customTools 管线;单服务器失败不炸进程。
365
+ const mcpBridge = new McpBridge(DATA_DIR, (...a) => console.log("[mcp]", ...a));
366
+ void mcpBridge.load().then(() => {
367
+ if (mcpBridge.getTools().length)
368
+ service.applyPluginAgentTools();
369
+ });
370
+ // 插件扩展点:SDK 工具执行事件(bash/读文件等 start+end)转发给已注册的插件。
371
+ service.onToolEvent = (ev) => pluginMgr.emitToolEvent(ev);
372
+ // 插件扩展点:插件注册的 AI 工具(registerAgentTool)+ MCP 桥工具 → 会话创建时
373
+ // 带上 + 变化时动态注入/移除已有会话。
374
+ service.pluginToolsProvider = () => [...pluginMgr.getAgentTools(), ...mcpBridge.getTools()];
375
+ pluginMgr.onAgentToolsChanged = () => service.applyPluginAgentTools();
376
+ // 插件扩展点:插件斜杠命令(registerCommand)→ 命令选择器目录 + prompt 拦截执行。
377
+ pluginMgr.onCommandsChanged = () => service.applyPluginCommandCatalog();
378
+ service.pluginCommandsProvider = () => pluginMgr.listCommands();
379
+ // 插件扩展点:插件常驻后台任务(registerBackgroundTask)→ 并入「后台任务」面板。
380
+ pluginMgr.onBgTasksChanged = () => service.refreshBackgroundServers();
381
+ service.pluginBgTasksProvider = () => pluginMgr.bgTasks();
382
+ service.pluginStopBgTask = (taskId) => pluginMgr.stopPluginBgTask(taskId);
383
+ // 插件宿主工作区实时跟随当前项目:任意客户端 set_cwd 成功后同步给
384
+ // PluginManager,编辑器等工作区跟随型插件随即切根(详见 plugins.ts notifyCwd)。
385
+ service.onClientCwdChanged = (cwd) => pluginMgr.notifyCwd(cwd);
386
+ // ---------------------------------------------------------------------------
387
+ // Self-update
388
+ // ---------------------------------------------------------------------------
389
+ // In-app updates now run `npm i -g pi-web-ui@latest` in a visible terminal
390
+ // tab (frontend-initiated); after it finishes the user restarts via
391
+ // `pi-web-ui server restart`. The PI_WEB_RESTART_CHILD port-wait handshake
392
+ // below stays: an externally orchestrated replacement child still needs it.
393
+ function scheduleQuit() {
394
+ const isLaunchd = process.platform === "darwin" && process.ppid === 1;
395
+ const isSystemd = process.platform === "linux" && !!process.env.INVOCATION_ID;
396
+ const inDocker = existsSync("/.dockerenv");
397
+ if (isLaunchd || isSystemd || inDocker) {
398
+ setTimeout(() => {
399
+ console.log("pi-web-ui:quit — shutting down (supervisor will restart)…");
400
+ if (isSystemd)
401
+ process.exit(3);
402
+ void shutdown();
403
+ }, 300);
404
+ return true;
405
+ }
406
+ setTimeout(() => {
407
+ console.log("pi-web-ui:quit — shutting down (restart to reload)…");
408
+ void shutdown();
409
+ }, 300);
410
+ return true;
411
+ }
412
+ service.onQuit = scheduleQuit;
413
+ /** 背压相对倍数:socket 未发送积压超过「最近一份 snapshot 大小 × 此倍数」时丢弃
414
+ * (issue #11 及其评论区的自适应建议)。固定 1MB 阈值在长会话下单份 snapshot 可达
415
+ * ~10MB——连半份都没发完就丢,前端频繁跳帧;短会话又太迟钝。相对阈值语义稳定在
416
+ * 「缓冲堆了约 N 份快照」,不随会话长短漂移。 */
417
+ const SNAPSHOT_BACKPRESSURE_FACTOR = 3;
418
+ /** 背压绝对下限:低于此积压永不丢快照(小会话的相对阈值只有几 KB,会被
419
+ * 正常的消息突发误伤,见 send() 内注释)。 */
420
+ const SNAPSHOT_BACKPRESSURE_MIN_BYTES = 262_144;
421
+ /** 背压丢弃后的延迟重发间隔。 */
422
+ const SNAPSHOT_RETRY_MS = 250;
423
+ /**
424
+ * Multi-tab serialization sharing: emit() hands the SAME message object to
425
+ * every socket of a client, but each send() used to JSON.stringify it
426
+ * separately — N open tabs serialized the same multi-MB snapshot N times per
427
+ * push. Keyed by object identity (WeakMap): a new snapshot is a new object,
428
+ * so the cache self-invalidates and never grows.
429
+ */
430
+ const serializedCache = new WeakMap();
431
+ function serializeShared(msg) {
432
+ let s = serializedCache.get(msg);
433
+ if (s === undefined) {
434
+ s = JSON.stringify(msg);
435
+ serializedCache.set(msg, s);
436
+ }
437
+ return s;
438
+ }
439
+ wss.on("connection", (ws) => {
440
+ // Count attached sockets (the control socket reports REAL sockets, not
441
+ // cached client-session objects).
442
+ service.noteSocketOpen();
443
+ let clientId = null;
444
+ let closed = false;
445
+ /** 最近一份全量 snapshot 的估算字节数(UTF-16 ×2),供背压相对阈值用(issue #11)。 */
446
+ let lastSnapshotBytes = 0;
447
+ /** Commands received while the session is still being created — replayed after attach. */
448
+ let pending = [];
449
+ /** 背压丢快照后的延迟重发定时器(去重:一次只排一个)。 */
450
+ let snapshotRetryTimer = null;
451
+ // 协议层错误(非法帧/未 masked 帧等):不注册 handler 会作为 uncaught
452
+ // exception 打崩整个进程(issue #11 附带发现)。记日志并按坏连接关闭。
453
+ ws.on("error", (err) => {
454
+ console.error(`[ws] socket error${clientId ? ` (${clientId})` : ""}:`, err.message);
455
+ try {
456
+ ws.close();
457
+ }
458
+ catch {
459
+ /* already closing */
460
+ }
461
+ });
462
+ const send = (msg) => {
463
+ if (closed || ws.readyState !== WebSocket.OPEN)
464
+ return;
465
+ // 发送背压(issue #11):socket 消费不过来时(前端慢/网络差),堆里会堆积
466
+ // 每份可达 ~10MB 的全量 snapshot 字符串,低内存主机直接 OOM。snapshot 是全量
467
+ // 幂等的且稍后必有更新的一份,可以安全丢弃——在序列化之前丢,连
468
+ // stringify 的分配都省掉。ready/notice/error/tool_delta 等消息必须送达。
469
+ // 阈值相对化(评论区建议):用「最近一份 snapshot 的字节数 × 倍数」做基准,
470
+ // 首份无基准不丢(首次必达)。wire.length 是 UTF-16 字符数,×2 估算字节。
471
+ // 下限保护(小会话误伤修复):小会话一份 snapshot 才 ~1KB,相对阈值只有几
472
+ // KB——前面一批 settings_state/slash_commands 的正常突发就能把 bufferedAmount
473
+ // 抬过阈值,把紧随其后的 snapshot_delta 静默丢掉;而丢弃后若无后续事件就
474
+ // 再也没有快照,客户端永远停在旧状态(前端靠 rev 缺口 get_state 自愈,
475
+ // 协议测试则直接卡死)。绝对下限保证小会话永不触发背压。
476
+ if ((msg.type === "snapshot" || msg.type === "snapshot_delta") &&
477
+ lastSnapshotBytes > 0 &&
478
+ ws.bufferedAmount > Math.max(SNAPSHOT_BACKPRESSURE_MIN_BYTES, SNAPSHOT_BACKPRESSURE_FACTOR * lastSnapshotBytes)) {
479
+ // 真正的慢客户端:丢弃是安全的,但不能「丢完就没了」——安排一次延迟
480
+ // 重发,等缓冲排空后快照最终必达(否则若此后再无事件,客户端将永久
481
+ // 停留在旧快照)。重发仍走 flushSnapshot:缓冲未排空则再次顺延。
482
+ if (!snapshotRetryTimer) {
483
+ snapshotRetryTimer = setTimeout(() => {
484
+ snapshotRetryTimer = null;
485
+ service.get(clientId ?? "")?.flushSnapshot();
486
+ }, SNAPSHOT_RETRY_MS);
487
+ }
488
+ return;
489
+ }
490
+ const wire = serializeShared(msg);
491
+ if (msg.type === "snapshot")
492
+ lastSnapshotBytes = wire.length * 2;
493
+ ws.send(wire);
494
+ };
495
+ // Plugins broadcast to every open socket; unregister on close below.
496
+ // Plugins broadcast to every open socket; unregister on close below. The
497
+ // cid getter lets plugins target THIS socket via host.sendTo(clientId).
498
+ const removePluginSender = pluginMgr.addSender(send, () => clientId);
499
+ const dispatch = (msg) => {
500
+ if (!clientId) {
501
+ pending.push(msg);
502
+ return;
503
+ }
504
+ const cs = service.get(clientId);
505
+ if (!cs) {
506
+ // Session not ready yet (hello processing) — hold the command.
507
+ pending.push(msg);
508
+ return;
509
+ }
510
+ switch (msg.type) {
511
+ case "prompt":
512
+ void cs.prompt(msg.text, msg.attachments, msg.queue);
513
+ break;
514
+ case "abort":
515
+ void cs.abort();
516
+ break;
517
+ case "abort_bash":
518
+ void cs.abortBash();
519
+ break;
520
+ case "kill_background_server":
521
+ void cs.killBackgroundServer(msg.port, msg.taskId);
522
+ break;
523
+ case "kill_background_servers":
524
+ void cs.killAllBackgroundServers();
525
+ break;
526
+ case "list_bg_servers":
527
+ void cs.listBgServers();
528
+ break;
529
+ case "new_chat":
530
+ void cs.newChat();
531
+ break;
532
+ case "edit_message":
533
+ void cs.editMessage(msg.messageId, msg.text, msg.attachments);
534
+ break;
535
+ case "cycle_model":
536
+ void cs.cycleModel();
537
+ break;
538
+ case "cycle_thinking":
539
+ cs.cycleThinking();
540
+ break;
541
+ case "get_state":
542
+ // Always a FULL snapshot: the client is (re)connecting or detected
543
+ // a rev/seq gap — it needs an authoritative state to rebuild from.
544
+ cs.flushSnapshot(true);
545
+ break;
546
+ case "get_commands":
547
+ void cs.pushSlashCommands();
548
+ break;
549
+ case "list_sessions":
550
+ void cs.refreshSessions();
551
+ break;
552
+ case "list_projects":
553
+ void cs.pushProjects();
554
+ break;
555
+ case "remove_project":
556
+ void cs.removeProject(msg.path);
557
+ break;
558
+ case "delete_session":
559
+ void cs.deleteSession(msg.path);
560
+ break;
561
+ case "switch_session":
562
+ void cs.switchSession(msg.path);
563
+ break;
564
+ case "switch_conversation":
565
+ void cs.switchConversation(msg.id);
566
+ break;
567
+ case "list_files":
568
+ void cs.listFiles(msg.path);
569
+ break;
570
+ case "search_files":
571
+ void cs.searchFiles(msg.query, msg.reqId);
572
+ break;
573
+ case "scm_status":
574
+ void cs.scmQuery("status", msg.reqId);
575
+ break;
576
+ case "scm_history":
577
+ void cs.scmQuery("history", msg.reqId);
578
+ break;
579
+ case "scm_filediff":
580
+ void cs.scmQuery("filediff", msg.reqId, { path: msg.path });
581
+ break;
582
+ case "scm_commit":
583
+ void cs.scmQuery("commit", msg.reqId, { hash: msg.hash });
584
+ break;
585
+ case "read_file":
586
+ void cs.readFile(msg.path);
587
+ break;
588
+ case "write_file":
589
+ void cs.writeFile(msg.path, msg.text);
590
+ break;
591
+ case "list_models":
592
+ void cs.listModels();
593
+ break;
594
+ case "set_model":
595
+ void cs.setModel(msg.modelId);
596
+ break;
597
+ case "set_thinking":
598
+ cs.setThinking(msg.level);
599
+ break;
600
+ case "set_cwd":
601
+ void cs.setCwd(msg.path);
602
+ break;
603
+ case "complete_path":
604
+ void cs.completePath(msg.path);
605
+ break;
606
+ case "check_update":
607
+ void cs.checkUpdate();
608
+ break;
609
+ case "dialog_response":
610
+ cs.resolveDialog(msg.id, msg.value);
611
+ break;
612
+ case "install_pi_agent":
613
+ void cs.installPiAgent();
614
+ break;
615
+ case "set_provider_api_key":
616
+ void cs.setProviderApiKey(msg.provider, msg.apiKey);
617
+ break;
618
+ case "clear_provider_api_key":
619
+ void cs.clearProviderApiKey(msg.provider);
620
+ break;
621
+ case "list_models_config":
622
+ void cs.listModelsConfig();
623
+ break;
624
+ case "save_model_config":
625
+ void cs.saveModelConfig(msg.providerId, msg.config);
626
+ break;
627
+ case "delete_model_config":
628
+ void cs.deleteModelConfig(msg.providerId);
629
+ break;
630
+ case "list_providers":
631
+ void cs.listProviders();
632
+ break;
633
+ case "fetch_models":
634
+ void cs.fetchModelsList(msg.reqId, msg.baseUrl, msg.apiKey, msg.authHeader, msg.api);
635
+ break;
636
+ case "refresh_provider_models":
637
+ void cs.refreshProviderModels(msg.providerId, msg.reqId);
638
+ break;
639
+ case "clone_provider":
640
+ void cs.cloneProvider(msg.provider, msg.reqId);
641
+ break;
642
+ case "terminal_create": {
643
+ const tm = cs.getTerminalManager(msg.conversationId);
644
+ if (tm)
645
+ tm.create(msg.terminalId, msg.cwd, msg.cols, msg.rows, cs.getTerminalCwd(msg.conversationId));
646
+ break;
647
+ }
648
+ case "terminal_input":
649
+ cs.getTerminalManager(msg.conversationId)?.input(msg.terminalId, msg.data);
650
+ break;
651
+ case "terminal_resize":
652
+ cs.getTerminalManager(msg.conversationId)?.resize(msg.terminalId, msg.cols, msg.rows);
653
+ break;
654
+ case "terminal_kill":
655
+ cs.getTerminalManager(msg.conversationId)?.kill(msg.terminalId);
656
+ break;
657
+ case "run_command":
658
+ cs.getTerminalManager(msg.conversationId)?.runCommand(msg.terminalId, msg.command, msg.cols, msg.rows, cs.getTerminalCwd(msg.conversationId));
659
+ break;
660
+ case "list_commands":
661
+ void cs.listCommands();
662
+ break;
663
+ case "save_commands":
664
+ void cs.saveCommands(msg.commands);
665
+ break;
666
+ case "set_goal":
667
+ void cs.setGoal(msg.goal, {
668
+ reviewModel: msg.reviewModel,
669
+ maxRounds: msg.maxRounds,
670
+ locked: msg.locked,
671
+ });
672
+ break;
673
+ case "clear_goal":
674
+ void cs.clearGoal();
675
+ break;
676
+ case "start_goal_wizard":
677
+ void cs.startGoalWizard(msg.text, {
678
+ wizardModel: msg.wizardModel,
679
+ maxRounds: msg.maxRounds,
680
+ locked: msg.locked,
681
+ });
682
+ break;
683
+ case "set_goal_prefs":
684
+ void cs.setGoalPrefs({
685
+ reviewModel: msg.reviewModel,
686
+ maxRounds: msg.maxRounds,
687
+ locked: msg.locked,
688
+ });
689
+ break;
690
+ case "get_settings":
691
+ cs.pushSettings();
692
+ break;
693
+ case "set_settings":
694
+ void cs.setSettings({
695
+ promptMode: msg.promptMode,
696
+ customSystemPrompt: msg.customSystemPrompt,
697
+ disabledSkills: msg.disabledSkills,
698
+ disabledExtensions: msg.disabledExtensions,
699
+ disabledPlugins: msg.disabledPlugins,
700
+ terminalToolsEnabled: msg.terminalToolsEnabled,
701
+ terminalBash: msg.terminalBash,
702
+ terminalBashIdleMs: msg.terminalBashIdleMs,
703
+ thinkingWrap: msg.thinkingWrap,
704
+ toolsWrap: msg.toolsWrap,
705
+ visionBridgeEnabled: msg.visionBridgeEnabled,
706
+ visionBridgeModel: msg.visionBridgeModel,
707
+ visionBridgePromptMode: msg.visionBridgePromptMode,
708
+ visionBridgePrompt: msg.visionBridgePrompt,
709
+ reviewPrompt: msg.reviewPrompt,
710
+ reviewDisabledSkills: msg.reviewDisabledSkills,
711
+ });
712
+ break;
713
+ case "extensions_reload":
714
+ void cs.reloadExtensions();
715
+ break;
716
+ case "plugin_message":
717
+ pluginMgr.handleMessage(msg.pluginId, msg.payload, clientId ?? undefined);
718
+ break;
719
+ case "plugin_settings": {
720
+ const r = pluginMgr.savePluginSettings(msg.pluginId, msg.values ?? {});
721
+ if (r.error) {
722
+ cs?.emitNotice("error", `插件设置保存失败:${r.error}`);
723
+ }
724
+ else {
725
+ cs?.emitNotice("info", "插件设置已保存");
726
+ }
727
+ break;
728
+ }
729
+ case "plugins_reload":
730
+ void pluginMgr.reload().then(() => pluginMgr.pushToAll());
731
+ break;
732
+ case "save_preset":
733
+ void cs.savePreset(msg.name);
734
+ break;
735
+ case "apply_preset":
736
+ void cs.applyPreset(msg.name);
737
+ break;
738
+ case "delete_preset":
739
+ void cs.deletePreset(msg.name);
740
+ break;
741
+ default:
742
+ break;
743
+ }
744
+ };
745
+ ws.on("message", (data) => {
746
+ let msg;
747
+ try {
748
+ msg = JSON.parse(data.toString());
749
+ }
750
+ catch {
751
+ return;
752
+ }
753
+ if (msg.type === "hello") {
754
+ const cid = msg.clientId || randomUUID();
755
+ clientId = cid;
756
+ service
757
+ .attach(cid, send)
758
+ .then((cs) => {
759
+ if (closed)
760
+ return;
761
+ send({
762
+ type: "ready",
763
+ clientId: cid,
764
+ serverVersion: VERSION,
765
+ protocolVersion: PROTOCOL_VERSION,
766
+ });
767
+ cs.flushSnapshot();
768
+ // Plugin catalog: re-scan + activate new dirs on every attach so
769
+ // freshly dropped plugins show up without a server restart.
770
+ pluginMgr
771
+ .ensureLoaded()
772
+ .then((plugins) => {
773
+ send({ type: "plugins", plugins, epoch: pluginMgr.epoch });
774
+ // 让各插件向新接入的客户端推送自身初始状态(onAttach 钩子)——
775
+ // 插件不要依赖客户端挂载后自己拉(见 plugins.ts onAttach 注释)。
776
+ pluginMgr.notifyAttach(cid);
777
+ // 插件命令可能在本客户端 attach 过程中才注册(首载竞态)——
778
+ // 重推一次目录,保证选择器完整。
779
+ service.applyPluginCommandCatalog();
780
+ })
781
+ .catch(() => { });
782
+ // Replay anything that arrived while the session was starting.
783
+ const queued = pending;
784
+ pending = [];
785
+ for (const m of queued)
786
+ dispatch(m);
787
+ })
788
+ .catch((err) => {
789
+ // Admission refused (quiesce): close the socket so the browser
790
+ // reconnect loop keeps retrying until admission reopens. Do NOT
791
+ // leave a half-alive connection that can only show an error.
792
+ if (err instanceof QuiesceRejectedError) {
793
+ closed = true;
794
+ if (ws.readyState === WebSocket.OPEN) {
795
+ ws.close(4403, "quiesced");
796
+ }
797
+ ws.terminate?.();
798
+ return;
799
+ }
800
+ // Real init failure (bad agent dir etc.) — keep the connection
801
+ // open so the user can see the error and fix it.
802
+ send({
803
+ type: "notice",
804
+ level: "error",
805
+ text: `会话初始化失败:${err.message}`,
806
+ });
807
+ });
808
+ return;
809
+ }
810
+ dispatch(msg);
811
+ });
812
+ ws.on("close", () => {
813
+ service.noteSocketClose();
814
+ closed = true;
815
+ pending = [];
816
+ removePluginSender();
817
+ if (snapshotRetryTimer) {
818
+ clearTimeout(snapshotRetryTimer);
819
+ snapshotRetryTimer = null;
820
+ }
821
+ if (clientId)
822
+ service.detach(clientId, send);
823
+ });
824
+ });
825
+ // When spawned by the old process as an auto-restart replacement, wait for
826
+ // the old instance to release the port before binding (it exits right after
827
+ // spawning us). Probe by attempting a connection: refused = free.
828
+ if (process.env[RESTART_CHILD_ENV] === "1") {
829
+ const deadline = Date.now() + 20_000;
830
+ const portFree = () => new Promise((resolve) => {
831
+ const sock = createConnection({ port: PORT, host: "127.0.0.1" });
832
+ sock.once("connect", () => {
833
+ sock.destroy();
834
+ resolve(false); // busy — old instance still up
835
+ });
836
+ sock.once("error", () => resolve(true)); // refused → free
837
+ sock.setTimeout(500, () => {
838
+ sock.destroy();
839
+ resolve(false);
840
+ });
841
+ });
842
+ while (Date.now() < deadline) {
843
+ if (await portFree())
844
+ break;
845
+ await new Promise((r) => setTimeout(r, 300));
846
+ }
847
+ }
848
+ httpServer.listen(PORT, HOST, () => {
849
+ console.log("");
850
+ console.log(" ⚡ pi-web-ui — web chat for the pi coding agent");
851
+ console.log(` http://localhost:${PORT}`);
852
+ console.log(` workspace : ${CWD}`);
853
+ console.log(` session dir : ${SESSION_DIR_ROOT}`);
854
+ console.log(` pi SDK : v${VERSION}`);
855
+ console.log(` bind : ${HOST}:${PORT}`);
856
+ console.log("");
857
+ });
858
+ // 上传文件保留期清理:启动扫一次 + 每 6 小时一次(best-effort,见 uploads.ts)
859
+ scheduleUploadCleanup();
860
+ // Local control socket (status / quiesce / unquiesce) — same data dir the
861
+ // CLI uses, so `pi-web-ui server status|quiesce|unquiesce` just works.
862
+ const stopControl = startControlServer({ service, dataDir: DATA_DIR, port: PORT });
863
+ let shuttingDown = false;
864
+ async function shutdown() {
865
+ if (shuttingDown)
866
+ return;
867
+ shuttingDown = true;
868
+ console.log("\nshutting down…");
869
+ clearInterval(heartbeatTimer);
870
+ stopControl();
871
+ pluginMgr.dispose();
872
+ mcpBridge.dispose();
873
+ await service.disposeAll();
874
+ wss.close();
875
+ httpServer.close();
876
+ process.exit(0);
877
+ }
878
+ process.on("SIGINT", () => void shutdown());
879
+ process.on("SIGTERM", () => void shutdown());