@gotcos/glasses-server 6.61.7 → 6.62.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +6 -0
- package/CHANGELOG.md +34 -0
- package/README.md +5 -1
- package/bin/cli.cjs +50 -3
- package/bin/hooks/cos-cursor-observer.cjs +11 -4
- package/bin/hooks/cos-session-hook +46 -11
- package/docs/provider-parity.md +43 -0
- package/managed-runtime-contract.json +1 -0
- package/package.json +4 -2
- package/server/index.ts +135 -16
- package/server/lib/agent-session-binding-store.ts +9 -3
- package/server/lib/agent-session-store.ts +7 -4
- package/server/lib/attached-provider-adapter.ts +158 -45
- package/server/lib/banned-permission-args.ts +42 -1
- package/server/lib/claude-bridge.ts +17 -8
- package/server/lib/claude-hooks-installer.ts +22 -2
- package/server/lib/codex-bridge.ts +17 -2
- package/server/lib/codex-hooks-installer.ts +538 -0
- package/server/lib/codex-live-queue.ts +1 -1
- package/server/lib/codex-model-catalog.ts +49 -2
- package/server/lib/codex-rollout-state.ts +90 -0
- package/server/lib/codex-session-posture.ts +528 -0
- package/server/lib/continue-plan.ts +180 -0
- package/server/lib/continue-read-health.ts +26 -0
- package/server/lib/cursor-bridge.ts +26 -6
- package/server/lib/cursor-fork-context.ts +140 -0
- package/server/lib/cursor-model-catalog.ts +62 -5
- package/server/lib/cursor-observer-installer.ts +48 -7
- package/server/lib/cursor-session-model.ts +339 -0
- package/server/lib/cursor-spawn-env.ts +210 -0
- package/server/lib/fork-thread.ts +143 -25
- package/server/lib/health-static-probes.ts +21 -11
- package/server/lib/lens-gist-engines.ts +15 -4
- package/server/lib/lens-gist.ts +1 -1
- package/server/lib/live-cues-cursor.ts +16 -6
- package/server/lib/occupancy-probes.ts +45 -1
- package/server/lib/permission-broker.ts +24 -9
- package/server/lib/provider-actions.ts +149 -0
- package/server/lib/provider-failure-text.ts +27 -0
- package/server/lib/provider-observe.ts +212 -0
- package/server/lib/provider-proof.ts +23 -14
- package/server/lib/query-job-runtime.ts +16 -1
- package/server/lib/query-job-types.ts +2 -1
- package/server/lib/session-act-fields.ts +61 -0
- package/server/lib/session-cancel.ts +95 -4
- package/server/lib/session-continue-facts-real.ts +74 -0
- package/server/lib/session-continue-facts.ts +267 -0
- package/server/lib/session-halt.ts +67 -6
- package/server/lib/session-hook-events.ts +57 -0
- package/server/lib/session-hook-ledger.ts +21 -3
- package/server/lib/session-hook-spool.ts +68 -6
- package/server/lib/session-hooks-runtime.ts +32 -2
- package/server/lib/session-peer-inbox-deps.ts +7 -6
- package/server/lib/session-recommendation.ts +3 -2
- package/server/lib/session-signal-store.ts +74 -5
- package/server/lib/session-stream-events.ts +32 -1
- package/server/lib/thread-attach-capability.ts +15 -1
- package/server/lib/thread-drain-kick.ts +4 -2
- package/server/lib/thread-occupancy.ts +44 -0
- package/server/lib/thread-turn-queue-deliver.ts +6 -1
- package/server/lib/thread-turn-queue.ts +6 -0
- package/server/lib/turn-request.ts +274 -0
- package/server/routes/agent-session-bindings.ts +224 -22
- package/server/routes/agent-session-stream.ts +26 -7
- package/server/routes/agent-sessions.ts +155 -10
- package/server/routes/health.ts +27 -2
- package/server/routes/session-hooks.ts +56 -5
- package/server/routes/thread-turn-queue.ts +37 -9
- package/server/scripts/hooks-cli.ts +33 -7
|
@@ -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.
|
|
92
|
+
* Providers that can be forked.
|
|
92
93
|
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* only
|
|
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
|
-
/**
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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 =>
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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.
|
package/server/lib/lens-gist.ts
CHANGED
|
@@ -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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
}
|