@gotcos/glasses-server 6.52.3 → 6.53.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,36 @@
1
+ ## 6.53.1
2
+
3
+ ### Cancel the whole Codex process tree
4
+
5
+ The 6.53.0 live Codex canary found that `codex exec resume` can launch a tool in its own process group. Cancelling the wrapper group stopped the COS turn, but an in-flight tool could be re-parented to launchd and continue running. This hotfix snapshots verified descendant identities before signalling the wrapper, reaches separate descendant groups, retains PID/start-time identities across re-parenting, and escalates those retained processes to SIGKILL after the five-second cancel grace. A wrapper `close` no longer clears the escalation while any verified tree member remains. Probe failure fails closed, and PID reuse is never signalled.
6
+
7
+ A spawned cancellation is now reported as ordinary `turn_cancelled` only after the adapter confirms the entire tree was reaped. An unverified or surviving tree takes the existing ambiguous-delivery fence path. A pre-spawn cancellation remains provably process-free; a spawned pre-write cancellation still requires reaping proof even though no prompt byte was sent.
8
+
9
+ - Tests: a real detached Node wrapper launches a SIGTERM-resistant tool in a second process group; the wrapper dies, the retained tool survives TERM, SIGKILL reaches it after re-parenting, and the tree is confirmed gone. Fault-injected process-table and start-time probes stay uncertain instead of inventing death. Adapter coverage proves wrapper close cannot settle early; route coverage proves both post-write and pre-write unreaped cancellations fence. The mutation gate removes each of those protections independently.
10
+
11
+ ## 6.53.0
12
+
13
+ ### Cancel a session run from the glasses
14
+
15
+ 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.
16
+
17
+ - **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".
18
+ - **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`.
19
+ - **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.
20
+ - **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).
21
+ - **`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`.
22
+ - **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.
23
+ - **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.
24
+ - **Health.** `features.sessionCancel`: `{"cosTurn": true, "deskClaude": <hooks on and the 6.53 hook installed>}`.
25
+ - **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.
26
+ - 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).
27
+
28
+ ### Grok 4.7
29
+
30
+ - **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.
31
+ - **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.
32
+ - 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.
33
+
1
34
  ## 6.52.3
2
35
 
3
36
  ### 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
- const grokHighFast = /cursor-grok-\d+(?:\.\d+)*-high-fast/.test(models)
304
+ // Cursor 2026.09 introduced `grok-4.7-high-fast` without the old
305
+ // `cursor-` prefix. Accept both exact high-fast families, like runtime does.
306
+ const grokHighFast = /(?:^|\n)(?:cursor-)?grok-\d+(?:\.\d+)*-high-fast\s+-/m.test(models)
305
307
  return {
306
308
  binary,
307
309
  version,
@@ -358,7 +360,7 @@ if (cursor.binary) {
358
360
  console.log(' Run: ' + bold('agent login'))
359
361
  } else if (cursor.auth === 'models-unresolved') {
360
362
  console.log(yellow(' ⚠') + ` Cursor Agent ${cursor.version} installed — required models unresolved`)
361
- console.log(' Verify: ' + bold('agent models') + ' includes Composer 2.5 Fast and a cursor-grok-*-high-fast id')
363
+ console.log(' Verify: ' + bold('agent models') + ' includes Composer 2.5 Fast and a grok-*-high-fast id')
362
364
  } else {
363
365
  console.log(yellow(' ⚠') + ` Cursor Agent ${cursor.version} installed — readiness unavailable`)
364
366
  console.log(' Verify: ' + bold('agent models'))
@@ -1,17 +1,66 @@
1
1
  #!/bin/sh
2
- # COS session hook (glasses-server 6.48.0; Cursor Stop follow-up 6.51.0). Installed into ~/.claude/settings.json by
2
+ # COS session hook (glasses-server 6.48.0; Cursor Stop follow-up 6.51.0; halt check 6.53.0). Installed into ~/.claude/settings.json by
3
3
  # `--hooks install`; Claude Code runs it with the hook event name as $1 and the event
4
4
  # payload on stdin. It spools one JSON file per event into the hook spool and exits 0
5
5
  # on every path. Only PermissionRequest ever talks to the server, and only when the
6
6
  # desk has been idle. Everything else is a file write, so the server being down or
7
7
  # restarting never delays a Claude session.
8
8
  #
9
+ # 6.53.0: PreToolUse is SYNCHRONOUS with no matcher, so the first branch below runs before
10
+ # EVERY tool call in every Claude session on this Mac. It is file-only (no curl, no spool
11
+ # write) and prints nothing unless the server left a halt marker for this session, when it
12
+ # prints the one reply that stops the run (canary C1b, 2026-09-21: `continue:false` alone
13
+ # still ran the tool; with the deny it did not). It never exits 2: exit 2 blocks the tool,
14
+ # while a timeout or any other code fails open (C6, C7).
15
+ #
9
16
  # The installer bakes COS_GLASSES_HOME and COS_HOOK_SPOOL into the command, so this
10
17
  # script and the server can never disagree about where the token or the spool is.
11
18
  PATH=/usr/bin:/bin:/usr/sbin; umask 077; trap '' PIPE
12
19
  HOME=${HOME:-$(cd ~ 2>/dev/null && pwd)}; [ -n "$HOME" ] || exit 0
13
20
  GH=${COS_GLASSES_HOME:-$HOME/.cos-glasses}
14
21
  SPOOL=${COS_HOOK_SPOOL:-$GH/data/hook-spool}; EVENT="$1"
22
+ # 6.53.0, the halt check. FIRST, before mkdir, perl and the B8 guard below: a full spool or
23
+ # a stale drain stamp must never stop a cancel, and an ordinary tool call must cost nothing
24
+ # but this. The marker folder sits beside the spool, like the Cursor queue folder; the server
25
+ # writes `<folder>/<session id>` (lib/session-halt.ts). stdin is read ONCE, to a temp file,
26
+ # and handed to the spool path below when the event still needs spooling.
27
+ IN=''
28
+ case "$EVENT" in PreToolUse|UserPromptSubmit)
29
+ IN=$(mktemp "${TMPDIR:-/tmp}/cos-hook.XXXXXX" 2>/dev/null || mktemp /tmp/cos-hook.XXXXXX 2>/dev/null) || IN=''
30
+ # No temp file: a tool call goes ahead unchecked (fail open), and a prompt is spooled
31
+ # below exactly as before, its stdin untouched.
32
+ [ -n "$IN" ] || [ "$EVENT" = UserPromptSubmit ] || exit 0
33
+ ;;
34
+ esac
35
+ [ -z "$IN" ] || {
36
+ HALT="$(dirname "$SPOOL")/session-halt"
37
+ trap 'rm -f "$IN"; exit 0' HUP INT TERM
38
+ head -c 1048576 > "$IN" 2>/dev/null
39
+ # Any marker at all? A glob is a builtin, so the common case (no cancel anywhere on the
40
+ # Mac) reads no session id and forks nothing for it.
41
+ SID=''; for F in "$HALT"/*; do [ -e "$F" ] && SID=x; break; done
42
+ # The FIRST session_id is the payload's own: Claude writes it first, and a tool's input
43
+ # that happens to carry one comes later.
44
+ [ -z "$SID" ] || SID=$(grep -Eo '"session_id"[[:space:]]*:[[:space:]]*"[0-9A-Fa-f-]{36}"' "$IN" 2>/dev/null | head -1 | grep -Eo '[0-9A-Fa-f-]{36}' | tr 'A-F' 'a-f')
45
+ if [ "$EVENT" = UserPromptSubmit ]; then
46
+ # A new prompt is never stopped: the marker lives only within the run it was written
47
+ # for. This covers a prompt typed at the desk, a Continue delivered into the open
48
+ # session, and a COS `-p` turn. Then the prompt is spooled exactly as before.
49
+ [ -z "$SID" ] || rm -f "$HALT/$SID" 2>/dev/null
50
+ elif [ -n "$SID" ] && [ -f "$HALT/$SID" ]; then
51
+ rm -f "$IN"
52
+ printf '%s' '{"continue":false,"stopReason":"Cancelled from COS","hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Cancelled from COS"}}'
53
+ exit 0
54
+ else
55
+ # Only the two tools the reducer reads were ever spooled (the 6.48 matcher); every
56
+ # other tool call ends here with nothing written and nothing printed.
57
+ TOOL=$(grep -Eo '"tool_name"[[:space:]]*:[[:space:]]*"[A-Za-z]+"' "$IN" 2>/dev/null | head -1)
58
+ case "$TOOL" in *'"AskUserQuestion"'|*'"ExitPlanMode"') ;; *) rm -f "$IN"; exit 0 ;; esac
59
+ fi
60
+ # The spool path below reads stdin: hand it the saved bytes. The open descriptor keeps
61
+ # them readable after the unlink, so no temp file outlives this line.
62
+ exec < "$IN"; rm -f "$IN"; trap - HUP INT TERM
63
+ }
15
64
  mkdir -p "$SPOOL/rejected" 2>/dev/null || exit 0
16
65
  TS=$(perl -MTime::HiRes -e 'printf "%.0f", Time::HiRes::time()*1000' 2>/dev/null || date +%s000)
17
66
  # B8 guard: a full spool or a stale drain stamp means nobody is reading; keep only permissions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.52.3",
3
+ "version": "6.53.1",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -24,7 +24,9 @@ import { agentSessionStreamRouter } from './routes/agent-session-stream.js'
24
24
  import { createAttachedTurnStream } from './lib/session-stream-producer.js'
25
25
  import { claudeSessionsRouter } from './routes/claude-sessions.js'
26
26
  import { createSessionHooksRouter } from './routes/session-hooks.js'
27
- import { deskIdleSeconds, registerDrainKickStats, registryIdleAfterStop, sessionHooksEnabled, sessionSignalStore, signalFor, startSessionHooksRuntime } from './lib/session-hooks-runtime.js'
27
+ import { cachedHookStatus, claudeDeskRunning, deskIdleSeconds, registerDrainKickStats, registryIdleAfterStop, sessionHooksEnabled, sessionSignalStore, signalFor, startSessionHooksRuntime } from './lib/session-hooks-runtime.js'
28
+ import { writeHaltMarker } from './lib/session-halt.js'
29
+ import { appendSessionCancelLedger, cancelEndsTurn, cancelHoldUntil as cancelHoldUntilFor, noteThreadCancelled, threadCancelledAt } from './lib/session-cancel.js'
28
30
  import {
29
31
  PermissionBroker,
30
32
  brokerSignalSink,
@@ -495,6 +497,8 @@ const deliverAttachedTurnForRoute = async (request: {
495
497
  nativeThreadId: string
496
498
  prompt: string
497
499
  onSpawn: (pid: number) => boolean
500
+ /** 6.53.0: the cancel from the lens, passed straight through to the adapter. */
501
+ abortSignal?: AbortSignal
498
502
  }): Promise<unknown> => {
499
503
  // Resolved here, not stored on the binding: the cwd is read from the transcript,
500
504
  // which records it verbatim. Decoding the project slug is lossy — this Mac's own
@@ -537,6 +541,7 @@ const deliverAttachedTurnForRoute = async (request: {
537
541
  // The only policy this build accepts. The adapter refuses anything else and
538
542
  // asserts no bypass/always-approve flag reaches the argv (plan 4.7).
539
543
  policy: 'read_only',
544
+ abortSignal: request.abortSignal,
540
545
  deps: {
541
546
  ...base,
542
547
  // `startMs` is deliberately unused: the adapter already probed it as a GATE
@@ -709,6 +714,14 @@ if (threadAttachEnabled()) {
709
714
  turnEnded: (provider: string, threadId: string) => {
710
715
  if (provider !== 'claude' && provider !== 'codex') return false
711
716
  try {
717
+ // 6.53.0: a cancel with nothing written since it ENDED the turn. A killed COS child
718
+ // leaves its last tool_use unanswered, and no Stop or terminal record ever follows.
719
+ const cancelledAt = threadCancelledAt(provider, threadId)
720
+ if (cancelledAt !== null) {
721
+ const readMtime = occupancyProbes.transcriptMtimeMs
722
+ const lastRow = typeof readMtime === 'function' ? readMtime(provider, threadId) : null
723
+ if (cancelEndsTurn(cancelledAt, typeof lastRow === 'number' ? lastRow : null)) return true
724
+ }
712
725
  // 6.48.1: the engine's own end of turn: a Stop hook newer than the turn's prompt
713
726
  // AND the registry flipped idle after it (the Stop hooks have returned; a prompt
714
727
  // typed during them is queued and dequeues only then). The transcript tail
@@ -732,9 +745,14 @@ if (threadAttachEnabled()) {
732
745
  if (provider !== 'claude' && provider !== 'codex') return false
733
746
  try {
734
747
  const now = Date.now()
748
+ // 6.53.0: evidence older than a cancel on this thread is the cancelled run's, not a
749
+ // live turn (plan R1: a killed child's dangling tool_use read `tool_pending` for the
750
+ // whole 30-minute ceiling and held every parked turn behind it).
751
+ const cancelledAt = threadCancelledAt(provider, threadId)
735
752
  if (provider === 'claude') {
736
753
  const signal = signalFor(threadId)
737
- if (signal && signal.turnOpen && now - signal.lastEventAt <= OPEN_TURN_CEILING_MS) return true
754
+ if (signal && signal.turnOpen && now - signal.lastEventAt <= OPEN_TURN_CEILING_MS
755
+ && !cancelEndsTurn(cancelledAt, signal.lastEventAt)) return true
738
756
  }
739
757
  const path = transcriptPathFor(provider, threadId, nativeHeadDeps)
740
758
  const verdict = transcriptTurnVerdict(provider, path)
@@ -742,6 +760,7 @@ if (threadAttachEnabled()) {
742
760
  if (verdict.reason !== 'tool_pending' && verdict.reason !== 'prompt_open') return false
743
761
  const read = occupancyProbes.transcriptMtimeMs
744
762
  const mtime = typeof read === 'function' ? read(provider, threadId) : null
763
+ if (cancelEndsTurn(cancelledAt, typeof mtime === 'number' ? mtime : null)) return false
745
764
  return typeof mtime === 'number' && now - mtime <= OPEN_TURN_CEILING_MS
746
765
  } catch {
747
766
  return false
@@ -758,6 +777,8 @@ if (threadAttachEnabled()) {
758
777
  }
759
778
  },
760
779
  deliver: (turn: QueuedThreadTurn) => deliverQueuedTurnOverLoopback(turn, PORT, API_TOKEN),
780
+ // 6.53.0: parked turns hold for two minutes after a cancel on their thread.
781
+ cancelHoldUntil: (provider: string, threadId: string) => cancelHoldUntilFor(provider, threadId),
761
782
  now: () => Date.now(),
762
783
  }
763
784
  app.use('/api', createThreadTurnQueueRouter(queueDeps))
@@ -852,6 +873,19 @@ app.use('/api', createAgentSessionBindingsRouter({
852
873
  // empty list — "nothing is bound" and "the store could not be read" must not
853
874
  // look the same.
854
875
  bindings: agentSessionBindingRegistry,
876
+ // 6.53.0: cancel a session run from the lens. The route's own in-flight map answers for
877
+ // a COS turn; these answer for a run at the desk. Read per request, so Install hooks in
878
+ // Control flips `hooks_outdated` to `desk_run` within the status cache's five seconds.
879
+ cancel: {
880
+ deskRunning: sessionId => claudeDeskRunning(sessionId),
881
+ hooksEnabled: () => sessionHooksEnabled(),
882
+ hooksReady: () => cachedHookStatus().installed,
883
+ writeHalt: (sessionId, marker) => writeHaltMarker(sessionId, marker),
884
+ settlePermissions: sessionId => permissionBroker.cancelSession(sessionId),
885
+ queuedWaiting: (provider, threadId) => readQueue(provider, threadId, Date.now()).filter(t => t.status === 'waiting').length,
886
+ noteCancelled: (provider, threadId, at) => noteThreadCancelled(provider, threadId, at),
887
+ ledger: row => appendSessionCancelLedger(row),
888
+ },
855
889
  }))
856
890
  app.use('/api', displayRouter)
857
891
  app.use('/api', transcribeStreamRouter)