@vzn/vx 0.0.505 → 0.0.507

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.
package/README.md CHANGED
@@ -149,8 +149,6 @@ Declared in `vx.workspace.ts`; without any, vx runs and caches locally.
149
149
  | [`@vzn/vx-mcp`](packages/vx-mcp) | `vx mcp`: a read-only MCP server for AI coding agents |
150
150
  | [`@vzn/vx-schedule-history`](packages/vx-schedule-history) | Schedule by the critical path learned from past runs |
151
151
 
152
- Only `@vzn/vx` is on npm today; the plugins' first publish is pending.
153
-
154
152
  Write your own: [plugin guide](https://vznjs.github.io/vx/guides/plugins/) ·
155
153
  [architecture](packages/vx/docs/architecture.md).
156
154
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.505",
3
+ "version": "0.0.507",
4
4
  "description": "The fastest task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.505",
25
- "@vzn/vx-linux-arm64": "0.0.505",
26
- "@vzn/vx-darwin-x64": "0.0.505",
27
- "@vzn/vx-darwin-arm64": "0.0.505"
24
+ "@vzn/vx-linux-x64": "0.0.507",
25
+ "@vzn/vx-linux-arm64": "0.0.507",
26
+ "@vzn/vx-darwin-x64": "0.0.507",
27
+ "@vzn/vx-darwin-arm64": "0.0.507"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -37,12 +37,21 @@
37
37
  // skipped — vx's outputs are regular files, and an artifact that claims
38
38
  // otherwise silently loses the claim rather than acting on it.
39
39
 
40
- import { chmodSync, renameSync, statSync, utimesSync } from 'node:fs'
40
+ import {
41
+ chmodSync,
42
+ lstatSync,
43
+ readFileSync,
44
+ realpathSync,
45
+ renameSync,
46
+ statSync,
47
+ utimesSync,
48
+ } from 'node:fs'
41
49
  import { lstat, mkdir, readlink, realpath, rmdir, stat, unlink, writeFile } from 'node:fs/promises'
42
50
  import path from 'node:path'
43
51
  import { UserError } from '../util/index.js'
44
52
  import { WORKSPACE_OUTPUT_PREFIX } from './layer.js'
45
53
  import { TarFormatError, type TarInput, tarEntries, tarPack, tarSize } from './tar-stream.js'
54
+ import { ON_THREAD_MAX } from './zstd.js'
46
55
 
47
56
  /** Archive entry name carrying the per-output mode/mtime sidecar. */
48
57
  const META_ENTRY = '.vx-meta.json'
@@ -200,6 +209,9 @@ export interface PackArgs {
200
209
  within?: string
201
210
  }
202
211
 
212
+ /** At or below this many outputs, `planArtifact` stats them on the calling thread. */
213
+ const ON_THREAD_STATS = 32
214
+
203
215
  /** A packed artifact's plan: its entries with their stats, and the tar's exact size. */
204
216
  export interface ArtifactPlan {
205
217
  inputs: TarInput[]
@@ -216,6 +228,12 @@ export interface ArtifactPlan {
216
228
  */
217
229
  export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
218
230
  const meta: MetaFile = { version: 1, files: {} }
231
+ // A few outputs are stat'ed on this thread: a pool round trip per stat
232
+ // cost more than the stat. Many go to the pool together, as before.
233
+ const few = args.outputs.size <= ON_THREAD_STATS
234
+ const lstatOf = few ? async (p: string) => lstatSync(p) : lstat
235
+ const statOf = few ? async (p: string) => statSync(p) : stat
236
+ const realpathOf = few ? async (p: string) => realpathSync(p) : realpath
219
237
  let withinReal: Promise<string> | undefined
220
238
  // Where this artifact's own non-link outputs really are: a link to one of
221
239
  // them packs bytes the task wrote itself. A root-anchored output tree is
@@ -225,7 +243,7 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
225
243
  const ownOutputs = (): Promise<Set<string>> =>
226
244
  (ownReal ??= Promise.all(
227
245
  [...args.outputs.values()].map(async (abs) =>
228
- (await lstat(abs)).isSymbolicLink() ? null : realpath(abs),
246
+ (await lstatOf(abs)).isSymbolicLink() ? null : realpathOf(abs),
229
247
  ),
230
248
  ).then((all) => new Set(all.filter((p): p is string => p !== null))))
231
249
  if (args.key !== undefined) meta.key = args.key
@@ -255,11 +273,11 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
255
273
  // lstat, so a regular file costs no second call; only a link is
256
274
  // resolved, and its body is read from the path this check resolved.
257
275
  let src = abs
258
- let st = await lstat(abs)
276
+ let st = await lstatOf(abs)
259
277
  if (st.isSymbolicLink()) {
260
- src = await realpath(abs).catch(dangling)
278
+ src = await realpathOf(abs).catch(dangling)
261
279
  if (args.within !== undefined) {
262
- withinReal ??= realpath(args.within)
280
+ withinReal ??= realpathOf(args.within)
263
281
  const root = await withinReal
264
282
  if (!src.startsWith(root + path.sep) && !(await ownOutputs()).has(src)) {
265
283
  throw new UserError(
@@ -267,7 +285,7 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
267
285
  )
268
286
  }
269
287
  }
270
- st = await stat(src).catch(dangling)
288
+ st = await statOf(src).catch(dangling)
271
289
  }
272
290
  if (!st.isFile()) {
273
291
  throw new UserError(
@@ -340,9 +358,16 @@ async function* summed(inputs: readonly TarInput[]): AsyncGenerator<TarInput> {
340
358
  * otherwise (measured 2026-09-03: 238 → 293 ms per 1 000 saves).
341
359
  */
342
360
  export async function packArtifactBytes(plan: ArtifactPlan): Promise<Uint8Array> {
361
+ // A small artifact's files are read on this thread (`ON_THREAD_MAX`).
362
+ const onThread = plan.size <= ON_THREAD_MAX
343
363
  const inputs = await Promise.all(
344
364
  plan.inputs.map(async (i) =>
345
- i.body instanceof Blob ? { ...i, body: await i.body.bytes() } : i,
365
+ i.body instanceof Blob
366
+ ? {
367
+ ...i,
368
+ body: onThread ? readFileSync((i.body as Bun.BunFile).name!) : await i.body.bytes(),
369
+ }
370
+ : i,
346
371
  ),
347
372
  )
348
373
  const out = new Uint8Array(plan.size)
@@ -88,7 +88,9 @@ import {
88
88
  bytesOf,
89
89
  decodedTar,
90
90
  MAX_DECOMPRESSED_ARTIFACT_BYTES,
91
+ oneChunk,
91
92
  STREAM_DECODE_FROM,
93
+ zstdCompress,
92
94
  zstdEncoder,
93
95
  } from './zstd.js'
94
96
  import { ConfigEvalTable } from './config-evals.js'
@@ -1296,7 +1298,7 @@ export class Cache implements CacheLayer {
1296
1298
  // again in `writeArtifactAndIndex` cost every save two thread-pool
1297
1299
  // round trips for an EEXIST and a stat (item 630).
1298
1300
  const endPack = span('save: pack')
1299
- const compressed = await this.packArtifactToTemp(this.tempPath(args.hash), args).catch(
1301
+ const packed = await this.packArtifactToTemp(this.tempPath(args.hash), args).catch(
1300
1302
  (err: unknown) => {
1301
1303
  // A save that could not open an output names the limit, not only
1302
1304
  // the file, which is fine (A-39).
@@ -1310,12 +1312,17 @@ export class Cache implements CacheLayer {
1310
1312
  )
1311
1313
  endPack()
1312
1314
  await this.guard(() =>
1313
- this.writeArtifactAndIndex(args.hash, compressed, {
1314
- taskId: args.entry.taskId,
1315
- command: args.entry.command,
1316
- durationMs: args.entry.durationMs,
1317
- ...(args.inputComponents !== undefined ? { inputComponents: args.inputComponents } : {}),
1318
- }),
1315
+ this.writeArtifactAndIndex(
1316
+ args.hash,
1317
+ 'tar' in packed ? packed.compressed : packed,
1318
+ {
1319
+ taskId: args.entry.taskId,
1320
+ command: args.entry.command,
1321
+ durationMs: args.entry.durationMs,
1322
+ ...(args.inputComponents !== undefined ? { inputComponents: args.inputComponents } : {}),
1323
+ },
1324
+ 'tar' in packed ? packed.tar : undefined,
1325
+ ),
1319
1326
  )
1320
1327
  }
1321
1328
 
@@ -1425,8 +1432,7 @@ export class Cache implements CacheLayer {
1425
1432
  // layout is predictable: a successful read finds `stdout` and
1426
1433
  // zero-or-more `outputs/<rel>` / `workspace-outputs/<rel>` entries.
1427
1434
  const plan = await this.planWithin(args)
1428
- if (plan.size <= STREAM_DECODE_FROM)
1429
- return await Bun.zstdCompress(await packArtifactBytes(plan))
1435
+ if (plan.size <= STREAM_DECODE_FROM) return await zstdCompress(await packArtifactBytes(plan))
1430
1436
  return bytesOf(packArtifactStream(plan).pipeThrough(zstdEncoder()))
1431
1437
  }
1432
1438
 
@@ -1466,10 +1472,12 @@ export class Cache implements CacheLayer {
1466
1472
  private async packArtifactToTemp(
1467
1473
  tmpPath: string,
1468
1474
  args: Parameters<Cache['packArtifact']>[0],
1469
- ): Promise<Uint8Array | { tmpPath: string }> {
1475
+ ): Promise<{ compressed: Uint8Array; tar: Uint8Array } | { tmpPath: string }> {
1470
1476
  const plan = await this.planWithin(args)
1471
- if (plan.size <= STREAM_DECODE_FROM)
1472
- return await Bun.zstdCompress(await packArtifactBytes(plan))
1477
+ if (plan.size <= STREAM_DECODE_FROM) {
1478
+ const tar = await packArtifactBytes(plan)
1479
+ return { compressed: await zstdCompress(tar), tar }
1480
+ }
1473
1481
  const sink = Bun.file(tmpPath).writer()
1474
1482
  try {
1475
1483
  for await (const chunk of packArtifactStream(plan).pipeThrough(zstdEncoder())) {
@@ -1505,6 +1513,11 @@ export class Cache implements CacheLayer {
1505
1513
  hash: string,
1506
1514
  compressed: Uint8Array | { tmpPath: string },
1507
1515
  meta: IngestMeta,
1516
+ /**
1517
+ * The tar `compressed` was made from, when this process packed it: it is
1518
+ * scanned as is rather than decoded back out of the bytes just encoded.
1519
+ */
1520
+ tar?: Uint8Array,
1508
1521
  ): Promise<void> {
1509
1522
  // Validate BEFORE anything touches the final path. `ingest()` feeds
1510
1523
  // us a temp of network bytes; a truncated/garbage body that went live first
@@ -1527,6 +1540,8 @@ export class Cache implements CacheLayer {
1527
1540
  const endWrite = span('save: write temp')
1528
1541
  // A write that fails part-way (a full disk) leaves the temp behind,
1529
1542
  // and a fresh one is an orphan only an hour on (A-14).
1543
+ // Off this thread even when small: a file's creation on the main
1544
+ // thread cost more CPU and wall than the round trip (A/B, 2026-10-04).
1530
1545
  await writeFile(tmpPath, compressed).catch(async (err: unknown) => {
1531
1546
  await unlink(tmpPath).catch(() => undefined)
1532
1547
  throw err
@@ -1546,7 +1561,9 @@ export class Cache implements CacheLayer {
1546
1561
  ? compressed
1547
1562
  : Bun.file(tmpPath)
1548
1563
  const endScan = span('save: scan')
1549
- scanned = await scanArtifact(await decodedTar(source, hash, this.artifactCeiling))
1564
+ scanned = await scanArtifact(
1565
+ tar !== undefined ? oneChunk(tar) : await decodedTar(source, hash, this.artifactCeiling),
1566
+ )
1550
1567
  endScan()
1551
1568
  // v17 invariant: every artifact carries a `stdout` entry. Its
1552
1569
  // absence means the bytes decompressed but aren't a vx artifact.
package/src/cache/zstd.ts CHANGED
@@ -92,8 +92,11 @@ async function zstdDecompressBounded(
92
92
  hash: string,
93
93
  cap: number = MAX_DECOMPRESSED_ARTIFACT_BYTES,
94
94
  ): Promise<Uint8Array> {
95
- assertDeclaredSize(compressed, hash, cap)
96
- const out = await Bun.zstdDecompress(compressed)
95
+ const declared = assertDeclaredSize(compressed, hash, cap)
96
+ const out =
97
+ declared !== null && declared <= BigInt(ON_THREAD_MAX)
98
+ ? Bun.zstdDecompressSync(compressed)
99
+ : await Bun.zstdDecompress(compressed)
97
100
  // UNREACHABLE as written, and kept deliberately. `assertDeclaredSize`
98
101
  // above refuses any frame DECLARING more than the cap, Bun refuses a
99
102
  // frame whose declaration disagrees with its body ("Decompression
@@ -121,6 +124,22 @@ function assertDeclaredSize(
121
124
  return declared
122
125
  }
123
126
 
127
+ /**
128
+ * At or below this many bytes an artifact's one-call codec and file I/O
129
+ * run on the calling thread. Each async call is a thread-pool round trip
130
+ * (a futex wake, an eventfd write, an epoll turn) that cost more CPU than
131
+ * the work itself for a one-file artifact, several times per save and
132
+ * per restore.
133
+ */
134
+ export const ON_THREAD_MAX = 256 * 1024
135
+
136
+ /** `Bun.zstdCompress`, on this thread when the input is small (`ON_THREAD_MAX`). */
137
+ export async function zstdCompress(bytes: Uint8Array): Promise<Uint8Array> {
138
+ return bytes.byteLength <= ON_THREAD_MAX
139
+ ? Bun.zstdCompressSync(bytes)
140
+ : await Bun.zstdCompress(bytes)
141
+ }
142
+
124
143
  /** Compressed artifacts above this size are decoded as a stream, on restore and on ingest. */
125
144
  export const STREAM_DECODE_FROM = 4 * 1024 * 1024
126
145
 
@@ -137,7 +156,8 @@ export async function bytesOf(stream: ReadableStream<Uint8Array>): Promise<Uint8
137
156
  export const zstdEncoder = (): TransformStream<Uint8Array, Uint8Array> =>
138
157
  new CompressionStream('zstd') as unknown as TransformStream<Uint8Array, Uint8Array>
139
158
 
140
- const oneChunk = (bytes: Uint8Array): ReadableStream<Uint8Array> =>
159
+ /** Bytes in memory as a one-chunk stream, for a reader that takes a stream. */
160
+ export const oneChunk = (bytes: Uint8Array): ReadableStream<Uint8Array> =>
141
161
  new ReadableStream({
142
162
  start(c) {
143
163
  c.enqueue(bytes)
@@ -11,7 +11,7 @@
11
11
  // takes even that.
12
12
 
13
13
  import { closeSync, readdirSync, readFileSync, writeSync } from 'node:fs'
14
- import { executablePath, procfsIsOwn } from '../util/index.js'
14
+ import { procfsIsOwn, taskShell } from '../util/index.js'
15
15
 
16
16
  export type Child = ReturnType<typeof Bun.spawn>
17
17
 
@@ -79,7 +79,7 @@ function startGuard(): void {
79
79
  if (guardFd !== undefined) return
80
80
  guardFd = null
81
81
  try {
82
- const guard = Bun.spawn([executablePath('sh'), '-c', GUARD_SCRIPT], {
82
+ const guard = Bun.spawn([taskShell(), '-c', GUARD_SCRIPT], {
83
83
  argv0: 'vx-group-guard',
84
84
  stdio: ['ignore', 'ignore', 'ignore', 'pipe'],
85
85
  // Out of vx's group, so a Ctrl-C at the terminal leaves it to the
@@ -60,6 +60,7 @@ import {
60
60
  import {
61
61
  BUN_GLOB_WILDCARDS,
62
62
  executablePath,
63
+ taskShell,
63
64
  grantPrefix,
64
65
  isTmpdirRefusal,
65
66
  procfsIsOwn,
@@ -236,7 +237,7 @@ async function trySandboxedTrue(
236
237
  undefined,
237
238
  weakerNested ? { enableWeakerNestedSandbox: true } : undefined,
238
239
  )
239
- const proc = Bun.spawn([executablePath('sh'), '-c', wrapped], {
240
+ const proc = Bun.spawn([taskShell(), '-c', wrapped], {
240
241
  argv0: 'sh',
241
242
  stdout: 'ignore',
242
243
  stderr: 'pipe',
@@ -1299,7 +1300,7 @@ function ownGroupCommand(
1299
1300
  // `/bin/sh` is dash (item 964).
1300
1301
  let sh: string
1301
1302
  try {
1302
- sh = executablePath('sh')
1303
+ sh = taskShell()
1303
1304
  } catch {
1304
1305
  return { command: `: 'vx-${tag}'; ${userCommand}`, forwards: false, traced: false }
1305
1306
  }
@@ -1433,15 +1434,19 @@ function wrapForTask(
1433
1434
  sockets: boolean,
1434
1435
  gitConfig: boolean,
1435
1436
  ): Promise<string> {
1437
+ // macOS: the runtime runs the command under bash unless told, and an
1438
+ // unsandboxed task runs under `taskShell()` (dash). Linux needs no word:
1439
+ // `ownGroupCommand` already puts the task shell inside.
1440
+ const shell = process.platform === 'darwin' ? taskShell() : undefined
1436
1441
  const run = perTaskRun
1437
- if (run === undefined) return SandboxManager.wrapWithSandbox(command, undefined, customConfig)
1442
+ if (run === undefined) return SandboxManager.wrapWithSandbox(command, shell, customConfig)
1438
1443
  const turn = wrapTurn.then(() => {
1439
1444
  SandboxManager.updateConfig({
1440
1445
  ...run,
1441
1446
  network: { ...run.network, allowAllUnixSockets: sockets },
1442
1447
  filesystem: { ...run.filesystem, allowGitConfig: gitConfig },
1443
1448
  })
1444
- return SandboxManager.wrapWithSandbox(command, undefined, customConfig)
1449
+ return SandboxManager.wrapWithSandbox(command, shell, customConfig)
1445
1450
  })
1446
1451
  wrapTurn = turn.catch(() => undefined)
1447
1452
  return turn
@@ -1588,11 +1593,7 @@ function spawnHostBridges(ports: readonly number[], tag: string): void {
1588
1593
  detached: true,
1589
1594
  })
1590
1595
  : Bun.spawn(
1591
- [
1592
- executablePath('sh'),
1593
- '-c',
1594
- `${guardLine(3)}exec ${argv.map(shellQuote).join(' ')}`,
1595
- ],
1596
+ [taskShell(), '-c', `${guardLine(3)}exec ${argv.map(shellQuote).join(' ')}`],
1596
1597
  { stdio: ['ignore', 'ignore', 'ignore', guard], detached: true },
1597
1598
  ),
1598
1599
  ),
@@ -1793,7 +1794,7 @@ async function runSandboxedOnce(
1793
1794
  try {
1794
1795
  // Resolved on vx's own PATH (util/which.ts), not the task's, where a
1795
1796
  // project's node_modules/.bin comes first.
1796
- const sh = executablePath('sh')
1797
+ const sh = taskShell()
1797
1798
  if (straceLog) traceFd = openSync(straceLog, 'w')
1798
1799
  // Descriptors: 3 the signal channel the in-sandbox watcher reads, 4 the
1799
1800
  // guard's pipe (kill-tree.ts), TRACE_FD the trace log; a gap is 'ignore'.
@@ -71,6 +71,10 @@ export class DeferredOutputs {
71
71
  * this throws and the caller fails the task naming the producer.
72
72
  */
73
73
  async materializeFor(node: TaskNode): Promise<void> {
74
+ // Nothing deferred, nothing to find: the closure walk is the size of
75
+ // the graph, and every task of a run without `--download=none` paid it
76
+ // (1.4 s of a 3,270-task cold run's main thread).
77
+ if (this.entries.size === 0) return
74
78
  const needed: string[] = []
75
79
  const seen = new Set<string>()
76
80
  // Pre-order on an explicit stack of dependency lists: a closure is as
@@ -801,6 +801,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
801
801
  if (c.kind === 'env' && named.includes(c.name) && !secretNamed(c.name))
802
802
  c.hash = MASKED + c.hash
803
803
  const inputs: TaskInputs | undefined = described?.inputs
804
+ // A plugin may keep the request past the run (the cache closed): it gets
805
+ // the upstream outputs read now. Only the local floor leaves them unread.
806
+ if (inputs !== undefined && !isLocalExecutor(args.executor)) void inputs.upstream
804
807
  // A declared input set that resolves to NOTHING is the quiet stale hit:
805
808
  // the key stops moving with this project's source and every later run
806
809
  // is a hit. Almost always a glob written against the wrong directory
@@ -173,15 +173,21 @@ export async function describeTaskInputs(
173
173
  )
174
174
  // Output lists come from the local index — one SELECT for every upstream
175
175
  // entry. An upstream with no entry (non-cacheable) contributes an empty list.
176
- const rows = args.cache.loadOutputFilesBatch(graft.map((g) => g.hash))
177
- const upstream = graft.map((g) => {
178
- const outputs = (rows.get(g.hash) ?? []).map((r) =>
179
- r.path.startsWith(WORKSPACE_OUTPUT_PREFIX)
180
- ? r.path.slice(WORKSPACE_OUTPUT_PREFIX.length)
181
- : relPosix(input.workspaceRoot, path.join(g.projectDir, r.path)),
182
- )
183
- return { taskId: g.taskId, hash: g.hash, outputs }
184
- })
176
+ // Read on first access: the local floor never reads it, and its every
177
+ // miss paid a query over the whole closure (execute-task reads it up
178
+ // front for a plugin executor).
179
+ const listUpstream = (): TaskInputs['upstream'] => {
180
+ const rows = args.cache.loadOutputFilesBatch(graft.map((g) => g.hash))
181
+ return graft.map((g) => {
182
+ const outputs = (rows.get(g.hash) ?? []).map((r) =>
183
+ r.path.startsWith(WORKSPACE_OUTPUT_PREFIX)
184
+ ? r.path.slice(WORKSPACE_OUTPUT_PREFIX.length)
185
+ : relPosix(input.workspaceRoot, path.join(g.projectDir, r.path)),
186
+ )
187
+ return { taskId: g.taskId, hash: g.hash, outputs }
188
+ })
189
+ }
190
+ let upstream: TaskInputs['upstream'] | undefined
185
191
  return {
186
192
  hash,
187
193
  facts,
@@ -194,7 +200,9 @@ export async function describeTaskInputs(
194
200
  command,
195
201
  output,
196
202
  })),
197
- upstream,
203
+ get upstream() {
204
+ return (upstream ??= listUpstream())
205
+ },
198
206
  packageJsonDigest: input.projectPackageJsonHash,
199
207
  configDigest: input.taskConfigHash,
200
208
  workspaceFingerprint: input.workspaceFingerprint,
package/src/util/index.ts CHANGED
@@ -52,7 +52,7 @@ export { splitTaskId } from './task-id.js'
52
52
  export { editDistance, listed, nearMatches, nearest } from './edit-distance.js'
53
53
  export { CORE_VERBS, MOVED_VERBS } from './verbs.js'
54
54
  export { isUnsupportedBun, MIN_BUN, unsupportedBunMessage } from './bun-version.js'
55
- export { executablePath, shellArgv } from './which.js'
55
+ export { executablePath, shellArgv, taskShell } from './which.js'
56
56
  export { procfsIsOwn } from './procfs.js'
57
57
  export { realPath } from './real-path.js'
58
58
  export {
package/src/util/which.ts CHANGED
@@ -47,9 +47,31 @@ export function executablePath(name: string): string {
47
47
  return at
48
48
  }
49
49
 
50
+ let dashMissedOn: string | undefined
51
+
52
+ /**
53
+ * The shell vx runs commands in: `dash` on macOS when vx's PATH has it,
54
+ * else `sh` on vx's PATH. macOS's `/bin/sh` is a stub that execs bash, two
55
+ * images for one shell, and bash where Linux runs dash, so a command read
56
+ * one way on a Mac and another on Linux CI (owner, 2026-10-04).
57
+ */
58
+ export function taskShell(): string {
59
+ if (process.platform === 'darwin') {
60
+ const PATH = process.env['PATH'] ?? ''
61
+ if (dashMissedOn !== PATH) {
62
+ try {
63
+ return executablePath('dash')
64
+ } catch {
65
+ dashMissedOn = PATH
66
+ }
67
+ }
68
+ }
69
+ return executablePath('sh')
70
+ }
71
+
50
72
  /**
51
73
  * The argv that runs `script` in the task shell: `sh -c`.
52
74
  */
53
75
  export function shellArgv(script: string): string[] {
54
- return [executablePath('sh'), '-c', script]
76
+ return [taskShell(), '-c', script]
55
77
  }
@@ -738,7 +738,9 @@ const BUN_MEMBERS_VX_READS: readonly PropertyKey[] = [
738
738
  'which',
739
739
  'write',
740
740
  'zstdCompress',
741
+ 'zstdCompressSync',
741
742
  'zstdDecompress',
743
+ 'zstdDecompressSync',
742
744
  ]
743
745
 
744
746
  /** A descriptor the snapshot did not read. */