@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,403 @@
1
+ // Installing the COS session hook into `~/.claude/settings.json`, and knowing whether it
2
+ // is installed.
3
+ //
4
+ // USER LEVEL ONLY. Hooks in `~/.claude/settings.json` fire for every Claude Code session
5
+ // on this Mac (Desktop tabs, the CLI, `claude -p` jobs). The project-level file the COS
6
+ // repo owns (seven memory and canvas hooks) is never touched; Claude merges levels, so
7
+ // both sets run.
8
+ //
9
+ // THE MERGE IS A PURE FUNCTION over the settings object (`mergeHookSettings`), tested
10
+ // against the real file's shape: keep every block whose commands do not name our script,
11
+ // drop every block that does, append one canonical block per subscribed event. Other
12
+ // events and every other top-level key pass through untouched. Nothing is written when
13
+ // the merged object equals the current one.
14
+ //
15
+ // STABLE PATH. The script is copied from the package (`bin/hooks/cos-session-hook`) to
16
+ // `~/.cos-glasses/bin/cos-session-hook`, because the managed runtime's generation
17
+ // directory changes on every server update and a settings file pointing into it would
18
+ // break at the first Update Server. `status` reports `script_outdated` when the package
19
+ // copy differs from the installed one, and `install` re-copies.
20
+
21
+ import { copyFileSync, chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
22
+ import { createHash, randomBytes } from 'node:crypto'
23
+ import { homedir } from 'node:os'
24
+ import { dirname, join, resolve } from 'node:path'
25
+ import { fileURLToPath } from 'node:url'
26
+ import { atomicWriteFileSync } from './atomic-fs.js'
27
+
28
+ const __dirname = dirname(fileURLToPath(import.meta.url))
29
+
30
+ /** Substring that marks a hook block as ours, whatever the home directory is. */
31
+ export const HOOK_SCRIPT_MARKER = '/.cos-glasses/bin/cos-session-hook'
32
+ export const HOOK_SCRIPT_NAME = 'cos-session-hook'
33
+
34
+ export interface HookSubscription {
35
+ event: string
36
+ matcher?: string
37
+ async: boolean
38
+ timeout: number
39
+ }
40
+
41
+ /**
42
+ * Every event the reducer understands, with the timing each one needs. SessionStart,
43
+ * UserPromptSubmit, Stop and SessionEnd are synchronous: measured 2026-09-15, an async
44
+ * Stop was dropped when a print-mode process exited right after it (PostToolUse landed,
45
+ * Stop did not), and those four carry the transitions a row is derived from (a dropped
46
+ * UserPromptSubmit would leave a running turn reading idle). The write is a few
47
+ * milliseconds. PermissionRequest must be synchronous to return a decision; the rest
48
+ * are fire-and-forget.
49
+ */
50
+ export const HOOK_SUBSCRIPTIONS: readonly HookSubscription[] = [
51
+ { event: 'SessionStart', async: false, timeout: 5 },
52
+ { event: 'SessionEnd', async: false, timeout: 5 },
53
+ { event: 'UserPromptSubmit', async: false, timeout: 5 },
54
+ { event: 'Stop', async: false, timeout: 5 },
55
+ { event: 'StopFailure', async: true, timeout: 10 },
56
+ { event: 'PermissionRequest', async: false, timeout: 130 },
57
+ { event: 'PermissionDenied', async: true, timeout: 10 },
58
+ { event: 'PreToolUse', matcher: 'AskUserQuestion|ExitPlanMode', async: true, timeout: 10 },
59
+ { event: 'PostToolUse', async: true, timeout: 10 },
60
+ { event: 'PostToolUseFailure', async: true, timeout: 10 },
61
+ { event: 'Notification', matcher: 'permission_prompt|idle_prompt|elicitation_dialog|agent_needs_input', async: true, timeout: 10 },
62
+ { event: 'SubagentStart', async: true, timeout: 10 },
63
+ { event: 'SubagentStop', async: true, timeout: 10 },
64
+ { event: 'PostCompact', async: true, timeout: 10 },
65
+ { event: 'PostModelSwitch', async: true, timeout: 10 },
66
+ ]
67
+
68
+ /**
69
+ * Where the token, port and spool live. Derived from `COS_DATA_DIR` when that is set, so
70
+ * a scratch server booted with a temp data dir never touches the production files under
71
+ * `~/.cos-glasses` (it did once, in review). `COS_GLASSES_HOME` overrides for tests.
72
+ */
73
+ export function cosGlassesHome(): string {
74
+ if (process.env.COS_GLASSES_HOME) return resolve(process.env.COS_GLASSES_HOME)
75
+ if (process.env.COS_DATA_DIR) return dirname(resolve(process.env.COS_DATA_DIR))
76
+ return join(homedir(), '.cos-glasses')
77
+ }
78
+
79
+ /** The spool the server drains; baked into the installed command so the script agrees. */
80
+ export function hookSpoolDir(): string {
81
+ if (process.env.COS_SESSION_HOOKS_SPOOL_DIR) return resolve(process.env.COS_SESSION_HOOKS_SPOOL_DIR)
82
+ return join(process.env.COS_DATA_DIR ? resolve(process.env.COS_DATA_DIR) : join(cosGlassesHome(), 'data'), 'hook-spool')
83
+ }
84
+
85
+ export function claudeSettingsPath(): string {
86
+ const configDir = process.env.CLAUDE_CONFIG_DIR ? resolve(process.env.CLAUDE_CONFIG_DIR) : join(homedir(), '.claude')
87
+ return join(configDir, 'settings.json')
88
+ }
89
+
90
+ export function stableHookScriptPath(): string {
91
+ return join(cosGlassesHome(), 'bin', HOOK_SCRIPT_NAME)
92
+ }
93
+
94
+ /** The copy shipped in this package. */
95
+ export function packagedHookScriptPath(): string {
96
+ return resolve(__dirname, '..', '..', 'bin', 'hooks', HOOK_SCRIPT_NAME)
97
+ }
98
+
99
+ export function hookTokenPath(): string { return join(cosGlassesHome(), 'hook-token') }
100
+ export function hookPortPath(): string { return join(cosGlassesHome(), 'hook-port') }
101
+ export function hookDeskIdlePath(): string { return join(cosGlassesHome(), 'hook-desk-idle-s') }
102
+
103
+ /** sh-safe quoting for the settings command string. Paths with spaces get single quotes. */
104
+ export function shellQuote(value: string): string {
105
+ return /^[A-Za-z0-9_\-./]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`
106
+ }
107
+
108
+ /**
109
+ * The installed command carries the two paths the script needs as environment, so the
110
+ * server and the script can never disagree about where the spool or the token is.
111
+ */
112
+ export function hookCommand(scriptPath: string, event: string, paths: HookPaths = currentHookPaths()): string {
113
+ return `COS_GLASSES_HOME=${shellQuote(paths.home)} COS_HOOK_SPOOL=${shellQuote(paths.spoolDir)} ${shellQuote(scriptPath)} ${event}`
114
+ }
115
+
116
+ export interface HookPaths {
117
+ home: string
118
+ spoolDir: string
119
+ }
120
+
121
+ export function currentHookPaths(): HookPaths {
122
+ return { home: cosGlassesHome(), spoolDir: hookSpoolDir() }
123
+ }
124
+
125
+ type HookBlock = { matcher?: unknown; hooks?: unknown }
126
+
127
+ function blockIsOurs(block: unknown): boolean {
128
+ if (!block || typeof block !== 'object') return false
129
+ const hooks = (block as HookBlock).hooks
130
+ if (!Array.isArray(hooks)) return false
131
+ return hooks.some(h => h && typeof h === 'object' && typeof (h as { command?: unknown }).command === 'string'
132
+ && ((h as { command: string }).command.includes(HOOK_SCRIPT_MARKER) || (h as { command: string }).command.includes(HOOK_SCRIPT_NAME + ' ')))
133
+ }
134
+
135
+ function canonicalBlock(scriptPath: string, sub: HookSubscription, paths: HookPaths): Record<string, unknown> {
136
+ const hook: Record<string, unknown> = { type: 'command', command: hookCommand(scriptPath, sub.event, paths), timeout: sub.timeout }
137
+ if (sub.async) hook.async = true
138
+ return sub.matcher ? { matcher: sub.matcher, hooks: [hook] } : { hooks: [hook] }
139
+ }
140
+
141
+ export type MergeResult =
142
+ | { ok: true; settings: Record<string, unknown>; changed: boolean }
143
+ /** The file holds a `hooks` shape this merge would have to destroy to proceed. */
144
+ | { ok: false; reason: 'settings_hooks_invalid' }
145
+
146
+ /**
147
+ * Pure. Adds or refreshes our blocks; every foreign block and key survives in order. A
148
+ * `hooks` key that is not an object, or an event whose value is not an array, is refused
149
+ * rather than replaced: "preserving every existing hook block" is the contract.
150
+ */
151
+ export function mergeHookSettings(current: unknown, scriptPath: string, paths: HookPaths = currentHookPaths()): MergeResult {
152
+ const settings: Record<string, unknown> = current && typeof current === 'object' && !Array.isArray(current) ? { ...(current as Record<string, unknown>) } : {}
153
+ const before = JSON.stringify(settings)
154
+ if (settings.hooks !== undefined && (!settings.hooks || typeof settings.hooks !== 'object' || Array.isArray(settings.hooks))) return { ok: false, reason: 'settings_hooks_invalid' }
155
+ const hooksIn = (settings.hooks ?? {}) as Record<string, unknown>
156
+ const hooks: Record<string, unknown> = { ...hooksIn }
157
+ for (const sub of HOOK_SUBSCRIPTIONS) {
158
+ const value = hooks[sub.event]
159
+ if (value !== undefined && !Array.isArray(value)) return { ok: false, reason: 'settings_hooks_invalid' }
160
+ const existing = Array.isArray(value) ? value : []
161
+ const foreign = existing.filter(b => !blockIsOurs(b))
162
+ hooks[sub.event] = [...foreign, canonicalBlock(scriptPath, sub, paths)]
163
+ }
164
+ settings.hooks = hooks
165
+ return { ok: true, settings, changed: JSON.stringify(settings) !== before }
166
+ }
167
+
168
+ /** Pure. Removes our blocks; emptied event arrays are deleted, everything else survives. */
169
+ export function stripHookSettings(current: unknown): MergeResult {
170
+ const settings: Record<string, unknown> = current && typeof current === 'object' && !Array.isArray(current) ? { ...(current as Record<string, unknown>) } : {}
171
+ const before = JSON.stringify(settings)
172
+ const hooksIn = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks) ? settings.hooks as Record<string, unknown> : null
173
+ if (!hooksIn) return { ok: true, settings, changed: false }
174
+ const hooks: Record<string, unknown> = {}
175
+ for (const [event, blocks] of Object.entries(hooksIn)) {
176
+ if (!Array.isArray(blocks)) { hooks[event] = blocks; continue }
177
+ const foreign = blocks.filter(b => !blockIsOurs(b))
178
+ if (foreign.length > 0) hooks[event] = foreign
179
+ }
180
+ settings.hooks = hooks
181
+ return { ok: true, settings, changed: JSON.stringify(settings) !== before }
182
+ }
183
+
184
+ /** Which of our events a settings object carries, by exact canonical command. */
185
+ export function subscribedEvents(current: unknown, scriptPath: string, paths: HookPaths = currentHookPaths()): { subscribed: string[]; missing: string[]; drifted: string[] } {
186
+ const hooks = current && typeof current === 'object' && (current as Record<string, unknown>).hooks
187
+ const table = hooks && typeof hooks === 'object' ? hooks as Record<string, unknown> : {}
188
+ const subscribed: string[] = []
189
+ const missing: string[] = []
190
+ const drifted: string[] = []
191
+ for (const sub of HOOK_SUBSCRIPTIONS) {
192
+ const blocks = Array.isArray(table[sub.event]) ? table[sub.event] as unknown[] : []
193
+ const ours = blocks.filter(blockIsOurs)
194
+ if (ours.length === 0) { missing.push(sub.event); continue }
195
+ const canonical = JSON.stringify(canonicalBlock(scriptPath, sub, paths))
196
+ if (ours.length === 1 && JSON.stringify(ours[0]) === canonical) subscribed.push(sub.event)
197
+ else drifted.push(sub.event)
198
+ }
199
+ return { subscribed, missing, drifted }
200
+ }
201
+
202
+ export type HookInstallState =
203
+ | 'installed'
204
+ | 'drift'
205
+ | 'missing'
206
+ | 'script_outdated'
207
+ /** The user-level file carries `disableAllHooks: true`: installed or not, nothing fires. */
208
+ | 'disabled_by_settings'
209
+ | 'settings_unparseable'
210
+ | 'settings_symlink'
211
+ /** The file exists but could not be read (EACCES, ELOOP, ENOTDIR): not "missing". */
212
+ | 'settings_unreadable'
213
+
214
+ export interface HookStatus {
215
+ state: HookInstallState
216
+ /** True only for `installed`: the boolean Control's banner keys off. */
217
+ installed: boolean
218
+ settingsPath: string
219
+ scriptPath: string
220
+ scriptSha: string | null
221
+ packageScriptSha: string | null
222
+ subscribed: string[]
223
+ missing: string[]
224
+ drifted: string[]
225
+ tokenPresent: boolean
226
+ }
227
+
228
+ function sha256File(path: string): string | null {
229
+ try { return createHash('sha256').update(readFileSync(path)).digest('hex') } catch { return null }
230
+ }
231
+
232
+ type SettingsRead = { ok: true; settings: unknown; existed: boolean } | { ok: false; reason: 'settings_unparseable' | 'settings_symlink' | 'settings_unreadable' }
233
+
234
+ function readSettings(path: string): SettingsRead {
235
+ try {
236
+ if (lstatSync(path).isSymbolicLink()) return { ok: false, reason: 'settings_symlink' }
237
+ } catch (error) {
238
+ // Only "no such file" is missing. Anything else is a file that could not be looked at,
239
+ // and "I could not look" must never render as "it is not there".
240
+ if ((error as { code?: string }).code === 'ENOENT') return { ok: true, settings: {}, existed: false }
241
+ return { ok: false, reason: 'settings_unreadable' }
242
+ }
243
+ let text: string
244
+ try { text = readFileSync(path, 'utf-8') } catch { return { ok: false, reason: 'settings_unreadable' } }
245
+ try {
246
+ return { ok: true, settings: JSON.parse(text), existed: true }
247
+ } catch {
248
+ return { ok: false, reason: 'settings_unparseable' }
249
+ }
250
+ }
251
+
252
+ function hooksDisabled(settings: unknown): boolean {
253
+ return !!settings && typeof settings === 'object' && (settings as { disableAllHooks?: unknown }).disableAllHooks === true
254
+ }
255
+
256
+ export function hookStatus(paths: { settingsPath?: string; scriptPath?: string; packageScriptPath?: string; hookPaths?: HookPaths } = {}): HookStatus {
257
+ const settingsPath = paths.settingsPath ?? claudeSettingsPath()
258
+ const scriptPath = paths.scriptPath ?? stableHookScriptPath()
259
+ const packageScriptPath = paths.packageScriptPath ?? packagedHookScriptPath()
260
+ const hookPaths = paths.hookPaths ?? currentHookPaths()
261
+ const scriptSha = sha256File(scriptPath)
262
+ const packageScriptSha = sha256File(packageScriptPath)
263
+ const tokenPresent = existsSync(hookTokenPath())
264
+ const read = readSettings(settingsPath)
265
+ if (!read.ok) {
266
+ return { state: read.reason, installed: false, settingsPath, scriptPath, scriptSha, packageScriptSha, subscribed: [], missing: HOOK_SUBSCRIPTIONS.map(s => s.event), drifted: [], tokenPresent }
267
+ }
268
+ const events = subscribedEvents(read.settings, scriptPath, hookPaths)
269
+ let state: HookInstallState
270
+ if (hooksDisabled(read.settings)) state = 'disabled_by_settings'
271
+ else if (events.subscribed.length === 0 && events.drifted.length === 0) state = 'missing'
272
+ else if (events.missing.length > 0 || events.drifted.length > 0) state = 'drift'
273
+ else if (!scriptSha || (packageScriptSha && scriptSha !== packageScriptSha)) state = 'script_outdated'
274
+ else state = 'installed'
275
+ return { state, installed: state === 'installed', settingsPath, scriptPath, scriptSha, packageScriptSha, ...events, tokenPresent }
276
+ }
277
+
278
+ export interface InstallOptions {
279
+ settingsPath?: string
280
+ scriptPath?: string
281
+ packageScriptPath?: string
282
+ hookPaths?: HookPaths
283
+ port?: number
284
+ deskIdleSeconds?: number
285
+ dryRun?: boolean
286
+ }
287
+
288
+ export interface InstallResult {
289
+ ok: boolean
290
+ reason?: string
291
+ changed: boolean
292
+ scriptCopied: boolean
293
+ backupPath: string | null
294
+ status: HookStatus
295
+ /** The merged settings, for `--dry-run` to print. */
296
+ merged?: Record<string, unknown>
297
+ }
298
+
299
+ const BACKUPS_KEPT = 3
300
+
301
+ function backupSettings(settingsPath: string): string | null {
302
+ if (!existsSync(settingsPath)) return null
303
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
304
+ const backup = `${settingsPath}.cos-backup-${stamp}`
305
+ try {
306
+ copyFileSync(settingsPath, backup)
307
+ chmodSync(backup, 0o600)
308
+ } catch { return null }
309
+ try {
310
+ const dir = dirname(settingsPath)
311
+ const prefix = `${settingsPath.slice(dir.length + 1)}.cos-backup-`
312
+ const olds = readdirSync(dir).filter(n => n.startsWith(prefix)).sort()
313
+ for (const old of olds.slice(0, Math.max(0, olds.length - BACKUPS_KEPT))) {
314
+ try { unlinkSync(join(dir, old)) } catch { /* fine */ }
315
+ }
316
+ } catch { /* the backup itself succeeded */ }
317
+ return backup
318
+ }
319
+
320
+ /** Mint the per-install token once (0600, atomically); write port and desk-idle files every time. */
321
+ export function ensureHookRuntimeFiles(port: number, deskIdleSeconds: number): void {
322
+ const home = cosGlassesHome()
323
+ mkdirSync(home, { recursive: true, mode: 0o700 })
324
+ if (!existsSync(hookTokenPath())) atomicWriteFileSync(hookTokenPath(), randomBytes(32).toString('hex'), { mode: 0o600 })
325
+ atomicWriteFileSync(hookPortPath(), String(port), { mode: 0o600 })
326
+ atomicWriteFileSync(hookDeskIdlePath(), String(Math.max(0, Math.trunc(deskIdleSeconds))), { mode: 0o600 })
327
+ }
328
+
329
+ export function readHookToken(): string | null {
330
+ try { return readFileSync(hookTokenPath(), 'utf-8').trim() || null } catch { return null }
331
+ }
332
+
333
+ /**
334
+ * Copy the packaged script to the stable path when it differs (`install`), or only when it
335
+ * is missing (`onlyIfMissing`, used at boot: an older server booting must never downgrade
336
+ * the script a newer `install` put there, since every Claude session on the Mac runs it).
337
+ * Returns true when copied.
338
+ */
339
+ export function ensureStableHookScript(scriptPath = stableHookScriptPath(), packageScriptPath = packagedHookScriptPath(), onlyIfMissing = false): boolean {
340
+ const want = sha256File(packageScriptPath)
341
+ if (!want) throw new Error(`hook script missing from the package at ${packageScriptPath}`)
342
+ const have = sha256File(scriptPath)
343
+ if (have === want) return false
344
+ if (have && onlyIfMissing) return false
345
+ mkdirSync(dirname(scriptPath), { recursive: true, mode: 0o755 })
346
+ atomicWriteFileSync(scriptPath, readFileSync(packageScriptPath), { mode: 0o755 })
347
+ chmodSync(scriptPath, 0o755)
348
+ return true
349
+ }
350
+
351
+ export function installClaudeHooks(options: InstallOptions = {}): InstallResult {
352
+ const settingsPath = options.settingsPath ?? claudeSettingsPath()
353
+ const scriptPath = options.scriptPath ?? stableHookScriptPath()
354
+ const packageScriptPath = options.packageScriptPath ?? packagedHookScriptPath()
355
+ const hookPaths = options.hookPaths ?? currentHookPaths()
356
+ const read = readSettings(settingsPath)
357
+ const statusNow = () => hookStatus({ settingsPath, scriptPath, packageScriptPath, hookPaths })
358
+ const refuse = (reason: string, scriptCopied = false): InstallResult => ({ ok: false, reason, changed: false, scriptCopied, backupPath: null, status: statusNow() })
359
+ if (!read.ok) return refuse(read.reason)
360
+ const merged = mergeHookSettings(read.settings, scriptPath, hookPaths)
361
+ if (!merged.ok) return refuse(merged.reason)
362
+ if (options.dryRun) {
363
+ return { ok: true, changed: merged.changed, scriptCopied: false, backupPath: null, status: statusNow(), merged: merged.settings }
364
+ }
365
+ let scriptCopied = false
366
+ try {
367
+ scriptCopied = ensureStableHookScript(scriptPath, packageScriptPath)
368
+ ensureHookRuntimeFiles(options.port ?? 3141, options.deskIdleSeconds ?? 90)
369
+ } catch (error) {
370
+ return refuse(error instanceof Error ? error.message : String(error), scriptCopied)
371
+ }
372
+ let backupPath: string | null = null
373
+ if (merged.changed) {
374
+ if (read.existed) {
375
+ backupPath = backupSettings(settingsPath)
376
+ // No backup, no overwrite: "with a backup of the file" is the contract.
377
+ if (!backupPath) return refuse('backup_failed', scriptCopied)
378
+ }
379
+ mkdirSync(dirname(settingsPath), { recursive: true, mode: 0o700 })
380
+ atomicWriteFileSync(settingsPath, JSON.stringify(merged.settings, null, 2) + '\n', { mode: 0o600 })
381
+ }
382
+ return { ok: true, changed: merged.changed, scriptCopied, backupPath, status: statusNow() }
383
+ }
384
+
385
+ export function uninstallClaudeHooks(options: Pick<InstallOptions, 'settingsPath' | 'scriptPath' | 'packageScriptPath' | 'hookPaths' | 'dryRun'> = {}): InstallResult {
386
+ const settingsPath = options.settingsPath ?? claudeSettingsPath()
387
+ const scriptPath = options.scriptPath ?? stableHookScriptPath()
388
+ const packageScriptPath = options.packageScriptPath ?? packagedHookScriptPath()
389
+ const hookPaths = options.hookPaths ?? currentHookPaths()
390
+ const read = readSettings(settingsPath)
391
+ const statusNow = () => hookStatus({ settingsPath, scriptPath, packageScriptPath, hookPaths })
392
+ if (!read.ok) return { ok: false, reason: read.reason, changed: false, scriptCopied: false, backupPath: null, status: statusNow() }
393
+ const stripped = stripHookSettings(read.settings)
394
+ if (!stripped.ok) return { ok: false, reason: stripped.reason, changed: false, scriptCopied: false, backupPath: null, status: statusNow() }
395
+ if (options.dryRun) return { ok: true, changed: stripped.changed, scriptCopied: false, backupPath: null, status: statusNow(), merged: stripped.settings }
396
+ let backupPath: string | null = null
397
+ if (stripped.changed) {
398
+ backupPath = backupSettings(settingsPath)
399
+ if (!backupPath) return { ok: false, reason: 'backup_failed', changed: false, scriptCopied: false, backupPath: null, status: statusNow() }
400
+ atomicWriteFileSync(settingsPath, JSON.stringify(stripped.settings, null, 2) + '\n', { mode: 0o600 })
401
+ }
402
+ return { ok: true, changed: stripped.changed, scriptCopied: false, backupPath, status: statusNow() }
403
+ }
@@ -29,6 +29,9 @@
29
29
  // nameSource entirely. Only `derived` is a function of the folder name.
30
30
 
31
31
  /** Fields this module will read. Everything else in the file is ignored. */
32
+ import { homedir } from 'node:os'
33
+ import { join, resolve } from 'node:path'
34
+
32
35
  export interface RawClaudeSession {
33
36
  pid?: unknown
34
37
  sessionId?: unknown
@@ -43,6 +46,7 @@ export interface RawClaudeSession {
43
46
  status?: unknown
44
47
  updatedAt?: unknown
45
48
  waitingFor?: unknown
49
+ statusUpdatedAt?: unknown
46
50
  }
47
51
 
48
52
  export interface ClaudePeer {
@@ -63,6 +67,30 @@ export interface ClaudePeer {
63
67
  startedAt: number | null
64
68
  }
65
69
 
70
+ /**
71
+ * A peer plus the facts the state deriver needs and the wire must not carry: the full
72
+ * session id (the wire shortens it on purpose) and when `status` last moved. Built by
73
+ * `readClaudePeerRecords`; `readClaudePeers` strips it back to the wire shape.
74
+ */
75
+ export interface ClaudePeerRecord extends ClaudePeer {
76
+ sessionId: string
77
+ pid: number
78
+ statusUpdatedAt: number | null
79
+ }
80
+
81
+ export function peerRecordFacts(raw: RawClaudeSession): { sessionId: string; pid: number; statusUpdatedAt: number | null } | null {
82
+ const pid = Number(raw.pid)
83
+ const sessionId = typeof raw.sessionId === 'string' ? raw.sessionId : ''
84
+ if (!Number.isInteger(pid) || pid <= 0 || !sessionId) return null
85
+ return { sessionId: sessionId.toLowerCase(), pid, statusUpdatedAt: millis(raw.statusUpdatedAt) }
86
+ }
87
+
88
+ /** The wire shape and nothing else: a record is never serialized as is. */
89
+ export function toWirePeer(record: ClaudePeerRecord): ClaudePeer {
90
+ const { sessionId: _sessionId, pid: _pid, statusUpdatedAt: _statusUpdatedAt, ...peer } = record
91
+ return peer
92
+ }
93
+
66
94
  export interface PeerProbes {
67
95
  /** signal-0 liveness. EPERM (another user) must resolve to false, not true. */
68
96
  isAlive: (pid: number) => boolean
@@ -73,6 +101,22 @@ export interface PeerProbes {
73
101
  /** A registry filename is exactly `<pid>.json`. Not `*.json`. */
74
102
  export const REGISTRY_FILENAME = /^\d+\.json$/
75
103
 
104
+ /**
105
+ * Where the registry lives.
106
+ *
107
+ * `COS_CLAUDE_SESSIONS_DIR` first because it is both the override for a non-standard
108
+ * install AND the test seam: `homedir()` is not mockable, so without an env hook the
109
+ * only testable path would be the real one. Then CLAUDE_CONFIG_DIR, which real
110
+ * installs do set; hardcoding ~/.claude breaks those. (Moved here from the route in
111
+ * 6.48.1 so the hooks runtime can read it without importing a router.)
112
+ */
113
+ export function claudeSessionsDir(): string {
114
+ const explicit = process.env.COS_CLAUDE_SESSIONS_DIR
115
+ if (explicit) return resolve(explicit)
116
+ const configDir = process.env.CLAUDE_CONFIG_DIR
117
+ return join(configDir ? resolve(configDir) : join(homedir(), '.claude'), 'sessions')
118
+ }
119
+
76
120
  function str(value: unknown): string | null {
77
121
  return typeof value === 'string' && value.length > 0 ? value : null
78
122
  }
@@ -787,6 +787,49 @@ export function buildOccupancyProbes(
787
787
  * adapter's pre-spawn preflight REFUSES a thenable rather than awaiting one —
788
788
  * awaiting there would reopen the very race the check exists to close.
789
789
  */
790
+ /**
791
+ * Add the hook turn clock (the B6 clause, 6.48.1) to a probe set.
792
+ *
793
+ * `signalFor` is the signal store's reader, taking a full session id or the registry's
794
+ * 8-character form; `registryIdleAfterStop` reads the registry record for the session.
795
+ * The probe answers a Stop time only when BOTH vouch: the session's newest hook event is
796
+ * that Stop, the turn is closed, no sub-agent is open, the session has not ended, and
797
+ * the registry says idle at or after the Stop (the hooks have returned). Codex never
798
+ * asks (its ids are not Claude sessions, and a Codex thread id sharing a Claude prefix
799
+ * must not read a Claude signal); `readHolderActivity` guards the provider too.
800
+ *
801
+ * Wired at the composition root only when `COS_SESSION_HOOKS` is on. Absent, the
802
+ * gate is byte-for-byte 6.48.0.
803
+ */
804
+ export function withHookTurnClock(
805
+ probes: OccupancyProbes,
806
+ signalFor: (sessionId: string) => HookTurnSignal | undefined,
807
+ registryIdleAfterStop: (sessionId: string, stopAt: number) => boolean | null,
808
+ ): OccupancyProbes {
809
+ return {
810
+ ...probes,
811
+ holderTurnEndedAtMs: (provider, threadId) => {
812
+ if (provider !== 'claude') return null
813
+ if (!NATIVE_THREAD_ID_RE.test(threadId)) return null
814
+ const signal = signalFor(threadId)
815
+ if (!signal || signal.lastEvent !== 'Stop' || signal.turnOpen || signal.subagentsOpen > 0 || signal.ended !== null) return null
816
+ if (typeof signal.stopAt !== 'number') return null
817
+ // The engine's own end of turn: the registry flipped idle AFTER this Stop, which
818
+ // happens only once every Stop hook has returned. Anything else is strict.
819
+ return registryIdleAfterStop(threadId, signal.stopAt) === true ? signal.stopAt : null
820
+ },
821
+ }
822
+ }
823
+
824
+ /** The facts the hook turn clock reads off a session signal. */
825
+ export interface HookTurnSignal {
826
+ lastEvent: string
827
+ turnOpen: boolean
828
+ subagentsOpen: number
829
+ ended: { at: number; reason: string } | null
830
+ stopAt: number | null
831
+ }
832
+
790
833
  export function withTranscriptClock(
791
834
  probes: OccupancyProbes,
792
835
  headDeps: NativeHeadDeps,