@skaile/forge-app-actions 0.0.0-stage → 0.2.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/README.md +61 -2
- package/dist/module.d.mts +23 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +37 -0
- package/dist/runtime/chat/index.d.ts +52 -0
- package/dist/runtime/chat/index.js +100 -0
- package/dist/runtime/chat/server.d.ts +36 -0
- package/dist/runtime/chat/server.js +27 -0
- package/dist/runtime/chat/types.d.ts +10 -0
- package/dist/runtime/chat/types.js +0 -0
- package/dist/runtime/client-capabilities.d.ts +29 -0
- package/dist/runtime/client-capabilities.js +25 -0
- package/dist/runtime/components-nuxt-ui/ForgeCapabilityApproval.d.vue.ts +2 -0
- package/dist/runtime/components-nuxt-ui/ForgeCapabilityApproval.vue +125 -0
- package/dist/runtime/components-nuxt-ui/ForgeCapabilityApproval.vue.d.ts +2 -0
- package/dist/runtime/components-nuxt-ui/ForgeCommandPalette.d.vue.ts +2 -0
- package/dist/runtime/components-nuxt-ui/ForgeCommandPalette.vue +218 -0
- package/dist/runtime/components-nuxt-ui/ForgeCommandPalette.vue.d.ts +2 -0
- package/dist/runtime/composables/defineClientCapability.d.ts +64 -0
- package/dist/runtime/composables/defineClientCapability.js +40 -0
- package/dist/runtime/composables/defineForgeAction.d.ts +82 -0
- package/dist/runtime/composables/defineForgeAction.js +147 -0
- package/dist/runtime/composables/useAgentCapabilities.d.ts +78 -0
- package/dist/runtime/composables/useAgentCapabilities.js +94 -0
- package/dist/runtime/composables/useCapabilityApproval.d.ts +109 -0
- package/dist/runtime/composables/useCapabilityApproval.js +74 -0
- package/dist/runtime/composables/useCapabilityInvocations.d.ts +150 -0
- package/dist/runtime/composables/useCapabilityInvocations.js +118 -0
- package/dist/runtime/composables/useCommandPalette.d.ts +28 -0
- package/dist/runtime/composables/useCommandPalette.js +49 -0
- package/dist/runtime/composables/usePaletteArgStep.d.ts +47 -0
- package/dist/runtime/composables/usePaletteArgStep.js +51 -0
- package/dist/runtime/internal/describe-error.d.ts +10 -0
- package/dist/runtime/internal/describe-error.js +12 -0
- package/dist/runtime/server/index.d.ts +7 -0
- package/dist/runtime/server/index.js +2 -0
- package/dist/runtime/server/utils/capability-registry.d.ts +35 -0
- package/dist/runtime/server/utils/capability-registry.js +24 -0
- package/dist/runtime/server/utils/client-capability-bridge.d.ts +647 -0
- package/dist/runtime/server/utils/client-capability-bridge.js +755 -0
- package/dist/runtime/shims-vue.d.ts +8 -0
- package/dist/runtime/types/client-capability.d.ts +159 -0
- package/dist/runtime/types/client-capability.js +0 -0
- package/dist/runtime/types/command-palette.d.ts +75 -0
- package/dist/runtime/types/command-palette.js +0 -0
- package/dist/runtime/types/forge-action.d.ts +152 -0
- package/dist/runtime/types/forge-action.js +0 -0
- package/dist/runtime/types/index.d.ts +3 -0
- package/dist/runtime/types/index.js +0 -0
- package/dist/types.d.mts +9 -0
- package/package.json +94 -4
- package/src/runtime/chat/index.ts +190 -0
- package/src/runtime/chat/server.ts +64 -0
- package/src/runtime/chat/types.ts +13 -0
- package/src/runtime/client-capabilities.ts +82 -0
- package/src/runtime/components-nuxt-ui/ForgeCapabilityApproval.vue +240 -0
- package/src/runtime/components-nuxt-ui/ForgeCommandPalette.vue +363 -0
- package/src/runtime/composables/defineClientCapability.ts +119 -0
- package/src/runtime/composables/defineForgeAction.ts +278 -0
- package/src/runtime/composables/useAgentCapabilities.ts +215 -0
- package/src/runtime/composables/useCapabilityApproval.ts +219 -0
- package/src/runtime/composables/useCapabilityInvocations.ts +325 -0
- package/src/runtime/composables/useCommandPalette.ts +97 -0
- package/src/runtime/composables/usePaletteArgStep.ts +110 -0
- package/src/runtime/internal/describe-error.ts +21 -0
- package/src/runtime/server/index.ts +8 -0
- package/src/runtime/server/utils/capability-registry.ts +66 -0
- package/src/runtime/server/utils/client-capability-bridge.ts +1201 -0
- package/src/runtime/shims-vue.d.ts +8 -0
- package/src/runtime/types/client-capability.ts +178 -0
- package/src/runtime/types/command-palette.ts +78 -0
- package/src/runtime/types/forge-action.ts +166 -0
- package/src/runtime/types/index.ts +24 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser-side **approval gate** for agent-invoked capabilities
|
|
3
|
+
* (spec §3 L7).
|
|
4
|
+
*
|
|
5
|
+
* On the platform, `requiresApproval` is a gateway feature: the gateway parks
|
|
6
|
+
* the invocation, renders a confirm card, and only then forwards the call to
|
|
7
|
+
* the app. Forge has no such gate — but it does not need one, because the forge
|
|
8
|
+
* handler already runs in the browser. So the gate is simply: the inbound
|
|
9
|
+
* dispatcher awaits a confirm dialog before it dispatches. Far simpler
|
|
10
|
+
* machinery, identical observable contract (`{ error: "declined" }` back to the
|
|
11
|
+
* agent, handler untouched).
|
|
12
|
+
*
|
|
13
|
+
* This module is the queue behind that dialog. It holds no UI of its own —
|
|
14
|
+
* `<ForgeCapabilityApproval />` (one variant per UI kit) renders
|
|
15
|
+
* {@link UseCapabilityApprovalReturn.current} and calls `approve` / `decline`.
|
|
16
|
+
* A consumer that wants its own dialog can ignore the component and read the
|
|
17
|
+
* same queue.
|
|
18
|
+
*
|
|
19
|
+
* **Every request settles exactly once, and settling removes it from the
|
|
20
|
+
* queue.** That is the difference between a cancellable dialog and one that
|
|
21
|
+
* merely loses a race: when the per-call deadline elapses, the entry
|
|
22
|
+
* disappears — the dialog closes, the promise resolves `"timeout"`, and the
|
|
23
|
+
* dispatcher answers the parked call. Nothing is left open for a user to
|
|
24
|
+
* confirm after the agent has already been told the call expired.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { type ComputedRef, computed, type ShallowRef, shallowRef } from "vue";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* How an approval request ended.
|
|
31
|
+
*
|
|
32
|
+
* `timeout` and `cancelled` are both "no answer", kept apart because they have
|
|
33
|
+
* different causes: `timeout` is this browser's own per-call deadline elapsing,
|
|
34
|
+
* `cancelled` is the caller pulling the request (an `AbortSignal`, or
|
|
35
|
+
* {@link UseCapabilityApprovalReturn.reset} on teardown).
|
|
36
|
+
*/
|
|
37
|
+
export type CapabilityApprovalDecision = "approved" | "declined" | "timeout" | "cancelled";
|
|
38
|
+
|
|
39
|
+
/** Default per-call deadline for an unanswered dialog. */
|
|
40
|
+
export const DEFAULT_APPROVAL_TIMEOUT_MS = 30_000;
|
|
41
|
+
|
|
42
|
+
/** What the caller asks the user to confirm. */
|
|
43
|
+
export interface CapabilityApprovalOptions {
|
|
44
|
+
/** Correlation token of the originating `capability_invoked` event. */
|
|
45
|
+
callId: string;
|
|
46
|
+
/** Capability name as invoked — possibly a deprecated alias. */
|
|
47
|
+
name: string;
|
|
48
|
+
/** Human-readable label, when the capability declared one. */
|
|
49
|
+
displayName?: string;
|
|
50
|
+
/** The capability's own description, shown as the "what this does" line. */
|
|
51
|
+
description?: string;
|
|
52
|
+
/**
|
|
53
|
+
* The **validated** arguments the handler would receive. Validation runs
|
|
54
|
+
* before the gate, so the user is never asked to approve a call that would
|
|
55
|
+
* have been rejected anyway, and what the dialog shows is what will run.
|
|
56
|
+
*/
|
|
57
|
+
input?: unknown;
|
|
58
|
+
/**
|
|
59
|
+
* Deadline in ms. `undefined` falls back to
|
|
60
|
+
* {@link DEFAULT_APPROVAL_TIMEOUT_MS}; `0` or a negative value means no
|
|
61
|
+
* browser-side deadline (the agent runner's own backstop still applies).
|
|
62
|
+
*/
|
|
63
|
+
timeoutMs?: number;
|
|
64
|
+
/** Cancels the request from outside — settles it `"cancelled"`. */
|
|
65
|
+
signal?: AbortSignal;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One entry in the approval queue, as the dialog renders it. */
|
|
69
|
+
export interface CapabilityApprovalRequest {
|
|
70
|
+
/** Queue-unique id. Distinct from `callId`: the queue owns this one. */
|
|
71
|
+
id: string;
|
|
72
|
+
callId: string;
|
|
73
|
+
name: string;
|
|
74
|
+
displayName?: string;
|
|
75
|
+
description?: string;
|
|
76
|
+
input?: unknown;
|
|
77
|
+
/** `Date.now()` when the request was enqueued. */
|
|
78
|
+
requestedAt: number;
|
|
79
|
+
/** Effective deadline in ms, or `undefined` when there is none. */
|
|
80
|
+
timeoutMs?: number;
|
|
81
|
+
/** `requestedAt + timeoutMs`, for a countdown. `undefined` when no deadline. */
|
|
82
|
+
expiresAt?: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface UseCapabilityApprovalReturn {
|
|
86
|
+
/**
|
|
87
|
+
* The pending queue, oldest first.
|
|
88
|
+
*
|
|
89
|
+
* `shallowRef` for the same reason `useAgentCapabilities` uses one: entries
|
|
90
|
+
* carry arbitrary handler input, and Vue's deep proxy has no business
|
|
91
|
+
* rewriting values that are about to be handed to application code.
|
|
92
|
+
*/
|
|
93
|
+
pending: Readonly<ShallowRef<CapabilityApprovalRequest[]>>;
|
|
94
|
+
/** The request the dialog should show — the oldest pending one, or `null`. */
|
|
95
|
+
current: ComputedRef<CapabilityApprovalRequest | null>;
|
|
96
|
+
/**
|
|
97
|
+
* Ask for confirmation. Resolves — never rejects — when the user answers,
|
|
98
|
+
* the deadline elapses, or the request is cancelled.
|
|
99
|
+
*/
|
|
100
|
+
request(options: CapabilityApprovalOptions): Promise<CapabilityApprovalDecision>;
|
|
101
|
+
/** Answer a request `"approved"`. Returns false if it already settled. */
|
|
102
|
+
approve(id: string): boolean;
|
|
103
|
+
/** Answer a request `"declined"`. Returns false if it already settled. */
|
|
104
|
+
decline(id: string): boolean;
|
|
105
|
+
/** Withdraw a request `"cancelled"`. Returns false if it already settled. */
|
|
106
|
+
cancel(id: string): boolean;
|
|
107
|
+
/** Cancel everything pending — session teardown, tests. */
|
|
108
|
+
reset(): void;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Module-level singleton ─────────────────────────────────────────────────
|
|
112
|
+
// Same pattern as useAgentCapabilities / useCommandPalette: one queue per
|
|
113
|
+
// browser tab, so the dialog can be mounted once in app.vue while any number
|
|
114
|
+
// of dispatch call sites feed it.
|
|
115
|
+
|
|
116
|
+
const _pending = shallowRef<CapabilityApprovalRequest[]>([]);
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Settlers live OUTSIDE the reactive ref on purpose. The queue is pure data
|
|
120
|
+
* that a template renders; the functions that resolve the promises are not
|
|
121
|
+
* something Vue should ever see, proxy, or diff.
|
|
122
|
+
*/
|
|
123
|
+
const _settlers = new Map<string, (decision: CapabilityApprovalDecision) => boolean>();
|
|
124
|
+
|
|
125
|
+
let _seq = 0;
|
|
126
|
+
|
|
127
|
+
function nextId(): string {
|
|
128
|
+
_seq += 1;
|
|
129
|
+
return `approval-${_seq}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const _current = computed<CapabilityApprovalRequest | null>(() => _pending.value[0] ?? null);
|
|
133
|
+
|
|
134
|
+
function settle(id: string, decision: CapabilityApprovalDecision): boolean {
|
|
135
|
+
return _settlers.get(id)?.(decision) ?? false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function request(options: CapabilityApprovalOptions): Promise<CapabilityApprovalDecision> {
|
|
139
|
+
return new Promise<CapabilityApprovalDecision>((resolve) => {
|
|
140
|
+
// An already-aborted signal never reaches the queue: showing a dialog that
|
|
141
|
+
// is dead on arrival would be worse than not showing one.
|
|
142
|
+
if (options.signal?.aborted) {
|
|
143
|
+
resolve("cancelled");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const id = nextId();
|
|
148
|
+
const requestedAt = Date.now();
|
|
149
|
+
const configured = options.timeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS;
|
|
150
|
+
const timeoutMs = configured > 0 ? configured : undefined;
|
|
151
|
+
|
|
152
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
153
|
+
let settled = false;
|
|
154
|
+
|
|
155
|
+
const finish = (decision: CapabilityApprovalDecision): boolean => {
|
|
156
|
+
if (settled) return false;
|
|
157
|
+
settled = true;
|
|
158
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
159
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
160
|
+
_settlers.delete(id);
|
|
161
|
+
// Removing the entry is what closes the dialog. Do it on EVERY exit,
|
|
162
|
+
// including timeout — a dialog that outlives its own call is exactly the
|
|
163
|
+
// leak this gate must not have.
|
|
164
|
+
_pending.value = _pending.value.filter((entry) => entry.id !== id);
|
|
165
|
+
resolve(decision);
|
|
166
|
+
return true;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
function onAbort() {
|
|
170
|
+
finish("cancelled");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
_settlers.set(id, finish);
|
|
174
|
+
|
|
175
|
+
if (timeoutMs !== undefined) {
|
|
176
|
+
timer = setTimeout(() => finish("timeout"), timeoutMs);
|
|
177
|
+
// Node/Bun timers keep the event loop alive; a pending confirm dialog is
|
|
178
|
+
// not a reason to hold a process open (matters for SSR and for tests).
|
|
179
|
+
(timer as unknown as { unref?: () => void }).unref?.();
|
|
180
|
+
}
|
|
181
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
182
|
+
|
|
183
|
+
_pending.value = [
|
|
184
|
+
..._pending.value,
|
|
185
|
+
{
|
|
186
|
+
id,
|
|
187
|
+
callId: options.callId,
|
|
188
|
+
name: options.name,
|
|
189
|
+
displayName: options.displayName,
|
|
190
|
+
description: options.description,
|
|
191
|
+
input: options.input,
|
|
192
|
+
requestedAt,
|
|
193
|
+
timeoutMs,
|
|
194
|
+
expiresAt: timeoutMs === undefined ? undefined : requestedAt + timeoutMs,
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The approval queue behind `requiresApproval`.
|
|
202
|
+
*
|
|
203
|
+
* Stateless accessor over a module-level singleton — call it from anywhere.
|
|
204
|
+
*/
|
|
205
|
+
export function useCapabilityApproval(): UseCapabilityApprovalReturn {
|
|
206
|
+
return {
|
|
207
|
+
pending: _pending,
|
|
208
|
+
current: _current,
|
|
209
|
+
request,
|
|
210
|
+
approve: (id: string) => settle(id, "approved"),
|
|
211
|
+
decline: (id: string) => settle(id, "declined"),
|
|
212
|
+
cancel: (id: string) => settle(id, "cancelled"),
|
|
213
|
+
reset: () => {
|
|
214
|
+
for (const id of [..._settlers.keys()]) settle(id, "cancelled");
|
|
215
|
+
// Belt and braces: settle() already empties the queue entry by entry.
|
|
216
|
+
_pending.value = [];
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of the forge app channel's **invoke** leg.
|
|
3
|
+
*
|
|
4
|
+
* `useAgentCapabilities()` announces what the browser can do; this composable
|
|
5
|
+
* is what happens when the agent takes it up on the offer. One
|
|
6
|
+
* `capability_invoked` arrives over the app's existing SSE stream and this
|
|
7
|
+
* module:
|
|
8
|
+
*
|
|
9
|
+
* 1. looks the name up in the browser registry (deprecated aliases included,
|
|
10
|
+
* because an alias is registered as its own entry sharing the handler),
|
|
11
|
+
* 2. validates the raw input against the capability's **live zod schema** —
|
|
12
|
+
* `inputSchema` on the wire is only a hint to the model, the schema here is
|
|
13
|
+
* the enforcement, and it runs BEFORE the handler,
|
|
14
|
+
* 3. for a capability that declares `requiresApproval`, awaits the user's
|
|
15
|
+
* confirmation (spec §3 L7) — declining answers the agent and the handler
|
|
16
|
+
* never runs,
|
|
17
|
+
* 4. runs the handler, where the real `ref()`s live,
|
|
18
|
+
* 5. POSTs `{ callId, result }` back so Nitro can answer the parked call.
|
|
19
|
+
*
|
|
20
|
+
* A thrown handler error becomes `{ error }`, which the agent runner turns into
|
|
21
|
+
* a rejected tool call — the agent is told the truth, not a silent success.
|
|
22
|
+
*
|
|
23
|
+
* See `docs/superpowers/specs/2026-08-22-forge-app-channel-design.md` §2.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describeError } from "../internal/describe-error";
|
|
27
|
+
import type {
|
|
28
|
+
ClientCapabilityContext,
|
|
29
|
+
ClientCapabilityLogger,
|
|
30
|
+
DefinedClientCapability,
|
|
31
|
+
} from "../types/client-capability";
|
|
32
|
+
import { type CapabilityAnnounceFetcher, useAgentCapabilities } from "./useAgentCapabilities";
|
|
33
|
+
import { DEFAULT_APPROVAL_TIMEOUT_MS, useCapabilityApproval } from "./useCapabilityApproval";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The agent → browser event, as it arrives on the SSE stream. Consumer apps tag
|
|
37
|
+
* it with their own routing fields (forge-workspace adds `_mode`); those are
|
|
38
|
+
* ignored here.
|
|
39
|
+
*/
|
|
40
|
+
export interface CapabilityInvokedEvent {
|
|
41
|
+
type: "capability_invoked";
|
|
42
|
+
/** Correlation token to answer with. */
|
|
43
|
+
callId: string;
|
|
44
|
+
/** Capability name as invoked — possibly a deprecated alias. */
|
|
45
|
+
name: string;
|
|
46
|
+
/** Raw, unvalidated arguments from the model. */
|
|
47
|
+
input?: unknown;
|
|
48
|
+
/**
|
|
49
|
+
* Spec §L4 — the one subscriber this invocation is addressed to, as elected
|
|
50
|
+
* by `ClientCapabilityBridge.routeTo()`. Present only when the app forwards
|
|
51
|
+
* it; absent (or `null`) means "whoever is listening", the pre-05 behaviour.
|
|
52
|
+
*
|
|
53
|
+
* A tab whose own `useAgentCapabilities().subscriberId` does not match is the
|
|
54
|
+
* *incumbent*: it stays connected and simply ignores the frame.
|
|
55
|
+
*/
|
|
56
|
+
ownerId?: string | null;
|
|
57
|
+
/**
|
|
58
|
+
* Apps tag their SSE frames with their own routing fields — forge-workspace
|
|
59
|
+
* adds `_mode`. They are passed through untouched, and kept on the type so a
|
|
60
|
+
* caller can still read its own tag after the guard has narrowed the frame.
|
|
61
|
+
*/
|
|
62
|
+
[key: string]: unknown;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface HandleInvocationOptions {
|
|
66
|
+
/**
|
|
67
|
+
* The upward POST endpoint that answers a parked call, e.g.
|
|
68
|
+
* `/api/agent/capability-result`. The consuming app owns this route —
|
|
69
|
+
* forge-app-actions never assumes a path.
|
|
70
|
+
*/
|
|
71
|
+
endpoint: string;
|
|
72
|
+
/**
|
|
73
|
+
* Extra fields merged into the JSON body, identifying the session to the
|
|
74
|
+
* app's own route (e.g. `{ mode: "work" }`). The session key
|
|
75
|
+
* itself is derived server-side; never send it from the browser.
|
|
76
|
+
*/
|
|
77
|
+
body?: Record<string, unknown>;
|
|
78
|
+
/**
|
|
79
|
+
* Opaque session key for the handler's {@link ClientCapabilityContext}.
|
|
80
|
+
* Informational only — the browser does not get to name its own session.
|
|
81
|
+
*/
|
|
82
|
+
sessionKey?: string;
|
|
83
|
+
/** Override the fetcher. Defaults to `globalThis.fetch`. */
|
|
84
|
+
fetcher?: CapabilityAnnounceFetcher;
|
|
85
|
+
/** Logger handed to the handler and used for this module's own warnings. */
|
|
86
|
+
log?: ClientCapabilityLogger;
|
|
87
|
+
/**
|
|
88
|
+
* Deadline for an unanswered approval dialog, in ms. Only consulted for a
|
|
89
|
+
* capability that declares `requiresApproval`, and only when the capability
|
|
90
|
+
* itself declares no `callTimeoutMs`. Defaults to
|
|
91
|
+
* {@link DEFAULT_APPROVAL_TIMEOUT_MS}; `0` disables the browser-side deadline
|
|
92
|
+
* and leaves the agent runner's own backstop as the only limit.
|
|
93
|
+
*/
|
|
94
|
+
approvalTimeoutMs?: number;
|
|
95
|
+
/**
|
|
96
|
+
* Cancels a pending approval dialog from outside — session teardown, a mode
|
|
97
|
+
* switch, a route change. An aborted signal settles the dialog as
|
|
98
|
+
* `approval-cancelled`; it does **not** interrupt a handler that has already
|
|
99
|
+
* started.
|
|
100
|
+
*/
|
|
101
|
+
signal?: AbortSignal;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Why an invocation failed, when it did. */
|
|
105
|
+
export type InvocationFailure =
|
|
106
|
+
/** No capability of that name is registered in this browser. */
|
|
107
|
+
| "unknown-capability"
|
|
108
|
+
/** The input did not satisfy the declared zod schema. The handler never ran. */
|
|
109
|
+
| "invalid-input"
|
|
110
|
+
/**
|
|
111
|
+
* The capability declared `requiresApproval` and the user said no. The
|
|
112
|
+
* handler never ran and nothing was mutated.
|
|
113
|
+
*/
|
|
114
|
+
| "declined"
|
|
115
|
+
/**
|
|
116
|
+
* An approval dialog went unanswered past the per-call deadline. The dialog
|
|
117
|
+
* was closed and the handler never ran.
|
|
118
|
+
*/
|
|
119
|
+
| "approval-timeout"
|
|
120
|
+
/**
|
|
121
|
+
* A pending approval dialog was withdrawn — `options.signal` aborted, or
|
|
122
|
+
* `useCapabilityApproval().reset()` ran on teardown. The handler never ran.
|
|
123
|
+
*/
|
|
124
|
+
| "approval-cancelled"
|
|
125
|
+
/** The handler itself threw. */
|
|
126
|
+
| "handler-error";
|
|
127
|
+
|
|
128
|
+
/** What {@link UseCapabilityInvocationsReturn.handle} did with one event. */
|
|
129
|
+
export interface InvocationOutcome {
|
|
130
|
+
callId: string;
|
|
131
|
+
name: string;
|
|
132
|
+
ok: boolean;
|
|
133
|
+
/** The handler's return value, when `ok`. */
|
|
134
|
+
result?: unknown;
|
|
135
|
+
/** The message sent back to the agent, when not `ok`. */
|
|
136
|
+
error?: string;
|
|
137
|
+
/** Present when not `ok`. */
|
|
138
|
+
failure?: InvocationFailure;
|
|
139
|
+
/** Whether the answering POST succeeded. */
|
|
140
|
+
posted: boolean;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface UseCapabilityInvocationsReturn {
|
|
144
|
+
/** Narrowing type-guard for an SSE frame. */
|
|
145
|
+
isInvocation(event: unknown): event is CapabilityInvokedEvent;
|
|
146
|
+
/**
|
|
147
|
+
* Handle one SSE frame. Returns `null` — and does nothing — when the frame is
|
|
148
|
+
* not a `capability_invoked`, so a caller can use it as the first line of its
|
|
149
|
+
* event switch and fall through. Also `null` when the frame names a different
|
|
150
|
+
* tab as its §L4 owner: this tab is the inert incumbent for that call.
|
|
151
|
+
*
|
|
152
|
+
* Never throws: every failure path ends in an `{ error }` answer to the agent.
|
|
153
|
+
*/
|
|
154
|
+
handle(event: unknown, options: HandleInvocationOptions): Promise<InvocationOutcome | null>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const defaultLog: ClientCapabilityLogger = {
|
|
158
|
+
info: (message, ...args) => console.log(message, ...args),
|
|
159
|
+
warn: (message, ...args) => console.warn(message, ...args),
|
|
160
|
+
error: (message, ...args) => console.error(message, ...args),
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
function isInvocationEvent(event: unknown): event is CapabilityInvokedEvent {
|
|
164
|
+
if (!event || typeof event !== "object") return false;
|
|
165
|
+
const e = event as Record<string, unknown>;
|
|
166
|
+
return (
|
|
167
|
+
e.type === "capability_invoked" &&
|
|
168
|
+
typeof e.callId === "string" &&
|
|
169
|
+
e.callId.length > 0 &&
|
|
170
|
+
typeof e.name === "string" &&
|
|
171
|
+
e.name.length > 0
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function postResult(
|
|
176
|
+
options: HandleInvocationOptions,
|
|
177
|
+
callId: string,
|
|
178
|
+
result: unknown,
|
|
179
|
+
log: ClientCapabilityLogger,
|
|
180
|
+
): Promise<boolean> {
|
|
181
|
+
const fetcher =
|
|
182
|
+
options.fetcher ?? (globalThis as { fetch?: CapabilityAnnounceFetcher }).fetch ?? undefined;
|
|
183
|
+
if (!fetcher) {
|
|
184
|
+
log.warn("[useCapabilityInvocations] no fetch implementation available");
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
const res = await fetcher(options.endpoint, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: { "content-type": "application/json" },
|
|
191
|
+
body: JSON.stringify({ ...options.body, callId, result }),
|
|
192
|
+
});
|
|
193
|
+
if (!res.ok) {
|
|
194
|
+
log.warn(`[useCapabilityInvocations] capability-result POST failed: HTTP ${res.status}`);
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
return true;
|
|
198
|
+
} catch (err) {
|
|
199
|
+
log.warn(`[useCapabilityInvocations] capability-result POST failed: ${describeError(err)}`);
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Inbound dispatcher for agent-invoked browser capabilities.
|
|
206
|
+
*
|
|
207
|
+
* Stateless — the registry it dispatches against is
|
|
208
|
+
* `useAgentCapabilities()`'s module-level singleton.
|
|
209
|
+
*/
|
|
210
|
+
export function useCapabilityInvocations(): UseCapabilityInvocationsReturn {
|
|
211
|
+
async function handle(
|
|
212
|
+
event: unknown,
|
|
213
|
+
options: HandleInvocationOptions,
|
|
214
|
+
): Promise<InvocationOutcome | null> {
|
|
215
|
+
if (!isInvocationEvent(event)) return null;
|
|
216
|
+
|
|
217
|
+
const log = options.log ?? defaultLog;
|
|
218
|
+
const { callId, name } = event;
|
|
219
|
+
const registry = useAgentCapabilities();
|
|
220
|
+
|
|
221
|
+
// Spec §L4 — single owner, last-writer-wins. Two tabs on one session both
|
|
222
|
+
// announce, both stay connected, but only the most recently announced one
|
|
223
|
+
// runs handlers. Apps are expected to filter their fan-out server-side as
|
|
224
|
+
// well; this is the second line of defence for fan-outs that cannot, and it
|
|
225
|
+
// is what stops a side effect from happening twice. The incumbent answers
|
|
226
|
+
// nothing — the owner's POST is the single answer to this callId.
|
|
227
|
+
if (typeof event.ownerId === "string" && event.ownerId !== registry.subscriberId) {
|
|
228
|
+
log.info(
|
|
229
|
+
`[useCapabilityInvocations] ${name} (${callId}) is addressed to another tab; ignoring`,
|
|
230
|
+
);
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
const capability: DefinedClientCapability | undefined = registry.get(name);
|
|
234
|
+
|
|
235
|
+
const fail = async (failure: InvocationFailure, error: string): Promise<InvocationOutcome> => {
|
|
236
|
+
log.warn(`[useCapabilityInvocations] ${name} (${callId}) ${failure}: ${error}`);
|
|
237
|
+
const posted = await postResult(options, callId, { error }, log);
|
|
238
|
+
return { callId, name, ok: false, error, failure, posted };
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
if (!capability) {
|
|
242
|
+
return fail(
|
|
243
|
+
"unknown-capability",
|
|
244
|
+
`No capability named "${name}" is registered in this browser.`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Validate BEFORE the handler runs. `inputSchema` on the wire only shapes
|
|
249
|
+
// what the model tries to send; this is what actually enforces it.
|
|
250
|
+
let input: unknown;
|
|
251
|
+
try {
|
|
252
|
+
input = capability.inputZod.parse(event.input ?? {});
|
|
253
|
+
} catch (err) {
|
|
254
|
+
return fail("invalid-input", `Invalid input for "${name}": ${describeError(err)}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// The approval gate (spec §3 L7). It sits AFTER validation on purpose: the
|
|
258
|
+
// user is shown the arguments that would actually run, and an invocation
|
|
259
|
+
// the schema would have rejected never interrupts them.
|
|
260
|
+
//
|
|
261
|
+
// A capability that does not declare `requiresApproval` skips this branch
|
|
262
|
+
// entirely — no dialog, no extra await, no behaviour change.
|
|
263
|
+
if (capability.requiresApproval) {
|
|
264
|
+
const decision = await useCapabilityApproval().request({
|
|
265
|
+
callId,
|
|
266
|
+
name,
|
|
267
|
+
displayName: capability.displayName,
|
|
268
|
+
description: capability.description,
|
|
269
|
+
input,
|
|
270
|
+
// The capability's own per-call timeout governs its dialog, so the gate
|
|
271
|
+
// cannot outlive the call the bridge parked for it.
|
|
272
|
+
timeoutMs:
|
|
273
|
+
capability.callTimeoutMs ?? options.approvalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS,
|
|
274
|
+
signal: options.signal,
|
|
275
|
+
});
|
|
276
|
+
if (decision === "declined") {
|
|
277
|
+
return fail("declined", `The user declined "${name}".`);
|
|
278
|
+
}
|
|
279
|
+
if (decision === "timeout") {
|
|
280
|
+
return fail(
|
|
281
|
+
"approval-timeout",
|
|
282
|
+
`Approval for "${name}" was not answered in time; the action did not run.`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
if (decision !== "approved") {
|
|
286
|
+
return fail(
|
|
287
|
+
"approval-cancelled",
|
|
288
|
+
`Approval for "${name}" was cancelled; the action did not run.`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const ctx: ClientCapabilityContext = {
|
|
294
|
+
sessionKey: options.sessionKey ?? "",
|
|
295
|
+
callId,
|
|
296
|
+
name,
|
|
297
|
+
log,
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
let result: unknown;
|
|
301
|
+
try {
|
|
302
|
+
result = await capability.handler(input, ctx);
|
|
303
|
+
} catch (err) {
|
|
304
|
+
return fail("handler-error", describeError(err));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Output validation is advisory. A handler that returns the wrong shape is
|
|
308
|
+
// an app bug, not an agent-visible failure, and turning it into one would
|
|
309
|
+
// invent a new way for a working action to report failure.
|
|
310
|
+
if (capability.outputZod) {
|
|
311
|
+
const check = capability.outputZod.safeParse(result);
|
|
312
|
+
if (!check.success) {
|
|
313
|
+
log.warn(
|
|
314
|
+
`[useCapabilityInvocations] ${name} returned a value that does not match its ` +
|
|
315
|
+
`declared output schema: ${describeError(check.error)}`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const posted = await postResult(options, callId, result, log);
|
|
321
|
+
return { callId, name, ok: true, result, posted };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return { isInvocation: isInvocationEvent, handle };
|
|
325
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { readonly, ref } from "vue";
|
|
2
|
+
import type { DeepReadonly, Ref } from "vue";
|
|
3
|
+
import type { PaletteAction } from "../types/command-palette";
|
|
4
|
+
|
|
5
|
+
// ── Module-level singleton ─────────────────────────────────────────────────
|
|
6
|
+
// Survives component unmounts — same pattern as capabilityRegistry.
|
|
7
|
+
const _actions = ref<PaletteAction[]>([]);
|
|
8
|
+
const _isOpen = ref(false);
|
|
9
|
+
|
|
10
|
+
// ── Public API types ───────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface UseCommandPaletteReturn {
|
|
13
|
+
/** Register an action. Idempotent by id — replaces existing if same id. */
|
|
14
|
+
registerAction(action: PaletteAction): void;
|
|
15
|
+
/** Remove a previously registered action. No-op if id not found. */
|
|
16
|
+
unregisterAction(id: string): void;
|
|
17
|
+
/**
|
|
18
|
+
* Fuzzy-filter registered actions by search string.
|
|
19
|
+
* Matches against label, description, and keywords.
|
|
20
|
+
* Returns all actions when search is empty or whitespace-only.
|
|
21
|
+
* Results are sorted: exact-label-start matches first, then substring.
|
|
22
|
+
*/
|
|
23
|
+
queryActions(search: string): PaletteAction[];
|
|
24
|
+
/** Reactive list of all currently registered actions. */
|
|
25
|
+
actions: DeepReadonly<Ref<PaletteAction[]>>;
|
|
26
|
+
/** Open/close state (read-only — toggled by ForgeCommandPalette). */
|
|
27
|
+
isOpen: Readonly<Ref<boolean>>;
|
|
28
|
+
/** Programmatically open the palette. */
|
|
29
|
+
open(): void;
|
|
30
|
+
/** Programmatically close the palette. */
|
|
31
|
+
close(): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Implementation ─────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Global command palette registry.
|
|
38
|
+
* Components call registerAction() to expose actions in the Cmd+K palette.
|
|
39
|
+
*/
|
|
40
|
+
export function useCommandPalette(): UseCommandPaletteReturn {
|
|
41
|
+
function registerAction(action: PaletteAction): void {
|
|
42
|
+
const idx = _actions.value.findIndex((a) => a.id === action.id);
|
|
43
|
+
if (idx !== -1) {
|
|
44
|
+
// Replace existing — idempotent
|
|
45
|
+
_actions.value = [..._actions.value.slice(0, idx), action, ..._actions.value.slice(idx + 1)];
|
|
46
|
+
} else {
|
|
47
|
+
_actions.value = [..._actions.value, action];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function unregisterAction(id: string): void {
|
|
52
|
+
const idx = _actions.value.findIndex((a) => a.id === id);
|
|
53
|
+
if (idx === -1) return; // no-op
|
|
54
|
+
_actions.value = _actions.value.filter((a) => a.id !== id);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function queryActions(search: string): PaletteAction[] {
|
|
58
|
+
const trimmed = search.trim().toLowerCase();
|
|
59
|
+
if (!trimmed) return [..._actions.value];
|
|
60
|
+
|
|
61
|
+
const matches = _actions.value.filter((action) => {
|
|
62
|
+
const haystack = [action.label, action.description ?? "", ...(action.keywords ?? [])]
|
|
63
|
+
.join(" ")
|
|
64
|
+
.toLowerCase();
|
|
65
|
+
return haystack.includes(trimmed);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Sort: exact-label-start matches first, then remaining substring matches
|
|
69
|
+
matches.sort((a, b) => {
|
|
70
|
+
const aStarts = a.label.toLowerCase().startsWith(trimmed);
|
|
71
|
+
const bStarts = b.label.toLowerCase().startsWith(trimmed);
|
|
72
|
+
if (aStarts && !bStarts) return -1;
|
|
73
|
+
if (!aStarts && bStarts) return 1;
|
|
74
|
+
return 0;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return matches;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function open(): void {
|
|
81
|
+
_isOpen.value = true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function close(): void {
|
|
85
|
+
_isOpen.value = false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
registerAction,
|
|
90
|
+
unregisterAction,
|
|
91
|
+
queryActions,
|
|
92
|
+
actions: readonly(_actions),
|
|
93
|
+
isOpen: readonly(_isOpen),
|
|
94
|
+
open,
|
|
95
|
+
close,
|
|
96
|
+
};
|
|
97
|
+
}
|