@vzn/vx 0.0.354 → 0.0.356

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
@@ -5,8 +5,8 @@
5
5
  <h1 align="center">vx</h1>
6
6
 
7
7
  <p align="center">
8
- <strong>A faster runner for your Turborepo or Nx repo.</strong><br>
9
- Keep <code>turbo.json</code> or <code>nx.json</code> and run it unchanged.
8
+ <strong>The fastest task runner for JS monorepos.</strong><br>
9
+ Measured against Turborepo and Nx, each in its own native config.
10
10
  </p>
11
11
 
12
12
  <p align="center">
@@ -18,50 +18,20 @@
18
18
 
19
19
  <!-- bench:start — generated by packages/vx-bench/update-site.ts from results.json; do not hand-edit -->
20
20
 
21
- | 1,090 packages, 3,270 tasks | vx | Turborepo | Nx |
22
- | -------------------------------- | ---------- | ---------- | -------- |
23
- | Cold build: time the runner adds | **9 s** | 1 min 35 s | 31 min |
24
- | Cold build: CPU burned | **34 s** | 1 min 13 s | 114 min |
25
- | Fully cached run | **476 ms** | 760 ms | 3.59 s |
26
- | Overhead per package | **8 ms** | 88 ms | 1,712 ms |
21
+ | 1,090 packages, 3,270 tasks | vx | Turborepo | Nx |
22
+ | -------------------------------- | ---------- | -------------------------- | ------------------------ |
23
+ | Cold build: time the runner adds | **9 s** | 1 min 35 s (vx 91% faster) | 31 min (vx 99% faster) |
24
+ | Cold build: CPU burned | **34 s** | 1 min 13 s (vx 53% faster) | 114 min (vx 99% faster) |
25
+ | Fully cached run | **476 ms** | 760 ms (vx 37% faster) | 3.59 s (vx 86% faster) |
26
+ | Overhead per package | **8 ms** | 88 ms (vx 91% faster) | 1,712 ms (vx 99% faster) |
27
+
28
+ vx N% faster: vx takes N% less time than that tool (1 − vx ÷ theirs); N% slower: N% more (vx ÷ theirs − 1).
27
29
 
28
30
  Time added is the wall time over the tasks' own ideal schedule (3 min 38 s).
29
31
  Same graph, commands and concurrency: [how it is measured](https://vznjs.github.io/vx/benchmarks/).
30
32
 
31
33
  <!-- bench:end -->
32
34
 
33
- ## Tried on real repos
34
-
35
- Public monorepos, each run with its own `turbo.json` or Nx graph against
36
- the tool it ships with, same machine and tasks per row, medians.
37
-
38
- <!-- repos:start — generated by packages/vx-bench/update-site.ts from real-repos.json; do not hand-edit -->
39
-
40
- | Repo | Against | Cold build | Restore outputs | Nothing to do |
41
- | ------------------------------------------------------------------- | ------- | --------------------- | ------------------- | ---------------------- |
42
- | [solidjs/solid](https://github.com/solidjs/solid) | Turbo | 40.6 s / 45.5 s | 66 ms / 127 ms | 51 ms / 95 ms |
43
- | [withastro/astro](https://github.com/withastro/astro) | Turbo | 48.0 s / 57.9 s | 478 ms / 1.43 s | 249 ms / 1.38 s |
44
- | [payloadcms/payload](https://github.com/payloadcms/payload) | Turbo | 126.9 s / 127.5 s | 3.44 s / 3.46 s | 256 ms / **237 ms** |
45
- | [medusajs/medusa](https://github.com/medusajs/medusa) | Turbo | 308 s / 315 s | 3.86 s / 7.16 s | 947 ms / 3.29 s |
46
- | [n8n-io/n8n](https://github.com/n8n-io/n8n) | Turbo | 137.2 s / **133.7 s** | 8.27 s / 12.5 s | 842 ms / 1.36 s |
47
- | [calcom/cal.com](https://github.com/calcom/cal.com) | Turbo | 245.7 s / 250.7 s | 17.4 s / 19.9 s | 14.7 s / 18.5 s |
48
- | [unocss/unocss](https://github.com/unocss/unocss) | Turbo | 45.1 s / 47.3 s | 859 ms / **185 ms** | 770 ms / **126 ms** |
49
- | [vueuse/vueuse](https://github.com/vueuse/vueuse) | Turbo | 21.4 s / 29.2 s | 263 ms / **128 ms** | 246 ms / **51–161 ms** |
50
- | [t3-oss/create-t3-turbo](https://github.com/t3-oss/create-t3-turbo) | Turbo | 27.2 s / 29.6 s | 199 ms / 340 ms | 174 ms / 342 ms |
51
- | [TanStack/query](https://github.com/TanStack/query) | Nx | 34.6 s / 37.1 s | 910 ms / 1.62 s | 291 ms / 1.60 s |
52
- | [strapi/strapi](https://github.com/strapi/strapi) | Nx | 238.2 s / 245.7 s | 1.61 s / 3.94 s | 341 ms / 4.02 s |
53
- | [novuhq/novu](https://github.com/novuhq/novu) | Nx | 291.2 s / 299.4 s | 3.10 s / 9.05 s | 655 ms / 8.55 s |
54
- | [TanStack/router](https://github.com/TanStack/router) | Nx | 123.0 s / 138.0 s | 1.27 s / 3.82 s | 626 ms / 3.91 s |
55
- | [refinedev/refine](https://github.com/refinedev/refine) | Nx | 96.2 s / 105.9 s | 1.06 s / 1.19 s | 303 ms / 1.16 s |
56
-
57
- vx first, theirs second; **bold** marks the other tool winning. These
58
- rows ran vx without a lock (every config evaluated per run); the harness
59
- now runs it from a `vx lock` snapshot (`--frozen`). How each
60
- repo was run, versions, dates and why a tool wins where it does:
61
- [vx on real Turbo and Nx repos](https://vznjs.github.io/vx/benchmarks/real-repos/).
62
-
63
- <!-- repos:end -->
64
-
65
35
  ## Why it is faster
66
36
 
67
37
  - **Nothing boots per task.** No daemon, no Node process per task: vx
@@ -71,46 +41,20 @@ repo was run, versions, dates and why a tool wins where it does:
71
41
  - **One pass over the repo.** One git walk per run; each stat and
72
42
  lookup happens once.
73
43
 
74
- ## Try it on your repo
75
-
76
- In a Turborepo or Nx workspace:
77
-
78
- ```sh
79
- npm install -D @vzn/vx @vzn/vx-migrate
80
- npx vx init # writes vx.workspace.ts, below; nothing else
81
- npx vx run build --all # your turbo.json or Nx targets, run by vx
82
- npx vx run build --all # again: up-to-date in milliseconds
83
- ```
84
-
85
- The one file `vx init` writes (in an Nx repo, `nx` for `turbo`):
86
-
87
- ```ts
88
- import type { WorkspaceConfig } from '@vzn/vx/config'
89
- import { turbo } from '@vzn/vx-migrate'
90
-
91
- export default { plugins: [turbo()] } satisfies WorkspaceConfig
92
- ```
93
-
94
- Nothing is rewritten. `@vzn/vx-migrate` is not on npm yet (first publish
95
- pending); [`examples/turbo`](examples/turbo) runs this setup, and
96
- `packages/vx/tests/try-it.unsafe.test.ts` runs these exact lines on a Turbo
97
- and an Nx repo, on every commit. Which `turbo.json`, `nx.json` and `project.json` keys vx honours,
98
- key by key: [Turbo and Nx config support](https://vznjs.github.io/vx/compare/turbo-nx-support/).
99
-
100
44
  ## Install
101
45
 
102
46
  ```sh
103
47
  npm install -D @vzn/vx # or: pnpm add -D -w · yarn add -D (-W on Yarn 1) · bun add -d
104
48
  ```
105
49
 
106
- The package ships a prebuilt binary for Linux and macOS, x64 and arm64
107
- (Windows via WSL); no Bun needed. The binary alone, from a
50
+ The package ships a prebuilt binary for Linux and macOS, x64 and arm64;
51
+ no Bun needed. The binary alone, from a
108
52
  [release](https://github.com/vznjs/vx/releases), needs no Node either.
53
+ Windows: use WSL.
109
54
 
110
55
  ## Quick start
111
56
 
112
- In a git repo with a `package.json` workspace and no `turbo.json` or
113
- `nx.json` (those get the one file above):
57
+ In a git repo with a `package.json` workspace:
114
58
 
115
59
  ```sh
116
60
  npx vx init # a vx.config.ts per package, from its scripts
@@ -151,13 +95,13 @@ runs on every commit. More: [Quickstart](https://vznjs.github.io/vx/quickstart/)
151
95
  [CLI](packages/vx/docs/cli.md) · [every config field](packages/vx/docs/schema.md) ·
152
96
  [playground](https://vznjs.github.io/vx/playground/) (the planner, in your browser).
153
97
 
154
- ## Configs of your own
98
+ ## Coming from Turbo or Nx
155
99
 
156
- When you want them, `bunx @vzn/vx-migrate` writes a `vx.config.ts` per
157
- package from `turbo.json` or the Nx graph. They derive the same cache
158
- keys, so what `turbo()` cached still hits. `turboCache()` and `nxCache()`
159
- keep your Turbo or Nx remote cache. [Parity map](packages/vx/docs/parity.md) ·
160
- [migration guide](https://vznjs.github.io/vx/guides/migrate/).
100
+ `bunx @vzn/vx-migrate` writes a `vx.config.ts` per package from
101
+ `turbo.json` or the Nx graph; `vx init` in such a repo writes a
102
+ `vx.workspace.ts` that reads them in place. Either is a temporary start:
103
+ move to native vx config, which is what the numbers above measure.
104
+ [Migration guide](https://vznjs.github.io/vx/guides/migrate/).
161
105
 
162
106
  ## More than speed
163
107
 
@@ -197,7 +141,7 @@ machine.
197
141
 
198
142
  | Package | What it adds |
199
143
  | ---------------------------------------------------------- | ----------------------------------------------------------------------------- |
200
- | [`@vzn/vx-migrate`](packages/vx-migrate) | Run a Turbo or Nx repo unchanged; write configs; keep its remote cache |
144
+ | [`@vzn/vx-migrate`](packages/vx-migrate) | Migrate from Turbo or Nx: write native configs, keep its remote cache |
201
145
  | [`@vzn/vx-reapi`](packages/vx-reapi) | Remote cache and remote execution over Bazel's REAPI (NativeLink, BuildBuddy) |
202
146
  | [`@vzn/vx-lockfile`](packages/vx-lockfile) | Key each project on its own lockfile slice: pnpm, bun, npm, yarn |
203
147
  | [`@vzn/vx-otel`](packages/vx-otel) | OpenTelemetry traces, metrics and logs, no SDK |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.354",
3
+ "version": "0.0.356",
4
4
  "description": "A fast, correct 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.354",
25
- "@vzn/vx-linux-arm64": "0.0.354",
26
- "@vzn/vx-darwin-x64": "0.0.354",
27
- "@vzn/vx-darwin-arm64": "0.0.354"
24
+ "@vzn/vx-linux-x64": "0.0.356",
25
+ "@vzn/vx-linux-arm64": "0.0.356",
26
+ "@vzn/vx-darwin-x64": "0.0.356",
27
+ "@vzn/vx-darwin-arm64": "0.0.356"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -122,10 +122,6 @@ const drain = async (body: AsyncIterable<Uint8Array>): Promise<void> => {
122
122
  }
123
123
  }
124
124
 
125
- const WIN32 = process.platform === 'win32'
126
- // A Windows link target may be spelled with either separator.
127
- const LINK_SEP = WIN32 ? /[\\/]/ : '/'
128
-
129
125
  /**
130
126
  * Sidecar shape. `files` maps an entry name to `[mode, mtimeMs]` — a
131
127
  * pair, not an object, because the sidecar is real entropy inside an
@@ -271,9 +267,7 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
271
267
  `output ${shown} is not a regular file (a symlink to a directory?): vx stores regular files only — emit a file there, or narrow cache.outputs.files to the files the task produces, or take it back with a '!' entry`,
272
268
  )
273
269
  }
274
- // Windows reports 0o666 (0o444 read-only) and never an exec bit: a
275
- // Linux restore of that would be world-writable.
276
- const mode = WIN32 ? 0o644 : st.mode & 0o777
270
+ const mode = st.mode & 0o777
277
271
  meta.files[name] = [mode, Math.floor(st.mtimeMs)]
278
272
  return {
279
273
  name,
@@ -788,9 +782,7 @@ class Extractor {
788
782
  let n = 0
789
783
  for (const s of this.staged) {
790
784
  const [mode, mtimeMs] = metaFor(s.name)
791
- // Windows has no mode bits: chmod there only sets the read-only
792
- // attribute, and a read-only restore refuses the next rename over it.
793
- if ((mode & 0o777) !== createdMode && !WIN32) chmodSync(s.tmp, mode & 0o777)
785
+ if ((mode & 0o777) !== createdMode) chmodSync(s.tmp, mode & 0o777)
794
786
  if (mtimeMs !== undefined) {
795
787
  // A Date, not seconds: Bun reads a negative number of seconds as
796
788
  // "now", and a Date before 1970 as itself (Bun 1.4.2).
@@ -904,7 +896,7 @@ async function resolveThrough(p: string): Promise<string> {
904
896
  at = path.parse(link).root
905
897
  rest = link.slice(at.length)
906
898
  }
907
- pending.push(...rest.split(LINK_SEP).filter(Boolean).reverse())
899
+ pending.push(...rest.split('/').filter(Boolean).reverse())
908
900
  }
909
901
  return at
910
902
  }
@@ -2026,8 +2026,8 @@ const SQLITE_FCNTL_PERSIST_WAL = 10
2026
2026
  * Close for real. A plain `close()` leaves the connection open while any
2027
2027
  * statement from `db.prepare()` lives (bun:sqlite 1.4.2 defers it, as
2028
2028
  * `sqlite3_close_v2` does), so `cache.db` and its `-wal` and `-shm` stayed
2029
- * open after `Cache.close()`: a leaked descriptor per run for an embedder,
2030
- * and on Windows a cache directory nothing could delete (O-10).
2029
+ * open after `Cache.close()`: a leaked descriptor per run for an embedder
2030
+ * (O-10).
2031
2031
  * `close(true)` finalizes them and closes.
2032
2032
  */
2033
2033
  function closeDb(db: Database): void {
@@ -556,7 +556,8 @@ export interface BlobSizeMemo {
556
556
  * the ones it lacks from one `cat-file` (65 ms over 3,000 loose objects,
557
557
  * the whole of the cost, measured 2026-10-02). A smudged (racy) entry
558
558
  * records 0 and is hashed from disk. An answer that cannot be read trusts
559
- * nothing.
559
+ * nothing. A filter that keeps the size (`tr a-z A-Z`) is not caught: only
560
+ * a read of every trusted file would be (caching.md, step 12).
560
561
  */
561
562
  async function dropResizedOids(enumeration: GitEnumeration, memo?: BlobSizeMemo): Promise<void> {
562
563
  const { trusted, indexed } = enumeration
@@ -22,7 +22,6 @@ import { rm, rmdir } from 'node:fs/promises'
22
22
  import type { CacheInputs } from '../config.js'
23
23
  import {
24
24
  asTrees,
25
- executablePath,
26
25
  isExecutableMissing,
27
26
  isLiteralPattern,
28
27
  normalizeGlob,
@@ -394,17 +393,15 @@ async function runRuntimeCommand(
394
393
  // vx's own `sh`, resolved on its PATH before the probe's: Bun.spawn looks
395
394
  // a bare name up on the child's PATH, which leads with the project's
396
395
  // `node_modules/.bin`, so a dependency's `sh` bin ran every probe (J-69).
397
- const argv =
398
- process.platform === 'win32' ? shellArgv(command) : [executablePath('sh'), '-c', command]
399
- proc = Bun.spawn(argv, {
396
+ proc = Bun.spawn(shellArgv(command), {
400
397
  cwd,
401
398
  env: { ...process.env, PATH: ambient ? `${prefix}${path.delimiter}${ambient}` : prefix },
402
399
  stdin: 'ignore',
403
400
  stdout: 'pipe',
404
401
  stderr: 'pipe',
405
402
  // Its own group, so the probe's whole tree can be taken down with vx
406
- // (`killProbesOnExit`). Windows has no groups.
407
- detached: process.platform !== 'win32',
403
+ // (`killProbesOnExit`).
404
+ detached: true,
408
405
  })
409
406
  } catch (err) {
410
407
  // The probe runs through `sh -c` like a task: a box without sh names
package/src/cli/help.ts CHANGED
@@ -61,7 +61,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
61
61
  ' foo, @scope/*, ./packages/foo, foo..., ...foo,',
62
62
  ' foo^..., !foo, [<since>]',
63
63
  ' --affected[=<base>] Sugar for --filter "...[<base>]". Default base =',
64
- ' affectedBase, else origin/HEAD, else HEAD~1.',
64
+ ' affectedBase, else origin/HEAD, else main or',
65
+ ' master, else HEAD~1.',
65
66
  " pkg#task Run a specific project's task directly.",
66
67
  '',
67
68
  'Execution (for run):',
package/src/cli/init.ts CHANGED
@@ -193,7 +193,7 @@ async function adopt(
193
193
  ? ''
194
194
  : `${cache}(): ${remote}, so vx shares that remote cache (inert where the variable is unset).\n`
195
195
  process.stdout.write(
196
- `vx init: ${source} found — ${runner}() from @vzn/vx-migrate runs this repo as it is; nothing else written.\n` +
196
+ `vx init: ${source} found — ${runner}() from @vzn/vx-migrate, a temporary start until bunx @vzn/vx-migrate writes native config; nothing else written.\n` +
197
197
  `${wrote}\n${cacheLine}\nnext: ${adoptionNext(root, runner, source)}\n`,
198
198
  )
199
199
  return 0
@@ -21,11 +21,10 @@ const REPO = 'vznjs/vx'
21
21
 
22
22
  /**
23
23
  * A Bun standalone-binary path lives under the bunfs virtual root.
24
- * Marker differs by platform / Bun version: `/$bunfs/...` (posix) and
25
- * `B:\~BUN\...` or `B:/~BUN/...` (windows).
24
+ * Marker: `/$bunfs/...`.
26
25
  */
27
26
  export function isBunfsPath(p: string): boolean {
28
- return p.startsWith('/$bunfs') || p.startsWith('B:\\~BUN') || p.startsWith('B:/~BUN')
27
+ return p.startsWith('/$bunfs')
29
28
  }
30
29
 
31
30
  /**
package/src/config.ts CHANGED
@@ -24,8 +24,9 @@ export interface WorkspaceConfig {
24
24
  cacheRetention?: { olderThan?: string; maxSize?: string }
25
25
  /**
26
26
  * The git ref a bare `--affected` compares with (`origin/develop`).
27
- * `--affected=<base>` overrides it. Omitted → `origin/HEAD`, else
28
- * `HEAD~1`. Never folded into a cache key.
27
+ * `--affected=<base>` overrides it. Omitted → `origin/HEAD`, then the
28
+ * first of `origin/main`, `origin/master`, `main`, `master` that is not
29
+ * HEAD, else `HEAD~1`. Never folded into a cache key.
29
30
  */
30
31
  affectedBase?: string
31
32
  /**
package/src/exec/env.ts CHANGED
@@ -32,22 +32,6 @@ export const ESSENTIAL_ENV: readonly string[] = [
32
32
  'NO_COLOR',
33
33
  'CI',
34
34
  'NODE_OPTIONS',
35
- 'SYSTEMROOT',
36
- 'APPDATA',
37
- 'LOCALAPPDATA',
38
- 'PROGRAMDATA',
39
- 'PROGRAMFILES',
40
- 'PROGRAMFILES(X86)',
41
- 'COMSPEC',
42
- 'PATHEXT',
43
- // Read on Windows by os.homedir(), npm and git, which break without them.
44
- 'SYSTEMDRIVE',
45
- 'WINDIR',
46
- 'USERPROFILE',
47
- 'HOMEDRIVE',
48
- 'HOMEPATH',
49
- 'NUMBER_OF_PROCESSORS',
50
- 'PROCESSOR_ARCHITECTURE',
51
35
  ]
52
36
 
53
37
  /**
@@ -15,25 +15,6 @@ import { executablePath, procfsIsOwn } from '../util/index.js'
15
15
 
16
16
  export type Child = ReturnType<typeof Bun.spawn>
17
17
 
18
- const WIN32 = process.platform === 'win32'
19
-
20
- /**
21
- * Windows has no process groups (`kill(-pid)` is ESRCH, which read as
22
- * "gone" and killed nothing) and no polite signal a console task hears
23
- * from another process, so every stop is `taskkill /T /F`: the child and
24
- * every descendant it still parents. A grandchild whose parent already
25
- * exited is out of the tree's reach (docs/design/windows-2026-09.md).
26
- */
27
- function killTreeWin32(child: Child): void {
28
- try {
29
- Bun.spawn(['taskkill', '/T', '/F', '/PID', String(child.pid)], {
30
- stdio: ['ignore', 'ignore', 'ignore'],
31
- })
32
- } catch {
33
- child.kill()
34
- }
35
- }
36
-
37
18
  /**
38
19
  * A child whose polite signals go down a pipe instead of to its group: a
39
20
  * Linux sandboxed task. The group there is bwrap's, whose monitor dies of
@@ -97,8 +78,6 @@ const GUARD_SCRIPT = [
97
78
  function startGuard(): void {
98
79
  if (guardFd !== undefined) return
99
80
  guardFd = null
100
- // The guard is an `sh` script over process groups; Windows has neither.
101
- if (process.platform === 'win32') return
102
81
  try {
103
82
  const guard = Bun.spawn([executablePath('sh'), '-c', GUARD_SCRIPT], {
104
83
  argv0: 'vx-group-guard',
@@ -227,7 +206,6 @@ export function killTree(child: Child, signal: 'SIGINT' | 'SIGTERM' | 'SIGKILL')
227
206
  // A pid of 0 would name OUR group (kill(0)): a child that never
228
207
  // spawned has nothing to kill.
229
208
  if (!(child.pid > 0) || goneGroups.has(child)) return
230
- if (WIN32) return killTreeWin32(child)
231
209
  const fd = signal === 'SIGKILL' ? undefined : channels.get(child)
232
210
  if (fd !== undefined) {
233
211
  try {
@@ -297,8 +275,6 @@ export async function untilGroupsGone(
297
275
  */
298
276
  function groupAlive(child: Child): boolean {
299
277
  if (!(child.pid > 0) || goneGroups.has(child)) return false
300
- // No groups: the tree kill is `taskkill /T`, and the leader is what is left to ask.
301
- if (WIN32) return child.exitCode === null && child.signalCode === null
302
278
  try {
303
279
  process.kill(-child.pid, 0)
304
280
  } catch (err) {
@@ -4,7 +4,7 @@
4
4
  // plugin claims the task, so it sits at the TAIL of every executor list
5
5
  // (owner, 2026-09-05). An executor plugin places work ELSEWHERE; its
6
6
  // absence is "here".
7
- import { runCommand, signalExitCode } from './runner.js'
7
+ import { runCommand, signalExitCode, stopSignal } from './runner.js'
8
8
  import { runSandboxed } from './sandbox-runtime.js'
9
9
  import type { TaskExecutor } from './executor.js'
10
10
 
@@ -29,8 +29,7 @@ export function localExecutor(): TaskExecutor {
29
29
  // runs after the teardown swept the run's children, and held the run
30
30
  // to the signal's bound.
31
31
  if (req.signal?.aborted === true) {
32
- const reason: unknown = req.signal.reason
33
- const signal = reason === 'SIGINT' || reason === 'SIGHUP' ? reason : 'SIGTERM'
32
+ const signal = stopSignal(req.signal.reason)
34
33
  const exitCode = signalExitCode(signal)
35
34
  return { exitCode, durationMs: 0, stdout: '', stderr: '', signal, violations: [] }
36
35
  }
@@ -4,7 +4,7 @@
4
4
  // process exits. cpuMs / peakRssBytes are then surfaced on RunResult and
5
5
  // folded into the v11 `runs` table by the orchestrator.
6
6
 
7
- import { existsSync, readFileSync } from 'node:fs'
7
+ import { existsSync } from 'node:fs'
8
8
  import { constants as osConstants } from 'node:os'
9
9
  import {
10
10
  shellArgv,
@@ -25,9 +25,8 @@ import {
25
25
  untilGroupsGone,
26
26
  } from './kill-tree.js'
27
27
 
28
- const WIN32 = process.platform === 'win32'
29
- /** `sh` names itself in its errors; `bun exec` on Windows keeps its own. */
30
- const SH_ARGV0 = WIN32 ? {} : { argv0: 'sh' }
28
+ /** `sh` names itself in its errors. */
29
+ const SH_ARGV0 = { argv0: 'sh' }
31
30
 
32
31
  export interface RunResult {
33
32
  exitCode: number
@@ -224,8 +223,6 @@ const SHELL_BUILTINS = new Set([
224
223
  * the shell.
225
224
  */
226
225
  export function execWrap(command: string): string {
227
- // Bun's shell, the task shell on Windows, has no `exec`.
228
- if (WIN32) return command
229
226
  const first = execWord(command)
230
227
  return first === undefined ? command : `exec ${command}`
231
228
  }
@@ -254,6 +251,16 @@ export function signalExitCode(signal: string): number {
254
251
  return num === undefined ? 130 : 128 + num
255
252
  }
256
253
 
254
+ /**
255
+ * The signal a run's stop sends a task, from its abort reason: a task
256
+ * stopped before its spawn exits as if that signal had killed it. As
257
+ * `forwardedSignal` (orchestrator/signals.ts): a hang-up forwards SIGTERM
258
+ * (to many servers SIGHUP means "reload"), so it reads as SIGTERM here.
259
+ */
260
+ export function stopSignal(reason: unknown): 'SIGINT' | 'SIGTERM' {
261
+ return reason === 'SIGINT' ? 'SIGINT' : 'SIGTERM'
262
+ }
263
+
257
264
  /**
258
265
  * The reverse: the signal an exit above 128 stands for (137 → SIGKILL),
259
266
  * by the platform's numbering; undefined for a plain exit. The shell
@@ -484,9 +491,8 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
484
491
  ],
485
492
  // Its own session and process group, so a kill reaches what it
486
493
  // forked (kill-tree.ts). stdin is a pipe, so a background group
487
- // never stops on a terminal read. Windows has no groups, and a
488
- // detached child there has no console.
489
- detached: !WIN32,
494
+ // never stops on a terminal read.
495
+ detached: true,
490
496
  },
491
497
  ),
492
498
  )
@@ -695,9 +701,8 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
695
701
  stdio: ['ignore', 'pipe', 'pipe', ...(guard === undefined ? [] : [guard])],
696
702
  // Its own session and process group, so a kill reaches what it
697
703
  // forked (kill-tree.ts). stdin is ignored, so a background group
698
- // never stops on a terminal read. Windows has no groups, and a
699
- // detached child there has no console.
700
- detached: !WIN32,
704
+ // never stops on a terminal read.
705
+ detached: true,
701
706
  }),
702
707
  )
703
708
  } catch (err) {
@@ -750,18 +755,15 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
750
755
  * `true` read 44 MB through vx while its shell's `VmHWM` was 1.9 MB, and
751
756
  * 300 MB allocated in the parent made `true` read 328 MB (2026-09-12).
752
757
  * Read after the child exits so it covers the whole task's span (the mark
753
- * is monotonic). Linux reads `VmHWM`; elsewhere the current RSS is the
754
- * bound in hand.
758
+ * is monotonic). Linux reads its own peak (`getrusage`); elsewhere the
759
+ * current RSS is the bound in hand.
755
760
  */
756
761
  export function ownRssHighWater(): number {
757
- if (process.platform === 'linux') {
758
- try {
759
- const m = /VmHWM:\s+(\d+) kB/.exec(readFileSync('/proc/self/status', 'utf8'))
760
- if (m !== null) return Number(m[1]) * 1024
761
- } catch {
762
- // /proc unreadable: fall through to the current RSS.
763
- }
764
- }
762
+ // getrusage's own peak, not `/proc/self/status`'s `VmHWM`: one syscall
763
+ // where generating and parsing the status file was ~20 µs a task alone
764
+ // and ~70 ms of a 1,000-task cold run. It is the same mark, raised only
765
+ // by the image this one exec'd from (a shell), within the floor's slack.
766
+ if (process.platform === 'linux') return peakRssBytes(process.resourceUsage().maxRSS)
765
767
  return process.memoryUsage.rss()
766
768
  }
767
769
 
@@ -10,6 +10,7 @@ import {
10
10
  isMountableLiteral,
11
11
  localBindingOn,
12
12
  MOUNT_WILDCARDS,
13
+ toRealPath,
13
14
  unique,
14
15
  } from './sandbox-paths.js'
15
16
  import type { SandboxedRunArgs } from './sandbox-runtime.js'
@@ -27,27 +28,50 @@ type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
27
28
  * is "Device or resource busy"; binding `/w/dist` instead succeeds.
28
29
  *
29
30
  * So on Linux a file-shaped grant is widened to its directory. That IS a
30
- * widening — the task may write its siblings — and it is the narrowest
31
+ * widening — the task may write its siblings, and read them unrefused
32
+ * (`widenedEntries` has those reads reported) — and it is the narrowest
31
33
  * grant the mechanism can express: the alternative is a declared output
32
34
  * the task cannot produce. macOS needs none of this (seatbelt matches
33
35
  * paths, it does not mount), so the grant stays exact there.
34
36
  */
35
37
  export function bindableWrites(paths: readonly string[]): string[] {
36
38
  if (process.platform !== 'linux') return [...paths]
37
- return unique(
38
- paths
39
- .filter((p) => !holdsGlobChar(p))
40
- .map((p) => {
41
- if (!isMountableLiteral(p)) return p
42
- try {
43
- if (statSync(p).isDirectory()) return p
44
- } catch {
45
- // Does not exist yet: `prepareOutputsForBind` creates a file for a
46
- // file-shaped grant, so treat it as one.
47
- }
48
- return path.dirname(p)
49
- }),
50
- )
39
+ return unique(paths.filter((p) => !holdsGlobChar(p)).map(bindOf))
40
+ }
41
+
42
+ function bindOf(p: string): string {
43
+ if (!isMountableLiteral(p)) return p
44
+ try {
45
+ if (statSync(p).isDirectory()) return p
46
+ } catch {
47
+ // Does not exist yet: `prepareOutputsForBind` creates a file for a
48
+ // file-shaped grant, so treat it as one.
49
+ }
50
+ return path.dirname(p)
51
+ }
52
+
53
+ /**
54
+ * What each directory `bindableWrites` widened a file grant to held when
55
+ * the task started, by real path. A read there is never refused, so the
56
+ * strace pass reports one of these no grant covers: a task granted
57
+ * `write: ['out.txt']` read the whole project unreported, and a cached run
58
+ * replayed an undeclared file's old bytes. What the task creates there is
59
+ * its own, not an input, so only these entries count. Empty off Linux.
60
+ */
61
+ export function widenedEntries(paths: readonly string[]): Map<string, Set<string>> {
62
+ const out = new Map<string, Set<string>>()
63
+ if (process.platform !== 'linux') return out
64
+ for (const p of paths) {
65
+ if (holdsGlobChar(p) || bindOf(p) === p) continue
66
+ const dir = toRealPath(path.dirname(p))
67
+ if (out.has(dir)) continue
68
+ try {
69
+ out.set(dir, new Set(readdirSync(dir).map((e) => path.join(dir, e))))
70
+ } catch {
71
+ // Gone or unreadable: nothing there to read.
72
+ }
73
+ }
74
+ return out
51
75
  }
52
76
 
53
77
  /**