@f5-sales-demo/xcsh 19.61.3 → 19.61.5
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/chat-handler.ts +8 -0
- package/src/browser/ttft-spans.ts +11 -0
- package/src/commands/manager-core.ts +26 -2
- package/src/commands/manager-keepalive.ts +117 -0
- package/src/commands/manager.ts +18 -4
- package/src/commands/worker.ts +69 -1
- package/src/config/auto-config.ts +48 -26
- package/src/config/model-registry.ts +13 -0
- package/src/config/settings-schema.ts +9 -1
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/sdk.ts +27 -3
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "19.61.
|
|
4
|
+
"version": "19.61.5",
|
|
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",
|
|
@@ -56,13 +56,13 @@
|
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"@agentclientprotocol/sdk": "0.16.1",
|
|
58
58
|
"@mozilla/readability": "^0.6",
|
|
59
|
-
"@f5-sales-demo/xcsh-stats": "19.61.
|
|
60
|
-
"@f5-sales-demo/pi-agent-core": "19.61.
|
|
61
|
-
"@f5-sales-demo/pi-ai": "19.61.
|
|
62
|
-
"@f5-sales-demo/pi-natives": "19.61.
|
|
63
|
-
"@f5-sales-demo/pi-resource-management": "19.61.
|
|
64
|
-
"@f5-sales-demo/pi-tui": "19.61.
|
|
65
|
-
"@f5-sales-demo/pi-utils": "19.61.
|
|
59
|
+
"@f5-sales-demo/xcsh-stats": "19.61.5",
|
|
60
|
+
"@f5-sales-demo/pi-agent-core": "19.61.5",
|
|
61
|
+
"@f5-sales-demo/pi-ai": "19.61.5",
|
|
62
|
+
"@f5-sales-demo/pi-natives": "19.61.5",
|
|
63
|
+
"@f5-sales-demo/pi-resource-management": "19.61.5",
|
|
64
|
+
"@f5-sales-demo/pi-tui": "19.61.5",
|
|
65
|
+
"@f5-sales-demo/pi-utils": "19.61.5",
|
|
66
66
|
"@sinclair/typebox": "^0.34",
|
|
67
67
|
"@xterm/headless": "^6.0",
|
|
68
68
|
"ajv": "^8.20",
|
|
@@ -31,12 +31,19 @@ export class ChatHandler {
|
|
|
31
31
|
#session: AgentSession;
|
|
32
32
|
#activeChats = new Map<string, ActiveChat>();
|
|
33
33
|
#activeHistoryHint: string | undefined;
|
|
34
|
+
#onTurnStart: (() => void) | undefined;
|
|
34
35
|
|
|
35
36
|
constructor(server: BridgeServer, session: AgentSession) {
|
|
36
37
|
this.#server = server;
|
|
37
38
|
this.#session = session;
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
/** Register a callback fired when a chat turn is accepted (used by the worker's
|
|
42
|
+
* manager keepalive to refresh lastSeen at turn start). */
|
|
43
|
+
onTurnStart(cb: () => void): void {
|
|
44
|
+
this.#onTurnStart = cb;
|
|
45
|
+
}
|
|
46
|
+
|
|
40
47
|
attach(): void {
|
|
41
48
|
this.#server.onMessage(msg => {
|
|
42
49
|
if (isChatRequest(msg)) this.#handleChatRequest(msg as unknown as ChatRequest);
|
|
@@ -75,6 +82,7 @@ export class ChatHandler {
|
|
|
75
82
|
spanEmitted: false,
|
|
76
83
|
};
|
|
77
84
|
this.#activeChats.set(id, chat);
|
|
85
|
+
this.#onTurnStart?.();
|
|
78
86
|
|
|
79
87
|
const unsubscribe = this.#session.subscribe((event: AgentSessionEvent) => {
|
|
80
88
|
this.#handleSessionEvent(chat, event);
|
|
@@ -28,6 +28,17 @@ export function chatSpans(id: string, entryAt: number, promptAt: number, firstDe
|
|
|
28
28
|
];
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Build the session-build span: the heavy `createAgentSession` step (model
|
|
33
|
+
* registry load, discovery, extension/plugin loading, context bootstrap) that
|
|
34
|
+
* runs between `worker_boot` (ends at bridge-listen) and `chat_handler`. Without
|
|
35
|
+
* this stage the cost is silently absorbed into total ttft_ms, hiding plugin/
|
|
36
|
+
* discovery regressions from the bench and the diagnostics panel.
|
|
37
|
+
*/
|
|
38
|
+
export function sessionBuildSpan(sid: string, cold: boolean, ms: number): SpanFrame {
|
|
39
|
+
return { type: "span", stage: "session_build", ms: clamp(ms), sid, cold };
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
/** Build the per-session cold-start spans, tagged with the session id + authoritative cold. */
|
|
32
43
|
export function coldStartSpans(
|
|
33
44
|
sid: string,
|
|
@@ -17,7 +17,9 @@ const SHUTDOWN_REASONS = new Set<string>(["superseded", "updated", "manual"]);
|
|
|
17
17
|
export type ControlMsg =
|
|
18
18
|
| { type: "provision"; sessionId: string; tenant: string }
|
|
19
19
|
| { type: "release"; sessionId: string }
|
|
20
|
-
|
|
20
|
+
// An sid-less status is the legacy no-op sink; an sid-carrying status is a
|
|
21
|
+
// keepalive from an actively-chatting worker (see keepaliveFrame/touchLastSeen).
|
|
22
|
+
| { type: "status"; sessionId?: string }
|
|
21
23
|
| { type: "hello" }
|
|
22
24
|
| { type: "shutdown"; reason: ShutdownReason };
|
|
23
25
|
|
|
@@ -42,7 +44,8 @@ function isNonEmpty(v: unknown): v is string {
|
|
|
42
44
|
export function parseControlMsg(raw: unknown): ControlMsg | null {
|
|
43
45
|
if (!raw || typeof raw !== "object") return null;
|
|
44
46
|
const m = raw as Record<string, unknown>;
|
|
45
|
-
if (m.type === "status")
|
|
47
|
+
if (m.type === "status")
|
|
48
|
+
return isNonEmpty(m.sessionId) ? { type: "status", sessionId: m.sessionId } : { type: "status" };
|
|
46
49
|
if (m.type === "provision" && isNonEmpty(m.sessionId) && isTenant(m.tenant))
|
|
47
50
|
return { type: "provision", sessionId: m.sessionId, tenant: m.tenant };
|
|
48
51
|
if (m.type === "release" && isNonEmpty(m.sessionId)) return { type: "release", sessionId: m.sessionId };
|
|
@@ -115,6 +118,27 @@ export function staleKeys(reg: Registry, now: number, idleMs: number): string[]
|
|
|
115
118
|
return out;
|
|
116
119
|
}
|
|
117
120
|
|
|
121
|
+
/** Refresh a worker's `lastSeen` from a keepalive/status frame; true iff a known
|
|
122
|
+
* session was touched. The manager never sees chat traffic (it flows worker↔
|
|
123
|
+
* bridge↔extension), so an actively-used session would otherwise be idle-reaped
|
|
124
|
+
* mid-conversation. An actively-chatting worker emits `keepaliveFrame` to drive
|
|
125
|
+
* this, keeping its session out of `staleKeys`. */
|
|
126
|
+
export function touchLastSeen(reg: Registry, sessionId: string | undefined, now: number): boolean {
|
|
127
|
+
if (!sessionId) return false;
|
|
128
|
+
const w = reg.get(sessionId);
|
|
129
|
+
if (!w) return false;
|
|
130
|
+
w.lastSeen = now;
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The NDJSON keepalive an actively-chatting worker writes to the manager control
|
|
135
|
+
* socket to refresh its `lastSeen` (consumed by `touchLastSeen`). Null for the
|
|
136
|
+
* unbound `spare` sentinel or an empty id — a spare has no session to keep alive. */
|
|
137
|
+
export function keepaliveFrame(sessionId: string): string | null {
|
|
138
|
+
if (!sessionId || sessionId === "spare") return null;
|
|
139
|
+
return `${JSON.stringify({ type: "status", sessionId })}\n`;
|
|
140
|
+
}
|
|
141
|
+
|
|
118
142
|
/** How many new spares to spawn now to reach `target`, without exceeding the port
|
|
119
143
|
* budget: spares + active workers must fit the discovery range. Never negative. */
|
|
120
144
|
export function sparesToSpawn(
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker→manager keepalive client.
|
|
3
|
+
*
|
|
4
|
+
* The manager idle-reaps a worker whose `lastSeen` is older than `IDLE_MS`, and
|
|
5
|
+
* `lastSeen` is refreshed ONLY when the manager receives a control frame. Chat
|
|
6
|
+
* traffic flows worker↔bridge↔extension and never reaches the manager, so an
|
|
7
|
+
* actively-used-then-briefly-idle session used to be reaped mid-conversation
|
|
8
|
+
* (the "Turn aborted." after a break). This client emits a `status{sessionId}`
|
|
9
|
+
* keepalive over the manager's UNIX control socket while a turn is in flight and
|
|
10
|
+
* at each turn start, so the manager's sweep leaves an in-use session alive.
|
|
11
|
+
*
|
|
12
|
+
* The socket lifecycle (connect/reconnect) is injected as a `Connector` so the
|
|
13
|
+
* scheduling/emit logic is unit-testable without a real socket; the worker wires
|
|
14
|
+
* a `Bun.connect` transport. Best-effort throughout: if the manager socket is
|
|
15
|
+
* down (e.g. mid-supersede) emits are dropped and the next emit reconnects — so
|
|
16
|
+
* after a manager handoff the keepalive re-targets the successor manager, which
|
|
17
|
+
* has already re-adopted this worker.
|
|
18
|
+
*/
|
|
19
|
+
import { keepaliveFrame } from "./manager-core";
|
|
20
|
+
|
|
21
|
+
/** A live connection to the manager control socket. */
|
|
22
|
+
export interface KeepaliveTransport {
|
|
23
|
+
write(data: string): void;
|
|
24
|
+
close(): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Opens a transport; `onClose` MUST be invoked when the connection drops so the
|
|
28
|
+
* keepalive knows to reconnect on its next emit. Returns null if it cannot open. */
|
|
29
|
+
export type Connector = (onClose: () => void) => Promise<KeepaliveTransport | null>;
|
|
30
|
+
|
|
31
|
+
export interface ManagerKeepaliveDeps {
|
|
32
|
+
connect: Connector;
|
|
33
|
+
/** Current worker session id (may transition from "spare" to a real id). */
|
|
34
|
+
sessionId: () => string;
|
|
35
|
+
/** True while a chat turn is in flight. */
|
|
36
|
+
busy: () => boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class ManagerKeepalive {
|
|
40
|
+
#deps: ManagerKeepaliveDeps;
|
|
41
|
+
#transport: KeepaliveTransport | null = null;
|
|
42
|
+
#connecting = false;
|
|
43
|
+
#pending = false; // a keepalive is due once a connection is (re)established
|
|
44
|
+
#stopped = false;
|
|
45
|
+
|
|
46
|
+
constructor(deps: ManagerKeepaliveDeps) {
|
|
47
|
+
this.#deps = deps;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Periodic tick (driven by the worker's interval): emit iff a turn is busy. */
|
|
51
|
+
tick(): void {
|
|
52
|
+
if (this.#deps.busy()) this.#emit();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One-shot emit at the start of each turn — refreshes lastSeen immediately so
|
|
56
|
+
* a session with long think-time between turns is never reaped between them. */
|
|
57
|
+
turnStart(): void {
|
|
58
|
+
this.#emit();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Stop emitting and close any open transport. */
|
|
62
|
+
stop(): void {
|
|
63
|
+
this.#stopped = true;
|
|
64
|
+
this.#pending = false;
|
|
65
|
+
this.#transport?.close();
|
|
66
|
+
this.#transport = null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#emit(): void {
|
|
70
|
+
if (this.#stopped) return;
|
|
71
|
+
const frame = keepaliveFrame(this.#deps.sessionId());
|
|
72
|
+
if (!frame) return; // spare / unbound → nothing to keep alive (also skips connecting)
|
|
73
|
+
if (this.#transport) {
|
|
74
|
+
try {
|
|
75
|
+
this.#transport.write(frame);
|
|
76
|
+
} catch {
|
|
77
|
+
this.#transport = null; // dead socket → drop and reconnect on next emit
|
|
78
|
+
this.#pending = true;
|
|
79
|
+
void this.#ensureConnected();
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
this.#pending = true;
|
|
84
|
+
void this.#ensureConnected();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async #ensureConnected(): Promise<void> {
|
|
88
|
+
if (this.#stopped || this.#transport || this.#connecting) return;
|
|
89
|
+
this.#connecting = true;
|
|
90
|
+
let t: KeepaliveTransport | null = null;
|
|
91
|
+
try {
|
|
92
|
+
t = await this.#deps.connect(() => {
|
|
93
|
+
this.#transport = null; // socket dropped → next emit reconnects
|
|
94
|
+
});
|
|
95
|
+
} catch {
|
|
96
|
+
t = null;
|
|
97
|
+
}
|
|
98
|
+
this.#connecting = false;
|
|
99
|
+
if (!t) return; // manager unreachable — best-effort, a later emit retries
|
|
100
|
+
if (this.#stopped) {
|
|
101
|
+
t.close();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
this.#transport = t;
|
|
105
|
+
if (this.#pending) {
|
|
106
|
+
this.#pending = false;
|
|
107
|
+
const frame = keepaliveFrame(this.#deps.sessionId());
|
|
108
|
+
if (frame) {
|
|
109
|
+
try {
|
|
110
|
+
this.#transport.write(frame);
|
|
111
|
+
} catch {
|
|
112
|
+
this.#transport = null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
package/src/commands/manager.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* on sessionId, so two tabs of the SAME tenant get two workers.
|
|
12
12
|
* {"type":"release","sessionId":"tab-7"} → kill + forget that session's worker
|
|
13
13
|
* {"type":"status"} → accepted; no-op sink
|
|
14
|
+
* {"type":"status","sessionId":"tab-7"} → keepalive: refresh that worker's
|
|
15
|
+
* lastSeen so an actively-chatting session is not idle-reaped
|
|
14
16
|
*
|
|
15
17
|
* All registry/port/idempotency/idle policy is the pure `manager-core`; this file
|
|
16
18
|
* is the thin I/O shell (socket, spawn, kill, timer) around it. A background sweep
|
|
@@ -25,7 +27,15 @@ import { Command } from "@f5-sales-demo/pi-utils/cli";
|
|
|
25
27
|
import { VERSION } from "@f5-sales-demo/pi-utils/dirs";
|
|
26
28
|
import { portCandidates } from "../browser/extension-bridge";
|
|
27
29
|
import { removeManagerState, writeManagerState } from "../services/manager-state";
|
|
28
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
needsProvision,
|
|
32
|
+
parseControlMsg,
|
|
33
|
+
pickPort,
|
|
34
|
+
type Registry,
|
|
35
|
+
sparesToSpawn,
|
|
36
|
+
staleKeys,
|
|
37
|
+
touchLastSeen,
|
|
38
|
+
} from "./manager-core";
|
|
29
39
|
|
|
30
40
|
/** Reap a worker idle longer than this (ms). */
|
|
31
41
|
const IDLE_MS = 20 * 60_000;
|
|
@@ -434,8 +444,13 @@ export default class Manager extends Command {
|
|
|
434
444
|
const managerProvisionMs = Date.now() - provisionReceivedAt;
|
|
435
445
|
if (!adoptSpare(msg, managerProvisionMs)) spawnWorker(msg, managerProvisionMs); // adopt a warm spare, else cold-spawn (fallback)
|
|
436
446
|
}
|
|
437
|
-
|
|
438
|
-
|
|
447
|
+
touchLastSeen(reg, msg.sessionId, Date.now()); // touch on every provision (keep-alive)
|
|
448
|
+
} else if (msg.type === "status") {
|
|
449
|
+
// Keepalive from an actively-chatting worker (#idle-reap): refresh
|
|
450
|
+
// lastSeen so the idle sweep never reaps a session that is in use.
|
|
451
|
+
// Chat traffic never reaches the manager, so this is the only signal
|
|
452
|
+
// that an otherwise-quiet-to-the-manager worker is still working.
|
|
453
|
+
touchLastSeen(reg, msg.sessionId, Date.now());
|
|
439
454
|
} else if (msg.type === "release") {
|
|
440
455
|
reap(msg.sessionId);
|
|
441
456
|
} else if (msg.type === "shutdown") {
|
|
@@ -452,7 +467,6 @@ export default class Manager extends Command {
|
|
|
452
467
|
/* client hung up mid-handshake — ignore */
|
|
453
468
|
}
|
|
454
469
|
}
|
|
455
|
-
// "status" is validated but currently a no-op sink.
|
|
456
470
|
};
|
|
457
471
|
|
|
458
472
|
// Per-connection NDJSON buffer: a control frame can be split across two TCP
|
package/src/commands/worker.ts
CHANGED
|
@@ -13,18 +13,21 @@
|
|
|
13
13
|
* `XCSH_SESSION_TENANT` so it advertises its assigned tenant even before a context
|
|
14
14
|
* is bound. This lets the extension panel lock onto the right tenant immediately.
|
|
15
15
|
*/
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
16
18
|
import { getProjectDir, getXCSHConfigDir, logger } from "@f5-sales-demo/pi-utils";
|
|
17
19
|
import { Command } from "@f5-sales-demo/pi-utils/cli";
|
|
18
20
|
import { ChatHandler } from "../browser/chat-handler";
|
|
19
21
|
import { startBridgeServer } from "../browser/extension-bridge";
|
|
20
22
|
import { createExtensionBridgeTools, EXTENSION_AGENT_TOOL_NAMES } from "../browser/extension-bridge-tools";
|
|
21
23
|
import { setSharedBridgeServer } from "../browser/provider";
|
|
22
|
-
import { coldStartSpans, type SpanFrame } from "../browser/ttft-spans";
|
|
24
|
+
import { coldStartSpans, type SpanFrame, sessionBuildSpan } from "../browser/ttft-spans";
|
|
23
25
|
import { initializeWithSettings } from "../discovery";
|
|
24
26
|
import { createAgentSession } from "../sdk";
|
|
25
27
|
import { activateTenantContext } from "../services/session-context-binding";
|
|
26
28
|
import { ContextService } from "../services/xcsh-context";
|
|
27
29
|
import { deriveTenantEnv } from "../services/xcsh-env";
|
|
30
|
+
import { type KeepaliveTransport, ManagerKeepalive } from "./manager-keepalive";
|
|
28
31
|
|
|
29
32
|
/** Mutable worker identity. Seeded from env at spawn (backward compat); replaced by a
|
|
30
33
|
* late IPC bind on a pre-warmed spare. `null` fields fall back to env, then the "spare"
|
|
@@ -77,6 +80,16 @@ export function sessionInfoForWorker(): {
|
|
|
77
80
|
/** Hard ceiling for draining an in-flight chat turn on SIGTERM before teardown (#1874). */
|
|
78
81
|
const WORKER_DRAIN_TIMEOUT_MS = 10_000;
|
|
79
82
|
|
|
83
|
+
/** How often the worker pings the manager while a turn is in flight, refreshing its
|
|
84
|
+
* lastSeen so an actively-chatting session is never idle-reaped. Comfortably under
|
|
85
|
+
* the manager's IDLE_MS (20 min); a turn also pings once at its start. */
|
|
86
|
+
const KEEPALIVE_MS = 60_000;
|
|
87
|
+
|
|
88
|
+
/** The manager control socket (same derivation as native-host / chrome-cli). */
|
|
89
|
+
function managerSockPath(): string {
|
|
90
|
+
return process.env.XCSH_MANAGER_SOCK ?? join(homedir(), ".xcsh", "manager.sock");
|
|
91
|
+
}
|
|
92
|
+
|
|
80
93
|
/** Browser-automation tool set — identical scoping to `main.ts`'s extension path.
|
|
81
94
|
* With scoped tools the ONLY way to create a resource is the form-driven workflow
|
|
82
95
|
* runner, which is exactly what the human watching the browser wants. */
|
|
@@ -162,11 +175,22 @@ export default class Worker extends Command {
|
|
|
162
175
|
for (const s of coldStartBuffer) bridge.send(s);
|
|
163
176
|
coldStartSent = true;
|
|
164
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
|
+
let sessionBuildFrame: SpanFrame | null = null;
|
|
182
|
+
let sessionBuildSent = false;
|
|
183
|
+
const flushSessionBuild = (): void => {
|
|
184
|
+
if (sessionBuildSent || !clientConnected || !sessionBuildFrame) return;
|
|
185
|
+
bridge.send(sessionBuildFrame);
|
|
186
|
+
sessionBuildSent = true;
|
|
187
|
+
};
|
|
165
188
|
// onConnected (raw WS open) is the deliberate flush trigger: no hello hook is exposed
|
|
166
189
|
// today, and the bridge's origin check already gates opens to the extension.
|
|
167
190
|
bridge.onConnected(() => {
|
|
168
191
|
clientConnected = true;
|
|
169
192
|
flushColdStart();
|
|
193
|
+
flushSessionBuild();
|
|
170
194
|
});
|
|
171
195
|
|
|
172
196
|
if (coldSpawn && process.env.XCSH_SESSION_ID && Number.isFinite(spawnAtEnv)) {
|
|
@@ -218,6 +242,7 @@ export default class Worker extends Command {
|
|
|
218
242
|
// session:createAgentSession — the heavy step between bridge-ready and
|
|
219
243
|
// session-ready (model registry, tools, context bootstrap). Wrapped as a span
|
|
220
244
|
// (parity with main.ts:892) so PI_TIMING reveals the per-tab session-load split.
|
|
245
|
+
const sessionBuildStart = Date.now();
|
|
221
246
|
const { session } = await logger.time("session:createAgentSession", createAgentSession, {
|
|
222
247
|
cwd,
|
|
223
248
|
hasUI: false,
|
|
@@ -232,6 +257,15 @@ export default class Worker extends Command {
|
|
|
232
257
|
// sets XCSH_BENCH_EXTENSION (absolute path). Inert for normal workers.
|
|
233
258
|
...(process.env.XCSH_BENCH_EXTENSION ? { additionalExtensionPaths: [process.env.XCSH_BENCH_EXTENSION] } : {}),
|
|
234
259
|
});
|
|
260
|
+
// TTFT: emit the session_build stage (createAgentSession seam) as a WS span so the
|
|
261
|
+
// bench and diagnostics panel stop hiding plugin/discovery/registry-load cost inside
|
|
262
|
+
// total ttft_ms. Buffered + flushed on connect (parity with cold-start spans).
|
|
263
|
+
sessionBuildFrame = sessionBuildSpan(
|
|
264
|
+
process.env.XCSH_SESSION_ID ?? "",
|
|
265
|
+
coldSpawn,
|
|
266
|
+
Date.now() - sessionBuildStart,
|
|
267
|
+
);
|
|
268
|
+
flushSessionBuild();
|
|
235
269
|
|
|
236
270
|
// TTFT Phase 3 hermeticity: the bench-instant stand-in provider is registered while
|
|
237
271
|
// createAgentSession loads the bench extension (additionalExtensionPaths), which is
|
|
@@ -253,6 +287,38 @@ export default class Worker extends Command {
|
|
|
253
287
|
const chatHandler = new ChatHandler(bridge, session);
|
|
254
288
|
chatHandler.attach();
|
|
255
289
|
|
|
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
|
+
const keepalive = new ManagerKeepalive({
|
|
297
|
+
sessionId: () => sessionInfoForWorker().sessionId ?? "spare",
|
|
298
|
+
busy: () => chatHandler.busy,
|
|
299
|
+
connect: async onClose => {
|
|
300
|
+
try {
|
|
301
|
+
const sock = await Bun.connect({
|
|
302
|
+
unix: managerSockPath(),
|
|
303
|
+
socket: { data() {}, close: () => onClose(), error: () => onClose() },
|
|
304
|
+
});
|
|
305
|
+
const transport: KeepaliveTransport = {
|
|
306
|
+
write: data => {
|
|
307
|
+
sock.write(data);
|
|
308
|
+
},
|
|
309
|
+
close: () => {
|
|
310
|
+
sock.end();
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
return transport;
|
|
314
|
+
} catch {
|
|
315
|
+
return null; // manager not reachable — a later emit retries
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
chatHandler.onTurnStart(() => keepalive.turnStart());
|
|
320
|
+
const keepaliveTimer = setInterval(() => keepalive.tick(), KEEPALIVE_MS);
|
|
321
|
+
|
|
256
322
|
// session-ready. Emit the per-tab boot breakdown when requested (parity with
|
|
257
323
|
// main.ts:1002-1009). PI_TIMING=x prints then exits — used by bench/extension-session.ts
|
|
258
324
|
// to measure total worker cold-start; otherwise this is a no-op.
|
|
@@ -266,6 +332,8 @@ export default class Worker extends Command {
|
|
|
266
332
|
|
|
267
333
|
let shuttingDown = false;
|
|
268
334
|
const teardown = () => {
|
|
335
|
+
clearInterval(keepaliveTimer);
|
|
336
|
+
keepalive.stop();
|
|
269
337
|
chatHandler.dispose();
|
|
270
338
|
void bridge.close().finally(() => process.exit(0));
|
|
271
339
|
};
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import * as fs from "node:fs";
|
|
17
17
|
import * as path from "node:path";
|
|
18
18
|
import { $env, logger, readProviderFromModelsYml } from "@f5-sales-demo/pi-utils";
|
|
19
|
+
import { DEFAULT_MODEL_ROLE } from "./settings-schema";
|
|
19
20
|
|
|
20
21
|
/** Current config schema version. Bump when the generated format changes. */
|
|
21
22
|
export const CURRENT_CONFIG_VERSION = 2;
|
|
@@ -118,47 +119,68 @@ export function readLiteLLMConfig(modelsPath: string): LiteLLMConfig | undefined
|
|
|
118
119
|
return { baseUrl: resolvedBaseUrl, apiKey: resolvedApiKey };
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* The default model role is baked into the binary (settings-schema `modelRoles`
|
|
124
|
+
* default) so a fresh install needs NO config.yml. Re-exported here for the
|
|
125
|
+
* healer and tests; there is a single source of truth in settings-schema.
|
|
126
|
+
*/
|
|
127
|
+
export const DEFAULT_MODEL_ROLE_VALUE = DEFAULT_MODEL_ROLE;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Generate config.yml for the LiteLLM proxy.
|
|
131
|
+
*
|
|
132
|
+
* The default model role is NOT written here — it ships in the binary
|
|
133
|
+
* (settings-schema), so we never persist a model id that can go stale (the
|
|
134
|
+
* failure mode behind the invalid-model / catalog-walk bugs). This only carries
|
|
135
|
+
* settings that differ from the built-in schema defaults.
|
|
136
|
+
*/
|
|
122
137
|
export function generateConfigYml(): string {
|
|
123
138
|
return [
|
|
124
139
|
"# Auto-generated by xcsh for LiteLLM proxy",
|
|
125
|
-
"
|
|
126
|
-
" default: anthropic/claude-opus-4-6",
|
|
127
|
-
"",
|
|
140
|
+
"# The default model role ships in the binary — none is written here.",
|
|
128
141
|
"providers:",
|
|
129
142
|
" image: openai",
|
|
130
143
|
" webSearch: anthropic",
|
|
131
144
|
"",
|
|
132
|
-
"generate_image:",
|
|
133
|
-
" enabled: true",
|
|
134
|
-
"",
|
|
135
|
-
"inspect_image:",
|
|
136
|
-
" enabled: true",
|
|
137
|
-
"",
|
|
138
|
-
"images:",
|
|
139
|
-
" blockImages: false",
|
|
140
|
-
" autoResize: true",
|
|
141
|
-
"",
|
|
142
|
-
"terminal:",
|
|
143
|
-
" showImages: true",
|
|
144
|
-
"",
|
|
145
145
|
].join("\n");
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
/**
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
149
|
+
* Provider prefixes that are never valid as a persisted runtime default.
|
|
150
|
+
* `bench-instant` is the TTFT benchmark stand-in provider — it only registers
|
|
151
|
+
* when XCSH_BENCH_EXTENSION is set, so a `bench-instant/*` default in a normal
|
|
152
|
+
* worker is unresolvable and drops xcsh into the "first available model"
|
|
153
|
+
* fallback (which can pick a model the F5 proxy can't serve).
|
|
154
|
+
*/
|
|
155
|
+
const UNRESOLVABLE_DEFAULT_PROVIDER_PREFIXES = ["bench-instant/"];
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Repair a config.yml whose `modelRoles.default` can never resolve at runtime
|
|
159
|
+
* (e.g. `bench-instant/bench-instant` leaked from a benchmark run) by rewriting
|
|
160
|
+
* it to the binary default. Left unhealed, such a default triggers the
|
|
161
|
+
* catalog-wide fallback that surfaced as the AWS-SSO / invalid-model errors.
|
|
162
|
+
*
|
|
163
|
+
* A config.yml with NO `modelRoles:` needs no healing — the binary provides the
|
|
164
|
+
* default. We deliberately do not add one, to avoid persisting a stale id.
|
|
153
165
|
*/
|
|
154
166
|
export function healConfigYmlModelRoles(configPath: string): void {
|
|
155
167
|
try {
|
|
156
168
|
const content = fs.readFileSync(configPath, "utf-8");
|
|
157
|
-
if (content.includes("modelRoles:")) return; //
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
169
|
+
if (!content.includes("modelRoles:")) return; // binary default applies
|
|
170
|
+
const defaultLine = /^(\s*)default:\s*(\S+)\s*$/m;
|
|
171
|
+
const match = content.match(defaultLine);
|
|
172
|
+
if (match) {
|
|
173
|
+
const [, indent, value] = match;
|
|
174
|
+
if (UNRESOLVABLE_DEFAULT_PROVIDER_PREFIXES.some(prefix => value.startsWith(prefix))) {
|
|
175
|
+
const healed = content.replace(defaultLine, `${indent}default: ${DEFAULT_MODEL_ROLE_VALUE}`);
|
|
176
|
+
fs.writeFileSync(configPath, healed);
|
|
177
|
+
logger.debug("Healed config.yml: replaced unresolvable default modelRole", {
|
|
178
|
+
configPath,
|
|
179
|
+
previous: value,
|
|
180
|
+
replacement: DEFAULT_MODEL_ROLE_VALUE,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
162
184
|
} catch {
|
|
163
185
|
// Best-effort — don't block startup
|
|
164
186
|
}
|
|
@@ -774,6 +774,8 @@ export class ModelRegistry {
|
|
|
774
774
|
#customProviderApiKeys: Map<string, string> = new Map();
|
|
775
775
|
#keylessProviders: Set<string> = new Set();
|
|
776
776
|
#discoverableProviders: DiscoveryProviderConfig[] = [];
|
|
777
|
+
/** Provider ids explicitly configured in models.yml (empty until a config is loaded). */
|
|
778
|
+
#configuredProviderIds: Set<string> = new Set();
|
|
777
779
|
#customModelOverlays: CustomModelOverlay[] = [];
|
|
778
780
|
#providerOverrides: Map<string, ProviderOverride> = new Map();
|
|
779
781
|
#modelOverrides: Map<string, Map<string, ModelOverride>> = new Map();
|
|
@@ -923,6 +925,7 @@ export class ModelRegistry {
|
|
|
923
925
|
this.#configError = configError;
|
|
924
926
|
this.#keylessProviders = keylessProviders;
|
|
925
927
|
this.#discoverableProviders = discoverableProviders;
|
|
928
|
+
this.#configuredProviderIds = configuredProviders;
|
|
926
929
|
this.#customModelOverlays = customModels;
|
|
927
930
|
this.#providerOverrides = overrides;
|
|
928
931
|
this.#modelOverrides = modelOverrides;
|
|
@@ -1993,6 +1996,16 @@ export class ModelRegistry {
|
|
|
1993
1996
|
return this.#models.filter(model => this.#isModelAvailable(model));
|
|
1994
1997
|
}
|
|
1995
1998
|
|
|
1999
|
+
/**
|
|
2000
|
+
* Provider ids explicitly configured in models.yml.
|
|
2001
|
+
* Empty when no config has been loaded (e.g. a fresh install before `/login`).
|
|
2002
|
+
* Used to keep automatic model selection scoped to providers the user has
|
|
2003
|
+
* actually set up, rather than probing the entire bundled catalog.
|
|
2004
|
+
*/
|
|
2005
|
+
getConfiguredProviderIds(): Set<string> {
|
|
2006
|
+
return new Set(this.#configuredProviderIds);
|
|
2007
|
+
}
|
|
2008
|
+
|
|
1996
2009
|
getDiscoverableProviders(): string[] {
|
|
1997
2010
|
const disabledProviders = getDisabledProviderIdsFromSettings();
|
|
1998
2011
|
return this.#discoverableProviders
|
|
@@ -152,6 +152,14 @@ export interface ModelTagsSettings {
|
|
|
152
152
|
const EMPTY_STRING_ARRAY: string[] = [];
|
|
153
153
|
const EMPTY_STRING_RECORD: Record<string, string> = {};
|
|
154
154
|
const DEFAULT_CYCLE_ORDER: string[] = ["smol", "default", "slow"];
|
|
155
|
+
/**
|
|
156
|
+
* Binary-baked default model role. Ships in the binary so a fresh install needs
|
|
157
|
+
* NO `~/.xcsh/agent/config.yml` — `/login` only supplies the (PII) proxy URL + key.
|
|
158
|
+
* The gateway serves the id `claude-opus-4-8`; its catalog entry carries the
|
|
159
|
+
* `context-1m-2025-08-07` beta for 1M context.
|
|
160
|
+
*/
|
|
161
|
+
export const DEFAULT_MODEL_ROLE = "anthropic/claude-opus-4-8";
|
|
162
|
+
const DEFAULT_MODEL_ROLES: Record<string, string> = { default: DEFAULT_MODEL_ROLE };
|
|
155
163
|
const EMPTY_MODEL_TAGS_RECORD: ModelTagsSettings = {};
|
|
156
164
|
export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
|
|
157
165
|
{
|
|
@@ -245,7 +253,7 @@ export const SETTINGS_SCHEMA = {
|
|
|
245
253
|
|
|
246
254
|
disabledExtensions: { type: "array", default: EMPTY_STRING_ARRAY },
|
|
247
255
|
|
|
248
|
-
modelRoles: { type: "record", default:
|
|
256
|
+
modelRoles: { type: "record", default: DEFAULT_MODEL_ROLES },
|
|
249
257
|
|
|
250
258
|
modelTags: { type: "record", default: EMPTY_MODEL_TAGS_RECORD },
|
|
251
259
|
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "19.61.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "19.61.5",
|
|
21
|
+
"commit": "e9f45bc1c87e84c1d99060a016ae1e20907e2210",
|
|
22
|
+
"shortCommit": "e9f45bc",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.61.
|
|
25
|
-
"commitDate": "2026-07-
|
|
26
|
-
"buildDate": "2026-07-
|
|
24
|
+
"tag": "v19.61.5",
|
|
25
|
+
"commitDate": "2026-07-08T01:47:55Z",
|
|
26
|
+
"buildDate": "2026-07-08T02:09:52.692Z",
|
|
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.61.
|
|
31
|
+
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/e9f45bc1c87e84c1d99060a016ae1e20907e2210",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.61.5"
|
|
33
33
|
};
|
package/src/sdk.ts
CHANGED
|
@@ -31,7 +31,13 @@ import { loadCapability } from "./capability";
|
|
|
31
31
|
import { type Rule, ruleCapability } from "./capability/rule";
|
|
32
32
|
import { hasLiteLLMEnv } from "./config/auto-config";
|
|
33
33
|
import { ModelRegistry } from "./config/model-registry";
|
|
34
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
defaultModelPerProvider,
|
|
36
|
+
formatModelString,
|
|
37
|
+
parseModelPattern,
|
|
38
|
+
parseModelString,
|
|
39
|
+
resolveModelRoleValue,
|
|
40
|
+
} from "./config/model-resolver";
|
|
35
41
|
import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./config/prompt-templates";
|
|
36
42
|
import { Settings, type SkillsSettings } from "./config/settings";
|
|
37
43
|
import { CursorExecHandlers } from "./cursor";
|
|
@@ -1296,8 +1302,26 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1296
1302
|
// Fall back to first available model with a valid API key.
|
|
1297
1303
|
// Skip fallback if the user explicitly requested a model via --model that wasn't found.
|
|
1298
1304
|
if (!model && !options.modelPattern) {
|
|
1299
|
-
|
|
1300
|
-
|
|
1305
|
+
// Scope automatic selection to providers the user has actually configured
|
|
1306
|
+
// in models.yml. This keeps a stray credential for an unconfigured provider
|
|
1307
|
+
// (e.g. an expired AWS_PROFILE that makes Bedrock look "authenticated") from
|
|
1308
|
+
// ever being selected, and stops us probing the entire bundled catalog. A
|
|
1309
|
+
// fresh install (nothing configured) falls straight through to /login guidance
|
|
1310
|
+
// instead of walking legacy catalog entries the proxy can't serve.
|
|
1311
|
+
const configuredProviders = modelRegistry.getConfiguredProviderIds();
|
|
1312
|
+
const candidates =
|
|
1313
|
+
configuredProviders.size > 0
|
|
1314
|
+
? modelRegistry.getAll().filter(candidate => configuredProviders.has(candidate.provider))
|
|
1315
|
+
: [];
|
|
1316
|
+
// Within a configured provider, prefer its designated default model over raw
|
|
1317
|
+
// catalog order (which begins at legacy ids like claude-3-5-sonnet-20240620).
|
|
1318
|
+
const isProviderDefault = (candidate: Model): boolean =>
|
|
1319
|
+
defaultModelPerProvider[candidate.provider as keyof typeof defaultModelPerProvider] === candidate.id;
|
|
1320
|
+
const orderedCandidates = [
|
|
1321
|
+
...candidates.filter(isProviderDefault),
|
|
1322
|
+
...candidates.filter(candidate => !isProviderDefault(candidate)),
|
|
1323
|
+
];
|
|
1324
|
+
for (const candidate of orderedCandidates) {
|
|
1301
1325
|
if (await hasModelApiKey(candidate)) {
|
|
1302
1326
|
model = candidate;
|
|
1303
1327
|
break;
|