CLI: run and watch
This page covers the five commands that start a run or connect to one: senro run, senro up,
senro attach, senro shell, and senro ui. For the full command table and exit codes, see
CLI.
senro run
senro run <pkg> [--ui=auto|tui|plain|none] [--trigger-event PATH] [--break-on-failure]
[-- pipeline-args...]
senro run ./ci # build, exec, auto-attach, render
senro run ./ci -- --env=staging # flags after -- go to the pipeline, not to senro
senro run ./ci --break-on-failure # hold a failed step open for a shell
senro run builds the named package with go build into a temporary binary, then runs it. A
package given as a directory (./ci) is built from inside that directory (go build -C ./ci), so
ci/ can be a module of its own, as senro init writes it for a repository without a go.mod.
The binary itself still runs in your current directory. If the
pipeline registered an attach server (by calling attach.Listen, see
Run options and outcomes), senro attaches to it and renders the
run exactly like senro attach would.
--trigger-event PATHis passed straight to the pipeline binary, which decides for itself whether the event applies.PATHcan be-to read from stdin.--trigger-event=with no value is refused as a typo. If you want no event, just leave the flag off.- A pipeline that never calls
attach.Listenstill runs, and its exit code is passed through as-is. Its stdout and stderr are relayed when the UI mode isplainornone. Undertui(what--ui=autopicks on a real terminal), they’re not connected at all, because the TUI owns the terminal. Pass--ui=plainif you need to see a non-attach pipeline’s output. - A Go toolchain must be on
PATH. Without one,senro runstops before it even builds, withsenro run: no Go toolchain found on PATH. It’ll tell you to build the binary yourself and run./pipeline --tuiinstead. Running an already-built binary needs no toolchain at all. - A package that fails to compile stops there too. You’ll see
go build’s own errors, followed bysenro run: go build ./ci: exit status 1, and an exit code of2. senro runsets$SENRO_FUNC_PKGon the pipeline process. This lets aFuncstep be cross-compiled for another platform without adding anything to the pipeline’s source. An explicitsenro.WithFuncBuildoverrides it. See A Func step off the coordinator.
Ctrl-C asks the engine to cancel gracefully instead of killing the process outright, so cleanup
and Always handlers still get to run. If a pipeline ignores the request, senro kills it after
five minutes so the CLI never hangs forever.
--break-on-failure
A failed step tears its sandbox down the moment it settles, and the state that would explain the
failure goes with it. --break-on-failure keeps it: an attempt that ends failed or timed_out
is held, sandbox and workspaces intact, until you release it.
- The flag reaches the pipeline as
SENRO_BREAK_ON_FAILURE=1in its environment, not as argv, because the pipeline binary owns its own flags. From Go it issenro.WithBreakOnFailure(). - A held step emits
breakpoint.hitwith"reason":"failure", and the TUI shows it asfailed, held (s shell · B release). Presssto open a shell in the state the failure left, andB(breakpoint.clear) to release it.run.cancelreleases every hold. - The hold never changes the outcome. Once released, a step with a
Retrypolicy goes on to its next attempt (and holds again if that fails too); one without settles as it would have. A run cancelled while a step is held gets no further attempt. - A held step keeps its parallel slot, and a sibling whose cache hit would restore into a workspace the held step mounts waits for the release. Every other step keeps running.
See The shell.
senro up
senro up <pkg> [--ui=auto|tui|plain|none] [-- pipeline-args...]
senro up ./ci
db 127.0.0.1:54321 (port 5432)
senro up runs a pipeline’s services, and only them and whatever they
need, and keeps them up until you press Ctrl-C. It builds and launches the pipeline exactly as
senro run does, with SENRO_UP=1 in its environment. Every other step is skipped as
not selected, and the services are never stopped by their consumers settling, only by the
cancellation.
- One line per endpoint, as each service becomes ready:
<step> <address> (port <n>), under--ui=plainand--ui=none. The address is the published one on this machine. Under the TUI the address is in the focused service’s log header instead. - Exit codes.
0when the session ends (normally byCtrl-C) with every service settledsucceeded;1when the run failed or a service did not settlesucceeded;2for a usage error, including a pipeline that declares no services, which saysthis pipeline declares no services. Unlikesenro run, an interrupt is not130: stopping the services is what a session is for. --trigger-eventis forwarded and--break-on-failureaccepted, both exactly as forsenro run.
senro attach
senro attach [--pid <pid> | --run <id> | --addr <host:port>] [--follow] [--tls]
[--ui=auto|tui|plain|none]
senro attach # auto-discover the one live run
senro attach --run 20260812T151058-540c8ca44b --follow # tail a finished run from disk
senro attach --addr 127.0.0.1:8443 --tls # a TCP attach server directly
Running senro attach with no flags discovers every live run registered on the machine, cleaning
up any whose process has already died, and attaches to the one it finds. If there’s more than one,
it won’t guess: it lists them all, with pid, run, pipeline, working directory, and start time, so
you can pick one with --pid.
That listing is printed to stderr as an error, with exit 2. If there are no live runs at all,
senro prints senro: no live senro runs found along with how to start one, also exit 2.
Which flag combination you need depends on whether the run you want is still live or already finished:
flowchart TD
Q{Which run?}
Q -- "no flags" --> L{How many live runs?}
L -- "0" --> E["error: no live senro runs found"]
L -- "1" --> One["attach to it"]
L -- "more than 1" --> List["list them all, exit 2:<br>pick one with --pid"]
Q -- "--pid N" --> Pid["attach to that live run"]
Q -- "--run ID" --> Live{"Live entry with that ID?"}
Live -- yes --> LiveAttach["attach live<br>(handoff to disk on exit)"]
Live -- no --> Disk["fall back to runs/ID on disk"]
Q -- "--run ID --follow" --> Follow["replay from disk only,<br>no live lookup at all"]
Q -- "--addr host:port" --> Addr["dial that TCP attach server directly"]
| Flag | Behavior |
|---|---|
--pid N | A specific live run by process id. A pid that was registered and whose process has since died gets its own message, not “never existed” |
--run ID | Prefers a live entry with that ID, so you get the live-to-disk handoff on exit for free, and otherwise falls back to the recorded run under runs/<id>/ |
--follow | Tails a run from disk only, no socket needed. Requires --run, and skips the live lookup entirely |
--addr host:port | Dials a TCP attach server directly, taking its token from $SENRO_ATTACH_TOKEN |
--tls | Says the --addr endpoint speaks TLS. Meaningless without --addr, and refused there |
--ui MODE | The renderer; see CLI |
--pidand--runcan’t be used together.--addrcan’t be combined with--pid,--run, or--followeither:--addrnames an endpoint directly, while the other flags search for one.--run <id>resolvesruns/<id>relative to your current directory. Run this command from the same place the pipeline ran.- On first contact, the client checks protocol versions. A matching major version is silent. A minor version mismatch prints a warning to stderr. A major version mismatch stops the connection instead of producing garbled output.
Recorded runs and live sockets both implement the same interface internally, so the client renders them identically. Replaying a finished run isn’t a second-class experience. See Attach.
senro shell
senro shell [--pid <pid> | --run <id> | --addr <host:port>] [--tls] [--tty]
[--offline] [--keep] [--cache-dir DIR] --step ID [--cmd ARG... | -- cmd...]
senro shell --step build # a session on the step's workspaces
senro shell --step build -- cat build.log # one command, non-interactively
senro shell --step build --tty # a real terminal
senro shell --run 20260812T151058-540c8ca44b --step build # a finished run, offline
senro shell opens a session inside a step of a live run. You get its workspaces (read-only), at
the same paths the step saw them, in the step’s own working directory, on the step’s own executor.
Pair it with a breakpoint to pause a run before a step and inspect what it’s about to run against.
See The shell for the full picture. Here are the essentials:
--stepis required. A session always runs inside one specific step’s workspaces, so there’s no default to fall back on.- No secrets reach a session. Not the files, not the
SENRO_SECRET_*variables, not any alias a step declared. A session can stay open indefinitely, and senro won’t put a credential back on disk for that long. - Pipes by default, a real terminal with
--tty. Without--tty, there’s no prompt and no line editing: you type a line and press enter. With it, you get a real pty, job control,^Cas a signal, and a window size that follows your own. localandcontainerexecutors support a terminal;sshdoes not. Onssh,--ttyis refused outright rather than silently downgraded (executor_no_terminal). Either way, a banner is printed to stderr, so a redirected stdout only captures what the session itself printed.- A finished run gets the offline form. A
--runthat names no live run, or any--runwith--offline, restores the step’s latest workspace snapshots into a temporary directory and runs this machine’s own$SHELLthere (or the--cmdcommand), not the step’s image. The directory is removed on exit unless--keep;--cache-dirnames the content store to restore from. The three flags need--run, since--pidand--addralways name a live run. See The shell. - It reaches a remote run the same way
senro attachdoes:--addrplus--tls, with the token from$SENRO_ATTACH_TOKEN. There’s no--tokenflag. - A read-only attach refuses it. If the attach server was started with
attach.Options{ReadOnly: true}, a shell request gets a 403. A shared dashboard never hands out a command prompt.
Everything after -- (or after --cmd, which takes the rest of the arguments) runs instead of
the default shell, and the session’s exit code is that command’s exit code. That makes
senro shell --step build -- test -f out/app usable in a script, just like running the command
directly.
Refusals print a short reason and exit 1, using the same vocabulary as
control operations: unknown_step, run_not_active,
executor_no_shell (an executor whose sandbox can’t host a session at all; this build has none),
executor_no_terminal, and sandbox_failed.
senro ui
senro ui [--pid <pid> | --run <id> | --addr <host:port>] [--tls] [--port N]
senro ui # the one live run on this machine
senro ui --addr 127.0.0.1:9944 # a run reached through a port-forward
senro ui --port 8730 # pin the loopback port instead of taking a free one
senro ui serves a browser view of a live run on loopback. It prints a one-time link to stdout
and keeps running until interrupted. The page itself is a Go client compiled to WebAssembly, and
it processes the run’s events using the same logic the TUI does, so the two views never disagree
about what a stream means.
- It offers the same controls as the TUI: cancel, pause, resume, retry, skip, set and clear
breakpoints, rerun-from, and accept/reject an analysis.
ws.snapshotis available but has no button for it (see the browser UI). It does not offersenro shell. - The run’s bearer token stays in this process and never reaches the browser. senro only accepts a
control request from the page itself, checked by its session cookie and a matching
Origin. - Loopback only, with no flag to widen that.
--port 0(the default) picks a free port for you. - There’s no
--followflag here. A finished run has no attach server to connect to; read one withsenro attach --run <id> --followinstead.
See The browser UI for more, including where the one-time link’s nonce does and doesn’t end up.
Where to go next
- Cache and verify:
cache gc,cache explain,verify. - Workspaces and runs:
ws ls/pull/diff,logs fetch,func check. - Reading a failed run: what to do when one of these reports a failure.