@f5-sales-demo/xcsh 19.57.1 → 19.58.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.57.1",
4
+ "version": "19.58.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.57.1",
59
- "@f5-sales-demo/pi-agent-core": "19.57.1",
60
- "@f5-sales-demo/pi-ai": "19.57.1",
61
- "@f5-sales-demo/pi-natives": "19.57.1",
62
- "@f5-sales-demo/pi-resource-management": "19.57.1",
63
- "@f5-sales-demo/pi-tui": "19.57.1",
64
- "@f5-sales-demo/pi-utils": "19.57.1",
58
+ "@f5-sales-demo/xcsh-stats": "19.58.0",
59
+ "@f5-sales-demo/pi-agent-core": "19.58.0",
60
+ "@f5-sales-demo/pi-ai": "19.58.0",
61
+ "@f5-sales-demo/pi-natives": "19.58.0",
62
+ "@f5-sales-demo/pi-resource-management": "19.58.0",
63
+ "@f5-sales-demo/pi-tui": "19.58.0",
64
+ "@f5-sales-demo/pi-utils": "19.58.0",
65
65
  "@sinclair/typebox": "^0.34",
66
66
  "@xterm/headless": "^6.0",
67
67
  "ajv": "^8.20",
@@ -51,3 +51,16 @@ export function staleKeys(reg: Registry, now: number, idleMs: number): string[]
51
51
  for (const w of reg.values()) if (now - w.lastSeen > idleMs) out.push(w.sessionId);
52
52
  return out;
53
53
  }
54
+
55
+ /** How many new spares to spawn now to reach `target`, without exceeding the port
56
+ * budget: spares + active workers must fit the discovery range. Never negative. */
57
+ export function sparesToSpawn(
58
+ target: number,
59
+ currentSpares: number,
60
+ activeWorkers: number,
61
+ totalPorts: number,
62
+ ): number {
63
+ const want = Math.max(0, target - currentSpares);
64
+ const freeSlots = Math.max(0, totalPorts - activeWorkers - currentSpares);
65
+ return Math.min(want, freeSlots);
66
+ }
@@ -21,7 +21,7 @@ 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
23
  import { portCandidates } from "../browser/extension-bridge";
24
- import { needsProvision, parseControlMsg, pickPort, type Registry, staleKeys } from "./manager-core";
24
+ import { needsProvision, parseControlMsg, pickPort, type Registry, sparesToSpawn, staleKeys } from "./manager-core";
25
25
 
26
26
  /** Reap a worker idle longer than this (ms). */
27
27
  const IDLE_MS = 20 * 60_000;
@@ -130,6 +130,80 @@ export default class Manager extends Command {
130
130
  const reg: Registry = new Map();
131
131
  const range = portCandidates();
132
132
 
133
+ const poolTarget = Math.max(0, Math.trunc(Number(process.env.XCSH_WORKER_POOL_SIZE ?? "2")) || 0);
134
+ interface SpareRec {
135
+ proc: Bun.Subprocess;
136
+ port: number;
137
+ pid: number;
138
+ }
139
+ const pool: SpareRec[] = [];
140
+
141
+ const spawnSpare = (): void => {
142
+ const usedPorts = new Set<number>([...reg.values()].map(w => w.port).concat(pool.map(s => s.port)));
143
+ const port = pickPort(
144
+ reg,
145
+ range.filter(p => !usedPorts.has(p) && isPortFree(p)),
146
+ );
147
+ if (port === null) return; // range full — do not pre-warm
148
+ const proc = Bun.spawn([process.execPath, ...workerArgv()], {
149
+ env: {
150
+ ...process.env,
151
+ XCSH_BROWSER_PROVIDER: "extension",
152
+ XCSH_BRIDGE_PORT: String(port),
153
+ // Identity-less spare: NO XCSH_SESSION_ID / XCSH_SESSION_TENANT (bound later via IPC).
154
+ // The spare marker makes sdk.ts skip its create-time context bootstrap, so the
155
+ // spare never boot-activates a tenant's context/credentials before its IPC bind
156
+ // (see shouldRunSessionContextBootstrap). Explicitly clear session identity too so
157
+ // the spare can never inherit an ambient session from the manager's env.
158
+ XCSH_WORKER_SPARE: "1",
159
+ XCSH_SESSION_ID: undefined,
160
+ XCSH_SESSION_TENANT: undefined,
161
+ XCSH_API_URL: undefined,
162
+ XCSH_API_TOKEN: undefined,
163
+ },
164
+ ipc() {}, // enable Bun parent→child IPC; no worker→manager messages needed today
165
+ stdout: "ignore",
166
+ stderr: "ignore",
167
+ });
168
+ const rec: SpareRec = { proc, port, pid: proc.pid };
169
+ pool.push(rec);
170
+ proc.exited.then(() => {
171
+ const i = pool.indexOf(rec);
172
+ if (i >= 0) pool.splice(i, 1); // a live spare died → drop + replenish
173
+ maintainPool();
174
+ });
175
+ console.error(`[xcsh manager] pre-warmed spare → pid ${proc.pid} on port ${port}`);
176
+ };
177
+
178
+ const maintainPool = (): void => {
179
+ const n = sparesToSpawn(poolTarget, pool.length, reg.size, range.length);
180
+ for (let i = 0; i < n; i++) spawnSpare();
181
+ };
182
+
183
+ /** Adopt a warm spare for a provision (bind over IPC). Returns false if none available. */
184
+ const adoptSpare = (msg: { sessionId: string; tenant: string }): boolean => {
185
+ const rec = pool.shift();
186
+ if (!rec) return false;
187
+ // `send` exists because the spare was spawned with an `ipc` handler.
188
+ (rec.proc as { send(m: unknown): void }).send({ type: "bind", sessionId: msg.sessionId, tenant: msg.tenant });
189
+ reg.set(msg.sessionId, {
190
+ sessionId: msg.sessionId,
191
+ tenant: msg.tenant,
192
+ port: rec.port,
193
+ pid: rec.pid,
194
+ lastSeen: Date.now(),
195
+ });
196
+ rec.proc.exited.then(() => {
197
+ const cur = reg.get(msg.sessionId);
198
+ if (cur && cur.pid === rec.pid) reg.delete(msg.sessionId);
199
+ });
200
+ maintainPool(); // replenish the consumed spare
201
+ console.error(
202
+ `[xcsh manager] adopted spare pid ${rec.pid} on port ${rec.port} as ${msg.sessionId} (${msg.tenant})`,
203
+ );
204
+ return true;
205
+ };
206
+
133
207
  const reap = (sessionId: string): void => {
134
208
  const w = reg.get(sessionId);
135
209
  if (!w) return;
@@ -195,7 +269,9 @@ export default class Manager extends Command {
195
269
  const msg = parseControlMsg(raw);
196
270
  if (!msg) return; // fail closed on unknown/invalid frames
197
271
  if (msg.type === "provision") {
198
- if (needsProvision(reg, msg.sessionId)) spawnWorker(msg);
272
+ if (needsProvision(reg, msg.sessionId)) {
273
+ if (!adoptSpare(msg)) spawnWorker(msg); // adopt a warm spare, else cold-spawn (fallback)
274
+ }
199
275
  const w = reg.get(msg.sessionId);
200
276
  if (w) w.lastSeen = Date.now(); // touch on every provision (keep-alive)
201
277
  } else if (msg.type === "release") {
@@ -255,6 +331,9 @@ export default class Manager extends Command {
255
331
  }
256
332
  console.error(`[xcsh manager] control socket listening at ${sockPath}`);
257
333
 
334
+ // Pre-warm the spare pool so provisions can adopt instead of cold-spawn.
335
+ if (poolTarget > 0) maintainPool();
336
+
258
337
  // Idle sweep: reap workers untouched for longer than the TTL.
259
338
  setInterval(() => {
260
339
  for (const key of staleKeys(reg, Date.now(), IDLE_MS)) reap(key);
@@ -13,7 +13,7 @@
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 { getProjectDir, getXCSHConfigDir } from "@f5-sales-demo/pi-utils";
16
+ import { getProjectDir, getXCSHConfigDir, logger } from "@f5-sales-demo/pi-utils";
17
17
  import { Command } from "@f5-sales-demo/pi-utils/cli";
18
18
  import { ChatHandler } from "../browser/chat-handler";
19
19
  import { startBridgeServer } from "../browser/extension-bridge";
@@ -21,13 +21,28 @@ import { createExtensionBridgeTools, EXTENSION_AGENT_TOOL_NAMES } from "../brows
21
21
  import { setSharedBridgeServer } from "../browser/provider";
22
22
  import { initializeWithSettings } from "../discovery";
23
23
  import { createAgentSession } from "../sdk";
24
+ import { activateTenantContext } from "../services/session-context-binding";
24
25
  import { ContextService } from "../services/xcsh-context";
25
26
  import { sessionKeyFromUrl } from "../services/xcsh-env";
26
27
 
28
+ /** Mutable worker identity. Seeded from env at spawn (backward compat); replaced by a
29
+ * late IPC bind on a pre-warmed spare. `null` fields fall back to env, then the "spare"
30
+ * sentinel (a non-`tab-<id>` string the extension registers but never binds to a tab). */
31
+ let boundIdentity: { sessionId: string; tenantKey: string } | null = null;
32
+
33
+ export function setWorkerIdentity(sessionId: string, tenantKey: string): void {
34
+ boundIdentity = { sessionId, tenantKey };
35
+ }
36
+ /** Test-only: clear late-bind state so env-seeded cases are deterministic. */
37
+ export function resetWorkerIdentity(): void {
38
+ boundIdentity = null;
39
+ }
40
+
27
41
  /** Tenant identity for the `hello` handshake. The active context wins; when the
28
- * worker is contextless we parse `XCSH_SESSION_TENANT` (`tenant|env`) so the panel
29
- * still learns which tenant this process serves (apiUrl stays null). Must be sync —
30
- * the bridge invokes it synchronously while answering `hello`. */
42
+ * worker is contextless we parse the bound tenant (or `XCSH_SESSION_TENANT`,
43
+ * `tenant|env`) so the panel still learns which tenant this process serves (apiUrl
44
+ * stays null). Must be sync — the bridge invokes it synchronously while answering
45
+ * `hello`. */
31
46
  export function sessionInfoForWorker(): {
32
47
  tenant: string | null;
33
48
  env: string | null;
@@ -35,9 +50,10 @@ export function sessionInfoForWorker(): {
35
50
  contextBound: boolean;
36
51
  sessionId: string | null;
37
52
  } {
38
- // The tab session key the manager spawned this worker for; echoed in hello_ack
39
- // so the extension can correlate a discovered worker back to the provisioned tab.
40
- const sessionId = process.env.XCSH_SESSION_ID ?? null;
53
+ // The tab session key this worker serves; echoed in hello_ack so the extension can
54
+ // correlate a discovered worker back to a provisioned tab. A late IPC bind wins over
55
+ // the spawn env; an unbound spare advertises the "spare" sentinel.
56
+ const sessionId = boundIdentity?.sessionId ?? process.env.XCSH_SESSION_ID ?? "spare";
41
57
  let apiUrl: string | null = null;
42
58
  let contextBound = false;
43
59
  try {
@@ -45,15 +61,15 @@ export function sessionInfoForWorker(): {
45
61
  // A worker is "context-bound" when it has an active stored context (not just an env-derived apiUrl).
46
62
  contextBound = ContextService.instance.getStatus().activeContextName != null;
47
63
  } catch {
48
- /* ContextService not initialized — fall through to env; contextBound stays false. */
64
+ /* ContextService not initialized — fall through to the tenant key; contextBound stays false. */
49
65
  }
50
66
  apiUrl = apiUrl ?? process.env.XCSH_API_URL ?? null;
51
67
  if (apiUrl) {
52
68
  const key = sessionKeyFromUrl(apiUrl);
53
69
  return { tenant: key?.tenant ?? null, env: key?.env ?? null, apiUrl, contextBound, sessionId };
54
70
  }
55
- // Contextless: the worker's assigned tenant is carried in XCSH_SESSION_TENANT.
56
- const raw = process.env.XCSH_SESSION_TENANT;
71
+ // Contextless: advertise the tenant carried by the bind (or spawn env).
72
+ const raw = boundIdentity?.tenantKey ?? process.env.XCSH_SESSION_TENANT;
57
73
  if (raw) {
58
74
  const [tenant, env] = raw.split("|");
59
75
  return { tenant: tenant || null, env: env || null, apiUrl: null, contextBound, sessionId };
@@ -90,6 +106,12 @@ export default class Worker extends Command {
90
106
  static description = "Run a headless extension-bridge worker (no TUI); blocks until SIGTERM";
91
107
 
92
108
  async run(): Promise<void> {
109
+ // Record the per-tab session-boot timeline (parity with main.ts:runRootCommand).
110
+ // Spans only accumulate while recording; nothing prints unless PI_TIMING is set,
111
+ // and each logger.time() returns its wrapped value unchanged — so a normal
112
+ // `xcsh worker` run is behaviorally identical to before.
113
+ logger.startTiming();
114
+
93
115
  process.env.XCSH_BROWSER_PROVIDER = "extension";
94
116
 
95
117
  const cwd = getProjectDir();
@@ -113,18 +135,46 @@ export default class Worker extends Command {
113
135
 
114
136
  // INSTANT-ON: start the bridge before the heavy session init so the extension
115
137
  // can connect immediately. Honors XCSH_BRIDGE_PORT (forced) or auto-selects.
116
- const bridge = await startBridgeServer();
138
+ // session:bridgeListen — time-to-"bridge-ready": the extension can connect and
139
+ // complete the hello/hello_ack handshake once this resolves (INSTANT-ON path).
140
+ const bridge = await logger.time("session:bridgeListen", startBridgeServer);
117
141
  console.error(`[xcsh worker] extension bridge listening on ws://127.0.0.1:${bridge.port}`);
118
142
  setSharedBridgeServer(bridge);
119
143
  bridge.setSessionInfo(sessionInfoForWorker);
120
144
  ContextService.onContextChange(() => bridge.broadcastTenantChanged());
121
145
 
146
+ // Pre-warm pool late-bind: the manager (our parent) sends {bind} over Bun IPC to adopt
147
+ // this spare for a tab. Apply the identity, activate the tenant's context LIVE, then
148
+ // re-announce via broadcastTenantChanged (now carrying the real sessionId).
149
+ process.on("message", (raw: unknown) => {
150
+ const m = raw as { type?: unknown; sessionId?: unknown; tenant?: unknown };
151
+ if (m?.type !== "bind" || typeof m.sessionId !== "string" || typeof m.tenant !== "string") return;
152
+ // Capture the narrowed values — TS widens `m.*` back to `unknown` inside the async closure.
153
+ const sessionId = m.sessionId;
154
+ const tenant = m.tenant;
155
+ setWorkerIdentity(sessionId, tenant);
156
+ void (async () => {
157
+ try {
158
+ await activateTenantContext(tenant);
159
+ } catch (err) {
160
+ console.error(`[xcsh worker] late tenant-bind failed: ${String(err)}`);
161
+ }
162
+ bridge.broadcastTenantChanged();
163
+ // Ack adoption complete (identity applied + context activated + re-announced).
164
+ // The manager ignores it today; the bench uses it to time adoption latency.
165
+ process.send?.({ type: "bound", sessionId });
166
+ })();
167
+ });
168
+
122
169
  // The extension's browser actions (navigate/click/read_ax/…) are not builtin
123
170
  // tools — turn each into a bridge-proxying CustomTool so the agent can drive
124
171
  // the browser (without this the agent only has catalog_workflow_runner and
125
172
  // merely narrates "Navigating…"). Include their names in the tool scope.
126
173
  const extensionTools = createExtensionBridgeTools(bridge);
127
- const { session } = await createAgentSession({
174
+ // session:createAgentSession the heavy step between bridge-ready and
175
+ // session-ready (model registry, tools, context bootstrap). Wrapped as a span
176
+ // (parity with main.ts:892) so PI_TIMING reveals the per-tab session-load split.
177
+ const { session } = await logger.time("session:createAgentSession", createAgentSession, {
128
178
  cwd,
129
179
  hasUI: false,
130
180
  toolNames: [...new Set([...BROWSER_TOOL_NAMES, ...EXTENSION_AGENT_TOOL_NAMES])],
@@ -139,6 +189,17 @@ export default class Worker extends Command {
139
189
  const chatHandler = new ChatHandler(bridge, session);
140
190
  chatHandler.attach();
141
191
 
192
+ // session-ready. Emit the per-tab boot breakdown when requested (parity with
193
+ // main.ts:1002-1009). PI_TIMING=x prints then exits — used by bench/extension-session.ts
194
+ // to measure total worker cold-start; otherwise this is a no-op.
195
+ if (process.env.PI_TIMING) {
196
+ logger.printTimings();
197
+ if (process.env.PI_TIMING === "x") {
198
+ process.exit(0);
199
+ }
200
+ }
201
+ logger.endTiming();
202
+
142
203
  let shuttingDown = false;
143
204
  const shutdown = () => {
144
205
  if (shuttingDown) return;
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.57.1",
21
- "commit": "afdece90c0203627849699502d77b189fd3fb320",
22
- "shortCommit": "afdece9",
20
+ "version": "19.58.0",
21
+ "commit": "def3667fcf2f20c14de7b9ab37b29e85b390e5c4",
22
+ "shortCommit": "def3667",
23
23
  "branch": "main",
24
- "tag": "v19.57.1",
25
- "commitDate": "2026-07-04T20:58:43Z",
26
- "buildDate": "2026-07-04T21:21:44.204Z",
24
+ "tag": "v19.58.0",
25
+ "commitDate": "2026-07-05T03:37:49Z",
26
+ "buildDate": "2026-07-05T04:00:08.049Z",
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/afdece90c0203627849699502d77b189fd3fb320",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.57.1"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/def3667fcf2f20c14de7b9ab37b29e85b390e5c4",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.58.0"
33
33
  };
@@ -8,6 +8,32 @@ import { theme } from "../../modes/theme/theme";
8
8
  * commands (/plugins, /context) so startup stays instant and never blocks or
9
9
  * live-updates. See docs/superpowers/specs for the fast-startup design.
10
10
  */
11
+ // biome-ignore format: preserve ASCII art layout
12
+ /** The F5 "ball" startup logo. Rows are vertically symmetric so the disk renders as a
13
+ * clean circle; keep it that way (see welcome-logo.test.ts). `▓`→red, `█`→white,
14
+ * `▒`→red stipple halo, `()|_`→red edge glyphs (see WelcomeComponent.#f5ColorLine). */
15
+ export const F5_LOGO_ROWS: readonly string[] = [
16
+ " ________",
17
+ " (▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒)",
18
+ " (▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒)",
19
+ " (▒▒▓▓▓▓██████████▓▓▓▓█████████████)",
20
+ " (▒▓▓▓▓██████▒▒▒▒▒███▓▓██████████████▒)",
21
+ " (▒▓▓▓▓██████▒▓▓▓▓▓▒▒▒▓██▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒)",
22
+ " (▒▓▓▓▓▓██████▓▓▓▓▓▓▓▓▓██▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒)",
23
+ " (▒▓▓███████████████▓▓▓▓█████████████▓▓▓▓▓▓▒)",
24
+ "(▒▓▓▓▒▒▒███████▒▒▒▒▒▓▓▓████████████████▓▓▓▓▓▒)",
25
+ "|▒▓▓▓▓▓▓▒██████▓▓▓▓▓▓▓████████████████████▓▓▒|",
26
+ "|▒▓▓▓▓▓▓▓██████▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒██████████▓▒|",
27
+ "(▒▓▓▓▓▓▓▓██████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒████████▒▒)",
28
+ " (▒▓▓▓▓▓▓██████▓▓▓▓▓▓▓███▓▓▓▓▓▓▓▓▓▓▒▒▒████▒▒)",
29
+ " (▒▓▓▓▓▓██████▓▓▓▓▓▓█████▓▓▓▓▓▓▓▓▓▓▓▓███▒▒)",
30
+ " (▒▒██████████▓▓▓▓▓▒██████▓▓▓▓▓▓▓▓███▒▒▒)",
31
+ " (▒▒▒▒▒██████████▓▓▒▒█████████████▒▒▓▒)",
32
+ " (▒▓▓▒▒▒▒▒▒▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒)",
33
+ " (▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒)",
34
+ " (▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒)",
35
+ ];
36
+
11
37
  export class WelcomeComponent implements Component {
12
38
  constructor(private readonly version: string) {}
13
39
  invalidate(): void {}
@@ -20,28 +46,7 @@ export class WelcomeComponent implements Component {
20
46
  if (boxWidth < 4) return [];
21
47
  const leftCol = boxWidth - 2;
22
48
 
23
- // biome-ignore format: preserve ASCII art layout
24
- const f5Logo = [
25
- " ________",
26
- " (▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒)",
27
- " (▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒)",
28
- " (▒▒▓▓▓▓██████████▓▓▓▓███████████████)",
29
- " (▒▓▓▓▓██████▒▒▒▒▒███▓▓█████████████████▒)",
30
- " (▒▓▓▓▓██████▒▓▓▓▓▓▒▒▒▓██▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒)",
31
- " (▒▓▓▓▓▓██████▓▓▓▓▓▓▓▓▓██▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒)",
32
- " (▒▓▓████████████████▓▓▓▓████████████▓▓▓▓▓▓▒)",
33
- "(▒▓▓▓▒▒▒███████▒▒▒▒▒▓▓▓████████████████▓▓▓▓▓▒)",
34
- "|▒▓▓▓▓▓▓▒██████▓▓▓▓▓▓▓████████████████████▓▓▒|",
35
- "|▒▓▓▓▓▓▓▓██████▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒██████████▓▒|",
36
- "(▒▓▓▓▓▓▓▓██████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒██████████▒▒)",
37
- " (▒▓▓▓▓▓▓██████▓▓▓▓▓▓▓███▓▓▓▓▓▓▓▓▓▒▒▒████▒▒)",
38
- " (▒▓▓▓▓▓██████▓▓▓▓▓▓██████▓▓▓▓▓▓▓▓▓▓▓▓████▒▒)",
39
- " (▒▒██████████▓▓▓▓▓▒██████▓▓▓▓▓▓▓▓██████▒▒▒)",
40
- " (▒▒▒▒▒██████████▓▓▒▒██████████████▒▒▓▒)",
41
- " (▒▓▓▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒)",
42
- " (▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒)",
43
- " (▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒)",
44
- ];
49
+ const f5Logo = F5_LOGO_ROWS;
45
50
 
46
51
  const logoColored = f5Logo.map(line => this.#f5ColorLine(line));
47
52
  const logoBlockPad = Math.max(0, Math.floor((leftCol - logoMaxWidth) / 2));
package/src/sdk.ts CHANGED
@@ -779,47 +779,53 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
779
779
  // is via the existing activate(); auth is validated but never blocks resume.
780
780
  try {
781
781
  const { ContextService } = await import("./services/xcsh-context");
782
- const { resolveAutoBind, chooseSessionContext } = await import("./services/session-context-binding");
782
+ const { resolveAutoBind, chooseSessionContext, activateTenantContext, shouldRunSessionContextBootstrap } =
783
+ await import("./services/session-context-binding");
783
784
  const svc = ContextService.instance; // inited in main.ts (throws for SDK/tests → caught)
784
- if (!process.env.XCSH_API_URL) {
785
+ // A pre-warmed spare (XCSH_WORKER_SPARE=1) skips the bootstrap entirely and
786
+ // stays contextless until its IPC bind activates the correct tenant — see
787
+ // shouldRunSessionContextBootstrap. Cold workers and interactive CLI are
788
+ // unchanged (they have no spare marker).
789
+ if (
790
+ shouldRunSessionContextBootstrap({
791
+ XCSH_API_URL: process.env.XCSH_API_URL,
792
+ XCSH_WORKER_SPARE: process.env.XCSH_WORKER_SPARE,
793
+ })
794
+ ) {
785
795
  const bound = existingSession.activeContextName; // resumed binding, if any
786
- const contexts = await svc.listContexts();
787
- const available = contexts.map(c => c.name);
788
796
  const tenantKey = process.env.XCSH_SESSION_TENANT;
789
- let autoBind: ReturnType<typeof resolveAutoBind>;
790
797
  if (tenantKey) {
791
- // Extension worker: match a context to this worker's tenant.
792
- const { sessionKeyFromUrl } = await import("./services/xcsh-env");
793
- const contextTenantKeys: Record<string, string> = {};
794
- for (const c of contexts) {
795
- const key = c.apiUrl ? sessionKeyFromUrl(c.apiUrl) : null;
796
- if (key) contextTenantKeys[c.name] = `${key.tenant}|${key.env}`;
797
- }
798
- autoBind = resolveAutoBind({
799
- kind: "extension",
800
- availableContexts: available,
801
- tenantKey,
802
- contextTenantKeys,
803
- });
804
- } else {
805
- const folderContext = await svc.resolveFolderContextName(cwd);
806
- autoBind = resolveAutoBind({ kind: "cli", availableContexts: available, folderContext });
807
- }
808
- const choice = chooseSessionContext(bound, autoBind);
809
- if ("activate" in choice) {
798
+ // Extension worker: match a context to this worker's tenant (shared with pool late-bind).
810
799
  try {
811
- await svc.activate(choice.activate); // fires onContextChange → records context_change
812
- await svc.validateToken(); // authenticate; non-blocking
800
+ await activateTenantContext(tenantKey, bound);
813
801
  } catch (err) {
814
802
  // Context deleted since last use, or auth failed → surface, never block.
815
803
  logger.warn("XCSH: session context bootstrap could not fully activate", {
816
- context: choice.activate,
804
+ tenantKey,
817
805
  error: String(err),
818
806
  });
819
807
  }
808
+ } else {
809
+ const contexts = await svc.listContexts();
810
+ const available = contexts.map(c => c.name);
811
+ const folderContext = await svc.resolveFolderContextName(cwd);
812
+ const autoBind = resolveAutoBind({ kind: "cli", availableContexts: available, folderContext });
813
+ const choice = chooseSessionContext(bound, autoBind);
814
+ if ("activate" in choice) {
815
+ try {
816
+ await svc.activate(choice.activate); // fires onContextChange → records context_change
817
+ await svc.validateToken(); // authenticate; non-blocking
818
+ } catch (err) {
819
+ // Context deleted since last use, or auth failed → surface, never block.
820
+ logger.warn("XCSH: session context bootstrap could not fully activate", {
821
+ context: choice.activate,
822
+ error: String(err),
823
+ });
824
+ }
825
+ }
826
+ // choice.needsSelection / choice.none → leave unbound; the /context status
827
+ // line and tools already prompt "run /context activate".
820
828
  }
821
- // choice.needsSelection / choice.none → leave unbound; the /context status
822
- // line and tools already prompt "run /context activate".
823
829
  }
824
830
  } catch {
825
831
  // ContextService not initialized (SDK consumers / tests) — skip bootstrap.
@@ -7,6 +7,22 @@
7
7
 
8
8
  export type SessionKind = "cli" | "extension";
9
9
 
10
+ /**
11
+ * Whether createAgentSession should run its create-time context bootstrap.
12
+ *
13
+ * A pre-warmed spare worker (XCSH_WORKER_SPARE=1) is spawned with NO api url and
14
+ * NO tenant, so the bootstrap would otherwise fall into the CLI single-context
15
+ * auto-bind branch and boot-activate a tenant's context/credentials BEFORE any
16
+ * IPC bind — breaking the "identity-less spare holds no tenant credentials until
17
+ * bind" isolation guarantee. A spare must stay contextless until its IPC bind
18
+ * calls activateTenantContext(). This keeps all other paths unchanged:
19
+ * interactive CLI (no marker) folder-auto-binds, and a cold-spawned worker
20
+ * (has XCSH_SESSION_TENANT, no marker) tenant-binds.
21
+ */
22
+ export function shouldRunSessionContextBootstrap(env: { XCSH_API_URL?: string; XCSH_WORKER_SPARE?: string }): boolean {
23
+ return !env.XCSH_API_URL && !env.XCSH_WORKER_SPARE;
24
+ }
25
+
10
26
  export interface AutoBindInput {
11
27
  kind: SessionKind;
12
28
  /** Names of all available (global) contexts. */
@@ -47,3 +63,34 @@ export function chooseSessionContext(
47
63
  if (autoBind.kind === "needsSelection") return { needsSelection: true };
48
64
  return { none: true };
49
65
  }
66
+
67
+ /**
68
+ * Activate the stored context matching a worker's tenant key ("tenant|env"), if any.
69
+ * Extracted from the createAgentSession bootstrap so a pre-warmed spare worker can bind
70
+ * its tenant AFTER startup (pool late-bind). Returns true if a context was activated.
71
+ * Never throws when no context matches — the caller stays tenant-advertised/unbound.
72
+ */
73
+ export async function activateTenantContext(tenantKey: string, bound: string | null = null): Promise<boolean> {
74
+ const { ContextService } = await import("./xcsh-context");
75
+ const { sessionKeyFromUrl } = await import("./xcsh-env");
76
+ const svc = ContextService.instance;
77
+ const contexts = await svc.listContexts();
78
+ const contextTenantKeys: Record<string, string> = {};
79
+ for (const c of contexts) {
80
+ const key = c.apiUrl ? sessionKeyFromUrl(c.apiUrl) : null;
81
+ if (key) contextTenantKeys[c.name] = `${key.tenant}|${key.env}`;
82
+ }
83
+ const autoBind = resolveAutoBind({
84
+ kind: "extension",
85
+ availableContexts: contexts.map(c => c.name),
86
+ tenantKey,
87
+ contextTenantKeys,
88
+ });
89
+ const choice = chooseSessionContext(bound ?? undefined, autoBind);
90
+ if ("activate" in choice) {
91
+ await svc.activate(choice.activate);
92
+ await svc.validateToken();
93
+ return true;
94
+ }
95
+ return false;
96
+ }