@f5-sales-demo/xcsh 19.57.2 → 19.58.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 +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/main.ts +17 -11
- package/src/modes/components/welcome.ts +27 -22
- package/src/modes/controllers/extension-ui-controller.ts +6 -4
- package/src/modes/interactive-mode.ts +7 -0
- package/src/sdk.ts +35 -29
- package/src/services/session-context-binding.ts +47 -0
- package/src/startup-profile.ts +48 -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.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.58.1",
|
|
59
|
+
"@f5-sales-demo/pi-agent-core": "19.58.1",
|
|
60
|
+
"@f5-sales-demo/pi-ai": "19.58.1",
|
|
61
|
+
"@f5-sales-demo/pi-natives": "19.58.1",
|
|
62
|
+
"@f5-sales-demo/pi-resource-management": "19.58.1",
|
|
63
|
+
"@f5-sales-demo/pi-tui": "19.58.1",
|
|
64
|
+
"@f5-sales-demo/pi-utils": "19.58.1",
|
|
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.1",
|
|
21
|
+
"commit": "f72a3fa845f24642e4db4d84aa427bb2e2d64538",
|
|
22
|
+
"shortCommit": "f72a3fa",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.
|
|
25
|
-
"commitDate": "2026-07-
|
|
26
|
-
"buildDate": "2026-07-
|
|
24
|
+
"tag": "v19.58.1",
|
|
25
|
+
"commitDate": "2026-07-05T13:33:19Z",
|
|
26
|
+
"buildDate": "2026-07-05T13:53:37.495Z",
|
|
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/f72a3fa845f24642e4db4d84aa427bb2e2d64538",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.58.1"
|
|
33
33
|
};
|
package/src/main.ts
CHANGED
|
@@ -57,6 +57,7 @@ import type { SubmittedUserInput } from "./modes/types";
|
|
|
57
57
|
import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage } from "./sdk";
|
|
58
58
|
import type { AgentSession } from "./session/agent-session";
|
|
59
59
|
import { resolveResumableSession, type SessionInfo, SessionManager } from "./session/session-manager";
|
|
60
|
+
import { profileDump, profileMark } from "./startup-profile";
|
|
60
61
|
import { resolvePromptInput } from "./system-prompt";
|
|
61
62
|
import type { LspStartupServerInfo } from "./tools";
|
|
62
63
|
import type { EventBus } from "./utils/event-bus";
|
|
@@ -146,8 +147,6 @@ export async function submitInteractiveInput(
|
|
|
146
147
|
}
|
|
147
148
|
}
|
|
148
149
|
|
|
149
|
-
const INITIAL_UPDATE_CHECK_TIMEOUT_MS = 500;
|
|
150
|
-
|
|
151
150
|
async function runInteractiveMode(
|
|
152
151
|
session: AgentSession,
|
|
153
152
|
version: string,
|
|
@@ -161,23 +160,26 @@ async function runInteractiveMode(
|
|
|
161
160
|
initialMessage?: string,
|
|
162
161
|
initialImages?: ImageContent[],
|
|
163
162
|
): Promise<void> {
|
|
164
|
-
|
|
165
|
-
versionCheckPromise.catch(() => undefined),
|
|
166
|
-
new Promise<string | undefined>(resolve => setTimeout(() => resolve(undefined), INITIAL_UPDATE_CHECK_TIMEOUT_MS)),
|
|
167
|
-
]);
|
|
163
|
+
profileMark("interactive: enter runInteractiveMode");
|
|
168
164
|
|
|
169
165
|
const mode = new InteractiveMode(session, version, setExtensionUIContext, lspServers, mcpManager, eventBus);
|
|
170
166
|
|
|
171
167
|
await mode.init();
|
|
168
|
+
profileMark("interactive: mode.init() done");
|
|
172
169
|
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
// resolves
|
|
176
|
-
if (
|
|
177
|
-
|
|
170
|
+
// Update notice: fully non-blocking. Startup never waits on the network version
|
|
171
|
+
// check; surface the notice whenever it resolves (typically after the prompt is
|
|
172
|
+
// already up). If it never resolves, the update is available on demand via /plugins.
|
|
173
|
+
if (settings.get("startup.checkUpdate")) {
|
|
174
|
+
void versionCheckPromise
|
|
175
|
+
.then(latest => {
|
|
176
|
+
if (latest) mode.showStatus(`Update available: v${latest} — run: xcsh update`, { dim: true });
|
|
177
|
+
})
|
|
178
|
+
.catch(() => {});
|
|
178
179
|
}
|
|
179
180
|
|
|
180
181
|
mode.renderInitialMessages();
|
|
182
|
+
profileMark("interactive: initial render requested");
|
|
181
183
|
|
|
182
184
|
for (const notify of notifs) {
|
|
183
185
|
if (!notify) {
|
|
@@ -210,6 +212,8 @@ async function runInteractiveMode(
|
|
|
210
212
|
}
|
|
211
213
|
}
|
|
212
214
|
|
|
215
|
+
profileMark("interactive: READY — awaiting first input");
|
|
216
|
+
profileDump();
|
|
213
217
|
while (true) {
|
|
214
218
|
const input = await mode.getUserInput();
|
|
215
219
|
await submitInteractiveInput(mode, session, input);
|
|
@@ -551,6 +555,7 @@ async function buildSessionOptions(
|
|
|
551
555
|
|
|
552
556
|
export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<void> {
|
|
553
557
|
logger.startTiming();
|
|
558
|
+
profileMark("entry: runtime + module-graph loaded (pre-main)");
|
|
554
559
|
|
|
555
560
|
// Initialize theme early with defaults (CLI commands need symbols)
|
|
556
561
|
// Will be re-initialized with user preferences later
|
|
@@ -895,6 +900,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
|
|
|
895
900
|
sessionOptions,
|
|
896
901
|
);
|
|
897
902
|
logger.time("main:afterCreateSession");
|
|
903
|
+
profileMark("createAgentSession done");
|
|
898
904
|
if (parsedArgs.apiKey && !sessionOptions.model && session.model) {
|
|
899
905
|
authStorage.setRuntimeApiKey(session.model.provider, parsedArgs.apiKey);
|
|
900
906
|
}
|
|
@@ -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));
|
|
@@ -274,10 +274,12 @@ export class ExtensionUiController {
|
|
|
274
274
|
this.showExtensionError(error.extensionPath, error.error);
|
|
275
275
|
});
|
|
276
276
|
|
|
277
|
-
// Emit session_start
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
277
|
+
// Emit session_start in the BACKGROUND so a slow hook (e.g. a plugin doing a
|
|
278
|
+
// network/CLI check) can never block the TUI paint. The runner discards
|
|
279
|
+
// session_start handler results, so nothing downstream depends on completion;
|
|
280
|
+
// hooks that update the UI (widgets/status) render progressively as they finish.
|
|
281
|
+
// Handler errors are still surfaced via the onError subscription above.
|
|
282
|
+
void extensionRunner.emit({ type: "session_start" });
|
|
281
283
|
}
|
|
282
284
|
|
|
283
285
|
setHookWidget(key: string, content: ExtensionWidgetContent, options?: ExtensionWidgetOptions): void {
|
|
@@ -35,6 +35,7 @@ import planModeApprovedPrompt from "../prompts/system/plan-mode-approved.md" wit
|
|
|
35
35
|
import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
|
|
36
36
|
import { HistoryStorage } from "../session/history-storage";
|
|
37
37
|
import type { SessionContext, SessionManager } from "../session/session-manager";
|
|
38
|
+
import { profileMark } from "../startup-profile";
|
|
38
39
|
import { STTController, type SttState } from "../stt";
|
|
39
40
|
import type { ExitPlanModeDetails } from "../tools";
|
|
40
41
|
import type { EventBus } from "../utils/event-bus";
|
|
@@ -294,6 +295,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
294
295
|
async init(): Promise<void> {
|
|
295
296
|
if (this.isInitialized) return;
|
|
296
297
|
|
|
298
|
+
profileMark("init: start");
|
|
297
299
|
logger.time("InteractiveMode.init:keybindings");
|
|
298
300
|
this.keybindings = KeybindingsManager.create();
|
|
299
301
|
|
|
@@ -305,6 +307,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
305
307
|
this.refreshSlashCommandState.bind(this),
|
|
306
308
|
getProjectDir(),
|
|
307
309
|
);
|
|
310
|
+
profileMark("init: refreshSlashCommandState done");
|
|
308
311
|
|
|
309
312
|
// Refresh user profile in background — fire and forget
|
|
310
313
|
reconcileFromCollectors().catch(err => logger.warn("Background profile refresh failed", { error: String(err) }));
|
|
@@ -368,10 +371,12 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
368
371
|
|
|
369
372
|
// Load initial todos
|
|
370
373
|
await this.#loadTodoList();
|
|
374
|
+
profileMark("init: loadTodoList done");
|
|
371
375
|
|
|
372
376
|
// Start the UI
|
|
373
377
|
const clearScreen = settings.get("startup.clearScreen");
|
|
374
378
|
this.ui.start(clearScreen);
|
|
379
|
+
profileMark("init: ui.start done");
|
|
375
380
|
pushTerminalTitle();
|
|
376
381
|
setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd());
|
|
377
382
|
this.#syncEditorMaxHeight();
|
|
@@ -379,9 +384,11 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
379
384
|
|
|
380
385
|
// Initialize hooks with TUI-based UI context
|
|
381
386
|
await this.initHooksAndCustomTools();
|
|
387
|
+
profileMark("init: initHooksAndCustomTools done");
|
|
382
388
|
|
|
383
389
|
// Restore mode from session (e.g. plan mode on resume)
|
|
384
390
|
await this.#restoreModeFromSession();
|
|
391
|
+
profileMark("init: restoreModeFromSession done");
|
|
385
392
|
|
|
386
393
|
// Subscribe to agent events
|
|
387
394
|
this.#subscribeToAgent();
|
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
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in startup profiler — `PI_STARTUP_PROFILE=1`.
|
|
3
|
+
*
|
|
4
|
+
* PI_TIMING exits before the TUI paints, so it cannot measure `runInteractiveMode`
|
|
5
|
+
* or the pre-`main` module-graph load. This records labeled timestamps (ms since
|
|
6
|
+
* process start, via `performance.now()`, which is measured from runtime start) and
|
|
7
|
+
* dumps the full boot timeline to a FILE at the input-ready point — so nothing
|
|
8
|
+
* corrupts the live TUI screen. Zero cost when the env var is unset.
|
|
9
|
+
*
|
|
10
|
+
* The FIRST mark's timestamp is the time from process start to that mark, i.e. it
|
|
11
|
+
* captures the runtime init + evaluation of the embedded module graph that happens
|
|
12
|
+
* before any of our code runs. Later marks show the per-step deltas through paint.
|
|
13
|
+
*
|
|
14
|
+
* Usage: PI_STARTUP_PROFILE=1 xcsh (then quit; read /tmp/xcsh-startup-profile.txt,
|
|
15
|
+
* or set PI_STARTUP_PROFILE_FILE to choose the path).
|
|
16
|
+
*/
|
|
17
|
+
import { writeFileSync } from "node:fs";
|
|
18
|
+
|
|
19
|
+
const ENABLED = !!process.env.PI_STARTUP_PROFILE;
|
|
20
|
+
const marks: Array<[label: string, atMs: number]> = [];
|
|
21
|
+
|
|
22
|
+
/** Record a labeled timestamp (no-op unless PI_STARTUP_PROFILE is set). */
|
|
23
|
+
export function profileMark(label: string): void {
|
|
24
|
+
if (ENABLED) marks.push([label, performance.now()]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Write the collected timeline to a file (no-op unless enabled / no marks). */
|
|
28
|
+
export function profileDump(): void {
|
|
29
|
+
if (!ENABLED || marks.length === 0) return;
|
|
30
|
+
const lines = ["=== xcsh startup profile (ms since process start) ===", ""];
|
|
31
|
+
let prev = 0;
|
|
32
|
+
for (const [label, at] of marks) {
|
|
33
|
+
const delta = at - prev;
|
|
34
|
+
lines.push(` ${at.toFixed(1).padStart(9)} ms (+${delta.toFixed(1).padStart(8)} ms) ${label}`);
|
|
35
|
+
prev = at;
|
|
36
|
+
}
|
|
37
|
+
lines.push(
|
|
38
|
+
"",
|
|
39
|
+
` first mark = runtime + module-graph load; total to ready = ${marks[marks.length - 1][1].toFixed(1)} ms`,
|
|
40
|
+
);
|
|
41
|
+
const file = process.env.PI_STARTUP_PROFILE_FILE || "/tmp/xcsh-startup-profile.txt";
|
|
42
|
+
try {
|
|
43
|
+
writeFileSync(file, `${lines.join("\n")}\n`);
|
|
44
|
+
process.stderr.write(`[xcsh] startup profile written to ${file}\n`);
|
|
45
|
+
} catch {
|
|
46
|
+
/* best-effort diagnostic */
|
|
47
|
+
}
|
|
48
|
+
}
|