@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.
@@ -0,0 +1,389 @@
1
+ // The signal store: what the hooks have said about each Claude session, reduced to
2
+ // one record per full session id.
3
+ //
4
+ // POSITIVE SIGNALS ONLY. Before 6.48.0 every state on a session row was inferred: a
5
+ // registry pid that answers signal 0, a transcript mtime inside a window, a tool_use
6
+ // block with no result behind it. This store holds what the engine itself announced
7
+ // through its hooks (session started, prompt submitted, permission wanted, turn stopped,
8
+ // session ended), and `session-state-derive.ts` ranks it above every inference.
9
+ //
10
+ // The reducer is PURE (`applyHookEvent`), so a recorded sequence of envelopes replays
11
+ // into a deterministic record and the fixtures under `__fixtures__/session-hooks-6.48.0`
12
+ // are the specification. Three rules that came out of the validation rounds:
13
+ //
14
+ // 1. A `waiting` entry clears only on RESOLUTION EVIDENCE for that request: the
15
+ // matching PostToolUse/PostToolUseFailure/PermissionDenied (same tool name + input
16
+ // fingerprint), or a turn boundary (Stop, StopFailure, UserPromptSubmit, SessionEnd, a
17
+ // non-compact SessionStart), or the broker's own decision. A parallel auto-allowed
18
+ // Read or a sub-agent's tool must not clear a real prompt. The deriver adds two more
19
+ // clearers it can see and this store cannot: a registry status that moved after the
20
+ // wait, and transcript activity newer than the wait.
21
+ // 2. A COS-spawned Continue child (`claude -p --resume <id>`) shares the Desktop tab's
22
+ // session id and fires its own SessionStart/Stop/SessionEnd. Events whose spooled
23
+ // ppid is a COS spawn are recorded as `child*` counters and never touch the phase.
24
+ // 3. `ended` is recorded here but RANKED in the deriver, which also sees the registry:
25
+ // an ended signal with an alive registry record is a child that ended, not the tab.
26
+
27
+ import type { HookEnvelope, HookEventName } from './session-hook-events.js'
28
+ import { clipText, toolFingerprint, toolTarget } from './session-hook-events.js'
29
+ import { isKeepWarmSessionTitle } from './agent-session-store.js'
30
+
31
+ export type WaitingKind = 'permission' | 'question' | 'plan' | 'mcp_input'
32
+
33
+ export interface WaitingSignal {
34
+ kind: WaitingKind
35
+ /** "Bash git push", "Use the API error text in the form?", "" */
36
+ detail: string
37
+ toolName: string
38
+ /** sha256(tool_name + canonical tool_input); '' when the event carried no tool. */
39
+ fingerprint: string
40
+ since: number
41
+ /** The broker's minted id when a permission request is pending there. */
42
+ requestId: string | null
43
+ }
44
+
45
+ export interface FailureSignal {
46
+ kind: string
47
+ at: number
48
+ }
49
+
50
+ export interface SessionSignal {
51
+ sessionId: string
52
+ firstSeenAt: number
53
+ lastEventAt: number
54
+ lastEvent: HookEventName
55
+ cwd: string | null
56
+ transcriptPath: string | null
57
+ permissionMode: string | null
58
+ model: string | null
59
+ /** A prompt was submitted and no Stop/StopFailure/SessionEnd has closed it. */
60
+ turnOpen: boolean
61
+ turnStartedAt: number | null
62
+ promptId: string | null
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
+ stopAt: number | null
65
+ waiting: WaitingSignal | null
66
+ failure: FailureSignal | null
67
+ lastReply: string
68
+ lastTool: string | null
69
+ lastToolAt: number | null
70
+ subagentsOpen: number
71
+ ended: { at: number; reason: string } | null
72
+ /** The prompt prefix Control already suppresses as a readiness check. */
73
+ keepWarm: boolean
74
+ compactions: number
75
+ /** Events from a COS-spawned child on this id, kept out of the phase. */
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
83
+ /** Hooks have been seen for this session: the row may say `state_source: hook`. */
84
+ hooksSeen: true
85
+ }
86
+
87
+ export interface ReducerContext {
88
+ /** Is this pid (or its parent) a process COS spawned itself? Consulted only when the
89
+ * envelope was not already classified at ingest (`child`). */
90
+ isCosSpawnedPid: (pid: number | null) => boolean
91
+ }
92
+
93
+ /** The tool fields the reducer reads: the live payload's, or the ledger's projection on replay. */
94
+ function toolFacts(p: Record<string, unknown>): { name: string; target: string; fingerprint: string } {
95
+ const name = typeof p.tool_name === 'string' ? p.tool_name : ''
96
+ const projectedTarget = typeof p.tool_target === 'string' ? p.tool_target : null
97
+ const projectedFingerprint = typeof p.tool_fingerprint === 'string' ? p.tool_fingerprint : null
98
+ return {
99
+ name,
100
+ target: projectedTarget ?? toolTarget(p.tool_input),
101
+ fingerprint: projectedFingerprint ?? toolFingerprint(name, p.tool_input ?? null),
102
+ }
103
+ }
104
+
105
+ function fresh(env: HookEnvelope): SessionSignal {
106
+ return {
107
+ sessionId: env.sessionId,
108
+ firstSeenAt: env.ts,
109
+ lastEventAt: env.ts,
110
+ lastEvent: env.event,
111
+ cwd: null,
112
+ transcriptPath: null,
113
+ permissionMode: null,
114
+ model: null,
115
+ turnOpen: false,
116
+ turnStartedAt: null,
117
+ promptId: null,
118
+ stopAt: null,
119
+ waiting: null,
120
+ failure: null,
121
+ lastReply: '',
122
+ lastTool: null,
123
+ lastToolAt: null,
124
+ subagentsOpen: 0,
125
+ ended: null,
126
+ keepWarm: false,
127
+ compactions: 0,
128
+ childEvents: 0,
129
+ entrypoint: null,
130
+ hooksSeen: true,
131
+ }
132
+ }
133
+
134
+ const str = (v: unknown): string | null => (typeof v === 'string' && v.length > 0 ? v : null)
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
+
149
+ /** Waiting kinds a PostToolUse of the same tool resolves without a fingerprint match. */
150
+ const TOOL_WAITING: Record<string, WaitingKind> = { AskUserQuestion: 'question', ExitPlanMode: 'plan' }
151
+
152
+ function resolvesWaiting(waiting: WaitingSignal, event: HookEventName, toolName: string, fingerprint: string): boolean {
153
+ if (waiting.fingerprint && waiting.fingerprint === fingerprint) return true
154
+ // Question/plan tools carry no meaningful input to fingerprint; the tool name is the key.
155
+ if (waiting.kind !== 'permission') return waiting.toolName === toolName
156
+ // A permission dialog is one at a time (canary 11: hooks run serially), and only a
157
+ // denied PROMPT fires PermissionDenied, so a denial naming the waiting tool is that
158
+ // prompt's denial even when the dialog rewrote the input. PostToolUse of the same name
159
+ // is not: a parallel auto-allowed Bash must not clear a prompt that still stands.
160
+ return event === 'PermissionDenied' && waiting.toolName === toolName
161
+ }
162
+
163
+ /**
164
+ * Apply one envelope. Returns a NEW record; the previous one is never mutated, so a
165
+ * subscriber holding the old value sees a consistent snapshot.
166
+ */
167
+ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelope, ctx: ReducerContext, child?: boolean): SessionSignal {
168
+ const base = prev ? { ...prev } : fresh(env)
169
+ const p = env.payload
170
+ const common = {
171
+ cwd: str(p.cwd) ?? base.cwd,
172
+ transcriptPath: str(p.transcript_path) ?? base.transcriptPath,
173
+ permissionMode: str(p.permission_mode) ?? base.permissionMode,
174
+ }
175
+
176
+ // Rule 2: a COS-spawned child on this session id is counted, never applied. The verdict
177
+ // is taken at ingest (and remembered on the ledger row) because the spawn ledger forgets
178
+ // a pid the moment the child exits.
179
+ if (child ?? ctx.isCosSpawnedPid(env.ppid)) {
180
+ return { ...base, ...common, childEvents: base.childEvents + 1 }
181
+ }
182
+
183
+ const next: SessionSignal = { ...base, ...common, lastEventAt: Math.max(base.lastEventAt, env.ts), lastEvent: env.event }
184
+
185
+ switch (env.event) {
186
+ case 'SessionStart': {
187
+ const source = str(p.source) ?? 'startup'
188
+ next.model = str(p.model) ?? next.model
189
+ if (source === 'compact') { next.compactions += 1; return next }
190
+ // startup | resume | clear | fork: a fresh conversation surface, nothing in flight.
191
+ return { ...next, turnOpen: false, turnStartedAt: null, waiting: null, failure: null, ended: null, subagentsOpen: 0 }
192
+ }
193
+ case 'UserPromptSubmit': {
194
+ const prompt = clipText(p.prompt)
195
+ return {
196
+ ...next,
197
+ turnOpen: true,
198
+ turnStartedAt: env.ts,
199
+ promptId: str(p.prompt_id),
200
+ waiting: null,
201
+ failure: null,
202
+ // The same predicate the session list uses to hide readiness checks.
203
+ keepWarm: prompt.length > 0 && isKeepWarmSessionTitle(prompt),
204
+ }
205
+ }
206
+ case 'PreToolUse': {
207
+ const tool = toolFacts(p)
208
+ const kind = TOOL_WAITING[tool.name]
209
+ if (kind) {
210
+ return {
211
+ ...next,
212
+ turnOpen: true,
213
+ waiting: { kind, detail: tool.target, toolName: tool.name, fingerprint: tool.fingerprint, since: env.ts, requestId: null },
214
+ }
215
+ }
216
+ return { ...next, ...turnOpenedByTool(next, p, env.ts), lastTool: tool.name || next.lastTool, lastToolAt: env.ts }
217
+ }
218
+ case 'PermissionRequest': {
219
+ const tool = toolFacts(p)
220
+ return {
221
+ ...next,
222
+ turnOpen: true,
223
+ waiting: {
224
+ kind: 'permission',
225
+ detail: tool.target ? `${tool.name} ${tool.target}` : tool.name,
226
+ toolName: tool.name,
227
+ fingerprint: tool.fingerprint,
228
+ since: env.ts,
229
+ requestId: null,
230
+ },
231
+ }
232
+ }
233
+ case 'PermissionDenied':
234
+ case 'PostToolUse':
235
+ case 'PostToolUseFailure': {
236
+ const tool = toolFacts(p)
237
+ const waiting = next.waiting && resolvesWaiting(next.waiting, env.event, tool.name, tool.fingerprint) ? null : next.waiting
238
+ const ran = env.event !== 'PermissionDenied'
239
+ return { ...next, ...turnOpenedByTool(next, p, env.ts), waiting, ...(ran ? { lastTool: tool.name || next.lastTool, lastToolAt: env.ts } : {}) }
240
+ }
241
+ case 'Notification': {
242
+ const type = str(p.notification_type) ?? ''
243
+ if (type === 'idle_prompt') return { ...next, turnOpen: false }
244
+ if (next.waiting) return next // a dialog already owns the attention; never downgrade its kind
245
+ const message = clipText(p.message, 120)
246
+ const kind: WaitingKind | null =
247
+ type === 'permission_prompt' ? 'permission'
248
+ : type === 'agent_needs_input' ? 'question'
249
+ : type === 'elicitation_dialog' ? 'mcp_input'
250
+ : null
251
+ if (!kind) return next
252
+ return { ...next, turnOpen: true, waiting: { kind, detail: message, toolName: '', fingerprint: '', since: env.ts, requestId: null } }
253
+ }
254
+ case 'Stop':
255
+ return {
256
+ ...next,
257
+ turnOpen: false,
258
+ stopAt: env.ts,
259
+ waiting: null,
260
+ lastReply: clipText(p.last_assistant_message) || next.lastReply,
261
+ }
262
+ case 'StopFailure':
263
+ return {
264
+ ...next,
265
+ turnOpen: false,
266
+ waiting: null,
267
+ failure: { kind: str(p.matcher) ?? str(p.error_type) ?? (clipText(p.error, 60) || 'unknown'), at: env.ts },
268
+ }
269
+ case 'SubagentStart':
270
+ return { ...next, subagentsOpen: next.subagentsOpen + 1 }
271
+ case 'SubagentStop':
272
+ return { ...next, subagentsOpen: Math.max(0, next.subagentsOpen - 1) }
273
+ case 'PostCompact':
274
+ return { ...next, compactions: next.compactions + 1 }
275
+ case 'PostModelSwitch':
276
+ return { ...next, model: str(p.to_model) ?? next.model }
277
+ case 'SessionEnd':
278
+ return { ...next, turnOpen: false, waiting: null, ended: { at: env.ts, reason: str(p.reason) ?? 'other' } }
279
+ }
280
+ }
281
+
282
+ export type SignalListener = (signal: SessionSignal, env: HookEnvelope, child: boolean) => void
283
+
284
+ /** Records older than this after a SessionEnd are dropped; Control keeps its own ledger. */
285
+ export const SIGNAL_PRUNE_AFTER_END_MS = 6 * 60 * 60_000
286
+ /** A record with no event at all for this long is a tab that died without a SessionEnd. */
287
+ export const SIGNAL_PRUNE_SILENT_MS = 24 * 60 * 60_000
288
+
289
+ export class SessionSignalStore {
290
+ private readonly signals = new Map<string, SessionSignal>()
291
+ private readonly listeners = new Set<SignalListener>()
292
+ private readonly ctx: ReducerContext
293
+
294
+ constructor(ctx: ReducerContext) {
295
+ this.ctx = ctx
296
+ }
297
+
298
+ apply(env: HookEnvelope, child?: boolean): SessionSignal {
299
+ const next = applyHookEvent(this.signals.get(env.sessionId), env, this.ctx, child)
300
+ this.signals.set(env.sessionId, next)
301
+ const isChild = child ?? this.ctx.isCosSpawnedPid(env.ppid)
302
+ for (const listener of this.listeners) {
303
+ try { listener(next, env, isChild) } catch (error) {
304
+ console.error(`[session-signals] listener failed: ${error instanceof Error ? error.message : error}`)
305
+ }
306
+ }
307
+ return next
308
+ }
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
+
317
+ /** RESERVED FOR 6.48.2 (the permission broker); no caller in 6.48.0. Attach the broker's
318
+ * minted id so rows can carry `pending_permission_id`. */
319
+ attachPermissionRequestId(sessionId: string, requestId: string | null): void {
320
+ const current = this.signals.get(sessionId)
321
+ if (!current?.waiting || current.waiting.kind !== 'permission') return
322
+ this.signals.set(sessionId, { ...current, waiting: { ...current.waiting, requestId } })
323
+ }
324
+
325
+ /** RESERVED FOR 6.48.2 (the permission broker); no caller in 6.48.0. The broker decided
326
+ * (allow or deny): that is resolution evidence. */
327
+ resolveWaiting(sessionId: string): void {
328
+ const current = this.signals.get(sessionId)
329
+ if (!current?.waiting) return
330
+ this.signals.set(sessionId, { ...current, waiting: null })
331
+ }
332
+
333
+ get(sessionId: string): SessionSignal | undefined {
334
+ return this.signals.get(sessionId.toLowerCase())
335
+ }
336
+
337
+ /** Eight-char prefix lookup for the registry's short ids. Two matches means no answer. */
338
+ getByPrefix(prefix: string): SessionSignal | undefined {
339
+ const needle = prefix.toLowerCase()
340
+ let found: SessionSignal | undefined
341
+ for (const [id, signal] of this.signals) {
342
+ if (!id.startsWith(needle)) continue
343
+ if (found) return undefined
344
+ found = signal
345
+ }
346
+ return found
347
+ }
348
+
349
+ subscribe(listener: SignalListener): () => void {
350
+ this.listeners.add(listener)
351
+ return () => { this.listeners.delete(listener) }
352
+ }
353
+
354
+ /** Drop records that ended long ago, and records silent for a day (a tab that died with no SessionEnd). */
355
+ prune(nowMs = Date.now()): number {
356
+ let dropped = 0
357
+ for (const [id, signal] of this.signals) {
358
+ const endedLongAgo = !!signal.ended && !signal.turnOpen && nowMs - signal.ended.at > SIGNAL_PRUNE_AFTER_END_MS
359
+ const silentForADay = nowMs - signal.lastEventAt > SIGNAL_PRUNE_SILENT_MS
360
+ if (endedLongAgo || silentForADay) {
361
+ this.signals.delete(id)
362
+ dropped++
363
+ }
364
+ }
365
+ return dropped
366
+ }
367
+
368
+ size(): number {
369
+ return this.signals.size
370
+ }
371
+
372
+ /** Every record, newest event first. A copy: callers cannot reach the map. */
373
+ snapshot(): SessionSignal[] {
374
+ return [...this.signals.values()].sort((a, b) => b.lastEventAt - a.lastEventAt)
375
+ }
376
+
377
+ newestEventAt(): number | null {
378
+ let newest: number | null = null
379
+ for (const signal of this.signals.values()) {
380
+ if (newest === null || signal.lastEventAt > newest) newest = signal.lastEventAt
381
+ }
382
+ return newest
383
+ }
384
+
385
+ __resetForTests(): void {
386
+ this.signals.clear()
387
+ this.listeners.clear()
388
+ }
389
+ }
@@ -0,0 +1,249 @@
1
+ // ONE derivation of a session's state, for every surface.
2
+ //
3
+ // Before 6.48.0 three readers derived state on their own: this server (`running` and
4
+ // `running_active` from occupancy and a 30 s transcript window), the Control helper
5
+ // (transcript tail + a 15 minute "in flight, then waiting" guess), and the EHPK (four
6
+ // states from the row's flags). The pet, the Sessions tab, the phone and the lens could
7
+ // disagree about the same tab. This function is the only place a state is decided;
8
+ // the rows carry its answer and its provenance.
9
+ //
10
+ // PRECEDENCE, in order, each with the evidence that makes it yield:
11
+ //
12
+ // hook the engine's own hook events for this session (`session-signal-store`)
13
+ // yields to registry when the registry's status moved more recently and the
14
+ // hooks have been silent for HOOK_SILENCE_MS
15
+ // registry `~/.claude/sessions/<pid>.json` status busy|idle|waiting with a live pid
16
+ // yields to `ended` after MISS_LIMIT consecutive derives with a dead pid
17
+ // transcript whatever the transcript tail says (activity clock, in-flight marker)
18
+ //
19
+ // `ended` from a SessionEnd hook is honoured only when no alive registry record remains:
20
+ // a COS Continue child shares the tab's session id and ends while the tab stays open.
21
+ //
22
+ // The wire key is `agent_state`, never `state`: `state` already exists on every row as
23
+ // `running|recent` and Control renders anything else as "Running" (validation round 1).
24
+
25
+ import type { SessionSignal, WaitingKind } from './session-signal-store.js'
26
+
27
+ export type AgentState = 'running' | 'waiting' | 'idle' | 'failed' | 'ended'
28
+ export type StateSource = 'hook' | 'registry' | 'transcript'
29
+
30
+ export interface DerivedSessionState {
31
+ agent_state: AgentState
32
+ state_source: StateSource
33
+ /** ISO time of the event that produced the current state. */
34
+ state_since: string
35
+ waiting_kind?: WaitingKind
36
+ waiting_detail?: string
37
+ failure?: string
38
+ last_reply?: string
39
+ pending_permission_id?: string
40
+ /** Consecutive derives that saw a dead registry pid; carried by the caller between polls. */
41
+ deadScans: number
42
+ /** When the pid was first seen dead, so two observations must also be DEAD_GRACE_MS apart. */
43
+ deadSince: number | null
44
+ }
45
+
46
+ /** The registry facts the deriver reads; a projection of the peer record, never the wire. */
47
+ export interface RegistryFacts {
48
+ alive: boolean
49
+ status: string | null
50
+ waitingFor: string | null
51
+ statusUpdatedAt: number | null
52
+ lastActiveAt: number | null
53
+ }
54
+
55
+ /** What the transcript tail says, when nothing better is known. */
56
+ export interface TranscriptFacts {
57
+ /** The newest assistant record is a tool_use with no result, or a user prompt awaits. */
58
+ inFlight: boolean
59
+ lastActivityAt: number | null
60
+ }
61
+
62
+ export interface DeriveInput {
63
+ signal: SessionSignal | undefined
64
+ registry: RegistryFacts | undefined
65
+ transcript: TranscriptFacts | undefined
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
75
+ /** The previous derive's `deadScans`, so the two-scan rule survives between polls. */
76
+ prevDeadScans?: number
77
+ /** The previous derive's `deadSince`. */
78
+ prevDeadSince?: number | null
79
+ }
80
+
81
+ /** Harness `MISS_LIMIT`: a pid must be dead on two consecutive scans before a row ends. */
82
+ export const MISS_LIMIT = 2
83
+ /**
84
+ * The two scans must also be this far apart in wall-clock time. A derive runs per HTTP
85
+ * request, and one client refresh is two requests in milliseconds; a registry file that
86
+ * is being rewritten must not read as a death.
87
+ */
88
+ export const DEAD_GRACE_MS = 5_000
89
+ /** A hook-derived wait with no live registry record behind it is not trusted past this. */
90
+ export const WAITING_CEILING_MS = 30 * 60_000
91
+ /** Transcript activity this much newer than a wait means the tool ran: the wait is over. */
92
+ export const WAITING_TRANSCRIPT_VETO_MS = 60_000
93
+ /** Hooks silent this long while the registry moved: the registry wins. */
94
+ export const HOOK_SILENCE_MS = 30 * 60_000
95
+ /** A turn that has been open this long with no event at all is no longer trusted as running. */
96
+ export const OPEN_TURN_CEILING_MS = 30 * 60_000
97
+
98
+ const iso = (ms: number) => new Date(ms).toISOString()
99
+
100
+ export function deriveSessionState(input: DeriveInput): DerivedSessionState {
101
+ const { signal, registry, transcript, now } = input
102
+ const prevDead = input.prevDeadScans ?? 0
103
+ const dead = !!registry && !registry.alive
104
+ const deadScans = dead ? prevDead + 1 : 0
105
+ const deadSince = dead ? (input.prevDeadSince ?? now) : null
106
+ const deadLongEnough = dead && deadScans >= MISS_LIMIT && deadSince !== null && now - deadSince >= DEAD_GRACE_MS
107
+ const registryMovedLater = !!(registry?.statusUpdatedAt && signal && registry.statusUpdatedAt > signal.lastEventAt)
108
+ const hooksSilent = !!signal && now - signal.lastEventAt > HOOK_SILENCE_MS
109
+ const carry = { deadScans, deadSince }
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
+
115
+ // A dead pid on two scans at least DEAD_GRACE_MS apart ends the row whatever the hooks
116
+ // last said, unless a hook event is newer than the registry's last movement (a resumed
117
+ // tab under a new pid).
118
+ if (deadLongEnough && !(signal && registry.lastActiveAt && signal.lastEventAt > registry.lastActiveAt)) {
119
+ return { agent_state: 'ended', state_source: 'registry', state_since: iso(registry.lastActiveAt ?? now), ...carry, ...replyOf(signal) }
120
+ }
121
+
122
+ if (signal && !(registryMovedLater && hooksSilent)) {
123
+ const fromHook = deriveFromSignal(signal, registry, transcript, now)
124
+ if (fromHook) return { ...fromHook, ...carry }
125
+ }
126
+
127
+ if (registry?.alive) {
128
+ const since = iso(registry.statusUpdatedAt ?? registry.lastActiveAt ?? now)
129
+ if (registry.status === 'waiting') {
130
+ return { agent_state: 'waiting', state_source: 'registry', state_since: since, waiting_kind: registryWaitingKind(registry.waitingFor), waiting_detail: registry.waitingFor ?? '', ...carry, ...replyOf(signal) }
131
+ }
132
+ if (registry.status === 'busy') return { agent_state: 'running', state_source: 'registry', state_since: since, ...carry, ...replyOf(signal) }
133
+ if (registry.status === 'idle') return { agent_state: 'idle', state_source: 'registry', state_since: since, ...carry, ...replyOf(signal) }
134
+ }
135
+
136
+ if (transcript) {
137
+ const since = iso(transcript.lastActivityAt ?? now)
138
+ return { agent_state: transcript.inFlight ? 'running' : 'idle', state_source: 'transcript', state_since: since, ...carry, ...replyOf(signal) }
139
+ }
140
+
141
+ // Only a signal that was ruled stale above, or nothing at all: a stale open turn is not
142
+ // evidence of work, so it reads idle.
143
+ if (signal) return { agent_state: 'idle', state_source: 'hook', state_since: iso(signal.stopAt ?? signal.lastEventAt), ...carry, ...replyOf(signal) }
144
+ return { agent_state: 'idle', state_source: 'transcript', state_since: iso(now), ...carry }
145
+ }
146
+
147
+ /** The registry writes `waitingFor: "dialog open"` for a permission dialog on this Mac. */
148
+ function registryWaitingKind(waitingFor: string | null): WaitingKind {
149
+ return waitingFor && /dialog|permission|approv/i.test(waitingFor) ? 'permission' : 'question'
150
+ }
151
+
152
+ function replyOf(signal: SessionSignal | undefined): { last_reply?: string } {
153
+ return signal?.lastReply ? { last_reply: signal.lastReply } : {}
154
+ }
155
+
156
+ type HookVerdict = Omit<DerivedSessionState, 'deadScans' | 'deadSince'>
157
+
158
+ function deriveFromSignal(signal: SessionSignal, registry: RegistryFacts | undefined, transcript: TranscriptFacts | undefined, now: number): HookVerdict | null {
159
+ const reply = replyOf(signal)
160
+ if (signal.ended && !(registry?.alive)) {
161
+ return { agent_state: 'ended', state_source: 'hook', state_since: iso(signal.ended.at), ...reply }
162
+ }
163
+ if (signal.waiting && waitStillStands(signal.waiting.since, registry, transcript, now)) {
164
+ return {
165
+ agent_state: 'waiting',
166
+ state_source: 'hook',
167
+ state_since: iso(signal.waiting.since),
168
+ waiting_kind: signal.waiting.kind,
169
+ waiting_detail: signal.waiting.detail,
170
+ ...(signal.waiting.requestId ? { pending_permission_id: signal.waiting.requestId } : {}),
171
+ ...reply,
172
+ }
173
+ }
174
+ if (signal.waiting) {
175
+ // The wait was overtaken by evidence the store cannot see (registry moved on, the
176
+ // transcript moved on, or nothing alive stands behind it): fall through to whoever can.
177
+ return null
178
+ }
179
+ if (signal.failure && !signal.turnOpen) {
180
+ return { agent_state: 'failed', state_source: 'hook', state_since: iso(signal.failure.at), failure: signal.failure.kind, ...reply }
181
+ }
182
+ if (signal.turnOpen) {
183
+ // An interrupt (Esc) fires no Stop: the registry moving to idle AFTER the last hook
184
+ // event is the only signal, and it must not wait for the half-hour ceiling.
185
+ if (registry?.alive && registry.status === 'idle' && registry.statusUpdatedAt && registry.statusUpdatedAt > signal.lastEventAt) return null
186
+ // An open turn with no event for half an hour is not evidence of work any more;
187
+ // let the registry or the transcript answer.
188
+ if (now - signal.lastEventAt > OPEN_TURN_CEILING_MS) return null
189
+ return { agent_state: 'running', state_source: 'hook', state_since: iso(signal.turnStartedAt ?? signal.lastEventAt), ...reply }
190
+ }
191
+ // A child that ended while the tab is alive reads idle, since the tab's own hooks are
192
+ // what would say otherwise.
193
+ return { agent_state: 'idle', state_source: 'hook', state_since: iso(signal.stopAt ?? signal.lastEventAt), ...reply }
194
+ }
195
+
196
+ /**
197
+ * A hook-derived wait stands while nothing contradicts it. Three things do: the registry
198
+ * moved after the wait began and no longer says waiting (the dialog closed); the
199
+ * transcript moved on well after the wait began (the tool ran); nothing alive stands
200
+ * behind a wait older than the ceiling (a tab that died with its dialog up, or a
201
+ * PermissionRequest spooled during an outage whose close half the guard dropped).
202
+ */
203
+ function waitStillStands(since: number, registry: RegistryFacts | undefined, transcript: TranscriptFacts | undefined, now: number): boolean {
204
+ if (registry?.alive && registry.statusUpdatedAt && registry.statusUpdatedAt > since && registry.status !== 'waiting' && registry.status !== null) return false
205
+ if (transcript?.lastActivityAt && transcript.lastActivityAt > since + WAITING_TRANSCRIPT_VETO_MS) return false
206
+ if (!(registry?.alive) && now - since > WAITING_CEILING_MS) return false
207
+ return true
208
+ }
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
+
236
+ /** The additive row fields, ready to spread onto a list or detail entry. */
237
+ export function derivedRowFields(derived: DerivedSessionState | undefined): Record<string, unknown> {
238
+ if (!derived) return {}
239
+ return {
240
+ agent_state: derived.agent_state,
241
+ state_source: derived.state_source,
242
+ state_since: derived.state_since,
243
+ ...(derived.waiting_kind ? { waiting_kind: derived.waiting_kind } : {}),
244
+ ...(derived.waiting_detail !== undefined ? { waiting_detail: derived.waiting_detail } : {}),
245
+ ...(derived.failure ? { failure: derived.failure } : {}),
246
+ ...(derived.last_reply ? { last_reply: derived.last_reply } : {}),
247
+ ...(derived.pending_permission_id ? { pending_permission_id: derived.pending_permission_id } : {}),
248
+ }
249
+ }