@vzn/vx 0.0.636 → 0.0.638

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 (151) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +246 -213
  3. package/docs/cli.md +791 -705
  4. package/docs/execution.md +68 -76
  5. package/docs/features.md +47 -42
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +392 -205
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +4 -4
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +28 -13
  19. package/src/bin.ts +30 -9
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +124 -53
  22. package/src/cache/chained-cache.ts +4 -0
  23. package/src/cache/git-inputs.ts +277 -63
  24. package/src/cache/index.ts +15 -1
  25. package/src/cache/inputs.ts +102 -36
  26. package/src/cache/key-fold.ts +8 -7
  27. package/src/cache/layer.ts +15 -18
  28. package/src/cache/layered-cache.ts +20 -102
  29. package/src/cache/plugin-cache.ts +248 -0
  30. package/src/cache/policy.ts +32 -27
  31. package/src/cache/run-history.ts +104 -14
  32. package/src/cache/run-store.ts +77 -0
  33. package/src/cache/schema.ts +58 -31
  34. package/src/cache/scoped-cache.ts +237 -0
  35. package/src/cli/cache.ts +173 -34
  36. package/src/cli/completions.ts +11 -7
  37. package/src/cli/core-alias.ts +1 -1
  38. package/src/cli/foreign-flags.ts +13 -12
  39. package/src/cli/help.ts +110 -70
  40. package/src/cli/index.ts +67 -24
  41. package/src/cli/info.ts +43 -27
  42. package/src/cli/init.ts +115 -407
  43. package/src/cli/last.ts +157 -9
  44. package/src/cli/lock.ts +356 -122
  45. package/src/cli/plan-format.ts +7 -5
  46. package/src/cli/plugin-commands.ts +81 -14
  47. package/src/cli/plugin-templates.ts +11 -11
  48. package/src/cli/run.ts +116 -160
  49. package/src/cli/select.ts +50 -47
  50. package/src/cli/show.ts +83 -59
  51. package/src/cli/task-verb.ts +6 -13
  52. package/src/cli/upgrade.ts +415 -149
  53. package/src/cli/watch-cycle.ts +9 -2
  54. package/src/cli/watch-filter.ts +39 -61
  55. package/src/cli/watch-fs.ts +29 -16
  56. package/src/cli/watch-judge.ts +18 -4
  57. package/src/cli/watch-set.ts +16 -42
  58. package/src/cli/watch.ts +227 -230
  59. package/src/cli/why.ts +38 -18
  60. package/src/cli/workspace-config.ts +33 -70
  61. package/src/config.ts +169 -70
  62. package/src/exec/env.ts +1 -31
  63. package/src/exec/executor.ts +1 -5
  64. package/src/exec/kill-tree.ts +2 -1
  65. package/src/exec/sandbox-runtime.ts +12 -1
  66. package/src/exec/sandbox-violations.ts +9 -1
  67. package/src/graph/dependency-spec.ts +1 -1
  68. package/src/graph/index.ts +1 -0
  69. package/src/graph/priorities.ts +1 -1
  70. package/src/graph/scheduler.ts +27 -7
  71. package/src/graph/task-graph.ts +76 -24
  72. package/src/handoff.ts +169 -0
  73. package/src/index.ts +35 -58
  74. package/src/orchestrator/affected-tasks.ts +15 -8
  75. package/src/orchestrator/command-windows.ts +44 -0
  76. package/src/orchestrator/deferred-outputs.ts +12 -10
  77. package/src/orchestrator/doctor.ts +98 -110
  78. package/src/orchestrator/download-policy.ts +3 -3
  79. package/src/orchestrator/events.ts +19 -3
  80. package/src/orchestrator/excluded-keys.ts +0 -2
  81. package/src/orchestrator/execute-task.ts +147 -69
  82. package/src/orchestrator/failure-mode.ts +19 -10
  83. package/src/orchestrator/fingerprint-watch.ts +18 -15
  84. package/src/orchestrator/forecast.ts +10 -2
  85. package/src/orchestrator/framed-output.ts +9 -5
  86. package/src/orchestrator/graph-stage.ts +5 -5
  87. package/src/orchestrator/history.ts +126 -30
  88. package/src/orchestrator/hit-restore.ts +15 -3
  89. package/src/orchestrator/index.ts +20 -5
  90. package/src/orchestrator/local-shortcircuit.ts +2 -1
  91. package/src/orchestrator/lockfile-claim.ts +79 -20
  92. package/src/orchestrator/logger.ts +80 -41
  93. package/src/orchestrator/metrics.ts +78 -9
  94. package/src/orchestrator/miss-save.ts +3 -0
  95. package/src/orchestrator/options.ts +42 -29
  96. package/src/orchestrator/output-host.ts +72 -0
  97. package/src/orchestrator/plan.ts +6 -8
  98. package/src/orchestrator/plugin-host.ts +100 -66
  99. package/src/orchestrator/plugin.ts +173 -67
  100. package/src/orchestrator/prepare.ts +163 -78
  101. package/src/orchestrator/projects.ts +105 -99
  102. package/src/orchestrator/remote-prefetch.ts +11 -6
  103. package/src/orchestrator/run-context.ts +25 -6
  104. package/src/orchestrator/run-failures.ts +17 -8
  105. package/src/orchestrator/run-lock.ts +1 -1
  106. package/src/orchestrator/run-records.ts +1 -1
  107. package/src/orchestrator/run-report.ts +9 -9
  108. package/src/orchestrator/run-settings.ts +48 -0
  109. package/src/orchestrator/run.ts +244 -119
  110. package/src/orchestrator/sandbox-fix.ts +39 -20
  111. package/src/orchestrator/sandbox-request.ts +84 -13
  112. package/src/orchestrator/signals.ts +1 -1
  113. package/src/orchestrator/stable-keys.ts +13 -11
  114. package/src/orchestrator/status-line.ts +49 -7
  115. package/src/orchestrator/summary.ts +115 -106
  116. package/src/orchestrator/task-hash.ts +12 -83
  117. package/src/orchestrator/telemetry.ts +4 -4
  118. package/src/settings.ts +428 -0
  119. package/src/{orchestrator → util}/colors.ts +5 -0
  120. package/src/util/config-frame.ts +31 -2
  121. package/src/util/errors.ts +33 -1
  122. package/src/util/essential-env.ts +31 -0
  123. package/src/util/hide-env.ts +107 -0
  124. package/src/util/index.ts +16 -3
  125. package/src/util/paths.ts +10 -0
  126. package/src/util/refusal-notice.ts +111 -0
  127. package/src/util/report.ts +31 -0
  128. package/src/util/settle.ts +49 -30
  129. package/src/util/size.ts +23 -4
  130. package/src/util/timing.ts +3 -2
  131. package/src/util/ui.ts +186 -0
  132. package/src/util/ulid.ts +1 -1
  133. package/src/util/verbs.ts +0 -15
  134. package/src/workspace/affected.ts +84 -232
  135. package/src/workspace/config-eval.ts +15 -20
  136. package/src/workspace/config-imports.ts +78 -3
  137. package/src/workspace/config-schema.ts +209 -268
  138. package/src/workspace/dependency-rules.ts +65 -0
  139. package/src/workspace/filter.ts +2 -2
  140. package/src/workspace/fingerprint.ts +11 -121
  141. package/src/workspace/index.ts +20 -27
  142. package/src/workspace/load-reads.ts +4 -4
  143. package/src/workspace/lockfile.ts +96 -140
  144. package/src/workspace/migration.ts +307 -215
  145. package/src/workspace/package-graph.ts +30 -216
  146. package/src/workspace/project-loader.ts +80 -285
  147. package/src/workspace/repo-id.ts +2 -1
  148. package/src/workspace/workspace.ts +363 -831
  149. package/src/cache/config-evals.ts +0 -130
  150. package/src/workspace/config-cache.ts +0 -661
  151. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -1,5 +1,5 @@
1
1
  // The `exec.sandbox.allow` entries that would have granted what a failed
2
- // task was denied, so the report names the line to add (CLI, JSON, MCP)
2
+ // task was denied, so the report names the line to add (CLI, JSON)
3
3
  // rather than leaving the user to turn absolute paths into grants. Built on
4
4
  // the failure path only: a passing run never calls it.
5
5
 
@@ -9,26 +9,22 @@ 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
13
 
13
14
  /** Grants to add under `exec.sandbox.allow`, each list sorted and unique. */
14
15
  type SandboxFix = NonNullable<TaskOutcome['sandboxFix']>
15
- type Kind = Exclude<keyof SandboxFix, 'withheld'>
16
-
17
- // Where credentials live. A denial here is never turned into a grant: an
18
- // agent applies `sandboxFix` as given, so a dependency probing ~/.ssh would
19
- // get its grant suggested and applied (Security, 2026-10-12).
20
- const CREDENTIAL_DIRS = ['.ssh', '.aws', '.gnupg', '.kube', '.config/gh', '.config/gcloud']
21
- const CREDENTIAL_FILES = ['.netrc', '.npmrc', '.docker/config.json', '.git-credentials', '.pypirc']
16
+ type Kind = Exclude<keyof SandboxFix, 'withheld' | 'inputs'>
22
17
 
18
+ // A denial in a credential store is never turned into a grant: an agent
19
+ // applies `sandboxFix` as given, so a dependency probing ~/.ssh would get
20
+ // its grant suggested and applied (Security, 2026-10-12). The stores are
21
+ // the ones the sandbox denies: a hand copy here missed half of them.
23
22
  function isCredential(abs: string): boolean {
24
23
  const base = path.basename(abs)
25
24
  if (base === '.env' || base.startsWith('.env.')) return true
26
25
  const rel = path.relative(os.homedir(), abs)
27
26
  if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return false
28
- return (
29
- CREDENTIAL_FILES.includes(rel) ||
30
- CREDENTIAL_DIRS.some((d) => rel === d || rel.startsWith(d + path.sep))
31
- )
27
+ return CREDENTIAL_STORES.some((d) => rel === d || rel.startsWith(d + path.sep))
32
28
  }
33
29
 
34
30
  /**
@@ -63,6 +59,16 @@ function readsDir(g: string, top: string): boolean {
63
59
  return first === top || !isLiteralPattern(first)
64
60
  }
65
61
 
62
+ const inProject = (grant: string): boolean => !grant.startsWith('~/') && !path.isAbsolute(grant)
63
+
64
+ /** Whether a project-relative path is one the task's input globs match. */
65
+ function keyedBy(globs: readonly string[]): (rel: string) => boolean {
66
+ const strip = (g: string) => g.replace(/^\.\//, '')
67
+ const take = globs.filter((g) => !g.startsWith('!')).map((g) => new Bun.Glob(strip(g)))
68
+ const drop = globs.filter((g) => g.startsWith('!')).map((g) => new Bun.Glob(strip(g.slice(1))))
69
+ return (rel) => take.some((g) => g.match(rel)) && !drop.some((g) => g.match(rel))
70
+ }
71
+
66
72
  function kindOf(v: SandboxViolation): Kind | undefined {
67
73
  const can = v.ignorable ?? []
68
74
  if (can.length === 1) return can[0]
@@ -78,16 +84,22 @@ export function sandboxFix(
78
84
  violations: readonly SandboxViolation[],
79
85
  projectDir: string,
80
86
  mask: (text: string) => string = (t) => t,
81
- /** The task's `cache.inputs.files`: a write under one is granted exactly. */
82
- inputs: readonly string[] = [],
87
+ /**
88
+ * The task's `cache.inputs.files`, undefined when it does not cache: a
89
+ * write under one is granted exactly, and a read of the project no glob
90
+ * matches is named for the key too.
91
+ */
92
+ inputs?: readonly string[],
83
93
  ): SandboxFix | undefined {
84
- const fix: Record<Kind | 'withheld', Set<string>> = {
94
+ const fix: Record<Kind | 'withheld' | 'inputs', Set<string>> = {
85
95
  read: new Set(),
86
96
  write: new Set(),
87
97
  network: new Set(),
88
98
  systemInfo: new Set(),
89
99
  withheld: new Set(),
100
+ inputs: new Set(),
90
101
  }
102
+ const keyed = inputs === undefined ? undefined : keyedBy(inputs)
91
103
  for (const v of violations) {
92
104
  if (v.hint === true || v.target === undefined) continue
93
105
  const kind = kindOf(v)
@@ -96,16 +108,19 @@ export function sandboxFix(
96
108
  else if (kind === 'systemInfo') fix.systemInfo.add(v.target)
97
109
  else if (v.path === undefined) continue
98
110
  else if (isCredential(v.path)) fix.withheld.add(grantPath(v.path, projectDir))
99
- else if (kind === 'read') fix.read.add(grantPath(v.path, projectDir))
100
- else {
111
+ else if (kind === 'read') {
112
+ const grant = grantPath(v.path, projectDir)
113
+ fix.read.add(grant)
114
+ if (keyed !== undefined && inProject(grant) && !keyed(grant)) fix.inputs.add(grant)
115
+ } else {
101
116
  const isDir =
102
117
  v.line.startsWith('mkdir') ||
103
118
  lstatSync(v.path, { throwIfNoEntry: false })?.isDirectory() === true
104
- fix.write.add(writeGrant(grantPath(v.path, projectDir), isDir, inputs))
119
+ fix.write.add(writeGrant(grantPath(v.path, projectDir), isDir, inputs ?? []))
105
120
  }
106
121
  }
107
122
  const out: SandboxFix = {}
108
- for (const k of ['read', 'write', 'network', 'systemInfo', 'withheld'] as const)
123
+ for (const k of ['read', 'write', 'network', 'systemInfo', 'withheld', 'inputs'] as const)
109
124
  if (fix[k].size > 0) out[k] = [...new Set([...fix[k]].map(mask))].sort()
110
125
  return Object.keys(out).length > 0 ? out : undefined
111
126
  }
@@ -114,7 +129,11 @@ export function sandboxFix(
114
129
  * The fix as the config line it is: `exec.sandbox.allow: { read: ['a'] }`,
115
130
  * or undefined when every denial was a withheld credential path.
116
131
  */
117
- export function sandboxFixLine({ withheld: _, ...fix }: SandboxFix): string | undefined {
132
+ export function sandboxFixLine({
133
+ withheld: _,
134
+ inputs: __,
135
+ ...fix
136
+ }: SandboxFix): string | undefined {
118
137
  if (Object.keys(fix).length === 0) return undefined
119
138
  const parts = Object.entries(fix).map(
120
139
  ([k, list]: [string, string[]]) => `${k}: [${list.map((p) => `'${p}'`).join(', ')}]`,
@@ -7,7 +7,7 @@ import { existsSync } from 'node:fs'
7
7
  import { lstat, mkdir, readdir, realpath, rm, writeFile } from 'node:fs/promises'
8
8
  import { homedir } from 'node:os'
9
9
  import path from 'node:path'
10
- import type { ExecConfig } from '../config.js'
10
+ import type { ExecConfig, SandboxConfig } from '../config.js'
11
11
  import {
12
12
  bindableWrites,
13
13
  initSandbox,
@@ -25,7 +25,6 @@ import {
25
25
  } from '../exec/index.js'
26
26
  import type { TaskNode } from '../graph/index.js'
27
27
  import { grantPrefix, relPosix, UserError } from '../util/index.js'
28
- import { WORKSPACE_FINGERPRINT_FILES } from '../workspace/index.js'
29
28
 
30
29
  /**
31
30
  * Arm the sandbox runtime for a run, lazily: only when at least one task
@@ -188,7 +187,7 @@ export async function sandboxRequestFor(
188
187
  /** The projects nested in this one (their dirs): a wall its grants do not reach. */
189
188
  nested: readonly string[] = [],
190
189
  /**
191
- * The run's cache directory. `.vx` is walled wherever it is; a
190
+ * The run's cache directory. `.vx/cache` is walled wherever it is; a
192
191
  * `cacheDir` configured inside a project was not, and a task whose write
193
192
  * grant covered it could plant artifacts and index rows (L-24).
194
193
  */
@@ -234,7 +233,7 @@ export async function sandboxRequestFor(
234
233
  const walls = [
235
234
  ...nested,
236
235
  path.join(workspaceRoot, '.git'),
237
- path.join(workspaceRoot, '.vx'),
236
+ path.join(workspaceRoot, '.vx', 'cache'),
238
237
  ...(cacheDir !== undefined &&
239
238
  toRealPath(cacheDir).startsWith(toRealPath(workspaceRoot) + path.sep)
240
239
  ? [cacheDir]
@@ -290,6 +289,53 @@ export async function sandboxRequestFor(
290
289
  return { sandbox: request, placeholders, withheld: links.withheld }
291
290
  }
292
291
 
292
+ /**
293
+ * Whether a sandboxed task could read `real` (a canonical path inside its
294
+ * project): under one of its read or write grants or its `node_modules`,
295
+ * and not behind a wall. vx packs a symlinked output as its target's
296
+ * bytes, outside the sandbox, so a link the task planted to a file it was
297
+ * denied (`dist/x -> ../.env` under `read: ['src/']`) packed that file
298
+ * into the cache wherever creating the link went unseen: seatbelt, or
299
+ * Linux untraced. The grants are matched as written, not expanded: a
300
+ * glob's hit made by the task itself counts.
301
+ */
302
+ export function sandboxReadable(
303
+ node: TaskNode,
304
+ sandbox: SandboxConfig,
305
+ workspaceRoot: string,
306
+ nested: readonly string[] = [],
307
+ ): (real: string) => boolean {
308
+ const projectReal = toRealPath(node.projectDir)
309
+ const at = (p: string): string =>
310
+ p.startsWith('~')
311
+ ? path.join(homedir(), p.slice(1))
312
+ : path.isAbsolute(p)
313
+ ? p
314
+ : path.join(projectReal, p)
315
+ const grants = [...(sandbox.allow?.read ?? []), ...(sandbox.allow?.write ?? [])].map(at)
316
+ const literals = [
317
+ ...grants.filter(isMountableLiteral).map((g) => toRealPath(g.replace(/\/+$/, '') || '/')),
318
+ toRealPath(path.join(node.projectDir, 'node_modules')),
319
+ toRealPath(path.join(workspaceRoot, 'node_modules')),
320
+ ]
321
+ const globs = grants.filter((g) => !isMountableLiteral(g)).map((g) => new Bun.Glob(g))
322
+ const walls = [
323
+ ...nested,
324
+ path.join(workspaceRoot, '.git'),
325
+ path.join(workspaceRoot, '.vx', 'cache'),
326
+ ].map(toRealPath)
327
+ return (real) => {
328
+ if (walls.some((w) => atOrUnder(real, w))) return false
329
+ if (literals.some((l) => atOrUnder(real, l))) return true
330
+ // A directory a glob matches is granted whole.
331
+ for (let p = real; atOrUnder(p, projectReal); p = path.dirname(p)) {
332
+ if (globs.some((g) => g.match(p))) return true
333
+ if (p === projectReal) break
334
+ }
335
+ return false
336
+ }
337
+ }
338
+
293
339
  /**
294
340
  * The sandbox half of the request, plus the empty files vx created so a
295
341
  * literal write grant had something to bind (`placeholders`). They are
@@ -619,9 +665,9 @@ export function untouchedPlaceholderLine(projectDir: string, placeholder: string
619
665
  /**
620
666
  * Where tools keep credentials under the user's home. A sandboxed task
621
667
  * reads none of them unless its `allow.read` names one (a publish task's
622
- * `~/.npmrc`).
668
+ * `~/.npmrc`), and `sandboxFix` never suggests one as a grant.
623
669
  */
624
- const CREDENTIAL_STORES = [
670
+ export const CREDENTIAL_STORES = [
625
671
  '.ssh',
626
672
  '.gnupg',
627
673
  '.aws',
@@ -647,6 +693,26 @@ const CREDENTIAL_STORES = [
647
693
  '.terraform.d/credentials.tfrc.json',
648
694
  '.vault-token',
649
695
  '.pgpass',
696
+ // Deploy and service CLIs a JS build runs beside, each at its home on
697
+ // Linux and macOS, and the agent CLIs that may run vx itself.
698
+ '.config/configstore/firebase-tools.json',
699
+ '.local/share/com.vercel.cli/auth.json',
700
+ 'Library/Application Support/com.vercel.cli/auth.json',
701
+ '.netlify/config.json',
702
+ '.config/netlify/config.json',
703
+ 'Library/Preferences/netlify/config.json',
704
+ '.wrangler/config',
705
+ '.config/.wrangler/config',
706
+ 'Library/Preferences/.wrangler/config',
707
+ '.fly/config.yml',
708
+ '.supabase/access-token',
709
+ '.config/doctl',
710
+ 'Library/Application Support/doctl',
711
+ '.config/stripe/config.toml',
712
+ '.config/op',
713
+ '.expo/state.json',
714
+ '.claude/.credentials.json',
715
+ '.codex/auth.json',
650
716
  ]
651
717
 
652
718
  /** The stores present on this host, learned once per home. */
@@ -697,7 +763,7 @@ export type WriteReach = 'none' | 'project' | 'workspace'
697
763
  /**
698
764
  * Where a task that ran a command may have written files no declaration
699
765
  * names — the files whose run-start facts (the git snapshot, its index
700
- * OIDs, the `package.json` digest) a later key must not reuse (item 743).
766
+ * OIDs) a later key must not reuse (item 743).
701
767
  *
702
768
  * A cached task declares its outputs and is held to them. A task with no
703
769
  * `cache` block can declare none, so it may have written anywhere
@@ -732,20 +798,25 @@ export function commandWriteReach(node: TaskNode, workspaceRoot: string): WriteR
732
798
  }
733
799
 
734
800
  /**
735
- * May this task's command rewrite a file the workspace fingerprint folds
736
- * (a lockfile, `pnpm-workspace.yaml`)? Those sit at the workspace root, so
801
+ * May this task's command rewrite a root file a plugin claims (`claimed`:
802
+ * a lockfile, `pnpm-workspace.yaml`)? Those sit at the workspace root, so
737
803
  * an unsandboxed task may only when the root is its own project (`pnpm
738
804
  * install` without `--frozen-lockfile` in a root task); a sandboxed one
739
805
  * when a write grant covers one. Anyone else writing there crosses a
740
- * project boundary, out of contract.
806
+ * project boundary, out of contract. With nothing claimed, no key folds a
807
+ * root file this way.
741
808
  */
742
- export function mayWriteFingerprint(node: TaskNode, workspaceRoot: string): boolean {
809
+ export function mayWriteFingerprint(
810
+ node: TaskNode,
811
+ workspaceRoot: string,
812
+ claimed: readonly string[],
813
+ ): boolean {
743
814
  const exec = node.config.exec
744
- if (exec === undefined) return false
815
+ if (exec === undefined || claimed.length === 0) return false
745
816
  if (exec.sandbox === undefined) return node.projectDir === workspaceRoot
746
817
  for (const grant of exec.sandbox.allow?.write ?? []) {
747
818
  const abs = path.resolve(node.projectDir, expandHome(grantPrefix(grant)))
748
- for (const f of WORKSPACE_FINGERPRINT_FILES) {
819
+ for (const f of claimed) {
749
820
  if (atOrUnder(path.join(workspaceRoot, f), abs)) return true
750
821
  }
751
822
  }
@@ -21,7 +21,7 @@ import type { Logger } from './logger.js'
21
21
  /**
22
22
  * How long a SIGTERMed child gets before SIGKILL on the way out. The
23
23
  * end-of-run persistent shutdown grants the same two seconds; both read
24
- * `VX_KILL_GRACE_MS` (util/settle.ts) so the suites that prove the
24
+ * `VX_RUN_KILL_GRACE` (util/settle.ts) so the suites that prove the
25
25
  * escalation do not wait the full grace.
26
26
  */
27
27
  export const SIGNAL_SHUTDOWN_GRACE_MS = 2000
@@ -28,7 +28,8 @@ export interface DeriveStableKeysArgs {
28
28
  nodes: Map<string, TaskNode>
29
29
  cache: CacheLayer
30
30
  workspaceRoot: string
31
- workspaceFingerprint: string
31
+ /** The root files the plugins claim (`VxPlugin.fingerprint`), for `mayWriteFingerprint`. */
32
+ claimedFiles: readonly string[]
32
33
  forwardArgs?: readonly string[] | undefined
33
34
  nestedDirsByProject: Map<string, string[]>
34
35
  gitFilesCache: GitFilesCache
@@ -58,7 +59,7 @@ export interface StableKey {
58
59
  * cache-enabled, non-group tasks paired with their keys.
59
60
  *
60
61
  * Reuses the run's `hashCache`, so execute-task's later computeTaskHash
61
- * for the same node hits the memo (package.json bytes + task config) —
62
+ * for the same node hits the memo (the task config hash, file lists) —
62
63
  * the key bytes are derived here, the heavy per-project hashing is
63
64
  * shared. No cache layer is touched: keys only.
64
65
  */
@@ -82,8 +83,8 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
82
83
  // - wsOutputUpstream: any upstream declares cache.outputs.workspaceFiles
83
84
  // (root-anchored, boundary-ignoring outputs), or has no cache block
84
85
  // and a sandbox write grant elsewhere in the workspace, or may rewrite
85
- // a file the workspace fingerprint folds (a root `pnpm install`): every
86
- // key folds that digest, taken once per run.
86
+ // a root file a plugin claims (a root `pnpm install`): every key folds
87
+ // what the claimant took from it, once per run.
87
88
  // - rewritersById / unfoldedById: the projects of every upstream CACHED
88
89
  // task that may rewrite its own inputs in place (a formatter declaring
89
90
  // no outputs; `commandWriteReach`), and the subset this task's key does
@@ -93,7 +94,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
93
94
  // unfolded dependency covered is still uncovered here: its key is not
94
95
  // in this one.
95
96
  // - wsRewriters: the same for a cached rewriter whose reach is every
96
- // project (a write grant elsewhere in the workspace, or a fingerprinted
97
+ // project (a write grant elsewhere in the workspace, or a claimed root
97
98
  // file, `mayWriteFingerprint`); one this key does not fold makes it
98
99
  // preliminary. A dependency this key folds needs no pass-through here:
99
100
  // a keyed task with such a rewriter unfolded is unstable itself, and
@@ -175,14 +176,17 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
175
176
  ;(wide ??= projects.empty()).add(depNode.projectName)
176
177
  } else if (reach === 'workspace') wsOutputUpstream = true
177
178
  const cached = depNode.config.cache !== undefined
178
- if (!cached && mayWriteFingerprint(depNode, args.workspaceRoot)) wsOutputUpstream = true
179
+ if (!cached && mayWriteFingerprint(depNode, args.workspaceRoot, args.claimedFiles))
180
+ wsOutputUpstream = true
179
181
  if (!anyUnfolded) continue
180
182
  // The cached twin: a formatter rewriting its own input ahead of a
181
183
  // reader that folds no key of it replayed seed B's build under seed A
182
184
  // on the fourth run, the same way (item 750).
183
185
  const rewrites = cached ? commandWriteReach(depNode, args.workspaceRoot) : 'none'
184
186
  const rewritesWs =
185
- cached && (rewrites === 'workspace' || mayWriteFingerprint(depNode, args.workspaceRoot))
187
+ cached &&
188
+ (rewrites === 'workspace' ||
189
+ mayWriteFingerprint(depNode, args.workspaceRoot, args.claimedFiles))
186
190
  const rewritersOfDep = rewritersById.get(dep)
187
191
  const wsRewriterOfDep = wsRewriters.has(dep)
188
192
  if (
@@ -239,7 +243,6 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
239
243
  node,
240
244
  upstream,
241
245
  workspaceRoot: args.workspaceRoot,
242
- workspaceFingerprint: args.workspaceFingerprint,
243
246
  cache: args.cache,
244
247
  forwardArgs: args.forwardArgs,
245
248
  nestedProjectDirs: args.nestedDirsByProject.get(node.projectName) ?? [],
@@ -256,7 +259,6 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
256
259
  node,
257
260
  upstream,
258
261
  workspaceRoot: args.workspaceRoot,
259
- workspaceFingerprint: args.workspaceFingerprint,
260
262
  cache: args.cache,
261
263
  forwardArgs: args.forwardArgs,
262
264
  nestedProjectDirs: args.nestedDirsByProject.get(node.projectName) ?? [],
@@ -339,7 +341,7 @@ export function probesAfterWrites(
339
341
  }
340
342
  if (!anyProbe) return late
341
343
  // A sandbox with no write grant still writes the declared outputs. A
342
- // fingerprinted file is only writable where `commandWriteReach` is not
344
+ // claimed root file is only writable where `commandWriteReach` is not
343
345
  // 'none' already (`mayWriteFingerprint`).
344
346
  const writers = new Set<string>()
345
347
  for (const n of nodes.values()) {
@@ -469,7 +471,7 @@ export function dependsOnSiblingOutputs(
469
471
  if (inputs === undefined || outputs.length === 0) return true
470
472
  // An output the reader's own `!` inputs take back whole is not in its
471
473
  // key: `['**/*', '!dist/**']` beside a `dist/**` producer keys the same
472
- // before and after it, which is what `rules.upfrontKeys` asks a config
474
+ // before and after it, which is what `run.rules.upfrontKeys` asks a config
473
475
  // to write (X-54).
474
476
  const back = inputs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
475
477
  const reach = back.length === 0 ? outputs : outputs.filter((o) => !outputTakenBack(o, back))
@@ -5,7 +5,7 @@
5
5
  // ordinary write erases the status display first, writes its content,
6
6
  // then redraws, so the display can never interleave with task output.
7
7
 
8
- import { paint, type ColorSupport } from './colors.js'
8
+ import { paint, type ColorSupport } from '../util/index.js'
9
9
  import { formatTaskRow, paintIdParts, TIME_COL } from './framed-output.js'
10
10
 
11
11
  const NO_COLOR: ColorSupport = { enabled: false }
@@ -26,6 +26,8 @@ export interface StatusStream {
26
26
  * TTY) means the region is never drawn anyway.
27
27
  */
28
28
  columns?: number
29
+ /** Terminal height in rows, read per draw as `columns` is. */
30
+ rows?: number
29
31
  }
30
32
 
31
33
  export interface OutputWriter {
@@ -137,14 +139,37 @@ export function createOutputWriter(
137
139
  * it. Reachable only on a TTY whose winsize ioctl fails, since a non-TTY
138
140
  * disables the region outright.
139
141
  */
140
- const regionRows = (lines: readonly string[]): number => {
142
+ const lineRows = (line: string): number => {
141
143
  const cols = stream.columns
142
- if (cols === undefined || cols <= 0) return lines.length
144
+ if (cols === undefined || cols <= 0) return 1
145
+ return Math.max(1, Math.ceil(Bun.stringWidth(line) / cols))
146
+ }
147
+ const regionRows = (lines: readonly string[]): number => {
143
148
  let rows = 0
144
- for (const line of lines) rows += Math.max(1, Math.ceil(Bun.stringWidth(line) / cols))
149
+ for (const line of lines) rows += lineRows(line)
145
150
  return rows
146
151
  }
147
152
 
153
+ /**
154
+ * The leading lines that fit the terminal's height, one row spared.
155
+ * Cursor-up stops at the top of the screen, and a row that scrolled into
156
+ * the scrollback is out of the erase's reach: a region taller than the
157
+ * terminal left its top behind on every redraw, one stacked block per tick
158
+ * (VS Code's short panel, 2026-10-14). The first line always shows.
159
+ */
160
+ const fitted = (lines: readonly string[]): readonly string[] => {
161
+ const height = stream.rows
162
+ if (height === undefined || height <= 0) return lines
163
+ let rows = 0
164
+ let n = 0
165
+ for (const line of lines) {
166
+ rows += lineRows(line)
167
+ if (n > 0 && rows > height - 1) break
168
+ n++
169
+ }
170
+ return n === lines.length ? lines : lines.slice(0, n)
171
+ }
172
+
148
173
  // Erase sequence for whatever is currently shown. A single row keeps
149
174
  // the exact legacy bytes (ESC[2K\r); anything taller moves to its
150
175
  // top row and clears to end of screen.
@@ -155,9 +180,10 @@ export function createOutputWriter(
155
180
  // only repeat these bytes.
156
181
  cancelTrailing()
157
182
  const erase = shown ? eraseSeq() : CLEAR
158
- stream.write(erase + current!.join('\n'))
183
+ const lines = fitted(current!)
184
+ stream.write(erase + lines.join('\n'))
159
185
  shown = true
160
- shownRows = regionRows(current!)
186
+ shownRows = regionRows(lines)
161
187
  lastDraw = now()
162
188
  }
163
189
  const set = (lines: readonly string[], o: { force?: boolean }): void => {
@@ -246,6 +272,8 @@ export interface StatusRegionState {
246
272
  blocks?: ReadonlyMap<string, number>
247
273
  /** The running task at the head of the longest unfinished chain. */
248
274
  critical?: string
275
+ /** A running task's usual duration from history; its row shows it. */
276
+ expected?: ReadonlyMap<string, number>
249
277
  /**
250
278
  * Ready persistent tasks (dev servers …) whose children keep
251
279
  * running after their outcome lands. Pinned until runEnd — the
@@ -349,13 +377,14 @@ export function formatStatusRegion(
349
377
  const notes: string[] = []
350
378
  if (s.critical === slot.id && busy > 1) notes.push(paint(WARN, 'critical path', colors))
351
379
  if (waiting > 0) notes.push(dim(`blocks ${waiting}`))
380
+ const usual = s.expected?.get(slot.id)
352
381
  lines.push(
353
382
  formatTaskRow(
354
383
  ' ',
355
384
  Math.max(0, s.nowMs - slot.startedMs),
356
385
  'running',
357
386
  ACCENT,
358
- '',
387
+ usual === undefined ? '' : formatExpected(usual),
359
388
  '',
360
389
  paintPinnedId(slot.id, colors),
361
390
  colors,
@@ -370,3 +399,16 @@ export function formatStatusRegion(
370
399
  lines.push(...s.summaryLines)
371
400
  return lines
372
401
  }
402
+
403
+ /**
404
+ * A running task's usual duration, fixed while the elapsed time counts up
405
+ * toward it (M, 2026-10-14): `~5.2s`, `~42s`, `~2m 05s`. Fits the cache
406
+ * column below 100 minutes.
407
+ */
408
+ function formatExpected(ms: number): string {
409
+ if (ms < 1000) return `~${Math.round(ms)}ms`
410
+ if (ms < 9950) return `~${(ms / 1000).toFixed(1)}s`
411
+ const s = Math.round(ms / 1000)
412
+ if (s < 60) return `~${s}s`
413
+ return `~${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
414
+ }