@gotcos/glasses-server 6.61.6 → 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 (72) hide show
  1. package/.env.example +6 -0
  2. package/CHANGELOG.md +41 -0
  3. package/README.md +5 -1
  4. package/bin/cli.cjs +50 -3
  5. package/bin/hooks/cos-cursor-observer.cjs +36 -0
  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 +5 -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 +28 -5
  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/codex-subagent-activity.ts +119 -0
  24. package/server/lib/continue-plan.ts +180 -0
  25. package/server/lib/continue-read-health.ts +26 -0
  26. package/server/lib/cursor-bridge.ts +26 -6
  27. package/server/lib/cursor-fork-context.ts +140 -0
  28. package/server/lib/cursor-model-catalog.ts +62 -5
  29. package/server/lib/cursor-observer-installer.ts +79 -0
  30. package/server/lib/cursor-session-model.ts +339 -0
  31. package/server/lib/cursor-spawn-env.ts +210 -0
  32. package/server/lib/fork-thread.ts +143 -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 +60 -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 +40 -3
  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 +81 -3
  57. package/server/lib/session-stream-events.ts +32 -1
  58. package/server/lib/session-transcript-watcher.ts +25 -1
  59. package/server/lib/session-work-observation.ts +46 -0
  60. package/server/lib/thread-attach-capability.ts +15 -1
  61. package/server/lib/thread-drain-kick.ts +4 -2
  62. package/server/lib/thread-occupancy.ts +44 -0
  63. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  64. package/server/lib/thread-turn-queue.ts +6 -0
  65. package/server/lib/turn-request.ts +274 -0
  66. package/server/routes/agent-session-bindings.ts +224 -22
  67. package/server/routes/agent-session-stream.ts +26 -7
  68. package/server/routes/agent-sessions.ts +163 -10
  69. package/server/routes/health.ts +27 -2
  70. package/server/routes/session-hooks.ts +60 -3
  71. package/server/routes/thread-turn-queue.ts +37 -9
  72. package/server/scripts/hooks-cli.ts +38 -9
@@ -30,12 +30,53 @@ export const HOOK_EVENT_NAMES = [
30
30
  'Notification',
31
31
  'SubagentStart',
32
32
  'SubagentStop',
33
+ 'PreCompact',
33
34
  'PostCompact',
34
35
  'PostModelSwitch',
36
+ // 6.62.0: Codex fires this when a turn is interrupted (Esc). It closes the turn, as a Stop
37
+ // does. Never in Claude's subscription list: Claude Code has no such hook.
38
+ 'Interrupt',
39
+ // 6.62.0: the Cursor observer's `afterAgentThought`. Display only, like every observer
40
+ // event: its text lives in memory and on the live feed, never in the ledger.
41
+ 'AgentThought',
35
42
  ] as const
36
43
 
37
44
  export type HookEventName = typeof HOOK_EVENT_NAMES[number]
38
45
 
46
+ /**
47
+ * 6.62.0: events the reducer understands that no Claude Code hook fires. They stay OUT of
48
+ * Claude's `HOOK_SUBSCRIPTIONS` (a new Claude block would turn every install into `drift`):
49
+ * `Interrupt` is Codex's, and `AgentThought` is the Cursor observer's.
50
+ */
51
+ export const NON_CLAUDE_HOOK_EVENTS: readonly HookEventName[] = ['Interrupt', 'AgentThought']
52
+
53
+ /**
54
+ * 6.62.0: which engine ran the hook. The Codex command carries `COS_HOOK_PROVIDER=codex`,
55
+ * which the script writes into the envelope; Claude Code and Cursor share the
56
+ * `~/.claude/settings.json` command and are told apart by the payload (`inferHookProvider`).
57
+ */
58
+ export type HookProvider = 'claude' | 'codex' | 'cursor'
59
+
60
+ export function isHookProvider(value: unknown): value is HookProvider {
61
+ return value === 'claude' || value === 'codex' || value === 'cursor'
62
+ }
63
+
64
+ /**
65
+ * The provider of one envelope, in this order (plan 2.1): the envelope's own stamp (the Codex
66
+ * command and the Cursor observer write one); else Cursor's `cursor_version`, which every
67
+ * Cursor payload carries and no other engine's does; else a transcript under
68
+ * `/.codex/sessions/` (a COS Claude block Codex imported runs without the stamp); else Claude.
69
+ */
70
+ export function inferHookProvider(stamped: unknown, payload: Record<string, unknown>): HookProvider {
71
+ if (isHookProvider(stamped)) return stamped
72
+ if ('cursor_version' in payload) return 'cursor'
73
+ if (typeof payload.transcript_path === 'string' && payload.transcript_path.includes('/.codex/sessions/')) return 'codex'
74
+ return 'claude'
75
+ }
76
+
77
+ /** 6.62.0: the cap on an agent thought kept in memory and sent on the live feed. */
78
+ export const AGENT_THOUGHT_MAX = 280
79
+
39
80
  const EVENT_SET: ReadonlySet<string> = new Set(HOOK_EVENT_NAMES)
40
81
 
41
82
  export function isHookEventName(value: unknown): value is HookEventName {
@@ -56,6 +97,13 @@ export interface HookEnvelope {
56
97
  event: HookEventName
57
98
  sessionId: string
58
99
  payload: Record<string, unknown>
100
+ /** 6.62.0: set by `parseHookEnvelope` and a 6.62 ledger row; absent on older rows and hand-built envelopes. */
101
+ provider?: HookProvider
102
+ /**
103
+ * 6.62.0 (QA W2): the envelope itself named the provider: a 6.62 hook script (from the Codex
104
+ * command, or from a Codex payload) or the Cursor observer wrote it. Inferred is not stamped.
105
+ */
106
+ stamped?: true
59
107
  }
60
108
 
61
109
  /**
@@ -110,6 +158,8 @@ export function parseHookEnvelope(text: string): ParsedHookEnvelope {
110
158
  event: r.event,
111
159
  sessionId,
112
160
  payload: p,
161
+ provider: inferHookProvider(r.provider, p),
162
+ ...(isHookProvider(r.provider) ? { stamped: true as const } : {}),
113
163
  },
114
164
  }
115
165
  }
@@ -154,10 +204,14 @@ export function projectHookPayload(event: HookEventName, payload: Record<string,
154
204
  const str = (k: string) => (typeof payload[k] === 'string' ? payload[k] as string : undefined)
155
205
  const base: Record<string, unknown> = {
156
206
  session_id: str('session_id'),
207
+ ...(payload.display_only === true ? { display_only: true } : {}),
157
208
  ...(str('transcript_path') ? { transcript_path: str('transcript_path') } : {}),
158
209
  ...(str('cwd') ? { cwd: str('cwd') } : {}),
159
210
  ...(str('permission_mode') ? { permission_mode: str('permission_mode') } : {}),
160
211
  ...(str('prompt_id') ? { prompt_id: str('prompt_id') } : {}),
212
+ // 6.62.0: Codex stamps every turn-scoped event with its turn id and model.
213
+ ...(str('turn_id') ? { turn_id: str('turn_id') } : {}),
214
+ ...(event !== 'SessionStart' && str('model') ? { model: clipText(payload.model, 100) } : {}),
161
215
  }
162
216
  switch (event) {
163
217
  case 'SessionStart':
@@ -199,7 +253,13 @@ export function projectHookPayload(event: HookEventName, payload: Record<string,
199
253
  return { ...base, agent_id: str('agent_id'), agent_type: str('agent_type') }
200
254
  case 'PostModelSwitch':
201
255
  return { ...base, from_model: str('from_model'), to_model: str('to_model') }
256
+ case 'PreCompact':
202
257
  case 'PostCompact':
258
+ case 'Interrupt':
259
+ return base
260
+ // The thought's TEXT is never kept: the ledger is a 10 MB ring replayed at boot, and a
261
+ // thought can quote anything the agent read. A replayed AgentThought carries nothing.
262
+ case 'AgentThought':
203
263
  return base
204
264
  }
205
265
  }
@@ -7,8 +7,8 @@
7
7
  // applying the same event twice.
8
8
 
9
9
  import { appendFileSync, existsSync, readFileSync, renameSync, statSync } from 'node:fs'
10
- import type { HookEnvelope, HookEventName } from './session-hook-events.js'
11
- import { projectHookPayload, isHookEventName, SESSION_ID_RE } from './session-hook-events.js'
10
+ import type { HookEnvelope, HookEventName, HookProvider } from './session-hook-events.js'
11
+ import { projectHookPayload, isHookEventName, isHookProvider, SESSION_ID_RE } from './session-hook-events.js'
12
12
 
13
13
  export const LEDGER_ROTATE_BYTES = 10 * 1024 * 1024
14
14
 
@@ -22,6 +22,14 @@ export interface LedgerRow {
22
22
  child?: boolean
23
23
  /** The registry's `entrypoint` for the session, once read (6.48.1); a replay restores it. */
24
24
  entrypoint?: string
25
+ /**
26
+ * 6.62.0: the engine, decided at ingest from the full payload (`inferHookProvider`). The
27
+ * projection drops `cursor_version`, so a replay could not tell a Cursor row again without
28
+ * it. A row from an older server has none and replays exactly as it did.
29
+ */
30
+ provider?: HookProvider
31
+ /** 6.62.0 (QA W2): the envelope carried the provider stamp; a replay keeps the evidence. */
32
+ stamped?: true
25
33
  payload: Record<string, unknown>
26
34
  }
27
35
 
@@ -67,6 +75,8 @@ export class SessionHookLedger {
67
75
  session_id: env.sessionId,
68
76
  ...(child ? { child: true } : {}),
69
77
  ...(entrypoint ? { entrypoint } : {}),
78
+ ...(env.provider ? { provider: env.provider } : {}),
79
+ ...(env.stamped ? { stamped: true as const } : {}),
70
80
  payload: projectHookPayload(env.event, env.payload),
71
81
  }
72
82
  } catch (error) {
@@ -119,7 +129,15 @@ export class SessionHookLedger {
119
129
  if (r.ts < sinceMs) continue
120
130
  const payload = r.payload && typeof r.payload === 'object' ? r.payload as Record<string, unknown> : {}
121
131
  const entrypoint = typeof r.entrypoint === 'string' && r.entrypoint ? r.entrypoint : null
122
- apply({ ts: r.ts, ppid: typeof r.ppid === 'number' ? r.ppid : null, event: r.event, sessionId: r.session_id.toLowerCase(), payload: { session_id: r.session_id, ...payload } }, r.key, r.child === true, entrypoint)
132
+ apply({
133
+ ts: r.ts,
134
+ ppid: typeof r.ppid === 'number' ? r.ppid : null,
135
+ event: r.event,
136
+ sessionId: r.session_id.toLowerCase(),
137
+ payload: { session_id: r.session_id, ...payload },
138
+ ...(isHookProvider(r.provider) ? { provider: r.provider } : {}),
139
+ ...(r.stamped === true && isHookProvider(r.provider) ? { stamped: true as const } : {}),
140
+ }, r.key, r.child === true, entrypoint)
123
141
  applied++
124
142
  }
125
143
  return { rows, applied, keys }
@@ -27,7 +27,13 @@ export const REJECTED_KEEP = 1_000
27
27
  export const REJECTED_MAX_AGE_MS = 7 * 24 * 60 * 60_000
28
28
  export const LAST_DRAIN_STAMP = '.last-drain'
29
29
 
30
- const SPOOL_FILE_RE = /^(\d{10,16})-(\d+)-([A-Za-z]+)\.json$/
30
+ /**
31
+ * `<ms>-<pid>-<Event>.json` from the hook script; 6.62.0: also `<ms>-<pid>-<12 hex>-<Event>.json`,
32
+ * the Cursor observer's name since it shipped (6.61.7). The nonce keeps two observer runs in one
33
+ * millisecond apart; until 6.62.0 every one of its files was moved aside as unrecognized.
34
+ * Ordering reads groups 1 and 2 only.
35
+ */
36
+ export const SPOOL_FILE_RE = /^(\d{10,16})-(\d+)(?:-[0-9a-f]{12})?-([A-Za-z]+)\.json$/
31
37
 
32
38
  /** A code, never a message: raw fs messages carry the home path and health is public. */
33
39
  export function errorCode(error: unknown): string {
@@ -38,6 +44,17 @@ export function errorCode(error: unknown): string {
38
44
 
39
45
  /** A hook killed mid-write leaves `.tmp.*`; reap those older than this. */
40
46
  export const TMP_MAX_AGE_MS = 10 * 60_000
47
+ /** 6.62.0: the observer's `.<name>` temp file, left by a run killed between write and rename. */
48
+ export const DOT_TEMP_MAX_AGE_MS = 60 * 60_000
49
+ /**
50
+ * 6.62.0: observer files 6.61.x moved aside as `.unrecognized` are put back ONCE, and only those
51
+ * from the last hour: older ones describe sub-agents and compactions long over (and canary
52
+ * throwaways), and age out with the rest of `rejected/` as before.
53
+ */
54
+ export const REINGEST_MAX_AGE_MS = 60 * 60_000
55
+ export const REINGEST_MARKER = '.reingested-6.62.0'
56
+ /** The window `rejected24h` counts over. */
57
+ export const REJECTED_WINDOW_MS = 24 * 60 * 60_000
41
58
  /** The startup drain never loops more times than a full spool needs. */
42
59
  export const STARTUP_MAX_PASSES = Math.ceil(2_000 / SPOOL_BATCH) + 1
43
60
 
@@ -63,7 +80,12 @@ export interface SpoolIngesterOptions {
63
80
 
64
81
  export interface SpoolStats {
65
82
  ingested: number
83
+ /** Since the process started (cumulative). */
66
84
  rejected: number
85
+ /** 6.62.0: in the last 24 hours, so a fixed cause reads as fixed without a restart. */
86
+ rejected24h: number
87
+ /** 6.62.0: rejected observer files put back into the spool at boot (once per Mac). */
88
+ reingested: number
67
89
  salvaged: number
68
90
  duplicates: number
69
91
  /** Files a sweep visited and could not remove (ledger refused, unreadable). */
@@ -90,7 +112,13 @@ export function startSpoolIngester(options: SpoolIngesterOptions): SpoolIngester
90
112
  const batch = options.batch ?? SPOOL_BATCH
91
113
  const seen = options.seenKeys ?? new Set<string>()
92
114
  const rejectedDir = join(dir, 'rejected')
93
- const stats: SpoolStats = { ingested: 0, rejected: 0, salvaged: 0, duplicates: 0, stuck: 0, foreign: 0, applyErrors: 0, backlog: 0, lastSweepAt: null, lastEventAt: null, lastError: null }
115
+ const stats: SpoolStats = { ingested: 0, rejected: 0, rejected24h: 0, reingested: 0, salvaged: 0, duplicates: 0, stuck: 0, foreign: 0, applyErrors: 0, backlog: 0, lastSweepAt: null, lastEventAt: null, lastError: null }
116
+ const rejectedAt: number[] = []
117
+ const countRejected = () => {
118
+ stats.rejected++
119
+ rejectedAt.push(now())
120
+ if (rejectedAt.length > 10_000) rejectedAt.splice(0, rejectedAt.length - 10_000)
121
+ }
94
122
  let ticking = false
95
123
  let stopped = false
96
124
  let watcher: FSWatcher | null = null
@@ -134,16 +162,22 @@ export function startSpoolIngester(options: SpoolIngesterOptions): SpoolIngester
134
162
  try { if (now() - statSync(join(dir, n)).mtimeMs > TMP_MAX_AGE_MS) unlinkSync(join(dir, n)) } catch { /* fine */ }
135
163
  continue
136
164
  }
165
+ // 6.62.0: the observer writes `.<spool name>` and renames it; one killed in between left
166
+ // it here for good. Only that exact shape, and only once it is an hour old.
167
+ if (n.startsWith('.') && SPOOL_FILE_RE.test(n.slice(1))) {
168
+ try { if (now() - statSync(join(dir, n)).mtimeMs > DOT_TEMP_MAX_AGE_MS) unlinkSync(join(dir, n)) } catch { /* fine */ }
169
+ continue
170
+ }
137
171
  if (n.startsWith('.')) continue
138
172
  if (!n.endsWith('.json')) { foreign++; continue }
139
- try { renameSync(join(dir, n), join(rejectedDir, `${n}.unrecognized`)) ; stats.rejected++ } catch { /* fine */ }
173
+ try { renameSync(join(dir, n), join(rejectedDir, `${n}.unrecognized`)) ; countRejected() } catch { /* fine */ }
140
174
  }
141
175
  stats.foreign = foreign
142
176
  return names.filter(n => SPOOL_FILE_RE.test(n)).sort(spoolOrder)
143
177
  }
144
178
 
145
179
  const reject = (name: string, reason: string) => {
146
- stats.rejected++
180
+ countRejected()
147
181
  try { renameSync(join(dir, name), join(rejectedDir, `${name}.${reason}`)) } catch {
148
182
  try { unlinkSync(join(dir, name)) } catch { /* gone already */ }
149
183
  }
@@ -203,7 +237,8 @@ export function startSpoolIngester(options: SpoolIngesterOptions): SpoolIngester
203
237
 
204
238
  const pruneRejected = () => {
205
239
  let names: string[]
206
- try { names = readdirSync(rejectedDir).sort() } catch { return }
240
+ // The re-ingest marker is not a rejected file: it is never pruned, so the re-ingest stays once.
241
+ try { names = readdirSync(rejectedDir).filter(n => n !== REINGEST_MARKER).sort() } catch { return }
207
242
  const cutoff = now() - REJECTED_MAX_AGE_MS
208
243
  const excess = Math.max(0, names.length - REJECTED_KEEP)
209
244
  names.forEach((n, index) => {
@@ -242,6 +277,29 @@ export function startSpoolIngester(options: SpoolIngesterOptions): SpoolIngester
242
277
  return removed
243
278
  }
244
279
 
280
+ // 6.62.0: put back the observer files 6.61.x could not read (see REINGEST_MAX_AGE_MS), once.
281
+ // Moved, never copied: the normal drain below ingests and deletes them like any other file.
282
+ const reingest = () => {
283
+ const marker = join(rejectedDir, REINGEST_MARKER)
284
+ if (existsSync(marker)) return
285
+ let names: string[] = []
286
+ try { names = readdirSync(rejectedDir) } catch { return }
287
+ const suffix = '.unrecognized'
288
+ for (const n of names) {
289
+ if (!n.endsWith(suffix)) continue
290
+ const original = n.slice(0, -suffix.length)
291
+ if (!SPOOL_FILE_RE.test(original)) continue
292
+ try {
293
+ if (now() - statSync(join(rejectedDir, n)).mtimeMs > REINGEST_MAX_AGE_MS) continue
294
+ if (existsSync(join(dir, original))) continue
295
+ renameSync(join(rejectedDir, n), join(dir, original))
296
+ stats.reingested++
297
+ } catch { /* left where it is; it ages out */ }
298
+ }
299
+ try { writeFileSync(marker, `${new Date(now()).toISOString()} ${stats.reingested}\n`, { mode: 0o600 }) } catch { /* the next boot tries once more */ }
300
+ }
301
+ reingest()
302
+
245
303
  // Startup drain: the backlog, in bounded passes, before anyone asks. Bounded twice: a pass
246
304
  // that removed nothing ends it (a refusing ledger must never spin the boot), and no
247
305
  // backlog needs more passes than a full spool.
@@ -267,6 +325,10 @@ export function startSpoolIngester(options: SpoolIngesterOptions): SpoolIngester
267
325
  clearInterval(timer)
268
326
  try { watcher?.close() } catch { /* fine */ }
269
327
  },
270
- stats: () => ({ ...stats }),
328
+ stats: () => {
329
+ const since = now() - REJECTED_WINDOW_MS
330
+ while (rejectedAt.length > 0 && rejectedAt[0]! < since) rejectedAt.shift()
331
+ return { ...stats, rejected24h: rejectedAt.length }
332
+ },
271
333
  }
272
334
  }
@@ -20,6 +20,7 @@ import { OPEN_TURN_CEILING_MS, deriveSessionState, type DerivedSessionState, typ
20
20
  import { HALT_MARKER_TTL_MS, HALT_REARM_UNVERIFIED_MS, clearHaltMarkerOnSessionEnd, dropHaltRearm, haltDeliveredTurn, rearmHaltOnPrompt, sweepHaltMarkers, type HaltMarker } from './session-halt.js'
21
21
  import { cancelVoidsOpenTurn, threadCancel } from './session-cancel.js'
22
22
  import { ensureHookRuntimeFiles, ensureStableHookScript, hookSpoolDir, hookStatus, type HookStatus } from './claude-hooks-installer.js'
23
+ import { codexHooksHealthField, cursorObserverHealthField, type CodexHooksHealth, type CursorObserverHealth } from './provider-observe.js'
23
24
 
24
25
  export function sessionHooksEnabled(): boolean {
25
26
  const raw = process.env.COS_SESSION_HOOKS
@@ -235,7 +236,7 @@ export function claudeDeskRunning(sessionId: string, now = Date.now(), dir = cla
235
236
  * hooks do not yet show the turn started (its UserPromptSubmit at or after `sentAt`), it is
236
237
  * re-armed once when that prompt's own UserPromptSubmit deletes it (`haltDeliveredTurn`).
237
238
  */
238
- export function haltHandedOffTurn(sessionId: string, marker: HaltMarker, turn: { prompt: string; sentAt: number; unverified?: boolean }, now = Date.now()): boolean {
239
+ export function haltHandedOffTurn(sessionId: string, marker: HaltMarker, turn: { prompt: string; sentAt: number; unverified?: boolean; verbatim?: boolean; deferMarker?: boolean }, now = Date.now()): boolean {
239
240
  const signal = signalFor(sessionId)
240
241
  const started = !!signal && typeof signal.turnStartedAt === 'number' && signal.turnStartedAt >= turn.sentAt
241
242
  return haltDeliveredTurn(sessionId, marker, {
@@ -245,6 +246,10 @@ export function haltHandedOffTurn(sessionId: string, marker: HaltMarker, turn: {
245
246
  now,
246
247
  // 6.53.3 /qa: an unverified hand-off (`maybe`) holds its re-arm two minutes, not an hour.
247
248
  windowMs: turn.unverified === true ? HALT_REARM_UNVERIFIED_MS : HALT_MARKER_TTL_MS,
249
+ // 6.62.0 (plan 3.6): a Codex turn queued into the app is matched verbatim.
250
+ verbatim: turn.verbatim === true,
251
+ // 6.62.0 /qa (W3): a busy hop's marker waits for the delivered turn's own prompt.
252
+ deferMarker: turn.deferMarker === true,
248
253
  })
249
254
  }
250
255
 
@@ -277,7 +282,8 @@ export function startSessionHooksRuntime(options: { port: number }): SessionHook
277
282
  ledger,
278
283
  seenKeys: replay.keys,
279
284
  isChild: env => isCosSpawnedPid(env.ppid),
280
- entrypointOf: enabled ? (env, child) => entrypointFor(env.sessionId, child) : undefined,
285
+ // 6.62.0: only a Claude session has a `~/.claude/sessions` record to read.
286
+ entrypointOf: enabled ? (env, child) => (env.provider === undefined || env.provider === 'claude' ? entrypointFor(env.sessionId, child) : null) : undefined,
281
287
  apply: enabled ? (env, child, entrypoint) => {
282
288
  sessionSignalStore.apply(env, child)
283
289
  if (entrypoint) sessionSignalStore.setEntrypoint(env.sessionId, entrypoint)
@@ -354,6 +360,14 @@ export interface SessionHooksHealth {
354
360
  signals: number
355
361
  replayed: { rows: number; applied: number } | null
356
362
  spool: SpoolStats | null
363
+ /**
364
+ * 6.62.0: the Codex hooks, APART from the Claude fields above (B2): `state`, `installed` and
365
+ * `hookHaltReady` stay Claude's alone, because Control 0.5.254 keys its banner and its
366
+ * Install check off them. No Codex banner this release.
367
+ */
368
+ codex: CodexHooksHealth
369
+ /** 6.62.0: the native Cursor observer (`~/.cursor/hooks.json`). */
370
+ cursorObserver: CursorObserverHealth
357
371
  }
358
372
 
359
373
  // Health is polled by three clients every few seconds; the status read (settings parse,
@@ -397,6 +411,8 @@ export function sessionHooksHealthFields(): { sessionHooks: SessionHooksHealth }
397
411
  signals: sessionSignalStore.size(),
398
412
  replayed,
399
413
  spool,
414
+ codex: codexHooksHealthField(),
415
+ cursorObserver: cursorObserverHealthField(),
400
416
  },
401
417
  }
402
418
  }
@@ -404,7 +420,28 @@ export function sessionHooksHealthFields(): { sessionHooks: SessionHooksHealth }
404
420
  /** Signal for a row id that may be the registry's eight-character form. */
405
421
  export function signalFor(sessionId: string): SessionSignal | undefined {
406
422
  if (!sessionHooksEnabled()) return undefined
407
- return sessionId.length >= 36 ? sessionSignalStore.get(sessionId) : sessionSignalStore.getByPrefix(sessionId)
423
+ const signal = sessionId.length >= 36 ? sessionSignalStore.get(sessionId) : sessionSignalStore.getByPrefix(sessionId)
424
+ return signal?.observationOnly ? undefined : signal
425
+ }
426
+
427
+ /**
428
+ * 6.62.0 (QA W2): has a hook event of this engine reached COS for this thread? The positive
429
+ * evidence a desk cancel needs before it may be offered for Codex or Cursor. Never an observer
430
+ * event (those prove the observer runs, not the halt check):
431
+ * codex an event the Codex command's script STAMPED (a 6.62 script, whose halt reply Codex
432
+ * honours; an inferred-only Codex event came from an older script that fails open)
433
+ * cursor an event from the Claude-compatible hook Cursor runs (its own `cursor_version`)
434
+ */
435
+ export function providerHookSeen(provider: 'codex' | 'cursor', threadId: string): boolean {
436
+ const signal = signalFor(threadId)
437
+ if (!signal || signal.provider !== provider) return false
438
+ return provider === 'codex' ? typeof signal.stampedAt === 'number' : true
439
+ }
440
+
441
+ /** Display observers do not create hook occupancy or permission evidence. */
442
+ export function workObservationFor(sessionId: string) {
443
+ if (!sessionHooksEnabled()) return undefined
444
+ return (sessionId.length >= 36 ? sessionSignalStore.get(sessionId) : sessionSignalStore.getByPrefix(sessionId))?.workObservation
408
445
  }
409
446
 
410
447
  /** 6.60.0: told when a row derives `ended`, so the broker stops saying the Mac is waiting on it. */
@@ -19,12 +19,13 @@ import {
19
19
  } from './session-peer-inbox.js'
20
20
 
21
21
  /**
22
- * `COS_CONTINUE_LIVE`. OFF by default in 6.49.0.
22
+ * `COS_CONTINUE_LIVE`. ON unless exactly '0' (6.62.0, D3).
23
23
  *
24
- * The transport rides a protocol Claude Code documents in a help string, not a
25
- * contract, and the first days are for reading the health row on one Mac. A literal
26
- * '1' turns it on; anything else, including absence, keeps every Continue on the
27
- * 6.48.2 path.
24
+ * 6.49.0 shipped it off: the transport rides a protocol Claude Code documents in a help
25
+ * string, not a contract, and the first days were for reading the health row on one Mac.
26
+ * Those days are behind it (Miles's Mac has run it on since 6.49; measured on Claude Code
27
+ * 2.1.289), and a Continue that lands in the open session is the desk behaviour. '0' keeps
28
+ * every Continue on the 6.48.2 spawn path; every refusal still falls back there anyway.
28
29
  *
29
30
  * WHERE TO SET IT. `~/.cos-glasses/.env` (read at boot by `env.ts`, the plist wins
30
31
  * when both carry it). Control's `providerEnvironmentKeys` allowlist does NOT carry
@@ -32,7 +33,7 @@ import {
32
33
  * by the next Install/Repair/Update Server; Control 0.5.234 is to add it.
33
34
  */
34
35
  export function continueLiveEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
35
- return env.COS_CONTINUE_LIVE === '1'
36
+ return env.COS_CONTINUE_LIVE !== '0'
36
37
  }
37
38
 
38
39
  /** Registry files are small; anything larger is not one. */
@@ -27,8 +27,9 @@ const EXACT = "Which one of the user's agent sessions is already doing this exac
27
27
  + 'A session on the same project doing different work does not count. Choose none if no session is doing this task.'
28
28
 
29
29
  /** Providers a Work handoff can fork natively (Control 0.5.243 applies a Fork suggestion as a same-platform fork).
30
- * One list with the fork route (agent-session-binding-store FORKABLE_PROVIDERS). */
31
- export const FORKABLE = new Set<string>(FORKABLE_PROVIDERS)
30
+ * 6.62.0 (W9): the fork route also serves Cursor ("New session with this context"), but Control 0.5.254 has no
31
+ * Cursor fork plan, so the recommender keeps to Claude and Codex until the Control release. */
32
+ export const FORKABLE = new Set<string>(FORKABLE_PROVIDERS.filter(provider => provider !== 'cursor'))
32
33
  const clip = (text: string | undefined, max: number) => (text ?? '').replace(/\s+/g, ' ').trim().slice(0, max)
33
34
  const round = (n: number) => Math.round(n * 100) / 100
34
35
 
@@ -1,3 +1,4 @@
1
+ import { observeWork, type WorkObservation } from './session-work-observation.js'
1
2
  // The signal store: what the hooks have said about each Claude session, reduced to
2
3
  // one record per full session id.
3
4
  //
@@ -24,8 +25,8 @@
24
25
  // 3. `ended` is recorded here but RANKED in the deriver, which also sees the registry:
25
26
  // an ended signal with an alive registry record is a child that ended, not the tab.
26
27
 
27
- import type { HookEnvelope, HookEventName } from './session-hook-events.js'
28
- import { clipText, toolFingerprint, toolTarget } from './session-hook-events.js'
28
+ import type { HookEnvelope, HookEventName, HookProvider } from './session-hook-events.js'
29
+ import { AGENT_THOUGHT_MAX, clipText, toolFingerprint, toolTarget } from './session-hook-events.js'
29
30
  import { isKeepWarmSessionTitle } from './agent-session-store.js'
30
31
 
31
32
  export type WaitingKind = 'permission' | 'question' | 'plan' | 'mcp_input'
@@ -48,6 +49,23 @@ export interface FailureSignal {
48
49
  }
49
50
 
50
51
  export interface SessionSignal {
52
+ workObservation?: WorkObservation
53
+ observationOnly?: boolean
54
+ /**
55
+ * 6.62.0: the engine whose hooks these are (`inferHookProvider`). Absent on a record built
56
+ * from envelopes that never carried one (an older ledger row), which is read as Claude.
57
+ */
58
+ provider?: HookProvider
59
+ /**
60
+ * 6.62.0: the newest agent thought the Cursor observer reported, capped at
61
+ * AGENT_THOUGHT_MAX. MEMORY ONLY: the ledger keeps no thought text, so a restart forgets it.
62
+ */
63
+ lastThought?: { text: string; at: number }
64
+ /**
65
+ * 6.62.0 (QA W2): the newest phase event (never an observer one) whose envelope the hook
66
+ * script stamped with its provider: proof a 6.62 script is running this session's hooks.
67
+ */
68
+ stampedAt?: number
51
69
  sessionId: string
52
70
  firstSeenAt: number
53
71
  lastEventAt: number
@@ -104,6 +122,7 @@ function toolFacts(p: Record<string, unknown>): { name: string; target: string;
104
122
 
105
123
  function fresh(env: HookEnvelope): SessionSignal {
106
124
  return {
125
+ ...(env.provider ? { provider: env.provider } : {}),
107
126
  sessionId: env.sessionId,
108
127
  firstSeenAt: env.ts,
109
128
  lastEventAt: env.ts,
@@ -133,6 +152,25 @@ function fresh(env: HookEnvelope): SessionSignal {
133
152
 
134
153
  const str = (v: unknown): string | null => (typeof v === 'string' && v.length > 0 ? v : null)
135
154
 
155
+ /**
156
+ * Observer evidence: never a phase, never a listener. 6.62.0: an AgentThought is display only
157
+ * whatever its payload says, so a thought can never open, close or reopen a turn.
158
+ */
159
+ export function isDisplayOnly(env: HookEnvelope): boolean {
160
+ return env.payload.display_only === true || env.event === 'AgentThought'
161
+ }
162
+
163
+ /**
164
+ * 6.62.0: an AgentThought with text becomes the record's `lastThought`, unless an older one
165
+ * arrived late. A replayed row carries no text (the ledger drops it) and changes nothing.
166
+ */
167
+ function thoughtOf(base: SessionSignal, env: HookEnvelope): Pick<SessionSignal, 'lastThought'> {
168
+ if (env.event !== 'AgentThought') return {}
169
+ const text = clipText(env.payload.text, AGENT_THOUGHT_MAX)
170
+ if (!text || (base.lastThought && base.lastThought.at > env.ts)) return {}
171
+ return { lastThought: { text, at: env.ts } }
172
+ }
173
+
136
174
  /**
137
175
  * 6.48.1. A tool runs only inside a turn, so a MAIN-THREAD tool event (no `agent_id`) is
138
176
  * evidence the turn is open even when its UserPromptSubmit was never seen: a tab adopted
@@ -178,8 +216,13 @@ function resolvesWaiting(waiting: WaitingSignal, event: HookEventName, toolName:
178
216
  */
179
217
  export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelope, ctx: ReducerContext, child?: boolean): SessionSignal {
180
218
  const base = prev ? { ...prev } : fresh(env)
219
+ if (env.provider) base.provider = env.provider
181
220
  const p = env.payload
221
+ if (isDisplayOnly(env)) return { ...base, ...(prev?.observationOnly !== false ? { lastEventAt: Math.max(base.lastEventAt, env.ts) } : {}), observationOnly: prev?.observationOnly ?? !prev, workObservation: observeWork(base.workObservation, env), ...thoughtOf(base, env) }
182
222
  const common = {
223
+ observationOnly: false,
224
+ ...(env.stamped ? { stampedAt: Math.max(base.stampedAt ?? 0, env.ts) } : {}),
225
+ workObservation: observeWork(base.workObservation, env),
183
226
  cwd: str(p.cwd) ?? base.cwd,
184
227
  transcriptPath: str(p.transcript_path) ?? base.transcriptPath,
185
228
  permissionMode: str(p.permission_mode) ?? base.permissionMode,
@@ -206,6 +249,11 @@ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelop
206
249
  const prompt = clipText(p.prompt)
207
250
  return {
208
251
  ...next,
252
+ // 6.62.0: Codex and Cursor have no registry to say a session lives on after its
253
+ // SessionEnd (a composer the hooks saw end and then reopened; a Codex thread resumed
254
+ // in the app without a SessionStart). A prompt after the end is a new life. Claude's
255
+ // own resume fires SessionStart, so its rows keep the 6.61 rule.
256
+ ...(next.provider === 'codex' || next.provider === 'cursor' ? { ended: null } : {}),
209
257
  turnOpen: true,
210
258
  turnStartedAt: env.ts,
211
259
  promptId: str(p.prompt_id),
@@ -274,6 +322,11 @@ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelop
274
322
  waiting: null,
275
323
  lastReply: clipText(p.last_assistant_message) || next.lastReply,
276
324
  }
325
+ // 6.62.0: Codex's Interrupt (Esc) ends the turn with no Stop and no reply.
326
+ case 'Interrupt':
327
+ return { ...next, turnOpen: false, stopAt: env.ts, waiting: null }
328
+ case 'AgentThought': // display only (`isDisplayOnly`); never reaches here
329
+ return next
277
330
  case 'StopFailure':
278
331
  return {
279
332
  ...next,
@@ -285,6 +338,8 @@ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelop
285
338
  return { ...next, subagentsOpen: next.subagentsOpen + 1 }
286
339
  case 'SubagentStop':
287
340
  return { ...next, subagentsOpen: Math.max(0, next.subagentsOpen - 1) }
341
+ case 'PreCompact':
342
+ return next
288
343
  case 'PostCompact':
289
344
  return { ...next, compactions: next.compactions + 1 }
290
345
  case 'PostModelSwitch':
@@ -295,6 +350,8 @@ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelop
295
350
  }
296
351
 
297
352
  export type SignalListener = (signal: SessionSignal, env: HookEnvelope, child: boolean) => void
353
+ /** 6.62.0: told of each new agent thought (display only; the drain, permission and halt listeners never are). */
354
+ export type ThoughtListener = (signal: SessionSignal, thought: { text: string; at: number }) => void
298
355
 
299
356
  /** Records older than this after a SessionEnd are dropped; Control keeps its own ledger. */
300
357
  export const SIGNAL_PRUNE_AFTER_END_MS = 6 * 60 * 60_000
@@ -304,6 +361,7 @@ export const SIGNAL_PRUNE_SILENT_MS = 24 * 60 * 60_000
304
361
  export class SessionSignalStore {
305
362
  private readonly signals = new Map<string, SessionSignal>()
306
363
  private readonly listeners = new Set<SignalListener>()
364
+ private readonly thoughtListeners = new Set<ThoughtListener>()
307
365
  private readonly ctx: ReducerContext
308
366
 
309
367
  constructor(ctx: ReducerContext) {
@@ -311,8 +369,21 @@ export class SessionSignalStore {
311
369
  }
312
370
 
313
371
  apply(env: HookEnvelope, child?: boolean): SessionSignal {
314
- const next = applyHookEvent(this.signals.get(env.sessionId), env, this.ctx, child)
372
+ const prev = this.signals.get(env.sessionId)
373
+ const next = applyHookEvent(prev, env, this.ctx, child)
315
374
  this.signals.set(env.sessionId, next)
375
+ if (isDisplayOnly(env)) {
376
+ // No drain, permission or halt listeners. A NEW thought reaches the live feed only.
377
+ const thought = next.lastThought
378
+ if (env.event === 'AgentThought' && thought && thought !== prev?.lastThought) {
379
+ for (const listener of this.thoughtListeners) {
380
+ try { listener(next, thought) } catch (error) {
381
+ console.error(`[session-signals] thought listener failed: ${error instanceof Error ? error.message : error}`)
382
+ }
383
+ }
384
+ }
385
+ return next
386
+ }
316
387
  const isChild = child ?? this.ctx.isCosSpawnedPid(env.ppid)
317
388
  for (const listener of this.listeners) {
318
389
  try { listener(next, env, isChild) } catch (error) {
@@ -383,6 +454,12 @@ export class SessionSignalStore {
383
454
  return () => { this.listeners.delete(listener) }
384
455
  }
385
456
 
457
+ /** 6.62.0: new agent thoughts, for the live feed. Display only. */
458
+ subscribeThoughts(listener: ThoughtListener): () => void {
459
+ this.thoughtListeners.add(listener)
460
+ return () => { this.thoughtListeners.delete(listener) }
461
+ }
462
+
386
463
  /** Drop records that ended long ago, and records silent for a day (a tab that died with no SessionEnd). */
387
464
  prune(nowMs = Date.now()): number {
388
465
  let dropped = 0
@@ -417,5 +494,6 @@ export class SessionSignalStore {
417
494
  __resetForTests(): void {
418
495
  this.signals.clear()
419
496
  this.listeners.clear()
497
+ this.thoughtListeners.clear()
420
498
  }
421
499
  }
@@ -565,6 +565,37 @@ export function promptDrafts(message: Record<string, unknown>): SessionStreamDra
565
565
  return [{ kind: 'prompt', text: flat }]
566
566
  }
567
567
 
568
+ /**
569
+ * Cursor (6.62.0, plan 1.5): the user's words are the inside of `<user_query>`; the rest of a
570
+ * Cursor user row is scaffolding (attached files, rules). Until 6.62.0 the whole row was
571
+ * dropped, so a Cursor feed never showed what it was working on. Everything else is the
572
+ * Claude-shaped content the two share.
573
+ */
574
+ function draftsFromCursorRecord(record: Record<string, unknown>): SessionStreamDraft[] {
575
+ if (record.role === 'user' && record.type === undefined) {
576
+ const message = asRecord(record.message)
577
+ if (!message) return []
578
+ const blocks = Array.isArray(message.content) ? message.content : typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : []
579
+ if (blocks.some(b => asRecord(b)?.type === 'tool_result')) return []
580
+ const text = blocks.map(b => asRecord(b)).filter(b => b && (b.type === 'text' || b.type === undefined) && typeof b.text === 'string').map(b => b!.text as string).join(' ')
581
+ const start = text.indexOf('<user_query>')
582
+ const end = text.indexOf('</user_query>')
583
+ const query = start >= 0 && end > start ? text.slice(start + '<user_query>'.length, end) : text
584
+ return promptDrafts({ content: [{ type: 'text', text: query }] })
585
+ }
586
+ return draftsFromClaudeRecord(record)
587
+ }
588
+
589
+ /**
590
+ * Cursor (6.62.0, plan 1.7): the observer's newest agent thought, as the reasoning line. The
591
+ * same shape Codex's reasoning headline rides (`CodexReasoningStatus`): an extra field on a
592
+ * `status: working` draft, one line, as long as a target.
593
+ */
594
+ export function cursorThoughtStatus(text: unknown): CodexReasoningStatus | null {
595
+ const reasoning = typeof text === 'string' ? oneLine(text, TARGET_MAX_CHARS) : ''
596
+ return reasoning ? { kind: 'status', state: 'working', reasoning } : null
597
+ }
598
+
568
599
  function draftsFromClaudeRecord(record: Record<string, unknown>): SessionStreamDraft[] {
569
600
  const type = typeof record.type === 'string' ? record.type : ''
570
601
 
@@ -1262,7 +1293,7 @@ export function draftsFromRecord(
1262
1293
  const obj = asRecord(record)
1263
1294
  if (!obj) return []
1264
1295
  try {
1265
- const drafts = provider === 'codex' ? draftsFromCodexRecord(obj) : draftsFromClaudeRecord(obj)
1296
+ const drafts = provider === 'codex' ? draftsFromCodexRecord(obj) : provider === 'cursor' ? draftsFromCursorRecord(obj) : draftsFromClaudeRecord(obj)
1266
1297
  return stampPromptTurnStart(drafts, obj)
1267
1298
  } catch {
1268
1299
  // A malformed record costs one line of the trail. It must never cost the stream.