@f5-sales-demo/xcsh 19.60.0 → 19.61.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 +9 -8
- package/scripts/api-specs-version.ts +20 -0
- package/scripts/check-bundle.ts +32 -0
- package/scripts/generate-api-spec-index.ts +29 -1
- package/src/browser/chat-handler.ts +23 -1
- package/src/browser/ttft-spans.ts +42 -0
- package/src/cli/chrome-cli.ts +29 -1
- package/src/cli/update-cli.ts +16 -0
- package/src/commands/chrome.ts +1 -1
- package/src/commands/manager.ts +133 -22
- package/src/commands/worker.ts +46 -1
- package/src/edit/modes/chunk.ts +1 -1
- package/src/internal-urls/api-spec-resolve.ts +50 -26
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/internal-urls/terraform-index.generated.ts +118 -387
- package/src/tools/ast-edit.ts +1 -1
- package/src/tools/ast-grep.ts +1 -1
- package/src/tools/grep.ts +46 -6
- package/src/tools/renderers.ts +2 -5
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.61.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",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"check": "biome check . && bun run format-prompts -- --check && bun run check:types",
|
|
36
36
|
"check:types": "bun run generate-build-info && bun run generate-extension-capabilities && bun run generate-api-spec-index && bun run generate-branding-index && bun run generate-terraform-index && tsgo -p tsconfig.json --noEmit",
|
|
37
37
|
"lint": "biome lint .",
|
|
38
|
+
"check:bundle": "bun scripts/check-bundle.ts",
|
|
38
39
|
"test": "bun run generate-build-info && bun run generate-extension-capabilities && bun run generate-api-spec-index && bun run generate-console-catalog && bun run generate-console-field-metadata && bun test --max-concurrency 4",
|
|
39
40
|
"fix": "biome check --write --unsafe . && bun run format-prompts && bun run generate-docs-index && bun run generate-api-spec-index && bun run generate-build-info",
|
|
40
41
|
"fmt": "biome format --write . && bun run format-prompts",
|
|
@@ -55,13 +56,13 @@
|
|
|
55
56
|
"dependencies": {
|
|
56
57
|
"@agentclientprotocol/sdk": "0.16.1",
|
|
57
58
|
"@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.
|
|
59
|
+
"@f5-sales-demo/xcsh-stats": "19.61.1",
|
|
60
|
+
"@f5-sales-demo/pi-agent-core": "19.61.1",
|
|
61
|
+
"@f5-sales-demo/pi-ai": "19.61.1",
|
|
62
|
+
"@f5-sales-demo/pi-natives": "19.61.1",
|
|
63
|
+
"@f5-sales-demo/pi-resource-management": "19.61.1",
|
|
64
|
+
"@f5-sales-demo/pi-tui": "19.61.1",
|
|
65
|
+
"@f5-sales-demo/pi-utils": "19.61.1",
|
|
65
66
|
"@sinclair/typebox": "^0.34",
|
|
66
67
|
"@xterm/headless": "^6.0",
|
|
67
68
|
"ajv": "^8.20",
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version helpers for selecting the api-specs-enriched source.
|
|
3
|
+
*
|
|
4
|
+
* Kept in a dedicated module (no side effects) so the freshness logic is unit-testable
|
|
5
|
+
* without importing generate-api-spec-index.ts, which runs the generator on load.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Normalize a release tag (`v2.1.167`) or bare version (`2.1.167`) to a bare version. */
|
|
9
|
+
export function normalizeSpecsTag(tag: string): string {
|
|
10
|
+
return tag.trim().replace(/^v/, "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Whether a local api-specs-enriched checkout matches the latest release tag.
|
|
15
|
+
* A stale checkout must NOT be used, or local/dev builds silently pin to old specs.
|
|
16
|
+
*/
|
|
17
|
+
export function isLocalSpecsCurrent(localVersion: string | undefined, latestTag: string): boolean {
|
|
18
|
+
if (!localVersion) return false;
|
|
19
|
+
return normalizeSpecsTag(localVersion) === normalizeSpecsTag(latestTag);
|
|
20
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Bundle guard: ensure the CLI entrypoint still bundles.
|
|
4
|
+
*
|
|
5
|
+
* Reproduces the `bun build --compile` module-graph analysis in ~1s so a
|
|
6
|
+
* `require()` into a top-level-await subgraph (or other bundle-breaking graph
|
|
7
|
+
* change) fails fast in a required CI gate — not only in the slower, optional
|
|
8
|
+
* install-methods build. See PRs #1888 → #1892 for the regression this guards.
|
|
9
|
+
*
|
|
10
|
+
* Runs as a standalone `bun` process (not under `bun test`, whose bundler
|
|
11
|
+
* resolver cannot resolve some lazily-imported specifiers). Exits non-zero on
|
|
12
|
+
* any bundle error.
|
|
13
|
+
*/
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
|
|
16
|
+
const cliEntry = path.resolve(import.meta.dir, "../src/cli.ts");
|
|
17
|
+
|
|
18
|
+
const result = await Bun.build({
|
|
19
|
+
entrypoints: [cliEntry],
|
|
20
|
+
target: "bun",
|
|
21
|
+
define: { PI_COMPILED: "true" },
|
|
22
|
+
external: ["mupdf"],
|
|
23
|
+
throw: false,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if (!result.success) {
|
|
27
|
+
console.error("Bundle check FAILED — the CLI entrypoint does not bundle:\n");
|
|
28
|
+
for (const log of result.logs) console.error(String(log));
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log("Bundle check passed — CLI entrypoint bundles cleanly.");
|
|
@@ -4,6 +4,7 @@ import * as fs from "node:fs";
|
|
|
4
4
|
import * as os from "node:os";
|
|
5
5
|
import * as path from "node:path";
|
|
6
6
|
import { $ } from "bun";
|
|
7
|
+
import { isLocalSpecsCurrent } from "./api-specs-version";
|
|
7
8
|
|
|
8
9
|
interface SpecPathOperation {
|
|
9
10
|
operationId?: string;
|
|
@@ -183,15 +184,42 @@ async function downloadFromRelease(): Promise<string> {
|
|
|
183
184
|
return extractDir;
|
|
184
185
|
}
|
|
185
186
|
|
|
187
|
+
function readLocalSpecsVersion(specsDir: string): string | undefined {
|
|
188
|
+
try {
|
|
189
|
+
const index = JSON.parse(fs.readFileSync(path.join(specsDir, "index.json"), "utf-8")) as { version?: string };
|
|
190
|
+
return index.version;
|
|
191
|
+
} catch {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
186
196
|
async function findSpecsDir(): Promise<string> {
|
|
187
197
|
const envDir = process.env.API_SPECS_DIR;
|
|
188
198
|
if (envDir && fs.existsSync(envDir)) {
|
|
199
|
+
// Explicit override — the caller is responsible for its freshness.
|
|
189
200
|
return envDir;
|
|
190
201
|
}
|
|
191
202
|
|
|
192
203
|
const localCheckout = path.resolve(import.meta.dir, "../../../../api-specs-enriched/docs/specifications/api");
|
|
193
204
|
if (fs.existsSync(localCheckout)) {
|
|
194
|
-
|
|
205
|
+
// Only build from the local checkout when it matches the latest release, so a
|
|
206
|
+
// stale checkout cannot silently pin the build to old specs. If GitHub is
|
|
207
|
+
// unreachable (offline dev), fall back to the local checkout with a warning.
|
|
208
|
+
try {
|
|
209
|
+
const latestTag = await resolveLatestTag();
|
|
210
|
+
const localVersion = readLocalSpecsVersion(localCheckout);
|
|
211
|
+
if (isLocalSpecsCurrent(localVersion, latestTag)) {
|
|
212
|
+
return localCheckout;
|
|
213
|
+
}
|
|
214
|
+
console.warn(
|
|
215
|
+
`Local api-specs-enriched checkout is stale (local ${localVersion ?? "unknown"} != latest ${latestTag}); building against the latest release instead.`,
|
|
216
|
+
);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
console.warn(
|
|
219
|
+
`Could not verify the latest api-specs version (${err instanceof Error ? err.message : err}); using the local checkout.`,
|
|
220
|
+
);
|
|
221
|
+
return localCheckout;
|
|
222
|
+
}
|
|
195
223
|
}
|
|
196
224
|
|
|
197
225
|
return downloadFromRelease();
|
|
@@ -14,12 +14,16 @@ import {
|
|
|
14
14
|
import { CONSOLE_ROUTES } from "./console-routes.generated";
|
|
15
15
|
import type { BridgeServer } from "./extension-bridge";
|
|
16
16
|
import { interpretPageState } from "./page-state-interpreter";
|
|
17
|
+
import { chatSpans } from "./ttft-spans";
|
|
17
18
|
|
|
18
19
|
interface ActiveChat {
|
|
19
20
|
id: string;
|
|
20
21
|
seq: number;
|
|
21
22
|
terminalSent: boolean;
|
|
22
23
|
unsubscribe: () => void;
|
|
24
|
+
entryAt: number;
|
|
25
|
+
promptAt: number | null;
|
|
26
|
+
spanEmitted: boolean;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
export class ChatHandler {
|
|
@@ -61,7 +65,15 @@ export class ChatHandler {
|
|
|
61
65
|
this.#activeHistoryHint = req.history_hint;
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
const chat: ActiveChat = {
|
|
68
|
+
const chat: ActiveChat = {
|
|
69
|
+
id,
|
|
70
|
+
seq: 0,
|
|
71
|
+
terminalSent: false,
|
|
72
|
+
unsubscribe: () => {},
|
|
73
|
+
entryAt: Date.now(),
|
|
74
|
+
promptAt: null,
|
|
75
|
+
spanEmitted: false,
|
|
76
|
+
};
|
|
65
77
|
this.#activeChats.set(id, chat);
|
|
66
78
|
|
|
67
79
|
const unsubscribe = this.#session.subscribe((event: AgentSessionEvent) => {
|
|
@@ -72,6 +84,7 @@ export class ChatHandler {
|
|
|
72
84
|
const prompt = composeChatPrompt(req.text, req.context, req.mode);
|
|
73
85
|
|
|
74
86
|
try {
|
|
87
|
+
chat.promptAt = Date.now();
|
|
75
88
|
await this.#session.prompt(prompt, { expandPromptTemplates: false, synthetic: false });
|
|
76
89
|
} catch (err: unknown) {
|
|
77
90
|
const message = err instanceof Error ? err.message : "unknown error";
|
|
@@ -121,6 +134,15 @@ export class ChatHandler {
|
|
|
121
134
|
seq: chat.seq++,
|
|
122
135
|
delta: ame.delta,
|
|
123
136
|
} satisfies ChatDelta);
|
|
137
|
+
// TTFT Phase 2: first token out — emit the chat-segment spans once, keyed by
|
|
138
|
+
// the c- turn id. chat.promptAt is set (prompt() was awaited before any event);
|
|
139
|
+
// guard against re-emit on later deltas.
|
|
140
|
+
if (!chat.spanEmitted && chat.promptAt !== null) {
|
|
141
|
+
chat.spanEmitted = true;
|
|
142
|
+
for (const s of chatSpans(chat.id, chat.entryAt, chat.promptAt, Date.now())) {
|
|
143
|
+
this.#server.send(s);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
124
146
|
} else if (ame.type === "error") {
|
|
125
147
|
const errorMsg = ame.error?.errorMessage ?? "LLM error";
|
|
126
148
|
this.#sendTerminal(chat, { type: "chat_error", id: chat.id, error: errorMsg });
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure TTFT span-frame builders for Phase 2. The worker emits these over the bridge
|
|
3
|
+
* WS; the extension records them (proc:'xcsh') and summarizeTtft stitches them into
|
|
4
|
+
* the init->first-token timeline. Chrome-free, so they unit-test in isolation.
|
|
5
|
+
*/
|
|
6
|
+
export interface SpanFrame {
|
|
7
|
+
type: "span";
|
|
8
|
+
stage: string;
|
|
9
|
+
ms: number;
|
|
10
|
+
id?: string;
|
|
11
|
+
sid?: string;
|
|
12
|
+
cold?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const clamp = (ms: number): number => (ms > 0 ? ms : 0);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Decompose one chat turn's route->first-token into two disjoint spans that sum to
|
|
19
|
+
* (firstDeltaAt - entryAt): provider_ttft (prompt issued -> first token) and
|
|
20
|
+
* chat_handler (the xcsh routing/compose/emit overhead = entry -> prompt issued).
|
|
21
|
+
*/
|
|
22
|
+
export function chatSpans(id: string, entryAt: number, promptAt: number, firstDeltaAt: number): SpanFrame[] {
|
|
23
|
+
const providerMs = clamp(firstDeltaAt - promptAt);
|
|
24
|
+
const handlerMs = clamp(firstDeltaAt - entryAt - providerMs);
|
|
25
|
+
return [
|
|
26
|
+
{ type: "span", stage: "provider_ttft", ms: providerMs, id },
|
|
27
|
+
{ type: "span", stage: "chat_handler", ms: handlerMs, id },
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Build the per-session cold-start spans, tagged with the session id + authoritative cold. */
|
|
32
|
+
export function coldStartSpans(
|
|
33
|
+
sid: string,
|
|
34
|
+
cold: boolean,
|
|
35
|
+
managerProvisionMs: number,
|
|
36
|
+
workerBootMs: number,
|
|
37
|
+
): SpanFrame[] {
|
|
38
|
+
return [
|
|
39
|
+
{ type: "span", stage: "manager_provision", ms: clamp(managerProvisionMs), sid, cold },
|
|
40
|
+
{ type: "span", stage: "worker_boot", ms: clamp(workerBootMs), sid, cold },
|
|
41
|
+
];
|
|
42
|
+
}
|
package/src/cli/chrome-cli.ts
CHANGED
|
@@ -7,14 +7,30 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
10
11
|
import * as path from "node:path";
|
|
11
12
|
import { acquirePage, type BrowserProviderStatus, CdpBrowserProvider } from "../browser";
|
|
12
13
|
import { PORT_RANGE_END, PORT_RANGE_START, resolveForcedPort } from "../browser/extension-bridge";
|
|
13
14
|
import { installNativeHost } from "../services/native-host-install";
|
|
14
15
|
|
|
16
|
+
/** Ask a running manager to step down (control-socket `shutdown` frame). Resolves
|
|
17
|
+
* true if a manager answered the socket, false if none was running. Best-effort. */
|
|
18
|
+
async function requestManagerShutdown(reason: "updated"): Promise<boolean> {
|
|
19
|
+
const sock = process.env.XCSH_MANAGER_SOCK ?? path.join(homedir(), ".xcsh", "manager.sock");
|
|
20
|
+
try {
|
|
21
|
+
const c = await Bun.connect({ unix: sock, socket: { data() {} } });
|
|
22
|
+
c.write(`${JSON.stringify({ type: "shutdown", reason })}\n`);
|
|
23
|
+
await Bun.sleep(50);
|
|
24
|
+
c.end();
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false; // no manager listening
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
15
31
|
type Settings = { get(key: string): unknown };
|
|
16
32
|
|
|
17
|
-
export type ChromeAction = "status" | "relaunch" | "setup" | "install-host";
|
|
33
|
+
export type ChromeAction = "status" | "relaunch" | "setup" | "install-host" | "recycle";
|
|
18
34
|
|
|
19
35
|
export const EXTENSION_ID = "klajkjdoehjidngligegnpknogmjjhkc";
|
|
20
36
|
|
|
@@ -116,6 +132,18 @@ export async function runChromeCommand(action: ChromeAction, settings: Settings)
|
|
|
116
132
|
const manifestPath = installNativeHost({ launchCommand, extensionIds: [EXTENSION_ID] });
|
|
117
133
|
return `Native-messaging host manifest written to:\n ${manifestPath}`;
|
|
118
134
|
}
|
|
135
|
+
if (action === "recycle") {
|
|
136
|
+
// Proactive post-upgrade recycle (#1874 Task 7): refresh the wrapper to the
|
|
137
|
+
// version-stable launcher, then ask any running (old) manager to step down so
|
|
138
|
+
// the new version takes effect NOW instead of on the next Chrome launch. The
|
|
139
|
+
// successor re-adopts live workers (zero-downtime). Both best-effort.
|
|
140
|
+
const launchCommand = nativeHostLaunchCommand();
|
|
141
|
+
installNativeHost({ launchCommand, extensionIds: [EXTENSION_ID] });
|
|
142
|
+
const stepped = await requestManagerShutdown("updated");
|
|
143
|
+
return stepped
|
|
144
|
+
? "Refreshed native-host wrapper; asked the running manager to recycle to this version."
|
|
145
|
+
: "Refreshed native-host wrapper; no manager was running (it will start fresh on next use).";
|
|
146
|
+
}
|
|
119
147
|
// relaunch: self-consented rung 3 — force allowRelaunch regardless of the setting.
|
|
120
148
|
const { mode } = await acquirePage({ settings, allowRelaunch: true });
|
|
121
149
|
return `Chrome ready (${mode}). Your real, logged-in session is now debuggable for xcsh.`;
|
package/src/cli/update-cli.ts
CHANGED
|
@@ -315,6 +315,10 @@ function updateViaBrew(targetPath: string, expectedVersion: string): void {
|
|
|
315
315
|
console.log(chalk.yellow(`To update to ${expectedVersion}, run:`));
|
|
316
316
|
console.log(chalk.cyan(` brew upgrade ${APP_NAME}`));
|
|
317
317
|
console.log(chalk.dim("\nThis ensures the update goes through your organization's Homebrew tap."));
|
|
318
|
+
// #1874 Task 7: brew upgrade runs out-of-band, so we can't recycle automatically.
|
|
319
|
+
// Nudge the one command that applies it to the Chrome extension immediately.
|
|
320
|
+
console.log(chalk.dim(`Then run ${APP_NAME} chrome recycle to apply it to the extension now`));
|
|
321
|
+
console.log(chalk.dim("(otherwise it takes effect the next time you open Chrome)."));
|
|
318
322
|
}
|
|
319
323
|
|
|
320
324
|
/**
|
|
@@ -413,6 +417,18 @@ export async function runUpdateCommand(opts: { force: boolean; check: boolean })
|
|
|
413
417
|
await updateViaBinaryAt(target.path, release.version);
|
|
414
418
|
break;
|
|
415
419
|
}
|
|
420
|
+
|
|
421
|
+
// #1874 Task 7: the binary just changed under a possibly-running OLD manager.
|
|
422
|
+
// Proactively recycle so the new version takes effect now instead of on the
|
|
423
|
+
// next Chrome launch — refresh the native-host wrapper + step the old manager
|
|
424
|
+
// down (its successor re-adopts live workers, zero-downtime). The just-updated
|
|
425
|
+
// `xcsh` on PATH is the new version. Best-effort, non-blocking; passive
|
|
426
|
+
// supersede covers it if this is skipped.
|
|
427
|
+
try {
|
|
428
|
+
Bun.spawn(["xcsh", "chrome", "recycle"], { stdout: "ignore", stderr: "ignore" });
|
|
429
|
+
} catch {
|
|
430
|
+
/* recycle is a convenience — never fail an update on it */
|
|
431
|
+
}
|
|
416
432
|
} catch (err) {
|
|
417
433
|
console.error(chalk.red(`Update failed: ${err}`));
|
|
418
434
|
process.exit(1);
|
package/src/commands/chrome.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { Args, Command } from "@f5-sales-demo/pi-utils/cli";
|
|
|
5
5
|
import { type ChromeAction, runChromeCommand } from "../cli/chrome-cli";
|
|
6
6
|
import { Settings, settings } from "../config/settings";
|
|
7
7
|
|
|
8
|
-
const ACTIONS: ChromeAction[] = ["status", "relaunch", "setup", "install-host"];
|
|
8
|
+
const ACTIONS: ChromeAction[] = ["status", "relaunch", "setup", "install-host", "recycle"];
|
|
9
9
|
|
|
10
10
|
export default class Chrome extends Command {
|
|
11
11
|
static description = "Inspect or arrange the Chrome session xcsh drives for console automation";
|
package/src/commands/manager.ts
CHANGED
|
@@ -54,6 +54,66 @@ function isPortFree(port: number): boolean {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** The PID listening on a loopback bridge port, or 0 if unknown (best-effort via
|
|
58
|
+
* lsof). Used only to give a re-adopted worker a reapable pid; 0 means "manage by
|
|
59
|
+
* socket liveness, never signal" (see killPid's pid<=0 guard). */
|
|
60
|
+
function pidListeningOn(port: number): number {
|
|
61
|
+
try {
|
|
62
|
+
const out = Bun.spawnSync(["lsof", "-t", `-iTCP:${port}`, "-sTCP:LISTEN"])
|
|
63
|
+
.stdout.toString()
|
|
64
|
+
.trim()
|
|
65
|
+
.split("\n")[0];
|
|
66
|
+
const pid = Number(out);
|
|
67
|
+
return Number.isInteger(pid) && pid > 0 ? pid : 0;
|
|
68
|
+
} catch {
|
|
69
|
+
return 0; // lsof unavailable — leave unknown
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Complete the extension `hello` handshake against a bridge port (with the
|
|
74
|
+
* origin header the bridge requires), resolving the `hello_ack` frame or null.
|
|
75
|
+
* EXTENSION_ID is lazy-required so it stays off the manager's cold-start path. */
|
|
76
|
+
function bridgeHello(port: number, timeoutMs = 400): Promise<Record<string, unknown> | null> {
|
|
77
|
+
return new Promise(resolve => {
|
|
78
|
+
let done = false;
|
|
79
|
+
const finish = (v: Record<string, unknown> | null) => {
|
|
80
|
+
if (done) return;
|
|
81
|
+
done = true;
|
|
82
|
+
resolve(v);
|
|
83
|
+
};
|
|
84
|
+
let ws: WebSocket;
|
|
85
|
+
try {
|
|
86
|
+
const { EXTENSION_ID } = require("../cli/chrome-cli");
|
|
87
|
+
ws = new WebSocket(`ws://127.0.0.1:${port}`, {
|
|
88
|
+
headers: { Origin: `chrome-extension://${EXTENSION_ID}` },
|
|
89
|
+
} as unknown as string[]);
|
|
90
|
+
} catch {
|
|
91
|
+
return finish(null);
|
|
92
|
+
}
|
|
93
|
+
const close = () => {
|
|
94
|
+
try {
|
|
95
|
+
ws.close();
|
|
96
|
+
} catch {
|
|
97
|
+
/* already closing */
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
ws.onopen = () => ws.send(JSON.stringify({ type: "hello" }));
|
|
101
|
+
ws.onmessage = e => {
|
|
102
|
+
try {
|
|
103
|
+
finish(JSON.parse(String(e.data)) as Record<string, unknown>);
|
|
104
|
+
} catch {
|
|
105
|
+
finish(null);
|
|
106
|
+
}
|
|
107
|
+
close();
|
|
108
|
+
};
|
|
109
|
+
ws.onerror = () => finish(null);
|
|
110
|
+
setTimeout(() => {
|
|
111
|
+
close();
|
|
112
|
+
finish(null);
|
|
113
|
+
}, timeoutMs);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
57
117
|
/**
|
|
58
118
|
* The argv (AFTER `process.execPath`) to re-run THIS binary in `mode`.
|
|
59
119
|
*
|
|
@@ -195,12 +255,46 @@ export default class Manager extends Command {
|
|
|
195
255
|
for (let i = 0; i < n; i++) spawnSpare();
|
|
196
256
|
};
|
|
197
257
|
|
|
258
|
+
/** Zero-downtime handoff (#1874 Task 6): on startup, discover bridge workers a
|
|
259
|
+
* PRIOR (superseded) manager left running and re-register them, so a tab's
|
|
260
|
+
* session survives the manager swap. Probes the whole range in parallel; a
|
|
261
|
+
* bridge answering with a real per-tab sessionId (not the "spare" sentinel) and
|
|
262
|
+
* a tenant+env is re-adopted. Spares/unknowns are ignored (the pool refills
|
|
263
|
+
* them). Best-effort — never throws. */
|
|
264
|
+
const readoptWorkers = async (): Promise<void> => {
|
|
265
|
+
const found = await Promise.all(range.map(async port => ({ port, ack: await bridgeHello(port) })));
|
|
266
|
+
for (const { port, ack } of found) {
|
|
267
|
+
if (!ack) continue;
|
|
268
|
+
const sid = ack.sessionId;
|
|
269
|
+
const tenant = ack.tenant;
|
|
270
|
+
const env = ack.env;
|
|
271
|
+
if (typeof sid !== "string" || sid === "spare" || typeof tenant !== "string" || typeof env !== "string") {
|
|
272
|
+
continue; // spare sentinel or tenant-less bridge — not a re-adoptable session
|
|
273
|
+
}
|
|
274
|
+
if (reg.has(sid)) continue;
|
|
275
|
+
reg.set(sid, {
|
|
276
|
+
sessionId: sid,
|
|
277
|
+
tenant: `${tenant}|${env}`,
|
|
278
|
+
port,
|
|
279
|
+
pid: pidListeningOn(port),
|
|
280
|
+
lastSeen: Date.now(),
|
|
281
|
+
});
|
|
282
|
+
console.error(`[xcsh manager] re-adopted worker ${sid} (${tenant}|${env}) on port ${port}`);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
198
286
|
/** Adopt a warm spare for a provision (bind over IPC). Returns false if none available. */
|
|
199
|
-
const adoptSpare = (msg: { sessionId: string; tenant: string }): boolean => {
|
|
287
|
+
const adoptSpare = (msg: { sessionId: string; tenant: string }, managerProvisionMs: number): boolean => {
|
|
200
288
|
const rec = pool.shift();
|
|
201
289
|
if (!rec) return false;
|
|
202
290
|
// `send` exists because the spare was spawned with an `ipc` handler.
|
|
203
|
-
(rec.proc as { send(m: unknown): void }).send({
|
|
291
|
+
(rec.proc as { send(m: unknown): void }).send({
|
|
292
|
+
type: "bind",
|
|
293
|
+
sessionId: msg.sessionId,
|
|
294
|
+
tenant: msg.tenant,
|
|
295
|
+
provisionMs: managerProvisionMs, // TTFT Phase 2: relayed manager_provision (warm)
|
|
296
|
+
cold: false, // authoritative: warm adopt
|
|
297
|
+
});
|
|
204
298
|
reg.set(msg.sessionId, {
|
|
205
299
|
sessionId: msg.sessionId,
|
|
206
300
|
tenant: msg.tenant,
|
|
@@ -219,18 +313,8 @@ export default class Manager extends Command {
|
|
|
219
313
|
return true;
|
|
220
314
|
};
|
|
221
315
|
|
|
222
|
-
const reap = (sessionId: string): void => {
|
|
223
|
-
const w = reg.get(sessionId);
|
|
224
|
-
if (!w) return;
|
|
225
|
-
try {
|
|
226
|
-
process.kill(w.pid);
|
|
227
|
-
} catch {
|
|
228
|
-
/* already gone */
|
|
229
|
-
}
|
|
230
|
-
reg.delete(sessionId);
|
|
231
|
-
};
|
|
232
|
-
|
|
233
316
|
const killPid = (pid: number): void => {
|
|
317
|
+
if (pid <= 0) return; // 0/negative = unknown (re-adopted worker) — NEVER signal (kill(0) hits the group)
|
|
234
318
|
try {
|
|
235
319
|
process.kill(pid); // SIGTERM — spares exit at once; bound workers self-drain (worker.ts)
|
|
236
320
|
} catch {
|
|
@@ -238,21 +322,35 @@ export default class Manager extends Command {
|
|
|
238
322
|
}
|
|
239
323
|
};
|
|
240
324
|
|
|
325
|
+
const reap = (sessionId: string): void => {
|
|
326
|
+
const w = reg.get(sessionId);
|
|
327
|
+
if (!w) return;
|
|
328
|
+
killPid(w.pid);
|
|
329
|
+
reg.delete(sessionId);
|
|
330
|
+
};
|
|
331
|
+
|
|
241
332
|
/** Graceful teardown (#1874): stop accepting, terminate the (session-less)
|
|
242
|
-
* spare pool at once,
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
* successor
|
|
333
|
+
* spare pool at once, drop the socket + liveness record, and exit. Idempotent.
|
|
334
|
+
*
|
|
335
|
+
* Bound workers depend on the reason: on a HANDOFF (superseded/updated — a
|
|
336
|
+
* successor manager is about to bind and re-adopt them), we LEAVE them running
|
|
337
|
+
* so tab sessions survive the swap (zero-downtime, Task 6). Otherwise (manual
|
|
338
|
+
* operator SIGTERM — no successor) we SIGTERM them so they drain + exit rather
|
|
339
|
+
* than leak. The manager frees the socket immediately either way. */
|
|
246
340
|
const gracefulShutdown = (reason: string): void => {
|
|
247
341
|
if (shuttingDown) return;
|
|
248
342
|
shuttingDown = true;
|
|
249
343
|
accepting = false;
|
|
344
|
+
const handoff = reason === "superseded" || reason === "updated";
|
|
250
345
|
console.error(
|
|
251
|
-
`[xcsh manager] graceful shutdown (${reason}); reaping ${pool.length} spare(s) +
|
|
346
|
+
`[xcsh manager] graceful shutdown (${reason}); reaping ${pool.length} spare(s)` +
|
|
347
|
+
(handoff ? `, leaving ${reg.size} worker(s) for re-adoption` : ` + ${reg.size} worker(s)`),
|
|
252
348
|
);
|
|
253
349
|
for (const s of pool.splice(0)) killPid(s.pid);
|
|
254
|
-
|
|
255
|
-
|
|
350
|
+
if (!handoff) {
|
|
351
|
+
for (const w of reg.values()) killPid(w.pid);
|
|
352
|
+
reg.clear();
|
|
353
|
+
}
|
|
256
354
|
removeManagerState(sockPath);
|
|
257
355
|
try {
|
|
258
356
|
fs.rmSync(sockPath, { force: true });
|
|
@@ -262,7 +360,7 @@ export default class Manager extends Command {
|
|
|
262
360
|
process.exit(0);
|
|
263
361
|
};
|
|
264
362
|
|
|
265
|
-
const spawnWorker = (msg: { sessionId: string; tenant: string }): void => {
|
|
363
|
+
const spawnWorker = (msg: { sessionId: string; tenant: string }, managerProvisionMs: number): void => {
|
|
266
364
|
// Registry-dedupe (pickPort) over only the ports free at the OS level.
|
|
267
365
|
const port = pickPort(reg, range.filter(isPortFree));
|
|
268
366
|
if (port === null) {
|
|
@@ -282,6 +380,12 @@ export default class Manager extends Command {
|
|
|
282
380
|
// spawned tenant key is authoritative (undefined removes the var in Bun).
|
|
283
381
|
XCSH_API_URL: undefined,
|
|
284
382
|
XCSH_API_TOKEN: undefined,
|
|
383
|
+
// TTFT Phase 2: relay cold-start timing to the worker (only it has a WS to
|
|
384
|
+
// the extension). Wall-clock spawn instant + manager_provision ms; COLD=1
|
|
385
|
+
// marks the authoritative cold spawn.
|
|
386
|
+
XCSH_TTFT_SPAWN_AT: String(Date.now()),
|
|
387
|
+
XCSH_TTFT_PROVISION_MS: String(managerProvisionMs),
|
|
388
|
+
XCSH_TTFT_COLD: "1",
|
|
285
389
|
},
|
|
286
390
|
stdout: "ignore",
|
|
287
391
|
stderr: "ignore",
|
|
@@ -325,8 +429,10 @@ export default class Manager extends Command {
|
|
|
325
429
|
}
|
|
326
430
|
return;
|
|
327
431
|
}
|
|
432
|
+
const provisionReceivedAt = Date.now(); // TTFT Phase 2: start of manager_provision
|
|
328
433
|
if (needsProvision(reg, msg.sessionId)) {
|
|
329
|
-
|
|
434
|
+
const managerProvisionMs = Date.now() - provisionReceivedAt;
|
|
435
|
+
if (!adoptSpare(msg, managerProvisionMs)) spawnWorker(msg, managerProvisionMs); // adopt a warm spare, else cold-spawn (fallback)
|
|
330
436
|
}
|
|
331
437
|
const w = reg.get(msg.sessionId);
|
|
332
438
|
if (w) w.lastSeen = Date.now(); // touch on every provision (keep-alive)
|
|
@@ -409,6 +515,11 @@ export default class Manager extends Command {
|
|
|
409
515
|
process.on("SIGTERM", () => gracefulShutdown("manual"));
|
|
410
516
|
process.on("SIGINT", () => gracefulShutdown("manual"));
|
|
411
517
|
|
|
518
|
+
// Zero-downtime handoff: re-adopt any bound workers a superseded manager left
|
|
519
|
+
// running BEFORE filling the pool (so their ports aren't mistaken for free).
|
|
520
|
+
// Awaited but bounded (~parallel 400ms); the socket already accepts connections.
|
|
521
|
+
await readoptWorkers();
|
|
522
|
+
|
|
412
523
|
// Pre-warm the spare pool so provisions can adopt instead of cold-spawn.
|
|
413
524
|
if (poolTarget > 0) maintainPool();
|
|
414
525
|
|
package/src/commands/worker.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { ChatHandler } from "../browser/chat-handler";
|
|
|
19
19
|
import { startBridgeServer } from "../browser/extension-bridge";
|
|
20
20
|
import { createExtensionBridgeTools, EXTENSION_AGENT_TOOL_NAMES } from "../browser/extension-bridge-tools";
|
|
21
21
|
import { setSharedBridgeServer } from "../browser/provider";
|
|
22
|
+
import { coldStartSpans, type SpanFrame } from "../browser/ttft-spans";
|
|
22
23
|
import { initializeWithSettings } from "../discovery";
|
|
23
24
|
import { createAgentSession } from "../sdk";
|
|
24
25
|
import { activateTenantContext } from "../services/session-context-binding";
|
|
@@ -109,6 +110,12 @@ export default class Worker extends Command {
|
|
|
109
110
|
// Spans only accumulate while recording; nothing prints unless PI_TIMING is set,
|
|
110
111
|
// and each logger.time() returns its wrapped value unchanged — so a normal
|
|
111
112
|
// `xcsh worker` run is behaviorally identical to before.
|
|
113
|
+
// TTFT Phase 2: the manager stamps XCSH_TTFT_SPAWN_AT at Bun.spawn for a cold
|
|
114
|
+
// spawn; worker_boot(cold) = bridge-listening instant - spawn instant (captures
|
|
115
|
+
// fork + runtime init, which logger.startTiming below misses).
|
|
116
|
+
const spawnAtEnv = Number(process.env.XCSH_TTFT_SPAWN_AT);
|
|
117
|
+
const coldSpawn = process.env.XCSH_TTFT_COLD === "1";
|
|
118
|
+
const managerProvisionMsEnv = Number(process.env.XCSH_TTFT_PROVISION_MS);
|
|
112
119
|
logger.startTiming();
|
|
113
120
|
|
|
114
121
|
process.env.XCSH_BROWSER_PROVIDER = "extension";
|
|
@@ -142,15 +149,50 @@ export default class Worker extends Command {
|
|
|
142
149
|
bridge.setSessionInfo(sessionInfoForWorker);
|
|
143
150
|
ContextService.onContextChange(() => bridge.broadcastTenantChanged());
|
|
144
151
|
|
|
152
|
+
// TTFT Phase 2: buffer the per-session cold-start spans and flush them once a
|
|
153
|
+
// client is actually connected. bridge.send() silently no-ops with no client, so
|
|
154
|
+
// the flush is gated on `clientConnected` — a cold-boot flush before the extension
|
|
155
|
+
// connects would otherwise burn the once-latch and lose the spans. Cold path is
|
|
156
|
+
// populated now (env); warm path by the {bind} handler below.
|
|
157
|
+
let coldStartBuffer: SpanFrame[] = [];
|
|
158
|
+
let coldStartSent = false;
|
|
159
|
+
let clientConnected = false;
|
|
160
|
+
const flushColdStart = (): void => {
|
|
161
|
+
if (coldStartSent || !clientConnected || coldStartBuffer.length === 0) return;
|
|
162
|
+
for (const s of coldStartBuffer) bridge.send(s);
|
|
163
|
+
coldStartSent = true;
|
|
164
|
+
};
|
|
165
|
+
// onConnected (raw WS open) is the deliberate flush trigger: no hello hook is exposed
|
|
166
|
+
// today, and the bridge's origin check already gates opens to the extension.
|
|
167
|
+
bridge.onConnected(() => {
|
|
168
|
+
clientConnected = true;
|
|
169
|
+
flushColdStart();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (coldSpawn && process.env.XCSH_SESSION_ID && Number.isFinite(spawnAtEnv)) {
|
|
173
|
+
const workerBootMs = Date.now() - spawnAtEnv; // spawn -> bridge listening
|
|
174
|
+
const mgrMs = Number.isFinite(managerProvisionMsEnv) ? managerProvisionMsEnv : 0;
|
|
175
|
+
coldStartBuffer = coldStartSpans(process.env.XCSH_SESSION_ID, true, mgrMs, workerBootMs);
|
|
176
|
+
// No flush here: at cold boot the extension has not connected yet; onConnected flushes.
|
|
177
|
+
}
|
|
178
|
+
|
|
145
179
|
// Pre-warm pool late-bind: the manager (our parent) sends {bind} over Bun IPC to adopt
|
|
146
180
|
// this spare for a tab. Apply the identity, activate the tenant's context LIVE, then
|
|
147
181
|
// re-announce via broadcastTenantChanged (now carrying the real sessionId).
|
|
148
182
|
process.on("message", (raw: unknown) => {
|
|
149
|
-
const m = raw as {
|
|
183
|
+
const m = raw as {
|
|
184
|
+
type?: unknown;
|
|
185
|
+
sessionId?: unknown;
|
|
186
|
+
tenant?: unknown;
|
|
187
|
+
provisionMs?: unknown;
|
|
188
|
+
cold?: unknown;
|
|
189
|
+
};
|
|
150
190
|
if (m?.type !== "bind" || typeof m.sessionId !== "string" || typeof m.tenant !== "string") return;
|
|
151
191
|
// Capture the narrowed values — TS widens `m.*` back to `unknown` inside the async closure.
|
|
152
192
|
const sessionId = m.sessionId;
|
|
153
193
|
const tenant = m.tenant;
|
|
194
|
+
const bindAt = Date.now(); // TTFT Phase 2: start of warm worker_boot (bind -> bound)
|
|
195
|
+
const relayedProvisionMs = typeof m.provisionMs === "number" ? m.provisionMs : 0;
|
|
154
196
|
setWorkerIdentity(sessionId, tenant);
|
|
155
197
|
void (async () => {
|
|
156
198
|
try {
|
|
@@ -162,6 +204,9 @@ export default class Worker extends Command {
|
|
|
162
204
|
// Ack adoption complete (identity applied + context activated + re-announced).
|
|
163
205
|
// The manager ignores it today; the bench uses it to time adoption latency.
|
|
164
206
|
process.send?.({ type: "bound", sessionId });
|
|
207
|
+
// TTFT Phase 2: warm adopt cold-start spans (worker_boot = bind -> bound).
|
|
208
|
+
coldStartBuffer = coldStartSpans(sessionId, false, relayedProvisionMs, Date.now() - bindAt);
|
|
209
|
+
flushColdStart();
|
|
165
210
|
})();
|
|
166
211
|
});
|
|
167
212
|
|
package/src/edit/modes/chunk.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as nodePath from "node:path";
|
|
3
3
|
import type { AgentToolResult } from "@f5-sales-demo/pi-agent-core";
|
|
4
|
+
import { StringEnum } from "@f5-sales-demo/pi-ai";
|
|
4
5
|
import {
|
|
5
6
|
ChunkAnchorStyle,
|
|
6
7
|
ChunkEditOp,
|
|
@@ -12,7 +13,6 @@ import {
|
|
|
12
13
|
type EditOperation as NativeEditOperation,
|
|
13
14
|
} from "@f5-sales-demo/pi-natives";
|
|
14
15
|
import { $envpos } from "@f5-sales-demo/pi-utils";
|
|
15
|
-
import { StringEnum } from "@f5-sales-demo/xcsh";
|
|
16
16
|
import { type Static, Type } from "@sinclair/typebox";
|
|
17
17
|
import type { BunFile } from "bun";
|
|
18
18
|
import { LRUCache } from "lru-cache";
|