@gotcos/glasses-server 6.53.3 → 6.53.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## 6.53.4
2
+
3
+ ### The fixes from 6.53.3's review
4
+
5
+ 6.53.3 was published before its four-reviewer /qa round finished (the round was cut short by a usage limit and rerun the next morning). This release carries that round's server fixes, and corrects three claims 6.53.3 made. Install hooks once after updating.
6
+
7
+ - **A `ps` that fails never releases a fence (blocker in 6.53.3).** 6.53.3's fence auto-release read every `execFileSync` error as "not running", so a `ps` killed at its 2 s timeout, an `EAGAIN` fork failure or a missing binary could release a cancel fence whose child might still be writing. Only the measured "no such process" answer (exit 1, no signal, not killed, no error code, nothing on stdout or stderr) counts as gone now; everything else, and a clean exit that printed nothing, is unverifiable and keeps the fence. `ps` is `/bin/ps` when present. A fence that reads unknown is re-checked on every fence check and can still be released by hand in COS Control.
8
+ - **The halt re-arm matches the delivered prompt exactly.** 6.53.3 matched the first 120 characters with `includes`, so a short Continue ("yes") matched the person's own next desk prompt: their run was stopped "Cancelled from COS" and the cancelled COS turn then ran. Now the words must be equal (whitespace collapsed, Claude Code's peer-message wrapper taken off), and the first prompt after the hand-off decides: a different prompt spends the re-arm and is logged. Stated limit: if the person's own prompt reaches the session first, the queued COS turn is not stopped; an identical desk prompt typed after the send and taken first still re-arms. An unverified hand-off holds its re-arm 2 minutes, not an hour.
9
+ - **The hook drains stdin on every event.** PermissionRequest, Stop, PostToolUse, SessionStart, SessionEnd and the other non-front events read only 1 MiB and left the rest unread, and the spool guard exited before reading; a larger payload could EPIPE the writer. The header comment no longer says the PreToolUse branch writes no spool. Script sha256 `df54677f...`; the 6.53.0 script (`1158bb06...`) and 6.53.3's (`c0b41bf8...`) still stop desk runs until Install hooks is run. The PreToolUse common path is unchanged (40 runs each, interleaved: 11.2 and 10.9 ms before, 11.4 and 11.0 ms after).
10
+ - **The mutation gate needs a failing test.** A kill was any non-zero vitest exit; now a mutant is killed only when a named test fails, and a run that errors without one fails the gate. Every 6.53.x case was re-run under that rule on a green baseline: 137 of 137 killed by a failing test (16 added in this round), none errored.
11
+ - **Every server suite removes its temp folders.** 39 test files made `mkdtemp` folders and never removed them (one run left 581 entries in `$TMPDIR`; now none made by a test). 6.53.3's "hook and installer suites clean up their temp roots" was true only for three suites.
12
+ - **Corrections to 6.53.3's notes.** "`reaped: true` is never reported while an owned process survives" holds for cancel and timeout only: a natural exit settles as soon as the CLI is reaped, with no tree probe. The graceful end-to-end bound is now derived from the fixture's stop delay and `TREE_POLL_MS` (2.4 s, was 4 s). The closed-session test imports its retention constant. README gains a "Cancel a run from the glasses (6.53.x)" section.
13
+ - Tests: full suite 5,128 passed, 2 skipped; typecheck clean; `npm audit --omit=dev` 0 vulnerabilities.
14
+
1
15
  ## 6.53.3
2
16
 
3
17
  ### Cancel owns the whole process tree, desk cancels hold, and the hook is faster
package/README.md CHANGED
@@ -265,6 +265,41 @@ Allow once or deny only: no permission rule is ever written. Rows carry
265
265
  row reads `waiting_kind: question` whatever the switch says; `/api/health` reports
266
266
  `permissionBroker` (counters and timestamps, no ids or text).
267
267
 
268
+ ## Cancel a run from the glasses (6.53.x)
269
+
270
+ The glasses' Cancel run row stops what a session is doing, the way the desk's own stop
271
+ button does. It needs **server 6.53.0 or later and COS Glasses 6.9.529 or later**;
272
+ **6.53.4 is recommended** (it owns the whole process tree, holds a desk cancel, fixes
273
+ the false fences 6.53.1 left, and never releases a fence when `ps` cannot answer).
274
+
275
+ - **A turn COS started** (`cos_turn`) is stopped by the server: its whole process tree is
276
+ signalled and the turn settles `turn_cancelled`.
277
+ - **A run at the desk** (`desk_run`: a Desktop tab or a terminal `claude`) is stopped by the
278
+ session hook at its next tool call, so a reply that is pure text finishes first. This
279
+ needs the hooks installed, and after every server update that changes the hook script
280
+ (6.53.0, 6.53.3, 6.53.4) you must **run Install hooks once** (COS Control, or
281
+ `npx --yes @gotcos/glasses-server@latest --hooks install`). Until then
282
+ `--hooks status` reads `script_outdated` and its `advice` line says so; a script from
283
+ 6.53.0 to 6.53.3 can still stop desk runs meanwhile, anything older answers
284
+ `hooks_outdated`. The PreToolUse hook runs before every tool call on the Mac and never
285
+ contacts the server; it writes a spool file only for AskUserQuestion, ExitPlanMode and
286
+ prompts, and always drains its input.
287
+ - **Codex and Cursor** runs at the desk answer `cancel_unsupported` ("Stop it there").
288
+ - **Fences a cancel left release themselves** once every process the cancelled turn
289
+ started is confirmed gone: only `ps` answering "no such process" counts, and a `ps` that
290
+ times out, cannot start or is missing keeps the fence. Any other fence is still released
291
+ by hand (`GET /api/agent-sessions/fences`, then `POST .../fences/release` with
292
+ `confirm: true`).
293
+ - **A cancel that lands while COS is handing a turn to the open session** writes the desk
294
+ marker, and re-arms it once if the next prompt that session starts is exactly the
295
+ delivered one. If your own prompt reaches the session first, the re-arm is dropped (and
296
+ logged) rather than stop your run.
297
+
298
+ Meetings: a closed meeting's record ages out after **4 hours**, after which its status
299
+ reads `missing` and the phone stops asking to save it. Saving a known meeting that has no
300
+ transcript answers **404 `nothing_to_save`** (with its state); only an unknown id answers
301
+ `session_not_found`.
302
+
268
303
  ## Configuration
269
304
 
270
305
  Config lives at `~/.cos-glasses/.env` (created on first run). Every key is
@@ -626,7 +661,8 @@ BIND_HOST=0.0.0.0 npm run start:server
626
661
  timers are frozen), `desk_active` (the Mac saw input), `approvals_off`
627
662
  (`COS_PERMISSION_BROKER=questions`), `broker_off`, `cursor`, `unsupported_tool`
628
663
  (ExitPlanMode is never held). `lastQuestionsPollAt` says when a client last counted.
629
- `--hooks status` must read `installed`. To turn it off with no reinstall, set
664
+ `--hooks status` must read `installed` (right after updating to 6.53.4 it reads
665
+ `script_outdated` until Install hooks is run once; its `advice` line says so). To turn it off with no reinstall, set
630
666
  `COS_PERMISSION_BROKER=0` in `~/.cos-glasses/.env` and restart. For the Messages
631
667
  trail, `/api/health` `messages_trail.readerErrors` counts lines a reader could not map.
632
668
  - *Offline meeting recovery unavailable?* — build 209+ requires server 6.11.0+.
@@ -7,11 +7,15 @@
7
7
  # restarting never delays a Claude session.
8
8
  #
9
9
  # 6.53.0: PreToolUse is SYNCHRONOUS with no matcher, so the first branch below runs before
10
- # EVERY tool call in every Claude session on this Mac. It is file-only (no curl, no spool
11
- # write) and prints nothing unless the server left a halt marker for this session, when it
12
- # prints the one reply that stops the run (canary C1b, 2026-09-21: `continue:false` alone
13
- # still ran the tool; with the deny it did not). It never exits 2: exit 2 blocks the tool,
14
- # while a timeout or any other code fails open (C6, C7).
10
+ # EVERY tool call in every Claude session on this Mac. It never contacts the server. For an
11
+ # ordinary tool it writes nothing at all; it spools only AskUserQuestion and ExitPlanMode
12
+ # (the two tools the reducer reads, the 6.48 matcher's set), and UserPromptSubmit, which
13
+ # shares the branch, is always spooled. It prints nothing unless the server left a halt
14
+ # marker for this session, when it prints the one reply that stops the run (canary C1b,
15
+ # 2026-09-21: `continue:false` alone still ran the tool; with the deny it did not). It never
16
+ # exits 2: exit 2 blocks the tool, while a timeout or any other code fails open (C6, C7).
17
+ # Every path, every event, drains stdin before it exits, so Claude Code's writer never
18
+ # EPIPEs whatever the payload's size (6.53.3; every other event since its /qa round).
15
19
  #
16
20
  # The installer bakes COS_GLASSES_HOME and COS_HOOK_SPOOL into the command, so this
17
21
  # script and the server can never disagree about where the token or the spool is.
@@ -89,6 +93,9 @@ case "$EVENT" in PreToolUse|UserPromptSubmit)
89
93
  trap 'cat >/dev/null 2>&1' EXIT
90
94
  ;;
91
95
  esac
96
+ # Every other event: until the spool read below has taken stdin, any exit (a failed mkdir,
97
+ # the B8 guard) drains it, as the front does.
98
+ [ -n "$FRONT" ] || trap 'cat >/dev/null 2>&1' EXIT
92
99
  mkdir -p "$SPOOL/rejected" 2>/dev/null || exit 0
93
100
  TS=$(perl -MTime::HiRes -e 'printf "%.0f", Time::HiRes::time()*1000' 2>/dev/null || date +%s000)
94
101
  # B8 guard: a full spool or a stale drain stamp means nobody is reading; keep only permissions.
@@ -97,13 +104,15 @@ if [ "$EVENT" != PermissionRequest ]; then
97
104
  STAMP="$SPOOL/.last-drain"; [ ! -f "$STAMP" ] || [ -z "$(find "$STAMP" -mmin +1440 2>/dev/null)" ] || exit 0 # -mtime +1 would mean two days
98
105
  fi
99
106
  T="$SPOOL/.tmp.$$.$TS"
107
+ # Stdin to disk with a 1 MiB cap; whatever lies past the cap is drained, never left for the
108
+ # writer to EPIPE on.
109
+ trap - EXIT
100
110
  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
111
+ # 6.53.3: the 4 KiB the halt check read, then the rest of stdin.
104
112
  { printf '%s' "$HEAD"; exec cat; } | { head -c 1048576 > "$T.in"; exec cat >/dev/null; }
105
113
  else
106
- head -c 1048576 > "$T.in" # stdin to disk, 1 MiB cap
114
+ # No `exec` here: this group runs in the script's own shell.
115
+ { head -c 1048576 > "$T.in"; cat >/dev/null; }
107
116
  fi
108
117
  case "$(head -c 1 "$T.in")" in '{') ;; *) EVENT=rejected ;; esac
109
118
  { printf '{"ts":%s,"ppid":%s,"event":"%s","payload":' "$TS" "$PPID" "$EVENT"; cat "$T.in"; printf '}\n'; } > "$T"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.53.3",
3
+ "version": "6.53.4",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
@@ -186,6 +186,14 @@ export interface AttachedTurnFailureResult {
186
186
  /**
187
187
  * Did `close`/`exit` actually fire, i.e. did the kernel reap the child?
188
188
  *
189
+ * WHAT IT DOES AND DOES NOT PROMISE (6.53.3, scoped by its /qa round). On a CANCEL or a
190
+ * TIMEOUT, `reaped: true` also means the whole owned process tree was read gone after
191
+ * `close` (the tree poll); a tree that outlives SIGKILL settles `reaped: false`. On a
192
+ * NATURAL exit (the provider closed without a cancel or timeout) it means only that the
193
+ * CLI itself was reaped: that path settles at once, with no tree probe, so a detached
194
+ * background job the tool left running is not looked for. A pre-spawn cancel reports
195
+ * `reaped: true` because no process ever existed.
196
+ *
189
197
  * NOT DERIVABLE FROM `exitCode`. A child killed by a signal reports
190
198
  * `code === null`, and the handlers below only assign `exitCode` for a numeric
191
199
  * code — so the dominant timeout shape (SIGTERM, then SIGKILL) is reaped while
@@ -285,15 +285,21 @@ export function hookStatus(paths: { settingsPath?: string; scriptPath?: string;
285
285
  }
286
286
 
287
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.
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__/`.
294
299
  */
295
300
  export const HALT_CAPABLE_PRIOR_SCRIPT_SHAS: readonly string[] = [
296
301
  '1158bb06297550128f01fc47caa68806a5287d6da2d05b0d1c812e8ae20764e7',
302
+ 'c0b41bf8581cfea498e1e1ac5220fe4e148bd97448d13b60a88879ee5992cc51',
297
303
  ]
298
304
 
299
305
  /**
@@ -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
+ }
@@ -38,6 +38,7 @@ import { join } from 'node:path'
38
38
  import { atomicWriteFileSync } from './atomic-fs.js'
39
39
  import { dataPath } from './data-dir.js'
40
40
  import { isValidNativeThreadId } from './native-thread-id.js'
41
+ import { unwrapPeerMessage } from './session-stream-events.js'
41
42
 
42
43
  /**
43
44
  * How long a marker may outlive its cancel. An hour, not the plan's first 15 minutes: the
@@ -46,6 +47,14 @@ import { isValidNativeThreadId } from './native-thread-id.js'
46
47
  */
47
48
  export const HALT_MARKER_TTL_MS = 60 * 60_000
48
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
+
49
58
  export interface HaltMarker {
50
59
  at: number
51
60
  clientCancelId: string
@@ -121,15 +130,30 @@ export function clearHaltMarkerOnSessionEnd(sessionId: string, endedAt: number,
121
130
  // plain write would then be deleted by the very turn it was written to stop.
122
131
  //
123
132
  // 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.
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.
128
152
 
129
153
  interface PendingRearm {
130
154
  marker: HaltMarker
131
- /** Text the delivered prompt carries (the peer-inbox acceptance marker). */
132
- promptMarker: string
155
+ /** The delivered prompt's words, whitespace-normalised (`normalizePromptWords`). */
156
+ words: string
133
157
  /** Prompts submitted before this are someone else's. */
134
158
  after: number
135
159
  until: number
@@ -147,18 +171,22 @@ const MAX_PENDING_REARMS = 64
147
171
  export function haltDeliveredTurn(
148
172
  sessionId: string,
149
173
  marker: HaltMarker,
150
- turn: { promptMarker: string; after: number; rearm: boolean; now?: number },
174
+ turn: { prompt: string; after: number; rearm: boolean; now?: number; windowMs?: number },
151
175
  dir = haltDir(),
152
176
  ): boolean {
153
177
  if (!writeHaltMarker(sessionId, marker, dir)) return false
154
178
  const id = sessionId.toLowerCase()
155
- if (!turn.rearm || typeof turn.promptMarker !== 'string' || turn.promptMarker.trim().length === 0) {
179
+ const words = normalizePromptWords(turn.prompt)
180
+ if (!turn.rearm || words.length === 0) {
156
181
  pendingRearms.delete(id)
157
182
  return true
158
183
  }
159
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
160
188
  pendingRearms.delete(id)
161
- pendingRearms.set(id, { marker, promptMarker: turn.promptMarker, after: turn.after, until: now + HALT_MARKER_TTL_MS })
189
+ pendingRearms.set(id, { marker, words, after: turn.after, until: now + windowMs })
162
190
  while (pendingRearms.size > MAX_PENDING_REARMS) {
163
191
  const oldest = pendingRearms.keys().next()
164
192
  if (oldest.done) break
@@ -167,10 +195,26 @@ export function haltDeliveredTurn(
167
195
  return true
168
196
  }
169
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
+
170
213
  /**
171
214
  * 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.
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.
174
218
  */
175
219
  export function rearmHaltOnPrompt(sessionId: string, promptAt: number, prompt: unknown, dir = haltDir(), now = Date.now()): boolean {
176
220
  const id = typeof sessionId === 'string' ? sessionId.toLowerCase() : ''
@@ -181,8 +225,11 @@ export function rearmHaltOnPrompt(sessionId: string, promptAt: number, prompt: u
181
225
  return false
182
226
  }
183
227
  if (!Number.isFinite(promptAt) || promptAt < pending.after) return false
184
- if (typeof prompt !== 'string' || !prompt.includes(pending.promptMarker)) return false
185
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
+ }
186
233
  return writeHaltMarker(id, pending.marker, dir)
187
234
  }
188
235
 
@@ -17,7 +17,7 @@ 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, dropHaltRearm, haltDeliveredTurn, rearmHaltOnPrompt, sweepHaltMarkers, type HaltMarker } from './session-halt.js'
20
+ import { HALT_MARKER_TTL_MS, HALT_REARM_UNVERIFIED_MS, clearHaltMarkerOnSessionEnd, dropHaltRearm, haltDeliveredTurn, rearmHaltOnPrompt, sweepHaltMarkers, type HaltMarker } from './session-halt.js'
21
21
  import { cancelVoidsOpenTurn, threadCancel } from './session-cancel.js'
22
22
  import { ensureHookRuntimeFiles, ensureStableHookScript, hookSpoolDir, hookStatus, type HookStatus } from './claude-hooks-installer.js'
23
23
 
@@ -235,10 +235,17 @@ export function claudeDeskRunning(sessionId: string, now = Date.now(), dir = cla
235
235
  * hooks do not yet show the turn started (its UserPromptSubmit at or after `sentAt`), it is
236
236
  * re-armed once when that prompt's own UserPromptSubmit deletes it (`haltDeliveredTurn`).
237
237
  */
238
- export function haltHandedOffTurn(sessionId: string, marker: HaltMarker, turn: { promptMarker: string; sentAt: number }, now = Date.now()): boolean {
238
+ export function haltHandedOffTurn(sessionId: string, marker: HaltMarker, turn: { prompt: string; sentAt: number; unverified?: boolean }, now = Date.now()): boolean {
239
239
  const signal = signalFor(sessionId)
240
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 })
241
+ return haltDeliveredTurn(sessionId, marker, {
242
+ prompt: turn.prompt,
243
+ after: turn.sentAt,
244
+ rearm: !started,
245
+ now,
246
+ // 6.53.3 /qa: an unverified hand-off (`maybe`) holds its re-arm two minutes, not an hour.
247
+ windowMs: turn.unverified === true ? HALT_REARM_UNVERIFIED_MS : HALT_MARKER_TTL_MS,
248
+ })
242
249
  }
243
250
 
244
251
  let ledger: SessionHookLedger | null = null
@@ -96,8 +96,7 @@
96
96
  // and answer 400. They never treat an unparsed body as an empty one.
97
97
 
98
98
  import type { FenceRecord } from '../lib/thread-fence-store.js'
99
- import { execFileSync } from 'node:child_process'
100
- import { fenceLiveness, makePidStartProbe, type FenceLiveness, type FenceLivenessDeps } from '../lib/fence-liveness.js'
99
+ import { defaultPidStartProbe, fenceLiveness, type FenceLiveness, type FenceLivenessDeps } from '../lib/fence-liveness.js'
101
100
  import { Router, type Request, type Response } from 'express'
102
101
  import { createHash, randomUUID } from 'node:crypto'
103
102
  import {
@@ -125,7 +124,7 @@ import {
125
124
  import type { RegistryCheck, RegistryRejection, RegistryResult } from '../lib/agent-session-binding-registry.js'
126
125
  import { recordCosSpawn, releaseCosSpawn } from '../lib/agent-session-ownership-store.js'
127
126
  import { isValidNativeThreadId } from '../lib/native-thread-id.js'
128
- import { PEER_VERIFY_TIMEOUT_MS, peerAcceptanceMarker } from '../lib/session-peer-inbox.js'
127
+ import { PEER_VERIFY_TIMEOUT_MS } from '../lib/session-peer-inbox.js'
129
128
  import {
130
129
  CANCEL_QUEUE_HOLD_MS,
131
130
  CLIENT_CANCEL_ID_RE,
@@ -460,7 +459,7 @@ export interface CancelDeps {
460
459
  * have reached) the open session. Writes the halt marker, and re-arms it once when that
461
460
  * turn's own UserPromptSubmit deletes it (`haltHandedOffTurn`). False: not armed.
462
461
  */
463
- haltHandedOffTurn?: (sessionId: string, marker: { at: number; clientCancelId: string }, turn: { promptMarker: string; sentAt: number }) => boolean
462
+ haltHandedOffTurn?: (sessionId: string, marker: { at: number; clientCancelId: string }, turn: { prompt: string; sentAt: number; unverified?: boolean }) => boolean
464
463
  /** Append to `data/session-cancel.jsonl`. */
465
464
  ledger?: (row: SessionCancelLedgerRow) => void
466
465
  }
@@ -1560,17 +1559,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1560
1559
  const guard = deps.guard ?? new TargetGuard(deps.fencePersistence ?? null)
1561
1560
  // `ps -o lstart=` is the only start-time keyword macOS ps offers. Injectable so a
1562
1561
  // test drives recycled and unreadable pids without spawning real processes.
1563
- const livenessDeps: FenceLivenessDeps = deps.liveness ?? {
1564
- pidStartMs: makePidStartProbe((pid) => {
1565
- try {
1566
- return execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], {
1567
- encoding: 'utf8', timeout: 2_000,
1568
- })
1569
- } catch {
1570
- return null // not running, or ps refused the pid
1571
- }
1572
- }),
1573
- }
1562
+ const livenessDeps: FenceLivenessDeps = deps.liveness ?? { pidStartMs: defaultPidStartProbe() }
1574
1563
  // 6.53.3 (review A4): at boot, release the fences a cancel left whose children are all
1575
1564
  // gone (6.53.1's false "unreaped" among them), then keep re-checking on every fence read.
1576
1565
  guard.adoptCancelLiveness(livenessDeps)
@@ -1691,8 +1680,11 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1691
1680
  }) ?? 'unsupported'
1692
1681
  if (target === 'desk_run') {
1693
1682
  const armed = cancelProbe(() => cancelDeps.haltHandedOffTurn?.(threadId, { at: latch.at, clientCancelId: latch.clientCancelId }, {
1694
- promptMarker: peerAcceptanceMarker(turn.prompt),
1683
+ // 6.53.3 /qa: the WHOLE delivered prompt (the re-arm matches it exactly), and whether
1684
+ // the hand-off was only possibly delivered (`maybe`: a two-minute re-arm, not an hour).
1685
+ prompt: turn.prompt,
1695
1686
  sentAt: turn.sentAt,
1687
+ unverified: reached === 'maybe',
1696
1688
  }) === true, false)
1697
1689
  if (armed) {
1698
1690
  const settledPermissions = cancelProbe(() => cancelDeps.settlePermissions?.(threadId) ?? 0, 0)