@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
@@ -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'
@@ -29,6 +29,7 @@ import {
29
29
  } from './conversation.js'
30
30
  import {
31
31
  isTerminalQueryJobStatus,
32
+ type QueryJobProviderLinkage,
32
33
  type QueryJobRequest,
33
34
  type QueryJobSnapshot,
34
35
  } from './query-job-types.js'
@@ -179,6 +180,17 @@ export async function projectPublicConversationTerminal(
179
180
  flushConversationToDisk()
180
181
  }
181
182
 
183
+ /**
184
+ * 6.62.0 (plan 3.9, W11): a provider's early "this is my session" into the job's linkage.
185
+ * A Claude announcement carries no provider (its bridge is unchanged) and reads as Claude.
186
+ */
187
+ export function nativeSessionLinkage(linkage: { cliSessionId: string; provider?: 'claude' | 'codex' | 'cursor' }): QueryJobProviderLinkage {
188
+ const provider = linkage.provider ?? 'claude'
189
+ return provider === 'codex'
190
+ ? { provider, codexThreadId: linkage.cliSessionId }
191
+ : { provider, cliSessionId: linkage.cliSessionId }
192
+ }
193
+
182
194
  const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callbacks }) => {
183
195
  // Resolve ids again at execution time. This closes the admission/execution
184
196
  // TOCTOU window without ever putting paths or bytes in the journal.
@@ -235,7 +247,10 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
235
247
  : { codexRunId: metadata.runId }),
236
248
  }),
237
249
  // 6.58.2: the provider's own session id, as soon as it names it (Claude: first stream event).
238
- onNativeSession: linkage => { void callbacks.onLinkage?.({ provider: 'claude', cliSessionId: linkage.cliSessionId }) },
250
+ // 6.62.0 (plan 3.9, W11): provider-aware, so a Codex or Cursor job links to its native
251
+ // session as early as a Claude one. Codex names it as `codexThreadId`, the field its
252
+ // completion has always used. No naming for Codex or Cursor this release (uncanaried).
253
+ onNativeSession: linkage => { void callbacks.onLinkage?.(nativeSessionLinkage(linkage)) },
239
254
  onChunk: text => { callbacks.onChunk(text) },
240
255
  onToolStatus: toolName => {
241
256
  const message = request.activityToolMode === 'off'
@@ -1,3 +1,4 @@
1
+ import { providerFailureCode } from './provider-failure-text.js'
1
2
  import { createHash } from 'node:crypto'
2
3
  import {
3
4
  parseMediaAttachmentRefs,
@@ -587,7 +588,7 @@ export function normalizeQueryJobError(error: unknown, fallbackCode = 'query_job
587
588
  : typeof error === 'string' ? error : fallbackCode
588
589
  const safe = sanitizeQueryJobActivity(rawMessage)
589
590
  return {
590
- code,
591
+ code: providerFailureCode(code, typeof rawMessage === 'string' ? rawMessage : ''),
591
592
  message: safe.text,
592
593
  ...(candidate.retryable === true ? { retryable: true } : {}),
593
594
  ...(typeof candidate.retryAfterMs === 'number' && Number.isFinite(candidate.retryAfterMs)
@@ -0,0 +1,61 @@
1
+ // `reported_model` and `continue_note` for the DETAIL route (6.62.0, plans 1.8 and 3.12).
2
+ // The route calls these two helpers and nothing else, so its own row shape (owned by the
3
+ // observation work) is untouched.
4
+ //
5
+ // 6.62.0 /qa (W11): list rows carry neither field. The lens reads both from the detail and
6
+ // the attach verdict only, and the list used to wait on these reads (transcript tails,
7
+ // rollout scans, sqlite) with no budget. Every failure here is an omitted field.
8
+
9
+ import { mapWithConcurrency, ACTIVITY_READ_CONCURRENCY } from './agent-session-activity.js'
10
+ import { readComposerFacts, type ComposerFacts } from './cursor-session-model.js'
11
+ import { continueFactsFields, resolveSessionContinueFacts, type ContinueFactsDeps, type SessionContinueFacts } from './session-continue-facts.js'
12
+ import { realContinueFactsDeps } from './session-continue-facts-real.js'
13
+
14
+ let deps: ContinueFactsDeps | null = null
15
+ function liveDeps(): ContinueFactsDeps {
16
+ deps ??= realContinueFactsDeps()
17
+ return deps
18
+ }
19
+
20
+ /** Tests only: drive the helpers with fakes. */
21
+ export function __setSessionActDepsForTests(next: ContinueFactsDeps | null): void {
22
+ deps = next
23
+ }
24
+
25
+ export interface SessionActRow {
26
+ provider: string
27
+ session_id: string
28
+ file?: string | null
29
+ }
30
+
31
+ /** The facts for each row, in order. Composer models for the whole page come from ONE query. */
32
+ export async function sessionActFactsFor(rows: readonly SessionActRow[], options: { noteAck?: boolean } = {}): Promise<Array<SessionContinueFacts | null>> {
33
+ const d = liveDeps()
34
+ let composerFacts: Map<string, ComposerFacts | null> | undefined
35
+ const cursorIds = rows.filter(row => row.provider === 'cursor').map(row => row.session_id)
36
+ if (cursorIds.length > 0) {
37
+ try {
38
+ composerFacts = await readComposerFacts(cursorIds, d.cursorComposerDb)
39
+ } catch {
40
+ composerFacts = undefined
41
+ }
42
+ }
43
+ return mapWithConcurrency(rows, ACTIVITY_READ_CONCURRENCY, async row => {
44
+ try {
45
+ return await resolveSessionContinueFacts({
46
+ provider: row.provider,
47
+ threadId: row.session_id,
48
+ transcriptPath: row.file ?? null,
49
+ cwd: null,
50
+ composerFacts,
51
+ noteAck: options.noteAck === true,
52
+ }, d)
53
+ } catch {
54
+ return null
55
+ }
56
+ })
57
+ }
58
+
59
+ export function sessionActFields(facts: SessionContinueFacts | null | undefined): { reported_model?: string; continue_note?: string } {
60
+ return continueFactsFields(facts)
61
+ }