@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.
- package/CHANGELOG.md +38 -0
- package/README.md +37 -1
- package/bin/hooks/cos-session-hook +88 -43
- package/package.json +1 -1
- package/server/index.ts +32 -61
- package/server/lib/attached-provider-adapter.ts +508 -176
- package/server/lib/claude-hooks-installer.ts +58 -0
- package/server/lib/fence-liveness.ts +76 -2
- package/server/lib/queue-turn-evidence.ts +137 -0
- package/server/lib/session-cancel.ts +70 -12
- package/server/lib/session-halt.ts +141 -0
- package/server/lib/session-hooks-runtime.ts +62 -2
- package/server/routes/agent-session-bindings.ts +265 -28
- package/server/routes/health.ts +2 -1
- package/server/routes/meeting.ts +13 -4
- package/server/routes/transcribe-stream.ts +29 -1
- package/server/scripts/hooks-cli.ts +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
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
|
+
|
|
15
|
+
## 6.53.3
|
|
16
|
+
|
|
17
|
+
### Cancel owns the whole process tree, desk cancels hold, and the hook is faster
|
|
18
|
+
|
|
19
|
+
Built from a four-reviewer regression review of 6.53.0 to 6.53.2 and a real-process harness. 6.53.2 fixed the 6.53.1 false fence (40 of 40 runs), but the harness found it could report a cancelled turn as reaped while a detached background job survived (3 of 3), and that it regressed a case 6.52.3 handled: when the provider exited but a same-group child still held its output open, cancel and timeout never signalled that child (2 of 2 survivors, 0 on 6.52.3). 6.53.2 was never published; this release supersedes it.
|
|
20
|
+
|
|
21
|
+
- **Every process a turn started is stopped.** The root process group is signalled while Node has not reaped the leader, including when the first process-table read fails. Every process group a tree member sits in is remembered with its leader's start time, so members re-parented to launchd are still owned, signalled and counted. `reaped: true` is never reported while an owned process survives. A failed or malformed read still latches doubt and fences.
|
|
22
|
+
- **Cancel settles as soon as the tree is gone.** Liveness is read asynchronously every 200 ms after `close` and settles on the first read that shows nothing of ours, so a tool that exits one second after SIGTERM settles in about 1.2 s instead of 5.3 s. Once doubt is latched, no more process-table reads run (6.53.2 ran about 29 blocking reads in 2 s).
|
|
23
|
+
- **A cancel before the prompt write ends cleanly.** The tree is killed and reaped, and the turn settles `turn_cancelled` with no fence. A cancel before spawn reports `reaped: true`.
|
|
24
|
+
- **A desk cancel cannot be undone by a parked turn.** A cancel records its target. Only a `cos_turn` cancel lets silence end a turn; a `desk_run` cancel keeps the open-turn evidence until the desk itself ends the run (Stop, StopFailure, SessionEnd, or an idle flip newer than the cancel). The same rule removes the phantom desk Cancel run after a killed `claude -p`.
|
|
25
|
+
- **Cancel during the live hand-off works.** It is latched and answered 202 `stopping` instead of 409 `cancel_failed`. If nothing reached the session the turn settles `turn_cancelled`; if it reached, the desk halt marker is written (and re-armed once on the delivered prompt's own UserPromptSubmit). Codex answers `cancel_unsupported`. Replaying the same `clientCancelId` returns the final outcome.
|
|
26
|
+
- **Fences left by a cancel release themselves.** A fence is released and logged only when its reason is `cancelled`, it was set at the ambiguous-delivery site, and every recorded child is confirmed gone. This clears threads locked by 6.53.1's false "may not have been delivered".
|
|
27
|
+
- **Closed meetings age out.** A closed-session record past its 4-hour retention is evicted, so status reads `missing` and the phone stops asking to save it. Saving a known session with no transcript answers 404 `nothing_to_save` (with its state); only an unknown id still answers `session_not_found`.
|
|
28
|
+
- **The session hook is about 40% faster and never breaks a large tool input.** The halt check, tool-name check and session-id parse run in shell on the first 4 KiB (read with `dd`: macOS `head -c` over-reads a pipe), and the rest of stdin is drained on every path, so a 2.5 MB Write no longer hits EPIPE. There is no temp file, so a new prompt always clears its session's marker. Median per tool call: about 29 ms to 17 ms quiet, 35 to 42 ms to about 23 ms under load. `--hooks status` adds an `advice` line; installs read `script_outdated` until Install hooks is run once.
|
|
29
|
+
- Tests: a real-process end-to-end suite (churn, idle, stubborn, graceful, orphan-stdout three ways, group-orphan, setsid escape; macOS) fails 5 of 9 on 6.53.2 and passes here; queue-turn evidence is tested against the real queue; hook tests run the real script (EPIPE, marker-clear and drain cases fail on the old script). Mutation gate: 74 new cases plus retargeted 6.53.0 cases, all killed on a green baseline. Hook and installer suites clean up their temp roots.
|
|
30
|
+
|
|
31
|
+
## 6.53.2
|
|
32
|
+
|
|
33
|
+
### Cancel verification no longer races a dying Codex tree
|
|
34
|
+
|
|
35
|
+
The installed 6.53.1 live canary successfully stopped the Codex wrapper and its detached tool, but then falsely fenced the session as `unreaped`. The verifier captured ancestry in one process-table snapshot and queried each process start time in later subprocesses. A short-lived descendant could disappear between those reads, permanently converting a successful cancellation into doubt.
|
|
36
|
+
|
|
37
|
+
Process identity and ancestry now come from one stable `ps` snapshot (`pid`, `ppid`, `pgid`, and `lstart`) under a fixed UTC/C locale. There are no per-PID identity probes in the cancellation walk, so ordinary process death cannot poison reaping proof. A failed snapshot or any malformed identity still fails closed. Real detached-process tests cover the disappearing-PID race, unreadable whole snapshots, malformed atomic identities, re-parenting, and SIGKILL escalation; three new mutation cases independently remove the atomic capture, restore the racy per-PID probe, and ignore malformed identity data.
|
|
38
|
+
|
|
1
39
|
## 6.53.1
|
|
2
40
|
|
|
3
41
|
### Cancel the whole Codex process tree
|
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+.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
-
# COS session hook (glasses-server 6.48.0; Cursor Stop follow-up 6.51.0; halt check 6.53.0). Installed into ~/.claude/settings.json by
|
|
2
|
+
# COS session hook (glasses-server 6.48.0; Cursor Stop follow-up 6.51.0; halt check 6.53.0; single entry 6.53.3). Installed into ~/.claude/settings.json by
|
|
3
3
|
# `--hooks install`; Claude Code runs it with the hook event name as $1 and the event
|
|
4
4
|
# payload on stdin. It spools one JSON file per event into the hook spool and exits 0
|
|
5
5
|
# on every path. Only PermissionRequest ever talks to the server, and only when the
|
|
@@ -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.
|
|
@@ -19,48 +23,79 @@ PATH=/usr/bin:/bin:/usr/sbin; umask 077; trap '' PIPE
|
|
|
19
23
|
HOME=${HOME:-$(cd ~ 2>/dev/null && pwd)}; [ -n "$HOME" ] || exit 0
|
|
20
24
|
GH=${COS_GLASSES_HOME:-$HOME/.cos-glasses}
|
|
21
25
|
SPOOL=${COS_HOOK_SPOOL:-$GH/data/hook-spool}; EVENT="$1"
|
|
22
|
-
# 6.53.0, the halt check.
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
# writes `<folder>/<session id>` (lib/session-halt.ts).
|
|
26
|
-
#
|
|
27
|
-
|
|
26
|
+
# 6.53.0, the halt check; 6.53.3, one entry for both synchronous events (review A, option a).
|
|
27
|
+
# FIRST, before mkdir, perl and the B8 guard below: a full spool or a stale drain stamp must
|
|
28
|
+
# never stop a cancel. The marker folder sits beside the spool, like the Cursor queue
|
|
29
|
+
# folder; the server writes `<folder>/<session id>` (lib/session-halt.ts).
|
|
30
|
+
#
|
|
31
|
+
# The common path, a tool call with no marker anywhere on the Mac, is one glob, one 4 KiB
|
|
32
|
+
# read, a shell `case`, and `exec cat` to drain the rest of stdin: no temp file, no grep,
|
|
33
|
+
# no dirname, no mkdir (median 29 ms to 17 ms per tool call on the release Mac, 40 runs
|
|
34
|
+
# each). Draining, not leaving stdin unread, keeps Claude Code's writer from EPIPE at any
|
|
35
|
+
# payload size (a 2.5 MB tool input did EPIPE against the 6.53.0 script).
|
|
36
|
+
FRONT=''
|
|
28
37
|
case "$EVENT" in PreToolUse|UserPromptSubmit)
|
|
29
|
-
|
|
30
|
-
#
|
|
31
|
-
|
|
32
|
-
[ -n "$IN" ] || [ "$EVENT" = UserPromptSubmit ] || exit 0
|
|
33
|
-
;;
|
|
34
|
-
esac
|
|
35
|
-
[ -z "$IN" ] || {
|
|
36
|
-
HALT="$(dirname "$SPOOL")/session-halt"
|
|
37
|
-
trap 'rm -f "$IN"; exit 0' HUP INT TERM
|
|
38
|
-
head -c 1048576 > "$IN" 2>/dev/null
|
|
39
|
-
# Any marker at all? A glob is a builtin, so the common case (no cancel anywhere on the
|
|
40
|
-
# Mac) reads no session id and forks nothing for it.
|
|
41
|
-
SID=''; for F in "$HALT"/*; do [ -e "$F" ] && SID=x; break; done
|
|
38
|
+
HALT="${SPOOL%/*}/session-halt"
|
|
39
|
+
# Any marker at all? A glob is a builtin, and it runs before stdin is touched.
|
|
40
|
+
ANY=''; for F in "$HALT"/*; do [ -e "$F" ] && ANY=1; break; done
|
|
42
41
|
# The FIRST session_id is the payload's own: Claude writes it first, and a tool's input
|
|
43
|
-
# that happens to carry one comes later.
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
# that happens to carry one comes later. Any JSON spacing; 36 hex-and-dash characters
|
|
43
|
+
# inside a closed string, or nothing.
|
|
44
|
+
sid_of() {
|
|
45
|
+
SID=''
|
|
46
|
+
case "$HEAD" in *'"session_id"'*)
|
|
47
|
+
SID=${HEAD#*'"session_id"'}; SID=${SID#"${SID%%[!:[:space:]]*}"}
|
|
48
|
+
case "$SID" in '"'*'"'*) SID=${SID#?}; SID=${SID%%'"'*} ;; *) SID='' ;; esac ;;
|
|
49
|
+
esac
|
|
50
|
+
case "$SID" in *[!0-9A-Fa-f-]*) SID='' ;; esac
|
|
51
|
+
[ ${#SID} -eq 36 ] || SID=''
|
|
52
|
+
}
|
|
53
|
+
# dd, never head: macOS head fills a 16 KiB buffer from a pipe and keeps only what -c
|
|
54
|
+
# asks for, so the bytes after 4 KiB would be lost to the spool below. dd does ONE
|
|
55
|
+
# read(2) of at most 4 KiB, and the `cat` after it starts at exactly the next byte.
|
|
56
|
+
HEAD=$(dd bs=4096 count=1 2>/dev/null)
|
|
57
|
+
SID=''; [ -z "$ANY" ] || sid_of
|
|
58
|
+
# Claude writes the session id first and a tool call's name within a few hundred bytes.
|
|
59
|
+
# If this read came back without what the event needs, the writer's bytes arrived in
|
|
60
|
+
# pieces: read once more rather than guess.
|
|
61
|
+
MORE=''; case "$EVENT$HEAD" in PreToolUse*'"tool_name"'*|UserPromptSubmit*) ;; *) MORE=1 ;; esac
|
|
62
|
+
if [ -n "$MORE" ] || { [ -n "$ANY" ] && [ -z "$SID" ]; }; then
|
|
63
|
+
HEAD="$HEAD$(dd bs=4096 count=1 2>/dev/null)"
|
|
64
|
+
[ -z "$ANY" ] || sid_of
|
|
65
|
+
fi
|
|
66
|
+
NEED=''
|
|
67
|
+
if [ "$EVENT" = UserPromptSubmit ]; then NEED=1
|
|
68
|
+
else
|
|
69
|
+
# Only the two tools the reducer reads are spooled (the 6.48 matcher). The FIRST
|
|
70
|
+
# "tool_name" is the payload's own; a tool's input that carries one comes later.
|
|
71
|
+
case "$HEAD" in *'"AskUserQuestion"'*|*'"ExitPlanMode"'*)
|
|
72
|
+
TN=${HEAD#*'"tool_name"'}; TN=${TN#"${TN%%[!:[:space:]]*}"}
|
|
73
|
+
case "$TN" in '"AskUserQuestion"'*|'"ExitPlanMode"'*) NEED=1 ;; esac ;;
|
|
74
|
+
esac
|
|
75
|
+
fi
|
|
76
|
+
case "$SID" in *[A-F]*) SID=$(printf '%s' "$SID" | tr 'A-F' 'a-f') ;; esac
|
|
77
|
+
if [ -z "$SID" ]; then :
|
|
78
|
+
elif [ "$EVENT" = UserPromptSubmit ]; then
|
|
46
79
|
# A new prompt is never stopped: the marker lives only within the run it was written
|
|
47
80
|
# for. This covers a prompt typed at the desk, a Continue delivered into the open
|
|
48
|
-
# session, and a COS `-p` turn.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
81
|
+
# session, and a COS `-p` turn. Nothing (no temp file, 6.53.3 review A10) stands between
|
|
82
|
+
# reading the id and clearing it. Then the prompt is spooled as before.
|
|
83
|
+
rm -f "$HALT/$SID" 2>/dev/null
|
|
84
|
+
elif [ -f "$HALT/$SID" ]; then
|
|
52
85
|
printf '%s' '{"continue":false,"stopReason":"Cancelled from COS","hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Cancelled from COS"}}'
|
|
53
|
-
|
|
54
|
-
else
|
|
55
|
-
# Only the two tools the reducer reads were ever spooled (the 6.48 matcher); every
|
|
56
|
-
# other tool call ends here with nothing written and nothing printed.
|
|
57
|
-
TOOL=$(grep -Eo '"tool_name"[[:space:]]*:[[:space:]]*"[A-Za-z]+"' "$IN" 2>/dev/null | head -1)
|
|
58
|
-
case "$TOOL" in *'"AskUserQuestion"'|*'"ExitPlanMode"') ;; *) rm -f "$IN"; exit 0 ;; esac
|
|
86
|
+
exec cat >/dev/null
|
|
59
87
|
fi
|
|
60
|
-
#
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
88
|
+
# Every other tool call ends here with nothing written and nothing printed.
|
|
89
|
+
[ -n "$NEED" ] || exec cat >/dev/null
|
|
90
|
+
FRONT=1
|
|
91
|
+
# Until the spool read below has taken stdin, any exit (a failed mkdir, the B8 guard)
|
|
92
|
+
# still drains it.
|
|
93
|
+
trap 'cat >/dev/null 2>&1' EXIT
|
|
94
|
+
;;
|
|
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
|
|
64
99
|
mkdir -p "$SPOOL/rejected" 2>/dev/null || exit 0
|
|
65
100
|
TS=$(perl -MTime::HiRes -e 'printf "%.0f", Time::HiRes::time()*1000' 2>/dev/null || date +%s000)
|
|
66
101
|
# B8 guard: a full spool or a stale drain stamp means nobody is reading; keep only permissions.
|
|
@@ -68,7 +103,17 @@ if [ "$EVENT" != PermissionRequest ]; then
|
|
|
68
103
|
N=$(ls "$SPOOL" 2>/dev/null | wc -l | tr -d ' '); [ "${N:-0}" -lt 2000 ] || exit 0
|
|
69
104
|
STAMP="$SPOOL/.last-drain"; [ ! -f "$STAMP" ] || [ -z "$(find "$STAMP" -mmin +1440 2>/dev/null)" ] || exit 0 # -mtime +1 would mean two days
|
|
70
105
|
fi
|
|
71
|
-
T="$SPOOL/.tmp.$$.$TS"
|
|
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
|
|
110
|
+
if [ -n "$FRONT" ]; then
|
|
111
|
+
# 6.53.3: the 4 KiB the halt check read, then the rest of stdin.
|
|
112
|
+
{ printf '%s' "$HEAD"; exec cat; } | { head -c 1048576 > "$T.in"; exec cat >/dev/null; }
|
|
113
|
+
else
|
|
114
|
+
# No `exec` here: this group runs in the script's own shell.
|
|
115
|
+
{ head -c 1048576 > "$T.in"; cat >/dev/null; }
|
|
116
|
+
fi
|
|
72
117
|
case "$(head -c 1 "$T.in")" in '{') ;; *) EVENT=rejected ;; esac
|
|
73
118
|
{ printf '{"ts":%s,"ppid":%s,"event":"%s","payload":' "$TS" "$PPID" "$EVENT"; cat "$T.in"; printf '}\n'; } > "$T"
|
|
74
119
|
rm -f "$T.in"
|
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": {
|
package/server/index.ts
CHANGED
|
@@ -24,9 +24,10 @@ import { agentSessionStreamRouter } from './routes/agent-session-stream.js'
|
|
|
24
24
|
import { createAttachedTurnStream } from './lib/session-stream-producer.js'
|
|
25
25
|
import { claudeSessionsRouter } from './routes/claude-sessions.js'
|
|
26
26
|
import { createSessionHooksRouter } from './routes/session-hooks.js'
|
|
27
|
-
import { cachedHookStatus, claudeDeskRunning, deskIdleSeconds, registerDrainKickStats, registryIdleAfterStop, sessionHooksEnabled, sessionSignalStore, signalFor, startSessionHooksRuntime } from './lib/session-hooks-runtime.js'
|
|
27
|
+
import { cachedHookStatus, claudeDeskRunning, deskIdleSeconds, deskTurnEndedAt, haltHandedOffTurn, registerDrainKickStats, registryIdleAfterStop, sessionHooksEnabled, sessionSignalStore, signalFor, startSessionHooksRuntime } from './lib/session-hooks-runtime.js'
|
|
28
28
|
import { writeHaltMarker } from './lib/session-halt.js'
|
|
29
|
-
import { appendSessionCancelLedger,
|
|
29
|
+
import { appendSessionCancelLedger, cancelHoldUntil as cancelHoldUntilFor, noteThreadCancelled, threadCancel } from './lib/session-cancel.js'
|
|
30
|
+
import { makeQueueTurnEvidence } from './lib/queue-turn-evidence.js'
|
|
30
31
|
import {
|
|
31
32
|
PermissionBroker,
|
|
32
33
|
brokerSignalSink,
|
|
@@ -148,8 +149,8 @@ import {
|
|
|
148
149
|
const app = express()
|
|
149
150
|
import { createThreadTurnQueueRouter, drainAllThreads } from './routes/thread-turn-queue.js'
|
|
150
151
|
import { createCursorStopFollowupRouter } from './routes/cursor-stop-followup.js'
|
|
151
|
-
import { queuedThreadKeys, readQueue,
|
|
152
|
-
import { readHookToken } from './lib/claude-hooks-installer.js'
|
|
152
|
+
import { queuedThreadKeys, readQueue, transcriptTurnVerdict, writeQueue } from './lib/thread-turn-queue-store.js'
|
|
153
|
+
import { hookHaltReady, readHookToken } from './lib/claude-hooks-installer.js'
|
|
153
154
|
import { OPEN_TURN_CEILING_MS } from './lib/session-state-derive.js'
|
|
154
155
|
import { createDrainKick, kickPlanFor } from './lib/thread-drain-kick.js'
|
|
155
156
|
import { transcriptPathFor } from './lib/native-head.js'
|
|
@@ -667,6 +668,22 @@ const sharedTargetGuard = new TargetGuard(
|
|
|
667
668
|
: null,
|
|
668
669
|
)
|
|
669
670
|
|
|
671
|
+
const queueTurnEvidence = makeQueueTurnEvidence({
|
|
672
|
+
signalFor: threadId => signalFor(threadId),
|
|
673
|
+
registryIdleAfterStop: (threadId, stopAt) => registryIdleAfterStop(threadId, stopAt),
|
|
674
|
+
deskEndedAt: threadId => deskTurnEndedAt(threadId),
|
|
675
|
+
transcriptMtimeMs: (provider, threadId) => {
|
|
676
|
+
const read = occupancyProbes.transcriptMtimeMs
|
|
677
|
+
const value = typeof read === 'function' ? read(provider as 'claude' | 'codex', threadId) : null
|
|
678
|
+
return typeof value === 'number' ? value : null
|
|
679
|
+
},
|
|
680
|
+
transcriptVerdict: (provider, threadId) =>
|
|
681
|
+
transcriptTurnVerdict(provider as 'claude' | 'codex', transcriptPathFor(provider as 'claude' | 'codex', threadId, nativeHeadDeps)),
|
|
682
|
+
threadCancel: (provider, threadId) => threadCancel(provider, threadId),
|
|
683
|
+
openTurnCeilingMs: OPEN_TURN_CEILING_MS,
|
|
684
|
+
now: () => Date.now(),
|
|
685
|
+
})
|
|
686
|
+
|
|
670
687
|
if (threadAttachEnabled()) {
|
|
671
688
|
const queueDeps = {
|
|
672
689
|
// TWO GATES, ONE ANSWER. `threadOccupancy` sees FOREIGN holders -- another app on
|
|
@@ -711,61 +728,12 @@ if (threadAttachEnabled()) {
|
|
|
711
728
|
return { attachable: false, reason: 'probe_failed' }
|
|
712
729
|
}
|
|
713
730
|
},
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
if (cancelledAt !== null) {
|
|
721
|
-
const readMtime = occupancyProbes.transcriptMtimeMs
|
|
722
|
-
const lastRow = typeof readMtime === 'function' ? readMtime(provider, threadId) : null
|
|
723
|
-
if (cancelEndsTurn(cancelledAt, typeof lastRow === 'number' ? lastRow : null)) return true
|
|
724
|
-
}
|
|
725
|
-
// 6.48.1: the engine's own end of turn: a Stop hook newer than the turn's prompt
|
|
726
|
-
// AND the registry flipped idle after it (the Stop hooks have returned; a prompt
|
|
727
|
-
// typed during them is queued and dequeues only then). The transcript tail
|
|
728
|
-
// (`turnFromTail`) answers for everything the hooks did not see. Both refuse on doubt.
|
|
729
|
-
if (provider === 'claude') {
|
|
730
|
-
const signal = signalFor(threadId)
|
|
731
|
-
if (signal && !signal.turnOpen && signal.lastEvent === 'Stop' && typeof signal.stopAt === 'number'
|
|
732
|
-
&& signal.stopAt >= (signal.turnStartedAt ?? 0) && signal.subagentsOpen === 0
|
|
733
|
-
&& registryIdleAfterStop(threadId, signal.stopAt) === true) return true
|
|
734
|
-
}
|
|
735
|
-
return transcriptTurnEnded(provider, transcriptPathFor(provider, threadId, nativeHeadDeps))
|
|
736
|
-
} catch {
|
|
737
|
-
return false
|
|
738
|
-
}
|
|
739
|
-
},
|
|
740
|
-
// 6.48.1: positive evidence the holder's turn is OPEN, which outranks the 30 s idle
|
|
741
|
-
// backstop (a long tool leaves the transcript untouched while it runs). Bounded: a
|
|
742
|
-
// signal or a tail older than OPEN_TURN_CEILING_MS is not evidence any more, so a
|
|
743
|
-
// crashed mid-tool session still drains by the backstop rather than holding for the TTL.
|
|
744
|
-
turnOpen: (provider: string, threadId: string): boolean => {
|
|
745
|
-
if (provider !== 'claude' && provider !== 'codex') return false
|
|
746
|
-
try {
|
|
747
|
-
const now = Date.now()
|
|
748
|
-
// 6.53.0: evidence older than a cancel on this thread is the cancelled run's, not a
|
|
749
|
-
// live turn (plan R1: a killed child's dangling tool_use read `tool_pending` for the
|
|
750
|
-
// whole 30-minute ceiling and held every parked turn behind it).
|
|
751
|
-
const cancelledAt = threadCancelledAt(provider, threadId)
|
|
752
|
-
if (provider === 'claude') {
|
|
753
|
-
const signal = signalFor(threadId)
|
|
754
|
-
if (signal && signal.turnOpen && now - signal.lastEventAt <= OPEN_TURN_CEILING_MS
|
|
755
|
-
&& !cancelEndsTurn(cancelledAt, signal.lastEventAt)) return true
|
|
756
|
-
}
|
|
757
|
-
const path = transcriptPathFor(provider, threadId, nativeHeadDeps)
|
|
758
|
-
const verdict = transcriptTurnVerdict(provider, path)
|
|
759
|
-
if (!verdict || verdict.ended) return false
|
|
760
|
-
if (verdict.reason !== 'tool_pending' && verdict.reason !== 'prompt_open') return false
|
|
761
|
-
const read = occupancyProbes.transcriptMtimeMs
|
|
762
|
-
const mtime = typeof read === 'function' ? read(provider, threadId) : null
|
|
763
|
-
if (cancelEndsTurn(cancelledAt, typeof mtime === 'number' ? mtime : null)) return false
|
|
764
|
-
return typeof mtime === 'number' && now - mtime <= OPEN_TURN_CEILING_MS
|
|
765
|
-
} catch {
|
|
766
|
-
return false
|
|
767
|
-
}
|
|
768
|
-
},
|
|
731
|
+
// 6.53.3: the two turn questions live in lib/queue-turn-evidence.ts, where a test drives
|
|
732
|
+
// them against the real drain decision. What a cancel on the thread may conclude now
|
|
733
|
+
// depends on what it was aimed at (review A1): a killed COS child's silence ends its
|
|
734
|
+
// turn; a desk run's silence does not, until the desk itself says the run is over.
|
|
735
|
+
turnEnded: queueTurnEvidence.turnEnded,
|
|
736
|
+
turnOpen: queueTurnEvidence.turnOpen,
|
|
769
737
|
activity: (provider: string, threadId: string): 'working' | 'idle' | 'unknown' => {
|
|
770
738
|
try {
|
|
771
739
|
const read = occupancyProbes.transcriptMtimeMs
|
|
@@ -879,11 +847,14 @@ app.use('/api', createAgentSessionBindingsRouter({
|
|
|
879
847
|
cancel: {
|
|
880
848
|
deskRunning: sessionId => claudeDeskRunning(sessionId),
|
|
881
849
|
hooksEnabled: () => sessionHooksEnabled(),
|
|
882
|
-
|
|
850
|
+
// 6.53.3: installed, or a halt-capable earlier script still in place (`hookHaltReady`).
|
|
851
|
+
hooksReady: () => hookHaltReady(cachedHookStatus()),
|
|
883
852
|
writeHalt: (sessionId, marker) => writeHaltMarker(sessionId, marker),
|
|
884
853
|
settlePermissions: sessionId => permissionBroker.cancelSession(sessionId),
|
|
885
854
|
queuedWaiting: (provider, threadId) => readQueue(provider, threadId, Date.now()).filter(t => t.status === 'waiting').length,
|
|
886
|
-
noteCancelled: (provider, threadId, at) => noteThreadCancelled(provider, threadId, at),
|
|
855
|
+
noteCancelled: (provider, threadId, at, target) => noteThreadCancelled(provider, threadId, at, target),
|
|
856
|
+
// 6.53.3 (review A6): a cancel that landed during a live hand-off whose turn reached the session.
|
|
857
|
+
haltHandedOffTurn: (sessionId, marker, turn) => haltHandedOffTurn(sessionId, marker, turn),
|
|
887
858
|
ledger: row => appendSessionCancelLedger(row),
|
|
888
859
|
},
|
|
889
860
|
}))
|