@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.
|
|
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.
|
|
59
|
-
"@f5-sales-demo/pi-agent-core": "19.
|
|
60
|
-
"@f5-sales-demo/pi-ai": "19.
|
|
61
|
-
"@f5-sales-demo/pi-natives": "19.
|
|
62
|
-
"@f5-sales-demo/pi-resource-management": "19.
|
|
63
|
-
"@f5-sales-demo/pi-tui": "19.
|
|
64
|
-
"@f5-sales-demo/pi-utils": "19.
|
|
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",
|
package/src/cli/chrome-cli.ts
CHANGED
|
@@ -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.`;
|
package/src/cli/update-cli.ts
CHANGED
|
@@ -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);
|
package/src/commands/chrome.ts
CHANGED
|
@@ -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";
|
package/src/commands/manager.ts
CHANGED
|
@@ -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,
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
* successor
|
|
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) +
|
|
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
|
-
|
|
255
|
-
|
|
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.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "19.61.0",
|
|
21
|
+
"commit": "54faf1b4029f7a7a4251c5cc6776c789b3e1af5c",
|
|
22
|
+
"shortCommit": "54faf1b",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.
|
|
25
|
-
"commitDate": "2026-07-
|
|
26
|
-
"buildDate": "2026-07-05T20:
|
|
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/
|
|
32
|
-
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.
|
|
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
|
};
|