Runaway tests, meet the reaper. A single NativeAOT binary that supervises every process it launches — killing runs that have genuinely hung, capping time, memory, and CPU when you ask it to, and automatically reaping the orphans your LLM agents leave behind when they hang, get distracted, or your machine suspends.
Works with Claude Code, Codex CLI, Gemini CLI, Cursor, Antigravity, Kimi, opencode, Copilot CLI, and anything else that shells out.
Contents — Why · Install · Quick start · Commands · Run flags · Buckets · .tman.kdl · Run logs · AI agent setup · Housekeeping · Exit codes · Scope
LLM agents start test suites and then hang, get distracted, or survive a machine suspend — leaving processes that drain your system for hours. tman wraps every run with hard limits and a reaper, so nothing outlives its welcome.
- wall-time + stall kills —
--max-time 10m,--stall 30m(silent and idle across the whole process tree = hung, so quiet-but-busy work likego testkeeps running) - resource culling — opt-in
--max-mem 2g,--max-cpu 95(sustained) kill the whole process tree; both are measured across the tree on Linux, macOS and Windows, so a runner that forks its workers cannot hide behind an idle root - kernel-enforced limits — opt-in
--limit-cpus 4runs the tree on four CPUs (an affinity mask, sonprocand every runner's worker pool see four), and--limit-mem 8gis a ceiling the kernel holds rather than one tman samples; Linux and Windows - orphan reaping — every
tmancommand kills children whose runner died and prunes expired records; a lock whose runner died is taken over in place by the next run of that name - dedup locks —
--name testrefuses duplicates;--replacekills the old run and waits for it to hand the name back - resource gating —
--max-parallel 2queues excess runs instead of stampeding cores - failure logs — every supervised run leaves
.tman/<alias>.log, and a failed one leaves.tman/<alias>.fail.logwith the failure lines extracted; both are cleared at the start of the next run - per-project scoping — locks and slots bucket by name (or command) and directory, so one repo's runs never block another's
- folder aliases —
.tman.kdlper project, with repo-root shims so./testis supervised transparently - ~3.8 MB native binary, zero runtime deps, cross-platform (linux/mac/windows, x64/arm64)
npm
npm install -g @standardbeagle/tmanshell one-liner
curl -fsSL https://raw.githubusercontent.com/standardbeagle/tman/main/install.sh | shfrom source — requires the .NET 10 SDK:
git clone https://github.com/standardbeagle/tman
cd tman
dotnet publish -c Release -r linux-x64 # or win-x64, osx-arm64, linux-arm64
cp bin/Release/net10.0/*/publish/tman ~/.local/bin/Prebuilt binaries are attached to every GitHub release for linux-x64, linux-arm64, win-x64, osx-arm64, and osx-x64.
# supervise anything
tman run --max-time 10m --max-mem 2g -- npm test
# adopt in a project (auto-detects npm / pytest / go / make)
cd your-project
tman init --shims --gitignore
./test # now supervised: stall backstop, dedup + parallel gating| command | what it does |
|---|---|
tman run [flags] -- <cmd> [args] |
run a process under supervision |
tman run --alias <name> [args] / tman <alias> |
run a .tman.kdl alias |
tman list [--all] |
list live runs — running, and queued for a slot (pid -) — or all records; PROJECT is the run's project root, the scope kill all works within |
tman kill <id|name|all> |
kill run(s), queued ones included. all means your runs: the same project root (nearest .tman.kdl above the cwd, else the cwd) and, when both sides have one, the same agent session (CLAUDE_CODE_SESSION_ID); other projects' and sessions' runs are left alone and counted, so one agent cannot stop its siblings' runs. A run named by id or name is killed wherever it is, with its owner printed when that is outside your scope. kill all --everywhere lifts the scope but needs a controlling terminal (/dev/tty); without one it refuses with exit 77 and names the command a person runs — a queued run ends without ever starting its child; the killed run ends killed: killed via tman kill and its tman exits 130. kill leaves the reason in ~/.tman/runs/<id>.kill before killing, and the run's own runner writes the outcome from it, so the two never race to save the record. An unknown flag refuses the command with exit 127 rather than being skipped; exits 1 when part of a run's tree could not be killed |
tman clean |
run the housekeeping sweep now and report what it did |
tman status [id|name|id-prefix] [--json] |
summary counts, or one run's detail |
tman init [--shims] [--gitignore] |
scaffold .tman.kdl + shims (aliases it cannot detect are left commented out, so ./test fails loudly instead of faking a pass); shims are named after the aliases in the config on disk, so re-running it in a project that already has one shims that file's aliases; --gitignore ignores .tman/ and the shims, and skips an alias whose name is already a directory, so /test never hides a test/ tree |
tman probe --pid <pid> [--start-ticks <ticks>] |
is a pid you recorded earlier still that process? Exit 0 mine, 1 gone, 3 not mine (the OS reused the pid); the verdict is also printed. --start-ticks is Linux only, read from field 22 of /proc/<pid>/stat when you record the pid; without it a live pid counts as yours. Read-only: it never signals or kills, and does not run the housekeeping sweep |
tman help [<command>|<alias>], tman <command> --help |
the overview, one command's usage, flags and exit codes, or what an alias runs and where (-h works too). Help changes nothing — it creates no files and runs no sweep — and every command refuses an argument it does not take with exit 127, rather than skipping it and doing its job. tman <alias> --help is not tman's: an alias passes every arg to its command, so that asks the alias's own tool |
tman hook pretooluse |
Claude Code hook: routes bare test/build commands through tman, and never blocks |
| flag | default | what it does |
|---|---|---|
--name N |
— | dedup lock; refuses if a live run has the same name in this directory |
--replace |
off | with --name: kill the existing run, then wait for its runner to release the name (up to --queue-timeout); refuses to start if it is still held |
--max-time T |
— | wall-clock limit → kill, exit 124 |
--stall T |
30m | no output and no cpu/io/kernel-io-wait activity for T → kill, exit 125 |
--max-mem M |
— | ceiling on the process tree's RSS (MB or 2g) → cull, exit 126 |
--max-cpu P |
— | process-tree CPU above P% for 3 consecutive 1s ticks → cull, exit 126 |
--limit-cpus N |
— | run the whole tree on N CPUs, enforced by the kernel — see Kernel-enforced limits |
--limit-mem M |
— | memory ceiling (MB or 2g) the kernel enforces on the whole tree; crossing it ends the tree → exit 126 |
--max-parallel N |
2 | queue until one of the bucket's N slots can be held; a run that has to wait says so once on stderr (with the queue timeout) and once more, slot acquired after Xs, when it gets through. While it waits it is a queued run: tman list shows it, tman kill ends it, and Ctrl+C ends it as killed: cancelled while queued with exit 130 — none of which starts its child |
--queue-timeout T |
5m | give up waiting for a slot; the run is recorded killed: queue timeout … and exits 130 |
--queue Q |
— | after its bucket admits it, also wait in the machine-wide named queue Q, in arrival order |
--no-queue |
off | join no named queue, even one the alias or defaults names |
Cap precedence: CLI flags > alias block > defaults block > built-ins.
A cap whose value cannot be read refuses the run with exit 127 and names it — bad --max-cpu "abc" for a flag, bad max-time "bogus" with the file and block for .tman.kdl — nothing is
clamped, defaulted, or dropped. Durations take ms/s/m/h, max-mem megabytes or a k/m/g
size, max-cpu a non-negative number, max-parallel a non-negative integer, limit-mem a size
above zero, and limit-cpus a whole number of at least 1. Flags and the
config share one parser, so each accepts exactly what the other does.
max-mem and max-cpu are samples: tman reads the tree once a second and culls it when a sample
is over. A runner that allocates 30 GB between two samples has already pushed the machine into
swap by the time tman looks. limit-cpus and limit-mem are held by the kernel instead, so there
is nothing to outrun.
| Linux | Windows | macOS | |
|---|---|---|---|
limit-cpus N |
affinity mask, set on the forking thread so the child inherits it before it runs | Job Object affinity | refused, exit 127 |
limit-mem M |
a memory cgroup: a systemd user scope by default, or a leaf in the delegated cgroup the machine config names | Job Object job memory limit | refused, exit 127 |
-
limit-cpusnarrows what the tree sees, not just what it gets.nproc, .NET'sProcessorCount, Go'sGOMAXPROCSand pytest-xdist's-n autoall count N, so a runner starts N workers instead of one per core and then fighting over N. tman picks the N CPUs the fewest live runs already hold, so two runs oflimit-cpus 4on a 16-CPU machine get eight CPUs between them, not the same four;tman statusshows which. Asking for more CPUs than tman may use gives every one of them. A nested run picks from its parent's set and can never widen it. -
limit-memends the whole tree. On Linux the kernel's OOM kill takes every process in the scope, and tman reads the scope's result to report the runculledwith exit 126 rather than as the child's bare SIGKILL (137). On Windows the job refuses the allocation that would cross the limit, and tman ends the tree on the job's notice. Swap does not count as room: a tree that could swap past its ceiling would still take that memory from the machine. -
Linux needs a delegated cgroup. An unprivileged process can set
memory.maxonly on a cgroup handed to it, solimit-memtakes one from whoever hands one out:-
By default, the systemd user manager. The command runs through
systemd-run --user --scopewithMemoryMax,MemorySwapMax=0andOOMPolicy=kill. Without a user manager with the memory controller delegated, the run is refused with exit 127 before it queues, naming what is missing.loginctl enable-lingerstarts a manager for a user with no login session, such as a CI account. -
Or a cgroup you name, for a container that owns its cgroup or a host where an admin delegated one, in
~/.tman/tman.kdl:cgroup "/sys/fs/cgroup/tman"
Each run gets its own
tman-<id>cgroup there, withmemory.max,memory.swap.max 0andmemory.oom.group 1. The command joins it before it runs — a shell writes its pid into the cgroup and execs the command — so the pid tman watches is the command's own and nothing it starts can start outside. Afterwards anything left in it is killed and the cgroup removed. The kernel moves a process between cgroups only for a writer ofcgroup.procsin the nearest cgroup holding both, so tman itself must run inside the delegated subtree, as a container's processes do; a cgroup delegated to you is no use to a tman in a login session or WSL's/non-systemd. tman checks this, and the cgroup's memory controller and write access, before the run queues, and refuses with exit 127 naming the one that fails.In a container, the cgroup tree must be writable: Docker mounts it read-only unless the container is
--privileged, or has--cap-add SYS_ADMIN(with AppArmor not blocking mounts) and remounts it. A cgroup that holds processes cannot hand controllers to its children, so move the container's processes out of the root first:# docker run --cgroupns=private --cap-add SYS_ADMIN --security-opt apparmor=unconfined ... mount -o remount,rw /sys/fs/cgroup mkdir /sys/fs/cgroup/init for p in $(cat /sys/fs/cgroup/cgroup.procs); do echo $p > /sys/fs/cgroup/init/cgroup.procs; done echo +memory > /sys/fs/cgroup/cgroup.subtree_control mkdir /sys/fs/cgroup/tman
A run nested inside a run with
limit-memstays in its parent's cgroup rather than making its own, which would move it out from under the parent's limit; it says so on stderr. -
-
Windows has a window. .NET cannot create a process suspended, so the child runs for the instant between its creation and tman putting it in the job. A process it started in that instant would be outside the job; a runtime or interpreter is still loading then, which is why this holds in practice, but it is not a guarantee.
-
macOS refuses both: an unprivileged process there has neither CPU affinity nor a memory ceiling for a process tree. Use
max-memandmax-cputhere.
--stallis a hang backstop, not a runtime budget. It answers "is this process dead?", not "is this taking too long?" — use--max-timefor the latter. A coldgo build ./...,npm run typecheckordotnet testcan legitimately run for many minutes while printing nothing, so a stall sized like an expected runtime kills healthy work. Set it well above the longest quiet stretch you ever expect.The built-in is
30m, andtman initscaffolds the same value from the same constant. It was60sthrough 0.2.0, which was a runtime budget wearing a backstop's name: across 959 supervised runs that guard fired 32 times and caught no actual hang, killing work likego build ./...at 60s that succeeded 14 other times, once taking 75s.If your
.tman.kdlhas nostallline, you get30m— including configs written against 0.2.0 and earlier. That is deliberate. Omittingstallnever meant "60s"; it meant you had no opinion, and the built-in supplied a bad one. Widening a backstop cannot make a passing run fail — it can only stop kills — and the only run it keeps alive longer is one that is silent and idle, which stays visible intman list, endable withtman kill, and holds at most one of its bucket'smax-parallelslots. If you do want a tight bound, that is--max-time, or writestallexplicitly and it wins.
Platform note. Every sample covers the whole process tree — the root and every descendant still parented under it — on all three platforms:
/procon Linux, libproc on macOS, a Toolhelp snapshot on Windows.--stall,--max-memand--max-cputherefore see a forked worker wherever it runs. What differs is the activity signal. Only Linux reads process states, so theD(kernel io wait) clause below is Linux's alone. The io counter isrchar/wcharon Linux, disk bytes on macOS, and all io transfer bytes on Windows. A descendant that has left the tree, reparented to init or launchd or orphaned on Windows, is no longer counted on any of them.
A run ends when its root process exits. Output still in the pipe is drained for up to 2s. After that, a process the run left behind that still holds its stdout or stderr —
server &, or a daemon that kept the pipe — is killed on Linux, which finds it by the pipe itself in/proc/*/fd. On macOS and Windows tman says on stderr that the output is still held and stops waiting. Either way the run finishes with the root's exit code and releases its slot. Before this a leftover kept the run open indefinitely, past--max-time, holding the slot. Leftovers that let go of the output, such as compiler servers and build daemons that outlivedotnet buildby design, are left running.
On Linux a tick counts as activity if the tree's CPU jiffies moved, its rchar/wchar moved, or
any process in it sits in state D (uninterruptible sleep — the kernel is servicing an io request).
So:
| The run is… | Protected? | Why |
|---|---|---|
burning CPU silently (go build, a compile) |
yes | cpu jiffies advance |
| reading/writing files silently | yes | rchar/wchar advance |
| blocked on disk or NFS io | yes — see the caveat below | state D |
| streaming over a socket, even slowly | yes | socket bytes move rchar/wchar |
| waiting on a peer that has sent nothing yet | no | zero bytes, zero CPU, and it parks in S — indistinguishable from sleep 3600 |
| genuinely idle or hung | no — killed, as intended | same signals, correctly absent |
The D row is level-triggered, and that has a cost. CPU and io count only when they move
between ticks; D counts whenever it is merely present. A process wedged permanently in D — a
dead NFS server, a failing disk — therefore looks alive to --stall on every tick forever. That is
a real hang that --stall will never kill, and --max-time is the only thing that bounds it. If
you touch network filesystems, do not run with --stall as your sole backstop.
The last two rows are the same signal, which is why the honest answer is a wide --stall: at the
30m default a slow API, a long DB query, a lock wait or a long-poll has to be silent for half an
hour before it trips. If an alias legitimately waits on a network peer, do not tighten --stall
to bound it — that is --max-time's job. --stall asks "is this dead?"; --max-time asks "has
this taken too long?", and only the second can bound a wait that looks identical to a hang.
Two Linux-only caveats. State D is read from /proc, so on macOS and Windows the disk/NFS row
falls back to output-only detection like everything else. And WSL2 under-reports socket bytes in
/proc/<pid>/io by roughly 5000x (6MB received moved rchar by 1187 bytes), so the streaming-socket
row still holds there but with far less margin — a run trickling a few bytes a minute can round to
no observable delta. File io accounting is exact on WSL2.
Dedup locks and parallel slots are scoped per bucket, not machine-wide. A bucket is
<name>@<dir> for a named run and <command>@<dir> for an unnamed one, where <dir> is the
.tman.kdl directory governing the run (or the cwd when there is none):
/repo-a tman test -> bucket test@/repo-a
/repo-b tman test -> bucket test@/repo-b # independent: does not queue behind repo-a
/repo-a tman run -- vite -> bucket vite@/repo-a # independent of test@/repo-a
So max-parallel 2 means two of this thing here, and a long test run in one checkout never
starves a build in another.
A run is admitted by holding one of its bucket's slot files open exclusively — not by counting
the live runs in the bucket. Only one runner can hold a given slot file, so runs launched at the
same instant queue as configured; counting could not offer that, because every racer reads the
count before any of them has a record to be counted. A slot is given up when the handle closes,
which includes the runner dying and the kernel closing it. A run that is already inside a
supervised tree (TMAN_RUN_ID set) is the same work as its parent and claims no slot of its own.
A bucket never sees another checkout, which is the point — until several projects compete for the
same machine. Three Rust and two C++ checkouts each building with every core is the case a
named queue is for. It is declared once for the machine in ~/.tman/tman.kdl (beside the run
store, so TMAN_HOME moves it too):
queue "compile" {
max-parallel 1
queue-timeout "8h" // optional; 8h by default
}Any project then joins it by name, with tman run --queue compile -- cargo build or from an alias:
alias "build" {
command "cargo"
args "build"
queue "compile"
}A project can also put every run in a queue at once, from its defaults:
defaults {
queue "compile"
}That reaches the runs no alias names: the PATH shims' tman run -- go test ./..., the Claude Code
hook's rewrite, and a bare tman run. An alias's own queue wins over it, and --queue wins over
both. A long-lived run — a dev server, a watcher — would hold a slot for hours, so start it with
tman run --no-queue; --no-queue together with --queue is refused.
- Arrival order. A run saves itself as
queuedin the queue's line before its first claim, and claims a slot only while nobody who joined earlier is still waiting.tman listshows each waiter's place asqueued #1,queued #2, andtman statusshows it asqueue: compile (position 2). A waiter whose tman died holds no place. Arrival is arrival as the store sees it: two runs that join within the same instant are ordered by id. - Bucket first, then queue. A run held up in its own project's bucket does not sit on a queue slot other projects are waiting for.
- Waiting is said, and bounded. A waiter says where it stands when it joins and once a minute
after that, and gives up after the queue's
queue-timeout— not the project's, which sizes its bucket's wait.--queue-timeouton the command line overrides both. - Cancellable. Ctrl+C or
tman killends a waiter askilledwith exit 130, its child never started, and the next in line moves up. - A queue nobody declared is refused with exit 127, naming the machine config. Nested runs join no queue, like they claim no bucket slot. The queue is read only when a run names one.
Resolved from the current directory upward, like .git. An alias (tman test, tman run --alias test) runs in the directory holding that .tman.kdl, so its args can be written relative to the
config and the record's cwd is the config's directory. A bare tman run -- cmd runs where you
are standing.
defaults {
stall "30m" // hang backstop, not a runtime budget — see --stall above
max-parallel 2
retain "24h" // how long finished run records are kept
// opt-in ceilings — a build is supposed to saturate cores and can want several GB
// max-mem 8192 // MB, summed across the process tree
// max-cpu 95 // percent, sustained, summed across the process tree
}
alias "test" {
command "npm"
args "run" "test"
}
alias "e2e" {
command "pytest"
args "tests/e2e" "--tb=short"
max-time "30m"
max-mem 4096
}The parser reads a subset of KDL: nodes with string and number arguments, // and /* */
comments, and /- slashdash, which comments out the next node or value. Properties
(max-parallel=4) are refused with a message naming the max-parallel 4 form to write instead.
The file is read whole or refused with exit 127. A node tman would otherwise skip is an error
naming it: an unknown or misspelt setting (max_time), a setting given twice or with no value, a
second defaults block or alias of the same name, a block cut off before its }, or a /*
comment that never closes. Each of those used to load, leaving out a bound the file declared.
Every run governed by a .tman.kdl writes its combined stdout and stderr to .tman/<alias>.log
next to that config, and a run that does not pass also writes .tman/<alias>.fail.log — a
digest carrying the outcome, the command, and every failure line in the log with the lines that
followed it, plus the tail. tman prints the digest path on stderr when it writes one.
$ ./test
...
tman: failures logged to /home/you/project/.tman/test.fail.log
$ cat .tman/test.fail.log
tman failure digest — test
outcome: exit 1
command: /usr/bin/python3 -m pytest -q
cwd: /home/you/project
started: 2026-08-23 16:30:40Z (2.1s)
full log: /home/you/project/.tman/test.log
── 3 failure line(s) ──
7: E AssertionError: widget count wrong
8: E assert 1 == 2
9:
10: test_smoke.py:2: AssertionError
11: =========================== short test summary info ============================
12: FAILED test_smoke.py::test_fails - AssertionError: widget count wrong
The point is the clearing. Both files are truncated at the start of every run, and the digest is
deleted when a run passes, so what is on disk always describes the last run of that alias. A
digest that outlived the failure it reported would be worse than none, because it reads exactly
like a real one — the presence of .tman/*.fail.log is the signal that something is broken now.
That is what makes it useful to an agent: it can answer "which test failed" by reading a path it can name, without re-running the suite, and long after the console output it came from is gone.
Failure lines are matched by shape, not by runner — FAILED …, --- FAIL:, [FAIL],
Failed!, panic:, not ok, : error …, ×, ●, and friends. A runner whose output nothing
matches degrades to "no failure lines matched" plus the tail, never to an empty file that reads
like a clean run.
| location | .tman/ beside the governing .tman.kdl; tman init --gitignore ignores it |
| three files per alias, not per run | <alias>.log, <alias>.fail.log, and <alias>.lock, so the path is nameable without looking a run id up first. The .lock is what keeps two runs from writing one log: a named run is already alone by its name lock, but max-parallel 2 admits two unnamed tman run -- npm test at once, so each run claims the lock while it writes. The second claimant gets no log and says so — tman: .tman/npm.log is held by a concurrent run; this run's output is not captured. The lock file is never removed, for the same reason as the store's locks below |
| nested runs | a run inside a supervised tree (TMAN_RUN_ID set) opens no log, as it claims no slot: its parent is already capturing the same output, and a second file under another name is the one an agent reads by mistake |
no .tman.kdl |
no log — an unconfigured tman run has no project to write into, and .tman/ dirs scattered through arbitrary cwds is not a side effect a supervisor should have |
| size | the full log is capped at 64 MB, after which the most recent 512 KB is kept and the cut is marked |
| killed runs | get a digest too: the outcome line names the kill reason, and the tail is the last output before the silence |
| a log write that fails | a full disk or a vanished mount stops capture with one line on stderr; the run goes on, and its digest is still written, with a capture: failed line saying the full log is incomplete |
Which mechanism you get depends on one property of your agent: whether its pre-tool hook can replace a shell command, or only allow and deny it. There is a guide per agent, each with the exact config, a runnable adapter where one is needed, and a smoke test:
| agent | integration | hook event | guide |
|---|---|---|---|
| Claude Code | rewrites | PreToolUse |
setup/claude-code |
| Codex CLI | rewrites | PreToolUse |
setup/codex-cli |
| Gemini CLI | rewrites | BeforeTool |
setup/gemini-cli |
| opencode | rewrites | tool.execute.before |
setup/opencode |
| Cursor | gates | beforeShellExecution |
setup/cursor |
| Antigravity | gates | PreToolUse |
setup/antigravity |
| Kimi Code CLI | gates | PreToolUse |
setup/kimi |
| GitHub Copilot CLI | gates | preToolUse |
setup/copilot-cli |
| Aider, Amp, Windsurf, Zed, CI | shims only | — | setup/other-agents |
Per-command caps — what --stall should be for a cold Rust build, why a dev server must never get
a --max-time — are in the tuning guides, one page
per test, lint, and build tool.
Shims only catch commands that go through a shell lookup, on a machine where tman init --shims
ran. An agent calling a Bash tool with npm test walks straight past them. tman hook pretooluse
is the same policy applied one level up — it reads the tool call on stdin and re-issues bare test
and build commands through tman:
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "tman hook pretooluse" }] }
]
}
}| the agent runs | what happens |
|---|---|
go test ./... in a project with .tman.kdl |
rewritten to /path/to/tman run -- go test ./..., and the rewrite is announced |
go test ./... with no .tman.kdl |
runs unchanged; the agent is told it was unsupervised |
tman test, or anything inside a supervised tree (TMAN_RUN_ID set) |
untouched — no double supervision |
cd app && npm test, CI=1 npm test |
runs unchanged, with a note; this hook does not parse shell and will not prefix a string it did not parse |
git status, npm run dev, anything else |
untouched |
| any of the above, when tman cannot prove which binary it is running as | runs unchanged, with a note naming the path it refused to use |
It supervises exactly what was asked for (tman run -- <command>), never the project's alias of
the same name — an alias can point somewhere else, and silently running something other than the
command in the transcript is worse than running it unsupervised.
The rewrite names the running binary by absolute path, not tman, because the Bash tool
resolves a program name against its own PATH — which frequently does not include ~/.local/bin
or the node bin directory tman installs into.
That path has to be proven to be tman before it is emitted: fully qualified, named as tman ships
(tman or tman.exe), and present on disk. Nothing else counts as proof. Launched as
dotnet tman.dll rather than as the tman executable, the running process is the shared dotnet
host, and rewriting to it would hand the agent dotnet run -- go test ./... — which, in a
directory holding a .csproj, builds and launches an unrelated application. So whenever the hook
cannot prove what it is about to name — no path, a bare or relative one, a path that is gone, or
one belonging to some other host — it warns, names the path it rejected, and leaves the command
exactly as written.
It cannot block you. A missing binary, a malformed request, an unreadable project: every
failure path leaves the command exactly as written. Exit code 2 is the only one Claude Code treats
as a block, and this hook never returns it — so tman being uninstalled costs supervision, never
the build.
There is no daemon and no cron entry. Every tman command — including tman list — performs the
same sweep before it does anything else:
- kills orphans (a live child whose runner died, e.g. after a machine suspend); a record whose
child had already gone is marked
killed: child exit status unknown, never as a clean exit. A run whose runner is still alive is left alone: the runner is the one writer of its record - deletes kill requests (
<id>.kill, seetman killbelow) once they pass the same window - deletes finished records older than
retain(default 24h), along with unreadable or off-schema record files that nothing else would ever revisit
Lock files are not part of it. A lock is claimed by holding its file open exclusively, so the
kernel releases it when its runner dies and the next run of that bucket takes the same file over
in place. Removing one is never safe while tman is running — a run that opened the name a moment
earlier would take the lock as the sweep dropped it and end up holding a file with no name, while
the next run created a fresh one — so nothing in tman removes them. ~/.tman/runs keeps one
.lock per bucket it has seen for the name dedup, plus one per parallel slot that bucket has ever
handed out.
The bound, stated plainly: each is ~50 bytes but occupies one filesystem block, so budget about
4 KB per bucket. A development machine reuses a handful of buckets and settles at a few dozen
kilobytes. The one case that grows without limit is a host whose working directory changes every
build — a CI runner whose workspace path carries a build number — which seeds a new bucket each
time: on the order of 20 MB a year at twenty builds a day. If that is you, rm ~/.tman/runs/*.lock
while no runs are live, from the same cleanup step that clears the workspace. tman deliberately
does not do this for you: making it safe against a concurrent claim costs every tman run a
store-wide lock, which is a poor trade for reclaiming a few megabytes a year.
tman clean runs that sweep on demand and prints the counts. Records are canonical on disk:
absolute resolved command paths, absolute cwd, one nested Caps object, and a schema version, so a
record written by a different tman version is discarded rather than half-read.
| code | meaning |
|---|---|
| 0–n | child's own exit code |
| 74 | the run store (~/.tman, or TMAN_HOME) cannot be written; the message says how to fix it |
| 124 | timed out (--max-time) |
| 125 | stalled (--stall) |
| 126 | culled (--max-mem / --max-cpu / --limit-mem) |
| 127 | command / config not found; a program that cannot be started is recorded as startfailed with the reason, and gets a digest |
| 130 | killed (dedup refusal, queue timeout, tman kill, Ctrl+C, exit status unknown) |
tman never reports 0 for a run it did not see finish. A Ctrl+C interrupts the run as a whole, so a child that traps the signal and exits 0 on its way out is still reported as 130.
tman is built for one machine at a time — a developer's laptop, or a department-sized CI host with a handful of runners. At that size it does what it says: a rogue test runner does not sit in a loop on your battery, and a test server does not hold a port for the rest of the afternoon.
It is not a fleet scheduler. There is no cross-machine coordination, no central store, and nothing here is tuned for dozens of simultaneous runs or for hosts that accumulate work indefinitely. If you are running an agent farm or a build fleet, you want a job scheduler, and tman under it is fine — but it will not be the thing keeping that fleet in order.
- Full docs — https://dev.standardbeagle.com/tman/docs/
- AI agent setup — https://dev.standardbeagle.com/tman/setup/ (Claude Code, Codex, Gemini CLI, Cursor, Antigravity, Kimi, opencode, Copilot CLI)
- Per-tool tuning — https://dev.standardbeagle.com/tman/tuning/ (Vitest, Jest, pytest, go test, dotnet test, cargo, Playwright, RSpec, Gradle, ESLint, Ruff, Biome, golangci-lint, tsc, Vite)
- Release history — CHANGELOG.md
Regenerate the demo gif with vhs assets/demo.tape.
