@f5-sales-demo/xcsh 19.74.2 → 19.76.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.74.2",
4
+ "version": "19.76.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": "0.16.1",
58
58
  "@mozilla/readability": "^0.6",
59
- "@f5-sales-demo/xcsh-stats": "19.74.2",
60
- "@f5-sales-demo/pi-agent-core": "19.74.2",
61
- "@f5-sales-demo/pi-ai": "19.74.2",
62
- "@f5-sales-demo/pi-natives": "19.74.2",
63
- "@f5-sales-demo/pi-resource-management": "19.74.2",
64
- "@f5-sales-demo/pi-tui": "19.74.2",
65
- "@f5-sales-demo/pi-utils": "19.74.2",
59
+ "@f5-sales-demo/xcsh-stats": "19.76.0",
60
+ "@f5-sales-demo/pi-agent-core": "19.76.0",
61
+ "@f5-sales-demo/pi-ai": "19.76.0",
62
+ "@f5-sales-demo/pi-natives": "19.76.0",
63
+ "@f5-sales-demo/pi-resource-management": "19.76.0",
64
+ "@f5-sales-demo/pi-tui": "19.76.0",
65
+ "@f5-sales-demo/pi-utils": "19.76.0",
66
66
  "@sinclair/typebox": "^0.34",
67
67
  "@xterm/headless": "^6.0",
68
68
  "ajv": "^8.20",
@@ -26,7 +26,7 @@ export interface ExtensionCapabilities {
26
26
 
27
27
  export const EXTENSION_CAPABILITIES: ExtensionCapabilities = {
28
28
  "version": "0.1.0",
29
- "contractVersion": "1.9.0",
29
+ "contractVersion": "1.10.0",
30
30
  "multiPortDiscovery": true,
31
31
  "protocol": "tool_request/result",
32
32
  "tools": [
@@ -855,6 +855,6 @@ export const EXTENSION_CAPABILITIES: ExtensionCapabilities = {
855
855
  }
856
856
  };
857
857
 
858
- export const EXTENSION_CONTRACT_VERSION = "1.9.0";
858
+ export const EXTENSION_CONTRACT_VERSION = "1.10.0";
859
859
 
860
860
  export const EXTENSION_TOOL_NAMES: readonly string[] = ["ping","capabilities","reload","debug_exec","detach","set_bridge_port","navigate","login","scroll_to","resize_window","tabs_list","tabs_create","tabs_close","click","click_element","click_xy","type_text","form_input","key_press","select_option","label_select","file_upload","read_ax","get_page_text","query_dom","find","wait_for","assert_text","screenshot","read_console","read_network","diag_suspension","diag_bridges","diag_activation","diag_ttft","capture_login_flow","wait_for_api_response","get_page_context","javascript_tool","browser_batch","set_explain_mode","annotate"];
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": "0.1.0",
3
- "contractVersion": "1.9.0",
3
+ "contractVersion": "1.10.0",
4
4
  "multiPortDiscovery": true,
5
5
  "protocol": "tool_request/result",
6
6
  "tools": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "contractVersion": "1.9.0",
2
+ "contractVersion": "1.10.0",
3
3
  "schemas": {
4
4
  "chat_request": {
5
5
  "type": "object",
@@ -32,6 +32,7 @@ import {
32
32
  } from "./chat-protocol";
33
33
  import { CONSOLE_ROUTES } from "./console-routes.generated";
34
34
  import type { BridgeServer } from "./extension-bridge";
35
+ import { type ClientHost, hostProfile } from "./host-profiles";
35
36
  import { interpretPageState } from "./page-state-interpreter";
36
37
  import { chatSpans } from "./ttft-spans";
37
38
 
@@ -163,7 +164,7 @@ export class ChatHandler {
163
164
  });
164
165
  chat.unsubscribe = unsubscribe;
165
166
 
166
- const prompt = composeChatPrompt(req.text, req.context, req.mode);
167
+ const prompt = composeChatPrompt(req.text, req.context, req.mode, this.#server.clientHost);
167
168
 
168
169
  try {
169
170
  chat.promptAt = Date.now();
@@ -435,49 +436,6 @@ export function classifyChatErrorReason(message: string): ChatErrorReason | unde
435
436
  return undefined;
436
437
  }
437
438
 
438
- /**
439
- * Chrome-extension self-awareness prompt. Injected when xcsh is serving a browser
440
- * chat (not the CLI TUI). Tells the LLM it's in a Chrome side panel alongside the
441
- * F5 XC console, what tools it has, and how to behave differently from the CLI.
442
- */
443
- const CHROME_CHAT_SYSTEM_PROMPT = `[System: You are xcsh, an AI assistant for the F5 Distributed Cloud console, running as a Chrome browser side panel — not a terminal CLI.
444
-
445
- CRITICAL: ALWAYS respond with TEXT FIRST. Do NOT jump straight to tool calls. The user sees a chat panel and expects a conversational text response, not silence while tools run in the background. For questions ("what page am I on?", "what is this?"), answer with text using the page context below — no tools needed. Only use tools when the user explicitly asks you to DO something (create, navigate, click, modify).
446
-
447
- CONTEXT: The user sees a small chat window alongside the F5 XC admin console. You receive page-aware context each turn: the current URL (interpreted as workspace/resource/CRUD operation/namespace), the API resource JSON, and the accessibility tree. USE THIS CONTEXT to answer questions — don't call tools to find information you already have.
448
-
449
- BEHAVIOR:
450
- - Respond concisely with markdown. The chat panel is narrow — avoid long code blocks.
451
- - You KNOW which page the user is on (injected below). Don't ask "what page are you on?" — tell them.
452
- - For questions about the page/resource: answer from the injected context. No tools.
453
- - If a blocking popup/survey appears, dismiss it by clicking the close button.
454
- - If on the LOGIN page: use the login tool to log in. The login tool handles ALL environments — production (*.console.ves.volterra.io) AND staging (*.staging.volterra.us, login-staging.volterra.us). Do NOT claim the login tool is broken, unsupported, or doesn't work for staging — it does.
455
-
456
- BROWSER AUTOMATION (when the user asks to create/modify/navigate resources):
457
- - You are IN a Chrome browser. The active console tab is your workspace — use IT.
458
- - For create/modify/delete: call catalog_workflow_runner IMMEDIATELY with ONE tool call per resource:
459
- {"resource": "health-check", "operation": "create", "params": {"name": "foo", "namespace": "demo"}, "presentation": "guided"}
460
- Do NOT read API specs first, do NOT create todos, do NOT orchestrate multi-step tool chains. The catalog_workflow_runner handles ALL the form navigation internally.
461
- - Say a brief text message BEFORE the tool call: "Creating health check **foo** — watch the browser." Then call the tool. Nothing else.
462
- - The human is WATCHING the form automation (fingerprint-before-click, highlights, ~1.5s/step). Do NOT use background API calls.
463
- - The browser may be at 85% zoom — automation handles coordinates at any zoom.
464
- - The console catalog has workflows for 100+ F5 XC resources.
465
- - Do NOT open new tabs — drive the existing console tab.
466
-
467
- MULTI-RESOURCE REQUESTS (when the user asks to create several resources in one prompt):
468
- - Create resources in DEPENDENCY ORDER: health checks first, then origin pools (which reference health checks), then load balancers (which reference origin pools and app firewalls).
469
- - After each catalog_workflow_runner call completes, IMMEDIATELY proceed to the next resource. Do NOT inspect, verify, click into, or navigate to the resource you just created. Do NOT open the JSON view. Do NOT read the page to confirm — the tool already confirmed success. Move directly to the next creation.
470
- - Between resources, say ONE short line: "Health check created. Now creating origin pool **bar** — watch the browser." Then call the next tool.
471
- - NEVER navigate to a list/detail/JSON view between creations. Stay on the automation path.
472
-
473
- SAFETY — NEVER DO THESE:
474
- - NEVER kill, stop, or manage processes on port 19222 — that is YOUR OWN bridge. Killing it kills you.
475
- - NEVER run lsof, fuser, kill, or pkill on the bridge port. You ARE the bridge.
476
- - NEVER use bash/shell tools to manage xcsh processes, ports, or the debugger connection.
477
- - NEVER run commands that would terminate your own process or the WebSocket server.]
478
-
479
- `;
480
-
481
439
  const MODE_INSTRUCTIONS: Record<InteractionMode, string> = {
482
440
  educational: "Explain concepts and settings in depth. Help the user understand what they're looking at and why.",
483
441
  presentation: "Guide a structured walkthrough. Narrate each step clearly for a live audience.",
@@ -486,77 +444,94 @@ const MODE_INSTRUCTIONS: Record<InteractionMode, string> = {
486
444
  annotation: "Create on-page teaching annotations that highlight key elements and explain their purpose.",
487
445
  };
488
446
 
489
- export function composeChatPrompt(text: string, context: PageContextSnapshot | null, mode: InteractionMode): string {
447
+ export function composeChatPrompt(
448
+ text: string,
449
+ context: PageContextSnapshot | null,
450
+ mode: InteractionMode,
451
+ host: ClientHost | null,
452
+ ): string {
490
453
  const parts: string[] = [];
491
454
 
492
- // Chrome-extension self-awareness: establishes the agent's browser context.
493
- parts.push(CHROME_CHAT_SYSTEM_PROMPT);
494
- parts.push(`[Chat mode: ${mode}] ${MODE_INSTRUCTIONS[mode]}`);
455
+ // Host-aware self-awareness: the Chrome extension gets the browser panel prompt;
456
+ // an Office add-in (excel/powerpoint/word) gets its document-assistant prompt. A
457
+ // null/unannounced host falls back to the Chrome profile.
458
+ const profile = hostProfile(host);
459
+ parts.push(profile.systemPrompt);
460
+
461
+ // Browser hosts ALSO get an interaction mode + the page-context block. Document
462
+ // hosts get NEITHER: Office sends no page context and has no browser modes; its
463
+ // tools + document state arrive at runtime via set_host_tools.
464
+ if (profile.kind === "browser") {
465
+ parts.push(`[Chat mode: ${mode}] ${MODE_INSTRUCTIONS[mode]}`);
466
+ if (context) composeBrowserPageContext(parts, context);
467
+ }
495
468
 
496
- if (context) {
497
- // Interpret the raw URL into structured page state (workspace, resource,
498
- // CRUD operation, namespace) using deterministic route-pattern matching
499
- // against console_ui.yaml — the LLM sees "origin_pool LIST in demo" not a
500
- // raw URL it must guess about.
501
- const pageState = interpretPageState(context.url, null, CONSOLE_ROUTES);
469
+ parts.push("");
470
+ parts.push(text);
471
+ return parts.join("\n");
472
+ }
502
473
 
503
- parts.push("");
504
- parts.push(`[Page contextcaptured at ${new Date(context.capturedAt).toISOString()}]`);
474
+ /** Append the Chrome-only page-context block (interpreted page state + API body +
475
+ * accessibility tree) to `parts`. Browser hosts only Office sends no context. */
476
+ function composeBrowserPageContext(parts: string[], context: PageContextSnapshot): void {
477
+ // Interpret the raw URL into structured page state (workspace, resource,
478
+ // CRUD operation, namespace) using deterministic route-pattern matching
479
+ // against console_ui.yaml — the LLM sees "origin_pool LIST in demo" not a
480
+ // raw URL it must guess about.
481
+ const pageState = interpretPageState(context.url, null, CONSOLE_ROUTES);
505
482
 
506
- // Tenant + environment (the LLM knows WHICH tenant on WHICH environment).
507
- if (pageState.tenant || pageState.environment) {
508
- parts.push(`Tenant: ${pageState.tenant ?? "unknown"} (${pageState.environment ?? "unknown"} environment)`);
509
- }
483
+ parts.push("");
484
+ parts.push(`[Page context — captured at ${new Date(context.capturedAt).toISOString()}]`);
510
485
 
511
- // Structured page state (the interpreted context the LLM acts on).
512
- if (pageState.operation === "login") {
513
- parts.push("Page: LOGIN session expired or first login. The user is on the Keycloak authentication page.");
514
- parts.push(
515
- "Use the login tool with their email and password to log in. The login tool handles both production and staging Keycloak (including login-staging.volterra.us). Do NOT bypass it or claim it doesn't support staging.",
516
- );
517
- } else if (pageState.resource) {
518
- const opLabel = pageState.operation.toUpperCase();
519
- const nsLabel = pageState.namespace ? ` in namespace "${pageState.namespace}"` : "";
520
- const nameLabel = pageState.resourceName ? ` — instance "${pageState.resourceName}"` : "";
521
- parts.push(`Page: ${pageState.resource} ${opLabel}${nameLabel} (workspace: ${pageState.workspace}${nsLabel})`);
522
- } else {
523
- parts.push(`Page: ${context.title} (unrecognized resource)`);
524
- }
525
- if (pageState.modalBlocking) {
526
- parts.push(`⚠ Modal blocking: ${pageState.modalText ?? "unknown overlay"}`);
527
- }
528
- parts.push(`URL: ${context.url}`);
529
- parts.push(`Title: ${context.title}`);
530
-
531
- if (context.api) {
532
- parts.push(
533
- `API resource (${context.api.resourceType ?? "unknown"}, status ${context.api.status}): ${context.api.url}`,
534
- );
535
- if (context.api.body) {
536
- const body =
537
- typeof context.api.body === "string" ? context.api.body : JSON.stringify(context.api.body, null, 2);
538
- parts.push(body);
539
- }
540
- if (context.api.truncated) {
541
- parts.push("[API body was truncated]");
542
- }
543
- }
486
+ // Tenant + environment (the LLM knows WHICH tenant on WHICH environment).
487
+ if (pageState.tenant || pageState.environment) {
488
+ parts.push(`Tenant: ${pageState.tenant ?? "unknown"} (${pageState.environment ?? "unknown"} environment)`);
489
+ }
544
490
 
545
- if (context.ax) {
546
- const ax = typeof context.ax === "string" ? context.ax : JSON.stringify(context.ax);
547
- parts.push(`Accessibility tree: ${ax}`);
491
+ // Structured page state (the interpreted context the LLM acts on).
492
+ if (pageState.operation === "login") {
493
+ parts.push("Page: LOGIN — session expired or first login. The user is on the Keycloak authentication page.");
494
+ parts.push(
495
+ "Use the login tool with their email and password to log in. The login tool handles both production and staging Keycloak (including login-staging.volterra.us). Do NOT bypass it or claim it doesn't support staging.",
496
+ );
497
+ } else if (pageState.resource) {
498
+ const opLabel = pageState.operation.toUpperCase();
499
+ const nsLabel = pageState.namespace ? ` in namespace "${pageState.namespace}"` : "";
500
+ const nameLabel = pageState.resourceName ? ` — instance "${pageState.resourceName}"` : "";
501
+ parts.push(`Page: ${pageState.resource} ${opLabel}${nameLabel} (workspace: ${pageState.workspace}${nsLabel})`);
502
+ } else {
503
+ parts.push(`Page: ${context.title} (unrecognized resource)`);
504
+ }
505
+ if (pageState.modalBlocking) {
506
+ parts.push(`⚠ Modal blocking: ${pageState.modalText ?? "unknown overlay"}`);
507
+ }
508
+ parts.push(`URL: ${context.url}`);
509
+ parts.push(`Title: ${context.title}`);
510
+
511
+ if (context.api) {
512
+ parts.push(
513
+ `API resource (${context.api.resourceType ?? "unknown"}, status ${context.api.status}): ${context.api.url}`,
514
+ );
515
+ if (context.api.body) {
516
+ const body =
517
+ typeof context.api.body === "string" ? context.api.body : JSON.stringify(context.api.body, null, 2);
518
+ parts.push(body);
548
519
  }
549
-
550
- if (context.truncated) {
551
- parts.push("[Page context was truncated]");
520
+ if (context.api.truncated) {
521
+ parts.push("[API body was truncated]");
552
522
  }
523
+ }
553
524
 
554
- parts.push("---");
525
+ if (context.ax) {
526
+ const ax = typeof context.ax === "string" ? context.ax : JSON.stringify(context.ax);
527
+ parts.push(`Accessibility tree: ${ax}`);
555
528
  }
556
529
 
557
- parts.push("");
558
- parts.push(text);
559
- return parts.join("\n");
530
+ if (context.truncated) {
531
+ parts.push("[Page context was truncated]");
532
+ }
533
+
534
+ parts.push("---");
560
535
  }
561
536
 
562
537
  export function classifyReferenceKind(url: string): "doc" | "console" {
@@ -15,6 +15,11 @@ import type {
15
15
  RpcHostToolUpdate,
16
16
  } from "../host-tools/types";
17
17
 
18
+ // The client host announced on the `hello` handshake (contract 1.10.0). Re-exported
19
+ // (type-only, fully erased) so browser-safe consumers — the office-pane bundle —
20
+ // can share the wire vocabulary without importing the host-profiles prompt data.
21
+ export type { ClientHost } from "./host-profiles";
22
+
18
23
  // ---------------------------------------------------------------------------
19
24
  // Page context snapshot (auto-attached by extension to every chat_request)
20
25
  // ---------------------------------------------------------------------------
@@ -97,3 +97,19 @@ export const BROWSER_TOOL_NAMES: readonly string[] = [
97
97
  "annotate",
98
98
  "set_explain_mode",
99
99
  ];
100
+
101
+ /**
102
+ * Builtin agent tools scoped into the headless OFFICE bridge (`xcsh office serve`).
103
+ * The Office task pane drives a document (Excel/PowerPoint/Word), NOT a browser, so
104
+ * it must get NONE of the {@link BROWSER_TOOL_NAMES} — those would be hallucinated
105
+ * against a host with no browser to drive. The document's own tools arrive at
106
+ * runtime over the bridge via `set_host_tools`; this list is only the minimal
107
+ * general-purpose, host-neutral builtin toolset a document assistant can safely use.
108
+ *
109
+ * NOTE: an EMPTY list cannot express "no builtin tools" — createAgentSession/createTools
110
+ * treat `[]` as "unscoped" and hand back the FULL builtin registry (bash/edit/python/
111
+ * browser/…). So we pass an explicit minimal set instead. `calc` is the one builtin
112
+ * that is pure computation — no browser, no shell, no filesystem, no network — and is
113
+ * genuinely useful for spreadsheet/document math.
114
+ */
115
+ export const OFFICE_TOOL_NAMES: readonly string[] = ["calc"];
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { Server, ServerWebSocket } from "bun";
3
3
  import { LOCALIP_HOST } from "./bridge-cert";
4
+ import { type ClientHost, isClientHost } from "./host-profiles";
4
5
 
5
6
  export interface ToolResult {
6
7
  content: unknown;
@@ -177,6 +178,9 @@ export class BridgeServer {
177
178
  #onMessage: Array<(msg: Record<string, unknown>) => void> = [];
178
179
  /** Heartbeat interval that sends pings to keep the MV3 service worker alive (sweep + chat). */
179
180
  #heartbeat: ReturnType<typeof setInterval> | null = null;
181
+ /** The client host learned from the `hello` handshake (null until a client
182
+ * announces one; the Chrome extension omits it → stays null → chrome profile). */
183
+ #clientHost: ClientHost | null = null;
180
184
  /** Provider of this process's tenant identity, answering the `hello` handshake. */
181
185
  #sessionInfo:
182
186
  | (() => {
@@ -203,6 +207,12 @@ export class BridgeServer {
203
207
  return this.#clients.size > 0;
204
208
  }
205
209
 
210
+ /** The client host announced by the connected client's `hello` (null until a
211
+ * client announces one). Read by the ChatHandler to pick the host-aware prompt. */
212
+ get clientHost(): ClientHost | null {
213
+ return this.#clientHost;
214
+ }
215
+
206
216
  /** Number of connected extension clients (channels). */
207
217
  get connectedCount(): number {
208
218
  return this.#clients.size;
@@ -364,7 +374,7 @@ export class BridgeServer {
364
374
 
365
375
  #handleMessage(ws: ServerWebSocket<undefined>, message: string | Buffer): void {
366
376
  const text = typeof message === "string" ? message : message.toString("utf8");
367
- let msg: { type?: string; id?: string; content?: unknown; is_error?: boolean };
377
+ let msg: { type?: string; id?: string; content?: unknown; is_error?: boolean; host?: unknown };
368
378
  try {
369
379
  msg = JSON.parse(text);
370
380
  } catch {
@@ -379,6 +389,11 @@ export class BridgeServer {
379
389
  ws.send(JSON.stringify({ type: "pong" }));
380
390
  } else if (msg.type === "hello") {
381
391
  // Identity handshake: tell the extension which tenant this process serves.
392
+ // Record the announced client host (contract 1.10.0): Office sends its
393
+ // lowercased Office.context.host ("excel"|"powerpoint"|"word"); the Chrome
394
+ // extension omits it → clientHost stays null → the browser profile. Invalid
395
+ // values are ignored (null retained). Echoed back so the client can confirm.
396
+ if (isClientHost(msg.host)) this.#clientHost = msg.host;
382
397
  const info = this.#sessionInfo?.() ?? {
383
398
  tenant: null,
384
399
  env: null,
@@ -396,6 +411,7 @@ export class BridgeServer {
396
411
  env: info.env,
397
412
  apiUrl: info.apiUrl,
398
413
  contextBound: info.contextBound,
414
+ host: this.#clientHost,
399
415
  pid: process.pid,
400
416
  wssPort: this.wssPort,
401
417
  canConfigureProvider: true,
@@ -21,7 +21,7 @@ import { deriveTenantEnv } from "../services/xcsh-env";
21
21
  import { resolveBridgeTls } from "./bridge-cert";
22
22
  import { ChatHandler } from "./chat-handler";
23
23
  import { type BridgeServer, startBridgeServer } from "./extension-bridge";
24
- import { BROWSER_TOOL_NAMES, createExtensionBridgeTools, EXTENSION_AGENT_TOOL_NAMES } from "./extension-bridge-tools";
24
+ import { OFFICE_TOOL_NAMES } from "./extension-bridge-tools";
25
25
  import { setSharedBridgeServer } from "./provider";
26
26
 
27
27
  /** A running headless bridge + a teardown that disposes the chat handler and
@@ -67,7 +67,6 @@ export interface HeadlessBridgeDeps {
67
67
  resolveBridgeTls: typeof resolveBridgeTls;
68
68
  startBridgeServer: typeof startBridgeServer;
69
69
  setSharedBridgeServer: typeof setSharedBridgeServer;
70
- createExtensionBridgeTools: typeof createExtensionBridgeTools;
71
70
  createAgentSession: typeof createAgentSession;
72
71
  ChatHandlerCtor: typeof ChatHandler;
73
72
  }
@@ -95,7 +94,6 @@ const defaultDeps: HeadlessBridgeDeps = {
95
94
  resolveBridgeTls,
96
95
  startBridgeServer,
97
96
  setSharedBridgeServer,
98
- createExtensionBridgeTools,
99
97
  createAgentSession,
100
98
  ChatHandlerCtor: ChatHandler,
101
99
  };
@@ -130,15 +128,17 @@ export async function startHeadlessChatBridge(deps: HeadlessBridgeDeps = default
130
128
  // selectProvider() reuses a dead bridge. The caller (startOfficeServe) treats
131
129
  // the rethrow as a non-fatal "pane only" fallback.
132
130
  try {
133
- // Turn the extension's browser actions into bridge-proxying CustomTools, then
134
- // create ONE headless session scoped to the browser tools (Office document
135
- // tools arrive at runtime via set_host_tools).
136
- const extensionTools = deps.createExtensionBridgeTools(bridge);
131
+ // Create ONE headless Office session scoped to the minimal general builtin set
132
+ // (OFFICE_TOOL_NAMES) with NO browser tools neither the browser BUILTINS
133
+ // (navigate/click/…) nor the bridge-proxying browser CUSTOM tools, both of which
134
+ // would be hallucinated in a document task pane (there is no browser to drive).
135
+ // The document's own tools (Excel/Word/PowerPoint) arrive at runtime via
136
+ // set_host_tools; chat over xcsh's configured provider needs no browser tooling.
137
137
  const { session } = await deps.createAgentSession({
138
138
  cwd,
139
139
  hasUI: false,
140
- toolNames: [...new Set([...BROWSER_TOOL_NAMES, ...EXTENSION_AGENT_TOOL_NAMES])],
141
- customTools: extensionTools,
140
+ toolNames: [...OFFICE_TOOL_NAMES],
141
+ customTools: [],
142
142
  // Headless: no MCP/LSP/extension discovery — lean, no network/blocking prompts.
143
143
  enableMCP: false,
144
144
  enableLsp: false,
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Host-aware system prompts for the bridge chat engine.
3
+ *
4
+ * The same `hello`/`hello_ack` bridge serves two very different clients: the
5
+ * Chrome extension (a browser side panel driving the F5 XC console) and the
6
+ * Office add-in task pane (Excel / PowerPoint / Word, driving the open
7
+ * document). A `host` field on the handshake tells the engine which one it is,
8
+ * so `composeChatPrompt` can inject the RIGHT self-awareness prompt instead of
9
+ * unconditionally telling every client it is a Chrome browser panel.
10
+ *
11
+ * Browser-safe: pure string/data module, no node/bun imports — so it can be
12
+ * type-imported by the office-pane's browser bundle via the native contract.
13
+ */
14
+
15
+ /**
16
+ * The client hosts the bridge can serve, as lowercase wire values. Office maps
17
+ * its `Office.context.host` ("Excel"|"PowerPoint"|"Word") to these; the Chrome
18
+ * extension is "chrome". Outlook is intentionally absent — the add-in omits the
19
+ * host for hosts without a document assistant, so the engine falls back to the
20
+ * default profile.
21
+ */
22
+ export type ClientHost = "chrome" | "excel" | "powerpoint" | "word";
23
+
24
+ /** Every {@link ClientHost} value, for iteration + validation. */
25
+ export const CLIENT_HOSTS: readonly ClientHost[] = ["chrome", "excel", "powerpoint", "word"];
26
+
27
+ /** Runtime guard: true when `v` is a valid {@link ClientHost} wire value. */
28
+ export function isClientHost(v: unknown): v is ClientHost {
29
+ return typeof v === "string" && (CLIENT_HOSTS as readonly string[]).includes(v);
30
+ }
31
+
32
+ /**
33
+ * A host profile pairs a `kind` (browser vs document) with the self-awareness
34
+ * system prompt for that host. `kind` gates browser-only behavior in
35
+ * `composeChatPrompt` (interaction modes + the page-context block) — document
36
+ * hosts get neither.
37
+ */
38
+ export interface HostProfile {
39
+ kind: "browser" | "document";
40
+ systemPrompt: string;
41
+ }
42
+
43
+ /**
44
+ * Chrome-extension self-awareness prompt. Injected when xcsh is serving a browser
45
+ * chat (not the CLI TUI). Tells the LLM it's in a Chrome side panel alongside the
46
+ * F5 XC console, what tools it has, and how to behave differently from the CLI.
47
+ */
48
+ export const CHROME_CHAT_SYSTEM_PROMPT = `[System: You are xcsh, an AI assistant for the F5 Distributed Cloud console, running as a Chrome browser side panel — not a terminal CLI.
49
+
50
+ CRITICAL: ALWAYS respond with TEXT FIRST. Do NOT jump straight to tool calls. The user sees a chat panel and expects a conversational text response, not silence while tools run in the background. For questions ("what page am I on?", "what is this?"), answer with text using the page context below — no tools needed. Only use tools when the user explicitly asks you to DO something (create, navigate, click, modify).
51
+
52
+ CONTEXT: The user sees a small chat window alongside the F5 XC admin console. You receive page-aware context each turn: the current URL (interpreted as workspace/resource/CRUD operation/namespace), the API resource JSON, and the accessibility tree. USE THIS CONTEXT to answer questions — don't call tools to find information you already have.
53
+
54
+ BEHAVIOR:
55
+ - Respond concisely with markdown. The chat panel is narrow — avoid long code blocks.
56
+ - You KNOW which page the user is on (injected below). Don't ask "what page are you on?" — tell them.
57
+ - For questions about the page/resource: answer from the injected context. No tools.
58
+ - If a blocking popup/survey appears, dismiss it by clicking the close button.
59
+ - If on the LOGIN page: use the login tool to log in. The login tool handles ALL environments — production (*.console.ves.volterra.io) AND staging (*.staging.volterra.us, login-staging.volterra.us). Do NOT claim the login tool is broken, unsupported, or doesn't work for staging — it does.
60
+
61
+ BROWSER AUTOMATION (when the user asks to create/modify/navigate resources):
62
+ - You are IN a Chrome browser. The active console tab is your workspace — use IT.
63
+ - For create/modify/delete: call catalog_workflow_runner IMMEDIATELY with ONE tool call per resource:
64
+ {"resource": "health-check", "operation": "create", "params": {"name": "foo", "namespace": "demo"}, "presentation": "guided"}
65
+ Do NOT read API specs first, do NOT create todos, do NOT orchestrate multi-step tool chains. The catalog_workflow_runner handles ALL the form navigation internally.
66
+ - Say a brief text message BEFORE the tool call: "Creating health check **foo** — watch the browser." Then call the tool. Nothing else.
67
+ - The human is WATCHING the form automation (fingerprint-before-click, highlights, ~1.5s/step). Do NOT use background API calls.
68
+ - The browser may be at 85% zoom — automation handles coordinates at any zoom.
69
+ - The console catalog has workflows for 100+ F5 XC resources.
70
+ - Do NOT open new tabs — drive the existing console tab.
71
+
72
+ MULTI-RESOURCE REQUESTS (when the user asks to create several resources in one prompt):
73
+ - Create resources in DEPENDENCY ORDER: health checks first, then origin pools (which reference health checks), then load balancers (which reference origin pools and app firewalls).
74
+ - After each catalog_workflow_runner call completes, IMMEDIATELY proceed to the next resource. Do NOT inspect, verify, click into, or navigate to the resource you just created. Do NOT open the JSON view. Do NOT read the page to confirm — the tool already confirmed success. Move directly to the next creation.
75
+ - Between resources, say ONE short line: "Health check created. Now creating origin pool **bar** — watch the browser." Then call the next tool.
76
+ - NEVER navigate to a list/detail/JSON view between creations. Stay on the automation path.
77
+
78
+ SAFETY — NEVER DO THESE:
79
+ - NEVER kill, stop, or manage processes on port 19222 — that is YOUR OWN bridge. Killing it kills you.
80
+ - NEVER run lsof, fuser, kill, or pkill on the bridge port. You ARE the bridge.
81
+ - NEVER use bash/shell tools to manage xcsh processes, ports, or the debugger connection.
82
+ - NEVER run commands that would terminate your own process or the WebSocket server.]
83
+
84
+ `;
85
+
86
+ /**
87
+ * Excel task-pane self-awareness prompt. The assistant works the OPEN workbook
88
+ * via host tools (arriving at runtime over the bridge), thinking in cells,
89
+ * ranges, and formula dependencies.
90
+ */
91
+ const EXCEL_CHAT_SYSTEM_PROMPT = `[System: You are xcsh, an AI assistant running as a task pane inside Microsoft Excel — not a terminal CLI. You help the user with the OPEN workbook using the Excel host tools available to you.
92
+
93
+ CRITICAL: ALWAYS respond with TEXT FIRST — the user sees a chat pane and expects a conversational reply, not silence while tools run. Answer questions from the data you read; only WRITE to the workbook when the user asks you to.
94
+
95
+ CONTEXT: You can only access the open workbook. Think in cells, ranges, and — above all — FORMULAS and their dependencies:
96
+ - Preserve formula relationships. When you change a cell, let dependent cells recompute; do not overwrite a formula with its current value unless asked.
97
+ - Warn the user before overwriting existing cell contents.
98
+ - Cite specific cells and ranges precisely (e.g. A1, Sheet1!B2:B10) so the user can follow along.
99
+
100
+ BEHAVIOR:
101
+ - Respond concisely with markdown. The task pane is narrow — avoid long code blocks.
102
+ - Read the workbook to answer questions about its data; do not guess.
103
+ - Make edits only when asked, one clear change at a time, and say what you changed and where.]
104
+
105
+ `;
106
+
107
+ /**
108
+ * PowerPoint task-pane self-awareness prompt. The assistant works the OPEN
109
+ * presentation via host tools, thinking in slides, shapes, and the slide master.
110
+ */
111
+ const POWERPOINT_CHAT_SYSTEM_PROMPT = `[System: You are xcsh, an AI assistant running as a task pane inside Microsoft PowerPoint — not a terminal CLI. You help the user with the OPEN presentation using the PowerPoint host tools available to you.
112
+
113
+ CRITICAL: ALWAYS respond with TEXT FIRST — the user sees a chat pane and expects a conversational reply, not silence while tools run. Answer questions from what you read; only edit the deck when the user asks you to.
114
+
115
+ CONTEXT: You can only access the open presentation. Think in slides, shapes, and the slide master:
116
+ - Conform any new content to the deck's existing template, fonts, and colors — do not introduce a different look.
117
+ - Make pinpoint, per-slide edits. Do NOT regenerate the whole deck to change one thing.
118
+ - Refer to slides by number so the user can follow along.
119
+
120
+ BEHAVIOR:
121
+ - Respond concisely with markdown. The task pane is narrow — avoid long code blocks.
122
+ - Read the presentation to answer questions about it; do not guess.
123
+ - Make edits only when asked, one clear change at a time, and say which slide you changed.]
124
+
125
+ `;
126
+
127
+ /**
128
+ * Word task-pane self-awareness prompt. The assistant works the OPEN document
129
+ * via host tools, thinking in paragraphs, the selection, comments, and tracked
130
+ * changes.
131
+ */
132
+ const WORD_CHAT_SYSTEM_PROMPT = `[System: You are xcsh, an AI assistant running as a task pane inside Microsoft Word — not a terminal CLI. You help the user with the OPEN document using the Word host tools available to you.
133
+
134
+ CRITICAL: ALWAYS respond with TEXT FIRST — the user sees a chat pane and expects a conversational reply, not silence while tools run. Answer questions from what you read; only edit the document when the user asks you to.
135
+
136
+ CONTEXT: You can only access the open document. Think in paragraphs, the current selection, comments, and tracked changes:
137
+ - Preserve the document's styles and numbering — do not flatten formatting.
138
+ - Describe your edits so the user can review them, and prefer changes the user can accept or reject.
139
+ - When the user refers to "the selection" (or "this"), act on the current selection.
140
+
141
+ BEHAVIOR:
142
+ - Respond concisely with markdown. The task pane is narrow — avoid long code blocks.
143
+ - Read the document to answer questions about it; do not guess.
144
+ - Make edits only when asked, one clear change at a time, and say what you changed.]
145
+
146
+ `;
147
+
148
+ /** The self-awareness profile per client host. */
149
+ export const HOST_PROFILES: Record<ClientHost, HostProfile> = {
150
+ chrome: { kind: "browser", systemPrompt: CHROME_CHAT_SYSTEM_PROMPT },
151
+ excel: { kind: "document", systemPrompt: EXCEL_CHAT_SYSTEM_PROMPT },
152
+ powerpoint: { kind: "document", systemPrompt: POWERPOINT_CHAT_SYSTEM_PROMPT },
153
+ word: { kind: "document", systemPrompt: WORD_CHAT_SYSTEM_PROMPT },
154
+ };
155
+
156
+ /** The host assumed when a client does not announce one (the Chrome extension,
157
+ * whose handshake predates the `host` field). */
158
+ export const DEFAULT_HOST: ClientHost = "chrome";
159
+
160
+ /** Resolve the profile for a host, falling back to the {@link DEFAULT_HOST}
161
+ * profile for a null/undefined host (an unannounced or non-document client). */
162
+ export function hostProfile(host: ClientHost | null | undefined): HostProfile {
163
+ return HOST_PROFILES[host ?? DEFAULT_HOST];
164
+ }
@@ -856,6 +856,67 @@ export async function listXcshPluginRoots(
856
856
  return result;
857
857
  }
858
858
 
859
+ export interface XcshPluginSummary {
860
+ /** Registry id (root.plugin) — the key the `xcsh://plugin/<id>` resolver matches on. */
861
+ id: string;
862
+ /** Display name from the plugin's own manifest (falls back to the registry id). */
863
+ name: string;
864
+ description: string;
865
+ }
866
+
867
+ /**
868
+ * Sanitize a manifest/package description for single-line rendering in the system prompt.
869
+ * Collapses whitespace runs to single spaces, trims, and caps length (appending "…" if truncated).
870
+ * Defense-in-depth: the value is rendered with `noEscape:true`, so a newline would break the
871
+ * single-line bullet and an oversized description would bloat the prompt.
872
+ */
873
+ function sanitizePluginDescription(description: unknown): string {
874
+ if (typeof description !== "string") return "";
875
+ const collapsed = description.replace(/\s+/g, " ").trim();
876
+ return collapsed.length > 300 ? `${collapsed.slice(0, 300)}…` : collapsed;
877
+ }
878
+
879
+ /** Read a plugin's human name+description from its own manifest. Fail-safe: never throws. */
880
+ export async function readPluginSummary(root: { plugin: string; path: string }): Promise<XcshPluginSummary> {
881
+ // Precedence mirrors MarketplaceManager#resolvePluginVersion.
882
+ const candidates: Array<{ rel: string; pick: (m: any) => { name?: string; description?: string } }> = [
883
+ { rel: ".xcsh-plugin/plugin.json", pick: m => ({ name: m?.name, description: m?.description }) },
884
+ {
885
+ rel: "package.json",
886
+ pick: m => ({ name: m?.xcsh?.name ?? m?.pi?.name, description: m?.xcsh?.description ?? m?.pi?.description }),
887
+ },
888
+ ];
889
+ for (const c of candidates) {
890
+ try {
891
+ const m = await Bun.file(path.join(root.path, c.rel)).json();
892
+ const { name, description } = c.pick(m);
893
+ if (name || description) {
894
+ return { id: root.plugin, name: name ?? root.plugin, description: sanitizePluginDescription(description) };
895
+ }
896
+ } catch {
897
+ // try next candidate
898
+ }
899
+ }
900
+ return { id: root.plugin, name: root.plugin, description: "" };
901
+ }
902
+
903
+ /** All installed plugins as {name, description}, sorted by name, deduped by plugin id. */
904
+ export async function listXcshPluginSummaries(home: string, cwd?: string): Promise<XcshPluginSummary[]> {
905
+ try {
906
+ const { roots } = await listXcshPluginRoots(home, cwd);
907
+ const seen = new Set<string>();
908
+ const out: XcshPluginSummary[] = [];
909
+ for (const r of roots) {
910
+ if (seen.has(r.plugin)) continue;
911
+ seen.add(r.plugin);
912
+ out.push(await readPluginSummary(r));
913
+ }
914
+ return out.sort((a, b) => a.name.localeCompare(b.name));
915
+ } catch {
916
+ return [];
917
+ }
918
+ }
919
+
859
920
  /**
860
921
  * Clear the plugin roots cache (useful for testing or when plugins change).
861
922
  */
@@ -0,0 +1,83 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { readPluginSummary } from "./helpers";
6
+
7
+ async function mkRoot(files: Record<string, unknown>): Promise<string> {
8
+ const dir = await mkdtemp(join(tmpdir(), "xps-"));
9
+ for (const [rel, content] of Object.entries(files)) {
10
+ const p = join(dir, rel);
11
+ await mkdir(join(p, ".."), { recursive: true });
12
+ await writeFile(p, JSON.stringify(content));
13
+ }
14
+ return dir;
15
+ }
16
+
17
+ describe("readPluginSummary", () => {
18
+ test("reads name+description from .xcsh-plugin/plugin.json", async () => {
19
+ const path = await mkRoot({
20
+ ".xcsh-plugin/plugin.json": { name: "meddpicc", description: "MEDDPICC framework" },
21
+ });
22
+ expect(await readPluginSummary({ plugin: "meddpicc", path })).toEqual({
23
+ id: "meddpicc",
24
+ name: "meddpicc",
25
+ description: "MEDDPICC framework",
26
+ });
27
+ });
28
+ test("falls back to package.json xcsh field", async () => {
29
+ const path = await mkRoot({ "package.json": { xcsh: { name: "foo", description: "Foo tool" } } });
30
+ expect(await readPluginSummary({ plugin: "foo", path })).toEqual({
31
+ id: "foo",
32
+ name: "foo",
33
+ description: "Foo tool",
34
+ });
35
+ });
36
+ test("falls back to package.json pi field", async () => {
37
+ const path = await mkRoot({ "package.json": { pi: { name: "legacy", description: "Legacy tool" } } });
38
+ expect(await readPluginSummary({ plugin: "legacy", path })).toEqual({
39
+ id: "legacy",
40
+ name: "legacy",
41
+ description: "Legacy tool",
42
+ });
43
+ });
44
+ test("no manifest → name from root.plugin, empty description", async () => {
45
+ const path = await mkRoot({});
46
+ expect(await readPluginSummary({ plugin: "bare", path })).toEqual({
47
+ id: "bare",
48
+ name: "bare",
49
+ description: "",
50
+ });
51
+ });
52
+ test("manifest without name → falls back to root.plugin", async () => {
53
+ const path = await mkRoot({ ".xcsh-plugin/plugin.json": { description: "desc only" } });
54
+ expect(await readPluginSummary({ plugin: "baz", path })).toEqual({
55
+ id: "baz",
56
+ name: "baz",
57
+ description: "desc only",
58
+ });
59
+ });
60
+ test("id is the registry id even when the manifest name differs", async () => {
61
+ const path = await mkRoot({
62
+ ".xcsh-plugin/plugin.json": { name: "Display Name", description: "mismatched name" },
63
+ });
64
+ expect(await readPluginSummary({ plugin: "registry-id", path })).toEqual({
65
+ id: "registry-id",
66
+ name: "Display Name",
67
+ description: "mismatched name",
68
+ });
69
+ });
70
+ test("multi-line / oversized description is collapsed to one line and capped with …", async () => {
71
+ const longDescription = `line one\nline two\t${"x".repeat(400)}`;
72
+ const path = await mkRoot({
73
+ ".xcsh-plugin/plugin.json": { name: "big", description: longDescription },
74
+ });
75
+ const summary = await readPluginSummary({ plugin: "big", path });
76
+ expect(summary.id).toBe("big");
77
+ expect(summary.description).not.toContain("\n");
78
+ expect(summary.description).not.toContain("\t");
79
+ expect(summary.description.startsWith("line one line two ")).toBe(true);
80
+ expect(summary.description.length).toBe(301); // 300 chars + the "…" ellipsis
81
+ expect(summary.description.endsWith("…")).toBe(true);
82
+ });
83
+ });
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.74.2",
21
- "commit": "c0ff94bc5476e1c082e31b85ab8afdd193010f33",
22
- "shortCommit": "c0ff94b",
20
+ "version": "19.76.0",
21
+ "commit": "2e4e3b0d461f6dc353419167c54b0da3c7310765",
22
+ "shortCommit": "2e4e3b0",
23
23
  "branch": "main",
24
- "tag": "v19.74.2",
25
- "commitDate": "2026-07-22T12:44:16Z",
26
- "buildDate": "2026-07-22T13:12:17.223Z",
24
+ "tag": "v19.76.0",
25
+ "commitDate": "2026-07-22T16:43:35Z",
26
+ "buildDate": "2026-07-22T17:06:30.694Z",
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/c0ff94bc5476e1c082e31b85ab8afdd193010f33",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.74.2"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/2e4e3b0d461f6dc353419167c54b0da3c7310765",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.76.0"
33
33
  };
@@ -307,7 +307,10 @@ Most tools resolve custom protocol URLs to internal resources (not web URLs):
307
307
  - `xcsh://console/<resource>/<operation>` — the exact ordered UI steps (selectors) for that operation.
308
308
  - `xcsh://extension` — Chrome extension bridge tool API reference: which tool to use (click, typeahead, input, navigation) for each automation task.
309
309
  {{#if hasPlugins}}
310
- Installed plugins expose capabilities, schemas, and executable helpers on demand. Read `xcsh://plugin` to list installed plugins and `xcsh://plugin/<name>` for a plugin's summary and how to go deeper pull this in only when a task matches a plugin's domain.
310
+ **Installed plugins** expose domain capabilities, schemas, and executable helpers on demand. When a task falls within a plugin's domain, consult that plugin: read `xcsh://plugin/<name>` for its summary, then **run its engine/helpers to produce any computed, scored, ranked, or "what to do next" result** rather than deriving it yourself or reading it from a data file a plugin's engine is the source of truth, and values already written into an artifact may be stale or wrong:
311
+ {{#each plugins}}
312
+ - **{{name}}** — {{description}} → `xcsh://plugin/{{id}}`
313
+ {{/each}}
311
314
  {{/if}}
312
315
 
313
316
  ### Presentation profile
@@ -3,36 +3,54 @@ import * as path from "node:path";
3
3
  import { prompt } from "@f5-sales-demo/pi-utils";
4
4
  import { registerCodingAgentPromptHelpers } from "./config/prompt-templates";
5
5
 
6
- // L0 of the MEDDPICC progressive-hints ladder: a single framework-agnostic line
7
- // telling the agent that installed plugins expose capabilities/schemas via
8
- // `xcsh://plugin/<name>`. It is gated on the `hasPlugins` render variable that
9
- // `buildSystemPrompt` computes from discovered plugin roots.
6
+ // Technique A of the MEDDPICC progressive-hints ladder: a generic, framework-agnostic
7
+ // "Installed Plugins" capability index. When ≥1 plugin is present, the template enumerates
8
+ // each installed plugin by name + own-manifest description + `xcsh://plugin/<name>` pointer,
9
+ // so the agent reliably consults the plugin instead of answering from memory. It is gated on
10
+ // the `hasPlugins` render variable and iterates the `plugins` array that `buildSystemPrompt`
11
+ // computes from discovered plugin summaries.
10
12
  //
11
- // Seam: the Handlebars-compile path (`prompt.render(template, data)`) — the same
12
- // seam every other conditional-rendering assertion in this package uses. It is
13
- // deterministic (no dependency on what is installed under the real home dir) and
14
- // exercises the exact template conditional this task adds. `buildSystemPrompt`
15
- // resolves plugins from `os.homedir()` and takes no `home` override, so a
16
- // true-case fixture through it would be non-deterministic.
13
+ // Seam: the Handlebars-compile path (`prompt.render(template, data)`) — the same seam every
14
+ // other conditional-rendering assertion in this package uses. It is deterministic (no
15
+ // dependency on what is installed under the real home dir) and exercises the exact template
16
+ // block this task renders. `buildSystemPrompt` resolves plugins from `os.homedir()` and takes
17
+ // no `home` override, so a true-case fixture through it would be non-deterministic.
17
18
 
18
19
  const systemPromptPath = path.resolve(import.meta.dir, "prompts/system/system-prompt.md");
19
20
 
20
- describe("L0 plugin-capability pointer", () => {
21
+ describe("Installed Plugins index (technique A)", () => {
21
22
  beforeAll(() => {
22
23
  registerCodingAgentPromptHelpers();
23
24
  });
24
25
 
25
- test("renders the generic pointer when a plugin is present, naming no plugin", async () => {
26
+ test("enumerates installed plugins with pointer, names no plugin in the core template", async () => {
26
27
  const template = await Bun.file(systemPromptPath).text();
27
- const rendered = prompt.render(template, { hasPlugins: true });
28
- expect(rendered).toContain("xcsh://plugin");
29
- // Generic/reusable: it must not name any specific plugin.
30
- expect(rendered).not.toContain("meddpicc");
28
+ const rendered = prompt.render(template, {
29
+ hasPlugins: true,
30
+ plugins: [{ id: "meddpicc", name: "meddpicc", description: "MEDDPICC qualification helper" }],
31
+ });
32
+ expect(rendered).toContain("Installed plugins");
33
+ expect(rendered).toContain("xcsh://plugin/meddpicc");
34
+ expect(rendered).toContain("MEDDPICC qualification helper");
31
35
  });
32
36
 
33
- test("omits the pointer entirely when no plugins are present", async () => {
37
+ test("pointer uses the registry id while the label uses the manifest name", async () => {
34
38
  const template = await Bun.file(systemPromptPath).text();
35
- const rendered = prompt.render(template, { hasPlugins: false });
39
+ const rendered = prompt.render(template, {
40
+ hasPlugins: true,
41
+ plugins: [{ id: "registry-id", name: "Display Name", description: "mismatched name and id" }],
42
+ });
43
+ // POINTER must resolve against the registry id, not the display name.
44
+ expect(rendered).toContain("xcsh://plugin/registry-id");
45
+ expect(rendered).not.toContain("xcsh://plugin/Display Name");
46
+ // DISPLAY label keeps the manifest name.
47
+ expect(rendered).toContain("**Display Name**");
48
+ });
49
+
50
+ test("omits the block entirely when no plugins are present", async () => {
51
+ const template = await Bun.file(systemPromptPath).text();
52
+ const rendered = prompt.render(template, { hasPlugins: false, plugins: [] });
53
+ expect(rendered).not.toContain("Installed plugins");
36
54
  expect(rendered).not.toContain("xcsh://plugin");
37
55
  });
38
56
  });
@@ -13,7 +13,7 @@ import { systemPromptCapability } from "./capability/system-prompt";
13
13
  import type { SkillsSettings } from "./config/settings";
14
14
  import { renderDeprecationGuardrails } from "./deprecations";
15
15
  import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile } from "./discovery";
16
- import { listXcshPluginRoots } from "./discovery/helpers";
16
+ import { listXcshPluginSummaries, type XcshPluginSummary } from "./discovery/helpers";
17
17
  import { isApplicableToContext, loadSkills, type Skill } from "./extensibility/skills";
18
18
  import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
19
19
  import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
@@ -556,12 +556,13 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
556
556
  ? loadSkills({ ...mergedSkillsSettings, cwd: resolvedCwd }).then(result => result.skills)
557
557
  : Promise.resolve([]);
558
558
 
559
- // L0 plugin-capability pointer: gate a generic `xcsh://plugin` hint on ≥1
560
- // discoverable plugin. Discovery is cached and reads one small registry file,
561
- // so this is effectively free; it fails safe to `false` (no hint) on error.
562
- const hasPluginsPromise: Promise<boolean> = listXcshPluginRoots(os.homedir(), resolvedCwd)
563
- .then(result => result.roots.length > 0)
564
- .catch(() => false);
559
+ // Installed-plugins capability index: enumerate installed plugins (name + own-manifest
560
+ // description + xcsh://plugin/<name> pointer) so the agent reliably consults them. Discovery
561
+ // is cached; fails safe to [] (no block) on error.
562
+ const pluginSummariesPromise: Promise<XcshPluginSummary[]> = listXcshPluginSummaries(
563
+ os.homedir(),
564
+ resolvedCwd,
565
+ ).catch(() => []);
565
566
 
566
567
  return Promise.all([
567
568
  resolvePromptInput(customPrompt, "system prompt"),
@@ -570,7 +571,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
570
571
  contextFilesPromise,
571
572
  agentsMdSearchPromise,
572
573
  skillsPromise,
573
- hasPluginsPromise,
574
+ pluginSummariesPromise,
574
575
  ]).then(
575
576
  ([
576
577
  resolvedCustomPrompt,
@@ -579,7 +580,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
579
580
  contextFiles,
580
581
  agentsMdSearch,
581
582
  skills,
582
- hasPlugins,
583
+ pluginSummaries,
583
584
  ]) => ({
584
585
  resolvedCustomPrompt,
585
586
  resolvedAppendPrompt,
@@ -587,7 +588,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
587
588
  contextFiles,
588
589
  agentsMdSearch,
589
590
  skills,
590
- hasPlugins,
591
+ pluginSummaries,
591
592
  }),
592
593
  );
593
594
  })();
@@ -612,7 +613,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
612
613
  files: [],
613
614
  };
614
615
  let skills: Skill[] = providedSkills ?? [];
615
- let hasPlugins = false;
616
+ let pluginSummaries: XcshPluginSummary[] = [];
616
617
 
617
618
  if (prepResult.type === "timeout") {
618
619
  logger.warn("System prompt preparation timed out; using minimal startup context", {
@@ -635,9 +636,12 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
635
636
  contextFiles = dedupeExactContextFiles(prepResult.value.contextFiles);
636
637
  agentsMdSearch = prepResult.value.agentsMdSearch;
637
638
  skills = prepResult.value.skills;
638
- hasPlugins = prepResult.value.hasPlugins;
639
+ pluginSummaries = prepResult.value.pluginSummaries;
639
640
  }
640
641
 
642
+ const plugins = pluginSummaries;
643
+ const hasPlugins = plugins.length > 0;
644
+
641
645
  const date = new Date().toISOString().slice(0, 10);
642
646
  const dateTime = date;
643
647
  const promptCwd = resolvedCwd.replace(/\\/g, "/");
@@ -692,6 +696,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
692
696
  agentsMdSearch,
693
697
  skills: contextFilteredSkills,
694
698
  rules: rules ?? [],
699
+ plugins,
695
700
  hasPlugins,
696
701
  alwaysApplyRules: injectedAlwaysApplyRules,
697
702
  date,