@gotcos/glasses-server 6.52.3 → 6.53.1

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.
@@ -2,6 +2,7 @@
2
2
  // GET /api/agent-sessions/bindings
3
3
  // POST /api/agent-sessions/:provider/:threadId/attach
4
4
  // POST /api/agent-sessions/bindings/:bindingId/turns
5
+ // POST /api/agent-sessions/:provider/:threadId/cancel (6.53.0, see its handler)
5
6
  //
6
7
  // The client-facing half of Continue Original Agent Thread. Phase 0 resolved
7
8
  // plan 4.3 to option B: COS attaches to a native desktop thread ONLY when no
@@ -125,6 +126,16 @@ import type { RegistryCheck, RegistryRejection, RegistryResult } from '../lib/ag
125
126
  import { recordCosSpawn, releaseCosSpawn } from '../lib/agent-session-ownership-store.js'
126
127
  import { isValidNativeThreadId } from '../lib/native-thread-id.js'
127
128
  import { PEER_VERIFY_TIMEOUT_MS } from '../lib/session-peer-inbox.js'
129
+ import {
130
+ CANCEL_QUEUE_HOLD_MS,
131
+ CLIENT_CANCEL_ID_RE,
132
+ cancelRefusalCopy,
133
+ cancelTargetFor,
134
+ type CancelFacts,
135
+ type CancelRefusal,
136
+ type CancelTarget,
137
+ type SessionCancelLedgerRow,
138
+ } from '../lib/session-cancel.js'
128
139
 
129
140
  /**
130
141
  * Read side of the binding lease store.
@@ -247,6 +258,12 @@ export interface AttachedTurnRequest {
247
258
  * simply never calls it.
248
259
  */
249
260
  onSpawn: (pid: number) => boolean
261
+ /**
262
+ * 6.53.0: aborted by POST .../cancel. The adapter checks it before the spawn and
263
+ * before the prompt write, and stops the child's process group after. Optional so an
264
+ * adapter that ignores it is byte-for-byte the 6.52 route.
265
+ */
266
+ abortSignal?: AbortSignal
250
267
  }
251
268
 
252
269
  /**
@@ -271,7 +288,7 @@ export type AttachedTurnResult =
271
288
  * that module's own `attachedDeliveryAmbiguous`: only `not_attempted` and
272
289
  * `aborted` are proof that nothing was sent.
273
290
  */
274
- | { ok: boolean; delivery: 'not_attempted' | 'aborted' | 'ambiguous' | 'delivered' }
291
+ | { ok: boolean; delivery: 'not_attempted' | 'aborted' | 'ambiguous' | 'delivered' | 'cancelled' }
275
292
 
276
293
  export interface AgentSessionBindingsDeps {
277
294
  /** Shared fence state. Omit and the router owns a private one, which is correct
@@ -407,6 +424,44 @@ export interface AgentSessionBindingsDeps {
407
424
  maxPromptChars?: number
408
425
  /** Injected so a test can pin the minted ids. Must return an id matching BINDING_ID_RE. */
409
426
  newId?: () => string
427
+
428
+ // ------------------------------------------------------------- cancel side
429
+
430
+ /**
431
+ * 6.53.0: what the cancel route and the attachability `cancel` field read about runs
432
+ * OUTSIDE this router. Every member is optional and an absent one reads as the cautious
433
+ * fact: no desk run seen, hooks off, not ready, nothing held. So an older wiring answers
434
+ * `not_running` or `hooks_disabled`, never a cancel it cannot carry out. A COS turn in
435
+ * flight is this router's own knowledge and needs none of it.
436
+ */
437
+ cancel?: CancelDeps
438
+ }
439
+
440
+ export interface CancelDeps {
441
+ /** A Claude session is running at the desk: its hooks say the turn is open, or its registry record says busy. */
442
+ deskRunning?: (sessionId: string) => boolean
443
+ /** `sessionHooksEnabled()`. */
444
+ hooksEnabled?: () => boolean
445
+ /** The installed hooks carry the 6.53.0 script and PreToolUse subscription. */
446
+ hooksReady?: () => boolean
447
+ /** `writeHaltMarker`. False means the cancel could not be armed. */
448
+ writeHalt?: (sessionId: string, marker: { at: number; clientCancelId: string }) => boolean
449
+ /** Settle this session's held permission prompts as deny with interrupt; returns how many. */
450
+ settlePermissions?: (sessionId: string) => number
451
+ /** Waiting turns parked on the thread (they hold for CANCEL_QUEUE_HOLD_MS). */
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
455
+ /** Append to `data/session-cancel.jsonl`. */
456
+ ledger?: (row: SessionCancelLedgerRow) => void
457
+ }
458
+
459
+ /**
460
+ * The attachability body plus what a cancel would do (6.53.0). A separate type so every
461
+ * other caller of `projectAttachability` keeps its four fields.
462
+ */
463
+ export interface AttachabilityWithCancel extends AttachabilityBody {
464
+ cancel: CancelTarget | null
410
465
  }
411
466
 
412
467
  /**
@@ -513,6 +568,10 @@ export type WriteRefusal =
513
568
  // thread dies on "already has an active writer" after the prompt is written, which
514
569
  // is the ambiguous fence this whole branch exists to avoid.
515
570
  | 'codex_live_unavailable'
571
+ // 6.53.0: the turn was stopped by POST .../cancel. Terminal and never retried: the
572
+ // person said stop. Deliberately NOT a fence (plan validation B2): the next Continue
573
+ // must work, and it re-reads the head.
574
+ | 'turn_cancelled'
516
575
  // ------------------------------------------------------------------- fork
517
576
  //
518
577
  // Fork gets its OWN members rather than reusing the ones above, and the reason
@@ -591,6 +650,8 @@ export const WRITE_REASON_COPY: Record<Exclude<WriteRefusal, OccupancyReason>, s
591
650
  'The Codex app has this thread open, and COS could not hand it the message. Nothing was sent. Send it from the Codex app, or fork it.',
592
651
  turn_failed:
593
652
  'COS could not run this turn. Nothing was sent. You can try again.',
653
+ turn_cancelled:
654
+ 'Run cancelled.',
594
655
 
595
656
  // Fork copy. No sentence here may end with "Fork it instead" — this IS the fork,
596
657
  // and pointing a failed fork back at itself is a dead end rather than an action.
@@ -1120,10 +1181,23 @@ export class TargetGuard {
1120
1181
  }
1121
1182
  }
1122
1183
 
1184
+ /** A COS turn this router is running (6.53.0). */
1185
+ interface InFlightTurn {
1186
+ turnId: string
1187
+ bindingId: string
1188
+ clientTurnId: string | null
1189
+ controller: AbortController
1190
+ startedAt: number
1191
+ /** False while a live hand-off is crossing its irreversible write boundary. */
1192
+ cancellable: boolean
1193
+ }
1194
+
1123
1195
  type Delivery =
1124
1196
  | { kind: 'completed'; after: string | null }
1125
1197
  | { kind: 'aborted' }
1126
1198
  | { kind: 'ambiguous' }
1199
+ /** Stopped by a cancel this route requested and positively reaped. */
1200
+ | { kind: 'cancelled' }
1127
1201
 
1128
1202
  /**
1129
1203
  * Read an adapter result without believing anything it did not say.
@@ -1131,9 +1205,27 @@ type Delivery =
1131
1205
  * The default is ambiguous, and every unrecognised shape lands there: null, an
1132
1206
  * array, a missing status, a status from a newer adapter. Only the two literals
1133
1207
  * this build understands are allowed to mean anything.
1208
+ *
1209
+ * `cancelRequested` (6.53.0) is THIS ROUTE's own knowledge that it aborted the turn. The
1210
+ * adapter's `cancelled` is believed only then: a result claiming a cancel nobody asked for
1211
+ * is a contradiction, and a contradiction is ambiguous, which fences. The other way round
1212
+ * holds too: a cancel we asked for that the adapter reports as delivered was delivered.
1134
1213
  */
1135
- export function classifyDelivery(result: unknown): Delivery {
1214
+ export function classifyDelivery(result: unknown, cancelRequested = false): Delivery {
1136
1215
  if (!result || typeof result !== 'object' || Array.isArray(result)) return { kind: 'ambiguous' }
1216
+ if (cancelRequested === true) {
1217
+ const { ok, reason, delivery, reaped } = result as { ok?: unknown; reason?: unknown; delivery?: unknown; reaped?: unknown }
1218
+ if (ok === false && reason === 'cancelled'
1219
+ && ((delivery === 'cancelled' && reaped === true)
1220
+ || delivery === 'not_attempted'
1221
+ || (delivery === 'aborted' && reaped === true))) {
1222
+ return { kind: 'cancelled' }
1223
+ }
1224
+ // A cancel result that names a spawned-but-unreaped child must not fall
1225
+ // through to the generic `aborted` classifier below. The route requested
1226
+ // the stop, but it cannot claim the process tree is gone, so fence it.
1227
+ if (ok === false && reason === 'cancelled') return { kind: 'ambiguous' }
1228
+ }
1137
1229
  const status = (result as { status?: unknown }).status
1138
1230
  if (status === 'completed') {
1139
1231
  const after = (result as { nativeRevisionAfter?: unknown }).nativeRevisionAfter
@@ -1402,6 +1494,61 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1402
1494
  : DEFAULT_MAX_PROMPT_CHARS
1403
1495
  const mintId = typeof deps?.newId === 'function' ? deps.newId : () => randomUUID()
1404
1496
 
1497
+ // ------------------------------------------------------------ in flight (6.53.0)
1498
+ //
1499
+ // Every COS turn this router is running, by target, from the moment its claim is taken
1500
+ // until the `finally` that releases the claim. It sits next to `TargetGuard.claims` and
1501
+ // has the same lifetime, so "a COS turn is in flight" here and "the target is claimed"
1502
+ // there cannot disagree. The controller is the ONLY way a cancel reaches the child:
1503
+ // never a stored pid, which could have been recycled by the time anyone signals it.
1504
+ // Entries are deleted by turnId, so a late unwind cannot delete its successor's entry.
1505
+ const inFlight = new Map<string, InFlightTurn>()
1506
+ const cancelDeps: CancelDeps = deps?.cancel ?? {}
1507
+ /** A throwing or absent probe reads as the cautious answer, never as a thrown route. */
1508
+ const cancelProbe = <T>(read: (() => T) | undefined, fallback: T): T => {
1509
+ if (typeof read !== 'function') return fallback
1510
+ try {
1511
+ return read()
1512
+ } catch (error) {
1513
+ console.error(`[agent-session-bindings] cancel probe threw: ${error instanceof Error ? error.message : error}`)
1514
+ return fallback
1515
+ }
1516
+ }
1517
+ /**
1518
+ * The facts `cancelTargetFor` decides on, for one thread. `verdictReason` is the
1519
+ * occupancy reason when the caller already holds a verdict (attachability) or null.
1520
+ * For Claude, a wired `deskRunning` is the authority (the hooks and the registry know a
1521
+ * desk turn precisely; a transcript written 20 s ago does not); without it, and for
1522
+ * Codex and Cursor, "a foreign writer is working" is the occupancy verdict's own
1523
+ * `native_thread_working`.
1524
+ */
1525
+ const cancelFactsFor = (provider: string, threadId: string, verdictReason: string | null): CancelFacts => {
1526
+ const entry = inFlight.get(targetKey(provider, threadId))
1527
+ const cosTurnInFlight = entry?.cancellable === true
1528
+ 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
+ if (!entry) {
1532
+ runningOutsideCos = provider === 'claude' && typeof cancelDeps.deskRunning === 'function'
1533
+ ? cancelProbe(() => cancelDeps.deskRunning!(threadId) === true, false)
1534
+ : verdictReason === 'native_thread_working'
1535
+ }
1536
+ return {
1537
+ provider,
1538
+ cosTurnInFlight,
1539
+ runningOutsideCos,
1540
+ hooksEnabled: cancelProbe(() => cancelDeps.hooksEnabled?.() === true, false),
1541
+ hooksReady: cancelProbe(() => cancelDeps.hooksReady?.() === true, false),
1542
+ }
1543
+ }
1544
+ /** The attachability body's `cancel`, for a thread id the caller has NOT validated. */
1545
+ const cancelFieldFor = (provider: string, threadId: string, verdictReason: string | null): CancelTarget | null => {
1546
+ if (!isBindableProvider(provider) || !isValidNativeThreadId(threadId)) return null
1547
+ return cancelTargetFor(cancelFactsFor(provider, threadId, verdictReason))
1548
+ }
1549
+ /** Idempotency for POST .../cancel: the answer each (thread, clientCancelId) got. */
1550
+ const cancelReplies = new Map<string, { status: number; body: Record<string, unknown>; at: number }>()
1551
+
1405
1552
  /**
1406
1553
  * One owner for "is this thread free right now", shared by the probe and both
1407
1554
  * writes, including its own try/catch and the self-contradiction re-check.
@@ -1535,15 +1682,24 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1535
1682
  // fail. It also keeps the surface self-consistent: reporting `attachable: true`
1536
1683
  // while the attach route is unrouted would leave a client unable to tell
1537
1684
  // whether the thread is free or the feature is off.
1685
+ // 6.53.0: every answer below also says what a cancel would do (`cancelTargetFor`, the
1686
+ // same function the cancel route decides with). Null means no run to cancel, and the
1687
+ // lens then shows no row. Computed on the disabled paths too: a desk run can be
1688
+ // cancelled whether or not COS may write into the thread.
1689
+ const provider = String(req.params.provider ?? '')
1690
+ const threadId = String(req.params.threadId ?? '')
1691
+ const withCancel = (body: AttachabilityBody): AttachabilityWithCancel =>
1692
+ ({ ...body, cancel: cancelFieldFor(provider, threadId, body.reason) })
1693
+
1538
1694
  if (!attachEnabled) {
1539
- res.json(projectAttachability({ attachable: false, owners: [], reason: 'attach_disabled' }))
1695
+ res.json(withCancel(projectAttachability({ attachable: false, owners: [], reason: 'attach_disabled' })))
1540
1696
  return
1541
1697
  }
1542
1698
 
1543
1699
  if (!canDetect) {
1544
1700
  // The mechanism does not exist on this install. Distinct from "it ran and
1545
1701
  // found nothing" by design (plan 4.3 wants the reason nameable).
1546
- res.json(projectAttachability({ attachable: false, owners: [], reason: 'detector_unavailable' }))
1702
+ res.json(withCancel(projectAttachability({ attachable: false, owners: [], reason: 'detector_unavailable' })))
1547
1703
  return
1548
1704
  }
1549
1705
 
@@ -1561,7 +1717,133 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1561
1717
  // truncated-id hole opened. The tests pin the ordering behaviorally instead:
1562
1718
  // probes that throw on every call still return `unsupported_provider` /
1563
1719
  // `invalid_thread_id`, which is only possible if nothing was probed.
1564
- res.json(runOccupancy(String(req.params.provider ?? ''), String(req.params.threadId ?? '')))
1720
+ res.json(withCancel(runOccupancy(provider, threadId)))
1721
+ })
1722
+
1723
+ // ------------------------------------------------------------------ cancel
1724
+ //
1725
+ // POST /api/agent-sessions/:provider/:threadId/cancel { clientCancelId }
1726
+ //
1727
+ // 6.53.0. Miles, 2026-09-21: "If there's a current run that's going, it essentially
1728
+ // clicks the cancel button ... similar to when they're at the desktop."
1729
+ //
1730
+ // REGISTERED UNCONDITIONALLY, not behind `attachEnabled`: a desk Claude run can be
1731
+ // stopped whether or not COS may write into the thread, and a 404 here is how the app
1732
+ // recognises a server older than 6.53 ("Update the server to cancel runs").
1733
+ //
1734
+ // WHAT IT CAN DO is decided by `cancelTargetFor`, the function the attachability
1735
+ // `cancel` field uses, so the row the lens showed and the answer to the tap agree:
1736
+ // cos_turn 202 {target, state: 'stopping', turnId, queuedHeld, queuedHoldMs}: our own child's
1737
+ // controller is aborted, and the turn route records `turn_cancelled`.
1738
+ // desk_run 202 {target, effective: 'next_tool_call', queuedHeld, queuedHoldMs}: a halt marker
1739
+ // the hook reads before the session's next tool call, and any permission
1740
+ // prompt the broker holds for that session is denied with interrupt.
1741
+ // refusals 409 {reason, reasonCopy}: not_running, cancel_unsupported (Codex app,
1742
+ // Cursor), hooks_outdated (Install hooks), hooks_disabled.
1743
+ //
1744
+ // `queuedHoldMs` is how long the parked turns wait (CANCEL_QUEUE_HOLD_MS), so a client
1745
+ // never hard-codes the two minutes.
1746
+ //
1747
+ // IDEMPOTENT PER clientCancelId: the same tap retried gets the same status and body
1748
+ // (plus `replayed: true`), and does not arm or abort anything twice.
1749
+ //
1750
+ // The body carries no path, no pid and no native id beyond the one in the URL.
1751
+ const CANCEL_REPLY_TTL_MS = 10 * 60_000
1752
+ const MAX_CANCEL_REPLIES = 256
1753
+ router.post('/agent-sessions/:provider/:threadId/cancel', (req, res) => {
1754
+ res.set('Cache-Control', 'private, no-store')
1755
+ const provider = String(req.params.provider ?? '')
1756
+ // Lower-cased BEFORE validation: it names a file and a map key, and the hook
1757
+ // lower-cases the id it reads, so any other spelling could never match.
1758
+ const threadId = String(req.params.threadId ?? '').toLowerCase()
1759
+ const body = plainBody(req)
1760
+ const clientCancelId = body?.clientCancelId
1761
+ if (!isBindableProvider(provider) || !isValidNativeThreadId(threadId)
1762
+ || typeof clientCancelId !== 'string' || !CLIENT_CANCEL_ID_RE.test(clientCancelId)) {
1763
+ res.status(400).json({ cancelled: false, reason: 'invalid_request', reasonCopy: cancelRefusalCopy('invalid_request', provider) })
1764
+ return
1765
+ }
1766
+
1767
+ const now = readNow() ?? Date.now()
1768
+ for (const [k, v] of cancelReplies) if (now - v.at > CANCEL_REPLY_TTL_MS) cancelReplies.delete(k)
1769
+ const replayKey = `${provider}:${threadId}:${clientCancelId}`
1770
+ const replay = cancelReplies.get(replayKey)
1771
+ if (replay) {
1772
+ res.status(replay.status).json({ ...replay.body, replayed: true })
1773
+ return
1774
+ }
1775
+
1776
+ const answer = (status: number, target: CancelTarget | null, outcome: 'accepted' | CancelRefusal, payload: Record<string, unknown>): void => {
1777
+ cancelReplies.set(replayKey, { status, body: payload, at: now })
1778
+ while (cancelReplies.size > MAX_CANCEL_REPLIES) {
1779
+ const oldest = cancelReplies.keys().next()
1780
+ if (oldest.done) break
1781
+ cancelReplies.delete(oldest.value)
1782
+ }
1783
+ cancelProbe(() => cancelDeps.ledger?.({ at: new Date(now).toISOString(), provider, threadId, target, outcome, clientCancelId }), undefined)
1784
+ console.log(`[agent-session-bindings] cancel provider=${provider} target=${target ?? 'none'} outcome=${outcome} status=${status} clientCancelId=${clientCancelId}`)
1785
+ res.status(status).json(payload)
1786
+ }
1787
+ const refuseCancel = (target: CancelTarget | null, reason: CancelRefusal, status = 409): void =>
1788
+ answer(status, target, reason, {
1789
+ cancelled: false,
1790
+ reason,
1791
+ ...(reason === 'cancel_unsupported' ? { provider } : {}),
1792
+ reasonCopy: cancelRefusalCopy(reason, provider),
1793
+ })
1794
+
1795
+ // The occupancy verdict is read only when the decision needs it: never for a COS turn
1796
+ // (the map knows) and never for Claude with a desk probe wired (the hooks know).
1797
+ const key = targetKey(provider, threadId)
1798
+ const activeEntry = inFlight.get(key)
1799
+ if (activeEntry && activeEntry.cancellable !== true) {
1800
+ return refuseCancel(null, 'cancel_failed')
1801
+ }
1802
+ const needsVerdict = !inFlight.has(key) && !(provider === 'claude' && typeof cancelDeps.deskRunning === 'function')
1803
+ const verdictReason = needsVerdict && canDetect ? projectAttachability(detectOccupancy(provider, threadId)).reason : null
1804
+ 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
+
1810
+ if (target === 'cos_turn') {
1811
+ const entry = inFlight.get(key)
1812
+ if (!entry) return refuseCancel(null, 'not_running')
1813
+ // The adapter stops the process group (SIGTERM, then SIGKILL after 5 s) and the turn
1814
+ // route records the outcome; this answer does not wait for either.
1815
+ entry.controller.abort()
1816
+ cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now), undefined)
1817
+ return answer(202, target, 'accepted', {
1818
+ cancelled: true,
1819
+ target,
1820
+ state: 'stopping',
1821
+ turnId: entry.turnId,
1822
+ queuedHeld: queuedHeld(),
1823
+ queuedHoldMs: CANCEL_QUEUE_HOLD_MS,
1824
+ })
1825
+ }
1826
+ if (target === 'desk_run') {
1827
+ const armed = cancelProbe(() => cancelDeps.writeHalt?.(threadId, { at: now, clientCancelId }) === true, false)
1828
+ // Not armed means nothing will stop the run: never report it accepted.
1829
+ if (!armed) return refuseCancel(target, 'cancel_failed', 500)
1830
+ // The away-from-desk case: a prompt the broker is holding would otherwise keep the
1831
+ // run waiting for its answer, and then show the Mac's own dialog. Deny it, with
1832
+ // interrupt, so the run ends now rather than at a tool that never comes.
1833
+ const settledPermissions = cancelProbe(() => cancelDeps.settlePermissions?.(threadId) ?? 0, 0)
1834
+ cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now), undefined)
1835
+ return answer(202, target, 'accepted', {
1836
+ cancelled: true,
1837
+ target,
1838
+ effective: 'next_tool_call',
1839
+ queuedHeld: queuedHeld(),
1840
+ queuedHoldMs: CANCEL_QUEUE_HOLD_MS,
1841
+ settledPermissions: Number.isInteger(settledPermissions) && settledPermissions > 0 ? settledPermissions : 0,
1842
+ })
1843
+ }
1844
+ if (target === 'unsupported') return refuseCancel(target, 'cancel_unsupported')
1845
+ if (target === 'hooks_outdated' || target === 'hooks_disabled') return refuseCancel(target, target)
1846
+ return refuseCancel(null, 'not_running')
1565
1847
  })
1566
1848
 
1567
1849
  router.get('/agent-sessions/bindings', (_req, res) => {
@@ -1952,6 +2234,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1952
2234
  `[agent-session-bindings] turn timing turnId=${turnId} outcome=${outcome} total=${Number((process.hrtime.bigint() - stageClock) / 1_000_000n)}ms ${stages.join(' ')}`
1953
2235
  /** The target we hold a claim on, released in the finally. */
1954
2236
  let claimedKey: string | null = null
2237
+ /** 6.53.0: this turn's cancel. Registered in `inFlight` at the claim, deleted in the finally. */
2238
+ const cancelController = new AbortController()
1955
2239
  // Hoisted so the CATCH site can fence with evidence. `binding` and `head` are
1956
2240
  // both declared inside the try, so neither is in scope where the route-error
1957
2241
  // fence is set — without these it would store a fence it can say nothing about.
@@ -2167,6 +2451,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2167
2451
  // see a free target and both deliver.
2168
2452
  if (!guard.tryClaim(key, turnId)) return refuseTurn('native_turn_in_progress')
2169
2453
  claimedKey = key
2454
+ // 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 })
2170
2456
 
2171
2457
  // Plan 4.3 step 6. The attach-time verdict is minutes old by now; a desktop
2172
2458
  // session started in the gap is exactly the residual risk option B leaves
@@ -2212,6 +2498,14 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2212
2498
  if (!pinned?.binding) return refuseTurn(registryRefusal(pinned?.reason))
2213
2499
  pinnedBindingId = bindingId
2214
2500
 
2501
+ // 6.53.0: a cancel that landed during the gate or the head read. Before the live hop,
2502
+ // which cannot be recalled once it writes into the open session. Nothing was sent;
2503
+ // not retryable, because the person asked for this turn to stop.
2504
+ if (cancelController.signal.aborted) {
2505
+ console.log(`[agent-session-bindings] turn cancelled before delivery provider=${binding.provider} turnId=${turnId} bindingId=${bindingId}`)
2506
+ return refuseTurn('turn_cancelled', { retryable: false })
2507
+ }
2508
+
2215
2509
  // 6.49.0: LIVE FIRST. If the session's own process is running -- a Desktop tab,
2216
2510
  // a `claude` in iTerm, the same registry record either way -- the turn goes into
2217
2511
  // it and paints where the user left the window, instead of into a resume child
@@ -2225,6 +2519,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2225
2519
  // on its own clock; the next interactive turn reads that as a change and asks,
2226
2520
  // which is the conservative side. Queued turns attach fresh and never notice.
2227
2521
  if (typeof deps.deliverLiveTurn === 'function' && binding.provider === 'claude') {
2522
+ const liveEntry = inFlight.get(key)
2523
+ if (liveEntry?.turnId === turnId) liveEntry.cancellable = false
2228
2524
  let live: { ok: boolean; reason: string; verifiedBy?: string | null; pid?: number | null } | null = null
2229
2525
  try {
2230
2526
  live = await deps.deliverLiveTurn({
@@ -2260,6 +2556,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2260
2556
  // Something may be in the session. Hold, never spawn over it.
2261
2557
  return refuseTurn('live_unverified', { retryable: true, deliveryState: 'unknown' })
2262
2558
  }
2559
+ // Every remaining result is documented as "nothing reached a session";
2560
+ // the abortable child path is safe to expose again before it starts.
2561
+ if (liveEntry?.turnId === turnId) liveEntry.cancellable = true
2263
2562
  // disabled / not_claude / no_record / protocol_unsupported / connect_failed /
2264
2563
  // suspect_expired: nothing reached a session. Spawn as always.
2265
2564
  }
@@ -2272,6 +2571,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2272
2571
  // current turn, or within seconds if the thread is idle (canary in
2273
2572
  // `codex-live-queue.ts`). A thread nobody holds never gets here.
2274
2573
  if (binding.provider === 'codex' && foreignHolder && typeof deps.deliverCodexLiveTurn === 'function') {
2574
+ const liveEntry = inFlight.get(key)
2575
+ if (liveEntry?.turnId === turnId) liveEntry.cancellable = false
2275
2576
  let live: { ok: boolean; reason: string; verifiedBy?: string | null; queuedId?: string | null } | null = null
2276
2577
  try {
2277
2578
  live = await deps.deliverCodexLiveTurn({
@@ -2300,6 +2601,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2300
2601
  })
2301
2602
  return
2302
2603
  }
2604
+ // The operator switched this off: nothing was queued, so the abortable
2605
+ // 6.50 child path is cancellable again before it starts.
2606
+ if (live?.reason === 'disabled' && liveEntry?.turnId === turnId) liveEntry.cancellable = true
2303
2607
  // The operator switched this off: the 6.50 path, unchanged, whatever it does.
2304
2608
  if (live?.reason !== 'disabled') {
2305
2609
  // A throw, a timeout, or a confirmation we could not read: a row MAY be in the
@@ -2370,6 +2674,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2370
2674
  sourceFingerprint: binding.sourceFingerprint,
2371
2675
  expectedNativeHead: head.raw,
2372
2676
  prompt,
2677
+ abortSignal: cancelController.signal,
2373
2678
  onSpawn: (pid: number): boolean => {
2374
2679
  // THE SELF-RECURSION ORDER. The child registers itself against the id
2375
2680
  // we are targeting, so unless it is in the ledger the next occupancy
@@ -2424,12 +2729,27 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2424
2729
  stderrClass: typeof r.stderrClass === 'string' ? r.stderrClass : undefined,
2425
2730
  durationMs: typeof r.durationMs === 'number' ? r.durationMs : undefined,
2426
2731
  }
2427
- delivery = classifyDelivery(result)
2732
+ delivery = classifyDelivery(result, cancelController.signal.aborted)
2428
2733
  } catch (error) {
2429
2734
  console.error(`[agent-session-bindings] adapter threw: ${error instanceof Error ? error.message : error}`)
2430
2735
  delivery = { kind: 'ambiguous' }
2431
2736
  }
2432
2737
 
2738
+ // 6.53.0: CANCELLED, BEFORE THE FENCE. An aborted child exits on a signal, which
2739
+ // without this branch is `ambiguous` and fences the thread (plan validation B2):
2740
+ // the person who pressed Cancel would find the thread they stopped locked until
2741
+ // someone releases a fence. A cancel is not an unknown. The turn is ledgered as
2742
+ // refused, `turn_cancelled`, not retryable, which every app since 6.9.4xx settles.
2743
+ if (delivery.kind === 'cancelled') {
2744
+ // The head moved if the child wrote anything before the stop. Acknowledge it the
2745
+ // way a completed turn does, so the next Continue on this binding is not refused
2746
+ // `native_thread_changed` for the run the person just cancelled.
2747
+ const reread = await readHead(binding.provider, binding.nativeThreadId)
2748
+ if (reread !== null) guard.acknowledgeHead(bindingId, reread.digest)
2749
+ console.log(`[agent-session-bindings] turn cancelled provider=${binding.provider} turnId=${turnId} bindingId=${bindingId} detail=${adapterEvidence.adapterDetail ?? 'none'} childReaped=${adapterEvidence.childReaped ?? 'unknown'} durationMs=${adapterEvidence.durationMs ?? 'unknown'} spawnCount=${recordedPids.length}`)
2750
+ return refuseTurn('turn_cancelled', { retryable: false, deliveryState: 'cancelled' })
2751
+ }
2752
+
2433
2753
  if (delivery.kind === 'aborted') return refuseTurn('provider_never_opened')
2434
2754
 
2435
2755
  if (delivery.kind === 'ambiguous') {
@@ -2536,6 +2856,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2536
2856
  console.error(`[agent-session-bindings] unpin failed: ${error instanceof Error ? error.message : error}`)
2537
2857
  }
2538
2858
  }
2859
+ // By turnId: only this turn's own entry, never a successor's.
2860
+ if (claimedKey !== null && inFlight.get(claimedKey)?.turnId === turnId) inFlight.delete(claimedKey)
2539
2861
  if (claimedKey !== null) guard.release(claimedKey, turnId)
2540
2862
  }
2541
2863
  })
@@ -80,7 +80,8 @@ import {
80
80
  threadAttachCapability,
81
81
  threadAttachHealthFields,
82
82
  } from '../lib/thread-attach-capability.js'
83
- import { sessionHooksHealthFields } from '../lib/session-hooks-runtime.js'
83
+ import { cachedHookStatus, sessionHooksEnabled, sessionHooksHealthFields } from '../lib/session-hooks-runtime.js'
84
+ import { sessionCancelFeature } from '../lib/session-cancel.js'
84
85
  import { permissionBrokerHealthFields, sessionQuestionsCapability } from '../lib/permission-broker.js'
85
86
  import { continueLiveEnabled, liveDeliveryStats } from '../lib/session-peer-inbox-deps.js'
86
87
  import { codexLiveStats } from '../lib/codex-live-queue-deps.js'
@@ -293,6 +294,10 @@ healthRouter.get('/health', async (_req, res) => {
293
294
  // This is a pure env read, so it costs nothing on a health poll, and health is
294
295
  // what every other toggle on that screen already reads.
295
296
  claudeSessions: claudeSessionsEnabled(),
297
+ // 6.53.0: cancel a session run. `cosTurn` always; `deskClaude` only once the hooks are
298
+ // applied and the 6.53 hook is installed (Install hooks in COS Control after updating).
299
+ // The status read is the same cached one `sessionHooks` below reports.
300
+ sessionCancel: sessionCancelFeature(sessionHooksEnabled(), cachedHookStatus().installed),
296
301
  }
297
302
  const voice = {
298
303
  available: keyStatus.hasKey || tts_local.ready,
@@ -32,6 +32,8 @@ export interface ThreadTurnQueueDeps {
32
32
  turnOpen?: (provider: string, threadId: string) => boolean
33
33
  /** The 30s transcript clock, as a backstop. */
34
34
  activity: (provider: string, threadId: string) => 'working' | 'idle' | 'unknown'
35
+ /** 6.53.0, optional: until when a cancel holds this thread's parked turns (`cancelHoldUntil`). */
36
+ cancelHoldUntil?: (provider: string, threadId: string) => number | null
35
37
  /**
36
38
  * Deliver one turn. Production wires this to a loopback attach + turn.
37
39
  *
@@ -85,6 +87,11 @@ function safeTurnOpen(deps: ThreadTurnQueueDeps, provider: string, threadId: str
85
87
  try { return deps.turnOpen!(provider, threadId) } catch { return undefined }
86
88
  }
87
89
 
90
+ /** A throwing cancel read is no hold: the gate still decides every delivery. */
91
+ function safeCancelHold(deps: ThreadTurnQueueDeps, provider: string, threadId: string): number | null {
92
+ try { return deps.cancelHoldUntil ? deps.cancelHoldUntil(provider, threadId) : null } catch { return null }
93
+ }
94
+
88
95
  function isRetryableDelivery(outcome: { reason?: string; serverRetryable?: boolean }): boolean {
89
96
  // The turn route publishes its own verdict; it outranks our inference either way.
90
97
  if (outcome.serverRetryable === false) return false
@@ -134,6 +141,7 @@ export async function drainThread(
134
141
  turnOpen: deps.turnOpen ? safeTurnOpen(deps, provider, threadId) : undefined,
135
142
  activity: deps.activity(provider, threadId),
136
143
  reason: gate.reason,
144
+ cancelHoldUntil: safeCancelHold(deps, provider, threadId),
137
145
  }
138
146
  const decision = drainDecision(turn, seen, deps.now())
139
147
 
@@ -167,6 +175,14 @@ export async function drainThread(
167
175
  turn.status = 'delivered'
168
176
  turn.settledAt = deps.now()
169
177
  delivered += 1
178
+ } else if (outcome.reason === 'turn_cancelled') {
179
+ // 6.53.0: the person cancelled THIS turn while it was being admitted (the turn route
180
+ // refuses it before the live hop). It is the run they stopped, so it is settled as
181
+ // cancelled here, never put back to wait and sent again after the hold.
182
+ turn.status = 'cancelled'
183
+ turn.reason = 'turn_cancelled'
184
+ turn.settledAt = deps.now()
185
+ retired += 1
170
186
  } else if (isRetryableDelivery(outcome)) {
171
187
  // A GATE REFUSAL IS NOT A FAILED DELIVERY, so it must not spend the ceiling --
172
188
  // measured cost of getting this wrong: three of the user's turns retired in about