@gotcos/glasses-server 6.48.0 → 6.48.1
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 +15 -4
- package/package.json +1 -1
- package/server/index.ts +70 -7
- package/server/lib/claude-session-registry.ts +19 -0
- package/server/lib/occupancy-probes.ts +43 -0
- package/server/lib/session-hook-events.ts +5 -0
- package/server/lib/session-hook-ledger.ts +7 -3
- package/server/lib/session-hook-spool.ts +11 -3
- package/server/lib/session-hooks-runtime.ts +114 -5
- package/server/lib/session-signal-store.ts +33 -5
- package/server/lib/session-state-derive.ts +38 -0
- package/server/lib/session-stream-bus.ts +93 -9
- package/server/lib/session-stream-events.ts +144 -0
- package/server/lib/thread-drain-kick.ts +153 -0
- package/server/lib/thread-occupancy.ts +36 -1
- package/server/lib/thread-turn-queue-deliver.ts +17 -6
- package/server/lib/thread-turn-queue-store.ts +70 -23
- package/server/lib/thread-turn-queue.ts +19 -3
- package/server/routes/agent-session-stream.ts +169 -11
- package/server/routes/agent-sessions.ts +34 -5
- package/server/routes/claude-sessions.ts +11 -17
- package/server/routes/session-hooks.ts +10 -0
- package/server/routes/thread-turn-queue.ts +15 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## 6.48.1
|
|
2
|
+
|
|
3
|
+
One derivation everywhere, a Desktop turn ends when the engine says it ended, a queued follow-up drains then instead of on the 30 s idle window, and the live feed carries the same state every row does. No client change is required: the next Control and EHPK builds read the new keys, and today's consumers ignore them.
|
|
4
|
+
|
|
5
|
+
- A queued follow-up is delivered when the turn ends, and "ends" means what the engine means. Claude Code does not close a turn at the `Stop` hook: it closes it when every Stop hook has RETURNED, and only then dequeues anything typed meanwhile and flips its registry record to `idle`. On the release Mac the COS repo's own Stop hooks run 12-38 s, so a delivery at Stop + 2 s would have raced a prompt typed at the desk. The drain now waits for the registry's `idle` stamped at or after the Stop (polled every second, 60 s at most), then sweeps through one in-flight guard shared with the 20 s timer, so a burst of Stops folds into exactly one follow-up pass and a sentence is never delivered twice. A kick is spent only when a queue names that session; a COS child's own end kicks at once so chained follow-ups do not wait for the timer. Measured on the release Mac against a live holder whose own Stop hook ran 25 s: the registry flipped idle 25.0 s after the Stop hook, the follow-up was delivered 185 ms after that flip (its user row landed 1.5 s later, once, answered once, in the holder's own transcript, with the holder still open). Before this release: 35 s or more from the turn's end, and a delivery during the Stop hooks was possible.
|
|
6
|
+
- The attach gate reads the same end of turn. With `COS_SESSION_HOOKS` on, a foreign Desktop holder whose newest hook event is a `Stop`, with the turn closed, no sub-agent open, and the registry idle at or after that Stop, reads idle at once instead of after the 30 s window. Null, unknown, a record that says busy, Codex and a far-future clock stay strict; the clause narrows a `working` verdict and never lifts a doubt. A COS Continue child registers under the tab's session id: when two records name the session the tab's is read, the child's only when it is alone.
|
|
7
|
+
- The transcript's own rule for "the turn ended", used where the hooks did not see the session: the newest assistant record's `stop_reason` is terminal (end_turn, stop_sequence, max_tokens, refusal) with no `tool_use` still awaiting its result, a `result` row, or a user interrupt; a new prompt reopens it, a `<task-notification>` and a cross-session message included; a prompt queued during the Stop hooks (Claude's `queue-operation` row) keeps it open; other meta and compact rows do not. The tail read is 512 KiB: 3 of the 10 newest Desktop transcripts on the release Mac end in a ~76 KB attachment row after the terminal record, which a 64 KiB read could not see past. Positive evidence that a turn is OPEN (the hooks, or the tail) now holds ahead of the tail's ended verdict and ahead of the idle backstop, each bounded to the last 30 minutes so a crashed session still drains.
|
|
8
|
+
- Every `status` draft on the session stream carries `agent_state`, `state_source`, `state_since` and, when known, `waiting_kind`, `waiting_detail`, `failure`, and on an idle state `last_reply`, as extra fields on the existing kind; `state` keeps its vocabulary (running and waiting read `working`, idle and failed `idle`, ended `done`). Every change of a session's hook state is a live status draft, so a client's state line moves on the engine's events. The stream derives from the same inputs the rows do (the hook signal, the registry record, and whether a COS turn is attached), so an Esc-interrupted turn reads idle on the lens the moment the list says so, and a Continue's trail reads `working` for as long as the child writes, never `done` from a stale Stop or from the child's own end. A shipped lens renders the frames exactly as before (its parser keeps `seq` and strips unknown fields). `COS_SESSION_HOOK_SSE=0` omits the extra fields and the live state drafts.
|
|
9
|
+
- Reconnect without a reseed: each streamed session keeps a ring of its last 500 events (the active turn kept whole up to 2,000, the ring outliving the last subscriber by five minutes), each event carries a `cursor` and the server's `epoch`, and the SSE `id:` line is `<epoch>.<cursor>`. `?after=` (or `Last-Event-ID`) replays what the ring holds after that cursor, from the same server life only; a cursor from another life, one past the ring, and one with nothing after it all seed as a fresh open, so a reconnect is never an empty screen. `?seed=turn` opens a session at its current prompt and every step since, oldest first, bounded by the same 512 KiB tail read. The default seed is unchanged.
|
|
10
|
+
- `queued_turns` on every `/api/agent-sessions` row (list, search, detail) and every `/api/claude-sessions` peer: the waiting follow-ups on that thread, omitted at 0. Search hits carry the same derived state fields the list does.
|
|
11
|
+
- Two 6.48.0 defects: a tab adopted mid-turn read idle from a `PostToolUse` with no prompt seen (Claude 2.1.272 reloads hooks when the settings change, so open tabs report without a restart); a main-thread tool event now opens the turn, and a sub-agent's (`agent_id`) does not, live or replayed. `GET /api/session-hooks/runs` listed an interactive tab as a run with no end; runs carry the registry `entrypoint` (`sdk-cli` for `claude -p`, `claude-desktop`, `cli`), the ledger row remembers it so a restart cannot forget which sessions were tabs, and tabs are listed only with `?all=1`.
|
|
12
|
+
- Two defects the live proof found in the follow-up path, both older than 6.48.0. The drainer's loopback delivery sent a turn body without `epoch` and `targetKey`, which the turns route has required since the binding hardening, so every drained follow-up was refused `invalid_request`, refunded and held for its whole TTL: it now sends the epoch the attach minted, the server's own target key and the attach's `boundTo`. And the 30 s idle backstop delivered a follow-up INTO a turn whose tool was still running (a 45 s tool leaves the transcript untouched for 45 s): the open-turn evidence above holds it. A queued turn whose `cosSessionId` the attach route would refuse is refused at enqueue instead of sitting in the queue.
|
|
13
|
+
- `/api/health` `sessionHooks.drain` reports the kick's passes, kicks, folds and open waits.
|
|
14
|
+
|
|
1
15
|
## 6.48.0
|
|
2
16
|
|
|
3
17
|
Session state now comes from Claude Code itself. A hook script spools every session event (session start and end, prompt submitted, turn stopped, permission wanted) and every session row carries one derived state with its provenance. No client change is required: Control 0.5.230 and glasses 6.9.478 ignore the new keys; Control 0.5.231 and the next EHPK read them.
|
package/README.md
CHANGED
|
@@ -181,14 +181,25 @@ The install keeps every hook you already had, backs the file up, and copies a sm
|
|
|
181
181
|
POSIX sh script to `~/.cos-glasses/bin/cos-session-hook`. The script writes one file per
|
|
182
182
|
event into `~/.cos-glasses/data/hook-spool` and never contacts the server (a permission
|
|
183
183
|
request may, only after the desk has been idle for 90 s, for the approval feature that ships
|
|
184
|
-
next). Sessions
|
|
185
|
-
`/api/
|
|
186
|
-
|
|
187
|
-
|
|
184
|
+
next). Sessions report `state_source: hook` on `/api/agent-sessions` and
|
|
185
|
+
`/api/claude-sessions` from their next event on (Claude Code 2.1.272 reloads its hooks
|
|
186
|
+
when the settings file changes, so open tabs need no restart; they may show Claude's
|
|
187
|
+
"hooks modified externally" notice once, which is expected: the user-level file changed
|
|
188
|
+
under them). A later COS Control offers the same install from its Sessions tab. Rows
|
|
189
|
+
change only while the server runs with `COS_CLAUDE_SESSIONS_ENABLED=1`
|
|
188
190
|
(`--hooks status` prints `serverApplies`). Turn the ingestion off with `COS_SESSION_HOOKS=0`
|
|
189
191
|
(the spool is still drained and stamped, rows are exactly as before). Removing the package
|
|
190
192
|
does not remove the hooks: run `--hooks uninstall` first.
|
|
191
193
|
|
|
194
|
+
Since 6.48.1 the hooks also drive the follow-up queue (a queued Continue lands when the
|
|
195
|
+
engine closes the turn: its `Stop` hook followed by its registry record flipping idle,
|
|
196
|
+
which is when every Stop hook has returned), the attach gate (a Desktop holder whose
|
|
197
|
+
turn just ended reads idle at once), and the live session stream (every `status` draft
|
|
198
|
+
carries `agent_state` and friends; `?after=<epoch>.<cursor>` replays what the ring holds
|
|
199
|
+
after a reconnect and seeds afresh when it holds nothing, `?seed=turn` opens at the
|
|
200
|
+
current prompt). `COS_SESSION_HOOK_SSE=0` omits the extra status fields and the live
|
|
201
|
+
state drafts; the `cursor`, `epoch` and `id:` line stay.
|
|
202
|
+
|
|
192
203
|
## Configuration
|
|
193
204
|
|
|
194
205
|
Config lives at `~/.cos-glasses/.env` (created on first run). Every key is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.48.
|
|
3
|
+
"version": "6.48.1",
|
|
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
|
@@ -23,7 +23,7 @@ import { agentSessionStreamRouter } from './routes/agent-session-stream.js'
|
|
|
23
23
|
import { createAttachedTurnStream } from './lib/session-stream-producer.js'
|
|
24
24
|
import { claudeSessionsRouter } from './routes/claude-sessions.js'
|
|
25
25
|
import { createSessionHooksRouter } from './routes/session-hooks.js'
|
|
26
|
-
import { startSessionHooksRuntime } from './lib/session-hooks-runtime.js'
|
|
26
|
+
import { registerDrainKickStats, registryIdleAfterStop, sessionHooksEnabled, sessionSignalStore, signalFor, startSessionHooksRuntime } from './lib/session-hooks-runtime.js'
|
|
27
27
|
import {
|
|
28
28
|
createAgentSessionBindingsRouter,
|
|
29
29
|
TargetGuard,
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
import { AgentSessionBindingRegistry } from './lib/agent-session-binding-registry.js'
|
|
34
34
|
import { targetKey } from './lib/agent-session-binding-store.js'
|
|
35
35
|
import { cosSpawnedPids } from './lib/agent-session-ownership-store.js'
|
|
36
|
-
import { buildOccupancyProbes, realOccupancyDirs } from './lib/occupancy-probes.js'
|
|
36
|
+
import { buildOccupancyProbes, realOccupancyDirs, withHookTurnClock } from './lib/occupancy-probes.js'
|
|
37
37
|
import { realAttachedWorkspaceDeps, resolveAttachedWorkspace } from './lib/attached-workspace.js'
|
|
38
38
|
import { deliverAttachedTurn, realAttachedTurnDeps } from './lib/attached-provider-adapter.js'
|
|
39
39
|
import { forkThread, realForkDeps } from './lib/fork-thread.js'
|
|
@@ -129,7 +129,9 @@ import {
|
|
|
129
129
|
|
|
130
130
|
const app = express()
|
|
131
131
|
import { createThreadTurnQueueRouter, drainAllThreads } from './routes/thread-turn-queue.js'
|
|
132
|
-
import { transcriptTurnEnded } from './lib/thread-turn-queue-store.js'
|
|
132
|
+
import { queuedThreadKeys, transcriptTurnEnded, transcriptTurnVerdict } from './lib/thread-turn-queue-store.js'
|
|
133
|
+
import { OPEN_TURN_CEILING_MS } from './lib/session-state-derive.js'
|
|
134
|
+
import { createDrainKick, kickPlanFor } from './lib/thread-drain-kick.js'
|
|
133
135
|
import { transcriptPathFor } from './lib/native-head.js'
|
|
134
136
|
import { deliverQueuedTurnOverLoopback } from './lib/thread-turn-queue-deliver.js'
|
|
135
137
|
import { readFences, writeFences } from './lib/thread-fence-store.js'
|
|
@@ -382,7 +384,12 @@ const attachedWorkspaceDeps = realAttachedWorkspaceDeps(nativeHeadDeps)
|
|
|
382
384
|
* Read the canary evidence in `thread-occupancy.ts` under THE IDLE-HOLDER
|
|
383
385
|
* RELAXATION before changing this line.
|
|
384
386
|
*/
|
|
385
|
-
const occupancyProbes =
|
|
387
|
+
const occupancyProbes = sessionHooksEnabled()
|
|
388
|
+
// 6.48.1, the B6 clause: with the hooks on, a foreign Desktop holder whose newest hook
|
|
389
|
+
// event is a Stop reads idle at once instead of after the 30 s transcript window. The
|
|
390
|
+
// wrapper only adds a probe; the gate's precedence is unchanged. Off is 6.48.0.
|
|
391
|
+
? withHookTurnClock(buildOccupancyProbes(cosSpawnedPids, nativeHeadDeps, threadAttachEnabled()), signalFor, registryIdleAfterStop)
|
|
392
|
+
: buildOccupancyProbes(cosSpawnedPids, nativeHeadDeps, threadAttachEnabled())
|
|
386
393
|
|
|
387
394
|
// 6.48.0: the hook spool ingester and the one signal store every session row reads. Starts
|
|
388
395
|
// before any router is registered so the first list request already sees the replayed
|
|
@@ -622,11 +629,44 @@ if (threadAttachEnabled()) {
|
|
|
622
629
|
turnEnded: (provider: string, threadId: string) => {
|
|
623
630
|
if (provider !== 'claude' && provider !== 'codex') return false
|
|
624
631
|
try {
|
|
632
|
+
// 6.48.1: the engine's own end of turn: a Stop hook newer than the turn's prompt
|
|
633
|
+
// AND the registry flipped idle after it (the Stop hooks have returned; a prompt
|
|
634
|
+
// typed during them is queued and dequeues only then). The transcript tail
|
|
635
|
+
// (`turnFromTail`) answers for everything the hooks did not see. Both refuse on doubt.
|
|
636
|
+
if (provider === 'claude') {
|
|
637
|
+
const signal = signalFor(threadId)
|
|
638
|
+
if (signal && !signal.turnOpen && signal.lastEvent === 'Stop' && typeof signal.stopAt === 'number'
|
|
639
|
+
&& signal.stopAt >= (signal.turnStartedAt ?? 0) && signal.subagentsOpen === 0
|
|
640
|
+
&& registryIdleAfterStop(threadId, signal.stopAt) === true) return true
|
|
641
|
+
}
|
|
625
642
|
return transcriptTurnEnded(provider, transcriptPathFor(provider, threadId, nativeHeadDeps))
|
|
626
643
|
} catch {
|
|
627
644
|
return false
|
|
628
645
|
}
|
|
629
646
|
},
|
|
647
|
+
// 6.48.1: positive evidence the holder's turn is OPEN, which outranks the 30 s idle
|
|
648
|
+
// backstop (a long tool leaves the transcript untouched while it runs). Bounded: a
|
|
649
|
+
// signal or a tail older than OPEN_TURN_CEILING_MS is not evidence any more, so a
|
|
650
|
+
// crashed mid-tool session still drains by the backstop rather than holding for the TTL.
|
|
651
|
+
turnOpen: (provider: string, threadId: string): boolean => {
|
|
652
|
+
if (provider !== 'claude' && provider !== 'codex') return false
|
|
653
|
+
try {
|
|
654
|
+
const now = Date.now()
|
|
655
|
+
if (provider === 'claude') {
|
|
656
|
+
const signal = signalFor(threadId)
|
|
657
|
+
if (signal && signal.turnOpen && now - signal.lastEventAt <= OPEN_TURN_CEILING_MS) return true
|
|
658
|
+
}
|
|
659
|
+
const path = transcriptPathFor(provider, threadId, nativeHeadDeps)
|
|
660
|
+
const verdict = transcriptTurnVerdict(provider, path)
|
|
661
|
+
if (!verdict || verdict.ended) return false
|
|
662
|
+
if (verdict.reason !== 'tool_pending' && verdict.reason !== 'prompt_open') return false
|
|
663
|
+
const read = occupancyProbes.transcriptMtimeMs
|
|
664
|
+
const mtime = typeof read === 'function' ? read(provider, threadId) : null
|
|
665
|
+
return typeof mtime === 'number' && now - mtime <= OPEN_TURN_CEILING_MS
|
|
666
|
+
} catch {
|
|
667
|
+
return false
|
|
668
|
+
}
|
|
669
|
+
},
|
|
630
670
|
activity: (provider: string, threadId: string): 'working' | 'idle' | 'unknown' => {
|
|
631
671
|
try {
|
|
632
672
|
const read = occupancyProbes.transcriptMtimeMs
|
|
@@ -645,10 +685,33 @@ if (threadAttachEnabled()) {
|
|
|
645
685
|
// Every 20s. Fast enough that a freed thread drains while the user is still looking
|
|
646
686
|
// at the pending row, slow enough to be nothing: the sweep does no work at all when
|
|
647
687
|
// no queue file exists. Unref'd so it never holds the process open.
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
688
|
+
//
|
|
689
|
+
// 6.48.1: the timer and the hooks' Stop share ONE in-flight guard (`thread-drain-kick`),
|
|
690
|
+
// so a Stop landing mid-sweep folds into exactly one follow-up pass and a sentence is
|
|
691
|
+
// never delivered twice. A Stop kicks only when a queue file names that session. A
|
|
692
|
+
// SubagentStop does NOT kick (QA, 2026-09-15): a background sub-agent finishing after
|
|
693
|
+
// the tab's Stop is answered by the model in a turn of its own, whose Stop kicks; and
|
|
694
|
+
// neither the hook short-circuit nor the B6 clause vouches for a session whose newest
|
|
695
|
+
// event is a SubagentStop, so a kick there could only fall through to the 30 s backstop.
|
|
696
|
+
const drainKick = createDrainKick({
|
|
697
|
+
drain: () => drainAllThreads(queueDeps),
|
|
698
|
+
queuedThreadIds: () => queuedThreadKeys().map(k => k.threadId),
|
|
699
|
+
})
|
|
700
|
+
registerDrainKickStats(() => drainKick.stats())
|
|
701
|
+
const queueDrainTimer = setInterval(() => { void drainKick.sweep() }, 20_000)
|
|
651
702
|
queueDrainTimer.unref()
|
|
703
|
+
if (sessionHooksEnabled()) {
|
|
704
|
+
sessionSignalStore.subscribe((signal, env, child) => {
|
|
705
|
+
// The rule is `kickPlanFor` (pure, pinned in thread-drain-kick.test.ts). The Stop is
|
|
706
|
+
// not the engine's end of turn: that is the registry flipping idle once every Stop
|
|
707
|
+
// hook has returned (12-38 s on this Mac). Wait for it, then sweep; a session with
|
|
708
|
+
// no registry record (a print run that exited) is ready at once.
|
|
709
|
+
const plan = kickPlanFor(signal, env.event, env.ts, child)
|
|
710
|
+
if (!plan) return
|
|
711
|
+
if (plan.kind === 'kick') drainKick.kick(signal.sessionId)
|
|
712
|
+
else drainKick.kickWhen(signal.sessionId, () => registryIdleAfterStop(signal.sessionId, plan.stopAt) !== false)
|
|
713
|
+
})
|
|
714
|
+
}
|
|
652
715
|
}
|
|
653
716
|
|
|
654
717
|
app.use('/api', createAgentSessionBindingsRouter({
|
|
@@ -29,6 +29,9 @@
|
|
|
29
29
|
// nameSource entirely. Only `derived` is a function of the folder name.
|
|
30
30
|
|
|
31
31
|
/** Fields this module will read. Everything else in the file is ignored. */
|
|
32
|
+
import { homedir } from 'node:os'
|
|
33
|
+
import { join, resolve } from 'node:path'
|
|
34
|
+
|
|
32
35
|
export interface RawClaudeSession {
|
|
33
36
|
pid?: unknown
|
|
34
37
|
sessionId?: unknown
|
|
@@ -98,6 +101,22 @@ export interface PeerProbes {
|
|
|
98
101
|
/** A registry filename is exactly `<pid>.json`. Not `*.json`. */
|
|
99
102
|
export const REGISTRY_FILENAME = /^\d+\.json$/
|
|
100
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Where the registry lives.
|
|
106
|
+
*
|
|
107
|
+
* `COS_CLAUDE_SESSIONS_DIR` first because it is both the override for a non-standard
|
|
108
|
+
* install AND the test seam: `homedir()` is not mockable, so without an env hook the
|
|
109
|
+
* only testable path would be the real one. Then CLAUDE_CONFIG_DIR, which real
|
|
110
|
+
* installs do set; hardcoding ~/.claude breaks those. (Moved here from the route in
|
|
111
|
+
* 6.48.1 so the hooks runtime can read it without importing a router.)
|
|
112
|
+
*/
|
|
113
|
+
export function claudeSessionsDir(): string {
|
|
114
|
+
const explicit = process.env.COS_CLAUDE_SESSIONS_DIR
|
|
115
|
+
if (explicit) return resolve(explicit)
|
|
116
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR
|
|
117
|
+
return join(configDir ? resolve(configDir) : join(homedir(), '.claude'), 'sessions')
|
|
118
|
+
}
|
|
119
|
+
|
|
101
120
|
function str(value: unknown): string | null {
|
|
102
121
|
return typeof value === 'string' && value.length > 0 ? value : null
|
|
103
122
|
}
|
|
@@ -787,6 +787,49 @@ export function buildOccupancyProbes(
|
|
|
787
787
|
* adapter's pre-spawn preflight REFUSES a thenable rather than awaiting one —
|
|
788
788
|
* awaiting there would reopen the very race the check exists to close.
|
|
789
789
|
*/
|
|
790
|
+
/**
|
|
791
|
+
* Add the hook turn clock (the B6 clause, 6.48.1) to a probe set.
|
|
792
|
+
*
|
|
793
|
+
* `signalFor` is the signal store's reader, taking a full session id or the registry's
|
|
794
|
+
* 8-character form; `registryIdleAfterStop` reads the registry record for the session.
|
|
795
|
+
* The probe answers a Stop time only when BOTH vouch: the session's newest hook event is
|
|
796
|
+
* that Stop, the turn is closed, no sub-agent is open, the session has not ended, and
|
|
797
|
+
* the registry says idle at or after the Stop (the hooks have returned). Codex never
|
|
798
|
+
* asks (its ids are not Claude sessions, and a Codex thread id sharing a Claude prefix
|
|
799
|
+
* must not read a Claude signal); `readHolderActivity` guards the provider too.
|
|
800
|
+
*
|
|
801
|
+
* Wired at the composition root only when `COS_SESSION_HOOKS` is on. Absent, the
|
|
802
|
+
* gate is byte-for-byte 6.48.0.
|
|
803
|
+
*/
|
|
804
|
+
export function withHookTurnClock(
|
|
805
|
+
probes: OccupancyProbes,
|
|
806
|
+
signalFor: (sessionId: string) => HookTurnSignal | undefined,
|
|
807
|
+
registryIdleAfterStop: (sessionId: string, stopAt: number) => boolean | null,
|
|
808
|
+
): OccupancyProbes {
|
|
809
|
+
return {
|
|
810
|
+
...probes,
|
|
811
|
+
holderTurnEndedAtMs: (provider, threadId) => {
|
|
812
|
+
if (provider !== 'claude') return null
|
|
813
|
+
if (!NATIVE_THREAD_ID_RE.test(threadId)) return null
|
|
814
|
+
const signal = signalFor(threadId)
|
|
815
|
+
if (!signal || signal.lastEvent !== 'Stop' || signal.turnOpen || signal.subagentsOpen > 0 || signal.ended !== null) return null
|
|
816
|
+
if (typeof signal.stopAt !== 'number') return null
|
|
817
|
+
// The engine's own end of turn: the registry flipped idle AFTER this Stop, which
|
|
818
|
+
// happens only once every Stop hook has returned. Anything else is strict.
|
|
819
|
+
return registryIdleAfterStop(threadId, signal.stopAt) === true ? signal.stopAt : null
|
|
820
|
+
},
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** The facts the hook turn clock reads off a session signal. */
|
|
825
|
+
export interface HookTurnSignal {
|
|
826
|
+
lastEvent: string
|
|
827
|
+
turnOpen: boolean
|
|
828
|
+
subagentsOpen: number
|
|
829
|
+
ended: { at: number; reason: string } | null
|
|
830
|
+
stopAt: number | null
|
|
831
|
+
}
|
|
832
|
+
|
|
790
833
|
export function withTranscriptClock(
|
|
791
834
|
probes: OccupancyProbes,
|
|
792
835
|
headDeps: NativeHeadDeps,
|
|
@@ -182,6 +182,11 @@ export function projectHookPayload(event: HookEventName, payload: Record<string,
|
|
|
182
182
|
tool_target: toolTarget(payload.tool_input),
|
|
183
183
|
tool_fingerprint: toolFingerprint(toolName, payload.tool_input ?? null),
|
|
184
184
|
...(str('tool_use_id') ? { tool_use_id: str('tool_use_id') } : {}),
|
|
185
|
+
// A sub-agent's tool events carry these (docs; unrecorded as of 2026-09-15). The
|
|
186
|
+
// store reads `agent_id` to keep them from reopening the main turn, so the ledger
|
|
187
|
+
// must keep it or a replay reopens what the live path did not.
|
|
188
|
+
...(str('agent_id') ? { agent_id: str('agent_id') } : {}),
|
|
189
|
+
...(str('agent_type') ? { agent_type: str('agent_type') } : {}),
|
|
185
190
|
...(event === 'PermissionRequest' && Array.isArray(payload.permission_suggestions)
|
|
186
191
|
? { permission_suggestions: payload.permission_suggestions.slice(0, 4) }
|
|
187
192
|
: {}),
|
|
@@ -20,6 +20,8 @@ export interface LedgerRow {
|
|
|
20
20
|
session_id: string
|
|
21
21
|
/** Classified at ingest, while the pid was alive: a COS-spawned child's event. */
|
|
22
22
|
child?: boolean
|
|
23
|
+
/** The registry's `entrypoint` for the session, once read (6.48.1); a replay restores it. */
|
|
24
|
+
entrypoint?: string
|
|
23
25
|
payload: Record<string, unknown>
|
|
24
26
|
}
|
|
25
27
|
|
|
@@ -54,7 +56,7 @@ export class SessionHookLedger {
|
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
/** Append one row. Returns false (and remembers why) when the disk refused. */
|
|
57
|
-
append(key: string, env: HookEnvelope, child = false): boolean {
|
|
59
|
+
append(key: string, env: HookEnvelope, child = false, entrypoint: string | null = null): boolean {
|
|
58
60
|
let row: LedgerRow
|
|
59
61
|
try {
|
|
60
62
|
row = {
|
|
@@ -64,6 +66,7 @@ export class SessionHookLedger {
|
|
|
64
66
|
event: env.event,
|
|
65
67
|
session_id: env.sessionId,
|
|
66
68
|
...(child ? { child: true } : {}),
|
|
69
|
+
...(entrypoint ? { entrypoint } : {}),
|
|
67
70
|
payload: projectHookPayload(env.event, env.payload),
|
|
68
71
|
}
|
|
69
72
|
} catch (error) {
|
|
@@ -96,7 +99,7 @@ export class SessionHookLedger {
|
|
|
96
99
|
* hide the rows that matter most, and forget their keys). Malformed lines are skipped;
|
|
97
100
|
* a boot must never fail on a torn last line.
|
|
98
101
|
*/
|
|
99
|
-
replay(sinceMs: number, apply: (env: HookEnvelope, key: string, child: boolean) => void): { rows: number; applied: number; keys: Set<string> } {
|
|
102
|
+
replay(sinceMs: number, apply: (env: HookEnvelope, key: string, child: boolean, entrypoint: string | null) => void): { rows: number; applied: number; keys: Set<string> } {
|
|
100
103
|
const keys = new Set<string>()
|
|
101
104
|
let rows = 0
|
|
102
105
|
let applied = 0
|
|
@@ -115,7 +118,8 @@ export class SessionHookLedger {
|
|
|
115
118
|
keys.add(r.key)
|
|
116
119
|
if (r.ts < sinceMs) continue
|
|
117
120
|
const payload = r.payload && typeof r.payload === 'object' ? r.payload as Record<string, unknown> : {}
|
|
118
|
-
|
|
121
|
+
const entrypoint = typeof r.entrypoint === 'string' && r.entrypoint ? r.entrypoint : null
|
|
122
|
+
apply({ ts: r.ts, ppid: typeof r.ppid === 'number' ? r.ppid : null, event: r.event, sessionId: r.session_id.toLowerCase(), payload: { session_id: r.session_id, ...payload } }, r.key, r.child === true, entrypoint)
|
|
119
123
|
applied++
|
|
120
124
|
}
|
|
121
125
|
return { rows, applied, keys }
|
|
@@ -45,9 +45,15 @@ export interface SpoolIngesterOptions {
|
|
|
45
45
|
dir: string
|
|
46
46
|
ledger: SessionHookLedger
|
|
47
47
|
/** Apply a parsed envelope to the store. Absent when the feature is off. */
|
|
48
|
-
apply?: (env: HookEnvelope, child: boolean) => void
|
|
48
|
+
apply?: (env: HookEnvelope, child: boolean, entrypoint: string | null) => void
|
|
49
49
|
/** Classify the envelope's ppid at INGEST time, while the pid is still alive. */
|
|
50
50
|
isChild?: (env: HookEnvelope) => boolean
|
|
51
|
+
/**
|
|
52
|
+
* The session's registry `entrypoint`, read at INGEST time while the record is alive
|
|
53
|
+
* (6.48.1). Written onto the ledger row so a replay after a restart knows a print run
|
|
54
|
+
* from a tab without a registry that has since been reaped.
|
|
55
|
+
*/
|
|
56
|
+
entrypointOf?: (env: HookEnvelope, child: boolean) => string | null
|
|
51
57
|
/** Keys already in the ledger at boot, so a replayed file is not applied twice. */
|
|
52
58
|
seenKeys?: Set<string>
|
|
53
59
|
sweepMs?: number
|
|
@@ -175,15 +181,17 @@ export function startSpoolIngester(options: SpoolIngesterOptions): SpoolIngester
|
|
|
175
181
|
// replay after a restart (when the spawn ledger is empty) applies the same rule.
|
|
176
182
|
let child = false
|
|
177
183
|
try { child = options.isChild?.(env) ?? false } catch { child = false }
|
|
184
|
+
let entrypoint: string | null = null
|
|
185
|
+
try { entrypoint = options.entrypointOf?.(env, child) ?? null } catch { entrypoint = null }
|
|
178
186
|
let appended = false
|
|
179
|
-
try { appended = ledger.append(name, env, child) } catch (error) { stats.lastError = errorCode(error) }
|
|
187
|
+
try { appended = ledger.append(name, env, child, entrypoint) } catch (error) { stats.lastError = errorCode(error) }
|
|
180
188
|
if (!appended) {
|
|
181
189
|
// Keep the file: the ledger is the durable record and it refused. Health shows why.
|
|
182
190
|
stats.lastError = ledger.stats().lastError ?? stats.lastError
|
|
183
191
|
return false
|
|
184
192
|
}
|
|
185
193
|
seen.add(name)
|
|
186
|
-
try { options.apply?.(env, child) } catch (error) {
|
|
194
|
+
try { options.apply?.(env, child, entrypoint) } catch (error) {
|
|
187
195
|
stats.applyErrors++
|
|
188
196
|
console.error(`[hook-spool] apply failed for ${name}: ${error instanceof Error ? error.message : error}`)
|
|
189
197
|
}
|
|
@@ -7,8 +7,11 @@
|
|
|
7
7
|
// Update Server (main.swift `providerEnvironmentKeys`).
|
|
8
8
|
|
|
9
9
|
import { execFileSync } from 'node:child_process'
|
|
10
|
-
import { mkdirSync } from 'node:fs'
|
|
10
|
+
import { mkdirSync, readFileSync, readdirSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
11
12
|
import { cosSpawnedPids } from './agent-session-ownership-store.js'
|
|
13
|
+
import { REGISTRY_FILENAME, claudeSessionsDir } from './claude-session-registry.js'
|
|
14
|
+
import { MAX_REGISTRY_FILES } from './thread-occupancy.js'
|
|
12
15
|
import { dataPath } from './data-dir.js'
|
|
13
16
|
import { SessionHookLedger } from './session-hook-ledger.js'
|
|
14
17
|
import { startSpoolIngester, type SpoolIngester, type SpoolStats } from './session-hook-spool.js'
|
|
@@ -78,6 +81,87 @@ export function isCosSpawnedPid(pid: number | null, nowMs = Date.now()): boolean
|
|
|
78
81
|
|
|
79
82
|
export const sessionSignalStore = new SessionSignalStore({ isCosSpawnedPid })
|
|
80
83
|
|
|
84
|
+
// 6.48.1: which kind of process a session is, read off its registry record while it is alive.
|
|
85
|
+
// `claude -p` registers as `entrypoint: sdk-cli`; a Desktop tab as `claude-desktop`; a terminal
|
|
86
|
+
// as `cli`. The record may land a beat after the SessionStart hook, so the read is retried on
|
|
87
|
+
// the session's next few events (a synchronous, bounded registry scan each) and then given
|
|
88
|
+
// up. Once known it rides the LEDGER ROW (QA, 2026-09-15): a replay after a restart restores
|
|
89
|
+
// it, so a tab that ended inside the replay window is not listed as a print run for want of
|
|
90
|
+
// a registry record that the reaper has since removed.
|
|
91
|
+
const ENTRYPOINT_ATTEMPTS = 6
|
|
92
|
+
const entrypointAttempts = new Map<string, number>()
|
|
93
|
+
|
|
94
|
+
/** The entrypoint to write on this event's ledger row: the store's, else one registry read while attempts remain. */
|
|
95
|
+
function entrypointFor(sessionId: string, child: boolean): string | null {
|
|
96
|
+
const known = sessionSignalStore.get(sessionId)?.entrypoint ?? null
|
|
97
|
+
if (known || child) return known
|
|
98
|
+
const tried = entrypointAttempts.get(sessionId) ?? 0
|
|
99
|
+
if (tried >= ENTRYPOINT_ATTEMPTS) return null
|
|
100
|
+
entrypointAttempts.set(sessionId, tried + 1)
|
|
101
|
+
if (entrypointAttempts.size > 512) entrypointAttempts.clear()
|
|
102
|
+
return registryEntrypointSync(sessionId)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** One synchronous pass over `~/.claude/sessions/<pid>.json`; null when no record names the session. */
|
|
106
|
+
export function registryEntrypointSync(sessionId: string, dir = claudeSessionsDir()): string | null {
|
|
107
|
+
return registryRecordSync(sessionId, dir)?.entrypoint ?? null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface RegistryRecordFacts {
|
|
111
|
+
pid: number | null
|
|
112
|
+
entrypoint: string | null
|
|
113
|
+
status: string | null
|
|
114
|
+
statusUpdatedAt: number | null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The registry record naming a session, read synchronously and bounded; null when none
|
|
119
|
+
* does. A COS Continue child (`claude -p --resume <id>`) registers under the SAME session
|
|
120
|
+
* id as the tab it resumes, so when two records name the session the one whose pid COS
|
|
121
|
+
* did not spawn is the tab's and wins; the child's is used only when it is alone.
|
|
122
|
+
*/
|
|
123
|
+
export function registryRecordSync(sessionId: string, dir = claudeSessionsDir()): RegistryRecordFacts | null {
|
|
124
|
+
let names: string[]
|
|
125
|
+
try { names = readdirSync(dir).filter(n => REGISTRY_FILENAME.test(n)).slice(0, MAX_REGISTRY_FILES) } catch { return null }
|
|
126
|
+
const wanted = sessionId.toLowerCase()
|
|
127
|
+
let childRecord: RegistryRecordFacts | null = null
|
|
128
|
+
for (const name of names) {
|
|
129
|
+
try {
|
|
130
|
+
const raw = JSON.parse(readFileSync(join(dir, name), 'utf-8')) as Record<string, unknown>
|
|
131
|
+
if (typeof raw.sessionId !== 'string' || raw.sessionId.toLowerCase() !== wanted) continue
|
|
132
|
+
const record: RegistryRecordFacts = {
|
|
133
|
+
pid: typeof raw.pid === 'number' ? raw.pid : null,
|
|
134
|
+
entrypoint: typeof raw.entrypoint === 'string' && raw.entrypoint ? raw.entrypoint : null,
|
|
135
|
+
status: typeof raw.status === 'string' ? raw.status : null,
|
|
136
|
+
statusUpdatedAt: typeof raw.statusUpdatedAt === 'number' ? raw.statusUpdatedAt : null,
|
|
137
|
+
}
|
|
138
|
+
if (isCosSpawnedPid(record.pid)) { childRecord ??= record; continue }
|
|
139
|
+
return record
|
|
140
|
+
} catch { /* a torn write; the next read retries */ }
|
|
141
|
+
}
|
|
142
|
+
return childRecord
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* THE ENGINE'S OWN END OF TURN (6.48.1, QA round). Claude Code does not end a turn at
|
|
147
|
+
* the `end_turn` row or at the Stop hook: it ends it when every Stop hook has RETURNED,
|
|
148
|
+
* and only then writes `stop_hook_summary`, dequeues anything typed meanwhile, and flips
|
|
149
|
+
* the registry to `idle`. On the release Mac the COS repo's own Stop hooks run 12-38 s
|
|
150
|
+
* (`transcript_summary_hook.py`), so "Stop + 2 s" was 13-38 s early: a prompt typed at
|
|
151
|
+
* the desk in that window is QUEUED and dequeues after the hooks, and a follow-up
|
|
152
|
+
* delivered at Stop + 2 s would have been a second writer. The registry's `idle` with
|
|
153
|
+
* `statusUpdatedAt` at or after the Stop is the only signal that says the hooks are done
|
|
154
|
+
* (measured: it flips 4-39 ms after the summary row).
|
|
155
|
+
*
|
|
156
|
+
* True only when a live record names the session AND says idle AND moved at or after
|
|
157
|
+
* `stopAt`. No record (a print run that already exited) is answered by the caller.
|
|
158
|
+
*/
|
|
159
|
+
export function registryIdleAfterStop(sessionId: string, stopAt: number, dir = claudeSessionsDir()): boolean | null {
|
|
160
|
+
const record = registryRecordSync(sessionId, dir)
|
|
161
|
+
if (!record) return null
|
|
162
|
+
return record.status === 'idle' && typeof record.statusUpdatedAt === 'number' && record.statusUpdatedAt >= stopAt
|
|
163
|
+
}
|
|
164
|
+
|
|
81
165
|
let ledger: SessionHookLedger | null = null
|
|
82
166
|
let ingester: SpoolIngester | null = null
|
|
83
167
|
let replayed: { rows: number; applied: number } | null = null
|
|
@@ -96,14 +180,22 @@ export function startSessionHooksRuntime(options: { port: number }): SessionHook
|
|
|
96
180
|
ledger = new SessionHookLedger(dataPath('session-hook-events.jsonl'))
|
|
97
181
|
const enabled = sessionHooksEnabled()
|
|
98
182
|
const since = Date.now() - LEDGER_REPLAY_WINDOW_MS
|
|
99
|
-
const replay = ledger.replay(since, (env, _key, child) => {
|
|
183
|
+
const replay = ledger.replay(since, (env, _key, child, entrypoint) => {
|
|
184
|
+
if (!enabled) return
|
|
185
|
+
sessionSignalStore.apply(env, child)
|
|
186
|
+
if (entrypoint) sessionSignalStore.setEntrypoint(env.sessionId, entrypoint)
|
|
187
|
+
})
|
|
100
188
|
replayed = { rows: replay.rows, applied: replay.applied }
|
|
101
189
|
ingester = startSpoolIngester({
|
|
102
190
|
dir,
|
|
103
191
|
ledger,
|
|
104
192
|
seenKeys: replay.keys,
|
|
105
193
|
isChild: env => isCosSpawnedPid(env.ppid),
|
|
106
|
-
|
|
194
|
+
entrypointOf: enabled ? (env, child) => entrypointFor(env.sessionId, child) : undefined,
|
|
195
|
+
apply: enabled ? (env, child, entrypoint) => {
|
|
196
|
+
sessionSignalStore.apply(env, child)
|
|
197
|
+
if (entrypoint) sessionSignalStore.setEntrypoint(env.sessionId, entrypoint)
|
|
198
|
+
} : undefined,
|
|
107
199
|
})
|
|
108
200
|
// Runtime files the script reads. The port can change per install; the token never does.
|
|
109
201
|
// The script is copied only when MISSING here: a boot must never downgrade what a newer
|
|
@@ -125,8 +217,17 @@ export function startSessionHooksRuntime(options: { port: number }): SessionHook
|
|
|
125
217
|
}
|
|
126
218
|
}
|
|
127
219
|
|
|
220
|
+
/** 6.48.1: the drain kick's counters, registered by the composition root when thread attach is on. */
|
|
221
|
+
type DrainKickStats = { passes: number; kicks: number; folded: number; inFlight: boolean; waiting: number }
|
|
222
|
+
let drainKickStats: (() => DrainKickStats) | null = null
|
|
223
|
+
export function registerDrainKickStats(read: () => DrainKickStats): void {
|
|
224
|
+
drainKickStats = read
|
|
225
|
+
}
|
|
226
|
+
|
|
128
227
|
export interface SessionHooksHealth {
|
|
129
228
|
enabled: boolean
|
|
229
|
+
/** The Stop-driven drain (6.48.1): null when thread attach is off. */
|
|
230
|
+
drain: DrainKickStats | null
|
|
130
231
|
/** The install state word; `installed` below is its boolean. */
|
|
131
232
|
state: HookStatus['state']
|
|
132
233
|
installed: boolean
|
|
@@ -169,6 +270,7 @@ export function sessionHooksHealthFields(): { sessionHooks: SessionHooksHealth }
|
|
|
169
270
|
return {
|
|
170
271
|
sessionHooks: {
|
|
171
272
|
enabled: sessionHooksEnabled(),
|
|
273
|
+
drain: drainKickStats ? drainKickStats() : null,
|
|
172
274
|
state: status.state,
|
|
173
275
|
installed: status.installed,
|
|
174
276
|
scriptSha: status.scriptSha,
|
|
@@ -197,11 +299,11 @@ export function signalFor(sessionId: string): SessionSignal | undefined {
|
|
|
197
299
|
* Derive with the two-scan memory kept here, keyed by full id when known. Rows the
|
|
198
300
|
* caller has no facts for at all get nothing, so an older payload stays byte-identical.
|
|
199
301
|
*/
|
|
200
|
-
export function deriveForRow(input: { sessionId: string; registry?: RegistryFacts; transcript?: TranscriptFacts; now?: number }): DerivedSessionState | undefined {
|
|
302
|
+
export function deriveForRow(input: { sessionId: string; registry?: RegistryFacts; transcript?: TranscriptFacts; now?: number; remember?: boolean; attachedTurn?: boolean }): DerivedSessionState | undefined {
|
|
201
303
|
// Off means off: with the feature disabled every row is byte-identical to 6.47.0.
|
|
202
304
|
if (!sessionHooksEnabled()) return undefined
|
|
203
305
|
const signal = signalFor(input.sessionId)
|
|
204
|
-
if (!signal && !input.registry && !input.transcript) return undefined
|
|
306
|
+
if (!signal && !input.registry && !input.transcript && !input.attachedTurn) return undefined
|
|
205
307
|
const now = input.now ?? Date.now()
|
|
206
308
|
const key = signal?.sessionId ?? input.sessionId.toLowerCase()
|
|
207
309
|
const prev = deadById.get(key)
|
|
@@ -212,7 +314,12 @@ export function deriveForRow(input: { sessionId: string; registry?: RegistryFact
|
|
|
212
314
|
now,
|
|
213
315
|
prevDeadScans: prev?.scans ?? 0,
|
|
214
316
|
prevDeadSince: prev?.since ?? null,
|
|
317
|
+
attachedTurn: input.attachedTurn === true,
|
|
215
318
|
})
|
|
319
|
+
// A reader with no registry facts (the live feed derives from the signal alone) must
|
|
320
|
+
// not touch the two-scan memory the row readers keep: a derive that saw no registry
|
|
321
|
+
// would reset a dead pid's count between two list requests.
|
|
322
|
+
if (input.remember === false || !input.registry) return derived
|
|
216
323
|
if (deadById.size > 2_048) {
|
|
217
324
|
for (const [k, v] of deadById) if (now - v.at > DEAD_MEMORY_MS) deadById.delete(k)
|
|
218
325
|
}
|
|
@@ -225,5 +332,7 @@ export function __resetSessionHooksForTests(): void {
|
|
|
225
332
|
deadById.clear()
|
|
226
333
|
parentCache.clear()
|
|
227
334
|
recentCosPids.clear()
|
|
335
|
+
entrypointAttempts.clear()
|
|
336
|
+
drainKickStats = null
|
|
228
337
|
statusCache = null
|
|
229
338
|
}
|