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