pi-web-ui 0.8.2 → 0.8.4

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/bin/pi-web-ui.mjs CHANGED
@@ -394,7 +394,7 @@ User=${process.env.SUDO_USER ?? userInfo().username}
394
394
  WorkingDirectory=${cwd}
395
395
  ${envLines}
396
396
  ExecStart=${JSON.stringify(NODE)} ${JSON.stringify(SERVER_ENTRY)}
397
- Restart=on-failure
397
+ Restart=always
398
398
  RestartSec=5
399
399
 
400
400
  [Install]
@@ -946,6 +946,11 @@ export class ClientSession {
946
946
  }
947
947
  /** True once updateApp succeeded — the process must restart to run new code. */
948
948
  pendingRestart = false;
949
+ /**
950
+ * Set by index.ts: called after a successful self-update; returns whether
951
+ * the process is going to restart itself (so the notice can say so).
952
+ */
953
+ onUpdateReady = undefined;
949
954
  /** Ask the npm registry for the latest pi-web-ui version and report it. */
950
955
  async checkUpdate() {
951
956
  const current = ClientSession.currentAppVersion();
@@ -993,10 +998,13 @@ export class ClientSession {
993
998
  ok: true,
994
999
  detail: out.slice(0, 400),
995
1000
  });
1001
+ const autoRestart = this.onUpdateReady?.() ?? false;
996
1002
  this.emit({
997
1003
  type: "notice",
998
1004
  level: "info",
999
- text: "✅ 已更新 pi-web-ui,重启服务后生效(pi-web-ui server restart)",
1005
+ text: autoRestart
1006
+ ? "✅ 已更新 pi-web-ui,正在自动重启…"
1007
+ : "✅ 已更新 pi-web-ui,重启服务后生效(pi-web-ui server restart)",
1000
1008
  });
1001
1009
  }
1002
1010
  else {
@@ -2350,6 +2358,11 @@ export class AgentService {
2350
2358
  clients = new Map();
2351
2359
  pending = new Map();
2352
2360
  stateStore;
2361
+ /**
2362
+ * Set by index.ts: called by a client session after a successful
2363
+ * self-update; returns whether the process will restart itself.
2364
+ */
2365
+ onUpdateReady = undefined;
2353
2366
  constructor(cwd, sessionDirRoot, stateFile) {
2354
2367
  this.cwd = cwd;
2355
2368
  this.sessionDirRoot = sessionDirRoot;
@@ -2395,6 +2408,8 @@ export class AgentService {
2395
2408
  }
2396
2409
  }
2397
2410
  cs.attachSink(send);
2411
+ // Forward the update hook (set once by index.ts) to every session.
2412
+ cs.onUpdateReady = this.onUpdateReady;
2398
2413
  return cs;
2399
2414
  }
2400
2415
  /** Remove a socket from a client's broadcast set (called on socket close). */
@@ -15,6 +15,8 @@
15
15
  import { existsSync } from "node:fs";
16
16
  import { stat } from "node:fs/promises";
17
17
  import { createServer } from "node:http";
18
+ import { createConnection } from "node:net";
19
+ import { spawn } from "node:child_process";
18
20
  import { basename, dirname, join, resolve } from "node:path";
19
21
  import { fileURLToPath } from "node:url";
20
22
  import { randomUUID } from "node:crypto";
@@ -109,6 +111,51 @@ const heartbeatTimer = setInterval(() => {
109
111
  const service = new AgentService(CWD, SESSION_DIR_ROOT,
110
112
  // Per-client persisted UI state: last-used workspace + recent projects.
111
113
  join(DATA_DIR, "client-state.json"));
114
+ // ---------------------------------------------------------------------------
115
+ // Self-update auto-restart
116
+ // ---------------------------------------------------------------------------
117
+ // npm i -g writes new code to disk but the running process keeps the old
118
+ // code in memory — so a successful in-app update hands the process over:
119
+ // macOS launchd (KeepAlive) and systemd (Restart) relaunch us on exit;
120
+ // foreground runs get a replacement child that waits for our port to free.
121
+ // Docker containers can't self-restart (the orchestrator owns that), so they
122
+ // keep the manual-restart notice.
123
+ const RESTART_CHILD_ENV = "PI_WEB_RESTART_CHILD";
124
+ function scheduleUpdateRestart() {
125
+ const isLaunchd = process.platform === "darwin" && process.ppid === 1;
126
+ const isSystemd = process.platform === "linux" && !!process.env.INVOCATION_ID;
127
+ const inDocker = existsSync("/.dockerenv");
128
+ if (isLaunchd || isSystemd || inDocker) {
129
+ // Supervisors relaunch on exit; Docker restarts externally. Nothing to
130
+ // spawn — just exit after the notice has flushed.
131
+ if (isLaunchd || isSystemd) {
132
+ setTimeout(() => {
133
+ console.log("update applied — auto-restarting…");
134
+ if (isSystemd) {
135
+ // Non-zero exit: legacy units use Restart=on-failure.
136
+ process.exit(3);
137
+ }
138
+ void shutdown();
139
+ }, 1500);
140
+ return true;
141
+ }
142
+ return false;
143
+ }
144
+ // Foreground / Windows: spawn a replacement from the updated install and
145
+ // exit. Same stdio (logs keep flowing), same args/env (port, cwd, data
146
+ // dir…); the child waits for this port to free before binding.
147
+ setTimeout(() => {
148
+ console.log("update applied — spawning replacement…");
149
+ spawn(process.execPath, process.argv.slice(1), {
150
+ stdio: "inherit",
151
+ env: { ...process.env, [RESTART_CHILD_ENV]: "1" },
152
+ ...(process.platform === "win32" ? { windowsHide: true } : {}),
153
+ });
154
+ void shutdown();
155
+ }, 1500);
156
+ return true;
157
+ }
158
+ service.onUpdateReady = scheduleUpdateRestart;
112
159
  wss.on("connection", (ws) => {
113
160
  let clientId = null;
114
161
  let closed = false;
@@ -279,6 +326,29 @@ wss.on("connection", (ws) => {
279
326
  service.detach(clientId, send);
280
327
  });
281
328
  });
329
+ // When spawned by the old process as an auto-restart replacement, wait for
330
+ // the old instance to release the port before binding (it exits right after
331
+ // spawning us). Probe by attempting a connection: refused = free.
332
+ if (process.env[RESTART_CHILD_ENV] === "1") {
333
+ const deadline = Date.now() + 20_000;
334
+ const portFree = () => new Promise((resolve) => {
335
+ const sock = createConnection({ port: PORT, host: "127.0.0.1" });
336
+ sock.once("connect", () => {
337
+ sock.destroy();
338
+ resolve(false); // busy — old instance still up
339
+ });
340
+ sock.once("error", () => resolve(true)); // refused → free
341
+ sock.setTimeout(500, () => {
342
+ sock.destroy();
343
+ resolve(false);
344
+ });
345
+ });
346
+ while (Date.now() < deadline) {
347
+ if (await portFree())
348
+ break;
349
+ await new Promise((r) => setTimeout(r, 300));
350
+ }
351
+ }
282
352
  httpServer.listen(PORT, () => {
283
353
  console.log("");
284
354
  console.log(" ⚡ pi-web-ui — web chat for the pi coding agent");
@@ -137,7 +137,10 @@ function shellEnv() {
137
137
  // and node-pty throws the generic "posix_spawnp failed". Locally-built
138
138
  // copies (build/Release) are fine; every `npm install` that picks the prebuild
139
139
  // — e.g. `npm i -g pi-web-ui`, which is what system-service installs run — is
140
- // broken until the bit is restored. Self-heal at startup, best-effort.
140
+ // broken until the bit is restored. Self-heal at startup AND lazily before
141
+ // every spawn (an `npm i -g` while the server is running replaces the helper
142
+ // under the running process, so the startup-only repair misses it).
143
+ // Best-effort: a read-only node_modules just keeps the old failure.
141
144
  const require = createRequire(import.meta.url);
142
145
  /** Absolute paths of every node-pty spawn-helper this install can exec. */
143
146
  function spawnHelperPaths() {
@@ -294,6 +297,9 @@ export class TerminalManager {
294
297
  this.fail(id, `目录不存在:${abs}`);
295
298
  return false;
296
299
  }
300
+ // node-pty's spawn-helper may have lost its +x bit since the last repair
301
+ // (e.g. a global npm install replaced the helper while this server runs).
302
+ repairSpawnHelperPermissions();
297
303
  let pty;
298
304
  try {
299
305
  pty = spawn(SHELL, SHELL_ARGS, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
5
5
  "license": "MIT",
6
6
  "type": "module",