@gotcos/glasses-server 6.46.1 → 6.48.0

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +24 -0
  3. package/bin/cli.cjs +22 -0
  4. package/bin/hooks/cos-session-hook +43 -0
  5. package/managed-runtime-contract.json +7 -1
  6. package/package.json +8 -2
  7. package/server/index.ts +85 -0
  8. package/server/lib/claude-hooks-installer.ts +403 -0
  9. package/server/lib/claude-session-registry.ts +25 -0
  10. package/server/lib/cos-operations-meetings.ts +99 -8
  11. package/server/lib/fireflies-client.ts +862 -0
  12. package/server/lib/fireflies-key.ts +182 -0
  13. package/server/lib/imported-library-rows.ts +616 -0
  14. package/server/lib/imported-meeting-library.ts +608 -0
  15. package/server/lib/maintenance-lifecycle.ts +14 -0
  16. package/server/lib/meeting-actions-store.ts +478 -0
  17. package/server/lib/meeting-actions.ts +2583 -0
  18. package/server/lib/meeting-corrections.ts +32 -1
  19. package/server/lib/meeting-decisions.ts +223 -0
  20. package/server/lib/meeting-engine/align.ts +167 -0
  21. package/server/lib/meeting-engine/attribute.ts +265 -0
  22. package/server/lib/meeting-engine/evidence.ts +428 -0
  23. package/server/lib/meeting-engine/pairing.ts +327 -0
  24. package/server/lib/meeting-engine/render.ts +694 -0
  25. package/server/lib/meeting-engine/split.ts +242 -0
  26. package/server/lib/meeting-engine/worker.ts +238 -0
  27. package/server/lib/meeting-engine-mode.ts +197 -0
  28. package/server/lib/meeting-file-guards.ts +141 -0
  29. package/server/lib/meeting-import.ts +763 -0
  30. package/server/lib/meeting-library-search.ts +146 -11
  31. package/server/lib/meeting-parse.ts +184 -0
  32. package/server/lib/meeting-store.ts +108 -275
  33. package/server/lib/meeting-suggestion-sides.ts +242 -0
  34. package/server/lib/morning-brief-runtime.ts +20 -8
  35. package/server/lib/pipeline-runner.ts +227 -0
  36. package/server/lib/session-hook-events.ts +200 -0
  37. package/server/lib/session-hook-ledger.ts +129 -0
  38. package/server/lib/session-hook-spool.ts +264 -0
  39. package/server/lib/session-hooks-runtime.ts +229 -0
  40. package/server/lib/session-signal-store.ts +361 -0
  41. package/server/lib/session-state-derive.ts +211 -0
  42. package/server/lib/voice-evidence-guard.ts +87 -0
  43. package/server/routes/agent-sessions.ts +56 -6
  44. package/server/routes/claude-sessions.ts +32 -5
  45. package/server/routes/fireflies-key.ts +102 -0
  46. package/server/routes/health.ts +2 -0
  47. package/server/routes/meeting-actions.ts +82 -0
  48. package/server/routes/meeting-engine.ts +52 -0
  49. package/server/routes/meeting-import.ts +67 -0
  50. package/server/routes/meeting-suggestions.ts +66 -0
  51. package/server/routes/meeting.ts +117 -10
  52. package/server/routes/meetings.ts +177 -50
  53. package/server/routes/session-hooks.ts +70 -0
  54. package/server/routes/voice.ts +18 -0
  55. package/server/scripts/hooks-cli.ts +48 -0
  56. package/server/lib/__fixtures__/query-jobs-6.43.3/2099-01-01.jsonl +0 -2
@@ -0,0 +1,229 @@
1
+ // The composition root for session hooks: the one signal store, the ledger, the spool
2
+ // ingester, and the per-session facts the routes read.
3
+ //
4
+ // `COS_SESSION_HOOKS` (default: on whenever `COS_CLAUDE_SESSIONS_ENABLED=1`) gates the
5
+ // APPLY, never the drain: with the flag off the spool is still ledgered, unlinked and
6
+ // stamped, and nothing reaches a row. Control's env allowlist carries the flag across
7
+ // Update Server (main.swift `providerEnvironmentKeys`).
8
+
9
+ import { execFileSync } from 'node:child_process'
10
+ import { mkdirSync } from 'node:fs'
11
+ import { cosSpawnedPids } from './agent-session-ownership-store.js'
12
+ import { dataPath } from './data-dir.js'
13
+ import { SessionHookLedger } from './session-hook-ledger.js'
14
+ import { startSpoolIngester, type SpoolIngester, type SpoolStats } from './session-hook-spool.js'
15
+ import { SessionSignalStore, type SessionSignal } from './session-signal-store.js'
16
+ import { deriveSessionState, type DerivedSessionState, type RegistryFacts, type TranscriptFacts } from './session-state-derive.js'
17
+ import { ensureHookRuntimeFiles, ensureStableHookScript, hookSpoolDir, hookStatus, type HookStatus } from './claude-hooks-installer.js'
18
+
19
+ export function sessionHooksEnabled(): boolean {
20
+ const raw = process.env.COS_SESSION_HOOKS
21
+ if (raw === '0' || raw === 'false' || raw === 'off') return false
22
+ if (raw === '1' || raw === 'true' || raw === 'on') return true
23
+ return process.env.COS_CLAUDE_SESSIONS_ENABLED === '1'
24
+ }
25
+
26
+ export function spoolDir(): string {
27
+ return hookSpoolDir()
28
+ }
29
+
30
+ export function deskIdleSeconds(): number {
31
+ const raw = Number(process.env.COS_PERMISSION_BROKER_DESK_IDLE_S)
32
+ return Number.isFinite(raw) && raw >= 0 ? Math.trunc(raw) : 90
33
+ }
34
+
35
+ /** Replay this much ledger history into the store at boot. */
36
+ export const LEDGER_REPLAY_WINDOW_MS = 6 * 60 * 60_000
37
+
38
+ // A hook's ppid is the claude process itself: macOS `sh -c '<script> <Event>'` execs the
39
+ // single command, and every recorded session (fixtures, 2026-09-15) shows one ppid across all
40
+ // of its events. The `ps` parent lookup below is the fallback for a shell that does not exec;
41
+ // it runs once per pid and is remembered for a minute.
42
+ const parentCache = new Map<number, { parent: number | null; at: number }>()
43
+ const PARENT_CACHE_MS = 60_000
44
+
45
+ function parentPid(pid: number): number | null {
46
+ const cached = parentCache.get(pid)
47
+ if (cached && Date.now() - cached.at < PARENT_CACHE_MS) return cached.parent
48
+ let parent: number | null = null
49
+ try {
50
+ const out = execFileSync('/bin/ps', ['-o', 'ppid=', '-p', String(pid)], { encoding: 'utf-8', timeout: 1_000 })
51
+ const n = Number(out.trim())
52
+ parent = Number.isInteger(n) && n > 0 ? n : null
53
+ } catch { parent = null }
54
+ if (parentCache.size > 512) parentCache.clear()
55
+ parentCache.set(pid, { parent, at: Date.now() })
56
+ return parent
57
+ }
58
+
59
+ // A child's synchronous SessionEnd is spooled ~150 ms before the child exits, and the spawn
60
+ // ledger forgets the pid the moment it does; the sweep that reads the file can run after.
61
+ // So every pid the ledger ever vouched for is remembered here for a grace window.
62
+ const recentCosPids = new Map<number, number>()
63
+ export const COS_PID_TOMBSTONE_MS = 60_000
64
+
65
+ export function isCosSpawnedPid(pid: number | null, nowMs = Date.now()): boolean {
66
+ if (pid === null) return false
67
+ const spawned = cosSpawnedPids()
68
+ for (const p of spawned.keys()) recentCosPids.set(p, nowMs)
69
+ if (recentCosPids.size > 256) {
70
+ for (const [p, at] of recentCosPids) if (nowMs - at > COS_PID_TOMBSTONE_MS) recentCosPids.delete(p)
71
+ }
72
+ const remembered = (p: number) => { const at = recentCosPids.get(p); return at !== undefined && nowMs - at <= COS_PID_TOMBSTONE_MS }
73
+ if (spawned.has(pid) || remembered(pid)) return true
74
+ if (spawned.size === 0 && recentCosPids.size === 0) return false
75
+ const parent = parentPid(pid)
76
+ return parent !== null && (spawned.has(parent) || remembered(parent))
77
+ }
78
+
79
+ export const sessionSignalStore = new SessionSignalStore({ isCosSpawnedPid })
80
+
81
+ let ledger: SessionHookLedger | null = null
82
+ let ingester: SpoolIngester | null = null
83
+ let replayed: { rows: number; applied: number } | null = null
84
+ /** The two-scan memory: consecutive dead observations and when the first one was. */
85
+ const deadById = new Map<string, { scans: number; since: number | null; at: number }>()
86
+ const DEAD_MEMORY_MS = 60 * 60_000
87
+
88
+ export interface SessionHooksRuntime {
89
+ store: SessionSignalStore
90
+ stop(): void
91
+ }
92
+
93
+ export function startSessionHooksRuntime(options: { port: number }): SessionHooksRuntime {
94
+ const dir = spoolDir()
95
+ try { mkdirSync(dir, { recursive: true, mode: 0o700 }) } catch { /* the ingester reports it */ }
96
+ ledger = new SessionHookLedger(dataPath('session-hook-events.jsonl'))
97
+ const enabled = sessionHooksEnabled()
98
+ const since = Date.now() - LEDGER_REPLAY_WINDOW_MS
99
+ const replay = ledger.replay(since, (env, _key, child) => { if (enabled) sessionSignalStore.apply(env, child) })
100
+ replayed = { rows: replay.rows, applied: replay.applied }
101
+ ingester = startSpoolIngester({
102
+ dir,
103
+ ledger,
104
+ seenKeys: replay.keys,
105
+ isChild: env => isCosSpawnedPid(env.ppid),
106
+ apply: enabled ? (env, child) => { sessionSignalStore.apply(env, child) } : undefined,
107
+ })
108
+ // Runtime files the script reads. The port can change per install; the token never does.
109
+ // The script is copied only when MISSING here: a boot must never downgrade what a newer
110
+ // `--hooks install` put at the stable path.
111
+ try {
112
+ ensureHookRuntimeFiles(options.port, deskIdleSeconds())
113
+ ensureStableHookScript(undefined, undefined, true)
114
+ } catch (error) {
115
+ console.error(`[session-hooks] runtime files: ${error instanceof Error ? error.message : error}`)
116
+ }
117
+ const pruneTimer = setInterval(() => { sessionSignalStore.prune() }, 10 * 60_000)
118
+ pruneTimer.unref()
119
+ return {
120
+ store: sessionSignalStore,
121
+ stop() {
122
+ clearInterval(pruneTimer)
123
+ ingester?.stop()
124
+ },
125
+ }
126
+ }
127
+
128
+ export interface SessionHooksHealth {
129
+ enabled: boolean
130
+ /** The install state word; `installed` below is its boolean. */
131
+ state: HookStatus['state']
132
+ installed: boolean
133
+ scriptSha: string | null
134
+ tokenPresent: boolean
135
+ lastEventAt: string | null
136
+ spoolBacklog: number
137
+ spoolStuck: number
138
+ applyErrors: number
139
+ ledgerBytes: number
140
+ /** Error CODES only (ENOSPC, EACCES); a message would carry the home path onto public health. */
141
+ ledgerError: string | null
142
+ spoolError: string | null
143
+ signals: number
144
+ replayed: { rows: number; applied: number } | null
145
+ spool: SpoolStats | null
146
+ }
147
+
148
+ // Health is polled by three clients every few seconds; the status read (settings parse,
149
+ // two hashes) is cheap but not free, and it cannot change faster than this.
150
+ let statusCache: { at: number; value: HookStatus } | null = null
151
+ const STATUS_CACHE_MS = 5_000
152
+
153
+ export function cachedHookStatus(nowMs = Date.now()): HookStatus {
154
+ if (statusCache && nowMs - statusCache.at < STATUS_CACHE_MS) return statusCache.value
155
+ const value = hookStatus()
156
+ statusCache = { at: nowMs, value }
157
+ return value
158
+ }
159
+
160
+ export function invalidateHookStatus(): void {
161
+ statusCache = null
162
+ }
163
+
164
+ export function sessionHooksHealthFields(): { sessionHooks: SessionHooksHealth } {
165
+ const status = cachedHookStatus()
166
+ const spool = ingester?.stats() ?? null
167
+ const ledgerStats = ledger?.stats() ?? null
168
+ const newest = sessionSignalStore.newestEventAt()
169
+ return {
170
+ sessionHooks: {
171
+ enabled: sessionHooksEnabled(),
172
+ state: status.state,
173
+ installed: status.installed,
174
+ scriptSha: status.scriptSha,
175
+ tokenPresent: status.tokenPresent,
176
+ lastEventAt: newest ? new Date(newest).toISOString() : null,
177
+ spoolBacklog: spool?.backlog ?? 0,
178
+ spoolStuck: spool?.stuck ?? 0,
179
+ applyErrors: spool?.applyErrors ?? 0,
180
+ ledgerBytes: ledgerStats?.bytes ?? 0,
181
+ ledgerError: ledgerStats?.lastError ?? null,
182
+ spoolError: spool?.lastError ?? null,
183
+ signals: sessionSignalStore.size(),
184
+ replayed,
185
+ spool,
186
+ },
187
+ }
188
+ }
189
+
190
+ /** Signal for a row id that may be the registry's eight-character form. */
191
+ export function signalFor(sessionId: string): SessionSignal | undefined {
192
+ if (!sessionHooksEnabled()) return undefined
193
+ return sessionId.length >= 36 ? sessionSignalStore.get(sessionId) : sessionSignalStore.getByPrefix(sessionId)
194
+ }
195
+
196
+ /**
197
+ * Derive with the two-scan memory kept here, keyed by full id when known. Rows the
198
+ * caller has no facts for at all get nothing, so an older payload stays byte-identical.
199
+ */
200
+ export function deriveForRow(input: { sessionId: string; registry?: RegistryFacts; transcript?: TranscriptFacts; now?: number }): DerivedSessionState | undefined {
201
+ // Off means off: with the feature disabled every row is byte-identical to 6.47.0.
202
+ if (!sessionHooksEnabled()) return undefined
203
+ const signal = signalFor(input.sessionId)
204
+ if (!signal && !input.registry && !input.transcript) return undefined
205
+ const now = input.now ?? Date.now()
206
+ const key = signal?.sessionId ?? input.sessionId.toLowerCase()
207
+ const prev = deadById.get(key)
208
+ const derived = deriveSessionState({
209
+ signal,
210
+ registry: input.registry,
211
+ transcript: input.transcript,
212
+ now,
213
+ prevDeadScans: prev?.scans ?? 0,
214
+ prevDeadSince: prev?.since ?? null,
215
+ })
216
+ if (deadById.size > 2_048) {
217
+ for (const [k, v] of deadById) if (now - v.at > DEAD_MEMORY_MS) deadById.delete(k)
218
+ }
219
+ deadById.set(key, { scans: derived.deadScans, since: derived.deadSince, at: now })
220
+ return derived
221
+ }
222
+
223
+ export function __resetSessionHooksForTests(): void {
224
+ sessionSignalStore.__resetForTests()
225
+ deadById.clear()
226
+ parentCache.clear()
227
+ recentCosPids.clear()
228
+ statusCache = null
229
+ }
@@ -0,0 +1,361 @@
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. (6.48.1's B6 occupancy clause will compare it to the transcript.) */
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
+ /** Hooks have been seen for this session: the row may say `state_source: hook`. */
78
+ hooksSeen: true
79
+ }
80
+
81
+ export interface ReducerContext {
82
+ /** Is this pid (or its parent) a process COS spawned itself? Consulted only when the
83
+ * envelope was not already classified at ingest (`child`). */
84
+ isCosSpawnedPid: (pid: number | null) => boolean
85
+ }
86
+
87
+ /** The tool fields the reducer reads: the live payload's, or the ledger's projection on replay. */
88
+ function toolFacts(p: Record<string, unknown>): { name: string; target: string; fingerprint: string } {
89
+ const name = typeof p.tool_name === 'string' ? p.tool_name : ''
90
+ const projectedTarget = typeof p.tool_target === 'string' ? p.tool_target : null
91
+ const projectedFingerprint = typeof p.tool_fingerprint === 'string' ? p.tool_fingerprint : null
92
+ return {
93
+ name,
94
+ target: projectedTarget ?? toolTarget(p.tool_input),
95
+ fingerprint: projectedFingerprint ?? toolFingerprint(name, p.tool_input ?? null),
96
+ }
97
+ }
98
+
99
+ function fresh(env: HookEnvelope): SessionSignal {
100
+ return {
101
+ sessionId: env.sessionId,
102
+ firstSeenAt: env.ts,
103
+ lastEventAt: env.ts,
104
+ lastEvent: env.event,
105
+ cwd: null,
106
+ transcriptPath: null,
107
+ permissionMode: null,
108
+ model: null,
109
+ turnOpen: false,
110
+ turnStartedAt: null,
111
+ promptId: null,
112
+ stopAt: null,
113
+ waiting: null,
114
+ failure: null,
115
+ lastReply: '',
116
+ lastTool: null,
117
+ lastToolAt: null,
118
+ subagentsOpen: 0,
119
+ ended: null,
120
+ keepWarm: false,
121
+ compactions: 0,
122
+ childEvents: 0,
123
+ hooksSeen: true,
124
+ }
125
+ }
126
+
127
+ const str = (v: unknown): string | null => (typeof v === 'string' && v.length > 0 ? v : null)
128
+
129
+ /** Waiting kinds a PostToolUse of the same tool resolves without a fingerprint match. */
130
+ const TOOL_WAITING: Record<string, WaitingKind> = { AskUserQuestion: 'question', ExitPlanMode: 'plan' }
131
+
132
+ function resolvesWaiting(waiting: WaitingSignal, event: HookEventName, toolName: string, fingerprint: string): boolean {
133
+ if (waiting.fingerprint && waiting.fingerprint === fingerprint) return true
134
+ // Question/plan tools carry no meaningful input to fingerprint; the tool name is the key.
135
+ if (waiting.kind !== 'permission') return waiting.toolName === toolName
136
+ // A permission dialog is one at a time (canary 11: hooks run serially), and only a
137
+ // denied PROMPT fires PermissionDenied, so a denial naming the waiting tool is that
138
+ // prompt's denial even when the dialog rewrote the input. PostToolUse of the same name
139
+ // is not: a parallel auto-allowed Bash must not clear a prompt that still stands.
140
+ return event === 'PermissionDenied' && waiting.toolName === toolName
141
+ }
142
+
143
+ /**
144
+ * Apply one envelope. Returns a NEW record; the previous one is never mutated, so a
145
+ * subscriber holding the old value sees a consistent snapshot.
146
+ */
147
+ export function applyHookEvent(prev: SessionSignal | undefined, env: HookEnvelope, ctx: ReducerContext, child?: boolean): SessionSignal {
148
+ const base = prev ? { ...prev } : fresh(env)
149
+ const p = env.payload
150
+ const common = {
151
+ cwd: str(p.cwd) ?? base.cwd,
152
+ transcriptPath: str(p.transcript_path) ?? base.transcriptPath,
153
+ permissionMode: str(p.permission_mode) ?? base.permissionMode,
154
+ }
155
+
156
+ // Rule 2: a COS-spawned child on this session id is counted, never applied. The verdict
157
+ // is taken at ingest (and remembered on the ledger row) because the spawn ledger forgets
158
+ // a pid the moment the child exits.
159
+ if (child ?? ctx.isCosSpawnedPid(env.ppid)) {
160
+ return { ...base, ...common, childEvents: base.childEvents + 1 }
161
+ }
162
+
163
+ const next: SessionSignal = { ...base, ...common, lastEventAt: Math.max(base.lastEventAt, env.ts), lastEvent: env.event }
164
+
165
+ switch (env.event) {
166
+ case 'SessionStart': {
167
+ const source = str(p.source) ?? 'startup'
168
+ next.model = str(p.model) ?? next.model
169
+ if (source === 'compact') { next.compactions += 1; return next }
170
+ // startup | resume | clear | fork: a fresh conversation surface, nothing in flight.
171
+ return { ...next, turnOpen: false, turnStartedAt: null, waiting: null, failure: null, ended: null, subagentsOpen: 0 }
172
+ }
173
+ case 'UserPromptSubmit': {
174
+ const prompt = clipText(p.prompt)
175
+ return {
176
+ ...next,
177
+ turnOpen: true,
178
+ turnStartedAt: env.ts,
179
+ promptId: str(p.prompt_id),
180
+ waiting: null,
181
+ failure: null,
182
+ // The same predicate the session list uses to hide readiness checks.
183
+ keepWarm: prompt.length > 0 && isKeepWarmSessionTitle(prompt),
184
+ }
185
+ }
186
+ case 'PreToolUse': {
187
+ const tool = toolFacts(p)
188
+ const kind = TOOL_WAITING[tool.name]
189
+ if (kind) {
190
+ return {
191
+ ...next,
192
+ turnOpen: true,
193
+ waiting: { kind, detail: tool.target, toolName: tool.name, fingerprint: tool.fingerprint, since: env.ts, requestId: null },
194
+ }
195
+ }
196
+ return { ...next, lastTool: tool.name || next.lastTool, lastToolAt: env.ts }
197
+ }
198
+ case 'PermissionRequest': {
199
+ const tool = toolFacts(p)
200
+ return {
201
+ ...next,
202
+ turnOpen: true,
203
+ waiting: {
204
+ kind: 'permission',
205
+ detail: tool.target ? `${tool.name} ${tool.target}` : tool.name,
206
+ toolName: tool.name,
207
+ fingerprint: tool.fingerprint,
208
+ since: env.ts,
209
+ requestId: null,
210
+ },
211
+ }
212
+ }
213
+ case 'PermissionDenied':
214
+ case 'PostToolUse':
215
+ case 'PostToolUseFailure': {
216
+ const tool = toolFacts(p)
217
+ const waiting = next.waiting && resolvesWaiting(next.waiting, env.event, tool.name, tool.fingerprint) ? null : next.waiting
218
+ const ran = env.event !== 'PermissionDenied'
219
+ return { ...next, waiting, ...(ran ? { lastTool: tool.name || next.lastTool, lastToolAt: env.ts } : {}) }
220
+ }
221
+ case 'Notification': {
222
+ const type = str(p.notification_type) ?? ''
223
+ if (type === 'idle_prompt') return { ...next, turnOpen: false }
224
+ if (next.waiting) return next // a dialog already owns the attention; never downgrade its kind
225
+ const message = clipText(p.message, 120)
226
+ const kind: WaitingKind | null =
227
+ type === 'permission_prompt' ? 'permission'
228
+ : type === 'agent_needs_input' ? 'question'
229
+ : type === 'elicitation_dialog' ? 'mcp_input'
230
+ : null
231
+ if (!kind) return next
232
+ return { ...next, turnOpen: true, waiting: { kind, detail: message, toolName: '', fingerprint: '', since: env.ts, requestId: null } }
233
+ }
234
+ case 'Stop':
235
+ return {
236
+ ...next,
237
+ turnOpen: false,
238
+ stopAt: env.ts,
239
+ waiting: null,
240
+ lastReply: clipText(p.last_assistant_message) || next.lastReply,
241
+ }
242
+ case 'StopFailure':
243
+ return {
244
+ ...next,
245
+ turnOpen: false,
246
+ waiting: null,
247
+ failure: { kind: str(p.matcher) ?? str(p.error_type) ?? (clipText(p.error, 60) || 'unknown'), at: env.ts },
248
+ }
249
+ case 'SubagentStart':
250
+ return { ...next, subagentsOpen: next.subagentsOpen + 1 }
251
+ case 'SubagentStop':
252
+ return { ...next, subagentsOpen: Math.max(0, next.subagentsOpen - 1) }
253
+ case 'PostCompact':
254
+ return { ...next, compactions: next.compactions + 1 }
255
+ case 'PostModelSwitch':
256
+ return { ...next, model: str(p.to_model) ?? next.model }
257
+ case 'SessionEnd':
258
+ return { ...next, turnOpen: false, waiting: null, ended: { at: env.ts, reason: str(p.reason) ?? 'other' } }
259
+ }
260
+ }
261
+
262
+ export type SignalListener = (signal: SessionSignal, env: HookEnvelope) => void
263
+
264
+ /** Records older than this after a SessionEnd are dropped; Control keeps its own ledger. */
265
+ export const SIGNAL_PRUNE_AFTER_END_MS = 6 * 60 * 60_000
266
+ /** A record with no event at all for this long is a tab that died without a SessionEnd. */
267
+ export const SIGNAL_PRUNE_SILENT_MS = 24 * 60 * 60_000
268
+
269
+ export class SessionSignalStore {
270
+ private readonly signals = new Map<string, SessionSignal>()
271
+ private readonly listeners = new Set<SignalListener>()
272
+ private readonly ctx: ReducerContext
273
+
274
+ constructor(ctx: ReducerContext) {
275
+ this.ctx = ctx
276
+ }
277
+
278
+ apply(env: HookEnvelope, child?: boolean): SessionSignal {
279
+ const next = applyHookEvent(this.signals.get(env.sessionId), env, this.ctx, child)
280
+ this.signals.set(env.sessionId, next)
281
+ for (const listener of this.listeners) {
282
+ try { listener(next, env) } catch (error) {
283
+ console.error(`[session-signals] listener failed: ${error instanceof Error ? error.message : error}`)
284
+ }
285
+ }
286
+ return next
287
+ }
288
+
289
+ /** RESERVED FOR 6.48.2 (the permission broker); no caller in 6.48.0. Attach the broker's
290
+ * minted id so rows can carry `pending_permission_id`. */
291
+ attachPermissionRequestId(sessionId: string, requestId: string | null): void {
292
+ const current = this.signals.get(sessionId)
293
+ if (!current?.waiting || current.waiting.kind !== 'permission') return
294
+ this.signals.set(sessionId, { ...current, waiting: { ...current.waiting, requestId } })
295
+ }
296
+
297
+ /** RESERVED FOR 6.48.2 (the permission broker); no caller in 6.48.0. The broker decided
298
+ * (allow or deny): that is resolution evidence. */
299
+ resolveWaiting(sessionId: string): void {
300
+ const current = this.signals.get(sessionId)
301
+ if (!current?.waiting) return
302
+ this.signals.set(sessionId, { ...current, waiting: null })
303
+ }
304
+
305
+ get(sessionId: string): SessionSignal | undefined {
306
+ return this.signals.get(sessionId.toLowerCase())
307
+ }
308
+
309
+ /** Eight-char prefix lookup for the registry's short ids. Two matches means no answer. */
310
+ getByPrefix(prefix: string): SessionSignal | undefined {
311
+ const needle = prefix.toLowerCase()
312
+ let found: SessionSignal | undefined
313
+ for (const [id, signal] of this.signals) {
314
+ if (!id.startsWith(needle)) continue
315
+ if (found) return undefined
316
+ found = signal
317
+ }
318
+ return found
319
+ }
320
+
321
+ subscribe(listener: SignalListener): () => void {
322
+ this.listeners.add(listener)
323
+ return () => { this.listeners.delete(listener) }
324
+ }
325
+
326
+ /** Drop records that ended long ago, and records silent for a day (a tab that died with no SessionEnd). */
327
+ prune(nowMs = Date.now()): number {
328
+ let dropped = 0
329
+ for (const [id, signal] of this.signals) {
330
+ const endedLongAgo = !!signal.ended && !signal.turnOpen && nowMs - signal.ended.at > SIGNAL_PRUNE_AFTER_END_MS
331
+ const silentForADay = nowMs - signal.lastEventAt > SIGNAL_PRUNE_SILENT_MS
332
+ if (endedLongAgo || silentForADay) {
333
+ this.signals.delete(id)
334
+ dropped++
335
+ }
336
+ }
337
+ return dropped
338
+ }
339
+
340
+ size(): number {
341
+ return this.signals.size
342
+ }
343
+
344
+ /** Every record, newest event first. A copy: callers cannot reach the map. */
345
+ snapshot(): SessionSignal[] {
346
+ return [...this.signals.values()].sort((a, b) => b.lastEventAt - a.lastEventAt)
347
+ }
348
+
349
+ newestEventAt(): number | null {
350
+ let newest: number | null = null
351
+ for (const signal of this.signals.values()) {
352
+ if (newest === null || signal.lastEventAt > newest) newest = signal.lastEventAt
353
+ }
354
+ return newest
355
+ }
356
+
357
+ __resetForTests(): void {
358
+ this.signals.clear()
359
+ this.listeners.clear()
360
+ }
361
+ }