vx watch
vx watch runs the named task, then re-runs it on every filesystem
change in the projects in scope. Press Ctrl+C to stop.
vx watch [OPTIONS] [--poll] TASK [-- forwarded-args...]OPTIONS are vx run’s, less the ones Constraints names.
Examples
Section titled “Examples”vx watch test # cwd project; re-test on changesvx watch test --all # every project that declares `test`vx watch lint --filter '@scope/*' # filtered scopevx watch build -- --sourcemap # forwarded args carry through every cycleLifecycle
Section titled “Lifecycle”- Initial run. Same code path as
vx run— same scope resolution, same task graph, same cache behaviour. The linevx watch: initial run...precedes it. - Watch loop. After the initial run finishes (or once it waits only
on a server’s readiness, X-139), the directory of
every project a cycle can run — the scope (the bare tasks’ scope
plus each
pkg#task’s own project; only those when every task is anchored) plus its transitive dependencies, the closure--filter 'app...'walks, cross-projectdependsOnedges included — is watched recursively, less any project nested inside it, with a config or not (a root project’s key leaves a nested project’s files out, so an edit there is no cycle; X-42, X-57). The workspace root is watched (non-recursively) for a claimed file (a lockfile), for.vx/projects.lock.jsonand for an edit to.vx/workspace.*— the one root file that shapes a run (plugins,configstage, concurrency) without being any task’s input; the cycle after it re-evaluates the file. A.gitignorethere or in a project’s parent directory is a cycle too: inputs are gitignore-aware, so it changes what keys read (X-136). A file a config imports by relative path from outside the watched projects (a shared preset) is watched too, and its edit is a cycle that re-reads the configs; so is one inside a project, which re-reads the set as the config’s own edit does (WD-14). A file the workspace config imports is loaded once per process, so its edit is named with the restart it needs rather than run stale (item 949). An import whose file is gone stays on the list by the name it would have, so its return is a cycle (X-133). The projects are the lock’s: a new.vx/projects.lock.jsonis the event that adds or drops one, and its directory is watched from before that cycle runs, so an edit made during it queues (X-138). A config added and not locked is no project yet. With no lock at start the first cycle fails saying so, and the watch waits for one. A project directory, or one a config imports from, removed and made again (a checkout that restorespackages/) is heard from its nearest directory that exists and each one above it, and a watch whose directory was replaced is armed again (an OS watch holds the deleted one;watch-recreated-dirs.test.ts). There, a project directory that is still the same one starts no cycle: a task writing at its top level moved its mtime, which macOS reports to the parent (watch-project-dir-write.test.ts). Under--filteror--affectedthe scope is the one resolved at start, and a new project joins it only as a dependency of it. A task’s own declared outputs (cache.outputs.files,outputs.workspaceFiles; a plugin’sprojectstage counts, as in a run) never trigger a re-run — a cycle that writesdist/is not an edit, nor is thedistdirectory itself coming and going (a literal entry covers its whole tree, as in the schema), unless some task reads the path as an input (an in-place formatter’ssrc/**output still leaves abuildreadingsrc/**watching it, item 946; a task’s own outputs are no input of it, so aturbo()task reading**/*does not re-run on its owndist/; only the tasks the run reaches throughdependsOnare asked, so an unwatchedlintreading**/*besidevx watch buildasks nothing) — and neither donode_modules,.gitor the cache directory. A write the task did NOT declare (a task with nocacheblock declares nothing) is caught by state, judged once the bytes have settled: a file whose bytes and mode did not change since the loop last saw it is not an edit (achmodis one, as the key reads the executable bit), nor is a directory whose entries (names and sizes) did not, nor a path that stayed gone; a symlink is its target string and what it points to, as the key folds them, so a retarget is an edit, and so is an edit behind it: watch arms each link’s target, wherever it lies, and names the link; a path the loop has never judged is an edit only if it was modified after the watchers went live (macOS delivers the initial run’s own writes after the arm; the later of the path’s mtime and ctime says which side of it a path belongs to, so a file moved in with an old mtime bymv,cp -portar xstill counts, and a symlink’s own times are read, never its target’s), and a path git did not list at the arm that is gone again (vim’s4913write probe, a lock file) is no edit; and nothing is judged while a cycle runs — its own writes are mid-flight, adistdeleted and not yet rebuilt is a state the tree will not keep — so paths that land mid-run are judged together one debounce window after it ends, an edit made meanwhile included. A task that writes into its own project,rm -rf dist && tscincluded, costs one extra cycle instead of re-running forever — when the bytes it leaves are the same. A file it rewrites with DIFFERENT bytes every run (a pid file, a timestamped log) is either git-ignored — a git-ignored path never starts a cycle, since no cache key can see it (a user’s edit to one still does in a project with a task that has a command and no cache, which reads what it likes, item 947, outside any project nested in it; a write the last cycle made is not, read off the later of its mtime and ctime, so an output a cache hit restores with its saved mtime is the run’s,watch-restored-output.test.ts) — or declared an output, or the loop re-runs on it; after three such cycles in a row watch names the path and the remedy, once, and keeps going. A dev server the last cycle left running counts as that cycle for as long as it runs, so a log it rewrites in its project is named too, with.gitignoreas the remedy (a persistent task declares no outputs; item 948). A file git tracks is never blamed on the server: three saves of a source file are the user’s (WD-7). When any project’s config declaresinputs.workspaceFiles, the per-project watchers are swapped for ONE recursive root watcher (boundaries are off for those globs, so a root-relative glob can name a file anywhere). That watcher hears every write in the tree and keeps only the ones a key can see: a path inside a project’s directory, a fingerprint file at the root, or a match of a declaredworkspaceFilesglob. A log written at the root, acoverage/or.turbo/tree, an editor’s scratch file are dropped before the trigger — sovx watch … > build.loginside the repo settles instead of feeding itself a cycle per line of its own output. The opening line,watching N projects · M tasks(after the initial run’s rows), is printed only after every watcher has reported a probe file written under it (.vx-watch-probe, re-written on a short backoff until its event arrives, then removed): on macOS a directory watcher can return before its event stream is live, and an edit in that gap is silently lost — so the line is a promise, not a hope. A probe is never an edit, at any depth: another watcher’s, seen under a nested project, ran a cycle (and restarted a dev server) with no edit made (item 1016). A watcher whose probe is not heard within 2 s is replaced by a poller that checks every 250 ms, with the noticevx watch: <dir>: no OS watch events within 2000 ms; polling every 250 ms instead. - On change. A cycle opens with a blank line and
changed <project> <relpath>, plus(+N more)when more paths changed, then its task rows asvx runprints them, with no footer. The named path is the first changed one that still exists, so an editor’s temporary file renamed away names nothing; a deletion names the cycle when nothing else changed. Thewatchingline is said once: a cycle that re-reads the watched set (a member came or went, the lock or a plugin changed) says nothing more;VX_DEBUG_WATCH=1says each arm on stderr asvx watch: armed N projects. Errors and refusals go to stderr. The orchestrator is invoked again with the same options, save one: the--affecteddiff is read once, for the initial run. A later cycle runs the requested task in every project of the scope fixed at start, and the cache keys decide what executes (the startup diff, asked again, kept an edit made since out of every cycle). Events arriving while a run is in flight queue and drain after the current cycle. Re-runs are debounced ~150ms after the last event, and wait at most 1 s after the first, so a writer that never pauses holds no edit back. - Exit.
SIGINT(Ctrl+C) exits 0; once the cycle in flight is down,vx watch: stoppedis the last line.
Path filtering
Section titled “Path filtering”Always ignored (no re-trigger):
node_modules/,.git/,.vx/cache/anywhere in the path.- A path git ignores (
git check-ignore, asked once per debounce window, never per event), unless a task’s inputs name it outright (.env.local,dist/**): invisible to every other cache key — inputs are tracked + untracked-not-ignored — so a cycle it started could change nothing. A tracked file that matches a pattern is not ignored, by git’s own rule; outside a repository nothing is. - Files ending in
~(editor swap files), and.vx/tmp/, where restores stage. - The run’s resolved cache directory, wherever it is.
.vx/cache/covers the default, butdefineWorkspace({ cache: { dir } })and--cache-dircan put it anywhere; watching it would let vx’s own cache writes trigger the next cycle, which writes again — a loop that never settles.
Everything else triggers a cycle. We deliberately don’t filter events
against per-task inputs.files — the cache hash is the source
of truth. A change to an irrelevant file produces a cache-hit run
(typically tens of ms); the cost is much smaller than the engineering
cost of a per-event glob match.
How changes are seen
Section titled “How changes are seen”One recursive OS watcher (fs.watch) per project directory in scope,
plus the workspace root for the fingerprint files. Each watcher must
prove it delivers: a directory whose watcher reports nothing within
2 s of the probe is polled every 250 ms instead, with a one-line
notice on stderr (no OS watch events within 2000 ms; polling every 250 ms instead). Where the watcher is known not to deliver — a sandbox
without FSEvents access on macOS, a network mount, a container bind —
VX_WATCH_POLL=1 polls from the start and skips the 2 s probe; the loop
says so once (vx watch: polling every 250 ms (VX_WATCH_POLL)). A
watcher the OS refuses for its watch limit (ENOSPC, EMFILE) is
polled too, with a notice naming the limit to raise. The
poller walks the same tree the event filter keeps: the always-ignored
segments and the run’s declared output containers are never sampled.
It samples each file’s later clock of mtime and ctime, so a replacement
that carries the old file’s mtime (cp -p, rsync -a, a mv) is an
edit to it as it is to the OS watcher.
Claimed root file changes
Section titled “Claimed root file changes”A root file a plugin claims — a lockfile or install setting under
@vzn/vx-lockfile (pnpm-lock.yaml, bun.lock, .npmrc, …),
turbo.json under turbo() (item 961) — triggers a cycle, and so does
.vx/projects.lock.json. Watch mode hears them because it watches the
workspace root (non-recursively); a plugin added to the workspace
config mid-watch counts from the cycle that loads it. The keys then
decide which projects actually re-run: core keys no package file, so a
lockfile edit re-keys only through its claimant
(cache key derivation). Every
cycle’s configs come from .vx/projects.lock.json, so a config edit
alone changes no command, and a re-lock (vx lock) is a cycle that
re-reads the watched set (item 971).
Constraints
Section titled “Constraints”The following flags are rejected (parser exits 1 before the initial run):
--dry— it skips execution; nothing to watch.--profile— would overwrite its target per cycle.--verbosity <n>(n > 0) — formats ONE run’s result; a watch loop has no single run to report. (--verbosity 0is accepted: it asks for what watch already prints.)
A requested persistent task (exec.persistent, a dev server) starts
once and stays up across cycles; each cycle re-runs what it depends on,
as the cache keys decide. So vx watch dev, with dev depending on
^build, rebuilds only the libraries an edit reaches on the way to the
app and leaves the server running; the dev tool’s own reload picks up
what was rebuilt. What the server writes keeps printing. It restarts
only when its own task config (or the args forwarded to it) changed, or
when it died: the old one is stopped first (the kill grace, then
SIGKILL), so the two never hold one port. One that dies while watch
idles is said (vx: app#dev exited with code 3); the next change
starts it again. A cycle in which a task the server depends on fails
stops it. An edit while a cycle waits only on a server that has not
printed its readyWhen line stops that cycle and starts the next, so a
fix reaches a server stuck before ready, in the initial run too.
Stopping watch stops the server too. A one-shot task (vx watch build)
re-runs itself and what it depends on.
Exit codes
Section titled “Exit codes”0— clean Ctrl+C / SIGTERM exit. A cycle in flight is torn down first — its children, and the dev server held between cycles, get the signal watch received (a Ctrl-C as SIGINT; a SIGTERM or SIGHUP as SIGTERM), a 2 s grace, then SIGKILL — and the process leaves only once it has returned, so a CI cancellation never orphans a task. Also0, with no watch, when--affectedselects nothing: watch printsvx watch: nothing affected since <ref>and exits asvx rundoes.1— parser error, missing scope, or a task name no project in scope declares: the initial run refuses it asvx rundoes, with the sameDid you meanhint, and watch exits rather than re-run the refusal on every change.
A cycle that throws — a vx.config.* that does not parse — prints
vx watch: cycle failed: <reason> and watch keeps watching, the initial
run included, so saving the fix re-runs (item 1017).
Re-run cycles whose orchestrator returns { ok: false } do NOT exit
the watch loop — a failed cycle just prints the framed FAILED block
and waits for the next change. This matches turbo watch / nx watch.