@f5-sales-demo/xcsh 19.58.2 → 19.60.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.2",
4
+ "version": "19.60.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.2",
59
- "@f5-sales-demo/pi-agent-core": "19.58.2",
60
- "@f5-sales-demo/pi-ai": "19.58.2",
61
- "@f5-sales-demo/pi-natives": "19.58.2",
62
- "@f5-sales-demo/pi-resource-management": "19.58.2",
63
- "@f5-sales-demo/pi-tui": "19.58.2",
64
- "@f5-sales-demo/pi-utils": "19.58.2",
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",
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" });
@@ -56,10 +56,15 @@ export function nativeHostLaunchCommand(
56
56
  execPath: string = process.execPath,
57
57
  resolveXcshBin: () => string | null = defaultResolveXcshBin,
58
58
  ): string[] {
59
+ // Prefer a VERSION-STABLE launcher on PATH (e.g. the Homebrew symlink
60
+ // /opt/homebrew/bin/xcsh) for BOTH compiled and bun installs. This keeps the
61
+ // native-messaging wrapper working across `brew upgrade`s instead of pinning
62
+ // the install-time versioned path (…/Cellar/xcsh/<v>/bin/xcsh), which froze
63
+ // the extension on an old version until install-host was re-run (#1874).
64
+ const xcsh = resolveXcshBin();
65
+ if (xcsh) return [xcsh];
59
66
  const base = path.basename(execPath).toLowerCase();
60
67
  if (base.startsWith("bun")) {
61
- const xcsh = resolveXcshBin();
62
- if (xcsh) return [xcsh];
63
68
  const script = argv[1];
64
69
  if (script && (script.endsWith(".ts") || script.endsWith(".js") || script.endsWith(".mjs"))) {
65
70
  return [execPath, path.resolve(script)];
@@ -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). */
@@ -130,6 +134,17 @@ export default class Manager extends Command {
130
134
  const reg: Registry = new Map();
131
135
  const range = portCandidates();
132
136
 
137
+ // The version this manager advertises (hello ack + manager.json). Overridable
138
+ // via env ONLY so the supersede integration tests can stand up an "old" manager
139
+ // (production always reports the real binary VERSION).
140
+ const selfVersion = process.env.XCSH_MANAGER_VERSION || VERSION;
141
+
142
+ // Lifecycle gate (#1874): once we begin graceful shutdown we stop accepting
143
+ // provisions (the host retries the successor manager) and never double-run
144
+ // the teardown.
145
+ let accepting = true;
146
+ let shuttingDown = false;
147
+
133
148
  const poolTarget = Math.max(0, Math.trunc(Number(process.env.XCSH_WORKER_POOL_SIZE ?? "2")) || 0);
134
149
  interface SpareRec {
135
150
  proc: Bun.Subprocess;
@@ -215,6 +230,38 @@ export default class Manager extends Command {
215
230
  reg.delete(sessionId);
216
231
  };
217
232
 
233
+ const killPid = (pid: number): void => {
234
+ try {
235
+ process.kill(pid); // SIGTERM — spares exit at once; bound workers self-drain (worker.ts)
236
+ } catch {
237
+ /* already gone */
238
+ }
239
+ };
240
+
241
+ /** 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. */
246
+ const gracefulShutdown = (reason: string): void => {
247
+ if (shuttingDown) return;
248
+ shuttingDown = true;
249
+ accepting = false;
250
+ console.error(
251
+ `[xcsh manager] graceful shutdown (${reason}); reaping ${pool.length} spare(s) + ${reg.size} worker(s)`,
252
+ );
253
+ for (const s of pool.splice(0)) killPid(s.pid);
254
+ for (const w of reg.values()) killPid(w.pid);
255
+ reg.clear();
256
+ removeManagerState(sockPath);
257
+ try {
258
+ fs.rmSync(sockPath, { force: true });
259
+ } catch {
260
+ /* best effort — the OS drops the bound socket on exit anyway */
261
+ }
262
+ process.exit(0);
263
+ };
264
+
218
265
  const spawnWorker = (msg: { sessionId: string; tenant: string }): void => {
219
266
  // Registry-dedupe (pickPort) over only the ports free at the OS level.
220
267
  const port = pickPort(reg, range.filter(isPortFree));
@@ -257,7 +304,7 @@ export default class Manager extends Command {
257
304
  console.error(`[xcsh manager] provisioned ${msg.sessionId} (${msg.tenant}) → pid ${proc.pid} on port ${port}`);
258
305
  };
259
306
 
260
- const handleFrame = (line: string): void => {
307
+ const handleFrame = (line: string, socket: { write(data: string): void }): void => {
261
308
  const trimmed = line.trim();
262
309
  if (!trimmed) return;
263
310
  let raw: unknown;
@@ -269,6 +316,15 @@ export default class Manager extends Command {
269
316
  const msg = parseControlMsg(raw);
270
317
  if (!msg) return; // fail closed on unknown/invalid frames
271
318
  if (msg.type === "provision") {
319
+ // Draining: refuse new work so the host retries the successor manager.
320
+ if (!accepting) {
321
+ try {
322
+ socket.write(`${JSON.stringify({ type: "draining" })}\n`);
323
+ } catch {
324
+ /* client hung up */
325
+ }
326
+ return;
327
+ }
272
328
  if (needsProvision(reg, msg.sessionId)) {
273
329
  if (!adoptSpare(msg)) spawnWorker(msg); // adopt a warm spare, else cold-spawn (fallback)
274
330
  }
@@ -276,6 +332,19 @@ export default class Manager extends Command {
276
332
  if (w) w.lastSeen = Date.now(); // touch on every provision (keep-alive)
277
333
  } else if (msg.type === "release") {
278
334
  reap(msg.sessionId);
335
+ } else if (msg.type === "shutdown") {
336
+ // A newer native-host (supersede) or the updater asks us to step down.
337
+ gracefulShutdown(msg.reason);
338
+ } else if (msg.type === "hello") {
339
+ // Identity handshake (#1874): a newer native-host reads our version to
340
+ // decide whether to supersede us. Answer over the same connection.
341
+ try {
342
+ socket.write(
343
+ `${JSON.stringify({ type: "manager_hello_ack", version: selfVersion, pid: process.pid })}\n`,
344
+ );
345
+ } catch {
346
+ /* client hung up mid-handshake — ignore */
347
+ }
279
348
  }
280
349
  // "status" is validated but currently a no-op sink.
281
350
  };
@@ -292,7 +361,7 @@ export default class Manager extends Command {
292
361
  const combined = (buffers.get(socket) ?? "") + data.toString("utf8");
293
362
  const parts = combined.split("\n");
294
363
  buffers.set(socket, parts.pop() ?? ""); // keep the incomplete trailing fragment
295
- for (const line of parts) handleFrame(line);
364
+ for (const line of parts) handleFrame(line, socket as { write(data: string): void });
296
365
  },
297
366
  close(socket: object): void {
298
367
  buffers.delete(socket);
@@ -331,6 +400,15 @@ export default class Manager extends Command {
331
400
  }
332
401
  console.error(`[xcsh manager] control socket listening at ${sockPath}`);
333
402
 
403
+ // Publish our liveness record (#1874) so a newer native-host can see which
404
+ // version owns the socket (and, if it must supersede us, find our pid).
405
+ writeManagerState(sockPath, { pid: process.pid, version: selfVersion, socket: sockPath, startedAt: Date.now() });
406
+
407
+ // Clean-signal handling: an operator SIGTERM (or upgrade/recycle) tears us
408
+ // down gracefully instead of orphaning workers + a stale socket/state file.
409
+ process.on("SIGTERM", () => gracefulShutdown("manual"));
410
+ process.on("SIGINT", () => gracefulShutdown("manual"));
411
+
334
412
  // Pre-warm the spare pool so provisions can adopt instead of cold-spawn.
335
413
  if (poolTarget > 0) maintainPool();
336
414
 
@@ -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
  }
@@ -23,7 +23,7 @@ import { initializeWithSettings } from "../discovery";
23
23
  import { createAgentSession } from "../sdk";
24
24
  import { activateTenantContext } from "../services/session-context-binding";
25
25
  import { ContextService } from "../services/xcsh-context";
26
- import { sessionKeyFromUrl } from "../services/xcsh-env";
26
+ import { deriveTenantEnv } from "../services/xcsh-env";
27
27
 
28
28
  /** Mutable worker identity. Seeded from env at spawn (backward compat); replaced by a
29
29
  * late IPC bind on a pre-warmed spare. `null` fields fall back to env, then the "spare"
@@ -64,19 +64,18 @@ export function sessionInfoForWorker(): {
64
64
  /* ContextService not initialized — fall through to the tenant key; contextBound stays false. */
65
65
  }
66
66
  apiUrl = apiUrl ?? process.env.XCSH_API_URL ?? null;
67
- if (apiUrl) {
68
- const key = sessionKeyFromUrl(apiUrl);
69
- return { tenant: key?.tenant ?? null, env: key?.env ?? null, apiUrl, contextBound, sessionId };
70
- }
71
- // Contextless: advertise the tenant carried by the bind (or spawn env).
72
- const raw = boundIdentity?.tenantKey ?? process.env.XCSH_SESSION_TENANT;
73
- if (raw) {
74
- const [tenant, env] = raw.split("|");
75
- return { tenant: tenant || null, env: env || null, apiUrl: null, contextBound, sessionId };
76
- }
77
- return { tenant: null, env: null, apiUrl: null, contextBound, sessionId };
67
+ // Prefer the apiUrl-derived key (active context wins), but fall back to the
68
+ // tenant this worker was assigned (IPC bind or spawn env) so an apiUrl whose
69
+ // host we can't parse never blanks a KNOWN tenant which would make the
70
+ // extension drop the bridge and show "No xcsh running for this tenant" (#1872).
71
+ const tenantKey = boundIdentity?.tenantKey ?? process.env.XCSH_SESSION_TENANT ?? null;
72
+ const { tenant, env } = deriveTenantEnv(apiUrl, tenantKey);
73
+ return { tenant, env, apiUrl, contextBound, sessionId };
78
74
  }
79
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
+
80
79
  /** Browser-automation tool set — identical scoping to `main.ts`'s extension path.
81
80
  * With scoped tools the ONLY way to create a resource is the form-driven workflow
82
81
  * runner, which is exactly what the human watching the browser wants. */
@@ -201,11 +200,24 @@ export default class Worker extends Command {
201
200
  logger.endTiming();
202
201
 
203
202
  let shuttingDown = false;
203
+ const teardown = () => {
204
+ chatHandler.dispose();
205
+ void bridge.close().finally(() => process.exit(0));
206
+ };
204
207
  const shutdown = () => {
205
208
  if (shuttingDown) return;
206
209
  shuttingDown = true;
207
- chatHandler.dispose();
208
- 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();
209
221
  };
210
222
  process.on("SIGTERM", shutdown);
211
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.2",
21
- "commit": "c666636feed4f03e00897187bb494b2bed0f27e9",
22
- "shortCommit": "c666636",
20
+ "version": "19.60.0",
21
+ "commit": "37266bce39f1c2714a947ebfcbd9dbae46b312a0",
22
+ "shortCommit": "37266bc",
23
23
  "branch": "main",
24
- "tag": "v19.58.2",
25
- "commitDate": "2026-07-05T17:19:13Z",
26
- "buildDate": "2026-07-05T17:40:57.828Z",
24
+ "tag": "v19.60.0",
25
+ "commitDate": "2026-07-05T19:32:50Z",
26
+ "buildDate": "2026-07-05T20:00:48.862Z",
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/c666636feed4f03e00897187bb494b2bed0f27e9",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.58.2"
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"
33
33
  };
package/src/main.ts CHANGED
@@ -815,7 +815,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
815
815
  // tenant), so the panel can lock its session and route per tenant.
816
816
  {
817
817
  const { ContextService } = await import("./services/xcsh-context");
818
- const { sessionKeyFromUrl } = await import("./services/xcsh-env");
818
+ const { deriveTenantEnv } = await import("./services/xcsh-env");
819
819
  const readInfo = () => {
820
820
  let apiUrl: string | null = null;
821
821
  let contextBound = false;
@@ -828,15 +828,13 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
828
828
  }
829
829
  // Fall back to the env override when no context is active (env-only mode).
830
830
  apiUrl = apiUrl ?? process.env.XCSH_API_URL ?? null;
831
- const key = apiUrl ? sessionKeyFromUrl(apiUrl) : null;
831
+ // Prefer the apiUrl-derived key; fall back to the assigned tenant key so an
832
+ // unparseable apiUrl never blanks a known tenant (#1872). Same contract as
833
+ // the worker path (sessionInfoForWorker).
834
+ const tenantKey = process.env.XCSH_SESSION_TENANT ?? null;
835
+ const { tenant, env } = deriveTenantEnv(apiUrl, tenantKey);
832
836
  // Interactive path has no per-tab XCSH_SESSION_ID; workers echo one, this doesn't.
833
- return {
834
- tenant: key?.tenant ?? null,
835
- env: key?.env ?? null,
836
- apiUrl,
837
- contextBound,
838
- sessionId: null,
839
- };
837
+ return { tenant, env, apiUrl, contextBound, sessionId: null };
840
838
  };
841
839
  bridgeServer.setSessionInfo(readInfo);
842
840
  ContextService.onContextChange(() => bridgeServer?.broadcastTenantChanged());
@@ -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
+ }
@@ -128,6 +128,31 @@ export function sessionKeyFromUrl(url: string | undefined): SessionKey | null {
128
128
  return null;
129
129
  }
130
130
 
131
+ /**
132
+ * Resolve the `{tenant, env}` a worker advertises to the extension's `hello`
133
+ * handshake. Prefers the session key parsed from `apiUrl` (the live/active
134
+ * context is authoritative) but falls back to the worker's assigned tenant key
135
+ * (`"tenant|env"`, from `boundIdentity.tenantKey` / `XCSH_SESSION_TENANT`) when
136
+ * `apiUrl` is absent OR its host doesn't parse to a key.
137
+ *
138
+ * The fallback is load-bearing (#1872): the extension's `liveTenants` filter
139
+ * keeps a bridge only when BOTH `tenant` and `env` are set, so returning
140
+ * `{tenant:null, env:null}` for a worker that KNOWS its tenant (e.g. an
141
+ * activated context whose stored `apiUrl` isn't `<tenant>.console.ves.volterra.io`)
142
+ * silently drops the bridge and the panel shows "No xcsh running for this tenant".
143
+ */
144
+ export function deriveTenantEnv(
145
+ apiUrl: string | null,
146
+ tenantKey: string | null | undefined,
147
+ ): { tenant: string | null; env: string | null } {
148
+ const key = apiUrl ? sessionKeyFromUrl(apiUrl) : null;
149
+ const [assignedTenant, assignedEnv] = (tenantKey ?? "").split("|");
150
+ return {
151
+ tenant: key?.tenant ?? (assignedTenant || null),
152
+ env: key?.env ?? (assignedEnv || null),
153
+ };
154
+ }
155
+
131
156
  /**
132
157
  * Reduce an API URL to its origin (`https://host[:port]`) — the canonical stored
133
158
  * form for a context endpoint.