@abide/abide 0.105.0 → 0.106.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 (100) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/bin/abide.ts +4 -1
  3. package/docs/REGISTRY.md +156 -44
  4. package/docs/RULEBOOK.md +375 -87
  5. package/docs/abide.md +7243 -10256
  6. package/package.json +4 -1
  7. package/scaffold/AGENTS.md +2 -0
  8. package/scaffold/src/getGreeting.ts +6 -0
  9. package/scaffold/src/index.abide +3 -1
  10. package/scaffold/src/tests/getGreeting.test.ts +6 -0
  11. package/scaffold/src/tests/index.test.ts +18 -0
  12. package/src/compiler/DOCS_SITE.ts +2 -0
  13. package/src/compiler/Lowering.ts +234 -14
  14. package/src/compiler/PositionTable.ts +14 -7
  15. package/src/compiler/PositionedRefusal.ts +13 -0
  16. package/src/compiler/Refusals.ts +18 -11
  17. package/src/compiler/SHARED_NAMES.ts +2 -0
  18. package/src/compiler/ShadowMap.ts +37 -0
  19. package/src/compiler/TEMPLATE_BLOCKS.ts +12 -0
  20. package/src/compiler/TypeSession.ts +410 -31
  21. package/src/compiler/appLayout.ts +18 -2
  22. package/src/compiler/argsSchemaOf.ts +55 -9
  23. package/src/compiler/checkApp.ts +33 -0
  24. package/src/compiler/checkedShadow.ts +36 -22
  25. package/src/compiler/citedClauses.ts +14 -0
  26. package/src/compiler/classifyRead.ts +6 -0
  27. package/src/compiler/classifyTemplate.ts +2 -1
  28. package/src/compiler/compileApp.ts +2 -2
  29. package/src/compiler/declaredExports.ts +27 -6
  30. package/src/compiler/emitClient.ts +304 -103
  31. package/src/compiler/handlersUnder.ts +4 -0
  32. package/src/compiler/invocationProps.ts +1 -1
  33. package/src/compiler/isIgnored.ts +27 -0
  34. package/src/compiler/parseAbide.ts +176 -76
  35. package/src/compiler/projectShadow.ts +30 -6
  36. package/src/compiler/readSources.ts +1 -1
  37. package/src/compiler/readsIn.ts +100 -1
  38. package/src/compiler/renderMarkup.ts +36 -5
  39. package/src/compiler/scaffold.ts +19 -6
  40. package/src/compiler/serveLanguage.ts +156 -21
  41. package/src/compiler/templateContext.ts +150 -0
  42. package/src/compiler/tokenize.ts +6 -0
  43. package/src/server/BRIDGE_VERSION.ts +1 -1
  44. package/src/server/Inbox.ts +8 -0
  45. package/src/server/MCP_PROTOCOL_VERSION.ts +1 -1
  46. package/src/server/MOUNTABLE.ts +6 -0
  47. package/src/server/abideModules.ts +8 -1
  48. package/src/server/answerRequest.ts +3 -1
  49. package/src/server/answerRpc.ts +26 -12
  50. package/src/server/answerSocket.ts +26 -1
  51. package/src/server/argsFor.ts +4 -3
  52. package/src/server/assistant.ts +19 -13
  53. package/src/server/bridgeRelay.ts +108 -20
  54. package/src/server/claudeApi.ts +7 -7
  55. package/src/server/claudeCode.ts +11 -11
  56. package/src/server/commands.ts +73 -9
  57. package/src/server/createApp.ts +41 -7
  58. package/src/server/devWorker.ts +3 -3
  59. package/src/server/generatedSurfaces.ts +37 -4
  60. package/src/server/muxUpgrade.ts +47 -0
  61. package/src/server/preload.ts +13 -2
  62. package/src/server/render.ts +4 -1
  63. package/src/server/runCompiled.ts +1 -0
  64. package/src/server/seedBuffer.ts +31 -1
  65. package/src/server/serveDev.ts +83 -72
  66. package/src/server/surfaceOf.ts +3 -0
  67. package/src/server/testMount.ts +53 -0
  68. package/src/shared/ADDRESSES.ts +1 -1
  69. package/src/shared/ERROR_META.ts +3 -0
  70. package/src/shared/Schema.ts +1 -1
  71. package/src/shared/WORK.ts +14 -12
  72. package/src/shared/applyPatch.ts +1 -1
  73. package/src/shared/boundValue.ts +81 -0
  74. package/src/shared/index.ts +7 -2
  75. package/src/shared/jsonSchema.ts +25 -2
  76. package/src/shared/log.ts +11 -1
  77. package/src/shared/reactive/Keyed.ts +30 -6
  78. package/src/shared/reactive/PATCHES.ts +1 -1
  79. package/src/shared/reactive/Reactive.ts +118 -60
  80. package/src/shared/reactive/canonicalKey.ts +1 -1
  81. package/src/shared/reactive/index.ts +13 -5
  82. package/src/shared/reactive/recordWrites.ts +1 -1
  83. package/src/shared/reactive/watch.ts +26 -4
  84. package/src/shared/rpc.ts +6 -1
  85. package/src/shared/url.ts +7 -3
  86. package/src/shared/validateJson.ts +1 -1
  87. package/src/ui/ArmRegion.ts +2 -2
  88. package/src/ui/List.ts +9 -9
  89. package/src/ui/RawRun.ts +1 -1
  90. package/src/ui/Router.ts +194 -36
  91. package/src/ui/Scope.ts +29 -0
  92. package/src/ui/adoptWalk.ts +3 -3
  93. package/src/ui/applyHead.ts +25 -3
  94. package/src/ui/bindProperty.ts +70 -22
  95. package/src/ui/index.ts +1 -1
  96. package/src/ui/leave.ts +5 -0
  97. package/src/ui/listenForRoutes.ts +14 -3
  98. package/src/ui/reconcile.ts +48 -28
  99. package/src/ui/regions.ts +36 -4
  100. package/src/ui/rows.ts +108 -28
@@ -99,6 +99,8 @@ export type Mount = {
99
99
  defaults: Defaults | undefined
100
100
  /** The JSON Schema the handler's args annotation derives, where it takes a record. 19.9. */
101
101
  args: JsonValue | undefined
102
+ /** The JSON Schema of what it answers, where its type says one. 19.21. */
103
+ result: JsonValue | undefined
102
104
  }
103
105
 
104
106
  /**
@@ -166,6 +168,7 @@ export function mountedOwn(
166
168
  copy: undefined,
167
169
  defaults: undefined,
168
170
  args: undefined,
171
+ result: undefined,
169
172
  handler: handler as Mounted['handler'],
170
173
  argsDocument: undefined,
171
174
  wireArgs: undefined,
@@ -250,14 +253,7 @@ export async function answerRpc(
250
253
  // 16.13 — A MUTATION FROM ANOTHER ORIGIN IS REFUSED unless the handler declared it may come.
251
254
  if (closed && !inUrl)
252
255
  return answered(crossingRefusal(crossing, found.method))
253
- // 16.54 — THE BODY CEILING, none where neither the handler nor the environment names one; 16.55 —
254
- // read off the declared length before a byte of the body is.
255
- const ceiling =
256
- handler.maxBodySize ??
257
- positiveSetting('ABIDE_MAX_REQUEST_BODY_SIZE', Number.POSITIVE_INFINITY)
258
- if (!inUrl && Number(request.headers.get('content-length') ?? 0) > ceiling)
259
- return answered(overCeiling(ceiling))
260
- const raw = await argsOf(request, url, inUrl, form, ceiling)
256
+ const raw = await argsOf(request, url, inUrl, form, handler.maxBodySize)
261
257
  if (isFailed(raw)) return answered(raw)
262
258
  const sent = withParameters(raw, params, inUrl || form, found)
263
259
  if (isFailed(sent)) return answered(sent)
@@ -442,16 +438,14 @@ async function argsOf(
442
438
  url: URL,
443
439
  inUrl: boolean,
444
440
  form: boolean,
445
- ceiling: number,
441
+ maxBodySize: number | undefined,
446
442
  ): Promise<unknown | Failed<'HttpError', undefined>> {
447
443
  if (inUrl) {
448
444
  const query = url.searchParams
449
445
  if (query.size === 0) return undefined
450
446
  return collapsed(query)
451
447
  }
452
- const body = Number.isFinite(ceiling)
453
- ? await bodyUnder(request, ceiling)
454
- : request
448
+ const body = await bodyWithin(request, maxBodySize)
455
449
  if (isFailed(body)) return body
456
450
  // 16.38 — a form's fields, a field given more than once a list, as a query's are.
457
451
  if (form) {
@@ -504,6 +498,26 @@ function withParameters(
504
498
  return isRecord(sent) ? { ...sent, ...fromPath } : { ...fromPath }
505
499
  }
506
500
 
501
+ /**
502
+ * The request's body under the ceiling `maxBodySize` sets, or `ABIDE_MAX_REQUEST_BODY_SIZE` where it
503
+ * is unset, or a 413 refusal past it — the request itself where neither names one.
504
+ *
505
+ * 16.54 — NO CEILING BY DEFAULT. 16.55 — read off the declared length before a byte of the body is.
506
+ * 16.78 — EVERY BODY ABIDE READS, a handler's and `/__abide/mcp`'s and `/__abide/bridge`'s alike.
507
+ */
508
+ export async function bodyWithin(
509
+ request: Request,
510
+ maxBodySize: number | undefined,
511
+ ): Promise<Request | Response | Failed<'HttpError', undefined>> {
512
+ const ceiling =
513
+ maxBodySize ??
514
+ positiveSetting('ABIDE_MAX_REQUEST_BODY_SIZE', Number.POSITIVE_INFINITY)
515
+ if (!Number.isFinite(ceiling)) return request
516
+ if (Number(request.headers.get('content-length') ?? 0) > ceiling)
517
+ return overCeiling(ceiling)
518
+ return bodyUnder(request, ceiling)
519
+ }
520
+
507
521
  /**
508
522
  * The request's body read up to `ceiling`, as a `Response`, or a 413 refusal past it.
509
523
  *
@@ -25,8 +25,10 @@ import { refuse } from '#shared/refuse.ts'
25
25
  import { rpcRequest } from '#shared/rpcRequest.ts'
26
26
  import { handlerKey, type Mounted } from './answerRpc.ts'
27
27
  import { argsFor } from './argsFor.ts'
28
+ import { positiveSetting } from './config.ts'
28
29
  import { closedTo, crossingOrigin, crossingRefusal } from './crossingOrigin.ts'
29
30
  import { failureOf } from './hooks.ts'
31
+ import { sendOrClose } from './muxUpgrade.ts'
30
32
  import {
31
33
  heldByApp,
32
34
  inOpenScope,
@@ -50,6 +52,9 @@ export type Socket = {
50
52
 
51
53
  const ENCODER = new TextEncoder()
52
54
 
55
+ // 18.31 — a page follows each value it reads once, so a thousand is a page no one could read.
56
+ const MAX_SUBSCRIPTIONS = 1024
57
+
53
58
  /**
54
59
  * One `ServerFrame`, for every follower it is sent to.
55
60
  *
@@ -124,7 +129,7 @@ class SocketPeer implements Peer {
124
129
  this.socket = socket
125
130
  }
126
131
  send(id: number, frame: Frame): void {
127
- this.socket.send(frame.json(id))
132
+ sendOrClose(this.socket, frame.json(id))
128
133
  }
129
134
  close(code: number, reason: string): unknown {
130
135
  return this.socket.close(code, reason)
@@ -351,6 +356,26 @@ export class MuxConnection {
351
356
  subscriptions.delete(id)
352
357
  return
353
358
  }
359
+ // 18.31 — A CONNECTION FOLLOWS AT MOST `ABIDE_MAX_SOCKET_SUBSCRIPTIONS` VALUES; 18.32 — a frame
360
+ // naming an id it already holds replaces that one rather than adding to them.
361
+ if (frame.op === 'sub' && !subscriptions.has(id)) {
362
+ const most = positiveSetting(
363
+ 'ABIDE_MAX_SOCKET_SUBSCRIPTIONS',
364
+ MAX_SUBSCRIPTIONS,
365
+ )
366
+ if (subscriptions.size >= most) {
367
+ this.peer.send(
368
+ id,
369
+ refusal(
370
+ refuse(
371
+ 429,
372
+ `This connection follows ${most} values already.`,
373
+ ),
374
+ ),
375
+ )
376
+ return
377
+ }
378
+ }
354
379
  const handler =
355
380
  frame.op === 'sub'
356
381
  ? this.mux.handlers.get(handlerKey('GET', address))
@@ -16,7 +16,8 @@ import { coerceArgs } from './coerceArgs.ts'
16
16
  * 19.7 — the text as the types the handler's `args` declares. 11.27 — then the defaults, ahead of
17
17
  * every key taken after it: the seed a render filed and the `global` entry standing were both filed
18
18
  * under them (11.29). 19.9 — THE WIRE HELD TO THE ANNOTATION, where no `args` schema is declared to
19
- * hold it; args that did not come are the handler's to answer.
19
+ * hold it. Args that did not come are held as an empty record: a schema is only derived where the
20
+ * handler's args are a required record, so a required member missing is refused, not read off nothing.
20
21
  */
21
22
  export function argsFor(
22
23
  found: Pick<Mounted, 'handler' | 'argsDocument' | 'wireArgs'>,
@@ -32,8 +33,8 @@ export function argsFor(
32
33
  query,
33
34
  )
34
35
  const args = normalized(found.handler, coerced)
35
- if (found.wireArgs !== undefined && args !== undefined) {
36
- const issues = validateJson(found.wireArgs, args)
36
+ if (found.wireArgs !== undefined) {
37
+ const issues = validateJson(found.wireArgs, args ?? {})
37
38
  if (issues !== null) throw validationError(issues)
38
39
  }
39
40
  return args
@@ -2,10 +2,10 @@
2
2
  // every provider shares: the transcript's check, the app's MCP tools as a `Toolbox`, and the turn cap.
3
3
  // A `Provider` runs the conversation — `claudeApi` by looping over the Messages API itself,
4
4
  // `claudeCode` by handing it to a Claude Code on the visitor's own machine — and yields the one
5
- // `AssistantEvent` stream either way (45.2).
5
+ // `AssistantEvent` stream either way (44.2).
6
6
  //
7
- // THE TOOLS ARE THE APP'S OWN MCP SURFACE (45.6), called in process through the app answering this
8
- // request and carrying the asker's credentials (45.7), so a tool does what the person asking could do
7
+ // THE TOOLS ARE THE APP'S OWN MCP SURFACE (44.6), called in process through the app answering this
8
+ // request and carrying the asker's credentials (44.7), so a tool does what the person asking could do
9
9
  // and nothing more. Both are CAPTURED when the conversation starts: a provider may call a tool from
10
10
  // another request's scope — `claudeCode` answers one from the bridge's — and the call is still this
11
11
  // asker's.
@@ -33,6 +33,12 @@ export type ToolDefinition = {
33
33
  name: string
34
34
  description: string
35
35
  inputSchema: unknown
36
+ annotations: {
37
+ readOnlyHint: boolean
38
+ destructiveHint: boolean
39
+ idempotentHint: boolean
40
+ }
41
+ outputSchema: unknown
36
42
  }
37
43
 
38
44
  /** The app's MCP tools, called as the asker. */
@@ -57,7 +63,7 @@ export type Provider = {
57
63
  export type AssistantOptions = { mcp?: boolean; maxTurns?: number }
58
64
 
59
65
  const MAX_TURNS = 10
60
- // 45.7 — the caller's credentials, and nothing else of their request.
66
+ // 44.7 — the caller's credentials, and nothing else of their request.
61
67
  const CARRIED_HEADERS = ['cookie', 'authorization']
62
68
 
63
69
  /** A conversation over `messages`, run by `provider`, as the events it produces. */
@@ -66,11 +72,11 @@ export async function* assistant(
66
72
  provider: Provider,
67
73
  options: AssistantOptions = {},
68
74
  ): AsyncGenerator<AssistantEvent> {
69
- // 45.3 — a transcript a model can answer ends on the asker's turn.
75
+ // 44.3 — a transcript a model can answer ends on the asker's turn.
70
76
  if (messages.at(-1)?.role !== 'user')
71
77
  throw refuse(
72
78
  400,
73
- 'The transcript has to end on a user turn for the assistant to answer it. RULEBOOK 45.3.',
79
+ 'The transcript has to end on a user turn for the assistant to answer it. RULEBOOK 44.3.',
74
80
  )
75
81
  const tools = toolboxOver(
76
82
  options.mcp === true ? appSurface() : NO_SURFACE,
@@ -90,7 +96,7 @@ type McpForward = (method: string, params: unknown) => Promise<McpReply>
90
96
  // the type rather than on it, since a `Toolbox` is public and this is not.
91
97
  const FORWARDS = new WeakMap<Toolbox, McpForward>()
92
98
 
93
- /** Where `tools` sends an MCP request whole. 45.17. */
99
+ /** Where `tools` sends an MCP request whole. 44.17. */
94
100
  export function forwardOf(tools: Toolbox): McpForward {
95
101
  const forward = FORWARDS.get(tools)
96
102
  if (forward === undefined)
@@ -107,23 +113,23 @@ const NO_SURFACE = new McpSurface(
107
113
  async () => new Response(null, { status: 404 }),
108
114
  )
109
115
 
110
- /** The MCP surface of the app answering this request. 45.6, 45.8. */
116
+ /** The MCP surface of the app answering this request. 44.6, 44.8. */
111
117
  function appSurface(): McpSurface {
112
118
  // 42.12 — the app answering this request, captured: a later call may come from another's scope.
113
119
  const app = within('assistant').app
114
120
  if (app === undefined)
115
121
  throw new Error(
116
- 'abide: assistant tools are the app’s MCP surface, and no app is answering this request. RULEBOOK 45.6.',
122
+ 'abide: assistant tools are the app’s MCP surface, and no app is answering this request. RULEBOOK 44.6.',
117
123
  )
118
- // 45.8, 20.7 — asking for tools where no handler is published to MCP is a mistake to name.
124
+ // 44.8, 20.7 — asking for tools where no handler is published to MCP is a mistake to name.
119
125
  if (!app.mcp.opened)
120
126
  throw new Error(
121
- 'abide: assistant was asked for tools, and this app publishes no handler to MCP. RULEBOOK 45.8.',
127
+ 'abide: assistant was asked for tools, and this app publishes no handler to MCP. RULEBOOK 44.8.',
122
128
  )
123
129
  return app.mcp
124
130
  }
125
131
 
126
- /** The asking request as a tool call is made: its credentials, and nothing else of it. 45.7. */
132
+ /** The asking request as a tool call is made: its credentials, and nothing else of it. 44.7. */
127
133
  function askerOf(asked: Request): Request {
128
134
  const carried: Record<string, string> = {}
129
135
  for (const name of CARRIED_HEADERS) {
@@ -133,7 +139,7 @@ function askerOf(asked: Request): Request {
133
139
  return new Request(asked.url, { headers: carried })
134
140
  }
135
141
 
136
- /** `surface` as a `Toolbox`, every call made as `asker`. 45.6, 45.7. */
142
+ /** `surface` as a `Toolbox`, every call made as `asker`. 44.6, 44.7. */
137
143
  function toolboxOver(surface: McpSurface, asker: Request): Toolbox {
138
144
  const tools: Toolbox = {
139
145
  list: surface.list(),
@@ -1,22 +1,25 @@
1
- // 45.13 — THE RELAY BETWEEN A PAGE AND THE CLAUDE CODE ON ITS VISITOR'S MACHINE. A browser cannot reach
1
+ // 44.13 — THE RELAY BETWEEN A PAGE AND THE CLAUDE CODE ON ITS VISITOR'S MACHINE. A browser cannot reach
2
2
  // a process on its own machine from an https page — Safari refuses it as mixed content, and Chrome
3
3
  // asks a local-network permission of every site — so the bridge dials OUT, to this address:
4
4
  //
5
5
  // GET /__abide/bridge?code=… the pairing, answered by a server-sent stream of what the bridge is
6
6
  // to do: the token it posts with, each turn, each tool reply, a cancel.
7
+ // GET /__abide/bridge the same stream, resumed by the token after it dropped (44.26).
7
8
  // POST /__abide/bridge what the bridge reports, one frame per line: a turn's events, the
8
9
  // MCP requests its Claude Code made, the turn's end or failure.
9
10
  //
10
11
  // A PAIRING IS TO A BROWSER — the `abide-caller` its page carries (26.5) — for as long as the bridge's
11
- // stream is open. HELD IN THIS PROCESS: an app served by several needs a visitor's requests to reach
12
+ // stream is open, or resumable. HELD IN THIS PROCESS: an app served by several needs a visitor's requests to reach
12
13
  // the one its bridge dialed.
13
14
 
15
+ import { isFailed } from '#shared/Failed.ts'
14
16
  import { refuse } from '#shared/refuse.ts'
15
17
  import { answered } from './answerFailure.ts'
16
- import { raiseTimeout } from './answerRpc.ts'
18
+ import { bodyWithin, raiseTimeout } from './answerRpc.ts'
17
19
  import { framed } from './answerValue.ts'
18
20
  import { Inbox } from './Inbox.ts'
19
21
  import { randomToken } from './randomToken.ts'
22
+ import { EVENT_STREAM_OPENER } from './streamEvent.ts'
20
23
 
21
24
  /**
22
25
  * What a turn hears from the bridge, in the order the bridge posted it: an event, an MCP request, the
@@ -40,22 +43,29 @@ export type Bridge = {
40
43
  // Whether a page has watched this pairing: only a watched bridge is sent away when it goes idle.
41
44
  watched: boolean
42
45
  idle: ReturnType<typeof setTimeout> | undefined
46
+ // Set while the stream is dropped and the pairing waits to be resumed (44.26).
47
+ held: ReturnType<typeof setTimeout> | undefined
43
48
  }
44
49
 
45
50
  /** How long a pairing code stands unused. */
46
51
  const CODE_LIFETIME = 10 * 60_000
47
52
  // How long a bridge outlives the last page watching it, so a reload does not end it.
48
53
  const IDLE_GRACE = 30_000
54
+ /** How long a pairing whose stream dropped waits to be resumed. 44.26. */
55
+ export const RESUME_MILLISECONDS = 30_000
56
+ /** How often a pairing's stream carries a comment while it has nothing else to send. 44.27. */
57
+ export const HEARTBEAT_MILLISECONDS = 15_000
58
+ const HEARTBEAT = new TextEncoder().encode(EVENT_STREAM_OPENER)
49
59
 
50
60
  const CODES = new Map<string, { caller: string; expires: number }>()
51
61
  const BY_CALLER = new Map<string, Bridge>()
52
62
  const BY_TOKEN = new Map<string, Bridge>()
53
63
  const WATCHERS = new Map<string, Set<() => void>>()
54
64
 
55
- // 45.19 — the relay answers once the app has handed out a pairing command, and never before.
65
+ // 44.19 — the relay answers once the app has handed out a pairing command, and never before.
56
66
  let minted = false
57
67
 
58
- /** A single-use pairing code for `caller`, and when it stops standing. 45.14. */
68
+ /** A single-use pairing code for `caller`, and when it stops standing. 44.14. */
59
69
  export function mintCode(caller: string): { code: string; expires: number } {
60
70
  const now = Date.now()
61
71
  for (const [code, pairing] of CODES)
@@ -113,7 +123,7 @@ export function openTurn(bridge: Bridge): {
113
123
  }
114
124
  }
115
125
 
116
- // 45.22 — A BRIDGE A PAGE WATCHED, THAT NO PAGE WATCHES NOW AND THAT IS ANSWERING NO TURN, is sent
126
+ // 44.22 — A BRIDGE A PAGE WATCHED, THAT NO PAGE WATCHES NOW AND THAT IS ANSWERING NO TURN, is sent
117
127
  // away once the grace runs.
118
128
  function armIdle(caller: string): void {
119
129
  const bridge = BY_CALLER.get(caller)
@@ -159,6 +169,8 @@ function dropBridge(bridge: Bridge): void {
159
169
  BY_TOKEN.delete(bridge.token)
160
170
  if (bridge.idle !== undefined) clearTimeout(bridge.idle)
161
171
  bridge.idle = undefined
172
+ if (bridge.held !== undefined) clearTimeout(bridge.held)
173
+ bridge.held = undefined
162
174
  for (const turn of bridge.turns.values()) {
163
175
  turn.push(
164
176
  item('fail', { message: 'the Claude Code bridge disconnected' }),
@@ -170,7 +182,7 @@ function dropBridge(bridge: Bridge): void {
170
182
  wakeWatchers(bridge.caller)
171
183
  }
172
184
 
173
- /** Answers `/__abide/bridge`, or `undefined` until a pairing code has been minted. 45.19. */
185
+ /** Answers `/__abide/bridge`, or `undefined` until a pairing code has been minted. 44.19. */
174
186
  export async function answerBridge(request: Request): Promise<unknown> {
175
187
  if (!minted) return undefined
176
188
  if (request.method === 'GET') return pair(request)
@@ -179,9 +191,10 @@ export async function answerBridge(request: Request): Promise<unknown> {
179
191
  }
180
192
 
181
193
  function pair(request: Request): Response {
182
- const code = new URL(request.url).searchParams.get('code') ?? ''
194
+ const code = new URL(request.url).searchParams.get('code')
195
+ if (code === null) return resume(request)
183
196
  const pairing = CODES.get(code)
184
- // 45.14 — a code pairs once.
197
+ // 44.14 — a code pairs once.
185
198
  CODES.delete(code)
186
199
  if (pairing === undefined || pairing.expires <= Date.now())
187
200
  return answered(
@@ -202,20 +215,87 @@ function pair(request: Request): Response {
202
215
  turns: new Map(),
203
216
  watched: WATCHERS.has(pairing.caller),
204
217
  idle: undefined,
218
+ held: undefined,
205
219
  }
206
220
  BY_CALLER.set(bridge.caller, bridge)
207
221
  BY_TOKEN.set(bridge.token, bridge)
208
222
  bridge.frames.push({ type: 'paired', token: bridge.token })
223
+ wakeWatchers(bridge.caller)
224
+ return attach(bridge, request)
225
+ }
226
+
227
+ /** The bridge the request's bearer token was handed when it paired. */
228
+ function pairedBy(request: Request): Bridge | undefined {
229
+ return BY_TOKEN.get(
230
+ (request.headers.get('authorization') ?? '').replace(/^Bearer /, ''),
231
+ )
232
+ }
233
+
234
+ // 44.26 — A DROPPED STREAM IS RESUMED BY ITS TOKEN, with every frame it had not sent: the stream it
235
+ // replaces, still open where the drop went unnoticed here, is ended.
236
+ function resume(request: Request): Response {
237
+ const bridge = pairedBy(request)
238
+ if (bridge === undefined)
239
+ return answered(refuse(401, 'No bridge is paired with this token.'))
240
+ if (bridge.held !== undefined) clearTimeout(bridge.held)
241
+ else bridge.frames = bridge.frames.handOff()
242
+ bridge.held = undefined
243
+ return attach(bridge, request)
244
+ }
245
+
246
+ /** `bridge`'s frames as this request's stream, held for `RESUME_MILLISECONDS` once it drops. */
247
+ function attach(bridge: Bridge, request: Request): Response {
248
+ const frames = bridge.frames
209
249
  // 16.57 — a pairing stays open for as long as the bridge holds it.
210
250
  raiseTimeout(Number.POSITIVE_INFINITY)
211
- request.signal.addEventListener('abort', () => dropBridge(bridge))
212
- wakeWatchers(bridge.caller)
213
- return framed(
214
- bridge.frames[Symbol.asyncIterator](),
251
+ request.signal.addEventListener('abort', () => {
252
+ if (bridge.frames !== frames || !BY_TOKEN.has(bridge.token)) return
253
+ bridge.frames = frames.handOff()
254
+ bridge.held = setTimeout(() => dropBridge(bridge), RESUME_MILLISECONDS)
255
+ })
256
+ const answer = framed(
257
+ frames[Symbol.asyncIterator](),
215
258
  'sse',
216
259
  new Headers(),
217
260
  200,
218
261
  )
262
+ return new Response(
263
+ withHeartbeat(answer.body as ReadableStream<Uint8Array>),
264
+ answer,
265
+ )
266
+ }
267
+
268
+ // 44.27 — A COMMENT EVERY HEARTBEAT, so a bridge can tell a connection that went silent without
269
+ // ending from a quiet one. A comment rather than a frame: a bridge reads only `data:` lines, and a
270
+ // frame would sit in the pairing's inbox through a drop.
271
+ function withHeartbeat(
272
+ body: ReadableStream<Uint8Array>,
273
+ ): ReadableStream<Uint8Array> {
274
+ const reader = body.getReader()
275
+ let beat: ReturnType<typeof setInterval> | undefined
276
+ return new ReadableStream<Uint8Array>({
277
+ start(controller) {
278
+ beat = setInterval(
279
+ () => controller.enqueue(HEARTBEAT),
280
+ HEARTBEAT_MILLISECONDS,
281
+ )
282
+ },
283
+ async pull(controller) {
284
+ try {
285
+ const next = await reader.read()
286
+ if (!next.done) return controller.enqueue(next.value)
287
+ clearInterval(beat)
288
+ controller.close()
289
+ } catch (thrown) {
290
+ clearInterval(beat)
291
+ controller.error(thrown)
292
+ }
293
+ },
294
+ cancel(reason) {
295
+ clearInterval(beat)
296
+ return reader.cancel(reason)
297
+ },
298
+ })
219
299
  }
220
300
 
221
301
  type BridgeReport = {
@@ -229,16 +309,24 @@ type BridgeReport = {
229
309
  }
230
310
 
231
311
  async function relay(request: Request): Promise<Response> {
232
- const token = (request.headers.get('authorization') ?? '').replace(
233
- /^Bearer /,
234
- '',
235
- )
236
- const bridge = BY_TOKEN.get(token)
312
+ const bridge = pairedBy(request)
237
313
  if (bridge === undefined)
238
314
  return answered(refuse(401, 'No bridge is paired with this token.'))
239
- for (const line of (await request.text()).split('\n')) {
315
+ // 16.78 — under the environment's body ceiling, as a handler's body is.
316
+ const body = await bodyWithin(request, undefined)
317
+ if (isFailed(body)) return answered(body)
318
+ // 44.23 — EVERY LINE PARSED BEFORE ANY IS RELAYED, so a post with one that is not JSON is refused
319
+ // whole rather than relayed in part.
320
+ const reports: BridgeReport[] = []
321
+ for (const line of (await body.text()).split('\n')) {
240
322
  if (line === '') continue
241
- const report = JSON.parse(line) as BridgeReport
323
+ try {
324
+ reports.push(JSON.parse(line) as BridgeReport)
325
+ } catch {
326
+ return answered(refuse(400, 'A reported line is not JSON.'))
327
+ }
328
+ }
329
+ for (const report of reports) {
242
330
  const turn = bridge.turns.get(report.turn)
243
331
  if (turn === undefined) continue
244
332
  turn.push(item(report.type, report))
@@ -1,4 +1,4 @@
1
- // 45.9 — THE MESSAGES API AS A `Provider`, configured the way Anthropic documents it: the request
1
+ // 44.9 — THE MESSAGES API AS A `Provider`, configured the way Anthropic documents it: the request
2
2
  // body's own fields, snake_case, beside the client's `apiKey` and `baseURL`. abide owns `messages`
3
3
  // and `stream`; every other field is laid on the body as written, and the `tools` written there —
4
4
  // a server tool like web search — are sent beside the app's own.
@@ -64,10 +64,10 @@ type ApiEvent = {
64
64
 
65
65
  /** The Messages API, streamed, looping while Claude asks for tools. */
66
66
  export function claudeApi(config: ClaudeApiConfig): Provider {
67
- // 45.10 — the key is the app's to hand over, and nothing reads one from the environment.
67
+ // 44.10 — the key is the app's to hand over, and nothing reads one from the environment.
68
68
  if (typeof config.apiKey !== 'string' || config.apiKey === '')
69
69
  throw new Error(
70
- 'abide: claudeApi was handed no apiKey. RULEBOOK 45.10.',
70
+ 'abide: claudeApi was handed no apiKey. RULEBOOK 44.10.',
71
71
  )
72
72
  const { apiKey, baseURL, tools: ownTools, ...body } = config
73
73
  return {
@@ -102,7 +102,7 @@ export function claudeApi(config: ClaudeApiConfig): Provider {
102
102
  }),
103
103
  },
104
104
  )
105
- // 45.21 — the API's own failure, answered before the first event where it comes first.
105
+ // 44.21 — the API's own failure, answered before the first event where it comes first.
106
106
  if (!answered.ok || answered.body === null)
107
107
  throw refuse(
108
108
  502,
@@ -143,7 +143,7 @@ export function claudeApi(config: ClaudeApiConfig): Provider {
143
143
  if (block.type !== 'tool_use') break
144
144
  const partial = partials[event.index] as string
145
145
  // Cut short at `max_tokens`, a call's input is not JSON — and that turn did
146
- // not stop for `tool_use`, so it is never run (45.9).
146
+ // not stop for `tool_use`, so it is never run (44.9).
147
147
  try {
148
148
  block.input =
149
149
  partial === '' ? {} : JSON.parse(partial)
@@ -165,7 +165,7 @@ export function claudeApi(config: ClaudeApiConfig): Provider {
165
165
  usage.output += event.usage?.output_tokens ?? 0
166
166
  break
167
167
  case 'error':
168
- // 45.21.
168
+ // 44.21.
169
169
  throw refuse(
170
170
  502,
171
171
  `The Claude API failed: ${event.error.message}`,
@@ -205,7 +205,7 @@ export function claudeApi(config: ClaudeApiConfig): Provider {
205
205
  is_error: result.failed,
206
206
  })
207
207
  }
208
- // 45.12 — every result of one turn in one message.
208
+ // 44.12 — every result of one turn in one message.
209
209
  history.push({ role: 'user', content })
210
210
  }
211
211
  },
@@ -1,11 +1,11 @@
1
- // 45.13 — THE CLAUDE CODE ON THE VISITOR'S OWN MACHINE AS A `Provider`, reached through the bridge they
1
+ // 44.13 — THE CLAUDE CODE ON THE VISITOR'S OWN MACHINE AS A `Provider`, reached through the bridge they
2
2
  // paired (`bridgeRelay.ts`). Claude Code runs its own loop there; what this side does is hand it the
3
3
  // turn and answer the MCP requests it makes from the `Toolbox` — in this request's scope, as this
4
- // asker (45.7) — so its tools are the site's and nothing of the visitor's machine is one.
4
+ // asker (44.7) — so its tools are the site's and nothing of the visitor's machine is one.
5
5
  //
6
6
  // CONFIGURED BY CLAUDE CODE'S OWN OPTION NAMES, and only the ones that shape the answer. What would give
7
7
  // a site power over the visitor's machine — its own tools, permissions, directory, settings — is not an
8
- // option here at all: the bridge decides it (45.15, 45.16).
8
+ // option here at all: the bridge decides it (44.15, 44.16).
9
9
 
10
10
  import { mounted } from '#shared/mount.ts'
11
11
  import { refuse } from '#shared/refuse.ts'
@@ -45,7 +45,7 @@ function claudeCodeProvider(config: ClaudeCodeConfig = {}): Provider {
45
45
  return {
46
46
  async *run(messages, tools, { maxTurns, signal }) {
47
47
  const bridge = pairedBridge(askingCaller())
48
- // 45.13 — nothing paired is the page's cue to show the command.
48
+ // 44.13 — nothing paired is the page's cue to show the command.
49
49
  if (bridge === undefined)
50
50
  throw refuse(
51
51
  409,
@@ -59,7 +59,7 @@ function claudeCodeProvider(config: ClaudeCodeConfig = {}): Provider {
59
59
  config,
60
60
  maxTurns,
61
61
  })
62
- // 45.18 — a caller hanging up ends the run on the bridge.
62
+ // 44.18 — a caller hanging up ends the run on the bridge.
63
63
  const cancel = (): void => {
64
64
  bridge.frames.push({ type: 'cancel', turn: turn.id })
65
65
  }
@@ -69,7 +69,7 @@ function claudeCodeProvider(config: ClaudeCodeConfig = {}): Provider {
69
69
  if (item.kind === 'event')
70
70
  yield item.event as AssistantEvent
71
71
  else if (item.kind === 'mcp')
72
- // 45.17 — answered by the app's own MCP surface, through the turn's `Toolbox`.
72
+ // 44.17 — answered by the app's own MCP surface, through the turn's `Toolbox`.
73
73
  void forwardOf(tools)(item.method, item.params)
74
74
  .catch((thrown: unknown) =>
75
75
  mcpFailure(-32603, String(thrown)),
@@ -83,7 +83,7 @@ function claudeCodeProvider(config: ClaudeCodeConfig = {}): Provider {
83
83
  })
84
84
  })
85
85
  else if (item.kind === 'end') return
86
- // 45.21.
86
+ // 44.21.
87
87
  else
88
88
  throw refuse(502, `Claude Code failed: ${item.message}`)
89
89
  }
@@ -95,7 +95,7 @@ function claudeCodeProvider(config: ClaudeCodeConfig = {}): Provider {
95
95
  }
96
96
  }
97
97
 
98
- /** The transcript as the one message Claude Code is sent: earlier turns folded above the last. 45.5. */
98
+ /** The transcript as the one message Claude Code is sent: earlier turns folded above the last. 44.5. */
99
99
  function foldedPrompt(messages: AssistantMessage[]): string {
100
100
  const last = (messages.at(-1) as AssistantMessage).content
101
101
  if (messages.length === 1) return last
@@ -107,7 +107,7 @@ function foldedPrompt(messages: AssistantMessage[]): string {
107
107
  return `<conversation>\n${earlier}</conversation>\n\n${last}`
108
108
  }
109
109
 
110
- /** The browser asking, which a pairing is to. 45.13. */
110
+ /** The browser asking, which a pairing is to. 44.13. */
111
111
  function askingCaller(): string {
112
112
  const caller = callerOf()
113
113
  if (caller === undefined)
@@ -118,7 +118,7 @@ function askingCaller(): string {
118
118
  return caller
119
119
  }
120
120
 
121
- /** The command that pairs a bridge with this browser, and when its code stops standing. 45.14. */
121
+ /** The command that pairs a bridge with this browser, and when its code stops standing. 44.14. */
122
122
  function bridgeCommand(): { command: string; expires: number } {
123
123
  const { code, expires } = mintCode(askingCaller())
124
124
  const site = `${appOrigin() ?? new URL(request().url).origin}${mounted('')}`
@@ -130,7 +130,7 @@ function bridgeCommand(): { command: string; expires: number } {
130
130
 
131
131
  /**
132
132
  * This browser's pairing as it changes: unpaired with the command to run, then paired the moment a
133
- * bridge connects, and unpaired again if it goes. 45.20.
133
+ * bridge connects, and unpaired again if it goes. 44.20.
134
134
  */
135
135
  async function* bridge(): AsyncGenerator<BridgeStatus> {
136
136
  const caller = askingCaller()