@f5-sales-demo/xcsh 19.58.3 → 19.61.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "19.58.3",
4
+ "version": "19.61.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -55,13 +55,13 @@
55
55
  "dependencies": {
56
56
  "@agentclientprotocol/sdk": "0.16.1",
57
57
  "@mozilla/readability": "^0.6",
58
- "@f5-sales-demo/xcsh-stats": "19.58.3",
59
- "@f5-sales-demo/pi-agent-core": "19.58.3",
60
- "@f5-sales-demo/pi-ai": "19.58.3",
61
- "@f5-sales-demo/pi-natives": "19.58.3",
62
- "@f5-sales-demo/pi-resource-management": "19.58.3",
63
- "@f5-sales-demo/pi-tui": "19.58.3",
64
- "@f5-sales-demo/pi-utils": "19.58.3",
58
+ "@f5-sales-demo/xcsh-stats": "19.61.0",
59
+ "@f5-sales-demo/pi-agent-core": "19.61.0",
60
+ "@f5-sales-demo/pi-ai": "19.61.0",
61
+ "@f5-sales-demo/pi-natives": "19.61.0",
62
+ "@f5-sales-demo/pi-resource-management": "19.61.0",
63
+ "@f5-sales-demo/pi-tui": "19.61.0",
64
+ "@f5-sales-demo/pi-utils": "19.61.0",
65
65
  "@sinclair/typebox": "^0.34",
66
66
  "@xterm/headless": "^6.0",
67
67
  "ajv": "^8.20",
@@ -156,6 +156,12 @@ export class ChatHandler {
156
156
  this.#server.send(frame);
157
157
  }
158
158
 
159
+ /** True while a chat turn is in flight (streaming or an active request). Used by
160
+ * the worker's SIGTERM drain to let a running turn finish before teardown (#1874). */
161
+ get busy(): boolean {
162
+ return this.#activeChats.size > 0 || this.#session.isStreaming;
163
+ }
164
+
159
165
  dispose(): void {
160
166
  for (const chat of this.#activeChats.values()) {
161
167
  this.#sendTerminal(chat, { type: "chat_error", id: chat.id, error: "session disposed" });
@@ -7,14 +7,30 @@
7
7
  */
8
8
 
9
9
  import * as fs from "node:fs";
10
+ import { homedir } from "node:os";
10
11
  import * as path from "node:path";
11
12
  import { acquirePage, type BrowserProviderStatus, CdpBrowserProvider } from "../browser";
12
13
  import { PORT_RANGE_END, PORT_RANGE_START, resolveForcedPort } from "../browser/extension-bridge";
13
14
  import { installNativeHost } from "../services/native-host-install";
14
15
 
16
+ /** Ask a running manager to step down (control-socket `shutdown` frame). Resolves
17
+ * true if a manager answered the socket, false if none was running. Best-effort. */
18
+ async function requestManagerShutdown(reason: "updated"): Promise<boolean> {
19
+ const sock = process.env.XCSH_MANAGER_SOCK ?? path.join(homedir(), ".xcsh", "manager.sock");
20
+ try {
21
+ const c = await Bun.connect({ unix: sock, socket: { data() {} } });
22
+ c.write(`${JSON.stringify({ type: "shutdown", reason })}\n`);
23
+ await Bun.sleep(50);
24
+ c.end();
25
+ return true;
26
+ } catch {
27
+ return false; // no manager listening
28
+ }
29
+ }
30
+
15
31
  type Settings = { get(key: string): unknown };
16
32
 
17
- export type ChromeAction = "status" | "relaunch" | "setup" | "install-host";
33
+ export type ChromeAction = "status" | "relaunch" | "setup" | "install-host" | "recycle";
18
34
 
19
35
  export const EXTENSION_ID = "klajkjdoehjidngligegnpknogmjjhkc";
20
36
 
@@ -56,10 +72,15 @@ export function nativeHostLaunchCommand(
56
72
  execPath: string = process.execPath,
57
73
  resolveXcshBin: () => string | null = defaultResolveXcshBin,
58
74
  ): string[] {
75
+ // Prefer a VERSION-STABLE launcher on PATH (e.g. the Homebrew symlink
76
+ // /opt/homebrew/bin/xcsh) for BOTH compiled and bun installs. This keeps the
77
+ // native-messaging wrapper working across `brew upgrade`s instead of pinning
78
+ // the install-time versioned path (…/Cellar/xcsh/<v>/bin/xcsh), which froze
79
+ // the extension on an old version until install-host was re-run (#1874).
80
+ const xcsh = resolveXcshBin();
81
+ if (xcsh) return [xcsh];
59
82
  const base = path.basename(execPath).toLowerCase();
60
83
  if (base.startsWith("bun")) {
61
- const xcsh = resolveXcshBin();
62
- if (xcsh) return [xcsh];
63
84
  const script = argv[1];
64
85
  if (script && (script.endsWith(".ts") || script.endsWith(".js") || script.endsWith(".mjs"))) {
65
86
  return [execPath, path.resolve(script)];
@@ -111,6 +132,18 @@ export async function runChromeCommand(action: ChromeAction, settings: Settings)
111
132
  const manifestPath = installNativeHost({ launchCommand, extensionIds: [EXTENSION_ID] });
112
133
  return `Native-messaging host manifest written to:\n ${manifestPath}`;
113
134
  }
135
+ if (action === "recycle") {
136
+ // Proactive post-upgrade recycle (#1874 Task 7): refresh the wrapper to the
137
+ // version-stable launcher, then ask any running (old) manager to step down so
138
+ // the new version takes effect NOW instead of on the next Chrome launch. The
139
+ // successor re-adopts live workers (zero-downtime). Both best-effort.
140
+ const launchCommand = nativeHostLaunchCommand();
141
+ installNativeHost({ launchCommand, extensionIds: [EXTENSION_ID] });
142
+ const stepped = await requestManagerShutdown("updated");
143
+ return stepped
144
+ ? "Refreshed native-host wrapper; asked the running manager to recycle to this version."
145
+ : "Refreshed native-host wrapper; no manager was running (it will start fresh on next use).";
146
+ }
114
147
  // relaunch: self-consented rung 3 — force allowRelaunch regardless of the setting.
115
148
  const { mode } = await acquirePage({ settings, allowRelaunch: true });
116
149
  return `Chrome ready (${mode}). Your real, logged-in session is now debuggable for xcsh.`;
@@ -315,6 +315,10 @@ function updateViaBrew(targetPath: string, expectedVersion: string): void {
315
315
  console.log(chalk.yellow(`To update to ${expectedVersion}, run:`));
316
316
  console.log(chalk.cyan(` brew upgrade ${APP_NAME}`));
317
317
  console.log(chalk.dim("\nThis ensures the update goes through your organization's Homebrew tap."));
318
+ // #1874 Task 7: brew upgrade runs out-of-band, so we can't recycle automatically.
319
+ // Nudge the one command that applies it to the Chrome extension immediately.
320
+ console.log(chalk.dim(`Then run ${APP_NAME} chrome recycle to apply it to the extension now`));
321
+ console.log(chalk.dim("(otherwise it takes effect the next time you open Chrome)."));
318
322
  }
319
323
 
320
324
  /**
@@ -413,6 +417,18 @@ export async function runUpdateCommand(opts: { force: boolean; check: boolean })
413
417
  await updateViaBinaryAt(target.path, release.version);
414
418
  break;
415
419
  }
420
+
421
+ // #1874 Task 7: the binary just changed under a possibly-running OLD manager.
422
+ // Proactively recycle so the new version takes effect now instead of on the
423
+ // next Chrome launch — refresh the native-host wrapper + step the old manager
424
+ // down (its successor re-adopts live workers, zero-downtime). The just-updated
425
+ // `xcsh` on PATH is the new version. Best-effort, non-blocking; passive
426
+ // supersede covers it if this is skipped.
427
+ try {
428
+ Bun.spawn(["xcsh", "chrome", "recycle"], { stdout: "ignore", stderr: "ignore" });
429
+ } catch {
430
+ /* recycle is a convenience — never fail an update on it */
431
+ }
416
432
  } catch (err) {
417
433
  console.error(chalk.red(`Update failed: ${err}`));
418
434
  process.exit(1);
@@ -5,7 +5,7 @@ import { Args, Command } from "@f5-sales-demo/pi-utils/cli";
5
5
  import { type ChromeAction, runChromeCommand } from "../cli/chrome-cli";
6
6
  import { Settings, settings } from "../config/settings";
7
7
 
8
- const ACTIONS: ChromeAction[] = ["status", "relaunch", "setup", "install-host"];
8
+ const ACTIONS: ChromeAction[] = ["status", "relaunch", "setup", "install-host", "recycle"];
9
9
 
10
10
  export default class Chrome extends Command {
11
11
  static description = "Inspect or arrange the Chrome session xcsh drives for console automation";
@@ -10,10 +10,26 @@ export interface WorkerRec {
10
10
 
11
11
  export type Registry = Map<string, WorkerRec>;
12
12
 
13
+ /** Reasons a manager is asked to step down (#1874). Closed set — fail closed. */
14
+ export type ShutdownReason = "superseded" | "updated" | "manual";
15
+ const SHUTDOWN_REASONS = new Set<string>(["superseded", "updated", "manual"]);
16
+
13
17
  export type ControlMsg =
14
18
  | { type: "provision"; sessionId: string; tenant: string }
15
19
  | { type: "release"; sessionId: string }
16
- | { type: "status" };
20
+ | { type: "status" }
21
+ | { type: "hello" }
22
+ | { type: "shutdown"; reason: ShutdownReason };
23
+
24
+ /** The manager's on-disk liveness record (`~/.xcsh/manager.json`), used for
25
+ * observability and as the escalation target (pid) when a superseded manager
26
+ * won't release the socket. The control-socket `hello` answer is authoritative. */
27
+ export interface ManagerState {
28
+ pid: number;
29
+ version: string;
30
+ socket: string;
31
+ startedAt: number;
32
+ }
17
33
 
18
34
  function isTenant(v: unknown): v is string {
19
35
  return typeof v === "string" && /^[^|]+\|[^|]+$/.test(v);
@@ -30,9 +46,56 @@ export function parseControlMsg(raw: unknown): ControlMsg | null {
30
46
  if (m.type === "provision" && isNonEmpty(m.sessionId) && isTenant(m.tenant))
31
47
  return { type: "provision", sessionId: m.sessionId, tenant: m.tenant };
32
48
  if (m.type === "release" && isNonEmpty(m.sessionId)) return { type: "release", sessionId: m.sessionId };
49
+ if (m.type === "hello") return { type: "hello" };
50
+ if (m.type === "shutdown" && typeof m.reason === "string" && SHUTDOWN_REASONS.has(m.reason))
51
+ return { type: "shutdown", reason: m.reason as ShutdownReason };
33
52
  return null;
34
53
  }
35
54
 
55
+ /** Parse a plain `major.minor.patch` version into a numeric tuple, or null. */
56
+ function parseSemver(v: string | null | undefined): [number, number, number] | null {
57
+ if (typeof v !== "string") return null;
58
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v.trim());
59
+ if (!m) return null;
60
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
61
+ }
62
+
63
+ /** True iff `ourVersion` is a valid version strictly greater than a valid
64
+ * `runningVersion` — the trigger to replace an older manager. Fails closed
65
+ * (false) on equal/newer/any-unparseable input, so we never downgrade or flap. */
66
+ export function shouldSupersede(runningVersion: string | null, ourVersion: string): boolean {
67
+ const a = parseSemver(runningVersion);
68
+ const b = parseSemver(ourVersion);
69
+ if (!a || !b) return false;
70
+ for (let i = 0; i < 3; i++) {
71
+ if (b[i] > a[i]) return true;
72
+ if (b[i] < a[i]) return false;
73
+ }
74
+ return false; // equal
75
+ }
76
+
77
+ /** Serialize the manager liveness record for `~/.xcsh/manager.json`. */
78
+ export function serializeManagerState(s: ManagerState): string {
79
+ return JSON.stringify({ pid: s.pid, version: s.version, socket: s.socket, startedAt: s.startedAt });
80
+ }
81
+
82
+ /** Parse `~/.xcsh/manager.json`; null on corrupt/missing/ill-shaped input
83
+ * (a positive pid, non-empty version + socket, and finite startedAt required). */
84
+ export function parseManagerState(text: string): ManagerState | null {
85
+ let raw: unknown;
86
+ try {
87
+ raw = JSON.parse(text);
88
+ } catch {
89
+ return null;
90
+ }
91
+ if (!raw || typeof raw !== "object") return null;
92
+ const m = raw as Record<string, unknown>;
93
+ if (typeof m.pid !== "number" || !Number.isInteger(m.pid) || m.pid <= 0) return null;
94
+ if (!isNonEmpty(m.version) || !isNonEmpty(m.socket)) return null;
95
+ if (typeof m.startedAt !== "number" || !Number.isFinite(m.startedAt)) return null;
96
+ return { pid: m.pid, version: m.version, socket: m.socket, startedAt: m.startedAt };
97
+ }
98
+
36
99
  /** Idempotency: only provision when there is no live worker for the sessionId. */
37
100
  export function needsProvision(reg: Registry, sessionId: string): boolean {
38
101
  return !reg.has(sessionId);
@@ -20,7 +20,11 @@ import * as fs from "node:fs";
20
20
  import { homedir } from "node:os";
21
21
  import { dirname, join } from "node:path";
22
22
  import { Command } from "@f5-sales-demo/pi-utils/cli";
23
+ // Lean subpath (not the package barrel, which pulls the winston logger graph and
24
+ // slows the manager's cold start — keep the daemon's module graph minimal).
25
+ import { VERSION } from "@f5-sales-demo/pi-utils/dirs";
23
26
  import { portCandidates } from "../browser/extension-bridge";
27
+ import { removeManagerState, writeManagerState } from "../services/manager-state";
24
28
  import { needsProvision, parseControlMsg, pickPort, type Registry, sparesToSpawn, staleKeys } from "./manager-core";
25
29
 
26
30
  /** Reap a worker idle longer than this (ms). */
@@ -50,6 +54,66 @@ function isPortFree(port: number): boolean {
50
54
  }
51
55
  }
52
56
 
57
+ /** The PID listening on a loopback bridge port, or 0 if unknown (best-effort via
58
+ * lsof). Used only to give a re-adopted worker a reapable pid; 0 means "manage by
59
+ * socket liveness, never signal" (see killPid's pid<=0 guard). */
60
+ function pidListeningOn(port: number): number {
61
+ try {
62
+ const out = Bun.spawnSync(["lsof", "-t", `-iTCP:${port}`, "-sTCP:LISTEN"])
63
+ .stdout.toString()
64
+ .trim()
65
+ .split("\n")[0];
66
+ const pid = Number(out);
67
+ return Number.isInteger(pid) && pid > 0 ? pid : 0;
68
+ } catch {
69
+ return 0; // lsof unavailable — leave unknown
70
+ }
71
+ }
72
+
73
+ /** Complete the extension `hello` handshake against a bridge port (with the
74
+ * origin header the bridge requires), resolving the `hello_ack` frame or null.
75
+ * EXTENSION_ID is lazy-required so it stays off the manager's cold-start path. */
76
+ function bridgeHello(port: number, timeoutMs = 400): Promise<Record<string, unknown> | null> {
77
+ return new Promise(resolve => {
78
+ let done = false;
79
+ const finish = (v: Record<string, unknown> | null) => {
80
+ if (done) return;
81
+ done = true;
82
+ resolve(v);
83
+ };
84
+ let ws: WebSocket;
85
+ try {
86
+ const { EXTENSION_ID } = require("../cli/chrome-cli");
87
+ ws = new WebSocket(`ws://127.0.0.1:${port}`, {
88
+ headers: { Origin: `chrome-extension://${EXTENSION_ID}` },
89
+ } as unknown as string[]);
90
+ } catch {
91
+ return finish(null);
92
+ }
93
+ const close = () => {
94
+ try {
95
+ ws.close();
96
+ } catch {
97
+ /* already closing */
98
+ }
99
+ };
100
+ ws.onopen = () => ws.send(JSON.stringify({ type: "hello" }));
101
+ ws.onmessage = e => {
102
+ try {
103
+ finish(JSON.parse(String(e.data)) as Record<string, unknown>);
104
+ } catch {
105
+ finish(null);
106
+ }
107
+ close();
108
+ };
109
+ ws.onerror = () => finish(null);
110
+ setTimeout(() => {
111
+ close();
112
+ finish(null);
113
+ }, timeoutMs);
114
+ });
115
+ }
116
+
53
117
  /**
54
118
  * The argv (AFTER `process.execPath`) to re-run THIS binary in `mode`.
55
119
  *
@@ -130,6 +194,17 @@ export default class Manager extends Command {
130
194
  const reg: Registry = new Map();
131
195
  const range = portCandidates();
132
196
 
197
+ // The version this manager advertises (hello ack + manager.json). Overridable
198
+ // via env ONLY so the supersede integration tests can stand up an "old" manager
199
+ // (production always reports the real binary VERSION).
200
+ const selfVersion = process.env.XCSH_MANAGER_VERSION || VERSION;
201
+
202
+ // Lifecycle gate (#1874): once we begin graceful shutdown we stop accepting
203
+ // provisions (the host retries the successor manager) and never double-run
204
+ // the teardown.
205
+ let accepting = true;
206
+ let shuttingDown = false;
207
+
133
208
  const poolTarget = Math.max(0, Math.trunc(Number(process.env.XCSH_WORKER_POOL_SIZE ?? "2")) || 0);
134
209
  interface SpareRec {
135
210
  proc: Bun.Subprocess;
@@ -180,6 +255,34 @@ export default class Manager extends Command {
180
255
  for (let i = 0; i < n; i++) spawnSpare();
181
256
  };
182
257
 
258
+ /** Zero-downtime handoff (#1874 Task 6): on startup, discover bridge workers a
259
+ * PRIOR (superseded) manager left running and re-register them, so a tab's
260
+ * session survives the manager swap. Probes the whole range in parallel; a
261
+ * bridge answering with a real per-tab sessionId (not the "spare" sentinel) and
262
+ * a tenant+env is re-adopted. Spares/unknowns are ignored (the pool refills
263
+ * them). Best-effort — never throws. */
264
+ const readoptWorkers = async (): Promise<void> => {
265
+ const found = await Promise.all(range.map(async port => ({ port, ack: await bridgeHello(port) })));
266
+ for (const { port, ack } of found) {
267
+ if (!ack) continue;
268
+ const sid = ack.sessionId;
269
+ const tenant = ack.tenant;
270
+ const env = ack.env;
271
+ if (typeof sid !== "string" || sid === "spare" || typeof tenant !== "string" || typeof env !== "string") {
272
+ continue; // spare sentinel or tenant-less bridge — not a re-adoptable session
273
+ }
274
+ if (reg.has(sid)) continue;
275
+ reg.set(sid, {
276
+ sessionId: sid,
277
+ tenant: `${tenant}|${env}`,
278
+ port,
279
+ pid: pidListeningOn(port),
280
+ lastSeen: Date.now(),
281
+ });
282
+ console.error(`[xcsh manager] re-adopted worker ${sid} (${tenant}|${env}) on port ${port}`);
283
+ }
284
+ };
285
+
183
286
  /** Adopt a warm spare for a provision (bind over IPC). Returns false if none available. */
184
287
  const adoptSpare = (msg: { sessionId: string; tenant: string }): boolean => {
185
288
  const rec = pool.shift();
@@ -204,15 +307,51 @@ export default class Manager extends Command {
204
307
  return true;
205
308
  };
206
309
 
310
+ const killPid = (pid: number): void => {
311
+ if (pid <= 0) return; // 0/negative = unknown (re-adopted worker) — NEVER signal (kill(0) hits the group)
312
+ try {
313
+ process.kill(pid); // SIGTERM — spares exit at once; bound workers self-drain (worker.ts)
314
+ } catch {
315
+ /* already gone */
316
+ }
317
+ };
318
+
207
319
  const reap = (sessionId: string): void => {
208
320
  const w = reg.get(sessionId);
209
321
  if (!w) return;
322
+ killPid(w.pid);
323
+ reg.delete(sessionId);
324
+ };
325
+
326
+ /** Graceful teardown (#1874): stop accepting, terminate the (session-less)
327
+ * spare pool at once, drop the socket + liveness record, and exit. Idempotent.
328
+ *
329
+ * Bound workers depend on the reason: on a HANDOFF (superseded/updated — a
330
+ * successor manager is about to bind and re-adopt them), we LEAVE them running
331
+ * so tab sessions survive the swap (zero-downtime, Task 6). Otherwise (manual
332
+ * operator SIGTERM — no successor) we SIGTERM them so they drain + exit rather
333
+ * than leak. The manager frees the socket immediately either way. */
334
+ const gracefulShutdown = (reason: string): void => {
335
+ if (shuttingDown) return;
336
+ shuttingDown = true;
337
+ accepting = false;
338
+ const handoff = reason === "superseded" || reason === "updated";
339
+ console.error(
340
+ `[xcsh manager] graceful shutdown (${reason}); reaping ${pool.length} spare(s)` +
341
+ (handoff ? `, leaving ${reg.size} worker(s) for re-adoption` : ` + ${reg.size} worker(s)`),
342
+ );
343
+ for (const s of pool.splice(0)) killPid(s.pid);
344
+ if (!handoff) {
345
+ for (const w of reg.values()) killPid(w.pid);
346
+ reg.clear();
347
+ }
348
+ removeManagerState(sockPath);
210
349
  try {
211
- process.kill(w.pid);
350
+ fs.rmSync(sockPath, { force: true });
212
351
  } catch {
213
- /* already gone */
352
+ /* best effort — the OS drops the bound socket on exit anyway */
214
353
  }
215
- reg.delete(sessionId);
354
+ process.exit(0);
216
355
  };
217
356
 
218
357
  const spawnWorker = (msg: { sessionId: string; tenant: string }): void => {
@@ -257,7 +396,7 @@ export default class Manager extends Command {
257
396
  console.error(`[xcsh manager] provisioned ${msg.sessionId} (${msg.tenant}) → pid ${proc.pid} on port ${port}`);
258
397
  };
259
398
 
260
- const handleFrame = (line: string): void => {
399
+ const handleFrame = (line: string, socket: { write(data: string): void }): void => {
261
400
  const trimmed = line.trim();
262
401
  if (!trimmed) return;
263
402
  let raw: unknown;
@@ -269,6 +408,15 @@ export default class Manager extends Command {
269
408
  const msg = parseControlMsg(raw);
270
409
  if (!msg) return; // fail closed on unknown/invalid frames
271
410
  if (msg.type === "provision") {
411
+ // Draining: refuse new work so the host retries the successor manager.
412
+ if (!accepting) {
413
+ try {
414
+ socket.write(`${JSON.stringify({ type: "draining" })}\n`);
415
+ } catch {
416
+ /* client hung up */
417
+ }
418
+ return;
419
+ }
272
420
  if (needsProvision(reg, msg.sessionId)) {
273
421
  if (!adoptSpare(msg)) spawnWorker(msg); // adopt a warm spare, else cold-spawn (fallback)
274
422
  }
@@ -276,6 +424,19 @@ export default class Manager extends Command {
276
424
  if (w) w.lastSeen = Date.now(); // touch on every provision (keep-alive)
277
425
  } else if (msg.type === "release") {
278
426
  reap(msg.sessionId);
427
+ } else if (msg.type === "shutdown") {
428
+ // A newer native-host (supersede) or the updater asks us to step down.
429
+ gracefulShutdown(msg.reason);
430
+ } else if (msg.type === "hello") {
431
+ // Identity handshake (#1874): a newer native-host reads our version to
432
+ // decide whether to supersede us. Answer over the same connection.
433
+ try {
434
+ socket.write(
435
+ `${JSON.stringify({ type: "manager_hello_ack", version: selfVersion, pid: process.pid })}\n`,
436
+ );
437
+ } catch {
438
+ /* client hung up mid-handshake — ignore */
439
+ }
279
440
  }
280
441
  // "status" is validated but currently a no-op sink.
281
442
  };
@@ -292,7 +453,7 @@ export default class Manager extends Command {
292
453
  const combined = (buffers.get(socket) ?? "") + data.toString("utf8");
293
454
  const parts = combined.split("\n");
294
455
  buffers.set(socket, parts.pop() ?? ""); // keep the incomplete trailing fragment
295
- for (const line of parts) handleFrame(line);
456
+ for (const line of parts) handleFrame(line, socket as { write(data: string): void });
296
457
  },
297
458
  close(socket: object): void {
298
459
  buffers.delete(socket);
@@ -331,6 +492,20 @@ export default class Manager extends Command {
331
492
  }
332
493
  console.error(`[xcsh manager] control socket listening at ${sockPath}`);
333
494
 
495
+ // Publish our liveness record (#1874) so a newer native-host can see which
496
+ // version owns the socket (and, if it must supersede us, find our pid).
497
+ writeManagerState(sockPath, { pid: process.pid, version: selfVersion, socket: sockPath, startedAt: Date.now() });
498
+
499
+ // Clean-signal handling: an operator SIGTERM (or upgrade/recycle) tears us
500
+ // down gracefully instead of orphaning workers + a stale socket/state file.
501
+ process.on("SIGTERM", () => gracefulShutdown("manual"));
502
+ process.on("SIGINT", () => gracefulShutdown("manual"));
503
+
504
+ // Zero-downtime handoff: re-adopt any bound workers a superseded manager left
505
+ // running BEFORE filling the pool (so their ports aren't mistaken for free).
506
+ // Awaited but bounded (~parallel 400ms); the socket already accepts connections.
507
+ await readoptWorkers();
508
+
334
509
  // Pre-warm the spare pool so provisions can adopt instead of cold-spawn.
335
510
  if (poolTarget > 0) maintainPool();
336
511
 
@@ -16,8 +16,12 @@
16
16
  import { homedir } from "node:os";
17
17
  import { join } from "node:path";
18
18
  import { Command } from "@f5-sales-demo/pi-utils/cli";
19
+ // Lean subpath (not the barrel) to keep the relay's cold start minimal.
20
+ import { VERSION } from "@f5-sales-demo/pi-utils/dirs";
19
21
  import { decodeNm, encodeNm } from "../browser/native-messaging";
22
+ import { readManagerState } from "../services/manager-state";
20
23
  import { reexecArgv } from "./manager";
24
+ import { shouldSupersede } from "./manager-core";
21
25
 
22
26
  /** Manager control socket — MUST match `commands/manager.ts` exactly. */
23
27
  const SOCKET_PATH = process.env.XCSH_MANAGER_SOCK ?? join(homedir(), ".xcsh", "manager.sock");
@@ -117,11 +121,24 @@ export default class ChromeHost extends Command {
117
121
  * Returns the connected socket, or undefined if unreachable after backoff.
118
122
  */
119
123
  private async ensureManager(socket: Parameters<typeof Bun.connect>[0]["socket"]): Promise<Sock | undefined> {
120
- try {
121
- dbg("connect: first attempt");
122
- return await Bun.connect({ unix: SOCKET_PATH, socket });
123
- } catch {
124
- dbg("connect: failedspawn detached manager");
124
+ // Version-aware supersede (#1874): if a manager already owns the socket, read
125
+ // its version. If THIS binary is newer (e.g. a `brew upgrade` happened but the
126
+ // old detached manager is still running), ask it to step down and take over —
127
+ // otherwise the extension stays pinned to the stale version forever. Same-or-
128
+ // newer running version just use it (never downgrade).
129
+ const runningVersion = await this.#managerVersion();
130
+ if (runningVersion !== null && !shouldSupersede(runningVersion, VERSION)) {
131
+ dbg(`manager live @ ${runningVersion} (ours ${VERSION}) — connecting`);
132
+ try {
133
+ return await Bun.connect({ unix: SOCKET_PATH, socket });
134
+ } catch {
135
+ dbg("live manager raced away before relay connect → spawn");
136
+ }
137
+ } else if (runningVersion !== null) {
138
+ dbg(`supersede: running ${runningVersion} < ours ${VERSION} → step down`);
139
+ await this.#stepDownRunningManager();
140
+ } else {
141
+ dbg("no manager reachable → spawn");
125
142
  }
126
143
 
127
144
  // Spawn the manager DETACHED and long-lived. It is NOT awaited/retained so
@@ -145,4 +162,92 @@ export default class ChromeHost extends Command {
145
162
  }
146
163
  return undefined;
147
164
  }
165
+
166
+ /** The running manager's advertised version via the `hello` handshake, or null
167
+ * if none is reachable. Uses a throwaway connection (separate from the relay). */
168
+ async #managerVersion(timeoutMs = 1500): Promise<string | null> {
169
+ const ack = await this.#managerRequest({ type: "hello" }, timeoutMs);
170
+ return typeof ack?.version === "string" ? ack.version : null;
171
+ }
172
+
173
+ /** Send one control frame and resolve the manager's first reply line (or null). */
174
+ #managerRequest(frame: unknown, timeoutMs: number): Promise<Record<string, unknown> | null> {
175
+ return new Promise(resolve => {
176
+ let buf = "";
177
+ let done = false;
178
+ const finish = (v: Record<string, unknown> | null) => {
179
+ if (done) return;
180
+ done = true;
181
+ resolve(v);
182
+ };
183
+ Bun.connect({
184
+ unix: SOCKET_PATH,
185
+ socket: {
186
+ open(c) {
187
+ c.write(`${JSON.stringify(frame)}\n`);
188
+ },
189
+ data(c, d) {
190
+ buf += d.toString("utf8");
191
+ const nl = buf.indexOf("\n");
192
+ if (nl < 0) return;
193
+ try {
194
+ finish(JSON.parse(buf.slice(0, nl)) as Record<string, unknown>);
195
+ } catch {
196
+ finish(null);
197
+ }
198
+ try {
199
+ c.end();
200
+ } catch {
201
+ /* already closing */
202
+ }
203
+ },
204
+ error() {
205
+ finish(null);
206
+ },
207
+ },
208
+ }).catch(() => finish(null));
209
+ setTimeout(() => finish(null), timeoutMs);
210
+ });
211
+ }
212
+
213
+ /** True if anything is accepting the control socket right now. */
214
+ async #managerReachable(): Promise<boolean> {
215
+ try {
216
+ const c = await Bun.connect({ unix: SOCKET_PATH, socket: { data() {} } });
217
+ c.end();
218
+ return true;
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+
224
+ /** Ask the running manager to step down, then wait (bounded) for the socket to
225
+ * free — escalating to SIGTERM on the recorded pid if it won't release. */
226
+ async #stepDownRunningManager(timeoutMs = 5000): Promise<void> {
227
+ try {
228
+ const c = await Bun.connect({ unix: SOCKET_PATH, socket: { data() {} } });
229
+ c.write(`${JSON.stringify({ type: "shutdown", reason: "superseded" })}\n`);
230
+ await Bun.sleep(50);
231
+ c.end();
232
+ } catch {
233
+ /* already gone */
234
+ }
235
+ const deadline = Date.now() + timeoutMs;
236
+ let escalated = false;
237
+ while (Date.now() < deadline) {
238
+ if (!(await this.#managerReachable())) return; // socket freed → success
239
+ if (!escalated && Date.now() > deadline - timeoutMs / 2) {
240
+ const st = readManagerState(SOCKET_PATH); // won't drain gracefully → SIGTERM the pid
241
+ if (st?.pid) {
242
+ try {
243
+ process.kill(st.pid);
244
+ } catch {
245
+ /* already gone */
246
+ }
247
+ }
248
+ escalated = true;
249
+ }
250
+ await Bun.sleep(100);
251
+ }
252
+ }
148
253
  }
@@ -73,6 +73,9 @@ export function sessionInfoForWorker(): {
73
73
  return { tenant, env, apiUrl, contextBound, sessionId };
74
74
  }
75
75
 
76
+ /** Hard ceiling for draining an in-flight chat turn on SIGTERM before teardown (#1874). */
77
+ const WORKER_DRAIN_TIMEOUT_MS = 10_000;
78
+
76
79
  /** Browser-automation tool set — identical scoping to `main.ts`'s extension path.
77
80
  * With scoped tools the ONLY way to create a resource is the form-driven workflow
78
81
  * runner, which is exactly what the human watching the browser wants. */
@@ -197,11 +200,24 @@ export default class Worker extends Command {
197
200
  logger.endTiming();
198
201
 
199
202
  let shuttingDown = false;
203
+ const teardown = () => {
204
+ chatHandler.dispose();
205
+ void bridge.close().finally(() => process.exit(0));
206
+ };
200
207
  const shutdown = () => {
201
208
  if (shuttingDown) return;
202
209
  shuttingDown = true;
203
- chatHandler.dispose();
204
- void bridge.close().finally(() => process.exit(0));
210
+ // Bounded drain (#1874): if a chat turn is in flight (e.g. the manager is
211
+ // recycling for an upgrade), let it finish before teardown instead of
212
+ // aborting the running agent turn — with a hard ceiling so a hung turn can
213
+ // never wedge shutdown. An idle worker tears down immediately.
214
+ if (!chatHandler.busy) return teardown();
215
+ const deadline = Date.now() + WORKER_DRAIN_TIMEOUT_MS;
216
+ const tick = () => {
217
+ if (!chatHandler.busy || Date.now() >= deadline) teardown();
218
+ else setTimeout(tick, 100);
219
+ };
220
+ tick();
205
221
  };
206
222
  process.on("SIGTERM", shutdown);
207
223
  process.on("SIGINT", shutdown);
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.58.3",
21
- "commit": "1e0e9e203ce3f95f2e4bb1069f4371d9b7529e91",
22
- "shortCommit": "1e0e9e2",
20
+ "version": "19.61.0",
21
+ "commit": "54faf1b4029f7a7a4251c5cc6776c789b3e1af5c",
22
+ "shortCommit": "54faf1b",
23
23
  "branch": "main",
24
- "tag": "v19.58.3",
25
- "commitDate": "2026-07-05T18:06:30Z",
26
- "buildDate": "2026-07-05T18:27:55.948Z",
24
+ "tag": "v19.61.0",
25
+ "commitDate": "2026-07-05T20:10:34Z",
26
+ "buildDate": "2026-07-05T20:32:34.362Z",
27
27
  "dirty": true,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5-sales-demo/xcsh",
30
30
  "repoSlug": "f5-sales-demo/xcsh",
31
- "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/1e0e9e203ce3f95f2e4bb1069f4371d9b7529e91",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.58.3"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/54faf1b4029f7a7a4251c5cc6776c789b3e1af5c",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.61.0"
33
33
  };
@@ -0,0 +1,48 @@
1
+ /** Filesystem side of the manager liveness record (`manager.json`), kept next to
2
+ * the control socket so a test's temp socket dir gets its own state file. The
3
+ * record is advisory/observability + the escalation target (pid) when a
4
+ * superseded manager won't release the socket; the control-socket `hello` answer
5
+ * is authoritative. Pure (de)serialization lives in commands/manager-core. */
6
+ import * as fs from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { type ManagerState, parseManagerState, serializeManagerState } from "../commands/manager-core";
9
+
10
+ /** `manager.json` alongside the given control socket. */
11
+ export function managerStatePathFor(sockPath: string): string {
12
+ return join(dirname(sockPath), "manager.json");
13
+ }
14
+
15
+ /** Atomically write the liveness record (temp + rename so readers never see a
16
+ * torn file). Best-effort: never throws — a failed write must not crash boot. */
17
+ export function writeManagerState(sockPath: string, state: ManagerState): void {
18
+ const p = managerStatePathFor(sockPath);
19
+ try {
20
+ // Restrictive modes: ~/.xcsh holds credentials (contexts/tokens), so the dir
21
+ // must be user-only (0700) if we're the one creating it, and the record
22
+ // user-only (0600) regardless of umask. rename() preserves the tmp's mode.
23
+ fs.mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
24
+ const tmp = `${p}.${process.pid}.tmp`;
25
+ fs.writeFileSync(tmp, serializeManagerState(state), { mode: 0o600 });
26
+ fs.renameSync(tmp, p);
27
+ } catch {
28
+ /* advisory only — the socket is the source of truth */
29
+ }
30
+ }
31
+
32
+ /** Read + validate the record, or null if absent/corrupt. */
33
+ export function readManagerState(sockPath: string): ManagerState | null {
34
+ try {
35
+ return parseManagerState(fs.readFileSync(managerStatePathFor(sockPath), "utf8"));
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /** Remove the record on graceful shutdown. Best-effort. */
42
+ export function removeManagerState(sockPath: string): void {
43
+ try {
44
+ fs.rmSync(managerStatePathFor(sockPath), { force: true });
45
+ } catch {
46
+ /* best effort */
47
+ }
48
+ }