@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.
@@ -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
  }
@@ -60,7 +60,7 @@ export interface SessionSignal {
60
60
  turnOpen: boolean
61
61
  turnStartedAt: number | null
62
62
  promptId: string | null
63
- /** Stamped by the newest Stop: `state_since` for an idle row. (6.48.1's B6 occupancy clause will compare it to the transcript.) */
63
+ /** Stamped by the newest Stop: `state_since` for an idle row, and the instant the B6 occupancy clause and the drain gate compare with the registry's `statusUpdatedAt` (6.48.1). */
64
64
  stopAt: number | null
65
65
  waiting: WaitingSignal | null
66
66
  failure: FailureSignal | null
@@ -74,6 +74,12 @@ export interface SessionSignal {
74
74
  compactions: number
75
75
  /** Events from a COS-spawned child on this id, kept out of the phase. */
76
76
  childEvents: number
77
+ /**
78
+ * The registry's `entrypoint` for this session (`claude-desktop`, `cli`, `sdk-cli`), read
79
+ * from `~/.claude/sessions` while the process is alive; null until seen. A `claude -p` job
80
+ * registers as `sdk-cli` (measured 2026-09-15), which is how `/runs` tells a job from a tab.
81
+ */
82
+ entrypoint: string | null
77
83
  /** Hooks have been seen for this session: the row may say `state_source: hook`. */
78
84
  hooksSeen: true
79
85
  }
@@ -120,12 +126,26 @@ function fresh(env: HookEnvelope): SessionSignal {
120
126
  keepWarm: false,
121
127
  compactions: 0,
122
128
  childEvents: 0,
129
+ entrypoint: null,
123
130
  hooksSeen: true,
124
131
  }
125
132
  }
126
133
 
127
134
  const str = (v: unknown): string | null => (typeof v === 'string' && v.length > 0 ? v : null)
128
135
 
136
+ /**
137
+ * 6.48.1. A tool runs only inside a turn, so a MAIN-THREAD tool event (no `agent_id`) is
138
+ * evidence the turn is open even when its UserPromptSubmit was never seen: a tab adopted
139
+ * mid-turn when the hooks were installed (2.1.272 reloads hooks on the settings change,
140
+ * measured 2026-09-15 16:16) read `idle` from the hooks while the registry said busy. A
141
+ * sub-agent's tool events carry `agent_id` and say nothing about the main thread, and a
142
+ * tool event after SessionEnd is a child's, never the tab's.
143
+ */
144
+ function turnOpenedByTool(prev: SessionSignal, p: Record<string, unknown>, at: number): Partial<SessionSignal> {
145
+ if (prev.turnOpen || prev.ended || str(p.agent_id)) return {}
146
+ return { turnOpen: true, turnStartedAt: prev.turnStartedAt ?? at }
147
+ }
148
+
129
149
  /** Waiting kinds a PostToolUse of the same tool resolves without a fingerprint match. */
130
150
  const TOOL_WAITING: Record<string, WaitingKind> = { AskUserQuestion: 'question', ExitPlanMode: 'plan' }
131
151
 
@@ -193,7 +213,7 @@ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelop
193
213
  waiting: { kind, detail: tool.target, toolName: tool.name, fingerprint: tool.fingerprint, since: env.ts, requestId: null },
194
214
  }
195
215
  }
196
- return { ...next, lastTool: tool.name || next.lastTool, lastToolAt: env.ts }
216
+ return { ...next, ...turnOpenedByTool(next, p, env.ts), lastTool: tool.name || next.lastTool, lastToolAt: env.ts }
197
217
  }
198
218
  case 'PermissionRequest': {
199
219
  const tool = toolFacts(p)
@@ -216,7 +236,7 @@ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelop
216
236
  const tool = toolFacts(p)
217
237
  const waiting = next.waiting && resolvesWaiting(next.waiting, env.event, tool.name, tool.fingerprint) ? null : next.waiting
218
238
  const ran = env.event !== 'PermissionDenied'
219
- return { ...next, waiting, ...(ran ? { lastTool: tool.name || next.lastTool, lastToolAt: env.ts } : {}) }
239
+ return { ...next, ...turnOpenedByTool(next, p, env.ts), waiting, ...(ran ? { lastTool: tool.name || next.lastTool, lastToolAt: env.ts } : {}) }
220
240
  }
221
241
  case 'Notification': {
222
242
  const type = str(p.notification_type) ?? ''
@@ -259,7 +279,7 @@ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelop
259
279
  }
260
280
  }
261
281
 
262
- export type SignalListener = (signal: SessionSignal, env: HookEnvelope) => void
282
+ export type SignalListener = (signal: SessionSignal, env: HookEnvelope, child: boolean) => void
263
283
 
264
284
  /** Records older than this after a SessionEnd are dropped; Control keeps its own ledger. */
265
285
  export const SIGNAL_PRUNE_AFTER_END_MS = 6 * 60 * 60_000
@@ -278,14 +298,22 @@ export class SessionSignalStore {
278
298
  apply(env: HookEnvelope, child?: boolean): SessionSignal {
279
299
  const next = applyHookEvent(this.signals.get(env.sessionId), env, this.ctx, child)
280
300
  this.signals.set(env.sessionId, next)
301
+ const isChild = child ?? this.ctx.isCosSpawnedPid(env.ppid)
281
302
  for (const listener of this.listeners) {
282
- try { listener(next, env) } catch (error) {
303
+ try { listener(next, env, isChild) } catch (error) {
283
304
  console.error(`[session-signals] listener failed: ${error instanceof Error ? error.message : error}`)
284
305
  }
285
306
  }
286
307
  return next
287
308
  }
288
309
 
310
+ /** The registry's entrypoint, once the runtime has read it; a no-op for an unknown session. */
311
+ setEntrypoint(sessionId: string, entrypoint: string | null): void {
312
+ const current = this.signals.get(sessionId)
313
+ if (!current || !entrypoint || current.entrypoint === entrypoint) return
314
+ this.signals.set(sessionId, { ...current, entrypoint })
315
+ }
316
+
289
317
  /** RESERVED FOR 6.48.2 (the permission broker); no caller in 6.48.0. Attach the broker's
290
318
  * minted id so rows can carry `pending_permission_id`. */
291
319
  attachPermissionRequestId(sessionId: string, requestId: string | null): void {
@@ -64,6 +64,14 @@ export interface DeriveInput {
64
64
  registry: RegistryFacts | undefined
65
65
  transcript: TranscriptFacts | undefined
66
66
  now: number
67
+ /**
68
+ * A COS-spawned turn (Continue, a queued follow-up, a job) is writing this session
69
+ * RIGHT NOW (`isAttachedTurnActive`). Its hook events are classified as a child's and
70
+ * kept out of the phase, so without this the tab's last state (idle after its Stop)
71
+ * would be reported while COS itself is generating (QA, 2026-09-15: the shipped lens
72
+ * drops the trail on `idle`). Running, source `transcript`: it is our own write.
73
+ */
74
+ attachedTurn?: boolean
67
75
  /** The previous derive's `deadScans`, so the two-scan rule survives between polls. */
68
76
  prevDeadScans?: number
69
77
  /** The previous derive's `deadSince`. */
@@ -100,6 +108,10 @@ export function deriveSessionState(input: DeriveInput): DerivedSessionState {
100
108
  const hooksSilent = !!signal && now - signal.lastEventAt > HOOK_SILENCE_MS
101
109
  const carry = { deadScans, deadSince }
102
110
 
111
+ if (input.attachedTurn === true) {
112
+ return { agent_state: 'running', state_source: 'transcript', state_since: iso(transcript?.lastActivityAt ?? signal?.lastEventAt ?? now), ...carry, ...replyOf(signal) }
113
+ }
114
+
103
115
  // A dead pid on two scans at least DEAD_GRACE_MS apart ends the row whatever the hooks
104
116
  // last said, unless a hook event is newer than the registry's last movement (a resumed
105
117
  // tab under a new pid).
@@ -195,6 +207,32 @@ function waitStillStands(since: number, registry: RegistryFacts | undefined, tra
195
207
  return true
196
208
  }
197
209
 
210
+ /**
211
+ * The fields a `status` draft carries (6.48.1): the row fields minus the permission id
212
+ * (slice 4's, never on the stream) and with `last_reply` only on an idle state, where a
213
+ * feed renders "Idle, last reply: …". A typed projection, so a new row field cannot reach
214
+ * the wire by accident.
215
+ */
216
+ export function derivedStatusFields(derived: DerivedSessionState): {
217
+ agent_state: AgentState
218
+ state_source: StateSource
219
+ state_since: string
220
+ waiting_kind?: WaitingKind
221
+ waiting_detail?: string
222
+ failure?: string
223
+ last_reply?: string
224
+ } {
225
+ return {
226
+ agent_state: derived.agent_state,
227
+ state_source: derived.state_source,
228
+ state_since: derived.state_since,
229
+ ...(derived.waiting_kind ? { waiting_kind: derived.waiting_kind } : {}),
230
+ ...(derived.waiting_detail !== undefined ? { waiting_detail: derived.waiting_detail } : {}),
231
+ ...(derived.failure ? { failure: derived.failure } : {}),
232
+ ...(derived.agent_state === 'idle' && derived.last_reply ? { last_reply: derived.last_reply } : {}),
233
+ }
234
+ }
235
+
198
236
  /** The additive row fields, ready to spread onto a list or detail entry. */
199
237
  export function derivedRowFields(derived: DerivedSessionState | undefined): Record<string, unknown> {
200
238
  if (!derived) return {}
@@ -21,16 +21,92 @@
21
21
  //
22
22
  // So: same transport, own keyspace. This module is deliberately small.
23
23
  //
24
- // NO REPLAY BUFFER HERE EITHER, and that is a decision rather than an omission. A
25
- // reconnecting client resumes LIVE and its 5s/15s/60s poll is what fills the gap it
26
- // missed; the contract gives it `seq` precisely so it can SEE the gap. Retaining a
27
- // per-session ring would add memory that grows with the number of sessions ever
28
- // opened, to duplicate a fallback that already exists and already works.
24
+ // A BOUNDED REPLAY RING PER STREAMED SESSION (6.48.1), and why the earlier "no ring"
25
+ // decision moved. `seq` stays per connection, so a shipped client still sees its gaps;
26
+ // a new client ALSO reads `cursor`, monotonic per session for this server's life, and
27
+ // reconnects with `?after=<cursor>` to be handed what it missed instead of a fresh
28
+ // seed. The ring holds the last RING_MAX published events, keeps the ACTIVE TURN whole
29
+ // (from its newest `prompt` draft onward, up to RING_HARD_MAX) and is dropped with the
30
+ // session's last subscriber plus a linger, so memory is bounded by streamed sessions,
31
+ // not by sessions ever opened. Even Terminal 0.10.4 ships the same shape (500-event
32
+ // ring, replay on reconnect, the active turn always retained).
29
33
 
30
34
  import type { SessionStreamDraft } from './session-stream-events.js'
31
35
 
32
- /** A draft with the publish instant stamped. `seq` stays per connection. */
33
- export type PublishedSessionEvent = SessionStreamDraft & { at: number }
36
+ /** A draft with the publish instant stamped. `seq` stays per connection; `cursor` is per session within `epoch`. */
37
+ export type PublishedSessionEvent = SessionStreamDraft & { at: number; cursor?: number; epoch?: number }
38
+
39
+ /**
40
+ * Cursors are meaningful only within one server life: a client holding a cursor from
41
+ * before a restart would otherwise be handed the wrong events from a fresh ring with no
42
+ * gap to notice (QA, 2026-09-15). Every event carries the epoch, and the SSE `id:` line
43
+ * is `<epoch>.<cursor>`; the route refuses to replay across epochs.
44
+ */
45
+ export const RING_EPOCH = Date.now()
46
+
47
+ /** Events the ring keeps per session. */
48
+ export const RING_MAX = 500
49
+ /** The active turn is never cut short of this. */
50
+ export const RING_HARD_MAX = 2_000
51
+ /** A session's ring outlives its last subscriber this long, so a reconnect finds the gap. */
52
+ export const RING_LINGER_MS = 5 * 60_000
53
+
54
+ interface Ring {
55
+ events: Array<PublishedSessionEvent & { cursor: number }>
56
+ nextCursor: number
57
+ /** Index into `events` of the newest prompt draft, or -1. */
58
+ turnStart: number
59
+ emptySince: number | null
60
+ }
61
+
62
+ const rings = new Map<string, Ring>()
63
+
64
+ function ringFor(key: string): Ring {
65
+ let ring = rings.get(key)
66
+ if (!ring) {
67
+ ring = { events: [], nextCursor: 1, turnStart: -1, emptySince: null }
68
+ rings.set(key, ring)
69
+ }
70
+ return ring
71
+ }
72
+
73
+ function remember(key: string, event: PublishedSessionEvent): PublishedSessionEvent & { cursor: number } {
74
+ const ring = ringFor(key)
75
+ const stamped = { ...event, cursor: ring.nextCursor++, epoch: RING_EPOCH }
76
+ ring.events.push(stamped)
77
+ if (stamped.kind === 'prompt') ring.turnStart = ring.events.length - 1
78
+ // Trim the oldest, but never into the active turn until the hard cap.
79
+ while (ring.events.length > RING_MAX) {
80
+ if (ring.turnStart === 0 && ring.events.length <= RING_HARD_MAX) break
81
+ ring.events.shift()
82
+ if (ring.turnStart >= 0) ring.turnStart--
83
+ }
84
+ return stamped
85
+ }
86
+
87
+ /** Events published to this session after `afterCursor`, oldest first. Empty for an unknown session. */
88
+ export function replaySessionStream(key: string, afterCursor: number): Array<PublishedSessionEvent & { cursor: number }> {
89
+ const ring = rings.get(key)
90
+ if (!ring) return []
91
+ const after = Number.isFinite(afterCursor) ? afterCursor : 0
92
+ return ring.events.filter(e => e.cursor > after)
93
+ }
94
+
95
+ /** The ring's oldest and newest cursors, so a client can tell a replay from a reseed. */
96
+ export function ringBounds(key: string): { oldest: number; newest: number } | null {
97
+ const ring = rings.get(key)
98
+ if (!ring || ring.events.length === 0) return null
99
+ return { oldest: ring.events[0].cursor, newest: ring.events[ring.events.length - 1].cursor }
100
+ }
101
+
102
+ /** Drop rings whose session has had no subscriber for the linger. Called by the route on release. */
103
+ export function sweepSessionRings(nowMs = Date.now()): number {
104
+ let dropped = 0
105
+ for (const [key, ring] of rings) {
106
+ if (ring.emptySince !== null && nowMs - ring.emptySince > RING_LINGER_MS) { rings.delete(key); dropped++ }
107
+ }
108
+ return dropped
109
+ }
34
110
 
35
111
  export type SessionStreamListener = (event: PublishedSessionEvent) => void
36
112
 
@@ -78,6 +154,7 @@ export function subscribeSessionStream(key: string, listener: SessionStreamListe
78
154
  const set = existing ?? new Set<SessionStreamListener>()
79
155
  set.add(listener)
80
156
  listeners.set(key, set)
157
+ ringFor(key).emptySince = null
81
158
 
82
159
  let released = false
83
160
  return () => {
@@ -88,7 +165,12 @@ export function subscribeSessionStream(key: string, listener: SessionStreamListe
88
165
  const current = listeners.get(key)
89
166
  if (!current) return
90
167
  current.delete(listener)
91
- if (current.size === 0) listeners.delete(key)
168
+ if (current.size === 0) {
169
+ listeners.delete(key)
170
+ const ring = rings.get(key)
171
+ if (ring) ring.emptySince = Date.now()
172
+ sweepSessionRings()
173
+ }
92
174
  }
93
175
  }
94
176
 
@@ -103,7 +185,8 @@ export function subscribeSessionStream(key: string, listener: SessionStreamListe
103
185
  export function publishSessionStream(key: string, draft: SessionStreamDraft, at: number = Date.now()): number {
104
186
  const set = listeners.get(key)
105
187
  if (!set || set.size === 0) return 0
106
- const event: PublishedSessionEvent = { ...draft, at }
188
+ // Remembered BEFORE fan-out, so a listener that reads the ring sees this event too.
189
+ const event: PublishedSessionEvent = remember(key, { ...draft, at })
107
190
  let delivered = 0
108
191
  for (const listener of [...set]) {
109
192
  try {
@@ -147,4 +230,5 @@ export function isAttachedTurnActive(key: string): boolean {
147
230
  export function __resetSessionStreamBusForTests(): void {
148
231
  listeners.clear()
149
232
  attachedTurns.clear()
233
+ rings.clear()
150
234
  }
@@ -457,6 +457,150 @@ export function draftsFromLine(provider: SessionStreamProvider, line: string): S
457
457
  return draftsFromRecord(provider, parsed)
458
458
  }
459
459
 
460
+ /**
461
+ * Did the newest turn in this transcript tail END? (6.48.1)
462
+ *
463
+ * THE RULE THE HARNESS USES, read against real Desktop transcripts on this Mac
464
+ * (2026-09-15): a Desktop session writes no `type:'result'` row at all (0 of 30 newest
465
+ * transcripts), so the status-draft rule above can only ever say "ended" for a
466
+ * `claude -p` run. What every Claude transcript DOES carry is `message.stop_reason` on
467
+ * each assistant record: `tool_use` while tools run, `end_turn` when the reply is done.
468
+ *
469
+ * ended the newest assistant record's stop_reason is terminal (end_turn,
470
+ * stop_sequence, max_tokens, refusal) and no tool_use it issued is still
471
+ * waiting for its tool_result; or a `result` row; or the user interrupted
472
+ * (`[Request interrupted by user`), which ends the turn without a reply.
473
+ * open a user prompt newer than the last terminal reply (a `<task-notification>`
474
+ * included: the model answers it), or a tool_use with no result yet.
475
+ *
476
+ * Skipped: `isMeta` and `isCompactSummary` rows (injected context, not a prompt), tool
477
+ * results, and the bookkeeping rows (`system`, `last-prompt`, `attachment`, `mode`...).
478
+ * Codex keeps its event rule (task_complete / turn_complete end, task_started opens).
479
+ *
480
+ * PURE, like everything else here. The queue store reads the tail; this decides.
481
+ */
482
+ export const TERMINAL_STOP_REASONS: ReadonlySet<string> = new Set(['end_turn', 'stop_sequence', 'max_tokens', 'refusal'])
483
+
484
+ export type TurnFromTail = { ended: boolean; reason: 'terminal_stop' | 'result_row' | 'interrupted' | 'codex_complete' | 'prompt_open' | 'prompt_queued' | 'tool_pending' | 'no_evidence' }
485
+
486
+ export function turnFromTail(provider: SessionStreamProvider, lines: readonly string[]): TurnFromTail {
487
+ let verdict: TurnFromTail = { ended: false, reason: 'no_evidence' }
488
+ const pendingToolUses = new Set<string>()
489
+ for (const line of lines) {
490
+ const trimmed = typeof line === 'string' ? line.trim() : ''
491
+ if (!trimmed || trimmed[0] !== '{') continue
492
+ let record: unknown
493
+ try { record = JSON.parse(trimmed) } catch { continue }
494
+ const r = asRecord(record)
495
+ if (!r) continue
496
+
497
+ if (provider === 'codex') {
498
+ for (const draft of draftsFromRecord('codex', r)) {
499
+ if (draft.kind === 'status') verdict = draft.state === 'done' ? { ended: true, reason: 'codex_complete' } : { ended: false, reason: 'prompt_open' }
500
+ }
501
+ continue
502
+ }
503
+
504
+ const type = typeof r.type === 'string' ? r.type : ''
505
+ if (type === 'result') { verdict = { ended: true, reason: 'result_row' }; pendingToolUses.clear(); continue }
506
+ // A prompt typed while the Stop hooks run is QUEUED, not submitted (Claude writes a
507
+ // `queue-operation` row); it dequeues once the hooks return and the model answers it.
508
+ // The turn is not over until then (QA, 2026-09-15). The dequeue is followed by the
509
+ // user row itself, which the next branch reads.
510
+ if (type === 'queue-operation') {
511
+ if (r.operation === 'enqueue') verdict = { ended: false, reason: 'prompt_queued' }
512
+ continue
513
+ }
514
+ // Injected context is not a prompt, except a cross-session message (`promptSource:
515
+ // 'system'`), which the model answers as a turn.
516
+ if (r.isCompactSummary === true) continue
517
+ if (r.isMeta === true && r.promptSource !== 'system') continue
518
+ const message = asRecord(r.message)
519
+ if (!message) continue
520
+ const content = Array.isArray(message.content) ? message.content : typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : []
521
+ const blocks = content.map(asRecord).filter((b): b is Record<string, unknown> => b !== null)
522
+
523
+ if (type === 'user' || message.role === 'user') {
524
+ const results = blocks.filter(b => b.type === 'tool_result')
525
+ if (results.length > 0) {
526
+ for (const b of results) if (typeof b.tool_use_id === 'string') pendingToolUses.delete(b.tool_use_id)
527
+ continue
528
+ }
529
+ const text = blocks.filter(b => b.type === 'text' || b.type === undefined).map(b => (typeof b.text === 'string' ? b.text : '')).join(' ')
530
+ if (/\[Request interrupted by user/.test(text)) { verdict = { ended: true, reason: 'interrupted' }; pendingToolUses.clear(); continue }
531
+ if (text.trim().length === 0) continue
532
+ verdict = { ended: false, reason: 'prompt_open' }
533
+ continue
534
+ }
535
+
536
+ if (type === 'assistant' || message.role === 'assistant') {
537
+ for (const b of blocks) if (b.type === 'tool_use' && typeof b.id === 'string') pendingToolUses.add(b.id)
538
+ const stop = typeof message.stop_reason === 'string' ? message.stop_reason : ''
539
+ if (TERMINAL_STOP_REASONS.has(stop)) {
540
+ verdict = pendingToolUses.size === 0 ? { ended: true, reason: 'terminal_stop' } : { ended: false, reason: 'tool_pending' }
541
+ } else if (stop === 'tool_use' || pendingToolUses.size > 0) {
542
+ verdict = { ended: false, reason: 'tool_pending' }
543
+ }
544
+ }
545
+ }
546
+ return verdict
547
+ }
548
+
549
+ /**
550
+ * The derived session state on a `status` draft (6.48.1).
551
+ *
552
+ * EXTRA FIELDS ON THE EXISTING KIND, NEVER A NEW KIND: every shipped EHPK drops an
553
+ * unknown `kind` before its `seq` accounting and paints a "missed N" gap per event
554
+ * (validation round 1, B2). The shipped parser keeps `seq` and strips unknown fields
555
+ * on a `status` draft (canary 8), so an old client renders exactly as before and a
556
+ * new one reads the state line off the same event.
557
+ *
558
+ * `state` keeps its closed vocabulary. The table:
559
+ * running -> working waiting -> working (+ waiting_kind/detail)
560
+ * idle -> idle failed -> idle (+ failure) ended -> done
561
+ *
562
+ * `done` on open flips the client to its digest body, which is intended for a session
563
+ * whose engine said SessionEnd. An ATTACHED COS TURN WINS (QA, 2026-09-15): the deriver
564
+ * is told `attachedTurn` and answers `running` for as long as the child is writing, so
565
+ * a Continue's trail is never handed off mid-stream by the tab's older Stop or by a
566
+ * child's own SessionEnd; the engine's state resumes the moment the turn detaches.
567
+ *
568
+ * `last_reply` rides only an idle state (the feed's "Idle, last reply: …" line).
569
+ */
570
+ export interface DerivedStatusFields {
571
+ agent_state: 'running' | 'waiting' | 'idle' | 'failed' | 'ended'
572
+ state_source: 'hook' | 'registry' | 'transcript'
573
+ state_since: string
574
+ waiting_kind?: string
575
+ waiting_detail?: string
576
+ failure?: string
577
+ last_reply?: string
578
+ }
579
+
580
+ export type StatusDraftWithDerived = { kind: 'status'; state: SessionStreamState } & Partial<DerivedStatusFields>
581
+
582
+ export function statusDraftWithDerived(
583
+ draft: { kind: 'status'; state: SessionStreamState },
584
+ derived: DerivedStatusFields | undefined,
585
+ ): StatusDraftWithDerived {
586
+ if (!derived) return draft
587
+ const state: SessionStreamState =
588
+ derived.agent_state === 'running' || derived.agent_state === 'waiting' ? 'working'
589
+ : derived.agent_state === 'ended' ? 'done'
590
+ : 'idle'
591
+ return {
592
+ kind: 'status',
593
+ state,
594
+ agent_state: derived.agent_state,
595
+ state_source: derived.state_source,
596
+ state_since: derived.state_since,
597
+ ...(derived.waiting_kind ? { waiting_kind: derived.waiting_kind } : {}),
598
+ ...(derived.waiting_detail !== undefined ? { waiting_detail: derived.waiting_detail } : {}),
599
+ ...(derived.failure ? { failure: derived.failure } : {}),
600
+ ...(state === 'idle' && derived.last_reply ? { last_reply: derived.last_reply } : {}),
601
+ }
602
+ }
603
+
460
604
  /** Draft plus transport stamps, in the field order the contract shows. */
461
605
  export function stampSessionEvent(draft: SessionStreamDraft, seq: number, at: number): SessionStreamEvent {
462
606
  return { seq, at, ...draft }