@vzn/vx 0.0.651 → 0.0.653

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 (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. package/src/workspace/workspace.ts +190 -30
@@ -6,7 +6,7 @@
6
6
  import type { TaskOutcome } from '../graph/index.js'
7
7
  import { tallyOutcomes } from './tally.js'
8
8
  import { outcomeLabel, projectOutcome, ranNoCache } from './events.js'
9
- import { isGroupTask } from '../graph/index.js'
9
+ import { isGroupTask, isKeyFailed, markKeyFailed } from '../graph/index.js'
10
10
  import { formatBytes, formatElapsed } from '../util/index.js'
11
11
  import * as ui from '../ui/index.js'
12
12
  import {
@@ -62,12 +62,17 @@ export interface SummaryStats {
62
62
  restoredRemote: number
63
63
  /** Ran here and saved to the cache: the legend's `↑`. */
64
64
  saved?: number
65
- /** Ran here with a `cache` block, succeeded, and saved nothing. */
65
+ /** Ran here with `cache: true`, succeeded, and saved nothing. */
66
66
  notSaved?: number
67
- /** Tasks that executed (success + failed) with a `cache` block — the misses. */
67
+ /** Tasks that executed (success + failed) with `cache: true` — the misses. */
68
68
  miss: number
69
- /** Executed with no `cache` block: never consulted the cache, not a miss. */
69
+ /** Executed without `cache: true`: never consulted the cache, not a miss. */
70
70
  noCache?: number
71
+ /**
72
+ * Failed hits whose restore threw: in no cache count, neither a miss nor
73
+ * a hit. They fill the cache bar as `failed` colours the tasks bar.
74
+ */
75
+ restoreFailed?: number
71
76
  /** Still to run (live section only; 0 in the final summary) — renders
72
77
  * as a gray ▱ remainder so the live meters FILL toward the final. */
73
78
  left?: number
@@ -114,6 +119,8 @@ export function cacheBucketOf(o: TaskOutcome): CacheBucket | undefined {
114
119
  ? 'notSaved'
115
120
  : 'miss'
116
121
  case 'failed':
122
+ // A hit whose restore failed is in no bucket: not a miss, not a hit.
123
+ if (o.restoreFailed === true) return undefined
117
124
  return ranNoCache(o) ? 'noCache' : 'miss'
118
125
  case 'cache-hit':
119
126
  return o.restored === false ? 'upToDate' : 'restoredLocal'
@@ -237,6 +244,7 @@ export function formatSummarySection(
237
244
  const hits = stats.upToDate + stats.restoredLocal + stats.restoredRemote
238
245
  const miss = stats.miss
239
246
  const noCache = stats.noCache ?? 0
247
+ const restoreFailed = stats.restoreFailed ?? 0
240
248
  const left = stats.left ?? 0
241
249
  const aborted = stats.aborted ?? 0
242
250
  const notRun = stats.notRun ?? 0
@@ -264,7 +272,7 @@ export function formatSummarySection(
264
272
  if (stats.skipped > 0) taskParts.push(paint('warn', `${stats.skipped} skipped`, colors))
265
273
  if (aborted > 0) taskParts.push(paint('aborted', `${aborted} aborted`, colors))
266
274
  if (notRun > 0) taskParts.push(paint('notRun', `${notRun} not run`, colors))
267
- if (running > 0) taskParts.push(paint('info', `${running} running`, colors))
275
+ if (running > 0) taskParts.push(paint('warn', `${running} running`, colors))
268
276
  if (scheduled > 0) taskParts.push(dim(`${scheduled} scheduled`))
269
277
  if (blocked > 0) taskParts.push(dim(`${blocked} blocked`))
270
278
  // What is not done yet, running included: the count the bar's unfilled part shows.
@@ -277,7 +285,7 @@ export function formatSummarySection(
277
285
  { n: stats.skipped, color: 'warn' },
278
286
  { n: aborted, color: 'aborted' },
279
287
  { n: notRun, color: 'notRun' },
280
- { n: running, color: 'info' },
288
+ { n: running, color: 'warn' },
281
289
  { n: scheduled, color: 'muted', dim: true },
282
290
  { n: blocked, color: 'muted', dim: true },
283
291
  remainder,
@@ -329,7 +337,7 @@ export function formatSummarySection(
329
337
 
330
338
  // Cache meter + numbers: per layer when the run context names its
331
339
  // layers, else miss · no-cache · up-to-date · local · remote.
332
- if (miss + noCache + hits + left + stats.skipped > 0) {
340
+ if (miss + noCache + hits + left + stats.skipped + restoreFailed > 0) {
333
341
  const layers = context?.cache
334
342
  const b = cacheBuckets(stats, layers)
335
343
  const cacheBar = segmentBar(
@@ -344,6 +352,7 @@ export function formatSummarySection(
344
352
  { n: b.uploaded, color: 'remote' },
345
353
  { n: b.uploading, color: 'info' },
346
354
  { n: b.remoteFailed, color: 'error' },
355
+ { n: restoreFailed, color: 'error' },
347
356
  { n: stats.skipped, color: 'warn' },
348
357
  remainder,
349
358
  ],
@@ -395,9 +404,9 @@ export function formatSummarySection(
395
404
  if (stats.restoredRemote > 0)
396
405
  cacheParts.push(paint('remote', `${stats.restoredRemote} remote`, colors))
397
406
  }
398
- // Skipped tasks ride the bar, yellow like the tasks meter, so the two
399
- // bars fill alike; their count is the tasks legend's alone (the footer
400
- // never says a fact twice, 2026-10-14).
407
+ // Skipped tasks ride the bar, yellow like the tasks meter, and failed
408
+ // restores red, so the two bars fill alike; their counts are the tasks
409
+ // legend's alone (the footer never says a fact twice, 2026-10-14).
401
410
  lines.push(row('cache', cacheBar))
402
411
  if (cacheParts.length > 0) lines.push(legend(cacheParts))
403
412
  }
@@ -478,7 +487,9 @@ export function formatSummarySection(
478
487
  }
479
488
  // The Summary panel; the final one names the vx that ran it (the live
480
489
  // one has no context yet).
490
+ // Two blank lines: the Summary stands apart from the task list above it.
481
491
  return [
492
+ '',
482
493
  '',
483
494
  panelTop(
484
495
  'Summary',
@@ -539,7 +550,9 @@ export function formatOutcomeTable(
539
550
  .filter((o) => !isGroupTask(o.node))
540
551
  .map((o) => {
541
552
  const v = projectOutcome(o)
542
- const status = neverStarted(o) ? 'not run' : outcomeLabel(v)
553
+ const status = neverStarted(o)
554
+ ? 'not run'
555
+ : outcomeLabel(isKeyFailed(o) ? markKeyFailed(v) : v)
543
556
  return { o, task: v.taskId, status, duration: `${v.durationMs}ms` }
544
557
  })
545
558
  if (rows.length === 0) return []
@@ -582,9 +595,19 @@ function tableStatusColor(o: TaskOutcome): Style {
582
595
 
583
596
  // Local byte formatter — the orchestrator can't import cli/format.ts
584
597
  // (module boundary), and the footer only needs one compact form.
585
- /** Ran here with a `cache` block, succeeded, and its result was not saved. */
598
+ /**
599
+ * Ran here with `cache: true`, succeeded, and its result was not saved.
600
+ * Outputs left in the executor's store (`outputs: 'deferred'`) ran
601
+ * elsewhere, whether or not the executor names a `where`.
602
+ */
586
603
  export function ranUnsaved(o: TaskOutcome): boolean {
587
- return o.status === 'success' && !ranNoCache(o) && o.saved !== true && o.where === undefined
604
+ return (
605
+ o.status === 'success' &&
606
+ !ranNoCache(o) &&
607
+ o.saved !== true &&
608
+ o.where === undefined &&
609
+ o.outputs !== 'deferred'
610
+ )
588
611
  }
589
612
 
590
613
  export function formatRunSummary(
@@ -593,17 +616,29 @@ export function formatRunSummary(
593
616
  colors: ColorSupport = NO_COLOR,
594
617
  context?: RunContext,
595
618
  ): string[] {
596
- // Counted here, not in the Tally: a task with no `cache` block never
619
+ // Counted here, not in the Tally: a task without `cache: true` never
597
620
  // consulted the cache, so it is not a miss.
598
621
  const noCache = outcomes.filter(
599
622
  (o) =>
600
623
  !isGroupTask(o.node) && (o.status === 'success' || o.status === 'failed') && ranNoCache(o),
601
624
  ).length
625
+ const restoreFailed = outcomes.filter(
626
+ (o) => !isGroupTask(o.node) && o.status === 'failed' && o.restoreFailed === true,
627
+ ).length
628
+ // A task with no key asked no cache and ran nothing: not a miss.
629
+ const keyFailed = outcomes.filter(
630
+ (o) => !isGroupTask(o.node) && o.status === 'failed' && isKeyFailed(o),
631
+ ).length
602
632
  const t = tallyOutcomes(outcomes)
603
633
  // Spread over executed tasks only — `success`/`failed` statuses ran
604
- // a command; cache-hit statuses replayed one.
634
+ // a command; cache-hit statuses replayed one, and a failed restore ran none.
605
635
  const durations = outcomes
606
- .filter((o) => (o.status === 'success' || o.status === 'failed') && !isGroupTask(o.node))
636
+ .filter(
637
+ (o) =>
638
+ (o.status === 'success' ||
639
+ (o.status === 'failed' && o.restoreFailed !== true && !isKeyFailed(o))) &&
640
+ !isGroupTask(o.node),
641
+ )
607
642
  .map((o) => o.durationMs)
608
643
  const heldOutcomes = outcomes.filter((o) => o.admissionHeldMs !== undefined)
609
644
  const notRun = outcomes.filter((o) => neverStarted(o) && !isGroupTask(o.node)).length
@@ -623,8 +658,15 @@ export function formatRunSummary(
623
658
  restoredRemote: t.restoredRemote,
624
659
  saved: outcomes.filter((o) => o.saved === true && !isGroupTask(o.node)).length,
625
660
  notSaved: outcomes.filter((o) => !isGroupTask(o.node) && ranUnsaved(o)).length,
626
- miss: t.total - t.skipped - (t.cachedLocal + t.cachedRemote) - noCache,
661
+ miss:
662
+ t.total -
663
+ t.skipped -
664
+ (t.cachedLocal + t.cachedRemote) -
665
+ noCache -
666
+ restoreFailed -
667
+ keyFailed,
627
668
  noCache,
669
+ ...(restoreFailed > 0 ? { restoreFailed } : {}),
628
670
  spread:
629
671
  durations.length > 0
630
672
  ? {
@@ -643,7 +685,8 @@ export function formatRunSummary(
643
685
  }
644
686
  : {}),
645
687
  ...(emptyGroups.length > 0 ? { emptyGroups } : {}),
646
- buckets: bucketsOf(outcomes),
688
+ // Read only to move a task by its remote traffic.
689
+ ...(context?.cache?.remote === true ? { buckets: bucketsOf(outcomes) } : {}),
647
690
  },
648
691
  totalMs,
649
692
  colors,
package/src/settings.ts CHANGED
@@ -118,7 +118,7 @@ export const SETTINGS: readonly Setting[] = [
118
118
  {
119
119
  path: 'cache.dir',
120
120
  kind: { type: 'string' },
121
- default: '.vx/cache, entries in ~/.vx/<repo>/cache',
121
+ default: '.vx/cache, entries in ~/.vx/<id>/cache',
122
122
  doc: 'Where the cache lives, relative to the workspace root; named, it holds the whole cache.',
123
123
  flag: { verb: 'run', name: '--cache-dir' },
124
124
  },
@@ -165,25 +165,25 @@ export const SETTINGS: readonly Setting[] = [
165
165
  flag: { verb: 'run', name: '--cache-download' },
166
166
  },
167
167
  {
168
- path: 'cache.inputStability.strategy',
168
+ path: 'inputs.stability.strategy',
169
169
  kind: { type: 'enum', values: ['immutable', 'mutable', 'unchecked'] },
170
170
  default: 'immutable',
171
171
  doc: 'What a write to an input during the command means for the save, for tasks without their own.',
172
- task: 'cache.inputStability.strategy',
172
+ task: 'inputs.stability.strategy',
173
173
  },
174
174
  {
175
- path: 'cache.inputStability.severity',
175
+ path: 'inputs.stability.severity',
176
176
  kind: { type: 'enum', values: ['warn', 'error'] },
177
177
  default: 'warn',
178
178
  doc: 'Whether a skipped save warns or fails the task, for tasks without their own.',
179
- task: 'cache.inputStability.severity',
179
+ task: 'inputs.stability.severity',
180
180
  },
181
181
  {
182
- path: 'cache.outputs.clean',
182
+ path: 'outputs.clean',
183
183
  kind: { type: 'bool' },
184
184
  default: 'true',
185
185
  doc: "Remove a task's declared outputs before it runs on a miss, for tasks without their own.",
186
- task: 'cache.outputs.clean',
186
+ task: 'outputs.clean',
187
187
  env: false,
188
188
  },
189
189
  {
@@ -222,7 +222,7 @@ export const SETTINGS: readonly Setting[] = [
222
222
  path: 'config.workerTimeout',
223
223
  kind: { type: 'duration', timer: true },
224
224
  default: '30s',
225
- doc: 'How long one config evaluation may take.',
225
+ doc: 'How long one config evaluation may await; at 5 s vx names the config still evaluating. A synchronous loop on the first load holds the thread the deadline needs, so it hangs until killed.',
226
226
  config: false,
227
227
  },
228
228
  {
@@ -249,7 +249,7 @@ export const SETTINGS: readonly Setting[] = [
249
249
  ]
250
250
 
251
251
  /** The `.vx/workspace` groups that hold settings; `baseBranch` and `plugins` are the other keys. */
252
- export const SETTING_GROUPS = ['run', 'cache', 'lock', 'watch'] as const
252
+ export const SETTING_GROUPS = ['run', 'cache', 'inputs', 'outputs', 'lock', 'watch'] as const
253
253
 
254
254
  const BY_PATH = new Map(SETTINGS.map((s) => [s.path, s]))
255
255
 
@@ -327,7 +327,9 @@ export function parseSettingValue(kind: SettingKind, raw: unknown): unknown {
327
327
  }
328
328
  case 'size':
329
329
  if (kind.off === true && (raw === false || raw === 'false')) return 0
330
- return typeof raw === 'string' ? parseSize(raw) : null
330
+ // A size carries its unit, as a duration does: a bare `32` read as
331
+ // bytes turns the store off without a word.
332
+ return typeof raw === 'string' && !/^\d+$/.test(raw) ? parseSize(raw) : null
331
333
  case 'bool':
332
334
  if (typeof raw === 'boolean') return raw
333
335
  return raw === 'true' || raw === '1' ? true : raw === 'false' || raw === '0' ? false : null
@@ -362,7 +364,16 @@ export function settingRefusal(
362
364
  // A bare number past the ceiling is past it in either unit it could mean.
363
365
  const ms = bare ? n : typeof raw === 'string' ? parseDuration(raw) : null
364
366
  if (bare && !(kind.timer === true && n > MAX_TIMEOUT_MS)) {
365
- return `${where} takes a duration with its unit, like '${n}ms' or '${Math.max(1, Math.round(n / 1000))}s' (got ${got})`
367
+ // Below a thousand the number most likely meant seconds (`--timeout
368
+ // 5`): that reading leads.
369
+ const like = n < 1000 ? `'${n}s' or '${n}ms'` : `'${n}ms' or '${Math.round(n / 1000)}s'`
370
+ return `${where} takes a duration with its unit, like ${like} (got ${got})`
371
+ }
372
+ // `'0s'` looks like the examples the generic refusal gives.
373
+ const negative = typeof raw === 'string' && raw.startsWith('-') && parseDuration(raw.slice(1))
374
+ if (ms === 0 || (negative !== false && negative !== null)) {
375
+ const none = kind.timer === true ? ' — omit it for no limit' : ''
376
+ return `${where} must be more than 0${none} (got ${got})`
366
377
  }
367
378
  if (kind.timer === true && ms !== null && ms > MAX_TIMEOUT_MS) {
368
379
  return (
@@ -372,6 +383,18 @@ export function settingRefusal(
372
383
  )
373
384
  }
374
385
  }
386
+ const bytes =
387
+ typeof raw === 'number'
388
+ ? raw
389
+ : typeof raw === 'string' && /^\d+$/.test(raw)
390
+ ? Number(raw)
391
+ : null
392
+ if (kind.type === 'size' && bytes !== null && Number.isSafeInteger(bytes) && bytes >= 0) {
393
+ if (bytes === 0 && kind.off === true) return `${where} is false to keep none (got ${got})`
394
+ // A small number most likely meant KiB (`32`): that reading leads.
395
+ const like = bytes < 1024 ? `'${bytes}KiB' or '${bytes}B'` : `'${bytes}B'`
396
+ return `${where} takes a size with its unit, like ${like} (got ${got})`
397
+ }
375
398
  return `${where} must be ${kindHint(kind)} (got ${got})`
376
399
  }
377
400
 
@@ -387,9 +410,22 @@ export function settingEnv(path: string, env: NodeJS.ProcessEnv = process.env):
387
410
  return v
388
411
  }
389
412
 
390
- /** Validate every set setting variable once, so a deep reader can trust what it reads. */
391
- export function checkSettingsEnv(env: NodeJS.ProcessEnv = process.env): void {
392
- for (const s of SETTINGS) settingEnv(s.path, env)
413
+ /**
414
+ * Validate every set setting variable once, so a deep reader can trust what
415
+ * it reads. One whose flag `args` holds (before a `--`) is left alone: the
416
+ * flag beats it, so nothing reads it.
417
+ */
418
+ export function checkSettingsEnv(
419
+ env: NodeJS.ProcessEnv = process.env,
420
+ args: readonly string[] = [],
421
+ ): void {
422
+ const end = args.indexOf('--')
423
+ const given = end === -1 ? args : args.slice(0, end)
424
+ for (const s of SETTINGS) {
425
+ const flag = s.flag?.name
426
+ if (flag !== undefined && given.some((a) => a === flag || a.startsWith(`${flag}=`))) continue
427
+ settingEnv(s.path, env)
428
+ }
393
429
  }
394
430
 
395
431
  /** Where a resolved setting came from. */
@@ -6,6 +6,7 @@
6
6
  import {
7
7
  barWidth,
8
8
  identityHue,
9
+ GUTTER,
9
10
  INDENT,
10
11
  LABEL_MIN,
11
12
  paint,
@@ -13,6 +14,8 @@ import {
13
14
  RESET,
14
15
  ruleWidth,
15
16
  SYMBOL,
17
+ TABLE_WIDTH,
18
+ underlined,
16
19
  type ColorSupport,
17
20
  type Style,
18
21
  type Tone,
@@ -31,6 +34,8 @@ export type Inline = string | Token | readonly Inline[]
31
34
  /** A laid-out piece of output; `render` and `print` space blocks apart. */
32
35
  export interface Block {
33
36
  readonly lines: (colors: ColorSupport) => string[]
37
+ /** A grid's column header: it rides its section's title line, past the gutter. */
38
+ readonly head?: (colors: ColorSupport) => string
34
39
  }
35
40
 
36
41
  /** A `project` or `project#task` id, each half in its name's hue, the `#` dim. */
@@ -120,24 +125,6 @@ function prose(text: string): Inline {
120
125
  .map((p, i) => (i % 2 === 1 ? cmd(p) : p === '' ? p : { style: 'value' as const, text: p }))
121
126
  }
122
127
 
123
- /**
124
- * A panel's top off colour: `─ Title count ──── note ─`, flush with the
125
- * bars. The same characters a pipe, a test and `grep` read.
126
- */
127
- function ruleLine(
128
- title: string,
129
- count: Inline | undefined,
130
- c: ColorSupport,
131
- note?: Inline,
132
- ): string {
133
- const plain = { enabled: false }
134
- const head = `─ ${title}${count === undefined ? '' : ` ${inline(count, plain)}`} `
135
- if (note === undefined)
136
- return `${head}${'─'.repeat(Math.max(8, ruleWidth(c) - Bun.stringWidth(head)))}`
137
- const tail = ` ${inline(note, plain)} ─`
138
- return `${head}${'─'.repeat(Math.max(3, ruleWidth(c) - Bun.stringWidth(head) - Bun.stringWidth(tail)))}${tail}`
139
- }
140
-
141
128
  // ── Panels ──────────────────────────────────────────────────────────
142
129
  // vx's screens look like a TUI and are not one: each part is a box drawn
143
130
  // line by line into the scrollback, so a pipe, a CI log and a terminal all
@@ -212,33 +199,40 @@ export function wrap(line: string, w: number): string[] {
212
199
  return rows
213
200
  }
214
201
 
215
- const border = (s: string, c: ColorSupport): string => paint('border', s, c)
202
+ /** A title line's width: the terminal's, so a task's name sits over it too; never under the bars'. */
203
+ const titleWidth = (c: ColorSupport): number => Math.max(ruleWidth(c), c.columns ?? TABLE_WIDTH)
216
204
 
217
205
  /**
218
- * A panel's top: the rule `─ Title count ──── note ─`, its title bold in
219
- * the brand hue, the rule a quiet grey. A panel has no box: its lines sit
220
- * under the rule as they are, so a terminal, a CI log and a pipe read the
221
- * same layout (off colour, the same characters unpainted).
206
+ * A section's title line: the title in the gutter, bold in the brand hue,
207
+ * a grid's column header past it (`head`), a note flush right, the whole
208
+ * line underlined in the rule's grey. No rule characters: off colour it is
209
+ * the same words, unmarked.
222
210
  */
223
- export function panelTop(title: string, c: ColorSupport, count?: Inline, note?: Inline): string {
224
- if (!c.enabled) return ruleLine(title, count, c, note)
225
- const plain = { enabled: false }
226
- const head = `─ ${title}${count === undefined ? '' : ` ${inline(count, plain)}`} `
227
- const tail = note === undefined ? '' : ` ${inline(note, plain)} ─`
228
- const dashes = Math.max(
229
- note === undefined ? 8 : 3,
230
- ruleWidth(c) - Bun.stringWidth(head) - Bun.stringWidth(tail),
231
- )
232
- return (
233
- border('─ ', c) +
211
+ export function panelTop(
212
+ title: string,
213
+ c: ColorSupport,
214
+ count?: Inline,
215
+ note?: Inline,
216
+ head?: string,
217
+ ): string {
218
+ let line =
219
+ INDENT +
234
220
  paint('heading', title, c) +
235
- (count === undefined ? '' : ` ${inline(typeof count === 'string' ? dim(count) : count, c)}`) +
236
- ' ' +
237
- border('─'.repeat(dashes), c) +
238
- (note === undefined
239
- ? ''
240
- : ` ${inline(typeof note === 'string' ? dim(note) : note, c)} ${border('─', c)}`)
241
- )
221
+ (count === undefined ? '' : ` ${inline(typeof count === 'string' ? dim(count) : count, c)}`)
222
+ if (head !== undefined) line += ' '.repeat(Math.max(1, GUTTER - Bun.stringWidth(line))) + head
223
+ const tail = note === undefined ? '' : inline(typeof note === 'string' ? dim(note) : note, c)
224
+ const used = Bun.stringWidth(line) + Bun.stringWidth(tail)
225
+ line += ' '.repeat(Math.max(tail === '' ? 0 : 2, titleWidth(c) - used)) + tail
226
+ return underlined(line, c)
227
+ }
228
+
229
+ /**
230
+ * A list's divider: the underline alone, from the grid's first column to the
231
+ * title lines' end (off colour, a blank line).
232
+ */
233
+ export function divider(c: ColorSupport): string {
234
+ if (!c.enabled) return ''
235
+ return ' '.repeat(GUTTER) + underlined(' '.repeat(titleWidth(c) - GUTTER), c)
242
236
  }
243
237
 
244
238
  /**
@@ -253,7 +247,7 @@ export function section(title: string, ...rest: (Block | Inline)[]): Block {
253
247
  const body = (counted ? rest.slice(1) : rest) as Block[]
254
248
  return block((c) => {
255
249
  const inner = body.flatMap((b, i) => (i === 0 ? b.lines(c) : ['', ...b.lines(c)]))
256
- return [panelTop(title, c, count), ...inner]
250
+ return [panelTop(title, c, count, undefined, body[0]?.head?.(c)), ...inner]
257
251
  })
258
252
  }
259
253
 
package/src/ui/index.ts CHANGED
@@ -9,6 +9,7 @@ export { ui }
9
9
 
10
10
  export {
11
11
  bar,
12
+ divider,
12
13
  barWidth,
13
14
  cmd,
14
15
  code,
@@ -50,6 +51,7 @@ export {
50
51
  export {
51
52
  detectColors,
52
53
  dumbTerminal,
54
+ GUTTER,
53
55
  INDENT,
54
56
  paint,
55
57
  ruleWidth,
@@ -58,7 +60,7 @@ export {
58
60
  type Style,
59
61
  type Tone,
60
62
  } from './theme.js'
61
- export { colorsOf, data, print, say, transient, type StreamName } from './terminal.js'
63
+ export { colorsOf, data, dataPieces, print, say, transient, type StreamName } from './terminal.js'
62
64
  export { endStyle, hasEscape, lastRedraw, leavesStyle, STYLE_END } from './ansi.js'
63
65
  export {
64
66
  createOutputWriter,
@@ -75,11 +75,38 @@ export interface OutputWriterOptions {
75
75
  now?: () => number
76
76
  /**
77
77
  * Off a TTY, hold writes and hand them to the stream once per turn of
78
- * the event loop until `settle()`. A warm 476-package run wrote each of
79
- * its 952 task lines as its own syscall (item 753). Off by default: a
80
- * caller reading its stream right after a write sees it.
78
+ * the event loop, or once 64 KiB are held, until `settle()`. A warm
79
+ * 476-package run wrote each of its 952 task lines as its own syscall
80
+ * (item 753). Off by default: a caller reading its stream right after a
81
+ * write sees it.
81
82
  */
82
83
  coalesce?: boolean
84
+ /** Floor between two coalesced writes; 0 writes once per turn. */
85
+ coalesceMs?: number
86
+ }
87
+
88
+ /** What `coalesce` holds before it writes without waiting for the turn's end. */
89
+ const HELD_MAX_CHARS = 64 * 1024
90
+
91
+ /** The longest piece of a string handed to the stream in one write. */
92
+ const WRITE_SLICE_CHARS = 1024 * 1024
93
+
94
+ /**
95
+ * `text` in slices: a stream encodes a string whole before it writes, and
96
+ * a frame's furniture makes its text two bytes a character, so one 100 MB
97
+ * frame took ~300 MB more to write than to build.
98
+ */
99
+ function writeSliced(stream: StatusStream, text: string): void {
100
+ let at = 0
101
+ while (text.length - at > WRITE_SLICE_CHARS) {
102
+ let end = at + WRITE_SLICE_CHARS
103
+ // Never between the halves of a surrogate pair.
104
+ const c = text.charCodeAt(end - 1)
105
+ if (c >= 0xd800 && c <= 0xdbff) end--
106
+ stream.write(text.slice(at, end))
107
+ at = end
108
+ }
109
+ stream.write(at === 0 ? text : text.slice(at))
83
110
  }
84
111
 
85
112
  export function createOutputWriter(
@@ -90,16 +117,29 @@ export function createOutputWriter(
90
117
  const minRedrawMs = opts.minRedrawMs ?? 100
91
118
  const forceFloorMs = opts.forceFloorMs ?? 30
92
119
  const now = opts.now ?? Date.now
120
+ const coalesceMs = opts.coalesceMs ?? 25
93
121
  // The coalesced writes not yet handed over; null once settled or when
94
122
  // not coalescing.
95
123
  let held: string[] | null = !enabled && opts.coalesce === true ? [] : null
124
+ let heldChars = 0
96
125
  let flushQueued = false
97
- const flushHeld = (): void => {
98
- flushQueued = false
126
+ let flushTimer: ReturnType<typeof setTimeout> | null = null
127
+ let lastFlush = -Infinity
128
+ const writeHeld = (): void => {
99
129
  if (held === null || held.length === 0) return
100
130
  const text = held.join('')
101
131
  held.length = 0
102
- stream.write(text)
132
+ heldChars = 0
133
+ lastFlush = now()
134
+ writeSliced(stream, text)
135
+ }
136
+ const flushHeld = (): void => {
137
+ flushQueued = false
138
+ if (flushTimer !== null) {
139
+ clearTimeout(flushTimer)
140
+ flushTimer = null
141
+ }
142
+ writeHeld()
103
143
  }
104
144
 
105
145
  let current: Region | null = null
@@ -276,13 +316,32 @@ export function createOutputWriter(
276
316
  write(chunk) {
277
317
  if (!enabled) {
278
318
  if (held === null) {
279
- stream.write(chunk)
319
+ writeSliced(stream, chunk)
320
+ return
321
+ }
322
+ // A big write goes as it is: joined with what is held, it was copied.
323
+ if (chunk.length >= HELD_MAX_CHARS) {
324
+ writeHeld()
325
+ writeSliced(stream, chunk)
280
326
  return
281
327
  }
282
328
  held.push(chunk)
329
+ heldChars += chunk.length
330
+ // A warm run replays its hits without yielding to the event loop,
331
+ // so the turn's end came only after every hit's log: 500 hits
332
+ // printing 1 MiB each were held at once (2 GB of heap).
333
+ if (heldChars >= HELD_MAX_CHARS) {
334
+ writeHeld()
335
+ return
336
+ }
283
337
  if (!flushQueued) {
284
338
  flushQueued = true
285
- setImmediate(flushHeld)
339
+ // At most one write per floor: a restore settles its hits over
340
+ // hundreds of turns, and a write per turn cost the 9,603-task
341
+ // restore ~100 ms of its main thread (2026-11-07).
342
+ const wait = lastFlush + coalesceMs - now()
343
+ if (wait > 0) flushTimer = setTimeout(flushHeld, wait)
344
+ else setImmediate(flushHeld)
286
345
  }
287
346
  return
288
347
  }
@@ -31,6 +31,18 @@ export function data(stream: StreamName, bytes: string): void {
31
31
  streamOf(stream).write(bytes)
32
32
  }
33
33
 
34
+ /**
35
+ * `data` for bytes too many for one string, written piece by piece. A
36
+ * pipe that reads slower than vx writes is waited on: unwaited, every
37
+ * piece queues in memory until the reader catches up.
38
+ */
39
+ export async function dataPieces(stream: StreamName, pieces: Iterable<string>): Promise<void> {
40
+ const s = streamOf(stream)
41
+ for (const piece of pieces) {
42
+ if (!s.write(piece)) await new Promise((resolve) => s.once('drain', resolve))
43
+ }
44
+ }
45
+
34
46
  /** Whether a stream paints. */
35
47
  export function colorsOf(stream: StreamName): ReturnType<typeof detectColors> {
36
48
  return detectColors(streamOf(stream))
package/src/ui/theme.ts CHANGED
@@ -77,7 +77,11 @@ export const INDENT = ' '
77
77
  export const LABEL_MIN = 8
78
78
  /** A meter's widest, and the label column beside it (2 indent + 8 label + 2 gap). */
79
79
  const BAR_MAX = 50
80
- const BAR_LABEL = 12
80
+ /** The gutter a section's title sits in: a field's label column, a grid row's lead. */
81
+ export const GUTTER = 12
82
+ /** A title line's width when the terminal's is unknown (a pipe, a CI log): past a task's name. */
83
+ export const TABLE_WIDTH = 120
84
+ const BAR_LABEL = GUTTER
81
85
 
82
86
  /**
83
87
  * Identity hues for the two halves of a `project#task` id, never a status
@@ -194,6 +198,17 @@ export function paintHex(
194
198
  return prefix === '' ? text : `${prefix}${text}${RESET}`
195
199
  }
196
200
 
201
+ // The underline in the rule's grey (SGR 58): a terminal without it underlines in the text's colour.
202
+ const UNDER_RULE = `${UNDERLINE}\x1b[58;2;110;118;129m`
203
+
204
+ /** A title line underlined end to end, gaps included, each hue kept. */
205
+ export function underlined(line: string, c: ColorSupport): string {
206
+ if (!c.enabled) return line.trimEnd()
207
+ // A trailing reset is the line's own end: re-opening the underline there left an empty pair.
208
+ const body = line.endsWith(RESET) ? line.slice(0, -RESET.length) : line
209
+ return `${UNDER_RULE}${body.replaceAll(RESET, RESET + UNDER_RULE)}${RESET}`
210
+ }
211
+
197
212
  /** `text` in a style; `bold` adds weight to a hue. */
198
213
  export function paint(style: Style, text: string, c: ColorSupport, bold = false): string {
199
214
  switch (style) {