tman documentation
Reference for every command, flag, and file. For attaching tman to a specific AI coding agent see the setup guides; for recommended caps per test or lint tool see thetuning guides.
install
# npm npm install -g @standardbeagle/tman # or the shell one-liner curl -fsSL https://raw.githubusercontent.com/standardbeagle/tman/main/install.sh | sh
commands
| command | what it does |
|---|---|
tman run [flags] -- <cmd> [args] | run a process under supervision |
tman run --alias <name> [args] | run an alias from .tman.kdl, in that file's directory |
tman <alias> [args] | shorthand for an alias |
tman list [--all] [--json] [--label K=V]... | list live runs (or all records), with each run's project root; --label keeps the runs carrying every pair given, and --json prints one versioned document ("schema": 1) with each run's identity, labels, state, end reason, exit and timestamps for another program to read |
tman kill <id|name|all> | kill run(s) by id or name, or all of your own (same project root and agent session; others are left alone); all --everywhere reaches every run but needs a terminal, exit 77 without one; --reason <text> is recorded with the kill, and the killed run exits 75; an unknown flag refuses the command with exit 127 rather than being skipped |
tman clean | run the housekeeping sweep now and report what it did |
tman queue [<name>] [--json] [--label K=V]... [--cores N] [--mem M] | each machine queue as admission sees it: budget, admitted runs and what they book, unattributed host load, what is free, the line in order, and whether a run of the given cost arriving now would be admitted at once — the queue's own rule. --json prints one versioned document ("schema": 1) for a scheduler |
tman probe --pid <pid> [--start-ticks <ticks>] | is a pid you recorded earlier still that process? exit 0 mine, 1 gone, 3 not mine (pid reused); read-only, --start-ticks is Linux only |
tman status [id|name|id-prefix] [--json] | summary counts, or one run's detail |
tman init [--shims] [--gitignore] | scaffold .tman.kdl (+ repo-root shims) |
tman hold --cores N --mem M [--pid <pid>] [--queue <name>] [--label K=V]... | book the cost of work another program started into a machine queue, for as long as it lives, so a client such as an agent runner reserves admission without tman knowing what the work is. Without --pid, the hold lasts until it is killed (Ctrl+C). The queue defaults to the machine's default. Labels work as on tman run |
tman hook pretooluse | Claude Code PreToolUse hook: route bare test/build commands through tman |
run flags
| flag | default | what it does |
|---|---|---|
--name N | — | dedup lock; refuses to start 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 | — | RSS above M (MB, or 2g) → cull, exit 126 |
--max-cpu P | — | process-tree CPU above P% for 3 consecutive seconds → cull, exit 126 |
--limit-cpus N | — | run the whole tree on N CPUs through an affinity mask the kernel enforces, so nproc sees N; Linux and Windows, refused on macOS |
--limit-mem M | — | memory ceiling the kernel enforces on the whole tree (a systemd user scope on Linux, a Job Object on Windows); crossing it ends the tree, exit 126; refused on macOS |
--max-parallel N | 2 | queue until one of the bucket's N slots can be held; a run that waits says so once on stderr, and once more when it gets a slot |
--queue-timeout T | 5m | give up waiting — for a bucket slot, or for a named queue's admission; the run exits 75 and its record says QueueTimedOut and QueuedSeconds |
--queue Q | — | wait in the machine-wide named queue Q, declared in ~/.tman/tman.kdl as queue "Q" { cores "80%"; mem "80%" } and admitted by measured cost (cores and memory); with no queue named anywhere, the machine's default queue if one is declared. A run that had to wait says tman: admitted to 'Q' after 3m05s on stderr when it starts |
--no-queue | off | join no queue, whatever .tman.kdl says, so the run books nothing; an alias can opt out without a flag with no-queue true |
--exclusive | off | run alone in its queue: wait for admitted work to finish, then hold the queue until it exits; past the starvation bound it starts beside the runs still admitted and names them, but never beside another exclusive run |
--service | off | a run that never ends — a dev server or watcher: it books its cost in the queue like tman hold, so no exclusive or starving run waits for it to end; an alias declares it with service true |
--label K=V | — | file the run under scope K=V (repeatable). TMAN_LABELS="k=v,k=v" in the environment does the same for every tman a shell starts, a flag winning a key both name; the run hands the merged set to its children as TMAN_LABELS. A label tman cannot read refuses the run with exit 127. tman list and tman queue filter by labels |
Durations accept ms, s, m, h (e.g. 90s, 10m). Cap precedence: CLI flags > alias block > defaults block > built-ins. A flag whose value cannot be read refuses the run with exit 127 and names the flag (bad --max-cpu); nothing is clamped or defaulted.
--max-time, --max-mem, and --max-cpu have no built-in default. A build is supposed to saturate cores and can legitimately want several GB, so culling it out of the box would break tman run -- vite build in a project with no config.
platform note
Every sample covers the whole process tree on all three platforms — /proc on Linux, libproc on macOS, a Toolhelp snapshot on Windows — so --stall, --max-mem and--max-cpu see a forked worker wherever it runs. Only Linux reads process states, so the kernel-io-wait (D) signal is Linux's alone; the io counter is rchar/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 not counted anywhere.
buckets
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 of 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 supervised process that re-enters tman — a PATH shim calling tman again — is recorded as a child of the run that launched it and does not claim a second slot.
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.
housekeeping
There is no daemon and no cron entry. Every tman invocation — includingtman list — sweeps the run store (~/.tman/runs, override withTMAN_HOME) before doing its own work:
- Orphans are killed. If a recorded run's runner process is dead but its child is still alive — the classic agent-got-distracted or machine-suspended case — the child's entire process tree is killed and the record marked
reaped. PID reuse is guarded by comparing process start times. - Finished records past
retainare deleted (24h by default), along with record files this tman cannot read, which nothing else would ever revisit.
Lock files are not part of the sweep. A lock is claimed by holding its file open exclusively, so a runner killed mid-run frees its bucket the moment the kernel drops its handle, and the next run of that bucket takes the lock over in place. Nothing in tman ever removes a lock file: ~/.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 lock file 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 the same sweep on demand and prints the counts. Records are canonical on disk — the command resolved to an absolute path, an absolute cwd, the caps the run actually ran under, and a schema version, so a record written by a different tman version is discarded rather than half-read.
.tman.kdl
Resolved from the current directory upward (like .git). An alias runs in the directory holding that .tman.kdl, so its args can be written relative to the config; a bare tman run -- cmd runs where you are standing. tman init auto-detects npm (test, e2e, lint, integration, build, typecheck), pytest, go, and make projects.
defaults {
stall "30m"
max-parallel 2
retain "24h" // how long finished run records are kept
// opt-in ceilings; builds legitimately saturate cores and eat RAM
// max-mem 8192
// max-cpu 95
}
alias "test" {
command "npm"
args "run" "test"
max-time "10m"
}
alias "e2e" {
command "pytest"
args "tests/e2e" "--tb=short"
max-time "30m"
max-mem 4096
}
alias "dev" {
command "vite"
service true // a dev server never ends: booked, but never waited for; the tman <alias> door takes no flags
}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 themax-parallel 4 form to write instead.
shims
tman init --shims writes an executable per alias at the repo root (./test,./e2e, plus .ps1 and .cmd on Windows — the extensionless script covers bash/zsh/fish/sh everywhere including Git Bash) that simply exec tman run --alias …. Existing muscle memory and agent habits keep working; supervision is transparent.--gitignore appends the shims and /.tman/ to .gitignore, skipping any alias whose name is already a directory so /test never hides a test/ tree. If PowerShell refuses to run the .ps1 shims, allow local scripts once:Set-ExecutionPolicy RemoteSigned -Scope CurrentUser.
exit codes
| code | meaning |
|---|---|
| 0–n | child's own exit code on normal completion |
| 124 | timed out (--max-time) |
| 125 | stalled (--stall) |
| 126 | culled (--max-mem / --max-cpu) |
| 127 | command / config not found |
| 75 | stopped by tman: killed on request (tman kill, or --replace by another run; the record says KillRequested and the reason), or ended by its queue-timeout (the record says QueueTimedOut and QueuedSeconds) |
| 130 | killed (dedup refusal, Ctrl+C, exit status unknown) |
claude code hook
Shims only catch commands that go through a shell lookup, in a project wheretman init --shims ran. An agent calling a Bash tool with npm test walks straight past them. tman hook pretooluse applies the same policy 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 |
| anything already inside a supervised tree | untouched — no double supervision |
cd app && npm test, CI=1 npm test | runs unchanged, with a note; the hook does not parse shell and will not prefix a string it did not parse |
| tman cannot prove which binary it is running as | runs unchanged, with a note naming the path it refused to use |
It never blocks a command. It also supervises exactly what was asked for (tman run -- <command>) and never the project's alias of the same name — an alias can point elsewhere, and silently running something other than the command in the transcript is worse than running it unsupervised.
scope
tman supervises 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. Under a build fleet's own scheduler tman is fine; it will not be the thing keeping that fleet in order.
building
# NativeAOT, zero external dependencies, .NET 10 SDK (and cc on Linux/macOS) dotnet publish -c Release -r linux-x64 # win-x64, osx-arm64, linux-arm64