@gotcos/glasses-server 6.53.1 → 6.53.3

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.
@@ -125,7 +125,7 @@ import {
125
125
  import type { RegistryCheck, RegistryRejection, RegistryResult } from '../lib/agent-session-binding-registry.js'
126
126
  import { recordCosSpawn, releaseCosSpawn } from '../lib/agent-session-ownership-store.js'
127
127
  import { isValidNativeThreadId } from '../lib/native-thread-id.js'
128
- import { PEER_VERIFY_TIMEOUT_MS } from '../lib/session-peer-inbox.js'
128
+ import { PEER_VERIFY_TIMEOUT_MS, peerAcceptanceMarker } from '../lib/session-peer-inbox.js'
129
129
  import {
130
130
  CANCEL_QUEUE_HOLD_MS,
131
131
  CLIENT_CANCEL_ID_RE,
@@ -450,8 +450,17 @@ export interface CancelDeps {
450
450
  settlePermissions?: (sessionId: string) => number
451
451
  /** Waiting turns parked on the thread (they hold for CANCEL_QUEUE_HOLD_MS). */
452
452
  queuedWaiting?: (provider: string, threadId: string) => number
453
- /** Record the cancel time the turn queue reads (`noteThreadCancelled`). */
454
- noteCancelled?: (provider: string, threadId: string, at: number) => void
453
+ /**
454
+ * Record the cancel the turn queue reads (`noteThreadCancelled`). 6.53.3: with its TARGET,
455
+ * because a killed COS child's silence ends its turn and a desk run's does not (review A1).
456
+ */
457
+ noteCancelled?: (provider: string, threadId: string, at: number, target: CancelTarget) => void
458
+ /**
459
+ * 6.53.3 (review A6): a cancel latched during a live hand-off whose turn reached (or may
460
+ * have reached) the open session. Writes the halt marker, and re-arms it once when that
461
+ * turn's own UserPromptSubmit deletes it (`haltHandedOffTurn`). False: not armed.
462
+ */
463
+ haltHandedOffTurn?: (sessionId: string, marker: { at: number; clientCancelId: string }, turn: { promptMarker: string; sentAt: number }) => boolean
455
464
  /** Append to `data/session-cancel.jsonl`. */
456
465
  ledger?: (row: SessionCancelLedgerRow) => void
457
466
  }
@@ -998,6 +1007,14 @@ export interface TargetFenceView {
998
1007
  fencedReason(targetKey: string): WriteRefusal | null
999
1008
  }
1000
1009
 
1010
+ /** 6.53.3: how often a read may re-probe a settled-cancel fence's children (`ps` per child). */
1011
+ export const CANCEL_FENCE_RECHECK_MS = 30_000
1012
+
1013
+ /** A fence a cancel left at the delivery-ambiguous site: the only kind `releaseSettledCancelFences` may touch. */
1014
+ function isSettledCancelCandidate(row: FenceRecord): boolean {
1015
+ return row.adapterReason === 'cancelled' && row.fenceSite === 'ambiguous'
1016
+ }
1017
+
1001
1018
  export class TargetGuard {
1002
1019
  /** targetKey -> turnId of the single COS turn allowed to be in flight. */
1003
1020
  private readonly claims = new Map<string, string>()
@@ -1006,6 +1023,9 @@ export class TargetGuard {
1006
1023
  private readonly fences = new Map<string, FenceRecord>()
1007
1024
  private readonly persistence: FencePersistence | null
1008
1025
  private persistDegraded = false
1026
+ /** 6.53.3: set by the router; null keeps `fencedReason` a pure read (tests, older wiring). */
1027
+ private cancelLiveness: FenceLivenessDeps | null = null
1028
+ private cancelSweepAt = Number.NEGATIVE_INFINITY
1009
1029
 
1010
1030
  constructor(persistence: FencePersistence | null = null) {
1011
1031
  this.persistence = persistence
@@ -1099,7 +1119,63 @@ export class TargetGuard {
1099
1119
 
1100
1120
  fencedReason(targetKey: string): WriteRefusal | null {
1101
1121
  const row = this.fences.get(targetKey)
1102
- return row === undefined ? null : (row.reason as WriteRefusal)
1122
+ if (row === undefined) return null
1123
+ // 6.53.3: a settled-cancel fence is re-checked here too, throttled, so a thread whose
1124
+ // cancelled child has since exited opens on its next use, not only on a restart or when
1125
+ // someone opens the Fences card.
1126
+ if (this.cancelLiveness !== null && isSettledCancelCandidate(row)) {
1127
+ const now = Date.now()
1128
+ if (now - this.cancelSweepAt >= CANCEL_FENCE_RECHECK_MS) {
1129
+ this.cancelSweepAt = now
1130
+ this.releaseSettledCancelFences(this.cancelLiveness)
1131
+ if (!this.fences.has(targetKey)) return null
1132
+ }
1133
+ }
1134
+ return row.reason as WriteRefusal
1135
+ }
1136
+
1137
+ /**
1138
+ * 6.53.3 (review A4): release every fence a CANCEL left behind whose recorded children
1139
+ * are all provably gone (`fenceLiveness` says `none_running`), and log each one with its
1140
+ * whole evidence record, as the operator release does.
1141
+ *
1142
+ * WHY THIS ONE CLASS, AND ONLY IT. A fence exists because a turn's delivery is unknowable
1143
+ * and a second copy could reach a real conversation. For a cancel that question is moot:
1144
+ * the person asked for the turn to stop, and the route settles it cancelled, never
1145
+ * retried. What remains is a second WRITER, and only the recorded child (the CLI, the one
1146
+ * process that writes the transcript) can be one. 6.53.1 fenced every Claude, Codex and
1147
+ * Cursor cancel whose tool was churning subprocesses (review A4: 10/10 false fences per
1148
+ * variant), and those fences are durable across restarts and upgrades.
1149
+ *
1150
+ * NEVER: a fence whose adapter reason is not `cancelled` (a timeout had 21 minutes of
1151
+ * tool calls; that is still a person's call), one set at the route-error site (a route
1152
+ * bug around a delivery), one whose children were never recorded or could not be probed
1153
+ * (`unknown`), or one with a child still alive (`running`). Returns what was released.
1154
+ */
1155
+ releaseSettledCancelFences(deps: FenceLivenessDeps, log: (line: string) => void = line => console.warn(line)): FenceRecord[] {
1156
+ const released: FenceRecord[] = []
1157
+ for (const row of [...this.fences.values()]) {
1158
+ if (!isSettledCancelCandidate(row)) continue
1159
+ let state: string
1160
+ try {
1161
+ state = fenceLiveness(row.spawns, deps).state
1162
+ } catch {
1163
+ continue
1164
+ }
1165
+ if (state !== 'none_running') continue
1166
+ const target = opaqueRevision(row.targetKey)
1167
+ const outcome = this.releaseFence(target)
1168
+ if (!outcome.ok) continue
1169
+ const ev = outcome.row
1170
+ released.push(ev)
1171
+ log(`[agent-session-bindings] fence RELEASED automatically (cancelled turn, every recorded child gone) target=${target} provider=${ev.provider} fencedAt=${ev.fencedAt} fenceSite=${ev.fenceSite ?? 'unknown'} adapterReason=${ev.adapterReason ?? 'unknown'} detail=${ev.adapterDetail ?? 'none'} exitCode=${ev.exitCode ?? 'null'} childReaped=${ev.childReaped ?? 'unknown'} stderrClass=${ev.stderrClass ?? 'none'} durationMs=${ev.durationMs ?? 'unknown'} spawnCount=${ev.spawns?.length ?? 0}`)
1172
+ }
1173
+ return released
1174
+ }
1175
+
1176
+ /** 6.53.3: the probe the lazy re-check in `fencedReason` uses; the router adopts its own. */
1177
+ adoptCancelLiveness(deps: FenceLivenessDeps): void {
1178
+ this.cancelLiveness = deps
1103
1179
  }
1104
1180
 
1105
1181
  /** Every fence, REDACTED for the wire: the raw targetKey embeds the private
@@ -1190,6 +1266,21 @@ interface InFlightTurn {
1190
1266
  startedAt: number
1191
1267
  /** False while a live hand-off is crossing its irreversible write boundary. */
1192
1268
  cancellable: boolean
1269
+ /**
1270
+ * 6.53.3 (review A6): a cancel that arrived while `cancellable` was false. It is LATCHED
1271
+ * (answered 202 `stopping`) and settled by the turn route once the hop says where the
1272
+ * turn went: nothing reached, `turn_cancelled` and no spawn; reached, the desk halt
1273
+ * marker. Until 6.53.2 it was refused 409 `cancel_failed`, that refusal was cached for the
1274
+ * tap, and a hop that reached nothing went on to spawn the turn the person had stopped.
1275
+ */
1276
+ latched: LatchedCancel | null
1277
+ }
1278
+
1279
+ interface LatchedCancel {
1280
+ at: number
1281
+ clientCancelId: string
1282
+ /** Every tap latched onto this turn: each one's cached answer is rewritten to the outcome. */
1283
+ replayKeys: string[]
1193
1284
  }
1194
1285
 
1195
1286
  type Delivery =
@@ -1480,6 +1571,14 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1480
1571
  }
1481
1572
  }),
1482
1573
  }
1574
+ // 6.53.3 (review A4): at boot, release the fences a cancel left whose children are all
1575
+ // gone (6.53.1's false "unreaped" among them), then keep re-checking on every fence read.
1576
+ guard.adoptCancelLiveness(livenessDeps)
1577
+ try {
1578
+ guard.releaseSettledCancelFences(livenessDeps)
1579
+ } catch (error) {
1580
+ console.error(`[agent-session-bindings] settled-cancel fence sweep failed: ${error instanceof Error ? error.message : error}`)
1581
+ }
1483
1582
  const ownership = deps?.ownership ?? { record: recordCosSpawn, release: releaseCosSpawn }
1484
1583
  // One per router. Injectable so the follow-on (attach accepting a `forkRef`)
1485
1584
  // shares this instance rather than standing up a second, disconnected one.
@@ -1524,10 +1623,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1524
1623
  */
1525
1624
  const cancelFactsFor = (provider: string, threadId: string, verdictReason: string | null): CancelFacts => {
1526
1625
  const entry = inFlight.get(targetKey(provider, threadId))
1527
- const cosTurnInFlight = entry?.cancellable === true
1626
+ // 6.53.3: a live hand-off in flight is still OUR turn, and a cancel on it is latched
1627
+ // (review A6), so it reads cos_turn too, never as a desk run while it commits.
1628
+ const cosTurnInFlight = entry !== undefined
1528
1629
  let runningOutsideCos = false
1529
- // A live hand-off that has started is still ours, but cannot honestly be
1530
- // cancelled: do not misclassify the same run as a desk run while it commits.
1531
1630
  if (!entry) {
1532
1631
  runningOutsideCos = provider === 'claude' && typeof cancelDeps.deskRunning === 'function'
1533
1632
  ? cancelProbe(() => cancelDeps.deskRunning!(threadId) === true, false)
@@ -1548,6 +1647,90 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1548
1647
  }
1549
1648
  /** Idempotency for POST .../cancel: the answer each (thread, clientCancelId) got. */
1550
1649
  const cancelReplies = new Map<string, { status: number; body: Record<string, unknown>; at: number }>()
1650
+ const queuedHeldFor = (provider: string, threadId: string): number => {
1651
+ const n = cancelProbe(() => cancelDeps.queuedWaiting?.(provider, threadId) ?? 0, 0)
1652
+ return Number.isInteger(n) && n > 0 ? n : 0
1653
+ }
1654
+
1655
+ /**
1656
+ * 6.53.3 (review A6): settle a cancel LATCHED during a live hand-off, once the hop has said
1657
+ * where the turn went, and rewrite the answer every latched tap replays to that outcome
1658
+ * (never the transient 202 alone, never a refusal from before the latch).
1659
+ *
1660
+ * none nothing reached a session: the turn route answers `turn_cancelled` and never
1661
+ * spawns. The cached 202 `stopping` already says so.
1662
+ * reached the turn is in the open session (or MAY be: an unverified write). What stops it
1663
+ * / maybe there is what stops any desk run: the halt marker, re-armed once through the
1664
+ * turn's own UserPromptSubmit. Codex and Cursor have no marker, so the answer is
1665
+ * the ordinary `cancel_unsupported` ("Stop it there"); hooks that cannot stop a
1666
+ * desk run answer their own reason; a marker that could not be written is
1667
+ * `cancel_failed` 500, exactly as on the desk path.
1668
+ */
1669
+ const settleLatchedCancel = (
1670
+ entry: InFlightTurn,
1671
+ provider: string,
1672
+ threadId: string,
1673
+ reached: 'none' | 'reached' | 'maybe',
1674
+ turn: { prompt: string; sentAt: number },
1675
+ ): void => {
1676
+ const latch = entry.latched
1677
+ if (latch === null) return
1678
+ const now = readNow() ?? Date.now()
1679
+ const common = { turnId: entry.turnId, queuedHeld: queuedHeldFor(provider, threadId), queuedHoldMs: CANCEL_QUEUE_HOLD_MS }
1680
+ let status = 202
1681
+ let target: CancelTarget = 'cos_turn'
1682
+ let outcome: 'accepted' | CancelRefusal = 'accepted'
1683
+ let body: Record<string, unknown> = { cancelled: true, target, state: 'stopping', ...common }
1684
+ if (reached !== 'none') {
1685
+ target = cancelTargetFor({
1686
+ provider,
1687
+ cosTurnInFlight: false,
1688
+ runningOutsideCos: true,
1689
+ hooksEnabled: cancelProbe(() => cancelDeps.hooksEnabled?.() === true, false),
1690
+ hooksReady: cancelProbe(() => cancelDeps.hooksReady?.() === true, false),
1691
+ }) ?? 'unsupported'
1692
+ if (target === 'desk_run') {
1693
+ const armed = cancelProbe(() => cancelDeps.haltHandedOffTurn?.(threadId, { at: latch.at, clientCancelId: latch.clientCancelId }, {
1694
+ promptMarker: peerAcceptanceMarker(turn.prompt),
1695
+ sentAt: turn.sentAt,
1696
+ }) === true, false)
1697
+ if (armed) {
1698
+ const settledPermissions = cancelProbe(() => cancelDeps.settlePermissions?.(threadId) ?? 0, 0)
1699
+ body = {
1700
+ cancelled: true,
1701
+ target,
1702
+ effective: 'next_tool_call',
1703
+ ...common,
1704
+ settledPermissions: Number.isInteger(settledPermissions) && settledPermissions > 0 ? settledPermissions : 0,
1705
+ }
1706
+ } else {
1707
+ status = 500
1708
+ outcome = 'cancel_failed'
1709
+ }
1710
+ } else {
1711
+ status = 409
1712
+ outcome = target === 'hooks_outdated' || target === 'hooks_disabled' ? target : 'cancel_unsupported'
1713
+ }
1714
+ if (outcome !== 'accepted') {
1715
+ body = {
1716
+ cancelled: false,
1717
+ reason: outcome,
1718
+ ...(outcome === 'cancel_unsupported' ? { provider } : {}),
1719
+ reasonCopy: cancelRefusalCopy(outcome, provider),
1720
+ }
1721
+ }
1722
+ }
1723
+ for (const replayKey of latch.replayKeys) {
1724
+ const previous = cancelReplies.get(replayKey)
1725
+ cancelReplies.set(replayKey, { status, body, at: previous?.at ?? now })
1726
+ }
1727
+ // The first answer was ledgered `accepted`; a cancel that then could not be carried out
1728
+ // gets a line of its own, so the ledger never records a stop that did not happen.
1729
+ if (outcome !== 'accepted') {
1730
+ cancelProbe(() => cancelDeps.ledger?.({ at: new Date(now).toISOString(), provider, threadId, target, outcome, clientCancelId: latch.clientCancelId }), undefined)
1731
+ }
1732
+ console.log(`[agent-session-bindings] latched cancel settled provider=${provider} turnId=${entry.turnId} hop=${reached} target=${target} outcome=${outcome} status=${status} taps=${latch.replayKeys.length}`)
1733
+ }
1551
1734
 
1552
1735
  /**
1553
1736
  * One owner for "is this thread free right now", shared by the probe and both
@@ -1630,6 +1813,13 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1630
1813
  // `degraded` is reported, never inferred: a memory-only fence set behaves
1631
1814
  // identically to a durable one until the process restarts, so a silent
1632
1815
  // fallback would be indistinguishable from working.
1816
+ // 6.53.3: a read releases the settled-cancel fences first (logged), so the card never
1817
+ // offers a person a fence the server can prove is safe to lift.
1818
+ try {
1819
+ guard.releaseSettledCancelFences(livenessDeps)
1820
+ } catch (error) {
1821
+ console.error(`[agent-session-bindings] settled-cancel fence sweep failed: ${error instanceof Error ? error.message : error}`)
1822
+ }
1633
1823
  res.json({ fences: guard.listFences(livenessDeps), degraded: guard.degraded() })
1634
1824
  })
1635
1825
 
@@ -1795,17 +1985,33 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1795
1985
  // The occupancy verdict is read only when the decision needs it: never for a COS turn
1796
1986
  // (the map knows) and never for Claude with a desk probe wired (the hooks know).
1797
1987
  const key = targetKey(provider, threadId)
1988
+ const queuedHeld = (): number => queuedHeldFor(provider, threadId)
1798
1989
  const activeEntry = inFlight.get(key)
1799
1990
  if (activeEntry && activeEntry.cancellable !== true) {
1800
- return refuseCancel(null, 'cancel_failed')
1991
+ // 6.53.3 (review A6): the turn is crossing a live hand-off's write boundary, where no
1992
+ // abort can recall it. LATCH the cancel on the turn and answer as for any COS turn;
1993
+ // the turn route settles it when the hop reports (`settleLatchedCancel`). The abort
1994
+ // is still signalled, so nothing after the hop can start the turn either.
1995
+ if (activeEntry.latched === null) {
1996
+ activeEntry.latched = { at: now, clientCancelId, replayKeys: [] }
1997
+ activeEntry.controller.abort()
1998
+ // Noted as what the run becomes if the hop lands it: a desk run (Claude) or one only
1999
+ // its app can stop. Either way only the engine's own end discounts its open turn.
2000
+ cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now, provider === 'claude' ? 'desk_run' : 'unsupported'), undefined)
2001
+ }
2002
+ activeEntry.latched.replayKeys.push(replayKey)
2003
+ return answer(202, 'cos_turn', 'accepted', {
2004
+ cancelled: true,
2005
+ target: 'cos_turn',
2006
+ state: 'stopping',
2007
+ turnId: activeEntry.turnId,
2008
+ queuedHeld: queuedHeld(),
2009
+ queuedHoldMs: CANCEL_QUEUE_HOLD_MS,
2010
+ })
1801
2011
  }
1802
2012
  const needsVerdict = !inFlight.has(key) && !(provider === 'claude' && typeof cancelDeps.deskRunning === 'function')
1803
2013
  const verdictReason = needsVerdict && canDetect ? projectAttachability(detectOccupancy(provider, threadId)).reason : null
1804
2014
  const target = cancelTargetFor(cancelFactsFor(provider, threadId, verdictReason))
1805
- const queuedHeld = (): number => {
1806
- const n = cancelProbe(() => cancelDeps.queuedWaiting?.(provider, threadId) ?? 0, 0)
1807
- return Number.isInteger(n) && n > 0 ? n : 0
1808
- }
1809
2015
 
1810
2016
  if (target === 'cos_turn') {
1811
2017
  const entry = inFlight.get(key)
@@ -1813,7 +2019,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1813
2019
  // The adapter stops the process group (SIGTERM, then SIGKILL after 5 s) and the turn
1814
2020
  // route records the outcome; this answer does not wait for either.
1815
2021
  entry.controller.abort()
1816
- cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now), undefined)
2022
+ cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now, target), undefined)
1817
2023
  return answer(202, target, 'accepted', {
1818
2024
  cancelled: true,
1819
2025
  target,
@@ -1831,7 +2037,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1831
2037
  // run waiting for its answer, and then show the Mac's own dialog. Deny it, with
1832
2038
  // interrupt, so the run ends now rather than at a tool that never comes.
1833
2039
  const settledPermissions = cancelProbe(() => cancelDeps.settlePermissions?.(threadId) ?? 0, 0)
1834
- cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now), undefined)
2040
+ cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now, target), undefined)
1835
2041
  return answer(202, target, 'accepted', {
1836
2042
  cancelled: true,
1837
2043
  target,
@@ -2452,7 +2658,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2452
2658
  if (!guard.tryClaim(key, turnId)) return refuseTurn('native_turn_in_progress')
2453
2659
  claimedKey = key
2454
2660
  // 6.53.0: cancellable from here, with the claim. Same key, same lifetime.
2455
- inFlight.set(key, { turnId, bindingId, clientTurnId, controller: cancelController, startedAt: now, cancellable: true })
2661
+ inFlight.set(key, { turnId, bindingId, clientTurnId, controller: cancelController, startedAt: now, cancellable: true, latched: null })
2456
2662
 
2457
2663
  // Plan 4.3 step 6. The attach-time verdict is minutes old by now; a desktop
2458
2664
  // session started in the gap is exactly the residual risk option B leaves
@@ -2521,6 +2727,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2521
2727
  if (typeof deps.deliverLiveTurn === 'function' && binding.provider === 'claude') {
2522
2728
  const liveEntry = inFlight.get(key)
2523
2729
  if (liveEntry?.turnId === turnId) liveEntry.cancellable = false
2730
+ /** 6.53.3: a cancel latched onto THIS turn while the hop was in flight (review A6). */
2731
+ const latchedHere = (): InFlightTurn | null => (liveEntry?.turnId === turnId && liveEntry.latched !== null ? liveEntry : null)
2732
+ const handOffAt = Date.now()
2524
2733
  let live: { ok: boolean; reason: string; verifiedBy?: string | null; pid?: number | null } | null = null
2525
2734
  try {
2526
2735
  live = await deps.deliverLiveTurn({
@@ -2538,6 +2747,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2538
2747
  }
2539
2748
  stage(`live(${live?.reason ?? 'threw'})`)
2540
2749
  if (live?.ok) {
2750
+ // 6.53.3: delivered, so the turn runs in the open session now, and what stops it
2751
+ // there is the desk halt marker. The turn itself WAS delivered and says so.
2752
+ const latched = latchedHere()
2753
+ if (latched) settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'reached', { prompt, sentAt: handOffAt })
2541
2754
  console.log(`[agent-session-bindings] turn delivered live provider=${binding.provider} turnId=${turnId} bindingId=${bindingId} verifiedBy=${live.verifiedBy ?? 'unknown'} pid=${live.pid ?? 'null'}`)
2542
2755
  respond(200, {
2543
2756
  turnId,
@@ -2553,11 +2766,27 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2553
2766
  return
2554
2767
  }
2555
2768
  if (live && (live.reason === 'unverified' || live.reason === 'write_failed')) {
2769
+ // 6.53.3: something MAY be in the session and the person asked for it to stop: arm
2770
+ // the marker as for a landed turn, and settle this turn as cancelled, never as a
2771
+ // retryable hold that the queue would send again after the cancel's two minutes.
2772
+ const latched = latchedHere()
2773
+ if (latched) {
2774
+ settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'maybe', { prompt, sentAt: handOffAt })
2775
+ return refuseTurn('turn_cancelled', { retryable: false, deliveryState: 'unknown' })
2776
+ }
2556
2777
  // Something may be in the session. Hold, never spawn over it.
2557
2778
  return refuseTurn('live_unverified', { retryable: true, deliveryState: 'unknown' })
2558
2779
  }
2559
- // Every remaining result is documented as "nothing reached a session";
2560
- // the abortable child path is safe to expose again before it starts.
2780
+ // Every remaining result is documented as "nothing reached a session".
2781
+ // 6.53.3: with a cancel latched on the way, that is the whole turn: cancelled, no
2782
+ // spawn. Until 6.53.2 the refused cancel was forgotten here and the child ran.
2783
+ const latched = latchedHere()
2784
+ if (latched) {
2785
+ settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'none', { prompt, sentAt: handOffAt })
2786
+ console.log(`[agent-session-bindings] turn cancelled during live hand-off provider=${binding.provider} turnId=${turnId} bindingId=${bindingId} hop=${live?.reason ?? 'threw'}`)
2787
+ return refuseTurn('turn_cancelled', { retryable: false })
2788
+ }
2789
+ // The abortable child path is safe to expose again before it starts.
2561
2790
  if (liveEntry?.turnId === turnId) liveEntry.cancellable = true
2562
2791
  // disabled / not_claude / no_record / protocol_unsupported / connect_failed /
2563
2792
  // suspect_expired: nothing reached a session. Spawn as always.
@@ -2573,6 +2802,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2573
2802
  if (binding.provider === 'codex' && foreignHolder && typeof deps.deliverCodexLiveTurn === 'function') {
2574
2803
  const liveEntry = inFlight.get(key)
2575
2804
  if (liveEntry?.turnId === turnId) liveEntry.cancellable = false
2805
+ /** 6.53.3: a cancel latched onto THIS turn while the hop was in flight (review A6). */
2806
+ const latchedHere = (): InFlightTurn | null => (liveEntry?.turnId === turnId && liveEntry.latched !== null ? liveEntry : null)
2807
+ const handOffAt = Date.now()
2576
2808
  let live: { ok: boolean; reason: string; verifiedBy?: string | null; queuedId?: string | null } | null = null
2577
2809
  try {
2578
2810
  live = await deps.deliverCodexLiveTurn({
@@ -2587,6 +2819,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2587
2819
  }
2588
2820
  stage(`codex-live(${live?.reason ?? 'threw'})`)
2589
2821
  if (live?.ok) {
2822
+ // 6.53.3: queued in the Codex app, which only the app can stop ("Stop it there").
2823
+ const latched = latchedHere()
2824
+ if (latched) settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'reached', { prompt, sentAt: handOffAt })
2590
2825
  console.log(`[agent-session-bindings] turn delivered live provider=codex turnId=${turnId} bindingId=${bindingId} verifiedBy=${live.verifiedBy ?? 'unknown'} queuedId=${live.queuedId ?? 'null'}`)
2591
2826
  respond(200, {
2592
2827
  turnId,
@@ -2601,6 +2836,14 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2601
2836
  })
2602
2837
  return
2603
2838
  }
2839
+ // 6.53.3: anything but a proven or possible queue row reached nothing; with a cancel
2840
+ // latched on the way that is the whole turn: cancelled, and no child.
2841
+ const codexLatched = latchedHere()
2842
+ if (codexLatched && live !== null && live.reason !== 'unverified') {
2843
+ settleLatchedCancel(codexLatched, binding.provider, binding.nativeThreadId, 'none', { prompt, sentAt: handOffAt })
2844
+ console.log(`[agent-session-bindings] turn cancelled during live hand-off provider=codex turnId=${turnId} bindingId=${bindingId} hop=${live.reason}`)
2845
+ return refuseTurn('turn_cancelled', { retryable: false })
2846
+ }
2604
2847
  // The operator switched this off: nothing was queued, so the abortable
2605
2848
  // 6.50 child path is cancellable again before it starts.
2606
2849
  if (live?.reason === 'disabled' && liveEntry?.turnId === turnId) liveEntry.cancellable = true
@@ -2624,6 +2867,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2624
2867
  spawns: [],
2625
2868
  })
2626
2869
  console.warn(`[agent-session-bindings] fence set site=codex_live provider=codex target=${opaqueRevision(key)} turnId=${turnId} bindingId=${bindingId} headBefore=${head.digest} adapterReason=codex_queue_unverified`)
2870
+ // 6.53.3: a row MAY be in the Codex app's queue; a latched cancel says where to stop it.
2871
+ if (codexLatched) settleLatchedCancel(codexLatched, binding.provider, binding.nativeThreadId, 'maybe', { prompt, sentAt: handOffAt })
2627
2872
  return reportAmbiguous()
2628
2873
  }
2629
2874
  // Nothing was queued, and the child cannot run against a held thread. Not
@@ -82,6 +82,7 @@ import {
82
82
  } from '../lib/thread-attach-capability.js'
83
83
  import { cachedHookStatus, sessionHooksEnabled, sessionHooksHealthFields } from '../lib/session-hooks-runtime.js'
84
84
  import { sessionCancelFeature } from '../lib/session-cancel.js'
85
+ import { hookHaltReady } from '../lib/claude-hooks-installer.js'
85
86
  import { permissionBrokerHealthFields, sessionQuestionsCapability } from '../lib/permission-broker.js'
86
87
  import { continueLiveEnabled, liveDeliveryStats } from '../lib/session-peer-inbox-deps.js'
87
88
  import { codexLiveStats } from '../lib/codex-live-queue-deps.js'
@@ -297,7 +298,7 @@ healthRouter.get('/health', async (_req, res) => {
297
298
  // 6.53.0: cancel a session run. `cosTurn` always; `deskClaude` only once the hooks are
298
299
  // applied and the 6.53 hook is installed (Install hooks in COS Control after updating).
299
300
  // The status read is the same cached one `sessionHooks` below reports.
300
- sessionCancel: sessionCancelFeature(sessionHooksEnabled(), cachedHookStatus().installed),
301
+ sessionCancel: sessionCancelFeature(sessionHooksEnabled(), hookHaltReady(cachedHookStatus())),
301
302
  }
302
303
  const voice = {
303
304
  available: keyStatus.hasKey || tts_local.ready,
@@ -182,8 +182,12 @@ interface MeetingSessionSource {
182
182
  hasAudio(sessionId: string): boolean
183
183
  moveAudioToPending(sessionId: string): string | null
184
184
  delete(sessionId: string, options?: { preserveAudio?: boolean }): void
185
+ /** 6.53.3: live, closed within its retention, or unknown (`getMeetingSessionStatus`). */
186
+ getState?(sessionId: string): 'active' | 'closed' | 'missing'
185
187
  }
186
188
 
189
+ const defaultSessionState = (sessionId: string): 'active' | 'closed' | 'missing' => getMeetingSessionStatus(sessionId).state
190
+
187
191
  export interface MeetingRouteDependencies {
188
192
  store?: MeetingStore
189
193
  sessions?: MeetingSessionSource
@@ -404,6 +408,7 @@ const defaultSessionSource: MeetingSessionSource = {
404
408
  hasAudio: hasSessionAudio,
405
409
  moveAudioToPending: moveSessionAudioToPending,
406
410
  delete: deleteSession,
411
+ getState: defaultSessionState,
407
412
  }
408
413
 
409
414
  function countWords(text: string): number {
@@ -566,10 +571,14 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
566
571
 
567
572
  const transcript = sessions.getTranscript(sessionId)
568
573
  if (!transcript?.trim()) {
569
- res.status(404).json({
570
- error: `No transcript found for session ${sessionId}`,
571
- reason: 'session_not_found',
572
- })
574
+ // 6.53.3 (review D3): a session the server KNOWS (live, or closed within its
575
+ // retention) that holds no transcript is not one it never heard of. Its own reason
576
+ // lets a client stop asking ("nothing to save") instead of retrying a 404 all day.
577
+ // Still 404, so every existing client reads it exactly as before.
578
+ const known = (sessions.getState ?? defaultSessionState)(sessionId)
579
+ res.status(404).json(known === 'active' || known === 'closed'
580
+ ? { error: `Nothing to save for session ${sessionId}`, reason: 'nothing_to_save', state: known }
581
+ : { error: `No transcript found for session ${sessionId}`, reason: 'session_not_found' })
573
582
  return
574
583
  }
575
584
 
@@ -437,6 +437,9 @@ export interface TranscriptionSessionLiveness {
437
437
  */
438
438
  export const __sessionsForTests = sessions
439
439
 
440
+ /** 6.53.3: the closed-session records, for tests only (same reasoning as above). */
441
+ export const __closedSessionRecordsForTests = closedSessionRecords
442
+
440
443
  export interface StrandedCapture {
441
444
  sessionId: string
442
445
  /** Minutes since the last ACK'd chunk. */
@@ -1151,6 +1154,8 @@ setInterval(() => {
1151
1154
  }
1152
1155
  }
1153
1156
  } catch {}
1157
+ // 6.53.3 (review D3): the in-memory records age out with the file's.
1158
+ try { sweepExpiredClosedSessions(Date.now()) } catch {}
1154
1159
  persistClosedSessions()
1155
1160
  }, 60_000)
1156
1161
 
@@ -1455,7 +1460,30 @@ export interface MeetingSessionStatusSnapshot {
1455
1460
  retainedUntil: string | null
1456
1461
  }
1457
1462
 
1458
- export function getMeetingSessionStatus(sessionId: string): MeetingSessionStatusSnapshot {
1463
+ /**
1464
+ * 6.53.3 (review D3): forget a closed session once its retention has passed, in memory as
1465
+ * well as on disk. Until 6.53.2 only the persisted file was pruned, so a closed session read
1466
+ * `closed` until the next restart, and a phone whose recovery hint named it POSTed save
1467
+ * (which can only 404) on every boot and foreground, all day. Past `retainedUntil` the
1468
+ * server now answers exactly as it would after a restart: `missing`, which every app since
1469
+ * 6.9.465 settles as terminal ("the server no longer retains it").
1470
+ */
1471
+ function evictExpiredClosedSession(sessionId: string, now: number): boolean {
1472
+ const closed = closedSessionRecords.get(sessionId)
1473
+ if (!closed || now - closed.closedAt <= CLOSED_SESSION_TTL_MS) return false
1474
+ closedSessionRecords.delete(sessionId)
1475
+ deletedSessions.delete(sessionId)
1476
+ return true
1477
+ }
1478
+
1479
+ export function sweepExpiredClosedSessions(now = Date.now()): number {
1480
+ let evicted = 0
1481
+ for (const id of [...closedSessionRecords.keys()]) if (evictExpiredClosedSession(id, now)) evicted++
1482
+ return evicted
1483
+ }
1484
+
1485
+ export function getMeetingSessionStatus(sessionId: string, now = Date.now()): MeetingSessionStatusSnapshot {
1486
+ evictExpiredClosedSession(sessionId, now)
1459
1487
  const active = sessions.get(sessionId)
1460
1488
  if (active) {
1461
1489
  const received = active.receivedIndices ?? []
@@ -8,7 +8,7 @@
8
8
  // Prints one JSON document. Exit 0 on success, 2 on a refusal (unparseable or symlinked
9
9
  // settings, a missing packaged script), 64 on a bad argument. Never prints the token.
10
10
 
11
- import { hookStatus, installClaudeHooks, uninstallClaudeHooks } from '../lib/claude-hooks-installer.js'
11
+ import { hookStatus, hookStatusAdvice, installClaudeHooks, uninstallClaudeHooks } from '../lib/claude-hooks-installer.js'
12
12
  import { sessionHooksEnabled } from '../lib/session-hooks-runtime.js'
13
13
 
14
14
  const args = process.argv.slice(2)
@@ -30,7 +30,11 @@ const serverApplies = sessionHooksEnabled()
30
30
  const flagsNote = serverApplies ? undefined : 'Rows change only when the server runs with COS_CLAUDE_SESSIONS_ENABLED=1 (or COS_SESSION_HOOKS=1).'
31
31
 
32
32
  if (action === 'status') {
33
- print({ action, serverApplies, ...(flagsNote ? { note: flagsNote } : {}), ...hookStatus() })
33
+ const status = hookStatus()
34
+ // 6.53.3: say in words what the state means and what fixes it (`script_outdated` after an
35
+ // update that changed the script, until Install hooks).
36
+ const advice = hookStatusAdvice(status)
37
+ print({ action, serverApplies, ...(flagsNote ? { note: flagsNote } : {}), ...(advice ? { advice } : {}), ...status })
34
38
  process.exit(0)
35
39
  }
36
40
  if (action === 'install') {