@f5-sales-demo/xcsh 21.2.0 → 21.2.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": "21.2.0",
4
+ "version": "21.2.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",
@@ -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.2.1",
65
+ "@f5-sales-demo/pi-ai": "21.2.1",
66
+ "@f5-sales-demo/pi-natives": "21.2.1",
67
+ "@f5-sales-demo/pi-resource-management": "21.2.1",
68
+ "@f5-sales-demo/pi-tui": "21.2.1",
69
+ "@f5-sales-demo/pi-utils": "21.2.1",
70
+ "@f5-sales-demo/xcsh-stats": "21.2.1",
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
  }
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "21.2.0",
21
- "commit": "d217f62f68fae54fe18d88c7d90a8f7dc4703c0d",
22
- "shortCommit": "d217f62",
20
+ "version": "21.2.1",
21
+ "commit": "21fcf6e73313274c68dcb1db998bd12b75326489",
22
+ "shortCommit": "21fcf6e",
23
23
  "branch": "main",
24
- "tag": "v21.2.0",
25
- "commitDate": "2026-08-30T03:18:04+00:00",
26
- "buildDate": "2026-08-30T03:46:25.618Z",
24
+ "tag": "v21.2.1",
25
+ "commitDate": "2026-08-31T13:35:12+00:00",
26
+ "buildDate": "2026-08-31T14:12:39.742Z",
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/d217f62f68fae54fe18d88c7d90a8f7dc4703c0d",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v21.2.0"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/21fcf6e73313274c68dcb1db998bd12b75326489",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v21.2.1"
33
33
  };
@@ -144,7 +144,7 @@ export const EMBEDDED_DOCS: Readonly<Record<string, string>> = {
144
144
  "en/natives/porting-to-natives.md": "---\ntitle: Porting to pi-natives (Node-API) — Field notes\ndescription: Field notes for migrating Node.js child_process and shell code to the Rust N-API native layer.\nsidebar:\n order: 9\n label: Porting to pi-natives\n---\n\nThis guide describes how to migrate performance-critical execution paths to Rust in `crates/pi-natives` and expose them through TypeScript bindings in `@f5-sales-demo/pi-natives`.\n\n## When to port to native modules\n\nPort execution paths to Rust when:\n\n- Execution paths execute within high-frequency rendering loops or large batch data pipelines.\n- JavaScript memory allocations dominate execution time (excessive string allocations, regex backtracking, large array transformations).\n- The operation is CPU-bound or performs blocking I/O that can run on libuv worker threads (`task::blocking`).\n- The operation orchestrates asynchronous I/O that benefits from the Tokio runtime (`task::future`).\n\nAvoid porting operations that require access to JavaScript runtime state or dynamic module imports. Native exports must remain pure, data-in/data-out functions.\n\n## Native export architecture\n\n### Rust implementation layer (`crates/pi-natives/`)\n\n1. Implement core functionality in `crates/pi-natives/src/<MODULE>.rs`.\n2. Register the module in `crates/pi-natives/src/lib.rs`.\n3. Annotate functions with `#[napi]`. Rust `snake_case` function names convert to JavaScript `camelCase` identifiers automatically.\n4. Use `task::blocking` for CPU-bound computation and `task::future` for asynchronous operations. Pass `CancelToken` instances when supporting timeouts or `AbortSignal`.\n\n### TypeScript binding layer (`packages/natives/`)\n\n1. Define interface augmentations in `packages/natives/src/<MODULE>/types.ts` extending `NativeBindings` via declaration merging.\n2. Import `<MODULE>/types.ts` in `packages/natives/src/native.ts` to activate type definitions.\n3. Add validation entries in `validateNative` within `packages/natives/src/native.ts` to enforce required exports at startup.\n4. Implement ergonomic wrappers in `packages/natives/src/<MODULE>/index.ts` and re-export them from `packages/natives/src/index.ts`.\n\n## Step-by-step porting workflow\n\n1. **Implement Rust logic**: Create functions with owned types (`String`, `Vec<String>`, `Uint8Array`) and wrap long loops with `ct.heartbeat()?`.\n2. **Expose TypeScript bindings**: Add types in `<MODULE>/types.ts` and export wrapper functions.\n3. **Register native validation**: Add `checkFn(\"exportName\")` to `validateNative` in `packages/natives/src/native.ts`.\n4. **Create performance benchmarks**: Benchmark JavaScript and Rust implementations side-by-side using `Bun.nanoseconds()`.\n5. **Compile native binaries**: Run `bun --cwd=packages/natives run build`.\n6. **Validate performance**: Verify that the native path outperforms JavaScript before switching production call sites.\n\n## Troubleshooting common issues\n\n### Stale native binaries\n\nThe runtime loader prioritizes platform-tagged binaries (`pi_natives.<PLATFORM>-<ARCH>.node`). If exports fail to update:\n\n```bash\nrm packages/natives/native/pi_natives.*.node\nbun --cwd=packages/natives run build\n```\n\nIf testing against pre-compiled binaries, remove the extraction cache:\n\n```bash\nrm -rf ~/.xcsh/natives/\n```\n\n### Missing export validation errors\n\nIf `validateNative` raises an error indicating missing exports:\n\n- Verify that the Rust export name matches the expected JavaScript camelCase name.\n- Verify that the binary was rebuilt after adding the `#[napi]` attribute.\n- Never disable or weaken `validateNative` checks.\n",
145
145
  "en/office-test-ownership.md": "# Office add-in test ownership\n\nThis document defines the canonical test ownership and execution responsibilities for the `office-pane` package and Office add-in task pane integration tests.\n\n## Test suites and domain ownership\n\n| Test suite / harness | Location | Canonical owner | Execution tier | Description |\n| --- | --- | --- | --- | --- |\n| **Office Task Pane UI Unit Tests** | `packages/office-pane/src/**/*.test.ts` | Frontend / UI Core Team | Local and PR CI | Core Office.js state management and task pane rendering |\n| **Office Host Integration Harness** | `packages/office-pane/test/integration/` | Ecosystem Integrations Team | PR CI Matrix | Simulated Office.js API host binding and event handling |\n| **Acceptance End-to-End Suite** | `packages/office-pane/test/e2e/` | QA and Release Engineering | Nightly Release CI | Headless browser execution against Word and Excel task pane mocks |\n\n## Maintenance responsibilities\n\n- **Frontend / UI Core Team**: Responsible for unit test coverage on UI components in `office-pane`.\n- **Ecosystem Integrations Team**: Responsible for host mock fidelity and API contract alignment.\n- **Release Engineering**: Responsible for end-to-end environment stability and CI matrix runtimes.\n",
146
146
  "en/providers/models.md": "---\ntitle: Model and Provider Configuration\ndescription: Model registry and provider configuration via models.yml with routing, fallback, and pricing.\nsidebar:\n order: 1\n label: Models & providers\n---\n\nThis document describes how xcsh loads model registries, applies configuration overrides, resolves API credentials, and manages model selection at runtime.\n\n## Core implementation architecture\n\nModel configuration and runtime resolution are implemented across the following modules:\n\n- `src/config/model-registry.ts`: Loads built-in and custom models, manages provider overrides, discovers local models, and integrates authentication.\n- `src/config/model-resolver.ts`: Parses model patterns, handles canonical coalescing, and selects default, small, and reasoning models.\n- `src/config/settings-schema.ts`: Defines settings schemas for `modelRoles` and transport options.\n- `src/session/auth-storage.ts`: Resolves API keys and OAuth tokens across configuration sources.\n- `packages/ai/src/models.ts` and `packages/ai/src/types.ts`: Defines built-in models and compatibility contracts.\n\n## Configuration file path\n\nDefault configuration path:\n\n- `~/.xcsh/agent/models.yml`\n\n> [!NOTE]\n> If `models.yml` is missing and a legacy `models.json` file exists in the configuration directory, xcsh automatically migrates the settings to `models.yml`.\n\n## Schema structure\n\n```yaml\nconfigVersion: 1\nproviders:\n <PROVIDER_ID>:\n baseUrl: https://api.example.com/v1\n apiKey: MY_API_KEY_ENV_VAR\n api: openai-completions\n headers:\n X-Custom-Header: value\n auth: apiKey\n discovery:\n type: ollama\n modelOverrides:\n <MODEL_ID>:\n name: Custom Model Display Name\n models:\n - id: custom-model-id\n name: Custom Model Name\n api: openai-completions\n contextWindow: 128000\n maxTokens: 16384\n cost:\n input: 0\n output: 0\n cacheRead: 0\n cacheWrite: 0\nequivalence:\n overrides:\n <PROVIDER_ID>/<MODEL_ID>: <CANONICAL_MODEL_ID>\n exclude:\n - <PROVIDER_ID>/<MODEL_ID>\n```\n\n### Supported API protocol types\n\n- `openai-completions`\n- `openai-responses`\n- `openai-codex-responses`\n- `azure-openai-responses`\n- `anthropic-messages`\n- `google-generative-ai`\n- `google-vertex`\n\n## Validation rules\n\n### Custom provider definitions (`models` defined)\n\nThe following fields are required:\n\n- `baseUrl`: Base endpoint URL.\n- `apiKey`: Required unless `auth: none` is explicitly configured.\n- `api`: Protocol type specified at the provider or model level.\n\n### Override-only providers (`models` omitted)\n\nMust specify at least one of:\n\n- `baseUrl`\n- `modelOverrides`\n- `discovery`\n\n## Merge and override hierarchy\n\nWhen initializing or refreshing the model registry, xcsh applies configuration in the following order:\n\n1. **Built-in catalog**: Loads default providers and models from `@f5-sales-demo/pi-ai`.\n2. **Custom configuration**: Parses `~/.xcsh/agent/models.yml`.\n3. **Provider overrides**: Applies custom `baseUrl` and default `headers` to built-in models.\n4. **Model overrides**: Merges custom settings from `modelOverrides`.\n5. **Custom models**: Appends or replaces model definitions matching existing `provider/id` pairs.\n6. **Runtime discovery**: Queries active local endpoints (such as Ollama or LM Studio) and registers discovered models.\n\n## Canonical model equivalence and coalescing\n\nxcsh groups equivalent model checkpoints from different providers under canonical upstream identifiers (for example, `claude-sonnet-4`, `gpt-5.3-codex`):\n\n- **Overrides**: Maps specific provider identifiers to standard canonical names via `equivalence.overrides`.\n- **Exclusions**: Removes specific variants from automatic grouping via `equivalence.exclude`.\n- **Resolution priority**: Selects concrete providers based on credential availability and `modelProviderOrder` precedence.\n\n## Runtime model discovery\n\nxcsh detects local inference runtimes automatically:\n\n- **Ollama**: Probes `http://127.0.0.1:11434/api/tags` (or `OLLAMA_BASE_URL`).\n- **LM Studio**: Probes `http://127.0.0.1:1234/v1/models` (or `LM_STUDIO_BASE_URL`).\n- **llama.cpp**: Probes `http://127.0.0.1:8080/v1/models` (or `LLAMA_CPP_BASE_URL`).\n\n## Credential resolution hierarchy\n\nWhen resolving API keys for a provider, xcsh evaluates sources in the following priority order:\n\n1. CLI flag `--api-key`\n2. Stored API keys in `agent.db`\n3. Stored OAuth tokens in `agent.db`\n4. Standard environment variables (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`)\n5. Provider `apiKey` field in `models.yml` (evaluated first as an environment variable name, then as a literal token)\n\n## Model roles and selection\n\nxcsh supports logical role aliases configured via `settings.modelRoles`:\n\n- `default`: Primary interaction model.\n- `smol`: Fast, lightweight model for metadata tasks and short tool evaluations.\n- `slow`: High-reasoning model for complex multi-step planning and analysis.\n- `plan`: Model assigned to interactive planning modes.\n- `commit`: Model assigned to git commit generation.\n\nEach role definition can include thinking intensity suffixes (for example, `pi/smol:minimal`, `claude-sonnet-4:high`).\n\n## Context promotion and fallback\n\nWhen a model context window overflows during a conversation turn (`context_length_exceeded`), xcsh automatically promotes the session to a larger-context sibling model before initiating context compaction:\n\n1. Evaluates explicit `contextPromotionTarget` configurations.\n2. Identifies the smallest available model with a larger context window on the same provider.\n3. Switches the session model temporarily and retries the turn.\n",
147
- "en/providers/openai-api-access.md": "---\ntitle: OpenAI access\ndescription: Choose ChatGPT subscription or usage-based OpenAI Platform access in xcsh.\nsidebar:\n order: 4\n label: OpenAI access\n---\n\nxcsh supports two distinct access methods for OpenAI services, depending on whether you authenticate with a ChatGPT subscription or an OpenAI Platform API key.\n\n## 1. ChatGPT subscription (OAuth)\n\nTo use your ChatGPT Plus or Pro subscription, run `/login openai-codex` or select **ChatGPT Plus/Pro (Codex Subscription)** in the `/login` interactive picker. xcsh stores the OAuth credentials securely in `agent.db` and discovers advertised subscription models.\n\n### Headless and SSH remote login\n\nWhen running inside an SSH session or headless terminal, xcsh automatically initiates device-code authentication:\n\n1. xcsh displays `https://auth.openai.com/codex/device` and a one-time verification code.\n2. Open the URL in any web browser and log in with your ChatGPT account credentials.\n3. Enter the one-time verification code.\n4. The remote xcsh process detects authorization automatically and completes authentication.\n\n> [!NOTE]\n> Device-code authentication requires enabling the beta feature in ChatGPT under **Settings → Security**, or via workspace administrator settings under **Workspace settings → Permissions & roles**.\n\n### Local desktop browser callback\n\nOn local workstations with graphical desktop environments, run `/login openai-codex-browser` (or select **ChatGPT Plus/Pro (Browser callback)**). This workflow opens your default browser and receives tokens through a local loopback listener on port 1455 (`http://localhost:1455/auth/callback`).\n\n## 2. OpenAI Platform API (usage-based billing)\n\nTo use pay-as-you-go OpenAI Platform access, configure the `OPENAI_API_KEY` environment variable or provide it in `models.yml`:\n\n```bash\nexport OPENAI_API_KEY=\"<OPENAI_API_KEY>\"\nxcsh\n```\n\nAfter starting xcsh, select an OpenAI model using `/model`.\n",
147
+ "en/providers/openai-api-access.md": "---\ntitle: OpenAI access\ndescription: Choose ChatGPT subscription or usage-based OpenAI Platform access in xcsh.\nsidebar:\n order: 4\n label: OpenAI access\n---\n\nxcsh supports two distinct access methods for OpenAI services, depending on whether you authenticate with a ChatGPT subscription or an OpenAI Platform API key.\n\n## 1. ChatGPT subscription (OAuth)\n\nTo use your ChatGPT Plus or Pro subscription, run `/login openai-codex` or select **ChatGPT Plus/Pro (Codex Subscription)** in the `/login` interactive picker. xcsh stores the OAuth credentials securely in `agent.db` and discovers advertised subscription models.\n\n### Headless and SSH remote login\n\nWhen running inside an SSH session or headless terminal, xcsh automatically initiates device-code authentication:\n\n1. xcsh displays `https://auth.openai.com/codex/device` and a one-time verification code.\n2. Open the URL in any web browser and log in with your ChatGPT account credentials.\n3. Enter the one-time verification code.\n4. The remote xcsh process detects authorization automatically and completes authentication.\n\n> [!NOTE]\n> Device-code authentication requires enabling the beta feature in ChatGPT under **Settings → Security**, or via workspace administrator settings under **Workspace settings → Permissions & roles**.\n\n### Local desktop browser callback\n\nOn local workstations with graphical desktop environments, the same `/login openai-codex` command opens your default browser and receives tokens through a local loopback listener on port 1455 (`http://localhost:1455/auth/callback`).\n\nIf device authorization is unavailable in an SSH or headless session, the same login flow offers browser/manual redirect authentication. Open the displayed URL locally, then paste the redirect URL into `/login <redirect URL>` on the remote host.\n\n## 2. OpenAI Platform API (usage-based billing)\n\nTo use pay-as-you-go OpenAI Platform access, configure the `OPENAI_API_KEY` environment variable or provide it in `models.yml`:\n\n```bash\nexport OPENAI_API_KEY=\"<OPENAI_API_KEY>\"\nxcsh\n```\n\nAfter starting xcsh, select an OpenAI model using `/model`.\n",
148
148
  "en/providers/provider-streaming-internals.md": "---\ntitle: Provider Streaming Internals\ndescription: Provider streaming implementation with SSE parsing, token counting, and backpressure handling.\nsidebar:\n order: 2\n label: Streaming internals\n---\n\nThis document describes how token and tool call streams from diverse LLM providers are normalized in `@f5-sales-demo/pi-ai` and propagated through `@f5-sales-demo/pi-agent-core` to `coding-agent` session events.\n\n## End-to-end streaming architecture\n\n1. **Stream dispatch**: `streamSimple()` in `packages/ai/src/stream.ts` maps provider-agnostic request options and dispatches them to the selected provider driver.\n2. **Provider normalization**: Provider stream drivers (`anthropic.ts`, `openai-responses.ts`, `google.ts`) translate vendor-specific Server-Sent Events (SSE) into a unified `AssistantMessageEvent` stream.\n3. **Event throttling**: `AssistantMessageEventStream` (`packages/ai/src/utils/event-stream.ts`) buffers and coalesces rapid delta events (~50ms cadence) to smooth UI rendering.\n4. **Agent loop consumption**: `agentLoop` (`packages/agent/src/agent-loop.ts`) processes events, updates in-flight message state, and emits `message_update` events.\n5. **Session event integration**: `AgentSession` (`packages/coding-agent/src/session/agent-session.ts`) handles user aborts, automated retries, context compaction, and tool call execution guards.\n\n## Unified stream contract (`AssistantMessageEvent`)\n\nAll provider drivers emit events conforming to the `AssistantMessageEvent` union:\n\n- `start`: Initiates stream processing.\n- Content block lifecycle triplets:\n - Text: `text_start` → `text_delta`* → `text_end`\n - Thinking / reasoning: `thinking_start` → `thinking_delta`* → `thinking_end`\n - Tool invocation: `toolcall_start` → `toolcall_delta`* → `toolcall_end`\n- Terminal events:\n - `done`: Emits termination reasons (`stop`, `length`, `toolUse`).\n - `error`: Emits failure reasons (`aborted`, `error`).\n\n## Provider-specific normalization logic\n\n### Anthropic (`anthropic-messages`)\n\n- Maps `message_start` to token usage metadata.\n- Maps `content_block_start` and `content_block_delta` to unified text, thinking, and tool invocation blocks.\n- Accumulates streamed JSON fragments in `partialJson` and reparses arguments incrementally via `parseStreamingJson()`.\n\n### OpenAI Responses (`openai-responses`)\n\n- Maps `response.output_item.added` to text or reasoning blocks.\n- Maps `response.reasoning_summary_text.delta` to `thinking_delta`.\n- Translates `response.function_call_arguments.delta` into `toolcall_delta`.\n- Normalizes tool call identifiers into `<CALL_ID>|<ITEM_ID>`.\n\n### Google Generative AI (`google-generative-ai`)\n\n- Parses `candidate.content.parts`, distinguishing thinking blocks via `isThinkingPart()`.\n- Emits synthetic `toolcall_delta` containing serialized JSON strings for structured tool call events.\n\n## Tool call JSON accumulation and error recovery\n\nIncremental tool arguments are parsed via `parseStreamingJson()` (`packages/ai/src/utils/json-parse.ts`):\n\n1. Attempts standard `JSON.parse`.\n2. Falls back to partial JSON parsing to evaluate incomplete streamed fragments.\n3. If partial parsing fails, returns `{}` temporarily until subsequent deltas provide valid JSON structures.\n4. Performs a final parse pass on `toolcall_end`.\n\n## Cancellation and lifecycle boundaries\n\n- **Provider HTTP request**: `options.signal` aborts the active HTTP transport connection.\n- **Agent loop**: Evaluates `signal.aborted` prior to processing each streamed event.\n- **Session abortion**: Calling `AgentSession.abort()` propagates cancellations to active tool subprocesses.\n- **Tool execution interrupts**: Tool runners listen to `AbortSignal.any([agentSignal, steeringAbortSignal])`, allowing users to interrupt long-running tools without discarding prior turns.\n\n## Related implementation files\n\n- `packages/ai/src/stream.ts`: Provider stream dispatcher and option normalizer.\n- `packages/ai/src/utils/event-stream.ts`: Stream queueing and delta event throttling.\n- `packages/ai/src/utils/json-parse.ts`: Incremental streaming JSON parser.\n- `packages/ai/src/providers/anthropic.ts`: Anthropic SSE event transformer.\n- `packages/ai/src/providers/openai-responses.ts`: OpenAI Responses event transformer.\n- `packages/ai/src/providers/google.ts`: Google Gemini event transformer.\n- `packages/agent/src/agent-loop.ts`: Agent event processing loop.\n- `packages/coding-agent/src/session/agent-session.ts`: Session lifecycle, retry policies, and persistence.\n",
149
149
  "en/providers/python-repl.md": "---\ntitle: Python Tool and IPython Runtime\ndescription: Python REPL tool runtime with IPython kernel management, execution, and output capture.\nsidebar:\n order: 3\n label: Python & IPython\n---\n\nThis document describes the Python execution architecture in `packages/coding-agent`, covering tool parameters, Jupyter Kernel Gateway lifecycles, environment isolation, execution semantics, and troubleshooting procedures.\n\n## Architecture overview\n\nPython execution is managed across several core modules:\n\n- `src/tools/python.ts`: Tool interface definition and interactive cell renderers.\n- `src/ipy/executor.ts`: Session-level kernel orchestration and execution scheduling.\n- `src/ipy/kernel.ts`: Kernel lifecycle management and WebSocket communication protocol.\n- `src/ipy/gateway-coordinator.ts`: Shared local Jupyter Kernel Gateway process coordinator.\n- `src/ipy/runtime.ts`: Python environment discovery, virtualenv resolution, and environment variable filtering.\n\n## Tool parameter schema\n\n```typescript\ninterface PythonToolParams {\n cells: Array<{\n code: string;\n title?: string;\n }>;\n timeout?: number; // Execution timeout in seconds (1–600, default: 30)\n cwd?: string; // Working directory for execution\n reset?: boolean; // Reset kernel state prior to executing first cell\n}\n```\n\nThe tool executes with `concurrency: \"exclusive\"`, ensuring sequential execution per session.\n\n## Gateway lifecycle management\n\n### Gateway operational modes\n\n1. **Local shared gateway (default)**:\n - Coordinates a single shared process under `~/.xcsh/agent/python-gateway/`.\n - Synchronizes access via `gateway.lock` and tracks state in `gateway.json`.\n - Starts `python -m kernel_gateway` bound to an ephemeral port on `127.0.0.1`.\n2. **External gateway**:\n - Configured via `PI_PYTHON_GATEWAY_URL`.\n - Authenticates requests with `PI_PYTHON_GATEWAY_TOKEN` when required.\n - Bypasses local process management.\n\n### Kernel lifecycle\n\n1. **Creation**: Allocates a new kernel session via `POST /api/kernels`.\n2. **Connection**: Establishes a WebSocket channel (`/api/kernels/:id/channels`).\n3. **Initialization**: Configures `cwd`, applies sanitized environment variables, and executes runtime preludes.\n4. **Module loading**: Imports custom modules from `~/.xcsh/agent/modules/*.py` and project-specific `<CWD>/.xcsh/modules/*.py`.\n5. **Termination**: Issues `DELETE /api/kernels/:id` and closes WebSocket connections upon session cleanup.\n\n## Environment isolation and security\n\nBefore launching Python runtimes, xcsh filters environment variables:\n\n- **Retained variables**: Core system variables (`PATH`, `HOME`, `VIRTUAL_ENV`, `PYTHONPATH`) and whitelisted prefixes (`LC_*`, `XDG_*`, `PI_*`).\n- **Sanitized variables**: Strips sensitive LLM API keys (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`) to prevent accidental leaks.\n\n### Python environment resolution order\n\n1. Active virtual environment (`VIRTUAL_ENV` or `<CWD>/.venv`)\n2. Managed xcsh virtual environment (`~/.xcsh/python-env`)\n3. System `python3` or `python` on `PATH`\n\n## Output capture and MIME rendering\n\nThe runtime captures structured output across multiple MIME types:\n\n- `text/markdown`: Rendered directly in interactive output panes.\n- `text/plain`: Standard console output and tracebacks.\n- `application/json`: Formatted JSON inspector trees.\n- `image/png`: Inline image payloads.\n- `application/x-xcsh-status`: Structured progress and execution status events.\n\n## Troubleshooting\n\n### Python tool unavailable\n\n- Verify that `jupyter_kernel_gateway` and `ipykernel` are installed in the resolved Python environment:\n\n ```bash\n python -m pip install jupyter_kernel_gateway ipykernel\n ```\n\n- Verify that `python.toolMode` or `PI_PY` is not set to `bash-only`.\n\n### Execution timeouts or hangs\n\n- Python does not support interactive standard input (`input()`). Avoid invoking interactive prompts.\n- To handle long-running workloads, increase the `timeout` parameter (up to 600 seconds).\n",
150
150
  "en/runtime-tools/bash-tool-runtime.md": "---\ntitle: Bash Tool Runtime\ndescription: Bash tool runtime with shell process management, sandboxing, timeout, and output streaming.\nsidebar:\n order: 1\n label: Bash tool\n---\n\nThis document describes the execution pipeline of the `bash` tool in `packages/coding-agent`, covering command normalization, interception rules, process sandboxing, output truncation, and UI rendering across execution modes.\n\n## Execution entry points\n\nxcsh provides two distinct shell execution interfaces:\n\n1. **Agent tool interface (`bash`)**: Invoked by LLMs during conversation turns. Supports command normalization, security interception, PTY emulation, and structured output formatting.\n - Entry point: `BashTool.execute()`\n2. **User shell execution (`!cmd`)**: Direct shell execution triggered by user input in the TUI or RPC mode.\n - Entry point: `AgentSession.executeBash()`\n\nBoth execution paths utilize the underlying `executeBash()` engine in `src/exec/bash-executor.ts` for non-interactive execution.\n\n## Execution pipeline\n\n### 1. Command normalization and argument parsing\n\nWhen a tool call occurs, `BashTool.execute()` normalizes command strings via `normalizeBashCommand()`:\n\n- Extracts trailing pipe limits (`| head -n N`, `| tail -n N`) into structured pagination parameters.\n- Trims outer whitespace while preserving internal arguments and heredocs.\n- Merges extracted limits with explicit `head` or `tail` parameters (explicit arguments take precedence).\n\n### 2. Command interception and rule enforcement\n\nIf `bashInterceptor.enabled` is active, the tool checks the command against configured regex rules before spawning processes:\n\n- **Rule evaluation**: Blocks commands when patterns match (e.g., using `cat` or `grep` when specialized tools exist) and the suggested alternative tool is present in active context (`ctx.toolNames`).\n- **Rejection behavior**: Raises a `ToolError` containing the blocking reason and guidance toward preferred tools (`view_file`, `grep_search`, `list_dir`, `replace_file_content`).\n\n### 3. Working directory validation and timeouts\n\n- Resolves working directories relative to session root (`resolveToCwd`).\n- Validates that directory paths exist and are accessible directories prior to execution.\n- Clamps timeout durations to the range of 1 to 3600 seconds (default: 30 seconds).\n\n### 4. Interactive PTY vs. non-interactive execution\n\nxcsh chooses PTY execution (`runInteractiveBashPty`) when all of the following conditions are met:\n\n- `bash.virtualTerminal` is configured to `on`.\n- `PI_NO_PTY` is not set to `1`.\n- The session has an active graphical/TUI terminal context (`ctx.hasUI === true`).\n\nIn headless, print, or RPC modes, xcsh always uses non-interactive execution.\n\n## Output streaming, truncation, and artifact spill\n\nOutput is processed through `OutputSink` in `src/session/streaming-output.ts`:\n\n- **Memory buffer**: Maintains a UTF-8-safe tail buffer (default: 50 KB).\n- **Artifact spillover**: When output exceeds the buffer threshold, xcsh writes the full stream to disk in artifact storage (`artifact://<ID>`).\n- **Truncation notice**: Injects truncation summaries into tool results, including total byte/line counts and artifact links for full-output retrieval.\n\n## Filesystem sandboxing and containment\n\nxcsh enforces filesystem containment boundaries using platform-native security primitives:\n\n| Platform | Kernel version | Security backend | Boundary enforcement |\n| -------- | -------------- | ---------------- | -------------------- |\n| macOS | Any | `seatbelt` | OS-enforced kernel sandbox |\n| Linux (modern) | Kernel ≥ 6.1 (Debian 12, Ubuntu 24.04, Fedora) | `landlock` (ABI ≥ 2) | OS-enforced kernel sandbox |\n| Linux (legacy) | Kernel < 6.1 (RHEL 9, Ubuntu 22.04 GA) | `scanner-only` | Command-text static analysis |\n\n> [!IMPORTANT]\n> On legacy Linux kernels with Landlock ABI 1 (kernel < 6.1), xcsh operates in `scanner-only` mode because ABI 1 lacks `LANDLOCK_ACCESS_FS_REFER` (which prevents `git` and `mv` operations). For full OS-enforced isolation, deploy on Linux kernels 6.1 or newer.\n\n## Related implementation files\n\n- `src/tools/bash.ts`: Tool definition, normalization, and rendering logic.\n- `src/tools/bash-normalize.ts`: Command string normalization and line limit extraction.\n- `src/tools/bash-interceptor.ts`: Pattern matching rules for command redirection.\n- `src/exec/bash-executor.ts`: Process execution engine and shell session reuse.\n- `src/tools/bash-interactive.ts`: Virtual PTY runtime and terminal input handling.\n- `src/session/streaming-output.ts`: `OutputSink` buffer management and artifact spillover.\n",
@@ -67,6 +67,29 @@ interface ScopedModelItem {
67
67
  thinkingLevel?: string;
68
68
  }
69
69
 
70
+ export interface DefaultPickerModelPresentation {
71
+ model: Model;
72
+ displaySelector: string;
73
+ selector: string;
74
+ }
75
+
76
+ const OPENAI_CODEX_GPT56_TIERS = new Set(["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol"]);
77
+
78
+ /** Collapse subscription tiers only in the ordinary picker; explicit --models scopes keep raw access. */
79
+ export function presentModelsForDefaultPicker(
80
+ models: readonly Model[],
81
+ explicitlyScoped = false,
82
+ ): DefaultPickerModelPresentation[] {
83
+ return models.flatMap(model => {
84
+ const selector = `${model.provider}/${model.id}`;
85
+ if (explicitlyScoped || model.provider !== "openai-codex" || !OPENAI_CODEX_GPT56_TIERS.has(model.id)) {
86
+ return [{ model, displaySelector: selector, selector }];
87
+ }
88
+ if (model.id !== "gpt-5.6-sol") return [];
89
+ return [{ model, displaySelector: "openai-codex/gpt-5.6", selector }];
90
+ });
91
+ }
92
+
70
93
  interface RoleAssignment {
71
94
  model: Model;
72
95
  thinkingLevel: ThinkingLevel;
@@ -359,12 +382,15 @@ export class ModelSelectorComponent extends Container {
359
382
 
360
383
  // Use scoped models if provided via --models flag
361
384
  if (this.#scopedModels.length > 0) {
362
- models = this.#scopedModels.map(scoped => ({
385
+ models = presentModelsForDefaultPicker(
386
+ this.#scopedModels.map(scoped => scoped.model),
387
+ true,
388
+ ).map(item => ({
363
389
  kind: "provider",
364
- provider: scoped.model.provider,
365
- id: scoped.model.id,
366
- model: scoped.model,
367
- selector: `${scoped.model.provider}/${scoped.model.id}`,
390
+ provider: item.model.provider,
391
+ id: item.displaySelector.slice(item.displaySelector.indexOf("/") + 1),
392
+ model: item.model,
393
+ selector: item.selector,
368
394
  }));
369
395
  } else {
370
396
  // Reload config and cached discovery state without blocking on live provider refresh
@@ -381,12 +407,12 @@ export class ModelSelectorComponent extends Container {
381
407
  // Load available models (built-in models still work even if models.json failed)
382
408
  try {
383
409
  const availableModels = this.#modelRegistry.getAvailable();
384
- models = availableModels.map((model: Model) => ({
410
+ models = presentModelsForDefaultPicker(availableModels).map(item => ({
385
411
  kind: "provider",
386
- provider: model.provider,
387
- id: model.id,
388
- model,
389
- selector: `${model.provider}/${model.id}`,
412
+ provider: item.model.provider,
413
+ id: item.displaySelector.slice(item.displaySelector.indexOf("/") + 1),
414
+ model: item.model,
415
+ selector: item.selector,
390
416
  }));
391
417
  } catch (error) {
392
418
  this.#allModels = [];
@@ -428,7 +454,28 @@ export class ModelSelectorComponent extends Container {
428
454
  compactSearchText: compactSearchText(searchText),
429
455
  };
430
456
  })
431
- .filter((item): item is CanonicalModelItem => item !== undefined);
457
+ .filter((item): item is CanonicalModelItem => item !== undefined)
458
+ .filter(
459
+ item =>
460
+ this.#scopedModels.length > 0 ||
461
+ item.model.provider !== "openai-codex" ||
462
+ !OPENAI_CODEX_GPT56_TIERS.has(item.model.id),
463
+ );
464
+ if (this.#scopedModels.length === 0) {
465
+ const friendly = models.find(item => item.provider === "openai-codex" && item.id === "gpt-5.6");
466
+ if (friendly) {
467
+ canonicalModels.push({
468
+ kind: "canonical",
469
+ id: "openai-codex/gpt-5.6",
470
+ model: friendly.model,
471
+ selector: friendly.selector,
472
+ variantCount: 1,
473
+ searchText: "openai-codex/gpt-5.6 GPT-5.6 Sol",
474
+ normalizedSearchText: normalizeSearchText("openai-codex/gpt-5.6 GPT-5.6 Sol"),
475
+ compactSearchText: compactSearchText("openai-codex/gpt-5.6 GPT-5.6 Sol"),
476
+ });
477
+ }
478
+ }
432
479
 
433
480
  this.#sortModels(models);
434
481
  this.#sortCanonicalModels(canonicalModels);
@@ -1,5 +1,6 @@
1
1
  import { ThinkingLevel } from "@f5-sales-demo/pi-agent-core";
2
2
  import { canonicalizeOAuthProviderId, type Model } from "@f5-sales-demo/pi-ai";
3
+ import type { Settings } from "../../config/settings";
3
4
  import type { VllmDiscoveredModel } from "../../config/vllm-config";
4
5
  import { applySubscriptionProfileRoles, type SubscriptionProfileId } from "../../routing/subscription-profiles";
5
6
 
@@ -42,10 +43,10 @@ export const GOOGLE_ANTIGRAVITY_LOGIN_MODEL_CHOICE: LoginModelChoice = {
42
43
  };
43
44
 
44
45
  export const OPENAI_CODEX_LOGIN_MODEL_CHOICE: LoginModelChoice = {
45
- label: "GPT-5.6 Terra",
46
- description: "Balanced OpenAI Codex subscription model with medium reasoning",
46
+ label: "GPT-5.6",
47
+ description: "OpenAI Codex subscription model with medium reasoning",
47
48
  provider: "openai-codex",
48
- modelId: "gpt-5.6-terra",
49
+ modelId: "gpt-5.6-sol",
49
50
  thinkingLevel: ThinkingLevel.Medium,
50
51
  };
51
52
 
@@ -83,12 +84,7 @@ interface ModelApplicableSession extends BaseModelApplicableSession {
83
84
  getAll(): Model[];
84
85
  getProviderDiscoveryState?(provider: string): { status: string; stale: boolean } | undefined;
85
86
  };
86
- settings?: {
87
- getModelRoles(): Readonly<Record<string, string | undefined>>;
88
- get?(key: "routing.profile"): "none" | SubscriptionProfileId;
89
- set(key: "modelRoles", value: Record<string, string>): void;
90
- set(key: "routing.profile", value: "none" | SubscriptionProfileId): void;
91
- };
87
+ settings?: Pick<Settings, "getModelRoles" | "get" | "set">;
92
88
  }
93
89
 
94
90
  /**
@@ -138,7 +134,12 @@ export async function applyOAuthLoginModel(
138
134
  }
139
135
 
140
136
  const settings = session.settings;
141
- const previousProfile = settings?.get?.("routing.profile") ?? "none";
137
+ const storedProfile = settings?.get("routing.profile");
138
+ const previousProfile: "none" | SubscriptionProfileId =
139
+ storedProfile === "google-antigravity" || storedProfile === "openai-codex" ? storedProfile : "none";
140
+ const storedRoutingMode = settings?.get("routing.mode");
141
+ const previousRoutingMode =
142
+ storedRoutingMode === "shadow" || storedRoutingMode === "auto" ? storedRoutingMode : "off";
142
143
  const previousRoles = settings
143
144
  ? Object.fromEntries(
144
145
  Object.entries(settings.getModelRoles()).filter(
@@ -155,6 +156,7 @@ export async function applyOAuthLoginModel(
155
156
  );
156
157
  if (!profile.applied) return undefined;
157
158
  settings.set("modelRoles", profile.roles);
159
+ settings.set("routing.mode", "off");
158
160
  settings.set("routing.profile", canonicalProvider as SubscriptionProfileId);
159
161
  }
160
162
 
@@ -162,12 +164,14 @@ export async function applyOAuthLoginModel(
162
164
  const applied = await applyModelAfterLogin(session, choice);
163
165
  if (!applied && settings) {
164
166
  settings.set("modelRoles", previousRoles);
167
+ settings.set("routing.mode", previousRoutingMode);
165
168
  settings.set("routing.profile", previousProfile);
166
169
  }
167
170
  return applied ? choice : undefined;
168
171
  } catch (error) {
169
172
  if (settings) {
170
173
  settings.set("modelRoles", previousRoles);
174
+ settings.set("routing.mode", previousRoutingMode);
171
175
  settings.set("routing.profile", previousProfile);
172
176
  }
173
177
  throw error;
@@ -86,7 +86,6 @@ import { commitVllmLogin } from "./vllm-login-transaction";
86
86
 
87
87
  const CALLBACK_SERVER_PROVIDERS = new Set<OAuthProvider>([
88
88
  "anthropic",
89
- "openai-codex-browser",
90
89
  "gitlab-duo",
91
90
  "google-gemini-cli",
92
91
  "google-antigravity",
@@ -1225,8 +1224,7 @@ export class SelectorController {
1225
1224
  this.ctx.showStatus(`Logging in to ${providerId}…`);
1226
1225
  const manualInput = this.ctx.oauthManualInput;
1227
1226
  const useManualInput =
1228
- CALLBACK_SERVER_PROVIDERS.has(providerId as OAuthProvider) ||
1229
- (providerId === "openai-codex" && resolveOpenAICodexLoginMethod() === "browser");
1227
+ CALLBACK_SERVER_PROVIDERS.has(providerId as OAuthProvider) || providerId === "openai-codex";
1230
1228
  const shouldOpenBrowser = providerId !== "openai-codex" || resolveOpenAICodexLoginMethod() === "browser";
1231
1229
  const loginCallbacks = {
1232
1230
  onAuth: (info: { url: string; instructions?: string }) => {
@@ -39,7 +39,7 @@ export const SUBSCRIPTION_ROUTING_PROFILES: Readonly<Record<SubscriptionProfileI
39
39
  provider: "openai-codex",
40
40
  roles: {
41
41
  smol: "openai-codex/gpt-5.6-luna:low",
42
- default: "openai-codex/gpt-5.6-terra:medium",
42
+ default: "openai-codex/gpt-5.6-sol:medium",
43
43
  slow: "openai-codex/gpt-5.6-sol:high",
44
44
  plan: "openai-codex/gpt-5.6-sol:high",
45
45
  },