docs
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 |
tman <alias> [args] | shorthand for an alias |
tman list [--all] | list live runs (or all records) |
tman kill <id|name|all> [--stale-only] | kill run(s) by id, name, or everything |
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 (+ repo-root shims) |
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 | — | CPU above P% for 3 consecutive seconds → cull, exit 126 |
--max-parallel N | 2 | queue until one of the bucket's N slots can be held |
--queue-timeout T | 5m | give up waiting for a parallel slot |
Durations accept ms, s, m, h (e.g. 90s, 10m). Cap precedence: CLI flags > alias block > defaults block > built-ins.
--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
Activity-aware stall detection walks the whole process tree on Linux only, where/proc exposes parent pids and per-process io counters cheaply. On macOS and Windows a sample sees the supervised process alone, so work done by a descendant is invisible and--stall falls back to output-only detection — give quiet-but-busy runs a longer--stall there. --max-mem has the same limit: it sums the tree on Linux and measures the root process elsewhere.
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). 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
}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 to .gitignore. 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 |
| 130 | killed (dedup refusal, queue timeout, tman kill) |
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 dotnet publish -c Release -r linux-x64 # win-x64, osx-arm64, linux-arm64