@vzn/vx 0.0.646 → 0.0.649

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
@@ -9,7 +9,7 @@
9
9
  import { exitSignal } from '../exec/index.js'
10
10
  import type { InputChanges, TaskNode, TaskOutcome } from '../graph/index.js'
11
11
  import { isGroupTask } from '../graph/index.js'
12
- import { maskedCommand, secretMask } from '../util/index.js'
12
+ import { heldSecrets, maskedCommand, type SecretMask, secretMask } from '../util/index.js'
13
13
  import type { Logger } from './logger.js'
14
14
  import type { RunContext } from './summary.js'
15
15
 
@@ -122,10 +122,19 @@ export function busLogger(bus: EventBus): Logger {
122
122
  let done = false
123
123
  // A status line carries plugin warnings, and a remote layer's warning
124
124
  // quotes the server's reply, which may echo what it was sent (L-40).
125
- const secrets = secretMask([process.env])
125
+ // Built again once a plugin's keychain answered: its token is in no env.
126
+ let secrets: SecretMask | null = null
127
+ let built = -1
128
+ const masked = (line: string): string => {
129
+ if (built !== heldSecrets()) {
130
+ built = heldSecrets()
131
+ secrets = secretMask([process.env])
132
+ }
133
+ return secrets?.mask(line) ?? line
134
+ }
126
135
  return {
127
- status: (line) => bus.emit({ kind: 'run:status', line: secrets?.mask(line) ?? line }),
128
- warn: (line) => bus.emit({ kind: 'run:status', line: secrets?.mask(line) ?? line, warn: true }),
136
+ status: (line) => bus.emit({ kind: 'run:status', line: masked(line) }),
137
+ warn: (line) => bus.emit({ kind: 'run:status', line: masked(line), warn: true }),
129
138
  taskStdout: (node, chunk) => bus.emit({ kind: 'task:stdout', node, chunk }),
130
139
  taskStderr: (node, chunk) => bus.emit({ kind: 'task:stderr', node, chunk }),
131
140
  taskComplete: (node, outcome) => bus.emit({ kind: 'task:complete', node, outcome }),
@@ -212,7 +221,7 @@ export function terminalSubscriber(sink: Logger): RunEventSubscriber {
212
221
  export function wireForwarder(send: (event: WireEvent) => void): RunEventSubscriber {
213
222
  // Which tasks the consumer has been told about. A skipped task never
214
223
  // reaches the scheduler's onStart, so its completion arrives with no
215
- // preceding task:start — and `createWireRenderer` resolves a completion's
224
+ // preceding task:start — and a consumer resolves a completion's
216
225
  // node from the start it recorded, so it would drop the task entirely
217
226
  // while the forwarded footer still counted it. Synthesize the start here,
218
227
  // where the live TaskNode is in hand, so the projection stays full
@@ -292,6 +301,8 @@ export interface OutcomeView {
292
301
  /** A skipped task: the failed (or aborted) task at the root of the block
293
302
  * (see `TaskOutcome`); absent for fail-fast's skip, which nothing blocked. */
294
303
  blockedBy?: string
304
+ /** A skipped task whose `exec.platforms` leave out this machine (see `TaskOutcome`). */
305
+ otherPlatform?: { readonly needs: readonly string[]; readonly host: string }
295
306
  /** How long an `admit` policy held the task with a worker free (see `TaskOutcome`). */
296
307
  admissionHeldMs?: number
297
308
  /** How long the task waited ready for a worker (see `TaskOutcome`). */
@@ -319,7 +330,8 @@ export interface OutcomeView {
319
330
  * header and footer, the focused one-liner, the hash-only audit line,
320
331
  * the verbose summary table: `success` / `restored-local` /
321
332
  * `restored-remote` / `up-to-date` / `failed` / `skipped`. A hit that
322
- * materialized nothing is up-to-date whichever layer answered.
333
+ * materialized nothing is up-to-date; a remote hit always restored
334
+ * (`hitOutcome`), so it is never up to date.
323
335
  */
324
336
  export function outcomeWord(o: Pick<OutcomeView, 'status' | 'restored'>): string {
325
337
  switch (o.status) {
@@ -344,26 +356,32 @@ export function outcomeLabel(
344
356
  | 'notReady'
345
357
  | 'inputMoved'
346
358
  | 'blockedBy'
359
+ | 'otherPlatform'
347
360
  >,
348
361
  ): string {
349
362
  if (o.status === 'failed')
350
363
  return failedLabel(o.exitCode, o.timedOut, o.sandboxViolations, o.notReady, o.inputMoved)
351
- if (o.status === 'skipped') return skippedLabel(o.blockedBy)
364
+ if (o.status === 'skipped') return skippedLabel(o)
352
365
  return outcomeWord(o)
353
366
  }
354
367
 
355
368
  /**
356
- * Why a task was skipped, one copy for every surface: `blocked by lib#build`
357
- * (the failed or aborted task at the root of the block), or '' for fail-fast's skip — the run stopped,
369
+ * Why a task was skipped, one copy for every surface: `needs darwin-arm64,
370
+ * this is linux-x64` (its `exec.platforms`), `blocked by lib#build` (the
371
+ * task at the root of the block), or '' for fail-fast's skip — the run stopped,
358
372
  * nothing blocked it, and a label claiming an upstream failure would lie.
359
373
  */
360
- export function skippedReason(blockedBy: string | undefined): string {
361
- return blockedBy === undefined ? '' : `blocked by ${blockedBy}`
374
+ export function skippedReason(o: SkipView = {}): string {
375
+ if (o.otherPlatform !== undefined)
376
+ return `needs ${o.otherPlatform.needs.join(' or ')}, this is ${o.otherPlatform.host}`
377
+ return o.blockedBy === undefined ? '' : `blocked by ${o.blockedBy}`
362
378
  }
363
379
 
380
+ type SkipView = Pick<OutcomeView, 'blockedBy' | 'otherPlatform'>
381
+
364
382
  /** `skipped (blocked by lib#build)`, or a bare `skipped` for fail-fast's. */
365
- function skippedLabel(blockedBy: string | undefined): string {
366
- const why = skippedReason(blockedBy)
383
+ function skippedLabel(o: SkipView): string {
384
+ const why = skippedReason(o)
367
385
  return why === '' ? 'skipped' : `skipped (${why})`
368
386
  }
369
387
 
@@ -448,6 +466,7 @@ export function projectOutcome(outcome: TaskOutcome): OutcomeView {
448
466
  if (outcome.notReady !== undefined) view.notReady = outcome.notReady
449
467
  if (outcome.inputMoved !== undefined) view.inputMoved = outcome.inputMoved
450
468
  if (outcome.blockedBy !== undefined) view.blockedBy = outcome.blockedBy
469
+ if (outcome.otherPlatform !== undefined) view.otherPlatform = outcome.otherPlatform
451
470
  if (outcome.restored !== undefined) view.restored = outcome.restored
452
471
  if (outcome.sandboxViolations !== undefined) view.sandboxViolations = outcome.sandboxViolations
453
472
  if (outcome.sandboxViolationLines !== undefined)
@@ -17,9 +17,16 @@ export interface LiveForecast {
17
17
  /**
18
18
  * Tasks not started because a dependency has not finished, in the order
19
19
  * the scheduler would start them, at most one per worker: what an idle
20
- * worker row holds (M, 2026-10-15).
20
+ * worker row holds (2026-10-15).
21
21
  */
22
22
  blocked?: readonly BlockedTask[]
23
+ /**
24
+ * Each running task's count of the tasks not started that depend on it
25
+ * directly (through a group, which runs nothing): its row's `blocked`
26
+ * cell, why they are not running. A task waiting on two running tasks
27
+ * counts under both.
28
+ */
29
+ blockedBy: ReadonlyMap<string, number>
23
30
  }
24
31
 
25
32
  /** A task an unfinished dependency holds back, and what holds it: the running task its longest chain waits on, else that dependency. */
@@ -34,16 +41,13 @@ export type Forecast = (
34
41
  nowMs: number,
35
42
  ) => LiveForecast
36
43
 
37
- /** How far past its p50 a running task may go before the forecast gives up. */
38
- const OVERRUN = 1.25
39
-
40
44
  /**
41
45
  * The later of the longest unfinished chain and the unfinished work spread
42
46
  * over the workers. A task still to run costs its p50 (a pending task may
43
47
  * yet hit, so this leans long, and the hits finish first); a running one
44
48
  * costs what its p50 has left, never below zero; a group or a task with no
45
- * history costs nothing. A running task well past its p50 means history
46
- * no longer describes this run, and the forecast says nothing of time.
49
+ * history costs nothing. Zero while tasks still run means the run is past
50
+ * its estimate: the live region counts the time since (2026-10-27).
47
51
  */
48
52
  export function createForecast(
49
53
  nodes: ReadonlyMap<string, TaskNode>,
@@ -84,6 +88,22 @@ export function createForecast(
84
88
  // graph order: once per run, as the scheduler computes it.
85
89
  const rank = computeReverseDepCount(nodes)
86
90
  const position = new Map(order.map((n, i) => [n.id, i]))
91
+ // The tasks each node waits on directly, a group's own looked through.
92
+ const direct = new Map<string, readonly string[]>()
93
+ const through = (id: string, into: Set<string>, seen: Set<string>): void => {
94
+ if (seen.has(id)) return
95
+ seen.add(id)
96
+ const d = nodes.get(id)
97
+ if (d === undefined || !isGroupTask(d)) into.add(id)
98
+ else for (const x of d.deps) through(x, into, seen)
99
+ }
100
+ for (const n of order) {
101
+ if (isGroupTask(n) || n.deps.length === 0) continue
102
+ const into = new Set<string>()
103
+ const seen = new Set<string>()
104
+ for (const d of n.deps) through(d, into, seen)
105
+ direct.set(n.id, [...into])
106
+ }
87
107
  const before = (a: string, b: string): boolean => {
88
108
  const d = (rank.get(a) ?? 0) - (rank.get(b) ?? 0)
89
109
  return d !== 0 ? d > 0 : position.get(a)! < position.get(b)!
@@ -103,9 +123,9 @@ export function createForecast(
103
123
  let chainEnd: string | undefined
104
124
  let work = 0
105
125
  let known = false
106
- let overrun = false
107
126
  // The best `workers` blocked tasks, kept sorted as the pass meets them.
108
127
  const held: BlockedTask[] = []
128
+ const blockedBy = new Map<string, number>()
109
129
  for (const n of order) {
110
130
  if (finished.has(n.id)) continue
111
131
  let start = 0
@@ -124,6 +144,8 @@ export function createForecast(
124
144
  since === undefined ? (head.get(n.id) ?? n.deps.find((d) => end.has(d))) : undefined
125
145
  if (by !== undefined && !isGroupTask(n)) {
126
146
  waiting++
147
+ for (const on of direct.get(n.id) ?? [])
148
+ if (running.has(on)) blockedBy.set(on, (blockedBy.get(on) ?? 0) + 1)
127
149
  let at = held.length
128
150
  while (at > 0 && before(n.id, held[at - 1]!.id)) at--
129
151
  if (at < workers) {
@@ -135,7 +157,6 @@ export function createForecast(
135
157
  let own = 0
136
158
  if (p !== undefined) {
137
159
  known = true
138
- if (since !== undefined && nowMs - since > p * OVERRUN) overrun = true
139
160
  own = since === undefined ? p : Math.max(0, p - (nowMs - since))
140
161
  }
141
162
  const e = start + own
@@ -146,7 +167,7 @@ export function createForecast(
146
167
  }
147
168
  work += own
148
169
  }
149
- if (!known || overrun) return { waiting, expected, blocked: held }
170
+ if (!known) return { waiting, expected, blocked: held, blockedBy }
150
171
  const critical = chainEnd === undefined ? undefined : head.get(chainEnd)
151
172
  return {
152
173
  etaMs: Math.max(chain, work / workers),
@@ -154,6 +175,7 @@ export function createForecast(
154
175
  waiting,
155
176
  expected,
156
177
  blocked: held,
178
+ blockedBy,
157
179
  }
158
180
  }
159
181
  }
@@ -32,22 +32,35 @@ import {
32
32
  identityColor,
33
33
  maskedCommand,
34
34
  paint,
35
+ printable,
36
+ ruleWidth,
35
37
  ui,
36
38
  type ColorSupport,
37
39
  } from '../util/index.js'
38
40
  import { neverStarted } from './summary.js'
39
- import { outcomeLabel, ranNoCache, skippedReason } from './events.js'
41
+ import { outcomeLabel, outcomeWord, ranNoCache, skippedReason } from './events.js'
40
42
  import { fileMemo, linkPaths } from './path-links.js'
41
43
 
42
44
  const NO_COLOR: ColorSupport = { enabled: false }
45
+ // Ends any colour a task's output left on, so vx's own lines below never
46
+ // take it (a task killed mid-write leaves one open).
47
+ export const RESET = '\x1b[0m'
48
+
49
+ /** Whether `text`'s last colour code sets a style rather than resetting it. */
50
+ export function leavesStyle(text: string): boolean {
51
+ const at = text.lastIndexOf('\x1b[')
52
+ if (at < 0) return false
53
+ const sgr = /^\[([\d;]*)m/.exec(text.slice(at + 1, at + 64))
54
+ // Not a colour code (a cursor move): look no further, as a reset costs nothing.
55
+ if (sgr === null) return true
56
+ return sgr[1] !== '' && sgr[1] !== '0'
57
+ }
43
58
 
44
59
  const ACCENT = '#06b6d4' // cyan-500 — bullets, remote-hit hint
45
60
  // Identity hues (`identityColor`) are outside the status palette (green /
46
61
  // red / yellow / cyan) so a task id can never read as an outcome. Each
47
62
  // half of an id hashes its own name: same project, same hue; same target,
48
63
  // same hue, in every run, list and region row.
49
- // Section rules + the summary rule share one frame width.
50
- const FRAME_WIDTH = 60
51
64
 
52
65
  const SUCCESS = '#22c55e' // green-500 — success / fresh
53
66
  const WARN = '#eab308' // yellow-500 — skipped
@@ -99,8 +112,11 @@ export function formatTaskBlock(
99
112
 
100
113
  const idPainted = paintTaskId(node, colors, { bold: true })
101
114
  const corner = (s: string) => paint('', s, colors, { dim: true })
102
- const header = formatBlockHeader(outcome, colors)
103
- const lines: string[] = [`${corner('┌─')} ${idPainted} ${corner('>')} ${header}`]
115
+ // A ready server's frame reads as the live one does: marked, and running.
116
+ const mark = persistentMark(node, colors)
117
+ const running = mark !== '' && outcome.status === 'success' && outcome.serverExit === undefined
118
+ const header = running ? paint(ACCENT, 'running', colors) : formatBlockHeader(outcome, colors)
119
+ const lines: string[] = [`${corner('┌─')} ${mark}${idPainted} ${corner('>')} ${header}`]
104
120
 
105
121
  // The command section shows what actually ran — executed tasks
106
122
  // (success and failed). Cache hits normally skip it (the stored
@@ -126,11 +142,18 @@ export function formatTaskBlock(
126
142
  lines.push(...violationSection(outcome, colors))
127
143
 
128
144
  lines.push(
129
- `${corner('└─')} ${idPainted}${whereNote(outcome, colors)} ${corner('──')}${formatBlockFooter(outcome, colors)}`,
145
+ `${corner('└─')} ${mark}${idPainted}${whereNote(outcome, colors)} ${corner('──')}${running ? runningFooter(outcome, colors) : formatBlockFooter(outcome, colors)}`,
130
146
  )
131
147
  return lines.join('\n') + '\n'
132
148
  }
133
149
 
150
+ /** What a bounded capture dropped from its head, so a capped log never reads as whole. */
151
+ function droppedNote(lost: number, colors: ColorSupport): string {
152
+ return paint('', `… ${lost} earlier character${lost === 1 ? '' : 's'} dropped`, colors, {
153
+ dim: true,
154
+ })
155
+ }
156
+
134
157
  /**
135
158
  * One `├─ <LABEL>` section of captured output, with the head-dropped notice
136
159
  * when the capture was bounded. Shared by the task frame and the persistent
@@ -147,9 +170,10 @@ function pushStreamSection(
147
170
  if (text.trim().length === 0) return
148
171
  lines.push(sectionLine(title, color, colors), '')
149
172
  if (lost > 0) {
150
- lines.push(paint('', `… ${lost} earlier characters dropped`, colors, { dim: true }))
173
+ lines.push(droppedNote(lost, colors))
151
174
  }
152
175
  pushBodyLines(lines, text)
176
+ if (leavesStyle(text)) lines.push(`${lines.pop()}${RESET}`)
153
177
  lines.push('')
154
178
  }
155
179
 
@@ -171,7 +195,7 @@ function pushOutputSection(
171
195
  if (output.every((c) => c.text.trim().length === 0)) return
172
196
  lines.push(sectionLine('OUTPUT', '', colors), '')
173
197
  if (lost > 0) {
174
- lines.push(paint('', `… ${lost} earlier characters dropped`, colors, { dim: true }))
198
+ lines.push(droppedNote(lost, colors))
175
199
  }
176
200
  const body: Array<{ text: string; err: boolean }> = []
177
201
  let open: { parts: string[]; err: boolean } | null = null
@@ -194,6 +218,8 @@ function pushOutputSection(
194
218
  if (!l.err || !colors.enabled) lines.push(text)
195
219
  else lines.push(l.text.includes('\x1b') ? text : paint(ERROR, text, colors))
196
220
  }
221
+ const styled = output.findLast((c) => c.text.includes('\x1b['))
222
+ if (styled !== undefined && leavesStyle(styled.text)) lines.push(`${lines.pop()}${RESET}`)
197
223
  lines.push('')
198
224
  }
199
225
 
@@ -207,8 +233,8 @@ function pushOutputSection(
207
233
  function sectionLine(title: string, color: string, colors: ColorSupport): string {
208
234
  const corner = (s: string) => paint('', s, colors, { dim: true })
209
235
  const label = paint(color, title, colors, { bold: true })
210
- const width = Math.min(FRAME_WIDTH, colors.columns ?? FRAME_WIDTH)
211
- const rule = corner('─'.repeat(Math.max(1, width - 4 - title.length)))
236
+ // As wide as the footer's rule, so the frames and the footer line up.
237
+ const rule = corner('─'.repeat(Math.max(1, ruleWidth(colors) - 4 - title.length)))
212
238
  return `${corner('├─')} ${label} ${rule}`
213
239
  }
214
240
 
@@ -263,13 +289,18 @@ function paintTaskId(node: TaskNode, colors: ColorSupport, opts: { bold?: boolea
263
289
  // the two axes out, each in its own color. Time is right-aligned in a
264
290
  // fixed cell so durations line up and a ticking elapsed never shifts
265
291
  // the row. All detail (exit code, output) lives in the framed block.
266
- export const TIME_COL = 7 // "0ms" … "999.99s"
292
+ export const TIME_COL = 7 // "0ms" … "999.99s", then "16m 40s" … "99m 59s"
267
293
  const STATUS_COL = 7 // "success" / "running" / "skipped" / "aborted" / "not run"
268
294
  const CACHE_COL = 8 // "no-cache" / "remote" / "local" / "fresh" / "miss"
295
+ const BLOCKED_COL = 7 // the live list's count of tasks a running one holds back
296
+ /** The list header's colour: violet-400, the summary rule's first stop. */
297
+ const HEADER = '#a78bfa'
269
298
 
270
299
  /** Right-align the duration in a TIME_COL cell (pad on the left). */
271
300
  function timeCell(ms: number | null, colors: ColorSupport): string {
272
- const raw = ms === null ? '' : formatElapsed(ms)
301
+ let raw = ms === null ? '' : formatElapsed(ms)
302
+ // `1000.00s` is wider than the cell; minutes fit it up to `99m 59s`.
303
+ if (ms !== null && raw.length > TIME_COL) raw = formatElapsed(ms, true)
273
304
  return ' '.repeat(Math.max(0, TIME_COL - raw.length)) + paint('', raw, colors, { dim: true })
274
305
  }
275
306
 
@@ -358,20 +389,32 @@ export function formatTaskRow(
358
389
  cacheColor: string,
359
390
  paintedId: string,
360
391
  colors: ColorSupport = NO_COLOR,
392
+ /** The live list's `blocked` cell, right-aligned before the id; absent off that list. */
393
+ blocked?: string,
361
394
  ): string {
362
395
  const st = cell(status, statusColor, STATUS_COL, colors)
363
396
  // `aborting` runs one into the cache cell, which is blank beside it.
364
397
  const ca = cell(cache, cacheColor, CACHE_COL - Math.max(0, status.length - STATUS_COL), colors)
365
- return ` ${glyph} ${timeCell(ms, colors)} ${st} ${ca} ${paintedId}`
398
+ const bl =
399
+ blocked === undefined
400
+ ? ''
401
+ : `${' '.repeat(Math.max(0, BLOCKED_COL - blocked.length))}${paint(WARN, blocked, colors)} `
402
+ return ` ${glyph} ${timeCell(ms, colors)} ${st} ${ca} ${bl}${paintedId}`
366
403
  }
367
404
 
368
405
  /**
369
- * The dim header over a task list, on the row grid (M, 2026-10-21).
370
- * `agent` when an executor places tasks: the name after the id's dot.
406
+ * The header over a task list, on the row grid: bold and coloured, so it
407
+ * reads apart from the rows (dim, it was lost among them). `worker` when an
408
+ * executor places tasks: the name after the id's dot. `blocked` on the live
409
+ * list, whose rows count the tasks each running one holds back.
371
410
  */
372
- export function formatListHeader(agent: boolean, colors: ColorSupport = NO_COLOR): string {
373
- const head = ` ${'time'.padStart(TIME_COL)} ${'status'.padEnd(STATUS_COL)} ${'cache'.padEnd(CACHE_COL)} task${agent ? ' \u00b7 agent' : ''}`
374
- return paint('', head, colors, { dim: true })
411
+ export function formatListHeader(
412
+ worker: boolean,
413
+ colors: ColorSupport = NO_COLOR,
414
+ blocked = false,
415
+ ): string {
416
+ const head = ` ${'time'.padStart(TIME_COL)} ${'status'.padEnd(STATUS_COL)} ${'cache'.padEnd(CACHE_COL)} ${blocked ? `${'blocked'.padStart(BLOCKED_COL)} ` : ''}task${worker ? ' \u00b7 worker' : ''}`
417
+ return paint(HEADER, head, colors, { bold: true })
375
418
  }
376
419
 
377
420
  /** Compact one-liner for a cache hit with nothing to replay. */
@@ -384,6 +427,21 @@ export function formatTaskHitLine(
384
427
  return formatOutcomeRow(o, paintTaskId(node, colors), o.durationMs, colors, onPath)
385
428
  }
386
429
 
430
+ /**
431
+ * The `--output-logs hash-only` line: outcome word, task id, cache key —
432
+ * absent for a task that never computed one (skipped, uncacheable). The
433
+ * word in its outcome's colour, the key dim.
434
+ */
435
+ export function formatTaskHashLine(
436
+ node: TaskNode,
437
+ o: TaskOutcome,
438
+ colors: ColorSupport = NO_COLOR,
439
+ ): string {
440
+ const color = o.status === 'aborted' ? ABORTED : statusOf(o).color
441
+ const key = o.hash === undefined ? '' : ` ${paint('', o.hash, colors, { dim: true })}`
442
+ return `${paint(color, outcomeWord(o), colors)} ${paintTaskId(node, colors)}${key}`
443
+ }
444
+
387
445
  /** Compact one-liner for an executed task in broad mode. */
388
446
  export function formatTaskExecutedLine(
389
447
  node: TaskNode,
@@ -423,17 +481,17 @@ function formatOutcomeRow(
423
481
 
424
482
  /** `critical path`: the row's task held the run's end while others ran beside it. */
425
483
  export function pathNote(colors: ColorSupport = NO_COLOR): string {
426
- return ` ${paint(WARN, 'critical path', colors)}`
484
+ return ` ${paint(ERROR, 'critical path', colors)}`
427
485
  }
428
486
 
429
- /** Where an executor ran the task, when not here, right after its id: `@small#2` (M, 2026-10-21). */
487
+ /** Where an executor ran the task, when not here, right after its id: `@small#2` (2026-10-21). */
430
488
  export function whereNote(o: TaskOutcome, colors: ColorSupport = NO_COLOR): string {
431
489
  return o.where === undefined ? '' : placeNote(o.where, colors)
432
490
  }
433
491
 
434
492
  /**
435
493
  * ` · <where>` after the id: the dot dim, the place in its own hue. Last
436
- * on the row, as an agent's name has no width to pad to (M, 2026-10-21).
494
+ * on the row, as a worker's name has no width to pad to (2026-10-21).
437
495
  */
438
496
  export function placeNote(where: string, colors: ColorSupport = NO_COLOR): string {
439
497
  return `${paint('', ' \u00b7', colors, { dim: true })} ${ui.inline(ui.place(where), colors)}`
@@ -465,9 +523,9 @@ export function flakyNote(o: TaskOutcome, colors: ColorSupport = NO_COLOR): stri
465
523
  export function formatTaskSkippedLine(
466
524
  node: TaskNode,
467
525
  colors: ColorSupport = NO_COLOR,
468
- blockedBy?: string,
526
+ skip?: Parameters<typeof skippedReason>[0],
469
527
  ): string {
470
- const why = skippedReason(blockedBy)
528
+ const why = skippedReason(skip)
471
529
  const suffix = why === '' ? '' : ` ${paint('', `• ${why}`, colors, { dim: true })}`
472
530
  return (
473
531
  formatTaskRow(
@@ -532,7 +590,7 @@ export function formatFrameOpen(
532
590
  ): string {
533
591
  const corner = (t: string) => paint('', t, colors, { dim: true })
534
592
  const cmd = shownCommand(node, forwardArgs)
535
- const mark = isPersistentNode(node) ? `${paint(ACCENT, '▸', colors)} ` : ''
593
+ const mark = persistentMark(node, colors)
536
594
  return `${corner('┌─')} ${mark}${paintTaskId(node, colors, { bold: true })} ${corner('>')} $ ${cmd}`
537
595
  }
538
596
 
@@ -541,8 +599,8 @@ export function formatFrameOpen(
541
599
  * sees them in context with the failing task, not as loose status output
542
600
  * above the box. Shared by BOTH renderers: the focused/live frame used to
543
601
  * omit it, so a single-task run — exactly how someone debugging one task
544
- * invokes vx — showed a failure with no violations at all (owner,
545
- * 2026-09-05).
602
+ * invokes vx — showed a failure with no violations at all
603
+ * (2026-09-05).
546
604
  */
547
605
  function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[] {
548
606
  const vlines = outcome.sandboxViolationLines
@@ -552,11 +610,13 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
552
610
  // VERBATIM: a violation is evidence, and evidence that has been rewritten
553
611
  // for display is not the thing the sandbox reported. The outcome keeps
554
612
  // every record for telemetry.
555
- const body = [...new Set(vlines)]
613
+ // Verbatim but for control bytes: a denied path is anyone's bytes, and
614
+ // one named `ESC ]0;…` retitled the terminal (the `\xNN` is still exact).
615
+ const body = [...new Set(vlines)].map(printable)
556
616
  // Red, not amber: a violation fails the task, so it reads as a failure.
557
617
  // vx's note, after the evidence: the grants that would have allowed it.
558
618
  // A read the output depends on is a key input too; the sandbox never
559
- // adds one (owner, 2026-10-09), so the note names the reads no input
619
+ // adds one (2026-10-09), so the note names the reads no input
560
620
  // glob matches and the user decides.
561
621
  const fix = outcome.sandboxFix
562
622
  const line = fix === undefined ? undefined : sandboxFixLine(fix)
@@ -569,12 +629,16 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
569
629
  ...(fix.inputs !== undefined
570
630
  ? [
571
631
  dim(
572
- `and, if the output depends on them, inputs.files: [${fix.inputs.map((p) => `'${p}'`).join(', ')}]`,
632
+ `and, if the output depends on them, inputs.files: [${fix.inputs.map((p) => `'${printable(p)}'`).join(', ')}]`,
573
633
  ),
574
634
  ]
575
635
  : []),
576
636
  ...(fix.withheld !== undefined
577
- ? [dim(`credential path, grant it only on purpose: ${fix.withheld.join(', ')}`)]
637
+ ? [
638
+ dim(
639
+ `credential path, grant it only on purpose: ${fix.withheld.map(printable).join(', ')}`,
640
+ ),
641
+ ]
578
642
  : []),
579
643
  '',
580
644
  ]
@@ -595,14 +659,13 @@ export function formatFrameClose(
595
659
  colors: ColorSupport = NO_COLOR,
596
660
  ): string {
597
661
  const corner = (t: string) => paint('', t, colors, { dim: true })
598
- const persistent = isPersistentNode(node)
599
- const mark = persistent ? `${paint(ACCENT, '▸', colors)} ` : ''
662
+ const mark = persistentMark(node, colors)
600
663
  // A persistent task's child keeps running past this "ready" outcome,
601
664
  // so the close reads `running` (accent), not `success` — the ▸ marks
602
665
  // both ends of the frame as a long-lived task.
603
666
  const tail =
604
- persistent && outcome.status === 'success'
605
- ? ` ${paint('', `(${formatElapsed(outcome.durationMs)})`, colors, { dim: true })} ${paint(ACCENT, 'running', colors)}`
667
+ mark !== '' && outcome.status === 'success' && outcome.serverExit === undefined
668
+ ? runningFooter(outcome, colors)
606
669
  : formatBlockFooter(outcome, colors)
607
670
  const close = `${corner('└─')} ${mark}${paintTaskId(node, colors, { bold: true })}${whereNote(outcome, colors)} ${corner('──')}${tail}`
608
671
  const violations = violationSection(outcome, colors)
@@ -618,8 +681,12 @@ function shownCommand(node: TaskNode, forwardArgs: readonly string[]): string {
618
681
  )
619
682
  }
620
683
 
621
- function isPersistentNode(node: TaskNode): boolean {
622
- return node.config.exec?.persistent !== undefined
684
+ function persistentMark(node: TaskNode, colors: ColorSupport): string {
685
+ return node.config.exec?.persistent !== undefined ? `${paint(ACCENT, '▸', colors)} ` : ''
686
+ }
687
+
688
+ function runningFooter(o: TaskOutcome, colors: ColorSupport): string {
689
+ return ` ${paint('', `(${formatElapsed(o.durationMs)})`, colors, { dim: true })} ${paint(ACCENT, 'running', colors)}`
623
690
  }
624
691
 
625
692
  /**
@@ -661,10 +728,37 @@ export function formatKeptLines(node: TaskNode, text: string, colors: ColorSuppo
661
728
  const prefix = `${paintTaskId(node, colors)} ${paint('', '│', colors, { dim: true })} `
662
729
  return text
663
730
  .split('\n')
664
- .map((line) => `${prefix}${line}\n`)
731
+ .map((line) => {
732
+ const shown = lastRedraw(line)
733
+ return `${prefix}${shown}${leavesStyle(shown) ? RESET : ''}\n`
734
+ })
665
735
  .join('')
666
736
  }
667
737
 
738
+ // eslint-disable-next-line no-control-regex -- ESC is the point
739
+ const LINE_RESET = /\r|\x1b\[[12]K|\x1b\[[01]?G/g
740
+ // eslint-disable-next-line no-control-regex -- ESC is the point
741
+ const TRAILING_RESETS = /(?:\r|\x1b\[[12]K|\x1b\[[01]?G)+$/
742
+ // eslint-disable-next-line no-control-regex -- ESC is the point
743
+ const SGR = /\x1b\[[0-9;]*m/g
744
+
745
+ /**
746
+ * What a line shows once its redraws have played: the text after its last
747
+ * `\r`, line erase (`CSI 1K`/`2K`) or move to column 1 (`CSI G`), with the
748
+ * colours set before it. Written after an id prefix, a dev server's redraw
749
+ * (`\x1b[2K\x1b[1Gbuilt in 30ms`, a `10%\r100%` progress line) erased or
750
+ * overwrote the prefix on a terminal. A reset that ends the line, as in
751
+ * CRLF, redraws nothing.
752
+ */
753
+ function lastRedraw(line: string): string {
754
+ if (!line.includes('\r') && !line.includes('\x1b')) return line
755
+ const text = line.replace(TRAILING_RESETS, '')
756
+ let cut = -1
757
+ for (const m of text.matchAll(LINE_RESET)) cut = m.index + m[0].length
758
+ if (cut === -1) return text
759
+ return (text.slice(0, cut).match(SGR) ?? []).join('') + text.slice(cut)
760
+ }
761
+
668
762
  /**
669
763
  * Between the requested task's frame and the summary footer, list the
670
764
  * persistent tasks (dev servers / watchers) the run is keeping alive in
@@ -698,6 +792,8 @@ function formatBlockHeader(o: TaskOutcome, colors: ColorSupport): string {
698
792
  return paint(ERROR, word, colors, { bold: true })
699
793
  case 'skipped':
700
794
  return paint(WARN, word, colors)
795
+ case 'aborted':
796
+ return paint(ABORTED, word, colors)
701
797
  default:
702
798
  return dim(word)
703
799
  }
@@ -723,12 +819,10 @@ function formatStatusTag(o: TaskOutcome, colors: ColorSupport): string {
723
819
  return paint(ERROR, word, colors, { bold: true })
724
820
  case 'skipped':
725
821
  return paint(WARN, word, colors)
726
- case 'cache-hit':
727
- case 'cache-hit-remote':
728
- case 'success':
729
- return paint('', word, colors, { dim: true })
822
+ case 'aborted':
823
+ return paint(ABORTED, word, colors)
730
824
  default:
731
- return o.status
825
+ return paint('', word, colors, { dim: true })
732
826
  }
733
827
  }
734
828
 
@@ -736,14 +830,11 @@ function formatStatusTag(o: TaskOutcome, colors: ColorSupport): string {
736
830
  * The fix as the config line it is: `exec.sandbox.allow: { read: ['a'] }`,
737
831
  * or undefined when every denial was a withheld credential path.
738
832
  */
739
- function sandboxFixLine({
740
- withheld: _,
741
- inputs: __,
742
- ...fix
743
- }: NonNullable<TaskOutcome['sandboxFix']>): string | undefined {
744
- if (Object.keys(fix).length === 0) return undefined
745
- const parts = Object.entries(fix).map(
746
- ([k, list]: [string, string[]]) => `${k}: [${list.map((p) => `'${p}'`).join(', ')}]`,
833
+ function sandboxFixLine(f: NonNullable<TaskOutcome['sandboxFix']>): string | undefined {
834
+ const fix = Object.entries(f).filter(([k]) => k !== 'withheld' && k !== 'inputs')
835
+ if (fix.length === 0) return undefined
836
+ const parts = fix.map(
837
+ ([k, list]: [string, string[]]) => `${k}: [${list.map((p) => `'${printable(p)}'`).join(', ')}]`,
747
838
  )
748
839
  return `exec.sandbox.allow: { ${parts.join(', ')} }`
749
840
  }
@@ -15,6 +15,7 @@ export {
15
15
  type RunEvent,
16
16
  type RunEventSubscriber,
17
17
  type RunStartInfo,
18
+ skippedReason,
18
19
  type TaskView,
19
20
  terminalSubscriber,
20
21
  toWireEvent,