@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.
- package/CHANGELOG.md +24 -0
- package/bin/abide.ts +4 -1
- package/docs/REGISTRY.md +156 -44
- package/docs/RULEBOOK.md +375 -87
- package/docs/abide.md +7243 -10256
- package/package.json +4 -1
- package/scaffold/AGENTS.md +2 -0
- package/scaffold/src/getGreeting.ts +6 -0
- package/scaffold/src/index.abide +3 -1
- package/scaffold/src/tests/getGreeting.test.ts +6 -0
- package/scaffold/src/tests/index.test.ts +18 -0
- package/src/compiler/DOCS_SITE.ts +2 -0
- package/src/compiler/Lowering.ts +234 -14
- package/src/compiler/PositionTable.ts +14 -7
- package/src/compiler/PositionedRefusal.ts +13 -0
- package/src/compiler/Refusals.ts +18 -11
- package/src/compiler/SHARED_NAMES.ts +2 -0
- package/src/compiler/ShadowMap.ts +37 -0
- package/src/compiler/TEMPLATE_BLOCKS.ts +12 -0
- package/src/compiler/TypeSession.ts +410 -31
- package/src/compiler/appLayout.ts +18 -2
- package/src/compiler/argsSchemaOf.ts +55 -9
- package/src/compiler/checkApp.ts +33 -0
- package/src/compiler/checkedShadow.ts +36 -22
- package/src/compiler/citedClauses.ts +14 -0
- package/src/compiler/classifyRead.ts +6 -0
- package/src/compiler/classifyTemplate.ts +2 -1
- package/src/compiler/compileApp.ts +2 -2
- package/src/compiler/declaredExports.ts +27 -6
- package/src/compiler/emitClient.ts +304 -103
- package/src/compiler/handlersUnder.ts +4 -0
- package/src/compiler/invocationProps.ts +1 -1
- package/src/compiler/isIgnored.ts +27 -0
- package/src/compiler/parseAbide.ts +176 -76
- package/src/compiler/projectShadow.ts +30 -6
- package/src/compiler/readSources.ts +1 -1
- package/src/compiler/readsIn.ts +100 -1
- package/src/compiler/renderMarkup.ts +36 -5
- package/src/compiler/scaffold.ts +19 -6
- package/src/compiler/serveLanguage.ts +156 -21
- package/src/compiler/templateContext.ts +150 -0
- package/src/compiler/tokenize.ts +6 -0
- package/src/server/BRIDGE_VERSION.ts +1 -1
- package/src/server/Inbox.ts +8 -0
- package/src/server/MCP_PROTOCOL_VERSION.ts +1 -1
- package/src/server/MOUNTABLE.ts +6 -0
- package/src/server/abideModules.ts +8 -1
- package/src/server/answerRequest.ts +3 -1
- package/src/server/answerRpc.ts +26 -12
- package/src/server/answerSocket.ts +26 -1
- package/src/server/argsFor.ts +4 -3
- package/src/server/assistant.ts +19 -13
- package/src/server/bridgeRelay.ts +108 -20
- package/src/server/claudeApi.ts +7 -7
- package/src/server/claudeCode.ts +11 -11
- package/src/server/commands.ts +73 -9
- package/src/server/createApp.ts +41 -7
- package/src/server/devWorker.ts +3 -3
- package/src/server/generatedSurfaces.ts +37 -4
- package/src/server/muxUpgrade.ts +47 -0
- package/src/server/preload.ts +13 -2
- package/src/server/render.ts +4 -1
- package/src/server/runCompiled.ts +1 -0
- package/src/server/seedBuffer.ts +31 -1
- package/src/server/serveDev.ts +83 -72
- package/src/server/surfaceOf.ts +3 -0
- package/src/server/testMount.ts +53 -0
- package/src/shared/ADDRESSES.ts +1 -1
- package/src/shared/ERROR_META.ts +3 -0
- package/src/shared/Schema.ts +1 -1
- package/src/shared/WORK.ts +14 -12
- package/src/shared/applyPatch.ts +1 -1
- package/src/shared/boundValue.ts +81 -0
- package/src/shared/index.ts +7 -2
- package/src/shared/jsonSchema.ts +25 -2
- package/src/shared/log.ts +11 -1
- package/src/shared/reactive/Keyed.ts +30 -6
- package/src/shared/reactive/PATCHES.ts +1 -1
- package/src/shared/reactive/Reactive.ts +118 -60
- package/src/shared/reactive/canonicalKey.ts +1 -1
- package/src/shared/reactive/index.ts +13 -5
- package/src/shared/reactive/recordWrites.ts +1 -1
- package/src/shared/reactive/watch.ts +26 -4
- package/src/shared/rpc.ts +6 -1
- package/src/shared/url.ts +7 -3
- package/src/shared/validateJson.ts +1 -1
- package/src/ui/ArmRegion.ts +2 -2
- package/src/ui/List.ts +9 -9
- package/src/ui/RawRun.ts +1 -1
- package/src/ui/Router.ts +194 -36
- package/src/ui/Scope.ts +29 -0
- package/src/ui/adoptWalk.ts +3 -3
- package/src/ui/applyHead.ts +25 -3
- package/src/ui/bindProperty.ts +70 -22
- package/src/ui/index.ts +1 -1
- package/src/ui/leave.ts +5 -0
- package/src/ui/listenForRoutes.ts +14 -3
- package/src/ui/reconcile.ts +48 -28
- package/src/ui/regions.ts +36 -4
- package/src/ui/rows.ts +108 -28
package/src/server/answerRpc.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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))
|
package/src/server/argsFor.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
package/src/server/assistant.ts
CHANGED
|
@@ -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 (
|
|
5
|
+
// `AssistantEvent` stream either way (44.2).
|
|
6
6
|
//
|
|
7
|
-
// THE TOOLS ARE THE APP'S OWN MCP SURFACE (
|
|
8
|
-
// request and carrying the asker's credentials (
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
122
|
+
'abide: assistant tools are the app’s MCP surface, and no app is answering this request. RULEBOOK 44.6.',
|
|
117
123
|
)
|
|
118
|
-
//
|
|
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
|
|
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.
|
|
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`.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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', () =>
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
bridge.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
package/src/server/claudeApi.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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 (
|
|
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
|
-
//
|
|
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
|
-
//
|
|
208
|
+
// 44.12 — every result of one turn in one message.
|
|
209
209
|
history.push({ role: 'user', content })
|
|
210
210
|
}
|
|
211
211
|
},
|
package/src/server/claudeCode.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
//
|
|
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 (
|
|
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 (
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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()
|