Machine-wide resource clearance for Claude Code sessions and subagents: see the headroom, and get cleared, held or diverted before launching more.

Status: v0.1.0. All 7 build steps are done: the shared snapshot and scribe election, presence and the gate, admission, the
/clearancepane, empirical forecasts, Docker and desktop attribution with convention checks, and THRASH with a floor learned from paging pressure. Windows only.
A Claude Code mod that makes every session on a machine aware of the machine's resources and of the other sessions:
isolation: "remote") is never gated by this machine.The band above the prompt, always up:
[marshaller] ● cleared 2s·6a RAM ▁▂▃▅▆▇ 82% 2.8 GB free
2s·6a: two more sessions and six more subagents fit. The sparkline is RAM in use over the last 50 s.▲ THRASH … spawns refused./clearance opens the full pane; /clearance check runs the convention checks (read-only): Supabase project_id left as default or shared, compose stacks without a working_dir label, hard-coded host ports, and host-port or project-name collisions between running containers.
The band takes the AbovePrompt slot. Another plugin that draws there without passing the band on (token-weather does) hides it; clearance passes the band on, so a plugin beneath it still shows.
claude plugin marketplace add LeventAksakal/clearance
claude plugin install clearance@clearance
Shared state lives in ~/.claude/clearance/ (see docs/design.md).
$.process: runs pwsh -File on the scripts in scripts/.sampler.ps1 (the scribe's, one per machine) reads memory, the process list with start times, and paging pressure (\Memory\Pages Input/sec, PDH) through Win32, and every 6th tick docker ps and docker stats --no-stream (read-only). It writes only under ~/.claude/clearance/.claim.ps1 creates one epoch file there.verify.ps1 is a manual, read-only cross-check of the snapshot (pwsh -File scripts/verify.ps1).$.fs:~/.claude/sessions/*.json (never the *.key files);~/.claude/clearance/: this session's presence file, its history file history/<yyyy-mm>/<sessionId>.jsonl (one line per finished subagent: type, duration, growth; one line of the session's peaks), and, while it is scribe, pressure.json (a histogram of paging against available memory);pressure.json to learn the forecasts and the floor;/clearance check only: reads supabase/config.toml and compose files in each session's folder and one level down.session.start, session.end, tool.call (every tool, after next, only to note progress; the call is never changed), turn.start and turn.complete (only to note whether the session is working), tool.call of Agent (notes isolation: "remote"), agent.spawn (refuses a subagent over the cap or in THRASH), classic.SubagentStart (adds the subagent's budget line; starts measuring it), classic.SubagentStop (records what it cost), command.run of clearance, ui.render of AbovePrompt (the band) and of Pane (the pane).$.tool.register: mcp__clearance__headroom, the census for the model. $.command.register: /clearance.$.ui.ask (the session-start dialog on HOLD), $.session.append (a system notice with the divert steps, only when chosen), $.ui.toast (one per THRASH episode; when a held start clears), $.ui.open, $.ui.status (terminal), $.ui.log (debug log).$.state clearance.badge, clearance.pane, clearance.startChecked, clearance.waitingForClearance.$.session.id, $.clock, $.env.get('USERPROFILE').$.http.Options (/config): minFreeGB 0 (learned from paging; 5% of RAM until pressure is seen), maxCommitPct 90, maxSessions 6, maxAgents 8, sessionBaselineGB 0 (learned). A positive minFreeGB or sessionBaselineGB fixes that value.
claude plugin validate .
claude plugin test .
npx tsc -p .
tsc needs .claude-plugin/types/, which the engine writes when the mod is hot-loaded (or /plugin-types).
MIT
hooks/register.tsx 552 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register } from 'claude-code'
3import { bandLine, badgeModel, cardLines, light, LIGHT_COLOR, TONE_COLOR, type BadgeRun } from './badge.ts'
4import { DIALOG, budgetLine, decideSpawn, divertSteps, headroomReport, withOwnAgents, withoutSession } from './admission.ts'
5import { describe, forecastAgent, forecastSession, type Forecast } from './forecast.ts'
6import { startGrowthWatch, startHistory, startTracker, type History, type Tracker } from './history.ts'
7import { checksReport, runChecks } from './checks.ts'
8import { emptyPressure, fold, isPressured, isThrash, lastBusyProgress, learnFloor, parsePressure, STALL_MS, type Floor, type Pressure } from './pressure.ts'
9import { advance, floorMB, gate, gateOptions, type GateOptions, type GateView } from './gate.ts'
10import type { Io } from './io.ts'
11import { paneLines, paneModel, type Tone } from './pane.ts'
12import { pathsFor } from './paths.ts'
13import { startPresence, type Presence } from './presence.ts'
14import { startScribe, type Scribe } from './scribe.ts'
15import { isFresh, statusLine, type Snapshot } from './snapshot.ts'
16import { H, SCALE, spriteSvg, W } from './sprite.ts'
17
18// Wiring only: hooks to modules. Step 1: the scribe election and the sampler.
19// Step 2: presence, the gate, the status line's states and the HOLD band.
20// Step 3: admission: the spawn gate, each subagent's budget line, the
21// headroom tool and the session-start dialog. Step 4: the /clearance pane.
22// The band: an always-up badge, the marshaller sprite and one line.
23
24const badge = atom({ plugin: 'clearance', key: 'badge' } as const, null)
25const pane = atom({ plugin: 'clearance', key: 'pane' } as const, null)
26const startChecked = atom({ plugin: 'clearance', key: 'startChecked' } as const, false)
27const waitingForClearance = atom({ plugin: 'clearance', key: 'waitingForClearance' } as const, false)
28
29const HEADROOM_TOOL = 'mcp__clearance__headroom'
30const PANE = 'clearance'
31const PANE_TITLE = 'clearance'
32
33const TONE: Record<Tone, { color?: string; dimColor?: boolean; bold?: boolean }> = {
34 plain: {},
35 dim: { dimColor: true },
36 ok: { color: 'green', bold: true },
37 warn: { color: 'yellow', bold: true },
38 head: { bold: true },
39}
40
41// The modules' reach. `$` stays in this file: the validator follows it only
42// within one file, so the other modules get these closures instead.
43// Log lines also go to ~/.claude/clearance/logs/<sessionId>.log (this session
44// its only writer), the last LOG_LINES of them, so an election can be read back
45// from sessions that don't run with --debug.
46const LOG_LINES = 200
47
48const ioFor = ($: EngineInterface, logFile: string): Io => {
49 const lines: string[] = []
50 return {
51 now: () => $.clock.now(),
52 sessionId: () => $.session.id(),
53 list: dir => $.fs.list(dir),
54 read: path => $.fs.read(path),
55 write: (path, text) => $.fs.write(path, text),
56 mtime: async path => (await $.fs.stat(path)).mtimeMs,
57 run: (argv, timeoutMs) => $.process.run(argv, { timeoutMs }),
58 spawn: argv => $.process.spawn({ argv }),
59 every: (ms, fn) => $.clock.every(ms, fn),
60 log: text => {
61 $.ui.log(`clearance: ${text}`, { to: 'debug' })
62 lines.push(`${new Date().toISOString()} ${text}`)
63 if (lines.length > LOG_LINES) lines.splice(0, lines.length - LOG_LINES)
64 void $.fs.write(logFile, lines.join('\n') + '\n').catch(() => {})
65 },
66 }
67}
68
69/** What the hooks share; the module's own, so a hot reload starts it over. */
70type Ctx = {
71 opts: GateOptions
72 presence: Presence | undefined
73 io: Io | undefined
74 /** The latest snapshot read, if it was fresh then. */
75 latest: Snapshot | undefined
76 /** This session's id as of the last session.start (the pane marks its row). */
77 sessionId: string
78 /** The band has been drawn on the desktop: the status line then stays empty, not repeating it. */
79 footerDrawn: boolean
80 /** Agent calls with `isolation: "remote"`, by tool_use_id: they run in the cloud, so the gate lets them through. */
81 remote: Set<string>
82 /** Remote subagents by agentId: their growth isn't this machine's, so the tracker skips them. */
83 remoteAgents: Set<string>
84 history: History | undefined
85 tracker: Tracker | undefined
86 /** Largest growth step of any live session: the subagent stand-in before any run is measured. */
87 growth: ReturnType<typeof startGrowthWatch>
88 /** The person fixed the session cost in the options; otherwise it is learned. */
89 sessionFixed: boolean
90 /** The paging histogram: kept and written by the scribe, read by the others. */
91 pressure: Pressure | undefined
92 floor: Floor
93 /** Pressured samples in a row (THRASH needs THRASH_RUN). */
94 pressuredRun: number
95 /** A THRASH episode is on: its one toast has been shown. */
96 inThrash: boolean
97}
98
99/** The scribe writes the paging histogram this often (in samples: one minute). */
100const PRESSURE_WRITE_SAMPLES = 12
101
102const NO_FLOOR: Floor = { mb: undefined, calmP90: undefined, calmFromMB: undefined, n: 0, basis: 'learning: no paging samples yet' }
103
104/** The learned floor goes into the gate (it applies while the floor option is 0, auto). */
105const relearnFloor = (ctx: Ctx) => {
106 ctx.floor = ctx.pressure ? learnFloor(ctx.pressure) : NO_FLOOR
107 ctx.opts.learnedFloorMB = ctx.floor.mb
108}
109
110const floorBasis = (ctx: Ctx) =>
111 ctx.opts.minFreeGB > 0 ? `set to ${ctx.opts.minFreeGB} GB in the options` : ctx.floor.mb !== undefined ? `learned: ${ctx.floor.basis}` : `policy 5% of RAM (${ctx.floor.basis})`
112
113/** Reads the paging histogram written by the scribe; a missing or broken file leaves what is in memory. */
114async function loadPressure($: EngineInterface, ctx: Ctx, path: string): Promise<void> {
115 try {
116 const p = parsePressure(await $.fs.read(path))
117 if (p) ctx.pressure = p
118 } catch {
119 // no histogram yet
120 }
121 relearnFloor(ctx)
122}
123
124/** Samples in the band's RAM sparkline: 10 × 5 s, the last 50 s. */
125const TRAIL = 10
126
127/** Every session's history reread from disk this often, to learn from the others. */
128const HISTORY_RELOAD_MS = 10 * 60_000
129/** This session's peaks written at most this often (they only grow). */
130const SESSION_WRITE_MS = 60_000
131
132/** The learned forecast for one more subagent of `type`, or the prior without history. */
133const agentForecast = (ctx: Ctx, type: string, now: number): Forecast =>
134 forecastAgent(ctx.history?.records() ?? [], type, ctx.growth.maxStepMB(), now)
135
136const basis = describe
137
138/** The session cost the gate uses, unless fixed in the options: recorded session peaks and every live session's size now. */
139const relearnSession = (ctx: Ctx, now: number) => {
140 if (ctx.sessionFixed) return
141 const live = (ctx.latest?.sessions ?? []).map(r => r.selfMB + r.childMB)
142 ctx.opts.sessionBaselineGB = forecastSession(ctx.history?.records() ?? [], live, now).mb / 1024
143}
144
145
146
147/** The latest snapshot if still fresh, with this session's subagents counted as they are now. */
148async function current($: EngineInterface, ctx: Ctx, now: number): Promise<Snapshot | undefined> {
149 if (!ctx.latest || !isFresh(ctx.latest, now) || !ctx.presence) return undefined
150 return withOwnAgents(ctx.latest, await $.session.id(), ctx.presence.agentsInFlight())
151}
152
153/** Once per session, on the first fresh snapshot: was this session started over the cap? Asks unawaited (S5). */
154async function checkStart($: EngineInterface, ctx: Ctx, s: Snapshot): Promise<void> {
155 if (await read($, startChecked)) return
156 await update($, startChecked, () => true)
157 const v = gate(withoutSession(s, await $.session.id()), ctx.opts, { kind: 'session' })
158 ctx.io?.log(`session-start check: ${v.state}${v.reasons.length ? ` (${v.reasons.join('; ')})` : ''}`)
159 if (v.state === 'CLEARED') return
160 void $.ui.ask(DIALOG.question(v), [DIALOG.divert, DIALOG.wait, DIALOG.anyway]).then(
161 async answer => {
162 ctx.io?.log(`session-start dialog: ${answer}`)
163 if (answer === DIALOG.divert) await $.session.append({ message: { type: 'system', content: [{ type: 'text', text: divertSteps(v) }] } })
164 if (answer === DIALOG.wait) await update($, waitingForClearance, () => true)
165 },
166 err => ctx.io?.log(`session-start dialog not shown: ${String(err)}`),
167 )
168}
169
170/** The person chose to wait at the session-start dialog and the machine has cleared: say so once. */
171async function toastIfWaiting($: EngineInterface, headroomMB: number): Promise<void> {
172 if (!(await read($, waitingForClearance))) return
173 await update($, waitingForClearance, () => false)
174 $.ui.toast(`clearance: cleared, ${(headroomMB / 1024).toFixed(1)} GB headroom`)
175}
176
177/** A line's runs as nested Texts, colored by tone. */
178const runs = (Text: ElementTable['Text'], line: BadgeRun[]) =>
179 line.map((r, i) => (
180 <Text key={`r${i}`} color={r.color ?? (r.tone ? TONE_COLOR[r.tone] : undefined)} bold={r.strong} dimColor={r.dim}>
181 {r.text}
182 </Text>
183 ))
184
185export const register: Register = (on, options) => {
186 const opts = gateOptions(options)
187 const ctx: Ctx = {
188 opts,
189 presence: undefined,
190 io: undefined,
191 latest: undefined,
192 sessionId: '',
193 footerDrawn: false,
194 remote: new Set(),
195 remoteAgents: new Set(),
196 history: undefined,
197 tracker: undefined,
198 growth: startGrowthWatch(),
199 sessionFixed: opts.sessionBaselineGB > 0,
200 pressure: undefined,
201 floor: NO_FLOOR,
202 pressuredRun: 0,
203 inThrash: false,
204 }
205 let sessionWrittenAt = 0
206 let pressureT = -1
207 let pressureFolded = 0
208 let pressurePath = ''
209 let isScribeNow = false
210 let lastThrash: string | undefined
211 let loadPressureNow: () => Promise<void> = async () => {}
212 /** RAM in use, percent, one per sample: the band's sparkline. */
213 const ramTrail: number[] = []
214 let trailT = -1
215 let historyLoadedAt = 0
216 let scribe: Scribe | undefined
217 let view: GateView | undefined
218 let shownBadge = 'null'
219
220 /** Writes this session's peaks as they grow, and rereads every session's history now and then. */
221 const keepHistory = async (now: number) => {
222 const history = ctx.history
223 if (!history || !ctx.tracker) return
224 if (now - sessionWrittenAt >= SESSION_WRITE_MS) {
225 sessionWrittenAt = now
226 const r = ctx.tracker.session(ctx.sessionId, now)
227 if (r) await history.setSession(r)
228 }
229 if (now - historyLoadedAt >= HISTORY_RELOAD_MS) {
230 historyLoadedAt = now
231 await history.reload()
232 relearnSession(ctx, now)
233 if (!isScribeNow && pressurePath) await loadPressureNow()
234 }
235 }
236
237 on('session.start', async ($, e, next) => {
238 const started = await next(e)
239 const home = await $.env.get('USERPROFILE')
240 if (!home) {
241 $.ui.status('clearance · USERPROFILE is not set')
242 return started
243 }
244 scribe?.stop()
245 const paths = pathsFor(home)
246 const sessionIo = ioFor($, `${paths.root}\\logs\\${await $.session.id()}.log`)
247 const presence = startPresence(sessionIo, paths)
248 ctx.sessionId = await $.session.id()
249 ctx.io = sessionIo
250 ctx.presence = presence
251 await presence.flush()
252 const startedAt = await $.clock.now()
253 ctx.tracker = startTracker()
254 try {
255 ctx.history = await startHistory(sessionIo, paths, ctx.sessionId, startedAt)
256 historyLoadedAt = startedAt
257 relearnSession(ctx, startedAt)
258 const f = agentForecast(ctx, 'general-purpose', startedAt)
259 sessionIo.log(
260 `history: ${ctx.history.records().length} records; subagent ${f.mb} MB (${basis(f)}); session ${Math.round(opts.sessionBaselineGB * 1024)} MB`,
261 )
262 } catch (err) {
263 sessionIo.log(`history: ${String(err)}`)
264 }
265 pressurePath = paths.pressure
266 loadPressureNow = () => loadPressure($, ctx, paths.pressure)
267 await loadPressureNow()
268 sessionIo.log(`floor: ${floorBasis(ctx)}`)
269 await $.command.register({
270 name: 'clearance',
271 description: "Show this machine's sessions, their memory and the headroom in a pane; `/clearance check` runs the convention checks",
272 })
273 await $.tool.register({
274 name: 'headroom',
275 description:
276 "clearance: this machine's memory headroom, every Claude session's use, and how many subagents of a type fit now. " +
277 'Call it before spawning several subagents, and size the fan-out to what it says fits.',
278 inputSchema: {
279 type: 'object',
280 properties: {
281 subagentType: { type: 'string', description: 'The subagent type you plan to spawn (default general-purpose).' },
282 count: { type: 'integer', minimum: 1, description: 'How many you plan to spawn.' },
283 },
284 },
285 })
286 scribe = startScribe(sessionIo, {
287 paths,
288 scripts: `${$.plugin.root}\\scripts`,
289 onTick: (snapshot, isScribe, now) => {
290 const fresh = snapshot && isFresh(snapshot, now) ? snapshot : undefined
291 ctx.latest = fresh
292 isScribeNow = isScribe
293 // Step 7: fold the sample into the paging histogram (the scribe), count a
294 // pressured run, and judge THRASH, once per sample.
295 let thrash: string | undefined = lastThrash
296 if (fresh && fresh.t !== pressureT) {
297 pressureT = fresh.t
298 const pages = fresh.machine.pagesInPerSec
299 if (isScribe && pages !== undefined) {
300 ctx.pressure = fold(ctx.pressure ?? emptyPressure(fresh.machine.totalMB), fresh.machine.availableMB, pages, fresh.t)
301 if (++pressureFolded % PRESSURE_WRITE_SAMPLES === 0) {
302 relearnFloor(ctx)
303 void $.fs.write(paths.pressure, JSON.stringify(ctx.pressure)).catch(err => sessionIo.log(`pressure write: ${String(err)}`))
304 }
305 }
306 ctx.pressuredRun = isPressured(pages, ctx.floor) ? ctx.pressuredRun + 1 : 0
307 const floor = floorMB(opts, fresh.machine.totalMB)
308 thrash = isThrash({ pressuredRun: ctx.pressuredRun, availableMB: fresh.machine.availableMB, floorMB: floor, lastProgressAt: lastBusyProgress(fresh.sessions), now })
309 ? ctx.pressuredRun >= 3
310 ? `THRASH: paging ${Math.round(pages ?? 0)}/s (calm ≤ ${ctx.floor.calmP90}/s) with ${(fresh.machine.availableMB / 1024).toFixed(1)} GB free, under the ${(floor / 1024).toFixed(1)} GB floor`
311 : `THRASH: ${(fresh.machine.availableMB / 1024).toFixed(1)} GB free, under half the floor, and no busy session progressed for ${STALL_MS / 60_000} min`
312 : undefined
313 lastThrash = thrash
314 }
315 view = fresh ? advance(view, fresh, opts, presence.reservedSince(fresh.t, now), thrash) : undefined
316 if (view?.shown.state === 'THRASH' && !ctx.inThrash) {
317 ctx.inThrash = true
318 sessionIo.log(thrash ?? 'THRASH')
319 $.ui.toast(`clearance: THRASH. The machine is paging hard below its floor; every spawn is refused until it recovers.`)
320 } else if (view && view.shown.state !== 'THRASH') ctx.inThrash = false
321 const model = fresh && view ? paneModel(fresh, view, opts, ctx.sessionId, isScribe, now, floorBasis(ctx)) : null
322 void update($, pane, () => model)
323 $.ui.status(ctx.footerDrawn ? undefined : statusLine(snapshot, now, view?.shown))
324 const own = fresh?.sessions.find(r => r.sessionId === ctx.sessionId)
325 if (fresh && own) ctx.tracker?.sample(own, fresh.t)
326 if (fresh && fresh.t !== trailT) {
327 trailT = fresh.t
328 ramTrail.push(Math.round(((fresh.machine.totalMB - fresh.machine.availableMB) / fresh.machine.totalMB) * 100))
329 if (ramTrail.length > TRAIL) ramTrail.shift()
330 }
331 if (fresh) {
332 ctx.growth.observe(fresh.sessions, fresh.t)
333 relearnSession(ctx, now)
334 }
335 void keepHistory(now).catch(err => sessionIo.log(`history: ${String(err)}`))
336 const agent = fresh
337 ? gate(fresh, opts, { kind: 'agent', mb: agentForecast(ctx, 'general-purpose', now).mb }, presence.reservedSince(fresh.t, now))
338 : undefined
339 const shown = badgeModel(snapshot, now, view, agent, {
340 me: ctx.sessionId,
341 floorMB: floorMB(opts, fresh?.machine.totalMB ?? 0),
342 agentAskMB: agentForecast(ctx, 'general-purpose', now).mb,
343 sessionAskMB: opts.sessionBaselineGB * 1024,
344 ramTrail: [...ramTrail],
345 floorBasis: floorBasis(ctx),
346 })
347 const key = JSON.stringify(shown)
348 if (key !== shownBadge) {
349 shownBadge = key
350 void update($, badge, () => shown)
351 }
352 if (!fresh) return
353 void checkStart($, ctx, fresh).catch(err => sessionIo.log(`session-start check: ${String(err)}`))
354 if (view?.shown.state === 'CLEARED') void toastIfWaiting($, view.shown.headroomMB)
355 },
356 })
357 return started
358 })
359
360 // The spawn gate (S4): over the cap the Agent call fails with the forecast;
361 // under it the memory is reserved before the spawn runs.
362 on('agent.spawn', async ($, e, next) => {
363 const presence = ctx.presence
364 if (ctx.remote.delete(e.tool_use_id)) {
365 ctx.io?.log(`spawn cleared, remote: ${e.subagentType} "${e.description}"`)
366 const result = await next(e)
367 if (result.agentId) ctx.remoteAgents.add(result.agentId)
368 return result
369 }
370 if (!presence) return next(e)
371 if (view?.shown.state === 'THRASH') {
372 ctx.io?.log(`spawn denied, THRASH: ${e.subagentType} "${e.description}"`)
373 return {
374 deny:
375 `clearance: THRASH. ${view.band?.reasons[0] ?? 'The machine is paging hard below its floor.'} Every spawn is refused until it recovers: ` +
376 'finish the work in this conversation, stop heavy processes, or divert to a cloud session (claude.ai/code) or another machine.',
377 }
378 }
379 const now = await $.clock.now()
380 const s = await current($, ctx, now)
381 const mb = agentForecast(ctx, e.subagentType, now).mb
382 const decision = decideSpawn(s, opts, e.subagentType, mb, s ? presence.reservedSince(s.t, now) : 0)
383 if (!decision.allow) {
384 ctx.io?.log(`spawn denied: ${e.subagentType} "${e.description}": ${decision.verdict.reasons.join('; ')}`)
385 return { deny: decision.deny }
386 }
387 await presence.reserve(e.tool_use_id, mb)
388 try {
389 const result = await next(e)
390 await presence.started(e.tool_use_id, result.agentId)
391 return result
392 } catch (err) {
393 await presence.started(e.tool_use_id, undefined)
394 throw err
395 }
396 })
397
398 // Every subagent learns its budget (S3).
399 on('classic.SubagentStart', async ($, e, next) => {
400 const result = await next(e)
401 const now = await $.clock.now()
402 if (!ctx.remoteAgents.has(e.agent_id)) ctx.tracker?.started(e.agent_id, e.agent_type, now)
403 const line = budgetLine(await current($, ctx, now), opts, e.agent_type, agentForecast(ctx, e.agent_type, now).mb)
404 return { ...result, additionalContext: [...(result.additionalContext ?? []), line] }
405 })
406
407 // A finished subagent: in flight no more, and one more record to learn from.
408 on('classic.SubagentStop', async ($, e, next) => {
409 await ctx.presence?.stopped(e.agent_id)
410 ctx.remoteAgents.delete(e.agent_id)
411 const now = await $.clock.now()
412 const record = ctx.tracker?.stopped(e.agent_id, now)
413 if (record && ctx.history) {
414 await ctx.history.addAgent(record)
415 const f = agentForecast(ctx, record.type, now)
416 ctx.io?.log(
417 `history: ${record.type} ran ${Math.round(record.durationMs / 1000)} s, grew ${record.growthMB} MB over ${record.samples} samples ` +
418 `(${record.concurrent} at once, cost ${record.costMB} MB); forecast now ${f.mb} MB (${basis(f)})`,
419 )
420 }
421 return next(e)
422 })
423
424 // The headroom tool (S2): the census and what fits, for planning a fan-out.
425 on('tool.call', { tool: HEADROOM_TOOL }, async ($, e) => {
426 const now = await $.clock.now()
427 const s = await current($, ctx, now)
428 // The tool's own arguments sit beside `tool` in the input.
429 const args = e as unknown as { subagentType?: unknown; count?: unknown }
430 const ask = {
431 subagentType: typeof args.subagentType === 'string' && args.subagentType ? args.subagentType : undefined,
432 count: typeof args.count === 'number' && args.count > 0 ? Math.floor(args.count) : undefined,
433 }
434 const mbFor = (type: string) => {
435 const f = agentForecast(ctx, type, now)
436 return { mb: f.mb, basis: basis(f) }
437 }
438 return { result: headroomReport(s, opts, s && ctx.presence ? ctx.presence.reservedSince(s.t, now) : 0, ask, now, mbFor) }
439 })
440
441 // A remote Agent call is the divert the gate recommends: note it, so its
442 // spawn isn't counted against this machine. The arguments sit beside `tool`.
443 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
444 const args = e as unknown as { isolation?: unknown }
445 const id = e.tool_use_id
446 if (args.isolation === 'remote' && id) ctx.remote.add(id)
447 try {
448 return await next(e)
449 } finally {
450 if (id) ctx.remote.delete(id)
451 }
452 })
453
454 // Busy for the THRASH stall rule: a main-loop turn in flight (a subagent's
455 // runs raise no turn.start; its turns carry agentId and are left alone).
456 on('turn.start', async ($, e, next) => {
457 await ctx.presence?.turn(true)
458 return next(e)
459 })
460 on('turn.complete', async ($, e, next) => {
461 try {
462 return await next(e)
463 } finally {
464 if (e.agentId === undefined) await ctx.presence?.turn(false)
465 }
466 })
467
468 // Progress for the THRASH detector: any tool result counts (throttled in presence).
469 on('tool.call', async ($, e, next) => {
470 const result = await next(e)
471 ctx.presence?.progress()
472 return result
473 })
474
475 on('command.run', { command: 'clearance' }, async ($, e) => {
476 if (e.args.trim() === 'check') {
477 const s = ctx.latest
478 if (!s || !ctx.io) return { text: 'clearance: no fresh snapshot yet; try again in a few seconds.' }
479 return { text: checksReport(await runChecks(ctx.io, s), s.containers !== undefined) }
480 }
481 await $.ui.open({ id: PANE, title: PANE_TITLE })
482 return { text: 'clearance pane opened.' }
483 })
484
485 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
486 const { Box, Text } = $.ui.resolve(e)
487 const lines = paneLines(await read($, pane), e.props.bodyColumns, await $.clock.now())
488 return (
489 <Box flexDirection="column">
490 {lines.map((line, i) => (
491 <Text key={`l${i}`} wrap="truncate-end" {...TONE[line.tone]}>
492 {line.text || ' '}
493 </Text>
494 ))}
495 </Box>
496 )
497 })
498
499 // The band, always up: the marshaller in the tier's color and one dense line;
500 // hovering it opens the machine and every session's use above the line. It
501 // yields to a survey and keeps a band another plugin draws beneath it.
502 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
503 if (e.props.hasSurvey) return next(e)
504 const b = (await read($, badge)) ?? badgeModel(undefined, 0, undefined)
505 const below = await next(e)
506 const t = $.ui.resolve(e)
507 const { Box, Text } = t
508 const tier = light(b)
509 if (e.surface !== 'terminal') ctx.footerDrawn = true
510 const sprite =
511 // The terminal's table stands a fragment in for Svg; it gets a glyph.
512 e.surface !== 'terminal' && 'Svg' in t ? (
513 <t.Svg source={spriteSvg(b.mood === 'THRASH' ? 'thrash' : tier)} alt={`clearance: ${b.mood === 'THRASH' ? 'THRASH' : tier}`} width={W * SCALE} height={H * SCALE} isInteractive />
514 ) : (
515 <Text color={LIGHT_COLOR[tier]} bold>
516 ●
517 </Text>
518 )
519 const ours = (
520 <Box key="clearance-band" flexDirection="column" paddingX={1}>
521 <Box display="none" hover={{ display: 'flex' }} flexDirection="column" marginBottom={1}>
522 {cardLines(b).map((line, i) => (
523 <Text key={`c${i}`} wrap="truncate-end">
524 {runs(Text, line)}
525 </Text>
526 ))}
527 </Box>
528 <Box flexDirection="row" alignItems="center" columnGap={1}>
529 {sprite}
530 <Text wrap="truncate-end">{runs(Text, bandLine(b))}</Text>
531 </Box>
532 </Box>
533 )
534 if (below.type === 'engine') return ours
535 return (
536 <Box flexDirection="column">
537 {below}
538 {ours}
539 </Box>
540 )
541 })
542
543 on('session.end', async ($, e, next) => {
544 // A /clear ends the conversation, not the process: the scribe keeps its role.
545 if (e.reason !== 'clear') {
546 await scribe?.resign()
547 scribe = undefined
548 }
549 return next(e)
550 })
551}
552hooks/badge.ts 178 lines1import type { ClearanceBadge } from '../types'
2import { attributeContainers } from './checks.ts'
3import { census, type GateView, type Verdict } from './gate.ts'
4import { ageMs, isFresh, type Snapshot } from './snapshot.ts'
5
6// The band's badge: the marshaller sprite and one line, always up, so the
7// machine's clearance reads at a glance beside the prompt. Pure: no `$` here.
8
9const gb = (mb: number) => (mb / 1024).toFixed(1)
10
11/** The badge for the latest snapshot read; rebuilt every tick, redrawn only when it changes. */
12const waiting = (note: string): ClearanceBadge => ({
13 mood: 'WAITING',
14 headroomMB: null,
15 fits: 0,
16 agentFits: 0,
17 sessions: 0,
18 agents: 0,
19 reasons: [],
20 note,
21 usedPct: 0,
22 availableMB: 0,
23 totalMB: 0,
24 floorMB: 0,
25 agentAskMB: 0,
26 sessionAskMB: 0,
27 rows: [],
28 otherMB: 0,
29 ramTrail: [],
30 pagesInPerSec: null,
31 floorBasis: '',
32 desktopMB: 0,
33 dockerVmMB: 0,
34 unattributedContainersMB: 0,
35})
36
37/** What the chip needs beyond the snapshot: this session, and the gate's floor and asks. */
38export type BadgeContext = { me: string; floorMB: number; agentAskMB: number; sessionAskMB: number; ramTrail?: number[]; floorBasis?: string }
39
40/** Rounded to what the lines show (0.1 GB), so a few MB of drift doesn't redraw them. */
41const tenth = (mb: number) => (Math.round(mb / 102.4) * 1024) / 10
42
43/**
44 * The badge for the latest snapshot read; rebuilt every tick, redrawn only when
45 * it changes. `agent` is the gate's verdict for one more general-purpose subagent.
46 */
47export const badgeModel = (
48 s: Snapshot | undefined,
49 now: number,
50 view: GateView | undefined,
51 agent?: Verdict,
52 at: BadgeContext = { me: '', floorMB: 0, agentAskMB: 0, sessionAskMB: 0 },
53): ClearanceBadge => {
54 if (!s || !view) return waiting('waiting for a snapshot')
55 if (!isFresh(s, now)) return waiting(`snapshot ${Math.round(ageMs(s, now) / 1000)} s old`)
56 const c = census(s)
57 const m = s.machine
58 const { bySession, unattributed } = attributeContainers(s)
59 const sum = (xs: readonly { memMB: number }[] | undefined) => (xs ?? []).reduce((a, x) => a + x.memMB, 0)
60 const sessionsMB = s.sessions.reduce((a, r) => a + r.selfMB + r.childMB, 0)
61 const known = sessionsMB + (s.desktop?.privateMB ?? 0) + (s.dockerVm?.privateMB ?? 0)
62 return {
63 mood: view.shown.state,
64 headroomMB: tenth(view.shown.headroomMB),
65 fits: view.shown.fits,
66 agentFits: agent?.fits ?? 0,
67 sessions: c.sessions,
68 agents: c.agents,
69 reasons: view.band?.reasons ?? [],
70 note: '',
71 usedPct: Math.round(((m.totalMB - m.availableMB) / m.totalMB) * 100),
72 availableMB: tenth(m.availableMB),
73 totalMB: m.totalMB,
74 floorMB: at.floorMB,
75 agentAskMB: tenth(at.agentAskMB),
76 sessionAskMB: tenth(at.sessionAskMB),
77 rows: s.sessions
78 .map(r => ({
79 where: r.cwd.split(/[\\/]+/).filter(Boolean).pop() ?? r.cwd,
80 selfMB: tenth(r.selfMB),
81 childMB: tenth(r.childMB),
82 agents: r.agentsInFlight ?? null,
83 isSelf: r.sessionId === at.me,
84 containersMB: tenth(sum(bySession.get(r.sessionId))),
85 }))
86 .sort((a, b) => b.selfMB + b.childMB - (a.selfMB + a.childMB)),
87 otherMB: tenth(Math.max(0, m.totalMB - m.availableMB - known)),
88 ramTrail: at.ramTrail ?? [],
89 pagesInPerSec: m.pagesInPerSec ?? null,
90 floorBasis: at.floorBasis ?? '',
91 desktopMB: tenth(s.desktop?.privateMB ?? 0),
92 dockerVmMB: tenth(s.dockerVm?.privateMB ?? 0),
93 unattributedContainersMB: tenth(sum(unattributed)),
94 }
95}
96
97export type BadgeTone = 'ok' | 'warn' | 'idle'
98
99/** A run of the badge's line: `strong` is the word, `dim` the rest. */
100export type BadgeRun = { text: string; tone?: BadgeTone; color?: string; strong?: boolean; dim?: boolean }
101
102const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
103
104/** Text colors by tone, matching the sprite's paddles. */
105export const TONE_COLOR: Record<BadgeTone, string> = { ok: '#3fb950', warn: '#f0a020', idle: '#94a3b8' }
106
107/** Traffic-light tiers for the footer: what can still start. */
108export type Light = 'green' | 'yellow' | 'red' | 'grey'
109
110/** green: a session fits; yellow: only subagents fit; red: nothing fits; grey: no numbers. */
111export const light = (b: ClearanceBadge): Light =>
112 b.mood === 'WAITING' ? 'grey' : b.mood === 'THRASH' ? 'red' : b.mood === 'CLEARED' && b.fits > 0 ? 'green' : b.agentFits > 0 ? 'yellow' : 'red'
113
114export const LIGHT_COLOR: Record<Light, string> = { green: '#3fb950', yellow: '#e3b341', red: '#f85149', grey: '#94a3b8' }
115
116const col = (text: string, width: number) => (text.length > width ? text.slice(0, width - 1) + '…' : text.padEnd(width))
117const num = (mb: number) => gb(mb).padStart(5)
118
119/** The hover card: the machine, why, and every session's use. One run list per line. */
120export const cardLines = (b: ClearanceBadge): BadgeRun[][] => {
121 if (b.mood === 'WAITING') return [[{ text: `clearance: ${b.note}`, dim: true }]]
122 const used = b.totalMB - b.availableMB
123 const lines: BadgeRun[][] = [
124 [{ text: 'RAM ', strong: true }, { text: `${gb(used)} of ${gb(b.totalMB)} GB in use, ${gb(b.availableMB)} GB free, floor ${gb(b.floorMB)} GB` }],
125 [
126 { text: 'paging ', strong: true },
127 { text: `${b.pagesInPerSec === null ? 'not read' : `${Math.round(b.pagesInPerSec)}/s`} · floor: ${b.floorBasis || 'policy, 5% of RAM'}`, dim: true },
128 ],
129 [
130 { text: 'asks ', strong: true },
131 { text: `session ${gb(b.sessionAskMB)} GB, subagent ${gb(b.agentAskMB)} GB → ` },
132 { text: `${plural(b.fits, 'session')}, ${plural(b.agentFits, 'agent')} fit`, color: LIGHT_COLOR[light(b)] },
133 ],
134 ]
135 for (const reason of b.reasons) lines.push([{ text: `hold: ${reason}`, color: LIGHT_COLOR.red }])
136 lines.push([{ text: `${col('GB', 16)} ${'self'.padStart(5)} ${'child'.padStart(5)} ${'ctr'.padStart(5)} agents`, dim: true }])
137 for (const r of b.rows)
138 lines.push([
139 { text: `${col(r.where, 16)} ${num(r.selfMB)} ${num(r.childMB)} ${num(r.containersMB)} ${r.agents === null ? '-' : r.agents}`, strong: r.isSelf },
140 ...(r.isSelf ? [{ text: ' ← this', dim: true }] : []),
141 ])
142 if (b.desktopMB) lines.push([{ text: `${col('desktop app', 16)} ${num(b.desktopMB)}`, dim: true }])
143 if (b.dockerVmMB) lines.push([{ text: `${col('WSL/Docker VM', 16)} ${num(b.dockerVmMB)}`, dim: true }, { text: ` unattributed containers ${gb(b.unattributedContainersMB)}`, dim: true }])
144 lines.push([{ text: `${col('everything else', 16)} ${num(b.otherMB)}`, dim: true }, { text: ' browsers, system, the rest', dim: true }])
145 return lines
146}
147
148const SPARK = '▁▂▃▄▅▆▇█'
149
150/** RAM in use as a sparkline, scaled 0–100%, so its height reads as how full the machine is. */
151export const sparkline = (pcts: readonly number[]) =>
152 pcts.map(p => SPARK[Math.min(SPARK.length - 1, Math.max(0, Math.floor((p / 100) * SPARK.length)))]).join('')
153
154/**
155 * The band's one dense line, beside the marshaller: the verdict in its tier's
156 * color (`2s·6a`: sessions and subagents that fit), the RAM trail, use, free.
157 */
158export const bandLine = (b: ClearanceBadge): BadgeRun[] => {
159 const tier = light(b)
160 const color = LIGHT_COLOR[tier]
161 if (tier === 'grey') return [{ text: '● clearance', color, strong: true }, { text: ` ${b.note}`, dim: true }]
162 if (b.mood === 'THRASH')
163 return [
164 { text: '▲ THRASH', color, strong: true },
165 { text: ` paging ${b.pagesInPerSec === null ? '?' : Math.round(b.pagesInPerSec)}/s ${gb(b.availableMB)} GB free, floor ${gb(b.floorMB)}`, color },
166 { text: ' spawns refused', dim: true },
167 ]
168 const verdict = tier === 'red' ? '● hold' : '● cleared'
169 return [
170 { text: verdict, color, strong: true },
171 { text: ` ${b.fits}s·${b.agentFits}a`, color },
172 { text: ' RAM ', dim: true },
173 { text: sparkline(b.ramTrail.length ? b.ramTrail : [b.usedPct]), color },
174 { text: ` ${b.usedPct}%` },
175 { text: tier === 'red' ? ` ${gb(b.availableMB)} GB free, floor ${gb(b.floorMB)}` : ` ${gb(b.availableMB)} GB free`, dim: tier !== 'red', color: tier === 'red' ? color : undefined },
176 ]
177}
178hooks/admission.ts 109 lines1import { census, floorMB, gate, type GateOptions, type Verdict } from './gate.ts'
2import type { Snapshot } from './snapshot.ts'
3
4// Admission (0004, design.md § Hooks): the spawn gate's deny text, the
5// subagent's budget line, the headroom tool's table and the session-start
6// check. Pure: no `$` here.
7
8
9const gb = (mb: number) => (mb / 1024).toFixed(1)
10
11export const DIVERT = 'divert to a cloud session (claude.ai/code) or to another machine (Remote Control or ssh)'
12
13export type SpawnDecision = { allow: true; verdict: Verdict | undefined } | { allow: false; deny: string; verdict: Verdict }
14
15/**
16 * Whether a subagent may start. Without a fresh snapshot it is allowed: the
17 * census being down must not block work (deter, never kill: 0003).
18 */
19export const decideSpawn = (s: Snapshot | undefined, o: GateOptions, subagentType: string, mb: number, extraReservedMB: number): SpawnDecision => {
20 if (!s) return { allow: true, verdict: undefined }
21 const v = gate(s, o, { kind: 'agent', mb }, extraReservedMB)
22 if (v.state === 'CLEARED') return { allow: true, verdict: v }
23 const inFlight = census(s).agents
24 const deny =
25 `clearance: HOLD. Forecast ${gb(mb)} GB for ${subagentType}, machine headroom ${gb(v.headroomMB)} GB ` +
26 `(${v.reasons.join('; ')}). ${inFlight} subagent${inFlight === 1 ? '' : 's'} in flight machine-wide. ` +
27 `Run at most ${v.fits} now: wait for running subagents to finish, do the work in this conversation, or ${DIVERT}. ` +
28 `Call mcp__clearance__headroom for the full table.`
29 return { allow: false, deny, verdict: v }
30}
31
32/** The line every subagent gets at its start (classic SubagentStart additionalContext). */
33export const budgetLine = (s: Snapshot | undefined, o: GateOptions, subagentType: string, mb: number): string => {
34 const head = s ? `machine headroom ${gb(gate(s, o, { kind: 'agent', mb }).headroomMB)} GB, ${census(s).agents} subagents in flight` : 'machine census unavailable'
35 return (
36 `clearance: this machine is memory-constrained. Your budget as ${subagentType} is about ${gb(mb)} GB (${head}). ` +
37 `Avoid starting heavy processes (dev servers, test watchers, browsers, docker) unless the task needs them, and stop any you start before you finish.`
38 )
39}
40
41export type HeadroomAsk = { subagentType?: string; count?: number }
42
43/** The headroom tool's answer: the census and what fits, as plain text for the model. */
44export const headroomReport = (
45 s: Snapshot | undefined,
46 o: GateOptions,
47 extraReservedMB: number,
48 ask: HeadroomAsk,
49 now: number,
50 mbFor: (type: string) => { mb: number; basis: string },
51): string => {
52 if (!s) return 'clearance: no fresh machine snapshot yet (the scribe is starting or gone). Spawns are not gated meanwhile.'
53 const type = ask.subagentType ?? 'general-purpose'
54 const { mb, basis } = mbFor(type)
55 const agent = gate(s, o, { kind: 'agent', mb }, extraReservedMB)
56 const session = gate(s, o, { kind: 'session' }, extraReservedMB)
57 const c = census(s)
58 const m = s.machine
59 const lines = [
60 `clearance census (sampled ${Math.max(0, Math.round((now - s.t) / 1000))} s ago)`,
61 `machine: available ${gb(m.availableMB)} GB of ${gb(m.totalMB)} GB (floor ${gb(floorMB(o, m.totalMB))} GB); commit ${gb(m.commitMB)}/${gb(m.commitLimitMB)} GB (ceiling ${o.maxCommitPct}%)`,
62 `headroom: ${gb(agent.headroomMB)} GB after reservations (${gb(c.reservedMB + extraReservedMB)} GB reserved)`,
63 `sessions: ${c.sessions} of ${o.maxSessions}; subagents in flight: ${c.agents} of ${o.maxAgents}`,
64 '',
65 'session | cwd | self GB | children GB | subagents',
66 ...s.sessions.map(r => `${r.sessionId.slice(0, 8)} | ${r.cwd} | ${gb(r.selfMB)} | ${gb(r.childMB)} | ${r.agentsInFlight ?? '-'}`),
67 '',
68 `subagent ${type}: forecast ${gb(mb)} GB (${basis}); ${agent.state}; at most ${agent.fits} now${agent.reasons.length ? ` (${agent.reasons.join('; ')})` : ''}`,
69 `new local session: forecast ${o.sessionBaselineGB.toFixed(2)} GB; ${session.state}; at most ${session.fits} now`,
70 ]
71 if (ask.count !== undefined && ask.count > agent.fits)
72 lines.push(`asked for ${ask.count} subagents: run ${agent.fits} now and queue the rest, or ${DIVERT}`)
73 return lines.join('\n')
74}
75
76/**
77 * The snapshot as if this session were not running yet: its row and its
78 * memory taken off, so the session-start check asks whether it should have
79 * been admitted rather than counting itself twice.
80 */
81export const withoutSession = (s: Snapshot, sessionId: string): Snapshot => {
82 const self = s.sessions.find(r => r.sessionId === sessionId)
83 if (!self) return s
84 const mb = self.selfMB + self.childMB
85 return {
86 ...s,
87 machine: { ...s.machine, availableMB: s.machine.availableMB + mb, commitMB: Math.max(0, s.machine.commitMB - mb) },
88 sessions: s.sessions.filter(r => r !== self),
89 }
90}
91
92export const DIALOG = {
93 question: (v: Verdict) => `clearance: this machine is on HOLD for a new session (${v.reasons.join('; ')}). Continue here?`,
94 divert: 'Divert: show how',
95 wait: 'Wait: tell me when cleared',
96 anyway: 'Start anyway',
97} as const
98
99export const divertSteps = (v: Verdict) =>
100 `clearance HOLD: ${v.reasons.join('; ')}. To keep this machine responsive, ${DIVERT}: ` +
101 `start a cloud session at claude.ai/code (or the app's cloud option), or open the project on another machine. ` +
102 `Close idle sessions here to free memory.`
103
104/** The snapshot with this session's subagent count as it is now, not as last sampled (spawns between samples count at once). */
105export const withOwnAgents = (s: Snapshot, sessionId: string, agentsInFlight: number): Snapshot => ({
106 ...s,
107 sessions: s.sessions.map(r => (r.sessionId === sessionId ? { ...r, agentsInFlight } : r)),
108})
109hooks/forecast.ts 147 lines1// Step 5: forecasts from what this machine was seen to use (design.md § Step 5).
2// Pure: no `$` here.
3//
4// No assumed sizes. A forecast is an upper confidence bound on a high quantile
5// of observed costs, distribution-free (order statistics), so it needs no model
6// of the distribution and no safety margin: fewer observations give a looser,
7// higher bound by construction. With too few for a bound, the largest observed;
8// with none, a stand-in the caller observed (never a constant).
9//
10// The two numbers below are policy, not sizes: how high a quantile to cover and
11// how sure to be of covering it.
12
13/** A finished subagent: how much its session's process tree grew while it ran. */
14/**
15 * Record version: 2 from the sampler that checks process start times. Records
16 * without it were measured when a reused pid could adopt an orphaned tree (one
17 * session read 8.4 GB of children), so they are not used.
18 */
19export const RECORD_VERSION = 2
20
21export type AgentRecord = {
22 kind: 'agent'
23 v: typeof RECORD_VERSION
24 /** When it stopped. */
25 t: number
26 type: string
27 durationMs: number
28 /** Snapshots seen while it ran; 0 means it was too short to measure and the record is not used. */
29 samples: number
30 /** Peak of the session tree (self + children) above its value at the start. */
31 growthMB: number
32 /** Most subagents of this session in flight at once while it ran. */
33 concurrent: number
34 /** Its share: growth split evenly among the subagents that overlapped it. */
35 costMB: number
36}
37
38/** A session's peaks: what a session grows to. One per session, rewritten as it grows. */
39export type SessionRecord = { kind: 'session'; v: typeof RECORD_VERSION; t: number; sessionId: string; peakSelfMB: number; peakChildMB: number; samples: number }
40
41export type HistoryRecord = AgentRecord | SessionRecord
42
43export const POLICY = {
44 /** Cover this share of runs... */
45 quantile: 0.9,
46 /** ...with this confidence. */
47 confidence: 0.9,
48 /** Records older than this are dropped when loading: a machine and its tools change. */
49 maxAgeMs: 60 * 24 * 3600_000,
50} as const
51
52export type Forecast = {
53 mb: number
54 /** Observations it rests on. */
55 n: number
56 /**
57 * `bound`: the quantile's upper confidence bound; `max`: too few for a bound,
58 * the largest seen; `standIn`: nothing recorded, the caller's observed stand-in.
59 */
60 method: 'bound' | 'max' | 'standIn'
61 /** For subagents: whether the records are this type's own or every type's. */
62 scope?: 'type' | 'pool'
63}
64
65/** P(X ≤ k) for X ~ Binomial(n, p). */
66const binomCdf = (k: number, n: number, p: number) => {
67 let term = Math.pow(1 - p, n)
68 let sum = term
69 for (let i = 1; i <= k; i++) {
70 term *= ((n - i + 1) / i) * (p / (1 - p))
71 sum += term
72 }
73 return sum
74}
75
76/**
77 * The distribution-free upper confidence bound on the `q` quantile: the
78 * smallest order statistic X(k) with P(X(k) ≥ x_q) ≥ `c`, that is
79 * P(Binomial(n, q) ≤ k − 1) ≥ c. Undefined when n is too small for any k
80 * (n < ln(1 − c) / ln(q), 22 at 0.9 / 0.9).
81 */
82export const quantileUpperBound = (values: readonly number[], q: number = POLICY.quantile, c: number = POLICY.confidence): number | undefined => {
83 const x = [...values].sort((a, b) => a - b)
84 for (let k = 1; k <= x.length; k++) if (binomCdf(k - 1, x.length, q) >= c) return x[k - 1]
85 return undefined
86}
87
88/** How many observations a bound needs under the policy. */
89export const needed = (q: number = POLICY.quantile, c: number = POLICY.confidence) => Math.ceil(Math.log(1 - c) / Math.log(q))
90
91const usable = (r: HistoryRecord, now: number) => now - r.t <= POLICY.maxAgeMs && r.samples > 0
92
93const estimate = (values: readonly number[], standInMB: number): Omit<Forecast, 'scope'> => {
94 const bound = quantileUpperBound(values)
95 if (bound !== undefined) return { mb: Math.max(1, Math.round(bound)), n: values.length, method: 'bound' }
96 if (values.length > 0) return { mb: Math.max(1, Math.round(Math.max(...values))), n: values.length, method: 'max' }
97 return { mb: Math.max(1, Math.round(standInMB)), n: 0, method: 'standIn' }
98}
99
100/**
101 * One more subagent of `type`: its own runs once they support a bound, else
102 * every type's runs. `standInMB` is used only before any run was measured.
103 */
104export const forecastAgent = (records: readonly HistoryRecord[], type: string, standInMB: number, now: number): Forecast => {
105 const agents = records.filter((r): r is AgentRecord => r.kind === 'agent' && usable(r, now))
106 const own = agents.filter(r => r.type === type).map(r => r.costMB)
107 if (quantileUpperBound(own) !== undefined) return { ...estimate(own, standInMB), scope: 'type' }
108 return { ...estimate(agents.map(r => r.costMB), standInMB), scope: 'pool' }
109}
110
111/**
112 * One more session: recorded session peaks (self + children) and every live
113 * session's size now, which is a lower bound of its own peak and is observed
114 * from the first sample.
115 */
116export const forecastSession = (records: readonly HistoryRecord[], liveMB: readonly number[], now: number): Forecast => {
117 const recorded = records.filter((r): r is SessionRecord => r.kind === 'session' && usable(r, now)).map(r => r.peakSelfMB + r.peakChildMB)
118 const values = [...recorded, ...liveMB]
119 return estimate(values, values.length ? Math.max(...values) : 0)
120}
121
122/** Reads a JSONL history file; a line that doesn't parse as a record is skipped. */
123export const parseHistory = (text: string): HistoryRecord[] => {
124 const out: HistoryRecord[] = []
125 for (const line of text.split('\n')) {
126 if (!line.trim()) continue
127 try {
128 const r = JSON.parse(line) as Partial<HistoryRecord>
129 const num = (x: unknown) => typeof x === 'number' && Number.isFinite(x)
130 if (r.v !== RECORD_VERSION) continue
131 if (r.kind === 'agent' && num(r.t) && typeof r.type === 'string' && num(r.costMB) && num(r.samples)) out.push(r as AgentRecord)
132 else if (r.kind === 'session' && num(r.t) && num(r.peakSelfMB) && num(r.peakChildMB) && num(r.samples)) out.push(r as SessionRecord)
133 } catch {
134 // a torn line from a crash mid-write
135 }
136 }
137 return out
138}
139
140/** Says what a forecast rests on, for the headroom tool and the hover card. */
141export const describe = (f: Forecast) =>
142 f.method === 'bound'
143 ? `p${POLICY.quantile * 100} bound at ${POLICY.confidence * 100}% confidence over ${f.n} ${f.scope === 'type' ? 'runs of this type' : 'runs'}`
144 : f.method === 'max'
145 ? `largest of ${f.n} observed (a bound needs ${needed()})`
146 : 'nothing measured yet: the largest growth step seen in a live session'
147hooks/history.ts 186 lines1import { POLICY, RECORD_VERSION, parseHistory, type AgentRecord, type HistoryRecord, type SessionRecord } from './forecast.ts'
2import type { Io } from './io.ts'
3import type { Paths } from './paths.ts'
4
5// Step 5: history. Each session writes its own file,
6// history/<yyyy-mm>/<sessionId>.jsonl (one writer per file, as everywhere under
7// ~/.claude/clearance), and reads every session's to learn the forecasts.
8
9/** What the tracker reads from this session's snapshot row each sample. */
10export type Tree = { selfMB: number; childMB: number }
11
12type Open = { type: string; t0: number; base: number; peak: number; samples: number; concurrent: number }
13
14/**
15 * Follows this session's subagents through the samples: a subagent's growth is
16 * the tree's peak while it ran above the tree when it started. Pure but stateful.
17 */
18export const startTracker = () => {
19 const open = new Map<string, Open>()
20 let last: { t: number; tree: Tree } | undefined
21 let peakSelf = 0
22 let peakChild = 0
23 let sessionSamples = 0
24 const size = (tree: Tree) => tree.selfMB + tree.childMB
25
26 return {
27 /** One snapshot of this session's row; a repeat of the same sample is ignored. */
28 sample(tree: Tree, t: number) {
29 if (last && last.t === t) return
30 last = { t, tree }
31 sessionSamples++
32 peakSelf = Math.max(peakSelf, tree.selfMB)
33 peakChild = Math.max(peakChild, tree.childMB)
34 for (const a of open.values()) {
35 a.peak = Math.max(a.peak, size(tree))
36 a.samples++
37 a.concurrent = Math.max(a.concurrent, open.size)
38 }
39 },
40 started(agentId: string, type: string, now: number) {
41 const base = last ? size(last.tree) : NaN
42 open.set(agentId, { type, t0: now, base, peak: base, samples: 0, concurrent: open.size + 1 })
43 for (const a of open.values()) a.concurrent = Math.max(a.concurrent, open.size)
44 },
45 /** The finished subagent's record; undefined for one this tracker never saw start. */
46 stopped(agentId: string, now: number): AgentRecord | undefined {
47 const a = open.get(agentId)
48 if (!a) return undefined
49 open.delete(agentId)
50 const measured = Number.isFinite(a.base) ? a.samples : 0
51 const growthMB = measured > 0 ? Math.max(0, a.peak - a.base) : 0
52 return {
53 kind: 'agent',
54 v: RECORD_VERSION,
55 t: now,
56 type: a.type,
57 durationMs: now - a.t0,
58 samples: measured,
59 growthMB,
60 concurrent: a.concurrent,
61 costMB: Math.round(growthMB / Math.max(1, a.concurrent)),
62 }
63 },
64 session(sessionId: string, now: number): SessionRecord | undefined {
65 if (sessionSamples === 0) return undefined
66 return { kind: 'session', v: RECORD_VERSION, t: now, sessionId, peakSelfMB: peakSelf, peakChildMB: peakChild, samples: sessionSamples }
67 },
68 inFlight: () => open.size,
69 }
70}
71
72export type Tracker = ReturnType<typeof startTracker>
73
74/**
75 * The largest growth any live session's tree showed between two samples: the
76 * subagent stand-in before any run was measured, observed rather than assumed.
77 */
78export const startGrowthWatch = () => {
79 const prev = new Map<string, number>()
80 let lastT = -1
81 let maxStepMB = 0
82 return {
83 observe(sessions: readonly { sessionId: string; selfMB: number; childMB: number }[], t: number) {
84 if (t === lastT) return
85 lastT = t
86 for (const r of sessions) {
87 const mb = r.selfMB + r.childMB
88 const before = prev.get(r.sessionId)
89 if (before !== undefined) maxStepMB = Math.max(maxStepMB, mb - before)
90 prev.set(r.sessionId, mb)
91 }
92 },
93 maxStepMB: () => maxStepMB,
94 }
95}
96
97const month = (ms: number) => new Date(ms).toISOString().slice(0, 7)
98
99export const historyFile = (paths: Paths, sessionId: string, startedAt: number) => `${paths.history}\\${month(startedAt)}\\${sessionId}.jsonl`
100
101/** This session's records (agents, then its one session record) as the file's text. */
102export const historyText = (agents: readonly AgentRecord[], session: SessionRecord | undefined) =>
103 [...agents, ...(session ? [session] : [])].map(r => JSON.stringify(r)).join('\n') + '\n'
104
105/**
106 * The store: loads every session's history (the last two months), and rewrites
107 * this session's file when it records. A hot reload reads its own file back,
108 * so nothing it recorded is lost.
109 */
110export const startHistory = async (io: Io, paths: Paths, sessionId: string, startedAt: number) => {
111 const own = historyFile(paths, sessionId, startedAt)
112 let others: HistoryRecord[] = []
113 let agents: AgentRecord[] = []
114 let session: SessionRecord | undefined
115 /** The session record as loaded: what an earlier load of this module had seen. */
116 let loaded: SessionRecord | undefined
117
118 const load = async () => {
119 const now = await io.now()
120 const all: HistoryRecord[] = []
121 let ownRecords: HistoryRecord[] = []
122 let months: string[] = []
123 try {
124 months = (await io.list(paths.history))
125 .filter(d => d.kind === 'dir' && /^\d{4}-\d{2}$/.test(d.name))
126 .map(d => d.name)
127 .sort()
128 .slice(-2)
129 } catch {
130 // no history yet
131 }
132 for (const m of months) {
133 const dir = `${paths.history}\\${m}`
134 let files: { name: string; kind: string }[] = []
135 try {
136 files = await io.list(dir)
137 } catch {
138 continue
139 }
140 for (const f of files) {
141 if (!f.name.endsWith('.jsonl')) continue
142 const path = `${dir}\\${f.name}`
143 try {
144 const records = parseHistory(await io.read(path)).filter(r => now - r.t <= POLICY.maxAgeMs)
145 if (path === own) ownRecords = records
146 else all.push(...records)
147 } catch {
148 // gone between list and read
149 }
150 }
151 }
152 others = all
153 agents = ownRecords.filter((r): r is AgentRecord => r.kind === 'agent')
154 session = ownRecords.find((r): r is SessionRecord => r.kind === 'session')
155 loaded = session
156 }
157
158 const write = async () => {
159 try {
160 await io.write(own, historyText(agents, session))
161 } catch (e) {
162 io.log(`history write: ${String(e)}`)
163 }
164 }
165
166 await load()
167 return {
168 reload: load,
169 records: (): HistoryRecord[] => [...others, ...agents, ...(session ? [session] : [])],
170 addAgent: async (r: AgentRecord) => {
171 agents.push(r)
172 await write()
173 },
174 /** The session's peaks only grow; written when they do. */
175 setSession: async (r: SessionRecord) => {
176 const grew = !session || r.peakSelfMB > session.peakSelfMB || r.peakChildMB > session.peakChildMB
177 session = loaded
178 ? { ...r, peakSelfMB: Math.max(r.peakSelfMB, loaded.peakSelfMB), peakChildMB: Math.max(r.peakChildMB, loaded.peakChildMB), samples: loaded.samples + r.samples }
179 : r
180 if (grew) await write()
181 },
182 }
183}
184
185export type History = Awaited<ReturnType<typeof startHistory>>
186hooks/checks.ts 143 lines1import type { Io } from './io.ts'
2import type { ContainerSample, Snapshot } from './snapshot.ts'
3
4// Step 6: container attribution and the convention checks (design.md §
5// Convention checks, 0001). Read-only: they report and suggest a fix and never
6// edit project files. Pure except `runChecks`, which reads through `Io`.
7
8const norm = (p: string) => p.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase()
9const within = (inner: string, outer: string) => inner === outer || inner.startsWith(outer + '\\')
10
11/**
12 * Each container to the session whose folder holds its compose working_dir (or
13 * sits inside it), the longest folder winning; no label or no match is unattributed.
14 */
15export const attributeContainers = (s: Snapshot) => {
16 const bySession = new Map<string, ContainerSample[]>()
17 const unattributed: ContainerSample[] = []
18 for (const c of s.containers ?? []) {
19 const dir = c.workingDir ? norm(c.workingDir) : undefined
20 let best: { id: string; len: number } | undefined
21 if (dir)
22 for (const r of s.sessions) {
23 const cwd = norm(r.cwd)
24 if ((within(dir, cwd) || within(cwd, dir)) && (!best || cwd.length > best.len)) best = { id: r.sessionId, len: cwd.length }
25 }
26 if (best) bySession.set(best.id, [...(bySession.get(best.id) ?? []), c])
27 else unattributed.push(c)
28 }
29 return { bySession, unattributed }
30}
31
32export type Finding = { check: string; where: string; detail: string; fix: string }
33
34/** Supabase's `project_id` left as "supabase", or one id in two folders: their stacks collide (container names, ports, volumes). */
35export const supabaseFindings = (configs: readonly { dir: string; projectId: string }[]): Finding[] => {
36 const out: Finding[] = []
37 for (const c of configs)
38 if (c.projectId === 'supabase')
39 out.push({ check: 'supabase project_id', where: c.dir, detail: 'project_id is the default "supabase"', fix: 'set a unique project_id in supabase/config.toml' })
40 const byId = new Map<string, string[]>()
41 for (const c of configs) byId.set(c.projectId, [...(byId.get(c.projectId) ?? []), c.dir])
42 for (const [id, dirs] of byId)
43 if (dirs.length > 1 && id !== 'supabase')
44 out.push({ check: 'supabase project_id', where: dirs.join(', '), detail: `project_id "${id}" in ${dirs.length} folders`, fix: 'give each folder its own project_id' })
45 return out
46}
47
48/** A running compose project with no working_dir label can't be attributed to a session. */
49export const unlabeledFindings = (containers: readonly ContainerSample[]): Finding[] => {
50 const projects = new Map<string, number>()
51 for (const c of containers) if (c.project && !c.workingDir) projects.set(c.project, (projects.get(c.project) ?? 0) + 1)
52 return [...projects].map(([project, n]) => ({
53 check: 'unattributable stack',
54 where: `compose project "${project}"`,
55 detail: `${n} running containers without a com.docker.compose.project.working_dir label`,
56 fix: 'start it with docker compose from its folder (the Supabase CLI omits the label: run it from the repo so the stack name says whose it is)',
57 }))
58}
59
60/** `"5432:5432"` in a compose file: a host port fixed in the file, so two worktrees of it can't run at once. */
61export const hardPortFindings = (files: readonly { path: string; text: string }[]): Finding[] => {
62 const out: Finding[] = []
63 for (const f of files) {
64 const ports = new Set<string>()
65 for (const m of f.text.matchAll(/^\s*-\s*["']?(?:[\d.]+:)?(\d{2,5}):\d{2,5}(?:\/\w+)?["']?\s*$/gm)) ports.add(m[1]!)
66 if (ports.size)
67 out.push({
68 check: 'hard-coded host ports',
69 where: f.path,
70 detail: `host ports ${[...ports].join(', ')}`,
71 fix: 'use env indirection ("${DB_PORT:-5432}:5432") so each worktree can pick its own',
72 })
73 }
74 return out
75}
76
77/** Host ports a container publishes, from `docker ps`' Ports column. */
78export const hostPorts = (ports: string | undefined) => [...new Set([...(ports ?? '').matchAll(/:(\d+)->/g)].map(m => m[1]!))]
79
80/** Two running containers from different folders or projects on one host port, or one project name from two folders. */
81export const collisionFindings = (containers: readonly ContainerSample[]): Finding[] => {
82 const out: Finding[] = []
83 const byPort = new Map<string, ContainerSample[]>()
84 for (const c of containers) for (const p of hostPorts(c.ports)) byPort.set(p, [...(byPort.get(p) ?? []), c])
85 for (const [port, cs] of byPort) {
86 const owners = new Set(cs.map(c => c.workingDir ?? c.project ?? c.name))
87 if (owners.size > 1)
88 out.push({ check: 'port collision', where: `host port ${port}`, detail: cs.map(c => c.name).join(', '), fix: 'give one of them another host port' })
89 }
90 const dirsByProject = new Map<string, Set<string>>()
91 for (const c of containers) if (c.project && c.workingDir) dirsByProject.set(c.project, (dirsByProject.get(c.project) ?? new Set()).add(norm(c.workingDir)))
92 for (const [project, dirs] of dirsByProject)
93 if (dirs.size > 1)
94 out.push({ check: 'compose project name', where: `project "${project}"`, detail: `running from ${[...dirs].join(', ')}`, fix: 'set a distinct COMPOSE_PROJECT_NAME per worktree' })
95 return out
96}
97
98const COMPOSE = /^(docker-)?compose(\.[\w-]+)?\.ya?ml$/i
99
100/** Reads each session folder's supabase/config.toml and compose files (the folder and one level down), then runs every check. */
101export const runChecks = async (io: Io, s: Snapshot): Promise<Finding[]> => {
102 const dirs = [...new Set(s.sessions.map(r => r.cwd))]
103 const configs: { dir: string; projectId: string }[] = []
104 const compose: { path: string; text: string }[] = []
105 const tryRead = async (path: string) => {
106 try {
107 return await io.read(path)
108 } catch {
109 return undefined
110 }
111 }
112 const tryList = async (dir: string) => {
113 try {
114 return await io.list(dir)
115 } catch {
116 return []
117 }
118 }
119 for (const dir of dirs) {
120 const toml = await tryRead(`${dir}\\supabase\\config.toml`)
121 const id = toml && /^\s*project_id\s*=\s*"([^"]*)"/m.exec(toml)?.[1]
122 if (id !== undefined && id !== null && toml) configs.push({ dir, projectId: id })
123 const top = await tryList(dir)
124 const candidates = top.filter(e => e.kind === 'file' && COMPOSE.test(e.name)).map(e => `${dir}\\${e.name}`)
125 for (const sub of top.filter(e => e.kind === 'dir' && !e.name.startsWith('.') && e.name !== 'node_modules'))
126 for (const e of await tryList(`${dir}\\${sub.name}`)) if (e.kind === 'file' && COMPOSE.test(e.name)) candidates.push(`${dir}\\${sub.name}\\${e.name}`)
127 for (const path of candidates) {
128 const text = await tryRead(path)
129 if (text) compose.push({ path, text })
130 }
131 }
132 const containers = s.containers ?? []
133 return [...supabaseFindings(configs), ...unlabeledFindings(containers), ...hardPortFindings(compose), ...collisionFindings(containers)]
134}
135
136export const checksReport = (findings: readonly Finding[], containersSeen: boolean) => {
137 const lines = ['clearance convention checks (read-only)']
138 if (!containersSeen) lines.push('(no container sample yet: Docker isn’t running, or the first docker read is pending)')
139 if (findings.length === 0) lines.push('no findings')
140 for (const f of findings) lines.push(`- ${f.check}: ${f.where}: ${f.detail}. Fix: ${f.fix}.`)
141 return lines.join('\n')
142}
143hooks/pressure.ts 157 lines1import { needed, POLICY } from './forecast.ts'
2
3// Step 7: the floor learned from paging pressure, and THRASH. Pure: no `$` here.
4//
5// The sampler reads hard page reads per second (\Memory\Pages Input/sec) with
6// every sample. The scribe folds each sample into a histogram: available memory
7// in bins of 1% of RAM, paging in power-of-two buckets. From it:
8//
9// - calm: the paging seen while available memory is at or above its median;
10// its p90 is this machine's ordinary paging, file reads and launches included;
11// - pressured bin: a bin below the median whose median paging is above the calm
12// p90, that is, a typical sample there pages more than 90% of calm samples;
13// - floor: the top edge of the highest pressured bin with enough samples to
14// judge (`needed()`, the same 22 a forecast bound needs). With no pressured
15// bin yet, the policy floor (5% of RAM) stands, and the basis says so.
16
17/** Paging buckets: 0, then [2^(i-1), 2^i) pages/s, up to 2^19 and above. */
18export const BUCKETS = 21
19
20export const bucketOf = (pagesPerSec: number) => (pagesPerSec < 1 ? 0 : Math.min(BUCKETS - 1, 1 + Math.floor(Math.log2(pagesPerSec))))
21
22/** A bucket's upper edge in pages/s: what a quantile landing in it is reported as. */
23export const bucketTop = (b: number) => (b === 0 ? 1 : Math.pow(2, b))
24
25export type Pressure = {
26 schema: 1
27 /** Bin width in MB: 1% of total RAM when the histogram started. */
28 binMB: number
29 totalMB: number
30 /** Per available-memory bin (index = floor(availableMB / binMB)), counts per paging bucket. */
31 bins: Record<string, number[]>
32 /** Samples folded in. */
33 n: number
34 /** Last sample time folded in: a sample is folded once. */
35 t: number
36}
37
38/** Halve every count past this many samples (about 2.9 days at 5 s): old evidence fades and the file stays small. */
39export const MAX_SAMPLES = 50_000
40
41export const emptyPressure = (totalMB: number): Pressure => ({ schema: 1, binMB: Math.max(1, Math.round(totalMB / 100)), totalMB, bins: {}, n: 0, t: 0 })
42
43export const parsePressure = (text: string): Pressure | undefined => {
44 try {
45 const p = JSON.parse(text) as Partial<Pressure>
46 if (p.schema !== 1 || typeof p.binMB !== 'number' || typeof p.bins !== 'object' || p.bins === null) return undefined
47 return { schema: 1, binMB: p.binMB, totalMB: p.totalMB ?? 0, bins: p.bins as Record<string, number[]>, n: p.n ?? 0, t: p.t ?? 0 }
48 } catch {
49 return undefined
50 }
51}
52
53/** One sample folded in (a new object); a repeat of the last sample's time is ignored. */
54export const fold = (p: Pressure, availableMB: number, pagesPerSec: number, t: number): Pressure => {
55 if (t <= p.t) return p
56 const bins: Record<string, number[]> = {}
57 const halve = p.n + 1 > MAX_SAMPLES
58 for (const [k, counts] of Object.entries(p.bins)) bins[k] = halve ? counts.map(c => c / 2) : [...counts]
59 const key = String(Math.floor(availableMB / p.binMB))
60 const row = bins[key] ?? Array<number>(BUCKETS).fill(0)
61 row[bucketOf(pagesPerSec)]! += 1
62 bins[key] = row
63 return { ...p, bins, n: (halve ? p.n / 2 : p.n) + 1, t }
64}
65
66/** The bucket where cumulative weight reaches `q` of the total, or -1 for no weight. */
67const quantileBucket = (counts: readonly number[], q: number) => {
68 const total = counts.reduce((a, b) => a + b, 0)
69 if (total <= 0) return -1
70 let acc = 0
71 for (let b = 0; b < counts.length; b++) {
72 acc += counts[b]!
73 if (acc >= q * total - 1e-9) return b
74 }
75 return counts.length - 1
76}
77
78export type Floor = {
79 /** The learned floor in MB, or undefined when no pressure has been seen. */
80 mb: number | undefined
81 /** Ordinary paging: the calm p90, pages/s (bucket top); undefined without enough calm samples. */
82 calmP90: number | undefined
83 /** The available level, MB, at and above which paging is calm: the median sample's bin. */
84 calmFromMB: number | undefined
85 n: number
86 basis: string
87}
88
89const gb = (mb: number) => (mb / 1024).toFixed(1)
90
91export const learnFloor = (p: Pressure): Floor => {
92 const keys = Object.keys(p.bins)
93 .map(Number)
94 .sort((a, b) => a - b)
95 const weight = (k: number) => p.bins[String(k)]!.reduce((a, b) => a + b, 0)
96 const total = keys.reduce((s, k) => s + weight(k), 0)
97 if (total < needed() * 2) return { mb: undefined, calmP90: undefined, calmFromMB: undefined, n: total, basis: `learning: ${Math.round(total)} samples` }
98
99 // The median sample's bin splits calm (at or above) from the candidates below.
100 let acc = 0
101 let medianKey = keys[keys.length - 1]!
102 for (const k of keys) {
103 acc += weight(k)
104 if (acc >= total / 2) {
105 medianKey = k
106 break
107 }
108 }
109 const calm = Array<number>(BUCKETS).fill(0)
110 for (const k of keys) if (k >= medianKey) p.bins[String(k)]!.forEach((c, b) => (calm[b]! += c))
111 const calmBucket = quantileBucket(calm, POLICY.quantile)
112 const calmP90 = bucketTop(calmBucket)
113 const calmFromMB = medianKey * p.binMB
114
115 let pressuredTop: number | undefined
116 for (const k of keys) {
117 if (k >= medianKey || weight(k) < needed()) continue
118 if (quantileBucket(p.bins[String(k)]!, 0.5) > calmBucket) pressuredTop = (k + 1) * p.binMB
119 }
120 if (pressuredTop === undefined)
121 return { mb: undefined, calmP90, calmFromMB, n: total, basis: `no pressure seen below ${gb(calmFromMB)} GB (calm paging ≤ ${calmP90}/s)` }
122 return {
123 mb: pressuredTop,
124 calmP90,
125 calmFromMB,
126 n: total,
127 basis: `paging above ${calmP90}/s (calm p90) is typical below ${gb(pressuredTop)} GB`,
128 }
129}
130
131/** A sample is pressured when it pages above the calm p90. */
132export const isPressured = (pagesPerSec: number | undefined, f: Floor) => pagesPerSec !== undefined && f.calmP90 !== undefined && pagesPerSec > f.calmP90
133
134/** Samples in a row a THRASH needs, as hysteresis does for HOLD. */
135export const THRASH_RUN = 3
136/** No session progressed for this long (design: THRASH's stall). */
137export const STALL_MS = 5 * 60_000
138
139/**
140 * The stall clock: the latest progress among busy sessions, or undefined when
141 * none is busy. A session waiting for its person is idle, not stalled.
142 */
143export const lastBusyProgress = (sessions: readonly { busy?: boolean; lastProgressAt?: number }[]) => {
144 const busy = sessions.filter(r => r.busy === true && r.lastProgressAt !== undefined)
145 return busy.length ? Math.max(...busy.map(r => r.lastProgressAt!)) : undefined
146}
147
148/**
149 * THRASH: the machine is paging hard below its floor for THRASH_RUN samples in
150 * a row, or (the design's rule) available memory is under half the floor while
151 * no busy session has made progress for STALL_MS (`lastProgressAt` from
152 * `lastBusyProgress`; undefined, nobody is working, never stalls).
153 */
154export const isThrash = (args: { pressuredRun: number; availableMB: number; floorMB: number; lastProgressAt: number | undefined; now: number }) =>
155 (args.pressuredRun >= THRASH_RUN && args.availableMB < args.floorMB) ||
156 (args.availableMB < args.floorMB / 2 && args.lastProgressAt !== undefined && args.now - args.lastProgressAt > STALL_MS)
157hooks/gate.ts 131 lines1import type { ClearanceBand } from '../types'
2import type { Shown, Snapshot } from './snapshot.ts'
3
4// The gate (0002, design.md § The gate): a memory floor and a commit ceiling,
5// plus count ceilings for sessions and subagents. Pure: no `$` here.
6
7export type GateOptions = {
8 /** The available-RAM floor; 0 means auto, `AUTO_FLOOR_PCT` of total RAM. */
9 minFreeGB: number
10 maxCommitPct: number
11 maxSessions: number
12 maxAgents: number
13 /** What a new session costs; 0 in the options means learned (register.tsx fills it from history and the live sessions). */
14 sessionBaselineGB: number
15 /** The floor learned from paging pressure (step 7), MB; used while `minFreeGB` is 0 (auto). Not an option. */
16 learnedFloorMB?: number
17}
18
19export const DEFAULTS: GateOptions = { minFreeGB: 0, maxCommitPct: 90, maxSessions: 6, maxAgents: 8, sessionBaselineGB: 0 }
20
21/**
22 * The auto floor, as a share of total RAM (decided 2026-10-04): a fixed 1.5 GB
23 * held a 16 GB machine that runs at 1–2 GB free almost always, while Windows
24 * compresses and pages long before it stalls; THRASH (step 7) watches the stall.
25 */
26export const AUTO_FLOOR_PCT = 5
27
28/** The available-RAM floor for this machine, in MB. */
29export const floorMB = (o: GateOptions, totalMB: number) =>
30 o.minFreeGB > 0 ? o.minFreeGB * 1024 : o.learnedFloorMB !== undefined ? o.learnedFloorMB : Math.round((totalMB * AUTO_FLOOR_PCT) / 100)
31
32/** `register(on, options)` values, with a default for any field that is missing or not a positive number. */
33export const gateOptions = (raw: Readonly<Record<string, unknown>>): GateOptions => {
34 const pick = (k: 'maxCommitPct' | 'maxSessions' | 'maxAgents') => {
35 const v = raw[k]
36 return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : DEFAULTS[k]
37 }
38 const floor = raw.minFreeGB
39 return {
40 minFreeGB: typeof floor === 'number' && Number.isFinite(floor) && floor > 0 ? floor : 0,
41 maxCommitPct: Math.min(100, pick('maxCommitPct')),
42 maxSessions: Math.floor(pick('maxSessions')),
43 maxAgents: Math.floor(pick('maxAgents')),
44 sessionBaselineGB: typeof raw.sessionBaselineGB === 'number' && raw.sessionBaselineGB > 0 ? raw.sessionBaselineGB : 0,
45 }
46}
47
48export type GateState = 'CLEARED' | 'HOLD' | 'THRASH'
49
50/** What is asked for: a new session (the baseline), or a subagent with its forecast. */
51export type Ask = { kind: 'session' } | { kind: 'agent'; mb: number }
52
53export type Verdict = {
54 state: GateState
55 /** Memory that can still be admitted: the tighter of the RAM floor and the commit ceiling, reservations taken off. */
56 headroomMB: number
57 /** How many more of the asked kind fit now. */
58 fits: number
59 /** Why it holds; empty when cleared. */
60 reasons: string[]
61}
62
63const gb = (mb: number) => (mb / 1024).toFixed(1)
64
65/** Machine-wide counts and reservations, summed from the snapshot's session rows. */
66export const census = (s: Snapshot) => {
67 let agents = 0
68 let reservedMB = 0
69 for (const row of s.sessions) {
70 agents += row.agentsInFlight ?? 0
71 reservedMB += row.reservedMB ?? 0
72 }
73 return { sessions: s.sessions.length, agents, reservedMB }
74}
75
76/**
77 * Whether `ask` fits. `extraReservedMB` is this session's own reservations not
78 * yet in the snapshot (recorded after the last sample).
79 */
80export const gate = (s: Snapshot, o: GateOptions, ask: Ask, extraReservedMB = 0): Verdict => {
81 const m = s.machine
82 const c = census(s)
83 const reserved = c.reservedMB + extraReservedMB
84 const floor = floorMB(o, m.totalMB)
85 const memRoom = m.availableMB - reserved - floor
86 const commitRoom = (m.commitLimitMB * o.maxCommitPct) / 100 - m.commitMB - reserved
87 const headroomMB = Math.max(0, Math.round(Math.min(memRoom, commitRoom)))
88 const cost = ask.kind === 'session' ? o.sessionBaselineGB * 1024 : Math.max(1, ask.mb)
89 const [count, ceiling, noun] = ask.kind === 'session' ? [c.sessions, o.maxSessions, 'sessions'] : [c.agents, o.maxAgents, 'subagents']
90
91 const reasons: string[] = []
92 if (memRoom < cost) reasons.push(`available ${gb(m.availableMB - reserved)} GB, floor ${gb(floor)} GB + ${gb(cost)} GB ask`)
93 if (commitRoom < cost)
94 reasons.push(`commit ${Math.round(((m.commitMB + reserved) / m.commitLimitMB) * 100)}% of ${gb(m.commitLimitMB)} GB, ceiling ${o.maxCommitPct}%`)
95 if (count >= ceiling) reasons.push(`${count} ${noun}, ceiling ${ceiling}`)
96
97 const fits = Math.max(0, Math.min(Math.floor(headroomMB / cost), ceiling - count))
98 return { state: reasons.length === 0 ? 'CLEARED' : 'HOLD', headroomMB, fits, reasons }
99}
100
101/** Hysteresis: a state must show in `need` consecutive samples before it replaces the shown one, so the status line doesn't flicker. */
102export type Settled = { shown: GateState; pending: GateState | undefined; streak: number }
103
104export const settle = (prev: Settled | undefined, next: GateState, need = 2): Settled => {
105 if (!prev) return { shown: next, pending: undefined, streak: 0 }
106 if (next === prev.shown) return { shown: prev.shown, pending: undefined, streak: 0 }
107 const streak = prev.pending === next ? prev.streak + 1 : 1
108 return streak >= need ? { shown: next, pending: undefined, streak: 0 } : { shown: prev.shown, pending: next, streak }
109}
110
111/** The gate as one session shows it: settled per sample, never per tick (ticks reread the same sample). */
112export type GateView = { t: number; settled: Settled; shown: Shown; band: ClearanceBand | null }
113
114/**
115 * `thrash` is step 7's verdict for this sample (pressure.ts), with its reason:
116 * it overrides the memory gate and settles with the same hysteresis.
117 */
118export const advance = (prev: GateView | undefined, s: Snapshot, o: GateOptions, extraReservedMB = 0, thrash?: string): GateView => {
119 const v = gate(s, o, { kind: 'session' }, extraReservedMB)
120 const state: GateState = thrash ? 'THRASH' : v.state
121 const settled = prev && prev.t === s.t ? prev.settled : settle(prev?.settled, state)
122 const isHeld = settled.shown !== 'CLEARED'
123 const reasons = settled.shown === 'THRASH' ? [thrash ?? 'THRASH: clearing; waiting for one more sample'] : v.reasons
124 return {
125 t: s.t,
126 settled,
127 shown: { state: settled.shown, headroomMB: v.headroomMB, fits: isHeld ? 0 : v.fits },
128 band: isHeld ? { state: 'HOLD', headroomMB: v.headroomMB, reasons: reasons.length > 0 ? reasons : ['clearing; waiting for one more sample'] } : null,
129 }
130}
131hooks/io.ts 15 lines1// The modules' reach, built by register.ts from `$` (the validator follows `$`
2// only within one file). Tests hand in a fake.
3export type Io = {
4 now: () => Promise<number>
5 sessionId: () => Promise<string>
6 list: (dir: string) => Promise<{ name: string; kind: string }[]>
7 read: (path: string) => Promise<string>
8 write: (path: string, text: string) => Promise<void>
9 mtime: (path: string) => Promise<number>
10 run: (argv: string[], timeoutMs: number) => Promise<{ exitCode: number; stdout: string; stderr: string }>
11 spawn: (argv: string[]) => AsyncGenerator<{ stream: 'stdout' | 'stderr'; text: string }, unknown>
12 every: (ms: number, fn: () => void) => { cancel: () => void }
13 log: (text: string) => void
14}
15hooks/pane.ts 112 lines1import type { ClearancePane, ClearancePaneSession } from '../types'
2import { attributeContainers } from './checks.ts'
3import { census, floorMB, type GateOptions, type GateView } from './gate.ts'
4import type { Snapshot } from './snapshot.ts'
5
6// The /clearance pane (design.md § UI): the model built from each sample, and
7// its lines laid out to the pane's width. Pure: no `$` here. Desktop-app and
8// Docker rows, unattributed containers and the convention checks join in step 6.
9
10const gb = (mb: number) => (mb / 1024).toFixed(1)
11
12/** The last two segments of a folder: `Code\clearance`. */
13export const shortPath = (path: string) => path.split(/[\\/]+/).filter(Boolean).slice(-2).join('\\')
14
15export const paneModel = (s: Snapshot, view: GateView, o: GateOptions, me: string, isScribe: boolean, now: number, floorBasis = ''): ClearancePane => {
16 const c = census(s)
17 const { bySession, unattributed } = attributeContainers(s)
18 const others: string[] = []
19 if (s.machine.pagesInPerSec !== undefined) others.push(`paging ${Math.round(s.machine.pagesInPerSec)} pages/s · floor ${floorBasis || 'policy'}`)
20 if (s.desktop) others.push(`desktop app ${gb(s.desktop.privateMB)} GB (${s.desktop.procs} processes)`)
21 if (s.dockerVm) others.push(`WSL/Docker VM ${gb(s.dockerVm.privateMB)} GB · containers ${gb(s.dockerVm.containersMB)} GB`)
22 for (const ctr of unattributed) others.push(` unattributed container ${ctr.name} (${ctr.project ?? 'no project'}) ${gb(ctr.memMB)} GB`)
23 const sessions: ClearancePaneSession[] = s.sessions
24 .map(r => {
25 const top = r.topChildren[0]
26 return {
27 id: r.sessionId.slice(0, 8),
28 where: shortPath(r.cwd),
29 selfMB: r.selfMB,
30 childMB: r.childMB,
31 children: r.children,
32 agents: r.agentsInFlight ?? null,
33 progressAgoS: r.lastProgressAt ? Math.max(0, Math.round((now - r.lastProgressAt) / 1000)) : null,
34 top: [top ? `${top.name} ${gb(top.privateMB)}` : '', ...(bySession.get(r.sessionId) ?? []).map(ctr => `${ctr.name} ${gb(ctr.memMB)}`)].filter(Boolean).join(', '),
35 isSelf: r.sessionId === me,
36 }
37 })
38 .sort((a, b) => b.selfMB + b.childMB - (a.selfMB + a.childMB))
39 return {
40 t: s.t,
41 epoch: s.epoch,
42 isScribe,
43 state: view.shown.state,
44 headroomMB: view.shown.headroomMB,
45 fits: view.shown.fits,
46 reasons: view.band?.reasons ?? [],
47 machine: s.machine,
48 limits: { minFreeGB: Math.round(floorMB(o, s.machine.totalMB) / 102.4) / 10, maxCommitPct: o.maxCommitPct, maxSessions: o.maxSessions, maxAgents: o.maxAgents },
49 agents: c.agents,
50 reservedMB: c.reservedMB,
51 sessions,
52 others,
53 }
54}
55
56export type Tone = 'plain' | 'dim' | 'ok' | 'warn' | 'head'
57export type PaneLine = { text: string; tone: Tone }
58
59const fit = (text: string, width: number) => (text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text.padEnd(width))
60const right = (text: string, width: number) => (text.length > width ? text.slice(0, width) : text.padStart(width))
61
62const ago = (s: number | null) => (s === null ? '-' : s < 60 ? `${s} s` : s < 3600 ? `${Math.round(s / 60)} min` : `${Math.round(s / 3600)} h`)
63
64/** The pane's lines for a body `columns` wide. */
65export const paneLines = (m: ClearancePane | null, columns: number, now: number): PaneLine[] => {
66 if (!m) return [{ text: 'Waiting for the first machine sample…', tone: 'dim' }]
67 const w = Math.max(40, columns)
68 const out: PaneLine[] = []
69 const held = m.state !== 'CLEARED'
70 out.push({
71 text: m.state === 'THRASH' ? `THRASH · every spawn refused` : held ? `HOLD · headroom ${gb(m.headroomMB)} GB` : `CLEARED · headroom ${gb(m.headroomMB)} GB · ${m.fits} more session${m.fits === 1 ? '' : 's'}`,
72 tone: held ? 'warn' : 'ok',
73 })
74 for (const r of m.reasons) out.push({ text: ` ${r}`, tone: 'warn' })
75 const mm = m.machine
76 out.push({
77 text: `available ${gb(mm.availableMB)} of ${gb(mm.totalMB)} GB (floor ${m.limits.minFreeGB}) · commit ${gb(mm.commitMB)}/${gb(mm.commitLimitMB)} GB (ceiling ${m.limits.maxCommitPct}%)`,
78 tone: 'plain',
79 })
80 out.push({
81 text: `sessions ${m.sessions.length}/${m.limits.maxSessions} · subagents ${m.agents}/${m.limits.maxAgents} · reserved ${gb(m.reservedMB)} GB · sampled ${Math.max(0, Math.round((now - m.t) / 1000))} s ago · epoch ${m.epoch}${m.isScribe ? ' (this session is scribe)' : ''}`,
82 tone: 'dim',
83 })
84 out.push({ text: '', tone: 'plain' })
85
86 // session(9) self(7) children(12) agents(7) progress(9) = 44, then where and top share the rest.
87 const rest = Math.max(10, w - 44 - 2)
88 const whereW = Math.min(28, Math.ceil(rest / 2))
89 const topW = Math.max(0, rest - whereW)
90 const row = (id: string, where: string, self: string, kids: string, agents: string, progress: string, top: string) =>
91 `${fit(id, 9)}${fit(where, whereW)} ${right(self, 6)} ${right(kids, 11)} ${right(agents, 6)} ${right(progress, 8)} ${fit(top, topW)}`.trimEnd()
92 out.push({ text: row('session', 'where', 'self', 'children', 'agents', 'progress', 'largest child'), tone: 'head' })
93 for (const s of m.sessions) {
94 out.push({
95 text: row(
96 `${s.id}${s.isSelf ? '*' : ''}`,
97 s.where,
98 gb(s.selfMB),
99 `${gb(s.childMB)} (${s.children})`,
100 s.agents === null ? '-' : String(s.agents),
101 ago(s.progressAgoS),
102 s.top,
103 ),
104 tone: s.isSelf ? 'plain' : 'dim',
105 })
106 }
107 out.push({ text: '', tone: 'plain' })
108 for (const line of m.others ?? []) out.push({ text: fit(line, w).trimEnd(), tone: 'dim' })
109 out.push({ text: fit('GB, private bytes. * this session. "-": a session without clearance. /clearance check: the convention checks.', w).trimEnd(), tone: 'dim' })
110 return out
111}
112hooks/paths.ts 33 lines1// The shared layout under ~/.claude/clearance (design.md § Shared state).
2// Every file there has exactly one writer, so nothing needs a lock.
3
4export type Paths = {
5 root: string
6 scribe: string
7 snapshot: string
8 presence: string
9 history: string
10 /** The paging-pressure histogram (step 7): the scribe's to write. */
11 pressure: string
12 /** ~/.claude/sessions: the local session registry. Read only the *.json files; the *.key files are secrets. */
13 registry: string
14}
15
16export const pathsFor = (home: string): Paths => {
17 const claude = `${home}\\.claude`
18 const root = `${claude}\\clearance`
19 return {
20 root,
21 scribe: `${root}\\scribe`,
22 snapshot: `${root}\\snapshot.json`,
23 presence: `${root}\\sessions`,
24 history: `${root}\\history`,
25 pressure: `${root}\\pressure.json`,
26 registry: `${claude}\\sessions`,
27 }
28}
29
30export const epochFile = (p: Paths, n: number) => `${p.scribe}\\epoch-${n}`
31export const resignedFile = (p: Paths, n: number) => `${p.scribe}\\resigned-${n}`
32export const registryFile = (p: Paths, pid: number) => `${p.registry}\\${pid}.json`
33hooks/presence.ts 132 lines1import type { Io } from './io.ts'
2import type { Paths } from './paths.ts'
3
4// This session's presence file, `sessions/<sessionId>.json` (design.md §
5// Shared state): its subagents in flight, its memory reservations and when it
6// last made progress. Only this session writes it; the sampler joins it onto
7// the session's snapshot row.
8//
9// `busy` is whether the session is working: a turn in flight, or a subagent
10// still running after it. A session waiting for its person is idle, not
11// stalled, so THRASH's stall rule reads only busy sessions.
12
13/** Progress (any tool result) is written at most this often. */
14export const PROGRESS_EVERY_MS = 15_000
15
16/**
17 * How long a reservation stands. It covers a cleared subagent until the
18 * memory it brings shows in samples; by then the sample counts it instead.
19 */
20export const RESERVATION_TTL_MS = 30_000
21
22/** Memory set aside for something admitted but not yet visible in a sample (a subagent just cleared). */
23export type Reservation = { id: string; mb: number; at: number }
24
25export type PresenceDoc = {
26 schema: 1
27 sessionId: string
28 agentsInFlight: number
29 reservations: Reservation[]
30 /** The sum of `reservations`, so the sampler needn't add them. */
31 reservedMB: number
32 /** A turn is in flight or a subagent is still running. */
33 busy: boolean
34 lastProgressAt: number
35 t: number
36}
37
38export const presenceDoc = (sessionId: string, agentsInFlight: number, reservations: Reservation[], busy: boolean, lastProgressAt: number, t: number): PresenceDoc => ({
39 schema: 1,
40 sessionId,
41 agentsInFlight,
42 reservations,
43 reservedMB: reservations.reduce((sum, r) => sum + r.mb, 0),
44 busy,
45 lastProgressAt,
46 t,
47})
48
49/** Whether a progress bump at `now` is worth a write. */
50export const isProgressDue = (lastWrittenAt: number, now: number) => now - lastWrittenAt >= PROGRESS_EVERY_MS
51
52export const liveReservations = (all: readonly Reservation[], now: number) => all.filter(r => now - r.at < RESERVATION_TTL_MS)
53
54export const presenceFile = (paths: Paths, sessionId: string) => `${paths.presence}\\${sessionId}.json`
55
56export type Presence = {
57 /** A tool result came back: bump `lastProgressAt`, written at most every PROGRESS_EVERY_MS. */
58 progress: () => void
59 /** A main-loop turn started (`true`: it counts as progress too) or ended; written now. */
60 turn: (inFlight: boolean) => Promise<void>
61 /** Writes the file now, under the current session id (a /clear changes it). */
62 flush: () => Promise<void>
63 /** This session's live reservations made after the sample taken at `t` (the sample can't count them yet). */
64 reservedSince: (t: number, now: number) => number
65 /** Sets memory aside for a cleared spawn, before the spawn runs, and writes it. */
66 reserve: (id: string, mb: number) => Promise<void>
67 /** The spawn started as `agentId` (its reservation is renamed), or never started (`undefined`: the reservation goes). */
68 started: (reservationId: string, agentId: string | undefined) => Promise<void>
69 /** The subagent stopped: it is no longer in flight (its reservation runs out on its own). */
70 stopped: (agentId: string) => Promise<void>
71 agentsInFlight: () => number
72}
73
74export const startPresence = (io: Io, paths: Paths): Presence => {
75 const agents = new Set<string>()
76 let reservations: Reservation[] = []
77 let lastProgressAt = 0
78 let turnInFlight = false
79 let lastWrittenAt = 0
80 let writing: Promise<void> | undefined
81
82 const flush = async () => {
83 const now = await io.now()
84 const sessionId = await io.sessionId()
85 lastWrittenAt = now
86 reservations = liveReservations(reservations, now)
87 const doc = presenceDoc(sessionId, agents.size, reservations, turnInFlight || agents.size > 0, lastProgressAt || now, now)
88 try {
89 await io.write(presenceFile(paths, sessionId), JSON.stringify(doc))
90 } catch (e) {
91 io.log(`presence write: ${String(e)}`)
92 }
93 }
94
95 const progress = () => {
96 void (async () => {
97 const now = await io.now()
98 lastProgressAt = now
99 if (writing || !isProgressDue(lastWrittenAt, now)) return
100 writing = flush().finally(() => (writing = undefined))
101 })()
102 }
103
104 return {
105 progress,
106 turn: async inFlight => {
107 turnInFlight = inFlight
108 if (inFlight) lastProgressAt = await io.now()
109 await flush()
110 },
111 flush,
112 reservedSince: (t, now) => liveReservations(reservations, now).filter(r => r.at > t).reduce((sum, r) => sum + r.mb, 0),
113 reserve: async (id, mb) => {
114 reservations.push({ id, mb, at: await io.now() })
115 await flush()
116 },
117 started: async (reservationId, agentId) => {
118 if (agentId === undefined) {
119 reservations = reservations.filter(r => r.id !== reservationId)
120 } else {
121 agents.add(agentId)
122 for (const r of reservations) if (r.id === reservationId) r.id = agentId
123 }
124 await flush()
125 },
126 stopped: async agentId => {
127 if (agents.delete(agentId)) await flush()
128 },
129 agentsInFlight: () => agents.size,
130 }
131}
132