@f5-sales-demo/xcsh 19.72.1 → 19.74.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.72.1",
4
+ "version": "19.74.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.72.1",
60
- "@f5-sales-demo/pi-agent-core": "19.72.1",
61
- "@f5-sales-demo/pi-ai": "19.72.1",
62
- "@f5-sales-demo/pi-natives": "19.72.1",
63
- "@f5-sales-demo/pi-resource-management": "19.72.1",
64
- "@f5-sales-demo/pi-tui": "19.72.1",
65
- "@f5-sales-demo/pi-utils": "19.72.1",
59
+ "@f5-sales-demo/xcsh-stats": "19.74.0",
60
+ "@f5-sales-demo/pi-agent-core": "19.74.0",
61
+ "@f5-sales-demo/pi-ai": "19.74.0",
62
+ "@f5-sales-demo/pi-natives": "19.74.0",
63
+ "@f5-sales-demo/pi-resource-management": "19.74.0",
64
+ "@f5-sales-demo/pi-tui": "19.74.0",
65
+ "@f5-sales-demo/pi-utils": "19.74.0",
66
66
  "@sinclair/typebox": "^0.34",
67
67
  "@xterm/headless": "^6.0",
68
68
  "ajv": "^8.20",
@@ -69,3 +69,31 @@ export function createExtensionBridgeTools(bridge: BridgeServer): CustomTool<TSc
69
69
  export const EXTENSION_AGENT_TOOL_NAMES: readonly string[] = EXTENSION_CAPABILITIES.tools
70
70
  .filter(def => !INTERNAL_TOOLS.has(def.name))
71
71
  .map(def => def.name);
72
+
73
+ /**
74
+ * Builtin agent tools scoped into a headless browser-bridge session (the Chrome
75
+ * extension worker and the Office `serve` bridge). Shared so both bootstraps use
76
+ * one list. Office document tools are NOT here — the pane advertises those over
77
+ * the bridge via `set_host_tools`, registered at runtime by the ChatHandler.
78
+ */
79
+ export const BROWSER_TOOL_NAMES: readonly string[] = [
80
+ "catalog_workflow_runner",
81
+ "navigate",
82
+ "click",
83
+ "click_element",
84
+ "fill",
85
+ "type_text",
86
+ "screenshot",
87
+ "login",
88
+ "read_ax",
89
+ "get_page_context",
90
+ "query_dom",
91
+ "find",
92
+ "wait_for",
93
+ "key_press",
94
+ "select_option",
95
+ "label_select",
96
+ "scroll_to",
97
+ "annotate",
98
+ "set_explain_mode",
99
+ ];
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Headless chat bridge — the no-TUI extension-bridge host used by `xcsh office
3
+ * serve` so one command yields a working task pane (pane files + a live chat/
4
+ * host-tool bridge). It mirrors the proven `xcsh worker` bootstrap
5
+ * (`commands/worker.ts`) MINUS the fleet concerns (manager keepalive, pre-warm
6
+ * IPC bind, TTFT spans): set the browser-provider env, init Settings + Context,
7
+ * quiet startup, provision the wss cert, start the bridge, publish session info,
8
+ * create ONE agent session scoped to the browser tools, and attach the
9
+ * `ChatHandler`. Office document tools (Excel/Word/PPT) are advertised by the
10
+ * pane at runtime over the bridge (`set_host_tools`), so they need no scoping here.
11
+ *
12
+ * The heavy / socket / network calls are injected (defaulting to the real ones)
13
+ * so the wiring is unit-testable without opening real listeners or a session.
14
+ *
15
+ * NOT browser-safe (node/bun): runs inside the full xcsh binary, never the pane.
16
+ */
17
+ import { getProjectDir, getXCSHConfigDir } from "@f5-sales-demo/pi-utils";
18
+ import { createAgentSession } from "../sdk";
19
+ import { ContextService } from "../services/xcsh-context";
20
+ import { deriveTenantEnv } from "../services/xcsh-env";
21
+ import { resolveBridgeTls } from "./bridge-cert";
22
+ import { ChatHandler } from "./chat-handler";
23
+ import { type BridgeServer, startBridgeServer } from "./extension-bridge";
24
+ import { BROWSER_TOOL_NAMES, createExtensionBridgeTools, EXTENSION_AGENT_TOOL_NAMES } from "./extension-bridge-tools";
25
+ import { setSharedBridgeServer } from "./provider";
26
+
27
+ /** A running headless bridge + a teardown that disposes the chat handler and
28
+ * closes the bridge (both ws + wss listeners). */
29
+ export interface HeadlessChatBridge {
30
+ bridge: BridgeServer;
31
+ dispose: () => Promise<void>;
32
+ }
33
+
34
+ /**
35
+ * Tenant identity for the `hello` handshake, contextless-friendly: the active
36
+ * `/context` wins (its apiUrl + name), otherwise fall back to the `XCSH_API_URL`/
37
+ * `XCSH_SESSION_TENANT` env so the pane still learns which tenant this process
38
+ * serves. Sync — the bridge calls it while answering `hello`. Mirrors the
39
+ * interactive path in `main.ts` (there is no per-tab session id here).
40
+ */
41
+ export function sessionInfoForOfficeServe(): {
42
+ tenant: string | null;
43
+ env: string | null;
44
+ apiUrl: string | null;
45
+ contextBound: boolean;
46
+ sessionId: string | null;
47
+ } {
48
+ let apiUrl: string | null = null;
49
+ let contextBound = false;
50
+ try {
51
+ apiUrl = ContextService.instance.activeApiUrl;
52
+ contextBound = ContextService.instance.getStatus().activeContextName != null;
53
+ } catch {
54
+ /* ContextService not initialized — env-only mode. */
55
+ }
56
+ apiUrl = apiUrl ?? process.env.XCSH_API_URL ?? null;
57
+ const tenantKey = process.env.XCSH_SESSION_TENANT ?? null;
58
+ const { tenant, env } = deriveTenantEnv(apiUrl, tenantKey);
59
+ return { tenant, env, apiUrl, contextBound, sessionId: null };
60
+ }
61
+
62
+ /** Injectable seams (defaulted to the real ones) so the bootstrap is testable. */
63
+ export interface HeadlessBridgeDeps {
64
+ /** Set the browser-provider env, init Settings + ContextService + provider
65
+ * persistence, and quiet startup; returns the project cwd for the session. */
66
+ initEnv: () => Promise<{ cwd: string }>;
67
+ resolveBridgeTls: typeof resolveBridgeTls;
68
+ startBridgeServer: typeof startBridgeServer;
69
+ setSharedBridgeServer: typeof setSharedBridgeServer;
70
+ createExtensionBridgeTools: typeof createExtensionBridgeTools;
71
+ createAgentSession: typeof createAgentSession;
72
+ ChatHandlerCtor: typeof ChatHandler;
73
+ }
74
+
75
+ const defaultDeps: HeadlessBridgeDeps = {
76
+ initEnv: async () => {
77
+ process.env.XCSH_BROWSER_PROVIDER = "extension";
78
+ const cwd = getProjectDir();
79
+ const { Settings, settings } = await import("../config/settings");
80
+ await Settings.init({ cwd });
81
+ // Init the ContextService singleton so sessionInfoForOfficeServe can read the
82
+ // active apiUrl once a /context is bound.
83
+ try {
84
+ ContextService.init(getXCSHConfigDir());
85
+ } catch {
86
+ /* already initialized / unavailable — continue. */
87
+ }
88
+ // Provider persistence for model discovery (parity with main.ts / worker.ts).
89
+ const { initializeWithSettings } = await import("../discovery");
90
+ initializeWithSettings(settings);
91
+ // Quiet startup: skip the welcome screen + blocking plugin "Fix now?" prompts.
92
+ settings.override("startup.quiet", true);
93
+ return { cwd };
94
+ },
95
+ resolveBridgeTls,
96
+ startBridgeServer,
97
+ setSharedBridgeServer,
98
+ createExtensionBridgeTools,
99
+ createAgentSession,
100
+ ChatHandlerCtor: ChatHandler,
101
+ };
102
+
103
+ /**
104
+ * Start the headless chat bridge and return it with a teardown. Fully awaits the
105
+ * session + ChatHandler.attach() before resolving, so once this resolves the pane
106
+ * can connect and chat immediately (no warm-up race). A `configure`-less pane
107
+ * chats over xcsh's already-configured provider.
108
+ */
109
+ export async function startHeadlessChatBridge(deps: HeadlessBridgeDeps = defaultDeps): Promise<HeadlessChatBridge> {
110
+ const { cwd } = await deps.initEnv();
111
+
112
+ // Provision the wss cert before binding (warm boot = on-disk cache hit);
113
+ // `undefined` (offline) → the bridge starts ws-only.
114
+ const tls = await deps.resolveBridgeTls();
115
+ const bridge = await deps.startBridgeServer(undefined, tls ? { tls } : undefined);
116
+ // Reuse this bridge for any in-process selectProvider() (no conflicting second bridge).
117
+ deps.setSharedBridgeServer(bridge);
118
+ bridge.setSessionInfo(sessionInfoForOfficeServe);
119
+ // Re-announce the tenant when the active context changes (best-effort).
120
+ try {
121
+ ContextService.onContextChange(() => bridge.broadcastTenantChanged());
122
+ } catch {
123
+ /* ContextService not initialized (tests) — the tenant is static. */
124
+ }
125
+
126
+ // Everything past the bind can throw (createAgentSession on a misconfigured
127
+ // provider, etc.). If it does, close the already-bound bridge and clear the
128
+ // shared-bridge global before rethrowing — otherwise the ws/wss listeners leak
129
+ // (keeping the event loop alive so Ctrl+C can't exit) and a later in-process
130
+ // selectProvider() reuses a dead bridge. The caller (startOfficeServe) treats
131
+ // the rethrow as a non-fatal "pane only" fallback.
132
+ 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);
137
+ const { session } = await deps.createAgentSession({
138
+ cwd,
139
+ hasUI: false,
140
+ toolNames: [...new Set([...BROWSER_TOOL_NAMES, ...EXTENSION_AGENT_TOOL_NAMES])],
141
+ customTools: extensionTools,
142
+ // Headless: no MCP/LSP/extension discovery — lean, no network/blocking prompts.
143
+ enableMCP: false,
144
+ enableLsp: false,
145
+ disableExtensionDiscovery: true,
146
+ });
147
+
148
+ const chatHandler = new deps.ChatHandlerCtor(bridge, session);
149
+ chatHandler.attach();
150
+
151
+ return {
152
+ bridge,
153
+ dispose: async () => {
154
+ chatHandler.dispose();
155
+ deps.setSharedBridgeServer(null);
156
+ await bridge.close();
157
+ },
158
+ };
159
+ } catch (err) {
160
+ deps.setSharedBridgeServer(null);
161
+ await bridge.close();
162
+ throw err;
163
+ }
164
+ }
@@ -115,7 +115,10 @@ export class CdpBrowserProvider implements BrowserProvider {
115
115
  * instead of starting a conflicting second one on the same port.
116
116
  */
117
117
  let _sharedBridgeServer: import("./extension-bridge").BridgeServer | null = null;
118
- export function setSharedBridgeServer(server: import("./extension-bridge").BridgeServer): void {
118
+ /** Publish (or, with `null`, clear) the process-shared bridge. Clearing is
119
+ * required when a partially-started bridge is torn down (e.g. session bootstrap
120
+ * failed after bind) so a later `selectProvider()` never reuses a closed bridge. */
121
+ export function setSharedBridgeServer(server: import("./extension-bridge").BridgeServer | null): void {
119
122
  _sharedBridgeServer = server;
120
123
  }
121
124
 
@@ -9,7 +9,14 @@
9
9
  */
10
10
  import { spawnSync } from "node:child_process";
11
11
  import * as path from "node:path";
12
- import { getOfficePaneDir, readManifest, startOfficePaneServer } from "../browser/office-pane-server";
12
+ import { LOCALIP_HOST } from "../browser/bridge-cert";
13
+ import { type HeadlessChatBridge, startHeadlessChatBridge } from "../browser/headless-bridge";
14
+ import {
15
+ getOfficePaneDir,
16
+ type OfficePaneServer,
17
+ readManifest,
18
+ startOfficePaneServer,
19
+ } from "../browser/office-pane-server";
13
20
 
14
21
  /** The subcommands `xcsh office` accepts (also the Args `options` constraint). */
15
22
  export const OFFICE_ACTIONS = ["serve", "manifest", "sideload"] as const;
@@ -39,9 +46,53 @@ export async function writeManifest(outPath?: string): Promise<string> {
39
46
  return text;
40
47
  }
41
48
 
42
- /** Start the :8444 listener, print the task-pane URL, and block until killed. */
49
+ /** Injectable seams for {@link startOfficeServe} (defaulted to the real ones). */
50
+ export interface OfficeServeDeps {
51
+ startOfficePaneServer: typeof startOfficePaneServer;
52
+ startHeadlessChatBridge: typeof startHeadlessChatBridge;
53
+ }
54
+
55
+ const defaultServeDeps: OfficeServeDeps = { startOfficePaneServer, startHeadlessChatBridge };
56
+
57
+ /** A running `office serve`: the pane file server, the (optional) chat bridge, and
58
+ * a teardown that disposes both. */
59
+ export interface OfficeServeHandle {
60
+ server: OfficePaneServer;
61
+ chat: HeadlessChatBridge | null;
62
+ dispose: () => Promise<void>;
63
+ }
64
+
65
+ /**
66
+ * Start BOTH the :8444 pane file server AND the headless chat bridge, so one
67
+ * `xcsh office serve` yields a working pane (no separately-run bridge). A bridge
68
+ * failure is NON-fatal: the pane still serves and we warn, so `serve` degrades to
69
+ * "pane only" rather than failing outright. Returns a teardown that disposes both.
70
+ * Extracted from {@link runServe} so the start/teardown wiring is unit-testable.
71
+ */
72
+ export async function startOfficeServe(deps: OfficeServeDeps = defaultServeDeps): Promise<OfficeServeHandle> {
73
+ const server = await deps.startOfficePaneServer();
74
+ let chat: HeadlessChatBridge | null = null;
75
+ try {
76
+ chat = await deps.startHeadlessChatBridge();
77
+ } catch (err) {
78
+ console.warn(
79
+ `Warning: the chat bridge could not start (${err instanceof Error ? err.message : String(err)}). ` +
80
+ "The task pane will load but chat is unavailable until a bridge is running.",
81
+ );
82
+ }
83
+ return {
84
+ server,
85
+ chat,
86
+ dispose: async () => {
87
+ if (chat) await chat.dispose();
88
+ server.stop();
89
+ },
90
+ };
91
+ }
92
+
93
+ /** Start the pane server + chat bridge, print status, and block until killed. */
43
94
  async function runServe(): Promise<void> {
44
- const server = await startOfficePaneServer();
95
+ const { server, chat, dispose } = await startOfficeServe();
45
96
  console.log(`Serving the xcsh Office task pane at ${server.taskpaneUrl}`);
46
97
  if (!server.trusted) {
47
98
  console.warn(
@@ -49,9 +100,26 @@ async function runServe(): Promise<void> {
49
100
  "Office's WebView may refuse to load the page until the cert is trusted.",
50
101
  );
51
102
  }
103
+ if (chat) {
104
+ if (chat.bridge.wssPort) {
105
+ console.log(
106
+ `Chat bridge ready on wss://${LOCALIP_HOST}:${chat.bridge.wssPort} — the pane connects automatically.`,
107
+ );
108
+ } else {
109
+ console.warn(
110
+ "Warning: the chat bridge is ws-only (no wss cert). The pane connects over wss and may not reach it.",
111
+ );
112
+ }
113
+ }
52
114
  console.log("Press Ctrl+C to stop.");
53
- // Bun.serve holds the event loop open; block run() so the process stays alive.
54
- await new Promise<never>(() => {});
115
+ // Block until a signal tears us down, then dispose the bridge + pane server.
116
+ await new Promise<void>(resolve => {
117
+ const shutdown = (): void => {
118
+ void dispose().finally(resolve);
119
+ };
120
+ process.once("SIGINT", shutdown);
121
+ process.once("SIGTERM", shutdown);
122
+ });
55
123
  }
56
124
 
57
125
  /** Run the Office sideload against the embedded bundle (best-effort). */
@@ -20,7 +20,11 @@ import { Command } from "@f5-sales-demo/pi-utils/cli";
20
20
  import { LOCALIP_HOST, resolveBridgeTls } from "../browser/bridge-cert";
21
21
  import { ChatHandler } from "../browser/chat-handler";
22
22
  import { startBridgeServer } from "../browser/extension-bridge";
23
- import { createExtensionBridgeTools, EXTENSION_AGENT_TOOL_NAMES } from "../browser/extension-bridge-tools";
23
+ import {
24
+ BROWSER_TOOL_NAMES,
25
+ createExtensionBridgeTools,
26
+ EXTENSION_AGENT_TOOL_NAMES,
27
+ } from "../browser/extension-bridge-tools";
24
28
  import { setSharedBridgeServer } from "../browser/provider";
25
29
  import { coldStartSpans, type SpanFrame, sessionBuildSpan } from "../browser/ttft-spans";
26
30
  import { initializeWithSettings } from "../discovery";
@@ -94,28 +98,6 @@ function managerSockPath(): string {
94
98
  /** Browser-automation tool set — identical scoping to `main.ts`'s extension path.
95
99
  * With scoped tools the ONLY way to create a resource is the form-driven workflow
96
100
  * runner, which is exactly what the human watching the browser wants. */
97
- const BROWSER_TOOL_NAMES = [
98
- "catalog_workflow_runner",
99
- "navigate",
100
- "click",
101
- "click_element",
102
- "fill",
103
- "type_text",
104
- "screenshot",
105
- "login",
106
- "read_ax",
107
- "get_page_context",
108
- "query_dom",
109
- "find",
110
- "wait_for",
111
- "key_press",
112
- "select_option",
113
- "label_select",
114
- "scroll_to",
115
- "annotate",
116
- "set_explain_mode",
117
- ];
118
-
119
101
  export default class Worker extends Command {
120
102
  static description = "Run a headless extension-bridge worker (no TUI); blocks until SIGTERM";
121
103
 
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.72.1",
21
- "commit": "7a6869d834d2686296ed22beb52ca0d28876a1ee",
22
- "shortCommit": "7a6869d",
20
+ "version": "19.74.0",
21
+ "commit": "c6fc2dd6c16c816e5857edc7e892837405dead7a",
22
+ "shortCommit": "c6fc2dd",
23
23
  "branch": "main",
24
- "tag": "v19.72.1",
25
- "commitDate": "2026-07-22T04:05:41Z",
26
- "buildDate": "2026-07-22T04:30:43.900Z",
24
+ "tag": "v19.74.0",
25
+ "commitDate": "2026-07-22T05:55:19Z",
26
+ "buildDate": "2026-07-22T06:19:41.705Z",
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/7a6869d834d2686296ed22beb52ca0d28876a1ee",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.72.1"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/c6fc2dd6c16c816e5857edc7e892837405dead7a",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.74.0"
33
33
  };
@@ -306,6 +306,9 @@ Most tools resolve custom protocol URLs to internal resources (not web URLs):
306
306
  - `xcsh://console/<resource>` — console route pattern, menu path, and available operations.
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
+ {{#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.
311
+ {{/if}}
309
312
 
310
313
  ### Presentation profile
311
314
 
@@ -0,0 +1,38 @@
1
+ import { beforeAll, describe, expect, test } from "bun:test";
2
+ import * as path from "node:path";
3
+ import { prompt } from "@f5-sales-demo/pi-utils";
4
+ import { registerCodingAgentPromptHelpers } from "./config/prompt-templates";
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.
10
+ //
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.
17
+
18
+ const systemPromptPath = path.resolve(import.meta.dir, "prompts/system/system-prompt.md");
19
+
20
+ describe("L0 plugin-capability pointer", () => {
21
+ beforeAll(() => {
22
+ registerCodingAgentPromptHelpers();
23
+ });
24
+
25
+ test("renders the generic pointer when a plugin is present, naming no plugin", async () => {
26
+ 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");
31
+ });
32
+
33
+ test("omits the pointer entirely when no plugins are present", async () => {
34
+ const template = await Bun.file(systemPromptPath).text();
35
+ const rendered = prompt.render(template, { hasPlugins: false });
36
+ expect(rendered).not.toContain("xcsh://plugin");
37
+ });
38
+ });
@@ -13,6 +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
17
  import { isApplicableToContext, loadSkills, type Skill } from "./extensibility/skills";
17
18
  import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
18
19
  import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
@@ -555,6 +556,13 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
555
556
  ? loadSkills({ ...mergedSkillsSettings, cwd: resolvedCwd }).then(result => result.skills)
556
557
  : Promise.resolve([]);
557
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);
565
+
558
566
  return Promise.all([
559
567
  resolvePromptInput(customPrompt, "system prompt"),
560
568
  resolvePromptInput(appendSystemPrompt, "append system prompt"),
@@ -562,6 +570,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
562
570
  contextFilesPromise,
563
571
  agentsMdSearchPromise,
564
572
  skillsPromise,
573
+ hasPluginsPromise,
565
574
  ]).then(
566
575
  ([
567
576
  resolvedCustomPrompt,
@@ -570,6 +579,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
570
579
  contextFiles,
571
580
  agentsMdSearch,
572
581
  skills,
582
+ hasPlugins,
573
583
  ]) => ({
574
584
  resolvedCustomPrompt,
575
585
  resolvedAppendPrompt,
@@ -577,6 +587,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
577
587
  contextFiles,
578
588
  agentsMdSearch,
579
589
  skills,
590
+ hasPlugins,
580
591
  }),
581
592
  );
582
593
  })();
@@ -601,6 +612,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
601
612
  files: [],
602
613
  };
603
614
  let skills: Skill[] = providedSkills ?? [];
615
+ let hasPlugins = false;
604
616
 
605
617
  if (prepResult.type === "timeout") {
606
618
  logger.warn("System prompt preparation timed out; using minimal startup context", {
@@ -623,6 +635,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
623
635
  contextFiles = dedupeExactContextFiles(prepResult.value.contextFiles);
624
636
  agentsMdSearch = prepResult.value.agentsMdSearch;
625
637
  skills = prepResult.value.skills;
638
+ hasPlugins = prepResult.value.hasPlugins;
626
639
  }
627
640
 
628
641
  const date = new Date().toISOString().slice(0, 10);
@@ -679,6 +692,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
679
692
  agentsMdSearch,
680
693
  skills: contextFilteredSkills,
681
694
  rules: rules ?? [],
695
+ hasPlugins,
682
696
  alwaysApplyRules: injectedAlwaysApplyRules,
683
697
  date,
684
698
  dateTime,