@f5-sales-demo/xcsh 19.61.4 → 19.62.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.61.4",
4
+ "version": "19.62.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",
@@ -56,13 +56,13 @@
56
56
  "dependencies": {
57
57
  "@agentclientprotocol/sdk": "0.16.1",
58
58
  "@mozilla/readability": "^0.6",
59
- "@f5-sales-demo/xcsh-stats": "19.61.4",
60
- "@f5-sales-demo/pi-agent-core": "19.61.4",
61
- "@f5-sales-demo/pi-ai": "19.61.4",
62
- "@f5-sales-demo/pi-natives": "19.61.4",
63
- "@f5-sales-demo/pi-resource-management": "19.61.4",
64
- "@f5-sales-demo/pi-tui": "19.61.4",
65
- "@f5-sales-demo/pi-utils": "19.61.4",
59
+ "@f5-sales-demo/xcsh-stats": "19.62.0",
60
+ "@f5-sales-demo/pi-agent-core": "19.62.0",
61
+ "@f5-sales-demo/pi-ai": "19.62.0",
62
+ "@f5-sales-demo/pi-natives": "19.62.0",
63
+ "@f5-sales-demo/pi-resource-management": "19.62.0",
64
+ "@f5-sales-demo/pi-tui": "19.62.0",
65
+ "@f5-sales-demo/pi-utils": "19.62.0",
66
66
  "@sinclair/typebox": "^0.34",
67
67
  "@xterm/headless": "^6.0",
68
68
  "ajv": "^8.20",
@@ -26,7 +26,7 @@ export interface ExtensionCapabilities {
26
26
 
27
27
  export const EXTENSION_CAPABILITIES: ExtensionCapabilities = {
28
28
  "version": "0.1.0",
29
- "contractVersion": "1.5.0",
29
+ "contractVersion": "1.6.0",
30
30
  "multiPortDiscovery": true,
31
31
  "protocol": "tool_request/result",
32
32
  "tools": [
@@ -625,6 +625,26 @@ export const EXTENSION_CAPABILITIES: ExtensionCapabilities = {
625
625
  },
626
626
  "flags": {}
627
627
  },
628
+ {
629
+ "name": "diag_activation",
630
+ "summary": "Diagnostic: per-gate tab-activation readiness timings (bridge/worker/page), cold/warm.",
631
+ "category": "read",
632
+ "params": {
633
+ "type": "object",
634
+ "properties": {}
635
+ },
636
+ "flags": {}
637
+ },
638
+ {
639
+ "name": "diag_ttft",
640
+ "summary": "Diagnostic: init→first-token timeline (per-stage ms, total, dominant, cold/warm).",
641
+ "category": "read",
642
+ "params": {
643
+ "type": "object",
644
+ "properties": {}
645
+ },
646
+ "flags": {}
647
+ },
628
648
  {
629
649
  "name": "capture_login_flow",
630
650
  "summary": "Diagnostic: captured login redirect chain annotated with tenant/env (Phase 0b).",
@@ -825,6 +845,6 @@ export const EXTENSION_CAPABILITIES: ExtensionCapabilities = {
825
845
  }
826
846
  };
827
847
 
828
- export const EXTENSION_CONTRACT_VERSION = "1.5.0";
848
+ export const EXTENSION_CONTRACT_VERSION = "1.6.0";
829
849
 
830
- export const EXTENSION_TOOL_NAMES: readonly string[] = ["ping","capabilities","reload","debug_exec","detach","set_bridge_port","navigate","login","scroll_to","resize_window","tabs_list","tabs_create","tabs_close","click","click_element","click_xy","type_text","form_input","key_press","select_option","label_select","file_upload","read_ax","get_page_text","query_dom","find","wait_for","assert_text","screenshot","read_console","read_network","diag_suspension","diag_bridges","capture_login_flow","wait_for_api_response","get_page_context","javascript_tool","browser_batch","set_explain_mode","annotate"];
850
+ export const EXTENSION_TOOL_NAMES: readonly string[] = ["ping","capabilities","reload","debug_exec","detach","set_bridge_port","navigate","login","scroll_to","resize_window","tabs_list","tabs_create","tabs_close","click","click_element","click_xy","type_text","form_input","key_press","select_option","label_select","file_upload","read_ax","get_page_text","query_dom","find","wait_for","assert_text","screenshot","read_console","read_network","diag_suspension","diag_bridges","diag_activation","diag_ttft","capture_login_flow","wait_for_api_response","get_page_context","javascript_tool","browser_batch","set_explain_mode","annotate"];
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": "0.1.0",
3
- "contractVersion": "1.5.0",
3
+ "contractVersion": "1.6.0",
4
4
  "multiPortDiscovery": true,
5
5
  "protocol": "tool_request/result",
6
6
  "tools": [
@@ -550,6 +550,26 @@
550
550
  },
551
551
  "flags": {}
552
552
  },
553
+ {
554
+ "name": "diag_activation",
555
+ "summary": "Diagnostic: per-gate tab-activation readiness timings (bridge/worker/page), cold/warm.",
556
+ "category": "read",
557
+ "params": {
558
+ "type": "object",
559
+ "properties": {}
560
+ },
561
+ "flags": {}
562
+ },
563
+ {
564
+ "name": "diag_ttft",
565
+ "summary": "Diagnostic: init→first-token timeline (per-stage ms, total, dominant, cold/warm).",
566
+ "category": "read",
567
+ "params": {
568
+ "type": "object",
569
+ "properties": {}
570
+ },
571
+ "flags": {}
572
+ },
553
573
  {
554
574
  "name": "capture_login_flow",
555
575
  "summary": "Diagnostic: captured login redirect chain annotated with tenant/env (Phase 0b).",
@@ -1,5 +1,5 @@
1
1
  {
2
- "contractVersion": "1.5.0",
2
+ "contractVersion": "1.6.0",
3
3
  "schemas": {
4
4
  "chat_request": {
5
5
  "type": "object",
@@ -210,6 +210,19 @@
210
210
  },
211
211
  "error": {
212
212
  "type": "string"
213
+ },
214
+ "reason": {
215
+ "anyOf": [
216
+ { "const": "bridge-disconnected", "type": "string" },
217
+ { "const": "bridge-unresponsive", "type": "string" },
218
+ { "const": "no-worker", "type": "string" },
219
+ { "const": "session-busy", "type": "string" },
220
+ { "const": "session-disposed", "type": "string" },
221
+ { "const": "token-expired", "type": "string" },
222
+ { "const": "token-expiring", "type": "string" },
223
+ { "const": "provider-4xx", "type": "string" },
224
+ { "const": "provider-5xx", "type": "string" }
225
+ ]
213
226
  }
214
227
  }
215
228
  },
@@ -433,7 +446,8 @@
433
446
  "chat_error": {
434
447
  "type": "chat_error",
435
448
  "id": "c-1111",
436
- "error": "HTTP 403 forbidden"
449
+ "error": "HTTP 403 forbidden",
450
+ "reason": "provider-4xx"
437
451
  },
438
452
  "chat_tool_notice": {
439
453
  "type": "chat_tool_notice",
@@ -4,6 +4,7 @@ import {
4
4
  type ChatDelta,
5
5
  type ChatDone,
6
6
  type ChatError,
7
+ type ChatErrorReason,
7
8
  type ChatReference,
8
9
  type ChatRequest,
9
10
  type InteractionMode,
@@ -31,12 +32,19 @@ export class ChatHandler {
31
32
  #session: AgentSession;
32
33
  #activeChats = new Map<string, ActiveChat>();
33
34
  #activeHistoryHint: string | undefined;
35
+ #onTurnStart: (() => void) | undefined;
34
36
 
35
37
  constructor(server: BridgeServer, session: AgentSession) {
36
38
  this.#server = server;
37
39
  this.#session = session;
38
40
  }
39
41
 
42
+ /** Register a callback fired when a chat turn is accepted (used by the worker's
43
+ * manager keepalive to refresh lastSeen at turn start). */
44
+ onTurnStart(cb: () => void): void {
45
+ this.#onTurnStart = cb;
46
+ }
47
+
40
48
  attach(): void {
41
49
  this.#server.onMessage(msg => {
42
50
  if (isChatRequest(msg)) this.#handleChatRequest(msg as unknown as ChatRequest);
@@ -45,7 +53,12 @@ export class ChatHandler {
45
53
 
46
54
  this.#server.onDisconnected(() => {
47
55
  for (const chat of this.#activeChats.values()) {
48
- this.#sendTerminal(chat, { type: "chat_error", id: chat.id, error: "bridge disconnected" });
56
+ this.#sendTerminal(chat, {
57
+ type: "chat_error",
58
+ id: chat.id,
59
+ error: "bridge disconnected",
60
+ reason: "bridge-disconnected",
61
+ });
49
62
  chat.unsubscribe();
50
63
  }
51
64
  this.#activeChats.clear();
@@ -56,7 +69,12 @@ export class ChatHandler {
56
69
  const { id } = req;
57
70
 
58
71
  if (this.#session.isStreaming || this.#activeChats.size > 0) {
59
- this.#server.send({ type: "chat_error", id, error: "session busy" } satisfies ChatError);
72
+ this.#server.send({
73
+ type: "chat_error",
74
+ id,
75
+ error: "session busy",
76
+ reason: "session-busy",
77
+ } satisfies ChatError);
60
78
  return;
61
79
  }
62
80
 
@@ -75,6 +93,7 @@ export class ChatHandler {
75
93
  spanEmitted: false,
76
94
  };
77
95
  this.#activeChats.set(id, chat);
96
+ this.#onTurnStart?.();
78
97
 
79
98
  const unsubscribe = this.#session.subscribe((event: AgentSessionEvent) => {
80
99
  this.#handleSessionEvent(chat, event);
@@ -88,7 +107,7 @@ export class ChatHandler {
88
107
  await this.#session.prompt(prompt, { expandPromptTemplates: false, synthetic: false });
89
108
  } catch (err: unknown) {
90
109
  const message = err instanceof Error ? err.message : "unknown error";
91
- this.#sendTerminal(chat, { type: "chat_error", id, error: message });
110
+ this.#sendTerminal(chat, { type: "chat_error", id, error: message, reason: classifyChatErrorReason(message) });
92
111
  } finally {
93
112
  if (!chat.terminalSent) {
94
113
  this.#sendTerminal(chat, { type: "chat_done", id });
@@ -145,15 +164,22 @@ export class ChatHandler {
145
164
  }
146
165
  } else if (ame.type === "error") {
147
166
  const errorMsg = ame.error?.errorMessage ?? "LLM error";
148
- this.#sendTerminal(chat, { type: "chat_error", id: chat.id, error: errorMsg });
167
+ this.#sendTerminal(chat, {
168
+ type: "chat_error",
169
+ id: chat.id,
170
+ error: errorMsg,
171
+ reason: classifyChatErrorReason(errorMsg),
172
+ });
149
173
  }
150
174
  } else if (event.type === "message_end" && event.message.role === "assistant") {
151
175
  const msg = event.message as AssistantMessage;
152
176
  if (msg.stopReason === "error") {
177
+ const errorMsg = msg.errorMessage ?? "assistant error";
153
178
  this.#sendTerminal(chat, {
154
179
  type: "chat_error",
155
180
  id: chat.id,
156
- error: msg.errorMessage ?? "assistant error",
181
+ error: errorMsg,
182
+ reason: classifyChatErrorReason(errorMsg),
157
183
  });
158
184
  } else {
159
185
  const references = extractReferences(msg);
@@ -186,13 +212,41 @@ export class ChatHandler {
186
212
 
187
213
  dispose(): void {
188
214
  for (const chat of this.#activeChats.values()) {
189
- this.#sendTerminal(chat, { type: "chat_error", id: chat.id, error: "session disposed" });
215
+ this.#sendTerminal(chat, {
216
+ type: "chat_error",
217
+ id: chat.id,
218
+ error: "session disposed",
219
+ reason: "session-disposed",
220
+ });
190
221
  chat.unsubscribe();
191
222
  }
192
223
  this.#activeChats.clear();
193
224
  }
194
225
  }
195
226
 
227
+ /** Best-effort classification of an upstream/provider error message into a
228
+ * ChatErrorReason so the panel can pick a distinct, actionable message. Returns
229
+ * undefined for an unclassified error (the panel then shows the raw error text).
230
+ * Token checks run before the generic 4xx check (a 401 is more useful as an
231
+ * expired-token hint than a bare client error). */
232
+ export function classifyChatErrorReason(message: string): ChatErrorReason | undefined {
233
+ const m = message.toLowerCase();
234
+ if (/\btoken\b[^.]*\b(expir|invalid)|\b(expir|invalid)[^.]*\btoken\b|aws sso|\/context (create|validate)/.test(m)) {
235
+ return "token-expired";
236
+ }
237
+ if (
238
+ /\b5\d\d\b|internal server error|bad gateway|service unavailable|gateway timeout|econnreset|etimedout|socket hang up|network error/.test(
239
+ m,
240
+ )
241
+ ) {
242
+ return "provider-5xx";
243
+ }
244
+ if (/\b4\d\d\b|forbidden|unauthorized|invalid model|bad request|not found|too many requests|rate limit/.test(m)) {
245
+ return "provider-4xx";
246
+ }
247
+ return undefined;
248
+ }
249
+
196
250
  /**
197
251
  * Chrome-extension self-awareness prompt. Injected when xcsh is serving a browser
198
252
  * chat (not the CLI TUI). Tells the LLM it's in a Chrome side panel alongside the
@@ -80,10 +80,30 @@ export interface ChatDone {
80
80
  references?: ChatReference[];
81
81
  }
82
82
 
83
+ /** Machine-readable cause of a terminal chat_error, so the panel can render a
84
+ * distinct, actionable message (and decide whether to auto-recover) instead of a
85
+ * generic failure. Additive/optional on the wire (contract 1.6.0); an omitted
86
+ * reason means an unclassified error (legacy behavior — show the error text).
87
+ * Shared vocabulary with the extension (keep both lists identical). */
88
+ export const CHAT_ERROR_REASONS = [
89
+ "bridge-disconnected", // the worker's bridge closed mid-turn
90
+ "bridge-unresponsive", // the socket looked open but the worker never answered
91
+ "no-worker", // no worker is running for this tab
92
+ "session-busy", // a turn is already in flight for this session
93
+ "session-disposed", // the worker session was torn down
94
+ "token-expired", // F5 XC API token expired
95
+ "token-expiring", // F5 XC API token is about to expire
96
+ "provider-4xx", // upstream provider rejected the request (client error)
97
+ "provider-5xx", // upstream provider failed (server error) — retryable
98
+ ] as const;
99
+
100
+ export type ChatErrorReason = (typeof CHAT_ERROR_REASONS)[number];
101
+
83
102
  export interface ChatError {
84
103
  type: "chat_error";
85
104
  id: string;
86
105
  error: string;
106
+ reason?: ChatErrorReason;
87
107
  }
88
108
 
89
109
  // ---------------------------------------------------------------------------
@@ -17,7 +17,9 @@ const SHUTDOWN_REASONS = new Set<string>(["superseded", "updated", "manual"]);
17
17
  export type ControlMsg =
18
18
  | { type: "provision"; sessionId: string; tenant: string }
19
19
  | { type: "release"; sessionId: string }
20
- | { type: "status" }
20
+ // An sid-less status is the legacy no-op sink; an sid-carrying status is a
21
+ // keepalive from an actively-chatting worker (see keepaliveFrame/touchLastSeen).
22
+ | { type: "status"; sessionId?: string }
21
23
  | { type: "hello" }
22
24
  | { type: "shutdown"; reason: ShutdownReason };
23
25
 
@@ -42,7 +44,8 @@ function isNonEmpty(v: unknown): v is string {
42
44
  export function parseControlMsg(raw: unknown): ControlMsg | null {
43
45
  if (!raw || typeof raw !== "object") return null;
44
46
  const m = raw as Record<string, unknown>;
45
- if (m.type === "status") return { type: "status" };
47
+ if (m.type === "status")
48
+ return isNonEmpty(m.sessionId) ? { type: "status", sessionId: m.sessionId } : { type: "status" };
46
49
  if (m.type === "provision" && isNonEmpty(m.sessionId) && isTenant(m.tenant))
47
50
  return { type: "provision", sessionId: m.sessionId, tenant: m.tenant };
48
51
  if (m.type === "release" && isNonEmpty(m.sessionId)) return { type: "release", sessionId: m.sessionId };
@@ -115,6 +118,27 @@ export function staleKeys(reg: Registry, now: number, idleMs: number): string[]
115
118
  return out;
116
119
  }
117
120
 
121
+ /** Refresh a worker's `lastSeen` from a keepalive/status frame; true iff a known
122
+ * session was touched. The manager never sees chat traffic (it flows worker↔
123
+ * bridge↔extension), so an actively-used session would otherwise be idle-reaped
124
+ * mid-conversation. An actively-chatting worker emits `keepaliveFrame` to drive
125
+ * this, keeping its session out of `staleKeys`. */
126
+ export function touchLastSeen(reg: Registry, sessionId: string | undefined, now: number): boolean {
127
+ if (!sessionId) return false;
128
+ const w = reg.get(sessionId);
129
+ if (!w) return false;
130
+ w.lastSeen = now;
131
+ return true;
132
+ }
133
+
134
+ /** The NDJSON keepalive an actively-chatting worker writes to the manager control
135
+ * socket to refresh its `lastSeen` (consumed by `touchLastSeen`). Null for the
136
+ * unbound `spare` sentinel or an empty id — a spare has no session to keep alive. */
137
+ export function keepaliveFrame(sessionId: string): string | null {
138
+ if (!sessionId || sessionId === "spare") return null;
139
+ return `${JSON.stringify({ type: "status", sessionId })}\n`;
140
+ }
141
+
118
142
  /** How many new spares to spawn now to reach `target`, without exceeding the port
119
143
  * budget: spares + active workers must fit the discovery range. Never negative. */
120
144
  export function sparesToSpawn(
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Worker→manager keepalive client.
3
+ *
4
+ * The manager idle-reaps a worker whose `lastSeen` is older than `IDLE_MS`, and
5
+ * `lastSeen` is refreshed ONLY when the manager receives a control frame. Chat
6
+ * traffic flows worker↔bridge↔extension and never reaches the manager, so an
7
+ * actively-used-then-briefly-idle session used to be reaped mid-conversation
8
+ * (the "Turn aborted." after a break). This client emits a `status{sessionId}`
9
+ * keepalive over the manager's UNIX control socket while a turn is in flight and
10
+ * at each turn start, so the manager's sweep leaves an in-use session alive.
11
+ *
12
+ * The socket lifecycle (connect/reconnect) is injected as a `Connector` so the
13
+ * scheduling/emit logic is unit-testable without a real socket; the worker wires
14
+ * a `Bun.connect` transport. Best-effort throughout: if the manager socket is
15
+ * down (e.g. mid-supersede) emits are dropped and the next emit reconnects — so
16
+ * after a manager handoff the keepalive re-targets the successor manager, which
17
+ * has already re-adopted this worker.
18
+ */
19
+ import { keepaliveFrame } from "./manager-core";
20
+
21
+ /** A live connection to the manager control socket. */
22
+ export interface KeepaliveTransport {
23
+ write(data: string): void;
24
+ close(): void;
25
+ }
26
+
27
+ /** Opens a transport; `onClose` MUST be invoked when the connection drops so the
28
+ * keepalive knows to reconnect on its next emit. Returns null if it cannot open. */
29
+ export type Connector = (onClose: () => void) => Promise<KeepaliveTransport | null>;
30
+
31
+ export interface ManagerKeepaliveDeps {
32
+ connect: Connector;
33
+ /** Current worker session id (may transition from "spare" to a real id). */
34
+ sessionId: () => string;
35
+ /** True while a chat turn is in flight. */
36
+ busy: () => boolean;
37
+ }
38
+
39
+ export class ManagerKeepalive {
40
+ #deps: ManagerKeepaliveDeps;
41
+ #transport: KeepaliveTransport | null = null;
42
+ #connecting = false;
43
+ #pending = false; // a keepalive is due once a connection is (re)established
44
+ #stopped = false;
45
+
46
+ constructor(deps: ManagerKeepaliveDeps) {
47
+ this.#deps = deps;
48
+ }
49
+
50
+ /** Periodic tick (driven by the worker's interval): emit iff a turn is busy. */
51
+ tick(): void {
52
+ if (this.#deps.busy()) this.#emit();
53
+ }
54
+
55
+ /** One-shot emit at the start of each turn — refreshes lastSeen immediately so
56
+ * a session with long think-time between turns is never reaped between them. */
57
+ turnStart(): void {
58
+ this.#emit();
59
+ }
60
+
61
+ /** Stop emitting and close any open transport. */
62
+ stop(): void {
63
+ this.#stopped = true;
64
+ this.#pending = false;
65
+ this.#transport?.close();
66
+ this.#transport = null;
67
+ }
68
+
69
+ #emit(): void {
70
+ if (this.#stopped) return;
71
+ const frame = keepaliveFrame(this.#deps.sessionId());
72
+ if (!frame) return; // spare / unbound → nothing to keep alive (also skips connecting)
73
+ if (this.#transport) {
74
+ try {
75
+ this.#transport.write(frame);
76
+ } catch {
77
+ this.#transport = null; // dead socket → drop and reconnect on next emit
78
+ this.#pending = true;
79
+ void this.#ensureConnected();
80
+ }
81
+ return;
82
+ }
83
+ this.#pending = true;
84
+ void this.#ensureConnected();
85
+ }
86
+
87
+ async #ensureConnected(): Promise<void> {
88
+ if (this.#stopped || this.#transport || this.#connecting) return;
89
+ this.#connecting = true;
90
+ let t: KeepaliveTransport | null = null;
91
+ try {
92
+ t = await this.#deps.connect(() => {
93
+ this.#transport = null; // socket dropped → next emit reconnects
94
+ });
95
+ } catch {
96
+ t = null;
97
+ }
98
+ this.#connecting = false;
99
+ if (!t) return; // manager unreachable — best-effort, a later emit retries
100
+ if (this.#stopped) {
101
+ t.close();
102
+ return;
103
+ }
104
+ this.#transport = t;
105
+ if (this.#pending) {
106
+ this.#pending = false;
107
+ const frame = keepaliveFrame(this.#deps.sessionId());
108
+ if (frame) {
109
+ try {
110
+ this.#transport.write(frame);
111
+ } catch {
112
+ this.#transport = null;
113
+ }
114
+ }
115
+ }
116
+ }
117
+ }
@@ -11,6 +11,8 @@
11
11
  * on sessionId, so two tabs of the SAME tenant get two workers.
12
12
  * {"type":"release","sessionId":"tab-7"} → kill + forget that session's worker
13
13
  * {"type":"status"} → accepted; no-op sink
14
+ * {"type":"status","sessionId":"tab-7"} → keepalive: refresh that worker's
15
+ * lastSeen so an actively-chatting session is not idle-reaped
14
16
  *
15
17
  * All registry/port/idempotency/idle policy is the pure `manager-core`; this file
16
18
  * is the thin I/O shell (socket, spawn, kill, timer) around it. A background sweep
@@ -25,7 +27,15 @@ import { Command } from "@f5-sales-demo/pi-utils/cli";
25
27
  import { VERSION } from "@f5-sales-demo/pi-utils/dirs";
26
28
  import { portCandidates } from "../browser/extension-bridge";
27
29
  import { removeManagerState, writeManagerState } from "../services/manager-state";
28
- import { needsProvision, parseControlMsg, pickPort, type Registry, sparesToSpawn, staleKeys } from "./manager-core";
30
+ import {
31
+ needsProvision,
32
+ parseControlMsg,
33
+ pickPort,
34
+ type Registry,
35
+ sparesToSpawn,
36
+ staleKeys,
37
+ touchLastSeen,
38
+ } from "./manager-core";
29
39
 
30
40
  /** Reap a worker idle longer than this (ms). */
31
41
  const IDLE_MS = 20 * 60_000;
@@ -434,8 +444,13 @@ export default class Manager extends Command {
434
444
  const managerProvisionMs = Date.now() - provisionReceivedAt;
435
445
  if (!adoptSpare(msg, managerProvisionMs)) spawnWorker(msg, managerProvisionMs); // adopt a warm spare, else cold-spawn (fallback)
436
446
  }
437
- const w = reg.get(msg.sessionId);
438
- if (w) w.lastSeen = Date.now(); // touch on every provision (keep-alive)
447
+ touchLastSeen(reg, msg.sessionId, Date.now()); // touch on every provision (keep-alive)
448
+ } else if (msg.type === "status") {
449
+ // Keepalive from an actively-chatting worker (#idle-reap): refresh
450
+ // lastSeen so the idle sweep never reaps a session that is in use.
451
+ // Chat traffic never reaches the manager, so this is the only signal
452
+ // that an otherwise-quiet-to-the-manager worker is still working.
453
+ touchLastSeen(reg, msg.sessionId, Date.now());
439
454
  } else if (msg.type === "release") {
440
455
  reap(msg.sessionId);
441
456
  } else if (msg.type === "shutdown") {
@@ -452,7 +467,6 @@ export default class Manager extends Command {
452
467
  /* client hung up mid-handshake — ignore */
453
468
  }
454
469
  }
455
- // "status" is validated but currently a no-op sink.
456
470
  };
457
471
 
458
472
  // Per-connection NDJSON buffer: a control frame can be split across two TCP
@@ -13,6 +13,8 @@
13
13
  * `XCSH_SESSION_TENANT` so it advertises its assigned tenant even before a context
14
14
  * is bound. This lets the extension panel lock onto the right tenant immediately.
15
15
  */
16
+ import { homedir } from "node:os";
17
+ import { join } from "node:path";
16
18
  import { getProjectDir, getXCSHConfigDir, logger } from "@f5-sales-demo/pi-utils";
17
19
  import { Command } from "@f5-sales-demo/pi-utils/cli";
18
20
  import { ChatHandler } from "../browser/chat-handler";
@@ -25,6 +27,7 @@ import { createAgentSession } from "../sdk";
25
27
  import { activateTenantContext } from "../services/session-context-binding";
26
28
  import { ContextService } from "../services/xcsh-context";
27
29
  import { deriveTenantEnv } from "../services/xcsh-env";
30
+ import { type KeepaliveTransport, ManagerKeepalive } from "./manager-keepalive";
28
31
 
29
32
  /** Mutable worker identity. Seeded from env at spawn (backward compat); replaced by a
30
33
  * late IPC bind on a pre-warmed spare. `null` fields fall back to env, then the "spare"
@@ -77,6 +80,16 @@ export function sessionInfoForWorker(): {
77
80
  /** Hard ceiling for draining an in-flight chat turn on SIGTERM before teardown (#1874). */
78
81
  const WORKER_DRAIN_TIMEOUT_MS = 10_000;
79
82
 
83
+ /** How often the worker pings the manager while a turn is in flight, refreshing its
84
+ * lastSeen so an actively-chatting session is never idle-reaped. Comfortably under
85
+ * the manager's IDLE_MS (20 min); a turn also pings once at its start. */
86
+ const KEEPALIVE_MS = 60_000;
87
+
88
+ /** The manager control socket (same derivation as native-host / chrome-cli). */
89
+ function managerSockPath(): string {
90
+ return process.env.XCSH_MANAGER_SOCK ?? join(homedir(), ".xcsh", "manager.sock");
91
+ }
92
+
80
93
  /** Browser-automation tool set — identical scoping to `main.ts`'s extension path.
81
94
  * With scoped tools the ONLY way to create a resource is the form-driven workflow
82
95
  * runner, which is exactly what the human watching the browser wants. */
@@ -274,6 +287,38 @@ export default class Worker extends Command {
274
287
  const chatHandler = new ChatHandler(bridge, session);
275
288
  chatHandler.attach();
276
289
 
290
+ // Manager keepalive: while a turn is in flight (and at each turn start), ping
291
+ // the manager control socket so it refreshes this worker's lastSeen and its
292
+ // idle sweep does not reap an actively-used session mid-conversation. Chat
293
+ // traffic never reaches the manager, so this is the only liveness signal.
294
+ // Best-effort + self-reconnecting: a dropped socket (e.g. manager supersede)
295
+ // is re-opened on the next emit, re-targeting the successor manager.
296
+ const keepalive = new ManagerKeepalive({
297
+ sessionId: () => sessionInfoForWorker().sessionId ?? "spare",
298
+ busy: () => chatHandler.busy,
299
+ connect: async onClose => {
300
+ try {
301
+ const sock = await Bun.connect({
302
+ unix: managerSockPath(),
303
+ socket: { data() {}, close: () => onClose(), error: () => onClose() },
304
+ });
305
+ const transport: KeepaliveTransport = {
306
+ write: data => {
307
+ sock.write(data);
308
+ },
309
+ close: () => {
310
+ sock.end();
311
+ },
312
+ };
313
+ return transport;
314
+ } catch {
315
+ return null; // manager not reachable — a later emit retries
316
+ }
317
+ },
318
+ });
319
+ chatHandler.onTurnStart(() => keepalive.turnStart());
320
+ const keepaliveTimer = setInterval(() => keepalive.tick(), KEEPALIVE_MS);
321
+
277
322
  // session-ready. Emit the per-tab boot breakdown when requested (parity with
278
323
  // main.ts:1002-1009). PI_TIMING=x prints then exits — used by bench/extension-session.ts
279
324
  // to measure total worker cold-start; otherwise this is a no-op.
@@ -287,6 +332,8 @@ export default class Worker extends Command {
287
332
 
288
333
  let shuttingDown = false;
289
334
  const teardown = () => {
335
+ clearInterval(keepaliveTimer);
336
+ keepalive.stop();
290
337
  chatHandler.dispose();
291
338
  void bridge.close().finally(() => process.exit(0));
292
339
  };
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.61.4",
21
- "commit": "8697bc5fee06350b861c8c8bf7bbac1f00f85408",
22
- "shortCommit": "8697bc5",
20
+ "version": "19.62.0",
21
+ "commit": "002bc2b7d7e14a95e282f940730cbdc347197635",
22
+ "shortCommit": "002bc2b",
23
23
  "branch": "main",
24
- "tag": "v19.61.4",
25
- "commitDate": "2026-07-07T16:14:37Z",
26
- "buildDate": "2026-07-07T16:40:48.327Z",
24
+ "tag": "v19.62.0",
25
+ "commitDate": "2026-07-08T02:22:10Z",
26
+ "buildDate": "2026-07-08T02:45:44.169Z",
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/8697bc5fee06350b861c8c8bf7bbac1f00f85408",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.61.4"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/002bc2b7d7e14a95e282f940730cbdc347197635",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.62.0"
33
33
  };