@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
@@ -12,31 +12,16 @@ import {
12
12
  thrownValueMessage,
13
13
  WATCHED_BUILTIN_NAMES,
14
14
  } from './config-eval.js'
15
- import { hasEsmExport, unprovidedBareImports } from './config-imports.js'
16
- import {
17
- configEvalKey,
18
- configEvalKeyFromClosure,
19
- configEvalKeyFromIdentities,
20
- configImports,
21
- type ConfigEvalStore,
22
- } from './config-cache.js'
15
+ import { configImports, hasEsmExport, unprovidedBareImports } from './config-imports.js'
23
16
  import { readOnce, unreadable } from './load-reads.js'
24
17
 
25
18
  // The validator lives in config-schema.ts; re-exported so a reader that
26
19
  // reaches the loader for it (the tests do) keeps working.
27
20
  export { validateProjectConfig }
28
21
 
29
- /** The workspace config's filenames at the root, in lookup order. `vx watch` re-runs on an edit to whichever exists. */
30
- export const WORKSPACE_CONFIG_FILENAMES = [
31
- 'vx.workspace.ts',
32
- 'vx.workspace.mts',
33
- 'vx.workspace.js',
34
- 'vx.workspace.mjs',
35
- // A CommonJS config (`module.exports = …`), as Vite and TS name theirs: it
36
- // was ignored without a word (D-86).
37
- 'vx.workspace.cts',
38
- 'vx.workspace.cjs',
39
- ]
22
+ // The filenames live with the root lookup, which runs before anything loads.
23
+ import { WORKSPACE_CONFIG_FILENAMES, workspaceFile, workspaceRootOf } from '../handoff.js'
24
+ export { WORKSPACE_CONFIG_FILENAMES }
40
25
 
41
26
  /** What a function default export is told; the playground says the same. */
42
27
  const EXPORTED_A_FUNCTION =
@@ -60,9 +45,9 @@ function assertDefaultObject(mod: unknown, kind: string, configPath: string): vo
60
45
  * The source a load already holds, by the specifier it imports it under.
61
46
  * Bun's loader would open and read a project config a second time; the
62
47
  * `vx-config-bytes` plugin hands it this instead, so the evaluation runs
63
- * exactly the bytes the eval-cache key and the import gate saw. It is also
64
- * the cheaper load: 1,000 configs imported this way took 80–100 ms against
65
- * 180 ms read by Bun (measured 2026-09-24).
48
+ * exactly the bytes the import gate saw. It is also the cheaper load:
49
+ * 1,000 configs imported this way took 80–100 ms against 180 ms read by
50
+ * Bun (measured 2026-09-24).
66
51
  *
67
52
  * Handed over as a string: `onLoad` reads a byte array as Latin-1, and a
68
53
  * `ü` in a description came back as `ü`. So only UTF-8 is served, and
@@ -194,7 +179,7 @@ async function loadDefaultExport(
194
179
  () =>
195
180
  reject(
196
181
  new UserError(
197
- `${kind} config ${configPath} did not finish evaluating within ${budget}ms (VX_CONFIG_WORKER_TIMEOUT_MS)`,
182
+ `${kind} config ${configPath} did not finish evaluating within ${budget}ms (VX_CONFIG_WORKER_TIMEOUT)`,
198
183
  ),
199
184
  ),
200
185
  budget,
@@ -296,7 +281,7 @@ const NAMED_FRAME = /\n\s+at .*[\\/]/
296
281
  *
297
282
  * `ResolveMessage` — an import that cannot be resolved. The usual cause has
298
283
  * one answer: the workspace runs the vx binary and never installed `@vzn/vx`,
299
- * which its own `vx.workspace.ts` imports. Bun's message also carries the
284
+ * which its own `.vx/workspace.ts` imports. Bun's message also carries the
300
285
  * module-cache bust query; the user gets the file they wrote.
301
286
  *
302
287
  * `BuildMessage` — a syntax or transpile error. Its message alone (`Expected
@@ -366,142 +351,23 @@ function loadFailure(err: unknown, configPath: string, kind: string): unknown {
366
351
  return new UserError(thrownValueMessage(kind, configPath, Bun.inspect(err, { compact: true })))
367
352
  }
368
353
 
369
- export interface LoadProjectConfigOptions {
370
- /** Observe the CURRENT environment: no eval cache, even when `evalCache` is passed. */
371
- fresh?: boolean
372
- /**
373
- * Serve a provably-pure config from its cached evaluation (see
374
- * config-cache.ts) and store a fresh one for next time. Off for `fresh`.
375
- */
376
- evalCache?: { store: ConfigEvalStore; workspaceRoot?: string; workspaceFingerprint: string }
377
- }
378
-
379
354
  /**
380
- * Load many configs at once: every file's bytes and cache key in parallel,
381
- * ONE store lookup for all keys, then only the misses are evaluated (in the
382
- * order given, so a failure names the first broken file the way a
383
- * one-by-one load did). A single-path load is the one-element case.
355
+ * Evaluate many configs at once: every file's bytes in parallel, then each
356
+ * one evaluated (in the order given, so a failure names the first broken
357
+ * file the way a one-by-one load did). A single-path load is the
358
+ * one-element case. Only `vx lock` evaluates; every run reads the lock.
384
359
  */
385
360
  const LOAD_WIDTH = 128
386
361
 
387
- export async function loadProjectConfigs(
388
- configPaths: readonly string[],
389
- opts?: LoadProjectConfigOptions,
390
- ): Promise<ProjectConfig[]> {
391
- const evalCache = opts?.fresh === true ? undefined : opts?.evalCache
392
- const store = evalCache?.store
393
- const hashFile = store?.hashFile?.bind(store)
394
- const hashBytes = store?.hashBytes?.bind(store)
395
- // The slow path keys from the bytes it already holds; `hashFile` there
396
- // memoised every closure file one autocommit upsert at a time (item 615).
397
- const slowKeyHash =
398
- hashBytes !== undefined ? { hashBytes } : hashFile !== undefined ? { hashFile } : {}
399
- // The warm fast path: a config whose ordered closure the store remembers
400
- // is keyed from per-file identities (a stat each, no read, no scan); the
401
- // slow path below reads, gates and scans, and indexes the closure for
402
- // next time when every import is explicit.
403
- const closures =
404
- hashFile !== undefined && store?.getConfigClosures !== undefined
405
- ? store.getConfigClosures(configPaths)
406
- : new Map<string, string[]>()
407
- // Every indexed closure's files identified in ONE batch, then keyed from
408
- // the map; a file the batch could not stat has no identity, which makes
409
- // that config's fast key miss and sends it down the slow path exactly as
410
- // a throwing per-file `hashFile` did.
411
- let identities: Map<string, string> | undefined
412
- if (closures.size > 0 && store?.hashFiles !== undefined) {
413
- const files = new Set<string>()
414
- for (const closure of closures.values()) for (const f of closure) files.add(f)
415
- identities = await store.hashFiles([...files])
416
- }
417
- // With the batch in hand a fast key is synchronous: an async key per
418
- // config, awaiting identities already in the map, was ~5 ms of a
419
- // 1,000-config warm load. Only a config off the fast path is awaited.
420
- const fastKeyOf = (configPath: string): string | null => {
421
- const closure = closures.get(configPath)
422
- if (closure === undefined || evalCache === undefined || identities === undefined) return null
423
- return configEvalKeyFromIdentities({
424
- closure,
425
- identities,
426
- workspaceFingerprint: evalCache.workspaceFingerprint,
427
- })
428
- }
429
- const fastHashFile = identities === undefined ? hashFile : undefined
430
- interface Prepared {
431
- configPath: string
432
- bytes: Uint8Array | null
433
- cacheKey: string | null
434
- indexed: boolean
435
- closure?: string[] | undefined
436
- }
362
+ export async function loadProjectConfigs(configPaths: readonly string[]): Promise<ProjectConfig[]> {
437
363
  const prepared = await Promise.all(
438
- configPaths.map((configPath): Prepared | Promise<Prepared> => {
439
- const fastKey = fastKeyOf(configPath)
440
- return fastKey !== null
441
- ? { configPath, bytes: null, cacheKey: fastKey, indexed: true }
442
- : slowPrepared(configPath)
443
- }),
444
- )
445
- async function slowPrepared(configPath: string): Promise<Prepared> {
446
- const closure = closures.get(configPath)
447
- if (closure !== undefined && evalCache !== undefined && fastHashFile !== undefined) {
448
- const fastKey = await configEvalKeyFromClosure({
449
- closure,
450
- hashFile: fastHashFile,
451
- workspaceFingerprint: evalCache.workspaceFingerprint,
452
- })
453
- if (fastKey !== null) return { configPath, bytes: null, cacheKey: fastKey, indexed: true }
454
- }
455
- const bytes = await Bun.file(configPath)
456
- .bytes()
457
- .catch((err: unknown) => unreadable(err, configPath))
458
- const keyed =
459
- evalCache === undefined
460
- ? null
461
- : await configEvalKey({
462
- configPath,
463
- bytes,
464
- workspaceRoot: evalCache.workspaceRoot,
465
- workspaceFingerprint: evalCache.workspaceFingerprint,
466
- ...slowKeyHash,
467
- })
468
- return {
364
+ configPaths.map(async (configPath) => ({
469
365
  configPath,
470
- bytes,
471
- cacheKey: keyed?.key ?? null,
472
- indexed: false,
473
- closure: keyed !== null && keyed.indexable ? keyed.closure : undefined,
474
- }
475
- }
476
- let hits = new Map<string, string>()
477
- if (evalCache !== undefined) {
478
- const keys = prepared.map((p) => p.cacheKey).filter((k): k is string => k !== null)
479
- if (keys.length > 0) {
480
- if (evalCache.store.getConfigEvals !== undefined) {
481
- hits = evalCache.store.getConfigEvals(keys)
482
- } else {
483
- for (const k of keys) {
484
- const hit = evalCache.store.getConfigEval(k)
485
- if (hit !== null) hits.set(k, hit)
486
- }
487
- }
488
- }
489
- }
490
- // What the round learned, written ONCE at the end: one transaction per
491
- // table where each evaluation was its own (1,000 configs cold: 100 ms of
492
- // autocommit inserts against 5, item 615). Written in `finally`, so a
493
- // config that fails validation costs the next attempt only its own
494
- // evaluation.
495
- const evals: Array<readonly [string, string]> = []
496
- const learnedClosures: Array<readonly [string, readonly string[]]> = []
497
- interface Loaded {
498
- config: ProjectConfig
499
- evaluated?: readonly [string, string]
500
- closure?: readonly [string, readonly string[]]
501
- }
502
- // Taken only when a config is evaluated in this process: reading every
503
- // descriptor of `Bun` makes Bun build its lazy members (`bun:sql`,
504
- // `node:stream`), ~7 ms of a two-config warm run where every load hit.
366
+ bytes: await Bun.file(configPath)
367
+ .bytes()
368
+ .catch((err: unknown) => unreadable(err, configPath)),
369
+ })),
370
+ )
505
371
  let builtins: BuiltinSnapshot | undefined
506
372
  let env: Readonly<Record<string, string | undefined>> = {}
507
373
  let cwd = ''
@@ -510,47 +376,19 @@ export async function loadProjectConfigs(
510
376
  // More than one evaluation in flight: a change seen after one load may
511
377
  // be another's.
512
378
  let overlapping = false
513
- let tainted = false
514
- const loadOne = async (entry: (typeof prepared)[number]): Promise<Loaded> => {
515
- const { configPath, cacheKey } = entry
516
- // A fast key that missed: the closure is stale or the file changed.
517
- // Take the slow path for this one config, which re-indexes it.
518
- let bytes = entry.bytes
519
- let closure = entry.closure
520
- let key = cacheKey
521
- if (entry.indexed) {
522
- bytes = await Bun.file(configPath)
523
- .bytes()
524
- .catch((err: unknown) => unreadable(err, configPath))
525
- const keyed = await configEvalKey({
526
- configPath,
527
- bytes,
528
- workspaceRoot: evalCache!.workspaceRoot,
529
- workspaceFingerprint: evalCache!.workspaceFingerprint,
530
- ...slowKeyHash,
531
- })
532
- key = keyed?.key ?? null
533
- closure = keyed !== null && keyed.indexable ? keyed.closure : undefined
534
- const slowRow = key === null ? null : (store!.getConfigEval(key) ?? null)
535
- const slowHit = slowRow === null ? undefined : storedConfig(slowRow)
536
- if (slowHit !== undefined) {
537
- return closure === undefined
538
- ? { config: slowHit }
539
- : { config: slowHit, closure: [configPath, closure] }
540
- }
541
- }
379
+ const loadOne = async (entry: (typeof prepared)[number]): Promise<ProjectConfig> => {
380
+ const { configPath, bytes } = entry
542
381
  // A REPEAT load in this process re-evaluates in a worker, because the
543
382
  // bust above cannot reach the config's import closure — see
544
- // config-eval.ts. A FIRST load keeps the in-process import, so the
545
- // single `vx run` hot path never pays for a worker.
383
+ // config-eval.ts. A FIRST load keeps the in-process import.
546
384
  const repeat = loadedConfigs.has(configPath)
547
385
  loadedConfigs.add(configPath)
548
- if (repeat) refuseUnprovidedImports(bytes!, configPath, 'Project')
386
+ if (repeat) refuseUnprovidedImports(bytes, configPath, 'Project')
549
387
  const mod = repeat
550
388
  ? await evaluateConfigFresh(configPath).catch((err: unknown) => {
551
389
  throw loadFailure(err, configPath, 'Project')
552
390
  })
553
- : await loadDefaultExport(configPath, 'Project', bytes!)
391
+ : await loadDefaultExport(configPath, 'Project', bytes)
554
392
  // Before anything reads through them: a replaced `Array.prototype.includes`
555
393
  // turned the JSON-data walk's own check into "a cyclic reference".
556
394
  const changed = repeat
@@ -578,18 +416,12 @@ export async function loadProjectConfigs(
578
416
  // malformed config reports the identical UserError whether it was
579
417
  // evaluated in-process or in a worker.
580
418
  validateProjectConfig(mod as ProjectConfig, configPath)
581
- const json = JSON.stringify(mod)
582
- // A tree of its own, as a hit and the lock hand out. The module object
583
- // shares what the config shares: one preset's task in two configs, one
584
- // `exec` in two tasks. A `project` hook that edits in place then edited
585
- // them all, so a cold run ran `echo P +plug +plug` where the warm run
586
- // and `--frozen` ran `echo P +plug`, under another key (item 967). A
587
- // config is JSON data (config-schema.ts), so the copy loses nothing.
588
- const config = JSON.parse(json) as ProjectConfig
589
- if (key === null) return { config }
590
- return closure === undefined
591
- ? { config, evaluated: [key, json] }
592
- : { config, evaluated: [key, json], closure: [configPath, closure] }
419
+ // A tree of its own, as the lock hands out. The module object shares
420
+ // what the config shares: one preset's task in two configs, one `exec`
421
+ // in two tasks. A `project` hook that edits in place then edited them
422
+ // all (item 967). A config is JSON data (config-schema.ts), so the copy
423
+ // loses nothing.
424
+ return JSON.parse(JSON.stringify(mod)) as ProjectConfig
593
425
  }
594
426
  // One worker for every repeat load in this round, however many there are.
595
427
  const endRound = beginEvalRound()
@@ -598,49 +430,29 @@ export async function loadProjectConfigs(
598
430
  // configs at ~160 ms of imports where 64 at once take ~60 (D-68), and
599
431
  // 128 took `load configs` from 432 to 411 ms more; the width bounds the
600
432
  // files a module load may hold open, so it stays under macOS's default
601
- // 256-descriptor limit. A failure
602
- // stops nothing (the rest are evaluated and stored for the next
603
- // attempt); the error thrown is the first in order, as the serial
604
- // loop's was.
605
- const results: Array<Loaded | { failed: unknown }> = []
606
- // A hit is taken here, synchronously as before: through the lanes each
607
- // cost the warm path a call and an await.
608
- const misses: number[] = []
609
- for (let i = 0; i < prepared.length; i++) {
610
- const key = prepared[i]!.cacheKey
611
- const hit = key === null ? undefined : hits.get(key)
612
- // Stored AFTER validation, so a hit needs none; the key covers every
613
- // byte the evaluation could have read.
614
- const cached = hit === undefined ? undefined : storedConfig(hit)
615
- if (cached !== undefined) results[i] = { config: cached }
616
- else misses.push(i)
617
- }
618
- if (misses.length > 0) {
433
+ // 256-descriptor limit. A failure stops nothing; the error thrown is
434
+ // the first in order, as the serial loop's was.
435
+ const results: Array<ProjectConfig | { failed: unknown }> = []
436
+ if (prepared.length > 0) {
619
437
  unredirect = stdoutToStderr()
620
438
  builtins = builtinSnapshot()
621
439
  env = { ...process.env }
622
440
  cwd = process.cwd()
623
441
  umask = currentUmask()
624
442
  }
625
- overlapping = misses.length > 1
443
+ overlapping = prepared.length > 1
626
444
  let next = 0
627
445
  const lane = async (): Promise<void> => {
628
- while (next < misses.length) {
629
- const i = misses[next++]!
446
+ while (next < prepared.length) {
447
+ const i = next++
630
448
  results[i] = await loadOne(prepared[i]!).catch((err: unknown) => ({ failed: err }))
631
449
  }
632
450
  }
633
- await Promise.all(Array.from({ length: Math.min(LOAD_WIDTH, misses.length) }, lane))
451
+ await Promise.all(Array.from({ length: Math.min(LOAD_WIDTH, prepared.length) }, lane))
634
452
  let first: { failed: unknown } | undefined
635
- for (const r of results) {
636
- if ('failed' in r) first ??= r
637
- else {
638
- if (r.evaluated !== undefined) evals.push(r.evaluated)
639
- if (r.closure !== undefined) learnedClosures.push(r.closure)
640
- }
641
- }
453
+ for (const r of results) if ('failed' in r) first ??= r
642
454
  const changed =
643
- misses.length > 0
455
+ prepared.length > 0
644
456
  ? [
645
457
  ...restoreBuiltins(builtins),
646
458
  ...restoreEnv(env),
@@ -648,9 +460,9 @@ export async function loadProjectConfigs(
648
460
  ...restoreUmask(umask),
649
461
  ]
650
462
  : []
651
- // Overlapping loads check most built-ins and the env only here, so a change one
652
- // config made may have broken another's load: it is refused first, and
653
- // nothing the round evaluated is stored.
463
+ // Overlapping loads check most built-ins and the env only here, so a
464
+ // change one config made may have broken another's load: it is refused
465
+ // first.
654
466
  const changedInRound =
655
467
  first?.failed instanceof ChangedInRound
656
468
  ? first.failed.changed
@@ -658,9 +470,7 @@ export async function loadProjectConfigs(
658
470
  ? changed
659
471
  : undefined
660
472
  if (changedInRound !== undefined) {
661
- tainted = true
662
- for (const i of misses) {
663
- const configPath = prepared[i]!.configPath
473
+ for (const { configPath } of prepared) {
664
474
  const own = await builtinsChangedBy(configPath)
665
475
  // The blaming worker is gone, so this thread reads alone; and a
666
476
  // worker that outlived its budget was ended before it could put
@@ -674,24 +484,11 @@ export async function loadProjectConfigs(
674
484
  // One load in the round, in the worker (a repeat load), is the one
675
485
  // that moved it: the worker no longer reads the umask to say so.
676
486
  if (changed.length > 0)
677
- throw builtinsChanged(
678
- changed,
679
- misses.length === 1 ? prepared[misses[0]!]!.configPath : undefined,
680
- )
681
- return results.map((r) => (r as Loaded).config)
487
+ throw builtinsChanged(changed, prepared.length === 1 ? prepared[0]!.configPath : undefined)
488
+ return results as ProjectConfig[]
682
489
  } finally {
683
490
  unredirect?.()
684
491
  endRound()
685
- if (store !== undefined && !tainted) {
686
- if (evals.length > 0) {
687
- if (store.putConfigEvals !== undefined) store.putConfigEvals(evals)
688
- else for (const [k, json] of evals) store.putConfigEval(k, json)
689
- }
690
- if (learnedClosures.length > 0 && store.putConfigClosure !== undefined) {
691
- if (store.putConfigClosures !== undefined) store.putConfigClosures(learnedClosures)
692
- else for (const [p, files] of learnedClosures) store.putConfigClosure(p, files)
693
- }
694
- }
695
492
  }
696
493
  }
697
494
 
@@ -699,8 +496,7 @@ export async function loadProjectConfigs(
699
496
  * Every route to fd 1 sent to stderr while configs evaluate in this
700
497
  * process; returns the release. A verb's stdout is its output (`vx show
701
498
  * --format json`), and a config's `console.log` came out ahead of the
702
- * JSON. The worker does the same for a repeat load (D-64), `vx mcp` for
703
- * its whole serve. Taken before a round's built-in snapshot and released
499
+ * JSON. The worker does the same for a repeat load (D-64). Taken before a round's built-in snapshot and released
704
500
  * after its check, so the guard sees no change of vx's own.
705
501
  *
706
502
  * Counted, because rounds overlap (`--affected`'s per-file sweep loads each
@@ -713,7 +509,7 @@ let redirectHolders = 0
713
509
  let undoRedirect = (): void => {}
714
510
  /**
715
511
  * Bun's own `Console`, taken at load: the console a round finds may be a
716
- * constructed one (`vx mcp` serves under its own), which has no `Console`.
512
+ * constructed one (a plugin's verb may install its own), which has no `Console`.
717
513
  * The global's, not `node:console`'s: the playground bundles this module
718
514
  * for the browser, where that specifier is a stub.
719
515
  */
@@ -831,11 +627,11 @@ const BUN_MEMBERS_VX_READS: readonly PropertyKey[] = [
831
627
  'JSONC',
832
628
  'Subprocess',
833
629
  'Transpiler',
834
- 'YAML',
835
630
  'color',
836
631
  'deepEquals',
837
632
  'env',
838
633
  'file',
634
+ 'gunzipSync',
839
635
  'hash',
840
636
  'inspect',
841
637
  'main',
@@ -843,7 +639,6 @@ const BUN_MEMBERS_VX_READS: readonly PropertyKey[] = [
843
639
  'plugin',
844
640
  'randomUUIDv7',
845
641
  'resolveSync',
846
- 'semver',
847
642
  'serve',
848
643
  'sleep',
849
644
  'sleepSync',
@@ -1076,11 +871,8 @@ function sameDescriptor(p: Primitives, a: PropertyDescriptor, b: PropertyDescrip
1076
871
  )
1077
872
  }
1078
873
 
1079
- export async function loadProjectConfig(
1080
- configPath: string,
1081
- opts?: LoadProjectConfigOptions,
1082
- ): Promise<ProjectConfig> {
1083
- const [config] = await loadProjectConfigs([configPath], opts)
874
+ export async function loadProjectConfig(configPath: string): Promise<ProjectConfig> {
875
+ const [config] = await loadProjectConfigs([configPath])
1084
876
  return config!
1085
877
  }
1086
878
 
@@ -1088,10 +880,10 @@ export async function loadProjectConfig(
1088
880
  const workspaceLoadedAt = new Map<string, number>()
1089
881
 
1090
882
  /**
1091
- * A repeat load in one process (`vx mcp`'s every call, `vx watch`'s every
1092
- * cycle) busts the workspace config's own URL, but Bun answers what it
883
+ * A repeat load in one process (`vx watch`'s every cycle, a plugin verb's
884
+ * every call) busts the workspace config's own URL, but Bun answers what it
1093
885
  * imports from the module registry: an edited local plugin kept its first
1094
- * version and `listTasks` served its old tasks with no word (item 1046).
886
+ * version and its old tasks were served with no word (item 1046).
1095
887
  * Bun cannot evaluate an imported module again, so the load is refused,
1096
888
  * naming the file, as `vx watch` already says when it sees the edit. Only
1097
889
  * a repeat load pays for the walk; a CLI verb loads once.
@@ -1111,23 +903,43 @@ async function refuseStaleWorkspaceImports(configPath: string, since: number): P
1111
903
  if (Math.floor(mtime) > since) {
1112
904
  // The walk names real paths: a root reached through a symlink (macOS's
1113
905
  // temp dir) named `../target/helper.mjs` from the link.
1114
- const from = path.dirname(realpathSync(configPath))
906
+ const real = realpathSync(configPath)
907
+ const root = workspaceRootOf(real)
1115
908
  throw new UserError(
1116
- `${path.basename(configPath)} imports ${path.relative(from, file)}, which changed after this process loaded it; a running process cannot evaluate an imported module again — restart it to apply the edit`,
909
+ `${path.relative(root, real)} imports ${path.relative(root, file)}, which changed after this process loaded it; a running process cannot evaluate an imported module again — restart it to apply the edit`,
1117
910
  )
1118
911
  }
1119
912
  }
1120
913
  }
1121
914
 
1122
915
  /**
1123
- * Find and load `vx.workspace.{ts,mts,js,mjs,cts,cjs}` from the workspace
916
+ * Find and load `.vx/workspace.{ts,mts,js,mjs,cts,cjs}` from the workspace
1124
917
  * root. Returns `null` if no such file exists (the common case;
1125
918
  * the schema is fully optional). Validates the shape and throws
1126
919
  * a `UserError` on malformed input.
1127
920
  */
1128
921
  export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig | null> {
1129
- for (const configPath of WORKSPACE_CONFIG_FILENAMES.map((f) => path.join(root, f))) {
1130
- const bytes = await readOnce(undefined, configPath)
922
+ // The root lookup already probed for it (principle 9): read what it
923
+ // found. A file gone since (a `vx watch` cycle after a rename) is probed for.
924
+ const found = await workspaceFile(path.resolve(root))
925
+ const known =
926
+ found !== null && workspaceRootOf(found) === path.resolve(root)
927
+ ? await Bun.file(found)
928
+ .bytes()
929
+ .then(
930
+ (bytes) => ({ configPath: found, bytes }),
931
+ () => null,
932
+ )
933
+ : null
934
+ const candidates =
935
+ known !== null
936
+ ? [known]
937
+ : found === null
938
+ ? []
939
+ : WORKSPACE_CONFIG_FILENAMES.map((f) => ({ configPath: path.join(root, f), bytes: null }))
940
+ for (const candidate of candidates) {
941
+ const { configPath } = candidate
942
+ const bytes = candidate.bytes ?? (await readOnce(undefined, configPath))
1131
943
  if (bytes === null) continue
1132
944
  const since = workspaceLoadedAt.get(configPath)
1133
945
  if (since !== undefined) await refuseStaleWorkspaceImports(configPath, since)
@@ -1172,20 +984,3 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
1172
984
  }
1173
985
  return null
1174
986
  }
1175
-
1176
- /**
1177
- * A stored evaluation, or undefined when the row is not one: a row cut
1178
- * short (a crash mid-write, a bad disk) failed every later run with a
1179
- * `SyntaxError` stack from this loader until the cache was wiped
1180
- * (fuzzed, L-17). As a miss it is evaluated again and the row replaced.
1181
- */
1182
- function storedConfig(json: string): ProjectConfig | undefined {
1183
- try {
1184
- const v: unknown = JSON.parse(json)
1185
- return typeof v === 'object' && v !== null && !Array.isArray(v)
1186
- ? (v as ProjectConfig)
1187
- : undefined
1188
- } catch {
1189
- return undefined
1190
- }
1191
- }
@@ -17,6 +17,7 @@ import {
17
17
  statSync,
18
18
  } from 'node:fs'
19
19
  import path from 'node:path'
20
+ import { gitEnv } from '../util/index.js'
20
21
 
21
22
  /**
22
23
  * 16 hex of sha256 of the repo key, sha256(`<identity>#<workspace path in
@@ -235,7 +236,7 @@ async function firstCommit(commonDir: string, root: string): Promise<string | nu
235
236
 
236
237
  async function git(cwd: string, args: string[]): Promise<string | null> {
237
238
  try {
238
- const p = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'ignore' })
239
+ const p = Bun.spawn(['git', ...args], { cwd, env: gitEnv(), stdout: 'pipe', stderr: 'ignore' })
239
240
  const [out, code] = await Promise.all([new Response(p.stdout).text(), p.exited])
240
241
  return code === 0 ? out : null
241
242
  } catch {