@vzn/vx 0.0.636 → 0.0.637

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +213 -207
  3. package/docs/cli.md +699 -655
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +376 -198
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +2 -2
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +16 -12
  19. package/src/bin.ts +17 -2
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +105 -53
  22. package/src/cache/git-inputs.ts +277 -63
  23. package/src/cache/index.ts +7 -1
  24. package/src/cache/inputs.ts +125 -35
  25. package/src/cache/key-fold.ts +8 -7
  26. package/src/cache/layer.ts +8 -17
  27. package/src/cache/layered-cache.ts +16 -102
  28. package/src/cache/policy.ts +25 -29
  29. package/src/cache/run-history.ts +36 -14
  30. package/src/cache/schema.ts +21 -23
  31. package/src/cache/scoped-cache.ts +224 -0
  32. package/src/cli/cache.ts +150 -28
  33. package/src/cli/completions.ts +2 -2
  34. package/src/cli/core-alias.ts +1 -1
  35. package/src/cli/foreign-flags.ts +13 -12
  36. package/src/cli/help.ts +94 -63
  37. package/src/cli/index.ts +42 -13
  38. package/src/cli/info.ts +38 -23
  39. package/src/cli/init.ts +115 -406
  40. package/src/cli/last.ts +145 -4
  41. package/src/cli/lock.ts +333 -111
  42. package/src/cli/plan-format.ts +2 -2
  43. package/src/cli/plugin-commands.ts +5 -9
  44. package/src/cli/plugin-templates.ts +10 -10
  45. package/src/cli/run.ts +103 -149
  46. package/src/cli/select.ts +33 -39
  47. package/src/cli/show.ts +17 -19
  48. package/src/cli/task-verb.ts +2 -9
  49. package/src/cli/upgrade.ts +402 -145
  50. package/src/cli/watch-cycle.ts +9 -2
  51. package/src/cli/watch-filter.ts +33 -60
  52. package/src/cli/watch-fs.ts +29 -16
  53. package/src/cli/watch-judge.ts +12 -3
  54. package/src/cli/watch-set.ts +16 -42
  55. package/src/cli/watch.ts +188 -215
  56. package/src/cli/why.ts +28 -17
  57. package/src/cli/workspace-config.ts +31 -68
  58. package/src/config.ts +169 -70
  59. package/src/exec/env.ts +1 -31
  60. package/src/exec/executor.ts +1 -5
  61. package/src/exec/kill-tree.ts +2 -1
  62. package/src/exec/sandbox-runtime.ts +12 -1
  63. package/src/exec/sandbox-violations.ts +9 -1
  64. package/src/graph/dependency-spec.ts +1 -1
  65. package/src/graph/index.ts +1 -0
  66. package/src/graph/priorities.ts +1 -1
  67. package/src/graph/scheduler.ts +19 -7
  68. package/src/graph/task-graph.ts +44 -20
  69. package/src/handoff.ts +169 -0
  70. package/src/index.ts +24 -57
  71. package/src/orchestrator/affected-tasks.ts +7 -5
  72. package/src/orchestrator/command-windows.ts +44 -0
  73. package/src/orchestrator/deferred-outputs.ts +12 -10
  74. package/src/orchestrator/doctor.ts +98 -110
  75. package/src/orchestrator/download-policy.ts +3 -3
  76. package/src/orchestrator/events.ts +19 -3
  77. package/src/orchestrator/excluded-keys.ts +0 -2
  78. package/src/orchestrator/execute-task.ts +158 -63
  79. package/src/orchestrator/fingerprint-watch.ts +18 -15
  80. package/src/orchestrator/forecast.ts +10 -2
  81. package/src/orchestrator/framed-output.ts +8 -3
  82. package/src/orchestrator/graph-stage.ts +5 -5
  83. package/src/orchestrator/history.ts +70 -30
  84. package/src/orchestrator/hit-restore.ts +15 -3
  85. package/src/orchestrator/index.ts +18 -4
  86. package/src/orchestrator/local-shortcircuit.ts +2 -1
  87. package/src/orchestrator/lockfile-claim.ts +79 -20
  88. package/src/orchestrator/logger.ts +71 -39
  89. package/src/orchestrator/metrics.ts +71 -6
  90. package/src/orchestrator/miss-save.ts +3 -0
  91. package/src/orchestrator/options.ts +42 -29
  92. package/src/orchestrator/output-host.ts +72 -0
  93. package/src/orchestrator/plan.ts +6 -8
  94. package/src/orchestrator/plugin-host.ts +87 -63
  95. package/src/orchestrator/plugin.ts +94 -60
  96. package/src/orchestrator/prepare.ts +113 -72
  97. package/src/orchestrator/projects.ts +102 -99
  98. package/src/orchestrator/remote-prefetch.ts +2 -1
  99. package/src/orchestrator/run-context.ts +25 -6
  100. package/src/orchestrator/run-failures.ts +17 -8
  101. package/src/orchestrator/run-lock.ts +1 -1
  102. package/src/orchestrator/run-report.ts +9 -9
  103. package/src/orchestrator/run-settings.ts +48 -0
  104. package/src/orchestrator/run.ts +121 -95
  105. package/src/orchestrator/sandbox-fix.ts +39 -20
  106. package/src/orchestrator/sandbox-request.ts +84 -13
  107. package/src/orchestrator/signals.ts +1 -1
  108. package/src/orchestrator/stable-keys.ts +13 -11
  109. package/src/orchestrator/status-line.ts +48 -6
  110. package/src/orchestrator/summary.ts +46 -62
  111. package/src/orchestrator/task-hash.ts +12 -83
  112. package/src/orchestrator/telemetry.ts +3 -3
  113. package/src/settings.ts +417 -0
  114. package/src/util/errors.ts +1 -1
  115. package/src/util/essential-env.ts +31 -0
  116. package/src/util/hide-env.ts +107 -0
  117. package/src/util/index.ts +8 -2
  118. package/src/util/paths.ts +10 -0
  119. package/src/util/settle.ts +49 -30
  120. package/src/util/size.ts +23 -4
  121. package/src/util/timing.ts +3 -2
  122. package/src/util/ulid.ts +1 -1
  123. package/src/util/verbs.ts +0 -15
  124. package/src/workspace/affected.ts +77 -222
  125. package/src/workspace/config-eval.ts +15 -20
  126. package/src/workspace/config-imports.ts +78 -3
  127. package/src/workspace/config-schema.ts +209 -268
  128. package/src/workspace/dependency-rules.ts +57 -0
  129. package/src/workspace/filter.ts +2 -2
  130. package/src/workspace/fingerprint.ts +11 -121
  131. package/src/workspace/index.ts +20 -27
  132. package/src/workspace/load-reads.ts +4 -4
  133. package/src/workspace/lockfile.ts +96 -140
  134. package/src/workspace/migration.ts +242 -146
  135. package/src/workspace/package-graph.ts +30 -216
  136. package/src/workspace/project-loader.ts +80 -285
  137. package/src/workspace/repo-id.ts +2 -1
  138. package/src/workspace/workspace.ts +362 -831
  139. package/src/cache/config-evals.ts +0 -130
  140. package/src/workspace/config-cache.ts +0 -661
  141. package/src/workspace/migrate-scripts.ts +0 -1121
package/src/cli/watch.ts CHANGED
@@ -24,20 +24,12 @@ import {
24
24
  type RunOptions,
25
25
  shellQuote,
26
26
  } from '../orchestrator/index.js'
27
- import {
28
- findWorkspaceRoot,
29
- type LoadReads,
30
- loadWorkspace,
31
- LOCKFILE_NAME,
32
- memberBaseDirs,
33
- type ProjectMeta,
34
- } from '../workspace/index.js'
27
+ import { findWorkspaceRoot, LOCKFILE_NAME, NO_LOCK, type ProjectMeta } from '../workspace/index.js'
35
28
  import { type CliLoadOptions, discoverCliProjects, loadCliWorkspace } from './workspace-config.js'
29
+ import { resolveSetting } from '../settings.js'
36
30
  import {
37
- isIgnoredWatchPath,
38
31
  isWorkspaceConfigFile,
39
32
  gitFiles,
40
- isWorkspaceFingerprintFile,
41
33
  makeFence,
42
34
  makeRootEventFilter,
43
35
  makeWatchIgnore,
@@ -47,7 +39,7 @@ import { CLOSED, fsClockNow, type WatchHandle, WatcherPool } from './watch-fs.js
47
39
  import { ChangeJudge } from './watch-judge.js'
48
40
  import { type WatchCycle, watchCycle } from './watch-cycle.js'
49
41
  import { hangupIgnored, maskedLine, restartTimings } from '../util/index.js'
50
- import { memberEntries, sameMembers, sweepConfigs, watchedProjects } from './watch-set.js'
42
+ import { sweepConfigs, watchedProjects } from './watch-set.js'
51
43
 
52
44
  /** One line for a watcher or re-read the OS refused; the loop goes on without it. */
53
45
  function sayCannot(what: string, err: unknown): void {
@@ -165,13 +157,18 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
165
157
  // closure `--filter 'app...'` walks, computed below once the initial
166
158
  // run has staged the configs. Plus the workspace root, for lockfile
167
159
  // changes.
168
- const reads: LoadReads = new Map()
169
- const workspaceRoot = await findWorkspaceRoot(cwd, reads)
170
- const workspace = await loadWorkspace(workspaceRoot, reads)
171
- const allProjects = await discoverCliProjects(workspace)
172
- // `//#check` is the root project's task, named by its package.json name
173
- // as the run names it (prepare.ts, D-39): taken literally, no project
174
- // was in scope and the watch exited 1 after a green run.
160
+ const workspaceRoot = await findWorkspaceRoot(cwd)
161
+ // No lock yet is no project yet: the first cycle fails saying so, and
162
+ // the lock `vx lock` writes is the event that arms the set.
163
+ let unlocked = false
164
+ const allProjects = await discoverCliProjects(workspaceRoot).catch((err: unknown) => {
165
+ if (!(err instanceof Error) || err.message !== NO_LOCK) throw err
166
+ unlocked = true
167
+ return []
168
+ })
169
+ // `//#check` is the root project's task, under the name the run gives
170
+ // it (prepare.ts, D-39): taken literally, no project was in scope and
171
+ // the watch exited 1 after a green run.
175
172
  const rootDir = path.resolve(workspaceRoot)
176
173
  const rootName = allProjects.find((p) => path.resolve(p.dir) === rootDir)?.name
177
174
  const anchored = opts.tasks
@@ -202,16 +199,16 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
202
199
  // config is loaded, and the loop arms with the run as its cycle in
203
200
  // flight. Unarmed, a server that never got ready held the initial run
204
201
  // for good, and the fix to it was never heard (X-139).
205
- process.stdout.write('vx watch: initial run...\n\n')
206
202
  let loop: Promise<void> | undefined
207
203
  const initial: InitialCycle = {
208
204
  cycle: watchCycle(opts, stop.signal, () => {
209
205
  if (initial.onWaiting !== undefined) initial.onWaiting()
210
- else if (loop === undefined && scope.length > 0 && !stop.signal.aborted)
206
+ else if (loop === undefined && (scope.length > 0 || unlocked) && !stop.signal.aborted)
211
207
  loop = armLoop(initial)
212
208
  }),
213
209
  held: Promise.resolve(undefined),
214
210
  }
211
+ const openingTasks = (): number | undefined => initial.cycle.tasks
215
212
  // A run that throws (a config that does not parse) is a failed cycle, as
216
213
  // it is once the loop runs: watch keeps watching, so the fix re-runs.
217
214
  // Uncaught, it ended watch at start while the same break mid-watch did
@@ -248,7 +245,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
248
245
  if (refusedToStart) return 1
249
246
  // After the initial run, so a `pkg#task` naming no project gets the
250
247
  // run's own refusal and its "did you mean".
251
- if (scope.length === 0) {
248
+ if (scope.length === 0 && !unlocked) {
252
249
  await held?.stop()
253
250
  process.stderr.write(`vx watch: no projects in scope\n`)
254
251
  return 1
@@ -263,22 +260,17 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
263
260
  delete opts.affected
264
261
  delete opts.selectedOutright
265
262
 
266
- const load: CliLoadOptions = {
267
- ...(opts.cacheDir !== undefined ? { cacheDir: opts.cacheDir } : {}),
268
- ...(opts.frozen === true ? { frozen: true } : {}),
269
- }
263
+ const load: CliLoadOptions = opts.cacheDir !== undefined ? { cacheDir: opts.cacheDir } : {}
270
264
  const swept = await sweepConfigs(allProjects, workspaceRoot, load, opts.tasks)
271
265
  const watched = await watchedProjects(workspaceRoot, allProjects, scope, load, swept.staged)
272
266
  const ws = await loadCliWorkspace(workspaceRoot)
273
267
  const claimsOf = (plugins: Parameters<typeof fingerprintClaims>[0]): Set<string> =>
274
- new Set([
275
- ...fingerprintClaims(plugins).keys(),
276
- ...(opts.frozen === true ? [LOCKFILE_NAME] : []),
277
- ])
268
+ new Set([...fingerprintClaims(plugins).keys(), LOCKFILE_NAME])
278
269
  await runWatchLoop({
279
270
  opts,
280
271
  held,
281
272
  initial,
273
+ tasks: openingTasks,
282
274
  stop: stop.signal,
283
275
  workspaceRoot,
284
276
  projects: watched,
@@ -290,16 +282,13 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
290
282
  uncached: swept.uncached,
291
283
  configImports: swept.configImports,
292
284
  workspaceConfigImports: swept.workspaceConfigImports,
293
- memberBases: memberBaseDirs(workspace),
294
- packageDirs: new Set(allProjects.map((p) => p.dir)),
295
285
  fenceDirs: fenceDirs(allProjects),
296
- // The workspace as the cycle that just ran saw it: a package added or
297
- // removed since the loop armed joins or leaves the watched set. The
298
- // scope is the one resolved at start; a new package joins it only as a
299
- // dependency of it.
286
+ // The workspace as the cycle that just ran saw it: a project the lock
287
+ // gained or lost since the loop armed joins or leaves the watched
288
+ // set. The scope is the one resolved at start; a new project joins
289
+ // it only as a dependency of it.
300
290
  rediscover: async () => {
301
- const workspace = await loadWorkspace(workspaceRoot)
302
- const all = await discoverCliProjects(workspace)
291
+ const all = await discoverCliProjects(workspaceRoot)
303
292
  const sweep = await sweepConfigs(all, workspaceRoot, load, opts.tasks)
304
293
  const now = await watchedProjects(workspaceRoot, all, inScope(all), load, sweep.staged)
305
294
  return {
@@ -308,7 +297,6 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
308
297
  // the cycle that loaded it; read once, its edits started nothing
309
298
  // until a restart.
310
299
  claimedRootFiles: claimsOf((await loadCliWorkspace(workspaceRoot)).plugins),
311
- memberBases: memberBaseDirs(workspace),
312
300
  workspaceWide: sweep.workspaceWide,
313
301
  workspaceInputs: sweep.workspaceInputs,
314
302
  outputs: sweep.outputs,
@@ -316,16 +304,16 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
316
304
  uncached: sweep.uncached,
317
305
  configImports: sweep.configImports,
318
306
  workspaceConfigImports: sweep.workspaceConfigImports,
319
- packageDirs: new Set(all.map((p) => p.dir)),
320
307
  fenceDirs: fenceDirs(all),
321
308
  }
322
309
  },
323
- // Under --frozen every cycle's configs are the lock's, so a re-lock is
310
+ // Every cycle's configs are the lock's, so a re-lock is
324
311
  // the one edit that changes what a cycle runs; unheard, the loop ran
325
312
  // the old lock until a restart (item 971).
326
313
  claimedRootFiles: claimsOf(ws.plugins),
327
314
  // The RESOLVED cache dir, not the `.vx` literal — see `makeWatchIgnore`.
328
315
  cacheDir: opts.cacheDir ?? ws.cacheDir,
316
+ poll: resolveSetting('watch.poll', ws.workspaceConfig, parsed.poll).value === true,
329
317
  })
330
318
  }
331
319
  }
@@ -345,6 +333,8 @@ interface WatchLoopArgs {
345
333
  held: HeldPersistent | undefined
346
334
  /** The initial run, still in flight: the loop's first cycle. */
347
335
  initial: InitialCycle | undefined
336
+ /** The initial run's task count, once it has started. */
337
+ tasks: () => number | undefined
348
338
  /** Aborted by the SIGINT/SIGTERM handlers `watchCmd` installed; the loop drains its cycle and resolves. */
349
339
  stop: AbortSignal
350
340
  workspaceRoot: string
@@ -372,14 +362,12 @@ interface WatchLoopArgs {
372
362
  * loaded once per process, so the set is fixed for the loop.
373
363
  */
374
364
  claimedRootFiles: ReadonlySet<string>
375
- /** The directory each `<dir>/*` package glob names at start; a member coming or going there is a cycle. */
376
- memberBases: readonly string[]
377
- /** Every package's directory, in scope or not: a member base's other entries are packages still to come. */
378
- packageDirs: ReadonlySet<string>
379
365
  /** Every project, in scope or not: a project's key leaves out what lies in one nested under it. */
380
366
  fenceDirs: readonly string[]
381
367
  /** The watched set again, after a cycle that followed an event which can change it. */
382
368
  rediscover: () => Promise<Rediscovered>
369
+ /** `watch.poll`: poll from the start instead of the OS watcher. */
370
+ poll: boolean
383
371
  }
384
372
 
385
373
  const fenceDirs = (all: readonly ProjectMeta[]): string[] => all.map((p) => p.dir)
@@ -387,7 +375,6 @@ const fenceDirs = (all: readonly ProjectMeta[]): string[] => all.map((p) => p.di
387
375
  interface Rediscovered {
388
376
  projects: readonly ProjectMeta[]
389
377
  claimedRootFiles: ReadonlySet<string>
390
- memberBases: readonly string[]
391
378
  workspaceWide: boolean
392
379
  workspaceInputs: readonly string[]
393
380
  outputs: ReadonlyMap<string, readonly string[]>
@@ -395,7 +382,6 @@ interface Rediscovered {
395
382
  uncached: ReadonlySet<string>
396
383
  configImports: readonly string[]
397
384
  workspaceConfigImports: readonly string[]
398
- packageDirs: ReadonlySet<string>
399
385
  fenceDirs: readonly string[]
400
386
  }
401
387
 
@@ -404,8 +390,6 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
404
390
  // The watched set as of the last cycle: `rearm` replaces these after an
405
391
  // event that can change it, and every filter below reads the current one.
406
392
  let workspaceWide = args.workspaceWide
407
- let packageDirs = args.packageDirs
408
- let memberBases = args.memberBases
409
393
  let projectDirs = args.projectDirs
410
394
  let workspaceInputs = args.workspaceInputs
411
395
  let outputs = args.outputs
@@ -485,7 +469,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
485
469
  // only after, it fell in the gap and ran nothing (X-138). The re-read
486
470
  // after the run stays, for what the run itself changed.
487
471
  if (reread) await rearm(false)
488
- process.stdout.write(`\nvx watch: ${label}; re-running...\n\n`)
472
+ process.stdout.write(`\nchanged ${label}\n`)
489
473
  if (stop.aborted) break
490
474
  restartTimings()
491
475
  // On the mtime clock, as the arm is: from `Date.now()` a write the
@@ -502,6 +486,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
502
486
  interruptIfWaiting,
503
487
  )
504
488
  const cycle = await runOrchestrator(current.opts)
489
+ lastTasks = current.tasks ?? lastTasks
505
490
  current = undefined
506
491
  held = cycle.persistent
507
492
  // What this cycle did not take (a server whose task did not run).
@@ -553,7 +538,9 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
553
538
  // project's dir recursively, so a `node_modules` write under a
554
539
  // project would otherwise trigger every save during `bun install` —
555
540
  // and vx's own cache writes would trigger a cycle that writes again.
556
- let isIgnoredPath = makeWatchIgnore(cacheDir, outputs, inputs)
541
+ /** What existed and was tracked at the arm, so a file born and gone since is no deletion and a tracked one is the user's edit (watch-judge.ts). */
542
+ const atArm = gitFiles(workspaceRoot)
543
+ let isIgnoredPath = makeWatchIgnore(cacheDir, outputs, inputs, atArm?.tracked)
557
544
  let fenced = makeFence(args.fenceDirs)
558
545
  let matters = makeRootEventFilter(
559
546
  workspaceRoot,
@@ -569,14 +556,12 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
569
556
  // unconditional segments AND this run's declared output containers. Read
570
557
  // through `isIgnoredPath` rather than captured, because `rearm` replaces
571
558
  // the filter when the selection changes.
572
- const pool = new WatcherPool((dir, rel) => isIgnoredPath(dir, rel))
559
+ const pool = new WatcherPool((dir, rel) => isIgnoredPath(dir, rel), args.poll)
573
560
  const arm = (dir: string, recursive: boolean, onEvent: (filename: string) => void): WatchHandle =>
574
561
  pool.arm(dir, recursive, onEvent)
575
562
 
576
563
  /** The instant the watchers go live, on the mtime clock (see `fsClockNow`): a path last modified before it is the initial run's, not an edit. */
577
564
  const armedAt = fsClockNow(cacheDir)
578
- /** What existed and was tracked at the arm, so a file born and gone since is no deletion and a tracked one is the user's edit (watch-judge.ts). */
579
- const atArm = gitFiles(workspaceRoot)
580
565
  /** Which settled paths are changes (watch-judge.ts); `pending` holds what fired since. */
581
566
  const changes = new ChangeJudge({
582
567
  workspaceRoot,
@@ -588,6 +573,38 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
588
573
  })
589
574
  /** Per-project arms by directory, so `rearm` can add and drop them. */
590
575
  const perProject = new Map<string, WatchHandle>()
576
+ // Each FSEvents stream goes live slower than the one before it: per
577
+ // project, 751 of 3000 missed their 2 s proof on a macos-latest runner
578
+ // and fell back to polling, a full core at idle; one stream at the root
579
+ // went live in 8 ms (tests/watch-one-stream.test.ts). Polling keeps its
580
+ // per-project walks, which skip what lies between projects.
581
+ const oneStream = process.platform === 'darwin' && !args.poll
582
+ /** The projects the one stream hands events to, by their real directory. */
583
+ let streamed: { proj: ProjectMeta; real: string }[] = []
584
+ const realRoot = ((): string => {
585
+ try {
586
+ return fs.realpathSync(workspaceRoot)
587
+ } catch {
588
+ return path.resolve(workspaceRoot)
589
+ }
590
+ })()
591
+ // The stream names a file by its real place under the root, so a project
592
+ // whose real directory lies outside the root keeps its own arm.
593
+ const toStream = (all: readonly ProjectMeta[]): { proj: ProjectMeta; real: string }[] => {
594
+ const out: { proj: ProjectMeta; real: string }[] = []
595
+ for (const proj of all) {
596
+ let real: string
597
+ try {
598
+ real = fs.realpathSync(proj.dir)
599
+ } catch {
600
+ continue
601
+ }
602
+ const rel = path.relative(realRoot, real)
603
+ if (rel.startsWith('..') || path.isAbsolute(rel)) continue
604
+ out.push({ proj, real })
605
+ }
606
+ return out
607
+ }
591
608
  // An OS watch holds the directory's inode, not its path: one removed and
592
609
  // made again (`rm -rf packages && git checkout packages`) keeps reporting
593
610
  // for the deleted one. Each arm notes the directory it holds, and a
@@ -605,32 +622,68 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
605
622
  }
606
623
  }
607
624
  const stale = (dir: string): boolean => armedAs.get(dir) !== inodeOf(dir)
608
- /** The root arm: recursive when workspace-wide, else the root's own files only. */
625
+ /** The root arm: recursive when workspace-wide or the one stream, else the root's own files only. */
609
626
  let rootArm: WatchHandle = CLOSED
627
+ let dotVxArm: WatchHandle = CLOSED
628
+ const projectEvent = (proj: ProjectMeta, filename: string): void => {
629
+ const abs = path.join(proj.dir, filename)
630
+ if (isIgnoredPath(proj.dir, filename) || fenced(proj.dir, abs)) return
631
+ // A module the config imports from inside the project is the config
632
+ // too: its edit can add an output or a `workspaceFiles` input.
633
+ if (shapesWatchedSet(filename) || configImportFiles.includes(abs)) reread = true
634
+ trigger(`${proj.name} ${filename}`, abs)
635
+ }
610
636
  const armProject = (proj: ProjectMeta): void => {
637
+ // Gone for now: its ancestor's arm (`armAncestors`) hears it return.
638
+ if (inodeOf(proj.dir) === undefined) return
611
639
  try {
612
- const handle = arm(proj.dir, true, (filename) => {
613
- const abs = path.join(proj.dir, filename)
614
- if (isIgnoredPath(proj.dir, filename) || fenced(proj.dir, abs)) return
615
- // A module the config imports from inside the project is the config
616
- // too: its edit can add an output or a `workspaceFiles` input.
617
- if (shapesWatchedSet(filename) || configImportFiles.includes(abs)) reread = true
618
- trigger(`${proj.name} ${filename}`, abs)
619
- })
640
+ const handle = arm(proj.dir, true, (filename) => projectEvent(proj, filename))
620
641
  perProject.set(proj.dir, handle)
621
642
  armedAs.set(proj.dir, inodeOf(proj.dir) ?? '')
622
643
  } catch (err) {
623
644
  sayCannot(`cannot watch ${proj.dir}`, err)
624
645
  }
625
646
  }
647
+ const rootFileEvent = (filename: string): void => {
648
+ if (
649
+ !isWorkspaceConfigFile(filename) &&
650
+ filename !== '.gitignore' &&
651
+ !claimedRootFiles.has(filename)
652
+ )
653
+ return
654
+ if (shapesWatchedSet(filename) || filename === LOCKFILE_NAME || claimedRootFiles.has(filename))
655
+ reread = true
656
+ trigger(`root ${filename}`, path.join(workspaceRoot, filename))
657
+ }
658
+ const dotVxEvent = (filename: string): void => {
659
+ const rel = `.vx/${filename}`
660
+ if (!isWorkspaceConfigFile(rel) && !claimedRootFiles.has(rel)) return
661
+ reread = true
662
+ trigger(`root ${rel}`, path.join(workspaceRoot, rel))
663
+ }
664
+ /**
665
+ * The one stream's events, handed out as the per-project arms, the
666
+ * root's own files and `.vx/` would have heard them: an event lies in
667
+ * every watched project whose tree holds it (a nested one's fence drops
668
+ * it from the outer), so each listener decides exactly as its own arm did.
669
+ */
670
+ const streamEvent = (filename: string): void => {
671
+ const rel = filename.split(path.sep).join('/')
672
+ if (!rel.includes('/')) rootFileEvent(rel)
673
+ else if (rel.startsWith('.vx/') && rel.indexOf('/', 4) < 0) dotVxEvent(rel.slice(4))
674
+ const abs = path.join(realRoot, filename)
675
+ for (const { proj, real } of streamed) {
676
+ if (abs.startsWith(real + path.sep)) projectEvent(proj, path.relative(real, abs))
677
+ }
678
+ }
626
679
  const armRoot = (): void => {
627
680
  try {
628
681
  rootArm = workspaceWide
629
682
  ? // workspaceFiles inputs in play: a root-relative glob can name a
630
683
  // file anywhere, so one recursive root watcher replaces the
631
- // per-project ones (it also covers lockfile / pnpm-workspace.yaml
632
- // edits). `matters` keeps the events a key can see — project
633
- // trees, root fingerprint files, the declared globs — and drops
684
+ // per-project ones (it also covers claimed root files).
685
+ // `matters` keeps the events a key can see —
686
+ // project trees, claimed root files, the declared globs — and drops
634
687
  // the rest of the tree; the ignore filter then keeps node_modules
635
688
  // / .git / .vx and declared outputs out of what remains.
636
689
  arm(workspaceRoot, true, (filename) => {
@@ -639,88 +692,50 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
639
692
  if (
640
693
  shapesWatchedSet(filename) ||
641
694
  filename === LOCKFILE_NAME ||
695
+ claimedRootFiles.has(filename) ||
642
696
  configImportFiles.includes(abs)
643
697
  )
644
698
  reread = true
645
699
  trigger(`root ${filename}`, abs)
646
700
  })
647
- : // The root itself, non-recursive, beside the per-project arms, so
648
- // lockfile + pnpm-workspace.yaml edits trigger re-runs even when
649
- // no project dir saw the change.
650
- arm(workspaceRoot, false, (filename) => {
651
- if (
652
- isWorkspaceFingerprintFile(filename) ||
653
- isWorkspaceConfigFile(filename) ||
654
- filename === 'package.json' ||
655
- filename === '.gitignore' ||
656
- claimedRootFiles.has(filename)
657
- ) {
658
- if (shapesWatchedSet(filename) || filename === LOCKFILE_NAME) reread = true
659
- trigger(`root ${filename}`, path.join(workspaceRoot, filename))
660
- }
661
- })
701
+ : oneStream
702
+ ? arm(workspaceRoot, true, streamEvent)
703
+ : // The root itself, non-recursive, beside the per-project arms, so
704
+ // claimed root files (a lockfile) trigger re-runs even when no
705
+ // project dir saw the change.
706
+ arm(workspaceRoot, false, rootFileEvent)
662
707
  } catch (err) {
663
708
  sayCannot(`cannot watch workspace root`, err)
664
709
  }
710
+ // The workspace config and the lock sit in `.vx/`, which the root's
711
+ // non-recursive arm does not see into.
712
+ if (workspaceWide || oneStream) return
713
+ try {
714
+ dotVxArm = arm(path.join(workspaceRoot, '.vx'), false, dotVxEvent)
715
+ } catch (err) {
716
+ sayCannot(`cannot watch ${path.join(workspaceRoot, '.vx')}`, err)
717
+ }
665
718
  }
666
719
  // One recursive watcher per project, unless workspace-wide: each project
667
720
  // owns its own subtree; the root is never watched recursively for them
668
- // (it would cover every project + node_modules + caches).
721
+ // (it would cover every project + node_modules + caches). On macOS one
722
+ // recursive stream at the root stands for all of them (`oneStream`).
669
723
  const armMode = (watched: readonly ProjectMeta[]): void => {
670
- if (!workspaceWide) for (const proj of watched) armProject(proj)
724
+ if (!workspaceWide) {
725
+ streamed = oneStream ? toStream(watched) : []
726
+ const inStream = new Set(streamed.map((s) => s.proj))
727
+ for (const proj of watched) if (!inStream.has(proj)) armProject(proj)
728
+ }
671
729
  armRoot()
672
730
  }
673
731
  const dropMode = (): void => {
732
+ streamed = []
674
733
  for (const handle of perProject.values()) handle.close()
675
734
  perProject.clear()
676
735
  rootArm.close()
677
736
  rootArm = CLOSED
678
- }
679
-
680
- /**
681
- * A directory under a member base with no package in it yet. The base's
682
- * own watcher is not recursive, so the `package.json` that makes it a
683
- * package is an event only here; an editor or `git checkout` that makes
684
- * the directory first and the manifest after left the package unwatched
685
- * for good (item 891). On a rearm a new entry is armed before its
686
- * manifest is checked, so one that landed after the re-read is an event
687
- * or is seen by the check. At start there is no re-read to race, and a
688
- * manifest already there is a directory the globs exclude.
689
- */
690
- const pending = new Map<string, WatchHandle>()
691
- const armPending = (afterReread: boolean): void => {
692
- const want = new Set<string>()
693
- for (const base of memberBases)
694
- for (const name of memberEntries(base)) {
695
- const dir = path.join(base, name)
696
- if (!packageDirs.has(dir)) want.add(dir)
697
- }
698
- for (const [dir, handle] of pending) {
699
- if (want.has(dir) && !stale(dir)) continue
700
- handle.close()
701
- pending.delete(dir)
702
- }
703
- for (const dir of want) {
704
- if (pending.has(dir)) continue
705
- const manifest = path.join(dir, 'package.json')
706
- const arrived = (): void => {
707
- reread = true
708
- trigger(path.relative(workspaceRoot, manifest), manifest)
709
- }
710
- try {
711
- pending.set(
712
- dir,
713
- arm(dir, false, (filename) => {
714
- if (filename === 'package.json') arrived()
715
- }),
716
- )
717
- armedAs.set(dir, inodeOf(dir) ?? '')
718
- } catch (err) {
719
- sayCannot(`cannot watch ${dir}`, err)
720
- continue
721
- }
722
- if (afterReread && fs.existsSync(manifest)) arrived()
723
- }
737
+ dotVxArm.close()
738
+ dotVxArm = CLOSED
724
739
  }
725
740
 
726
741
  /**
@@ -782,10 +797,13 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
782
797
  armAncestors()
783
798
  }
784
799
 
785
- const watchingLine = (count: number): string =>
786
- workspaceWide
787
- ? 'vx watch: watching the workspace root (workspaceFiles inputs in use)'
788
- : `vx watch: watching ${count} project(s)`
800
+ // Said once the loop is armed, and again after a cycle that re-read the
801
+ // watched set (a member came or went, the lock or a plugin changed).
802
+ let lastTasks: number | undefined
803
+ const sayWatching = (count: number): void => {
804
+ const tasks = lastTasks === undefined ? '' : ` · ${lastTasks} task${lastTasks === 1 ? '' : 's'}`
805
+ process.stdout.write(`watching ${count} project${count === 1 ? '' : 's'}${tasks}\n`)
806
+ }
789
807
 
790
808
  const rearm = async (say = true): Promise<void> => {
791
809
  let next: Rediscovered
@@ -803,10 +821,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
803
821
  claimedRootFiles = next.claimedRootFiles
804
822
  configImportFiles = next.configImports
805
823
  wsConfigImportFiles = next.workspaceConfigImports
806
- packageDirs = next.packageDirs
807
- memberBases = next.memberBases
808
- armBases()
809
- isIgnoredPath = makeWatchIgnore(cacheDir, outputs, inputs)
824
+ armAncestors()
825
+ isIgnoredPath = makeWatchIgnore(cacheDir, outputs, inputs, atArm?.tracked)
810
826
  fenced = makeFence(next.fenceDirs)
811
827
  matters = makeRootEventFilter(
812
828
  workspaceRoot,
@@ -823,53 +839,60 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
823
839
  workspaceWide = next.workspaceWide
824
840
  armMode(next.projects)
825
841
  } else if (!workspaceWide) {
826
- const keep = new Set(projectDirs)
842
+ streamed = oneStream ? toStream(next.projects) : []
843
+ const inStream = new Set(streamed.map((s) => s.proj))
844
+ const own = next.projects.filter((p) => !inStream.has(p))
845
+ const keep = new Set(own.map((p) => p.dir))
827
846
  for (const [dir, handle] of perProject) {
828
847
  if (keep.has(dir) && !stale(dir)) continue
829
848
  handle.close()
830
849
  perProject.delete(dir)
831
850
  }
832
- for (const proj of next.projects) if (!perProject.has(proj.dir)) armProject(proj)
851
+ for (const proj of own) if (!perProject.has(proj.dir)) armProject(proj)
833
852
  }
834
- armPending(true)
835
853
  armImports()
836
854
  // A new arm proves delivery like the first ones: an edit in the new
837
- // package right after this cycle is seen, not lost in the gap.
855
+ // project right after this cycle is seen, not lost in the gap.
838
856
  await pool.proved()
839
- if (say) process.stdout.write(`${watchingLine(next.projects.length)}\n`)
857
+ if (say) sayWatching(next.projects.length)
840
858
  }
841
859
 
842
860
  armMode(projects)
843
861
 
844
- // A package added while the loop runs is a directory entry appearing
845
- // under the glob's directory (`packages/` for `packages/*`); one
846
- // non-recursive watcher there hears it come or go, and the cycle it
847
- // triggers re-reads the workspace (`rearm`) so the new package's own
848
- // edits are cycles from then on. Until 2026-09-10 the watched set was
849
- // fixed when the loop armed: the next cycle ran the new package, and
850
- // every edit inside it after that was silence. The bases themselves are
851
- // re-read with the set: a glob added to the list watched nothing new
852
- // until a restart (item 1018).
853
- const baseArms = new Map<string, WatchHandle>()
854
- // A base removed and made again is no event to its own watch (it holds
855
- // the deleted directory) nor to the root arm, which drops every name but
856
- // its own files: `watching 0 project(s)` and silence until a restart. So
857
- // each base, and each directory a config imports from, is watched from
858
- // its nearest directory that exists, for the next name on the way down:
859
- // its parent while it is there, an ancestor when the parent went too
860
- // (`apps/` removed under `apps/web/*`).
862
+ // A project directory, or one a config imports from, removed and made
863
+ // again (`rm -rf packages && git checkout packages`) is no event to its
864
+ // own watch (it holds the deleted directory) nor to the root arm, which
865
+ // drops every name but its own files: silence until a restart. So each
866
+ // is watched from its nearest directory that exists, for the next name
867
+ // on the way down: its parent while it is there, an ancestor when the
868
+ // parent went too (`apps/` removed under `apps/web/site`). Every
869
+ // directory above it up to the root is watched for the next name too:
870
+ // FSEvents reports nothing for a watched directory's children when the
871
+ // directory itself goes with them, so on macOS the parent's arm alone
872
+ // never heard `packages/` removed under `packages/a` (inotify did).
861
873
  const ancestorArms = new Map<string, WatchHandle>()
862
874
  let ancestorNames = new Map<string, Set<string>>()
863
875
  const armAncestors = (): void => {
864
876
  ancestorNames = new Map()
865
- for (const base of [...memberBases, ...importNames.keys()]) {
877
+ const rootDir = path.resolve(workspaceRoot)
878
+ const below = projectDirs.filter((d) => path.resolve(d) !== rootDir)
879
+ for (const base of [...below, ...importNames.keys()]) {
866
880
  let dir = path.dirname(base)
867
881
  let name = path.basename(base)
868
882
  while (inodeOf(dir) === undefined && dir !== workspaceRoot && dir !== path.dirname(dir)) {
869
883
  name = path.basename(dir)
870
884
  dir = path.dirname(dir)
871
885
  }
872
- ancestorNames.set(dir, (ancestorNames.get(dir) ?? new Set()).add(name))
886
+ for (;;) {
887
+ const names = ancestorNames.get(dir) ?? new Set<string>()
888
+ // A directory already holding this name was climbed from here before.
889
+ const climbed = names.has(name)
890
+ ancestorNames.set(dir, names.add(name))
891
+ // Outside the root (a preset a config imports from `../`) only the nearest arm.
892
+ if (climbed || !dir.startsWith(workspaceRoot + path.sep)) break
893
+ name = path.basename(dir)
894
+ dir = path.dirname(dir)
895
+ }
873
896
  }
874
897
  for (const [dir, handle] of ancestorArms) {
875
898
  if (ancestorNames.has(dir) && !stale(dir)) continue
@@ -882,7 +905,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
882
905
  ancestorArms.set(
883
906
  dir,
884
907
  arm(dir, false, (filename) => {
885
- if (!ancestorNames.get(dir)?.has(filename)) return
908
+ // A project's parent's `.gitignore` shapes what its keys read, as the root's does.
909
+ if (!ancestorNames.get(dir)?.has(filename) && filename !== '.gitignore') return
886
910
  reread = true
887
911
  const abs = path.join(dir, filename)
888
912
  trigger(path.relative(workspaceRoot, abs), abs)
@@ -894,59 +918,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
894
918
  }
895
919
  }
896
920
  }
897
- const armBases = (): void => {
898
- armAncestors()
899
- const want = new Set(memberBases)
900
- for (const [base, handle] of baseArms) {
901
- if (want.has(base) && !stale(base)) continue
902
- handle.close()
903
- baseArms.delete(base)
904
- }
905
- for (const base of want) {
906
- if (baseArms.has(base)) continue
907
- // Gone for now: its parent's arm hears it come back.
908
- if (inodeOf(base) === undefined) continue
909
- let members = memberEntries(base)
910
- try {
911
- baseArms.set(
912
- base,
913
- arm(base, false, (filename) => {
914
- if (isIgnoredWatchPath(filename)) return
915
- // Inputs are gitignore-aware: an edit here changes what the
916
- // members' keys read, and `vx run` would miss on it.
917
- if (filename === '.gitignore') {
918
- trigger(
919
- path.relative(workspaceRoot, path.join(base, filename)),
920
- path.join(base, filename),
921
- )
922
- return
923
- }
924
- // Only a member coming or going. On macOS a non-recursive watcher
925
- // also reports a member whose CONTENTS changed (FSEvents names the
926
- // directory a write landed in), so a task writing into its own
927
- // project — or the arm's own probe file — read as a member event
928
- // and cost an uncached task one execution per cycle (CI,
929
- // 2026-09-10).
930
- const now = memberEntries(base)
931
- // The same names, but a directory still without a package made
932
- // again (or replaced by a rename): its pending watch holds the
933
- // deleted one, and the `package.json` that lands next is heard
934
- // only by a new arm.
935
- const entry = path.join(base, filename)
936
- if (sameMembers(members, now) && !(pending.has(entry) && stale(entry))) return
937
- members = now
938
- reread = true
939
- trigger(`${path.relative(workspaceRoot, base)}/${filename}`, path.join(base, filename))
940
- }),
941
- )
942
- armedAs.set(base, inodeOf(base) ?? '')
943
- } catch (err) {
944
- sayCannot(`cannot watch ${base}`, err)
945
- }
946
- }
947
- }
948
- armBases()
949
- armPending(false)
921
+ armAncestors()
950
922
  armImports()
951
923
 
952
924
  // The orchestrator's own writes never kick the loop: `makeWatchIgnore`
@@ -963,7 +935,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
963
935
  interruptIfWaiting()
964
936
  }
965
937
 
966
- process.stdout.write(`\n${watchingLine(projects.length)}; press Ctrl+C to stop\n`)
938
+ lastTasks = args.tasks()
939
+ sayWatching(projects.length)
967
940
 
968
941
  return await new Promise<void>((resolve) => {
969
942
  const cleanup = async (): Promise<void> => {