@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 +14 -0
- package/README.md +37 -1
- package/bin/hooks/cos-session-hook +18 -9
- package/package.json +1 -1
- package/server/lib/attached-provider-adapter.ts +8 -0
- package/server/lib/claude-hooks-installer.ts +12 -6
- package/server/lib/fence-liveness.ts +76 -2
- package/server/lib/session-halt.ts +59 -12
- package/server/lib/session-hooks-runtime.ts +10 -3
- package/server/routes/agent-session-bindings.ts +8 -16
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
|
|
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
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
+
"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
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
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
|
-
|
|
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
|
|
125
|
-
// the delivered
|
|
126
|
-
//
|
|
127
|
-
//
|
|
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
|
-
/**
|
|
132
|
-
|
|
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: {
|
|
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
|
-
|
|
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,
|
|
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.
|
|
173
|
-
*
|
|
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: {
|
|
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, {
|
|
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 {
|
|
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
|
|
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: {
|
|
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
|
-
|
|
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)
|