src/cli/watch.ts — vx watch subcommand (and watch-fs.ts, watch-filter.ts, watch-set.ts, watch-judge.ts)
Purpose
Section titled “Purpose”Run a task once, then re-run it on every filesystem change in the
projects in scope. The initial run goes through the same orchestrator
path as vx run; the watch loop just keeps calling it on debounced
filesystem events.
Public surface
Section titled “Public surface”export async function watchCmd(args: readonly string[]): Promise<number>
// The loop's parts, exported for the watch suites:export function watchRefusal(parsed: RunArgs): string | null // the refusal line for a flag watch cannot honourexport function pendingAfterCycle( pending: ReadonlyMap<string, string>, aborted: boolean,): [abs: string, label: string] | undefined
// watch-set.ts — what is watched: the projects a cycle can run, what their// configs declare, the member dirs a package glob can grow:export async function watchedProjects( workspaceRoot, allProjects, scope, load?, staged?,): Promise<ProjectMeta[]>export interface ConfigSweep { workspaceWide: boolean workspaceInputs: string[] outputs: Map<string, string[]> inputs: Map<string, string[]> uncached: Set<string> configImports: string[] workspaceConfigImports: string[] staged: Map<string, ProjectEntry> | null}export async function sweepConfigs(projects, workspaceRoot, load?): Promise<ConfigSweep>export function memberEntries(base: string): ReadonlySet<string>export function sameMembers(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean
// watch-judge.ts — which settled paths are changes (state gate, self-write// window, the 3-cycle notice):export interface JudgeContext { workspaceRoot: string armedAt: number held(): boolean uncached(): ReadonlySet<string>}export class ChangeJudge { readonly pending: Map<string, string> // path → label, what fired since the last judgement lastCycle: { start: number; end: number } | undefined constructor(ctx: JudgeContext) judge(): string | undefined // the first changed path's label, or none}
// watch-filter.ts — which events matter, decided over paths alone:export function isIgnoredWatchPath(rel: string): boolean // node_modules / .git / .vx segments, .tsbuildinfo / ~ suffixesexport function makeWatchIgnore( cacheDir, outputs?, inputs?,): (base: string, filename: string) => boolean // the above plus the cache dir and every declared output no task readsexport function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set<string> // one `git check-ignore --stdin`export function makeRootEventFilter( workspaceRoot: string, projectDirs: readonly string[], workspaceInputs: readonly string[],): (filename: string) => booleanexport function shapesWatchedSet(filename: string): boolean // a manifest, a config or a fingerprint file: re-read the watched setexport function isWorkspaceFingerprintFile(name: string): booleanexport function isWorkspaceConfigFile(name: string): boolean
// watch-fs.ts — the file-system side, which knows nothing of tasks or cycles:export const IGNORED_SEGMENTS: string[] // node_modules / .git / .vxexport const WATCH_PROBE = '.vx-watch-probe'export const WATCH_PROBE_TIMEOUT_MS = 2_000export interface WatchHandle { close(): void}export interface ArmedWatcher { watcher: fs.FSWatcher ready: Promise<boolean> // true once the watcher reported the probe, false on timeout}export function pollWatcher( dir: string, recursive: boolean, onEvent: (filename: string) => void, intervalMs?: number, skipDir?: (rel: string) => boolean,): WatchHandleexport function armWatcher( dir: string, recursive: boolean, onEvent: (filename: string) => void, timeoutMs?: number,): ArmedWatcherexport function modifiedBefore(abs: string, t: number): booleanexport function fsClockNow(dir: string): numberexport const CLOSED: WatchHandle // watches nothing: a dropped slot, an arm not madeexport class WatcherPool { constructor(skip: (dir: string, rel: string) => boolean) // what the poller leaves unsampled arm(dir: string, recursive: boolean, onEvent: (filename: string) => void): WatchHandle // OS watcher, poller on no proof, an OS watch limit or VX_WATCH_POLL proved(): Promise<void> // every arm so far proved delivery or fell back closeAll(): void}cli/index.ts dispatches vx watch <...> here. Returns the exit code
(0 on clean Ctrl+C; 1 on parser / scope error). armWatcher proves
delivery before the loop trusts a watcher (a probe file the watcher
must report within the timeout); pollWatcher is the fallback that
re-walks the tree when the platform’s watcher never does, or when the
OS watch limit refuses one (ENOSPC / EMFILE, E-49).
Flag surface
Section titled “Flag surface”watchCmd reuses cli/run.ts:parseRunArgs so every vx run flag
that makes sense for a loop is supported. Rejected with exit 1:
| Flag | Reason |
|---|---|
--dry / --graph | They skip execution; nothing to watch. |
--summarize / --profile | Would overwrite their target file every cycle. |
| (no task name) | Watch needs an explicit task — no picker. |
Everything else (--all, --filter, --affected, --concurrency,
--no-cache, --exclude-dependencies, forwarded -- args) passes
through unchanged. --report / --report-file / --verbosity above 0
are refused too: they format one run’s result.
Algorithm
Section titled “Algorithm”parseRunArgs+ validate the watch-mode rejections above.cli/run.ts:resolveRunOptions(parsed, cwd, tasks)→RunOptions. Same scope resolution asvx run.- Enumerate projects in the resolved scope via
listProjects. Empty scope → exit 1. - Initial run. Print
vx watch: initial run...; callorchestrator.run(opts). One that ran nothing and failed (a task no project declares) exits 1. - Watch loop (
runWatchLoop):- For each project a cycle can run (
watchedProjects: the scope plus its transitive dependencies throughbuildPackageGraphwith the cross-projectdependsOnedgestaskEdgescollects — whatvx runwould run for the same filter),fs.watch(dir, { recursive: true }). Bun supports recursive watch on every platform. - For the workspace root,
fs.watch(root, { recursive: false })— only fingerprint files (pnpm-lock.yaml/bun.lock/ …) and the workspace config (vx.workspace.*,WORKSPACE_CONFIG_FILENAMES) trigger: the config is no task’s input, and it shapes every cycle (plugins,configstage, concurrency); a cycle re-evaluates it since its import is keyed on its bytes. When any task declaresinputs.workspaceFiles, ONEfs.watch(root, { recursive: true })replaces all of the above, andmakeRootEventFilterkeeps the events a key can see — a path inside any project’s directory, a fingerprint file or the workspace config at the root, a match of a declaredworkspaceFilesglob (negations not consulted: a!only narrows, and a spurious event is one cache-hit cycle) — and drops the rest of the tree, so a log written at the root or acoverage/run is not a cycle. - For the directory each
<dir>/*package glob names (memberBaseDirs),fs.watch(base, { recursive: false }): a member coming or going there is a cycle, and the cycle’s end re-reads the workspace (rediscover: discovery, the sweep, the watched closure) andrearms — new project dirs get an arm that proves delivery before the loop goes on, dropped ones are closed, the root filter and the ignore filter are rebuilt on the new set. Until 2026-09-10 the set was fixed when the loop armed: the next cycle ran the new package and every edit inside it was silence (tests/watch-loop-members.test.ts, the added-package pair). The scope is the one resolved at start; a glob of another shape has no such directory. - The same re-read follows a cycle started by a file that shapes
the watched set (
shapesWatchedSet): apackage.json(a dependency added under--filterwidens the closure), a project config (a task that starts or stops declaringworkspaceFilesswaps the arm between per-project and root,dropMode/armMode) or the workspace config. And a directory under a member base with no package in it yet gets a non-recursive arm of its own (armPending): the base’s watcher never hears thepackage.jsonwritten inside it, so a directory made before its manifest stayed unwatched for good. Until item 891 each of these waited for a restart (the three item-891 rows). - Filter out
node_modules/.git/.vxpath segments,.tsbuildinfo/~suffixes (editor swap files), the RESOLVED cache directory (a relocatedcacheDirwould otherwise re-trigger every cycle), and each project’s declared outputs (cache.outputs.files, root-relativeworkspaceFiles) — a cycle that writesdist/is not an edit, and neither isdistitself; a path some task declares as an input is never dropped, whoever declares it as an output (item 946) — the directory holding an output tree,outputContainer, which the clean before a miss prunes and the task re-creates; a literal entry is its whole tree, as in the schema (makeWatchIgnore, pinned intests/watch-rules.test.ts; end to end intests/watch-loop.test.ts). The outputs come from the run path’s staged load (sweepConfigs→loadProjects), so an output aprojectplugin gave a config-less package is ignored like a declared one, and a pure config is served from its cached evaluation; a config that fails to load drops the sweep to the files that do load. The sweep’s load is also whatwatchedProjectsreads the cross edges from, so a watch start is the initial run’s scoped load plus one sweep, not a third load; and the options every cycle re-runs carry nostagedmap — a cycle after an edit evaluates live (tests/staged-once.test.ts). - Catch UNDECLARED writes by settled state — a file’s bytes, a
directory’s entry names and sizes, absence — judged one debounce
window after events stop, and never while a cycle runs (its own
writes are mid-flight; what landed is judged together once it
ends, under the label of what arrived). Before 2026-09-10 a
deletion and a directory passed unconditionally and a mid-run
judgement saw a half-rebuilt
dist:rm -rf dist && tscwith no outputs declared looped forever (tests/watch-loop-uncached.test.ts, the delete-and-recreate pair). The prior text: - Catch UNDECLARED writes by content: a task with no
cacheblock declares no outputs and still writes into its project, and its own write re-triggered the cycle without end (the init walkthrough, 2026-09-04). When the debounce timer fires, every path that fired in the window is hashed on its SETTLED bytes and the cycle is skipped if none differ from what the loop last hashed; a real edit, a deletion or a first sighting modified after the arm passes (modifiedBefore: the initial run’s own writes arrive after the arm on macOS, and their mtime and ctime both predate it; the ctime is what catches a file moved in with an old mtime, item 945) — so a self-write costs one redundant cycle, not an unbounded number. Debounce time, not event time: on Linux a shell redirect truncates the file (one event, empty) and then writes it (another, full), so consecutive events never agree (CI read 9 re-runs where macOS, which coalesces the two, read 2). Pinned end to end intests/cli.test.ts. - Debounce events
~150msafter the last one before triggering a cycle. - Reentrancy guard: while a cycle is running, further events set
a
pendingflag; the loop drains it after the current cycle finishes. Two events can collapse into one re-run.
- For each project a cycle can run (
- Exit.
watchCmdinstallsprocess.oncehandlers for SIGINT, SIGTERM and SIGHUP BEFORE the initial run; each aborts oneAbortController, with the signal’s name as the reason, whose signal every cycle’srun()carries (RunOptions.signal,handleSignals: false). The in-flight cycle tears its children down (the received signal, a SIGHUP as SIGTERM;VX_KILL_GRACE_MS; SIGKILL) and returns; the loop closes its watchers, waits for that cycle, stops the persistent tasks it holds with the same signal, and resolves 0. SIGINT also printsvx watch: stopped. Until 2026-09-10 the handlers went in with the loop, so a SIGTERM during the initial run took Bun’s default (exit 143) and orphaned the cycle’s child (tests/watch-signals.test.ts).
Why not filter by cache.inputs.files
Section titled “Why not filter by cache.inputs.files”We could pre-compute the union of every task’s input globs in the resolved graph and reject events outside it. We don’t, for two reasons:
- The cache key is the source of truth. A spurious cycle is a cache-hit re-run (~tens of ms). Pre-filtering would mean redoing the glob + project-boundary work on every event — easily worse than the cache lookup.
- Globs change with
vx.config.tsedits. Pre-computing would miss config changes that re-shape what’s watched. The current “watch the whole project dir” approach is robust.
Persistent tasks across cycles
Section titled “Persistent tasks across cycles”Watch mode re-invokes orchestrator.run per cycle with
holdPersistent: true, so the requested persistent tasks a cycle
started are handed back running (RunSummary.persistent) instead of
being stopped when its graph ends. The loop holds them while it idles;
the next cycle calls their stop() before its run, and the stop path
calls it after the in-flight cycle returns. A dependency-only
persistent task is still stopped at the end of its cycle, as under
vx run. So a persistent dev server is up between cycles and
re-spawned by each one. Until 2026-09-24 the server was stopped at the
END of each cycle and was dead whenever watch sat idle
(tests/watch-loop.test.ts › “the dev server stays up while watch
idles and is replaced when the next cycle starts”).
For dev-server workflows, use the dev tool’s own watch (vite,
tsc -b -w, bun --watch) rather than vx watch. vx watch is
for vx watch test / vx watch lint / vx watch build —
non-persistent tasks where each cycle should re-run cleanly.
What this does NOT do
Section titled “What this does NOT do”-
Start a cycle on a path git ignores:
gitIgnoredasksgit check-ignore --stdinonce per judgement (never per event), and a path no cache key can see starts nothing — the pid file or log a dev server rewrites on every start made the loop re-run itself forever (item 237). A tracked file matching a pattern is not ignored, by git’s rule; outside a repository nothing is. -
Settle a file the task rewrites with DIFFERENT bytes every run when it is neither ignored nor declared: the loop re-runs on it, and after three cycles in a row started by the same path after a run, watch names it and the remedy once (
watch-loop-selfwrite.test.ts). -
Doesn’t accept the interactive picker — task name is required.
-
Doesn’t filter events through declared input globs.
-
Doesn’t dedupe events by project — every file change triggers a re-run of the user’s specified task across the entire scope.
-
Doesn’t re-read the package globs: a
pnpm-workspace.yamledit that adds a new base directory is a cycle, but the base is watched only from the next start. -
Doesn’t carry a persistent task through a cycle: it stays up while watch idles, and the next cycle stops and re-spawns it.
-
Re-key a cycle when a task rewrites a lockfile during it: the keys are taken once per cycle. The run itself notices (item 750,
fingerprint-watch.md): nothing keyed before the rewrite is restored or saved, and the rewrite is an event the next cycle re-keys on. A rewrite to the same bytes starts that one cycle and settles (probed 2026-09-25, item 763).
tests/cli.test.ts:
vx watchwith no task → exits 1.--dry/--graph/--summarize/--profilerejected.- Parser errors prefixed with
vx watch:. - End-to-end re-run: fixture workspace with one task that
cats a source file; assertion writes the file mid-watch and checks the new content appears in stdout; SIGINT exits cleanly.
The loop’s own suites: tests/watch-rules.test.ts (the ignore rules
and the root event filter), tests/watch-loop.test.ts (cycles end to
end), tests/watch-loop-members.test.ts (a package coming or going),
tests/watch-loop-uncached.test.ts (undeclared writes judged by
settled state), tests/watch-loop-selfwrite.test.ts (a file rewritten
with different bytes every run), tests/watch-signals.test.ts (SIGINT
and SIGTERM during the initial run and a cycle; a Ctrl-C reaches the
cycle’s task and the held dev server as SIGINT), and
tests/staged-once.test.ts (a cycle evaluates live).
Replacing this module
Section titled “Replacing this module”Plausible extensions, all contained:
- Picker support — borrow the
pickTaskflow fromcli/select.tsfor TTY-with-no-task. - Per-project debouncing — track which project’s events arrived
in the current debounce window and only re-run tasks in those
projects (
opts.projects = [...affected]). Useful for very large workspaces. - Persistent-task hand-off — track persistent children across
cycles so a dev server doesn’t restart on every file change.
Schema-extending change; cooperate with
execute-task.ts.