Processes#
The PROCESSES rail is the second thing a workspace has, and it is the reason
butai is a workbench rather than a terminal with agents in it. A process is
a long-running command the daemon owns: your dev server, a watcher, a test
loop, a docker logs -f, or a plain shell. It is started for you when the
workspace opens, it keeps running while you look at something else, and it says
in one word whether it came up.
Mechanically a process is a terminal pane with a ProcMeta beside it — a
name, the command, and an optional ready substring. The pane is exactly the
same kind of pane an agent gets: a PTY, a child, a server-side VT emulator. The
ProcMeta is what turns it into a supervised row.
A process is not sandboxed, restarted on failure, or health-checked. Nothing here is a substitute for systemd. What butai gives you is a row that is honest about what the command is doing, in the same window as the agent that is editing the code the command is running.
Declaring one#
Four ways in, and they differ only in what the ProcMeta ends up holding.
| how | name | command | ready |
|---|---|---|---|
[[processes]] in .butai.toml |
name |
cmd |
ready, if given |
t in the rail, or [+ term] on its separator |
shell |
the default shell | — (already ok) |
:process NAME COMMAND, or a key bound to it |
NAME |
COMMAND |
never |
butai process start NAME COMMAND…, or POST …/processes |
name |
command |
never |
.butai.toml is the only route that can set a ready marker. Everything
started by hand gets ready = None, which has a consequence worth knowing
before you read the status table: a hand-started command never reaches ok.
[[processes]]
name = "dev"
cmd = "npm run dev"
ready = "Local:"
Configuration owns the key table and the file's lifecycle (read once at workspace creation, never watched, never rewritten). What each field does:
nameis the rail label and nothing else — it is not an identifier, it is not deduplicated, and two rows may share one. The exception is the literal nameshell, which is treated as "unnamed": the daemon relabels those rows with whatever the pane's tty currently has in the foreground, so six shells are not six identical rows. That probe is a/procread cached for 500 ms, capped at 512 characters, and it deliberately answers nothing when the foreground process is a login shell sitting at its prompt with only flags — so an idle shell keeps readingshellrather than-zsh.cmdruns through$SHELL -c, in the workspace directory. The whole string becomes the pane's command label, not just its first word: a row readingsudosays far less thansudo apt-get update -y. One special case — ifcmdis exactly the resolved default shell, the pane is spawned as a plain interactive shell instead ofshell -c shell.readyis a substring. See Thereadymarker.
There is no env, no cwd and no restart policy field. [[agents]] env
exists; [[processes]] has no equivalent, so put the variables in the command
(FOO=1 npm run dev) — it is going through a shell anyway.
Status#
One token per row, and it is computed fresh every time the process list is
built, from two inputs: the child's exit code, and whether the ready marker
has been seen.
| token | colour | what it means | what computes it |
|---|---|---|---|
ok |
ok | the marker was seen, or the row is a shell | ready_seen, and the child is alive |
... |
attention | output is actively streaming | Attention::Working, and not yet ok |
run |
ink | alive, and nothing has said otherwise | the fallback |
done |
ink | exited 0 | the exit code |
FAIL(n) |
danger | exited non-zero | the exit code |
The order in that table is the order the match runs in, and two things follow from it:
okoutranks.... Once a process has signalled ready it never goes back to showing activity, however much it prints. The marker is about startup, and a row that flickered betweenokand...all afternoon would be reporting noise.- A row with no marker can never leave
run/...until it exits. That is the right shape for a server — it does not "finish" — and it is why areadyis worth writing for anything whose startup you want to watch complete.
... is the same "working" signal the AGENTS rail uses: output arriving within
the last two seconds and streaming for at least one second. The one-second
floor exists because opening a pane from another client resizes it and the
child answers the SIGWINCH with a full repaint; on raw recency that reads as
work. A process also inherits the built-in busy markers agents use, so a
program that prints Ctrl+C to stop in the bottom eight rows of its screen
will read ... for as long as that line is visible.
Collapsed (alt-z), the same three states become one glyph in the left gutter:
P✓ for ok or done, P✗ for FAIL, P· for everything else.
No client shows a rolled-up "3 ok, 1 failed" count. The one aggregate that
exists is an exit code: butai process status exits non-zero if any row has
failed, which is what makes it usable in a condition —
butai process status -q || butai agent send 7 "the dev server died". Flags are
The command line's.
The ready marker#
ready is a case-sensitive substring, matched against the raw output
stream — not against the rendered screen. The scan runs in the daemon's
output loop, once per coalesced burst, before the bytes reach the VT emulator.
Three consequences, in descending order of how likely they are to surprise you:
- It must be contiguous in the bytes. A marker the program paints in two
colours has an escape sequence in the middle of it and will not match. Pick
a run of plain text:
Local:rather than the whole decorated banner. - It never un-matches.
ready_seenis a latch. Once set, the scan stops running for that pane entirely. - It is not lowercased. The agent detection markers are matched case-insensitively; this one is not.
ready_carry, and the boundary problem it solves#
Output arrives coalesced per drain, and a server's startup banner is routinely
written in more than one write — or lands across a 64 KiB read boundary. A
naive "does this burst contain the marker?" check misses it in exactly that
case, and the row then stays run forever even though the marker was
printed, because the latch above means there is no second chance.
So ProcMeta keeps ready_carry: the tail of the previous burst, prepended to
the next one before the search. It is bounded by the marker's own length minus
one — just enough to complete a split match, and nothing to tune.
When the marker is never seen#
Nothing happens. The row stays run (or ... while output arrives) until the
process exits, and then reports done or FAIL(n) like any other. There is no
timeout, no warning, and no "readiness failed" state — a marker that never
appears is indistinguishable from a server that is still starting, and butai
does not guess between them. If a row you expected to go green never does,
suspect the escape-sequence case above first.
Starting, stopping, restarting#
Start puts the new row at the end of the rail. A process from .butai.toml
does not take the stage — the workspace's opening shell keeps it, regardless of
how many processes and autostart agents come up behind it. A process you
start does take the stage, because you asked for it.
Stop is x on the row, DELETE …/panes/{pane}, or the row menu's Close.
It is immediate and leaves nothing behind: the pane is forgotten first and the
child killed as it is dropped, so there is no corpse row and no exit code to
read. If you want to see how a process died, let it die on its own.
Dropping the pane closes the master side of the PTY and sends the child a
SIGHUP. The child is a session leader with the pane as its controlling
terminal, so the hangup reaches its foreground process group — but a program
that installs a SIGHUP handler, or a grandchild the shell put in the
background, can outlive the row.
Restart is r, the row menu's Restart, or
POST …/processes/{pane}/restart. It kills the old child, spawns a new one
with the same name, command and ready, and resets ready_seen and
ready_carry so the marker has to be seen again. Three details that are
visible:
- Output does not survive it. The new pane starts blank. Restore replays a saved screen; restart deliberately does not — you asked for a fresh run, and the previous one's log above it would read as part of it.
- The pane id changes. A client holding the old id has to let go; the TUI clears the stage when the restarted pane was on it.
- The row moves to the bottom of the rail, because the restart re-appends it. There is no way to hold a position.
A process that exits cleanly on its own (code 0) is removed from the rail
automatically. A process that fails stays as a red FAIL(n) corpse so its
last output can still be read, and x dismisses it. Any row named shell — the
one a workspace opens with, and every one t adds — is the exception: it is
removed however it exited, because a shell that failed is you mistyping exit.
And when such a row is the only thing left in the workspace — no agents, no
other processes — leaving it closes the workspace.
Processes never produce notifications. The notification feed is agents only, so a build that fails while you are looking at another tab is a red row you find, not an alert that finds you.
Across a daemon restart#
Two stores, and processes are in both.
~/.butai/session.json holds each workspace's process list — name, command,
ready, and the dump file that goes with it. It is rewritten synchronously
whenever a workspace opens or closes and whenever the roster changes.
~/.butai/panes/<slug>-<hash>/proc-<i>.bin holds that row's recent output. Each
file starts butai-dump 1 <cols> <rows>\n and then carries the raw byte
stream, so the geometry needed to replay it travels with it rather than
depending on a second file staying in step. Dumps are sampled on the ~2 second
telemetry tick and once more as the daemon goes down; a hard crash therefore
costs the last couple of seconds, which is the bound the workspace list already
had.
On the way back up, each saved process is spawned again — the command runs from the top — and its dump is replayed into the fresh emulator first, at the size it was recorded at, then resized. So the pane you return to reads as the pane you left, with the previous run's log above the new one's first line. Nothing else survives: the child is new, its pid is new, and anything the old run held in memory is gone.
Restore replays the persisted list, not .butai.toml's block. This is the
rule that makes the two agree instead of fighting: the saved list already
contains the workspace file's processes — they were spawned from it and then
recorded — plus anything you started by hand, minus anything you closed.
Replaying the file on top would duplicate the first group and lose the other
two. So a process you deleted from .butai.toml does not come back, and a
process you started with t does.
Two edges worth knowing:
- A workspace whose directory does not resolve at startup is deferred, not dropped — an unmounted share reads exactly like a deleted folder, and this runs when mounts are least likely to be up. Its entry and its dumps are kept and written back out, so the next start can rebuild it.
- A restored row without a
readymarker comes back asok, where before the restart it readrun. Rows with a marker restart atrunand have to earnokagain, which is correct; the marker-less case takes the same "nothing to wait for" branch a shell does. It is cosmetic — nothing readsokexcept the rail — but it means the token is not stable across a daemon restart.
Set [general] restore_bytes = 0 to switch the capture off entirely. Panes
then come back blank, which is what they did before any of this existed.
Working directory and environment#
Every process runs in the workspace's directory — the project root, or
whichever path the workspace was opened on. There is no per-process cwd; cd
in the command if you need one (cd web && npm run dev), which is what the
docker log followers do.
Every pane the daemon spawns — process, agent or shell — carries:
| variable | value |
|---|---|
TERM |
xterm-256color |
COLORTERM |
truecolor |
BUTAI |
the socket this daemon bound; the nesting guard reads it |
BUTAI_SOCKET |
the same path, for --socket |
BUTAI_PANE |
this pane's id |
BUTAI_WORKSPACE |
the workspace it belongs to |
BUTAI_SOCKET and BUTAI_WORKSPACE are what make butai inside a pane act on
its own daemon and its own workspace without being told. Driving butai from inside a pane
is the surface built on that.
PATH is repaired. $SHELL -c is a non-interactive shell: it sources
none of the files that put ~/.local/bin, ~/.bun/bin or nvm's node on the
path, and the daemon itself was probably started by a session manager rather
than your login shell. So the daemon prepends the directories a login shell
would have added — only the ones that exist, only the ones not already present,
and nvm's version directories all-or-nothing, because a PATH that already
names one was written by your own hook and adding the others would put an older
node in front of it. A daemon started from a login shell gets its PATH back
byte for byte.
That is why npm run dev in a .butai.toml finds npm, and why the failure it
prevents is confusing rather than obviously environmental: the same line works
when you type it into a pane, where the shell is interactive and has read its
rc file.
Everything else is inherited from the daemon's own environment, which is whatever started it.
Output#
A process pane is a terminal pane, so all of it applies: it is a real PTY with
a real VT emulator behind it, and it is interactive. Stage it and type —
Ctrl-C interrupts the build, q quits the pager, arrow keys reach the
program. The daemon answers cursor-position queries on the child's behalf so
programs that ask and then block do not stall.
| what | how much | where |
|---|---|---|
| scrollback | [general] scrollback lines, default 5000 |
in memory, per pane |
| restart capture | [general] restore_bytes bytes, default 256 KiB |
~/.butai/panes/<key>/proc-<i>.bin |
The two budgets are counted differently on purpose. Scrollback is what you scroll through, so lines are the unit. The capture is the untouched byte stream — a pane redrawing a full-screen TUI spends far more per line than one printing log text — so bytes are what actually bounds the cost. 256 KiB is a few screens of a redraw-heavy program and well over a thousand lines of plain output.
Dump files are keyed by position (proc-0.bin, proc-1.bin, …), written by
the same walk that builds the persisted list so the two cannot drift. Files
belonging to rows that have since closed are pruned on each pass, and so are
whole directories for closed workspaces. The directory name is a readable slug
of the project directory plus a hash of its full path: the slug alone would
collide across the several .../src directories you have open at once and
those workspaces would replay each other's output, and the hash alone would be
unreadable in a directory you are expected to be able to delete by hand.
To read a process's output without attaching to it — from a script, or from an
agent in the next pane — use butai pane read <id>, or
GET …/panes/{pane}/output. It is a query: it does not resize the pane or
clear its bell.
Docker#
Containers are telemetry, not panes. Every ~2 seconds the daemon runs
docker ps -a and reads each container's name, state, compose project and
compose working directory, capped at 64 rows. Docker missing, failing or taking
longer than two seconds yields nothing at all, which is why the SYSTEM rail
simply has no container line on a machine without it.
Those rows are grouped into stacks: one per compose project, plus one single-member stack per standalone container. A stack with no running container is dropped from the list entirely. A stack is "yours" when its compose working directory is at, under, or over the workspace's directory, and yours sort first — the DOCKER space shows only those, falling back to every started stack when none match, so the page is never mysteriously empty.
Following a container's logs is an ordinary process pane. The client posts
docker logs -f --tail 200 <name> (or cd <workdir> && docker compose --ansi
always logs -f --tail 200 for a stack) to POST …/processes and streams the
resulting pane like any other. There is no docker-logs message on the wire and
there does not need to be one: following a log is running a program and
watching its output, which the protocol has always been able to express. The
same is true of the other verbs — restart is a pane running docker restart,
and s opens a pane running docker exec -it <name> sh.
This is the PTY-versus-JSON rule doing its job. Docker logs are bytes from a program, so they take the path bytes take; nothing about them justifies a second one. It is also why every client gets the feature for free — the browser client and the Mac client do exactly this, and neither has a VT parser.
The cost is that followers are real rows in a real rail, and they have to be
cleaned up. Both clients kill theirs when you leave the page and when the
client goes away; the browser also fires the kill on pagehide with
fetch(…, {keepalive: true}), since the kill is a DELETE and sendBeacon is
POST-only. Without that, a docker logs -f outlives every detach and the
PROCESSES rail fills with followers nobody asked for. The browser hides its
logs: panes from the rail; the terminal names them logs <label> and shows
them, so you can see the one that is running.
(The daemon's Workspace still carries a docker_logs slot from when the
follower was special-cased. Nothing assigns it — the followers are in
processes with everything else.)
Processes, agents and terminals#
All three are PTY-backed panes rendered by the same emulator. What differs is the metadata beside them and what the daemon does with it.
| process | agent | plain shell | |
|---|---|---|---|
| declared in | .butai.toml [[processes]], or by hand |
~/.butai/config.toml [[agents]] |
nothing — t |
| launched via | $SHELL -c <cmd> |
command + args, resolved on PATH |
the shell, interactively |
| lives in | the PROCESSES rail | the AGENTS rail | the PROCESSES rail |
| status comes from | exit code + ready |
the footer band of its own screen | the same as a process (always ok) |
| status values | ok run ... done FAIL(n) |
waiting working finished idle exited |
ok, then done/FAIL(n) |
| configurable detection | — | waiting_pattern, busy_pattern |
— |
| configurable env | — | [[agents]] env |
— |
| clean exit (0) | row removed | row removed | row removed |
| non-zero exit | red corpse row | red corpse row | row removed |
| notification on exit | no | yes | no |
| restart verb | r |
— (kill and spawn again) | r |
| restored with its output | yes | yes | yes |
| conversation reopened | n/a | yes, by minted id | n/a |
| row label | its name, or the foreground command when named shell |
its live OSC title | the foreground command |
The one asymmetry that catches people: there is no r for an agent. A
process is a command you can run again; an agent is a conversation, and
restarting one means deciding what happens to that conversation — which is what
the restore path does with resume_args and a named session id, and which is
not a thing to do by reflex on a keystroke.