@f5-sales-demo/xcsh 19.60.0 → 19.61.1

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.1",
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",
@@ -35,6 +35,7 @@
35
35
  "check": "biome check . && bun run format-prompts -- --check && bun run check:types",
36
36
  "check:types": "bun run generate-build-info && bun run generate-extension-capabilities && bun run generate-api-spec-index && bun run generate-branding-index && bun run generate-terraform-index && tsgo -p tsconfig.json --noEmit",
37
37
  "lint": "biome lint .",
38
+ "check:bundle": "bun scripts/check-bundle.ts",
38
39
  "test": "bun run generate-build-info && bun run generate-extension-capabilities && bun run generate-api-spec-index && bun run generate-console-catalog && bun run generate-console-field-metadata && bun test --max-concurrency 4",
39
40
  "fix": "biome check --write --unsafe . && bun run format-prompts && bun run generate-docs-index && bun run generate-api-spec-index && bun run generate-build-info",
40
41
  "fmt": "biome format --write . && bun run format-prompts",
@@ -55,13 +56,13 @@
55
56
  "dependencies": {
56
57
  "@agentclientprotocol/sdk": "0.16.1",
57
58
  "@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",
59
+ "@f5-sales-demo/xcsh-stats": "19.61.1",
60
+ "@f5-sales-demo/pi-agent-core": "19.61.1",
61
+ "@f5-sales-demo/pi-ai": "19.61.1",
62
+ "@f5-sales-demo/pi-natives": "19.61.1",
63
+ "@f5-sales-demo/pi-resource-management": "19.61.1",
64
+ "@f5-sales-demo/pi-tui": "19.61.1",
65
+ "@f5-sales-demo/pi-utils": "19.61.1",
65
66
  "@sinclair/typebox": "^0.34",
66
67
  "@xterm/headless": "^6.0",
67
68
  "ajv": "^8.20",
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Version helpers for selecting the api-specs-enriched source.
3
+ *
4
+ * Kept in a dedicated module (no side effects) so the freshness logic is unit-testable
5
+ * without importing generate-api-spec-index.ts, which runs the generator on load.
6
+ */
7
+
8
+ /** Normalize a release tag (`v2.1.167`) or bare version (`2.1.167`) to a bare version. */
9
+ export function normalizeSpecsTag(tag: string): string {
10
+ return tag.trim().replace(/^v/, "");
11
+ }
12
+
13
+ /**
14
+ * Whether a local api-specs-enriched checkout matches the latest release tag.
15
+ * A stale checkout must NOT be used, or local/dev builds silently pin to old specs.
16
+ */
17
+ export function isLocalSpecsCurrent(localVersion: string | undefined, latestTag: string): boolean {
18
+ if (!localVersion) return false;
19
+ return normalizeSpecsTag(localVersion) === normalizeSpecsTag(latestTag);
20
+ }
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Bundle guard: ensure the CLI entrypoint still bundles.
4
+ *
5
+ * Reproduces the `bun build --compile` module-graph analysis in ~1s so a
6
+ * `require()` into a top-level-await subgraph (or other bundle-breaking graph
7
+ * change) fails fast in a required CI gate — not only in the slower, optional
8
+ * install-methods build. See PRs #1888 → #1892 for the regression this guards.
9
+ *
10
+ * Runs as a standalone `bun` process (not under `bun test`, whose bundler
11
+ * resolver cannot resolve some lazily-imported specifiers). Exits non-zero on
12
+ * any bundle error.
13
+ */
14
+ import * as path from "node:path";
15
+
16
+ const cliEntry = path.resolve(import.meta.dir, "../src/cli.ts");
17
+
18
+ const result = await Bun.build({
19
+ entrypoints: [cliEntry],
20
+ target: "bun",
21
+ define: { PI_COMPILED: "true" },
22
+ external: ["mupdf"],
23
+ throw: false,
24
+ });
25
+
26
+ if (!result.success) {
27
+ console.error("Bundle check FAILED — the CLI entrypoint does not bundle:\n");
28
+ for (const log of result.logs) console.error(String(log));
29
+ process.exit(1);
30
+ }
31
+
32
+ console.log("Bundle check passed — CLI entrypoint bundles cleanly.");
@@ -4,6 +4,7 @@ import * as fs from "node:fs";
4
4
  import * as os from "node:os";
5
5
  import * as path from "node:path";
6
6
  import { $ } from "bun";
7
+ import { isLocalSpecsCurrent } from "./api-specs-version";
7
8
 
8
9
  interface SpecPathOperation {
9
10
  operationId?: string;
@@ -183,15 +184,42 @@ async function downloadFromRelease(): Promise<string> {
183
184
  return extractDir;
184
185
  }
185
186
 
187
+ function readLocalSpecsVersion(specsDir: string): string | undefined {
188
+ try {
189
+ const index = JSON.parse(fs.readFileSync(path.join(specsDir, "index.json"), "utf-8")) as { version?: string };
190
+ return index.version;
191
+ } catch {
192
+ return undefined;
193
+ }
194
+ }
195
+
186
196
  async function findSpecsDir(): Promise<string> {
187
197
  const envDir = process.env.API_SPECS_DIR;
188
198
  if (envDir && fs.existsSync(envDir)) {
199
+ // Explicit override — the caller is responsible for its freshness.
189
200
  return envDir;
190
201
  }
191
202
 
192
203
  const localCheckout = path.resolve(import.meta.dir, "../../../../api-specs-enriched/docs/specifications/api");
193
204
  if (fs.existsSync(localCheckout)) {
194
- return localCheckout;
205
+ // Only build from the local checkout when it matches the latest release, so a
206
+ // stale checkout cannot silently pin the build to old specs. If GitHub is
207
+ // unreachable (offline dev), fall back to the local checkout with a warning.
208
+ try {
209
+ const latestTag = await resolveLatestTag();
210
+ const localVersion = readLocalSpecsVersion(localCheckout);
211
+ if (isLocalSpecsCurrent(localVersion, latestTag)) {
212
+ return localCheckout;
213
+ }
214
+ console.warn(
215
+ `Local api-specs-enriched checkout is stale (local ${localVersion ?? "unknown"} != latest ${latestTag}); building against the latest release instead.`,
216
+ );
217
+ } catch (err) {
218
+ console.warn(
219
+ `Could not verify the latest api-specs version (${err instanceof Error ? err.message : err}); using the local checkout.`,
220
+ );
221
+ return localCheckout;
222
+ }
195
223
  }
196
224
 
197
225
  return downloadFromRelease();
@@ -14,12 +14,16 @@ import {
14
14
  import { CONSOLE_ROUTES } from "./console-routes.generated";
15
15
  import type { BridgeServer } from "./extension-bridge";
16
16
  import { interpretPageState } from "./page-state-interpreter";
17
+ import { chatSpans } from "./ttft-spans";
17
18
 
18
19
  interface ActiveChat {
19
20
  id: string;
20
21
  seq: number;
21
22
  terminalSent: boolean;
22
23
  unsubscribe: () => void;
24
+ entryAt: number;
25
+ promptAt: number | null;
26
+ spanEmitted: boolean;
23
27
  }
24
28
 
25
29
  export class ChatHandler {
@@ -61,7 +65,15 @@ export class ChatHandler {
61
65
  this.#activeHistoryHint = req.history_hint;
62
66
  }
63
67
 
64
- const chat: ActiveChat = { id, seq: 0, terminalSent: false, unsubscribe: () => {} };
68
+ const chat: ActiveChat = {
69
+ id,
70
+ seq: 0,
71
+ terminalSent: false,
72
+ unsubscribe: () => {},
73
+ entryAt: Date.now(),
74
+ promptAt: null,
75
+ spanEmitted: false,
76
+ };
65
77
  this.#activeChats.set(id, chat);
66
78
 
67
79
  const unsubscribe = this.#session.subscribe((event: AgentSessionEvent) => {
@@ -72,6 +84,7 @@ export class ChatHandler {
72
84
  const prompt = composeChatPrompt(req.text, req.context, req.mode);
73
85
 
74
86
  try {
87
+ chat.promptAt = Date.now();
75
88
  await this.#session.prompt(prompt, { expandPromptTemplates: false, synthetic: false });
76
89
  } catch (err: unknown) {
77
90
  const message = err instanceof Error ? err.message : "unknown error";
@@ -121,6 +134,15 @@ export class ChatHandler {
121
134
  seq: chat.seq++,
122
135
  delta: ame.delta,
123
136
  } satisfies ChatDelta);
137
+ // TTFT Phase 2: first token out — emit the chat-segment spans once, keyed by
138
+ // the c- turn id. chat.promptAt is set (prompt() was awaited before any event);
139
+ // guard against re-emit on later deltas.
140
+ if (!chat.spanEmitted && chat.promptAt !== null) {
141
+ chat.spanEmitted = true;
142
+ for (const s of chatSpans(chat.id, chat.entryAt, chat.promptAt, Date.now())) {
143
+ this.#server.send(s);
144
+ }
145
+ }
124
146
  } else if (ame.type === "error") {
125
147
  const errorMsg = ame.error?.errorMessage ?? "LLM error";
126
148
  this.#sendTerminal(chat, { type: "chat_error", id: chat.id, error: errorMsg });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Pure TTFT span-frame builders for Phase 2. The worker emits these over the bridge
3
+ * WS; the extension records them (proc:'xcsh') and summarizeTtft stitches them into
4
+ * the init->first-token timeline. Chrome-free, so they unit-test in isolation.
5
+ */
6
+ export interface SpanFrame {
7
+ type: "span";
8
+ stage: string;
9
+ ms: number;
10
+ id?: string;
11
+ sid?: string;
12
+ cold?: boolean;
13
+ }
14
+
15
+ const clamp = (ms: number): number => (ms > 0 ? ms : 0);
16
+
17
+ /**
18
+ * Decompose one chat turn's route->first-token into two disjoint spans that sum to
19
+ * (firstDeltaAt - entryAt): provider_ttft (prompt issued -> first token) and
20
+ * chat_handler (the xcsh routing/compose/emit overhead = entry -> prompt issued).
21
+ */
22
+ export function chatSpans(id: string, entryAt: number, promptAt: number, firstDeltaAt: number): SpanFrame[] {
23
+ const providerMs = clamp(firstDeltaAt - promptAt);
24
+ const handlerMs = clamp(firstDeltaAt - entryAt - providerMs);
25
+ return [
26
+ { type: "span", stage: "provider_ttft", ms: providerMs, id },
27
+ { type: "span", stage: "chat_handler", ms: handlerMs, id },
28
+ ];
29
+ }
30
+
31
+ /** Build the per-session cold-start spans, tagged with the session id + authoritative cold. */
32
+ export function coldStartSpans(
33
+ sid: string,
34
+ cold: boolean,
35
+ managerProvisionMs: number,
36
+ workerBootMs: number,
37
+ ): SpanFrame[] {
38
+ return [
39
+ { type: "span", stage: "manager_provision", ms: clamp(managerProvisionMs), sid, cold },
40
+ { type: "span", stage: "worker_boot", ms: clamp(workerBootMs), sid, cold },
41
+ ];
42
+ }
@@ -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,12 +255,46 @@ 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
- const adoptSpare = (msg: { sessionId: string; tenant: string }): boolean => {
287
+ const adoptSpare = (msg: { sessionId: string; tenant: string }, managerProvisionMs: number): boolean => {
200
288
  const rec = pool.shift();
201
289
  if (!rec) return false;
202
290
  // `send` exists because the spare was spawned with an `ipc` handler.
203
- (rec.proc as { send(m: unknown): void }).send({ type: "bind", sessionId: msg.sessionId, tenant: msg.tenant });
291
+ (rec.proc as { send(m: unknown): void }).send({
292
+ type: "bind",
293
+ sessionId: msg.sessionId,
294
+ tenant: msg.tenant,
295
+ provisionMs: managerProvisionMs, // TTFT Phase 2: relayed manager_provision (warm)
296
+ cold: false, // authoritative: warm adopt
297
+ });
204
298
  reg.set(msg.sessionId, {
205
299
  sessionId: msg.sessionId,
206
300
  tenant: msg.tenant,
@@ -219,18 +313,8 @@ export default class Manager extends Command {
219
313
  return true;
220
314
  };
221
315
 
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
316
  const killPid = (pid: number): void => {
317
+ if (pid <= 0) return; // 0/negative = unknown (re-adopted worker) — NEVER signal (kill(0) hits the group)
234
318
  try {
235
319
  process.kill(pid); // SIGTERM — spares exit at once; bound workers self-drain (worker.ts)
236
320
  } catch {
@@ -238,21 +322,35 @@ export default class Manager extends Command {
238
322
  }
239
323
  };
240
324
 
325
+ const reap = (sessionId: string): void => {
326
+ const w = reg.get(sessionId);
327
+ if (!w) return;
328
+ killPid(w.pid);
329
+ reg.delete(sessionId);
330
+ };
331
+
241
332
  /** 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. */
333
+ * spare pool at once, drop the socket + liveness record, and exit. Idempotent.
334
+ *
335
+ * Bound workers depend on the reason: on a HANDOFF (superseded/updated a
336
+ * successor manager is about to bind and re-adopt them), we LEAVE them running
337
+ * so tab sessions survive the swap (zero-downtime, Task 6). Otherwise (manual
338
+ * operator SIGTERM — no successor) we SIGTERM them so they drain + exit rather
339
+ * than leak. The manager frees the socket immediately either way. */
246
340
  const gracefulShutdown = (reason: string): void => {
247
341
  if (shuttingDown) return;
248
342
  shuttingDown = true;
249
343
  accepting = false;
344
+ const handoff = reason === "superseded" || reason === "updated";
250
345
  console.error(
251
- `[xcsh manager] graceful shutdown (${reason}); reaping ${pool.length} spare(s) + ${reg.size} worker(s)`,
346
+ `[xcsh manager] graceful shutdown (${reason}); reaping ${pool.length} spare(s)` +
347
+ (handoff ? `, leaving ${reg.size} worker(s) for re-adoption` : ` + ${reg.size} worker(s)`),
252
348
  );
253
349
  for (const s of pool.splice(0)) killPid(s.pid);
254
- for (const w of reg.values()) killPid(w.pid);
255
- reg.clear();
350
+ if (!handoff) {
351
+ for (const w of reg.values()) killPid(w.pid);
352
+ reg.clear();
353
+ }
256
354
  removeManagerState(sockPath);
257
355
  try {
258
356
  fs.rmSync(sockPath, { force: true });
@@ -262,7 +360,7 @@ export default class Manager extends Command {
262
360
  process.exit(0);
263
361
  };
264
362
 
265
- const spawnWorker = (msg: { sessionId: string; tenant: string }): void => {
363
+ const spawnWorker = (msg: { sessionId: string; tenant: string }, managerProvisionMs: number): void => {
266
364
  // Registry-dedupe (pickPort) over only the ports free at the OS level.
267
365
  const port = pickPort(reg, range.filter(isPortFree));
268
366
  if (port === null) {
@@ -282,6 +380,12 @@ export default class Manager extends Command {
282
380
  // spawned tenant key is authoritative (undefined removes the var in Bun).
283
381
  XCSH_API_URL: undefined,
284
382
  XCSH_API_TOKEN: undefined,
383
+ // TTFT Phase 2: relay cold-start timing to the worker (only it has a WS to
384
+ // the extension). Wall-clock spawn instant + manager_provision ms; COLD=1
385
+ // marks the authoritative cold spawn.
386
+ XCSH_TTFT_SPAWN_AT: String(Date.now()),
387
+ XCSH_TTFT_PROVISION_MS: String(managerProvisionMs),
388
+ XCSH_TTFT_COLD: "1",
285
389
  },
286
390
  stdout: "ignore",
287
391
  stderr: "ignore",
@@ -325,8 +429,10 @@ export default class Manager extends Command {
325
429
  }
326
430
  return;
327
431
  }
432
+ const provisionReceivedAt = Date.now(); // TTFT Phase 2: start of manager_provision
328
433
  if (needsProvision(reg, msg.sessionId)) {
329
- if (!adoptSpare(msg)) spawnWorker(msg); // adopt a warm spare, else cold-spawn (fallback)
434
+ const managerProvisionMs = Date.now() - provisionReceivedAt;
435
+ if (!adoptSpare(msg, managerProvisionMs)) spawnWorker(msg, managerProvisionMs); // adopt a warm spare, else cold-spawn (fallback)
330
436
  }
331
437
  const w = reg.get(msg.sessionId);
332
438
  if (w) w.lastSeen = Date.now(); // touch on every provision (keep-alive)
@@ -409,6 +515,11 @@ export default class Manager extends Command {
409
515
  process.on("SIGTERM", () => gracefulShutdown("manual"));
410
516
  process.on("SIGINT", () => gracefulShutdown("manual"));
411
517
 
518
+ // Zero-downtime handoff: re-adopt any bound workers a superseded manager left
519
+ // running BEFORE filling the pool (so their ports aren't mistaken for free).
520
+ // Awaited but bounded (~parallel 400ms); the socket already accepts connections.
521
+ await readoptWorkers();
522
+
412
523
  // Pre-warm the spare pool so provisions can adopt instead of cold-spawn.
413
524
  if (poolTarget > 0) maintainPool();
414
525
 
@@ -19,6 +19,7 @@ import { ChatHandler } from "../browser/chat-handler";
19
19
  import { startBridgeServer } from "../browser/extension-bridge";
20
20
  import { createExtensionBridgeTools, EXTENSION_AGENT_TOOL_NAMES } from "../browser/extension-bridge-tools";
21
21
  import { setSharedBridgeServer } from "../browser/provider";
22
+ import { coldStartSpans, type SpanFrame } from "../browser/ttft-spans";
22
23
  import { initializeWithSettings } from "../discovery";
23
24
  import { createAgentSession } from "../sdk";
24
25
  import { activateTenantContext } from "../services/session-context-binding";
@@ -109,6 +110,12 @@ export default class Worker extends Command {
109
110
  // Spans only accumulate while recording; nothing prints unless PI_TIMING is set,
110
111
  // and each logger.time() returns its wrapped value unchanged — so a normal
111
112
  // `xcsh worker` run is behaviorally identical to before.
113
+ // TTFT Phase 2: the manager stamps XCSH_TTFT_SPAWN_AT at Bun.spawn for a cold
114
+ // spawn; worker_boot(cold) = bridge-listening instant - spawn instant (captures
115
+ // fork + runtime init, which logger.startTiming below misses).
116
+ const spawnAtEnv = Number(process.env.XCSH_TTFT_SPAWN_AT);
117
+ const coldSpawn = process.env.XCSH_TTFT_COLD === "1";
118
+ const managerProvisionMsEnv = Number(process.env.XCSH_TTFT_PROVISION_MS);
112
119
  logger.startTiming();
113
120
 
114
121
  process.env.XCSH_BROWSER_PROVIDER = "extension";
@@ -142,15 +149,50 @@ export default class Worker extends Command {
142
149
  bridge.setSessionInfo(sessionInfoForWorker);
143
150
  ContextService.onContextChange(() => bridge.broadcastTenantChanged());
144
151
 
152
+ // TTFT Phase 2: buffer the per-session cold-start spans and flush them once a
153
+ // client is actually connected. bridge.send() silently no-ops with no client, so
154
+ // the flush is gated on `clientConnected` — a cold-boot flush before the extension
155
+ // connects would otherwise burn the once-latch and lose the spans. Cold path is
156
+ // populated now (env); warm path by the {bind} handler below.
157
+ let coldStartBuffer: SpanFrame[] = [];
158
+ let coldStartSent = false;
159
+ let clientConnected = false;
160
+ const flushColdStart = (): void => {
161
+ if (coldStartSent || !clientConnected || coldStartBuffer.length === 0) return;
162
+ for (const s of coldStartBuffer) bridge.send(s);
163
+ coldStartSent = true;
164
+ };
165
+ // onConnected (raw WS open) is the deliberate flush trigger: no hello hook is exposed
166
+ // today, and the bridge's origin check already gates opens to the extension.
167
+ bridge.onConnected(() => {
168
+ clientConnected = true;
169
+ flushColdStart();
170
+ });
171
+
172
+ if (coldSpawn && process.env.XCSH_SESSION_ID && Number.isFinite(spawnAtEnv)) {
173
+ const workerBootMs = Date.now() - spawnAtEnv; // spawn -> bridge listening
174
+ const mgrMs = Number.isFinite(managerProvisionMsEnv) ? managerProvisionMsEnv : 0;
175
+ coldStartBuffer = coldStartSpans(process.env.XCSH_SESSION_ID, true, mgrMs, workerBootMs);
176
+ // No flush here: at cold boot the extension has not connected yet; onConnected flushes.
177
+ }
178
+
145
179
  // Pre-warm pool late-bind: the manager (our parent) sends {bind} over Bun IPC to adopt
146
180
  // this spare for a tab. Apply the identity, activate the tenant's context LIVE, then
147
181
  // re-announce via broadcastTenantChanged (now carrying the real sessionId).
148
182
  process.on("message", (raw: unknown) => {
149
- const m = raw as { type?: unknown; sessionId?: unknown; tenant?: unknown };
183
+ const m = raw as {
184
+ type?: unknown;
185
+ sessionId?: unknown;
186
+ tenant?: unknown;
187
+ provisionMs?: unknown;
188
+ cold?: unknown;
189
+ };
150
190
  if (m?.type !== "bind" || typeof m.sessionId !== "string" || typeof m.tenant !== "string") return;
151
191
  // Capture the narrowed values — TS widens `m.*` back to `unknown` inside the async closure.
152
192
  const sessionId = m.sessionId;
153
193
  const tenant = m.tenant;
194
+ const bindAt = Date.now(); // TTFT Phase 2: start of warm worker_boot (bind -> bound)
195
+ const relayedProvisionMs = typeof m.provisionMs === "number" ? m.provisionMs : 0;
154
196
  setWorkerIdentity(sessionId, tenant);
155
197
  void (async () => {
156
198
  try {
@@ -162,6 +204,9 @@ export default class Worker extends Command {
162
204
  // Ack adoption complete (identity applied + context activated + re-announced).
163
205
  // The manager ignores it today; the bench uses it to time adoption latency.
164
206
  process.send?.({ type: "bound", sessionId });
207
+ // TTFT Phase 2: warm adopt cold-start spans (worker_boot = bind -> bound).
208
+ coldStartBuffer = coldStartSpans(sessionId, false, relayedProvisionMs, Date.now() - bindAt);
209
+ flushColdStart();
165
210
  })();
166
211
  });
167
212
 
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as nodePath from "node:path";
3
3
  import type { AgentToolResult } from "@f5-sales-demo/pi-agent-core";
4
+ import { StringEnum } from "@f5-sales-demo/pi-ai";
4
5
  import {
5
6
  ChunkAnchorStyle,
6
7
  ChunkEditOp,
@@ -12,7 +13,6 @@ import {
12
13
  type EditOperation as NativeEditOperation,
13
14
  } from "@f5-sales-demo/pi-natives";
14
15
  import { $envpos } from "@f5-sales-demo/pi-utils";
15
- import { StringEnum } from "@f5-sales-demo/xcsh";
16
16
  import { type Static, Type } from "@sinclair/typebox";
17
17
  import type { BunFile } from "bun";
18
18
  import { LRUCache } from "lru-cache";