@abide/abide 0.124.1 → 0.125.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/ABIDE.md +4 -0
  2. package/CHANGELOG.md +36 -0
  3. package/bin/abide.ts +3 -2
  4. package/docs/REGISTRY.md +66 -40
  5. package/docs/RULEBOOK.md +202 -18
  6. package/docs/abide.md +391 -67
  7. package/package.json +1 -1
  8. package/src/compiler/appMap.ts +14 -1
  9. package/src/compiler/declaredExports.ts +41 -0
  10. package/src/compiler/handlersUnder.ts +1 -0
  11. package/src/compiler/hookCalls.ts +1 -0
  12. package/src/compiler/projectShadow.ts +52 -2
  13. package/src/compiler/readsIn.ts +39 -34
  14. package/src/server/PageRender.ts +7 -8
  15. package/src/server/abideModules.ts +6 -0
  16. package/src/server/answerRequest.ts +115 -52
  17. package/src/server/answerSocket.ts +6 -5
  18. package/src/server/answerWorker.ts +34 -23
  19. package/src/server/assistant.ts +141 -35
  20. package/src/server/banner.ts +9 -5
  21. package/src/server/bridgeRelay.ts +3 -3
  22. package/src/server/callerOf.ts +5 -0
  23. package/src/server/claudeCode.ts +20 -11
  24. package/src/server/commands.ts +14 -7
  25. package/src/server/createApp.ts +65 -12
  26. package/src/server/devWorker.ts +18 -16
  27. package/src/server/generatedSurfaces.ts +6 -5
  28. package/src/server/handlerFileMount.ts +2 -0
  29. package/src/server/hooks.ts +54 -8
  30. package/src/server/index.ts +18 -4
  31. package/src/server/invokeHandler.ts +1 -1
  32. package/src/server/mountHandlers.ts +6 -0
  33. package/src/server/principal.ts +3 -2
  34. package/src/server/render.ts +11 -2
  35. package/src/server/requestScope.ts +61 -1
  36. package/src/server/responseRelay.ts +9 -3
  37. package/src/server/serveDev.ts +18 -3
  38. package/src/server/sqliteStore.ts +65 -0
  39. package/src/server/testMount.ts +1 -0
  40. package/src/shared/AssistantEvent.ts +40 -0
  41. package/src/shared/BrowserHooks.ts +73 -0
  42. package/src/shared/CURRENT_ROUTER.ts +3 -1
  43. package/src/shared/Issues.ts +11 -3
  44. package/src/shared/Middleware.ts +9 -9
  45. package/src/shared/UNEXPECTED.ts +1 -1
  46. package/src/shared/answerOf.ts +9 -4
  47. package/src/shared/imagePiece.ts +85 -0
  48. package/src/shared/index.ts +17 -0
  49. package/src/shared/isServer.ts +15 -0
  50. package/src/shared/navigate.ts +10 -3
  51. package/src/shared/onError.ts +51 -0
  52. package/src/shared/onRefusal.ts +47 -0
  53. package/src/shared/online.ts +5 -1
  54. package/src/shared/reactive/Keyed.ts +12 -1
  55. package/src/shared/reactive/Reactive.ts +53 -25
  56. package/src/shared/reactive/cellShape.ts +4 -3
  57. package/src/shared/reactive/poll.ts +49 -0
  58. package/src/shared/reactive/scoped.ts +10 -1
  59. package/src/shared/reactive/scoping.ts +14 -0
  60. package/src/shared/reactive/watch.ts +19 -8
  61. package/src/shared/resultsOf.ts +21 -0
  62. package/src/shared/route.ts +18 -4
  63. package/src/shared/rpc.ts +112 -26
  64. package/src/shared/seededDocument.ts +5 -6
  65. package/src/shared/storedValue.ts +32 -0
  66. package/src/shared/webStore.ts +54 -0
  67. package/src/ui/Router.ts +42 -9
  68. package/src/ui/outboxQueue.ts +1 -1
  69. package/src/ui/wire.ts +4 -4
  70. package/src/shared/KEEP_OPENED.ts +0 -7
  71. /package/src/{server → shared}/IMAGE_TYPES.ts +0 -0
@@ -26,6 +26,7 @@ import type { FollowedHandler } from './mountHandlers.ts'
26
26
  import { sendOrClose } from './muxUpgrade.ts'
27
27
  import {
28
28
  inOpenScope,
29
+ RECEIVED_FROM,
29
30
  releaseScope,
30
31
  type RequestScope,
31
32
  scopeFor,
@@ -139,11 +140,11 @@ export class MuxConnection {
139
140
  upgrade.url,
140
141
  )
141
142
  // NO `received`: an upgraded connection has no http idle timeout for 16.23 to set.
142
- this.scope = scopeFor(
143
- new Request(upgrade, { signal: this.hangUp.signal }),
144
- undefined,
145
- mux.app,
146
- )
143
+ const request = new Request(upgrade, { signal: this.hangUp.signal })
144
+ // 22.12 — every frame came from where the upgrade did.
145
+ if (RECEIVED_FROM.has(upgrade))
146
+ RECEIVED_FROM.set(request, RECEIVED_FROM.get(upgrade) ?? null)
147
+ this.scope = scopeFor(request, undefined, mux.app)
147
148
  // 22.10 — the headers went out with the upgrade, so nothing a frame writes is carried.
148
149
  this.scope.applied = true
149
150
  }
@@ -7,7 +7,6 @@
7
7
 
8
8
  import { CLIENT_ADDRESS } from '#shared/ADDRESSES.ts'
9
9
  import { KEEP_HEADER } from '#shared/KEEP_HEADER.ts'
10
- import { KEEP_OPENED } from '#shared/KEEP_OPENED.ts'
11
10
  import { KEPT_HEADER } from '#shared/KEPT_HEADER.ts'
12
11
  import { MOUNT, mounted } from '#shared/mount.ts'
13
12
  import { NAVIGATION_HEADER } from '#shared/NAVIGATION_HEADER.ts'
@@ -42,7 +41,7 @@ async function dropOurs(keeping) {
42
41
  }
43
42
  `
44
43
 
45
- // 45.3 through 45.8, 45.13, 45.17 through 45.21, and 45.24.
44
+ // 45.3 through 45.8, 45.13, and 45.17 through 45.26.
46
45
  const SAVING = `
47
46
  self.addEventListener('install', (event) => {
48
47
  event.waitUntil(
@@ -149,31 +148,33 @@ function marked(saved) {
149
148
  })
150
149
  }
151
150
 
152
- // A page as the worker asks for it, whether a navigation or the worker keeping an open window's:
153
- // past the browser's cache, as a page kept, and with its redirects left to the page — one followed
154
- // would be saved under the address it was asked at, holding where it led (RULEBOOK 45.4, 45.21,
155
- // 45.23).
156
- function pageRequest(url, headers, keep) {
151
+ // A page no navigation is asking for, as the worker asks for it: past the browser's cache, as a page
152
+ // kept, and with its redirects left unfollowed — one followed would be saved under the address it was
153
+ // asked at, holding where it led (RULEBOOK 45.4, 45.21, 45.23).
154
+ function pageRequest(url, headers) {
157
155
  const asking = new Headers(headers)
158
- asking.set(KEEP, keep)
156
+ asking.set(KEEP, '1')
159
157
  return new Request(url, { headers: asking, redirect: 'manual', cache: 'no-cache' })
160
158
  }
161
159
 
162
- // 45.21 — PAST THE BROWSER'S OWN CACHE, which holds the same answers under the same headers and would
163
- // hand a revalidation the copy it is replacing. A navigation's request takes no init of its own, so it
164
- // is asked again by address, its redirects left to the page as a navigation's are — and 45.23, as a
165
- // page kept, which a script's fetch is not otherwise told from (RULEBOOK 41.29, 41.30), and as the
166
- // page the navigation opens where opened says it is, which then hydrates from what it seeded.
160
+ // 45.25 — THE NAVIGATION ASKED AS ITSELF: any init rebuilds a navigation's request as a script's
161
+ // fetch, and a proxy in front of the app tells the two apart — one sending a page load to sign-in
162
+ // answers a fetch 401. As itself its mode is navigate, the page that hydrates (RULEBOOK 41.29), its redirects
163
+ // left to the browser. Anything else is asked past the browser's own cache (RULEBOOK 45.21), which
164
+ // holds the same answers under the same headers and would hand a revalidation the copy it is replacing
165
+ // — and a page as one kept, which a script's fetch is not otherwise told from (RULEBOOK 45.23, 41.30).
167
166
  async function fromNetwork(event, page, opened) {
168
167
  const request = event.request
169
- const asked = page
170
- ? pageRequest(request.url, request.headers, opened ? OPENED : '1')
171
- : new Request(request, { cache: 'no-cache' })
168
+ const asked = opened
169
+ ? request
170
+ : page
171
+ ? pageRequest(request.url, request.headers)
172
+ : new Request(request, { cache: 'no-cache' })
172
173
  const since = drops
173
174
  const response = await fetch(asked)
174
175
  // A copy nothing reads is a tee holding every chunk, so one is cloned only to be saved.
175
176
  const copy = kept(response) ? response.clone() : undefined
176
- event.waitUntil(keep(request, copy, since, response.headers.has(PRINCIPAL)))
177
+ event.waitUntil(keep(request, response, copy, since))
177
178
  return response
178
179
  }
179
180
 
@@ -181,11 +182,11 @@ async function fromNetwork(event, page, opened) {
181
182
  // window's as the worker activates, and an address the router reached.
182
183
  function keepPage(url) {
183
184
  const since = drops
184
- return fetch(pageRequest(url, undefined, '1')).then((response) => {
185
+ return fetch(pageRequest(url)).then((response) => {
185
186
  // Nothing reads this answer, so one 45.3 or 45.4 turns away is let go here.
186
187
  const copy = kept(response) ? response : undefined
187
188
  if (copy === undefined) response.body?.cancel()
188
- return keep(new Request(url), copy, since, response.headers.has(PRINCIPAL))
189
+ return keep(new Request(url), response, copy, since)
189
190
  }, () => {})
190
191
  }
191
192
 
@@ -273,12 +274,23 @@ function dropSaved() {
273
274
  // Saves a copy 45.3 and 45.4 let through where no drop has come since its request was asked — after
274
275
  // the drop its own answer makes where that answer rotated the principal — and cancels one where one has.
275
276
  // One any caller may be answered is saved whenever it was asked, since no caller's cookies made it.
276
- async function keep(request, copy, since, rotated) {
277
- if (rotated) {
277
+ async function keep(request, response, copy, since) {
278
+ if (response.headers.has(PRINCIPAL)) {
278
279
  await dropSaved()
279
280
  since = drops
280
281
  }
281
- if (copy === undefined) return
282
+ // 45.26 — AN ANSWER NOT SAVED STILL REPLACES THE COPY: a redirect or a 401 is what the address
283
+ // answers now, and a copy left standing would answer every load in its stale-while-revalidate
284
+ // window in its place — a session lapsed behind a sign-in proxy never reaching the sign-in. A
285
+ // server error is no answer to replace a copy with.
286
+ if (copy === undefined) {
287
+ if (response.status >= 500) return
288
+ await Promise.all([
289
+ caches.open(SAVED).then((cache) => cache.delete(request)),
290
+ caches.open(SHARED).then((cache) => cache.delete(request)),
291
+ ])
292
+ return
293
+ }
282
294
  const shared = directivesOf(copy).has('public')
283
295
  if (!shared && since !== drops) return copy.body?.cancel()
284
296
  // An address whose answer went public or private since leaves no copy behind for savedCopy to
@@ -338,7 +350,6 @@ export function answerWorker(manifest: Manifest | undefined): Response {
338
350
  script = `${CACHES}const PRINCIPAL = ${JSON.stringify(PRINCIPAL_HEADER)}
339
351
  const KEPT = ${JSON.stringify(KEPT_HEADER)}
340
352
  const KEEP = ${JSON.stringify(KEEP_HEADER)}
341
- const OPENED = ${JSON.stringify(KEEP_OPENED)}
342
353
  const NAVIGATION = ${JSON.stringify(NAVIGATION_HEADER)}
343
354
  const REACHED = ${JSON.stringify(REACHED_MESSAGE)}
344
355
  // 45.4 — the types a stream 16.32 frames, which has no end to save.
@@ -10,35 +10,28 @@
10
10
  // another request's scope — `claudeCode` answers one from the bridge's — and the call is still this
11
11
  // asker's.
12
12
 
13
+ import type {
14
+ AssistantContent,
15
+ AssistantEvent,
16
+ AssistantMessage,
17
+ } from '#shared/AssistantEvent.ts'
18
+ import { IMAGE_TYPES } from '#shared/IMAGE_TYPES.ts'
13
19
  import { namedLogger } from '#shared/log.ts'
14
- import { refuse } from '#shared/refuse.ts'
20
+ import { mounted } from '#shared/mount.ts'
21
+ import { aborted, refuse } from '#shared/refuse.ts'
15
22
 
16
23
  import { type McpReply, McpSurface } from './generatedSurfaces.ts'
17
- import { IMAGE_TYPES } from './IMAGE_TYPES.ts'
18
24
  import { request, within } from './requestScope.ts'
25
+ import type { Listed } from './surfaceOf.ts'
19
26
 
20
- /** A piece of a turn: words, or an image as base64 with its type. */
21
- export type AssistantContent =
22
- | { type: 'text'; text: string }
23
- | { type: 'image'; data: string; mimeType: string }
24
-
25
- /** One turn of the transcript a caller sends: its words, or its pieces in order. */
26
- export type AssistantMessage = {
27
- role: 'user' | 'assistant'
28
- content: string | AssistantContent[]
29
- }
30
-
31
- /** What a conversation yields, the same from every provider. */
32
- export type AssistantEvent =
33
- | { type: 'text'; text: string }
27
+ /**
28
+ * An `AssistantEvent` as a `Provider` yields it: a tool call named, and its result in words, with what
29
+ * `assistant` reads off the app's own surface for both — the handler, and the value — still to come.
30
+ */
31
+ export type ProviderEvent =
32
+ | Exclude<AssistantEvent, { type: 'tool' | 'result' }>
34
33
  | { type: 'tool'; id: string; name: string }
35
- | { type: 'input'; id: string; input: unknown }
36
34
  | { type: 'result'; id: string; text: string; failed: boolean }
37
- | {
38
- type: 'done'
39
- stop: string
40
- usage: { input: number; output: number }
41
- }
42
35
 
43
36
  /** A tool as the app's MCP surface lists it. */
44
37
  export type ToolDefinition = {
@@ -68,11 +61,15 @@ export type Provider = {
68
61
  messages: (AssistantMessage & { content: AssistantContent[] })[],
69
62
  tools: Toolbox,
70
63
  limits: { maxTurns: number; signal: AbortSignal },
71
- ): AsyncGenerator<AssistantEvent>
64
+ ): AsyncGenerator<ProviderEvent>
72
65
  }
73
66
 
74
67
  /** What `assistant` takes beside a provider, the same for every one. */
75
- export type AssistantOptions = { mcp?: boolean; maxTurns?: number }
68
+ export type AssistantOptions = {
69
+ mcp?: boolean
70
+ maxTurns?: number
71
+ signal?: AbortSignal
72
+ }
76
73
 
77
74
  const MAX_TURNS = 10
78
75
  const ASSISTANT_LOG = namedLogger('abide:assistant')
@@ -118,19 +115,128 @@ export async function* assistant(
118
115
  )
119
116
  }
120
117
  }
121
- const tools = toolboxOver(
122
- options.mcp === true ? appSurface() : NO_SURFACE,
123
- askerOf(request()),
124
- )
125
- const signal = request().signal
126
- const events = provider.run(turns, tools, {
127
- maxTurns: options.maxTurns ?? MAX_TURNS,
118
+ const surface = options.mcp === true ? appSurface() : NO_SURFACE
119
+ const tools = toolboxOver(surface, askerOf(request()))
120
+ // 44.57 — the caller's signal and the request's are one: either ends the conversation.
121
+ const signal =
122
+ options.signal === undefined
123
+ ? request().signal
124
+ : AbortSignal.any([request().signal, options.signal])
125
+ const events = endedBy(
126
+ provider.run(turns, tools, {
127
+ maxTurns: options.maxTurns ?? MAX_TURNS,
128
+ signal,
129
+ }),
128
130
  signal,
129
- })
130
- // 28.7 — a closed namespace runs the provider's stream untouched.
131
+ )
132
+ // 28.7 — a closed namespace pays for no line.
131
133
  yield* ASSISTANT_LOG.enabled()
132
- ? traced(events, messages.length, signal)
133
- : events
134
+ ? traced(read(events, surface), messages.length, signal)
135
+ : read(events, surface)
136
+ }
137
+
138
+ /**
139
+ * `events` until `signal` aborts, then `aborted`. 44.57.
140
+ *
141
+ * THE ABORT DOES NOT WAIT FOR THE PROVIDER: one stuck in an await it was handed no signal for — a
142
+ * bridge that never answers — is asked to return and left to, and the conversation ends now. An
143
+ * abort read ahead of a throw, as `traced` reads it: a provider that throws as it unwinds was ended
144
+ * by the abort.
145
+ */
146
+ async function* endedBy(
147
+ events: AsyncGenerator<ProviderEvent>,
148
+ signal: AbortSignal,
149
+ ): AsyncGenerator<ProviderEvent> {
150
+ // A STEP RACES ITS OWN ABORT: one promise pending for the whole conversation would keep a
151
+ // reaction per event, none freed until it ends.
152
+ let rejectStep: (reason: unknown) => void = () => {}
153
+ const stop = (): void => rejectStep(aborted())
154
+ signal.addEventListener('abort', stop, { once: true })
155
+ try {
156
+ for (;;) {
157
+ if (signal.aborted) throw aborted()
158
+ const step = await Promise.race([
159
+ events.next(),
160
+ new Promise<never>((_, reject) => {
161
+ rejectStep = reject
162
+ }),
163
+ ])
164
+ if (step.done === true) return
165
+ yield step.value
166
+ }
167
+ } catch (thrown) {
168
+ throw signal.aborted ? aborted() : thrown
169
+ } finally {
170
+ signal.removeEventListener('abort', stop)
171
+ // A provider still awaiting returns once that await settles, and nobody waits for it.
172
+ events.return(undefined).catch(() => {})
173
+ }
174
+ }
175
+
176
+ /** A called tool's handler, and whether its answer is a value read as JSON (19.21). */
177
+ type Called = {
178
+ handler: { method: Listed['method']; address: string } | undefined
179
+ valued: boolean
180
+ }
181
+
182
+ const NOT_THE_APPS: Called = { handler: undefined, valued: false }
183
+
184
+ /**
185
+ * `events` with each call's handler, and each answer's value, read off `surface`. 44.48, 44.49.
186
+ *
187
+ * READ HERE RATHER THAN BY EACH PROVIDER, so a provider the app wrote yields the same events abide's
188
+ * do (44.2): a provider names the tool as the `Toolbox` lists it (44.50), and the rest is the surface's.
189
+ */
190
+ async function* read(
191
+ events: AsyncGenerator<ProviderEvent>,
192
+ surface: McpSurface,
193
+ ): AsyncGenerator<AssistantEvent> {
194
+ const calls = new Map<string, Called>()
195
+ for await (const event of events) {
196
+ if (event.type === 'tool') {
197
+ const tool = surface.tool(event.name)
198
+ const called: Called =
199
+ tool === undefined
200
+ ? NOT_THE_APPS
201
+ : {
202
+ // 16.10 — the address an `Rpc` carries, under the mount.
203
+ handler: {
204
+ method: tool.method,
205
+ address: mounted(tool.address),
206
+ },
207
+ valued: tool.result !== undefined,
208
+ }
209
+ calls.set(event.id, called)
210
+ yield {
211
+ type: 'tool',
212
+ id: event.id,
213
+ name: event.name,
214
+ handler: called.handler,
215
+ }
216
+ } else if (event.type === 'result') {
217
+ const called = calls.get(event.id) ?? NOT_THE_APPS
218
+ yield {
219
+ type: 'result',
220
+ id: event.id,
221
+ text: event.text,
222
+ failed: event.failed,
223
+ handler: called.handler,
224
+ value:
225
+ called.valued && !event.failed
226
+ ? valueOf(event.text)
227
+ : undefined,
228
+ }
229
+ } else yield event
230
+ }
231
+ }
232
+
233
+ /** An answer's words as the JSON value they spell, or `undefined` where they spell none. */
234
+ function valueOf(text: string): unknown {
235
+ try {
236
+ return JSON.parse(text)
237
+ } catch {
238
+ return undefined
239
+ }
134
240
  }
135
241
 
136
242
  /**
@@ -9,15 +9,16 @@ import type { AppPackage } from './config.ts'
9
9
  * What `start` and `dev` print once the app is listening: the app, and every address it answers at.
10
10
  *
11
11
  * THE LOCAL ADDRESS IS THE FIRST URL PRINTED, because a harness reading the output takes the first
12
- * `http://` it finds (`addressOf` in the tests). The network rows follow it unconditionally: neither
13
- * `listen` nor `serveDev` names a hostname, and `Bun.serve` given none binds every interface —
14
- * whatever `url` says.
12
+ * `http://` it finds (`addressOf` in the tests). 38.58, 38.136 — the network rows follow it where the
13
+ * listener binds every interface, which `Bun.serve` given no `hostname` does whatever `url` says, and
14
+ * are left out where `hostname` names one interface.
15
15
  */
16
16
  export function banner(
17
17
  host: string,
18
18
  command: string,
19
19
  identity: AppPackage,
20
20
  server: Bun.Server<unknown>,
21
+ hostname: string | undefined,
21
22
  ): string {
22
23
  const color = useColor()
23
24
  const paint = (code: string, text: string): string =>
@@ -29,8 +30,11 @@ export function banner(
29
30
  ? ''
30
31
  : ` ${paint('2', `${identity.name}${identity.version === undefined ? '' : ` ${identity.version}`}`)}`
31
32
  let text = `\n ${title}${named}\n\n ${paint('32', '➜')} ${paint('1', 'Local:')} ${paint('36', local)}\n`
32
- for (const address of networkAddresses())
33
- text += ` ${paint('32', '➜')} ${paint('1', 'Network:')} ${paint('36', `${server.url.protocol}//${address}:${server.port}/`)}\n`
33
+ const everyInterface =
34
+ hostname === undefined || hostname === '0.0.0.0' || hostname === '::'
35
+ if (everyInterface)
36
+ for (const address of networkAddresses())
37
+ text += ` ${paint('32', '➜')} ${paint('1', 'Network:')} ${paint('36', `${server.url.protocol}//${address}:${server.port}/`)}\n`
34
38
  return text
35
39
  }
36
40
 
@@ -22,7 +22,7 @@ import { refuse } from '#shared/refuse.ts'
22
22
  import { answered } from './answerFailure.ts'
23
23
  import { bodyWithin } from './answerRpc.ts'
24
24
  import { framed } from './answerValue.ts'
25
- import type { AssistantEvent } from './assistant.ts'
25
+ import type { ProviderEvent } from './assistant.ts'
26
26
  import { Inbox } from './Inbox.ts'
27
27
  import { randomToken } from './randomToken.ts'
28
28
  import { RESUME_MILLISECONDS } from './RESUME_MILLISECONDS.ts'
@@ -126,7 +126,7 @@ export async function* runTurn({
126
126
  /** Never rejects: its failure is a reply too. */
127
127
  answer: (method: string, params: unknown) => Promise<unknown>
128
128
  signal: AbortSignal
129
- }): AsyncGenerator<AssistantEvent> {
129
+ }): AsyncGenerator<ProviderEvent> {
130
130
  const bridge = BY_KEY.get(pairingKey(caller, signedIn))
131
131
  // 44.13, 44.33 — nothing paired by this sign-in is the page's cue to show the command.
132
132
  if (bridge === undefined)
@@ -146,7 +146,7 @@ export async function* runTurn({
146
146
  signal.addEventListener('abort', cancel)
147
147
  try {
148
148
  for await (const item of inbox) {
149
- if (item.kind === 'event') yield item.event as AssistantEvent
149
+ if (item.kind === 'event') yield item.event as ProviderEvent
150
150
  else if (item.kind === 'mcp')
151
151
  void answer(item.method, item.params).then((reply) => {
152
152
  bridge.frames.push({
@@ -46,6 +46,11 @@ export function callerOrMint(): string {
46
46
  return callerOf() ?? mintCaller(scope)
47
47
  }
48
48
 
49
+ /** This request's caller, given rather than read off its cookie, with no cookie written. 42.21. */
50
+ export function presumeCaller(caller: string): void {
51
+ within('App caller').caller = caller
52
+ }
53
+
49
54
  /**
50
55
  * Mints a fresh caller for this request, sets its cookie, and returns it.
51
56
  *
@@ -8,17 +8,15 @@
8
8
  // directory, settings — is not an option here at all, and its built-in `tools` are a request the visitor
9
9
  // grants or does not: the bridge decides (44.15, 44.16, 44.29).
10
10
 
11
+ import type {
12
+ AssistantContent,
13
+ AssistantMessage,
14
+ } from '#shared/AssistantEvent.ts'
11
15
  import { BRIDGE_SCHEME } from '#shared/BRIDGE_SCHEME.ts'
12
16
  import { mounted } from '#shared/mount.ts'
13
17
  import { refuse } from '#shared/refuse.ts'
14
18
 
15
- import {
16
- type AssistantContent,
17
- type AssistantMessage,
18
- blocksOf,
19
- forwardOf,
20
- type Provider,
21
- } from './assistant.ts'
19
+ import { blocksOf, forwardOf, type Provider } from './assistant.ts'
22
20
  import { BRIDGE_VERSION } from './BRIDGE_VERSION.ts'
23
21
  import { mintCode, pairingStatus, runTurn } from './bridgeRelay.ts'
24
22
  import { callerOf } from './callerOf.ts'
@@ -45,12 +43,13 @@ type PairingOptions = Pick<ClaudeCodeConfig, 'tools'>
45
43
 
46
44
  /**
47
45
  * Whether this browser's bridge is connected, and the command and the link that pair one where it is
48
- * not.
46
+ * not, with the command that installs the app the link opens.
49
47
  */
50
48
  export type BridgeStatus = {
51
49
  paired: boolean
52
50
  command: string | undefined
53
51
  link: string | undefined
52
+ install: string | undefined
54
53
  }
55
54
 
56
55
  /** The visitor's own Claude Code, reached through the bridge paired to their browser. */
@@ -123,17 +122,21 @@ function askingCaller(): string {
123
122
  function bridgeCommand(options: PairingOptions = {}): {
124
123
  command: string
125
124
  link: string
125
+ install: string
126
126
  expires: number
127
127
  } {
128
128
  const { code, expires } = mintCode(askingCaller(), signedInAs())
129
129
  return { ...commandFor(code, options), expires }
130
130
  }
131
131
 
132
- /** The command and the link that pair a bridge by `code`, granting `tools`. */
132
+ /**
133
+ * The command and the link that pair a bridge by `code`, granting `tools`, and the command that
134
+ * installs the app the link opens — of the release the command runs, 44.56.
135
+ */
133
136
  function commandFor(
134
137
  code: string,
135
138
  { tools }: PairingOptions,
136
- ): { command: string; link: string } {
139
+ ): { command: string; link: string; install: string } {
137
140
  const site = `${appOrigin() ?? new URL(request().url).origin}${mounted('')}`
138
141
  const granted = tools?.length ? tools.join(',') : undefined
139
142
  const query = new URLSearchParams({ site, code, version: BRIDGE_VERSION })
@@ -142,6 +145,7 @@ function commandFor(
142
145
  return {
143
146
  command: `bunx @abide/bridge@${BRIDGE_VERSION} ${site} ${code}${grant}`,
144
147
  link: `${BRIDGE_SCHEME}://pair?${query}`,
148
+ install: `bunx @abide/bridge@${BRIDGE_VERSION} install`,
145
149
  }
146
150
  }
147
151
 
@@ -159,7 +163,12 @@ async function* bridge(
159
163
  request().signal,
160
164
  ))
161
165
  yield code === undefined
162
- ? { paired: true, command: undefined, link: undefined }
166
+ ? {
167
+ paired: true,
168
+ command: undefined,
169
+ link: undefined,
170
+ install: undefined,
171
+ }
163
172
  : { paired: false, ...commandFor(code, options) }
164
173
  }
165
174
 
@@ -245,10 +245,11 @@ function usage(host: Host, commands: Row[]): string {
245
245
  text += ` ${command.padEnd(width)} ${meaning}\n`
246
246
  if (!sourced)
247
247
  return `${text}
248
- --port the listen port the app is served on
249
- --url the running app the shell, call, watch, openapi and mcp talk to
250
- --method the method call sends, where the handler answers more than one
251
- --version the version of this executable's app
248
+ --port the listen port the app is served on
249
+ --hostname the interface it listens on — every one where none is named
250
+ --url the running app the shell, call, watch, openapi and mcp talk to
251
+ --method the method call sends, where the handler answers more than one
252
+ --version the version of this executable's app
252
253
  `
253
254
  text += `
254
255
  --root the app's source directory, where its pages sit —
@@ -256,6 +257,7 @@ function usage(host: Host, commands: Row[]): string {
256
257
  --out where the build goes, beside the app by default; the file compile and
257
258
  bundle write — named after the app, where you stand
258
259
  --port the listen port for dev, start and the shell's app
260
+ --hostname the interface they listen on — every one where none is named
259
261
  --url the running app the shell, call, watch, openapi and mcp talk to
260
262
  --target the platform compile builds for — linux-x64, darwin-arm64, windows-x64…
261
263
  --platforms several of them, comma-separated, one executable each
@@ -291,6 +293,8 @@ function parseFlags(argv: string[]) {
291
293
  // `PORT` and then falls back to 3000 — `abide dev` scanning on from there, 38.63 — and
292
294
  // a default written here would take that away from every host that sets it.
293
295
  port: { type: 'string' },
296
+ // NO DEFAULT EITHER: `Bun.serve` given none binds every interface (38.136).
297
+ hostname: { type: 'string' },
294
298
  url: { type: 'string' },
295
299
  method: { type: 'string' },
296
300
  target: { type: 'string' },
@@ -321,14 +325,17 @@ async function served(
321
325
  process.env.NODE_ENV ??= 'production'
322
326
  const booted = await booting(host, app, given)
323
327
  if (typeof booted === 'number') return booted
324
- const port = given.values.port
325
- await booted.listen(port === undefined ? {} : { port: Number(port) })
328
+ const { port, hostname } = given.values
329
+ await booted.listen({
330
+ ...(port === undefined ? {} : { port: Number(port) }),
331
+ ...(hostname === undefined ? {} : { hostname }),
332
+ })
326
333
  stopOnSignalOrFault(booted)
327
334
  const server = LISTENING.server as Bun.Server<unknown>
328
335
  return {
329
336
  app: booted,
330
337
  url: withoutTrailingSlash(server.url.href),
331
- heading: banner(host.name, 'start', app.package, server),
338
+ heading: banner(host.name, 'start', app.package, server, hostname),
332
339
  }
333
340
  }
334
341