@gotcos/glasses-server 6.61.7 → 6.62.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.
Files changed (69) hide show
  1. package/.env.example +6 -0
  2. package/CHANGELOG.md +34 -0
  3. package/README.md +5 -1
  4. package/bin/cli.cjs +50 -3
  5. package/bin/hooks/cos-cursor-observer.cjs +11 -4
  6. package/bin/hooks/cos-session-hook +46 -11
  7. package/docs/provider-parity.md +43 -0
  8. package/managed-runtime-contract.json +1 -0
  9. package/package.json +4 -2
  10. package/server/index.ts +135 -16
  11. package/server/lib/agent-session-binding-store.ts +9 -3
  12. package/server/lib/agent-session-store.ts +7 -4
  13. package/server/lib/attached-provider-adapter.ts +158 -45
  14. package/server/lib/banned-permission-args.ts +42 -1
  15. package/server/lib/claude-bridge.ts +17 -8
  16. package/server/lib/claude-hooks-installer.ts +22 -2
  17. package/server/lib/codex-bridge.ts +17 -2
  18. package/server/lib/codex-hooks-installer.ts +538 -0
  19. package/server/lib/codex-live-queue.ts +1 -1
  20. package/server/lib/codex-model-catalog.ts +49 -2
  21. package/server/lib/codex-rollout-state.ts +90 -0
  22. package/server/lib/codex-session-posture.ts +528 -0
  23. package/server/lib/continue-plan.ts +180 -0
  24. package/server/lib/continue-read-health.ts +26 -0
  25. package/server/lib/cursor-bridge.ts +26 -6
  26. package/server/lib/cursor-fork-context.ts +140 -0
  27. package/server/lib/cursor-model-catalog.ts +62 -5
  28. package/server/lib/cursor-observer-installer.ts +48 -7
  29. package/server/lib/cursor-session-model.ts +339 -0
  30. package/server/lib/cursor-spawn-env.ts +210 -0
  31. package/server/lib/fork-thread.ts +143 -25
  32. package/server/lib/health-static-probes.ts +21 -11
  33. package/server/lib/lens-gist-engines.ts +15 -4
  34. package/server/lib/lens-gist.ts +1 -1
  35. package/server/lib/live-cues-cursor.ts +16 -6
  36. package/server/lib/occupancy-probes.ts +45 -1
  37. package/server/lib/permission-broker.ts +24 -9
  38. package/server/lib/provider-actions.ts +149 -0
  39. package/server/lib/provider-failure-text.ts +27 -0
  40. package/server/lib/provider-observe.ts +212 -0
  41. package/server/lib/provider-proof.ts +23 -14
  42. package/server/lib/query-job-runtime.ts +16 -1
  43. package/server/lib/query-job-types.ts +2 -1
  44. package/server/lib/session-act-fields.ts +61 -0
  45. package/server/lib/session-cancel.ts +95 -4
  46. package/server/lib/session-continue-facts-real.ts +74 -0
  47. package/server/lib/session-continue-facts.ts +267 -0
  48. package/server/lib/session-halt.ts +67 -6
  49. package/server/lib/session-hook-events.ts +57 -0
  50. package/server/lib/session-hook-ledger.ts +21 -3
  51. package/server/lib/session-hook-spool.ts +68 -6
  52. package/server/lib/session-hooks-runtime.ts +32 -2
  53. package/server/lib/session-peer-inbox-deps.ts +7 -6
  54. package/server/lib/session-recommendation.ts +3 -2
  55. package/server/lib/session-signal-store.ts +74 -5
  56. package/server/lib/session-stream-events.ts +32 -1
  57. package/server/lib/thread-attach-capability.ts +15 -1
  58. package/server/lib/thread-drain-kick.ts +4 -2
  59. package/server/lib/thread-occupancy.ts +44 -0
  60. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  61. package/server/lib/thread-turn-queue.ts +6 -0
  62. package/server/lib/turn-request.ts +274 -0
  63. package/server/routes/agent-session-bindings.ts +224 -22
  64. package/server/routes/agent-session-stream.ts +26 -7
  65. package/server/routes/agent-sessions.ts +155 -10
  66. package/server/routes/health.ts +27 -2
  67. package/server/routes/session-hooks.ts +56 -5
  68. package/server/routes/thread-turn-queue.ts +37 -9
  69. package/server/scripts/hooks-cli.ts +33 -7
@@ -121,13 +121,16 @@ import {
121
121
  type BindingState,
122
122
  type NativeBinding,
123
123
  } from '../lib/agent-session-binding-store.js'
124
+ import { boundedContinueNote, CONTINUE_NOTE_HEADER, continueNoteAcknowledged, fallbackContinueNote } from '../lib/continue-plan.js'
124
125
  import type { RegistryCheck, RegistryRejection, RegistryResult } from '../lib/agent-session-binding-registry.js'
125
126
  import { recordCosSpawn, releaseCosSpawn } from '../lib/agent-session-ownership-store.js'
126
127
  import { isValidNativeThreadId } from '../lib/native-thread-id.js'
128
+ import { FORK_PROVIDER_FAILURES, type ForkProviderFailure } from '../lib/fork-thread.js'
127
129
  import { PEER_VERIFY_TIMEOUT_MS } from '../lib/session-peer-inbox.js'
128
130
  import {
129
131
  CANCEL_QUEUE_HOLD_MS,
130
132
  CLIENT_CANCEL_ID_RE,
133
+ DESK_RUN_EFFECT_COPY,
131
134
  cancelRefusalCopy,
132
135
  cancelTargetFor,
133
136
  type CancelFacts,
@@ -257,6 +260,11 @@ export interface AttachedTurnRequest {
257
260
  * simply never calls it.
258
261
  */
259
262
  onSpawn: (pid: number) => boolean
263
+ /**
264
+ * 6.62.0 /qa (Q3): the client sent `X-COS-Continue-Note: 1`, i.e. it shows `continue_note`.
265
+ * Only then may a Cursor Continue run Run Everything; otherwise it keeps the 6.61 Ask posture.
266
+ */
267
+ continueNoteAck?: boolean
260
268
  /**
261
269
  * 6.53.0: aborted by POST .../cancel. The adapter checks it before the spawn and
262
270
  * before the prompt write, and stops the child's process group after. Optional so an
@@ -370,6 +378,29 @@ export interface AgentSessionBindingsDeps {
370
378
  */
371
379
  deliverCodexLiveTurn?: (request: { provider: string; sessionId: string; prompt: string; foreignHolder: boolean }) => Promise<{ ok: boolean; reason: string; verifiedBy?: string | null; queuedId?: string | null }>
372
380
 
381
+ /**
382
+ * 6.62.0 (plan 3.6, W8): may a BUSY Codex thread the Codex app holds take a turn through
383
+ * that app's own queue right now? Production: `COS_CODEX_LIVE_QUEUE` is on AND the COS
384
+ * queue for the thread is empty (so a new turn never jumps one already waiting). Absent
385
+ * or anything but `true`: a busy holder refuses `native_thread_working`, as in 6.61.
386
+ */
387
+ busyCodexHop?: (threadId: string) => boolean
388
+
389
+ /**
390
+ * 6.62.0 (plan 3.12): what a Continue on this thread would run with, in 60 characters
391
+ * or less (`session-continue-facts.ts`). Added to the attachability verdict as
392
+ * `continue_note`. Optional; absent, null, slow (past `CONTINUE_NOTE_BUDGET_MS`) or a
393
+ * throw omits the field, and the verdict is unchanged.
394
+ */
395
+ continueNote?: (provider: BindableProvider, threadId: string, options?: { noteAck: boolean }) => Promise<string | null> | string | null
396
+
397
+ /**
398
+ * 6.62.0 /qa (W9): the note when `continueNote` misses its budget, throws, or answers
399
+ * nothing usable, so a Cursor Continue never runs Run Everything unannounced. Defaults to
400
+ * `fallbackContinueNote` with this process's environment.
401
+ */
402
+ continueNoteFallback?: (provider: BindableProvider, options: { noteAck: boolean }) => string | null
403
+
373
404
  /**
374
405
  * The self-recursion ledger. Defaults to the real process-wide one.
375
406
  *
@@ -406,7 +437,7 @@ export interface AgentSessionBindingsDeps {
406
437
  * write a NEW session rather than the one asked for, which for a fork means the
407
438
  * copy silently lands in the wrong project.
408
439
  */
409
- resolveForkWorkspace?: (provider: BindableProvider, nativeThreadId: string) => string | null
440
+ resolveForkWorkspace?: (provider: BindableProvider, nativeThreadId: string) => string | null | Promise<string | null>
410
441
 
411
442
  /**
412
443
  * Where an opaque fork reference is exchanged for the thread it names.
@@ -459,7 +490,7 @@ export interface CancelDeps {
459
490
  * have reached) the open session. Writes the halt marker, and re-arms it once when that
460
491
  * turn's own UserPromptSubmit deletes it (`haltHandedOffTurn`). False: not armed.
461
492
  */
462
- haltHandedOffTurn?: (sessionId: string, marker: { at: number; clientCancelId: string }, turn: { prompt: string; sentAt: number; unverified?: boolean }) => boolean
493
+ haltHandedOffTurn?: (sessionId: string, marker: { at: number; clientCancelId: string }, turn: { prompt: string; sentAt: number; unverified?: boolean; verbatim?: boolean; deferMarker?: boolean }) => boolean
463
494
  /** Append to `data/session-cancel.jsonl`. */
464
495
  ledger?: (row: SessionCancelLedgerRow) => void
465
496
  }
@@ -597,6 +628,13 @@ export type WriteRefusal =
597
628
  | 'fork_failed'
598
629
  | 'fork_source_mutated'
599
630
  | 'fork_orphan_possible'
631
+ // 6.62.1 (2026-10-05): the copy exists and is named, but the provider refused its
632
+ // first turn. Each says WHY, because the next step differs: a smaller source, a
633
+ // reset, or a sign-in. None is retryable: a retry makes another full copy.
634
+ | 'fork_turn_failed'
635
+ | 'fork_context_too_long'
636
+ | 'fork_provider_limit'
637
+ | 'fork_provider_auth'
600
638
 
601
639
  /**
602
640
  * Footer copy for the refusals that are not occupancy reasons.
@@ -680,8 +718,18 @@ export const WRITE_REASON_COPY: Record<Exclude<WriteRefusal, OccupancyReason>, s
680
718
  // touches it.
681
719
  fork_source_mutated:
682
720
  'The original thread changed while COS was copying it. Open the original on your Mac and check it before doing anything else.',
721
+ // 6.62.1: no "untouched" here. This outcome is reached exactly when COS could not
722
+ // read the copy's result, so it cannot vouch for anything after the spawn either.
683
723
  fork_orphan_possible:
684
- 'COS lost track of the copy it was making. Your original is untouched, but a partial copy may exist on your Mac.',
724
+ 'COS did not get an answer from the copy it was making. A copy may exist on your Mac; check your sessions before forking again.',
725
+ fork_turn_failed:
726
+ 'COS made the copy, but the assistant did not answer in it. Open the copy to see why before trying again.',
727
+ fork_context_too_long:
728
+ 'This conversation is too long to continue in a copy: the assistant refused it as too long. Start a new session with the task instead.',
729
+ fork_provider_limit:
730
+ 'The assistant hit its usage limit, so the copy got no answer. Try again after the limit resets, or start a new session with another assistant.',
731
+ fork_provider_auth:
732
+ 'The assistant needs you to sign in again on this Mac before COS can copy this conversation.',
685
733
  }
686
734
 
687
735
  export function writeReasonCopy(reason: WriteRefusal): string {
@@ -814,7 +862,7 @@ export function projectAttachability(verdict: Occupancy): AttachabilityBody {
814
862
  const owners = Array.isArray(verdict?.owners) ? verdict.owners : null
815
863
  // Strictly `=== true`. An idleHolder of 1, 'yes', or {} is a malformed verdict,
816
864
  // and a malformed verdict must not buy an exemption.
817
- const foreignOwnerDeclared = verdict?.idleHolder === true
865
+ const foreignOwnerDeclared = verdict?.idleHolder === true || verdict?.busyHolder === true
818
866
  const sound =
819
867
  verdict?.attachable === true &&
820
868
  verdict.reason === null &&
@@ -1412,9 +1460,23 @@ export class ForkRefStore {
1412
1460
  export type ForkOutcome =
1413
1461
  | { kind: 'created'; newNativeThreadId: string; integrity: 'verified_unchanged' | 'unverified' }
1414
1462
  | { kind: 'mutated' }
1415
- | { kind: 'orphan_possible' }
1463
+ // 6.62.1: the copy is named but its first turn failed; `cause` is the provider's own
1464
+ // reason when its error event said one.
1465
+ | { kind: 'turn_failed'; newNativeThreadId: string; cause: ForkProviderFailure | null }
1466
+ | { kind: 'orphan_possible'; cause?: ForkProviderFailure | null }
1416
1467
  | { kind: 'failed' }
1417
1468
 
1469
+ const forkProviderFailure = (detail: unknown): ForkProviderFailure | null =>
1470
+ typeof detail === 'string' && (FORK_PROVIDER_FAILURES as readonly string[]).includes(detail) ? detail as ForkProviderFailure : null
1471
+
1472
+ /** The refusal reason for a provider-stated cause, or the generic one. */
1473
+ export function forkFailureReason(cause: ForkProviderFailure | null | undefined, generic: WriteRefusal): WriteRefusal {
1474
+ if (cause === 'context_too_long') return 'fork_context_too_long'
1475
+ if (cause === 'provider_limit') return 'fork_provider_limit'
1476
+ if (cause === 'provider_auth') return 'fork_provider_auth'
1477
+ return generic
1478
+ }
1479
+
1418
1480
  /**
1419
1481
  * Read a fork result without believing anything it did not say.
1420
1482
  *
@@ -1427,7 +1489,7 @@ export type ForkOutcome =
1427
1489
  */
1428
1490
  export function classifyFork(result: unknown, sourceNativeThreadId: string): ForkOutcome {
1429
1491
  if (!result || typeof result !== 'object' || Array.isArray(result)) return { kind: 'orphan_possible' }
1430
- const { ok, newNativeThreadId, sourceIntegrity, forkState, reason } = result as Record<string, unknown>
1492
+ const { ok, newNativeThreadId, sourceIntegrity, forkState, reason, detail } = result as Record<string, unknown>
1431
1493
 
1432
1494
  if (ok === true) {
1433
1495
  // The single invariant this route re-checks itself rather than inheriting.
@@ -1449,11 +1511,19 @@ export function classifyFork(result: unknown, sourceNativeThreadId: string): For
1449
1511
  if (reason === 'source_thread_mutated' || sourceIntegrity === 'mutated') return { kind: 'mutated' }
1450
1512
  // Only an explicit "no child was created" earns the clean failure.
1451
1513
  if (forkState === 'none') return { kind: 'failed' }
1514
+ // 6.62.1: a named copy is not an orphan. Same id rules as success: valid, and
1515
+ // never the source (that would be an append reported as a copy).
1516
+ if (isValidNativeThreadId(newNativeThreadId) && newNativeThreadId !== sourceNativeThreadId) {
1517
+ return { kind: 'turn_failed', newNativeThreadId, cause: forkProviderFailure(detail) }
1518
+ }
1519
+ return { kind: 'orphan_possible', cause: forkProviderFailure(detail) }
1452
1520
  }
1453
1521
  return { kind: 'orphan_possible' }
1454
1522
  }
1455
1523
 
1456
1524
  export const FORKED_COPY = 'Copied into a new thread. Your original is untouched.'
1525
+ /** 6.62.1: `unverified` must never be rendered as confirmed untouched (see the success body). */
1526
+ export const FORKED_COPY_UNVERIFIED = 'Copied into a new thread. COS could not re-read your original afterwards to confirm it is unchanged.'
1457
1527
 
1458
1528
  /**
1459
1529
  * Both fingerprints present, bounded, and strings.
@@ -1500,6 +1570,12 @@ export const TURN_SENT_CODEX_QUEUE_COPY = 'Queued in the Codex app. It runs when
1500
1570
  */
1501
1571
  export const LIVE_VERIFY_BUDGET_MS = PEER_VERIFY_TIMEOUT_MS
1502
1572
 
1573
+ /**
1574
+ * 6.62.0: how long the attachability verdict waits for its `continue_note`. The verdict is
1575
+ * a menu-open read; a cold Codex posture scan or a busy Cursor database must not hold it.
1576
+ */
1577
+ export const CONTINUE_NOTE_BUDGET_MS = 1_500
1578
+
1503
1579
  /**
1504
1580
  * The 202 copy. Says QUEUED and not sent, because at this instant the provider has
1505
1581
  * not been spawned — claiming otherwise would be the same silent-success lie the
@@ -1623,6 +1699,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1623
1699
  }
1624
1700
  return {
1625
1701
  provider,
1702
+ threadId,
1626
1703
  cosTurnInFlight,
1627
1704
  runningOutsideCos,
1628
1705
  hooksEnabled: cancelProbe(() => cancelDeps.hooksEnabled?.() === true, false),
@@ -1660,7 +1737,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1660
1737
  provider: string,
1661
1738
  threadId: string,
1662
1739
  reached: 'none' | 'reached' | 'maybe',
1663
- turn: { prompt: string; sentAt: number },
1740
+ turn: { prompt: string; sentAt: number; busy?: boolean },
1664
1741
  ): void => {
1665
1742
  const latch = entry.latched
1666
1743
  if (latch === null) return
@@ -1673,6 +1750,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1673
1750
  if (reached !== 'none') {
1674
1751
  target = cancelTargetFor({
1675
1752
  provider,
1753
+ threadId,
1676
1754
  cosTurnInFlight: false,
1677
1755
  runningOutsideCos: true,
1678
1756
  hooksEnabled: cancelProbe(() => cancelDeps.hooksEnabled?.() === true, false),
@@ -1685,6 +1763,13 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1685
1763
  prompt: turn.prompt,
1686
1764
  sentAt: turn.sentAt,
1687
1765
  unverified: reached === 'maybe',
1766
+ // 6.62.0 (plan 3.6): a Codex turn went into the app's own queue, which hands its
1767
+ // text to the session's UserPromptSubmit as is: matched verbatim, no peer unwrap.
1768
+ ...(provider === 'codex' ? { verbatim: true } : {}),
1769
+ // 6.62.0 /qa (W3): on a BUSY hop the session is still running Miles's OWN desk turn.
1770
+ // A marker written now would stop that turn at its next tool, so only the re-arm is
1771
+ // registered: the marker lands on the delivered turn's own UserPromptSubmit.
1772
+ ...(turn.busy === true ? { deferMarker: true } : {}),
1688
1773
  }) === true, false)
1689
1774
  if (armed) {
1690
1775
  const settledPermissions = cancelProbe(() => cancelDeps.settlePermissions?.(threadId) ?? 0, 0)
@@ -1692,6 +1777,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1692
1777
  cancelled: true,
1693
1778
  target,
1694
1779
  effective: 'next_tool_call',
1780
+ effectCopy: DESK_RUN_EFFECT_COPY,
1695
1781
  ...common,
1696
1782
  settledPermissions: Number.isInteger(settledPermissions) && settledPermissions > 0 ? settledPermissions : 0,
1697
1783
  }
@@ -1743,8 +1829,29 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1743
1829
  return { attachable: false, owners: [], reason: 'probe_failed' }
1744
1830
  }
1745
1831
  }
1832
+ /**
1833
+ * 6.62.0 (plan 3.6, W8): a WORKING Codex holder that is not ours, on a thread the hop may
1834
+ * take, reads attachable through a declared `busyHolder`. Shared by the probe, attach and
1835
+ * turns, so all three agree. Anything less than every condition keeps the verdict as is.
1836
+ */
1837
+ const withBusyCodexHop = (provider: string, threadId: string, detected: Occupancy): Occupancy => {
1838
+ if (provider !== 'codex' || detected.attachable || detected.reason !== 'native_thread_working') return detected
1839
+ if (typeof deps.deliverCodexLiveTurn !== 'function' || typeof deps.busyCodexHop !== 'function') return detected
1840
+ const owners = Array.isArray(detected.owners) ? detected.owners : []
1841
+ if (!owners.some(owner => owner?.selfOwned !== true && owner?.source === 'codex-writer-lock')) return detected
1842
+ let allowed = false
1843
+ try {
1844
+ allowed = deps.busyCodexHop(threadId) === true
1845
+ } catch (error) {
1846
+ console.error(`[agent-session-bindings] busy codex hop check threw: ${error instanceof Error ? error.message : error}`)
1847
+ allowed = false
1848
+ }
1849
+ return allowed ? { attachable: true, owners, reason: null, busyHolder: true } : detected
1850
+ }
1851
+ const routeOccupancy = (provider: string, threadId: string): Occupancy =>
1852
+ withBusyCodexHop(provider, threadId, detectOccupancy(provider, threadId))
1746
1853
  const runOccupancy = (provider: string, threadId: string): AttachabilityBody =>
1747
- projectAttachability(detectOccupancy(provider, threadId))
1854
+ projectAttachability(routeOccupancy(provider, threadId))
1748
1855
 
1749
1856
  const refuseAttach = (res: Response, reason: WriteRefusal): void => {
1750
1857
  res.status(refusalStatus(reason)).json({
@@ -1853,7 +1960,46 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1853
1960
  res.json({ released: true, target, provider: outcome.row.provider })
1854
1961
  })
1855
1962
 
1856
- router.get('/agent-sessions/:provider/:threadId/attachability', (req, res) => {
1963
+ /**
1964
+ * 6.62.0 (plan 3.12): `continue_note` for the verdict, bounded in time and length. 6.62.0
1965
+ * /qa (W9): a read that misses the budget, throws, or answers nothing usable gets the
1966
+ * provider's fixed note instead, never no note (Claude has none either way).
1967
+ */
1968
+ const continueNoteFor = async (provider: string, threadId: string, noteAck: boolean): Promise<{ continue_note?: string }> => {
1969
+ const read = deps.continueNote
1970
+ if (typeof read !== 'function' || !isBindableProvider(provider) || !isValidNativeThreadId(threadId)) return {}
1971
+ const fallback = (): { continue_note?: string } => {
1972
+ let note: string | null = null
1973
+ try {
1974
+ note = deps.continueNoteFallback
1975
+ ? deps.continueNoteFallback(provider, { noteAck })
1976
+ : fallbackContinueNote(provider, { noteAck })
1977
+ } catch {
1978
+ note = null
1979
+ }
1980
+ const bounded = boundedContinueNote(note)
1981
+ return bounded ? { continue_note: bounded } : {}
1982
+ }
1983
+ let timer: ReturnType<typeof setTimeout> | null = null
1984
+ let missed = false
1985
+ try {
1986
+ const note = await Promise.race([
1987
+ Promise.resolve(read(provider, threadId, { noteAck })),
1988
+ new Promise<null>(resolve => { timer = setTimeout(() => { missed = true; resolve(null) }, CONTINUE_NOTE_BUDGET_MS) }),
1989
+ ])
1990
+ const bounded = boundedContinueNote(note)
1991
+ if (bounded) return { continue_note: bounded }
1992
+ if (missed) console.warn(`[agent-session-bindings] continue note missed its ${CONTINUE_NOTE_BUDGET_MS} ms budget provider=${provider}: fixed note sent`)
1993
+ return fallback()
1994
+ } catch (error) {
1995
+ console.error(`[agent-session-bindings] continue note failed: ${error instanceof Error ? error.message : error}`)
1996
+ return fallback()
1997
+ } finally {
1998
+ if (timer) clearTimeout(timer)
1999
+ }
2000
+ }
2001
+
2002
+ router.get('/agent-sessions/:provider/:threadId/attachability', async (req, res) => {
1857
2003
  // An occupancy verdict is a liveness answer with a lifetime of roughly now.
1858
2004
  // A cached `attachable: true` is indistinguishable from a stale one, which is
1859
2005
  // the whole failure this feature exists to prevent.
@@ -1899,7 +2045,17 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1899
2045
  // truncated-id hole opened. The tests pin the ordering behaviorally instead:
1900
2046
  // probes that throw on every call still return `unsupported_provider` /
1901
2047
  // `invalid_thread_id`, which is only possible if nothing was probed.
1902
- res.json(withCancel(runOccupancy(provider, threadId)))
2048
+ // 6.62.0 /qa (Q2): the busy Codex hop makes the verdict attachable, but a desk run IS
2049
+ // working on that thread, so the cancel row reads the RAW reason. Built from the verdict's
2050
+ // reason, the lens hid Cancel run on every busy Codex Desktop thread.
2051
+ const detected = routeOccupancy(provider, threadId)
2052
+ const verdictBody = projectAttachability(detected)
2053
+ const cancelReason = detected.busyHolder === true ? 'native_thread_working' : verdictBody.reason
2054
+ res.json({
2055
+ ...verdictBody,
2056
+ cancel: cancelFieldFor(provider, threadId, cancelReason),
2057
+ ...(await continueNoteFor(provider, threadId, continueNoteAcknowledged(req.headers[CONTINUE_NOTE_HEADER]))),
2058
+ })
1903
2059
  })
1904
2060
 
1905
2061
  // ------------------------------------------------------------------ cancel
@@ -1989,7 +2145,11 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1989
2145
  activeEntry.controller.abort()
1990
2146
  // Noted as what the run becomes if the hop lands it: a desk run (Claude) or one only
1991
2147
  // 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)
2148
+ // 6.62.0: a landed Codex or Cursor hop is a desk run COS can stop when that engine's
2149
+ // hooks can (cancelTargetFor's own rule); Claude keeps its 6.53.3 answer unchanged.
2150
+ const landedAs = provider === 'claude' ? 'desk_run'
2151
+ : cancelTargetFor({ ...cancelFactsFor(provider, threadId, null), cosTurnInFlight: false, runningOutsideCos: true }) === 'desk_run' ? 'desk_run' : 'unsupported'
2152
+ cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now, landedAs), undefined)
1993
2153
  }
1994
2154
  activeEntry.latched.replayKeys.push(replayKey)
1995
2155
  return answer(202, 'cos_turn', 'accepted', {
@@ -2034,6 +2194,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2034
2194
  cancelled: true,
2035
2195
  target,
2036
2196
  effective: 'next_tool_call',
2197
+ // 6.62.0 (plan 3.8): the words for what the tap did, the same for every engine.
2198
+ effectCopy: DESK_RUN_EFFECT_COPY,
2037
2199
  queuedHeld: queuedHeld(),
2038
2200
  queuedHoldMs: CANCEL_QUEUE_HOLD_MS,
2039
2201
  settledPermissions: Number.isInteger(settledPermissions) && settledPermissions > 0 ? settledPermissions : 0,
@@ -2331,7 +2493,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2331
2493
  if (typeof resolveWorkspace !== 'function') return refuseFork('fork_workspace_unresolvable')
2332
2494
  let cwd: string | null = null
2333
2495
  try {
2334
- cwd = resolveWorkspace(providerParam, threadIdParam)
2496
+ // 6.62.0: may answer later (a Cursor IDE composer's folder is read from Cursor's own
2497
+ // store). Still before the claim below, which stays the last synchronous step.
2498
+ cwd = await resolveWorkspace(providerParam, threadIdParam)
2335
2499
  } catch (error) {
2336
2500
  console.error(`[agent-session-bindings] fork workspace resolve threw: ${error instanceof Error ? error.message : error}`)
2337
2501
  cwd = null
@@ -2371,14 +2535,32 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2371
2535
  }
2372
2536
 
2373
2537
  const outcome = classifyFork(raw, threadIdParam)
2538
+ {
2539
+ // 6.62.1: one line per attempt. On 2026-10-05 a fork's real cause existed only
2540
+ // in the copy's transcript; nothing reached the server log. Enums and numbers
2541
+ // only: no prompt, no provider text, no thread id.
2542
+ const r = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
2543
+ console.log(`[agent-session-bindings] fork outcome provider=${providerParam} outcome=${outcome.kind} cause=${forkProviderFailure(r.detail) ?? 'none'} exit=${typeof r.exitCode === 'number' && Number.isFinite(r.exitCode) ? r.exitCode : 'none'} ms=${typeof r.durationMs === 'number' && Number.isFinite(r.durationMs) ? r.durationMs : 'unknown'}`)
2544
+ }
2374
2545
 
2375
2546
  if (outcome.kind === 'mutated') {
2376
2547
  // The original moved. Loudest outcome in the feature, and not retryable:
2377
2548
  // the user needs to look at their own thread before anything else touches it.
2378
2549
  return refuseFork('fork_source_mutated', { orphanPossible: true, retryable: false })
2379
2550
  }
2551
+ if (outcome.kind === 'turn_failed') {
2552
+ // The copy exists and Control can open it (the helper resolves the digest to the
2553
+ // local transcript). Not an orphan, and not retryable: another try copies again.
2554
+ const copyRef = forkRefs.remember(providerParam, outcome.newNativeThreadId, now)
2555
+ return refuseFork(forkFailureReason(outcome.cause, 'fork_turn_failed'), {
2556
+ forkRef: copyRef, turnFailed: true, failureClass: outcome.cause, orphanPossible: false, retryable: false,
2557
+ })
2558
+ }
2380
2559
  if (outcome.kind === 'orphan_possible') {
2381
- return refuseFork('fork_orphan_possible', { orphanPossible: true })
2560
+ const cause = outcome.cause ?? null
2561
+ return refuseFork(forkFailureReason(cause, 'fork_orphan_possible'), {
2562
+ orphanPossible: true, failureClass: cause, retryable: cause === null,
2563
+ })
2382
2564
  }
2383
2565
  if (outcome.kind === 'failed') return refuseFork('fork_failed')
2384
2566
 
@@ -2389,7 +2571,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2389
2571
  res.status(201).json({
2390
2572
  forked: true,
2391
2573
  reason: null,
2392
- reasonCopy: FORKED_COPY,
2574
+ reasonCopy: outcome.integrity === 'verified_unchanged' ? FORKED_COPY : FORKED_COPY_UNVERIFIED,
2393
2575
  forkRef,
2394
2576
  // Reported, never assumed. `unverified` means COS could not read the
2395
2577
  // original at both ends — which is not the same as, and must never be
@@ -2656,14 +2838,16 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2656
2838
  // session started in the gap is exactly the residual risk option B leaves
2657
2839
  // open, and it is terminal here rather than a warning because COS has no
2658
2840
  // cross-process lock that could fence a live desktop writer.
2659
- const detected = detectOccupancy(binding.provider, binding.nativeThreadId)
2841
+ const detected = routeOccupancy(binding.provider, binding.nativeThreadId)
2660
2842
  const verdict = projectAttachability(detected)
2661
2843
  stage('gate')
2662
2844
  if (!verdict.attachable) return refuseTurn(verdict.reason ?? 'probe_failed')
2663
2845
  // 6.51.0: the SAME verdict says whether a live process that is not ours holds the
2664
- // thread. Only the declared idle-holder exemption can carry one on an attachable
2665
- // verdict (`projectAttachability` refuses anything else), so this is that fact.
2666
- const foreignHolder = detected.idleHolder === true
2846
+ // thread. Only the declared idle-holder exemption (or, 6.62.0, the busy Codex hop)
2847
+ // can carry one on an attachable verdict (`projectAttachability` refuses anything
2848
+ // else), so this is that fact.
2849
+ const busyCodexHop = detected.busyHolder === true
2850
+ const foreignHolder = (detected.idleHolder === true || busyCodexHop)
2667
2851
  && Array.isArray(detected.owners)
2668
2852
  && detected.owners.some(owner => owner?.selfOwned !== true)
2669
2853
 
@@ -2677,7 +2861,15 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2677
2861
  // reads as diverged, because the first turn is what moved the head.
2678
2862
  const baseline = guard.acknowledgedHead(bindingId) ?? binding.nativeHeadAtAttach
2679
2863
  if (typeof baseline !== 'string' || baseline.length === 0) return refuseTurn('native_head_unavailable')
2680
- if (head.digest !== baseline) {
2864
+ if (head.digest !== baseline && busyCodexHop) {
2865
+ // 6.62.0 (plan 3.6): a busy thread moves while it works; that is what busy means.
2866
+ // The hop puts the turn into the app's own queue, which runs it after the current
2867
+ // turn, the same "append to whatever the thread is when it frees" the COS queue's
2868
+ // watermark exemption already grants a parked turn. Recorded, so the next
2869
+ // interactive turn compares against this head.
2870
+ console.log(`[agent-session-bindings] busy codex hop acknowledges a moving head turnId=${turnId} bindingId=${bindingId}`)
2871
+ guard.acknowledgeHead(bindingId, head.digest)
2872
+ } else if (head.digest !== baseline) {
2681
2873
  // Only a changed/not-changed signal and a new opaque revision. No diff, no
2682
2874
  // content, no path — the client is told THAT it moved, never to what.
2683
2875
  if (acknowledged !== head.digest) {
@@ -2813,7 +3005,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2813
3005
  if (live?.ok) {
2814
3006
  // 6.53.3: queued in the Codex app, which only the app can stop ("Stop it there").
2815
3007
  const latched = latchedHere()
2816
- if (latched) settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'reached', { prompt, sentAt: handOffAt })
3008
+ if (latched) settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'reached', { prompt, sentAt: handOffAt, busy: busyCodexHop })
2817
3009
  console.log(`[agent-session-bindings] turn delivered live provider=codex turnId=${turnId} bindingId=${bindingId} verifiedBy=${live.verifiedBy ?? 'unknown'} queuedId=${live.queuedId ?? 'null'}`)
2818
3010
  respond(200, {
2819
3011
  turnId,
@@ -2832,10 +3024,18 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2832
3024
  // latched on the way that is the whole turn: cancelled, and no child.
2833
3025
  const codexLatched = latchedHere()
2834
3026
  if (codexLatched && live !== null && live.reason !== 'unverified') {
2835
- settleLatchedCancel(codexLatched, binding.provider, binding.nativeThreadId, 'none', { prompt, sentAt: handOffAt })
3027
+ settleLatchedCancel(codexLatched, binding.provider, binding.nativeThreadId, 'none', { prompt, sentAt: handOffAt, busy: busyCodexHop })
2836
3028
  console.log(`[agent-session-bindings] turn cancelled during live hand-off provider=codex turnId=${turnId} bindingId=${bindingId} hop=${live.reason}`)
2837
3029
  return refuseTurn('turn_cancelled', { retryable: false })
2838
3030
  }
3031
+ // 6.62.0 (plan 3.6): a BUSY thread never falls through to a child (the writer lock
3032
+ // is held, so the spawn would fail after the prompt and fence). Whatever reached no
3033
+ // queue row is the 6.61 answer instead: working, and queueable, so the COS queue
3034
+ // takes it. A row that MAY be queued is still the ambiguous fence below.
3035
+ if (busyCodexHop && live !== null && live.reason !== 'unverified') {
3036
+ console.log(`[agent-session-bindings] busy codex hop declined provider=codex turnId=${turnId} bindingId=${bindingId} hop=${live.reason}`)
3037
+ return refuseTurn('native_thread_working')
3038
+ }
2839
3039
  // The operator switched this off: nothing was queued, so the abortable
2840
3040
  // 6.50 child path is cancellable again before it starts.
2841
3041
  if (live?.reason === 'disabled' && liveEntry?.turnId === turnId) liveEntry.cancellable = true
@@ -2860,7 +3060,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2860
3060
  })
2861
3061
  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
3062
  // 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 })
3063
+ if (codexLatched) settleLatchedCancel(codexLatched, binding.provider, binding.nativeThreadId, 'maybe', { prompt, sentAt: handOffAt, busy: busyCodexHop })
2864
3064
  return reportAmbiguous()
2865
3065
  }
2866
3066
  // Nothing was queued, and the child cannot run against a held thread. Not
@@ -2912,6 +3112,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2912
3112
  expectedNativeHead: head.raw,
2913
3113
  prompt,
2914
3114
  abortSignal: cancelController.signal,
3115
+ // 6.62.0 /qa (Q3): only a client that shows `continue_note` gets Cursor Run Everything.
3116
+ continueNoteAck: continueNoteAcknowledged(req.headers[CONTINUE_NOTE_HEADER]),
2915
3117
  onSpawn: (pid: number): boolean => {
2916
3118
  // THE SELF-RECURSION ORDER. The child registers itself against the id
2917
3119
  // we are targeting, so unless it is in the ledger the next occupancy
@@ -79,12 +79,12 @@ import {
79
79
  transcriptWatcherDegraded,
80
80
  readTranscriptSeedLines,
81
81
  } from '../lib/session-transcript-watcher.js'
82
- import { foldSeedOutcomes, draftsFromLine, statusDraftWithDerived, type DerivedStatusFields } from '../lib/session-stream-events.js'
82
+ import { cursorThoughtStatus, foldSeedOutcomes, draftsFromLine, statusDraftWithDerived, type DerivedStatusFields } from '../lib/session-stream-events.js'
83
83
  import type { SessionStreamState } from '../lib/session-stream-events.js'
84
84
  import { RING_EPOCH, replaySessionStream, ringBounds } from '../lib/session-stream-bus.js'
85
85
  import { claudeSessionsDir, readClaudePeerRecords, registryFacts } from './claude-sessions.js'
86
86
  import type { RegistryFacts } from '../lib/session-state-derive.js'
87
- import { deriveForRow, sessionHooksEnabled, sessionSignalStore } from '../lib/session-hooks-runtime.js'
87
+ import { deriveForRow, sessionHooksEnabled, sessionSignalStore, signalFor } from '../lib/session-hooks-runtime.js'
88
88
  import { derivedStatusFields } from '../lib/session-state-derive.js'
89
89
 
90
90
  export const agentSessionStreamRouter = Router()
@@ -121,9 +121,12 @@ export const REGISTRY_REFRESH_MS = 5_000
121
121
  type RegistryReader = () => Promise<RegistryFacts | undefined>
122
122
 
123
123
  function derivedForStream(provider: AgentProvider, sessionId: string, key: string, registry: RegistryFacts | undefined): DerivedStatusFields | undefined {
124
- if (provider !== 'claude' || !sessionHookSseEnabled()) return undefined
124
+ if (!sessionHookSseEnabled()) return undefined
125
+ // 6.62.0 (plan 1.3): Codex and Cursor too, once their hooks have spoken for the session. A
126
+ // session they have not stays exactly as before: the transcript's own status lines narrate it.
127
+ if (provider !== 'claude' && !signalFor(sessionId)) return undefined
125
128
  try {
126
- const derived = deriveForRow({ sessionId, registry, remember: false, attachedTurn: isAttachedTurnActive(key) })
129
+ const derived = deriveForRow({ sessionId, registry: provider === 'claude' ? registry : undefined, remember: false, attachedTurn: isAttachedTurnActive(key) })
127
130
  return derived ? derivedStatusFields(derived) : undefined
128
131
  } catch {
129
132
  return undefined
@@ -278,7 +281,8 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
278
281
  // opening state (nothing awaits once the headers are out, so a close can never slip
279
282
  // between a write and the `close` listener), refreshed at most every REGISTRY_REFRESH_MS.
280
283
  const readRegistry = registryReaderFor(sessionId)
281
- let registry: RegistryFacts | undefined = await readRegistry()
284
+ // Only a Claude session has a `~/.claude/sessions` record (6.62.0: the feed now derives for all three).
285
+ let registry: RegistryFacts | undefined = provider === 'claude' ? await readRegistry() : undefined
282
286
 
283
287
  res.writeHead(200, {
284
288
  'Content-Type': 'text/event-stream',
@@ -300,6 +304,7 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
300
304
  let closed = false
301
305
 
302
306
  let releaseSignals: (() => void) | null = null
307
+ let releaseThoughts: (() => void) | null = null
303
308
 
304
309
  const teardown = (): void => {
305
310
  if (closed) return
@@ -308,6 +313,7 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
308
313
  unsubscribe()
309
314
  releaseWatcher?.()
310
315
  releaseSignals?.()
316
+ releaseThoughts?.()
311
317
  try { res.end() } catch { /* already gone */ }
312
318
  }
313
319
 
@@ -437,7 +443,8 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
437
443
  // client's state line moves on the engine's own events (prompt, permission prompt,
438
444
  // Stop, end) rather than on the transcript clock. Filtered to this session (the
439
445
  // client may have addressed it by the registry's 8-character form).
440
- if (provider === 'claude' && sessionHookSseEnabled() && !closed) {
446
+ // 6.62.0 (plan 1.3): Codex and Cursor sessions as well, now that their hooks reach the store.
447
+ if (sessionHookSseEnabled() && !closed) {
441
448
  const wanted = sessionId.toLowerCase()
442
449
  // `lastStamp` is whatever the OPENING status actually wrote (set inside `write`), so a
443
450
  // change during the seed read is emitted as the first live line, not lost.
@@ -446,7 +453,7 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
446
453
  if (signal.sessionId !== wanted && !signal.sessionId.startsWith(wanted)) return
447
454
  // The registry may have moved with the hooks (an Esc flips it idle); refresh it
448
455
  // off the hot path and let the next event read the new facts.
449
- void readRegistry().then(facts => { registry = facts })
456
+ if (provider === 'claude') void readRegistry().then(facts => { registry = facts })
450
457
  const derived = derivedForStream(provider, sessionId, key, registry)
451
458
  if (!derived) return
452
459
  // Only a CHANGE of state is a line; tool events inside a running turn are narrated
@@ -454,6 +461,16 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
454
461
  if (stampOf(derived) === lastStamp) return
455
462
  write({ kind: 'status', state: 'working', at: Date.now() })
456
463
  })
464
+ // 6.62.0 (plan 1.7): a Cursor session's newest agent thought is its reasoning line, the
465
+ // way Codex's reasoning headline is. Memory and this feed only; never the ledger.
466
+ if (provider === 'cursor') {
467
+ releaseThoughts = sessionSignalStore.subscribeThoughts((signal, thought) => {
468
+ if (closed) return
469
+ if (signal.sessionId !== wanted && !signal.sessionId.startsWith(wanted)) return
470
+ const draft = cursorThoughtStatus(thought.text)
471
+ if (draft) write({ ...draft, at: Date.now() })
472
+ })
473
+ }
457
474
  }
458
475
 
459
476
  releaseWatcher = path === null
@@ -466,6 +483,8 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
466
483
  releaseWatcher = null
467
484
  releaseSignals?.()
468
485
  releaseSignals = null
486
+ releaseThoughts?.()
487
+ releaseThoughts = null
469
488
  return
470
489
  }
471
490