@gotcos/glasses-server 6.53.1 → 6.53.4

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,41 @@
1
+ ## 6.53.4
2
+
3
+ ### The fixes from 6.53.3's review
4
+
5
+ 6.53.3 was published before its four-reviewer /qa round finished (the round was cut short by a usage limit and rerun the next morning). This release carries that round's server fixes, and corrects three claims 6.53.3 made. Install hooks once after updating.
6
+
7
+ - **A `ps` that fails never releases a fence (blocker in 6.53.3).** 6.53.3's fence auto-release read every `execFileSync` error as "not running", so a `ps` killed at its 2 s timeout, an `EAGAIN` fork failure or a missing binary could release a cancel fence whose child might still be writing. Only the measured "no such process" answer (exit 1, no signal, not killed, no error code, nothing on stdout or stderr) counts as gone now; everything else, and a clean exit that printed nothing, is unverifiable and keeps the fence. `ps` is `/bin/ps` when present. A fence that reads unknown is re-checked on every fence check and can still be released by hand in COS Control.
8
+ - **The halt re-arm matches the delivered prompt exactly.** 6.53.3 matched the first 120 characters with `includes`, so a short Continue ("yes") matched the person's own next desk prompt: their run was stopped "Cancelled from COS" and the cancelled COS turn then ran. Now the words must be equal (whitespace collapsed, Claude Code's peer-message wrapper taken off), and the first prompt after the hand-off decides: a different prompt spends the re-arm and is logged. Stated limit: if the person's own prompt reaches the session first, the queued COS turn is not stopped; an identical desk prompt typed after the send and taken first still re-arms. An unverified hand-off holds its re-arm 2 minutes, not an hour.
9
+ - **The hook drains stdin on every event.** PermissionRequest, Stop, PostToolUse, SessionStart, SessionEnd and the other non-front events read only 1 MiB and left the rest unread, and the spool guard exited before reading; a larger payload could EPIPE the writer. The header comment no longer says the PreToolUse branch writes no spool. Script sha256 `df54677f...`; the 6.53.0 script (`1158bb06...`) and 6.53.3's (`c0b41bf8...`) still stop desk runs until Install hooks is run. The PreToolUse common path is unchanged (40 runs each, interleaved: 11.2 and 10.9 ms before, 11.4 and 11.0 ms after).
10
+ - **The mutation gate needs a failing test.** A kill was any non-zero vitest exit; now a mutant is killed only when a named test fails, and a run that errors without one fails the gate. Every 6.53.x case was re-run under that rule on a green baseline: 137 of 137 killed by a failing test (16 added in this round), none errored.
11
+ - **Every server suite removes its temp folders.** 39 test files made `mkdtemp` folders and never removed them (one run left 581 entries in `$TMPDIR`; now none made by a test). 6.53.3's "hook and installer suites clean up their temp roots" was true only for three suites.
12
+ - **Corrections to 6.53.3's notes.** "`reaped: true` is never reported while an owned process survives" holds for cancel and timeout only: a natural exit settles as soon as the CLI is reaped, with no tree probe. The graceful end-to-end bound is now derived from the fixture's stop delay and `TREE_POLL_MS` (2.4 s, was 4 s). The closed-session test imports its retention constant. README gains a "Cancel a run from the glasses (6.53.x)" section.
13
+ - Tests: full suite 5,128 passed, 2 skipped; typecheck clean; `npm audit --omit=dev` 0 vulnerabilities.
14
+
15
+ ## 6.53.3
16
+
17
+ ### Cancel owns the whole process tree, desk cancels hold, and the hook is faster
18
+
19
+ Built from a four-reviewer regression review of 6.53.0 to 6.53.2 and a real-process harness. 6.53.2 fixed the 6.53.1 false fence (40 of 40 runs), but the harness found it could report a cancelled turn as reaped while a detached background job survived (3 of 3), and that it regressed a case 6.52.3 handled: when the provider exited but a same-group child still held its output open, cancel and timeout never signalled that child (2 of 2 survivors, 0 on 6.52.3). 6.53.2 was never published; this release supersedes it.
20
+
21
+ - **Every process a turn started is stopped.** The root process group is signalled while Node has not reaped the leader, including when the first process-table read fails. Every process group a tree member sits in is remembered with its leader's start time, so members re-parented to launchd are still owned, signalled and counted. `reaped: true` is never reported while an owned process survives. A failed or malformed read still latches doubt and fences.
22
+ - **Cancel settles as soon as the tree is gone.** Liveness is read asynchronously every 200 ms after `close` and settles on the first read that shows nothing of ours, so a tool that exits one second after SIGTERM settles in about 1.2 s instead of 5.3 s. Once doubt is latched, no more process-table reads run (6.53.2 ran about 29 blocking reads in 2 s).
23
+ - **A cancel before the prompt write ends cleanly.** The tree is killed and reaped, and the turn settles `turn_cancelled` with no fence. A cancel before spawn reports `reaped: true`.
24
+ - **A desk cancel cannot be undone by a parked turn.** A cancel records its target. Only a `cos_turn` cancel lets silence end a turn; a `desk_run` cancel keeps the open-turn evidence until the desk itself ends the run (Stop, StopFailure, SessionEnd, or an idle flip newer than the cancel). The same rule removes the phantom desk Cancel run after a killed `claude -p`.
25
+ - **Cancel during the live hand-off works.** It is latched and answered 202 `stopping` instead of 409 `cancel_failed`. If nothing reached the session the turn settles `turn_cancelled`; if it reached, the desk halt marker is written (and re-armed once on the delivered prompt's own UserPromptSubmit). Codex answers `cancel_unsupported`. Replaying the same `clientCancelId` returns the final outcome.
26
+ - **Fences left by a cancel release themselves.** A fence is released and logged only when its reason is `cancelled`, it was set at the ambiguous-delivery site, and every recorded child is confirmed gone. This clears threads locked by 6.53.1's false "may not have been delivered".
27
+ - **Closed meetings age out.** A closed-session record past its 4-hour retention is evicted, so status reads `missing` and the phone stops asking to save it. Saving a known session with no transcript answers 404 `nothing_to_save` (with its state); only an unknown id still answers `session_not_found`.
28
+ - **The session hook is about 40% faster and never breaks a large tool input.** The halt check, tool-name check and session-id parse run in shell on the first 4 KiB (read with `dd`: macOS `head -c` over-reads a pipe), and the rest of stdin is drained on every path, so a 2.5 MB Write no longer hits EPIPE. There is no temp file, so a new prompt always clears its session's marker. Median per tool call: about 29 ms to 17 ms quiet, 35 to 42 ms to about 23 ms under load. `--hooks status` adds an `advice` line; installs read `script_outdated` until Install hooks is run once.
29
+ - Tests: a real-process end-to-end suite (churn, idle, stubborn, graceful, orphan-stdout three ways, group-orphan, setsid escape; macOS) fails 5 of 9 on 6.53.2 and passes here; queue-turn evidence is tested against the real queue; hook tests run the real script (EPIPE, marker-clear and drain cases fail on the old script). Mutation gate: 74 new cases plus retargeted 6.53.0 cases, all killed on a green baseline. Hook and installer suites clean up their temp roots.
30
+
31
+ ## 6.53.2
32
+
33
+ ### Cancel verification no longer races a dying Codex tree
34
+
35
+ The installed 6.53.1 live canary successfully stopped the Codex wrapper and its detached tool, but then falsely fenced the session as `unreaped`. The verifier captured ancestry in one process-table snapshot and queried each process start time in later subprocesses. A short-lived descendant could disappear between those reads, permanently converting a successful cancellation into doubt.
36
+
37
+ Process identity and ancestry now come from one stable `ps` snapshot (`pid`, `ppid`, `pgid`, and `lstart`) under a fixed UTC/C locale. There are no per-PID identity probes in the cancellation walk, so ordinary process death cannot poison reaping proof. A failed snapshot or any malformed identity still fails closed. Real detached-process tests cover the disappearing-PID race, unreadable whole snapshots, malformed atomic identities, re-parenting, and SIGKILL escalation; three new mutation cases independently remove the atomic capture, restore the racy per-PID probe, and ignore malformed identity data.
38
+
1
39
  ## 6.53.1
2
40
 
3
41
  ### Cancel the whole Codex process tree
package/README.md CHANGED
@@ -265,6 +265,41 @@ Allow once or deny only: no permission rule is ever written. Rows carry
265
265
  row reads `waiting_kind: question` whatever the switch says; `/api/health` reports
266
266
  `permissionBroker` (counters and timestamps, no ids or text).
267
267
 
268
+ ## Cancel a run from the glasses (6.53.x)
269
+
270
+ The glasses' Cancel run row stops what a session is doing, the way the desk's own stop
271
+ button does. It needs **server 6.53.0 or later and COS Glasses 6.9.529 or later**;
272
+ **6.53.4 is recommended** (it owns the whole process tree, holds a desk cancel, fixes
273
+ the false fences 6.53.1 left, and never releases a fence when `ps` cannot answer).
274
+
275
+ - **A turn COS started** (`cos_turn`) is stopped by the server: its whole process tree is
276
+ signalled and the turn settles `turn_cancelled`.
277
+ - **A run at the desk** (`desk_run`: a Desktop tab or a terminal `claude`) is stopped by the
278
+ session hook at its next tool call, so a reply that is pure text finishes first. This
279
+ needs the hooks installed, and after every server update that changes the hook script
280
+ (6.53.0, 6.53.3, 6.53.4) you must **run Install hooks once** (COS Control, or
281
+ `npx --yes @gotcos/glasses-server@latest --hooks install`). Until then
282
+ `--hooks status` reads `script_outdated` and its `advice` line says so; a script from
283
+ 6.53.0 to 6.53.3 can still stop desk runs meanwhile, anything older answers
284
+ `hooks_outdated`. The PreToolUse hook runs before every tool call on the Mac and never
285
+ contacts the server; it writes a spool file only for AskUserQuestion, ExitPlanMode and
286
+ prompts, and always drains its input.
287
+ - **Codex and Cursor** runs at the desk answer `cancel_unsupported` ("Stop it there").
288
+ - **Fences a cancel left release themselves** once every process the cancelled turn
289
+ started is confirmed gone: only `ps` answering "no such process" counts, and a `ps` that
290
+ times out, cannot start or is missing keeps the fence. Any other fence is still released
291
+ by hand (`GET /api/agent-sessions/fences`, then `POST .../fences/release` with
292
+ `confirm: true`).
293
+ - **A cancel that lands while COS is handing a turn to the open session** writes the desk
294
+ marker, and re-arms it once if the next prompt that session starts is exactly the
295
+ delivered one. If your own prompt reaches the session first, the re-arm is dropped (and
296
+ logged) rather than stop your run.
297
+
298
+ Meetings: a closed meeting's record ages out after **4 hours**, after which its status
299
+ reads `missing` and the phone stops asking to save it. Saving a known meeting that has no
300
+ transcript answers **404 `nothing_to_save`** (with its state); only an unknown id answers
301
+ `session_not_found`.
302
+
268
303
  ## Configuration
269
304
 
270
305
  Config lives at `~/.cos-glasses/.env` (created on first run). Every key is
@@ -626,7 +661,8 @@ BIND_HOST=0.0.0.0 npm run start:server
626
661
  timers are frozen), `desk_active` (the Mac saw input), `approvals_off`
627
662
  (`COS_PERMISSION_BROKER=questions`), `broker_off`, `cursor`, `unsupported_tool`
628
663
  (ExitPlanMode is never held). `lastQuestionsPollAt` says when a client last counted.
629
- `--hooks status` must read `installed`. To turn it off with no reinstall, set
664
+ `--hooks status` must read `installed` (right after updating to 6.53.4 it reads
665
+ `script_outdated` until Install hooks is run once; its `advice` line says so). To turn it off with no reinstall, set
630
666
  `COS_PERMISSION_BROKER=0` in `~/.cos-glasses/.env` and restart. For the Messages
631
667
  trail, `/api/health` `messages_trail.readerErrors` counts lines a reader could not map.
632
668
  - *Offline meeting recovery unavailable?* — build 209+ requires server 6.11.0+.
@@ -1,5 +1,5 @@
1
1
  #!/bin/sh
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
2
+ # COS session hook (glasses-server 6.48.0; Cursor Stop follow-up 6.51.0; halt check 6.53.0; single entry 6.53.3). 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
@@ -7,11 +7,15 @@
7
7
  # restarting never delays a Claude session.
8
8
  #
9
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).
10
+ # EVERY tool call in every Claude session on this Mac. It never contacts the server. For an
11
+ # ordinary tool it writes nothing at all; it spools only AskUserQuestion and ExitPlanMode
12
+ # (the two tools the reducer reads, the 6.48 matcher's set), and UserPromptSubmit, which
13
+ # shares the branch, is always spooled. It prints nothing unless the server left a halt
14
+ # marker for this session, when it prints the one reply that stops the run (canary C1b,
15
+ # 2026-09-21: `continue:false` alone still ran the tool; with the deny it did not). It never
16
+ # exits 2: exit 2 blocks the tool, while a timeout or any other code fails open (C6, C7).
17
+ # Every path, every event, drains stdin before it exits, so Claude Code's writer never
18
+ # EPIPEs whatever the payload's size (6.53.3; every other event since its /qa round).
15
19
  #
16
20
  # The installer bakes COS_GLASSES_HOME and COS_HOOK_SPOOL into the command, so this
17
21
  # script and the server can never disagree about where the token or the spool is.
@@ -19,48 +23,79 @@ PATH=/usr/bin:/bin:/usr/sbin; umask 077; trap '' PIPE
19
23
  HOME=${HOME:-$(cd ~ 2>/dev/null && pwd)}; [ -n "$HOME" ] || exit 0
20
24
  GH=${COS_GLASSES_HOME:-$HOME/.cos-glasses}
21
25
  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=''
26
+ # 6.53.0, the halt check; 6.53.3, one entry for both synchronous events (review A, option a).
27
+ # FIRST, before mkdir, perl and the B8 guard below: a full spool or a stale drain stamp must
28
+ # never stop a cancel. The marker folder sits beside the spool, like the Cursor queue
29
+ # folder; the server writes `<folder>/<session id>` (lib/session-halt.ts).
30
+ #
31
+ # The common path, a tool call with no marker anywhere on the Mac, is one glob, one 4 KiB
32
+ # read, a shell `case`, and `exec cat` to drain the rest of stdin: no temp file, no grep,
33
+ # no dirname, no mkdir (median 29 ms to 17 ms per tool call on the release Mac, 40 runs
34
+ # each). Draining, not leaving stdin unread, keeps Claude Code's writer from EPIPE at any
35
+ # payload size (a 2.5 MB tool input did EPIPE against the 6.53.0 script).
36
+ FRONT=''
28
37
  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
38
+ HALT="${SPOOL%/*}/session-halt"
39
+ # Any marker at all? A glob is a builtin, and it runs before stdin is touched.
40
+ ANY=''; for F in "$HALT"/*; do [ -e "$F" ] && ANY=1; break; done
42
41
  # 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
42
+ # that happens to carry one comes later. Any JSON spacing; 36 hex-and-dash characters
43
+ # inside a closed string, or nothing.
44
+ sid_of() {
45
+ SID=''
46
+ case "$HEAD" in *'"session_id"'*)
47
+ SID=${HEAD#*'"session_id"'}; SID=${SID#"${SID%%[!:[:space:]]*}"}
48
+ case "$SID" in '"'*'"'*) SID=${SID#?}; SID=${SID%%'"'*} ;; *) SID='' ;; esac ;;
49
+ esac
50
+ case "$SID" in *[!0-9A-Fa-f-]*) SID='' ;; esac
51
+ [ ${#SID} -eq 36 ] || SID=''
52
+ }
53
+ # dd, never head: macOS head fills a 16 KiB buffer from a pipe and keeps only what -c
54
+ # asks for, so the bytes after 4 KiB would be lost to the spool below. dd does ONE
55
+ # read(2) of at most 4 KiB, and the `cat` after it starts at exactly the next byte.
56
+ HEAD=$(dd bs=4096 count=1 2>/dev/null)
57
+ SID=''; [ -z "$ANY" ] || sid_of
58
+ # Claude writes the session id first and a tool call's name within a few hundred bytes.
59
+ # If this read came back without what the event needs, the writer's bytes arrived in
60
+ # pieces: read once more rather than guess.
61
+ MORE=''; case "$EVENT$HEAD" in PreToolUse*'"tool_name"'*|UserPromptSubmit*) ;; *) MORE=1 ;; esac
62
+ if [ -n "$MORE" ] || { [ -n "$ANY" ] && [ -z "$SID" ]; }; then
63
+ HEAD="$HEAD$(dd bs=4096 count=1 2>/dev/null)"
64
+ [ -z "$ANY" ] || sid_of
65
+ fi
66
+ NEED=''
67
+ if [ "$EVENT" = UserPromptSubmit ]; then NEED=1
68
+ else
69
+ # Only the two tools the reducer reads are spooled (the 6.48 matcher). The FIRST
70
+ # "tool_name" is the payload's own; a tool's input that carries one comes later.
71
+ case "$HEAD" in *'"AskUserQuestion"'*|*'"ExitPlanMode"'*)
72
+ TN=${HEAD#*'"tool_name"'}; TN=${TN#"${TN%%[!:[:space:]]*}"}
73
+ case "$TN" in '"AskUserQuestion"'*|'"ExitPlanMode"'*) NEED=1 ;; esac ;;
74
+ esac
75
+ fi
76
+ case "$SID" in *[A-F]*) SID=$(printf '%s' "$SID" | tr 'A-F' 'a-f') ;; esac
77
+ if [ -z "$SID" ]; then :
78
+ elif [ "$EVENT" = UserPromptSubmit ]; then
46
79
  # A new prompt is never stopped: the marker lives only within the run it was written
47
80
  # 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"
81
+ # session, and a COS `-p` turn. Nothing (no temp file, 6.53.3 review A10) stands between
82
+ # reading the id and clearing it. Then the prompt is spooled as before.
83
+ rm -f "$HALT/$SID" 2>/dev/null
84
+ elif [ -f "$HALT/$SID" ]; then
52
85
  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
86
+ exec cat >/dev/null
59
87
  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
- }
88
+ # Every other tool call ends here with nothing written and nothing printed.
89
+ [ -n "$NEED" ] || exec cat >/dev/null
90
+ FRONT=1
91
+ # Until the spool read below has taken stdin, any exit (a failed mkdir, the B8 guard)
92
+ # still drains it.
93
+ trap 'cat >/dev/null 2>&1' EXIT
94
+ ;;
95
+ esac
96
+ # Every other event: until the spool read below has taken stdin, any exit (a failed mkdir,
97
+ # the B8 guard) drains it, as the front does.
98
+ [ -n "$FRONT" ] || trap 'cat >/dev/null 2>&1' EXIT
64
99
  mkdir -p "$SPOOL/rejected" 2>/dev/null || exit 0
65
100
  TS=$(perl -MTime::HiRes -e 'printf "%.0f", Time::HiRes::time()*1000' 2>/dev/null || date +%s000)
66
101
  # B8 guard: a full spool or a stale drain stamp means nobody is reading; keep only permissions.
@@ -68,7 +103,17 @@ if [ "$EVENT" != PermissionRequest ]; then
68
103
  N=$(ls "$SPOOL" 2>/dev/null | wc -l | tr -d ' '); [ "${N:-0}" -lt 2000 ] || exit 0
69
104
  STAMP="$SPOOL/.last-drain"; [ ! -f "$STAMP" ] || [ -z "$(find "$STAMP" -mmin +1440 2>/dev/null)" ] || exit 0 # -mtime +1 would mean two days
70
105
  fi
71
- T="$SPOOL/.tmp.$$.$TS"; head -c 1048576 > "$T.in" # stdin to disk, 1 MiB cap
106
+ T="$SPOOL/.tmp.$$.$TS"
107
+ # Stdin to disk with a 1 MiB cap; whatever lies past the cap is drained, never left for the
108
+ # writer to EPIPE on.
109
+ trap - EXIT
110
+ if [ -n "$FRONT" ]; then
111
+ # 6.53.3: the 4 KiB the halt check read, then the rest of stdin.
112
+ { printf '%s' "$HEAD"; exec cat; } | { head -c 1048576 > "$T.in"; exec cat >/dev/null; }
113
+ else
114
+ # No `exec` here: this group runs in the script's own shell.
115
+ { head -c 1048576 > "$T.in"; cat >/dev/null; }
116
+ fi
72
117
  case "$(head -c 1 "$T.in")" in '{') ;; *) EVENT=rejected ;; esac
73
118
  { printf '{"ts":%s,"ppid":%s,"event":"%s","payload":' "$TS" "$PPID" "$EVENT"; cat "$T.in"; printf '}\n'; } > "$T"
74
119
  rm -f "$T.in"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.53.1",
3
+ "version": "6.53.4",
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,9 +24,10 @@ 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 { cachedHookStatus, claudeDeskRunning, deskIdleSeconds, registerDrainKickStats, registryIdleAfterStop, sessionHooksEnabled, sessionSignalStore, signalFor, startSessionHooksRuntime } from './lib/session-hooks-runtime.js'
27
+ import { cachedHookStatus, claudeDeskRunning, deskIdleSeconds, deskTurnEndedAt, haltHandedOffTurn, registerDrainKickStats, registryIdleAfterStop, sessionHooksEnabled, sessionSignalStore, signalFor, startSessionHooksRuntime } from './lib/session-hooks-runtime.js'
28
28
  import { writeHaltMarker } from './lib/session-halt.js'
29
- import { appendSessionCancelLedger, cancelEndsTurn, cancelHoldUntil as cancelHoldUntilFor, noteThreadCancelled, threadCancelledAt } from './lib/session-cancel.js'
29
+ import { appendSessionCancelLedger, cancelHoldUntil as cancelHoldUntilFor, noteThreadCancelled, threadCancel } from './lib/session-cancel.js'
30
+ import { makeQueueTurnEvidence } from './lib/queue-turn-evidence.js'
30
31
  import {
31
32
  PermissionBroker,
32
33
  brokerSignalSink,
@@ -148,8 +149,8 @@ import {
148
149
  const app = express()
149
150
  import { createThreadTurnQueueRouter, drainAllThreads } from './routes/thread-turn-queue.js'
150
151
  import { createCursorStopFollowupRouter } from './routes/cursor-stop-followup.js'
151
- import { queuedThreadKeys, readQueue, transcriptTurnEnded, transcriptTurnVerdict, writeQueue } from './lib/thread-turn-queue-store.js'
152
- import { readHookToken } from './lib/claude-hooks-installer.js'
152
+ import { queuedThreadKeys, readQueue, transcriptTurnVerdict, writeQueue } from './lib/thread-turn-queue-store.js'
153
+ import { hookHaltReady, readHookToken } from './lib/claude-hooks-installer.js'
153
154
  import { OPEN_TURN_CEILING_MS } from './lib/session-state-derive.js'
154
155
  import { createDrainKick, kickPlanFor } from './lib/thread-drain-kick.js'
155
156
  import { transcriptPathFor } from './lib/native-head.js'
@@ -667,6 +668,22 @@ const sharedTargetGuard = new TargetGuard(
667
668
  : null,
668
669
  )
669
670
 
671
+ const queueTurnEvidence = makeQueueTurnEvidence({
672
+ signalFor: threadId => signalFor(threadId),
673
+ registryIdleAfterStop: (threadId, stopAt) => registryIdleAfterStop(threadId, stopAt),
674
+ deskEndedAt: threadId => deskTurnEndedAt(threadId),
675
+ transcriptMtimeMs: (provider, threadId) => {
676
+ const read = occupancyProbes.transcriptMtimeMs
677
+ const value = typeof read === 'function' ? read(provider as 'claude' | 'codex', threadId) : null
678
+ return typeof value === 'number' ? value : null
679
+ },
680
+ transcriptVerdict: (provider, threadId) =>
681
+ transcriptTurnVerdict(provider as 'claude' | 'codex', transcriptPathFor(provider as 'claude' | 'codex', threadId, nativeHeadDeps)),
682
+ threadCancel: (provider, threadId) => threadCancel(provider, threadId),
683
+ openTurnCeilingMs: OPEN_TURN_CEILING_MS,
684
+ now: () => Date.now(),
685
+ })
686
+
670
687
  if (threadAttachEnabled()) {
671
688
  const queueDeps = {
672
689
  // TWO GATES, ONE ANSWER. `threadOccupancy` sees FOREIGN holders -- another app on
@@ -711,61 +728,12 @@ if (threadAttachEnabled()) {
711
728
  return { attachable: false, reason: 'probe_failed' }
712
729
  }
713
730
  },
714
- turnEnded: (provider: string, threadId: string) => {
715
- if (provider !== 'claude' && provider !== 'codex') return false
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
- }
725
- // 6.48.1: the engine's own end of turn: a Stop hook newer than the turn's prompt
726
- // AND the registry flipped idle after it (the Stop hooks have returned; a prompt
727
- // typed during them is queued and dequeues only then). The transcript tail
728
- // (`turnFromTail`) answers for everything the hooks did not see. Both refuse on doubt.
729
- if (provider === 'claude') {
730
- const signal = signalFor(threadId)
731
- if (signal && !signal.turnOpen && signal.lastEvent === 'Stop' && typeof signal.stopAt === 'number'
732
- && signal.stopAt >= (signal.turnStartedAt ?? 0) && signal.subagentsOpen === 0
733
- && registryIdleAfterStop(threadId, signal.stopAt) === true) return true
734
- }
735
- return transcriptTurnEnded(provider, transcriptPathFor(provider, threadId, nativeHeadDeps))
736
- } catch {
737
- return false
738
- }
739
- },
740
- // 6.48.1: positive evidence the holder's turn is OPEN, which outranks the 30 s idle
741
- // backstop (a long tool leaves the transcript untouched while it runs). Bounded: a
742
- // signal or a tail older than OPEN_TURN_CEILING_MS is not evidence any more, so a
743
- // crashed mid-tool session still drains by the backstop rather than holding for the TTL.
744
- turnOpen: (provider: string, threadId: string): boolean => {
745
- if (provider !== 'claude' && provider !== 'codex') return false
746
- try {
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)
752
- if (provider === 'claude') {
753
- const signal = signalFor(threadId)
754
- if (signal && signal.turnOpen && now - signal.lastEventAt <= OPEN_TURN_CEILING_MS
755
- && !cancelEndsTurn(cancelledAt, signal.lastEventAt)) return true
756
- }
757
- const path = transcriptPathFor(provider, threadId, nativeHeadDeps)
758
- const verdict = transcriptTurnVerdict(provider, path)
759
- if (!verdict || verdict.ended) return false
760
- if (verdict.reason !== 'tool_pending' && verdict.reason !== 'prompt_open') return false
761
- const read = occupancyProbes.transcriptMtimeMs
762
- const mtime = typeof read === 'function' ? read(provider, threadId) : null
763
- if (cancelEndsTurn(cancelledAt, typeof mtime === 'number' ? mtime : null)) return false
764
- return typeof mtime === 'number' && now - mtime <= OPEN_TURN_CEILING_MS
765
- } catch {
766
- return false
767
- }
768
- },
731
+ // 6.53.3: the two turn questions live in lib/queue-turn-evidence.ts, where a test drives
732
+ // them against the real drain decision. What a cancel on the thread may conclude now
733
+ // depends on what it was aimed at (review A1): a killed COS child's silence ends its
734
+ // turn; a desk run's silence does not, until the desk itself says the run is over.
735
+ turnEnded: queueTurnEvidence.turnEnded,
736
+ turnOpen: queueTurnEvidence.turnOpen,
769
737
  activity: (provider: string, threadId: string): 'working' | 'idle' | 'unknown' => {
770
738
  try {
771
739
  const read = occupancyProbes.transcriptMtimeMs
@@ -879,11 +847,14 @@ app.use('/api', createAgentSessionBindingsRouter({
879
847
  cancel: {
880
848
  deskRunning: sessionId => claudeDeskRunning(sessionId),
881
849
  hooksEnabled: () => sessionHooksEnabled(),
882
- hooksReady: () => cachedHookStatus().installed,
850
+ // 6.53.3: installed, or a halt-capable earlier script still in place (`hookHaltReady`).
851
+ hooksReady: () => hookHaltReady(cachedHookStatus()),
883
852
  writeHalt: (sessionId, marker) => writeHaltMarker(sessionId, marker),
884
853
  settlePermissions: sessionId => permissionBroker.cancelSession(sessionId),
885
854
  queuedWaiting: (provider, threadId) => readQueue(provider, threadId, Date.now()).filter(t => t.status === 'waiting').length,
886
- noteCancelled: (provider, threadId, at) => noteThreadCancelled(provider, threadId, at),
855
+ noteCancelled: (provider, threadId, at, target) => noteThreadCancelled(provider, threadId, at, target),
856
+ // 6.53.3 (review A6): a cancel that landed during a live hand-off whose turn reached the session.
857
+ haltHandedOffTurn: (sessionId, marker, turn) => haltHandedOffTurn(sessionId, marker, turn),
887
858
  ledger: row => appendSessionCancelLedger(row),
888
859
  },
889
860
  }))