@gotcos/glasses-server 6.53.0 → 6.53.3

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,37 @@
1
+ ## 6.53.3
2
+
3
+ ### Cancel owns the whole process tree, desk cancels hold, and the hook is faster
4
+
5
+ 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.
6
+
7
+ - **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.
8
+ - **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).
9
+ - **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`.
10
+ - **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`.
11
+ - **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.
12
+ - **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".
13
+ - **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`.
14
+ - **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.
15
+ - 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.
16
+
17
+ ## 6.53.2
18
+
19
+ ### Cancel verification no longer races a dying Codex tree
20
+
21
+ 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.
22
+
23
+ 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.
24
+
25
+ ## 6.53.1
26
+
27
+ ### Cancel the whole Codex process tree
28
+
29
+ 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.
30
+
31
+ 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.
32
+
33
+ - 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.
34
+
1
35
  ## 6.53.0
2
36
 
3
37
  ### Cancel a session run from the glasses
@@ -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
@@ -19,48 +19,76 @@ PATH=/usr/bin:/bin:/usr/sbin; umask 077; trap '' PIPE
19
19
  HOME=${HOME:-$(cd ~ 2>/dev/null && pwd)}; [ -n "$HOME" ] || exit 0
20
20
  GH=${COS_GLASSES_HOME:-$HOME/.cos-glasses}
21
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=''
22
+ # 6.53.0, the halt check; 6.53.3, one entry for both synchronous events (review A, option a).
23
+ # FIRST, before mkdir, perl and the B8 guard below: a full spool or a stale drain stamp must
24
+ # never stop a cancel. The marker folder sits beside the spool, like the Cursor queue
25
+ # folder; the server writes `<folder>/<session id>` (lib/session-halt.ts).
26
+ #
27
+ # The common path, a tool call with no marker anywhere on the Mac, is one glob, one 4 KiB
28
+ # read, a shell `case`, and `exec cat` to drain the rest of stdin: no temp file, no grep,
29
+ # no dirname, no mkdir (median 29 ms to 17 ms per tool call on the release Mac, 40 runs
30
+ # each). Draining, not leaving stdin unread, keeps Claude Code's writer from EPIPE at any
31
+ # payload size (a 2.5 MB tool input did EPIPE against the 6.53.0 script).
32
+ FRONT=''
28
33
  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
34
+ HALT="${SPOOL%/*}/session-halt"
35
+ # Any marker at all? A glob is a builtin, and it runs before stdin is touched.
36
+ ANY=''; for F in "$HALT"/*; do [ -e "$F" ] && ANY=1; break; done
42
37
  # 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
38
+ # that happens to carry one comes later. Any JSON spacing; 36 hex-and-dash characters
39
+ # inside a closed string, or nothing.
40
+ sid_of() {
41
+ SID=''
42
+ case "$HEAD" in *'"session_id"'*)
43
+ SID=${HEAD#*'"session_id"'}; SID=${SID#"${SID%%[!:[:space:]]*}"}
44
+ case "$SID" in '"'*'"'*) SID=${SID#?}; SID=${SID%%'"'*} ;; *) SID='' ;; esac ;;
45
+ esac
46
+ case "$SID" in *[!0-9A-Fa-f-]*) SID='' ;; esac
47
+ [ ${#SID} -eq 36 ] || SID=''
48
+ }
49
+ # dd, never head: macOS head fills a 16 KiB buffer from a pipe and keeps only what -c
50
+ # asks for, so the bytes after 4 KiB would be lost to the spool below. dd does ONE
51
+ # read(2) of at most 4 KiB, and the `cat` after it starts at exactly the next byte.
52
+ HEAD=$(dd bs=4096 count=1 2>/dev/null)
53
+ SID=''; [ -z "$ANY" ] || sid_of
54
+ # Claude writes the session id first and a tool call's name within a few hundred bytes.
55
+ # If this read came back without what the event needs, the writer's bytes arrived in
56
+ # pieces: read once more rather than guess.
57
+ MORE=''; case "$EVENT$HEAD" in PreToolUse*'"tool_name"'*|UserPromptSubmit*) ;; *) MORE=1 ;; esac
58
+ if [ -n "$MORE" ] || { [ -n "$ANY" ] && [ -z "$SID" ]; }; then
59
+ HEAD="$HEAD$(dd bs=4096 count=1 2>/dev/null)"
60
+ [ -z "$ANY" ] || sid_of
61
+ fi
62
+ NEED=''
63
+ if [ "$EVENT" = UserPromptSubmit ]; then NEED=1
64
+ else
65
+ # Only the two tools the reducer reads are spooled (the 6.48 matcher). The FIRST
66
+ # "tool_name" is the payload's own; a tool's input that carries one comes later.
67
+ case "$HEAD" in *'"AskUserQuestion"'*|*'"ExitPlanMode"'*)
68
+ TN=${HEAD#*'"tool_name"'}; TN=${TN#"${TN%%[!:[:space:]]*}"}
69
+ case "$TN" in '"AskUserQuestion"'*|'"ExitPlanMode"'*) NEED=1 ;; esac ;;
70
+ esac
71
+ fi
72
+ case "$SID" in *[A-F]*) SID=$(printf '%s' "$SID" | tr 'A-F' 'a-f') ;; esac
73
+ if [ -z "$SID" ]; then :
74
+ elif [ "$EVENT" = UserPromptSubmit ]; then
46
75
  # A new prompt is never stopped: the marker lives only within the run it was written
47
76
  # 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"
77
+ # session, and a COS `-p` turn. Nothing (no temp file, 6.53.3 review A10) stands between
78
+ # reading the id and clearing it. Then the prompt is spooled as before.
79
+ rm -f "$HALT/$SID" 2>/dev/null
80
+ elif [ -f "$HALT/$SID" ]; then
52
81
  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
82
+ exec cat >/dev/null
59
83
  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
- }
84
+ # Every other tool call ends here with nothing written and nothing printed.
85
+ [ -n "$NEED" ] || exec cat >/dev/null
86
+ FRONT=1
87
+ # Until the spool read below has taken stdin, any exit (a failed mkdir, the B8 guard)
88
+ # still drains it.
89
+ trap 'cat >/dev/null 2>&1' EXIT
90
+ ;;
91
+ esac
64
92
  mkdir -p "$SPOOL/rejected" 2>/dev/null || exit 0
65
93
  TS=$(perl -MTime::HiRes -e 'printf "%.0f", Time::HiRes::time()*1000' 2>/dev/null || date +%s000)
66
94
  # B8 guard: a full spool or a stale drain stamp means nobody is reading; keep only permissions.
@@ -68,7 +96,15 @@ if [ "$EVENT" != PermissionRequest ]; then
68
96
  N=$(ls "$SPOOL" 2>/dev/null | wc -l | tr -d ' '); [ "${N:-0}" -lt 2000 ] || exit 0
69
97
  STAMP="$SPOOL/.last-drain"; [ ! -f "$STAMP" ] || [ -z "$(find "$STAMP" -mmin +1440 2>/dev/null)" ] || exit 0 # -mtime +1 would mean two days
70
98
  fi
71
- T="$SPOOL/.tmp.$$.$TS"; head -c 1048576 > "$T.in" # stdin to disk, 1 MiB cap
99
+ T="$SPOOL/.tmp.$$.$TS"
100
+ if [ -n "$FRONT" ]; then
101
+ # 6.53.3: the 4 KiB the halt check read, then the rest of stdin, to disk with the same
102
+ # 1 MiB cap; whatever lies past the cap is drained, never left for the writer to EPIPE on.
103
+ trap - EXIT
104
+ { printf '%s' "$HEAD"; exec cat; } | { head -c 1048576 > "$T.in"; exec cat >/dev/null; }
105
+ else
106
+ head -c 1048576 > "$T.in" # stdin to disk, 1 MiB cap
107
+ fi
72
108
  case "$(head -c 1 "$T.in")" in '{') ;; *) EVENT=rejected ;; esac
73
109
  { printf '{"ts":%s,"ppid":%s,"event":"%s","payload":' "$TS" "$PPID" "$EVENT"; cat "$T.in"; printf '}\n'; } > "$T"
74
110
  rm -f "$T.in"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.53.0",
3
+ "version": "6.53.3",
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
  }))