@gotcos/glasses-server 6.61.7 → 6.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.env.example +6 -0
  2. package/CHANGELOG.md +39 -0
  3. package/README.md +5 -1
  4. package/bin/cli.cjs +50 -3
  5. package/bin/hooks/cos-cursor-observer.cjs +11 -4
  6. package/bin/hooks/cos-session-hook +46 -11
  7. package/docs/provider-parity.md +43 -0
  8. package/managed-runtime-contract.json +1 -0
  9. package/package.json +4 -2
  10. package/server/index.ts +141 -17
  11. package/server/lib/agent-session-binding-store.ts +9 -3
  12. package/server/lib/agent-session-store.ts +7 -4
  13. package/server/lib/attached-provider-adapter.ts +158 -45
  14. package/server/lib/banned-permission-args.ts +42 -1
  15. package/server/lib/claude-bridge.ts +17 -8
  16. package/server/lib/claude-hooks-installer.ts +22 -2
  17. package/server/lib/codex-bridge.ts +17 -2
  18. package/server/lib/codex-hooks-installer.ts +538 -0
  19. package/server/lib/codex-live-queue.ts +1 -1
  20. package/server/lib/codex-model-catalog.ts +49 -2
  21. package/server/lib/codex-rollout-state.ts +90 -0
  22. package/server/lib/codex-session-posture.ts +528 -0
  23. package/server/lib/continue-plan.ts +180 -0
  24. package/server/lib/continue-read-health.ts +26 -0
  25. package/server/lib/cursor-bridge.ts +26 -6
  26. package/server/lib/cursor-fork-context.ts +140 -0
  27. package/server/lib/cursor-model-catalog.ts +62 -5
  28. package/server/lib/cursor-observer-installer.ts +48 -7
  29. package/server/lib/cursor-session-model.ts +339 -0
  30. package/server/lib/cursor-spawn-env.ts +210 -0
  31. package/server/lib/fork-job-ledger.ts +194 -0
  32. package/server/lib/fork-thread.ts +169 -25
  33. package/server/lib/health-static-probes.ts +21 -11
  34. package/server/lib/lens-gist-engines.ts +15 -4
  35. package/server/lib/lens-gist.ts +1 -1
  36. package/server/lib/live-cues-cursor.ts +16 -6
  37. package/server/lib/occupancy-probes.ts +45 -1
  38. package/server/lib/permission-broker.ts +24 -9
  39. package/server/lib/provider-actions.ts +149 -0
  40. package/server/lib/provider-failure-text.ts +27 -0
  41. package/server/lib/provider-observe.ts +212 -0
  42. package/server/lib/provider-proof.ts +23 -14
  43. package/server/lib/query-job-runtime.ts +16 -1
  44. package/server/lib/query-job-types.ts +2 -1
  45. package/server/lib/session-act-fields.ts +61 -0
  46. package/server/lib/session-cancel.ts +95 -4
  47. package/server/lib/session-continue-facts-real.ts +74 -0
  48. package/server/lib/session-continue-facts.ts +267 -0
  49. package/server/lib/session-halt.ts +67 -6
  50. package/server/lib/session-hook-events.ts +57 -0
  51. package/server/lib/session-hook-ledger.ts +21 -3
  52. package/server/lib/session-hook-spool.ts +68 -6
  53. package/server/lib/session-hooks-runtime.ts +32 -2
  54. package/server/lib/session-peer-inbox-deps.ts +7 -6
  55. package/server/lib/session-recommendation.ts +3 -2
  56. package/server/lib/session-signal-store.ts +74 -5
  57. package/server/lib/session-stream-events.ts +32 -1
  58. package/server/lib/thread-attach-capability.ts +15 -1
  59. package/server/lib/thread-drain-kick.ts +4 -2
  60. package/server/lib/thread-occupancy.ts +44 -0
  61. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  62. package/server/lib/thread-turn-queue.ts +6 -0
  63. package/server/lib/turn-request.ts +274 -0
  64. package/server/routes/agent-session-bindings.ts +349 -26
  65. package/server/routes/agent-session-stream.ts +26 -7
  66. package/server/routes/agent-sessions.ts +155 -10
  67. package/server/routes/health.ts +27 -2
  68. package/server/routes/session-hooks.ts +56 -5
  69. package/server/routes/thread-turn-queue.ts +37 -9
  70. package/server/scripts/hooks-cli.ts +33 -7
@@ -0,0 +1,194 @@
1
+ /**
2
+ * 6.63.0: the durable record of a background fork (`POST .../fork` with a `clientForkId`).
3
+ *
4
+ * WHY THIS EXISTS. A fork runs a real model turn in the copy before it can say what it
5
+ * made, and the attached adapter lets that turn run for 21 minutes. COS Control waited 5,
6
+ * so on 2026-10-05 a 6.5-minute fork that SUCCEEDED was reported to Miles as "Server
7
+ * stopped", the card kept pointing at the original thread, and the copy only surfaced in
8
+ * the server log. A caller that cannot hold a request open for 21 minutes (Control holds
9
+ * its Work journal lock while it waits; iOS suspends the phone's WebView) needs to start a
10
+ * fork, get an answer at once, and read the outcome later. That outcome lives here.
11
+ *
12
+ * WHAT IT HOLDS. One row per `clientForkId`: `running` until the fork settles, then the
13
+ * exact status and body the synchronous route would have answered. The FIRST settle wins
14
+ * and is never overwritten, so a replayed id always sees what its own fork did.
15
+ *
16
+ * RESTART. A `running` row written by an earlier server process cannot settle any more:
17
+ * its promise died with that process, and its child may or may not have made a copy.
18
+ * `load` turns such rows into the cautious `fork_orphan_possible` answer (not
19
+ * retryable), which tells the user to look in Sessions before forking again. It never
20
+ * reports them as failed: a copy may exist.
21
+ *
22
+ * PRIVACY. No prompt, no native thread id: the body is the one the route already puts on
23
+ * the wire (a `forkRef` digest, never an id), and the source is kept only as a digest.
24
+ */
25
+ import { durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
26
+
27
+ /** Same shape as a client turn id: what the client mints and can safely echo back. */
28
+ export const CLIENT_FORK_ID_RE = /^[A-Za-z0-9][A-Za-z0-9-]{7,63}$/
29
+
30
+ /** Rows kept. A fork is a user action, so this is weeks of history, and the file stays small. */
31
+ export const MAX_FORK_JOBS = 200
32
+ /** A settled row older than this is dropped on the next write. */
33
+ export const FORK_JOB_TTL_MS = 7 * 24 * 60 * 60_000
34
+
35
+ export type ForkJobState = 'running' | 'done'
36
+
37
+ export interface ForkJobRow {
38
+ clientForkId: string
39
+ /** `opaqueRevision(targetKey(provider, sourceThreadId))`: which source this job forks, never the id. */
40
+ source: string
41
+ provider: string
42
+ state: ForkJobState
43
+ acceptedAt: number
44
+ /** The process that accepted it. A `running` row from another process is interrupted. */
45
+ instance: string
46
+ settledAt?: number
47
+ /** The HTTP status the synchronous route would have answered. */
48
+ status?: number
49
+ /** The body the synchronous route would have answered. */
50
+ result?: Record<string, unknown>
51
+ }
52
+
53
+ export interface ForkJobPersistence {
54
+ load: () => unknown
55
+ save: (rows: ForkJobRow[]) => void
56
+ }
57
+
58
+ /** The answer an interrupted fork settles to. Copy matches the route's own orphan wording. */
59
+ export const INTERRUPTED_FORK_RESULT: Record<string, unknown> = {
60
+ forked: false,
61
+ forkRef: null,
62
+ sourceIntegrity: null,
63
+ orphanPossible: true,
64
+ retryable: false,
65
+ interrupted: true,
66
+ reason: 'fork_orphan_possible',
67
+ reasonCopy: 'The server restarted while COS was making the copy. A copy may exist: look for it in Sessions before forking again.',
68
+ }
69
+ export const INTERRUPTED_FORK_STATUS = 503
70
+
71
+ function isRow(value: unknown): value is ForkJobRow {
72
+ if (!value || typeof value !== 'object') return false
73
+ const r = value as Record<string, unknown>
74
+ return typeof r.clientForkId === 'string' && CLIENT_FORK_ID_RE.test(r.clientForkId)
75
+ && typeof r.source === 'string' && typeof r.provider === 'string'
76
+ && (r.state === 'running' || r.state === 'done')
77
+ && typeof r.acceptedAt === 'number' && Number.isFinite(r.acceptedAt)
78
+ && typeof r.instance === 'string'
79
+ && (r.state === 'running' || (typeof r.status === 'number' && !!r.result && typeof r.result === 'object'))
80
+ }
81
+
82
+ export type BeginResult =
83
+ | { kind: 'started'; row: ForkJobRow }
84
+ /** This id already has a job: the caller replays it and never spawns. */
85
+ | { kind: 'existing'; row: ForkJobRow }
86
+ /** The id is already used for a different source thread. */
87
+ | { kind: 'conflict' }
88
+ /** The ledger could not be written, so the fork must not start (its outcome would be unreadable). */
89
+ | { kind: 'unavailable' }
90
+
91
+ export class ForkJobLedger {
92
+ private rows = new Map<string, ForkJobRow>()
93
+
94
+ constructor(
95
+ private readonly instance: string,
96
+ private readonly persistence?: ForkJobPersistence,
97
+ now: number = Date.now(),
98
+ ) {
99
+ let raw: unknown = []
100
+ try { raw = persistence?.load() ?? [] } catch (error) {
101
+ console.error(`[fork-job-ledger] load failed: ${error instanceof Error ? error.message : error}`)
102
+ raw = []
103
+ }
104
+ let interrupted = 0
105
+ for (const value of Array.isArray(raw) ? raw : []) {
106
+ if (!isRow(value)) continue
107
+ let row = value
108
+ if (row.state === 'running' && row.instance !== instance) {
109
+ row = { ...row, state: 'done', settledAt: now, status: INTERRUPTED_FORK_STATUS, result: { ...INTERRUPTED_FORK_RESULT } }
110
+ interrupted += 1
111
+ }
112
+ this.rows.set(row.clientForkId, row)
113
+ }
114
+ if (interrupted > 0) {
115
+ console.warn(`[fork-job-ledger] ${interrupted} fork job(s) were running when the server last stopped; recorded as orphan-possible`)
116
+ this.save(now)
117
+ }
118
+ }
119
+
120
+ get(clientForkId: string): ForkJobRow | null {
121
+ return this.rows.get(clientForkId) ?? null
122
+ }
123
+
124
+ /** Records a running job. Persisted BEFORE the caller spawns anything. */
125
+ begin(input: { clientForkId: string; source: string; provider: string }, now: number): BeginResult {
126
+ const existing = this.rows.get(input.clientForkId)
127
+ if (existing) return existing.source === input.source ? { kind: 'existing', row: existing } : { kind: 'conflict' }
128
+ const row: ForkJobRow = {
129
+ clientForkId: input.clientForkId, source: input.source, provider: input.provider,
130
+ state: 'running', acceptedAt: now, instance: this.instance,
131
+ }
132
+ this.rows.set(row.clientForkId, row)
133
+ if (!this.save(now)) {
134
+ this.rows.delete(row.clientForkId)
135
+ return { kind: 'unavailable' }
136
+ }
137
+ return { kind: 'started', row }
138
+ }
139
+
140
+ /** The first settle wins. Returns false if the row is unknown or already settled. */
141
+ settle(clientForkId: string, status: number, result: Record<string, unknown>, now: number): boolean {
142
+ const row = this.rows.get(clientForkId)
143
+ if (!row || row.state !== 'running') return false
144
+ this.rows.set(clientForkId, { ...row, state: 'done', settledAt: now, status, result })
145
+ // A failed write keeps the settled row in memory, so this process still answers it.
146
+ this.save(now)
147
+ return true
148
+ }
149
+
150
+ /** Running rows in this process (for health). */
151
+ runningCount(): number {
152
+ let n = 0
153
+ for (const row of this.rows.values()) if (row.state === 'running') n += 1
154
+ return n
155
+ }
156
+
157
+ private save(now: number): boolean {
158
+ // Drop settled rows past the TTL, then the oldest settled rows past the cap. Running
159
+ // rows are never dropped: their outcome is still owed to a caller.
160
+ for (const [id, row] of this.rows) {
161
+ if (row.state === 'done' && now - (row.settledAt ?? row.acceptedAt) > FORK_JOB_TTL_MS) this.rows.delete(id)
162
+ }
163
+ if (this.rows.size > MAX_FORK_JOBS) {
164
+ const settled = [...this.rows.values()].filter(r => r.state === 'done').sort((a, b) => a.acceptedAt - b.acceptedAt)
165
+ for (const row of settled) {
166
+ if (this.rows.size <= MAX_FORK_JOBS) break
167
+ this.rows.delete(row.clientForkId)
168
+ }
169
+ }
170
+ if (!this.persistence) return true
171
+ try {
172
+ this.persistence.save([...this.rows.values()])
173
+ return true
174
+ } catch (error) {
175
+ console.error(`[fork-job-ledger] save failed: ${error instanceof Error ? error.message : error}`)
176
+ return false
177
+ }
178
+ }
179
+ }
180
+
181
+ /** File persistence: `<data>/fork-jobs.json`, written durably, quarantined if corrupt. */
182
+ export function fileForkJobPersistence(path: string): ForkJobPersistence {
183
+ return {
184
+ load: () => {
185
+ const loaded = loadJsonOrQuarantine<unknown>(path)
186
+ if (loaded.status === 'corrupt') {
187
+ console.warn(`[fork-job-ledger] fork job file was corrupt, quarantined as ${loaded.quarantinedAs}`)
188
+ return []
189
+ }
190
+ return loaded.status === 'ok' ? loaded.data : []
191
+ },
192
+ save: rows => durableAtomicWriteFileSync(path, `${JSON.stringify(rows)}\n`),
193
+ }
194
+ }
@@ -78,7 +78,6 @@ import {
78
78
  buildAttachedEnv,
79
79
  classifyStderr,
80
80
  extractNativeIdsFromLine,
81
- isAttachedPermissionPolicy,
82
81
  resolveProviderBinary,
83
82
  type AttachedChildProcess,
84
83
  type AttachedSpawnRequest,
@@ -86,19 +85,34 @@ import {
86
85
  type BinaryResolution,
87
86
  } from './attached-provider-adapter.js'
88
87
  import { findBannedPermissionArg } from './banned-permission-args.js'
88
+ import { PROVIDER_AUTH_RE, PROVIDER_OVERFLOW_RE, PROVIDER_QUOTA_RE } from './provider-failure-text.js'
89
+ import { cursorIsolationUnavailableError, cursorSpawnEnv, isCursorIsolationUnavailable, releaseCursorSpawnOnExit } from './cursor-spawn-env.js'
89
90
 
90
91
  /**
91
- * Providers that can be forked. A strict subset of the attached set.
92
+ * Providers that can be forked.
92
93
  *
93
- * Cursor can Continue (ask-mode resume) and cannot Fork: Agent CLI has no
94
- * `--fork-session` equivalent, and advertising Fork on Cursor would 404 the
95
- * only remaining write path for that row if Continue were also hidden.
94
+ * 6.62.0 (D4, plan 3.7): Cursor's Agent CLI has no `--fork-session`, so its "fork" is a
95
+ * NEW chat seeded with a bounded context bundle the caller built ("New session with this
96
+ * context"). It is read-only (`--mode ask`, never `--force`) and stays under the full ban,
97
+ * like every other fork (B3).
96
98
  */
97
99
  export type ForkProvider = ForkableProvider
98
100
 
99
- /** Same one-member policy as the attached path. An omitted policy is refused. */
101
+ /** The model a Cursor fork runs when the caller names none (or one that is not a slug). */
102
+ export const CURSOR_FORK_FALLBACK_MODEL = 'composer-2.5-fast'
103
+ const CURSOR_SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,99}$/i
104
+
105
+ /**
106
+ * One member, and it stays `read_only` (plan v3, B3): D1 widened CONTINUE to the session's
107
+ * own posture, never Fork. Every fork, Cursor's included, is read-only. Its own predicate
108
+ * since 6.62.0, when the attached path's policy word became `session_posture`.
109
+ */
100
110
  export type ForkPermissionPolicy = 'read_only'
101
111
 
112
+ export function isForkPermissionPolicy(value: unknown): value is ForkPermissionPolicy {
113
+ return value === 'read_only'
114
+ }
115
+
102
116
  /** Terminal reason on failure. Never carries prompt, transcript or provider text. */
103
117
  export type ForkFailure =
104
118
  | 'invalid_provider'
@@ -192,6 +206,70 @@ export interface ForkFailureResult {
192
206
 
193
207
  export type ForkResult = ForkSuccess | ForkFailureResult
194
208
 
209
+ /**
210
+ * Why the provider itself refused the turn, read from its own error event. An enum
211
+ * only: the provider's sentence never leaves this module. On 2026-10-05 a complete
212
+ * 14.8 MB copy's first turn was refused "Prompt is too long", and the route could
213
+ * only say "COS lost track of the copy": every refusal after the prompt was
214
+ * written looked the same.
215
+ */
216
+ export type ForkProviderFailure = 'context_too_long' | 'provider_limit' | 'provider_auth'
217
+ export const FORK_PROVIDER_FAILURES: readonly ForkProviderFailure[] = ['context_too_long', 'provider_limit', 'provider_auth']
218
+
219
+ export function classifyProviderErrorText(text: string): ForkProviderFailure | null {
220
+ const sample = text.slice(0, 8_000)
221
+ // Overflow first: "Prompt is too long" can share a line with a usage hint.
222
+ if (PROVIDER_OVERFLOW_RE.test(sample)) return 'context_too_long'
223
+ if (PROVIDER_QUOTA_RE.test(sample)) return 'provider_limit'
224
+ if (PROVIDER_AUTH_RE.test(sample)) return 'provider_auth'
225
+ return null
226
+ }
227
+
228
+ /**
229
+ * One stdout line, judged only when it is an error carrier: Claude's stream-json
230
+ * `result` with `is_error`, an assistant event with `error` / `isApiErrorMessage`,
231
+ * or a Codex/Cursor `error` / `turn.failed` event. Ordinary assistant text that
232
+ * merely mentions a limit is never read.
233
+ */
234
+ export function classifyProviderOutputLine(line: string): ForkProviderFailure | null {
235
+ return readProviderOutputLine(line)?.cause ?? null
236
+ }
237
+
238
+ /**
239
+ * The cause on one stdout line, and whether the line is the provider's FINAL verdict
240
+ * (Claude's `result` with `is_error`, Codex's `turn.failed`). A terminal verdict beats
241
+ * anything earlier. A retry notice is not a verdict at all: the Claude CLI writes
242
+ * `{type:"system",subtype:"api_retry",error:"overloaded"|"rate_limit"}` and Codex
243
+ * writes `{type:"error",message:"Reconnecting... 1/5 (… 429 …)"}` for retries that
244
+ * may recover, and the first-carrier rule turned a recovered 529 followed by
245
+ * "Prompt is too long" into "usage limit" (6.62.1 QA).
246
+ */
247
+ export function readProviderOutputLine(line: string): { cause: ForkProviderFailure | null; terminal: boolean } | null {
248
+ if (!line || line.length > 2_000_000 || line[0] !== '{') return null
249
+ let j: any
250
+ try { j = JSON.parse(line) } catch { return null }
251
+ if (!j || typeof j !== 'object') return null
252
+ if (j.type === 'system' && j.subtype === 'api_retry') return null
253
+ if (j.type === 'error' && typeof j.message === 'string' && /\b(?:reconnecting|retrying)\b/i.test(j.message)) return null
254
+ const terminal = (j.type === 'result' && j.is_error === true) || j.type === 'turn.failed'
255
+ const carrier = terminal || j.isApiErrorMessage === true || typeof j.error === 'string' || j.type === 'error'
256
+ if (!carrier) return null
257
+ return { cause: providerCauseOf(j), terminal }
258
+ }
259
+
260
+ function providerCauseOf(j: any): ForkProviderFailure | null {
261
+ if (j.error === 'rate_limit' || j.apiErrorStatus === 429) return 'provider_limit'
262
+ if (j.error === 'authentication_failed') return 'provider_auth'
263
+ const parts: string[] = []
264
+ const add = (v: unknown) => { if (typeof v === 'string') parts.push(v.slice(0, 2_000)) }
265
+ add(j.result); add(j.error); add(j.message)
266
+ if (j.error && typeof j.error === 'object') add(j.error.message)
267
+ const content = j.message && typeof j.message === 'object' ? j.message.content : null
268
+ if (typeof content === 'string') add(content)
269
+ else if (Array.isArray(content)) for (const c of content.slice(0, 8)) add(c?.text)
270
+ return classifyProviderErrorText(parts.join('\n'))
271
+ }
272
+
195
273
  /**
196
274
  * Might this call have left a thread behind that nobody can name?
197
275
  *
@@ -251,6 +329,25 @@ export function buildClaudeForkArgs(nativeThreadId: string): string[] {
251
329
  * `--ephemeral` is deliberately absent: a fork that is not written to disk is not a
252
330
  * thread the user can go back to, which is the entire deliverable.
253
331
  */
332
+ /**
333
+ * Cursor: `agent -p --mode ask --model <slug> --output-format stream-json --trust
334
+ * --workspace <ws>`, prompt (the context bundle, then the message) on stdin.
335
+ *
336
+ * NO `--resume`: there is nothing to resume into, the source chat is only read (by the
337
+ * caller, for the bundle). NO `--force`: a fork is read-only (B3). The new chat's id comes
338
+ * back as `session_id` on the stream, and the exactly-one-new-id rule below holds as is.
339
+ */
340
+ export function buildCursorForkArgs(cwd: string, model: string): string[] {
341
+ return [
342
+ '-p',
343
+ '--mode', 'ask',
344
+ '--model', model,
345
+ '--output-format', 'stream-json',
346
+ '--trust',
347
+ '--workspace', cwd,
348
+ ]
349
+ }
350
+
254
351
  export function buildCodexForkArgs(nativeThreadId: string, cwd: string): string[] {
255
352
  return [
256
353
  'exec',
@@ -274,10 +371,15 @@ export function buildCodexForkArgs(nativeThreadId: string, cwd: string): string[
274
371
  * model against the user's workspace, so it needs the same list the attached path
275
372
  * has — and needs it to keep being the same list after someone edits one of them.
276
373
  */
277
- function buildForkArgs(provider: ForkProvider, nativeThreadId: string, cwd: string): string[] {
278
- const args = provider === 'claude'
279
- ? buildClaudeForkArgs(nativeThreadId)
280
- : buildCodexForkArgs(nativeThreadId, cwd)
374
+ function buildForkArgs(provider: ForkProvider, nativeThreadId: string, cwd: string, cursorModel?: string): string[] {
375
+ // An explicit branch per provider: until 6.62.0 a ternary sent anything that was not
376
+ // Claude down the Codex path, which for Cursor would have been a `codex` argv.
377
+ let args: string[]
378
+ if (provider === 'claude') args = buildClaudeForkArgs(nativeThreadId)
379
+ else if (provider === 'codex') args = buildCodexForkArgs(nativeThreadId, cwd)
380
+ else if (provider === 'cursor') args = buildCursorForkArgs(cwd, cursorModel ?? CURSOR_FORK_FALLBACK_MODEL)
381
+ else throw new Error('fork argv: unknown provider')
382
+ // NO allowance, ever: every fork stays under the full ban.
281
383
  const banned = findBannedPermissionArg(args)
282
384
  if (banned !== null) {
283
385
  // The flag name only. Never the argv, which carries the thread id and the cwd.
@@ -395,7 +497,7 @@ export interface ForkDeps {
395
497
  * Exists ONLY so a test can hand back an argv carrying a banned permission flag
396
498
  * and prove the fork is refused with zero spawns. Production never sets it.
397
499
  */
398
- buildArgs?: (provider: ForkProvider, nativeThreadId: string, cwd: string) => string[]
500
+ buildArgs?: (provider: ForkProvider, nativeThreadId: string, cwd: string, cursorModel?: string) => string[]
399
501
  }
400
502
 
401
503
  export interface ForkRequest {
@@ -404,6 +506,12 @@ export interface ForkRequest {
404
506
  prompt: unknown
405
507
  cwd: unknown
406
508
  policy: unknown
509
+ /**
510
+ * 6.62.0: the Cursor fork's model, a flat slug (the session's own, validated by the
511
+ * caller against the installed list). Ignored for Claude and Codex, whose forks keep
512
+ * their source's model. Absent or not a slug: `CURSOR_FORK_FALLBACK_MODEL`.
513
+ */
514
+ cursorModel?: unknown
407
515
  deps: ForkDeps
408
516
  /** Wall-clock budget for the provider run. Omitted uses the attached default. */
409
517
  timeoutMs?: number
@@ -541,7 +649,7 @@ async function run(request: ForkRequest, deps: ForkDeps, startedAt: number): Pro
541
649
 
542
650
  const base = { provider, sourceNativeThreadId }
543
651
 
544
- if (!isAttachedPermissionPolicy(request.policy)) {
652
+ if (!isForkPermissionPolicy(request.policy)) {
545
653
  // Includes `undefined`. An omitted policy is not a request for the safest one;
546
654
  // it is a caller that has not decided.
547
655
  return fail('unsupported_policy', 'none', { ...base, durationMs: duration() })
@@ -602,9 +710,12 @@ async function run(request: ForkRequest, deps: ForkDeps, startedAt: number): Pro
602
710
  const watermarkBefore = readWatermark(deps, provider, sourceNativeThreadId)
603
711
 
604
712
  // --- 4. Spawn ---------------------------------------------------------------
713
+ const cursorModel = provider === 'cursor' && typeof request.cursorModel === 'string' && CURSOR_SLUG_RE.test(request.cursorModel)
714
+ ? request.cursorModel
715
+ : undefined
605
716
  let args: string[]
606
717
  try {
607
- args = (deps.buildArgs ?? buildForkArgs)(provider, sourceNativeThreadId, cwd)
718
+ args = (deps.buildArgs ?? buildForkArgs)(provider, sourceNativeThreadId, cwd, cursorModel)
608
719
  } catch {
609
720
  return fail('unsupported_policy', 'none', { ...base, detail: 'argv_build_failed', durationMs: duration() })
610
721
  }
@@ -617,10 +728,11 @@ async function run(request: ForkRequest, deps: ForkDeps, startedAt: number): Pro
617
728
 
618
729
  let child: AttachedChildProcess
619
730
  try {
620
- child = deps.spawn({ binaryPath, args, cwd, env: buildAttachedEnv() })
621
- } catch {
731
+ child = deps.spawn({ binaryPath, args, cwd, env: buildAttachedEnv(), provider })
732
+ } catch (error) {
622
733
  // Includes ENOENT. No process exists, so no thread can have been created.
623
- return fail('spawn_failed', 'none', { ...base, detail: 'threw', durationMs: duration() })
734
+ const detail = isCursorIsolationUnavailable(error) ? 'cursor_isolation_unavailable' : 'threw'
735
+ return fail('spawn_failed', 'none', { ...base, detail, durationMs: duration() })
624
736
  }
625
737
  if (!child || typeof child !== 'object' || typeof child.on !== 'function') {
626
738
  return fail('spawn_failed', 'none', { ...base, detail: 'no_child', durationMs: duration() })
@@ -730,6 +842,14 @@ function driveChild(input: DriveInput): Promise<ForkResult> {
730
842
  let stderrSample = ''
731
843
  let forkState: ForkState = 'none'
732
844
  let exitCode: number | null = null
845
+ let providerFailure: ForkProviderFailure | null = null
846
+ let terminalFailure: ForkProviderFailure | null = null
847
+ const noteProviderLine = (line: string) => {
848
+ const read = readProviderOutputLine(line)
849
+ if (!read?.cause) return
850
+ if (read.terminal) terminalFailure = read.cause // the last verdict wins
851
+ else providerFailure ??= read.cause
852
+ }
733
853
  let settled = false
734
854
  let timedOut = false
735
855
  let spawnErrored = false
@@ -779,6 +899,7 @@ function driveChild(input: DriveInput): Promise<ForkResult> {
779
899
  for (const id of extractNativeIdsFromLine(line)) {
780
900
  if (!observedIds.includes(id)) observedIds.push(id)
781
901
  }
902
+ noteProviderLine(line)
782
903
  }
783
904
  // Deliberately nothing else: no text, no tool calls, no transcript. This
784
905
  // module cannot leak what it never held.
@@ -805,12 +926,22 @@ function driveChild(input: DriveInput): Promise<ForkResult> {
805
926
  for (const id of extractNativeIdsFromLine(stdoutTail)) {
806
927
  if (!observedIds.includes(id)) observedIds.push(id)
807
928
  }
929
+ noteProviderLine(stdoutTail)
808
930
  stdoutTail = ''
809
931
  }
810
932
 
811
- if (timedOut) return settleFailure('timeout')
933
+ // After the prompt was written a copy may exist. When the provider named exactly
934
+ // one new thread, carry it, so the route can point at the copy instead of
935
+ // telling the user it may exist somewhere (2026-10-05).
936
+ const copyId = (): string | null => {
937
+ if (forkState !== 'possible') return null
938
+ const verdict = selectForkedId(observedIds, sourceNativeThreadId)
939
+ return verdict.ok ? verdict.newNativeThreadId : null
940
+ }
941
+ const cause = (): string | null => terminalFailure ?? providerFailure ?? classifyProviderErrorText(stderrSample)
942
+ if (timedOut) return settleFailure('timeout', { newNativeThreadId: copyId() })
812
943
  if (spawnErrored) return settleFailure('spawn_failed', { detail: 'child_error' })
813
- if (exitCode !== 0) return settleFailure('provider_exit_nonzero')
944
+ if (exitCode !== 0) return settleFailure('provider_exit_nonzero', { newNativeThreadId: copyId(), detail: cause() })
814
945
 
815
946
  const verdict = selectForkedId(observedIds, sourceNativeThreadId)
816
947
  if (!verdict.ok) {
@@ -930,13 +1061,26 @@ export function realForkDeps(
930
1061
  return {
931
1062
  now: () => Date.now(),
932
1063
  resolveBinary: provider => resolveProviderBinary(provider),
933
- spawn: request => nodeSpawn(request.binaryPath, [...request.args], {
934
- stdio: ['pipe', 'pipe', 'pipe'],
935
- cwd: request.cwd,
936
- env: request.env,
937
- // Group leader, so the whole provider tree can be signalled on timeout.
938
- detached: true,
939
- }) as unknown as AttachedChildProcess,
1064
+ spawn: request => {
1065
+ // 6.62.0 (plan 3.4): the Cursor fork's `--model` must not rewrite the CLI default.
1066
+ const isolation = request.provider === 'cursor' ? cursorSpawnEnv({ baseEnv: request.env }) : null
1067
+ // 6.62.0 /qa (W6): a fork never falls back to the shared Cursor config.
1068
+ if (isolation && !isolation.isolated) throw cursorIsolationUnavailableError()
1069
+ try {
1070
+ const child = nodeSpawn(request.binaryPath, [...request.args], {
1071
+ stdio: ['pipe', 'pipe', 'pipe'],
1072
+ cwd: request.cwd,
1073
+ env: isolation ? isolation.env : request.env,
1074
+ // Group leader, so the whole provider tree can be signalled on timeout.
1075
+ detached: true,
1076
+ })
1077
+ if (isolation) releaseCursorSpawnOnExit(child, isolation)
1078
+ return child as unknown as AttachedChildProcess
1079
+ } catch (error) {
1080
+ isolation?.release()
1081
+ throw error
1082
+ }
1083
+ },
940
1084
  processStartMs: pid => realProcessStartMs(pid),
941
1085
  recordSpawn: (pid, startMs) => recordCosSpawn(pid, startMs),
942
1086
  releaseSpawn: pid => releaseCosSpawn(pid),
@@ -1,4 +1,5 @@
1
1
  import { execFile } from 'node:child_process'
2
+ import { cursorSpawnEnv } from './cursor-spawn-env.js'
2
3
  import { resolveProviderBinary } from './provider-binary.js'
3
4
  import { PYTHON_BIN } from './python-bridge.js'
4
5
  import {
@@ -77,17 +78,26 @@ function cacheTtlMs(): number {
77
78
  return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : DEFAULT_CACHE_TTL_MS
78
79
  }
79
80
 
80
- function execute(file: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
81
+ function execute(file: string, args: string[], options: { cursorIsolation?: boolean } = {}): Promise<{ stdout: string; stderr: string }> {
81
82
  return new Promise((resolve, reject) => {
82
- execFile(file, args, {
83
- encoding: 'utf8',
84
- timeout: PROBE_TIMEOUT_MS,
85
- maxBuffer: 256 * 1024,
86
- killSignal: 'SIGKILL',
87
- }, (error, stdout, stderr) => {
88
- if (error) return reject(error)
89
- resolve({ stdout: String(stdout), stderr: String(stderr) })
90
- })
83
+ // 6.62.0 (plan 3.4): every `agent` spawn runs on a config dir of its own.
84
+ const isolation = options.cursorIsolation === true ? cursorSpawnEnv() : null
85
+ try {
86
+ execFile(file, args, {
87
+ encoding: 'utf8',
88
+ timeout: PROBE_TIMEOUT_MS,
89
+ maxBuffer: 256 * 1024,
90
+ killSignal: 'SIGKILL',
91
+ ...(isolation ? { env: isolation.env } : {}),
92
+ }, (error, stdout, stderr) => {
93
+ isolation?.release()
94
+ if (error) return reject(error)
95
+ resolve({ stdout: String(stdout), stderr: String(stderr) })
96
+ })
97
+ } catch (error) {
98
+ isolation?.release()
99
+ reject(error)
100
+ }
91
101
  })
92
102
  }
93
103
 
@@ -143,7 +153,7 @@ async function probeCursor(): Promise<{ value: string; available: boolean }> {
143
153
  const agentBinary = resolveAgentBinary()
144
154
  if (!agentBinary) return { value: 'error', available: false }
145
155
  try {
146
- const result = await execute(agentBinary, ['about'])
156
+ const result = await execute(agentBinary, ['about'], { cursorIsolation: true })
147
157
  const combined = `${result.stdout}\n${result.stderr}`.trim()
148
158
  const version = parseCursorAboutVersion(combined)
149
159
  // Catalog discovery is authenticated downstream truth and can take up to
@@ -18,7 +18,8 @@
18
18
  // dictation cleaner ships (no `--tools ""`) wrote a 26.5k-token cache of tool definitions.
19
19
  // Codex and Cursor carry 11 to 17k tokens of their own harness on every call.
20
20
 
21
- import { spawn } from 'node:child_process'
21
+ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
22
+ import { cursorSpawnEnv, releaseCursorSpawnOnExit } from './cursor-spawn-env.js'
22
23
  import { mkdtempSync, rmSync } from 'node:fs'
23
24
  import { tmpdir } from 'node:os'
24
25
  import { dirname, join } from 'node:path'
@@ -119,14 +120,24 @@ interface ProcessResult {
119
120
  export function runProcess(
120
121
  binary: string,
121
122
  args: string[],
122
- opts: { stdin: string; cwd: string; timeoutMs: number; label: string },
123
+ opts: { stdin: string; cwd: string; timeoutMs: number; label: string; cursorIsolation?: boolean },
123
124
  ): Promise<ProcessResult> {
124
125
  return new Promise((resolve, reject) => {
125
126
  let stdout = ''
126
127
  let stderr = ''
127
128
  let settled = false
128
129
  let killTimer: NodeJS.Timeout | null = null
129
- const proc = spawn(binary, args, { stdio: ['pipe', 'pipe', 'pipe'], env: lensGistChildEnv(binary), cwd: opts.cwd })
130
+ // 6.62.0 (plan 3.4): a Cursor child gets its own config dir, so `--model` cannot
131
+ // rewrite the person's CLI default (C9). Removed when the child exits.
132
+ const isolation = opts.cursorIsolation === true ? cursorSpawnEnv({ baseEnv: lensGistChildEnv(binary) }) : null
133
+ let proc: ChildProcessWithoutNullStreams
134
+ try {
135
+ proc = spawn(binary, args, { stdio: ['pipe', 'pipe', 'pipe'], env: isolation ? isolation.env : lensGistChildEnv(binary), cwd: opts.cwd })
136
+ } catch (error) {
137
+ isolation?.release()
138
+ throw error
139
+ }
140
+ if (isolation) releaseCursorSpawnOnExit(proc, isolation)
130
141
  const finish = (fn: () => void) => {
131
142
  if (settled) return
132
143
  settled = true
@@ -319,7 +330,7 @@ async function runCli(
319
330
  ): Promise<LensGistEngineRun> {
320
331
  const binary = binaryFor(provider)
321
332
  const result = await withScratchWorkspace(workspace =>
322
- runProcess(binary, args(workspace), { stdin: prompt, cwd: workspace, timeoutMs: opts.timeoutMs, label: provider }))
333
+ runProcess(binary, args(workspace), { stdin: prompt, cwd: workspace, timeoutMs: opts.timeoutMs, label: provider, cursorIsolation: provider === 'cursor' }))
323
334
  const providerText = `${result.stdout}\n${result.stderr}`
324
335
  if (result.code !== 0) {
325
336
  // Codex reports its failure as a JSON event AND a non-zero exit; the event says why.
@@ -937,7 +937,7 @@ async function cursorGistModels(): Promise<string[]> {
937
937
  const binary = resolveProviderBinary('cursor')
938
938
  if (!binary.ok) return []
939
939
  try {
940
- const result = await runProcess(binary.path, ['models'], { stdin: '', cwd: homedir(), timeoutMs: 15_000, label: 'cursor models' })
940
+ const result = await runProcess(binary.path, ['models'], { stdin: '', cwd: homedir(), timeoutMs: 15_000, label: 'cursor models', cursorIsolation: true })
941
941
  const ids = result.code === 0 ? parseAgentModelsText(result.stdout).map(m => m.id).filter(isLensGistModelName) : []
942
942
  if (ids.length) cursorModels = { at: Date.now(), ids }
943
943
  return ids
@@ -13,6 +13,7 @@ import {
13
13
  extractCursorResponseText,
14
14
  } from './cursor-bridge.js'
15
15
  import { resolveAgentBinary } from './cursor-model-catalog.js'
16
+ import { cursorSpawnEnv, releaseCursorSpawnOnExit } from './cursor-spawn-env.js'
16
17
  import { classifyCursorError, getCursorExecutionCwd } from './cursor-run-ledger.js'
17
18
  import { terminalProviderAuthFailure } from './provider-terminal-error.js'
18
19
  import { terminateProviderProcess } from './provider-process-lifecycle.js'
@@ -70,12 +71,21 @@ export async function composerAsk(input: {
70
71
  // detached: the CLI gets its own process group so a timeout kill reaches
71
72
  // tool grandchildren. This is NEW relative to cursor-bridge (which spawns
72
73
  // attached) — the shutdown path in the engine must also kill this tree.
73
- const proc = spawn(agentBinary, args, {
74
- stdio: ['pipe', 'pipe', 'pipe'],
75
- env,
76
- cwd: workspace,
77
- detached: true,
78
- })
74
+ // 6.62.0 (plan 3.4): `--model` here must not rewrite the person's CLI default (C9).
75
+ const isolation = cursorSpawnEnv({ baseEnv: env })
76
+ let proc: ReturnType<typeof spawn>
77
+ try {
78
+ proc = spawn(agentBinary, args, {
79
+ stdio: ['pipe', 'pipe', 'pipe'],
80
+ env: isolation.env,
81
+ cwd: workspace,
82
+ detached: true,
83
+ })
84
+ } catch (error) {
85
+ isolation.release()
86
+ throw error
87
+ }
88
+ releaseCursorSpawnOnExit(proc, isolation)
79
89
  input.onProcess?.(proc)
80
90
 
81
91
  const finish = (result: ComposerAskResult) => {