@f5xc-salesdemos/xcsh 18.72.0 → 18.75.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.75.0] - 2026-05-23
6
+
7
+ ### Added
8
+
9
+ - Auto-validate credentials on context switch: SDK context change listener now fires a background `validateToken()` after emitting the switch notification, sending a follow-up `context_validation_result` custom message so the LLM knows whether the new context's credentials are valid
10
+
5
11
  ## [18.64.2] - 2026-05-16
6
12
 
7
13
  ### Fixed
@@ -20,11 +26,13 @@
20
26
  ### Changed
21
27
 
22
28
  - Regenerated API spec index from catalog v2.1.82: http_loadbalancer CRUD verification corrections (6 new server defaults, corrected minimum configs, cross-field dependencies, default_pool inline pool discovery, 5 composable routing approaches) and tcp_loadbalancer minimum config corrections (listen_port, origin_pools_weights, do_not_advertise format, 9 server defaults, forced hash_policy default) ([#753](https://github.com/f5xc-salesdemos/xcsh/issues/753), [#757](https://github.com/f5xc-salesdemos/xcsh/issues/757))
29
+
23
30
  ## [18.53.0] - 2026-05-09
24
31
 
25
32
  ### Changed
26
33
 
27
34
  - Autoresearch subsystem code quality: -513 lines (18.8% reduction), ~13% faster type checking. Un-exported internal symbols, relocated types, consolidated duplicate patterns, replaced manual deep copies with `structuredClone`, replaced `while(exec)` with `matchAll`, compressed control flow, extracted shared interfaces ([#734](https://github.com/f5xc-salesdemos/xcsh/pull/734))
35
+
28
36
  ## [18.53.0] - 2026-05-09
29
37
 
30
38
  ### Fixed
@@ -79,13 +87,11 @@
79
87
 
80
88
  ## [18.18.4] - 2026-04-26
81
89
 
82
-
83
90
  ### Fixed
84
91
 
85
92
  - Fixed gutter width propagation in the fallback tool renderer: `#formatToolExecution()` now receives the actual available width at render-time and uses it for line truncation instead of a hardcoded 80-column limit. On narrow terminals (<82 cols) this prevents content wider than the gutter-adjusted viewport; on wide terminals it allows longer output lines. ([#117](https://github.com/f5xc-salesdemos/xcsh/issues/117))
86
93
  - Fixed `resolveConfigValue` returning literal env var names (e.g. `"LITELLM_API_KEY"`) as API keys when the env var is unset, causing 401 errors on first launch. The resolver now rejects unresolved `ALL_CAPS_WITH_UNDERSCORES` patterns, matching the existing guard in `resolveYamlApiKeyConfig`. ([#241](https://github.com/f5xc-salesdemos/xcsh/issues/241))
87
94
 
88
-
89
95
  ### Changed
90
96
 
91
97
  - Renamed F5 XC credential system from "profile" to "context" to align with kubectl conventions. The `/profile` command is now `/context`, all types/classes use `Context*` naming (`ContextService`, `ContextStatus`, `F5XCContext`, etc.), on-disk paths changed from `profiles/` to `contexts/` and `active_profile` to `active_context`, and the status-line segment ID is now `context_f5xc`. ([#302](https://github.com/f5xc-salesdemos/xcsh/issues/302))
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/xcsh",
4
- "version": "18.72.0",
4
+ "version": "18.75.3",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5xc-salesdemos/xcsh",
7
7
  "author": "Can Boluk",
@@ -48,12 +48,12 @@
48
48
  "dependencies": {
49
49
  "@agentclientprotocol/sdk": "0.16.1",
50
50
  "@mozilla/readability": "^0.6",
51
- "@f5xc-salesdemos/xcsh-stats": "18.72.0",
52
- "@f5xc-salesdemos/pi-agent-core": "18.72.0",
53
- "@f5xc-salesdemos/pi-ai": "18.72.0",
54
- "@f5xc-salesdemos/pi-natives": "18.72.0",
55
- "@f5xc-salesdemos/pi-tui": "18.72.0",
56
- "@f5xc-salesdemos/pi-utils": "18.72.0",
51
+ "@f5xc-salesdemos/xcsh-stats": "workspace:*",
52
+ "@f5xc-salesdemos/pi-agent-core": "workspace:*",
53
+ "@f5xc-salesdemos/pi-ai": "workspace:*",
54
+ "@f5xc-salesdemos/pi-natives": "workspace:*",
55
+ "@f5xc-salesdemos/pi-tui": "workspace:*",
56
+ "@f5xc-salesdemos/pi-utils": "workspace:*",
57
57
  "@sinclair/typebox": "^0.34",
58
58
  "@xterm/headless": "^6.0",
59
59
  "ajv": "^8.18",
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "18.72.0",
21
- "commit": "ee865e583dfa21db5c5f5bf07001570c5f7f9086",
22
- "shortCommit": "ee865e5",
20
+ "version": "18.75.3",
21
+ "commit": "6856457e251f8583b237e72702ac395bc7173f42",
22
+ "shortCommit": "6856457",
23
23
  "branch": "main",
24
- "tag": "v18.72.0",
25
- "commitDate": "2026-05-19T19:45:52Z",
26
- "buildDate": "2026-05-19T20:24:28.090Z",
24
+ "tag": "v18.75.3",
25
+ "commitDate": "2026-05-23T08:23:41Z",
26
+ "buildDate": "2026-05-23T17:15:18.019Z",
27
27
  "dirty": false,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5xc-salesdemos/xcsh",
30
30
  "repoSlug": "f5xc-salesdemos/xcsh",
31
- "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/ee865e583dfa21db5c5f5bf07001570c5f7f9086",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.72.0"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/6856457e251f8583b237e72702ac395bc7173f42",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.75.3"
33
33
  };
@@ -10,6 +10,18 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
10
10
  path: { abbreviate: true, maxLength: 40, stripWorkPrefix: true },
11
11
  git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: true },
12
12
  },
13
+ dropOrder: [
14
+ "pr",
15
+ "token_total",
16
+ "cost",
17
+ "git",
18
+ "path",
19
+ "context_pct",
20
+ "plan_mode",
21
+ "model",
22
+ "pi",
23
+ "context_f5xc",
24
+ ],
13
25
  },
14
26
 
15
27
  minimal: {
@@ -20,6 +32,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
20
32
  path: { abbreviate: true, maxLength: 30 },
21
33
  git: { showBranch: true, showStaged: false, showUnstaged: false, showUntracked: false },
22
34
  },
35
+ dropOrder: ["git", "plan_mode", "context_pct", "path", "context_f5xc"],
23
36
  },
24
37
 
25
38
  compact: {
@@ -30,6 +43,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
30
43
  model: { showThinkingLevel: false },
31
44
  git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: false },
32
45
  },
46
+ dropOrder: ["pr", "cost", "git", "context_pct", "plan_mode", "model", "context_f5xc"],
33
47
  },
34
48
 
35
49
  full: {
@@ -52,6 +66,25 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
52
66
  git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: true },
53
67
  time: { format: "24h", showSeconds: false },
54
68
  },
69
+ dropOrder: [
70
+ "time",
71
+ "time_spent",
72
+ "cache_read",
73
+ "token_rate",
74
+ "token_out",
75
+ "token_in",
76
+ "subagents",
77
+ "pr",
78
+ "git",
79
+ "path",
80
+ "cost",
81
+ "context_pct",
82
+ "plan_mode",
83
+ "hostname",
84
+ "model",
85
+ "pi",
86
+ "context_f5xc",
87
+ ],
55
88
  },
56
89
 
57
90
  nerd: {
@@ -77,6 +110,28 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
77
110
  git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: true },
78
111
  time: { format: "24h", showSeconds: true },
79
112
  },
113
+ dropOrder: [
114
+ "context_total",
115
+ "cache_write",
116
+ "session",
117
+ "time",
118
+ "time_spent",
119
+ "cache_read",
120
+ "token_rate",
121
+ "token_out",
122
+ "token_in",
123
+ "subagents",
124
+ "pr",
125
+ "git",
126
+ "path",
127
+ "cost",
128
+ "context_pct",
129
+ "plan_mode",
130
+ "hostname",
131
+ "model",
132
+ "pi",
133
+ "context_f5xc",
134
+ ],
80
135
  },
81
136
 
82
137
  ascii: {
@@ -89,6 +144,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
89
144
  path: { abbreviate: true, maxLength: 40 },
90
145
  git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: true },
91
146
  },
147
+ dropOrder: ["pr", "token_total", "cost", "git", "path", "context_pct", "model", "context_f5xc"],
92
148
  },
93
149
 
94
150
  xcsh: {
@@ -101,6 +157,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
101
157
  git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: true },
102
158
  context_pct: { compact: true },
103
159
  },
160
+ dropOrder: ["plan_mode", "git", "path", "context_pct", "context_f5xc"],
104
161
  },
105
162
 
106
163
  custom: {
@@ -461,6 +461,25 @@ export const SEGMENTS: Record<StatusLineSegmentId, StatusLineSegment> = {
461
461
  return { content: "", visible: false };
462
462
  }
463
463
  },
464
+ truncate(maxWidth: number, _ctx: SegmentContext): RenderedSegment | null {
465
+ try {
466
+ const { truncateF5XCContextSegment } = require("../../../services/f5xc-context-segment");
467
+ const result = truncateF5XCContextSegment(maxWidth);
468
+ if (!result) return null;
469
+ let bg = theme.fgColorAsBg("statusLineContextF5xcBg");
470
+ let fg = theme.getFgAnsi("statusLineContextF5xcFg");
471
+ if (result.tokenHealth === "expiring") {
472
+ bg = theme.fgColorAsBg("statusLineGitDirtyBg");
473
+ fg = theme.getFgAnsi("statusLineGitDirtyFg");
474
+ } else if (result.tokenHealth === "expired") {
475
+ bg = theme.fgColorAsBg("statusLineGitConflictBg");
476
+ fg = theme.getFgAnsi("statusLineGitConflictFg");
477
+ }
478
+ return { ...result, bg, fg };
479
+ } catch {
480
+ return null;
481
+ }
482
+ },
464
483
  },
465
484
  };
466
485
 
@@ -66,6 +66,7 @@ export interface RenderedSegment {
66
66
  export interface StatusLineSegment {
67
67
  id: StatusLineSegmentId;
68
68
  render(ctx: SegmentContext): RenderedSegment;
69
+ truncate?(maxWidth: number, ctx: SegmentContext): RenderedSegment | null;
69
70
  }
70
71
 
71
72
  // ═══════════════════════════════════════════════════════════════════════════
@@ -91,4 +92,5 @@ export interface PresetDef {
91
92
  rightSegments: StatusLineSegmentId[];
92
93
  separator: StatusLineSeparatorStyle;
93
94
  segmentOptions?: StatusLineSegmentOptions;
95
+ dropOrder?: StatusLineSegmentId[];
94
96
  }
@@ -20,7 +20,7 @@ import {
20
20
  type PrCacheContext,
21
21
  } from "./status-line/git-utils";
22
22
  import { getPreset } from "./status-line/presets";
23
- import { renderSegment, type SegmentContext } from "./status-line/segments";
23
+ import { renderSegment, SEGMENTS, type SegmentContext } from "./status-line/segments";
24
24
  import { getSeparator } from "./status-line/separators";
25
25
  import { calculateTokensPerSecond } from "./status-line/token-rate";
26
26
 
@@ -490,8 +490,9 @@ export class StatusLineComponent implements Component {
490
490
 
491
491
  // Collect visible segments (preserving bg/fg metadata)
492
492
  type SegPart = { content: string; bg: string; fg: string };
493
- const collectParts = (segIds: readonly string[]): SegPart[] => {
493
+ const collectParts = (segIds: readonly string[]): { parts: SegPart[]; ids: string[] } => {
494
494
  const parts: SegPart[] = [];
495
+ const ids: string[] = [];
495
496
  for (const segId of segIds) {
496
497
  const rendered = renderSegment(segId as any, ctx);
497
498
  if (rendered.visible && rendered.content) {
@@ -500,13 +501,14 @@ export class StatusLineComponent implements Component {
500
501
  bg: rendered.bg || defaultBg,
501
502
  fg: rendered.fg || defaultFg,
502
503
  });
504
+ ids.push(segId);
503
505
  }
504
506
  }
505
- return parts;
507
+ return { parts, ids };
506
508
  };
507
509
 
508
- const leftParts = collectParts(effectiveSettings.leftSegments);
509
- const rightParts = collectParts(effectiveSettings.rightSegments);
510
+ const { parts: leftParts, ids: leftIds } = collectParts(effectiveSettings.leftSegments);
511
+ const { parts: rightParts, ids: rightIds } = collectParts(effectiveSettings.rightSegments);
510
512
 
511
513
  const runningBackgroundJobs = this.session.getAsyncJobSnapshot()?.running.length ?? 0;
512
514
  if (runningBackgroundJobs > 0) {
@@ -534,6 +536,8 @@ export class StatusLineComponent implements Component {
534
536
  const topFillWidth = Math.max(0, width);
535
537
  const left = [...leftParts];
536
538
  const right = [...rightParts];
539
+ const leftSegIds = [...leftIds];
540
+ const rightSegIds = [...rightIds];
537
541
 
538
542
  const sepWidth = visibleWidth(separatorDef.left);
539
543
  const capWidth = separatorDef.endCaps ? visibleWidth(separatorDef.endCaps.right) : 0;
@@ -541,23 +545,74 @@ export class StatusLineComponent implements Component {
541
545
  const groupWidth = (parts: SegPart[]): number => {
542
546
  if (parts.length === 0) return 0;
543
547
  const partsWidth = parts.reduce((sum, p) => sum + visibleWidth(p.content), 0);
544
- // Each segment gets 1 char padding on each side, separators between segments
545
548
  const sepTotal = Math.max(0, parts.length - 1) * sepWidth;
546
549
  return partsWidth + parts.length * 2 + sepTotal + capWidth;
547
550
  };
548
551
 
549
552
  let leftWidth = groupWidth(left);
550
553
  let rightWidth = groupWidth(right);
551
- const totalWidth = () => leftWidth + rightWidth + (left.length > 0 && right.length > 0 ? 1 : 0);
554
+ const totalWidth = () => leftWidth + rightWidth;
555
+
556
+ if (topFillWidth > 0 && totalWidth() > topFillWidth) {
557
+ const preset = getPreset(effectiveSettings.preset ?? "default");
558
+ const dropOrder = preset.dropOrder;
559
+
560
+ if (dropOrder) {
561
+ type SegRef = { group: "left" | "right"; part: SegPart; segId: string };
562
+ const segRefs: SegRef[] = [
563
+ ...left.map((part, i) => ({ group: "left" as const, part, segId: leftSegIds[i] })),
564
+ ...right.map((part, i) => ({ group: "right" as const, part, segId: rightSegIds[i] })),
565
+ ];
566
+
567
+ const inOrder: SegRef[] = [];
568
+ const notInOrder: SegRef[] = [];
569
+ for (const ref of segRefs) {
570
+ const orderIdx = dropOrder.indexOf(ref.segId as any);
571
+ if (orderIdx === -1) notInOrder.push(ref);
572
+ else inOrder.push(ref);
573
+ }
574
+ inOrder.sort((a, b) => dropOrder.indexOf(a.segId as any) - dropOrder.indexOf(b.segId as any));
575
+ const dropQueue = [...notInOrder, ...inOrder];
576
+
577
+ for (const ref of dropQueue) {
578
+ if (totalWidth() <= topFillWidth) break;
579
+
580
+ const segDef = SEGMENTS[ref.segId as StatusLineSegmentId];
581
+ const group = ref.group === "left" ? left : right;
582
+
583
+ const currentIdx = group.indexOf(ref.part);
584
+ if (currentIdx === -1) continue;
585
+
586
+ if (segDef?.truncate) {
587
+ const currentContentWidth = visibleWidth(ref.part.content);
588
+ const deficit = totalWidth() - topFillWidth;
589
+ const targetWidth = Math.max(1, currentContentWidth - deficit);
590
+ const truncated = segDef.truncate(targetWidth, ctx);
591
+ if (truncated?.visible && truncated.content) {
592
+ group[currentIdx] = {
593
+ content: truncated.content,
594
+ bg: truncated.bg || ref.part.bg,
595
+ fg: truncated.fg || ref.part.fg,
596
+ };
597
+ if (ref.group === "left") leftWidth = groupWidth(left);
598
+ else rightWidth = groupWidth(right);
599
+ continue;
600
+ }
601
+ }
552
602
 
553
- if (topFillWidth > 0) {
554
- while (totalWidth() > topFillWidth && right.length > 0) {
555
- right.pop();
556
- rightWidth = groupWidth(right);
557
- }
558
- while (totalWidth() > topFillWidth && left.length > 0) {
559
- left.pop();
560
- leftWidth = groupWidth(left);
603
+ group.splice(currentIdx, 1);
604
+ if (ref.group === "left") leftWidth = groupWidth(left);
605
+ else rightWidth = groupWidth(right);
606
+ }
607
+ } else {
608
+ while (totalWidth() > topFillWidth && right.length > 0) {
609
+ right.pop();
610
+ rightWidth = groupWidth(right);
611
+ }
612
+ while (totalWidth() > topFillWidth && left.length > 0) {
613
+ left.pop();
614
+ leftWidth = groupWidth(left);
615
+ }
561
616
  }
562
617
  }
563
618
 
@@ -636,13 +691,16 @@ export class StatusLineComponent implements Component {
636
691
  const rightGroup = renderGroup(right, "right");
637
692
  if (!leftGroup && !rightGroup) return "";
638
693
 
639
- if (topFillWidth === 0 || left.length === 0 || right.length === 0) {
694
+ if (topFillWidth === 0 || (left.length === 0 && right.length === 0)) {
640
695
  return leftGroup + (leftGroup && rightGroup ? " " : "") + rightGroup;
641
696
  }
642
697
 
643
- leftWidth = groupWidth(left);
644
- rightWidth = groupWidth(right);
645
- const gapWidth = Math.max(1, topFillWidth - leftWidth - rightWidth);
698
+ const actualLeftWidth = visibleWidth(leftGroup);
699
+ const actualRightWidth = visibleWidth(rightGroup);
700
+ const gapWidth = Math.max(0, topFillWidth - actualLeftWidth - actualRightWidth);
701
+ if (gapWidth === 0) {
702
+ return leftGroup + rightGroup;
703
+ }
646
704
  const gapFill = theme.fg("border", theme.boxRound.horizontal.repeat(gapWidth));
647
705
  return leftGroup + gapFill + rightGroup;
648
706
  }
@@ -424,7 +424,13 @@ function detectAnomalies(
424
424
  // Main generator
425
425
  // ---------------------------------------------------------------------------
426
426
 
427
- export async function generatePipelineReport(options: PipelineReportOptions): Promise<PipelineReportData> {
427
+ export type SfQueryFn = (soql: string, orgAlias?: string) => Promise<Record<string, unknown>[]>;
428
+
429
+ export async function generatePipelineReport(
430
+ options: PipelineReportOptions,
431
+ queryFn?: SfQueryFn,
432
+ ): Promise<PipelineReportData> {
433
+ const query: SfQueryFn = queryFn ?? runSfQuery;
428
434
  const {
429
435
  userIds,
430
436
  orgAlias,
@@ -501,15 +507,15 @@ export async function generatePipelineReport(options: PipelineReportOptions): Pr
501
507
 
502
508
  // Three parallel queries first: combined OLI + renewals + FY booked
503
509
  const [oliRecords, renewalRecords, fyBookedResult] = await Promise.all([
504
- runSfQuery(
510
+ query(
505
511
  `SELECT ${fields} FROM OpportunityLineItem WHERE ${oliTeamScope} AND ${combinedDateFilter} AND ${commonFilters}`,
506
512
  orgAlias,
507
513
  ),
508
- runSfQuery(
514
+ query(
509
515
  `SELECT ${renewalFields} FROM Opportunity WHERE ${renewalWhere} ORDER BY True_ACV__c DESC NULLS LAST`,
510
516
  orgAlias,
511
517
  ),
512
- runSfQuery(fyBookedQuery, orgAlias),
518
+ query(fyBookedQuery, orgAlias),
513
519
  ]);
514
520
  const fyBookedTotal = (fyBookedResult[0]?.total as number) ?? 0;
515
521
 
@@ -548,11 +554,8 @@ export async function generatePipelineReport(options: PipelineReportOptions): Pr
548
554
  const bookedWhere = `${oppTeamScope} AND ForecastCategoryName != 'Omitted' AND Amount > 0 AND IsWon = true AND CloseDate >= ${quarterStart} AND CloseDate <= ${quarterEnd}`;
549
555
 
550
556
  const [fallbackNetNew, fallbackBooked] = await Promise.all([
551
- runSfQuery(`SELECT ${oppFields} FROM Opportunity WHERE ${oppWhere} ORDER BY Amount DESC NULLS LAST`, orgAlias),
552
- runSfQuery(
553
- `SELECT ${oppFields} FROM Opportunity WHERE ${bookedWhere} ORDER BY Amount DESC NULLS LAST`,
554
- orgAlias,
555
- ),
557
+ query(`SELECT ${oppFields} FROM Opportunity WHERE ${oppWhere} ORDER BY Amount DESC NULLS LAST`, orgAlias),
558
+ query(`SELECT ${oppFields} FROM Opportunity WHERE ${bookedWhere} ORDER BY Amount DESC NULLS LAST`, orgAlias),
556
559
  ]);
557
560
 
558
561
  function parseOppFallback(records: Record<string, unknown>[]): LineItemRecord[] {
@@ -602,7 +605,7 @@ export async function generatePipelineReport(options: PipelineReportOptions): Pr
602
605
  const historyFields =
603
606
  "OpportunityId, Opportunity.Name, Opportunity.Account.Name, Field, OldValue, NewValue, CreatedDate";
604
607
  const historyWhere = `OpportunityId IN (${idList}) AND Field IN ('Amount','ForecastCategoryName','StageName') AND CreatedDate = LAST_N_DAYS:7`;
605
- historyRecords = await runSfQuery(
608
+ historyRecords = await query(
606
609
  `SELECT ${historyFields} FROM OpportunityFieldHistory WHERE ${historyWhere} ORDER BY CreatedDate DESC LIMIT 50`,
607
610
  orgAlias,
608
611
  );
@@ -0,0 +1,25 @@
1
+ Generate a comprehensive F5 Distributed Cloud pipeline report for the current fiscal quarter.
2
+
3
+ <instruction>
4
+ Use this tool when the user asks for a "pipeline report", "forecast", "what's my pipeline", "how's the quarter", "show my deals", or any request for a structured view of the sales pipeline. It runs all the necessary queries automatically.
5
+
6
+ Do NOT use sf_query for pipeline reports — sf_pipeline_report handles the multi-query orchestration (net new, booked, renewals, anomaly detection, close distribution) in a single call.
7
+
8
+ Use sf_query for ad-hoc, one-off lookups: specific account checks, MEDDPICC qualification, case queries, or any query outside the pipeline reporting context.
9
+
10
+ Parameters:
11
+ **target_org**: optional — only needed when the default org is not the correct Salesforce instance.
12
+
13
+ What the report includes:
14
+ Booked (closed-won) this quarter by account, broken out by Platform (Distributed Cloud) and Point (Shape + DI).
15
+ Open net-new pipeline, grouped by territory and account, with forecast category (Commit / Best Case / Pipeline).
16
+ Open renewal pipeline (True ACV / Upsell ACV).
17
+ Forecast summary: Commit + Best Case + Pipeline totals for quota-eligible products.
18
+ Top deals by amount with owner, stage, close date, and next steps.
19
+ Close-date distribution: pipeline bucketed by month.
20
+ FY-to-date booked total vs quota (when quota is set in user profile).
21
+ Data quality anomalies: slipped close dates, stalled deals (no activity >30 days), missing territories, urgent renewals closing within 30 days, unclassified SKUs.
22
+ Recent pipeline changes (last 7 days) from OpportunityFieldHistory.
23
+
24
+ After the tool returns, present the report as-is. Do not re-query Salesforce to fill in gaps — the report already contains all available data. If the report shows zero pipeline, mention that the user may need to read `xcsh://salesforce?refresh=true` to refresh their team membership context.
25
+ </instruction>
@@ -3,7 +3,10 @@ Execute SOQL queries against Salesforce via sf CLI. Returns structured results a
3
3
  <instruction>
4
4
  Always provide a `description` parameter (2-4 words) summarizing the query's purpose — it appears in the output header. Examples: "forecast breakdown", "in-quarter pipeline", "closed-won deals", "open opportunities", "stalled deals", "renewal pipeline", "booked this quarter".
5
5
 
6
- Use for pipeline reporting, case management, account intelligence, and ad-hoc data queries.
6
+ For structured pipeline reports, use **sf_pipeline_report** instead of sf_query. sf_pipeline_report runs multi-query orchestration (net new, booked, renewals, anomaly detection, close distribution) in one call.
7
+ Use sf_query for ad-hoc SOQL queries: specific account lookups, MEDDPICC data, case queries, or one-off investigations.
8
+
9
+ Use for ad-hoc data queries, account intelligence, and one-off investigations.
7
10
 
8
11
  Common query templates (substitute {userId} from user profile — read `xcsh://user` to get identifiers.salesforceId):
9
12
 
@@ -106,7 +109,7 @@ For each deal, assess these 8 MEDDPICC elements from available SFDC data:
106
109
  **D**ecision Criteria: Are evaluation criteria documented? Check Opportunity.NextStep, Description.
107
110
  **D**ecision Process: Is the buying process mapped? Check stage progression timeline, paper process.
108
111
  **P**aper Process: Are procurement steps known? Check Opportunity.Description for legal/procurement notes.
109
- **Identify** Pain: Is the business pain articulated? Check Opportunity.Description, discovery notes.
112
+ **I**dentify Pain: Is the business pain articulated? Check Opportunity.Description, discovery notes.
110
113
  **C**hampion: Is there an internal advocate? Check Contact roles for 'Champion' or active engagement.
111
114
  **C**ompetition: Are competitors identified? Check Opportunity.CompetitorName or description.
112
115
  Score each element: Green (validated), Yellow (partially known), Red (unknown/missing).
package/src/sdk.ts CHANGED
@@ -1906,6 +1906,30 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1906
1906
  });
1907
1907
  lastEmittedContext = { name: currentName, namespace: currentNamespace };
1908
1908
  void session.refreshBaseSystemPrompt();
1909
+ // Role 3: background-validate credentials after context switch so the
1910
+ // LLM knows whether the new context is usable. Fire-and-forget — do
1911
+ // not block the context change notification.
1912
+ void (async () => {
1913
+ try {
1914
+ const { status: authStatus, latencyMs } = await service.instance.validateToken({
1915
+ timeoutMs: 5000,
1916
+ });
1917
+ const qualifier =
1918
+ authStatus === "connected"
1919
+ ? `connected${latencyMs ? ` (${latencyMs}ms)` : ""}`
1920
+ : authStatus === "auth_error"
1921
+ ? "credential error -- token may be invalid or expired"
1922
+ : "unreachable -- network error or tenant offline";
1923
+ void session.sendCustomMessage({
1924
+ customType: "context_validation_result",
1925
+ content: `[Auth status: ${authStatus}] Credentials for ${currentTenant}: ${qualifier}.`,
1926
+ display: true,
1927
+ attribution: "agent",
1928
+ });
1929
+ } catch {
1930
+ // Validation failed (e.g., no credentials configured) -- skip silently.
1931
+ }
1932
+ })();
1909
1933
  } catch {
1910
1934
  // ContextService.instance throws if not initialized; skip.
1911
1935
  }
@@ -7,3 +7,43 @@ export function formatContextLabel(status: ContextStatus): string {
7
7
  if (status.tokenHealth === "expiring" || status.tokenHealth === "expired") return `${base} ⚠`;
8
8
  return base;
9
9
  }
10
+
11
+ export function truncateContextLabel(status: ContextStatus, maxWidth: number): string | null {
12
+ if (!status.isConfigured) return null;
13
+
14
+ const left = status.activeContextTenant ?? status.activeContextName ?? "env";
15
+ const right = status.activeContextNamespace ?? "default";
16
+ const hasWarning = status.tokenHealth === "expiring" || status.tokenHealth === "expired";
17
+ const warningSuffix = hasWarning ? " ⚠" : "";
18
+ const warningLen = warningSuffix.length;
19
+ const available = maxWidth - warningLen;
20
+
21
+ if (available < 4) return null;
22
+
23
+ const full = `${left}:${right}`;
24
+ if (full.length <= available) return `${full}${warningSuffix}`;
25
+
26
+ // Tier 1: Truncate right side, keep full left
27
+ if (available >= left.length + 3) {
28
+ const rightMax = available - left.length - 1 - 1;
29
+ return `${left}:${right.slice(0, rightMax)}…${warningSuffix}`;
30
+ }
31
+
32
+ // Tier 2: Abbreviate both sides
33
+ if (available >= 6) {
34
+ const contentBudget = available - 2;
35
+ const leftBudget = Math.max(2, Math.ceil(contentBudget / 2));
36
+ const rightBudget = contentBudget - leftBudget;
37
+ const abbrLeft = left.slice(0, leftBudget);
38
+ const abbrRight = rightBudget > 0 ? right.slice(0, rightBudget) : "";
39
+ return `${abbrLeft}:${abbrRight}…${warningSuffix}`;
40
+ }
41
+
42
+ // Tier 3: Tenant abbreviation only
43
+ if (available >= 4) {
44
+ const leftBudget = available - 2;
45
+ return `${left.slice(0, leftBudget)}:…${warningSuffix}`;
46
+ }
47
+
48
+ return null;
49
+ }
@@ -1,5 +1,5 @@
1
1
  import { ContextService, type TokenHealth } from "./f5xc-context";
2
- import { formatContextLabel } from "./f5xc-context-display";
2
+ import { formatContextLabel, truncateContextLabel } from "./f5xc-context-display";
3
3
 
4
4
  export interface RenderedSegment {
5
5
  content: string;
@@ -21,3 +21,19 @@ export function renderF5XCContextSegment(): RenderedSegment {
21
21
  return { content: "", visible: false };
22
22
  }
23
23
  }
24
+
25
+ export function truncateF5XCContextSegment(maxWidth: number): RenderedSegment | null {
26
+ try {
27
+ const service = ContextService.instance;
28
+ const status = service.getStatus();
29
+
30
+ if (!status.isConfigured) return null;
31
+
32
+ const truncated = truncateContextLabel(status, maxWidth);
33
+ if (truncated === null) return null;
34
+
35
+ return { content: truncated, visible: true, tokenHealth: status.tokenHealth };
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
@@ -54,6 +54,7 @@ import { ResolveTool } from "./resolve";
54
54
  import { reportFindingTool } from "./review";
55
55
  import { SearchToolBm25Tool } from "./search-tool-bm25";
56
56
  import { SfOrgDisplayTool, SfQueryTool, SfSetupTool } from "./sf";
57
+ import { SfPipelineReportTool } from "./sf-pipeline-report";
57
58
  import { loadSshTool } from "./ssh";
58
59
  import { SubmitResultTool } from "./submit-result";
59
60
  import { type TodoPhase, TodoWriteTool } from "./todo-write";
@@ -239,6 +240,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
239
240
  sf_setup: SfSetupTool.createIf,
240
241
  sf_query: SfQueryTool.createIf,
241
242
  sf_org_display: SfOrgDisplayTool.createIf,
243
+ sf_pipeline_report: SfPipelineReportTool.createIf,
242
244
  find: s => new FindTool(s),
243
245
  grep: s => new GrepTool(s),
244
246
  lsp: LspTool.createIf,
@@ -74,4 +74,5 @@ export const toolRenderers: Record<string, ToolRenderer> = {
74
74
  sf_setup: sfToolRenderer as ToolRenderer,
75
75
  sf_query: sfToolRenderer as ToolRenderer,
76
76
  sf_org_display: sfToolRenderer as ToolRenderer,
77
+ sf_pipeline_report: sfToolRenderer as ToolRenderer,
77
78
  };
@@ -0,0 +1,175 @@
1
+ import type {
2
+ AgentTool,
3
+ AgentToolContext,
4
+ AgentToolResult,
5
+ AgentToolUpdateCallback,
6
+ } from "@f5xc-salesdemos/pi-agent-core";
7
+ import { $which, prompt } from "@f5xc-salesdemos/pi-utils";
8
+ import { type Static, Type } from "@sinclair/typebox";
9
+ import { loadSalesforceContext } from "../internal-urls/salesforce-context";
10
+ import { loadProfile } from "../internal-urls/user-profile";
11
+ import { generatePipelineReport, type SfQueryFn } from "../pipeline-report/generator";
12
+ import { renderPipelineReport } from "../pipeline-report/renderer";
13
+ import type { PipelineReportData, PipelineReportOptions } from "../pipeline-report/types";
14
+ import sfPipelineReportDescription from "../prompts/tools/sf-pipeline-report.md" with { type: "text" };
15
+ import type { ToolSession } from ".";
16
+ import { makeExecApi, type SfErrorType, type SfToolDetails } from "./sf";
17
+ import { execSfJson, SfAuthError, SfNoDefaultOrgError, SfSessionExpiredError } from "./sf/exec";
18
+ import { ORG_ALIAS_PATTERN } from "./sf/types";
19
+
20
+ const sfPipelineReportSchema = Type.Object({
21
+ target_org: Type.Optional(Type.String({ description: "Org alias or username to run report against" })),
22
+ });
23
+
24
+ type SfPipelineReportInput = Static<typeof sfPipelineReportSchema>;
25
+
26
+ type SfResult = AgentToolResult<SfToolDetails> & { isError?: boolean };
27
+
28
+ function fiscalQuarterDates(): { start: string; end: string } {
29
+ const now = new Date();
30
+ const m = now.getMonth();
31
+ const y = now.getFullYear();
32
+
33
+ let start: Date;
34
+ let end: Date;
35
+
36
+ if (m >= 10) {
37
+ start = new Date(y, 10, 1);
38
+ end = new Date(y + 1, 1, 0);
39
+ } else if (m === 0) {
40
+ start = new Date(y - 1, 10, 1);
41
+ end = new Date(y, 1, 0);
42
+ } else if (m <= 3) {
43
+ start = new Date(y, 1, 1);
44
+ end = new Date(y, 4, 0);
45
+ } else if (m <= 6) {
46
+ start = new Date(y, 4, 1);
47
+ end = new Date(y, 7, 0);
48
+ } else {
49
+ start = new Date(y, 7, 1);
50
+ end = new Date(y, 10, 0);
51
+ }
52
+
53
+ const fmt = (d: Date) => d.toISOString().split("T")[0]!;
54
+ return { start: fmt(start), end: fmt(end) };
55
+ }
56
+
57
+ function buildQueryFn(cwd: string, orgAlias?: string): SfQueryFn {
58
+ const api = makeExecApi(cwd);
59
+ return async (soql: string, queryOrgAlias?: string): Promise<Record<string, unknown>[]> => {
60
+ const org = queryOrgAlias ?? orgAlias;
61
+ const args = ["data", "query", "--query", soql];
62
+ if (org) args.push("--target-org", org);
63
+ try {
64
+ const result = await execSfJson(api, args, undefined, soql);
65
+ const data = result.result as { records?: Record<string, unknown>[] };
66
+ return data.records ?? [];
67
+ } catch {
68
+ return [];
69
+ }
70
+ };
71
+ }
72
+
73
+ function detectErrorType(err: unknown): SfErrorType {
74
+ if (err instanceof SfAuthError) return "auth_required";
75
+ if (err instanceof SfSessionExpiredError) return "session_expired";
76
+ if (err instanceof SfNoDefaultOrgError) return "no_default_org";
77
+ return "exec_error";
78
+ }
79
+
80
+ export class SfPipelineReportTool implements AgentTool<typeof sfPipelineReportSchema, SfToolDetails> {
81
+ readonly name = "sf_pipeline_report";
82
+ readonly label = "Salesforce Pipeline Report";
83
+ readonly description = prompt.render(sfPipelineReportDescription);
84
+ readonly parameters = sfPipelineReportSchema;
85
+
86
+ constructor(readonly session: ToolSession) {}
87
+
88
+ static createIf(session: ToolSession): SfPipelineReportTool | null {
89
+ if (!$which("sf")) return null;
90
+ return new SfPipelineReportTool(session);
91
+ }
92
+
93
+ async execute(
94
+ _toolCallId: string,
95
+ params: SfPipelineReportInput,
96
+ _signal?: AbortSignal,
97
+ _onUpdate?: AgentToolUpdateCallback<SfToolDetails>,
98
+ _context?: AgentToolContext,
99
+ ): Promise<SfResult> {
100
+ const base: SfToolDetails = { tool: "sf_pipeline_report" };
101
+
102
+ if (params.target_org && !ORG_ALIAS_PATTERN.test(params.target_org)) {
103
+ return {
104
+ content: [
105
+ {
106
+ type: "text",
107
+ text: `Error: invalid org alias "${params.target_org}". Only alphanumeric characters, dots, underscores, hyphens, and @ are allowed.`,
108
+ },
109
+ ],
110
+ isError: true,
111
+ details: { ...base, errorType: "exec_error" },
112
+ };
113
+ }
114
+
115
+ const profile = await loadProfile();
116
+ const sfContext = await loadSalesforceContext();
117
+
118
+ const userId = profile.identifiers?.salesforceId;
119
+ if (!userId) {
120
+ return {
121
+ content: [
122
+ {
123
+ type: "text",
124
+ text: "No Salesforce user ID found. Run sf_setup with action 'status' first, then read `xcsh://user` to confirm your salesforceId is set.",
125
+ },
126
+ ],
127
+ isError: true,
128
+ details: { ...base, errorType: "auth_required" },
129
+ };
130
+ }
131
+
132
+ const partnerId = profile.partner?.id;
133
+ const userIds = partnerId ? [userId, partnerId] : [userId];
134
+ const { start, end } = fiscalQuarterDates();
135
+
136
+ const staleDate = new Date();
137
+ staleDate.setFullYear(staleDate.getFullYear() - 1);
138
+ const staleCutoff = staleDate.toISOString().split("T")[0]!;
139
+
140
+ const partnerName = profile.partner?.name;
141
+ const selfName = [profile.givenName, profile.familyName].filter(Boolean).join(" ").trim();
142
+ const teamMemberNames = partnerName && selfName ? [selfName, partnerName] : selfName ? [selfName] : undefined;
143
+
144
+ const orgAlias = params.target_org ?? sfContext?.orgAlias;
145
+
146
+ const options: PipelineReportOptions = {
147
+ userIds,
148
+ orgAlias,
149
+ quarterStart: start,
150
+ quarterEnd: end,
151
+ staleCutoff,
152
+ confirmedTerritories: profile.territories ?? sfContext?.confirmedTerritories ?? sfContext?.territories,
153
+ teamMemberNames,
154
+ };
155
+
156
+ try {
157
+ const queryFn = buildQueryFn(this.session.cwd, orgAlias);
158
+ const data: PipelineReportData = await generatePipelineReport(options, queryFn);
159
+ const report = renderPipelineReport(data, sfContext?.instanceUrl ?? "");
160
+
161
+ return {
162
+ content: [{ type: "text", text: report }],
163
+ details: { ...base, pipelineReport: data },
164
+ };
165
+ } catch (err) {
166
+ const errorType = detectErrorType(err);
167
+ const message = err instanceof Error ? err.message : String(err);
168
+ return {
169
+ content: [{ type: "text", text: message }],
170
+ isError: true,
171
+ details: { ...base, errorType },
172
+ };
173
+ }
174
+ }
175
+ }
@@ -124,6 +124,11 @@ export const sfToolRenderer = {
124
124
  const text = renderStatusLine({ icon: "pending", title: TOOL_TITLE, description }, uiTheme);
125
125
  return new Text(text, 0, 0);
126
126
  }
127
+ if (args.action === undefined && args.query === undefined) {
128
+ // Pipeline report — no action or query args
129
+ const text = renderStatusLine({ icon: "pending", title: TOOL_TITLE, description: "pipeline report" }, uiTheme);
130
+ return new Text(text, 0, 0);
131
+ }
127
132
  const action = args.action ?? "org";
128
133
  const text = renderStatusLine(
129
134
  {
@@ -244,6 +249,47 @@ export const sfToolRenderer = {
244
249
  const text = result.content?.find(c => c.type === "text")?.text ?? "";
245
250
  addSection(sections, "Result", [uiTheme.fg("toolOutput", text)], uiTheme);
246
251
  }
252
+ } else if (tool === "sf_pipeline_report") {
253
+ description = "pipeline report";
254
+ const report = details?.pipelineReport;
255
+ if (report) {
256
+ const acctCount =
257
+ report.netNew.accounts.length + report.booked.accounts.length + report.renewals.accounts.length;
258
+ meta.push(uiTheme.fg("dim", `${report.lineItemCount} items`));
259
+ meta.push(uiTheme.fg("dim", `${acctCount} accounts`));
260
+ if (report.anomalies.length > 0) {
261
+ meta.push(uiTheme.fg("warning", `${report.anomalies.length} anomalies`));
262
+ }
263
+
264
+ const fc = report.forecast;
265
+ const fmtK = (v: number) =>
266
+ v >= 1_000_000
267
+ ? `$${(v / 1_000_000).toFixed(1)}M`
268
+ : v >= 1_000
269
+ ? `$${(v / 1_000).toFixed(0)}K`
270
+ : `$${v.toFixed(0)}`;
271
+ const summaryLines = [
272
+ ` ${uiTheme.fg("toolTitle", "Commit".padEnd(12))}${uiTheme.fg("toolOutput", fmtK(fc.commit))}`,
273
+ ` ${uiTheme.fg("toolTitle", "Best Case".padEnd(12))}${uiTheme.fg("toolOutput", fmtK(fc.bestCase))}`,
274
+ ` ${uiTheme.fg("toolTitle", "Pipeline".padEnd(12))}${uiTheme.fg("toolOutput", fmtK(fc.pipeline))}`,
275
+ ];
276
+ addSection(sections, "Forecast", summaryLines, uiTheme);
277
+
278
+ const text = result.content?.find(c => c.type === "text")?.text ?? "";
279
+ const reportLines = text.split("\n").map(line => replaceTabs(uiTheme.fg("toolOutput", line)));
280
+ addSection(sections, "Report", reportLines, uiTheme);
281
+
282
+ if (report.anomalies.length > 0) {
283
+ const anomalyLines = report.anomalies.map(a => {
284
+ const icon = a.severity === "warning" ? "[WARN]" : a.severity === "error" ? "[ERR]" : "[INFO]";
285
+ return uiTheme.fg(a.severity === "info" ? "muted" : "warning", ` ${icon} ${a.message}`);
286
+ });
287
+ addSection(sections, "Anomalies", anomalyLines, uiTheme);
288
+ }
289
+ } else {
290
+ const text = result.content?.find(c => c.type === "text")?.text ?? "";
291
+ addSection(sections, "Result", [uiTheme.fg("toolOutput", text)], uiTheme);
292
+ }
247
293
  } else {
248
294
  const text = result.content?.find(c => c.type === "text")?.text ?? "";
249
295
  addSection(sections, "Result", [uiTheme.fg("toolOutput", text)], uiTheme);
package/src/tools/sf.ts CHANGED
@@ -24,7 +24,7 @@ import { deriveQueryLabel, formatOrgDetail, formatOrgTable, formatQueryResults }
24
24
  import type { SfOrg, SfQueryResult, SfRawResult } from "./sf/types";
25
25
  import { ORG_ALIAS_PATTERN } from "./sf/types";
26
26
 
27
- function makeExecApi(cwd: string): SfExecApi {
27
+ export function makeExecApi(cwd: string): SfExecApi {
28
28
  return {
29
29
  async exec(command: string, args: string[], _options?: { signal?: AbortSignal }): Promise<SfRawResult> {
30
30
  // Never pass signal to Bun.spawn and never pre-check signal.aborted.
@@ -96,11 +96,12 @@ type SfOrgDisplayInput = Static<typeof sfOrgDisplaySchema>;
96
96
  export type SfErrorType = "auth_required" | "session_expired" | "no_default_org" | "invalid_query" | "exec_error";
97
97
 
98
98
  export interface SfToolDetails {
99
- tool: "sf_setup" | "sf_query" | "sf_org_display";
99
+ tool: "sf_setup" | "sf_query" | "sf_org_display" | "sf_pipeline_report";
100
100
  action?: string;
101
101
  orgs?: SfOrg[];
102
102
  queryResult?: SfQueryResult;
103
103
  queryDescription?: string;
104
+ pipelineReport?: import("../pipeline-report/types").PipelineReportData;
104
105
  errorType?: SfErrorType;
105
106
  }
106
107