@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.
@@ -284,6 +284,64 @@ 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. 6.53.3
289
+ * rewrote the script for speed (review A, option a), so an install that has not run Install
290
+ * hooks since reads `script_outdated` and Control shows its banner. Those scripts still stop
291
+ * a desk run exactly as before, though, so a desk cancel is not refused `hooks_outdated` in
292
+ * the meantime (`hookHaltReady`). A script older than 6.53.0 has no halt check and never
293
+ * qualifies.
294
+ * - 1158bb06...: the one script 6.53.0, 6.53.1 and 6.53.2 all shipped.
295
+ * - c0b41bf8...: the 6.53.3 script as first built, before its /qa round made every event
296
+ * drain stdin. Never published, but installable from a local build of that commit
297
+ * (7433b7f), and it carries the same halt check.
298
+ * Each is pinned against its bytes in `server/lib/__fixtures__/`.
299
+ */
300
+ export const HALT_CAPABLE_PRIOR_SCRIPT_SHAS: readonly string[] = [
301
+ '1158bb06297550128f01fc47caa68806a5287d6da2d05b0d1c812e8ae20764e7',
302
+ 'c0b41bf8581cfea498e1e1ac5220fe4e148bd97448d13b60a88879ee5992cc51',
303
+ ]
304
+
305
+ /**
306
+ * 6.53.3: can the hooks on this Mac stop a desk Claude run now? `installed`, or the only
307
+ * difference is a halt-capable earlier script (above). What the cancel route's
308
+ * `hooksReady` and `/api/health` `features.sessionCancel.deskClaude` read.
309
+ */
310
+ export function hookHaltReady(status: Pick<HookStatus, 'installed' | 'state' | 'scriptSha'>): boolean {
311
+ if (status.installed === true) return true
312
+ return status.state === 'script_outdated' && typeof status.scriptSha === 'string'
313
+ && HALT_CAPABLE_PRIOR_SCRIPT_SHAS.includes(status.scriptSha)
314
+ }
315
+
316
+ const INSTALL_COMMAND = 'npx --yes @gotcos/glasses-server@latest --hooks install'
317
+
318
+ /**
319
+ * 6.53.3: one plain sentence for `--hooks status` saying what the state means and what fixes
320
+ * it. Null when installed. Carries no path: the status JSON beside it already names them.
321
+ */
322
+ export function hookStatusAdvice(status: Pick<HookStatus, 'installed' | 'state' | 'scriptSha' | 'packageScriptSha'>): string | null {
323
+ const short = (sha: string | null): string => (sha ? sha.slice(0, 12) : 'none')
324
+ switch (status.state) {
325
+ case 'installed':
326
+ return null
327
+ case 'script_outdated':
328
+ return `The installed hook script is older than this package's (installed ${short(status.scriptSha)}, package ${short(status.packageScriptSha)}). `
329
+ + 'Every Claude session keeps running the old script until the hooks are reinstalled: '
330
+ + `run \`${INSTALL_COMMAND}\`, or press Install hooks in COS Control. `
331
+ + (hookHaltReady(status)
332
+ ? 'Cancel from the lens still stops desk runs with the installed script meanwhile.'
333
+ : 'Until then a desk run cannot be cancelled from the lens.')
334
+ case 'drift':
335
+ case 'missing':
336
+ return `The hook subscriptions in the Claude settings file are ${status.state === 'missing' ? 'not installed' : 'not the ones this package installs'}. `
337
+ + `Run \`${INSTALL_COMMAND}\`, or press Install hooks in COS Control.`
338
+ case 'disabled_by_settings':
339
+ return 'The Claude settings file sets disableAllHooks: no hook runs, installed or not.'
340
+ default:
341
+ return 'The Claude settings file could not be read as it is; nothing was changed. Fix or restore the file, then install again.'
342
+ }
343
+ }
344
+
287
345
  export interface InstallOptions {
288
346
  settingsPath?: string
289
347
  scriptPath?: string
@@ -27,6 +27,9 @@
27
27
  // human deciding whether to release; an over-confident `none_running` is worse than
28
28
  // admitting the probe could not see.
29
29
 
30
+ import { execFileSync } from 'node:child_process'
31
+ import { existsSync } from 'node:fs'
32
+
30
33
  /** Recorded spawn: a pid and the start COS measured when it created the child. */
31
34
  export interface RecordedSpawn {
32
35
  pid: number
@@ -55,7 +58,7 @@ export interface FenceLiveness {
55
58
  }
56
59
 
57
60
  export interface FenceLivenessDeps {
58
- /** Epoch ms at which `pid` started, or null when it is not running.
61
+ /** Epoch ms at which `pid` started, or null ONLY when it is provably not running.
59
62
  * MAY THROW; a throwing probe is not an answer and resolves to `unknown`. */
60
63
  pidStartMs: (pid: number) => number | null
61
64
  }
@@ -116,7 +119,9 @@ export function makePidStartProbe(
116
119
  const raw = run(pid)
117
120
  if (raw === null) return null
118
121
  const text = raw.trim()
119
- if (!text) return null
122
+ // A clean exit that printed nothing is not "not running" (only the runner's null is):
123
+ // throw, so it is counted unverifiable.
124
+ if (!text) throw new Error('empty process start from ps')
120
125
  const parsed = Date.parse(text)
121
126
  // Unparseable is NOT "not running" -- throw so the caller counts it
122
127
  // unverifiable rather than silently reading a live child as gone.
@@ -124,3 +129,72 @@ export function makePidStartProbe(
124
129
  return parsed
125
130
  }
126
131
  }
132
+
133
+ /** What this module reads off the error `execFileSync` throws. */
134
+ export interface ExecFailureLike {
135
+ status?: unknown
136
+ signal?: unknown
137
+ killed?: unknown
138
+ code?: unknown
139
+ error?: unknown
140
+ stdout?: unknown
141
+ stderr?: unknown
142
+ }
143
+
144
+ /** The slice of `execFileSync` the probe calls. Injectable so a test can throw every error
145
+ * shape Node produces without spawning anything. */
146
+ export type PsExec = (file: string, args: readonly string[], options: { encoding: 'utf8'; timeout: number }) => string
147
+
148
+ /** How long one `ps` may take before Node kills it (the kill is NOT an answer). */
149
+ export const PS_PROBE_TIMEOUT_MS = 2_000
150
+
151
+ const PS_BIN = existsSync('/bin/ps') ? '/bin/ps' : 'ps'
152
+
153
+ const emptyOutput = (value: unknown): boolean =>
154
+ (typeof value === 'string' && value.trim() === '') || (Buffer.isBuffer(value) && value.length === 0)
155
+
156
+ /**
157
+ * 6.53.3 /qa BLOCKER: is this `execFileSync` failure `ps -p` saying "no such process"?
158
+ *
159
+ * THE ONE ANSWER THAT MEANS GONE. `ps -p <pid>` for a pid that is not running exits 1 on its
160
+ * own and prints nothing on either stream (measured, macOS 26 and node 24: `{status: 1,
161
+ * signal: null, stdout: '', stderr: ''}` with no `code` and no `error`). Everything else is
162
+ * ps failing to answer, and says nothing about the child: a kill at the timeout (`ETIMEDOUT`,
163
+ * SIGTERM), a fork that failed (`EAGAIN`), no binary (`ENOENT`), a signal, output on stdout or
164
+ * a complaint on stderr, any other status. Until 6.53.3's QA every one of those read as gone,
165
+ * and gone is `none_running`, which releases a cancel fence (`releaseSettledCancelFences`) on a
166
+ * child that may still be writing.
167
+ */
168
+ export function psSaysNoSuchProcess(error: unknown): boolean {
169
+ if (error === null || typeof error !== 'object') return false
170
+ const e = error as ExecFailureLike
171
+ return e.status === 1
172
+ && (e.signal === null || e.signal === undefined)
173
+ && e.killed !== true
174
+ && (e.code === null || e.code === undefined)
175
+ && (e.error === null || e.error === undefined)
176
+ && emptyOutput(e.stdout)
177
+ && emptyOutput(e.stderr)
178
+ }
179
+
180
+ /**
181
+ * The production runner: `ps -p <pid> -o lstart=`'s stdout, or null ONLY when ps said the
182
+ * process does not exist (`psSaysNoSuchProcess`). Any other failure THROWS, which
183
+ * `fenceLiveness` counts unverifiable, so the fence stays.
184
+ */
185
+ export function psLstartRunner(exec: PsExec = execFileSync as unknown as PsExec): (pid: number) => string | null {
186
+ return (pid: number) => {
187
+ try {
188
+ return exec(PS_BIN, ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8', timeout: PS_PROBE_TIMEOUT_MS })
189
+ } catch (error) {
190
+ if (psSaysNoSuchProcess(error)) return null
191
+ const e = (error ?? {}) as ExecFailureLike
192
+ throw new Error(`ps could not answer for pid ${pid} (status=${String(e.status ?? null)} signal=${String(e.signal ?? null)} code=${String(e.code ?? 'none')})`)
193
+ }
194
+ }
195
+ }
196
+
197
+ /** The probe the fence routes use in production. */
198
+ export function defaultPidStartProbe(exec?: PsExec): (pid: number) => number | null {
199
+ return makePidStartProbe(psLstartRunner(exec))
200
+ }
@@ -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,12 +28,17 @@
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'
34
38
  import { atomicWriteFileSync } from './atomic-fs.js'
35
39
  import { dataPath } from './data-dir.js'
36
40
  import { isValidNativeThreadId } from './native-thread-id.js'
41
+ import { unwrapPeerMessage } from './session-stream-events.js'
37
42
 
38
43
  /**
39
44
  * How long a marker may outlive its cancel. An hour, not the plan's first 15 minutes: the
@@ -42,6 +47,14 @@ import { isValidNativeThreadId } from './native-thread-id.js'
42
47
  */
43
48
  export const HALT_MARKER_TTL_MS = 60 * 60_000
44
49
 
50
+ /**
51
+ * 6.53.3 /qa: how long a re-arm waits when the hand-off was NOT verified (`maybe`: the frame
52
+ * may or may not have reached the session). A verified hand-off keeps the marker's hour; an
53
+ * unverified one that has not surfaced as a prompt within two minutes most likely never
54
+ * arrived, and holding it longer only widens the window for an identical desk prompt.
55
+ */
56
+ export const HALT_REARM_UNVERIFIED_MS = 2 * 60_000
57
+
45
58
  export interface HaltMarker {
46
59
  at: number
47
60
  clientCancelId: string
@@ -105,6 +118,134 @@ export function clearHaltMarkerOnSessionEnd(sessionId: string, endedAt: number,
105
118
  }
106
119
  }
107
120
 
121
+ // ---------------------------------------------------------------------------
122
+ // 6.53.3 (review A6): a cancel that landed while COS was HANDING a turn to the open session
123
+ // ---------------------------------------------------------------------------
124
+ //
125
+ // The live hop writes the turn into the running session's inbox, where it waits its turn
126
+ // (a busy session) or starts at once (an idle one). Once it has landed, the only stop is
127
+ // this marker. But the hook deletes the marker on UserPromptSubmit, and the delivered
128
+ // turn's OWN UserPromptSubmit may come after the marker is written: a busy session takes
129
+ // the message only when its current run ends (which the marker itself brings about). A
130
+ // plain write would then be deleted by the very turn it was written to stop.
131
+ //
132
+ // So the marker is written, and when the delivered turn has not visibly started yet, it is
133
+ // written AGAIN, once, on the first UserPromptSubmit for that session AFTER the send, if that
134
+ // prompt IS the delivered one. The window is the marker's own hour (two minutes for an
135
+ // unverified hand-off); SessionEnd drops it. In memory on purpose: a restart forgets it, like
136
+ // every other hold here.
137
+ //
138
+ // 6.53.3 /qa (Skeptic W1): the first cut matched `prompt.includes(<first 120 characters>)`.
139
+ // A short COS prompt ("yes") is inside most desk prompts, so the person's own next prompt
140
+ // re-armed the marker: THEIR run was denied "Cancelled from COS", and the delivered turn's
141
+ // own prompt, finding nothing pending, then ran the cancelled turn. Now:
142
+ // - EXACT: the delivered words and the prompt's words are equal once whitespace runs are
143
+ // collapsed and Claude Code's peer wrapper ("Another Claude session sent a message: ...",
144
+ // which a live Continue arrives in) is taken off. The frame carries no id of its own and
145
+ // its text is not changed to add one.
146
+ // - FIRST PROMPT DECIDES: the first prompt after the send spends the re-arm whether or not
147
+ // it matches. A non-match is logged (never the prompt). This gives up the cancel when
148
+ // the person's own prompt reaches the session first, rather than risk halting their run:
149
+ // a halted desk run is the worse error, and the lens already said "at its next step".
150
+ // - LIMIT, stated: a desk prompt with exactly the delivered words, typed after the send and
151
+ // taken first, is indistinguishable from the delivered turn and re-arms.
152
+
153
+ interface PendingRearm {
154
+ marker: HaltMarker
155
+ /** The delivered prompt's words, whitespace-normalised (`normalizePromptWords`). */
156
+ words: string
157
+ /** Prompts submitted before this are someone else's. */
158
+ after: number
159
+ until: number
160
+ }
161
+
162
+ const pendingRearms = new Map<string, PendingRearm>()
163
+ const MAX_PENDING_REARMS = 64
164
+
165
+ /**
166
+ * Write the marker for a turn COS just put into the open session. `rearm` is false when
167
+ * the turn has visibly started already (its UserPromptSubmit ran before this write, so
168
+ * nothing will delete the marker but the next prompt). False when the write failed; the
169
+ * caller must then not report the cancel as armed.
170
+ */
171
+ export function haltDeliveredTurn(
172
+ sessionId: string,
173
+ marker: HaltMarker,
174
+ turn: { prompt: string; after: number; rearm: boolean; now?: number; windowMs?: number },
175
+ dir = haltDir(),
176
+ ): boolean {
177
+ if (!writeHaltMarker(sessionId, marker, dir)) return false
178
+ const id = sessionId.toLowerCase()
179
+ const words = normalizePromptWords(turn.prompt)
180
+ if (!turn.rearm || words.length === 0) {
181
+ pendingRearms.delete(id)
182
+ return true
183
+ }
184
+ const now = turn.now ?? Date.now()
185
+ const windowMs = typeof turn.windowMs === 'number' && Number.isFinite(turn.windowMs) && turn.windowMs > 0
186
+ ? Math.min(turn.windowMs, HALT_MARKER_TTL_MS)
187
+ : HALT_MARKER_TTL_MS
188
+ pendingRearms.delete(id)
189
+ pendingRearms.set(id, { marker, words, after: turn.after, until: now + windowMs })
190
+ while (pendingRearms.size > MAX_PENDING_REARMS) {
191
+ const oldest = pendingRearms.keys().next()
192
+ if (oldest.done) break
193
+ pendingRearms.delete(oldest.value)
194
+ }
195
+ return true
196
+ }
197
+
198
+ /** Whitespace runs collapsed to one space, ends trimmed. Non-strings are empty. */
199
+ export function normalizePromptWords(text: unknown): string {
200
+ return typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : ''
201
+ }
202
+
203
+ /**
204
+ * Is `prompt` (a UserPromptSubmit's `prompt`) the delivered prompt? Equal words, compared
205
+ * whole, after taking off Claude Code's peer wrapper when the prompt arrived in one.
206
+ */
207
+ export function isDeliveredPrompt(prompt: unknown, delivered: string): boolean {
208
+ const want = normalizePromptWords(delivered)
209
+ if (want.length === 0 || typeof prompt !== 'string') return false
210
+ return normalizePromptWords(unwrapPeerMessage(prompt) ?? prompt) === want
211
+ }
212
+
213
+ /**
214
+ * A UserPromptSubmit from the session itself (never a COS child): the hook has just deleted
215
+ * the session's marker. The FIRST such prompt after the send spends the pending re-arm: when
216
+ * it is the delivered turn's own prompt the marker is written back, otherwise the re-arm is
217
+ * dropped and logged. True when the marker was re-armed.
218
+ */
219
+ export function rearmHaltOnPrompt(sessionId: string, promptAt: number, prompt: unknown, dir = haltDir(), now = Date.now()): boolean {
220
+ const id = typeof sessionId === 'string' ? sessionId.toLowerCase() : ''
221
+ const pending = pendingRearms.get(id)
222
+ if (!pending) return false
223
+ if (now > pending.until) {
224
+ pendingRearms.delete(id)
225
+ return false
226
+ }
227
+ if (!Number.isFinite(promptAt) || promptAt < pending.after) return false
228
+ pendingRearms.delete(id)
229
+ if (!isDeliveredPrompt(prompt, pending.words)) {
230
+ console.warn(`[session-halt] re-arm dropped: the first prompt after the hand-off was not the delivered turn session=${id.slice(0, 8)} clientCancelId=${pending.marker.clientCancelId}`)
231
+ return false
232
+ }
233
+ return writeHaltMarker(id, pending.marker, dir)
234
+ }
235
+
236
+ /** The session ended: nothing is left to stop. */
237
+ export function dropHaltRearm(sessionId: string): void {
238
+ if (typeof sessionId === 'string') pendingRearms.delete(sessionId.toLowerCase())
239
+ }
240
+
241
+ export function hasPendingHaltRearm(sessionId: string): boolean {
242
+ return typeof sessionId === 'string' && pendingRearms.has(sessionId.toLowerCase())
243
+ }
244
+
245
+ export function __resetHaltRearmsForTests(): void {
246
+ pendingRearms.clear()
247
+ }
248
+
108
249
  export function hasHaltMarker(sessionId: string, dir = haltDir()): boolean {
109
250
  const path = haltMarkerPath(sessionId, dir)
110
251
  if (path === null) return false