Run a fleet of coding agents against one repo, in parallel, without them stepping on each other - from a single tmux session you drive like a dispatcher. Claude Code is the built-in default; point any repo or slot at a different agent (see Agents).
sm lays out one git worktree ("slot") per agent ("worker"), builds a tmux session with
one worker pane per slot plus a control window (the "desk"), and gives you the operations
a dispatcher needs: see who's free, hand off work, check in, and reclaim finished slots.
It ships as a CLI (sm) and a zero-dependency MCP server, so both you and your agents can
drive it.
One coding agent per repo checkout is the natural limit: two agents in one working tree clobber each other's branches, index, and build artifacts. Worktrees fix the isolation, but running many of them surfaces the next set of problems - the ones slot machine actually exists for:
- "Which slot is safe to reuse?" A lock file lies (its owner may be dead), and an empty-looking worktree may hold a worker mid-task that just hasn't branched yet.
- "What is everyone doing?" Eyeballing N panes doesn't scale, and transcript silence doesn't mean idle - a slow tool call looks identical to done.
- "How do workers reach you?" Scraping their panes is lossy; results get stranded in scrollback.
- Shared one-of-a-kind resources - an authenticated browser, a port - get raced and killed.
What the workflow gains you: N tracks of work in flight with one dispatcher (you, or an agent in the desk seat) at the wheel, safe reuse decided from evidence, and a structured loop - find capacity, hand off, check in, reclaim - instead of digging through tmux by hand.
A session is a floor with a desk and a row of slots. The dispatcher sits
at the desk; a worker sits in each slot. The dispatcher never leaves the desk and
never touches a slot's files; workers never leave their slot. Work flows one way (desk ->
slot via sm worker run / sm msg send), results flow back the other (slot -> desk via
sm msg report, read with sm msg inbox).
Each slot cycles:
free -> handed a task (locked, wip) -> PR open (waiting-merge) -> merged -> reset -> free
sm slot ls classifies every slot along that cycle from its git branch + GitHub PR state,
and sm slot reset closes the loop by returning a merged slot to a clean base. The
dispatcher's whole job is keeping that cycle turning: sm worker role prints the full
briefing for either seat (dispatcher brief at the desk, worker brief inside a slot).
slot machine is opinionated. The opinions, explicitly:
- One slot, one worker, one track of work. No sharing, no doubling up.
- The dispatcher delegates and observes - never edits. If a slot needs a fix, its worker makes it.
- Freeness is evidence, not vibes. Reusability is derived from branch + PR state;
a lock is authoritative for busy, never for free (owners die;
sm lock prunereclaims stale ones deterministically). - Structured back-channel over pane-scraping. Reporting via
sm msg reportis part of a worker being done. - Shared one-of-a-kind resources are locked, not raced.
sm lock claim browseris atomic; the loser learns who holds it instead of killing it. - Rigid vocabulary. Every concept has exactly one name (below); a test fails the suite if the docs drift.
- Agents are first-class operators. Every query and action takes
--json(the interactive session builder is the one exception), the MCP server mirrors the CLI (minus the client-interactive commands: session create/attach/detach, preflight, stats), and usage is recorded locally (sm stats) so the interface evolves from evidence.
The rigid definitions (sm help vocab):
| term | definition |
|---|---|
| repo | the git repo a session is built around; everything derives from it |
| slot | one worktree of the repo; holds at most one worker and one track of work |
| worker | the coding-agent process in a slot's pane (Claude by default, or any configured agent) |
| desk | the session's first window; the seat the dispatcher runs the session from |
| dispatcher | the role at the desk: finds capacity, hands off, checks in, reclaims - never edits a slot |
| session | the multiplexer session laying out the desk + slot panes for one repo |
| lock | a claim on a slot (.worktree-lock) or a shared machine resource (e.g. the authenticated browser) |
Node >= 22 and a terminal multiplexer required - tmux by default, or zellij >= 0.44 (see
Multiplexers); gh (authenticated) powers PR-state classification.
git clone <this repo> && cd slot-machine
node bin/sm doctor --fixdoctor --fix detects and installs everything safely automatable: the ~/.local/bin
symlinks (sm, slot-machine, slot-machine-mcp), the tmux pane-title block, and MCP
registration with Claude Code (claude mcp add slot-machine), then re-verifies and tells
you what's left (e.g. gh auth login, PATH). Prefer manual? Symlink bin/* yourself and
register the MCP server with claude mcp add slot-machine -s user -- ~/.local/bin/slot-machine-mcp.
Published via the tylerbre/homebrew-tap tap
(node is the only dependency; sm is the shell bin):
brew install tylerbre/tap/slot-machineThe formula lives in the tap. To cut a release: npm run pack (produces
slot-machine-vX.Y.Z.tar.gz of tracked files at HEAD) -> attach it to the vX.Y.Z GitHub
release here -> bump the formula's url/sha256 in the tap
(shasum -a 256 slot-machine-vX.Y.Z.tar.gz).
sm repo use ~/code/acme # point sm at a repo; prefix/session/base derive from it
sm slot create a; sm slot create b; sm slot create c # set up three slots
sm session create # build + attach the session (desk + worker panes)
# from the desk:
sm slot ls # who's free? (git branch + PR state)
sm worker run "fix ABC-123: <link>" # hand a task to the first free worker
sm worker ps # who's working / idle / waiting?
sm worker logs a # one worker in depth: last message + pane tail
sm msg inbox # read what workers reported back
sm slot reset a # merged? return it to a clean base
sm # any time later: hop back into the most recent sessionsm doctor verifies the whole setup (tmux/git/gh, repo config, slots, pane titles);
sm doctor --fix-tmux writes the recommended pane-title settings into your tmux.conf
(a marked block, safe to re-apply) and applies them live - with many worker panes, the
border title is how you tell them apart.
Commands are namespaced by the noun they act on, with docker-style generic verbs
(ls, inspect, create, rm, kill):
sm doctor check environment + repo health
sm stats [--days N] command usage: counts, error rates, timings
sm floor [--watch] one-shot fleet snapshot: sessions, slots, locks, inbox, watch
sm journal [--tail N] [-s SLOT] the repo's turn journal: what happened, when, to which worker
sm watch --check [--ack] | [--loop] supervision digest: peek, surface durably, or block for events
sm help [ns] [cmd] | vocab overview, namespace, command detail, or vocabulary
sm repo ls known repos (current marked with *)
sm repo use REPO select the current repo (derives root/prefix/session/base)
sm repo inspect [REPO] one repo's resolved context
sm repo rm REPO forget a repo (config only)
sm repo config [--agent N] [--model M] set the repo's default agent instance + model
sm agents ls the agent roster: each instance, its plugin, models
sm agents dir [PATH] get/set where user plugins live (~/.config/slot/agents)
sm agents add NAME [--use P|--plugin F] add an instance (--env K=V, --models a,b, --default-model M, --mcp FILE)
sm agents rm NAME remove an instance (refused if another instance uses it)
sm session ls list the repo's running sessions
sm session create [N] [name] [--kill] build or attach a session (N = 2|3|4 panes/window)
sm session attach [NAME] attach/switch (default: most recent; bare 'sm' too)
sm session detach [NAME] detach your client (or every client of NAME)
sm session reload [NAME] add panes for slots created after the session was built
sm session kill NAME... | --all kill session(s); conversations survive on disk
sm slot ls [--free|--watch] classify each slot: free/merged (reusable) vs busy
sm slot inspect SLOT one slot in depth: branch, worker, lock, PRs
sm slot focus SLOT | -f jump the tmux client to a slot's pane
sm slot create LABEL [base] | rm LABEL create / remove a slot worktree (create takes --agent/--model)
sm slot config LABEL [--agent] [--model] set a slot's agent-instance/model override
sm slot reset SLOT [--force] return a slot to a clean base branch @ origin/<base>
sm worker ps [--watch] every worker: live/dead, activity, current task
sm worker run MESSAGE [--brief] hand a task to the first reusable slot's worker
sm worker logs SLOT [-n N] [-f] one worker in depth: activity, last message, pane tail
sm worker kill SLOT end a worker's process; its pane falls back to a shell
sm worker role [dispatcher|worker] print the desk->slots operating model (auto-detected)
sm worker preflight assert cwd is your slot worktree (workers, before git work)
sm msg send MESSAGE [-s SPEC|-f] type a line into slot panes (all, a subset, or -f first-free)
sm msg report "MSG" | sm msg inbox worker -> dispatcher back-channel (report to send, inbox to read)
sm msg inbox --unread only reports since your last read; advances the read cursor
sm lock ls list held resource locks (holder, age, task)
sm lock claim NAME [task] | release NAME lock a slot OR shared resource (e.g. browser) / free it
sm lock prune SLOT... | --stale remove stale worktree locks (dead owner session)
Add --json to most commands for machine-readable output; --repo DIR targets a repo
for one command. sm --help is the overview; sm help <ns> prints a namespace; sm <ns> <cmd> --help (or sm help <ns> <cmd>) gives detailed help with examples.
sm slot ls decides reusability from each slot's git branch + GitHub PR state (not just
the lock file):
| status | meaning |
|---|---|
free |
reusable - idle on its base branch |
merged |
reusable - all PRs for the branch are merged |
waiting-merge |
busy - open PR |
wip |
busy - commits ahead of the base, no PR yet |
dirty |
busy - uncommitted changes |
closed-pr |
busy - PR closed without merging |
locked |
busy - a live session holds the worktree |
stale |
busy - locked, but the owner session is dead (reclaim with sm lock prune) |
active |
busy - a live worker is mid-task (no branch/lock yet) |
Workers report; the dispatcher must actually read it. Supervision closes that loop with deterministic machinery instead of a recurring AI duty.
Report verbs. A report leads with a verb so the watch can triage it:
sm msg report "done: PR #123, 96%" # also: blocked: / needs-decision: / failed:
sm msg report "working: tests green" # progress note - absorbed, aged by its window
sm msg report "paused: waiting on UAT env" # declared wait - re-surfaces hourly
Verbs are parsed on read, never stored; a report with no verb always surfaces (unknown
demands attention). msg report prints a tip when the message has no verb.
The core is one pure decision. sm watch --check gathers evidence (inbox, claims,
worker liveness sampled twice, PR state) and classifies every event as absorb (positive
evidence of work) or surface (needs the dispatcher): attention-verb and verb-less
reports, stale paused: (> 60m), stalled working: (> 30m with no live activity),
crashed workers (claim held, worker gone in both samples), merged PRs on claimed slots.
Surfaced events print as a digest, capped at 5 lines with an overflow pointer. Exit 0 =
digest, 3 = nothing. A peek changes nothing and is safe from any seat, any time.
Acknowledgment is explicit and durable. sm watch --check --ack journals the facts
and advances a surfaced watermark for what it printed - only that - so a capped digest
drains batch by batch and every predicate re-decides from durable state (a watch that
restarts every 30 seconds classifies identically to one running for hours). Reading is a
separate act: sm msg inbox --unread shows reports past YOUR read cursor and advances
it, non-destructively. The watch never marks anything read.
Delivery is a plugin capability, not a core dependency. sm floor always shows the
truth (unread counts, oldest-unread age, watch armed or NOT armed) with zero setup. A
human can block on events with sm watch --timeout 600. For hands-free delivery into a
Claude Code desk session, the claude plugin installs seat-gated hooks (Stop +
UserPromptSubmit) into the desk project's settings:
sm doctor --fix # run in the desk project: installs the watch hooks
SM_DESK=1 claude # launch the desk seat - only this session delivers
Without SM_DESK=1 the hooks are silent no-ops, so extra desk agents and workers never
fight over delivery. A consecutive-block budget guarantees a broken check can never wedge
a session.
sm serve exposes the fleet to a browser: the same command surface the CLI and MCP
share (an explicit x-web allowlist of it), plus live event streams, on 127.0.0.1
only. It is API-ONLY - sm never serves HTML. The dispatcher cockpit web app lives in its
own repo (sm-cockpit), runs on its own origin, proxies /api to this bridge, and never
touches sm internals or state files.
sm serve # the API bridge (default port 7767)
sm serve --rotate-token # re-mint the pairing credential
Pairing: serve prints a one-time token; open the COCKPIT's URL with #token=<hex> and
the browser exchanges it for an HttpOnly session cookie, then drops it - after pairing,
no credential exists anywhere page JavaScript can read. The token is an execution
credential for this machine (the surface dispatches tasks and resets slots), so it lives
0600, compares constant-time, and rotation is one flag. Full wire contract:
docs/http-api.md.
Everything derives from a repo's main-worktree dir, so sm is multi-repo:
- root = the repo's parent dir (slots are siblings)
- prefix =
<name>-slot-(repoacme->acme-slot-) - session prefix =
<name> - base branch = the repo's default branch (
origin/HEAD), elsemain
Set the current repo (persisted in ~/.config/slot/config.json); all commands then act
on it:
sm repo use ~/code/acme # derive + select
sm repo use ~/code/foo --prefix wt- --session foo --base master # override derived values
sm repo ls # show current + known reposAny command also takes --repo DIR for a one-off repo without switching:
sm --repo ~/code/foo slot lsEach slot runs a coding agent. Claude is the built-in default, and an untouched setup behaves exactly as before. To run a different agent - or several separately-configured Claudes - slot machine resolves each slot to an agent instance through a small plugin contract, so the core never hardcodes any one agent.
- plugin - the conformance code for an agent type (
claudeis built in): how to launch it, tell whether its pane is working/waiting/idle, read its last message, and resume its transcript. - instance - a named, configured use of a plugin, kept in the roster
(
~/.config/slot/config.json). Instances sharing a plugin differ only by env, model, and MCP servers, sopersonal-claudeandenterprise-claudeare two instances of the oneclaudeplugin, each with its ownCLAUDE_CONFIG_DIR.
Per slot the agent resolves as: the slot's override -> the repo's default -> the built-in claude.
sm repo config --agent claude --model sonnet # this repo's default agent + model
sm slot create b --agent claude --model haiku # a slot pinned to a different model
sm agents add enterprise-claude --use claude \
--env CLAUDE_CONFIG_DIR=~/.claude-work # a second Claude, different account
sm repo config --agent enterprise-claude # make it this repo's default
sm agents ls # the roster + each instance's statusUser plugins. Point sm agents dir at a directory of plugin modules, then register one:
sm agents add my-agent --plugin my-agent.mjs --models fast,smart --default-model fast. A
plugin (or instance) can also declare its own MCP servers (--mcp servers.json), wired into
that agent by sm doctor --fix. Built-in agents are held to full contract conformance. A user
plugin that fails to load is skipped and reported by sm agents ls; one that loads but
misbehaves at runtime degrades (its slot is left at a shell) rather than crashing the tool.
The session/pane layer sits behind the same plugin-contract pattern as agents. tmux is the
built-in default, and an untouched setup behaves exactly as before. Zellij (>= 0.44) ships
as a second backend - select it globally in ~/.config/slot/config.json:
Every backend implements one contract (sessions > windows > panes, addressed by returned
handles; structured pane records; type/submit/capture primitives), conformance-tested for
both built-ins. Worker panes are stamped with their slot label at spawn, so slot correlation
survives a worker cd-ing away from its worktree on either backend.
Zellij is experimental, with two known v1 limits: sm worker kill cannot resolve the
pane's process (zellij exposes no pane pid - end the worker from inside its pane), and
worker live/dead detection for shell panes uses a prompt heuristic (zellij does not report a
pane's foreground command). Requires zellij >= 0.44.0; older versions are refused with a
clear error.
slot-machine-mcp is a zero-dependency stdio MCP server that wraps the CLI - each tool shells out
to sm <ns> <cmd> --json, so it stays in step with the CLI. The tools
mirror the CLI: sm_repo_ls, sm_repo_use, sm_doctor, sm_session_ls, sm_session_kill,
sm_session_reload, sm_slot_ls, sm_slot_focus, sm_slot_inspect, sm_slot_create, sm_slot_rm,
sm_slot_reset, sm_worker_ps, sm_worker_run, sm_worker_logs, sm_worker_kill, sm_worker_role, sm_msg_send,
sm_msg_report, sm_msg_inbox, sm_lock_claim, sm_lock_release, sm_lock_ls,
sm_lock_prune.
The agent-roster commands (sm agents ..., sm repo config, sm slot config) are dispatcher
setup and are intentionally CLI-only - not exposed as MCP tools. A worker reaches this MCP
server itself; a plugin can register additional MCP servers for its agent, wired by
sm doctor --fix.
npm test # node --test (discovers test/)
npm run lint # eslint (@antfu/eslint-config - one opinionated preset; it also owns formatting)
npm run format # eslint --fix (antfu formats; there is no Prettier)
npm run pack # slot-machine-vX.Y.Z.tar.gz of HEAD (tracked files only)lib/ is organized by responsibility, no barrel files - import from the specific module:
slots/-pure.mjs(classification/parsing, unit-tested),locks.mjs(the worktree document: claim/worker/turn sections, serialized atomic writes, embedded resource locks),journal.mjs(the append-only per-repo turn journal),gather.mjs(multiplexer/git/gh state).mux/- the multiplexer plugin system:contract.mjs(the op catalog),tmux.mjsandzellij.mjs(backends; every backend format string lives in its backend),index.mjs(registry + the send-reliability helpers built on backend primitives).plugin/contract.mjs- the ok/err envelope and guarded call path shared by the agent and multiplexer plugin systems.commands/- one file per namespace (repo/session/slot/worker/msg/lock/top) plusshared.mjs(cross-command helpers).schema.mjs+elevators.mjs- the zero-dep JSON-Schema validator, loader, and version-migration runner; the schemas themselves live inschema/(lockfile, config, inbox, usage, one per command).argspec.mjs- the single arg-spec adapter so the CLI parser and the MCP tools cannot drift.exec.mjs(git/gh/OS-process plumbing),format.mjs(output),context.mjs(repo resolution + config),constants.mjs(config/data),help.mjs(usage/vocabulary text),router.mjs(dispatch table).
bin/sm and bin/slot-machine are thin entry points over the router; bin/slot-machine-mcp is the
MCP server, whose tools are derived at runtime from schema/commands/*.json. See
docs/architecture.md for diagrams and CHANGELOG.md for the
release history. Integration tests drive real tmux against this machine's configured repo and skip
cleanly where there isn't one.

{ "settings": { "mux": "zellij" } }