@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.
@@ -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
- return result.delivery !== 'not_attempted' && result.delivery !== 'aborted'
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
- forceTimer = setTimeout(() => {
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
- settleFailure('timeout', { detail: 'unreaped', reaped: false })
1059
- }, FORCE_SETTLE_MS)
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
- const pid = child.pid
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', matcher: 'AskUserQuestion|ExitPlanMode', async: true, timeout: 10 },
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 `cursor-grok-<ver>-high-fast` in `agent
24
- * models`. This grok id is only the fallback when the live list has none.
25
- * `xhigh-fast` is a different SKU and is never selected here.
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': 'cursor-grok-4.5-high-fast',
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
- const GROK_HIGH_FAST_RE = /^cursor-grok-(\d+(?:\.\d+)*)-high-fast$/i
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
- /** Newest `cursor-grok-*-high-fast`. Ignores low/medium/xhigh and non-fast. */
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
- if (!best || compareVersionTuples(version, best.version) > 0) {
64
- best = { model, version }
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].trim()
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
- const models = Array.isArray(parsed.models) ? parsed.models.filter(m => typeof m?.id === 'string' && m.id) : []
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,