@abide/abide 0.105.1 → 0.107.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 +28 -0
  2. package/bin/abide.ts +4 -1
  3. package/docs/REGISTRY.md +156 -44
  4. package/docs/RULEBOOK.md +392 -88
  5. package/docs/abide.md +7245 -10248
  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 +7 -4
  28. package/src/compiler/compileApp.ts +2 -2
  29. package/src/compiler/declaredExports.ts +27 -6
  30. package/src/compiler/emitClient.ts +306 -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 +21 -14
  53. package/src/server/bridgeRelay.ts +108 -20
  54. package/src/server/claudeApi.ts +19 -11
  55. package/src/server/claudeCode.ts +19 -14
  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/bindElement.ts +4 -0
  95. package/src/ui/bindProperty.ts +70 -22
  96. package/src/ui/index.ts +1 -1
  97. package/src/ui/listenForRoutes.ts +14 -3
  98. package/src/ui/reconcile.ts +23 -28
  99. package/src/ui/regions.ts +9 -2
  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.
@@ -20,7 +20,8 @@ export type AssistantMessage = { role: 'user' | 'assistant'; content: string }
20
20
  /** What a conversation yields, the same from every provider. */
21
21
  export type AssistantEvent =
22
22
  | { type: 'text'; text: string }
23
- | { type: 'tool'; id: string; name: string; input: unknown }
23
+ | { type: 'tool'; id: string; name: string }
24
+ | { type: 'input'; id: string; input: unknown }
24
25
  | { type: 'result'; id: string; text: string; failed: boolean }
25
26
  | {
26
27
  type: 'done'
@@ -33,6 +34,12 @@ export type ToolDefinition = {
33
34
  name: string
34
35
  description: string
35
36
  inputSchema: unknown
37
+ annotations: {
38
+ readOnlyHint: boolean
39
+ destructiveHint: boolean
40
+ idempotentHint: boolean
41
+ }
42
+ outputSchema: unknown
36
43
  }
37
44
 
38
45
  /** The app's MCP tools, called as the asker. */
@@ -57,7 +64,7 @@ export type Provider = {
57
64
  export type AssistantOptions = { mcp?: boolean; maxTurns?: number }
58
65
 
59
66
  const MAX_TURNS = 10
60
- // 45.7 — the caller's credentials, and nothing else of their request.
67
+ // 44.7 — the caller's credentials, and nothing else of their request.
61
68
  const CARRIED_HEADERS = ['cookie', 'authorization']
62
69
 
63
70
  /** A conversation over `messages`, run by `provider`, as the events it produces. */
@@ -66,11 +73,11 @@ export async function* assistant(
66
73
  provider: Provider,
67
74
  options: AssistantOptions = {},
68
75
  ): AsyncGenerator<AssistantEvent> {
69
- // 45.3 — a transcript a model can answer ends on the asker's turn.
76
+ // 44.3 — a transcript a model can answer ends on the asker's turn.
70
77
  if (messages.at(-1)?.role !== 'user')
71
78
  throw refuse(
72
79
  400,
73
- 'The transcript has to end on a user turn for the assistant to answer it. RULEBOOK 45.3.',
80
+ 'The transcript has to end on a user turn for the assistant to answer it. RULEBOOK 44.3.',
74
81
  )
75
82
  const tools = toolboxOver(
76
83
  options.mcp === true ? appSurface() : NO_SURFACE,
@@ -90,7 +97,7 @@ type McpForward = (method: string, params: unknown) => Promise<McpReply>
90
97
  // the type rather than on it, since a `Toolbox` is public and this is not.
91
98
  const FORWARDS = new WeakMap<Toolbox, McpForward>()
92
99
 
93
- /** Where `tools` sends an MCP request whole. 45.17. */
100
+ /** Where `tools` sends an MCP request whole. 44.17. */
94
101
  export function forwardOf(tools: Toolbox): McpForward {
95
102
  const forward = FORWARDS.get(tools)
96
103
  if (forward === undefined)
@@ -107,23 +114,23 @@ const NO_SURFACE = new McpSurface(
107
114
  async () => new Response(null, { status: 404 }),
108
115
  )
109
116
 
110
- /** The MCP surface of the app answering this request. 45.6, 45.8. */
117
+ /** The MCP surface of the app answering this request. 44.6, 44.8. */
111
118
  function appSurface(): McpSurface {
112
119
  // 42.12 — the app answering this request, captured: a later call may come from another's scope.
113
120
  const app = within('assistant').app
114
121
  if (app === undefined)
115
122
  throw new Error(
116
- 'abide: assistant tools are the app’s MCP surface, and no app is answering this request. RULEBOOK 45.6.',
123
+ 'abide: assistant tools are the app’s MCP surface, and no app is answering this request. RULEBOOK 44.6.',
117
124
  )
118
- // 45.8, 20.7 — asking for tools where no handler is published to MCP is a mistake to name.
125
+ // 44.8, 20.7 — asking for tools where no handler is published to MCP is a mistake to name.
119
126
  if (!app.mcp.opened)
120
127
  throw new Error(
121
- 'abide: assistant was asked for tools, and this app publishes no handler to MCP. RULEBOOK 45.8.',
128
+ 'abide: assistant was asked for tools, and this app publishes no handler to MCP. RULEBOOK 44.8.',
122
129
  )
123
130
  return app.mcp
124
131
  }
125
132
 
126
- /** The asking request as a tool call is made: its credentials, and nothing else of it. 45.7. */
133
+ /** The asking request as a tool call is made: its credentials, and nothing else of it. 44.7. */
127
134
  function askerOf(asked: Request): Request {
128
135
  const carried: Record<string, string> = {}
129
136
  for (const name of CARRIED_HEADERS) {
@@ -133,7 +140,7 @@ function askerOf(asked: Request): Request {
133
140
  return new Request(asked.url, { headers: carried })
134
141
  }
135
142
 
136
- /** `surface` as a `Toolbox`, every call made as `asker`. 45.6, 45.7. */
143
+ /** `surface` as a `Toolbox`, every call made as `asker`. 44.6, 44.7. */
137
144
  function toolboxOver(surface: McpSurface, asker: Request): Toolbox {
138
145
  const tools: Toolbox = {
139
146
  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,
@@ -118,10 +118,19 @@ export function claudeApi(config: ClaudeApiConfig): Provider {
118
118
  case 'message_start':
119
119
  usage.input += inputOf(event.message.usage)
120
120
  break
121
- case 'content_block_start':
122
- blocks[event.index] = event.content_block
121
+ case 'content_block_start': {
122
+ const block = event.content_block
123
+ blocks[event.index] = block
123
124
  partials[event.index] = ''
125
+ // 44.29 — announced as the model starts writing it.
126
+ if (block.type === 'tool_use')
127
+ yield {
128
+ type: 'tool',
129
+ id: block.id as string,
130
+ name: block.name as string,
131
+ }
124
132
  break
133
+ }
125
134
  case 'content_block_delta': {
126
135
  const block = blocks[event.index] as Block
127
136
  const delta = event.delta
@@ -143,7 +152,7 @@ export function claudeApi(config: ClaudeApiConfig): Provider {
143
152
  if (block.type !== 'tool_use') break
144
153
  const partial = partials[event.index] as string
145
154
  // 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).
155
+ // not stop for `tool_use`, so it is never run (44.9).
147
156
  try {
148
157
  block.input =
149
158
  partial === '' ? {} : JSON.parse(partial)
@@ -152,9 +161,8 @@ export function claudeApi(config: ClaudeApiConfig): Provider {
152
161
  }
153
162
  called.push(block)
154
163
  yield {
155
- type: 'tool',
164
+ type: 'input',
156
165
  id: block.id as string,
157
- name: block.name as string,
158
166
  input: block.input,
159
167
  }
160
168
  break
@@ -165,7 +173,7 @@ export function claudeApi(config: ClaudeApiConfig): Provider {
165
173
  usage.output += event.usage?.output_tokens ?? 0
166
174
  break
167
175
  case 'error':
168
- // 45.21.
176
+ // 44.21.
169
177
  throw refuse(
170
178
  502,
171
179
  `The Claude API failed: ${event.error.message}`,
@@ -205,7 +213,7 @@ export function claudeApi(config: ClaudeApiConfig): Provider {
205
213
  is_error: result.failed,
206
214
  })
207
215
  }
208
- // 45.12 — every result of one turn in one message.
216
+ // 44.12 — every result of one turn in one message.
209
217
  history.push({ role: 'user', content })
210
218
  }
211
219
  },