@f5-sales-demo/xcsh 19.57.2 → 19.58.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 +8 -8
- package/src/commands/manager-core.ts +13 -0
- package/src/commands/manager.ts +81 -2
- package/src/commands/worker.ts +48 -9
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/modes/components/welcome.ts +27 -22
- package/src/sdk.ts +35 -29
- package/src/services/session-context-binding.ts +47 -0
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.58.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.
|
|
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.58.0",
|
|
59
|
+
"@f5-sales-demo/pi-agent-core": "19.58.0",
|
|
60
|
+
"@f5-sales-demo/pi-ai": "19.58.0",
|
|
61
|
+
"@f5-sales-demo/pi-natives": "19.58.0",
|
|
62
|
+
"@f5-sales-demo/pi-resource-management": "19.58.0",
|
|
63
|
+
"@f5-sales-demo/pi-tui": "19.58.0",
|
|
64
|
+
"@f5-sales-demo/pi-utils": "19.58.0",
|
|
65
65
|
"@sinclair/typebox": "^0.34",
|
|
66
66
|
"@xterm/headless": "^6.0",
|
|
67
67
|
"ajv": "^8.20",
|
|
@@ -51,3 +51,16 @@ export function staleKeys(reg: Registry, now: number, idleMs: number): string[]
|
|
|
51
51
|
for (const w of reg.values()) if (now - w.lastSeen > idleMs) out.push(w.sessionId);
|
|
52
52
|
return out;
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
/** How many new spares to spawn now to reach `target`, without exceeding the port
|
|
56
|
+
* budget: spares + active workers must fit the discovery range. Never negative. */
|
|
57
|
+
export function sparesToSpawn(
|
|
58
|
+
target: number,
|
|
59
|
+
currentSpares: number,
|
|
60
|
+
activeWorkers: number,
|
|
61
|
+
totalPorts: number,
|
|
62
|
+
): number {
|
|
63
|
+
const want = Math.max(0, target - currentSpares);
|
|
64
|
+
const freeSlots = Math.max(0, totalPorts - activeWorkers - currentSpares);
|
|
65
|
+
return Math.min(want, freeSlots);
|
|
66
|
+
}
|
package/src/commands/manager.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { homedir } from "node:os";
|
|
|
21
21
|
import { dirname, join } from "node:path";
|
|
22
22
|
import { Command } from "@f5-sales-demo/pi-utils/cli";
|
|
23
23
|
import { portCandidates } from "../browser/extension-bridge";
|
|
24
|
-
import { needsProvision, parseControlMsg, pickPort, type Registry, staleKeys } from "./manager-core";
|
|
24
|
+
import { needsProvision, parseControlMsg, pickPort, type Registry, sparesToSpawn, staleKeys } from "./manager-core";
|
|
25
25
|
|
|
26
26
|
/** Reap a worker idle longer than this (ms). */
|
|
27
27
|
const IDLE_MS = 20 * 60_000;
|
|
@@ -130,6 +130,80 @@ export default class Manager extends Command {
|
|
|
130
130
|
const reg: Registry = new Map();
|
|
131
131
|
const range = portCandidates();
|
|
132
132
|
|
|
133
|
+
const poolTarget = Math.max(0, Math.trunc(Number(process.env.XCSH_WORKER_POOL_SIZE ?? "2")) || 0);
|
|
134
|
+
interface SpareRec {
|
|
135
|
+
proc: Bun.Subprocess;
|
|
136
|
+
port: number;
|
|
137
|
+
pid: number;
|
|
138
|
+
}
|
|
139
|
+
const pool: SpareRec[] = [];
|
|
140
|
+
|
|
141
|
+
const spawnSpare = (): void => {
|
|
142
|
+
const usedPorts = new Set<number>([...reg.values()].map(w => w.port).concat(pool.map(s => s.port)));
|
|
143
|
+
const port = pickPort(
|
|
144
|
+
reg,
|
|
145
|
+
range.filter(p => !usedPorts.has(p) && isPortFree(p)),
|
|
146
|
+
);
|
|
147
|
+
if (port === null) return; // range full — do not pre-warm
|
|
148
|
+
const proc = Bun.spawn([process.execPath, ...workerArgv()], {
|
|
149
|
+
env: {
|
|
150
|
+
...process.env,
|
|
151
|
+
XCSH_BROWSER_PROVIDER: "extension",
|
|
152
|
+
XCSH_BRIDGE_PORT: String(port),
|
|
153
|
+
// Identity-less spare: NO XCSH_SESSION_ID / XCSH_SESSION_TENANT (bound later via IPC).
|
|
154
|
+
// The spare marker makes sdk.ts skip its create-time context bootstrap, so the
|
|
155
|
+
// spare never boot-activates a tenant's context/credentials before its IPC bind
|
|
156
|
+
// (see shouldRunSessionContextBootstrap). Explicitly clear session identity too so
|
|
157
|
+
// the spare can never inherit an ambient session from the manager's env.
|
|
158
|
+
XCSH_WORKER_SPARE: "1",
|
|
159
|
+
XCSH_SESSION_ID: undefined,
|
|
160
|
+
XCSH_SESSION_TENANT: undefined,
|
|
161
|
+
XCSH_API_URL: undefined,
|
|
162
|
+
XCSH_API_TOKEN: undefined,
|
|
163
|
+
},
|
|
164
|
+
ipc() {}, // enable Bun parent→child IPC; no worker→manager messages needed today
|
|
165
|
+
stdout: "ignore",
|
|
166
|
+
stderr: "ignore",
|
|
167
|
+
});
|
|
168
|
+
const rec: SpareRec = { proc, port, pid: proc.pid };
|
|
169
|
+
pool.push(rec);
|
|
170
|
+
proc.exited.then(() => {
|
|
171
|
+
const i = pool.indexOf(rec);
|
|
172
|
+
if (i >= 0) pool.splice(i, 1); // a live spare died → drop + replenish
|
|
173
|
+
maintainPool();
|
|
174
|
+
});
|
|
175
|
+
console.error(`[xcsh manager] pre-warmed spare → pid ${proc.pid} on port ${port}`);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const maintainPool = (): void => {
|
|
179
|
+
const n = sparesToSpawn(poolTarget, pool.length, reg.size, range.length);
|
|
180
|
+
for (let i = 0; i < n; i++) spawnSpare();
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** Adopt a warm spare for a provision (bind over IPC). Returns false if none available. */
|
|
184
|
+
const adoptSpare = (msg: { sessionId: string; tenant: string }): boolean => {
|
|
185
|
+
const rec = pool.shift();
|
|
186
|
+
if (!rec) return false;
|
|
187
|
+
// `send` exists because the spare was spawned with an `ipc` handler.
|
|
188
|
+
(rec.proc as { send(m: unknown): void }).send({ type: "bind", sessionId: msg.sessionId, tenant: msg.tenant });
|
|
189
|
+
reg.set(msg.sessionId, {
|
|
190
|
+
sessionId: msg.sessionId,
|
|
191
|
+
tenant: msg.tenant,
|
|
192
|
+
port: rec.port,
|
|
193
|
+
pid: rec.pid,
|
|
194
|
+
lastSeen: Date.now(),
|
|
195
|
+
});
|
|
196
|
+
rec.proc.exited.then(() => {
|
|
197
|
+
const cur = reg.get(msg.sessionId);
|
|
198
|
+
if (cur && cur.pid === rec.pid) reg.delete(msg.sessionId);
|
|
199
|
+
});
|
|
200
|
+
maintainPool(); // replenish the consumed spare
|
|
201
|
+
console.error(
|
|
202
|
+
`[xcsh manager] adopted spare pid ${rec.pid} on port ${rec.port} as ${msg.sessionId} (${msg.tenant})`,
|
|
203
|
+
);
|
|
204
|
+
return true;
|
|
205
|
+
};
|
|
206
|
+
|
|
133
207
|
const reap = (sessionId: string): void => {
|
|
134
208
|
const w = reg.get(sessionId);
|
|
135
209
|
if (!w) return;
|
|
@@ -195,7 +269,9 @@ export default class Manager extends Command {
|
|
|
195
269
|
const msg = parseControlMsg(raw);
|
|
196
270
|
if (!msg) return; // fail closed on unknown/invalid frames
|
|
197
271
|
if (msg.type === "provision") {
|
|
198
|
-
if (needsProvision(reg, msg.sessionId))
|
|
272
|
+
if (needsProvision(reg, msg.sessionId)) {
|
|
273
|
+
if (!adoptSpare(msg)) spawnWorker(msg); // adopt a warm spare, else cold-spawn (fallback)
|
|
274
|
+
}
|
|
199
275
|
const w = reg.get(msg.sessionId);
|
|
200
276
|
if (w) w.lastSeen = Date.now(); // touch on every provision (keep-alive)
|
|
201
277
|
} else if (msg.type === "release") {
|
|
@@ -255,6 +331,9 @@ export default class Manager extends Command {
|
|
|
255
331
|
}
|
|
256
332
|
console.error(`[xcsh manager] control socket listening at ${sockPath}`);
|
|
257
333
|
|
|
334
|
+
// Pre-warm the spare pool so provisions can adopt instead of cold-spawn.
|
|
335
|
+
if (poolTarget > 0) maintainPool();
|
|
336
|
+
|
|
258
337
|
// Idle sweep: reap workers untouched for longer than the TTL.
|
|
259
338
|
setInterval(() => {
|
|
260
339
|
for (const key of staleKeys(reg, Date.now(), IDLE_MS)) reap(key);
|
package/src/commands/worker.ts
CHANGED
|
@@ -21,13 +21,28 @@ import { createExtensionBridgeTools, EXTENSION_AGENT_TOOL_NAMES } from "../brows
|
|
|
21
21
|
import { setSharedBridgeServer } from "../browser/provider";
|
|
22
22
|
import { initializeWithSettings } from "../discovery";
|
|
23
23
|
import { createAgentSession } from "../sdk";
|
|
24
|
+
import { activateTenantContext } from "../services/session-context-binding";
|
|
24
25
|
import { ContextService } from "../services/xcsh-context";
|
|
25
26
|
import { sessionKeyFromUrl } from "../services/xcsh-env";
|
|
26
27
|
|
|
28
|
+
/** Mutable worker identity. Seeded from env at spawn (backward compat); replaced by a
|
|
29
|
+
* late IPC bind on a pre-warmed spare. `null` fields fall back to env, then the "spare"
|
|
30
|
+
* sentinel (a non-`tab-<id>` string the extension registers but never binds to a tab). */
|
|
31
|
+
let boundIdentity: { sessionId: string; tenantKey: string } | null = null;
|
|
32
|
+
|
|
33
|
+
export function setWorkerIdentity(sessionId: string, tenantKey: string): void {
|
|
34
|
+
boundIdentity = { sessionId, tenantKey };
|
|
35
|
+
}
|
|
36
|
+
/** Test-only: clear late-bind state so env-seeded cases are deterministic. */
|
|
37
|
+
export function resetWorkerIdentity(): void {
|
|
38
|
+
boundIdentity = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
27
41
|
/** Tenant identity for the `hello` handshake. The active context wins; when the
|
|
28
|
-
* worker is contextless we parse
|
|
29
|
-
* still learns which tenant this process serves (apiUrl
|
|
30
|
-
* the bridge invokes it synchronously while answering
|
|
42
|
+
* worker is contextless we parse the bound tenant (or `XCSH_SESSION_TENANT`,
|
|
43
|
+
* `tenant|env`) so the panel still learns which tenant this process serves (apiUrl
|
|
44
|
+
* stays null). Must be sync — the bridge invokes it synchronously while answering
|
|
45
|
+
* `hello`. */
|
|
31
46
|
export function sessionInfoForWorker(): {
|
|
32
47
|
tenant: string | null;
|
|
33
48
|
env: string | null;
|
|
@@ -35,9 +50,10 @@ export function sessionInfoForWorker(): {
|
|
|
35
50
|
contextBound: boolean;
|
|
36
51
|
sessionId: string | null;
|
|
37
52
|
} {
|
|
38
|
-
// The tab session key
|
|
39
|
-
//
|
|
40
|
-
|
|
53
|
+
// The tab session key this worker serves; echoed in hello_ack so the extension can
|
|
54
|
+
// correlate a discovered worker back to a provisioned tab. A late IPC bind wins over
|
|
55
|
+
// the spawn env; an unbound spare advertises the "spare" sentinel.
|
|
56
|
+
const sessionId = boundIdentity?.sessionId ?? process.env.XCSH_SESSION_ID ?? "spare";
|
|
41
57
|
let apiUrl: string | null = null;
|
|
42
58
|
let contextBound = false;
|
|
43
59
|
try {
|
|
@@ -45,15 +61,15 @@ export function sessionInfoForWorker(): {
|
|
|
45
61
|
// A worker is "context-bound" when it has an active stored context (not just an env-derived apiUrl).
|
|
46
62
|
contextBound = ContextService.instance.getStatus().activeContextName != null;
|
|
47
63
|
} catch {
|
|
48
|
-
/* ContextService not initialized — fall through to
|
|
64
|
+
/* ContextService not initialized — fall through to the tenant key; contextBound stays false. */
|
|
49
65
|
}
|
|
50
66
|
apiUrl = apiUrl ?? process.env.XCSH_API_URL ?? null;
|
|
51
67
|
if (apiUrl) {
|
|
52
68
|
const key = sessionKeyFromUrl(apiUrl);
|
|
53
69
|
return { tenant: key?.tenant ?? null, env: key?.env ?? null, apiUrl, contextBound, sessionId };
|
|
54
70
|
}
|
|
55
|
-
// Contextless: the
|
|
56
|
-
const raw = process.env.XCSH_SESSION_TENANT;
|
|
71
|
+
// Contextless: advertise the tenant carried by the bind (or spawn env).
|
|
72
|
+
const raw = boundIdentity?.tenantKey ?? process.env.XCSH_SESSION_TENANT;
|
|
57
73
|
if (raw) {
|
|
58
74
|
const [tenant, env] = raw.split("|");
|
|
59
75
|
return { tenant: tenant || null, env: env || null, apiUrl: null, contextBound, sessionId };
|
|
@@ -127,6 +143,29 @@ export default class Worker extends Command {
|
|
|
127
143
|
bridge.setSessionInfo(sessionInfoForWorker);
|
|
128
144
|
ContextService.onContextChange(() => bridge.broadcastTenantChanged());
|
|
129
145
|
|
|
146
|
+
// Pre-warm pool late-bind: the manager (our parent) sends {bind} over Bun IPC to adopt
|
|
147
|
+
// this spare for a tab. Apply the identity, activate the tenant's context LIVE, then
|
|
148
|
+
// re-announce via broadcastTenantChanged (now carrying the real sessionId).
|
|
149
|
+
process.on("message", (raw: unknown) => {
|
|
150
|
+
const m = raw as { type?: unknown; sessionId?: unknown; tenant?: unknown };
|
|
151
|
+
if (m?.type !== "bind" || typeof m.sessionId !== "string" || typeof m.tenant !== "string") return;
|
|
152
|
+
// Capture the narrowed values — TS widens `m.*` back to `unknown` inside the async closure.
|
|
153
|
+
const sessionId = m.sessionId;
|
|
154
|
+
const tenant = m.tenant;
|
|
155
|
+
setWorkerIdentity(sessionId, tenant);
|
|
156
|
+
void (async () => {
|
|
157
|
+
try {
|
|
158
|
+
await activateTenantContext(tenant);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
console.error(`[xcsh worker] late tenant-bind failed: ${String(err)}`);
|
|
161
|
+
}
|
|
162
|
+
bridge.broadcastTenantChanged();
|
|
163
|
+
// Ack adoption complete (identity applied + context activated + re-announced).
|
|
164
|
+
// The manager ignores it today; the bench uses it to time adoption latency.
|
|
165
|
+
process.send?.({ type: "bound", sessionId });
|
|
166
|
+
})();
|
|
167
|
+
});
|
|
168
|
+
|
|
130
169
|
// The extension's browser actions (navigate/click/read_ax/…) are not builtin
|
|
131
170
|
// tools — turn each into a bridge-proxying CustomTool so the agent can drive
|
|
132
171
|
// the browser (without this the agent only has catalog_workflow_runner and
|
|
@@ -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.58.0",
|
|
21
|
+
"commit": "def3667fcf2f20c14de7b9ab37b29e85b390e5c4",
|
|
22
|
+
"shortCommit": "def3667",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.
|
|
25
|
-
"commitDate": "2026-07-
|
|
26
|
-
"buildDate": "2026-07-
|
|
24
|
+
"tag": "v19.58.0",
|
|
25
|
+
"commitDate": "2026-07-05T03:37:49Z",
|
|
26
|
+
"buildDate": "2026-07-05T04:00:08.049Z",
|
|
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/def3667fcf2f20c14de7b9ab37b29e85b390e5c4",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.58.0"
|
|
33
33
|
};
|
|
@@ -8,6 +8,32 @@ import { theme } from "../../modes/theme/theme";
|
|
|
8
8
|
* commands (/plugins, /context) so startup stays instant and never blocks or
|
|
9
9
|
* live-updates. See docs/superpowers/specs for the fast-startup design.
|
|
10
10
|
*/
|
|
11
|
+
// biome-ignore format: preserve ASCII art layout
|
|
12
|
+
/** The F5 "ball" startup logo. Rows are vertically symmetric so the disk renders as a
|
|
13
|
+
* clean circle; keep it that way (see welcome-logo.test.ts). `▓`→red, `█`→white,
|
|
14
|
+
* `▒`→red stipple halo, `()|_`→red edge glyphs (see WelcomeComponent.#f5ColorLine). */
|
|
15
|
+
export const F5_LOGO_ROWS: readonly string[] = [
|
|
16
|
+
" ________",
|
|
17
|
+
" (▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒)",
|
|
18
|
+
" (▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒)",
|
|
19
|
+
" (▒▒▓▓▓▓██████████▓▓▓▓█████████████)",
|
|
20
|
+
" (▒▓▓▓▓██████▒▒▒▒▒███▓▓██████████████▒)",
|
|
21
|
+
" (▒▓▓▓▓██████▒▓▓▓▓▓▒▒▒▓██▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒)",
|
|
22
|
+
" (▒▓▓▓▓▓██████▓▓▓▓▓▓▓▓▓██▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒)",
|
|
23
|
+
" (▒▓▓███████████████▓▓▓▓█████████████▓▓▓▓▓▓▒)",
|
|
24
|
+
"(▒▓▓▓▒▒▒███████▒▒▒▒▒▓▓▓████████████████▓▓▓▓▓▒)",
|
|
25
|
+
"|▒▓▓▓▓▓▓▒██████▓▓▓▓▓▓▓████████████████████▓▓▒|",
|
|
26
|
+
"|▒▓▓▓▓▓▓▓██████▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒██████████▓▒|",
|
|
27
|
+
"(▒▓▓▓▓▓▓▓██████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒████████▒▒)",
|
|
28
|
+
" (▒▓▓▓▓▓▓██████▓▓▓▓▓▓▓███▓▓▓▓▓▓▓▓▓▓▒▒▒████▒▒)",
|
|
29
|
+
" (▒▓▓▓▓▓██████▓▓▓▓▓▓█████▓▓▓▓▓▓▓▓▓▓▓▓███▒▒)",
|
|
30
|
+
" (▒▒██████████▓▓▓▓▓▒██████▓▓▓▓▓▓▓▓███▒▒▒)",
|
|
31
|
+
" (▒▒▒▒▒██████████▓▓▒▒█████████████▒▒▓▒)",
|
|
32
|
+
" (▒▓▓▒▒▒▒▒▒▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒)",
|
|
33
|
+
" (▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒)",
|
|
34
|
+
" (▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒)",
|
|
35
|
+
];
|
|
36
|
+
|
|
11
37
|
export class WelcomeComponent implements Component {
|
|
12
38
|
constructor(private readonly version: string) {}
|
|
13
39
|
invalidate(): void {}
|
|
@@ -20,28 +46,7 @@ export class WelcomeComponent implements Component {
|
|
|
20
46
|
if (boxWidth < 4) return [];
|
|
21
47
|
const leftCol = boxWidth - 2;
|
|
22
48
|
|
|
23
|
-
|
|
24
|
-
const f5Logo = [
|
|
25
|
-
" ________",
|
|
26
|
-
" (▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒)",
|
|
27
|
-
" (▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒)",
|
|
28
|
-
" (▒▒▓▓▓▓██████████▓▓▓▓███████████████)",
|
|
29
|
-
" (▒▓▓▓▓██████▒▒▒▒▒███▓▓█████████████████▒)",
|
|
30
|
-
" (▒▓▓▓▓██████▒▓▓▓▓▓▒▒▒▓██▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒)",
|
|
31
|
-
" (▒▓▓▓▓▓██████▓▓▓▓▓▓▓▓▓██▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒)",
|
|
32
|
-
" (▒▓▓████████████████▓▓▓▓████████████▓▓▓▓▓▓▒)",
|
|
33
|
-
"(▒▓▓▓▒▒▒███████▒▒▒▒▒▓▓▓████████████████▓▓▓▓▓▒)",
|
|
34
|
-
"|▒▓▓▓▓▓▓▒██████▓▓▓▓▓▓▓████████████████████▓▓▒|",
|
|
35
|
-
"|▒▓▓▓▓▓▓▓██████▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒██████████▓▒|",
|
|
36
|
-
"(▒▓▓▓▓▓▓▓██████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒██████████▒▒)",
|
|
37
|
-
" (▒▓▓▓▓▓▓██████▓▓▓▓▓▓▓███▓▓▓▓▓▓▓▓▓▒▒▒████▒▒)",
|
|
38
|
-
" (▒▓▓▓▓▓██████▓▓▓▓▓▓██████▓▓▓▓▓▓▓▓▓▓▓▓████▒▒)",
|
|
39
|
-
" (▒▒██████████▓▓▓▓▓▒██████▓▓▓▓▓▓▓▓██████▒▒▒)",
|
|
40
|
-
" (▒▒▒▒▒██████████▓▓▒▒██████████████▒▒▓▒)",
|
|
41
|
-
" (▒▓▓▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒)",
|
|
42
|
-
" (▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒)",
|
|
43
|
-
" (▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒)",
|
|
44
|
-
];
|
|
49
|
+
const f5Logo = F5_LOGO_ROWS;
|
|
45
50
|
|
|
46
51
|
const logoColored = f5Logo.map(line => this.#f5ColorLine(line));
|
|
47
52
|
const logoBlockPad = Math.max(0, Math.floor((leftCol - logoMaxWidth) / 2));
|
package/src/sdk.ts
CHANGED
|
@@ -779,47 +779,53 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
779
779
|
// is via the existing activate(); auth is validated but never blocks resume.
|
|
780
780
|
try {
|
|
781
781
|
const { ContextService } = await import("./services/xcsh-context");
|
|
782
|
-
const { resolveAutoBind, chooseSessionContext } =
|
|
782
|
+
const { resolveAutoBind, chooseSessionContext, activateTenantContext, shouldRunSessionContextBootstrap } =
|
|
783
|
+
await import("./services/session-context-binding");
|
|
783
784
|
const svc = ContextService.instance; // inited in main.ts (throws for SDK/tests → caught)
|
|
784
|
-
|
|
785
|
+
// A pre-warmed spare (XCSH_WORKER_SPARE=1) skips the bootstrap entirely and
|
|
786
|
+
// stays contextless until its IPC bind activates the correct tenant — see
|
|
787
|
+
// shouldRunSessionContextBootstrap. Cold workers and interactive CLI are
|
|
788
|
+
// unchanged (they have no spare marker).
|
|
789
|
+
if (
|
|
790
|
+
shouldRunSessionContextBootstrap({
|
|
791
|
+
XCSH_API_URL: process.env.XCSH_API_URL,
|
|
792
|
+
XCSH_WORKER_SPARE: process.env.XCSH_WORKER_SPARE,
|
|
793
|
+
})
|
|
794
|
+
) {
|
|
785
795
|
const bound = existingSession.activeContextName; // resumed binding, if any
|
|
786
|
-
const contexts = await svc.listContexts();
|
|
787
|
-
const available = contexts.map(c => c.name);
|
|
788
796
|
const tenantKey = process.env.XCSH_SESSION_TENANT;
|
|
789
|
-
let autoBind: ReturnType<typeof resolveAutoBind>;
|
|
790
797
|
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
|
-
}
|
|
808
|
-
const choice = chooseSessionContext(bound, autoBind);
|
|
809
|
-
if ("activate" in choice) {
|
|
798
|
+
// Extension worker: match a context to this worker's tenant (shared with pool late-bind).
|
|
810
799
|
try {
|
|
811
|
-
await
|
|
812
|
-
await svc.validateToken(); // authenticate; non-blocking
|
|
800
|
+
await activateTenantContext(tenantKey, bound);
|
|
813
801
|
} catch (err) {
|
|
814
802
|
// Context deleted since last use, or auth failed → surface, never block.
|
|
815
803
|
logger.warn("XCSH: session context bootstrap could not fully activate", {
|
|
816
|
-
|
|
804
|
+
tenantKey,
|
|
817
805
|
error: String(err),
|
|
818
806
|
});
|
|
819
807
|
}
|
|
808
|
+
} else {
|
|
809
|
+
const contexts = await svc.listContexts();
|
|
810
|
+
const available = contexts.map(c => c.name);
|
|
811
|
+
const folderContext = await svc.resolveFolderContextName(cwd);
|
|
812
|
+
const autoBind = resolveAutoBind({ kind: "cli", availableContexts: available, folderContext });
|
|
813
|
+
const choice = chooseSessionContext(bound, autoBind);
|
|
814
|
+
if ("activate" in choice) {
|
|
815
|
+
try {
|
|
816
|
+
await svc.activate(choice.activate); // fires onContextChange → records context_change
|
|
817
|
+
await svc.validateToken(); // authenticate; non-blocking
|
|
818
|
+
} catch (err) {
|
|
819
|
+
// Context deleted since last use, or auth failed → surface, never block.
|
|
820
|
+
logger.warn("XCSH: session context bootstrap could not fully activate", {
|
|
821
|
+
context: choice.activate,
|
|
822
|
+
error: String(err),
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
// choice.needsSelection / choice.none → leave unbound; the /context status
|
|
827
|
+
// line and tools already prompt "run /context activate".
|
|
820
828
|
}
|
|
821
|
-
// choice.needsSelection / choice.none → leave unbound; the /context status
|
|
822
|
-
// line and tools already prompt "run /context activate".
|
|
823
829
|
}
|
|
824
830
|
} catch {
|
|
825
831
|
// ContextService not initialized (SDK consumers / tests) — skip bootstrap.
|
|
@@ -7,6 +7,22 @@
|
|
|
7
7
|
|
|
8
8
|
export type SessionKind = "cli" | "extension";
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Whether createAgentSession should run its create-time context bootstrap.
|
|
12
|
+
*
|
|
13
|
+
* A pre-warmed spare worker (XCSH_WORKER_SPARE=1) is spawned with NO api url and
|
|
14
|
+
* NO tenant, so the bootstrap would otherwise fall into the CLI single-context
|
|
15
|
+
* auto-bind branch and boot-activate a tenant's context/credentials BEFORE any
|
|
16
|
+
* IPC bind — breaking the "identity-less spare holds no tenant credentials until
|
|
17
|
+
* bind" isolation guarantee. A spare must stay contextless until its IPC bind
|
|
18
|
+
* calls activateTenantContext(). This keeps all other paths unchanged:
|
|
19
|
+
* interactive CLI (no marker) folder-auto-binds, and a cold-spawned worker
|
|
20
|
+
* (has XCSH_SESSION_TENANT, no marker) tenant-binds.
|
|
21
|
+
*/
|
|
22
|
+
export function shouldRunSessionContextBootstrap(env: { XCSH_API_URL?: string; XCSH_WORKER_SPARE?: string }): boolean {
|
|
23
|
+
return !env.XCSH_API_URL && !env.XCSH_WORKER_SPARE;
|
|
24
|
+
}
|
|
25
|
+
|
|
10
26
|
export interface AutoBindInput {
|
|
11
27
|
kind: SessionKind;
|
|
12
28
|
/** Names of all available (global) contexts. */
|
|
@@ -47,3 +63,34 @@ export function chooseSessionContext(
|
|
|
47
63
|
if (autoBind.kind === "needsSelection") return { needsSelection: true };
|
|
48
64
|
return { none: true };
|
|
49
65
|
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Activate the stored context matching a worker's tenant key ("tenant|env"), if any.
|
|
69
|
+
* Extracted from the createAgentSession bootstrap so a pre-warmed spare worker can bind
|
|
70
|
+
* its tenant AFTER startup (pool late-bind). Returns true if a context was activated.
|
|
71
|
+
* Never throws when no context matches — the caller stays tenant-advertised/unbound.
|
|
72
|
+
*/
|
|
73
|
+
export async function activateTenantContext(tenantKey: string, bound: string | null = null): Promise<boolean> {
|
|
74
|
+
const { ContextService } = await import("./xcsh-context");
|
|
75
|
+
const { sessionKeyFromUrl } = await import("./xcsh-env");
|
|
76
|
+
const svc = ContextService.instance;
|
|
77
|
+
const contexts = await svc.listContexts();
|
|
78
|
+
const contextTenantKeys: Record<string, string> = {};
|
|
79
|
+
for (const c of contexts) {
|
|
80
|
+
const key = c.apiUrl ? sessionKeyFromUrl(c.apiUrl) : null;
|
|
81
|
+
if (key) contextTenantKeys[c.name] = `${key.tenant}|${key.env}`;
|
|
82
|
+
}
|
|
83
|
+
const autoBind = resolveAutoBind({
|
|
84
|
+
kind: "extension",
|
|
85
|
+
availableContexts: contexts.map(c => c.name),
|
|
86
|
+
tenantKey,
|
|
87
|
+
contextTenantKeys,
|
|
88
|
+
});
|
|
89
|
+
const choice = chooseSessionContext(bound ?? undefined, autoBind);
|
|
90
|
+
if ("activate" in choice) {
|
|
91
|
+
await svc.activate(choice.activate);
|
|
92
|
+
await svc.validateToken();
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|