@gotcos/glasses-server 6.53.1 → 6.53.4

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.
@@ -17,7 +17,8 @@ import { SessionHookLedger } from './session-hook-ledger.js'
17
17
  import { startSpoolIngester, type SpoolIngester, type SpoolStats } from './session-hook-spool.js'
18
18
  import { SessionSignalStore, type SessionSignal } from './session-signal-store.js'
19
19
  import { OPEN_TURN_CEILING_MS, deriveSessionState, type DerivedSessionState, type RegistryFacts, type TranscriptFacts } from './session-state-derive.js'
20
- import { clearHaltMarkerOnSessionEnd, sweepHaltMarkers } from './session-halt.js'
20
+ import { HALT_MARKER_TTL_MS, HALT_REARM_UNVERIFIED_MS, clearHaltMarkerOnSessionEnd, dropHaltRearm, haltDeliveredTurn, rearmHaltOnPrompt, sweepHaltMarkers, type HaltMarker } from './session-halt.js'
21
+ import { cancelVoidsOpenTurn, threadCancel } from './session-cancel.js'
21
22
  import { ensureHookRuntimeFiles, ensureStableHookScript, hookSpoolDir, hookStatus, type HookStatus } from './claude-hooks-installer.js'
22
23
 
23
24
  export function sessionHooksEnabled(): boolean {
@@ -173,6 +174,29 @@ function pidAlive(pid: number): boolean {
173
174
  }
174
175
  }
175
176
 
177
+ /**
178
+ * 6.53.3: when the desk session's own engine said its run is over, AS IT STANDS NOW: the
179
+ * registry's `idle` flip (`statusUpdatedAt`), or the hooks' Stop / StopFailure / SessionEnd
180
+ * while no turn is open. Null when neither says so (a busy record, an open hook turn, no
181
+ * record at all). What a desk_run cancel waits for before it may discount the evidence
182
+ * that the run is still going (`cancelVoidsOpenTurn`, review A1).
183
+ */
184
+ export function deskTurnEndedAt(sessionId: string, dir = claudeSessionsDir()): number | null {
185
+ let ended: number | null = null
186
+ const later = (at: unknown): void => {
187
+ if (typeof at === 'number' && Number.isFinite(at) && (ended === null || at > ended)) ended = at
188
+ }
189
+ const signal = signalFor(sessionId)
190
+ if (signal && !signal.turnOpen) {
191
+ later(signal.stopAt)
192
+ later(signal.failure?.at)
193
+ later(signal.ended?.at)
194
+ }
195
+ const record = registryRecordSync(sessionId, dir)
196
+ if (record && record.status === 'idle') later(record.statusUpdatedAt)
197
+ return ended
198
+ }
199
+
176
200
  /**
177
201
  * 6.53.0: is Claude working on this session AT THE DESK right now (a Desktop tab, a terminal,
178
202
  * or a Continue delivered live into either)? The cancel route's `deskRunning`: a yes offers
@@ -183,15 +207,47 @@ function pidAlive(pid: number): boolean {
183
207
  * own in-flight turn) says `busy`, and its pid is alive.
184
208
  * Canary C9 (2026-09-21): after a hook stop the registry read idle within a second, so a
185
209
  * cancelled desk run stops reading as running at once.
210
+ *
211
+ * 6.53.3 (review A5): the hooks' open turn is weighed against the cancel on record, by the
212
+ * rule the turn queue uses. A `claude -p` COS child killed by a cancel fires no Stop or
213
+ * SessionEnd; its open turn with nothing since the cancel is the dead child's, and must not
214
+ * put a phantom "Cancel run" (desk_run) on a thread where nothing runs.
186
215
  */
187
216
  export function claudeDeskRunning(sessionId: string, now = Date.now(), dir = claudeSessionsDir()): boolean {
188
217
  const signal = signalFor(sessionId)
189
- if (signal && signal.turnOpen && signal.ended === null && now - signal.lastEventAt <= OPEN_TURN_CEILING_MS) return true
218
+ if (signal && signal.turnOpen && signal.ended === null && now - signal.lastEventAt <= OPEN_TURN_CEILING_MS) {
219
+ const cancel = threadCancel('claude', sessionId)
220
+ if (cancel === null || !cancelVoidsOpenTurn({
221
+ cancel,
222
+ lastActivityAt: signal.lastEventAt,
223
+ turnStartedAt: signal.turnStartedAt,
224
+ deskEndedAt: deskTurnEndedAt(sessionId, dir),
225
+ })) return true
226
+ }
190
227
  const record = registryRecordSync(sessionId, dir)
191
228
  if (!record || record.status !== 'busy' || record.pid === null || isCosSpawnedPid(record.pid, now)) return false
192
229
  return pidAlive(record.pid)
193
230
  }
194
231
 
232
+ /**
233
+ * 6.53.3 (review A6): stop a turn COS has just handed into the open session, because a
234
+ * cancel arrived while the hand-off was in flight. The marker is written now; when the
235
+ * hooks do not yet show the turn started (its UserPromptSubmit at or after `sentAt`), it is
236
+ * re-armed once when that prompt's own UserPromptSubmit deletes it (`haltDeliveredTurn`).
237
+ */
238
+ export function haltHandedOffTurn(sessionId: string, marker: HaltMarker, turn: { prompt: string; sentAt: number; unverified?: boolean }, now = Date.now()): boolean {
239
+ const signal = signalFor(sessionId)
240
+ const started = !!signal && typeof signal.turnStartedAt === 'number' && signal.turnStartedAt >= turn.sentAt
241
+ return haltDeliveredTurn(sessionId, marker, {
242
+ prompt: turn.prompt,
243
+ after: turn.sentAt,
244
+ rearm: !started,
245
+ now,
246
+ // 6.53.3 /qa: an unverified hand-off (`maybe`) holds its re-arm two minutes, not an hour.
247
+ windowMs: turn.unverified === true ? HALT_REARM_UNVERIFIED_MS : HALT_MARKER_TTL_MS,
248
+ })
249
+ }
250
+
195
251
  let ledger: SessionHookLedger | null = null
196
252
  let ingester: SpoolIngester | null = null
197
253
  let replayed: { rows: number; applied: number } | null = null
@@ -233,6 +289,10 @@ export function startSessionHooksRuntime(options: { port: number }): SessionHook
233
289
  // clear a cancel written since; the TTL sweep covers anything this misses.
234
290
  const unsubscribeHalt = enabled ? sessionSignalStore.subscribe((_signal, env, child) => {
235
291
  if (env.event === 'SessionEnd' && !child) clearHaltMarkerOnSessionEnd(env.sessionId, env.ts)
292
+ // 6.53.3 (review A6): a turn COS handed into the open session while a cancel was on its
293
+ // way. Its own UserPromptSubmit just deleted the marker; write it back (once).
294
+ if (env.event === 'UserPromptSubmit' && !child) rearmHaltOnPrompt(env.sessionId, env.ts, env.payload.prompt)
295
+ if (env.event === 'SessionEnd' && !child) dropHaltRearm(env.sessionId)
236
296
  }) : () => {}
237
297
  sweepHaltMarkers(Date.now())
238
298
  // Runtime files the script reads. The port can change per install; the token never does.
@@ -96,8 +96,7 @@
96
96
  // and answer 400. They never treat an unparsed body as an empty one.
97
97
 
98
98
  import type { FenceRecord } from '../lib/thread-fence-store.js'
99
- import { execFileSync } from 'node:child_process'
100
- import { fenceLiveness, makePidStartProbe, type FenceLiveness, type FenceLivenessDeps } from '../lib/fence-liveness.js'
99
+ import { defaultPidStartProbe, fenceLiveness, type FenceLiveness, type FenceLivenessDeps } from '../lib/fence-liveness.js'
101
100
  import { Router, type Request, type Response } from 'express'
102
101
  import { createHash, randomUUID } from 'node:crypto'
103
102
  import {
@@ -450,8 +449,17 @@ export interface CancelDeps {
450
449
  settlePermissions?: (sessionId: string) => number
451
450
  /** Waiting turns parked on the thread (they hold for CANCEL_QUEUE_HOLD_MS). */
452
451
  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
452
+ /**
453
+ * Record the cancel the turn queue reads (`noteThreadCancelled`). 6.53.3: with its TARGET,
454
+ * because a killed COS child's silence ends its turn and a desk run's does not (review A1).
455
+ */
456
+ noteCancelled?: (provider: string, threadId: string, at: number, target: CancelTarget) => void
457
+ /**
458
+ * 6.53.3 (review A6): a cancel latched during a live hand-off whose turn reached (or may
459
+ * have reached) the open session. Writes the halt marker, and re-arms it once when that
460
+ * turn's own UserPromptSubmit deletes it (`haltHandedOffTurn`). False: not armed.
461
+ */
462
+ haltHandedOffTurn?: (sessionId: string, marker: { at: number; clientCancelId: string }, turn: { prompt: string; sentAt: number; unverified?: boolean }) => boolean
455
463
  /** Append to `data/session-cancel.jsonl`. */
456
464
  ledger?: (row: SessionCancelLedgerRow) => void
457
465
  }
@@ -998,6 +1006,14 @@ export interface TargetFenceView {
998
1006
  fencedReason(targetKey: string): WriteRefusal | null
999
1007
  }
1000
1008
 
1009
+ /** 6.53.3: how often a read may re-probe a settled-cancel fence's children (`ps` per child). */
1010
+ export const CANCEL_FENCE_RECHECK_MS = 30_000
1011
+
1012
+ /** A fence a cancel left at the delivery-ambiguous site: the only kind `releaseSettledCancelFences` may touch. */
1013
+ function isSettledCancelCandidate(row: FenceRecord): boolean {
1014
+ return row.adapterReason === 'cancelled' && row.fenceSite === 'ambiguous'
1015
+ }
1016
+
1001
1017
  export class TargetGuard {
1002
1018
  /** targetKey -> turnId of the single COS turn allowed to be in flight. */
1003
1019
  private readonly claims = new Map<string, string>()
@@ -1006,6 +1022,9 @@ export class TargetGuard {
1006
1022
  private readonly fences = new Map<string, FenceRecord>()
1007
1023
  private readonly persistence: FencePersistence | null
1008
1024
  private persistDegraded = false
1025
+ /** 6.53.3: set by the router; null keeps `fencedReason` a pure read (tests, older wiring). */
1026
+ private cancelLiveness: FenceLivenessDeps | null = null
1027
+ private cancelSweepAt = Number.NEGATIVE_INFINITY
1009
1028
 
1010
1029
  constructor(persistence: FencePersistence | null = null) {
1011
1030
  this.persistence = persistence
@@ -1099,7 +1118,63 @@ export class TargetGuard {
1099
1118
 
1100
1119
  fencedReason(targetKey: string): WriteRefusal | null {
1101
1120
  const row = this.fences.get(targetKey)
1102
- return row === undefined ? null : (row.reason as WriteRefusal)
1121
+ if (row === undefined) return null
1122
+ // 6.53.3: a settled-cancel fence is re-checked here too, throttled, so a thread whose
1123
+ // cancelled child has since exited opens on its next use, not only on a restart or when
1124
+ // someone opens the Fences card.
1125
+ if (this.cancelLiveness !== null && isSettledCancelCandidate(row)) {
1126
+ const now = Date.now()
1127
+ if (now - this.cancelSweepAt >= CANCEL_FENCE_RECHECK_MS) {
1128
+ this.cancelSweepAt = now
1129
+ this.releaseSettledCancelFences(this.cancelLiveness)
1130
+ if (!this.fences.has(targetKey)) return null
1131
+ }
1132
+ }
1133
+ return row.reason as WriteRefusal
1134
+ }
1135
+
1136
+ /**
1137
+ * 6.53.3 (review A4): release every fence a CANCEL left behind whose recorded children
1138
+ * are all provably gone (`fenceLiveness` says `none_running`), and log each one with its
1139
+ * whole evidence record, as the operator release does.
1140
+ *
1141
+ * WHY THIS ONE CLASS, AND ONLY IT. A fence exists because a turn's delivery is unknowable
1142
+ * and a second copy could reach a real conversation. For a cancel that question is moot:
1143
+ * the person asked for the turn to stop, and the route settles it cancelled, never
1144
+ * retried. What remains is a second WRITER, and only the recorded child (the CLI, the one
1145
+ * process that writes the transcript) can be one. 6.53.1 fenced every Claude, Codex and
1146
+ * Cursor cancel whose tool was churning subprocesses (review A4: 10/10 false fences per
1147
+ * variant), and those fences are durable across restarts and upgrades.
1148
+ *
1149
+ * NEVER: a fence whose adapter reason is not `cancelled` (a timeout had 21 minutes of
1150
+ * tool calls; that is still a person's call), one set at the route-error site (a route
1151
+ * bug around a delivery), one whose children were never recorded or could not be probed
1152
+ * (`unknown`), or one with a child still alive (`running`). Returns what was released.
1153
+ */
1154
+ releaseSettledCancelFences(deps: FenceLivenessDeps, log: (line: string) => void = line => console.warn(line)): FenceRecord[] {
1155
+ const released: FenceRecord[] = []
1156
+ for (const row of [...this.fences.values()]) {
1157
+ if (!isSettledCancelCandidate(row)) continue
1158
+ let state: string
1159
+ try {
1160
+ state = fenceLiveness(row.spawns, deps).state
1161
+ } catch {
1162
+ continue
1163
+ }
1164
+ if (state !== 'none_running') continue
1165
+ const target = opaqueRevision(row.targetKey)
1166
+ const outcome = this.releaseFence(target)
1167
+ if (!outcome.ok) continue
1168
+ const ev = outcome.row
1169
+ released.push(ev)
1170
+ 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}`)
1171
+ }
1172
+ return released
1173
+ }
1174
+
1175
+ /** 6.53.3: the probe the lazy re-check in `fencedReason` uses; the router adopts its own. */
1176
+ adoptCancelLiveness(deps: FenceLivenessDeps): void {
1177
+ this.cancelLiveness = deps
1103
1178
  }
1104
1179
 
1105
1180
  /** Every fence, REDACTED for the wire: the raw targetKey embeds the private
@@ -1190,6 +1265,21 @@ interface InFlightTurn {
1190
1265
  startedAt: number
1191
1266
  /** False while a live hand-off is crossing its irreversible write boundary. */
1192
1267
  cancellable: boolean
1268
+ /**
1269
+ * 6.53.3 (review A6): a cancel that arrived while `cancellable` was false. It is LATCHED
1270
+ * (answered 202 `stopping`) and settled by the turn route once the hop says where the
1271
+ * turn went: nothing reached, `turn_cancelled` and no spawn; reached, the desk halt
1272
+ * marker. Until 6.53.2 it was refused 409 `cancel_failed`, that refusal was cached for the
1273
+ * tap, and a hop that reached nothing went on to spawn the turn the person had stopped.
1274
+ */
1275
+ latched: LatchedCancel | null
1276
+ }
1277
+
1278
+ interface LatchedCancel {
1279
+ at: number
1280
+ clientCancelId: string
1281
+ /** Every tap latched onto this turn: each one's cached answer is rewritten to the outcome. */
1282
+ replayKeys: string[]
1193
1283
  }
1194
1284
 
1195
1285
  type Delivery =
@@ -1469,16 +1559,14 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1469
1559
  const guard = deps.guard ?? new TargetGuard(deps.fencePersistence ?? null)
1470
1560
  // `ps -o lstart=` is the only start-time keyword macOS ps offers. Injectable so a
1471
1561
  // test drives recycled and unreadable pids without spawning real processes.
1472
- const livenessDeps: FenceLivenessDeps = deps.liveness ?? {
1473
- pidStartMs: makePidStartProbe((pid) => {
1474
- try {
1475
- return execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], {
1476
- encoding: 'utf8', timeout: 2_000,
1477
- })
1478
- } catch {
1479
- return null // not running, or ps refused the pid
1480
- }
1481
- }),
1562
+ const livenessDeps: FenceLivenessDeps = deps.liveness ?? { pidStartMs: defaultPidStartProbe() }
1563
+ // 6.53.3 (review A4): at boot, release the fences a cancel left whose children are all
1564
+ // gone (6.53.1's false "unreaped" among them), then keep re-checking on every fence read.
1565
+ guard.adoptCancelLiveness(livenessDeps)
1566
+ try {
1567
+ guard.releaseSettledCancelFences(livenessDeps)
1568
+ } catch (error) {
1569
+ console.error(`[agent-session-bindings] settled-cancel fence sweep failed: ${error instanceof Error ? error.message : error}`)
1482
1570
  }
1483
1571
  const ownership = deps?.ownership ?? { record: recordCosSpawn, release: releaseCosSpawn }
1484
1572
  // One per router. Injectable so the follow-on (attach accepting a `forkRef`)
@@ -1524,10 +1612,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1524
1612
  */
1525
1613
  const cancelFactsFor = (provider: string, threadId: string, verdictReason: string | null): CancelFacts => {
1526
1614
  const entry = inFlight.get(targetKey(provider, threadId))
1527
- const cosTurnInFlight = entry?.cancellable === true
1615
+ // 6.53.3: a live hand-off in flight is still OUR turn, and a cancel on it is latched
1616
+ // (review A6), so it reads cos_turn too, never as a desk run while it commits.
1617
+ const cosTurnInFlight = entry !== undefined
1528
1618
  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
1619
  if (!entry) {
1532
1620
  runningOutsideCos = provider === 'claude' && typeof cancelDeps.deskRunning === 'function'
1533
1621
  ? cancelProbe(() => cancelDeps.deskRunning!(threadId) === true, false)
@@ -1548,6 +1636,93 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1548
1636
  }
1549
1637
  /** Idempotency for POST .../cancel: the answer each (thread, clientCancelId) got. */
1550
1638
  const cancelReplies = new Map<string, { status: number; body: Record<string, unknown>; at: number }>()
1639
+ const queuedHeldFor = (provider: string, threadId: string): number => {
1640
+ const n = cancelProbe(() => cancelDeps.queuedWaiting?.(provider, threadId) ?? 0, 0)
1641
+ return Number.isInteger(n) && n > 0 ? n : 0
1642
+ }
1643
+
1644
+ /**
1645
+ * 6.53.3 (review A6): settle a cancel LATCHED during a live hand-off, once the hop has said
1646
+ * where the turn went, and rewrite the answer every latched tap replays to that outcome
1647
+ * (never the transient 202 alone, never a refusal from before the latch).
1648
+ *
1649
+ * none nothing reached a session: the turn route answers `turn_cancelled` and never
1650
+ * spawns. The cached 202 `stopping` already says so.
1651
+ * reached the turn is in the open session (or MAY be: an unverified write). What stops it
1652
+ * / maybe there is what stops any desk run: the halt marker, re-armed once through the
1653
+ * turn's own UserPromptSubmit. Codex and Cursor have no marker, so the answer is
1654
+ * the ordinary `cancel_unsupported` ("Stop it there"); hooks that cannot stop a
1655
+ * desk run answer their own reason; a marker that could not be written is
1656
+ * `cancel_failed` 500, exactly as on the desk path.
1657
+ */
1658
+ const settleLatchedCancel = (
1659
+ entry: InFlightTurn,
1660
+ provider: string,
1661
+ threadId: string,
1662
+ reached: 'none' | 'reached' | 'maybe',
1663
+ turn: { prompt: string; sentAt: number },
1664
+ ): void => {
1665
+ const latch = entry.latched
1666
+ if (latch === null) return
1667
+ const now = readNow() ?? Date.now()
1668
+ const common = { turnId: entry.turnId, queuedHeld: queuedHeldFor(provider, threadId), queuedHoldMs: CANCEL_QUEUE_HOLD_MS }
1669
+ let status = 202
1670
+ let target: CancelTarget = 'cos_turn'
1671
+ let outcome: 'accepted' | CancelRefusal = 'accepted'
1672
+ let body: Record<string, unknown> = { cancelled: true, target, state: 'stopping', ...common }
1673
+ if (reached !== 'none') {
1674
+ target = cancelTargetFor({
1675
+ provider,
1676
+ cosTurnInFlight: false,
1677
+ runningOutsideCos: true,
1678
+ hooksEnabled: cancelProbe(() => cancelDeps.hooksEnabled?.() === true, false),
1679
+ hooksReady: cancelProbe(() => cancelDeps.hooksReady?.() === true, false),
1680
+ }) ?? 'unsupported'
1681
+ if (target === 'desk_run') {
1682
+ const armed = cancelProbe(() => cancelDeps.haltHandedOffTurn?.(threadId, { at: latch.at, clientCancelId: latch.clientCancelId }, {
1683
+ // 6.53.3 /qa: the WHOLE delivered prompt (the re-arm matches it exactly), and whether
1684
+ // the hand-off was only possibly delivered (`maybe`: a two-minute re-arm, not an hour).
1685
+ prompt: turn.prompt,
1686
+ sentAt: turn.sentAt,
1687
+ unverified: reached === 'maybe',
1688
+ }) === true, false)
1689
+ if (armed) {
1690
+ const settledPermissions = cancelProbe(() => cancelDeps.settlePermissions?.(threadId) ?? 0, 0)
1691
+ body = {
1692
+ cancelled: true,
1693
+ target,
1694
+ effective: 'next_tool_call',
1695
+ ...common,
1696
+ settledPermissions: Number.isInteger(settledPermissions) && settledPermissions > 0 ? settledPermissions : 0,
1697
+ }
1698
+ } else {
1699
+ status = 500
1700
+ outcome = 'cancel_failed'
1701
+ }
1702
+ } else {
1703
+ status = 409
1704
+ outcome = target === 'hooks_outdated' || target === 'hooks_disabled' ? target : 'cancel_unsupported'
1705
+ }
1706
+ if (outcome !== 'accepted') {
1707
+ body = {
1708
+ cancelled: false,
1709
+ reason: outcome,
1710
+ ...(outcome === 'cancel_unsupported' ? { provider } : {}),
1711
+ reasonCopy: cancelRefusalCopy(outcome, provider),
1712
+ }
1713
+ }
1714
+ }
1715
+ for (const replayKey of latch.replayKeys) {
1716
+ const previous = cancelReplies.get(replayKey)
1717
+ cancelReplies.set(replayKey, { status, body, at: previous?.at ?? now })
1718
+ }
1719
+ // The first answer was ledgered `accepted`; a cancel that then could not be carried out
1720
+ // gets a line of its own, so the ledger never records a stop that did not happen.
1721
+ if (outcome !== 'accepted') {
1722
+ cancelProbe(() => cancelDeps.ledger?.({ at: new Date(now).toISOString(), provider, threadId, target, outcome, clientCancelId: latch.clientCancelId }), undefined)
1723
+ }
1724
+ 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}`)
1725
+ }
1551
1726
 
1552
1727
  /**
1553
1728
  * One owner for "is this thread free right now", shared by the probe and both
@@ -1630,6 +1805,13 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1630
1805
  // `degraded` is reported, never inferred: a memory-only fence set behaves
1631
1806
  // identically to a durable one until the process restarts, so a silent
1632
1807
  // fallback would be indistinguishable from working.
1808
+ // 6.53.3: a read releases the settled-cancel fences first (logged), so the card never
1809
+ // offers a person a fence the server can prove is safe to lift.
1810
+ try {
1811
+ guard.releaseSettledCancelFences(livenessDeps)
1812
+ } catch (error) {
1813
+ console.error(`[agent-session-bindings] settled-cancel fence sweep failed: ${error instanceof Error ? error.message : error}`)
1814
+ }
1633
1815
  res.json({ fences: guard.listFences(livenessDeps), degraded: guard.degraded() })
1634
1816
  })
1635
1817
 
@@ -1795,17 +1977,33 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1795
1977
  // The occupancy verdict is read only when the decision needs it: never for a COS turn
1796
1978
  // (the map knows) and never for Claude with a desk probe wired (the hooks know).
1797
1979
  const key = targetKey(provider, threadId)
1980
+ const queuedHeld = (): number => queuedHeldFor(provider, threadId)
1798
1981
  const activeEntry = inFlight.get(key)
1799
1982
  if (activeEntry && activeEntry.cancellable !== true) {
1800
- return refuseCancel(null, 'cancel_failed')
1983
+ // 6.53.3 (review A6): the turn is crossing a live hand-off's write boundary, where no
1984
+ // abort can recall it. LATCH the cancel on the turn and answer as for any COS turn;
1985
+ // the turn route settles it when the hop reports (`settleLatchedCancel`). The abort
1986
+ // is still signalled, so nothing after the hop can start the turn either.
1987
+ if (activeEntry.latched === null) {
1988
+ activeEntry.latched = { at: now, clientCancelId, replayKeys: [] }
1989
+ activeEntry.controller.abort()
1990
+ // Noted as what the run becomes if the hop lands it: a desk run (Claude) or one only
1991
+ // its app can stop. Either way only the engine's own end discounts its open turn.
1992
+ cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now, provider === 'claude' ? 'desk_run' : 'unsupported'), undefined)
1993
+ }
1994
+ activeEntry.latched.replayKeys.push(replayKey)
1995
+ return answer(202, 'cos_turn', 'accepted', {
1996
+ cancelled: true,
1997
+ target: 'cos_turn',
1998
+ state: 'stopping',
1999
+ turnId: activeEntry.turnId,
2000
+ queuedHeld: queuedHeld(),
2001
+ queuedHoldMs: CANCEL_QUEUE_HOLD_MS,
2002
+ })
1801
2003
  }
1802
2004
  const needsVerdict = !inFlight.has(key) && !(provider === 'claude' && typeof cancelDeps.deskRunning === 'function')
1803
2005
  const verdictReason = needsVerdict && canDetect ? projectAttachability(detectOccupancy(provider, threadId)).reason : null
1804
2006
  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
2007
 
1810
2008
  if (target === 'cos_turn') {
1811
2009
  const entry = inFlight.get(key)
@@ -1813,7 +2011,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1813
2011
  // The adapter stops the process group (SIGTERM, then SIGKILL after 5 s) and the turn
1814
2012
  // route records the outcome; this answer does not wait for either.
1815
2013
  entry.controller.abort()
1816
- cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now), undefined)
2014
+ cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now, target), undefined)
1817
2015
  return answer(202, target, 'accepted', {
1818
2016
  cancelled: true,
1819
2017
  target,
@@ -1831,7 +2029,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1831
2029
  // run waiting for its answer, and then show the Mac's own dialog. Deny it, with
1832
2030
  // interrupt, so the run ends now rather than at a tool that never comes.
1833
2031
  const settledPermissions = cancelProbe(() => cancelDeps.settlePermissions?.(threadId) ?? 0, 0)
1834
- cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now), undefined)
2032
+ cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now, target), undefined)
1835
2033
  return answer(202, target, 'accepted', {
1836
2034
  cancelled: true,
1837
2035
  target,
@@ -2452,7 +2650,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2452
2650
  if (!guard.tryClaim(key, turnId)) return refuseTurn('native_turn_in_progress')
2453
2651
  claimedKey = key
2454
2652
  // 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 })
2653
+ inFlight.set(key, { turnId, bindingId, clientTurnId, controller: cancelController, startedAt: now, cancellable: true, latched: null })
2456
2654
 
2457
2655
  // Plan 4.3 step 6. The attach-time verdict is minutes old by now; a desktop
2458
2656
  // session started in the gap is exactly the residual risk option B leaves
@@ -2521,6 +2719,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2521
2719
  if (typeof deps.deliverLiveTurn === 'function' && binding.provider === 'claude') {
2522
2720
  const liveEntry = inFlight.get(key)
2523
2721
  if (liveEntry?.turnId === turnId) liveEntry.cancellable = false
2722
+ /** 6.53.3: a cancel latched onto THIS turn while the hop was in flight (review A6). */
2723
+ const latchedHere = (): InFlightTurn | null => (liveEntry?.turnId === turnId && liveEntry.latched !== null ? liveEntry : null)
2724
+ const handOffAt = Date.now()
2524
2725
  let live: { ok: boolean; reason: string; verifiedBy?: string | null; pid?: number | null } | null = null
2525
2726
  try {
2526
2727
  live = await deps.deliverLiveTurn({
@@ -2538,6 +2739,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2538
2739
  }
2539
2740
  stage(`live(${live?.reason ?? 'threw'})`)
2540
2741
  if (live?.ok) {
2742
+ // 6.53.3: delivered, so the turn runs in the open session now, and what stops it
2743
+ // there is the desk halt marker. The turn itself WAS delivered and says so.
2744
+ const latched = latchedHere()
2745
+ if (latched) settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'reached', { prompt, sentAt: handOffAt })
2541
2746
  console.log(`[agent-session-bindings] turn delivered live provider=${binding.provider} turnId=${turnId} bindingId=${bindingId} verifiedBy=${live.verifiedBy ?? 'unknown'} pid=${live.pid ?? 'null'}`)
2542
2747
  respond(200, {
2543
2748
  turnId,
@@ -2553,11 +2758,27 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2553
2758
  return
2554
2759
  }
2555
2760
  if (live && (live.reason === 'unverified' || live.reason === 'write_failed')) {
2761
+ // 6.53.3: something MAY be in the session and the person asked for it to stop: arm
2762
+ // the marker as for a landed turn, and settle this turn as cancelled, never as a
2763
+ // retryable hold that the queue would send again after the cancel's two minutes.
2764
+ const latched = latchedHere()
2765
+ if (latched) {
2766
+ settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'maybe', { prompt, sentAt: handOffAt })
2767
+ return refuseTurn('turn_cancelled', { retryable: false, deliveryState: 'unknown' })
2768
+ }
2556
2769
  // Something may be in the session. Hold, never spawn over it.
2557
2770
  return refuseTurn('live_unverified', { retryable: true, deliveryState: 'unknown' })
2558
2771
  }
2559
- // Every remaining result is documented as "nothing reached a session";
2560
- // the abortable child path is safe to expose again before it starts.
2772
+ // Every remaining result is documented as "nothing reached a session".
2773
+ // 6.53.3: with a cancel latched on the way, that is the whole turn: cancelled, no
2774
+ // spawn. Until 6.53.2 the refused cancel was forgotten here and the child ran.
2775
+ const latched = latchedHere()
2776
+ if (latched) {
2777
+ settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'none', { prompt, sentAt: handOffAt })
2778
+ console.log(`[agent-session-bindings] turn cancelled during live hand-off provider=${binding.provider} turnId=${turnId} bindingId=${bindingId} hop=${live?.reason ?? 'threw'}`)
2779
+ return refuseTurn('turn_cancelled', { retryable: false })
2780
+ }
2781
+ // The abortable child path is safe to expose again before it starts.
2561
2782
  if (liveEntry?.turnId === turnId) liveEntry.cancellable = true
2562
2783
  // disabled / not_claude / no_record / protocol_unsupported / connect_failed /
2563
2784
  // suspect_expired: nothing reached a session. Spawn as always.
@@ -2573,6 +2794,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2573
2794
  if (binding.provider === 'codex' && foreignHolder && typeof deps.deliverCodexLiveTurn === 'function') {
2574
2795
  const liveEntry = inFlight.get(key)
2575
2796
  if (liveEntry?.turnId === turnId) liveEntry.cancellable = false
2797
+ /** 6.53.3: a cancel latched onto THIS turn while the hop was in flight (review A6). */
2798
+ const latchedHere = (): InFlightTurn | null => (liveEntry?.turnId === turnId && liveEntry.latched !== null ? liveEntry : null)
2799
+ const handOffAt = Date.now()
2576
2800
  let live: { ok: boolean; reason: string; verifiedBy?: string | null; queuedId?: string | null } | null = null
2577
2801
  try {
2578
2802
  live = await deps.deliverCodexLiveTurn({
@@ -2587,6 +2811,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2587
2811
  }
2588
2812
  stage(`codex-live(${live?.reason ?? 'threw'})`)
2589
2813
  if (live?.ok) {
2814
+ // 6.53.3: queued in the Codex app, which only the app can stop ("Stop it there").
2815
+ const latched = latchedHere()
2816
+ if (latched) settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'reached', { prompt, sentAt: handOffAt })
2590
2817
  console.log(`[agent-session-bindings] turn delivered live provider=codex turnId=${turnId} bindingId=${bindingId} verifiedBy=${live.verifiedBy ?? 'unknown'} queuedId=${live.queuedId ?? 'null'}`)
2591
2818
  respond(200, {
2592
2819
  turnId,
@@ -2601,6 +2828,14 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2601
2828
  })
2602
2829
  return
2603
2830
  }
2831
+ // 6.53.3: anything but a proven or possible queue row reached nothing; with a cancel
2832
+ // latched on the way that is the whole turn: cancelled, and no child.
2833
+ const codexLatched = latchedHere()
2834
+ if (codexLatched && live !== null && live.reason !== 'unverified') {
2835
+ settleLatchedCancel(codexLatched, binding.provider, binding.nativeThreadId, 'none', { prompt, sentAt: handOffAt })
2836
+ console.log(`[agent-session-bindings] turn cancelled during live hand-off provider=codex turnId=${turnId} bindingId=${bindingId} hop=${live.reason}`)
2837
+ return refuseTurn('turn_cancelled', { retryable: false })
2838
+ }
2604
2839
  // The operator switched this off: nothing was queued, so the abortable
2605
2840
  // 6.50 child path is cancellable again before it starts.
2606
2841
  if (live?.reason === 'disabled' && liveEntry?.turnId === turnId) liveEntry.cancellable = true
@@ -2624,6 +2859,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2624
2859
  spawns: [],
2625
2860
  })
2626
2861
  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`)
2862
+ // 6.53.3: a row MAY be in the Codex app's queue; a latched cancel says where to stop it.
2863
+ if (codexLatched) settleLatchedCancel(codexLatched, binding.provider, binding.nativeThreadId, 'maybe', { prompt, sentAt: handOffAt })
2627
2864
  return reportAmbiguous()
2628
2865
  }
2629
2866
  // 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 ?? []