@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.
Files changed (69) hide show
  1. package/.env.example +6 -0
  2. package/CHANGELOG.md +34 -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 +135 -16
  11. package/server/lib/agent-session-binding-store.ts +9 -3
  12. package/server/lib/agent-session-store.ts +7 -4
  13. package/server/lib/attached-provider-adapter.ts +158 -45
  14. package/server/lib/banned-permission-args.ts +42 -1
  15. package/server/lib/claude-bridge.ts +17 -8
  16. package/server/lib/claude-hooks-installer.ts +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-thread.ts +143 -25
  32. package/server/lib/health-static-probes.ts +21 -11
  33. package/server/lib/lens-gist-engines.ts +15 -4
  34. package/server/lib/lens-gist.ts +1 -1
  35. package/server/lib/live-cues-cursor.ts +16 -6
  36. package/server/lib/occupancy-probes.ts +45 -1
  37. package/server/lib/permission-broker.ts +24 -9
  38. package/server/lib/provider-actions.ts +149 -0
  39. package/server/lib/provider-failure-text.ts +27 -0
  40. package/server/lib/provider-observe.ts +212 -0
  41. package/server/lib/provider-proof.ts +23 -14
  42. package/server/lib/query-job-runtime.ts +16 -1
  43. package/server/lib/query-job-types.ts +2 -1
  44. package/server/lib/session-act-fields.ts +61 -0
  45. package/server/lib/session-cancel.ts +95 -4
  46. package/server/lib/session-continue-facts-real.ts +74 -0
  47. package/server/lib/session-continue-facts.ts +267 -0
  48. package/server/lib/session-halt.ts +67 -6
  49. package/server/lib/session-hook-events.ts +57 -0
  50. package/server/lib/session-hook-ledger.ts +21 -3
  51. package/server/lib/session-hook-spool.ts +68 -6
  52. package/server/lib/session-hooks-runtime.ts +32 -2
  53. package/server/lib/session-peer-inbox-deps.ts +7 -6
  54. package/server/lib/session-recommendation.ts +3 -2
  55. package/server/lib/session-signal-store.ts +74 -5
  56. package/server/lib/session-stream-events.ts +32 -1
  57. package/server/lib/thread-attach-capability.ts +15 -1
  58. package/server/lib/thread-drain-kick.ts +4 -2
  59. package/server/lib/thread-occupancy.ts +44 -0
  60. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  61. package/server/lib/thread-turn-queue.ts +6 -0
  62. package/server/lib/turn-request.ts +274 -0
  63. package/server/routes/agent-session-bindings.ts +224 -22
  64. package/server/routes/agent-session-stream.ts +26 -7
  65. package/server/routes/agent-sessions.ts +155 -10
  66. package/server/routes/health.ts +27 -2
  67. package/server/routes/session-hooks.ts +56 -5
  68. package/server/routes/thread-turn-queue.ts +37 -9
  69. package/server/scripts/hooks-cli.ts +33 -7
@@ -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
- const proc = spawn(agentBinary, args, {
360
- stdio: ['pipe', 'pipe', 'pipe'],
361
- env,
362
- cwd: cursorCwd,
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
- const child = spawn(agentBinary, ['models'], {
248
- stdio: ['ignore', 'pipe', 'pipe'],
249
- env,
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.'
@@ -1,26 +1,33 @@
1
- import { readFileSync, existsSync, lstatSync, mkdirSync, copyFileSync, chmodSync } from 'node:fs'
1
+ import { readFileSync, existsSync, lstatSync, mkdirSync, copyFileSync, chmodSync, accessSync, statSync, constants } from 'node:fs'
2
2
  import { dirname, join, resolve } from 'node:path'
3
3
  import { homedir } from 'node:os'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { atomicWriteFileSync } from './atomic-fs.js'
6
6
  import { cosGlassesHome, hookSpoolDir } from './claude-hooks-installer.js'
7
- export const CURSOR_OBSERVER_EVENTS=['subagentStart','subagentStop','preCompact','postToolUse','afterAgentResponse','stop','sessionEnd'] as const
7
+ // 6.62.0: `afterAgentThought` (the reasoning line, display only) joins the observer's events.
8
+ export const CURSOR_OBSERVER_EVENTS=['subagentStart','subagentStop','preCompact','postToolUse','afterAgentResponse','stop','sessionEnd','afterAgentThought'] as const
8
9
  const quote=(s:string)=>"'"+s.replace(/'/g,"'\\''")+"'"
9
- export function installCursorObserver(options:{settingsPath?:string;scriptPath?:string;spool?:string;dryRun?:boolean;uninstall?:boolean}={}) {
10
- const settings=options.settingsPath??join(process.env.CURSOR_CONFIG_DIR??join(homedir(),'.cursor'),'hooks.json')
11
- const script=options.scriptPath??join(cosGlassesHome(),'bin','cos-cursor-observer.cjs')
10
+ export const cursorHooksPath=()=>join(process.env.CURSOR_CONFIG_DIR??join(homedir(),'.cursor'),'hooks.json')
11
+ export const cursorObserverScriptPath=()=>join(cosGlassesHome(),'bin','cos-cursor-observer.cjs')
12
+ // 6.62.0 (W18): ours by the SCRIPT path, never by `process.execPath`. A brew upgrade moves node,
13
+ // and keying on it made every observer hook foreign, so the next install appended duplicates.
14
+ const oursFor=(script:string)=>(h:any)=>typeof h?.command==='string'&&h.command.includes(' '+quote(script)+' ')
15
+ export function installCursorObserver(options:{settingsPath?:string;scriptPath?:string;spool?:string;dryRun?:boolean;uninstall?:boolean;nodePath?:string}={}) {
16
+ const settings=options.settingsPath??cursorHooksPath()
17
+ const script=options.scriptPath??cursorObserverScriptPath()
12
18
  const spool=options.spool??hookSpoolDir()
19
+ const node=options.nodePath??process.execPath
13
20
  try {
14
21
  if(existsSync(settings)&&lstatSync(settings).isSymbolicLink())throw new Error('symlinked Cursor settings')
15
22
  const old=existsSync(settings)?readFileSync(settings,'utf8'):'{}', config=JSON.parse(old)
16
23
  if(!config||typeof config!=='object'||Array.isArray(config)||(config.version!==undefined&&config.version!==1))throw new Error('unsupported Cursor config')
17
24
  if(config.hooks!==undefined&&(!config.hooks||typeof config.hooks!=='object'||Array.isArray(config.hooks)))throw new Error('invalid Cursor hooks')
18
25
  const hooks={...config.hooks}
26
+ const ours=oursFor(script)
19
27
  for(const event of CURSOR_OBSERVER_EVENTS){
20
28
  if(hooks[event]!==undefined&&!Array.isArray(hooks[event]))throw new Error('invalid Cursor hook list')
21
- const ours=(h:any)=>typeof h?.command==='string'&&h.command.startsWith(quote(process.execPath)+' '+quote(script)+' ')
22
29
  hooks[event]=(hooks[event]??[]).filter((h:any)=>!ours(h))
23
- if(!options.uninstall)hooks[event].push({command:[process.execPath,script,event,spool].map(quote).join(' '),timeout:5})
30
+ if(!options.uninstall)hooks[event].push({command:[node,script,event,spool].map(quote).join(' '),timeout:5})
24
31
  if(!hooks[event].length)delete hooks[event]
25
32
  }
26
33
  const next=JSON.stringify({...config,version:1,hooks},null,2)+'\n'
@@ -36,3 +43,37 @@ export function installCursorObserver(options:{settingsPath?:string;scriptPath?:
36
43
  return {ok:true,changed:next!==old,events:[...CURSOR_OBSERVER_EVENTS],dryRun:!!options.dryRun}
37
44
  }catch(error){return {ok:false,changed:false,reason:error instanceof Error?error.message:String(error)}}
38
45
  }
46
+ /** The first single-quoted word of an observer command (`quote` above): the node binary it runs. */
47
+ export function observerNodeOf(command:string):string|null{
48
+ if(command[0]!=="'")return null
49
+ let out='',i=0
50
+ for(;;){
51
+ const end=command.indexOf("'",i+1);if(end<0)return null
52
+ out+=command.slice(i+1,end);i=end+1
53
+ if(command.startsWith("\\''",i)){out+="'";i+=2}else break
54
+ }
55
+ return out||null
56
+ }
57
+ /**
58
+ * 6.62.0 health: `installed` when every observer event has exactly one hook of ours, and
59
+ * `nodeOk` when the node binary each of those commands names still exists (a brew upgrade can
60
+ * remove it, and Cursor then runs nothing). Never throws.
61
+ */
62
+ export function cursorObserverStatus(options:{settingsPath?:string;scriptPath?:string}={}):{installed:boolean;nodeOk:boolean;events:number;reason:string|null}{
63
+ const settings=options.settingsPath??cursorHooksPath(),ours=oursFor(options.scriptPath??cursorObserverScriptPath())
64
+ try{
65
+ if(!existsSync(settings))return {installed:false,nodeOk:false,events:0,reason:'missing'}
66
+ if(lstatSync(settings).isSymbolicLink())return {installed:false,nodeOk:false,events:0,reason:'symlink'}
67
+ const hooks=JSON.parse(readFileSync(settings,'utf8'))?.hooks??{}
68
+ let events=0,nodeOk=true
69
+ for(const event of CURSOR_OBSERVER_EVENTS){
70
+ const mine=Array.isArray(hooks[event])?hooks[event].filter(ours):[]
71
+ if(mine.length===1)events++
72
+ for(const h of mine){
73
+ const node=observerNodeOf(h.command)
74
+ try{if(!node||!statSync(node).isFile())nodeOk=false;else accessSync(node,constants.X_OK)}catch{nodeOk=false}
75
+ }
76
+ }
77
+ return {installed:events===CURSOR_OBSERVER_EVENTS.length,nodeOk:events>0&&nodeOk,events,reason:null}
78
+ }catch{return {installed:false,nodeOk:false,events:0,reason:'unreadable'}}
79
+ }