@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.
- package/CHANGELOG.md +26 -0
- package/README.md +35 -0
- package/bin/cli.cjs +22 -0
- package/bin/hooks/cos-session-hook +43 -0
- package/managed-runtime-contract.json +7 -1
- package/package.json +3 -1
- package/server/index.ts +78 -6
- package/server/lib/claude-hooks-installer.ts +403 -0
- package/server/lib/claude-session-registry.ts +44 -0
- package/server/lib/occupancy-probes.ts +43 -0
- package/server/lib/session-hook-events.ts +205 -0
- package/server/lib/session-hook-ledger.ts +133 -0
- package/server/lib/session-hook-spool.ts +272 -0
- package/server/lib/session-hooks-runtime.ts +338 -0
- package/server/lib/session-signal-store.ts +389 -0
- package/server/lib/session-state-derive.ts +249 -0
- package/server/lib/session-stream-bus.ts +93 -9
- package/server/lib/session-stream-events.ts +144 -0
- package/server/lib/thread-drain-kick.ts +153 -0
- package/server/lib/thread-occupancy.ts +36 -1
- package/server/lib/thread-turn-queue-deliver.ts +17 -6
- package/server/lib/thread-turn-queue-store.ts +70 -23
- package/server/lib/thread-turn-queue.ts +19 -3
- package/server/routes/agent-session-stream.ts +169 -11
- package/server/routes/agent-sessions.ts +88 -9
- package/server/routes/claude-sessions.ts +42 -21
- package/server/routes/health.ts +2 -0
- package/server/routes/session-hooks.ts +80 -0
- package/server/routes/thread-turn-queue.ts +15 -0
- package/server/scripts/hooks-cli.ts +48 -0
- package/server/lib/__fixtures__/query-jobs-6.43.3/2099-01-01.jsonl +0 -2
|
@@ -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
|
+
}
|