@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.
- package/.env.example +6 -0
- package/CHANGELOG.md +41 -0
- package/README.md +5 -1
- package/bin/cli.cjs +50 -3
- package/bin/hooks/cos-cursor-observer.cjs +36 -0
- 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 +5 -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 +28 -5
- 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/codex-subagent-activity.ts +119 -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 +79 -0
- 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 +60 -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 +40 -3
- 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 +81 -3
- package/server/lib/session-stream-events.ts +32 -1
- package/server/lib/session-transcript-watcher.ts +25 -1
- package/server/lib/session-work-observation.ts +46 -0
- 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 +163 -10
- package/server/routes/health.ts +27 -2
- package/server/routes/session-hooks.ts +60 -3
- package/server/routes/thread-turn-queue.ts +37 -9
- package/server/scripts/hooks-cli.ts +38 -9
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Read-only display evidence. Never imported by attachment, queue or write gates.
|
|
2
|
+
// Open spawn edges describe relationships, not work: completed children remain open.
|
|
3
|
+
import { execFile } from 'node:child_process'
|
|
4
|
+
import { promisify } from 'node:util'
|
|
5
|
+
import { open, realpath, readdir, stat } from 'node:fs/promises'
|
|
6
|
+
import { dirname, join, relative } from 'node:path'
|
|
7
|
+
|
|
8
|
+
const exec = promisify(execFile)
|
|
9
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
10
|
+
export const SUBAGENT_FRESH_MS = 5 * 60_000
|
|
11
|
+
const MAX_ROWS = 512
|
|
12
|
+
const MAX_TAIL = 4 * 1024 * 1024
|
|
13
|
+
const MAX_SNAPSHOT_BYTES = 16 * 1024 * 1024
|
|
14
|
+
export interface SubagentActivity {
|
|
15
|
+
active: number
|
|
16
|
+
completed: number
|
|
17
|
+
unknown: number
|
|
18
|
+
total: number
|
|
19
|
+
checked_at: number
|
|
20
|
+
partial: boolean
|
|
21
|
+
}
|
|
22
|
+
export type ChildState = 'active' | 'completed' | 'unknown'
|
|
23
|
+
|
|
24
|
+
/** Latest explicit turn lifecycle wins; ordinary output alone never starts a turn. */
|
|
25
|
+
interface ChildEvidence { lifecycle: 'open' | 'completed' | 'unknown'; latest: number }
|
|
26
|
+
function parseChildEvidence(text: string, now: number, startsMidFile = false): ChildEvidence {
|
|
27
|
+
const lines = text.split('\n')
|
|
28
|
+
if (startsMidFile) lines.shift()
|
|
29
|
+
let latest = 0
|
|
30
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
31
|
+
let row: any
|
|
32
|
+
try { row = JSON.parse(lines[i]) } catch { continue }
|
|
33
|
+
const at = Date.parse(row?.timestamp)
|
|
34
|
+
if (Number.isFinite(at) && at <= now + 5000 && at > 0) latest = Math.max(latest, at)
|
|
35
|
+
if (row?.type !== 'event_msg') continue
|
|
36
|
+
const kind = row.payload?.type
|
|
37
|
+
if (['task_complete', 'task_failed', 'turn_aborted', 'turn_cancelled'].includes(kind)) return { lifecycle: Number.isFinite(at) && at > 0 && at <= now + 5000 ? 'completed' : 'unknown', latest }
|
|
38
|
+
if (kind === 'task_started') {
|
|
39
|
+
// A crashed/interrupted writer with no terminal event must age into uncertainty.
|
|
40
|
+
return { lifecycle: Number.isFinite(at) && at > 0 && at <= now + 5000 ? 'open' : 'unknown', latest }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { lifecycle: 'unknown', latest }
|
|
44
|
+
}
|
|
45
|
+
function stateOf(evidence: ChildEvidence, now: number): ChildState {
|
|
46
|
+
if (evidence.lifecycle === 'completed') return 'completed'
|
|
47
|
+
return evidence.lifecycle === 'open' && evidence.latest <= now + 5000 && now - evidence.latest <= SUBAGENT_FRESH_MS ? 'active' : 'unknown'
|
|
48
|
+
}
|
|
49
|
+
export function childStateFromTail(text: string, now: number, startsMidFile = false): ChildState {
|
|
50
|
+
return stateOf(parseChildEvidence(text, now, startsMidFile), now)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const fileMemo = new Map<string, { mtime: number; size: number; evidence: ChildEvidence }>()
|
|
54
|
+
async function childState(file: string, root: string, now: number, budget: { bytes: number }): Promise<ChildState> {
|
|
55
|
+
try {
|
|
56
|
+
const canonical = await realpath(file)
|
|
57
|
+
const rel = relative(await realpath(root), canonical)
|
|
58
|
+
if (!rel || rel.startsWith('..') || rel.startsWith('/') || !canonical.endsWith('.jsonl')) return 'unknown'
|
|
59
|
+
const st = await stat(canonical)
|
|
60
|
+
if (!st.isFile()) return 'unknown'
|
|
61
|
+
const hit = fileMemo.get(canonical)
|
|
62
|
+
if (hit && hit.mtime === st.mtimeMs && hit.size === st.size) return stateOf(hit.evidence, now)
|
|
63
|
+
const handle = await open(canonical, 'r')
|
|
64
|
+
let evidence: ChildEvidence = { lifecycle: 'unknown', latest: 0 }
|
|
65
|
+
try {
|
|
66
|
+
for (let limit = 256 * 1024; ; limit *= 4) {
|
|
67
|
+
if (budget.bytes <= 0) return 'unknown'
|
|
68
|
+
const length = Math.min(st.size, limit, budget.bytes), start = st.size - length
|
|
69
|
+
budget.bytes -= length
|
|
70
|
+
const buffer = Buffer.alloc(length)
|
|
71
|
+
const read = await handle.read(buffer, 0, length, start)
|
|
72
|
+
evidence = parseChildEvidence(buffer.subarray(0, read.bytesRead).toString('utf8'), now, start > 0)
|
|
73
|
+
if (evidence.lifecycle !== 'unknown' || start === 0 || limit >= MAX_TAIL) break
|
|
74
|
+
}
|
|
75
|
+
} finally { await handle.close() }
|
|
76
|
+
// Cache only lifecycle metadata, never transcript text. Freshness is recomputed.
|
|
77
|
+
if (fileMemo.size >= MAX_ROWS) fileMemo.delete(fileMemo.keys().next().value!)
|
|
78
|
+
fileMemo.set(canonical, { mtime: st.mtimeMs, size: st.size, evidence })
|
|
79
|
+
return stateOf(evidence, now)
|
|
80
|
+
} catch { return 'unknown' }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One bounded read-only SQLite snapshot per list/detail read. */
|
|
84
|
+
export async function readCodexSubagents(
|
|
85
|
+
parents: readonly string[], sessionsRoot: string, now = Date.now(),
|
|
86
|
+
): Promise<Map<string, SubagentActivity>> {
|
|
87
|
+
const ids = [...new Set(parents.filter(id => UUID.test(id)).map(id => id.toLowerCase()))].slice(0, 80)
|
|
88
|
+
const out = new Map<string, SubagentActivity>()
|
|
89
|
+
if (!ids.length) return out
|
|
90
|
+
try {
|
|
91
|
+
const home = dirname(sessionsRoot)
|
|
92
|
+
const files = (await readdir(home)).filter(n => /^state_\d+\.sqlite$/.test(n)).sort((a,b) => Number(b.match(/\d+/)![0])-Number(a.match(/\d+/)![0]))
|
|
93
|
+
if (!files.length) return out // older installations do not advertise this capability
|
|
94
|
+
// UUID validation above is the SQL parameter boundary; no prompt/path reaches SQL.
|
|
95
|
+
const values = ids.map(id => `'${id}'`).join(',')
|
|
96
|
+
const query = `WITH RECURSIVE children(root,id,depth,trail) AS (
|
|
97
|
+
SELECT parent_thread_id,child_thread_id,1,','||parent_thread_id||','||child_thread_id||',' FROM thread_spawn_edges WHERE status='open' AND parent_thread_id IN (${values})
|
|
98
|
+
UNION ALL SELECT c.root,e.child_thread_id,c.depth+1,c.trail||e.child_thread_id||',' FROM children c JOIN thread_spawn_edges e ON e.parent_thread_id=c.id WHERE e.status='open' AND c.depth<16 AND instr(c.trail,','||e.child_thread_id||',')=0
|
|
99
|
+
) SELECT DISTINCT c.root,c.id,t.rollout_path,t.archived,c.depth FROM children c LEFT JOIN threads t ON t.id=c.id LIMIT ${MAX_ROWS+1}`
|
|
100
|
+
const { stdout } = await exec('/usr/bin/sqlite3', ['-readonly', '-json', join(home,files[0]), query], { timeout: 2000, maxBuffer: 2*1024*1024 })
|
|
101
|
+
const rows = JSON.parse(stdout || '[]') as Array<{root:string;id:string;rollout_path?:string;archived?:number;depth:number}>
|
|
102
|
+
for (const id of ids) out.set(id, {active:0,completed:0,unknown:0,total:0,checked_at:now,partial:rows.length>MAX_ROWS})
|
|
103
|
+
const seen = new Set<string>()
|
|
104
|
+
const budget = { bytes: MAX_SNAPSHOT_BYTES }
|
|
105
|
+
// Sequential bounded reads avoid descriptor storms; most children are memo hits.
|
|
106
|
+
for (const row of rows.slice(0, MAX_ROWS)) {
|
|
107
|
+
const key = row.root+':'+row.id, summary = out.get(row.root)
|
|
108
|
+
if (!summary || seen.has(key)) continue
|
|
109
|
+
seen.add(key); summary.total++
|
|
110
|
+
if (row.depth >= 16) summary.partial = true
|
|
111
|
+
const state = row.archived === 1 ? 'completed' : typeof row.rollout_path === 'string' ? await childState(row.rollout_path, sessionsRoot, now, budget) : 'unknown'
|
|
112
|
+
summary[state === 'active' ? 'active' : state === 'completed' ? 'completed' : 'unknown']++
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// Do not convert a schema/read failure into a fabricated zero-agent count.
|
|
116
|
+
for (const id of ids) out.set(id, {active:0,completed:0,unknown:0,total:0,checked_at:now,partial:true})
|
|
117
|
+
}
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// What one Continue runs with, per provider (6.62.0, plans 3.1-3.3 and 3.12, decision D1).
|
|
2
|
+
//
|
|
3
|
+
// D1 (Miles, binding): every Continue carries its session's full permissions.
|
|
4
|
+
// Claude unchanged: `claude -p --resume` already runs as the session runs (APA:473-496),
|
|
5
|
+
// and keeps the session's model (canary C5).
|
|
6
|
+
// Codex the session's own sandbox, model and effort, read from its rollout
|
|
7
|
+
// (`codex-session-posture.ts`), passed explicitly because resume takes the
|
|
8
|
+
// config defaults otherwise (C11). Full access only in a folder Codex trusts.
|
|
9
|
+
// Cursor Run Everything (`--force`) on the session's own model (C4, C5), with every
|
|
10
|
+
// spawn isolated so `--model` cannot rewrite the user's CLI default (C9).
|
|
11
|
+
//
|
|
12
|
+
// THE PLAN IS DATA. The index shim resolves it, the adapter builds argv from it, and the
|
|
13
|
+
// SAME plan names the one `PermissionAllowance` both of the adapter's ban checks receive.
|
|
14
|
+
// A plan the adapter cannot validate is refused there, never widened.
|
|
15
|
+
//
|
|
16
|
+
// THE KILL SWITCH. `COS_CONTINUE_FULL_PERMISSIONS=0` in `~/.cos-glasses/.env` resolves NO
|
|
17
|
+
// plan for Codex and Cursor, and the adapter then builds exactly the 6.61 argv (Codex
|
|
18
|
+
// `getCodexTrustMode()`, Cursor `--mode ask` on composer-2.5-fast). Default on. Control
|
|
19
|
+
// 0.5.254's provider allowlist does not carry the key, so it belongs in `.env`.
|
|
20
|
+
|
|
21
|
+
import type { PermissionAllowance } from './banned-permission-args.js'
|
|
22
|
+
import { CONTINUE_NOTE_MAX, fitModelNote, type CodexContinuePlan } from './codex-session-posture.js'
|
|
23
|
+
import { CURSOR_CONTINUE_FALLBACK_MODEL } from './cursor-session-model.js'
|
|
24
|
+
|
|
25
|
+
export type { CodexContinuePlan } from './codex-session-posture.js'
|
|
26
|
+
|
|
27
|
+
export interface CursorContinuePlan {
|
|
28
|
+
provider: 'cursor'
|
|
29
|
+
/** `force` is Run Everything. The only mode a 6.62 plan carries; Ask is "no plan". */
|
|
30
|
+
mode: 'force'
|
|
31
|
+
/** A flat slug the installed Cursor lists, or the fallback. */
|
|
32
|
+
model: string
|
|
33
|
+
/** The session's own model was read AND listed. False means the fallback. */
|
|
34
|
+
matched: boolean
|
|
35
|
+
note: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type AttachedContinuePlan = CodexContinuePlan | CursorContinuePlan
|
|
39
|
+
|
|
40
|
+
/** `COS_CONTINUE_FULL_PERMISSIONS`: ON unless exactly '0'. */
|
|
41
|
+
export function continueFullPermissionsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
42
|
+
return env.COS_CONTINUE_FULL_PERMISSIONS !== '0'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const CODEX_SANDBOXES = new Set(['read-only', 'workspace-write', 'danger-full-access'])
|
|
46
|
+
const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/
|
|
47
|
+
const EFFORT_RE = /^[a-z]{2,16}$/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Is this a plan the adapter may build from? Structural, and total: anything that is not
|
|
51
|
+
* exactly a plan for THIS provider with values of the right shape is `invalid`. A value
|
|
52
|
+
* here becomes an argv element, so the shapes are the argv rules.
|
|
53
|
+
*/
|
|
54
|
+
export function validateContinuePlan(provider: string, plan: unknown): AttachedContinuePlan | null | 'invalid' {
|
|
55
|
+
if (plan === undefined || plan === null) return null
|
|
56
|
+
if (typeof plan !== 'object' || Array.isArray(plan)) return 'invalid'
|
|
57
|
+
const row = plan as Record<string, unknown>
|
|
58
|
+
if (row.provider !== provider) return 'invalid'
|
|
59
|
+
if (provider === 'codex') {
|
|
60
|
+
if (typeof row.sandbox !== 'string' || !CODEX_SANDBOXES.has(row.sandbox)) return 'invalid'
|
|
61
|
+
if (row.model !== null && (typeof row.model !== 'string' || !MODEL_RE.test(row.model))) return 'invalid'
|
|
62
|
+
if (row.effort !== null && (typeof row.effort !== 'string' || !EFFORT_RE.test(row.effort))) return 'invalid'
|
|
63
|
+
if (row.networkAccess !== null && typeof row.networkAccess !== 'boolean') return 'invalid'
|
|
64
|
+
if (row.source !== 'session' && row.source !== 'fallback') return 'invalid'
|
|
65
|
+
// A fallback plan never carries more than the 6.61 sandbox.
|
|
66
|
+
if (row.source === 'fallback' && (row.sandbox === 'danger-full-access' || row.model !== null || row.effort !== null)) return 'invalid'
|
|
67
|
+
return plan as CodexContinuePlan
|
|
68
|
+
}
|
|
69
|
+
if (provider === 'cursor') {
|
|
70
|
+
if (row.mode !== 'force') return 'invalid'
|
|
71
|
+
if (typeof row.model !== 'string' || !MODEL_RE.test(row.model)) return 'invalid'
|
|
72
|
+
return plan as CursorContinuePlan
|
|
73
|
+
}
|
|
74
|
+
// Claude has no plan: its Continue is unchanged.
|
|
75
|
+
return 'invalid'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The ONE allowance for this plan, or undefined (the full ban). Built once per turn and
|
|
80
|
+
* passed to both checks. Only the two escalations D1 names exist.
|
|
81
|
+
*/
|
|
82
|
+
export function continueAllowanceFor(plan: AttachedContinuePlan | null | undefined): PermissionAllowance | undefined {
|
|
83
|
+
if (!plan) return undefined
|
|
84
|
+
if (plan.provider === 'cursor' && plan.mode === 'force') return { path: 'attached_continue', cursorForce: true }
|
|
85
|
+
if (plan.provider === 'codex' && plan.sandbox === 'danger-full-access') {
|
|
86
|
+
return { path: 'attached_continue', codexSandbox: 'danger-full-access' }
|
|
87
|
+
}
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface CursorPlanInput {
|
|
92
|
+
/** Flat slugs, most specific first (`cursorSlugCandidates`, or a CLI chat's own slug). */
|
|
93
|
+
candidates: readonly string[]
|
|
94
|
+
isKnown: (id: string) => boolean
|
|
95
|
+
label: (id: string) => string | null
|
|
96
|
+
/** COS created this CLI chat in Ask mode: a Run Everything Continue escalates it (W14). */
|
|
97
|
+
askModeChat: boolean
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Run Everything on the session's model, validated against the full `agent models` list. */
|
|
101
|
+
export function planCursorContinue(input: CursorPlanInput): CursorContinuePlan {
|
|
102
|
+
let model: string | null = null
|
|
103
|
+
for (const candidate of input.candidates) {
|
|
104
|
+
try {
|
|
105
|
+
if (MODEL_RE.test(candidate) && input.isKnown(candidate)) { model = candidate; break }
|
|
106
|
+
} catch {
|
|
107
|
+
model = null
|
|
108
|
+
break
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const matched = model !== null
|
|
112
|
+
const chosen = model ?? CURSOR_CONTINUE_FALLBACK_MODEL
|
|
113
|
+
let label: string | null = null
|
|
114
|
+
try {
|
|
115
|
+
label = input.label(chosen)
|
|
116
|
+
} catch {
|
|
117
|
+
label = null
|
|
118
|
+
}
|
|
119
|
+
// The CLI's display name when the note stays short; else the slug itself (lens width).
|
|
120
|
+
const display = label || (matched ? null : 'Composer 2.5 Fast')
|
|
121
|
+
const note = input.askModeChat
|
|
122
|
+
? fitModelNote(m => `Now Run Everything (was Ask), ${m}.`, display, chosen, 'Now Run Everything (was Ask).')
|
|
123
|
+
: matched
|
|
124
|
+
? fitModelNote(m => `Run Everything on ${m}.`, display, chosen, 'Run Everything on this session\'s model.')
|
|
125
|
+
: fitModelNote(m => `Run Everything on ${m}; model unread.`, display, chosen, 'Run Everything; session model unread.')
|
|
126
|
+
return { provider: 'cursor', mode: 'force', model: chosen, matched, note }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The note when the kill switch is on, per provider. Claude has none: it never changed. */
|
|
130
|
+
export function killSwitchNote(provider: string, codexFallbackSandbox: 'read-only' | 'workspace-write'): string | null {
|
|
131
|
+
if (provider === 'codex') {
|
|
132
|
+
return codexFallbackSandbox === 'workspace-write'
|
|
133
|
+
? 'Workspace write: full permissions switched off.'
|
|
134
|
+
: 'Read-only: full permissions switched off.'
|
|
135
|
+
}
|
|
136
|
+
if (provider === 'cursor') return 'Ask mode: full permissions switched off.'
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 6.62.0 /qa (Q3): the request header by which a client says it SHOWS `continue_note`. Only
|
|
142
|
+
* then does a Cursor Continue run Run Everything: glasses 599 and older label Cursor Continue
|
|
143
|
+
* "Ask", and the server must not do more than the button said. Codex needs no gate (the old
|
|
144
|
+
* copy, "uses this session's settings", stays true). The kill switch still wins.
|
|
145
|
+
*/
|
|
146
|
+
export const CONTINUE_NOTE_HEADER = 'x-cos-continue-note'
|
|
147
|
+
|
|
148
|
+
/** The header's value read strictly: exactly '1' (an array takes its first value). */
|
|
149
|
+
export function continueNoteAcknowledged(value: unknown): boolean {
|
|
150
|
+
const first = Array.isArray(value) ? value[0] : value
|
|
151
|
+
return typeof first === 'string' && first.trim() === '1'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** A Cursor Continue for a client that does not show the note: the 6.61 Ask posture, said so. */
|
|
155
|
+
export const CURSOR_ASK_UNACKED_NOTE = 'Ask mode (read-only) for this app version.'
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 6.62.0 /qa (W9): the note a verdict carries when the read missed its budget, so a Cursor
|
|
159
|
+
* Continue never runs Run Everything without saying so. Claude has none (unchanged).
|
|
160
|
+
*/
|
|
161
|
+
export function fallbackContinueNote(
|
|
162
|
+
provider: string,
|
|
163
|
+
options: { noteAck: boolean; env?: NodeJS.ProcessEnv; codexFallbackSandbox?: 'read-only' | 'workspace-write' },
|
|
164
|
+
): string | null {
|
|
165
|
+
if (provider !== 'codex' && provider !== 'cursor') return null
|
|
166
|
+
if (!continueFullPermissionsEnabled(options.env)) return killSwitchNote(provider, options.codexFallbackSandbox ?? 'read-only')
|
|
167
|
+
if (provider === 'codex') return 'This Codex session\'s own access.'
|
|
168
|
+
return options.noteAck ? 'Run Everything on this chat\'s model.' : CURSOR_ASK_UNACKED_NOTE
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** An IDE composer is not resumed from outside; a turn queued for it runs in Cursor. */
|
|
172
|
+
export const CURSOR_COMPOSER_NOTE = 'Runs in Cursor\'s own chat with its settings.'
|
|
173
|
+
|
|
174
|
+
/** Belt and braces for every note that reaches the wire (plan 3.12). */
|
|
175
|
+
export function boundedContinueNote(note: unknown): string | null {
|
|
176
|
+
if (typeof note !== 'string') return null
|
|
177
|
+
const trimmed = note.replace(/\s+/g, ' ').trim()
|
|
178
|
+
if (trimmed.length === 0) return null
|
|
179
|
+
return trimmed.length <= CONTINUE_NOTE_MAX ? trimmed : null
|
|
180
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The Continue side's own counters on /api/health (6.62.0 /qa W6): spawn isolation, Codex
|
|
2
|
+
// posture reads and Cursor model reads. Until this they were counted and never read, so a
|
|
3
|
+
// Mac whose isolation kept failing open looked exactly like one where it worked.
|
|
4
|
+
|
|
5
|
+
import { cursorSpawnStats } from './cursor-spawn-env.js'
|
|
6
|
+
import { postureReadStats } from './codex-session-posture.js'
|
|
7
|
+
import { cursorModelReadStats } from './cursor-session-model.js'
|
|
8
|
+
import { cursorChatHolderStats } from './occupancy-probes.js'
|
|
9
|
+
|
|
10
|
+
export function continueReadHealthFields(): {
|
|
11
|
+
continueReads: {
|
|
12
|
+
cursorSpawn: typeof cursorSpawnStats
|
|
13
|
+
codexPosture: typeof postureReadStats
|
|
14
|
+
cursorModel: typeof cursorModelReadStats
|
|
15
|
+
cursorChatHolders: typeof cursorChatHolderStats
|
|
16
|
+
}
|
|
17
|
+
} {
|
|
18
|
+
return {
|
|
19
|
+
continueReads: {
|
|
20
|
+
cursorSpawn: { ...cursorSpawnStats },
|
|
21
|
+
codexPosture: { ...postureReadStats },
|
|
22
|
+
cursorModel: { ...cursorModelReadStats },
|
|
23
|
+
cursorChatHolders: { ...cursorChatHolderStats },
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Cursor Agent CLI bridge — ask or full agent (`--force` + `--sandbox disabled`).
|
|
2
2
|
// Fail-closed: never fall through to Claude.
|
|
3
3
|
|
|
4
|
-
import { spawn } from 'node:child_process'
|
|
4
|
+
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
|
5
5
|
import { logTokenAudit } from './token-audit.js'
|
|
6
6
|
import { cleanupModelImageInputs, type ModelImageInput } from './model-image-input.js'
|
|
7
7
|
import { buildSystemPrompt, buildLightweightSystemPrompt } from './context-builder.js'
|
|
@@ -64,6 +64,8 @@ import {
|
|
|
64
64
|
} from '../../shared/media-attachment.js'
|
|
65
65
|
import { terminalProviderAuthFailure } from './provider-terminal-error.js'
|
|
66
66
|
import { teeJobTrail } from './job-trail.js'
|
|
67
|
+
import { cursorSpawnEnv, releaseCursorSpawnOnExit } from './cursor-spawn-env.js'
|
|
68
|
+
import { applyTurnRequestEnv, createTurnRequestBinding, spawnWithTurnRequest } from './turn-request.js'
|
|
67
69
|
|
|
68
70
|
const INACTIVITY_MS = 180_000
|
|
69
71
|
const WALL_MAX_MS = 900_000
|
|
@@ -355,12 +357,24 @@ export async function callCursorStreaming(
|
|
|
355
357
|
const env = { ...process.env }
|
|
356
358
|
delete env.CLAUDECODE
|
|
357
359
|
if (outputImagePublisher) Object.assign(env, outputImagePublisher.env)
|
|
360
|
+
const turnRequest = createTurnRequestBinding({ userText: query, prompt, turnId: options?.turnId })
|
|
361
|
+
applyTurnRequestEnv(env, turnRequest)
|
|
362
|
+
// 6.62.0 (plan 3.4): Messages and reviews pass `--model`; the child's own config dir keeps
|
|
363
|
+
// that from rewriting the person's CLI default (C9). Removed when the child exits.
|
|
364
|
+
const isolation = cursorSpawnEnv({ baseEnv: env })
|
|
358
365
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
366
|
+
let proc: ChildProcessWithoutNullStreams
|
|
367
|
+
try {
|
|
368
|
+
proc = spawnWithTurnRequest(turnRequest, () => spawn(agentBinary, args, {
|
|
369
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
370
|
+
env: isolation.env,
|
|
371
|
+
cwd: cursorCwd,
|
|
372
|
+
}))
|
|
373
|
+
} catch (error) {
|
|
374
|
+
isolation.release()
|
|
375
|
+
throw error
|
|
376
|
+
}
|
|
377
|
+
releaseCursorSpawnOnExit(proc, isolation)
|
|
364
378
|
|
|
365
379
|
let fullText = ''
|
|
366
380
|
let stderr = ''
|
|
@@ -606,12 +620,18 @@ export async function callCursorStreaming(
|
|
|
606
620
|
}
|
|
607
621
|
}, WALL_MAX_MS)
|
|
608
622
|
|
|
623
|
+
let nativeSessionAnnounced = false
|
|
609
624
|
function handleEvent(event: any) {
|
|
610
625
|
const nextSessionId = extractCursorSessionId(event)
|
|
611
626
|
if (nextSessionId && nextSessionId !== cursorChatId) {
|
|
612
627
|
cursorChatId = nextSessionId
|
|
613
628
|
updateCursorRun(run.runId, { cursorChatId })
|
|
614
629
|
}
|
|
630
|
+
// 6.62.0 (plan 3.9): the job links to its native chat at the first event naming it.
|
|
631
|
+
if (nextSessionId && !nativeSessionAnnounced) {
|
|
632
|
+
nativeSessionAnnounced = true
|
|
633
|
+
try { callbacks.onNativeSession?.({ provider: 'cursor', cliSessionId: nextSessionId }) } catch { /* best effort */ }
|
|
634
|
+
}
|
|
615
635
|
|
|
616
636
|
if (event?.type === 'system' && event?.subtype === 'init') {
|
|
617
637
|
callbacks.onToolStatus?.(
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// "New session with this context" for Cursor (6.62.0, D4, plan 3.7, W9).
|
|
2
|
+
//
|
|
3
|
+
// Cursor's Agent CLI cannot fork a chat. So the Cursor Fork is a NEW read-only chat
|
|
4
|
+
// (`fork-thread.ts` `buildCursorForkArgs`) whose first prompt carries a bounded bundle of
|
|
5
|
+
// the source chat, then the person's message. Three things live here, all pure or bounded:
|
|
6
|
+
//
|
|
7
|
+
// - the bundle (title, first request, the last turns), always under MAX_PROMPT_CHARS;
|
|
8
|
+
// - the workspace: the hook signal's cwd, else the IDE composer's own recorded folder
|
|
9
|
+
// (`composerData.workspaceIdentifier`), else the CLI chat's spawn spelling. IDE
|
|
10
|
+
// composers have no `spawnWorkspace`, which is why the 6.61 lookup refused them all;
|
|
11
|
+
// - the model: the session's own slug when the installed Cursor lists it, else the
|
|
12
|
+
// fork fallback.
|
|
13
|
+
|
|
14
|
+
import type { SessionTurn } from './agent-session-turns.js'
|
|
15
|
+
|
|
16
|
+
export const CURSOR_FORK_TURNS = 12
|
|
17
|
+
const MAX_TURN_CHARS = 4_000
|
|
18
|
+
const MAX_FIRST_PROMPT_CHARS = 1_500
|
|
19
|
+
const MAX_TITLE_CHARS = 200
|
|
20
|
+
|
|
21
|
+
export interface CursorForkPromptInput {
|
|
22
|
+
title: string | null
|
|
23
|
+
firstPrompt: string | null
|
|
24
|
+
turns: readonly SessionTurn[]
|
|
25
|
+
message: string
|
|
26
|
+
maxChars: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function clip(text: string, max: number): string {
|
|
30
|
+
const clean = text.replace(/\r\n/g, '\n').trim()
|
|
31
|
+
return clean.length <= max ? clean : `${clean.slice(0, max - 1)}…`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The first prompt of the new chat. The message is always whole and always last; context
|
|
36
|
+
* is dropped oldest-first until the whole fits `maxChars`. A message that leaves no room
|
|
37
|
+
* goes alone.
|
|
38
|
+
*/
|
|
39
|
+
export function buildCursorForkPrompt(input: CursorForkPromptInput): string {
|
|
40
|
+
const message = input.message
|
|
41
|
+
const tail = `\n\nNew message:\n${message}`
|
|
42
|
+
// 6.62.0 /qa (W10): Cursor titles a chat by its first line, so the FIRST line is the source's
|
|
43
|
+
// title (else the person's message), never a fixed header every fork would share.
|
|
44
|
+
const titleLine = input.title && input.title.trim()
|
|
45
|
+
? clip(input.title.split('\n')[0]!, MAX_TITLE_CHARS)
|
|
46
|
+
: clip(message.split('\n').find(line => line.trim()) ?? message, MAX_TITLE_CHARS)
|
|
47
|
+
const header = 'A new chat that carries on from an earlier Cursor chat. Its context, for reference only:'
|
|
48
|
+
const lines: string[] = [titleLine, '', header, '']
|
|
49
|
+
if (input.title && input.title.trim()) lines.push(`Title: ${clip(input.title, MAX_TITLE_CHARS)}`)
|
|
50
|
+
if (input.firstPrompt && input.firstPrompt.trim()) lines.push(`First request: ${clip(input.firstPrompt, MAX_FIRST_PROMPT_CHARS)}`)
|
|
51
|
+
const head = lines.join('\n')
|
|
52
|
+
const rendered = input.turns
|
|
53
|
+
.filter(turn => typeof turn?.text === 'string' && turn.text.trim())
|
|
54
|
+
.map(turn => `${turn.role === 'user' ? 'User' : 'Assistant'}: ${clip(turn.text, MAX_TURN_CHARS)}`)
|
|
55
|
+
const budget = input.maxChars - tail.length
|
|
56
|
+
if (budget <= head.length) return message.length <= input.maxChars ? message : message.slice(0, input.maxChars)
|
|
57
|
+
// Newest turns are kept; the oldest go first.
|
|
58
|
+
const kept: string[] = []
|
|
59
|
+
let used = head.length + '\n\nRecent conversation (oldest first):'.length
|
|
60
|
+
for (let i = rendered.length - 1; i >= 0; i--) {
|
|
61
|
+
const cost = rendered[i]!.length + 1
|
|
62
|
+
if (used + cost > budget) break
|
|
63
|
+
kept.unshift(rendered[i]!)
|
|
64
|
+
used += cost
|
|
65
|
+
}
|
|
66
|
+
const body = kept.length > 0 ? `${head}\n\nRecent conversation (oldest first):\n${kept.join('\n')}` : head
|
|
67
|
+
return `${body}${tail}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface CursorForkWorkspaceDeps {
|
|
71
|
+
/** The hook signal's cwd for this session, or null. */
|
|
72
|
+
signalCwd: (threadId: string) => string | null
|
|
73
|
+
/** The IDE composer's recorded workspace folder, or null. */
|
|
74
|
+
composerWorkspace: (threadId: string) => Promise<string | null>
|
|
75
|
+
/** The CLI chat's spawn spelling (`spawnWorkspace`), or null. */
|
|
76
|
+
spawnWorkspace: (threadId: string) => string | null
|
|
77
|
+
/** MUST throw on "cannot tell"; false only for absent. */
|
|
78
|
+
dirExists: (path: string) => boolean
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function usable(path: unknown, deps: CursorForkWorkspaceDeps): path is string {
|
|
82
|
+
if (typeof path !== 'string' || !path.startsWith('/') || path.includes('\0')) return false
|
|
83
|
+
try {
|
|
84
|
+
return deps.dirExists(path) === true
|
|
85
|
+
} catch {
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Where the new chat runs. Null refuses the fork: never the server's own cwd. */
|
|
91
|
+
export async function resolveCursorForkWorkspace(threadId: string, deps: CursorForkWorkspaceDeps): Promise<string | null> {
|
|
92
|
+
let candidate: string | null = null
|
|
93
|
+
try { candidate = deps.signalCwd(threadId) } catch { candidate = null }
|
|
94
|
+
if (usable(candidate, deps)) return candidate
|
|
95
|
+
try { candidate = await deps.composerWorkspace(threadId) } catch { candidate = null }
|
|
96
|
+
if (usable(candidate, deps)) return candidate
|
|
97
|
+
try { candidate = deps.spawnWorkspace(threadId) } catch { candidate = null }
|
|
98
|
+
if (usable(candidate, deps)) return candidate
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface CursorForkPrepDeps {
|
|
103
|
+
transcriptPath: (threadId: string) => string | null
|
|
104
|
+
readTurns: (path: string, limit: number) => Promise<SessionTurn[]>
|
|
105
|
+
readTitle: (path: string, threadId: string) => Promise<{ title: string | null; firstPrompt: string | null }>
|
|
106
|
+
/** The session's own model as a slug the installed Cursor lists, or null. */
|
|
107
|
+
sessionModel: (threadId: string, path: string | null) => Promise<string | null>
|
|
108
|
+
maxChars: number
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The prompt and model for one Cursor fork. A failed read costs context, never the fork:
|
|
113
|
+
* with nothing readable the new chat gets the message alone and the fallback model.
|
|
114
|
+
*/
|
|
115
|
+
export async function prepareCursorFork(
|
|
116
|
+
threadId: string,
|
|
117
|
+
message: string,
|
|
118
|
+
deps: CursorForkPrepDeps,
|
|
119
|
+
): Promise<{ prompt: string; cursorModel: string | null }> {
|
|
120
|
+
let path: string | null = null
|
|
121
|
+
try { path = deps.transcriptPath(threadId) } catch { path = null }
|
|
122
|
+
let turns: SessionTurn[] = []
|
|
123
|
+
let title: string | null = null
|
|
124
|
+
let firstPrompt: string | null = null
|
|
125
|
+
// 6.62.0 /qa (W17): a fork that loses its context says so in the server log.
|
|
126
|
+
const lost: string[] = []
|
|
127
|
+
if (path) {
|
|
128
|
+
try { turns = await deps.readTurns(path, CURSOR_FORK_TURNS) } catch { turns = []; lost.push('turns') }
|
|
129
|
+
try { ({ title, firstPrompt } = await deps.readTitle(path, threadId)) } catch { title = null; firstPrompt = null; lost.push('title') }
|
|
130
|
+
} else {
|
|
131
|
+
lost.push('transcript')
|
|
132
|
+
}
|
|
133
|
+
let cursorModel: string | null = null
|
|
134
|
+
try { cursorModel = await deps.sessionModel(threadId, path) } catch { cursorModel = null; lost.push('model') }
|
|
135
|
+
if (lost.length > 0) console.warn(`[cursor-fork] context lost thread=${threadId.slice(0, 8)} missing=${lost.join(',')}`)
|
|
136
|
+
return {
|
|
137
|
+
prompt: buildCursorForkPrompt({ title, firstPrompt, turns, message, maxChars: deps.maxChars }),
|
|
138
|
+
cursorModel,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
// Stable app slots (cursor-grok / cursor-composer) map to concrete CLI model
|
|
4
4
|
// ids proven in Phase 0. `agent models` is text-only — parse + cache it.
|
|
5
5
|
|
|
6
|
-
import { spawn } from 'node:child_process'
|
|
6
|
+
import { spawn, type ChildProcessByStdio } from 'node:child_process'
|
|
7
|
+
import type { Readable } from 'node:stream'
|
|
7
8
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
8
9
|
import { dirname, resolve } from 'node:path'
|
|
9
10
|
import { resolveProviderBinary } from './provider-binary.js'
|
|
11
|
+
import { cursorSpawnEnv, releaseCursorSpawnOnExit } from './cursor-spawn-env.js'
|
|
10
12
|
import {
|
|
11
13
|
CURSOR_COMPOSER_MODEL,
|
|
12
14
|
CURSOR_GROK_MODEL,
|
|
@@ -187,6 +189,21 @@ function unavailableCatalog(refreshError?: string, agentBinary?: string): Cursor
|
|
|
187
189
|
return buildCursorModelCatalog([], 'unavailable', new Date().toISOString(), agentBinary, refreshError)
|
|
188
190
|
}
|
|
189
191
|
|
|
192
|
+
function readDiskModels(): CursorCatalogModel[] {
|
|
193
|
+
const path = catalogCachePath()
|
|
194
|
+
if (!existsSync(path)) return []
|
|
195
|
+
try {
|
|
196
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as { models?: CursorCatalogModel[] }
|
|
197
|
+
return Array.isArray(parsed.models)
|
|
198
|
+
? parsed.models
|
|
199
|
+
.filter(m => typeof m?.id === 'string' && m.id)
|
|
200
|
+
.map(m => ({ id: m.id, displayName: normalizeCursorDisplayName(typeof m.displayName === 'string' ? m.displayName : '') }))
|
|
201
|
+
: []
|
|
202
|
+
} catch {
|
|
203
|
+
return []
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
190
207
|
function readDiskCatalog(): CursorModelCatalog | null {
|
|
191
208
|
const path = catalogCachePath()
|
|
192
209
|
if (!existsSync(path)) return null
|
|
@@ -244,10 +261,19 @@ async function fetchCliModels(agentBinary: string): Promise<CursorCatalogModel[]
|
|
|
244
261
|
return new Promise((resolveModels, reject) => {
|
|
245
262
|
const env = { ...process.env }
|
|
246
263
|
delete env.CLAUDECODE
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
264
|
+
// 6.62.0 (plan 3.4): every `agent` spawn runs on a config dir of its own.
|
|
265
|
+
const isolation = cursorSpawnEnv({ baseEnv: env })
|
|
266
|
+
let child: ChildProcessByStdio<null, Readable, Readable>
|
|
267
|
+
try {
|
|
268
|
+
child = spawn(agentBinary, ['models'], {
|
|
269
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
270
|
+
env: isolation.env,
|
|
271
|
+
})
|
|
272
|
+
} catch (error) {
|
|
273
|
+
isolation.release()
|
|
274
|
+
throw error
|
|
275
|
+
}
|
|
276
|
+
releaseCursorSpawnOnExit(child, isolation)
|
|
251
277
|
let settled = false
|
|
252
278
|
let stdout = ''
|
|
253
279
|
let stderr = ''
|
|
@@ -283,6 +309,36 @@ async function fetchCliModels(agentBinary: string): Promise<CursorCatalogModel[]
|
|
|
283
309
|
let catalogSnapshot = readDiskCatalog() ?? unavailableCatalog()
|
|
284
310
|
let refreshPromise: Promise<CursorModelCatalog> | null = null
|
|
285
311
|
|
|
312
|
+
/**
|
|
313
|
+
* 6.62.0 (plan 3.3, W17): EVERY model `agent models` listed, kept in memory beside the two
|
|
314
|
+
* slot `options`. The options stay two because the phone's slots read them
|
|
315
|
+
* (work-phone.ts); this list is what a Continue validates a session's own model against,
|
|
316
|
+
* so it can pass any model and modifier the installed Cursor offers.
|
|
317
|
+
*/
|
|
318
|
+
let fullModelList: CursorCatalogModel[] = readDiskModels()
|
|
319
|
+
|
|
320
|
+
export function getFullCursorModelList(): readonly CursorCatalogModel[] {
|
|
321
|
+
return fullModelList
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Exact, case-insensitive membership in the full list. */
|
|
325
|
+
export function isKnownCursorModel(modelId: string): boolean {
|
|
326
|
+
const wanted = typeof modelId === 'string' ? modelId.trim().toLowerCase() : ''
|
|
327
|
+
return wanted.length > 0 && fullModelList.some(model => model.id.toLowerCase() === wanted)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** The CLI's own display name for a listed model (normalized), or null. */
|
|
331
|
+
export function cursorModelDisplayName(modelId: string): string | null {
|
|
332
|
+
const wanted = typeof modelId === 'string' ? modelId.trim().toLowerCase() : ''
|
|
333
|
+
const found = fullModelList.find(model => model.id.toLowerCase() === wanted)
|
|
334
|
+
return found ? normalizeCursorDisplayName(found.displayName) || null : null
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Tests only. */
|
|
338
|
+
export function __setFullCursorModelListForTests(models: CursorCatalogModel[]): void {
|
|
339
|
+
fullModelList = [...models]
|
|
340
|
+
}
|
|
341
|
+
|
|
286
342
|
export function getCursorModelCatalogSnapshot(): CursorModelCatalog {
|
|
287
343
|
return catalogSnapshot
|
|
288
344
|
}
|
|
@@ -332,6 +388,7 @@ export async function getCursorModelCatalog(forceRefresh = false): Promise<Curso
|
|
|
332
388
|
try {
|
|
333
389
|
const models = await fetchCliModels(agentBinary)
|
|
334
390
|
catalogSnapshot = buildCursorModelCatalog(models, 'cli', new Date().toISOString(), agentBinary)
|
|
391
|
+
fullModelList = models
|
|
335
392
|
writeDiskCatalog(models, agentBinary)
|
|
336
393
|
} catch (err) {
|
|
337
394
|
const refreshError = err instanceof Error ? err.message : 'Live Cursor model discovery unavailable.'
|