@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.
- package/CHANGELOG.md +25 -0
- package/README.md +24 -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 +8 -2
- package/server/index.ts +85 -0
- package/server/lib/claude-hooks-installer.ts +403 -0
- package/server/lib/claude-session-registry.ts +25 -0
- package/server/lib/cos-operations-meetings.ts +99 -8
- package/server/lib/fireflies-client.ts +862 -0
- package/server/lib/fireflies-key.ts +182 -0
- package/server/lib/imported-library-rows.ts +616 -0
- package/server/lib/imported-meeting-library.ts +608 -0
- package/server/lib/maintenance-lifecycle.ts +14 -0
- package/server/lib/meeting-actions-store.ts +478 -0
- package/server/lib/meeting-actions.ts +2583 -0
- package/server/lib/meeting-corrections.ts +32 -1
- package/server/lib/meeting-decisions.ts +223 -0
- package/server/lib/meeting-engine/align.ts +167 -0
- package/server/lib/meeting-engine/attribute.ts +265 -0
- package/server/lib/meeting-engine/evidence.ts +428 -0
- package/server/lib/meeting-engine/pairing.ts +327 -0
- package/server/lib/meeting-engine/render.ts +694 -0
- package/server/lib/meeting-engine/split.ts +242 -0
- package/server/lib/meeting-engine/worker.ts +238 -0
- package/server/lib/meeting-engine-mode.ts +197 -0
- package/server/lib/meeting-file-guards.ts +141 -0
- package/server/lib/meeting-import.ts +763 -0
- package/server/lib/meeting-library-search.ts +146 -11
- package/server/lib/meeting-parse.ts +184 -0
- package/server/lib/meeting-store.ts +108 -275
- package/server/lib/meeting-suggestion-sides.ts +242 -0
- package/server/lib/morning-brief-runtime.ts +20 -8
- package/server/lib/pipeline-runner.ts +227 -0
- package/server/lib/session-hook-events.ts +200 -0
- package/server/lib/session-hook-ledger.ts +129 -0
- package/server/lib/session-hook-spool.ts +264 -0
- package/server/lib/session-hooks-runtime.ts +229 -0
- package/server/lib/session-signal-store.ts +361 -0
- package/server/lib/session-state-derive.ts +211 -0
- package/server/lib/voice-evidence-guard.ts +87 -0
- package/server/routes/agent-sessions.ts +56 -6
- package/server/routes/claude-sessions.ts +32 -5
- package/server/routes/fireflies-key.ts +102 -0
- package/server/routes/health.ts +2 -0
- package/server/routes/meeting-actions.ts +82 -0
- package/server/routes/meeting-engine.ts +52 -0
- package/server/routes/meeting-import.ts +67 -0
- package/server/routes/meeting-suggestions.ts +66 -0
- package/server/routes/meeting.ts +117 -10
- package/server/routes/meetings.ts +177 -50
- package/server/routes/session-hooks.ts +70 -0
- package/server/routes/voice.ts +18 -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,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,
|
|
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
|
|
299
|
+
async function liveClaudePeerRecords(): Promise<ClaudePeerRecord[]> {
|
|
293
300
|
if (!claudeSessionsEnabled()) return []
|
|
294
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
+
})
|
package/server/routes/health.ts
CHANGED
|
@@ -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(),
|