@f5-sales-demo/xcsh 19.55.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.55.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.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",
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",
@@ -108,7 +108,9 @@ export class BridgeServer {
108
108
  /** Stable per-process session id, advertised to the extension on `hello`. */
109
109
  #sessionId = `sess-${crypto.randomUUID()}`;
110
110
  /** Provider of this process's tenant identity, answering the `hello` handshake. */
111
- #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;
112
114
 
113
115
  /** The port the WebSocket server is listening on (0 = not bound). */
114
116
  get port(): number {
@@ -145,13 +147,15 @@ export class BridgeServer {
145
147
 
146
148
  /** Set the tenant-identity provider that answers the extension's `hello`
147
149
  * handshake with `{ tenant, env, apiUrl }` for THIS xcsh process/context. */
148
- 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 {
149
153
  this.#sessionInfo = cb;
150
154
  }
151
155
 
152
156
  /** Push a tenant change to all connected panels (e.g. after `/context activate`). */
153
157
  broadcastTenantChanged(): void {
154
- const info = this.#sessionInfo?.() ?? { tenant: null, env: null, apiUrl: null };
158
+ const info = this.#sessionInfo?.() ?? { tenant: null, env: null, apiUrl: null, contextBound: false };
155
159
  for (const c of this.#clients.values()) {
156
160
  try {
157
161
  c.send(JSON.stringify({ type: "tenant_changed", sessionId: this.#sessionId, ...info }));
@@ -249,7 +253,7 @@ export class BridgeServer {
249
253
  ws.send(JSON.stringify({ type: "pong" }));
250
254
  } else if (msg.type === "hello") {
251
255
  // Identity handshake: tell the extension which tenant this process serves.
252
- const info = this.#sessionInfo?.() ?? { tenant: null, env: null, apiUrl: null };
256
+ const info = this.#sessionInfo?.() ?? { tenant: null, env: null, apiUrl: null, contextBound: false };
253
257
  const { EXTENSION_CONTRACT_VERSION } = require("./capabilities.generated");
254
258
  ws.send(
255
259
  JSON.stringify({
@@ -259,6 +263,7 @@ export class BridgeServer {
259
263
  tenant: info.tenant,
260
264
  env: info.env,
261
265
  apiUrl: info.apiUrl,
266
+ contextBound: info.contextBound,
262
267
  pid: process.pid,
263
268
  }),
264
269
  );
@@ -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
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 [
@@ -56,6 +102,15 @@ export async function runChromeCommand(action: ChromeAction, settings: Settings)
56
102
  `Install/keep the xcsh Chrome extension from the Web Store:\n ${WEB_STORE_URL}`
57
103
  );
58
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
+ }
59
114
  // relaunch: self-consented rung 3 — force allowRelaunch regardless of the setting.
60
115
  const { mode } = await acquirePage({ settings, allowRelaunch: true });
61
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
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Manager mode — `xcsh manager`.
3
+ *
4
+ * A detached, long-lived control server that owns the fleet of per-tenant
5
+ * `xcsh worker` processes. It listens on a UNIX SOCKET (`~/.xcsh/manager.sock`,
6
+ * override `XCSH_MANAGER_SOCK`) for NDJSON control frames — one JSON object per
7
+ * line — validated by the pure `manager-core` protocol:
8
+ *
9
+ * {"type":"provision","tenantKey":"acme|staging"} → spawn a worker (idempotent)
10
+ * {"type":"release","tenantKey":"acme|staging"} → kill + forget the worker
11
+ * {"type":"status"} → accepted; no-op sink
12
+ *
13
+ * All registry/port/idempotency/idle policy is the pure `manager-core`; this file
14
+ * is the thin I/O shell (socket, spawn, kill, timer) around it. A background sweep
15
+ * reaps workers idle longer than the TTL. The process blocks forever.
16
+ */
17
+ import * as fs from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { dirname, join } from "node:path";
20
+ import { Command } from "@f5-sales-demo/pi-utils/cli";
21
+ import { portCandidates } from "../browser/extension-bridge";
22
+ import { needsProvision, parseControlMsg, pickPort, type Registry, staleKeys } from "./manager-core";
23
+
24
+ /** Reap a worker idle longer than this (ms). */
25
+ const IDLE_MS = 20 * 60_000;
26
+ /** How often the idle sweep runs (ms). */
27
+ const SWEEP_MS = 60_000;
28
+
29
+ /**
30
+ * Whether a loopback TCP port is bindable RIGHT NOW. `pickPort` only dedupes
31
+ * against the manager's own registry; a range port can still be held by another
32
+ * app, a stale worker, or a second manager. We pre-filter the range with this so
33
+ * a spawned worker (which binds its forced `XCSH_BRIDGE_PORT` strictly) never
34
+ * lands on an occupied port and dies. Bun.listen binds and throws synchronously,
35
+ * so this stays sync — keeping provision idempotency race-free.
36
+ */
37
+ function isPortFree(port: number): boolean {
38
+ try {
39
+ const listener = Bun.listen({
40
+ hostname: "127.0.0.1",
41
+ port,
42
+ socket: { data() {}, open() {}, close() {}, error() {} },
43
+ });
44
+ listener.stop(true);
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * The argv (AFTER `process.execPath`) to re-run THIS binary in `mode`.
53
+ *
54
+ * Dev: launched as `bun /abs/src/cli.ts manager`, so `process.argv[1]` is the
55
+ * script path → `["/abs/src/cli.ts", <mode>]`, and `process.execPath` is bun.
56
+ *
57
+ * Compiled: `process.execPath` IS the xcsh binary and `process.argv[1]` is the
58
+ * subcommand (e.g. "manager"), not a script file → `[<mode>]`.
59
+ *
60
+ * We detect dev by the script extension so the integration tests — which run
61
+ * `bun src/cli.ts <mode>` — re-exec a genuinely working subcommand. Shared by
62
+ * the manager (worker re-exec) and the chrome-host (manager re-exec).
63
+ */
64
+ export function reexecArgv(mode: "worker" | "manager"): string[] {
65
+ const script = process.argv[1];
66
+ if (script && (script.endsWith(".ts") || script.endsWith(".js") || script.endsWith(".mjs"))) {
67
+ return [script, mode];
68
+ }
69
+ return [mode];
70
+ }
71
+
72
+ /** The argv (AFTER `process.execPath`) to re-run THIS binary in `worker` mode. */
73
+ export function workerArgv(): string[] {
74
+ return reexecArgv("worker");
75
+ }
76
+
77
+ export default class Manager extends Command {
78
+ static description = "Run the detached control server that spawns/reaps per-tenant workers; blocks forever";
79
+
80
+ async run(): Promise<void> {
81
+ const sockPath = process.env.XCSH_MANAGER_SOCK ?? join(homedir(), ".xcsh", "manager.sock");
82
+ fs.mkdirSync(dirname(sockPath), { recursive: true });
83
+
84
+ const reg: Registry = new Map();
85
+ const range = portCandidates();
86
+
87
+ const reap = (tenantKey: string): void => {
88
+ const w = reg.get(tenantKey);
89
+ if (!w) return;
90
+ try {
91
+ process.kill(w.pid);
92
+ } catch {
93
+ /* already gone */
94
+ }
95
+ reg.delete(tenantKey);
96
+ };
97
+
98
+ const spawnWorker = (tenantKey: string): void => {
99
+ // Registry-dedupe (pickPort) over only the ports free at the OS level.
100
+ const port = pickPort(reg, range.filter(isPortFree));
101
+ if (port === null) {
102
+ console.error(`[xcsh manager] port range exhausted; cannot provision ${tenantKey}`);
103
+ return;
104
+ }
105
+ const proc = Bun.spawn([process.execPath, ...workerArgv()], {
106
+ env: {
107
+ ...process.env,
108
+ XCSH_BROWSER_PROVIDER: "extension",
109
+ XCSH_SESSION_TENANT: tenantKey,
110
+ XCSH_BRIDGE_PORT: String(port),
111
+ // Isolate the worker's tenant binding: an ambient XCSH_API_URL in the
112
+ // manager's env would make sdk.ts skip the XCSH_SESSION_TENANT branch and
113
+ // bind hello_ack.tenant from the env apiUrl instead. Clear both so the
114
+ // spawned tenant key is authoritative (undefined removes the var in Bun).
115
+ XCSH_API_URL: undefined,
116
+ XCSH_API_TOKEN: undefined,
117
+ },
118
+ stdout: "ignore",
119
+ stderr: "ignore",
120
+ });
121
+ reg.set(tenantKey, { tenantKey, port, pid: proc.pid, lastSeen: Date.now() });
122
+ // Reconcile a dead worker: when THIS process exits (crash, forced-port
123
+ // EADDRINUSE with no retry, etc.), drop its registry entry so the next
124
+ // provision respawns instead of finding a zombie. Guard on pid so an
125
+ // already-respawned entry for the same tenant is never clobbered.
126
+ proc.exited.then(() => {
127
+ const cur = reg.get(tenantKey);
128
+ if (cur && cur.pid === proc.pid) reg.delete(tenantKey);
129
+ });
130
+ console.error(`[xcsh manager] provisioned ${tenantKey} → pid ${proc.pid} on port ${port}`);
131
+ };
132
+
133
+ const handleFrame = (line: string): void => {
134
+ const trimmed = line.trim();
135
+ if (!trimmed) return;
136
+ let raw: unknown;
137
+ try {
138
+ raw = JSON.parse(trimmed);
139
+ } catch {
140
+ return; // malformed line — ignore, keep serving
141
+ }
142
+ const msg = parseControlMsg(raw);
143
+ if (!msg) return; // fail closed on unknown/invalid frames
144
+ if (msg.type === "provision") {
145
+ if (needsProvision(reg, msg.tenantKey)) spawnWorker(msg.tenantKey);
146
+ const w = reg.get(msg.tenantKey);
147
+ if (w) w.lastSeen = Date.now(); // touch on every provision (keep-alive)
148
+ } else if (msg.type === "release") {
149
+ reap(msg.tenantKey);
150
+ }
151
+ // "status" is validated but currently a no-op sink.
152
+ };
153
+
154
+ // Per-connection NDJSON buffer: a control frame can be split across two TCP
155
+ // reads, so we only parse complete newline-terminated lines and retain any
156
+ // trailing fragment until the rest of it arrives. Keyed by socket (WeakMap
157
+ // so a closed connection's buffer is collected without manual bookkeeping).
158
+ const buffers = new WeakMap<object, string>();
159
+ const socketConfig = {
160
+ unix: sockPath,
161
+ socket: {
162
+ data(socket: object, data: Buffer): void {
163
+ const combined = (buffers.get(socket) ?? "") + data.toString("utf8");
164
+ const parts = combined.split("\n");
165
+ buffers.set(socket, parts.pop() ?? ""); // keep the incomplete trailing fragment
166
+ for (const line of parts) handleFrame(line);
167
+ },
168
+ close(socket: object): void {
169
+ buffers.delete(socket);
170
+ },
171
+ },
172
+ };
173
+
174
+ // Single-manager invariant — probe liveness BEFORE binding.
175
+ //
176
+ // Two chrome-hosts can cold-start concurrently, both find no socket, and
177
+ // both spawn a manager. `Bun.listen({unix})` SILENTLY unlinks and rebinds
178
+ // any existing socket path — it does NOT throw EADDRINUSE even when a live
179
+ // manager is accepting on it (verified on Bun 1.3.x, in- and cross-process).
180
+ // So a naive `listen` would clobber the first manager's socket, leaving it
181
+ // orphaned on an unlinked path (blocking forever, leaking its worker ports)
182
+ // while a second live manager takes over — breaking "at most one manager".
183
+ //
184
+ // Guard by CONNECTING first: if a live manager answers, this process lost
185
+ // the race and exits WITHOUT touching the socket file (it belongs to the
186
+ // live manager). If nothing answers, the path is free or a STALE file from
187
+ // a crashed manager — either way `Bun.listen` safely (re)binds it (its own
188
+ // unlink-and-rebind reclaims the stale file; no explicit rm needed).
189
+ let liveOwner = false;
190
+ try {
191
+ const probe = await Promise.race([
192
+ Bun.connect({ unix: sockPath, socket: { data() {} } }),
193
+ new Promise<never>((_, reject) =>
194
+ setTimeout(() => reject(new Error("manager liveness probe timeout")), 500),
195
+ ),
196
+ ]);
197
+ liveOwner = true;
198
+ probe.end();
199
+ } catch {
200
+ /* nothing accepting → free path, or a stale socket file from a crashed manager */
201
+ }
202
+ if (liveOwner) {
203
+ console.error(`[xcsh manager] another manager already live at ${sockPath}; exiting`);
204
+ process.exit(0);
205
+ }
206
+ Bun.listen(socketConfig);
207
+ console.error(`[xcsh manager] control socket listening at ${sockPath}`);
208
+
209
+ // Idle sweep: reap workers untouched for longer than the TTL.
210
+ setInterval(() => {
211
+ for (const key of staleKeys(reg, Date.now(), IDLE_MS)) reap(key);
212
+ }, SWEEP_MS);
213
+
214
+ // Detached, long-lived: block until the process is torn down.
215
+ await new Promise<never>(() => {});
216
+ }
217
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * `chrome-host` — native-messaging relay subcommand.
3
+ *
4
+ * Launched by Chrome as the native-messaging host. It is a thin relay between
5
+ * Chrome's stdio (native-messaging framing: 4-byte LE length + JSON) and the
6
+ * long-lived `xcsh manager` control server's UNIX socket (newline-delimited
7
+ * JSON). No business logic lives here.
8
+ *
9
+ * Unlike a plain relay, the host ENSURES the manager before relaying: if the
10
+ * control socket can't be connected (manager not yet running), it spawns a
11
+ * DETACHED, long-lived `xcsh manager` and retries with a short backoff. The
12
+ * ensure is idempotent — if the first connect succeeds no manager is spawned,
13
+ * so we never launch two. If the manager still can't be reached after the
14
+ * backoff we exit cleanly (0); the extension retries the whole bootstrap.
15
+ */
16
+ import { homedir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { Command } from "@f5-sales-demo/pi-utils/cli";
19
+ import { decodeNm, encodeNm } from "../browser/native-messaging";
20
+ import { reexecArgv } from "./manager";
21
+
22
+ /** Manager control socket — MUST match `commands/manager.ts` exactly. */
23
+ const SOCKET_PATH = process.env.XCSH_MANAGER_SOCK ?? join(homedir(), ".xcsh", "manager.sock");
24
+
25
+ /** Connect-retry budget after (re)spawning the manager: ~2s total. */
26
+ const CONNECT_RETRIES = 20;
27
+ const CONNECT_BACKOFF_MS = 100;
28
+
29
+ // Diagnostic relay tracing, OFF unless ~/.xcsh/chrome-host-debug exists. Security:
30
+ // writes to a 0600 file in the user's home, lifecycle + byte-counts ONLY — never
31
+ // message content — and never to stdout (that would corrupt the NM stream).
32
+ function dbg(msg: string): void {
33
+ try {
34
+ const fs = require("node:fs");
35
+ const home = process.env.HOME || homedir();
36
+ if (!fs.existsSync(join(home, ".xcsh", "chrome-host-debug"))) return;
37
+ const fd = fs.openSync(join(home, ".xcsh", "chrome-host.log"), "a", 0o600);
38
+ fs.appendFileSync(fd, `${new Date().toISOString()} pid=${process.pid} ${msg}\n`);
39
+ fs.closeSync(fd);
40
+ } catch {
41
+ /* logging must never break the relay */
42
+ }
43
+ }
44
+
45
+ type Sock = Awaited<ReturnType<typeof Bun.connect>>;
46
+
47
+ export default class ChromeHost extends Command {
48
+ static description = "Native-messaging relay between Chrome and the xcsh manager socket (internal)";
49
+
50
+ async run(): Promise<void> {
51
+ let socketBuffer = "";
52
+ const socketHandlers = {
53
+ data(_sock: Sock, chunk: Uint8Array): void {
54
+ // Manager → Chrome: NDJSON lines → native-messaging frames.
55
+ socketBuffer += new TextDecoder().decode(chunk);
56
+ let newlineIndex = socketBuffer.indexOf("\n");
57
+ while (newlineIndex !== -1) {
58
+ const line = socketBuffer.slice(0, newlineIndex);
59
+ socketBuffer = socketBuffer.slice(newlineIndex + 1);
60
+ if (line.length > 0) {
61
+ dbg(`socket→chrome ${line.length}B`);
62
+ // Guard the parse/encode: a malformed manager line would otherwise
63
+ // throw INSIDE this Bun socket callback. Drop it (trace only) — never
64
+ // surface the error on stdout, which is the native-messaging stream.
65
+ try {
66
+ process.stdout.write(encodeNm(JSON.parse(line)));
67
+ } catch {
68
+ dbg("socket→chrome drop: malformed line");
69
+ }
70
+ }
71
+ newlineIndex = socketBuffer.indexOf("\n");
72
+ }
73
+ },
74
+ close(): void {
75
+ dbg("manager socket closed → exit");
76
+ process.exit(0);
77
+ },
78
+ error(): void {
79
+ dbg("manager socket error → exit");
80
+ process.exit(0);
81
+ },
82
+ };
83
+
84
+ dbg(`start sock=${SOCKET_PATH} argc=${process.argv.length}`);
85
+ const socket = await this.ensureManager(socketHandlers);
86
+ if (!socket) {
87
+ // Manager unreachable after ensure — exit cleanly; the extension retries.
88
+ dbg("ensureManager: unreachable → exit");
89
+ process.exit(0);
90
+ }
91
+
92
+ // Chrome stdin → manager: native-messaging frames → NDJSON lines.
93
+ dbg("reading chrome stdin");
94
+ let stdinBuffer: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
95
+ for await (const chunk of process.stdin) {
96
+ const bytes = new Uint8Array(chunk);
97
+ const next = new Uint8Array(stdinBuffer.length + bytes.length);
98
+ next.set(stdinBuffer, 0);
99
+ next.set(bytes, stdinBuffer.length);
100
+ const { messages, rest } = decodeNm(next);
101
+ stdinBuffer = rest;
102
+ for (const msg of messages) {
103
+ dbg(`chrome→socket ${JSON.stringify(msg).length}B`);
104
+ socket.write(`${JSON.stringify(msg)}\n`);
105
+ }
106
+ }
107
+
108
+ // Chrome closed stdin — tear down and exit.
109
+ dbg("chrome stdin EOF → exit");
110
+ socket.end();
111
+ process.exit(0);
112
+ }
113
+
114
+ /**
115
+ * Connect to the manager, spawning a detached one if the first connect fails.
116
+ * Idempotent: a successful first connect spawns nothing (never two managers).
117
+ * Returns the connected socket, or undefined if unreachable after backoff.
118
+ */
119
+ private async ensureManager(socket: Parameters<typeof Bun.connect>[0]["socket"]): Promise<Sock | undefined> {
120
+ try {
121
+ dbg("connect: first attempt");
122
+ return await Bun.connect({ unix: SOCKET_PATH, socket });
123
+ } catch {
124
+ dbg("connect: failed → spawn detached manager");
125
+ }
126
+
127
+ // Spawn the manager DETACHED and long-lived. It is NOT awaited/retained so
128
+ // it outlives this host process; on macOS killing the host does not kill it.
129
+ Bun.spawn([process.execPath, ...reexecArgv("manager")], {
130
+ stdin: "ignore",
131
+ stdout: "ignore",
132
+ stderr: "ignore",
133
+ });
134
+
135
+ // Retry-connect with a short backoff while the manager binds its socket.
136
+ for (let i = 0; i < CONNECT_RETRIES; i++) {
137
+ await Bun.sleep(CONNECT_BACKOFF_MS);
138
+ try {
139
+ const s = await Bun.connect({ unix: SOCKET_PATH, socket });
140
+ dbg(`connect: succeeded after ${i + 1} retries`);
141
+ return s;
142
+ } catch {
143
+ /* manager not bound yet — keep retrying */
144
+ }
145
+ }
146
+ return undefined;
147
+ }
148
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Headless worker mode — `xcsh worker`.
3
+ *
4
+ * A non-interactive process that starts the Chrome-extension bridge, creates ONE
5
+ * agent session bound to this worker's tenant (`XCSH_SESSION_TENANT`, matched to a
6
+ * context by the session-context bootstrap in `createAgentSession`), attaches the
7
+ * chat handler, and blocks until SIGTERM/SIGINT. It mirrors the extension-bridge
8
+ * startup path in `main.ts` (bridge → setSessionInfo → browser-only tool scoping →
9
+ * createAgentSession → ChatHandler.attach) MINUS the TUI.
10
+ *
11
+ * Unlike the interactive path — whose `hello_ack` tenant is derived purely from the
12
+ * active context's apiUrl (null when contextless) — the worker also falls back to
13
+ * `XCSH_SESSION_TENANT` so it advertises its assigned tenant even before a context
14
+ * is bound. This lets the extension panel lock onto the right tenant immediately.
15
+ */
16
+ import { getProjectDir, getXCSHConfigDir } from "@f5-sales-demo/pi-utils";
17
+ import { Command } from "@f5-sales-demo/pi-utils/cli";
18
+ import { ChatHandler } from "../browser/chat-handler";
19
+ import { startBridgeServer } from "../browser/extension-bridge";
20
+ import { setSharedBridgeServer } from "../browser/provider";
21
+ import { initializeWithSettings } from "../discovery";
22
+ import { createAgentSession } from "../sdk";
23
+ import { ContextService } from "../services/xcsh-context";
24
+ import { sessionKeyFromUrl } from "../services/xcsh-env";
25
+
26
+ /** Tenant identity for the `hello` handshake. The active context wins; when the
27
+ * worker is contextless we parse `XCSH_SESSION_TENANT` (`tenant|env`) so the panel
28
+ * still learns which tenant this process serves (apiUrl stays null). Must be sync —
29
+ * the bridge invokes it synchronously while answering `hello`. */
30
+ export function sessionInfoForWorker(): {
31
+ tenant: string | null;
32
+ env: string | null;
33
+ apiUrl: string | null;
34
+ contextBound: boolean;
35
+ } {
36
+ let apiUrl: string | null = null;
37
+ let contextBound = false;
38
+ try {
39
+ apiUrl = ContextService.instance.activeApiUrl;
40
+ // A worker is "context-bound" when it has an active stored context (not just an env-derived apiUrl).
41
+ contextBound = ContextService.instance.getStatus().activeContextName != null;
42
+ } catch {
43
+ /* ContextService not initialized — fall through to env; contextBound stays false. */
44
+ }
45
+ apiUrl = apiUrl ?? process.env.XCSH_API_URL ?? null;
46
+ if (apiUrl) {
47
+ const key = sessionKeyFromUrl(apiUrl);
48
+ return { tenant: key?.tenant ?? null, env: key?.env ?? null, apiUrl, contextBound };
49
+ }
50
+ // Contextless: the worker's assigned tenant is carried in XCSH_SESSION_TENANT.
51
+ const raw = process.env.XCSH_SESSION_TENANT;
52
+ if (raw) {
53
+ const [tenant, env] = raw.split("|");
54
+ return { tenant: tenant || null, env: env || null, apiUrl: null, contextBound };
55
+ }
56
+ return { tenant: null, env: null, apiUrl: null, contextBound };
57
+ }
58
+
59
+ /** Browser-automation tool set — identical scoping to `main.ts`'s extension path.
60
+ * With scoped tools the ONLY way to create a resource is the form-driven workflow
61
+ * runner, which is exactly what the human watching the browser wants. */
62
+ const BROWSER_TOOL_NAMES = [
63
+ "catalog_workflow_runner",
64
+ "navigate",
65
+ "click",
66
+ "click_element",
67
+ "fill",
68
+ "type_text",
69
+ "screenshot",
70
+ "login",
71
+ "read_ax",
72
+ "get_page_context",
73
+ "query_dom",
74
+ "find",
75
+ "wait_for",
76
+ "key_press",
77
+ "select_option",
78
+ "label_select",
79
+ "scroll_to",
80
+ "annotate",
81
+ "set_explain_mode",
82
+ ];
83
+
84
+ export default class Worker extends Command {
85
+ static description = "Run a headless extension-bridge worker (no TUI); blocks until SIGTERM";
86
+
87
+ async run(): Promise<void> {
88
+ process.env.XCSH_BROWSER_PROVIDER = "extension";
89
+
90
+ const cwd = getProjectDir();
91
+ const { Settings, settings } = await import("../config/settings");
92
+ await Settings.init({ cwd });
93
+
94
+ // Init the ContextService singleton so the session-context bootstrap (Task 3)
95
+ // can match XCSH_SESSION_TENANT to a stored context, and so sessionInfoForWorker
96
+ // can read the active apiUrl once bound.
97
+ try {
98
+ ContextService.init(getXCSHConfigDir());
99
+ } catch {
100
+ /* already initialized / unavailable — continue. */
101
+ }
102
+
103
+ // Provider persistence for model discovery (parity with main.ts).
104
+ initializeWithSettings(settings);
105
+
106
+ // Quiet startup: skip the welcome screen + blocking plugin "Fix now?" prompts.
107
+ settings.override("startup.quiet", true);
108
+
109
+ // INSTANT-ON: start the bridge before the heavy session init so the extension
110
+ // can connect immediately. Honors XCSH_BRIDGE_PORT (forced) or auto-selects.
111
+ const bridge = await startBridgeServer();
112
+ console.error(`[xcsh worker] extension bridge listening on ws://127.0.0.1:${bridge.port}`);
113
+ setSharedBridgeServer(bridge);
114
+ bridge.setSessionInfo(sessionInfoForWorker);
115
+ ContextService.onContextChange(() => bridge.broadcastTenantChanged());
116
+
117
+ const { session } = await createAgentSession({
118
+ cwd,
119
+ hasUI: false,
120
+ toolNames: BROWSER_TOOL_NAMES,
121
+ // Headless worker: no MCP discovery, no LSP warmup, no extension discovery —
122
+ // keep startup lean and free of network calls / blocking prompts.
123
+ enableMCP: false,
124
+ enableLsp: false,
125
+ disableExtensionDiscovery: true,
126
+ });
127
+
128
+ const chatHandler = new ChatHandler(bridge, session);
129
+ chatHandler.attach();
130
+
131
+ let shuttingDown = false;
132
+ const shutdown = () => {
133
+ if (shuttingDown) return;
134
+ shuttingDown = true;
135
+ chatHandler.dispose();
136
+ void bridge.close().finally(() => process.exit(0));
137
+ };
138
+ process.on("SIGTERM", shutdown);
139
+ process.on("SIGINT", shutdown);
140
+
141
+ // Block until a signal tears us down.
142
+ await new Promise<never>(() => {});
143
+ }
144
+ }
@@ -1,23 +1,77 @@
1
+ import { connect } from "node:net";
1
2
  import type { ExtensionAPI } from "@f5-sales-demo/xcsh";
2
3
 
3
4
  /**
4
5
  * herdr integration (bundled, default-on).
5
6
  *
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.
7
+ * Reports xcsh's live agent state to the herdr terminal multiplexer so an xcsh
8
+ * pane shows up as a first-class "xcsh" assistant with an idle / working /
9
+ * blocked indicator.
10
+ *
11
+ * Transport: herdr injects `HERDR_SOCKET_PATH` into every pane, so state is
12
+ * reported by writing a newline-delimited JSON-RPC request straight to that unix
13
+ * socket. This is PATH-independent — unlike shelling out to the `herdr` CLI,
14
+ * which silently no-ops when herdr runs as a launchd/`brew services` server and
15
+ * spawns panes without `/opt/homebrew/bin` on PATH. If `HERDR_SOCKET_PATH` is
16
+ * somehow unset, we fall back to the `herdr` CLI.
9
17
  *
10
18
  * xcsh is a fork of pi; a user may have both installed, so this reporter always
11
19
  * 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.
20
+ * `source: "xcsh"` so herdr's passive pi-detection heuristics cannot mislabel
21
+ * the pane.
14
22
  *
15
23
  * 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.
24
+ * (detected via `HERDR_PANE_ID`), so it has zero effect for users who do not run
25
+ * xcsh under herdr.
18
26
  */
19
27
 
20
28
  const HERDR_AGENT_LABEL = "xcsh";
29
+ const REPORT_METHOD = "pane.report_agent";
30
+ const RELEASE_METHOD = "pane.release_agent";
31
+ const SOCKET_TIMEOUT_MS = 2000;
32
+
33
+ /**
34
+ * Write one newline-delimited JSON-RPC request to herdr's unix socket and close.
35
+ * Fire-and-forget: the response is ignored and every failure path is reported
36
+ * via `onError` without throwing, so a dead socket never disturbs the agent.
37
+ */
38
+ function sendToHerdrSocket(
39
+ socketPath: string,
40
+ method: string,
41
+ params: Record<string, unknown>,
42
+ onError: (err: unknown) => void,
43
+ ): void {
44
+ const conn = connect({ path: socketPath });
45
+ // Never let a report keep xcsh's event loop alive.
46
+ conn.unref();
47
+ conn.once("error", err => {
48
+ onError(err);
49
+ conn.destroy();
50
+ });
51
+ conn.once("connect", () => {
52
+ conn.end(`${JSON.stringify({ id: "xcsh:herdr-reporter", method, params })}\n`);
53
+ });
54
+ conn.setTimeout(SOCKET_TIMEOUT_MS, () => conn.destroy());
55
+ }
56
+
57
+ /** Translate a JSON-RPC report/release into `herdr` CLI arguments (fallback). */
58
+ function toCliArgs(method: string, params: Record<string, unknown>): string[] {
59
+ const subcommand = method === REPORT_METHOD ? "report-agent" : "release-agent";
60
+ const args = [
61
+ "pane",
62
+ subcommand,
63
+ String(params.pane_id),
64
+ "--source",
65
+ String(params.source),
66
+ "--agent",
67
+ String(params.agent),
68
+ ];
69
+ if (params.state !== undefined) {
70
+ args.push("--state", String(params.state));
71
+ }
72
+ args.push("--seq", String(params.seq));
73
+ return args;
74
+ }
21
75
 
22
76
  export default function herdrReporter(pi: ExtensionAPI): void {
23
77
  const paneId = process.env.HERDR_PANE_ID;
@@ -33,29 +87,30 @@ export default function herdrReporter(pi: ExtensionAPI): void {
33
87
 
34
88
  pi.setLabel(HERDR_AGENT_LABEL);
35
89
 
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
- });
90
+ const onError = (err: unknown): void => {
91
+ pi.logger.debug("herdr report failed", {
92
+ error: err instanceof Error ? err.message : String(err),
42
93
  });
43
94
  };
44
95
 
96
+ const send = (method: string, params: Record<string, unknown>): void => {
97
+ const socketPath = process.env.HERDR_SOCKET_PATH;
98
+ if (socketPath) {
99
+ sendToHerdrSocket(socketPath, method, params, onError);
100
+ return;
101
+ }
102
+ // Fallback for the rare case herdr did not inject a socket path.
103
+ pi.exec("herdr", toCliArgs(method, params)).catch(onError);
104
+ };
105
+
45
106
  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",
107
+ send(REPORT_METHOD, {
108
+ pane_id: paneId,
109
+ source: HERDR_AGENT_LABEL,
110
+ agent: HERDR_AGENT_LABEL,
55
111
  state,
56
- "--seq",
57
- String(seq++),
58
- ]);
112
+ seq: seq++,
113
+ });
59
114
  };
60
115
 
61
116
  // Announce presence as soon as the session is initialized.
@@ -87,16 +142,11 @@ export default function herdrReporter(pi: ExtensionAPI): void {
87
142
 
88
143
  // Relinquish pane authority so herdr stops showing xcsh once we exit.
89
144
  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
- ]);
145
+ send(RELEASE_METHOD, {
146
+ pane_id: paneId,
147
+ source: HERDR_AGENT_LABEL,
148
+ agent: HERDR_AGENT_LABEL,
149
+ seq: seq++,
150
+ });
101
151
  });
102
152
  }
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.55.0",
21
- "commit": "d4705fb3ced007a59e110d11971eb25c3dc9e83f",
22
- "shortCommit": "d4705fb",
20
+ "version": "19.55.1",
21
+ "commit": "48c9f78cdcabe71d95cc97b067c64119f03fb22d",
22
+ "shortCommit": "48c9f78",
23
23
  "branch": "main",
24
- "tag": "v19.55.0",
25
- "commitDate": "2026-07-02T22:38:12Z",
26
- "buildDate": "2026-07-02T23:02:06.036Z",
24
+ "tag": "v19.55.1",
25
+ "commitDate": "2026-07-03T01:03:31Z",
26
+ "buildDate": "2026-07-03T01:25:43.578Z",
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/d4705fb3ced007a59e110d11971eb25c3dc9e83f",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.55.0"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/48c9f78cdcabe71d95cc97b067c64119f03fb22d",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.55.1"
33
33
  };
package/src/main.ts CHANGED
@@ -826,15 +826,18 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
826
826
  const { sessionKeyFromUrl } = await import("./services/xcsh-env");
827
827
  const readInfo = () => {
828
828
  let apiUrl: string | null = null;
829
+ let contextBound = false;
829
830
  try {
830
831
  apiUrl = ContextService.instance.activeApiUrl;
832
+ // Context-bound when a stored context is active (not just an env-derived apiUrl).
833
+ contextBound = ContextService.instance.getStatus().activeContextName != null;
831
834
  } catch {
832
835
  /* ContextService not initialized */
833
836
  }
834
837
  // Fall back to the env override when no context is active (env-only mode).
835
838
  apiUrl = apiUrl ?? process.env.XCSH_API_URL ?? null;
836
839
  const key = apiUrl ? sessionKeyFromUrl(apiUrl) : null;
837
- return { tenant: key?.tenant ?? null, env: key?.env ?? null, apiUrl };
840
+ return { tenant: key?.tenant ?? null, env: key?.env ?? null, apiUrl, contextBound };
838
841
  };
839
842
  bridgeServer.setSessionInfo(readInfo);
840
843
  ContextService.onContextChange(() => bridgeServer?.broadcastTenantChanged());
package/src/sdk.ts CHANGED
@@ -783,9 +783,28 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
783
783
  const svc = ContextService.instance; // inited in main.ts (throws for SDK/tests → caught)
784
784
  if (!process.env.XCSH_API_URL) {
785
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 });
786
+ const contexts = await svc.listContexts();
787
+ const available = contexts.map(c => c.name);
788
+ const tenantKey = process.env.XCSH_SESSION_TENANT;
789
+ let autoBind: ReturnType<typeof resolveAutoBind>;
790
+ if (tenantKey) {
791
+ // Extension worker: match a context to this worker's tenant.
792
+ const { sessionKeyFromUrl } = await import("./services/xcsh-env");
793
+ const contextTenantKeys: Record<string, string> = {};
794
+ for (const c of contexts) {
795
+ const key = c.apiUrl ? sessionKeyFromUrl(c.apiUrl) : null;
796
+ if (key) contextTenantKeys[c.name] = `${key.tenant}|${key.env}`;
797
+ }
798
+ autoBind = resolveAutoBind({
799
+ kind: "extension",
800
+ availableContexts: available,
801
+ tenantKey,
802
+ contextTenantKeys,
803
+ });
804
+ } else {
805
+ const folderContext = await svc.resolveFolderContextName(cwd);
806
+ autoBind = resolveAutoBind({ kind: "cli", availableContexts: available, folderContext });
807
+ }
789
808
  const choice = chooseSessionContext(bound, autoBind);
790
809
  if ("activate" in choice) {
791
810
  try {
@@ -0,0 +1,63 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+
5
+ /** Chrome native-messaging host name (matches the pre-WS-transition host). */
6
+ export const NATIVE_HOST_NAME = "com.xcsh.xcsh.chrome_host";
7
+
8
+ function nativeHostDir(home: string, platform: NodeJS.Platform): string {
9
+ if (platform === "darwin")
10
+ return path.join(home, "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts");
11
+ if (platform === "linux") return path.join(home, ".config", "google-chrome", "NativeMessagingHosts");
12
+ throw new Error(`native-host install unsupported on platform '${platform}' (macOS/Linux only)`);
13
+ }
14
+
15
+ /** Single-quote a token for a POSIX `sh` command line (safe for spaces/specials). */
16
+ function shQuote(token: string): string {
17
+ return `'${token.replace(/'/g, `'\\''`)}'`;
18
+ }
19
+
20
+ /**
21
+ * Idempotently write the user-level NM host manifest AND its launcher wrapper.
22
+ * Returns the manifest path.
23
+ *
24
+ * Chrome native-messaging manifests support ONLY {name, description, path, type,
25
+ * allowed_origins} — Chrome does NOT honor an `args` field, and when it launches
26
+ * the host it passes the calling extension's origin as argv[1] to `path`. So the
27
+ * manifest CANNOT point straight at the xcsh binary (that would run the default
28
+ * `launch`/TUI, never `chrome-host`). Instead `path` points at a generated
29
+ * executable wrapper that execs the real xcsh with the `chrome-host` subcommand
30
+ * and forwards Chrome's args ("$@"). `launchCommand` is the resolved exec prefix
31
+ * (e.g. ["/usr/local/bin/xcsh"] compiled, or ["/path/bun", "/abs/src/cli.ts"] in
32
+ * dev) — resolved by the CLI layer so this stays a pure writer.
33
+ */
34
+ export function installNativeHost(opts: {
35
+ launchCommand: string[];
36
+ extensionIds: string[];
37
+ home?: string;
38
+ platform?: NodeJS.Platform;
39
+ }): string {
40
+ if (opts.launchCommand.length === 0) throw new Error("installNativeHost: launchCommand must not be empty");
41
+ const home = opts.home ?? os.homedir();
42
+ const platform = opts.platform ?? process.platform;
43
+ const dir = nativeHostDir(home, platform);
44
+ const manifestPath = path.join(dir, `${NATIVE_HOST_NAME}.json`);
45
+ const wrapperPath = path.join(dir, `${NATIVE_HOST_NAME}.sh`);
46
+ fs.mkdirSync(dir, { recursive: true });
47
+
48
+ // Executable launcher: exec the real xcsh with `chrome-host`, forwarding
49
+ // Chrome's origin arg via "$@". 0o755 so Chrome can execute it directly.
50
+ const wrapper = `#!/bin/sh\nexec ${opts.launchCommand.map(shQuote).join(" ")} chrome-host "$@"\n`;
51
+ fs.writeFileSync(wrapperPath, wrapper, { mode: 0o755 });
52
+ fs.chmodSync(wrapperPath, 0o755); // writeFileSync mode is subject to umask; force it.
53
+
54
+ const manifest = {
55
+ name: NATIVE_HOST_NAME,
56
+ description: "xcsh Chrome native-messaging host (auto-provisioning bootstrap)",
57
+ path: wrapperPath,
58
+ type: "stdio",
59
+ allowed_origins: opts.extensionIds.map(id => `chrome-extension://${id}/`),
60
+ };
61
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
62
+ return manifestPath;
63
+ }