@abide/abide 0.124.0 → 0.125.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/ABIDE.md +4 -0
  2. package/CHANGELOG.md +43 -0
  3. package/bin/abide.ts +3 -2
  4. package/docs/REGISTRY.md +67 -41
  5. package/docs/RULEBOOK.md +207 -20
  6. package/docs/abide.md +392 -68
  7. package/package.json +1 -1
  8. package/src/compiler/Lowering.ts +29 -4
  9. package/src/compiler/appMap.ts +14 -1
  10. package/src/compiler/declaredExports.ts +41 -0
  11. package/src/compiler/handlersUnder.ts +1 -0
  12. package/src/compiler/hookCalls.ts +1 -0
  13. package/src/compiler/projectShadow.ts +53 -2
  14. package/src/compiler/readsIn.ts +39 -34
  15. package/src/server/BRIDGE_VERSION.ts +1 -1
  16. package/src/server/PageRender.ts +7 -8
  17. package/src/server/abideModules.ts +6 -0
  18. package/src/server/answerRequest.ts +115 -52
  19. package/src/server/answerSocket.ts +6 -5
  20. package/src/server/answerWorker.ts +34 -23
  21. package/src/server/assistant.ts +141 -35
  22. package/src/server/banner.ts +9 -5
  23. package/src/server/bridgeRelay.ts +3 -3
  24. package/src/server/callerOf.ts +5 -0
  25. package/src/server/claudeCode.ts +20 -11
  26. package/src/server/commands.ts +14 -7
  27. package/src/server/createApp.ts +65 -12
  28. package/src/server/devWorker.ts +18 -16
  29. package/src/server/generatedSurfaces.ts +6 -5
  30. package/src/server/handlerFileMount.ts +2 -0
  31. package/src/server/hooks.ts +54 -8
  32. package/src/server/index.ts +18 -4
  33. package/src/server/invokeHandler.ts +1 -1
  34. package/src/server/mountHandlers.ts +6 -0
  35. package/src/server/principal.ts +3 -2
  36. package/src/server/render.ts +11 -2
  37. package/src/server/requestScope.ts +61 -1
  38. package/src/server/responseRelay.ts +9 -3
  39. package/src/server/serveDev.ts +18 -3
  40. package/src/server/sqliteStore.ts +65 -0
  41. package/src/server/testMount.ts +1 -0
  42. package/src/shared/AssistantEvent.ts +40 -0
  43. package/src/shared/BrowserHooks.ts +73 -0
  44. package/src/shared/CURRENT_ROUTER.ts +3 -1
  45. package/src/shared/Issues.ts +11 -3
  46. package/src/shared/Middleware.ts +9 -9
  47. package/src/shared/UNEXPECTED.ts +1 -1
  48. package/src/shared/answerOf.ts +9 -4
  49. package/src/shared/imagePiece.ts +85 -0
  50. package/src/shared/index.ts +17 -0
  51. package/src/shared/isServer.ts +15 -0
  52. package/src/shared/navigate.ts +10 -3
  53. package/src/shared/onError.ts +51 -0
  54. package/src/shared/onRefusal.ts +47 -0
  55. package/src/shared/online.ts +5 -1
  56. package/src/shared/reactive/Keyed.ts +12 -1
  57. package/src/shared/reactive/Reactive.ts +65 -26
  58. package/src/shared/reactive/cellShape.ts +4 -3
  59. package/src/shared/reactive/poll.ts +49 -0
  60. package/src/shared/reactive/scoped.ts +10 -1
  61. package/src/shared/reactive/scoping.ts +14 -0
  62. package/src/shared/reactive/watch.ts +19 -8
  63. package/src/shared/resultsOf.ts +21 -0
  64. package/src/shared/route.ts +18 -4
  65. package/src/shared/rpc.ts +112 -26
  66. package/src/shared/seededDocument.ts +5 -6
  67. package/src/shared/storedValue.ts +32 -0
  68. package/src/shared/webStore.ts +54 -0
  69. package/src/ui/Router.ts +42 -9
  70. package/src/ui/outboxQueue.ts +1 -1
  71. package/src/ui/wire.ts +4 -4
  72. package/src/shared/KEEP_OPENED.ts +0 -7
  73. /package/src/{server → shared}/IMAGE_TYPES.ts +0 -0
@@ -33,7 +33,12 @@ import { answerBridge } from './bridgeRelay.ts'
33
33
  import { compressAnswer } from './compressAnswer.ts'
34
34
  import { DOCUMENT_HEADERS } from './DOCUMENT_HEADERS.ts'
35
35
  import { answerMcp, openApi } from './generatedSurfaces.ts'
36
- import { failureOf, type LaneRung } from './hooks.ts'
36
+ import {
37
+ type AppContext,
38
+ failureOf,
39
+ type Lane,
40
+ type LaneRung,
41
+ } from './hooks.ts'
37
42
  import { type Mounted, mountedOwn } from './mountHandlers.ts'
38
43
  import { muxAdmission } from './muxUpgrade.ts'
39
44
  import { OFFLINE_STALE } from './OFFLINE_STALE.ts'
@@ -95,10 +100,7 @@ export type RouteRow = {
95
100
  * 16.14 — composed once with the table. Every server block is imported before it is built
96
101
  * (37.1), so these are the rungs the boot found, as the root's are.
97
102
  */
98
- answers: Map<
99
- Ranked | Mounted,
100
- (ctx: { request: Request }) => Promise<Response>
101
- >
103
+ answers: Map<Ranked | Mounted, (ctx: AppContext) => Promise<Response>>
102
104
  }
103
105
 
104
106
  /** A public file, read once at boot: where the bytes are, the etag off them, and its type. */
@@ -171,7 +173,7 @@ export async function answerRequest(
171
173
  // 20.5, 20.9, 36.7 — a surface of abide's own, or a 404 where it answers nothing.
172
174
  const surface = SURFACES.get(asked)
173
175
  if (surface !== undefined) {
174
- const answer = await surface(app, request)
176
+ const answer = await surface.answer(app, request)
175
177
  if (answer === undefined) return notFound()
176
178
  // 21.4 — an answer about the app's surfaces is never cached.
177
179
  return answerValue(
@@ -187,11 +189,7 @@ export async function answerRequest(
187
189
  // 37.3 — THE MUX'S UPGRADE PASSES THE APP LANE as every request does (`muxUpgrade`).
188
190
  const admitted = muxAdmission(asked, request, app)
189
191
  if (admitted !== undefined) return admitted
190
- // 16.70 — a private handler is answered by the route table, below abide's own namespace.
191
- if (
192
- asked.startsWith(`${ABIDE_ADDRESS}/`) &&
193
- !asked.startsWith(`${PRIVATE_HANDLERS_ADDRESS}/`)
194
- ) {
192
+ if (isFrameworkAddress(asked)) {
195
193
  const answer = app.framework(asked, request)
196
194
  if (answer === undefined) return notFound()
197
195
  return readsOnly(request) ? answer : methodRefused(app, asked, READS)
@@ -217,38 +215,7 @@ export async function answerRequest(
217
215
  refuse(400, 'The address is not a valid path.'),
218
216
  )
219
217
 
220
- // 19.20 — THE MOST SPECIFIC ROUTE ANSWERING THE METHOD, an `OPTIONS` asking about the method
221
- // its preflight names (16.24). A route that matches and answers some other method is held, so
222
- // an address nothing answers the method at is a 405 naming what is answered there (15.17).
223
- const preflight = request.method === 'OPTIONS'
224
- // `null` for an `OPTIONS` that names no method, which is no preflight (Fetch's CORS-preflight
225
- // request carries one) and so no handler's: the route answers 15.17's 405 with what it allows.
226
- const asking = preflight
227
- ? request.headers.get('access-control-request-method')
228
- : method
229
- let allowed: Set<string> | undefined
230
- let found:
231
- | { route: RouteRow; params: Params; handler: Mounted | undefined }
232
- | undefined
233
- for (const route of app.routes) {
234
- const params = matchOf(route.segments, parts)
235
- if (params === undefined) continue
236
- const handler =
237
- asking === null ? undefined : route.handlers.get(asking as Method)
238
- if (
239
- handler !== undefined ||
240
- (!preflight && asking === 'GET' && route.page !== undefined)
241
- ) {
242
- found = { route, params, handler }
243
- break
244
- }
245
- allowed ??= new Set()
246
- if (route.page !== undefined) allowed.add('GET').add('HEAD')
247
- for (const method of route.handlers.keys()) {
248
- allowed.add(method)
249
- if (method === 'GET') allowed.add('HEAD')
250
- }
251
- }
218
+ const { found, allowed } = routed(app, parts, request)
252
219
  if (found === undefined) {
253
220
  if (allowed !== undefined)
254
221
  return methodRefused(app, asked, [...allowed], request)
@@ -270,8 +237,90 @@ export async function answerRequest(
270
237
  scope.params = found.params
271
238
  const answer = found.route.answers.get(
272
239
  found.handler ?? (found.route.page as Ranked),
273
- ) as (ctx: { request: Request }) => Promise<Response>
274
- return answer({ request })
240
+ ) as (ctx: AppContext) => Promise<Response>
241
+ return answer({
242
+ request,
243
+ lane: found.handler === undefined ? 'page' : 'handler',
244
+ })
245
+ }
246
+
247
+ /**
248
+ * What `answerRequest` would answer `request` with, by the arm of its dispatch it takes. 37.19.
249
+ *
250
+ * ASKED IN THE DISPATCH'S OWN ORDER, over the same tables, so a gate reading it before routing
251
+ * (37.3) is told what routing will choose.
252
+ */
253
+ export function laneOf(app: AppSources, request: Request): Lane {
254
+ const asked = new URL(request.url).pathname
255
+ if (OWN_HANDLERS.has(asked)) return 'handler'
256
+ const surface = SURFACES.get(asked)
257
+ if (surface !== undefined) return surface.lane
258
+ if (muxAdmission(asked, request, app) !== undefined) return 'socket'
259
+ if (isFrameworkAddress(asked)) return 'abide'
260
+ if (app.published.has(decodedOrKept(asked))) return 'file'
261
+ const parts = partsOf(asked)
262
+ if (parts === undefined) return 'page'
263
+ return routed(app, parts, request).found?.handler === undefined
264
+ ? 'page'
265
+ : 'handler'
266
+ }
267
+
268
+ /**
269
+ * Whether `asked` is an address abide answers out of the command's own files.
270
+ *
271
+ * 16.70 — a private handler is answered by the route table, below abide's own namespace.
272
+ */
273
+ function isFrameworkAddress(asked: string): boolean {
274
+ return (
275
+ asked.startsWith(`${ABIDE_ADDRESS}/`) &&
276
+ !asked.startsWith(`${PRIVATE_HANDLERS_ADDRESS}/`)
277
+ )
278
+ }
279
+
280
+ /**
281
+ * The route answering `request` at `parts`, or the methods answered there where none answers its own.
282
+ *
283
+ * 19.20 — THE MOST SPECIFIC ROUTE ANSWERING THE METHOD, an `OPTIONS` asking about the method
284
+ * its preflight names (16.24). A route that matches and answers some other method is held, so
285
+ * an address nothing answers the method at is a 405 naming what is answered there (15.17).
286
+ */
287
+ function routed(
288
+ app: AppSources,
289
+ parts: string[],
290
+ request: Request,
291
+ ): {
292
+ found:
293
+ | { route: RouteRow; params: Params; handler: Mounted | undefined }
294
+ | undefined
295
+ allowed: Set<string> | undefined
296
+ } {
297
+ const preflight = request.method === 'OPTIONS'
298
+ // `null` for an `OPTIONS` that names no method, which is no preflight (Fetch's CORS-preflight
299
+ // request carries one) and so no handler's: the route answers 15.17's 405 with what it allows.
300
+ const asking = preflight
301
+ ? request.headers.get('access-control-request-method')
302
+ : request.method === 'HEAD'
303
+ ? 'GET'
304
+ : request.method
305
+ let allowed: Set<string> | undefined
306
+ for (const route of app.routes) {
307
+ const params = matchOf(route.segments, parts)
308
+ if (params === undefined) continue
309
+ const handler =
310
+ asking === null ? undefined : route.handlers.get(asking as Method)
311
+ if (
312
+ handler !== undefined ||
313
+ (!preflight && asking === 'GET' && route.page !== undefined)
314
+ )
315
+ return { found: { route, params, handler }, allowed: undefined }
316
+ allowed ??= new Set()
317
+ if (route.page !== undefined) allowed.add('GET').add('HEAD')
318
+ for (const method of route.handlers.keys()) {
319
+ allowed.add(method)
320
+ if (method === 'GET') allowed.add('HEAD')
321
+ }
322
+ }
323
+ return { found: undefined, allowed }
275
324
  }
276
325
 
277
326
  /**
@@ -332,7 +381,8 @@ async function answerMatched(
332
381
  // would otherwise render one address and hydrate against another.
333
382
  const at = new URL(request.url)
334
383
  at.pathname = mounted(at.pathname)
335
- arrive(at.href, page.pattern, params)
384
+ // 23.29 — a request is a document load, which arrives at an entry of its own.
385
+ arrive(at.href, page.pattern, params, 0)
336
386
  // 11.32 — the page renders in a scope of its own, so what its values hold is this request's —
337
387
  // and 41.13, every call it makes is recorded into its render.
338
388
  beginPageRender(request)
@@ -369,18 +419,31 @@ const OWN_HANDLERS = new Map([
369
419
 
370
420
  // THE ADDRESSES ABIDE ANSWERS ITSELF, looked up once rather than asked in turn: each to its value,
371
421
  // a `Response` of its own, or `undefined` for a 404.
422
+ // 37.19 — each with the lane a gate is told it is.
372
423
  const SURFACES = new Map<
373
424
  string,
374
- (app: AppSources, request: Request) => unknown
425
+ {
426
+ lane: Lane
427
+ answer: (app: AppSources, request: Request) => unknown
428
+ }
375
429
  >([
376
- [OPENAPI_ADDRESS, (app) => openApi(app.handlers)],
430
+ [
431
+ OPENAPI_ADDRESS,
432
+ { lane: 'openapi', answer: (app) => openApi(app.handlers) },
433
+ ],
377
434
  // Filled by the app answering this request, so never `undefined` here.
378
435
  [
379
436
  MCP_ADDRESS,
380
- (_app, request) =>
381
- answerMcp((within('mcp').app as AnsweringApp).mcp, request),
437
+ {
438
+ lane: 'mcp',
439
+ answer: (_app, request) =>
440
+ answerMcp((within('mcp').app as AnsweringApp).mcp, request),
441
+ },
442
+ ],
443
+ [
444
+ BRIDGE_ADDRESS,
445
+ { lane: 'bridge', answer: (_app, request) => answerBridge(request) },
382
446
  ],
383
- [BRIDGE_ADDRESS, (_app, request) => answerBridge(request)],
384
447
  ])
385
448
 
386
449
  /**
@@ -395,7 +458,7 @@ export function answerInside(
395
458
  app: AppSources,
396
459
  found: Ranked | Mounted,
397
460
  rungs: LaneRung[],
398
- ): (ctx: { request: Request }) => Promise<Response> {
461
+ ): (ctx: AppContext) => Promise<Response> {
399
462
  return compose(rungs, ({ request }) => answerMatched(app, request, found))
400
463
  }
401
464
 
@@ -26,6 +26,7 @@ import type { FollowedHandler } from './mountHandlers.ts'
26
26
  import { sendOrClose } from './muxUpgrade.ts'
27
27
  import {
28
28
  inOpenScope,
29
+ RECEIVED_FROM,
29
30
  releaseScope,
30
31
  type RequestScope,
31
32
  scopeFor,
@@ -139,11 +140,11 @@ export class MuxConnection {
139
140
  upgrade.url,
140
141
  )
141
142
  // NO `received`: an upgraded connection has no http idle timeout for 16.23 to set.
142
- this.scope = scopeFor(
143
- new Request(upgrade, { signal: this.hangUp.signal }),
144
- undefined,
145
- mux.app,
146
- )
143
+ const request = new Request(upgrade, { signal: this.hangUp.signal })
144
+ // 22.12 — every frame came from where the upgrade did.
145
+ if (RECEIVED_FROM.has(upgrade))
146
+ RECEIVED_FROM.set(request, RECEIVED_FROM.get(upgrade) ?? null)
147
+ this.scope = scopeFor(request, undefined, mux.app)
147
148
  // 22.10 — the headers went out with the upgrade, so nothing a frame writes is carried.
148
149
  this.scope.applied = true
149
150
  }
@@ -7,7 +7,6 @@
7
7
 
8
8
  import { CLIENT_ADDRESS } from '#shared/ADDRESSES.ts'
9
9
  import { KEEP_HEADER } from '#shared/KEEP_HEADER.ts'
10
- import { KEEP_OPENED } from '#shared/KEEP_OPENED.ts'
11
10
  import { KEPT_HEADER } from '#shared/KEPT_HEADER.ts'
12
11
  import { MOUNT, mounted } from '#shared/mount.ts'
13
12
  import { NAVIGATION_HEADER } from '#shared/NAVIGATION_HEADER.ts'
@@ -42,7 +41,7 @@ async function dropOurs(keeping) {
42
41
  }
43
42
  `
44
43
 
45
- // 45.3 through 45.8, 45.13, 45.17 through 45.21, and 45.24.
44
+ // 45.3 through 45.8, 45.13, and 45.17 through 45.26.
46
45
  const SAVING = `
47
46
  self.addEventListener('install', (event) => {
48
47
  event.waitUntil(
@@ -149,31 +148,33 @@ function marked(saved) {
149
148
  })
150
149
  }
151
150
 
152
- // A page as the worker asks for it, whether a navigation or the worker keeping an open window's:
153
- // past the browser's cache, as a page kept, and with its redirects left to the page — one followed
154
- // would be saved under the address it was asked at, holding where it led (RULEBOOK 45.4, 45.21,
155
- // 45.23).
156
- function pageRequest(url, headers, keep) {
151
+ // A page no navigation is asking for, as the worker asks for it: past the browser's cache, as a page
152
+ // kept, and with its redirects left unfollowed — one followed would be saved under the address it was
153
+ // asked at, holding where it led (RULEBOOK 45.4, 45.21, 45.23).
154
+ function pageRequest(url, headers) {
157
155
  const asking = new Headers(headers)
158
- asking.set(KEEP, keep)
156
+ asking.set(KEEP, '1')
159
157
  return new Request(url, { headers: asking, redirect: 'manual', cache: 'no-cache' })
160
158
  }
161
159
 
162
- // 45.21 — PAST THE BROWSER'S OWN CACHE, which holds the same answers under the same headers and would
163
- // hand a revalidation the copy it is replacing. A navigation's request takes no init of its own, so it
164
- // is asked again by address, its redirects left to the page as a navigation's are — and 45.23, as a
165
- // page kept, which a script's fetch is not otherwise told from (RULEBOOK 41.29, 41.30), and as the
166
- // page the navigation opens where opened says it is, which then hydrates from what it seeded.
160
+ // 45.25 — THE NAVIGATION ASKED AS ITSELF: any init rebuilds a navigation's request as a script's
161
+ // fetch, and a proxy in front of the app tells the two apart — one sending a page load to sign-in
162
+ // answers a fetch 401. As itself its mode is navigate, the page that hydrates (RULEBOOK 41.29), its redirects
163
+ // left to the browser. Anything else is asked past the browser's own cache (RULEBOOK 45.21), which
164
+ // holds the same answers under the same headers and would hand a revalidation the copy it is replacing
165
+ // — and a page as one kept, which a script's fetch is not otherwise told from (RULEBOOK 45.23, 41.30).
167
166
  async function fromNetwork(event, page, opened) {
168
167
  const request = event.request
169
- const asked = page
170
- ? pageRequest(request.url, request.headers, opened ? OPENED : '1')
171
- : new Request(request, { cache: 'no-cache' })
168
+ const asked = opened
169
+ ? request
170
+ : page
171
+ ? pageRequest(request.url, request.headers)
172
+ : new Request(request, { cache: 'no-cache' })
172
173
  const since = drops
173
174
  const response = await fetch(asked)
174
175
  // A copy nothing reads is a tee holding every chunk, so one is cloned only to be saved.
175
176
  const copy = kept(response) ? response.clone() : undefined
176
- event.waitUntil(keep(request, copy, since, response.headers.has(PRINCIPAL)))
177
+ event.waitUntil(keep(request, response, copy, since))
177
178
  return response
178
179
  }
179
180
 
@@ -181,11 +182,11 @@ async function fromNetwork(event, page, opened) {
181
182
  // window's as the worker activates, and an address the router reached.
182
183
  function keepPage(url) {
183
184
  const since = drops
184
- return fetch(pageRequest(url, undefined, '1')).then((response) => {
185
+ return fetch(pageRequest(url)).then((response) => {
185
186
  // Nothing reads this answer, so one 45.3 or 45.4 turns away is let go here.
186
187
  const copy = kept(response) ? response : undefined
187
188
  if (copy === undefined) response.body?.cancel()
188
- return keep(new Request(url), copy, since, response.headers.has(PRINCIPAL))
189
+ return keep(new Request(url), response, copy, since)
189
190
  }, () => {})
190
191
  }
191
192
 
@@ -273,12 +274,23 @@ function dropSaved() {
273
274
  // Saves a copy 45.3 and 45.4 let through where no drop has come since its request was asked — after
274
275
  // the drop its own answer makes where that answer rotated the principal — and cancels one where one has.
275
276
  // One any caller may be answered is saved whenever it was asked, since no caller's cookies made it.
276
- async function keep(request, copy, since, rotated) {
277
- if (rotated) {
277
+ async function keep(request, response, copy, since) {
278
+ if (response.headers.has(PRINCIPAL)) {
278
279
  await dropSaved()
279
280
  since = drops
280
281
  }
281
- if (copy === undefined) return
282
+ // 45.26 — AN ANSWER NOT SAVED STILL REPLACES THE COPY: a redirect or a 401 is what the address
283
+ // answers now, and a copy left standing would answer every load in its stale-while-revalidate
284
+ // window in its place — a session lapsed behind a sign-in proxy never reaching the sign-in. A
285
+ // server error is no answer to replace a copy with.
286
+ if (copy === undefined) {
287
+ if (response.status >= 500) return
288
+ await Promise.all([
289
+ caches.open(SAVED).then((cache) => cache.delete(request)),
290
+ caches.open(SHARED).then((cache) => cache.delete(request)),
291
+ ])
292
+ return
293
+ }
282
294
  const shared = directivesOf(copy).has('public')
283
295
  if (!shared && since !== drops) return copy.body?.cancel()
284
296
  // An address whose answer went public or private since leaves no copy behind for savedCopy to
@@ -338,7 +350,6 @@ export function answerWorker(manifest: Manifest | undefined): Response {
338
350
  script = `${CACHES}const PRINCIPAL = ${JSON.stringify(PRINCIPAL_HEADER)}
339
351
  const KEPT = ${JSON.stringify(KEPT_HEADER)}
340
352
  const KEEP = ${JSON.stringify(KEEP_HEADER)}
341
- const OPENED = ${JSON.stringify(KEEP_OPENED)}
342
353
  const NAVIGATION = ${JSON.stringify(NAVIGATION_HEADER)}
343
354
  const REACHED = ${JSON.stringify(REACHED_MESSAGE)}
344
355
  // 45.4 — the types a stream 16.32 frames, which has no end to save.
@@ -10,35 +10,28 @@
10
10
  // another request's scope — `claudeCode` answers one from the bridge's — and the call is still this
11
11
  // asker's.
12
12
 
13
+ import type {
14
+ AssistantContent,
15
+ AssistantEvent,
16
+ AssistantMessage,
17
+ } from '#shared/AssistantEvent.ts'
18
+ import { IMAGE_TYPES } from '#shared/IMAGE_TYPES.ts'
13
19
  import { namedLogger } from '#shared/log.ts'
14
- import { refuse } from '#shared/refuse.ts'
20
+ import { mounted } from '#shared/mount.ts'
21
+ import { aborted, refuse } from '#shared/refuse.ts'
15
22
 
16
23
  import { type McpReply, McpSurface } from './generatedSurfaces.ts'
17
- import { IMAGE_TYPES } from './IMAGE_TYPES.ts'
18
24
  import { request, within } from './requestScope.ts'
25
+ import type { Listed } from './surfaceOf.ts'
19
26
 
20
- /** A piece of a turn: words, or an image as base64 with its type. */
21
- export type AssistantContent =
22
- | { type: 'text'; text: string }
23
- | { type: 'image'; data: string; mimeType: string }
24
-
25
- /** One turn of the transcript a caller sends: its words, or its pieces in order. */
26
- export type AssistantMessage = {
27
- role: 'user' | 'assistant'
28
- content: string | AssistantContent[]
29
- }
30
-
31
- /** What a conversation yields, the same from every provider. */
32
- export type AssistantEvent =
33
- | { type: 'text'; text: string }
27
+ /**
28
+ * An `AssistantEvent` as a `Provider` yields it: a tool call named, and its result in words, with what
29
+ * `assistant` reads off the app's own surface for both — the handler, and the value — still to come.
30
+ */
31
+ export type ProviderEvent =
32
+ | Exclude<AssistantEvent, { type: 'tool' | 'result' }>
34
33
  | { type: 'tool'; id: string; name: string }
35
- | { type: 'input'; id: string; input: unknown }
36
34
  | { type: 'result'; id: string; text: string; failed: boolean }
37
- | {
38
- type: 'done'
39
- stop: string
40
- usage: { input: number; output: number }
41
- }
42
35
 
43
36
  /** A tool as the app's MCP surface lists it. */
44
37
  export type ToolDefinition = {
@@ -68,11 +61,15 @@ export type Provider = {
68
61
  messages: (AssistantMessage & { content: AssistantContent[] })[],
69
62
  tools: Toolbox,
70
63
  limits: { maxTurns: number; signal: AbortSignal },
71
- ): AsyncGenerator<AssistantEvent>
64
+ ): AsyncGenerator<ProviderEvent>
72
65
  }
73
66
 
74
67
  /** What `assistant` takes beside a provider, the same for every one. */
75
- export type AssistantOptions = { mcp?: boolean; maxTurns?: number }
68
+ export type AssistantOptions = {
69
+ mcp?: boolean
70
+ maxTurns?: number
71
+ signal?: AbortSignal
72
+ }
76
73
 
77
74
  const MAX_TURNS = 10
78
75
  const ASSISTANT_LOG = namedLogger('abide:assistant')
@@ -118,19 +115,128 @@ export async function* assistant(
118
115
  )
119
116
  }
120
117
  }
121
- const tools = toolboxOver(
122
- options.mcp === true ? appSurface() : NO_SURFACE,
123
- askerOf(request()),
124
- )
125
- const signal = request().signal
126
- const events = provider.run(turns, tools, {
127
- maxTurns: options.maxTurns ?? MAX_TURNS,
118
+ const surface = options.mcp === true ? appSurface() : NO_SURFACE
119
+ const tools = toolboxOver(surface, askerOf(request()))
120
+ // 44.57 — the caller's signal and the request's are one: either ends the conversation.
121
+ const signal =
122
+ options.signal === undefined
123
+ ? request().signal
124
+ : AbortSignal.any([request().signal, options.signal])
125
+ const events = endedBy(
126
+ provider.run(turns, tools, {
127
+ maxTurns: options.maxTurns ?? MAX_TURNS,
128
+ signal,
129
+ }),
128
130
  signal,
129
- })
130
- // 28.7 — a closed namespace runs the provider's stream untouched.
131
+ )
132
+ // 28.7 — a closed namespace pays for no line.
131
133
  yield* ASSISTANT_LOG.enabled()
132
- ? traced(events, messages.length, signal)
133
- : events
134
+ ? traced(read(events, surface), messages.length, signal)
135
+ : read(events, surface)
136
+ }
137
+
138
+ /**
139
+ * `events` until `signal` aborts, then `aborted`. 44.57.
140
+ *
141
+ * THE ABORT DOES NOT WAIT FOR THE PROVIDER: one stuck in an await it was handed no signal for — a
142
+ * bridge that never answers — is asked to return and left to, and the conversation ends now. An
143
+ * abort read ahead of a throw, as `traced` reads it: a provider that throws as it unwinds was ended
144
+ * by the abort.
145
+ */
146
+ async function* endedBy(
147
+ events: AsyncGenerator<ProviderEvent>,
148
+ signal: AbortSignal,
149
+ ): AsyncGenerator<ProviderEvent> {
150
+ // A STEP RACES ITS OWN ABORT: one promise pending for the whole conversation would keep a
151
+ // reaction per event, none freed until it ends.
152
+ let rejectStep: (reason: unknown) => void = () => {}
153
+ const stop = (): void => rejectStep(aborted())
154
+ signal.addEventListener('abort', stop, { once: true })
155
+ try {
156
+ for (;;) {
157
+ if (signal.aborted) throw aborted()
158
+ const step = await Promise.race([
159
+ events.next(),
160
+ new Promise<never>((_, reject) => {
161
+ rejectStep = reject
162
+ }),
163
+ ])
164
+ if (step.done === true) return
165
+ yield step.value
166
+ }
167
+ } catch (thrown) {
168
+ throw signal.aborted ? aborted() : thrown
169
+ } finally {
170
+ signal.removeEventListener('abort', stop)
171
+ // A provider still awaiting returns once that await settles, and nobody waits for it.
172
+ events.return(undefined).catch(() => {})
173
+ }
174
+ }
175
+
176
+ /** A called tool's handler, and whether its answer is a value read as JSON (19.21). */
177
+ type Called = {
178
+ handler: { method: Listed['method']; address: string } | undefined
179
+ valued: boolean
180
+ }
181
+
182
+ const NOT_THE_APPS: Called = { handler: undefined, valued: false }
183
+
184
+ /**
185
+ * `events` with each call's handler, and each answer's value, read off `surface`. 44.48, 44.49.
186
+ *
187
+ * READ HERE RATHER THAN BY EACH PROVIDER, so a provider the app wrote yields the same events abide's
188
+ * do (44.2): a provider names the tool as the `Toolbox` lists it (44.50), and the rest is the surface's.
189
+ */
190
+ async function* read(
191
+ events: AsyncGenerator<ProviderEvent>,
192
+ surface: McpSurface,
193
+ ): AsyncGenerator<AssistantEvent> {
194
+ const calls = new Map<string, Called>()
195
+ for await (const event of events) {
196
+ if (event.type === 'tool') {
197
+ const tool = surface.tool(event.name)
198
+ const called: Called =
199
+ tool === undefined
200
+ ? NOT_THE_APPS
201
+ : {
202
+ // 16.10 — the address an `Rpc` carries, under the mount.
203
+ handler: {
204
+ method: tool.method,
205
+ address: mounted(tool.address),
206
+ },
207
+ valued: tool.result !== undefined,
208
+ }
209
+ calls.set(event.id, called)
210
+ yield {
211
+ type: 'tool',
212
+ id: event.id,
213
+ name: event.name,
214
+ handler: called.handler,
215
+ }
216
+ } else if (event.type === 'result') {
217
+ const called = calls.get(event.id) ?? NOT_THE_APPS
218
+ yield {
219
+ type: 'result',
220
+ id: event.id,
221
+ text: event.text,
222
+ failed: event.failed,
223
+ handler: called.handler,
224
+ value:
225
+ called.valued && !event.failed
226
+ ? valueOf(event.text)
227
+ : undefined,
228
+ }
229
+ } else yield event
230
+ }
231
+ }
232
+
233
+ /** An answer's words as the JSON value they spell, or `undefined` where they spell none. */
234
+ function valueOf(text: string): unknown {
235
+ try {
236
+ return JSON.parse(text)
237
+ } catch {
238
+ return undefined
239
+ }
134
240
  }
135
241
 
136
242
  /**
@@ -9,15 +9,16 @@ import type { AppPackage } from './config.ts'
9
9
  * What `start` and `dev` print once the app is listening: the app, and every address it answers at.
10
10
  *
11
11
  * THE LOCAL ADDRESS IS THE FIRST URL PRINTED, because a harness reading the output takes the first
12
- * `http://` it finds (`addressOf` in the tests). The network rows follow it unconditionally: neither
13
- * `listen` nor `serveDev` names a hostname, and `Bun.serve` given none binds every interface —
14
- * whatever `url` says.
12
+ * `http://` it finds (`addressOf` in the tests). 38.58, 38.136 — the network rows follow it where the
13
+ * listener binds every interface, which `Bun.serve` given no `hostname` does whatever `url` says, and
14
+ * are left out where `hostname` names one interface.
15
15
  */
16
16
  export function banner(
17
17
  host: string,
18
18
  command: string,
19
19
  identity: AppPackage,
20
20
  server: Bun.Server<unknown>,
21
+ hostname: string | undefined,
21
22
  ): string {
22
23
  const color = useColor()
23
24
  const paint = (code: string, text: string): string =>
@@ -29,8 +30,11 @@ export function banner(
29
30
  ? ''
30
31
  : ` ${paint('2', `${identity.name}${identity.version === undefined ? '' : ` ${identity.version}`}`)}`
31
32
  let text = `\n ${title}${named}\n\n ${paint('32', '➜')} ${paint('1', 'Local:')} ${paint('36', local)}\n`
32
- for (const address of networkAddresses())
33
- text += ` ${paint('32', '➜')} ${paint('1', 'Network:')} ${paint('36', `${server.url.protocol}//${address}:${server.port}/`)}\n`
33
+ const everyInterface =
34
+ hostname === undefined || hostname === '0.0.0.0' || hostname === '::'
35
+ if (everyInterface)
36
+ for (const address of networkAddresses())
37
+ text += ` ${paint('32', '➜')} ${paint('1', 'Network:')} ${paint('36', `${server.url.protocol}//${address}:${server.port}/`)}\n`
34
38
  return text
35
39
  }
36
40
 
@@ -22,7 +22,7 @@ import { refuse } from '#shared/refuse.ts'
22
22
  import { answered } from './answerFailure.ts'
23
23
  import { bodyWithin } from './answerRpc.ts'
24
24
  import { framed } from './answerValue.ts'
25
- import type { AssistantEvent } from './assistant.ts'
25
+ import type { ProviderEvent } from './assistant.ts'
26
26
  import { Inbox } from './Inbox.ts'
27
27
  import { randomToken } from './randomToken.ts'
28
28
  import { RESUME_MILLISECONDS } from './RESUME_MILLISECONDS.ts'
@@ -126,7 +126,7 @@ export async function* runTurn({
126
126
  /** Never rejects: its failure is a reply too. */
127
127
  answer: (method: string, params: unknown) => Promise<unknown>
128
128
  signal: AbortSignal
129
- }): AsyncGenerator<AssistantEvent> {
129
+ }): AsyncGenerator<ProviderEvent> {
130
130
  const bridge = BY_KEY.get(pairingKey(caller, signedIn))
131
131
  // 44.13, 44.33 — nothing paired by this sign-in is the page's cue to show the command.
132
132
  if (bridge === undefined)
@@ -146,7 +146,7 @@ export async function* runTurn({
146
146
  signal.addEventListener('abort', cancel)
147
147
  try {
148
148
  for await (const item of inbox) {
149
- if (item.kind === 'event') yield item.event as AssistantEvent
149
+ if (item.kind === 'event') yield item.event as ProviderEvent
150
150
  else if (item.kind === 'mcp')
151
151
  void answer(item.method, item.params).then((reply) => {
152
152
  bridge.frames.push({
@@ -46,6 +46,11 @@ export function callerOrMint(): string {
46
46
  return callerOf() ?? mintCaller(scope)
47
47
  }
48
48
 
49
+ /** This request's caller, given rather than read off its cookie, with no cookie written. 42.21. */
50
+ export function presumeCaller(caller: string): void {
51
+ within('App caller').caller = caller
52
+ }
53
+
49
54
  /**
50
55
  * Mints a fresh caller for this request, sets its cookie, and returns it.
51
56
  *