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.
- package/CHANGELOG.md +74 -2
- package/README.md +69 -14
- package/README.zh-CN.md +36 -10
- package/bin/pi-web-ui.mjs +18 -6
- package/dist/server/agent-service.js +577 -127
- package/dist/server/auth-cookie.js +26 -0
- package/dist/server/claim-files-tool.js +6 -7
- package/dist/server/client-state.js +125 -8
- package/dist/server/conversation-read-tool.js +2 -1
- package/dist/server/delegate-task.js +10 -3
- package/dist/server/dsh/dsh-agent-service.js +43 -1
- package/dist/server/files-service.js +79 -27
- package/dist/server/http-proxy.js +72 -0
- package/dist/server/index.js +84 -17
- package/dist/server/patch-node-pty.js +11 -0
- package/dist/server/plugins.js +1 -0
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/read-tool.js +37 -3
- package/dist/server/resolve-global-sdk.js +72 -0
- package/dist/server/sdk-origin.js +64 -0
- package/dist/server/serialize.js +59 -21
- package/dist/server/settings-service.js +12 -1
- package/dist/server/skill-tool.js +2 -1
- package/dist/server/soft-cap.js +38 -4
- package/dist/server/subagents.js +30 -13
- package/dist/server/terminals.js +38 -18
- package/dist/server/themes.js +15 -0
- package/dist/server/tool-info.js +89 -0
- package/dist/server/tool-manager.js +107 -14
- package/package.json +2 -2
- package/plugins/catalog.json +10 -0
- package/themes/ayu-light.css +136 -0
- package/themes/catppuccin-latte.css +4 -0
- package/themes/catppuccin.css +4 -0
- package/themes/codex.css +136 -0
- package/themes/cyberpunk.css +4 -0
- package/themes/dazzle.css +4 -0
- package/themes/everforest-light.css +136 -0
- package/themes/geist.css +136 -0
- package/themes/gruvbox-light.css +136 -0
- package/themes/kanagawa-lotus.css +136 -0
- package/themes/md-preview.css +4 -0
- package/themes/mist.css +4 -0
- package/themes/nord.css +4 -0
- package/themes/one-dark.css +4 -0
- package/themes/paper.css +4 -0
- package/themes/rose-pine-dawn.css +136 -0
- package/themes/sakura.css +4 -0
- package/themes/solarized-light.css +4 -0
- package/themes/tokyo-night.css +4 -0
- package/themes/white.css +4 -0
- package/web/dist/assets/{TerminalPanel-RACpk6AX.js → TerminalPanel-B-CMQuzm.js} +1 -1
- package/web/dist/assets/index-18jK6al2.js +364 -0
- package/web/dist/assets/index-B3S9MxnN.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BgrnP9Jz.js +0 -364
- 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.
|
|
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
|
|
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
|
|
154
|
-
*
|
|
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 (
|
|
182
|
-
// mklink /D
|
|
183
|
-
//
|
|
184
|
-
//
|
|
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
|
-
|
|
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
|
-
// 工作区根也允许「上一级」→
|
|
349
|
-
parent: rel === "" ?
|
|
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:
|
|
463
|
+
type: isDir ? "dir" : "file",
|
|
407
464
|
});
|
|
408
465
|
}
|
|
409
|
-
if (
|
|
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
|
-
.
|
|
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
|
-
|
|
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,
|
|
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
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -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 {
|
|
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
|
|
207
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
2331
|
-
//
|
|
2332
|
-
//
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
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]
|
|
2404
|
+
console.warn(`[catalog] 插件目录预同步失败(不阻断启动): ${r.error}`);
|
|
2343
2405
|
return;
|
|
2344
2406
|
}
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
(
|
|
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();
|
package/dist/server/plugins.js
CHANGED
package/dist/server/read-tool.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
121
|
+
// 转发内置实现时带上归一后的 path(模型可能只给了 file_path)。
|
|
122
|
+
return base.execute(toolCallId, { ...input, path }, signal, onUpdate, ctx);
|
|
89
123
|
},
|
|
90
124
|
});
|
|
91
125
|
}
|