@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.
@@ -284,6 +284,58 @@ export function hookStatus(paths: { settingsPath?: string; scriptPath?: string;
284
284
  return { state, installed: state === 'installed', settingsPath, scriptPath, scriptSha, packageScriptSha, ...events, tokenPresent }
285
285
  }
286
286
 
287
+ /**
288
+ * 6.53.3: sha256 of every EARLIER script that already carries the halt check: the one script
289
+ * 6.53.0, 6.53.1 and 6.53.2 all shipped. 6.53.3 rewrote the script for speed (review A,
290
+ * option a), so an install that has not run Install hooks since reads `script_outdated`
291
+ * and Control shows its banner. Its script still stops a desk run exactly as before,
292
+ * though, so a desk cancel is not refused `hooks_outdated` in the meantime
293
+ * (`hookHaltReady`). A script older than 6.53.0 has no halt check and never qualifies.
294
+ */
295
+ export const HALT_CAPABLE_PRIOR_SCRIPT_SHAS: readonly string[] = [
296
+ '1158bb06297550128f01fc47caa68806a5287d6da2d05b0d1c812e8ae20764e7',
297
+ ]
298
+
299
+ /**
300
+ * 6.53.3: can the hooks on this Mac stop a desk Claude run now? `installed`, or the only
301
+ * difference is a halt-capable earlier script (above). What the cancel route's
302
+ * `hooksReady` and `/api/health` `features.sessionCancel.deskClaude` read.
303
+ */
304
+ export function hookHaltReady(status: Pick<HookStatus, 'installed' | 'state' | 'scriptSha'>): boolean {
305
+ if (status.installed === true) return true
306
+ return status.state === 'script_outdated' && typeof status.scriptSha === 'string'
307
+ && HALT_CAPABLE_PRIOR_SCRIPT_SHAS.includes(status.scriptSha)
308
+ }
309
+
310
+ const INSTALL_COMMAND = 'npx --yes @gotcos/glasses-server@latest --hooks install'
311
+
312
+ /**
313
+ * 6.53.3: one plain sentence for `--hooks status` saying what the state means and what fixes
314
+ * it. Null when installed. Carries no path: the status JSON beside it already names them.
315
+ */
316
+ export function hookStatusAdvice(status: Pick<HookStatus, 'installed' | 'state' | 'scriptSha' | 'packageScriptSha'>): string | null {
317
+ const short = (sha: string | null): string => (sha ? sha.slice(0, 12) : 'none')
318
+ switch (status.state) {
319
+ case 'installed':
320
+ return null
321
+ case 'script_outdated':
322
+ return `The installed hook script is older than this package's (installed ${short(status.scriptSha)}, package ${short(status.packageScriptSha)}). `
323
+ + 'Every Claude session keeps running the old script until the hooks are reinstalled: '
324
+ + `run \`${INSTALL_COMMAND}\`, or press Install hooks in COS Control. `
325
+ + (hookHaltReady(status)
326
+ ? 'Cancel from the lens still stops desk runs with the installed script meanwhile.'
327
+ : 'Until then a desk run cannot be cancelled from the lens.')
328
+ case 'drift':
329
+ case 'missing':
330
+ return `The hook subscriptions in the Claude settings file are ${status.state === 'missing' ? 'not installed' : 'not the ones this package installs'}. `
331
+ + `Run \`${INSTALL_COMMAND}\`, or press Install hooks in COS Control.`
332
+ case 'disabled_by_settings':
333
+ return 'The Claude settings file sets disableAllHooks: no hook runs, installed or not.'
334
+ default:
335
+ return 'The Claude settings file could not be read as it is; nothing was changed. Fix or restore the file, then install again.'
336
+ }
337
+ }
338
+
287
339
  export interface InstallOptions {
288
340
  settingsPath?: string
289
341
  scriptPath?: string
@@ -0,0 +1,137 @@
1
+ // The thread-turn queue's two turn questions, `turnEnded` and `turnOpen` (6.53.3).
2
+ //
3
+ // Moved out of the composition root (index.ts) unchanged in shape, so the one rule that
4
+ // changed in 6.53.3 can be driven by a test against the real `drainDecision`: WHAT a
5
+ // cancel on the thread may conclude about a turn that still reads open depends on what the
6
+ // cancel was aimed at (review A1, `cancelVoidsOpenTurn` in session-cancel.ts).
7
+ //
8
+ // cos_turn COS killed its own child: nothing written since the cancel ends the turn.
9
+ // desk_run the run stops at its NEXT tool call, so the hooks' and the transcript's
10
+ // open-turn evidence stands until the desk's own engine says the run is over
11
+ // at or after the cancel (a Stop, StopFailure or SessionEnd, or the registry's
12
+ // idle flip). Until 6.53.2 a desk run inside a long tool read "over" 30 s after
13
+ // the cancel, and a parked follow-up was delivered live into it.
14
+ //
15
+ // Every read is injected; index.ts wires the real ones. Both answers refuse on doubt: a
16
+ // throw is `false` for each, which is what the composition root did before the move.
17
+
18
+ import { cancelVoidsOpenTurn, type ThreadCancel } from './session-cancel.js'
19
+
20
+ /** The hook signal fields these questions read (a `SessionSignal` satisfies it). */
21
+ export interface TurnEvidenceSignal {
22
+ turnOpen: boolean
23
+ lastEvent: string
24
+ lastEventAt: number
25
+ turnStartedAt: number | null
26
+ stopAt: number | null
27
+ subagentsOpen: number
28
+ }
29
+
30
+ /** The transcript tail's own verdict (`transcriptTurnVerdict`). */
31
+ export interface TurnEvidenceVerdict {
32
+ ended: boolean
33
+ reason?: string | null
34
+ }
35
+
36
+ export interface QueueTurnEvidenceDeps {
37
+ /** The hooks' signal for a Claude session; undefined when the hooks are off or silent. */
38
+ signalFor: (threadId: string) => TurnEvidenceSignal | undefined
39
+ /** `registryIdleAfterStop`: true only when the live record flipped idle at or after `stopAt`. */
40
+ registryIdleAfterStop: (threadId: string, stopAt: number) => boolean | null
41
+ /** `deskTurnEndedAt`: when the desk session's engine said its run is over, as it stands now. */
42
+ deskEndedAt: (threadId: string) => number | null
43
+ /** The transcript's mtime, or null when unmeasurable. */
44
+ transcriptMtimeMs: (provider: string, threadId: string) => number | null
45
+ /** The transcript tail's verdict, or null when unreadable. */
46
+ transcriptVerdict: (provider: string, threadId: string) => TurnEvidenceVerdict | null
47
+ /** The cancel on record for the thread (`threadCancel`). */
48
+ threadCancel: (provider: string, threadId: string) => ThreadCancel | null
49
+ /** Past this age a signal or a tail is not evidence any more (OPEN_TURN_CEILING_MS). */
50
+ openTurnCeilingMs: number
51
+ now: () => number
52
+ }
53
+
54
+ export interface QueueTurnEvidence {
55
+ turnEnded: (provider: string, threadId: string) => boolean
56
+ turnOpen: (provider: string, threadId: string) => boolean
57
+ }
58
+
59
+ export function makeQueueTurnEvidence(deps: QueueTurnEvidenceDeps): QueueTurnEvidence {
60
+ const mtime = (provider: string, threadId: string): number | null => {
61
+ const value = deps.transcriptMtimeMs(provider, threadId)
62
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
63
+ }
64
+ /** Read once per question, and only when a cancel is on record (it scans the registry). */
65
+ const deskEnded = (provider: string, threadId: string): (() => number | null) => {
66
+ let cached: number | null | undefined
67
+ return () => {
68
+ if (cached === undefined) cached = provider === 'claude' ? deps.deskEndedAt(threadId) : null
69
+ return cached
70
+ }
71
+ }
72
+
73
+ const turnEnded = (provider: string, threadId: string): boolean => {
74
+ if (provider !== 'claude' && provider !== 'codex') return false
75
+ try {
76
+ const signal = provider === 'claude' ? deps.signalFor(threadId) : undefined
77
+ // 6.53.0: a cancel ends the turn when nothing was written since it (a killed COS
78
+ // child leaves its last tool_use unanswered, and no Stop or terminal record follows).
79
+ // 6.53.3: only a cos_turn cancel concludes that; a desk_run cancel needs the desk's
80
+ // own end, stamped at or after the cancel.
81
+ const cancel = deps.threadCancel(provider, threadId)
82
+ if (cancel !== null && cancelVoidsOpenTurn({
83
+ cancel,
84
+ lastActivityAt: mtime(provider, threadId),
85
+ turnStartedAt: signal?.turnStartedAt ?? null,
86
+ deskEndedAt: deskEnded(provider, threadId)(),
87
+ })) return true
88
+ // 6.48.1: the engine's own end of turn: a Stop hook newer than the turn's prompt
89
+ // AND the registry flipped idle after it (the Stop hooks have returned; a prompt
90
+ // typed during them is queued and dequeues only then). The transcript tail
91
+ // answers for everything the hooks did not see. Both refuse on doubt.
92
+ if (provider === 'claude') {
93
+ if (signal && !signal.turnOpen && signal.lastEvent === 'Stop' && typeof signal.stopAt === 'number'
94
+ && signal.stopAt >= (signal.turnStartedAt ?? 0) && signal.subagentsOpen === 0
95
+ && deps.registryIdleAfterStop(threadId, signal.stopAt) === true) return true
96
+ }
97
+ return deps.transcriptVerdict(provider, threadId)?.ended === true
98
+ } catch {
99
+ return false
100
+ }
101
+ }
102
+
103
+ // 6.48.1: positive evidence the holder's turn is OPEN, which outranks the 30 s idle
104
+ // backstop (a long tool leaves the transcript untouched while it runs). Bounded: a
105
+ // signal or a tail older than the ceiling is not evidence any more, so a crashed
106
+ // mid-tool session still drains by the backstop rather than holding for the TTL.
107
+ const turnOpen = (provider: string, threadId: string): boolean => {
108
+ if (provider !== 'claude' && provider !== 'codex') return false
109
+ try {
110
+ const now = deps.now()
111
+ const cancel = deps.threadCancel(provider, threadId)
112
+ const ended = deskEnded(provider, threadId)
113
+ if (provider === 'claude') {
114
+ const signal = deps.signalFor(threadId)
115
+ if (signal && signal.turnOpen && now - signal.lastEventAt <= deps.openTurnCeilingMs
116
+ && !(cancel !== null && cancelVoidsOpenTurn({
117
+ cancel,
118
+ lastActivityAt: signal.lastEventAt,
119
+ turnStartedAt: signal.turnStartedAt,
120
+ deskEndedAt: ended(),
121
+ }))) return true
122
+ }
123
+ const verdict = deps.transcriptVerdict(provider, threadId)
124
+ if (!verdict || verdict.ended) return false
125
+ if (verdict.reason !== 'tool_pending' && verdict.reason !== 'prompt_open') return false
126
+ const last = mtime(provider, threadId)
127
+ // 6.53.0 / 6.53.3: a cos_turn cancel with nothing written since discounts a dangling
128
+ // tool_use (the killed child's); a desk_run cancel only once the desk said it ended.
129
+ if (cancel !== null && cancelVoidsOpenTurn({ cancel, lastActivityAt: last, turnStartedAt: null, deskEndedAt: ended() })) return false
130
+ return last !== null && now - last <= deps.openTurnCeilingMs
131
+ } catch {
132
+ return false
133
+ }
134
+ }
135
+
136
+ return { turnEnded, turnOpen }
137
+ }
@@ -104,30 +104,47 @@ export const CANCEL_QUEUE_HOLD_MS = 2 * 60_000
104
104
  export const CANCEL_MEMORY_MS = 60 * 60_000
105
105
  const MAX_REMEMBERED_CANCELS = 256
106
106
 
107
+ /**
108
+ * One cancel on record for a thread (6.53.3): WHEN, and WHAT it was aimed at. The target
109
+ * decides what the cancel may conclude about a turn that still reads open (see
110
+ * `cancelVoidsOpenTurn`). Null is an older caller that did not say; it is read as a desk
111
+ * run, the reading that concludes the least.
112
+ */
113
+ export interface ThreadCancel {
114
+ at: number
115
+ target: CancelTarget | null
116
+ }
117
+
107
118
  // In memory on purpose: every reader is a hold of minutes, and a restart that forgets
108
119
  // one lets the queue drain by the gate's ordinary rules, which is the pre-6.53 behaviour.
109
- const cancelledAtByThread = new Map<string, number>()
120
+ const cancelsByThread = new Map<string, ThreadCancel>()
110
121
  const cancelKey = (provider: string, threadId: string) => `${provider}:${threadId.toLowerCase()}`
111
122
 
112
- export function noteThreadCancelled(provider: string, threadId: string, at: number): void {
123
+ export function noteThreadCancelled(provider: string, threadId: string, at: number, target: CancelTarget | null = null): void {
113
124
  if (!Number.isFinite(at)) return
114
125
  const key = cancelKey(provider, threadId)
115
- cancelledAtByThread.delete(key)
116
- cancelledAtByThread.set(key, at)
117
- if (cancelledAtByThread.size > MAX_REMEMBERED_CANCELS) {
118
- for (const [k, when] of cancelledAtByThread) {
119
- if (at - when > CANCEL_MEMORY_MS) cancelledAtByThread.delete(k)
126
+ cancelsByThread.delete(key)
127
+ cancelsByThread.set(key, { at, target })
128
+ if (cancelsByThread.size > MAX_REMEMBERED_CANCELS) {
129
+ for (const [k, when] of cancelsByThread) {
130
+ if (at - when.at > CANCEL_MEMORY_MS) cancelsByThread.delete(k)
120
131
  }
121
- while (cancelledAtByThread.size > MAX_REMEMBERED_CANCELS) {
122
- const oldest = cancelledAtByThread.keys().next()
132
+ while (cancelsByThread.size > MAX_REMEMBERED_CANCELS) {
133
+ const oldest = cancelsByThread.keys().next()
123
134
  if (oldest.done) break
124
- cancelledAtByThread.delete(oldest.value)
135
+ cancelsByThread.delete(oldest.value)
125
136
  }
126
137
  }
127
138
  }
128
139
 
129
140
  export function threadCancelledAt(provider: string, threadId: string): number | null {
130
- return cancelledAtByThread.get(cancelKey(provider, threadId)) ?? null
141
+ return cancelsByThread.get(cancelKey(provider, threadId))?.at ?? null
142
+ }
143
+
144
+ /** 6.53.3: the cancel on record for the thread, with its target; null when none. A copy. */
145
+ export function threadCancel(provider: string, threadId: string): ThreadCancel | null {
146
+ const found = cancelsByThread.get(cancelKey(provider, threadId))
147
+ return found ? { at: found.at, target: found.target } : null
131
148
  }
132
149
 
133
150
  /** Until when the thread's parked turns hold, or null when no cancel is on record. */
@@ -151,8 +168,49 @@ export function cancelEndsTurn(cancelledAt: number | null, lastRowAt: number | n
151
168
  return cancelledAt >= lastRowAt
152
169
  }
153
170
 
171
+ /** What `cancelVoidsOpenTurn` weighs for one piece of open-turn evidence. */
172
+ export interface OpenTurnEvidence {
173
+ /** The cancel on record for the thread (`threadCancel`), or null. */
174
+ cancel: ThreadCancel | null
175
+ /** When the evidence was last written: the hook's newest event, or the transcript mtime. */
176
+ lastActivityAt: number | null
177
+ /** When the turn the evidence belongs to STARTED (the hook's UserPromptSubmit); null when unknown. */
178
+ turnStartedAt: number | null
179
+ /**
180
+ * When the desk session's own engine said the run is over, as it stands NOW: the
181
+ * registry's `idle` flip (`statusUpdatedAt`), or the hooks' Stop / StopFailure /
182
+ * SessionEnd while no turn is open. Null when nothing says so.
183
+ */
184
+ deskEndedAt: number | null
185
+ }
186
+
187
+ /**
188
+ * 6.53.3 (review A1): may the cancel on record discount evidence that the thread's turn
189
+ * is still OPEN?
190
+ *
191
+ * cos_turn COS killed its own child. Nothing written since the cancel means the turn
192
+ * is over: the dangling `tool_use` is the dead child's (6.53.0, `cancelEndsTurn`).
193
+ * desk_run A halt marker stops a desk run only at its NEXT tool call, so a run inside a
194
+ * long tool writes nothing for minutes and is still running. Silence proves
195
+ * nothing here. Only the engine's own end, stamped at or after the cancel and
196
+ * after the turn began, closes it. Until 6.53.2 the cos_turn rule applied to
197
+ * both, and a turn parked behind a cancelled desk run was delivered live into
198
+ * the run the person had just stopped (and its UserPromptSubmit then deleted
199
+ * the marker, so the cancel was lost).
200
+ * null An older caller that did not say: read as desk_run, which concludes least.
201
+ */
202
+ export function cancelVoidsOpenTurn(evidence: OpenTurnEvidence): boolean {
203
+ const { cancel } = evidence
204
+ if (cancel === null || !Number.isFinite(cancel.at)) return false
205
+ if (cancel.target === 'cos_turn') return cancelEndsTurn(cancel.at, evidence.lastActivityAt)
206
+ const ended = evidence.deskEndedAt
207
+ if (ended === null || !Number.isFinite(ended) || ended < cancel.at) return false
208
+ const started = evidence.turnStartedAt
209
+ return started === null || !Number.isFinite(started) || ended >= started
210
+ }
211
+
154
212
  export function __resetThreadCancelsForTests(): void {
155
- cancelledAtByThread.clear()
213
+ cancelsByThread.clear()
156
214
  }
157
215
 
158
216
  // ---------------------------------------------------------------------------
@@ -28,6 +28,10 @@
28
28
  // - a sweep deletes anything older than HALT_MARKER_TTL_MS.
29
29
  // It is NOT deleted on Stop: a background subagent reports its parent's session id (C8),
30
30
  // and it is exactly the work a cancel must also stop.
31
+ //
32
+ // 6.53.3: ONE exception to "a new prompt clears it": a turn COS itself handed into the open
33
+ // session while a cancel was on its way (`haltDeliveredTurn`). That turn's own prompt must
34
+ // not be the one that frees it, so the server writes the marker back, once, when it sees it.
31
35
 
32
36
  import { mkdirSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs'
33
37
  import { join } from 'node:path'
@@ -105,6 +109,96 @@ export function clearHaltMarkerOnSessionEnd(sessionId: string, endedAt: number,
105
109
  }
106
110
  }
107
111
 
112
+ // ---------------------------------------------------------------------------
113
+ // 6.53.3 (review A6): a cancel that landed while COS was HANDING a turn to the open session
114
+ // ---------------------------------------------------------------------------
115
+ //
116
+ // The live hop writes the turn into the running session's inbox, where it waits its turn
117
+ // (a busy session) or starts at once (an idle one). Once it has landed, the only stop is
118
+ // this marker. But the hook deletes the marker on UserPromptSubmit, and the delivered
119
+ // turn's OWN UserPromptSubmit may come after the marker is written: a busy session takes
120
+ // the message only when its current run ends (which the marker itself brings about). A
121
+ // plain write would then be deleted by the very turn it was written to stop.
122
+ //
123
+ // So the marker is written, and when the delivered turn has not visibly started yet, it is
124
+ // written AGAIN, once, on the first UserPromptSubmit for that session whose prompt carries
125
+ // the delivered text. Matching the text keeps the person's own next desk prompt from ever
126
+ // being the one that re-arms it; the window is the marker's own hour; SessionEnd drops it.
127
+ // In memory on purpose: a restart forgets it, like every other hold here.
128
+
129
+ interface PendingRearm {
130
+ marker: HaltMarker
131
+ /** Text the delivered prompt carries (the peer-inbox acceptance marker). */
132
+ promptMarker: string
133
+ /** Prompts submitted before this are someone else's. */
134
+ after: number
135
+ until: number
136
+ }
137
+
138
+ const pendingRearms = new Map<string, PendingRearm>()
139
+ const MAX_PENDING_REARMS = 64
140
+
141
+ /**
142
+ * Write the marker for a turn COS just put into the open session. `rearm` is false when
143
+ * the turn has visibly started already (its UserPromptSubmit ran before this write, so
144
+ * nothing will delete the marker but the next prompt). False when the write failed; the
145
+ * caller must then not report the cancel as armed.
146
+ */
147
+ export function haltDeliveredTurn(
148
+ sessionId: string,
149
+ marker: HaltMarker,
150
+ turn: { promptMarker: string; after: number; rearm: boolean; now?: number },
151
+ dir = haltDir(),
152
+ ): boolean {
153
+ if (!writeHaltMarker(sessionId, marker, dir)) return false
154
+ const id = sessionId.toLowerCase()
155
+ if (!turn.rearm || typeof turn.promptMarker !== 'string' || turn.promptMarker.trim().length === 0) {
156
+ pendingRearms.delete(id)
157
+ return true
158
+ }
159
+ const now = turn.now ?? Date.now()
160
+ pendingRearms.delete(id)
161
+ pendingRearms.set(id, { marker, promptMarker: turn.promptMarker, after: turn.after, until: now + HALT_MARKER_TTL_MS })
162
+ while (pendingRearms.size > MAX_PENDING_REARMS) {
163
+ const oldest = pendingRearms.keys().next()
164
+ if (oldest.done) break
165
+ pendingRearms.delete(oldest.value)
166
+ }
167
+ return true
168
+ }
169
+
170
+ /**
171
+ * A UserPromptSubmit from the session itself (never a COS child): the hook has just deleted
172
+ * the session's marker. When it was the delivered turn's own prompt, write the marker back.
173
+ * One shot. True when the marker was re-armed.
174
+ */
175
+ export function rearmHaltOnPrompt(sessionId: string, promptAt: number, prompt: unknown, dir = haltDir(), now = Date.now()): boolean {
176
+ const id = typeof sessionId === 'string' ? sessionId.toLowerCase() : ''
177
+ const pending = pendingRearms.get(id)
178
+ if (!pending) return false
179
+ if (now > pending.until) {
180
+ pendingRearms.delete(id)
181
+ return false
182
+ }
183
+ if (!Number.isFinite(promptAt) || promptAt < pending.after) return false
184
+ if (typeof prompt !== 'string' || !prompt.includes(pending.promptMarker)) return false
185
+ pendingRearms.delete(id)
186
+ return writeHaltMarker(id, pending.marker, dir)
187
+ }
188
+
189
+ /** The session ended: nothing is left to stop. */
190
+ export function dropHaltRearm(sessionId: string): void {
191
+ if (typeof sessionId === 'string') pendingRearms.delete(sessionId.toLowerCase())
192
+ }
193
+
194
+ export function hasPendingHaltRearm(sessionId: string): boolean {
195
+ return typeof sessionId === 'string' && pendingRearms.has(sessionId.toLowerCase())
196
+ }
197
+
198
+ export function __resetHaltRearmsForTests(): void {
199
+ pendingRearms.clear()
200
+ }
201
+
108
202
  export function hasHaltMarker(sessionId: string, dir = haltDir()): boolean {
109
203
  const path = haltMarkerPath(sessionId, dir)
110
204
  if (path === null) return false
@@ -17,7 +17,8 @@ import { SessionHookLedger } from './session-hook-ledger.js'
17
17
  import { startSpoolIngester, type SpoolIngester, type SpoolStats } from './session-hook-spool.js'
18
18
  import { SessionSignalStore, type SessionSignal } from './session-signal-store.js'
19
19
  import { OPEN_TURN_CEILING_MS, deriveSessionState, type DerivedSessionState, type RegistryFacts, type TranscriptFacts } from './session-state-derive.js'
20
- import { clearHaltMarkerOnSessionEnd, sweepHaltMarkers } from './session-halt.js'
20
+ import { clearHaltMarkerOnSessionEnd, dropHaltRearm, haltDeliveredTurn, rearmHaltOnPrompt, sweepHaltMarkers, type HaltMarker } from './session-halt.js'
21
+ import { cancelVoidsOpenTurn, threadCancel } from './session-cancel.js'
21
22
  import { ensureHookRuntimeFiles, ensureStableHookScript, hookSpoolDir, hookStatus, type HookStatus } from './claude-hooks-installer.js'
22
23
 
23
24
  export function sessionHooksEnabled(): boolean {
@@ -173,6 +174,29 @@ function pidAlive(pid: number): boolean {
173
174
  }
174
175
  }
175
176
 
177
+ /**
178
+ * 6.53.3: when the desk session's own engine said its run is over, AS IT STANDS NOW: the
179
+ * registry's `idle` flip (`statusUpdatedAt`), or the hooks' Stop / StopFailure / SessionEnd
180
+ * while no turn is open. Null when neither says so (a busy record, an open hook turn, no
181
+ * record at all). What a desk_run cancel waits for before it may discount the evidence
182
+ * that the run is still going (`cancelVoidsOpenTurn`, review A1).
183
+ */
184
+ export function deskTurnEndedAt(sessionId: string, dir = claudeSessionsDir()): number | null {
185
+ let ended: number | null = null
186
+ const later = (at: unknown): void => {
187
+ if (typeof at === 'number' && Number.isFinite(at) && (ended === null || at > ended)) ended = at
188
+ }
189
+ const signal = signalFor(sessionId)
190
+ if (signal && !signal.turnOpen) {
191
+ later(signal.stopAt)
192
+ later(signal.failure?.at)
193
+ later(signal.ended?.at)
194
+ }
195
+ const record = registryRecordSync(sessionId, dir)
196
+ if (record && record.status === 'idle') later(record.statusUpdatedAt)
197
+ return ended
198
+ }
199
+
176
200
  /**
177
201
  * 6.53.0: is Claude working on this session AT THE DESK right now (a Desktop tab, a terminal,
178
202
  * or a Continue delivered live into either)? The cancel route's `deskRunning`: a yes offers
@@ -183,15 +207,40 @@ function pidAlive(pid: number): boolean {
183
207
  * own in-flight turn) says `busy`, and its pid is alive.
184
208
  * Canary C9 (2026-09-21): after a hook stop the registry read idle within a second, so a
185
209
  * cancelled desk run stops reading as running at once.
210
+ *
211
+ * 6.53.3 (review A5): the hooks' open turn is weighed against the cancel on record, by the
212
+ * rule the turn queue uses. A `claude -p` COS child killed by a cancel fires no Stop or
213
+ * SessionEnd; its open turn with nothing since the cancel is the dead child's, and must not
214
+ * put a phantom "Cancel run" (desk_run) on a thread where nothing runs.
186
215
  */
187
216
  export function claudeDeskRunning(sessionId: string, now = Date.now(), dir = claudeSessionsDir()): boolean {
188
217
  const signal = signalFor(sessionId)
189
- if (signal && signal.turnOpen && signal.ended === null && now - signal.lastEventAt <= OPEN_TURN_CEILING_MS) return true
218
+ if (signal && signal.turnOpen && signal.ended === null && now - signal.lastEventAt <= OPEN_TURN_CEILING_MS) {
219
+ const cancel = threadCancel('claude', sessionId)
220
+ if (cancel === null || !cancelVoidsOpenTurn({
221
+ cancel,
222
+ lastActivityAt: signal.lastEventAt,
223
+ turnStartedAt: signal.turnStartedAt,
224
+ deskEndedAt: deskTurnEndedAt(sessionId, dir),
225
+ })) return true
226
+ }
190
227
  const record = registryRecordSync(sessionId, dir)
191
228
  if (!record || record.status !== 'busy' || record.pid === null || isCosSpawnedPid(record.pid, now)) return false
192
229
  return pidAlive(record.pid)
193
230
  }
194
231
 
232
+ /**
233
+ * 6.53.3 (review A6): stop a turn COS has just handed into the open session, because a
234
+ * cancel arrived while the hand-off was in flight. The marker is written now; when the
235
+ * hooks do not yet show the turn started (its UserPromptSubmit at or after `sentAt`), it is
236
+ * re-armed once when that prompt's own UserPromptSubmit deletes it (`haltDeliveredTurn`).
237
+ */
238
+ export function haltHandedOffTurn(sessionId: string, marker: HaltMarker, turn: { promptMarker: string; sentAt: number }, now = Date.now()): boolean {
239
+ const signal = signalFor(sessionId)
240
+ const started = !!signal && typeof signal.turnStartedAt === 'number' && signal.turnStartedAt >= turn.sentAt
241
+ return haltDeliveredTurn(sessionId, marker, { promptMarker: turn.promptMarker, after: turn.sentAt, rearm: !started, now })
242
+ }
243
+
195
244
  let ledger: SessionHookLedger | null = null
196
245
  let ingester: SpoolIngester | null = null
197
246
  let replayed: { rows: number; applied: number } | null = null
@@ -233,6 +282,10 @@ export function startSessionHooksRuntime(options: { port: number }): SessionHook
233
282
  // clear a cancel written since; the TTL sweep covers anything this misses.
234
283
  const unsubscribeHalt = enabled ? sessionSignalStore.subscribe((_signal, env, child) => {
235
284
  if (env.event === 'SessionEnd' && !child) clearHaltMarkerOnSessionEnd(env.sessionId, env.ts)
285
+ // 6.53.3 (review A6): a turn COS handed into the open session while a cancel was on its
286
+ // way. Its own UserPromptSubmit just deleted the marker; write it back (once).
287
+ if (env.event === 'UserPromptSubmit' && !child) rearmHaltOnPrompt(env.sessionId, env.ts, env.payload.prompt)
288
+ if (env.event === 'SessionEnd' && !child) dropHaltRearm(env.sessionId)
236
289
  }) : () => {}
237
290
  sweepHaltMarkers(Date.now())
238
291
  // Runtime files the script reads. The port can change per install; the token never does.