@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
@@ -9,7 +9,7 @@ import path from 'node:path'
9
9
  import type { SandboxViolation } from '../exec/index.js'
10
10
  import type { TaskOutcome } from '../graph/index.js'
11
11
  import { isLiteralPattern } from '../util/index.js'
12
- import { CREDENTIAL_STORES } from './sandbox-request.js'
12
+ import { credentialStorePaths } from './sandbox-request.js'
13
13
 
14
14
  /** Grants to add under `exec.sandbox.allow`, each list sorted and unique. */
15
15
  type SandboxFix = NonNullable<TaskOutcome['sandboxFix']>
@@ -22,9 +22,7 @@ type Kind = Exclude<keyof SandboxFix, 'withheld' | 'inputs'>
22
22
  function isCredential(abs: string): boolean {
23
23
  const base = path.basename(abs)
24
24
  if (base === '.env' || base.startsWith('.env.')) return true
25
- const rel = path.relative(os.homedir(), abs)
26
- if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return false
27
- return CREDENTIAL_STORES.some((d) => rel === d || rel.startsWith(d + path.sep))
25
+ return credentialStorePaths(os.homedir()).some((d) => abs === d || abs.startsWith(d + path.sep))
28
26
  }
29
27
 
30
28
  /**
@@ -33,7 +31,8 @@ function isCredential(abs: string): boolean {
33
31
  */
34
32
  function grantPath(abs: string, projectDir: string): string {
35
33
  const rel = path.relative(projectDir, abs)
36
- if (rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel)) return rel
34
+ if (rel === '') return '.'
35
+ if (!rel.startsWith('..') && !path.isAbsolute(rel)) return rel
37
36
  const home = os.homedir()
38
37
  return abs.startsWith(home + path.sep) ? `~/${abs.slice(home.length + 1)}` : abs
39
38
  }
@@ -111,6 +110,9 @@ export function sandboxFix(
111
110
  else if (kind === 'read') {
112
111
  const grant = grantPath(v.path, projectDir)
113
112
  fix.read.add(grant)
113
+ // The link the task opened lies in a directory the sandbox lacks: the
114
+ // target alone stays unreachable, and the fix named it again forever.
115
+ if (v.via !== undefined) fix.read.add(grantPath(v.via, projectDir))
114
116
  if (keyed !== undefined && inProject(grant) && !keyed(grant)) fix.inputs.add(grant)
115
117
  } else {
116
118
  const isDir =
@@ -45,6 +45,11 @@ export interface SandboxArmer {
45
45
  readonly armed: boolean
46
46
  }
47
47
 
48
+ /** What the run's sandbox union reads of a task: a `TaskNode`, or a worker's order. */
49
+ export interface SandboxedNode {
50
+ readonly config: { readonly exec?: { readonly sandbox?: ExecConfig['sandbox'] } }
51
+ }
52
+
48
53
  /**
49
54
  * What SRT's run-wide `initialize()` is armed with, folded from every
50
55
  * sandboxed task in the graph — or `null` when the run has none.
@@ -71,7 +76,7 @@ export interface SandboxRunUnion {
71
76
  weakerNested: boolean
72
77
  }
73
78
 
74
- export function sandboxRunUnion(nodes: Iterable<TaskNode>): SandboxRunUnion | null {
79
+ export function sandboxRunUnion(nodes: Iterable<SandboxedNode>): SandboxRunUnion | null {
75
80
  const sandboxed = [...nodes].filter((n) => n.config.exec?.sandbox !== undefined)
76
81
  if (sandboxed.length === 0) return null
77
82
  const weakerNested = sandboxed.every((n) => n.config.exec?.sandbox?.weakerWhenNested === true)
@@ -119,7 +124,7 @@ export function sandboxRunUnion(nodes: Iterable<TaskNode>): SandboxRunUnion | nu
119
124
  * port list (its bridge is a unix socket the task's side creates), lifts
120
125
  * it for the run. macOS keeps per-task precision through vx's own rules.
121
126
  */
122
- export function prepareSandbox(nodes: Iterable<TaskNode>): SandboxArmer | null {
127
+ export function prepareSandbox(nodes: Iterable<SandboxedNode>): SandboxArmer | null {
123
128
  const union = sandboxRunUnion(nodes)
124
129
  if (union === null) return null
125
130
  const { domains, deniedDomains, unixSockets, gitConfig, weakerNested } = union
@@ -162,7 +167,7 @@ export function prepareSandbox(nodes: Iterable<TaskNode>): SandboxArmer | null {
162
167
  * The sandbox half of an `ExecuteRequest` for one task, shared by the
163
168
  * cached path (through the executor) and the persistent path (spawned here).
164
169
  *
165
- * The user's grants derive NOTHING from `cache` (owner, 2026-09-05). Those
170
+ * The user's grants derive NOTHING from `cache` (2026-09-05). Those
166
171
  * are two different questions: `inputs` says what INVALIDATES the
167
172
  * task, `sandbox.allow` says what it may TOUCH. Deriving one from the other
168
173
  * coupled them in both directions — a declaration added for caching
@@ -180,7 +185,7 @@ export function prepareSandbox(nodes: Iterable<TaskNode>): SandboxArmer | null {
180
185
  * with no `cache`, which has no key to be stale.
181
186
  */
182
187
  export async function sandboxRequestFor(
183
- node: TaskNode,
188
+ node: Pick<TaskNode, 'projectDir'>,
184
189
  sandbox: NonNullable<ExecConfig['sandbox']>,
185
190
  workspaceRoot: string,
186
191
  keyed: ReadonlySet<string> | undefined,
@@ -204,6 +209,15 @@ export async function sandboxRequestFor(
204
209
  `pattern, not a name — rename the directory, or run the task without exec.sandbox`,
205
210
  )
206
211
  }
212
+ // The runtime reports a refused touch one line per record: a path holding
213
+ // a line break arrived split, and an undeclared write to the project's own
214
+ // file was judged as outside it, with the wrong hint.
215
+ if (/[\n\r]/.test(home)) {
216
+ throw new UserError(
217
+ `exec.sandbox: ${home} holds a line break, which the sandbox runtime's reports cannot ` +
218
+ `carry, so a refused touch is misreported — rename the directory, or run the task without exec.sandbox`,
219
+ )
220
+ }
207
221
  // Bun's realpath throws ENOENT on a path holding a backslash, and SRT
208
222
  // mounts no path it cannot resolve: the task saw no project and ran in
209
223
  // $HOME.
@@ -221,7 +235,7 @@ export async function sandboxRequestFor(
221
235
  // sibling project, so granting `node_modules` grants a link whose
222
236
  // target is outside it. That target is a dependency, not a reach-out:
223
237
  // no project config should have to name a sibling to import what its
224
- // own `package.json` depends on (owner, 2026-09-05) — when the key
238
+ // own `package.json` depends on (2026-09-05) — when the key
225
239
  // moves with it.
226
240
  const links = await linkedDeps(depDirs, node.projectDir, workspaceRoot, keyed)
227
241
  depDirs.push(...links.granted)
@@ -230,8 +244,10 @@ export async function sandboxRequestFor(
230
244
  // Pre-create what the task said it will write — after the grants are
231
245
  // resolved, which refuses one that leaves the project through a link
232
246
  // before anything is created along it (item 1003).
233
- const walls = [
234
- ...nested,
247
+ // The repository's and vx's own walls are sealed: no write grant may
248
+ // reach inside them either. A nested project is not: a grant into it is
249
+ // as explicit as one into a sibling.
250
+ const sealed = [
235
251
  path.join(workspaceRoot, '.git'),
236
252
  path.join(workspaceRoot, '.vx', 'cache'),
237
253
  ...(cacheDir !== undefined &&
@@ -239,6 +255,7 @@ export async function sandboxRequestFor(
239
255
  ? [cacheDir]
240
256
  : []),
241
257
  ].map(toRealPath)
258
+ const walls = [...nested.map(toRealPath), ...sealed]
242
259
  const resolved = resolveSandboxConfig(sandbox, node.projectDir, walls)
243
260
  // Made before the walls judge the binds: a directory grant naming nothing
244
261
  // yet (`dist/`, `dist/**`) was judged as a file, its bind its parent, and
@@ -246,7 +263,7 @@ export async function sandboxRequestFor(
246
263
  const placeholders = await prepareOutputsForBind(node.projectDir, sandbox.allow?.write ?? [])
247
264
  let config: ResolvedSandboxConfig
248
265
  try {
249
- config = wallOff(resolved, workspaceRoot, walls)
266
+ config = wallOff(resolved, workspaceRoot, walls, sealed)
250
267
  } catch (err) {
251
268
  await sweepPlaceholders(placeholders)
252
269
  throw err
@@ -271,6 +288,7 @@ export async function sandboxRequestFor(
271
288
  baseDenyRead: [
272
289
  ...(process.platform === 'darwin' ? [workspaceRoot, ...walls] : [workspaceRoot]),
273
290
  ...(await credentialStores(workspaceRoot, config.allowWrite)),
291
+ ...(await vxStores(workspaceRoot, config.allowWrite, cacheDir)),
274
292
  ],
275
293
  // …but only denials INSIDE the project are worth reporting. A task
276
294
  // bumping into the wall is the sandbox working, not a finding: the
@@ -285,6 +303,7 @@ export async function sandboxRequestFor(
285
303
  // withholding exists to stop, not the wall.
286
304
  reportLinked: links.withheld.map((w) => w.dir),
287
305
  config,
306
+ ...(keyed === undefined ? {} : { keyed: [...keyed] }),
288
307
  }
289
308
  return { sandbox: request, placeholders, withheld: links.withheld }
290
309
  }
@@ -425,7 +444,7 @@ async function linkedDeps(
425
444
  */
426
445
  export function withheldLinkLine(taskId: string, w: WithheldLink): string {
427
446
  return (
428
- `vx: ${taskId} read \`${w.target}\` through \`${w.link}\`, and its key folds no task of ` +
447
+ `vx: ${taskId} read \`${w.target}\` through \`${w.link}\`, and its key includes no task of ` +
429
448
  `${w.name}, so an edit there would not re-run it. Add a \`dependsOn\` edge that ` +
430
449
  `reaches one (\`^build\` where ${w.name}#build keys its sources, or a \`source\` task: ` +
431
450
  `\`${w.name}#source\`), or grant and key the files yourself (\`allow.read\` plus ` +
@@ -534,20 +553,23 @@ async function fileOnPath(from: string, to: string): Promise<string> {
534
553
  * punched (`punchWalls`); a write grant whose bind would hold one — a file
535
554
  * grant binds its directory on Linux, so `write: ['out.txt']` in a root
536
555
  * project bound the whole workspace writable, `.git` included — is
537
- * refused, as there is no writable bind that leaves a wall out. A glob's
556
+ * refused, as there is no writable bind that leaves a wall out. One
557
+ * inside a sealed wall (`.git/hooks/`, vx's store) is refused too: a
558
+ * hook planted there runs unsandboxed at the next commit. A glob's
538
559
  * hits already stop at the walls (`resolveSandboxConfig` takes them).
539
560
  */
540
561
  function wallOff(
541
562
  config: ResolvedSandboxConfig,
542
563
  workspaceRoot: string,
543
564
  walls: readonly string[],
565
+ sealed: readonly string[],
544
566
  ): ResolvedSandboxConfig {
545
567
  const home = toRealPath(workspaceRoot)
546
568
  for (const bind of bindableWrites(config.allowWrite)) {
547
569
  // A bind outside the workspace is the user's own path (`/tmp/x`,
548
570
  // `~/.cache/y`), spelled there on purpose; only one inside is judged.
549
571
  if (!atOrUnder(bind, home)) continue
550
- const wall = walls.find((w) => atOrUnder(w, bind))
572
+ const wall = walls.find((w) => atOrUnder(w, bind)) ?? sealed.find((w) => atOrUnder(bind, w))
551
573
  if (wall === undefined) continue
552
574
  throw new UserError(
553
575
  `exec.sandbox.allow.write: the grant binding ${bind} would make ${wall} writable — another ` +
@@ -668,8 +690,23 @@ export const CREDENTIAL_STORES = [
668
690
  '.codex/auth.json',
669
691
  ]
670
692
 
671
- /** The stores present on this host, learned once per home. */
672
- let presentStores: { home: string; paths: string[] } | undefined
693
+ /**
694
+ * Each store's path: under `home`, and again where `$XDG_CONFIG_HOME` or
695
+ * `$XDG_DATA_HOME` moves a `.config` or `.local/share` one. A task sees
696
+ * both, so a CLI reads its login there. A relative value is no XDG path.
697
+ */
698
+ export function credentialStorePaths(home: string): string[] {
699
+ const moved = [
700
+ ['.config/', process.env['XDG_CONFIG_HOME']],
701
+ ['.local/share/', process.env['XDG_DATA_HOME']],
702
+ ] as const
703
+ const out = new Set(CREDENTIAL_STORES.map((rel) => path.join(home, rel)))
704
+ for (const rel of CREDENTIAL_STORES)
705
+ for (const [prefix, dir] of moved)
706
+ if (dir !== undefined && path.isAbsolute(dir) && rel.startsWith(prefix))
707
+ out.add(path.join(dir, rel.slice(prefix.length)))
708
+ return [...out]
709
+ }
673
710
 
674
711
  /**
675
712
  * The credential stores to deny, less any that holds the workspace, and
@@ -683,19 +720,38 @@ async function credentialStores(
683
720
  workspaceRoot: string,
684
721
  writes: readonly string[],
685
722
  ): Promise<string[]> {
686
- const home = homedir()
687
- if (presentStores?.home !== home) {
688
- presentStores = {
689
- home,
690
- paths: CREDENTIAL_STORES.map((rel) => path.join(home, rel)).filter((p) => existsSync(p)),
691
- }
692
- }
693
- const stores = presentStores.paths.filter(
694
- (p) => workspaceRoot !== p && !workspaceRoot.startsWith(p + path.sep),
723
+ // Asked per task, never memoized: an unsandboxed task earlier in the run
724
+ // (`npm config set` writing a token) makes a store the first sandboxed
725
+ // task did not see, and a refusal is never decided from a memo.
726
+ const stores = credentialStorePaths(homedir()).filter(
727
+ (p) => existsSync(p) && workspaceRoot !== p && !workspaceRoot.startsWith(p + path.sep),
695
728
  )
696
729
  return (await Promise.all(stores.map((p) => denyAround(toRealPath(p), writes)))).flat()
697
730
  }
698
731
 
732
+ /**
733
+ * vx's own stores outside the workspace: `~/.vx`, which holds every
734
+ * repository's shared store (`resolveStoreRoot`), and a cache directory
735
+ * configured outside it. The rest of home stays readable, so without this
736
+ * a task with no grant read every project's cached outputs, around the
737
+ * cross-project wall and its own key. One inside the workspace is denied
738
+ * with it already.
739
+ */
740
+ async function vxStores(
741
+ workspaceRoot: string,
742
+ writes: readonly string[],
743
+ cacheDir: string | undefined,
744
+ ): Promise<string[]> {
745
+ const root = toRealPath(workspaceRoot)
746
+ // HOME first, as `resolveStoreRoot` reads it.
747
+ const home = process.env['HOME'] || homedir()
748
+ const stores = [path.join(home, '.vx'), ...(cacheDir === undefined ? [] : [cacheDir])]
749
+ .filter((p) => existsSync(p))
750
+ .map(toRealPath)
751
+ .filter((p) => !atOrUnder(root, p) && !atOrUnder(p, root))
752
+ return (await Promise.all(stores.map((p) => denyAround(p, writes)))).flat()
753
+ }
754
+
699
755
  /** `dir` denied, or its entries no write grant lies under or at when one is inside it. */
700
756
  async function denyAround(dir: string, writes: readonly string[]): Promise<string[]> {
701
757
  const inside = writes.filter((w) => atOrUnder(w, dir))
@@ -22,7 +22,7 @@ export function shellVerdict(args: {
22
22
  cwd: string
23
23
  bins: string[]
24
24
  /** The signal that killed the child when the runner saw one (`RunResult.signal`). */
25
- signal?: string | undefined
25
+ signal?: string | number | undefined
26
26
  /** A sandboxed task's view: whether a file is hidden from it (no grant reads it). */
27
27
  hidden?: ((file: string) => boolean) | undefined
28
28
  }): string | undefined {
@@ -84,7 +84,10 @@ function fileVerdict(file: string): string {
84
84
  }
85
85
 
86
86
  /** The signal behind an exit above 128, from the runner's own report or the code alone. */
87
- function signalVerdict(code: number, signal: string | undefined): string | undefined {
87
+ function signalVerdict(code: number, signal: string | number | undefined): string | undefined {
88
+ if (typeof signal === 'number') {
89
+ return `[vx] exit ${code} is how the shell reports a death by signal ${signal}: the process was killed by that signal`
90
+ }
88
91
  const signals = osConstants.signals as Partial<Record<string, number>>
89
92
  let name: string | undefined
90
93
  let num: number | undefined
@@ -96,9 +99,6 @@ function signalVerdict(code: number, signal: string | undefined): string | undef
96
99
  num = code - 128
97
100
  }
98
101
  if (name === undefined || num === undefined) return undefined
99
- // A SIGINT/SIGTERM the runner saw is a shutdown or an outside stop, and
100
- // the orchestrator reverts the task to aborted — that path has its own line.
101
- if (signal !== undefined && (signal === 'SIGINT' || signal === 'SIGTERM')) return undefined
102
102
  const why = SIGNAL_WHY[name] ?? 'the process was killed by that signal'
103
103
  const how =
104
104
  signal !== undefined
@@ -15,7 +15,13 @@
15
15
 
16
16
  import { signalExitCode } from '../exec/index.js'
17
17
  import { claimExitForSignal, hangupIgnored, killGraceMs, settleWithin } from '../util/index.js'
18
- import { holdGroups, killTree, untilGroupsGone } from '../exec/index.js'
18
+ import {
19
+ holdGroups,
20
+ killTree,
21
+ pauseTree,
22
+ restoreTerminalModes,
23
+ untilGroupsGone,
24
+ } from '../exec/index.js'
19
25
  import type { Logger } from '../report/index.js'
20
26
 
21
27
  /**
@@ -135,6 +141,9 @@ export function forwardSignals(args: {
135
141
  // teardown must not throw on the way out
136
142
  }
137
143
  for (const child of everyChild()) killTree(child, 'SIGKILL')
144
+ // The runner restores a task's terminal modes when the task exits,
145
+ // which this exit does not wait for.
146
+ restoreTerminalModes()
138
147
  try {
139
148
  args.cache.close()
140
149
  } catch {
@@ -177,17 +186,33 @@ export function forwardSignals(args: {
177
186
  const onSigint = (): void => onSignal('SIGINT')
178
187
  const onSigterm = (): void => onSignal('SIGTERM')
179
188
  const onSighup = (): void => onSignal('SIGHUP')
189
+ // Ctrl-Z stops the terminal's foreground group, which is vx's alone: a
190
+ // task runs in its own session. vx stopped and its tasks ran on, an
191
+ // interactive one still reading the terminal the shell had taken back.
192
+ // So vx stops each task's group, then itself; `fg` or `bg` continues vx,
193
+ // and vx continues them.
194
+ const onSigtstp = (): void => {
195
+ for (const child of everyChild()) pauseTree(child, 'SIGSTOP')
196
+ process.kill(process.pid, 'SIGSTOP')
197
+ }
198
+ const onSigcont = (): void => {
199
+ for (const child of everyChild()) pauseTree(child, 'SIGCONT')
200
+ }
180
201
  if (args.enabled) {
181
202
  process.on('SIGINT', onSigint)
182
203
  process.on('SIGTERM', onSigterm)
183
204
  // A hang-up vx was started deaf to (nohup) stays ignored.
184
205
  if (!hangupIgnored()) process.on('SIGHUP', onSighup)
206
+ process.on('SIGTSTP', onSigtstp)
207
+ process.on('SIGCONT', onSigcont)
185
208
  }
186
209
  return {
187
210
  remove: () => {
188
211
  process.off('SIGINT', onSigint)
189
212
  process.off('SIGTERM', onSigterm)
190
213
  process.off('SIGHUP', onSighup)
214
+ process.off('SIGTSTP', onSigtstp)
215
+ process.off('SIGCONT', onSigcont)
191
216
  },
192
217
  }
193
218
  }
@@ -57,6 +57,8 @@ export interface DeriveStableKeysArgs {
57
57
  * execute-task reuses the key when the live upstream matches.
58
58
  */
59
59
  groupKeys?: Map<string, UpfrontGroupKey>
60
+ /** Filled with every key the walk makes, in the order it makes them (`RunDerivation`). */
61
+ keys?: Map<string, string>
60
62
  }
61
63
 
62
64
  export interface StableKey {
@@ -79,7 +81,7 @@ export interface StableKey {
79
81
  export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<StableKey[]> {
80
82
  const order = topoOrder(args.nodes)
81
83
  const late = probesAfterWrites(args.nodes, args.workspaceRoot)
82
- const keyById = new Map<string, string>()
84
+ const keyById = args.keys ?? new Map<string, string>()
83
85
  const unstableById = new Set<string>()
84
86
  // Transitive-upstream output producers, accumulated in topo order — the
85
87
  // inputs to the stability gate. A task's key is preliminary if an upstream
@@ -338,6 +340,90 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
338
340
  return stableKeys
339
341
  }
340
342
 
343
+ /**
344
+ * The keys `deriveStableKeys` would make, from a derivation under the same
345
+ * graph, configs and ignored outputs (`readRunDelta`): every task's key
346
+ * is the one remembered unless it is `dirty`, and a dirty task's is derived
347
+ * as the walk derives it, over its deps' keys, in the walk's order. Which
348
+ * tasks are stable and which uncached keys are kept are the remembered
349
+ * verdicts: they read only what the global token names. `keys` is updated
350
+ * in place.
351
+ */
352
+ export async function rederiveKeys(
353
+ args: Omit<DeriveStableKeysArgs, 'keys' | 'groupKeys'>,
354
+ keys: Map<string, string>,
355
+ dirty: ReadonlySet<string>,
356
+ ): Promise<void> {
357
+ for (const [id] of keys) {
358
+ if (!dirty.has(id)) continue
359
+ const node = args.nodes.get(id)!
360
+ const upstream = synthUpstream(node, args.nodes, keys)
361
+ keys.set(
362
+ id,
363
+ isGroupTask(node) && node.config.inputs === undefined
364
+ ? computeGroupHash(upstream)
365
+ : await computeTaskHash({
366
+ node,
367
+ upstream,
368
+ workspaceRoot: args.workspaceRoot,
369
+ cache: args.cache,
370
+ forwardArgs: args.forwardArgs,
371
+ nestedProjectDirs: args.nestedDirsByProject.get(node.projectName) ?? [],
372
+ gitFilesCache: args.gitFilesCache,
373
+ hashCache: args.hashCache,
374
+ }),
375
+ )
376
+ }
377
+ }
378
+
379
+ /**
380
+ * A project's positive outputs indexed by literal head, so a reader finds
381
+ * the ones its inputs reach (`workspaceInputsReach`: a head equal to, above
382
+ * or below an input's) without testing each: one project of 9000 tasks
383
+ * tested 81 million pairs, 6 s of a plan (2026-10-25).
384
+ */
385
+ class ProjectOutputs {
386
+ private readonly globs: string[] = []
387
+ private readonly byHead = new Map<string, number[]>()
388
+ private sorted: string[] | undefined
389
+
390
+ add(glob: string, head: string): void {
391
+ const key = head === '.' ? '' : head
392
+ let at = this.byHead.get(key)
393
+ if (at === undefined) this.byHead.set(key, (at = []))
394
+ at.push(this.globs.length)
395
+ this.globs.push(glob)
396
+ this.sorted = undefined
397
+ }
398
+
399
+ /** The globs `inputs` reach, in the order they were added. */
400
+ reachedBy(inputs: readonly string[]): string[] {
401
+ const hit = new Set<number>()
402
+ const take = (head: string) => {
403
+ for (const i of this.byHead.get(head) ?? []) hit.add(i)
404
+ }
405
+ for (const raw of inputs) {
406
+ if (raw.startsWith('!')) continue
407
+ take('')
408
+ const p = literalPrefix(raw)
409
+ if (p === '') return [...this.globs]
410
+ for (let at = p.indexOf('/'); at !== -1; at = p.indexOf('/', at + 1)) take(p.slice(0, at))
411
+ take(p)
412
+ const sorted = (this.sorted ??= [...this.byHead.keys()].sort())
413
+ const from = `${p}/`
414
+ let lo = 0
415
+ let hi = sorted.length
416
+ while (lo < hi) {
417
+ const mid = (lo + hi) >> 1
418
+ if (sorted[mid]! < from) lo = mid + 1
419
+ else hi = mid
420
+ }
421
+ for (let i = lo; i < sorted.length && sorted[i]!.startsWith(from); i++) take(sorted[i]!)
422
+ }
423
+ return [...hit].sort((a, b) => a - b).map((i) => this.globs[i]!)
424
+ }
425
+ }
426
+
341
427
  /**
342
428
  * The same-project outputs a reader's inputs reach, root-relative, each with
343
429
  * its project dir and project-relative glob: the outputs the gate would key
@@ -350,14 +436,13 @@ export function gatedOutputs(
350
436
  const out = new Map<string, { projectDir: string; glob: string }>()
351
437
  // Each project's positive outputs with their literal heads, read once:
352
438
  // 1,000 projects' tasks spent 5-8 ms here re-splitting the same globs.
353
- const outputsOf = new Map<string, { glob: string; head: string }[]>()
439
+ const outputsOf = new Map<string, ProjectOutputs>()
354
440
  for (const n of nodes.values()) {
355
441
  const files = n.config.cache?.outputs.files
356
442
  if (files === undefined || files.length === 0) continue
357
- let list = outputsOf.get(n.projectName)
358
- if (list === undefined) outputsOf.set(n.projectName, (list = []))
359
- for (const glob of files)
360
- if (!glob.startsWith('!')) list.push({ glob, head: literalPrefix(glob) })
443
+ let po = outputsOf.get(n.projectName)
444
+ if (po === undefined) outputsOf.set(n.projectName, (po = new ProjectOutputs()))
445
+ for (const glob of files) if (!glob.startsWith('!')) po.add(glob, literalPrefix(glob))
361
446
  }
362
447
  if (outputsOf.size === 0) return out
363
448
  for (const n of nodes.values()) {
@@ -366,8 +451,7 @@ export function gatedOutputs(
366
451
  if (inputs === undefined || outputs === undefined || isGroupTask(n)) continue
367
452
  let back: string[] | undefined
368
453
  let rel: string | undefined
369
- for (const { glob: o, head } of outputs) {
370
- if (!workspaceInputsReach(inputs, [head])) continue
454
+ for (const o of outputs.reachedBy(inputs)) {
371
455
  rel ??= relPosix(workspaceRoot, n.projectDir)
372
456
  back ??= inputs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
373
457
  const rooted = rel === '' ? o : `${rel}/${o}`
@@ -1,11 +1,11 @@
1
1
  // vx's own steps (reading the lock, keying, uploads, teardown) rendered as
2
- // tasks (M, 2026-10-15): `vx:<step>`, and `<plugin package>:setup` /
2
+ // tasks (2026-10-15): `vx:<step>`, and `<plugin package>:setup` /
3
3
  // `:teardown`. `:` is no task id's separator, so a step never collides with
4
4
  // a project named `vx` or a plugin package's own `#setup` task. A step is
5
5
  // not a task: no stat, history row or telemetry record counts it.
6
6
  //
7
7
  // A run shows only the steps it waits on, before its first task or its end
8
- // (M, 2026-10-16). One that runs beside the tasks (`vx:prefetch`) is a
8
+ // (2026-10-16). One that runs beside the tasks (`vx:prefetch`) is a
9
9
  // `background` step: never shown, and kept for `vx last` (run-steps.ts).
10
10
  import type { Logger } from '../report/index.js'
11
11
  import type { RunStep } from './run-steps.js'