pi-web-ui 0.92.0 → 0.94.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 (57) hide show
  1. package/CHANGELOG.md +74 -2
  2. package/README.md +69 -14
  3. package/README.zh-CN.md +36 -10
  4. package/bin/pi-web-ui.mjs +18 -6
  5. package/dist/server/agent-service.js +577 -127
  6. package/dist/server/auth-cookie.js +26 -0
  7. package/dist/server/claim-files-tool.js +6 -7
  8. package/dist/server/client-state.js +125 -8
  9. package/dist/server/conversation-read-tool.js +2 -1
  10. package/dist/server/delegate-task.js +10 -3
  11. package/dist/server/dsh/dsh-agent-service.js +43 -1
  12. package/dist/server/files-service.js +79 -27
  13. package/dist/server/http-proxy.js +72 -0
  14. package/dist/server/index.js +84 -17
  15. package/dist/server/patch-node-pty.js +11 -0
  16. package/dist/server/plugins.js +1 -0
  17. package/dist/server/protocol-version.js +1 -1
  18. package/dist/server/read-tool.js +37 -3
  19. package/dist/server/resolve-global-sdk.js +72 -0
  20. package/dist/server/sdk-origin.js +64 -0
  21. package/dist/server/serialize.js +59 -21
  22. package/dist/server/settings-service.js +12 -1
  23. package/dist/server/skill-tool.js +2 -1
  24. package/dist/server/soft-cap.js +38 -4
  25. package/dist/server/subagents.js +30 -13
  26. package/dist/server/terminals.js +38 -18
  27. package/dist/server/themes.js +15 -0
  28. package/dist/server/tool-info.js +89 -0
  29. package/dist/server/tool-manager.js +107 -14
  30. package/package.json +2 -2
  31. package/plugins/catalog.json +10 -0
  32. package/themes/ayu-light.css +136 -0
  33. package/themes/catppuccin-latte.css +4 -0
  34. package/themes/catppuccin.css +4 -0
  35. package/themes/codex.css +136 -0
  36. package/themes/cyberpunk.css +4 -0
  37. package/themes/dazzle.css +4 -0
  38. package/themes/everforest-light.css +136 -0
  39. package/themes/geist.css +136 -0
  40. package/themes/gruvbox-light.css +136 -0
  41. package/themes/kanagawa-lotus.css +136 -0
  42. package/themes/md-preview.css +4 -0
  43. package/themes/mist.css +4 -0
  44. package/themes/nord.css +4 -0
  45. package/themes/one-dark.css +4 -0
  46. package/themes/paper.css +4 -0
  47. package/themes/rose-pine-dawn.css +136 -0
  48. package/themes/sakura.css +4 -0
  49. package/themes/solarized-light.css +4 -0
  50. package/themes/tokyo-night.css +4 -0
  51. package/themes/white.css +4 -0
  52. package/web/dist/assets/{TerminalPanel-RACpk6AX.js → TerminalPanel-B-CMQuzm.js} +1 -1
  53. package/web/dist/assets/index-18jK6al2.js +364 -0
  54. package/web/dist/assets/index-B3S9MxnN.css +1 -0
  55. package/web/dist/index.html +2 -2
  56. package/web/dist/assets/index-BgrnP9Jz.js +0 -364
  57. package/web/dist/assets/index-Cy8dSTZt.css +0 -1
@@ -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,11 +74,11 @@ 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)
81
- return IS_WIN32 && /^[A-Za-z]:$/.test(s) ? MACHINE_ROOT : null;
81
+ return /^[A-Za-z]:$/.test(s) ? MACHINE_ROOT : null;
82
82
  if (i === 0)
83
83
  return "/"; // posix "/a" → "/"
84
84
  return s.slice(0, i);
@@ -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
+ }
@@ -28,6 +28,7 @@ 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";
@@ -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
@@ -173,6 +175,11 @@ function buildId() {
173
175
  // <SESSION_DIR_ROOT>/--<cwd>--/, shared with the pi CLI/TUI (getAgentDir
174
176
  // honors PI_CODING_AGENT_DIR).
175
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
+ }
176
183
  // Windows 轻量 bash 兜底:把 <home>/.pi-web/bin 前置到 PATH(SDK 的 bash 工具经
177
184
  // findBashOnPath 会找到其中的 bash.exe),并在无 Git Bash 时后台下载 busybox-w32。
178
185
  // 终端面板的 shell 探测链也已包含该目录(见 terminals.ts resolveShell)。
@@ -203,8 +210,17 @@ function requestTokens(req) {
203
210
  if (typeof cookie === "string") {
204
211
  for (const part of cookie.split(";")) {
205
212
  const [k, ...rest] = part.trim().split("=");
206
- if (k === "pi_web_token")
207
- 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);
208
224
  }
209
225
  }
210
226
  return out.filter(Boolean);
@@ -212,7 +228,9 @@ function requestTokens(req) {
212
228
  function tokenOk(req) {
213
229
  return requestTokens(req).includes(AUTH_TOKEN);
214
230
  }
215
- /** 请求携带的 pi_web_token cookie 值(未带/损坏时为空串)。 */
231
+ /** 请求携带的 pi_web_token cookie 的**口令值**(未带/损坏时为空串)。
232
+ * 已解码:下发时是 `encodeURIComponent` 过的(issue #261),所以这里拿到的是
233
+ * 可直接与 `AUTH_TOKEN` 比较的原文。 */
216
234
  function cookieToken(req) {
217
235
  const cookie = req.headers.cookie;
218
236
  if (typeof cookie !== "string")
@@ -220,7 +238,7 @@ function cookieToken(req) {
220
238
  for (const part of cookie.split(";")) {
221
239
  const [k, ...rest] = part.trim().split("=");
222
240
  if (k === "pi_web_token")
223
- return rest.join("=").trim();
241
+ return decodeCookieToken(rest.join("=").trim());
224
242
  }
225
243
  return "";
226
244
  }
@@ -247,7 +265,10 @@ if (AUTH_TOKEN) {
247
265
  // Secure 只在 TLS 连接上加:常加会让明文 HTTP(默认 loopback)收不到 cookie。
248
266
  const secure = isTlsRequest(req);
249
267
  if (ok) {
250
- if (cookie !== encodeURIComponent(AUTH_TOKEN)) {
268
+ // cookieToken 已解码成原文(issue #261),所以直接和原始口令比 ——
269
+ // 以前拿 `encodeURIComponent(AUTH_TOKEN)` 比,含 `=` / 非 ASCII 的口令
270
+ // 永远不相等(于是每个请求都重发 cookie,且带 cookie 的请求反而 401)。
271
+ if (cookie !== AUTH_TOKEN) {
251
272
  res.setHeader("Set-Cookie", buildPiWebTokenCookie(encodeURIComponent(AUTH_TOKEN), 31536000, secure));
252
273
  }
253
274
  }
@@ -284,7 +305,16 @@ const SERVICE_INFO = toServiceInfo(ORIGIN);
284
305
  const TABS = parseTabs();
285
306
  registerFileTransferRoutes(app, (clientId) => service.get(clientId)?.cwd);
286
307
  app.get("/api/health", (_req, res) => {
287
- 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
+ });
288
318
  });
289
319
  /**
290
320
  * Stream a workspace file over HTTP.
@@ -1498,6 +1528,17 @@ wss.on("connection", (ws) => {
1498
1528
  case "get_commands":
1499
1529
  void cs.pushSlashCommands();
1500
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;
1501
1542
  case "list_sessions":
1502
1543
  void cs.refreshSessions();
1503
1544
  break;
@@ -1519,6 +1560,11 @@ wss.on("connection", (ws) => {
1519
1560
  case "dismiss_conversation":
1520
1561
  void cs.dismissConversation(msg.id, msg.withFinishedSubagents, msg.force);
1521
1562
  break;
1563
+ case "persist_conversation":
1564
+ if (typeof cs.persistConversation === "function") {
1565
+ void cs.persistConversation(msg.id);
1566
+ }
1567
+ break;
1522
1568
  case "dismiss_finished_subagents":
1523
1569
  void cs.dismissFinishedSubagents(msg.parentId);
1524
1570
  break;
@@ -1841,6 +1887,7 @@ wss.on("connection", (ws) => {
1841
1887
  terminalToolsEnabled: msg.terminalToolsEnabled,
1842
1888
  terminalBash: msg.terminalBash,
1843
1889
  terminalBashIdleMs: msg.terminalBashIdleMs,
1890
+ toolWatchdogTimeoutMs: msg.toolWatchdogTimeoutMs,
1844
1891
  readDirEnabled: msg.readDirEnabled,
1845
1892
  editSoftEnabled: msg.editSoftEnabled,
1846
1893
  questionnaireEnabled: msg.questionnaireEnabled,
@@ -1848,6 +1895,7 @@ wss.on("connection", (ws) => {
1848
1895
  goalModeEnabled: msg.goalModeEnabled,
1849
1896
  thinkingWrap: msg.thinkingWrap,
1850
1897
  toolsWrap: msg.toolsWrap,
1898
+ toolImagesEnabled: msg.toolImagesEnabled,
1851
1899
  devNoCache: msg.devNoCache,
1852
1900
  autoReload: msg.autoReload,
1853
1901
  skillsFullText: msg.skillsFullText,
@@ -2322,29 +2370,48 @@ httpServer.listen(PORT, HOST, () => {
2322
2370
  console.log(` workspace : ${CWD}`);
2323
2371
  console.log(` session dir : ${SESSION_DIR_ROOT}`);
2324
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
+ }
2325
2380
  console.log(` bind : ${HOST}:${PORT}`);
2326
2381
  console.log("");
2327
2382
  });
2328
2383
  // 上传文件保留期清理:启动扫一次 + 每 6 小时一次(best-effort,见 uploads.ts)
2329
2384
  scheduleUploadCleanup();
2330
- // 开机目录预同步(issue #165):PI_WEB_PLUGIN_CATALOG_URL 指向一份插件市场目录文档
2331
- // (headless/预置场景,不开浏览器也能装插件)。只跑一次、失败只告警不阻断启动;
2332
- // 条目逐条安装/更新(托管实例上安装会被安装器拒绝,但列表本身仍会更新)。
2333
- const BOOT_CATALOG_URL = (process.env.PI_WEB_PLUGIN_CATALOG_URL ?? "").trim();
2334
- if (BOOT_CATALOG_URL) {
2335
- void syncPluginCatalog(BOOT_CATALOG_URL, { install: true }, {
2385
+ // 开机目录预同步(issue #165):拉取一份插件市场目录文档 → 写可安装列表 → 逐条安装/更新。
2386
+ // 官方社区清单(xing-shuyin/pi-web-ui-plugins,PR 经 CI 自动审核 + 构建发布)作为**默认来源**,
2387
+ // 开箱即用、无需配置 PI_WEB_PLUGIN_CATALOG_URL;显式设为空串或 off/0/false/no 可关闭。
2388
+ // 默认**仅同步市场目录列表,绝不自动安装插件**(用户在界面按需点击安装)。
2389
+ // 仅在显式配置 PI_WEB_PLUGIN_CATALOG_INSTALL=1/true 时才顺手全部安装(headless/容器预置镜像场景)。
2390
+ const OFFICIAL_PLUGIN_CATALOG_URL = "https://xing-shuyin.github.io/pi-web-ui-plugins/catalog.json";
2391
+ const BOOT_CATALOG_URL = process.env.PI_WEB_PLUGIN_CATALOG_URL === undefined
2392
+ ? OFFICIAL_PLUGIN_CATALOG_URL
2393
+ : process.env.PI_WEB_PLUGIN_CATALOG_URL.trim();
2394
+ const bootCatalogDisabled = !BOOT_CATALOG_URL || /^(0|off|false|no)$/i.test(BOOT_CATALOG_URL);
2395
+ const autoInstall = /^(1|true|yes)$/i.test(process.env.PI_WEB_PLUGIN_CATALOG_INSTALL ?? "");
2396
+ if (!bootCatalogDisabled) {
2397
+ void syncPluginCatalog(BOOT_CATALOG_URL, { install: autoInstall }, {
2336
2398
  customCatalogPath: pluginMgr.customCatalogPath,
2337
2399
  pluginsDir: join(DATA_DIR, "plugins"),
2338
2400
  installer: pluginInstaller,
2339
2401
  afterWrite: () => reloadPluginsAndPush(),
2340
2402
  }).then((r) => {
2341
2403
  if (!r.ok) {
2342
- console.warn(`[catalog] PI_WEB_PLUGIN_CATALOG_URL 同步失败(不阻断启动): ${r.error}`);
2404
+ console.warn(`[catalog] 插件目录预同步失败(不阻断启动): ${r.error}`);
2343
2405
  return;
2344
2406
  }
2345
- const bad = (r.installed ?? []).filter((i) => !i.ok);
2346
- console.log(`[catalog] PI_WEB_PLUGIN_CATALOG_URL 同步完成:安装 ${(r.installed ?? []).length - bad.length} 成功 / ${bad.length} 失败` +
2347
- (bad.length ? `:${bad.map((i) => `${i.id}(${i.error ?? "?"})`).join(";")}` : ""));
2407
+ if (autoInstall) {
2408
+ const bad = (r.installed ?? []).filter((i) => !i.ok);
2409
+ console.log(`[catalog] 插件目录预同步完成:安装 ${(r.installed ?? []).length - bad.length} 成功 / ${bad.length} 失败` +
2410
+ (bad.length ? `:${bad.map((i) => `${i.id}(${i.error ?? "?"})`).join(";")}` : ""));
2411
+ }
2412
+ else {
2413
+ console.log(`[catalog] 插件市场列表预同步完成(共 ${(r.entries ?? []).length} 个条目,按需安装)`);
2414
+ }
2348
2415
  });
2349
2416
  }
2350
2417
  // Local control socket (status / quiesce / unquiesce) — same data dir the
@@ -76,5 +76,16 @@ function applyPatches() {
76
76
  " }",
77
77
  " });",
78
78
  ].join("\n"));
79
+ // 3. conpty_console_list_agent: tolerate dead shell PID (AttachConsole failed)
80
+ // when process already exited or was killed — return [] instead of crashing.
81
+ patchFile(pkgDir, "lib/conpty_console_list_agent.js", "var consoleProcessList = getConsoleProcessList(shellPid);\nprocess.send({ consoleProcessList: consoleProcessList });\nprocess.exit(0);", [
82
+ "try {",
83
+ " var consoleProcessList = getConsoleProcessList(shellPid);",
84
+ " process.send({ consoleProcessList: consoleProcessList });",
85
+ "} catch (e) {",
86
+ " process.send({ consoleProcessList: [] });",
87
+ "}",
88
+ "process.exit(0);",
89
+ ].join("\n"));
79
90
  }
80
91
  applyPatches();
@@ -185,6 +185,7 @@ const UI_SLOTS = new Set([
185
185
  "contextmenu.message",
186
186
  "contextmenu.session",
187
187
  "contextmenu.file",
188
+ "contextmenu.toolcall",
188
189
  "settings.pages",
189
190
  "leftpanel.sessions",
190
191
  "chat.header",
@@ -8,4 +8,4 @@
8
8
  * its own copy in web/src/protocol-version.ts; scripts/check-protocol-sync.mjs
9
9
  * verifies the two never drift.
10
10
  */
11
- export const PROTOCOL_VERSION = 18;
11
+ export const PROTOCOL_VERSION = 19;
@@ -25,6 +25,7 @@ import { stat } from "node:fs/promises";
25
25
  import { homedir } from "node:os";
26
26
  import { isAbsolute, join, resolve as nodeResolve } from "node:path";
27
27
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, createLsToolDefinition, createReadToolDefinition, defineTool, } from "@earendil-works/pi-coding-agent";
28
+ import { Type } from "typebox";
28
29
  import { bilingual, pick } from "./i18n.js";
29
30
  const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
30
31
  /**
@@ -52,6 +53,34 @@ export async function isDirectoryPath(absolutePath) {
52
53
  return false;
53
54
  }
54
55
  }
56
+ /** 覆盖定义的参数 schema:内置的 path/offset/limit + file_path 别名。 */
57
+ const readDirSchema = Type.Object({
58
+ path: Type.String({
59
+ description: bilingual("Path to the file (or directory) to read (relative or absolute)", "要读取的文件(或目录)路径(相对或绝对)"),
60
+ }),
61
+ file_path: Type.Optional(Type.String({
62
+ description: bilingual("Alias of `path` — some clients/models emit file_path; if both are given, `path` wins", "`path` 的别名 —— 部分客户端/模型习惯发 file_path;两者都给时以 `path` 为准"),
63
+ })),
64
+ offset: Type.Optional(Type.Number({
65
+ description: bilingual("Line number to start reading from (1-indexed)", "从第几行开始读(从 1 起算)"),
66
+ })),
67
+ limit: Type.Optional(Type.Number({
68
+ description: bilingual("Maximum number of lines to read (for a directory path: maximum number of entries)", "最多读多少行(路径是目录时 = 最多列多少条目)"),
69
+ })),
70
+ }, {});
71
+ /**
72
+ * 校验前归一(SDK 的 prepareArguments 在 schema 校验前执行):只有 file_path 时
73
+ * 把它当 path(path 在 schema 里仍必填),两者都给时以 path 为准。
74
+ */
75
+ export function prepareReadArguments(raw) {
76
+ const args = raw && typeof raw === "object" && !Array.isArray(raw) ? { ...raw } : {};
77
+ const primary = typeof args.path === "string" ? args.path : "";
78
+ const alias = typeof args.file_path === "string" ? args.file_path : "";
79
+ if (!primary.trim() && alias.trim())
80
+ args.path = alias;
81
+ // 两者都没给时这里仍缺 path(静态类型是谎,运行时交给 schema 校验报错)。
82
+ return args;
83
+ }
55
84
  /**
56
85
  * 生成「read 读目录」覆盖定义。cwd 仅供创建时固定;执行时优先 ctx.cwd
57
86
  * (会话工作区)。
@@ -63,15 +92,19 @@ export function makeReadDirTool(fallbackCwd, options = {}) {
63
92
  const getLang = options.getLang ?? (() => "en");
64
93
  return defineTool({
65
94
  ...base,
66
- description: bilingual(`${base.description} If the path is a directory, its entries are listed instead of file contents (one entry per line, directories suffixed with '/'); in that case \`limit\` caps the number of entries and \`offset\` is ignored.`, `读取文件内容。支持文本文件与图片(jpg, png, gif, webp, bmp),图片作为附件发出。文本输出截断到 ${DEFAULT_MAX_LINES} 行或 ${DEFAULT_MAX_BYTES / 1024}KB(先到者为准),大文件用 offset/limit 续读。路径是目录时改为列出目录条目(一行一项,目录带 '/' 后缀;此时 limit 是条目上限,offset 忽略)。`),
95
+ description: bilingual(`${base.description} Also accepts \`file_path\` as an alias of \`path\`. If the path is a directory, its entries are listed instead of file contents (one entry per line, directories suffixed with '/'); in that case \`limit\` caps the number of entries and \`offset\` is ignored.`, `读取文件内容。支持文本文件与图片(jpg, png, gif, webp, bmp),图片作为附件发出。文本输出截断到 ${DEFAULT_MAX_LINES} 行或 ${DEFAULT_MAX_BYTES / 1024}KB(先到者为准),大文件用 offset/limit 续读。路径也可用 \`file_path\` 传(path 的别名,两者都给时以 path 为准)。路径是目录时改为列出目录条目(一行一项,目录带 '/' 后缀;此时 limit 是条目上限,offset 忽略)。`),
67
96
  promptSnippet: bilingual("Read file contents (a directory path lists its entries)", "读取文件内容(传目录则列出其条目)"),
68
97
  promptGuidelines: [
69
98
  ...(base.promptGuidelines ?? []),
70
99
  bilingual("Use read on a directory to list its entries — no need to shell out to `ls`", "要看目录内容直接把目录路径交给 read,不必再走 bash 的 ls"),
71
100
  ],
101
+ parameters: readDirSchema,
102
+ prepareArguments: prepareReadArguments,
72
103
  async execute(toolCallId, params, signal, onUpdate, ctx) {
73
104
  const input = (params ?? {});
74
- const path = typeof input.path === "string" ? input.path : "";
105
+ // 兜底(不依赖 prepareArguments 一定跑过):path 缺省/空时用 file_path。
106
+ const rawPath = typeof input.path === "string" && input.path.trim() ? input.path : input.file_path;
107
+ const path = typeof rawPath === "string" ? rawPath : "";
75
108
  if (path && dirEnabled()) {
76
109
  const cwd = typeof ctx?.cwd === "string" ? ctx.cwd : fallbackCwd;
77
110
  if (await isDirectoryPath(resolvePathForDirCheck(path, cwd))) {
@@ -85,7 +118,8 @@ export function makeReadDirTool(fallbackCwd, options = {}) {
85
118
  return { content, details: undefined };
86
119
  }
87
120
  }
88
- return base.execute(toolCallId, params, signal, onUpdate, ctx);
121
+ // 转发内置实现时带上归一后的 path(模型可能只给了 file_path)。
122
+ return base.execute(toolCallId, { ...input, path }, signal, onUpdate, ctx);
89
123
  },
90
124
  });
91
125
  }