@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,755 @@
1
+ import * as z from "zod";
2
+ import {
3
+ capabilityRegistry
4
+ } from "./capability-registry.js";
5
+ export const NO_UI_ATTACHED_ERROR = "No UI is attached to this session, so the capability cannot run. Tell the user to open the app in a browser and try again.";
6
+ export const SESSION_ENDED_ERROR = "The UI session ended before the capability returned a result.";
7
+ export const DEFAULT_DISCONNECT_GRACE_MS = 5e3;
8
+ export const DEFAULT_CLIENT_CALL_TIMEOUT_MS = 3e4;
9
+ export const CALL_TIMEOUT_ERROR_PREFIX = "The UI did not answer";
10
+ export function capabilityTimeoutError(name, timeoutMs) {
11
+ return `${CALL_TIMEOUT_ERROR_PREFIX} "${name}" within ${Math.round(timeoutMs / 1e3)}s. The browser may have navigated away or the handler may be stuck. Tell the user what you were trying to do and ask them to retry.`;
12
+ }
13
+ export const clientCapabilityDescriptorSchema = z.object({
14
+ name: z.string().min(1),
15
+ displayName: z.string().optional(),
16
+ description: z.string(),
17
+ inputSchema: z.record(z.string(), z.unknown()),
18
+ outputSchema: z.record(z.string(), z.unknown()).optional(),
19
+ side: z.literal("client").optional(),
20
+ scope: z.enum(["session", "turn"]).optional(),
21
+ requiresApproval: z.boolean().optional(),
22
+ kind: z.enum(["effect", "render", "query"]).optional(),
23
+ audience: z.array(z.enum(["llm", "user", "runtime"])).optional(),
24
+ promptFragment: z.string().optional(),
25
+ callTimeoutMs: z.number().optional(),
26
+ render: z.unknown().optional()
27
+ });
28
+ export const clientCapabilityAnnouncementSchema = z.object({
29
+ subscriberId: z.string().optional(),
30
+ capabilities: z.array(clientCapabilityDescriptorSchema)
31
+ });
32
+ export const clientCapabilityResultSchema = z.object({
33
+ callId: z.string().min(1),
34
+ result: z.unknown()
35
+ });
36
+ const MAX_REMEMBERED_ANNOUNCERS = 16;
37
+ const defaultLogger = {
38
+ info: (message) => console.log(message),
39
+ warn: (message) => console.warn(message)
40
+ };
41
+ export class ClientCapabilityBridge {
42
+ sessions = /* @__PURE__ */ new Map();
43
+ senders = /* @__PURE__ */ new Map();
44
+ invokers = /* @__PURE__ */ new Map();
45
+ /** In-flight invocations, keyed `${sessionKey}` → `callId` → entry. */
46
+ parked = /* @__PURE__ */ new Map();
47
+ /** §L3 timers, parallel to {@link parked}. */
48
+ callTimers = /* @__PURE__ */ new Map();
49
+ /**
50
+ * Live downstream subscribers per session key → **open connection count** for
51
+ * that subscriber id.
52
+ *
53
+ * Ref-counted rather than a plain set because one tab can legitimately hold
54
+ * more than one stream for a key (a remount that opens before the old socket
55
+ * finishes closing, a second component subscribing). A plain set would let
56
+ * the first close retire a subscriber the second connection still needs.
57
+ *
58
+ * The entry is created on the first {@link addSubscriber} for a key and then
59
+ * kept **forever**, even when it empties. Its presence is what
60
+ * {@link tracksSubscribers} reports, and that is what lets an app opt in to
61
+ * §L1/§L2/§L4 by wiring its SSE endpoint without forcing every app to.
62
+ */
63
+ subscribers = /* @__PURE__ */ new Map();
64
+ /** §L1 pending grace timers, keyed by session key. */
65
+ graceTimers = /* @__PURE__ */ new Map();
66
+ /** Last merged list pushed per session key, for the §L5 change diff. */
67
+ lastSent = /* @__PURE__ */ new Map();
68
+ /** Names in the last pushed list, so §L5 can deregister what dropped out. */
69
+ lastSentNames = /* @__PURE__ */ new Map();
70
+ /** `${sessionKey}::${name}` pairs already warned about, so §L6 logs once. */
71
+ warnedCollisions = /* @__PURE__ */ new Set();
72
+ serverRegistry;
73
+ log;
74
+ disconnectGraceMs;
75
+ defaultCallTimeoutMs;
76
+ /** Monotonic announce counter backing §L4's owner election. */
77
+ announceSeq = 0;
78
+ constructor(options = {}) {
79
+ this.serverRegistry = options.serverRegistry ?? capabilityRegistry;
80
+ this.log = options.logger ?? defaultLogger;
81
+ this.disconnectGraceMs = options.disconnectGraceMs ?? DEFAULT_DISCONNECT_GRACE_MS;
82
+ this.defaultCallTimeoutMs = options.callTimeoutMs ?? DEFAULT_CLIENT_CALL_TIMEOUT_MS;
83
+ }
84
+ /**
85
+ * Record (or replace) the capability list a browser announced for
86
+ * `sessionKey`. Announcements are whole registries, never deltas.
87
+ *
88
+ * §L4 — an announcement re-seats ownership only when it is a genuine *write*:
89
+ * a subscriber this key has not heard from before, or a registry whose
90
+ * contents actually changed. An identical re-announce from a subscriber
91
+ * already on record refreshes liveness and cancels the §L1 grace timer, but
92
+ * leaves the running order alone.
93
+ *
94
+ * That distinction matters because re-announcing is not a rare event. Every
95
+ * SSE reopen re-announces (§L5), and at least one consumer — forge-concept —
96
+ * ties its stream to a drawer the user can toggle, so a tab can re-announce
97
+ * many times a minute without the user meaning anything by it. Under plain
98
+ * "the newest announce wins", a background tab's reconnect would silently
99
+ * steal routing from the foreground tab the user is actually looking at.
100
+ * A genuinely new tab, or a tab that changed what it offers, still wins.
101
+ *
102
+ * @returns `true` when the stored client list changed.
103
+ */
104
+ announce(sessionKey, capabilities, options = {}) {
105
+ const normalised = capabilities.map((c) => this.normalise(c));
106
+ const previous = this.sessions.get(sessionKey);
107
+ const changed = signature(previous?.capabilities ?? []) !== signature(normalised);
108
+ const announcedAt = Date.now();
109
+ this.cancelGrace(sessionKey);
110
+ const announcers = previous?.announcers ?? /* @__PURE__ */ new Map();
111
+ const { subscriberId } = options;
112
+ if (subscriberId && (changed || !announcers.has(subscriberId))) {
113
+ announcers.set(subscriberId, ++this.announceSeq);
114
+ }
115
+ this.sessions.set(sessionKey, {
116
+ capabilities: normalised,
117
+ ownerId: highestAnnouncer(announcers) ?? subscriberId ?? null,
118
+ announcedAt,
119
+ announcers
120
+ });
121
+ const announcedNames = new Set(normalised.map((c) => c.name));
122
+ for (const key of [...this.warnedCollisions]) {
123
+ const [key0, name] = splitWarnKey(key);
124
+ if (key0 === sessionKey && !announcedNames.has(name)) {
125
+ this.warnedCollisions.delete(key);
126
+ }
127
+ }
128
+ return changed;
129
+ }
130
+ /**
131
+ * Drop the announced list for `sessionKey` — the "no browser attached" state.
132
+ * The sender registration survives, so a later re-announce can still push.
133
+ *
134
+ * @returns `true` when something was actually removed.
135
+ */
136
+ withdraw(sessionKey) {
137
+ const had = (this.sessions.get(sessionKey)?.capabilities.length ?? 0) > 0;
138
+ this.sessions.delete(sessionKey);
139
+ for (const key of [...this.warnedCollisions]) {
140
+ if (splitWarnKey(key)[0] === sessionKey) this.warnedCollisions.delete(key);
141
+ }
142
+ return had;
143
+ }
144
+ /** Client-announced capabilities for `sessionKey`, in wire shape. */
145
+ listClient(sessionKey) {
146
+ return [...this.sessions.get(sessionKey)?.capabilities ?? []];
147
+ }
148
+ /** True when a browser has announced a non-empty registry for `sessionKey`. */
149
+ hasClient(sessionKey) {
150
+ return (this.sessions.get(sessionKey)?.capabilities.length ?? 0) > 0;
151
+ }
152
+ /**
153
+ * The §L4 owner as elected from announcements alone, ignoring who is still
154
+ * connected — i.e. the most recent *genuine* announcer (see {@link announce}
155
+ * for why a bare re-announce does not count as one).
156
+ *
157
+ * {@link routeTo} is what routing actually uses; this is the answer for an
158
+ * app that does not track subscribers, and the fallback when it does but
159
+ * nobody connected has announced.
160
+ */
161
+ ownerOf(sessionKey) {
162
+ return this.sessions.get(sessionKey)?.ownerId ?? null;
163
+ }
164
+ /**
165
+ * Spec §L4 — the subscriber an invocation for `sessionKey` should be routed
166
+ * to right now: **the most recently announced one that is still connected**.
167
+ *
168
+ * Plain last-writer-wins ({@link ownerOf}) is the rule while every announcer
169
+ * is still around. It is not enough on its own: when the newest tab closes,
170
+ * routing to its dead subscriber id would strand every subsequent call in the
171
+ * §L2 no-UI branch even though an older tab is sitting there fully able to
172
+ * answer. So the election is over *live* announcers.
173
+ *
174
+ * Returns `null` when the app does not track subscribers (in which case the
175
+ * announced {@link ownerOf} is used verbatim, preserving pre-05 behaviour) or
176
+ * when nobody who is connected has announced.
177
+ */
178
+ routeTo(sessionKey) {
179
+ if (!this.tracksSubscribers(sessionKey)) return this.ownerOf(sessionKey);
180
+ const live = this.subscribers.get(sessionKey);
181
+ const announcers = this.sessions.get(sessionKey)?.announcers;
182
+ if (!live || !announcers) return null;
183
+ let best = null;
184
+ let bestSeq = -1;
185
+ for (const [id, seq] of announcers) {
186
+ if (!live.has(id)) continue;
187
+ if (seq > bestSeq) {
188
+ best = id;
189
+ bestSeq = seq;
190
+ }
191
+ }
192
+ return best;
193
+ }
194
+ // ── Subscribers (spec §L1, §L2, §L4) ─────────────────────────────────────
195
+ /**
196
+ * Record that a downstream subscriber (one SSE connection) is live for
197
+ * `sessionKey`. Apps call this from the endpoint that opens the stream.
198
+ *
199
+ * Idempotent per `subscriberId`, and it cancels any pending §L1 grace timer —
200
+ * which is exactly what makes a page reload a no-op: the tab's stream closes
201
+ * and reopens well inside the grace window, so nothing is ever deregistered.
202
+ */
203
+ addSubscriber(sessionKey, subscriberId) {
204
+ this.cancelGrace(sessionKey);
205
+ const live = this.subscribers.get(sessionKey) ?? /* @__PURE__ */ new Map();
206
+ live.set(subscriberId, (live.get(subscriberId) ?? 0) + 1);
207
+ this.subscribers.set(sessionKey, live);
208
+ }
209
+ /**
210
+ * Record that a subscriber's stream closed.
211
+ *
212
+ * When it was the last one for `sessionKey`, arm the §L1 grace timer. On
213
+ * expiry — and only if nobody has connected or announced since — the
214
+ * browser-announced registry is withdrawn and the resulting
215
+ * `capability_deregister` is pushed upstream.
216
+ *
217
+ * Deliberately *not* {@link clear}: the sender and invoker belong to the agent
218
+ * session, which is still very much alive, and tearing them down would leave
219
+ * a returning browser unable to register anything until the session restarts.
220
+ *
221
+ * @returns `true` when this disconnect armed the grace timer.
222
+ */
223
+ removeSubscriber(sessionKey, subscriberId) {
224
+ const live = this.subscribers.get(sessionKey);
225
+ if (!live) return false;
226
+ const open = (live.get(subscriberId) ?? 0) - 1;
227
+ if (open > 0) {
228
+ live.set(subscriberId, open);
229
+ return false;
230
+ }
231
+ live.delete(subscriberId);
232
+ this.pruneAnnouncers(sessionKey);
233
+ if (live.size > 0) return false;
234
+ this.cancelGrace(sessionKey);
235
+ const timer = setTimeout(() => {
236
+ this.graceTimers.delete(sessionKey);
237
+ void this.expireSession(sessionKey);
238
+ }, this.disconnectGraceMs);
239
+ timer.unref?.();
240
+ this.graceTimers.set(sessionKey, timer);
241
+ return true;
242
+ }
243
+ /**
244
+ * Keep {@link ClientCapabilitySessionState.announcers} bounded: a long-lived
245
+ * session key can accumulate one entry per tab that ever announced. Live
246
+ * subscribers and the current owner are never dropped; the oldest departed
247
+ * announcers go first.
248
+ */
249
+ pruneAnnouncers(sessionKey) {
250
+ const announcers = this.sessions.get(sessionKey)?.announcers;
251
+ if (!announcers || announcers.size <= MAX_REMEMBERED_ANNOUNCERS) return;
252
+ const live = this.subscribers.get(sessionKey);
253
+ const owner = highestAnnouncer(announcers);
254
+ const droppable = [...announcers.entries()].filter(([id]) => id !== owner && !live?.has(id)).sort((a, b) => a[1] - b[1]);
255
+ for (const [id] of droppable) {
256
+ if (announcers.size <= MAX_REMEMBERED_ANNOUNCERS) break;
257
+ announcers.delete(id);
258
+ }
259
+ }
260
+ /** Live subscriber ids for `sessionKey`. */
261
+ subscriberIds(sessionKey) {
262
+ return [...this.subscribers.get(sessionKey)?.keys() ?? []];
263
+ }
264
+ /** How many downstream subscribers are live for `sessionKey`. */
265
+ subscriberCount(sessionKey) {
266
+ return this.subscribers.get(sessionKey)?.size ?? 0;
267
+ }
268
+ /**
269
+ * Whether this app has ever registered a subscriber for `sessionKey` — i.e.
270
+ * whether subscriber counts are meaningful for it.
271
+ *
272
+ * An app that never calls {@link addSubscriber} keeps the pre-05 contract:
273
+ * liveness is whatever its invoker returns. One that does gets §L1/§L2/§L4.
274
+ */
275
+ tracksSubscribers(sessionKey) {
276
+ return this.subscribers.has(sessionKey);
277
+ }
278
+ /** Whether a §L1 grace timer is currently pending for `sessionKey`. */
279
+ isAwaitingGrace(sessionKey) {
280
+ return this.graceTimers.has(sessionKey);
281
+ }
282
+ cancelGrace(sessionKey) {
283
+ const timer = this.graceTimers.get(sessionKey);
284
+ if (timer === void 0) return;
285
+ clearTimeout(timer);
286
+ this.graceTimers.delete(sessionKey);
287
+ }
288
+ /**
289
+ * §L1 grace expiry: the last browser really is gone. Drop the announced
290
+ * registry and push the deregistration upstream.
291
+ *
292
+ * In-flight calls are left alone on purpose. Their §L3 timers are the
293
+ * authority on when they die, and a grace expiry is not proof the answer is
294
+ * never coming — a slow reload can land just past the window.
295
+ */
296
+ async expireSession(sessionKey) {
297
+ if (this.subscriberCount(sessionKey) > 0) return;
298
+ const names = this.listClient(sessionKey).map((c) => c.name);
299
+ if (!this.withdraw(sessionKey)) return;
300
+ this.log.info(
301
+ `[client-capability-bridge] [${sessionKey}] last subscriber gone for ${this.disconnectGraceMs}ms \u2014 deregistering ${names.length} browser-owned capabilit${names.length === 1 ? "y" : "ies"}`
302
+ );
303
+ await this.sync(sessionKey);
304
+ }
305
+ /** Every session key that currently has an announced registry. */
306
+ sessionKeys() {
307
+ return [...this.sessions.keys()];
308
+ }
309
+ /**
310
+ * The single list to register with the agent: the server-side registry plus
311
+ * whatever the browser announced.
312
+ *
313
+ * Spec §L6 — on a name collision **the server entry wins** and the bridge logs
314
+ * a warning (once per session key + name).
315
+ */
316
+ merged(sessionKey) {
317
+ const server = this.serverRegistry.list();
318
+ const serverNames = new Set(server.map((c) => c.name));
319
+ const merged = [...server];
320
+ for (const client of this.listClient(sessionKey)) {
321
+ if (serverNames.has(client.name)) {
322
+ const warnKey = `${sessionKey}::${client.name}`;
323
+ if (!this.warnedCollisions.has(warnKey)) {
324
+ this.warnedCollisions.add(warnKey);
325
+ this.log.warn(
326
+ `[client-capability-bridge] capability name collision on "${client.name}" for session "${sessionKey}": the server-side entry wins, the client-announced entry is ignored.`
327
+ );
328
+ }
329
+ continue;
330
+ }
331
+ merged.push(client);
332
+ }
333
+ return merged;
334
+ }
335
+ /**
336
+ * Register the transport used to push commands upstream for `sessionKey`.
337
+ * Pass `null` to detach (e.g. when the agent session is replaced).
338
+ */
339
+ setSender(sessionKey, sender) {
340
+ if (sender) {
341
+ this.senders.set(sessionKey, sender);
342
+ } else {
343
+ this.senders.delete(sessionKey);
344
+ this.lastSent.delete(sessionKey);
345
+ this.lastSentNames.delete(sessionKey);
346
+ }
347
+ }
348
+ /** Whether a sender is currently attached for `sessionKey`. */
349
+ hasSender(sessionKey) {
350
+ return this.senders.has(sessionKey);
351
+ }
352
+ // ── Invocation (spec §2 downward leg, §L2) ───────────────────────────────
353
+ /**
354
+ * Register the transport that pushes an invocation **down to the browser** for
355
+ * `sessionKey` — in every forge app, the app's existing SSE fan-out. Pass
356
+ * `null` to detach.
357
+ *
358
+ * The invoker's boolean return is the liveness signal §L2 hinges on: `false`
359
+ * means nothing received it, and the bridge answers the agent rather than
360
+ * parking a call that can never be resolved.
361
+ */
362
+ setInvoker(sessionKey, invoker) {
363
+ if (invoker) {
364
+ this.invokers.set(sessionKey, invoker);
365
+ } else {
366
+ this.invokers.delete(sessionKey);
367
+ }
368
+ }
369
+ /** Whether a downstream invoker is currently attached for `sessionKey`. */
370
+ hasInvoker(sessionKey) {
371
+ return this.invokers.has(sessionKey);
372
+ }
373
+ /**
374
+ * Whether `name` is one the browser announced for `sessionKey` — i.e. whether
375
+ * this invocation belongs to the app channel rather than to the server-side
376
+ * `capabilityRegistry`. This is the routing predicate;
377
+ * {@link routeCapabilityInvocation} is the routing itself.
378
+ *
379
+ * §L6: a name the *server* registry also owns is not a client capability. The
380
+ * server entry is the one that was registered with the agent, so it is the one
381
+ * that must answer.
382
+ */
383
+ hasClientCapability(sessionKey, name) {
384
+ if (this.serverRegistry.list().some((c) => c.name === name)) return false;
385
+ return this.listClient(sessionKey).some((c) => c.name === name);
386
+ }
387
+ /**
388
+ * Route one `capability_invoked` to the browser that announced it.
389
+ *
390
+ * Parks the `callId` and hands the invocation to the registered invoker. When
391
+ * there is no invoker, or the invoker reports that no live UI received it,
392
+ * the call is answered immediately with {@link NO_UI_ATTACHED_ERROR} and
393
+ * never parked (§L2).
394
+ */
395
+ async invoke(sessionKey, invocation) {
396
+ const outcome = await this.park(sessionKey, invocation);
397
+ if (outcome === "no-ui") {
398
+ await this.sendResult(sessionKey, invocation.callId, { error: NO_UI_ATTACHED_ERROR });
399
+ }
400
+ return outcome;
401
+ }
402
+ /**
403
+ * Like {@link invoke}, but the browser's answer resolves this promise instead
404
+ * of going to the sender — for a server-side caller that is not the agent
405
+ * (a test driver, a route).
406
+ *
407
+ * Resolves with exactly what the browser posted, `{ error }` envelopes
408
+ * included; the caller maps them. Rejects with `Error(NO_UI_ATTACHED_ERROR)`
409
+ * when no live UI received the invocation (never parked), and with
410
+ * `Error(capabilityTimeoutError(...))` when the §L3 timer fires first. A
411
+ * session restart or clear ({@link dropPendingCalls}) resolves it with
412
+ * `{ error }`, like any other parked call.
413
+ */
414
+ call(sessionKey, invocation) {
415
+ return new Promise((resolve, reject) => {
416
+ this.park(
417
+ sessionKey,
418
+ { ...invocation, invokedBy: "driver" },
419
+ { settle: resolve, reject }
420
+ ).then((outcome) => {
421
+ if (outcome === "no-ui") reject(new Error(NO_UI_ATTACHED_ERROR));
422
+ }, reject);
423
+ });
424
+ }
425
+ /**
426
+ * Shared by {@link invoke} and {@link call}: the §L2 liveness checks, the
427
+ * §L4 owner stamp, the park and the §L3 timer, then the hand-off to the
428
+ * invoker. Answers nobody — a `"no-ui"` outcome leaves that to the caller,
429
+ * with the call already unparked.
430
+ */
431
+ async park(sessionKey, invocation, driver) {
432
+ const invoker = this.invokers.get(sessionKey);
433
+ if (this.tracksSubscribers(sessionKey) && this.subscriberCount(sessionKey) === 0) {
434
+ return "no-ui";
435
+ }
436
+ if (!invoker) return "no-ui";
437
+ const full = {
438
+ ...invocation,
439
+ sessionKey,
440
+ ownerId: this.routeTo(sessionKey)
441
+ };
442
+ const timeoutMs = this.timeoutFor(sessionKey, invocation.name);
443
+ const entry = { ...full, parkedAt: Date.now(), timeoutMs, ...driver };
444
+ const calls = this.parked.get(sessionKey) ?? /* @__PURE__ */ new Map();
445
+ calls.set(invocation.callId, entry);
446
+ this.parked.set(sessionKey, calls);
447
+ this.armCallTimer(sessionKey, invocation.callId, invocation.name, timeoutMs);
448
+ let delivered = false;
449
+ try {
450
+ delivered = await invoker(full);
451
+ } catch (err) {
452
+ this.log.warn(
453
+ `[client-capability-bridge] [${sessionKey}] invoker threw for "${invocation.name}" (callId=${invocation.callId}): ${err}`
454
+ );
455
+ delivered = false;
456
+ }
457
+ if (delivered) return "dispatched";
458
+ this.clearCallTimer(sessionKey, invocation.callId);
459
+ if (calls.delete(invocation.callId) && calls.size === 0) this.parked.delete(sessionKey);
460
+ return "no-ui";
461
+ }
462
+ // ── Per-call timeouts (spec §L3) ─────────────────────────────────────────
463
+ /**
464
+ * The §L3 deadline for one capability: whatever the browser announced as
465
+ * `callTimeoutMs`, else this bridge's default.
466
+ *
467
+ * Both must stay under the runner's own `DEFAULT_CAPABILITY_CALL_TIMEOUT_MS`
468
+ * (60 s) to be worth anything — a timer that fires second only ever reports
469
+ * what the runner has already given up on.
470
+ */
471
+ timeoutFor(sessionKey, name) {
472
+ const declared = this.listClient(sessionKey).find((c) => c.name === name)?.callTimeoutMs;
473
+ return typeof declared === "number" && declared > 0 ? declared : this.defaultCallTimeoutMs;
474
+ }
475
+ armCallTimer(sessionKey, callId, name, timeoutMs) {
476
+ const timer = setTimeout(() => {
477
+ this.clearCallTimer(sessionKey, callId);
478
+ const calls = this.parked.get(sessionKey);
479
+ const entry = calls?.get(callId);
480
+ if (!calls || !entry) return;
481
+ calls.delete(callId);
482
+ if (calls.size === 0) this.parked.delete(sessionKey);
483
+ this.log.warn(
484
+ `[client-capability-bridge] [${sessionKey}] "${name}" (callId=${callId}) timed out after ${timeoutMs}ms \u2014 the browser never answered`
485
+ );
486
+ const message = capabilityTimeoutError(name, timeoutMs);
487
+ if (entry.reject) {
488
+ entry.reject(new Error(message));
489
+ return;
490
+ }
491
+ void this.sendResult(sessionKey, callId, { error: message });
492
+ }, timeoutMs);
493
+ timer.unref?.();
494
+ const timers = this.callTimers.get(sessionKey) ?? /* @__PURE__ */ new Map();
495
+ timers.set(callId, timer);
496
+ this.callTimers.set(sessionKey, timers);
497
+ }
498
+ clearCallTimer(sessionKey, callId) {
499
+ const timers = this.callTimers.get(sessionKey);
500
+ const timer = timers?.get(callId);
501
+ if (timer !== void 0) clearTimeout(timer);
502
+ timers?.delete(callId);
503
+ if (timers && timers.size === 0) this.callTimers.delete(sessionKey);
504
+ }
505
+ clearCallTimers(sessionKey) {
506
+ for (const timer of this.callTimers.get(sessionKey)?.values() ?? []) clearTimeout(timer);
507
+ this.callTimers.delete(sessionKey);
508
+ }
509
+ /**
510
+ * Answer a parked `callId` with what the browser posted back and forward it
511
+ * to the agent as one `capability_result`.
512
+ *
513
+ * Returns `false` — and sends nothing — when the `callId` is not parked under
514
+ * `sessionKey`. That covers the duplicate answer (two tabs both saw the SSE
515
+ * event and both posted), the late answer after a timeout, and a forged POST
516
+ * for someone else's call. Only a call this bridge actually parked can be
517
+ * answered.
518
+ */
519
+ async resolve(sessionKey, callId, result) {
520
+ const calls = this.parked.get(sessionKey);
521
+ const entry = calls?.get(callId);
522
+ if (!calls || !entry) return false;
523
+ this.clearCallTimer(sessionKey, callId);
524
+ calls.delete(callId);
525
+ if (calls.size === 0) this.parked.delete(sessionKey);
526
+ if (entry.settle) {
527
+ entry.settle(result);
528
+ return true;
529
+ }
530
+ await this.sendResult(sessionKey, callId, result);
531
+ return true;
532
+ }
533
+ /**
534
+ * Answer every parked call for `sessionKey` with an error and forget them,
535
+ * leaving the announced registry, sender and invoker in place.
536
+ *
537
+ * This is what a session restart needs: the agent process behind those calls
538
+ * is gone, so nothing will ever resolve them, but the browser is still
539
+ * attached and its announcement must survive — {@link clear} would throw it
540
+ * away and the agent would come back up with no client capabilities until the
541
+ * next page load.
542
+ *
543
+ * @returns how many calls were dropped.
544
+ */
545
+ dropPendingCalls(sessionKey, error = SESSION_ENDED_ERROR) {
546
+ const count = this.parked.get(sessionKey)?.size ?? 0;
547
+ this.settleAll(sessionKey, error);
548
+ return count;
549
+ }
550
+ /** `callId`s currently parked for `sessionKey`. Ticket 05's timeout seam. */
551
+ pendingCallIds(sessionKey) {
552
+ return [...this.parked.get(sessionKey)?.keys() ?? []];
553
+ }
554
+ /** The parked entry for one `callId`, or `undefined`. */
555
+ pendingCall(sessionKey, callId) {
556
+ return this.parked.get(sessionKey)?.get(callId);
557
+ }
558
+ /**
559
+ * Push one `capability_result` upstream, bypassing the parked-call check.
560
+ *
561
+ * For results the bridge did not park — i.e. the server-side
562
+ * `capabilityRegistry` path, which runs in Nitro and answers synchronously.
563
+ * Browser-supplied results must go through {@link resolve} instead, which only
564
+ * answers a `callId` this bridge actually parked.
565
+ *
566
+ * Never throws.
567
+ */
568
+ async sendResult(sessionKey, callId, result) {
569
+ const sender = this.senders.get(sessionKey);
570
+ if (!sender) {
571
+ this.log.warn(
572
+ `[client-capability-bridge] [${sessionKey}] no sender \u2014 capability_result for callId=${callId} dropped; the agent will wait out its own timeout.`
573
+ );
574
+ return;
575
+ }
576
+ try {
577
+ await sender({ type: "capability_result", callId, result });
578
+ } catch (err) {
579
+ this.log.warn(`[client-capability-bridge] [${sessionKey}] capability_result failed: ${err}`);
580
+ }
581
+ }
582
+ /**
583
+ * Answer every parked call for `sessionKey` with `error`, then forget them.
584
+ *
585
+ * Synchronous on purpose: {@link clear} tears the sender down in the same tick,
586
+ * so the sender is snapshotted here rather than looked up per call.
587
+ *
588
+ * Calls parked by {@link call} have no `callId` the agent knows about: their
589
+ * `settle` receives the `{ error }` envelope instead of the sender.
590
+ */
591
+ settleAll(sessionKey, error) {
592
+ this.clearCallTimers(sessionKey);
593
+ const calls = this.parked.get(sessionKey);
594
+ if (!calls || calls.size === 0) return;
595
+ const entries = [...calls.values()];
596
+ this.parked.delete(sessionKey);
597
+ const sender = this.senders.get(sessionKey);
598
+ for (const entry of entries) {
599
+ const { callId } = entry;
600
+ try {
601
+ if (entry.settle) {
602
+ entry.settle({ error });
603
+ } else if (sender) {
604
+ void Promise.resolve(
605
+ sender({ type: "capability_result", callId, result: { error } })
606
+ ).catch((err) => {
607
+ this.log.warn(
608
+ `[client-capability-bridge] [${sessionKey}] settle of callId=${callId} failed: ${err}`
609
+ );
610
+ });
611
+ }
612
+ } catch (err) {
613
+ this.log.warn(
614
+ `[client-capability-bridge] [${sessionKey}] settle of callId=${callId} failed: ${err}`
615
+ );
616
+ }
617
+ }
618
+ }
619
+ /**
620
+ * Reconcile the agent's registry with the merged list: `capability_deregister`
621
+ * for names that dropped out, then `capability_register` for what remains.
622
+ *
623
+ * Spec §L5 — no-ops entirely when the merged list is byte-identical to what
624
+ * was last sent for this key, so an SSE reconnect that re-announces an
625
+ * unchanged registry produces **zero** upstream traffic. Pass `force: true` at
626
+ * session creation, where a fresh agent process has no prior registration.
627
+ *
628
+ * The deregister leg is not optional bookkeeping: the runner's
629
+ * `handleCapabilityRegister` loops `registry.register(cap)` per entry, so a
630
+ * `capability_register` only ever **adds**. Re-sending a shorter list would
631
+ * leave the removed capability registered and callable. Only
632
+ * `capability_deregister` takes one away.
633
+ *
634
+ * @returns the merged list (whether or not anything was sent).
635
+ */
636
+ async sync(sessionKey, options = {}) {
637
+ const capabilities = this.merged(sessionKey);
638
+ const sender = this.senders.get(sessionKey);
639
+ if (!sender) return capabilities;
640
+ const next = signature(capabilities);
641
+ if (!options.force && this.lastSent.get(sessionKey) === next) return capabilities;
642
+ const nextNames = new Set(capabilities.map((c) => c.name));
643
+ const removed = [...this.lastSentNames.get(sessionKey) ?? []].filter(
644
+ (name) => !nextNames.has(name)
645
+ );
646
+ try {
647
+ if (removed.length > 0) {
648
+ await sender({ type: "capability_deregister", names: removed });
649
+ this.log.info(
650
+ `[client-capability-bridge] [${sessionKey}] deregistered ${removed.length} capabilit${removed.length === 1 ? "y" : "ies"}: ${removed.join(", ")}`
651
+ );
652
+ }
653
+ if (capabilities.length > 0) {
654
+ await sender({ type: "capability_register", capabilities });
655
+ this.log.info(
656
+ `[client-capability-bridge] [${sessionKey}] registered ${capabilities.length} capabilities (${this.listClient(sessionKey).length} from the browser)`
657
+ );
658
+ }
659
+ this.lastSent.set(sessionKey, next);
660
+ this.lastSentNames.set(sessionKey, nextNames);
661
+ } catch (err) {
662
+ this.log.warn(
663
+ `[client-capability-bridge] [${sessionKey}] capability registration failed: ${err}`
664
+ );
665
+ }
666
+ return capabilities;
667
+ }
668
+ /**
669
+ * Forget everything for `sessionKey`, sender and invoker included.
670
+ *
671
+ * Parked calls are answered with {@link SESSION_ENDED_ERROR} on the way out —
672
+ * dropping them silently would leave the agent's turn blocked until the
673
+ * runner's own 60 s timeout.
674
+ */
675
+ clear(sessionKey) {
676
+ this.settleAll(sessionKey, SESSION_ENDED_ERROR);
677
+ this.cancelGrace(sessionKey);
678
+ this.sessions.delete(sessionKey);
679
+ this.senders.delete(sessionKey);
680
+ this.invokers.delete(sessionKey);
681
+ this.subscribers.delete(sessionKey);
682
+ this.lastSent.delete(sessionKey);
683
+ this.lastSentNames.delete(sessionKey);
684
+ for (const key of [...this.warnedCollisions]) {
685
+ if (splitWarnKey(key)[0] === sessionKey) this.warnedCollisions.delete(key);
686
+ }
687
+ }
688
+ /** Test-only: wipe all state. Parked calls are dropped, not answered. */
689
+ reset() {
690
+ for (const key of [...this.callTimers.keys()]) this.clearCallTimers(key);
691
+ for (const key of [...this.graceTimers.keys()]) this.cancelGrace(key);
692
+ this.sessions.clear();
693
+ this.senders.clear();
694
+ this.invokers.clear();
695
+ this.subscribers.clear();
696
+ this.parked.clear();
697
+ this.lastSent.clear();
698
+ this.lastSentNames.clear();
699
+ this.warnedCollisions.clear();
700
+ }
701
+ /**
702
+ * Stamp the wire fields the browser must not author: `side` is fixed to
703
+ * `"client"` and `origin` to `{ kind: "client" }` (the analogue of app-sdk's
704
+ * SDK-stamped `origin`). Unknown fields are dropped.
705
+ */
706
+ normalise(descriptor) {
707
+ return {
708
+ name: descriptor.name,
709
+ displayName: descriptor.displayName,
710
+ description: descriptor.description,
711
+ inputSchema: descriptor.inputSchema,
712
+ outputSchema: descriptor.outputSchema,
713
+ side: "client",
714
+ origin: { kind: "client" },
715
+ scope: descriptor.scope,
716
+ requiresApproval: descriptor.requiresApproval,
717
+ kind: descriptor.kind,
718
+ audience: descriptor.audience,
719
+ promptFragment: descriptor.promptFragment,
720
+ callTimeoutMs: descriptor.callTimeoutMs,
721
+ render: descriptor.render
722
+ };
723
+ }
724
+ }
725
+ function highestAnnouncer(announcers) {
726
+ let best = null;
727
+ let bestSeq = -1;
728
+ for (const [id, seq] of announcers) {
729
+ if (seq > bestSeq) {
730
+ best = id;
731
+ bestSeq = seq;
732
+ }
733
+ }
734
+ return best;
735
+ }
736
+ function signature(capabilities) {
737
+ return JSON.stringify([...capabilities].sort((a, b) => a.name.localeCompare(b.name)));
738
+ }
739
+ function splitWarnKey(key) {
740
+ const idx = key.lastIndexOf("::");
741
+ return [key.slice(0, idx), key.slice(idx + 2)];
742
+ }
743
+ export const clientCapabilityBridge = new ClientCapabilityBridge();
744
+ export async function routeCapabilityInvocation(sessionKey, invocation, deps) {
745
+ const bridge = deps.bridge ?? clientCapabilityBridge;
746
+ if (bridge.hasClientCapability(sessionKey, invocation.name)) {
747
+ await bridge.invoke(sessionKey, invocation);
748
+ return "client";
749
+ }
750
+ const registry = deps.registry ?? capabilityRegistry;
751
+ const input = invocation.input && typeof invocation.input === "object" ? invocation.input : {};
752
+ const result = await registry.dispatch(invocation.name, input, deps.ctx);
753
+ await bridge.sendResult(sessionKey, invocation.callId, result);
754
+ return "server";
755
+ }