@f5-sales-demo/xcsh 21.2.0 → 21.4.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": "21.2.0",
4
+ "version": "21.4.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",
@@ -61,13 +61,13 @@
61
61
  },
62
62
  "dependencies": {
63
63
  "@agentclientprotocol/sdk": "1.3.0",
64
- "@f5-sales-demo/pi-agent-core": "21.2.0",
65
- "@f5-sales-demo/pi-ai": "21.2.0",
66
- "@f5-sales-demo/pi-natives": "21.2.0",
67
- "@f5-sales-demo/pi-resource-management": "21.2.0",
68
- "@f5-sales-demo/pi-tui": "21.2.0",
69
- "@f5-sales-demo/pi-utils": "21.2.0",
70
- "@f5-sales-demo/xcsh-stats": "21.2.0",
64
+ "@f5-sales-demo/pi-agent-core": "21.4.0",
65
+ "@f5-sales-demo/pi-ai": "21.4.0",
66
+ "@f5-sales-demo/pi-natives": "21.4.0",
67
+ "@f5-sales-demo/pi-resource-management": "21.4.0",
68
+ "@f5-sales-demo/pi-tui": "21.4.0",
69
+ "@f5-sales-demo/pi-utils": "21.4.0",
70
+ "@f5-sales-demo/xcsh-stats": "21.4.0",
71
71
  "@mozilla/readability": "^0.6",
72
72
  "@sinclair/typebox": "^0.34",
73
73
  "@xterm/headless": "^6.0",
@@ -1,20 +1,17 @@
1
1
  /**
2
- * Headless chat bridge — the no-TUI extension-bridge host used by `xcsh office
3
- * serve` so one command yields a working task pane (pane files + a live chat/
4
- * host-tool bridge). It mirrors the proven `xcsh worker` bootstrap
5
- * (`commands/worker.ts`) MINUS the fleet concerns (manager keepalive, pre-warm
6
- * IPC bind, TTFT spans): set the browser-provider env, init Settings + Context,
7
- * quiet startup, provision the wss cert, start the bridge, publish session info,
8
- * create ONE agent session scoped to the browser tools, and attach the
9
- * `ChatHandler`. Office document tools (Excel/Word/PPT) are advertised by the
10
- * pane at runtime over the bridge (`set_host_tools`), so they need no scoping here.
2
+ * Shared no-TUI extension-bridge bootstrap for `xcsh office serve` and `xcsh
3
+ * worker`: initialize the browser-provider environment, Settings, Context, and
4
+ * discovery; resolve TLS; bind and publish the bridge; create one profile-scoped
5
+ * agent session; and attach its ChatHandler. Worker fleet behavior stays in
6
+ * `commands/worker.ts` and is connected through the lifecycle hooks below.
7
+ * Office document tools are advertised by the pane at runtime over the bridge.
11
8
  *
12
9
  * The heavy / socket / network calls are injected (defaulting to the real ones)
13
10
  * so the wiring is unit-testable without opening real listeners or a session.
14
11
  *
15
12
  * NOT browser-safe (node/bun): runs inside the full xcsh binary, never the pane.
16
13
  */
17
- import { getProjectDir, getXCSHConfigDir } from "@f5-sales-demo/pi-utils";
14
+ import { getProjectDir, getXCSHConfigDir, logger } from "@f5-sales-demo/pi-utils";
18
15
  import { parseModelString } from "../config/model-resolver";
19
16
  import { DEFAULT_MODEL_ROLE } from "../config/settings-schema";
20
17
  import { createAgentSession } from "../sdk";
@@ -24,8 +21,13 @@ import { SessionManager } from "../session/session-manager";
24
21
  import { resolveBridgeTls } from "./bridge-cert";
25
22
  import { ChatHandler } from "./chat-handler";
26
23
  import { isPickPath, type PathPicked } from "./chat-protocol";
27
- import { type BridgeServer, OFFICE_PORT_RANGE, startBridgeServer } from "./extension-bridge";
28
- import { OFFICE_TOOL_NAMES } from "./extension-bridge-tools";
24
+ import { type BridgeServer, type BridgeSessionInfo, OFFICE_PORT_RANGE, startBridgeServer } from "./extension-bridge";
25
+ import {
26
+ BROWSER_TOOL_NAMES,
27
+ createExtensionBridgeTools,
28
+ EXTENSION_AGENT_TOOL_NAMES,
29
+ OFFICE_TOOL_NAMES,
30
+ } from "./extension-bridge-tools";
29
31
  import { pickPathNative } from "./native-picker";
30
32
  import { setSharedBridgeServer } from "./provider";
31
33
 
@@ -33,9 +35,34 @@ import { setSharedBridgeServer } from "./provider";
33
35
  * closes the bridge (both ws + wss listeners). */
34
36
  export interface HeadlessChatBridge {
35
37
  bridge: BridgeServer;
38
+ handler: ChatHandler;
36
39
  dispose: () => Promise<void>;
37
40
  }
38
41
 
42
+ type Session = Awaited<ReturnType<typeof createAgentSession>>["session"];
43
+
44
+ export interface HeadlessBridgeLifecycleContext {
45
+ bridge: BridgeServer;
46
+ cwd: string;
47
+ }
48
+
49
+ export interface HeadlessBridgeSessionContext extends HeadlessBridgeLifecycleContext {
50
+ session: Session;
51
+ }
52
+
53
+ export type HeadlessBridgeOptions =
54
+ | {
55
+ kind?: "office";
56
+ afterBridgeBind?: (context: HeadlessBridgeLifecycleContext) => void | Promise<void>;
57
+ afterSessionCreate?: (context: HeadlessBridgeSessionContext) => void | Promise<void>;
58
+ }
59
+ | {
60
+ kind: "worker";
61
+ sessionInfo: () => BridgeSessionInfo;
62
+ afterBridgeBind?: (context: HeadlessBridgeLifecycleContext) => void | Promise<void>;
63
+ afterSessionCreate?: (context: HeadlessBridgeSessionContext) => void | Promise<void>;
64
+ };
65
+
39
66
  /**
40
67
  * Tenant identity for the `hello` handshake, contextless-friendly: the active
41
68
  * `/context` wins (its apiUrl + name), otherwise fall back to the `XCSH_API_URL`/
@@ -113,20 +140,25 @@ const defaultDeps: HeadlessBridgeDeps = {
113
140
  * can connect and chat immediately (no warm-up race). A `configure`-less pane
114
141
  * chats over xcsh's already-configured provider.
115
142
  */
116
- export async function startHeadlessChatBridge(deps: HeadlessBridgeDeps = defaultDeps): Promise<HeadlessChatBridge> {
143
+ export async function startHeadlessChatBridge(
144
+ deps: HeadlessBridgeDeps = defaultDeps,
145
+ options: HeadlessBridgeOptions = { kind: "office" },
146
+ ): Promise<HeadlessChatBridge> {
117
147
  const { cwd } = await deps.initEnv();
148
+ const kind = options.kind ?? "office";
118
149
 
119
150
  // Provision the wss cert before binding (warm boot = on-disk cache hit);
120
151
  // `undefined` (offline) → the bridge starts ws-only.
121
152
  const tls = await deps.resolveBridgeTls();
122
- // Office-serve binds the DEDICATED office range (disjoint from the Chrome worker
123
- // range) so the two can never collide on a port.
124
- const bridge = await deps.startBridgeServer(undefined, {
125
- serveKind: "office",
126
- sessionInfo: sessionInfoForOfficeServe,
127
- ...(tls ? { tls } : {}),
128
- range: OFFICE_PORT_RANGE,
129
- });
153
+ const start = () =>
154
+ deps.startBridgeServer(undefined, {
155
+ serveKind: kind === "worker" ? "browser" : "office",
156
+ sessionInfo: options.kind === "worker" ? options.sessionInfo : sessionInfoForOfficeServe,
157
+ ...(tls ? { tls } : {}),
158
+ ...(kind === "office" ? { range: OFFICE_PORT_RANGE } : {}),
159
+ });
160
+ const bridge = kind === "worker" ? await logger.time("session:bridgeListen", start) : await start();
161
+
130
162
  // Reuse this bridge for any in-process selectProvider() (no conflicting second bridge).
131
163
  deps.setSharedBridgeServer(bridge);
132
164
  // Re-announce the tenant when the active context changes (best-effort).
@@ -136,72 +168,72 @@ export async function startHeadlessChatBridge(deps: HeadlessBridgeDeps = default
136
168
  /* ContextService not initialized (tests) — the tenant is static. */
137
169
  }
138
170
 
139
- // Everything past the bind can throw (createAgentSession on a misconfigured
140
- // provider, etc.). If it does, close the already-bound bridge and clear the
141
- // shared-bridge global before rethrowing — otherwise the ws/wss listeners leak
142
- // (keeping the event loop alive so Ctrl+C can't exit) and a later in-process
143
- // selectProvider() reuses a dead bridge. The caller (startOfficeServe) treats
144
- // the rethrow as a non-fatal "pane only" fallback.
171
+ let chatHandler: ChatHandler | undefined;
145
172
  try {
146
- const officeDefault = parseModelString(DEFAULT_MODEL_ROLE);
147
- if (!officeDefault) {
148
- throw new Error("Invalid baked Office default model selector");
173
+ await options.afterBridgeBind?.({ bridge, cwd });
174
+
175
+ let session: Session;
176
+ if (kind === "worker") {
177
+ const create = () =>
178
+ deps.createAgentSession({
179
+ cwd,
180
+ hasUI: false,
181
+ toolNames: [...new Set([...BROWSER_TOOL_NAMES, ...EXTENSION_AGENT_TOOL_NAMES])],
182
+ customTools: createExtensionBridgeTools(bridge),
183
+ enableMCP: false,
184
+ enableLsp: false,
185
+ disableExtensionDiscovery: true,
186
+ ...(process.env.XCSH_BENCH_EXTENSION
187
+ ? { additionalExtensionPaths: [process.env.XCSH_BENCH_EXTENSION] }
188
+ : {}),
189
+ });
190
+ ({ session } = await logger.time("session:createAgentSession", create));
191
+ } else {
192
+ const officeDefault = parseModelString(DEFAULT_MODEL_ROLE);
193
+ if (!officeDefault) {
194
+ throw new Error("Invalid baked Office default model selector");
195
+ }
196
+ ({ session } = await deps.createAgentSession({
197
+ cwd,
198
+ hasUI: false,
199
+ modelPattern: DEFAULT_MODEL_ROLE,
200
+ thinkingLevel: officeDefault.thinkingLevel,
201
+ sessionManager: SessionManager.inMemory(cwd),
202
+ toolNames: [...OFFICE_TOOL_NAMES],
203
+ customTools: [],
204
+ enableMCP: false,
205
+ enableLsp: false,
206
+ disableExtensionDiscovery: true,
207
+ bundledExtensions: ["sandbox-guard"],
208
+ }));
149
209
  }
150
- // Create ONE headless Office session with the full CLI-parity builtin set
151
- // (OFFICE_TOOL_NAMES: bash/read/write/edit/grep/inspect_image/… — NO browser tools, which
152
- // would be hallucinated in a document task pane). The document's own tools
153
- // (Excel/Word/PowerPoint) arrive at runtime via set_host_tools.
154
- const { session } = await deps.createAgentSession({
155
- cwd,
156
- hasUI: false,
157
- // Office must open on the production GPT profile regardless of a stale
158
- // global/default role or resumable session. A saved pane model is applied
159
- // afterward by its explicit configure frame and remains authoritative.
160
- modelPattern: DEFAULT_MODEL_ROLE,
161
- thinkingLevel: officeDefault.thinkingLevel,
162
- // Office conversations can contain private workbook and working-directory
163
- // data. Keep the entire headless session ephemeral instead of inheriting
164
- // createAgentSession's file-backed default.
165
- sessionManager: SessionManager.inMemory(cwd),
166
- toolNames: [...OFFICE_TOOL_NAMES],
167
- customTools: [],
168
- // Headless: no MCP/LSP/extension discovery — lean, no network/blocking prompts.
169
- enableMCP: false,
170
- enableLsp: false,
171
- disableExtensionDiscovery: true,
172
- // …but DO load the bundled filesystem sandbox: the pane runs full CLI-parity
173
- // tools (bash/read/write), so it needs the CLI's safety net confining file
174
- // tools + the shell's cwd to the launch directory subtree (sandbox.enabled
175
- // defaults true). Without this, a discovery-disabled session ran ungated.
176
- bundledExtensions: ["sandbox-guard"],
177
- });
178
210
 
179
- const chatHandler = new deps.ChatHandlerCtor(bridge, session);
211
+ await options.afterSessionCreate?.({ bridge, session, cwd });
212
+ chatHandler = new deps.ChatHandlerCtor(bridge, session);
180
213
  chatHandler.attach();
181
214
 
182
- // Second bridge subscriber (the bridge fans out to all): serve `pick_path` by
183
- // opening a native OS picker and replying `path_picked`. Pure — it only returns
184
- // the chosen path; the sandbox grant + prompt note happen in ChatHandler when the
185
- // path rides the next `chat_request.contextPaths` (kept atomic there). `disposed`
186
- // guards against a superseded serve firing the picker on a closed bridge.
187
215
  let disposed = false;
188
- bridge.onMessage(async msg => {
189
- if (disposed || !isPickPath(msg)) return;
190
- const { path, canceled, unsupported } = await deps.pickPath((msg as { mode: "file" | "folder" }).mode);
191
- if (disposed) return;
192
- bridge.send({ type: "path_picked", path, canceled, unsupported } satisfies PathPicked);
193
- });
216
+ if (kind === "office") {
217
+ bridge.onMessage(async msg => {
218
+ if (disposed || !isPickPath(msg)) return;
219
+ const { path, canceled, unsupported } = await deps.pickPath((msg as { mode: "file" | "folder" }).mode);
220
+ if (disposed) return;
221
+ bridge.send({ type: "path_picked", path, canceled, unsupported } satisfies PathPicked);
222
+ });
223
+ }
194
224
 
195
225
  return {
196
226
  bridge,
227
+ handler: chatHandler,
197
228
  dispose: async () => {
198
229
  disposed = true;
199
- chatHandler.dispose();
230
+ chatHandler?.dispose();
200
231
  deps.setSharedBridgeServer(null);
201
232
  await bridge.close();
202
233
  },
203
234
  };
204
235
  } catch (err) {
236
+ chatHandler?.dispose();
205
237
  deps.setSharedBridgeServer(null);
206
238
  await bridge.close();
207
239
  throw err;
@@ -15,20 +15,11 @@
15
15
  */
16
16
  import { homedir } from "node:os";
17
17
  import { join } from "node:path";
18
- import { getProjectDir, getXCSHConfigDir, logger } from "@f5-sales-demo/pi-utils";
18
+ import { logger } from "@f5-sales-demo/pi-utils";
19
19
  import { Command } from "@f5-sales-demo/pi-utils/cli";
20
- import { LOCALIP_HOST, resolveBridgeTls } from "../browser/bridge-cert";
21
- import { ChatHandler } from "../browser/chat-handler";
22
- import { startBridgeServer } from "../browser/extension-bridge";
23
- import {
24
- BROWSER_TOOL_NAMES,
25
- createExtensionBridgeTools,
26
- EXTENSION_AGENT_TOOL_NAMES,
27
- } from "../browser/extension-bridge-tools";
28
- import { setSharedBridgeServer } from "../browser/provider";
20
+ import { LOCALIP_HOST } from "../browser/bridge-cert";
21
+ import { startHeadlessChatBridge } from "../browser/headless-bridge";
29
22
  import { coldStartSpans, type SpanFrame, sessionBuildSpan } from "../browser/ttft-spans";
30
- import { initializeWithSettings } from "../discovery";
31
- import { createAgentSession } from "../sdk";
32
23
  import { activateTenantContext } from "../services/session-context-binding";
33
24
  import { ContextService } from "../services/xcsh-context";
34
25
  import { deriveTenantEnv } from "../services/xcsh-env";
@@ -102,197 +93,113 @@ export default class Worker extends Command {
102
93
  static description = "Run a headless extension-bridge worker (no TUI); blocks until SIGTERM";
103
94
 
104
95
  async run(): Promise<void> {
105
- // Record the per-tab session-boot timeline (parity with main.ts:runRootCommand).
106
- // Spans only accumulate while recording; nothing prints unless PI_TIMING is set,
107
- // and each logger.time() returns its wrapped value unchanged — so a normal
108
- // `xcsh worker` run is behaviorally identical to before.
109
- // TTFT Phase 2: the manager stamps XCSH_TTFT_SPAWN_AT at Bun.spawn for a cold
110
- // spawn; worker_boot(cold) = bridge-listening instant - spawn instant (captures
111
- // fork + runtime init, which logger.startTiming below misses).
112
96
  const spawnAtEnv = Number(process.env.XCSH_TTFT_SPAWN_AT);
113
97
  const coldSpawn = process.env.XCSH_TTFT_COLD === "1";
114
98
  const managerProvisionMsEnv = Number(process.env.XCSH_TTFT_PROVISION_MS);
115
99
  logger.startTiming();
116
100
 
117
- process.env.XCSH_BROWSER_PROVIDER = "extension";
118
-
119
- const cwd = getProjectDir();
120
- const { Settings, settings } = await import("../config/settings");
121
- await Settings.init({ cwd });
122
-
123
- // Init the ContextService singleton so the session-context bootstrap (Task 3)
124
- // can match XCSH_SESSION_TENANT to a stored context, and so sessionInfoForWorker
125
- // can read the active apiUrl once bound.
126
- try {
127
- ContextService.init(getXCSHConfigDir());
128
- } catch {
129
- /* already initialized / unavailable — continue. */
130
- }
131
-
132
- // Provider persistence for model discovery (parity with main.ts).
133
- initializeWithSettings(settings);
134
-
135
- // Quiet startup: skip the welcome screen + blocking plugin "Fix now?" prompts.
136
- settings.override("startup.quiet", true);
137
-
138
- // Provision the wss cert BEFORE the session:bridgeListen span — its only
139
- // network path (a cold/stale-cache fetch) must NOT inflate the measured
140
- // bridge-ready time; a warm boot is a fast on-disk cache hit. `undefined`
141
- // (offline / local-ip.sh unreachable) → the bridge starts ws-only (no crash).
142
- const tls = await resolveBridgeTls();
143
-
144
- // INSTANT-ON: start the bridge before the heavy session init so the extension
145
- // can connect immediately. Honors XCSH_BRIDGE_PORT (forced) or auto-selects.
146
- // session:bridgeListen — time-to-"bridge-ready": the extension can connect and
147
- // complete the hello/hello_ack handshake once this resolves (INSTANT-ON path).
148
- const bridge = await logger.time("session:bridgeListen", () =>
149
- startBridgeServer(undefined, {
150
- serveKind: "browser",
151
- sessionInfo: sessionInfoForWorker,
152
- ...(tls ? { tls } : {}),
153
- }),
154
- );
155
- console.error(
156
- `[xcsh worker] extension bridge listening on ws://127.0.0.1:${bridge.port}` +
157
- (bridge.wssPort ? ` + wss://${LOCALIP_HOST}:${bridge.wssPort}` : ""),
158
- );
159
- if (process.connected) {
160
- process.send?.({ type: "ready", sessionId: sessionInfoForWorker().sessionId });
161
- }
162
- setSharedBridgeServer(bridge);
163
- ContextService.onContextChange(() => bridge.broadcastTenantChanged());
164
-
165
- // TTFT Phase 2: buffer the per-session cold-start spans and flush them once a
166
- // client is actually connected. bridge.send() silently no-ops with no client, so
167
- // the flush is gated on `clientConnected` — a cold-boot flush before the extension
168
- // connects would otherwise burn the once-latch and lose the spans. Cold path is
169
- // populated now (env); warm path by the {bind} handler below.
170
101
  let coldStartBuffer: SpanFrame[] = [];
171
102
  let coldStartSent = false;
172
103
  let clientConnected = false;
173
- const flushColdStart = (): void => {
174
- if (coldStartSent || !clientConnected || coldStartBuffer.length === 0) return;
175
- for (const s of coldStartBuffer) bridge.send(s);
176
- coldStartSent = true;
177
- };
178
- // TTFT: the session_build span (createAgentSession seam) is computed after the
179
- // bridge is listening but possibly before the extension connects — buffer it and
180
- // flush on connect, same as the cold-start spans.
181
104
  let sessionBuildFrame: SpanFrame | null = null;
182
105
  let sessionBuildSent = false;
183
- const flushSessionBuild = (): void => {
184
- if (sessionBuildSent || !clientConnected || !sessionBuildFrame) return;
185
- bridge.send(sessionBuildFrame);
186
- sessionBuildSent = true;
187
- };
188
- // onConnected (raw WS open) is the deliberate flush trigger: no hello hook is exposed
189
- // today, and the bridge's origin check already gates opens to the extension.
190
- bridge.onConnected(() => {
191
- clientConnected = true;
192
- flushColdStart();
193
- flushSessionBuild();
194
- });
106
+ let sessionBuildStart = 0;
195
107
 
196
- if (coldSpawn && process.env.XCSH_SESSION_ID && Number.isFinite(spawnAtEnv)) {
197
- const workerBootMs = Date.now() - spawnAtEnv; // spawn -> bridge listening
198
- const mgrMs = Number.isFinite(managerProvisionMsEnv) ? managerProvisionMsEnv : 0;
199
- coldStartBuffer = coldStartSpans(process.env.XCSH_SESSION_ID, true, mgrMs, workerBootMs);
200
- // No flush here: at cold boot the extension has not connected yet; onConnected flushes.
201
- }
108
+ const running = await startHeadlessChatBridge(undefined, {
109
+ kind: "worker",
110
+ sessionInfo: sessionInfoForWorker,
111
+ afterBridgeBind: ({ bridge }) => {
112
+ console.error(
113
+ `[xcsh worker] extension bridge listening on ws://127.0.0.1:${bridge.port}` +
114
+ (bridge.wssPort ? ` + wss://${LOCALIP_HOST}:${bridge.wssPort}` : ""),
115
+ );
116
+ if (process.connected) {
117
+ process.send?.({ type: "ready", sessionId: sessionInfoForWorker().sessionId });
118
+ }
202
119
 
203
- // Pre-warm pool late-bind: the manager (our parent) sends {bind} over Bun IPC to adopt
204
- // this spare for a tab. Apply the identity, activate the tenant's context LIVE, then
205
- // re-announce via broadcastTenantChanged (now carrying the real sessionId).
206
- process.on("message", (raw: unknown) => {
207
- const m = raw as {
208
- type?: unknown;
209
- sessionId?: unknown;
210
- tenant?: unknown;
211
- provisionMs?: unknown;
212
- cold?: unknown;
213
- };
214
- if (m?.type !== "bind" || typeof m.sessionId !== "string" || typeof m.tenant !== "string") return;
215
- // Capture the narrowed values — TS widens `m.*` back to `unknown` inside the async closure.
216
- const sessionId = m.sessionId;
217
- const tenant = m.tenant;
218
- const bindAt = Date.now(); // TTFT Phase 2: start of warm worker_boot (bind -> bound)
219
- const relayedProvisionMs = typeof m.provisionMs === "number" ? m.provisionMs : 0;
220
- setWorkerIdentity(sessionId, tenant);
221
- void (async () => {
222
- try {
223
- await activateTenantContext(tenant);
224
- } catch {
225
- console.error("[xcsh worker] late tenant-bind failed");
120
+ const flushColdStart = (): void => {
121
+ if (coldStartSent || !clientConnected || coldStartBuffer.length === 0) return;
122
+ for (const span of coldStartBuffer) bridge.send(span);
123
+ coldStartSent = true;
124
+ };
125
+ const flushSessionBuild = (): void => {
126
+ if (sessionBuildSent || !clientConnected || !sessionBuildFrame) return;
127
+ bridge.send(sessionBuildFrame);
128
+ sessionBuildSent = true;
129
+ };
130
+
131
+ bridge.onConnected(() => {
132
+ clientConnected = true;
133
+ flushColdStart();
134
+ flushSessionBuild();
135
+ });
136
+
137
+ if (coldSpawn && process.env.XCSH_SESSION_ID && Number.isFinite(spawnAtEnv)) {
138
+ const workerBootMs = Date.now() - spawnAtEnv;
139
+ const managerMs = Number.isFinite(managerProvisionMsEnv) ? managerProvisionMsEnv : 0;
140
+ coldStartBuffer = coldStartSpans(process.env.XCSH_SESSION_ID, true, managerMs, workerBootMs);
226
141
  }
227
- bridge.broadcastTenantChanged();
228
- // The standalone benchmark retains IPC to measure adoption latency. A real
229
- // manager disconnects after binding so the worker survives manager handoff.
230
- if (process.connected) process.send?.({ type: "bound", sessionId });
231
- // TTFT Phase 2: warm adopt cold-start spans (worker_boot = bind -> bound).
232
- coldStartBuffer = coldStartSpans(sessionId, false, relayedProvisionMs, Date.now() - bindAt);
233
- flushColdStart();
234
- })();
235
- });
236
142
 
237
- // The extension's browser actions (navigate/click/read_ax/…) are not builtin
238
- // tools — turn each into a bridge-proxying CustomTool so the agent can drive
239
- // the browser (without this the agent only has catalog_workflow_runner and
240
- // merely narrates "Navigating…"). Include their names in the tool scope.
241
- const extensionTools = createExtensionBridgeTools(bridge);
242
- // session:createAgentSession — the heavy step between bridge-ready and
243
- // session-ready (model registry, tools, context bootstrap). Wrapped as a span
244
- // (parity with main.ts:892) so PI_TIMING reveals the per-tab session-load split.
245
- const sessionBuildStart = Date.now();
246
- const { session } = await logger.time("session:createAgentSession", createAgentSession, {
247
- cwd,
248
- hasUI: false,
249
- toolNames: [...new Set([...BROWSER_TOOL_NAMES, ...EXTENSION_AGENT_TOOL_NAMES])],
250
- customTools: extensionTools,
251
- // Headless worker: no MCP discovery, no LSP warmup, no extension discovery —
252
- // keep startup lean and free of network calls / blocking prompts.
253
- enableMCP: false,
254
- enableLsp: false,
255
- disableExtensionDiscovery: true,
256
- // TTFT Phase 3: load the hermetic bench stand-in provider when the benchmark
257
- // sets XCSH_BENCH_EXTENSION (absolute path). Inert for normal workers.
258
- ...(process.env.XCSH_BENCH_EXTENSION ? { additionalExtensionPaths: [process.env.XCSH_BENCH_EXTENSION] } : {}),
259
- });
260
- // TTFT: emit the session_build stage (createAgentSession seam) as a WS span so the
261
- // bench and diagnostics panel stop hiding plugin/discovery/registry-load cost inside
262
- // total ttft_ms. Buffered + flushed on connect (parity with cold-start spans).
263
- sessionBuildFrame = sessionBuildSpan(
264
- process.env.XCSH_SESSION_ID ?? "",
265
- coldSpawn,
266
- Date.now() - sessionBuildStart,
267
- );
268
- flushSessionBuild();
143
+ process.on("message", (raw: unknown) => {
144
+ const message = raw as {
145
+ type?: unknown;
146
+ sessionId?: unknown;
147
+ tenant?: unknown;
148
+ provisionMs?: unknown;
149
+ cold?: unknown;
150
+ };
151
+ if (
152
+ message?.type !== "bind" ||
153
+ typeof message.sessionId !== "string" ||
154
+ typeof message.tenant !== "string"
155
+ )
156
+ return;
157
+ const sessionId = message.sessionId;
158
+ const tenant = message.tenant;
159
+ const bindAt = Date.now();
160
+ const relayedProvisionMs = typeof message.provisionMs === "number" ? message.provisionMs : 0;
161
+ setWorkerIdentity(sessionId, tenant);
162
+ void (async () => {
163
+ try {
164
+ await activateTenantContext(tenant);
165
+ } catch {
166
+ console.error("[xcsh worker] late tenant-bind failed");
167
+ }
168
+ bridge.broadcastTenantChanged();
169
+ if (process.connected) process.send?.({ type: "bound", sessionId });
170
+ coldStartBuffer = coldStartSpans(sessionId, false, relayedProvisionMs, Date.now() - bindAt);
171
+ flushColdStart();
172
+ })();
173
+ });
269
174
 
270
- // TTFT Phase 3 hermeticity: the bench-instant stand-in provider is registered while
271
- // createAgentSession loads the bench extension (additionalExtensionPaths), which is
272
- // AFTER the default-model role is resolved — so the worker would otherwise fall back
273
- // to a real provider and the benchmark would measure live network TTFT, not xcsh
274
- // overhead. In bench mode, explicitly select bench-instant now that it is registered.
275
- // Gated on XCSH_BENCH_EXTENSION → completely inert for a normal `xcsh worker`.
276
- if (process.env.XCSH_BENCH_EXTENSION) {
277
- const benchModel = session.modelRegistry.find("bench-instant", "bench-instant");
278
- if (benchModel) {
279
- await session.setModel(benchModel);
280
- } else {
281
- console.error(
282
- "[xcsh worker] BENCH ERROR: bench-instant model not registered — benchmark would measure a real provider",
175
+ // The shared bootstrap creates the session immediately after this hook.
176
+ sessionBuildStart = Date.now();
177
+ },
178
+ afterSessionCreate: async ({ bridge, session }) => {
179
+ sessionBuildFrame = sessionBuildSpan(
180
+ process.env.XCSH_SESSION_ID ?? "",
181
+ coldSpawn,
182
+ Date.now() - sessionBuildStart,
283
183
  );
284
- }
285
- }
184
+ if (clientConnected && !sessionBuildSent) {
185
+ bridge.send(sessionBuildFrame);
186
+ sessionBuildSent = true;
187
+ }
286
188
 
287
- const chatHandler = new ChatHandler(bridge, session);
288
- chatHandler.attach();
189
+ if (process.env.XCSH_BENCH_EXTENSION) {
190
+ const benchModel = session.modelRegistry.find("bench-instant", "bench-instant");
191
+ if (benchModel) {
192
+ await session.setModel(benchModel);
193
+ } else {
194
+ console.error(
195
+ "[xcsh worker] BENCH ERROR: bench-instant model not registered — benchmark would measure a real provider",
196
+ );
197
+ }
198
+ }
199
+ },
200
+ });
201
+ const { handler: chatHandler } = running;
289
202
 
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
203
  const keepalive = new ManagerKeepalive({
297
204
  sessionId: () => sessionInfoForWorker().sessionId ?? "spare",
298
205
  busy: () => chatHandler.busy,
@@ -312,16 +219,13 @@ export default class Worker extends Command {
312
219
  };
313
220
  return transport;
314
221
  } catch {
315
- return null; // manager not reachable — a later emit retries
222
+ return null;
316
223
  }
317
224
  },
318
225
  });
319
226
  chatHandler.onTurnStart(() => keepalive.turnStart());
320
227
  const keepaliveTimer = setInterval(() => keepalive.tick(), KEEPALIVE_MS);
321
228
 
322
- // session-ready. Emit the per-tab boot breakdown when requested (parity with
323
- // main.ts:1002-1009). PI_TIMING=x prints then exits — used by bench/extension-session.ts
324
- // to measure total worker cold-start; otherwise this is a no-op.
325
229
  if (process.env.PI_TIMING) {
326
230
  logger.printTimings();
327
231
  if (process.env.PI_TIMING === "x") {
@@ -334,16 +238,11 @@ export default class Worker extends Command {
334
238
  const teardown = () => {
335
239
  clearInterval(keepaliveTimer);
336
240
  keepalive.stop();
337
- chatHandler.dispose();
338
- void bridge.close().finally(() => process.exit(0));
241
+ void running.dispose().finally(() => process.exit(0));
339
242
  };
340
243
  const shutdown = () => {
341
244
  if (shuttingDown) return;
342
245
  shuttingDown = true;
343
- // Bounded drain (#1874): if a chat turn is in flight (e.g. the manager is
344
- // recycling for an upgrade), let it finish before teardown instead of
345
- // aborting the running agent turn — with a hard ceiling so a hung turn can
346
- // never wedge shutdown. An idle worker tears down immediately.
347
246
  if (!chatHandler.busy) return teardown();
348
247
  const deadline = Date.now() + WORKER_DRAIN_TIMEOUT_MS;
349
248
  const tick = () => {
@@ -355,7 +254,6 @@ export default class Worker extends Command {
355
254
  process.on("SIGTERM", shutdown);
356
255
  process.on("SIGINT", shutdown);
357
256
 
358
- // Block until a signal tears us down.
359
257
  await Promise.withResolvers<never>().promise;
360
258
  }
361
259
  }
@@ -1976,6 +1976,24 @@ export const SETTINGS_SCHEMA = {
1976
1976
  "sandbox.allowWrite": { type: "array", default: [] as string[] },
1977
1977
 
1978
1978
  // Provider selection
1979
+ "providers.vertexProject": {
1980
+ type: "string",
1981
+ default: undefined,
1982
+ ui: {
1983
+ tab: "providers",
1984
+ label: "Vertex AI Project",
1985
+ description: "Non-secret corporate Google Cloud project selected by /login",
1986
+ },
1987
+ },
1988
+ "providers.vertexLocation": {
1989
+ type: "string",
1990
+ default: undefined,
1991
+ ui: {
1992
+ tab: "providers",
1993
+ label: "Vertex AI Location",
1994
+ description: "Non-secret Vertex AI location selected by /login",
1995
+ },
1996
+ },
1979
1997
  "providers.webSearch": {
1980
1998
  type: "enum",
1981
1999
  values: [