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 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: p.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
- ...(config.headers && Object.keys(config.headers).length > 0
2085
- ? { headers: config.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
+ }