@f5-sales-demo/xcsh 19.61.3 → 19.61.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "19.61.3",
4
+ "version": "19.61.4",
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.3",
60
- "@f5-sales-demo/pi-agent-core": "19.61.3",
61
- "@f5-sales-demo/pi-ai": "19.61.3",
62
- "@f5-sales-demo/pi-natives": "19.61.3",
63
- "@f5-sales-demo/pi-resource-management": "19.61.3",
64
- "@f5-sales-demo/pi-tui": "19.61.3",
65
- "@f5-sales-demo/pi-utils": "19.61.3",
59
+ "@f5-sales-demo/xcsh-stats": "19.61.4",
60
+ "@f5-sales-demo/pi-agent-core": "19.61.4",
61
+ "@f5-sales-demo/pi-ai": "19.61.4",
62
+ "@f5-sales-demo/pi-natives": "19.61.4",
63
+ "@f5-sales-demo/pi-resource-management": "19.61.4",
64
+ "@f5-sales-demo/pi-tui": "19.61.4",
65
+ "@f5-sales-demo/pi-utils": "19.61.4",
66
66
  "@sinclair/typebox": "^0.34",
67
67
  "@xterm/headless": "^6.0",
68
68
  "ajv": "^8.20",
@@ -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,
@@ -19,7 +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
+ import { coldStartSpans, type SpanFrame, sessionBuildSpan } from "../browser/ttft-spans";
23
23
  import { initializeWithSettings } from "../discovery";
24
24
  import { createAgentSession } from "../sdk";
25
25
  import { activateTenantContext } from "../services/session-context-binding";
@@ -162,11 +162,22 @@ export default class Worker extends Command {
162
162
  for (const s of coldStartBuffer) bridge.send(s);
163
163
  coldStartSent = true;
164
164
  };
165
+ // TTFT: the session_build span (createAgentSession seam) is computed after the
166
+ // bridge is listening but possibly before the extension connects — buffer it and
167
+ // flush on connect, same as the cold-start spans.
168
+ let sessionBuildFrame: SpanFrame | null = null;
169
+ let sessionBuildSent = false;
170
+ const flushSessionBuild = (): void => {
171
+ if (sessionBuildSent || !clientConnected || !sessionBuildFrame) return;
172
+ bridge.send(sessionBuildFrame);
173
+ sessionBuildSent = true;
174
+ };
165
175
  // onConnected (raw WS open) is the deliberate flush trigger: no hello hook is exposed
166
176
  // today, and the bridge's origin check already gates opens to the extension.
167
177
  bridge.onConnected(() => {
168
178
  clientConnected = true;
169
179
  flushColdStart();
180
+ flushSessionBuild();
170
181
  });
171
182
 
172
183
  if (coldSpawn && process.env.XCSH_SESSION_ID && Number.isFinite(spawnAtEnv)) {
@@ -218,6 +229,7 @@ export default class Worker extends Command {
218
229
  // session:createAgentSession — the heavy step between bridge-ready and
219
230
  // session-ready (model registry, tools, context bootstrap). Wrapped as a span
220
231
  // (parity with main.ts:892) so PI_TIMING reveals the per-tab session-load split.
232
+ const sessionBuildStart = Date.now();
221
233
  const { session } = await logger.time("session:createAgentSession", createAgentSession, {
222
234
  cwd,
223
235
  hasUI: false,
@@ -232,6 +244,15 @@ export default class Worker extends Command {
232
244
  // sets XCSH_BENCH_EXTENSION (absolute path). Inert for normal workers.
233
245
  ...(process.env.XCSH_BENCH_EXTENSION ? { additionalExtensionPaths: [process.env.XCSH_BENCH_EXTENSION] } : {}),
234
246
  });
247
+ // TTFT: emit the session_build stage (createAgentSession seam) as a WS span so the
248
+ // bench and diagnostics panel stop hiding plugin/discovery/registry-load cost inside
249
+ // total ttft_ms. Buffered + flushed on connect (parity with cold-start spans).
250
+ sessionBuildFrame = sessionBuildSpan(
251
+ process.env.XCSH_SESSION_ID ?? "",
252
+ coldSpawn,
253
+ Date.now() - sessionBuildStart,
254
+ );
255
+ flushSessionBuild();
235
256
 
236
257
  // TTFT Phase 3 hermeticity: the bench-instant stand-in provider is registered while
237
258
  // createAgentSession loads the bench extension (additionalExtensionPaths), which is
@@ -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
- /** Generate config.yml with sensible defaults for LiteLLM. */
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
- "modelRoles:",
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
- * Ensure config.yml has a modelRoles.default entry.
150
- * Existing configs from earlier versions may be missing this, causing xcsh
151
- * to fall through to the "first available model" fallback which picks stale
152
- * cached models that the proxy can't serve.
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; // Already has modelRoles
158
- // Prepend modelRoles section
159
- const healed = `modelRoles:\n default: anthropic/claude-opus-4-6\n\n${content}`;
160
- fs.writeFileSync(configPath, healed);
161
- logger.debug("Healed config.yml: added default modelRoles", { configPath });
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: EMPTY_STRING_RECORD },
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.3",
21
- "commit": "1546a875f07608812967e1169ea86c2bd3f837ac",
22
- "shortCommit": "1546a87",
20
+ "version": "19.61.4",
21
+ "commit": "8697bc5fee06350b861c8c8bf7bbac1f00f85408",
22
+ "shortCommit": "8697bc5",
23
23
  "branch": "main",
24
- "tag": "v19.61.3",
25
- "commitDate": "2026-07-07T11:57:08Z",
26
- "buildDate": "2026-07-07T12:21:42.145Z",
24
+ "tag": "v19.61.4",
25
+ "commitDate": "2026-07-07T16:14:37Z",
26
+ "buildDate": "2026-07-07T16:40:48.327Z",
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/1546a875f07608812967e1169ea86c2bd3f837ac",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.61.3"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/8697bc5fee06350b861c8c8bf7bbac1f00f85408",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.61.4"
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 { formatModelString, parseModelPattern, parseModelString, resolveModelRoleValue } from "./config/model-resolver";
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
- const allModels = modelRegistry.getAll();
1300
- for (const candidate of allModels) {
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;