@f5-sales-demo/xcsh 19.54.0 → 19.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "19.54.0",
4
+ "version": "19.55.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -55,13 +55,13 @@
55
55
  "dependencies": {
56
56
  "@agentclientprotocol/sdk": "0.16.1",
57
57
  "@mozilla/readability": "^0.6",
58
- "@f5-sales-demo/xcsh-stats": "19.54.0",
59
- "@f5-sales-demo/pi-agent-core": "19.54.0",
60
- "@f5-sales-demo/pi-ai": "19.54.0",
61
- "@f5-sales-demo/pi-natives": "19.54.0",
62
- "@f5-sales-demo/pi-resource-management": "19.54.0",
63
- "@f5-sales-demo/pi-tui": "19.54.0",
64
- "@f5-sales-demo/pi-utils": "19.54.0",
58
+ "@f5-sales-demo/xcsh-stats": "19.55.0",
59
+ "@f5-sales-demo/pi-agent-core": "19.55.0",
60
+ "@f5-sales-demo/pi-ai": "19.55.0",
61
+ "@f5-sales-demo/pi-natives": "19.55.0",
62
+ "@f5-sales-demo/pi-resource-management": "19.55.0",
63
+ "@f5-sales-demo/pi-tui": "19.55.0",
64
+ "@f5-sales-demo/pi-utils": "19.55.0",
65
65
  "@sinclair/typebox": "^0.34",
66
66
  "@xterm/headless": "^6.0",
67
67
  "ajv": "^8.20",
@@ -54,6 +54,7 @@ const output = [
54
54
  "export interface ExtensionCapabilities {",
55
55
  "\treadonly version: string;",
56
56
  "\treadonly contractVersion: string;",
57
+ "\treadonly multiPortDiscovery?: boolean;",
57
58
  "\treadonly protocol: string;",
58
59
  "\treadonly tools: readonly ExtensionToolDef[];",
59
60
  "\treadonly features: Record<string, unknown>;",
@@ -18,6 +18,7 @@ export interface ExtensionToolDef {
18
18
  export interface ExtensionCapabilities {
19
19
  readonly version: string;
20
20
  readonly contractVersion: string;
21
+ readonly multiPortDiscovery?: boolean;
21
22
  readonly protocol: string;
22
23
  readonly tools: readonly ExtensionToolDef[];
23
24
  readonly features: Record<string, unknown>;
@@ -25,7 +26,8 @@ export interface ExtensionCapabilities {
25
26
 
26
27
  export const EXTENSION_CAPABILITIES: ExtensionCapabilities = {
27
28
  "version": "0.1.0",
28
- "contractVersion": "1.4.0",
29
+ "contractVersion": "1.5.0",
30
+ "multiPortDiscovery": true,
29
31
  "protocol": "tool_request/result",
30
32
  "tools": [
31
33
  {
@@ -613,6 +615,16 @@ export const EXTENSION_CAPABILITIES: ExtensionCapabilities = {
613
615
  },
614
616
  "flags": {}
615
617
  },
618
+ {
619
+ "name": "diag_bridges",
620
+ "summary": "List discovered xcsh bridges (port, tenant, env, sessionId, lastSeen) for multi-session diagnostics.",
621
+ "category": "read",
622
+ "params": {
623
+ "type": "object",
624
+ "properties": {}
625
+ },
626
+ "flags": {}
627
+ },
616
628
  {
617
629
  "name": "capture_login_flow",
618
630
  "summary": "Diagnostic: captured login redirect chain annotated with tenant/env (Phase 0b).",
@@ -813,6 +825,6 @@ export const EXTENSION_CAPABILITIES: ExtensionCapabilities = {
813
825
  }
814
826
  };
815
827
 
816
- export const EXTENSION_CONTRACT_VERSION = "1.4.0";
828
+ export const EXTENSION_CONTRACT_VERSION = "1.5.0";
817
829
 
818
- export const EXTENSION_TOOL_NAMES: readonly string[] = ["ping","capabilities","reload","debug_exec","detach","set_bridge_port","navigate","login","scroll_to","resize_window","tabs_list","tabs_create","tabs_close","click","click_element","click_xy","type_text","form_input","key_press","select_option","label_select","file_upload","read_ax","get_page_text","query_dom","find","wait_for","assert_text","screenshot","read_console","read_network","diag_suspension","capture_login_flow","wait_for_api_response","get_page_context","javascript_tool","browser_batch","set_explain_mode","annotate"];
830
+ export const EXTENSION_TOOL_NAMES: readonly string[] = ["ping","capabilities","reload","debug_exec","detach","set_bridge_port","navigate","login","scroll_to","resize_window","tabs_list","tabs_create","tabs_close","click","click_element","click_xy","type_text","form_input","key_press","select_option","label_select","file_upload","read_ax","get_page_text","query_dom","find","wait_for","assert_text","screenshot","read_console","read_network","diag_suspension","diag_bridges","capture_login_flow","wait_for_api_response","get_page_context","javascript_tool","browser_batch","set_explain_mode","annotate"];
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "version": "0.1.0",
3
- "contractVersion": "1.4.0",
3
+ "contractVersion": "1.5.0",
4
+ "multiPortDiscovery": true,
4
5
  "protocol": "tool_request/result",
5
6
  "tools": [
6
7
  {
@@ -539,6 +540,16 @@
539
540
  },
540
541
  "flags": {}
541
542
  },
543
+ {
544
+ "name": "diag_bridges",
545
+ "summary": "List discovered xcsh bridges (port, tenant, env, sessionId, lastSeen) for multi-session diagnostics.",
546
+ "category": "read",
547
+ "params": {
548
+ "type": "object",
549
+ "properties": {}
550
+ },
551
+ "flags": {}
552
+ },
542
553
  {
543
554
  "name": "capture_login_flow",
544
555
  "summary": "Diagnostic: captured login redirect chain annotated with tenant/env (Phase 0b).",
@@ -1,5 +1,5 @@
1
1
  {
2
- "contractVersion": "1.4.0",
2
+ "contractVersion": "1.5.0",
3
3
  "schemas": {
4
4
  "chat_request": {
5
5
  "type": "object",
@@ -67,6 +67,25 @@ export function resolvePort(port?: number): number {
67
67
  return DEFAULT_PORT;
68
68
  }
69
69
 
70
+ /** Inclusive loopback discovery range for auto-selected bridge ports. */
71
+ export const PORT_RANGE_START = 19222;
72
+ export const PORT_RANGE_END = 19241;
73
+
74
+ /** Every port in the discovery range, lowest first. */
75
+ export function portCandidates(): number[] {
76
+ const out: number[] = [];
77
+ for (let p = PORT_RANGE_START; p <= PORT_RANGE_END; p++) out.push(p);
78
+ return out;
79
+ }
80
+
81
+ /** The explicitly forced port (arg → XCSH_BRIDGE_PORT), or null to auto-select. */
82
+ export function resolveForcedPort(port?: number): number | null {
83
+ if (typeof port === "number" && Number.isFinite(port) && port > 0) return port;
84
+ const env = Number(process.env.XCSH_BRIDGE_PORT);
85
+ if (Number.isFinite(env) && env > 0) return env;
86
+ return null;
87
+ }
88
+
70
89
  /**
71
90
  * Loopback WebSocket server bridging xcsh to the Chrome extension. Speaks JSON
72
91
  * over WS frames: requests `{type:"tool_request",...}`, replies
@@ -159,56 +178,58 @@ export class BridgeServer {
159
178
  this.#resolveClient(channelId)?.send(JSON.stringify(payload));
160
179
  }
161
180
 
162
- /** Bind the WebSocket server to a loopback port. Called by {@link startBridgeServer}. */
163
- listen(port: number, opts?: { skipOriginCheck?: boolean }): void {
164
- this.#server = Bun.serve({
165
- port,
166
- hostname: "127.0.0.1",
167
- fetch: (req, server) => {
168
- // Validate the Origin header: only the xcsh Chrome extension may connect.
169
- // This restores the access-control guarantee that the Unix socket's 0o600
170
- // permissions previously provided.
171
- if (!opts?.skipOriginCheck) {
172
- const origin = req.headers.get("origin") ?? "";
173
- const { EXTENSION_ID } = require("../cli/chrome-cli");
174
- if (origin !== `chrome-extension://${EXTENSION_ID}`) {
175
- return new Response("Forbidden", { status: 403 });
181
+ #onOpen(ws: ServerWebSocket<undefined>): void {
182
+ // Assign a channel ID to each connection. For backwards compat (single
183
+ // extension), the first connection gets "default". Additional connections
184
+ // get "ch-1", "ch-2", etc. — supporting multi-tab parallelism.
185
+ const channelId = this.#clients.size === 0 ? "default" : `ch-${++this.#nextChannelIndex}`;
186
+ (ws as unknown as { channelId: string }).channelId = channelId;
187
+ this.#clients.set(channelId, ws);
188
+ // Start a heartbeat ping to keep the MV3 service worker alive.
189
+ // Chrome suspends idle SWs after ~30s; a ping every 15s prevents that.
190
+ if (!this.#heartbeat) {
191
+ this.#heartbeat = setInterval(() => {
192
+ for (const c of this.#clients.values()) {
193
+ try {
194
+ c.send(JSON.stringify({ type: "ping" }));
195
+ } catch {
196
+ /* client may have dropped */
176
197
  }
177
198
  }
178
- if (server.upgrade(req)) return undefined;
179
- return new Response("xcsh bridge: WebSocket only", { status: 426 });
180
- },
181
- websocket: {
182
- open: ws => {
183
- // Assign a channel ID to each connection. For backwards compat (single
184
- // extension), the first connection gets "default". Additional connections
185
- // get "ch-1", "ch-2", etc. supporting multi-tab parallelism.
186
- const channelId = this.#clients.size === 0 ? "default" : `ch-${++this.#nextChannelIndex}`;
187
- (ws as unknown as { channelId: string }).channelId = channelId;
188
- this.#clients.set(channelId, ws);
189
- // Start a heartbeat ping to keep the MV3 service worker alive.
190
- // Chrome suspends idle SWs after ~30s; a ping every 15s prevents that.
191
- if (!this.#heartbeat) {
192
- this.#heartbeat = setInterval(() => {
193
- for (const c of this.#clients.values()) {
194
- try {
195
- c.send(JSON.stringify({ type: "ping" }));
196
- } catch {
197
- /* client may have dropped */
198
- }
199
- }
200
- }, 15_000);
199
+ }, 15_000);
200
+ }
201
+ for (const cb of this.#onConnected) cb();
202
+ }
203
+
204
+ /** Try to bind the loopback WS server to `port`. Returns false on EADDRINUSE
205
+ * (so the caller can try the next candidate); rethrows any other error. */
206
+ listen(port: number, opts?: { skipOriginCheck?: boolean }): boolean {
207
+ try {
208
+ this.#server = Bun.serve({
209
+ port,
210
+ hostname: "127.0.0.1",
211
+ fetch: (req, server) => {
212
+ if (!opts?.skipOriginCheck) {
213
+ const origin = req.headers.get("origin") ?? "";
214
+ const { EXTENSION_ID } = require("../cli/chrome-cli");
215
+ if (origin !== `chrome-extension://${EXTENSION_ID}`) {
216
+ return new Response("Forbidden", { status: 403 });
217
+ }
201
218
  }
202
- for (const cb of this.#onConnected) cb();
203
- },
204
- message: (ws, message) => {
205
- this.#handleMessage(ws, message);
219
+ if (server.upgrade(req)) return undefined;
220
+ return new Response("xcsh bridge: WebSocket only", { status: 426 });
206
221
  },
207
- close: ws => {
208
- this.#onClose(ws);
222
+ websocket: {
223
+ open: ws => this.#onOpen(ws),
224
+ message: (ws, message) => this.#handleMessage(ws, message),
225
+ close: ws => this.#onClose(ws),
209
226
  },
210
- },
211
- });
227
+ });
228
+ return true;
229
+ } catch (e) {
230
+ if (e instanceof Error && /EADDRINUSE|address already in use|in use/i.test(e.message)) return false;
231
+ throw e;
232
+ }
212
233
  }
213
234
 
214
235
  #handleMessage(ws: ServerWebSocket<undefined>, message: string | Buffer): void {
@@ -229,10 +250,12 @@ export class BridgeServer {
229
250
  } else if (msg.type === "hello") {
230
251
  // Identity handshake: tell the extension which tenant this process serves.
231
252
  const info = this.#sessionInfo?.() ?? { tenant: null, env: null, apiUrl: null };
253
+ const { EXTENSION_CONTRACT_VERSION } = require("./capabilities.generated");
232
254
  ws.send(
233
255
  JSON.stringify({
234
256
  type: "hello_ack",
235
257
  sessionId: this.#sessionId,
258
+ contractVersion: EXTENSION_CONTRACT_VERSION,
236
259
  tenant: info.tenant,
237
260
  env: info.env,
238
261
  apiUrl: info.apiUrl,
@@ -293,12 +316,23 @@ export class BridgeServer {
293
316
  }
294
317
 
295
318
  /**
296
- * Start the {@link BridgeServer} on the resolved loopback port (explicit arg,
297
- * then `XCSH_BRIDGE_PORT`, then {@link DEFAULT_PORT}). The WebSocket transport
319
+ * Start the {@link BridgeServer} on the resolved loopback port. If a port is
320
+ * forced (explicit arg or `XCSH_BRIDGE_PORT`) it must be free throws loudly
321
+ * if taken. Otherwise, auto-selects the lowest free port in the discovery range
322
+ * ({@link PORT_RANGE_START}–{@link PORT_RANGE_END}). The WebSocket transport
298
323
  * needs no filesystem setup — Chrome connects directly to `ws://127.0.0.1:<port>`.
299
324
  */
300
325
  export async function startBridgeServer(port?: number, opts?: { skipOriginCheck?: boolean }): Promise<BridgeServer> {
301
326
  const server = new BridgeServer();
302
- server.listen(resolvePort(port), opts);
303
- return server;
327
+ const forced = resolveForcedPort(port);
328
+ if (forced !== null) {
329
+ if (!server.listen(forced, opts)) {
330
+ throw new Error(`XCSH_BRIDGE_PORT ${forced} is already in use — free it or pick another port`);
331
+ }
332
+ return server;
333
+ }
334
+ for (const candidate of portCandidates()) {
335
+ if (server.listen(candidate, opts)) return server;
336
+ }
337
+ throw new Error(`no free xcsh bridge port in ${PORT_RANGE_START}-${PORT_RANGE_END} — is another app on the range?`);
304
338
  }
@@ -501,7 +501,6 @@ export class ExtensionPageActions implements PageActions {
501
501
  throw new Error(`waitFor "${selector}" timed out after ${ms}ms`);
502
502
  }
503
503
 
504
- // biome-ignore lint/suspicious/useAwait: signature is async (PageActions contract)
505
504
  async screenshot(_file: string): Promise<void> {
506
505
  // Intentionally a no-op for the extension provider. CDP captureScreenshot
507
506
  // transiently FREEZES the MV3 service worker; in observable mode the runner
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { acquirePage, type BrowserProviderStatus, CdpBrowserProvider } from "../browser";
10
- import { resolvePort } from "../browser/extension-bridge";
10
+ import { PORT_RANGE_END, PORT_RANGE_START, resolveForcedPort } from "../browser/extension-bridge";
11
11
 
12
12
  type Settings = { get(key: string): unknown };
13
13
 
@@ -43,12 +43,17 @@ export async function runChromeCommand(action: ChromeAction, settings: Settings)
43
43
  if (action === "status") return renderStatus(await provider.status());
44
44
  if (action === "setup") {
45
45
  // The extension connects directly over a loopback WebSocket — no native-messaging
46
- // host manifest to install. Report the port so the user can point the extension at it.
47
- const port = resolvePort();
46
+ // host manifest to install. A forced port is reported exactly; otherwise xcsh
47
+ // auto-selects the lowest free port in the discovery range at launch.
48
+ const forced = resolveForcedPort();
49
+ const where =
50
+ forced !== null
51
+ ? `ws://127.0.0.1:${forced} (forced via XCSH_BRIDGE_PORT)`
52
+ : `the lowest free port in ${PORT_RANGE_START}-${PORT_RANGE_END} (printed in the xcsh startup banner)`;
48
53
  return (
49
- `The xcsh Chrome extension connects directly to xcsh over a loopback WebSocket on ` +
50
- `ws://127.0.0.1:${port} (override with XCSH_BRIDGE_PORT).\n` +
51
- `Install/keep the xcsh Chrome extension from the Web Store, then it can drive your real Chrome:\n ${WEB_STORE_URL}`
54
+ `The xcsh Chrome extension connects directly to xcsh over a loopback WebSocket on ${where}.\n` +
55
+ `The extension scans that range and links each tenant's xcsh automatically.\n` +
56
+ `Install/keep the xcsh Chrome extension from the Web Store:\n ${WEB_STORE_URL}`
52
57
  );
53
58
  }
54
59
  // relaunch: self-consented rung 3 — force allowRelaunch regardless of the setting.
@@ -0,0 +1,102 @@
1
+ import type { ExtensionAPI } from "@f5-sales-demo/xcsh";
2
+
3
+ /**
4
+ * herdr integration (bundled, default-on).
5
+ *
6
+ * Reports xcsh's live agent state to the herdr terminal multiplexer over its
7
+ * socket API so an xcsh pane shows up as a first-class "xcsh" assistant with an
8
+ * idle / working / blocked indicator.
9
+ *
10
+ * xcsh is a fork of pi; a user may have both installed, so this reporter always
11
+ * identifies itself as "xcsh" (never "pi") and claims pane authority via
12
+ * `--source xcsh` so herdr's passive pi-detection heuristics cannot mislabel the
13
+ * pane.
14
+ *
15
+ * The extension is completely inert unless it is running inside a herdr pane
16
+ * (detected via the `HERDR_PANE_ID` environment variable herdr injects), so it
17
+ * has zero effect for users who do not run xcsh under herdr.
18
+ */
19
+
20
+ const HERDR_AGENT_LABEL = "xcsh";
21
+
22
+ export default function herdrReporter(pi: ExtensionAPI): void {
23
+ const paneId = process.env.HERDR_PANE_ID;
24
+ if (!paneId) {
25
+ // Not running under herdr — do not register anything.
26
+ return;
27
+ }
28
+
29
+ let seq = 0;
30
+ // Tracks whether an interactive prompt is currently awaiting the user, so an
31
+ // agent_end that fires while a prompt is open is reported as blocked, not idle.
32
+ let promptOpen = false;
33
+
34
+ pi.setLabel(HERDR_AGENT_LABEL);
35
+
36
+ // Fire-and-forget: a missing or failing herdr CLI must never disturb the agent.
37
+ const runHerdr = (args: string[]): void => {
38
+ pi.exec("herdr", args).catch((err: unknown) => {
39
+ pi.logger.debug("herdr report failed", {
40
+ error: err instanceof Error ? err.message : String(err),
41
+ });
42
+ });
43
+ };
44
+
45
+ const report = (state: "idle" | "working" | "blocked"): void => {
46
+ runHerdr([
47
+ "pane",
48
+ "report-agent",
49
+ paneId,
50
+ "--source",
51
+ HERDR_AGENT_LABEL,
52
+ "--agent",
53
+ HERDR_AGENT_LABEL,
54
+ "--state",
55
+ state,
56
+ "--seq",
57
+ String(seq++),
58
+ ]);
59
+ };
60
+
61
+ // Announce presence as soon as the session is initialized.
62
+ pi.on("session_start", () => {
63
+ report("idle");
64
+ });
65
+
66
+ // Busy while the agent loop is streaming a response.
67
+ pi.on("agent_start", () => {
68
+ report("working");
69
+ });
70
+
71
+ // Back to idle when the loop ends — unless we are waiting on a user prompt.
72
+ pi.on("agent_end", () => {
73
+ report(promptOpen ? "blocked" : "idle");
74
+ });
75
+
76
+ // An interactive prompt (permission gate, ask tool, confirm/input) is
77
+ // awaiting the user: that is herdr's "needs attention" (blocked) state.
78
+ pi.on("user_prompt_start", () => {
79
+ promptOpen = true;
80
+ report("blocked");
81
+ });
82
+
83
+ pi.on("user_prompt_end", (_event, ctx) => {
84
+ promptOpen = false;
85
+ report(ctx.isIdle() ? "idle" : "working");
86
+ });
87
+
88
+ // Relinquish pane authority so herdr stops showing xcsh once we exit.
89
+ pi.on("session_shutdown", () => {
90
+ runHerdr([
91
+ "pane",
92
+ "release-agent",
93
+ paneId,
94
+ "--source",
95
+ HERDR_AGENT_LABEL,
96
+ "--agent",
97
+ HERDR_AGENT_LABEL,
98
+ "--seq",
99
+ String(seq++),
100
+ ]);
101
+ });
102
+ }
@@ -20,6 +20,7 @@ import type { CustomMessage } from "../../session/messages";
20
20
  import { EventBus } from "../../utils/event-bus";
21
21
  import { getAllPluginExtensionPaths } from "../plugins/loader";
22
22
  import { resolvePath } from "../utils";
23
+ import herdrReporter from "./bundled/herdr-reporter";
23
24
  import type {
24
25
  Extension,
25
26
  ExtensionAPI,
@@ -480,6 +481,37 @@ async function discoverExtensionsInDir(dir: string): Promise<string[]> {
480
481
  /**
481
482
  * Discover and load extensions from standard locations.
482
483
  */
484
+ /** Extensions bundled with xcsh and loaded by default (before user extensions). */
485
+ const BUNDLED_EXTENSIONS: ReadonlyArray<{ name: string; factory: ExtensionFactory }> = [
486
+ { name: "herdr-reporter", factory: herdrReporter },
487
+ ];
488
+
489
+ /**
490
+ * Load the extensions that ship with xcsh. They share the runtime and event bus
491
+ * of the discovered user extensions and are prepended so user extensions keep the
492
+ * ability to override. A bundled extension that throws is recorded as an error
493
+ * but never aborts the overall load.
494
+ */
495
+ async function loadBundledExtensions(
496
+ result: LoadExtensionsResult,
497
+ cwd: string,
498
+ eventBus: EventBus,
499
+ isDisabled: (name: string) => boolean,
500
+ ): Promise<void> {
501
+ for (const { name, factory } of BUNDLED_EXTENSIONS) {
502
+ if (isDisabled(name)) continue;
503
+ try {
504
+ const extension = await loadExtensionFromFactory(factory, cwd, eventBus, result.runtime, `bundled:${name}`);
505
+ result.extensions.unshift(extension);
506
+ } catch (err) {
507
+ result.errors.push({
508
+ path: `bundled:${name}`,
509
+ error: err instanceof Error ? err.message : String(err),
510
+ });
511
+ }
512
+ }
513
+ }
514
+
483
515
  export async function discoverAndLoadExtensions(
484
516
  configuredPaths: string[],
485
517
  cwd: string,
@@ -568,5 +600,8 @@ export async function discoverAndLoadExtensions(
568
600
  addPath(resolved);
569
601
  }
570
602
 
571
- return loadExtensions(allPaths, cwd, eventBus);
603
+ const resolvedEventBus = eventBus ?? new EventBus();
604
+ const result = await loadExtensions(allPaths, cwd, resolvedEventBus);
605
+ await loadBundledExtensions(result, cwd, resolvedEventBus, isDisabledName);
606
+ return result;
572
607
  }
@@ -541,6 +541,26 @@ export interface MessageEndEvent {
541
541
  message: AgentMessage;
542
542
  }
543
543
 
544
+ /** Kind of interactive prompt currently awaiting the user. */
545
+ export type UserPromptKind = "select" | "confirm" | "input";
546
+
547
+ /**
548
+ * Fired when an interactive prompt (permission gate, `ask` tool, confirm/input
549
+ * dialog) is shown and is awaiting the user. Signals a "blocked / needs
550
+ * attention" state that is otherwise not observable from the agent event stream,
551
+ * since the session stays `isStreaming` while a prompt is open.
552
+ */
553
+ export interface UserPromptStartEvent {
554
+ type: "user_prompt_start";
555
+ kind: UserPromptKind;
556
+ }
557
+
558
+ /** Fired when an interactive prompt resolves (answered, cancelled, or timed out). */
559
+ export interface UserPromptEndEvent {
560
+ type: "user_prompt_end";
561
+ kind: UserPromptKind;
562
+ }
563
+
544
564
  /** Fired when a tool starts executing */
545
565
  export interface ToolExecutionStartEvent {
546
566
  type: "tool_execution_start";
@@ -817,6 +837,8 @@ export type ExtensionEvent =
817
837
  | MessageStartEvent
818
838
  | MessageUpdateEvent
819
839
  | MessageEndEvent
840
+ | UserPromptStartEvent
841
+ | UserPromptEndEvent
820
842
  | ToolExecutionStartEvent
821
843
  | ToolExecutionUpdateEvent
822
844
  | ToolExecutionEndEvent
@@ -1011,6 +1033,8 @@ export interface ExtensionAPI {
1011
1033
  on(event: "message_start", handler: ExtensionHandler<MessageStartEvent>): void;
1012
1034
  on(event: "message_update", handler: ExtensionHandler<MessageUpdateEvent>): void;
1013
1035
  on(event: "message_end", handler: ExtensionHandler<MessageEndEvent>): void;
1036
+ on(event: "user_prompt_start", handler: ExtensionHandler<UserPromptStartEvent>): void;
1037
+ on(event: "user_prompt_end", handler: ExtensionHandler<UserPromptEndEvent>): void;
1014
1038
  on(event: "tool_execution_start", handler: ExtensionHandler<ToolExecutionStartEvent>): void;
1015
1039
  on(event: "tool_execution_update", handler: ExtensionHandler<ToolExecutionUpdateEvent>): void;
1016
1040
  on(event: "tool_execution_end", handler: ExtensionHandler<ToolExecutionEndEvent>): void;
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.54.0",
21
- "commit": "adc80f2ff5554dd9a0359ce4b1ed170be3af79e7",
22
- "shortCommit": "adc80f2",
20
+ "version": "19.55.0",
21
+ "commit": "d4705fb3ced007a59e110d11971eb25c3dc9e83f",
22
+ "shortCommit": "d4705fb",
23
23
  "branch": "main",
24
- "tag": "v19.54.0",
25
- "commitDate": "2026-07-02T03:04:58Z",
26
- "buildDate": "2026-07-02T03:37:42.022Z",
24
+ "tag": "v19.55.0",
25
+ "commitDate": "2026-07-02T22:38:12Z",
26
+ "buildDate": "2026-07-02T23:02:06.036Z",
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/adc80f2ff5554dd9a0359ce4b1ed170be3af79e7",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.54.0"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/d4705fb3ced007a59e110d11971eb25c3dc9e83f",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.55.0"
33
33
  };
package/src/main.ts CHANGED
@@ -617,16 +617,14 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
617
617
  const cwd = getProjectDir();
618
618
  await logger.time("settings:init", Settings.init, { cwd });
619
619
 
620
- // F5 XC context loading optional, never blocks startup.
621
- // NOTE: This runs in the CLI path only. SDK consumers using createAgentSession()
622
- // directly must call ContextService.init(configDir).loadActive() themselves.
620
+ // F5 XC context is session-scoped: nothing loads at startup. We still init the
621
+ // ContextService singleton so /context commands and the session bootstrap work.
623
622
  try {
624
623
  const { ContextService } = await import("./services/xcsh-context");
625
624
  const { getXCSHConfigDir } = await import("@f5-sales-demo/pi-utils");
626
- const contextService = ContextService.init(getXCSHConfigDir());
627
- await contextService.loadActive(cwd);
625
+ ContextService.init(getXCSHConfigDir());
628
626
  } catch {
629
- // F5 XC auth is optionalsilently continue if anything fails
627
+ // ContextService optional (SDK consumers / tests) — continue.
630
628
  }
631
629
 
632
630
  if (parsedArgs.mode === "rpc") {
@@ -816,6 +814,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
816
814
  let sessionReady = false;
817
815
  if (process.env.XCSH_BROWSER_PROVIDER?.toLowerCase() === "extension") {
818
816
  bridgeServer = await startBridgeServer();
817
+ console.error(`[xcsh] extension bridge listening on ws://127.0.0.1:${bridgeServer.port}`);
819
818
  // Make the bridge globally available so ALL selectProvider() calls reuse it
820
819
  // (prevents starting a conflicting second bridge on the same port).
821
820
  setSharedBridgeServer(bridgeServer);
@@ -13,6 +13,7 @@ import type {
13
13
  ExtensionWidgetContent,
14
14
  ExtensionWidgetOptions,
15
15
  TerminalInputHandler,
16
+ UserPromptKind,
16
17
  } from "../../extensibility/extensions";
17
18
  import { HookEditorComponent } from "../../modes/components/hook-editor";
18
19
  import { HookInputComponent } from "../../modes/components/hook-input";
@@ -652,6 +653,15 @@ export class ExtensionUiController {
652
653
  /**
653
654
  * Show a selector for hooks.
654
655
  */
656
+ /**
657
+ * Notify extensions that an interactive prompt is awaiting the user
658
+ * (blocked / needs-attention) and, via the returned promise's settlement,
659
+ * when it resolves. Fire-and-forget: reporting must never disturb the prompt.
660
+ */
661
+ #emitPromptSignal(type: "user_prompt_start" | "user_prompt_end", kind: UserPromptKind): void {
662
+ this.ctx.session.extensionRunner?.emit({ type, kind }).catch(() => {});
663
+ }
664
+
655
665
  showHookSelector(
656
666
  title: string,
657
667
  options: string[],
@@ -713,6 +723,7 @@ export class ExtensionUiController {
713
723
  this.ctx.editorContainer.addChild(this.ctx.hookSelector);
714
724
  this.ctx.ui.setFocus(this.ctx.hookSelector);
715
725
  this.ctx.ui.requestRender();
726
+ this.#emitPromptSignal("user_prompt_start", "select");
716
727
  if (dialogOptions?.signal) {
717
728
  if (dialogOptions.signal.aborted) {
718
729
  onAbort();
@@ -720,7 +731,7 @@ export class ExtensionUiController {
720
731
  dialogOptions.signal.addEventListener("abort", onAbort, { once: true });
721
732
  }
722
733
  }
723
- return promise;
734
+ return promise.finally(() => this.#emitPromptSignal("user_prompt_end", "select"));
724
735
  }
725
736
  /**
726
737
  * Hide the hook selector.
@@ -786,6 +797,7 @@ export class ExtensionUiController {
786
797
  this.ctx.editorContainer.addChild(this.ctx.hookInput);
787
798
  this.ctx.ui.setFocus(this.ctx.hookInput);
788
799
  this.ctx.ui.requestRender();
800
+ this.#emitPromptSignal("user_prompt_start", "input");
789
801
  if (dialogOptions?.signal) {
790
802
  if (dialogOptions.signal.aborted) {
791
803
  onAbort();
@@ -793,7 +805,7 @@ export class ExtensionUiController {
793
805
  dialogOptions.signal.addEventListener("abort", onAbort, { once: true });
794
806
  }
795
807
  }
796
- return promise;
808
+ return promise.finally(() => this.#emitPromptSignal("user_prompt_end", "input"));
797
809
  }
798
810
 
799
811
  /**
package/src/sdk.ts CHANGED
@@ -771,6 +771,41 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
771
771
  const existingSession = logger.time("loadSessionContext", () =>
772
772
  deobfuscateSessionContext(sessionManager.buildSessionContext(), obfuscator),
773
773
  );
774
+
775
+ // --- Session-scoped context bootstrap ------------------------------------
776
+ // No context is auto-loaded at startup. The session decides: a RESUMED session
777
+ // re-activates the context recorded in its context_change log; a NEW session
778
+ // smart-auto-binds (folder-local, else the single context, else ask). Loading
779
+ // is via the existing activate(); auth is validated but never blocks resume.
780
+ try {
781
+ const { ContextService } = await import("./services/xcsh-context");
782
+ const { resolveAutoBind, chooseSessionContext } = await import("./services/session-context-binding");
783
+ const svc = ContextService.instance; // inited in main.ts (throws for SDK/tests → caught)
784
+ if (!process.env.XCSH_API_URL) {
785
+ const bound = existingSession.activeContextName; // resumed binding, if any
786
+ const available = (await svc.listContexts()).map(c => c.name);
787
+ const folderContext = await svc.resolveFolderContextName(cwd);
788
+ const autoBind = resolveAutoBind({ kind: "cli", availableContexts: available, folderContext });
789
+ const choice = chooseSessionContext(bound, autoBind);
790
+ if ("activate" in choice) {
791
+ try {
792
+ await svc.activate(choice.activate); // fires onContextChange → records context_change
793
+ await svc.validateToken(); // authenticate; non-blocking
794
+ } catch (err) {
795
+ // Context deleted since last use, or auth failed → surface, never block.
796
+ logger.warn("XCSH: session context bootstrap could not fully activate", {
797
+ context: choice.activate,
798
+ error: String(err),
799
+ });
800
+ }
801
+ }
802
+ // choice.needsSelection / choice.none → leave unbound; the /context status
803
+ // line and tools already prompt "run /context activate".
804
+ }
805
+ } catch {
806
+ // ContextService not initialized (SDK consumers / tests) — skip bootstrap.
807
+ }
808
+
774
809
  const existingBranch = logger.time("getSessionBranch", () => sessionManager.getBranch());
775
810
  const hasExistingSession = existingBranch.length > 0;
776
811
  const hasThinkingEntry = existingBranch.some(entry => entry.type === "thinking_level_change");
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Pure decisions for session-scoped context binding. No I/O — the caller
3
+ * gathers inputs (available contexts, folder-local context, tenant key) and
4
+ * performs the activation. Key-agnostic so the deferred extension/daemon phase
5
+ * reuses it unchanged.
6
+ */
7
+
8
+ export type SessionKind = "cli" | "extension";
9
+
10
+ export interface AutoBindInput {
11
+ kind: SessionKind;
12
+ /** Names of all available (global) contexts. */
13
+ availableContexts: string[];
14
+ /** CLI: the folder-local context name resolved from `.xcsh/`, if any. */
15
+ folderContext?: string | null;
16
+ /** Extension: the tenant|env key of the focused tab. */
17
+ tenantKey?: string | null;
18
+ /** Extension: contextName → its tenant|env key (derived from apiUrl). */
19
+ contextTenantKeys?: Record<string, string>;
20
+ }
21
+
22
+ export type AutoBindResult = { kind: "bind"; contextName: string } | { kind: "needsSelection" } | { kind: "none" };
23
+
24
+ /** Decide which context (if any) a NEW session should auto-bind. */
25
+ export function resolveAutoBind(input: AutoBindInput): AutoBindResult {
26
+ if (input.kind === "extension") {
27
+ if (!input.tenantKey) return { kind: "none" };
28
+ const match = Object.entries(input.contextTenantKeys ?? {}).find(([, key]) => key === input.tenantKey);
29
+ return match ? { kind: "bind", contextName: match[0] } : { kind: "needsSelection" };
30
+ }
31
+ // cli
32
+ if (input.folderContext) return { kind: "bind", contextName: input.folderContext };
33
+ if (input.availableContexts.length === 1) return { kind: "bind", contextName: input.availableContexts[0] };
34
+ if (input.availableContexts.length === 0) return { kind: "none" };
35
+ return { kind: "needsSelection" };
36
+ }
37
+
38
+ export type SessionContextChoice = { activate: string } | { needsSelection: true } | { none: true };
39
+
40
+ /** Resume (boundContextName present) wins; otherwise fall back to the auto-bind result. */
41
+ export function chooseSessionContext(
42
+ boundContextName: string | undefined,
43
+ autoBind: AutoBindResult,
44
+ ): SessionContextChoice {
45
+ if (boundContextName) return { activate: boundContextName };
46
+ if (autoBind.kind === "bind") return { activate: autoBind.contextName };
47
+ if (autoBind.kind === "needsSelection") return { needsSelection: true };
48
+ return { none: true };
49
+ }
@@ -363,6 +363,19 @@ export class ContextService {
363
363
  return this.#activeContext?.defaultNamespace ?? null;
364
364
  }
365
365
 
366
+ /** The project-local (`.xcsh/`) context NAME for `cwd`, or null. No activation,
367
+ * no settings mutation — a pure lookup used by the session bootstrap. */
368
+ async resolveFolderContextName(cwd: string): Promise<string | null> {
369
+ try {
370
+ const resolver = new ContextResolver({ paths: xcshContextPaths });
371
+ const local = await resolver.resolve(cwd);
372
+ if (local && local.source === "local") return (local.context as XCSHContext).name ?? null;
373
+ } catch {
374
+ /* no local context */
375
+ }
376
+ return null;
377
+ }
378
+
366
379
  async loadActive(cwd?: string): Promise<XCSHContext | null> {
367
380
  // FR-102: XCSH_API_URL is the signal to skip context loading entirely.
368
381
  // Subprocesses inherit process.env, so they already see the env vars directly.
@@ -429,19 +442,10 @@ export class ContextService {
429
442
  // sync form keeps createContext/deleteContext race-free with no coordination.
430
443
  await this.listContexts();
431
444
 
432
- let contextName = this.#readActiveContextName();
433
-
434
- // FR-104: auto-activate if exactly one context exists
435
- let autoActivated = false;
436
- if (!contextName) {
437
- const contexts = this.#listContextFiles();
438
- if (contexts.length === 1) {
439
- contextName = contexts[0].replace(/\.json$/, "");
440
- autoActivated = true;
441
- } else {
442
- return null;
443
- }
444
- }
445
+ const contextName = this.#readActiveContextName();
446
+ // Session-scoped: no auto-activate of a lone context. Without an explicit
447
+ // active_context pointer there is no global default to load.
448
+ if (!contextName) return null;
445
449
 
446
450
  // Read the context JSON
447
451
  const context = this.#readContext(contextName);
@@ -460,12 +464,6 @@ export class ContextService {
460
464
  return null;
461
465
  }
462
466
 
463
- // Only persist active_context after the context validates
464
- if (autoActivated) {
465
- this.#atomicWrite(this.activeContextPath, contextName);
466
- logger.debug("XCSH: auto-activated single context", { name: contextName });
467
- }
468
-
469
467
  this.#activeContext = context;
470
468
  this.#applyToSettings(context);
471
469
  // Detect mixed source: context loaded but some fields come from process.env
@@ -495,9 +493,6 @@ export class ContextService {
495
493
 
496
494
  this.#assertCompatibleVersion(context);
497
495
 
498
- // NFR-402: write active_context first — if it fails, don't update settings
499
- this.#atomicWrite(this.activeContextPath, name);
500
-
501
496
  if (this.#activeContext && this.#activeContext.name !== name) {
502
497
  this.#previousContextName = this.#activeContext.name;
503
498
  }