@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,211 @@
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
+ /** The previous derive's `deadScans`, so the two-scan rule survives between polls. */
68
+ prevDeadScans?: number
69
+ /** The previous derive's `deadSince`. */
70
+ prevDeadSince?: number | null
71
+ }
72
+
73
+ /** Harness `MISS_LIMIT`: a pid must be dead on two consecutive scans before a row ends. */
74
+ export const MISS_LIMIT = 2
75
+ /**
76
+ * The two scans must also be this far apart in wall-clock time. A derive runs per HTTP
77
+ * request, and one client refresh is two requests in milliseconds; a registry file that
78
+ * is being rewritten must not read as a death.
79
+ */
80
+ export const DEAD_GRACE_MS = 5_000
81
+ /** A hook-derived wait with no live registry record behind it is not trusted past this. */
82
+ export const WAITING_CEILING_MS = 30 * 60_000
83
+ /** Transcript activity this much newer than a wait means the tool ran: the wait is over. */
84
+ export const WAITING_TRANSCRIPT_VETO_MS = 60_000
85
+ /** Hooks silent this long while the registry moved: the registry wins. */
86
+ export const HOOK_SILENCE_MS = 30 * 60_000
87
+ /** A turn that has been open this long with no event at all is no longer trusted as running. */
88
+ export const OPEN_TURN_CEILING_MS = 30 * 60_000
89
+
90
+ const iso = (ms: number) => new Date(ms).toISOString()
91
+
92
+ export function deriveSessionState(input: DeriveInput): DerivedSessionState {
93
+ const { signal, registry, transcript, now } = input
94
+ const prevDead = input.prevDeadScans ?? 0
95
+ const dead = !!registry && !registry.alive
96
+ const deadScans = dead ? prevDead + 1 : 0
97
+ const deadSince = dead ? (input.prevDeadSince ?? now) : null
98
+ const deadLongEnough = dead && deadScans >= MISS_LIMIT && deadSince !== null && now - deadSince >= DEAD_GRACE_MS
99
+ const registryMovedLater = !!(registry?.statusUpdatedAt && signal && registry.statusUpdatedAt > signal.lastEventAt)
100
+ const hooksSilent = !!signal && now - signal.lastEventAt > HOOK_SILENCE_MS
101
+ const carry = { deadScans, deadSince }
102
+
103
+ // A dead pid on two scans at least DEAD_GRACE_MS apart ends the row whatever the hooks
104
+ // last said, unless a hook event is newer than the registry's last movement (a resumed
105
+ // tab under a new pid).
106
+ if (deadLongEnough && !(signal && registry.lastActiveAt && signal.lastEventAt > registry.lastActiveAt)) {
107
+ return { agent_state: 'ended', state_source: 'registry', state_since: iso(registry.lastActiveAt ?? now), ...carry, ...replyOf(signal) }
108
+ }
109
+
110
+ if (signal && !(registryMovedLater && hooksSilent)) {
111
+ const fromHook = deriveFromSignal(signal, registry, transcript, now)
112
+ if (fromHook) return { ...fromHook, ...carry }
113
+ }
114
+
115
+ if (registry?.alive) {
116
+ const since = iso(registry.statusUpdatedAt ?? registry.lastActiveAt ?? now)
117
+ if (registry.status === 'waiting') {
118
+ return { agent_state: 'waiting', state_source: 'registry', state_since: since, waiting_kind: registryWaitingKind(registry.waitingFor), waiting_detail: registry.waitingFor ?? '', ...carry, ...replyOf(signal) }
119
+ }
120
+ if (registry.status === 'busy') return { agent_state: 'running', state_source: 'registry', state_since: since, ...carry, ...replyOf(signal) }
121
+ if (registry.status === 'idle') return { agent_state: 'idle', state_source: 'registry', state_since: since, ...carry, ...replyOf(signal) }
122
+ }
123
+
124
+ if (transcript) {
125
+ const since = iso(transcript.lastActivityAt ?? now)
126
+ return { agent_state: transcript.inFlight ? 'running' : 'idle', state_source: 'transcript', state_since: since, ...carry, ...replyOf(signal) }
127
+ }
128
+
129
+ // Only a signal that was ruled stale above, or nothing at all: a stale open turn is not
130
+ // evidence of work, so it reads idle.
131
+ if (signal) return { agent_state: 'idle', state_source: 'hook', state_since: iso(signal.stopAt ?? signal.lastEventAt), ...carry, ...replyOf(signal) }
132
+ return { agent_state: 'idle', state_source: 'transcript', state_since: iso(now), ...carry }
133
+ }
134
+
135
+ /** The registry writes `waitingFor: "dialog open"` for a permission dialog on this Mac. */
136
+ function registryWaitingKind(waitingFor: string | null): WaitingKind {
137
+ return waitingFor && /dialog|permission|approv/i.test(waitingFor) ? 'permission' : 'question'
138
+ }
139
+
140
+ function replyOf(signal: SessionSignal | undefined): { last_reply?: string } {
141
+ return signal?.lastReply ? { last_reply: signal.lastReply } : {}
142
+ }
143
+
144
+ type HookVerdict = Omit<DerivedSessionState, 'deadScans' | 'deadSince'>
145
+
146
+ function deriveFromSignal(signal: SessionSignal, registry: RegistryFacts | undefined, transcript: TranscriptFacts | undefined, now: number): HookVerdict | null {
147
+ const reply = replyOf(signal)
148
+ if (signal.ended && !(registry?.alive)) {
149
+ return { agent_state: 'ended', state_source: 'hook', state_since: iso(signal.ended.at), ...reply }
150
+ }
151
+ if (signal.waiting && waitStillStands(signal.waiting.since, registry, transcript, now)) {
152
+ return {
153
+ agent_state: 'waiting',
154
+ state_source: 'hook',
155
+ state_since: iso(signal.waiting.since),
156
+ waiting_kind: signal.waiting.kind,
157
+ waiting_detail: signal.waiting.detail,
158
+ ...(signal.waiting.requestId ? { pending_permission_id: signal.waiting.requestId } : {}),
159
+ ...reply,
160
+ }
161
+ }
162
+ if (signal.waiting) {
163
+ // The wait was overtaken by evidence the store cannot see (registry moved on, the
164
+ // transcript moved on, or nothing alive stands behind it): fall through to whoever can.
165
+ return null
166
+ }
167
+ if (signal.failure && !signal.turnOpen) {
168
+ return { agent_state: 'failed', state_source: 'hook', state_since: iso(signal.failure.at), failure: signal.failure.kind, ...reply }
169
+ }
170
+ if (signal.turnOpen) {
171
+ // An interrupt (Esc) fires no Stop: the registry moving to idle AFTER the last hook
172
+ // event is the only signal, and it must not wait for the half-hour ceiling.
173
+ if (registry?.alive && registry.status === 'idle' && registry.statusUpdatedAt && registry.statusUpdatedAt > signal.lastEventAt) return null
174
+ // An open turn with no event for half an hour is not evidence of work any more;
175
+ // let the registry or the transcript answer.
176
+ if (now - signal.lastEventAt > OPEN_TURN_CEILING_MS) return null
177
+ return { agent_state: 'running', state_source: 'hook', state_since: iso(signal.turnStartedAt ?? signal.lastEventAt), ...reply }
178
+ }
179
+ // A child that ended while the tab is alive reads idle, since the tab's own hooks are
180
+ // what would say otherwise.
181
+ return { agent_state: 'idle', state_source: 'hook', state_since: iso(signal.stopAt ?? signal.lastEventAt), ...reply }
182
+ }
183
+
184
+ /**
185
+ * A hook-derived wait stands while nothing contradicts it. Three things do: the registry
186
+ * moved after the wait began and no longer says waiting (the dialog closed); the
187
+ * transcript moved on well after the wait began (the tool ran); nothing alive stands
188
+ * behind a wait older than the ceiling (a tab that died with its dialog up, or a
189
+ * PermissionRequest spooled during an outage whose close half the guard dropped).
190
+ */
191
+ function waitStillStands(since: number, registry: RegistryFacts | undefined, transcript: TranscriptFacts | undefined, now: number): boolean {
192
+ if (registry?.alive && registry.statusUpdatedAt && registry.statusUpdatedAt > since && registry.status !== 'waiting' && registry.status !== null) return false
193
+ if (transcript?.lastActivityAt && transcript.lastActivityAt > since + WAITING_TRANSCRIPT_VETO_MS) return false
194
+ if (!(registry?.alive) && now - since > WAITING_CEILING_MS) return false
195
+ return true
196
+ }
197
+
198
+ /** The additive row fields, ready to spread onto a list or detail entry. */
199
+ export function derivedRowFields(derived: DerivedSessionState | undefined): Record<string, unknown> {
200
+ if (!derived) return {}
201
+ return {
202
+ agent_state: derived.agent_state,
203
+ state_source: derived.state_source,
204
+ state_since: derived.state_since,
205
+ ...(derived.waiting_kind ? { waiting_kind: derived.waiting_kind } : {}),
206
+ ...(derived.waiting_detail !== undefined ? { waiting_detail: derived.waiting_detail } : {}),
207
+ ...(derived.failure ? { failure: derived.failure } : {}),
208
+ ...(derived.last_reply ? { last_reply: derived.last_reply } : {}),
209
+ ...(derived.pending_permission_id ? { pending_permission_id: derived.pending_permission_id } : {}),
210
+ }
211
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * What may be used as VOICE EVIDENCE (6.47.0, WS5).
3
+ *
4
+ * A voice profile is built from audio this Mac captured. An imported meeting has
5
+ * no audio at all — Fireflies sends text — and a derived record (a merge, or one
6
+ * piece of a split) is a FUNCTION of other records rather than a recording. Its
7
+ * speaker names came from the vendor's own diarization or from a voice match
8
+ * suggestion, so training a profile on either would fold a cloud label back into
9
+ * the local identity store and then present it as local evidence. That is the
10
+ * one loop the speaker system must never close (CLAUDE.md: "Cloud speaker names
11
+ * training voice profiles (never)").
12
+ *
13
+ * REFUSES BY ID KIND ONLY. The rule is a property of the identifier in hand:
14
+ * an `imported:` or `blended:` record id, or an `.import.json` / `.derived.json`
15
+ * evidence path. It is NOT a property of the underlying meeting. A G2 session
16
+ * that happens to have a merged record derived from it is still a real capture
17
+ * with real audio, and relabelling it is exactly how its profile gets better —
18
+ * so this guard must never refuse a G2 session merely because derived records
19
+ * exist. The plan states that explicitly, and the execution gate pins it.
20
+ *
21
+ * PURE. No filesystem, no lookups: a guard that has to read the disk to decide
22
+ * fails open the moment the disk is slow, and this one runs before every voice
23
+ * mutation.
24
+ */
25
+
26
+ /** A record id that names an imported or derived record rather than a capture. */
27
+ export const IMPORTED_EVIDENCE_ID_PATTERN = /^(imported|blended):/
28
+
29
+ /** The two sidecars of the imported library. Never chunk audio, never `.g2-chunks.json`. */
30
+ export const IMPORTED_EVIDENCE_PATH_PATTERN = /\.(?:import|derived)\.json$/i
31
+
32
+ export type VoiceEvidenceReason = 'imported_read_only' | 'blended_derived_record'
33
+
34
+ export interface VoiceEvidenceRefusal {
35
+ status: 409
36
+ body: {
37
+ error: string
38
+ reason: VoiceEvidenceReason
39
+ /** The exact value that was refused, so a caller can say which input failed. */
40
+ source: string
41
+ }
42
+ }
43
+
44
+ /** The kind of an id, or null when it names neither. */
45
+ export function importedEvidenceKind(value: unknown): 'imported' | 'blended' | null {
46
+ if (typeof value !== 'string') return null
47
+ const id = value.match(IMPORTED_EVIDENCE_ID_PATTERN)
48
+ if (id) return id[1] as 'imported' | 'blended'
49
+ // A path is only ever evidence of a derived record, so it refuses as `blended`
50
+ // when it is a `.derived.json` and as `imported` when it is an `.import.json`.
51
+ if (IMPORTED_EVIDENCE_PATH_PATTERN.test(value)) {
52
+ return /\.derived\.json$/i.test(value) ? 'blended' : 'imported'
53
+ }
54
+ return null
55
+ }
56
+
57
+ export function isImportedVoiceEvidence(value: unknown): boolean {
58
+ return importedEvidenceKind(value) !== null
59
+ }
60
+
61
+ /**
62
+ * Refuse the first value that names an imported or derived record.
63
+ *
64
+ * Returns null when every value is acceptable, so a caller reads as
65
+ * `const refusal = assertVoiceEvidenceSource([...]); if (refusal) ...`.
66
+ */
67
+ export function assertVoiceEvidenceSource(values: ReadonlyArray<unknown>): VoiceEvidenceRefusal | null {
68
+ for (const value of values) {
69
+ const kind = importedEvidenceKind(value)
70
+ if (!kind) continue
71
+ return {
72
+ status: 409,
73
+ body: kind === 'imported'
74
+ ? {
75
+ error: 'Imported meetings carry no audio, so they cannot train or correct a voice',
76
+ reason: 'imported_read_only',
77
+ source: String(value),
78
+ }
79
+ : {
80
+ error: 'This record was derived from other meetings. Open the recording it came from',
81
+ reason: 'blended_derived_record',
82
+ source: String(value),
83
+ },
84
+ }
85
+ }
86
+ return null
87
+ }
@@ -37,7 +37,10 @@ import {
37
37
  type AgentSessionSort,
38
38
  } from '../lib/agent-session-store.js'
39
39
  import { searchAgentSessions, type AgentSessionSearchHit } from '../lib/agent-session-search.js'
40
- import { claudeSessionNamesVisible, claudeSessionsDir, claudeSessionsEnabled, readClaudePeers } from './claude-sessions.js'
40
+ import { claudeSessionNamesVisible, claudeSessionsDir, claudeSessionsEnabled, readClaudePeerRecords, registryFacts } from './claude-sessions.js'
41
+ import type { ClaudePeerRecord } from '../lib/claude-session-registry.js'
42
+ import { deriveForRow } from '../lib/session-hooks-runtime.js'
43
+ import { derivedRowFields, type DerivedSessionState } from '../lib/session-state-derive.js'
41
44
  import { workspaceFromCwd } from '../lib/claude-session-registry.js'
42
45
  import {
43
46
  occupiedThreads,
@@ -261,7 +264,7 @@ function runningForThread(provider: AgentProvider, threadId: string, mtimeMs: nu
261
264
  }
262
265
  }
263
266
 
264
- function toEntry(row: AgentSessionRow, activity?: SessionActivity | null) {
267
+ function toEntry(row: AgentSessionRow, activity?: SessionActivity | null, derived?: DerivedSessionState) {
265
268
  return {
266
269
  session_id: row.session_id,
267
270
  provider: row.provider,
@@ -286,12 +289,19 @@ function toEntry(row: AgentSessionRow, activity?: SessionActivity | null) {
286
289
  // unknown, so an older client and a Cursor row see exactly the payload they did.
287
290
  ...(activity?.lastActivityAt ? { last_activity_at: activity.lastActivityAt } : {}),
288
291
  ...(activity?.lastTool ? { last_tool: activity.lastTool } : {}),
292
+ // 6.48.0: the one derived state (hook > registry > transcript) with its provenance.
293
+ // Under a NEW key: `state` above is `running|recent` and Control renders any other
294
+ // value there as "Running". Omitted entirely when nothing is known, like the two above.
295
+ ...derivedRowFields(derived),
289
296
  }
290
297
  }
291
298
 
292
- async function liveClaudeRows(): Promise<AgentSessionRow[]> {
299
+ async function liveClaudePeerRecords(): Promise<ClaudePeerRecord[]> {
293
300
  if (!claudeSessionsEnabled()) return []
294
- const peers = await readClaudePeers(claudeSessionsDir(), undefined, claudeSessionNamesVisible())
301
+ return readClaudePeerRecords(claudeSessionsDir(), undefined, claudeSessionNamesVisible())
302
+ }
303
+
304
+ function liveClaudeRows(peers: ClaudePeerRecord[]): AgentSessionRow[] {
295
305
  return peers.filter(peer => peer.alive).map(peer => ({
296
306
  session_id: peer.id,
297
307
  provider: 'claude' as const,
@@ -322,7 +332,8 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
322
332
  // ONE alias load per request, shared by the walk, every live row and every
323
333
  // held thread. Measured 2026-09-10: sixteen loads of 119 MB before this line.
324
334
  const aliases = await loadClaudeDesktopAliases(roots.claudeCodeSessions)
325
- const live = await liveClaudeRows()
335
+ const peers = await liveClaudePeerRecords()
336
+ const live = liveClaudeRows(peers)
326
337
  const dropped = emptySessionListDropped()
327
338
  const sessions = await listAgentSessions(roots, new Date(), live, limit, sort, dropped, aliases)
328
339
  // 6.45.5: each row's last real activity, read from its transcript records. Memoized on
@@ -342,8 +353,35 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
342
353
  if (mtimeMs !== null) clocks.set(threadId, activityClockMs(mtimeMs, activityById.get(threadId)))
343
354
  }
344
355
  const running = withActiveRecently(scan, clocks, Date.now())
356
+ // Derived AFTER the walk: live rows carry the registry's eight-character id until
357
+ // `enrichLiveClaude` finds their transcript, and the deriver wants the full one where
358
+ // it exists. The registry facts are joined back by prefix; a transcript-only row (an
359
+ // ended job, a tab closed hours ago) still gets a state from its activity clock.
360
+ const now = Date.now()
361
+ // First wins, and `readClaudePeerRecords` sorts alive first: a dead predecessor's file
362
+ // (a resumed tab, a finished Continue child) must never shadow the live record.
363
+ const peersByPrefix = new Map<string, ClaudePeerRecord>()
364
+ for (const peer of peers) {
365
+ const prefix = peer.sessionId.slice(0, 8)
366
+ if (!peersByPrefix.has(prefix)) peersByPrefix.set(prefix, peer)
367
+ }
368
+ const derivedById = new Map<string, DerivedSessionState | undefined>()
369
+ for (const row of sessions) {
370
+ if (row.provider !== 'claude') continue
371
+ const peer = peersByPrefix.get(row.session_id.slice(0, 8).toLowerCase())
372
+ const hint = running.occupied.get(row.session_id)
373
+ derivedById.set(row.session_id, deriveForRow({
374
+ sessionId: row.session_id,
375
+ registry: peer ? registryFacts(peer) : undefined,
376
+ transcript: {
377
+ inFlight: hint?.activeRecently === true,
378
+ lastActivityAt: activityById.get(row.session_id)?.lastActivityAt ? Date.parse(activityById.get(row.session_id)!.lastActivityAt!) : null,
379
+ },
380
+ now,
381
+ }))
382
+ }
345
383
  res.json({
346
- sessions: sessions.map((row, index) => withRunning(toEntry(row, activity[index]), running)),
384
+ sessions: sessions.map((row, index) => withRunning(toEntry(row, activity[index], derivedById.get(row.session_id)), running)),
347
385
  total: sessions.length,
348
386
  windowHours: AGENT_SESSION_WINDOW_HOURS,
349
387
  sort,
@@ -417,8 +455,20 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
417
455
  const modified = st.mtime.toISOString()
418
456
  const activity = await readSessionActivity(provider, found)
419
457
  const running = runningForThread(provider, parsed.session_id, activityClockMs(st.mtimeMs, activity))
458
+ // 6.48.0: the detail carries the same derived state as its list row. The registry is
459
+ // read once here (a few hundred small files at most) because the detail has no walk.
460
+ let derived: DerivedSessionState | undefined
461
+ if (provider === 'claude') {
462
+ const peer = (await liveClaudePeerRecords()).find(p => p.sessionId === parsed.session_id.toLowerCase()) // alive first
463
+ derived = deriveForRow({
464
+ sessionId: parsed.session_id,
465
+ registry: peer ? registryFacts(peer) : undefined,
466
+ transcript: { inFlight: running.occupied.get(parsed.session_id)?.activeRecently === true, lastActivityAt: activity.lastActivityAt ? Date.parse(activity.lastActivityAt) : null },
467
+ })
468
+ }
420
469
  res.json({
421
470
  ...withRunning({ session_id: parsed.session_id }, running),
471
+ ...derivedRowFields(derived),
422
472
  // The client must be able to tell "this server stamped nothing" from "this
423
473
  // server stamped false", because the two demand opposite behaviour: an old
424
474
  // server's silence means keep using the hint borrowed from the list row, and
@@ -28,7 +28,12 @@ import {
28
28
  type ClaudePeer,
29
29
  type PeerProbes,
30
30
  type RawClaudeSession,
31
+ type ClaudePeerRecord,
32
+ peerRecordFacts,
33
+ toWirePeer,
31
34
  } from '../lib/claude-session-registry.js'
35
+ import { deriveForRow } from '../lib/session-hooks-runtime.js'
36
+ import { derivedRowFields, type RegistryFacts } from '../lib/session-state-derive.js'
32
37
 
33
38
  export const claudeSessionsRouter = Router()
34
39
 
@@ -92,6 +97,15 @@ export async function readClaudePeers(
92
97
  probes: PeerProbes = realProbes,
93
98
  showNames = claudeSessionNamesVisible(),
94
99
  ): Promise<ClaudePeer[]> {
100
+ return (await readClaudePeerRecords(dir, probes, showNames)).map(toWirePeer)
101
+ }
102
+
103
+ /** The peers with the deriver's facts attached. Server-side only; see `toWirePeer`. */
104
+ export async function readClaudePeerRecords(
105
+ dir: string,
106
+ probes: PeerProbes = realProbes,
107
+ showNames = claudeSessionNamesVisible(),
108
+ ): Promise<ClaudePeerRecord[]> {
95
109
  let names: string[]
96
110
  try {
97
111
  names = await readdir(dir)
@@ -101,7 +115,7 @@ export async function readClaudePeers(
101
115
  // separately so this cannot be mistaken for "the feature is off".
102
116
  return []
103
117
  }
104
- const peers: ClaudePeer[] = []
118
+ const peers: ClaudePeerRecord[] = []
105
119
  for (const name of names.filter(n => REGISTRY_FILENAME.test(n)).slice(0, MAX_REGISTRY_FILES)) {
106
120
  const full = join(dir, name)
107
121
  try {
@@ -114,7 +128,8 @@ export async function readClaudePeers(
114
128
  let mtimeMs: number | null = null
115
129
  try { mtimeMs = (await stat(full)).mtimeMs } catch { /* raced the reaper */ }
116
130
  const peer = toPeer(raw, probes, mtimeMs, showNames)
117
- if (peer) peers.push(peer)
131
+ const facts = peerRecordFacts(raw)
132
+ if (peer && facts) peers.push({ ...peer, ...facts })
118
133
  } catch {
119
134
  // ENOENT between readdir and read is NORMAL here — the reaper is actively
120
135
  // unlinking these — and a torn read is expected because writes are
@@ -122,7 +137,12 @@ export async function readClaudePeers(
122
137
  continue
123
138
  }
124
139
  }
125
- return sortPeers(peers)
140
+ return sortPeers(peers) as ClaudePeerRecord[]
141
+ }
142
+
143
+ /** The deriver's view of a registry record. */
144
+ export function registryFacts(record: ClaudePeerRecord): RegistryFacts {
145
+ return { alive: record.alive, status: record.status, waitingFor: record.waitingFor, statusUpdatedAt: record.statusUpdatedAt, lastActiveAt: record.lastActiveAt }
126
146
  }
127
147
 
128
148
  function boundedInteger(value: unknown, fallback: number, min: number, max: number): number {
@@ -148,9 +168,16 @@ claudeSessionsRouter.get('/claude-sessions', async (req, res) => {
148
168
  }
149
169
  try {
150
170
  const limit = boundedInteger(req.query.limit, 30, 1, 100)
151
- const peers = await readClaudePeers(claudeSessionsDir())
171
+ const records = await readClaudePeerRecords(claudeSessionsDir())
172
+ const peers = records.map(toWirePeer)
173
+ // 6.48.0: the same derived state every surface reads, stamped ADDITIVELY on the wire
174
+ // peer. `toPeer` stays byte-identical (its key set is pinned); the eight extra keys
175
+ // come from the signal store and the registry facts an older client never sees.
152
176
  res.json({
153
- peers: peers.slice(0, limit),
177
+ peers: records.slice(0, limit).map(record => ({
178
+ ...toWirePeer(record),
179
+ ...derivedRowFields(deriveForRow({ sessionId: record.sessionId, registry: registryFacts(record) })),
180
+ })),
154
181
  counts: countPeers(peers),
155
182
  enabled: true,
156
183
  generatedAt: Date.now(),
@@ -0,0 +1,102 @@
1
+ // Fireflies key endpoints.
2
+ //
3
+ // POST /api/fireflies-key/set store the key, then check it
4
+ // GET /api/fireflies-key/status configured, source, savedAt, validatedAt, lastCheck
5
+ // DELETE /api/fireflies-key remove the stored key
6
+ // POST /api/fireflies-key/check check now (this is the user-initiated one)
7
+ //
8
+ // The key is never in a response, and never in a log line. `status` reads the
9
+ // remembered check rather than calling the vendor, so a settings pane that
10
+ // polls it costs nothing.
11
+
12
+ import { Router } from 'express'
13
+ import { FirefliesKeyError, getFirefliesClient, getFirefliesKeyStore, type FirefliesKeyStore } from '../lib/fireflies-key.js'
14
+ import { getFirefliesImporter } from '../lib/meeting-import.js'
15
+ import type { FirefliesClient, FirefliesKeyCheck } from '../lib/fireflies-client.js'
16
+
17
+ export interface FirefliesKeyRouterDeps {
18
+ store: () => FirefliesKeyStore
19
+ client: () => FirefliesClient
20
+ /** Told whenever the stored key changes, so a sticky invalid_key can clear. */
21
+ onKeyChanged?: (event: { configured: boolean }) => void
22
+ /** Told the result of every check, for the same reason. */
23
+ onKeyChecked?: (check: FirefliesKeyCheck) => void
24
+ }
25
+
26
+ export function createFirefliesKeyRouter(deps: FirefliesKeyRouterDeps): Router {
27
+ const router = Router()
28
+
29
+ const runCheck = async (): Promise<FirefliesKeyCheck> => {
30
+ const client = deps.client()
31
+ const check = await client.checkKey({ userInitiated: true })
32
+ deps.store().recordCheck(check)
33
+ deps.onKeyChecked?.(check)
34
+ return check
35
+ }
36
+
37
+ router.post('/fireflies-key/set', async (req, res) => {
38
+ try {
39
+ const store = deps.store()
40
+ const saved = store.save(req.body?.key)
41
+ deps.client().forgetKeyCheck()
42
+ deps.onKeyChanged?.({ configured: true })
43
+ const check = await runCheck()
44
+ res.json({ ok: true, savedAt: saved.savedAt, status: store.status(), check })
45
+ } catch (error) {
46
+ if (error instanceof FirefliesKeyError) {
47
+ return res.status(error.status).json({ error: { code: error.code, message: error.message } })
48
+ }
49
+ console.error('[fireflies-key] save failed')
50
+ res.status(500).json({ error: { code: 'fireflies_key_save_failed', message: 'The key could not be saved.' } })
51
+ }
52
+ })
53
+
54
+ router.get('/fireflies-key/status', (_req, res) => {
55
+ try {
56
+ const status = deps.store().status()
57
+ const cached = deps.client().cachedKeyCheck()
58
+ res.json({ ...status, ...(cached ? { lastCheck: { ...cached, cached: undefined } } : {}) })
59
+ } catch (error) {
60
+ console.error('[fireflies-key] status failed')
61
+ res.status(500).json({ error: { code: 'fireflies_key_unavailable', message: 'The key status could not be read.' } })
62
+ }
63
+ })
64
+
65
+ router.delete('/fireflies-key', (_req, res) => {
66
+ try {
67
+ const store = deps.store()
68
+ store.delete()
69
+ deps.client().forgetKeyCheck()
70
+ deps.onKeyChanged?.({ configured: store.status().configured })
71
+ res.json({ ok: true, status: store.status() })
72
+ } catch (error) {
73
+ if (error instanceof FirefliesKeyError) {
74
+ return res.status(error.status).json({ error: { code: error.code, message: error.message } })
75
+ }
76
+ console.error('[fireflies-key] delete failed')
77
+ res.status(500).json({ error: { code: 'fireflies_key_delete_failed', message: 'The key could not be removed.' } })
78
+ }
79
+ })
80
+
81
+ router.post('/fireflies-key/check', async (_req, res) => {
82
+ try {
83
+ res.json(await runCheck())
84
+ } catch (error) {
85
+ console.error('[fireflies-key] check failed')
86
+ res.status(500).json({ error: { code: 'fireflies_key_check_failed', message: 'The key could not be checked.' } })
87
+ }
88
+ })
89
+
90
+ return router
91
+ }
92
+
93
+ // A sticky invalid_key is what stops the importer from spending a budget on a
94
+ // key the vendor already refused, so the two things that can make it wrong - a
95
+ // new key, and a check that now succeeds - have to reach the importer from
96
+ // here. Without this the only way out of the sticky state is a restart.
97
+ export const firefliesKeyRouter = createFirefliesKeyRouter({
98
+ store: getFirefliesKeyStore,
99
+ client: getFirefliesClient,
100
+ onKeyChanged: () => getFirefliesImporter().onKeyChanged(),
101
+ onKeyChecked: check => getFirefliesImporter().onKeyChecked(check),
102
+ })
@@ -79,6 +79,7 @@ import {
79
79
  threadAttachCapability,
80
80
  threadAttachHealthFields,
81
81
  } from '../lib/thread-attach-capability.js'
82
+ import { sessionHooksHealthFields } from '../lib/session-hooks-runtime.js'
82
83
 
83
84
  export const healthRouter = Router()
84
85
 
@@ -349,6 +350,7 @@ healthRouter.get('/health', async (_req, res) => {
349
350
  ...checks,
350
351
  server_version: managedServerVersion(),
351
352
  ...threadAttachHealthFields(threadAttach),
353
+ ...sessionHooksHealthFields(),
352
354
  server_instance_id: getServerInstanceId(),
353
355
  boot_id: serverMetrics.bootId,
354
356
  generation_id: getServerGenerationId(),