@vzn/vx 0.0.645 → 0.0.648

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/README.md +39 -50
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -32,21 +32,35 @@ import {
32
32
  identityColor,
33
33
  maskedCommand,
34
34
  paint,
35
+ printable,
36
+ ruleWidth,
37
+ ui,
35
38
  type ColorSupport,
36
39
  } from '../util/index.js'
37
40
  import { neverStarted } from './summary.js'
38
- import { outcomeLabel, ranNoCache, skippedReason } from './events.js'
41
+ import { outcomeLabel, outcomeWord, ranNoCache, skippedReason } from './events.js'
39
42
  import { fileMemo, linkPaths } from './path-links.js'
40
43
 
41
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
+ }
42
58
 
43
59
  const ACCENT = '#06b6d4' // cyan-500 — bullets, remote-hit hint
44
60
  // Identity hues (`identityColor`) are outside the status palette (green /
45
61
  // red / yellow / cyan) so a task id can never read as an outcome. Each
46
62
  // half of an id hashes its own name: same project, same hue; same target,
47
63
  // same hue, in every run, list and region row.
48
- // Section rules + the summary rule share one frame width.
49
- const FRAME_WIDTH = 60
50
64
 
51
65
  const SUCCESS = '#22c55e' // green-500 — success / fresh
52
66
  const WARN = '#eab308' // yellow-500 — skipped
@@ -98,8 +112,11 @@ export function formatTaskBlock(
98
112
 
99
113
  const idPainted = paintTaskId(node, colors, { bold: true })
100
114
  const corner = (s: string) => paint('', s, colors, { dim: true })
101
- const header = formatBlockHeader(outcome, colors)
102
- 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}`]
103
120
 
104
121
  // The command section shows what actually ran — executed tasks
105
122
  // (success and failed). Cache hits normally skip it (the stored
@@ -124,10 +141,19 @@ export function formatTaskBlock(
124
141
 
125
142
  lines.push(...violationSection(outcome, colors))
126
143
 
127
- lines.push(`${corner('└─')} ${idPainted} ${corner('──')}${formatBlockFooter(outcome, colors)}`)
144
+ lines.push(
145
+ `${corner('└─')} ${mark}${idPainted}${whereNote(outcome, colors)} ${corner('──')}${running ? runningFooter(outcome, colors) : formatBlockFooter(outcome, colors)}`,
146
+ )
128
147
  return lines.join('\n') + '\n'
129
148
  }
130
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
+
131
157
  /**
132
158
  * One `├─ <LABEL>` section of captured output, with the head-dropped notice
133
159
  * when the capture was bounded. Shared by the task frame and the persistent
@@ -144,9 +170,10 @@ function pushStreamSection(
144
170
  if (text.trim().length === 0) return
145
171
  lines.push(sectionLine(title, color, colors), '')
146
172
  if (lost > 0) {
147
- lines.push(paint('', `… ${lost} earlier characters dropped`, colors, { dim: true }))
173
+ lines.push(droppedNote(lost, colors))
148
174
  }
149
175
  pushBodyLines(lines, text)
176
+ if (leavesStyle(text)) lines.push(`${lines.pop()}${RESET}`)
150
177
  lines.push('')
151
178
  }
152
179
 
@@ -168,7 +195,7 @@ function pushOutputSection(
168
195
  if (output.every((c) => c.text.trim().length === 0)) return
169
196
  lines.push(sectionLine('OUTPUT', '', colors), '')
170
197
  if (lost > 0) {
171
- lines.push(paint('', `… ${lost} earlier characters dropped`, colors, { dim: true }))
198
+ lines.push(droppedNote(lost, colors))
172
199
  }
173
200
  const body: Array<{ text: string; err: boolean }> = []
174
201
  let open: { parts: string[]; err: boolean } | null = null
@@ -191,6 +218,8 @@ function pushOutputSection(
191
218
  if (!l.err || !colors.enabled) lines.push(text)
192
219
  else lines.push(l.text.includes('\x1b') ? text : paint(ERROR, text, colors))
193
220
  }
221
+ const styled = output.findLast((c) => c.text.includes('\x1b['))
222
+ if (styled !== undefined && leavesStyle(styled.text)) lines.push(`${lines.pop()}${RESET}`)
194
223
  lines.push('')
195
224
  }
196
225
 
@@ -204,8 +233,8 @@ function pushOutputSection(
204
233
  function sectionLine(title: string, color: string, colors: ColorSupport): string {
205
234
  const corner = (s: string) => paint('', s, colors, { dim: true })
206
235
  const label = paint(color, title, colors, { bold: true })
207
- const width = Math.min(FRAME_WIDTH, colors.columns ?? FRAME_WIDTH)
208
- 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)))
209
238
  return `${corner('├─')} ${label} ${rule}`
210
239
  }
211
240
 
@@ -260,13 +289,18 @@ function paintTaskId(node: TaskNode, colors: ColorSupport, opts: { bold?: boolea
260
289
  // the two axes out, each in its own color. Time is right-aligned in a
261
290
  // fixed cell so durations line up and a ticking elapsed never shifts
262
291
  // the row. All detail (exit code, output) lives in the framed block.
263
- export const TIME_COL = 7 // "0ms" … "999.99s"
292
+ export const TIME_COL = 7 // "0ms" … "999.99s", then "16m 40s" … "99m 59s"
264
293
  const STATUS_COL = 7 // "success" / "running" / "skipped" / "aborted" / "not run"
265
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'
266
298
 
267
299
  /** Right-align the duration in a TIME_COL cell (pad on the left). */
268
300
  function timeCell(ms: number | null, colors: ColorSupport): string {
269
- 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)
270
304
  return ' '.repeat(Math.max(0, TIME_COL - raw.length)) + paint('', raw, colors, { dim: true })
271
305
  }
272
306
 
@@ -355,11 +389,32 @@ export function formatTaskRow(
355
389
  cacheColor: string,
356
390
  paintedId: string,
357
391
  colors: ColorSupport = NO_COLOR,
392
+ /** The live list's `blocked` cell, right-aligned before the id; absent off that list. */
393
+ blocked?: string,
358
394
  ): string {
359
395
  const st = cell(status, statusColor, STATUS_COL, colors)
360
396
  // `aborting` runs one into the cache cell, which is blank beside it.
361
397
  const ca = cell(cache, cacheColor, CACHE_COL - Math.max(0, status.length - STATUS_COL), colors)
362
- 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}`
403
+ }
404
+
405
+ /**
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.
410
+ */
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 })
363
418
  }
364
419
 
365
420
  /** Compact one-liner for a cache hit with nothing to replay. */
@@ -367,8 +422,24 @@ export function formatTaskHitLine(
367
422
  node: TaskNode,
368
423
  o: TaskOutcome,
369
424
  colors: ColorSupport = NO_COLOR,
425
+ onPath = false,
370
426
  ): string {
371
- return formatOutcomeRow(o, paintTaskId(node, colors), o.durationMs, colors)
427
+ return formatOutcomeRow(o, paintTaskId(node, colors), o.durationMs, colors, onPath)
428
+ }
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}`
372
443
  }
373
444
 
374
445
  /** Compact one-liner for an executed task in broad mode. */
@@ -376,8 +447,9 @@ export function formatTaskExecutedLine(
376
447
  node: TaskNode,
377
448
  o: TaskOutcome,
378
449
  colors: ColorSupport = NO_COLOR,
450
+ onPath = false,
379
451
  ): string {
380
- return formatOutcomeRow(o, paintTaskId(node, colors), o.durationMs, colors)
452
+ return formatOutcomeRow(o, paintTaskId(node, colors), o.durationMs, colors, onPath)
381
453
  }
382
454
 
383
455
  /** Shared: render any outcome on the grid. */
@@ -386,6 +458,7 @@ function formatOutcomeRow(
386
458
  paintedId: string,
387
459
  ms: number | null,
388
460
  colors: ColorSupport,
461
+ onPath: boolean,
389
462
  ): string {
390
463
  const st = statusOf(o)
391
464
  const ca = cacheOf(o)
@@ -399,10 +472,31 @@ function formatOutcomeRow(
399
472
  ca.color,
400
473
  paintedId,
401
474
  colors,
402
- ) + flakyNote(o, colors)
475
+ ) +
476
+ (onPath ? pathNote(colors) : '') +
477
+ whereNote(o, colors) +
478
+ flakyNote(o, colors)
403
479
  )
404
480
  }
405
481
 
482
+ /** `critical path`: the row's task held the run's end while others ran beside it. */
483
+ export function pathNote(colors: ColorSupport = NO_COLOR): string {
484
+ return ` ${paint(ERROR, 'critical path', colors)}`
485
+ }
486
+
487
+ /** Where an executor ran the task, when not here, right after its id: `@small#2` (2026-10-21). */
488
+ export function whereNote(o: TaskOutcome, colors: ColorSupport = NO_COLOR): string {
489
+ return o.where === undefined ? '' : placeNote(o.where, colors)
490
+ }
491
+
492
+ /**
493
+ * ` · <where>` after the id: the dot dim, the place in its own hue. Last
494
+ * on the row, as a worker's name has no width to pad to (2026-10-21).
495
+ */
496
+ export function placeNote(where: string, colors: ColorSupport = NO_COLOR): string {
497
+ return `${paint('', ' \u00b7', colors, { dim: true })} ${ui.inline(ui.place(where), colors)}`
498
+ }
499
+
406
500
  /**
407
501
  * The dim note a flaky task's row and frame carry (`flaky - failed 1×
408
502
  * before`): nothing prints below the footer, so the task's own line is
@@ -429,9 +523,9 @@ export function flakyNote(o: TaskOutcome, colors: ColorSupport = NO_COLOR): stri
429
523
  export function formatTaskSkippedLine(
430
524
  node: TaskNode,
431
525
  colors: ColorSupport = NO_COLOR,
432
- blockedBy?: string,
526
+ skip?: Parameters<typeof skippedReason>[0],
433
527
  ): string {
434
- const why = skippedReason(blockedBy)
528
+ const why = skippedReason(skip)
435
529
  const suffix = why === '' ? '' : ` ${paint('', `• ${why}`, colors, { dim: true })}`
436
530
  return (
437
531
  formatTaskRow(
@@ -496,7 +590,7 @@ export function formatFrameOpen(
496
590
  ): string {
497
591
  const corner = (t: string) => paint('', t, colors, { dim: true })
498
592
  const cmd = shownCommand(node, forwardArgs)
499
- const mark = isPersistentNode(node) ? `${paint(ACCENT, '▸', colors)} ` : ''
593
+ const mark = persistentMark(node, colors)
500
594
  return `${corner('┌─')} ${mark}${paintTaskId(node, colors, { bold: true })} ${corner('>')} $ ${cmd}`
501
595
  }
502
596
 
@@ -505,8 +599,8 @@ export function formatFrameOpen(
505
599
  * sees them in context with the failing task, not as loose status output
506
600
  * above the box. Shared by BOTH renderers: the focused/live frame used to
507
601
  * omit it, so a single-task run — exactly how someone debugging one task
508
- * invokes vx — showed a failure with no violations at all (owner,
509
- * 2026-09-05).
602
+ * invokes vx — showed a failure with no violations at all
603
+ * (2026-09-05).
510
604
  */
511
605
  function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[] {
512
606
  const vlines = outcome.sandboxViolationLines
@@ -516,11 +610,13 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
516
610
  // VERBATIM: a violation is evidence, and evidence that has been rewritten
517
611
  // for display is not the thing the sandbox reported. The outcome keeps
518
612
  // every record for telemetry.
519
- 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)
520
616
  // Red, not amber: a violation fails the task, so it reads as a failure.
521
617
  // vx's note, after the evidence: the grants that would have allowed it.
522
618
  // A read the output depends on is a key input too; the sandbox never
523
- // 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
524
620
  // glob matches and the user decides.
525
621
  const fix = outcome.sandboxFix
526
622
  const line = fix === undefined ? undefined : sandboxFixLine(fix)
@@ -533,12 +629,16 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
533
629
  ...(fix.inputs !== undefined
534
630
  ? [
535
631
  dim(
536
- `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(', ')}]`,
537
633
  ),
538
634
  ]
539
635
  : []),
540
636
  ...(fix.withheld !== undefined
541
- ? [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
+ ]
542
642
  : []),
543
643
  '',
544
644
  ]
@@ -559,16 +659,15 @@ export function formatFrameClose(
559
659
  colors: ColorSupport = NO_COLOR,
560
660
  ): string {
561
661
  const corner = (t: string) => paint('', t, colors, { dim: true })
562
- const persistent = isPersistentNode(node)
563
- const mark = persistent ? `${paint(ACCENT, '▸', colors)} ` : ''
662
+ const mark = persistentMark(node, colors)
564
663
  // A persistent task's child keeps running past this "ready" outcome,
565
664
  // so the close reads `running` (accent), not `success` — the ▸ marks
566
665
  // both ends of the frame as a long-lived task.
567
666
  const tail =
568
- persistent && outcome.status === 'success'
569
- ? ` ${paint('', `(${formatElapsed(outcome.durationMs)})`, colors, { dim: true })} ${paint(ACCENT, 'running', colors)}`
667
+ mark !== '' && outcome.status === 'success' && outcome.serverExit === undefined
668
+ ? runningFooter(outcome, colors)
570
669
  : formatBlockFooter(outcome, colors)
571
- const close = `${corner('└─')} ${mark}${paintTaskId(node, colors, { bold: true })} ${corner('──')}${tail}`
670
+ const close = `${corner('└─')} ${mark}${paintTaskId(node, colors, { bold: true })}${whereNote(outcome, colors)} ${corner('──')}${tail}`
572
671
  const violations = violationSection(outcome, colors)
573
672
  return violations.length === 0 ? close : `${violations.join('\n')}\n${close}`
574
673
  }
@@ -582,8 +681,12 @@ function shownCommand(node: TaskNode, forwardArgs: readonly string[]): string {
582
681
  )
583
682
  }
584
683
 
585
- function isPersistentNode(node: TaskNode): boolean {
586
- 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)}`
587
690
  }
588
691
 
589
692
  /**
@@ -625,10 +728,37 @@ export function formatKeptLines(node: TaskNode, text: string, colors: ColorSuppo
625
728
  const prefix = `${paintTaskId(node, colors)} ${paint('', '│', colors, { dim: true })} `
626
729
  return text
627
730
  .split('\n')
628
- .map((line) => `${prefix}${line}\n`)
731
+ .map((line) => {
732
+ const shown = lastRedraw(line)
733
+ return `${prefix}${shown}${leavesStyle(shown) ? RESET : ''}\n`
734
+ })
629
735
  .join('')
630
736
  }
631
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
+
632
762
  /**
633
763
  * Between the requested task's frame and the summary footer, list the
634
764
  * persistent tasks (dev servers / watchers) the run is keeping alive in
@@ -662,6 +792,8 @@ function formatBlockHeader(o: TaskOutcome, colors: ColorSupport): string {
662
792
  return paint(ERROR, word, colors, { bold: true })
663
793
  case 'skipped':
664
794
  return paint(WARN, word, colors)
795
+ case 'aborted':
796
+ return paint(ABORTED, word, colors)
665
797
  default:
666
798
  return dim(word)
667
799
  }
@@ -687,12 +819,10 @@ function formatStatusTag(o: TaskOutcome, colors: ColorSupport): string {
687
819
  return paint(ERROR, word, colors, { bold: true })
688
820
  case 'skipped':
689
821
  return paint(WARN, word, colors)
690
- case 'cache-hit':
691
- case 'cache-hit-remote':
692
- case 'success':
693
- return paint('', word, colors, { dim: true })
822
+ case 'aborted':
823
+ return paint(ABORTED, word, colors)
694
824
  default:
695
- return o.status
825
+ return paint('', word, colors, { dim: true })
696
826
  }
697
827
  }
698
828
 
@@ -700,14 +830,11 @@ function formatStatusTag(o: TaskOutcome, colors: ColorSupport): string {
700
830
  * The fix as the config line it is: `exec.sandbox.allow: { read: ['a'] }`,
701
831
  * or undefined when every denial was a withheld credential path.
702
832
  */
703
- function sandboxFixLine({
704
- withheld: _,
705
- inputs: __,
706
- ...fix
707
- }: NonNullable<TaskOutcome['sandboxFix']>): string | undefined {
708
- if (Object.keys(fix).length === 0) return undefined
709
- const parts = Object.entries(fix).map(
710
- ([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(', ')}]`,
711
838
  )
712
839
  return `exec.sandbox.allow: { ${parts.join(', ')} }`
713
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,