pi-web-ui 0.91.0 → 0.93.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 (64) hide show
  1. package/CHANGELOG.md +90 -1
  2. package/README.md +69 -14
  3. package/README.zh-CN.md +36 -10
  4. package/bin/pi-web-ui.mjs +39 -8
  5. package/dist/server/agent-service.js +938 -163
  6. package/dist/server/auth-cookie.js +26 -0
  7. package/dist/server/claim-files-tool.js +115 -0
  8. package/dist/server/claim-store.js +301 -0
  9. package/dist/server/client-state.js +114 -6
  10. package/dist/server/conversation-read-tool.js +371 -28
  11. package/dist/server/conversation-touches.js +357 -0
  12. package/dist/server/delegate-task.js +10 -3
  13. package/dist/server/dsh/dsh-agent-service.js +50 -1
  14. package/dist/server/files-service.js +78 -26
  15. package/dist/server/http-proxy.js +72 -0
  16. package/dist/server/index.js +94 -11
  17. package/dist/server/model-admin.js +124 -2
  18. package/dist/server/model-enrich.js +478 -0
  19. package/dist/server/patch-node-pty.js +11 -0
  20. package/dist/server/plugin-schedule.js +27 -2
  21. package/dist/server/plugins.js +66 -10
  22. package/dist/server/present-files-tool.js +303 -0
  23. package/dist/server/protocol-version.js +1 -1
  24. package/dist/server/read-tool.js +125 -0
  25. package/dist/server/resolve-global-sdk.js +72 -0
  26. package/dist/server/sdk-origin.js +64 -0
  27. package/dist/server/serialize.js +80 -22
  28. package/dist/server/settings-service.js +19 -1
  29. package/dist/server/skill-tool.js +2 -1
  30. package/dist/server/subagents.js +30 -13
  31. package/dist/server/terminals.js +38 -18
  32. package/dist/server/text-sniff.js +15 -0
  33. package/dist/server/themes.js +32 -2
  34. package/dist/server/tool-info.js +89 -0
  35. package/dist/server/tool-manager.js +110 -11
  36. package/package.json +2 -2
  37. package/plugin-sdk/README.md +1 -0
  38. package/plugins/catalog.json +20 -0
  39. package/themes/ayu-light.css +136 -0
  40. package/themes/catppuccin-latte.css +140 -0
  41. package/themes/catppuccin.css +144 -0
  42. package/themes/codex.css +136 -0
  43. package/themes/cyberpunk.css +4 -0
  44. package/themes/dazzle.css +4 -0
  45. package/themes/everforest-light.css +136 -0
  46. package/themes/geist.css +136 -0
  47. package/themes/gruvbox-light.css +136 -0
  48. package/themes/kanagawa-lotus.css +136 -0
  49. package/themes/md-preview.css +4 -0
  50. package/themes/mist.css +4 -0
  51. package/themes/nord.css +134 -0
  52. package/themes/one-dark.css +135 -0
  53. package/themes/paper.css +16 -12
  54. package/themes/rose-pine-dawn.css +136 -0
  55. package/themes/sakura.css +4 -0
  56. package/themes/solarized-light.css +135 -0
  57. package/themes/tokyo-night.css +144 -0
  58. package/themes/white.css +4 -0
  59. package/web/dist/assets/{TerminalPanel-GtDQ1h85.js → TerminalPanel-C1A23aI-.js} +1 -1
  60. package/web/dist/assets/index-B48QWf0I.js +364 -0
  61. package/web/dist/assets/index-DGL5OInG.css +1 -0
  62. package/web/dist/index.html +2 -2
  63. package/web/dist/assets/index-CdsKltOS.css +0 -1
  64. package/web/dist/assets/index-OoULNF70.js +0 -363
@@ -5,7 +5,7 @@
5
5
  * 全部为无状态 fs 操作 + 两个自持的 watcher(当前列出目录、git dir),
6
6
  * 经 FilesHost 回调与 ClientSession 解耦。
7
7
  */
8
- import { mkdirSync, readFileSync, statSync, writeFileSync, watch } from "node:fs";
8
+ import { Dirent, mkdirSync, readFileSync, statSync, writeFileSync, watch } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { resolve, relative, sep } from "node:path";
11
11
  import { pick } from "./i18n.js";
@@ -57,7 +57,7 @@ export function isAbsoluteWirePath(p) {
57
57
  * 不产生结尾斜杠,这里只对补全/直接输入做防御性清理。 */
58
58
  export function normWirePath(p) {
59
59
  if (p.endsWith("/") && p !== "/")
60
- return p.slice(0, -1);
60
+ return p.replace(/\/+$/, "") || "/";
61
61
  return p;
62
62
  }
63
63
  /** wire 绝对路径("C:/Users/x" / "/Users/x" / "C:")→ 原生绝对路径。
@@ -74,7 +74,7 @@ export function wireToAbs(wire) {
74
74
  /** 机器浏览模式下某目录的父级 wire 路径:盘符根("C:")→ 机器根;posix "/" 无父级。 */
75
75
  export function absoluteParent(wire) {
76
76
  const s = normWirePath(wire);
77
- if (s === "" || s === MACHINE_ROOT)
77
+ if (s === "" || s === MACHINE_ROOT || s === "/")
78
78
  return null;
79
79
  const i = s.lastIndexOf("/");
80
80
  if (i < 0)
@@ -150,8 +150,9 @@ export function workspacePath(root, raw) {
150
150
  * system dirs (C:\$Recycle.Bin, Program Files internals, OneDrive placeholders)
151
151
  * throw EPERM/EACCES on open — that must not kill the panel, so it degrades
152
152
  * to an empty listing plus a warning. Directory symlinks/junctions are
153
- * followed so mklink /D folders stay navigable; broken links still show as
154
- * files instead of vanishing. The cap is 4x posix and truncation is reported
153
+ * followed (all platforms — Android/Termux's ~/storage/* entries are symlinks
154
+ * too) so folder links stay navigable; broken links still show as files
155
+ * instead of vanishing. The cap is 4x posix and truncation is reported
155
156
  * via `truncated` instead of happening silently.
156
157
  */
157
158
  async function readDirForUI(abs, rel) {
@@ -178,10 +179,10 @@ async function readDirForUI(abs, rel) {
178
179
  if (ignored.has(d.name))
179
180
  continue;
180
181
  let type;
181
- if (IS_WIN32 && d.isSymbolicLink()) {
182
- // mklink /D symlinks and junctions are reparse points — libuv
183
- // classifies them as links, so isDirectory() is false. Follow the
184
- // target so folder links stay navigable; broken links still show.
182
+ if (d.isSymbolicLink()) {
183
+ // Symlinks (mklink /D reparse points on win32, but also the directory
184
+ // links under Termux's ~/storage) are followed on ALL platforms so
185
+ // folder links stay navigable; broken links still show as files.
185
186
  try {
186
187
  const st = await fs.stat(join(abs, d.name));
187
188
  type = st.isDirectory() ? "dir" : "file";
@@ -210,6 +211,26 @@ async function readDirForUI(abs, rel) {
210
211
  out.length = MAX;
211
212
  return { entries: out, truncated };
212
213
  }
214
+ /** dirent → FileEntry classification: directory symlinks are resolved via
215
+ * stat() on ALL platforms (#262 — Termux's ~/storage/* entries are symlinks;
216
+ * the directory-only cwd picker renders an empty list if they type as files). */
217
+ async function classifyDirent(d, absDir) {
218
+ const fs = await import("node:fs/promises");
219
+ const { join } = await import("node:path");
220
+ let type;
221
+ if (d.isSymbolicLink()) {
222
+ try {
223
+ type = (await fs.stat(join(absDir, d.name))).isDirectory() ? "dir" : "file";
224
+ }
225
+ catch {
226
+ type = "file"; // broken link
227
+ }
228
+ }
229
+ else {
230
+ type = d.isDirectory() ? "dir" : "file";
231
+ }
232
+ return { name: d.name, type };
233
+ }
213
234
  export class FilesService {
214
235
  host;
215
236
  // ---- 当前列出目录的 watcher ----
@@ -248,6 +269,24 @@ export class FilesService {
248
269
  }
249
270
  return out;
250
271
  }
272
+ // Android/Termux: "/" itself is not listable (even `ls /` is denied),
273
+ // so fall back to $HOME + shared storage as the machine-root landing
274
+ // spots — otherwise machine browsing is a dead end on Android.
275
+ try {
276
+ await fsp.readdir("/");
277
+ }
278
+ catch {
279
+ const home = homedir();
280
+ const out = [{ name: home, path: home, type: "dir" }];
281
+ try {
282
+ await fsp.readdir("/storage/emulated/0");
283
+ out.push({ name: "/storage/emulated/0", path: "/storage/emulated/0", type: "dir" });
284
+ }
285
+ catch {
286
+ // no shared storage
287
+ }
288
+ return out;
289
+ }
251
290
  return [{ name: "/", path: "/", type: "dir" }];
252
291
  }
253
292
  /** 单字段错误文本的语言(host 未接线时英文默认)。 */
@@ -286,7 +325,13 @@ export class FilesService {
286
325
  async listFiles(relPath) {
287
326
  const { resolve, sep, relative } = await import("node:path");
288
327
  const root = resolve(this.host.getCwd());
289
- const raw = relPath ?? "";
328
+ let raw = relPath ?? "";
329
+ // Expand a leading "~/" (path-bar input) to the home directory — same
330
+ // rules as completePath/makeDir. The result is absolute, so it lands in
331
+ // the machine-browse branch below; wire paths always use "/".
332
+ if (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\")) {
333
+ raw = (raw === "~" ? homedir() : homedir() + raw.slice(1)).split(sep).join("/");
334
+ }
290
335
  // ---- 机器根(此电脑:盘符列表)—— 工作区之上的虚拟层 ----
291
336
  if (raw === MACHINE_ROOT || raw === MACHINE_ROOT + "/") {
292
337
  const entries = await this.machineRootEntries();
@@ -342,11 +387,12 @@ export class FilesService {
342
387
  else {
343
388
  this.watchDir(target, rel);
344
389
  }
390
+ const rootWire = normWirePath(root.split(sep).join("/"));
345
391
  this.host.emit({
346
392
  type: "files",
347
393
  path: rel === "" ? "" : rel,
348
- // 工作区根也允许「上一级」→ 机器根(Windows 换盘符 / posix 到 /)。
349
- parent: rel === "" ? MACHINE_ROOT : rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "",
394
+ // 工作区根也允许「上一级」→ 项目文件夹的上一级(盘符根则到机器根,posix 根无父级)。
395
+ parent: rel === "" ? absoluteParent(rootWire) : rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "",
350
396
  entries,
351
397
  truncated,
352
398
  });
@@ -399,14 +445,25 @@ export class FilesService {
399
445
  if (ignored.has(d.name))
400
446
  continue;
401
447
  const childRel = rel ? `${rel}/${d.name}` : d.name;
448
+ let isDir = d.isDirectory();
449
+ if (d.isSymbolicLink()) {
450
+ // Follow directory symlinks (Termux's ~/storage/*); the depth
451
+ // cap doubles as a symlink-cycle guard, budgets bound the cost.
452
+ try {
453
+ isDir = (await fsp.stat(join(abs, d.name))).isDirectory();
454
+ }
455
+ catch {
456
+ isDir = false; // broken link — treat as file
457
+ }
458
+ }
402
459
  if (d.name.toLowerCase().includes(q)) {
403
460
  results.push({
404
461
  path: childRel,
405
462
  name: d.name,
406
- type: d.isDirectory() ? "dir" : "file",
463
+ type: isDir ? "dir" : "file",
407
464
  });
408
465
  }
409
- if (d.isDirectory()) {
466
+ if (isDir) {
410
467
  await walk(join(abs, d.name), childRel, depth + 1);
411
468
  }
412
469
  }
@@ -1344,13 +1401,8 @@ export class FilesService {
1344
1401
  empty();
1345
1402
  return;
1346
1403
  }
1347
- const items = dirents
1348
- .filter((d) => !ignoredEntries().has(d.name))
1349
- .map((d) => ({
1350
- name: d.name,
1351
- path: `${drive}/${d.name}`,
1352
- type: (d.isDirectory() ? "dir" : "file"),
1353
- }))
1404
+ const items = (await Promise.all(dirents.filter((d) => !ignoredEntries().has(d.name)).map((d) => classifyDirent(d, `${drive}\\`))))
1405
+ .map((e) => ({ ...e, path: `${drive}/${e.name}` }))
1354
1406
  .sort((a, b) => {
1355
1407
  const aHidden = a.name.startsWith(".");
1356
1408
  const bHidden = b.name.startsWith(".");
@@ -1391,14 +1443,14 @@ export class FilesService {
1391
1443
  return;
1392
1444
  }
1393
1445
  const { join } = await import("node:path");
1394
- const completions = dirents
1446
+ const completions = (await Promise.all(dirents
1395
1447
  .filter((d) => d.name.startsWith(prefix) && !ignoredEntries().has(d.name))
1396
- .map((d) => ({
1397
- name: d.name,
1448
+ .map((d) => classifyDirent(d, dirPart))))
1449
+ .map((e) => ({
1450
+ ...e,
1398
1451
  // Windows users type backslashes — normalize the completion to the
1399
1452
  // wire format ("/") so the picked path round-trips cleanly.
1400
- path: IS_WIN32 ? join(dirPart, d.name).split(sep).join("/") : dirPart + d.name,
1401
- type: (d.isDirectory() ? "dir" : "file"),
1453
+ path: IS_WIN32 ? join(dirPart, e.name).split(sep).join("/") : dirPart + e.name,
1402
1454
  }))
1403
1455
  .sort((a, b) => {
1404
1456
  const aHidden = a.name.startsWith(".");
@@ -0,0 +1,72 @@
1
+ /**
2
+ * server/http-proxy.ts
3
+ *
4
+ * Propagates HTTP proxy settings from ~/.pi/agent/settings.json and environment
5
+ * variables (HTTP_PROXY / HTTPS_PROXY / ALL_PROXY) to process.env and sets up
6
+ * undici's global dispatcher with EnvHttpProxyAgent, ensuring that Node's
7
+ * native fetch() and SDK HTTP calls route through the configured proxy.
8
+ */
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { EventEmitter } from "node:events";
12
+ import * as undici from "undici";
13
+ /** Read proxy settings from settings.json and environment. */
14
+ export function resolveProxySettings(agentDir) {
15
+ let httpProxy;
16
+ const settingsPath = join(agentDir, "settings.json");
17
+ if (existsSync(settingsPath)) {
18
+ try {
19
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
20
+ if (typeof parsed.httpProxy === "string" && parsed.httpProxy.trim()) {
21
+ httpProxy = parsed.httpProxy.trim();
22
+ }
23
+ }
24
+ catch {
25
+ /* ignore parse error */
26
+ }
27
+ }
28
+ return {
29
+ httpProxy,
30
+ envHttpProxy: process.env.HTTP_PROXY,
31
+ envHttpsProxy: process.env.HTTPS_PROXY,
32
+ envAllProxy: process.env.ALL_PROXY,
33
+ };
34
+ }
35
+ /**
36
+ * Initialize HTTP proxy support:
37
+ * 1. If httpProxy is present in settings.json and env is not set, populate HTTP_PROXY and HTTPS_PROXY.
38
+ * 2. If any proxy is configured (settings or env), set up undici's global dispatcher with EnvHttpProxyAgent
39
+ * so that global fetch() uses the proxy.
40
+ */
41
+ export function initHttpProxy(agentDir) {
42
+ const config = resolveProxySettings(agentDir);
43
+ if (config.httpProxy) {
44
+ process.env.HTTP_PROXY ??= config.httpProxy;
45
+ process.env.HTTPS_PROXY ??= config.httpProxy;
46
+ }
47
+ const effectiveProxy = process.env.HTTPS_PROXY || process.env.HTTP_PROXY || process.env.ALL_PROXY;
48
+ if (!effectiveProxy) {
49
+ return { active: false };
50
+ }
51
+ try {
52
+ const dispatcher = withUndiciErrorListener(new undici.EnvHttpProxyAgent({
53
+ allowH2: false,
54
+ proxyTunnel: true,
55
+ bodyTimeout: 300_000,
56
+ headersTimeout: 300_000,
57
+ }));
58
+ undici.setGlobalDispatcher(dispatcher);
59
+ undici.install?.();
60
+ return { active: true, proxyUrl: effectiveProxy };
61
+ }
62
+ catch (err) {
63
+ console.warn("[proxy] Failed to configure global undici proxy dispatcher:", err);
64
+ return { active: false, proxyUrl: effectiveProxy };
65
+ }
66
+ }
67
+ function withUndiciErrorListener(dispatcher) {
68
+ if (dispatcher instanceof EventEmitter) {
69
+ dispatcher.on("error", () => { });
70
+ }
71
+ return dispatcher;
72
+ }
@@ -16,7 +16,7 @@
16
16
  * all share one conversation list per project.
17
17
  * PI_CODING_AGENT_DIR pi config dir (auth/models/skills) — passed to the SDK
18
18
  */
19
- import { existsSync, readFileSync } from "node:fs";
19
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
20
20
  import { stat } from "node:fs/promises";
21
21
  import { createServer, request as proxyRequest } from "node:http";
22
22
  import { createConnection } from "node:net";
@@ -28,11 +28,12 @@ import express from "express";
28
28
  import compression from "compression";
29
29
  import { WebSocket, WebSocketServer } from "ws";
30
30
  import { VERSION, getAgentDir } from "@earendil-works/pi-coding-agent";
31
+ import { sdkCopies, sdkOriginNote } from "./sdk-origin.js";
31
32
  import { PROTOCOL_VERSION } from "./protocol-version.js";
32
33
  import { AgentService, workspacePath, QuiesceRejectedError } from "./agent-service.js";
33
34
  import { WS_MAX_PAYLOAD_BYTES, isAbsoluteWirePath, wireToAbs } from "./files-service.js";
34
35
  import { registerFileTransferRoutes } from "./file-transfer-routes.js";
35
- import { previewKind } from "./text-sniff.js";
36
+ import { isAudioFile, previewKind } from "./text-sniff.js";
36
37
  import { startControlServer } from "./control-socket.js";
37
38
  import { scheduleUploadCleanup } from "./uploads.js";
38
39
  import { ensureWindowsBash, windowsBashDir } from "./ensure-bash.js";
@@ -49,7 +50,8 @@ import { McpBridge } from "./mcp-bridge.js";
49
50
  import { createMcpHotReload } from "./mcp-hot-reload.js";
50
51
  import { createHostMetricsSampler } from "./host-metrics.js";
51
52
  import { SchedulerStore } from "./scheduler-tasks.js";
52
- import { buildPiWebTokenCookie, isTlsRequest } from "./auth-cookie.js";
53
+ import { initHttpProxy } from "./http-proxy.js";
54
+ import { buildPiWebTokenCookie, decodeCookieToken, isTlsRequest } from "./auth-cookie.js";
53
55
  /** Strip npm-injected env (`npm start` exports `npm_config_*` / `npm_package_*` /
54
56
  * `npm_lifecycle_*` into every child). Anything this server spawns — shells,
55
57
  * `pi update` — would otherwise inherit `npm_config_allow_scripts`, which npm
@@ -81,6 +83,18 @@ function cliFlag(name) {
81
83
  const PORT = Number(cliFlag("--port") ?? process.env.PI_WEB_PORT ?? 8787);
82
84
  const CWD = resolve(cliFlag("--cwd") ?? process.env.PI_WEB_CWD ?? process.cwd());
83
85
  const DATA_DIR = resolve(cliFlag("--data-dir") ?? process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web"));
86
+ // The data dir is where the control socket, client state, plugins, themes and
87
+ // uploads live, but nothing guarantees it exists on a first run (a fresh
88
+ // `server install` never creates it). The control socket binds at startup —
89
+ // before any store gets a chance to mkdir its own subdirectory — and bind()
90
+ // into a missing directory fails with EACCES, silently disabling the whole
91
+ // control channel (status/quiesce) for that process. Create it up front.
92
+ try {
93
+ mkdirSync(DATA_DIR, { recursive: true });
94
+ }
95
+ catch (err) {
96
+ console.warn(`[data] 无法创建数据目录 ${DATA_DIR}: ${err.message}`);
97
+ }
84
98
  // Dev-no-cache setting read without a ClientStateStore instance (the index.html
85
99
  // route runs before any client attaches). Reads the same global settings blob.
86
100
  function readDevNoCacheSetting() {
@@ -161,6 +175,11 @@ function buildId() {
161
175
  // <SESSION_DIR_ROOT>/--<cwd>--/, shared with the pi CLI/TUI (getAgentDir
162
176
  // honors PI_CODING_AGENT_DIR).
163
177
  const SESSION_DIR_ROOT = join(getAgentDir(), "sessions");
178
+ // Propagate pi agent's httpProxy setting and environment proxies to undici/fetch
179
+ const proxyInfo = initHttpProxy(getAgentDir());
180
+ if (proxyInfo.active) {
181
+ console.log(`[proxy] Outbound HTTP proxy enabled: ${proxyInfo.proxyUrl}`);
182
+ }
164
183
  // Windows 轻量 bash 兜底:把 <home>/.pi-web/bin 前置到 PATH(SDK 的 bash 工具经
165
184
  // findBashOnPath 会找到其中的 bash.exe),并在无 Git Bash 时后台下载 busybox-w32。
166
185
  // 终端面板的 shell 探测链也已包含该目录(见 terminals.ts resolveShell)。
@@ -191,8 +210,17 @@ function requestTokens(req) {
191
210
  if (typeof cookie === "string") {
192
211
  for (const part of cookie.split(";")) {
193
212
  const [k, ...rest] = part.trim().split("=");
194
- if (k === "pi_web_token")
195
- out.push(rest.join("=").trim());
213
+ if (k !== "pi_web_token")
214
+ continue;
215
+ // 我们下发的是 encodeURIComponent 后的值(issue #261),而手写 / 旧客户端的
216
+ // 明文 cookie 也可能出现(`=` 在值里合法,按首个 = 切分);两种候选都进池子。
217
+ const raw = rest.join("=").trim();
218
+ if (!raw)
219
+ continue;
220
+ out.push(raw);
221
+ const decoded = decodeCookieToken(raw);
222
+ if (decoded !== raw)
223
+ out.push(decoded);
196
224
  }
197
225
  }
198
226
  return out.filter(Boolean);
@@ -200,7 +228,9 @@ function requestTokens(req) {
200
228
  function tokenOk(req) {
201
229
  return requestTokens(req).includes(AUTH_TOKEN);
202
230
  }
203
- /** 请求携带的 pi_web_token cookie 值(未带/损坏时为空串)。 */
231
+ /** 请求携带的 pi_web_token cookie 的**口令值**(未带/损坏时为空串)。
232
+ * 已解码:下发时是 `encodeURIComponent` 过的(issue #261),所以这里拿到的是
233
+ * 可直接与 `AUTH_TOKEN` 比较的原文。 */
204
234
  function cookieToken(req) {
205
235
  const cookie = req.headers.cookie;
206
236
  if (typeof cookie !== "string")
@@ -208,7 +238,7 @@ function cookieToken(req) {
208
238
  for (const part of cookie.split(";")) {
209
239
  const [k, ...rest] = part.trim().split("=");
210
240
  if (k === "pi_web_token")
211
- return rest.join("=").trim();
241
+ return decodeCookieToken(rest.join("=").trim());
212
242
  }
213
243
  return "";
214
244
  }
@@ -235,7 +265,10 @@ if (AUTH_TOKEN) {
235
265
  // Secure 只在 TLS 连接上加:常加会让明文 HTTP(默认 loopback)收不到 cookie。
236
266
  const secure = isTlsRequest(req);
237
267
  if (ok) {
238
- if (cookie !== encodeURIComponent(AUTH_TOKEN)) {
268
+ // cookieToken 已解码成原文(issue #261),所以直接和原始口令比 ——
269
+ // 以前拿 `encodeURIComponent(AUTH_TOKEN)` 比,含 `=` / 非 ASCII 的口令
270
+ // 永远不相等(于是每个请求都重发 cookie,且带 cookie 的请求反而 401)。
271
+ if (cookie !== AUTH_TOKEN) {
239
272
  res.setHeader("Set-Cookie", buildPiWebTokenCookie(encodeURIComponent(AUTH_TOKEN), 31536000, secure));
240
273
  }
241
274
  }
@@ -272,14 +305,25 @@ const SERVICE_INFO = toServiceInfo(ORIGIN);
272
305
  const TABS = parseTabs();
273
306
  registerFileTransferRoutes(app, (clientId) => service.get(clientId)?.cwd);
274
307
  app.get("/api/health", (_req, res) => {
275
- res.json({ ok: true, piVersion: VERSION, cwd: CWD, pid: process.pid, engine: ENGINE });
308
+ res.json({
309
+ ok: true,
310
+ piVersion: VERSION,
311
+ // issue #260:服务实际加载的是自带副本,不是全局 pi CLI 那份。这里把两份都报出来,
312
+ // 用户就不用猜「为什么升了全局 SDK 不生效」。(纯新增字段,piVersion 语义不变。)
313
+ piSdkCopies: sdkCopies(),
314
+ cwd: CWD,
315
+ pid: process.pid,
316
+ engine: ENGINE,
317
+ });
276
318
  });
277
319
  /**
278
320
  * Stream a workspace file over HTTP.
279
321
  *
280
322
  * Media preview (no download param): only image/video kinds are served —
281
323
  * text goes over the WebSocket, and exe/jar/etc. are never exposed here.
282
- * express's sendFile handles Range requests, so video seeking works.
324
+ * Audio files (isAudioFile: browser-playable containers only) are allowed too
325
+ * so present_files cards and the preview dialog can inline-play them.
326
+ * express's sendFile handles Range requests, so video/audio seeking works.
283
327
  *
284
328
  * Download (?download=1): any file kind is served with
285
329
  * Content-Disposition: attachment so the browser saves it instead of
@@ -316,7 +360,8 @@ app.get("/api/file", async (req, res) => {
316
360
  // allowlist them explicitly here.
317
361
  const lower = name.toLowerCase();
318
362
  const isHtmlPreview = lower.endsWith(".html") || lower.endsWith(".htm") || lower.endsWith(".xhtml");
319
- if (!isDownload && kind !== "image" && kind !== "video" && !isHtmlPreview) {
363
+ const isAudioPreview = isAudioFile(name);
364
+ if (!isDownload && kind !== "image" && kind !== "video" && !isHtmlPreview && !isAudioPreview) {
320
365
  res.status(400).end("not a previewable media file");
321
366
  return;
322
367
  }
@@ -1483,6 +1528,17 @@ wss.on("connection", (ws) => {
1483
1528
  case "get_commands":
1484
1529
  void cs.pushSlashCommands();
1485
1530
  break;
1531
+ case "get_tool_info":
1532
+ // 工具卡右键菜单的「显示工具详细信息」:按需取一次工具定义(不进快照)。
1533
+ // 引擎没实现(或旧版服务端)时回一条 unsupported,前端据此显示「不支持」
1534
+ // 而不是永远转圈。
1535
+ if (typeof cs.getToolInfo === "function") {
1536
+ void cs.getToolInfo(msg.name);
1537
+ }
1538
+ else {
1539
+ send({ type: "tool_info", name: msg.name, found: false, unsupported: true });
1540
+ }
1541
+ break;
1486
1542
  case "list_sessions":
1487
1543
  void cs.refreshSessions();
1488
1544
  break;
@@ -1504,6 +1560,11 @@ wss.on("connection", (ws) => {
1504
1560
  case "dismiss_conversation":
1505
1561
  void cs.dismissConversation(msg.id, msg.withFinishedSubagents, msg.force);
1506
1562
  break;
1563
+ case "persist_conversation":
1564
+ if (typeof cs.persistConversation === "function") {
1565
+ void cs.persistConversation(msg.id);
1566
+ }
1567
+ break;
1507
1568
  case "dismiss_finished_subagents":
1508
1569
  void cs.dismissFinishedSubagents(msg.parentId);
1509
1570
  break;
@@ -1609,6 +1670,12 @@ wss.on("connection", (ws) => {
1609
1670
  case "set_model":
1610
1671
  void cs.setModel(msg.modelId);
1611
1672
  break;
1673
+ case "set_default_model":
1674
+ void cs.setDefaultModel?.(msg.modelId);
1675
+ break;
1676
+ case "clear_default_model":
1677
+ cs.clearDefaultModel?.();
1678
+ break;
1612
1679
  case "set_thinking":
1613
1680
  cs.setThinking(msg.level);
1614
1681
  break;
@@ -1727,6 +1794,12 @@ wss.on("connection", (ws) => {
1727
1794
  case "clone_provider":
1728
1795
  void cs.cloneProvider(msg.provider, msg.reqId);
1729
1796
  break;
1797
+ case "enrich_models":
1798
+ void cs.enrichModels(msg.reqId, msg.ids, msg.hints);
1799
+ break;
1800
+ case "abort_enrich_models":
1801
+ cs.abortEnrichModels(msg.reqId);
1802
+ break;
1730
1803
  case "list_provider_keys":
1731
1804
  cs.listProviderKeys();
1732
1805
  break;
@@ -1814,12 +1887,15 @@ wss.on("connection", (ws) => {
1814
1887
  terminalToolsEnabled: msg.terminalToolsEnabled,
1815
1888
  terminalBash: msg.terminalBash,
1816
1889
  terminalBashIdleMs: msg.terminalBashIdleMs,
1890
+ toolWatchdogTimeoutMs: msg.toolWatchdogTimeoutMs,
1891
+ readDirEnabled: msg.readDirEnabled,
1817
1892
  editSoftEnabled: msg.editSoftEnabled,
1818
1893
  questionnaireEnabled: msg.questionnaireEnabled,
1819
1894
  parallelReminderEnabled: msg.parallelReminderEnabled,
1820
1895
  goalModeEnabled: msg.goalModeEnabled,
1821
1896
  thinkingWrap: msg.thinkingWrap,
1822
1897
  toolsWrap: msg.toolsWrap,
1898
+ toolImagesEnabled: msg.toolImagesEnabled,
1823
1899
  devNoCache: msg.devNoCache,
1824
1900
  autoReload: msg.autoReload,
1825
1901
  skillsFullText: msg.skillsFullText,
@@ -2294,6 +2370,13 @@ httpServer.listen(PORT, HOST, () => {
2294
2370
  console.log(` workspace : ${CWD}`);
2295
2371
  console.log(` session dir : ${SESSION_DIR_ROOT}`);
2296
2372
  console.log(` pi SDK : v${VERSION}`);
2373
+ // issue #260:全局那份 pi SDK 不是服务在用的那份(自带副本赢在 Node 解析顺序上)。
2374
+ // 不提示的话,用户会以为 `npm i -g @earendil-works/pi-coding-agent@latest` 生效了。
2375
+ const sdkNote = sdkOriginNote(sdkCopies(), VERSION);
2376
+ if (sdkNote) {
2377
+ 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
+ }
2297
2380
  console.log(` bind : ${HOST}:${PORT}`);
2298
2381
  console.log("");
2299
2382
  });