@f5-sales-demo/xcsh 19.61.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "19.61.0",
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.61.0",
59
- "@f5-sales-demo/pi-agent-core": "19.61.0",
60
- "@f5-sales-demo/pi-ai": "19.61.0",
61
- "@f5-sales-demo/pi-natives": "19.61.0",
62
- "@f5-sales-demo/pi-resource-management": "19.61.0",
63
- "@f5-sales-demo/pi-tui": "19.61.0",
64
- "@f5-sales-demo/pi-utils": "19.61.0",
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
- return localCheckout;
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 = { id, seq: 0, terminalSent: false, unsubscribe: () => {} };
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
+ }
@@ -284,11 +284,17 @@ export default class Manager extends Command {
284
284
  };
285
285
 
286
286
  /** Adopt a warm spare for a provision (bind over IPC). Returns false if none available. */
287
- const adoptSpare = (msg: { sessionId: string; tenant: string }): boolean => {
287
+ const adoptSpare = (msg: { sessionId: string; tenant: string }, managerProvisionMs: number): boolean => {
288
288
  const rec = pool.shift();
289
289
  if (!rec) return false;
290
290
  // `send` exists because the spare was spawned with an `ipc` handler.
291
- (rec.proc as { send(m: unknown): void }).send({ type: "bind", sessionId: msg.sessionId, tenant: msg.tenant });
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
+ });
292
298
  reg.set(msg.sessionId, {
293
299
  sessionId: msg.sessionId,
294
300
  tenant: msg.tenant,
@@ -354,7 +360,7 @@ export default class Manager extends Command {
354
360
  process.exit(0);
355
361
  };
356
362
 
357
- const spawnWorker = (msg: { sessionId: string; tenant: string }): void => {
363
+ const spawnWorker = (msg: { sessionId: string; tenant: string }, managerProvisionMs: number): void => {
358
364
  // Registry-dedupe (pickPort) over only the ports free at the OS level.
359
365
  const port = pickPort(reg, range.filter(isPortFree));
360
366
  if (port === null) {
@@ -374,6 +380,12 @@ export default class Manager extends Command {
374
380
  // spawned tenant key is authoritative (undefined removes the var in Bun).
375
381
  XCSH_API_URL: undefined,
376
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",
377
389
  },
378
390
  stdout: "ignore",
379
391
  stderr: "ignore",
@@ -417,8 +429,10 @@ export default class Manager extends Command {
417
429
  }
418
430
  return;
419
431
  }
432
+ const provisionReceivedAt = Date.now(); // TTFT Phase 2: start of manager_provision
420
433
  if (needsProvision(reg, msg.sessionId)) {
421
- if (!adoptSpare(msg)) spawnWorker(msg); // adopt a warm spare, else cold-spawn (fallback)
434
+ const managerProvisionMs = Date.now() - provisionReceivedAt;
435
+ if (!adoptSpare(msg, managerProvisionMs)) spawnWorker(msg, managerProvisionMs); // adopt a warm spare, else cold-spawn (fallback)
422
436
  }
423
437
  const w = reg.get(msg.sessionId);
424
438
  if (w) w.lastSeen = Date.now(); // touch on every provision (keep-alive)
@@ -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 { type?: unknown; sessionId?: unknown; tenant?: unknown };
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
 
@@ -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";
@@ -47,29 +47,25 @@ export function createApiSpecResolver(
47
47
  return {
48
48
  async resolve(url: InternalUrl): Promise<InternalResource> {
49
49
  const pathname = url.rawPathname ?? url.pathname;
50
- const domain = pathname.replace(/^\//, "").replace(/\/$/, "");
51
-
52
- if (!domain) {
53
- return makeResource(url, renderDomainIndex(index));
54
- }
50
+ const requestedDomain = pathname.replace(/^\//, "").replace(/\/$/, "");
55
51
 
56
52
  // Reserved sub-paths — checked before domain lookup
57
- if (domain === "workflows" || domain.startsWith("workflows/")) {
58
- const workflowId = domain.replace(/^workflows\/?/, "");
53
+ if (requestedDomain === "workflows" || requestedDomain.startsWith("workflows/")) {
54
+ const workflowId = requestedDomain.replace(/^workflows\/?/, "");
59
55
  return makeResource(url, workflowId ? renderWorkflowDetail(workflowId, index) : renderWorkflowIndex(index));
60
56
  }
61
57
 
62
- if (domain === "errors" || domain.startsWith("errors/")) {
63
- const errorKey = domain.replace(/^errors\/?/, "");
58
+ if (requestedDomain === "errors" || requestedDomain.startsWith("errors/")) {
59
+ const errorKey = requestedDomain.replace(/^errors\/?/, "");
64
60
  return makeResource(url, errorKey ? renderErrorDetail(errorKey, index) : renderErrorIndex(index));
65
61
  }
66
62
 
67
- if (domain === "glossary" || domain.startsWith("glossary/")) {
63
+ if (requestedDomain === "glossary" || requestedDomain.startsWith("glossary/")) {
68
64
  return makeResource(url, renderGlossary(index));
69
65
  }
70
66
 
71
- if (domain === "validation" || domain.startsWith("validation/")) {
72
- const resourceKey = domain.replace(/^validation\/?/, "");
67
+ if (requestedDomain === "validation" || requestedDomain.startsWith("validation/")) {
68
+ const resourceKey = requestedDomain.replace(/^validation\/?/, "");
73
69
  return makeResource(
74
70
  url,
75
71
  resourceKey
@@ -78,32 +74,55 @@ export function createApiSpecResolver(
78
74
  );
79
75
  }
80
76
 
81
- const entry = index.domains.find(d => d.domain === domain);
77
+ const resourceName = url.searchParams.get("resource");
78
+
79
+ // Resolve the domain entry. When a resource is named against a wrong or
80
+ // omitted domain (e.g. `config?resource=http_loadbalancer`), fall back to
81
+ // the domain that actually owns that resource instead of erroring out.
82
+ let entry = requestedDomain ? index.domains.find(d => d.domain === requestedDomain) : undefined;
83
+ let resolutionNote: string | undefined;
84
+ if (!entry && resourceName) {
85
+ const owner = findDomainForResource(index, resourceName);
86
+ if (owner) {
87
+ entry = owner;
88
+ resolutionNote = requestedDomain
89
+ ? `> Note: domain \`${requestedDomain}\` not found; resolved resource \`${resourceName}\` in domain \`${owner.domain}\`.`
90
+ : `> Note: no domain specified; resolved resource \`${resourceName}\` in domain \`${owner.domain}\`.`;
91
+ }
92
+ }
93
+
82
94
  if (!entry) {
83
- return makeResource(url, renderUnknownDomain(domain, index));
95
+ return makeResource(
96
+ url,
97
+ requestedDomain ? renderUnknownDomain(requestedDomain, index) : renderDomainIndex(index),
98
+ );
84
99
  }
85
100
 
101
+ const domain = entry.domain;
102
+ const withNote = (content: string): string => (resolutionNote ? `${resolutionNote}\n\n${content}` : content);
103
+
86
104
  try {
87
- const resource = url.searchParams.get("resource");
88
105
  const pathFilter = url.searchParams.get("path");
89
106
 
90
- if (resource) {
107
+ if (resourceName) {
91
108
  const crud = url.searchParams.get("crud") === "true";
92
109
  const spec = lookup(domain);
93
- const matchingPaths = filterPathsByResource(spec, resource, entry);
110
+ const matchingPaths = filterPathsByResource(spec, resourceName, entry);
94
111
  if (Object.keys(matchingPaths).length === 0) {
95
- return makeResource(url, renderUnknownResource(resource, entry, spec));
112
+ return makeResource(url, withNote(renderUnknownResource(resourceName, entry, spec)));
96
113
  }
97
114
  return makeResource(
98
115
  url,
99
- renderResourceSpec(
100
- domain,
101
- resource,
102
- spec,
103
- entry,
104
- { crudOnly: crud },
105
- enrichments?.[domain],
106
- validationData,
116
+ withNote(
117
+ renderResourceSpec(
118
+ domain,
119
+ resourceName,
120
+ spec,
121
+ entry,
122
+ { crudOnly: crud },
123
+ enrichments?.[domain],
124
+ validationData,
125
+ ),
107
126
  ),
108
127
  );
109
128
  }
@@ -123,6 +142,11 @@ export function createApiSpecResolver(
123
142
  };
124
143
  }
125
144
 
145
+ /** Find the domain entry that owns a resource by name (for cross-domain resolution). */
146
+ function findDomainForResource(index: ApiSpecIndex, resourceName: string): ApiSpecDomainEntry | undefined {
147
+ return index.domains.find(d => d.resources.some(r => r.name === resourceName));
148
+ }
149
+
126
150
  function makeResource(url: InternalUrl, content: string): InternalResource {
127
151
  return {
128
152
  url: url.href,
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.61.0",
21
- "commit": "54faf1b4029f7a7a4251c5cc6776c789b3e1af5c",
22
- "shortCommit": "54faf1b",
20
+ "version": "19.61.1",
21
+ "commit": "2060729b09fb7a4ede87e49a0ac0fb76725160ae",
22
+ "shortCommit": "2060729",
23
23
  "branch": "main",
24
- "tag": "v19.61.0",
25
- "commitDate": "2026-07-05T20:10:34Z",
26
- "buildDate": "2026-07-05T20:32:34.362Z",
24
+ "tag": "v19.61.1",
25
+ "commitDate": "2026-07-07T00:26:41Z",
26
+ "buildDate": "2026-07-07T01:18:58.960Z",
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/54faf1b4029f7a7a4251c5cc6776c789b3e1af5c",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.61.0"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/2060729b09fb7a4ede87e49a0ac0fb76725160ae",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.61.1"
33
33
  };