@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 +42 -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 +91 -7
- package/server/lib/claude-hooks-installer.ts +10 -1
- package/server/lib/client-instance-claim.ts +22 -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/session-stream-events.ts +32 -4
- package/server/lib/thread-turn-queue.ts +9 -0
- package/server/routes/agent-session-bindings.ts +322 -6
- package/server/routes/client-instance.ts +12 -1
- package/server/routes/health.ts +10 -1
- package/server/routes/thread-turn-queue.ts +16 -0
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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 `
|
|
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,
|