@f5-sales-demo/xcsh 19.55.1 → 19.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "19.55.1",
4
+ "version": "19.56.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -55,13 +55,13 @@
55
55
  "dependencies": {
56
56
  "@agentclientprotocol/sdk": "0.16.1",
57
57
  "@mozilla/readability": "^0.6",
58
- "@f5-sales-demo/xcsh-stats": "19.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",
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",
65
65
  "@sinclair/typebox": "^0.34",
66
66
  "@xterm/headless": "^6.0",
67
67
  "ajv": "^8.20",
@@ -105,11 +105,15 @@ export class BridgeServer {
105
105
  #onMessage: Array<(msg: Record<string, unknown>) => void> = [];
106
106
  /** Heartbeat interval that sends pings to keep the MV3 service worker alive (sweep + chat). */
107
107
  #heartbeat: ReturnType<typeof setInterval> | null = null;
108
- /** Stable per-process session id, advertised to the extension on `hello`. */
109
- #sessionId = `sess-${crypto.randomUUID()}`;
110
108
  /** Provider of this process's tenant identity, answering the `hello` handshake. */
111
109
  #sessionInfo:
112
- | (() => { tenant: string | null; env: string | null; apiUrl: string | null; contextBound: boolean })
110
+ | (() => {
111
+ tenant: string | null;
112
+ env: string | null;
113
+ apiUrl: string | null;
114
+ contextBound: boolean;
115
+ sessionId: string | null;
116
+ })
113
117
  | null = null;
114
118
 
115
119
  /** The port the WebSocket server is listening on (0 = not bound). */
@@ -140,25 +144,33 @@ export class BridgeServer {
140
144
  this.#onMessage.push(cb);
141
145
  }
142
146
 
143
- /** This process's stable session id (advertised on the `hello` handshake). */
144
- get sessionId(): string {
145
- return this.#sessionId;
146
- }
147
-
148
147
  /** Set the tenant-identity provider that answers the extension's `hello`
149
148
  * handshake with `{ tenant, env, apiUrl }` for THIS xcsh process/context. */
150
149
  setSessionInfo(
151
- cb: () => { tenant: string | null; env: string | null; apiUrl: string | null; contextBound: boolean },
150
+ cb: () => {
151
+ tenant: string | null;
152
+ env: string | null;
153
+ apiUrl: string | null;
154
+ contextBound: boolean;
155
+ sessionId: string | null;
156
+ },
152
157
  ): void {
153
158
  this.#sessionInfo = cb;
154
159
  }
155
160
 
156
161
  /** Push a tenant change to all connected panels (e.g. after `/context activate`). */
157
162
  broadcastTenantChanged(): void {
158
- const info = this.#sessionInfo?.() ?? { tenant: null, env: null, apiUrl: null, contextBound: false };
163
+ const info = this.#sessionInfo?.() ?? {
164
+ tenant: null,
165
+ env: null,
166
+ apiUrl: null,
167
+ contextBound: false,
168
+ sessionId: null,
169
+ };
159
170
  for (const c of this.#clients.values()) {
160
171
  try {
161
- c.send(JSON.stringify({ type: "tenant_changed", sessionId: this.#sessionId, ...info }));
172
+ // `sessionId` (the tab-correlation key) comes from `info`, matching hello_ack.
173
+ c.send(JSON.stringify({ type: "tenant_changed", ...info }));
162
174
  } catch {
163
175
  /* client may have dropped */
164
176
  }
@@ -253,12 +265,18 @@ export class BridgeServer {
253
265
  ws.send(JSON.stringify({ type: "pong" }));
254
266
  } else if (msg.type === "hello") {
255
267
  // Identity handshake: tell the extension which tenant this process serves.
256
- const info = this.#sessionInfo?.() ?? { tenant: null, env: null, apiUrl: null, contextBound: false };
268
+ const info = this.#sessionInfo?.() ?? {
269
+ tenant: null,
270
+ env: null,
271
+ apiUrl: null,
272
+ contextBound: false,
273
+ sessionId: null,
274
+ };
257
275
  const { EXTENSION_CONTRACT_VERSION } = require("./capabilities.generated");
258
276
  ws.send(
259
277
  JSON.stringify({
260
278
  type: "hello_ack",
261
- sessionId: this.#sessionId,
279
+ sessionId: info.sessionId,
262
280
  contractVersion: EXTENSION_CONTRACT_VERSION,
263
281
  tenant: info.tenant,
264
282
  env: info.env,
@@ -1,7 +1,8 @@
1
1
  /** Pure worker-registry + control-protocol logic for the manager. No I/O. */
2
2
 
3
3
  export interface WorkerRec {
4
- tenantKey: string; // "tenant|env"
4
+ sessionId: string; // per-tab session key, e.g. "tab-7"
5
+ tenant: string; // "tenant|env" — carried for the worker's context env, not the key
5
6
  port: number;
6
7
  pid: number;
7
8
  lastSeen: number; // epoch ms
@@ -10,28 +11,31 @@ export interface WorkerRec {
10
11
  export type Registry = Map<string, WorkerRec>;
11
12
 
12
13
  export type ControlMsg =
13
- | { type: "provision"; tenantKey: string }
14
- | { type: "release"; tenantKey: string }
14
+ | { type: "provision"; sessionId: string; tenant: string }
15
+ | { type: "release"; sessionId: string }
15
16
  | { type: "status" };
16
17
 
17
- function isTenantKey(v: unknown): v is string {
18
+ function isTenant(v: unknown): v is string {
18
19
  return typeof v === "string" && /^[^|]+\|[^|]+$/.test(v);
19
20
  }
21
+ function isNonEmpty(v: unknown): v is string {
22
+ return typeof v === "string" && v.length > 0;
23
+ }
20
24
 
21
25
  /** Validate an inbound control frame; null if malformed (fail closed). */
22
26
  export function parseControlMsg(raw: unknown): ControlMsg | null {
23
27
  if (!raw || typeof raw !== "object") return null;
24
28
  const m = raw as Record<string, unknown>;
25
29
  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
- }
30
+ if (m.type === "provision" && isNonEmpty(m.sessionId) && isTenant(m.tenant))
31
+ return { type: "provision", sessionId: m.sessionId, tenant: m.tenant };
32
+ if (m.type === "release" && isNonEmpty(m.sessionId)) return { type: "release", sessionId: m.sessionId };
29
33
  return null;
30
34
  }
31
35
 
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);
36
+ /** Idempotency: only provision when there is no live worker for the sessionId. */
37
+ export function needsProvision(reg: Registry, sessionId: string): boolean {
38
+ return !reg.has(sessionId);
35
39
  }
36
40
 
37
41
  /** Lowest free port in the range not already held by a worker. */
@@ -41,9 +45,9 @@ export function pickPort(reg: Registry, range: number[]): number | null {
41
45
  return null;
42
46
  }
43
47
 
44
- /** Keys whose worker has been idle longer than idleMs. */
48
+ /** sessionIds whose worker has been idle longer than idleMs. */
45
49
  export function staleKeys(reg: Registry, now: number, idleMs: number): string[] {
46
50
  const out: string[] = [];
47
- for (const w of reg.values()) if (now - w.lastSeen > idleMs) out.push(w.tenantKey);
51
+ for (const w of reg.values()) if (now - w.lastSeen > idleMs) out.push(w.sessionId);
48
52
  return out;
49
53
  }
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * Manager mode — `xcsh manager`.
3
3
  *
4
- * A detached, long-lived control server that owns the fleet of per-tenant
4
+ * A detached, long-lived control server that owns the fleet of per-tab
5
5
  * `xcsh worker` processes. It listens on a UNIX SOCKET (`~/.xcsh/manager.sock`,
6
6
  * override `XCSH_MANAGER_SOCK`) for NDJSON control frames — one JSON object per
7
7
  * line — validated by the pure `manager-core` protocol:
8
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
9
+ * {"type":"provision","sessionId":"tab-7","tenant":"acme|staging"}
10
+ * spawn a worker for that sessionId (idempotent). The registry is keyed
11
+ * on sessionId, so two tabs of the SAME tenant get two workers.
12
+ * {"type":"release","sessionId":"tab-7"} → kill + forget that session's worker
13
+ * {"type":"status"} → accepted; no-op sink
12
14
  *
13
15
  * All registry/port/idempotency/idle policy is the pure `manager-core`; this file
14
16
  * is the thin I/O shell (socket, spawn, kill, timer) around it. A background sweep
@@ -75,7 +77,7 @@ export function workerArgv(): string[] {
75
77
  }
76
78
 
77
79
  export default class Manager extends Command {
78
- static description = "Run the detached control server that spawns/reaps per-tenant workers; blocks forever";
80
+ static description = "Run the detached control server that spawns/reaps per-tab workers; blocks forever";
79
81
 
80
82
  async run(): Promise<void> {
81
83
  const sockPath = process.env.XCSH_MANAGER_SOCK ?? join(homedir(), ".xcsh", "manager.sock");
@@ -84,29 +86,30 @@ export default class Manager extends Command {
84
86
  const reg: Registry = new Map();
85
87
  const range = portCandidates();
86
88
 
87
- const reap = (tenantKey: string): void => {
88
- const w = reg.get(tenantKey);
89
+ const reap = (sessionId: string): void => {
90
+ const w = reg.get(sessionId);
89
91
  if (!w) return;
90
92
  try {
91
93
  process.kill(w.pid);
92
94
  } catch {
93
95
  /* already gone */
94
96
  }
95
- reg.delete(tenantKey);
97
+ reg.delete(sessionId);
96
98
  };
97
99
 
98
- const spawnWorker = (tenantKey: string): void => {
100
+ const spawnWorker = (msg: { sessionId: string; tenant: string }): void => {
99
101
  // Registry-dedupe (pickPort) over only the ports free at the OS level.
100
102
  const port = pickPort(reg, range.filter(isPortFree));
101
103
  if (port === null) {
102
- console.error(`[xcsh manager] port range exhausted; cannot provision ${tenantKey}`);
104
+ console.error(`[xcsh manager] port range exhausted; cannot provision ${msg.sessionId}`);
103
105
  return;
104
106
  }
105
107
  const proc = Bun.spawn([process.execPath, ...workerArgv()], {
106
108
  env: {
107
109
  ...process.env,
108
110
  XCSH_BROWSER_PROVIDER: "extension",
109
- XCSH_SESSION_TENANT: tenantKey,
111
+ XCSH_SESSION_ID: msg.sessionId,
112
+ XCSH_SESSION_TENANT: msg.tenant,
110
113
  XCSH_BRIDGE_PORT: String(port),
111
114
  // Isolate the worker's tenant binding: an ambient XCSH_API_URL in the
112
115
  // manager's env would make sdk.ts skip the XCSH_SESSION_TENANT branch and
@@ -118,16 +121,22 @@ export default class Manager extends Command {
118
121
  stdout: "ignore",
119
122
  stderr: "ignore",
120
123
  });
121
- reg.set(tenantKey, { tenantKey, port, pid: proc.pid, lastSeen: Date.now() });
124
+ reg.set(msg.sessionId, {
125
+ sessionId: msg.sessionId,
126
+ tenant: msg.tenant,
127
+ port,
128
+ pid: proc.pid,
129
+ lastSeen: Date.now(),
130
+ });
122
131
  // Reconcile a dead worker: when THIS process exits (crash, forced-port
123
132
  // EADDRINUSE with no retry, etc.), drop its registry entry so the next
124
133
  // provision respawns instead of finding a zombie. Guard on pid so an
125
- // already-respawned entry for the same tenant is never clobbered.
134
+ // already-respawned entry for the same sessionId is never clobbered.
126
135
  proc.exited.then(() => {
127
- const cur = reg.get(tenantKey);
128
- if (cur && cur.pid === proc.pid) reg.delete(tenantKey);
136
+ const cur = reg.get(msg.sessionId);
137
+ if (cur && cur.pid === proc.pid) reg.delete(msg.sessionId);
129
138
  });
130
- console.error(`[xcsh manager] provisioned ${tenantKey} → pid ${proc.pid} on port ${port}`);
139
+ console.error(`[xcsh manager] provisioned ${msg.sessionId} (${msg.tenant}) → pid ${proc.pid} on port ${port}`);
131
140
  };
132
141
 
133
142
  const handleFrame = (line: string): void => {
@@ -142,11 +151,11 @@ export default class Manager extends Command {
142
151
  const msg = parseControlMsg(raw);
143
152
  if (!msg) return; // fail closed on unknown/invalid frames
144
153
  if (msg.type === "provision") {
145
- if (needsProvision(reg, msg.tenantKey)) spawnWorker(msg.tenantKey);
146
- const w = reg.get(msg.tenantKey);
154
+ if (needsProvision(reg, msg.sessionId)) spawnWorker(msg);
155
+ const w = reg.get(msg.sessionId);
147
156
  if (w) w.lastSeen = Date.now(); // touch on every provision (keep-alive)
148
157
  } else if (msg.type === "release") {
149
- reap(msg.tenantKey);
158
+ reap(msg.sessionId);
150
159
  }
151
160
  // "status" is validated but currently a no-op sink.
152
161
  };
@@ -32,7 +32,11 @@ export function sessionInfoForWorker(): {
32
32
  env: string | null;
33
33
  apiUrl: string | null;
34
34
  contextBound: boolean;
35
+ sessionId: string | null;
35
36
  } {
37
+ // The tab session key the manager spawned this worker for; echoed in hello_ack
38
+ // so the extension can correlate a discovered worker back to the provisioned tab.
39
+ const sessionId = process.env.XCSH_SESSION_ID ?? null;
36
40
  let apiUrl: string | null = null;
37
41
  let contextBound = false;
38
42
  try {
@@ -45,15 +49,15 @@ export function sessionInfoForWorker(): {
45
49
  apiUrl = apiUrl ?? process.env.XCSH_API_URL ?? null;
46
50
  if (apiUrl) {
47
51
  const key = sessionKeyFromUrl(apiUrl);
48
- return { tenant: key?.tenant ?? null, env: key?.env ?? null, apiUrl, contextBound };
52
+ return { tenant: key?.tenant ?? null, env: key?.env ?? null, apiUrl, contextBound, sessionId };
49
53
  }
50
54
  // Contextless: the worker's assigned tenant is carried in XCSH_SESSION_TENANT.
51
55
  const raw = process.env.XCSH_SESSION_TENANT;
52
56
  if (raw) {
53
57
  const [tenant, env] = raw.split("|");
54
- return { tenant: tenant || null, env: env || null, apiUrl: null, contextBound };
58
+ return { tenant: tenant || null, env: env || null, apiUrl: null, contextBound, sessionId };
55
59
  }
56
- return { tenant: null, env: null, apiUrl: null, contextBound };
60
+ return { tenant: null, env: null, apiUrl: null, contextBound, sessionId };
57
61
  }
58
62
 
59
63
  /** Browser-automation tool set — identical scoping to `main.ts`'s extension path.
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.55.1",
21
- "commit": "48c9f78cdcabe71d95cc97b067c64119f03fb22d",
22
- "shortCommit": "48c9f78",
20
+ "version": "19.56.0",
21
+ "commit": "34e6ed88ab9aea762a9819b7ad53dfa6b7807f9a",
22
+ "shortCommit": "34e6ed8",
23
23
  "branch": "main",
24
- "tag": "v19.55.1",
25
- "commitDate": "2026-07-03T01:03:31Z",
26
- "buildDate": "2026-07-03T01:25:43.578Z",
24
+ "tag": "v19.56.0",
25
+ "commitDate": "2026-07-03T13:11:30Z",
26
+ "buildDate": "2026-07-03T13:33:05.007Z",
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/48c9f78cdcabe71d95cc97b067c64119f03fb22d",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.55.1"
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"
33
33
  };
package/src/main.ts CHANGED
@@ -837,7 +837,14 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
837
837
  // Fall back to the env override when no context is active (env-only mode).
838
838
  apiUrl = apiUrl ?? process.env.XCSH_API_URL ?? null;
839
839
  const key = apiUrl ? sessionKeyFromUrl(apiUrl) : null;
840
- return { tenant: key?.tenant ?? null, env: key?.env ?? null, apiUrl, contextBound };
840
+ // Interactive path has no per-tab XCSH_SESSION_ID; workers echo one, this doesn't.
841
+ return {
842
+ tenant: key?.tenant ?? null,
843
+ env: key?.env ?? null,
844
+ apiUrl,
845
+ contextBound,
846
+ sessionId: null,
847
+ };
841
848
  };
842
849
  bridgeServer.setSessionInfo(readInfo);
843
850
  ContextService.onContextChange(() => bridgeServer?.broadcastTenantChanged());