@f5-sales-demo/xcsh 19.54.0 → 19.55.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "19.54.0",
4
+ "version": "19.55.1",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -55,13 +55,13 @@
55
55
  "dependencies": {
56
56
  "@agentclientprotocol/sdk": "0.16.1",
57
57
  "@mozilla/readability": "^0.6",
58
- "@f5-sales-demo/xcsh-stats": "19.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.1",
59
+ "@f5-sales-demo/pi-agent-core": "19.55.1",
60
+ "@f5-sales-demo/pi-ai": "19.55.1",
61
+ "@f5-sales-demo/pi-natives": "19.55.1",
62
+ "@f5-sales-demo/pi-resource-management": "19.55.1",
63
+ "@f5-sales-demo/pi-tui": "19.55.1",
64
+ "@f5-sales-demo/pi-utils": "19.55.1",
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
@@ -89,7 +108,9 @@ export class BridgeServer {
89
108
  /** Stable per-process session id, advertised to the extension on `hello`. */
90
109
  #sessionId = `sess-${crypto.randomUUID()}`;
91
110
  /** Provider of this process's tenant identity, answering the `hello` handshake. */
92
- #sessionInfo: (() => { tenant: string | null; env: string | null; apiUrl: string | null }) | null = null;
111
+ #sessionInfo:
112
+ | (() => { tenant: string | null; env: string | null; apiUrl: string | null; contextBound: boolean })
113
+ | null = null;
93
114
 
94
115
  /** The port the WebSocket server is listening on (0 = not bound). */
95
116
  get port(): number {
@@ -126,13 +147,15 @@ export class BridgeServer {
126
147
 
127
148
  /** Set the tenant-identity provider that answers the extension's `hello`
128
149
  * handshake with `{ tenant, env, apiUrl }` for THIS xcsh process/context. */
129
- setSessionInfo(cb: () => { tenant: string | null; env: string | null; apiUrl: string | null }): void {
150
+ setSessionInfo(
151
+ cb: () => { tenant: string | null; env: string | null; apiUrl: string | null; contextBound: boolean },
152
+ ): void {
130
153
  this.#sessionInfo = cb;
131
154
  }
132
155
 
133
156
  /** Push a tenant change to all connected panels (e.g. after `/context activate`). */
134
157
  broadcastTenantChanged(): void {
135
- const info = this.#sessionInfo?.() ?? { tenant: null, env: null, apiUrl: null };
158
+ const info = this.#sessionInfo?.() ?? { tenant: null, env: null, apiUrl: null, contextBound: false };
136
159
  for (const c of this.#clients.values()) {
137
160
  try {
138
161
  c.send(JSON.stringify({ type: "tenant_changed", sessionId: this.#sessionId, ...info }));
@@ -159,56 +182,58 @@ export class BridgeServer {
159
182
  this.#resolveClient(channelId)?.send(JSON.stringify(payload));
160
183
  }
161
184
 
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 });
185
+ #onOpen(ws: ServerWebSocket<undefined>): void {
186
+ // Assign a channel ID to each connection. For backwards compat (single
187
+ // extension), the first connection gets "default". Additional connections
188
+ // get "ch-1", "ch-2", etc. — supporting multi-tab parallelism.
189
+ const channelId = this.#clients.size === 0 ? "default" : `ch-${++this.#nextChannelIndex}`;
190
+ (ws as unknown as { channelId: string }).channelId = channelId;
191
+ this.#clients.set(channelId, ws);
192
+ // Start a heartbeat ping to keep the MV3 service worker alive.
193
+ // Chrome suspends idle SWs after ~30s; a ping every 15s prevents that.
194
+ if (!this.#heartbeat) {
195
+ this.#heartbeat = setInterval(() => {
196
+ for (const c of this.#clients.values()) {
197
+ try {
198
+ c.send(JSON.stringify({ type: "ping" }));
199
+ } catch {
200
+ /* client may have dropped */
176
201
  }
177
202
  }
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);
203
+ }, 15_000);
204
+ }
205
+ for (const cb of this.#onConnected) cb();
206
+ }
207
+
208
+ /** Try to bind the loopback WS server to `port`. Returns false on EADDRINUSE
209
+ * (so the caller can try the next candidate); rethrows any other error. */
210
+ listen(port: number, opts?: { skipOriginCheck?: boolean }): boolean {
211
+ try {
212
+ this.#server = Bun.serve({
213
+ port,
214
+ hostname: "127.0.0.1",
215
+ fetch: (req, server) => {
216
+ if (!opts?.skipOriginCheck) {
217
+ const origin = req.headers.get("origin") ?? "";
218
+ const { EXTENSION_ID } = require("../cli/chrome-cli");
219
+ if (origin !== `chrome-extension://${EXTENSION_ID}`) {
220
+ return new Response("Forbidden", { status: 403 });
221
+ }
201
222
  }
202
- for (const cb of this.#onConnected) cb();
203
- },
204
- message: (ws, message) => {
205
- this.#handleMessage(ws, message);
223
+ if (server.upgrade(req)) return undefined;
224
+ return new Response("xcsh bridge: WebSocket only", { status: 426 });
206
225
  },
207
- close: ws => {
208
- this.#onClose(ws);
226
+ websocket: {
227
+ open: ws => this.#onOpen(ws),
228
+ message: (ws, message) => this.#handleMessage(ws, message),
229
+ close: ws => this.#onClose(ws),
209
230
  },
210
- },
211
- });
231
+ });
232
+ return true;
233
+ } catch (e) {
234
+ if (e instanceof Error && /EADDRINUSE|address already in use|in use/i.test(e.message)) return false;
235
+ throw e;
236
+ }
212
237
  }
213
238
 
214
239
  #handleMessage(ws: ServerWebSocket<undefined>, message: string | Buffer): void {
@@ -228,14 +253,17 @@ export class BridgeServer {
228
253
  ws.send(JSON.stringify({ type: "pong" }));
229
254
  } else if (msg.type === "hello") {
230
255
  // Identity handshake: tell the extension which tenant this process serves.
231
- const info = this.#sessionInfo?.() ?? { tenant: null, env: null, apiUrl: null };
256
+ const info = this.#sessionInfo?.() ?? { tenant: null, env: null, apiUrl: null, contextBound: false };
257
+ const { EXTENSION_CONTRACT_VERSION } = require("./capabilities.generated");
232
258
  ws.send(
233
259
  JSON.stringify({
234
260
  type: "hello_ack",
235
261
  sessionId: this.#sessionId,
262
+ contractVersion: EXTENSION_CONTRACT_VERSION,
236
263
  tenant: info.tenant,
237
264
  env: info.env,
238
265
  apiUrl: info.apiUrl,
266
+ contextBound: info.contextBound,
239
267
  pid: process.pid,
240
268
  }),
241
269
  );
@@ -293,12 +321,23 @@ export class BridgeServer {
293
321
  }
294
322
 
295
323
  /**
296
- * Start the {@link BridgeServer} on the resolved loopback port (explicit arg,
297
- * then `XCSH_BRIDGE_PORT`, then {@link DEFAULT_PORT}). The WebSocket transport
324
+ * Start the {@link BridgeServer} on the resolved loopback port. If a port is
325
+ * forced (explicit arg or `XCSH_BRIDGE_PORT`) it must be free throws loudly
326
+ * if taken. Otherwise, auto-selects the lowest free port in the discovery range
327
+ * ({@link PORT_RANGE_START}–{@link PORT_RANGE_END}). The WebSocket transport
298
328
  * needs no filesystem setup — Chrome connects directly to `ws://127.0.0.1:<port>`.
299
329
  */
300
330
  export async function startBridgeServer(port?: number, opts?: { skipOriginCheck?: boolean }): Promise<BridgeServer> {
301
331
  const server = new BridgeServer();
302
- server.listen(resolvePort(port), opts);
303
- return server;
332
+ const forced = resolveForcedPort(port);
333
+ if (forced !== null) {
334
+ if (!server.listen(forced, opts)) {
335
+ throw new Error(`XCSH_BRIDGE_PORT ${forced} is already in use — free it or pick another port`);
336
+ }
337
+ return server;
338
+ }
339
+ for (const candidate of portCandidates()) {
340
+ if (server.listen(candidate, opts)) return server;
341
+ }
342
+ throw new Error(`no free xcsh bridge port in ${PORT_RANGE_START}-${PORT_RANGE_END} — is another app on the range?`);
304
343
  }
@@ -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
@@ -6,12 +6,15 @@
6
6
  * touching Chrome, settings, or the network.
7
7
  */
8
8
 
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
9
11
  import { acquirePage, type BrowserProviderStatus, CdpBrowserProvider } from "../browser";
10
- import { resolvePort } from "../browser/extension-bridge";
12
+ import { PORT_RANGE_END, PORT_RANGE_START, resolveForcedPort } from "../browser/extension-bridge";
13
+ import { installNativeHost } from "../services/native-host-install";
11
14
 
12
15
  type Settings = { get(key: string): unknown };
13
16
 
14
- export type ChromeAction = "status" | "relaunch" | "setup";
17
+ export type ChromeAction = "status" | "relaunch" | "setup" | "install-host";
15
18
 
16
19
  export const EXTENSION_ID = "klajkjdoehjidngligegnpknogmjjhkc";
17
20
 
@@ -22,6 +25,49 @@ export const EXTENSION_ID = "klajkjdoehjidngligegnpknogmjjhkc";
22
25
  */
23
26
  export const WEB_STORE_URL = `https://chromewebstore.google.com/detail/${EXTENSION_ID}`;
24
27
 
28
+ /** Find a compiled `xcsh` binary on PATH (self-contained; survives Chrome's stripped env). */
29
+ function defaultResolveXcshBin(): string | null {
30
+ const dirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
31
+ for (const dir of dirs) {
32
+ const candidate = path.join(dir, "xcsh");
33
+ try {
34
+ fs.accessSync(candidate, fs.constants.X_OK);
35
+ return candidate;
36
+ } catch {
37
+ /* not on this PATH entry */
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * Resolve the exec prefix Chrome's launcher wrapper should invoke to reach the
45
+ * `chrome-host` relay (the wrapper appends `chrome-host "$@"`).
46
+ *
47
+ * Compiled: `process.execPath` IS the xcsh binary → ["<xcsh>"].
48
+ * Dev (`bun /abs/src/cli.ts …`): `process.execPath` is bun, which fails under
49
+ * Chrome's stripped env for a `.ts` entry — so prefer a compiled `xcsh` on PATH
50
+ * when resolvable; otherwise fall back to ["<bun>", "<abs entry script>"]. The
51
+ * script is resolved to an ABSOLUTE path because Chrome launches the host from an
52
+ * arbitrary working directory.
53
+ */
54
+ export function nativeHostLaunchCommand(
55
+ argv: string[] = process.argv,
56
+ execPath: string = process.execPath,
57
+ resolveXcshBin: () => string | null = defaultResolveXcshBin,
58
+ ): string[] {
59
+ const base = path.basename(execPath).toLowerCase();
60
+ if (base.startsWith("bun")) {
61
+ const xcsh = resolveXcshBin();
62
+ if (xcsh) return [xcsh];
63
+ const script = argv[1];
64
+ if (script && (script.endsWith(".ts") || script.endsWith(".js") || script.endsWith(".mjs"))) {
65
+ return [execPath, path.resolve(script)];
66
+ }
67
+ }
68
+ return [execPath];
69
+ }
70
+
25
71
  export function renderStatus(s: BrowserProviderStatus): string {
26
72
  const yn = (b: boolean) => (b ? "yes" : "no");
27
73
  return [
@@ -43,14 +89,28 @@ export async function runChromeCommand(action: ChromeAction, settings: Settings)
43
89
  if (action === "status") return renderStatus(await provider.status());
44
90
  if (action === "setup") {
45
91
  // 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();
92
+ // host manifest to install. A forced port is reported exactly; otherwise xcsh
93
+ // auto-selects the lowest free port in the discovery range at launch.
94
+ const forced = resolveForcedPort();
95
+ const where =
96
+ forced !== null
97
+ ? `ws://127.0.0.1:${forced} (forced via XCSH_BRIDGE_PORT)`
98
+ : `the lowest free port in ${PORT_RANGE_START}-${PORT_RANGE_END} (printed in the xcsh startup banner)`;
48
99
  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}`
100
+ `The xcsh Chrome extension connects directly to xcsh over a loopback WebSocket on ${where}.\n` +
101
+ `The extension scans that range and links each tenant's xcsh automatically.\n` +
102
+ `Install/keep the xcsh Chrome extension from the Web Store:\n ${WEB_STORE_URL}`
52
103
  );
53
104
  }
105
+ if (action === "install-host") {
106
+ // Chrome ignores a manifest `args` field and can't select the `chrome-host`
107
+ // subcommand, so the manifest `path` points at a generated wrapper that execs
108
+ // the resolved xcsh with `chrome-host`. Resolve the launch prefix here (reads
109
+ // process.execPath/argv/PATH); installNativeHost stays a pure writer.
110
+ const launchCommand = nativeHostLaunchCommand();
111
+ const manifestPath = installNativeHost({ launchCommand, extensionIds: [EXTENSION_ID] });
112
+ return `Native-messaging host manifest written to:\n ${manifestPath}`;
113
+ }
54
114
  // relaunch: self-consented rung 3 — force allowRelaunch regardless of the setting.
55
115
  const { mode } = await acquirePage({ settings, allowRelaunch: true });
56
116
  return `Chrome ready (${mode}). Your real, logged-in session is now debuggable for xcsh.`;
package/src/cli.ts CHANGED
@@ -52,16 +52,19 @@ const commands: CommandEntry[] = [
52
52
  { name: "commit", load: () => import("./commands/commit").then(m => m.default) },
53
53
  { name: "config", load: () => import("./commands/config").then(m => m.default) },
54
54
  { name: "chrome", load: () => import("./commands/chrome").then(m => m.default) },
55
+ { name: "chrome-host", load: () => import("./commands/native-host").then(m => m.default) },
55
56
  { name: "grep", load: () => import("./commands/grep").then(m => m.default) },
56
57
  { name: "grievances", load: () => import("./commands/grievances").then(m => m.default) },
57
58
  { name: "read", load: () => import("./commands/read").then(m => m.default) },
58
59
  { name: "jupyter", load: () => import("./commands/jupyter").then(m => m.default) },
60
+ { name: "manager", load: () => import("./commands/manager").then(m => m.default) },
59
61
  { name: "plugin", load: () => import("./commands/plugin").then(m => m.default) },
60
62
  { name: "setup", load: () => import("./commands/setup").then(m => m.default) },
61
63
  { name: "shell", load: () => import("./commands/shell").then(m => m.default) },
62
64
  { name: "ssh", load: () => import("./commands/ssh").then(m => m.default) },
63
65
  { name: "stats", load: () => import("./commands/stats").then(m => m.default) },
64
66
  { name: "update", load: () => import("./commands/update").then(m => m.default) },
67
+ { name: "worker", load: () => import("./commands/worker").then(m => m.default) },
65
68
  { name: "search", load: () => import("./commands/web-search").then(m => m.default), aliases: ["q"] },
66
69
  ];
67
70
 
@@ -90,11 +93,17 @@ export function runCli(argv: string[]): Promise<void> {
90
93
  // Everything else that isn't a known subcommand routes to "launch".
91
94
  const first = argv[0];
92
95
  const runArgv =
93
- first === "--help" || first === "-h" || first === "--version" || first === "-v" || first === "help"
94
- ? argv
95
- : isSubcommand(first)
96
+ // Chrome launches the native-messaging host with the calling extension's
97
+ // origin (chrome-extension://…/) as the first arg. Route that to the
98
+ // `chrome-host` relay — a safety net if a manifest `path` points straight at
99
+ // the binary instead of the launcher wrapper.
100
+ first?.startsWith("chrome-extension://")
101
+ ? ["chrome-host", ...argv]
102
+ : first === "--help" || first === "-h" || first === "--version" || first === "-v" || first === "help"
96
103
  ? argv
97
- : ["launch", ...argv];
104
+ : isSubcommand(first)
105
+ ? argv
106
+ : ["launch", ...argv];
98
107
  return run({ bin: APP_NAME, version: VERSION, argv: runArgv, commands, help: showHelp });
99
108
  }
100
109
 
@@ -5,7 +5,7 @@ import { Args, Command } from "@f5-sales-demo/pi-utils/cli";
5
5
  import { type ChromeAction, runChromeCommand } from "../cli/chrome-cli";
6
6
  import { Settings, settings } from "../config/settings";
7
7
 
8
- const ACTIONS: ChromeAction[] = ["status", "relaunch", "setup"];
8
+ const ACTIONS: ChromeAction[] = ["status", "relaunch", "setup", "install-host"];
9
9
 
10
10
  export default class Chrome extends Command {
11
11
  static description = "Inspect or arrange the Chrome session xcsh drives for console automation";
@@ -0,0 +1,49 @@
1
+ /** Pure worker-registry + control-protocol logic for the manager. No I/O. */
2
+
3
+ export interface WorkerRec {
4
+ tenantKey: string; // "tenant|env"
5
+ port: number;
6
+ pid: number;
7
+ lastSeen: number; // epoch ms
8
+ }
9
+
10
+ export type Registry = Map<string, WorkerRec>;
11
+
12
+ export type ControlMsg =
13
+ | { type: "provision"; tenantKey: string }
14
+ | { type: "release"; tenantKey: string }
15
+ | { type: "status" };
16
+
17
+ function isTenantKey(v: unknown): v is string {
18
+ return typeof v === "string" && /^[^|]+\|[^|]+$/.test(v);
19
+ }
20
+
21
+ /** Validate an inbound control frame; null if malformed (fail closed). */
22
+ export function parseControlMsg(raw: unknown): ControlMsg | null {
23
+ if (!raw || typeof raw !== "object") return null;
24
+ const m = raw as Record<string, unknown>;
25
+ if (m.type === "status") return { type: "status" };
26
+ if ((m.type === "provision" || m.type === "release") && isTenantKey(m.tenantKey)) {
27
+ return { type: m.type, tenantKey: m.tenantKey };
28
+ }
29
+ return null;
30
+ }
31
+
32
+ /** Idempotency: only provision when there is no live worker for the key. */
33
+ export function needsProvision(reg: Registry, tenantKey: string): boolean {
34
+ return !reg.has(tenantKey);
35
+ }
36
+
37
+ /** Lowest free port in the range not already held by a worker. */
38
+ export function pickPort(reg: Registry, range: number[]): number | null {
39
+ const used = new Set([...reg.values()].map(w => w.port));
40
+ for (const p of range) if (!used.has(p)) return p;
41
+ return null;
42
+ }
43
+
44
+ /** Keys whose worker has been idle longer than idleMs. */
45
+ export function staleKeys(reg: Registry, now: number, idleMs: number): string[] {
46
+ const out: string[] = [];
47
+ for (const w of reg.values()) if (now - w.lastSeen > idleMs) out.push(w.tenantKey);
48
+ return out;
49
+ }