Skip to content
vxvx
GitHubBlueskydev.toRSS

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 pass
error: expected 3, got 2
1 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.34s

The 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#build

A 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#dev

The 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 attempts

A 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.

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).

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.

Turborepo, Nx and other product names are trademarks of their owners. vx is not affiliated with or endorsed by them.