@gotcos/glasses-server 6.52.3 → 6.53.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/CHANGELOG.md +33 -0
- package/bin/cli.cjs +4 -2
- package/bin/hooks/cos-session-hook +50 -1
- package/package.json +1 -1
- package/server/index.ts +36 -2
- package/server/lib/attached-provider-adapter.ts +338 -28
- package/server/lib/claude-hooks-installer.ts +10 -1
- package/server/lib/cursor-model-catalog.ts +45 -12
- package/server/lib/permission-broker.ts +48 -0
- package/server/lib/session-cancel.ts +190 -0
- package/server/lib/session-halt.ts +156 -0
- package/server/lib/session-hooks-runtime.ts +45 -2
- package/server/lib/thread-turn-queue.ts +9 -0
- package/server/routes/agent-session-bindings.ts +328 -6
- package/server/routes/health.ts +6 -1
- package/server/routes/thread-turn-queue.ts +16 -0
|
@@ -59,8 +59,14 @@
|
|
|
59
59
|
// aborted a child existed, we terminated it, no prompt byte was written
|
|
60
60
|
// ambiguous we called write(); we cannot prove the provider did not act
|
|
61
61
|
// delivered clean exit 0 AND the provider echoed our exact target id
|
|
62
|
+
// cancelled (6.53.0) we called write(), then the USER asked us to stop and we
|
|
63
|
+
// killed the child. The provider may have acted on part of the turn,
|
|
64
|
+
// but that is the user's decision, not an unknown: nothing is lost and
|
|
65
|
+
// nothing may be sent again by COS on its own.
|
|
62
66
|
//
|
|
63
|
-
// Only the first two are provably safe to report as a clean failure.
|
|
67
|
+
// Only the first two are provably safe to report as a clean failure. `cancelled` is not
|
|
68
|
+
// a clean failure either. It avoids a fence only when the whole spawned process tree was
|
|
69
|
+
// positively reaped; doubt or a surviving member is ambiguous and fences fail-closed.
|
|
64
70
|
// `attachedDeliveryAmbiguous` makes that mapping total, and treats any state it
|
|
65
71
|
// does not recognise as ambiguous, so adding a state later cannot silently open
|
|
66
72
|
// the replay path.
|
|
@@ -87,7 +93,7 @@
|
|
|
87
93
|
import { accessSync, constants as fsConstants, statSync } from 'node:fs'
|
|
88
94
|
import { delimiter, isAbsolute, join } from 'node:path'
|
|
89
95
|
import { homedir } from 'node:os'
|
|
90
|
-
import { spawn as nodeSpawn } from 'node:child_process'
|
|
96
|
+
import { execFileSync, spawn as nodeSpawn } from 'node:child_process'
|
|
91
97
|
|
|
92
98
|
import { isValidNativeThreadId } from './native-thread-id.js'
|
|
93
99
|
import { isBindableProvider, type BindableProvider } from './agent-session-binding-store.js'
|
|
@@ -129,9 +135,11 @@ export type AttachedTurnFailure =
|
|
|
129
135
|
| 'no_native_id_returned'
|
|
130
136
|
| 'native_id_mismatch'
|
|
131
137
|
| 'adapter_internal_error'
|
|
138
|
+
/** 6.53.0: the caller's abort signal fired (a cancel from the lens). Never ambiguous. */
|
|
139
|
+
| 'cancelled'
|
|
132
140
|
|
|
133
141
|
/** See the header. The boundary is the first byte handed to the child's stdin. */
|
|
134
|
-
export type AttachedDeliveryState = 'not_attempted' | 'aborted' | 'ambiguous' | 'delivered'
|
|
142
|
+
export type AttachedDeliveryState = 'not_attempted' | 'aborted' | 'ambiguous' | 'delivered' | 'cancelled'
|
|
135
143
|
|
|
136
144
|
/**
|
|
137
145
|
* Bounded classification of a provider's stderr.
|
|
@@ -202,7 +210,10 @@ export type AttachedTurnResult = AttachedTurnSuccess | AttachedTurnFailureResult
|
|
|
202
210
|
*/
|
|
203
211
|
export function attachedDeliveryAmbiguous(result: AttachedTurnResult): boolean {
|
|
204
212
|
if (result.ok) return false
|
|
205
|
-
|
|
213
|
+
// `cancelled` is listed by name, not inferred: a state added later still defaults to
|
|
214
|
+
// fenced. It is not ambiguous because the user ended the turn, and a fence would lock
|
|
215
|
+
// them out of the thread they just stopped (plan validation B2, 2026-09-21).
|
|
216
|
+
return result.delivery !== 'not_attempted' && result.delivery !== 'aborted' && result.delivery !== 'cancelled'
|
|
206
217
|
}
|
|
207
218
|
|
|
208
219
|
// ---------------------------------------------------------------------------
|
|
@@ -305,6 +316,12 @@ export interface AttachedTurnDeps {
|
|
|
305
316
|
* process group on the developer's machine.
|
|
306
317
|
*/
|
|
307
318
|
terminate: (child: AttachedChildProcess, signal: NodeJS.Signals) => void
|
|
319
|
+
/**
|
|
320
|
+
* True while any verified member of the provider process tree is still live.
|
|
321
|
+
* Unknown/probe failure MUST read as true: a cancellation may only be reported
|
|
322
|
+
* reaped after the whole tree is positively gone.
|
|
323
|
+
*/
|
|
324
|
+
processTreeAlive: (child: AttachedChildProcess) => boolean
|
|
308
325
|
/**
|
|
309
326
|
* Argv builder. Optional, defaults to the real one.
|
|
310
327
|
*
|
|
@@ -342,6 +359,13 @@ export interface AttachedTurnRequest {
|
|
|
342
359
|
deps: AttachedTurnDeps
|
|
343
360
|
/** Wall-clock budget for the provider run. Omitted uses the default. */
|
|
344
361
|
timeoutMs?: number
|
|
362
|
+
/**
|
|
363
|
+
* 6.53.0: the cancel from the lens. Checked before the spawn and before the prompt is
|
|
364
|
+
* written (a turn that never started is `cancelled` with delivery `not_attempted` or
|
|
365
|
+
* `aborted`); after the write it stops the child's process group, SIGTERM then SIGKILL
|
|
366
|
+
* after CANCEL_KILL_GRACE_MS. A natural exit 0 with our id that lands first still wins.
|
|
367
|
+
*/
|
|
368
|
+
abortSignal?: AbortSignal
|
|
345
369
|
}
|
|
346
370
|
|
|
347
371
|
/**
|
|
@@ -358,6 +382,14 @@ export const MAX_ATTACHED_TIMEOUT_MS = 30 * 60_000
|
|
|
358
382
|
/** SIGTERM first; this is how long the child gets before SIGKILL. */
|
|
359
383
|
export const KILL_GRACE_MS = 2_000
|
|
360
384
|
|
|
385
|
+
/**
|
|
386
|
+
* The same escalation for a CANCEL, with a longer grace. A timeout is a child that has
|
|
387
|
+
* stopped answering; a cancel lands on a child that is working and may be mid-write, and
|
|
388
|
+
* canary C3 (2026-09-21) saw `claude -p --resume` end cleanly on SIGTERM in about 4 s with
|
|
389
|
+
* a transcript that parses and resumes. Two seconds would SIGKILL it inside that window.
|
|
390
|
+
*/
|
|
391
|
+
export const CANCEL_KILL_GRACE_MS = 5_000
|
|
392
|
+
|
|
361
393
|
/**
|
|
362
394
|
* Last-resort settle after SIGKILL.
|
|
363
395
|
*
|
|
@@ -778,6 +810,11 @@ async function run(
|
|
|
778
810
|
if (bannedArg !== null) {
|
|
779
811
|
return fail('unsupported_policy', 'not_attempted', { ...base, detail: `banned_arg:${bannedArg}`, durationMs: duration() })
|
|
780
812
|
}
|
|
813
|
+
// 6.53.0: a cancel that landed before the spawn. No process is created, so nothing can
|
|
814
|
+
// have been delivered; the turn ends `cancelled`, which the route never fences.
|
|
815
|
+
if (request.abortSignal?.aborted === true) {
|
|
816
|
+
return fail('cancelled', 'not_attempted', { ...base, detail: 'before_spawn', durationMs: duration() })
|
|
817
|
+
}
|
|
781
818
|
let child: AttachedChildProcess
|
|
782
819
|
try {
|
|
783
820
|
child = deps.spawn({ binaryPath, args, cwd, env: buildAttachedEnv() })
|
|
@@ -830,7 +867,7 @@ async function run(
|
|
|
830
867
|
}
|
|
831
868
|
|
|
832
869
|
try {
|
|
833
|
-
return await driveChild({ child, pid, deps, provider, nativeThreadId, prompt, timeoutMs, startedAt })
|
|
870
|
+
return await driveChild({ child, pid, deps, provider, nativeThreadId, prompt, timeoutMs, startedAt, abortSignal: request.abortSignal })
|
|
834
871
|
} finally {
|
|
835
872
|
// Every path: success, mismatch, non-zero exit, timeout, throw. A leaked
|
|
836
873
|
// entry lets a recycled pid inherit our self-ownership claim.
|
|
@@ -847,6 +884,15 @@ function safeTerminate(deps: AttachedTurnDeps, child: AttachedChildProcess, sign
|
|
|
847
884
|
}
|
|
848
885
|
}
|
|
849
886
|
|
|
887
|
+
function safeProcessTreeAlive(deps: AttachedTurnDeps, child: AttachedChildProcess): boolean {
|
|
888
|
+
try {
|
|
889
|
+
return deps.processTreeAlive(child) === true
|
|
890
|
+
} catch {
|
|
891
|
+
// A failed ownership probe is not proof that the process tree is gone.
|
|
892
|
+
return true
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
850
896
|
function safeRelease(deps: AttachedTurnDeps, pid: number): void {
|
|
851
897
|
try {
|
|
852
898
|
deps.releaseSpawn(pid)
|
|
@@ -864,10 +910,11 @@ interface DriveInput {
|
|
|
864
910
|
prompt: string
|
|
865
911
|
timeoutMs: number
|
|
866
912
|
startedAt: number
|
|
913
|
+
abortSignal?: AbortSignal
|
|
867
914
|
}
|
|
868
915
|
|
|
869
916
|
function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
|
|
870
|
-
const { child, deps, provider, nativeThreadId, prompt, timeoutMs, startedAt } = input
|
|
917
|
+
const { child, deps, provider, nativeThreadId, prompt, timeoutMs, startedAt, abortSignal } = input
|
|
871
918
|
|
|
872
919
|
return new Promise<AttachedTurnResult>((resolve) => {
|
|
873
920
|
/** Distinct ids seen. More than one means a fork happened; see the header. */
|
|
@@ -879,16 +926,22 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
|
|
|
879
926
|
let exitCode: number | null = null
|
|
880
927
|
let settled = false
|
|
881
928
|
let timedOut = false
|
|
929
|
+
/** 6.53.0: the caller's abort fired after the child existed. Read before `timedOut`. */
|
|
930
|
+
let cancelled = false
|
|
882
931
|
let spawnErrored = false
|
|
932
|
+
let onAbort: (() => void) | null = null
|
|
883
933
|
|
|
884
934
|
let deadline: ReturnType<typeof setTimeout> | null = null
|
|
885
935
|
let graceTimer: ReturnType<typeof setTimeout> | null = null
|
|
886
936
|
let forceTimer: ReturnType<typeof setTimeout> | null = null
|
|
937
|
+
let treePollTimer: ReturnType<typeof setTimeout> | null = null
|
|
938
|
+
let terminalObserved = false
|
|
887
939
|
|
|
888
940
|
const clearTimers = () => {
|
|
889
941
|
if (deadline) { clearTimeout(deadline); deadline = null }
|
|
890
942
|
if (graceTimer) { clearTimeout(graceTimer); graceTimer = null }
|
|
891
943
|
if (forceTimer) { clearTimeout(forceTimer); forceTimer = null }
|
|
944
|
+
if (treePollTimer) { clearTimeout(treePollTimer); treePollTimer = null }
|
|
892
945
|
}
|
|
893
946
|
|
|
894
947
|
const duration = () => readDuration(deps, startedAt)
|
|
@@ -897,6 +950,10 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
|
|
|
897
950
|
if (settled) return
|
|
898
951
|
settled = true
|
|
899
952
|
clearTimers()
|
|
953
|
+
if (onAbort) {
|
|
954
|
+
try { abortSignal?.removeEventListener('abort', onAbort) } catch { /* nothing to release */ }
|
|
955
|
+
onAbort = null
|
|
956
|
+
}
|
|
900
957
|
resolve(result)
|
|
901
958
|
}
|
|
902
959
|
|
|
@@ -955,6 +1012,10 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
|
|
|
955
1012
|
}
|
|
956
1013
|
|
|
957
1014
|
const finishTerminal = () => {
|
|
1015
|
+
// The CLI wrapper may close before a tool in a separate process group.
|
|
1016
|
+
// Keep the escalation timers alive until the verified whole tree is gone.
|
|
1017
|
+
if ((cancelled || timedOut) && safeProcessTreeAlive(deps, child)) return
|
|
1018
|
+
|
|
958
1019
|
// Drain whatever sat in the trailing partial line before judging.
|
|
959
1020
|
if (stdoutTail.length > 0) {
|
|
960
1021
|
for (const id of extractNativeIdsFromLine(stdoutTail)) {
|
|
@@ -963,6 +1024,13 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
|
|
|
963
1024
|
stdoutTail = ''
|
|
964
1025
|
}
|
|
965
1026
|
|
|
1027
|
+
// 6.53.0: a cancel outranks every failure below, the timeout included (a cancel in
|
|
1028
|
+
// the timeout's kill grace is still the user's decision). It does NOT outrank a
|
|
1029
|
+
// clean exit 0 that echoed our exact id: that turn finished before the stop landed,
|
|
1030
|
+
// and reporting it cancelled would hide a delivered turn.
|
|
1031
|
+
if (cancelled && !(exitCode === 0 && delivery === 'ambiguous' && observedIds.length === 1 && observedIds[0] === nativeThreadId)) {
|
|
1032
|
+
return settleFailure('cancelled', { delivery: 'cancelled' })
|
|
1033
|
+
}
|
|
966
1034
|
if (timedOut) return settleFailure('timeout')
|
|
967
1035
|
if (spawnErrored) return settleFailure('spawn_failed', { detail: 'child_error' })
|
|
968
1036
|
|
|
@@ -1018,6 +1086,7 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
|
|
|
1018
1086
|
})
|
|
1019
1087
|
child.on('close', (code: number | null) => {
|
|
1020
1088
|
if (typeof code === 'number') exitCode = code
|
|
1089
|
+
terminalObserved = true
|
|
1021
1090
|
finishTerminal()
|
|
1022
1091
|
})
|
|
1023
1092
|
} catch {
|
|
@@ -1051,15 +1120,60 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
|
|
|
1051
1120
|
safeTerminate(deps, child, 'SIGTERM')
|
|
1052
1121
|
graceTimer = setTimeout(() => {
|
|
1053
1122
|
safeTerminate(deps, child, 'SIGKILL')
|
|
1054
|
-
|
|
1123
|
+
const forceAt = Date.now() + FORCE_SETTLE_MS
|
|
1124
|
+
const pollForTreeExit = () => {
|
|
1125
|
+
if (settled) return
|
|
1126
|
+
if (terminalObserved && !safeProcessTreeAlive(deps, child)) return finishTerminal()
|
|
1127
|
+
if (Date.now() < forceAt) {
|
|
1128
|
+
treePollTimer = setTimeout(pollForTreeExit, 25)
|
|
1129
|
+
return
|
|
1130
|
+
}
|
|
1055
1131
|
// A child that survived SIGKILL cannot be reached from here, and
|
|
1056
1132
|
// blocking forever would wedge the coordinator and any Control drain
|
|
1057
|
-
// behind it.
|
|
1058
|
-
|
|
1059
|
-
|
|
1133
|
+
// behind it. A cancel that landed during this escalation still reads
|
|
1134
|
+
// as the user's (6.53.0).
|
|
1135
|
+
if (cancelled) settleFailure('cancelled', { delivery: 'cancelled', detail: 'unreaped', reaped: false })
|
|
1136
|
+
else settleFailure('timeout', { detail: 'unreaped', reaped: false })
|
|
1137
|
+
}
|
|
1138
|
+
forceTimer = setTimeout(pollForTreeExit, 25)
|
|
1060
1139
|
}, KILL_GRACE_MS)
|
|
1061
1140
|
}, timeoutMs)
|
|
1062
1141
|
|
|
1142
|
+
// --- the cancel (6.53.0) ---------------------------------------------------
|
|
1143
|
+
// Registered before the write so an abort from here on is seen. The group SIGTERM then
|
|
1144
|
+
// SIGKILL is the timeout's own sequence with a longer grace; the force settle keeps a
|
|
1145
|
+
// child that survives SIGKILL from wedging the route's claim.
|
|
1146
|
+
if (abortSignal) {
|
|
1147
|
+
onAbort = () => {
|
|
1148
|
+
if (settled || cancelled) return
|
|
1149
|
+
cancelled = true
|
|
1150
|
+
// A timeout already escalating keeps its own timers; the result still reads
|
|
1151
|
+
// cancelled (finishTerminal checks `cancelled` first).
|
|
1152
|
+
if (timedOut) return
|
|
1153
|
+
if (deadline) { clearTimeout(deadline); deadline = null }
|
|
1154
|
+
safeTerminate(deps, child, 'SIGTERM')
|
|
1155
|
+
graceTimer = setTimeout(() => {
|
|
1156
|
+
safeTerminate(deps, child, 'SIGKILL')
|
|
1157
|
+
const forceAt = Date.now() + FORCE_SETTLE_MS
|
|
1158
|
+
const pollForTreeExit = () => {
|
|
1159
|
+
if (settled) return
|
|
1160
|
+
if (terminalObserved && !safeProcessTreeAlive(deps, child)) return finishTerminal()
|
|
1161
|
+
if (Date.now() < forceAt) {
|
|
1162
|
+
treePollTimer = setTimeout(pollForTreeExit, 25)
|
|
1163
|
+
return
|
|
1164
|
+
}
|
|
1165
|
+
settleFailure('cancelled', { delivery: 'cancelled', detail: 'unreaped', reaped: false })
|
|
1166
|
+
}
|
|
1167
|
+
forceTimer = setTimeout(pollForTreeExit, 25)
|
|
1168
|
+
}, CANCEL_KILL_GRACE_MS)
|
|
1169
|
+
}
|
|
1170
|
+
try {
|
|
1171
|
+
abortSignal.addEventListener('abort', onAbort, { once: true })
|
|
1172
|
+
} catch {
|
|
1173
|
+
onAbort = null
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1063
1177
|
// --- deliver the prompt --------------------------------------------------
|
|
1064
1178
|
const stdin = child.stdin
|
|
1065
1179
|
if (!stdin || typeof stdin.write !== 'function' || typeof stdin.end !== 'function' || !child.stdout) {
|
|
@@ -1078,6 +1192,15 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
|
|
|
1078
1192
|
/* an stdin that cannot take a listener still gets the write attempt below */
|
|
1079
1193
|
}
|
|
1080
1194
|
|
|
1195
|
+
// 6.53.0: a cancel that landed between the spawn check and here (a dependency that
|
|
1196
|
+
// yielded, or one that aborted synchronously). No prompt byte is written: the child is
|
|
1197
|
+
// killed and the turn is provably undelivered.
|
|
1198
|
+
if (abortSignal?.aborted === true) {
|
|
1199
|
+
safeTerminate(deps, child, 'SIGKILL')
|
|
1200
|
+
delivery = 'aborted'
|
|
1201
|
+
return settleFailure('cancelled', { detail: 'before_write', reaped: false })
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1081
1204
|
try {
|
|
1082
1205
|
// THE BOUNDARY. From this call on, we cannot prove the provider did not
|
|
1083
1206
|
// act on the prompt, so every non-success outcome is ambiguous.
|
|
@@ -1097,6 +1220,207 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
|
|
|
1097
1220
|
// Production wiring
|
|
1098
1221
|
// ---------------------------------------------------------------------------
|
|
1099
1222
|
|
|
1223
|
+
interface AttachedProcessRow {
|
|
1224
|
+
pid: number
|
|
1225
|
+
ppid: number
|
|
1226
|
+
pgid: number
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* Read the live parent and process-group graph for an attached provider.
|
|
1231
|
+
*
|
|
1232
|
+
* Codex may put a shell tool in its OWN process group. Signalling only the
|
|
1233
|
+
* detached CLI group then kills Codex while the tool is re-parented to pid 1.
|
|
1234
|
+
* This probe is synchronous because termination is a one-shot ownership
|
|
1235
|
+
* boundary: the parent graph must be captured before the CLI can exit and erase
|
|
1236
|
+
* it. A failed probe still signals the original root group below, but latches
|
|
1237
|
+
* uncertainty so the adapter cannot later report a false whole-tree reap.
|
|
1238
|
+
*/
|
|
1239
|
+
function readAttachedProcessRows(): AttachedProcessRow[] | null {
|
|
1240
|
+
if (process.platform === 'win32') return []
|
|
1241
|
+
try {
|
|
1242
|
+
const output = execFileSync('/bin/ps', ['-axo', 'pid=,ppid=,pgid='], {
|
|
1243
|
+
encoding: 'utf8',
|
|
1244
|
+
timeout: 1_000,
|
|
1245
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
1246
|
+
})
|
|
1247
|
+
return output.split('\n').flatMap(line => {
|
|
1248
|
+
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s*$/)
|
|
1249
|
+
if (!match) return []
|
|
1250
|
+
const pid = Number(match[1])
|
|
1251
|
+
const ppid = Number(match[2])
|
|
1252
|
+
const pgid = Number(match[3])
|
|
1253
|
+
if (![pid, ppid, pgid].every(Number.isSafeInteger) || pid <= 0 || ppid < 0 || pgid <= 0) return []
|
|
1254
|
+
return [{ pid, ppid, pgid }]
|
|
1255
|
+
})
|
|
1256
|
+
} catch {
|
|
1257
|
+
return null
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function attachedProcessTree(rows: AttachedProcessRow[], roots: Iterable<number>): AttachedProcessRow[] {
|
|
1262
|
+
const owned = new Set<number>(roots)
|
|
1263
|
+
let changed = true
|
|
1264
|
+
while (changed) {
|
|
1265
|
+
changed = false
|
|
1266
|
+
for (const row of rows) {
|
|
1267
|
+
if (!owned.has(row.pid) && owned.has(row.ppid)) {
|
|
1268
|
+
owned.add(row.pid)
|
|
1269
|
+
changed = true
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
return rows.filter(row => owned.has(row.pid))
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function signalPid(pid: number, signal: NodeJS.Signals): void {
|
|
1277
|
+
try { process.kill(pid, signal) } catch { /* already terminal or unavailable */ }
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function signalGroup(pgid: number, signal: NodeJS.Signals): void {
|
|
1281
|
+
if (process.platform === 'win32') return
|
|
1282
|
+
try { process.kill(-pgid, signal) } catch { /* already terminal or unavailable */ }
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* Stop the provider plus every descendant visible before its parent link is
|
|
1287
|
+
* lost. Captured pid/start identities survive the SIGTERM grace, so SIGKILL can
|
|
1288
|
+
* still reach an uncooperative tool after the CLI has exited and the tool has
|
|
1289
|
+
* been re-parented to launchd. Identities are re-checked before reuse; a recycled
|
|
1290
|
+
* pid is never signalled from remembered state.
|
|
1291
|
+
*/
|
|
1292
|
+
function terminateAttachedProcessTree(
|
|
1293
|
+
child: AttachedChildProcess,
|
|
1294
|
+
signal: NodeJS.Signals,
|
|
1295
|
+
rememberedByRoot: Map<number, Map<number, number>>,
|
|
1296
|
+
uncertainRoots: Set<number>,
|
|
1297
|
+
): void {
|
|
1298
|
+
const rootPid = child.pid
|
|
1299
|
+
if (typeof rootPid !== 'number' || !Number.isSafeInteger(rootPid) || rootPid <= 0) {
|
|
1300
|
+
try { ;(child as any).kill?.(signal) } catch { /* nothing further is available */ }
|
|
1301
|
+
return
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
const remembered = rememberedByRoot.get(rootPid) ?? new Map<number, number>()
|
|
1305
|
+
rememberedByRoot.set(rootPid, remembered)
|
|
1306
|
+
|
|
1307
|
+
// Only identities that still name the same kernel process may seed a fresh
|
|
1308
|
+
// descendant scan. This is what makes the five-second SIGKILL safe after the
|
|
1309
|
+
// provider leader has already disappeared.
|
|
1310
|
+
const rememberedRoots: number[] = []
|
|
1311
|
+
for (const [pid, startedAt] of remembered) {
|
|
1312
|
+
if (realProcessStartMs(pid) === startedAt) rememberedRoots.push(pid)
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
const rows = readAttachedProcessRows()
|
|
1316
|
+
if (rows === null) uncertainRoots.add(rootPid)
|
|
1317
|
+
const knownRootStart = remembered.get(rootPid)
|
|
1318
|
+
const currentRootStart = realProcessStartMs(rootPid)
|
|
1319
|
+
const rootVerified = currentRootStart !== null
|
|
1320
|
+
&& (knownRootStart === undefined || currentRootStart === knownRootStart)
|
|
1321
|
+
if (currentRootStart === null && rows?.some(row => row.pid === rootPid)) uncertainRoots.add(rootPid)
|
|
1322
|
+
if (knownRootStart === undefined && currentRootStart !== null) remembered.set(rootPid, currentRootStart)
|
|
1323
|
+
const roots = [...rememberedRoots]
|
|
1324
|
+
if (rootVerified && !roots.includes(rootPid)) roots.push(rootPid)
|
|
1325
|
+
const tree = rows === null ? [] : attachedProcessTree(rows, roots)
|
|
1326
|
+
for (const row of tree) {
|
|
1327
|
+
const startedAt = realProcessStartMs(row.pid)
|
|
1328
|
+
if (startedAt !== null) remembered.set(row.pid, startedAt)
|
|
1329
|
+
else uncertainRoots.add(rootPid)
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// A child in a separate group must be signalled BEFORE the CLI group. If the
|
|
1333
|
+
// CLI exits first, macOS reparents that child to pid 1 and the relationship is
|
|
1334
|
+
// no longer discoverable.
|
|
1335
|
+
const descendantGroups = new Set(
|
|
1336
|
+
tree.filter(row => row.pid !== rootPid && row.pgid !== rootPid).map(row => row.pgid),
|
|
1337
|
+
)
|
|
1338
|
+
for (const pgid of descendantGroups) signalGroup(pgid, signal)
|
|
1339
|
+
for (const row of tree) {
|
|
1340
|
+
if (row.pid !== rootPid) signalPid(row.pid, signal)
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
const rootGroupStillOwned = rootVerified || tree.some(row => row.pgid === rootPid)
|
|
1344
|
+
if (rootGroupStillOwned) signalGroup(rootPid, signal)
|
|
1345
|
+
|
|
1346
|
+
// Direct-child fallback covers a spawn that failed before setsid established
|
|
1347
|
+
// the detached group, and is harmless after a successful group signal.
|
|
1348
|
+
try { ;(child as any).kill?.(signal) } catch { /* already terminal */ }
|
|
1349
|
+
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
function attachedProcessTreeAlive(
|
|
1353
|
+
child: AttachedChildProcess,
|
|
1354
|
+
rememberedByRoot: Map<number, Map<number, number>>,
|
|
1355
|
+
uncertainRoots: Set<number>,
|
|
1356
|
+
): boolean {
|
|
1357
|
+
const rootPid = child.pid
|
|
1358
|
+
if (typeof rootPid !== 'number' || !Number.isSafeInteger(rootPid) || rootPid <= 0) return false
|
|
1359
|
+
const remembered = rememberedByRoot.get(rootPid)
|
|
1360
|
+
const rows = readAttachedProcessRows()
|
|
1361
|
+
// A failed global process-table read cannot prove closure. Keep the record so
|
|
1362
|
+
// the SIGKILL escalation can still target identities captured before TERM.
|
|
1363
|
+
if (rows === null) return true
|
|
1364
|
+
|
|
1365
|
+
let anyLive = false
|
|
1366
|
+
if (remembered) {
|
|
1367
|
+
const verifiedRoots: number[] = []
|
|
1368
|
+
for (const [pid, startedAt] of remembered) {
|
|
1369
|
+
const rowExists = rows.some(row => row.pid === pid)
|
|
1370
|
+
if (!rowExists) {
|
|
1371
|
+
// Absence from a successful full process-table snapshot is the only
|
|
1372
|
+
// positive proof this identity is gone.
|
|
1373
|
+
remembered.delete(pid)
|
|
1374
|
+
continue
|
|
1375
|
+
}
|
|
1376
|
+
const currentStart = realProcessStartMs(pid)
|
|
1377
|
+
if (currentStart === startedAt) {
|
|
1378
|
+
anyLive = true
|
|
1379
|
+
verifiedRoots.push(pid)
|
|
1380
|
+
} else if (currentStart === null) {
|
|
1381
|
+
// The pid still exists but its start could not be read. That is doubt,
|
|
1382
|
+
// not death; retain the identity and permanently fail this cancellation
|
|
1383
|
+
// toward the route's ambiguity fence.
|
|
1384
|
+
anyLive = true
|
|
1385
|
+
uncertainRoots.add(rootPid)
|
|
1386
|
+
} else {
|
|
1387
|
+
// A different start means PID reuse. The original identity is gone and
|
|
1388
|
+
// the replacement may never be signalled from remembered state.
|
|
1389
|
+
remembered.delete(pid)
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
const fresh = attachedProcessTree(rows, verifiedRoots)
|
|
1393
|
+
for (const row of fresh) {
|
|
1394
|
+
const startedAt = realProcessStartMs(row.pid)
|
|
1395
|
+
if (startedAt !== null) {
|
|
1396
|
+
remembered.set(row.pid, startedAt)
|
|
1397
|
+
anyLive = true
|
|
1398
|
+
} else {
|
|
1399
|
+
anyLive = true
|
|
1400
|
+
uncertainRoots.add(rootPid)
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// Once a snapshot/start probe missed part of the tree, later ancestry cannot
|
|
1406
|
+
// reconstruct a process that may already have been re-parented. Never convert
|
|
1407
|
+
// that uncertainty into reaped:true; the route will fence after force-settle.
|
|
1408
|
+
if (uncertainRoots.has(rootPid)) anyLive = true
|
|
1409
|
+
|
|
1410
|
+
try {
|
|
1411
|
+
process.kill(-rootPid, 0)
|
|
1412
|
+
anyLive = true
|
|
1413
|
+
} catch (error: any) {
|
|
1414
|
+
if (error?.code !== 'ESRCH') anyLive = true
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
if (!anyLive) {
|
|
1418
|
+
rememberedByRoot.delete(rootPid)
|
|
1419
|
+
uncertainRoots.delete(rootPid)
|
|
1420
|
+
}
|
|
1421
|
+
return anyLive
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1100
1424
|
/**
|
|
1101
1425
|
* The real dependency set.
|
|
1102
1426
|
*
|
|
@@ -1106,6 +1430,8 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
|
|
|
1106
1430
|
* which is the one answer this module must never invent.
|
|
1107
1431
|
*/
|
|
1108
1432
|
export function realAttachedTurnDeps(preflight: () => AttachedPreflightVerdict): AttachedTurnDeps {
|
|
1433
|
+
const rememberedProcesses = new Map<number, Map<number, number>>()
|
|
1434
|
+
const uncertainProcessTrees = new Set<number>()
|
|
1109
1435
|
return {
|
|
1110
1436
|
now: () => Date.now(),
|
|
1111
1437
|
preflight,
|
|
@@ -1121,23 +1447,7 @@ export function realAttachedTurnDeps(preflight: () => AttachedPreflightVerdict):
|
|
|
1121
1447
|
processStartMs: pid => realProcessStartMs(pid),
|
|
1122
1448
|
recordSpawn: (pid, startMs) => recordCosSpawn(pid, startMs),
|
|
1123
1449
|
releaseSpawn: pid => releaseCosSpawn(pid),
|
|
1124
|
-
terminate: (child, signal) =>
|
|
1125
|
-
|
|
1126
|
-
if (typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0) {
|
|
1127
|
-
try {
|
|
1128
|
-
// Negative pid = process group, reachable because we spawned
|
|
1129
|
-
// detached. Kills the CLI's own children too.
|
|
1130
|
-
process.kill(-pid, signal)
|
|
1131
|
-
return
|
|
1132
|
-
} catch {
|
|
1133
|
-
/* fall through to the direct signal */
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
try {
|
|
1137
|
-
;(child as any).kill?.(signal)
|
|
1138
|
-
} catch {
|
|
1139
|
-
/* nothing further is available */
|
|
1140
|
-
}
|
|
1141
|
-
},
|
|
1450
|
+
terminate: (child, signal) => terminateAttachedProcessTree(child, signal, rememberedProcesses, uncertainProcessTrees),
|
|
1451
|
+
processTreeAlive: child => attachedProcessTreeAlive(child, rememberedProcesses, uncertainProcessTrees),
|
|
1142
1452
|
}
|
|
1143
1453
|
}
|
|
@@ -46,6 +46,15 @@ export interface HookSubscription {
|
|
|
46
46
|
* UserPromptSubmit would leave a running turn reading idle). The write is a few
|
|
47
47
|
* milliseconds. PermissionRequest must be synchronous to return a decision; the rest
|
|
48
48
|
* are fire-and-forget.
|
|
49
|
+
*
|
|
50
|
+
* 6.53.0: PreToolUse is SYNCHRONOUS with NO matcher, so the script's halt check sees every
|
|
51
|
+
* tool call and can stop a desk run (cancel from the lens). Canaries, 2026-09-21: the stop
|
|
52
|
+
* needs `permissionDecision: deny` plus `continue: false` (C1b; `continue: false` alone
|
|
53
|
+
* still ran the tool), the check costs about 11 ms per call (C2), and a hook that times out
|
|
54
|
+
* (C6) or exits 1 (C7) lets the tool run, so a broken hook fails open. ONE block per event,
|
|
55
|
+
* because the merge keeps exactly one of ours per event and status requires it: the script
|
|
56
|
+
* spools only AskUserQuestion and ExitPlanMode, which is what the old async matcher block
|
|
57
|
+
* did. Changing this row makes every existing install read `drift` until Install hooks.
|
|
49
58
|
*/
|
|
50
59
|
export const HOOK_SUBSCRIPTIONS: readonly HookSubscription[] = [
|
|
51
60
|
{ event: 'SessionStart', async: false, timeout: 5 },
|
|
@@ -55,7 +64,7 @@ export const HOOK_SUBSCRIPTIONS: readonly HookSubscription[] = [
|
|
|
55
64
|
{ event: 'StopFailure', async: true, timeout: 10 },
|
|
56
65
|
{ event: 'PermissionRequest', async: false, timeout: 130 },
|
|
57
66
|
{ event: 'PermissionDenied', async: true, timeout: 10 },
|
|
58
|
-
{ event: 'PreToolUse',
|
|
67
|
+
{ event: 'PreToolUse', async: false, timeout: 5 },
|
|
59
68
|
{ event: 'PostToolUse', async: true, timeout: 10 },
|
|
60
69
|
{ event: 'PostToolUseFailure', async: true, timeout: 10 },
|
|
61
70
|
{ event: 'Notification', matcher: 'permission_prompt|idle_prompt|elicitation_dialog|agent_needs_input', async: true, timeout: 10 },
|
|
@@ -20,12 +20,19 @@ const DEFAULT_REFRESH_TIMEOUT_MS = 7_000
|
|
|
20
20
|
/**
|
|
21
21
|
* Stable slot → CLI model id mapping.
|
|
22
22
|
* Composer stays pinned (no versioned high-fast family). Grok high-fast is
|
|
23
|
-
* chosen at catalog build: newest `
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* chosen at catalog build: newest `grok-<ver>-high-fast` or
|
|
24
|
+
* `cursor-grok-<ver>-high-fast` proven by `agent models`. The constant records
|
|
25
|
+
* the current canonical id; it is not returned when the live catalog does not
|
|
26
|
+
* prove that model exists. `xhigh-fast` is a different SKU and is never selected.
|
|
27
|
+
*
|
|
28
|
+
* 2026-09-21 (6.53.0): Grok 4.7 shipped under a NEW id scheme with no `cursor-`
|
|
29
|
+
* prefix (`grok-4.7-high-fast`, per `cursor-agent models` on CLI 2026.09.18,
|
|
30
|
+
* which still lists the old `cursor-grok-4.6-high-fast` and 4.5 ids). The ask:
|
|
31
|
+
* "make sure we're running the latest and greatest on the grok front". The
|
|
32
|
+
* canonical id moves to the new scheme; the live list remains authoritative.
|
|
26
33
|
*/
|
|
27
34
|
export const CURSOR_SLOT_MODEL_IDS = {
|
|
28
|
-
'cursor-grok': '
|
|
35
|
+
'cursor-grok': 'grok-4.7-high-fast',
|
|
29
36
|
'cursor-composer': 'composer-2.5-fast',
|
|
30
37
|
} as const satisfies Record<CursorModelPreference, string>
|
|
31
38
|
|
|
@@ -34,7 +41,12 @@ const SLOT_DISPLAY_FALLBACK = {
|
|
|
34
41
|
'cursor-composer': 'Composer 2.5 Fast',
|
|
35
42
|
} as const satisfies Record<CursorModelPreference, string>
|
|
36
43
|
|
|
37
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Both id schemes: `cursor-grok-4.6-high-fast` (through 4.6) and `grok-4.7-high-fast`
|
|
46
|
+
* (4.7 on). Anchored at both ends, so `-xhigh-fast`, `-medium-fast`, `-low-fast` and the
|
|
47
|
+
* non-fast `-high` never match.
|
|
48
|
+
*/
|
|
49
|
+
const GROK_HIGH_FAST_RE = /^(?:cursor-)?grok-(\d+(?:\.\d+)*)-high-fast$/i
|
|
38
50
|
|
|
39
51
|
export function parseCursorGrokHighFastVersion(id: string): number[] | null {
|
|
40
52
|
const match = GROK_HIGH_FAST_RE.exec(id.trim())
|
|
@@ -42,6 +54,17 @@ export function parseCursorGrokHighFastVersion(id: string): number[] | null {
|
|
|
42
54
|
return match[1].split('.').map(part => Number(part))
|
|
43
55
|
}
|
|
44
56
|
|
|
57
|
+
/**
|
|
58
|
+
* A display name as the CLI prints it, made printable. CLI 2026.09.18 writes
|
|
59
|
+
* "Grok 4.7 High Fast" with a double space and trailing zero-width characters; the lens
|
|
60
|
+
* would draw neither correctly. Format characters (`\p{Cf}`: zero-width space and joiners,
|
|
61
|
+
* direction marks, BOM) are removed, runs of whitespace become one space, and the ends
|
|
62
|
+
* are trimmed.
|
|
63
|
+
*/
|
|
64
|
+
export function normalizeCursorDisplayName(name: string): string {
|
|
65
|
+
return name.replace(/\p{Cf}/gu, '').replace(/\s+/g, ' ').trim()
|
|
66
|
+
}
|
|
67
|
+
|
|
45
68
|
export function compareVersionTuples(a: number[], b: number[]): number {
|
|
46
69
|
const length = Math.max(a.length, b.length)
|
|
47
70
|
for (let index = 0; index < length; index++) {
|
|
@@ -52,16 +75,22 @@ export function compareVersionTuples(a: number[], b: number[]): number {
|
|
|
52
75
|
return 0
|
|
53
76
|
}
|
|
54
77
|
|
|
55
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Newest Grok high-fast, either id scheme, compared numerically. Ignores low/medium/xhigh
|
|
80
|
+
* and non-fast. On an exact version tie the unprefixed id (the new scheme) wins, whatever
|
|
81
|
+
* order the CLI lists them in.
|
|
82
|
+
*/
|
|
56
83
|
export function selectNewestCursorGrokHighFast(
|
|
57
84
|
models: CursorCatalogModel[],
|
|
58
85
|
): CursorCatalogModel | undefined {
|
|
59
|
-
let best: { model: CursorCatalogModel; version: number[] } | undefined
|
|
86
|
+
let best: { model: CursorCatalogModel; version: number[]; prefixed: boolean } | undefined
|
|
60
87
|
for (const model of models) {
|
|
61
88
|
const version = parseCursorGrokHighFastVersion(model.id)
|
|
62
89
|
if (!version) continue
|
|
63
|
-
|
|
64
|
-
|
|
90
|
+
const prefixed = /^cursor-/i.test(model.id.trim())
|
|
91
|
+
const order = best ? compareVersionTuples(version, best.version) : 1
|
|
92
|
+
if (!best || order > 0 || (order === 0 && best.prefixed && !prefixed)) {
|
|
93
|
+
best = { model, version, prefixed }
|
|
65
94
|
}
|
|
66
95
|
}
|
|
67
96
|
return best?.model
|
|
@@ -109,7 +138,7 @@ export function parseAgentModelsText(text: string): CursorCatalogModel[] {
|
|
|
109
138
|
const match = /^([a-z0-9][a-z0-9._\[\]-]*)\s+-\s+(.+)$/i.exec(line)
|
|
110
139
|
if (!match) continue
|
|
111
140
|
const id = match[1]
|
|
112
|
-
const displayName = match[2]
|
|
141
|
+
const displayName = normalizeCursorDisplayName(match[2])
|
|
113
142
|
if (!id || !displayName || seen.has(id)) continue
|
|
114
143
|
seen.add(id)
|
|
115
144
|
models.push({ id, displayName })
|
|
@@ -126,7 +155,6 @@ export function buildCursorModelCatalog(
|
|
|
126
155
|
): CursorModelCatalog {
|
|
127
156
|
const byId = new Map(models.map(model => [model.id, model]))
|
|
128
157
|
const grok = selectNewestCursorGrokHighFast(models)
|
|
129
|
-
?? byId.get(CURSOR_SLOT_MODEL_IDS[CURSOR_GROK_MODEL])
|
|
130
158
|
const composer = byId.get(CURSOR_SLOT_MODEL_IDS[CURSOR_COMPOSER_MODEL])
|
|
131
159
|
const options: CursorModelOption[] = [
|
|
132
160
|
{
|
|
@@ -168,7 +196,12 @@ function readDiskCatalog(): CursorModelCatalog | null {
|
|
|
168
196
|
refreshedAt?: string
|
|
169
197
|
agentBinary?: string
|
|
170
198
|
}
|
|
171
|
-
|
|
199
|
+
// A cache written before 6.53.0 kept the CLI's raw display names; normalize on read.
|
|
200
|
+
const models = Array.isArray(parsed.models)
|
|
201
|
+
? parsed.models
|
|
202
|
+
.filter(m => typeof m?.id === 'string' && m.id)
|
|
203
|
+
.map(m => ({ ...m, displayName: normalizeCursorDisplayName(typeof m.displayName === 'string' ? m.displayName : '') }))
|
|
204
|
+
: []
|
|
172
205
|
if (models.length === 0) return null
|
|
173
206
|
return buildCursorModelCatalog(
|
|
174
207
|
models,
|