pi-web-ui 0.22.0 → 0.23.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/README.md +23 -0
- package/README.zh-CN.md +17 -0
- package/bin/pi-web-ui.mjs +98 -2
- package/dist/server/agent-service.js +145 -4
- package/dist/server/control-socket.js +177 -0
- package/dist/server/index.js +113 -3
- package/package.json +2 -2
- package/web/dist/assets/{index-BehovRZ4.css → index-B8AkogcB.css} +1 -1
- package/web/dist/assets/{index-CFXSK8-7.js → index-DqEHUXqU.js} +55 -55
- package/web/dist/index.html +2 -2
package/README.md
CHANGED
|
@@ -68,8 +68,31 @@ pi-web-ui server stop # stop (auto-start stays)
|
|
|
68
68
|
pi-web-ui server start # start again
|
|
69
69
|
pi-web-ui server uninstall # remove the service entirely
|
|
70
70
|
pi-web-ui server shortcut # desktop one-click launch icon
|
|
71
|
+
pi-web-ui server quiesce # drain: refuse NEW chats/messages, let running ones finish
|
|
72
|
+
pi-web-ui server unquiesce # reopen admission
|
|
71
73
|
```
|
|
72
74
|
|
|
75
|
+
`server status` also shows live stats via a local control socket (version,
|
|
76
|
+
PID, quiesce state, connected browsers, running conversations) — the same
|
|
77
|
+
socket drives `quiesce`/`unquiesce`.
|
|
78
|
+
|
|
79
|
+
## Security
|
|
80
|
+
|
|
81
|
+
- **Loopback-only by default** — the server binds `127.0.0.1` and is not
|
|
82
|
+
reachable from the network unless you explicitly set `PI_WEB_HOST=0.0.0.0`
|
|
83
|
+
(e.g. LAN access, Docker port mapping — the compose file sets it for you).
|
|
84
|
+
- **WebSocket origin check** — browser pages connecting to `/ws` must present
|
|
85
|
+
an `Origin` whose hostname **and port** match the request `Host`;
|
|
86
|
+
cross-origin pages are rejected with 403. Non-browser clients (no `Origin`)
|
|
87
|
+
are unaffected. Add `PI_WEB_ALLOW_ORIGINS=http://your-host:port` for
|
|
88
|
+
reverse-proxy setups.
|
|
89
|
+
- **Quiesce** — `server quiesce` refuses new prompts/forks/session resumes
|
|
90
|
+
until you `server unquiesce`; in-flight runs finish cleanly (useful before
|
|
91
|
+
upgrades/backups).
|
|
92
|
+
- **Credentials stay server-side** — provider `headers` (which may carry
|
|
93
|
+
`Authorization` / API keys) are never sent to the browser; the model
|
|
94
|
+
management UI edits everything else and the server preserves the headers.
|
|
95
|
+
|
|
73
96
|
- **macOS** → launchd agent (no sudo), logs to `/tmp/pi-web-ui.log` / `.err`
|
|
74
97
|
- **Linux** → systemd unit (`systemctl enable --now`), logs via `journalctl -u pi-web-ui -f`
|
|
75
98
|
- **Windows** → Task Scheduler logon task (hidden PowerShell window, no black console)
|
package/README.zh-CN.md
CHANGED
|
@@ -64,8 +64,25 @@ pi-web-ui server stop # 停止(开机自启保留)
|
|
|
64
64
|
pi-web-ui server start # 再次启动
|
|
65
65
|
pi-web-ui server uninstall # 彻底移除服务
|
|
66
66
|
pi-web-ui server shortcut # 桌面一键启动图标
|
|
67
|
+
pi-web-ui server quiesce # 排空:拒绝新的对话/消息,存量运行继续跑完
|
|
68
|
+
pi-web-ui server unquiesce # 解除排空,恢复接收新工作
|
|
67
69
|
```
|
|
68
70
|
|
|
71
|
+
`server status` 还会经本地控制 socket 显示实时状态(版本、PID、排空状态、
|
|
72
|
+
浏览器连接数、运行中对话数)——`quiesce`/`unquiesce` 也走同一个 socket。
|
|
73
|
+
|
|
74
|
+
## 安全
|
|
75
|
+
|
|
76
|
+
- **默认只绑 loopback** —— 服务器只监听 `127.0.0.1`,不暴露到网络;需要局域网访问或
|
|
77
|
+
Docker 端口映射时显式设置 `PI_WEB_HOST=0.0.0.0`(docker-compose.yml 已内置)。
|
|
78
|
+
- **WebSocket Origin 校验** —— 浏览器页面连 `/ws` 时其 Origin 的 hostname **和端口**
|
|
79
|
+
必须与请求 Host 一致,跨源页面直接 403;无 Origin 的非浏览器客户端不受影响。
|
|
80
|
+
反向代理场景可用 `PI_WEB_ALLOW_ORIGINS=http://你的域名:端口` 放行。
|
|
81
|
+
- **Quiesce 排空** —— `server quiesce` 后拒绝新的 prompt/编辑重问/会话恢复,存量运行
|
|
82
|
+
跑完为止(升级/备份前用);`server unquiesce` 恢复。
|
|
83
|
+
- **凭据不下发浏览器** —— provider 的 `headers`(可能含 Authorization / API key)
|
|
84
|
+
永不发给浏览器;模型管理 UI 编辑其他字段,服务端自动保留 headers。
|
|
85
|
+
|
|
69
86
|
- **macOS** → launchd 代理(无需 sudo),日志 `/tmp/pi-web-ui.log` / `.err`
|
|
70
87
|
- **Linux** → systemd unit(`systemctl enable --now`),日志 `journalctl -u pi-web-ui -f`
|
|
71
88
|
- **Windows** → 计划任务(登录自启,隐藏 PowerShell 窗口,无黑窗)
|
package/bin/pi-web-ui.mjs
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* PI_CODING_AGENT_DIR。
|
|
24
24
|
*/
|
|
25
25
|
import { spawnSync } from "node:child_process";
|
|
26
|
+
import { createConnection } from "node:net";
|
|
26
27
|
import { get as httpGet } from "node:http";
|
|
27
28
|
import {
|
|
28
29
|
chmodSync,
|
|
@@ -60,6 +61,8 @@ const HELP = `pi-web-ui v${pkg.version} — web chat for the pi coding agent
|
|
|
60
61
|
pi-web-ui server shortcut [选项] 在桌面创建「一键启动」图标(启动服务并打开浏览器)
|
|
61
62
|
pi-web-ui server uninstall [选项] 卸载系统服务(同时移除桌面图标)
|
|
62
63
|
pi-web-ui server start|stop|restart|status [选项]
|
|
64
|
+
pi-web-ui server quiesce [选项] 进入排空模式:拒绝新的对话/消息/编辑,存量运行继续跑完
|
|
65
|
+
pi-web-ui server unquiesce [选项] 解除排空模式,恢复接收新工作
|
|
63
66
|
pi-web-ui --version / --help
|
|
64
67
|
|
|
65
68
|
server 选项:
|
|
@@ -1069,6 +1072,90 @@ function uninstallWindows(opts) {
|
|
|
1069
1072
|
console.log(`🗑 已移除桌面快捷方式`);
|
|
1070
1073
|
}
|
|
1071
1074
|
|
|
1075
|
+
// ---------------------------------------------------------------------------
|
|
1076
|
+
// ---------------------------------------------------------------------------
|
|
1077
|
+
// Local control socket (status / quiesce / unquiesce). The server listens on
|
|
1078
|
+
// a mode-0600 Unix socket (POSIX) or a named pipe (Windows) under its data
|
|
1079
|
+
// dir; same path rules as server/control-socket.ts so the CLI and server
|
|
1080
|
+
// always agree without sharing code.
|
|
1081
|
+
// ---------------------------------------------------------------------------
|
|
1082
|
+
|
|
1083
|
+
/** Resolve the control socket path for the given options. */
|
|
1084
|
+
function controlPath(opts) {
|
|
1085
|
+
const dir = opts.dataDir
|
|
1086
|
+
? resolve(opts.dataDir)
|
|
1087
|
+
: process.env.PI_WEB_DATA_DIR
|
|
1088
|
+
? resolve(process.env.PI_WEB_DATA_DIR)
|
|
1089
|
+
: join(homedir(), ".pi-web");
|
|
1090
|
+
return isWin
|
|
1091
|
+
? `\\\\.\\pipe\\pi-web-ui-${String(opts.port ?? process.env.PORT ?? "8787")}`
|
|
1092
|
+
: join(dir, "pi-web-ui.sock");
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/** Send one control command to a RUNNING server; resolves null if unreachable. */
|
|
1096
|
+
function controlCommand(opts, cmd) {
|
|
1097
|
+
const path = controlPath(opts);
|
|
1098
|
+
return new Promise((resolvePromise) => {
|
|
1099
|
+
const sock = createConnection(path);
|
|
1100
|
+
let done = false;
|
|
1101
|
+
const finish = (v) => {
|
|
1102
|
+
if (done) return;
|
|
1103
|
+
done = true;
|
|
1104
|
+
clearTimeout(timer);
|
|
1105
|
+
sock.destroy();
|
|
1106
|
+
resolvePromise(v);
|
|
1107
|
+
};
|
|
1108
|
+
const timer = setTimeout(() => finish(null), 3000);
|
|
1109
|
+
let buf = "";
|
|
1110
|
+
sock.on("connect", () => sock.write(JSON.stringify({ cmd }) + "\n"));
|
|
1111
|
+
sock.on("data", (chunk) => {
|
|
1112
|
+
buf += chunk.toString("utf8");
|
|
1113
|
+
const nl = buf.indexOf("\n");
|
|
1114
|
+
if (nl >= 0) {
|
|
1115
|
+
try {
|
|
1116
|
+
finish(JSON.parse(buf.slice(0, nl)));
|
|
1117
|
+
} catch {
|
|
1118
|
+
finish(null);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
1122
|
+
sock.on("error", () => finish(null));
|
|
1123
|
+
sock.on("close", () => finish(null));
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/** Append the live server status (via the control socket) to `server status`. */
|
|
1128
|
+
async function printLiveStatus(opts) {
|
|
1129
|
+
const st = await controlCommand(opts, "status");
|
|
1130
|
+
if (!st || !st.ok) {
|
|
1131
|
+
console.log(" (服务器未运行或控制通道不可达 — 启动后可查 server status 实时信息)");
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
console.log(" --- 实时状态 (control socket) ---");
|
|
1135
|
+
console.log(` 版本 : ${st.version} · PID ${st.pid}`);
|
|
1136
|
+
console.log(` 目录 : ${st.cwd}`);
|
|
1137
|
+
console.log(
|
|
1138
|
+
` 排空 : ${st.quiesced ? `是(自 ${new Date(st.quiescedSince).toLocaleString()})` : "否"}`,
|
|
1139
|
+
);
|
|
1140
|
+
console.log(
|
|
1141
|
+
` 连接 : ${st.connectedClients} 个浏览器 · ${st.activeConversations} 个运行中对话 · ${st.pendingMessages} 条排队消息`,
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/** `server quiesce|unquiesce` — toggle the admission gate on a RUNNING server. */
|
|
1146
|
+
async function setQuiesce(opts, on) {
|
|
1147
|
+
const st = await controlCommand(opts, on ? "quiesce" : "unquiesce");
|
|
1148
|
+
if (!st || !st.ok) {
|
|
1149
|
+
fail(`服务器未运行或控制通道不可达(${controlPath(opts)})`);
|
|
1150
|
+
}
|
|
1151
|
+
console.log(
|
|
1152
|
+
on
|
|
1153
|
+
? "⏸ 已进入排空模式(quiesce):拒绝新的对话/消息/编辑,存量运行继续跑完。\n" +
|
|
1154
|
+
" 跑完后用 pi-web-ui server unquiesce 恢复。"
|
|
1155
|
+
: "▶ 已解除排空模式(unquiesce):恢复接收新的对话/消息/编辑。",
|
|
1156
|
+
);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1072
1159
|
function controlService(action, opts) {
|
|
1073
1160
|
const name = opts.name ?? "pi-web-ui";
|
|
1074
1161
|
|
|
@@ -1279,12 +1366,21 @@ async function serverCmd(argv) {
|
|
|
1279
1366
|
case "start":
|
|
1280
1367
|
case "stop":
|
|
1281
1368
|
case "restart":
|
|
1282
|
-
case "status":
|
|
1283
1369
|
controlService(action, opts);
|
|
1284
1370
|
break;
|
|
1371
|
+
case "status":
|
|
1372
|
+
controlService("status", opts);
|
|
1373
|
+
await printLiveStatus(opts);
|
|
1374
|
+
break;
|
|
1375
|
+
case "quiesce":
|
|
1376
|
+
await setQuiesce(opts, true);
|
|
1377
|
+
break;
|
|
1378
|
+
case "unquiesce":
|
|
1379
|
+
await setQuiesce(opts, false);
|
|
1380
|
+
break;
|
|
1285
1381
|
default:
|
|
1286
1382
|
fail(
|
|
1287
|
-
`未知操作: ${action}(install / shortcut / uninstall / start / stop / restart / status)`,
|
|
1383
|
+
`未知操作: ${action}(install / shortcut / uninstall / start / stop / restart / status / quiesce / unquiesce)`,
|
|
1288
1384
|
);
|
|
1289
1385
|
}
|
|
1290
1386
|
}
|
|
@@ -14,7 +14,7 @@ import { spawn } from "node:child_process";
|
|
|
14
14
|
import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync, watch, } from "node:fs";
|
|
15
15
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
|
-
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashTool, createLocalBashOperations, defineTool, getAgentDir, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashTool, createLocalBashOperations, defineTool, getAgentDir, ModelRuntime, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import { Type } from "typebox";
|
|
19
19
|
import { serializeMessage, serializeStreamingMessage, } from "./serialize.js";
|
|
20
20
|
import { loadCommands, saveCommandsFile, TerminalManager, } from "./terminals.js";
|
|
@@ -23,6 +23,18 @@ const WIDGET_REFRESH_MS = 2000;
|
|
|
23
23
|
const WIDGET_WIDTH = 80;
|
|
24
24
|
/** Preview panel cap: only the first 512KB of a file is ever read/sent. */
|
|
25
25
|
const MAX_PREVIEW_BYTES = 512 * 1024;
|
|
26
|
+
/** Thrown when the service is quiesced (draining) and the request is NEW work
|
|
27
|
+
* the admission controller refuses: a brand-new client attach, a prompt,
|
|
28
|
+
* a fork, a session resume, or a goal wizard start. index.ts closes the
|
|
29
|
+
* WebSocket with 4403 so the browser reconnect loop can retry after the
|
|
30
|
+
* server reopens admission (see AgentService.quiesce). */
|
|
31
|
+
export class QuiesceRejectedError extends Error {
|
|
32
|
+
code = "QUIESCED";
|
|
33
|
+
constructor(detail) {
|
|
34
|
+
super(`服务器正在排空存量工作(quiesce)——${detail}`);
|
|
35
|
+
this.name = "QuiesceRejectedError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
26
38
|
const PREVIEW_IMAGE_EXTS = new Set([
|
|
27
39
|
"png",
|
|
28
40
|
"jpg",
|
|
@@ -931,6 +943,9 @@ function conversationTitle(session) {
|
|
|
931
943
|
}
|
|
932
944
|
export class ClientSession {
|
|
933
945
|
clientId;
|
|
946
|
+
/** Set by AgentService.attach: reflects the SERVICE-wide quiesce flag
|
|
947
|
+
* (server draining — new work rejected). Default false for direct use. */
|
|
948
|
+
isQuiesced = () => false;
|
|
934
949
|
cwd;
|
|
935
950
|
/** pi config dir (auth/models/skills). */
|
|
936
951
|
agentDir;
|
|
@@ -2042,7 +2057,8 @@ export class ClientSession {
|
|
|
2042
2057
|
baseUrl: p.baseUrl,
|
|
2043
2058
|
apiKey: p.apiKey,
|
|
2044
2059
|
authHeader: p.authHeader,
|
|
2045
|
-
headers
|
|
2060
|
+
// headers are intentionally NOT sent to the browser — they may
|
|
2061
|
+
// contain Authorization / API-key values; kept server-side only.
|
|
2046
2062
|
models,
|
|
2047
2063
|
};
|
|
2048
2064
|
});
|
|
@@ -2075,14 +2091,17 @@ export class ClientSession {
|
|
|
2075
2091
|
}
|
|
2076
2092
|
try {
|
|
2077
2093
|
const { providers } = this.readModelsConfig();
|
|
2094
|
+
// headers never reach the browser, so the incoming config can't carry
|
|
2095
|
+
// them — preserve the previously stored values when they are absent.
|
|
2096
|
+
const prevHeaders = providers[pid]?.headers;
|
|
2078
2097
|
providers[pid] = {
|
|
2079
2098
|
...(config.name?.trim() ? { name: config.name.trim() } : {}),
|
|
2080
2099
|
...(config.api?.trim() ? { api: config.api.trim() } : {}),
|
|
2081
2100
|
...(config.baseUrl?.trim() ? { baseUrl: config.baseUrl.trim() } : {}),
|
|
2082
2101
|
...(config.apiKey?.trim() ? { apiKey: config.apiKey.trim() } : {}),
|
|
2083
2102
|
...(config.authHeader ? { authHeader: true } : {}),
|
|
2084
|
-
...(
|
|
2085
|
-
? { headers:
|
|
2103
|
+
...(prevHeaders && Object.keys(prevHeaders).length > 0
|
|
2104
|
+
? { headers: prevHeaders }
|
|
2086
2105
|
: {}),
|
|
2087
2106
|
models,
|
|
2088
2107
|
};
|
|
@@ -2572,6 +2591,44 @@ export class ClientSession {
|
|
|
2572
2591
|
// ---------------------------------------------------------------------------
|
|
2573
2592
|
// Commands
|
|
2574
2593
|
// ---------------------------------------------------------------------------
|
|
2594
|
+
/** True when the service is draining (quiesced): emits a rejection notice
|
|
2595
|
+
* and returns true. Guards every NEW-work entry point (prompt / new chat /
|
|
2596
|
+
* edit-resend / session resume / goal wizard) — existing runs keep going.
|
|
2597
|
+
* Called BEFORE any LLM/token work starts so quiesce is a hard admission
|
|
2598
|
+
* gate, not a best-effort hint. */
|
|
2599
|
+
quiesceBlocked() {
|
|
2600
|
+
if (!this.isQuiesced())
|
|
2601
|
+
return false;
|
|
2602
|
+
this.emit({
|
|
2603
|
+
type: "notice",
|
|
2604
|
+
level: "error",
|
|
2605
|
+
text: "服务器正在排空存量工作(quiesce),已拒绝新的对话/消息/编辑。存量运行会继续跑完;用 pi-web-ui server unquiesce 可恢复。",
|
|
2606
|
+
});
|
|
2607
|
+
this.flushSnapshot();
|
|
2608
|
+
return true;
|
|
2609
|
+
}
|
|
2610
|
+
/** Conversations with an in-flight run — active work for quiesce status. */
|
|
2611
|
+
activeConversations() {
|
|
2612
|
+
let n = 0;
|
|
2613
|
+
for (const c of this.convs.values()) {
|
|
2614
|
+
try {
|
|
2615
|
+
if (c.session.isStreaming)
|
|
2616
|
+
n += 1;
|
|
2617
|
+
}
|
|
2618
|
+
catch {
|
|
2619
|
+
// session being replaced — not running
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
return n;
|
|
2623
|
+
}
|
|
2624
|
+
/** Messages queued in the SDK (steer + follow-up) — pending work for
|
|
2625
|
+
* quiesce status. Quiesce refuses to add more, so this only drains. */
|
|
2626
|
+
pendingMessages() {
|
|
2627
|
+
let n = 0;
|
|
2628
|
+
for (const c of this.convs.values())
|
|
2629
|
+
n += c.queueFollowUp + c.queueSteering;
|
|
2630
|
+
return n;
|
|
2631
|
+
}
|
|
2575
2632
|
async prompt(text, attachments) {
|
|
2576
2633
|
try {
|
|
2577
2634
|
const s = this.session;
|
|
@@ -2583,6 +2640,11 @@ export class ClientSession {
|
|
|
2583
2640
|
this.flushSnapshot();
|
|
2584
2641
|
return;
|
|
2585
2642
|
}
|
|
2643
|
+
// Native commands above are pure config tweaks (no tokens) — allow them
|
|
2644
|
+
// even while quiesced. Everything that reaches the SDK is NEW work and
|
|
2645
|
+
// is refused until admission reopens.
|
|
2646
|
+
if (this.quiesceBlocked())
|
|
2647
|
+
return;
|
|
2586
2648
|
// Attach files as independent nextTurn context messages (asides) so the
|
|
2587
2649
|
// user message stays clean; they render as separate attachment cards.
|
|
2588
2650
|
const asides = await this.buildAttachmentMessages(attachments);
|
|
@@ -3255,6 +3317,8 @@ export class ClientSession {
|
|
|
3255
3317
|
}
|
|
3256
3318
|
}
|
|
3257
3319
|
async newChat() {
|
|
3320
|
+
if (this.quiesceBlocked())
|
|
3321
|
+
return;
|
|
3258
3322
|
// Reuse an already-open blank conversation instead of piling up new ones
|
|
3259
3323
|
// on every click: if the active chat has no messages it IS the new chat
|
|
3260
3324
|
// (focus already on it); otherwise switch to the first blank one (under
|
|
@@ -3438,6 +3502,8 @@ export class ClientSession {
|
|
|
3438
3502
|
}
|
|
3439
3503
|
/** Switch the active session to a persisted one (from listSessions). */
|
|
3440
3504
|
async switchSession(path) {
|
|
3505
|
+
if (this.quiesceBlocked())
|
|
3506
|
+
return;
|
|
3441
3507
|
try {
|
|
3442
3508
|
await this.runtime.switchSession(path);
|
|
3443
3509
|
await this.bindSession();
|
|
@@ -3496,6 +3562,8 @@ export class ClientSession {
|
|
|
3496
3562
|
* session list, so nothing is ever lost.
|
|
3497
3563
|
*/
|
|
3498
3564
|
async editMessage(messageId, text) {
|
|
3565
|
+
if (this.quiesceBlocked())
|
|
3566
|
+
return;
|
|
3499
3567
|
const trimmed = text.trim();
|
|
3500
3568
|
if (!trimmed) {
|
|
3501
3569
|
this.emit({
|
|
@@ -4035,6 +4103,8 @@ export class ClientSession {
|
|
|
4035
4103
|
* Mutually exclusive with the review loop.
|
|
4036
4104
|
*/
|
|
4037
4105
|
async startGoalWizard(text, opts) {
|
|
4106
|
+
if (this.quiesceBlocked())
|
|
4107
|
+
return;
|
|
4038
4108
|
const draft = (text ?? "").trim();
|
|
4039
4109
|
if (!draft)
|
|
4040
4110
|
return;
|
|
@@ -4726,6 +4796,15 @@ export class ClientSession {
|
|
|
4726
4796
|
export class AgentService {
|
|
4727
4797
|
cwd;
|
|
4728
4798
|
clients = new Map();
|
|
4799
|
+
/** Quiesce (draining) state — the service refuses NEW work (prompts, forks,
|
|
4800
|
+
* session resumes, new clients) so a deploy/upgrade/backup can stop cleanly
|
|
4801
|
+
* once existing runs finish. Controlled via the local control socket:
|
|
4802
|
+
* `pi-web-ui server quiesce|unquiesce`. */
|
|
4803
|
+
quiesced = false;
|
|
4804
|
+
quiescedAt = 0;
|
|
4805
|
+
/** Attached browser sockets (reported by index.ts on open/close) — the
|
|
4806
|
+
* control socket reports real sockets, not cached client-session objects. */
|
|
4807
|
+
socketCount = 0;
|
|
4729
4808
|
pending = new Map();
|
|
4730
4809
|
stateStore;
|
|
4731
4810
|
/**
|
|
@@ -4740,6 +4819,60 @@ export class AgentService {
|
|
|
4740
4819
|
this.stateStore = new ClientStateStore(stateFile);
|
|
4741
4820
|
}
|
|
4742
4821
|
/** Get or create the session for a client, racing attach calls safely. */
|
|
4822
|
+
/** True while the service is draining — new work is refused. */
|
|
4823
|
+
isQuiesced() {
|
|
4824
|
+
return this.quiesced;
|
|
4825
|
+
}
|
|
4826
|
+
/** Enter quiesce: stop admitting new work. Existing runs keep going. */
|
|
4827
|
+
quiesce() {
|
|
4828
|
+
this.quiesced = true;
|
|
4829
|
+
this.quiescedAt = Date.now();
|
|
4830
|
+
}
|
|
4831
|
+
/** Leave quiesce: admit new work again. */
|
|
4832
|
+
unquiesce() {
|
|
4833
|
+
this.quiesced = false;
|
|
4834
|
+
this.quiescedAt = 0;
|
|
4835
|
+
}
|
|
4836
|
+
/** Snapshot for the control socket / status command. */
|
|
4837
|
+
quiesceInfo() {
|
|
4838
|
+
return this.quiesced
|
|
4839
|
+
? { quiesced: true, quiescedSince: this.quiescedAt }
|
|
4840
|
+
: { quiesced: false };
|
|
4841
|
+
}
|
|
4842
|
+
/** Aggregate across every client session: conversations with in-flight runs. */
|
|
4843
|
+
activeConversations() {
|
|
4844
|
+
let n = 0;
|
|
4845
|
+
for (const cs of this.clients.values())
|
|
4846
|
+
n += cs.activeConversations();
|
|
4847
|
+
return n;
|
|
4848
|
+
}
|
|
4849
|
+
/** Aggregate across every client session: messages queued in the SDK. */
|
|
4850
|
+
pendingMessages() {
|
|
4851
|
+
let n = 0;
|
|
4852
|
+
for (const cs of this.clients.values())
|
|
4853
|
+
n += cs.pendingMessages();
|
|
4854
|
+
return n;
|
|
4855
|
+
}
|
|
4856
|
+
/** index.ts calls this when a browser socket opens/closes. */
|
|
4857
|
+
noteSocketOpen() {
|
|
4858
|
+
this.socketCount += 1;
|
|
4859
|
+
}
|
|
4860
|
+
noteSocketClose() {
|
|
4861
|
+
this.socketCount = Math.max(0, this.socketCount - 1);
|
|
4862
|
+
}
|
|
4863
|
+
/** Full status for the control socket / `server status` command. */
|
|
4864
|
+
serviceStatus() {
|
|
4865
|
+
return {
|
|
4866
|
+
pid: process.pid,
|
|
4867
|
+
version: VERSION,
|
|
4868
|
+
cwd: this.cwd,
|
|
4869
|
+
...this.quiesceInfo(),
|
|
4870
|
+
connectedClients: this.socketCount,
|
|
4871
|
+
activeConversations: this.activeConversations(),
|
|
4872
|
+
pendingMessages: this.pendingMessages(),
|
|
4873
|
+
};
|
|
4874
|
+
}
|
|
4875
|
+
/** Get or create the session for a client, racing attach calls safely. */
|
|
4743
4876
|
async attach(clientId, send) {
|
|
4744
4877
|
let cs = this.clients.get(clientId);
|
|
4745
4878
|
if (!cs) {
|
|
@@ -4749,6 +4882,13 @@ export class AgentService {
|
|
|
4749
4882
|
}
|
|
4750
4883
|
else {
|
|
4751
4884
|
// Restore this client's last-used workspace when it still exists;
|
|
4885
|
+
// Admission gate: while quiesced, only clients with an EXISTING
|
|
4886
|
+
// session may attach (they can watch their runs drain); brand-new
|
|
4887
|
+
// clients are refused — index.ts closes their socket (4403) and the
|
|
4888
|
+
// browser reconnect loop retries after admission reopens.
|
|
4889
|
+
if (this.quiesced) {
|
|
4890
|
+
throw new QuiesceRejectedError("新连接被拒绝,请等服务器恢复后重试");
|
|
4891
|
+
}
|
|
4752
4892
|
// otherwise fall back to the server's configured default cwd.
|
|
4753
4893
|
let cwd = this.cwd;
|
|
4754
4894
|
const saved = this.stateStore.get(clientId);
|
|
@@ -4783,6 +4923,7 @@ export class AgentService {
|
|
|
4783
4923
|
// Forward hooks (set once by index.ts) to every session.
|
|
4784
4924
|
cs.onUpdateReady = this.onUpdateReady;
|
|
4785
4925
|
cs.onQuit = this.onQuit;
|
|
4926
|
+
cs.isQuiesced = () => this.quiesced;
|
|
4786
4927
|
return cs;
|
|
4787
4928
|
}
|
|
4788
4929
|
/** Remove a socket from a client's broadcast set (called on socket close). */
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local control socket for the pi-web-ui server.
|
|
3
|
+
*
|
|
4
|
+
* Lets the CLI (and humans) query status and quiesce/unquiesce the server
|
|
5
|
+
* WITHOUT opening a network port or exposing an unauthenticated HTTP
|
|
6
|
+
* endpoint. Only the local OS user can reach it:
|
|
7
|
+
* - POSIX: a mode-0600 Unix domain socket at <dataDir>/pi-web-ui.sock
|
|
8
|
+
* - Windows: a named pipe \\.\pipe\pi-web-ui-<port>
|
|
9
|
+
*
|
|
10
|
+
* Protocol: one JSON object per line.
|
|
11
|
+
* → {"cmd":"status"} ← {"ok":true, ...serviceStatus}
|
|
12
|
+
* → {"cmd":"quiesce"} ← {"ok":true}
|
|
13
|
+
* → {"cmd":"unquiesce"} ← {"ok":true}
|
|
14
|
+
* → anything else ← {"ok":false,"error":"..."}
|
|
15
|
+
*
|
|
16
|
+
* Idle connections are closed after a short timeout so a stuck CLI never
|
|
17
|
+
* holds the socket.
|
|
18
|
+
*/
|
|
19
|
+
import { createServer, createConnection } from "node:net";
|
|
20
|
+
import { chmodSync, existsSync, rmSync } from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
/** How long a control connection may sit idle before the server closes it. */
|
|
23
|
+
const CONTROL_IDLE_TIMEOUT_MS = 5_000;
|
|
24
|
+
/** How long the CLI waits for a reply before giving up. */
|
|
25
|
+
const CONTROL_CLIENT_TIMEOUT_MS = 3_000;
|
|
26
|
+
/** Socket path (POSIX) or pipe name (Windows). */
|
|
27
|
+
export function controlPath(dataDir, port) {
|
|
28
|
+
return process.platform === "win32"
|
|
29
|
+
? `\\\\.\\pipe\\pi-web-ui-${port}`
|
|
30
|
+
: join(dataDir, "pi-web-ui.sock");
|
|
31
|
+
}
|
|
32
|
+
/** Start the control socket; returns a stop function. */
|
|
33
|
+
export function startControlServer(opts) {
|
|
34
|
+
const { service, dataDir, port } = opts;
|
|
35
|
+
const path = controlPath(dataDir, port);
|
|
36
|
+
let server;
|
|
37
|
+
let stop = false;
|
|
38
|
+
if (process.platform === "win32") {
|
|
39
|
+
server = createServer(handleConnection);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
// Remove a stale socket left by a previous crash (only if it's ours —
|
|
43
|
+
// an existing socket file that refuses connections is stale).
|
|
44
|
+
if (existsSync(path)) {
|
|
45
|
+
try {
|
|
46
|
+
rmSync(path);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
/* best-effort */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
server = createServer(handleConnection);
|
|
53
|
+
}
|
|
54
|
+
// A second instance on the same data dir / port would fail to bind — don't
|
|
55
|
+
// crash the server over it, just log and run without a control socket.
|
|
56
|
+
server.on("error", (err) => {
|
|
57
|
+
if (err.code === "EADDRINUSE") {
|
|
58
|
+
console.warn(`[control] socket ${path} already in use — control socket disabled`);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
console.warn(`[control] socket error: ${err.message}`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
function handleConnection(sock) {
|
|
65
|
+
let buf = "";
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
sock.destroy();
|
|
68
|
+
}, CONTROL_IDLE_TIMEOUT_MS);
|
|
69
|
+
sock.on("data", (chunk) => {
|
|
70
|
+
buf += chunk.toString("utf8");
|
|
71
|
+
let nl;
|
|
72
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
73
|
+
const line = buf.slice(0, nl).trim();
|
|
74
|
+
buf = buf.slice(nl + 1);
|
|
75
|
+
if (!line)
|
|
76
|
+
continue;
|
|
77
|
+
timer.refresh();
|
|
78
|
+
let req;
|
|
79
|
+
try {
|
|
80
|
+
req = JSON.parse(line);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
sock.write(JSON.stringify({ ok: false, error: "bad json" }) + "\n");
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
let resp;
|
|
87
|
+
switch (req.cmd) {
|
|
88
|
+
case "status":
|
|
89
|
+
resp = { ok: true, ...service.serviceStatus() };
|
|
90
|
+
break;
|
|
91
|
+
case "quiesce":
|
|
92
|
+
service.quiesce();
|
|
93
|
+
resp = { ok: true };
|
|
94
|
+
break;
|
|
95
|
+
case "unquiesce":
|
|
96
|
+
service.unquiesce();
|
|
97
|
+
resp = { ok: true };
|
|
98
|
+
break;
|
|
99
|
+
default:
|
|
100
|
+
resp = { ok: false, error: `unknown cmd: ${String(req.cmd)}` };
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
sock.write(JSON.stringify(resp) + "\n");
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
sock.on("error", () => {
|
|
107
|
+
/* client vanished */
|
|
108
|
+
});
|
|
109
|
+
sock.on("close", () => clearTimeout(timer));
|
|
110
|
+
}
|
|
111
|
+
if (process.platform === "win32") {
|
|
112
|
+
// net.Server on a named pipe: listen on the pipe name directly.
|
|
113
|
+
server.listen(path, () => {
|
|
114
|
+
console.log(` control : ${path}`);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
server.listen(path, () => {
|
|
119
|
+
try {
|
|
120
|
+
chmodSync(path, 0o600);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
/* best-effort */
|
|
124
|
+
}
|
|
125
|
+
console.log(` control : ${path}`);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return () => {
|
|
129
|
+
stop = true;
|
|
130
|
+
server.close();
|
|
131
|
+
try {
|
|
132
|
+
if (process.platform !== "win32" && existsSync(path))
|
|
133
|
+
rmSync(path);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
/* best-effort */
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* CLI-side client: send one command and return the parsed reply (or null if
|
|
142
|
+
* the server is unreachable / timed out).
|
|
143
|
+
*/
|
|
144
|
+
export function sendControlCommand(dataDir, port, cmd) {
|
|
145
|
+
const path = controlPath(dataDir, port);
|
|
146
|
+
return new Promise((resolve) => {
|
|
147
|
+
const sock = createConnection(path);
|
|
148
|
+
let done = false;
|
|
149
|
+
const finish = (v) => {
|
|
150
|
+
if (done)
|
|
151
|
+
return;
|
|
152
|
+
done = true;
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
sock.destroy();
|
|
155
|
+
resolve(v);
|
|
156
|
+
};
|
|
157
|
+
const timer = setTimeout(() => finish(null), CONTROL_CLIENT_TIMEOUT_MS);
|
|
158
|
+
let buf = "";
|
|
159
|
+
sock.on("connect", () => {
|
|
160
|
+
sock.write(JSON.stringify({ cmd }) + "\n");
|
|
161
|
+
});
|
|
162
|
+
sock.on("data", (chunk) => {
|
|
163
|
+
buf += chunk.toString("utf8");
|
|
164
|
+
const nl = buf.indexOf("\n");
|
|
165
|
+
if (nl >= 0) {
|
|
166
|
+
try {
|
|
167
|
+
finish(JSON.parse(buf.slice(0, nl)));
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
finish(null);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
sock.on("error", () => finish(null));
|
|
175
|
+
sock.on("close", () => finish(null));
|
|
176
|
+
});
|
|
177
|
+
}
|