@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 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 started after the install report `state_source: hook` on
185
- `/api/agent-sessions` and `/api/claude-sessions`; tabs already open keep their startup
186
- hook snapshot until restarted, and may show Claude's "hooks modified externally" notice once (expected: the user-level file changed under them). The next COS Control (0.5.231) offers the same install from
187
- its Sessions tab. Rows change only while the server runs with `COS_CLAUDE_SESSIONS_ENABLED=1`
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.0",
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 = buildOccupancyProbes(cosSpawnedPids, nativeHeadDeps, threadAttachEnabled())
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
- const queueDrainTimer = setInterval(() => {
649
- void drainAllThreads(queueDeps).catch(() => { /* the next sweep retries */ })
650
- }, 20_000)
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
- 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)
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) => { if (enabled) sessionSignalStore.apply(env, 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
- apply: enabled ? (env, child) => { sessionSignalStore.apply(env, child) } : undefined,
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
  }