@gotcos/glasses-server 6.52.0 → 6.53.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/CHANGELOG.md CHANGED
@@ -1,3 +1,45 @@
1
+ ## 6.53.0
2
+
3
+ ### Cancel a session run from the glasses
4
+
5
+ Miles, 2026-09-21: "If there's a current run that's going, it essentially clicks the cancel button ... similar to when they're at the desktop." This is the server half; COS Glasses 6.9.529 adds the Cancel run row. Who owns the run decides what a cancel can do, and the server says which in every attachability answer.
6
+
7
+ - **A COS turn stops at once.** A Continue COS itself spawned (`claude -p --resume`, `codex exec resume`, `cursor-agent`) is registered when its target claim is taken, with its own abort controller, and forgotten in the same `finally` that releases the claim. A cancel aborts it: the adapter sends the child's process group SIGTERM, then SIGKILL after 5 s (a timeout keeps its 2 s; canary C3 saw `claude -p` end cleanly on SIGTERM in about 4 s), and checks the cancel before the spawn and before the prompt is written. The turn ends `turn_cancelled`: ledgered as refused and not retryable, so every app settles it, and NEVER fenced. Until now an aborted child exited on a signal, which read as "may or may not have been delivered" and locked the thread (plan validation B2). A clean exit 0 with our thread id that lands first still counts as delivered. The route re-reads the thread head after a cancel, so the next Continue is not refused as "changed on your Mac".
8
+ - **A Claude run at the desk stops at its next tool call.** A Desktop tab, a terminal, or a Continue delivered into the open window. The server writes a halt marker, `data/session-halt/<session id>`, and the session hook, now synchronous on PreToolUse, answers deny plus `continue: false` before the next tool. Canaries on Claude Code 2.1.278: `continue: false` alone still ran the tool, with the deny it did not (C1b); an interactive session honours it (C5), and so does one opened before the hook was added (C10); the check costs about 11 ms per tool call (C2); a hook that times out or exits 1 lets the tool run (C6, C7), and the script never exits 2. A reply that is pure text finishes first, which is why the lens says "Stops at its next step." A permission prompt the broker is holding for that session is denied at once with `interrupt: true`.
9
+ - **The marker lives only within the run it was written for.** The hook itself removes it on the session's next UserPromptSubmit (a prompt at the desk, a live Continue, a COS turn), so a new prompt is never stopped. The server removes it on that session's SessionEnd and after an hour. Not on Stop: a background subagent reports its parent's session id (C8) and is work a cancel must stop too.
10
+ - **Codex app and Cursor runs cannot be stopped from outside**, and the answer says so: "Runs in Codex on your Mac. Stop it there." (or Cursor).
11
+ - **`POST /api/agent-sessions/:provider/:threadId/cancel`**, body `{"clientCancelId": "..."}`, registered whether or not thread attach is on. `202 {"cancelled": true, "target": "cos_turn", "state": "stopping", "turnId", "queuedHeld", "queuedHoldMs"}` or `202 {"cancelled": true, "target": "desk_run", "effective": "next_tool_call", "queuedHeld", "queuedHoldMs", "settledPermissions"}` (`queuedHoldMs` is the hold below, 120000, so a client does not invent its duration); otherwise `409 {"cancelled": false, "reason", "reasonCopy"}` with `not_running` ("Nothing running now."), `cancel_unsupported`, `hooks_outdated` ("Install hooks in COS Control, then retry.") or `hooks_disabled`. A malformed provider, thread id or `clientCancelId` is `400 invalid_request`; a desk marker that could not be written is `500 cancel_failed` ("Could not cancel. Check the Mac."), never an acceptance. The same `clientCancelId` gets the same answer again while this server process remains up. Every cancel is a line in `data/session-cancel.jsonl`.
12
+ - **Attachability says what a cancel would do.** `GET .../attachability` gains `cancel`: `cos_turn`, `desk_run`, `unsupported`, `hooks_outdated`, `hooks_disabled`, or null when nothing is running. The route and this field are decided by one function (`cancelTargetFor`), so the row the lens shows and the answer to the tap agree. A 6.52 server sends no field, and the app then shows no row.
13
+ - **Parked turns wait two minutes after a cancel while this server process remains up**, then send as normal; the 202 says how many are held. The short hold is deliberately in memory, so a server restart returns persisted queued turns to the ordinary delivery gate. A cancel newer than the session's last transcript row or hook event ends the turn for the queue, so a killed child's unanswered tool call no longer holds parked turns for the 30-minute open-turn ceiling. A queued turn cancelled while it was being admitted is settled cancelled, never sent again.
14
+ - **Health.** `features.sessionCancel`: `{"cosTurn": true, "deskClaude": <hooks on and the 6.53 hook installed>}`.
15
+ - **Rollout: Install hooks in COS Control after updating the server.** The hook script and the PreToolUse subscription both changed (one synchronous block, no matcher; the script still spools only AskUserQuestion and ExitPlanMode). Update Server alone never re-copies the script, so until Install hooks runs the hooks read `drift` or `script_outdated`, a desk cancel answers `hooks_outdated`, and `deskClaude` is false. A COS turn can be cancelled right away.
16
+ - Tests: the hook script executed against fixtures (the deny exactly, nothing printed or spooled for an ordinary tool, the two spooled tools, UserPromptSubmit clearing and still spooling, malformed input, the first session_id winning over one inside `tool_input`, a full spool, exit status never 2, no temp file left); the installer's single PreToolUse block and a 6.52 install reading drift; the adapter's abort before spawn, before the write, mid-run with the 5 s grace, a clean exit racing the cancel, a cancel during a timeout; the route end to end with the real adapter (SIGTERM, `turn_cancelled`, no fence), the head acknowledgement, idempotency, every refusal, the attachability field; the broker's deny with interrupt; the queue hold and its order after expiry; marker cleanup on SessionEnd but not Stop or a child; health. The script's sha pin moves on purpose. The halt cases live in their own file so a mutation of the script is judged by behaviour, not by the pin. Gate: the `halt-*`, `adapter-*cancel*`, `cancel-*`, `desk-running-*`, `broker-cancel-*`, `queue-cancel-*` and `health-desk-claude-needs-install` cases, 48 of 48 killed. Not gated: the script's lower-casing of the session id, whose mutant survives on this Mac's case-insensitive volume (it matters only on a case-sensitive one).
17
+
18
+ ### Grok 4.7
19
+
20
+ - **Grok 4.7: the Cursor Grok slot now picks the newest Grok, including the new grok-4.7 ids (no cursor- prefix), so Messages on Grok run Grok 4.7 when the CLI advertises it.** Miles, 2026-09-21: "make sure we're running the latest and greatest on the grok front". `cursor-agent models` on CLI 2026.09.18 lists `grok-4.7-high-fast` beside the old `cursor-grok-4.6-high-fast`. The slot matches both schemes, compares versions numerically, and on an exact tie prefers the unprefixed id; low, medium, xhigh and non-fast are still never chosen. The live model list remains authoritative; `grok-4.7-high-fast` is the canonical id, not an unverified fallback.
21
+ - **Display names from the CLI are cleaned**: format characters (the trailing zero-width spaces) are removed and runs of whitespace collapse, so the slot reads "Grok 4.7 High Fast", including from a model cache written before this release.
22
+ - Tests: the 2026-09-18 list picks `grok-4.7-high-fast` with a clean name; low, medium, xhigh and non-fast ignored in the new scheme; an old-scheme-only list still picks its newest; numeric comparison and the tie in both list orders; the new ids resolve to the Grok slot. Gate: `grok-*`, 4 of 4 killed.
23
+
24
+ ## 6.52.3
25
+
26
+ ### A quiet ring stays with its owner; a zombie never takes it on waking
27
+
28
+ 2026-09-20: a hidden copy of the glasses app from the night before took the ring after the live copy went quiet, and answered a hold-to-talk 27 minutes later with a stale session. Under 6.50.4 an older boot could take a ring whose owner had been quiet for 45 s, and a locked phone is quiet for longer than that.
29
+
30
+ - **An older boot takes a quiet ring back only after ten minutes** (`CLIENT_INSTANCE_RECLAIM_MS`), **and only while it has itself been claiming** (`CLIENT_INSTANCE_AWAKE_MS`, 45 s: its own previous claim must be that recent). Both copies sleep when the phone does, so a zombie that wakes beside the live copy has a gap of its own and is refused; the live copy's refresh then lands within a tick. A copy never seen before is not awake. A NEWER boot still takes at once, however quiet the owner: reopening COS remains the fix for a dead copy. The route remembers each copy's last claim time (`CLIENT_INSTANCE_MAX_SEEN` 64, least recent dropped).
31
+ - Everything else in the referee is 6.50.4: one owner per device, the recording holds (own tagged chunks, `recording` in the claim), `check: true` never takes, no protocol change. Glasses 6.9.510 and 6.9.516 see only the longer takeover window. No new client is required; glasses 6.9.518 adds the matching client-side check.
32
+ - **The 6.52.1 Codex fixtures now expect `turn_started_at`.** 6.52.1 stamped the prompt line with the user record's timestamp and left three `session-stream-codex` expectations on the old shape, so the suite was red. The behaviour is unchanged; the expectations were updated.
33
+ - Tests: the reclaim edge (10 min, awake), the overnight zombie (hours-old own claim, never seen), the awake edge to the millisecond, the newer boot still taking, and over HTTP: checks past the window say owner without taking, a first claim after sleep is refused and the next tick takes. Gate: `referee-zombie-reclaims-after-45s`, `referee-asleep-zombie-reclaims`, `referee-never-seen-counts-as-awake`, `referee-last-seen-unnoted`.
34
+
35
+ ## 6.52.1
36
+
37
+ ### Session turn clock
38
+
39
+ Sessions prompt events now carry additive `turn_started_at` (ISO), the timestamp on the user record that started the turn. Seed, reconnect, and live frames keep that value; transport `at` is still `Date.now()` at emit time, so a reconnect no longer resets the glasses elapsed clock. `/api/models` advertises `capabilities.runningTurnMeta.protocolVersion = 1`. A 6.52.0 client strips the unknown field and renders as before. Messages jobs already have durable `startedAt`; this is Sessions only.
40
+
41
+ - Tests: a Claude user row with `timestamp` stamps the prompt draft; a row without one is byte-identical to 6.52.0; the models capability is present beside `sessionQuestions`.
42
+
1
43
  ## 6.52.0
2
44
 
3
45
  ### Messages trail
package/bin/cli.cjs CHANGED
@@ -301,7 +301,9 @@ function cursorCliState() {
301
301
  stdio: ['ignore', 'pipe', 'pipe'],
302
302
  timeout: 7000,
303
303
  })
304
- const grokHighFast = /cursor-grok-\d+(?:\.\d+)*-high-fast/.test(models)
304
+ // Cursor 2026.09 introduced `grok-4.7-high-fast` without the old
305
+ // `cursor-` prefix. Accept both exact high-fast families, like runtime does.
306
+ const grokHighFast = /(?:^|\n)(?:cursor-)?grok-\d+(?:\.\d+)*-high-fast\s+-/m.test(models)
305
307
  return {
306
308
  binary,
307
309
  version,
@@ -358,7 +360,7 @@ if (cursor.binary) {
358
360
  console.log(' Run: ' + bold('agent login'))
359
361
  } else if (cursor.auth === 'models-unresolved') {
360
362
  console.log(yellow(' ⚠') + ` Cursor Agent ${cursor.version} installed — required models unresolved`)
361
- console.log(' Verify: ' + bold('agent models') + ' includes Composer 2.5 Fast and a cursor-grok-*-high-fast id')
363
+ console.log(' Verify: ' + bold('agent models') + ' includes Composer 2.5 Fast and a grok-*-high-fast id')
362
364
  } else {
363
365
  console.log(yellow(' ⚠') + ` Cursor Agent ${cursor.version} installed — readiness unavailable`)
364
366
  console.log(' Verify: ' + bold('agent models'))
@@ -1,17 +1,66 @@
1
1
  #!/bin/sh
2
- # COS session hook (glasses-server 6.48.0; Cursor Stop follow-up 6.51.0). Installed into ~/.claude/settings.json by
2
+ # COS session hook (glasses-server 6.48.0; Cursor Stop follow-up 6.51.0; halt check 6.53.0). Installed into ~/.claude/settings.json by
3
3
  # `--hooks install`; Claude Code runs it with the hook event name as $1 and the event
4
4
  # payload on stdin. It spools one JSON file per event into the hook spool and exits 0
5
5
  # on every path. Only PermissionRequest ever talks to the server, and only when the
6
6
  # desk has been idle. Everything else is a file write, so the server being down or
7
7
  # restarting never delays a Claude session.
8
8
  #
9
+ # 6.53.0: PreToolUse is SYNCHRONOUS with no matcher, so the first branch below runs before
10
+ # EVERY tool call in every Claude session on this Mac. It is file-only (no curl, no spool
11
+ # write) and prints nothing unless the server left a halt marker for this session, when it
12
+ # prints the one reply that stops the run (canary C1b, 2026-09-21: `continue:false` alone
13
+ # still ran the tool; with the deny it did not). It never exits 2: exit 2 blocks the tool,
14
+ # while a timeout or any other code fails open (C6, C7).
15
+ #
9
16
  # The installer bakes COS_GLASSES_HOME and COS_HOOK_SPOOL into the command, so this
10
17
  # script and the server can never disagree about where the token or the spool is.
11
18
  PATH=/usr/bin:/bin:/usr/sbin; umask 077; trap '' PIPE
12
19
  HOME=${HOME:-$(cd ~ 2>/dev/null && pwd)}; [ -n "$HOME" ] || exit 0
13
20
  GH=${COS_GLASSES_HOME:-$HOME/.cos-glasses}
14
21
  SPOOL=${COS_HOOK_SPOOL:-$GH/data/hook-spool}; EVENT="$1"
22
+ # 6.53.0, the halt check. FIRST, before mkdir, perl and the B8 guard below: a full spool or
23
+ # a stale drain stamp must never stop a cancel, and an ordinary tool call must cost nothing
24
+ # but this. The marker folder sits beside the spool, like the Cursor queue folder; the server
25
+ # writes `<folder>/<session id>` (lib/session-halt.ts). stdin is read ONCE, to a temp file,
26
+ # and handed to the spool path below when the event still needs spooling.
27
+ IN=''
28
+ case "$EVENT" in PreToolUse|UserPromptSubmit)
29
+ IN=$(mktemp "${TMPDIR:-/tmp}/cos-hook.XXXXXX" 2>/dev/null || mktemp /tmp/cos-hook.XXXXXX 2>/dev/null) || IN=''
30
+ # No temp file: a tool call goes ahead unchecked (fail open), and a prompt is spooled
31
+ # below exactly as before, its stdin untouched.
32
+ [ -n "$IN" ] || [ "$EVENT" = UserPromptSubmit ] || exit 0
33
+ ;;
34
+ esac
35
+ [ -z "$IN" ] || {
36
+ HALT="$(dirname "$SPOOL")/session-halt"
37
+ trap 'rm -f "$IN"; exit 0' HUP INT TERM
38
+ head -c 1048576 > "$IN" 2>/dev/null
39
+ # Any marker at all? A glob is a builtin, so the common case (no cancel anywhere on the
40
+ # Mac) reads no session id and forks nothing for it.
41
+ SID=''; for F in "$HALT"/*; do [ -e "$F" ] && SID=x; break; done
42
+ # The FIRST session_id is the payload's own: Claude writes it first, and a tool's input
43
+ # that happens to carry one comes later.
44
+ [ -z "$SID" ] || SID=$(grep -Eo '"session_id"[[:space:]]*:[[:space:]]*"[0-9A-Fa-f-]{36}"' "$IN" 2>/dev/null | head -1 | grep -Eo '[0-9A-Fa-f-]{36}' | tr 'A-F' 'a-f')
45
+ if [ "$EVENT" = UserPromptSubmit ]; then
46
+ # A new prompt is never stopped: the marker lives only within the run it was written
47
+ # for. This covers a prompt typed at the desk, a Continue delivered into the open
48
+ # session, and a COS `-p` turn. Then the prompt is spooled exactly as before.
49
+ [ -z "$SID" ] || rm -f "$HALT/$SID" 2>/dev/null
50
+ elif [ -n "$SID" ] && [ -f "$HALT/$SID" ]; then
51
+ rm -f "$IN"
52
+ printf '%s' '{"continue":false,"stopReason":"Cancelled from COS","hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Cancelled from COS"}}'
53
+ exit 0
54
+ else
55
+ # Only the two tools the reducer reads were ever spooled (the 6.48 matcher); every
56
+ # other tool call ends here with nothing written and nothing printed.
57
+ TOOL=$(grep -Eo '"tool_name"[[:space:]]*:[[:space:]]*"[A-Za-z]+"' "$IN" 2>/dev/null | head -1)
58
+ case "$TOOL" in *'"AskUserQuestion"'|*'"ExitPlanMode"') ;; *) rm -f "$IN"; exit 0 ;; esac
59
+ fi
60
+ # The spool path below reads stdin: hand it the saved bytes. The open descriptor keeps
61
+ # them readable after the unlink, so no temp file outlives this line.
62
+ exec < "$IN"; rm -f "$IN"; trap - HUP INT TERM
63
+ }
15
64
  mkdir -p "$SPOOL/rejected" 2>/dev/null || exit 0
16
65
  TS=$(perl -MTime::HiRes -e 'printf "%.0f", Time::HiRes::time()*1000' 2>/dev/null || date +%s000)
17
66
  # B8 guard: a full spool or a stale drain stamp means nobody is reading; keep only permissions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.52.0",
3
+ "version": "6.53.0",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -24,7 +24,9 @@ import { agentSessionStreamRouter } from './routes/agent-session-stream.js'
24
24
  import { createAttachedTurnStream } from './lib/session-stream-producer.js'
25
25
  import { claudeSessionsRouter } from './routes/claude-sessions.js'
26
26
  import { createSessionHooksRouter } from './routes/session-hooks.js'
27
- import { deskIdleSeconds, registerDrainKickStats, registryIdleAfterStop, sessionHooksEnabled, sessionSignalStore, signalFor, startSessionHooksRuntime } from './lib/session-hooks-runtime.js'
27
+ import { cachedHookStatus, claudeDeskRunning, deskIdleSeconds, registerDrainKickStats, registryIdleAfterStop, sessionHooksEnabled, sessionSignalStore, signalFor, startSessionHooksRuntime } from './lib/session-hooks-runtime.js'
28
+ import { writeHaltMarker } from './lib/session-halt.js'
29
+ import { appendSessionCancelLedger, cancelEndsTurn, cancelHoldUntil as cancelHoldUntilFor, noteThreadCancelled, threadCancelledAt } from './lib/session-cancel.js'
28
30
  import {
29
31
  PermissionBroker,
30
32
  brokerSignalSink,
@@ -495,6 +497,8 @@ const deliverAttachedTurnForRoute = async (request: {
495
497
  nativeThreadId: string
496
498
  prompt: string
497
499
  onSpawn: (pid: number) => boolean
500
+ /** 6.53.0: the cancel from the lens, passed straight through to the adapter. */
501
+ abortSignal?: AbortSignal
498
502
  }): Promise<unknown> => {
499
503
  // Resolved here, not stored on the binding: the cwd is read from the transcript,
500
504
  // which records it verbatim. Decoding the project slug is lossy — this Mac's own
@@ -537,6 +541,7 @@ const deliverAttachedTurnForRoute = async (request: {
537
541
  // The only policy this build accepts. The adapter refuses anything else and
538
542
  // asserts no bypass/always-approve flag reaches the argv (plan 4.7).
539
543
  policy: 'read_only',
544
+ abortSignal: request.abortSignal,
540
545
  deps: {
541
546
  ...base,
542
547
  // `startMs` is deliberately unused: the adapter already probed it as a GATE
@@ -709,6 +714,14 @@ if (threadAttachEnabled()) {
709
714
  turnEnded: (provider: string, threadId: string) => {
710
715
  if (provider !== 'claude' && provider !== 'codex') return false
711
716
  try {
717
+ // 6.53.0: a cancel with nothing written since it ENDED the turn. A killed COS child
718
+ // leaves its last tool_use unanswered, and no Stop or terminal record ever follows.
719
+ const cancelledAt = threadCancelledAt(provider, threadId)
720
+ if (cancelledAt !== null) {
721
+ const readMtime = occupancyProbes.transcriptMtimeMs
722
+ const lastRow = typeof readMtime === 'function' ? readMtime(provider, threadId) : null
723
+ if (cancelEndsTurn(cancelledAt, typeof lastRow === 'number' ? lastRow : null)) return true
724
+ }
712
725
  // 6.48.1: the engine's own end of turn: a Stop hook newer than the turn's prompt
713
726
  // AND the registry flipped idle after it (the Stop hooks have returned; a prompt
714
727
  // typed during them is queued and dequeues only then). The transcript tail
@@ -732,9 +745,14 @@ if (threadAttachEnabled()) {
732
745
  if (provider !== 'claude' && provider !== 'codex') return false
733
746
  try {
734
747
  const now = Date.now()
748
+ // 6.53.0: evidence older than a cancel on this thread is the cancelled run's, not a
749
+ // live turn (plan R1: a killed child's dangling tool_use read `tool_pending` for the
750
+ // whole 30-minute ceiling and held every parked turn behind it).
751
+ const cancelledAt = threadCancelledAt(provider, threadId)
735
752
  if (provider === 'claude') {
736
753
  const signal = signalFor(threadId)
737
- if (signal && signal.turnOpen && now - signal.lastEventAt <= OPEN_TURN_CEILING_MS) return true
754
+ if (signal && signal.turnOpen && now - signal.lastEventAt <= OPEN_TURN_CEILING_MS
755
+ && !cancelEndsTurn(cancelledAt, signal.lastEventAt)) return true
738
756
  }
739
757
  const path = transcriptPathFor(provider, threadId, nativeHeadDeps)
740
758
  const verdict = transcriptTurnVerdict(provider, path)
@@ -742,6 +760,7 @@ if (threadAttachEnabled()) {
742
760
  if (verdict.reason !== 'tool_pending' && verdict.reason !== 'prompt_open') return false
743
761
  const read = occupancyProbes.transcriptMtimeMs
744
762
  const mtime = typeof read === 'function' ? read(provider, threadId) : null
763
+ if (cancelEndsTurn(cancelledAt, typeof mtime === 'number' ? mtime : null)) return false
745
764
  return typeof mtime === 'number' && now - mtime <= OPEN_TURN_CEILING_MS
746
765
  } catch {
747
766
  return false
@@ -758,6 +777,8 @@ if (threadAttachEnabled()) {
758
777
  }
759
778
  },
760
779
  deliver: (turn: QueuedThreadTurn) => deliverQueuedTurnOverLoopback(turn, PORT, API_TOKEN),
780
+ // 6.53.0: parked turns hold for two minutes after a cancel on their thread.
781
+ cancelHoldUntil: (provider: string, threadId: string) => cancelHoldUntilFor(provider, threadId),
761
782
  now: () => Date.now(),
762
783
  }
763
784
  app.use('/api', createThreadTurnQueueRouter(queueDeps))
@@ -852,6 +873,19 @@ app.use('/api', createAgentSessionBindingsRouter({
852
873
  // empty list — "nothing is bound" and "the store could not be read" must not
853
874
  // look the same.
854
875
  bindings: agentSessionBindingRegistry,
876
+ // 6.53.0: cancel a session run from the lens. The route's own in-flight map answers for
877
+ // a COS turn; these answer for a run at the desk. Read per request, so Install hooks in
878
+ // Control flips `hooks_outdated` to `desk_run` within the status cache's five seconds.
879
+ cancel: {
880
+ deskRunning: sessionId => claudeDeskRunning(sessionId),
881
+ hooksEnabled: () => sessionHooksEnabled(),
882
+ hooksReady: () => cachedHookStatus().installed,
883
+ writeHalt: (sessionId, marker) => writeHaltMarker(sessionId, marker),
884
+ settlePermissions: sessionId => permissionBroker.cancelSession(sessionId),
885
+ queuedWaiting: (provider, threadId) => readQueue(provider, threadId, Date.now()).filter(t => t.status === 'waiting').length,
886
+ noteCancelled: (provider, threadId, at) => noteThreadCancelled(provider, threadId, at),
887
+ ledger: row => appendSessionCancelLedger(row),
888
+ },
855
889
  }))
856
890
  app.use('/api', displayRouter)
857
891
  app.use('/api', transcribeStreamRouter)
@@ -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, but it is not a fence: a fence exists to stop COS resending a
69
+ // turn nobody knows the fate of, and a cancelled turn is one the user chose to end.
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.
@@ -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
  // ---------------------------------------------------------------------------
@@ -342,6 +353,13 @@ export interface AttachedTurnRequest {
342
353
  deps: AttachedTurnDeps
343
354
  /** Wall-clock budget for the provider run. Omitted uses the default. */
344
355
  timeoutMs?: number
356
+ /**
357
+ * 6.53.0: the cancel from the lens. Checked before the spawn and before the prompt is
358
+ * written (a turn that never started is `cancelled` with delivery `not_attempted` or
359
+ * `aborted`); after the write it stops the child's process group, SIGTERM then SIGKILL
360
+ * after CANCEL_KILL_GRACE_MS. A natural exit 0 with our id that lands first still wins.
361
+ */
362
+ abortSignal?: AbortSignal
345
363
  }
346
364
 
347
365
  /**
@@ -358,6 +376,14 @@ export const MAX_ATTACHED_TIMEOUT_MS = 30 * 60_000
358
376
  /** SIGTERM first; this is how long the child gets before SIGKILL. */
359
377
  export const KILL_GRACE_MS = 2_000
360
378
 
379
+ /**
380
+ * The same escalation for a CANCEL, with a longer grace. A timeout is a child that has
381
+ * stopped answering; a cancel lands on a child that is working and may be mid-write, and
382
+ * canary C3 (2026-09-21) saw `claude -p --resume` end cleanly on SIGTERM in about 4 s with
383
+ * a transcript that parses and resumes. Two seconds would SIGKILL it inside that window.
384
+ */
385
+ export const CANCEL_KILL_GRACE_MS = 5_000
386
+
361
387
  /**
362
388
  * Last-resort settle after SIGKILL.
363
389
  *
@@ -778,6 +804,11 @@ async function run(
778
804
  if (bannedArg !== null) {
779
805
  return fail('unsupported_policy', 'not_attempted', { ...base, detail: `banned_arg:${bannedArg}`, durationMs: duration() })
780
806
  }
807
+ // 6.53.0: a cancel that landed before the spawn. No process is created, so nothing can
808
+ // have been delivered; the turn ends `cancelled`, which the route never fences.
809
+ if (request.abortSignal?.aborted === true) {
810
+ return fail('cancelled', 'not_attempted', { ...base, detail: 'before_spawn', durationMs: duration() })
811
+ }
781
812
  let child: AttachedChildProcess
782
813
  try {
783
814
  child = deps.spawn({ binaryPath, args, cwd, env: buildAttachedEnv() })
@@ -830,7 +861,7 @@ async function run(
830
861
  }
831
862
 
832
863
  try {
833
- return await driveChild({ child, pid, deps, provider, nativeThreadId, prompt, timeoutMs, startedAt })
864
+ return await driveChild({ child, pid, deps, provider, nativeThreadId, prompt, timeoutMs, startedAt, abortSignal: request.abortSignal })
834
865
  } finally {
835
866
  // Every path: success, mismatch, non-zero exit, timeout, throw. A leaked
836
867
  // entry lets a recycled pid inherit our self-ownership claim.
@@ -864,10 +895,11 @@ interface DriveInput {
864
895
  prompt: string
865
896
  timeoutMs: number
866
897
  startedAt: number
898
+ abortSignal?: AbortSignal
867
899
  }
868
900
 
869
901
  function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
870
- const { child, deps, provider, nativeThreadId, prompt, timeoutMs, startedAt } = input
902
+ const { child, deps, provider, nativeThreadId, prompt, timeoutMs, startedAt, abortSignal } = input
871
903
 
872
904
  return new Promise<AttachedTurnResult>((resolve) => {
873
905
  /** Distinct ids seen. More than one means a fork happened; see the header. */
@@ -879,7 +911,10 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
879
911
  let exitCode: number | null = null
880
912
  let settled = false
881
913
  let timedOut = false
914
+ /** 6.53.0: the caller's abort fired after the child existed. Read before `timedOut`. */
915
+ let cancelled = false
882
916
  let spawnErrored = false
917
+ let onAbort: (() => void) | null = null
883
918
 
884
919
  let deadline: ReturnType<typeof setTimeout> | null = null
885
920
  let graceTimer: ReturnType<typeof setTimeout> | null = null
@@ -897,6 +932,10 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
897
932
  if (settled) return
898
933
  settled = true
899
934
  clearTimers()
935
+ if (onAbort) {
936
+ try { abortSignal?.removeEventListener('abort', onAbort) } catch { /* nothing to release */ }
937
+ onAbort = null
938
+ }
900
939
  resolve(result)
901
940
  }
902
941
 
@@ -963,6 +1002,13 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
963
1002
  stdoutTail = ''
964
1003
  }
965
1004
 
1005
+ // 6.53.0: a cancel outranks every failure below, the timeout included (a cancel in
1006
+ // the timeout's kill grace is still the user's decision). It does NOT outrank a
1007
+ // clean exit 0 that echoed our exact id: that turn finished before the stop landed,
1008
+ // and reporting it cancelled would hide a delivered turn.
1009
+ if (cancelled && !(exitCode === 0 && delivery === 'ambiguous' && observedIds.length === 1 && observedIds[0] === nativeThreadId)) {
1010
+ return settleFailure('cancelled', { delivery: 'cancelled' })
1011
+ }
966
1012
  if (timedOut) return settleFailure('timeout')
967
1013
  if (spawnErrored) return settleFailure('spawn_failed', { detail: 'child_error' })
968
1014
 
@@ -1054,12 +1100,41 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1054
1100
  forceTimer = setTimeout(() => {
1055
1101
  // A child that survived SIGKILL cannot be reached from here, and
1056
1102
  // blocking forever would wedge the coordinator and any Control drain
1057
- // behind it.
1058
- settleFailure('timeout', { detail: 'unreaped', reaped: false })
1103
+ // behind it. A cancel that landed during this escalation still reads
1104
+ // as the user's (6.53.0).
1105
+ if (cancelled) settleFailure('cancelled', { delivery: 'cancelled', detail: 'unreaped', reaped: false })
1106
+ else settleFailure('timeout', { detail: 'unreaped', reaped: false })
1059
1107
  }, FORCE_SETTLE_MS)
1060
1108
  }, KILL_GRACE_MS)
1061
1109
  }, timeoutMs)
1062
1110
 
1111
+ // --- the cancel (6.53.0) ---------------------------------------------------
1112
+ // Registered before the write so an abort from here on is seen. The group SIGTERM then
1113
+ // SIGKILL is the timeout's own sequence with a longer grace; the force settle keeps a
1114
+ // child that survives SIGKILL from wedging the route's claim.
1115
+ if (abortSignal) {
1116
+ onAbort = () => {
1117
+ if (settled || cancelled) return
1118
+ cancelled = true
1119
+ // A timeout already escalating keeps its own timers; the result still reads
1120
+ // cancelled (finishTerminal checks `cancelled` first).
1121
+ if (timedOut) return
1122
+ if (deadline) { clearTimeout(deadline); deadline = null }
1123
+ safeTerminate(deps, child, 'SIGTERM')
1124
+ graceTimer = setTimeout(() => {
1125
+ safeTerminate(deps, child, 'SIGKILL')
1126
+ forceTimer = setTimeout(() => {
1127
+ settleFailure('cancelled', { delivery: 'cancelled', detail: 'unreaped', reaped: false })
1128
+ }, FORCE_SETTLE_MS)
1129
+ }, CANCEL_KILL_GRACE_MS)
1130
+ }
1131
+ try {
1132
+ abortSignal.addEventListener('abort', onAbort, { once: true })
1133
+ } catch {
1134
+ onAbort = null
1135
+ }
1136
+ }
1137
+
1063
1138
  // --- deliver the prompt --------------------------------------------------
1064
1139
  const stdin = child.stdin
1065
1140
  if (!stdin || typeof stdin.write !== 'function' || typeof stdin.end !== 'function' || !child.stdout) {
@@ -1078,6 +1153,15 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1078
1153
  /* an stdin that cannot take a listener still gets the write attempt below */
1079
1154
  }
1080
1155
 
1156
+ // 6.53.0: a cancel that landed between the spawn check and here (a dependency that
1157
+ // yielded, or one that aborted synchronously). No prompt byte is written: the child is
1158
+ // killed and the turn is provably undelivered.
1159
+ if (abortSignal?.aborted === true) {
1160
+ safeTerminate(deps, child, 'SIGKILL')
1161
+ delivery = 'aborted'
1162
+ return settleFailure('cancelled', { detail: 'before_write', reaped: false })
1163
+ }
1164
+
1081
1165
  try {
1082
1166
  // THE BOUNDARY. From this call on, we cannot prove the provider did not
1083
1167
  // act on the prompt, so every non-success outcome is ambiguous.
@@ -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 },
@@ -16,6 +16,20 @@
16
16
  export const CLIENT_INSTANCE_TICK_MS = 15_000
17
17
  /** Three missed ticks: the owner is gone. */
18
18
  export const CLIENT_INSTANCE_LIVE_MS = 3 * CLIENT_INSTANCE_TICK_MS
19
+ /**
20
+ * 6.52.3: how long the owner must be quiet before an OLDER boot may take the ring back.
21
+ * On 2026-09-20 a hidden copy from the night before took the ring after the live copy
22
+ * went quiet (a locked phone throttles its timers) and answered a hold-to-talk 27 minutes
23
+ * later with a stale session. 45 s is a normal quiet spell for a locked phone; a dead
24
+ * copy stays dead, so waiting ten minutes costs nothing.
25
+ */
26
+ export const CLIENT_INSTANCE_RECLAIM_MS = 10 * 60_000
27
+ /**
28
+ * 6.52.3: an older boot may take the ring back only when ITS OWN previous claim is this
29
+ * recent. Both copies sleep when the phone does; a zombie that woke up alongside the live
30
+ * copy has a gap of its own, and the live copy's refresh lands within a tick.
31
+ */
32
+ export const CLIENT_INSTANCE_AWAKE_MS = CLIENT_INSTANCE_LIVE_MS
19
33
  /**
20
34
  * 6.50.4: a meeting chunk inside this window means that copy is recording. Five chunk
21
35
  * intervals (~6 s each), so a stretch of silence or a slow upload does not end the hold.
@@ -126,6 +140,8 @@ export function arbitrateClientInstance(
126
140
  claim: ClientInstanceClaim,
127
141
  now: number,
128
142
  evidence: CaptureEvidence = NO_CAPTURE,
143
+ /** When this claimant last claimed (any verdict), or undefined: never seen. */
144
+ claimantLastSeenAt?: number,
129
145
  ): { owner: ClientInstanceOwner; verdict: ClientInstanceVerdict; took: boolean } {
130
146
  const fresh: ClientInstanceOwner = { ...claim, seenAt: now }
131
147
  if (!owner) return { owner: fresh, verdict: 'owner', took: true }
@@ -142,6 +158,11 @@ export function arbitrateClientInstance(
142
158
  // The ring moves on the newer copy's first claim after the meeting stops.
143
159
  if (ownerIsRecording(owner, evidence, now)) return { owner, verdict: 'yield', took: false }
144
160
  if (isNewer(claim, owner)) return { owner: fresh, verdict: 'owner', took: true }
145
- if (now - owner.seenAt > CLIENT_INSTANCE_LIVE_MS) return { owner: fresh, verdict: 'owner', took: true }
161
+ // 6.52.3: an older boot takes a quiet ring back only after CLIENT_INSTANCE_RECLAIM_MS,
162
+ // and only while it has itself been claiming (awake), never on the first tick after a
163
+ // gap of its own. A dead newer copy is replaced when the user reopens COS (a newer boot
164
+ // still takes at once); this rule is for the copy that is merely quiet.
165
+ const claimantAwake = claimantLastSeenAt !== undefined && now - claimantLastSeenAt <= CLIENT_INSTANCE_AWAKE_MS
166
+ if (now - owner.seenAt > CLIENT_INSTANCE_RECLAIM_MS && claimantAwake) return { owner: fresh, verdict: 'owner', took: true }
146
167
  return { owner, verdict: 'yield', took: false }
147
168
  }
@@ -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,