@gotcos/glasses-server 6.48.0 → 6.48.2

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,25 @@
1
+ ## 6.48.2
2
+
3
+ Continue and Fork work again on a session whose transcript opens with a big row. Reported from the lens on 6.9.482 (Miles, 2026-09-15): Continue on a Claude session answered "Can't continue. Fork thread instead" while the row said the session was attachable.
4
+
5
+ - The attach route reads the session's working directory out of the transcript's first rows. It read a fixed 512 KiB, and a session whose first prompt carried pasted screenshots opens with one row of about 1 MB, so the window held four bookkeeping rows and a torn fifth, no `cwd`, and every Continue and Fork on that session was refused `target_unresolvable`. The read now widens (512 KiB, 2 MiB, 8 MiB, 16 MiB) while the window keeps ending inside a row with no `cwd` seen, and stops as soon as the whole file has been read or a row boundary shows the transcript records none.
6
+ - The first `cwd` in the transcript is the workspace. Two different values used to refuse the attach as a disagreement; on the release Mac every such transcript was one session whose shell had `cd`-ed into a subfolder inside a turn, and Claude stamps each row with the process cwd of the moment. The directory the session started in keys its project folder and is the only place `--resume` finds it, so it is the answer. On the release Mac 738 of 758 transcripts resolve (before: 703); the 20 that do not are scratch sessions whose temporary directory has since been deleted, which is a refusal by design.
7
+ - Tests: the first-cwd rule for Claude and Codex, the torn-window signal, the widening sequence against the production shape (four bookkeeping rows, a 950 KB image row, then the cwd), a short file read once, a first row past the cap refused at the cap. Mutation gate: the widening and the first-cwd rule.
8
+
9
+ ## 6.48.1
10
+
11
+ 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.
12
+
13
+ - 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.
14
+ - 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.
15
+ - 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.
16
+ - 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.
17
+ - 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.
18
+ - `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.
19
+ - 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`.
20
+ - 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.
21
+ - `/api/health` `sessionHooks.drain` reports the kick's passes, kicks, folds and open waits.
22
+
1
23
  ## 6.48.0
2
24
 
3
25
  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.2",
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({
@@ -61,9 +61,20 @@ export interface AttachedWorkspaceDeps {
61
61
  cursorSpawnWorkspace?: (threadId: string) => string | null
62
62
  }
63
63
 
64
- /** Enough to reach a Claude message row or the Codex session meta row. */
64
+ /**
65
+ * The first window read for the cwd: enough to reach a Claude message row or the Codex
66
+ * session meta row in the common case. NOT enough on its own (6.48.2): a session whose
67
+ * first prompt carries pasted screenshots opens with a single row of ~1 MB, so 512 KiB
68
+ * held four bookkeeping rows and a torn fifth, no cwd, and every Continue on that
69
+ * session was refused `target_unresolvable` (Miles, 2026-09-15, from the lens; 55 of
70
+ * 758 transcripts on the release Mac). When a window ends inside a row before any cwd
71
+ * was seen, the read widens up to WORKSPACE_SCAN_BYTES_MAX.
72
+ */
65
73
  export const WORKSPACE_SCAN_BYTES = 512 * 1024
66
74
 
75
+ /** The widest head read: a first prompt with ten screenshots fits; a 13 GB rollout does not get read. */
76
+ export const WORKSPACE_SCAN_BYTES_MAX = 16 * 1024 * 1024
77
+
67
78
  /** Rows to inspect before giving up. Bounds a pathological single-line file. */
68
79
  export const WORKSPACE_SCAN_ROWS = 400
69
80
 
@@ -72,19 +83,37 @@ export function fingerprint(value: string): string {
72
83
  }
73
84
 
74
85
  /**
75
- * Pull the recorded cwd out of transcript text.
86
+ * Pull the recorded cwd out of transcript text: the FIRST row that records one.
76
87
  *
77
88
  * Claude puts it at the row's top level; Codex puts it in the session meta row's
78
89
  * `payload`. A torn trailing line is NORMAL — these files are appended to — so a
79
90
  * parse failure on any single row is skipped rather than fatal.
80
91
  *
81
- * Returns null when no row records one, and DISAGREEMENT is also null: a transcript
82
- * whose rows claim two different working directories is not something to guess at.
92
+ * THE FIRST cwd IS THE WORKSPACE (6.48.2). Until 6.48.1 two different values in the
93
+ * window refused the attach as a disagreement. On the release Mac every such
94
+ * transcript was one session whose shell `cd`-ed inside a turn (`operations/scripts`,
95
+ * a weekly folder): Claude stamps each row with the process cwd of the moment, all
96
+ * under the directory the session was started in. That starting directory is the
97
+ * workspace: it keys the transcript's project folder, it is the only place
98
+ * `claude --resume` finds the session, and it is what the first row records.
99
+ *
100
+ * Returns null only when no row in the window records one.
83
101
  */
84
102
  export function cwdFromTranscript(text: string): string | null {
85
- const seen = new Set<string>()
103
+ return scanForCwd(text).cwd
104
+ }
105
+
106
+ /**
107
+ * The scan with its outcome: the cwd, or whether the window ended INSIDE a row before
108
+ * any cwd was seen (a first prompt of pasted screenshots is one row of ~1 MB), which
109
+ * tells the resolver that a wider read can still answer.
110
+ */
111
+ export function scanForCwd(text: string): { cwd: string | null; endedMidRow: boolean; rows: number } {
86
112
  let rows = 0
87
- for (const line of text.split('\n')) {
113
+ const lines = text.split('\n')
114
+ // A window that does not end with a newline ended inside whatever row came last.
115
+ const endedMidRow = text.length > 0 && !text.endsWith('\n')
116
+ for (const line of lines) {
88
117
  if (rows >= WORKSPACE_SCAN_ROWS) break
89
118
  const trimmed = line.trim()
90
119
  if (!trimmed) continue
@@ -94,18 +123,14 @@ export function cwdFromTranscript(text: string): string | null {
94
123
  if (!row || typeof row !== 'object' || Array.isArray(row)) continue
95
124
  const record = row as Record<string, unknown>
96
125
  const direct = record.cwd
97
- if (typeof direct === 'string' && direct.length > 0) seen.add(direct)
126
+ if (typeof direct === 'string' && direct.length > 0) return { cwd: direct, endedMidRow: false, rows }
98
127
  const payload = record.payload
99
128
  if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
100
129
  const nested = (payload as Record<string, unknown>).cwd
101
- if (typeof nested === 'string' && nested.length > 0) seen.add(nested)
130
+ if (typeof nested === 'string' && nested.length > 0) return { cwd: nested, endedMidRow: false, rows }
102
131
  }
103
- // One consistent answer found early is enough; keep scanning only while it is
104
- // still the only one, so a disagreement is detected rather than short-circuited.
105
- if (seen.size > 1) return null
106
132
  }
107
- if (seen.size !== 1) return null
108
- return [...seen][0]!
133
+ return { cwd: null, endedMidRow, rows }
109
134
  }
110
135
 
111
136
  /**
@@ -145,17 +170,27 @@ export function resolveAttachedWorkspace(
145
170
  }
146
171
  if (provider !== 'claude' && provider !== 'codex') return null
147
172
  let path: string | null
148
- let text: string | null
173
+ let cwd: string | null = null
149
174
  try {
150
175
  path = deps.transcriptPath(provider, threadId)
151
176
  if (path === null) return null
152
- text = deps.readHead(path, WORKSPACE_SCAN_BYTES)
177
+ // Widen the head read while the window keeps ending inside a row with no cwd seen:
178
+ // 512 KiB, 2 MiB, 8 MiB, then the cap. A window that ended on a row boundary with
179
+ // no cwd is a transcript that records none, and widening would not change that.
180
+ let window = WORKSPACE_SCAN_BYTES
181
+ for (;;) {
182
+ const text = deps.readHead(path, window)
183
+ if (text === null) return null
184
+ const scan = scanForCwd(text)
185
+ cwd = scan.cwd
186
+ // The whole file came back (shorter than the window): there is nothing wider to read.
187
+ const wholeFile = Buffer.byteLength(text, 'utf8') < window
188
+ if (cwd !== null || wholeFile || !scan.endedMidRow || scan.rows >= WORKSPACE_SCAN_ROWS || window >= WORKSPACE_SCAN_BYTES_MAX) break
189
+ window = Math.min(window * 4, WORKSPACE_SCAN_BYTES_MAX)
190
+ }
153
191
  } catch {
154
192
  return null
155
193
  }
156
- if (text === null) return null
157
-
158
- const cwd = cwdFromTranscript(text)
159
194
  if (cwd === null) return null
160
195
  // A relative cwd would resolve against the SERVER's working directory, which is
161
196
  // not a location we control and is never what the user meant.
@@ -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
  }