@vzn/vx 0.0.646 → 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 (184) hide show
  1. package/README.md +29 -22
  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 +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  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 +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -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/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -12,8 +12,11 @@ import {
12
12
  flakyNote,
13
13
  pathNote,
14
14
  whereNote,
15
+ formatTaskHashLine,
15
16
  formatTaskHitLine,
16
17
  formatTaskSkippedLine,
18
+ leavesStyle,
19
+ RESET,
17
20
  type OutputChunk,
18
21
  } from './framed-output.js'
19
22
  import {
@@ -29,8 +32,8 @@ import {
29
32
  formatSummarySection,
30
33
  neverStarted,
31
34
  ranUnsaved,
32
- type CacheBucket,
33
35
  type RunContext,
36
+ type TaskBucket,
34
37
  } from './summary.js'
35
38
  import { isGroupTask } from '../graph/index.js'
36
39
  import {
@@ -41,7 +44,9 @@ import {
41
44
  tailText,
42
45
  type Tail,
43
46
  detectColors,
47
+ envFlagOn,
44
48
  type ColorSupport,
49
+ ui,
45
50
  } from '../util/index.js'
46
51
  import { isCacheHit } from '../graph/index.js'
47
52
  import { failedLabel, outcomeWord, ranNoCache } from './events.js'
@@ -167,11 +172,6 @@ export interface OutputView {
167
172
  ci?: boolean
168
173
  }
169
174
 
170
- /** CI=0 / CI=false count as "not CI" — several vendors use CI=true. */
171
- function truthyEnv(v: string | undefined): boolean {
172
- return v !== undefined && v !== '' && v !== '0' && v !== 'false'
173
- }
174
-
175
175
  // ── GitHub Actions workflow-command escaping ────────────────────────
176
176
  // The runner parses `::`-prefixed LINES out of the log, so anything
177
177
  // interpolated into one has to be encoded or it changes the command's shape.
@@ -214,8 +214,8 @@ export function resolveOutputView(
214
214
  },
215
215
  env: Record<string, string | undefined> = process.env,
216
216
  ): OutputView {
217
- const ci = truthyEnv(env['CI'])
218
- const gha = truthyEnv(env['GITHUB_ACTIONS'])
217
+ const ci = envFlagOn(env['CI'])
218
+ const gha = envFlagOn(env['GITHUB_ACTIONS'])
219
219
  const mk = (mode: OutputView['mode']): OutputView => ({
220
220
  mode,
221
221
  ...(gha ? { gha: true } : {}),
@@ -234,7 +234,7 @@ export function resolveOutputView(
234
234
  const FORECAST_AFTER_MS = 1000
235
235
  /** A run shorter than this ends without a desktop notification. */
236
236
  const NOTIFY_AFTER_MS = 10_000
237
- // 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).
238
238
  const STEP_SHOWN_MS = 150
239
239
 
240
240
  export function defaultLogger(
@@ -347,16 +347,15 @@ export function defaultLogger(
347
347
  let forecastFn: Forecast | undefined
348
348
  let graphForecast: (() => Forecast) | undefined
349
349
  let forecastTimer: ReturnType<typeof setTimeout> | null = null
350
- let etaMs: number | undefined
350
+ // When the forecast says the run ends; past it, the region counts up.
351
+ let deadlineMs: number | undefined
351
352
  let critical: string | undefined
352
353
  // Tasks the region showed on the critical path: their finished rows say so too.
353
354
  const onPath = new Set<string>()
354
355
  let blockedCount = 0
355
356
  let expected: ReadonlyMap<string, number> | undefined
356
357
  let blocked: readonly BlockedTask[] | undefined
357
- // The blocked task each idle worker row holds, by slot: a row keeps its
358
- // task until it starts, in that row, or leaves the list, so nothing hops.
359
- const heldRows: (string | null)[] = []
358
+ let blockedBy: ReadonlyMap<string, number> | undefined
360
359
  let etaAtMs = 0
361
360
  const signals = writer.enabled
362
361
  ? terminalSignals(opts.env ?? process.env)
@@ -365,18 +364,11 @@ export function defaultLogger(
365
364
  let progressShown = -1
366
365
  let progressRed = false
367
366
 
368
- // Every interactive view renders the fixed-height worker region:
369
- // one row per worker slot so a task's name never moves while it
370
- // runs (the display derives from the stable worker set, not the
371
- // churning task set). Sized at runStart from concurrency, capped at
372
- // 10 rows; excess running tasks queue for a freed slot, each shown
373
- // in its own row after the slots. Focused flow keeps its lifecycle:
374
- // 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.
375
371
  let slots: (WorkerSlot | null)[] = []
376
- // Rows shown: the most workers busy at once so far. An idle row past it
377
- // was noise (a 10-core machine drew 9 on a one-chain run); it never
378
- // shrinks, so a row never jumps.
379
- let shownSlots = 0
380
372
  const slotQueue: WorkerSlot[] = []
381
373
  let succeeded = 0
382
374
  // Executed with no `cache` block: never a miss, counted apart.
@@ -388,12 +380,12 @@ export function defaultLogger(
388
380
  let notSaved = 0
389
381
  let skippedCount = 0
390
382
  // Each finished task's cache bucket by its key: its remote traffic moves it.
391
- const buckets = new Map<string, CacheBucket>()
383
+ const buckets = new Map<string, TaskBucket>()
392
384
  // The graph is done and vx's own closing steps run: the region stays,
393
- // 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).
394
386
  let tail = false
395
387
  // The run was stopped: the region stays, its running rows say
396
- // `aborting`, and nothing is forecast or held (M, 2026-10-17).
388
+ // `aborting`, and nothing is forecast or held (2026-10-17).
397
389
  let stopping = false
398
390
  // Counted live once the run is stopping, as the footer counts them.
399
391
  let stopped = 0
@@ -415,7 +407,8 @@ export function defaultLogger(
415
407
  // deferred to runEnd (owner: '✗ line, continue, all full frames at
416
408
  // the end'). 'full'/CI keeps frames inline.
417
409
  const deferredFailures: string[] = []
418
- const pinnedPersistent: string[] = []
410
+ // Each ready server's outcome: one whose child has exited (`serverExit`) is no longer pinned.
411
+ const pinnedPersistent: TaskOutcome[] = []
419
412
  let flushedFailures = false
420
413
  // A persistent task (dev server, watcher, daemon) is the one task kind
421
414
  // nothing bounds: a one-shot command's output ends when it exits, but a
@@ -472,10 +465,27 @@ export function defaultLogger(
472
465
  }
473
466
  const printsKept = (tail: { outcome?: TaskOutcome }): boolean =>
474
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'
475
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.
476
480
  const refresh = (forced: boolean): void => {
477
481
  if (statusDead) return
478
- 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
479
489
  const now = Date.now()
480
490
  // A task its executor still holds has not begun: its clock starts at
481
491
  // the first frame a machine runs it.
@@ -486,16 +496,13 @@ export function defaultLogger(
486
496
  x.waiting = where(x.id) === null
487
497
  if (x.waiting) x.startedMs = now
488
498
  }
489
- // A free row shows what it holds from the first frame: the graph
490
- // knows, without history (M, 2026-10-17).
491
- if (forecastFn === undefined && graphForecast !== undefined && !tail && !stopping)
492
- for (let i = 0; i < shownSlots; i++)
493
- if (slots[i] === null) {
494
- forecastFn = graphForecast()
495
- graphForecast = undefined
496
- force = true
497
- break
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
+ }
499
506
  // Twice a second at most: one pass over the unfinished graph.
500
507
  if (forecastFn !== undefined && !tail && !stopping && (force || now - etaAtMs >= 500)) {
501
508
  etaAtMs = now
@@ -503,16 +510,23 @@ export function defaultLogger(
503
510
  for (const s of slots) if (s !== null) running.set(s.id, s.startedMs)
504
511
  for (const s of slotQueue) running.set(s.id, s.startedMs)
505
512
  const f = forecastFn(finished, running, now)
506
- 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)
507
519
  critical = f.critical
508
520
  blockedCount = f.waiting
509
521
  expected = f.expected
510
522
  blocked = f.blocked
523
+ blockedBy = f.blockedBy
511
524
  }
525
+ const etaMs = deadlineMs === undefined ? undefined : deadlineMs - now
512
526
  if (signals.progress && total > 0) {
513
527
  const elapsed = now - startedAtMs
514
528
  const pct = Math.floor(
515
- etaMs !== undefined && elapsed + etaMs > 0
529
+ etaMs !== undefined && etaMs > 0
516
530
  ? (elapsed / (elapsed + etaMs)) * 100
517
531
  : (done / total) * 100,
518
532
  )
@@ -532,7 +546,7 @@ export function defaultLogger(
532
546
  if (x.waiting === true) scheduled++
533
547
  else running++
534
548
  }
535
- if (critical !== undefined && running > 1) onPath.add(critical)
549
+ if (critical !== undefined && running > 0) onPath.add(critical)
536
550
  // The live summary IS the final summary's section, built from the
537
551
  // same formatter so the region visually becomes the printout.
538
552
  const summaryLines =
@@ -557,7 +571,11 @@ export function defaultLogger(
557
571
  ...(running > 0 ? { running } : {}),
558
572
  ...(scheduled > 0 ? { scheduled } : {}),
559
573
  ...(blockedCount > 0 && !tail && !stopping ? { blocked: blockedCount } : {}),
560
- ...(etaMs !== undefined && !tail && !stopping ? { etaMs } : {}),
574
+ ...(etaMs !== undefined && !tail && !stopping
575
+ ? etaMs > 0
576
+ ? { etaMs }
577
+ : { overMs: -etaMs }
578
+ : {}),
561
579
  buckets,
562
580
  spread:
563
581
  spreadCount > 0
@@ -568,60 +586,30 @@ export function defaultLogger(
568
586
  colors,
569
587
  runContext,
570
588
  )
571
- const held = blocked === undefined || stopping ? undefined : holdRows(blocked)
572
589
  const shownSteps: WorkerSlot[] = []
573
590
  for (const [id, startedMs] of steps)
574
591
  if (now - startedMs >= STEP_SHOWN_MS) shownSteps.push({ id, startedMs })
575
- writer.setRegion(
576
- formatStatusRegion(
577
- {
578
- pinnedPersistent,
579
- // Every running task has a row: one beyond the slots follows them (M, 2026-10-21).
580
- slots: [...slots.slice(0, shownSlots), ...slotQueue],
581
- ...(held !== undefined ? { blocked: held } : {}),
582
- ...(stopping ? { stopping } : {}),
583
- ...(shownSteps.length > 0 ? { steps: shownSteps } : {}),
584
- nowMs: Date.now(),
585
- summaryLines,
586
- ...(out.rows !== undefined && out.rows > 0 ? { rows: out.rows } : {}),
587
- ...(runContext?.whereRunning !== undefined ? { where: runContext.whereRunning } : {}),
588
- ...(expected !== undefined ? { expected } : {}),
589
- ...(critical !== undefined ? { critical } : {}),
590
- },
591
- colors,
592
- ),
593
- { 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,
594
611
  )
595
612
  }
596
- // Each idle worker row holds a blocked task, in scheduler order. A row
597
- // with none left to hold leaves the region (M, 2026-10-15): blocked tasks
598
- // only dwindle, so it is never needed again, and the rows below close up.
599
- const holdRows = (list: readonly BlockedTask[]): (BlockedTask | null)[] => {
600
- const started = new Set<string>()
601
- for (const s of slots) if (s !== null) started.add(s.id)
602
- for (const s of slotQueue) started.add(s.id)
603
- const open = new Map<string, BlockedTask>()
604
- for (const b of list) if (!started.has(b.id)) open.set(b.id, b)
605
- const rows: (BlockedTask | null)[] = []
606
- for (let i = 0; i < shownSlots; i++) {
607
- const kept = slots[i] === null ? open.get(heldRows[i] ?? '') : undefined
608
- rows.push(kept ?? null)
609
- if (kept !== undefined) open.delete(kept.id)
610
- }
611
- const rest = open.values()
612
- for (let i = 0; i < shownSlots; i++)
613
- if (slots[i] === null && rows[i] === null) rows[i] = rest.next().value ?? null
614
- for (let i = shownSlots - 1; i >= 0; i--) {
615
- if (slots[i] !== null || rows[i] !== null) continue
616
- rows.splice(i, 1)
617
- slots.splice(i, 1)
618
- slots.push(null)
619
- shownSlots--
620
- }
621
- heldRows.length = 0
622
- for (const r of rows) heldRows.push(r?.id ?? null)
623
- return rows
624
- }
625
613
  const tick = (): void => {
626
614
  if (!writer.enabled || statusDead || ticker !== null) return
627
615
  // Keeps the per-worker elapsed time ticking between task events
@@ -652,14 +640,36 @@ export function defaultLogger(
652
640
  }
653
641
 
654
642
  // The task log opens with one blank line before its first row, on a
655
- // 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
656
644
  // no task row (`status`). Without a footer, the caller frames the log
657
645
  // (`vx watch` opens each cycle with its own line).
658
646
  let logOpened = opts.footer === false
659
647
  // The log ends on a blank line (a block's own): a blank status line
660
648
  // after it (the footer's) would make two; one blank line apart is the rule.
661
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
+ }
662
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 => {
663
673
  if (chunk.length > 0) endsBlank = chunk.endsWith('\n\n')
664
674
  if (!logOpened && chunk.length > 0) {
665
675
  logOpened = true
@@ -673,7 +683,7 @@ export function defaultLogger(
673
683
  lineEmitted = true
674
684
  }
675
685
  // The list's header, once over its first row, where the live region
676
- // heads its own rows the same way (M, 2026-10-21).
686
+ // heads its own rows the same way (2026-10-21).
677
687
  let headed = !writer.enabled
678
688
  const emitRow = (line: string): void => {
679
689
  if (!headed) {
@@ -686,6 +696,7 @@ export function defaultLogger(
686
696
  // write so a stray newline doesn't sneak into the output.
687
697
  const emitBlock = (block: string): void => {
688
698
  if (block.length === 0) return
699
+ endStreamLine()
689
700
  const lead = lineEmitted || streamedSinceBlock ? '\n' : ''
690
701
  taskWrite(`${lead}${block}\n`)
691
702
  lineEmitted = false
@@ -739,21 +750,15 @@ export function defaultLogger(
739
750
  flushKept()
740
751
  // A live-streamed server keeps writing after its frame closes, so a
741
752
  // summary row or "exited with code" can follow its partial line.
742
- if (streamMidLine) {
743
- writer.write('\n')
744
- streamMidLine = false
745
- }
753
+ endStreamLine()
746
754
  if (line === '' && endsBlank) return
747
755
  endsBlank = line === ''
748
756
  writer.write(`${line}\n`)
749
757
  },
750
758
  warn(line) {
751
759
  flushKept()
752
- if (streamMidLine) {
753
- writer.write('\n')
754
- streamMidLine = false
755
- }
756
- writer.writeTo(errOut, `${line}\n`)
760
+ endStreamLine()
761
+ writer.writeTo(errOut, `${ui.tagged('warn', line, colors)}\n`)
757
762
  },
758
763
  runStart(info) {
759
764
  total = info.total
@@ -761,9 +766,7 @@ export function defaultLogger(
761
766
  runContext = info.context
762
767
  startedAtMs = Date.now()
763
768
  started = true
764
- const cap = Math.max(1, Math.min(info.concurrency ?? 10, 10))
765
- slots = Array.from({ length: cap }, () => null)
766
- shownSlots = 0
769
+ slots = Array.from({ length: Math.max(1, info.concurrency ?? 10) }, () => null)
767
770
  tick()
768
771
  refresh(true)
769
772
  },
@@ -775,7 +778,12 @@ export function defaultLogger(
775
778
  stepEnd(id, { durationMs, ok }) {
776
779
  steps.delete(id)
777
780
  if (durationMs < STEP_SHOWN_MS) return
778
- if (ok ? view.mode !== 'none' && view.mode !== 'errors-only' : view.mode !== 'none')
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
+ )
779
787
  emitRow(formatStepLine(id, durationMs, ok, colors))
780
788
  refresh(true)
781
789
  },
@@ -783,7 +791,7 @@ export function defaultLogger(
783
791
  if (stopping) return
784
792
  stopping = true
785
793
  blocked = undefined
786
- heldRows.length = 0
794
+ blockedBy = undefined
787
795
  refresh(true)
788
796
  },
789
797
  runDone() {
@@ -797,34 +805,25 @@ export function defaultLogger(
797
805
  // What is coalesced lands before the task writes past it.
798
806
  writer.settle()
799
807
  }
800
- // Focused flow: the status line exists for the dependency
801
- // phase only. The moment a requested node starts streaming,
802
- // 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.
803
812
  if (streamsLive(node)) {
804
- killStatus()
805
813
  // Open the live frame: full task info even when the command
806
814
  // streams nothing (or the hit replays nothing).
807
815
  emitLine(formatFrameOpen(node, colors, forwardArgs))
808
- return
809
- }
810
- // Bound a persistent task's output from its FIRST chunk. Waiting for
811
- // its outcome would leave a never-ready one uncapped forever.
812
- 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.
813
819
  persistentTails.set(node.id, { node, out: createTail(), err: createTail() })
814
820
  }
821
+ // The rows are the status region's; with none, nothing reads them.
822
+ if (statusDead) return
815
823
  const slot: WorkerSlot = { id: node.id, startedMs: Date.now() }
816
- // A blocked task starts in the row that held it; another takes a free
817
- // row, else the last held one, whose task moves to the next free row.
818
- const own = heldRows.indexOf(node.id)
819
- let free = own >= 0 && slots[own] === null ? own : -1
820
- if (free < 0)
821
- free = slots.findIndex((s, i) => s === null && i < shownSlots && heldRows[i] == null)
822
- if (free < 0) free = slots.findLastIndex((s, i) => s === null && i < shownSlots)
823
- if (free < 0) free = slots.indexOf(null)
824
- if (free >= 0) {
825
- slots[free] = slot
826
- shownSlots = Math.max(shownSlots, free + 1)
827
- } else slotQueue.push(slot)
824
+ const free = slots.indexOf(null)
825
+ if (free >= 0) slots[free] = slot
826
+ else slotQueue.push(slot)
828
827
  refresh(true)
829
828
  },
830
829
  holdStatus: () => writer.hold(),
@@ -861,7 +860,7 @@ export function defaultLogger(
861
860
  tail = true
862
861
  slots = []
863
862
  blocked = undefined
864
- shownSlots = 0
863
+ blockedBy = undefined
865
864
  slotQueue.length = 0
866
865
  refresh(true)
867
866
  } else if (!tail) killStatus()
@@ -920,9 +919,7 @@ export function defaultLogger(
920
919
  taskStdout(node, chunk) {
921
920
  if (discardsOutput) return
922
921
  if (streamsLive(node)) {
923
- streamedSinceBlock = true
924
- if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
925
- taskWrite(chunk)
922
+ streamWrite(chunk)
926
923
  return
927
924
  }
928
925
  const tail = persistentTails.get(node.id)
@@ -931,14 +928,13 @@ export function defaultLogger(
931
928
  else appendTail(tail.out, chunk)
932
929
  return
933
930
  }
931
+ if (heldOver(node)) return streamKept(node, chunk)
934
932
  pushChunk(node.id, chunk, false)
935
933
  },
936
934
  taskStderr(node, chunk) {
937
935
  if (discardsOutput) return
938
936
  if (streamsLive(node)) {
939
- streamedSinceBlock = true
940
- if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
941
- taskWrite(chunk)
937
+ streamWrite(chunk)
942
938
  return
943
939
  }
944
940
  const tail = persistentTails.get(node.id)
@@ -947,6 +943,7 @@ export function defaultLogger(
947
943
  else appendTail(tail.err, chunk)
948
944
  return
949
945
  }
946
+ if (heldOver(node)) return streamKept(node, chunk)
950
947
  pushChunk(node.id, chunk, true)
951
948
  },
952
949
  taskComplete(node, outcome) {
@@ -997,23 +994,21 @@ export function defaultLogger(
997
994
  (view.mode === 'focused' && isPrimary(node)) ||
998
995
  (framesTerminal && holdsTerminal(node)))
999
996
  if (framed && streamsLive(node)) {
1000
- if (streamMidLine) {
1001
- taskWrite('\n')
1002
- streamMidLine = false
1003
- }
1004
997
  emitFrameClose(formatFrameClose(node, outcome, colors))
1005
998
  return
1006
999
  }
1007
1000
  if (framed && output.some((c) => c.text.length > 0)) {
1008
1001
  emitBlock(
1009
- formatTaskBlock(
1010
- node,
1011
- outcome,
1012
- { output, ...dropped },
1013
- colors,
1014
- true,
1015
- forwardArgs,
1016
- links,
1002
+ fenced(
1003
+ formatTaskBlock(
1004
+ node,
1005
+ outcome,
1006
+ { output, ...dropped },
1007
+ colors,
1008
+ true,
1009
+ forwardArgs,
1010
+ links,
1011
+ ),
1017
1012
  ),
1018
1013
  )
1019
1014
  return
@@ -1035,7 +1030,7 @@ export function defaultLogger(
1035
1030
  // A persistent task's outcome arrives at READY; the child
1036
1031
  // keeps running until the orchestrator SIGTERMs it at run
1037
1032
  // end — pin it so its liveness stays visible.
1038
- pinnedPersistent.push(node.id)
1033
+ pinnedPersistent.push(outcome)
1039
1034
  // …and capture what it writes from here on. A live-streaming
1040
1035
  // node already owns the terminal, so buffering it would emit
1041
1036
  // its output twice.
@@ -1045,16 +1040,19 @@ export function defaultLogger(
1045
1040
  }
1046
1041
  // Free the task's slot; the longest-waiting queued task
1047
1042
  // (if any) takes it over, keeping lowest-index-first reuse.
1048
- const si = slots.findIndex((s) => s !== null && s.id === node.id)
1049
- if (si >= 0) slots[si] = slotQueue.shift() ?? null
1050
- else {
1051
- const qi = slotQueue.findIndex((s) => s.id === node.id)
1052
- 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
+ }
1053
1050
  }
1054
1051
  const bucket = cacheBucketOf(outcome)
1055
1052
  if (bucket !== undefined) {
1056
- if (outcome.hash !== undefined) buckets.set(outcome.hash, bucket)
1057
- 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)
1058
1056
  }
1059
1057
  switch (outcome.status) {
1060
1058
  case 'success':
@@ -1085,10 +1083,6 @@ export function defaultLogger(
1085
1083
  }
1086
1084
  if (isGroupTask(node)) return
1087
1085
  if (holdsTerminal(node) && streamsLive(node) && outcome.status !== 'skipped') {
1088
- if (streamMidLine) {
1089
- taskWrite('\n')
1090
- streamMidLine = false
1091
- }
1092
1086
  emitFrameClose(formatFrameClose(node, outcome, colors))
1093
1087
  return
1094
1088
  }
@@ -1100,13 +1094,12 @@ export function defaultLogger(
1100
1094
  // The word carries the outcome (skips/failures included); the
1101
1095
  // key is what this mode exists to show — absent for a task
1102
1096
  // that never computed one (skipped, uncacheable).
1103
- const key = outcome.hash === undefined ? '' : ` ${outcome.hash}`
1104
- emitLine(`${outcomeWord(outcome)} ${node.id}${key}`)
1097
+ emitLine(formatTaskHashLine(node, outcome, colors))
1105
1098
  return
1106
1099
  }
1107
1100
  case 'errors-only':
1108
1101
  if (outcome.status === 'skipped') {
1109
- emitRow(formatTaskSkippedLine(node, colors, outcome.blockedBy))
1102
+ emitRow(formatTaskSkippedLine(node, colors, outcome))
1110
1103
  return
1111
1104
  }
1112
1105
  if (outcome.status !== 'failed') return
@@ -1145,7 +1138,7 @@ export function defaultLogger(
1145
1138
  } else if (outcome.status === 'success') {
1146
1139
  emitRow(formatTaskExecutedLine(node, outcome, colors, onPath.has(node.id)))
1147
1140
  } else if (outcome.status === 'skipped') {
1148
- emitRow(formatTaskSkippedLine(node, colors, outcome.blockedBy))
1141
+ emitRow(formatTaskSkippedLine(node, colors, outcome))
1149
1142
  }
1150
1143
  return
1151
1144
  case 'focused':
@@ -1154,7 +1147,7 @@ export function defaultLogger(
1154
1147
  // stopped), so no frame-open fired — and a skip has no output,
1155
1148
  // so a one-liner carries everything a frame would.
1156
1149
  if (outcome.status === 'skipped') {
1157
- emitRow(formatTaskSkippedLine(node, colors, outcome.blockedBy))
1150
+ emitRow(formatTaskSkippedLine(node, colors, outcome))
1158
1151
  return
1159
1152
  }
1160
1153
  if (streamsLive(node)) {
@@ -1163,10 +1156,6 @@ export function defaultLogger(
1163
1156
  // this close — full task info for every outcome, cached
1164
1157
  // and up-to-date included (owner: "always full frame
1165
1158
  // for a single task").
1166
- if (streamMidLine) {
1167
- taskWrite('\n')
1168
- streamMidLine = false
1169
- }
1170
1159
  emitFrameClose(formatFrameClose(node, outcome, colors))
1171
1160
  return
1172
1161
  }
@@ -1192,14 +1181,16 @@ export function defaultLogger(
1192
1181
  // forceCommand: a requested task's frame shows `$ cmd`
1193
1182
  // whether it ran or was cached — same frame every run.
1194
1183
  emitBlock(
1195
- formatTaskBlock(
1196
- node,
1197
- outcome,
1198
- { output, ...dropped },
1199
- colors,
1200
- true,
1201
- forwardArgs,
1202
- links,
1184
+ fenced(
1185
+ formatTaskBlock(
1186
+ node,
1187
+ outcome,
1188
+ { output, ...dropped },
1189
+ colors,
1190
+ true,
1191
+ forwardArgs,
1192
+ links,
1193
+ ),
1203
1194
  ),
1204
1195
  )
1205
1196
  return
@@ -1207,7 +1198,7 @@ export function defaultLogger(
1207
1198
  // Dependency-pulled nodes: silent on success; a skip gets its
1208
1199
  // row, failures the ◼ row now and their frame replayed at runEnd.
1209
1200
  if (outcome.status === 'skipped') {
1210
- emitRow(formatTaskSkippedLine(node, colors, outcome.blockedBy))
1201
+ emitRow(formatTaskSkippedLine(node, colors, outcome))
1211
1202
  } else if (outcome.status === 'failed') {
1212
1203
  emitRow(failureRow(node, outcome))
1213
1204
  deferredFailures.push(
@@ -1235,7 +1226,7 @@ export function defaultLogger(
1235
1226
  return
1236
1227
  }
1237
1228
  if (outcome.status === 'skipped') {
1238
- emitRow(formatTaskSkippedLine(node, colors, outcome.blockedBy))
1229
+ emitRow(formatTaskSkippedLine(node, colors, outcome))
1239
1230
  return
1240
1231
  }
1241
1232
  const block = formatTaskBlock(