@gotcos/glasses-server 6.47.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.
@@ -13,11 +13,17 @@ import { closeSync, constants, existsSync, fstatSync, mkdirSync, openSync, readd
13
13
  import { join } from 'node:path'
14
14
  import { atomicWriteFileSync } from './atomic-fs.js'
15
15
  import { dataPath } from './data-dir.js'
16
- import { draftsFromLine, type SessionStreamProvider } from './session-stream-events.js'
16
+ import { turnFromTail, type SessionStreamProvider, type TurnFromTail } from './session-stream-events.js'
17
17
  import { pruneQueue, type QueuedThreadTurn } from './thread-turn-queue.js'
18
18
 
19
- /** Bytes of transcript tail read to decide whether the last turn ended. */
20
- export const TURN_END_TAIL_BYTES = 64 * 1024
19
+ /**
20
+ * Bytes of transcript tail read to decide whether the last turn ended. 512 KiB, not 64:
21
+ * on the release Mac 3 of the 10 newest Desktop transcripts end in a ~76 KB bookkeeping
22
+ * row (a `prompt_snapshot` attachment) AFTER the terminal record, and a 64 KiB tail read
23
+ * only that row, saw no conversation record, and held the queue for the 30 s backstop
24
+ * (QA, 2026-09-15). The read is one `pread` per decision; nothing here streams.
25
+ */
26
+ export const TURN_END_TAIL_BYTES = 512 * 1024
21
27
 
22
28
  function queueDir(): string {
23
29
  const dir = dataPath('thread-turn-queue')
@@ -62,6 +68,46 @@ export function writeQueue(provider: string, threadId: string, queue: readonly Q
62
68
  atomicWriteFileSync(queuePath(provider, threadId), `${JSON.stringify(queue, null, 2)}\n`)
63
69
  }
64
70
 
71
+ /**
72
+ * How many WAITING follow-ups a list/detail row should show (6.48.1).
73
+ *
74
+ * Queue files are named with the native thread id (often a full UUID). Claude
75
+ * list rows stay on the registry's 8-character form until a transcript match
76
+ * expands them. An exact match always wins; an 8-character id is allowed to
77
+ * count a unique prefix among waiting queues of that provider, and an
78
+ * ambiguous prefix counts as zero rather than guessing.
79
+ *
80
+ * PURE: the store builds the input from disk; this decides the number.
81
+ */
82
+ export function queuedWaitingForSession(
83
+ queues: ReadonlyArray<{ provider: string; threadId: string; waiting: number }>,
84
+ provider: string, sessionId: string,
85
+ ): number {
86
+ const id = sessionId.toLowerCase()
87
+ const rows = queues.filter(q => q.provider === provider && q.waiting > 0)
88
+ const exact = rows.find(q => q.threadId.toLowerCase() === id)
89
+ if (exact) return exact.waiting
90
+ if (id.length !== 8) return 0
91
+ const prefixed = rows.filter(q => q.threadId.toLowerCase().startsWith(id))
92
+ const owners = new Set(prefixed.map(q => q.threadId.toLowerCase()))
93
+ if (owners.size !== 1) return 0
94
+ return prefixed[0]!.waiting
95
+ }
96
+
97
+ /** One lookup for a whole list/search request: one directory read, then O(1) per row. */
98
+ export function queuedWaitingLookup(now: number): (provider: string, sessionId: string) => number {
99
+ const queues = queuedThreadKeys().map(({ provider, threadId }) => ({
100
+ provider,
101
+ threadId,
102
+ waiting: readQueue(provider, threadId, now).filter(t => t.status === 'waiting').length,
103
+ }))
104
+ return (provider, sessionId) => queuedWaitingForSession(queues, provider, sessionId)
105
+ }
106
+
107
+ export function queuedTurnsFields(count: number): { queued_turns?: number } {
108
+ return count > 0 ? { queued_turns: count } : {}
109
+ }
110
+
65
111
  /** Every thread with a queue file, for the drain sweep. */
66
112
  export function queuedThreadKeys(): Array<{ provider: string; threadId: string }> {
67
113
  try {
@@ -83,24 +129,32 @@ export function queuedThreadKeys(): Array<{ provider: string; threadId: string }
83
129
  /**
84
130
  * Did the holder's last turn END?
85
131
  *
86
- * REUSES `draftsFromLine`, the same parser the live stream uses, so "the turn ended"
87
- * means exactly here what it means there. Hand-rolling a second `type === 'result'`
88
- * check is how two definitions of done drift apart.
132
+ * 6.48.1: decided by `turnFromTail`, the transcript's own rule (a terminal
133
+ * `stop_reason` with no tool_use awaiting its result, a `result` row, or a user
134
+ * interrupt). It replaced the status-draft loop here because a Desktop transcript
135
+ * writes no `result` row, so the old rule could only ever say "ended" for a
136
+ * `claude -p` run and every Desktop Continue waited the 30 s idle backstop (measured
137
+ * 2026-09-15: 0 of 30 newest transcripts carry one). The live feed still narrates
138
+ * from `draftsFromLine`; the two are pinned to agree on recorded tails.
89
139
  *
90
140
  * Reads a bounded tail, newest record wins. Returns false on any doubt -- an
91
141
  * unreadable transcript is not evidence a turn finished, and false only means the
92
142
  * queue HOLDS, which is always the safe answer.
93
143
  */
144
+ /** The full tail verdict, for callers that need to know OPEN as well as ended. Null on any doubt. */
145
+ export function transcriptTurnVerdict(provider: SessionStreamProvider, path: string | null): TurnFromTail | null {
146
+ const lines = readTail(path)
147
+ return lines === null ? null : turnFromTail(provider, lines)
148
+ }
149
+
94
150
  export function transcriptTurnEnded(provider: SessionStreamProvider, path: string | null): boolean {
95
- if (!path || !existsSync(path)) return false
151
+ return transcriptTurnVerdict(provider, path)?.ended === true
152
+ }
153
+
154
+ /** The bounded tail as lines, with the same two open flags; null when it cannot be read. */
155
+ function readTail(path: string | null): string[] | null {
156
+ if (!path || !existsSync(path)) return null
96
157
  try {
97
- // BOTH flags, and both are load-bearing -- hazard-invariants.test.ts enforces
98
- // them and each is right on its own terms. O_NOFOLLOW: a symlinked `<id>.jsonl`
99
- // could point at any file on disk and would be parsed here as a transcript.
100
- // O_NONBLOCK: `openSync` on a FIFO with no writer NEVER RETURNS, and it is a
101
- // synchronous syscall on Node's single thread, so one planted path would stop
102
- // health, meeting save and transcribe-stream along with this drain. That one is
103
- // recorded in the repo as three reproductions of the same bug, >34s to SIGKILL.
104
158
  const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
105
159
  try {
106
160
  const size = fstatSync(fd).size
@@ -110,18 +164,11 @@ export function transcriptTurnEnded(provider: SessionStreamProvider, path: strin
110
164
  const lines = buf.toString('utf-8').split('\n')
111
165
  // The first line of a tail read is almost always a fragment.
112
166
  if (start > 0) lines.shift()
113
- let ended = false
114
- for (const line of lines) {
115
- if (!line.trim()) continue
116
- for (const draft of draftsFromLine(provider, line)) {
117
- if (draft.kind === 'status') ended = draft.state === 'done'
118
- }
119
- }
120
- return ended
167
+ return lines
121
168
  } finally {
122
169
  closeSync(fd)
123
170
  }
124
171
  } catch {
125
- return false
172
+ return null
126
173
  }
127
174
  }
@@ -53,11 +53,15 @@
53
53
  // ---------------------------------------------------------------------------
54
54
  // Thirty seconds of transcript silence is the wrong trigger on its own: a long tool
55
55
  // call goes quiet mid-turn, and draining there would inject a message into the middle
56
- // of someone's reasoning. The precise signal is the turn ENDING, which is observable in
57
- // the transcript itself -- Claude writes a `result` record, Codex a `task_complete` /
56
+ // of someone's reasoning. The precise signal is the turn ENDING. For Claude that is the
57
+ // engine's own Stop hook followed by the registry flipping `idle` (6.48.1; a Desktop
58
+ // transcript carries no `result` record, and the Stop alone is 12-38 s early while the
59
+ // Stop hooks run), else the transcript's newest assistant record with a terminal
60
+ // `stop_reason` and no pending tool (`turnFromTail`); Codex writes `task_complete` /
58
61
  // `turn_complete`. The idle clock stays as a BACKSTOP for a holder that dies or a
59
62
  // provider that writes no terminal record, so a queue cannot wedge forever on a missing
60
- // event. Same reasoning as the session-trail handoff, one layer down.
63
+ // event, and positive evidence of an OPEN turn outranks it. Same reasoning as the
64
+ // session-trail handoff, one layer down.
61
65
 
62
66
  /** Terminal-ish states a queued turn can reach. `waiting` is the only live one. */
63
67
  export type QueuedTurnStatus =
@@ -186,6 +190,14 @@ export interface DrainObservation {
186
190
  turnEnded: boolean
187
191
  /** The 30s transcript clock. `idle` is the backstop when no terminal record lands. */
188
192
  activity: 'working' | 'idle' | 'unknown'
193
+ /**
194
+ * 6.48.1: POSITIVE evidence the turn is still open (a tool_use awaiting its result, a
195
+ * prompt newer than the last reply, or the hooks saying the turn has not stopped),
196
+ * bounded by the caller to a recent window. It outranks the idle backstop: a 40 s tool
197
+ * leaves the transcript untouched for 40 s, and "idle for 30 s" used to deliver a
198
+ * follow-up straight into that live turn (measured 2026-09-15 on the 6.48.1 live proof).
199
+ */
200
+ turnOpen?: boolean
189
201
  /** The gate's reason when `attachable` is false. Carried ONLY so a fence -- the one
190
202
  * hold a clock cannot end -- can be given a longer life than a busy thread. */
191
203
  reason?: string | null
@@ -218,6 +230,10 @@ export function drainDecision(
218
230
  // Turn-ended is the precise signal; idle is the backstop for a holder that wrote no
219
231
  // terminal record. `working` holds even when attachable, because attachable only says
220
232
  // no one else owns it -- it does not say a turn is not mid-flight.
233
+ // Positive evidence the turn is OPEN outranks a terminal record older than it: a prompt
234
+ // queued at the desk during the Stop hooks dequeues after them, and the tail's newest
235
+ // terminal record cannot see it (QA, 2026-09-15).
236
+ if (seen.turnOpen === true) return 'hold'
221
237
  if (seen.turnEnded) return 'deliver'
222
238
  return seen.activity === 'idle' ? 'deliver' : 'hold'
223
239
  }
@@ -2,15 +2,24 @@
2
2
  //
3
3
  // Server-sent events for ONE agent session. Each `data:` line is one JSON object:
4
4
  //
5
- // {"seq":1,"at":1786890000000,"kind":"tool","verb":"read","target":"x.ts","detail":""}
6
- // {"seq":2,"at":1786890001000,"kind":"prose","text":"..."}
7
- // {"seq":3,"at":1786890002000,"kind":"status","state":"working"}
5
+ // {"seq":1,"at":1786890000000,"kind":"tool","verb":"read","target":"x.ts","detail":"","cursor":41,"epoch":1789...}
6
+ // {"seq":2,"at":1786890001000,"kind":"prose","text":"...","cursor":42,"epoch":1789...}
7
+ // {"seq":3,"at":1786890002000,"kind":"status","state":"working","agent_state":"running",...}
8
8
  // {"seq":4,"at":1786890003000,"kind":"heartbeat"}
9
9
  //
10
10
  // `seq` is monotonic PER CONNECTION from 1, so a client detects loss from a gap. There
11
11
  // are no named SSE events and no comment keepalives: one shape, so a client needs one
12
12
  // handler and can never miss a keepalive it was not parsing.
13
13
  //
14
+ // 6.48.1 adds, all ignorable by a client that reads `data:` lines and known fields:
15
+ // - `cursor` and `epoch` on every event the per-session ring remembers, and an SSE
16
+ // `id: <epoch>.<cursor>` line before it. `?after=<epoch>.<cursor>` (or the standard
17
+ // `Last-Event-ID` header) replays what that ring holds after the cursor, from the
18
+ // same server life only; anything else, and an empty replay, seeds as a fresh open.
19
+ // - `?seed=turn` seeds from the current turn's prompt instead of the last 7 steps.
20
+ // - the derived state fields on every `status` draft (`COS_SESSION_HOOK_SSE=0` omits
21
+ // them), and a `status` line on every change of that state.
22
+ //
14
23
  // ---------------------------------------------------------------------------
15
24
  // A DEAD STREAM MUST DEGRADE TO THE POLL, NEVER TO A FROZEN SCREEN
16
25
  // ---------------------------------------------------------------------------
@@ -70,8 +79,13 @@ import {
70
79
  transcriptWatcherDegraded,
71
80
  readTranscriptSeedLines,
72
81
  } from '../lib/session-transcript-watcher.js'
73
- import { draftsFromLine } from '../lib/session-stream-events.js'
82
+ import { draftsFromLine, statusDraftWithDerived, type DerivedStatusFields } from '../lib/session-stream-events.js'
74
83
  import type { SessionStreamState } from '../lib/session-stream-events.js'
84
+ import { RING_EPOCH, replaySessionStream, ringBounds } from '../lib/session-stream-bus.js'
85
+ import { claudeSessionsDir, readClaudePeerRecords, registryFacts } from './claude-sessions.js'
86
+ import type { RegistryFacts } from '../lib/session-state-derive.js'
87
+ import { deriveForRow, sessionHooksEnabled, sessionSignalStore } from '../lib/session-hooks-runtime.js'
88
+ import { derivedStatusFields } from '../lib/session-state-derive.js'
75
89
 
76
90
  export const agentSessionStreamRouter = Router()
77
91
 
@@ -85,18 +99,92 @@ export function sessionStreamEnabled(env: NodeJS.ProcessEnv = process.env): bool
85
99
  return env.COS_SESSION_STREAM_ENABLED !== '0'
86
100
  }
87
101
 
102
+ /**
103
+ * 6.48.1: the derived session state rides every `status` draft as extra fields (see
104
+ * `statusDraftWithDerived`). `COS_SESSION_HOOK_SSE=0` keeps the drafts exactly as 6.48.0
105
+ * wrote them; the hooks themselves are unaffected. Absent means on.
106
+ */
107
+ export function sessionHookSseEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
108
+ return env.COS_SESSION_HOOK_SSE !== '0' && sessionHooksEnabled()
109
+ }
110
+
111
+ /**
112
+ * The derived state for a Claude session the stream is showing, or nothing.
113
+ *
114
+ * SAME INPUTS AS THE ROWS (QA, 2026-09-15): the registry facts (so an Esc-interrupted
115
+ * turn and a stray SessionEnd are read the way the list reads them) and whether a COS
116
+ * turn is attached (which is `running`, whatever the tab's hooks last said). The
117
+ * registry is read once per connection and again at most every REGISTRY_REFRESH_MS.
118
+ */
119
+ export const REGISTRY_REFRESH_MS = 5_000
120
+
121
+ type RegistryReader = () => Promise<RegistryFacts | undefined>
122
+
123
+ function derivedForStream(provider: AgentProvider, sessionId: string, key: string, registry: RegistryFacts | undefined): DerivedStatusFields | undefined {
124
+ if (provider !== 'claude' || !sessionHookSseEnabled()) return undefined
125
+ try {
126
+ const derived = deriveForRow({ sessionId, registry, remember: false, attachedTurn: isAttachedTurnActive(key) })
127
+ return derived ? derivedStatusFields(derived) : undefined
128
+ } catch {
129
+ return undefined
130
+ }
131
+ }
132
+
133
+ /** The registry record for a session, cached briefly; undefined when none names it. */
134
+ function registryReaderFor(sessionId: string): RegistryReader {
135
+ const wanted = sessionId.toLowerCase()
136
+ let cached: { at: number; facts: RegistryFacts | undefined } | null = null
137
+ return async () => {
138
+ if (cached && Date.now() - cached.at < REGISTRY_REFRESH_MS) return cached.facts
139
+ let facts: RegistryFacts | undefined
140
+ try {
141
+ const record = (await readClaudePeerRecords(claudeSessionsDir())).find(p => p.sessionId === wanted || p.sessionId.startsWith(wanted))
142
+ facts = record ? registryFacts(record) : undefined
143
+ } catch {
144
+ facts = undefined
145
+ }
146
+ cached = { at: Date.now(), facts }
147
+ return facts
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Whether a cursor can be replayed from a ring with these bounds: only when the ring
153
+ * still holds the event right after it (an evicted cursor would replay the whole ring
154
+ * with the gap unnoticed) and there IS one (a cursor at the newest has nothing to
155
+ * replay, and an empty replay must seed, never open an empty screen).
156
+ */
157
+ export function cursorReplayable(after: number | null, bounds: { oldest: number; newest: number } | null): boolean {
158
+ if (after === null || bounds === null) return false
159
+ return bounds.oldest <= after + 1 && after < bounds.newest
160
+ }
161
+
162
+ /** `?after=<epoch>.<cursor>` (the `id:` line's shape) or a bare cursor from this epoch; null when absent or unusable. */
163
+ export function parseAfterCursor(raw: unknown, epoch: number = RING_EPOCH): number | null {
164
+ if (typeof raw !== 'string') return null
165
+ const m = /^(?:(\d{1,16})\.)?(\d{1,12})$/.exec(raw.trim())
166
+ if (!m) return null
167
+ if (m[1] !== undefined && Number(m[1]) !== epoch) return null
168
+ return Number(m[2])
169
+ }
170
+
171
+ /** A turn-anchored seed (`?seed=turn`): the newest prompt and EVERY step after it, bounded. */
172
+ export const TURN_SEED_MAX_STEPS = 500
173
+
88
174
  function asProvider(value: string): AgentProvider | null {
89
175
  if (value === 'claude' || value === 'codex' || value === 'cursor') return value
90
176
  return null
91
177
  }
92
178
 
93
179
  /**
94
- * The state to open with.
180
+ * The TRANSPORT's opening state, before the derived state is stamped over it.
95
181
  *
96
182
  * `working` when a COS turn is writing right now, or when the transcript was touched
97
183
  * inside the same 30s window the session list already uses to call a thread active.
98
- * Otherwise `idle`. Never `done`: this route cannot observe the end of a turn it did
99
- * not start, and claiming one would be an invention.
184
+ * Otherwise `idle`. Never `done` from HERE: this function cannot observe the end of a
185
+ * turn it did not start. Since 6.48.1 `write()` restates `state` from the deriver when
186
+ * the hooks are on, and an engine that said SessionEnd (with no live registry record
187
+ * behind the session) opens as `done`, which is the client's digest body and intended.
100
188
  */
101
189
  export async function openingState(
102
190
  key: string,
@@ -186,6 +274,12 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
186
274
 
187
275
  const state = await openingState(key, path, Date.now())
188
276
 
277
+ // The registry facts the feed derives with: read in the same pre-header window as the
278
+ // opening state (nothing awaits once the headers are out, so a close can never slip
279
+ // between a write and the `close` listener), refreshed at most every REGISTRY_REFRESH_MS.
280
+ const readRegistry = registryReaderFor(sessionId)
281
+ let registry: RegistryFacts | undefined = await readRegistry()
282
+
189
283
  res.writeHead(200, {
190
284
  'Content-Type': 'text/event-stream',
191
285
  'Cache-Control': 'no-cache',
@@ -205,19 +299,34 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
205
299
  let seq = 0
206
300
  let closed = false
207
301
 
302
+ let releaseSignals: (() => void) | null = null
303
+
208
304
  const teardown = (): void => {
209
305
  if (closed) return
210
306
  closed = true
211
307
  if (heartbeat !== null) clearInterval(heartbeat)
212
308
  unsubscribe()
213
309
  releaseWatcher?.()
310
+ releaseSignals?.()
214
311
  try { res.end() } catch { /* already gone */ }
215
312
  }
216
313
 
314
+ let lastStamp = ''
315
+ const stampOf = (d: DerivedStatusFields | undefined) => d ? `${d.agent_state}|${d.waiting_kind ?? ''}|${d.waiting_detail ?? ''}|${d.failure ?? ''}` : ''
316
+
217
317
  const write = (event: PublishedSessionEvent): void => {
218
318
  if (closed) return
219
319
  try {
220
- res.write(`data: ${JSON.stringify({ seq: ++seq, ...event })}\n\n`)
320
+ // ONE stamping point for the derived state: every status draft this connection
321
+ // writes, opening or live, seeded or published, carries the same extra fields.
322
+ let out: PublishedSessionEvent = event
323
+ if (event.kind === 'status') {
324
+ const derived = derivedForStream(provider, sessionId, key, registry)
325
+ lastStamp = stampOf(derived)
326
+ out = { ...event, ...statusDraftWithDerived(event, derived) }
327
+ }
328
+ const idLine = typeof out.cursor === 'number' ? `id: ${out.epoch ?? RING_EPOCH}.${out.cursor}\n` : ''
329
+ res.write(`${idLine}data: ${JSON.stringify({ seq: ++seq, ...out })}\n\n`)
221
330
  } catch {
222
331
  // A failed write means the socket is gone. Close rather than swallow, so the
223
332
  // watcher and the subscription are released instead of leaking behind a dead
@@ -257,7 +366,25 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
257
366
  //
258
367
  // NEVER FATAL. A session whose history cannot be read still streams; it just starts
259
368
  // empty, exactly as it did before this existed.
260
- if (path !== null && startOffset > 0) {
369
+ // 6.48.1 RECONNECT: `?after=<cursor>` replays from the session's ring instead of
370
+ // seeding, when the ring still reaches back that far. A ring that does not (server
371
+ // restarted, linger expired) falls through to the seed, and the client sees a cursor
372
+ // jump it treats as a reseed.
373
+ // The standard SSE header is honoured as the fallback for `?after=`; the shipped lens
374
+ // and Control both pass the query and read `data:` lines only.
375
+ const after = parseAfterCursor(req.query.after) ?? parseAfterCursor(req.get('last-event-id'))
376
+ const bounds = ringBounds(key)
377
+ // Replayable only when the cursor is from THIS epoch, the ring reaches back to it, it
378
+ // is not past the ring, and the ring actually holds something after it. A ring that
379
+ // holds nothing after the cursor (the sole client was away, so nothing was published
380
+ // while it was gone) seeds instead: an empty replay must never be an empty screen
381
+ // (QA, 2026-09-15).
382
+ const replay = after !== null && cursorReplayable(after, bounds) ? replaySessionStream(key, after) : []
383
+ const replayable = replay.length > 0
384
+ for (const event of replay) write(event)
385
+ const seedMode = String(req.query.seed ?? '') === 'turn' ? 'turn' : 'window'
386
+
387
+ if (!replayable && path !== null && startOffset > 0) {
261
388
  try {
262
389
  const lines = await readTranscriptSeedLines(path, startOffset)
263
390
  const drafts = lines.flatMap(line => draftsFromLine(provider, line))
@@ -275,11 +402,17 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
275
402
  // So the newest prompt in the whole read window is emitted FIRST, unconditionally,
276
403
  // and the step budget is spent entirely on steps. The client pins it rather than
277
404
  // listing it, so it costs nothing in the scrolling window.
278
- const lastPrompt = [...drafts].reverse().find(d => d.kind === 'prompt')
405
+ const lastPromptIndex = drafts.map(d => d.kind).lastIndexOf('prompt')
406
+ const lastPrompt = lastPromptIndex >= 0 ? drafts[lastPromptIndex] : undefined
279
407
  if (lastPrompt) write({ ...lastPrompt, at: Date.now() })
280
408
 
409
+ // 6.48.1 `?seed=turn`: the whole current turn, oldest first, so a client opening
410
+ // mid-turn reads it top to bottom (Terminal V2's turn-anchored open). The default
411
+ // stays the last SEED_EVENTS steps, which is what shipped lenses were built for.
281
412
  const steps = drafts.filter(d => d.kind === 'tool' || d.kind === 'prose')
282
- for (const draft of steps.slice(-SEED_EVENTS)) {
413
+ const turnSteps = lastPromptIndex >= 0 ? drafts.slice(lastPromptIndex + 1).filter(d => d.kind === 'tool' || d.kind === 'prose') : steps
414
+ const seeded = seedMode === 'turn' ? turnSteps.slice(-TURN_SEED_MAX_STEPS) : steps.slice(-SEED_EVENTS)
415
+ for (const draft of seeded) {
283
416
  // NOT tagged as seeded. A replayed step is a step that really happened, and a
284
417
  // second rendering style for it would be a distinction without a use. The one
285
418
  // consequence is that the client's "N ago" clock starts at open rather than at
@@ -298,6 +431,29 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
298
431
  for (const event of pending.splice(0)) write(event)
299
432
  deliver = write
300
433
 
434
+ // 6.48.1: every change to this session's hook signal is a live `status` draft, so the
435
+ // client's state line moves on the engine's own events (prompt, permission prompt,
436
+ // Stop, end) rather than on the transcript clock. Filtered to this session (the
437
+ // client may have addressed it by the registry's 8-character form).
438
+ if (provider === 'claude' && sessionHookSseEnabled() && !closed) {
439
+ const wanted = sessionId.toLowerCase()
440
+ // `lastStamp` is whatever the OPENING status actually wrote (set inside `write`), so a
441
+ // change during the seed read is emitted as the first live line, not lost.
442
+ releaseSignals = sessionSignalStore.subscribe(signal => {
443
+ if (closed) return
444
+ if (signal.sessionId !== wanted && !signal.sessionId.startsWith(wanted)) return
445
+ // The registry may have moved with the hooks (an Esc flips it idle); refresh it
446
+ // off the hot path and let the next event read the new facts.
447
+ void readRegistry().then(facts => { registry = facts })
448
+ const derived = derivedForStream(provider, sessionId, key, registry)
449
+ if (!derived) return
450
+ // Only a CHANGE of state is a line; tool events inside a running turn are narrated
451
+ // by the transcript tail and must not each repaint the state.
452
+ if (stampOf(derived) === lastStamp) return
453
+ write({ kind: 'status', state: 'working', at: Date.now() })
454
+ })
455
+ }
456
+
301
457
  releaseWatcher = path === null
302
458
  ? null
303
459
  : acquireTranscriptWatcher({ key, path, provider, offset: startOffset })
@@ -306,6 +462,8 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
306
462
  if (closed) {
307
463
  releaseWatcher?.()
308
464
  releaseWatcher = null
465
+ releaseSignals?.()
466
+ releaseSignals = null
309
467
  return
310
468
  }
311
469
 
@@ -37,7 +37,12 @@ import {
37
37
  type AgentSessionSort,
38
38
  } from '../lib/agent-session-store.js'
39
39
  import { searchAgentSessions, type AgentSessionSearchHit } from '../lib/agent-session-search.js'
40
- import { claudeSessionNamesVisible, claudeSessionsDir, claudeSessionsEnabled, readClaudePeers } from './claude-sessions.js'
40
+ import { claudeSessionNamesVisible, claudeSessionsDir, claudeSessionsEnabled, readClaudePeerRecords, registryFacts } from './claude-sessions.js'
41
+ import type { ClaudePeerRecord } from '../lib/claude-session-registry.js'
42
+ import { deriveForRow } from '../lib/session-hooks-runtime.js'
43
+ import { derivedRowFields, type DerivedSessionState } from '../lib/session-state-derive.js'
44
+ import { queuedTurnsFields, queuedWaitingLookup } from '../lib/thread-turn-queue-store.js'
45
+ import { isAttachedTurnActive, sessionStreamKey } from '../lib/session-stream-bus.js'
41
46
  import { workspaceFromCwd } from '../lib/claude-session-registry.js'
42
47
  import {
43
48
  occupiedThreads,
@@ -75,9 +80,9 @@ function asSort(value: unknown): AgentSessionSort {
75
80
  return String(value ?? '').toLowerCase() === 'opened' ? 'opened' : 'updated'
76
81
  }
77
82
 
78
- function toSearchHit(row: AgentSessionSearchHit) {
83
+ function toSearchHit(row: AgentSessionSearchHit, queuedTurns = 0, derived?: DerivedSessionState) {
79
84
  return {
80
- ...toEntry(row),
85
+ ...toEntry(row, undefined, derived, queuedTurns),
81
86
  snippet: row.snippet,
82
87
  keywordScore: row.keywordScore,
83
88
  semanticScore: row.semanticScore,
@@ -261,7 +266,7 @@ function runningForThread(provider: AgentProvider, threadId: string, mtimeMs: nu
261
266
  }
262
267
  }
263
268
 
264
- function toEntry(row: AgentSessionRow, activity?: SessionActivity | null) {
269
+ function toEntry(row: AgentSessionRow, activity?: SessionActivity | null, derived?: DerivedSessionState, queuedTurns = 0) {
265
270
  return {
266
271
  session_id: row.session_id,
267
272
  provider: row.provider,
@@ -286,12 +291,22 @@ function toEntry(row: AgentSessionRow, activity?: SessionActivity | null) {
286
291
  // unknown, so an older client and a Cursor row see exactly the payload they did.
287
292
  ...(activity?.lastActivityAt ? { last_activity_at: activity.lastActivityAt } : {}),
288
293
  ...(activity?.lastTool ? { last_tool: activity.lastTool } : {}),
294
+ // 6.48.0: the one derived state (hook > registry > transcript) with its provenance.
295
+ // Under a NEW key: `state` above is `running|recent` and Control renders any other
296
+ // value there as "Running". Omitted entirely when nothing is known, like the two above.
297
+ ...derivedRowFields(derived),
298
+ // 6.48.1: waiting follow-ups on this thread. Omitted at 0 so an older client
299
+ // and a quiet row look the same as 6.48.0.
300
+ ...queuedTurnsFields(queuedTurns),
289
301
  }
290
302
  }
291
303
 
292
- async function liveClaudeRows(): Promise<AgentSessionRow[]> {
304
+ async function liveClaudePeerRecords(): Promise<ClaudePeerRecord[]> {
293
305
  if (!claudeSessionsEnabled()) return []
294
- const peers = await readClaudePeers(claudeSessionsDir(), undefined, claudeSessionNamesVisible())
306
+ return readClaudePeerRecords(claudeSessionsDir(), undefined, claudeSessionNamesVisible())
307
+ }
308
+
309
+ function liveClaudeRows(peers: ClaudePeerRecord[]): AgentSessionRow[] {
295
310
  return peers.filter(peer => peer.alive).map(peer => ({
296
311
  session_id: peer.id,
297
312
  provider: 'claude' as const,
@@ -322,7 +337,8 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
322
337
  // ONE alias load per request, shared by the walk, every live row and every
323
338
  // held thread. Measured 2026-09-10: sixteen loads of 119 MB before this line.
324
339
  const aliases = await loadClaudeDesktopAliases(roots.claudeCodeSessions)
325
- const live = await liveClaudeRows()
340
+ const peers = await liveClaudePeerRecords()
341
+ const live = liveClaudeRows(peers)
326
342
  const dropped = emptySessionListDropped()
327
343
  const sessions = await listAgentSessions(roots, new Date(), live, limit, sort, dropped, aliases)
328
344
  // 6.45.5: each row's last real activity, read from its transcript records. Memoized on
@@ -342,8 +358,37 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
342
358
  if (mtimeMs !== null) clocks.set(threadId, activityClockMs(mtimeMs, activityById.get(threadId)))
343
359
  }
344
360
  const running = withActiveRecently(scan, clocks, Date.now())
361
+ // Derived AFTER the walk: live rows carry the registry's eight-character id until
362
+ // `enrichLiveClaude` finds their transcript, and the deriver wants the full one where
363
+ // it exists. The registry facts are joined back by prefix; a transcript-only row (an
364
+ // ended job, a tab closed hours ago) still gets a state from its activity clock.
365
+ const now = Date.now()
366
+ // First wins, and `readClaudePeerRecords` sorts alive first: a dead predecessor's file
367
+ // (a resumed tab, a finished Continue child) must never shadow the live record.
368
+ const peersByPrefix = new Map<string, ClaudePeerRecord>()
369
+ for (const peer of peers) {
370
+ const prefix = peer.sessionId.slice(0, 8)
371
+ if (!peersByPrefix.has(prefix)) peersByPrefix.set(prefix, peer)
372
+ }
373
+ const derivedById = new Map<string, DerivedSessionState | undefined>()
374
+ for (const row of sessions) {
375
+ if (row.provider !== 'claude') continue
376
+ const peer = peersByPrefix.get(row.session_id.slice(0, 8).toLowerCase())
377
+ const hint = running.occupied.get(row.session_id)
378
+ derivedById.set(row.session_id, deriveForRow({
379
+ sessionId: row.session_id,
380
+ registry: peer ? registryFacts(peer) : undefined,
381
+ transcript: {
382
+ inFlight: hint?.activeRecently === true,
383
+ lastActivityAt: activityById.get(row.session_id)?.lastActivityAt ? Date.parse(activityById.get(row.session_id)!.lastActivityAt!) : null,
384
+ },
385
+ now,
386
+ attachedTurn: isAttachedTurnActive(sessionStreamKey('claude', row.session_id)),
387
+ }))
388
+ }
389
+ const queuedOf = queuedWaitingLookup(now)
345
390
  res.json({
346
- sessions: sessions.map((row, index) => withRunning(toEntry(row, activity[index]), running)),
391
+ sessions: sessions.map((row, index) => withRunning(toEntry(row, activity[index], derivedById.get(row.session_id), queuedOf(row.provider, row.session_id)), running)),
347
392
  total: sessions.length,
348
393
  windowHours: AGENT_SESSION_WINDOW_HOURS,
349
394
  sort,
@@ -372,9 +417,29 @@ agentSessionsRouter.get('/agent-sessions/search', async (req, res) => {
372
417
  try {
373
418
  const limit = boundedInteger(req.query.limit, 20, 1, 50)
374
419
  const result = await searchAgentSessions({ query, limit })
420
+ const queuedOf = queuedWaitingLookup(Date.now())
421
+ // 6.48.1: search hits carry the same derived state as list rows (no list/search shape
422
+ // drift), from the registry and the hook signal; a hit has no transcript walk.
423
+ const peers = await liveClaudePeerRecords()
424
+ const peersByPrefix = new Map<string, ClaudePeerRecord>()
425
+ for (const peer of peers) {
426
+ const prefix = peer.sessionId.slice(0, 8)
427
+ if (!peersByPrefix.has(prefix)) peersByPrefix.set(prefix, peer)
428
+ }
429
+ const derivedFor = (hit: AgentSessionSearchHit): DerivedSessionState | undefined => {
430
+ if (hit.provider !== 'claude') return undefined
431
+ const peer = peersByPrefix.get(hit.session_id.slice(0, 8).toLowerCase())
432
+ return deriveForRow({
433
+ sessionId: hit.session_id,
434
+ registry: peer ? registryFacts(peer) : undefined,
435
+ transcript: hit.modified ? { inFlight: false, lastActivityAt: Date.parse(hit.modified) } : undefined,
436
+ attachedTurn: isAttachedTurnActive(sessionStreamKey('claude', hit.session_id)),
437
+ remember: false,
438
+ })
439
+ }
375
440
  res.json({
376
441
  ...result,
377
- hits: result.hits.map(toSearchHit),
442
+ hits: result.hits.map(hit => toSearchHit(hit, queuedOf(hit.provider, hit.session_id), derivedFor(hit))),
378
443
  })
379
444
  } catch (error) {
380
445
  console.error(`[agent-sessions] search failed: ${error instanceof Error ? error.message : error}`)
@@ -417,8 +482,22 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
417
482
  const modified = st.mtime.toISOString()
418
483
  const activity = await readSessionActivity(provider, found)
419
484
  const running = runningForThread(provider, parsed.session_id, activityClockMs(st.mtimeMs, activity))
485
+ // 6.48.0: the detail carries the same derived state as its list row. The registry is
486
+ // read once here (a few hundred small files at most) because the detail has no walk.
487
+ let derived: DerivedSessionState | undefined
488
+ if (provider === 'claude') {
489
+ const peer = (await liveClaudePeerRecords()).find(p => p.sessionId === parsed.session_id.toLowerCase()) // alive first
490
+ derived = deriveForRow({
491
+ sessionId: parsed.session_id,
492
+ registry: peer ? registryFacts(peer) : undefined,
493
+ transcript: { inFlight: running.occupied.get(parsed.session_id)?.activeRecently === true, lastActivityAt: activity.lastActivityAt ? Date.parse(activity.lastActivityAt) : null },
494
+ attachedTurn: isAttachedTurnActive(sessionStreamKey('claude', parsed.session_id)),
495
+ })
496
+ }
420
497
  res.json({
421
498
  ...withRunning({ session_id: parsed.session_id }, running),
499
+ ...derivedRowFields(derived),
500
+ ...queuedTurnsFields(queuedWaitingLookup(Date.now())(provider, parsed.session_id)),
422
501
  // The client must be able to tell "this server stamped nothing" from "this
423
502
  // server stamped false", because the two demand opposite behaviour: an old
424
503
  // server's silence means keep using the hint borrowed from the list row, and