headroom

headroom keeps the memory and the processors of one computer free enough for the work that runs on it. It is a small daemon with a SQLite database. It serves its procedures over HTTP, and later over MCP.

Why

Many Claude Code sessions, Docker and the browser share one computer. When memory runs low, every session slows down, and the low-memory guard of Claude Code stops jobs. Before headroom, a person or an organizer session looked at the free memory and told each session when to start a heavy run. That is slow, and it does not scale.

headroom does three jobs:

  1. Observe. It samples the memory counters of the computer every few seconds. It gives the memory of each process tree to its owner, for example a Claude Code session or Docker. It keeps the history in SQLite.
  2. Admit. A job asks for a lease. The lease names an amount of memory, a count of processors or a slot of a named resource. The daemon grants the lease when the measured free memory has room for it. Otherwise the job waits in a queue.
  3. Enforce. headroom starts the job itself, with a memory limit at the size of its lease. On Windows, a job object holds the limit. On Linux, a cgroup holds it. A job that grows past its lease stops with a clear reason, and the other jobs keep their memory.

Status

Version 0 works: the sampler, the leases, the daemon, the CLI and the service. Its leases are advisory. docs/ROADMAP.md gives the plan and the status of each item.

Use

Register the daemon as a service of your user. It starts at login, and it starts again when it stops. It listens on 127.0.0.1:4730 and writes its address and token to ~/.headroom/daemon.json.

headroom service install
headroom service status

The command line of the daemon has the title headroom-daemon. The command line of a Node server of the gateway has the title headroom-mcp-<name>, and the server has the variable HEADROOM_MCP_SERVER. To find or stop these processes, search for the titles, not for cli.ts.

On Windows, a scheduled task starts the daemon at logon and every 2 minutes when it stopped. The log is ~/.headroom/daemon.log. On Linux, a systemd user unit starts it. headroom service restart loads a new version: it waits until no lease waits, and it marks the stop as planned, so no stop alert starts. headroom service uninstall stops the daemon and removes the service. To start the daemon in a terminal, use headroom serve.

When leases wait for a long time, use headroom service restart --when-idle to load a new version. The command does not wait. The daemon restarts itself at its first tick with no waiting lease. headroom status shows the restart that waits, and --cancel cancels it. During a planned restart, headroom run waits for the new daemon.

Run a heavy job under a lease. The job waits until the memory is free. When the daemon does not run, the job runs at once with a warning.

headroom run --mem 2g -- pnpm test
headroom run --slot docker --mem 1g -- docker compose up -d mongo

See the memory, the free space of the drives, the free amounts, the leases and the memory of each owner:

headroom status
headroom history --minutes 120

The list "Memory by owner" gives the private memory and the memory in RAM of each owner. Its last row, unattributed, gives the memory in use that no owner holds (H49):

headroom status also gives the owners that wrote most in the last hour, the processors in use, the network, the TCP connections and the shared services:

Writes (last hour): session:graph 31.2 GB, mcp:vex 2.1 GB.
CPU: 11.2 of 16 cores in use (2 kept for Mark). Most: session:graph 6.1, mcp:vex 2.0. Capped: graph-e2e 4 cores.
Process starts: 1,450 a minute. Most (at least): session:client 410 (jobs 12, own commands 398, job e2e: 9,800 so far), session:organizer 120.
Network: 1.2 MB/s in, 0.3 MB/s out.
TCP: 812 connections (120 waiting to close), 2% of the dynamic ports.
Services: docker ok (12 ms), fleet-site ok (3 ms), mcp:vex ok, mcp:loom idle.

Sessions start dev servers, and two servers on the same port collide. headroom ports shows the listening TCP ports, with the process and the owner of each port, by port. --json gives the list and the dynamic port range.

PORT       PID  PROCESS                    OWNER                  ADDRESSES
135        932  svchost.exe                system                 0.0.0.0, ::
3000     21512  node.exe                   session:graph          127.0.0.1
5173     30404  node.exe                   session:client         ::1

3 listening ports. Outgoing connections use the dynamic ports 49152-65535, so give a dev server a port below 49152.

The peer of a local connection (H46)

The fleet site serves its procedures as MCP tools to claude.exe. Each call needs the fleet role of the caller. The site gets the role from the TCP peer, not from a header. The procedure resources.peer gives it:

headroom peer <localPort> <remotePort> shows the same answer for a check by hand. --json gives the JSON. An error gives the exit code 3.

Peer: node.exe (process 8932)
Via: 8932 > 4888 > 23080 > 21804 > 15696 > 25064 (the last is claude.exe)
Session: headroom (19f84d12-fed5-4c42-a6bd-7d856e31cc16)
Role: headroom

The limit. This attribution is as strong as "the same Windows user". It is not a security boundary. A process of that user can choose its parent with PROC_THREAD_ATTRIBUTE_PARENT_PROCESS, and it can write a session file. docs/RESOURCES.md gives the details.

Processors (H23)

headroom watches the cores in use, and keeps some cores for Mark:

The alerts use the reads of the processor times that the daemon already makes each 10 seconds. The 1-minute average at each read gives the time above a line.

Process starts (H29)

On 2026-10-24, a pool trace showed a leak in the AMD display driver amdkmdag.sys. Each new process leaks approximately 0.9 KB of the non-paged pool. With 14 or more new processes each second, the pool grew approximately 800 MB each hour. Until Mark updates the driver (hub ask amd-driver), the fleet must start fewer processes. headroom shows who starts them:

The line Process starts of headroom status gives the starts of the last 10 minutes, in one minute. The owners show their jobs and their own commands. Status.starts gives the same data, and the dashboard has a panel Process starts.

On 2026-10-24, this computer started 1,450 processes in one minute. A read every second found only 260 of them, and a read every 10 seconds found 60.

Spawn-heavy jobs and browser tests (H47)

On 2026-10-24, the Playwright browsers of graph did not start three times, with 0xC0000142. Two of these runs overlapped bursts of process starts from other leases. The desktop heap, the memory, the commit charge and the kernel pools had room. Thus the burst itself stops the start of a browser.

A spawn-heavy job and a browser test do not run at the same time:

headroom status gives the line Spawn-heavy now: test-churn (server-stack), 1,870 a minute. headroom job and its JSON give the peak, the average and the class of a job. The dashboard gives them in the timeline of the jobs. A wait gives a reason that names the other job:

waits for the browser tests of graph (pnpm e2e) to end: this job starts many processes (about 1,870 a minute in past runs), and browsers can fail to start beside it
waits for test-churn of server-stack to end: it starts many processes (about 1,870 a minute), and browsers can fail to start beside it
waits for the other jobs to start fewer processes: tsc (client) 400 and vitest (vex) 300 start 700 a minute together, above the limit of 600, and browsers can fail to start beside it

These reasons do not start with waits for memory, so the hook of the plugin does not count them. docs/RESOURCES.md gives the rules.

Handles and orphans (H48)

On 2026-10-25, a subagent started find / -name ... | head -3 in Git Bash. The Bash call ended after 2 minutes, but find.exe stayed as an orphan. It held 1.3 million handles, and the paged pool grew 593 MB in one hour. The daemon gave no alert, and after a restart it did not know the owner. Thus headroom watches the handles and keeps the owners of orphans:

The system file cache (H50)

On 2026-10-25, the available memory fell two times while no job used much memory. The system file cache grew from 0.5 GB to 10.1 GB in some minutes, and the available memory stayed near 1 GB for 22 minutes. Then the reaper of Claude Code stopped jobs. Windows Search read 350 MB each second from the files of the sessions on D:, and Defender scanned them.

ALERT since 05:40 UTC: find.exe (pid 27056) holds 1,313,105 handles, +40,000 a minute. It started 44 min ago. Owner: session:server-stack tools (orphan, parent ended). Command: find / -name WIRE-PROTOCOL.md -path *procedures*. The owner stops it by its pid.

Battery and power (H30)

On a laptop, the battery is a resource too. headroom reads it with the processes, every 10 seconds. The read starts no process and no timer:

The line Power of headroom status gives the state, for example Power: on battery, 68%, 18.0 W, 1 h 58 min left (power mode: balanced). Most (estimate): session:server-stack 3.1 W, session:graph 2.4 W. or Power: plugged in, charging, 92%. The dashboard has a panel for each resource and a chip Power.

On battery below a floor, a heavy job waits. The next section gives this rule and the other conditions of a job (H31).

GPU (H36)

The GPU of this laptop, a Radeon 860M, uses the main memory. headroom reads the GPU with the processes, every 10 seconds. The read starts no process and no timer:

The line GPU of headroom status gives the state, for example GPU: 1.3 of 17.8 GB (0.3 GB in RAM), 34% busy. Most: session:graph chrome.exe 820 MB. headroom gpu gives each adapter, its owners and the leased jobs, and --json gives Status.gpu. The dashboard has a panel for each adapter.

Docker inside (H38)

Docker Desktop keeps its containers in one VM. Windows shows the VM as one process, vmmemWSL. Thus headroom asks the Docker engine for each container:

The line Docker of headroom status gives the state, for example Docker: 1 container, 0.6 GB, 0.55 cores. Most: session:graph graph-mongo-1 0.6 GB. Build cache 17.1 GB. Unused images 26.2 GB. headroom docker gives each container with its owner, memory, cores and state, the VM, the disk use and the cost of the reads. --json gives Status.docker. The dashboard has one card, after the card of the Docker engine.

Forecasts (H32)

headroom tells the time until each resource reaches a limit, so the fleet acts before the limit. The forecast comes from the history of the last hour:

headroom forecast lists the forecast of each instance to its nearest limit, for example Drive D: 20 GB free in 4 days (at 1.1 GB an hour, confidence 0.65). --json gives the data. headroom status adds the line Forecast (24 h) with the forecasts under 24 hours. Each card of the dashboard shows its forecast in one line, and Status.resources gives the field forecast.

What uses the space, and a safe cleanup (H21)

headroom disk report shows what uses the space of each drive. headroom disk clean removes only the data that is disposable by definition. All other data goes to Mark.

headroom disk report                     # the known places, 30 seconds
headroom disk report --drive C: --deep   # also the largest folders to depth 3, 3 minutes
headroom disk clean                      # a dry run: what the cleanup deletes
headroom disk clean --apply              # delete it, and log each delete

headroom disk clean is a dry run. With --apply, it deletes only 3 kinds of data:

  1. The temp folders of the jobs whose lease ended. The daemon tells the state of each lease. With no answer, the cleanup removes no folder.
  2. The trace files directly in the temp folder of the user and in the temp root, 7 days or older. These are node_trace.*.log, *.cpuprofile, *.heapsnapshot, *.heapprofile and the Playwright temp folders.
  3. pnpm store prune, only when no lease is live, no pnpm process runs, and less than half of the store has one hard link.

Heat and throttling (H35)

When heat or a power limit slows the CPU, timing tests and benchmarks give wrong numbers. Such jobs wait for a cool CPU, and headroom names the owners that make the heat.

headroom thermal prints the reads, the state, the condition cool and the owners. --json gives the data. headroom status adds a line only when the CPU is throttled or hot, for example Heat: the CPU runs at 62% of its speed since 14:02: a power or heat limit.

On this laptop on 2026-10-25, all six counters gave values. The CPU had no limit (100%) and ran at 190% to 204% of its nominal 2.0 GHz. The zone \_TZ.THM0 was at 86 °C to 100 °C.

Conditions for a job (H31)

Some jobs need a fact of the computer, not an amount. A benchmark needs AC power, because its numbers on battery are wrong. A fan-out of claude -p needs room in the usage window. --when makes the lease wait until each condition holds:

headroom run --when ac -- pnpm bench
headroom run --when battery>=50,usage<80 -- pnpm agents
headroom run --timing -- pnpm test:timeouts
headroom run --when "starts<700" -- pnpm e2e
Condition It holds when
ac The laptop is plugged in. A computer with no battery counts as plugged in.
battery>=N The battery is at N% or more, or the laptop is plugged in.
usage<N The five-hour usage window of Claude is under N%. The data comes from the usage file of the fleet.
starts<N The computer starts fewer than N processes in one minute, over the last 2 minutes. The daemon tests it at each read of the processes.
away Mark is away from the laptop (H33). A computer with no read of the presence counts as away.
cool The CPU was not throttled in the last 2 minutes (H35). A computer with no read of the speed counters counts as cool.

The battery policy. On battery below the floor, a new heavy lease waits. A lease is heavy when it asks for 2 GB of memory or more, holds the slot browser, or is spawn-heavy. --battery-ok starts it. headroom config battery-floor 15 changes the floor, and 0 turns the rule off. The default is 25%.

The usage data. usage<N reads ~/git/fleet/artifacts/hub/db/usage/current.json, at most one time in 30 seconds. headroom config usage-file <path> changes the file. The condition fails closed:

The reasons do not start with waits for memory, so the hook of the plugin does not count them:

headroom: lease 3f2a9c1e for pnpm bench, queued until the laptop is plugged in.
waits until the laptop is plugged in (--timing): timing numbers on battery are wrong, so run timing tests in CI
waits until the battery is at 25% or more, or plugged in (now 18%): this job is heavy (it asks for 4.0 GB of memory). --battery-ok starts it on a low battery
waits until the five-hour usage window is under 80% (now 91%, resets at 02:10)
waits for fresh usage data: the file is 95 minutes old
waits until the computer starts fewer than 700 processes a minute (now 1,108)

headroom job gives the conditions of a job and a time when it ran without one of them. The timeline of the dashboard draws the wait for a condition as a dashed line, and the time without a condition as a red line. A restart of the daemon for an idle queue does not wait for a lease that waits for a condition. The new daemon keeps the lease.

Keep room for Mark (H33)

While Mark works at the laptop or is on a call, heavy jobs wait or run slower, so the laptop stays fast for Mark. When Mark steps away, they run at full speed.

State When
present An input came in the last 5 minutes.
busy Mark is present, and a full-screen app or a presentation runs. A call also makes Mark busy, with or without input: an app uses the camera or the microphone.
away No input came for 5 minutes, Windows tells that the user is inactive, the display is off, or the session is locked.

The measure. The reads are cheap. They start no process, no timer and no window:

The rules.

headroom run --when away -- pnpm bench
headroom config away-after 10m      # no input for 10 minutes: away (the default: 5 min)
headroom config busy-wait 30m       # the longest wait of a heavy job while Mark is busy (the default: 20 min)
headroom config present-reserve 3   # 3 more cores for Mark while Mark is at the laptop (the default: 2)
headroom config presence off        # measure only: no wait, no efficiency mode, no change of the reserve

headroom status gives one line, for example You: present (last input 12 s ago). Heavy jobs run in eco mode., You: on a call since 10:02. 2 heavy jobs wait. or You: away since 09:40. headroom presence gives the state, its reason and the last 20 changes. --json gives the data. The dashboard shows the state in the chip You.

The reboot budget and a planned drain (H34)

The AMD display driver of this laptop leaks kernel memory with each new process. Only a reboot gives the memory back. As the leak grows, process starts fail at smaller bursts (0xC0000142). A reboot works best in a planned window: no job runs, each session wrote its handoff, and the resume plan brings the fleet back.

headroom does not reboot the computer, and it does not log off. Only Mark does these actions.

The budget. headroom reboot-plan gives the facts and a recommendation. --json gives the data, and Status.reboot has the same data.

Reboot: Reboot within 6 hours: the non-paged pool reaches its alert level at 07:40. A Windows update waits for a reboot too.
Kernel pools: non-paged 2.6 GB (alert level 3.0 GB, +80 MB in the last hour, at its alert level in 6 h 10 min), paged 2.6 GB (alert level 4.0 GB, +10 MB in the last hour).
Automatic drain: at 6.0 GB of the kernel pools together, now 5.2 GB. The forecast reaches it at 25 Oct 11:20, in 8 h 40 min.
Windows: an update waits for a reboot (Windows Update).
Uptime: 3 days, since 22 Oct 21:10.
Drain: none. `headroom drain --at 02:30` plans one.
Resume plan: C:\Users\<you>\.headroom\resume-plan.md, written 2 h 10 min ago (25 Oct 00:30). A drain writes it again at its start.

The rules of the recommendation go in this order:

  1. A kernel pool at or above its alert level (3 GB non-paged, 4 GB paged), or the two pools at or above the automatic level: "Reboot soon".
  2. A forecast (H32) of a pool to its alert level, or of the two pools to the automatic level, within 24 hours: "Reboot within N hours", with the time.
  3. A pool that grows 500 MB or more in one hour, with no forecast: "Plan a reboot".
  4. Only a waiting Windows update: "Reboot when it suits you". With another rule, the update adds one sentence.
  5. Else: "No reboot needed", with the time of a far forecast.

headroom reads two registry keys, and writes none: HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired and HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending.

The drain. A drain holds new jobs before a reboot:

headroom drain --at 02:30 --until 06:00   # from 02:30, new jobs wait. At 06:00, the drain ends if no reboot came.
headroom drain --at now
headroom drain                            # the drain and its jobs
headroom drain --cancel
headroom run --during-drain -- git status # this job starts also in the drain

The automatic drain. At a high pool level, new processes fail with 0xC0000142. On 2026-10-24 the two pools reached approximately 6.3 GB, and a reboot was necessary. No session watches the pools all the time, so headroom does it:

headroom config auto-drain 6g      # the level: the non-paged pool and the paged pool together. The default: 6 GB.
headroom config auto-drain off     # no automatic drain
headroom config auto-drain --clear # the default level again

The resume plan (H13). headroom resume-plan prints the command of each live session, the organizer first. It also writes the plan to ~/.headroom/resume-plan.md. headroom reboot-plan tells where the file is and its age.

The shared services (H27)

On 2026-10-24, the Docker engine stopped to answer, and an MCP server of the gateway did not start. Nothing told the sessions. Since H27, the daemon checks the shared services every 30 seconds, with a time limit of 3 seconds for each check:

Each service has a state: ok, slow (an answer after more than 1 second), down, idle or absent. headroom services shows the state, the latency, the start of the state, the last good check and the last error of each service. --json gives the list.

SERVICE                  STATE    LATENCY  SINCE (UTC)      LAST OK  LAST ERROR
docker                   ok         12 ms  16:02 (2.7 h)    18:45    -
fleet-site               ok          3 ms  16:02 (2.7 h)    18:45    -
mcp:vex                  down           -  18:41 (4 min)    -        18:45 Error: Cannot find module 'zod'
mcp:loom                 idle           -  16:02 (2.7 h)    -        -

On Windows, headroom run puts the job into a job object:

A pool with no capacity is a lock of 1. Set a capacity with headroom pool docker 2. The processors are different: their count is the capacity of cpu, and headroom pool cpu 8 sets a lower cap.

A queued job can run after the shell that started it stops. headroom job gives the state of a job, so a session can wait for the end of the job and read its exit code:

headroom run --job graph-e2e --log D:\logs\e2e.log -- pnpm e2e
headroom job graph-e2e
headroom job 3f2a9c1e --json
headroom: lease 3f2a9c1e for graph-e2e, queued for browser.
graph-e2e (3f2a9c1e) of graph: exited with code 1 at 17:42, waited 12 min, ran 6 min, peak 1.8 GB, 412 processes started, at most 380 starts a minute, normal (about 350 a minute in past runs). Log: D:\logs\e2e.log

A session can take its queued job off the queue with headroom cancel, for example after its subagent ended:

headroom cancel 3f2a9c1e                       # the lease id, or its first 8 characters
headroom cancel --job graph-e2e --holder graph # only the jobs of one holder
headroom cancel --job "pnpm test" --all        # each waiting match

A job that writes much data asks for space on a drive with --disk. --disk 5g asks for space on the drive of the current folder, and --disk D:=5g names the drive. The lease waits until the drive has the space. The broker keeps the critical limit of the disk alert free: 5 GB, or 5% of a small drive. A disk lease holds its full amount until its release. A lease that is larger than the drive, or that names a drive that does not exist, is cancelled at once.

headroom run --disk 5g -- pnpm test
headroom run --disk D:=20g --mem 2g -- node trace.js

Set a temp root to give each job its own temp folder:

headroom config job-temp D:\headroom\tmp
headroom config

The alerts tell about a problem before the memory runs out. headroom status shows them first, and headroom alerts --watch writes one line when an alert starts or ends. The alerts only report. They do not stop a process.

Alert The condition
runaway One process holds more than 4 GB with no lease. Docker and the system are not examined.
orphan A process with no owner holds more than 1 GB, and its parent stopped.
handles One process holds more than 100,000 handles (a warning) or more than 500,000 (critical), or gains more than 20,000 handles in 5 minutes. The id is runaway:handles:<pid>. The alert names the owner, also after the parent ended, and the command line (H48).
memory Less than 1 GB of memory is available.
cache The system file cache holds 4 GB or more, and less than 6 GB of memory is available (a warning) or less than 1 GB (critical). The alert names the owners that read and wrote most in the last hour (H50).
commit The commit charge is above 90% of its limit.
desktop More than 1000 processes run in the session. The desktop heap can run out, and new processes then fail at their start.
pool The non-paged kernel pool is above 3 GB, or the paged pool is above 4 GB, or one of them grew more than 500 MB in one hour. The alert names the pool tags that grow.
disk A fixed drive has less than 20 GB free (a warning), or less than 5 GB free (critical). The alert gives the free space that the drive lost in the last hour. It names the 3 owners that wrote most in the last hour, each above 100 MB, for example Most writes in the last hour: session:graph 31.2 GB, mcp:vex 2.1 GB. It also names the 3 largest files above 1 GB of the last 2 hours in the home folder, the downloads, the temporary folders and the root of the drive. A drive smaller than 100 GB uses 20% and 5% of its size.
ports More than 80% of the dynamic TCP ports are in use (a warning), or more than 95% (critical). When no dynamic port is free, new connections fail. The alert names the 3 owners with the most connections, and the count in TIME_WAIT.
service A shared service does not answer for 2 checks in a row (one minute): critical for Docker and the MCP servers, a warning for a URL of the setting health. An answer after more than 1 second for 3 checks in a row is a warning. The id is service:<name>, for example The Docker engine does not answer since 18:42 UTC (3 min). Containers such as Mongo and the graph tests need it. headroom does not restart it. An idle MCP server and an absent Docker engine give no alert.
stop Another process stopped the daemon, or 2 clients of headroom run in 2 minutes. This alert is an event: it stays for 15 minutes.

On Windows, the daemon reads the kernel pool by pool tag every 5 minutes. A driver marks its allocations with a tag, so the tag that grows names the driver that leaks. headroom pool-tags --drivers shows the largest tags, the tags that grew in the last hour, and the driver files that contain each tag. It needs no admin rights and no driver kit.

The plugin for Claude Code

The plugin headroom has a hook for the Bash and PowerShell tools. When a session starts a heavy command (tests, builds, Playwright, Docker builds) and the memory is tight, the hook refuses the command. It gives the session the form under a lease, for example cd app && headroom run --mem auto -- pnpm test.

To turn it on in Claude Code: /plugin marketplace add C:\Users\<you>\git\headroom, then /plugin install headroom@headroom. To turn it off: /plugin disable headroom@headroom.

The dashboard

The daemon serves a dashboard at http://127.0.0.1:4730/. headroom dashboard opens it in the browser. It shows the alerts and what uses the computer. Then it shows one panel for each resource, the jobs, the owners, the MCP gateway and the pool tags. Its charts show the last hour to the last 7 days.

The MCP gateway

Claude Code starts a copy of each stdio MCP server for each session. The gateway starts one copy of a server for all the sessions, and serves it at http://127.0.0.1:4730/mcp/<name>.

headroom mcp add vex --cwd ~/git/Vex/packages/mcp -- node --import tsx src/main.ts
headroom mcp config vex
headroom mcp list

Claude Code waits 30 seconds for each MCP server of a session. When many sessions start at the same time, the first start of a server can take more time. headroom mcp warm starts the servers before the sessions start:

headroom mcp warm --timeout 120s

A program that starts only stdio servers, for example the launcher of a plugin, uses the bridge:

node packages/daemon/src/bridge.ts vex -- npx -y @mark1russell7/vex-mcp

The bridge sends the messages of the session to the gateway. When the gateway does not serve the server, the bridge starts the command after --. Thus the same launcher works on a computer without headroom. The bridge uses approximately 30 MB, and it opens a new session after a restart of the daemon.

Exit code Meaning
The code of the job The job ran under its lease.
75 The lease was not granted in time, or it ended before the job started. Or a fixed drive has less than 5 GB free: a full drive stops the builds, the tests and git of all the sessions. Nothing ran. Free space, or start the job again with --force-disk.
2 The command line is not correct.

How it is built

License

MIT