@vzn/vx 0.0.645 → 0.0.648

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 (186) hide show
  1. package/README.md +39 -50
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -5,12 +5,18 @@ import {
5
5
  formatFrameOpen,
6
6
  formatKeptLines,
7
7
  formatPersistentTailBlock,
8
+ formatListHeader,
8
9
  formatTaskAbortedLine,
9
10
  formatTaskBlock,
10
11
  formatTaskExecutedLine,
11
12
  flakyNote,
13
+ pathNote,
14
+ whereNote,
15
+ formatTaskHashLine,
12
16
  formatTaskHitLine,
13
17
  formatTaskSkippedLine,
18
+ leavesStyle,
19
+ RESET,
14
20
  type OutputChunk,
15
21
  } from './framed-output.js'
16
22
  import {
@@ -26,8 +32,8 @@ import {
26
32
  formatSummarySection,
27
33
  neverStarted,
28
34
  ranUnsaved,
29
- type CacheBucket,
30
35
  type RunContext,
36
+ type TaskBucket,
31
37
  } from './summary.js'
32
38
  import { isGroupTask } from '../graph/index.js'
33
39
  import {
@@ -38,7 +44,9 @@ import {
38
44
  tailText,
39
45
  type Tail,
40
46
  detectColors,
47
+ envFlagOn,
41
48
  type ColorSupport,
49
+ ui,
42
50
  } from '../util/index.js'
43
51
  import { isCacheHit } from '../graph/index.js'
44
52
  import { failedLabel, outcomeWord, ranNoCache } from './events.js'
@@ -164,11 +172,6 @@ export interface OutputView {
164
172
  ci?: boolean
165
173
  }
166
174
 
167
- /** CI=0 / CI=false count as "not CI" — several vendors use CI=true. */
168
- function truthyEnv(v: string | undefined): boolean {
169
- return v !== undefined && v !== '' && v !== '0' && v !== 'false'
170
- }
171
-
172
175
  // ── GitHub Actions workflow-command escaping ────────────────────────
173
176
  // The runner parses `::`-prefixed LINES out of the log, so anything
174
177
  // interpolated into one has to be encoded or it changes the command's shape.
@@ -211,8 +214,8 @@ export function resolveOutputView(
211
214
  },
212
215
  env: Record<string, string | undefined> = process.env,
213
216
  ): OutputView {
214
- const ci = truthyEnv(env['CI'])
215
- const gha = truthyEnv(env['GITHUB_ACTIONS'])
217
+ const ci = envFlagOn(env['CI'])
218
+ const gha = envFlagOn(env['GITHUB_ACTIONS'])
216
219
  const mk = (mode: OutputView['mode']): OutputView => ({
217
220
  mode,
218
221
  ...(gha ? { gha: true } : {}),
@@ -231,7 +234,7 @@ export function resolveOutputView(
231
234
  const FORECAST_AFTER_MS = 1000
232
235
  /** A run shorter than this ends without a desktop notification. */
233
236
  const NOTIFY_AFTER_MS = 10_000
234
- // A step of vx's own quicker than this shows nowhere (M, 2026-10-15).
237
+ // A step of vx's own quicker than this shows nowhere (2026-10-15).
235
238
  const STEP_SHOWN_MS = 150
236
239
 
237
240
  export function defaultLogger(
@@ -344,14 +347,15 @@ export function defaultLogger(
344
347
  let forecastFn: Forecast | undefined
345
348
  let graphForecast: (() => Forecast) | undefined
346
349
  let forecastTimer: ReturnType<typeof setTimeout> | null = null
347
- let etaMs: number | undefined
350
+ // When the forecast says the run ends; past it, the region counts up.
351
+ let deadlineMs: number | undefined
348
352
  let critical: string | undefined
349
- let blocks: ReadonlyMap<string, number> | undefined
353
+ // Tasks the region showed on the critical path: their finished rows say so too.
354
+ const onPath = new Set<string>()
355
+ let blockedCount = 0
350
356
  let expected: ReadonlyMap<string, number> | undefined
351
357
  let blocked: readonly BlockedTask[] | undefined
352
- // The blocked task each idle worker row holds, by slot: a row keeps its
353
- // task until it starts, in that row, or leaves the list, so nothing hops.
354
- const heldRows: (string | null)[] = []
358
+ let blockedBy: ReadonlyMap<string, number> | undefined
355
359
  let etaAtMs = 0
356
360
  const signals = writer.enabled
357
361
  ? terminalSignals(opts.env ?? process.env)
@@ -360,18 +364,11 @@ export function defaultLogger(
360
364
  let progressShown = -1
361
365
  let progressRed = false
362
366
 
363
- // Every interactive view renders the fixed-height worker region:
364
- // one row per worker slot so a task's name never moves while it
365
- // runs (the display derives from the stable worker set, not the
366
- // churning task set). Sized at runStart from concurrency, capped at
367
- // 10 rows; excess running tasks queue for a freed slot and surface
368
- // as `+k more` on the stats line. Focused flow keeps its lifecycle:
369
- // the region dies the moment a requested node starts streaming.
367
+ // Every interactive view renders the fixed-height running list: one
368
+ // row per worker, sized at runStart from concurrency, so its height
369
+ // never changes; a task keeps its slot for its whole life. Excess
370
+ // running tasks queue for a freed slot, each shown after the slots.
370
371
  let slots: (WorkerSlot | null)[] = []
371
- // Rows shown: the most workers busy at once so far. An idle row past it
372
- // was noise (a 10-core machine drew 9 on a one-chain run); it never
373
- // shrinks, so a row never jumps.
374
- let shownSlots = 0
375
372
  const slotQueue: WorkerSlot[] = []
376
373
  let succeeded = 0
377
374
  // Executed with no `cache` block: never a miss, counted apart.
@@ -383,12 +380,12 @@ export function defaultLogger(
383
380
  let notSaved = 0
384
381
  let skippedCount = 0
385
382
  // Each finished task's cache bucket by its key: its remote traffic moves it.
386
- const buckets = new Map<string, CacheBucket>()
383
+ const buckets = new Map<string, TaskBucket>()
387
384
  // The graph is done and vx's own closing steps run: the region stays,
388
- // footer and step rows, until the footer replaces it (M, 2026-10-15).
385
+ // footer and step rows, until the footer replaces it (2026-10-15).
389
386
  let tail = false
390
387
  // The run was stopped: the region stays, its running rows say
391
- // `aborting`, and nothing is forecast or held (M, 2026-10-17).
388
+ // `aborting`, and nothing is forecast or held (2026-10-17).
392
389
  let stopping = false
393
390
  // Counted live once the run is stopping, as the footer counts them.
394
391
  let stopped = 0
@@ -410,7 +407,8 @@ export function defaultLogger(
410
407
  // deferred to runEnd (owner: '✗ line, continue, all full frames at
411
408
  // the end'). 'full'/CI keeps frames inline.
412
409
  const deferredFailures: string[] = []
413
- const pinnedPersistent: string[] = []
410
+ // Each ready server's outcome: one whose child has exited (`serverExit`) is no longer pinned.
411
+ const pinnedPersistent: TaskOutcome[] = []
414
412
  let flushedFailures = false
415
413
  // A persistent task (dev server, watcher, daemon) is the one task kind
416
414
  // nothing bounds: a one-shot command's output ends when it exits, but a
@@ -467,21 +465,44 @@ export function defaultLogger(
467
465
  }
468
466
  const printsKept = (tail: { outcome?: TaskOutcome }): boolean =>
469
467
  flushedPersistent && tail.outcome !== undefined && view.mode !== 'errors-only'
468
+ // A server writing before this run started its task is one an earlier run
469
+ // left running (`vx watch` keeps its dev server): it streams as a kept
470
+ // one, live above the rows. Buffered as a task yet to run, it was never shown.
471
+ const heldOver = (node: TaskNode): boolean =>
472
+ node.config.exec?.persistent !== undefined && view.mode !== 'errors-only'
470
473
 
474
+ // A forced refresh the region has not been built for yet: the build
475
+ // takes the forecast pass the event asked for.
476
+ let forcePending = false
477
+ // The region is built when the writer draws it (`setRegion`'s function
478
+ // form), from the state as it is then: the events a forced-redraw floor
479
+ // coalesces build nothing.
471
480
  const refresh = (forced: boolean): void => {
472
481
  if (statusDead) return
473
- let force = forced
482
+ const first = forecastFn === undefined && graphForecast !== undefined && !tail && !stopping
483
+ forcePending ||= forced || first
484
+ writer.setRegion(buildRegion, { force: forced || first })
485
+ }
486
+ const buildRegion = (): readonly string[] => {
487
+ let force = forcePending
488
+ forcePending = false
474
489
  const now = Date.now()
475
- // A free row shows what it holds from the first frame: the graph
476
- // knows, without history (M, 2026-10-17).
477
- if (forecastFn === undefined && graphForecast !== undefined && !tail && !stopping)
478
- for (let i = 0; i < shownSlots; i++)
479
- if (slots[i] === null) {
480
- forecastFn = graphForecast()
481
- graphForecast = undefined
482
- force = true
483
- break
484
- }
490
+ // A task its executor still holds has not begun: its clock starts at
491
+ // the first frame a machine runs it.
492
+ const where = runContext?.whereRunning
493
+ if (where !== undefined)
494
+ for (const x of [...slots, ...slotQueue]) {
495
+ if (x === null) continue
496
+ x.waiting = where(x.id) === null
497
+ if (x.waiting) x.startedMs = now
498
+ }
499
+ // The rows' blocked counts and planned tasks from the first frame: the
500
+ // graph knows, without history.
501
+ if (forecastFn === undefined && graphForecast !== undefined && !tail && !stopping) {
502
+ forecastFn = graphForecast()
503
+ graphForecast = undefined
504
+ force = true
505
+ }
485
506
  // Twice a second at most: one pass over the unfinished graph.
486
507
  if (forecastFn !== undefined && !tail && !stopping && (force || now - etaAtMs >= 500)) {
487
508
  etaAtMs = now
@@ -489,16 +510,23 @@ export function defaultLogger(
489
510
  for (const s of slots) if (s !== null) running.set(s.id, s.startedMs)
490
511
  for (const s of slotQueue) running.set(s.id, s.startedMs)
491
512
  const f = forecastFn(finished, running, now)
492
- etaMs = f.etaMs
513
+ deadlineMs =
514
+ f.etaMs === undefined
515
+ ? undefined
516
+ : f.etaMs > 0
517
+ ? now + f.etaMs
518
+ : Math.min(deadlineMs ?? now, now)
493
519
  critical = f.critical
494
- blocks = f.blocks
520
+ blockedCount = f.waiting
495
521
  expected = f.expected
496
522
  blocked = f.blocked
523
+ blockedBy = f.blockedBy
497
524
  }
525
+ const etaMs = deadlineMs === undefined ? undefined : deadlineMs - now
498
526
  if (signals.progress && total > 0) {
499
527
  const elapsed = now - startedAtMs
500
528
  const pct = Math.floor(
501
- etaMs !== undefined && elapsed + etaMs > 0
529
+ etaMs !== undefined && etaMs > 0
502
530
  ? (elapsed / (elapsed + etaMs)) * 100
503
531
  : (done / total) * 100,
504
532
  )
@@ -509,6 +537,16 @@ export function defaultLogger(
509
537
  out.write(progressEscape(progressShown, progressRed))
510
538
  }
511
539
  }
540
+ let running = 0
541
+ let scheduled = 0
542
+ // Stopping, the rows say `aborting`: nothing is running or scheduled.
543
+ if (!tail && !stopping)
544
+ for (const x of [...slots, ...slotQueue]) {
545
+ if (x === null) continue
546
+ if (x.waiting === true) scheduled++
547
+ else running++
548
+ }
549
+ if (critical !== undefined && running > 0) onPath.add(critical)
512
550
  // The live summary IS the final summary's section, built from the
513
551
  // same formatter so the region visually becomes the printout.
514
552
  const summaryLines =
@@ -530,7 +568,14 @@ export function defaultLogger(
530
568
  miss: succeeded + failed - noCache,
531
569
  noCache,
532
570
  left: total - done - stopped - notRun,
533
- ...(etaMs !== undefined && !tail && !stopping ? { etaMs } : {}),
571
+ ...(running > 0 ? { running } : {}),
572
+ ...(scheduled > 0 ? { scheduled } : {}),
573
+ ...(blockedCount > 0 && !tail && !stopping ? { blocked: blockedCount } : {}),
574
+ ...(etaMs !== undefined && !tail && !stopping
575
+ ? etaMs > 0
576
+ ? { etaMs }
577
+ : { overMs: -etaMs }
578
+ : {}),
534
579
  buckets,
535
580
  spread:
536
581
  spreadCount > 0
@@ -541,59 +586,30 @@ export function defaultLogger(
541
586
  colors,
542
587
  runContext,
543
588
  )
544
- const held = blocked === undefined || stopping ? undefined : holdRows(blocked)
545
589
  const shownSteps: WorkerSlot[] = []
546
590
  for (const [id, startedMs] of steps)
547
591
  if (now - startedMs >= STEP_SHOWN_MS) shownSteps.push({ id, startedMs })
548
- writer.setRegion(
549
- formatStatusRegion(
550
- {
551
- pinnedPersistent,
552
- slots: slots.slice(0, shownSlots),
553
- ...(held !== undefined ? { blocked: held } : {}),
554
- overflow: slotQueue.length,
555
- ...(stopping ? { stopping } : {}),
556
- ...(shownSteps.length > 0 ? { steps: shownSteps } : {}),
557
- nowMs: Date.now(),
558
- summaryLines,
559
- ...(blocks !== undefined ? { blocks } : {}),
560
- ...(expected !== undefined ? { expected } : {}),
561
- ...(critical !== undefined ? { critical } : {}),
562
- },
563
- colors,
564
- ),
565
- { force },
592
+ return formatStatusRegion(
593
+ {
594
+ pinnedPersistent: pinnedPersistent
595
+ .filter((o) => o.serverExit === undefined)
596
+ .map((o) => o.node.id),
597
+ // Every running task has a row: one beyond the slots follows them.
598
+ slots: [...slots, ...slotQueue],
599
+ size: slots.length,
600
+ ...(blocked !== undefined && !stopping ? { next: blocked } : {}),
601
+ ...(blockedBy !== undefined && !stopping ? { blockedBy } : {}),
602
+ ...(stopping ? { stopping } : {}),
603
+ ...(shownSteps.length > 0 ? { steps: shownSteps } : {}),
604
+ nowMs: Date.now(),
605
+ summaryLines,
606
+ ...(runContext?.whereRunning !== undefined ? { where: runContext.whereRunning } : {}),
607
+ ...(expected !== undefined ? { expected } : {}),
608
+ ...(critical !== undefined ? { critical } : {}),
609
+ },
610
+ colors,
566
611
  )
567
612
  }
568
- // Each idle worker row holds a blocked task, in scheduler order. A row
569
- // with none left to hold leaves the region (M, 2026-10-15): blocked tasks
570
- // only dwindle, so it is never needed again, and the rows below close up.
571
- const holdRows = (list: readonly BlockedTask[]): (BlockedTask | null)[] => {
572
- const started = new Set<string>()
573
- for (const s of slots) if (s !== null) started.add(s.id)
574
- for (const s of slotQueue) started.add(s.id)
575
- const open = new Map<string, BlockedTask>()
576
- for (const b of list) if (!started.has(b.id)) open.set(b.id, b)
577
- const rows: (BlockedTask | null)[] = []
578
- for (let i = 0; i < shownSlots; i++) {
579
- const kept = slots[i] === null ? open.get(heldRows[i] ?? '') : undefined
580
- rows.push(kept ?? null)
581
- if (kept !== undefined) open.delete(kept.id)
582
- }
583
- const rest = open.values()
584
- for (let i = 0; i < shownSlots; i++)
585
- if (slots[i] === null && rows[i] === null) rows[i] = rest.next().value ?? null
586
- for (let i = shownSlots - 1; i >= 0; i--) {
587
- if (slots[i] !== null || rows[i] !== null) continue
588
- rows.splice(i, 1)
589
- slots.splice(i, 1)
590
- slots.push(null)
591
- shownSlots--
592
- }
593
- heldRows.length = 0
594
- for (const r of rows) heldRows.push(r?.id ?? null)
595
- return rows
596
- }
597
613
  const tick = (): void => {
598
614
  if (!writer.enabled || statusDead || ticker !== null) return
599
615
  // Keeps the per-worker elapsed time ticking between task events
@@ -624,14 +640,36 @@ export function defaultLogger(
624
640
  }
625
641
 
626
642
  // The task log opens with one blank line before its first row, on a
627
- // terminal and off one (M, 2026-10-14). A note vx prints before it is
643
+ // terminal and off one (2026-10-14). A note vx prints before it is
628
644
  // no task row (`status`). Without a footer, the caller frames the log
629
645
  // (`vx watch` opens each cycle with its own line).
630
646
  let logOpened = opts.footer === false
631
647
  // The log ends on a blank line (a block's own): a blank status line
632
648
  // after it (the footer's) would make two; one blank line apart is the rule.
633
649
  let endsBlank = false
650
+ // A live-streamed task's partial line, and any colour it left on, end
651
+ // before anything of vx's own: a row or a block written after it (a step
652
+ // that ends while the task streams, another task's failure, a kept
653
+ // server's line) was glued to it, `building... time status cache
654
+ // task`, and took its colour.
655
+ let streamStyled = false
656
+ const endStreamLine = (): void => {
657
+ const end = (streamStyled ? RESET : '') + (streamMidLine ? '\n' : '')
658
+ streamStyled = false
659
+ streamMidLine = false
660
+ if (end.length > 0) logWrite(end)
661
+ }
634
662
  const taskWrite = (chunk: string): void => {
663
+ endStreamLine()
664
+ logWrite(chunk)
665
+ }
666
+ const streamWrite = (chunk: string): void => {
667
+ streamedSinceBlock = true
668
+ if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
669
+ if (chunk.includes('\x1b[')) streamStyled = leavesStyle(chunk)
670
+ logWrite(chunk)
671
+ }
672
+ const logWrite = (chunk: string): void => {
635
673
  if (chunk.length > 0) endsBlank = chunk.endsWith('\n\n')
636
674
  if (!logOpened && chunk.length > 0) {
637
675
  logOpened = true
@@ -644,10 +682,21 @@ export function defaultLogger(
644
682
  taskWrite(`${line}\n`)
645
683
  lineEmitted = true
646
684
  }
685
+ // The list's header, once over its first row, where the live region
686
+ // heads its own rows the same way (2026-10-21).
687
+ let headed = !writer.enabled
688
+ const emitRow = (line: string): void => {
689
+ if (!headed) {
690
+ headed = true
691
+ emitLine(formatListHeader(runContext?.whereRunning !== undefined, colors))
692
+ }
693
+ emitLine(line)
694
+ }
647
695
  // formatTaskBlock returns '' for group tasks (no exec) — skip the
648
696
  // write so a stray newline doesn't sneak into the output.
649
697
  const emitBlock = (block: string): void => {
650
698
  if (block.length === 0) return
699
+ endStreamLine()
651
700
  const lead = lineEmitted || streamedSinceBlock ? '\n' : ''
652
701
  taskWrite(`${lead}${block}\n`)
653
702
  lineEmitted = false
@@ -684,6 +733,8 @@ export function defaultLogger(
684
733
 
685
734
  const failureRow = (node: TaskNode, outcome: TaskOutcome): string =>
686
735
  formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(outcome)) +
736
+ (onPath.has(node.id) ? pathNote(colors) : '') +
737
+ whereNote(outcome, colors) +
687
738
  flakyNote(outcome, colors)
688
739
 
689
740
  return {
@@ -699,21 +750,15 @@ export function defaultLogger(
699
750
  flushKept()
700
751
  // A live-streamed server keeps writing after its frame closes, so a
701
752
  // summary row or "exited with code" can follow its partial line.
702
- if (streamMidLine) {
703
- writer.write('\n')
704
- streamMidLine = false
705
- }
753
+ endStreamLine()
706
754
  if (line === '' && endsBlank) return
707
755
  endsBlank = line === ''
708
756
  writer.write(`${line}\n`)
709
757
  },
710
758
  warn(line) {
711
759
  flushKept()
712
- if (streamMidLine) {
713
- writer.write('\n')
714
- streamMidLine = false
715
- }
716
- writer.writeTo(errOut, `${line}\n`)
760
+ endStreamLine()
761
+ writer.writeTo(errOut, `${ui.tagged('warn', line, colors)}\n`)
717
762
  },
718
763
  runStart(info) {
719
764
  total = info.total
@@ -721,9 +766,7 @@ export function defaultLogger(
721
766
  runContext = info.context
722
767
  startedAtMs = Date.now()
723
768
  started = true
724
- const cap = Math.max(1, Math.min(info.concurrency ?? 10, 10))
725
- slots = Array.from({ length: cap }, () => null)
726
- shownSlots = 0
769
+ slots = Array.from({ length: Math.max(1, info.concurrency ?? 10) }, () => null)
727
770
  tick()
728
771
  refresh(true)
729
772
  },
@@ -735,15 +778,20 @@ export function defaultLogger(
735
778
  stepEnd(id, { durationMs, ok }) {
736
779
  steps.delete(id)
737
780
  if (durationMs < STEP_SHOWN_MS) return
738
- if (ok ? view.mode !== 'none' && view.mode !== 'errors-only' : view.mode !== 'none')
739
- emitLine(formatStepLine(id, durationMs, ok, colors))
781
+ // `hash-only` is one line per task: only a step that failed adds one.
782
+ if (
783
+ ok
784
+ ? view.mode !== 'none' && view.mode !== 'errors-only' && view.mode !== 'hash-only'
785
+ : view.mode !== 'none'
786
+ )
787
+ emitRow(formatStepLine(id, durationMs, ok, colors))
740
788
  refresh(true)
741
789
  },
742
790
  runStop() {
743
791
  if (stopping) return
744
792
  stopping = true
745
793
  blocked = undefined
746
- heldRows.length = 0
794
+ blockedBy = undefined
747
795
  refresh(true)
748
796
  },
749
797
  runDone() {
@@ -757,34 +805,25 @@ export function defaultLogger(
757
805
  // What is coalesced lands before the task writes past it.
758
806
  writer.settle()
759
807
  }
760
- // Focused flow: the status line exists for the dependency
761
- // phase only. The moment a requested node starts streaming,
762
- // clear it for good — its raw output owns the terminal now.
808
+ // Focused flow: a requested node streams raw, and the region stays
809
+ // under its output (the writer erases and redraws it around each
810
+ // write): taken down, the summary and the run's id were gone until
811
+ // the task ended.
763
812
  if (streamsLive(node)) {
764
- killStatus()
765
813
  // Open the live frame: full task info even when the command
766
814
  // streams nothing (or the hit replays nothing).
767
815
  emitLine(formatFrameOpen(node, colors, forwardArgs))
768
- return
769
- }
770
- // Bound a persistent task's output from its FIRST chunk. Waiting for
771
- // its outcome would leave a never-ready one uncapped forever.
772
- if (node.config.exec?.persistent !== undefined && !persistentTails.has(node.id)) {
816
+ } else if (node.config.exec?.persistent !== undefined && !persistentTails.has(node.id)) {
817
+ // Bound a persistent task's output from its FIRST chunk. Waiting for
818
+ // its outcome would leave a never-ready one uncapped forever.
773
819
  persistentTails.set(node.id, { node, out: createTail(), err: createTail() })
774
820
  }
821
+ // The rows are the status region's; with none, nothing reads them.
822
+ if (statusDead) return
775
823
  const slot: WorkerSlot = { id: node.id, startedMs: Date.now() }
776
- // A blocked task starts in the row that held it; another takes a free
777
- // row, else the last held one, whose task moves to the next free row.
778
- const own = heldRows.indexOf(node.id)
779
- let free = own >= 0 && slots[own] === null ? own : -1
780
- if (free < 0)
781
- free = slots.findIndex((s, i) => s === null && i < shownSlots && heldRows[i] == null)
782
- if (free < 0) free = slots.findLastIndex((s, i) => s === null && i < shownSlots)
783
- if (free < 0) free = slots.indexOf(null)
784
- if (free >= 0) {
785
- slots[free] = slot
786
- shownSlots = Math.max(shownSlots, free + 1)
787
- } else slotQueue.push(slot)
824
+ const free = slots.indexOf(null)
825
+ if (free >= 0) slots[free] = slot
826
+ else slotQueue.push(slot)
788
827
  refresh(true)
789
828
  },
790
829
  holdStatus: () => writer.hold(),
@@ -821,7 +860,7 @@ export function defaultLogger(
821
860
  tail = true
822
861
  slots = []
823
862
  blocked = undefined
824
- shownSlots = 0
863
+ blockedBy = undefined
825
864
  slotQueue.length = 0
826
865
  refresh(true)
827
866
  } else if (!tail) killStatus()
@@ -880,9 +919,7 @@ export function defaultLogger(
880
919
  taskStdout(node, chunk) {
881
920
  if (discardsOutput) return
882
921
  if (streamsLive(node)) {
883
- streamedSinceBlock = true
884
- if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
885
- taskWrite(chunk)
922
+ streamWrite(chunk)
886
923
  return
887
924
  }
888
925
  const tail = persistentTails.get(node.id)
@@ -891,14 +928,13 @@ export function defaultLogger(
891
928
  else appendTail(tail.out, chunk)
892
929
  return
893
930
  }
931
+ if (heldOver(node)) return streamKept(node, chunk)
894
932
  pushChunk(node.id, chunk, false)
895
933
  },
896
934
  taskStderr(node, chunk) {
897
935
  if (discardsOutput) return
898
936
  if (streamsLive(node)) {
899
- streamedSinceBlock = true
900
- if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
901
- taskWrite(chunk)
937
+ streamWrite(chunk)
902
938
  return
903
939
  }
904
940
  const tail = persistentTails.get(node.id)
@@ -907,6 +943,7 @@ export function defaultLogger(
907
943
  else appendTail(tail.err, chunk)
908
944
  return
909
945
  }
946
+ if (heldOver(node)) return streamKept(node, chunk)
910
947
  pushChunk(node.id, chunk, true)
911
948
  },
912
949
  taskComplete(node, outcome) {
@@ -957,23 +994,21 @@ export function defaultLogger(
957
994
  (view.mode === 'focused' && isPrimary(node)) ||
958
995
  (framesTerminal && holdsTerminal(node)))
959
996
  if (framed && streamsLive(node)) {
960
- if (streamMidLine) {
961
- taskWrite('\n')
962
- streamMidLine = false
963
- }
964
997
  emitFrameClose(formatFrameClose(node, outcome, colors))
965
998
  return
966
999
  }
967
1000
  if (framed && output.some((c) => c.text.length > 0)) {
968
1001
  emitBlock(
969
- formatTaskBlock(
970
- node,
971
- outcome,
972
- { output, ...dropped },
973
- colors,
974
- true,
975
- forwardArgs,
976
- links,
1002
+ fenced(
1003
+ formatTaskBlock(
1004
+ node,
1005
+ outcome,
1006
+ { output, ...dropped },
1007
+ colors,
1008
+ true,
1009
+ forwardArgs,
1010
+ links,
1011
+ ),
977
1012
  ),
978
1013
  )
979
1014
  return
@@ -981,7 +1016,7 @@ export function defaultLogger(
981
1016
  // Otherwise a row in the task list, in every view that lists a
982
1017
  // failure: the run exits red, and the row names why.
983
1018
  if (view.mode !== 'none' && view.mode !== 'hash-only') {
984
- emitLine(formatTaskAbortedLine(node, outcome, colors))
1019
+ emitRow(formatTaskAbortedLine(node, outcome, colors))
985
1020
  }
986
1021
  return
987
1022
  }
@@ -995,7 +1030,7 @@ export function defaultLogger(
995
1030
  // A persistent task's outcome arrives at READY; the child
996
1031
  // keeps running until the orchestrator SIGTERMs it at run
997
1032
  // end — pin it so its liveness stays visible.
998
- pinnedPersistent.push(node.id)
1033
+ pinnedPersistent.push(outcome)
999
1034
  // …and capture what it writes from here on. A live-streaming
1000
1035
  // node already owns the terminal, so buffering it would emit
1001
1036
  // its output twice.
@@ -1005,16 +1040,19 @@ export function defaultLogger(
1005
1040
  }
1006
1041
  // Free the task's slot; the longest-waiting queued task
1007
1042
  // (if any) takes it over, keeping lowest-index-first reuse.
1008
- const si = slots.findIndex((s) => s !== null && s.id === node.id)
1009
- if (si >= 0) slots[si] = slotQueue.shift() ?? null
1010
- else {
1011
- const qi = slotQueue.findIndex((s) => s.id === node.id)
1012
- if (qi >= 0) slotQueue.splice(qi, 1)
1043
+ if (!statusDead) {
1044
+ const si = slots.findIndex((s) => s !== null && s.id === node.id)
1045
+ if (si >= 0) slots[si] = slotQueue.shift() ?? null
1046
+ else {
1047
+ const qi = slotQueue.findIndex((s) => s.id === node.id)
1048
+ if (qi >= 0) slotQueue.splice(qi, 1)
1049
+ }
1013
1050
  }
1014
1051
  const bucket = cacheBucketOf(outcome)
1015
1052
  if (bucket !== undefined) {
1016
- if (outcome.hash !== undefined) buckets.set(outcome.hash, bucket)
1017
- buckets.set(node.id, bucket)
1053
+ const entry = { task: node.id, bucket }
1054
+ if (outcome.hash !== undefined) buckets.set(outcome.hash, entry)
1055
+ buckets.set(node.id, entry)
1018
1056
  }
1019
1057
  switch (outcome.status) {
1020
1058
  case 'success':
@@ -1045,10 +1083,6 @@ export function defaultLogger(
1045
1083
  }
1046
1084
  if (isGroupTask(node)) return
1047
1085
  if (holdsTerminal(node) && streamsLive(node) && outcome.status !== 'skipped') {
1048
- if (streamMidLine) {
1049
- taskWrite('\n')
1050
- streamMidLine = false
1051
- }
1052
1086
  emitFrameClose(formatFrameClose(node, outcome, colors))
1053
1087
  return
1054
1088
  }
@@ -1060,17 +1094,16 @@ export function defaultLogger(
1060
1094
  // The word carries the outcome (skips/failures included); the
1061
1095
  // key is what this mode exists to show — absent for a task
1062
1096
  // that never computed one (skipped, uncacheable).
1063
- const key = outcome.hash === undefined ? '' : ` ${outcome.hash}`
1064
- emitLine(`${outcomeWord(outcome)} ${node.id}${key}`)
1097
+ emitLine(formatTaskHashLine(node, outcome, colors))
1065
1098
  return
1066
1099
  }
1067
1100
  case 'errors-only':
1068
1101
  if (outcome.status === 'skipped') {
1069
- emitLine(formatTaskSkippedLine(node, colors, outcome.blockedBy))
1102
+ emitRow(formatTaskSkippedLine(node, colors, outcome))
1070
1103
  return
1071
1104
  }
1072
1105
  if (outcome.status !== 'failed') return
1073
- emitLine(failureRow(node, outcome))
1106
+ emitRow(failureRow(node, outcome))
1074
1107
  deferredFailures.push(
1075
1108
  formatTaskBlock(
1076
1109
  node,
@@ -1090,7 +1123,7 @@ export function defaultLogger(
1090
1123
  // surface in the end-of-run summary.
1091
1124
  if (outcome.status === 'failed') {
1092
1125
  // ◼ row now; the full frame replays at runEnd.
1093
- emitLine(failureRow(node, outcome))
1126
+ emitRow(failureRow(node, outcome))
1094
1127
  deferredFailures.push(
1095
1128
  formatTaskBlock(
1096
1129
  node,
@@ -1103,9 +1136,9 @@ export function defaultLogger(
1103
1136
  ),
1104
1137
  )
1105
1138
  } else if (outcome.status === 'success') {
1106
- emitLine(formatTaskExecutedLine(node, outcome, colors))
1139
+ emitRow(formatTaskExecutedLine(node, outcome, colors, onPath.has(node.id)))
1107
1140
  } else if (outcome.status === 'skipped') {
1108
- emitLine(formatTaskSkippedLine(node, colors, outcome.blockedBy))
1141
+ emitRow(formatTaskSkippedLine(node, colors, outcome))
1109
1142
  }
1110
1143
  return
1111
1144
  case 'focused':
@@ -1114,7 +1147,7 @@ export function defaultLogger(
1114
1147
  // stopped), so no frame-open fired — and a skip has no output,
1115
1148
  // so a one-liner carries everything a frame would.
1116
1149
  if (outcome.status === 'skipped') {
1117
- emitLine(formatTaskSkippedLine(node, colors, outcome.blockedBy))
1150
+ emitRow(formatTaskSkippedLine(node, colors, outcome))
1118
1151
  return
1119
1152
  }
1120
1153
  if (streamsLive(node)) {
@@ -1123,10 +1156,6 @@ export function defaultLogger(
1123
1156
  // this close — full task info for every outcome, cached
1124
1157
  // and up-to-date included (owner: "always full frame
1125
1158
  // for a single task").
1126
- if (streamMidLine) {
1127
- taskWrite('\n')
1128
- streamMidLine = false
1129
- }
1130
1159
  emitFrameClose(formatFrameClose(node, outcome, colors))
1131
1160
  return
1132
1161
  }
@@ -1135,7 +1164,7 @@ export function defaultLogger(
1135
1164
  // to runEnd like everywhere else; everything else emits
1136
1165
  // ONE atomic block from the buffered output.
1137
1166
  if (outcome.status === 'failed') {
1138
- emitLine(failureRow(node, outcome))
1167
+ emitRow(failureRow(node, outcome))
1139
1168
  deferredFailures.push(
1140
1169
  formatTaskBlock(
1141
1170
  node,
@@ -1152,14 +1181,16 @@ export function defaultLogger(
1152
1181
  // forceCommand: a requested task's frame shows `$ cmd`
1153
1182
  // whether it ran or was cached — same frame every run.
1154
1183
  emitBlock(
1155
- formatTaskBlock(
1156
- node,
1157
- outcome,
1158
- { output, ...dropped },
1159
- colors,
1160
- true,
1161
- forwardArgs,
1162
- links,
1184
+ fenced(
1185
+ formatTaskBlock(
1186
+ node,
1187
+ outcome,
1188
+ { output, ...dropped },
1189
+ colors,
1190
+ true,
1191
+ forwardArgs,
1192
+ links,
1193
+ ),
1163
1194
  ),
1164
1195
  )
1165
1196
  return
@@ -1167,9 +1198,9 @@ export function defaultLogger(
1167
1198
  // Dependency-pulled nodes: silent on success; a skip gets its
1168
1199
  // row, failures the ◼ row now and their frame replayed at runEnd.
1169
1200
  if (outcome.status === 'skipped') {
1170
- emitLine(formatTaskSkippedLine(node, colors, outcome.blockedBy))
1201
+ emitRow(formatTaskSkippedLine(node, colors, outcome))
1171
1202
  } else if (outcome.status === 'failed') {
1172
- emitLine(failureRow(node, outcome))
1203
+ emitRow(failureRow(node, outcome))
1173
1204
  deferredFailures.push(
1174
1205
  formatTaskBlock(
1175
1206
  node,
@@ -1191,11 +1222,11 @@ export function defaultLogger(
1191
1222
  // point); misses/failures are always framed. One-liners
1192
1223
  // stay outside ::group:: — there's nothing to collapse.
1193
1224
  if (isHit && output.every((c) => c.text.trim().length === 0)) {
1194
- emitLine(formatTaskHitLine(node, outcome, colors))
1225
+ emitRow(formatTaskHitLine(node, outcome, colors, onPath.has(node.id)))
1195
1226
  return
1196
1227
  }
1197
1228
  if (outcome.status === 'skipped') {
1198
- emitLine(formatTaskSkippedLine(node, colors, outcome.blockedBy))
1229
+ emitRow(formatTaskSkippedLine(node, colors, outcome))
1199
1230
  return
1200
1231
  }
1201
1232
  const block = formatTaskBlock(