Output format
vx run emits framed blocks. Stdout/stderr from each task is
buffered until completion, then dumped inside the block — so
concurrent tasks never interleave their lines. A lone requested task
streams live instead (§ Output).
Frame anatomy:
┌─ app#test > failed (exit 1, 1 sandbox violation)
$ bun test
├─ OUTPUT ──────────────────────────────────────────────────
2 passerror: expected 3, got 21 fail
├─ SANDBOX VIOLATIONS (1) ──────────────────────────────────
write ../shared/notes.txt
└─ app#test ── (2.10s) failed (exit 1, 1 sandbox violation)Every section is conditional: the $ <command> line only for an
executed task (success or failed — a hit replays its stored output and
shows none), OUTPUT only when the task wrote something,
SANDBOX VIOLATIONS (N) only when the sandbox recorded some (N
counts them) or SANDBOX when vx has only a note on a failure. The
header carries the outcome (restored-local • abc12345, failed (exit N), …) and the footer repeats it after the duration. A test renders
this block and checks it against this page, byte for byte.
OUTPUT holds what the task wrote in the order vx read it, so an
error stays beside the test that printed it: a stderr line renders
red when colour is on (a line the task coloured itself keeps its own
colours, and a line takes the stream that began it); without colour
the lines are the task’s bytes.
The two streams are two pipes, so lines written within the same
instant may read stdout first; a cache hit replays the same order. A
persistent task’s tail after ready keeps STDOUT (since ready) and
STDERR (since ready) apart: its bounded tails hold no order.
Frame corners and rules render dim, section labels (├─ …) bold in
their state colour; the id keeps its identity coloring. Content lines are raw — no left border, no
indent — so long lines wrap without colliding with frame glyphs and
copy/paste yields the verbatim output. Every block (and every live
frame close in focused flow) is followed by a blank line so frames
never collide with the next one-liner. A persistent task’s frame is
marked with a cyan ▸ after ┌─/└─, and its close reads running
(the child is still alive), or failed (exit <n>) for a server that
died on its own before the run stopped it.
There is no top-of-run banner — the run context lives in the footer. The task log opens with one blank line before its first row, on a terminal or not; a note vx prints before it is not part of it. A broad run looks like:
⇢ 4ms success local @vzn/vx#format-check ⏺︎ 5.20s success miss @vzn/vx#test
─ vx 0.0.0 ─────────────────────────────────────────────────── projects ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 1 affected · 3 total tasks ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰ 2 success · 2 total cache ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰ local 1↓ 1↑
info 8 workers · local cache · run 01a14941-c8cf-7a3e-9b51-2f06d4e8c713 time 5.34sThe three bars are meters: projects is what the run covered against
the workspace, tasks is failed / success / skipped, cache has a
segment per legend state (a skipped task rides it too, so the two bars
fill alike; its count is on the tasks legend only). Its legend reads
per layer: local 1✓ 1∅ 2↓ 1↑ · remote 3↓ 4↑ 2✗ 1⇡. ✓ is a fresh
hit (the local cache answered and its outputs were already on disk; a
remote hit is never fresh, outputs or none: it is ↓ from the remote), ∅ a task with no cache, ↓ is restored from that
layer and ↑ saved to it, ✗ a task a remote read or write failed
for, and ⇡ an upload still running (live only, always last). The
region stays through vx’s closing steps, worker rows gone, and the
final footer replaces it in place. Each task counts once, under its strongest state: a
failed remote read or write, then an upload running, then saved to a
remote, then saved here, then restored, then fresh, then no cache; so
a task saved here and uploaded is a remote ↑. A key the remote already
held is not sent, so it is no remote ↑. Each count is painted
in its segment’s colour, number and glyph alike: ✓ green, ∅ dim,
local blue, remote dark blue, ✗ and a miss red, ⇡ cyan; the
local and remote words are dim. A layer shows only when the run’s
--cache-policy uses it. N miss (ran and saved nothing: it failed,
or ran elsewhere) is red, N not saved (ran, succeeded, saved
nothing) yellow, each its own segment; N not downloaded (outputs left in the remote store) and
every count show only when non-zero. vx last names each remote
failure on its task’s row. A task an executor plugin ran elsewhere
says where last on its row and after the id on its frame (app#build · small#2), and the plugin may
add rows of its own above info (workers), in which case info
names no worker pool. The info row ends with run <id>: the
run’s whole id, as vx last <id> takes it. The
time row is the wall time, then the spread over executed tasks
only — a hit’s restore time never enters it. The footer never says a
fact twice: a spread value already on the row is left out, so one
executed task shows no spread, and there is no closing result row
repeating the tasks, cache and time rows. A task with no cache block,
or any task of a run whose --cache-policy reads and writes nothing
(--no-cache), could never hit, so it is counted apart (1 no-cache
for vx run dev), as --dry labels it. A test renders this run and
checks it against this page, byte for byte.
Group tasks emit no framed block by design (they aren’t real tasks); running a group focused surfaces its real member tasks instead.
Nothing below the footer. The footer is the run’s last word.
What a run has to say about one task rides that task’s row; what it
says about the run (a server that died, outputs left remote, a file
--profile wrote, a history, upload, plugin or sandbox
warning) prints above the footer: it prints once the run’s closing work
is done, the VX_DEBUG_TIMING=1 table included. Only a kept server’s own output
follows it.
Narrow terminals. The footer is 62 columns: its rule and 50-cell bars. On a terminal narrower than that the bars shrink and the rule with them, as do a frame’s section rules, so none wraps onto a second row. Off a terminal they keep their width.
Warnings on stderr. A run’s warnings, such as [vx] app#build: cache.outputs matched no files … or a plugin’s, go to stderr. Stdout
carries only the rows, the frames and the footer, so vx run build > log.txt keeps them out of the log.
Skipped rows. A red run lists every task that never started as a
row naming the failure that blocked it, so the footer’s 1 skipped
always has a name above it:
⊘ skipped app#build • blocked by lib#build ⊘ skipped web#build • blocked by lib#buildA skip’s cause is followed through a chain of skips to the failure at its root; a skip with no failed upstream is fail-fast’s and carries no suffix. A blocked group (a task with no command) has no row: it never starts by definition and no counter counts it.
Aborted and not-run rows. A stop (Ctrl-C, a shutdown signal)
keeps the live view: each running row reads aborting with its timer
until its task exits, and the task list names what the stop cut short
(aborted, with its time so far) and what it reached first (not run). Neither reached the cache:
✗ 2.41s aborted app#dev ◌ not run web#build ◌ not run web#test 3.37s aborting api#devThe tasks legend counts them in the total as they land, each state its
own segment of the bar: 2 success · 1 aborted · 2 not run · 5 total.
While the run goes it also counts the tasks running now and those an
executor holds for a machine, then those an unfinished dependency holds
back, each a dim segment: 30 success · 4 running · 12 scheduled · 16 blocked · 62 total.
The summary then replaces the live view. The total is
always last on its line. A framed task (focused or full) that
printed while it stopped shows its frame instead of a row.
Flaky rows. A task the run just proved nondeterministic says so in a dim note on its own row (and its frame’s footer) — from the local run history alone, no service:
◼︎ 4.21s failed miss app#test flaky - passed 3× before ⏺︎ 12.84s success miss api#e2e flaky - failed 1× before · 2 attemptsA task is flaky when its exact cache key FAILED AFTER IT HAD PASSED
(this run counted; a cache hit is a pass, it replayed one), or when it
passed only on a retry (exec.retries / --retries) this run. A failure on a
key that never passed is a break and carries no note, every retry of it
failed too — a changed input
that fails is what a red run usually means — and the first pass after
only failures is a recovery (a missing tool installed), not a flake.
Only tasks with a cache block are judged: “same inputs, red after
green” is a claim
only declared inputs can back, and a task without them keys on its
config alone. It is judged as the task finishes, so the row carries
it: zero cost for a hit or a skip, one probe of the failed-row index
for a green miss; vx info keeps the standing list.
Colors
Section titled “Colors”ANSI truecolor (ansi-16m) sequences, gated by env:
| Var | Effect |
|---|---|
NO_COLOR=… (non-empty) |
Force off. Overrides FORCE_COLOR. |
FORCE_COLOR=0 / =false |
Force off, on a TTY too. |
FORCE_COLOR=… (any other) |
Force on — 1, true, 2, 3, the empty string, etc. |
| (neither) | On iff stdout.isTTY. |
TERM=dumb |
Off unless FORCE_COLOR forces it, and no live region. |
0 and false are FORCE_COLOR’s “off” by the convention
supports-color, chalk and Node follow; until 2026-09-24 vx read every
non-empty value as “on”, so a CI setting FORCE_COLOR=0 got escape
sequences in its log (the bug Nx has as nx#35292). An empty NO_COLOR
has no effect (no-color.org).
A task’s output is a pipe, so a tool would print plain; vx sets
FORCE_COLOR=1 on every task, as Nx and Turbo do, unless the task
already sees a FORCE_COLOR (any value, 0 included) or a non-empty
NO_COLOR. The cache stores the coloured bytes, and wherever vx’s own
output is plain (a pipe, CI without FORCE_COLOR, NO_COLOR) it
strips the escapes from task output, live and replayed alike, an
escape cut across two chunks included (tests/plain-output.test.ts,
tests/cli-colors-e2e.test.ts). A custom log passed to run() gets
task output as written. A task that writes its own output to a file
and wants it plain sets env: { define: { FORCE_COLOR: '0' } }.
Programmatic callers passing a custom log to the run options always
see plain text.
Only a run’s own output (vx run, vx watch) is painted, and the
help on a terminal: headings bold, vx <verb> and each flag or example
in cyan; NO_COLOR or FORCE_COLOR=0 turns it off
(tests/help-colors.test.ts). Every other verb (show, info, why,
last, a --dry plan, cache clean), and the help off a
terminal, prints plain text whatever FORCE_COLOR says, so a script
that forces colour for a run’s log still parses them
(tests/cli-colors-e2e.test.ts).
Shared by every verb
Section titled “Shared by every verb”Two runs on one workspace take turns: the second waits for the first’s
run lock and, after a second, says [vx] waiting for another vx run (pid N) on this workspace to finish… (see caching.md § Concurrent
runs). The lock lives in this user’s own directory in the temp
directory, so two runs take turns only when they are one user’s and
share TMPDIR: a nix develop shell sets its own.
Every verb: a path vx must write that this user cannot (EACCES,
EPERM, EROFS — a read-only checkout, another user’s files) or that
the disk has no room for (ENOSPC, EDQUOT) exits 1 with one line
naming the path, vx: EACCES: permission denied, open '…/.vx/projects.lock.json' — a path vx must write is not writable by this user (or — the disk that path is on is full), never a stack. Inside a run the task’s line
says the same. A process out of file descriptors (EMFILE, ENFILE;
macOS starts a shell at 256) exits 1 with the error and — the process is out of file descriptors; raise the limit (ulimit -n 4096) and re-run.