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

commandwhat 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 cleanrun 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 pretooluseClaude Code PreToolUse hook: route bare test/build commands through tman

run flags

flagdefaultwhat it does
--name Ndedup lock; refuses to start if a live run has the same name in this directory
--replaceoffwith --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 Twall-clock limit → kill, exit 124
--stall T30mno output and no cpu/io/kernel-io-wait activity for T → kill, exit 125
--max-mem MRSS above M (MB, or 2g) → cull, exit 126
--max-cpu PCPU above P% for 3 consecutive seconds → cull, exit 126
--max-parallel N2queue until one of the bucket's N slots can be held
--queue-timeout T5mgive 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:

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

codemeaning
0–nchild's own exit code on normal completion
124timed out (--max-time)
125stalled (--stall)
126culled (--max-mem / --max-cpu)
127command / config not found
130killed (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 runswhat happens
go test ./... in a project with .tman.kdlrewritten to /path/to/tman run -- go test ./..., and the rewrite is announced
go test ./... with no .tman.kdlruns unchanged; the agent is told it was unsupervised
anything already inside a supervised treeuntouched — no double supervision
cd app && npm test, CI=1 npm testruns 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 asruns 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