@gotcos/glasses-server 6.27.13 → 6.29.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,2024 @@
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
+ // --------------------------------------------------------------- fork side
335
+
336
+ /**
337
+ * `forkThread` from `server/lib/fork-thread.ts`.
338
+ *
339
+ * Wire it as `req => forkThread({ ...req, deps: realForkDeps(watermark) })`.
340
+ * Unwired means the fork route refuses; it never falls back to the attached
341
+ * adapter, which would append to the very thread fork exists to leave alone.
342
+ */
343
+ forkThread?: (request: ForkRouteRequest) => Promise<unknown> | unknown
344
+
345
+ /**
346
+ * Absolute directory the forked provider run happens in, or null.
347
+ *
348
+ * SEPARATE from `resolveTarget` on purpose: that one deliberately returns only
349
+ * fingerprints, because plan 3.3 keeps a filesystem path off anything
350
+ * client-visible, and a fork needs a real path to spawn in. Wire it as
351
+ * `(p, id) => resolveAttachedWorkspace(p, id, deps)?.path ?? null`. Null is a
352
+ * refusal — `attached-workspace.ts` records that a wrong cwd makes the provider
353
+ * write a NEW session rather than the one asked for, which for a fork means the
354
+ * copy silently lands in the wrong project.
355
+ */
356
+ resolveForkWorkspace?: (provider: BindableProvider, nativeThreadId: string) => string | null
357
+
358
+ /**
359
+ * Where an opaque fork reference is exchanged for the thread it names.
360
+ *
361
+ * Defaults to a per-router instance. Injectable so the follow-on work — teaching
362
+ * attach to accept a `forkRef` instead of a native id in the path — can share
363
+ * one store between the two routes rather than inventing a second.
364
+ */
365
+ forkRefs?: ForkRefStore
366
+
367
+ /** Lease TTL for a new binding. */
368
+ attachTtlMs?: number
369
+ /** Upper bound on one prompt. A prompt is not a payload. */
370
+ maxPromptChars?: number
371
+ /** Injected so a test can pin the minted ids. Must return an id matching BINDING_ID_RE. */
372
+ newId?: () => string
373
+ }
374
+
375
+ /**
376
+ * Human-facing footer copy, one line per reason.
377
+ *
378
+ * `Record<OccupancyReason, string>` is the enforcement: adding a member to the
379
+ * union without adding copy here is a compile error, so a new reason cannot ship
380
+ * as a blank footer. The test then drives every one of these through the real
381
+ * route, so a reason that exists in the map but is unreachable also fails.
382
+ *
383
+ * Plan 4.3 fixes the wording of the case that matters: a thread open on the
384
+ * desktop must read as a deliberate safety behavior, not a malfunction, and must
385
+ * offer Fork. No slashes in any string here, so the redaction assertion can be a
386
+ * flat "the body contains no path separator".
387
+ */
388
+ export const REASON_COPY: Record<OccupancyReason, string> = {
389
+ // Not a fault and not a busy thread - the write feature is off. The copy says so
390
+ // plainly rather than implying something is wrong, because Fork-only is a
391
+ // supported permanent configuration, not a degraded one (plan 4.9).
392
+ attach_disabled:
393
+ 'Continuing a thread on your Mac is turned off. COS is read-only here. Fork it instead.',
394
+ live_desktop_process:
395
+ 'Open on your Mac. COS will not write into a thread another app is holding. Fork it instead.',
396
+ unsupported_provider:
397
+ 'This assistant cannot be continued from COS yet. Fork it instead.',
398
+ invalid_thread_id:
399
+ 'That thread reference is not a valid id. Fork it instead.',
400
+ detector_unavailable:
401
+ 'COS cannot check for a live owner on this Mac, so it will not continue this thread. Fork it instead.',
402
+ registry_unreadable:
403
+ 'COS could not read the session records for this thread, so it will not continue it. Fork it instead.',
404
+ unverifiable_process_start:
405
+ 'COS could not confirm which process holds this thread, so it will not continue it. Fork it instead.',
406
+ unverifiable_liveness_socket:
407
+ 'COS could not confirm the owning app is still live, so it will not continue this thread. Fork it instead.',
408
+ probe_failed:
409
+ 'The live owner check failed, so COS will not continue this thread. Fork it instead.',
410
+ }
411
+
412
+ export const ATTACHABLE_COPY = 'Ready to continue in the original thread.'
413
+
414
+ /**
415
+ * Copy for a reason string this build does not recognise.
416
+ *
417
+ * Reachable only if the detector returns a value outside its own union, i.e. a
418
+ * bug. The safe rendering of a bug is a refusal with words, never an empty
419
+ * footer and never a silent attach.
420
+ */
421
+ export const UNKNOWN_REASON_COPY = 'COS could not establish whether this thread is free. Fork it instead.'
422
+
423
+ export function reasonCopy(reason: OccupancyReason | null): string {
424
+ if (reason === null) return ATTACHABLE_COPY
425
+ const copy = Object.prototype.hasOwnProperty.call(REASON_COPY, reason) ? REASON_COPY[reason] : undefined
426
+ return typeof copy === 'string' && copy.length > 0 ? copy : UNKNOWN_REASON_COPY
427
+ }
428
+
429
+ /**
430
+ * Every way a write can be refused.
431
+ *
432
+ * Supersets `OccupancyReason` so the attach refusal for a live desktop owner is
433
+ * literally the same value and the same footer line the attachability probe
434
+ * already returns — a lens that renders one renders the other.
435
+ */
436
+ export type WriteRefusal =
437
+ | OccupancyReason
438
+ | 'invalid_request'
439
+ | 'binding_registry_unwired'
440
+ | 'binding_registry_degraded'
441
+ | 'binding_registry_unavailable'
442
+ | 'target_unresolvable'
443
+ | 'native_head_unavailable'
444
+ | 'native_thread_changed'
445
+ | 'native_target_busy'
446
+ | 'native_turn_in_progress'
447
+ | 'native_target_fenced'
448
+ | 'attach_failed'
449
+ | 'unknown_binding'
450
+ | 'binding_not_active'
451
+ | 'binding_detached'
452
+ | 'binding_expired'
453
+ | 'stale_epoch'
454
+ | 'target_mismatch'
455
+ | 'binding_unusable'
456
+ | 'pin_failed'
457
+ | 'adapter_unwired'
458
+ | 'provider_never_opened'
459
+ | 'delivery_ambiguous'
460
+ | 'turn_failed'
461
+ // ------------------------------------------------------------------- fork
462
+ //
463
+ // Fork gets its OWN members rather than reusing the ones above, and the reason
464
+ // is entirely in the copy. Every continuation refusal ends with the words "Fork
465
+ // it instead", because fork is the fallback. Reusing one of them on the fork
466
+ // route tells a user whose fork just failed to go and fork it — which reads as
467
+ // a bug, and leaves them with no next step at all. So `unsupported_provider`
468
+ // and `fork_unsupported_provider` are the same condition with different
469
+ // endings, deliberately, and neither route may borrow the other's.
470
+ | 'fork_unwired'
471
+ | 'fork_unsupported_provider'
472
+ | 'fork_invalid_thread_id'
473
+ | 'fork_workspace_unresolvable'
474
+ | 'fork_in_progress'
475
+ | 'fork_failed'
476
+ | 'fork_source_mutated'
477
+ | 'fork_orphan_possible'
478
+
479
+ /**
480
+ * Footer copy for the refusals that are not occupancy reasons.
481
+ *
482
+ * Same enforcement as `REASON_COPY`: `Record<…, string>` means a new member of
483
+ * the union cannot ship without copy, and the suite drives every key through a
484
+ * real route so a member that has copy but is unreachable fails too. No slashes
485
+ * in any string, which keeps the "nothing this router returns is path-shaped"
486
+ * assertion a flat substring check.
487
+ */
488
+ export const WRITE_REASON_COPY: Record<Exclude<WriteRefusal, OccupancyReason>, string> = {
489
+ invalid_request:
490
+ 'That request was not something COS could read. Nothing was sent.',
491
+ binding_registry_unwired:
492
+ 'This build cannot record a continuation, so it will not start one. Fork it instead.',
493
+ binding_registry_degraded:
494
+ 'COS could not read its own continuation records, so it will not continue this thread. Fork it instead.',
495
+ binding_registry_unavailable:
496
+ 'COS could not read its own continuation records, so it will not continue this thread. Fork it instead.',
497
+ target_unresolvable:
498
+ 'COS could not work out where this thread lives, so it will not continue it. Fork it instead.',
499
+ native_head_unavailable:
500
+ 'COS could not read where this thread currently ends, so it will not write to it. Fork it instead.',
501
+ native_thread_changed:
502
+ 'This thread changed on your Mac since you attached. Refresh, continue anyway, or fork.',
503
+ native_target_busy:
504
+ 'This thread is already attached to another COS chat. Detach that one first.',
505
+ native_turn_in_progress:
506
+ 'A COS turn is already running on this thread. Wait for it to finish.',
507
+ native_target_fenced:
508
+ 'An earlier turn on this thread may or may not have been delivered. Open the thread on your Mac and check before sending again.',
509
+ attach_failed:
510
+ 'COS could not record a continuation for this thread. Fork it instead.',
511
+ unknown_binding:
512
+ 'That continuation is no longer on record. Attach again.',
513
+ binding_not_active:
514
+ 'That continuation is not live yet. Attach again.',
515
+ binding_detached:
516
+ 'That continuation was detached. Attach again.',
517
+ binding_expired:
518
+ 'That continuation timed out. Attach again.',
519
+ stale_epoch:
520
+ 'This prompt was written against an earlier attach of the same thread. It was not sent. Attach again.',
521
+ target_mismatch:
522
+ 'This prompt names a different thread than the continuation it claims. It was not sent.',
523
+ binding_unusable:
524
+ 'That continuation cannot run work. Attach again.',
525
+ pin_failed:
526
+ 'COS could not hold the continuation open for this turn, so it did not send it.',
527
+ adapter_unwired:
528
+ 'This build cannot drive the original thread yet. Fork it instead.',
529
+ provider_never_opened:
530
+ 'The assistant never opened the thread, so nothing was sent. You can try again.',
531
+ delivery_ambiguous:
532
+ 'COS lost track of this turn after sending it. Open the thread on your Mac and check before sending again.',
533
+ turn_failed:
534
+ 'COS could not run this turn. Nothing was sent. You can try again.',
535
+
536
+ // Fork copy. No sentence here may end with "Fork it instead" — this IS the fork,
537
+ // and pointing a failed fork back at itself is a dead end rather than an action.
538
+ fork_unwired:
539
+ 'This build cannot copy a thread into a new one. Open it on your Mac instead.',
540
+ fork_unsupported_provider:
541
+ 'This assistant cannot be copied into a new thread from COS yet.',
542
+ fork_invalid_thread_id:
543
+ 'That thread reference is not a valid id, so there is nothing to copy.',
544
+ fork_workspace_unresolvable:
545
+ 'COS could not work out where this thread lives, so it will not copy it. Open it on your Mac instead.',
546
+ fork_in_progress:
547
+ 'A copy of this thread is already being made. Wait for it to finish.',
548
+ fork_failed:
549
+ 'COS could not copy this thread. Your original is untouched. You can try again.',
550
+ // The one outcome this whole feature exists to prevent, reported plainly. No
551
+ // retry offered: the user needs to look at the original before anything else
552
+ // touches it.
553
+ fork_source_mutated:
554
+ 'The original thread changed while COS was copying it. Open the original on your Mac and check it before doing anything else.',
555
+ fork_orphan_possible:
556
+ 'COS lost track of the copy it was making. Your original is untouched, but a partial copy may exist on your Mac.',
557
+ }
558
+
559
+ export function writeReasonCopy(reason: WriteRefusal): string {
560
+ if (Object.prototype.hasOwnProperty.call(REASON_COPY, reason)) {
561
+ return REASON_COPY[reason as OccupancyReason]
562
+ }
563
+ const copy = Object.prototype.hasOwnProperty.call(WRITE_REASON_COPY, reason)
564
+ ? WRITE_REASON_COPY[reason as Exclude<WriteRefusal, OccupancyReason>]
565
+ : undefined
566
+ return typeof copy === 'string' && copy.length > 0 ? copy : UNKNOWN_REASON_COPY
567
+ }
568
+
569
+ /**
570
+ * Refusals that mean "this build or this machine cannot do it", answered 503.
571
+ *
572
+ * Everything else that is not a malformed request is 409: a conflict with the
573
+ * state of the world, which the user can act on. The split lives in one table so
574
+ * the two routes cannot drift, and so a new refusal defaults to 409 — the
575
+ * conservative choice, since a client is far more likely to auto-retry a 503.
576
+ */
577
+ const CAPABILITY_REFUSALS: ReadonlySet<WriteRefusal> = new Set<WriteRefusal>([
578
+ 'detector_unavailable',
579
+ 'binding_registry_unwired',
580
+ 'binding_registry_degraded',
581
+ 'binding_registry_unavailable',
582
+ 'adapter_unwired',
583
+ 'fork_unwired',
584
+ ])
585
+
586
+ export function refusalStatus(reason: WriteRefusal): number {
587
+ if (reason === 'invalid_request') return 400
588
+ return CAPABILITY_REFUSALS.has(reason) ? 503 : 409
589
+ }
590
+
591
+ /**
592
+ * Map anything the binding registry can say onto a refusal with copy.
593
+ *
594
+ * `Record<RegistryRejection, WriteRefusal>` is the point: the registry owns that
595
+ * union and can grow it, and this must not compile if it does. Several members
596
+ * are unreachable through these two routes (`invalid_ttl` needs a bad TTL, which
597
+ * this router supplies itself) and collapse onto a generic refusal rather than
598
+ * inventing copy nobody can ever see.
599
+ */
600
+ export const REGISTRY_REJECTION_REFUSAL: Record<RegistryRejection, WriteRefusal> = {
601
+ // Value-type rejections.
602
+ invalid_thread_id: 'invalid_thread_id',
603
+ invalid_provider: 'unsupported_provider',
604
+ invalid_binding_id: 'unknown_binding',
605
+ invalid_epoch: 'invalid_request',
606
+ invalid_ttl: 'attach_failed',
607
+ unknown_binding: 'unknown_binding',
608
+ binding_not_active: 'binding_not_active',
609
+ binding_detached: 'binding_detached',
610
+ binding_expired: 'binding_expired',
611
+ stale_epoch: 'stale_epoch',
612
+ target_mismatch: 'target_mismatch',
613
+ missing_target_key: 'invalid_request',
614
+ binding_pinned: 'binding_unusable',
615
+ terminal_state: 'binding_unusable',
616
+ // Registry-level rejections.
617
+ store_unavailable: 'binding_registry_degraded',
618
+ persist_failed: 'attach_failed',
619
+ reentrant_mutation: 'attach_failed',
620
+ target_busy: 'native_target_busy',
621
+ binding_id_in_use: 'attach_failed',
622
+ invalid_job_id: 'pin_failed',
623
+ too_many_pins: 'pin_failed',
624
+ registry_full: 'attach_failed',
625
+ epoch_ledger_full: 'attach_failed',
626
+ caller_supplied_epoch: 'attach_failed',
627
+ }
628
+
629
+ export function registryRefusal(reason: RegistryRejection | null | undefined): WriteRefusal {
630
+ if (typeof reason !== 'string') return 'attach_failed'
631
+ return Object.prototype.hasOwnProperty.call(REGISTRY_REJECTION_REFUSAL, reason)
632
+ ? REGISTRY_REJECTION_REFUSAL[reason]
633
+ : 'attach_failed'
634
+ }
635
+
636
+ /**
637
+ * Bounded, deterministic, non-identifying stand-in for a provider revision token
638
+ * or a `boundTo` marker.
639
+ *
640
+ * WHY NOT THE RAW VALUES. `boundToMarker` is length-prefixed over
641
+ * bindingId + epoch + targetKey, and targetKey embeds the exact private native
642
+ * thread id — so returning the raw marker would put the native id on the wire
643
+ * through the one route whose entire redaction contract says it never does. The
644
+ * revision token comes from a module this one does not own, and plan 4.3 only
645
+ * ASKS that it carry no content or path; asking is not enforcing. A digest makes
646
+ * both true by construction, stays comparable across requests, and the client can
647
+ * hand it back for the Continue Anyway acknowledgement.
648
+ *
649
+ * NOT A CAPABILITY TOKEN. It is a digest of values the attaching client already
650
+ * holds, so it proves recognisability, not authority. Authorization is
651
+ * `requireApiToken` at the app level, exactly as for every other route here.
652
+ */
653
+ export function opaqueRevision(value: string): string {
654
+ return createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 32)
655
+ }
656
+
657
+ /** Shape of an opaque value coming back from a client. 128 bits of hex, nothing else. */
658
+ export const OPAQUE_RE = /^[0-9a-f]{32}$/
659
+
660
+ export interface AttachabilityBody {
661
+ attachable: boolean
662
+ reason: OccupancyReason | null
663
+ reasonCopy: string
664
+ ownerCount: number
665
+ }
666
+
667
+ /**
668
+ * Project a verdict to the wire, re-checking its internal consistency.
669
+ *
670
+ * The detector is trusted to be correct; it is not trusted to STAY correct. Three
671
+ * shapes are contradictions rather than verdicts, and each one resolves the
672
+ * permissive way if simply forwarded: attachable with a reason, attachable with an
673
+ * owner that is not provably ours, and a non-array owners field. Any of them is a
674
+ * defect upstream, and a defect must not resolve to permissive.
675
+ */
676
+ export function projectAttachability(verdict: Occupancy): AttachabilityBody {
677
+ const owners = Array.isArray(verdict?.owners) ? verdict.owners : null
678
+ const sound =
679
+ verdict?.attachable === true &&
680
+ verdict.reason === null &&
681
+ owners !== null &&
682
+ owners.every(owner => owner?.selfOwned === true)
683
+ const reason: OccupancyReason | null = sound ? null : ((verdict?.reason ?? 'probe_failed') as OccupancyReason)
684
+ return {
685
+ attachable: sound,
686
+ reason,
687
+ reasonCopy: reasonCopy(sound ? null : reason),
688
+ // Total owners, self-owned included. Reporting only foreign owners would make
689
+ // the count read lower than reality, and this number exists so a client can
690
+ // never be MORE confident than the server. Attachability is `attachable`,
691
+ // never an inference from this field being zero.
692
+ ownerCount: owners === null ? 0 : owners.length,
693
+ }
694
+ }
695
+
696
+ /** Compile-time exhaustive membership test for a binding state. */
697
+ const BINDING_STATES: Record<BindingState, true> = {
698
+ staging: true,
699
+ active: true,
700
+ detaching: true,
701
+ detached: true,
702
+ }
703
+
704
+ function isBindingState(value: unknown): value is BindingState {
705
+ return typeof value === 'string' && Object.prototype.hasOwnProperty.call(BINDING_STATES, value)
706
+ }
707
+
708
+ /**
709
+ * Is this registry row trustworthy enough to describe on the wire?
710
+ *
711
+ * Validated with the SHARED id validator, never a local copy: native-thread-id.ts
712
+ * exists because two modules written in the same session disagreed about what an
713
+ * id is, and a truncated id then sailed through occupancy as attachable.
714
+ */
715
+ export function isUsableBindingRow(value: unknown): value is NativeBinding {
716
+ if (!value || typeof value !== 'object') return false
717
+ const row = value as Partial<NativeBinding>
718
+ if (typeof row.bindingId !== 'string' || !BINDING_ID_RE.test(row.bindingId)) return false
719
+ if (!isBindableProvider(row.provider)) return false
720
+ if (!isValidNativeThreadId(row.nativeThreadId)) return false
721
+ if (!isBindingState(row.state)) return false
722
+ if (!Number.isInteger(row.epoch) || (row.epoch as number) < 1) return false
723
+ if (typeof row.expiresAt !== 'number' || !Number.isFinite(row.expiresAt)) return false
724
+ if (!Array.isArray(row.pinnedJobs)) return false
725
+ return true
726
+ }
727
+
728
+ export interface BindingSummary {
729
+ bindingId: string
730
+ provider: string
731
+ state: BindingState
732
+ epoch: number
733
+ expiresAt: number
734
+ pinned: boolean
735
+ expired: boolean
736
+ }
737
+
738
+ /**
739
+ * Redacted binding row.
740
+ *
741
+ * Dropped on purpose, each because it is either identifying or forgeable:
742
+ * `nativeThreadId` (the private native id), `targetKey` (the mutex key plan 3.3
743
+ * names explicitly), `cosSessionId` (client-supplied, and SAFE_ID_RE permits '/'
744
+ * inside it), both fingerprints (nothing constrains them to be hashes rather than
745
+ * raw paths), `nativeHeadAtAttach` (an opaque revision nobody needs in a list) and
746
+ * `pinnedJobs` (ids, where a boolean answers the only question a list view asks).
747
+ */
748
+ export function projectBinding(binding: NativeBinding, now: number): BindingSummary {
749
+ return {
750
+ bindingId: binding.bindingId,
751
+ provider: binding.provider,
752
+ state: binding.state,
753
+ epoch: binding.epoch,
754
+ expiresAt: binding.expiresAt,
755
+ pinned: isPinned(binding),
756
+ expired: isExpired(binding, now),
757
+ }
758
+ }
759
+
760
+ function occupancyDepsUsable(deps: AgentSessionBindingsDeps): boolean {
761
+ const probes = deps?.probes
762
+ const dirs = deps?.dirs
763
+ return (
764
+ !!probes &&
765
+ typeof probes.dirExists === 'function' &&
766
+ typeof probes.readDir === 'function' &&
767
+ typeof probes.readFile === 'function' &&
768
+ typeof probes.isAlive === 'function' &&
769
+ typeof probes.processStartMs === 'function' &&
770
+ typeof probes.fileExists === 'function' &&
771
+ typeof probes.lockHolders === 'function' &&
772
+ typeof probes.cosSpawnedPids === 'function' &&
773
+ !!dirs &&
774
+ typeof dirs.claudeSessionsDir === 'string' &&
775
+ typeof dirs.codexLocksDir === 'string'
776
+ )
777
+ }
778
+
779
+ function bindingDepsUsable(deps: AgentSessionBindingsDeps): boolean {
780
+ return (
781
+ !!deps?.bindings &&
782
+ typeof deps.bindings.list === 'function' &&
783
+ typeof deps.bindings.available === 'function' &&
784
+ typeof deps?.now === 'function'
785
+ )
786
+ }
787
+
788
+ /** Can this build mint and drive a binding at all, or only describe one? */
789
+ function bindingWriteDepsUsable(deps: AgentSessionBindingsDeps): boolean {
790
+ const b = deps?.bindings
791
+ return (
792
+ bindingDepsUsable(deps) &&
793
+ typeof b?.create === 'function' &&
794
+ typeof b?.activate === 'function' &&
795
+ typeof b?.forceDetach === 'function' &&
796
+ typeof b?.get === 'function' &&
797
+ typeof b?.checkQueuedPrompt === 'function' &&
798
+ typeof b?.pin === 'function' &&
799
+ typeof b?.unpin === 'function'
800
+ )
801
+ }
802
+
803
+ export const DEFAULT_ATTACH_TTL_MS = 30 * 60_000
804
+ export const DEFAULT_MAX_PROMPT_CHARS = 32_000
805
+ /** Bindings whose advanced head is remembered. See `acknowledgeHead`. */
806
+ export const MAX_TRACKED_HEADS = 512
807
+
808
+ /** A COS session id may contain ':' and '/', which is exactly why it is never projected. */
809
+ export const COS_SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/
810
+
811
+ /**
812
+ * The in-process half of plan 4.5 and 4.6: one COS turn per native target, and a
813
+ * target that may already hold an undelivered turn stays shut.
814
+ *
815
+ * NOT DURABLE, AND THAT IS A STATED GAP. Plan 4.5 wants the reservation
816
+ * persisted in the job journal and rehydrated on boot, and 4.6 item 3 wants the
817
+ * fence to have its own lifecycle and an operator release path. Both belong to
818
+ * Phase 2, which owns that journal. What lives here is the process-lifetime
819
+ * version, which is enough to make the two properties true for a running server
820
+ * and fails in the safe direction on restart: a claim is released (no turn is
821
+ * running after a restart anyway) and a FENCE is lost, which is the one that
822
+ * matters and is why it is called out rather than implied.
823
+ */
824
+ class TargetGuard {
825
+ /** targetKey -> turnId of the single COS turn allowed to be in flight. */
826
+ private readonly claims = new Map<string, string>()
827
+ /** targetKey -> why no further turn may be delivered. */
828
+ private readonly fences = new Map<string, WriteRefusal>()
829
+ /** bindingId -> the head digest this binding is currently reconciled to. */
830
+ private readonly heads = new Map<string, string>()
831
+
832
+ /**
833
+ * Check and set in ONE synchronous step.
834
+ *
835
+ * Plan 4.5 requirement 1 is a "synchronous, non-blocking target reservation
836
+ * check" that returns immediately, and the atomicity is load-bearing: with an
837
+ * await between the read and the write, two turns admitted in the same tick
838
+ * both see a free target. Every caller must reach this before its first await.
839
+ */
840
+ tryClaim(targetKey: string, turnId: string): boolean {
841
+ if (this.claims.has(targetKey)) return false
842
+ this.claims.set(targetKey, turnId)
843
+ return true
844
+ }
845
+
846
+ /** Only the holder may release, so a late unwind cannot free someone else's claim. */
847
+ release(targetKey: string, turnId: string): void {
848
+ if (this.claims.get(targetKey) === turnId) this.claims.delete(targetKey)
849
+ }
850
+
851
+ fence(targetKey: string, reason: WriteRefusal): void {
852
+ if (!this.fences.has(targetKey)) this.fences.set(targetKey, reason)
853
+ }
854
+
855
+ fencedReason(targetKey: string): WriteRefusal | null {
856
+ return this.fences.get(targetKey) ?? null
857
+ }
858
+
859
+ /**
860
+ * The head this binding is reconciled to: the attach baseline, then whatever
861
+ * the user acknowledged or a completed turn produced.
862
+ *
863
+ * Without this the SECOND turn on a binding always fails the divergence check,
864
+ * because the first turn moved the head itself.
865
+ */
866
+ acknowledgedHead(bindingId: string): string | null {
867
+ return this.heads.get(bindingId) ?? null
868
+ }
869
+
870
+ acknowledgeHead(bindingId: string, digest: string): void {
871
+ // Re-insert so the eviction order is by last use.
872
+ this.heads.delete(bindingId)
873
+ this.heads.set(bindingId, digest)
874
+ while (this.heads.size > MAX_TRACKED_HEADS) {
875
+ const oldest = this.heads.keys().next()
876
+ if (oldest.done) break
877
+ this.heads.delete(oldest.value)
878
+ }
879
+ // Eviction here is safe in a way eviction almost never is in this feature:
880
+ // losing an advance makes the next turn fall back to the ATTACH baseline, so
881
+ // it sees a changed head and asks the user to acknowledge. Strictly more
882
+ // conservative, never less.
883
+ }
884
+ }
885
+
886
+ type Delivery =
887
+ | { kind: 'completed'; after: string | null }
888
+ | { kind: 'aborted' }
889
+ | { kind: 'ambiguous' }
890
+
891
+ /**
892
+ * Read an adapter result without believing anything it did not say.
893
+ *
894
+ * The default is ambiguous, and every unrecognised shape lands there: null, an
895
+ * array, a missing status, a status from a newer adapter. Only the two literals
896
+ * this build understands are allowed to mean anything.
897
+ */
898
+ export function classifyDelivery(result: unknown): Delivery {
899
+ if (!result || typeof result !== 'object' || Array.isArray(result)) return { kind: 'ambiguous' }
900
+ const status = (result as { status?: unknown }).status
901
+ if (status === 'completed') {
902
+ const after = (result as { nativeRevisionAfter?: unknown }).nativeRevisionAfter
903
+ return { kind: 'completed', after: typeof after === 'string' && after.length > 0 ? after : null }
904
+ }
905
+ if (status === 'aborted') return { kind: 'aborted' }
906
+
907
+ // The adapter module's shape. Read only when `status` said nothing, so a result
908
+ // carrying both is decided by exactly one rule.
909
+ const { ok, delivery } = result as { ok?: unknown; delivery?: unknown }
910
+ if (typeof ok === 'boolean') {
911
+ // Success is not inferred from `ok` alone: a truthy result whose delivery
912
+ // state is anything but `delivered` is a contradiction, and a contradiction
913
+ // is ambiguous rather than done.
914
+ if (ok === true && delivery === 'delivered') {
915
+ const after = (result as { nativeRevisionAfter?: unknown }).nativeRevisionAfter
916
+ return { kind: 'completed', after: typeof after === 'string' && after.length > 0 ? after : null }
917
+ }
918
+ if (ok === false && (delivery === 'not_attempted' || delivery === 'aborted')) return { kind: 'aborted' }
919
+ }
920
+ return { kind: 'ambiguous' }
921
+ }
922
+
923
+ export function isOpaque(value: unknown): value is string {
924
+ return typeof value === 'string' && OPAQUE_RE.test(value)
925
+ }
926
+
927
+ // ------------------------------------------------------------------------ fork
928
+
929
+ /** What the route hands `forkThread`. The client supplies none of these but the prompt. */
930
+ export interface ForkRouteRequest {
931
+ provider: BindableProvider
932
+ nativeThreadId: string
933
+ prompt: string
934
+ /** Resolved server-side. Plan 4.2: the client never sends a path. */
935
+ cwd: string
936
+ policy: 'read_only'
937
+ }
938
+
939
+ export const FORK_REF_TTL_MS = 30 * 60_000
940
+ export const MAX_TRACKED_FORK_REFS = 256
941
+
942
+ /**
943
+ * Opaque handles for freshly forked threads.
944
+ *
945
+ * The fork route must tell the client WHICH thread it created, and it may not put
946
+ * a native thread id on the wire — the redaction contract at the top of this file
947
+ * is absolute about that, and a fork id is exactly as identifying as any other.
948
+ * So the client gets a digest and the server keeps the mapping.
949
+ *
950
+ * NOT A CAPABILITY TOKEN, for the same reason `opaqueRevision` is not: the digest
951
+ * is derived from values, not from a secret, so holding one proves recognisability
952
+ * and nothing else. Authorization remains `requireApiToken` at the app level.
953
+ *
954
+ * Bounded and TTL'd because it is unbounded client-triggered state otherwise.
955
+ * Eviction is safe in the direction that matters: a lost handle means the client
956
+ * must find the thread on the desktop, never that something binds to the wrong one.
957
+ */
958
+ export class ForkRefStore {
959
+ private readonly refs = new Map<string, { provider: BindableProvider; nativeThreadId: string; at: number }>()
960
+
961
+ remember(provider: BindableProvider, nativeThreadId: string, now: number): string {
962
+ const ref = opaqueRevision(targetKey(provider, nativeThreadId))
963
+ this.refs.delete(ref)
964
+ this.refs.set(ref, { provider, nativeThreadId, at: now })
965
+ while (this.refs.size > MAX_TRACKED_FORK_REFS) {
966
+ const oldest = this.refs.keys().next()
967
+ if (oldest.done) break
968
+ this.refs.delete(oldest.value)
969
+ }
970
+ return ref
971
+ }
972
+
973
+ lookup(ref: unknown, now: number): { provider: BindableProvider; nativeThreadId: string } | null {
974
+ if (!isOpaque(ref)) return null
975
+ const row = this.refs.get(ref)
976
+ if (!row) return null
977
+ if (!Number.isFinite(now) || now - row.at > FORK_REF_TTL_MS) {
978
+ this.refs.delete(ref)
979
+ return null
980
+ }
981
+ return { provider: row.provider, nativeThreadId: row.nativeThreadId }
982
+ }
983
+ }
984
+
985
+ /**
986
+ * What a fork attempt actually achieved.
987
+ *
988
+ * created a new thread exists and is named
989
+ * mutated the ORIGINAL changed — terminal, and the loudest outcome here
990
+ * orphan_possible something may have been created that nobody can name
991
+ * failed provably nothing was created
992
+ */
993
+ export type ForkOutcome =
994
+ | { kind: 'created'; newNativeThreadId: string; integrity: 'verified_unchanged' | 'unverified' }
995
+ | { kind: 'mutated' }
996
+ | { kind: 'orphan_possible' }
997
+ | { kind: 'failed' }
998
+
999
+ /**
1000
+ * Read a fork result without believing anything it did not say.
1001
+ *
1002
+ * Recognised STRUCTURALLY rather than by importing `fork-thread.ts`, matching how
1003
+ * this router already reads the attached adapter: a change over there cannot break
1004
+ * this build, it can only stop matching — and the default for "stopped matching"
1005
+ * is `orphan_possible`, the cautious side. `failed` is claimed ONLY for a result
1006
+ * that positively says no child was ever created, because "I do not recognise this"
1007
+ * is not "nothing happened".
1008
+ */
1009
+ export function classifyFork(result: unknown, sourceNativeThreadId: string): ForkOutcome {
1010
+ if (!result || typeof result !== 'object' || Array.isArray(result)) return { kind: 'orphan_possible' }
1011
+ const { ok, newNativeThreadId, sourceIntegrity, forkState, reason } = result as Record<string, unknown>
1012
+
1013
+ if (ok === true) {
1014
+ // The single invariant this route re-checks itself rather than inheriting.
1015
+ // `fork-thread.ts` guarantees the returned id differs from the source, but
1016
+ // this router does not import it, so a structurally-matching object from
1017
+ // anywhere would otherwise be taken at its word — and the value at stake is
1018
+ // whether COS is about to hand the user's LIVE thread back to them labelled
1019
+ // as a fresh copy.
1020
+ if (!isValidNativeThreadId(newNativeThreadId)) return { kind: 'orphan_possible' }
1021
+ if (newNativeThreadId === sourceNativeThreadId) return { kind: 'mutated' }
1022
+ if (sourceIntegrity === 'mutated') return { kind: 'mutated' }
1023
+ if (sourceIntegrity !== 'verified_unchanged' && sourceIntegrity !== 'unverified') {
1024
+ return { kind: 'orphan_possible' }
1025
+ }
1026
+ return { kind: 'created', newNativeThreadId, integrity: sourceIntegrity }
1027
+ }
1028
+
1029
+ if (ok === false) {
1030
+ if (reason === 'source_thread_mutated' || sourceIntegrity === 'mutated') return { kind: 'mutated' }
1031
+ // Only an explicit "no child was created" earns the clean failure.
1032
+ if (forkState === 'none') return { kind: 'failed' }
1033
+ }
1034
+ return { kind: 'orphan_possible' }
1035
+ }
1036
+
1037
+ export const FORKED_COPY = 'Copied into a new thread. Your original is untouched.'
1038
+
1039
+ /**
1040
+ * Both fingerprints present, bounded, and strings.
1041
+ *
1042
+ * They are persisted into the binding and handed to the adapter, and nothing in
1043
+ * the type says they are hashes rather than raw paths — the store's own header
1044
+ * says so. Bounded because they become JSON in a durable file; never projected,
1045
+ * which is why the shape check is all that is needed here.
1046
+ */
1047
+ export function isUsableResolution(value: unknown): value is TargetResolution {
1048
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
1049
+ const { workspaceFingerprint: w, sourceFingerprint: s } = value as Partial<TargetResolution>
1050
+ return (
1051
+ typeof w === 'string' && w.length > 0 && w.length <= 1024 &&
1052
+ typeof s === 'string' && s.length > 0 && s.length <= 1024
1053
+ )
1054
+ }
1055
+
1056
+ /** Parsed body, or null for anything that is not a JSON object — including an unparsed body. */
1057
+ function plainBody(req: Request): Record<string, unknown> | null {
1058
+ const body: unknown = (req as { body?: unknown }).body
1059
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return null
1060
+ return body as Record<string, unknown>
1061
+ }
1062
+
1063
+ export const ATTACHED_COPY = 'Attached. COS is driving the original thread.'
1064
+ export const TURN_SENT_COPY = 'Sent to the original thread.'
1065
+
1066
+ /**
1067
+ * The 202 copy. Says QUEUED and not sent, because at this instant the provider has
1068
+ * not been spawned — claiming otherwise would be the same silent-success lie the
1069
+ * ambiguous path exists to avoid.
1070
+ */
1071
+ export const TURN_QUEUED_COPY = 'Queued to the original thread. It keeps running if you put your phone away.'
1072
+
1073
+ /** Status of a turn that is admitted and still running. */
1074
+ export const TURN_PENDING_COPY = 'Still working on your Mac.'
1075
+
1076
+ /**
1077
+ * Status of a turn this binding has never heard of.
1078
+ *
1079
+ * Deliberately NOT phrased as "it failed": an unknown key is far more likely to be
1080
+ * a client asking about a turn that never got admitted than a lost one, and
1081
+ * telling the user a turn failed is how a retry puts a second copy into a real
1082
+ * conversation.
1083
+ */
1084
+ export const TURN_UNKNOWN_COPY = 'COS has no record of that turn. Nothing was sent.'
1085
+
1086
+ /**
1087
+ * Is writing into a native desktop thread turned on?
1088
+ *
1089
+ * OFF BY DEFAULT, permanently and by design (plan 4.9). A user who never sets this
1090
+ * gets exactly the behavior that existed before this feature: read-only browsing
1091
+ * and Fork-only everywhere. That is a supported end state, not a migration step.
1092
+ *
1093
+ * Same shape as `claudeSessionsEnabled()` in routes/claude-sessions.ts rather than
1094
+ * a second flag pattern. Strict `=== '1'`, so any other value including 'true'
1095
+ * reads as OFF - a feature that writes into a human's conversation should be hard
1096
+ * to turn on by accident.
1097
+ */
1098
+ /** Client-supplied idempotency key. Long enough that a collision is deliberate. */
1099
+ export const CLIENT_TURN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{7,127}$/
1100
+
1101
+ export function threadAttachEnabled(): boolean {
1102
+ return process.env.COS_THREAD_ATTACH_ENABLED === '1'
1103
+ }
1104
+
1105
+ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps): Router {
1106
+ const router = Router()
1107
+ // Evaluated once at wiring time. Incomplete dependencies are a wiring bug, and
1108
+ // the safe response to a wiring bug is a route that refuses with a reason, not
1109
+ // a server that will not boot and not a route that guesses.
1110
+ // Resolved once at wiring time, like canDetect/canListBindings. Injectable so a
1111
+ // test can drive both states without mutating process.env.
1112
+ const attachEnabled = deps?.attachEnabled ?? threadAttachEnabled()
1113
+ const canDetect = occupancyDepsUsable(deps)
1114
+ const canListBindings = bindingDepsUsable(deps)
1115
+ const canWriteBindings = bindingWriteDepsUsable(deps)
1116
+ const detect = deps?.occupancy ?? threadOccupancy
1117
+ const guard = new TargetGuard()
1118
+ const ownership = deps?.ownership ?? { record: recordCosSpawn, release: releaseCosSpawn }
1119
+ // One per router. Injectable so the follow-on (attach accepting a `forkRef`)
1120
+ // shares this instance rather than standing up a second, disconnected one.
1121
+ const forkRefs = deps?.forkRefs instanceof ForkRefStore ? deps.forkRefs : new ForkRefStore()
1122
+ const attachTtlMs =
1123
+ Number.isFinite(deps?.attachTtlMs) && (deps.attachTtlMs as number) > 0
1124
+ ? (deps.attachTtlMs as number)
1125
+ : DEFAULT_ATTACH_TTL_MS
1126
+ const maxPromptChars =
1127
+ Number.isInteger(deps?.maxPromptChars) && (deps.maxPromptChars as number) > 0
1128
+ ? (deps.maxPromptChars as number)
1129
+ : DEFAULT_MAX_PROMPT_CHARS
1130
+ const mintId = typeof deps?.newId === 'function' ? deps.newId : () => randomUUID()
1131
+
1132
+ /**
1133
+ * One owner for "is this thread free right now", shared by the probe and both
1134
+ * writes, including its own try/catch and the self-contradiction re-check.
1135
+ *
1136
+ * Sharing it is the point. Provider and id validation, the reason precedence
1137
+ * between them, and the refusal to trust an unsound verdict all live in exactly
1138
+ * one place, so the write routes cannot come to a different conclusion than the
1139
+ * probe the user was shown a second earlier.
1140
+ */
1141
+ const runOccupancy = (provider: string, threadId: string): AttachabilityBody => {
1142
+ let verdict: Occupancy
1143
+ try {
1144
+ verdict = detect(provider, threadId, deps.probes, deps.dirs)
1145
+ } catch (error) {
1146
+ // `threadOccupancy` already contains its own probe try/catch, so reaching
1147
+ // here means the detector itself threw. Logged without the thread id.
1148
+ console.error(`[agent-session-bindings] occupancy threw: ${error instanceof Error ? error.message : error}`)
1149
+ verdict = { attachable: false, owners: [], reason: 'probe_failed' }
1150
+ }
1151
+ return projectAttachability(verdict)
1152
+ }
1153
+
1154
+ const refuseAttach = (res: Response, reason: WriteRefusal): void => {
1155
+ res.status(refusalStatus(reason)).json({
1156
+ attached: false,
1157
+ reason,
1158
+ reasonCopy: writeReasonCopy(reason),
1159
+ })
1160
+ }
1161
+
1162
+ /** Read a finite clock, or null. A route that cannot tell the time refuses. */
1163
+ const readNow = (): number | null => {
1164
+ try {
1165
+ const value = deps.now()
1166
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
1167
+ } catch (error) {
1168
+ console.error(`[agent-session-bindings] clock threw: ${error instanceof Error ? error.message : error}`)
1169
+ return null
1170
+ }
1171
+ }
1172
+
1173
+ /**
1174
+ * Current head as an opaque digest, or null when it could not be established.
1175
+ *
1176
+ * Null covers three different upstream events on purpose — the module is
1177
+ * unwired, it answered null, or it threw — because all three leave this router
1178
+ * without a baseline, and without a baseline plan 4.3 has no divergence check
1179
+ * at all. The raw token is returned alongside so the adapter can be handed it;
1180
+ * only the digest ever reaches the wire.
1181
+ */
1182
+ const readHead = async (
1183
+ provider: BindableProvider,
1184
+ threadId: string,
1185
+ ): Promise<{ raw: string; digest: string } | null> => {
1186
+ const read = deps.nativeHead
1187
+ if (typeof read !== 'function') return null
1188
+ try {
1189
+ const raw = await read(provider, threadId)
1190
+ if (typeof raw !== 'string' || raw.length === 0) return null
1191
+ return { raw, digest: opaqueRevision(raw) }
1192
+ } catch (error) {
1193
+ console.error(`[agent-session-bindings] native head threw: ${error instanceof Error ? error.message : error}`)
1194
+ return null
1195
+ }
1196
+ }
1197
+
1198
+ router.get('/agent-sessions/:provider/:threadId/attachability', (req, res) => {
1199
+ // An occupancy verdict is a liveness answer with a lifetime of roughly now.
1200
+ // A cached `attachable: true` is indistinguishable from a stale one, which is
1201
+ // the whole failure this feature exists to prevent.
1202
+ res.set('Cache-Control', 'private, no-store')
1203
+
1204
+ // Before `canDetect` and before any probe. With no write path there is nothing
1205
+ // to protect against, so a disabled install does no filesystem work and cannot
1206
+ // fail. It also keeps the surface self-consistent: reporting `attachable: true`
1207
+ // while the attach route is unrouted would leave a client unable to tell
1208
+ // whether the thread is free or the feature is off.
1209
+ if (!attachEnabled) {
1210
+ res.json(projectAttachability({ attachable: false, owners: [], reason: 'attach_disabled' }))
1211
+ return
1212
+ }
1213
+
1214
+ if (!canDetect) {
1215
+ // The mechanism does not exist on this install. Distinct from "it ran and
1216
+ // found nothing" by design (plan 4.3 wants the reason nameable).
1217
+ res.json(projectAttachability({ attachable: false, owners: [], reason: 'detector_unavailable' }))
1218
+ return
1219
+ }
1220
+
1221
+ // 200 for every verdict, including a bad provider or a malformed id.
1222
+ //
1223
+ // The route's job is to answer "can I attach?", and "no, because that is not a
1224
+ // provider COS can continue" is an answer the footer can render. Following
1225
+ // claude-sessions.ts, which returns 200 for the switched-off case for the same
1226
+ // reason: a client that gets a 4xx has to invent copy, and invented copy is
1227
+ // where "unavailable" quietly becomes "try anyway".
1228
+ //
1229
+ // Provider and id are validated by `threadOccupancy` BEFORE it touches a
1230
+ // filesystem, in that order. Re-checking them here would be a second copy of a
1231
+ // rule that already has one owner, and the two copies are exactly how the
1232
+ // truncated-id hole opened. The tests pin the ordering behaviorally instead:
1233
+ // probes that throw on every call still return `unsupported_provider` /
1234
+ // `invalid_thread_id`, which is only possible if nothing was probed.
1235
+ res.json(runOccupancy(String(req.params.provider ?? ''), String(req.params.threadId ?? '')))
1236
+ })
1237
+
1238
+ router.get('/agent-sessions/bindings', (_req, res) => {
1239
+ res.set('Cache-Control', 'private, no-store')
1240
+
1241
+ const unavailable = (reason: string) => {
1242
+ // 503, not an empty 200. An empty `bindings` array from this route must
1243
+ // always mean "proved there are none"; if it could also mean "could not
1244
+ // look", every caller inherits the absence-inference bug.
1245
+ res.status(503).json({ bindings: [], available: false, reason, generatedAt: null })
1246
+ }
1247
+
1248
+ // Distinct from `binding_registry_unavailable` on purpose, and the mutation
1249
+ // pass is why. With one shared reason this branch had NO observable behavior:
1250
+ // an unwired registry threw inside the try below and produced the identical
1251
+ // response, so deleting the gate changed nothing and the guard was decoration.
1252
+ // Separating them gives it a job worth testing and answers the question an
1253
+ // operator actually has: a build was wired wrong, not a disk that failed.
1254
+ if (!canListBindings) {
1255
+ unavailable('binding_registry_unwired')
1256
+ return
1257
+ }
1258
+
1259
+ let rows: readonly NativeBinding[]
1260
+ let now: number
1261
+ try {
1262
+ // Asked BEFORE the list, because a degraded store answers `list()` with an
1263
+ // empty array rather than an error. Anything other than an explicit `true`
1264
+ // is unusable: a probe that answers "maybe" is answering no.
1265
+ if (deps.bindings.available() !== true) {
1266
+ unavailable('binding_registry_degraded')
1267
+ return
1268
+ }
1269
+ rows = deps.bindings.list()
1270
+ now = deps.now()
1271
+ } catch (error) {
1272
+ console.error(`[agent-session-bindings] binding list failed: ${error instanceof Error ? error.message : error}`)
1273
+ unavailable('binding_registry_unavailable')
1274
+ return
1275
+ }
1276
+
1277
+ if (!Array.isArray(rows) || typeof now !== 'number' || !Number.isFinite(now)) {
1278
+ unavailable('binding_registry_unavailable')
1279
+ return
1280
+ }
1281
+
1282
+ const bindings: BindingSummary[] = []
1283
+ for (const row of rows) {
1284
+ // A malformed row makes the WHOLE listing unavailable rather than a silently
1285
+ // shorter one. A list that quietly drops the row it could not parse tells the
1286
+ // operator a binding is gone when it may be live and holding a target.
1287
+ if (!isUsableBindingRow(row)) {
1288
+ unavailable('binding_registry_unreadable')
1289
+ return
1290
+ }
1291
+ if (isTerminal(row)) continue
1292
+ bindings.push(projectBinding(row, now))
1293
+ }
1294
+
1295
+ res.json({ bindings, available: true, reason: null, generatedAt: now })
1296
+ })
1297
+
1298
+ // ---------------------------------------------------------------- attach
1299
+ //
1300
+ // Registered before the turns route only for tidiness; the two paths end in
1301
+ // different literal segments (`attach` vs `turns`) so neither can shadow the
1302
+ // other, and neither can shadow `agentSessionsRouter`'s two-segment GETs.
1303
+ // Registered ONLY when the feature is on. Not a handler that declines - an
1304
+ // unregistered path 404s, so a disabled server holds no reachable write code.
1305
+ if (attachEnabled) router.post('/agent-sessions/:provider/:threadId/attach', async (req, res) => {
1306
+ res.set('Cache-Control', 'private, no-store')
1307
+ try {
1308
+ if (!canDetect) return refuseAttach(res, 'detector_unavailable')
1309
+ if (!canWriteBindings) return refuseAttach(res, 'binding_registry_unwired')
1310
+
1311
+ // The only field the client may send. Plan 4.2: no path, cwd, executable,
1312
+ // model, target key, permission mode or credentials — the server resolves
1313
+ // every execution field itself. Extra keys are ignored rather than rejected
1314
+ // so a newer client cannot be broken by an older server, but nothing outside
1315
+ // this one field is ever read.
1316
+ const body = plainBody(req)
1317
+ const cosSessionId = body?.cosSessionId
1318
+ if (typeof cosSessionId !== 'string' || !COS_SESSION_ID_RE.test(cosSessionId)) {
1319
+ return refuseAttach(res, 'invalid_request')
1320
+ }
1321
+
1322
+ const now = readNow()
1323
+ if (now === null) return refuseAttach(res, 'binding_registry_unavailable')
1324
+
1325
+ let available: boolean
1326
+ try {
1327
+ available = deps.bindings.available() === true
1328
+ } catch (error) {
1329
+ console.error(`[agent-session-bindings] availability threw: ${error instanceof Error ? error.message : error}`)
1330
+ return refuseAttach(res, 'binding_registry_unavailable')
1331
+ }
1332
+ if (!available) return refuseAttach(res, 'binding_registry_degraded')
1333
+
1334
+ const providerParam = String(req.params.provider ?? '')
1335
+ const threadIdParam = String(req.params.threadId ?? '')
1336
+
1337
+ // THE HARD PRECONDITION (plan 4.3, option B). Not advisory, not a warning,
1338
+ // and not a field on a successful response: a thread with any live owner —
1339
+ // or any doubt about whether it has one — is Fork-only, and attach is the
1340
+ // gate that makes that true.
1341
+ const verdict = runOccupancy(providerParam, threadIdParam)
1342
+ if (!verdict.attachable) return refuseAttach(res, verdict.reason ?? 'probe_failed')
1343
+
1344
+ // `runOccupancy` already proved both, provider first. These narrow the types
1345
+ // rather than re-deciding the rule — a second copy of the id rule is how the
1346
+ // truncated-id hole opened. A failure here is a self-contradicting detector,
1347
+ // and a contradiction refuses.
1348
+ if (!isBindableProvider(providerParam)) return refuseAttach(res, 'unsupported_provider')
1349
+ if (!isValidNativeThreadId(threadIdParam)) return refuseAttach(res, 'invalid_thread_id')
1350
+
1351
+ const key = targetKey(providerParam, threadIdParam)
1352
+ const fenced = guard.fencedReason(key)
1353
+ // A target holding a turn that may already have been delivered does not open
1354
+ // again just because the binding that delivered it is gone. Checked here as
1355
+ // well as in the turn route, because a fresh attach is the obvious way around
1356
+ // a per-binding fence.
1357
+ if (fenced !== null) return refuseAttach(res, fenced)
1358
+
1359
+ const resolve = deps.resolveTarget
1360
+ if (typeof resolve !== 'function') return refuseAttach(res, 'target_unresolvable')
1361
+ let resolved: TargetResolution | null = null
1362
+ try {
1363
+ resolved = resolve(providerParam, threadIdParam)
1364
+ } catch (error) {
1365
+ console.error(`[agent-session-bindings] target resolve threw: ${error instanceof Error ? error.message : error}`)
1366
+ resolved = null
1367
+ }
1368
+ if (!isUsableResolution(resolved)) return refuseAttach(res, 'target_unresolvable')
1369
+
1370
+ // The divergence baseline. No baseline, no attach: plan 4.3 is the only
1371
+ // thing that makes a desktop edit visible, and a binding that cannot run it
1372
+ // would be a binding whose every turn is unchecked.
1373
+ const head = await readHead(providerParam, threadIdParam)
1374
+ if (head === null) return refuseAttach(res, 'native_head_unavailable')
1375
+
1376
+ const bindingId = `bnd-${mintId()}`
1377
+ if (!BINDING_ID_RE.test(bindingId)) return refuseAttach(res, 'attach_failed')
1378
+
1379
+ // `create` reads the epoch from the DURABLE per-target high-water ledger and
1380
+ // refuses a caller-supplied one. Passing `priorEpoch` from a live binding —
1381
+ // the shortcut this deliberately cannot express — is what reopens the replay
1382
+ // window after a detach and eviction. It also enforces one non-terminal
1383
+ // binding per target: a second attach gets `target_busy`, never a re-bind.
1384
+ const created = deps.bindings.create!({
1385
+ bindingId,
1386
+ cosSessionId,
1387
+ provider: providerParam,
1388
+ nativeThreadId: threadIdParam,
1389
+ workspaceFingerprint: resolved!.workspaceFingerprint,
1390
+ sourceFingerprint: resolved!.sourceFingerprint,
1391
+ nativeHeadAtAttach: head.digest,
1392
+ ttlMs: attachTtlMs,
1393
+ now,
1394
+ })
1395
+ if (!created?.binding) return refuseAttach(res, registryRefusal(created?.reason))
1396
+
1397
+ const activated = deps.bindings.activate!(bindingId, now)
1398
+ if (!activated?.binding) {
1399
+ // A staging binding still HOLDS the target. Leaving it there would make
1400
+ // every later attach to this thread fail `target_busy` until the lease
1401
+ // expired, with nothing driving it and nothing to detach.
1402
+ try {
1403
+ deps.bindings.forceDetach!(bindingId, now)
1404
+ } catch (error) {
1405
+ console.error(`[agent-session-bindings] rollback failed: ${error instanceof Error ? error.message : error}`)
1406
+ }
1407
+ return refuseAttach(res, registryRefusal(activated?.reason))
1408
+ }
1409
+
1410
+ res.status(201).json({
1411
+ attached: true,
1412
+ reason: null,
1413
+ reasonCopy: ATTACHED_COPY,
1414
+ bindingId,
1415
+ epoch: activated.binding.epoch,
1416
+ // Digest, not the marker. `boundToMarker` embeds the targetKey, which
1417
+ // embeds the exact private native id, and this router does not put that on
1418
+ // the wire. The digest is deterministic, so the client can hand it back and
1419
+ // the server recomputes it.
1420
+ boundTo: opaqueRevision(boundToMarker(activated.binding)),
1421
+ revision: head.digest,
1422
+ binding: projectBinding(activated.binding, now),
1423
+ })
1424
+ } catch (error) {
1425
+ console.error(`[agent-session-bindings] attach failed: ${error instanceof Error ? error.message : error}`)
1426
+ if (!res.headersSent) refuseAttach(res, 'attach_failed')
1427
+ }
1428
+ })
1429
+
1430
+ // ------------------------------------------------------------------ fork
1431
+ //
1432
+ // The action seventeen refusal strings in this feature already recommend, and
1433
+ // which until now did not exist anywhere in the server or the app.
1434
+ //
1435
+ // THERE IS NO OCCUPANCY CHECK IN THIS HANDLER, AND THAT IS THE POINT. Attach and
1436
+ // turns both refuse when a live desktop process holds the thread, because they
1437
+ // are about to APPEND to it. A fork appends to nothing: it reads the source and
1438
+ // writes a NEW thread, verified byte-for-byte on both providers on 2026-08-16. A
1439
+ // live owner is therefore not a hazard here — it is the ordinary case, and the
1440
+ // reason the user was sent to this route in the first place. Gating fork on
1441
+ // occupancy would refuse precisely when it is needed and leave the user with no
1442
+ // path at all. Nothing in this handler may grow such a gate.
1443
+ //
1444
+ // FOR THE SAME REASON it ignores the fence, the binding registry, and
1445
+ // `native_target_busy`. A fenced thread is one that may hold an undelivered COS
1446
+ // turn, and the fence copy tells the user to go and look at it — forking it is
1447
+ // safe and is very often the next thing they want.
1448
+ //
1449
+ // IT IS STILL GATED ON `COS_THREAD_ATTACH_ENABLED`. Fork does not write into an
1450
+ // existing conversation, but it does spawn a provider CLI against the user's
1451
+ // workspace on their behalf, which is the same class of authority the flag
1452
+ // exists to hold. A disabled server holds no reachable fork code either.
1453
+ // UNGATED, deliberately, and this is a correction rather than an oversight.
1454
+ //
1455
+ // With fork behind the same flag, the SHIPPING DEFAULT was incoherent: seventeen
1456
+ // refusal strings say "Fork it instead", the lens drew an enabled Fork row, and
1457
+ // the tap got an Express HTML 404 with no reason and no copy. Reproduced.
1458
+ //
1459
+ // The flag exists to gate WRITING INTO AN EXISTING CONVERSATION. Fork does not do
1460
+ // that: it creates a NEW thread and leaves the source byte-identical, measured on
1461
+ // a disposable thread (original 75194 bytes before and after, a new transcript
1462
+ // carrying the history). So the thing the flag protects is not the thing fork
1463
+ // does, and gating it only removed the alternative that every refusal recommends.
1464
+ //
1465
+ // It is also what "read-only with Fork-only" means as a permanent supported
1466
+ // state: browse, and branch off rather than write in.
1467
+ router.post('/agent-sessions/:provider/:threadId/fork', async (req, res) => {
1468
+ res.set('Cache-Control', 'private, no-store')
1469
+
1470
+ /** The per-source serialisation claim, released in the finally. */
1471
+ let claimedForkKey: string | null = null
1472
+
1473
+ const refuseFork = (reason: WriteRefusal, extra: Record<string, unknown> = {}): void => {
1474
+ if (res.headersSent) return
1475
+ res.status(refusalStatus(reason)).json({
1476
+ forked: false,
1477
+ forkRef: null,
1478
+ sourceIntegrity: null,
1479
+ // Default false because every refusal that reaches it directly is
1480
+ // pre-spawn. The paths that cannot say it override it explicitly.
1481
+ orphanPossible: false,
1482
+ retryable: true,
1483
+ reason,
1484
+ reasonCopy: writeReasonCopy(reason),
1485
+ ...extra,
1486
+ })
1487
+ }
1488
+
1489
+ try {
1490
+ const fork = deps.forkThread
1491
+ if (typeof fork !== 'function') return refuseFork('fork_unwired')
1492
+
1493
+ const body = plainBody(req)
1494
+ // Covers the case where no JSON parser ran at all: an unparsed body is not an
1495
+ // empty one.
1496
+ if (body === null) return refuseFork('invalid_request')
1497
+
1498
+ const cosSessionId = body.cosSessionId
1499
+ if (typeof cosSessionId !== 'string' || !COS_SESSION_ID_RE.test(cosSessionId)) {
1500
+ return refuseFork('invalid_request')
1501
+ }
1502
+ const prompt = body.prompt
1503
+ if (typeof prompt !== 'string' || prompt.trim().length === 0 || prompt.length > maxPromptChars) {
1504
+ return refuseFork('invalid_request')
1505
+ }
1506
+
1507
+ const providerParam = String(req.params.provider ?? '')
1508
+ const threadIdParam = String(req.params.threadId ?? '')
1509
+ // Validated HERE rather than inherited from an occupancy verdict, because
1510
+ // this route deliberately never asks for one. The id becomes a spawn
1511
+ // argument and a lock key, so `isValidNativeThreadId` is the whole guard.
1512
+ if (!isBindableProvider(providerParam)) return refuseFork('fork_unsupported_provider')
1513
+ if (!isValidNativeThreadId(threadIdParam)) return refuseFork('fork_invalid_thread_id')
1514
+
1515
+ const now = readNow()
1516
+ if (now === null) return refuseFork('fork_failed')
1517
+
1518
+ const resolveWorkspace = deps.resolveForkWorkspace
1519
+ if (typeof resolveWorkspace !== 'function') return refuseFork('fork_workspace_unresolvable')
1520
+ let cwd: string | null = null
1521
+ try {
1522
+ cwd = resolveWorkspace(providerParam, threadIdParam)
1523
+ } catch (error) {
1524
+ console.error(`[agent-session-bindings] fork workspace resolve threw: ${error instanceof Error ? error.message : error}`)
1525
+ cwd = null
1526
+ }
1527
+ // Absolute, checked here as well as in the module. A relative path resolves
1528
+ // against the SERVER's cwd, so the copy would land in the wrong project while
1529
+ // every response looked correct.
1530
+ if (typeof cwd !== 'string' || cwd.length === 0 || !cwd.startsWith('/') || cwd.includes('\0')) {
1531
+ return refuseFork('fork_workspace_unresolvable')
1532
+ }
1533
+
1534
+ // LAST SYNCHRONOUS STATEMENT BEFORE THE FIRST AWAIT. One fork per source
1535
+ // thread at a time: this route spawns a provider CLI, and without a claim a
1536
+ // client retry loop spawns one child per request with nothing bounding it.
1537
+ // A DISTINCT key namespace from the turn claim, so a fork can never block a
1538
+ // continuation or be blocked by one — `targetKey` is length-prefixed and
1539
+ // therefore unambiguous, and this prefix cannot collide with one.
1540
+ const forkKey = `fork:${targetKey(providerParam, threadIdParam)}`
1541
+ if (!guard.tryClaim(forkKey, forkKey)) return refuseFork('fork_in_progress')
1542
+ claimedForkKey = forkKey
1543
+
1544
+ let raw: unknown
1545
+ try {
1546
+ raw = await fork({
1547
+ provider: providerParam,
1548
+ nativeThreadId: threadIdParam,
1549
+ prompt,
1550
+ cwd,
1551
+ // Text-only, same as the attached path. A fork runs a real model turn
1552
+ // against a workspace the user did not hand us explicitly.
1553
+ policy: 'read_only',
1554
+ })
1555
+ } catch (error) {
1556
+ console.error(`[agent-session-bindings] fork threw: ${error instanceof Error ? error.message : error}`)
1557
+ // A throw from an unknown point cannot prove no child ran.
1558
+ return refuseFork('fork_orphan_possible', { orphanPossible: true })
1559
+ }
1560
+
1561
+ const outcome = classifyFork(raw, threadIdParam)
1562
+
1563
+ if (outcome.kind === 'mutated') {
1564
+ // The original moved. Loudest outcome in the feature, and not retryable:
1565
+ // the user needs to look at their own thread before anything else touches it.
1566
+ return refuseFork('fork_source_mutated', { orphanPossible: true, retryable: false })
1567
+ }
1568
+ if (outcome.kind === 'orphan_possible') {
1569
+ return refuseFork('fork_orphan_possible', { orphanPossible: true })
1570
+ }
1571
+ if (outcome.kind === 'failed') return refuseFork('fork_failed')
1572
+
1573
+ // Digest, not the id. The native thread id never crosses this boundary, for
1574
+ // a fresh fork exactly as for an existing thread.
1575
+ const forkRef = forkRefs.remember(providerParam, outcome.newNativeThreadId, now)
1576
+
1577
+ res.status(201).json({
1578
+ forked: true,
1579
+ reason: null,
1580
+ reasonCopy: FORKED_COPY,
1581
+ forkRef,
1582
+ // Reported, never assumed. `unverified` means COS could not read the
1583
+ // original at both ends — which is not the same as, and must never be
1584
+ // rendered as, "confirmed untouched".
1585
+ sourceIntegrity: outcome.integrity,
1586
+ orphanPossible: false,
1587
+ })
1588
+ } catch (error) {
1589
+ console.error(`[agent-session-bindings] fork route failed: ${error instanceof Error ? error.message : error}`)
1590
+ // A bug in this handler cannot prove whether a child ran, so it reports the
1591
+ // cautious outcome rather than a clean failure.
1592
+ refuseFork('fork_orphan_possible', { orphanPossible: true })
1593
+ } finally {
1594
+ if (claimedForkKey !== null) guard.release(claimedForkKey, claimedForkKey)
1595
+ }
1596
+ })
1597
+
1598
+ // ----------------------------------------------------------------- turns
1599
+ if (attachEnabled) router.post('/agent-sessions/bindings/:bindingId/turns', async (req, res) => {
1600
+ res.set('Cache-Control', 'private, no-store')
1601
+
1602
+ const turnId = mintId()
1603
+ /** The target we hold a claim on, released in the finally. */
1604
+ let claimedKey: string | null = null
1605
+ /** Children the adapter reported, released in the finally. */
1606
+ const recordedPids: number[] = []
1607
+ let pinnedBindingId: string | null = null
1608
+ let requestNow = 0
1609
+ /**
1610
+ * Has the adapter been ENTERED? Everything after this point is ambiguous on
1611
+ * a throw; everything before it is provably undelivered.
1612
+ */
1613
+ let deliveryAttempted = false
1614
+ /** Client idempotency key and its binding. Null until the body is validated. */
1615
+ let clientTurnId: string | null = null
1616
+ let ledgerBindingId: string | null = null
1617
+ /**
1618
+ * Has the 202 already gone out, leaving the ledger as the ONLY way to report
1619
+ * what happened?
1620
+ *
1621
+ * A provider turn runs for minutes — up to a 21 minute default — and the phone
1622
+ * cannot hold a request open across that: iOS suspends the WebView the moment
1623
+ * it is backgrounded. So every gate below runs synchronously, and delivery
1624
+ * alone is backgrounded once the last gate passes.
1625
+ */
1626
+ let queued = false
1627
+
1628
+ const respond = (status: number, payload: Record<string, unknown>): void => {
1629
+ if (!res.headersSent) res.status(status).json(payload)
1630
+ // Remembered ONLY when the prompt may have reached the provider. A
1631
+ // pre-delivery refusal (stale epoch, malformed body, busy target) must stay
1632
+ // re-evaluatable: the binding may be fine by the time the client retries, and
1633
+ // replaying a stale "no" would be its own bug.
1634
+ //
1635
+ // `completed` and `ambiguous` are the two that must never run twice. Measured
1636
+ // 2026-08-16: two byte-identical POSTs both returned completed and the user's
1637
+ // real transcript ended up with two copies of the turn.
1638
+ // Once queued, the ledger is the ONLY reporting channel — the 202 is long
1639
+ // gone. A post-202 refusal that recorded nothing would leave the status route
1640
+ // answering `pending` forever, which reads to the user as a turn still
1641
+ // running when it was actually refused minutes ago. Pre-202 refusals keep the
1642
+ // old semantics deliberately: they stay re-evaluatable, because the binding
1643
+ // may well be fine by the time the client retries.
1644
+ const outcome = payload.outcome
1645
+ const terminal = outcome === 'completed' || outcome === 'ambiguous' || (queued && outcome === 'refused')
1646
+ if (clientTurnId !== null && ledgerBindingId !== null && terminal) {
1647
+ try {
1648
+ deps.bindings.recordTurn?.(ledgerBindingId, clientTurnId, { ...payload, status }, readNow() ?? requestNow)
1649
+ } catch (error) {
1650
+ // A ledger failure must not turn a delivered turn into an error response.
1651
+ // The cost is a lost idempotency record, never a lost turn.
1652
+ console.error(`[agent-session-bindings] turn ledger write failed: ${error instanceof Error ? error.message : error}`)
1653
+ }
1654
+ }
1655
+ }
1656
+ const refuseTurn = (reason: WriteRefusal, extra: Record<string, unknown> = {}): void => {
1657
+ respond(refusalStatus(reason), {
1658
+ turnId,
1659
+ outcome: 'refused',
1660
+ // Every refusal below is reached BEFORE the adapter is entered, so this
1661
+ // default is a statement of fact, not an optimistic guess. The two paths
1662
+ // that cannot say it — the ambiguous outcome and the fence — override it.
1663
+ deliveryState: 'not_delivered',
1664
+ retryable: true,
1665
+ changed: false,
1666
+ revision: null,
1667
+ reason,
1668
+ reasonCopy: writeReasonCopy(reason),
1669
+ ...extra,
1670
+ })
1671
+ }
1672
+ const reportAmbiguous = (): void => {
1673
+ respond(refusalStatus('delivery_ambiguous'), {
1674
+ turnId,
1675
+ outcome: 'ambiguous',
1676
+ // The whole point. "I did not see it land" is not "it did not land", so a
1677
+ // client must never read this as a failure it may retry.
1678
+ deliveryState: 'unknown',
1679
+ retryable: false,
1680
+ changed: false,
1681
+ revision: null,
1682
+ reason: 'delivery_ambiguous',
1683
+ reasonCopy: writeReasonCopy('delivery_ambiguous'),
1684
+ })
1685
+ }
1686
+
1687
+ try {
1688
+ if (!canDetect) return refuseTurn('detector_unavailable')
1689
+ if (!canWriteBindings) return refuseTurn('binding_registry_unwired')
1690
+ const deliver = deps.deliverAttachedTurn
1691
+ if (typeof deliver !== 'function') return refuseTurn('adapter_unwired')
1692
+ if (typeof deps.nativeHead !== 'function') return refuseTurn('native_head_unavailable')
1693
+ if (typeof ownership?.record !== 'function' || typeof ownership?.release !== 'function') {
1694
+ // Without the ledger a spawned child cannot be recognised as ours, so the
1695
+ // next occupancy check reads it as a live foreign owner and the thread
1696
+ // locks itself out. Refusing beats delivering a turn that poisons the next.
1697
+ return refuseTurn('adapter_unwired')
1698
+ }
1699
+
1700
+ const now = readNow()
1701
+ if (now === null) return refuseTurn('binding_registry_unavailable')
1702
+ requestNow = now
1703
+
1704
+ let available: boolean
1705
+ try {
1706
+ available = deps.bindings.available() === true
1707
+ } catch (error) {
1708
+ console.error(`[agent-session-bindings] availability threw: ${error instanceof Error ? error.message : error}`)
1709
+ return refuseTurn('binding_registry_unavailable')
1710
+ }
1711
+ if (!available) return refuseTurn('binding_registry_degraded')
1712
+
1713
+ const bindingId = String(req.params.bindingId ?? '')
1714
+ // A malformed id names no binding. Answered as `unknown_binding` rather than
1715
+ // a distinct shape so a prober cannot tell "wrong format" from "no such
1716
+ // lease".
1717
+ if (!BINDING_ID_RE.test(bindingId)) return refuseTurn('unknown_binding')
1718
+
1719
+ const body = plainBody(req)
1720
+ // Covers the case where no JSON parser ran at all: an unparsed body is not
1721
+ // an empty one, and must not fall through as a turn with no claims to check.
1722
+ if (body === null) return refuseTurn('invalid_request')
1723
+
1724
+ const prompt = body.prompt
1725
+ if (typeof prompt !== 'string' || prompt.trim().length === 0 || prompt.length > maxPromptChars) {
1726
+ return refuseTurn('invalid_request')
1727
+ }
1728
+ const epoch = body.epoch
1729
+ if (!Number.isInteger(epoch) || (epoch as number) < 1) return refuseTurn('invalid_request')
1730
+ const claimedTargetKey = body.targetKey
1731
+ if (typeof claimedTargetKey !== 'string' || claimedTargetKey.length === 0 || claimedTargetKey.length > 512) {
1732
+ return refuseTurn('invalid_request')
1733
+ }
1734
+ const acknowledged = body.acknowledgedRevision
1735
+ if (acknowledged !== undefined && acknowledged !== null && !isOpaque(acknowledged)) {
1736
+ return refuseTurn('invalid_request')
1737
+ }
1738
+ const boundTo = body.boundTo
1739
+ if (boundTo !== undefined && boundTo !== null && !isOpaque(boundTo)) return refuseTurn('invalid_request')
1740
+
1741
+ // REQUIRED, not optional. A turn with no idempotency key cannot be made safe:
1742
+ // the client cannot tell "delivered but the 200 was lost" from "never
1743
+ // arrived", so it will retry, and without a key the server cannot tell that
1744
+ // retry from a new turn. Required rather than defaulted because there are no
1745
+ // existing callers to break - the feature ships dark.
1746
+ const submitted = body.clientTurnId
1747
+ if (typeof submitted !== 'string' || !CLIENT_TURN_ID_RE.test(submitted)) {
1748
+ return refuseTurn('invalid_request')
1749
+ }
1750
+ clientTurnId = submitted
1751
+ ledgerBindingId = bindingId
1752
+
1753
+ // REPLAY, before any occupancy check, head read, or spawn: if this exact turn
1754
+ // already reached a terminal state, hand back what it actually did.
1755
+ const already = deps.bindings.findTurn?.(bindingId, clientTurnId) ?? null
1756
+ if (already !== null && already.result && typeof already.result === 'object') {
1757
+ const { status, ...rest } = already.result as Record<string, unknown>
1758
+ if (!res.headersSent) {
1759
+ res.status(typeof status === 'number' ? status : 200).json({ ...rest, replayed: true })
1760
+ }
1761
+ return
1762
+ }
1763
+
1764
+ // The client-queued-prompt gate, used rather than reimplemented: it is the
1765
+ // one place that orders state before epoch before target, and a second
1766
+ // opinion here is how the store's own header says the two drifted apart.
1767
+ const gate = deps.bindings.checkQueuedPrompt!({ bindingId, epoch: epoch as number, targetKey: claimedTargetKey }, now)
1768
+ if (gate?.ok !== true) return refuseTurn(registryRefusal(gate?.reason))
1769
+
1770
+ const binding = deps.bindings.get!(bindingId)
1771
+ // Only `active` runs work. `staging` is the pre-commit state of the journaled
1772
+ // Chat handoff and must never execute against a Chat that can still roll back.
1773
+ const usable = assertUsable(binding ?? null, now)
1774
+ if (usable.ok !== true || !binding) return refuseTurn(registryRefusal(usable.reason ?? 'unknown_binding'))
1775
+ if (binding.targetKey !== claimedTargetKey) return refuseTurn('target_mismatch')
1776
+ if (binding.epoch !== epoch) return refuseTurn('stale_epoch')
1777
+ if (typeof boundTo === 'string' && boundTo !== opaqueRevision(boundToMarker(binding))) {
1778
+ return refuseTurn('target_mismatch')
1779
+ }
1780
+ if (!isBindableProvider(binding.provider) || !isValidNativeThreadId(binding.nativeThreadId)) {
1781
+ return refuseTurn('binding_unusable')
1782
+ }
1783
+
1784
+ const key = binding.targetKey
1785
+ const fenced = guard.fencedReason(key)
1786
+ if (fenced !== null) {
1787
+ return refuseTurn(fenced, { retryable: false, deliveryState: 'unknown' })
1788
+ }
1789
+
1790
+ // LAST SYNCHRONOUS STATEMENT BEFORE THE FIRST AWAIT. Check-and-set in one
1791
+ // call: with an await between them, two turns admitted in the same tick both
1792
+ // see a free target and both deliver.
1793
+ if (!guard.tryClaim(key, turnId)) return refuseTurn('native_turn_in_progress')
1794
+ claimedKey = key
1795
+
1796
+ // Plan 4.3 step 6. The attach-time verdict is minutes old by now; a desktop
1797
+ // session started in the gap is exactly the residual risk option B leaves
1798
+ // open, and it is terminal here rather than a warning because COS has no
1799
+ // cross-process lock that could fence a live desktop writer.
1800
+ const verdict = runOccupancy(binding.provider, binding.nativeThreadId)
1801
+ if (!verdict.attachable) return refuseTurn(verdict.reason ?? 'probe_failed')
1802
+
1803
+ const head = await readHead(binding.provider, binding.nativeThreadId)
1804
+ if (head === null) return refuseTurn('native_head_unavailable')
1805
+
1806
+ // The attach baseline, advanced by each completed turn and by each explicit
1807
+ // Continue Anyway. Without the advance the SECOND turn on a binding always
1808
+ // reads as diverged, because the first turn is what moved the head.
1809
+ const baseline = guard.acknowledgedHead(bindingId) ?? binding.nativeHeadAtAttach
1810
+ if (typeof baseline !== 'string' || baseline.length === 0) return refuseTurn('native_head_unavailable')
1811
+ if (head.digest !== baseline) {
1812
+ // Only a changed/not-changed signal and a new opaque revision. No diff, no
1813
+ // content, no path — the client is told THAT it moved, never to what.
1814
+ if (acknowledged !== head.digest) {
1815
+ return refuseTurn('native_thread_changed', { changed: true, revision: head.digest })
1816
+ }
1817
+ // Continue Anyway: a new admission carrying the acknowledged revision.
1818
+ // Recorded now rather than on completion, because the user acknowledged
1819
+ // this revision whatever the turn goes on to do.
1820
+ guard.acknowledgeHead(bindingId, head.digest)
1821
+ }
1822
+
1823
+ // A failed pin is FATAL, per the registry's own caller contract: an unpinned
1824
+ // binding can expire or be detached mid-turn, which defeats the lease.
1825
+ const pinned = deps.bindings.pin!(bindingId, turnId, now)
1826
+ if (!pinned?.binding) return refuseTurn(registryRefusal(pinned?.reason))
1827
+ pinnedBindingId = bindingId
1828
+
1829
+ // THE QUEUE POINT. Every gate is now behind us — body, replay, queued-prompt,
1830
+ // lease, target, epoch, fence, claim, occupancy, head baseline, pin — so a
1831
+ // refusal still reaches the user immediately and precisely. Only the spawn is
1832
+ // backgrounded, because only the spawn takes minutes.
1833
+ //
1834
+ // Nothing below changes. `respond` already writes to the response only when
1835
+ // headers have not been sent, and to the ledger regardless, so each terminal
1836
+ // outcome now lands in the ledger and the status route serves it.
1837
+ queued = true
1838
+ res.status(202).json({
1839
+ turnId,
1840
+ outcome: 'queued',
1841
+ clientTurnId,
1842
+ bindingId,
1843
+ deliveryState: 'pending',
1844
+ retryable: false,
1845
+ changed: false,
1846
+ revision: null,
1847
+ reason: null,
1848
+ reasonCopy: TURN_QUEUED_COPY,
1849
+ })
1850
+
1851
+ let delivery: Delivery
1852
+ deliveryAttempted = true
1853
+ try {
1854
+ const result = await deliver({
1855
+ turnId,
1856
+ bindingId,
1857
+ epoch: binding.epoch,
1858
+ provider: binding.provider,
1859
+ nativeThreadId: binding.nativeThreadId,
1860
+ workspaceFingerprint: binding.workspaceFingerprint,
1861
+ sourceFingerprint: binding.sourceFingerprint,
1862
+ expectedNativeHead: head.raw,
1863
+ prompt,
1864
+ onSpawn: (pid: number): boolean => {
1865
+ // THE SELF-RECURSION ORDER. The child registers itself against the id
1866
+ // we are targeting, so unless it is in the ledger the next occupancy
1867
+ // check reads our own process as a live foreign owner.
1868
+ let startMs: number | null = null
1869
+ try {
1870
+ // MEASURED, never `Date.now()`. The wall clock drifts up to 992 ms
1871
+ // against a 1500 ms tolerance, and a near-miss silently disables
1872
+ // self-identification instead of failing loudly.
1873
+ startMs = deps.probes.processStartMs(pid)
1874
+ } catch {
1875
+ startMs = null
1876
+ }
1877
+ if (typeof startMs !== 'number' || !Number.isFinite(startMs)) return false
1878
+ let outcome: string
1879
+ try {
1880
+ outcome = ownership.record(pid, startMs)
1881
+ } catch {
1882
+ return false
1883
+ }
1884
+ // The ledger reports WHY it refused. Anything but an accepted claim
1885
+ // means this child is unrecognisable to us, so the adapter must abort
1886
+ // before the prompt rather than deliver a turn that poisons the next
1887
+ // occupancy check.
1888
+ if (outcome !== 'recorded') return false
1889
+ recordedPids.push(pid)
1890
+ return true
1891
+ },
1892
+ })
1893
+ delivery = classifyDelivery(result)
1894
+ } catch (error) {
1895
+ console.error(`[agent-session-bindings] adapter threw: ${error instanceof Error ? error.message : error}`)
1896
+ delivery = { kind: 'ambiguous' }
1897
+ }
1898
+
1899
+ if (delivery.kind === 'aborted') return refuseTurn('provider_never_opened')
1900
+
1901
+ if (delivery.kind === 'ambiguous') {
1902
+ // Plan 4.6: the reservation is HELD, not released. A hand-crafted next
1903
+ // admission — the client Retry button that mints a fresh generation and
1904
+ // clears every other fence — has to hit something server-side, and this is
1905
+ // it. The fence outlives the binding, so re-attaching does not open it.
1906
+ //
1907
+ // Fenced under its own reason, not this turn's: `delivery_ambiguous`
1908
+ // describes what happened to THIS request, while a later caller needs to
1909
+ // be told the thread is shut and why it must be inspected first.
1910
+ guard.fence(key, 'native_target_fenced')
1911
+ return reportAmbiguous()
1912
+ }
1913
+
1914
+ let after = delivery.after === null ? null : opaqueRevision(delivery.after)
1915
+ if (after === null) {
1916
+ // Best effort. If it fails the baseline simply does not advance and the
1917
+ // next turn asks for an acknowledgement — conservative, never permissive.
1918
+ const reread = await readHead(binding.provider, binding.nativeThreadId)
1919
+ after = reread === null ? null : reread.digest
1920
+ }
1921
+ if (after !== null) guard.acknowledgeHead(bindingId, after)
1922
+
1923
+ // Phase 4 owns Message persistence. Nothing about the prompt or the reply is
1924
+ // written, logged or echoed here; the terminal outcome is the whole result.
1925
+ respond(200, {
1926
+ turnId,
1927
+ outcome: 'completed',
1928
+ deliveryState: 'delivered',
1929
+ retryable: false,
1930
+ changed: false,
1931
+ revision: after,
1932
+ reason: null,
1933
+ reasonCopy: TURN_SENT_COPY,
1934
+ })
1935
+ } catch (error) {
1936
+ console.error(`[agent-session-bindings] turn failed: ${error instanceof Error ? error.message : error}`)
1937
+ if (deliveryAttempted) {
1938
+ // A bug in this route that happened AROUND a delivery is indistinguishable
1939
+ // from a delivery.
1940
+ if (claimedKey !== null) guard.fence(claimedKey, 'native_target_fenced')
1941
+ reportAmbiguous()
1942
+ } else {
1943
+ refuseTurn('turn_failed')
1944
+ }
1945
+ } finally {
1946
+ for (const pid of recordedPids) {
1947
+ try {
1948
+ ownership.release(pid)
1949
+ } catch (error) {
1950
+ console.error(`[agent-session-bindings] spawn release failed: ${error instanceof Error ? error.message : error}`)
1951
+ }
1952
+ }
1953
+ if (pinnedBindingId !== null) {
1954
+ try {
1955
+ deps.bindings.unpin!(pinnedBindingId, turnId, readNow() ?? requestNow)
1956
+ } catch (error) {
1957
+ console.error(`[agent-session-bindings] unpin failed: ${error instanceof Error ? error.message : error}`)
1958
+ }
1959
+ }
1960
+ if (claimedKey !== null) guard.release(claimedKey, turnId)
1961
+ }
1962
+ })
1963
+
1964
+ /**
1965
+ * What happened to a queued turn.
1966
+ *
1967
+ * Reads the durable turn ledger, which is the same record the replay path serves,
1968
+ * so a poll and a retry can never disagree about what a turn did.
1969
+ *
1970
+ * Gated with the write routes: this reports on attached turns, and an install
1971
+ * that cannot make them has nothing to report on.
1972
+ */
1973
+ if (attachEnabled) router.get('/agent-sessions/bindings/:bindingId/turns/:clientTurnId', (req, res) => {
1974
+ res.set('Cache-Control', 'private, no-store')
1975
+
1976
+ const bindingId = String(req.params.bindingId ?? '')
1977
+ const clientTurnId = String(req.params.clientTurnId ?? '')
1978
+ if (!BINDING_ID_RE.test(bindingId) || !CLIENT_TURN_ID_RE.test(clientTurnId)) {
1979
+ res.status(400).json({ outcome: 'invalid_request', reasonCopy: TURN_UNKNOWN_COPY })
1980
+ return
1981
+ }
1982
+
1983
+ let entry: { result?: unknown } | null = null
1984
+ try {
1985
+ entry = deps.bindings.findTurn?.(bindingId, clientTurnId) ?? null
1986
+ } catch (error) {
1987
+ console.error(`[agent-session-bindings] turn status read failed: ${error instanceof Error ? error.message : error}`)
1988
+ // A ledger read that THREW is not a turn that did not happen. Reporting
1989
+ // `unknown` here would invite the retry that double-posts.
1990
+ res.status(503).json({ outcome: 'unavailable', reasonCopy: TURN_PENDING_COPY })
1991
+ return
1992
+ }
1993
+
1994
+ if (entry === null) {
1995
+ // Genuinely absent. Admitted-and-running is indistinguishable from
1996
+ // never-admitted in the ledger alone, so this stays 404 and the copy avoids
1997
+ // asserting either.
1998
+ res.status(404).json({ outcome: 'unknown', reasonCopy: TURN_UNKNOWN_COPY })
1999
+ return
2000
+ }
2001
+
2002
+ const stored = entry.result && typeof entry.result === 'object'
2003
+ ? entry.result as Record<string, unknown>
2004
+ : null
2005
+ if (stored === null) {
2006
+ res.status(200).json({ outcome: 'pending', reasonCopy: TURN_PENDING_COPY })
2007
+ return
2008
+ }
2009
+ // `status` is the ledger's record of the ORIGINAL response code and must not
2010
+ // become this poll's status — a refused turn reported correctly is a successful
2011
+ // read.
2012
+ // Surfaced under its own name rather than dropped: the code the turn actually
2013
+ // produced is exactly what a caller that missed the 202's eventual outcome
2014
+ // needs in order to react the way it would have to the original response.
2015
+ const { status: recorded, ...rest } = stored
2016
+ res.status(200).json({
2017
+ ...rest,
2018
+ recordedStatus: typeof recorded === 'number' ? recorded : null,
2019
+ polled: true,
2020
+ })
2021
+ })
2022
+
2023
+ return router
2024
+ }