@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.
@@ -305,6 +305,18 @@ export function questionHookOutput(toolInput: Record<string, unknown>, answers:
305
305
  return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'allow', updatedInput: { ...toolInput, answers } } } }
306
306
  }
307
307
 
308
+ /** 6.53.0: the message a held prompt gets when its session's run is cancelled from COS. */
309
+ export const CANCEL_DENY_MESSAGE = 'Cancelled from COS'
310
+
311
+ /**
312
+ * 6.53.0: a held prompt whose run was cancelled from the lens. Deny with `interrupt: true`,
313
+ * so Claude ends the RUN rather than trying something else in place of this tool. The one
314
+ * case where a broker reply interrupts, and only ever from `cancelSession`.
315
+ */
316
+ export function cancelHookOutput(): Record<string, unknown> {
317
+ return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'deny', message: CANCEL_DENY_MESSAGE, interrupt: true } } }
318
+ }
319
+
308
320
  /** A tool approval: allow once, or deny with the one message. Never a rule. */
309
321
  export function approvalHookOutput(decision: 'allow' | 'deny'): Record<string, unknown> {
310
322
  const body = decision === 'allow' ? { behavior: 'allow' } : { behavior: 'deny', message: DENY_MESSAGE }
@@ -933,6 +945,42 @@ export class PermissionBroker {
933
945
  return { status: 200, body: { ok: true, id: item.id, kind: item.kind, ...chosen } }
934
946
  }
935
947
 
948
+ /**
949
+ * 6.53.0: a desk run of this session was cancelled from COS. Every prompt the broker holds
950
+ * for it is answered deny with `interrupt: true` (the away-from-desk case: without this the
951
+ * run waits out the deadline and then shows the Mac's dialog, and a cancel "at the next
952
+ * tool call" never arrives, because the next tool is the one being asked about). Recorded
953
+ * as an answer, so a lens answering the same card afterwards reads 409 already_answered.
954
+ * A hook that already left is `hook_gone`, as for any answer. Returns how many it denied.
955
+ */
956
+ cancelSession(sessionId: string): number {
957
+ const wanted = typeof sessionId === 'string' ? sessionId.toLowerCase() : ''
958
+ if (!wanted) return 0
959
+ let denied = 0
960
+ for (const item of [...this.items.values()]) {
961
+ if (item.state !== 'pending' || item.sessionId !== wanted) continue
962
+ if (!item.channel || !item.channel.writable() || !item.channel.reply(cancelHookOutput())) {
963
+ this.settle(item, 'hook_gone')
964
+ continue
965
+ }
966
+ item.state = 'answered'
967
+ item.settledAt = this.deps.now()
968
+ item.answer = { clientAnswerId: 'cos-cancel', chosen: { decision: 'deny' } }
969
+ item.channel = null
970
+ item.toolInput = {}
971
+ item.questions = null
972
+ item.approval = null
973
+ if (item.timer) clearTimeout(item.timer)
974
+ item.timer = null
975
+ this.counters.answered++
976
+ try { this.deps.signals?.decided(item.sessionId, item.id, item.fingerprint) } catch { /* rows are advisory */ }
977
+ this.log(`cancelled ${item.id} kind=${item.kind} pending=${this.pendingCount()}`)
978
+ denied++
979
+ }
980
+ this.prune()
981
+ return denied
982
+ }
983
+
936
984
  /**
937
985
  * "Leave for the Mac" on a pending item. As for an answer, the hook must still be listening:
938
986
  * one that already left is `hook_gone` (the Mac shows its dialog either way).
@@ -0,0 +1,190 @@
1
+ // Cancel a session run from the lens (6.53.0): the decisions, the copy, the ledger, and
2
+ // the per-thread cancel time the turn queue reads.
3
+ //
4
+ // Miles, 2026-09-21: "a cancel button ... If there's a current run that's going, it
5
+ // essentially clicks the cancel button ... similar to when they're at the desktop."
6
+ //
7
+ // WHO OWNS THE RUN DECIDES WHAT A CANCEL CAN DO (plan PLAN_g2_cancel_run_529_653):
8
+ // cos_turn a Continue COS itself spawned (`claude -p --resume`, `codex exec resume`,
9
+ // `cursor-agent`). The route aborts its own child: immediate, every provider.
10
+ // desk_run Claude Code at the desk (a Desktop tab, a terminal, a Continue delivered
11
+ // live into the open window). A halt marker plus the synchronous PreToolUse
12
+ // hook stops it at its NEXT tool call (canaries C1b, C5, C10).
13
+ // unsupported the Codex app or Cursor. Nothing outside the app can reach the run.
14
+ // hooks_* a desk Claude run on a Mac whose hooks cannot stop it yet.
15
+ //
16
+ // `cancelTargetFor` is the ONE function that decides, and both the cancel route and the
17
+ // attachability body's `cancel` field call it, so the row the lens shows and the answer
18
+ // the tap gets cannot disagree (plan validation B4).
19
+
20
+ import { appendFileSync, chmodSync, mkdirSync } from 'node:fs'
21
+ import { dirname } from 'node:path'
22
+ import { dataPath } from './data-dir.js'
23
+
24
+ export type CancelTarget = 'cos_turn' | 'desk_run' | 'unsupported' | 'hooks_outdated' | 'hooks_disabled'
25
+
26
+ export interface CancelFacts {
27
+ provider: string
28
+ /** A COS turn is in flight on this thread (the route's own in-flight map). */
29
+ cosTurnInFlight: boolean
30
+ /** A run outside COS is working on this thread right now. */
31
+ runningOutsideCos: boolean
32
+ /** `sessionHooksEnabled()`: the hook signal store is applied at all. */
33
+ hooksEnabled: boolean
34
+ /** The hooks installed on this Mac carry the 6.53.0 script and subscription. */
35
+ hooksReady: boolean
36
+ }
37
+
38
+ /**
39
+ * PURE, and the precedence is the point:
40
+ * 1. COS's own turn first. It is the one run COS can end at once, and a marker would
41
+ * also stop it only at its next tool (R3), so aborting the child is always better.
42
+ * 2. Nothing running: null. The lens shows no row at all.
43
+ * 3. Codex and Cursor cannot be stopped from outside their app.
44
+ * 4. A desk Claude run needs the hooks applied AND the new script installed; either
45
+ * missing is its own answer, because the fixes differ (a setting vs Install hooks).
46
+ */
47
+ export function cancelTargetFor(facts: CancelFacts): CancelTarget | null {
48
+ if (facts.cosTurnInFlight === true) return 'cos_turn'
49
+ if (facts.runningOutsideCos !== true) return null
50
+ if (facts.provider !== 'claude') return 'unsupported'
51
+ if (facts.hooksEnabled !== true) return 'hooks_disabled'
52
+ if (facts.hooksReady !== true) return 'hooks_outdated'
53
+ return 'desk_run'
54
+ }
55
+
56
+ /**
57
+ * `/api/health` `features.sessionCancel` (6.53.0). `cosTurn` is true in every 6.53 build: the
58
+ * route aborts its own child. `deskClaude` says whether a desk Claude run can be stopped
59
+ * NOW: the hooks applied AND the 6.53 script and subscription installed. False until
60
+ * Install hooks after the update, which is the rollout step Control's banner asks for.
61
+ */
62
+ export function sessionCancelFeature(hooksEnabled: boolean, hooksInstalled: boolean): { cosTurn: true; deskClaude: boolean } {
63
+ return { cosTurn: true, deskClaude: hooksEnabled === true && hooksInstalled === true }
64
+ }
65
+
66
+ /** Refusal codes on the cancel route, with the lens's own words (app 6.9.529 matches). */
67
+ export type CancelRefusal = 'not_running' | 'cancel_unsupported' | 'hooks_outdated' | 'hooks_disabled' | 'cancel_failed' | 'invalid_request'
68
+
69
+ export const CANCEL_REASON_COPY: Record<Exclude<CancelRefusal, 'cancel_unsupported'>, string> = {
70
+ not_running: 'Nothing running now.',
71
+ hooks_outdated: 'Install hooks in COS Control, then retry.',
72
+ hooks_disabled: 'Session hooks are off on the Mac.',
73
+ cancel_failed: 'Could not cancel. Check the Mac.',
74
+ invalid_request: 'That cancel was not something COS could read. Nothing was stopped.',
75
+ }
76
+
77
+ /** Where an unsupported run lives, named so the user knows where to stop it. */
78
+ export function cancelUnsupportedCopy(provider: string): string {
79
+ if (provider === 'codex') return 'Runs in Codex on your Mac. Stop it there.'
80
+ if (provider === 'cursor') return 'Runs in Cursor on your Mac. Stop it there.'
81
+ return 'Runs on your Mac. Stop it there.'
82
+ }
83
+
84
+ export function cancelRefusalCopy(reason: CancelRefusal, provider: string): string {
85
+ return reason === 'cancel_unsupported' ? cancelUnsupportedCopy(provider) : CANCEL_REASON_COPY[reason]
86
+ }
87
+
88
+ /** Client-supplied idempotency key for one tap. The app mints `cc<time><random>`. */
89
+ export const CLIENT_CANCEL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{3,127}$/
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // The queue hold (plan validation: "Parked turns hold for 2 minutes after a cancel")
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /**
96
+ * How long turns parked on a thread wait after a cancel before they send as normal. The
97
+ * person just said stop; a queued follow-up firing a second later would start the run
98
+ * they stopped all over again. Two minutes is long enough to decide, short enough that
99
+ * a turn they still want is not lost, and the lens says how many are held.
100
+ */
101
+ export const CANCEL_QUEUE_HOLD_MS = 2 * 60_000
102
+
103
+ /** Past this a cancel time can no longer change a decision (the open-turn ceiling is 30 min). */
104
+ export const CANCEL_MEMORY_MS = 60 * 60_000
105
+ const MAX_REMEMBERED_CANCELS = 256
106
+
107
+ // In memory on purpose: every reader is a hold of minutes, and a restart that forgets
108
+ // 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>()
110
+ const cancelKey = (provider: string, threadId: string) => `${provider}:${threadId.toLowerCase()}`
111
+
112
+ export function noteThreadCancelled(provider: string, threadId: string, at: number): void {
113
+ if (!Number.isFinite(at)) return
114
+ 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)
120
+ }
121
+ while (cancelledAtByThread.size > MAX_REMEMBERED_CANCELS) {
122
+ const oldest = cancelledAtByThread.keys().next()
123
+ if (oldest.done) break
124
+ cancelledAtByThread.delete(oldest.value)
125
+ }
126
+ }
127
+ }
128
+
129
+ export function threadCancelledAt(provider: string, threadId: string): number | null {
130
+ return cancelledAtByThread.get(cancelKey(provider, threadId)) ?? null
131
+ }
132
+
133
+ /** Until when the thread's parked turns hold, or null when no cancel is on record. */
134
+ export function cancelHoldUntil(provider: string, threadId: string): number | null {
135
+ const at = threadCancelledAt(provider, threadId)
136
+ return at === null ? null : at + CANCEL_QUEUE_HOLD_MS
137
+ }
138
+
139
+ /**
140
+ * Does a cancel END the turn the transcript or the hooks still show open?
141
+ *
142
+ * Yes when the cancel is at least as new as the session's last row (or event): nothing
143
+ * has happened since the stop, so a dangling `tool_use` is the killed child's, not a live
144
+ * tool. Without this a cancelled COS turn's unanswered tool call reads `tool_pending` for
145
+ * the whole 30-minute open-turn ceiling and holds every parked turn behind it (plan R1).
146
+ * Anything written AFTER the cancel is live evidence again, and the ordinary rules decide.
147
+ */
148
+ export function cancelEndsTurn(cancelledAt: number | null, lastRowAt: number | null): boolean {
149
+ if (cancelledAt === null || !Number.isFinite(cancelledAt)) return false
150
+ if (lastRowAt === null || !Number.isFinite(lastRowAt)) return true
151
+ return cancelledAt >= lastRowAt
152
+ }
153
+
154
+ export function __resetThreadCancelsForTests(): void {
155
+ cancelledAtByThread.clear()
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // The ledger
160
+ // ---------------------------------------------------------------------------
161
+
162
+ export interface SessionCancelLedgerRow {
163
+ at: string
164
+ provider: string
165
+ threadId: string
166
+ /** What the cancel was aimed at, or null when nothing was running. */
167
+ target: CancelTarget | null
168
+ /** `accepted`, or the refusal code. */
169
+ outcome: 'accepted' | CancelRefusal
170
+ clientCancelId: string
171
+ }
172
+
173
+ export function sessionCancelLedgerPath(): string {
174
+ return dataPath('session-cancel.jsonl')
175
+ }
176
+
177
+ /**
178
+ * One line per cancel, appended (O_APPEND: a line either lands whole or not at all, as the
179
+ * other jsonl ledgers here). A failed write is logged and dropped: a ledger must never turn
180
+ * a cancel that worked into an error.
181
+ */
182
+ export function appendSessionCancelLedger(row: SessionCancelLedgerRow, path = sessionCancelLedgerPath()): void {
183
+ try {
184
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
185
+ appendFileSync(path, `${JSON.stringify(row)}\n`, { encoding: 'utf8', mode: 0o600 })
186
+ chmodSync(path, 0o600)
187
+ } catch (error) {
188
+ console.warn(`[session-cancel] ledger write skipped: ${error instanceof Error ? (error as NodeJS.ErrnoException).code ?? error.message : error}`)
189
+ }
190
+ }
@@ -0,0 +1,156 @@
1
+ // Halt markers: how a cancel from the lens stops a Claude run at the desk (6.53.0).
2
+ //
3
+ // Miles, 2026-09-21: "If there's a current run that's going, it essentially clicks the
4
+ // cancel button ... similar to when they're at the desktop."
5
+ //
6
+ // COS has no handle on a Desktop tab or a terminal `claude`: nothing it can signal, no
7
+ // socket that takes an interrupt. What it does have is the synchronous PreToolUse hook
8
+ // every Claude session on this Mac runs (`bin/hooks/cos-session-hook`). So a cancel is a
9
+ // FILE: `<data>/session-halt/<session id>`. The hook tests for it before every tool call
10
+ // and, when it is there, prints the deny plus `continue: false` that canary C1b proved
11
+ // stops the run. The run stops at its NEXT tool call; a reply that is pure text finishes
12
+ // first, and the lens copy says "at its next step" for that reason.
13
+ //
14
+ // WHERE. `dataPath('session-halt')`, beside the spool: the hook finds the folder as
15
+ // `$(dirname "$SPOOL")/session-halt`, exactly as it finds the Cursor queue folder, and the
16
+ // spool is `<data>/hook-spool` (claude-hooks-installer.ts `hookSpoolDir`). The two agree
17
+ // for the same reason the queue folder does.
18
+ //
19
+ // THE NAME IS THE LOWERCASE SESSION ID, validated. It becomes a path component here and a
20
+ // `[ -f ]` test in a shell script, and the script lowercases what it reads, so an upper-case
21
+ // file would never match and a malformed one must never be written at all.
22
+ //
23
+ // LIFETIME. A marker lives only within the run it was written for:
24
+ // - the hook deletes it itself on UserPromptSubmit (a new desk prompt, a Continue
25
+ // delivered into the open session, and a COS `-p` turn all fire it), so a new prompt
26
+ // is never stopped;
27
+ // - the server deletes it on that session's SessionEnd (session-hooks-runtime.ts);
28
+ // - a sweep deletes anything older than HALT_MARKER_TTL_MS.
29
+ // It is NOT deleted on Stop: a background subagent reports its parent's session id (C8),
30
+ // and it is exactly the work a cancel must also stop.
31
+
32
+ import { mkdirSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs'
33
+ import { join } from 'node:path'
34
+ import { atomicWriteFileSync } from './atomic-fs.js'
35
+ import { dataPath } from './data-dir.js'
36
+ import { isValidNativeThreadId } from './native-thread-id.js'
37
+
38
+ /**
39
+ * How long a marker may outlive its cancel. An hour, not the plan's first 15 minutes: the
40
+ * hook deliberately ignores age (a long build must still be cancellable), so this sweep is
41
+ * the only thing that ends a marker whose session neither prompted again nor ended.
42
+ */
43
+ export const HALT_MARKER_TTL_MS = 60 * 60_000
44
+
45
+ export interface HaltMarker {
46
+ at: number
47
+ clientCancelId: string
48
+ }
49
+
50
+ export function haltDir(): string {
51
+ return dataPath('session-halt')
52
+ }
53
+
54
+ /** The marker's path, or null for anything that is not a full native session id. */
55
+ export function haltMarkerPath(sessionId: string, dir = haltDir()): string | null {
56
+ const id = typeof sessionId === 'string' ? sessionId.toLowerCase() : ''
57
+ return isValidNativeThreadId(id) ? join(dir, id) : null
58
+ }
59
+
60
+ /**
61
+ * Write (or refresh) the marker for one session. Atomic: the hook must never read a torn
62
+ * file, though it only tests existence. False when the id is not a session id or the write
63
+ * failed; the caller must then not report the cancel as accepted.
64
+ */
65
+ export function writeHaltMarker(sessionId: string, marker: HaltMarker, dir = haltDir()): boolean {
66
+ const path = haltMarkerPath(sessionId, dir)
67
+ if (path === null) return false
68
+ try {
69
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
70
+ atomicWriteFileSync(path, `${JSON.stringify({ at: marker.at, clientCancelId: marker.clientCancelId })}\n`, { mode: 0o600 })
71
+ return true
72
+ } catch (error) {
73
+ console.error(`[session-halt] write failed: ${error instanceof Error ? (error as NodeJS.ErrnoException).code ?? error.message : error}`)
74
+ return false
75
+ }
76
+ }
77
+
78
+ /** True when a marker was removed. A missing one is not an error. */
79
+ export function clearHaltMarker(sessionId: string, dir = haltDir()): boolean {
80
+ const path = haltMarkerPath(sessionId, dir)
81
+ if (path === null) return false
82
+ try {
83
+ unlinkSync(path)
84
+ return true
85
+ } catch {
86
+ return false
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Clear the marker because the SESSION ended, but only a marker written at or before the
92
+ * event. A SessionEnd drained late from the spool must not remove a cancel written after
93
+ * it for the same session id (a resumed tab reuses its id).
94
+ */
95
+ export function clearHaltMarkerOnSessionEnd(sessionId: string, endedAt: number, dir = haltDir()): boolean {
96
+ const path = haltMarkerPath(sessionId, dir)
97
+ if (path === null) return false
98
+ const at = markerAt(path)
99
+ if (at === null || !Number.isFinite(endedAt) || at > endedAt) return false
100
+ try {
101
+ unlinkSync(path)
102
+ return true
103
+ } catch {
104
+ return false
105
+ }
106
+ }
107
+
108
+ export function hasHaltMarker(sessionId: string, dir = haltDir()): boolean {
109
+ const path = haltMarkerPath(sessionId, dir)
110
+ if (path === null) return false
111
+ try {
112
+ return statSync(path).isFile()
113
+ } catch {
114
+ return false
115
+ }
116
+ }
117
+
118
+ /** The marker's own `at`, else the file's mtime; null when neither can be read. */
119
+ function markerAt(path: string): number | null {
120
+ try {
121
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as { at?: unknown }
122
+ if (typeof parsed.at === 'number' && Number.isFinite(parsed.at)) return parsed.at
123
+ } catch { /* fall back to the clock the filesystem kept */ }
124
+ try {
125
+ return statSync(path).mtimeMs
126
+ } catch {
127
+ return null
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Remove every marker older than `ttlMs`, and any leftover the atomic write abandoned.
133
+ * Returns how many were removed. A marker dated in the future (a clock step) is judged by
134
+ * its mtime instead, so it cannot live for ever.
135
+ */
136
+ export function sweepHaltMarkers(now: number, ttlMs = HALT_MARKER_TTL_MS, dir = haltDir()): number {
137
+ let names: string[]
138
+ try { names = readdirSync(dir) } catch { return 0 }
139
+ let removed = 0
140
+ for (const name of names) {
141
+ const path = join(dir, name)
142
+ let at = markerAt(path)
143
+ if (at !== null && at > now) {
144
+ try { at = statSync(path).mtimeMs } catch { at = null }
145
+ }
146
+ // A name the server would never write (a `.tmp` from a crashed write, anything else)
147
+ // is removed on sight: nothing reads it, and the hook's glob would keep forking for it.
148
+ const foreign = !isValidNativeThreadId(name)
149
+ if (!foreign && at !== null && now - at < ttlMs) continue
150
+ try {
151
+ unlinkSync(path)
152
+ removed++
153
+ } catch { /* already gone, or a directory: leave it */ }
154
+ }
155
+ return removed
156
+ }
@@ -16,7 +16,8 @@ import { dataPath } from './data-dir.js'
16
16
  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
- import { deriveSessionState, type DerivedSessionState, type RegistryFacts, type TranscriptFacts } from './session-state-derive.js'
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
21
  import { ensureHookRuntimeFiles, ensureStableHookScript, hookSpoolDir, hookStatus, type HookStatus } from './claude-hooks-installer.js'
21
22
 
22
23
  export function sessionHooksEnabled(): boolean {
@@ -162,6 +163,35 @@ export function registryIdleAfterStop(sessionId: string, stopAt: number, dir = c
162
163
  return record.status === 'idle' && typeof record.statusUpdatedAt === 'number' && record.statusUpdatedAt >= stopAt
163
164
  }
164
165
 
166
+ /** `kill(pid, 0)`: EPERM is a live process we may not signal; only ESRCH is gone. */
167
+ function pidAlive(pid: number): boolean {
168
+ try {
169
+ process.kill(pid, 0)
170
+ return true
171
+ } catch (error) {
172
+ return (error as NodeJS.ErrnoException).code === 'EPERM'
173
+ }
174
+ }
175
+
176
+ /**
177
+ * 6.53.0: is Claude working on this session AT THE DESK right now (a Desktop tab, a terminal,
178
+ * or a Continue delivered live into either)? The cancel route's `deskRunning`: a yes offers
179
+ * `desk_run`, which arms a halt marker. Two witnesses, either one is enough:
180
+ * - the hooks: the turn is open, the session has not ended, and the newest event is inside
181
+ * the open-turn ceiling (a tab that died mid-turn must not read running for ever);
182
+ * - the registry: the session's own record (never a COS child's: that run is the route's
183
+ * own in-flight turn) says `busy`, and its pid is alive.
184
+ * Canary C9 (2026-09-21): after a hook stop the registry read idle within a second, so a
185
+ * cancelled desk run stops reading as running at once.
186
+ */
187
+ export function claudeDeskRunning(sessionId: string, now = Date.now(), dir = claudeSessionsDir()): boolean {
188
+ const signal = signalFor(sessionId)
189
+ if (signal && signal.turnOpen && signal.ended === null && now - signal.lastEventAt <= OPEN_TURN_CEILING_MS) return true
190
+ const record = registryRecordSync(sessionId, dir)
191
+ if (!record || record.status !== 'busy' || record.pid === null || isCosSpawnedPid(record.pid, now)) return false
192
+ return pidAlive(record.pid)
193
+ }
194
+
165
195
  let ledger: SessionHookLedger | null = null
166
196
  let ingester: SpoolIngester | null = null
167
197
  let replayed: { rows: number; applied: number } | null = null
@@ -197,6 +227,14 @@ export function startSessionHooksRuntime(options: { port: number }): SessionHook
197
227
  if (entrypoint) sessionSignalStore.setEntrypoint(env.sessionId, entrypoint)
198
228
  } : undefined,
199
229
  })
230
+ // 6.53.0: a halt marker dies with its session. SessionEnd only, never Stop: a background
231
+ // subagent reports its parent's session id (canary C8) and is work a cancel must stop.
232
+ // Subscribed AFTER the replay above, so an old SessionEnd in the replay window cannot
233
+ // clear a cancel written since; the TTL sweep covers anything this misses.
234
+ const unsubscribeHalt = enabled ? sessionSignalStore.subscribe((_signal, env, child) => {
235
+ if (env.event === 'SessionEnd' && !child) clearHaltMarkerOnSessionEnd(env.sessionId, env.ts)
236
+ }) : () => {}
237
+ sweepHaltMarkers(Date.now())
200
238
  // Runtime files the script reads. The port can change per install; the token never does.
201
239
  // The script is copied only when MISSING here: a boot must never downgrade what a newer
202
240
  // `--hooks install` put at the stable path.
@@ -206,12 +244,17 @@ export function startSessionHooksRuntime(options: { port: number }): SessionHook
206
244
  } catch (error) {
207
245
  console.error(`[session-hooks] runtime files: ${error instanceof Error ? error.message : error}`)
208
246
  }
209
- const pruneTimer = setInterval(() => { sessionSignalStore.prune() }, 10 * 60_000)
247
+ // The same ten-minute tick sweeps halt markers past their hour (6.53.0).
248
+ const pruneTimer = setInterval(() => {
249
+ sessionSignalStore.prune()
250
+ sweepHaltMarkers(Date.now())
251
+ }, 10 * 60_000)
210
252
  pruneTimer.unref()
211
253
  return {
212
254
  store: sessionSignalStore,
213
255
  stop() {
214
256
  clearInterval(pruneTimer)
257
+ unsubscribeHalt()
215
258
  ingester?.stop()
216
259
  },
217
260
  }
@@ -201,6 +201,12 @@ export interface DrainObservation {
201
201
  /** The gate's reason when `attachable` is false. Carried ONLY so a fence -- the one
202
202
  * hold a clock cannot end -- can be given a longer life than a busy thread. */
203
203
  reason?: string | null
204
+ /**
205
+ * 6.53.0: until when a cancel on this thread holds its parked turns (epoch ms), or
206
+ * null. The person just said stop; a follow-up parked behind the run must not start it
207
+ * again a second later. It holds, then sends as normal (CANCEL_QUEUE_HOLD_MS, 2 min).
208
+ */
209
+ cancelHoldUntil?: number | null
204
210
  }
205
211
 
206
212
  export type DrainDecision = 'deliver' | 'hold' | 'expire' | 'give_up'
@@ -225,6 +231,9 @@ export function drainDecision(
225
231
  const effectiveTtl = seen.reason === 'native_target_fenced' ? FENCE_HELD_TURN_TTL_MS : ttlMs
226
232
  if (now - turn.queuedAt >= effectiveTtl) return 'expire'
227
233
  if (turn.attempts >= MAX_DELIVERY_ATTEMPTS) return 'give_up'
234
+ // 6.53.0: AFTER the two retirements, so a hold can never keep alive a turn that ran out
235
+ // of time or tries; BEFORE the gate, because it is a hold whatever the gate says.
236
+ if (typeof seen.cancelHoldUntil === 'number' && Number.isFinite(seen.cancelHoldUntil) && now < seen.cancelHoldUntil) return 'hold'
228
237
  // THE GATE, unweakened. Everything below is about WHEN, never about whether.
229
238
  if (!seen.attachable) return 'hold'
230
239
  // Turn-ended is the precise signal; idle is the backstop for a holder that wrote no