@f5-sales-demo/xcsh 19.56.0 → 19.56.2

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.56.0",
4
+ "version": "19.56.2",
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.56.0",
59
- "@f5-sales-demo/pi-agent-core": "19.56.0",
60
- "@f5-sales-demo/pi-ai": "19.56.0",
61
- "@f5-sales-demo/pi-natives": "19.56.0",
62
- "@f5-sales-demo/pi-resource-management": "19.56.0",
63
- "@f5-sales-demo/pi-tui": "19.56.0",
64
- "@f5-sales-demo/pi-utils": "19.56.0",
58
+ "@f5-sales-demo/xcsh-stats": "19.56.2",
59
+ "@f5-sales-demo/pi-agent-core": "19.56.2",
60
+ "@f5-sales-demo/pi-ai": "19.56.2",
61
+ "@f5-sales-demo/pi-natives": "19.56.2",
62
+ "@f5-sales-demo/pi-resource-management": "19.56.2",
63
+ "@f5-sales-demo/pi-tui": "19.56.2",
64
+ "@f5-sales-demo/pi-utils": "19.56.2",
65
65
  "@sinclair/typebox": "^0.34",
66
66
  "@xterm/headless": "^6.0",
67
67
  "ajv": "^8.20",
@@ -0,0 +1,151 @@
1
+ // Headless worker-tool harness — no Chrome, no F5 login.
2
+ //
3
+ // Spawns a real `xcsh worker`, connects a MOCK extension bridge client (exactly
4
+ // the hello handshake + origin the real service worker uses), sends a chat_request
5
+ // asking it to navigate, and observes:
6
+ // (a) which tools the worker's agent actually has (from the daily log), and
7
+ // (b) whether the agent emits a `navigate` tool_request over the bridge.
8
+ //
9
+ // This isolates the "agent responds but never drives" bug to the WORKER's tool
10
+ // registration, deterministically and repeatably.
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ // XCSH_DEV=1 → run the local source build (`bun src/cli.ts worker`) so source
15
+ // edits are testable; otherwise the installed Homebrew binary.
16
+ const XCSH_REPO = new URL("..", import.meta.url).pathname; // packages/coding-agent
17
+ const DEV = process.env.XCSH_DEV === "1";
18
+ const SPAWN_CMD = DEV ? ["bun", "src/cli.ts", "worker"] : ["/opt/homebrew/bin/xcsh", "worker"];
19
+ const SPAWN_CWD = DEV ? XCSH_REPO : process.cwd();
20
+ const EXT_ID = "klajkjdoehjidngligegnpknogmjjhkc";
21
+ const ORIGIN = `chrome-extension://${EXT_ID}`; // bridge origin check: exact, no trailing slash
22
+ const PORT = 19260; // away from the real backend range (19222-19241)
23
+ const TENANT = process.env.HARNESS_TENANT ?? "f5-sales-demo|production";
24
+ const PROMPT = process.env.HARNESS_PROMPT ?? "Navigate the browser to the origin pools list.";
25
+
26
+ function log(...a: unknown[]) {
27
+ console.log("[harness]", ...a);
28
+ }
29
+
30
+ // 1) Spawn the worker with a forced bridge port + tenant identity.
31
+ const worker = Bun.spawn(SPAWN_CMD, {
32
+ cwd: SPAWN_CWD,
33
+ env: {
34
+ ...process.env,
35
+ XCSH_BRIDGE_PORT: String(PORT),
36
+ XCSH_SESSION_ID: "tab-9999",
37
+ XCSH_SESSION_TENANT: TENANT,
38
+ },
39
+ stdout: "pipe",
40
+ stderr: "pipe",
41
+ });
42
+ log(`spawned worker pid=${worker.pid} (${DEV ? "DEV build" : "homebrew"}), port=${PORT}, tenant=${TENANT}`);
43
+
44
+ // Stream worker stderr; resolve when the bridge is listening.
45
+ let bridgeUp = false;
46
+ const decoder = new TextDecoder();
47
+ (async () => {
48
+ for await (const chunk of worker.stderr) {
49
+ const s = decoder.decode(chunk);
50
+ for (const line of s.split("\n")) {
51
+ if (line.trim()) log("worker⟩", line.trim());
52
+ if (line.includes("extension bridge listening")) bridgeUp = true;
53
+ }
54
+ }
55
+ })();
56
+
57
+ // Wait for the bridge, then give the (heavier) agent session time to init + attach
58
+ // the chat handler (chatHandler.attach() runs AFTER createAgentSession).
59
+ for (let i = 0; i < 120 && !bridgeUp; i++) await Bun.sleep(250);
60
+ if (!bridgeUp) {
61
+ log("FAIL: bridge never came up");
62
+ worker.kill();
63
+ process.exit(1);
64
+ }
65
+ log("bridge up; waiting 12s for agent session init…");
66
+ await Bun.sleep(12_000);
67
+
68
+ // 2) Connect the mock extension client with the correct Origin.
69
+ const toolRequests: string[] = [];
70
+ let chatText = "";
71
+ let chatErr: string | null = null;
72
+ let done = false;
73
+
74
+ const ws = new WebSocket(`ws://127.0.0.1:${PORT}`, { headers: { Origin: ORIGIN } } as unknown as string[]);
75
+ ws.onopen = () => {
76
+ log("client connected; sending hello");
77
+ ws.send(JSON.stringify({ type: "hello", contractVersion: "1.5.0", extensionId: "harness" }));
78
+ };
79
+ ws.onerror = () => log("client ws error (origin rejected?)");
80
+ ws.onmessage = e => {
81
+ let m: Record<string, unknown>;
82
+ try {
83
+ m = JSON.parse(e.data as string);
84
+ } catch {
85
+ return;
86
+ }
87
+ switch (m.type) {
88
+ case "hello_ack":
89
+ log(`hello_ack: tenant=${m.tenant} sessionId=${m.sessionId} contractVersion=${m.contractVersion}`);
90
+ log(`sending chat_request: "${PROMPT}"`);
91
+ ws.send(
92
+ JSON.stringify({
93
+ type: "chat_request",
94
+ id: "c-harness-1",
95
+ text: PROMPT,
96
+ context: null,
97
+ mode: "configuration",
98
+ }),
99
+ );
100
+ break;
101
+ case "tool_request":
102
+ log(`◆ TOOL_REQUEST from agent: tool="${m.tool}" id=${m.id}`);
103
+ toolRequests.push(m.tool as string);
104
+ // Reply so the agent's turn can proceed.
105
+ ws.send(JSON.stringify({ type: "tool_result", id: m.id, content: "ok (harness stub)", is_error: false }));
106
+ break;
107
+ case "chat_delta":
108
+ chatText += (m.delta as string) ?? "";
109
+ break;
110
+ case "chat_done":
111
+ done = true;
112
+ break;
113
+ case "chat_error":
114
+ chatErr = (m.error as string) ?? "unknown";
115
+ done = true;
116
+ break;
117
+ }
118
+ };
119
+
120
+ // 3) Wait for the turn to finish (or time out).
121
+ for (let i = 0; i < 240 && !done; i++) await Bun.sleep(250);
122
+
123
+ // 4) Read the worker's actual tool set from the daily log.
124
+ let activeTools = "(not found in daily log)";
125
+ try {
126
+ const logPath = join(homedir(), ".xcsh", "logs", `xcsh.${new Date().toISOString().slice(0, 10)}.log`);
127
+ const text = await Bun.file(logPath).text();
128
+ const lines = text.split("\n").filter(l => l.includes(`"pid":${worker.pid}`) && l.includes("activeToolNames"));
129
+ if (lines.length) {
130
+ const parsed = JSON.parse(lines[lines.length - 1]);
131
+ activeTools = JSON.stringify(parsed.activeToolNames);
132
+ }
133
+ } catch {}
134
+
135
+ console.log("\n==================== RESULT ====================");
136
+ console.log("agent tool_requests emitted :", toolRequests.length ? toolRequests.join(", ") : "(none)");
137
+ console.log("worker activeToolNames :", activeTools);
138
+ console.log("has navigate tool? :", activeTools.includes('"navigate"') ? "YES" : "NO");
139
+ console.log("emitted a navigate? :", toolRequests.includes("navigate") ? "YES" : "NO");
140
+ console.log("chat error :", chatErr ?? "(none)");
141
+ console.log("agent said (first 240 chars) :", chatText.slice(0, 240).replace(/\n/g, " "));
142
+ if (toolRequests.includes("navigate")) console.log("\n[PASS] worker agent HAS + CALLS navigate — tool registration OK");
143
+ else console.log("\n[FAIL] worker never called navigate — browser tools missing from the agent");
144
+ console.log("===============================================");
145
+
146
+ try {
147
+ ws.close();
148
+ } catch {}
149
+ worker.kill();
150
+ await Bun.sleep(300);
151
+ process.exit(0);
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Agent tools for the Chrome extension's browser actions.
3
+ *
4
+ * The extension advertises a fixed set of tools (EXTENSION_CAPABILITIES.tools:
5
+ * navigate, click, read_ax, type_text, …). Before this factory, those names were
6
+ * requested by the worker (BROWSER_TOOL_NAMES) but never registered as agent
7
+ * tools — so the agent could only run the form-driven `catalog_workflow_runner`
8
+ * and had no way to `navigate`/`click`, i.e. it replied "Navigating…" and never
9
+ * drove the browser. This turns each advertised tool into a `CustomTool` whose
10
+ * `execute` proxies the call to the connected extension over the bridge.
11
+ */
12
+ import type { AgentToolResult, AgentToolUpdateCallback } from "@f5-sales-demo/pi-agent-core";
13
+ import type { TSchema } from "@sinclair/typebox";
14
+ import type { CustomTool, CustomToolContext } from "../extensibility/custom-tools/types";
15
+ import { EXTENSION_CAPABILITIES, type ExtensionToolDef } from "./capabilities.generated";
16
+ import type { BridgeServer } from "./extension-bridge";
17
+
18
+ /**
19
+ * Extension tools that are transport/diagnostic plumbing, not agent actions —
20
+ * never exposed to the LLM. Everything else in EXTENSION_CAPABILITIES becomes a
21
+ * callable agent tool.
22
+ */
23
+ const INTERNAL_TOOLS = new Set<string>([
24
+ "ping",
25
+ "capabilities",
26
+ "reload",
27
+ "debug_exec",
28
+ "detach",
29
+ "set_bridge_port",
30
+ "diag_suspension",
31
+ "diag_bridges",
32
+ ]);
33
+
34
+ /** Build a CustomTool that proxies one extension tool to `bridge.request`. */
35
+ function bridgeTool(bridge: BridgeServer, def: ExtensionToolDef): CustomTool<TSchema, unknown> {
36
+ return {
37
+ name: def.name,
38
+ label: def.name,
39
+ description: def.summary,
40
+ // The extension's JSON-Schema `params` is a TypeBox-compatible schema object.
41
+ parameters: (def.params ?? { type: "object", properties: {} }) as unknown as TSchema,
42
+ async execute(
43
+ _toolCallId: string,
44
+ params: unknown,
45
+ _onUpdate: AgentToolUpdateCallback<unknown, TSchema> | undefined,
46
+ _ctx: CustomToolContext,
47
+ _signal?: AbortSignal,
48
+ ): Promise<AgentToolResult<unknown, TSchema>> {
49
+ const res = await bridge.request(def.name, (params ?? {}) as Record<string, unknown>);
50
+ const raw = typeof res.content === "string" ? res.content : JSON.stringify(res.content);
51
+ // AgentToolResult has no isError flag — surface the extension's error in the
52
+ // text so the model sees the failure and can react.
53
+ const text = res.is_error ? `Error: ${raw}` : raw;
54
+ return { content: [{ type: "text" as const, text }] };
55
+ },
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Every agent-facing extension tool as a bridge-proxying CustomTool. Pass the
61
+ * result to `createAgentSession({ customTools })` so the worker's agent can drive
62
+ * the browser. Requires `bridge` (the worker's live extension-bridge server).
63
+ */
64
+ export function createExtensionBridgeTools(bridge: BridgeServer): CustomTool<TSchema, unknown>[] {
65
+ return EXTENSION_CAPABILITIES.tools.filter(def => !INTERNAL_TOOLS.has(def.name)).map(def => bridgeTool(bridge, def));
66
+ }
67
+
68
+ /** Names of the agent-facing extension tools (for tool-scoping / tests). */
69
+ export const EXTENSION_AGENT_TOOL_NAMES: readonly string[] = EXTENSION_CAPABILITIES.tools
70
+ .filter(def => !INTERNAL_TOOLS.has(def.name))
71
+ .map(def => def.name);
@@ -76,6 +76,50 @@ export function workerArgv(): string[] {
76
76
  return reexecArgv("worker");
77
77
  }
78
78
 
79
+ /**
80
+ * Acquire the manager control socket, robust across Bun runtimes.
81
+ *
82
+ * The single-manager invariant needs three things at once: (1) never clobber a
83
+ * LIVE manager's socket, (2) reclaim a STALE socket left by a crashed/killed
84
+ * manager, and (3) survive a concurrent cold-start race. Bun's unix `listen` is
85
+ * NOT a reliable oracle here — dev `bun run` silently unlinks-and-rebinds a
86
+ * stale socket, while the COMPILED binary throws EADDRINUSE (xcsh #1846), which
87
+ * previously crashed the manager and silently killed all automation. So we drive
88
+ * it explicitly:
89
+ *
90
+ * 1. Probe for a live owner. If one answers → we lost; bail ("already-live").
91
+ * 2. Try to listen. If it binds → done ("bound").
92
+ * 3. On EADDRINUSE, RE-PROBE: a live answer now means a rival bound between
93
+ * our probe and our listen → bail WITHOUT touching its socket. Otherwise
94
+ * the file is confirmed stale → unlink it and retry listen once. A still-
95
+ * failing retry (or any non-EADDRINUSE error) propagates loudly.
96
+ *
97
+ * Effects are injected so the branch logic is unit-tested deterministically
98
+ * (the compiled-only EADDRINUSE path is unreachable from a dev test otherwise).
99
+ */
100
+ export async function acquireControlSocket(opts: {
101
+ sockPath: string;
102
+ probeLive: (sockPath: string) => Promise<boolean>;
103
+ listen: () => void;
104
+ unlink: (sockPath: string) => void;
105
+ isAddrInUse: (err: unknown) => boolean;
106
+ }): Promise<"bound" | "already-live"> {
107
+ const { sockPath, probeLive, listen, unlink, isAddrInUse } = opts;
108
+ if (await probeLive(sockPath)) return "already-live";
109
+ try {
110
+ listen();
111
+ return "bound";
112
+ } catch (err) {
113
+ if (!isAddrInUse(err)) throw err;
114
+ // Bind collided. Distinguish a live rival (lost a cold-start race) from a
115
+ // stale file left by a crashed manager.
116
+ if (await probeLive(sockPath)) return "already-live";
117
+ unlink(sockPath); // confirmed stale → reclaim it
118
+ listen(); // retry once; a real failure now propagates
119
+ return "bound";
120
+ }
121
+ }
122
+
79
123
  export default class Manager extends Command {
80
124
  static description = "Run the detached control server that spawns/reaps per-tab workers; blocks forever";
81
125
 
@@ -180,39 +224,35 @@ export default class Manager extends Command {
180
224
  },
181
225
  };
182
226
 
183
- // Single-manager invariant probe liveness BEFORE binding.
184
- //
185
- // Two chrome-hosts can cold-start concurrently, both find no socket, and
186
- // both spawn a manager. `Bun.listen({unix})` SILENTLY unlinks and rebinds
187
- // any existing socket path it does NOT throw EADDRINUSE even when a live
188
- // manager is accepting on it (verified on Bun 1.3.x, in- and cross-process).
189
- // So a naive `listen` would clobber the first manager's socket, leaving it
190
- // orphaned on an unlinked path (blocking forever, leaking its worker ports)
191
- // while a second live manager takes over — breaking "at most one manager".
192
- //
193
- // Guard by CONNECTING first: if a live manager answers, this process lost
194
- // the race and exits WITHOUT touching the socket file (it belongs to the
195
- // live manager). If nothing answers, the path is free or a STALE file from
196
- // a crashed manager — either way `Bun.listen` safely (re)binds it (its own
197
- // unlink-and-rebind reclaims the stale file; no explicit rm needed).
198
- let liveOwner = false;
199
- try {
200
- const probe = await Promise.race([
201
- Bun.connect({ unix: sockPath, socket: { data() {} } }),
202
- new Promise<never>((_, reject) =>
203
- setTimeout(() => reject(new Error("manager liveness probe timeout")), 500),
204
- ),
205
- ]);
206
- liveOwner = true;
207
- probe.end();
208
- } catch {
209
- /* nothing accepting → free path, or a stale socket file from a crashed manager */
210
- }
211
- if (liveOwner) {
227
+ // Single-manager invariant + stale-socket reclamation. A live manager is
228
+ // never clobbered (we probe first and again on collision); a stale socket
229
+ // from a crashed/killed manager is reclaimed rather than crashing on
230
+ // EADDRINUSE. See acquireControlSocket for the full rationale (xcsh #1846).
231
+ const probeLive = async (p: string): Promise<boolean> => {
232
+ try {
233
+ const probe = await Promise.race([
234
+ Bun.connect({ unix: p, socket: { data() {} } }),
235
+ new Promise<never>((_, reject) =>
236
+ setTimeout(() => reject(new Error("manager liveness probe timeout")), 500),
237
+ ),
238
+ ]);
239
+ probe.end();
240
+ return true;
241
+ } catch {
242
+ return false; // nothing accepting → free path, or a stale socket file
243
+ }
244
+ };
245
+ const outcome = await acquireControlSocket({
246
+ sockPath,
247
+ probeLive,
248
+ listen: () => Bun.listen(socketConfig),
249
+ unlink: p => fs.rmSync(p, { force: true }),
250
+ isAddrInUse: err => (err as { code?: string } | null)?.code === "EADDRINUSE",
251
+ });
252
+ if (outcome === "already-live") {
212
253
  console.error(`[xcsh manager] another manager already live at ${sockPath}; exiting`);
213
254
  process.exit(0);
214
255
  }
215
- Bun.listen(socketConfig);
216
256
  console.error(`[xcsh manager] control socket listening at ${sockPath}`);
217
257
 
218
258
  // Idle sweep: reap workers untouched for longer than the TTL.
@@ -17,6 +17,7 @@ import { getProjectDir, getXCSHConfigDir } from "@f5-sales-demo/pi-utils";
17
17
  import { Command } from "@f5-sales-demo/pi-utils/cli";
18
18
  import { ChatHandler } from "../browser/chat-handler";
19
19
  import { startBridgeServer } from "../browser/extension-bridge";
20
+ import { createExtensionBridgeTools, EXTENSION_AGENT_TOOL_NAMES } from "../browser/extension-bridge-tools";
20
21
  import { setSharedBridgeServer } from "../browser/provider";
21
22
  import { initializeWithSettings } from "../discovery";
22
23
  import { createAgentSession } from "../sdk";
@@ -118,10 +119,16 @@ export default class Worker extends Command {
118
119
  bridge.setSessionInfo(sessionInfoForWorker);
119
120
  ContextService.onContextChange(() => bridge.broadcastTenantChanged());
120
121
 
122
+ // The extension's browser actions (navigate/click/read_ax/…) are not builtin
123
+ // tools — turn each into a bridge-proxying CustomTool so the agent can drive
124
+ // the browser (without this the agent only has catalog_workflow_runner and
125
+ // merely narrates "Navigating…"). Include their names in the tool scope.
126
+ const extensionTools = createExtensionBridgeTools(bridge);
121
127
  const { session } = await createAgentSession({
122
128
  cwd,
123
129
  hasUI: false,
124
- toolNames: BROWSER_TOOL_NAMES,
130
+ toolNames: [...new Set([...BROWSER_TOOL_NAMES, ...EXTENSION_AGENT_TOOL_NAMES])],
131
+ customTools: extensionTools,
125
132
  // Headless worker: no MCP discovery, no LSP warmup, no extension discovery —
126
133
  // keep startup lean and free of network calls / blocking prompts.
127
134
  enableMCP: false,
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.56.0",
21
- "commit": "34e6ed88ab9aea762a9819b7ad53dfa6b7807f9a",
22
- "shortCommit": "34e6ed8",
20
+ "version": "19.56.2",
21
+ "commit": "864f91ecbd26a814dda896b5c402a0a30494e942",
22
+ "shortCommit": "864f91e",
23
23
  "branch": "main",
24
- "tag": "v19.56.0",
25
- "commitDate": "2026-07-03T13:11:30Z",
26
- "buildDate": "2026-07-03T13:33:05.007Z",
24
+ "tag": "v19.56.2",
25
+ "commitDate": "2026-07-04T00:19:20Z",
26
+ "buildDate": "2026-07-04T00:42:34.771Z",
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/34e6ed88ab9aea762a9819b7ad53dfa6b7807f9a",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.56.0"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/864f91ecbd26a814dda896b5c402a0a30494e942",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.56.2"
33
33
  };