@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,1201 @@
|
|
|
1
|
+
// server/utils/client-capability-bridge.ts
|
|
2
|
+
//
|
|
3
|
+
// NOTE: this file must live *directly* in `runtime/server/utils/`. The Nuxt
|
|
4
|
+
// module auto-imports that directory NON-recursively (`src/module.ts:116`), so
|
|
5
|
+
// a subdirectory would silently never reach consumers.
|
|
6
|
+
|
|
7
|
+
import type { Capability } from "@skaile/workspaces/sdk/types";
|
|
8
|
+
import * as z from "zod";
|
|
9
|
+
import {
|
|
10
|
+
type CapabilityHandlerCtx,
|
|
11
|
+
type CapabilityRegistry,
|
|
12
|
+
capabilityRegistry,
|
|
13
|
+
} from "./capability-registry";
|
|
14
|
+
|
|
15
|
+
// ── Session-key contract ───────────────────────────────────────────────────
|
|
16
|
+
//
|
|
17
|
+
// The bridge keys everything — announced registries, owners, senders — by an
|
|
18
|
+
// **opaque string**. It never parses the key or assumes a shape. Each app picks
|
|
19
|
+
// the identity its SSE stream already filters on (spec §4):
|
|
20
|
+
//
|
|
21
|
+
// forge-workspace / forge-site → a constant (one session per process)
|
|
22
|
+
// forge-project → `` `${userId}:${slug}:${mode}` ``
|
|
23
|
+
// forge-concept → the concept name
|
|
24
|
+
// forge-assistant → its `processKey` getter
|
|
25
|
+
//
|
|
26
|
+
// Callers must derive the key **server-side** wherever possible. A browser that
|
|
27
|
+
// can name its own session key can announce into someone else's session.
|
|
28
|
+
|
|
29
|
+
/** Commands the bridge pushes upstream toward the agent. */
|
|
30
|
+
export type ClientCapabilityCommand =
|
|
31
|
+
| { type: "capability_register"; capabilities: Capability[] }
|
|
32
|
+
| { type: "capability_deregister"; names: string[] }
|
|
33
|
+
| { type: "capability_result"; callId: string; result: unknown };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Transport that delivers a command **upstream to the agent** for one session
|
|
37
|
+
* key. Apps register it from their agent-manager.
|
|
38
|
+
*
|
|
39
|
+
* It must NOT be `session.dispatcher.sendCommand`. `SessionDispatcher.shouldPersist()`
|
|
40
|
+
* is a pure denylist against `TRANSIENT_TYPES`, and none of the three capability
|
|
41
|
+
* command types appear in it — a dispatcher send is therefore written into the
|
|
42
|
+
* JSONL message store and resurfaces as a chat message. Use
|
|
43
|
+
* `processManager.sendCommand(processKey, command)`, which goes straight to the
|
|
44
|
+
* transport with no message store involved.
|
|
45
|
+
*/
|
|
46
|
+
export type ClientCapabilitySender = (command: ClientCapabilityCommand) => Promise<void> | void;
|
|
47
|
+
|
|
48
|
+
/** One `capability_invoked` on its way down to the browser. */
|
|
49
|
+
export interface ClientCapabilityInvocation {
|
|
50
|
+
/** Correlation token minted by the agent runner. */
|
|
51
|
+
callId: string;
|
|
52
|
+
/** Name as invoked — may be a deprecated alias of the handler's real name. */
|
|
53
|
+
name: string;
|
|
54
|
+
/** Raw, unvalidated arguments. The browser validates against the zod schema. */
|
|
55
|
+
input: unknown;
|
|
56
|
+
/** The opaque key this invocation was routed under. */
|
|
57
|
+
sessionKey: string;
|
|
58
|
+
/**
|
|
59
|
+
* Spec §L4 — the single subscriber this invocation is addressed to, or `null`
|
|
60
|
+
* when the app does not track subscribers (then it goes to whoever is
|
|
61
|
+
* listening, the pre-05 behaviour).
|
|
62
|
+
*
|
|
63
|
+
* The app's invoker is expected to tag its SSE frame with this so only the
|
|
64
|
+
* owning tab runs the handler. `useCapabilityInvocations()` in the browser
|
|
65
|
+
* enforces the same rule a second time as defence in depth, for apps whose
|
|
66
|
+
* fan-out cannot filter.
|
|
67
|
+
*/
|
|
68
|
+
ownerId: string | null;
|
|
69
|
+
/**
|
|
70
|
+
* Who started this invocation. Absent means the workspace agent; `"driver"`
|
|
71
|
+
* is a server-side caller using {@link ClientCapabilityBridge.call}. The
|
|
72
|
+
* browser never reads it — it is for logs and the app's `capability_invoked`
|
|
73
|
+
* frame only.
|
|
74
|
+
*/
|
|
75
|
+
invokedBy?: "agent" | "driver";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Transport that delivers an invocation **downstream to the browser** for one
|
|
80
|
+
* session key — in every forge app this is the existing SSE fan-out.
|
|
81
|
+
*
|
|
82
|
+
* Returns whether at least one live UI actually received it. `false` is what
|
|
83
|
+
* makes spec §L2 work: the bridge answers the agent immediately with a
|
|
84
|
+
* no-UI-attached error instead of parking a call nobody will ever resolve.
|
|
85
|
+
*/
|
|
86
|
+
export type ClientCapabilityInvoker = (
|
|
87
|
+
invocation: ClientCapabilityInvocation,
|
|
88
|
+
) => boolean | Promise<boolean>;
|
|
89
|
+
|
|
90
|
+
/** Error text the agent receives when an invocation arrives with no live UI (§L2). */
|
|
91
|
+
export const NO_UI_ATTACHED_ERROR =
|
|
92
|
+
"No UI is attached to this session, so the capability cannot run. " +
|
|
93
|
+
"Tell the user to open the app in a browser and try again.";
|
|
94
|
+
|
|
95
|
+
/** Error text used when a parked call is dropped because its session ended. */
|
|
96
|
+
export const SESSION_ENDED_ERROR = "The UI session ended before the capability returned a result.";
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Spec §L1 — how long the last subscriber for a session key may stay gone
|
|
100
|
+
* before its browser-announced capabilities are deregistered.
|
|
101
|
+
*
|
|
102
|
+
* A page reload drops and re-opens the SSE connection within a few hundred
|
|
103
|
+
* milliseconds; 5 s is comfortably longer than that and short enough that the
|
|
104
|
+
* agent stops advertising a dead UI quickly.
|
|
105
|
+
*/
|
|
106
|
+
export const DEFAULT_DISCONNECT_GRACE_MS = 5_000;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Spec §L3 — fallback per-call timeout for a capability that declares no
|
|
110
|
+
* `callTimeoutMs` of its own.
|
|
111
|
+
*
|
|
112
|
+
* Deliberately **under** the runner's own `DEFAULT_CAPABILITY_CALL_TIMEOUT_MS`
|
|
113
|
+
* (60 s). The runner's timer is the last-resort backstop; this one has to fire
|
|
114
|
+
* first or it buys nothing.
|
|
115
|
+
*/
|
|
116
|
+
export const DEFAULT_CLIENT_CALL_TIMEOUT_MS = 30_000;
|
|
117
|
+
|
|
118
|
+
/** Stable prefix of every §L3 timeout message, so callers can match on it. */
|
|
119
|
+
export const CALL_TIMEOUT_ERROR_PREFIX = "The UI did not answer";
|
|
120
|
+
|
|
121
|
+
/** Error text the agent receives when a parked call outlives its timeout (§L3). */
|
|
122
|
+
export function capabilityTimeoutError(name: string, timeoutMs: number): string {
|
|
123
|
+
return (
|
|
124
|
+
`${CALL_TIMEOUT_ERROR_PREFIX} "${name}" within ${Math.round(timeoutMs / 1000)}s. ` +
|
|
125
|
+
"The browser may have navigated away or the handler may be stuck. " +
|
|
126
|
+
"Tell the user what you were trying to do and ask them to retry."
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** What the bridge remembers about one in-flight invocation. */
|
|
131
|
+
export interface ParkedCall extends ClientCapabilityInvocation {
|
|
132
|
+
/** `Date.now()` at park time. */
|
|
133
|
+
parkedAt: number;
|
|
134
|
+
/** The §L3 deadline this call is under, in ms. */
|
|
135
|
+
timeoutMs: number;
|
|
136
|
+
/**
|
|
137
|
+
* Set only for a call made through {@link ClientCapabilityBridge.call}: the
|
|
138
|
+
* browser's answer (or a session-end `{ error }`) goes here instead of
|
|
139
|
+
* upstream to the agent.
|
|
140
|
+
*/
|
|
141
|
+
settle?: (result: unknown) => void;
|
|
142
|
+
/** Companion of {@link settle}: receives the §L3 timeout. */
|
|
143
|
+
reject?: (error: Error) => void;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Outcome of {@link ClientCapabilityBridge.invoke}. */
|
|
147
|
+
export type InvokeOutcome =
|
|
148
|
+
/** Parked and handed to a live UI; a `capability_result` is expected. */
|
|
149
|
+
| "dispatched"
|
|
150
|
+
/** §L2 — answered immediately with {@link NO_UI_ATTACHED_ERROR}; never parked. */
|
|
151
|
+
| "no-ui";
|
|
152
|
+
|
|
153
|
+
export interface ClientCapabilityBridgeLogger {
|
|
154
|
+
info(message: string): void;
|
|
155
|
+
warn(message: string): void;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The `list()` half of a server-side capability registry. */
|
|
159
|
+
export interface ServerCapabilitySource {
|
|
160
|
+
list(): Capability[];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface ClientCapabilityBridgeOptions {
|
|
164
|
+
/**
|
|
165
|
+
* Server-side registry merged into every registration. Defaults to the
|
|
166
|
+
* process-wide `capabilityRegistry` singleton, which keeps working untouched.
|
|
167
|
+
*/
|
|
168
|
+
serverRegistry?: ServerCapabilitySource;
|
|
169
|
+
logger?: ClientCapabilityBridgeLogger;
|
|
170
|
+
/** §L1 disconnect grace period. Defaults to {@link DEFAULT_DISCONNECT_GRACE_MS}. */
|
|
171
|
+
disconnectGraceMs?: number;
|
|
172
|
+
/**
|
|
173
|
+
* §L3 fallback per-call timeout, used when the announced capability declares
|
|
174
|
+
* no `callTimeoutMs`. Defaults to {@link DEFAULT_CLIENT_CALL_TIMEOUT_MS}.
|
|
175
|
+
*/
|
|
176
|
+
callTimeoutMs?: number;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** What the bridge remembers about one announcing session. */
|
|
180
|
+
export interface ClientCapabilitySessionState {
|
|
181
|
+
/** Normalised, wire-shaped capabilities the browser announced. */
|
|
182
|
+
capabilities: Capability[];
|
|
183
|
+
/** §L4 owner: the most recent *genuine* announcer. See {@link ClientCapabilityBridge.announce}. */
|
|
184
|
+
ownerId: string | null;
|
|
185
|
+
/** `Date.now()` of the most recent announcement. */
|
|
186
|
+
announcedAt: number;
|
|
187
|
+
/**
|
|
188
|
+
* Announce **sequence number** per announcing subscriber, highest wins. Kept
|
|
189
|
+
* alongside {@link ownerId} so that when the incumbent owner's SSE connection
|
|
190
|
+
* goes away the bridge can fall back to the next-most-recent announcer that
|
|
191
|
+
* is still connected, instead of routing into a closed tab (§L4).
|
|
192
|
+
*
|
|
193
|
+
* A counter, not a timestamp: two tabs can easily announce inside the same
|
|
194
|
+
* millisecond, and `Date.now()` would then make last-writer-wins a coin flip
|
|
195
|
+
* decided by Map insertion order. The counter cannot tie.
|
|
196
|
+
*/
|
|
197
|
+
announcers: Map<string, number>;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── Announcement validation ────────────────────────────────────────────────
|
|
201
|
+
// Announcements arrive as untrusted browser input, so consumer routes should
|
|
202
|
+
// parse before handing them to `announce()`.
|
|
203
|
+
|
|
204
|
+
/** Zod schema for one announced capability descriptor. */
|
|
205
|
+
export const clientCapabilityDescriptorSchema = z.object({
|
|
206
|
+
name: z.string().min(1),
|
|
207
|
+
displayName: z.string().optional(),
|
|
208
|
+
description: z.string(),
|
|
209
|
+
inputSchema: z.record(z.string(), z.unknown()),
|
|
210
|
+
outputSchema: z.record(z.string(), z.unknown()).optional(),
|
|
211
|
+
side: z.literal("client").optional(),
|
|
212
|
+
scope: z.enum(["session", "turn"]).optional(),
|
|
213
|
+
requiresApproval: z.boolean().optional(),
|
|
214
|
+
kind: z.enum(["effect", "render", "query"]).optional(),
|
|
215
|
+
audience: z.array(z.enum(["llm", "user", "runtime"])).optional(),
|
|
216
|
+
promptFragment: z.string().optional(),
|
|
217
|
+
callTimeoutMs: z.number().optional(),
|
|
218
|
+
render: z.unknown().optional(),
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
/** Zod schema for the body of an upward announce POST. */
|
|
222
|
+
export const clientCapabilityAnnouncementSchema = z.object({
|
|
223
|
+
subscriberId: z.string().optional(),
|
|
224
|
+
capabilities: z.array(clientCapabilityDescriptorSchema),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Zod schema for the body of the upward `capability-result` POST — the browser
|
|
229
|
+
* answering a parked `callId`.
|
|
230
|
+
*
|
|
231
|
+
* `result` is deliberately opaque: it is either the handler's return value or an
|
|
232
|
+
* `{ error: string }` envelope, and the runner distinguishes the two itself
|
|
233
|
+
* (`resolveCapabilityResult` rejects the LLM's tool call when `"error" in result`).
|
|
234
|
+
* The bridge never inspects it.
|
|
235
|
+
*/
|
|
236
|
+
export const clientCapabilityResultSchema = z.object({
|
|
237
|
+
callId: z.string().min(1),
|
|
238
|
+
result: z.unknown(),
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
/** Body of an upward `capability-result` POST, minus app-specific session fields. */
|
|
242
|
+
export type IncomingClientCapabilityResult = z.infer<typeof clientCapabilityResultSchema>;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A capability descriptor **as it arrives on the wire** — exactly what
|
|
246
|
+
* {@link clientCapabilityDescriptorSchema} produces, which is what
|
|
247
|
+
* {@link ClientCapabilityBridge.announce} accepts.
|
|
248
|
+
*
|
|
249
|
+
* Looser than {@link ClientCapabilityDescriptor} in two places, both because
|
|
250
|
+
* the bridge is the authority rather than the browser:
|
|
251
|
+
* - `side` is optional; {@link ClientCapabilityBridge} stamps it either way.
|
|
252
|
+
* - `render` is `unknown`; it is narrowed to `RenderSpec` on normalisation.
|
|
253
|
+
*
|
|
254
|
+
* A `ClientCapabilityDescriptor` (what `useAgentCapabilities().toWire()`
|
|
255
|
+
* returns) is assignable to this, so the browser-side and server-side types
|
|
256
|
+
* meet without a cast at the route boundary.
|
|
257
|
+
*/
|
|
258
|
+
export type IncomingClientCapability = z.infer<typeof clientCapabilityDescriptorSchema>;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* How many past announcers one session key remembers. Only needed so a
|
|
262
|
+
* reconnect can be told apart from a first announce (§L4), so a small window is
|
|
263
|
+
* plenty — and the cap keeps a long-lived key from growing without bound.
|
|
264
|
+
*/
|
|
265
|
+
const MAX_REMEMBERED_ANNOUNCERS = 16;
|
|
266
|
+
|
|
267
|
+
const defaultLogger: ClientCapabilityBridgeLogger = {
|
|
268
|
+
info: (message) => console.log(message),
|
|
269
|
+
warn: (message) => console.warn(message),
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Nitro-side relay between the browser's capability registry and the agent.
|
|
274
|
+
*
|
|
275
|
+
* Responsibilities in this (announce-half) ticket:
|
|
276
|
+
* - own the announced client capability list per opaque session key
|
|
277
|
+
* - merge it with the server-side registry into ONE `capability_register`
|
|
278
|
+
* - on a name collision the **server entry wins** and a warning is logged (§L6)
|
|
279
|
+
* - only push upstream when the merged list actually changed (§L5)
|
|
280
|
+
*
|
|
281
|
+
* And the invoke half (ticket 04):
|
|
282
|
+
* - route a `capability_invoked` whose name the browser announced downstream
|
|
283
|
+
* over the app's existing SSE fan-out ({@link setInvoker})
|
|
284
|
+
* - park the `callId` until the browser POSTs a result ({@link resolve})
|
|
285
|
+
* - §L2: an invocation that reaches no live UI is answered **immediately** with
|
|
286
|
+
* {@link NO_UI_ATTACHED_ERROR}. It is never parked, so it can never hang.
|
|
287
|
+
*
|
|
288
|
+
* And the lifecycle half (ticket 05):
|
|
289
|
+
* - §L1 subscriber counting per session key ({@link addSubscriber} /
|
|
290
|
+
* {@link removeSubscriber}), with a grace period before the last
|
|
291
|
+
* disconnect deregisters browser-owned capabilities
|
|
292
|
+
* - §L2 sharpened: liveness is now *this key's* subscriber count, not "is
|
|
293
|
+
* anything at all listening"
|
|
294
|
+
* - §L3 per-call timeout, defaulting to {@link DEFAULT_CLIENT_CALL_TIMEOUT_MS}
|
|
295
|
+
* and overridable per capability via `callTimeoutMs`
|
|
296
|
+
* - §L4 last-writer-wins owner election ({@link routeTo}), stamped onto every
|
|
297
|
+
* invocation as {@link ClientCapabilityInvocation.ownerId}
|
|
298
|
+
* - §L5 {@link sync} emits `capability_deregister` for names that dropped out
|
|
299
|
+
* of the merged list, and stays silent when nothing changed
|
|
300
|
+
*
|
|
301
|
+
* ── What the *model* actually sees ────────────────────────────────────────
|
|
302
|
+
* Everything above is exact at the **registry** level: after a §L1 grace expiry
|
|
303
|
+
* the runner's `CapabilityRegistry` really has dropped the client capability,
|
|
304
|
+
* and after a re-announce it really has it back.
|
|
305
|
+
*
|
|
306
|
+
* The LLM's view lags, and by how much depends on the harness. `composeTools()`
|
|
307
|
+
* runs inside `startQuery()` — a session's first prompt — and later prompts
|
|
308
|
+
* reuse that query via `streamInput`, so a tool the model already materialised
|
|
309
|
+
* stays materialised for the rest of the conversation. Observed live against
|
|
310
|
+
* `skaile serve` + the Claude Agent SDK harness, however, capabilities reach the
|
|
311
|
+
* model through a *deferred* MCP surface it has to search for: after a §L1
|
|
312
|
+
* deregistration mid-conversation the model's `ToolSearch` returned "no
|
|
313
|
+
* matching deferred tools" and it told the user the tool was unavailable —
|
|
314
|
+
* i.e. the deregistration did land within the same conversation.
|
|
315
|
+
*
|
|
316
|
+
* Do not rely on that. It is a property of how one harness exposes the registry,
|
|
317
|
+
* not a guarantee of the protocol. The invariant to design against is: §L1 is
|
|
318
|
+
* the advertisement, §L2 is the enforcement. A model that calls a capability
|
|
319
|
+
* whose UI has gone gets {@link NO_UI_ATTACHED_ERROR} immediately, whatever its
|
|
320
|
+
* tool list happens to say.
|
|
321
|
+
*/
|
|
322
|
+
export class ClientCapabilityBridge {
|
|
323
|
+
private readonly sessions = new Map<string, ClientCapabilitySessionState>();
|
|
324
|
+
private readonly senders = new Map<string, ClientCapabilitySender>();
|
|
325
|
+
private readonly invokers = new Map<string, ClientCapabilityInvoker>();
|
|
326
|
+
/** In-flight invocations, keyed `${sessionKey}` → `callId` → entry. */
|
|
327
|
+
private readonly parked = new Map<string, Map<string, ParkedCall>>();
|
|
328
|
+
/** §L3 timers, parallel to {@link parked}. */
|
|
329
|
+
private readonly callTimers = new Map<string, Map<string, ReturnType<typeof setTimeout>>>();
|
|
330
|
+
/**
|
|
331
|
+
* Live downstream subscribers per session key → **open connection count** for
|
|
332
|
+
* that subscriber id.
|
|
333
|
+
*
|
|
334
|
+
* Ref-counted rather than a plain set because one tab can legitimately hold
|
|
335
|
+
* more than one stream for a key (a remount that opens before the old socket
|
|
336
|
+
* finishes closing, a second component subscribing). A plain set would let
|
|
337
|
+
* the first close retire a subscriber the second connection still needs.
|
|
338
|
+
*
|
|
339
|
+
* The entry is created on the first {@link addSubscriber} for a key and then
|
|
340
|
+
* kept **forever**, even when it empties. Its presence is what
|
|
341
|
+
* {@link tracksSubscribers} reports, and that is what lets an app opt in to
|
|
342
|
+
* §L1/§L2/§L4 by wiring its SSE endpoint without forcing every app to.
|
|
343
|
+
*/
|
|
344
|
+
private readonly subscribers = new Map<string, Map<string, number>>();
|
|
345
|
+
/** §L1 pending grace timers, keyed by session key. */
|
|
346
|
+
private readonly graceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
347
|
+
/** Last merged list pushed per session key, for the §L5 change diff. */
|
|
348
|
+
private readonly lastSent = new Map<string, string>();
|
|
349
|
+
/** Names in the last pushed list, so §L5 can deregister what dropped out. */
|
|
350
|
+
private readonly lastSentNames = new Map<string, Set<string>>();
|
|
351
|
+
/** `${sessionKey}::${name}` pairs already warned about, so §L6 logs once. */
|
|
352
|
+
private readonly warnedCollisions = new Set<string>();
|
|
353
|
+
|
|
354
|
+
private readonly serverRegistry: ServerCapabilitySource;
|
|
355
|
+
private readonly log: ClientCapabilityBridgeLogger;
|
|
356
|
+
private readonly disconnectGraceMs: number;
|
|
357
|
+
private readonly defaultCallTimeoutMs: number;
|
|
358
|
+
/** Monotonic announce counter backing §L4's owner election. */
|
|
359
|
+
private announceSeq = 0;
|
|
360
|
+
|
|
361
|
+
constructor(options: ClientCapabilityBridgeOptions = {}) {
|
|
362
|
+
this.serverRegistry = options.serverRegistry ?? capabilityRegistry;
|
|
363
|
+
this.log = options.logger ?? defaultLogger;
|
|
364
|
+
this.disconnectGraceMs = options.disconnectGraceMs ?? DEFAULT_DISCONNECT_GRACE_MS;
|
|
365
|
+
this.defaultCallTimeoutMs = options.callTimeoutMs ?? DEFAULT_CLIENT_CALL_TIMEOUT_MS;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Record (or replace) the capability list a browser announced for
|
|
370
|
+
* `sessionKey`. Announcements are whole registries, never deltas.
|
|
371
|
+
*
|
|
372
|
+
* §L4 — an announcement re-seats ownership only when it is a genuine *write*:
|
|
373
|
+
* a subscriber this key has not heard from before, or a registry whose
|
|
374
|
+
* contents actually changed. An identical re-announce from a subscriber
|
|
375
|
+
* already on record refreshes liveness and cancels the §L1 grace timer, but
|
|
376
|
+
* leaves the running order alone.
|
|
377
|
+
*
|
|
378
|
+
* That distinction matters because re-announcing is not a rare event. Every
|
|
379
|
+
* SSE reopen re-announces (§L5), and at least one consumer — forge-concept —
|
|
380
|
+
* ties its stream to a drawer the user can toggle, so a tab can re-announce
|
|
381
|
+
* many times a minute without the user meaning anything by it. Under plain
|
|
382
|
+
* "the newest announce wins", a background tab's reconnect would silently
|
|
383
|
+
* steal routing from the foreground tab the user is actually looking at.
|
|
384
|
+
* A genuinely new tab, or a tab that changed what it offers, still wins.
|
|
385
|
+
*
|
|
386
|
+
* @returns `true` when the stored client list changed.
|
|
387
|
+
*/
|
|
388
|
+
announce(
|
|
389
|
+
sessionKey: string,
|
|
390
|
+
capabilities: IncomingClientCapability[],
|
|
391
|
+
options: { subscriberId?: string } = {},
|
|
392
|
+
): boolean {
|
|
393
|
+
const normalised = capabilities.map((c) => this.normalise(c));
|
|
394
|
+
const previous = this.sessions.get(sessionKey);
|
|
395
|
+
const changed = signature(previous?.capabilities ?? []) !== signature(normalised);
|
|
396
|
+
const announcedAt = Date.now();
|
|
397
|
+
|
|
398
|
+
// An announcement is proof of life: it cancels a pending §L1 grace timer
|
|
399
|
+
// even if the SSE connection has not (re)registered yet.
|
|
400
|
+
this.cancelGrace(sessionKey);
|
|
401
|
+
|
|
402
|
+
const announcers = previous?.announcers ?? new Map<string, number>();
|
|
403
|
+
const { subscriberId } = options;
|
|
404
|
+
if (subscriberId && (changed || !announcers.has(subscriberId))) {
|
|
405
|
+
announcers.set(subscriberId, ++this.announceSeq);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
this.sessions.set(sessionKey, {
|
|
409
|
+
capabilities: normalised,
|
|
410
|
+
ownerId: highestAnnouncer(announcers) ?? subscriberId ?? null,
|
|
411
|
+
announcedAt,
|
|
412
|
+
announcers,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// Let a name that is no longer announced warn again if it comes back.
|
|
416
|
+
const announcedNames = new Set(normalised.map((c) => c.name));
|
|
417
|
+
for (const key of [...this.warnedCollisions]) {
|
|
418
|
+
const [key0, name] = splitWarnKey(key);
|
|
419
|
+
if (key0 === sessionKey && !announcedNames.has(name)) {
|
|
420
|
+
this.warnedCollisions.delete(key);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return changed;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Drop the announced list for `sessionKey` — the "no browser attached" state.
|
|
429
|
+
* The sender registration survives, so a later re-announce can still push.
|
|
430
|
+
*
|
|
431
|
+
* @returns `true` when something was actually removed.
|
|
432
|
+
*/
|
|
433
|
+
withdraw(sessionKey: string): boolean {
|
|
434
|
+
const had = (this.sessions.get(sessionKey)?.capabilities.length ?? 0) > 0;
|
|
435
|
+
this.sessions.delete(sessionKey);
|
|
436
|
+
for (const key of [...this.warnedCollisions]) {
|
|
437
|
+
if (splitWarnKey(key)[0] === sessionKey) this.warnedCollisions.delete(key);
|
|
438
|
+
}
|
|
439
|
+
return had;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Client-announced capabilities for `sessionKey`, in wire shape. */
|
|
443
|
+
listClient(sessionKey: string): Capability[] {
|
|
444
|
+
return [...(this.sessions.get(sessionKey)?.capabilities ?? [])];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** True when a browser has announced a non-empty registry for `sessionKey`. */
|
|
448
|
+
hasClient(sessionKey: string): boolean {
|
|
449
|
+
return (this.sessions.get(sessionKey)?.capabilities.length ?? 0) > 0;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The §L4 owner as elected from announcements alone, ignoring who is still
|
|
454
|
+
* connected — i.e. the most recent *genuine* announcer (see {@link announce}
|
|
455
|
+
* for why a bare re-announce does not count as one).
|
|
456
|
+
*
|
|
457
|
+
* {@link routeTo} is what routing actually uses; this is the answer for an
|
|
458
|
+
* app that does not track subscribers, and the fallback when it does but
|
|
459
|
+
* nobody connected has announced.
|
|
460
|
+
*/
|
|
461
|
+
ownerOf(sessionKey: string): string | null {
|
|
462
|
+
return this.sessions.get(sessionKey)?.ownerId ?? null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Spec §L4 — the subscriber an invocation for `sessionKey` should be routed
|
|
467
|
+
* to right now: **the most recently announced one that is still connected**.
|
|
468
|
+
*
|
|
469
|
+
* Plain last-writer-wins ({@link ownerOf}) is the rule while every announcer
|
|
470
|
+
* is still around. It is not enough on its own: when the newest tab closes,
|
|
471
|
+
* routing to its dead subscriber id would strand every subsequent call in the
|
|
472
|
+
* §L2 no-UI branch even though an older tab is sitting there fully able to
|
|
473
|
+
* answer. So the election is over *live* announcers.
|
|
474
|
+
*
|
|
475
|
+
* Returns `null` when the app does not track subscribers (in which case the
|
|
476
|
+
* announced {@link ownerOf} is used verbatim, preserving pre-05 behaviour) or
|
|
477
|
+
* when nobody who is connected has announced.
|
|
478
|
+
*/
|
|
479
|
+
routeTo(sessionKey: string): string | null {
|
|
480
|
+
if (!this.tracksSubscribers(sessionKey)) return this.ownerOf(sessionKey);
|
|
481
|
+
|
|
482
|
+
const live = this.subscribers.get(sessionKey);
|
|
483
|
+
const announcers = this.sessions.get(sessionKey)?.announcers;
|
|
484
|
+
if (!live || !announcers) return null;
|
|
485
|
+
|
|
486
|
+
let best: string | null = null;
|
|
487
|
+
let bestSeq = -1;
|
|
488
|
+
for (const [id, seq] of announcers) {
|
|
489
|
+
if (!live.has(id)) continue;
|
|
490
|
+
if (seq > bestSeq) {
|
|
491
|
+
best = id;
|
|
492
|
+
bestSeq = seq;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return best;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ── Subscribers (spec §L1, §L2, §L4) ─────────────────────────────────────
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Record that a downstream subscriber (one SSE connection) is live for
|
|
502
|
+
* `sessionKey`. Apps call this from the endpoint that opens the stream.
|
|
503
|
+
*
|
|
504
|
+
* Idempotent per `subscriberId`, and it cancels any pending §L1 grace timer —
|
|
505
|
+
* which is exactly what makes a page reload a no-op: the tab's stream closes
|
|
506
|
+
* and reopens well inside the grace window, so nothing is ever deregistered.
|
|
507
|
+
*/
|
|
508
|
+
addSubscriber(sessionKey: string, subscriberId: string): void {
|
|
509
|
+
this.cancelGrace(sessionKey);
|
|
510
|
+
const live = this.subscribers.get(sessionKey) ?? new Map<string, number>();
|
|
511
|
+
live.set(subscriberId, (live.get(subscriberId) ?? 0) + 1);
|
|
512
|
+
this.subscribers.set(sessionKey, live);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Record that a subscriber's stream closed.
|
|
517
|
+
*
|
|
518
|
+
* When it was the last one for `sessionKey`, arm the §L1 grace timer. On
|
|
519
|
+
* expiry — and only if nobody has connected or announced since — the
|
|
520
|
+
* browser-announced registry is withdrawn and the resulting
|
|
521
|
+
* `capability_deregister` is pushed upstream.
|
|
522
|
+
*
|
|
523
|
+
* Deliberately *not* {@link clear}: the sender and invoker belong to the agent
|
|
524
|
+
* session, which is still very much alive, and tearing them down would leave
|
|
525
|
+
* a returning browser unable to register anything until the session restarts.
|
|
526
|
+
*
|
|
527
|
+
* @returns `true` when this disconnect armed the grace timer.
|
|
528
|
+
*/
|
|
529
|
+
removeSubscriber(sessionKey: string, subscriberId: string): boolean {
|
|
530
|
+
const live = this.subscribers.get(sessionKey);
|
|
531
|
+
if (!live) return false;
|
|
532
|
+
const open = (live.get(subscriberId) ?? 0) - 1;
|
|
533
|
+
if (open > 0) {
|
|
534
|
+
live.set(subscriberId, open);
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
live.delete(subscriberId);
|
|
538
|
+
// NB: the (possibly empty) map stays — see the `subscribers` field comment.
|
|
539
|
+
//
|
|
540
|
+
// The subscriber deliberately stays in `announcers`. Its entry is what
|
|
541
|
+
// makes a reconnect a *re*-announce rather than a first announce, and
|
|
542
|
+
// {@link routeTo} already skips announcers that are not currently live, so
|
|
543
|
+
// a stale entry can never win the election. Only the memory is a concern,
|
|
544
|
+
// and that is bounded below.
|
|
545
|
+
this.pruneAnnouncers(sessionKey);
|
|
546
|
+
if (live.size > 0) return false;
|
|
547
|
+
|
|
548
|
+
this.cancelGrace(sessionKey);
|
|
549
|
+
const timer = setTimeout(() => {
|
|
550
|
+
this.graceTimers.delete(sessionKey);
|
|
551
|
+
void this.expireSession(sessionKey);
|
|
552
|
+
}, this.disconnectGraceMs);
|
|
553
|
+
(timer as { unref?: () => void }).unref?.();
|
|
554
|
+
this.graceTimers.set(sessionKey, timer);
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Keep {@link ClientCapabilitySessionState.announcers} bounded: a long-lived
|
|
560
|
+
* session key can accumulate one entry per tab that ever announced. Live
|
|
561
|
+
* subscribers and the current owner are never dropped; the oldest departed
|
|
562
|
+
* announcers go first.
|
|
563
|
+
*/
|
|
564
|
+
private pruneAnnouncers(sessionKey: string): void {
|
|
565
|
+
const announcers = this.sessions.get(sessionKey)?.announcers;
|
|
566
|
+
if (!announcers || announcers.size <= MAX_REMEMBERED_ANNOUNCERS) return;
|
|
567
|
+
const live = this.subscribers.get(sessionKey);
|
|
568
|
+
const owner = highestAnnouncer(announcers);
|
|
569
|
+
const droppable = [...announcers.entries()]
|
|
570
|
+
.filter(([id]) => id !== owner && !live?.has(id))
|
|
571
|
+
.sort((a, b) => a[1] - b[1]);
|
|
572
|
+
for (const [id] of droppable) {
|
|
573
|
+
if (announcers.size <= MAX_REMEMBERED_ANNOUNCERS) break;
|
|
574
|
+
announcers.delete(id);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Live subscriber ids for `sessionKey`. */
|
|
579
|
+
subscriberIds(sessionKey: string): string[] {
|
|
580
|
+
return [...(this.subscribers.get(sessionKey)?.keys() ?? [])];
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** How many downstream subscribers are live for `sessionKey`. */
|
|
584
|
+
subscriberCount(sessionKey: string): number {
|
|
585
|
+
return this.subscribers.get(sessionKey)?.size ?? 0;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Whether this app has ever registered a subscriber for `sessionKey` — i.e.
|
|
590
|
+
* whether subscriber counts are meaningful for it.
|
|
591
|
+
*
|
|
592
|
+
* An app that never calls {@link addSubscriber} keeps the pre-05 contract:
|
|
593
|
+
* liveness is whatever its invoker returns. One that does gets §L1/§L2/§L4.
|
|
594
|
+
*/
|
|
595
|
+
tracksSubscribers(sessionKey: string): boolean {
|
|
596
|
+
return this.subscribers.has(sessionKey);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Whether a §L1 grace timer is currently pending for `sessionKey`. */
|
|
600
|
+
isAwaitingGrace(sessionKey: string): boolean {
|
|
601
|
+
return this.graceTimers.has(sessionKey);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private cancelGrace(sessionKey: string): void {
|
|
605
|
+
const timer = this.graceTimers.get(sessionKey);
|
|
606
|
+
if (timer === undefined) return;
|
|
607
|
+
clearTimeout(timer);
|
|
608
|
+
this.graceTimers.delete(sessionKey);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* §L1 grace expiry: the last browser really is gone. Drop the announced
|
|
613
|
+
* registry and push the deregistration upstream.
|
|
614
|
+
*
|
|
615
|
+
* In-flight calls are left alone on purpose. Their §L3 timers are the
|
|
616
|
+
* authority on when they die, and a grace expiry is not proof the answer is
|
|
617
|
+
* never coming — a slow reload can land just past the window.
|
|
618
|
+
*/
|
|
619
|
+
private async expireSession(sessionKey: string): Promise<void> {
|
|
620
|
+
if (this.subscriberCount(sessionKey) > 0) return;
|
|
621
|
+
const names = this.listClient(sessionKey).map((c) => c.name);
|
|
622
|
+
if (!this.withdraw(sessionKey)) return;
|
|
623
|
+
this.log.info(
|
|
624
|
+
`[client-capability-bridge] [${sessionKey}] last subscriber gone for ` +
|
|
625
|
+
`${this.disconnectGraceMs}ms — deregistering ${names.length} browser-owned ` +
|
|
626
|
+
`capabilit${names.length === 1 ? "y" : "ies"}`,
|
|
627
|
+
);
|
|
628
|
+
await this.sync(sessionKey);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** Every session key that currently has an announced registry. */
|
|
632
|
+
sessionKeys(): string[] {
|
|
633
|
+
return [...this.sessions.keys()];
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* The single list to register with the agent: the server-side registry plus
|
|
638
|
+
* whatever the browser announced.
|
|
639
|
+
*
|
|
640
|
+
* Spec §L6 — on a name collision **the server entry wins** and the bridge logs
|
|
641
|
+
* a warning (once per session key + name).
|
|
642
|
+
*/
|
|
643
|
+
merged(sessionKey: string): Capability[] {
|
|
644
|
+
const server = this.serverRegistry.list();
|
|
645
|
+
const serverNames = new Set(server.map((c) => c.name));
|
|
646
|
+
const merged = [...server];
|
|
647
|
+
|
|
648
|
+
for (const client of this.listClient(sessionKey)) {
|
|
649
|
+
if (serverNames.has(client.name)) {
|
|
650
|
+
const warnKey = `${sessionKey}::${client.name}`;
|
|
651
|
+
if (!this.warnedCollisions.has(warnKey)) {
|
|
652
|
+
this.warnedCollisions.add(warnKey);
|
|
653
|
+
this.log.warn(
|
|
654
|
+
`[client-capability-bridge] capability name collision on "${client.name}" ` +
|
|
655
|
+
`for session "${sessionKey}": the server-side entry wins, the ` +
|
|
656
|
+
`client-announced entry is ignored.`,
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
merged.push(client);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return merged;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Register the transport used to push commands upstream for `sessionKey`.
|
|
669
|
+
* Pass `null` to detach (e.g. when the agent session is replaced).
|
|
670
|
+
*/
|
|
671
|
+
setSender(sessionKey: string, sender: ClientCapabilitySender | null): void {
|
|
672
|
+
if (sender) {
|
|
673
|
+
this.senders.set(sessionKey, sender);
|
|
674
|
+
} else {
|
|
675
|
+
this.senders.delete(sessionKey);
|
|
676
|
+
this.lastSent.delete(sessionKey);
|
|
677
|
+
this.lastSentNames.delete(sessionKey);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Whether a sender is currently attached for `sessionKey`. */
|
|
682
|
+
hasSender(sessionKey: string): boolean {
|
|
683
|
+
return this.senders.has(sessionKey);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// ── Invocation (spec §2 downward leg, §L2) ───────────────────────────────
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Register the transport that pushes an invocation **down to the browser** for
|
|
690
|
+
* `sessionKey` — in every forge app, the app's existing SSE fan-out. Pass
|
|
691
|
+
* `null` to detach.
|
|
692
|
+
*
|
|
693
|
+
* The invoker's boolean return is the liveness signal §L2 hinges on: `false`
|
|
694
|
+
* means nothing received it, and the bridge answers the agent rather than
|
|
695
|
+
* parking a call that can never be resolved.
|
|
696
|
+
*/
|
|
697
|
+
setInvoker(sessionKey: string, invoker: ClientCapabilityInvoker | null): void {
|
|
698
|
+
if (invoker) {
|
|
699
|
+
this.invokers.set(sessionKey, invoker);
|
|
700
|
+
} else {
|
|
701
|
+
this.invokers.delete(sessionKey);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** Whether a downstream invoker is currently attached for `sessionKey`. */
|
|
706
|
+
hasInvoker(sessionKey: string): boolean {
|
|
707
|
+
return this.invokers.has(sessionKey);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Whether `name` is one the browser announced for `sessionKey` — i.e. whether
|
|
712
|
+
* this invocation belongs to the app channel rather than to the server-side
|
|
713
|
+
* `capabilityRegistry`. This is the routing predicate;
|
|
714
|
+
* {@link routeCapabilityInvocation} is the routing itself.
|
|
715
|
+
*
|
|
716
|
+
* §L6: a name the *server* registry also owns is not a client capability. The
|
|
717
|
+
* server entry is the one that was registered with the agent, so it is the one
|
|
718
|
+
* that must answer.
|
|
719
|
+
*/
|
|
720
|
+
hasClientCapability(sessionKey: string, name: string): boolean {
|
|
721
|
+
if (this.serverRegistry.list().some((c) => c.name === name)) return false;
|
|
722
|
+
return this.listClient(sessionKey).some((c) => c.name === name);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Route one `capability_invoked` to the browser that announced it.
|
|
727
|
+
*
|
|
728
|
+
* Parks the `callId` and hands the invocation to the registered invoker. When
|
|
729
|
+
* there is no invoker, or the invoker reports that no live UI received it,
|
|
730
|
+
* the call is answered immediately with {@link NO_UI_ATTACHED_ERROR} and
|
|
731
|
+
* never parked (§L2).
|
|
732
|
+
*/
|
|
733
|
+
async invoke(
|
|
734
|
+
sessionKey: string,
|
|
735
|
+
invocation: Omit<ClientCapabilityInvocation, "sessionKey" | "ownerId">,
|
|
736
|
+
): Promise<InvokeOutcome> {
|
|
737
|
+
const outcome = await this.park(sessionKey, invocation);
|
|
738
|
+
if (outcome === "no-ui") {
|
|
739
|
+
await this.sendResult(sessionKey, invocation.callId, { error: NO_UI_ATTACHED_ERROR });
|
|
740
|
+
}
|
|
741
|
+
return outcome;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Like {@link invoke}, but the browser's answer resolves this promise instead
|
|
746
|
+
* of going to the sender — for a server-side caller that is not the agent
|
|
747
|
+
* (a test driver, a route).
|
|
748
|
+
*
|
|
749
|
+
* Resolves with exactly what the browser posted, `{ error }` envelopes
|
|
750
|
+
* included; the caller maps them. Rejects with `Error(NO_UI_ATTACHED_ERROR)`
|
|
751
|
+
* when no live UI received the invocation (never parked), and with
|
|
752
|
+
* `Error(capabilityTimeoutError(...))` when the §L3 timer fires first. A
|
|
753
|
+
* session restart or clear ({@link dropPendingCalls}) resolves it with
|
|
754
|
+
* `{ error }`, like any other parked call.
|
|
755
|
+
*/
|
|
756
|
+
call(
|
|
757
|
+
sessionKey: string,
|
|
758
|
+
invocation: Omit<ClientCapabilityInvocation, "sessionKey" | "ownerId">,
|
|
759
|
+
): Promise<unknown> {
|
|
760
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
761
|
+
this.park(
|
|
762
|
+
sessionKey,
|
|
763
|
+
{ ...invocation, invokedBy: "driver" },
|
|
764
|
+
{ settle: resolve, reject },
|
|
765
|
+
).then((outcome) => {
|
|
766
|
+
if (outcome === "no-ui") reject(new Error(NO_UI_ATTACHED_ERROR));
|
|
767
|
+
}, reject);
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Shared by {@link invoke} and {@link call}: the §L2 liveness checks, the
|
|
773
|
+
* §L4 owner stamp, the park and the §L3 timer, then the hand-off to the
|
|
774
|
+
* invoker. Answers nobody — a `"no-ui"` outcome leaves that to the caller,
|
|
775
|
+
* with the call already unparked.
|
|
776
|
+
*/
|
|
777
|
+
private async park(
|
|
778
|
+
sessionKey: string,
|
|
779
|
+
invocation: Omit<ClientCapabilityInvocation, "sessionKey" | "ownerId">,
|
|
780
|
+
driver?: { settle: (result: unknown) => void; reject: (error: Error) => void },
|
|
781
|
+
): Promise<InvokeOutcome> {
|
|
782
|
+
const invoker = this.invokers.get(sessionKey);
|
|
783
|
+
|
|
784
|
+
// §L2, sharpened. Before ticket 05 the only liveness signal was the
|
|
785
|
+
// invoker's boolean, and every forge app derived it from "is anything at
|
|
786
|
+
// all listening to my event emitter" — which counts a subscriber for a
|
|
787
|
+
// different session as a live UI. Once an app tracks subscribers per
|
|
788
|
+
// session key we can answer that question exactly, and do it *before*
|
|
789
|
+
// burning a fan-out.
|
|
790
|
+
if (this.tracksSubscribers(sessionKey) && this.subscriberCount(sessionKey) === 0) {
|
|
791
|
+
return "no-ui";
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (!invoker) return "no-ui";
|
|
795
|
+
|
|
796
|
+
// §L4 — one owner. The app's invoker is expected to deliver only to this
|
|
797
|
+
// subscriber; the others stay connected and inert.
|
|
798
|
+
const full: ClientCapabilityInvocation = {
|
|
799
|
+
...invocation,
|
|
800
|
+
sessionKey,
|
|
801
|
+
ownerId: this.routeTo(sessionKey),
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
// Park BEFORE dispatching: an SSE round trip can complete inside the same
|
|
805
|
+
// tick as the invoker call, and a result that arrives before the park would
|
|
806
|
+
// be dropped as unknown.
|
|
807
|
+
const timeoutMs = this.timeoutFor(sessionKey, invocation.name);
|
|
808
|
+
const entry: ParkedCall = { ...full, parkedAt: Date.now(), timeoutMs, ...driver };
|
|
809
|
+
const calls = this.parked.get(sessionKey) ?? new Map<string, ParkedCall>();
|
|
810
|
+
calls.set(invocation.callId, entry);
|
|
811
|
+
this.parked.set(sessionKey, calls);
|
|
812
|
+
this.armCallTimer(sessionKey, invocation.callId, invocation.name, timeoutMs);
|
|
813
|
+
|
|
814
|
+
let delivered = false;
|
|
815
|
+
try {
|
|
816
|
+
delivered = await invoker(full);
|
|
817
|
+
} catch (err) {
|
|
818
|
+
this.log.warn(
|
|
819
|
+
`[client-capability-bridge] [${sessionKey}] invoker threw for ` +
|
|
820
|
+
`"${invocation.name}" (callId=${invocation.callId}): ${err}`,
|
|
821
|
+
);
|
|
822
|
+
delivered = false;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if (delivered) return "dispatched";
|
|
826
|
+
|
|
827
|
+
// Nothing received it — unpark; the caller answers.
|
|
828
|
+
this.clearCallTimer(sessionKey, invocation.callId);
|
|
829
|
+
if (calls.delete(invocation.callId) && calls.size === 0) this.parked.delete(sessionKey);
|
|
830
|
+
return "no-ui";
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// ── Per-call timeouts (spec §L3) ─────────────────────────────────────────
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* The §L3 deadline for one capability: whatever the browser announced as
|
|
837
|
+
* `callTimeoutMs`, else this bridge's default.
|
|
838
|
+
*
|
|
839
|
+
* Both must stay under the runner's own `DEFAULT_CAPABILITY_CALL_TIMEOUT_MS`
|
|
840
|
+
* (60 s) to be worth anything — a timer that fires second only ever reports
|
|
841
|
+
* what the runner has already given up on.
|
|
842
|
+
*/
|
|
843
|
+
timeoutFor(sessionKey: string, name: string): number {
|
|
844
|
+
const declared = this.listClient(sessionKey).find((c) => c.name === name)?.callTimeoutMs;
|
|
845
|
+
return typeof declared === "number" && declared > 0 ? declared : this.defaultCallTimeoutMs;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
private armCallTimer(sessionKey: string, callId: string, name: string, timeoutMs: number): void {
|
|
849
|
+
const timer = setTimeout(() => {
|
|
850
|
+
this.clearCallTimer(sessionKey, callId);
|
|
851
|
+
const calls = this.parked.get(sessionKey);
|
|
852
|
+
const entry = calls?.get(callId);
|
|
853
|
+
if (!calls || !entry) return;
|
|
854
|
+
calls.delete(callId);
|
|
855
|
+
if (calls.size === 0) this.parked.delete(sessionKey);
|
|
856
|
+
this.log.warn(
|
|
857
|
+
`[client-capability-bridge] [${sessionKey}] "${name}" (callId=${callId}) timed out ` +
|
|
858
|
+
`after ${timeoutMs}ms — the browser never answered`,
|
|
859
|
+
);
|
|
860
|
+
const message = capabilityTimeoutError(name, timeoutMs);
|
|
861
|
+
if (entry.reject) {
|
|
862
|
+
entry.reject(new Error(message));
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
void this.sendResult(sessionKey, callId, { error: message });
|
|
866
|
+
}, timeoutMs);
|
|
867
|
+
(timer as { unref?: () => void }).unref?.();
|
|
868
|
+
|
|
869
|
+
const timers = this.callTimers.get(sessionKey) ?? new Map();
|
|
870
|
+
timers.set(callId, timer);
|
|
871
|
+
this.callTimers.set(sessionKey, timers);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
private clearCallTimer(sessionKey: string, callId: string): void {
|
|
875
|
+
const timers = this.callTimers.get(sessionKey);
|
|
876
|
+
const timer = timers?.get(callId);
|
|
877
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
878
|
+
timers?.delete(callId);
|
|
879
|
+
if (timers && timers.size === 0) this.callTimers.delete(sessionKey);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
private clearCallTimers(sessionKey: string): void {
|
|
883
|
+
for (const timer of this.callTimers.get(sessionKey)?.values() ?? []) clearTimeout(timer);
|
|
884
|
+
this.callTimers.delete(sessionKey);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Answer a parked `callId` with what the browser posted back and forward it
|
|
889
|
+
* to the agent as one `capability_result`.
|
|
890
|
+
*
|
|
891
|
+
* Returns `false` — and sends nothing — when the `callId` is not parked under
|
|
892
|
+
* `sessionKey`. That covers the duplicate answer (two tabs both saw the SSE
|
|
893
|
+
* event and both posted), the late answer after a timeout, and a forged POST
|
|
894
|
+
* for someone else's call. Only a call this bridge actually parked can be
|
|
895
|
+
* answered.
|
|
896
|
+
*/
|
|
897
|
+
async resolve(sessionKey: string, callId: string, result: unknown): Promise<boolean> {
|
|
898
|
+
const calls = this.parked.get(sessionKey);
|
|
899
|
+
const entry = calls?.get(callId);
|
|
900
|
+
if (!calls || !entry) return false;
|
|
901
|
+
|
|
902
|
+
this.clearCallTimer(sessionKey, callId);
|
|
903
|
+
calls.delete(callId);
|
|
904
|
+
if (calls.size === 0) this.parked.delete(sessionKey);
|
|
905
|
+
|
|
906
|
+
// A driver-originated call answers its own caller; the agent never saw this callId.
|
|
907
|
+
if (entry.settle) {
|
|
908
|
+
entry.settle(result);
|
|
909
|
+
return true;
|
|
910
|
+
}
|
|
911
|
+
await this.sendResult(sessionKey, callId, result);
|
|
912
|
+
return true;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Answer every parked call for `sessionKey` with an error and forget them,
|
|
917
|
+
* leaving the announced registry, sender and invoker in place.
|
|
918
|
+
*
|
|
919
|
+
* This is what a session restart needs: the agent process behind those calls
|
|
920
|
+
* is gone, so nothing will ever resolve them, but the browser is still
|
|
921
|
+
* attached and its announcement must survive — {@link clear} would throw it
|
|
922
|
+
* away and the agent would come back up with no client capabilities until the
|
|
923
|
+
* next page load.
|
|
924
|
+
*
|
|
925
|
+
* @returns how many calls were dropped.
|
|
926
|
+
*/
|
|
927
|
+
dropPendingCalls(sessionKey: string, error = SESSION_ENDED_ERROR): number {
|
|
928
|
+
const count = this.parked.get(sessionKey)?.size ?? 0;
|
|
929
|
+
this.settleAll(sessionKey, error);
|
|
930
|
+
return count;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** `callId`s currently parked for `sessionKey`. Ticket 05's timeout seam. */
|
|
934
|
+
pendingCallIds(sessionKey: string): string[] {
|
|
935
|
+
return [...(this.parked.get(sessionKey)?.keys() ?? [])];
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** The parked entry for one `callId`, or `undefined`. */
|
|
939
|
+
pendingCall(sessionKey: string, callId: string): ParkedCall | undefined {
|
|
940
|
+
return this.parked.get(sessionKey)?.get(callId);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Push one `capability_result` upstream, bypassing the parked-call check.
|
|
945
|
+
*
|
|
946
|
+
* For results the bridge did not park — i.e. the server-side
|
|
947
|
+
* `capabilityRegistry` path, which runs in Nitro and answers synchronously.
|
|
948
|
+
* Browser-supplied results must go through {@link resolve} instead, which only
|
|
949
|
+
* answers a `callId` this bridge actually parked.
|
|
950
|
+
*
|
|
951
|
+
* Never throws.
|
|
952
|
+
*/
|
|
953
|
+
async sendResult(sessionKey: string, callId: string, result: unknown): Promise<void> {
|
|
954
|
+
const sender = this.senders.get(sessionKey);
|
|
955
|
+
if (!sender) {
|
|
956
|
+
this.log.warn(
|
|
957
|
+
`[client-capability-bridge] [${sessionKey}] no sender — capability_result ` +
|
|
958
|
+
`for callId=${callId} dropped; the agent will wait out its own timeout.`,
|
|
959
|
+
);
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
try {
|
|
963
|
+
await sender({ type: "capability_result", callId, result });
|
|
964
|
+
} catch (err) {
|
|
965
|
+
this.log.warn(`[client-capability-bridge] [${sessionKey}] capability_result failed: ${err}`);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* Answer every parked call for `sessionKey` with `error`, then forget them.
|
|
971
|
+
*
|
|
972
|
+
* Synchronous on purpose: {@link clear} tears the sender down in the same tick,
|
|
973
|
+
* so the sender is snapshotted here rather than looked up per call.
|
|
974
|
+
*
|
|
975
|
+
* Calls parked by {@link call} have no `callId` the agent knows about: their
|
|
976
|
+
* `settle` receives the `{ error }` envelope instead of the sender.
|
|
977
|
+
*/
|
|
978
|
+
private settleAll(sessionKey: string, error: string): void {
|
|
979
|
+
this.clearCallTimers(sessionKey);
|
|
980
|
+
const calls = this.parked.get(sessionKey);
|
|
981
|
+
if (!calls || calls.size === 0) return;
|
|
982
|
+
const entries = [...calls.values()];
|
|
983
|
+
this.parked.delete(sessionKey);
|
|
984
|
+
|
|
985
|
+
const sender = this.senders.get(sessionKey);
|
|
986
|
+
for (const entry of entries) {
|
|
987
|
+
const { callId } = entry;
|
|
988
|
+
try {
|
|
989
|
+
if (entry.settle) {
|
|
990
|
+
entry.settle({ error });
|
|
991
|
+
} else if (sender) {
|
|
992
|
+
void Promise.resolve(
|
|
993
|
+
sender({ type: "capability_result", callId, result: { error } }),
|
|
994
|
+
).catch((err) => {
|
|
995
|
+
this.log.warn(
|
|
996
|
+
`[client-capability-bridge] [${sessionKey}] settle of callId=${callId} failed: ${err}`,
|
|
997
|
+
);
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
} catch (err) {
|
|
1001
|
+
this.log.warn(
|
|
1002
|
+
`[client-capability-bridge] [${sessionKey}] settle of callId=${callId} failed: ${err}`,
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Reconcile the agent's registry with the merged list: `capability_deregister`
|
|
1010
|
+
* for names that dropped out, then `capability_register` for what remains.
|
|
1011
|
+
*
|
|
1012
|
+
* Spec §L5 — no-ops entirely when the merged list is byte-identical to what
|
|
1013
|
+
* was last sent for this key, so an SSE reconnect that re-announces an
|
|
1014
|
+
* unchanged registry produces **zero** upstream traffic. Pass `force: true` at
|
|
1015
|
+
* session creation, where a fresh agent process has no prior registration.
|
|
1016
|
+
*
|
|
1017
|
+
* The deregister leg is not optional bookkeeping: the runner's
|
|
1018
|
+
* `handleCapabilityRegister` loops `registry.register(cap)` per entry, so a
|
|
1019
|
+
* `capability_register` only ever **adds**. Re-sending a shorter list would
|
|
1020
|
+
* leave the removed capability registered and callable. Only
|
|
1021
|
+
* `capability_deregister` takes one away.
|
|
1022
|
+
*
|
|
1023
|
+
* @returns the merged list (whether or not anything was sent).
|
|
1024
|
+
*/
|
|
1025
|
+
async sync(sessionKey: string, options: { force?: boolean } = {}): Promise<Capability[]> {
|
|
1026
|
+
const capabilities = this.merged(sessionKey);
|
|
1027
|
+
const sender = this.senders.get(sessionKey);
|
|
1028
|
+
if (!sender) return capabilities;
|
|
1029
|
+
|
|
1030
|
+
const next = signature(capabilities);
|
|
1031
|
+
if (!options.force && this.lastSent.get(sessionKey) === next) return capabilities;
|
|
1032
|
+
|
|
1033
|
+
const nextNames = new Set(capabilities.map((c) => c.name));
|
|
1034
|
+
const removed = [...(this.lastSentNames.get(sessionKey) ?? [])].filter(
|
|
1035
|
+
(name) => !nextNames.has(name),
|
|
1036
|
+
);
|
|
1037
|
+
|
|
1038
|
+
try {
|
|
1039
|
+
if (removed.length > 0) {
|
|
1040
|
+
await sender({ type: "capability_deregister", names: removed });
|
|
1041
|
+
this.log.info(
|
|
1042
|
+
`[client-capability-bridge] [${sessionKey}] deregistered ${removed.length} ` +
|
|
1043
|
+
`capabilit${removed.length === 1 ? "y" : "ies"}: ${removed.join(", ")}`,
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
if (capabilities.length > 0) {
|
|
1047
|
+
await sender({ type: "capability_register", capabilities });
|
|
1048
|
+
this.log.info(
|
|
1049
|
+
`[client-capability-bridge] [${sessionKey}] registered ${capabilities.length} ` +
|
|
1050
|
+
`capabilities (${this.listClient(sessionKey).length} from the browser)`,
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
this.lastSent.set(sessionKey, next);
|
|
1054
|
+
this.lastSentNames.set(sessionKey, nextNames);
|
|
1055
|
+
} catch (err) {
|
|
1056
|
+
this.log.warn(
|
|
1057
|
+
`[client-capability-bridge] [${sessionKey}] capability registration failed: ${err}`,
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
return capabilities;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Forget everything for `sessionKey`, sender and invoker included.
|
|
1066
|
+
*
|
|
1067
|
+
* Parked calls are answered with {@link SESSION_ENDED_ERROR} on the way out —
|
|
1068
|
+
* dropping them silently would leave the agent's turn blocked until the
|
|
1069
|
+
* runner's own 60 s timeout.
|
|
1070
|
+
*/
|
|
1071
|
+
clear(sessionKey: string): void {
|
|
1072
|
+
this.settleAll(sessionKey, SESSION_ENDED_ERROR);
|
|
1073
|
+
this.cancelGrace(sessionKey);
|
|
1074
|
+
this.sessions.delete(sessionKey);
|
|
1075
|
+
this.senders.delete(sessionKey);
|
|
1076
|
+
this.invokers.delete(sessionKey);
|
|
1077
|
+
this.subscribers.delete(sessionKey);
|
|
1078
|
+
this.lastSent.delete(sessionKey);
|
|
1079
|
+
this.lastSentNames.delete(sessionKey);
|
|
1080
|
+
for (const key of [...this.warnedCollisions]) {
|
|
1081
|
+
if (splitWarnKey(key)[0] === sessionKey) this.warnedCollisions.delete(key);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/** Test-only: wipe all state. Parked calls are dropped, not answered. */
|
|
1086
|
+
reset(): void {
|
|
1087
|
+
for (const key of [...this.callTimers.keys()]) this.clearCallTimers(key);
|
|
1088
|
+
for (const key of [...this.graceTimers.keys()]) this.cancelGrace(key);
|
|
1089
|
+
this.sessions.clear();
|
|
1090
|
+
this.senders.clear();
|
|
1091
|
+
this.invokers.clear();
|
|
1092
|
+
this.subscribers.clear();
|
|
1093
|
+
this.parked.clear();
|
|
1094
|
+
this.lastSent.clear();
|
|
1095
|
+
this.lastSentNames.clear();
|
|
1096
|
+
this.warnedCollisions.clear();
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* Stamp the wire fields the browser must not author: `side` is fixed to
|
|
1101
|
+
* `"client"` and `origin` to `{ kind: "client" }` (the analogue of app-sdk's
|
|
1102
|
+
* SDK-stamped `origin`). Unknown fields are dropped.
|
|
1103
|
+
*/
|
|
1104
|
+
private normalise(descriptor: IncomingClientCapability): Capability {
|
|
1105
|
+
return {
|
|
1106
|
+
name: descriptor.name,
|
|
1107
|
+
displayName: descriptor.displayName,
|
|
1108
|
+
description: descriptor.description,
|
|
1109
|
+
inputSchema: descriptor.inputSchema,
|
|
1110
|
+
outputSchema: descriptor.outputSchema,
|
|
1111
|
+
side: "client",
|
|
1112
|
+
origin: { kind: "client" },
|
|
1113
|
+
scope: descriptor.scope,
|
|
1114
|
+
requiresApproval: descriptor.requiresApproval,
|
|
1115
|
+
kind: descriptor.kind,
|
|
1116
|
+
audience: descriptor.audience,
|
|
1117
|
+
promptFragment: descriptor.promptFragment,
|
|
1118
|
+
callTimeoutMs: descriptor.callTimeoutMs,
|
|
1119
|
+
render: descriptor.render as Capability["render"],
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/** The subscriber with the highest announce sequence, or `null` for an empty map. */
|
|
1125
|
+
function highestAnnouncer(announcers: Map<string, number>): string | null {
|
|
1126
|
+
let best: string | null = null;
|
|
1127
|
+
let bestSeq = -1;
|
|
1128
|
+
for (const [id, seq] of announcers) {
|
|
1129
|
+
if (seq > bestSeq) {
|
|
1130
|
+
best = id;
|
|
1131
|
+
bestSeq = seq;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
return best;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** Stable content signature used for the §L5 change diff. */
|
|
1138
|
+
function signature(capabilities: Capability[]): string {
|
|
1139
|
+
return JSON.stringify([...capabilities].sort((a, b) => a.name.localeCompare(b.name)));
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/** `${sessionKey}::${name}` — session keys may contain `:`, names may not. */
|
|
1143
|
+
function splitWarnKey(key: string): [string, string] {
|
|
1144
|
+
const idx = key.lastIndexOf("::");
|
|
1145
|
+
return [key.slice(0, idx), key.slice(idx + 2)];
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Process-wide singleton, matching `capabilityRegistry`'s lifetime. Apps that
|
|
1150
|
+
* need isolation (tests, multi-tenant harnesses) can construct their own.
|
|
1151
|
+
*/
|
|
1152
|
+
export const clientCapabilityBridge = new ClientCapabilityBridge();
|
|
1153
|
+
|
|
1154
|
+
/** Which side of the channel answered a `capability_invoked`. */
|
|
1155
|
+
export type CapabilityRoute = "client" | "server";
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* The whole `capability_invoked` branch of an app's agent-manager `onEvent`
|
|
1159
|
+
* hook, in one call.
|
|
1160
|
+
*
|
|
1161
|
+
* A name the browser announced goes down the app channel (SSE → handler in the
|
|
1162
|
+
* browser → POST back → {@link ClientCapabilityBridge.resolve}). Anything else
|
|
1163
|
+
* — including a name the server registry owns, and an unknown name — is
|
|
1164
|
+
* dispatched in Nitro against the server-side `capabilityRegistry`, exactly as
|
|
1165
|
+
* before. That fallback is why spec §L6 holds: genuinely server-side
|
|
1166
|
+
* capabilities keep working untouched.
|
|
1167
|
+
*
|
|
1168
|
+
* Both branches answer through the bridge's sender, so both get the
|
|
1169
|
+
* `processManager.sendCommand` transport rather than the persisting
|
|
1170
|
+
* `SessionDispatcher` one (see {@link ClientCapabilitySender}).
|
|
1171
|
+
*
|
|
1172
|
+
* @returns which side took the call.
|
|
1173
|
+
*/
|
|
1174
|
+
export async function routeCapabilityInvocation(
|
|
1175
|
+
sessionKey: string,
|
|
1176
|
+
invocation: { callId: string; name: string; input: unknown },
|
|
1177
|
+
deps: {
|
|
1178
|
+
/** Defaults to the process-wide {@link clientCapabilityBridge}. */
|
|
1179
|
+
bridge?: ClientCapabilityBridge;
|
|
1180
|
+
/** Defaults to the process-wide `capabilityRegistry`. */
|
|
1181
|
+
registry?: CapabilityRegistry;
|
|
1182
|
+
/** Context handed to a server-side handler. */
|
|
1183
|
+
ctx: CapabilityHandlerCtx;
|
|
1184
|
+
},
|
|
1185
|
+
): Promise<CapabilityRoute> {
|
|
1186
|
+
const bridge = deps.bridge ?? clientCapabilityBridge;
|
|
1187
|
+
|
|
1188
|
+
if (bridge.hasClientCapability(sessionKey, invocation.name)) {
|
|
1189
|
+
await bridge.invoke(sessionKey, invocation);
|
|
1190
|
+
return "client";
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
const registry = deps.registry ?? capabilityRegistry;
|
|
1194
|
+
const input =
|
|
1195
|
+
invocation.input && typeof invocation.input === "object"
|
|
1196
|
+
? (invocation.input as Record<string, unknown>)
|
|
1197
|
+
: {};
|
|
1198
|
+
const result = await registry.dispatch(invocation.name, input, deps.ctx);
|
|
1199
|
+
await bridge.sendResult(sessionKey, invocation.callId, result);
|
|
1200
|
+
return "server";
|
|
1201
|
+
}
|