@abide/abide 0.113.0 → 0.114.1

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @abide/abide
2
2
 
3
+ ## 0.114.1
4
+
5
+ ### Patch Changes
6
+
7
+ - ea48b1b: a server stack frame names the .abide column it was thrown at — bun 1.4.4 counts a call site's column from 1 ([`81bcd9b`](https://github.com/briancray/abide/commit/81bcd9b5dab56c14c6b9262361d0dfdb068fb3f5))
8
+
9
+ ## 0.114.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 595ccb1: a Claude Code pairing belongs to the sign-in that minted its code — another sign-in at the browser is refused 409 and offered its own, and an open bridge status follows a sign-in or sign-out ([`7036ed1`](https://github.com/briancray/abide/commit/7036ed181763c74eaf72ad4550e05c9d63a2d6ab))
14
+
3
15
  ## 0.113.0
4
16
 
5
17
  ### Minor Changes
package/docs/REGISTRY.md CHANGED
@@ -338,10 +338,10 @@ type Gated<Gate, Accepted> = [Schema<Accepted>] extends [Gate] ? never : Failed<
338
338
 
339
339
  | Name | Signature | Meaning | Rules |
340
340
  | --- | --- | --- | --- |
341
- | `claudeCode` | `(config?: ClaudeCodeConfig) => Provider` | The visitor's own Claude Code, reached through the bridge paired with their browser, as a provider. | 44.5, 44.13, 44.17, 44.18, 44.19, 44.24, 44.25, 44.27, 44.30 |
341
+ | `claudeCode` | `(config?: ClaudeCodeConfig) => Provider` | The visitor's own Claude Code, reached through the bridge paired with their browser, as a provider. | 44.5, 44.13, 44.17, 44.18, 44.19, 44.24, 44.25, 44.27, 44.30, 44.33 |
342
342
  | `ClaudeCodeConfig` | `{ model?: string; fallbackModel?: string; systemPrompt?: string; appendSystemPrompt?: string; effort?: 'low' \| 'medium' \| 'high' \| 'xhigh' \| 'max'; maxBudgetUsd?: number; betas?: string[]; disallowedTools?: string[]; tools?: string[] }` | The Claude Code options a site may set for a turn, and the built-in tools it asks the visitor for. | 44.16, 44.29 |
343
- | `claudeCode.bridgeCommand` | `(options?: { tools?: string[] }) => { command: string; expires: number }` | The command that pairs a bridge with this browser, granting the built-in tools it is given. | 44.14, 44.32 |
344
- | `claudeCode.bridge` | `(options?: { tools?: string[] }) => AsyncGenerator<BridgeStatus>` | This browser's pairing, as it changes. | 44.20, 44.22, 44.32 |
343
+ | `claudeCode.bridgeCommand` | `(options?: { tools?: string[] }) => { command: string; expires: number }` | The command that pairs a bridge with this browser, granting the built-in tools it is given. | 44.14, 44.32, 44.33 |
344
+ | `claudeCode.bridge` | `(options?: { tools?: string[] }) => AsyncGenerator<BridgeStatus>` | This browser's pairing, as it changes. | 44.20, 44.22, 44.32, 44.33, 44.34 |
345
345
  | `BridgeStatus` | `{ paired: boolean; command: string \| undefined }` | Whether a bridge is paired, and the command that pairs one where none is. | 44.20 |
346
346
  | `/__abide/bridge` | `GET`, `POST` | Where a bridge pairs, and is relayed its turns. | 36.7, 44.14, 44.15, 44.19, 44.23, 44.26, 16.57, 16.78 |
347
347
 
package/docs/RULEBOOK.md CHANGED
@@ -3191,6 +3191,14 @@ and naming it `unfinished` where it ended without a `done`.
3191
3191
  44.32 A command `claudeCode.bridgeCommand` or `claudeCode.bridge` mints with `tools` MUST grant
3192
3192
  those built-in tools after its `--` as `--tools`, and one minted without `tools` MUST carry no `--`.
3193
3193
 
3194
+ 44.33 A pairing MUST be to the claims the principal was sealed with (26.11), or to none, where its
3195
+ code was minted, and `claudeCode` and `claudeCode.bridge` MUST read it as absent to a request from
3196
+ that browser carrying any other.
3197
+
3198
+ 44.34 A `claudeCode.bridge` stream MUST yield the pairing of whoever a `principal.set` or a
3199
+ `principal.clear` made at its browser since it opened signs in, at the caller 26.9 rotates it to,
3200
+ and the command it yields MUST pair that one.
3201
+
3194
3202
  # 45. Offline
3195
3203
 
3196
3204
  45.1 Where `ABIDE_OFFLINE` is on, a page `abide build` built MUST register the worker
package/docs/abide.md CHANGED
@@ -5644,6 +5644,8 @@ Pass the same `tools` to `claudeCode.bridge` or `claudeCode.bridgeCommand` and t
5644
5644
 
5645
5645
  The visitor pairs once per visit by running a command, which dials out to the site. Asking with nothing paired is refused 409.
5646
5646
 
5647
+ The tools the conversation calls run as the visitor, on the sign-in their ask carried, so a tool reads `principal` as any handler does. The pairing belongs to that sign-in too. If someone else signs in at the same browser, their asks are refused 409 and the status offers them a command of their own, so nobody's turn runs on another member's Claude Code.
5648
+
5647
5649
  | Name | What it does for the CRM |
5648
5650
  | --- | --- |
5649
5651
  | `claudeCode.bridge` | Streams this browser's pairing: unpaired with the command to run, then paired. |
@@ -7860,9 +7862,9 @@ Generated surfaces: what is written in place rather than imported.
7860
7862
 
7861
7863
  | Name | Signature | Meaning | Rules |
7862
7864
  | --- | --- | --- | --- |
7863
- | `claudeCode` | `(config?: ClaudeCodeConfig) => Provider` | The visitor's own Claude Code, reached through the bridge paired with their browser, as a provider. | [44.5](/rulebook#44.5), [44.13](/rulebook#44.13), [44.17](/rulebook#44.17), [44.18](/rulebook#44.18), [44.19](/rulebook#44.19), [44.24](/rulebook#44.24), [44.25](/rulebook#44.25), [44.27](/rulebook#44.27), [44.30](/rulebook#44.30) |
7864
- | `claudeCode.bridgeCommand` | `(options?: { tools?: string[] }) => { command: string; expires: number }` | The command that pairs a bridge with this browser, granting the built-in tools it is given. | [44.14](/rulebook#44.14), [44.32](/rulebook#44.32) |
7865
- | `claudeCode.bridge` | `(options?: { tools?: string[] }) => AsyncGenerator<BridgeStatus>` | This browser's pairing, as it changes. | [44.20](/rulebook#44.20), [44.22](/rulebook#44.22), [44.32](/rulebook#44.32) |
7865
+ | `claudeCode` | `(config?: ClaudeCodeConfig) => Provider` | The visitor's own Claude Code, reached through the bridge paired with their browser, as a provider. | [44.5](/rulebook#44.5), [44.13](/rulebook#44.13), [44.17](/rulebook#44.17), [44.18](/rulebook#44.18), [44.19](/rulebook#44.19), [44.24](/rulebook#44.24), [44.25](/rulebook#44.25), [44.27](/rulebook#44.27), [44.30](/rulebook#44.30), [44.33](/rulebook#44.33) |
7866
+ | `claudeCode.bridgeCommand` | `(options?: { tools?: string[] }) => { command: string; expires: number }` | The command that pairs a bridge with this browser, granting the built-in tools it is given. | [44.14](/rulebook#44.14), [44.32](/rulebook#44.32), [44.33](/rulebook#44.33) |
7867
+ | `claudeCode.bridge` | `(options?: { tools?: string[] }) => AsyncGenerator<BridgeStatus>` | This browser's pairing, as it changes. | [44.20](/rulebook#44.20), [44.22](/rulebook#44.22), [44.32](/rulebook#44.32), [44.33](/rulebook#44.33), [44.34](/rulebook#44.34) |
7866
7868
  | `/__abide/bridge` | `GET`, `POST` | Where a bridge pairs, and is relayed its turns. | [36.7](/rulebook#36.7), [44.14](/rulebook#44.14), [44.15](/rulebook#44.15), [44.19](/rulebook#44.19), [44.23](/rulebook#44.23), [44.26](/rulebook#44.26), [16.57](/rulebook#16.57), [16.78](/rulebook#16.78) |
7867
7869
 
7868
7870
  ## Taught in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.113.0",
3
+ "version": "0.114.1",
4
4
  "type": "module",
5
5
  "description": "Isomorphic type-safe framework for reactive async interfaces for humans and machines",
6
6
  "license": "MIT",
@@ -25,7 +25,7 @@
25
25
  "cli"
26
26
  ],
27
27
  "engines": {
28
- "bun": ">=1.4.3"
28
+ "bun": ">=1.4.4"
29
29
  },
30
30
  "publishConfig": {
31
31
  "access": "public",
@@ -8,9 +8,10 @@
8
8
  // POST /__abide/bridge what the bridge reports, one frame per line: a turn's events, the
9
9
  // MCP requests its Claude Code made, the turn's end or failure.
10
10
  //
11
- // A PAIRING IS TO A BROWSER — the `abide-caller` its page carries (26.5) — for as long as the bridge's
12
- // stream is open, or resumable. HELD IN THIS PROCESS: an app served by several needs a visitor's requests to reach
13
- // the one its bridge dialed.
11
+ // A PAIRING IS TO A BROWSER — the `abide-caller` its page carries (26.5) — and to whoever was signed
12
+ // in there when its code was minted (44.33), for as long as the bridge's stream is open, or
13
+ // resumable. HELD IN THIS PROCESS: an app served by several needs a visitor's requests to reach the
14
+ // one its bridge dialed.
14
15
 
15
16
  import { isFailed } from '#shared/Failed.ts'
16
17
  import { refuse } from '#shared/refuse.ts'
@@ -35,7 +36,8 @@ export type TurnItem = {
35
36
 
36
37
  /** One paired bridge: what it is sent, and the turns it is answering. */
37
38
  export type Bridge = {
38
- caller: string
39
+ /** The browser and the sign-in it is paired to (`pairingKey`). 44.33. */
40
+ key: string
39
41
  token: string
40
42
  frames: Inbox<unknown>
41
43
  turns: Map<string, Inbox<TurnItem>>
@@ -53,40 +55,68 @@ const IDLE_GRACE = 30_000
53
55
  /** How long a pairing whose stream dropped waits to be resumed. 44.26. */
54
56
  export const RESUME_MILLISECONDS = 60_000
55
57
 
56
- const CODES = new Map<string, { caller: string; expires: number }>()
57
- const BY_CALLER = new Map<string, Bridge>()
58
+ const CODES = new Map<string, { key: string; expires: number }>()
59
+ const BY_KEY = new Map<string, Bridge>()
58
60
  const BY_TOKEN = new Map<string, Bridge>()
59
61
  const WATCHERS = new Map<string, Set<() => void>>()
62
+ const FOLLOWERS = new Map<
63
+ string,
64
+ Set<(caller: string, signedIn: string) => void>
65
+ >()
66
+
67
+ /**
68
+ * What a pairing is filed under: the browser, and who was signed in there (`signedInAs`).
69
+ *
70
+ * 44.33 — ONE PER SIGN-IN, so pairing at a browser replaces only that sign-in's own bridge, and a
71
+ * page watching for one sign-in neither reaches another's nor keeps it from going idle (44.22).
72
+ */
73
+ function pairingKey(caller: string, signedIn: string): string {
74
+ return `${caller}\n${signedIn}`
75
+ }
60
76
 
61
77
  // 44.19 — the relay answers once the app has handed out a pairing command, and never before.
62
78
  let minted = false
63
79
 
64
- /** A single-use pairing code for `caller`, and when it stops standing. 44.14. */
65
- export function mintCode(caller: string): { code: string; expires: number } {
80
+ /** A single-use pairing code for `caller` signed in as `signedIn`, and when it stops standing. 44.14. */
81
+ export function mintCode(
82
+ caller: string,
83
+ signedIn: string,
84
+ ): { code: string; expires: number } {
66
85
  const now = Date.now()
67
86
  for (const [code, pairing] of CODES)
68
87
  if (pairing.expires <= now) CODES.delete(code)
69
88
  const code = randomToken(12, 'base64url')
70
89
  const expires = now + CODE_LIFETIME
71
- CODES.set(code, { caller, expires })
90
+ CODES.set(code, { key: pairingKey(caller, signedIn), expires })
72
91
  minted = true
73
92
  return { code, expires }
74
93
  }
75
94
 
76
- /** The bridge paired to `caller`, if its stream is open. */
77
- export function pairedBridge(caller: string): Bridge | undefined {
78
- return BY_CALLER.get(caller)
95
+ /** The bridge paired to `caller` signed in as `signedIn`, if its stream is open. 44.33. */
96
+ export function pairedBridge(
97
+ caller: string,
98
+ signedIn: string,
99
+ ): Bridge | undefined {
100
+ return BY_KEY.get(pairingKey(caller, signedIn))
79
101
  }
80
102
 
81
- /** Registers `wake` to run whenever `caller`'s pairing changes; the returned function undoes it. */
82
- export function watchPairing(caller: string, wake: () => void): () => void {
83
- let watchers = WATCHERS.get(caller)
103
+ /**
104
+ * Registers `wake` to run whenever the pairing of `caller` signed in as `signedIn` changes; the
105
+ * returned function undoes it.
106
+ */
107
+ export function watchPairing(
108
+ caller: string,
109
+ signedIn: string,
110
+ wake: () => void,
111
+ ): () => void {
112
+ const key = pairingKey(caller, signedIn)
113
+ let watchers = WATCHERS.get(key)
84
114
  if (watchers === undefined) {
85
115
  watchers = new Set()
86
- WATCHERS.set(caller, watchers)
116
+ WATCHERS.set(key, watchers)
87
117
  }
88
118
  watchers.add(wake)
89
- const bridge = BY_CALLER.get(caller)
119
+ const bridge = BY_KEY.get(key)
90
120
  if (bridge !== undefined) {
91
121
  bridge.watched = true
92
122
  if (bridge.idle !== undefined) clearTimeout(bridge.idle)
@@ -95,8 +125,8 @@ export function watchPairing(caller: string, wake: () => void): () => void {
95
125
  return () => {
96
126
  watchers.delete(wake)
97
127
  if (watchers.size > 0) return
98
- WATCHERS.delete(caller)
99
- armIdle(caller)
128
+ WATCHERS.delete(key)
129
+ armIdle(key)
100
130
  }
101
131
  }
102
132
 
@@ -114,15 +144,15 @@ export function openTurn(bridge: Bridge): {
114
144
  inbox,
115
145
  close: () => {
116
146
  bridge.turns.delete(id)
117
- if (!WATCHERS.has(bridge.caller)) armIdle(bridge.caller)
147
+ if (!WATCHERS.has(bridge.key)) armIdle(bridge.key)
118
148
  },
119
149
  }
120
150
  }
121
151
 
122
152
  // 44.22 — A BRIDGE A PAGE WATCHED, THAT NO PAGE WATCHES NOW AND THAT IS ANSWERING NO TURN, is sent
123
153
  // away once the grace runs.
124
- function armIdle(caller: string): void {
125
- const bridge = BY_CALLER.get(caller)
154
+ function armIdle(key: string): void {
155
+ const bridge = BY_KEY.get(key)
126
156
  if (
127
157
  bridge === undefined ||
128
158
  !bridge.watched ||
@@ -136,8 +166,40 @@ function armIdle(caller: string): void {
136
166
  }, IDLE_GRACE)
137
167
  }
138
168
 
139
- function wakeWatchers(caller: string): void {
140
- for (const wake of WATCHERS.get(caller) ?? []) wake()
169
+ function wakeWatchers(key: string): void {
170
+ for (const wake of WATCHERS.get(key) ?? []) wake()
171
+ }
172
+
173
+ /**
174
+ * Registers `follow` to hear who signs in at `caller` from here on, and the caller a sign-out rotates
175
+ * it to (26.9); the returned function undoes it.
176
+ *
177
+ * 44.34 — A STATUS STREAM OPENED BEFORE A SIGN-IN carries the cookies it was opened with, so it hears
178
+ * of the new one here rather than off its own request.
179
+ */
180
+ export function followSignIn(
181
+ caller: string,
182
+ follow: (caller: string, signedIn: string) => void,
183
+ ): () => void {
184
+ let followers = FOLLOWERS.get(caller)
185
+ if (followers === undefined) {
186
+ followers = new Set()
187
+ FOLLOWERS.set(caller, followers)
188
+ }
189
+ followers.add(follow)
190
+ return () => {
191
+ followers.delete(follow)
192
+ if (followers.size === 0) FOLLOWERS.delete(caller)
193
+ }
194
+ }
195
+
196
+ /** Tells every status stream at `caller` that the browser is `now`, signed in as `signedIn`. 44.34. */
197
+ export function signedInAt(
198
+ caller: string,
199
+ now: string,
200
+ signedIn: string,
201
+ ): void {
202
+ for (const follow of FOLLOWERS.get(caller) ?? []) follow(now, signedIn)
141
203
  }
142
204
 
143
205
  function item(
@@ -161,7 +223,7 @@ function item(
161
223
  }
162
224
 
163
225
  function dropBridge(bridge: Bridge): void {
164
- if (BY_CALLER.get(bridge.caller) === bridge) BY_CALLER.delete(bridge.caller)
226
+ if (BY_KEY.get(bridge.key) === bridge) BY_KEY.delete(bridge.key)
165
227
  BY_TOKEN.delete(bridge.token)
166
228
  if (bridge.idle !== undefined) clearTimeout(bridge.idle)
167
229
  bridge.idle = undefined
@@ -175,7 +237,7 @@ function dropBridge(bridge: Bridge): void {
175
237
  }
176
238
  bridge.turns.clear()
177
239
  bridge.frames.close()
178
- wakeWatchers(bridge.caller)
240
+ wakeWatchers(bridge.key)
179
241
  }
180
242
 
181
243
  /** Answers `/__abide/bridge`, or `undefined` until a pairing code has been minted. 44.19. */
@@ -199,24 +261,24 @@ function pair(request: Request): Response {
199
261
  'This pairing code was not issued here, or it has been used, or it has expired.',
200
262
  ),
201
263
  )
202
- const standing = BY_CALLER.get(pairing.caller)
264
+ const standing = BY_KEY.get(pairing.key)
203
265
  if (standing !== undefined) {
204
266
  standing.frames.push({ type: 'bye' })
205
267
  dropBridge(standing)
206
268
  }
207
269
  const bridge: Bridge = {
208
- caller: pairing.caller,
270
+ key: pairing.key,
209
271
  token: randomToken(24, 'base64url'),
210
272
  frames: new Inbox(),
211
273
  turns: new Map(),
212
- watched: WATCHERS.has(pairing.caller),
274
+ watched: WATCHERS.has(pairing.key),
213
275
  idle: undefined,
214
276
  held: undefined,
215
277
  }
216
- BY_CALLER.set(bridge.caller, bridge)
278
+ BY_KEY.set(bridge.key, bridge)
217
279
  BY_TOKEN.set(bridge.token, bridge)
218
280
  bridge.frames.push({ type: 'paired', token: bridge.token })
219
- wakeWatchers(bridge.caller)
281
+ wakeWatchers(bridge.key)
220
282
  return attach(bridge, request)
221
283
  }
222
284
 
@@ -18,6 +18,7 @@ import {
18
18
  } from './assistant.ts'
19
19
  import { BRIDGE_VERSION } from './BRIDGE_VERSION.ts'
20
20
  import {
21
+ followSignIn,
21
22
  mintCode,
22
23
  openTurn,
23
24
  pairedBridge,
@@ -27,6 +28,7 @@ import { callerOf } from './callerOf.ts'
27
28
  import { appOrigin } from './config.ts'
28
29
  import { mcpFailure } from './generatedSurfaces.ts'
29
30
  import { Inbox } from './Inbox.ts'
31
+ import { signedInAs } from './principal.ts'
30
32
  import { request } from './requestScope.ts'
31
33
 
32
34
  /** The Claude Code options a site may set for a turn, and the built-in tools it asks the visitor for. */
@@ -52,12 +54,12 @@ export type BridgeStatus = { paired: boolean; command: string | undefined }
52
54
  function claudeCodeProvider(config: ClaudeCodeConfig = {}): Provider {
53
55
  return {
54
56
  async *run(messages, tools, { maxTurns, signal }) {
55
- const bridge = pairedBridge(askingCaller())
56
- // 44.13 — nothing paired is the page's cue to show the command.
57
+ const bridge = pairedBridge(askingCaller(), signedInAs())
58
+ // 44.13, 44.33 — nothing paired by this sign-in is the page's cue to show the command.
57
59
  if (bridge === undefined)
58
60
  throw refuse(
59
61
  409,
60
- 'No Claude Code is paired with this browser. claudeCode.bridgeCommand() is the command that pairs one.',
62
+ 'No Claude Code is paired with this browser as the one signed in. claudeCode.bridgeCommand() is the command that pairs one.',
61
63
  )
62
64
  const turn = openTurn(bridge)
63
65
  bridge.frames.push({
@@ -130,11 +132,20 @@ function askingCaller(): string {
130
132
  * The command that pairs a bridge with this browser, and when its code stops standing. 44.14. The
131
133
  * `tools` it is given are written after its `--`, so the visitor running it grants them (44.29, 44.32).
132
134
  */
133
- function bridgeCommand({ tools }: PairingOptions = {}): {
135
+ function bridgeCommand(options: PairingOptions = {}): {
134
136
  command: string
135
137
  expires: number
136
138
  } {
137
- const { code, expires } = mintCode(askingCaller())
139
+ return commandFor(askingCaller(), signedInAs(), options)
140
+ }
141
+
142
+ /** The command pairing a bridge with `caller` signed in as `signedIn` (44.33). */
143
+ function commandFor(
144
+ caller: string,
145
+ signedIn: string,
146
+ { tools }: PairingOptions,
147
+ ): { command: string; expires: number } {
148
+ const { code, expires } = mintCode(caller, signedIn)
138
149
  const site = `${appOrigin() ?? new URL(request().url).origin}${mounted('')}`
139
150
  const grant = tools?.length ? ` -- --tools ${tools.join(',')}` : ''
140
151
  return {
@@ -146,29 +157,50 @@ function bridgeCommand({ tools }: PairingOptions = {}): {
146
157
  /**
147
158
  * This browser's pairing as it changes: unpaired with the command to run, then paired the moment a
148
159
  * bridge connects, and unpaired again if it goes. 44.20.
160
+ *
161
+ * 44.34 — IT FOLLOWS WHOEVER SIGNS IN AT THE BROWSER, though its own request carries the cookies it
162
+ * opened with: a sign-in or a sign-out made since is the pairing it watches from then on, and the
163
+ * command it offers is that sign-in's.
149
164
  */
150
165
  async function* bridge(
151
166
  options: PairingOptions = {},
152
167
  ): AsyncGenerator<BridgeStatus> {
153
- const caller = askingCaller()
168
+ let caller = askingCaller()
169
+ let signedIn = signedInAs()
154
170
  const signal = request().signal
155
171
  // A change landing while the loop was not waiting — paused at its `yield` — stays queued, so it
156
172
  // looks again; a look that finds nothing new yields nothing.
157
173
  const changes = new Inbox<undefined>()
158
174
  const waiting = changes[Symbol.asyncIterator]()
159
175
  const rouse = (): void => changes.push(undefined)
160
- const unwatch = watchPairing(caller, rouse)
176
+ let watchingAs = ''
177
+ let unwatch = (): void => {}
178
+ let unfollow = (): void => {}
179
+ const follow = (now: string, next: string): void => {
180
+ caller = now
181
+ signedIn = next
182
+ rouse()
183
+ }
161
184
  signal.addEventListener('abort', rouse)
162
185
  let pairing: { command: string; expires: number } | undefined
163
186
  let said = ''
164
187
  try {
165
188
  while (!signal.aborted) {
166
- const paired = pairedBridge(caller) !== undefined
189
+ const watching = `${caller} ${signedIn}`
190
+ if (watching !== watchingAs) {
191
+ unwatch()
192
+ unfollow()
193
+ unwatch = watchPairing(caller, signedIn, rouse)
194
+ unfollow = followSignIn(caller, follow)
195
+ watchingAs = watching
196
+ pairing = undefined
197
+ }
198
+ const paired = pairedBridge(caller, signedIn) !== undefined
167
199
  if (
168
200
  !paired &&
169
201
  (pairing === undefined || pairing.expires <= Date.now())
170
202
  )
171
- pairing = bridgeCommand(options)
203
+ pairing = commandFor(caller, signedIn, options)
172
204
  const status: BridgeStatus = {
173
205
  paired,
174
206
  command: paired ? undefined : pairing?.command,
@@ -188,6 +220,7 @@ async function* bridge(
188
220
  } finally {
189
221
  signal.removeEventListener('abort', rouse)
190
222
  unwatch()
223
+ unfollow()
191
224
  }
192
225
  }
193
226
 
@@ -62,7 +62,7 @@ function placeFrames(error: Error, frames: CallSite[]): string {
62
62
  return out
63
63
  }
64
64
 
65
- // A call site's column counts from 0 where the stack bun writes counts from 1.
65
+ // A call site's line and column count from 1; a map's from 0.
66
66
  function placed(frame: CallSite): string {
67
67
  // A handler file's frames name the file the author wrote, not the face it is served as.
68
68
  const file = (frame.getFileName() ?? '<anonymous>').replace(
@@ -70,15 +70,19 @@ function placed(frame: CallSite): string {
70
70
  '',
71
71
  )
72
72
  const line = frame.getLineNumber() ?? 0
73
- const column = frame.getColumnNumber() ?? 0
73
+ const column = frame.getColumnNumber() ?? 1
74
74
  const registered = registeredFor(file)
75
75
  if (registered !== undefined && line > 0) {
76
76
  registered.lines ??= decodeMappings(registered.mappings)
77
- const place = placeAt(registered.lines, line - 1, column)
77
+ const place = placeAt(
78
+ registered.lines,
79
+ line - 1,
80
+ Math.max(column - 1, 0),
81
+ )
78
82
  if (place !== undefined)
79
83
  return `${registered.source}:${place[0] + 1}:${place[1] + 1}`
80
84
  }
81
- return `${file}:${line}:${column + 1}`
85
+ return `${file}:${line}:${column}`
82
86
  }
83
87
 
84
88
  function registeredFor(path: string): Registered | undefined {
@@ -18,10 +18,11 @@ import {
18
18
  import { refuse } from '#shared/refuse.ts'
19
19
  import { declare } from '#shared/rpc.ts'
20
20
  import { mountedOwn } from './answerRpc.ts'
21
- import { callerOrMint, mintCaller } from './callerOf.ts'
21
+ import { signedInAt } from './bridgeRelay.ts'
22
+ import { callerOf, callerOrMint, mintCaller } from './callerOf.ts'
22
23
  import { config, positiveSetting } from './config.ts'
23
24
  import { HOOKS, inOrder } from './hooks.ts'
24
- import { within } from './requestScope.ts'
25
+ import { type RequestScope, type Sealed, within } from './requestScope.ts'
25
26
 
26
27
  // 26.18.
27
28
  const PRINCIPAL_COOKIE = 'abide-principal'
@@ -84,9 +85,7 @@ function seal(
84
85
  *
85
86
  * 26.13 — A BAD SIGNATURE, A LAPSED SEAL AND A MALFORMED COOKIE ARE ONE ANSWER: nothing.
86
87
  */
87
- function unseal(
88
- sealed: string | null,
89
- ): { claims: Record<string, unknown>; expires: number } | undefined {
88
+ function unseal(sealed: string | null): Sealed | undefined {
90
89
  if (sealed === null) return undefined
91
90
  const [header, payload, signed, ...rest] = sealed.split('.')
92
91
  // ONE HEADER IS READ, abide's own: a token naming another `alg` — `none` above all — is no seal.
@@ -182,8 +181,8 @@ async function documentOf(
182
181
  function resolvePrincipal(): Promise<Principal> {
183
182
  const scope = within('principal')
184
183
  scope.principal ??= (async () => {
185
- const opened = unseal(cookies().get(PRINCIPAL_COOKIE))
186
- if (opened === undefined) return ANONYMOUS
184
+ const opened = openedSeal(scope)
185
+ if (opened === null) return ANONYMOUS
187
186
  let expires = opened.expires
188
187
  const life = ttl()
189
188
  if (expires - Date.now() < life / 2 && scope.applied === false) {
@@ -206,7 +205,38 @@ export function presumePrincipal(claims: Record<string, unknown>): void {
206
205
  throw new Error(
207
206
  'abide: App.run takes a principal of claims, as principal.set does. RULEBOOK 26.11.',
208
207
  )
209
- scope.principal = documentOf(claims, Date.now() + ttl())
208
+ const expires = Date.now() + ttl()
209
+ scope.principal = documentOf(claims, expires)
210
+ scope.opened = { claims: asSealed(claims), expires }
211
+ }
212
+
213
+ /** What this request's principal cookie opened to, read and verified once a request. */
214
+ function openedSeal(scope: RequestScope): Sealed | null {
215
+ if (scope.opened === undefined)
216
+ scope.opened = unseal(cookies().get(PRINCIPAL_COOKIE)) ?? null
217
+ return scope.opened
218
+ }
219
+
220
+ /** `claims` as a later request opens them from their seal: through JSON, and `exp` and `iat` aside. */
221
+ function asSealed(claims: Record<string, unknown>): Record<string, unknown> {
222
+ const { exp: _, iat: __, ...sealed } = JSON.parse(JSON.stringify(claims))
223
+ return sealed
224
+ }
225
+
226
+ /** Who `opened` signs in, as one string: its claims as JSON, or `null`'s. 44.33. */
227
+ function identityOf(opened: Sealed | null): string {
228
+ return JSON.stringify(opened === null ? null : opened.claims)
229
+ }
230
+
231
+ /**
232
+ * Who this request is signed in as, as one string.
233
+ *
234
+ * 44.33 — THE CLAIMS, NOT THE RESOLVED DOCUMENT: what `onPrincipal` reads off them can change
235
+ * between requests, and a seal sealed again for another life (26.15) holds the same claims. Read
236
+ * synchronously, off the cookie, so the pairing command stays a plain call.
237
+ */
238
+ export function signedInAs(): string {
239
+ return identityOf(openedSeal(within('claudeCode')))
210
240
  }
211
241
 
212
242
  /**
@@ -233,6 +263,11 @@ async function set(claims: Record<string, unknown>): Promise<void> {
233
263
  scope.response.headers.set(PRINCIPAL_HEADER, 'set')
234
264
  const resolved = documentOf(claims, expires)
235
265
  scope.principal = resolved
266
+ scope.opened = { claims: asSealed(claims), expires }
267
+ // 44.34 — a status stream opened before this sign-in follows it.
268
+ const caller = callerOf()
269
+ if (caller !== undefined)
270
+ signedInAt(caller, caller, identityOf(scope.opened))
236
271
  principalMembers().document.set(await resolved)
237
272
  PRINCIPAL_LOG.debug('set')
238
273
  }
@@ -247,8 +282,12 @@ function clear(): void {
247
282
  const scope = within('principal.clear')
248
283
  cookies().delete(PRINCIPAL_COOKIE)
249
284
  scope.response.headers.set(PRINCIPAL_HEADER, 'clear')
285
+ const signedOut = callerOf()
250
286
  const minted = mintCaller(scope)
251
287
  scope.principal = Promise.resolve(ANONYMOUS)
288
+ scope.opened = null
289
+ // 44.34 — and one opened before this sign-out follows it to the caller it rotated to.
290
+ if (signedOut !== undefined) signedInAt(signedOut, minted, identityOf(null))
252
291
  principalMembers().document.set(ANONYMOUS)
253
292
  principalMembers().caller.set(minted)
254
293
  PRINCIPAL_LOG.debug('clear')
@@ -22,6 +22,9 @@ import { IDLE_TIMEOUT_SECONDS } from './IDLE_TIMEOUT_SECONDS.ts'
22
22
  /** The three framings a stream is answered in. 17.6. */
23
23
  export type Framing = Framed | 'bytes'
24
24
 
25
+ /** A principal cookie's claims, `exp` and `iat` aside, and when it lapses. 26.11. */
26
+ export type Sealed = { claims: Record<string, unknown>; expires: number }
27
+
25
28
  /** What one request, or one `App.run`, carries across every `await` its work makes. */
26
29
  export type RequestScope = Scope & {
27
30
  /**
@@ -38,6 +41,8 @@ export type RequestScope = Scope & {
38
41
  caller: string | undefined
39
42
  /** This request's one resolve of the principal. 26.14. */
40
43
  principal: Promise<Principal> | undefined
44
+ /** What the principal cookie opened to once read, or `null` where it held no seal. */
45
+ opened: Sealed | null | undefined
41
46
  /** 16.23 — whether the received request's idle timeout is off until the answer is sent. */
42
47
  unreaped: boolean
43
48
  /** This response's nonce, minted by the `csp` rung or on the first ask. 29.3. */
@@ -168,6 +173,7 @@ export function scopeFor(
168
173
  unreaped: false,
169
174
  caller: undefined,
170
175
  principal: undefined,
176
+ opened: undefined,
171
177
  applied: false,
172
178
  request: given,
173
179
  route: undefined,