@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
@@ -53,7 +53,8 @@ import { basename, join, resolve } from 'node:path'
53
53
  import { NATIVE_THREAD_ID_RE } from './native-thread-id.js'
54
54
  import { transcriptPathFor, type NativeHeadDeps } from './native-head.js'
55
55
  import { parseProcStartUtcMs, type OccupancyDirs, type OccupancyProbes } from './thread-occupancy.js'
56
- import { resolveCursorAgentSession } from './cursor-agent-store.js'
56
+ import { cursorTranscriptPath, resolveCursorAgentSession } from './cursor-agent-store.js'
57
+ import { activeSqliteReaderPids, cliChatLastWriteMs } from './cursor-session-model.js'
57
58
 
58
59
  // Re-exported rather than reimplemented. `claudeSessionsDir` already encodes the
59
60
  // COS_CLAUDE_SESSIONS_DIR -> CLAUDE_CONFIG_DIR -> ~/.claude precedence AND is the
@@ -348,6 +349,39 @@ export function readFile(path: string): string | null {
348
349
  * other id validator (`SAFE_ID_RE`) permits `/` and `.`. If that validation is
349
350
  * ever dropped upstream, this refuses rather than probing an arbitrary path.
350
351
  */
352
+ /**
353
+ * 6.62.0 /qa (W7): one bounded `lsof` on a Cursor CLI chat's `store.db`. A process that
354
+ * holds it open and is not COS (not the server, not a COS child, not one of COS's own
355
+ * short sqlite3 reads) is a run working that chat right now, at the desk or in a terminal
356
+ * `agent`, even when it has written nothing for 30 s. Null: lsof could not tell (timeout,
357
+ * missing, refused); the caller then falls back to the 30 s write rule and this logs it.
358
+ */
359
+ export const CURSOR_CHAT_HOLDER_TIMEOUT_MS = 3_000
360
+ export const cursorChatHolderStats = { probes: 0, held: 0, failed: 0 }
361
+
362
+ export function cursorChatForeignHolder(
363
+ chatDir: string,
364
+ ownPids: () => Iterable<number>,
365
+ run: (bin: string, args: string[], timeoutMs: number) => ProbeOutcome = runProbe,
366
+ ): boolean | null {
367
+ if (typeof chatDir !== 'string' || !chatDir.startsWith('/') || chatDir.includes('\0')) return null
368
+ const path = join(chatDir, 'store.db')
369
+ cursorChatHolderStats.probes += 1
370
+ let pids: number[]
371
+ try {
372
+ pids = interpretLockHolders(run(LSOF_BIN, ['-w', '-t', '--', path], CURSOR_CHAT_HOLDER_TIMEOUT_MS), path)
373
+ } catch (error) {
374
+ cursorChatHolderStats.failed += 1
375
+ console.warn(`[occupancy-probes] cursor chat holder probe failed, the 30 s write rule decides: ${error instanceof Error ? error.message : error}`)
376
+ return null
377
+ }
378
+ const own = new Set<number>([process.pid])
379
+ try { for (const pid of ownPids()) own.add(pid) } catch { /* the ledger is advisory here */ }
380
+ const held = pids.some(pid => !own.has(pid))
381
+ if (held) cursorChatHolderStats.held += 1
382
+ return held
383
+ }
384
+
351
385
  export function lockHolders(path: string): number[] {
352
386
  if (typeof path !== 'string' || path.length === 0 || path.includes('\0')) {
353
387
  throw new Error('lockHolders: refusing an unusable path')
@@ -728,6 +762,16 @@ export function realOccupancyProbes(ledger: SpawnLedgerAccessor): OccupancyProbe
728
762
  lockHolders,
729
763
  cosSpawnedPids: () => sanitizeLedger(ledger()),
730
764
  cursorAgentSession: (threadId, chatsDir) => resolveCursorAgentSession(threadId, chatsDir),
765
+ // 6.62.0 /qa (W7): positive evidence first: a non-COS process holding the chat open.
766
+ cursorChatForeignHolder: chatDir => cursorChatForeignHolder(chatDir, () => [
767
+ ...sanitizeLedger(ledger()).keys(),
768
+ ...activeSqliteReaderPids(),
769
+ ]),
770
+ // 6.62.0 (W14): the CLI chat busy window reads the chat's own files.
771
+ cursorChatLastWriteMs: (chatDir, threadId) => cliChatLastWriteMs(
772
+ chatDir,
773
+ cursorTranscriptPath(threadId, join(process.env.COS_AGENT_SESSIONS_HOME?.trim() || homedir(), '.cursor', 'projects')),
774
+ ),
731
775
  // transcriptMtimeMs is DELIBERATELY absent here. See `withTranscriptClock`.
732
776
  }
733
777
  }
@@ -112,7 +112,7 @@
112
112
  import { execFile } from 'node:child_process'
113
113
  import { randomBytes } from 'node:crypto'
114
114
  import { boundForRedaction } from './activity-preview.js'
115
- import { toolFingerprint, SESSION_ID_RE, type HookEnvelope } from './session-hook-events.js'
115
+ import { inferHookProvider, toolFingerprint, SESSION_ID_RE, type HookEnvelope } from './session-hook-events.js'
116
116
  import { ASK_USER_QUESTION_TOOL, type SessionSignalStore } from './session-signal-store.js'
117
117
 
118
118
  // ---------------------------------------------------------------------------
@@ -302,6 +302,11 @@ export interface PermissionRequestFacts {
302
302
  * received it (its second argument). Null for an earlier script or block: `holdCeilingS`.
303
303
  */
304
304
  hookWaitS: number | null
305
+ /**
306
+ * 6.62.0 (plan 3.10): the engine that asked, from the envelope's stamp (the Codex command
307
+ * writes one) or the payload. Cursor never reaches here (`cursor` refusal above).
308
+ */
309
+ provider?: 'claude' | 'codex'
305
310
  }
306
311
 
307
312
  export type EnvelopeVerdict =
@@ -329,7 +334,7 @@ function readPermissionRequestEnvelope(body: unknown): EnvelopeRead {
329
334
  const hookWaitS = typeof wait === 'number' && Number.isInteger(wait) && wait > 0 && wait <= HOOK_WAIT_STAMP_MAX_S ? wait : null
330
335
  return {
331
336
  ok: true,
332
- facts: { sessionId: p.session_id.toLowerCase(), toolName: p.tool_name, toolInput: p.tool_input, hookStartedAtMs: ts, hookWaitS },
337
+ facts: { sessionId: p.session_id.toLowerCase(), toolName: p.tool_name, toolInput: p.tool_input, hookStartedAtMs: ts, hookWaitS, provider: inferHookProvider(body.provider, p) === 'codex' ? 'codex' : 'claude' },
333
338
  }
334
339
  }
335
340
 
@@ -457,9 +462,16 @@ export const CANCEL_DENY_MESSAGE = 'Cancelled from COS'
457
462
  * 6.53.0: a held prompt whose run was cancelled from the lens. Deny with `interrupt: true`,
458
463
  * so Claude ends the RUN rather than trying something else in place of this tool. The one
459
464
  * case where a broker reply interrupts, and only ever from `cancelSession`.
465
+ *
466
+ * 6.62.0 (W20, C2): never for Codex. Its PermissionRequest refuses `interrupt` (it fails
467
+ * closed on the field), so a Codex prompt gets the plain deny; the halt marker stops the run
468
+ * at its next command.
460
469
  */
461
- export function cancelHookOutput(): Record<string, unknown> {
462
- return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'deny', message: CANCEL_DENY_MESSAGE, interrupt: true } } }
470
+ export function cancelHookOutput(provider: 'claude' | 'codex' = 'claude'): Record<string, unknown> {
471
+ const decision = provider === 'codex'
472
+ ? { behavior: 'deny', message: CANCEL_DENY_MESSAGE }
473
+ : { behavior: 'deny', message: CANCEL_DENY_MESSAGE, interrupt: true }
474
+ return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision } }
463
475
  }
464
476
 
465
477
  /** A tool approval: allow once, or deny with the one message. Never a rule. */
@@ -819,6 +831,8 @@ type Chosen = { answers: Record<string, string[]> } | { decision: 'allow' | 'den
819
831
  interface BrokerItem {
820
832
  id: string
821
833
  sessionId: string
834
+ /** 6.62.0: the engine that asked (plan 3.10); every view says it. */
835
+ provider: 'claude' | 'codex'
822
836
  kind: BrokerItemKind
823
837
  toolName: string
824
838
  toolInput: Record<string, unknown>
@@ -849,7 +863,7 @@ interface BrokerItem {
849
863
  export interface SessionQuestionView {
850
864
  id: string
851
865
  sessionId: string
852
- provider: 'claude'
866
+ provider: 'claude' | 'codex'
853
867
  kind: BrokerItemKind
854
868
  tool: string
855
869
  createdAt: string
@@ -872,7 +886,7 @@ export interface SessionQuestionView {
872
886
  export interface SettledQuestionView {
873
887
  id: string
874
888
  sessionId: string
875
- provider: 'claude'
889
+ provider: 'claude' | 'codex'
876
890
  kind: BrokerItemKind
877
891
  tool: string
878
892
  createdAt: string
@@ -1213,6 +1227,7 @@ export class PermissionBroker {
1213
1227
  const item: BrokerItem = {
1214
1228
  id,
1215
1229
  sessionId: facts.sessionId,
1230
+ provider: facts.provider ?? 'claude',
1216
1231
  kind: request.kind,
1217
1232
  toolName: facts.toolName,
1218
1233
  // Only a question needs its input again (the answer is built on it). An approval's
@@ -1289,7 +1304,7 @@ export class PermissionBroker {
1289
1304
  views.push({
1290
1305
  id: item.id,
1291
1306
  sessionId: item.sessionId,
1292
- provider: 'claude',
1307
+ provider: item.provider,
1293
1308
  kind: item.kind,
1294
1309
  tool: item.toolName,
1295
1310
  createdAt: new Date(item.createdAt).toISOString(),
@@ -1321,7 +1336,7 @@ export class PermissionBroker {
1321
1336
  return listed.map(item => ({
1322
1337
  id: item.id,
1323
1338
  sessionId: item.sessionId,
1324
- provider: 'claude' as const,
1339
+ provider: item.provider,
1325
1340
  kind: item.kind,
1326
1341
  tool: item.toolName,
1327
1342
  createdAt: new Date(item.createdAt).toISOString(),
@@ -1444,7 +1459,7 @@ export class PermissionBroker {
1444
1459
  let denied = 0
1445
1460
  for (const item of [...this.items.values()]) {
1446
1461
  if (item.state !== 'pending' || item.sessionId !== wanted) continue
1447
- if (!item.channel || !item.channel.writable() || !item.channel.reply(cancelHookOutput())) {
1462
+ if (!item.channel || !item.channel.writable() || !item.channel.reply(cancelHookOutput(item.provider))) {
1448
1463
  this.settle(item, 'hook_gone')
1449
1464
  continue
1450
1465
  }
@@ -0,0 +1,149 @@
1
+ // What each provider can DO through COS (6.62.0, plan 3.11): `providers.<p>.act`.
2
+ //
3
+ // One object per provider, published on /api/health and /api/models (the phone reads the
4
+ // second). Every entry is `{ supported }`, and every `supported: false` carries a reason
5
+ // code, so a client never has to invent copy for a missing capability. Computed per request
6
+ // from the same live flags the routes decide with; this module decides nothing itself.
7
+ //
8
+ // The observation half (`providers.<p>.observe`, plan 1.10) is published by the hooks
9
+ // work; `mergeProviderSections` joins the two under one `providers` key so neither
10
+ // spread can overwrite the other.
11
+
12
+ import { continueFullPermissionsEnabled } from './continue-plan.js'
13
+ import { permissionBrokerHealthFields } from './permission-broker.js'
14
+ import { continueLiveEnabled } from './session-peer-inbox-deps.js'
15
+ import { codexLiveQueueEnabled } from './codex-live-queue.js'
16
+
17
+ export type ProviderName = 'claude' | 'codex' | 'cursor'
18
+
19
+ export interface ProviderCapability {
20
+ supported: boolean
21
+ /** Present exactly when `supported` is false: a stable, enum-like code. */
22
+ reason?: string
23
+ /** Optional qualifier on a supported entry (`new_session`, `next_command`). */
24
+ mode?: string
25
+ }
26
+
27
+ export interface ProviderAct {
28
+ continueIdle: ProviderCapability
29
+ continueBusy: ProviderCapability
30
+ fork: ProviderCapability
31
+ cancelDesk: ProviderCapability
32
+ approvals: ProviderCapability
33
+ nativeQueue: ProviderCapability
34
+ keepModel: ProviderCapability
35
+ permissions: ProviderCapability
36
+ }
37
+
38
+ export interface ProviderActInput {
39
+ /** `threadAttachCapability().enabled`: the write routes exist. */
40
+ attachEnabled: boolean
41
+ /** `threadForkProviders`. */
42
+ forkProviders: readonly string[]
43
+ /** `features.sessionCancel`, whatever keys this build publishes there. */
44
+ sessionCancel: Record<string, unknown> | null | undefined
45
+ /** The permission broker is on (`permissionBroker.enabled`). */
46
+ brokerEnabled: boolean
47
+ /** `COS_CONTINUE_LIVE` (Claude's live hop). */
48
+ continueLive: boolean
49
+ /** `COS_CODEX_LIVE_QUEUE` (Codex's app-queue hop, idle and busy). */
50
+ codexLiveQueue: boolean
51
+ env?: NodeJS.ProcessEnv
52
+ }
53
+
54
+ const yes = (mode?: string): ProviderCapability => (mode ? { supported: true, mode } : { supported: true })
55
+ const no = (reason: string): ProviderCapability => ({ supported: false, reason })
56
+
57
+ /** The three `act` objects. Pure over its input. */
58
+ export function providerActs(input: ProviderActInput): Record<ProviderName, ProviderAct> {
59
+ const full = continueFullPermissionsEnabled(input.env)
60
+ const attach = input.attachEnabled === true
61
+ const cancel = input.sessionCancel ?? {}
62
+ const continueIdle = (mode?: string) => (attach ? yes(mode) : no('attach_disabled'))
63
+ const fork = (provider: ProviderName, mode?: string) =>
64
+ (input.forkProviders.includes(provider) ? yes(mode) : no('fork_unsupported'))
65
+ const desk = (key: string) => (cancel[key] === true ? yes('next_command') : no('hooks_not_ready'))
66
+ return {
67
+ claude: {
68
+ continueIdle: continueIdle(input.continueLive ? 'live' : 'resume'),
69
+ // A busy Claude session takes no outside turn mid-run; the turn waits for its Stop.
70
+ continueBusy: no('queued_until_turn_end'),
71
+ fork: fork('claude'),
72
+ cancelDesk: desk('deskClaude'),
73
+ approvals: input.brokerEnabled ? yes() : no('broker_off'),
74
+ nativeQueue: no('engine_limit'),
75
+ keepModel: yes(),
76
+ permissions: yes('session'),
77
+ },
78
+ codex: {
79
+ continueIdle: continueIdle(input.codexLiveQueue ? 'app_queue' : 'resume'),
80
+ continueBusy: !attach ? no('attach_disabled') : input.codexLiveQueue ? yes('app_queue') : no('codex_live_queue_off'),
81
+ fork: fork('codex'),
82
+ cancelDesk: desk('deskCodex'),
83
+ // The broker takes Codex PermissionRequests; Desktop threads run approval `never`.
84
+ approvals: input.brokerEnabled ? yes('on_request_only') : no('broker_off'),
85
+ nativeQueue: yes(),
86
+ keepModel: full ? yes() : no('full_permissions_off'),
87
+ permissions: full ? yes('session_posture') : no('full_permissions_off'),
88
+ },
89
+ cursor: {
90
+ // An idle IDE composer cannot take a message from outside; a CLI chat can.
91
+ continueIdle: attach ? yes('cli_chat_only') : no('attach_disabled'),
92
+ continueBusy: no('queued_until_turn_end'),
93
+ fork: fork('cursor', 'new_session'),
94
+ cancelDesk: desk('deskCursor'),
95
+ // Cursor approvals cannot be answered remotely in `-p` (canary C3a).
96
+ approvals: no('engine_limit'),
97
+ nativeQueue: no('engine_limit'),
98
+ keepModel: full ? yes() : no('full_permissions_off'),
99
+ permissions: full ? yes('run_everything') : no('full_permissions_off'),
100
+ },
101
+ }
102
+ }
103
+
104
+ /** `{ claude: { act }, codex: { act }, cursor: { act } }`, ready to merge. */
105
+ export function providerActSections(input: ProviderActInput): Record<ProviderName, { act: ProviderAct }> {
106
+ const acts = providerActs(input)
107
+ return { claude: { act: acts.claude }, codex: { act: acts.codex }, cursor: { act: acts.cursor } }
108
+ }
109
+
110
+ /**
111
+ * Join per-provider sections (`act` from here, `observe` from the hooks work) into ONE
112
+ * `providers` object. Later sections add keys; they never replace a provider wholesale.
113
+ */
114
+ export function mergeProviderSections(
115
+ ...sections: Array<Partial<Record<string, Record<string, unknown>>> | null | undefined>
116
+ ): Record<string, Record<string, unknown>> {
117
+ const out: Record<string, Record<string, unknown>> = {}
118
+ for (const section of sections) {
119
+ if (!section) continue
120
+ for (const [provider, fields] of Object.entries(section)) {
121
+ if (!fields || typeof fields !== 'object') continue
122
+ out[provider] = { ...(out[provider] ?? {}), ...fields }
123
+ }
124
+ }
125
+ return out
126
+ }
127
+
128
+ /**
129
+ * The live input, read from the same flags the routes decide with. Separate from the pure
130
+ * `providerActs` so tests drive every branch without the environment.
131
+ */
132
+ export function liveProviderActInput(base: {
133
+ attachEnabled: boolean
134
+ forkProviders: readonly string[]
135
+ sessionCancel: Record<string, unknown> | null | undefined
136
+ }): ProviderActInput {
137
+ let brokerEnabled = false
138
+ try {
139
+ brokerEnabled = permissionBrokerHealthFields().permissionBroker?.enabled === true
140
+ } catch {
141
+ brokerEnabled = false
142
+ }
143
+ return {
144
+ ...base,
145
+ brokerEnabled,
146
+ continueLive: continueLiveEnabled(),
147
+ codexLiveQueue: codexLiveQueueEnabled(),
148
+ }
149
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * A provider's own words for why it refused a turn. The ONE copy of these patterns:
3
+ * provider proof, the lens gist (via provider-proof), fork classification and the
4
+ * query-job error code all read them, so a usage limit is named the same everywhere.
5
+ * 6.62.1 moved them here so low-level modules can import them without the proof's
6
+ * process machinery. Leaf module: no imports.
7
+ */
8
+ export const PROVIDER_QUOTA_RE = /usage[ _]limit|hit your (?:usage |session )?limit|session limit|rate.?limit|too many requests|\b429\b|\bquota\b|resets? (?:at|in) |over capacity|overloaded|\b529\b|plan limit/i
9
+ export const PROVIDER_AUTH_RE = /not logged in|please (?:log ?in|sign in)|invalid api key|authentication|unauthori[sz]ed|\b401\b|\b403\b|login required|token (?:has )?expired|no credentials/i
10
+ export const PROVIDER_OVERFLOW_RE = /prompt is too long|context window|too many tokens|exceeds the (?:model|context)/i
11
+
12
+ /** Codes a provider bridge raises when it has nothing better to say. Only these are upgraded. */
13
+ const GENERIC_CODES = new Set(['query_job_failed', 'claude.error', 'codex.error', 'cursor.error', 'provider_exit_nonzero'])
14
+
15
+ /**
16
+ * A generic failure whose message carries the provider's reason gets a code a client
17
+ * can act on: Work showed "claude.error / query_job_failed" for "You've hit your
18
+ * session limit · resets 4pm" on 2026-10-05, so it offered the same exhausted provider.
19
+ */
20
+ export function providerFailureCode(code: string, message: string): string {
21
+ if (!GENERIC_CODES.has(code)) return code
22
+ const sample = message.slice(0, 8_000)
23
+ if (PROVIDER_OVERFLOW_RE.test(sample)) return 'provider_context_too_long'
24
+ if (PROVIDER_QUOTA_RE.test(sample)) return 'provider_limit'
25
+ if (PROVIDER_AUTH_RE.test(sample)) return 'provider_auth'
26
+ return code
27
+ }
@@ -0,0 +1,212 @@
1
+ // What COS can SEE of each engine's sessions (6.62.0, provider parity), for `/api/health`.
2
+ //
3
+ // Two jobs. First, the Codex hooks and the Cursor observer as health fields beside the Claude
4
+ // ones (`sessionHooks.codex`, `sessionHooks.cursorObserver`). Claude's `sessionHooks.state`,
5
+ // `installed` and `hookHaltReady` are untouched and stay Claude-only (B2): Control 0.5.254
6
+ // keys its banner and its Install check off them.
7
+ //
8
+ // Second, `providers.<engine>.observe`: one short source code per observation (hooks, live
9
+ // state, child agents, compaction, reasoning), so a client can say why a row is quiet. Each is
10
+ // the name of the evidence, or `none`.
11
+ //
12
+ // READ FROM A SNAPSHOT, NEVER ON THE HEALTH REQUEST. Codex's trust needs a spawn of its
13
+ // app-server, and the files live in the user's home. The composition root starts a background
14
+ // refresh (`startProviderObserveRefresh`); the install routes refresh after they write. A
15
+ // process that never started it (every test) reports `checkedAt: null` and reads no home file.
16
+
17
+ import { CODEX_TRUST_REFRESH_MS, cachedCodexHookTrust, codexHookStatus, codexPresent, refreshCodexHookTrust, type CodexHookStatus, type CodexHookTrust } from './codex-hooks-installer.js'
18
+ import { sweepStaleCursorSpawnDirs } from './cursor-spawn-env.js'
19
+ import { cursorObserverStatus } from './cursor-observer-installer.js'
20
+ import { setCodexDeskCancelReady } from './session-cancel.js'
21
+
22
+ export interface CodexHooksHealth {
23
+ /** Codex's binary resolves and it has a home. Nothing else here is read when it is not. */
24
+ present: boolean
25
+ installed: boolean
26
+ /** The install state word (`CodexHookStatus.state`), or null when Codex is not present. */
27
+ state: CodexHookStatus['state'] | null
28
+ /**
29
+ * Codex's own word through `hooks/list` (K3): the newest answer for CODEX_TRUST_CACHE_MS, kept
30
+ * through a failed read (QA W4); `unknown` until read, past the TTL, or when not installed.
31
+ */
32
+ trust: CodexHookTrust
33
+ /**
34
+ * QA W4: why `trust` says what it says when it is not a fresh answer: the newest read's failure
35
+ * code (`timeout`, `rpc_error`, `spawn_failed`, `no_binary`), `not_listed`, `stale`,
36
+ * `unchecked`, or `not_installed`. Null for a fresh answer.
37
+ */
38
+ trustReason: string | null
39
+ /** The stable script is this package's: the only script whose Codex halt reply Codex honours. */
40
+ scriptOk: boolean
41
+ /** When the file status was last read; null when this process never read it. */
42
+ checkedAt: string | null
43
+ trustCheckedAt: string | null
44
+ }
45
+
46
+ export interface CursorObserverHealth {
47
+ installed: boolean
48
+ /** The node binary the observer commands name still exists (W18). */
49
+ nodeOk: boolean
50
+ checkedAt: string | null
51
+ }
52
+
53
+ interface Snapshot {
54
+ codex: { present: boolean; status: CodexHookStatus | null; at: number } | null
55
+ cursor: { installed: boolean; nodeOk: boolean; at: number } | null
56
+ }
57
+
58
+ const snapshot: Snapshot = { codex: null, cursor: null }
59
+
60
+ /** Re-read both files now (cheap: two small JSON files and two hashes). Trust is separate. */
61
+ export function refreshProviderHookFiles(nowMs = Date.now()): void {
62
+ try {
63
+ const present = codexPresent()
64
+ snapshot.codex = { present, status: present ? codexHookStatus() : null, at: nowMs }
65
+ } catch { /* the previous snapshot stands */ }
66
+ try {
67
+ const cursor = cursorObserverStatus()
68
+ snapshot.cursor = { installed: cursor.installed, nodeOk: cursor.nodeOk, at: nowMs }
69
+ } catch { /* the previous snapshot stands */ }
70
+ }
71
+
72
+ const iso = (ms: number | null | undefined) => (typeof ms === 'number' ? new Date(ms).toISOString() : null)
73
+
74
+ export function codexHooksHealthField(nowMs = Date.now()): CodexHooksHealth {
75
+ const codex = snapshot.codex
76
+ const trust = cachedCodexHookTrust(nowMs)
77
+ return {
78
+ present: codex?.present === true,
79
+ installed: codex?.status?.installed === true,
80
+ state: codex?.status?.state ?? null,
81
+ trust: codex?.status?.installed === true ? trust.trust : 'unknown',
82
+ trustReason: codex?.status?.installed === true ? trust.trustReason : 'not_installed',
83
+ scriptOk: codex?.status?.scriptOk === true,
84
+ checkedAt: iso(codex?.at),
85
+ trustCheckedAt: iso(trust.checkedAt),
86
+ }
87
+ }
88
+
89
+ export function cursorObserverHealthField(): CursorObserverHealth {
90
+ const cursor = snapshot.cursor
91
+ return { installed: cursor?.installed === true, nodeOk: cursor?.nodeOk === true, checkedAt: iso(cursor?.at) }
92
+ }
93
+
94
+ /**
95
+ * 6.62.0 (plan 3.8): can the Codex hooks stop a desk Codex run right now? Installed, trusted by
96
+ * Codex itself, and this package's script (whose Codex reply is the deny-only one).
97
+ */
98
+ export function codexDeskHaltReady(nowMs = Date.now()): boolean {
99
+ const field = codexHooksHealthField(nowMs)
100
+ return field.present && field.installed && field.scriptOk && (field.trust === 'trusted' || field.trust === 'managed')
101
+ }
102
+
103
+ export type ObserveSource = string
104
+
105
+ export interface ProviderObserve {
106
+ /** The hook pipeline this engine's sessions report through: a state word, or `none`. */
107
+ hooks: ObserveSource
108
+ /** Where a row's running/idle comes from. */
109
+ liveState: ObserveSource
110
+ /** Where child agents are counted from. */
111
+ children: ObserveSource
112
+ /** Where "compacting context" comes from. */
113
+ compaction: ObserveSource
114
+ /** Where the reasoning line on the live feed comes from. */
115
+ reasoning: ObserveSource
116
+ }
117
+
118
+ /**
119
+ * `providers.<engine>.observe` (plan 1.10). `claudeHooks` is Claude's install state word and
120
+ * whether its hooks can stop a run (they are what Cursor runs too).
121
+ */
122
+ export function providerObserveFields(claudeHooks: { state: string; ready: boolean }, nowMs = Date.now()): Record<'claude' | 'codex' | 'cursor', { observe: ProviderObserve }> {
123
+ const codex = codexHooksHealthField(nowMs)
124
+ const cursor = cursorObserverHealthField()
125
+ const codexHooksRun = codex.installed && (codex.trust === 'trusted' || codex.trust === 'managed')
126
+ return {
127
+ claude: {
128
+ observe: {
129
+ hooks: claudeHooks.state,
130
+ liveState: claudeHooks.ready ? 'hook' : 'registry',
131
+ children: claudeHooks.ready ? 'hook' : 'none',
132
+ compaction: claudeHooks.ready ? 'hook' : 'none',
133
+ // By design: Claude's thinking blocks are never put on the lens (session-stream-events).
134
+ reasoning: 'none',
135
+ },
136
+ },
137
+ codex: {
138
+ observe: {
139
+ hooks: !codex.present ? 'absent' : codex.installed ? codex.trust : (codex.state ?? 'unknown'),
140
+ liveState: codexHooksRun ? 'hook' : 'transcript',
141
+ children: 'state_db',
142
+ // The rollout writes a compaction only when it is over; only the hook sees one start.
143
+ compaction: codexHooksRun ? 'hook' : 'rollout_end',
144
+ reasoning: 'rollout',
145
+ },
146
+ },
147
+ cursor: {
148
+ observe: {
149
+ hooks: claudeHooks.ready ? 'claude_hooks' : claudeHooks.state,
150
+ liveState: claudeHooks.ready ? 'hook' : 'file_time',
151
+ children: cursor.installed && cursor.nodeOk ? 'observer' : 'none',
152
+ compaction: cursor.installed && cursor.nodeOk ? 'observer' : 'none',
153
+ reasoning: cursor.installed && cursor.nodeOk ? 'observer' : 'none',
154
+ },
155
+ },
156
+ }
157
+ }
158
+
159
+ /** File status every half minute; Codex trust every CODEX_TRUST_REFRESH_MS (a third of its cache window). */
160
+ export const PROVIDER_FILES_REFRESH_MS = 30_000
161
+
162
+ let refreshTimer: ReturnType<typeof setInterval> | null = null
163
+ let lastTrustAt = 0
164
+
165
+ /**
166
+ * The composition root's background refresh. Never on a request path; never in a test that
167
+ * does not call it. The trust read runs only while our hooks are installed in Codex's file.
168
+ */
169
+ export function startProviderObserveRefresh(): () => void {
170
+ // The desk cancel reads Codex readiness from the same snapshot health shows (plan 3.8).
171
+ setCodexDeskCancelReady(() => codexDeskHaltReady())
172
+ // QA W20: per-spawn Cursor config folders a crash or a restart left behind are reaped at
173
+ // boot, not only when the next Cursor spawn happens to run.
174
+ try {
175
+ const swept = sweepStaleCursorSpawnDirs()
176
+ if (swept > 0) console.log(`[provider-observe] removed ${swept} stale Cursor spawn folder(s)`)
177
+ } catch (error) {
178
+ console.warn(`[provider-observe] Cursor spawn sweep skipped: ${error instanceof Error ? error.name : 'error'}`)
179
+ }
180
+ const tick = () => {
181
+ refreshProviderHookFiles()
182
+ const now = Date.now()
183
+ if (snapshot.codex?.status?.installed === true && now - lastTrustAt >= CODEX_TRUST_REFRESH_MS) {
184
+ lastTrustAt = now
185
+ void refreshCodexHookTrust().catch(() => { /* the last answer stands */ })
186
+ }
187
+ }
188
+ tick()
189
+ refreshTimer = setInterval(tick, PROVIDER_FILES_REFRESH_MS)
190
+ refreshTimer.unref?.()
191
+ return () => { if (refreshTimer) clearInterval(refreshTimer); refreshTimer = null }
192
+ }
193
+
194
+ /**
195
+ * After an install or uninstall: the files now, and a FRESH trust read in the background. A read
196
+ * already in flight began before the write and is superseded, never reused (QA W4).
197
+ */
198
+ export function refreshAfterInstall(): void {
199
+ refreshProviderHookFiles()
200
+ if (snapshot.codex?.status?.installed === true) {
201
+ lastTrustAt = Date.now()
202
+ void refreshCodexHookTrust({ fresh: true }).catch(() => { /* the last answer stands */ })
203
+ }
204
+ }
205
+
206
+ export function __resetProviderObserveForTests(): void {
207
+ snapshot.codex = null
208
+ snapshot.cursor = null
209
+ lastTrustAt = 0
210
+ if (refreshTimer) clearInterval(refreshTimer)
211
+ refreshTimer = null
212
+ }
@@ -1,7 +1,11 @@
1
1
  import { spawn } from 'node:child_process'
2
2
  import { cosBrainDir } from './launch-dir.js'
3
3
  import { resolveAgentBinary } from './cursor-model-catalog.js'
4
+ import { cursorSpawnEnv, releaseCursorSpawnOnExit } from './cursor-spawn-env.js'
4
5
  import { terminateProviderProcess } from './provider-process-lifecycle.js'
6
+ import { PROVIDER_AUTH_RE, PROVIDER_OVERFLOW_RE, PROVIDER_QUOTA_RE } from './provider-failure-text.js'
7
+
8
+ export { PROVIDER_AUTH_RE, PROVIDER_OVERFLOW_RE, PROVIDER_QUOTA_RE }
5
9
 
6
10
  export type ProofProvider = 'claude' | 'codex' | 'cursor'
7
11
  export const PROOF_PROVIDERS: readonly ProofProvider[] = ['claude', 'codex', 'cursor']
@@ -54,12 +58,17 @@ export const CLAUDE_SAFE_MODE_ENV = 'CLAUDE_CODE_SAFE_MODE'
54
58
 
55
59
  type TerminationReason = 'timeout' | 'abort' | null
56
60
 
61
+ function spawnPiped(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv) {
62
+ return spawn(command, args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true })
63
+ }
64
+
57
65
  export function runBounded(
58
66
  command: string,
59
67
  args: string[],
60
68
  input: string,
61
69
  timeoutMs = 120_000,
62
70
  signal?: AbortSignal,
71
+ options: { cursorIsolation?: boolean } = {},
63
72
  ): Promise<ProcessResult> {
64
73
  return new Promise((resolvePromise) => {
65
74
  if (signal?.aborted) {
@@ -69,12 +78,17 @@ export function runBounded(
69
78
  const env = { ...process.env }
70
79
  delete env.CLAUDECODE
71
80
  env[CLAUDE_SAFE_MODE_ENV] = '1'
72
- const child = spawn(command, args, {
73
- cwd: cosBrainDir() ?? process.cwd(),
74
- env,
75
- stdio: ['pipe', 'pipe', 'pipe'],
76
- detached: true,
77
- })
81
+ // 6.62.0 (plan 3.4): the Cursor proof runs on a config dir of its own, like every
82
+ // other `agent` spawn, so Update Server's proof cannot touch the person's CLI default.
83
+ const isolation = options.cursorIsolation === true ? cursorSpawnEnv({ baseEnv: env }) : null
84
+ let child: ReturnType<typeof spawnPiped>
85
+ try {
86
+ child = spawnPiped(command, args, cosBrainDir() ?? process.cwd(), isolation ? isolation.env : env)
87
+ } catch (error) {
88
+ isolation?.release()
89
+ throw error
90
+ }
91
+ if (isolation) releaseCursorSpawnOnExit(child, isolation)
78
92
  let stdout = ''
79
93
  let stderr = ''
80
94
  let settled = false
@@ -194,11 +208,6 @@ function safeProofError(result: ProcessResult): string {
194
208
  return 'provider returned no valid proof response'
195
209
  }
196
210
 
197
- /** A provider's own words for a spent session, usage, rate or capacity limit. Also the lens
198
- * gist's (lens-gist-engines.ts), so both read a limit the same way. */
199
- export const PROVIDER_QUOTA_RE = /usage[ _]limit|hit your (?:usage |session )?limit|session limit|rate.?limit|too many requests|\b429\b|\bquota\b|resets? (?:at|in) |over capacity|overloaded|\b529\b|plan limit/i
200
- const AUTH_RE = /not logged in|please (?:log ?in|sign in)|invalid api key|authentication|unauthori[sz]ed|\b401\b|\b403\b|login required|token (?:has )?expired|no credentials/i
201
- const OVERFLOW_RE = /prompt is too long|context window|too many tokens|exceeds the (?:model|context)/i
202
211
  const MISSING_RE = /ENOENT|command not found|no such file/i
203
212
 
204
213
  /** Vendor text the provider printed, so the code can be derived without ever
@@ -210,9 +219,9 @@ export function classifyProofFailure(result: ProcessResult, answer: string, expe
210
219
  if (result.timedOut) return 'provider_timeout'
211
220
  const haystack = `${result.stderr}\n${result.stdout}`.slice(0, 40_000)
212
221
  if (result.code === null && MISSING_RE.test(haystack)) return 'provider_missing'
213
- if (OVERFLOW_RE.test(haystack)) return 'provider_context_overflow'
222
+ if (PROVIDER_OVERFLOW_RE.test(haystack)) return 'provider_context_overflow'
214
223
  if (PROVIDER_QUOTA_RE.test(haystack)) return 'provider_quota'
215
- if (AUTH_RE.test(haystack)) return 'provider_auth'
224
+ if (PROVIDER_AUTH_RE.test(haystack)) return 'provider_auth'
216
225
  if (result.code === 0) return answer === expected ? 'provider_failed' : 'provider_bad_answer'
217
226
  return 'provider_failed'
218
227
  }
@@ -286,7 +295,7 @@ async function executeProof(provider: ProofProvider, signal?: AbortSignal): Prom
286
295
  } else {
287
296
  const binary = resolveAgentBinary()
288
297
  result = binary
289
- ? await runBounded(binary, cursorProofArgs(), PROOF_PROMPT, CURSOR_PROOF_TIMEOUT_MS, signal)
298
+ ? await runBounded(binary, cursorProofArgs(), PROOF_PROMPT, CURSOR_PROOF_TIMEOUT_MS, signal, { cursorIsolation: true })
290
299
  : { code: null, stdout: '', stderr: 'ENOENT: cursor agent binary not found', timedOut: false, aborted: false }
291
300
  }
292
301
  const text = provider === 'claude'