broapp 0.2.0 → 0.3.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.
@@ -34,7 +34,7 @@ const providerInfo = s.object({
34
34
  label: s.string(),
35
35
  local: s.boolean(),
36
36
  needs: s.object({
37
- apiKey: s.boolean(),
37
+ apiKey: s.enum(['required', 'optional', 'none']),
38
38
  baseUrl: s.enum(['required', 'optional', 'none']),
39
39
  }),
40
40
  defaultBaseUrl: s.nullable(s.string()),
@@ -55,6 +55,53 @@ const chatTurn = s.object({
55
55
  content: s.string({ max: 20_000 }),
56
56
  });
57
57
 
58
+ /**
59
+ * One image on a turn. `data` is base64 without the `data:` prefix.
60
+ *
61
+ * The bounds are the browser's contract as much as the host's: the panel
62
+ * downscales before it sends, and four images of two million characters still
63
+ * fit inside one Brobridge frame with room to spare.
64
+ */
65
+ const chatFile = s.object({
66
+ name: s.string({ max: 200 }),
67
+ mediaType: s.string({ pattern: /image\/(png|jpeg|gif|webp)/ }),
68
+ data: s.string({ min: 1, max: 2_000_000 }),
69
+ });
70
+
71
+ /** A conversation identifier. The host chooses it; the browser only echoes it. */
72
+ const threadId = s.string({ pattern: /[A-Za-z0-9_-]{8,64}/ });
73
+
74
+ /** A conversation, without its messages. */
75
+ const thread = s.object({
76
+ id: threadId,
77
+ title: s.string({ max: 120 }),
78
+ /** Null means "whatever Settings says". */
79
+ modelId: s.nullable(s.string({ max: 200 })),
80
+ createdAt: s.number(),
81
+ updatedAt: s.number(),
82
+ messageCount: s.number({ int: true, min: 0 }),
83
+ });
84
+
85
+ /**
86
+ * One stored UI message. Parts are the AI SDK's; the host stores, never
87
+ * interprets.
88
+ *
89
+ * `s.unknown()` is normally forbidden on an input, because the point of an
90
+ * input schema is that browser-supplied data is untrusted. It is right here
91
+ * for the one reason that exempts it: nothing on the host ever reads inside a
92
+ * part. They are written to SQLite as JSON and handed back to the same browser
93
+ * that sent them, so the shape the host would be validating is a shape only
94
+ * the AI SDK understands and only the AI SDK consumes. What is still bounded
95
+ * is the *amount*: 200 parts to a message, 200 messages to a save, and a byte
96
+ * ceiling on the whole save in `threads.ts`.
97
+ */
98
+ const storedMessage = s.object({
99
+ id: s.string({ max: 200 }),
100
+ role: s.enum(['user', 'assistant', 'system']),
101
+ parts: s.array(s.unknown(), { max: 200 }),
102
+ metadata: s.optional(s.unknown()),
103
+ });
104
+
58
105
  /**
59
106
  * One stream event, flat because the validator has no unions.
60
107
  *
@@ -72,6 +119,10 @@ const chatEvent = s.object({
72
119
  output: s.optional(s.unknown()),
73
120
  denied: s.optional(s.boolean()),
74
121
  permission: s.optional(s.enum(['read', 'confirm'])),
122
+ requestId: s.optional(s.string()),
123
+ releaseId: s.optional(s.string()),
124
+ argumentsHash: s.optional(s.string()),
125
+ expiresAt: s.optional(s.number()),
75
126
  inputTokens: s.optional(s.number()),
76
127
  outputTokens: s.optional(s.number()),
77
128
  code: s.optional(s.string()),
@@ -119,6 +170,56 @@ export const aiContract = defineContract({
119
170
  output: s.object({ accepted: s.boolean() }),
120
171
  summary: 'Answer a confirm event. `accepted` is false when no run is waiting on that call.',
121
172
  },
173
+ // Conversations are the user's own data, so every route below answers even
174
+ // when no provider is configured: somebody who has just removed their key
175
+ // is still entitled to read and delete what they wrote.
176
+ 'ai.threadsList': {
177
+ input: s.void(),
178
+ output: s.object({ threads: s.array(thread, { max: 500 }) }),
179
+ summary: 'Every stored conversation, most recently changed first.',
180
+ },
181
+ 'ai.threadsCreate': {
182
+ input: s.object({
183
+ title: s.optional(s.string({ max: 120 })),
184
+ modelId: s.optional(s.nullable(s.string({ max: 200 }))),
185
+ }),
186
+ output: thread,
187
+ summary: 'Start a conversation. Without a title it is named after its first message.',
188
+ },
189
+ 'ai.threadsGet': {
190
+ input: s.object({ id: threadId }),
191
+ output: s.object({ thread, messages: s.array(storedMessage, { max: 200 }) }),
192
+ summary: 'One conversation and its messages.',
193
+ },
194
+ 'ai.threadsSave': {
195
+ input: s.object({
196
+ id: threadId,
197
+ messages: s.array(storedMessage, { max: 200 }),
198
+ title: s.optional(s.string({ max: 120 })),
199
+ }),
200
+ output: thread,
201
+ summary: 'Replace the messages of a conversation, whole.',
202
+ },
203
+ 'ai.threadsUpdate': {
204
+ input: s.object({
205
+ id: threadId,
206
+ title: s.optional(s.string({ max: 120 })),
207
+ // Null puts the conversation back on whatever Settings says.
208
+ modelId: s.optional(s.nullable(s.string({ max: 200 }))),
209
+ }),
210
+ output: thread,
211
+ summary: 'Rename a conversation, or give it a model of its own.',
212
+ },
213
+ 'ai.threadsDelete': {
214
+ input: s.object({ id: threadId }),
215
+ output: s.object({ deleted: s.boolean() }),
216
+ summary: 'Delete one conversation and its messages.',
217
+ },
218
+ 'ai.threadsClear': {
219
+ input: s.void(),
220
+ output: s.object({ deleted: s.number({ int: true, min: 0 }) }),
221
+ summary: 'Delete every conversation.',
222
+ },
122
223
  },
123
224
  streams: {
124
225
  'ai.chat': {
@@ -127,6 +228,14 @@ export const aiContract = defineContract({
127
228
  message: s.string({ min: 1, max: 20_000 }),
128
229
  refs: s.array(s.string({ max: 200 }), { max: 50 }),
129
230
  history: s.array(chatTurn, { max: 100 }),
231
+ // Images travel with the turn they arrive on. History keeps a
232
+ // placeholder instead, because a transcript of base64 would not fit.
233
+ files: s.optional(s.array(chatFile, { max: 4 })),
234
+ // The model for this turn only, and only *within* the configured
235
+ // provider. A provider is never overridden per turn: a different
236
+ // provider means a different key and a different answer to "does this
237
+ // leave my computer", and that stays a Settings decision.
238
+ modelId: s.optional(s.string({ max: 200 })),
130
239
  }),
131
240
  event: chatEvent,
132
241
  summary: 'One chat turn. Emits text, tool calls, confirmations and usage.',
@@ -10,7 +10,10 @@ export type {
10
10
  AiSettings,
11
11
  BroappModel,
12
12
  ChatEvent,
13
+ ChatFile,
13
14
  ChatTurn,
14
15
  ProviderInfo,
16
+ StoredMessage,
17
+ Thread,
15
18
  ToolPermission,
16
19
  } from './types.ts';
@@ -5,9 +5,22 @@
5
5
  * without changing the matching interface in `types.ts` fails `tsc` instead of
6
6
  * failing later, in the browser, as a shape that is almost right.
7
7
  */
8
- import type { OperationInput, OperationOutput, StreamEvent } from '../../shared/contract.ts';
8
+ import type {
9
+ OperationInput,
10
+ OperationOutput,
11
+ StreamEvent,
12
+ StreamParams,
13
+ } from '../../shared/contract.ts';
9
14
  import type { AiContract } from './contract.ts';
10
- import type { AiSettings, BroappModel, ChatEvent, ProviderInfo } from './types.ts';
15
+ import type {
16
+ AiSettings,
17
+ BroappModel,
18
+ ChatEvent,
19
+ ChatFile,
20
+ ProviderInfo,
21
+ StoredMessage,
22
+ Thread,
23
+ } from './types.ts';
11
24
 
12
25
  type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
13
26
  ? true
@@ -37,6 +50,21 @@ void providerMatch;
37
50
  const chatEventMatch: Equal<StreamEvent<AiContract, 'ai.chat'>, ChatEvent> = true;
38
51
  void chatEventMatch;
39
52
 
53
+ const chatFileMatch: Equal<
54
+ NonNullable<StreamParams<AiContract, 'ai.chat'>['files']>[number],
55
+ ChatFile
56
+ > = true;
57
+ void chatFileMatch;
58
+
59
+ const threadMatch: Equal<OperationOutput<AiContract, 'ai.threadsCreate'>, Thread> = true;
60
+ void threadMatch;
61
+
62
+ const storedMessageMatch: Equal<
63
+ OperationOutput<AiContract, 'ai.threadsGet'>['messages'][number],
64
+ StoredMessage
65
+ > = true;
66
+ void storedMessageMatch;
67
+
40
68
  // The update route is the only one that takes a partial: every field optional,
41
69
  // so a browser can change one setting without restating the rest.
42
70
  const updateAcceptsNothing: OperationInput<AiContract, 'ai.settingsUpdate'> = {};
@@ -31,7 +31,10 @@ export interface ProviderInfo {
31
31
  label: string;
32
32
  /** True when requests stay on this machine with the current settings. */
33
33
  local: boolean;
34
- needs: { apiKey: boolean; baseUrl: 'required' | 'optional' | 'none' };
34
+ needs: {
35
+ apiKey: 'required' | 'optional' | 'none';
36
+ baseUrl: 'required' | 'optional' | 'none';
37
+ };
35
38
  defaultBaseUrl: string | null;
36
39
  }
37
40
 
@@ -58,6 +61,56 @@ export interface ChatTurn {
58
61
  content: string;
59
62
  }
60
63
 
64
+ /**
65
+ * One image sent with a chat turn.
66
+ *
67
+ * `data` is base64 with no `data:` prefix, at most 2,000,000 characters, and
68
+ * `mediaType` is one of `image/png`, `image/jpeg`, `image/gif`, `image/webp`.
69
+ * At most four travel with one message, and they travel only with the message
70
+ * they arrive on: a later turn's `history` keeps the line
71
+ * `[image: <name>]` in place of the image itself, because a transcript of
72
+ * base64 would not fit inside the contract's bound on a turn.
73
+ */
74
+ export interface ChatFile {
75
+ name: string;
76
+ mediaType: string;
77
+ data: string;
78
+ }
79
+
80
+ /**
81
+ * A stored conversation, without its messages.
82
+ *
83
+ * `modelId` is null for a conversation that follows Settings, and a model id
84
+ * for one that has been pinned to a model of its own. The provider is never
85
+ * part of a conversation: it is a Settings decision, because changing it
86
+ * changes which key is used and whether anything leaves the computer.
87
+ */
88
+ export interface Thread {
89
+ id: string;
90
+ title: string;
91
+ modelId: string | null;
92
+ createdAt: number;
93
+ updatedAt: number;
94
+ messageCount: number;
95
+ }
96
+
97
+ /**
98
+ * One message as it is stored.
99
+ *
100
+ * `parts` are the AI SDK's own message parts. The host writes them as JSON and
101
+ * hands them back unread — it has no opinion about what a part is, which is
102
+ * why the type is `unknown[]` rather than a copy of the SDK's union that would
103
+ * drift from it. One thing the host *does* change on the way in: a `file` part
104
+ * becomes the text `[image: name]`, because a data URL in SQLite would be a
105
+ * copy of the image nobody asked to keep.
106
+ */
107
+ export interface StoredMessage {
108
+ id: string;
109
+ role: 'user' | 'assistant' | 'system';
110
+ parts: unknown[];
111
+ metadata?: unknown;
112
+ }
113
+
61
114
  /**
62
115
  * One event on the `ai.chat` stream. Flat on purpose: the `s` validator has
63
116
  * no unions, so the discriminant is `type` and the other fields are
@@ -65,7 +118,8 @@ export interface ChatTurn {
65
118
  *
66
119
  * text text
67
120
  * tool-call callId, tool, input, permission
68
- * confirm callId, tool, input (waits for ai.chat.confirm)
121
+ * confirm callId, tool, input, requestId, releaseId, argumentsHash,
122
+ * expiresAt (waits for ai.chatConfirm)
69
123
  * tool-result callId, tool, output, denied?
70
124
  * usage inputTokens, outputTokens
71
125
  * done —
@@ -80,6 +134,12 @@ export interface ChatEvent {
80
134
  output?: unknown;
81
135
  denied?: boolean;
82
136
  permission?: ToolPermission;
137
+ /** On `confirm`: what the gate is waiting on, so an answer can name it. */
138
+ requestId?: string;
139
+ releaseId?: string;
140
+ argumentsHash?: string;
141
+ /** On `confirm`: when the question stops waiting, so the card can count down. */
142
+ expiresAt?: number;
83
143
  inputTokens?: number;
84
144
  outputTokens?: number;
85
145
  code?: string;
package/src/host/app.ts CHANGED
@@ -16,6 +16,7 @@ import type { BridgeStream } from '@brobridgejs/core';
16
16
 
17
17
  import type {
18
18
  AnyContract,
19
+ Effect,
19
20
  OperationInput,
20
21
  OperationName,
21
22
  OperationOutput,
@@ -23,10 +24,18 @@ import type {
23
24
  StreamName,
24
25
  StreamParams,
25
26
  } from '../shared/contract.ts';
26
- import { assertNoReservedRoutes, splitRoute } from '../shared/contract.ts';
27
- import { INTERNAL_ERROR_MESSAGE, isPublicBridgeError, PublicError } from '../shared/errors.ts';
27
+ import { assertNoReservedRoutes, effectOf, splitRoute } from '../shared/contract.ts';
28
+ import {
29
+ INTERNAL_ERROR_MESSAGE,
30
+ isPublicBridgeError,
31
+ isPublicError,
32
+ PublicError,
33
+ } from '../shared/errors.ts';
28
34
  import { encodeEvent } from '../shared/ndjson.ts';
29
- import { ValidationError } from '../shared/schema.ts';
35
+ import { isValidationError } from '../shared/schema.ts';
36
+
37
+ import { createGate } from './gate.ts';
38
+ import type { Channel, Envelope, ExecutionMode, Gate } from './gate.ts';
30
39
 
31
40
  /**
32
41
  * What an operation handler is told about its caller.
@@ -41,6 +50,14 @@ import { ValidationError } from '../shared/schema.ts';
41
50
  export interface CallContext {
42
51
  /** The route name, for logging. */
43
52
  readonly route: string;
53
+ /** Correlates this call with the gate's record and any approval it needed. */
54
+ readonly requestId: string;
55
+ /** Who asked, as the adapter that received the request said so. */
56
+ readonly channel: Channel;
57
+ /** Free text naming the caller: 'tab', 'ai:<runId>', 'mcp:<client>'. */
58
+ readonly caller: string;
59
+ /** `preview` means the data directory is a copy and nothing may leave the machine. */
60
+ readonly mode: ExecutionMode;
44
61
  }
45
62
 
46
63
  /** A unary operation implementation. */
@@ -81,6 +98,12 @@ export interface HostLogger {
81
98
  /** Options for {@link createHostApp}. */
82
99
  export interface HostAppOptions {
83
100
  readonly logger?: HostLogger;
101
+ /**
102
+ * The gate every call passes. An application that does not supply one gets a
103
+ * live gate for an unreleased application: the policy still applies, there is
104
+ * simply nothing recording it and no release to bind an approval to.
105
+ */
106
+ readonly gate?: Gate;
84
107
  }
85
108
 
86
109
  /** A contract with implementations attached, ready to mount on a bridge. */
@@ -106,12 +129,22 @@ export interface HostApp<C extends AnyContract> {
106
129
  * A route that is a stream, or one no handler implements, is a programming
107
130
  * error rather than a call failure, so it throws `TypeError` at the call
108
131
  * site instead of rejecting.
132
+ *
133
+ * The envelope is required, and required to be built by the adapter making
134
+ * the call rather than passed through from whatever asked it to. That is
135
+ * what stops a model from invoking an operation as the user.
109
136
  */
110
- invoke<K extends OperationName<C>>(name: K, input: unknown): Promise<OperationOutput<C, K>>;
137
+ invoke<K extends OperationName<C>>(
138
+ name: K,
139
+ input: unknown,
140
+ envelope: Envelope,
141
+ ): Promise<OperationOutput<C, K>>;
111
142
  /** Abort every stream this app currently has open. Called during shutdown. */
112
143
  abortAll(reason: string): void;
113
144
  /** How many streams are running right now. */
114
145
  readonly activeStreams: number;
146
+ /** The gate this application's calls pass through. */
147
+ readonly gate: Gate;
115
148
  }
116
149
 
117
150
  /**
@@ -141,6 +174,12 @@ export function createReservedHostApp<C extends AnyContract>(
141
174
  options: HostAppOptions = {},
142
175
  ): HostApp<C> {
143
176
  const logger: HostLogger = options.logger ?? console;
177
+ // An application that was not given a gate still has one. There is no
178
+ // ungated path into a handler, so the only question a missing option answers
179
+ // is what the records say, not whether the policy applies.
180
+ const gate: Gate =
181
+ options.gate ??
182
+ createGate({ appId: 'app', releaseId: 'unreleased', mode: 'live', logger });
144
183
  const operations = new Map<string, OperationHandler<C, never>>();
145
184
  const streams = new Map<string, StreamHandlerFor<C, never>>();
146
185
  const running = new Set<AbortController>();
@@ -159,13 +198,12 @@ export function createReservedHostApp<C extends AnyContract>(
159
198
  * call is not more trusted than a browser call just because it originates
160
199
  * inside the host — so there is one implementation and two callers.
161
200
  */
162
- async function runOperation(route: string, raw: unknown): Promise<unknown> {
201
+ async function runOperation(route: string, raw: unknown, envelope: Envelope): Promise<unknown> {
163
202
  const spec = contract.operations[route];
164
203
  const handler = operations.get(route);
165
204
  if (spec === undefined || handler === undefined) {
166
205
  throw new TypeError(`operation ${JSON.stringify(route)} has no implementation`);
167
206
  }
168
- const context: CallContext = { route };
169
207
  let input: unknown;
170
208
  try {
171
209
  input = spec.input.parse(raw);
@@ -173,13 +211,27 @@ export function createReservedHostApp<C extends AnyContract>(
173
211
  // A validation message names a field and a constraint from the contract
174
212
  // the browser already has. It carries nothing the caller did not send,
175
213
  // so it is safe to return and useful to see.
214
+ //
215
+ // This is deliberately before the gate. A call that never had valid
216
+ // arguments asked nobody for anything, so there is nothing to approve and
217
+ // nothing worth recording.
176
218
  throw new PublicError(
177
219
  'invalid_input',
178
- cause instanceof ValidationError ? cause.message : 'invalid input',
220
+ isValidationError(cause) ? cause.message : 'invalid input',
179
221
  ).toBridgeError();
180
222
  }
223
+ const context: CallContext = {
224
+ route,
225
+ requestId: envelope.requestId,
226
+ channel: envelope.channel,
227
+ caller: envelope.caller,
228
+ mode: gate.mode === 'preview' || envelope.mode === 'preview' ? 'preview' : 'live',
229
+ };
181
230
  try {
182
- const output = await handler(input as never, context);
231
+ const output = await gate.guard(
232
+ { ...envelope, route, effect: spec.effect ?? envelope.effectHint ?? 'write', input },
233
+ () => Promise.resolve(handler(input as never, context)),
234
+ );
183
235
  return spec.output.parse(output);
184
236
  } catch (cause) {
185
237
  throw wrap(cause, route, logger);
@@ -201,7 +253,7 @@ export function createReservedHostApp<C extends AnyContract>(
201
253
  return app;
202
254
  },
203
255
 
204
- invoke(name, input) {
256
+ invoke(name, input, envelope) {
205
257
  // Structural mistakes surface synchronously: a stream is not invokable
206
258
  // and a missing handler is a bug, and neither should look like a failed
207
259
  // call to whatever is awaiting the result.
@@ -214,7 +266,7 @@ export function createReservedHostApp<C extends AnyContract>(
214
266
  if (!operations.has(name)) {
215
267
  throw new TypeError(`operation ${JSON.stringify(name)} has no implementation`);
216
268
  }
217
- return runOperation(name, input).catch((cause: unknown) => {
269
+ return runOperation(name, input, envelope).catch((cause: unknown) => {
218
270
  // On the bridge, Brobridge reduces an unexpected failure to a fixed
219
271
  // sentence on the way out. `invoke` has no transport to do that, and
220
272
  // its caller is the AI layer, which may put what it is given into a
@@ -227,6 +279,8 @@ export function createReservedHostApp<C extends AnyContract>(
227
279
  return running.size;
228
280
  },
229
281
 
282
+ gate,
283
+
230
284
  abortAll(reason) {
231
285
  for (const controller of running) controller.abort(new Error(reason));
232
286
  },
@@ -248,7 +302,15 @@ export function createReservedHostApp<C extends AnyContract>(
248
302
  const { group, member } = splitRoute(route);
249
303
  if (contract.operations[route] === undefined) continue;
250
304
  const service = groups.get(group) ?? {};
251
- service[member] = (raw: unknown): Promise<unknown> => runOperation(route, raw);
305
+ // The envelope is built here and nowhere else on this path. A tab has
306
+ // no way to describe itself as anything other than the user, because
307
+ // nothing it sends is read when this is filled in.
308
+ service[member] = (raw: unknown): Promise<unknown> =>
309
+ runOperation(route, raw, {
310
+ requestId: crypto.randomUUID(),
311
+ channel: 'user',
312
+ caller: 'tab',
313
+ });
252
314
  groups.set(group, service);
253
315
  }
254
316
  for (const [group, service] of groups) bridge.expose(group, service);
@@ -257,7 +319,7 @@ export function createReservedHostApp<C extends AnyContract>(
257
319
  const spec = contract.streams[route];
258
320
  if (spec === undefined) continue;
259
321
  bridge.stream(route, (stream: BridgeStream, streamContext: StreamContext) =>
260
- runStream(stream, streamContext, route, spec, handler, running, logger),
322
+ runStream(stream, streamContext, route, spec, handler, running, logger, gate),
261
323
  );
262
324
  }
263
325
  },
@@ -276,7 +338,7 @@ export function createReservedHostApp<C extends AnyContract>(
276
338
  * accidentally undo the reduction by wrapping the message.
277
339
  */
278
340
  function wrap(cause: unknown, route: string, logger: HostLogger): unknown {
279
- if (cause instanceof PublicError) return cause.toBridgeError();
341
+ if (isPublicError(cause)) return cause.toBridgeError();
280
342
  logger.error(`[broapp] ${route} failed: ${String(cause instanceof Error ? cause.stack ?? cause.message : cause)}`);
281
343
  return cause;
282
344
  }
@@ -297,10 +359,15 @@ async function runStream<E>(
297
359
  stream: BridgeStream,
298
360
  streamContext: StreamContext,
299
361
  route: string,
300
- spec: { params: { parse(value: unknown): unknown }; event: { parse(value: unknown): unknown } },
362
+ spec: {
363
+ params: { parse(value: unknown): unknown };
364
+ event: { parse(value: unknown): unknown };
365
+ effect?: Effect;
366
+ },
301
367
  handler: (params: never, sink: StreamSink<E>) => void | Promise<void>,
302
368
  running: Set<AbortController>,
303
369
  logger: HostLogger,
370
+ gate: Gate,
304
371
  ): Promise<void> {
305
372
  const controller = new AbortController();
306
373
  running.add(controller);
@@ -319,7 +386,7 @@ async function runStream<E>(
319
386
  running.delete(controller);
320
387
  throw new PublicError(
321
388
  'invalid_input',
322
- cause instanceof ValidationError ? cause.message : 'invalid stream parameters',
389
+ isValidationError(cause) ? cause.message : 'invalid stream parameters',
323
390
  ).toBridgeError();
324
391
  }
325
392
 
@@ -333,7 +400,23 @@ async function runStream<E>(
333
400
  };
334
401
 
335
402
  try {
336
- await handler(params as never, sink);
403
+ // A stream is guarded once, when it starts. Its events are the one call's
404
+ // output, not a series of calls, so there is nothing further to decide
405
+ // once the handler is running.
406
+ await gate.guard(
407
+ {
408
+ requestId: crypto.randomUUID(),
409
+ channel: 'user',
410
+ caller: 'tab',
411
+ signal: controller.signal,
412
+ route,
413
+ effect: effectOf(spec),
414
+ input: params,
415
+ },
416
+ async () => {
417
+ await handler(params as never, sink);
418
+ },
419
+ );
337
420
  if (!controller.signal.aborted) await stream.end();
338
421
  } catch (cause) {
339
422
  // A cancelled stream is not a fault: the browser asked for it, the stream
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The table a waiting question and a later answer meet in.
3
+ *
4
+ * An approver has to ask somebody, and the somebody is at the other end of a
5
+ * route: a chat stream, a launcher tab, an MCP client's own UI. So the ask and
6
+ * the answer are two separate calls, and something has to hold the question in
7
+ * between. That is all this is — with one property that matters more than the
8
+ * bookkeeping.
9
+ *
10
+ * An answer must name the question it is answering. A `requestId` alone would
11
+ * let an answer meant for one call approve a different one that happened to be
12
+ * pending under the same identifier after a rebuild, or after a model changed
13
+ * its arguments between the question and the click. So an answer may carry the
14
+ * release and the arguments hash it believes it is approving, and a value that
15
+ * does not match the pending question is a denial, not a near miss.
16
+ */
17
+ import type { HostLogger } from './app.ts';
18
+ import type { ApprovalQuestion, Approver } from './gate.ts';
19
+
20
+ /** What the route that received a person's answer passes back in. */
21
+ export interface ApprovalAnswer {
22
+ readonly requestId: string;
23
+ readonly approved: boolean;
24
+ /** When given, must equal the pending question's value or the answer is a mismatch. */
25
+ readonly releaseId?: string;
26
+ readonly argumentsHash?: string;
27
+ }
28
+
29
+ /** What happened to an answer. */
30
+ export type AnswerResult = 'accepted' | 'unknown' | 'mismatch';
31
+
32
+ /** An {@link Approver} whose answers arrive later, over some other route. */
33
+ export interface PendingApprovals extends Approver {
34
+ /** Called by the route that receives the person's answer. */
35
+ answer(answer: ApprovalAnswer): AnswerResult;
36
+ /**
37
+ * Questions currently waiting, for a UI to show. `input` is shown as-is;
38
+ * adapters must not put secrets in inputs.
39
+ */
40
+ readonly pending: readonly ApprovalQuestion[];
41
+ }
42
+
43
+ /** One question, and the callback that settles it. */
44
+ interface Waiting {
45
+ readonly question: ApprovalQuestion;
46
+ settle(approved: boolean): void;
47
+ }
48
+
49
+ /** Build a pending-approval table. */
50
+ export function createPendingApprovals(logger?: HostLogger): PendingApprovals {
51
+ const log: HostLogger = logger ?? console;
52
+ const waiting = new Map<string, Waiting>();
53
+
54
+ return {
55
+ ask(question: ApprovalQuestion, signal: AbortSignal): Promise<boolean> {
56
+ // Two questions under one identifier would make an answer ambiguous, and
57
+ // an ambiguous approval is the one thing this table exists to prevent.
58
+ // The caller chose the identifier, so a collision is its bug.
59
+ if (waiting.has(question.requestId)) {
60
+ throw new TypeError(
61
+ `an approval for request ${JSON.stringify(question.requestId)} is already pending`,
62
+ );
63
+ }
64
+ return new Promise<boolean>((resolve) => {
65
+ let settled = false;
66
+ const finish = (approved: boolean): void => {
67
+ if (settled) return;
68
+ settled = true;
69
+ waiting.delete(question.requestId);
70
+ signal.removeEventListener('abort', onAbort);
71
+ resolve(approved);
72
+ };
73
+ // A question nobody answers is a denial, not a hung call: the person
74
+ // may have closed the tab, and nothing may run unattended.
75
+ const onAbort = (): void => finish(false);
76
+ signal.addEventListener('abort', onAbort, { once: true });
77
+ if (signal.aborted) finish(false);
78
+ else waiting.set(question.requestId, { question, settle: finish });
79
+ });
80
+ },
81
+
82
+ answer(answer: ApprovalAnswer): AnswerResult {
83
+ const entry = waiting.get(answer.requestId);
84
+ // Nobody waiting covers both a stale answer and a second answer for a
85
+ // question that has already been settled and removed.
86
+ if (entry === undefined) return 'unknown';
87
+
88
+ const wrong =
89
+ answer.releaseId !== undefined && answer.releaseId !== entry.question.releaseId
90
+ ? 'releaseId'
91
+ : answer.argumentsHash !== undefined &&
92
+ answer.argumentsHash !== entry.question.argumentsHash
93
+ ? 'argumentsHash'
94
+ : null;
95
+ if (wrong !== null) {
96
+ // An answer about a different release or different arguments is not
97
+ // this question's answer. It is treated as a denial rather than
98
+ // ignored, so the caller stops waiting instead of hanging until the
99
+ // deadline for a question that will never be answered correctly.
100
+ log.warn(
101
+ `[broapp] an approval for ${entry.question.route} named a different ${wrong} and was refused`,
102
+ );
103
+ entry.settle(false);
104
+ return 'mismatch';
105
+ }
106
+
107
+ entry.settle(answer.approved);
108
+ return 'accepted';
109
+ },
110
+
111
+ get pending(): readonly ApprovalQuestion[] {
112
+ return [...waiting.values()].map((entry) => entry.question);
113
+ },
114
+ };
115
+ }