@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.
Files changed (73) hide show
  1. package/README.md +61 -2
  2. package/dist/module.d.mts +23 -0
  3. package/dist/module.json +9 -0
  4. package/dist/module.mjs +37 -0
  5. package/dist/runtime/chat/index.d.ts +52 -0
  6. package/dist/runtime/chat/index.js +100 -0
  7. package/dist/runtime/chat/server.d.ts +36 -0
  8. package/dist/runtime/chat/server.js +27 -0
  9. package/dist/runtime/chat/types.d.ts +10 -0
  10. package/dist/runtime/chat/types.js +0 -0
  11. package/dist/runtime/client-capabilities.d.ts +29 -0
  12. package/dist/runtime/client-capabilities.js +25 -0
  13. package/dist/runtime/components-nuxt-ui/ForgeCapabilityApproval.d.vue.ts +2 -0
  14. package/dist/runtime/components-nuxt-ui/ForgeCapabilityApproval.vue +125 -0
  15. package/dist/runtime/components-nuxt-ui/ForgeCapabilityApproval.vue.d.ts +2 -0
  16. package/dist/runtime/components-nuxt-ui/ForgeCommandPalette.d.vue.ts +2 -0
  17. package/dist/runtime/components-nuxt-ui/ForgeCommandPalette.vue +218 -0
  18. package/dist/runtime/components-nuxt-ui/ForgeCommandPalette.vue.d.ts +2 -0
  19. package/dist/runtime/composables/defineClientCapability.d.ts +64 -0
  20. package/dist/runtime/composables/defineClientCapability.js +40 -0
  21. package/dist/runtime/composables/defineForgeAction.d.ts +82 -0
  22. package/dist/runtime/composables/defineForgeAction.js +147 -0
  23. package/dist/runtime/composables/useAgentCapabilities.d.ts +78 -0
  24. package/dist/runtime/composables/useAgentCapabilities.js +94 -0
  25. package/dist/runtime/composables/useCapabilityApproval.d.ts +109 -0
  26. package/dist/runtime/composables/useCapabilityApproval.js +74 -0
  27. package/dist/runtime/composables/useCapabilityInvocations.d.ts +150 -0
  28. package/dist/runtime/composables/useCapabilityInvocations.js +118 -0
  29. package/dist/runtime/composables/useCommandPalette.d.ts +28 -0
  30. package/dist/runtime/composables/useCommandPalette.js +49 -0
  31. package/dist/runtime/composables/usePaletteArgStep.d.ts +47 -0
  32. package/dist/runtime/composables/usePaletteArgStep.js +51 -0
  33. package/dist/runtime/internal/describe-error.d.ts +10 -0
  34. package/dist/runtime/internal/describe-error.js +12 -0
  35. package/dist/runtime/server/index.d.ts +7 -0
  36. package/dist/runtime/server/index.js +2 -0
  37. package/dist/runtime/server/utils/capability-registry.d.ts +35 -0
  38. package/dist/runtime/server/utils/capability-registry.js +24 -0
  39. package/dist/runtime/server/utils/client-capability-bridge.d.ts +647 -0
  40. package/dist/runtime/server/utils/client-capability-bridge.js +755 -0
  41. package/dist/runtime/shims-vue.d.ts +8 -0
  42. package/dist/runtime/types/client-capability.d.ts +159 -0
  43. package/dist/runtime/types/client-capability.js +0 -0
  44. package/dist/runtime/types/command-palette.d.ts +75 -0
  45. package/dist/runtime/types/command-palette.js +0 -0
  46. package/dist/runtime/types/forge-action.d.ts +152 -0
  47. package/dist/runtime/types/forge-action.js +0 -0
  48. package/dist/runtime/types/index.d.ts +3 -0
  49. package/dist/runtime/types/index.js +0 -0
  50. package/dist/types.d.mts +9 -0
  51. package/package.json +94 -4
  52. package/src/runtime/chat/index.ts +190 -0
  53. package/src/runtime/chat/server.ts +64 -0
  54. package/src/runtime/chat/types.ts +13 -0
  55. package/src/runtime/client-capabilities.ts +82 -0
  56. package/src/runtime/components-nuxt-ui/ForgeCapabilityApproval.vue +240 -0
  57. package/src/runtime/components-nuxt-ui/ForgeCommandPalette.vue +363 -0
  58. package/src/runtime/composables/defineClientCapability.ts +119 -0
  59. package/src/runtime/composables/defineForgeAction.ts +278 -0
  60. package/src/runtime/composables/useAgentCapabilities.ts +215 -0
  61. package/src/runtime/composables/useCapabilityApproval.ts +219 -0
  62. package/src/runtime/composables/useCapabilityInvocations.ts +325 -0
  63. package/src/runtime/composables/useCommandPalette.ts +97 -0
  64. package/src/runtime/composables/usePaletteArgStep.ts +110 -0
  65. package/src/runtime/internal/describe-error.ts +21 -0
  66. package/src/runtime/server/index.ts +8 -0
  67. package/src/runtime/server/utils/capability-registry.ts +66 -0
  68. package/src/runtime/server/utils/client-capability-bridge.ts +1201 -0
  69. package/src/runtime/shims-vue.d.ts +8 -0
  70. package/src/runtime/types/client-capability.ts +178 -0
  71. package/src/runtime/types/command-palette.ts +78 -0
  72. package/src/runtime/types/forge-action.ts +166 -0
  73. package/src/runtime/types/index.ts +24 -0
@@ -0,0 +1,278 @@
1
+ /**
2
+ * `defineForgeAction` — one declaration for Cmd+K and the agent.
3
+ *
4
+ * Spec: `docs/superpowers/specs/2026-08-22-forge-app-channel-design.md` §2.3,
5
+ * which adopts the platform's rule verbatim — *"All user-facing functionality is
6
+ * a command-palette action; register it so it is discoverable via Cmd+K **and**
7
+ * usable by the AI agent"*.
8
+ *
9
+ * ```ts
10
+ * export const openFile = defineForgeAction({
11
+ * id: "workspace.open_file", // → capability `app.workspace.open_file`
12
+ * label: "Open file…",
13
+ * description: "Open a file in the user's file viewer.",
14
+ * input: z.object({ path: z.string().min(1) }),
15
+ * arg: { placeholder: "path/to/file", toInput: (path) => ({ path }) },
16
+ * run: async ({ path }) => { await useFileViewer().openFile(path); return { opened: true, path } },
17
+ * })
18
+ * ```
19
+ *
20
+ * `audience` defaults to `['user', 'llm']`. That default is the whole point:
21
+ * a new UI feature is agent-controllable unless its author opts out, instead of
22
+ * agent-controllability being a bespoke per-feature integration.
23
+ *
24
+ * ## No third registry
25
+ *
26
+ * This does not store anything. It *projects* one declaration into the two
27
+ * registries forge already had — `useCommandPalette()` (idempotent by id) and
28
+ * `useAgentCapabilities()` (idempotent by name) — and `useForgeActions()`
29
+ * writes through to both. Those remain the single sources of truth for "what is
30
+ * in the palette" and "what may the agent call", so nothing can drift.
31
+ *
32
+ * ## The id/name seam
33
+ *
34
+ * The palette has always used bare ids (`workspace.open_file`); the platform
35
+ * **rejects** capability registrations outside the `app.` namespace (spec §2.2).
36
+ * The rule is therefore: **author the palette id, and the capability name is
37
+ * `app.<id>`.** An id that already carries the prefix is a mistake and throws,
38
+ * because otherwise `app.app.workspace.open_file` would ship silently.
39
+ */
40
+
41
+ import * as z from "zod";
42
+ import type { ClientCapabilityContext, ClientCapabilityLogger } from "../types/client-capability";
43
+ import type { PaletteAction } from "../types/command-palette";
44
+ import type {
45
+ DefineForgeActionArgs,
46
+ ForgeAction,
47
+ ForgeActionAudience,
48
+ ForgeActionContext,
49
+ } from "../types/forge-action";
50
+ import { defineClientCapability, deprecatedClientCapabilityAlias } from "./defineClientCapability";
51
+ import { useAgentCapabilities } from "./useAgentCapabilities";
52
+ import { useCommandPalette } from "./useCommandPalette";
53
+
54
+ /** The default when `audience` is omitted: reachable from both surfaces. */
55
+ export const DEFAULT_FORGE_ACTION_AUDIENCE: ForgeActionAudience[] = ["user", "llm"];
56
+
57
+ /** `<sector>.<verb>`, lowercase — what the derived `app.` name has to look like. */
58
+ const ID_PATTERN = /^[a-z0-9][a-z0-9_-]*(?:\.[a-z0-9][a-z0-9_-]*)+$/;
59
+
60
+ const defaultLog: ClientCapabilityLogger = {
61
+ info: (message, ...args) => console.log(message, ...args),
62
+ warn: (message, ...args) => console.warn(message, ...args),
63
+ error: (message, ...args) => console.error(message, ...args),
64
+ };
65
+
66
+ function fail(id: string, message: string): never {
67
+ throw new Error(`[defineForgeAction] ${id}: ${message}`);
68
+ }
69
+
70
+ /**
71
+ * Build a {@link ForgeAction} from one declaration.
72
+ *
73
+ * Throws — at define time, i.e. on the first import in dev — for the four
74
+ * declarations that could only fail later and further away:
75
+ * - an `id` that already starts with `app.` (see the id/name seam above),
76
+ * - an llm-audience action with no `description` (the model reads nothing),
77
+ * - an llm-audience `id` that is not `<sector>.<verb>` (spec §2.2's namespace),
78
+ * - a user-audience action whose input needs arguments but declares no `arg`
79
+ * (a palette row that exists only to fail on Enter).
80
+ */
81
+ export function defineForgeAction<T extends z.ZodType = z.ZodObject<Record<string, never>>>(
82
+ args: DefineForgeActionArgs<T>,
83
+ ): ForgeAction {
84
+ const { id } = args;
85
+ if (!id || id.trim().length === 0) fail("<empty>", "an action needs a non-empty id.");
86
+
87
+ const audience = args.audience ?? DEFAULT_FORGE_ACTION_AUDIENCE;
88
+ if (audience.length === 0) {
89
+ fail(id, "audience is empty — an action nobody can trigger is not an action.");
90
+ }
91
+ const forUser = audience.includes("user");
92
+ const forLlm = audience.includes("llm");
93
+
94
+ if (id.startsWith("app.")) {
95
+ fail(
96
+ id,
97
+ "the id is the PALETTE id and must not carry the `app.` prefix — it is added " +
98
+ `automatically to derive the capability name. Use "${id.slice(4)}".`,
99
+ );
100
+ }
101
+
102
+ const name = args.name ?? `app.${id}`;
103
+ if (forLlm) {
104
+ if (!args.description) {
105
+ fail(id, "an action with audience 'llm' must declare a description — the model reads it.");
106
+ }
107
+ if (!args.name && !ID_PATTERN.test(id)) {
108
+ fail(
109
+ id,
110
+ "an agent-visible id must be lowercase `<sector>.<verb>` (spec §2.2), so the " +
111
+ `derived capability name lands in the platform's \`app.\` namespace as \`app.${id}\`.`,
112
+ );
113
+ }
114
+ if (!name.startsWith("app.")) {
115
+ fail(
116
+ id,
117
+ `capability name "${name}" is outside the \`app.\` namespace, which the platform rejects.`,
118
+ );
119
+ }
120
+ }
121
+
122
+ const inputZod: z.ZodType = args.input ?? z.object({});
123
+
124
+ // A palette row must be runnable with what the palette can collect. Either
125
+ // the schema accepts `{}` (no arguments) or the author declared how to get the
126
+ // one string it needs. Anything else is a row that only ever throws.
127
+ const acceptsNoArgs = inputZod.safeParse({}).success;
128
+ if (forUser && !acceptsNoArgs && !args.arg) {
129
+ fail(
130
+ id,
131
+ "the input schema requires arguments but no `arg` was declared, so the Cmd+K entry " +
132
+ "could never run. Declare `arg: { placeholder, toInput }` (and optionally `options` " +
133
+ "for a picker), or narrow the audience to ['llm'].",
134
+ );
135
+ }
136
+
137
+ async function run(rawInput?: unknown, ctxIn?: Partial<ForgeActionContext>): Promise<unknown> {
138
+ const parsed = inputZod.parse(rawInput ?? {});
139
+ const ctx: ForgeActionContext = {
140
+ source: ctxIn?.source ?? "user",
141
+ name: ctxIn?.name ?? name,
142
+ sessionKey: ctxIn?.sessionKey ?? "",
143
+ callId: ctxIn?.callId,
144
+ log: ctxIn?.log ?? defaultLog,
145
+ };
146
+ return await args.run(parsed as z.infer<T>, ctx);
147
+ }
148
+
149
+ // ── The Cmd+K projection ────────────────────────────────────────────────
150
+ let paletteAction: PaletteAction | undefined;
151
+ if (forUser) {
152
+ paletteAction = {
153
+ id,
154
+ label: args.label,
155
+ // The model's description is written to be exhaustive; a palette row is
156
+ // one line. `hint` is where an action visible on both surfaces says the
157
+ // short version.
158
+ description: args.hint ?? args.description,
159
+ icon: args.icon,
160
+ keywords: args.keywords,
161
+ group: args.group,
162
+ // `arg` carries `toInput`, which is this module's business, not the
163
+ // palette's — project down to the prompt half the component consumes.
164
+ arg: args.arg
165
+ ? {
166
+ label: args.arg.label,
167
+ placeholder: args.arg.placeholder,
168
+ options: args.arg.options,
169
+ allowCustom: args.arg.allowCustom,
170
+ }
171
+ : undefined,
172
+ handler: async (value?: string) => {
173
+ const input = args.arg ? args.arg.toInput(value ?? "") : {};
174
+ // `requiresApproval` deliberately does NOT gate this path: the user is
175
+ // the actor here, and asking them to confirm the thing they just chose
176
+ // from Cmd+K is a dialog with no information in it. The gate exists for
177
+ // invocations the user did not initiate (spec §L7).
178
+ await run(input, { source: "user" });
179
+ },
180
+ };
181
+ }
182
+
183
+ // ── The agent projection ────────────────────────────────────────────────
184
+ const capability = forLlm
185
+ ? defineClientCapability({
186
+ name,
187
+ displayName: args.label,
188
+ description: args.description as string,
189
+ input: inputZod,
190
+ output: args.output,
191
+ kind: args.kind,
192
+ audience,
193
+ scope: args.scope,
194
+ requiresApproval: args.requiresApproval,
195
+ promptFragment: args.promptFragment,
196
+ callTimeoutMs: args.callTimeoutMs,
197
+ render: args.render,
198
+ // Input arrives already parsed by `useCapabilityInvocations`; re-parsing
199
+ // here would only pay the cost twice.
200
+ handler: async (input: unknown, capCtx: ClientCapabilityContext) =>
201
+ await args.run(input as z.infer<T>, {
202
+ source: "llm",
203
+ name: capCtx.name,
204
+ sessionKey: capCtx.sessionKey,
205
+ callId: capCtx.callId,
206
+ log: capCtx.log,
207
+ }),
208
+ })
209
+ : undefined;
210
+
211
+ const aliases =
212
+ capability && args.deprecatedNames
213
+ ? args.deprecatedNames.map((old) => deprecatedClientCapabilityAlias(capability, old))
214
+ : [];
215
+
216
+ return {
217
+ id,
218
+ name,
219
+ label: args.label,
220
+ audience,
221
+ inputZod,
222
+ paletteAction,
223
+ capability,
224
+ aliases,
225
+ run,
226
+ };
227
+ }
228
+
229
+ export interface UseForgeActionsReturn {
230
+ /**
231
+ * Write one action through to the registries its audience names. Idempotent:
232
+ * the palette replaces by id and the capability registry replaces by name,
233
+ * exactly as they already did, so re-registering on HMR or a remount is a
234
+ * no-op rather than a duplicate.
235
+ */
236
+ register(action: ForgeAction): ForgeAction;
237
+ /** Register several, in order. */
238
+ registerAll(actions: ForgeAction[]): void;
239
+ /**
240
+ * Remove an action from both registries, aliases included. Takes the action
241
+ * itself rather than an id — the aliases it owns cannot be derived from one.
242
+ */
243
+ unregister(action: ForgeAction): void;
244
+ }
245
+
246
+ /**
247
+ * Register forge actions into the palette and the agent capability registry.
248
+ *
249
+ * ```ts
250
+ * export default defineNuxtPlugin(() => {
251
+ * useForgeActions().registerAll([openFile, toggleSidebar])
252
+ * // then announce upward once — see the app-channel wiring
253
+ * })
254
+ * ```
255
+ */
256
+ export function useForgeActions(): UseForgeActionsReturn {
257
+ const palette = useCommandPalette();
258
+ const capabilities = useAgentCapabilities();
259
+
260
+ function register(action: ForgeAction): ForgeAction {
261
+ if (action.paletteAction) palette.registerAction(action.paletteAction);
262
+ if (action.capability) capabilities.register(action.capability);
263
+ for (const alias of action.aliases) capabilities.register(alias);
264
+ return action;
265
+ }
266
+
267
+ function registerAll(actions: ForgeAction[]): void {
268
+ for (const action of actions) register(action);
269
+ }
270
+
271
+ function unregister(action: ForgeAction): void {
272
+ palette.unregisterAction(action.id);
273
+ capabilities.unregister(action.name);
274
+ for (const alias of action.aliases) capabilities.unregister(alias.name);
275
+ }
276
+
277
+ return { register, registerAll, unregister };
278
+ }
@@ -0,0 +1,215 @@
1
+ import { type ShallowRef, shallowRef } from "vue";
2
+ import type {
3
+ ClientCapabilityAnnouncement,
4
+ ClientCapabilityDescriptor,
5
+ DefinedClientCapability,
6
+ } from "../types/client-capability";
7
+
8
+ // ── Module-level singleton ─────────────────────────────────────────────────
9
+ // Survives component unmounts — same pattern as useCommandPalette / capabilityRegistry.
10
+ //
11
+ // `shallowRef`, not `ref`, deliberately: a DefinedClientCapability holds live
12
+ // Zod schemas and a handler closure. Vue's deep reactive proxy would wrap the
13
+ // Zod instances, and zod v4's internal private fields do not survive being
14
+ // read through a Proxy — `inputZod.parse()` (ticket 04) would break. Only the
15
+ // array identity needs to be reactive, so a shallow ref is both correct and
16
+ // cheaper.
17
+ const _capabilities = shallowRef<DefinedClientCapability[]>([]);
18
+
19
+ /**
20
+ * Stable per-tab token, minted once per browser context. The bridge records it
21
+ * as the announcing session's owner, which is what spec §L4's last-writer-wins
22
+ * routing compares (implemented in ticket 05).
23
+ */
24
+ const _subscriberId = mintSubscriberId();
25
+
26
+ function mintSubscriberId(): string {
27
+ const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
28
+ if (typeof c?.randomUUID === "function") return c.randomUUID();
29
+ return `sub-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
30
+ }
31
+
32
+ // ── Public API types ───────────────────────────────────────────────────────
33
+
34
+ /** Minimal `fetch` shape the announce transport needs. Injectable for tests. */
35
+ export type CapabilityAnnounceFetcher = (
36
+ url: string,
37
+ init: { method: string; headers: Record<string, string>; body: string },
38
+ ) => Promise<{ ok: boolean; status: number }>;
39
+
40
+ export interface AnnounceOptions {
41
+ /**
42
+ * The upward POST endpoint, e.g. `/api/agent/capabilities`. The consuming app
43
+ * owns this route (spec §2) — forge-app-actions never assumes a path.
44
+ */
45
+ endpoint: string;
46
+ /**
47
+ * Extra fields merged into the JSON body. This is where the app supplies
48
+ * whatever identifies the session to its own route — a single-session app
49
+ * sends nothing; a multi-user app would send the parts of its
50
+ * `userId:slug:mode` key. Never send the derived key from the browser if the
51
+ * server can derive it itself.
52
+ */
53
+ body?: Record<string, unknown>;
54
+ /** Override the fetcher. Defaults to `globalThis.fetch`. */
55
+ fetcher?: CapabilityAnnounceFetcher;
56
+ }
57
+
58
+ export interface AnnounceResult {
59
+ ok: boolean;
60
+ /** How many capability descriptors were sent. `0` for a withdrawal. */
61
+ sent: number;
62
+ /** Present when `ok` is false. */
63
+ error?: string;
64
+ }
65
+
66
+ export interface UseAgentCapabilitiesReturn {
67
+ /** Register a capability. Idempotent by name — replaces an existing entry. */
68
+ register(capability: DefinedClientCapability): void;
69
+ /** Register several at once. Idempotent by name, in order. */
70
+ registerAll(capabilities: DefinedClientCapability[]): void;
71
+ /** Remove a previously registered capability. No-op if the name is unknown. */
72
+ unregister(name: string): void;
73
+ /** Look up one registered capability by name. */
74
+ get(name: string): DefinedClientCapability | undefined;
75
+ /** Snapshot of every registered capability (schemas and handlers included). */
76
+ list(): DefinedClientCapability[];
77
+ /** The JSON-serialisable projection announced upward. */
78
+ toWire(): ClientCapabilityDescriptor[];
79
+ /** Reactive list of registered capabilities. */
80
+ capabilities: Readonly<ShallowRef<DefinedClientCapability[]>>;
81
+ /** Stable per-tab token sent with every announcement. */
82
+ subscriberId: string;
83
+ /**
84
+ * POST the whole registry upward. Announcements are never deltas — the bridge
85
+ * replaces its stored list, so a reconnect re-announce is naturally idempotent
86
+ * (spec §L5).
87
+ *
88
+ * Never throws: a failed announce logs a warning and returns `ok: false`, so a
89
+ * dead route cannot break app mount.
90
+ */
91
+ announce(options: AnnounceOptions): Promise<AnnounceResult>;
92
+ /**
93
+ * POST an empty registry upward — the explicit withdrawal seam for spec §L1's
94
+ * "no browser, no capability". Ticket 05 adds the SSE-disconnect grace period
95
+ * that calls this from the server side; this is the voluntary client-side path.
96
+ */
97
+ withdraw(options: AnnounceOptions): Promise<AnnounceResult>;
98
+ /** Test-only: empty the singleton. Not for application code. */
99
+ reset(): void;
100
+ }
101
+
102
+ // ── Implementation ─────────────────────────────────────────────────────────
103
+
104
+ /** Project a defined capability down to the fields that cross the wire. */
105
+ function toDescriptor(capability: DefinedClientCapability): ClientCapabilityDescriptor {
106
+ const {
107
+ inputZod: _inputZod,
108
+ outputZod: _outputZod,
109
+ handler: _handler,
110
+ ...descriptor
111
+ } = capability;
112
+ return descriptor;
113
+ }
114
+
115
+ async function post(
116
+ options: AnnounceOptions,
117
+ payload: ClientCapabilityAnnouncement,
118
+ ): Promise<AnnounceResult> {
119
+ const sent = payload.capabilities.length;
120
+ const fetcher =
121
+ options.fetcher ?? (globalThis as { fetch?: CapabilityAnnounceFetcher }).fetch ?? undefined;
122
+
123
+ if (!fetcher) {
124
+ return { ok: false, sent, error: "no fetch implementation available" };
125
+ }
126
+
127
+ try {
128
+ const res = await fetcher(options.endpoint, {
129
+ method: "POST",
130
+ headers: { "content-type": "application/json" },
131
+ body: JSON.stringify({ ...options.body, ...payload }),
132
+ });
133
+ if (!res.ok) {
134
+ const error = `announce failed: HTTP ${res.status}`;
135
+ console.warn(`[useAgentCapabilities] ${error}`);
136
+ return { ok: false, sent, error };
137
+ }
138
+ return { ok: true, sent };
139
+ } catch (err) {
140
+ const error = err instanceof Error ? err.message : String(err);
141
+ console.warn(`[useAgentCapabilities] announce failed: ${error}`);
142
+ return { ok: false, sent, error };
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Browser-side agent capability registry.
148
+ *
149
+ * Components and plugins call `register()` to declare what the agent may invoke
150
+ * in this tab, then `announce()` once on mount so Nitro — and through it the
151
+ * agent's `capability_register` — reflects that a UI actually exists.
152
+ */
153
+ export function useAgentCapabilities(): UseAgentCapabilitiesReturn {
154
+ function register(capability: DefinedClientCapability): void {
155
+ const idx = _capabilities.value.findIndex((c) => c.name === capability.name);
156
+ if (idx !== -1) {
157
+ // Replace existing — idempotent
158
+ _capabilities.value = [
159
+ ..._capabilities.value.slice(0, idx),
160
+ capability,
161
+ ..._capabilities.value.slice(idx + 1),
162
+ ];
163
+ } else {
164
+ _capabilities.value = [..._capabilities.value, capability];
165
+ }
166
+ }
167
+
168
+ function registerAll(capabilities: DefinedClientCapability[]): void {
169
+ for (const capability of capabilities) register(capability);
170
+ }
171
+
172
+ function unregister(name: string): void {
173
+ const idx = _capabilities.value.findIndex((c) => c.name === name);
174
+ if (idx === -1) return; // no-op
175
+ _capabilities.value = _capabilities.value.filter((c) => c.name !== name);
176
+ }
177
+
178
+ function get(name: string): DefinedClientCapability | undefined {
179
+ return _capabilities.value.find((c) => c.name === name);
180
+ }
181
+
182
+ function list(): DefinedClientCapability[] {
183
+ return [..._capabilities.value];
184
+ }
185
+
186
+ function toWire(): ClientCapabilityDescriptor[] {
187
+ return _capabilities.value.map(toDescriptor);
188
+ }
189
+
190
+ function announce(options: AnnounceOptions): Promise<AnnounceResult> {
191
+ return post(options, { subscriberId: _subscriberId, capabilities: toWire() });
192
+ }
193
+
194
+ function withdraw(options: AnnounceOptions): Promise<AnnounceResult> {
195
+ return post(options, { subscriberId: _subscriberId, capabilities: [] });
196
+ }
197
+
198
+ function reset(): void {
199
+ _capabilities.value = [];
200
+ }
201
+
202
+ return {
203
+ register,
204
+ registerAll,
205
+ unregister,
206
+ get,
207
+ list,
208
+ toWire,
209
+ capabilities: _capabilities,
210
+ subscriberId: _subscriberId,
211
+ announce,
212
+ withdraw,
213
+ reset,
214
+ };
215
+ }