@f5-sales-demo/xcsh 19.72.0 → 19.73.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.72.0",
4
+ "version": "19.73.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",
@@ -56,13 +56,13 @@
56
56
  "dependencies": {
57
57
  "@agentclientprotocol/sdk": "0.16.1",
58
58
  "@mozilla/readability": "^0.6",
59
- "@f5-sales-demo/xcsh-stats": "19.72.0",
60
- "@f5-sales-demo/pi-agent-core": "19.72.0",
61
- "@f5-sales-demo/pi-ai": "19.72.0",
62
- "@f5-sales-demo/pi-natives": "19.72.0",
63
- "@f5-sales-demo/pi-resource-management": "19.72.0",
64
- "@f5-sales-demo/pi-tui": "19.72.0",
65
- "@f5-sales-demo/pi-utils": "19.72.0",
59
+ "@f5-sales-demo/xcsh-stats": "19.73.0",
60
+ "@f5-sales-demo/pi-agent-core": "19.73.0",
61
+ "@f5-sales-demo/pi-ai": "19.73.0",
62
+ "@f5-sales-demo/pi-natives": "19.73.0",
63
+ "@f5-sales-demo/pi-resource-management": "19.73.0",
64
+ "@f5-sales-demo/pi-tui": "19.73.0",
65
+ "@f5-sales-demo/pi-utils": "19.73.0",
66
66
  "@sinclair/typebox": "^0.34",
67
67
  "@xterm/headless": "^6.0",
68
68
  "ajv": "^8.20",
@@ -69,3 +69,31 @@ export function createExtensionBridgeTools(bridge: BridgeServer): CustomTool<TSc
69
69
  export const EXTENSION_AGENT_TOOL_NAMES: readonly string[] = EXTENSION_CAPABILITIES.tools
70
70
  .filter(def => !INTERNAL_TOOLS.has(def.name))
71
71
  .map(def => def.name);
72
+
73
+ /**
74
+ * Builtin agent tools scoped into a headless browser-bridge session (the Chrome
75
+ * extension worker and the Office `serve` bridge). Shared so both bootstraps use
76
+ * one list. Office document tools are NOT here — the pane advertises those over
77
+ * the bridge via `set_host_tools`, registered at runtime by the ChatHandler.
78
+ */
79
+ export const BROWSER_TOOL_NAMES: readonly string[] = [
80
+ "catalog_workflow_runner",
81
+ "navigate",
82
+ "click",
83
+ "click_element",
84
+ "fill",
85
+ "type_text",
86
+ "screenshot",
87
+ "login",
88
+ "read_ax",
89
+ "get_page_context",
90
+ "query_dom",
91
+ "find",
92
+ "wait_for",
93
+ "key_press",
94
+ "select_option",
95
+ "label_select",
96
+ "scroll_to",
97
+ "annotate",
98
+ "set_explain_mode",
99
+ ];
@@ -0,0 +1,164 @@
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.
11
+ *
12
+ * The heavy / socket / network calls are injected (defaulting to the real ones)
13
+ * so the wiring is unit-testable without opening real listeners or a session.
14
+ *
15
+ * NOT browser-safe (node/bun): runs inside the full xcsh binary, never the pane.
16
+ */
17
+ import { getProjectDir, getXCSHConfigDir } from "@f5-sales-demo/pi-utils";
18
+ import { createAgentSession } from "../sdk";
19
+ import { ContextService } from "../services/xcsh-context";
20
+ import { deriveTenantEnv } from "../services/xcsh-env";
21
+ import { resolveBridgeTls } from "./bridge-cert";
22
+ import { ChatHandler } from "./chat-handler";
23
+ import { type BridgeServer, startBridgeServer } from "./extension-bridge";
24
+ import { BROWSER_TOOL_NAMES, createExtensionBridgeTools, EXTENSION_AGENT_TOOL_NAMES } from "./extension-bridge-tools";
25
+ import { setSharedBridgeServer } from "./provider";
26
+
27
+ /** A running headless bridge + a teardown that disposes the chat handler and
28
+ * closes the bridge (both ws + wss listeners). */
29
+ export interface HeadlessChatBridge {
30
+ bridge: BridgeServer;
31
+ dispose: () => Promise<void>;
32
+ }
33
+
34
+ /**
35
+ * Tenant identity for the `hello` handshake, contextless-friendly: the active
36
+ * `/context` wins (its apiUrl + name), otherwise fall back to the `XCSH_API_URL`/
37
+ * `XCSH_SESSION_TENANT` env so the pane still learns which tenant this process
38
+ * serves. Sync — the bridge calls it while answering `hello`. Mirrors the
39
+ * interactive path in `main.ts` (there is no per-tab session id here).
40
+ */
41
+ export function sessionInfoForOfficeServe(): {
42
+ tenant: string | null;
43
+ env: string | null;
44
+ apiUrl: string | null;
45
+ contextBound: boolean;
46
+ sessionId: string | null;
47
+ } {
48
+ let apiUrl: string | null = null;
49
+ let contextBound = false;
50
+ try {
51
+ apiUrl = ContextService.instance.activeApiUrl;
52
+ contextBound = ContextService.instance.getStatus().activeContextName != null;
53
+ } catch {
54
+ /* ContextService not initialized — env-only mode. */
55
+ }
56
+ apiUrl = apiUrl ?? process.env.XCSH_API_URL ?? null;
57
+ const tenantKey = process.env.XCSH_SESSION_TENANT ?? null;
58
+ const { tenant, env } = deriveTenantEnv(apiUrl, tenantKey);
59
+ return { tenant, env, apiUrl, contextBound, sessionId: null };
60
+ }
61
+
62
+ /** Injectable seams (defaulted to the real ones) so the bootstrap is testable. */
63
+ export interface HeadlessBridgeDeps {
64
+ /** Set the browser-provider env, init Settings + ContextService + provider
65
+ * persistence, and quiet startup; returns the project cwd for the session. */
66
+ initEnv: () => Promise<{ cwd: string }>;
67
+ resolveBridgeTls: typeof resolveBridgeTls;
68
+ startBridgeServer: typeof startBridgeServer;
69
+ setSharedBridgeServer: typeof setSharedBridgeServer;
70
+ createExtensionBridgeTools: typeof createExtensionBridgeTools;
71
+ createAgentSession: typeof createAgentSession;
72
+ ChatHandlerCtor: typeof ChatHandler;
73
+ }
74
+
75
+ const defaultDeps: HeadlessBridgeDeps = {
76
+ initEnv: async () => {
77
+ process.env.XCSH_BROWSER_PROVIDER = "extension";
78
+ const cwd = getProjectDir();
79
+ const { Settings, settings } = await import("../config/settings");
80
+ await Settings.init({ cwd });
81
+ // Init the ContextService singleton so sessionInfoForOfficeServe can read the
82
+ // active apiUrl once a /context is bound.
83
+ try {
84
+ ContextService.init(getXCSHConfigDir());
85
+ } catch {
86
+ /* already initialized / unavailable — continue. */
87
+ }
88
+ // Provider persistence for model discovery (parity with main.ts / worker.ts).
89
+ const { initializeWithSettings } = await import("../discovery");
90
+ initializeWithSettings(settings);
91
+ // Quiet startup: skip the welcome screen + blocking plugin "Fix now?" prompts.
92
+ settings.override("startup.quiet", true);
93
+ return { cwd };
94
+ },
95
+ resolveBridgeTls,
96
+ startBridgeServer,
97
+ setSharedBridgeServer,
98
+ createExtensionBridgeTools,
99
+ createAgentSession,
100
+ ChatHandlerCtor: ChatHandler,
101
+ };
102
+
103
+ /**
104
+ * Start the headless chat bridge and return it with a teardown. Fully awaits the
105
+ * session + ChatHandler.attach() before resolving, so once this resolves the pane
106
+ * can connect and chat immediately (no warm-up race). A `configure`-less pane
107
+ * chats over xcsh's already-configured provider.
108
+ */
109
+ export async function startHeadlessChatBridge(deps: HeadlessBridgeDeps = defaultDeps): Promise<HeadlessChatBridge> {
110
+ const { cwd } = await deps.initEnv();
111
+
112
+ // Provision the wss cert before binding (warm boot = on-disk cache hit);
113
+ // `undefined` (offline) → the bridge starts ws-only.
114
+ const tls = await deps.resolveBridgeTls();
115
+ const bridge = await deps.startBridgeServer(undefined, tls ? { tls } : undefined);
116
+ // Reuse this bridge for any in-process selectProvider() (no conflicting second bridge).
117
+ deps.setSharedBridgeServer(bridge);
118
+ bridge.setSessionInfo(sessionInfoForOfficeServe);
119
+ // Re-announce the tenant when the active context changes (best-effort).
120
+ try {
121
+ ContextService.onContextChange(() => bridge.broadcastTenantChanged());
122
+ } catch {
123
+ /* ContextService not initialized (tests) — the tenant is static. */
124
+ }
125
+
126
+ // Everything past the bind can throw (createAgentSession on a misconfigured
127
+ // provider, etc.). If it does, close the already-bound bridge and clear the
128
+ // shared-bridge global before rethrowing — otherwise the ws/wss listeners leak
129
+ // (keeping the event loop alive so Ctrl+C can't exit) and a later in-process
130
+ // selectProvider() reuses a dead bridge. The caller (startOfficeServe) treats
131
+ // the rethrow as a non-fatal "pane only" fallback.
132
+ try {
133
+ // Turn the extension's browser actions into bridge-proxying CustomTools, then
134
+ // create ONE headless session scoped to the browser tools (Office document
135
+ // tools arrive at runtime via set_host_tools).
136
+ const extensionTools = deps.createExtensionBridgeTools(bridge);
137
+ const { session } = await deps.createAgentSession({
138
+ cwd,
139
+ hasUI: false,
140
+ toolNames: [...new Set([...BROWSER_TOOL_NAMES, ...EXTENSION_AGENT_TOOL_NAMES])],
141
+ customTools: extensionTools,
142
+ // Headless: no MCP/LSP/extension discovery — lean, no network/blocking prompts.
143
+ enableMCP: false,
144
+ enableLsp: false,
145
+ disableExtensionDiscovery: true,
146
+ });
147
+
148
+ const chatHandler = new deps.ChatHandlerCtor(bridge, session);
149
+ chatHandler.attach();
150
+
151
+ return {
152
+ bridge,
153
+ dispose: async () => {
154
+ chatHandler.dispose();
155
+ deps.setSharedBridgeServer(null);
156
+ await bridge.close();
157
+ },
158
+ };
159
+ } catch (err) {
160
+ deps.setSharedBridgeServer(null);
161
+ await bridge.close();
162
+ throw err;
163
+ }
164
+ }
@@ -115,7 +115,10 @@ export class CdpBrowserProvider implements BrowserProvider {
115
115
  * instead of starting a conflicting second one on the same port.
116
116
  */
117
117
  let _sharedBridgeServer: import("./extension-bridge").BridgeServer | null = null;
118
- export function setSharedBridgeServer(server: import("./extension-bridge").BridgeServer): void {
118
+ /** Publish (or, with `null`, clear) the process-shared bridge. Clearing is
119
+ * required when a partially-started bridge is torn down (e.g. session bootstrap
120
+ * failed after bind) so a later `selectProvider()` never reuses a closed bridge. */
121
+ export function setSharedBridgeServer(server: import("./extension-bridge").BridgeServer | null): void {
119
122
  _sharedBridgeServer = server;
120
123
  }
121
124
 
@@ -9,7 +9,14 @@
9
9
  */
10
10
  import { spawnSync } from "node:child_process";
11
11
  import * as path from "node:path";
12
- import { getOfficePaneDir, readManifest, startOfficePaneServer } from "../browser/office-pane-server";
12
+ import { LOCALIP_HOST } from "../browser/bridge-cert";
13
+ import { type HeadlessChatBridge, startHeadlessChatBridge } from "../browser/headless-bridge";
14
+ import {
15
+ getOfficePaneDir,
16
+ type OfficePaneServer,
17
+ readManifest,
18
+ startOfficePaneServer,
19
+ } from "../browser/office-pane-server";
13
20
 
14
21
  /** The subcommands `xcsh office` accepts (also the Args `options` constraint). */
15
22
  export const OFFICE_ACTIONS = ["serve", "manifest", "sideload"] as const;
@@ -39,9 +46,53 @@ export async function writeManifest(outPath?: string): Promise<string> {
39
46
  return text;
40
47
  }
41
48
 
42
- /** Start the :8444 listener, print the task-pane URL, and block until killed. */
49
+ /** Injectable seams for {@link startOfficeServe} (defaulted to the real ones). */
50
+ export interface OfficeServeDeps {
51
+ startOfficePaneServer: typeof startOfficePaneServer;
52
+ startHeadlessChatBridge: typeof startHeadlessChatBridge;
53
+ }
54
+
55
+ const defaultServeDeps: OfficeServeDeps = { startOfficePaneServer, startHeadlessChatBridge };
56
+
57
+ /** A running `office serve`: the pane file server, the (optional) chat bridge, and
58
+ * a teardown that disposes both. */
59
+ export interface OfficeServeHandle {
60
+ server: OfficePaneServer;
61
+ chat: HeadlessChatBridge | null;
62
+ dispose: () => Promise<void>;
63
+ }
64
+
65
+ /**
66
+ * Start BOTH the :8444 pane file server AND the headless chat bridge, so one
67
+ * `xcsh office serve` yields a working pane (no separately-run bridge). A bridge
68
+ * failure is NON-fatal: the pane still serves and we warn, so `serve` degrades to
69
+ * "pane only" rather than failing outright. Returns a teardown that disposes both.
70
+ * Extracted from {@link runServe} so the start/teardown wiring is unit-testable.
71
+ */
72
+ export async function startOfficeServe(deps: OfficeServeDeps = defaultServeDeps): Promise<OfficeServeHandle> {
73
+ const server = await deps.startOfficePaneServer();
74
+ let chat: HeadlessChatBridge | null = null;
75
+ try {
76
+ chat = await deps.startHeadlessChatBridge();
77
+ } catch (err) {
78
+ console.warn(
79
+ `Warning: the chat bridge could not start (${err instanceof Error ? err.message : String(err)}). ` +
80
+ "The task pane will load but chat is unavailable until a bridge is running.",
81
+ );
82
+ }
83
+ return {
84
+ server,
85
+ chat,
86
+ dispose: async () => {
87
+ if (chat) await chat.dispose();
88
+ server.stop();
89
+ },
90
+ };
91
+ }
92
+
93
+ /** Start the pane server + chat bridge, print status, and block until killed. */
43
94
  async function runServe(): Promise<void> {
44
- const server = await startOfficePaneServer();
95
+ const { server, chat, dispose } = await startOfficeServe();
45
96
  console.log(`Serving the xcsh Office task pane at ${server.taskpaneUrl}`);
46
97
  if (!server.trusted) {
47
98
  console.warn(
@@ -49,9 +100,26 @@ async function runServe(): Promise<void> {
49
100
  "Office's WebView may refuse to load the page until the cert is trusted.",
50
101
  );
51
102
  }
103
+ if (chat) {
104
+ if (chat.bridge.wssPort) {
105
+ console.log(
106
+ `Chat bridge ready on wss://${LOCALIP_HOST}:${chat.bridge.wssPort} — the pane connects automatically.`,
107
+ );
108
+ } else {
109
+ console.warn(
110
+ "Warning: the chat bridge is ws-only (no wss cert). The pane connects over wss and may not reach it.",
111
+ );
112
+ }
113
+ }
52
114
  console.log("Press Ctrl+C to stop.");
53
- // Bun.serve holds the event loop open; block run() so the process stays alive.
54
- await new Promise<never>(() => {});
115
+ // Block until a signal tears us down, then dispose the bridge + pane server.
116
+ await new Promise<void>(resolve => {
117
+ const shutdown = (): void => {
118
+ void dispose().finally(resolve);
119
+ };
120
+ process.once("SIGINT", shutdown);
121
+ process.once("SIGTERM", shutdown);
122
+ });
55
123
  }
56
124
 
57
125
  /** Run the Office sideload against the embedded bundle (best-effort). */
@@ -20,7 +20,11 @@ import { Command } from "@f5-sales-demo/pi-utils/cli";
20
20
  import { LOCALIP_HOST, resolveBridgeTls } from "../browser/bridge-cert";
21
21
  import { ChatHandler } from "../browser/chat-handler";
22
22
  import { startBridgeServer } from "../browser/extension-bridge";
23
- import { createExtensionBridgeTools, EXTENSION_AGENT_TOOL_NAMES } from "../browser/extension-bridge-tools";
23
+ import {
24
+ BROWSER_TOOL_NAMES,
25
+ createExtensionBridgeTools,
26
+ EXTENSION_AGENT_TOOL_NAMES,
27
+ } from "../browser/extension-bridge-tools";
24
28
  import { setSharedBridgeServer } from "../browser/provider";
25
29
  import { coldStartSpans, type SpanFrame, sessionBuildSpan } from "../browser/ttft-spans";
26
30
  import { initializeWithSettings } from "../discovery";
@@ -94,28 +98,6 @@ function managerSockPath(): string {
94
98
  /** Browser-automation tool set — identical scoping to `main.ts`'s extension path.
95
99
  * With scoped tools the ONLY way to create a resource is the form-driven workflow
96
100
  * runner, which is exactly what the human watching the browser wants. */
97
- const BROWSER_TOOL_NAMES = [
98
- "catalog_workflow_runner",
99
- "navigate",
100
- "click",
101
- "click_element",
102
- "fill",
103
- "type_text",
104
- "screenshot",
105
- "login",
106
- "read_ax",
107
- "get_page_context",
108
- "query_dom",
109
- "find",
110
- "wait_for",
111
- "key_press",
112
- "select_option",
113
- "label_select",
114
- "scroll_to",
115
- "annotate",
116
- "set_explain_mode",
117
- ];
118
-
119
101
  export default class Worker extends Command {
120
102
  static description = "Run a headless extension-bridge worker (no TUI); blocks until SIGTERM";
121
103
 
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.72.0",
21
- "commit": "43fbd3cf08806284c4d377bf883d684b838364ca",
22
- "shortCommit": "43fbd3c",
20
+ "version": "19.73.0",
21
+ "commit": "3f32c968ef8b2a0c9fabbc1be3fbe5c7395a7dc9",
22
+ "shortCommit": "3f32c96",
23
23
  "branch": "main",
24
- "tag": "v19.72.0",
25
- "commitDate": "2026-07-22T02:38:28Z",
26
- "buildDate": "2026-07-22T03:07:27.733Z",
24
+ "tag": "v19.73.0",
25
+ "commitDate": "2026-07-22T04:58:36Z",
26
+ "buildDate": "2026-07-22T05:24:54.248Z",
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/43fbd3cf08806284c4d377bf883d684b838364ca",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.72.0"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/3f32c968ef8b2a0c9fabbc1be3fbe5c7395a7dc9",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.73.0"
33
33
  };