@gotcos/glasses-server 6.52.3 → 6.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ /** 6.53.0: stopped by a cancel this route requested. Never fenced. */
1200
+ | { kind: 'cancelled' }
1127
1201
 
1128
1202
  /**
1129
1203
  * Read an adapter result without believing anything it did not say.
@@ -1131,9 +1205,21 @@ 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 } = result as { ok?: unknown; reason?: unknown; delivery?: unknown }
1218
+ if (ok === false && reason === 'cancelled'
1219
+ && (delivery === 'cancelled' || delivery === 'not_attempted' || delivery === 'aborted')) {
1220
+ return { kind: 'cancelled' }
1221
+ }
1222
+ }
1137
1223
  const status = (result as { status?: unknown }).status
1138
1224
  if (status === 'completed') {
1139
1225
  const after = (result as { nativeRevisionAfter?: unknown }).nativeRevisionAfter
@@ -1402,6 +1488,61 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1402
1488
  : DEFAULT_MAX_PROMPT_CHARS
1403
1489
  const mintId = typeof deps?.newId === 'function' ? deps.newId : () => randomUUID()
1404
1490
 
1491
+ // ------------------------------------------------------------ in flight (6.53.0)
1492
+ //
1493
+ // Every COS turn this router is running, by target, from the moment its claim is taken
1494
+ // until the `finally` that releases the claim. It sits next to `TargetGuard.claims` and
1495
+ // has the same lifetime, so "a COS turn is in flight" here and "the target is claimed"
1496
+ // there cannot disagree. The controller is the ONLY way a cancel reaches the child:
1497
+ // never a stored pid, which could have been recycled by the time anyone signals it.
1498
+ // Entries are deleted by turnId, so a late unwind cannot delete its successor's entry.
1499
+ const inFlight = new Map<string, InFlightTurn>()
1500
+ const cancelDeps: CancelDeps = deps?.cancel ?? {}
1501
+ /** A throwing or absent probe reads as the cautious answer, never as a thrown route. */
1502
+ const cancelProbe = <T>(read: (() => T) | undefined, fallback: T): T => {
1503
+ if (typeof read !== 'function') return fallback
1504
+ try {
1505
+ return read()
1506
+ } catch (error) {
1507
+ console.error(`[agent-session-bindings] cancel probe threw: ${error instanceof Error ? error.message : error}`)
1508
+ return fallback
1509
+ }
1510
+ }
1511
+ /**
1512
+ * The facts `cancelTargetFor` decides on, for one thread. `verdictReason` is the
1513
+ * occupancy reason when the caller already holds a verdict (attachability) or null.
1514
+ * For Claude, a wired `deskRunning` is the authority (the hooks and the registry know a
1515
+ * desk turn precisely; a transcript written 20 s ago does not); without it, and for
1516
+ * Codex and Cursor, "a foreign writer is working" is the occupancy verdict's own
1517
+ * `native_thread_working`.
1518
+ */
1519
+ const cancelFactsFor = (provider: string, threadId: string, verdictReason: string | null): CancelFacts => {
1520
+ const entry = inFlight.get(targetKey(provider, threadId))
1521
+ const cosTurnInFlight = entry?.cancellable === true
1522
+ let runningOutsideCos = false
1523
+ // A live hand-off that has started is still ours, but cannot honestly be
1524
+ // cancelled: do not misclassify the same run as a desk run while it commits.
1525
+ if (!entry) {
1526
+ runningOutsideCos = provider === 'claude' && typeof cancelDeps.deskRunning === 'function'
1527
+ ? cancelProbe(() => cancelDeps.deskRunning!(threadId) === true, false)
1528
+ : verdictReason === 'native_thread_working'
1529
+ }
1530
+ return {
1531
+ provider,
1532
+ cosTurnInFlight,
1533
+ runningOutsideCos,
1534
+ hooksEnabled: cancelProbe(() => cancelDeps.hooksEnabled?.() === true, false),
1535
+ hooksReady: cancelProbe(() => cancelDeps.hooksReady?.() === true, false),
1536
+ }
1537
+ }
1538
+ /** The attachability body's `cancel`, for a thread id the caller has NOT validated. */
1539
+ const cancelFieldFor = (provider: string, threadId: string, verdictReason: string | null): CancelTarget | null => {
1540
+ if (!isBindableProvider(provider) || !isValidNativeThreadId(threadId)) return null
1541
+ return cancelTargetFor(cancelFactsFor(provider, threadId, verdictReason))
1542
+ }
1543
+ /** Idempotency for POST .../cancel: the answer each (thread, clientCancelId) got. */
1544
+ const cancelReplies = new Map<string, { status: number; body: Record<string, unknown>; at: number }>()
1545
+
1405
1546
  /**
1406
1547
  * One owner for "is this thread free right now", shared by the probe and both
1407
1548
  * writes, including its own try/catch and the self-contradiction re-check.
@@ -1535,15 +1676,24 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1535
1676
  // fail. It also keeps the surface self-consistent: reporting `attachable: true`
1536
1677
  // while the attach route is unrouted would leave a client unable to tell
1537
1678
  // whether the thread is free or the feature is off.
1679
+ // 6.53.0: every answer below also says what a cancel would do (`cancelTargetFor`, the
1680
+ // same function the cancel route decides with). Null means no run to cancel, and the
1681
+ // lens then shows no row. Computed on the disabled paths too: a desk run can be
1682
+ // cancelled whether or not COS may write into the thread.
1683
+ const provider = String(req.params.provider ?? '')
1684
+ const threadId = String(req.params.threadId ?? '')
1685
+ const withCancel = (body: AttachabilityBody): AttachabilityWithCancel =>
1686
+ ({ ...body, cancel: cancelFieldFor(provider, threadId, body.reason) })
1687
+
1538
1688
  if (!attachEnabled) {
1539
- res.json(projectAttachability({ attachable: false, owners: [], reason: 'attach_disabled' }))
1689
+ res.json(withCancel(projectAttachability({ attachable: false, owners: [], reason: 'attach_disabled' })))
1540
1690
  return
1541
1691
  }
1542
1692
 
1543
1693
  if (!canDetect) {
1544
1694
  // The mechanism does not exist on this install. Distinct from "it ran and
1545
1695
  // found nothing" by design (plan 4.3 wants the reason nameable).
1546
- res.json(projectAttachability({ attachable: false, owners: [], reason: 'detector_unavailable' }))
1696
+ res.json(withCancel(projectAttachability({ attachable: false, owners: [], reason: 'detector_unavailable' })))
1547
1697
  return
1548
1698
  }
1549
1699
 
@@ -1561,7 +1711,133 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1561
1711
  // truncated-id hole opened. The tests pin the ordering behaviorally instead:
1562
1712
  // probes that throw on every call still return `unsupported_provider` /
1563
1713
  // `invalid_thread_id`, which is only possible if nothing was probed.
1564
- res.json(runOccupancy(String(req.params.provider ?? ''), String(req.params.threadId ?? '')))
1714
+ res.json(withCancel(runOccupancy(provider, threadId)))
1715
+ })
1716
+
1717
+ // ------------------------------------------------------------------ cancel
1718
+ //
1719
+ // POST /api/agent-sessions/:provider/:threadId/cancel { clientCancelId }
1720
+ //
1721
+ // 6.53.0. Miles, 2026-09-21: "If there's a current run that's going, it essentially
1722
+ // clicks the cancel button ... similar to when they're at the desktop."
1723
+ //
1724
+ // REGISTERED UNCONDITIONALLY, not behind `attachEnabled`: a desk Claude run can be
1725
+ // stopped whether or not COS may write into the thread, and a 404 here is how the app
1726
+ // recognises a server older than 6.53 ("Update the server to cancel runs").
1727
+ //
1728
+ // WHAT IT CAN DO is decided by `cancelTargetFor`, the function the attachability
1729
+ // `cancel` field uses, so the row the lens showed and the answer to the tap agree:
1730
+ // cos_turn 202 {target, state: 'stopping', turnId, queuedHeld, queuedHoldMs}: our own child's
1731
+ // controller is aborted, and the turn route records `turn_cancelled`.
1732
+ // desk_run 202 {target, effective: 'next_tool_call', queuedHeld, queuedHoldMs}: a halt marker
1733
+ // the hook reads before the session's next tool call, and any permission
1734
+ // prompt the broker holds for that session is denied with interrupt.
1735
+ // refusals 409 {reason, reasonCopy}: not_running, cancel_unsupported (Codex app,
1736
+ // Cursor), hooks_outdated (Install hooks), hooks_disabled.
1737
+ //
1738
+ // `queuedHoldMs` is how long the parked turns wait (CANCEL_QUEUE_HOLD_MS), so a client
1739
+ // never hard-codes the two minutes.
1740
+ //
1741
+ // IDEMPOTENT PER clientCancelId: the same tap retried gets the same status and body
1742
+ // (plus `replayed: true`), and does not arm or abort anything twice.
1743
+ //
1744
+ // The body carries no path, no pid and no native id beyond the one in the URL.
1745
+ const CANCEL_REPLY_TTL_MS = 10 * 60_000
1746
+ const MAX_CANCEL_REPLIES = 256
1747
+ router.post('/agent-sessions/:provider/:threadId/cancel', (req, res) => {
1748
+ res.set('Cache-Control', 'private, no-store')
1749
+ const provider = String(req.params.provider ?? '')
1750
+ // Lower-cased BEFORE validation: it names a file and a map key, and the hook
1751
+ // lower-cases the id it reads, so any other spelling could never match.
1752
+ const threadId = String(req.params.threadId ?? '').toLowerCase()
1753
+ const body = plainBody(req)
1754
+ const clientCancelId = body?.clientCancelId
1755
+ if (!isBindableProvider(provider) || !isValidNativeThreadId(threadId)
1756
+ || typeof clientCancelId !== 'string' || !CLIENT_CANCEL_ID_RE.test(clientCancelId)) {
1757
+ res.status(400).json({ cancelled: false, reason: 'invalid_request', reasonCopy: cancelRefusalCopy('invalid_request', provider) })
1758
+ return
1759
+ }
1760
+
1761
+ const now = readNow() ?? Date.now()
1762
+ for (const [k, v] of cancelReplies) if (now - v.at > CANCEL_REPLY_TTL_MS) cancelReplies.delete(k)
1763
+ const replayKey = `${provider}:${threadId}:${clientCancelId}`
1764
+ const replay = cancelReplies.get(replayKey)
1765
+ if (replay) {
1766
+ res.status(replay.status).json({ ...replay.body, replayed: true })
1767
+ return
1768
+ }
1769
+
1770
+ const answer = (status: number, target: CancelTarget | null, outcome: 'accepted' | CancelRefusal, payload: Record<string, unknown>): void => {
1771
+ cancelReplies.set(replayKey, { status, body: payload, at: now })
1772
+ while (cancelReplies.size > MAX_CANCEL_REPLIES) {
1773
+ const oldest = cancelReplies.keys().next()
1774
+ if (oldest.done) break
1775
+ cancelReplies.delete(oldest.value)
1776
+ }
1777
+ cancelProbe(() => cancelDeps.ledger?.({ at: new Date(now).toISOString(), provider, threadId, target, outcome, clientCancelId }), undefined)
1778
+ console.log(`[agent-session-bindings] cancel provider=${provider} target=${target ?? 'none'} outcome=${outcome} status=${status} clientCancelId=${clientCancelId}`)
1779
+ res.status(status).json(payload)
1780
+ }
1781
+ const refuseCancel = (target: CancelTarget | null, reason: CancelRefusal, status = 409): void =>
1782
+ answer(status, target, reason, {
1783
+ cancelled: false,
1784
+ reason,
1785
+ ...(reason === 'cancel_unsupported' ? { provider } : {}),
1786
+ reasonCopy: cancelRefusalCopy(reason, provider),
1787
+ })
1788
+
1789
+ // The occupancy verdict is read only when the decision needs it: never for a COS turn
1790
+ // (the map knows) and never for Claude with a desk probe wired (the hooks know).
1791
+ const key = targetKey(provider, threadId)
1792
+ const activeEntry = inFlight.get(key)
1793
+ if (activeEntry && activeEntry.cancellable !== true) {
1794
+ return refuseCancel(null, 'cancel_failed')
1795
+ }
1796
+ const needsVerdict = !inFlight.has(key) && !(provider === 'claude' && typeof cancelDeps.deskRunning === 'function')
1797
+ const verdictReason = needsVerdict && canDetect ? projectAttachability(detectOccupancy(provider, threadId)).reason : null
1798
+ const target = cancelTargetFor(cancelFactsFor(provider, threadId, verdictReason))
1799
+ const queuedHeld = (): number => {
1800
+ const n = cancelProbe(() => cancelDeps.queuedWaiting?.(provider, threadId) ?? 0, 0)
1801
+ return Number.isInteger(n) && n > 0 ? n : 0
1802
+ }
1803
+
1804
+ if (target === 'cos_turn') {
1805
+ const entry = inFlight.get(key)
1806
+ if (!entry) return refuseCancel(null, 'not_running')
1807
+ // The adapter stops the process group (SIGTERM, then SIGKILL after 5 s) and the turn
1808
+ // route records the outcome; this answer does not wait for either.
1809
+ entry.controller.abort()
1810
+ cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now), undefined)
1811
+ return answer(202, target, 'accepted', {
1812
+ cancelled: true,
1813
+ target,
1814
+ state: 'stopping',
1815
+ turnId: entry.turnId,
1816
+ queuedHeld: queuedHeld(),
1817
+ queuedHoldMs: CANCEL_QUEUE_HOLD_MS,
1818
+ })
1819
+ }
1820
+ if (target === 'desk_run') {
1821
+ const armed = cancelProbe(() => cancelDeps.writeHalt?.(threadId, { at: now, clientCancelId }) === true, false)
1822
+ // Not armed means nothing will stop the run: never report it accepted.
1823
+ if (!armed) return refuseCancel(target, 'cancel_failed', 500)
1824
+ // The away-from-desk case: a prompt the broker is holding would otherwise keep the
1825
+ // run waiting for its answer, and then show the Mac's own dialog. Deny it, with
1826
+ // interrupt, so the run ends now rather than at a tool that never comes.
1827
+ const settledPermissions = cancelProbe(() => cancelDeps.settlePermissions?.(threadId) ?? 0, 0)
1828
+ cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now), undefined)
1829
+ return answer(202, target, 'accepted', {
1830
+ cancelled: true,
1831
+ target,
1832
+ effective: 'next_tool_call',
1833
+ queuedHeld: queuedHeld(),
1834
+ queuedHoldMs: CANCEL_QUEUE_HOLD_MS,
1835
+ settledPermissions: Number.isInteger(settledPermissions) && settledPermissions > 0 ? settledPermissions : 0,
1836
+ })
1837
+ }
1838
+ if (target === 'unsupported') return refuseCancel(target, 'cancel_unsupported')
1839
+ if (target === 'hooks_outdated' || target === 'hooks_disabled') return refuseCancel(target, target)
1840
+ return refuseCancel(null, 'not_running')
1565
1841
  })
1566
1842
 
1567
1843
  router.get('/agent-sessions/bindings', (_req, res) => {
@@ -1952,6 +2228,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1952
2228
  `[agent-session-bindings] turn timing turnId=${turnId} outcome=${outcome} total=${Number((process.hrtime.bigint() - stageClock) / 1_000_000n)}ms ${stages.join(' ')}`
1953
2229
  /** The target we hold a claim on, released in the finally. */
1954
2230
  let claimedKey: string | null = null
2231
+ /** 6.53.0: this turn's cancel. Registered in `inFlight` at the claim, deleted in the finally. */
2232
+ const cancelController = new AbortController()
1955
2233
  // Hoisted so the CATCH site can fence with evidence. `binding` and `head` are
1956
2234
  // both declared inside the try, so neither is in scope where the route-error
1957
2235
  // fence is set — without these it would store a fence it can say nothing about.
@@ -2167,6 +2445,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2167
2445
  // see a free target and both deliver.
2168
2446
  if (!guard.tryClaim(key, turnId)) return refuseTurn('native_turn_in_progress')
2169
2447
  claimedKey = key
2448
+ // 6.53.0: cancellable from here, with the claim. Same key, same lifetime.
2449
+ inFlight.set(key, { turnId, bindingId, clientTurnId, controller: cancelController, startedAt: now, cancellable: true })
2170
2450
 
2171
2451
  // Plan 4.3 step 6. The attach-time verdict is minutes old by now; a desktop
2172
2452
  // session started in the gap is exactly the residual risk option B leaves
@@ -2212,6 +2492,14 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2212
2492
  if (!pinned?.binding) return refuseTurn(registryRefusal(pinned?.reason))
2213
2493
  pinnedBindingId = bindingId
2214
2494
 
2495
+ // 6.53.0: a cancel that landed during the gate or the head read. Before the live hop,
2496
+ // which cannot be recalled once it writes into the open session. Nothing was sent;
2497
+ // not retryable, because the person asked for this turn to stop.
2498
+ if (cancelController.signal.aborted) {
2499
+ console.log(`[agent-session-bindings] turn cancelled before delivery provider=${binding.provider} turnId=${turnId} bindingId=${bindingId}`)
2500
+ return refuseTurn('turn_cancelled', { retryable: false })
2501
+ }
2502
+
2215
2503
  // 6.49.0: LIVE FIRST. If the session's own process is running -- a Desktop tab,
2216
2504
  // a `claude` in iTerm, the same registry record either way -- the turn goes into
2217
2505
  // it and paints where the user left the window, instead of into a resume child
@@ -2225,6 +2513,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2225
2513
  // on its own clock; the next interactive turn reads that as a change and asks,
2226
2514
  // which is the conservative side. Queued turns attach fresh and never notice.
2227
2515
  if (typeof deps.deliverLiveTurn === 'function' && binding.provider === 'claude') {
2516
+ const liveEntry = inFlight.get(key)
2517
+ if (liveEntry?.turnId === turnId) liveEntry.cancellable = false
2228
2518
  let live: { ok: boolean; reason: string; verifiedBy?: string | null; pid?: number | null } | null = null
2229
2519
  try {
2230
2520
  live = await deps.deliverLiveTurn({
@@ -2260,6 +2550,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2260
2550
  // Something may be in the session. Hold, never spawn over it.
2261
2551
  return refuseTurn('live_unverified', { retryable: true, deliveryState: 'unknown' })
2262
2552
  }
2553
+ // Every remaining result is documented as "nothing reached a session";
2554
+ // the abortable child path is safe to expose again before it starts.
2555
+ if (liveEntry?.turnId === turnId) liveEntry.cancellable = true
2263
2556
  // disabled / not_claude / no_record / protocol_unsupported / connect_failed /
2264
2557
  // suspect_expired: nothing reached a session. Spawn as always.
2265
2558
  }
@@ -2272,6 +2565,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2272
2565
  // current turn, or within seconds if the thread is idle (canary in
2273
2566
  // `codex-live-queue.ts`). A thread nobody holds never gets here.
2274
2567
  if (binding.provider === 'codex' && foreignHolder && typeof deps.deliverCodexLiveTurn === 'function') {
2568
+ const liveEntry = inFlight.get(key)
2569
+ if (liveEntry?.turnId === turnId) liveEntry.cancellable = false
2275
2570
  let live: { ok: boolean; reason: string; verifiedBy?: string | null; queuedId?: string | null } | null = null
2276
2571
  try {
2277
2572
  live = await deps.deliverCodexLiveTurn({
@@ -2300,6 +2595,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2300
2595
  })
2301
2596
  return
2302
2597
  }
2598
+ // The operator switched this off: nothing was queued, so the abortable
2599
+ // 6.50 child path is cancellable again before it starts.
2600
+ if (live?.reason === 'disabled' && liveEntry?.turnId === turnId) liveEntry.cancellable = true
2303
2601
  // The operator switched this off: the 6.50 path, unchanged, whatever it does.
2304
2602
  if (live?.reason !== 'disabled') {
2305
2603
  // A throw, a timeout, or a confirmation we could not read: a row MAY be in the
@@ -2370,6 +2668,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2370
2668
  sourceFingerprint: binding.sourceFingerprint,
2371
2669
  expectedNativeHead: head.raw,
2372
2670
  prompt,
2671
+ abortSignal: cancelController.signal,
2373
2672
  onSpawn: (pid: number): boolean => {
2374
2673
  // THE SELF-RECURSION ORDER. The child registers itself against the id
2375
2674
  // we are targeting, so unless it is in the ledger the next occupancy
@@ -2424,12 +2723,27 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2424
2723
  stderrClass: typeof r.stderrClass === 'string' ? r.stderrClass : undefined,
2425
2724
  durationMs: typeof r.durationMs === 'number' ? r.durationMs : undefined,
2426
2725
  }
2427
- delivery = classifyDelivery(result)
2726
+ delivery = classifyDelivery(result, cancelController.signal.aborted)
2428
2727
  } catch (error) {
2429
2728
  console.error(`[agent-session-bindings] adapter threw: ${error instanceof Error ? error.message : error}`)
2430
2729
  delivery = { kind: 'ambiguous' }
2431
2730
  }
2432
2731
 
2732
+ // 6.53.0: CANCELLED, BEFORE THE FENCE. An aborted child exits on a signal, which
2733
+ // without this branch is `ambiguous` and fences the thread (plan validation B2):
2734
+ // the person who pressed Cancel would find the thread they stopped locked until
2735
+ // someone releases a fence. A cancel is not an unknown. The turn is ledgered as
2736
+ // refused, `turn_cancelled`, not retryable, which every app since 6.9.4xx settles.
2737
+ if (delivery.kind === 'cancelled') {
2738
+ // The head moved if the child wrote anything before the stop. Acknowledge it the
2739
+ // way a completed turn does, so the next Continue on this binding is not refused
2740
+ // `native_thread_changed` for the run the person just cancelled.
2741
+ const reread = await readHead(binding.provider, binding.nativeThreadId)
2742
+ if (reread !== null) guard.acknowledgeHead(bindingId, reread.digest)
2743
+ 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}`)
2744
+ return refuseTurn('turn_cancelled', { retryable: false, deliveryState: 'cancelled' })
2745
+ }
2746
+
2433
2747
  if (delivery.kind === 'aborted') return refuseTurn('provider_never_opened')
2434
2748
 
2435
2749
  if (delivery.kind === 'ambiguous') {
@@ -2536,6 +2850,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2536
2850
  console.error(`[agent-session-bindings] unpin failed: ${error instanceof Error ? error.message : error}`)
2537
2851
  }
2538
2852
  }
2853
+ // By turnId: only this turn's own entry, never a successor's.
2854
+ if (claimedKey !== null && inFlight.get(claimedKey)?.turnId === turnId) inFlight.delete(claimedKey)
2539
2855
  if (claimedKey !== null) guard.release(claimedKey, turnId)
2540
2856
  }
2541
2857
  })
@@ -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