@f5-sales-demo/xcsh 19.92.1 → 19.93.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "19.92.1",
4
+ "version": "19.93.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -56,13 +56,13 @@
56
56
  "dependencies": {
57
57
  "@agentclientprotocol/sdk": "1.3.0",
58
58
  "@mozilla/readability": "^0.6",
59
- "@f5-sales-demo/xcsh-stats": "19.92.1",
60
- "@f5-sales-demo/pi-agent-core": "19.92.1",
61
- "@f5-sales-demo/pi-ai": "19.92.1",
62
- "@f5-sales-demo/pi-natives": "19.92.1",
63
- "@f5-sales-demo/pi-resource-management": "19.92.1",
64
- "@f5-sales-demo/pi-tui": "19.92.1",
65
- "@f5-sales-demo/pi-utils": "19.92.1",
59
+ "@f5-sales-demo/xcsh-stats": "19.93.0",
60
+ "@f5-sales-demo/pi-agent-core": "19.93.0",
61
+ "@f5-sales-demo/pi-ai": "19.93.0",
62
+ "@f5-sales-demo/pi-natives": "19.93.0",
63
+ "@f5-sales-demo/pi-resource-management": "19.93.0",
64
+ "@f5-sales-demo/pi-tui": "19.93.0",
65
+ "@f5-sales-demo/pi-utils": "19.93.0",
66
66
  "@sinclair/typebox": "^0.34",
67
67
  "@xterm/headless": "^6.0",
68
68
  "ajv": "^8.20",
@@ -10,6 +10,7 @@ import {
10
10
  normalizeHostToolDefinitions,
11
11
  RpcHostToolBridge,
12
12
  } from "../host-tools";
13
+ import { extractReferences } from "../references";
13
14
  import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
14
15
  import {
15
16
  type ChatDelta,
@@ -17,7 +18,6 @@ import {
17
18
  type ChatError,
18
19
  type ChatErrorReason,
19
20
  type ChatKeepalive,
20
- type ChatReference,
21
21
  type ChatRequest,
22
22
  type Configure,
23
23
  type ConfigureAck,
@@ -680,39 +680,6 @@ function composeBrowserPageContext(parts: string[], context: PageContextSnapshot
680
680
  parts.push("---");
681
681
  }
682
682
 
683
- export function classifyReferenceKind(url: string): "doc" | "console" {
684
- try {
685
- const parsed = new URL(url);
686
- if (/\.console\.ves\.volterra\.io$/.test(parsed.hostname)) return "console";
687
- if (parsed.hostname === "docs.cloud.f5.com" || parsed.pathname.startsWith("/docs")) return "doc";
688
- } catch {
689
- /* malformed URL — default to doc */
690
- }
691
- return "doc";
692
- }
693
-
694
- function titleFromUrl(url: string): string {
695
- try {
696
- const parsed = new URL(url);
697
- const segments = parsed.pathname.split("/").filter(Boolean);
698
- return segments.length > 0 ? segments[segments.length - 1] : parsed.hostname;
699
- } catch {
700
- return url;
701
- }
702
- }
703
-
704
- /**
705
- * Trailing characters a bare-URL match may greedily swallow at a markdown/prose
706
- * boundary — markdown emphasis + code (`*_~` and backtick) and sentence/wrap
707
- * punctuation. A real URL effectively never ends in these, so trimming them yields
708
- * the intended link (e.g. `**https://…/llms.txt**` or a code-wrapped
709
- * `` `https://…/llms.txt` `` → `https://…/llms.txt`). The markdown-link branch is
710
- * bounded by its closing `)` and needs no trimming.
711
- */
712
- function trimTrailingMarkup(url: string): string {
713
- return url.replace(/[*_~`,.;:!?'")\]}>]+$/, "");
714
- }
715
-
716
683
  /** Panel-facing labels for provider-side tools, used for the activity rows (#2340). */
717
684
  function serverToolLabels(toolName: string): { running: string; done: string } {
718
685
  switch (toolName) {
@@ -724,47 +691,3 @@ function serverToolLabels(toolName: string): { running: string; done: string } {
724
691
  return { running: `${toolName}: running`, done: toolName };
725
692
  }
726
693
  }
727
-
728
- export function extractReferences(msg: AssistantMessage): ChatReference[] {
729
- const refs: ChatReference[] = [];
730
- const seen = new Set<string>();
731
-
732
- // STRUCTURED CITATIONS FIRST (#2340): a provider-side search reports the real page title, so
733
- // it beats the regex scrape below — which can only guess a title from the URL path, and finds
734
- // nothing at all when the model cites a source without printing its URL in the prose. Done as
735
- // its own pass so a citation in a later block still wins over a scrape in an earlier one.
736
- for (const block of msg.content) {
737
- if (block.type !== "text" || !block.citations) continue;
738
- for (const citation of block.citations) {
739
- const url = citation.url;
740
- if (!url || seen.has(url)) continue;
741
- seen.add(url);
742
- refs.push({
743
- kind: classifyReferenceKind(url),
744
- title: citation.title?.trim() || titleFromUrl(url),
745
- url,
746
- });
747
- }
748
- }
749
-
750
- for (const block of msg.content) {
751
- if (block.type !== "text") continue;
752
-
753
- const mdLinkRegex = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
754
- for (let match = mdLinkRegex.exec(block.text); match !== null; match = mdLinkRegex.exec(block.text)) {
755
- const [, title, url] = match;
756
- if (seen.has(url)) continue;
757
- seen.add(url);
758
- refs.push({ kind: classifyReferenceKind(url), title, url });
759
- }
760
-
761
- const bareUrlRegex = /(?<!\()(https?:\/\/[^\s)>\]]+)/g;
762
- for (let match = bareUrlRegex.exec(block.text); match !== null; match = bareUrlRegex.exec(block.text)) {
763
- const url = trimTrailingMarkup(match[1]);
764
- if (seen.has(url)) continue;
765
- seen.add(url);
766
- refs.push({ kind: classifyReferenceKind(url), title: titleFromUrl(url), url });
767
- }
768
- }
769
- return refs;
770
- }
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.92.1",
21
- "commit": "f3e12dd43623f1171d4dff7c837532e64ab8bb4b",
22
- "shortCommit": "f3e12dd",
20
+ "version": "19.93.0",
21
+ "commit": "212247c75cbe621350eda99e20ca752b44cd730f",
22
+ "shortCommit": "212247c",
23
23
  "branch": "main",
24
- "tag": "v19.92.1",
25
- "commitDate": "2026-07-26T01:09:53Z",
26
- "buildDate": "2026-07-26T01:30:47.762Z",
24
+ "tag": "v19.93.0",
25
+ "commitDate": "2026-07-26T05:23:45Z",
26
+ "buildDate": "2026-07-26T05:48:10.440Z",
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/f3e12dd43623f1171d4dff7c837532e64ab8bb4b",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.92.1"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/212247c75cbe621350eda99e20ca752b44cd730f",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.93.0"
33
33
  };
@@ -148,6 +148,22 @@ export const TERRAFORM_INDEX: TerraformIndex = {
148
148
  "workload_flavor",
149
149
  ],
150
150
  },
151
+ {
152
+ name: "Uncategorized",
153
+ slug: "uncategorized",
154
+ description: "Resources pending categorization",
155
+ resource_count: 8,
156
+ resources: [
157
+ "application_profiles",
158
+ "authorization_server",
159
+ "bot_infrastructure",
160
+ "domain",
161
+ "mitigated_domain",
162
+ "protected_application",
163
+ "protected_domain",
164
+ "registration_approval",
165
+ ],
166
+ },
151
167
  {
152
168
  name: "DNS",
153
169
  slug: "dns",
@@ -163,21 +179,6 @@ export const TERRAFORM_INDEX: TerraformIndex = {
163
179
  "dns_zone",
164
180
  ],
165
181
  },
166
- {
167
- name: "Uncategorized",
168
- slug: "uncategorized",
169
- description: "Resources pending categorization",
170
- resource_count: 7,
171
- resources: [
172
- "application_profiles",
173
- "authorization_server",
174
- "bot_infrastructure",
175
- "mitigated_domain",
176
- "protected_application",
177
- "protected_domain",
178
- "registration_approval",
179
- ],
180
- },
181
182
  {
182
183
  name: "API Security",
183
184
  slug: "api-security",
@@ -185,13 +186,6 @@ export const TERRAFORM_INDEX: TerraformIndex = {
185
186
  resource_count: 5,
186
187
  resources: ["api_crawler", "api_definition", "api_discovery", "api_testing", "app_api_group"],
187
188
  },
188
- {
189
- name: "Monitoring",
190
- slug: "monitoring",
191
- description: "Log receivers, alert policies, APM, and global logging configuration",
192
- resource_count: 5,
193
- resources: ["alert_receiver", "alert_template", "apm", "global_log_receiver", "log_receiver"],
194
- },
195
189
  {
196
190
  name: "Applications",
197
191
  slug: "applications",
@@ -213,6 +207,13 @@ export const TERRAFORM_INDEX: TerraformIndex = {
213
207
  resource_count: 4,
214
208
  resources: ["certificate", "certificate_chain", "crl", "trusted_ca_list"],
215
209
  },
210
+ {
211
+ name: "Monitoring",
212
+ slug: "monitoring",
213
+ description: "Log receivers, alert policies, APM, and global logging configuration",
214
+ resource_count: 4,
215
+ resources: ["alert_receiver", "alert_template", "global_log_receiver", "log_receiver"],
216
+ },
216
217
  {
217
218
  name: "VPN",
218
219
  slug: "vpn",
@@ -386,16 +387,6 @@ export const TERRAFORM_INDEX: TerraformIndex = {
386
387
  },
387
388
  import_syntax: "terraform import xcsh_api_testing.example namespace/name",
388
389
  },
389
- apm: {
390
- category: "monitoring",
391
- description: "New APM as a service with configured parameters",
392
- required: ["name", "namespace"],
393
- minimal_config: 'resource "xcsh_apm" "example" {\n name = "example-apm"\n namespace = "staging"\n}',
394
- dependencies: {
395
- requires: [],
396
- },
397
- import_syntax: "terraform import xcsh_apm.example namespace/name",
398
- },
399
390
  app_api_group: {
400
391
  category: "api-security",
401
392
  description: "App_api_group creates a new object in the storage backend for metadata.namespace",
@@ -531,7 +522,7 @@ export const TERRAFORM_INDEX: TerraformIndex = {
531
522
  bgp_routing_policy: {
532
523
  category: "security",
533
524
  description:
534
- "Bgp routing policy is a list of rules containing match criteria and action to be applied. these rules help contol routes which are imported or exported to bgp peers. configuration",
525
+ "Bgp routing policy is a list of rules containing match criteria and action to be applied. these rules help control routes which are imported or exported to bgp peers. configuration",
535
526
  required: ["name", "namespace"],
536
527
  minimal_config:
537
528
  'resource "xcsh_bgp_routing_policy" "example" {\n name = "example-bgp-routing-policy"\n namespace = "staging"\n}',
@@ -857,6 +848,17 @@ export const TERRAFORM_INDEX: TerraformIndex = {
857
848
  },
858
849
  import_syntax: "terraform import xcsh_dns_zone.example namespace/name",
859
850
  },
851
+ domain: {
852
+ category: "uncategorized",
853
+ description: "Allowed domain",
854
+ required: ["name", "namespace", "allowed_domain"],
855
+ minimal_config:
856
+ 'resource "xcsh_domain" "example" {\n name = "example-domain"\n namespace = "staging"\n\n allowed_domain = "example-value"\n}',
857
+ dependencies: {
858
+ requires: [],
859
+ },
860
+ import_syntax: "terraform import xcsh_domain.example namespace/name",
861
+ },
860
862
  endpoint: {
861
863
  category: "load-balancing",
862
864
  description: "Endpoint will create the object in the storage backend for namespace metadata.namespace",
@@ -1636,9 +1638,9 @@ export const TERRAFORM_INDEX: TerraformIndex = {
1636
1638
  udp_loadbalancer: {
1637
1639
  category: "load-balancing",
1638
1640
  description: "Load balancing UDP traffic across origin pools",
1639
- required: ["name", "namespace", "dns_volterra_managed", "enable_per_packet_load_balancing", "idle_timeout"],
1641
+ required: ["name", "namespace", "dns_volterra_managed", "idle_timeout"],
1640
1642
  minimal_config:
1641
- 'resource "xcsh_udp_loadbalancer" "example" {\n name = "example-udp-loadbalancer"\n namespace = "staging"\n\n domains = ["example-value"]\n dns_volterra_managed = true\n enable_per_packet_load_balancing = true\n idle_timeout = 1\n}',
1643
+ 'resource "xcsh_udp_loadbalancer" "example" {\n name = "example-udp-loadbalancer"\n namespace = "staging"\n\n domains = ["example-value"]\n dns_volterra_managed = true\n idle_timeout = 1\n}',
1642
1644
  dependencies: {
1643
1645
  requires: ["namespace", "origin_pool"],
1644
1646
  },
@@ -24,6 +24,7 @@ import {
24
24
  RpcHostToolBridge,
25
25
  } from "../../host-tools";
26
26
  import { type Theme, theme } from "../../modes/theme/theme";
27
+ import { referencesEventFor } from "../../references";
27
28
  import type { AgentSession } from "../../session/agent-session";
28
29
  import { mapContextStatus, runWelcomeChecks } from "../components/welcome-checks";
29
30
  import type {
@@ -497,6 +498,11 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
497
498
  // Output all agent events as JSON
498
499
  session.subscribe(event => {
499
500
  output(event);
501
+ // Citations for the host's Sources chips, mirroring `chat_done.references` on
502
+ // the WS bridge. The turn-boundary rule (an intermediate tool-use step also
503
+ // emits message_end) lives in the shared, tested `referencesEventFor` (#2420).
504
+ const refs = referencesEventFor(event);
505
+ if (refs) output(refs);
500
506
  });
501
507
 
502
508
  // Handle a single command
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Citation extraction — the "Sources" chips a surface shows under an answer.
3
+ *
4
+ * Neutral module ON PURPOSE. This used to live in `browser/chat-handler.ts`, whose
5
+ * 14 imports would have dragged the whole browser chat stack into the RPC path when
6
+ * `rpc-mode` needed the same extraction (#2420). One extractor, and one rule for
7
+ * "which assistant message ends a turn", shared by every transport — a client
8
+ * reimplementing either would drift (see the #2249 trailing-markup regression).
9
+ */
10
+ import type { AssistantMessage } from "@f5-sales-demo/pi-ai";
11
+ import type { ChatReference } from "./browser/chat-protocol";
12
+
13
+ export function classifyReferenceKind(url: string): "doc" | "console" {
14
+ try {
15
+ const parsed = new URL(url);
16
+ if (/\.console\.ves\.volterra\.io$/.test(parsed.hostname)) return "console";
17
+ if (parsed.hostname === "docs.cloud.f5.com" || parsed.pathname.startsWith("/docs")) return "doc";
18
+ } catch {
19
+ /* malformed URL — default to doc */
20
+ }
21
+ return "doc";
22
+ }
23
+ function titleFromUrl(url: string): string {
24
+ try {
25
+ const parsed = new URL(url);
26
+ const segments = parsed.pathname.split("/").filter(Boolean);
27
+ return segments.length > 0 ? segments[segments.length - 1] : parsed.hostname;
28
+ } catch {
29
+ return url;
30
+ }
31
+ }
32
+ /**
33
+ * Trailing characters a bare-URL match may greedily swallow at a markdown/prose
34
+ * boundary — markdown emphasis + code (`*_~` and backtick) and sentence/wrap
35
+ * punctuation. A real URL effectively never ends in these, so trimming them yields
36
+ * the intended link (e.g. `**https://…/llms.txt**` or a code-wrapped
37
+ * `` `https://…/llms.txt` `` → `https://…/llms.txt`). The markdown-link branch is
38
+ * bounded by its closing `)` and needs no trimming.
39
+ */
40
+ function trimTrailingMarkup(url: string): string {
41
+ return url.replace(/[*_~`,.;:!?'")\]}>]+$/, "");
42
+ }
43
+ export function extractReferences(msg: AssistantMessage): ChatReference[] {
44
+ const refs: ChatReference[] = [];
45
+ const seen = new Set<string>();
46
+
47
+ // STRUCTURED CITATIONS FIRST (#2340): a provider-side search reports the real page title, so
48
+ // it beats the regex scrape below — which can only guess a title from the URL path, and finds
49
+ // nothing at all when the model cites a source without printing its URL in the prose. Done as
50
+ // its own pass so a citation in a later block still wins over a scrape in an earlier one.
51
+ for (const block of msg.content) {
52
+ if (block.type !== "text" || !block.citations) continue;
53
+ for (const citation of block.citations) {
54
+ const url = citation.url;
55
+ if (!url || seen.has(url)) continue;
56
+ seen.add(url);
57
+ refs.push({
58
+ kind: classifyReferenceKind(url),
59
+ title: citation.title?.trim() || titleFromUrl(url),
60
+ url,
61
+ });
62
+ }
63
+ }
64
+
65
+ for (const block of msg.content) {
66
+ if (block.type !== "text") continue;
67
+
68
+ const mdLinkRegex = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
69
+ for (let match = mdLinkRegex.exec(block.text); match !== null; match = mdLinkRegex.exec(block.text)) {
70
+ const [, title, url] = match;
71
+ if (seen.has(url)) continue;
72
+ seen.add(url);
73
+ refs.push({ kind: classifyReferenceKind(url), title, url });
74
+ }
75
+
76
+ const bareUrlRegex = /(?<!\()(https?:\/\/[^\s)>\]]+)/g;
77
+ for (let match = bareUrlRegex.exec(block.text); match !== null; match = bareUrlRegex.exec(block.text)) {
78
+ const url = trimTrailingMarkup(match[1]);
79
+ if (seen.has(url)) continue;
80
+ seen.add(url);
81
+ refs.push({ kind: classifyReferenceKind(url), title: titleFromUrl(url), url });
82
+ }
83
+ }
84
+ return refs;
85
+ }
86
+
87
+ /** The RPC stream's citation event, mirroring `chat_done.references` on the bridge. */
88
+ export interface RpcReferencesEvent {
89
+ type: "references";
90
+ references: ChatReference[];
91
+ }
92
+
93
+ /**
94
+ * Decide whether an agent event should produce a citation event, and build it.
95
+ *
96
+ * Pure so the TURN-BOUNDARY rule is testable: an intermediate tool-use step also
97
+ * emits `message_end`, and treating that as terminal is the trap
98
+ * `chat-handler.ts` documents at length — it would publish the citations of a
99
+ * half-finished answer (and, on the bridge, end the turn early). Returns null for
100
+ * anything that is not a settled assistant message, and for a turn that cited
101
+ * nothing, so callers emit only real content.
102
+ *
103
+ * Typed by what it CONSUMES (a tagged event that may carry a message) rather than by
104
+ * one event union: the bridge and the RPC session stream different unions
105
+ * (`AgentEvent` vs `AgentSessionEvent`) and both must be able to call this.
106
+ */
107
+ export function referencesEventFor(event: { type: string; message?: unknown }): RpcReferencesEvent | null {
108
+ if (event.type !== "message_end") return null;
109
+ const message = (event as { message?: { role?: string } }).message;
110
+ if (message?.role !== "assistant") return null;
111
+ const assistant = message as AssistantMessage;
112
+ // Intermediate tool-use step — the turn continues after the tool round-trip.
113
+ if (assistant.stopReason === "toolUse" || assistant.content.some(part => part.type === "toolCall")) return null;
114
+ const references = extractReferences(assistant);
115
+ return references.length > 0 ? { type: "references", references } : null;
116
+ }