@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.
- package/CHANGELOG.md +26 -0
- package/README.md +35 -0
- package/bin/cli.cjs +22 -0
- package/bin/hooks/cos-session-hook +43 -0
- package/managed-runtime-contract.json +7 -1
- package/package.json +3 -1
- package/server/index.ts +78 -6
- package/server/lib/claude-hooks-installer.ts +403 -0
- package/server/lib/claude-session-registry.ts +44 -0
- package/server/lib/occupancy-probes.ts +43 -0
- package/server/lib/session-hook-events.ts +205 -0
- package/server/lib/session-hook-ledger.ts +133 -0
- package/server/lib/session-hook-spool.ts +272 -0
- package/server/lib/session-hooks-runtime.ts +338 -0
- package/server/lib/session-signal-store.ts +389 -0
- package/server/lib/session-state-derive.ts +249 -0
- package/server/lib/session-stream-bus.ts +93 -9
- package/server/lib/session-stream-events.ts +144 -0
- package/server/lib/thread-drain-kick.ts +153 -0
- package/server/lib/thread-occupancy.ts +36 -1
- package/server/lib/thread-turn-queue-deliver.ts +17 -6
- package/server/lib/thread-turn-queue-store.ts +70 -23
- package/server/lib/thread-turn-queue.ts +19 -3
- package/server/routes/agent-session-stream.ts +169 -11
- package/server/routes/agent-sessions.ts +88 -9
- package/server/routes/claude-sessions.ts +42 -21
- package/server/routes/health.ts +2 -0
- package/server/routes/session-hooks.ts +80 -0
- package/server/routes/thread-turn-queue.ts +15 -0
- package/server/scripts/hooks-cli.ts +48 -0
- package/server/lib/__fixtures__/query-jobs-6.43.3/2099-01-01.jsonl +0 -2
|
@@ -0,0 +1,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
|
+
}
|