@f5-sales-demo/xcsh 21.2.0 → 21.4.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/browser/headless-bridge.ts +103 -71
- package/src/commands/worker.ts +94 -196
- package/src/config/settings-schema.ts +18 -0
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/internal-urls/docs-index.generated.ts +3 -3
- package/src/internal-urls/terraform-index.generated.ts +1 -1
- package/src/modes/components/model-selector.ts +58 -11
- package/src/modes/components/oauth-selector.ts +14 -2
- package/src/modes/controllers/login-model.ts +22 -10
- package/src/modes/controllers/login-options.ts +21 -0
- package/src/modes/controllers/selector-controller.ts +67 -3
- package/src/modes/controllers/vertex-login-flow.ts +109 -0
- package/src/routing/subscription-profiles.ts +1 -1
- package/src/sdk.ts +14 -1
- package/src/slash-commands/builtin-registry.ts +2 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "21.
|
|
4
|
+
"version": "21.4.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",
|
|
@@ -61,13 +61,13 @@
|
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
63
|
"@agentclientprotocol/sdk": "1.3.0",
|
|
64
|
-
"@f5-sales-demo/pi-agent-core": "21.
|
|
65
|
-
"@f5-sales-demo/pi-ai": "21.
|
|
66
|
-
"@f5-sales-demo/pi-natives": "21.
|
|
67
|
-
"@f5-sales-demo/pi-resource-management": "21.
|
|
68
|
-
"@f5-sales-demo/pi-tui": "21.
|
|
69
|
-
"@f5-sales-demo/pi-utils": "21.
|
|
70
|
-
"@f5-sales-demo/xcsh-stats": "21.
|
|
64
|
+
"@f5-sales-demo/pi-agent-core": "21.4.0",
|
|
65
|
+
"@f5-sales-demo/pi-ai": "21.4.0",
|
|
66
|
+
"@f5-sales-demo/pi-natives": "21.4.0",
|
|
67
|
+
"@f5-sales-demo/pi-resource-management": "21.4.0",
|
|
68
|
+
"@f5-sales-demo/pi-tui": "21.4.0",
|
|
69
|
+
"@f5-sales-demo/pi-utils": "21.4.0",
|
|
70
|
+
"@f5-sales-demo/xcsh-stats": "21.4.0",
|
|
71
71
|
"@mozilla/readability": "^0.6",
|
|
72
72
|
"@sinclair/typebox": "^0.34",
|
|
73
73
|
"@xterm/headless": "^6.0",
|
|
@@ -1,20 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* create ONE agent session scoped to the browser tools, and attach the
|
|
9
|
-
* `ChatHandler`. Office document tools (Excel/Word/PPT) are advertised by the
|
|
10
|
-
* pane at runtime over the bridge (`set_host_tools`), so they need no scoping here.
|
|
2
|
+
* Shared no-TUI extension-bridge bootstrap for `xcsh office serve` and `xcsh
|
|
3
|
+
* worker`: initialize the browser-provider environment, Settings, Context, and
|
|
4
|
+
* discovery; resolve TLS; bind and publish the bridge; create one profile-scoped
|
|
5
|
+
* agent session; and attach its ChatHandler. Worker fleet behavior stays in
|
|
6
|
+
* `commands/worker.ts` and is connected through the lifecycle hooks below.
|
|
7
|
+
* Office document tools are advertised by the pane at runtime over the bridge.
|
|
11
8
|
*
|
|
12
9
|
* The heavy / socket / network calls are injected (defaulting to the real ones)
|
|
13
10
|
* so the wiring is unit-testable without opening real listeners or a session.
|
|
14
11
|
*
|
|
15
12
|
* NOT browser-safe (node/bun): runs inside the full xcsh binary, never the pane.
|
|
16
13
|
*/
|
|
17
|
-
import { getProjectDir, getXCSHConfigDir } from "@f5-sales-demo/pi-utils";
|
|
14
|
+
import { getProjectDir, getXCSHConfigDir, logger } from "@f5-sales-demo/pi-utils";
|
|
18
15
|
import { parseModelString } from "../config/model-resolver";
|
|
19
16
|
import { DEFAULT_MODEL_ROLE } from "../config/settings-schema";
|
|
20
17
|
import { createAgentSession } from "../sdk";
|
|
@@ -24,8 +21,13 @@ import { SessionManager } from "../session/session-manager";
|
|
|
24
21
|
import { resolveBridgeTls } from "./bridge-cert";
|
|
25
22
|
import { ChatHandler } from "./chat-handler";
|
|
26
23
|
import { isPickPath, type PathPicked } from "./chat-protocol";
|
|
27
|
-
import { type BridgeServer, OFFICE_PORT_RANGE, startBridgeServer } from "./extension-bridge";
|
|
28
|
-
import {
|
|
24
|
+
import { type BridgeServer, type BridgeSessionInfo, OFFICE_PORT_RANGE, startBridgeServer } from "./extension-bridge";
|
|
25
|
+
import {
|
|
26
|
+
BROWSER_TOOL_NAMES,
|
|
27
|
+
createExtensionBridgeTools,
|
|
28
|
+
EXTENSION_AGENT_TOOL_NAMES,
|
|
29
|
+
OFFICE_TOOL_NAMES,
|
|
30
|
+
} from "./extension-bridge-tools";
|
|
29
31
|
import { pickPathNative } from "./native-picker";
|
|
30
32
|
import { setSharedBridgeServer } from "./provider";
|
|
31
33
|
|
|
@@ -33,9 +35,34 @@ import { setSharedBridgeServer } from "./provider";
|
|
|
33
35
|
* closes the bridge (both ws + wss listeners). */
|
|
34
36
|
export interface HeadlessChatBridge {
|
|
35
37
|
bridge: BridgeServer;
|
|
38
|
+
handler: ChatHandler;
|
|
36
39
|
dispose: () => Promise<void>;
|
|
37
40
|
}
|
|
38
41
|
|
|
42
|
+
type Session = Awaited<ReturnType<typeof createAgentSession>>["session"];
|
|
43
|
+
|
|
44
|
+
export interface HeadlessBridgeLifecycleContext {
|
|
45
|
+
bridge: BridgeServer;
|
|
46
|
+
cwd: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface HeadlessBridgeSessionContext extends HeadlessBridgeLifecycleContext {
|
|
50
|
+
session: Session;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type HeadlessBridgeOptions =
|
|
54
|
+
| {
|
|
55
|
+
kind?: "office";
|
|
56
|
+
afterBridgeBind?: (context: HeadlessBridgeLifecycleContext) => void | Promise<void>;
|
|
57
|
+
afterSessionCreate?: (context: HeadlessBridgeSessionContext) => void | Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
| {
|
|
60
|
+
kind: "worker";
|
|
61
|
+
sessionInfo: () => BridgeSessionInfo;
|
|
62
|
+
afterBridgeBind?: (context: HeadlessBridgeLifecycleContext) => void | Promise<void>;
|
|
63
|
+
afterSessionCreate?: (context: HeadlessBridgeSessionContext) => void | Promise<void>;
|
|
64
|
+
};
|
|
65
|
+
|
|
39
66
|
/**
|
|
40
67
|
* Tenant identity for the `hello` handshake, contextless-friendly: the active
|
|
41
68
|
* `/context` wins (its apiUrl + name), otherwise fall back to the `XCSH_API_URL`/
|
|
@@ -113,20 +140,25 @@ const defaultDeps: HeadlessBridgeDeps = {
|
|
|
113
140
|
* can connect and chat immediately (no warm-up race). A `configure`-less pane
|
|
114
141
|
* chats over xcsh's already-configured provider.
|
|
115
142
|
*/
|
|
116
|
-
export async function startHeadlessChatBridge(
|
|
143
|
+
export async function startHeadlessChatBridge(
|
|
144
|
+
deps: HeadlessBridgeDeps = defaultDeps,
|
|
145
|
+
options: HeadlessBridgeOptions = { kind: "office" },
|
|
146
|
+
): Promise<HeadlessChatBridge> {
|
|
117
147
|
const { cwd } = await deps.initEnv();
|
|
148
|
+
const kind = options.kind ?? "office";
|
|
118
149
|
|
|
119
150
|
// Provision the wss cert before binding (warm boot = on-disk cache hit);
|
|
120
151
|
// `undefined` (offline) → the bridge starts ws-only.
|
|
121
152
|
const tls = await deps.resolveBridgeTls();
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
153
|
+
const start = () =>
|
|
154
|
+
deps.startBridgeServer(undefined, {
|
|
155
|
+
serveKind: kind === "worker" ? "browser" : "office",
|
|
156
|
+
sessionInfo: options.kind === "worker" ? options.sessionInfo : sessionInfoForOfficeServe,
|
|
157
|
+
...(tls ? { tls } : {}),
|
|
158
|
+
...(kind === "office" ? { range: OFFICE_PORT_RANGE } : {}),
|
|
159
|
+
});
|
|
160
|
+
const bridge = kind === "worker" ? await logger.time("session:bridgeListen", start) : await start();
|
|
161
|
+
|
|
130
162
|
// Reuse this bridge for any in-process selectProvider() (no conflicting second bridge).
|
|
131
163
|
deps.setSharedBridgeServer(bridge);
|
|
132
164
|
// Re-announce the tenant when the active context changes (best-effort).
|
|
@@ -136,72 +168,72 @@ export async function startHeadlessChatBridge(deps: HeadlessBridgeDeps = default
|
|
|
136
168
|
/* ContextService not initialized (tests) — the tenant is static. */
|
|
137
169
|
}
|
|
138
170
|
|
|
139
|
-
|
|
140
|
-
// provider, etc.). If it does, close the already-bound bridge and clear the
|
|
141
|
-
// shared-bridge global before rethrowing — otherwise the ws/wss listeners leak
|
|
142
|
-
// (keeping the event loop alive so Ctrl+C can't exit) and a later in-process
|
|
143
|
-
// selectProvider() reuses a dead bridge. The caller (startOfficeServe) treats
|
|
144
|
-
// the rethrow as a non-fatal "pane only" fallback.
|
|
171
|
+
let chatHandler: ChatHandler | undefined;
|
|
145
172
|
try {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
173
|
+
await options.afterBridgeBind?.({ bridge, cwd });
|
|
174
|
+
|
|
175
|
+
let session: Session;
|
|
176
|
+
if (kind === "worker") {
|
|
177
|
+
const create = () =>
|
|
178
|
+
deps.createAgentSession({
|
|
179
|
+
cwd,
|
|
180
|
+
hasUI: false,
|
|
181
|
+
toolNames: [...new Set([...BROWSER_TOOL_NAMES, ...EXTENSION_AGENT_TOOL_NAMES])],
|
|
182
|
+
customTools: createExtensionBridgeTools(bridge),
|
|
183
|
+
enableMCP: false,
|
|
184
|
+
enableLsp: false,
|
|
185
|
+
disableExtensionDiscovery: true,
|
|
186
|
+
...(process.env.XCSH_BENCH_EXTENSION
|
|
187
|
+
? { additionalExtensionPaths: [process.env.XCSH_BENCH_EXTENSION] }
|
|
188
|
+
: {}),
|
|
189
|
+
});
|
|
190
|
+
({ session } = await logger.time("session:createAgentSession", create));
|
|
191
|
+
} else {
|
|
192
|
+
const officeDefault = parseModelString(DEFAULT_MODEL_ROLE);
|
|
193
|
+
if (!officeDefault) {
|
|
194
|
+
throw new Error("Invalid baked Office default model selector");
|
|
195
|
+
}
|
|
196
|
+
({ session } = await deps.createAgentSession({
|
|
197
|
+
cwd,
|
|
198
|
+
hasUI: false,
|
|
199
|
+
modelPattern: DEFAULT_MODEL_ROLE,
|
|
200
|
+
thinkingLevel: officeDefault.thinkingLevel,
|
|
201
|
+
sessionManager: SessionManager.inMemory(cwd),
|
|
202
|
+
toolNames: [...OFFICE_TOOL_NAMES],
|
|
203
|
+
customTools: [],
|
|
204
|
+
enableMCP: false,
|
|
205
|
+
enableLsp: false,
|
|
206
|
+
disableExtensionDiscovery: true,
|
|
207
|
+
bundledExtensions: ["sandbox-guard"],
|
|
208
|
+
}));
|
|
149
209
|
}
|
|
150
|
-
// Create ONE headless Office session with the full CLI-parity builtin set
|
|
151
|
-
// (OFFICE_TOOL_NAMES: bash/read/write/edit/grep/inspect_image/… — NO browser tools, which
|
|
152
|
-
// would be hallucinated in a document task pane). The document's own tools
|
|
153
|
-
// (Excel/Word/PowerPoint) arrive at runtime via set_host_tools.
|
|
154
|
-
const { session } = await deps.createAgentSession({
|
|
155
|
-
cwd,
|
|
156
|
-
hasUI: false,
|
|
157
|
-
// Office must open on the production GPT profile regardless of a stale
|
|
158
|
-
// global/default role or resumable session. A saved pane model is applied
|
|
159
|
-
// afterward by its explicit configure frame and remains authoritative.
|
|
160
|
-
modelPattern: DEFAULT_MODEL_ROLE,
|
|
161
|
-
thinkingLevel: officeDefault.thinkingLevel,
|
|
162
|
-
// Office conversations can contain private workbook and working-directory
|
|
163
|
-
// data. Keep the entire headless session ephemeral instead of inheriting
|
|
164
|
-
// createAgentSession's file-backed default.
|
|
165
|
-
sessionManager: SessionManager.inMemory(cwd),
|
|
166
|
-
toolNames: [...OFFICE_TOOL_NAMES],
|
|
167
|
-
customTools: [],
|
|
168
|
-
// Headless: no MCP/LSP/extension discovery — lean, no network/blocking prompts.
|
|
169
|
-
enableMCP: false,
|
|
170
|
-
enableLsp: false,
|
|
171
|
-
disableExtensionDiscovery: true,
|
|
172
|
-
// …but DO load the bundled filesystem sandbox: the pane runs full CLI-parity
|
|
173
|
-
// tools (bash/read/write), so it needs the CLI's safety net confining file
|
|
174
|
-
// tools + the shell's cwd to the launch directory subtree (sandbox.enabled
|
|
175
|
-
// defaults true). Without this, a discovery-disabled session ran ungated.
|
|
176
|
-
bundledExtensions: ["sandbox-guard"],
|
|
177
|
-
});
|
|
178
210
|
|
|
179
|
-
|
|
211
|
+
await options.afterSessionCreate?.({ bridge, session, cwd });
|
|
212
|
+
chatHandler = new deps.ChatHandlerCtor(bridge, session);
|
|
180
213
|
chatHandler.attach();
|
|
181
214
|
|
|
182
|
-
// Second bridge subscriber (the bridge fans out to all): serve `pick_path` by
|
|
183
|
-
// opening a native OS picker and replying `path_picked`. Pure — it only returns
|
|
184
|
-
// the chosen path; the sandbox grant + prompt note happen in ChatHandler when the
|
|
185
|
-
// path rides the next `chat_request.contextPaths` (kept atomic there). `disposed`
|
|
186
|
-
// guards against a superseded serve firing the picker on a closed bridge.
|
|
187
215
|
let disposed = false;
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
216
|
+
if (kind === "office") {
|
|
217
|
+
bridge.onMessage(async msg => {
|
|
218
|
+
if (disposed || !isPickPath(msg)) return;
|
|
219
|
+
const { path, canceled, unsupported } = await deps.pickPath((msg as { mode: "file" | "folder" }).mode);
|
|
220
|
+
if (disposed) return;
|
|
221
|
+
bridge.send({ type: "path_picked", path, canceled, unsupported } satisfies PathPicked);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
194
224
|
|
|
195
225
|
return {
|
|
196
226
|
bridge,
|
|
227
|
+
handler: chatHandler,
|
|
197
228
|
dispose: async () => {
|
|
198
229
|
disposed = true;
|
|
199
|
-
chatHandler
|
|
230
|
+
chatHandler?.dispose();
|
|
200
231
|
deps.setSharedBridgeServer(null);
|
|
201
232
|
await bridge.close();
|
|
202
233
|
},
|
|
203
234
|
};
|
|
204
235
|
} catch (err) {
|
|
236
|
+
chatHandler?.dispose();
|
|
205
237
|
deps.setSharedBridgeServer(null);
|
|
206
238
|
await bridge.close();
|
|
207
239
|
throw err;
|
package/src/commands/worker.ts
CHANGED
|
@@ -15,20 +15,11 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { homedir } from "node:os";
|
|
17
17
|
import { join } from "node:path";
|
|
18
|
-
import {
|
|
18
|
+
import { logger } from "@f5-sales-demo/pi-utils";
|
|
19
19
|
import { Command } from "@f5-sales-demo/pi-utils/cli";
|
|
20
|
-
import { LOCALIP_HOST
|
|
21
|
-
import {
|
|
22
|
-
import { startBridgeServer } from "../browser/extension-bridge";
|
|
23
|
-
import {
|
|
24
|
-
BROWSER_TOOL_NAMES,
|
|
25
|
-
createExtensionBridgeTools,
|
|
26
|
-
EXTENSION_AGENT_TOOL_NAMES,
|
|
27
|
-
} from "../browser/extension-bridge-tools";
|
|
28
|
-
import { setSharedBridgeServer } from "../browser/provider";
|
|
20
|
+
import { LOCALIP_HOST } from "../browser/bridge-cert";
|
|
21
|
+
import { startHeadlessChatBridge } from "../browser/headless-bridge";
|
|
29
22
|
import { coldStartSpans, type SpanFrame, sessionBuildSpan } from "../browser/ttft-spans";
|
|
30
|
-
import { initializeWithSettings } from "../discovery";
|
|
31
|
-
import { createAgentSession } from "../sdk";
|
|
32
23
|
import { activateTenantContext } from "../services/session-context-binding";
|
|
33
24
|
import { ContextService } from "../services/xcsh-context";
|
|
34
25
|
import { deriveTenantEnv } from "../services/xcsh-env";
|
|
@@ -102,197 +93,113 @@ export default class Worker extends Command {
|
|
|
102
93
|
static description = "Run a headless extension-bridge worker (no TUI); blocks until SIGTERM";
|
|
103
94
|
|
|
104
95
|
async run(): Promise<void> {
|
|
105
|
-
// Record the per-tab session-boot timeline (parity with main.ts:runRootCommand).
|
|
106
|
-
// Spans only accumulate while recording; nothing prints unless PI_TIMING is set,
|
|
107
|
-
// and each logger.time() returns its wrapped value unchanged — so a normal
|
|
108
|
-
// `xcsh worker` run is behaviorally identical to before.
|
|
109
|
-
// TTFT Phase 2: the manager stamps XCSH_TTFT_SPAWN_AT at Bun.spawn for a cold
|
|
110
|
-
// spawn; worker_boot(cold) = bridge-listening instant - spawn instant (captures
|
|
111
|
-
// fork + runtime init, which logger.startTiming below misses).
|
|
112
96
|
const spawnAtEnv = Number(process.env.XCSH_TTFT_SPAWN_AT);
|
|
113
97
|
const coldSpawn = process.env.XCSH_TTFT_COLD === "1";
|
|
114
98
|
const managerProvisionMsEnv = Number(process.env.XCSH_TTFT_PROVISION_MS);
|
|
115
99
|
logger.startTiming();
|
|
116
100
|
|
|
117
|
-
process.env.XCSH_BROWSER_PROVIDER = "extension";
|
|
118
|
-
|
|
119
|
-
const cwd = getProjectDir();
|
|
120
|
-
const { Settings, settings } = await import("../config/settings");
|
|
121
|
-
await Settings.init({ cwd });
|
|
122
|
-
|
|
123
|
-
// Init the ContextService singleton so the session-context bootstrap (Task 3)
|
|
124
|
-
// can match XCSH_SESSION_TENANT to a stored context, and so sessionInfoForWorker
|
|
125
|
-
// can read the active apiUrl once bound.
|
|
126
|
-
try {
|
|
127
|
-
ContextService.init(getXCSHConfigDir());
|
|
128
|
-
} catch {
|
|
129
|
-
/* already initialized / unavailable — continue. */
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Provider persistence for model discovery (parity with main.ts).
|
|
133
|
-
initializeWithSettings(settings);
|
|
134
|
-
|
|
135
|
-
// Quiet startup: skip the welcome screen + blocking plugin "Fix now?" prompts.
|
|
136
|
-
settings.override("startup.quiet", true);
|
|
137
|
-
|
|
138
|
-
// Provision the wss cert BEFORE the session:bridgeListen span — its only
|
|
139
|
-
// network path (a cold/stale-cache fetch) must NOT inflate the measured
|
|
140
|
-
// bridge-ready time; a warm boot is a fast on-disk cache hit. `undefined`
|
|
141
|
-
// (offline / local-ip.sh unreachable) → the bridge starts ws-only (no crash).
|
|
142
|
-
const tls = await resolveBridgeTls();
|
|
143
|
-
|
|
144
|
-
// INSTANT-ON: start the bridge before the heavy session init so the extension
|
|
145
|
-
// can connect immediately. Honors XCSH_BRIDGE_PORT (forced) or auto-selects.
|
|
146
|
-
// session:bridgeListen — time-to-"bridge-ready": the extension can connect and
|
|
147
|
-
// complete the hello/hello_ack handshake once this resolves (INSTANT-ON path).
|
|
148
|
-
const bridge = await logger.time("session:bridgeListen", () =>
|
|
149
|
-
startBridgeServer(undefined, {
|
|
150
|
-
serveKind: "browser",
|
|
151
|
-
sessionInfo: sessionInfoForWorker,
|
|
152
|
-
...(tls ? { tls } : {}),
|
|
153
|
-
}),
|
|
154
|
-
);
|
|
155
|
-
console.error(
|
|
156
|
-
`[xcsh worker] extension bridge listening on ws://127.0.0.1:${bridge.port}` +
|
|
157
|
-
(bridge.wssPort ? ` + wss://${LOCALIP_HOST}:${bridge.wssPort}` : ""),
|
|
158
|
-
);
|
|
159
|
-
if (process.connected) {
|
|
160
|
-
process.send?.({ type: "ready", sessionId: sessionInfoForWorker().sessionId });
|
|
161
|
-
}
|
|
162
|
-
setSharedBridgeServer(bridge);
|
|
163
|
-
ContextService.onContextChange(() => bridge.broadcastTenantChanged());
|
|
164
|
-
|
|
165
|
-
// TTFT Phase 2: buffer the per-session cold-start spans and flush them once a
|
|
166
|
-
// client is actually connected. bridge.send() silently no-ops with no client, so
|
|
167
|
-
// the flush is gated on `clientConnected` — a cold-boot flush before the extension
|
|
168
|
-
// connects would otherwise burn the once-latch and lose the spans. Cold path is
|
|
169
|
-
// populated now (env); warm path by the {bind} handler below.
|
|
170
101
|
let coldStartBuffer: SpanFrame[] = [];
|
|
171
102
|
let coldStartSent = false;
|
|
172
103
|
let clientConnected = false;
|
|
173
|
-
const flushColdStart = (): void => {
|
|
174
|
-
if (coldStartSent || !clientConnected || coldStartBuffer.length === 0) return;
|
|
175
|
-
for (const s of coldStartBuffer) bridge.send(s);
|
|
176
|
-
coldStartSent = true;
|
|
177
|
-
};
|
|
178
|
-
// TTFT: the session_build span (createAgentSession seam) is computed after the
|
|
179
|
-
// bridge is listening but possibly before the extension connects — buffer it and
|
|
180
|
-
// flush on connect, same as the cold-start spans.
|
|
181
104
|
let sessionBuildFrame: SpanFrame | null = null;
|
|
182
105
|
let sessionBuildSent = false;
|
|
183
|
-
|
|
184
|
-
if (sessionBuildSent || !clientConnected || !sessionBuildFrame) return;
|
|
185
|
-
bridge.send(sessionBuildFrame);
|
|
186
|
-
sessionBuildSent = true;
|
|
187
|
-
};
|
|
188
|
-
// onConnected (raw WS open) is the deliberate flush trigger: no hello hook is exposed
|
|
189
|
-
// today, and the bridge's origin check already gates opens to the extension.
|
|
190
|
-
bridge.onConnected(() => {
|
|
191
|
-
clientConnected = true;
|
|
192
|
-
flushColdStart();
|
|
193
|
-
flushSessionBuild();
|
|
194
|
-
});
|
|
106
|
+
let sessionBuildStart = 0;
|
|
195
107
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
108
|
+
const running = await startHeadlessChatBridge(undefined, {
|
|
109
|
+
kind: "worker",
|
|
110
|
+
sessionInfo: sessionInfoForWorker,
|
|
111
|
+
afterBridgeBind: ({ bridge }) => {
|
|
112
|
+
console.error(
|
|
113
|
+
`[xcsh worker] extension bridge listening on ws://127.0.0.1:${bridge.port}` +
|
|
114
|
+
(bridge.wssPort ? ` + wss://${LOCALIP_HOST}:${bridge.wssPort}` : ""),
|
|
115
|
+
);
|
|
116
|
+
if (process.connected) {
|
|
117
|
+
process.send?.({ type: "ready", sessionId: sessionInfoForWorker().sessionId });
|
|
118
|
+
}
|
|
202
119
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
} catch {
|
|
225
|
-
console.error("[xcsh worker] late tenant-bind failed");
|
|
120
|
+
const flushColdStart = (): void => {
|
|
121
|
+
if (coldStartSent || !clientConnected || coldStartBuffer.length === 0) return;
|
|
122
|
+
for (const span of coldStartBuffer) bridge.send(span);
|
|
123
|
+
coldStartSent = true;
|
|
124
|
+
};
|
|
125
|
+
const flushSessionBuild = (): void => {
|
|
126
|
+
if (sessionBuildSent || !clientConnected || !sessionBuildFrame) return;
|
|
127
|
+
bridge.send(sessionBuildFrame);
|
|
128
|
+
sessionBuildSent = true;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
bridge.onConnected(() => {
|
|
132
|
+
clientConnected = true;
|
|
133
|
+
flushColdStart();
|
|
134
|
+
flushSessionBuild();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (coldSpawn && process.env.XCSH_SESSION_ID && Number.isFinite(spawnAtEnv)) {
|
|
138
|
+
const workerBootMs = Date.now() - spawnAtEnv;
|
|
139
|
+
const managerMs = Number.isFinite(managerProvisionMsEnv) ? managerProvisionMsEnv : 0;
|
|
140
|
+
coldStartBuffer = coldStartSpans(process.env.XCSH_SESSION_ID, true, managerMs, workerBootMs);
|
|
226
141
|
}
|
|
227
|
-
bridge.broadcastTenantChanged();
|
|
228
|
-
// The standalone benchmark retains IPC to measure adoption latency. A real
|
|
229
|
-
// manager disconnects after binding so the worker survives manager handoff.
|
|
230
|
-
if (process.connected) process.send?.({ type: "bound", sessionId });
|
|
231
|
-
// TTFT Phase 2: warm adopt cold-start spans (worker_boot = bind -> bound).
|
|
232
|
-
coldStartBuffer = coldStartSpans(sessionId, false, relayedProvisionMs, Date.now() - bindAt);
|
|
233
|
-
flushColdStart();
|
|
234
|
-
})();
|
|
235
|
-
});
|
|
236
142
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
flushSessionBuild();
|
|
143
|
+
process.on("message", (raw: unknown) => {
|
|
144
|
+
const message = raw as {
|
|
145
|
+
type?: unknown;
|
|
146
|
+
sessionId?: unknown;
|
|
147
|
+
tenant?: unknown;
|
|
148
|
+
provisionMs?: unknown;
|
|
149
|
+
cold?: unknown;
|
|
150
|
+
};
|
|
151
|
+
if (
|
|
152
|
+
message?.type !== "bind" ||
|
|
153
|
+
typeof message.sessionId !== "string" ||
|
|
154
|
+
typeof message.tenant !== "string"
|
|
155
|
+
)
|
|
156
|
+
return;
|
|
157
|
+
const sessionId = message.sessionId;
|
|
158
|
+
const tenant = message.tenant;
|
|
159
|
+
const bindAt = Date.now();
|
|
160
|
+
const relayedProvisionMs = typeof message.provisionMs === "number" ? message.provisionMs : 0;
|
|
161
|
+
setWorkerIdentity(sessionId, tenant);
|
|
162
|
+
void (async () => {
|
|
163
|
+
try {
|
|
164
|
+
await activateTenantContext(tenant);
|
|
165
|
+
} catch {
|
|
166
|
+
console.error("[xcsh worker] late tenant-bind failed");
|
|
167
|
+
}
|
|
168
|
+
bridge.broadcastTenantChanged();
|
|
169
|
+
if (process.connected) process.send?.({ type: "bound", sessionId });
|
|
170
|
+
coldStartBuffer = coldStartSpans(sessionId, false, relayedProvisionMs, Date.now() - bindAt);
|
|
171
|
+
flushColdStart();
|
|
172
|
+
})();
|
|
173
|
+
});
|
|
269
174
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
if (benchModel) {
|
|
279
|
-
await session.setModel(benchModel);
|
|
280
|
-
} else {
|
|
281
|
-
console.error(
|
|
282
|
-
"[xcsh worker] BENCH ERROR: bench-instant model not registered — benchmark would measure a real provider",
|
|
175
|
+
// The shared bootstrap creates the session immediately after this hook.
|
|
176
|
+
sessionBuildStart = Date.now();
|
|
177
|
+
},
|
|
178
|
+
afterSessionCreate: async ({ bridge, session }) => {
|
|
179
|
+
sessionBuildFrame = sessionBuildSpan(
|
|
180
|
+
process.env.XCSH_SESSION_ID ?? "",
|
|
181
|
+
coldSpawn,
|
|
182
|
+
Date.now() - sessionBuildStart,
|
|
283
183
|
);
|
|
284
|
-
|
|
285
|
-
|
|
184
|
+
if (clientConnected && !sessionBuildSent) {
|
|
185
|
+
bridge.send(sessionBuildFrame);
|
|
186
|
+
sessionBuildSent = true;
|
|
187
|
+
}
|
|
286
188
|
|
|
287
|
-
|
|
288
|
-
|
|
189
|
+
if (process.env.XCSH_BENCH_EXTENSION) {
|
|
190
|
+
const benchModel = session.modelRegistry.find("bench-instant", "bench-instant");
|
|
191
|
+
if (benchModel) {
|
|
192
|
+
await session.setModel(benchModel);
|
|
193
|
+
} else {
|
|
194
|
+
console.error(
|
|
195
|
+
"[xcsh worker] BENCH ERROR: bench-instant model not registered — benchmark would measure a real provider",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
const { handler: chatHandler } = running;
|
|
289
202
|
|
|
290
|
-
// Manager keepalive: while a turn is in flight (and at each turn start), ping
|
|
291
|
-
// the manager control socket so it refreshes this worker's lastSeen and its
|
|
292
|
-
// idle sweep does not reap an actively-used session mid-conversation. Chat
|
|
293
|
-
// traffic never reaches the manager, so this is the only liveness signal.
|
|
294
|
-
// Best-effort + self-reconnecting: a dropped socket (e.g. manager supersede)
|
|
295
|
-
// is re-opened on the next emit, re-targeting the successor manager.
|
|
296
203
|
const keepalive = new ManagerKeepalive({
|
|
297
204
|
sessionId: () => sessionInfoForWorker().sessionId ?? "spare",
|
|
298
205
|
busy: () => chatHandler.busy,
|
|
@@ -312,16 +219,13 @@ export default class Worker extends Command {
|
|
|
312
219
|
};
|
|
313
220
|
return transport;
|
|
314
221
|
} catch {
|
|
315
|
-
return null;
|
|
222
|
+
return null;
|
|
316
223
|
}
|
|
317
224
|
},
|
|
318
225
|
});
|
|
319
226
|
chatHandler.onTurnStart(() => keepalive.turnStart());
|
|
320
227
|
const keepaliveTimer = setInterval(() => keepalive.tick(), KEEPALIVE_MS);
|
|
321
228
|
|
|
322
|
-
// session-ready. Emit the per-tab boot breakdown when requested (parity with
|
|
323
|
-
// main.ts:1002-1009). PI_TIMING=x prints then exits — used by bench/extension-session.ts
|
|
324
|
-
// to measure total worker cold-start; otherwise this is a no-op.
|
|
325
229
|
if (process.env.PI_TIMING) {
|
|
326
230
|
logger.printTimings();
|
|
327
231
|
if (process.env.PI_TIMING === "x") {
|
|
@@ -334,16 +238,11 @@ export default class Worker extends Command {
|
|
|
334
238
|
const teardown = () => {
|
|
335
239
|
clearInterval(keepaliveTimer);
|
|
336
240
|
keepalive.stop();
|
|
337
|
-
|
|
338
|
-
void bridge.close().finally(() => process.exit(0));
|
|
241
|
+
void running.dispose().finally(() => process.exit(0));
|
|
339
242
|
};
|
|
340
243
|
const shutdown = () => {
|
|
341
244
|
if (shuttingDown) return;
|
|
342
245
|
shuttingDown = true;
|
|
343
|
-
// Bounded drain (#1874): if a chat turn is in flight (e.g. the manager is
|
|
344
|
-
// recycling for an upgrade), let it finish before teardown instead of
|
|
345
|
-
// aborting the running agent turn — with a hard ceiling so a hung turn can
|
|
346
|
-
// never wedge shutdown. An idle worker tears down immediately.
|
|
347
246
|
if (!chatHandler.busy) return teardown();
|
|
348
247
|
const deadline = Date.now() + WORKER_DRAIN_TIMEOUT_MS;
|
|
349
248
|
const tick = () => {
|
|
@@ -355,7 +254,6 @@ export default class Worker extends Command {
|
|
|
355
254
|
process.on("SIGTERM", shutdown);
|
|
356
255
|
process.on("SIGINT", shutdown);
|
|
357
256
|
|
|
358
|
-
// Block until a signal tears us down.
|
|
359
257
|
await Promise.withResolvers<never>().promise;
|
|
360
258
|
}
|
|
361
259
|
}
|
|
@@ -1976,6 +1976,24 @@ export const SETTINGS_SCHEMA = {
|
|
|
1976
1976
|
"sandbox.allowWrite": { type: "array", default: [] as string[] },
|
|
1977
1977
|
|
|
1978
1978
|
// Provider selection
|
|
1979
|
+
"providers.vertexProject": {
|
|
1980
|
+
type: "string",
|
|
1981
|
+
default: undefined,
|
|
1982
|
+
ui: {
|
|
1983
|
+
tab: "providers",
|
|
1984
|
+
label: "Vertex AI Project",
|
|
1985
|
+
description: "Non-secret corporate Google Cloud project selected by /login",
|
|
1986
|
+
},
|
|
1987
|
+
},
|
|
1988
|
+
"providers.vertexLocation": {
|
|
1989
|
+
type: "string",
|
|
1990
|
+
default: undefined,
|
|
1991
|
+
ui: {
|
|
1992
|
+
tab: "providers",
|
|
1993
|
+
label: "Vertex AI Location",
|
|
1994
|
+
description: "Non-secret Vertex AI location selected by /login",
|
|
1995
|
+
},
|
|
1996
|
+
},
|
|
1979
1997
|
"providers.webSearch": {
|
|
1980
1998
|
type: "enum",
|
|
1981
1999
|
values: [
|