Skip to content
standardbeaglePublic

Latest commit

 

History

213 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

tman — supervise runaway test processes from AI coding agents

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.

npm license docs

Works with Claude Code, Codex CLI, Gemini CLI, Cursor, Antigravity, Kimi, opencode, Copilot CLI, and anything else that shells out.

demo

Contents — Why · Install · Quick start · Commands · Run flags · Buckets · .tman.kdl · Run logs · AI agent setup · Housekeeping · Exit codes · Scope

Why

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 like go test keeps 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 4 runs the tree on four CPUs (an affinity mask, so nproc and every runner's worker pool see four), and --limit-mem 8g is a ceiling the kernel holds rather than one tman samples; Linux and Windows
  • orphan reaping — every tman command 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 test refuses duplicates; --replace kills the old run and waits for it to hand the name back
  • resource gating — --max-parallel 2 queues excess runs instead of stampeding cores
  • failure logs — every supervised run leaves .tman/<alias>.log, and a failed one leaves .tman/<alias>.fail.log with 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.kdl per project, with repo-root shims so ./test is supervised transparently
  • ~3.8 MB native binary, zero runtime deps, cross-platform (linux/mac/windows, x64/arm64)

Install

npm

npm install -g @standardbeagle/tman

shell one-liner

curl -fsSL https://raw.githubusercontent.com/standardbeagle/tman/main/install.sh | sh

from 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.

Quick start

# 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

Commands

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

Run flags

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.

Kernel-enforced limits

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-cpus narrows what the tree sees, not just what it gets. nproc, .NET's ProcessorCount, Go's GOMAXPROCS and pytest-xdist's -n auto all 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 of limit-cpus 4 on a 16-CPU machine get eight CPUs between them, not the same four; tman status shows 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-mem ends 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 run culled with 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.max only on a cgroup handed to it, so limit-mem takes one from whoever hands one out:

    • By default, the systemd user manager. The command runs through systemd-run --user --scope with MemoryMax, MemorySwapMax=0 and OOMPolicy=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-linger starts 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, with memory.max, memory.swap.max 0 and memory.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 of cgroup.procs in 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-mem stays 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-mem and max-cpu there.

--stall is a hang backstop, not a runtime budget. It answers "is this process dead?", not "is this taking too long?" — use --max-time for the latter. A cold go build ./..., npm run typecheck or dotnet test can 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, and tman init scaffolds the same value from the same constant. It was 60s through 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 like go build ./... at 60s that succeeded 14 other times, once taking 75s.

If your .tman.kdl has no stall line, you get 30m — including configs written against 0.2.0 and earlier. That is deliberate. Omitting stall never 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 in tman list, endable with tman kill, and holds at most one of its bucket's max-parallel slots. If you do want a tight bound, that is --max-time, or write stall explicitly 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: /proc on Linux, libproc on macOS, a Toolhelp snapshot on Windows. --stall, --max-mem and --max-cpu therefore see a forked worker wherever it runs. What differs is the activity signal. Only Linux reads process states, so the D (kernel io wait) clause below is Linux's alone. The io counter is rchar/wchar on 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 outlive dotnet build by design, are left running.

Which waits --stall protects

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.

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: 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.

Named queues

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 queued in the queue's line before its first claim, and claims a slot only while nobody who joined earlier is still waiting. tman list shows each waiter's place as queued #1, queued #2, and tman status shows it as queue: 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-timeout on the command line overrides both.
  • Cancellable. Ctrl+C or tman kill ends a waiter as killed with 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.

.tman.kdl

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.

Run logs

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

AI agent setup

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.

Claude Code hook

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.

Housekeeping

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, see tman kill below) 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.

Exit codes

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.

Scope

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.

Docs + demo

Regenerate the demo gif with vhs assets/demo.tape.

License

MIT

Releases

Packages

Contributors

Languages