@gotcos/glasses-server 6.61.6 → 6.62.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/.env.example +6 -0
  2. package/CHANGELOG.md +41 -0
  3. package/README.md +5 -1
  4. package/bin/cli.cjs +50 -3
  5. package/bin/hooks/cos-cursor-observer.cjs +36 -0
  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 +5 -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 +28 -5
  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/codex-subagent-activity.ts +119 -0
  24. package/server/lib/continue-plan.ts +180 -0
  25. package/server/lib/continue-read-health.ts +26 -0
  26. package/server/lib/cursor-bridge.ts +26 -6
  27. package/server/lib/cursor-fork-context.ts +140 -0
  28. package/server/lib/cursor-model-catalog.ts +62 -5
  29. package/server/lib/cursor-observer-installer.ts +79 -0
  30. package/server/lib/cursor-session-model.ts +339 -0
  31. package/server/lib/cursor-spawn-env.ts +210 -0
  32. package/server/lib/fork-thread.ts +143 -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 +60 -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 +40 -3
  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 +81 -3
  57. package/server/lib/session-stream-events.ts +32 -1
  58. package/server/lib/session-transcript-watcher.ts +25 -1
  59. package/server/lib/session-work-observation.ts +46 -0
  60. package/server/lib/thread-attach-capability.ts +15 -1
  61. package/server/lib/thread-drain-kick.ts +4 -2
  62. package/server/lib/thread-occupancy.ts +44 -0
  63. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  64. package/server/lib/thread-turn-queue.ts +6 -0
  65. package/server/lib/turn-request.ts +274 -0
  66. package/server/routes/agent-session-bindings.ts +224 -22
  67. package/server/routes/agent-session-stream.ts +26 -7
  68. package/server/routes/agent-sessions.ts +163 -10
  69. package/server/routes/health.ts +27 -2
  70. package/server/routes/session-hooks.ts +60 -3
  71. package/server/routes/thread-turn-queue.ts +37 -9
  72. package/server/scripts/hooks-cli.ts +38 -9
@@ -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,51 @@ 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
+ if (!line || line.length > 2_000_000 || line[0] !== '{') return null
236
+ let j: any
237
+ try { j = JSON.parse(line) } catch { return null }
238
+ if (!j || typeof j !== 'object') return null
239
+ const carrier = (j.type === 'result' && j.is_error === true) || j.isApiErrorMessage === true
240
+ || typeof j.error === 'string' || j.type === 'error' || j.type === 'turn.failed'
241
+ if (!carrier) return null
242
+ if (j.error === 'rate_limit' || j.apiErrorStatus === 429) return 'provider_limit'
243
+ if (j.error === 'authentication_failed') return 'provider_auth'
244
+ const parts: string[] = []
245
+ const add = (v: unknown) => { if (typeof v === 'string') parts.push(v.slice(0, 2_000)) }
246
+ add(j.result); add(j.error); add(j.message)
247
+ if (j.error && typeof j.error === 'object') add(j.error.message)
248
+ const content = j.message && typeof j.message === 'object' ? j.message.content : null
249
+ if (typeof content === 'string') add(content)
250
+ else if (Array.isArray(content)) for (const c of content.slice(0, 8)) add(c?.text)
251
+ return classifyProviderErrorText(parts.join('\n'))
252
+ }
253
+
195
254
  /**
196
255
  * Might this call have left a thread behind that nobody can name?
197
256
  *
@@ -251,6 +310,25 @@ export function buildClaudeForkArgs(nativeThreadId: string): string[] {
251
310
  * `--ephemeral` is deliberately absent: a fork that is not written to disk is not a
252
311
  * thread the user can go back to, which is the entire deliverable.
253
312
  */
313
+ /**
314
+ * Cursor: `agent -p --mode ask --model <slug> --output-format stream-json --trust
315
+ * --workspace <ws>`, prompt (the context bundle, then the message) on stdin.
316
+ *
317
+ * NO `--resume`: there is nothing to resume into, the source chat is only read (by the
318
+ * caller, for the bundle). NO `--force`: a fork is read-only (B3). The new chat's id comes
319
+ * back as `session_id` on the stream, and the exactly-one-new-id rule below holds as is.
320
+ */
321
+ export function buildCursorForkArgs(cwd: string, model: string): string[] {
322
+ return [
323
+ '-p',
324
+ '--mode', 'ask',
325
+ '--model', model,
326
+ '--output-format', 'stream-json',
327
+ '--trust',
328
+ '--workspace', cwd,
329
+ ]
330
+ }
331
+
254
332
  export function buildCodexForkArgs(nativeThreadId: string, cwd: string): string[] {
255
333
  return [
256
334
  'exec',
@@ -274,10 +352,15 @@ export function buildCodexForkArgs(nativeThreadId: string, cwd: string): string[
274
352
  * model against the user's workspace, so it needs the same list the attached path
275
353
  * has — and needs it to keep being the same list after someone edits one of them.
276
354
  */
277
- function buildForkArgs(provider: ForkProvider, nativeThreadId: string, cwd: string): string[] {
278
- const args = provider === 'claude'
279
- ? buildClaudeForkArgs(nativeThreadId)
280
- : buildCodexForkArgs(nativeThreadId, cwd)
355
+ function buildForkArgs(provider: ForkProvider, nativeThreadId: string, cwd: string, cursorModel?: string): string[] {
356
+ // An explicit branch per provider: until 6.62.0 a ternary sent anything that was not
357
+ // Claude down the Codex path, which for Cursor would have been a `codex` argv.
358
+ let args: string[]
359
+ if (provider === 'claude') args = buildClaudeForkArgs(nativeThreadId)
360
+ else if (provider === 'codex') args = buildCodexForkArgs(nativeThreadId, cwd)
361
+ else if (provider === 'cursor') args = buildCursorForkArgs(cwd, cursorModel ?? CURSOR_FORK_FALLBACK_MODEL)
362
+ else throw new Error('fork argv: unknown provider')
363
+ // NO allowance, ever: every fork stays under the full ban.
281
364
  const banned = findBannedPermissionArg(args)
282
365
  if (banned !== null) {
283
366
  // The flag name only. Never the argv, which carries the thread id and the cwd.
@@ -395,7 +478,7 @@ export interface ForkDeps {
395
478
  * Exists ONLY so a test can hand back an argv carrying a banned permission flag
396
479
  * and prove the fork is refused with zero spawns. Production never sets it.
397
480
  */
398
- buildArgs?: (provider: ForkProvider, nativeThreadId: string, cwd: string) => string[]
481
+ buildArgs?: (provider: ForkProvider, nativeThreadId: string, cwd: string, cursorModel?: string) => string[]
399
482
  }
400
483
 
401
484
  export interface ForkRequest {
@@ -404,6 +487,12 @@ export interface ForkRequest {
404
487
  prompt: unknown
405
488
  cwd: unknown
406
489
  policy: unknown
490
+ /**
491
+ * 6.62.0: the Cursor fork's model, a flat slug (the session's own, validated by the
492
+ * caller against the installed list). Ignored for Claude and Codex, whose forks keep
493
+ * their source's model. Absent or not a slug: `CURSOR_FORK_FALLBACK_MODEL`.
494
+ */
495
+ cursorModel?: unknown
407
496
  deps: ForkDeps
408
497
  /** Wall-clock budget for the provider run. Omitted uses the attached default. */
409
498
  timeoutMs?: number
@@ -541,7 +630,7 @@ async function run(request: ForkRequest, deps: ForkDeps, startedAt: number): Pro
541
630
 
542
631
  const base = { provider, sourceNativeThreadId }
543
632
 
544
- if (!isAttachedPermissionPolicy(request.policy)) {
633
+ if (!isForkPermissionPolicy(request.policy)) {
545
634
  // Includes `undefined`. An omitted policy is not a request for the safest one;
546
635
  // it is a caller that has not decided.
547
636
  return fail('unsupported_policy', 'none', { ...base, durationMs: duration() })
@@ -602,9 +691,12 @@ async function run(request: ForkRequest, deps: ForkDeps, startedAt: number): Pro
602
691
  const watermarkBefore = readWatermark(deps, provider, sourceNativeThreadId)
603
692
 
604
693
  // --- 4. Spawn ---------------------------------------------------------------
694
+ const cursorModel = provider === 'cursor' && typeof request.cursorModel === 'string' && CURSOR_SLUG_RE.test(request.cursorModel)
695
+ ? request.cursorModel
696
+ : undefined
605
697
  let args: string[]
606
698
  try {
607
- args = (deps.buildArgs ?? buildForkArgs)(provider, sourceNativeThreadId, cwd)
699
+ args = (deps.buildArgs ?? buildForkArgs)(provider, sourceNativeThreadId, cwd, cursorModel)
608
700
  } catch {
609
701
  return fail('unsupported_policy', 'none', { ...base, detail: 'argv_build_failed', durationMs: duration() })
610
702
  }
@@ -617,10 +709,11 @@ async function run(request: ForkRequest, deps: ForkDeps, startedAt: number): Pro
617
709
 
618
710
  let child: AttachedChildProcess
619
711
  try {
620
- child = deps.spawn({ binaryPath, args, cwd, env: buildAttachedEnv() })
621
- } catch {
712
+ child = deps.spawn({ binaryPath, args, cwd, env: buildAttachedEnv(), provider })
713
+ } catch (error) {
622
714
  // Includes ENOENT. No process exists, so no thread can have been created.
623
- return fail('spawn_failed', 'none', { ...base, detail: 'threw', durationMs: duration() })
715
+ const detail = isCursorIsolationUnavailable(error) ? 'cursor_isolation_unavailable' : 'threw'
716
+ return fail('spawn_failed', 'none', { ...base, detail, durationMs: duration() })
624
717
  }
625
718
  if (!child || typeof child !== 'object' || typeof child.on !== 'function') {
626
719
  return fail('spawn_failed', 'none', { ...base, detail: 'no_child', durationMs: duration() })
@@ -730,6 +823,7 @@ function driveChild(input: DriveInput): Promise<ForkResult> {
730
823
  let stderrSample = ''
731
824
  let forkState: ForkState = 'none'
732
825
  let exitCode: number | null = null
826
+ let providerFailure: ForkProviderFailure | null = null
733
827
  let settled = false
734
828
  let timedOut = false
735
829
  let spawnErrored = false
@@ -779,6 +873,7 @@ function driveChild(input: DriveInput): Promise<ForkResult> {
779
873
  for (const id of extractNativeIdsFromLine(line)) {
780
874
  if (!observedIds.includes(id)) observedIds.push(id)
781
875
  }
876
+ providerFailure ??= classifyProviderOutputLine(line)
782
877
  }
783
878
  // Deliberately nothing else: no text, no tool calls, no transcript. This
784
879
  // module cannot leak what it never held.
@@ -805,12 +900,22 @@ function driveChild(input: DriveInput): Promise<ForkResult> {
805
900
  for (const id of extractNativeIdsFromLine(stdoutTail)) {
806
901
  if (!observedIds.includes(id)) observedIds.push(id)
807
902
  }
903
+ providerFailure ??= classifyProviderOutputLine(stdoutTail)
808
904
  stdoutTail = ''
809
905
  }
810
906
 
811
- if (timedOut) return settleFailure('timeout')
907
+ // After the prompt was written a copy may exist. When the provider named exactly
908
+ // one new thread, carry it, so the route can point at the copy instead of
909
+ // telling the user it may exist somewhere (2026-10-05).
910
+ const copyId = (): string | null => {
911
+ if (forkState !== 'possible') return null
912
+ const verdict = selectForkedId(observedIds, sourceNativeThreadId)
913
+ return verdict.ok ? verdict.newNativeThreadId : null
914
+ }
915
+ const cause = (): string | null => providerFailure ?? classifyProviderErrorText(stderrSample)
916
+ if (timedOut) return settleFailure('timeout', { newNativeThreadId: copyId() })
812
917
  if (spawnErrored) return settleFailure('spawn_failed', { detail: 'child_error' })
813
- if (exitCode !== 0) return settleFailure('provider_exit_nonzero')
918
+ if (exitCode !== 0) return settleFailure('provider_exit_nonzero', { newNativeThreadId: copyId(), detail: cause() })
814
919
 
815
920
  const verdict = selectForkedId(observedIds, sourceNativeThreadId)
816
921
  if (!verdict.ok) {
@@ -930,13 +1035,26 @@ export function realForkDeps(
930
1035
  return {
931
1036
  now: () => Date.now(),
932
1037
  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,
1038
+ spawn: request => {
1039
+ // 6.62.0 (plan 3.4): the Cursor fork's `--model` must not rewrite the CLI default.
1040
+ const isolation = request.provider === 'cursor' ? cursorSpawnEnv({ baseEnv: request.env }) : null
1041
+ // 6.62.0 /qa (W6): a fork never falls back to the shared Cursor config.
1042
+ if (isolation && !isolation.isolated) throw cursorIsolationUnavailableError()
1043
+ try {
1044
+ const child = nodeSpawn(request.binaryPath, [...request.args], {
1045
+ stdio: ['pipe', 'pipe', 'pipe'],
1046
+ cwd: request.cwd,
1047
+ env: isolation ? isolation.env : request.env,
1048
+ // Group leader, so the whole provider tree can be signalled on timeout.
1049
+ detached: true,
1050
+ })
1051
+ if (isolation) releaseCursorSpawnOnExit(child, isolation)
1052
+ return child as unknown as AttachedChildProcess
1053
+ } catch (error) {
1054
+ isolation?.release()
1055
+ throw error
1056
+ }
1057
+ },
940
1058
  processStartMs: pid => realProcessStartMs(pid),
941
1059
  recordSpawn: (pid, startMs) => recordCosSpawn(pid, startMs),
942
1060
  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) => {
@@ -53,7 +53,8 @@ import { basename, join, resolve } from 'node:path'
53
53
  import { NATIVE_THREAD_ID_RE } from './native-thread-id.js'
54
54
  import { transcriptPathFor, type NativeHeadDeps } from './native-head.js'
55
55
  import { parseProcStartUtcMs, type OccupancyDirs, type OccupancyProbes } from './thread-occupancy.js'
56
- import { resolveCursorAgentSession } from './cursor-agent-store.js'
56
+ import { cursorTranscriptPath, resolveCursorAgentSession } from './cursor-agent-store.js'
57
+ import { activeSqliteReaderPids, cliChatLastWriteMs } from './cursor-session-model.js'
57
58
 
58
59
  // Re-exported rather than reimplemented. `claudeSessionsDir` already encodes the
59
60
  // COS_CLAUDE_SESSIONS_DIR -> CLAUDE_CONFIG_DIR -> ~/.claude precedence AND is the
@@ -348,6 +349,39 @@ export function readFile(path: string): string | null {
348
349
  * other id validator (`SAFE_ID_RE`) permits `/` and `.`. If that validation is
349
350
  * ever dropped upstream, this refuses rather than probing an arbitrary path.
350
351
  */
352
+ /**
353
+ * 6.62.0 /qa (W7): one bounded `lsof` on a Cursor CLI chat's `store.db`. A process that
354
+ * holds it open and is not COS (not the server, not a COS child, not one of COS's own
355
+ * short sqlite3 reads) is a run working that chat right now, at the desk or in a terminal
356
+ * `agent`, even when it has written nothing for 30 s. Null: lsof could not tell (timeout,
357
+ * missing, refused); the caller then falls back to the 30 s write rule and this logs it.
358
+ */
359
+ export const CURSOR_CHAT_HOLDER_TIMEOUT_MS = 3_000
360
+ export const cursorChatHolderStats = { probes: 0, held: 0, failed: 0 }
361
+
362
+ export function cursorChatForeignHolder(
363
+ chatDir: string,
364
+ ownPids: () => Iterable<number>,
365
+ run: (bin: string, args: string[], timeoutMs: number) => ProbeOutcome = runProbe,
366
+ ): boolean | null {
367
+ if (typeof chatDir !== 'string' || !chatDir.startsWith('/') || chatDir.includes('\0')) return null
368
+ const path = join(chatDir, 'store.db')
369
+ cursorChatHolderStats.probes += 1
370
+ let pids: number[]
371
+ try {
372
+ pids = interpretLockHolders(run(LSOF_BIN, ['-w', '-t', '--', path], CURSOR_CHAT_HOLDER_TIMEOUT_MS), path)
373
+ } catch (error) {
374
+ cursorChatHolderStats.failed += 1
375
+ console.warn(`[occupancy-probes] cursor chat holder probe failed, the 30 s write rule decides: ${error instanceof Error ? error.message : error}`)
376
+ return null
377
+ }
378
+ const own = new Set<number>([process.pid])
379
+ try { for (const pid of ownPids()) own.add(pid) } catch { /* the ledger is advisory here */ }
380
+ const held = pids.some(pid => !own.has(pid))
381
+ if (held) cursorChatHolderStats.held += 1
382
+ return held
383
+ }
384
+
351
385
  export function lockHolders(path: string): number[] {
352
386
  if (typeof path !== 'string' || path.length === 0 || path.includes('\0')) {
353
387
  throw new Error('lockHolders: refusing an unusable path')
@@ -728,6 +762,16 @@ export function realOccupancyProbes(ledger: SpawnLedgerAccessor): OccupancyProbe
728
762
  lockHolders,
729
763
  cosSpawnedPids: () => sanitizeLedger(ledger()),
730
764
  cursorAgentSession: (threadId, chatsDir) => resolveCursorAgentSession(threadId, chatsDir),
765
+ // 6.62.0 /qa (W7): positive evidence first: a non-COS process holding the chat open.
766
+ cursorChatForeignHolder: chatDir => cursorChatForeignHolder(chatDir, () => [
767
+ ...sanitizeLedger(ledger()).keys(),
768
+ ...activeSqliteReaderPids(),
769
+ ]),
770
+ // 6.62.0 (W14): the CLI chat busy window reads the chat's own files.
771
+ cursorChatLastWriteMs: (chatDir, threadId) => cliChatLastWriteMs(
772
+ chatDir,
773
+ cursorTranscriptPath(threadId, join(process.env.COS_AGENT_SESSIONS_HOME?.trim() || homedir(), '.cursor', 'projects')),
774
+ ),
731
775
  // transcriptMtimeMs is DELIBERATELY absent here. See `withTranscriptClock`.
732
776
  }
733
777
  }
@@ -112,7 +112,7 @@
112
112
  import { execFile } from 'node:child_process'
113
113
  import { randomBytes } from 'node:crypto'
114
114
  import { boundForRedaction } from './activity-preview.js'
115
- import { toolFingerprint, SESSION_ID_RE, type HookEnvelope } from './session-hook-events.js'
115
+ import { inferHookProvider, toolFingerprint, SESSION_ID_RE, type HookEnvelope } from './session-hook-events.js'
116
116
  import { ASK_USER_QUESTION_TOOL, type SessionSignalStore } from './session-signal-store.js'
117
117
 
118
118
  // ---------------------------------------------------------------------------
@@ -302,6 +302,11 @@ export interface PermissionRequestFacts {
302
302
  * received it (its second argument). Null for an earlier script or block: `holdCeilingS`.
303
303
  */
304
304
  hookWaitS: number | null
305
+ /**
306
+ * 6.62.0 (plan 3.10): the engine that asked, from the envelope's stamp (the Codex command
307
+ * writes one) or the payload. Cursor never reaches here (`cursor` refusal above).
308
+ */
309
+ provider?: 'claude' | 'codex'
305
310
  }
306
311
 
307
312
  export type EnvelopeVerdict =
@@ -329,7 +334,7 @@ function readPermissionRequestEnvelope(body: unknown): EnvelopeRead {
329
334
  const hookWaitS = typeof wait === 'number' && Number.isInteger(wait) && wait > 0 && wait <= HOOK_WAIT_STAMP_MAX_S ? wait : null
330
335
  return {
331
336
  ok: true,
332
- facts: { sessionId: p.session_id.toLowerCase(), toolName: p.tool_name, toolInput: p.tool_input, hookStartedAtMs: ts, hookWaitS },
337
+ facts: { sessionId: p.session_id.toLowerCase(), toolName: p.tool_name, toolInput: p.tool_input, hookStartedAtMs: ts, hookWaitS, provider: inferHookProvider(body.provider, p) === 'codex' ? 'codex' : 'claude' },
333
338
  }
334
339
  }
335
340
 
@@ -457,9 +462,16 @@ export const CANCEL_DENY_MESSAGE = 'Cancelled from COS'
457
462
  * 6.53.0: a held prompt whose run was cancelled from the lens. Deny with `interrupt: true`,
458
463
  * so Claude ends the RUN rather than trying something else in place of this tool. The one
459
464
  * case where a broker reply interrupts, and only ever from `cancelSession`.
465
+ *
466
+ * 6.62.0 (W20, C2): never for Codex. Its PermissionRequest refuses `interrupt` (it fails
467
+ * closed on the field), so a Codex prompt gets the plain deny; the halt marker stops the run
468
+ * at its next command.
460
469
  */
461
- export function cancelHookOutput(): Record<string, unknown> {
462
- return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'deny', message: CANCEL_DENY_MESSAGE, interrupt: true } } }
470
+ export function cancelHookOutput(provider: 'claude' | 'codex' = 'claude'): Record<string, unknown> {
471
+ const decision = provider === 'codex'
472
+ ? { behavior: 'deny', message: CANCEL_DENY_MESSAGE }
473
+ : { behavior: 'deny', message: CANCEL_DENY_MESSAGE, interrupt: true }
474
+ return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision } }
463
475
  }
464
476
 
465
477
  /** A tool approval: allow once, or deny with the one message. Never a rule. */
@@ -819,6 +831,8 @@ type Chosen = { answers: Record<string, string[]> } | { decision: 'allow' | 'den
819
831
  interface BrokerItem {
820
832
  id: string
821
833
  sessionId: string
834
+ /** 6.62.0: the engine that asked (plan 3.10); every view says it. */
835
+ provider: 'claude' | 'codex'
822
836
  kind: BrokerItemKind
823
837
  toolName: string
824
838
  toolInput: Record<string, unknown>
@@ -849,7 +863,7 @@ interface BrokerItem {
849
863
  export interface SessionQuestionView {
850
864
  id: string
851
865
  sessionId: string
852
- provider: 'claude'
866
+ provider: 'claude' | 'codex'
853
867
  kind: BrokerItemKind
854
868
  tool: string
855
869
  createdAt: string
@@ -872,7 +886,7 @@ export interface SessionQuestionView {
872
886
  export interface SettledQuestionView {
873
887
  id: string
874
888
  sessionId: string
875
- provider: 'claude'
889
+ provider: 'claude' | 'codex'
876
890
  kind: BrokerItemKind
877
891
  tool: string
878
892
  createdAt: string
@@ -1213,6 +1227,7 @@ export class PermissionBroker {
1213
1227
  const item: BrokerItem = {
1214
1228
  id,
1215
1229
  sessionId: facts.sessionId,
1230
+ provider: facts.provider ?? 'claude',
1216
1231
  kind: request.kind,
1217
1232
  toolName: facts.toolName,
1218
1233
  // Only a question needs its input again (the answer is built on it). An approval's
@@ -1289,7 +1304,7 @@ export class PermissionBroker {
1289
1304
  views.push({
1290
1305
  id: item.id,
1291
1306
  sessionId: item.sessionId,
1292
- provider: 'claude',
1307
+ provider: item.provider,
1293
1308
  kind: item.kind,
1294
1309
  tool: item.toolName,
1295
1310
  createdAt: new Date(item.createdAt).toISOString(),
@@ -1321,7 +1336,7 @@ export class PermissionBroker {
1321
1336
  return listed.map(item => ({
1322
1337
  id: item.id,
1323
1338
  sessionId: item.sessionId,
1324
- provider: 'claude' as const,
1339
+ provider: item.provider,
1325
1340
  kind: item.kind,
1326
1341
  tool: item.toolName,
1327
1342
  createdAt: new Date(item.createdAt).toISOString(),
@@ -1444,7 +1459,7 @@ export class PermissionBroker {
1444
1459
  let denied = 0
1445
1460
  for (const item of [...this.items.values()]) {
1446
1461
  if (item.state !== 'pending' || item.sessionId !== wanted) continue
1447
- if (!item.channel || !item.channel.writable() || !item.channel.reply(cancelHookOutput())) {
1462
+ if (!item.channel || !item.channel.writable() || !item.channel.reply(cancelHookOutput(item.provider))) {
1448
1463
  this.settle(item, 'hook_gone')
1449
1464
  continue
1450
1465
  }