@f5-sales-demo/xcsh 21.34.0 → 21.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/package.json +8 -8
  2. package/src/browser/capabilities.generated.ts +12 -0
  3. package/src/browser/capabilities.json +19 -7
  4. package/src/browser/chat-handler.ts +46 -0
  5. package/src/browser/extension-bridge-tools.ts +4 -2
  6. package/src/cli/args.ts +2 -1
  7. package/src/config/settings-schema.ts +5 -13
  8. package/src/config/settings.ts +0 -8
  9. package/src/extensibility/extensions/bundled/herdr-reporter.ts +2 -2
  10. package/src/extensibility/extensions/types.ts +1 -8
  11. package/src/herdr/client.ts +6 -1
  12. package/src/herdr/interactions.ts +221 -0
  13. package/src/internal-urls/api-catalog-discovery.ts +103 -0
  14. package/src/internal-urls/api-catalog-resolve.ts +16 -14
  15. package/src/internal-urls/build-info.generated.ts +8 -8
  16. package/src/internal-urls/docs-index.generated.ts +1 -1
  17. package/src/internal-urls/terraform-index.generated.ts +1 -15
  18. package/src/modes/components/request-user-input.ts +133 -0
  19. package/src/modes/components/settings-defs.ts +0 -8
  20. package/src/modes/controllers/event-controller.ts +46 -7
  21. package/src/modes/controllers/extension-ui-controller.ts +31 -25
  22. package/src/modes/interactive-mode.ts +10 -979
  23. package/src/modes/rpc/rpc-mode.ts +17 -0
  24. package/src/modes/rpc/rpc-types.ts +15 -0
  25. package/src/modes/types.ts +2 -3
  26. package/src/plan-mode/state.ts +0 -1
  27. package/src/prompts/system/default-mode-active.md +3 -0
  28. package/src/prompts/system/plan-mode-active.md +92 -84
  29. package/src/prompts/system/system-prompt.md +1 -2
  30. package/src/prompts/tools/request-user-input-async.md +1 -0
  31. package/src/remote-control/history.ts +11 -0
  32. package/src/remote-control/interactions.ts +7 -46
  33. package/src/remote-control/managed-sessions.ts +1 -5
  34. package/src/remote-control/router.ts +1 -0
  35. package/src/remote-control/session.ts +49 -19
  36. package/src/sdk.ts +7 -4
  37. package/src/session/agent-session.ts +263 -134
  38. package/src/session/turn-phase.ts +5 -0
  39. package/src/session/user-interactions.ts +246 -60
  40. package/src/slash-commands/builtin-registry.ts +10 -0
  41. package/src/tools/context.ts +0 -6
  42. package/src/tools/index.ts +14 -10
  43. package/src/tools/plan-mode-guard.ts +3 -16
  44. package/src/tools/renderers.ts +0 -2
  45. package/src/tools/request-user-input.ts +144 -0
  46. package/src/modes/components/question-flow.ts +0 -324
  47. package/src/plan-mode/approved-plan.ts +0 -55
  48. package/src/prompts/system/plan-mode-reference.md +0 -14
  49. package/src/prompts/system/plan-mode-tool-decision-reminder.md +0 -10
  50. package/src/prompts/tools/ask.md +0 -28
  51. package/src/prompts/tools/exit-plan-mode.md +0 -41
  52. package/src/session/question-types.ts +0 -56
  53. package/src/tools/ask.ts +0 -446
  54. package/src/tools/exit-plan-mode.ts +0 -101
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "21.34.0",
4
+ "version": "21.35.1",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -63,13 +63,13 @@
63
63
  },
64
64
  "dependencies": {
65
65
  "@agentclientprotocol/sdk": "1.4.0",
66
- "@f5-sales-demo/pi-agent-core": "21.34.0",
67
- "@f5-sales-demo/pi-ai": "21.34.0",
68
- "@f5-sales-demo/pi-natives": "21.34.0",
69
- "@f5-sales-demo/pi-resource-management": "21.34.0",
70
- "@f5-sales-demo/pi-tui": "21.34.0",
71
- "@f5-sales-demo/pi-utils": "21.34.0",
72
- "@f5-sales-demo/xcsh-stats": "21.34.0",
66
+ "@f5-sales-demo/pi-agent-core": "21.35.1",
67
+ "@f5-sales-demo/pi-ai": "21.35.1",
68
+ "@f5-sales-demo/pi-natives": "21.35.1",
69
+ "@f5-sales-demo/pi-resource-management": "21.35.1",
70
+ "@f5-sales-demo/pi-tui": "21.35.1",
71
+ "@f5-sales-demo/pi-utils": "21.35.1",
72
+ "@f5-sales-demo/xcsh-stats": "21.35.1",
73
73
  "@mozilla/readability": "^0.6",
74
74
  "@sinclair/typebox": "0.34.52",
75
75
  "@xterm/headless": "^6.0",
@@ -837,6 +837,18 @@ export const EXTENSION_CAPABILITIES: ExtensionCapabilities = {
837
837
  "annotation": "Annotate the page for documentation and teaching."
838
838
  }
839
839
  }
840
+ },
841
+ "interactions": {
842
+ "contract": "xcsh.interaction.v1",
843
+ "waiting": "request_user_input",
844
+ "asynchronous": "request_user_input_async",
845
+ "planActions": [
846
+ "implement",
847
+ "fresh",
848
+ "stay"
849
+ ],
850
+ "snapshot": true,
851
+ "responseReceipts": true
840
852
  }
841
853
  }
842
854
  };
@@ -421,7 +421,7 @@
421
421
  },
422
422
  {
423
423
  "name": "query_dom",
424
- "summary": "Direct DOM.querySelector at wire speed bypasses Runtime.evaluate for simple CSS selectors.",
424
+ "summary": "Direct DOM.querySelector at wire speed \u2014 bypasses Runtime.evaluate for simple CSS selectors.",
425
425
  "category": "read",
426
426
  "params": {
427
427
  "type": "object",
@@ -584,7 +584,7 @@
584
584
  },
585
585
  {
586
586
  "name": "diag_ttft",
587
- "summary": "Diagnostic: init→first-token timeline (per-stage ms, total, dominant, cold/warm).",
587
+ "summary": "Diagnostic: init\u2192first-token timeline (per-stage ms, total, dominant, cold/warm).",
588
588
  "category": "read",
589
589
  "params": {
590
590
  "type": "object",
@@ -675,7 +675,7 @@
675
675
  },
676
676
  {
677
677
  "name": "set_explain_mode",
678
- "summary": "Enter/leave explain mode the gate for all on-page annotation overlays.",
678
+ "summary": "Enter/leave explain mode \u2014 the gate for all on-page annotation overlays.",
679
679
  "category": "annotation",
680
680
  "params": {
681
681
  "type": "object",
@@ -740,7 +740,7 @@
740
740
  },
741
741
  "capture": {
742
742
  "tool": "screenshot",
743
- "description": "Capture a screenshot (base64 PNG) for headless annotated-capture flows."
743
+ "description": "Capture a screenshot (base64 PNG) \u2014 for headless annotated-capture flows."
744
744
  },
745
745
  "viewport": {
746
746
  "tool": "resize_window",
@@ -777,12 +777,12 @@
777
777
  "media_asset_chunk",
778
778
  "media_asset_error"
779
779
  ],
780
- "description": "User xcsh chat over the bridge. The extension side panel sends chat_request (with mode and page-context snapshot); xcsh streams ordered assistant items using chat_message_start, correlated chat_delta tokens, and chat_message_end. Each item declares commentary or final_answer phase. Exactly one terminal chat_done (with reference links) or chat_error closes the whole turn. Chat ids are prefixed \"c-\". Tool calls during a turn use the normal tool_request flow. chat_stop halts a streaming response. chat_tool_notice is emitted by the EXTENSION (the service worker) to the panel as a best-effort UI signal when a tool runs during a turn it is NOT sent by xcsh; xcsh must not produce it to avoid double-rendering in the panel. Rich media uses chat_media descriptors and bounded media_asset_read/media_asset_chunk exchanges; media_asset_error reports unavailable assets.",
780
+ "description": "User \u2194 xcsh chat over the bridge. The extension side panel sends chat_request (with mode and page-context snapshot); xcsh streams ordered assistant items using chat_message_start, correlated chat_delta tokens, and chat_message_end. Each item declares commentary or final_answer phase. Exactly one terminal chat_done (with reference links) or chat_error closes the whole turn. Chat ids are prefixed \"c-\". Tool calls during a turn use the normal tool_request flow. chat_stop halts a streaming response. chat_tool_notice is emitted by the EXTENSION (the service worker) to the panel as a best-effort UI signal when a tool runs during a turn \u2014 it is NOT sent by xcsh; xcsh must not produce it to avoid double-rendering in the panel. Rich media uses chat_media descriptors and bounded media_asset_read/media_asset_chunk exchanges; media_asset_error reports unavailable assets.",
781
781
  "promptHints": {
782
782
  "role": "You are xcsh, the AI assistant embedded in the F5 Distributed Cloud (XC) console side panel. The user is viewing a live console page; help them drive automation and understand settings and their purpose.",
783
- "grounding": "Every user message carries a page-context snapshot: url/path, title, a trimmed accessibility tree, and when available the live XC API resource JSON the page loaded (context.api.body). Treat context.api.body as the authoritative current state of the resource and ground answers in it instead of guessing; respect the truncated flags.",
783
+ "grounding": "Every user message carries a page-context snapshot: url/path, title, a trimmed accessibility tree, and \u2014 when available \u2014 the live XC API resource JSON the page loaded (context.api.body). Treat context.api.body as the authoritative current state of the resource and ground answers in it instead of guessing; respect the truncated flags.",
784
784
  "referenceLinks": "Emit every citation as a markdown link [title](url): F5 XC docs (docs.cloud.f5.com) as doc references and the tenant console host as console deep-links. These populate the panel References drawer, so always format references this way rather than as bare URLs.",
785
- "toolUse": "Drive the console with the extension tools via the normal tool_request flow (navigate, click, annotate, screenshot, get_page_context, and so on). The extension surfaces tool activity to the panel itself (chat_tool_notice) never emit chat_tool_notice yourself. Each turn ends with exactly one terminal frame, which the transport handles.",
785
+ "toolUse": "Drive the console with the extension tools via the normal tool_request flow (navigate, click, annotate, screenshot, get_page_context, and so on). The extension surfaces tool activity to the panel itself (chat_tool_notice) \u2014 never emit chat_tool_notice yourself. Each turn ends with exactly one terminal frame, which the transport handles.",
786
786
  "modes": {
787
787
  "educational": "Explain concepts and answer questions about settings and their purpose.",
788
788
  "presentation": "Guided, human-paced walkthrough/demo of the console.",
@@ -791,6 +791,18 @@
791
791
  "annotation": "Annotate the page for documentation and teaching."
792
792
  }
793
793
  }
794
+ },
795
+ "interactions": {
796
+ "contract": "xcsh.interaction.v1",
797
+ "waiting": "request_user_input",
798
+ "asynchronous": "request_user_input_async",
799
+ "planActions": [
800
+ "implement",
801
+ "fresh",
802
+ "stay"
803
+ ],
804
+ "snapshot": true,
805
+ "responseReceipts": true
794
806
  }
795
807
  }
796
808
  }
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AssistantMessage, AssistantMessagePhase, ImageContent } from "@f5-sales-demo/pi-ai";
4
+ import { isInteractionCommand } from "../../../chat-ui/src/interactions/transport";
4
5
  import { parseModelString } from "../config/model-resolver";
5
6
  import { settings } from "../config/settings";
6
7
  import { DEFAULT_MODEL_ROLE } from "../config/settings-schema";
@@ -112,6 +113,7 @@ export class ChatHandler {
112
113
  // and awaits the correlated `host_tool_result`. Reused verbatim from the stdio RPC
113
114
  // driver — the only WS-specific wiring is the `send()` output sink below.
114
115
  #hostToolBridge: RpcHostToolBridge;
116
+ #unsubscribeInteractions?: () => void;
115
117
 
116
118
  constructor(server: BridgeServer, session: AgentSession) {
117
119
  this.#server = server;
@@ -126,7 +128,50 @@ export class ChatHandler {
126
128
  }
127
129
 
128
130
  attach(): void {
131
+ this.#unsubscribeInteractions = this.#session.subscribe(event => {
132
+ if (event.type === "interaction")
133
+ this.#server.send({ type: "interaction_event", revision: event.event.revision, event: event.event });
134
+ if (event.type === "interaction_snapshot") this.#server.send(event);
135
+ if (event.type === "plan_available" || event.type === "plan_resolved") this.#server.send(event);
136
+ });
129
137
  this.#server.onMessage(msg => {
138
+ if (msg.type === "interaction_snapshot") {
139
+ this.#server.send({
140
+ type: "interaction_snapshot",
141
+ sessionId: this.#session.sessionId,
142
+ ...this.#session.userInteractions.snapshot(),
143
+ plan: this.#session.conversationPlans.current,
144
+ });
145
+ return;
146
+ }
147
+ if (isInteractionCommand(msg) && (msg.type === "interaction_respond" || msg.type === "interaction_cancel")) {
148
+ if (msg.type === "interaction_respond") {
149
+ const accepted = this.#session.userInteractions.respondExternal(
150
+ msg.requestId,
151
+ msg.responseId,
152
+ msg.value,
153
+ msg.identity,
154
+ );
155
+ this.#server.send({ type: "interaction_receipt", responseId: msg.responseId, accepted });
156
+ } else {
157
+ const request = this.#session.userInteractions.pending().find(request => request.id === msg.requestId);
158
+ const matches =
159
+ request?.identity &&
160
+ Object.entries(request.identity).every(
161
+ ([key, value]) => msg.identity[key as keyof typeof msg.identity] === value,
162
+ );
163
+ if (matches && this.#session.userInteractions.resolve(msg.requestId, "interrupted"))
164
+ void this.#session.abort();
165
+ }
166
+ return;
167
+ }
168
+ if (isInteractionCommand(msg) && msg.type === "plan_decide") {
169
+ void this.#session.decidePlan(msg.planId, msg.action).then(
170
+ receipt => this.#server.send({ type: "interaction_receipt", responseId: msg.responseId, ...receipt }),
171
+ () => this.#server.send({ type: "interaction_receipt", responseId: msg.responseId, accepted: false }),
172
+ );
173
+ return;
174
+ }
130
175
  if (this.#server.serveKind === "browser" && isBrowserChatRequest(msg)) this.#handleChatRequest(msg);
131
176
  else if (this.#server.serveKind === "office" && isTransportChatRequest(msg)) this.#handleChatRequest(msg);
132
177
  else if (isChatStop(msg)) this.#handleChatStop(msg as unknown as { id: string });
@@ -655,6 +700,7 @@ export class ChatHandler {
655
700
  }
656
701
 
657
702
  dispose(): void {
703
+ this.#unsubscribeInteractions?.();
658
704
  this.#disposed = true;
659
705
  this.#pendingRequest = null; // abandon any queued prompt — don't replay into a dead session
660
706
  // Fail any in-flight host-tool call — the session is going away.
@@ -112,8 +112,8 @@ export const BROWSER_TOOL_NAMES: readonly string[] = [
112
112
  * DELIBERATELY EXCLUDED:
113
113
  * - Every {@link BROWSER_TOOL_NAMES} entry — there is no browser to drive in a
114
114
  * document pane, so navigate/click/screenshot would only be hallucinated.
115
- * - `ask` (needs interactive stdinwould hang headless), `python` (spawns a
116
- * kernel → startup cost), `ssh`/`debug`/`notebook`/`browser`/`get_page_context`.
115
+ * - `python` (spawns a kernelstartup cost),
116
+ * `ssh`/`debug`/`notebook`/`browser`/`get_page_context`.
117
117
  *
118
118
  * SAFETY: the headless session pairs this with the bundled `sandbox-guard`
119
119
  * extension (see headless-bridge.ts `bundledExtensions`), which confines the file
@@ -145,4 +145,6 @@ export const OFFICE_TOOL_NAMES: readonly string[] = [
145
145
  "task",
146
146
  "calc",
147
147
  "inspect_image",
148
+ "request_user_input",
149
+ "request_user_input_async",
148
150
  ];
package/src/cli/args.ts CHANGED
@@ -470,7 +470,8 @@ ${chalk.bold("Available Tools (default-enabled unless noted):")}
470
470
  task - Launch sub-agents for parallel tasks
471
471
  todo_write - Manage todo/task lists
472
472
  web_search - Search the web
473
- ask - Ask user questions (interactive mode only)
473
+ request_user_input - Ask structured questions and wait (Plan mode by default)
474
+ request_user_input_async - Ask questions without blocking ongoing work
474
475
 
475
476
  ${chalk.bold("Sandbox Options:")}
476
477
  --no-sandbox ${LAUNCH_FLAGS["no-sandbox"].description}
@@ -951,24 +951,16 @@ export const SETTINGS_SCHEMA = {
951
951
  ui: { tab: "interaction", label: "Completion Notification", description: "Notify when the agent completes" },
952
952
  },
953
953
 
954
- "ask.timeout": {
955
- type: "number",
956
- default: 30,
954
+ "interactions.waitingInDefault": {
955
+ type: "boolean",
956
+ default: false,
957
957
  ui: {
958
958
  tab: "interaction",
959
- label: "Ask Timeout",
960
- description: "Auto-select recommended option after timeout (0 to disable)",
961
- submenu: true,
959
+ label: "Waiting questions in Default mode",
960
+ description: "Allow request_user_input outside Plan mode",
962
961
  },
963
962
  },
964
963
 
965
- "ask.notify": {
966
- type: "enum",
967
- values: ["on", "off"] as const,
968
- default: "on",
969
- ui: { tab: "interaction", label: "Ask Notification", description: "Notify when ask tool is waiting for input" },
970
- },
971
-
972
964
  // Speech-to-text
973
965
  "stt.enabled": {
974
966
  type: "boolean",
@@ -572,14 +572,6 @@ export class Settings {
572
572
  delete raw.queueMode;
573
573
  }
574
574
 
575
- // ask.timeout: ms -> seconds (if value > 1000, it's old ms format)
576
- if (raw.ask && typeof (raw.ask as Record<string, unknown>).timeout === "number") {
577
- const oldValue = (raw.ask as Record<string, unknown>).timeout as number;
578
- if (oldValue > 1000) {
579
- (raw.ask as Record<string, unknown>).timeout = Math.round(oldValue / 1000);
580
- }
581
- }
582
-
583
575
  // Migrate old flat "theme" string to nested theme.dark/theme.light
584
576
  if (typeof raw.theme === "string") {
585
577
  const oldTheme = raw.theme;
@@ -173,7 +173,7 @@ function nativeCapability(): string | undefined {
173
173
 
174
174
  /** Protocol 23 adds a workspace receipt without changing native action semantics. */
175
175
  function supportsNativeLifecycle(protocol: number | undefined): boolean {
176
- return protocol === 22 || protocol === 23;
176
+ return protocol !== undefined && protocol >= 22 && protocol <= 25;
177
177
  }
178
178
 
179
179
  function persistedTurns(ctx: ExtensionContext): PersistedTurn[] {
@@ -962,7 +962,7 @@ export default function herdrReporter(pi: ExtensionAPI): void {
962
962
  if (!normalizedPhasesObserved) scheduleSettledTurnReconcile(ctx);
963
963
  });
964
964
 
965
- // An interactive prompt (permission gate, ask tool, confirm/input) is
965
+ // An interactive prompt (permission gate, waiting user-input tool, confirm/input) is
966
966
  // awaiting the user: that is herdr's "needs attention" (blocked) state.
967
967
  pi.on("user_prompt_start", async event => {
968
968
  promptBlockedReason = getPromptBlockedReason(event.kind);
@@ -39,7 +39,6 @@ import type { PythonResult } from "../../ipy/executor";
39
39
  import type { Theme } from "../../modes/theme/theme";
40
40
  import type { CompactionPreparation, CompactionResult } from "../../session/compaction";
41
41
  import type { CustomMessage } from "../../session/messages";
42
- import type { InteractionQuestion, QuestionAnswers } from "../../session/question-types";
43
42
  import type {
44
43
  BranchSummaryEntry,
45
44
  CompactionEntry,
@@ -112,12 +111,6 @@ export type ExtensionWidgetContent = string[] | ExtensionUiComponentFactory | un
112
111
  * Each mode (interactive, RPC, print) provides its own implementation.
113
112
  */
114
113
  export interface ExtensionUIContext {
115
- /** Present a whole question set through one shared completion owner when supported. */
116
- questions?(
117
- questions: readonly InteractionQuestion[],
118
- dialogOptions?: ExtensionUIDialogOptions,
119
- ): Promise<QuestionAnswers | undefined>;
120
-
121
114
  /** Show a selector and return the user's choice. */
122
115
  select(title: string, options: string[], dialogOptions?: ExtensionUIDialogOptions): Promise<string | undefined>;
123
116
 
@@ -555,7 +548,7 @@ export interface MessageEndEvent {
555
548
  export type UserPromptKind = "select" | "confirm" | "input";
556
549
 
557
550
  /**
558
- * Fired when an interactive prompt (permission gate, `ask` tool, confirm/input
551
+ * Fired when an interactive prompt (permission gate, waiting user-input tool, confirm/input
559
552
  * dialog) is shown and is awaiting the user. Signals a "blocked / needs
560
553
  * attention" state that is otherwise not observable from the agent event stream,
561
554
  * since the session stays `isStreaming` while a prompt is open.
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { connect } from "node:net";
3
3
 
4
4
  export const HERDR_PROTOCOL_MIN_VERSION = 19;
5
- export const HERDR_PROTOCOL_MAX_VERSION = 23;
5
+ export const HERDR_PROTOCOL_MAX_VERSION = 25;
6
6
  const DEFAULT_TIMEOUT_MS = 5_000;
7
7
  const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
8
8
 
@@ -61,6 +61,11 @@ export class HerdrClient {
61
61
  return this.negotiatedProtocol;
62
62
  }
63
63
 
64
+ capabilityVersion(name: string): number | undefined {
65
+ const version = this.capabilities[name];
66
+ return typeof version === "number" && Number.isSafeInteger(version) ? version : undefined;
67
+ }
68
+
64
69
  hasCapability(name: string): boolean {
65
70
  return this.capabilities[name] === true;
66
71
  }
@@ -0,0 +1,221 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import type { ConversationPlan, PlanAction } from "../../../chat-ui/src/interactions/conversation-plan";
3
+ import type {
4
+ InteractionIdentity,
5
+ UserInteraction,
6
+ UserInteractionEvent,
7
+ UserInteractions,
8
+ } from "../session/user-interactions";
9
+
10
+ interface Client {
11
+ ensureProtocol(): Promise<void>;
12
+ capabilityVersion(name: string): number | undefined;
13
+ request(method: string, params: Record<string, unknown>): Promise<Record<string, unknown>>;
14
+ }
15
+ interface Binding {
16
+ execution_id: string;
17
+ pane_id: string;
18
+ producer: string;
19
+ generation: number;
20
+ }
21
+ interface Owner extends Binding {
22
+ session_id: string;
23
+ }
24
+ interface Target {
25
+ owner: Owner;
26
+ request_id: string;
27
+ }
28
+ interface Entry {
29
+ target: Target;
30
+ identity: InteractionIdentity;
31
+ report: Record<string, unknown>;
32
+ decide?: (action: PlanAction) => Promise<{ accepted: boolean }>;
33
+ }
34
+ const record = (value: unknown): value is Record<string, unknown> =>
35
+ value !== null && typeof value === "object" && !Array.isArray(value);
36
+
37
+ /** Additional observation never owns completion or disables the local interaction path. */
38
+ export class HerdrInteractionBridge {
39
+ #entries = new Map<string, Entry>();
40
+ #reports: Record<string, unknown>[] = [];
41
+ #acks: Record<string, unknown>[] = [];
42
+ #decisions = new Map<string, { action: unknown; accepted: boolean }>();
43
+ #unsubscribe: () => void;
44
+ #timer?: ReturnType<typeof setInterval>;
45
+ #flight?: Promise<void>;
46
+ #closed = false;
47
+ #failureReported = false;
48
+ constructor(
49
+ private readonly client: Client,
50
+ private readonly interactions: UserInteractions,
51
+ private readonly binding: Binding,
52
+ private readonly onError: (error: unknown) => void,
53
+ private readonly capability?: string,
54
+ poll = true,
55
+ ) {
56
+ this.#unsubscribe = interactions.subscribe(event => this.#observe(event));
57
+ for (const request of interactions.pending()) this.#open(request, 1);
58
+ if (poll) {
59
+ this.#timer = setInterval(() => {
60
+ void this.flush();
61
+ }, 1000);
62
+ this.#timer.unref?.();
63
+ }
64
+ }
65
+ #observe(event: UserInteractionEvent): void {
66
+ if (event.type === "opened") this.#open(event.interaction, event.revision);
67
+ else {
68
+ const entry = this.#entries.get(event.interaction.id);
69
+ if (!entry) return;
70
+ entry.report = { ...entry.report, event_revision: event.revision, state: event.reason ?? "owner_lost" };
71
+ this.#reports.push(structuredClone(entry.report));
72
+ }
73
+ }
74
+ #open(request: UserInteraction, revision: number): void {
75
+ const identity = request.identity;
76
+ if (!identity || (request.kind !== "request_user_input" && request.delivery !== "async")) return;
77
+ const target = { owner: { ...this.binding, session_id: identity.sessionId }, request_id: request.id };
78
+ const report = {
79
+ target,
80
+ thread_id: identity.threadId,
81
+ turn_id: identity.turnId,
82
+ item_id: identity.itemId,
83
+ question_ids: request.inputQuestions?.map(question => question.id) ?? [request.questionId ?? identity.itemId],
84
+ event_revision: revision,
85
+ kind: request.delivery === "async" ? "async" : "waiting",
86
+ state: "pending",
87
+ payload:
88
+ request.delivery === "async"
89
+ ? {
90
+ id: identity.itemId,
91
+ type: "agentMessage",
92
+ text: request.title,
93
+ phase: "final_answer",
94
+ delivery: "async",
95
+ questions: [{ title: request.title, ...(request.options ? { options: request.options } : {}) }],
96
+ }
97
+ : { questions: request.inputQuestions, isBlocking: true, autoResolutionMs: null },
98
+ };
99
+ this.#entries.set(request.id, { target, identity: structuredClone(identity), report });
100
+ this.#reports.push(structuredClone(report));
101
+ }
102
+ plan(
103
+ plan: ConversationPlan,
104
+ identity: InteractionIdentity,
105
+ decide: (action: PlanAction) => Promise<{ accepted: boolean }>,
106
+ ): void {
107
+ const target = { owner: { ...this.binding, session_id: identity.sessionId }, request_id: plan.id };
108
+ const report = {
109
+ target,
110
+ thread_id: identity.threadId,
111
+ turn_id: identity.turnId,
112
+ item_id: plan.itemId,
113
+ question_ids: [],
114
+ event_revision: plan.revision,
115
+ kind: "plan_decision",
116
+ payload: plan,
117
+ state: "pending",
118
+ };
119
+ this.#entries.set(plan.id, { target, identity, report, decide });
120
+ this.#reports.push(structuredClone(report));
121
+ }
122
+ resolvePlan(planId: string): void {
123
+ const entry = this.#entries.get(planId);
124
+ if (!entry) return;
125
+ entry.report = { ...entry.report, event_revision: Number(entry.report.event_revision) + 1, state: "answered" };
126
+ this.#reports.push(structuredClone(entry.report));
127
+ }
128
+ flush(): Promise<void> {
129
+ if (this.#closed) return Promise.resolve();
130
+ if (this.#flight) return this.#flight;
131
+ this.#flight = this.#flush()
132
+ .catch(error => {
133
+ if (!this.#failureReported) this.onError(error);
134
+ this.#failureReported = true;
135
+ })
136
+ .finally(() => {
137
+ this.#flight = undefined;
138
+ });
139
+ return this.#flight;
140
+ }
141
+ async #flush(): Promise<void> {
142
+ if (!this.#entries.size) return;
143
+ await this.client.ensureProtocol();
144
+ if (this.client.capabilityVersion("agent_interactions") !== 1)
145
+ throw new Error("Herdr interaction capability is unavailable");
146
+ await this.#flushAcknowledgements();
147
+ while (this.#reports.length) {
148
+ const report = this.#reports[0];
149
+ const response = await this.client.request("agent.interaction.report", {
150
+ ...report,
151
+ ...(this.capability ? { native_capability: this.capability } : {}),
152
+ });
153
+ if (response.type !== "agent_interaction") throw new Error("Herdr did not acknowledge the interaction report");
154
+ this.#reports.shift();
155
+ }
156
+ const owners = new Map(
157
+ [...this.#entries.values()]
158
+ .filter(entry => entry.report.state === "pending")
159
+ .map(entry => [JSON.stringify(entry.target.owner), entry.target.owner]),
160
+ );
161
+ for (const owner of owners.values()) {
162
+ const producer = { owner, ...(this.capability ? { native_capability: this.capability } : {}) };
163
+ const response = await this.client.request("agent.interaction.delivery.get", producer);
164
+ if (response.type !== "agent_interaction_deliveries" || !Array.isArray(response.deliveries))
165
+ throw new Error("Invalid Herdr delivery response");
166
+ for (const delivery of response.deliveries) {
167
+ if (
168
+ !record(delivery) ||
169
+ !record(delivery.receipt) ||
170
+ !record(delivery.receipt.target) ||
171
+ typeof delivery.receipt.response_id !== "string"
172
+ )
173
+ throw new Error("Invalid Herdr delivery identity");
174
+ const receipt = delivery.receipt;
175
+ const target = receipt.target as unknown as Target;
176
+ const entry = this.#entries.get(target.request_id);
177
+ if (!entry || !isDeepStrictEqual(target, entry.target)) throw new Error("Herdr delivery owner mismatch");
178
+ let accepted: boolean;
179
+ if (entry.decide) {
180
+ const prior = this.#decisions.get(receipt.response_id as string);
181
+ if (prior) accepted = prior.action === delivery.answer && prior.accepted;
182
+ else {
183
+ accepted =
184
+ typeof delivery.answer === "string" &&
185
+ ["implement", "fresh", "stay"].includes(delivery.answer) &&
186
+ (await entry.decide(delivery.answer as PlanAction)).accepted;
187
+ this.#decisions.set(receipt.response_id as string, { action: delivery.answer, accepted });
188
+ }
189
+ } else
190
+ accepted = this.interactions.respondExternal(
191
+ target.request_id,
192
+ receipt.response_id as string,
193
+ delivery.answer,
194
+ entry.identity,
195
+ );
196
+ this.#acks.push({
197
+ producer,
198
+ request_id: target.request_id,
199
+ response_id: receipt.response_id,
200
+ accepted,
201
+ });
202
+ await this.#flushAcknowledgements();
203
+ }
204
+ }
205
+ this.#failureReported = false;
206
+ }
207
+ async #flushAcknowledgements(): Promise<void> {
208
+ while (this.#acks.length) {
209
+ const ack = await this.client.request("agent.interaction.delivery.ack", this.#acks[0]);
210
+ if (ack.type !== "agent_interaction_receipt")
211
+ throw new Error("Herdr did not acknowledge producer answer delivery");
212
+ this.#acks.shift();
213
+ }
214
+ }
215
+ async close(): Promise<void> {
216
+ if (this.#timer) clearInterval(this.#timer);
217
+ this.#unsubscribe();
218
+ await this.flush();
219
+ this.#closed = true;
220
+ }
221
+ }
@@ -0,0 +1,103 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { ApiCatalogCategory, ApiCatalogIndex } from "./api-catalog-types";
3
+
4
+ export interface ApiCatalogDiscoveryDocument {
5
+ readonly id: string;
6
+ readonly categoryName: string;
7
+ readonly markdown: string;
8
+ }
9
+
10
+ export interface ApiCatalogDiscoveryCorpus {
11
+ readonly catalogVersion: string;
12
+ readonly documents: readonly ApiCatalogDiscoveryDocument[];
13
+ }
14
+
15
+ export interface ApiCatalogDiscoveryCandidate {
16
+ readonly categoryName: string;
17
+ readonly destination: string;
18
+ }
19
+
20
+ export function normalizeApiCatalogDiscoveryTerm(value: string): string {
21
+ return value.toLowerCase().replace(/[_\s]+/g, "-");
22
+ }
23
+
24
+ /** The legacy text predicate extracted without changing its semantics. */
25
+ export function matchesBaselineCatalogDiscoveryTerm(term: string, values: readonly string[]): boolean {
26
+ const normalized = normalizeApiCatalogDiscoveryTerm(term);
27
+ return values.some(value => normalizeApiCatalogDiscoveryTerm(value).includes(normalized));
28
+ }
29
+
30
+ function categoryDocument(category: ApiCatalogCategory): ApiCatalogDiscoveryDocument {
31
+ const destination = `xcsh://api-catalog/${category.name}`;
32
+ const operations = category.operations.flatMap(operation => [
33
+ `- Operation: ${operation.name}`,
34
+ ` - Alias: ${(operation.operationAliases ?? []).join(", ")}`,
35
+ ` - Description: ${operation.description}`,
36
+ ` - Method: ${operation.method.toUpperCase()}`,
37
+ ` - Path: ${operation.path}`,
38
+ ` - Operation ID: ${operation.operationId}`,
39
+ ]);
40
+
41
+ return {
42
+ id: `category:${category.name}`,
43
+ categoryName: category.name,
44
+ markdown: [
45
+ `# ${category.displayName}`,
46
+ "",
47
+ `- Category: ${category.name}`,
48
+ `- Destination: ${destination}`,
49
+ "",
50
+ "## Operations",
51
+ ...(operations.length > 0 ? operations : ["- None"]),
52
+ "",
53
+ ].join("\n"),
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Produces a stable, authoritative-only corpus. It intentionally contains no
59
+ * tenant data, credentials, generated examples, or speculative API fields.
60
+ */
61
+ export function buildApiCatalogDiscoveryCorpus(
62
+ index: ApiCatalogIndex,
63
+ data: Readonly<Record<string, ApiCatalogCategory>>,
64
+ ): ApiCatalogDiscoveryCorpus {
65
+ return {
66
+ catalogVersion: index.version,
67
+ documents: Object.values(data)
68
+ .slice()
69
+ .sort((left, right) => left.name.localeCompare(right.name))
70
+ .map(categoryDocument),
71
+ };
72
+ }
73
+
74
+ export function fingerprintApiCatalogDiscoveryCorpus(
75
+ corpus: ApiCatalogDiscoveryCorpus,
76
+ sourceSha: string,
77
+ engineVersion: string,
78
+ ): string {
79
+ const bytes = JSON.stringify({
80
+ sourceSha,
81
+ catalogVersion: corpus.catalogVersion,
82
+ engineVersion,
83
+ documents: corpus.documents,
84
+ });
85
+ return createHash("sha256").update(bytes).digest("hex");
86
+ }
87
+
88
+ /**
89
+ * Byte-equivalent baseline candidate selection for the existing renderer.
90
+ * Ranking is intentionally unchanged: canonical CRUD promotion stays in the
91
+ * renderer where it has access to API-spec evidence.
92
+ */
93
+ export function rankBaselineCatalogDiscovery(
94
+ term: string,
95
+ corpus: ApiCatalogDiscoveryCorpus,
96
+ ): readonly ApiCatalogDiscoveryCandidate[] {
97
+ return corpus.documents
98
+ .filter(document => matchesBaselineCatalogDiscoveryTerm(term, [document.markdown]))
99
+ .map(document => ({
100
+ categoryName: document.categoryName,
101
+ destination: `xcsh://api-catalog/${document.categoryName}`,
102
+ }));
103
+ }