Supervise units that run in terminals. muster reads
~/.config/blit/instances/NAME/muster/ (NAME defaults to default), starts
what it finds in dependency order, restarts what crashes or what you edit, and
journals every decision. A supervised unit is an ordinary blit PTY, so
supervised and attachable are the same thing. BLIT_MUSTER_DIR remains an
explicit override.
blit ext run --persist --restart always muster extensions/dist/muster.wasm
blit @muster list # every unit and instance
blit @muster status epic/gateway # one unit, with its retained runs
blit @muster start|stop|restart NAME # a unit, or a whole instance
blit @muster instantiate blit epic \
PORTS=auto # a stack instance, running
blit @muster log -n 20 # why something is not running
blit @muster doctor # everything wrong with the directory
blit @muster env api --values # which .env file won
blit @muster schema > ~/.config/blit/muster.schema.jsonThe design and its reasoning are in
docs/design/muster.md. This file is what you
need to run it.
An entry's name is its basename without .json, unique because the filesystem
says so. A top-level file is a unit unless stack, include, or worktrees
selects an instance, an included unit directory, or a dynamic worktree source.
Leading . is ignored, and nothing below the second level is read.
~/.config/blit/instances/default/muster/
postgres.json a unit
blit/ a stack of templates
stack.json its parameter declarations
server.json
main.json an instance → main/server, main/gateway
epic.json another → epic/server, epic/gateway
A unit needs exactly one of command (an argv, exec'd directly) or shell (a
line for the server's login shell — fish, where $SHELL is fish). Both is
refused, and so is neither.
A stack or an include may name a directory anywhere, so a stack can live in
the repository it starts and the configuration directory holds only a pointer.
A bare word is a subdirectory; anything with a / or a leading ~ is a path.
// ~/.config/blit/instances/default/muster/epic.json — a worktree stack
{ "stack": "/src/blit/.claude/worktrees/epic/.blit/muster",
"vars": { "PORTS": 10010 } }
// ~/.config/blit/instances/default/muster/work.json — ordinary units
{ "include": "~/work/units" }The two differ in naming, which is the whole reason both exist. An instance
qualifies its templates as <instance>/<template> — epic/server — which is
what you want when the same stack runs once per worktree, and which sorts every
unit of an instance together. An include does not: its units keep their own
names, as though the files had been dropped in the configuration directory. Two includes offering
one name is therefore an error rather than a merge — doctor names both files,
and omit resolves it. An included directory holds units only; its
subdirectories are not stacks, because an instance names a stack by path.
Inside a stack, ${STACK_DIR} is the stack's own directory, and a relative
cwd or envFile resolves against it. ${BLIT_SOCKET} is the named local
server socket for that instance. So a stack at <repo>/.blit/muster/ reaches
its checkout with "cwd": "../.."; the stack location is the checkout identity.
Discovery never leaves the configuration directory. Muster does not look for
.blit/muster in a repository, a cwd, or any ancestor of one: cloning a
repository and starting a server must not run its code. The pointer file is a
deliberate act — and the same act already grants arbitrary execution, so this
adds reach rather than privilege. What it does add is that a branch switch can
change what a template says, since the file is written by git checkout
rather than by you. restartOnChange is on by default, so that edit takes
effect the way any other does.
Each distinct external directory costs one FS_SYNC, shared between pointers
that name the same one, and dropped when the last pointer goes away.
A worktree source is one deliberate pointer to a repository's main checkout and a stack path inside it:
{
"worktrees": "/src/blit",
"stack": ".blit/muster",
"vars": { "PORTS": "auto" }
}The source file's name is the main instance name. If this is blit.json, the
main units are blit/server and so on; a linked worktree whose Git
administrative id is epic becomes blit-epic/server. Muster watches only
Git's worktrees/*/gitdir pointers and each selected stack directory, not the
repository or object database.
A port parameter used this way declares both its span and its first block:
{ "kind": "ports", "span": 4, "start": 10000 }The main worktree receives start exactly. Linked worktrees receive the first
free block above it; the concrete leases are durable in kv, so inserting or
removing another worktree does not move them. A removed worktree keeps its
reservation and receives it again if it returns.
There is no "re-read the directory" verb, because there is nothing to ask for: muster watches every directory it reads, and an edit is loaded before you could type a command about it.
The one exception is a directory whose FS_SYNC the server refused — usually a
pointer written before its target exists, which is the ordinary order of events
when a stack lives in a worktree you are about to create. That cannot arrive by
itself, because nothing watches a directory that is not being watched, so it is
the only thing here that polls: retried after 5s, doubling to a minute, reset
whenever any watch succeeds. blit @muster rewatch forces it now, and says what
happened:
$ blit @muster doctor
/src/blit/wt/epic/.blit/muster cannot watch this directory (status 1)
$ git worktree add /src/blit/wt/epic
$ blit @muster rewatch
/src/blit/wt/epic/.blit/muster watched
With nothing broken it says so, rather than reporting work it did not need to
do. It is its own verb rather than an argument-less reload because retrying a
watch and reloading a unit share no subject and no effect, and one word for both
would put the wrong one a forgotten argument away.
requires is hard and implies ordering: the dependency must be ready first,
and the dependent stops when it leaves ready. wants starts something without
waiting for it. after orders without starting anything. Cycles are refused at
load with every member named.
Ready means readyWhen: spawn (the fork worked), {"delay":"2s"},
{"path":"/tmp/x.sock"}, {"log":"listening on"}, {"tcp":"127.0.0.1:5432"},
{"http":"http://127.0.0.1:10001/"}, or manual. A oneshot is ready when it
exits 0, so successful completion is both its health and its readiness signal.
Re-running a successful oneshot is staged. Its dependents keep using the last
successful result while the new run is in progress or failed; they restart only
when a later run exits 0. An initial run still gates dependents normally, and an
explicit stop still takes the dependency tree down immediately.
restartOnFailure, restartOnAbnormal and restartOnChange default on;
restartOnSuccess does not, because a process that exits 0 usually meant it —
and the blit dev server exits 0 on purpose when it is replaced, so retrying that
is an infinite loop.
Retries start with a one-second jittered backoff, capped at 30 seconds. After
five consecutive failures a unit stays failed; set startLimit to 0 for no
limit. A failed oneshot is never retried automatically: it is a task with a
result, and can be started again explicitly after the failure is fixed.
restartOnAbnormal is separate from restartOnFailure because they answer
different questions. A process that returns 1 has decided something; a process
the OOM killer took has not. "restartOnFailure": false means "obey what it
says", and it should not also mean "and stay down when it is shot" — so the two
are independent, and either is enough. Blit's exit status negates the
terminating signal, which is how a kill is told from a return.
Every restart is a new terminal. C2S_RESTART would keep the pane, but it
replays the spec the PTY was created with, so it cannot serve a restart caused
by an edit; using it only for crashes would make the two kinds behave
differently. Instead keep (default 1) retains that many exited terminals per
unit, so a crash loop leaves its last runs addressable rather than concatenated
into one pane:
$ blit @muster status crasher
unit crasher
phase backoff
failures 7
run 31 exit 1 seq 7
run 30 exit 1 seq 6
blit terminal journal 30 then reads that run with no scrollback archaeology.
Some programs are a handle on something else — docker compose up, a tunnel, a
device — and signalling the handle leaves the thing it opened running.
stopCommand runs instead of the signal:
{
"command": ["docker", "compose", "up"],
"stopCommand": ["docker", "compose", "down"],
"timeoutStop": "60s"
}It replaces the signal, not the deadline: SIGKILL still arrives at
timeoutStop, because a stop command that does not stop the unit is exactly the
case it exists to survive.
reloadCommand is the same shape for blit @muster reload <name>. A unit with
no reloadCommand is restarted instead, and the answer says which happened
per unit:
$ blit @muster reload epic
epic/gateway reloaded
epic/ui restarted
Both run in a terminal of their own, tagged muster/<unit>/stop|reload, with
the unit's cwd and resolved environment — a stop command that cannot see
DOCKER_HOST talks to a different machine than the one it is stopping. Neither
is a run of the unit: no sequence number, never adopted, not retained.
Precedence ascends: what the server derives, then each envFile in order, then
env. Files are read at every start, so editing .env and restarting is
enough. The merged map travels in CREATE2's environment block and reaches
execve as envp — never a command line, so an envFile secret is not in
ps, not in /proc/<pid>/cmdline, and not on disk.
A command unit runs no rc file, so PATH is the server's. Under a server
started from a systemd unit that is often coreutils, findutils, grep and sed —
no cargo, no pnpm, no node. The server resolves command[0] against the
child's own environment, so the fix is one shared env file:
# ~/.config/blit/instances/default/muster/path.env
PATH=/home/you/.nix-profile/bin:/run/current-system/sw/bin:/usr/bin:/binlisted by every unit. blit @muster doctor resolves command[0] against the
effective PATH rather than the server's, so it tells you before a start does.
A binary that does not resolve fails silently: the terminal exists, exits 1,
and prints nothing. @muster status shows the run and doctor names the
program; the terminal itself will not.
A stack's stack.json declares parameters; an instance binds them. Inside a
template, ${NAME}, ${NAME+N} and ${NAME-N} substitute in any string value,
never a key. ${INSTANCE} and ${STACK} are always defined. An unbound name
fails that instance with the file, pointer and variable named — there is no
empty-string fallback, because a parameter you forgot should not quietly
produce http://127.0.0.1:/.
${ is the only trigger, so $BLIT_DEV_SOCK in a shell template is still the
shell's variable.
{ "stack": "blit", "vars": { "PORTS": 10000 } }Dependencies inside a stack name templates unqualified and always resolve within
the same instance. "omit": ["website"] drops one; anything requiring an
omitted template fails to load, by name. "autostart": false holds the whole
instance.
Declaring a parameter {"kind":"ports","span":4,"start":10000} lets auto
allocate the first instance and lets doctor report two instances whose blocks
overlap — the failure mode of several dev stacks, which otherwise presents as
EADDRINUSE in whichever one lost.
blit @muster instantiate blit epic PORTS=autoThat writes the instance file and starts it, in one command, because those were
never two decisions: an instance file with autostart — the default — is the
start. What comes back is the units and the phase each is actually in, not what
they were asked to do, because the write goes into the mirror and the load and
reconcile happen before the answer is sent.
VALUE is typed the way a JSON file would type it: a number is a number, true
is a boolean, and everything else is the text you typed, so paths need no
quoting. PORTS=auto takes the lowest free block for a parameter declared
{"kind":"ports"} — free against every instance's block, not just this
stack's. Its first instance uses the declaration's start; without one, the
first instance still has to say a number rather than guessing a machine-wide
range.
Nothing is written if the instance would not load — the expansion runs first,
against the same code that will run it for real, so a forgotten parameter is an
error on your terminal rather than a finding in doctor about a file you never
typed. An existing name is refused unless --force; --no-start writes
"autostart": false for someone who wants to read it before it runs.
Only the configuration directory is writable, and only at its top level. A stack directory outside it is a repository muster was pointed at, and the rule that keeps discovery out of it keeps writes out of it too.
muster publishes blit.muster.v1, and the browser's Manage pane grows a
Muster tab while something is listening on it (js/ui/src/MusterPanel.tsx,
mirrored by js/ui/src/muster.ts). The tab shows the tree the CLI cannot:
instance ▸ unit ▸ (terminal, windows). A unit's windows are there because a run
is spawned onto its own stamped Wayland socket, so the compositor — not the
supervisor guessing at process trees — is what says which window is whose. A
unit's current and retained terminal chips open that terminal on click and use
the ordinary pane-assignment payload when dragged onto a pane.
The wire is JSON, one object per message.
| server → panel | meaning |
|---|---|
{"type":"hello","version":1,"dir":…} |
which directory is being watched |
{"type":"state","units":[…],"gone":[…]} |
these units, whole; those names no longer exist |
{"type":"state","full":true,"units":…,"instances":…} |
the entire table, and the tree it hangs under |
{"type":"events","records":[…]} |
journal records, as @muster log --json prints them |
The panel sends the CLI's verbs as bare lines: start NAME, stop NAME,
restart NAME, rewatch, resync. A name is a unit or an instance.
Two properties are worth stating, because they are what the design is for:
- A unit arrives whole, never as a patch. So a reader that missed a frame is correct after the next one, and no reconciliation code exists on either side.
- A frame carries only what changed. Transitions mark units dirty and a
flush 80 ms later sends that set; a hundred-unit directory does not ship a
hundred rows because one of them restarted.
fullmarks the frames that redefine the whole table — a new reader's first, and afterresync— and those are the only ones where an absent unit means a deleted unit.
Env-file values never appear on the channel, as they never appear in
@muster status or the journal. @muster env --values remains the only way to
read them.
blit ext update muster … does not kill the stack. The supervisor keeps the
S2C_LIST burst from bootstrap (bootstrap_with_initial; plain bootstrap()
discards it, and there is no request that asks again), and re-adopts every PTY
tagged muster/<unit>/<seq>. Per unit the highest sequence that has not
exited is the live run — the burst carries S2C_EXITED for the rest — and the
others become retained history.
Adoption re-runs only a readyWhen that describes the present: path, tcp,
http. log, delay and spawn describe a past event whose evidence may have
been evicted, so a live terminal is taken as the evidence instead. Re-running
one of those stalls a healthy unit until timeoutStart and then replaces it,
which is the restart storm adoption exists to prevent.
cargo test -p blit-ext-muster --lib covers the parts worth getting right on
the host: unit-file parsing, substitution, dotenv merging, backoff, retention
and dependency order. Nothing there needs a server.
For the rest, use a named private server, which gives the socket, extension catalog, KV database, cache, and muster directory independent defaults:
blit server --name mus
blit --on local:mus ext run --persist --restart always \
muster extensions/dist/muster.wasm
blit --on local:mus @muster listSet BLIT_MUSTER_DIR on that server when the automatically namespaced
configuration directory is not the scratch location you want.
Without --on local:mus the CLI talks to its default target, which is usually
not the server under test.
The browser half is exercised by e2e/tests/muster-panel.spec.ts, which brings
its own units: start-servers.sh points the supervisor at an empty
BLIT_MUSTER_DIR of its own and publishes the path, so the spec never reads —
or starts — whatever you actually supervise.
- The durable journal tail in kv: the ring is in memory, so
@muster logstarts empty after the supervisor restarts. @muster remove, the other half ofinstantiate. Deleting an instance file needsFS_OP, and unlike a write it destroys something.- Dependencies across stack boundaries. A self-contained stack has no ambiguity
about which instance a name means; a shared database between per-worktree
stacks wants a decision about migrations before it wants a
requires. - A restart caused by a file change is journaled with cause
crashrather thanfile, because the retry runs through the backoff path.
envFilekey subsets, or apassEnvlist forwarding named server variables. Both are ways to make what a unit sees depend on something other than its own file, and the current answer — the file says everything, and@muster env --valuesshows what it resolved to — is worth more than the convenience.- A stack fetched from a repository, pinned by digest like an extension module. Discovery never leaves the configuration directory, on purpose: cloning a repository must not be able to start running its code. A stack you point at by path is you naming it; a stack muster fetches is not.