@f5-sales-demo/xcsh 19.57.2 → 19.58.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "19.57.2",
4
+ "version": "19.58.1",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -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.2",
59
- "@f5-sales-demo/pi-agent-core": "19.57.2",
60
- "@f5-sales-demo/pi-ai": "19.57.2",
61
- "@f5-sales-demo/pi-natives": "19.57.2",
62
- "@f5-sales-demo/pi-resource-management": "19.57.2",
63
- "@f5-sales-demo/pi-tui": "19.57.2",
64
- "@f5-sales-demo/pi-utils": "19.57.2",
58
+ "@f5-sales-demo/xcsh-stats": "19.58.1",
59
+ "@f5-sales-demo/pi-agent-core": "19.58.1",
60
+ "@f5-sales-demo/pi-ai": "19.58.1",
61
+ "@f5-sales-demo/pi-natives": "19.58.1",
62
+ "@f5-sales-demo/pi-resource-management": "19.58.1",
63
+ "@f5-sales-demo/pi-tui": "19.58.1",
64
+ "@f5-sales-demo/pi-utils": "19.58.1",
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);
@@ -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 };
@@ -127,6 +143,29 @@ export default class Worker extends Command {
127
143
  bridge.setSessionInfo(sessionInfoForWorker);
128
144
  ContextService.onContextChange(() => bridge.broadcastTenantChanged());
129
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
+
130
169
  // The extension's browser actions (navigate/click/read_ax/…) are not builtin
131
170
  // tools — turn each into a bridge-proxying CustomTool so the agent can drive
132
171
  // the browser (without this the agent only has catalog_workflow_runner and
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.57.2",
21
- "commit": "642fb826019640261cf1e3c1ec0206581fd9c1fc",
22
- "shortCommit": "642fb82",
20
+ "version": "19.58.1",
21
+ "commit": "f72a3fa845f24642e4db4d84aa427bb2e2d64538",
22
+ "shortCommit": "f72a3fa",
23
23
  "branch": "main",
24
- "tag": "v19.57.2",
25
- "commitDate": "2026-07-04T22:01:28Z",
26
- "buildDate": "2026-07-04T22:29:54.204Z",
24
+ "tag": "v19.58.1",
25
+ "commitDate": "2026-07-05T13:33:19Z",
26
+ "buildDate": "2026-07-05T13:53:37.495Z",
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/642fb826019640261cf1e3c1ec0206581fd9c1fc",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.57.2"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/f72a3fa845f24642e4db4d84aa427bb2e2d64538",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.58.1"
33
33
  };
package/src/main.ts CHANGED
@@ -57,6 +57,7 @@ import type { SubmittedUserInput } from "./modes/types";
57
57
  import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage } from "./sdk";
58
58
  import type { AgentSession } from "./session/agent-session";
59
59
  import { resolveResumableSession, type SessionInfo, SessionManager } from "./session/session-manager";
60
+ import { profileDump, profileMark } from "./startup-profile";
60
61
  import { resolvePromptInput } from "./system-prompt";
61
62
  import type { LspStartupServerInfo } from "./tools";
62
63
  import type { EventBus } from "./utils/event-bus";
@@ -146,8 +147,6 @@ export async function submitInteractiveInput(
146
147
  }
147
148
  }
148
149
 
149
- const INITIAL_UPDATE_CHECK_TIMEOUT_MS = 500;
150
-
151
150
  async function runInteractiveMode(
152
151
  session: AgentSession,
153
152
  version: string,
@@ -161,23 +160,26 @@ async function runInteractiveMode(
161
160
  initialMessage?: string,
162
161
  initialImages?: ImageContent[],
163
162
  ): Promise<void> {
164
- const initialUpdateVersion = await Promise.race([
165
- versionCheckPromise.catch(() => undefined),
166
- new Promise<string | undefined>(resolve => setTimeout(() => resolve(undefined), INITIAL_UPDATE_CHECK_TIMEOUT_MS)),
167
- ]);
163
+ profileMark("interactive: enter runInteractiveMode");
168
164
 
169
165
  const mode = new InteractiveMode(session, version, setExtensionUIContext, lspServers, mcpManager, eventBus);
170
166
 
171
167
  await mode.init();
168
+ profileMark("interactive: mode.init() done");
172
169
 
173
- // Non-blocking, one-shot update notice: only surface it if the background check
174
- // already resolved within the startup race (no waiting, no live re-render). If it
175
- // resolves later, the update is available on demand via /plugins.
176
- if (initialUpdateVersion && settings.get("startup.checkUpdate")) {
177
- mode.showStatus(`Update available: v${initialUpdateVersion} — run: xcsh update`, { dim: true });
170
+ // Update notice: fully non-blocking. Startup never waits on the network version
171
+ // check; surface the notice whenever it resolves (typically after the prompt is
172
+ // already up). If it never resolves, the update is available on demand via /plugins.
173
+ if (settings.get("startup.checkUpdate")) {
174
+ void versionCheckPromise
175
+ .then(latest => {
176
+ if (latest) mode.showStatus(`Update available: v${latest} — run: xcsh update`, { dim: true });
177
+ })
178
+ .catch(() => {});
178
179
  }
179
180
 
180
181
  mode.renderInitialMessages();
182
+ profileMark("interactive: initial render requested");
181
183
 
182
184
  for (const notify of notifs) {
183
185
  if (!notify) {
@@ -210,6 +212,8 @@ async function runInteractiveMode(
210
212
  }
211
213
  }
212
214
 
215
+ profileMark("interactive: READY — awaiting first input");
216
+ profileDump();
213
217
  while (true) {
214
218
  const input = await mode.getUserInput();
215
219
  await submitInteractiveInput(mode, session, input);
@@ -551,6 +555,7 @@ async function buildSessionOptions(
551
555
 
552
556
  export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<void> {
553
557
  logger.startTiming();
558
+ profileMark("entry: runtime + module-graph loaded (pre-main)");
554
559
 
555
560
  // Initialize theme early with defaults (CLI commands need symbols)
556
561
  // Will be re-initialized with user preferences later
@@ -895,6 +900,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
895
900
  sessionOptions,
896
901
  );
897
902
  logger.time("main:afterCreateSession");
903
+ profileMark("createAgentSession done");
898
904
  if (parsedArgs.apiKey && !sessionOptions.model && session.model) {
899
905
  authStorage.setRuntimeApiKey(session.model.provider, parsedArgs.apiKey);
900
906
  }
@@ -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));
@@ -274,10 +274,12 @@ export class ExtensionUiController {
274
274
  this.showExtensionError(error.extensionPath, error.error);
275
275
  });
276
276
 
277
- // Emit session_start event
278
- await extensionRunner.emit({
279
- type: "session_start",
280
- });
277
+ // Emit session_start in the BACKGROUND so a slow hook (e.g. a plugin doing a
278
+ // network/CLI check) can never block the TUI paint. The runner discards
279
+ // session_start handler results, so nothing downstream depends on completion;
280
+ // hooks that update the UI (widgets/status) render progressively as they finish.
281
+ // Handler errors are still surfaced via the onError subscription above.
282
+ void extensionRunner.emit({ type: "session_start" });
281
283
  }
282
284
 
283
285
  setHookWidget(key: string, content: ExtensionWidgetContent, options?: ExtensionWidgetOptions): void {
@@ -35,6 +35,7 @@ import planModeApprovedPrompt from "../prompts/system/plan-mode-approved.md" wit
35
35
  import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
36
36
  import { HistoryStorage } from "../session/history-storage";
37
37
  import type { SessionContext, SessionManager } from "../session/session-manager";
38
+ import { profileMark } from "../startup-profile";
38
39
  import { STTController, type SttState } from "../stt";
39
40
  import type { ExitPlanModeDetails } from "../tools";
40
41
  import type { EventBus } from "../utils/event-bus";
@@ -294,6 +295,7 @@ export class InteractiveMode implements InteractiveModeContext {
294
295
  async init(): Promise<void> {
295
296
  if (this.isInitialized) return;
296
297
 
298
+ profileMark("init: start");
297
299
  logger.time("InteractiveMode.init:keybindings");
298
300
  this.keybindings = KeybindingsManager.create();
299
301
 
@@ -305,6 +307,7 @@ export class InteractiveMode implements InteractiveModeContext {
305
307
  this.refreshSlashCommandState.bind(this),
306
308
  getProjectDir(),
307
309
  );
310
+ profileMark("init: refreshSlashCommandState done");
308
311
 
309
312
  // Refresh user profile in background — fire and forget
310
313
  reconcileFromCollectors().catch(err => logger.warn("Background profile refresh failed", { error: String(err) }));
@@ -368,10 +371,12 @@ export class InteractiveMode implements InteractiveModeContext {
368
371
 
369
372
  // Load initial todos
370
373
  await this.#loadTodoList();
374
+ profileMark("init: loadTodoList done");
371
375
 
372
376
  // Start the UI
373
377
  const clearScreen = settings.get("startup.clearScreen");
374
378
  this.ui.start(clearScreen);
379
+ profileMark("init: ui.start done");
375
380
  pushTerminalTitle();
376
381
  setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd());
377
382
  this.#syncEditorMaxHeight();
@@ -379,9 +384,11 @@ export class InteractiveMode implements InteractiveModeContext {
379
384
 
380
385
  // Initialize hooks with TUI-based UI context
381
386
  await this.initHooksAndCustomTools();
387
+ profileMark("init: initHooksAndCustomTools done");
382
388
 
383
389
  // Restore mode from session (e.g. plan mode on resume)
384
390
  await this.#restoreModeFromSession();
391
+ profileMark("init: restoreModeFromSession done");
385
392
 
386
393
  // Subscribe to agent events
387
394
  this.#subscribeToAgent();
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
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Opt-in startup profiler — `PI_STARTUP_PROFILE=1`.
3
+ *
4
+ * PI_TIMING exits before the TUI paints, so it cannot measure `runInteractiveMode`
5
+ * or the pre-`main` module-graph load. This records labeled timestamps (ms since
6
+ * process start, via `performance.now()`, which is measured from runtime start) and
7
+ * dumps the full boot timeline to a FILE at the input-ready point — so nothing
8
+ * corrupts the live TUI screen. Zero cost when the env var is unset.
9
+ *
10
+ * The FIRST mark's timestamp is the time from process start to that mark, i.e. it
11
+ * captures the runtime init + evaluation of the embedded module graph that happens
12
+ * before any of our code runs. Later marks show the per-step deltas through paint.
13
+ *
14
+ * Usage: PI_STARTUP_PROFILE=1 xcsh (then quit; read /tmp/xcsh-startup-profile.txt,
15
+ * or set PI_STARTUP_PROFILE_FILE to choose the path).
16
+ */
17
+ import { writeFileSync } from "node:fs";
18
+
19
+ const ENABLED = !!process.env.PI_STARTUP_PROFILE;
20
+ const marks: Array<[label: string, atMs: number]> = [];
21
+
22
+ /** Record a labeled timestamp (no-op unless PI_STARTUP_PROFILE is set). */
23
+ export function profileMark(label: string): void {
24
+ if (ENABLED) marks.push([label, performance.now()]);
25
+ }
26
+
27
+ /** Write the collected timeline to a file (no-op unless enabled / no marks). */
28
+ export function profileDump(): void {
29
+ if (!ENABLED || marks.length === 0) return;
30
+ const lines = ["=== xcsh startup profile (ms since process start) ===", ""];
31
+ let prev = 0;
32
+ for (const [label, at] of marks) {
33
+ const delta = at - prev;
34
+ lines.push(` ${at.toFixed(1).padStart(9)} ms (+${delta.toFixed(1).padStart(8)} ms) ${label}`);
35
+ prev = at;
36
+ }
37
+ lines.push(
38
+ "",
39
+ ` first mark = runtime + module-graph load; total to ready = ${marks[marks.length - 1][1].toFixed(1)} ms`,
40
+ );
41
+ const file = process.env.PI_STARTUP_PROFILE_FILE || "/tmp/xcsh-startup-profile.txt";
42
+ try {
43
+ writeFileSync(file, `${lines.join("\n")}\n`);
44
+ process.stderr.write(`[xcsh] startup profile written to ${file}\n`);
45
+ } catch {
46
+ /* best-effort diagnostic */
47
+ }
48
+ }