@gotcos/glasses-server 6.28.0 → 6.30.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.
@@ -331,6 +331,39 @@ export interface AgentSessionBindingsDeps {
331
331
  release: (pid: number) => boolean
332
332
  }
333
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
+
334
367
  /** Lease TTL for a new binding. */
335
368
  attachTtlMs?: number
336
369
  /** Upper bound on one prompt. A prompt is not a payload. */
@@ -425,6 +458,23 @@ export type WriteRefusal =
425
458
  | 'provider_never_opened'
426
459
  | 'delivery_ambiguous'
427
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'
428
478
 
429
479
  /**
430
480
  * Footer copy for the refusals that are not occupancy reasons.
@@ -482,6 +532,28 @@ export const WRITE_REASON_COPY: Record<Exclude<WriteRefusal, OccupancyReason>, s
482
532
  'COS lost track of this turn after sending it. Open the thread on your Mac and check before sending again.',
483
533
  turn_failed:
484
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.',
485
557
  }
486
558
 
487
559
  export function writeReasonCopy(reason: WriteRefusal): string {
@@ -508,6 +580,7 @@ const CAPABILITY_REFUSALS: ReadonlySet<WriteRefusal> = new Set<WriteRefusal>([
508
580
  'binding_registry_degraded',
509
581
  'binding_registry_unavailable',
510
582
  'adapter_unwired',
583
+ 'fork_unwired',
511
584
  ])
512
585
 
513
586
  export function refusalStatus(reason: WriteRefusal): number {
@@ -851,6 +924,118 @@ export function isOpaque(value: unknown): value is string {
851
924
  return typeof value === 'string' && OPAQUE_RE.test(value)
852
925
  }
853
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
+
854
1039
  /**
855
1040
  * Both fingerprints present, bounded, and strings.
856
1041
  *
@@ -878,6 +1063,26 @@ function plainBody(req: Request): Record<string, unknown> | null {
878
1063
  export const ATTACHED_COPY = 'Attached. COS is driving the original thread.'
879
1064
  export const TURN_SENT_COPY = 'Sent to the original thread.'
880
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
+
881
1086
  /**
882
1087
  * Is writing into a native desktop thread turned on?
883
1088
  *
@@ -911,6 +1116,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
911
1116
  const detect = deps?.occupancy ?? threadOccupancy
912
1117
  const guard = new TargetGuard()
913
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()
914
1122
  const attachTtlMs =
915
1123
  Number.isFinite(deps?.attachTtlMs) && (deps.attachTtlMs as number) > 0
916
1124
  ? (deps.attachTtlMs as number)
@@ -1219,6 +1427,174 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1219
1427
  }
1220
1428
  })
1221
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
+
1222
1598
  // ----------------------------------------------------------------- turns
1223
1599
  if (attachEnabled) router.post('/agent-sessions/bindings/:bindingId/turns', async (req, res) => {
1224
1600
  res.set('Cache-Control', 'private, no-store')
@@ -1238,6 +1614,16 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1238
1614
  /** Client idempotency key and its binding. Null until the body is validated. */
1239
1615
  let clientTurnId: string | null = null
1240
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
1241
1627
 
1242
1628
  const respond = (status: number, payload: Record<string, unknown>): void => {
1243
1629
  if (!res.headersSent) res.status(status).json(payload)
@@ -1249,8 +1635,15 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1249
1635
  // `completed` and `ambiguous` are the two that must never run twice. Measured
1250
1636
  // 2026-08-16: two byte-identical POSTs both returned completed and the user's
1251
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.
1252
1644
  const outcome = payload.outcome
1253
- if (clientTurnId !== null && ledgerBindingId !== null && (outcome === 'completed' || outcome === 'ambiguous')) {
1645
+ const terminal = outcome === 'completed' || outcome === 'ambiguous' || (queued && outcome === 'refused')
1646
+ if (clientTurnId !== null && ledgerBindingId !== null && terminal) {
1254
1647
  try {
1255
1648
  deps.bindings.recordTurn?.(ledgerBindingId, clientTurnId, { ...payload, status }, readNow() ?? requestNow)
1256
1649
  } catch (error) {
@@ -1433,6 +1826,28 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1433
1826
  if (!pinned?.binding) return refuseTurn(registryRefusal(pinned?.reason))
1434
1827
  pinnedBindingId = bindingId
1435
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
+
1436
1851
  let delivery: Delivery
1437
1852
  deliveryAttempted = true
1438
1853
  try {
@@ -1546,5 +1961,64 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1546
1961
  }
1547
1962
  })
1548
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
+
1549
2023
  return router
1550
2024
  }
@@ -32,6 +32,9 @@ import {
32
32
  import { searchAgentSessions, type AgentSessionSearchHit } from '../lib/agent-session-search.js'
33
33
  import { claudeSessionNamesVisible, claudeSessionsDir, claudeSessionsEnabled, readClaudePeers } from './claude-sessions.js'
34
34
  import { workspaceFromCwd } from '../lib/claude-session-registry.js'
35
+ import { occupiedThreads, noOccupancyKnown, type OccupiedScan, type OccupiedThread } from '../lib/occupied-threads.js'
36
+ import { realOccupancyDirs, realOccupancyProbes } from '../lib/occupancy-probes.js'
37
+ import { cosSpawnedPids } from '../lib/agent-session-ownership-store.js'
35
38
 
36
39
  export const agentSessionsRouter = Router()
37
40
 
@@ -61,6 +64,60 @@ function toSearchHit(row: AgentSessionSearchHit) {
61
64
  }
62
65
  }
63
66
 
67
+ /**
68
+ * Which of these sessions a desktop process is holding right now.
69
+ *
70
+ * ONE scan for the whole page. Calling `threadOccupancy` per row would re-read the
71
+ * registry and shell out to `ps` per entry, which at 53 sessions is hundreds of
72
+ * process spawns on a single list request.
73
+ *
74
+ * THIS IS A DISPLAY HINT AND MUST NEVER GATE A WRITE. The attach and turn routes
75
+ * keep probing at the moment of the write, unchanged, because a list is rendered
76
+ * seconds or minutes before the user acts and a desktop session opened in that gap
77
+ * is exactly the race the per-write probe exists to catch.
78
+ */
79
+ function runningThreads(rows: readonly AgentSessionRow[]): OccupiedScan {
80
+ try {
81
+ const dirs = realOccupancyDirs()
82
+ // The spawn ledger is what lets a turn COS itself queued read as ours rather
83
+ // than as a foreign desktop window holding the thread.
84
+ const probes = realOccupancyProbes(cosSpawnedPids)
85
+ const byProvider = new Map<string, string[]>()
86
+ for (const row of rows) {
87
+ if (row.provider !== 'claude' && row.provider !== 'codex') continue
88
+ const list = byProvider.get(row.provider) ?? []
89
+ list.push(row.session_id)
90
+ byProvider.set(row.provider, list)
91
+ }
92
+ const merged = new Map<string, OccupiedThread>()
93
+ let degraded = false
94
+ for (const [provider, ids] of byProvider) {
95
+ const scan = occupiedThreads(provider, ids, probes, dirs)
96
+ for (const [id, occ] of scan.occupied) merged.set(id, occ)
97
+ if (scan.degraded) degraded = true
98
+ }
99
+ return { occupied: merged, degraded }
100
+ } catch (error) {
101
+ // The list is the point; occupancy is decoration. A probe failure must never
102
+ // cost the user their sessions.
103
+ console.error(`[agent-sessions] occupancy scan failed: ${error instanceof Error ? error.message : error}`)
104
+ return noOccupancyKnown()
105
+ }
106
+ }
107
+
108
+ /** Stamp the running hint onto a projected row. */
109
+ function withRunning<T extends { session_id: string }>(entry: T, scan: OccupiedScan) {
110
+ const occ = scan.occupied.get(entry.session_id)
111
+ return {
112
+ ...entry,
113
+ // An agent is working in this thread right now, whoever started it.
114
+ running: occ !== undefined,
115
+ // Held by something that is not COS, so a Continue would be refused. The
116
+ // badge reads `running`; the Continue affordance reads this.
117
+ running_foreign: (occ?.foreignOwners ?? 0) > 0,
118
+ }
119
+ }
120
+
64
121
  function toEntry(row: AgentSessionRow) {
65
122
  return {
66
123
  session_id: row.session_id,
@@ -107,12 +164,16 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
107
164
  const sort = asSort(req.query.sort)
108
165
  const live = await liveClaudeRows()
109
166
  const sessions = await listAgentSessions(agentSessionRoots(), new Date(), live, limit, sort)
167
+ const running = runningThreads(sessions)
110
168
  res.json({
111
- sessions: sessions.map(toEntry),
169
+ sessions: sessions.map(row => withRunning(toEntry(row), running)),
112
170
  total: sessions.length,
113
171
  windowHours: AGENT_SESSION_WINDOW_HOURS,
114
172
  sort,
115
173
  enabled: true,
174
+ // True when a probe could not see clearly. The client must render "unknown"
175
+ // rather than treating a quiet scan as "nothing is running".
176
+ runningDegraded: running.degraded,
116
177
  })
117
178
  } catch (error) {
118
179
  console.error(`[agent-sessions] list failed: ${error instanceof Error ? error.message : error}`)