@gotcos/glasses-server 6.61.7 → 6.63.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.
Files changed (70) hide show
  1. package/.env.example +6 -0
  2. package/CHANGELOG.md +39 -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 +141 -17
  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-job-ledger.ts +194 -0
  32. package/server/lib/fork-thread.ts +169 -25
  33. package/server/lib/health-static-probes.ts +21 -11
  34. package/server/lib/lens-gist-engines.ts +15 -4
  35. package/server/lib/lens-gist.ts +1 -1
  36. package/server/lib/live-cues-cursor.ts +16 -6
  37. package/server/lib/occupancy-probes.ts +45 -1
  38. package/server/lib/permission-broker.ts +24 -9
  39. package/server/lib/provider-actions.ts +149 -0
  40. package/server/lib/provider-failure-text.ts +27 -0
  41. package/server/lib/provider-observe.ts +212 -0
  42. package/server/lib/provider-proof.ts +23 -14
  43. package/server/lib/query-job-runtime.ts +16 -1
  44. package/server/lib/query-job-types.ts +2 -1
  45. package/server/lib/session-act-fields.ts +61 -0
  46. package/server/lib/session-cancel.ts +95 -4
  47. package/server/lib/session-continue-facts-real.ts +74 -0
  48. package/server/lib/session-continue-facts.ts +267 -0
  49. package/server/lib/session-halt.ts +67 -6
  50. package/server/lib/session-hook-events.ts +57 -0
  51. package/server/lib/session-hook-ledger.ts +21 -3
  52. package/server/lib/session-hook-spool.ts +68 -6
  53. package/server/lib/session-hooks-runtime.ts +32 -2
  54. package/server/lib/session-peer-inbox-deps.ts +7 -6
  55. package/server/lib/session-recommendation.ts +3 -2
  56. package/server/lib/session-signal-store.ts +74 -5
  57. package/server/lib/session-stream-events.ts +32 -1
  58. package/server/lib/thread-attach-capability.ts +15 -1
  59. package/server/lib/thread-drain-kick.ts +4 -2
  60. package/server/lib/thread-occupancy.ts +44 -0
  61. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  62. package/server/lib/thread-turn-queue.ts +6 -0
  63. package/server/lib/turn-request.ts +274 -0
  64. package/server/routes/agent-session-bindings.ts +349 -26
  65. package/server/routes/agent-session-stream.ts +26 -7
  66. package/server/routes/agent-sessions.ts +155 -10
  67. package/server/routes/health.ts +27 -2
  68. package/server/routes/session-hooks.ts +56 -5
  69. package/server/routes/thread-turn-queue.ts +37 -9
  70. package/server/scripts/hooks-cli.ts +33 -7
@@ -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
 
@@ -44,7 +44,10 @@ import { searchAgentSessions, type AgentSessionSearchHit } from '../lib/agent-se
44
44
  import { claudeSessionNamesVisible, claudeSessionsDir, claudeSessionsEnabled, readClaudePeerRecords, registryFacts } from './claude-sessions.js'
45
45
  import type { ClaudePeerRecord } from '../lib/claude-session-registry.js'
46
46
  import { deriveForRow, signalFor, workObservationFor } from '../lib/session-hooks-runtime.js'
47
- import { derivedRowFields, type DerivedSessionState } from '../lib/session-state-derive.js'
47
+ import { derivedRowFields, type DerivedSessionState, type RegistryFacts } from '../lib/session-state-derive.js'
48
+ import type { WorkObservation } from '../lib/session-work-observation.js'
49
+ import type { SessionSignal } from '../lib/session-signal-store.js'
50
+ import { HOOK_ACTIVE_CAP_MS, codexRolloutFacts, type CodexRolloutFacts } from '../lib/codex-rollout-state.js'
48
51
  import { queuedTurnsFields, allQueuedWaitingLookup } from '../lib/thread-turn-queue-store.js'
49
52
  import { isAttachedTurnActive, sessionStreamKey } from '../lib/session-stream-bus.js'
50
53
  import { workspaceFromCwd } from '../lib/claude-session-registry.js'
@@ -67,6 +70,8 @@ import {
67
70
  import type { OccupancyDirs } from '../lib/thread-occupancy.js'
68
71
  import { parseTurnsParam, readRecentSessionTurns, type RecentTurnsRead } from '../lib/agent-session-turns.js'
69
72
  import { cosSpawnedPids } from '../lib/agent-session-ownership-store.js'
73
+ import { sessionActFactsFor, sessionActFields } from '../lib/session-act-fields.js'
74
+ import { CONTINUE_NOTE_HEADER, continueNoteAcknowledged } from '../lib/continue-plan.js'
70
75
 
71
76
  export const agentSessionsRouter = Router()
72
77
 
@@ -202,24 +207,129 @@ async function transcriptMtimes(
202
207
  * rename here is silent on this side and renders every session as merely open on
203
208
  * the lens. Pure: it takes the scan, it does no I/O.
204
209
  */
205
- export function withRunning<T extends { session_id: string }>(entry: T, scan: OccupiedScan) {
210
+ export function withRunning<T extends { session_id: string }>(entry: T, scan: OccupiedScan, hint: RunningHint | null = null) {
206
211
  const occ = scan.occupied.get(entry.session_id)
212
+ // 6.62.0: a Cursor row with hook evidence is decided by it (there is no process occupancy
213
+ // for Cursor, and its file time says nothing once the hooks have spoken).
214
+ const decides = hint?.decides === true
207
215
  return {
208
216
  ...entry,
209
217
  // A process HOLDS this thread open, whoever started it. Not "is generating":
210
218
  // a Claude record for an open window outlives the work by however long the
211
219
  // window stays up, which is why `running_active` exists below.
212
- running: occ !== undefined,
220
+ running: decides ? hint!.running : occ !== undefined || hint?.running === true,
213
221
  // Held by something that is not COS, so a Continue would be refused. The
214
222
  // badge reads `running`; the Continue affordance reads this.
215
- running_foreign: (occ?.foreignOwners ?? 0) > 0,
223
+ running_foreign: (occ?.foreignOwners ?? 0) > 0 || hint?.foreign === true,
216
224
  // The transcript was WRITTEN in the last few seconds: an agent is generating
217
225
  // here, not just holding the file. This is the only one of the three that
218
226
  // clears on its own when the work stops. Still a display hint, never a gate.
219
- running_active: occ?.activeRecently === true,
227
+ // 6.62.0: or the engine's own hooks (or a Codex rollout) say a turn is open and the
228
+ // last event is inside HOOK_ACTIVE_CAP_MS (plan 1.1-1.3).
229
+ running_active: decides ? hint!.active : occ?.activeRecently === true || hint?.active === true,
220
230
  }
221
231
  }
222
232
 
233
+ /**
234
+ * 6.62.0 (plan 1.1-1.3): what the engine's own evidence says about working, for `withRunning`.
235
+ *
236
+ * A DISPLAY HINT, NEVER A GATE: the write gate (`thread-occupancy.ts`) is untouched. An open
237
+ * turn reads active only while its last event is inside HOOK_ACTIVE_CAP_MS: an Esc with no
238
+ * Stop, a crash, a Cursor abort leave a turn open, and the half-hour open-turn ceiling would
239
+ * otherwise show a ghost "working" row for thirty minutes (W5).
240
+ *
241
+ * hook running, last event inside the cap running, active (every provider)
242
+ * hook running, quiet past the cap Cursor: running, NOT active (OPEN, quiet: a
243
+ * tool can run longer than the cap with no event,
244
+ * and an Esc with no Stop looks the same, QA W8);
245
+ * Claude and Codex: no hint (occupancy answers)
246
+ * hook waiting running, not active
247
+ * Codex rollout turn open inside the cap running, active (no Codex hooks yet)
248
+ * Cursor, any other hook state not running, not active (`decides`)
249
+ *
250
+ * A hook-derived `running` only exists inside the deriver's 30-minute open-turn ceiling, so an
251
+ * open-quiet Cursor row ends there, or at its Stop or SessionEnd.
252
+ *
253
+ * For Claude and Codex the hint only ADDS to occupancy (a held, freshly written thread still
254
+ * reads as it did). For Cursor, whose list has no occupancy at all, a hook signal decides, and
255
+ * a running Cursor turn is the user's own (an IDE composer or their terminal; COS's own child
256
+ * is kept out of the phase), so it is foreign.
257
+ */
258
+ export interface RunningHint {
259
+ running: boolean
260
+ active: boolean
261
+ foreign: boolean
262
+ /** Cursor with hook evidence: this hint IS the answer, occupancy and file time are not read. */
263
+ decides: boolean
264
+ }
265
+
266
+ export function runningHintFor(provider: AgentProvider, derived: DerivedSessionState | undefined, lastEventAt: number | null, now: number): RunningHint | null {
267
+ const cursor = provider === 'cursor'
268
+ const quiet: RunningHint | null = cursor ? { running: false, active: false, foreign: false, decides: true } : null
269
+ if (!derived) return null
270
+ const inCap = lastEventAt !== null && now - lastEventAt <= HOOK_ACTIVE_CAP_MS && lastEventAt <= now + 5_000
271
+ if (derived.state_source === 'hook') {
272
+ if (derived.agent_state === 'waiting') return { running: true, active: false, foreign: cursor, decides: cursor }
273
+ if (derived.agent_state === 'running' && inCap) return { running: true, active: true, foreign: cursor, decides: cursor }
274
+ if (derived.agent_state === 'running' && cursor) return { running: true, active: false, foreign: true, decides: true }
275
+ return quiet
276
+ }
277
+ if (provider === 'codex' && derived.state_source === 'transcript' && derived.agent_state === 'running' && inCap) {
278
+ return { running: true, active: true, foreign: false, decides: false }
279
+ }
280
+ return null
281
+ }
282
+
283
+ /** The newest thing a signal heard: an event, or (Cursor) a thought. */
284
+ function signalLastAt(signal: SessionSignal | undefined): number | null {
285
+ if (!signal) return null
286
+ return Math.max(signal.lastEventAt, signal.lastThought?.at ?? 0)
287
+ }
288
+
289
+ /** A Codex thread with a writer-lock holder: alive, with no status of its own (W6). */
290
+ const CODEX_HOLDER_FACTS: RegistryFacts = { alive: true, status: null, waitingFor: null, statusUpdatedAt: null, lastActiveAt: null }
291
+
292
+ /**
293
+ * 6.62.0 (plan 1.3): one derivation for a Codex row. The hooks when they have spoken; else the
294
+ * rollout's own turn markers, read for a thread written inside the cap. Registry facts only
295
+ * when a writer-lock holder exists: passing "dead" facts for every unloaded thread would end
296
+ * each of them after two scans (session-state-derive.ts).
297
+ */
298
+ function deriveCodexRow(sessionId: string, held: boolean, facts: CodexRolloutFacts | null, now: number, remember = true): DerivedSessionState | undefined {
299
+ return deriveForRow({
300
+ sessionId,
301
+ registry: held ? CODEX_HOLDER_FACTS : undefined,
302
+ transcript: facts ? { inFlight: facts.open, lastActivityAt: facts.mtimeMs } : undefined,
303
+ now,
304
+ remember,
305
+ attachedTurn: isAttachedTurnActive(sessionStreamKey('codex', sessionId)),
306
+ })
307
+ }
308
+
309
+ /** The evidence clock a row's hint is judged by: the hooks, else (Codex) the rollout write. */
310
+ function hintClock(signal: SessionSignal | undefined, derived: DerivedSessionState | undefined, facts: CodexRolloutFacts | null): number | null {
311
+ if (derived?.state_source === 'hook') return signalLastAt(signal)
312
+ return facts?.mtimeMs ?? null
313
+ }
314
+
315
+ /**
316
+ * 6.62.0 (plan 1.9): Codex compaction. Codex's PreCompact hook opens it; the rollout's
317
+ * `ContextCompaction` item, written when it is over, closes it (as does the PostCompact hook).
318
+ */
319
+ function codexCompactionFields(observation: WorkObservation | undefined, facts: CodexRolloutFacts | null, now: number): ReturnType<typeof workObservationFields> {
320
+ if (!observation || observation.compactingAt === null) return {}
321
+ if (facts?.compactedAt != null && facts.compactedAt >= observation.compactingAt) return {}
322
+ const { compaction } = workObservationFields({ ...observation, agents: [], partial: false }, now)
323
+ return compaction ? { compaction } : {}
324
+ }
325
+
326
+ /** 6.62.0 (plan 1.1): a Cursor row's last activity is its last hook event; the transcript has no clock. */
327
+ function cursorActivityFields(provider: AgentProvider, signal: SessionSignal | undefined): { last_activity_at?: string } {
328
+ if (provider !== 'cursor') return {}
329
+ const at = signalLastAt(signal)
330
+ return at ? { last_activity_at: new Date(at).toISOString() } : {}
331
+ }
332
+
223
333
  /**
224
334
  * Occupancy plus freshness for ONE thread, for the detail route.
225
335
  *
@@ -399,11 +509,30 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
399
509
  if (row.provider !== 'cursor' || !signalFor(row.session_id)) continue
400
510
  derivedById.set(row.session_id, deriveForRow({ sessionId: row.session_id, now, remember: false }))
401
511
  }
512
+ // 6.62.0 (plan 1.3): Codex rows get the one derivation too, from the hooks or the rollout.
513
+ const rolloutById = new Map<string, CodexRolloutFacts | null>()
514
+ for (const row of sessions) {
515
+ if (row.provider !== 'codex') continue
516
+ const facts = codexRolloutFacts(row.file, now)
517
+ rolloutById.set(row.session_id, facts)
518
+ derivedById.set(row.session_id, deriveCodexRow(row.session_id, running.occupied.has(row.session_id), facts, now))
519
+ }
402
520
  const queuedOf = await allQueuedWaitingLookup(now)
403
521
  const subagents = await readCodexSubagents(sessions.filter(row => row.provider === 'codex').map(row => row.session_id), roots.codexSessions, now)
404
522
  res.json({
405
- sessions: sessions.map((row, index) => ({ ...withRunning(toEntry(row, activity[index], derivedById.get(row.session_id), queuedOf(row.provider, row.session_id)), running),
406
- ...(row.provider === 'codex' ? (subagents.has(row.session_id) ? { subagent_activity: subagents.get(row.session_id) } : {}) : workObservationFields(workObservationFor(row.session_id), now)) })),
523
+ sessions: sessions.map((row, index) => {
524
+ const derived = derivedById.get(row.session_id)
525
+ const signal = signalFor(row.session_id)
526
+ const facts = rolloutById.get(row.session_id) ?? null
527
+ const hint = runningHintFor(row.provider, derived, hintClock(signal, derived, facts), now)
528
+ return {
529
+ ...withRunning(toEntry(row, activity[index], derived, queuedOf(row.provider, row.session_id)), running, hint),
530
+ ...cursorActivityFields(row.provider, signal),
531
+ ...(row.provider === 'codex'
532
+ ? { ...(subagents.has(row.session_id) ? { subagent_activity: subagents.get(row.session_id) } : {}), ...codexCompactionFields(workObservationFor(row.session_id), facts, now) }
533
+ : workObservationFields(workObservationFor(row.session_id), now)),
534
+ }
535
+ }),
407
536
  total: sessions.length,
408
537
  windowHours: AGENT_SESSION_WINDOW_HOURS,
409
538
  sort,
@@ -442,7 +571,9 @@ agentSessionsRouter.get('/agent-sessions/search', async (req, res) => {
442
571
  if (!peersByPrefix.has(prefix)) peersByPrefix.set(prefix, peer)
443
572
  }
444
573
  const derivedFor = (hit: AgentSessionSearchHit): DerivedSessionState | undefined => {
445
- if (hit.provider !== 'claude') return undefined
574
+ // 6.62.0 (plan 1.3): a Codex or Cursor hit the hooks have spoken for gets the same state
575
+ // as its row, from the signal alone (a hit has no transcript walk).
576
+ if (hit.provider !== 'claude') return signalFor(hit.session_id) ? deriveForRow({ sessionId: hit.session_id, remember: false, attachedTurn: isAttachedTurnActive(sessionStreamKey(hit.provider, hit.session_id)) }) : undefined
446
577
  const peer = peersByPrefix.get(hit.session_id.slice(0, 8).toLowerCase())
447
578
  return deriveForRow({
448
579
  sessionId: hit.session_id,
@@ -515,7 +646,9 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
515
646
  const named = names.get(parsed.session_id) || names.get(sessionId)
516
647
  if (named) parsed.display_label = named
517
648
  }
518
- const observed = provider === 'codex' ? {} : workObservationFields(workObservationFor(parsed.session_id))
649
+ const now = Date.now()
650
+ const rollout = provider === 'codex' ? codexRolloutFacts(found, now) : null
651
+ const observed = provider === 'codex' ? codexCompactionFields(workObservationFor(parsed.session_id), rollout, now) : workObservationFields(workObservationFor(parsed.session_id))
519
652
  const subagents = provider === 'codex' ? (await readCodexSubagents([sessionId], agentSessionRoots().codexSessions)).get(sessionId.toLowerCase()) : observed.subagent_activity
520
653
  const modified = st.mtime.toISOString()
521
654
  const activity = await readSessionActivity(provider, found)
@@ -534,9 +667,14 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
534
667
  } else if (provider === 'cursor' && signalFor(parsed.session_id)) {
535
668
  // 6.51.0: the detail agrees with its list row (hook-grounded Cursor state).
536
669
  derived = deriveForRow({ sessionId: parsed.session_id, remember: false })
670
+ } else if (provider === 'codex') {
671
+ // 6.62.0 (plan 1.3): as its list row: the hooks, else the rollout's turn markers.
672
+ derived = deriveCodexRow(parsed.session_id, running.occupied.has(parsed.session_id), rollout, now, false)
537
673
  }
674
+ const signal = signalFor(parsed.session_id)
675
+ const hint = runningHintFor(provider, derived, hintClock(signal, derived, rollout), now)
538
676
  res.json({
539
- ...withRunning({ session_id: parsed.session_id }, running),
677
+ ...withRunning({ session_id: parsed.session_id }, running, hint),
540
678
  ...derivedRowFields(derived),
541
679
  ...queuedTurnsFields((await allQueuedWaitingLookup(Date.now()))(provider, parsed.session_id)),
542
680
  // The client must be able to tell "this server stamped nothing" from "this
@@ -587,8 +725,15 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
587
725
  file_size_bytes: parsed.file_size_bytes,
588
726
  omitted_tools: parsed.omitted_tools,
589
727
  ...(activity.lastActivityAt ? { last_activity_at: activity.lastActivityAt } : {}),
728
+ ...cursorActivityFields(provider, signal),
590
729
  ...(activity.lastTool ? { last_tool: activity.lastTool } : {}),
591
730
  ...(recentTurns ? { recent_turns: recentTurns.turns, recent_turns_more: recentTurns.more } : {}),
731
+ // 6.62.0 (plans 1.8, 3.12): the lens reads both from the DETAIL (display-pages.ts).
732
+ // 6.62.0 /qa (W11): detail and the attach verdict only; list rows never wait on model reads.
733
+ // Q3: the note says Ask unless this client declared it shows the note.
734
+ ...sessionActFields((await sessionActFactsFor([{ provider, session_id: parsed.session_id, file: found }], {
735
+ noteAck: continueNoteAcknowledged(req.headers[CONTINUE_NOTE_HEADER]),
736
+ }))[0]),
592
737
  })
593
738
  } catch (error) {
594
739
  console.error(`[agent-sessions] detail failed: ${error instanceof Error ? error.message : error}`)
@@ -87,7 +87,10 @@ import {
87
87
  } from '../lib/thread-attach-capability.js'
88
88
  import { cachedHookStatus, sessionHooksEnabled, sessionHooksHealthFields } from '../lib/session-hooks-runtime.js'
89
89
  import { sessionCancelFeature } from '../lib/session-cancel.js'
90
- import { hookHaltReady } from '../lib/claude-hooks-installer.js'
90
+ import { codexDeskHaltReady, providerObserveFields } from '../lib/provider-observe.js'
91
+ import { liveProviderActInput, mergeProviderSections, providerActSections } from '../lib/provider-actions.js'
92
+ import { cursorDeskHaltReady, hookHaltReady } from '../lib/claude-hooks-installer.js'
93
+ import { continueReadHealthFields } from '../lib/continue-read-health.js'
91
94
  import { permissionBrokerHealthFields, sessionQuestionsCapability } from '../lib/permission-broker.js'
92
95
  import { continueLiveEnabled, liveDeliveryStats } from '../lib/session-peer-inbox-deps.js'
93
96
  import { codexLiveStats } from '../lib/codex-live-queue-deps.js'
@@ -305,7 +308,9 @@ healthRouter.get('/health', async (_req, res) => {
305
308
  // 6.53.0: cancel a session run. `cosTurn` always; `deskClaude` only once the hooks are
306
309
  // applied and the 6.53 hook is installed (Install hooks in COS Control after updating).
307
310
  // The status read is the same cached one `sessionHooks` below reports.
308
- sessionCancel: sessionCancelFeature(sessionHooksEnabled(), hookHaltReady(cachedHookStatus())),
311
+ // 6.62.0: `deskCodex` (the Codex hooks installed AND trusted by Codex) and `deskCursor` (it
312
+ // runs the Claude hooks). `deskClaude` reads exactly as before.
313
+ sessionCancel: sessionCancelFeature(sessionHooksEnabled(), hookHaltReady(cachedHookStatus()), codexDeskHaltReady(), cursorDeskHaltReady(cachedHookStatus())),
309
314
  }
310
315
  const voice = {
311
316
  available: keyStatus.hasKey || tts_local.ready,
@@ -415,6 +420,18 @@ healthRouter.get('/health', async (_req, res) => {
415
420
  // how many are held (that is on the authenticated questions route).
416
421
  ...permissionBrokerHealthFields(),
417
422
  ...continueLiveHealthFields(),
423
+ // 6.62.0 /qa (W6): spawn isolation, posture and Cursor model read counters.
424
+ ...continueReadHealthFields(),
425
+ // 6.62.0: ONE providers object per engine. `act` (plan 3.11): what each provider can do,
426
+ // with a reason for every "no". `observe` (plan 1.10): what COS can see of its sessions.
427
+ providers: mergeProviderSections(
428
+ providerActSections(liveProviderActInput({
429
+ attachEnabled: threadAttach.enabled,
430
+ forkProviders: threadAttach.forkProviders ?? [],
431
+ sessionCancel: features.sessionCancel,
432
+ })),
433
+ providerObserveFields({ state: cachedHookStatus().state, ready: hookHaltReady(cachedHookStatus()) }),
434
+ ),
418
435
  server_instance_id: getServerInstanceId(),
419
436
  boot_id: serverMetrics.bootId,
420
437
  generation_id: getServerGenerationId(),
@@ -548,6 +565,14 @@ healthRouter.get('/models', async (req, res) => {
548
565
  res.json({
549
566
  ...catalog,
550
567
  ...threadAttachHealthFields(threadAttach),
568
+ // 6.62.0 (plans 3.5, 3.11): the phone reads THIS surface, so the live-Continue counters
569
+ // and the per-provider action contract are published here too, same helpers as health.
570
+ ...continueLiveHealthFields(),
571
+ providers: mergeProviderSections(providerActSections(liveProviderActInput({
572
+ attachEnabled: threadAttach.enabled,
573
+ forkProviders: threadAttach.forkProviders ?? [],
574
+ sessionCancel: sessionCancelFeature(sessionHooksEnabled(), hookHaltReady(cachedHookStatus()), codexDeskHaltReady(), cursorDeskHaltReady(cachedHookStatus())),
575
+ })), providerObserveFields({ state: cachedHookStatus().state, ready: hookHaltReady(cachedHookStatus()) })),
551
576
  options: [
552
577
  ...(catalog.options ?? []),
553
578
  ...cursorOptions,
@@ -1,4 +1,6 @@
1
1
  import { installCursorObserver } from '../lib/cursor-observer-installer.js'
2
+ import { codexPresent, installCodexHooks, uninstallCodexHooks, type CodexInstallResult } from '../lib/codex-hooks-installer.js'
3
+ import { refreshAfterInstall } from '../lib/provider-observe.js'
2
4
  // Session hooks: status, install, uninstall, and today's runs.
3
5
  //
4
6
  // `GET /api/session-hooks/status` is what Control's banner keys off. Five outcomes on
@@ -17,8 +19,50 @@ export function isInteractiveEntrypoint(entrypoint: string | null): boolean {
17
19
  return entrypoint === 'claude-desktop' || entrypoint === 'cli'
18
20
  }
19
21
 
20
- export function createSessionHooksRouter(options: { port: number }): Router {
22
+ /**
23
+ * 6.62.0: the Codex half of install and uninstall, injectable so a test never touches the
24
+ * real `~/.codex`. Defaults are the real installer, gated on Codex being present at all.
25
+ */
26
+ export interface SessionHooksCodexDeps {
27
+ present: () => boolean
28
+ install: (options: { dryRun: boolean }) => CodexInstallResult
29
+ uninstall: (options: { dryRun: boolean }) => CodexInstallResult
30
+ /** Re-read the provider hook files (and Codex trust, in the background) after a write. */
31
+ refresh: () => void
32
+ }
33
+
34
+ const realCodexDeps: SessionHooksCodexDeps = {
35
+ present: () => codexPresent(),
36
+ install: options => installCodexHooks(options),
37
+ uninstall: options => uninstallCodexHooks(options),
38
+ refresh: () => refreshAfterInstall(),
39
+ }
40
+
41
+ /**
42
+ * The Codex half, run AFTER Claude's and the observer's and never able to change their answer
43
+ * (B2): a missing Codex is `skipped`, a refusal or a throw is reported in `codex` and the route
44
+ * still answers as Claude's result says.
45
+ */
46
+ export function codexHookStep(deps: SessionHooksCodexDeps, action: 'install' | 'uninstall', dryRun: boolean): Record<string, unknown> {
47
+ try {
48
+ if (!deps.present()) return { ok: true, skipped: 'codex_not_present' }
49
+ const result = action === 'install' ? deps.install({ dryRun }) : deps.uninstall({ dryRun })
50
+ return {
51
+ ok: result.ok,
52
+ ...(result.reason ? { reason: result.reason } : {}),
53
+ changed: result.changed,
54
+ backupPath: result.backupPath,
55
+ status: { state: result.status.state, installed: result.status.installed, scriptOk: result.status.scriptOk },
56
+ ...(dryRun && result.merged ? { merged: result.merged } : {}),
57
+ }
58
+ } catch (error) {
59
+ return { ok: false, reason: 'codex_step_threw', detail: error instanceof Error ? error.name : 'error' }
60
+ }
61
+ }
62
+
63
+ export function createSessionHooksRouter(options: { port: number; codex?: SessionHooksCodexDeps }): Router {
21
64
  const router = Router()
65
+ const codex = options.codex ?? realCodexDeps
22
66
 
23
67
  router.get('/session-hooks/status', (_req, res) => {
24
68
  res.set('Cache-Control', 'private, no-store')
@@ -34,8 +78,10 @@ export function createSessionHooksRouter(options: { port: number }): Router {
34
78
  return
35
79
  }
36
80
  const cursorObserver = installCursorObserver({ dryRun })
37
- if (!cursorObserver.ok) { res.status(409).json({ ok: false, reason: 'cursor_observer_install_failed', cursorObserver, status: result.status }); return }
38
- res.json({ ok: true, cursorObserver, changed: result.changed || cursorObserver.changed, scriptCopied: result.scriptCopied, backupPath: result.backupPath, status: result.status, ...(dryRun ? { merged: result.merged } : {}) })
81
+ const codexStep = codexHookStep(codex, 'install', dryRun)
82
+ if (!dryRun) { try { codex.refresh() } catch { /* health catches up on its own tick */ } }
83
+ if (!cursorObserver.ok) { res.status(409).json({ ok: false, reason: 'cursor_observer_install_failed', cursorObserver, codex: codexStep, status: result.status }); return }
84
+ res.json({ ok: true, cursorObserver, codex: codexStep, changed: result.changed || cursorObserver.changed, scriptCopied: result.scriptCopied, backupPath: result.backupPath, status: result.status, ...(dryRun ? { merged: result.merged } : {}) })
39
85
  })
40
86
 
41
87
  router.post('/session-hooks/uninstall', (req, res) => {
@@ -47,8 +93,10 @@ export function createSessionHooksRouter(options: { port: number }): Router {
47
93
  return
48
94
  }
49
95
  const cursorObserver = installCursorObserver({ dryRun, uninstall: true })
50
- if (!cursorObserver.ok) { res.status(409).json({ ok: false, reason: 'cursor_observer_uninstall_failed', cursorObserver, status: result.status }); return }
51
- res.json({ ok: true, cursorObserver, changed: result.changed || cursorObserver.changed, backupPath: result.backupPath, status: result.status, ...(dryRun ? { merged: result.merged } : {}) })
96
+ const codexStep = codexHookStep(codex, 'uninstall', dryRun)
97
+ if (!dryRun) { try { codex.refresh() } catch { /* health catches up on its own tick */ } }
98
+ if (!cursorObserver.ok) { res.status(409).json({ ok: false, reason: 'cursor_observer_uninstall_failed', cursorObserver, codex: codexStep, status: result.status }); return }
99
+ res.json({ ok: true, cursorObserver, codex: codexStep, changed: result.changed || cursorObserver.changed, backupPath: result.backupPath, status: result.status, ...(dryRun ? { merged: result.merged } : {}) })
52
100
  })
53
101
 
54
102
  // Sessions the hooks saw start and end, for Control's scheduled-job ledger. A run
@@ -63,6 +111,9 @@ export function createSessionHooksRouter(options: { port: number }): Router {
63
111
  const runs: Array<Record<string, unknown>> = []
64
112
  for (const signal of sessionSignalStore.snapshot()) {
65
113
  if (signal.observationOnly) continue
114
+ // 6.62.0: Control's scheduled-job ledger lists `claude -p` jobs. A Codex thread (no
115
+ // registry entrypoint at all) or a Cursor composer is never one.
116
+ if (signal.provider !== undefined && signal.provider !== 'claude') continue
66
117
  if (signal.firstSeenAt < sinceMs && !(signal.ended && signal.ended.at >= sinceMs)) continue
67
118
  if (!all && isInteractiveEntrypoint(signal.entrypoint)) continue
68
119
  runs.push({
@@ -16,6 +16,7 @@
16
16
  // EVERY DEPENDENCY IS INJECTED so the whole path is testable without a live server.
17
17
 
18
18
  import { Router, type Request, type Response } from 'express'
19
+ import { CONTINUE_NOTE_HEADER, continueNoteAcknowledged } from '../lib/continue-plan.js'
19
20
  import { nativeQueueControl, plainQueueText, type NativeQueueControl } from '../lib/codex-queue-control.js'
20
21
  import { COS_SESSION_ID_RE, DEFAULT_MAX_PROMPT_CHARS } from './agent-session-bindings.js'
21
22
  import {
@@ -49,6 +50,13 @@ export interface ThreadTurnQueueDeps {
49
50
  serverRetryable?: boolean
50
51
  }>
51
52
  nativeQueue?: NativeQueueControl
53
+ /**
54
+ * 6.62.0 (W14), optional: is this Cursor id an Agent CLI chat (a `~/.cursor/chats` dir)?
55
+ * Only those are drained from here, and only when the gate, which now includes the
56
+ * chat's 30 s busy window, says free. An IDE composer still leaves only through its own
57
+ * Stop hook. Absent: no Cursor turn is ever drained here, exactly as in 6.61.
58
+ */
59
+ cursorCliChat?: (threadId: string) => boolean
52
60
  now: () => number
53
61
  }
54
62
 
@@ -92,6 +100,11 @@ function safeTurnOpen(deps: ThreadTurnQueueDeps, provider: string, threadId: str
92
100
  try { return deps.turnOpen!(provider, threadId) } catch { return undefined }
93
101
  }
94
102
 
103
+ /** A throwing or absent CLI-chat probe means "not a CLI chat": the 6.61 Cursor hold. */
104
+ function safeCursorCliChat(deps: ThreadTurnQueueDeps, threadId: string): boolean {
105
+ try { return deps.cursorCliChat ? deps.cursorCliChat(threadId) === true : false } catch { return false }
106
+ }
107
+
95
108
  /** A throwing cancel read is no hold: the gate still decides every delivery. */
96
109
  function safeCancelHold(deps: ThreadTurnQueueDeps, provider: string, threadId: string): number | null {
97
110
  try { return deps.cancelHoldUntil ? deps.cancelHoldUntil(provider, threadId) : null } catch { return null }
@@ -142,18 +155,31 @@ export async function drainThread(
142
155
  // composer's own Stop hook (`routes/cursor-stop-followup.ts`), which claims it the
143
156
  // moment the turn ends; a second deliverer could hand the same sentence over twice.
144
157
  // The pass still runs for it so the TTL retires an unclaimed turn honestly.
145
- const gate: ReturnType<ThreadTurnQueueDeps['occupancy']> = provider === 'cursor'
158
+ //
159
+ // 6.62.0 (W14): an Agent CLI chat is the exception. A `-p` run fires no Stop hook, so
160
+ // nothing else would ever send its parked turn; it drains here once the gate (with the
161
+ // chat's 30 s busy window) reads free, and that quiet window is its turn-ended signal.
162
+ const cliChat = provider === 'cursor' && safeCursorCliChat(deps, threadId)
163
+ const gate: ReturnType<ThreadTurnQueueDeps['occupancy']> = provider === 'cursor' && !cliChat
146
164
  ? { attachable: false, reason: 'cursor_stop_hook_delivery' }
147
165
  : refusedGate ?? deps.occupancy(provider, threadId)
148
166
  if (!gate.attachable) refusedGate = gate
149
- const seen: DrainObservation = {
150
- attachable: gate.attachable,
151
- turnEnded: deps.turnEnded(provider, threadId),
152
- turnOpen: deps.turnOpen ? safeTurnOpen(deps, provider, threadId) : undefined,
153
- activity: deps.activity(provider, threadId),
154
- reason: gate.reason,
155
- cancelHoldUntil: safeCancelHold(deps, provider, threadId),
156
- }
167
+ const seen: DrainObservation = cliChat
168
+ ? {
169
+ attachable: gate.attachable,
170
+ turnEnded: gate.attachable,
171
+ activity: gate.attachable ? 'idle' : 'working',
172
+ reason: gate.reason,
173
+ cancelHoldUntil: safeCancelHold(deps, provider, threadId),
174
+ }
175
+ : {
176
+ attachable: gate.attachable,
177
+ turnEnded: deps.turnEnded(provider, threadId),
178
+ turnOpen: deps.turnOpen ? safeTurnOpen(deps, provider, threadId) : undefined,
179
+ activity: deps.activity(provider, threadId),
180
+ reason: gate.reason,
181
+ cancelHoldUntil: safeCancelHold(deps, provider, threadId),
182
+ }
157
183
  const decision = drainDecision(turn, seen, deps.now())
158
184
 
159
185
  if (decision === 'hold') { held += 1; continue }
@@ -282,6 +308,8 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
282
308
  const admitted = admitToQueue(queue, {
283
309
  clientTurnId, cosSessionId, provider, threadId, prompt,
284
310
  queuedAt: now, status: 'waiting', attempts: 0,
311
+ // 6.62.0 /qa (Q3): the acknowledgement travels with the row to its drain.
312
+ ...(continueNoteAcknowledged(req.headers[CONTINUE_NOTE_HEADER]) ? { continueNoteAck: true as const } : {}),
285
313
  })
286
314
  if (!admitted.ok) return res.status(409).json({ error: admitted.reason })
287
315