Skip to content
vxvx
GitHubBlueskydev.toRSS

vx run

vx run runs the named task(s) in dependency order and skips any whose inputs did not change. By default only the project containing the current working directory is selected — dependsOn still expands so the project’s upstream workspace deps run too. Override with --all, --filter, --affected, or an explicit pkg#task.

vx run [OPTIONS] [TASK | PKG#TASK ...] [-- forwarded-args...]
Terminal window
vx run build # the cwd project's build, and what it depends on
vx run build --all # every project that declares build
vx run test --affected # only what this branch changed, and its dependents
vx run app#build lint # one project's build, and lint in scope
vx run test -- --watch=false # forward arguments to the task

The first run executes and saves; the next finds nothing changed:

$ vx run build --all
⏺︎ 17ms success miss lib#build
⏺︎ 6ms success miss app#build
─ vx 0.0.0 ───────────────────────────────────────────────────
projects ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰
2 affected · 2 total
tasks ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰
2 success · 2 total
cache ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰
local 2↑
info 4 workers · local cache · run 01a16e03-afb0-76ee-9cea-18a61c9d1e91
time 101ms · max 17ms · avg 12ms · min 6ms
$ vx run build --all
─ vx 0.0.0 ───────────────────────────────────────────────────
projects ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰
2 affected · 2 total
tasks ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰
2 success · 2 total
cache ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰
local 2✓
info 4 workers · local cache · run 01a16e03-b0b1-77f6-a758-f7aa680822ab
time 16ms

The Flags table lists every option.

If no task name is given:

  • In a TTY — an interactive picker lists every pkg#task entry across the workspace (only the selected projects’ under --filter / --affected; --affected selecting nothing exits 0 as a run does), prints description next to each, prompts for a number, runs the chosen one. The menu and prompt go to stderr, so vx run > out.txt still asks on the terminal. Ctrl-C at the prompt exits 130 as an interrupted run does; Ctrl-D exits 1 with no task picked. A workspace with no task exits 1 naming how to declare one (under tasks in a vx.config, or vx init).
  • Not a TTY — exits 1 with missing task name (stdin is not a TTY, so no picker; tasks here: build, test), naming the cwd project’s tasks, else every project’s (twelve, then and N more); outside a workspace it reads vx run <task>, e.g. vx run build.
  • Under --dry=json, on a terminal too — the same refusal (a JSON answer opens no picker; …), with the VX_E_USAGE error document on stdout: a program cannot pick.
Form Effect
(default) The project that contains cwd. Errors if cwd is not inside a project.
pkg#task Just that project.
//#task The root project’s task (Turbo’s spelling; the root is a project, D-39).
--all Every project that declares the task.
--filter <pat> (repeatable) pnpm-style filter DSL (see below).
--affected[=<base>] The tasks a git change reaches: the ones it touches and those whose dependsOn closure holds one.

Combining: every include (--filter <pat>, --affected) is taken first and every ! exclude after them all, as pnpm does, so an exclude removes what any include added, whichever side of it it sits (items 955, 979). The union holds for tasks too: --affected --filter other runs other’s tasks whether or not the change reaches them (X-10). --all with a filter is the filter’s selection: the filters refine it rather than being overridden by it (--all --filter '!docs' is everything but docs).

The full DSL lives in src/workspace/filter.ts; this is the user- facing summary.

A filter that matches nothing refuses the run (no projects matched filter(s): …) with Did you mean &lt;name&gt;? when a project name is within two edits, or when exactly one scoped project’s name after its / is (--filter vx-otel hints @vzn/vx-otel), and Projects: a, b otherwise (M-56). A list names eight, then a count. An unmatched tag: filter hints the nearest tag instead (Did you mean tag:scope:web?), else lists the tags. When every pattern matched and an exclusion took back all of it, the refusal names the exclusion instead (no projects selected: !app excluded every project the other filters matched).

Form Meaning
<pattern> Match by package name. * matches any characters, including /. A pattern matching no package may leave out the scope, as pnpm reads it (cart is @nx-example/cart when one package carries it). One holding a / outside a scope that matches no package is a directory, as Nx’s --projects reads it (apps/* is ./apps/*).
./<dir> The package at <dir> alone, as Turbo and pnpm read it (. is the root project); a <dir> that is no package matches the packages under it (relative to workspace root; D-43).
{<dir>} Same as ./<dir>.
./<glob> A glob over root-relative project dirs: ./packages/* (direct children), {apps/**} (nested too; a trailing ** matches zero dirs, so ./packages/kit/** holds kit itself, as pnpm and Turbo read it). A path that names a project dir literally is read literally first, so ./packages/[abc] is that directory.
. The root project alone, when the root is a project (D-39); otherwise the packages under the root, i.e. every package, not the one you are standing in.
// The root project alone, Turbo’s name for it; matches nothing when the root is no project (D-46).
tag:<pattern> The projects whose config tags hold a match, as Nx’s tag: reads them (* as in a name). Takes every operator a name takes: !tag:x, ...tag:x, tag:x^..., tag:x[main]. Nx’s --projects tag:x aliases it.
<pattern>... Match + all transitive dependencies (see below what an edge is).
...<pattern> Match + all transitive dependents.
<pattern>^... Only the transitive dependencies, excluding the matched package itself.
...^<pattern> Only the transitive dependents, excluding the matched package itself.
...<pattern>... Match + its dependents + the dependencies of all of them, as Turbo selects (...db... takes the packages the apps that use db build on).
<sel>[<ref>] The packages <sel> (a name pattern or {<dir>}) selects that changed since <ref>, as Turbo and pnpm read @scope/*[main] (D-44).
<sel>...[<ref>] The packages <sel> selects that changed since <ref> or depend on one that did; no dependency is added (Turbo: @acme/api...[HEAD] is api when only its dependency changed).
!<pattern> Exclude packages matching <pattern>, from everything the includes select, in any order.
[<git-ref>] Projects whose files changed since <git-ref> (main, HEAD~5, …).

An edge is one a plugin supplies — a discover entry’s deps (order and selection) or reach (selection only); the lockfile plugins (bun(), pnpm(), npm(), yarn() from @vzn/vx-lockfile) supply what the package manager installed, and core reads no edge from a package.json — OR a cross-project dependsOn entry (e2e’s test: { dependsOn: ['app#build'] } makes e2e a dependent of app). The task graph knows both, so selection follows both: vx run test --filter '...app' runs e2e#test even though no plugin gives e2e an edge to app. There is no implicitDependencies field — declare the edge where the task needs it.

A filter that names no project (..., a bare !) is refused, and one whose pattern matched but whose walk selected nothing says what it matched: no projects selected: filter "...^core" matched core, and no project depends on it (item 1030).

Examples:

Terminal window
vx run build --filter @scope/* # all packages under @scope
vx run build --filter app... # app and its transitive deps
vx run build --filter ...util # util and everything depending on it
vx run build --filter app^... # only app's deps (not app)
vx run build --filter '*' --filter '!docs' # everything except docs
vx run build --filter '[origin/main]' # projects with files changed since main

Run the task only in projects whose files changed since <base>.

  • --affected (no value) uses VX_AFFECTED_BASE when set, else a plugin’s base commit when one names it (setBranch({ base }): github() a pull request’s base, nx() NX_BASE, turbo() TURBO_SCM_BASE, else, turbo() only, as Turbo does, on GitHub Actions the pull request’s base branch or the push’s before), else the merge base of HEAD and origin/<baseBranch> (schema: baseBranch is origin/HEAD’s branch, then main, then master; the local branch in a clone with no remote; its tip when they share no history). On the base branch itself there is no default (set VX_AFFECTED_BASE or pass one): vx refuses (--affected has no default on the base branch (main): run --all (the cache skips what did not change), or name the last good commit, --affected=&lt;sha&gt;), since HEAD~1 saw one commit of a multi-commit push. CI on main needs only the current commit: --all reads no history, and the cache runs only what changed. A shallow clone (CI’s one-commit checkout) needs no more history: vx fetches from origin what the merge base needs, the base’s tip at depth 1, then both tips deepened 1, 2, 4… commits until git finds it, commits and trees only (no blobs). A pull request’s merge checkout has its base as HEAD’s parent, so that is one fetch. With no base branch and no origin to fetch it from, vx says so (--affected has no base here). And when an explicit base IS the commit you are on, the nothing affected since <ref> note says the ref is HEAD itself and names the two bases you probably meant (--affected=origin/main, --affected=HEAD~1).
  • Without git on PATH, every shape is one line — vx requires git: failed to spawn 'git' … Install git and re-run — the same the input enumeration prints; a minimal image met a stack here before (2026-09-16). Outside a git work tree every shape says vx requires git: &lt;root&gt; is not inside a git work tree, as a plain run does, and a repository with no commit yet says so, not “no base branch” (X-52).
  • --affected=<ref> uses the given git ref. A value that is empty or starts with - is refused before git sees it: the ref is an argument, never a shell command, and an option-like one (--output=<path>) would be a real git diff option. A range that does not end at HEAD (HEAD~2..HEAD~1, main...feature) is refused there too, naming the base to pass alone — ranges are not supported — pass the base alone ("HEAD~2") — because the other end is always the working tree; <base>...HEAD, Turbo’s CI spelling, is read as <base>, since vx diffs from the merge base to a working tree that holds HEAD (D-117). Its two-dot <base>..HEAD diffs from <base> itself, not the merge base. An exclusion (^main) is refused the same way, naming main. A ref that does not exist is git ref "<ref>" did not resolve; in a shallow clone whose origin did not give it, it adds that the clone is shallow and how to fetch the base (git fetch --depth=1 origin <base>). A ref naming a path inside a commit (main:packages) is refused, naming the commit to pass: its paths lack the prefix, so the diff would select the wrong projects. A root tree (main^{tree}) is still a base.
  • A member whose directory is a symlink to a place elsewhere under the workspace root (packages/b -> ../ext/b) is selected by a change at that real place too: git names the files where they live, not by the link (item 1079). A link to a directory outside the root is outside git’s view, and a change there selects nothing.
  • The diff runs from the merge base of the ref and HEAD, not from the ref itself, so a branch whose base has moved on sees only its own changes — never the files other people landed on main since it forked (Turbo and Nx do the same). Refs with no common ancestor diff from the ref.

It selects the tasks the change reaches (owner, 2026-10-04). A change seeds tasks in the projects it touches:

  • a cached task when a changed path is one of its declared inputs (files, workspaceFiles), or a changed submodule or embedded repository is one its workspaceFiles may reach into (git reports it as one path; a glob whose fixed prefix is above or inside it counts);
  • every task of a project whose vx.config.* changed, or that holds a changed path no task of its declares (vx cannot prove that path re-keys nothing), or that holds a changed submodule or embedded repository (git reports it as one path, and no task’s globs can say whether they reach inside), or that a lockfile claim or a config import names (a dependency an install added or removed reaches here as the lockfile’s change);
  • a task with no inputs when a changed path lies in its project or the project is reached whole; a root file another task of its declares (workspaceFiles) is no change there. A group seeds nothing.

A requested task runs when its dependsOn closure holds a seeded task, so a change reaches another project only along a task edge. With app#test behind ^build, an edit to ui/src/button.ts that ui#build’s inputs read runs ui’s tasks and app#test; an edit to ui/src/button.test.ts that only ui#test reads runs ui#test alone, and app#lint, which depends on nothing, never runs for a change in ui. A pkg#task you name runs whatever the diff. This is the edge the cache key folds (a cached app#test with no edge to ui never sees ui’s files either). A project that declares no build gets one keyed on all its files (schema.md), so any change there reaches a dependant behind ^build; a package ^name passes through for want of a config reaches it the same way. The graph is the one a graph plugin leaves: an edge or an input it adds reaches a task as a declared one does, and a task it marks requested runs whatever the diff. With such a plugin every project is a candidate once the diff touched anything, and its hook sees every candidate task before the selection prunes the graph. Nx 23.3 (NX_LEGACY_AFFECTED=false) and Turbo (affectedUsingTaskInputs) select tasks the same way, behind flags.

Until 2026-09-16 the sugar was the changed-only [<base>] form (item 287), and until 2026-10-04 it took every task of every manifest dependent, which the filter forms still do:

Terminal window
vx run test --affected # the tasks the change reaches
vx run test --filter '...[main]' # what changed + every dependent project
vx run test --filter '[main]' # only the projects that changed

Its candidate projects are --filter '...[<base>]'’s; both are resolved by src/workspace/affected.ts, which unions git diff against <base> with git ls-files --others so a brand-new untracked source file counts as a change (input hashing sees it, so --affected must too). A project inside a submodule or an embedded repository is selected when git reports that repository changed — a dirty or moved submodule (vendor/sub), an untracked embedded repository (vendor/nested/): the workspace repository sees the nested one as a single path (git diff --raw’s gitlink mode, 160000, on either side names one), so a change inside is a change to it, and every project under it is selected, the project that holds it included. A repository’s own request to hide submodules from a diff (diff.ignoreSubmodules, submodule.<name>.ignore) does not apply: the key sees the change whatever git is told to show. A symlinked input keys what it points to, so a link whose target changed (a file the diff names, or one under a linked directory) is a changed path of its project, and a link to a target git cannot diff (outside the workspace, or git-ignored) is one on every run. A gitignored file a task’s inputs name outright (.env.local, dist/**) never selects the task on its own, as in Nx: --affected reflects only what git sees. The key still hashes it, so a run of the task misses when it changed. .vx/projects.lock.json is filtered out of the changed set — a vx lock re-write never marks every project affected.

A lockfile change selects everything. The root lockfiles, pnpm-workspace.yaml, .yarnrc.yml, .npmrc, bunfig.toml and the patches bun.lock names are folded into the workspace fingerprint, which is part of every task’s cache key — so a bun install / pnpm update invalidates the whole cache. Those files sit at the workspace root and belong to no project, so mapping changed paths to project directories would select nothing; --affected widens to every project instead, for the same reason it unions in untracked files. Only the ROOT copies count: a lockfile vendored inside a package is not hashed and selects just that package. A lockfile a plugin CLAIMS (VxPlugin.fingerprint, e.g. pnpm-lock.yaml under @vzn/vx-lockfile) is the exception on both sides: the key folds what the plugin says per project, so --affected asks the plugin which projects the change touches — given the bytes at the base ref and in the working tree — and selects those. An edit to a file the lockfile names but does not pin (a patch bun.lock names, a file: directory dependency under any of the four managers, or a directory one links) is asked about the same way; only a plugin that cannot tell widens, as @vzn/vx-lockfile does for the install settings it claims (.npmrc, bunfig.toml, pnpm-workspace.yaml, .yarnrc.yml).

A dropped edge selects its dependent. A package the change deleted (its package.json was there at the base and is gone) is no project now, so its paths map to nothing; and one whose version or name moved may no longer satisfy what a dependent declares (lib: ^1.0.0 after a bump to 2.0.0). Either drops an edge the dependent’s key folded, while today’s graph shows no dependent to walk to. So the package graph is built again over the changed manifests as the base had them, and every project whose workspace dependencies differ is selected, and so is a project whose task names a removed or renamed package in dependsOn: ['lib#build'], an edge the package graph cannot see (item 1085). An edit to the root manifest’s workspaces selects every project: which packages left the workspace is a discovery at the base.

A new nested project selects the project above it. A project’s inputs stop at every project below it, so a package.json that makes an existing directory a project re-keys the project that held it; the change maps to the new project alone, so the one above it is selected too. “New” is judged at the base: no manifest there, or one with no name.

A workspace config change selects everything. An edit to .vx/workspace.* selects every project: the config and project stages its plugins install shape every resolved config, so the edit can re-key any task, and selection cannot tell which. It is not in the fingerprint (a key moves only when a stage’s output does). A root file a plugin’s stages read without importing it is seen when the plugin CLAIMS it (VxPlugin.fingerprint): turbo() claims turbo.json and turbo.jsonc, nx() claims nx.json, and an edit asks the claimant, which answers every project (item 961).

A file your config IMPORTS selects that project. vx hashes the resolved config, so a shared preset a vx.config.* imports is part of the cache key — and selection follows the same rule. Editing shared/preset.ts selects every project whose config imports it, directly or through another shared file, even though the file belongs to no project and no workspaceFiles glob names it. The scan is STATIC (nothing is evaluated) and follows RELATIVE specifiers, and a bare one the nearest tsconfig maps through paths or baseUrl; any other bare specifier is a package, and a lockfile change already selects everything. It crosses project boundaries as the evaluation does: a config importing ../../packages/lib/preset.ts is selected when preset.ts or a file it imports inside lib changes. Import your helpers by bare specifier to opt out. See docs/modules/config-imports.md.

Nothing changed exits 0. When the selection comes only from --affected / [<ref>] and resolves to zero projects, vx prints nothing affected since <ref> and exits 0 — a docs-only commit must not fail vx run lint test build --affected=origin/main. When the diff did match and an exclusion or an empty walk took it all back, the exit-0 note says that instead (no projects selected: !app excluded every project the other filters matched), never that nothing changed. A name or path pattern that matches nothing is still an error (a probable typo), and a pattern that matches nothing alongside one that matched is warned about on stderr.

An empty selection never cancels an anchored task. Project scope applies to bare names only, so vx run app#deploy build --affected=origin/main with nothing changed still runs app#deploy (vx notes nothing affected since <ref> — running app#deploy only on stderr). Only a bare-name-only invocation short-circuits to exit 0. With only pkg#task names the scope goes unused, but --filter and --affected are still resolved: a pattern that matches nothing or a ref git does not know refuses the run as it does beside a bare name.

Anything after -- is forwarded (shell-quoted) to the task’s exec.command:

Terminal window
vx run test -- --watch # underlying test runner sees "--watch"
vx run build -- --sourcemap # build command gets "--sourcemap"

They are appended to the command’s last line (trailing blank lines are dropped first, so a multi-line command’s closing newline does not run them as a command of their own), or before a # comment still open there (echo args: # show gets them; with comment-only lines below a commented line, before the earliest). A heredoc’s body and terminator are not command lines: cat <<X … X gets them on the cat <<X line (X-12). A <<X in a comment or in quotes opens no heredoc, and a quote in a body opens no string (X-110). A persistent task gets them too, with or without a readyWhen.

Forwarded args are folded into the cache key — different args produce different cache entries. They scope to user-requested tasks only; dependsOn-pulled deps don’t see them (so upstream cache identity stays clean). A frame’s $ <command> line shows the command as it ran, so a requested task’s carries the args and a dependency’s does not. A group (a task with no command) takes none, so a request that names only groups is refused (args after \--` reach no task`) rather than run with the args unheard.

Flag Type Default Description
--filter <pattern> repeatable (none) pnpm-style filter DSL (see above). --filter=<pattern> form too.
--all boolean off Select every project that declares the task.
--affected[=<base>] optional value off Select the tasks a change since <base> reaches: the ones it touches and those whose dependsOn closure holds one (default a plugin’s base, else the merge base with origin/<baseBranch>; none on that branch); candidates are --filter "...[<base>]"’s.
--exclude-dependencies[=<names>] optional value off Drop dependsOn edges. No value = all (just the requested task runs; a group’s members run as the group); comma-list = drop only those names, each of which some project must declare (a typo is refused with the nearest name, item 1026). An edge to a task the run schedules anyway (--all requests it) stays, so the two still run in order, and so does the order through a dropped task: with gen dropped from test → gen → build and build requested, test still waits for build. An empty = value is a parse error (ambiguous — see below). A dropped dependency does not run but is still keyed, so every key is the one a full run derives; a task keyed on one may hit but does not save (caching.md step 8).
--concurrency <n> int or <n>% run.concurrency, else the cores (cgroup-capped) Maximum parallel tasks that EXECUTE; confirmed cache-hit restores are disk work and run on their own lane, up to twice this. 1 serializes both; 50% is half the CPUs (rounded, never below 1; over 100% is allowed for I/O-bound work). --concurrency=<n> form too.
--schedule <rule> value run.schedule, else most-work Which ready task starts first when more are ready than workers: most-work, critical-path, direct-dependents, ready-order (docs/schema.md § run.schedule).
--branch <name> value VX_RUN_BRANCH, else git’s branch, then the CI provider’s The branch the run records in its history and forecasts from. No config key: a committed branch would mislabel every other branch’s runs.
--no-cache boolean off Disable caching entirely (no reads, no writes); output globs are NOT cleaned.
--force boolean off Re-execute everything (skip cache reads); writes stay on. A key already stored is kept, and a remote that holds it is not sent it again. Output globs are cleaned (so the saved snapshot is clean).
--cache-policy <spec> (--cache-policy) value VX_CACHE_POLICY / cache.policy Per-layer read/write control. See below. An EMPTY spec (--cache-policy=) is a parse error — it applied nothing and left every axis on; pass --no-cache to disable them all.
--cache-dir <path> value VX_CACHE_DIR / cache.dir / ~/.vx/<id>/cache Cache directory override, resolved relative to cwd (absolute paths used as-is). Beats VX_CACHE_DIR, the workspace’s cache.dir and the default; like them, the directory then holds the whole cache, shared through no store, for every cache the run opens, so the workspace’s default dir is not created beside it. A per-run knob — never folded into a cache key. --cache-dir=<path> form too; the space form rejects a value starting with -. A directory this user cannot write into serves its hits and saves nothing, saying so once; an empty one fails the run before any task with cache directory <path> is not writable (EACCES: …).
--retries <n> value run.retries, else 0 Re-run a failed task up to n more times; never a persistent one. A task’s own exec.retries wins (even an explicit 0). Never affects cache keys. --retries=<n> form too.
--continue[=<mode>] value deps-ok What a failed task takes down with it. never stops dispatch on the first failure; deps-ok (default) skips only its dependents; always (bare --continue) runs dependents anyway. See § Failure propagation.
--timeout <duration> duration run.timeout, else none A task’s time limit (30s, 10m), for tasks without their own exec.timeout, which always wins. A runaway task is killed + failed. A bare number is refused naming the forms with units. Never affects cache keys. --timeout=<duration> form too.
--output-logs <mode> value VX_RUN_OUTPUT_LOGS, else flow-derived full | errors-only | hash-only | none — explicit output override. See § --output-logs. --output-logs=<mode> form too.
--cache-download <mode> value cache.download, else all all | toplevel | none — where a REMOTELY-executed task’s outputs land. none leaves them in the remote CAS and fetches lazily, only when a locally-placed task needs them. Never affects cache keys. See § --cache-download. --cache-download=<mode> form too.
--cache-concurrency-download <n> int (1+) cache.concurrency.download, else min(96, max(cores × 3, 64)) Remote downloads at once.
--cache-concurrency-upload <n> int (1+) cache.concurrency.upload, else min(96, max(cores × 3, 64)) Uploads at once, per remote.
--cache-concurrency-restore <n> int (1+) cache.concurrency.restore, else twice --concurrency Local hits restoring their outputs at once.
--verbosity <n> int (0+) 0 1 or more prints a per-task table (groups left out) after the framed blocks, above the footer. --verbosity=<n> form too.
--dry[=text|json] optional value off Print the task graph + predicted cache hit/miss; skip execution. VX_DEBUG_TIMING=1 prints the stage table here as it does for a run.
--profile[=<path>] optional value off (profile.json when set) Write Chrome-trace JSON of the run’s wallclock spans.

Mutual exclusion:

  • --dry with --profile — the profile needs a real run to write about.

Unknown flags are a parse error (unknown flag: --foo), naming the nearest flag the verb accepts when one is within two edits (unknown flag: --concurency (did you mean --concurrency?)). Every verb does this against its own usage line and --help: vx info --formt hints --format, vx lock --chek hints --check, vx upgrade --hlp hints --help, and --json on a verb that takes --format hints --format json. vx version takes no word, so vx version --hlp is refused (exit 1) where it once printed the version.

A task typed where the verb goes (turbo build, nx build app) is refused with the vx run that runs it: vx build names vx run build --all from the root and vx run build inside a project, vx build app names vx run build --filter app, and vx app#build names vx run app#build. A typo of a task (vx biuld) names the task and the same vx run, unless a verb is as close (vx rnu hints run). The default build is no declared task, so it is no hint: where it is the only build, vx build is an unknown command. It stays a refusal: a plugin verb of the same name is the verb, and would change what vx build means the day one was declared.

Optional-value flags take their value with = only. --affected, --exclude-dependencies, --dry and --profile are all valid bare, so a following word is read as a task name — vx run --affected build means “run build, affected scope”, and there is no way to tell that apart from “build is the git base”. Write --affected=origin/main. Getting it wrong is loud, not silent: the value becomes a positional that matches no project, so the run refuses to start (see “Every requested name must resolve” above).

--exclude-dependencies= with an EMPTY value is rejected rather than guessed — “drop every edge” and “drop none” are both plausible readings. Pass bare --exclude-dependencies for the first, omit the flag for the second.

Value flags (--filter, --concurrency, --output-logs, --verbosity, --cache-dir, …) accept both --flag value and --flag=value. In the space form, --cache-dir rejects a value starting with -: that is always a swallowed flag (an unquoted empty shell variable), never a path or task id. Use the = form for a literal leading dash.

Numeric flags take a plain decimal integer. --concurrency, --retries and --verbosity reject hex (0x10), exponent (1e3), fractional (2.7), signed (+4) and space-padded forms, plus anything past 2^53 (it would parse to a number you did not type). These all used to be silently reinterpreted — --concurrency 0x10 ran 16 workers. A duration flag (--timeout) takes its unit (500ms, 30s, 10m): a bare 500 is refused, naming both readings.

An empty = value means “no value” on two flags, which then act like their bare forms: --profile= writes profile.json and --affected= uses the default base. Every other flag refuses an empty value: --dry=, --continue= and --exclude-dependencies= as well as the ones with no bare form (--retries=, --timeout=, --cache-dir=, --filter=, --cache-policy=).

Cache control: --cache-policy, --no-cache, --force

Section titled “Cache control: --cache-policy, --no-cache, --force”

The cache has four independent axes — localRead, localWrite, remoteRead, remoteWrite — and the three flags above resolve them in this precedence order:

  1. Start with every axis on (the default).
  2. Apply each --cache-policy=<spec> segment (the base), or, with no --cache-policy, the VX_CACHE_POLICY environment variable’s spec, or, with neither, .vx/workspace’s cache.policy (docs/schema.md).
  3. If --no-cache was passed, force all four off.
  4. If --force was passed, force both reads off (writes stay whatever the base / --cache-policy left them).
  5. Off CI (CI unset, 0 or false), unless --cache-policy, VX_CACHE_POLICY or cache.policy names the remote, force remoteWrite off: a key holds only declared inputs, so a laptop’s write could replay on CI. cache.scope never changes an axis.

A spec that names a remote axis (remote:r, remote:w, remote:rw) in a workspace whose plugins supply no remote layer gets one status line saying so, naming where the spec came from (--cache-policy, VX_CACHE_POLICY or cache.policy) — the axes are inert without a layer to serve them, and a CI job that believes it is filling a shared cache should be told.

Whatever the axes, a lookup asks the local cache first, then each remote in the order the plugins declare them.

The axes govern task artifacts. A plugin’s own entries (ctx.cache, such as the default branch’s timings a scheduler plans by) are read from every remote the run may read or write, so a remote:w run is still planned on the shared timings.

So --no-cache always wins over --force. The common cases:

  • --no-cache → nothing reads, nothing writes, and declared output globs are left untouched (you’re debugging; vx won’t manage your tree).
  • --force → re-execute every task (reads off) with writes on. An entry is immutable: a key the local cache already holds is kept, and each remote that can batch (hasMany) is asked once, in the background, which keys it holds; one that cannot is asked has per key before its upload. Held keys are not uploaded again (a probe that fails uploads, as before). Output globs ARE cleaned before each task, so the disk holds this run’s outputs.

--cache-policy=<spec> is a comma-separated list of <layer>:<flags> segments. layer is local or remote; flags is any subset of r (read) and w (write), order-independent and possibly empty. A mentioned layer is set EXACTLY to its flags; an unmentioned layer keeps its current value. Both --cache-policy=<spec> and the space form --cache-policy <spec> are accepted.

Spec Effect
--cache-policy=local:rw,remote:r remote read-only (won’t upload); local full
--cache-policy=local:r local read-only; remote untouched (still full)
--cache-policy=remote: remote fully off; local untouched
--cache-policy=local:,remote:rw don’t touch the local cache, but still upload to remote

local: means “don’t serve hits out of the pre-existing local cache” — a remote hit is still delivered (the artifact has to land on disk to be extracted), it just never short-circuits the remote read.

Combine with --force for “re-execute and fill only the remote”: --cache-policy=local: --force (local off, reads off, remote write-only; keys the remote already holds are not sent).

Invalid layers/flags are a parse error (--cache-policy: invalid layer 'disk', --cache-policy: invalid flag 'x', …); VX_CACHE_POLICY and cache.policy say their own name instead.

What a run prints is derived from the run’s intent (its “flow”), unless explicitly overridden:

  • FOCUSED — no selection flag was passed. The user is running “their” task; cwd and task count are irrelevant to the classification.
  • BROAD — the invocation used --all, --filter, or --affected. The user asked about a swath of the workspace and wants news, not output.
  • CI — the CI env var is truthy (CI=0 / CI=false don’t count). Wins over the flow.

Reported task lines share one column grid — <glyph> <time> <status> <cache> <name> — with two orthogonal axes: the glyph SHAPE encodes the cache axis, the glyph COLOR (and the status word) the task axis.

Glyph Cache axis Status word
⏺ miss — the task ran success
► fresh (up-to-date) success
⇢ restored from local cache success
⇣ restored from remote success
◼ failed failed
⊘ skipped (blocked upstream) skipped
▸ persistent (dev server) running

A live WORKER row carries no glyph: the ticking elapsed time leads it, which is the motion the run has instead of a spinner.

Per-task visibility by outcome. Each cell is the SHAPE of what prints — silent, one-liner, frame, or a conditional; the table is pinned to the renderer by tests/output-doc-drift.test.ts, so the vocabulary is fixed:

Outcome focused (requested task) focused (dependency) broad CI / full
executed frame silent one-liner frame
restored-local/-remote frame silent silent frame, or one-liner if quiet
up-to-date frame silent silent frame, or one-liner if quiet
failed frame one-liner + frame replays one-liner + frame replays frame
skipped one-liner one-liner one-liner one-liner

What the shapes mean in each column:

  • focused, requested task. The frame is LIVE when it is the only requested task: ┌─ prints at task start, the command’s output (or a hit’s replayed stdout) streams raw between the brackets, └─ closes it. With several requested tasks the same frame is buffered and emitted atomically (see below). Every outcome that ran or was cached gets one — including an up-to-date hit, whose frame carries the $ cmd line so a requested task looks the same whether it ran or not.
  • skipped is the exception: it never started, so no frame was opened, and it produced nothing a frame could hold. The one-liner says everything, the blocker included (⊘ skipped app#deploy • blocked by lib#build; a fail-fast skip, which nothing blocked, carries no suffix). Every view that lists tasks prints it, a dependency’s included: nothing prints below the footer.
  • frame, or one-liner if quiet — a cache hit with stored stdout is worth a frame (the output is the point); a hit with nothing to replay compresses to one line, which is what keeps a 2000-task warm run readable.
  • one-liner + frame replays — the ◼ … failed line prints immediately; the full frame is held and replayed at run end.

When a dependency fails mid-run, the stream gets ONE permanent ◼ … failed miss <id> line and the run continues; all full failure frames replay together at run end, right above the summary, and are never capped.

The end-of-run summary always prints; cache-hit counts that broad mode silences per-task surface there. A focused vx run test is meant to feel like running the test command directly — same output, just faster.

Groups are transparent folders. A group task (no exec, just dependsOn) has no output of its own, so running one focused — vx run build where build chains build.bun which chains build.bun.darwin-arm64 … — surfaces the real tasks it stands for and shows them like requested tasks. The walk descends through nested groups but never leaves the requested project (^/cross-project deps aren’t surfaced) and never goes past a real task into its own deps. The requested count for the live-vs-buffered decision counts the surfaced tasks: one real task streams live, several buffer into atomic blocks. (Surfacing is display-only — it does not make those tasks “requested”, so -- forwardArgs still go only to what you named.)

Live streaming applies only when there is exactly one requested task. Live open/close framing assumes a single task owns the terminal between its open (┌─) and close (└─) lines — with two requested tasks running concurrently their frames would interleave into garbage. So when more than one task is requested (vx run build test), each requested task instead buffers its output and renders as a single atomic block at completion. The shapes are the ones in the table above — anything that ran or was cached gets a full frame, up-to-date included; only skipped is a one-liner. The blocks are blank-line separated and never interleave. A single vx run test keeps the live-stream experience unchanged.

On an interactive terminal (TTY stdout, not CI) a status region tracks the run live. Top to bottom:

  1. A blank separator line — keeps the live region visually apart from the completed-task scrollback above it.
  2. Pinned persistent tasks — ▸ <id> running for every persistent task that became ready. The pin lives until run end, so it is the visible evidence the dev server is still alive. After the summary, a requested persistent task keeps vx in the foreground, with the persistent tasks it depends on, until it — or, with several, the first of them — exits (one kept only as a dependency that exits 0, a daemon that forked and returned, does not end it); the rest still up are then torn down (SIGTERM, a 2 s grace, SIGKILL), one status line names the task and its code (vx: app#dev exited with code 1; stopping 1 other persistent task), and a non-zero exit makes the run exit 1 and is what vx last records. A Ctrl-C prints no such line: the server ended because it was stopped, and vx exits 130. A run with a failure elsewhere (a task failed or skipped, a server never ready or crashed) holds nothing: it stops its servers and exits 1, unless --continue=always; vx watch keeps its server through a failed cycle. While vx holds them, what the servers write streams below the summary a line at a time under each one’s id (app#dev │ Local: http://localhost:5173), except under --output-logs errors-only, hash-only or none; a single requested server in the focused flow streams raw from its frame.
  3. Worker rows — one per worker slot that has been busy (at most min(concurrency, 10)), no glyph and no spinner: the live ticking elapsed time leads ( 568ms running <id>). A task stays in its row for its whole life, so nothing jumps; a task running beyond the slots gets a row after them until one frees. A dim header names the columns over the rows (time status cache task, · worker when an executor places tasks), and once over the list’s first row on a terminal. From the first frame, an idle worker row holds a task an unfinished dependency blocks, one per row, in the order the scheduler starts them (most dependents first): blocked solid-js#build by solid-js#types (dim blocked, the id, then the running task that holds it back). The task starts in the row that held it. A row with no blocked task left to hold leaves the region and the rows below close up; blocked tasks only dwindle, so it never comes back. When an executor places tasks (@vzn/vx-dte), the rows are only what runs: no idle or blocked row, blocked tasks are the tasks line’s dim N blocked alone. critical path (yellow) follows the running task the longest unfinished chain waits on, by the same p50s as the forecast below and only while two or more run; its row in the list says it too once it ends. Where local history knows the task, its row also shows its usual duration (its executed p50) in the cache column, fixed while the elapsed time counts up toward it (3.33s running ~5.2s solid-js#types).
  4. vx’s own steps — what vx does around the tasks that the run waits on, before its first task starts or before it ends, as rows of their own below the worker rows: vx:load (configs and graph from the lock), vx:run-lock, <plugin>:setup, vx:keys (keys and local probes), <plugin>:schedule, then after the graph vx:uploads, vx:history, <plugin>:telemetry, <plugin>:teardown, vx:snapshots, vx:retention, vx:close and vx:sandbox. A step shows once it has run 150 ms, ticking like a task ( 1.2s running internal vx:uploads); no worker slot or --concurrency holds it back. When it ends it leaves a list row like a task’s, internal in the cache column (⏺︎ 4.60s success internal vx:uploads); a quicker one shows nowhere. A step that runs beside the tasks, vx:prefetch (a remote’s downloads), never shows: vx last lists it. : is no task id’s separator, so a step never reads as a task: a project named vx keeps vx#build. No step is a task: no count, bar, history row, forecast, flaky verdict or telemetry record holds one; --profile and VX_DEBUG_TIMING time vx’s stages their own way. Steps show only beside a live region; a log (CI, a pipe) prints none.
  5. The live summary section — the SAME meters the final footer prints (tasks + cache bars with legends, time), filling in as the run progresses, under a bare vx wordmark rule. A second in, the time row adds what is left (time 1.41s · ~3s left): each unfinished task’s executed p50 from local history, the longer of the longest unfinished chain and that work spread over the workers, a running task’s spent time taken off its own. It says nothing when history knows none of what is left, or once a running task is 25% past its p50. A run that ends inside the second never reads history for it, and the final summary never shows it. A p50 here is the median of the task’s last five timings on this branch, else on any branch; the timings are shared by every worktree of the repo, so a new one has a forecast on its first run.

In a terminal known to read them, the region also sends zero-width escapes: tab or taskbar progress (OSC 9;4: Ghostty, iTerm2 3.6+, Windows Terminal, ConEmu), filling by the forecast or else by tasks done, red once a task fails, and cleared when the region goes; and a run of 10 s or more ends with a desktop notification (OSC 9: Ghostty, iTerm2, WezTerm), vx: 12 tasks done or vx: 1 of 12 tasks failed. The terminal is named by TERM_PROGRAM (or WT_SESSION, ConEmuPID); any other, tmux included, gets neither, since there OSC 9 may itself be a notification.

In a terminal known to open OSC 8 links (Ghostty, iTerm2 3.1+, WezTerm, VS Code, Windows Terminal, kitty, Konsole, VTE terminals; never inside tmux or screen), a failed task’s frame links each path that names a file in the task’s project, src/a.ts:12:5 or src/a.ts(12,5), to that file, so a click from the workspace root opens the right one. The text is the task’s output unchanged; a successful task’s frame is never linked.

The region is redrawn in place (cursor-up + clear; not a TUI — no alternate screen) and erased before the final summary prints. On a terminal shorter than the region, it shows only the rows that fit. In the focused flow it only lives while dependencies run; it disappears for good the moment the requested task starts streaming.

Redraw cost is bounded: task events force a redraw, but forced redraws within 30 ms of the last draw coalesce into a single trailing draw when the floor expires (the final state always lands). On a 3,270-task warm run this cuts ~6.7 MB of redraw ANSI to ~20 KB.

Identity coloring: each half of a project#task renders in a stable hue hashed from its own name (same project = same color, same target name = same color, in every run and every surface: every #build matches), with a dim # between, all outside the status palette, so an id can never read as an outcome.

On GitHub Actions (GITHUB_ACTIONS truthy, full output mode), each task’s block is wrapped in ::group::<id> (<outcome> <duration>) / ::endgroup:: so it collapses in the log viewer. Failed tasks stay pre-expanded and emit an ::error title=<id>::failed (exit N) annotation instead (above 128 the label names the signal: failed (exit 137, 128 + SIGKILL), a timeout its reason, failed (timed out, exit 143), a persistent task that never became ready its reason, and a sandboxed task its violation count, as every surface labels a failure). In every output mode there, a task’s own text (a frame, a server’s output since ready) is fenced in ::stop-commands::, so a line it prints never becomes a workflow command.

Explicit override; always beats the flow and CI defaults. full (frames for executed work, one-liners for quiet cache hits), errors-only (only failed tasks print, a server that died on its own after ready included, with what it wrote since; the CI noise budget), hash-only (one line per task — outcome word, task id, cache key — and no log output at all; the run’s audit trail of which key each task resolved to, Turbo parity), none (no per-task output). The end-of-run summary always prints.

--continue[=never|deps-ok|always] controls what a failed task takes down with it:

  • deps-ok (default): the failure’s transitive dependents are skipped; independent siblings keep running. A server that dies after it became ready is a failure to its dependents not yet started, including those that reach it through a group.
  • never: fail fast — the first failure stops dispatch. In-flight tasks finish their attempt and start no retry; everything not yet started (cache restores included) completes as skipped. A server that dies after it became ready stops dispatch the same way.
  • always (bare --continue): dependents run even when an upstream failed — to surface every failure in one pass. A task downstream of a failure (directly, or through successes built on it) runs and cleans its outputs as usual but is never saved: under pure-input hashing its key is the one a healthy run derives, while its bytes were built on a partial tree, so caching it would hand the next clean run a stale hit. A server that died after it became ready is such a failure to what starts after its death. A cache hit still restores (that artifact came from a healthy run), and the next run without the failure rebuilds the rest.

The mode is the local scheduler’s: a task a plugin executor runs elsewhere is one dispatch, failed or not, like any other.

Where a remotely-executed task’s outputs land. all (default) downloads every task’s outputs to this machine as it completes — today’s behaviour, byte for byte. toplevel brings home only the tasks you actually asked for — including the real tasks behind a requested group — and leaves intermediates remote. none leaves them in the remote CAS and fetches them lazily: only when a locally-placed task in the same run actually needs them (Bazel calls this “build without the bytes”). A CI job that only wants the verdict moves no output bytes at all.

Locally-executed tasks always write in place and ignore the flag, and exec.remote: 'only' still means never — --cache-download cannot override it in either direction. It never affects a cache key: transfer tuning cannot change what a command produces.

One safety gate: a task whose outputs another task’s inputs globs could read on disk is silently kept eager, because deferring it would make that key depend on whether the bytes arrived. A task with no cache that a cached task depends on counts as reading its whole project (C-16). --dry reports how many tasks would keep their outputs remote and names each downgrade, so a run that defers nothing says why. A run in which any task declares a inputs.runtime / workspaceRuntime command defers nothing: a shell command’s reads cannot be bounded, so vx cannot prove it will not read a deferred output (the same reason vx refuses to infer inputs by tracing). When bytes are fetched later, vx saves an ordinary cache entry for them, so the next run is a plain local hit.

Code When
0 Every task finished success or cache-hit (local or remote); or --affected left no project that declares the task.
1 At least one task ended failed or skipped; a persistent task exited non-zero after it was ready; a task name no project declares; or parse/setup error.
130 / 143 / 129 Interrupted (SIGINT / SIGTERM / SIGHUP): each task’s process group (the task and what it forked) gets vx’s signal once (a SIGHUP as a SIGTERM), a 2 s grace to go, then SIGKILL; a second signal skips the grace. vx then dies of the signal, so a shell script running it stops too (a run that handed a task the terminal exits with the code, so the terminal is restored).

A task runs in its own session, so a terminal’s Ctrl-C reaches vx alone, and each task hears it once: from vx, as SIGINT. A stop starts no more remote cache uploads and does not wait on one in flight, nor on a remote lookup or download: what was half-read is dropped and fetched again next run.

Installed from npm, vx is a sh launcher that execs the platform binary, so every signal reaches vx directly and no Node starts first (a vx --version through npm: 14 ms, was 56 ms with the Node launcher, 2026-10-12). Where it finds no binary beside it (musl, Yarn PnP, a platform with none) it hands off to a Node launcher. On Node 22.15 or later that one replaces itself with the binary (process.execve) too. On older Node it runs the binary and waits for it: a signal sent to the launcher alone (kill, a process manager) is passed to the binary, and a terminal’s Ctrl-C already reaches both, so the launcher does not send it twice.

A reader that leaves does not change the code. vx run build | head -1 closes the pipe after one line; the run still finishes, saves what it built and releases its lock, and exits with its own verdict — the output after that point goes nowhere (EPIPE, on stdout or stderr, is not an error vx reports). Before 2026-09-16 the same pipeline died with a stack and exit 1 after its task had succeeded.

--dry short-circuits execution. It builds the full task graph, compute every task’s cache key, and probe the cache to predict the hit/miss outcome. Against a remote cache the probe is a lightweight existence check — planning never downloads or ingests artifacts.

$ vx run ci --dry
would run:
✓ @vzn/vx#format-check cache hit (local) 02bfe8a9
✓ @vzn/vx#lint cache hit (local) d66cfed2
▶ @vzn/vx#test cache miss — would exec 68595e49 ~72.64s
3 task(s) planned, 2 cache hits (2 local), 1 would run.
predicted: ~72.64s wall · ~72.64s total execution

Time prediction. A would-run task with recorded history shows its typical executed duration (~p50 over its recent non-hit runs in the local cache.db). The footer predicts the run: wall is the longest dependency chain of would-run cost (cache hits restore near-instantly and count as 0), total execution is the sum across would-run tasks. Tasks with no history count as 0 and are called out (N tasks without history (+?)) — the totals are honest lower bounds. The footer is omitted when nothing would run or when no would-run task has history.

Placement. When the workspace’s plugins supply more than one executor, each row carries the one the task would land on (@spy-remote; @local for the floor; @noop for an exec.remote: 'only' task no remote accepts, which the run would skip rather than run here) and the JSON object carries it as executor. With one executor there is nothing to choose and the column is absent, except @noop, which a remote-only task carries whatever the count; it costs nothing in the prediction. If an executor hook throws or returns something that is not an executor, the plan still prints — --dry never fails over a label — but says so on the status line, in the plugin’s name: [vx] placement not shown — plugin 'x' … (the run would refuse on it).

Status legend:

Symbol Meaning
✓ cache hit (local) — entry already in <cacheDir>/
↓ cache hit (remote) — entry would be fetched from the layer
▶ cache miss — task would execute (under --force too: nothing is read, what runs is saved)
∅ no-cache — task opts out (no cache block, or --no-cache)
· @noop — task would not run anywhere
− not started — a server you did not request whose every dependant restores a local hit
○ group task (suppressed in human view; in DOT + JSON)

--dry=json emits the same data as a structured object, alone on stdout (a stage’s warnings go to stderr), and with nothing to run (nothing affected) an empty tasks list, the note on stderr; schemas/plan.json is its JSON Schema, held like the read verbs’ (see Machine-readable output):

{
"tasks": [
{
"id": "@vzn/vx#lint",
"project": "@vzn/vx",
"task": "lint",
"description": "oxlint with tsgolint-backed type-aware checks",
"hash": "d66cfed2...",
"cacheStatus": "hit-local",
"deps": [],
"p50Ms": 72640
}
],
"predicted": { "wallMs": 0, "workMs": 0, "unknownCount": 0, "criticalPath": [] }
}

Every task with history carries p50Ms, hits included (only the text view’s ~p50 is limited to tasks that would run); predicted counts would-run tasks only and is present whenever local history was readable. Its criticalPath names the would-run tasks with history on the chain wallMs measures, dependencies first: the tasks to speed up to shorten the run.

Under --affected, each requested task the diff kept gets a line, and its JSON object an affected reason:

$ vx run build lint --affected=main --dry
▶ app#build cache miss — would exec …
affected: packages/lib/src/a.ts changed (an input), via lib#build
· lib#lint no-cache …
affected: packages/lib/src/a.ts changed (in its project)

kind is input (a changed file is a declared input), project (its project changed and the task reads it whole: a task with no inputs, or a changed file no task of the project declares, often an output git does not ignore; no file for a config import), workspace (a workspace-wide file changed: the workspace config, or a root file a plugin claims such as a lockfile, reached every task of the project), package (a ^ edge passes a changed project with no such task), named (pkg#task beside a bare task) or selected (another filter selected its project). via is the dependsOn chain, nearest first, to the task the change reached. A dependency the kept tasks pull in carries none. The reasons are gathered only for a plan; a run pays nothing. --dry=json also states affectedBase, the ref the diff was taken against: a bare --affected picks it (a plugin’s base, else the base branch), so a reason that names no file may come from a commit the base reaches, a root package.json edit among them.

--profile adds a side-effect after a real run completes. Errors writing the artifact are surfaced via vx: failed to write … but don’t change the run’s exit code — the run already happened.

Writes a Chrome-trace JSON of every task’s wallclock span. Open in chrome://tracing or https://ui.perfetto.dev.

{
"traceEvents": [
{
"name": "@vzn/vx#lint",
"cat": "success",
"ph": "X",
"ts": 12345,
"dur": 4321,
"pid": 1,
"tid": 1,
"args": {
"exitCode": 0,
"hash": "...",
"cpuMs": 123,
"peakRssBytes": 45678
}
}
]
}

Each project gets a distinct tid, and one more for each task it runs beside another of its own, so concurrent tasks render on separate lanes. ts and dur are microseconds derived from the per-task hrtime.bigint() spans the runner captures. cat carries the task’s final status (success, cache-hit, cache-hit-remote, failed). A remote hit this run downloaded also carries fetchMs, its download and ingest; the rest of its dur is vx’s.

With a remote cache, the run’s lane 0 holds two cache spans: stable keys (the up-front keys, args.keys) and remote probe (the batch check, args.keys asked, args.held found). An executor adds its own events after the run’s process (TaskExecutor.profile): @vzn/vx-dte adds one process per worker, each task on a lane of its own, and the orchestrator’s waits (its README).

Default path: profile.json (cwd-relative).

Sandbox isolation is opt-in per task via an exec.sandbox block in the task’s config — there is no --sandbox CLI flag. See modules/sandbox-runtime.md for the full reference.

vx.config.ts
export default {
tasks: {
build: {
exec: {
command: 'tsc',
sandbox: {
allow: {
read: ['.'],
write: ['dist/**'],
},
},
},
inputs: { files: ['src/**'] },
cache: { outputs: { files: ['dist/**'] } },
},
},
}

The grants are the task’s whole permission surface — nothing is derived from cache, and the only thing core adds is node_modules plus the workspace packages linked there, never a link back to the task’s own project (npm and Yarn link that too). For a task that declares cache a linked package is granted only when its key folds a task of that package; declaring cache narrows core’s grant and widens nothing. Enforcement anchors at the workspace root (a task never leaves its project); only denials inside the project, or under a withheld linked package, are reported.

Policy: fail on violation. Any task that touches a path it didn’t declare either fails naturally (Linux: ENOENT from bwrap’s mount-namespace hide) or is flagged via the macOS violation store and forced to exit non-zero. No cache is written for a failed task. The task’s SANDBOX VIOLATIONS section ends with a dimmed fix: line, the grants that would have allowed each denial, project-relative: fix: exec.sandbox.allow: { read: ['tsconfig.json'], write: ['dist/'] }. A write grants the top directory it landed in, so the next file there passes too, unless inputs.files reads that directory: then it grants the file alone, so the task cannot write its own inputs; a read the output depends on is a key input as well, and the line says so, since a grant never adds one. A credential path (~/.ssh, ~/.aws, ~/.gnupg, ~/.kube, ~/.config/gh, ~/.config/gcloud, ~/.netrc, ~/.npmrc, ~/.pypirc, ~/.git-credentials, ~/.docker/config.json, a .env file) is never put in the line: an agent applies it as given, so it is named apart as one to grant only on purpose. “Didn’t declare” means the grants, which on Linux are wider than they read: a file-shaped write grant is bound as its whole directory, so every file beside it is readable without a violation — schema.md § exec.sandbox has the shape and the remedy (outputs in a subdirectory).

vx run arms the sandbox runtime when a task that declares exec.sandbox is about to execute; a cache hit needs none. If the platform cannot host it (Linux needs bwrap, socat and rg on PATH; macOS, sandbox-exec), that task fails with one line, sandbox not available: &lt;reason&gt;, and never runs unsandboxed. Tasks without a sandbox run as usual, and the failure’s dependents follow --continue. A task an executor that enforces the sandbox where it runs takes (@vzn/vx-dte’s workers) arms nothing here, so a distributed run’s orchestrator needs no sandbox runtime.

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