@gotcos/glasses-server 6.27.13 → 6.28.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.
@@ -0,0 +1,1550 @@
1
+ // GET /api/agent-sessions/:provider/:threadId/attachability
2
+ // GET /api/agent-sessions/bindings
3
+ // POST /api/agent-sessions/:provider/:threadId/attach
4
+ // POST /api/agent-sessions/bindings/:bindingId/turns
5
+ //
6
+ // The client-facing half of Continue Original Agent Thread. Phase 0 resolved
7
+ // plan 4.3 to option B: COS attaches to a native desktop thread ONLY when no
8
+ // live process owns it. This router is where the lens and Control ask that
9
+ // question, and it is the ONLY thing standing between a "not sure" and a write
10
+ // into a conversation somebody else is holding.
11
+ //
12
+ // FAIL CLOSED, WITHOUT EXCEPTION. Every unknown, throw, missing dependency,
13
+ // unreadable row and unrecognised input resolves to not-attachable with a named
14
+ // reason. The sibling detector's header lists six inputs where the first version
15
+ // turned "I found no owner" into "there is no owner"; this file must not add a
16
+ // seventh at the edge. There is exactly one way to reach `attachable: true`:
17
+ // a supported provider, a valid id, a detector that demonstrably exists on this
18
+ // install, every candidate record parsed, and no owner that is not provably ours.
19
+ //
20
+ // WHAT THIS ROUTE MAY NOT SAY. Plan 3.3 requires the client-visible reference to
21
+ // be non-identifying, and lib/claude-session-registry.ts:117 goes to real trouble
22
+ // never to spread a raw registry record toward a lens. So the response body is
23
+ // four fields, listed by hand, and carries NO pid, NO native thread id, NO
24
+ // filesystem or socket path, NO cwd and NO target lock key. `ownerCount` is the
25
+ // entire owner projection. The test asserts the exact key set and that the whole
26
+ // serialized body contains no '/' at all, which is a cheap standing proof that
27
+ // nothing path-shaped ever leaks through a future edit.
28
+ //
29
+ // EVERYTHING IS INJECTED. Probes, directories, the clock and the binding registry
30
+ // all arrive as dependencies, following `createQueryJobsRouter` (routes/query-jobs.ts,
31
+ // registered at index.ts:269). That is what lets the whole verdict surface be
32
+ // tested without a real machine, a real Claude install, or a real wall clock.
33
+ //
34
+ // AUTH AND NETWORK POLICY ARE INHERITED, NOT INVENTED. index.ts applies the IP
35
+ // allowlist (:134), CORS (:144) and `requireApiToken` (:157) to everything under
36
+ // /api before any router is reached, exactly as `agentSessionsRouter` (:279) and
37
+ // `claudeSessionsRouter` (:282) rely on. This router adds no auth of its own and
38
+ // must be mounted the same way: `app.use('/api', createAgentSessionBindingsRouter(deps))`.
39
+ //
40
+ // NO FEATURE FLAG, deliberately. `claude-sessions` is dark by default because it
41
+ // projects another product's 0700 state directory onto the wire. This route
42
+ // projects a boolean, a reason enum and a count. There is nothing here to gate,
43
+ // and a flag would only add a state in which the lens cannot tell "unsafe to
44
+ // attach" from "switched off".
45
+ //
46
+ // ---------------------------------------------------------------- write side
47
+ //
48
+ // The two POST routes below take the same posture and add four rules of their
49
+ // own. They are the first code in this feature that can change a real desktop
50
+ // conversation, so read them as "what has to be TRUE before a prompt moves",
51
+ // never as "what has to be false before we refuse".
52
+ //
53
+ // 1. THE OCCUPANCY VERDICT IS A HARD PRECONDITION, TWICE. Plan 4.3 resolved to
54
+ // option B: protocol 1 attaches only to a thread with no live owner. Attach
55
+ // refuses on any doubt the detector can raise, and the turn route RE-RUNS the
56
+ // same detector immediately before delivery (plan 4.3 step 6). A newly
57
+ // appeared owner there is terminal, not a warning — there is no COS-side
58
+ // cross-process lock that could fence a desktop writer, so the only safe
59
+ // response to one appearing is to not deliver.
60
+ //
61
+ // 2. SELF-RECURSION ORDERING (plan 4.4). A `claude --resume <id>` child writes
62
+ // ITSELF into ~/.claude/sessions/<pid>.json carrying the id we are targeting,
63
+ // so from the next check's point of view our own child is a live foreign
64
+ // owner. The spawn ledger is the only thing that tells them apart and it is
65
+ // keyed pid -> process START, so the order is fixed:
66
+ // re-check -> spawn -> record(pid, MEASURED start) -> prompt -> release
67
+ // The start MUST come from `probes.processStartMs(pid)`. `Date.now()` drifts
68
+ // by up to 992 ms against a 1500 ms tolerance and silently disables
69
+ // self-identification under load, after which the second turn on a thread
70
+ // refuses forever and reads as a detector bug. That is why `onSpawn` returns
71
+ // a boolean: if COS cannot record the child it cannot recognise it, so the
72
+ // adapter must abort BEFORE any prompt byte rather than deliver a turn COS
73
+ // will mistake for someone else's next time.
74
+ //
75
+ // 3. UNKNOWN DELIVERY IS NOT FAILED DELIVERY. An adapter result this build does
76
+ // not recognise, or a throw, means the prompt MAY have landed in the real
77
+ // thread. It is reported as `deliveryState: 'unknown'` with `retryable:
78
+ // false`, and it FENCES the target so no later turn or re-attach can deliver
79
+ // a second copy (plan 4.6). Only an explicit `{ status: 'aborted' }` — which
80
+ // plan 4.6 item 4 restricts to "the provider demonstrably never opened the
81
+ // session" — is allowed to mean nothing happened.
82
+ //
83
+ // 4. THE TWO INJECTED MODULES ARE OPTIONAL AND THEIR ABSENCE IS A REFUSAL.
84
+ // `native-head.ts` and `attached-provider-adapter.ts` are being written in
85
+ // parallel, so this router declares the shape it needs and takes it through
86
+ // `deps`. Every new dependency is OPTIONAL in the type — index.ts constructs
87
+ // this router today and must keep compiling — and every route checks for it
88
+ // at request time and answers 503 with a named reason when it is missing.
89
+ // Optional in the type is not optional in behavior.
90
+ //
91
+ // BODY PARSING IS INHERITED, LIKE AUTH. index.ts installs
92
+ // `express.json({ limit: '10mb' })` at :262, before this router at :296, and no
93
+ // router in this repo mounts its own parser. So `req.body` arrives parsed. If it
94
+ // does not — a future remount above the parser — the POST routes see a non-object
95
+ // and answer 400. They never treat an unparsed body as an empty one.
96
+
97
+ import { Router, type Request, type Response } from 'express'
98
+ import { createHash, randomUUID } from 'node:crypto'
99
+ import {
100
+ threadOccupancy,
101
+ type Occupancy,
102
+ type OccupancyDirs,
103
+ type OccupancyProbes,
104
+ type OccupancyReason,
105
+ } from '../lib/thread-occupancy.js'
106
+ import {
107
+ BINDING_ID_RE,
108
+ assertUsable,
109
+ boundToMarker,
110
+ isBindableProvider,
111
+ isExpired,
112
+ isPinned,
113
+ isTerminal,
114
+ targetKey,
115
+ type BindableProvider,
116
+ type BindingState,
117
+ type NativeBinding,
118
+ } from '../lib/agent-session-binding-store.js'
119
+ import type { RegistryCheck, RegistryRejection, RegistryResult } from '../lib/agent-session-binding-registry.js'
120
+ import { recordCosSpawn, releaseCosSpawn } from '../lib/agent-session-ownership-store.js'
121
+ import { isValidNativeThreadId } from '../lib/native-thread-id.js'
122
+
123
+ /**
124
+ * Read side of the binding lease store.
125
+ *
126
+ * `list` MUST THROW on a read failure. Returning `[]` would make "I could not
127
+ * read the registry" byte-identical to "nothing is bound", which is the exact
128
+ * absence-inference this feature keeps re-learning. The route turns a throw into
129
+ * an explicit unavailable response so an empty list always means proved-empty.
130
+ */
131
+ export interface BindingRegistry {
132
+ /** Every binding the server knows, terminal ones included. Filtering is this route's job. */
133
+ list: () => readonly NativeBinding[]
134
+ /**
135
+ * Idempotency. Optional so a caller can wire a registry without them, but a
136
+ * turns route built on such a registry cannot make a repeated POST safe - the
137
+ * route requires a client key and simply records nothing if these are absent.
138
+ */
139
+ findTurn?: (bindingId: string, turnId: string) => { result: unknown } | null
140
+ recordTurn?: (bindingId: string, turnId: string, result: unknown, now: number) => unknown
141
+ /**
142
+ * Is the durable store behind `list` usable at all?
143
+ *
144
+ * REQUIRED, not optional, and this is the reason: `AgentSessionBindingRegistry`
145
+ * (lib/agent-session-binding-registry.ts:578) returns `[]` from `list()` when it
146
+ * hydrated `degraded`. Forwarding that as a 200 with an empty array would tell an
147
+ * operator "nothing is bound" when the truth is "the store could not be read" —
148
+ * the same absence-inference this whole feature keeps re-learning, relocated to
149
+ * the wiring seam where no amount of care inside the route can catch it. Making
150
+ * it a required member means a router cannot be constructed without an answer.
151
+ * Wire it as `() => registry.hydration.status !== 'degraded'`.
152
+ */
153
+ available: () => boolean
154
+
155
+ // -------------------------------------------------------------- write side
156
+ //
157
+ // OPTIONAL IN THE TYPE, REQUIRED IN BEHAVIOR. `AgentSessionBindingRegistry`
158
+ // implements every one of these, so the production wiring at index.ts:296
159
+ // satisfies them by passing the registry itself and needs no edit. They are
160
+ // optional so that a read-only registry — which is what the GET routes were
161
+ // built against — still constructs, and so a build that forgets one gets a
162
+ // named 503 from the write routes instead of a TypeError at request time.
163
+
164
+ /**
165
+ * Attach.
166
+ *
167
+ * `AgentSessionBindingRegistry.create` is the ONLY sanctioned way to mint a
168
+ * binding, and the reason is the epoch. It reads `priorEpoch` from the durable
169
+ * per-target high-water ledger itself and returns `caller_supplied_epoch` if a
170
+ * caller tries to pass one. Reading the epoch from a CURRENT in-memory binding
171
+ * — the obvious-looking alternative — reopens the replay window the epoch
172
+ * exists to close: after detach and eviction the next attach would restart at
173
+ * 1, and a prompt queued against the first attach would match it.
174
+ */
175
+ create?: (input: {
176
+ bindingId: string
177
+ cosSessionId: string
178
+ provider: string
179
+ nativeThreadId: string
180
+ workspaceFingerprint: string
181
+ sourceFingerprint: string
182
+ nativeHeadAtAttach?: string | null
183
+ ttlMs: number
184
+ now: number
185
+ }) => RegistryResult
186
+ activate?: (bindingId: string, now: number) => RegistryResult
187
+ /** Frees a target whose attach failed halfway. Never used on a live binding. */
188
+ forceDetach?: (bindingId: string, now: number) => RegistryResult
189
+ get?: (bindingId: string) => NativeBinding | null
190
+ /** The durable epoch/state gate for a client-queued prompt. */
191
+ checkQueuedPrompt?: (
192
+ claim: { bindingId: string; epoch: number; targetKey: string },
193
+ now: number,
194
+ ) => RegistryCheck
195
+ /** A rejection here is FATAL to the turn, per the registry's own caller contract. */
196
+ pin?: (bindingId: string, jobId: string, now: number) => RegistryResult
197
+ unpin?: (bindingId: string, jobId: string, now: number) => RegistryResult
198
+ }
199
+
200
+ /** What attach resolves server-side because plan 4.2 forbids the client sending it. */
201
+ export interface TargetResolution {
202
+ workspaceFingerprint: string
203
+ sourceFingerprint: string
204
+ }
205
+
206
+ /**
207
+ * The request handed to the attached provider adapter.
208
+ *
209
+ * Declared here rather than imported because `server/lib/attached-provider-adapter.ts`
210
+ * is being written in parallel. This is the contract the route requires; the
211
+ * wiring may shim a differently-shaped adapter onto it.
212
+ */
213
+ export interface AttachedTurnRequest {
214
+ turnId: string
215
+ bindingId: string
216
+ epoch: number
217
+ provider: BindableProvider
218
+ /** Exact private native id. The adapter resumes THIS and nothing prefix-matched. */
219
+ nativeThreadId: string
220
+ workspaceFingerprint: string
221
+ sourceFingerprint: string
222
+ /**
223
+ * The RAW provider revision token read immediately before this call, for an
224
+ * adapter that can re-assert it at the provider. It never reaches the wire:
225
+ * everything this router returns is a digest of it.
226
+ */
227
+ expectedNativeHead: string
228
+ /** Never logged, never persisted by this router, never echoed in a response. */
229
+ prompt: string
230
+ /**
231
+ * Called with the child pid the instant the process exists and BEFORE any
232
+ * prompt byte is written or any `turn/start` is sent.
233
+ *
234
+ * Returns true when COS recorded the spawn and the adapter may proceed. Returns
235
+ * FALSE when COS could not establish the child's process start and therefore
236
+ * cannot recognise it as its own later; the adapter MUST then kill the child
237
+ * and return `{ status: 'aborted' }` without delivering. Delivering anyway
238
+ * produces a turn that COS will read as a live foreign owner on the next check.
239
+ *
240
+ * An adapter that does not spawn a process (a socket-based `thread/resume`)
241
+ * simply never calls it.
242
+ */
243
+ onSpawn: (pid: number) => boolean
244
+ }
245
+
246
+ /**
247
+ * What the adapter may claim.
248
+ *
249
+ * `aborted` is the ONLY value that means "nothing was delivered", and plan 4.6
250
+ * item 4 restricts it to the cases where the provider demonstrably never opened
251
+ * the session: spawn ENOENT, a non-zero exit before any transport handshake, an
252
+ * app-server rejecting `thread/resume`. Everything else — including a throw, a
253
+ * missing status and a status this build does not know — is AMBIGUOUS, because
254
+ * "I did not see the delivery" is not "the delivery did not happen".
255
+ */
256
+ export type AttachedTurnResult =
257
+ | { status: 'completed'; nativeRevisionAfter?: string | null }
258
+ | { status: 'aborted'; reason?: string }
259
+ /**
260
+ * The shape `server/lib/attached-provider-adapter.ts` actually returns.
261
+ *
262
+ * Recognised STRUCTURALLY rather than by importing that module, so this router
263
+ * stays standalone and a change over there cannot break this build — it can
264
+ * only stop matching, which lands in the ambiguous default. The mapping mirrors
265
+ * that module's own `attachedDeliveryAmbiguous`: only `not_attempted` and
266
+ * `aborted` are proof that nothing was sent.
267
+ */
268
+ | { ok: boolean; delivery: 'not_attempted' | 'aborted' | 'ambiguous' | 'delivered' }
269
+
270
+ export interface AgentSessionBindingsDeps {
271
+ probes: OccupancyProbes
272
+ dirs: OccupancyDirs
273
+ /** Epoch ms. Injected so lease expiry is decidable in a test without waiting. */
274
+ now: () => number
275
+ bindings: BindingRegistry
276
+ /**
277
+ * The occupancy detector. Defaults to the real one and should stay that way in
278
+ * production: the seam exists so a test can hand back a SELF-CONTRADICTORY
279
+ * verdict and prove this route still refuses it. Without the seam that guard
280
+ * would be unreachable, and an unreachable guard is an untested guard.
281
+ */
282
+ occupancy?: (
283
+ provider: string,
284
+ threadId: string,
285
+ probes: OccupancyProbes,
286
+ dirs: OccupancyDirs,
287
+ ) => Occupancy
288
+
289
+ // -------------------------------------------------------------- write side
290
+
291
+ /**
292
+ * The execution fields plan 4.2 forbids the client from sending.
293
+ *
294
+ * Synchronous on purpose. Attach has no target claim held while it runs, so an
295
+ * await here would widen the window between the occupancy verdict and
296
+ * `create()` for nothing; the registry is the arbiter of a concurrent attach
297
+ * either way. Null means "could not resolve", which is a refusal.
298
+ */
299
+ resolveTarget?: (provider: BindableProvider, nativeThreadId: string) => TargetResolution | null
300
+
301
+ /**
302
+ * Bounded opaque revision token for the native thread, from
303
+ * `server/lib/native-head.ts`.
304
+ *
305
+ * Wire it as `(p, id) => nativeHead(p, id, realNativeHeadDeps())` so this
306
+ * router never has to know that module's dependency shape. Null means "could
307
+ * not determine", which is a refusal at BOTH attach and turn: with no baseline
308
+ * there is no divergence check, and plan 4.3 is the only thing making a desktop
309
+ * edit visible.
310
+ *
311
+ * The token never reaches the wire. Everything this router returns is
312
+ * `opaqueRevision()` of it, so a token that turns out to be path-shaped or
313
+ * content-bearing cannot leak through this surface.
314
+ */
315
+ nativeHead?: (provider: BindableProvider, threadId: string) => Promise<string | null> | string | null
316
+
317
+ /** `deliverAttachedTurn` from `server/lib/attached-provider-adapter.ts`. */
318
+ deliverAttachedTurn?: (request: AttachedTurnRequest) => Promise<unknown> | unknown
319
+
320
+ /**
321
+ * The self-recursion ledger. Defaults to the real process-wide one.
322
+ *
323
+ * `record` takes a MEASURED process start, never a wall clock. See rule 2 in
324
+ * the header.
325
+ */
326
+ /** Overrides the env gate. Tests only; production reads threadAttachEnabled(). */
327
+ attachEnabled?: boolean
328
+
329
+ ownership?: {
330
+ record: (pid: number, startMs: number) => string
331
+ release: (pid: number) => boolean
332
+ }
333
+
334
+ /** Lease TTL for a new binding. */
335
+ attachTtlMs?: number
336
+ /** Upper bound on one prompt. A prompt is not a payload. */
337
+ maxPromptChars?: number
338
+ /** Injected so a test can pin the minted ids. Must return an id matching BINDING_ID_RE. */
339
+ newId?: () => string
340
+ }
341
+
342
+ /**
343
+ * Human-facing footer copy, one line per reason.
344
+ *
345
+ * `Record<OccupancyReason, string>` is the enforcement: adding a member to the
346
+ * union without adding copy here is a compile error, so a new reason cannot ship
347
+ * as a blank footer. The test then drives every one of these through the real
348
+ * route, so a reason that exists in the map but is unreachable also fails.
349
+ *
350
+ * Plan 4.3 fixes the wording of the case that matters: a thread open on the
351
+ * desktop must read as a deliberate safety behavior, not a malfunction, and must
352
+ * offer Fork. No slashes in any string here, so the redaction assertion can be a
353
+ * flat "the body contains no path separator".
354
+ */
355
+ export const REASON_COPY: Record<OccupancyReason, string> = {
356
+ // Not a fault and not a busy thread - the write feature is off. The copy says so
357
+ // plainly rather than implying something is wrong, because Fork-only is a
358
+ // supported permanent configuration, not a degraded one (plan 4.9).
359
+ attach_disabled:
360
+ 'Continuing a thread on your Mac is turned off. COS is read-only here. Fork it instead.',
361
+ live_desktop_process:
362
+ 'Open on your Mac. COS will not write into a thread another app is holding. Fork it instead.',
363
+ unsupported_provider:
364
+ 'This assistant cannot be continued from COS yet. Fork it instead.',
365
+ invalid_thread_id:
366
+ 'That thread reference is not a valid id. Fork it instead.',
367
+ detector_unavailable:
368
+ 'COS cannot check for a live owner on this Mac, so it will not continue this thread. Fork it instead.',
369
+ registry_unreadable:
370
+ 'COS could not read the session records for this thread, so it will not continue it. Fork it instead.',
371
+ unverifiable_process_start:
372
+ 'COS could not confirm which process holds this thread, so it will not continue it. Fork it instead.',
373
+ unverifiable_liveness_socket:
374
+ 'COS could not confirm the owning app is still live, so it will not continue this thread. Fork it instead.',
375
+ probe_failed:
376
+ 'The live owner check failed, so COS will not continue this thread. Fork it instead.',
377
+ }
378
+
379
+ export const ATTACHABLE_COPY = 'Ready to continue in the original thread.'
380
+
381
+ /**
382
+ * Copy for a reason string this build does not recognise.
383
+ *
384
+ * Reachable only if the detector returns a value outside its own union, i.e. a
385
+ * bug. The safe rendering of a bug is a refusal with words, never an empty
386
+ * footer and never a silent attach.
387
+ */
388
+ export const UNKNOWN_REASON_COPY = 'COS could not establish whether this thread is free. Fork it instead.'
389
+
390
+ export function reasonCopy(reason: OccupancyReason | null): string {
391
+ if (reason === null) return ATTACHABLE_COPY
392
+ const copy = Object.prototype.hasOwnProperty.call(REASON_COPY, reason) ? REASON_COPY[reason] : undefined
393
+ return typeof copy === 'string' && copy.length > 0 ? copy : UNKNOWN_REASON_COPY
394
+ }
395
+
396
+ /**
397
+ * Every way a write can be refused.
398
+ *
399
+ * Supersets `OccupancyReason` so the attach refusal for a live desktop owner is
400
+ * literally the same value and the same footer line the attachability probe
401
+ * already returns — a lens that renders one renders the other.
402
+ */
403
+ export type WriteRefusal =
404
+ | OccupancyReason
405
+ | 'invalid_request'
406
+ | 'binding_registry_unwired'
407
+ | 'binding_registry_degraded'
408
+ | 'binding_registry_unavailable'
409
+ | 'target_unresolvable'
410
+ | 'native_head_unavailable'
411
+ | 'native_thread_changed'
412
+ | 'native_target_busy'
413
+ | 'native_turn_in_progress'
414
+ | 'native_target_fenced'
415
+ | 'attach_failed'
416
+ | 'unknown_binding'
417
+ | 'binding_not_active'
418
+ | 'binding_detached'
419
+ | 'binding_expired'
420
+ | 'stale_epoch'
421
+ | 'target_mismatch'
422
+ | 'binding_unusable'
423
+ | 'pin_failed'
424
+ | 'adapter_unwired'
425
+ | 'provider_never_opened'
426
+ | 'delivery_ambiguous'
427
+ | 'turn_failed'
428
+
429
+ /**
430
+ * Footer copy for the refusals that are not occupancy reasons.
431
+ *
432
+ * Same enforcement as `REASON_COPY`: `Record<…, string>` means a new member of
433
+ * the union cannot ship without copy, and the suite drives every key through a
434
+ * real route so a member that has copy but is unreachable fails too. No slashes
435
+ * in any string, which keeps the "nothing this router returns is path-shaped"
436
+ * assertion a flat substring check.
437
+ */
438
+ export const WRITE_REASON_COPY: Record<Exclude<WriteRefusal, OccupancyReason>, string> = {
439
+ invalid_request:
440
+ 'That request was not something COS could read. Nothing was sent.',
441
+ binding_registry_unwired:
442
+ 'This build cannot record a continuation, so it will not start one. Fork it instead.',
443
+ binding_registry_degraded:
444
+ 'COS could not read its own continuation records, so it will not continue this thread. Fork it instead.',
445
+ binding_registry_unavailable:
446
+ 'COS could not read its own continuation records, so it will not continue this thread. Fork it instead.',
447
+ target_unresolvable:
448
+ 'COS could not work out where this thread lives, so it will not continue it. Fork it instead.',
449
+ native_head_unavailable:
450
+ 'COS could not read where this thread currently ends, so it will not write to it. Fork it instead.',
451
+ native_thread_changed:
452
+ 'This thread changed on your Mac since you attached. Refresh, continue anyway, or fork.',
453
+ native_target_busy:
454
+ 'This thread is already attached to another COS chat. Detach that one first.',
455
+ native_turn_in_progress:
456
+ 'A COS turn is already running on this thread. Wait for it to finish.',
457
+ native_target_fenced:
458
+ 'An earlier turn on this thread may or may not have been delivered. Open the thread on your Mac and check before sending again.',
459
+ attach_failed:
460
+ 'COS could not record a continuation for this thread. Fork it instead.',
461
+ unknown_binding:
462
+ 'That continuation is no longer on record. Attach again.',
463
+ binding_not_active:
464
+ 'That continuation is not live yet. Attach again.',
465
+ binding_detached:
466
+ 'That continuation was detached. Attach again.',
467
+ binding_expired:
468
+ 'That continuation timed out. Attach again.',
469
+ stale_epoch:
470
+ 'This prompt was written against an earlier attach of the same thread. It was not sent. Attach again.',
471
+ target_mismatch:
472
+ 'This prompt names a different thread than the continuation it claims. It was not sent.',
473
+ binding_unusable:
474
+ 'That continuation cannot run work. Attach again.',
475
+ pin_failed:
476
+ 'COS could not hold the continuation open for this turn, so it did not send it.',
477
+ adapter_unwired:
478
+ 'This build cannot drive the original thread yet. Fork it instead.',
479
+ provider_never_opened:
480
+ 'The assistant never opened the thread, so nothing was sent. You can try again.',
481
+ delivery_ambiguous:
482
+ 'COS lost track of this turn after sending it. Open the thread on your Mac and check before sending again.',
483
+ turn_failed:
484
+ 'COS could not run this turn. Nothing was sent. You can try again.',
485
+ }
486
+
487
+ export function writeReasonCopy(reason: WriteRefusal): string {
488
+ if (Object.prototype.hasOwnProperty.call(REASON_COPY, reason)) {
489
+ return REASON_COPY[reason as OccupancyReason]
490
+ }
491
+ const copy = Object.prototype.hasOwnProperty.call(WRITE_REASON_COPY, reason)
492
+ ? WRITE_REASON_COPY[reason as Exclude<WriteRefusal, OccupancyReason>]
493
+ : undefined
494
+ return typeof copy === 'string' && copy.length > 0 ? copy : UNKNOWN_REASON_COPY
495
+ }
496
+
497
+ /**
498
+ * Refusals that mean "this build or this machine cannot do it", answered 503.
499
+ *
500
+ * Everything else that is not a malformed request is 409: a conflict with the
501
+ * state of the world, which the user can act on. The split lives in one table so
502
+ * the two routes cannot drift, and so a new refusal defaults to 409 — the
503
+ * conservative choice, since a client is far more likely to auto-retry a 503.
504
+ */
505
+ const CAPABILITY_REFUSALS: ReadonlySet<WriteRefusal> = new Set<WriteRefusal>([
506
+ 'detector_unavailable',
507
+ 'binding_registry_unwired',
508
+ 'binding_registry_degraded',
509
+ 'binding_registry_unavailable',
510
+ 'adapter_unwired',
511
+ ])
512
+
513
+ export function refusalStatus(reason: WriteRefusal): number {
514
+ if (reason === 'invalid_request') return 400
515
+ return CAPABILITY_REFUSALS.has(reason) ? 503 : 409
516
+ }
517
+
518
+ /**
519
+ * Map anything the binding registry can say onto a refusal with copy.
520
+ *
521
+ * `Record<RegistryRejection, WriteRefusal>` is the point: the registry owns that
522
+ * union and can grow it, and this must not compile if it does. Several members
523
+ * are unreachable through these two routes (`invalid_ttl` needs a bad TTL, which
524
+ * this router supplies itself) and collapse onto a generic refusal rather than
525
+ * inventing copy nobody can ever see.
526
+ */
527
+ export const REGISTRY_REJECTION_REFUSAL: Record<RegistryRejection, WriteRefusal> = {
528
+ // Value-type rejections.
529
+ invalid_thread_id: 'invalid_thread_id',
530
+ invalid_provider: 'unsupported_provider',
531
+ invalid_binding_id: 'unknown_binding',
532
+ invalid_epoch: 'invalid_request',
533
+ invalid_ttl: 'attach_failed',
534
+ unknown_binding: 'unknown_binding',
535
+ binding_not_active: 'binding_not_active',
536
+ binding_detached: 'binding_detached',
537
+ binding_expired: 'binding_expired',
538
+ stale_epoch: 'stale_epoch',
539
+ target_mismatch: 'target_mismatch',
540
+ missing_target_key: 'invalid_request',
541
+ binding_pinned: 'binding_unusable',
542
+ terminal_state: 'binding_unusable',
543
+ // Registry-level rejections.
544
+ store_unavailable: 'binding_registry_degraded',
545
+ persist_failed: 'attach_failed',
546
+ reentrant_mutation: 'attach_failed',
547
+ target_busy: 'native_target_busy',
548
+ binding_id_in_use: 'attach_failed',
549
+ invalid_job_id: 'pin_failed',
550
+ too_many_pins: 'pin_failed',
551
+ registry_full: 'attach_failed',
552
+ epoch_ledger_full: 'attach_failed',
553
+ caller_supplied_epoch: 'attach_failed',
554
+ }
555
+
556
+ export function registryRefusal(reason: RegistryRejection | null | undefined): WriteRefusal {
557
+ if (typeof reason !== 'string') return 'attach_failed'
558
+ return Object.prototype.hasOwnProperty.call(REGISTRY_REJECTION_REFUSAL, reason)
559
+ ? REGISTRY_REJECTION_REFUSAL[reason]
560
+ : 'attach_failed'
561
+ }
562
+
563
+ /**
564
+ * Bounded, deterministic, non-identifying stand-in for a provider revision token
565
+ * or a `boundTo` marker.
566
+ *
567
+ * WHY NOT THE RAW VALUES. `boundToMarker` is length-prefixed over
568
+ * bindingId + epoch + targetKey, and targetKey embeds the exact private native
569
+ * thread id — so returning the raw marker would put the native id on the wire
570
+ * through the one route whose entire redaction contract says it never does. The
571
+ * revision token comes from a module this one does not own, and plan 4.3 only
572
+ * ASKS that it carry no content or path; asking is not enforcing. A digest makes
573
+ * both true by construction, stays comparable across requests, and the client can
574
+ * hand it back for the Continue Anyway acknowledgement.
575
+ *
576
+ * NOT A CAPABILITY TOKEN. It is a digest of values the attaching client already
577
+ * holds, so it proves recognisability, not authority. Authorization is
578
+ * `requireApiToken` at the app level, exactly as for every other route here.
579
+ */
580
+ export function opaqueRevision(value: string): string {
581
+ return createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 32)
582
+ }
583
+
584
+ /** Shape of an opaque value coming back from a client. 128 bits of hex, nothing else. */
585
+ export const OPAQUE_RE = /^[0-9a-f]{32}$/
586
+
587
+ export interface AttachabilityBody {
588
+ attachable: boolean
589
+ reason: OccupancyReason | null
590
+ reasonCopy: string
591
+ ownerCount: number
592
+ }
593
+
594
+ /**
595
+ * Project a verdict to the wire, re-checking its internal consistency.
596
+ *
597
+ * The detector is trusted to be correct; it is not trusted to STAY correct. Three
598
+ * shapes are contradictions rather than verdicts, and each one resolves the
599
+ * permissive way if simply forwarded: attachable with a reason, attachable with an
600
+ * owner that is not provably ours, and a non-array owners field. Any of them is a
601
+ * defect upstream, and a defect must not resolve to permissive.
602
+ */
603
+ export function projectAttachability(verdict: Occupancy): AttachabilityBody {
604
+ const owners = Array.isArray(verdict?.owners) ? verdict.owners : null
605
+ const sound =
606
+ verdict?.attachable === true &&
607
+ verdict.reason === null &&
608
+ owners !== null &&
609
+ owners.every(owner => owner?.selfOwned === true)
610
+ const reason: OccupancyReason | null = sound ? null : ((verdict?.reason ?? 'probe_failed') as OccupancyReason)
611
+ return {
612
+ attachable: sound,
613
+ reason,
614
+ reasonCopy: reasonCopy(sound ? null : reason),
615
+ // Total owners, self-owned included. Reporting only foreign owners would make
616
+ // the count read lower than reality, and this number exists so a client can
617
+ // never be MORE confident than the server. Attachability is `attachable`,
618
+ // never an inference from this field being zero.
619
+ ownerCount: owners === null ? 0 : owners.length,
620
+ }
621
+ }
622
+
623
+ /** Compile-time exhaustive membership test for a binding state. */
624
+ const BINDING_STATES: Record<BindingState, true> = {
625
+ staging: true,
626
+ active: true,
627
+ detaching: true,
628
+ detached: true,
629
+ }
630
+
631
+ function isBindingState(value: unknown): value is BindingState {
632
+ return typeof value === 'string' && Object.prototype.hasOwnProperty.call(BINDING_STATES, value)
633
+ }
634
+
635
+ /**
636
+ * Is this registry row trustworthy enough to describe on the wire?
637
+ *
638
+ * Validated with the SHARED id validator, never a local copy: native-thread-id.ts
639
+ * exists because two modules written in the same session disagreed about what an
640
+ * id is, and a truncated id then sailed through occupancy as attachable.
641
+ */
642
+ export function isUsableBindingRow(value: unknown): value is NativeBinding {
643
+ if (!value || typeof value !== 'object') return false
644
+ const row = value as Partial<NativeBinding>
645
+ if (typeof row.bindingId !== 'string' || !BINDING_ID_RE.test(row.bindingId)) return false
646
+ if (!isBindableProvider(row.provider)) return false
647
+ if (!isValidNativeThreadId(row.nativeThreadId)) return false
648
+ if (!isBindingState(row.state)) return false
649
+ if (!Number.isInteger(row.epoch) || (row.epoch as number) < 1) return false
650
+ if (typeof row.expiresAt !== 'number' || !Number.isFinite(row.expiresAt)) return false
651
+ if (!Array.isArray(row.pinnedJobs)) return false
652
+ return true
653
+ }
654
+
655
+ export interface BindingSummary {
656
+ bindingId: string
657
+ provider: string
658
+ state: BindingState
659
+ epoch: number
660
+ expiresAt: number
661
+ pinned: boolean
662
+ expired: boolean
663
+ }
664
+
665
+ /**
666
+ * Redacted binding row.
667
+ *
668
+ * Dropped on purpose, each because it is either identifying or forgeable:
669
+ * `nativeThreadId` (the private native id), `targetKey` (the mutex key plan 3.3
670
+ * names explicitly), `cosSessionId` (client-supplied, and SAFE_ID_RE permits '/'
671
+ * inside it), both fingerprints (nothing constrains them to be hashes rather than
672
+ * raw paths), `nativeHeadAtAttach` (an opaque revision nobody needs in a list) and
673
+ * `pinnedJobs` (ids, where a boolean answers the only question a list view asks).
674
+ */
675
+ export function projectBinding(binding: NativeBinding, now: number): BindingSummary {
676
+ return {
677
+ bindingId: binding.bindingId,
678
+ provider: binding.provider,
679
+ state: binding.state,
680
+ epoch: binding.epoch,
681
+ expiresAt: binding.expiresAt,
682
+ pinned: isPinned(binding),
683
+ expired: isExpired(binding, now),
684
+ }
685
+ }
686
+
687
+ function occupancyDepsUsable(deps: AgentSessionBindingsDeps): boolean {
688
+ const probes = deps?.probes
689
+ const dirs = deps?.dirs
690
+ return (
691
+ !!probes &&
692
+ typeof probes.dirExists === 'function' &&
693
+ typeof probes.readDir === 'function' &&
694
+ typeof probes.readFile === 'function' &&
695
+ typeof probes.isAlive === 'function' &&
696
+ typeof probes.processStartMs === 'function' &&
697
+ typeof probes.fileExists === 'function' &&
698
+ typeof probes.lockHolders === 'function' &&
699
+ typeof probes.cosSpawnedPids === 'function' &&
700
+ !!dirs &&
701
+ typeof dirs.claudeSessionsDir === 'string' &&
702
+ typeof dirs.codexLocksDir === 'string'
703
+ )
704
+ }
705
+
706
+ function bindingDepsUsable(deps: AgentSessionBindingsDeps): boolean {
707
+ return (
708
+ !!deps?.bindings &&
709
+ typeof deps.bindings.list === 'function' &&
710
+ typeof deps.bindings.available === 'function' &&
711
+ typeof deps?.now === 'function'
712
+ )
713
+ }
714
+
715
+ /** Can this build mint and drive a binding at all, or only describe one? */
716
+ function bindingWriteDepsUsable(deps: AgentSessionBindingsDeps): boolean {
717
+ const b = deps?.bindings
718
+ return (
719
+ bindingDepsUsable(deps) &&
720
+ typeof b?.create === 'function' &&
721
+ typeof b?.activate === 'function' &&
722
+ typeof b?.forceDetach === 'function' &&
723
+ typeof b?.get === 'function' &&
724
+ typeof b?.checkQueuedPrompt === 'function' &&
725
+ typeof b?.pin === 'function' &&
726
+ typeof b?.unpin === 'function'
727
+ )
728
+ }
729
+
730
+ export const DEFAULT_ATTACH_TTL_MS = 30 * 60_000
731
+ export const DEFAULT_MAX_PROMPT_CHARS = 32_000
732
+ /** Bindings whose advanced head is remembered. See `acknowledgeHead`. */
733
+ export const MAX_TRACKED_HEADS = 512
734
+
735
+ /** A COS session id may contain ':' and '/', which is exactly why it is never projected. */
736
+ export const COS_SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/
737
+
738
+ /**
739
+ * The in-process half of plan 4.5 and 4.6: one COS turn per native target, and a
740
+ * target that may already hold an undelivered turn stays shut.
741
+ *
742
+ * NOT DURABLE, AND THAT IS A STATED GAP. Plan 4.5 wants the reservation
743
+ * persisted in the job journal and rehydrated on boot, and 4.6 item 3 wants the
744
+ * fence to have its own lifecycle and an operator release path. Both belong to
745
+ * Phase 2, which owns that journal. What lives here is the process-lifetime
746
+ * version, which is enough to make the two properties true for a running server
747
+ * and fails in the safe direction on restart: a claim is released (no turn is
748
+ * running after a restart anyway) and a FENCE is lost, which is the one that
749
+ * matters and is why it is called out rather than implied.
750
+ */
751
+ class TargetGuard {
752
+ /** targetKey -> turnId of the single COS turn allowed to be in flight. */
753
+ private readonly claims = new Map<string, string>()
754
+ /** targetKey -> why no further turn may be delivered. */
755
+ private readonly fences = new Map<string, WriteRefusal>()
756
+ /** bindingId -> the head digest this binding is currently reconciled to. */
757
+ private readonly heads = new Map<string, string>()
758
+
759
+ /**
760
+ * Check and set in ONE synchronous step.
761
+ *
762
+ * Plan 4.5 requirement 1 is a "synchronous, non-blocking target reservation
763
+ * check" that returns immediately, and the atomicity is load-bearing: with an
764
+ * await between the read and the write, two turns admitted in the same tick
765
+ * both see a free target. Every caller must reach this before its first await.
766
+ */
767
+ tryClaim(targetKey: string, turnId: string): boolean {
768
+ if (this.claims.has(targetKey)) return false
769
+ this.claims.set(targetKey, turnId)
770
+ return true
771
+ }
772
+
773
+ /** Only the holder may release, so a late unwind cannot free someone else's claim. */
774
+ release(targetKey: string, turnId: string): void {
775
+ if (this.claims.get(targetKey) === turnId) this.claims.delete(targetKey)
776
+ }
777
+
778
+ fence(targetKey: string, reason: WriteRefusal): void {
779
+ if (!this.fences.has(targetKey)) this.fences.set(targetKey, reason)
780
+ }
781
+
782
+ fencedReason(targetKey: string): WriteRefusal | null {
783
+ return this.fences.get(targetKey) ?? null
784
+ }
785
+
786
+ /**
787
+ * The head this binding is reconciled to: the attach baseline, then whatever
788
+ * the user acknowledged or a completed turn produced.
789
+ *
790
+ * Without this the SECOND turn on a binding always fails the divergence check,
791
+ * because the first turn moved the head itself.
792
+ */
793
+ acknowledgedHead(bindingId: string): string | null {
794
+ return this.heads.get(bindingId) ?? null
795
+ }
796
+
797
+ acknowledgeHead(bindingId: string, digest: string): void {
798
+ // Re-insert so the eviction order is by last use.
799
+ this.heads.delete(bindingId)
800
+ this.heads.set(bindingId, digest)
801
+ while (this.heads.size > MAX_TRACKED_HEADS) {
802
+ const oldest = this.heads.keys().next()
803
+ if (oldest.done) break
804
+ this.heads.delete(oldest.value)
805
+ }
806
+ // Eviction here is safe in a way eviction almost never is in this feature:
807
+ // losing an advance makes the next turn fall back to the ATTACH baseline, so
808
+ // it sees a changed head and asks the user to acknowledge. Strictly more
809
+ // conservative, never less.
810
+ }
811
+ }
812
+
813
+ type Delivery =
814
+ | { kind: 'completed'; after: string | null }
815
+ | { kind: 'aborted' }
816
+ | { kind: 'ambiguous' }
817
+
818
+ /**
819
+ * Read an adapter result without believing anything it did not say.
820
+ *
821
+ * The default is ambiguous, and every unrecognised shape lands there: null, an
822
+ * array, a missing status, a status from a newer adapter. Only the two literals
823
+ * this build understands are allowed to mean anything.
824
+ */
825
+ export function classifyDelivery(result: unknown): Delivery {
826
+ if (!result || typeof result !== 'object' || Array.isArray(result)) return { kind: 'ambiguous' }
827
+ const status = (result as { status?: unknown }).status
828
+ if (status === 'completed') {
829
+ const after = (result as { nativeRevisionAfter?: unknown }).nativeRevisionAfter
830
+ return { kind: 'completed', after: typeof after === 'string' && after.length > 0 ? after : null }
831
+ }
832
+ if (status === 'aborted') return { kind: 'aborted' }
833
+
834
+ // The adapter module's shape. Read only when `status` said nothing, so a result
835
+ // carrying both is decided by exactly one rule.
836
+ const { ok, delivery } = result as { ok?: unknown; delivery?: unknown }
837
+ if (typeof ok === 'boolean') {
838
+ // Success is not inferred from `ok` alone: a truthy result whose delivery
839
+ // state is anything but `delivered` is a contradiction, and a contradiction
840
+ // is ambiguous rather than done.
841
+ if (ok === true && delivery === 'delivered') {
842
+ const after = (result as { nativeRevisionAfter?: unknown }).nativeRevisionAfter
843
+ return { kind: 'completed', after: typeof after === 'string' && after.length > 0 ? after : null }
844
+ }
845
+ if (ok === false && (delivery === 'not_attempted' || delivery === 'aborted')) return { kind: 'aborted' }
846
+ }
847
+ return { kind: 'ambiguous' }
848
+ }
849
+
850
+ export function isOpaque(value: unknown): value is string {
851
+ return typeof value === 'string' && OPAQUE_RE.test(value)
852
+ }
853
+
854
+ /**
855
+ * Both fingerprints present, bounded, and strings.
856
+ *
857
+ * They are persisted into the binding and handed to the adapter, and nothing in
858
+ * the type says they are hashes rather than raw paths — the store's own header
859
+ * says so. Bounded because they become JSON in a durable file; never projected,
860
+ * which is why the shape check is all that is needed here.
861
+ */
862
+ export function isUsableResolution(value: unknown): value is TargetResolution {
863
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
864
+ const { workspaceFingerprint: w, sourceFingerprint: s } = value as Partial<TargetResolution>
865
+ return (
866
+ typeof w === 'string' && w.length > 0 && w.length <= 1024 &&
867
+ typeof s === 'string' && s.length > 0 && s.length <= 1024
868
+ )
869
+ }
870
+
871
+ /** Parsed body, or null for anything that is not a JSON object — including an unparsed body. */
872
+ function plainBody(req: Request): Record<string, unknown> | null {
873
+ const body: unknown = (req as { body?: unknown }).body
874
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return null
875
+ return body as Record<string, unknown>
876
+ }
877
+
878
+ export const ATTACHED_COPY = 'Attached. COS is driving the original thread.'
879
+ export const TURN_SENT_COPY = 'Sent to the original thread.'
880
+
881
+ /**
882
+ * Is writing into a native desktop thread turned on?
883
+ *
884
+ * OFF BY DEFAULT, permanently and by design (plan 4.9). A user who never sets this
885
+ * gets exactly the behavior that existed before this feature: read-only browsing
886
+ * and Fork-only everywhere. That is a supported end state, not a migration step.
887
+ *
888
+ * Same shape as `claudeSessionsEnabled()` in routes/claude-sessions.ts rather than
889
+ * a second flag pattern. Strict `=== '1'`, so any other value including 'true'
890
+ * reads as OFF - a feature that writes into a human's conversation should be hard
891
+ * to turn on by accident.
892
+ */
893
+ /** Client-supplied idempotency key. Long enough that a collision is deliberate. */
894
+ export const CLIENT_TURN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{7,127}$/
895
+
896
+ export function threadAttachEnabled(): boolean {
897
+ return process.env.COS_THREAD_ATTACH_ENABLED === '1'
898
+ }
899
+
900
+ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps): Router {
901
+ const router = Router()
902
+ // Evaluated once at wiring time. Incomplete dependencies are a wiring bug, and
903
+ // the safe response to a wiring bug is a route that refuses with a reason, not
904
+ // a server that will not boot and not a route that guesses.
905
+ // Resolved once at wiring time, like canDetect/canListBindings. Injectable so a
906
+ // test can drive both states without mutating process.env.
907
+ const attachEnabled = deps?.attachEnabled ?? threadAttachEnabled()
908
+ const canDetect = occupancyDepsUsable(deps)
909
+ const canListBindings = bindingDepsUsable(deps)
910
+ const canWriteBindings = bindingWriteDepsUsable(deps)
911
+ const detect = deps?.occupancy ?? threadOccupancy
912
+ const guard = new TargetGuard()
913
+ const ownership = deps?.ownership ?? { record: recordCosSpawn, release: releaseCosSpawn }
914
+ const attachTtlMs =
915
+ Number.isFinite(deps?.attachTtlMs) && (deps.attachTtlMs as number) > 0
916
+ ? (deps.attachTtlMs as number)
917
+ : DEFAULT_ATTACH_TTL_MS
918
+ const maxPromptChars =
919
+ Number.isInteger(deps?.maxPromptChars) && (deps.maxPromptChars as number) > 0
920
+ ? (deps.maxPromptChars as number)
921
+ : DEFAULT_MAX_PROMPT_CHARS
922
+ const mintId = typeof deps?.newId === 'function' ? deps.newId : () => randomUUID()
923
+
924
+ /**
925
+ * One owner for "is this thread free right now", shared by the probe and both
926
+ * writes, including its own try/catch and the self-contradiction re-check.
927
+ *
928
+ * Sharing it is the point. Provider and id validation, the reason precedence
929
+ * between them, and the refusal to trust an unsound verdict all live in exactly
930
+ * one place, so the write routes cannot come to a different conclusion than the
931
+ * probe the user was shown a second earlier.
932
+ */
933
+ const runOccupancy = (provider: string, threadId: string): AttachabilityBody => {
934
+ let verdict: Occupancy
935
+ try {
936
+ verdict = detect(provider, threadId, deps.probes, deps.dirs)
937
+ } catch (error) {
938
+ // `threadOccupancy` already contains its own probe try/catch, so reaching
939
+ // here means the detector itself threw. Logged without the thread id.
940
+ console.error(`[agent-session-bindings] occupancy threw: ${error instanceof Error ? error.message : error}`)
941
+ verdict = { attachable: false, owners: [], reason: 'probe_failed' }
942
+ }
943
+ return projectAttachability(verdict)
944
+ }
945
+
946
+ const refuseAttach = (res: Response, reason: WriteRefusal): void => {
947
+ res.status(refusalStatus(reason)).json({
948
+ attached: false,
949
+ reason,
950
+ reasonCopy: writeReasonCopy(reason),
951
+ })
952
+ }
953
+
954
+ /** Read a finite clock, or null. A route that cannot tell the time refuses. */
955
+ const readNow = (): number | null => {
956
+ try {
957
+ const value = deps.now()
958
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
959
+ } catch (error) {
960
+ console.error(`[agent-session-bindings] clock threw: ${error instanceof Error ? error.message : error}`)
961
+ return null
962
+ }
963
+ }
964
+
965
+ /**
966
+ * Current head as an opaque digest, or null when it could not be established.
967
+ *
968
+ * Null covers three different upstream events on purpose — the module is
969
+ * unwired, it answered null, or it threw — because all three leave this router
970
+ * without a baseline, and without a baseline plan 4.3 has no divergence check
971
+ * at all. The raw token is returned alongside so the adapter can be handed it;
972
+ * only the digest ever reaches the wire.
973
+ */
974
+ const readHead = async (
975
+ provider: BindableProvider,
976
+ threadId: string,
977
+ ): Promise<{ raw: string; digest: string } | null> => {
978
+ const read = deps.nativeHead
979
+ if (typeof read !== 'function') return null
980
+ try {
981
+ const raw = await read(provider, threadId)
982
+ if (typeof raw !== 'string' || raw.length === 0) return null
983
+ return { raw, digest: opaqueRevision(raw) }
984
+ } catch (error) {
985
+ console.error(`[agent-session-bindings] native head threw: ${error instanceof Error ? error.message : error}`)
986
+ return null
987
+ }
988
+ }
989
+
990
+ router.get('/agent-sessions/:provider/:threadId/attachability', (req, res) => {
991
+ // An occupancy verdict is a liveness answer with a lifetime of roughly now.
992
+ // A cached `attachable: true` is indistinguishable from a stale one, which is
993
+ // the whole failure this feature exists to prevent.
994
+ res.set('Cache-Control', 'private, no-store')
995
+
996
+ // Before `canDetect` and before any probe. With no write path there is nothing
997
+ // to protect against, so a disabled install does no filesystem work and cannot
998
+ // fail. It also keeps the surface self-consistent: reporting `attachable: true`
999
+ // while the attach route is unrouted would leave a client unable to tell
1000
+ // whether the thread is free or the feature is off.
1001
+ if (!attachEnabled) {
1002
+ res.json(projectAttachability({ attachable: false, owners: [], reason: 'attach_disabled' }))
1003
+ return
1004
+ }
1005
+
1006
+ if (!canDetect) {
1007
+ // The mechanism does not exist on this install. Distinct from "it ran and
1008
+ // found nothing" by design (plan 4.3 wants the reason nameable).
1009
+ res.json(projectAttachability({ attachable: false, owners: [], reason: 'detector_unavailable' }))
1010
+ return
1011
+ }
1012
+
1013
+ // 200 for every verdict, including a bad provider or a malformed id.
1014
+ //
1015
+ // The route's job is to answer "can I attach?", and "no, because that is not a
1016
+ // provider COS can continue" is an answer the footer can render. Following
1017
+ // claude-sessions.ts, which returns 200 for the switched-off case for the same
1018
+ // reason: a client that gets a 4xx has to invent copy, and invented copy is
1019
+ // where "unavailable" quietly becomes "try anyway".
1020
+ //
1021
+ // Provider and id are validated by `threadOccupancy` BEFORE it touches a
1022
+ // filesystem, in that order. Re-checking them here would be a second copy of a
1023
+ // rule that already has one owner, and the two copies are exactly how the
1024
+ // truncated-id hole opened. The tests pin the ordering behaviorally instead:
1025
+ // probes that throw on every call still return `unsupported_provider` /
1026
+ // `invalid_thread_id`, which is only possible if nothing was probed.
1027
+ res.json(runOccupancy(String(req.params.provider ?? ''), String(req.params.threadId ?? '')))
1028
+ })
1029
+
1030
+ router.get('/agent-sessions/bindings', (_req, res) => {
1031
+ res.set('Cache-Control', 'private, no-store')
1032
+
1033
+ const unavailable = (reason: string) => {
1034
+ // 503, not an empty 200. An empty `bindings` array from this route must
1035
+ // always mean "proved there are none"; if it could also mean "could not
1036
+ // look", every caller inherits the absence-inference bug.
1037
+ res.status(503).json({ bindings: [], available: false, reason, generatedAt: null })
1038
+ }
1039
+
1040
+ // Distinct from `binding_registry_unavailable` on purpose, and the mutation
1041
+ // pass is why. With one shared reason this branch had NO observable behavior:
1042
+ // an unwired registry threw inside the try below and produced the identical
1043
+ // response, so deleting the gate changed nothing and the guard was decoration.
1044
+ // Separating them gives it a job worth testing and answers the question an
1045
+ // operator actually has: a build was wired wrong, not a disk that failed.
1046
+ if (!canListBindings) {
1047
+ unavailable('binding_registry_unwired')
1048
+ return
1049
+ }
1050
+
1051
+ let rows: readonly NativeBinding[]
1052
+ let now: number
1053
+ try {
1054
+ // Asked BEFORE the list, because a degraded store answers `list()` with an
1055
+ // empty array rather than an error. Anything other than an explicit `true`
1056
+ // is unusable: a probe that answers "maybe" is answering no.
1057
+ if (deps.bindings.available() !== true) {
1058
+ unavailable('binding_registry_degraded')
1059
+ return
1060
+ }
1061
+ rows = deps.bindings.list()
1062
+ now = deps.now()
1063
+ } catch (error) {
1064
+ console.error(`[agent-session-bindings] binding list failed: ${error instanceof Error ? error.message : error}`)
1065
+ unavailable('binding_registry_unavailable')
1066
+ return
1067
+ }
1068
+
1069
+ if (!Array.isArray(rows) || typeof now !== 'number' || !Number.isFinite(now)) {
1070
+ unavailable('binding_registry_unavailable')
1071
+ return
1072
+ }
1073
+
1074
+ const bindings: BindingSummary[] = []
1075
+ for (const row of rows) {
1076
+ // A malformed row makes the WHOLE listing unavailable rather than a silently
1077
+ // shorter one. A list that quietly drops the row it could not parse tells the
1078
+ // operator a binding is gone when it may be live and holding a target.
1079
+ if (!isUsableBindingRow(row)) {
1080
+ unavailable('binding_registry_unreadable')
1081
+ return
1082
+ }
1083
+ if (isTerminal(row)) continue
1084
+ bindings.push(projectBinding(row, now))
1085
+ }
1086
+
1087
+ res.json({ bindings, available: true, reason: null, generatedAt: now })
1088
+ })
1089
+
1090
+ // ---------------------------------------------------------------- attach
1091
+ //
1092
+ // Registered before the turns route only for tidiness; the two paths end in
1093
+ // different literal segments (`attach` vs `turns`) so neither can shadow the
1094
+ // other, and neither can shadow `agentSessionsRouter`'s two-segment GETs.
1095
+ // Registered ONLY when the feature is on. Not a handler that declines - an
1096
+ // unregistered path 404s, so a disabled server holds no reachable write code.
1097
+ if (attachEnabled) router.post('/agent-sessions/:provider/:threadId/attach', async (req, res) => {
1098
+ res.set('Cache-Control', 'private, no-store')
1099
+ try {
1100
+ if (!canDetect) return refuseAttach(res, 'detector_unavailable')
1101
+ if (!canWriteBindings) return refuseAttach(res, 'binding_registry_unwired')
1102
+
1103
+ // The only field the client may send. Plan 4.2: no path, cwd, executable,
1104
+ // model, target key, permission mode or credentials — the server resolves
1105
+ // every execution field itself. Extra keys are ignored rather than rejected
1106
+ // so a newer client cannot be broken by an older server, but nothing outside
1107
+ // this one field is ever read.
1108
+ const body = plainBody(req)
1109
+ const cosSessionId = body?.cosSessionId
1110
+ if (typeof cosSessionId !== 'string' || !COS_SESSION_ID_RE.test(cosSessionId)) {
1111
+ return refuseAttach(res, 'invalid_request')
1112
+ }
1113
+
1114
+ const now = readNow()
1115
+ if (now === null) return refuseAttach(res, 'binding_registry_unavailable')
1116
+
1117
+ let available: boolean
1118
+ try {
1119
+ available = deps.bindings.available() === true
1120
+ } catch (error) {
1121
+ console.error(`[agent-session-bindings] availability threw: ${error instanceof Error ? error.message : error}`)
1122
+ return refuseAttach(res, 'binding_registry_unavailable')
1123
+ }
1124
+ if (!available) return refuseAttach(res, 'binding_registry_degraded')
1125
+
1126
+ const providerParam = String(req.params.provider ?? '')
1127
+ const threadIdParam = String(req.params.threadId ?? '')
1128
+
1129
+ // THE HARD PRECONDITION (plan 4.3, option B). Not advisory, not a warning,
1130
+ // and not a field on a successful response: a thread with any live owner —
1131
+ // or any doubt about whether it has one — is Fork-only, and attach is the
1132
+ // gate that makes that true.
1133
+ const verdict = runOccupancy(providerParam, threadIdParam)
1134
+ if (!verdict.attachable) return refuseAttach(res, verdict.reason ?? 'probe_failed')
1135
+
1136
+ // `runOccupancy` already proved both, provider first. These narrow the types
1137
+ // rather than re-deciding the rule — a second copy of the id rule is how the
1138
+ // truncated-id hole opened. A failure here is a self-contradicting detector,
1139
+ // and a contradiction refuses.
1140
+ if (!isBindableProvider(providerParam)) return refuseAttach(res, 'unsupported_provider')
1141
+ if (!isValidNativeThreadId(threadIdParam)) return refuseAttach(res, 'invalid_thread_id')
1142
+
1143
+ const key = targetKey(providerParam, threadIdParam)
1144
+ const fenced = guard.fencedReason(key)
1145
+ // A target holding a turn that may already have been delivered does not open
1146
+ // again just because the binding that delivered it is gone. Checked here as
1147
+ // well as in the turn route, because a fresh attach is the obvious way around
1148
+ // a per-binding fence.
1149
+ if (fenced !== null) return refuseAttach(res, fenced)
1150
+
1151
+ const resolve = deps.resolveTarget
1152
+ if (typeof resolve !== 'function') return refuseAttach(res, 'target_unresolvable')
1153
+ let resolved: TargetResolution | null = null
1154
+ try {
1155
+ resolved = resolve(providerParam, threadIdParam)
1156
+ } catch (error) {
1157
+ console.error(`[agent-session-bindings] target resolve threw: ${error instanceof Error ? error.message : error}`)
1158
+ resolved = null
1159
+ }
1160
+ if (!isUsableResolution(resolved)) return refuseAttach(res, 'target_unresolvable')
1161
+
1162
+ // The divergence baseline. No baseline, no attach: plan 4.3 is the only
1163
+ // thing that makes a desktop edit visible, and a binding that cannot run it
1164
+ // would be a binding whose every turn is unchecked.
1165
+ const head = await readHead(providerParam, threadIdParam)
1166
+ if (head === null) return refuseAttach(res, 'native_head_unavailable')
1167
+
1168
+ const bindingId = `bnd-${mintId()}`
1169
+ if (!BINDING_ID_RE.test(bindingId)) return refuseAttach(res, 'attach_failed')
1170
+
1171
+ // `create` reads the epoch from the DURABLE per-target high-water ledger and
1172
+ // refuses a caller-supplied one. Passing `priorEpoch` from a live binding —
1173
+ // the shortcut this deliberately cannot express — is what reopens the replay
1174
+ // window after a detach and eviction. It also enforces one non-terminal
1175
+ // binding per target: a second attach gets `target_busy`, never a re-bind.
1176
+ const created = deps.bindings.create!({
1177
+ bindingId,
1178
+ cosSessionId,
1179
+ provider: providerParam,
1180
+ nativeThreadId: threadIdParam,
1181
+ workspaceFingerprint: resolved!.workspaceFingerprint,
1182
+ sourceFingerprint: resolved!.sourceFingerprint,
1183
+ nativeHeadAtAttach: head.digest,
1184
+ ttlMs: attachTtlMs,
1185
+ now,
1186
+ })
1187
+ if (!created?.binding) return refuseAttach(res, registryRefusal(created?.reason))
1188
+
1189
+ const activated = deps.bindings.activate!(bindingId, now)
1190
+ if (!activated?.binding) {
1191
+ // A staging binding still HOLDS the target. Leaving it there would make
1192
+ // every later attach to this thread fail `target_busy` until the lease
1193
+ // expired, with nothing driving it and nothing to detach.
1194
+ try {
1195
+ deps.bindings.forceDetach!(bindingId, now)
1196
+ } catch (error) {
1197
+ console.error(`[agent-session-bindings] rollback failed: ${error instanceof Error ? error.message : error}`)
1198
+ }
1199
+ return refuseAttach(res, registryRefusal(activated?.reason))
1200
+ }
1201
+
1202
+ res.status(201).json({
1203
+ attached: true,
1204
+ reason: null,
1205
+ reasonCopy: ATTACHED_COPY,
1206
+ bindingId,
1207
+ epoch: activated.binding.epoch,
1208
+ // Digest, not the marker. `boundToMarker` embeds the targetKey, which
1209
+ // embeds the exact private native id, and this router does not put that on
1210
+ // the wire. The digest is deterministic, so the client can hand it back and
1211
+ // the server recomputes it.
1212
+ boundTo: opaqueRevision(boundToMarker(activated.binding)),
1213
+ revision: head.digest,
1214
+ binding: projectBinding(activated.binding, now),
1215
+ })
1216
+ } catch (error) {
1217
+ console.error(`[agent-session-bindings] attach failed: ${error instanceof Error ? error.message : error}`)
1218
+ if (!res.headersSent) refuseAttach(res, 'attach_failed')
1219
+ }
1220
+ })
1221
+
1222
+ // ----------------------------------------------------------------- turns
1223
+ if (attachEnabled) router.post('/agent-sessions/bindings/:bindingId/turns', async (req, res) => {
1224
+ res.set('Cache-Control', 'private, no-store')
1225
+
1226
+ const turnId = mintId()
1227
+ /** The target we hold a claim on, released in the finally. */
1228
+ let claimedKey: string | null = null
1229
+ /** Children the adapter reported, released in the finally. */
1230
+ const recordedPids: number[] = []
1231
+ let pinnedBindingId: string | null = null
1232
+ let requestNow = 0
1233
+ /**
1234
+ * Has the adapter been ENTERED? Everything after this point is ambiguous on
1235
+ * a throw; everything before it is provably undelivered.
1236
+ */
1237
+ let deliveryAttempted = false
1238
+ /** Client idempotency key and its binding. Null until the body is validated. */
1239
+ let clientTurnId: string | null = null
1240
+ let ledgerBindingId: string | null = null
1241
+
1242
+ const respond = (status: number, payload: Record<string, unknown>): void => {
1243
+ if (!res.headersSent) res.status(status).json(payload)
1244
+ // Remembered ONLY when the prompt may have reached the provider. A
1245
+ // pre-delivery refusal (stale epoch, malformed body, busy target) must stay
1246
+ // re-evaluatable: the binding may be fine by the time the client retries, and
1247
+ // replaying a stale "no" would be its own bug.
1248
+ //
1249
+ // `completed` and `ambiguous` are the two that must never run twice. Measured
1250
+ // 2026-08-16: two byte-identical POSTs both returned completed and the user's
1251
+ // real transcript ended up with two copies of the turn.
1252
+ const outcome = payload.outcome
1253
+ if (clientTurnId !== null && ledgerBindingId !== null && (outcome === 'completed' || outcome === 'ambiguous')) {
1254
+ try {
1255
+ deps.bindings.recordTurn?.(ledgerBindingId, clientTurnId, { ...payload, status }, readNow() ?? requestNow)
1256
+ } catch (error) {
1257
+ // A ledger failure must not turn a delivered turn into an error response.
1258
+ // The cost is a lost idempotency record, never a lost turn.
1259
+ console.error(`[agent-session-bindings] turn ledger write failed: ${error instanceof Error ? error.message : error}`)
1260
+ }
1261
+ }
1262
+ }
1263
+ const refuseTurn = (reason: WriteRefusal, extra: Record<string, unknown> = {}): void => {
1264
+ respond(refusalStatus(reason), {
1265
+ turnId,
1266
+ outcome: 'refused',
1267
+ // Every refusal below is reached BEFORE the adapter is entered, so this
1268
+ // default is a statement of fact, not an optimistic guess. The two paths
1269
+ // that cannot say it — the ambiguous outcome and the fence — override it.
1270
+ deliveryState: 'not_delivered',
1271
+ retryable: true,
1272
+ changed: false,
1273
+ revision: null,
1274
+ reason,
1275
+ reasonCopy: writeReasonCopy(reason),
1276
+ ...extra,
1277
+ })
1278
+ }
1279
+ const reportAmbiguous = (): void => {
1280
+ respond(refusalStatus('delivery_ambiguous'), {
1281
+ turnId,
1282
+ outcome: 'ambiguous',
1283
+ // The whole point. "I did not see it land" is not "it did not land", so a
1284
+ // client must never read this as a failure it may retry.
1285
+ deliveryState: 'unknown',
1286
+ retryable: false,
1287
+ changed: false,
1288
+ revision: null,
1289
+ reason: 'delivery_ambiguous',
1290
+ reasonCopy: writeReasonCopy('delivery_ambiguous'),
1291
+ })
1292
+ }
1293
+
1294
+ try {
1295
+ if (!canDetect) return refuseTurn('detector_unavailable')
1296
+ if (!canWriteBindings) return refuseTurn('binding_registry_unwired')
1297
+ const deliver = deps.deliverAttachedTurn
1298
+ if (typeof deliver !== 'function') return refuseTurn('adapter_unwired')
1299
+ if (typeof deps.nativeHead !== 'function') return refuseTurn('native_head_unavailable')
1300
+ if (typeof ownership?.record !== 'function' || typeof ownership?.release !== 'function') {
1301
+ // Without the ledger a spawned child cannot be recognised as ours, so the
1302
+ // next occupancy check reads it as a live foreign owner and the thread
1303
+ // locks itself out. Refusing beats delivering a turn that poisons the next.
1304
+ return refuseTurn('adapter_unwired')
1305
+ }
1306
+
1307
+ const now = readNow()
1308
+ if (now === null) return refuseTurn('binding_registry_unavailable')
1309
+ requestNow = now
1310
+
1311
+ let available: boolean
1312
+ try {
1313
+ available = deps.bindings.available() === true
1314
+ } catch (error) {
1315
+ console.error(`[agent-session-bindings] availability threw: ${error instanceof Error ? error.message : error}`)
1316
+ return refuseTurn('binding_registry_unavailable')
1317
+ }
1318
+ if (!available) return refuseTurn('binding_registry_degraded')
1319
+
1320
+ const bindingId = String(req.params.bindingId ?? '')
1321
+ // A malformed id names no binding. Answered as `unknown_binding` rather than
1322
+ // a distinct shape so a prober cannot tell "wrong format" from "no such
1323
+ // lease".
1324
+ if (!BINDING_ID_RE.test(bindingId)) return refuseTurn('unknown_binding')
1325
+
1326
+ const body = plainBody(req)
1327
+ // Covers the case where no JSON parser ran at all: an unparsed body is not
1328
+ // an empty one, and must not fall through as a turn with no claims to check.
1329
+ if (body === null) return refuseTurn('invalid_request')
1330
+
1331
+ const prompt = body.prompt
1332
+ if (typeof prompt !== 'string' || prompt.trim().length === 0 || prompt.length > maxPromptChars) {
1333
+ return refuseTurn('invalid_request')
1334
+ }
1335
+ const epoch = body.epoch
1336
+ if (!Number.isInteger(epoch) || (epoch as number) < 1) return refuseTurn('invalid_request')
1337
+ const claimedTargetKey = body.targetKey
1338
+ if (typeof claimedTargetKey !== 'string' || claimedTargetKey.length === 0 || claimedTargetKey.length > 512) {
1339
+ return refuseTurn('invalid_request')
1340
+ }
1341
+ const acknowledged = body.acknowledgedRevision
1342
+ if (acknowledged !== undefined && acknowledged !== null && !isOpaque(acknowledged)) {
1343
+ return refuseTurn('invalid_request')
1344
+ }
1345
+ const boundTo = body.boundTo
1346
+ if (boundTo !== undefined && boundTo !== null && !isOpaque(boundTo)) return refuseTurn('invalid_request')
1347
+
1348
+ // REQUIRED, not optional. A turn with no idempotency key cannot be made safe:
1349
+ // the client cannot tell "delivered but the 200 was lost" from "never
1350
+ // arrived", so it will retry, and without a key the server cannot tell that
1351
+ // retry from a new turn. Required rather than defaulted because there are no
1352
+ // existing callers to break - the feature ships dark.
1353
+ const submitted = body.clientTurnId
1354
+ if (typeof submitted !== 'string' || !CLIENT_TURN_ID_RE.test(submitted)) {
1355
+ return refuseTurn('invalid_request')
1356
+ }
1357
+ clientTurnId = submitted
1358
+ ledgerBindingId = bindingId
1359
+
1360
+ // REPLAY, before any occupancy check, head read, or spawn: if this exact turn
1361
+ // already reached a terminal state, hand back what it actually did.
1362
+ const already = deps.bindings.findTurn?.(bindingId, clientTurnId) ?? null
1363
+ if (already !== null && already.result && typeof already.result === 'object') {
1364
+ const { status, ...rest } = already.result as Record<string, unknown>
1365
+ if (!res.headersSent) {
1366
+ res.status(typeof status === 'number' ? status : 200).json({ ...rest, replayed: true })
1367
+ }
1368
+ return
1369
+ }
1370
+
1371
+ // The client-queued-prompt gate, used rather than reimplemented: it is the
1372
+ // one place that orders state before epoch before target, and a second
1373
+ // opinion here is how the store's own header says the two drifted apart.
1374
+ const gate = deps.bindings.checkQueuedPrompt!({ bindingId, epoch: epoch as number, targetKey: claimedTargetKey }, now)
1375
+ if (gate?.ok !== true) return refuseTurn(registryRefusal(gate?.reason))
1376
+
1377
+ const binding = deps.bindings.get!(bindingId)
1378
+ // Only `active` runs work. `staging` is the pre-commit state of the journaled
1379
+ // Chat handoff and must never execute against a Chat that can still roll back.
1380
+ const usable = assertUsable(binding ?? null, now)
1381
+ if (usable.ok !== true || !binding) return refuseTurn(registryRefusal(usable.reason ?? 'unknown_binding'))
1382
+ if (binding.targetKey !== claimedTargetKey) return refuseTurn('target_mismatch')
1383
+ if (binding.epoch !== epoch) return refuseTurn('stale_epoch')
1384
+ if (typeof boundTo === 'string' && boundTo !== opaqueRevision(boundToMarker(binding))) {
1385
+ return refuseTurn('target_mismatch')
1386
+ }
1387
+ if (!isBindableProvider(binding.provider) || !isValidNativeThreadId(binding.nativeThreadId)) {
1388
+ return refuseTurn('binding_unusable')
1389
+ }
1390
+
1391
+ const key = binding.targetKey
1392
+ const fenced = guard.fencedReason(key)
1393
+ if (fenced !== null) {
1394
+ return refuseTurn(fenced, { retryable: false, deliveryState: 'unknown' })
1395
+ }
1396
+
1397
+ // LAST SYNCHRONOUS STATEMENT BEFORE THE FIRST AWAIT. Check-and-set in one
1398
+ // call: with an await between them, two turns admitted in the same tick both
1399
+ // see a free target and both deliver.
1400
+ if (!guard.tryClaim(key, turnId)) return refuseTurn('native_turn_in_progress')
1401
+ claimedKey = key
1402
+
1403
+ // Plan 4.3 step 6. The attach-time verdict is minutes old by now; a desktop
1404
+ // session started in the gap is exactly the residual risk option B leaves
1405
+ // open, and it is terminal here rather than a warning because COS has no
1406
+ // cross-process lock that could fence a live desktop writer.
1407
+ const verdict = runOccupancy(binding.provider, binding.nativeThreadId)
1408
+ if (!verdict.attachable) return refuseTurn(verdict.reason ?? 'probe_failed')
1409
+
1410
+ const head = await readHead(binding.provider, binding.nativeThreadId)
1411
+ if (head === null) return refuseTurn('native_head_unavailable')
1412
+
1413
+ // The attach baseline, advanced by each completed turn and by each explicit
1414
+ // Continue Anyway. Without the advance the SECOND turn on a binding always
1415
+ // reads as diverged, because the first turn is what moved the head.
1416
+ const baseline = guard.acknowledgedHead(bindingId) ?? binding.nativeHeadAtAttach
1417
+ if (typeof baseline !== 'string' || baseline.length === 0) return refuseTurn('native_head_unavailable')
1418
+ if (head.digest !== baseline) {
1419
+ // Only a changed/not-changed signal and a new opaque revision. No diff, no
1420
+ // content, no path — the client is told THAT it moved, never to what.
1421
+ if (acknowledged !== head.digest) {
1422
+ return refuseTurn('native_thread_changed', { changed: true, revision: head.digest })
1423
+ }
1424
+ // Continue Anyway: a new admission carrying the acknowledged revision.
1425
+ // Recorded now rather than on completion, because the user acknowledged
1426
+ // this revision whatever the turn goes on to do.
1427
+ guard.acknowledgeHead(bindingId, head.digest)
1428
+ }
1429
+
1430
+ // A failed pin is FATAL, per the registry's own caller contract: an unpinned
1431
+ // binding can expire or be detached mid-turn, which defeats the lease.
1432
+ const pinned = deps.bindings.pin!(bindingId, turnId, now)
1433
+ if (!pinned?.binding) return refuseTurn(registryRefusal(pinned?.reason))
1434
+ pinnedBindingId = bindingId
1435
+
1436
+ let delivery: Delivery
1437
+ deliveryAttempted = true
1438
+ try {
1439
+ const result = await deliver({
1440
+ turnId,
1441
+ bindingId,
1442
+ epoch: binding.epoch,
1443
+ provider: binding.provider,
1444
+ nativeThreadId: binding.nativeThreadId,
1445
+ workspaceFingerprint: binding.workspaceFingerprint,
1446
+ sourceFingerprint: binding.sourceFingerprint,
1447
+ expectedNativeHead: head.raw,
1448
+ prompt,
1449
+ onSpawn: (pid: number): boolean => {
1450
+ // THE SELF-RECURSION ORDER. The child registers itself against the id
1451
+ // we are targeting, so unless it is in the ledger the next occupancy
1452
+ // check reads our own process as a live foreign owner.
1453
+ let startMs: number | null = null
1454
+ try {
1455
+ // MEASURED, never `Date.now()`. The wall clock drifts up to 992 ms
1456
+ // against a 1500 ms tolerance, and a near-miss silently disables
1457
+ // self-identification instead of failing loudly.
1458
+ startMs = deps.probes.processStartMs(pid)
1459
+ } catch {
1460
+ startMs = null
1461
+ }
1462
+ if (typeof startMs !== 'number' || !Number.isFinite(startMs)) return false
1463
+ let outcome: string
1464
+ try {
1465
+ outcome = ownership.record(pid, startMs)
1466
+ } catch {
1467
+ return false
1468
+ }
1469
+ // The ledger reports WHY it refused. Anything but an accepted claim
1470
+ // means this child is unrecognisable to us, so the adapter must abort
1471
+ // before the prompt rather than deliver a turn that poisons the next
1472
+ // occupancy check.
1473
+ if (outcome !== 'recorded') return false
1474
+ recordedPids.push(pid)
1475
+ return true
1476
+ },
1477
+ })
1478
+ delivery = classifyDelivery(result)
1479
+ } catch (error) {
1480
+ console.error(`[agent-session-bindings] adapter threw: ${error instanceof Error ? error.message : error}`)
1481
+ delivery = { kind: 'ambiguous' }
1482
+ }
1483
+
1484
+ if (delivery.kind === 'aborted') return refuseTurn('provider_never_opened')
1485
+
1486
+ if (delivery.kind === 'ambiguous') {
1487
+ // Plan 4.6: the reservation is HELD, not released. A hand-crafted next
1488
+ // admission — the client Retry button that mints a fresh generation and
1489
+ // clears every other fence — has to hit something server-side, and this is
1490
+ // it. The fence outlives the binding, so re-attaching does not open it.
1491
+ //
1492
+ // Fenced under its own reason, not this turn's: `delivery_ambiguous`
1493
+ // describes what happened to THIS request, while a later caller needs to
1494
+ // be told the thread is shut and why it must be inspected first.
1495
+ guard.fence(key, 'native_target_fenced')
1496
+ return reportAmbiguous()
1497
+ }
1498
+
1499
+ let after = delivery.after === null ? null : opaqueRevision(delivery.after)
1500
+ if (after === null) {
1501
+ // Best effort. If it fails the baseline simply does not advance and the
1502
+ // next turn asks for an acknowledgement — conservative, never permissive.
1503
+ const reread = await readHead(binding.provider, binding.nativeThreadId)
1504
+ after = reread === null ? null : reread.digest
1505
+ }
1506
+ if (after !== null) guard.acknowledgeHead(bindingId, after)
1507
+
1508
+ // Phase 4 owns Message persistence. Nothing about the prompt or the reply is
1509
+ // written, logged or echoed here; the terminal outcome is the whole result.
1510
+ respond(200, {
1511
+ turnId,
1512
+ outcome: 'completed',
1513
+ deliveryState: 'delivered',
1514
+ retryable: false,
1515
+ changed: false,
1516
+ revision: after,
1517
+ reason: null,
1518
+ reasonCopy: TURN_SENT_COPY,
1519
+ })
1520
+ } catch (error) {
1521
+ console.error(`[agent-session-bindings] turn failed: ${error instanceof Error ? error.message : error}`)
1522
+ if (deliveryAttempted) {
1523
+ // A bug in this route that happened AROUND a delivery is indistinguishable
1524
+ // from a delivery.
1525
+ if (claimedKey !== null) guard.fence(claimedKey, 'native_target_fenced')
1526
+ reportAmbiguous()
1527
+ } else {
1528
+ refuseTurn('turn_failed')
1529
+ }
1530
+ } finally {
1531
+ for (const pid of recordedPids) {
1532
+ try {
1533
+ ownership.release(pid)
1534
+ } catch (error) {
1535
+ console.error(`[agent-session-bindings] spawn release failed: ${error instanceof Error ? error.message : error}`)
1536
+ }
1537
+ }
1538
+ if (pinnedBindingId !== null) {
1539
+ try {
1540
+ deps.bindings.unpin!(pinnedBindingId, turnId, readNow() ?? requestNow)
1541
+ } catch (error) {
1542
+ console.error(`[agent-session-bindings] unpin failed: ${error instanceof Error ? error.message : error}`)
1543
+ }
1544
+ }
1545
+ if (claimedKey !== null) guard.release(claimedKey, turnId)
1546
+ }
1547
+ })
1548
+
1549
+ return router
1550
+ }