@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,339 @@
|
|
|
1
|
+
// Which model a Cursor session runs on, read from Cursor's own stores (6.62.0, plans 1.8 + 3.3).
|
|
2
|
+
//
|
|
3
|
+
// WHY. `agent -p --resume <id>` WITHOUT `--model` runs the GLOBAL CLI default, not the
|
|
4
|
+
// chat's model (canary C5: a composer chat resumed as grok when the default was grok). So
|
|
5
|
+
// a Continue that keeps the session's model has to read it and pass it. Flat slugs only:
|
|
6
|
+
// bracket ids (`grok-4.7[effort=low,fast=true]`) fail pre-flight (C5d).
|
|
7
|
+
//
|
|
8
|
+
// TWO STORES, TWO READS.
|
|
9
|
+
// IDE composer `globalStorage/state.vscdb`, table `cursorDiskKV`, key
|
|
10
|
+
// `composerData:<id>`, field `modelConfig` = `{modelName, selectedModels:
|
|
11
|
+
// [{modelId, parameters: [{id: 'reasoning_effort', value}, {id: 'fast'}]}]}`.
|
|
12
|
+
// The database is 8.9 GB: EXACT keys only, never a LIKE scan, in a query of
|
|
13
|
+
// its OWN (never shared with the names query, so a slow read here cannot blank
|
|
14
|
+
// the titles), 2 s timeout, 60 s cache per id (validation W17: 18 ms for 20).
|
|
15
|
+
// CLI chat `~/.cursor/chats/<hash>/<id>/store.db`: the newest blob naming
|
|
16
|
+
// `providerOptions.cursor.modelName`, already a flat slug. Opened
|
|
17
|
+
// `?mode=ro&immutable=1` (plain `mode=ro` fails SQLITE_CANTOPEN on these),
|
|
18
|
+
// which ignores the WAL, so the answer can trail by one turn. Cached on the
|
|
19
|
+
// file's (mtime, size). Its `meta` row also says whether COS created the chat
|
|
20
|
+
// in Ask mode, which a Run Everything Continue must name (W14).
|
|
21
|
+
//
|
|
22
|
+
// Every failure is "unknown": the field is omitted and the Continue falls back to
|
|
23
|
+
// composer-2.5-fast with a note. A read here never blocks a list.
|
|
24
|
+
|
|
25
|
+
import { execFile } from 'node:child_process'
|
|
26
|
+
import { statSync } from 'node:fs'
|
|
27
|
+
import { stat } from 'node:fs/promises'
|
|
28
|
+
import { join } from 'node:path'
|
|
29
|
+
import { isValidNativeThreadId } from './native-thread-id.js'
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
export const SQLITE3_BIN = '/usr/bin/sqlite3'
|
|
33
|
+
export const COMPOSER_MODEL_TTL_MS = 60_000
|
|
34
|
+
export const COMPOSER_MODEL_TIMEOUT_MS = 2_000
|
|
35
|
+
/** Keep the IN list bounded; a list page asks for at most a few dozen composers. */
|
|
36
|
+
const MAX_COMPOSER_IDS_PER_QUERY = 64
|
|
37
|
+
const MAX_CACHE_ENTRIES = 512
|
|
38
|
+
|
|
39
|
+
/** The fallback model when a session's own model cannot be read or is not listed. */
|
|
40
|
+
export const CURSOR_CONTINUE_FALLBACK_MODEL = 'composer-2.5-fast'
|
|
41
|
+
|
|
42
|
+
export interface CursorModelConfig {
|
|
43
|
+
modelName?: unknown
|
|
44
|
+
maxMode?: unknown
|
|
45
|
+
selectedModels?: unknown
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ComposerFacts {
|
|
49
|
+
modelConfig: CursorModelConfig | null
|
|
50
|
+
/** `workspaceIdentifier.uri.fsPath`: the folder the composer runs in, when recorded. */
|
|
51
|
+
workspace: string | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const SLUG_PART_RE = /^[a-z0-9][a-z0-9.]*$/i
|
|
55
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,99}$/i
|
|
56
|
+
|
|
57
|
+
function paramValue(parameters: unknown, id: string): string | null {
|
|
58
|
+
if (!Array.isArray(parameters)) return null
|
|
59
|
+
for (const entry of parameters) {
|
|
60
|
+
if (!entry || typeof entry !== 'object') continue
|
|
61
|
+
const row = entry as { id?: unknown; value?: unknown }
|
|
62
|
+
if (row.id !== id) continue
|
|
63
|
+
if (typeof row.value === 'string') return row.value.trim()
|
|
64
|
+
if (typeof row.value === 'boolean') return row.value ? 'true' : 'false'
|
|
65
|
+
}
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Flat slugs for an IDE composer's model, most specific first: family, then
|
|
71
|
+
* `-<reasoning_effort>`, then `-fast` (`grok-4.7` + xhigh + fast = `grok-4.7-xhigh-fast`).
|
|
72
|
+
* The caller validates against the full `agent models` list and takes the first listed.
|
|
73
|
+
*/
|
|
74
|
+
export function cursorSlugCandidates(config: CursorModelConfig | null | undefined): string[] {
|
|
75
|
+
if (!config || typeof config !== 'object') return []
|
|
76
|
+
const selected = Array.isArray(config.selectedModels) ? config.selectedModels[0] as { modelId?: unknown; parameters?: unknown } | undefined : undefined
|
|
77
|
+
const family = typeof selected?.modelId === 'string' && selected.modelId.trim()
|
|
78
|
+
? selected.modelId.trim()
|
|
79
|
+
: typeof config.modelName === 'string' ? config.modelName.trim() : ''
|
|
80
|
+
if (!family || !SLUG_RE.test(family)) return []
|
|
81
|
+
const effort = paramValue(selected?.parameters, 'reasoning_effort')
|
|
82
|
+
const fastRaw = paramValue(selected?.parameters, 'fast') ?? paramValue(selected?.parameters, 'speed')
|
|
83
|
+
const fast = fastRaw === 'true' || fastRaw === 'fast'
|
|
84
|
+
const usableEffort = effort && SLUG_PART_RE.test(effort) ? effort.toLowerCase() : null
|
|
85
|
+
const out: string[] = []
|
|
86
|
+
const push = (slug: string) => { if (SLUG_RE.test(slug) && !out.includes(slug)) out.push(slug) }
|
|
87
|
+
if (usableEffort && fast) push(`${family}-${usableEffort}-fast`)
|
|
88
|
+
if (usableEffort) push(`${family}-${usableEffort}`)
|
|
89
|
+
if (fast) push(`${family}-fast`)
|
|
90
|
+
push(family)
|
|
91
|
+
return out
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The first candidate the installed Cursor lists, or null. */
|
|
95
|
+
export function pickListedCursorModel(candidates: readonly string[], isKnown: (id: string) => boolean): string | null {
|
|
96
|
+
for (const candidate of candidates) {
|
|
97
|
+
try {
|
|
98
|
+
if (isKnown(candidate)) return candidate
|
|
99
|
+
} catch {
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// IDE composer: state.vscdb
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
const composerCache = new Map<string, { at: number; facts: ComposerFacts | null }>()
|
|
111
|
+
|
|
112
|
+
/** Tests only. */
|
|
113
|
+
export function __resetCursorSessionModelCachesForTests(): void {
|
|
114
|
+
composerCache.clear()
|
|
115
|
+
chatCache.clear()
|
|
116
|
+
cursorModelReadStats.composerQueries = 0
|
|
117
|
+
cursorModelReadStats.chatQueries = 0
|
|
118
|
+
cursorModelReadStats.composerFailures = 0
|
|
119
|
+
cursorModelReadStats.chatFailures = 0
|
|
120
|
+
chatFailures.clear()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const cursorModelReadStats = { composerQueries: 0, chatQueries: 0, composerFailures: 0, chatFailures: 0 }
|
|
124
|
+
|
|
125
|
+
export type SqliteRunner = (args: readonly string[], timeoutMs: number) => Promise<string>
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 6.62.0 /qa (W7): the sqlite3 children COS has running right now. They hold a chat's
|
|
129
|
+
* `store.db` open for a moment, and the busy probe must not mistake COS's own read for a run.
|
|
130
|
+
*/
|
|
131
|
+
const sqliteReaders = new Set<number>()
|
|
132
|
+
export function activeSqliteReaderPids(): number[] {
|
|
133
|
+
return [...sqliteReaders]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const realSqliteRunner: SqliteRunner = (args, timeoutMs) => new Promise((resolve, reject) => {
|
|
137
|
+
const child = execFile(SQLITE3_BIN, [...args], { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024, encoding: 'utf8' }, (error, stdout) => {
|
|
138
|
+
if (typeof child.pid === 'number') sqliteReaders.delete(child.pid)
|
|
139
|
+
if (error) reject(error)
|
|
140
|
+
else resolve(String(stdout))
|
|
141
|
+
})
|
|
142
|
+
if (typeof child.pid === 'number') sqliteReaders.add(child.pid)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
function parseJsonText(value: unknown): unknown {
|
|
146
|
+
if (typeof value !== 'string' || value.length === 0) return null
|
|
147
|
+
try {
|
|
148
|
+
return JSON.parse(value)
|
|
149
|
+
} catch {
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Model config and workspace for each composer id, from ONE exact-key query. Ids that are
|
|
156
|
+
* not native thread ids are dropped before they reach SQL. Cached 60 s per id, including a
|
|
157
|
+
* miss, so a composer with no row is not re-queried on every poll.
|
|
158
|
+
*/
|
|
159
|
+
export async function readComposerFacts(
|
|
160
|
+
ids: readonly string[],
|
|
161
|
+
dbPath: string,
|
|
162
|
+
options: { now?: number; run?: SqliteRunner } = {},
|
|
163
|
+
): Promise<Map<string, ComposerFacts | null>> {
|
|
164
|
+
const now = options.now ?? Date.now()
|
|
165
|
+
const out = new Map<string, ComposerFacts | null>()
|
|
166
|
+
const wanted: string[] = []
|
|
167
|
+
for (const raw of ids) {
|
|
168
|
+
const id = typeof raw === 'string' ? raw.toLowerCase() : ''
|
|
169
|
+
if (!isValidNativeThreadId(id) || out.has(id) || wanted.includes(id)) continue
|
|
170
|
+
const hit = composerCache.get(id)
|
|
171
|
+
if (hit && now - hit.at < COMPOSER_MODEL_TTL_MS) out.set(id, hit.facts)
|
|
172
|
+
else if (wanted.length < MAX_COMPOSER_IDS_PER_QUERY) wanted.push(id)
|
|
173
|
+
}
|
|
174
|
+
if (wanted.length === 0 || !dbPath) return out
|
|
175
|
+
try {
|
|
176
|
+
const st = await stat(dbPath)
|
|
177
|
+
if (!st.isFile()) return out
|
|
178
|
+
} catch {
|
|
179
|
+
return out
|
|
180
|
+
}
|
|
181
|
+
const keys = wanted.map(id => `'composerData:${id}'`).join(',')
|
|
182
|
+
const sql = `SELECT key AS k, json_extract(value, '$.modelConfig') AS mc, json_extract(value, '$.workspaceIdentifier.uri.fsPath') AS ws FROM cursorDiskKV WHERE key IN (${keys})`
|
|
183
|
+
// 6.62.0 /qa (W11): a FAILED read is cached for the same 60 s as an answer, so a locked
|
|
184
|
+
// or slow database costs one 2 s wait per minute, not one per poll.
|
|
185
|
+
const rememberMiss = (): Map<string, ComposerFacts | null> => {
|
|
186
|
+
cursorModelReadStats.composerFailures += 1
|
|
187
|
+
for (const id of wanted) {
|
|
188
|
+
composerCache.delete(id)
|
|
189
|
+
composerCache.set(id, { at: now, facts: null })
|
|
190
|
+
out.set(id, null)
|
|
191
|
+
}
|
|
192
|
+
return out
|
|
193
|
+
}
|
|
194
|
+
let stdout: string
|
|
195
|
+
try {
|
|
196
|
+
cursorModelReadStats.composerQueries += 1
|
|
197
|
+
stdout = await (options.run ?? realSqliteRunner)(['-readonly', '-json', dbPath, sql], COMPOSER_MODEL_TIMEOUT_MS)
|
|
198
|
+
} catch {
|
|
199
|
+
return rememberMiss()
|
|
200
|
+
}
|
|
201
|
+
const found = new Map<string, ComposerFacts>()
|
|
202
|
+
try {
|
|
203
|
+
const rows = JSON.parse(stdout.trim() || '[]') as Array<{ k?: unknown; mc?: unknown; ws?: unknown }>
|
|
204
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
205
|
+
const key = typeof row.k === 'string' ? row.k : ''
|
|
206
|
+
const id = key.startsWith('composerData:') ? key.slice('composerData:'.length).toLowerCase() : ''
|
|
207
|
+
if (!isValidNativeThreadId(id)) continue
|
|
208
|
+
const mc = parseJsonText(row.mc)
|
|
209
|
+
found.set(id, {
|
|
210
|
+
modelConfig: mc && typeof mc === 'object' && !Array.isArray(mc) ? mc as CursorModelConfig : null,
|
|
211
|
+
workspace: typeof row.ws === 'string' && row.ws.startsWith('/') && !row.ws.includes('\0') ? row.ws : null,
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
return rememberMiss()
|
|
216
|
+
}
|
|
217
|
+
for (const id of wanted) {
|
|
218
|
+
const facts = found.get(id) ?? null
|
|
219
|
+
composerCache.delete(id)
|
|
220
|
+
composerCache.set(id, { at: now, facts })
|
|
221
|
+
out.set(id, facts)
|
|
222
|
+
}
|
|
223
|
+
while (composerCache.size > MAX_CACHE_ENTRIES) {
|
|
224
|
+
const oldest = composerCache.keys().next()
|
|
225
|
+
if (oldest.done) break
|
|
226
|
+
composerCache.delete(oldest.value)
|
|
227
|
+
}
|
|
228
|
+
return out
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// CLI chat: store.db
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
export interface CliChatFacts {
|
|
236
|
+
/** The newest `providerOptions.cursor.modelName`, a flat slug. */
|
|
237
|
+
model: string | null
|
|
238
|
+
/** The chat's `meta` mode (`ask`, `default`, …). */
|
|
239
|
+
mode: string | null
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const chatCache = new Map<string, { mtimeMs: number; size: number; facts: CliChatFacts | null }>()
|
|
243
|
+
/** 6.62.0 /qa (W11): when a chat's read last FAILED; a failure is not retried for 60 s. */
|
|
244
|
+
const chatFailures = new Map<string, number>()
|
|
245
|
+
const MODEL_NAME_RE = /"modelName"\s*:\s*"([^"\\]{1,100})"/g
|
|
246
|
+
|
|
247
|
+
function hexToText(hex: string): string | null {
|
|
248
|
+
const clean = hex.trim()
|
|
249
|
+
if (!/^[0-9a-f]*$/i.test(clean) || clean.length % 2 !== 0) return null
|
|
250
|
+
try {
|
|
251
|
+
return Buffer.from(clean, 'hex').toString('utf8')
|
|
252
|
+
} catch {
|
|
253
|
+
return null
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The chat's model and mode, or null when `store.db` cannot be read. */
|
|
258
|
+
export async function readCliChatFacts(
|
|
259
|
+
chatDir: string,
|
|
260
|
+
options: { run?: SqliteRunner; now?: number } = {},
|
|
261
|
+
): Promise<CliChatFacts | null> {
|
|
262
|
+
if (typeof chatDir !== 'string' || !chatDir.startsWith('/')) return null
|
|
263
|
+
const dbPath = join(chatDir, 'store.db')
|
|
264
|
+
let mtimeMs: number
|
|
265
|
+
let size: number
|
|
266
|
+
try {
|
|
267
|
+
const st = await stat(dbPath)
|
|
268
|
+
if (!st.isFile()) return null
|
|
269
|
+
mtimeMs = st.mtimeMs
|
|
270
|
+
size = st.size
|
|
271
|
+
} catch {
|
|
272
|
+
return null
|
|
273
|
+
}
|
|
274
|
+
const hit = chatCache.get(dbPath)
|
|
275
|
+
if (hit && hit.mtimeMs === mtimeMs && hit.size === size) return hit.facts
|
|
276
|
+
const failedAt = chatFailures.get(dbPath)
|
|
277
|
+
const now = options.now ?? Date.now()
|
|
278
|
+
if (failedAt !== undefined && now - failedAt < COMPOSER_MODEL_TTL_MS) return null
|
|
279
|
+
// A URI, so the path is percent-encoded: a chat dir under a folder with spaces is ordinary.
|
|
280
|
+
const uri = `file:${encodeURI(dbPath).replace(/\?/g, '%3F').replace(/#/g, '%23')}?mode=ro&immutable=1`
|
|
281
|
+
const run = options.run ?? realSqliteRunner
|
|
282
|
+
let facts: CliChatFacts | null = null
|
|
283
|
+
try {
|
|
284
|
+
cursorModelReadStats.chatQueries += 1
|
|
285
|
+
const blob = await run([uri, "SELECT CAST(data AS TEXT) FROM blobs WHERE instr(CAST(data AS TEXT), '\"modelName\"') > 0 ORDER BY rowid DESC LIMIT 1"], COMPOSER_MODEL_TIMEOUT_MS)
|
|
286
|
+
let model: string | null = null
|
|
287
|
+
for (const match of blob.matchAll(MODEL_NAME_RE)) {
|
|
288
|
+
const candidate = match[1]!.trim()
|
|
289
|
+
if (SLUG_RE.test(candidate)) model = candidate
|
|
290
|
+
}
|
|
291
|
+
let mode: string | null = null
|
|
292
|
+
try {
|
|
293
|
+
// The CLI stores the meta row as HEX of its JSON (measured on CLI 2026.10.01); a row
|
|
294
|
+
// written as plain JSON is read as it is.
|
|
295
|
+
const raw = (await run([uri, "SELECT CAST(value AS TEXT) FROM meta ORDER BY rowid ASC LIMIT 1"], COMPOSER_MODEL_TIMEOUT_MS)).trim()
|
|
296
|
+
const text = raw.startsWith('{') ? raw : hexToText(raw)
|
|
297
|
+
const meta = parseJsonText(text ?? '') as { mode?: unknown } | null
|
|
298
|
+
if (meta && typeof meta.mode === 'string' && /^[a-z-]{1,24}$/i.test(meta.mode)) mode = meta.mode.toLowerCase()
|
|
299
|
+
} catch {
|
|
300
|
+
mode = null
|
|
301
|
+
}
|
|
302
|
+
facts = { model, mode }
|
|
303
|
+
} catch {
|
|
304
|
+
cursorModelReadStats.chatFailures += 1
|
|
305
|
+
chatFailures.delete(dbPath)
|
|
306
|
+
chatFailures.set(dbPath, now)
|
|
307
|
+
while (chatFailures.size > MAX_CACHE_ENTRIES) {
|
|
308
|
+
const oldest = chatFailures.keys().next()
|
|
309
|
+
if (oldest.done) break
|
|
310
|
+
chatFailures.delete(oldest.value)
|
|
311
|
+
}
|
|
312
|
+
return null
|
|
313
|
+
}
|
|
314
|
+
chatFailures.delete(dbPath)
|
|
315
|
+
chatCache.delete(dbPath)
|
|
316
|
+
chatCache.set(dbPath, { mtimeMs, size, facts })
|
|
317
|
+
while (chatCache.size > MAX_CACHE_ENTRIES) {
|
|
318
|
+
const oldest = chatCache.keys().next()
|
|
319
|
+
if (oldest.done) break
|
|
320
|
+
chatCache.delete(oldest.value)
|
|
321
|
+
}
|
|
322
|
+
return facts
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The most recent write to a CLI chat (its `store.db`, the WAL beside it, or its agent
|
|
327
|
+
* transcript), in epoch ms, or null. SYNCHRONOUS: the occupancy gate it feeds is.
|
|
328
|
+
*/
|
|
329
|
+
export function cliChatLastWriteMs(chatDir: string, transcriptPath: string | null): number | null {
|
|
330
|
+
let latest: number | null = null
|
|
331
|
+
for (const path of [join(chatDir, 'store.db'), join(chatDir, 'store.db-wal'), transcriptPath]) {
|
|
332
|
+
if (!path) continue
|
|
333
|
+
try {
|
|
334
|
+
const mtimeMs = statSync(path).mtimeMs
|
|
335
|
+
if (latest === null || mtimeMs > latest) latest = mtimeMs
|
|
336
|
+
} catch { /* absent is fine */ }
|
|
337
|
+
}
|
|
338
|
+
return latest
|
|
339
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Every Cursor `agent` spawn gets a config dir of its own (6.62.0, plan 3.4, canaries C9/K1).
|
|
2
|
+
//
|
|
3
|
+
// WHY. The CLI REWRITES `~/.cursor/cli-config.json` (`model`, `selectedModel`,
|
|
4
|
+
// `modelParameters`, `modelSelectionHistory`) whenever a run passes `--model` (C9,
|
|
5
|
+
// confirmed twice). COS passes `--model` on Continue, Messages, reviews, Live Cues and
|
|
6
|
+
// the lens gist, so every one of those silently changed the person's own CLI default.
|
|
7
|
+
//
|
|
8
|
+
// THE FIX, AS K1 PROVED IT (2026-10-05 09:21): `CURSOR_CONFIG_DIR` = a fresh per-spawn
|
|
9
|
+
// dir (0700) holding a COPY of `cli-config.json` (so auth and permissions follow the
|
|
10
|
+
// person's current config), with symlinks to `~/.cursor/{chats,hooks.json,mcp.json,
|
|
11
|
+
// plugins,skills-cursor}` so a `--resume` finds the chat, the COS observer still fires,
|
|
12
|
+
// and MCP servers still load. The rewrite lands in the copy, and the copy is deleted when
|
|
13
|
+
// the child exits.
|
|
14
|
+
//
|
|
15
|
+
// PER SPAWN, NEVER SHARED. One shared dir races between overlapping spawns (and the CLI
|
|
16
|
+
// already leaves `cli-config.json.*.tmp` files behind by the thousand). `mkdtemp` gives
|
|
17
|
+
// each child its own.
|
|
18
|
+
//
|
|
19
|
+
// CHILD ENV ONLY. `CURSOR_CONFIG_DIR` is set on the env object handed to the spawn, never
|
|
20
|
+
// on `process.env`: the server's own later reads must keep seeing the person's real dir.
|
|
21
|
+
//
|
|
22
|
+
// SYMLINKS ARE NEVER FOLLOWED ON CLEANUP. `release` unlinks each link by name first, then
|
|
23
|
+
// removes the dir. Deleting through `chats` would delete the person's chats.
|
|
24
|
+
//
|
|
25
|
+
// `dictation-clean.ts` is exempt: it builds a STRICTER per-spawn profile of its own (no
|
|
26
|
+
// MCP, no hooks, no inherited permissions), which this must not loosen.
|
|
27
|
+
|
|
28
|
+
import { copyFileSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync, unlinkSync, chmodSync, statSync, existsSync } from 'node:fs'
|
|
29
|
+
import { homedir } from 'node:os'
|
|
30
|
+
import { join } from 'node:path'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What K1 linked, plus `agents` and `plans` (6.62.0 /qa W6), each only when it exists. The
|
|
34
|
+
* census test and `bin/cli.cjs` read this exact list.
|
|
35
|
+
*/
|
|
36
|
+
export const CURSOR_SPAWN_LINKS = ['chats', 'hooks.json', 'mcp.json', 'plugins', 'skills-cursor', 'agents', 'plans'] as const
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 6.62.0 /qa (W6): the code a spawn that MUST be isolated (a Continue, a fork) throws when it
|
|
40
|
+
* cannot be: those paths refuse with an honest reason instead of falling back to the shared
|
|
41
|
+
* config, where `--model` (and Run Everything's own config writes) would land in the
|
|
42
|
+
* person's real CLI default. Every other spawn may still fail open, and logs it.
|
|
43
|
+
*/
|
|
44
|
+
export const CURSOR_ISOLATION_UNAVAILABLE = 'COS_CURSOR_ISOLATION_UNAVAILABLE'
|
|
45
|
+
|
|
46
|
+
export function cursorIsolationUnavailableError(): Error & { code: string } {
|
|
47
|
+
const error = new Error('Cursor spawn isolation unavailable') as Error & { code: string }
|
|
48
|
+
error.code = CURSOR_ISOLATION_UNAVAILABLE
|
|
49
|
+
return error
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isCursorIsolationUnavailable(error: unknown): boolean {
|
|
53
|
+
return !!error && typeof error === 'object' && (error as { code?: unknown }).code === CURSOR_ISOLATION_UNAVAILABLE
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A spawn dir older than this belongs to a server that died mid-run; a Continue lasts 30 min at most. */
|
|
57
|
+
export const CURSOR_SPAWN_STALE_MS = 6 * 60 * 60_000
|
|
58
|
+
|
|
59
|
+
const SPAWN_PREFIX = 'spawn-'
|
|
60
|
+
|
|
61
|
+
export interface CursorSpawnEnvOptions {
|
|
62
|
+
/** The env the caller built for the child (already stripped as it wants). Default `process.env`. */
|
|
63
|
+
baseEnv?: NodeJS.ProcessEnv
|
|
64
|
+
/** The person's Cursor config dir. Default: `CURSOR_CONFIG_DIR` of the server, else `~/.cursor`. */
|
|
65
|
+
sourceDir?: string
|
|
66
|
+
/** Where per-spawn dirs live. Default `~/.cos-glasses/cursor-cli`. */
|
|
67
|
+
root?: string
|
|
68
|
+
now?: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CursorSpawnIsolation {
|
|
72
|
+
/** The child's env. Carries `CURSOR_CONFIG_DIR` only when `isolated`. */
|
|
73
|
+
env: NodeJS.ProcessEnv
|
|
74
|
+
dir: string | null
|
|
75
|
+
isolated: boolean
|
|
76
|
+
/** Idempotent. Unlinks the links by name, then removes the dir. */
|
|
77
|
+
release: () => void
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const cursorSpawnStats = { created: 0, released: 0, failed: 0, swept: 0 }
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `~/.cos-glasses/cursor-cli`. `COS_CURSOR_SPAWN_ROOT` and `COS_CURSOR_SPAWN_SOURCE` exist
|
|
84
|
+
* for the test config only (vitest.config.ts), so no suite that drives a Cursor spawn path
|
|
85
|
+
* can read the live ~/.cursor or write under the live ~/.cos-glasses. Production sets neither.
|
|
86
|
+
*/
|
|
87
|
+
export function cursorSpawnRoot(): string {
|
|
88
|
+
return process.env.COS_CURSOR_SPAWN_ROOT?.trim() || join(homedir(), '.cos-glasses', 'cursor-cli')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function defaultCursorSourceDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
92
|
+
return process.env.COS_CURSOR_SPAWN_SOURCE?.trim() || env.CURSOR_CONFIG_DIR?.trim() || join(homedir(), '.cursor')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Remove one spawn dir without ever following a link out of it. */
|
|
96
|
+
export function removeCursorSpawnDir(dir: string): void {
|
|
97
|
+
for (const name of CURSOR_SPAWN_LINKS) {
|
|
98
|
+
const path = join(dir, name)
|
|
99
|
+
try {
|
|
100
|
+
if (lstatSync(path).isSymbolicLink()) unlinkSync(path)
|
|
101
|
+
} catch { /* absent */ }
|
|
102
|
+
}
|
|
103
|
+
// Anything else the CLI made in here is its own (the rewritten config, a stats cache).
|
|
104
|
+
// A link the CLI made under another name is unlinked, not followed, by rmSync.
|
|
105
|
+
rmSync(dir, { recursive: true, force: true })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Remove spawn dirs a dead server left behind. Never touches anything not named `spawn-*`. */
|
|
109
|
+
export function sweepStaleCursorSpawnDirs(root = cursorSpawnRoot(), now = Date.now(), maxAgeMs = CURSOR_SPAWN_STALE_MS): number {
|
|
110
|
+
let names: string[]
|
|
111
|
+
try { names = readdirSync(root) } catch { return 0 }
|
|
112
|
+
let removed = 0
|
|
113
|
+
for (const name of names) {
|
|
114
|
+
if (!name.startsWith(SPAWN_PREFIX)) continue
|
|
115
|
+
const dir = join(root, name)
|
|
116
|
+
try {
|
|
117
|
+
const st = lstatSync(dir)
|
|
118
|
+
if (!st.isDirectory() || now - st.mtimeMs < maxAgeMs) continue
|
|
119
|
+
removeCursorSpawnDir(dir)
|
|
120
|
+
removed++
|
|
121
|
+
} catch { /* gone, or not ours to judge */ }
|
|
122
|
+
}
|
|
123
|
+
cursorSpawnStats.swept += removed
|
|
124
|
+
return removed
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let lastSweepAt = 0
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A config dir for ONE Cursor child. On any failure the child runs with the caller's env
|
|
131
|
+
* unchanged (`isolated: false`): the 6.61 behaviour, logged and counted, never a refused
|
|
132
|
+
* spawn, because a broken scratch dir must not take Continue or Messages down with it.
|
|
133
|
+
*/
|
|
134
|
+
export function cursorSpawnEnv(options: CursorSpawnEnvOptions = {}): CursorSpawnIsolation {
|
|
135
|
+
const baseEnv = options.baseEnv ?? process.env
|
|
136
|
+
const root = options.root ?? cursorSpawnRoot()
|
|
137
|
+
const sourceDir = options.sourceDir ?? defaultCursorSourceDir(baseEnv)
|
|
138
|
+
const now = options.now ?? Date.now()
|
|
139
|
+
if (now - lastSweepAt > 10 * 60_000) {
|
|
140
|
+
lastSweepAt = now
|
|
141
|
+
try { sweepStaleCursorSpawnDirs(root, now) } catch { /* best effort */ }
|
|
142
|
+
}
|
|
143
|
+
let dir: string | null = null
|
|
144
|
+
try {
|
|
145
|
+
mkdirSync(root, { recursive: true, mode: 0o700 })
|
|
146
|
+
dir = mkdtempSync(join(root, SPAWN_PREFIX))
|
|
147
|
+
chmodSync(dir, 0o700)
|
|
148
|
+
const config = join(sourceDir, 'cli-config.json')
|
|
149
|
+
if (existsSync(config)) {
|
|
150
|
+
copyFileSync(config, join(dir, 'cli-config.json'))
|
|
151
|
+
chmodSync(join(dir, 'cli-config.json'), 0o600)
|
|
152
|
+
}
|
|
153
|
+
// `chats` must exist at the source: without it the CLI would write a new chat into this
|
|
154
|
+
// dir, and the dir is deleted when the child exits.
|
|
155
|
+
try {
|
|
156
|
+
if (!existsSync(join(sourceDir, 'chats'))) mkdirSync(join(sourceDir, 'chats'), { recursive: true, mode: 0o700 })
|
|
157
|
+
} catch { /* the link below still points at where the chats belong */ }
|
|
158
|
+
for (const name of CURSOR_SPAWN_LINKS) {
|
|
159
|
+
const target = join(sourceDir, name)
|
|
160
|
+
try {
|
|
161
|
+
statSync(target)
|
|
162
|
+
} catch {
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
symlinkSync(target, join(dir, name))
|
|
166
|
+
}
|
|
167
|
+
} catch (error) {
|
|
168
|
+
cursorSpawnStats.failed += 1
|
|
169
|
+
console.warn(`[cursor-spawn-env] isolation unavailable, child uses the shared config: ${error instanceof Error ? (error as NodeJS.ErrnoException).code ?? error.message : error}`)
|
|
170
|
+
if (dir) {
|
|
171
|
+
try { removeCursorSpawnDir(dir) } catch { /* best effort */ }
|
|
172
|
+
}
|
|
173
|
+
return { env: { ...baseEnv }, dir: null, isolated: false, release: () => {} }
|
|
174
|
+
}
|
|
175
|
+
cursorSpawnStats.created += 1
|
|
176
|
+
const env: NodeJS.ProcessEnv = { ...baseEnv, CURSOR_CONFIG_DIR: dir }
|
|
177
|
+
let released = false
|
|
178
|
+
const isolatedDir = dir
|
|
179
|
+
return {
|
|
180
|
+
env,
|
|
181
|
+
dir: isolatedDir,
|
|
182
|
+
isolated: true,
|
|
183
|
+
release: () => {
|
|
184
|
+
if (released) return
|
|
185
|
+
released = true
|
|
186
|
+
try {
|
|
187
|
+
removeCursorSpawnDir(isolatedDir)
|
|
188
|
+
cursorSpawnStats.released += 1
|
|
189
|
+
} catch (error) {
|
|
190
|
+
console.warn(`[cursor-spawn-env] cleanup failed: ${error instanceof Error ? error.message : error}`)
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Release the isolation when the child is done: on `close`, or on an `error` from a child
|
|
198
|
+
* that never started (no pid). An `error` from a RUNNING child (a failed kill) is not the
|
|
199
|
+
* end of it, and pulling its config dir out from under it mid-run would be.
|
|
200
|
+
*/
|
|
201
|
+
export function releaseCursorSpawnOnExit(
|
|
202
|
+
child: { pid?: number; once?: (event: string, listener: (...args: any[]) => void) => unknown; on: (event: string, listener: (...args: any[]) => void) => unknown },
|
|
203
|
+
isolation: CursorSpawnIsolation,
|
|
204
|
+
): void {
|
|
205
|
+
const listen = typeof child.once === 'function' ? child.once.bind(child) : child.on.bind(child)
|
|
206
|
+
listen('close', () => isolation.release())
|
|
207
|
+
listen('error', () => {
|
|
208
|
+
if (typeof child.pid !== 'number') isolation.release()
|
|
209
|
+
})
|
|
210
|
+
}
|