@f5-sales-demo/xcsh 19.60.0 → 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.60.0",
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.60.0",
59
- "@f5-sales-demo/pi-agent-core": "19.60.0",
60
- "@f5-sales-demo/pi-ai": "19.60.0",
61
- "@f5-sales-demo/pi-natives": "19.60.0",
62
- "@f5-sales-demo/pi-resource-management": "19.60.0",
63
- "@f5-sales-demo/pi-tui": "19.60.0",
64
- "@f5-sales-demo/pi-utils": "19.60.0",
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",
@@ -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
 
@@ -116,6 +132,18 @@ export async function runChromeCommand(action: ChromeAction, settings: Settings)
116
132
  const manifestPath = installNativeHost({ launchCommand, extensionIds: [EXTENSION_ID] });
117
133
  return `Native-messaging host manifest written to:\n ${manifestPath}`;
118
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
+ }
119
147
  // relaunch: self-consented rung 3 — force allowRelaunch regardless of the setting.
120
148
  const { mode } = await acquirePage({ settings, allowRelaunch: true });
121
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";
@@ -54,6 +54,66 @@ function isPortFree(port: number): boolean {
54
54
  }
55
55
  }
56
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
+
57
117
  /**
58
118
  * The argv (AFTER `process.execPath`) to re-run THIS binary in `mode`.
59
119
  *
@@ -195,6 +255,34 @@ export default class Manager extends Command {
195
255
  for (let i = 0; i < n; i++) spawnSpare();
196
256
  };
197
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
+
198
286
  /** Adopt a warm spare for a provision (bind over IPC). Returns false if none available. */
199
287
  const adoptSpare = (msg: { sessionId: string; tenant: string }): boolean => {
200
288
  const rec = pool.shift();
@@ -219,18 +307,8 @@ export default class Manager extends Command {
219
307
  return true;
220
308
  };
221
309
 
222
- const reap = (sessionId: string): void => {
223
- const w = reg.get(sessionId);
224
- if (!w) return;
225
- try {
226
- process.kill(w.pid);
227
- } catch {
228
- /* already gone */
229
- }
230
- reg.delete(sessionId);
231
- };
232
-
233
310
  const killPid = (pid: number): void => {
311
+ if (pid <= 0) return; // 0/negative = unknown (re-adopted worker) — NEVER signal (kill(0) hits the group)
234
312
  try {
235
313
  process.kill(pid); // SIGTERM — spares exit at once; bound workers self-drain (worker.ts)
236
314
  } catch {
@@ -238,21 +316,35 @@ export default class Manager extends Command {
238
316
  }
239
317
  };
240
318
 
319
+ const reap = (sessionId: string): void => {
320
+ const w = reg.get(sessionId);
321
+ if (!w) return;
322
+ killPid(w.pid);
323
+ reg.delete(sessionId);
324
+ };
325
+
241
326
  /** Graceful teardown (#1874): stop accepting, terminate the (session-less)
242
- * spare pool at once, SIGTERM bound workers so they drain their in-flight turn,
243
- * drop the socket + liveness record, and exit. Idempotent. Bounded by each
244
- * worker's own drain ceiling the manager frees the socket immediately so a
245
- * successor can bind without waiting on drains. */
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. */
246
334
  const gracefulShutdown = (reason: string): void => {
247
335
  if (shuttingDown) return;
248
336
  shuttingDown = true;
249
337
  accepting = false;
338
+ const handoff = reason === "superseded" || reason === "updated";
250
339
  console.error(
251
- `[xcsh manager] graceful shutdown (${reason}); reaping ${pool.length} spare(s) + ${reg.size} worker(s)`,
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)`),
252
342
  );
253
343
  for (const s of pool.splice(0)) killPid(s.pid);
254
- for (const w of reg.values()) killPid(w.pid);
255
- reg.clear();
344
+ if (!handoff) {
345
+ for (const w of reg.values()) killPid(w.pid);
346
+ reg.clear();
347
+ }
256
348
  removeManagerState(sockPath);
257
349
  try {
258
350
  fs.rmSync(sockPath, { force: true });
@@ -409,6 +501,11 @@ export default class Manager extends Command {
409
501
  process.on("SIGTERM", () => gracefulShutdown("manual"));
410
502
  process.on("SIGINT", () => gracefulShutdown("manual"));
411
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
+
412
509
  // Pre-warm the spare pool so provisions can adopt instead of cold-spawn.
413
510
  if (poolTarget > 0) maintainPool();
414
511
 
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.60.0",
21
- "commit": "37266bce39f1c2714a947ebfcbd9dbae46b312a0",
22
- "shortCommit": "37266bc",
20
+ "version": "19.61.0",
21
+ "commit": "54faf1b4029f7a7a4251c5cc6776c789b3e1af5c",
22
+ "shortCommit": "54faf1b",
23
23
  "branch": "main",
24
- "tag": "v19.60.0",
25
- "commitDate": "2026-07-05T19:32:50Z",
26
- "buildDate": "2026-07-05T20:00:48.862Z",
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/37266bce39f1c2714a947ebfcbd9dbae46b312a0",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.60.0"
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
  };