@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
@@ -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
 
@@ -1,15 +1,21 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { installCursorObserver } from '../lib/cursor-observer-installer.js'
3
- // Install, inspect or remove the COS session hook in ~/.claude/settings.json.
3
+ // Install, inspect or remove the COS session hook in ~/.claude/settings.json, and (6.62.0) in
4
+ // ~/.codex/hooks.json when Codex is on this Mac.
4
5
  //
5
- // npx --yes @gotcos/glasses-server@latest --hooks install [--dry-run] [--port 3141]
6
+ // npx --yes @gotcos/glasses-server@latest --hooks install [--dry-run] [--port 3141] [--codex]
6
7
  // npx --yes @gotcos/glasses-server@latest --hooks status
7
- // npx --yes @gotcos/glasses-server@latest --hooks uninstall [--dry-run]
8
+ // npx --yes @gotcos/glasses-server@latest --hooks uninstall [--dry-run] [--codex]
9
+ //
10
+ // `--codex` acts on Codex ONLY (the rollback runbook's `--hooks uninstall --codex` leaves the
11
+ // Claude hooks in place). Without it, Codex rides along when present and never changes the
12
+ // exit code: a Codex refusal is reported under `codex` and Claude's answer stands (B2).
8
13
  //
9
14
  // Prints one JSON document. Exit 0 on success, 2 on a refusal (unparseable or symlinked
10
15
  // settings, a missing packaged script), 64 on a bad argument. Never prints the token.
11
16
 
12
17
  import { hookStatus, hookStatusAdvice, installClaudeHooks, uninstallClaudeHooks } from '../lib/claude-hooks-installer.js'
18
+ import { codexHookAdvice, codexHookStatus, codexPresent, installCodexHooks, refreshCodexHookTrust, uninstallCodexHooks } from '../lib/codex-hooks-installer.js'
13
19
  import { sessionHooksEnabled } from '../lib/session-hooks-runtime.js'
14
20
 
15
21
  const args = process.argv.slice(2)
@@ -20,36 +26,56 @@ const port = portIndex >= 0 ? Number(args[portIndex + 1]) : Number(process.env.P
20
26
  const deskIndex = args.indexOf('--desk-idle-s')
21
27
  const deskIdleSeconds = deskIndex >= 0 ? Number(args[deskIndex + 1]) : Number(process.env.COS_PERMISSION_BROKER_DESK_IDLE_S ?? 90)
22
28
 
29
+ const codexOnly = args.includes('--codex')
30
+
23
31
  function print(value: unknown): void {
24
32
  console.log(JSON.stringify(value, null, 2))
25
33
  }
26
34
 
35
+ /** The Codex half: skipped when Codex is not here; with trust read from Codex itself (K3). */
36
+ async function codexPart(action: 'status' | 'install' | 'uninstall'): Promise<Record<string, unknown> & { ok: boolean }> {
37
+ if (!codexPresent()) return { ok: true, skipped: 'codex_not_present' }
38
+ const result = action === 'status' ? { ok: true, status: codexHookStatus() }
39
+ : action === 'install' ? installCodexHooks({ dryRun }) : uninstallCodexHooks({ dryRun })
40
+ const trust = result.status.installed && !dryRun ? await refreshCodexHookTrust({ fresh: true }) : null
41
+ const advice = action === 'uninstall' ? null : codexHookAdvice(result.status, trust?.trust ?? 'unknown')
42
+ // QA W4: why the trust word is what it is (a failed read's code, `not_listed`), or null.
43
+ return { ...result, ...(trust ? { trust: trust.trust, trustReason: trust.reason } : {}), ...(advice ? { advice } : {}) }
44
+ }
45
+
27
46
  // `serverApplies` says whether the running server would READ the spool into rows: the
28
47
  // hooks can be installed while the feature is off (COS_CLAUDE_SESSIONS_ENABLED unset),
29
48
  // and a status that said only "installed" would hide that.
30
49
  const serverApplies = sessionHooksEnabled()
31
50
  const flagsNote = serverApplies ? undefined : 'Rows change only when the server runs with COS_CLAUDE_SESSIONS_ENABLED=1 (or COS_SESSION_HOOKS=1).'
32
51
 
52
+ if ((action === 'install' || action === 'uninstall') && codexOnly) {
53
+ const codex = await codexPart(action)
54
+ print({ action, dryRun, codexOnly: true, codex })
55
+ process.exit(codex.ok ? 0 : 2)
56
+ }
33
57
  if (action === 'status') {
34
58
  const status = hookStatus()
35
59
  // 6.53.3: say in words what the state means and what fixes it (`script_outdated` after an
36
60
  // update that changed the script, until Install hooks).
37
61
  const advice = hookStatusAdvice(status)
38
- print({ action, serverApplies, ...(flagsNote ? { note: flagsNote } : {}), ...(advice ? { advice } : {}), ...status })
62
+ print({ action, serverApplies, ...(flagsNote ? { note: flagsNote } : {}), ...(advice ? { advice } : {}), ...status, codex: await codexPart('status') })
39
63
  process.exit(0)
40
64
  }
41
65
  if (action === 'install') {
42
66
  if (!Number.isFinite(port) || port <= 0) { console.error('Bad --port'); process.exit(64) }
43
67
  const result = installClaudeHooks({ port, deskIdleSeconds: Number.isFinite(deskIdleSeconds) ? deskIdleSeconds : 90, dryRun })
44
68
  const cursorObserver = result.ok ? installCursorObserver({ dryRun }) : undefined
45
- print({ action, dryRun, serverApplies, ...(flagsNote ? { note: flagsNote } : {}), ...result, cursorObserver })
69
+ const codex = result.ok ? await codexPart('install') : undefined
70
+ print({ action, dryRun, serverApplies, ...(flagsNote ? { note: flagsNote } : {}), ...result, cursorObserver, codex })
46
71
  process.exit(result.ok && cursorObserver?.ok ? 0 : 2)
47
72
  }
48
73
  if (action === 'uninstall') {
49
74
  const result = uninstallClaudeHooks({ dryRun })
50
75
  const cursorObserver = result.ok ? installCursorObserver({ dryRun, uninstall: true }) : undefined
51
- print({ action, dryRun, ...result, cursorObserver })
76
+ const codex = result.ok ? await codexPart('uninstall') : undefined
77
+ print({ action, dryRun, ...result, cursorObserver, codex })
52
78
  process.exit(result.ok && cursorObserver?.ok ? 0 : 2)
53
79
  }
54
- console.error('Usage: --hooks install|status|uninstall [--dry-run] [--port N] [--desk-idle-s N]')
80
+ console.error('Usage: --hooks install|status|uninstall [--dry-run] [--port N] [--desk-idle-s N] [--codex]')
55
81
  process.exit(64)