@gotcos/glasses-server 6.52.3 → 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 +23 -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/cursor-model-catalog.ts +45 -12
- package/server/lib/permission-broker.ts +48 -0
- package/server/lib/session-cancel.ts +190 -0
- package/server/lib/session-halt.ts +156 -0
- package/server/lib/session-hooks-runtime.ts +45 -2
- package/server/lib/thread-turn-queue.ts +9 -0
- package/server/routes/agent-session-bindings.ts +322 -6
- package/server/routes/health.ts +6 -1
- package/server/routes/thread-turn-queue.ts +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
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
|
+
|
|
1
24
|
## 6.52.3
|
|
2
25
|
|
|
3
26
|
### A quiet ring stays with its owner; a zombie never takes it on waking
|
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 },
|
|
@@ -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,
|
|
@@ -305,6 +305,18 @@ export function questionHookOutput(toolInput: Record<string, unknown>, answers:
|
|
|
305
305
|
return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'allow', updatedInput: { ...toolInput, answers } } } }
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
+
/** 6.53.0: the message a held prompt gets when its session's run is cancelled from COS. */
|
|
309
|
+
export const CANCEL_DENY_MESSAGE = 'Cancelled from COS'
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* 6.53.0: a held prompt whose run was cancelled from the lens. Deny with `interrupt: true`,
|
|
313
|
+
* so Claude ends the RUN rather than trying something else in place of this tool. The one
|
|
314
|
+
* case where a broker reply interrupts, and only ever from `cancelSession`.
|
|
315
|
+
*/
|
|
316
|
+
export function cancelHookOutput(): Record<string, unknown> {
|
|
317
|
+
return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'deny', message: CANCEL_DENY_MESSAGE, interrupt: true } } }
|
|
318
|
+
}
|
|
319
|
+
|
|
308
320
|
/** A tool approval: allow once, or deny with the one message. Never a rule. */
|
|
309
321
|
export function approvalHookOutput(decision: 'allow' | 'deny'): Record<string, unknown> {
|
|
310
322
|
const body = decision === 'allow' ? { behavior: 'allow' } : { behavior: 'deny', message: DENY_MESSAGE }
|
|
@@ -933,6 +945,42 @@ export class PermissionBroker {
|
|
|
933
945
|
return { status: 200, body: { ok: true, id: item.id, kind: item.kind, ...chosen } }
|
|
934
946
|
}
|
|
935
947
|
|
|
948
|
+
/**
|
|
949
|
+
* 6.53.0: a desk run of this session was cancelled from COS. Every prompt the broker holds
|
|
950
|
+
* for it is answered deny with `interrupt: true` (the away-from-desk case: without this the
|
|
951
|
+
* run waits out the deadline and then shows the Mac's dialog, and a cancel "at the next
|
|
952
|
+
* tool call" never arrives, because the next tool is the one being asked about). Recorded
|
|
953
|
+
* as an answer, so a lens answering the same card afterwards reads 409 already_answered.
|
|
954
|
+
* A hook that already left is `hook_gone`, as for any answer. Returns how many it denied.
|
|
955
|
+
*/
|
|
956
|
+
cancelSession(sessionId: string): number {
|
|
957
|
+
const wanted = typeof sessionId === 'string' ? sessionId.toLowerCase() : ''
|
|
958
|
+
if (!wanted) return 0
|
|
959
|
+
let denied = 0
|
|
960
|
+
for (const item of [...this.items.values()]) {
|
|
961
|
+
if (item.state !== 'pending' || item.sessionId !== wanted) continue
|
|
962
|
+
if (!item.channel || !item.channel.writable() || !item.channel.reply(cancelHookOutput())) {
|
|
963
|
+
this.settle(item, 'hook_gone')
|
|
964
|
+
continue
|
|
965
|
+
}
|
|
966
|
+
item.state = 'answered'
|
|
967
|
+
item.settledAt = this.deps.now()
|
|
968
|
+
item.answer = { clientAnswerId: 'cos-cancel', chosen: { decision: 'deny' } }
|
|
969
|
+
item.channel = null
|
|
970
|
+
item.toolInput = {}
|
|
971
|
+
item.questions = null
|
|
972
|
+
item.approval = null
|
|
973
|
+
if (item.timer) clearTimeout(item.timer)
|
|
974
|
+
item.timer = null
|
|
975
|
+
this.counters.answered++
|
|
976
|
+
try { this.deps.signals?.decided(item.sessionId, item.id, item.fingerprint) } catch { /* rows are advisory */ }
|
|
977
|
+
this.log(`cancelled ${item.id} kind=${item.kind} pending=${this.pendingCount()}`)
|
|
978
|
+
denied++
|
|
979
|
+
}
|
|
980
|
+
this.prune()
|
|
981
|
+
return denied
|
|
982
|
+
}
|
|
983
|
+
|
|
936
984
|
/**
|
|
937
985
|
* "Leave for the Mac" on a pending item. As for an answer, the hook must still be listening:
|
|
938
986
|
* one that already left is `hook_gone` (the Mac shows its dialog either way).
|