@gotcos/glasses-server 6.53.0 → 6.53.3
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 +34 -0
- package/bin/hooks/cos-session-hook +74 -38
- package/package.json +1 -1
- package/server/index.ts +32 -61
- package/server/lib/attached-provider-adapter.ts +599 -49
- package/server/lib/claude-hooks-installer.ts +52 -0
- package/server/lib/queue-turn-evidence.ts +137 -0
- package/server/lib/session-cancel.ts +70 -12
- package/server/lib/session-halt.ts +94 -0
- package/server/lib/session-hooks-runtime.ts +55 -2
- package/server/routes/agent-session-bindings.ts +271 -20
- 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,37 @@
|
|
|
1
|
+
## 6.53.3
|
|
2
|
+
|
|
3
|
+
### Cancel owns the whole process tree, desk cancels hold, and the hook is faster
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
- **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.
|
|
8
|
+
- **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).
|
|
9
|
+
- **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`.
|
|
10
|
+
- **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`.
|
|
11
|
+
- **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.
|
|
12
|
+
- **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".
|
|
13
|
+
- **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`.
|
|
14
|
+
- **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.
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
17
|
+
## 6.53.2
|
|
18
|
+
|
|
19
|
+
### Cancel verification no longer races a dying Codex tree
|
|
20
|
+
|
|
21
|
+
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.
|
|
22
|
+
|
|
23
|
+
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.
|
|
24
|
+
|
|
25
|
+
## 6.53.1
|
|
26
|
+
|
|
27
|
+
### Cancel the whole Codex process tree
|
|
28
|
+
|
|
29
|
+
The 6.53.0 live Codex canary found that `codex exec resume` can launch a tool in its own process group. Cancelling the wrapper group stopped the COS turn, but an in-flight tool could be re-parented to launchd and continue running. This hotfix snapshots verified descendant identities before signalling the wrapper, reaches separate descendant groups, retains PID/start-time identities across re-parenting, and escalates those retained processes to SIGKILL after the five-second cancel grace. A wrapper `close` no longer clears the escalation while any verified tree member remains. Probe failure fails closed, and PID reuse is never signalled.
|
|
30
|
+
|
|
31
|
+
A spawned cancellation is now reported as ordinary `turn_cancelled` only after the adapter confirms the entire tree was reaped. An unverified or surviving tree takes the existing ambiguous-delivery fence path. A pre-spawn cancellation remains provably process-free; a spawned pre-write cancellation still requires reaping proof even though no prompt byte was sent.
|
|
32
|
+
|
|
33
|
+
- Tests: a real detached Node wrapper launches a SIGTERM-resistant tool in a second process group; the wrapper dies, the retained tool survives TERM, SIGKILL reaches it after re-parenting, and the tree is confirmed gone. Fault-injected process-table and start-time probes stay uncertain instead of inventing death. Adapter coverage proves wrapper close cannot settle early; route coverage proves both post-write and pre-write unreaped cancellations fence. The mutation gate removes each of those protections independently.
|
|
34
|
+
|
|
1
35
|
## 6.53.0
|
|
2
36
|
|
|
3
37
|
### Cancel a session run from the glasses
|
|
@@ -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
|
|
@@ -19,48 +19,76 @@ PATH=/usr/bin:/bin:/usr/sbin; umask 077; trap '' PIPE
|
|
|
19
19
|
HOME=${HOME:-$(cd ~ 2>/dev/null && pwd)}; [ -n "$HOME" ] || exit 0
|
|
20
20
|
GH=${COS_GLASSES_HOME:-$HOME/.cos-glasses}
|
|
21
21
|
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
|
-
|
|
22
|
+
# 6.53.0, the halt check; 6.53.3, one entry for both synchronous events (review A, option a).
|
|
23
|
+
# FIRST, before mkdir, perl and the B8 guard below: a full spool or a stale drain stamp must
|
|
24
|
+
# never stop a cancel. The marker folder sits beside the spool, like the Cursor queue
|
|
25
|
+
# folder; the server writes `<folder>/<session id>` (lib/session-halt.ts).
|
|
26
|
+
#
|
|
27
|
+
# The common path, a tool call with no marker anywhere on the Mac, is one glob, one 4 KiB
|
|
28
|
+
# read, a shell `case`, and `exec cat` to drain the rest of stdin: no temp file, no grep,
|
|
29
|
+
# no dirname, no mkdir (median 29 ms to 17 ms per tool call on the release Mac, 40 runs
|
|
30
|
+
# each). Draining, not leaving stdin unread, keeps Claude Code's writer from EPIPE at any
|
|
31
|
+
# payload size (a 2.5 MB tool input did EPIPE against the 6.53.0 script).
|
|
32
|
+
FRONT=''
|
|
28
33
|
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
|
|
34
|
+
HALT="${SPOOL%/*}/session-halt"
|
|
35
|
+
# Any marker at all? A glob is a builtin, and it runs before stdin is touched.
|
|
36
|
+
ANY=''; for F in "$HALT"/*; do [ -e "$F" ] && ANY=1; break; done
|
|
42
37
|
# 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
|
-
|
|
38
|
+
# that happens to carry one comes later. Any JSON spacing; 36 hex-and-dash characters
|
|
39
|
+
# inside a closed string, or nothing.
|
|
40
|
+
sid_of() {
|
|
41
|
+
SID=''
|
|
42
|
+
case "$HEAD" in *'"session_id"'*)
|
|
43
|
+
SID=${HEAD#*'"session_id"'}; SID=${SID#"${SID%%[!:[:space:]]*}"}
|
|
44
|
+
case "$SID" in '"'*'"'*) SID=${SID#?}; SID=${SID%%'"'*} ;; *) SID='' ;; esac ;;
|
|
45
|
+
esac
|
|
46
|
+
case "$SID" in *[!0-9A-Fa-f-]*) SID='' ;; esac
|
|
47
|
+
[ ${#SID} -eq 36 ] || SID=''
|
|
48
|
+
}
|
|
49
|
+
# dd, never head: macOS head fills a 16 KiB buffer from a pipe and keeps only what -c
|
|
50
|
+
# asks for, so the bytes after 4 KiB would be lost to the spool below. dd does ONE
|
|
51
|
+
# read(2) of at most 4 KiB, and the `cat` after it starts at exactly the next byte.
|
|
52
|
+
HEAD=$(dd bs=4096 count=1 2>/dev/null)
|
|
53
|
+
SID=''; [ -z "$ANY" ] || sid_of
|
|
54
|
+
# Claude writes the session id first and a tool call's name within a few hundred bytes.
|
|
55
|
+
# If this read came back without what the event needs, the writer's bytes arrived in
|
|
56
|
+
# pieces: read once more rather than guess.
|
|
57
|
+
MORE=''; case "$EVENT$HEAD" in PreToolUse*'"tool_name"'*|UserPromptSubmit*) ;; *) MORE=1 ;; esac
|
|
58
|
+
if [ -n "$MORE" ] || { [ -n "$ANY" ] && [ -z "$SID" ]; }; then
|
|
59
|
+
HEAD="$HEAD$(dd bs=4096 count=1 2>/dev/null)"
|
|
60
|
+
[ -z "$ANY" ] || sid_of
|
|
61
|
+
fi
|
|
62
|
+
NEED=''
|
|
63
|
+
if [ "$EVENT" = UserPromptSubmit ]; then NEED=1
|
|
64
|
+
else
|
|
65
|
+
# Only the two tools the reducer reads are spooled (the 6.48 matcher). The FIRST
|
|
66
|
+
# "tool_name" is the payload's own; a tool's input that carries one comes later.
|
|
67
|
+
case "$HEAD" in *'"AskUserQuestion"'*|*'"ExitPlanMode"'*)
|
|
68
|
+
TN=${HEAD#*'"tool_name"'}; TN=${TN#"${TN%%[!:[:space:]]*}"}
|
|
69
|
+
case "$TN" in '"AskUserQuestion"'*|'"ExitPlanMode"'*) NEED=1 ;; esac ;;
|
|
70
|
+
esac
|
|
71
|
+
fi
|
|
72
|
+
case "$SID" in *[A-F]*) SID=$(printf '%s' "$SID" | tr 'A-F' 'a-f') ;; esac
|
|
73
|
+
if [ -z "$SID" ]; then :
|
|
74
|
+
elif [ "$EVENT" = UserPromptSubmit ]; then
|
|
46
75
|
# A new prompt is never stopped: the marker lives only within the run it was written
|
|
47
76
|
# 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
|
-
|
|
77
|
+
# session, and a COS `-p` turn. Nothing (no temp file, 6.53.3 review A10) stands between
|
|
78
|
+
# reading the id and clearing it. Then the prompt is spooled as before.
|
|
79
|
+
rm -f "$HALT/$SID" 2>/dev/null
|
|
80
|
+
elif [ -f "$HALT/$SID" ]; then
|
|
52
81
|
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
|
|
82
|
+
exec cat >/dev/null
|
|
59
83
|
fi
|
|
60
|
-
#
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
84
|
+
# Every other tool call ends here with nothing written and nothing printed.
|
|
85
|
+
[ -n "$NEED" ] || exec cat >/dev/null
|
|
86
|
+
FRONT=1
|
|
87
|
+
# Until the spool read below has taken stdin, any exit (a failed mkdir, the B8 guard)
|
|
88
|
+
# still drains it.
|
|
89
|
+
trap 'cat >/dev/null 2>&1' EXIT
|
|
90
|
+
;;
|
|
91
|
+
esac
|
|
64
92
|
mkdir -p "$SPOOL/rejected" 2>/dev/null || exit 0
|
|
65
93
|
TS=$(perl -MTime::HiRes -e 'printf "%.0f", Time::HiRes::time()*1000' 2>/dev/null || date +%s000)
|
|
66
94
|
# B8 guard: a full spool or a stale drain stamp means nobody is reading; keep only permissions.
|
|
@@ -68,7 +96,15 @@ if [ "$EVENT" != PermissionRequest ]; then
|
|
|
68
96
|
N=$(ls "$SPOOL" 2>/dev/null | wc -l | tr -d ' '); [ "${N:-0}" -lt 2000 ] || exit 0
|
|
69
97
|
STAMP="$SPOOL/.last-drain"; [ ! -f "$STAMP" ] || [ -z "$(find "$STAMP" -mmin +1440 2>/dev/null)" ] || exit 0 # -mtime +1 would mean two days
|
|
70
98
|
fi
|
|
71
|
-
T="$SPOOL/.tmp.$$.$TS"
|
|
99
|
+
T="$SPOOL/.tmp.$$.$TS"
|
|
100
|
+
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
|
|
104
|
+
{ printf '%s' "$HEAD"; exec cat; } | { head -c 1048576 > "$T.in"; exec cat >/dev/null; }
|
|
105
|
+
else
|
|
106
|
+
head -c 1048576 > "$T.in" # stdin to disk, 1 MiB cap
|
|
107
|
+
fi
|
|
72
108
|
case "$(head -c 1 "$T.in")" in '{') ;; *) EVENT=rejected ;; esac
|
|
73
109
|
{ printf '{"ts":%s,"ppid":%s,"event":"%s","payload":' "$TS" "$PPID" "$EVENT"; cat "$T.in"; printf '}\n'; } > "$T"
|
|
74
110
|
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.3",
|
|
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
|
}))
|