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.
- package/CHANGELOG.md +90 -1
- package/README.md +69 -14
- package/README.zh-CN.md +36 -10
- package/bin/pi-web-ui.mjs +39 -8
- package/dist/server/agent-service.js +938 -163
- package/dist/server/auth-cookie.js +26 -0
- package/dist/server/claim-files-tool.js +115 -0
- package/dist/server/claim-store.js +301 -0
- package/dist/server/client-state.js +114 -6
- package/dist/server/conversation-read-tool.js +371 -28
- package/dist/server/conversation-touches.js +357 -0
- package/dist/server/delegate-task.js +10 -3
- package/dist/server/dsh/dsh-agent-service.js +50 -1
- package/dist/server/files-service.js +78 -26
- package/dist/server/http-proxy.js +72 -0
- package/dist/server/index.js +94 -11
- package/dist/server/model-admin.js +124 -2
- package/dist/server/model-enrich.js +478 -0
- package/dist/server/patch-node-pty.js +11 -0
- package/dist/server/plugin-schedule.js +27 -2
- package/dist/server/plugins.js +66 -10
- package/dist/server/present-files-tool.js +303 -0
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/read-tool.js +125 -0
- package/dist/server/resolve-global-sdk.js +72 -0
- package/dist/server/sdk-origin.js +64 -0
- package/dist/server/serialize.js +80 -22
- package/dist/server/settings-service.js +19 -1
- package/dist/server/skill-tool.js +2 -1
- package/dist/server/subagents.js +30 -13
- package/dist/server/terminals.js +38 -18
- package/dist/server/text-sniff.js +15 -0
- package/dist/server/themes.js +32 -2
- package/dist/server/tool-info.js +89 -0
- package/dist/server/tool-manager.js +110 -11
- package/package.json +2 -2
- package/plugin-sdk/README.md +1 -0
- package/plugins/catalog.json +20 -0
- package/themes/ayu-light.css +136 -0
- package/themes/catppuccin-latte.css +140 -0
- package/themes/catppuccin.css +144 -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 +134 -0
- package/themes/one-dark.css +135 -0
- package/themes/paper.css +16 -12
- package/themes/rose-pine-dawn.css +136 -0
- package/themes/sakura.css +4 -0
- package/themes/solarized-light.css +135 -0
- package/themes/tokyo-night.css +144 -0
- package/themes/white.css +4 -0
- package/web/dist/assets/{TerminalPanel-GtDQ1h85.js → TerminalPanel-C1A23aI-.js} +1 -1
- package/web/dist/assets/index-B48QWf0I.js +364 -0
- package/web/dist/assets/index-DGL5OInG.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CdsKltOS.css +0 -1
- 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.
|
|
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
|
|
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
|
@@ -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 {
|
|
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
|
|
195
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
});
|