@f5-sales-demo/xcsh 19.55.1 → 19.56.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.
|
|
4
|
+
"version": "19.56.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.
|
|
59
|
-
"@f5-sales-demo/pi-agent-core": "19.
|
|
60
|
-
"@f5-sales-demo/pi-ai": "19.
|
|
61
|
-
"@f5-sales-demo/pi-natives": "19.
|
|
62
|
-
"@f5-sales-demo/pi-resource-management": "19.
|
|
63
|
-
"@f5-sales-demo/pi-tui": "19.
|
|
64
|
-
"@f5-sales-demo/pi-utils": "19.
|
|
58
|
+
"@f5-sales-demo/xcsh-stats": "19.56.1",
|
|
59
|
+
"@f5-sales-demo/pi-agent-core": "19.56.1",
|
|
60
|
+
"@f5-sales-demo/pi-ai": "19.56.1",
|
|
61
|
+
"@f5-sales-demo/pi-natives": "19.56.1",
|
|
62
|
+
"@f5-sales-demo/pi-resource-management": "19.56.1",
|
|
63
|
+
"@f5-sales-demo/pi-tui": "19.56.1",
|
|
64
|
+
"@f5-sales-demo/pi-utils": "19.56.1",
|
|
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
|
-
| (() => {
|
|
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: () => {
|
|
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?.() ?? {
|
|
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
|
-
|
|
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?.() ?? {
|
|
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:
|
|
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
|
-
|
|
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";
|
|
14
|
-
| { type: "release";
|
|
14
|
+
| { type: "provision"; sessionId: string; tenant: string }
|
|
15
|
+
| { type: "release"; sessionId: string }
|
|
15
16
|
| { type: "status" };
|
|
16
17
|
|
|
17
|
-
function
|
|
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 (
|
|
27
|
-
return { type: m.
|
|
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
|
|
33
|
-
export function needsProvision(reg: Registry,
|
|
34
|
-
return !reg.has(
|
|
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
|
-
/**
|
|
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.
|
|
51
|
+
for (const w of reg.values()) if (now - w.lastSeen > idleMs) out.push(w.sessionId);
|
|
48
52
|
return out;
|
|
49
53
|
}
|
package/src/commands/manager.ts
CHANGED
|
@@ -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-
|
|
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","
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
@@ -74,8 +76,52 @@ export function workerArgv(): string[] {
|
|
|
74
76
|
return reexecArgv("worker");
|
|
75
77
|
}
|
|
76
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
|
+
|
|
77
123
|
export default class Manager extends Command {
|
|
78
|
-
static description = "Run the detached control server that spawns/reaps per-
|
|
124
|
+
static description = "Run the detached control server that spawns/reaps per-tab workers; blocks forever";
|
|
79
125
|
|
|
80
126
|
async run(): Promise<void> {
|
|
81
127
|
const sockPath = process.env.XCSH_MANAGER_SOCK ?? join(homedir(), ".xcsh", "manager.sock");
|
|
@@ -84,29 +130,30 @@ export default class Manager extends Command {
|
|
|
84
130
|
const reg: Registry = new Map();
|
|
85
131
|
const range = portCandidates();
|
|
86
132
|
|
|
87
|
-
const reap = (
|
|
88
|
-
const w = reg.get(
|
|
133
|
+
const reap = (sessionId: string): void => {
|
|
134
|
+
const w = reg.get(sessionId);
|
|
89
135
|
if (!w) return;
|
|
90
136
|
try {
|
|
91
137
|
process.kill(w.pid);
|
|
92
138
|
} catch {
|
|
93
139
|
/* already gone */
|
|
94
140
|
}
|
|
95
|
-
reg.delete(
|
|
141
|
+
reg.delete(sessionId);
|
|
96
142
|
};
|
|
97
143
|
|
|
98
|
-
const spawnWorker = (
|
|
144
|
+
const spawnWorker = (msg: { sessionId: string; tenant: string }): void => {
|
|
99
145
|
// Registry-dedupe (pickPort) over only the ports free at the OS level.
|
|
100
146
|
const port = pickPort(reg, range.filter(isPortFree));
|
|
101
147
|
if (port === null) {
|
|
102
|
-
console.error(`[xcsh manager] port range exhausted; cannot provision ${
|
|
148
|
+
console.error(`[xcsh manager] port range exhausted; cannot provision ${msg.sessionId}`);
|
|
103
149
|
return;
|
|
104
150
|
}
|
|
105
151
|
const proc = Bun.spawn([process.execPath, ...workerArgv()], {
|
|
106
152
|
env: {
|
|
107
153
|
...process.env,
|
|
108
154
|
XCSH_BROWSER_PROVIDER: "extension",
|
|
109
|
-
|
|
155
|
+
XCSH_SESSION_ID: msg.sessionId,
|
|
156
|
+
XCSH_SESSION_TENANT: msg.tenant,
|
|
110
157
|
XCSH_BRIDGE_PORT: String(port),
|
|
111
158
|
// Isolate the worker's tenant binding: an ambient XCSH_API_URL in the
|
|
112
159
|
// manager's env would make sdk.ts skip the XCSH_SESSION_TENANT branch and
|
|
@@ -118,16 +165,22 @@ export default class Manager extends Command {
|
|
|
118
165
|
stdout: "ignore",
|
|
119
166
|
stderr: "ignore",
|
|
120
167
|
});
|
|
121
|
-
reg.set(
|
|
168
|
+
reg.set(msg.sessionId, {
|
|
169
|
+
sessionId: msg.sessionId,
|
|
170
|
+
tenant: msg.tenant,
|
|
171
|
+
port,
|
|
172
|
+
pid: proc.pid,
|
|
173
|
+
lastSeen: Date.now(),
|
|
174
|
+
});
|
|
122
175
|
// Reconcile a dead worker: when THIS process exits (crash, forced-port
|
|
123
176
|
// EADDRINUSE with no retry, etc.), drop its registry entry so the next
|
|
124
177
|
// provision respawns instead of finding a zombie. Guard on pid so an
|
|
125
|
-
// already-respawned entry for the same
|
|
178
|
+
// already-respawned entry for the same sessionId is never clobbered.
|
|
126
179
|
proc.exited.then(() => {
|
|
127
|
-
const cur = reg.get(
|
|
128
|
-
if (cur && cur.pid === proc.pid) reg.delete(
|
|
180
|
+
const cur = reg.get(msg.sessionId);
|
|
181
|
+
if (cur && cur.pid === proc.pid) reg.delete(msg.sessionId);
|
|
129
182
|
});
|
|
130
|
-
console.error(`[xcsh manager] provisioned ${
|
|
183
|
+
console.error(`[xcsh manager] provisioned ${msg.sessionId} (${msg.tenant}) → pid ${proc.pid} on port ${port}`);
|
|
131
184
|
};
|
|
132
185
|
|
|
133
186
|
const handleFrame = (line: string): void => {
|
|
@@ -142,11 +195,11 @@ export default class Manager extends Command {
|
|
|
142
195
|
const msg = parseControlMsg(raw);
|
|
143
196
|
if (!msg) return; // fail closed on unknown/invalid frames
|
|
144
197
|
if (msg.type === "provision") {
|
|
145
|
-
if (needsProvision(reg, msg.
|
|
146
|
-
const w = reg.get(msg.
|
|
198
|
+
if (needsProvision(reg, msg.sessionId)) spawnWorker(msg);
|
|
199
|
+
const w = reg.get(msg.sessionId);
|
|
147
200
|
if (w) w.lastSeen = Date.now(); // touch on every provision (keep-alive)
|
|
148
201
|
} else if (msg.type === "release") {
|
|
149
|
-
reap(msg.
|
|
202
|
+
reap(msg.sessionId);
|
|
150
203
|
}
|
|
151
204
|
// "status" is validated but currently a no-op sink.
|
|
152
205
|
};
|
|
@@ -171,39 +224,35 @@ export default class Manager extends Command {
|
|
|
171
224
|
},
|
|
172
225
|
};
|
|
173
226
|
|
|
174
|
-
// Single-manager invariant
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
/* nothing accepting → free path, or a stale socket file from a crashed manager */
|
|
201
|
-
}
|
|
202
|
-
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") {
|
|
203
253
|
console.error(`[xcsh manager] another manager already live at ${sockPath}; exiting`);
|
|
204
254
|
process.exit(0);
|
|
205
255
|
}
|
|
206
|
-
Bun.listen(socketConfig);
|
|
207
256
|
console.error(`[xcsh manager] control socket listening at ${sockPath}`);
|
|
208
257
|
|
|
209
258
|
// Idle sweep: reap workers untouched for longer than the TTL.
|
package/src/commands/worker.ts
CHANGED
|
@@ -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.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "19.56.1",
|
|
21
|
+
"commit": "5630d1694415b3da8edb73803fdf7ad37467d77d",
|
|
22
|
+
"shortCommit": "5630d16",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.
|
|
25
|
-
"commitDate": "2026-07-
|
|
26
|
-
"buildDate": "2026-07-
|
|
24
|
+
"tag": "v19.56.1",
|
|
25
|
+
"commitDate": "2026-07-03T17:49:52Z",
|
|
26
|
+
"buildDate": "2026-07-03T18:14:31.834Z",
|
|
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/
|
|
32
|
-
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.
|
|
31
|
+
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/5630d1694415b3da8edb73803fdf7ad37467d77d",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.56.1"
|
|
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
|
-
|
|
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());
|