@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,200 @@
1
+ // The hook envelope: what `bin/hooks/cos-session-hook` writes, and the only shape the
2
+ // ingester, the ledger and the signal store ever read.
3
+ //
4
+ // One file per Claude Code hook event, spooled by a POSIX sh script that never talks to
5
+ // this server (PermissionRequest excepted). The script wraps the payload Claude piped to
6
+ // it: `{"ts":<ms>,"ppid":<pid>,"event":"<name>","payload":<stdin>}`.
7
+ //
8
+ // FIELD NAMES COME FROM RECORDED 2.1.272 PAYLOADS WHERE ONE EXISTS, never from the docs
9
+ // alone. The docs said `start_reason`, `end_reason`, `stop_reason` and a `tool_use_id` on
10
+ // PermissionRequest; the recordings (2026-09-15) carry `source`, `reason`, no stop reason
11
+ // at all, and no tool_use_id on PermissionRequest. Fixtures under
12
+ // `__fixtures__/session-hooks-6.48.0/` are those recordings; their README lists the
13
+ // interactive-only events (Notification, StopFailure, sub-agents, compaction, model switch)
14
+ // whose shapes are still the documented ones until a Desktop recording lands.
15
+
16
+ import { createHash } from 'node:crypto'
17
+
18
+ /** Events the installer subscribes and the reducer understands. Anything else is dropped. */
19
+ export const HOOK_EVENT_NAMES = [
20
+ 'SessionStart',
21
+ 'SessionEnd',
22
+ 'UserPromptSubmit',
23
+ 'Stop',
24
+ 'StopFailure',
25
+ 'PermissionRequest',
26
+ 'PermissionDenied',
27
+ 'PreToolUse',
28
+ 'PostToolUse',
29
+ 'PostToolUseFailure',
30
+ 'Notification',
31
+ 'SubagentStart',
32
+ 'SubagentStop',
33
+ 'PostCompact',
34
+ 'PostModelSwitch',
35
+ ] as const
36
+
37
+ export type HookEventName = typeof HOOK_EVENT_NAMES[number]
38
+
39
+ const EVENT_SET: ReadonlySet<string> = new Set(HOOK_EVENT_NAMES)
40
+
41
+ export function isHookEventName(value: unknown): value is HookEventName {
42
+ return typeof value === 'string' && EVENT_SET.has(value)
43
+ }
44
+
45
+ /** A full Claude session id: the transcript basename and the registry `sessionId`. */
46
+ export const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
47
+
48
+ /** The spool file is capped by the script at 1 MiB of stdin; the envelope adds a few bytes. */
49
+ export const HOOK_ENVELOPE_MAX_BYTES = 1_048_576 + 256
50
+
51
+ export interface HookEnvelope {
52
+ /** Milliseconds, stamped by the script when the hook ran. */
53
+ ts: number
54
+ /** The hook process's parent pid: the claude process, or the `sh -c` wrapper. */
55
+ ppid: number | null
56
+ event: HookEventName
57
+ sessionId: string
58
+ payload: Record<string, unknown>
59
+ }
60
+
61
+ /**
62
+ * What a regex can still read off the head of a file that did not parse: enough for the
63
+ * store to apply a payload-less event so a cut Stop still turns a row idle, never enough
64
+ * to trust any other field.
65
+ */
66
+ export interface SalvagedHookHead {
67
+ sessionId: string
68
+ event: HookEventName
69
+ ts: number
70
+ }
71
+
72
+ export type ParsedHookEnvelope =
73
+ | { ok: true; envelope: HookEnvelope }
74
+ | { ok: false; reason: 'too_large' | 'not_json' | 'not_envelope' | 'unknown_event' | 'no_session'; salvaged: SalvagedHookHead | null }
75
+
76
+ const SALVAGE_SESSION_RE = /"session_id"\s*:\s*"([0-9a-f-]{36})"/i
77
+ const SALVAGE_EVENT_RE = /"event"\s*:\s*"([A-Za-z]+)"/
78
+ const SALVAGE_TS_RE = /"ts"\s*:\s*(\d{10,16})/
79
+
80
+ function salvage(text: string): SalvagedHookHead | null {
81
+ const head = text.slice(0, 4096)
82
+ const session = SALVAGE_SESSION_RE.exec(head)?.[1]
83
+ const event = SALVAGE_EVENT_RE.exec(head)?.[1]
84
+ const ts = Number(SALVAGE_TS_RE.exec(head)?.[1])
85
+ if (!session || !SESSION_ID_RE.test(session) || !isHookEventName(event) || !Number.isFinite(ts)) return null
86
+ return { sessionId: session.toLowerCase(), event, ts }
87
+ }
88
+
89
+ export function parseHookEnvelope(text: string): ParsedHookEnvelope {
90
+ if (text.length > HOOK_ENVELOPE_MAX_BYTES) return { ok: false, reason: 'too_large', salvaged: salvage(text) }
91
+ let raw: unknown
92
+ try { raw = JSON.parse(text) } catch { return { ok: false, reason: 'not_json', salvaged: salvage(text) } }
93
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, reason: 'not_envelope', salvaged: null }
94
+ const r = raw as Record<string, unknown>
95
+ const ts = Number(r.ts)
96
+ const payload = r.payload
97
+ if (!Number.isFinite(ts) || !payload || typeof payload !== 'object' || Array.isArray(payload)) {
98
+ return { ok: false, reason: 'not_envelope', salvaged: salvage(text) }
99
+ }
100
+ if (!isHookEventName(r.event)) return { ok: false, reason: 'unknown_event', salvaged: null }
101
+ const p = payload as Record<string, unknown>
102
+ const sessionId = typeof p.session_id === 'string' && SESSION_ID_RE.test(p.session_id) ? p.session_id.toLowerCase() : null
103
+ if (!sessionId) return { ok: false, reason: 'no_session', salvaged: null }
104
+ const ppid = Number(r.ppid)
105
+ return {
106
+ ok: true,
107
+ envelope: {
108
+ ts,
109
+ ppid: Number.isInteger(ppid) && ppid > 0 ? ppid : null,
110
+ event: r.event,
111
+ sessionId,
112
+ payload: p,
113
+ },
114
+ }
115
+ }
116
+
117
+ /** Pin the fingerprint the broker and the reducer share: tool name + canonical input. */
118
+ export function toolFingerprint(toolName: string, toolInput: unknown): string {
119
+ return createHash('sha256').update(toolName).update('\0').update(canonicalJson(toolInput)).digest('hex')
120
+ }
121
+
122
+ function canonicalJson(value: unknown): string {
123
+ if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null)
124
+ if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']'
125
+ const o = value as Record<string, unknown>
126
+ return '{' + Object.keys(o).sort().map(k => JSON.stringify(k) + ':' + canonicalJson(o[k])).join(',') + '}'
127
+ }
128
+
129
+ /** The 80-char target a waiting line shows: the command, path or URL the tool was called with. */
130
+ export function toolTarget(toolInput: unknown, max = 80): string {
131
+ if (!toolInput || typeof toolInput !== 'object') return ''
132
+ const i = toolInput as Record<string, unknown>
133
+ const candidate = [i.command, i.file_path, i.path, i.url, i.pattern, i.query, i.prompt, i.description]
134
+ .find(v => typeof v === 'string' && v.trim().length > 0)
135
+ const text = typeof candidate === 'string' ? candidate.replace(/\s+/g, ' ').trim() : ''
136
+ return text.length > max ? text.slice(0, max - 1) + '…' : text
137
+ }
138
+
139
+ /** Text fields kept from a payload. Everything else is dropped before the ledger sees it. */
140
+ export const HOOK_TEXT_MAX = 400
141
+
142
+ export function clipText(value: unknown, max = HOOK_TEXT_MAX): string {
143
+ if (typeof value !== 'string') return ''
144
+ const text = value.trim()
145
+ return text.length > max ? text.slice(0, max - 1) + '…' : text
146
+ }
147
+
148
+ /**
149
+ * What the ledger keeps of a payload. A PostToolUse carries the whole tool_response (a
150
+ * Read can exceed 100 KB) and a Stop the whole reply; neither belongs in a durable
151
+ * 10 MB ring. The reducer needs exactly these.
152
+ */
153
+ export function projectHookPayload(event: HookEventName, payload: Record<string, unknown>): Record<string, unknown> {
154
+ const str = (k: string) => (typeof payload[k] === 'string' ? payload[k] as string : undefined)
155
+ const base: Record<string, unknown> = {
156
+ session_id: str('session_id'),
157
+ ...(str('transcript_path') ? { transcript_path: str('transcript_path') } : {}),
158
+ ...(str('cwd') ? { cwd: str('cwd') } : {}),
159
+ ...(str('permission_mode') ? { permission_mode: str('permission_mode') } : {}),
160
+ ...(str('prompt_id') ? { prompt_id: str('prompt_id') } : {}),
161
+ }
162
+ switch (event) {
163
+ case 'SessionStart':
164
+ return { ...base, source: str('source'), ...(str('model') ? { model: str('model') } : {}) }
165
+ case 'SessionEnd':
166
+ return { ...base, reason: str('reason') }
167
+ case 'UserPromptSubmit':
168
+ return { ...base, prompt: clipText(payload.prompt) }
169
+ case 'Stop':
170
+ return { ...base, last_assistant_message: clipText(payload.last_assistant_message), stop_hook_active: payload.stop_hook_active === true }
171
+ case 'StopFailure':
172
+ return { ...base, error: clipText(payload.error ?? payload.error_type ?? payload.message, 120), matcher: str('matcher') ?? str('error_type') }
173
+ case 'PermissionRequest':
174
+ case 'PermissionDenied':
175
+ case 'PreToolUse':
176
+ case 'PostToolUse':
177
+ case 'PostToolUseFailure': {
178
+ const toolName = str('tool_name') ?? ''
179
+ return {
180
+ ...base,
181
+ tool_name: toolName,
182
+ tool_target: toolTarget(payload.tool_input),
183
+ tool_fingerprint: toolFingerprint(toolName, payload.tool_input ?? null),
184
+ ...(str('tool_use_id') ? { tool_use_id: str('tool_use_id') } : {}),
185
+ ...(event === 'PermissionRequest' && Array.isArray(payload.permission_suggestions)
186
+ ? { permission_suggestions: payload.permission_suggestions.slice(0, 4) }
187
+ : {}),
188
+ }
189
+ }
190
+ case 'Notification':
191
+ return { ...base, notification_type: str('notification_type'), message: clipText(payload.message, 160) }
192
+ case 'SubagentStart':
193
+ case 'SubagentStop':
194
+ return { ...base, agent_id: str('agent_id'), agent_type: str('agent_type') }
195
+ case 'PostModelSwitch':
196
+ return { ...base, from_model: str('from_model'), to_model: str('to_model') }
197
+ case 'PostCompact':
198
+ return base
199
+ }
200
+ }
@@ -0,0 +1,129 @@
1
+ // The durable trail of hook events: `session-hook-events.jsonl`, one projected row per
2
+ // envelope, rotated once at 10 MB, replayed on boot to warm the signal store.
3
+ //
4
+ // The ledger keeps the PROJECTION (`projectHookPayload`), never the raw payload: a
5
+ // PostToolUse response or a full reply would churn the ring in minutes. Each row carries
6
+ // the spool filename as its key so a crash between append and unlink replays without
7
+ // applying the same event twice.
8
+
9
+ import { appendFileSync, existsSync, readFileSync, renameSync, statSync } from 'node:fs'
10
+ import type { HookEnvelope, HookEventName } from './session-hook-events.js'
11
+ import { projectHookPayload, isHookEventName, SESSION_ID_RE } from './session-hook-events.js'
12
+
13
+ export const LEDGER_ROTATE_BYTES = 10 * 1024 * 1024
14
+
15
+ export interface LedgerRow {
16
+ key: string
17
+ ts: number
18
+ ppid: number | null
19
+ event: HookEventName
20
+ session_id: string
21
+ /** Classified at ingest, while the pid was alive: a COS-spawned child's event. */
22
+ child?: boolean
23
+ payload: Record<string, unknown>
24
+ }
25
+
26
+ function codeOf(error: unknown): string {
27
+ const code = (error as { code?: unknown })?.code
28
+ return typeof code === 'string' && code ? code : (error instanceof Error ? error.name || 'Error' : 'error')
29
+ }
30
+
31
+ function readText(path: string): string {
32
+ if (!existsSync(path)) return ''
33
+ try { return readFileSync(path, 'utf-8') } catch { return '' }
34
+ }
35
+
36
+ function oldestTs(text: string): number | null {
37
+ for (const line of text.split('\n')) {
38
+ if (!line.trim()) continue
39
+ try {
40
+ const ts = (JSON.parse(line) as { ts?: unknown }).ts
41
+ if (typeof ts === 'number') return ts
42
+ } catch { continue }
43
+ }
44
+ return null
45
+ }
46
+
47
+ export class SessionHookLedger {
48
+ readonly path: string
49
+ private lastError: string | null = null
50
+ private appended = 0
51
+
52
+ constructor(path: string) {
53
+ this.path = path
54
+ }
55
+
56
+ /** Append one row. Returns false (and remembers why) when the disk refused. */
57
+ append(key: string, env: HookEnvelope, child = false): boolean {
58
+ let row: LedgerRow
59
+ try {
60
+ row = {
61
+ key,
62
+ ts: env.ts,
63
+ ppid: env.ppid,
64
+ event: env.event,
65
+ session_id: env.sessionId,
66
+ ...(child ? { child: true } : {}),
67
+ payload: projectHookPayload(env.event, env.payload),
68
+ }
69
+ } catch (error) {
70
+ // A pathological payload must not escape into the sweep; the caller rejects the file.
71
+ this.lastError = codeOf(error)
72
+ return false
73
+ }
74
+ try {
75
+ this.rotateIfNeeded()
76
+ appendFileSync(this.path, JSON.stringify(row) + '\n', { mode: 0o600 })
77
+ this.appended++
78
+ this.lastError = null
79
+ return true
80
+ } catch (error) {
81
+ this.lastError = codeOf(error)
82
+ return false
83
+ }
84
+ }
85
+
86
+ private rotateIfNeeded(): void {
87
+ let size = 0
88
+ try { size = statSync(this.path).size } catch { return }
89
+ if (size < LEDGER_ROTATE_BYTES) return
90
+ try { renameSync(this.path, this.path + '.1') } catch { /* the next append tries again */ }
91
+ }
92
+
93
+ /**
94
+ * Replay rows newer than `sinceMs`. The rotated `.1` is read too whenever the current
95
+ * file does not reach back to `sinceMs` (a rotation inside the window would otherwise
96
+ * hide the rows that matter most, and forget their keys). Malformed lines are skipped;
97
+ * a boot must never fail on a torn last line.
98
+ */
99
+ replay(sinceMs: number, apply: (env: HookEnvelope, key: string, child: boolean) => void): { rows: number; applied: number; keys: Set<string> } {
100
+ const keys = new Set<string>()
101
+ let rows = 0
102
+ let applied = 0
103
+ const current = readText(this.path)
104
+ const oldest = oldestTs(current)
105
+ const texts = oldest !== null && oldest > sinceMs ? [readText(this.path + '.1'), current] : [current]
106
+ for (const line of texts.join('\n').split('\n')) {
107
+ if (!line.trim()) continue
108
+ let row: unknown
109
+ try { row = JSON.parse(line) } catch { continue }
110
+ if (!row || typeof row !== 'object') continue
111
+ const r = row as Partial<LedgerRow>
112
+ rows++
113
+ if (typeof r.key !== 'string' || typeof r.ts !== 'number' || !isHookEventName(r.event)) continue
114
+ if (typeof r.session_id !== 'string' || !SESSION_ID_RE.test(r.session_id)) continue
115
+ keys.add(r.key)
116
+ if (r.ts < sinceMs) continue
117
+ const payload = r.payload && typeof r.payload === 'object' ? r.payload as Record<string, unknown> : {}
118
+ apply({ ts: r.ts, ppid: typeof r.ppid === 'number' ? r.ppid : null, event: r.event, sessionId: r.session_id.toLowerCase(), payload: { session_id: r.session_id, ...payload } }, r.key, r.child === true)
119
+ applied++
120
+ }
121
+ return { rows, applied, keys }
122
+ }
123
+
124
+ stats(): { bytes: number; appended: number; lastError: string | null } {
125
+ let bytes = 0
126
+ try { bytes = statSync(this.path).size } catch { /* none yet */ }
127
+ return { bytes, appended: this.appended, lastError: this.lastError }
128
+ }
129
+ }
@@ -0,0 +1,264 @@
1
+ // The spool ingester: reads the one-file-per-event spool `bin/hooks/cos-session-hook`
2
+ // writes, ledgers each event, applies it to the signal store, and unlinks the file.
3
+ //
4
+ // THE FILE IS THE BUS. The hook script never talks to this server, so a session's
5
+ // hooks keep spooling while the managed runtime restarts or is stopped for a week; the
6
+ // startup drain applies the backlog in filename order (millisecond timestamp, then pid)
7
+ // and `state_since` comes from each file's own stamp, not its arrival.
8
+ //
9
+ // Same poll discipline as `session-transcript-watcher.ts`: a `fs.watch` on the directory
10
+ // is only a wake-up, the 2 s readdir sweep is the guaranteed path, one sweep at a time
11
+ // (`ticking`), timer unref'd so it never holds the process open. A sweep applies at most
12
+ // BATCH files, so a 2,000-file backlog is drained in bounded slices of the event loop.
13
+ //
14
+ // Off is still a drain. With `COS_SESSION_HOOKS=0` the ingester runs with `apply` set to
15
+ // nothing: it ledgers, unlinks and stamps `.last-drain`, because the script's own guard
16
+ // stops spooling when that stamp goes stale, and a spool nobody reads must not grow.
17
+
18
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, utimesSync, watch, writeFileSync, type FSWatcher } from 'node:fs'
19
+ import { join } from 'node:path'
20
+ import type { HookEnvelope } from './session-hook-events.js'
21
+ import { parseHookEnvelope } from './session-hook-events.js'
22
+ import type { SessionHookLedger } from './session-hook-ledger.js'
23
+
24
+ export const SPOOL_SWEEP_MS = 2_000
25
+ export const SPOOL_BATCH = 200
26
+ export const REJECTED_KEEP = 1_000
27
+ export const REJECTED_MAX_AGE_MS = 7 * 24 * 60 * 60_000
28
+ export const LAST_DRAIN_STAMP = '.last-drain'
29
+
30
+ const SPOOL_FILE_RE = /^(\d{10,16})-(\d+)-([A-Za-z]+)\.json$/
31
+
32
+ /** A code, never a message: raw fs messages carry the home path and health is public. */
33
+ export function errorCode(error: unknown): string {
34
+ const code = (error as { code?: unknown })?.code
35
+ if (typeof code === 'string' && code) return code
36
+ return error instanceof Error ? error.name || 'Error' : 'error'
37
+ }
38
+
39
+ /** A hook killed mid-write leaves `.tmp.*`; reap those older than this. */
40
+ export const TMP_MAX_AGE_MS = 10 * 60_000
41
+ /** The startup drain never loops more times than a full spool needs. */
42
+ export const STARTUP_MAX_PASSES = Math.ceil(2_000 / SPOOL_BATCH) + 1
43
+
44
+ export interface SpoolIngesterOptions {
45
+ dir: string
46
+ ledger: SessionHookLedger
47
+ /** Apply a parsed envelope to the store. Absent when the feature is off. */
48
+ apply?: (env: HookEnvelope, child: boolean) => void
49
+ /** Classify the envelope's ppid at INGEST time, while the pid is still alive. */
50
+ isChild?: (env: HookEnvelope) => boolean
51
+ /** Keys already in the ledger at boot, so a replayed file is not applied twice. */
52
+ seenKeys?: Set<string>
53
+ sweepMs?: number
54
+ batch?: number
55
+ now?: () => number
56
+ }
57
+
58
+ export interface SpoolStats {
59
+ ingested: number
60
+ rejected: number
61
+ salvaged: number
62
+ duplicates: number
63
+ /** Files a sweep visited and could not remove (ledger refused, unreadable). */
64
+ stuck: number
65
+ /** Non-`.json` names in the spool that are not ours; they count against the script's cap. */
66
+ foreign: number
67
+ applyErrors: number
68
+ backlog: number
69
+ lastSweepAt: number | null
70
+ lastEventAt: number | null
71
+ lastError: string | null
72
+ }
73
+
74
+ export interface SpoolIngester {
75
+ sweep(): number
76
+ stop(): void
77
+ stats(): SpoolStats
78
+ }
79
+
80
+ export function startSpoolIngester(options: SpoolIngesterOptions): SpoolIngester {
81
+ const { dir, ledger } = options
82
+ const now = options.now ?? (() => Date.now())
83
+ const sweepMs = options.sweepMs ?? SPOOL_SWEEP_MS
84
+ const batch = options.batch ?? SPOOL_BATCH
85
+ const seen = options.seenKeys ?? new Set<string>()
86
+ const rejectedDir = join(dir, 'rejected')
87
+ const stats: SpoolStats = { ingested: 0, rejected: 0, salvaged: 0, duplicates: 0, stuck: 0, foreign: 0, applyErrors: 0, backlog: 0, lastSweepAt: null, lastEventAt: null, lastError: null }
88
+ let ticking = false
89
+ let stopped = false
90
+ let watcher: FSWatcher | null = null
91
+ let pruneCounter = 0
92
+ let lastStampAt = 0
93
+ let loggedStuck = false
94
+
95
+ try { mkdirSync(rejectedDir, { recursive: true, mode: 0o700 }) } catch { /* the sweep reports it */ }
96
+
97
+ // The stamp is a metadata change inside the watched directory, so it would wake the
98
+ // watcher, which would sweep, which would stamp: touched at most once per sweep interval.
99
+ const stamp = () => {
100
+ const t = now()
101
+ if (t - lastStampAt < sweepMs) return
102
+ lastStampAt = t
103
+ const path = join(dir, LAST_DRAIN_STAMP)
104
+ try {
105
+ if (existsSync(path)) { const d = new Date(t); utimesSync(path, d, d) } else writeFileSync(path, '', { mode: 0o600 })
106
+ } catch { /* a missing stamp only makes the script cautious */ }
107
+ }
108
+
109
+ /** Spool order: the millisecond stamp, then the pid, both as numbers (a pid wrap must not reorder). */
110
+ const spoolOrder = (a: string, b: string): number => {
111
+ const ma = SPOOL_FILE_RE.exec(a)!, mb = SPOOL_FILE_RE.exec(b)!
112
+ return (Number(ma[1]) - Number(mb[1])) || (Number(ma[2]) - Number(mb[2])) || a.localeCompare(b)
113
+ }
114
+
115
+ const listSpool = (): string[] => {
116
+ let names: string[]
117
+ try { names = readdirSync(dir) } catch (error) {
118
+ stats.lastError = errorCode(error)
119
+ return []
120
+ }
121
+ // A `.json` whose name the script never writes (a partial rename) is moved aside so the
122
+ // count the script sees stays honest and `backlog` never hides it. Anything else that is
123
+ // not ours (a stray file someone dropped here) is left where it is and only counted.
124
+ let foreign = 0
125
+ for (const n of names) {
126
+ if (SPOOL_FILE_RE.test(n) || n === 'rejected' || n === LAST_DRAIN_STAMP) continue
127
+ if (n.startsWith('.tmp.')) {
128
+ try { if (now() - statSync(join(dir, n)).mtimeMs > TMP_MAX_AGE_MS) unlinkSync(join(dir, n)) } catch { /* fine */ }
129
+ continue
130
+ }
131
+ if (n.startsWith('.')) continue
132
+ if (!n.endsWith('.json')) { foreign++; continue }
133
+ try { renameSync(join(dir, n), join(rejectedDir, `${n}.unrecognized`)) ; stats.rejected++ } catch { /* fine */ }
134
+ }
135
+ stats.foreign = foreign
136
+ return names.filter(n => SPOOL_FILE_RE.test(n)).sort(spoolOrder)
137
+ }
138
+
139
+ const reject = (name: string, reason: string) => {
140
+ stats.rejected++
141
+ try { renameSync(join(dir, name), join(rejectedDir, `${name}.${reason}`)) } catch {
142
+ try { unlinkSync(join(dir, name)) } catch { /* gone already */ }
143
+ }
144
+ }
145
+
146
+ /** True when the file is gone afterwards (ingested, rejected or deduped); false when it stays. */
147
+ const ingestOne = (name: string): boolean => {
148
+ const path = join(dir, name)
149
+ let text: string
150
+ try { text = readFileSync(path, 'utf-8') } catch {
151
+ // Raced a rename, or unreadable: the next sweep sees it or not; it is not progress.
152
+ return !existsSync(path)
153
+ }
154
+ let parsed: ReturnType<typeof parseHookEnvelope>
155
+ try { parsed = parseHookEnvelope(text) } catch (error) {
156
+ stats.lastError = errorCode(error)
157
+ reject(name, 'parse_threw')
158
+ return true
159
+ }
160
+ let env: HookEnvelope | null = null
161
+ if (parsed.ok) {
162
+ env = parsed.envelope
163
+ } else if (parsed.salvaged) {
164
+ // A cut Stop still turns the row idle; nothing else in the file is trusted.
165
+ env = { ts: parsed.salvaged.ts, ppid: null, event: parsed.salvaged.event, sessionId: parsed.salvaged.sessionId, payload: { session_id: parsed.salvaged.sessionId } }
166
+ stats.salvaged++
167
+ }
168
+ if (!env) { reject(name, parsed.ok ? 'unknown' : parsed.reason); return true }
169
+ if (seen.has(name)) {
170
+ stats.duplicates++
171
+ try { unlinkSync(path) } catch { /* fine */ }
172
+ return true
173
+ }
174
+ // Classified NOW, while the child pid is alive; the ledger row remembers the verdict so a
175
+ // replay after a restart (when the spawn ledger is empty) applies the same rule.
176
+ let child = false
177
+ try { child = options.isChild?.(env) ?? false } catch { child = false }
178
+ let appended = false
179
+ try { appended = ledger.append(name, env, child) } catch (error) { stats.lastError = errorCode(error) }
180
+ if (!appended) {
181
+ // Keep the file: the ledger is the durable record and it refused. Health shows why.
182
+ stats.lastError = ledger.stats().lastError ?? stats.lastError
183
+ return false
184
+ }
185
+ seen.add(name)
186
+ try { options.apply?.(env, child) } catch (error) {
187
+ stats.applyErrors++
188
+ console.error(`[hook-spool] apply failed for ${name}: ${error instanceof Error ? error.message : error}`)
189
+ }
190
+ stats.ingested++
191
+ stats.lastEventAt = env.ts
192
+ try { unlinkSync(path) } catch { /* a re-read is deduped by `seen` */ }
193
+ return true
194
+ }
195
+
196
+ const pruneRejected = () => {
197
+ let names: string[]
198
+ try { names = readdirSync(rejectedDir).sort() } catch { return }
199
+ const cutoff = now() - REJECTED_MAX_AGE_MS
200
+ const excess = Math.max(0, names.length - REJECTED_KEEP)
201
+ names.forEach((n, index) => {
202
+ const path = join(rejectedDir, n)
203
+ let old = false
204
+ try { old = statSync(path).mtimeMs < cutoff } catch { return }
205
+ if (index < excess || old) { try { unlinkSync(path) } catch { /* fine */ } }
206
+ })
207
+ }
208
+
209
+ /** Returns the number of files REMOVED this pass; a stuck file is not progress. */
210
+ const sweep = (): number => {
211
+ if (ticking || stopped) return 0
212
+ ticking = true
213
+ let removed = 0
214
+ let stuck = 0
215
+ try {
216
+ const names = listSpool()
217
+ for (const name of names.slice(0, batch)) {
218
+ if (ingestOne(name)) removed++
219
+ else stuck++
220
+ }
221
+ stats.stuck = stuck
222
+ stats.backlog = Math.max(0, names.length - removed)
223
+ stats.lastSweepAt = now()
224
+ if (stuck > 0 && !loggedStuck) {
225
+ loggedStuck = true
226
+ console.error(`[hook-spool] ${stuck} file(s) could not be drained (${stats.lastError ?? 'unknown'}); they stay in ${dir}`)
227
+ }
228
+ if (stuck === 0) loggedStuck = false
229
+ stamp()
230
+ if (++pruneCounter % 30 === 0) pruneRejected()
231
+ } finally {
232
+ ticking = false
233
+ }
234
+ return removed
235
+ }
236
+
237
+ // Startup drain: the backlog, in bounded passes, before anyone asks. Bounded twice: a pass
238
+ // that removed nothing ends it (a refusing ledger must never spin the boot), and no
239
+ // backlog needs more passes than a full spool.
240
+ for (let pass = 0; pass < STARTUP_MAX_PASSES && !stopped; pass++) {
241
+ if (sweep() === 0) break
242
+ }
243
+
244
+ const timer = setInterval(sweep, sweepMs)
245
+ timer.unref()
246
+ try {
247
+ // Only a spool file is a wake-up. The stamp and the rejected dir change metadata here
248
+ // too, and reacting to them would be a self-sustaining loop.
249
+ watcher = watch(dir, { persistent: false }, (_event, filename) => {
250
+ if (typeof filename === 'string' && SPOOL_FILE_RE.test(filename)) sweep()
251
+ })
252
+ watcher.on('error', () => { /* the interval is the guaranteed path */ })
253
+ } catch { watcher = null }
254
+
255
+ return {
256
+ sweep,
257
+ stop() {
258
+ stopped = true
259
+ clearInterval(timer)
260
+ try { watcher?.close() } catch { /* fine */ }
261
+ },
262
+ stats: () => ({ ...stats }),
263
+ }
264
+ }