SLOPSHOPPER

clearance

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

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-04LeventAksakal/clearance
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clearance
│ ┃ clearance ✕ › fix the failing auth test and add an audit log call │ ┃ Waiting for the first machine sample… │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ● ● clearance waiting for a snapshot ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ clearance: clearance · USERPROFILE is not set

Draws

Band
● ● clearance waiting for a snapshot
Pane · clearance
Waiting for the first machine sample…
README

clearance

Status: v0.1.0. All 7 build steps are done: the shared snapshot and scribe election, presence and the gate, admission, the /clearance pane, 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:

  • See every Claude session, its subagents, child processes and containers, the desktop app and the WSL/Docker VM, and what each one uses.
  • Know the headroom: how many more local sessions or subagents fit right now, updated every 5 s.
  • Get clearance before launching more. Over the cap, a new session is held, with the choice to divert to a cloud session, Remote Control or ssh, or to wait. A subagent spawn is refused with a forecast, so the agent can resize its fan-out; a cloud subagent (isolation: "remote") is never gated by this machine.
  • Learn, don't assume: what a subagent and a session cost is an upper confidence bound on what this machine was seen to use, and the memory floor is where this machine was seen to start paging hard.

What you see

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.
  • The pixel marshaller takes the traffic-light tier: green waves both paddles (a session fits), yellow waves one (only subagents fit), red crosses them overhead (nothing fits), grey dozes (no snapshot). In THRASH it shakes and the line reads ▲ THRASH … spawns refused.
  • Hover the band for the machine, the floor and what it rests on, the forecasts, and every session's own, child and container memory, then the desktop app, the VM and everything else.

/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.

Install

claude plugin marketplace add LeventAksakal/clearance
claude plugin install clearance@clearance

What it reaches

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:
  • reads ~/.claude/sessions/*.json (never the *.key files);
  • reads and writes ~/.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);
  • reads every session's history of the last two months and pressure.json to learn the forecasts and the floor;
  • for /clearance check only: reads supabase/config.toml and compose files in each session's folder and one level down.
  • Hooks: 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').
  • No $.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.

Develop

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).

License

MIT

Source 16 files
hooks/register.tsx 552 lines
1import { 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}
552
hooks/badge.ts 178 lines
1import 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}
178
hooks/admission.ts 109 lines
1import { 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})
109
hooks/forecast.ts 147 lines
1// 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'
147
hooks/history.ts 186 lines
1import { 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>>
186
hooks/checks.ts 143 lines
1import 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}
143
hooks/pressure.ts 157 lines
1import { 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)
157
hooks/gate.ts 131 lines
1import 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}
131
hooks/io.ts 15 lines
1// 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}
15
hooks/pane.ts 112 lines
1import 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}
112
hooks/paths.ts 33 lines
1// 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`
33
hooks/presence.ts 132 lines
1import 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