@vzn/vx 0.0.636 → 0.0.638

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