@gotcos/glasses-server 6.47.0 → 6.48.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,205 @@
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
+ // A sub-agent's tool events carry these (docs; unrecorded as of 2026-09-15). The
186
+ // store reads `agent_id` to keep them from reopening the main turn, so the ledger
187
+ // must keep it or a replay reopens what the live path did not.
188
+ ...(str('agent_id') ? { agent_id: str('agent_id') } : {}),
189
+ ...(str('agent_type') ? { agent_type: str('agent_type') } : {}),
190
+ ...(event === 'PermissionRequest' && Array.isArray(payload.permission_suggestions)
191
+ ? { permission_suggestions: payload.permission_suggestions.slice(0, 4) }
192
+ : {}),
193
+ }
194
+ }
195
+ case 'Notification':
196
+ return { ...base, notification_type: str('notification_type'), message: clipText(payload.message, 160) }
197
+ case 'SubagentStart':
198
+ case 'SubagentStop':
199
+ return { ...base, agent_id: str('agent_id'), agent_type: str('agent_type') }
200
+ case 'PostModelSwitch':
201
+ return { ...base, from_model: str('from_model'), to_model: str('to_model') }
202
+ case 'PostCompact':
203
+ return base
204
+ }
205
+ }
@@ -0,0 +1,133 @@
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
+ /** The registry's `entrypoint` for the session, once read (6.48.1); a replay restores it. */
24
+ entrypoint?: string
25
+ payload: Record<string, unknown>
26
+ }
27
+
28
+ function codeOf(error: unknown): string {
29
+ const code = (error as { code?: unknown })?.code
30
+ return typeof code === 'string' && code ? code : (error instanceof Error ? error.name || 'Error' : 'error')
31
+ }
32
+
33
+ function readText(path: string): string {
34
+ if (!existsSync(path)) return ''
35
+ try { return readFileSync(path, 'utf-8') } catch { return '' }
36
+ }
37
+
38
+ function oldestTs(text: string): number | null {
39
+ for (const line of text.split('\n')) {
40
+ if (!line.trim()) continue
41
+ try {
42
+ const ts = (JSON.parse(line) as { ts?: unknown }).ts
43
+ if (typeof ts === 'number') return ts
44
+ } catch { continue }
45
+ }
46
+ return null
47
+ }
48
+
49
+ export class SessionHookLedger {
50
+ readonly path: string
51
+ private lastError: string | null = null
52
+ private appended = 0
53
+
54
+ constructor(path: string) {
55
+ this.path = path
56
+ }
57
+
58
+ /** Append one row. Returns false (and remembers why) when the disk refused. */
59
+ append(key: string, env: HookEnvelope, child = false, entrypoint: string | null = null): boolean {
60
+ let row: LedgerRow
61
+ try {
62
+ row = {
63
+ key,
64
+ ts: env.ts,
65
+ ppid: env.ppid,
66
+ event: env.event,
67
+ session_id: env.sessionId,
68
+ ...(child ? { child: true } : {}),
69
+ ...(entrypoint ? { entrypoint } : {}),
70
+ payload: projectHookPayload(env.event, env.payload),
71
+ }
72
+ } catch (error) {
73
+ // A pathological payload must not escape into the sweep; the caller rejects the file.
74
+ this.lastError = codeOf(error)
75
+ return false
76
+ }
77
+ try {
78
+ this.rotateIfNeeded()
79
+ appendFileSync(this.path, JSON.stringify(row) + '\n', { mode: 0o600 })
80
+ this.appended++
81
+ this.lastError = null
82
+ return true
83
+ } catch (error) {
84
+ this.lastError = codeOf(error)
85
+ return false
86
+ }
87
+ }
88
+
89
+ private rotateIfNeeded(): void {
90
+ let size = 0
91
+ try { size = statSync(this.path).size } catch { return }
92
+ if (size < LEDGER_ROTATE_BYTES) return
93
+ try { renameSync(this.path, this.path + '.1') } catch { /* the next append tries again */ }
94
+ }
95
+
96
+ /**
97
+ * Replay rows newer than `sinceMs`. The rotated `.1` is read too whenever the current
98
+ * file does not reach back to `sinceMs` (a rotation inside the window would otherwise
99
+ * hide the rows that matter most, and forget their keys). Malformed lines are skipped;
100
+ * a boot must never fail on a torn last line.
101
+ */
102
+ replay(sinceMs: number, apply: (env: HookEnvelope, key: string, child: boolean, entrypoint: string | null) => void): { rows: number; applied: number; keys: Set<string> } {
103
+ const keys = new Set<string>()
104
+ let rows = 0
105
+ let applied = 0
106
+ const current = readText(this.path)
107
+ const oldest = oldestTs(current)
108
+ const texts = oldest !== null && oldest > sinceMs ? [readText(this.path + '.1'), current] : [current]
109
+ for (const line of texts.join('\n').split('\n')) {
110
+ if (!line.trim()) continue
111
+ let row: unknown
112
+ try { row = JSON.parse(line) } catch { continue }
113
+ if (!row || typeof row !== 'object') continue
114
+ const r = row as Partial<LedgerRow>
115
+ rows++
116
+ if (typeof r.key !== 'string' || typeof r.ts !== 'number' || !isHookEventName(r.event)) continue
117
+ if (typeof r.session_id !== 'string' || !SESSION_ID_RE.test(r.session_id)) continue
118
+ keys.add(r.key)
119
+ if (r.ts < sinceMs) continue
120
+ const payload = r.payload && typeof r.payload === 'object' ? r.payload as Record<string, unknown> : {}
121
+ const entrypoint = typeof r.entrypoint === 'string' && r.entrypoint ? r.entrypoint : null
122
+ 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, entrypoint)
123
+ applied++
124
+ }
125
+ return { rows, applied, keys }
126
+ }
127
+
128
+ stats(): { bytes: number; appended: number; lastError: string | null } {
129
+ let bytes = 0
130
+ try { bytes = statSync(this.path).size } catch { /* none yet */ }
131
+ return { bytes, appended: this.appended, lastError: this.lastError }
132
+ }
133
+ }
@@ -0,0 +1,272 @@
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, entrypoint: string | null) => void
49
+ /** Classify the envelope's ppid at INGEST time, while the pid is still alive. */
50
+ isChild?: (env: HookEnvelope) => boolean
51
+ /**
52
+ * The session's registry `entrypoint`, read at INGEST time while the record is alive
53
+ * (6.48.1). Written onto the ledger row so a replay after a restart knows a print run
54
+ * from a tab without a registry that has since been reaped.
55
+ */
56
+ entrypointOf?: (env: HookEnvelope, child: boolean) => string | null
57
+ /** Keys already in the ledger at boot, so a replayed file is not applied twice. */
58
+ seenKeys?: Set<string>
59
+ sweepMs?: number
60
+ batch?: number
61
+ now?: () => number
62
+ }
63
+
64
+ export interface SpoolStats {
65
+ ingested: number
66
+ rejected: number
67
+ salvaged: number
68
+ duplicates: number
69
+ /** Files a sweep visited and could not remove (ledger refused, unreadable). */
70
+ stuck: number
71
+ /** Non-`.json` names in the spool that are not ours; they count against the script's cap. */
72
+ foreign: number
73
+ applyErrors: number
74
+ backlog: number
75
+ lastSweepAt: number | null
76
+ lastEventAt: number | null
77
+ lastError: string | null
78
+ }
79
+
80
+ export interface SpoolIngester {
81
+ sweep(): number
82
+ stop(): void
83
+ stats(): SpoolStats
84
+ }
85
+
86
+ export function startSpoolIngester(options: SpoolIngesterOptions): SpoolIngester {
87
+ const { dir, ledger } = options
88
+ const now = options.now ?? (() => Date.now())
89
+ const sweepMs = options.sweepMs ?? SPOOL_SWEEP_MS
90
+ const batch = options.batch ?? SPOOL_BATCH
91
+ const seen = options.seenKeys ?? new Set<string>()
92
+ const rejectedDir = join(dir, 'rejected')
93
+ 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 }
94
+ let ticking = false
95
+ let stopped = false
96
+ let watcher: FSWatcher | null = null
97
+ let pruneCounter = 0
98
+ let lastStampAt = 0
99
+ let loggedStuck = false
100
+
101
+ try { mkdirSync(rejectedDir, { recursive: true, mode: 0o700 }) } catch { /* the sweep reports it */ }
102
+
103
+ // The stamp is a metadata change inside the watched directory, so it would wake the
104
+ // watcher, which would sweep, which would stamp: touched at most once per sweep interval.
105
+ const stamp = () => {
106
+ const t = now()
107
+ if (t - lastStampAt < sweepMs) return
108
+ lastStampAt = t
109
+ const path = join(dir, LAST_DRAIN_STAMP)
110
+ try {
111
+ if (existsSync(path)) { const d = new Date(t); utimesSync(path, d, d) } else writeFileSync(path, '', { mode: 0o600 })
112
+ } catch { /* a missing stamp only makes the script cautious */ }
113
+ }
114
+
115
+ /** Spool order: the millisecond stamp, then the pid, both as numbers (a pid wrap must not reorder). */
116
+ const spoolOrder = (a: string, b: string): number => {
117
+ const ma = SPOOL_FILE_RE.exec(a)!, mb = SPOOL_FILE_RE.exec(b)!
118
+ return (Number(ma[1]) - Number(mb[1])) || (Number(ma[2]) - Number(mb[2])) || a.localeCompare(b)
119
+ }
120
+
121
+ const listSpool = (): string[] => {
122
+ let names: string[]
123
+ try { names = readdirSync(dir) } catch (error) {
124
+ stats.lastError = errorCode(error)
125
+ return []
126
+ }
127
+ // A `.json` whose name the script never writes (a partial rename) is moved aside so the
128
+ // count the script sees stays honest and `backlog` never hides it. Anything else that is
129
+ // not ours (a stray file someone dropped here) is left where it is and only counted.
130
+ let foreign = 0
131
+ for (const n of names) {
132
+ if (SPOOL_FILE_RE.test(n) || n === 'rejected' || n === LAST_DRAIN_STAMP) continue
133
+ if (n.startsWith('.tmp.')) {
134
+ try { if (now() - statSync(join(dir, n)).mtimeMs > TMP_MAX_AGE_MS) unlinkSync(join(dir, n)) } catch { /* fine */ }
135
+ continue
136
+ }
137
+ if (n.startsWith('.')) continue
138
+ if (!n.endsWith('.json')) { foreign++; continue }
139
+ try { renameSync(join(dir, n), join(rejectedDir, `${n}.unrecognized`)) ; stats.rejected++ } catch { /* fine */ }
140
+ }
141
+ stats.foreign = foreign
142
+ return names.filter(n => SPOOL_FILE_RE.test(n)).sort(spoolOrder)
143
+ }
144
+
145
+ const reject = (name: string, reason: string) => {
146
+ stats.rejected++
147
+ try { renameSync(join(dir, name), join(rejectedDir, `${name}.${reason}`)) } catch {
148
+ try { unlinkSync(join(dir, name)) } catch { /* gone already */ }
149
+ }
150
+ }
151
+
152
+ /** True when the file is gone afterwards (ingested, rejected or deduped); false when it stays. */
153
+ const ingestOne = (name: string): boolean => {
154
+ const path = join(dir, name)
155
+ let text: string
156
+ try { text = readFileSync(path, 'utf-8') } catch {
157
+ // Raced a rename, or unreadable: the next sweep sees it or not; it is not progress.
158
+ return !existsSync(path)
159
+ }
160
+ let parsed: ReturnType<typeof parseHookEnvelope>
161
+ try { parsed = parseHookEnvelope(text) } catch (error) {
162
+ stats.lastError = errorCode(error)
163
+ reject(name, 'parse_threw')
164
+ return true
165
+ }
166
+ let env: HookEnvelope | null = null
167
+ if (parsed.ok) {
168
+ env = parsed.envelope
169
+ } else if (parsed.salvaged) {
170
+ // A cut Stop still turns the row idle; nothing else in the file is trusted.
171
+ env = { ts: parsed.salvaged.ts, ppid: null, event: parsed.salvaged.event, sessionId: parsed.salvaged.sessionId, payload: { session_id: parsed.salvaged.sessionId } }
172
+ stats.salvaged++
173
+ }
174
+ if (!env) { reject(name, parsed.ok ? 'unknown' : parsed.reason); return true }
175
+ if (seen.has(name)) {
176
+ stats.duplicates++
177
+ try { unlinkSync(path) } catch { /* fine */ }
178
+ return true
179
+ }
180
+ // Classified NOW, while the child pid is alive; the ledger row remembers the verdict so a
181
+ // replay after a restart (when the spawn ledger is empty) applies the same rule.
182
+ let child = false
183
+ try { child = options.isChild?.(env) ?? false } catch { child = false }
184
+ let entrypoint: string | null = null
185
+ try { entrypoint = options.entrypointOf?.(env, child) ?? null } catch { entrypoint = null }
186
+ let appended = false
187
+ try { appended = ledger.append(name, env, child, entrypoint) } catch (error) { stats.lastError = errorCode(error) }
188
+ if (!appended) {
189
+ // Keep the file: the ledger is the durable record and it refused. Health shows why.
190
+ stats.lastError = ledger.stats().lastError ?? stats.lastError
191
+ return false
192
+ }
193
+ seen.add(name)
194
+ try { options.apply?.(env, child, entrypoint) } catch (error) {
195
+ stats.applyErrors++
196
+ console.error(`[hook-spool] apply failed for ${name}: ${error instanceof Error ? error.message : error}`)
197
+ }
198
+ stats.ingested++
199
+ stats.lastEventAt = env.ts
200
+ try { unlinkSync(path) } catch { /* a re-read is deduped by `seen` */ }
201
+ return true
202
+ }
203
+
204
+ const pruneRejected = () => {
205
+ let names: string[]
206
+ try { names = readdirSync(rejectedDir).sort() } catch { return }
207
+ const cutoff = now() - REJECTED_MAX_AGE_MS
208
+ const excess = Math.max(0, names.length - REJECTED_KEEP)
209
+ names.forEach((n, index) => {
210
+ const path = join(rejectedDir, n)
211
+ let old = false
212
+ try { old = statSync(path).mtimeMs < cutoff } catch { return }
213
+ if (index < excess || old) { try { unlinkSync(path) } catch { /* fine */ } }
214
+ })
215
+ }
216
+
217
+ /** Returns the number of files REMOVED this pass; a stuck file is not progress. */
218
+ const sweep = (): number => {
219
+ if (ticking || stopped) return 0
220
+ ticking = true
221
+ let removed = 0
222
+ let stuck = 0
223
+ try {
224
+ const names = listSpool()
225
+ for (const name of names.slice(0, batch)) {
226
+ if (ingestOne(name)) removed++
227
+ else stuck++
228
+ }
229
+ stats.stuck = stuck
230
+ stats.backlog = Math.max(0, names.length - removed)
231
+ stats.lastSweepAt = now()
232
+ if (stuck > 0 && !loggedStuck) {
233
+ loggedStuck = true
234
+ console.error(`[hook-spool] ${stuck} file(s) could not be drained (${stats.lastError ?? 'unknown'}); they stay in ${dir}`)
235
+ }
236
+ if (stuck === 0) loggedStuck = false
237
+ stamp()
238
+ if (++pruneCounter % 30 === 0) pruneRejected()
239
+ } finally {
240
+ ticking = false
241
+ }
242
+ return removed
243
+ }
244
+
245
+ // Startup drain: the backlog, in bounded passes, before anyone asks. Bounded twice: a pass
246
+ // that removed nothing ends it (a refusing ledger must never spin the boot), and no
247
+ // backlog needs more passes than a full spool.
248
+ for (let pass = 0; pass < STARTUP_MAX_PASSES && !stopped; pass++) {
249
+ if (sweep() === 0) break
250
+ }
251
+
252
+ const timer = setInterval(sweep, sweepMs)
253
+ timer.unref()
254
+ try {
255
+ // Only a spool file is a wake-up. The stamp and the rejected dir change metadata here
256
+ // too, and reacting to them would be a self-sustaining loop.
257
+ watcher = watch(dir, { persistent: false }, (_event, filename) => {
258
+ if (typeof filename === 'string' && SPOOL_FILE_RE.test(filename)) sweep()
259
+ })
260
+ watcher.on('error', () => { /* the interval is the guaranteed path */ })
261
+ } catch { watcher = null }
262
+
263
+ return {
264
+ sweep,
265
+ stop() {
266
+ stopped = true
267
+ clearInterval(timer)
268
+ try { watcher?.close() } catch { /* fine */ }
269
+ },
270
+ stats: () => ({ ...stats }),
271
+ }
272
+ }