@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
@@ -8,11 +8,13 @@ import type { Pointer } from 'bun:ffi'
8
8
  import { closeSync, existsSync, readFileSync } from 'node:fs'
9
9
  import { constants as osConstants } from 'node:os'
10
10
  import {
11
+ formatBytes,
11
12
  shellArgv,
12
13
  isExecutableMissing,
13
14
  isOutOfFds,
14
15
  killGraceMs,
15
16
  OUT_OF_FDS_HINT,
17
+ helperEnv,
16
18
  type SecretMask,
17
19
  } from '../util/index.js'
18
20
  import {
@@ -35,10 +37,11 @@ export interface RunResult {
35
37
  durationMs: number
36
38
  stdout: string
37
39
  stderr: string
38
- /** The signal that killed the child, if any (Bun's `signalCode`).
40
+ /** The signal that killed the child, if any (Bun's `signalCode`): its
41
+ * name, or its number when it has none (Linux's real-time signals).
39
42
  * SIGINT/SIGTERM here means a Ctrl-C / shutdown teardown — the
40
43
  * orchestrator reverts such a task to aborted, not failed. */
41
- signal?: string
44
+ signal?: string | number
42
45
  /** True when vx's own `timeout` timer fired and SIGTERMed the child.
43
46
  * Distinguishes a timeout (a real `failed`) from a Ctrl-C shutdown
44
47
  * SIGTERM (which the orchestrator reverts to `aborted`). */
@@ -106,12 +109,8 @@ export interface RunOptions {
106
109
  type Fd = 'inherit' | 'ignore' | 'pipe' | number
107
110
 
108
111
  /** A child's descriptors 0–2: vx's own for a task that holds the terminal. */
109
- function taskStdio(
110
- terminal: boolean | undefined,
111
- stdin: 'ignore' | 'pipe',
112
- pipes: TaskPipes,
113
- ): [Fd, Fd, Fd] {
114
- return terminal === true ? ['inherit', 'inherit', 'inherit'] : [stdin, ...pipes.stdio]
112
+ function taskStdio(terminal: boolean | undefined, pipes: TaskPipes): [Fd, Fd, Fd] {
113
+ return terminal === true ? ['inherit', 'inherit', 'inherit'] : [pipes.stdin, ...pipes.stdio]
115
114
  }
116
115
 
117
116
  const O_NONBLOCK = 0o4000
@@ -180,6 +179,14 @@ function realPipes(): Int32Array | undefined {
180
179
  return fds
181
180
  }
182
181
 
182
+ /** One pipe, `[read, write]`, both close-on-exec, or undefined as {@link realPipes}. */
183
+ function heldPipe(): Int32Array | undefined {
184
+ const calls = linuxPipeCalls()
185
+ if (calls === null) return undefined
186
+ const fds = new Int32Array(2)
187
+ return calls.pipe2(calls.ptr(fds), O_CLOEXEC) === 0 ? fds : undefined
188
+ }
189
+
183
190
  /**
184
191
  * A task's stdout and stderr. Bun's `'pipe'` is a socketpair, and Linux
185
192
  * opens `/dev/stdout` through `/proc/self/fd/1`, which a socket refuses
@@ -188,25 +195,35 @@ function realPipes(): Int32Array | undefined {
188
195
  * Linux vx makes the pipes (`pipe2` through `bun:ffi`) and hands the child
189
196
  * the write ends. macOS opens `/dev/fd/N` as a dup of the descriptor,
190
197
  * which a socket allows, so Bun's pipe stays there.
198
+ *
199
+ * `held`: stdin is a pipe vx keeps open and never writes (a persistent
200
+ * task's, schema.md), for the same reason: `cat /dev/stdin` on Bun's
201
+ * socketpair failed with ENXIO instead of blocking. Otherwise `'ignore'`.
191
202
  */
192
203
  export class TaskPipes {
204
+ /** Descriptor 0 for `Bun.spawn`'s `stdio`. */
205
+ readonly stdin: number | 'pipe' | 'ignore'
193
206
  /** Descriptors 1 and 2 for `Bun.spawn`'s `stdio`. */
194
207
  readonly stdio: readonly [number | 'pipe', number | 'pipe']
195
208
  readonly #read: readonly number[]
209
+ readonly #stdinWrite: number | undefined
196
210
 
197
- constructor(terminal?: boolean) {
211
+ constructor(terminal?: boolean, held?: boolean) {
198
212
  const fds = terminal === true ? undefined : realPipes()
199
213
  this.stdio = fds === undefined ? ['pipe', 'pipe'] : [fds[1]!, fds[3]!]
200
214
  this.#read = fds === undefined ? [] : [fds[0]!, fds[2]!]
215
+ const inFds = held === true && terminal !== true ? heldPipe() : undefined
216
+ this.stdin = held !== true ? 'ignore' : inFds === undefined ? 'pipe' : inFds[0]!
217
+ this.#stdinWrite = inFds?.[1]
201
218
  }
202
219
 
203
220
  /**
204
- * Close vx's copies of the write ends once the spawn has returned or
205
- * thrown: the child holds its own, and one left here is a writer the
206
- * reader's EOF would wait for.
221
+ * Close vx's copies of the child's ends once the spawn has returned or
222
+ * thrown: the child holds its own, and a write end left here is a writer
223
+ * the reader's EOF would wait for.
207
224
  */
208
225
  spawned(): void {
209
- for (const fd of this.stdio) if (typeof fd === 'number') closeSync(fd)
226
+ for (const fd of [this.stdin, ...this.stdio]) if (typeof fd === 'number') closeSync(fd)
210
227
  }
211
228
 
212
229
  /** The child's stdout and stderr. */
@@ -231,6 +248,16 @@ export class TaskPipes {
231
248
  close(): void {
232
249
  for (const fd of this.#read) closeSync(fd)
233
250
  }
251
+
252
+ /**
253
+ * Close the held stdin's write end, once the child has exited and its
254
+ * output has ended. Not with the output alone: a server that sends it
255
+ * to a log (`exec >log 2>&1`) ended its streams at once, read stdin EOF
256
+ * and exited.
257
+ */
258
+ closeStdin(): void {
259
+ if (this.#stdinWrite !== undefined) closeSync(this.#stdinWrite)
260
+ }
234
261
  }
235
262
 
236
263
  /**
@@ -245,14 +272,30 @@ export class TaskPipes {
245
272
  function keepTerminalModes(): (() => void) | undefined {
246
273
  const saved = stty('-g')
247
274
  if (saved === undefined) return undefined
248
- return () => {
275
+ const restore = (): void => {
276
+ if (!owed.delete(restore)) return
249
277
  if (stty('-g') !== saved) stty(saved)
250
278
  }
279
+ owed.add(restore)
280
+ return restore
281
+ }
282
+
283
+ /** The modes of every task still holding the terminal, owed back on its exit. */
284
+ const owed = new Set<() => void>()
285
+
286
+ /**
287
+ * Put the terminal back now for every task still holding it: vx is
288
+ * exiting without waiting for them (a second signal, signals.ts), so the
289
+ * restore on each task's exit never runs.
290
+ */
291
+ export function restoreTerminalModes(): void {
292
+ for (const restore of [...owed]) restore()
251
293
  }
252
294
 
253
295
  function stty(arg: string): string | undefined {
254
296
  try {
255
297
  const out = Bun.spawnSync(['stty', arg], {
298
+ env: helperEnv(),
256
299
  stdio: ['inherit', 'pipe', 'ignore'],
257
300
  detached: true,
258
301
  })
@@ -279,10 +322,13 @@ export function shellQuote(arg: string): string {
279
322
  * `<<X` in a comment or quotes opens no heredoc, and a quote in a body
280
323
  * opens nothing (X-110).
281
324
  */
282
- function shellShape(command: string): { comment: number; body: boolean[] } {
325
+ function shellShape(command: string): { comment: number; body: boolean[]; subshell: boolean } {
283
326
  const lines = command.split('\n')
284
327
  const body = lines.map(() => false)
285
328
  const pending: Array<{ delim: string; tabs: boolean }> = []
329
+ // Per open `(`: does it open a subshell, not `$(`, `$((`, `<(` or `f()`.
330
+ const parens: boolean[] = []
331
+ let subshell = false
286
332
  let quote = ''
287
333
  let comment = -1
288
334
  let atWordStart = true
@@ -330,17 +376,32 @@ function shellShape(command: string): { comment: number; body: boolean[] } {
330
376
  continue
331
377
  }
332
378
  if (c === '<' && command[i - 1] !== '<') {
333
- const m = /^<<(-?)[ \t]*\\?(['"]?)([A-Za-z_]\w*)\2/.exec(command.slice(i, i + 256))
334
- if (m !== null) {
335
- pending.push({ delim: m[3]!, tabs: m[1] === '-' })
379
+ // The delimiter is a whole word, quotes removed: `<<END-OF` and
380
+ // `<<'a b'` end at `END-OF` and `a b`. One opening with a digit is
381
+ // left alone, for `$((x<<2))`.
382
+ const m = /^<<(-?)[ \t]*((?:[^\s;&|()<>'"\\]|\\.|'[^']*'|"(?:[^"\\]|\\.)*")+)/.exec(
383
+ command.slice(i, i + 256),
384
+ )
385
+ if (m !== null && !/^\d/.test(m[2]!)) {
386
+ const delim = m[2]!.replace(/'([^']*)'|"((?:[^"\\]|\\.)*)"|\\(.)/g, (_, s, d, e) =>
387
+ s !== undefined ? s : d !== undefined ? d.replace(/\\(.)/g, '$1') : e,
388
+ )
389
+ pending.push({ delim, tabs: m[1] === '-' })
336
390
  i += m[0].length - 1
337
391
  atWordStart = false
338
392
  continue
339
393
  }
340
394
  }
395
+ if (c === '(') {
396
+ const prev = command[i - 1] ?? ''
397
+ parens.push(!/[$<>\w]/.test(prev) && !(prev === '(' && parens.at(-1) === false))
398
+ } else if (c === ')') {
399
+ const opened = parens.pop()
400
+ if (i === command.length - 1) subshell = opened === true
401
+ }
341
402
  atWordStart = /[\s;&|()<>]/.test(c)
342
403
  }
343
- return { comment, body }
404
+ return { comment, body, subshell }
344
405
  }
345
406
 
346
407
  /**
@@ -372,6 +433,21 @@ export function withForwardArgs(command: string, args: readonly string[] | undef
372
433
  // ran the args as a command of their own: `--watch: not found`. Kept
373
434
  // when an odd run of backslashes ends it: that escapes what follows.
374
435
  const escaped = (/\\+$/.exec(trimmed)?.[0].length ?? 0) % 2 === 1
436
+ // So did one that ends in a lone `;` or `&` (or a case arm's `;;`): the
437
+ // args go before it. An escaped one (find's `-exec … \;`) is a word,
438
+ // and takes them after.
439
+ const sep = /(?:^|[^;&|<>\\])(?:\\\\)*(;;|[;&])$/.exec(trimmed)?.[1]
440
+ // After a compound command's closer they were a syntax error
441
+ // (`(cmd) --fix`, `fi --fix`): they go to the last command inside it.
442
+ // A closer word counts only where a command starts, after a separator.
443
+ const closer = shape.subshell
444
+ ? ')'
445
+ : /(?:^|[^\\])(?:\\\\)*[;&\n][ \t]*(fi|done|esac|\})$/.exec(trimmed)?.[1]
446
+ const end = sep ?? closer
447
+ if (end !== undefined) {
448
+ const head = trimmed.slice(0, -end.length).trimEnd()
449
+ return `${withForwardArgs(head, args)}${trimmed.slice(head.length)}`
450
+ }
375
451
  return `${escaped ? command : trimmed} ${quoted}`
376
452
  }
377
453
  // A comment-only last line after a commented line: the args go before
@@ -433,6 +509,28 @@ const SHELL_BUILTINS = new Set([
433
509
  'let',
434
510
  'declare',
435
511
  'typeset',
512
+ // The rest of bash's builtins, as `compgen -b` lists them.
513
+ 'bg',
514
+ 'bind',
515
+ 'caller',
516
+ 'compgen',
517
+ 'complete',
518
+ 'compopt',
519
+ 'dirs',
520
+ 'disown',
521
+ 'enable',
522
+ 'fc',
523
+ 'fg',
524
+ 'getopts',
525
+ 'help',
526
+ 'history',
527
+ 'logout',
528
+ 'mapfile',
529
+ 'popd',
530
+ 'pushd',
531
+ 'readarray',
532
+ 'shopt',
533
+ 'suspend',
436
534
  // bash's reserved words that may open a one-line command; macOS's `sh`
437
535
  // is bash, and `exec [[ … ]]` there is "exec: [[: not found".
438
536
  '[[',
@@ -484,11 +582,13 @@ export function execWord(command: string): string | undefined {
484
582
 
485
583
  /**
486
584
  * POSIX shells report signal death as 128 + signal number (SIGTERM →
487
- * 143, SIGKILL → 137). Bun gives us the signal NAME; `os.constants`
488
- * maps it to the platform-correct number. Unknown names fall back to
489
- * 130 (128 + SIGINT).
585
+ * 143, SIGKILL → 137). Bun gives the signal's NAME, or since 1.4.4 its
586
+ * number when the signal has no name; `os.constants` maps a name to
587
+ * the platform-correct number. Unknown names fall back to 130 (128 +
588
+ * SIGINT).
490
589
  */
491
- export function signalExitCode(signal: string): number {
590
+ export function signalExitCode(signal: string | number): number {
591
+ if (typeof signal === 'number') return 128 + signal
492
592
  const num = (osConstants.signals as Partial<Record<string, number>>)[signal]
493
593
  return num === undefined ? 130 : 128 + num
494
594
  }
@@ -607,9 +707,38 @@ const POST_EXIT_DRAIN_MS = 250
607
707
  * run; the line makes the loss visible.
608
708
  */
609
709
  export const POST_EXIT_CUT_LINE =
610
- `\n[vx] output after the task's shell exited was cut: a process it left running ` +
710
+ `[vx] output after the task's shell exited was cut: a process it left running ` +
611
711
  `still held its stdout/stderr ${POST_EXIT_DRAIN_MS} ms later\n`
612
712
 
713
+ /**
714
+ * A task's two output callbacks, wrapped to learn whether its output so far
715
+ * ends mid-line, so a line vx adds after it (`POST_EXIT_CUT_LINE`) breaks
716
+ * the line only then: a newline always before it printed a blank line under
717
+ * the task's last line. An absent callback stays absent (nothing decodes).
718
+ */
719
+ export function lineEnds(
720
+ onStdout?: (chunk: string) => void,
721
+ onStderr?: (chunk: string) => void,
722
+ ): {
723
+ onStdout?: (chunk: string) => void
724
+ onStderr?: (chunk: string) => void
725
+ /** `text` on a line of its own. */
726
+ own(text: string): string
727
+ } {
728
+ let mid = false
729
+ const track =
730
+ (f: (chunk: string) => void) =>
731
+ (chunk: string): void => {
732
+ if (chunk.length > 0) mid = !chunk.endsWith('\n')
733
+ f(chunk)
734
+ }
735
+ return {
736
+ ...(onStdout && { onStdout: track(onStdout) }),
737
+ ...(onStderr && { onStderr: track(onStderr) }),
738
+ own: (text) => (mid ? `\n${text}` : text),
739
+ }
740
+ }
741
+
613
742
  /**
614
743
  * After the direct child has exited, wait for the stdout/stderr readers to
615
744
  * reach EOF — but bound it: an orphaned grandchild holding the pipe open would
@@ -669,8 +798,11 @@ export class PersistentReadyError extends Error {
669
798
  }
670
799
 
671
800
  export interface PersistentSpawn {
672
- /** Underlying Bun subprocess so the orchestrator can SIGTERM it later. */
673
- child: ReturnType<typeof Bun.spawn>
801
+ /**
802
+ * Underlying Bun subprocess so the orchestrator can SIGTERM it later;
803
+ * undefined when the spawn itself failed and `ready` has rejected.
804
+ */
805
+ child: ReturnType<typeof Bun.spawn> | undefined
674
806
  /**
675
807
  * Resolves once the task is considered "ready":
676
808
  * - immediately on successful spawn when no `readyWhen` is given,
@@ -726,7 +858,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
726
858
  const readyRe = opts.readyWhen !== undefined ? new RegExp(opts.readyWhen) : undefined
727
859
 
728
860
  let child: ReturnType<typeof Bun.spawn>
729
- const pipes = new TaskPipes(opts.terminal)
861
+ const pipes = new TaskPipes(opts.terminal, true)
730
862
  const restoreModes = opts.terminal === true ? keepTerminalModes() : undefined
731
863
  try {
732
864
  const signalFd = opts.signalChannel === true
@@ -748,7 +880,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
748
880
  // The one-shot spawn below keeps 'ignore', so a task that reads
749
881
  // stdin can never hang CI.
750
882
  stdio: [
751
- ...taskStdio(opts.terminal, 'pipe', pipes),
883
+ ...taskStdio(opts.terminal, pipes),
752
884
  ...(signalFd ? ['pipe' as const] : []),
753
885
  ...(guard === undefined ? [] : [guard]),
754
886
  ],
@@ -767,9 +899,15 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
767
899
  }
768
900
  } catch (err) {
769
901
  pipes.close()
770
- const message = err instanceof Error ? err.message : String(err)
902
+ pipes.closeStdin()
903
+ // Bun names `sh` in the ENOENT a missing directory gives too.
904
+ // The command the shell was handed, a sandbox's wrap included.
905
+ const message =
906
+ isExecutableMissing(err) && !existsSync(opts.cwd)
907
+ ? `its working directory ${opts.cwd} does not exist`
908
+ : spawnErrorReason(err, opts.signalChannel === true ? SANDBOXED_TASK : 'task', opts.command)
771
909
  return {
772
- child: undefined as unknown as ReturnType<typeof Bun.spawn>,
910
+ child: undefined,
773
911
  ready: Promise.reject(
774
912
  new PersistentReadyError(`failed to spawn persistent task: ${message}`, 'spawn'),
775
913
  ),
@@ -874,6 +1012,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
874
1012
  const readers = Promise.all([consumeChunks(stdout, false), consumeChunks(stderr, true)]).finally(
875
1013
  () => pipes.close(),
876
1014
  )
1015
+ void Promise.allSettled([readers, child.exited]).then(() => pipes.closeStdin())
877
1016
 
878
1017
  opts.liveChildren?.add(child)
879
1018
  opts.onSpawn?.(child.pid)
@@ -932,6 +1071,10 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
932
1071
  new Promise((resolve) => setTimeout(resolve, POST_EXIT_DRAIN_MS).unref?.()),
933
1072
  ])
934
1073
  }
1074
+ // A clean exit is ready: a setup step (migrate, seed, a `docker
1075
+ // compose up --wait`) has done its work, and what it started, if
1076
+ // anything, is torn down with the run (2026-10-22).
1077
+ if (code === 0) markReady()
935
1078
  if (readyAt === undefined && gaveUpAt === undefined) {
936
1079
  rejectReady(
937
1080
  new PersistentReadyError(
@@ -955,21 +1098,57 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
955
1098
  }
956
1099
  }
957
1100
 
1101
+ /** `spawnFailureText`'s `what` for a sandboxed task: its command is the sandbox's wrap of the task's. */
1102
+ export const SANDBOXED_TASK = 'sandboxed task'
1103
+
958
1104
  /**
959
1105
  * What a task's frame says when `Bun.spawn` itself threw. The text goes
960
1106
  * through `onStderr` AND onto the result: the orchestrator retains no
961
1107
  * stderr (execute-task's capture), so a reason that only sat on the result
962
1108
  * reached nobody — a box without `sh` showed "failed (exit 127)" under a
963
1109
  * bare `$ <command>` and nothing else (2026-09-16). An ENOENT with the
964
- * working directory in place is the shell; Bun's own text names the rest.
1110
+ * working directory in place is the shell, without it the directory; Bun's
1111
+ * own text names the rest.
965
1112
  */
966
- export function spawnFailureText(err: unknown, cwd: string, what = 'task'): string {
967
- if (isExecutableMissing(err) && existsSync(cwd)) {
968
- return `\n[vx] vx runs each task with sh -c: failed to spawn 'sh' (working dir: ${cwd}). Install a POSIX sh and re-run.\n`
1113
+ export function spawnFailureText(
1114
+ err: unknown,
1115
+ cwd: string,
1116
+ what = 'task',
1117
+ command?: string,
1118
+ ): string {
1119
+ if (isExecutableMissing(err)) {
1120
+ // Bun names `sh` in the ENOENT a missing directory gives too.
1121
+ return existsSync(cwd)
1122
+ ? `[vx] vx runs each task with sh -c: failed to spawn 'sh' (working dir: ${cwd}). Install a POSIX sh and re-run.\n`
1123
+ : `[vx] failed to spawn ${what}: its working directory ${cwd} does not exist\n`
969
1124
  }
1125
+ return `[vx] failed to spawn ${what}: ${spawnErrorReason(err, what, command)}\n`
1126
+ }
1127
+
1128
+ /** Bun's text for a spawn that threw, and the way out when the limit hit is a known one. */
1129
+ function spawnErrorReason(err: unknown, what: string, command: string | undefined): string {
970
1130
  const message = err instanceof Error ? err.message : String(err)
971
- const hint = isOutOfFds(err) ? ` — ${OUT_OF_FDS_HINT}` : ''
972
- return `\n[vx] failed to spawn ${what}: ${message}${hint}\n`
1131
+ const hint = isOutOfFds(err)
1132
+ ? ` — ${OUT_OF_FDS_HINT}`
1133
+ : command !== undefined && (err as NodeJS.ErrnoException | undefined)?.code === 'E2BIG'
1134
+ ? ` — ${argTooLongHint(command, what === SANDBOXED_TASK)}`
1135
+ : ''
1136
+ return `${message}${hint}`
1137
+ }
1138
+
1139
+ /**
1140
+ * A task's command, the args after `--` folded in, is ONE argument to
1141
+ * `sh -c`, and Linux caps one argument at 128 KiB (MAX_ARG_STRLEN), far
1142
+ * below the argv total: a file list forwarded to a linter failed with a
1143
+ * bare E2BIG, which reads as "too many files" and names no way out.
1144
+ */
1145
+ function argTooLongHint(command: string, wrapped: boolean): string {
1146
+ const kib = Math.ceil(Buffer.byteLength(command) / 1024)
1147
+ const what = wrapped ? "with the args after -- and the sandbox's wrap" : 'with the args after --'
1148
+ return (
1149
+ `the command, ${what}, is ${kib} KiB, and one argument to sh -c may hold at ` +
1150
+ `most 128 KiB on Linux: pass a long list through a file the tool reads (xargs, a response file)`
1151
+ )
973
1152
  }
974
1153
 
975
1154
  export async function runCommand(opts: RunOptions): Promise<RunResult> {
@@ -985,10 +1164,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
985
1164
  ...SH_ARGV0,
986
1165
  cwd: opts.cwd,
987
1166
  env: opts.env as Record<string, string>,
988
- stdio: [
989
- ...taskStdio(opts.terminal, 'ignore', pipes),
990
- ...(guard === undefined ? [] : [guard]),
991
- ],
1167
+ stdio: [...taskStdio(opts.terminal, pipes), ...(guard === undefined ? [] : [guard])],
992
1168
  // Its own session and process group, so a kill reaches what it
993
1169
  // forked (kill-tree.ts). A terminal handed over is not this
994
1170
  // session's controlling one, so no read stops the group.
@@ -997,7 +1173,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
997
1173
  )
998
1174
  } catch (err) {
999
1175
  pipes.close()
1000
- const stderr = spawnFailureText(err, opts.cwd)
1176
+ const stderr = spawnFailureText(err, opts.cwd, 'task', fullCommand)
1001
1177
  opts.onStderr?.(stderr)
1002
1178
  return { exitCode: 127, durationMs: Date.now() - start, stdout: '', stderr, spawnFailed: true }
1003
1179
  } finally {
@@ -1009,9 +1185,10 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
1009
1185
  const timeout = armTimeout(proc, opts.timeoutMs)
1010
1186
  const ac = new AbortController()
1011
1187
  const [out, err] = pipes.streams(proc)
1188
+ const ends = lineEnds(opts.onStdout, opts.onStderr)
1012
1189
  const streams = Promise.all([
1013
- streamToString(out, opts.onStdout, ac.signal, opts.capture?.stdout ?? true),
1014
- streamToString(err, opts.onStderr, ac.signal, opts.capture?.stderr ?? true),
1190
+ streamToString(out, ends.onStdout, ac.signal, opts.capture?.stdout ?? true),
1191
+ streamToString(err, ends.onStderr, ac.signal, opts.capture?.stderr ?? true),
1015
1192
  ]).finally(() => pipes.close())
1016
1193
  // Gate on the child's own exit, not on stream EOF: an orphaned grandchild
1017
1194
  // can keep the pipe open past the child's exit (a timeout SIGTERM leaves the
@@ -1026,17 +1203,21 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
1026
1203
  if (timeout.timedOut()) ac.abort()
1027
1204
  else cut = await drainOrAbort(streams, ac)
1028
1205
  const [stdout, streamed] = await streams
1029
- if (cut) opts.onStderr?.(POST_EXIT_CUT_LINE)
1030
- const stderr = cut ? streamed + POST_EXIT_CUT_LINE : streamed
1206
+ const cutLine = ends.own(POST_EXIT_CUT_LINE)
1207
+ if (cut) opts.onStderr?.(cutLine)
1208
+ const stderr = cut ? streamed + cutLine : streamed
1031
1209
  opts.liveChildren?.delete(proc)
1032
1210
  releaseGroup(proc)
1033
- const exitCode = proc.exitCode ?? (proc.signalCode ? signalExitCode(proc.signalCode) : 1)
1211
+ const exited = proc.exitCode ?? (proc.signalCode ? signalExitCode(proc.signalCode) : 1)
1212
+ // A timeout is a failure, whatever the child said: one that traps the
1213
+ // TERM and exits 0 reported success to every executor built on this.
1214
+ const exitCode = exited === 0 && timeout.timedOut() ? signalExitCode('SIGTERM') : exited
1034
1215
  return {
1035
1216
  exitCode,
1036
1217
  durationMs: Date.now() - start,
1037
1218
  stdout,
1038
1219
  stderr,
1039
- ...(proc.signalCode ? { signal: proc.signalCode } : {}),
1220
+ ...(proc.signalCode != null ? { signal: proc.signalCode } : {}),
1040
1221
  ...(timeout.timedOut() ? { timedOut: true } : {}),
1041
1222
  ...(cut ? { leftRunning: true } : {}),
1042
1223
  ...resourceUsageToCpuRss(proc.resourceUsage(), rssFloorFor),
@@ -1101,9 +1282,6 @@ function rssFloorFor(peak: number): number {
1101
1282
  export const CAPTURE_HEAD_CHARS = 8 * 1024 * 1024
1102
1283
  export const CAPTURE_TAIL_CHARS = 8 * 1024 * 1024
1103
1284
 
1104
- const mib = (n: number): string => `${(n / (1024 * 1024)).toFixed(1)} MiB`
1105
- const size = (n: number): string => (n < 1024 * 1024 ? `${n / 1024} KiB` : mib(n))
1106
-
1107
1285
  /** The line that stands where the dropped middle was. */
1108
1286
  export function droppedOutputLine(
1109
1287
  dropped: number,
@@ -1111,8 +1289,9 @@ export function droppedOutputLine(
1111
1289
  tailChars = CAPTURE_TAIL_CHARS,
1112
1290
  ): string {
1113
1291
  return (
1114
- `\n[vx] ${mib(dropped)} of output not kept — vx keeps the first ${size(headChars)} ` +
1115
- `and the last ${size(tailChars)} of a task's output\n`
1292
+ // In vx's own units: a fixed MiB tier printed a few KiB dropped as `0.0 MiB`.
1293
+ `\n[vx] ${formatBytes(dropped)} of output not kept — vx keeps the first ${formatBytes(headChars)} ` +
1294
+ `and the last ${formatBytes(tailChars)} of a task's output\n`
1116
1295
  )
1117
1296
  }
1118
1297
 
@@ -1131,7 +1310,12 @@ export function maskCaptured(text: string, secrets: SecretMask): string {
1131
1310
  return head + text.slice(at, end) + tail
1132
1311
  }
1133
1312
 
1134
- const DROPPED_LINE = /^\n\[vx\] [\d.]+ MiB of output not kept — /
1313
+ const DROPPED_LINE = /^\n\[vx\] [\d.]+ [KMGTP]?B of output not kept — /
1314
+
1315
+ /** Whether `chunk` is the line a `BoundedCapture` puts where its dropped middle was. */
1316
+ export function isDroppedOutputChunk(chunk: CapturedChunk): boolean {
1317
+ return !chunk.err && DROPPED_LINE.test(chunk.text)
1318
+ }
1135
1319
 
1136
1320
  const isHighSurrogate = (code: number): boolean => code >= 0xd800 && code <= 0xdbff
1137
1321
 
@@ -1196,6 +1380,25 @@ export class BoundedCapture {
1196
1380
  }
1197
1381
  }
1198
1382
 
1383
+ /**
1384
+ * The same output under tighter bounds. What this capture already
1385
+ * dropped is counted in the new one's dropped line: pushed through
1386
+ * `chunks()`, the old line fell into the new middle with its count.
1387
+ * `through` maps each side's chunks before the new cut (the store's
1388
+ * mask), so the cut cannot split a value first.
1389
+ */
1390
+ rebound(
1391
+ headChars: number,
1392
+ tailChars: number,
1393
+ through: (chunks: CapturedChunk[]) => CapturedChunk[] = (c) => c,
1394
+ ): BoundedCapture {
1395
+ const to = new BoundedCapture(headChars, tailChars)
1396
+ for (const c of through(this.head)) to.push(c.text, c.err)
1397
+ for (const c of through(this.tail)) to.push(c.text, c.err)
1398
+ to.dropped += this.dropped
1399
+ return to
1400
+ }
1401
+
1199
1402
  /** The kept chunks in arrival order, the dropped middle named on stdout. */
1200
1403
  chunks(): CapturedChunk[] {
1201
1404
  return this.dropped === 0
@@ -14,6 +14,7 @@ import {
14
14
  unique,
15
15
  } from './sandbox-paths.js'
16
16
  import type { SandboxedRunArgs } from './sandbox-runtime.js'
17
+ import { ui } from '../util/index.js'
17
18
 
18
19
  type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
19
20
 
@@ -200,7 +201,8 @@ export function punchWritePaths(readPath: string, writePaths: readonly string[])
200
201
  }
201
202
  if (linked.length > 0 && !warnedSymlinkPunch.has(readPath)) {
202
203
  warnedSymlinkPunch.add(readPath)
203
- process.stderr.write(
204
+ ui.say(
205
+ 'warn',
204
206
  `[vx] sandbox: a write grant under ${readPath} makes its ${linked.length} symlinked ` +
205
207
  `entr${linked.length === 1 ? 'y' : 'ies'} (${linked
206
208
  .slice(0, 3)
@@ -232,7 +234,8 @@ function holdsGlobChar(grant: string): boolean {
232
234
  if (at === -1) return false
233
235
  if (!warnedGlob.has(grant)) {
234
236
  warnedGlob.add(grant)
235
- process.stderr.write(
237
+ ui.say(
238
+ 'warn',
236
239
  `[vx] sandbox: the write grant ${grant} holds ${globChar(grant[at]!)}, and the Linux ` +
237
240
  `sandbox mounts no write path that does, so a write under it is refused. Grant the ` +
238
241
  `directory above it instead: ${grant.slice(0, grant.lastIndexOf(path.sep, at) + 1)}\n`,
@@ -264,7 +267,8 @@ export function bindableReads(paths: readonly string[]): string[] {
264
267
  if (at === -1) return true
265
268
  if (!warnedGlob.has(grant)) {
266
269
  warnedGlob.add(grant)
267
- process.stderr.write(
270
+ ui.say(
271
+ 'warn',
268
272
  `[vx] sandbox: the read grant ${grant} holds ${globChar(grant[at]!)}, which the Linux ` +
269
273
  `sandbox ${grant[at] === '\\' ? 'cannot resolve' : 'reads as a pattern that also matches its siblings'}, so it is not granted. ` +
270
274
  `Rename it, or grant the directory above it: ` +
@@ -12,7 +12,7 @@ import path from 'node:path'
12
12
  * own set), which also counts `{}`: `write: ['g/{a,b}.txt']` is a
13
13
  * LITERAL to the sandbox, gets a placeholder file, and is widened to its
14
14
  * directory like any other file-shaped grant — measured ok, and pinned in
15
- * `tests/sandbox-runtime.unsafe.test.ts`. Counting the brace here would
15
+ * `tests/sandbox-lifecycle.unsafe.test.ts`. Counting the brace here would
16
16
  * move that spelling into the scan, which finds nothing before the task has
17
17
  * written, and turn a working grant into `Read-only file system`. The two
18
18
  * predicates answer different questions: whether a declaration must be