@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,338 @@
1
+ // The composition root for session hooks: the one signal store, the ledger, the spool
2
+ // ingester, and the per-session facts the routes read.
3
+ //
4
+ // `COS_SESSION_HOOKS` (default: on whenever `COS_CLAUDE_SESSIONS_ENABLED=1`) gates the
5
+ // APPLY, never the drain: with the flag off the spool is still ledgered, unlinked and
6
+ // stamped, and nothing reaches a row. Control's env allowlist carries the flag across
7
+ // Update Server (main.swift `providerEnvironmentKeys`).
8
+
9
+ import { execFileSync } from 'node:child_process'
10
+ import { mkdirSync, readFileSync, readdirSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+ import { cosSpawnedPids } from './agent-session-ownership-store.js'
13
+ import { REGISTRY_FILENAME, claudeSessionsDir } from './claude-session-registry.js'
14
+ import { MAX_REGISTRY_FILES } from './thread-occupancy.js'
15
+ import { dataPath } from './data-dir.js'
16
+ import { SessionHookLedger } from './session-hook-ledger.js'
17
+ import { startSpoolIngester, type SpoolIngester, type SpoolStats } from './session-hook-spool.js'
18
+ import { SessionSignalStore, type SessionSignal } from './session-signal-store.js'
19
+ import { deriveSessionState, type DerivedSessionState, type RegistryFacts, type TranscriptFacts } from './session-state-derive.js'
20
+ import { ensureHookRuntimeFiles, ensureStableHookScript, hookSpoolDir, hookStatus, type HookStatus } from './claude-hooks-installer.js'
21
+
22
+ export function sessionHooksEnabled(): boolean {
23
+ const raw = process.env.COS_SESSION_HOOKS
24
+ if (raw === '0' || raw === 'false' || raw === 'off') return false
25
+ if (raw === '1' || raw === 'true' || raw === 'on') return true
26
+ return process.env.COS_CLAUDE_SESSIONS_ENABLED === '1'
27
+ }
28
+
29
+ export function spoolDir(): string {
30
+ return hookSpoolDir()
31
+ }
32
+
33
+ export function deskIdleSeconds(): number {
34
+ const raw = Number(process.env.COS_PERMISSION_BROKER_DESK_IDLE_S)
35
+ return Number.isFinite(raw) && raw >= 0 ? Math.trunc(raw) : 90
36
+ }
37
+
38
+ /** Replay this much ledger history into the store at boot. */
39
+ export const LEDGER_REPLAY_WINDOW_MS = 6 * 60 * 60_000
40
+
41
+ // A hook's ppid is the claude process itself: macOS `sh -c '<script> <Event>'` execs the
42
+ // single command, and every recorded session (fixtures, 2026-09-15) shows one ppid across all
43
+ // of its events. The `ps` parent lookup below is the fallback for a shell that does not exec;
44
+ // it runs once per pid and is remembered for a minute.
45
+ const parentCache = new Map<number, { parent: number | null; at: number }>()
46
+ const PARENT_CACHE_MS = 60_000
47
+
48
+ function parentPid(pid: number): number | null {
49
+ const cached = parentCache.get(pid)
50
+ if (cached && Date.now() - cached.at < PARENT_CACHE_MS) return cached.parent
51
+ let parent: number | null = null
52
+ try {
53
+ const out = execFileSync('/bin/ps', ['-o', 'ppid=', '-p', String(pid)], { encoding: 'utf-8', timeout: 1_000 })
54
+ const n = Number(out.trim())
55
+ parent = Number.isInteger(n) && n > 0 ? n : null
56
+ } catch { parent = null }
57
+ if (parentCache.size > 512) parentCache.clear()
58
+ parentCache.set(pid, { parent, at: Date.now() })
59
+ return parent
60
+ }
61
+
62
+ // A child's synchronous SessionEnd is spooled ~150 ms before the child exits, and the spawn
63
+ // ledger forgets the pid the moment it does; the sweep that reads the file can run after.
64
+ // So every pid the ledger ever vouched for is remembered here for a grace window.
65
+ const recentCosPids = new Map<number, number>()
66
+ export const COS_PID_TOMBSTONE_MS = 60_000
67
+
68
+ export function isCosSpawnedPid(pid: number | null, nowMs = Date.now()): boolean {
69
+ if (pid === null) return false
70
+ const spawned = cosSpawnedPids()
71
+ for (const p of spawned.keys()) recentCosPids.set(p, nowMs)
72
+ if (recentCosPids.size > 256) {
73
+ for (const [p, at] of recentCosPids) if (nowMs - at > COS_PID_TOMBSTONE_MS) recentCosPids.delete(p)
74
+ }
75
+ const remembered = (p: number) => { const at = recentCosPids.get(p); return at !== undefined && nowMs - at <= COS_PID_TOMBSTONE_MS }
76
+ if (spawned.has(pid) || remembered(pid)) return true
77
+ if (spawned.size === 0 && recentCosPids.size === 0) return false
78
+ const parent = parentPid(pid)
79
+ return parent !== null && (spawned.has(parent) || remembered(parent))
80
+ }
81
+
82
+ export const sessionSignalStore = new SessionSignalStore({ isCosSpawnedPid })
83
+
84
+ // 6.48.1: which kind of process a session is, read off its registry record while it is alive.
85
+ // `claude -p` registers as `entrypoint: sdk-cli`; a Desktop tab as `claude-desktop`; a terminal
86
+ // as `cli`. The record may land a beat after the SessionStart hook, so the read is retried on
87
+ // the session's next few events (a synchronous, bounded registry scan each) and then given
88
+ // up. Once known it rides the LEDGER ROW (QA, 2026-09-15): a replay after a restart restores
89
+ // it, so a tab that ended inside the replay window is not listed as a print run for want of
90
+ // a registry record that the reaper has since removed.
91
+ const ENTRYPOINT_ATTEMPTS = 6
92
+ const entrypointAttempts = new Map<string, number>()
93
+
94
+ /** The entrypoint to write on this event's ledger row: the store's, else one registry read while attempts remain. */
95
+ function entrypointFor(sessionId: string, child: boolean): string | null {
96
+ const known = sessionSignalStore.get(sessionId)?.entrypoint ?? null
97
+ if (known || child) return known
98
+ const tried = entrypointAttempts.get(sessionId) ?? 0
99
+ if (tried >= ENTRYPOINT_ATTEMPTS) return null
100
+ entrypointAttempts.set(sessionId, tried + 1)
101
+ if (entrypointAttempts.size > 512) entrypointAttempts.clear()
102
+ return registryEntrypointSync(sessionId)
103
+ }
104
+
105
+ /** One synchronous pass over `~/.claude/sessions/<pid>.json`; null when no record names the session. */
106
+ export function registryEntrypointSync(sessionId: string, dir = claudeSessionsDir()): string | null {
107
+ return registryRecordSync(sessionId, dir)?.entrypoint ?? null
108
+ }
109
+
110
+ export interface RegistryRecordFacts {
111
+ pid: number | null
112
+ entrypoint: string | null
113
+ status: string | null
114
+ statusUpdatedAt: number | null
115
+ }
116
+
117
+ /**
118
+ * The registry record naming a session, read synchronously and bounded; null when none
119
+ * does. A COS Continue child (`claude -p --resume <id>`) registers under the SAME session
120
+ * id as the tab it resumes, so when two records name the session the one whose pid COS
121
+ * did not spawn is the tab's and wins; the child's is used only when it is alone.
122
+ */
123
+ export function registryRecordSync(sessionId: string, dir = claudeSessionsDir()): RegistryRecordFacts | null {
124
+ let names: string[]
125
+ try { names = readdirSync(dir).filter(n => REGISTRY_FILENAME.test(n)).slice(0, MAX_REGISTRY_FILES) } catch { return null }
126
+ const wanted = sessionId.toLowerCase()
127
+ let childRecord: RegistryRecordFacts | null = null
128
+ for (const name of names) {
129
+ try {
130
+ const raw = JSON.parse(readFileSync(join(dir, name), 'utf-8')) as Record<string, unknown>
131
+ if (typeof raw.sessionId !== 'string' || raw.sessionId.toLowerCase() !== wanted) continue
132
+ const record: RegistryRecordFacts = {
133
+ pid: typeof raw.pid === 'number' ? raw.pid : null,
134
+ entrypoint: typeof raw.entrypoint === 'string' && raw.entrypoint ? raw.entrypoint : null,
135
+ status: typeof raw.status === 'string' ? raw.status : null,
136
+ statusUpdatedAt: typeof raw.statusUpdatedAt === 'number' ? raw.statusUpdatedAt : null,
137
+ }
138
+ if (isCosSpawnedPid(record.pid)) { childRecord ??= record; continue }
139
+ return record
140
+ } catch { /* a torn write; the next read retries */ }
141
+ }
142
+ return childRecord
143
+ }
144
+
145
+ /**
146
+ * THE ENGINE'S OWN END OF TURN (6.48.1, QA round). Claude Code does not end a turn at
147
+ * the `end_turn` row or at the Stop hook: it ends it when every Stop hook has RETURNED,
148
+ * and only then writes `stop_hook_summary`, dequeues anything typed meanwhile, and flips
149
+ * the registry to `idle`. On the release Mac the COS repo's own Stop hooks run 12-38 s
150
+ * (`transcript_summary_hook.py`), so "Stop + 2 s" was 13-38 s early: a prompt typed at
151
+ * the desk in that window is QUEUED and dequeues after the hooks, and a follow-up
152
+ * delivered at Stop + 2 s would have been a second writer. The registry's `idle` with
153
+ * `statusUpdatedAt` at or after the Stop is the only signal that says the hooks are done
154
+ * (measured: it flips 4-39 ms after the summary row).
155
+ *
156
+ * True only when a live record names the session AND says idle AND moved at or after
157
+ * `stopAt`. No record (a print run that already exited) is answered by the caller.
158
+ */
159
+ export function registryIdleAfterStop(sessionId: string, stopAt: number, dir = claudeSessionsDir()): boolean | null {
160
+ const record = registryRecordSync(sessionId, dir)
161
+ if (!record) return null
162
+ return record.status === 'idle' && typeof record.statusUpdatedAt === 'number' && record.statusUpdatedAt >= stopAt
163
+ }
164
+
165
+ let ledger: SessionHookLedger | null = null
166
+ let ingester: SpoolIngester | null = null
167
+ let replayed: { rows: number; applied: number } | null = null
168
+ /** The two-scan memory: consecutive dead observations and when the first one was. */
169
+ const deadById = new Map<string, { scans: number; since: number | null; at: number }>()
170
+ const DEAD_MEMORY_MS = 60 * 60_000
171
+
172
+ export interface SessionHooksRuntime {
173
+ store: SessionSignalStore
174
+ stop(): void
175
+ }
176
+
177
+ export function startSessionHooksRuntime(options: { port: number }): SessionHooksRuntime {
178
+ const dir = spoolDir()
179
+ try { mkdirSync(dir, { recursive: true, mode: 0o700 }) } catch { /* the ingester reports it */ }
180
+ ledger = new SessionHookLedger(dataPath('session-hook-events.jsonl'))
181
+ const enabled = sessionHooksEnabled()
182
+ const since = Date.now() - LEDGER_REPLAY_WINDOW_MS
183
+ const replay = ledger.replay(since, (env, _key, child, entrypoint) => {
184
+ if (!enabled) return
185
+ sessionSignalStore.apply(env, child)
186
+ if (entrypoint) sessionSignalStore.setEntrypoint(env.sessionId, entrypoint)
187
+ })
188
+ replayed = { rows: replay.rows, applied: replay.applied }
189
+ ingester = startSpoolIngester({
190
+ dir,
191
+ ledger,
192
+ seenKeys: replay.keys,
193
+ isChild: env => isCosSpawnedPid(env.ppid),
194
+ entrypointOf: enabled ? (env, child) => entrypointFor(env.sessionId, child) : undefined,
195
+ apply: enabled ? (env, child, entrypoint) => {
196
+ sessionSignalStore.apply(env, child)
197
+ if (entrypoint) sessionSignalStore.setEntrypoint(env.sessionId, entrypoint)
198
+ } : undefined,
199
+ })
200
+ // Runtime files the script reads. The port can change per install; the token never does.
201
+ // The script is copied only when MISSING here: a boot must never downgrade what a newer
202
+ // `--hooks install` put at the stable path.
203
+ try {
204
+ ensureHookRuntimeFiles(options.port, deskIdleSeconds())
205
+ ensureStableHookScript(undefined, undefined, true)
206
+ } catch (error) {
207
+ console.error(`[session-hooks] runtime files: ${error instanceof Error ? error.message : error}`)
208
+ }
209
+ const pruneTimer = setInterval(() => { sessionSignalStore.prune() }, 10 * 60_000)
210
+ pruneTimer.unref()
211
+ return {
212
+ store: sessionSignalStore,
213
+ stop() {
214
+ clearInterval(pruneTimer)
215
+ ingester?.stop()
216
+ },
217
+ }
218
+ }
219
+
220
+ /** 6.48.1: the drain kick's counters, registered by the composition root when thread attach is on. */
221
+ type DrainKickStats = { passes: number; kicks: number; folded: number; inFlight: boolean; waiting: number }
222
+ let drainKickStats: (() => DrainKickStats) | null = null
223
+ export function registerDrainKickStats(read: () => DrainKickStats): void {
224
+ drainKickStats = read
225
+ }
226
+
227
+ export interface SessionHooksHealth {
228
+ enabled: boolean
229
+ /** The Stop-driven drain (6.48.1): null when thread attach is off. */
230
+ drain: DrainKickStats | null
231
+ /** The install state word; `installed` below is its boolean. */
232
+ state: HookStatus['state']
233
+ installed: boolean
234
+ scriptSha: string | null
235
+ tokenPresent: boolean
236
+ lastEventAt: string | null
237
+ spoolBacklog: number
238
+ spoolStuck: number
239
+ applyErrors: number
240
+ ledgerBytes: number
241
+ /** Error CODES only (ENOSPC, EACCES); a message would carry the home path onto public health. */
242
+ ledgerError: string | null
243
+ spoolError: string | null
244
+ signals: number
245
+ replayed: { rows: number; applied: number } | null
246
+ spool: SpoolStats | null
247
+ }
248
+
249
+ // Health is polled by three clients every few seconds; the status read (settings parse,
250
+ // two hashes) is cheap but not free, and it cannot change faster than this.
251
+ let statusCache: { at: number; value: HookStatus } | null = null
252
+ const STATUS_CACHE_MS = 5_000
253
+
254
+ export function cachedHookStatus(nowMs = Date.now()): HookStatus {
255
+ if (statusCache && nowMs - statusCache.at < STATUS_CACHE_MS) return statusCache.value
256
+ const value = hookStatus()
257
+ statusCache = { at: nowMs, value }
258
+ return value
259
+ }
260
+
261
+ export function invalidateHookStatus(): void {
262
+ statusCache = null
263
+ }
264
+
265
+ export function sessionHooksHealthFields(): { sessionHooks: SessionHooksHealth } {
266
+ const status = cachedHookStatus()
267
+ const spool = ingester?.stats() ?? null
268
+ const ledgerStats = ledger?.stats() ?? null
269
+ const newest = sessionSignalStore.newestEventAt()
270
+ return {
271
+ sessionHooks: {
272
+ enabled: sessionHooksEnabled(),
273
+ drain: drainKickStats ? drainKickStats() : null,
274
+ state: status.state,
275
+ installed: status.installed,
276
+ scriptSha: status.scriptSha,
277
+ tokenPresent: status.tokenPresent,
278
+ lastEventAt: newest ? new Date(newest).toISOString() : null,
279
+ spoolBacklog: spool?.backlog ?? 0,
280
+ spoolStuck: spool?.stuck ?? 0,
281
+ applyErrors: spool?.applyErrors ?? 0,
282
+ ledgerBytes: ledgerStats?.bytes ?? 0,
283
+ ledgerError: ledgerStats?.lastError ?? null,
284
+ spoolError: spool?.lastError ?? null,
285
+ signals: sessionSignalStore.size(),
286
+ replayed,
287
+ spool,
288
+ },
289
+ }
290
+ }
291
+
292
+ /** Signal for a row id that may be the registry's eight-character form. */
293
+ export function signalFor(sessionId: string): SessionSignal | undefined {
294
+ if (!sessionHooksEnabled()) return undefined
295
+ return sessionId.length >= 36 ? sessionSignalStore.get(sessionId) : sessionSignalStore.getByPrefix(sessionId)
296
+ }
297
+
298
+ /**
299
+ * Derive with the two-scan memory kept here, keyed by full id when known. Rows the
300
+ * caller has no facts for at all get nothing, so an older payload stays byte-identical.
301
+ */
302
+ export function deriveForRow(input: { sessionId: string; registry?: RegistryFacts; transcript?: TranscriptFacts; now?: number; remember?: boolean; attachedTurn?: boolean }): DerivedSessionState | undefined {
303
+ // Off means off: with the feature disabled every row is byte-identical to 6.47.0.
304
+ if (!sessionHooksEnabled()) return undefined
305
+ const signal = signalFor(input.sessionId)
306
+ if (!signal && !input.registry && !input.transcript && !input.attachedTurn) return undefined
307
+ const now = input.now ?? Date.now()
308
+ const key = signal?.sessionId ?? input.sessionId.toLowerCase()
309
+ const prev = deadById.get(key)
310
+ const derived = deriveSessionState({
311
+ signal,
312
+ registry: input.registry,
313
+ transcript: input.transcript,
314
+ now,
315
+ prevDeadScans: prev?.scans ?? 0,
316
+ prevDeadSince: prev?.since ?? null,
317
+ attachedTurn: input.attachedTurn === true,
318
+ })
319
+ // A reader with no registry facts (the live feed derives from the signal alone) must
320
+ // not touch the two-scan memory the row readers keep: a derive that saw no registry
321
+ // would reset a dead pid's count between two list requests.
322
+ if (input.remember === false || !input.registry) return derived
323
+ if (deadById.size > 2_048) {
324
+ for (const [k, v] of deadById) if (now - v.at > DEAD_MEMORY_MS) deadById.delete(k)
325
+ }
326
+ deadById.set(key, { scans: derived.deadScans, since: derived.deadSince, at: now })
327
+ return derived
328
+ }
329
+
330
+ export function __resetSessionHooksForTests(): void {
331
+ sessionSignalStore.__resetForTests()
332
+ deadById.clear()
333
+ parentCache.clear()
334
+ recentCosPids.clear()
335
+ entrypointAttempts.clear()
336
+ drainKickStats = null
337
+ statusCache = null
338
+ }