@gotcos/glasses-server 6.61.7 → 6.62.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +6 -0
- package/CHANGELOG.md +34 -0
- package/README.md +5 -1
- package/bin/cli.cjs +50 -3
- package/bin/hooks/cos-cursor-observer.cjs +11 -4
- package/bin/hooks/cos-session-hook +46 -11
- package/docs/provider-parity.md +43 -0
- package/managed-runtime-contract.json +1 -0
- package/package.json +4 -2
- package/server/index.ts +135 -16
- package/server/lib/agent-session-binding-store.ts +9 -3
- package/server/lib/agent-session-store.ts +7 -4
- package/server/lib/attached-provider-adapter.ts +158 -45
- package/server/lib/banned-permission-args.ts +42 -1
- package/server/lib/claude-bridge.ts +17 -8
- package/server/lib/claude-hooks-installer.ts +22 -2
- package/server/lib/codex-bridge.ts +17 -2
- package/server/lib/codex-hooks-installer.ts +538 -0
- package/server/lib/codex-live-queue.ts +1 -1
- package/server/lib/codex-model-catalog.ts +49 -2
- package/server/lib/codex-rollout-state.ts +90 -0
- package/server/lib/codex-session-posture.ts +528 -0
- package/server/lib/continue-plan.ts +180 -0
- package/server/lib/continue-read-health.ts +26 -0
- package/server/lib/cursor-bridge.ts +26 -6
- package/server/lib/cursor-fork-context.ts +140 -0
- package/server/lib/cursor-model-catalog.ts +62 -5
- package/server/lib/cursor-observer-installer.ts +48 -7
- package/server/lib/cursor-session-model.ts +339 -0
- package/server/lib/cursor-spawn-env.ts +210 -0
- package/server/lib/fork-thread.ts +143 -25
- package/server/lib/health-static-probes.ts +21 -11
- package/server/lib/lens-gist-engines.ts +15 -4
- package/server/lib/lens-gist.ts +1 -1
- package/server/lib/live-cues-cursor.ts +16 -6
- package/server/lib/occupancy-probes.ts +45 -1
- package/server/lib/permission-broker.ts +24 -9
- package/server/lib/provider-actions.ts +149 -0
- package/server/lib/provider-failure-text.ts +27 -0
- package/server/lib/provider-observe.ts +212 -0
- package/server/lib/provider-proof.ts +23 -14
- package/server/lib/query-job-runtime.ts +16 -1
- package/server/lib/query-job-types.ts +2 -1
- package/server/lib/session-act-fields.ts +61 -0
- package/server/lib/session-cancel.ts +95 -4
- package/server/lib/session-continue-facts-real.ts +74 -0
- package/server/lib/session-continue-facts.ts +267 -0
- package/server/lib/session-halt.ts +67 -6
- package/server/lib/session-hook-events.ts +57 -0
- package/server/lib/session-hook-ledger.ts +21 -3
- package/server/lib/session-hook-spool.ts +68 -6
- package/server/lib/session-hooks-runtime.ts +32 -2
- package/server/lib/session-peer-inbox-deps.ts +7 -6
- package/server/lib/session-recommendation.ts +3 -2
- package/server/lib/session-signal-store.ts +74 -5
- package/server/lib/session-stream-events.ts +32 -1
- package/server/lib/thread-attach-capability.ts +15 -1
- package/server/lib/thread-drain-kick.ts +4 -2
- package/server/lib/thread-occupancy.ts +44 -0
- package/server/lib/thread-turn-queue-deliver.ts +6 -1
- package/server/lib/thread-turn-queue.ts +6 -0
- package/server/lib/turn-request.ts +274 -0
- package/server/routes/agent-session-bindings.ts +224 -22
- package/server/routes/agent-session-stream.ts +26 -7
- package/server/routes/agent-sessions.ts +155 -10
- package/server/routes/health.ts +27 -2
- package/server/routes/session-hooks.ts +56 -5
- package/server/routes/thread-turn-queue.ts +37 -9
- package/server/scripts/hooks-cli.ts +33 -7
|
@@ -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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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 (
|
|
222
|
+
if (PROVIDER_OVERFLOW_RE.test(haystack)) return 'provider_context_overflow'
|
|
214
223
|
if (PROVIDER_QUOTA_RE.test(haystack)) return 'provider_quota'
|
|
215
|
-
if (
|
|
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
|
-
|
|
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
|
+
}
|