@vzn/vx 0.0.491 → 0.0.492

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.491",
3
+ "version": "0.0.492",
4
4
  "description": "The fastest task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.491",
25
- "@vzn/vx-linux-arm64": "0.0.491",
26
- "@vzn/vx-darwin-x64": "0.0.491",
27
- "@vzn/vx-darwin-arm64": "0.0.491"
24
+ "@vzn/vx-linux-x64": "0.0.492",
25
+ "@vzn/vx-linux-arm64": "0.0.492",
26
+ "@vzn/vx-darwin-x64": "0.0.492",
27
+ "@vzn/vx-darwin-arm64": "0.0.492"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -16,6 +16,7 @@ export {
16
16
  type InvocationRecord,
17
17
  KEYED_RUNS_SQL,
18
18
  parseCachePolicy,
19
+ scopeCachePolicy,
19
20
  type RunRecord,
20
21
  type TaskInputRow,
21
22
  WORKSPACE_OUTPUT_PREFIX,
@@ -33,6 +33,7 @@ import type {
33
33
  SaveArgs,
34
34
  } from './cache.js'
35
35
  import { FULL_CACHE_POLICY, type OutputDirRow } from './cache.js'
36
+ import { xxh3hex } from '../util/index.js'
36
37
 
37
38
  /**
38
39
  * What a remote cache layer must provide — THE plugin seam for remote
@@ -157,6 +158,68 @@ function batchOf(hashes: readonly string[]): { batch: string } {
157
158
  return { batch: `of ${hashes.length} artifact${hashes.length === 1 ? '' : 's'}` }
158
159
  }
159
160
 
161
+ /**
162
+ * The remote as an untrusted scope sees it: reads try the scope's key,
163
+ * where a PR's own earlier runs left it, then the trusted one; writes go to the scope's key only. A scope's key is
164
+ * derived from the task key, so every wire stores it unchanged and the
165
+ * trusted keyspace never holds a scoped write. What a scoped read
166
+ * returns is ingested under the TASK key, and ingest refuses an artifact
167
+ * recording another key, so a derived-key collision degrades to a miss.
168
+ */
169
+ class ScopedRemote implements RemoteCacheLayer {
170
+ readonly endpoint?: string
171
+ readonly hasMany?: (hashes: readonly string[]) => Promise<Set<string> | null>
172
+ /** Task keys a batch probe found under the trusted key alone: their GET skips the scope's. */
173
+ private readonly trustedOnly = new Set<string>()
174
+
175
+ constructor(
176
+ private readonly remote: RemoteCacheLayer,
177
+ private readonly scope: string,
178
+ ) {
179
+ if (remote.endpoint !== undefined) this.endpoint = remote.endpoint
180
+ if (remote.hasMany !== undefined) this.hasMany = (hashes) => this.scopedHasMany(hashes)
181
+ }
182
+
183
+ private scoped(hash: string): string {
184
+ return scopedKey(this.scope, hash)
185
+ }
186
+
187
+ async has(hash: string): Promise<boolean> {
188
+ return (await this.remote.has(this.scoped(hash))) || this.remote.has(hash)
189
+ }
190
+
191
+ private async scopedHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
192
+ const scoped = hashes.map((h) => this.scoped(h))
193
+ const [trusted, own] = await Promise.all([
194
+ this.remote.hasMany!(hashes),
195
+ this.remote.hasMany!(scoped),
196
+ ])
197
+ if (trusted == null || own == null) return null
198
+ const found = new Set(trusted)
199
+ for (let i = 0; i < hashes.length; i++) {
200
+ if (own.has(scoped[i]!)) found.add(hashes[i]!)
201
+ else if (trusted.has(hashes[i]!)) this.trustedOnly.add(hashes[i]!)
202
+ }
203
+ return found
204
+ }
205
+
206
+ async get(
207
+ hash: string,
208
+ ): Promise<{ body: Blob | Response; durationMs: number | undefined } | null> {
209
+ if (this.trustedOnly.has(hash)) return this.remote.get(hash)
210
+ return (await this.remote.get(this.scoped(hash))) ?? this.remote.get(hash)
211
+ }
212
+
213
+ put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void> {
214
+ return this.remote.put(this.scoped(hash), body, meta)
215
+ }
216
+ }
217
+
218
+ /** The key an untrusted scope stores `hash` under: task-key shaped, 16 hex. */
219
+ function scopedKey(scope: string, hash: string): string {
220
+ return xxh3hex(`vx-cache-scope\0${scope}\0${hash}`)
221
+ }
222
+
160
223
  export interface LayeredCacheOptions {
161
224
  /**
162
225
  * Called for the remote failures the layer degrades to a miss, once per
@@ -222,12 +285,17 @@ export class LayeredCache implements CacheLayer {
222
285
  */
223
286
  private readonly reported = new Map<string, { cause: string; repeats: number }>()
224
287
 
288
+ /** The remote as this run's scope sees it: `remote` itself when trusted. */
289
+ private readonly wire: RemoteCacheLayer
290
+
225
291
  constructor(
226
292
  readonly local: Cache,
227
293
  private readonly remote: RemoteCacheLayer,
228
294
  private readonly options: LayeredCacheOptions = {},
229
295
  ) {
230
296
  this.policy = options.policy ?? FULL_CACHE_POLICY
297
+ const scope = this.policy.remoteScope
298
+ this.wire = scope === undefined ? remote : new ScopedRemote(remote, scope)
231
299
  this.endpoint = printableEndpoint(remote.endpoint)
232
300
  }
233
301
 
@@ -254,11 +322,11 @@ export class LayeredCache implements CacheLayer {
254
322
  * degrades to "no batch info" and the caller falls back to per-hash.
255
323
  */
256
324
  async remoteHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
257
- if (!this.policy.remoteRead || this.remote.hasMany === undefined) return null
325
+ if (!this.policy.remoteRead || this.wire.hasMany === undefined) return null
258
326
  try {
259
327
  // `return await`, deliberately: the catch below is the never-fail
260
328
  // contract, and a returned promise's rejection would sail past it.
261
- const found: unknown = await this.remote.hasMany(hashes)
329
+ const found: unknown = await this.wire.hasMany(hashes)
262
330
  if (found !== null && found !== undefined && !(found instanceof Set)) {
263
331
  this.reportRemoteError(
264
332
  'probe',
@@ -337,7 +405,7 @@ export class LayeredCache implements CacheLayer {
337
405
  if ((await this.local.has(hash)) === 'local') return 'local'
338
406
  if (!this.policy.remoteRead) return null
339
407
  try {
340
- return (await this.remote.has(hash)) ? 'remote' : null
408
+ return (await this.wire.has(hash)) ? 'remote' : null
341
409
  } catch (err) {
342
410
  this.reportRemoteError('probe', hash, err)
343
411
  return null
@@ -373,7 +441,7 @@ export class LayeredCache implements CacheLayer {
373
441
 
374
442
  let remoteResult: unknown
375
443
  try {
376
- remoteResult = await this.remote.get(hash)
444
+ remoteResult = await this.wire.get(hash)
377
445
  } catch (err) {
378
446
  this.reportRemoteError('download', hash, err)
379
447
  return false
@@ -478,7 +546,7 @@ export class LayeredCache implements CacheLayer {
478
546
  try {
479
547
  const body =
480
548
  packed !== undefined ? new Blob([packed]) : Bun.file(this.local.outputsPath(hash))
481
- await this.remote.put(hash, body, { durationMs })
549
+ await this.wire.put(hash, body, { durationMs })
482
550
  } catch (err) {
483
551
  this.reportRemoteError('upload', hash, err)
484
552
  }
@@ -20,6 +20,12 @@ export interface CachePolicy {
20
20
  localWrite: boolean
21
21
  remoteRead: boolean
22
22
  remoteWrite: boolean
23
+ /**
24
+ * An untrusted scope (`cacheScope: 'pr-123'`): remote reads try this
25
+ * scope's keys, then the trusted ones; remote writes go to this scope's
26
+ * keys only. Absent = trusted. See {@link scopeCachePolicy}.
27
+ */
28
+ remoteScope?: string
23
29
  }
24
30
 
25
31
  /** All four axes on — the default when no cache flag is passed. */
@@ -84,3 +90,17 @@ export function parseCachePolicy(spec: string, base: CachePolicy = FULL_CACHE_PO
84
90
  }
85
91
  return out
86
92
  }
93
+
94
+ /**
95
+ * Apply a workspace's `cacheScope` to the run's policy. `'trusted'` (or
96
+ * none) changes nothing; `'read-only'` turns remote writes off; any other
97
+ * name is an untrusted scope. A clamp, never a widening: `--cache` still
98
+ * decides which axes are on. A client-side convention, not a boundary —
99
+ * any run holding a write credential can write a trusted key; only the
100
+ * cache server, by token, can refuse it (docs/security.md).
101
+ */
102
+ export function scopeCachePolicy(policy: CachePolicy, scope: string | undefined): CachePolicy {
103
+ if (scope === undefined || scope === 'trusted') return policy
104
+ if (scope === 'read-only') return { ...policy, remoteWrite: false }
105
+ return { ...policy, remoteScope: scope }
106
+ }
package/src/cli/run.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { appendFileSync, mkdirSync } from 'node:fs'
2
2
  import path from 'node:path'
3
+ import { isatty } from 'node:tty'
3
4
  import { translateForeign } from './foreign-flags.js'
4
5
  import { flagHint, seeHelp } from './help.js'
5
6
  import { defaultAffectedBase, findWorkspaceRoot } from '../workspace/index.js'
@@ -422,6 +423,11 @@ export function detectFlow(
422
423
  : 'focused'
423
424
  }
424
425
 
426
+ /** `CI=0` / `CI=false` are not CI, as the output flow reads them. */
427
+ function onCI(v: string | undefined): boolean {
428
+ return v !== undefined && v !== '' && v !== '0' && v !== 'false'
429
+ }
430
+
425
431
  /**
426
432
  * Resolve parsed `vx run` argv into the `RunOptions` the orchestrator
427
433
  * consumes. Shared between `runCmd` and `watchCmd` so both subcommands
@@ -535,12 +541,20 @@ export async function resolveRunOptions(
535
541
  tasks: [...tasks],
536
542
  cache: parsed.cache,
537
543
  ...(parsed.remoteRequested ? { remoteRequested: true } : {}),
544
+ // A laptop reads the shared cache and never writes it (owner,
545
+ // 2026-10-03); naming the remote in `--cache` is asking to.
546
+ ...(!parsed.remoteRequested && !onCI(process.env['CI'])
547
+ ? { defaultCacheScope: 'read-only' }
548
+ : {}),
538
549
  flow: detectFlow(parsed),
539
550
  ...(parsed.frozen ? { frozen: true } : {}),
540
551
  ...(parsed.outputLogs !== undefined ? { outputLogs: parsed.outputLogs } : {}),
541
552
  ...(parsed.download !== undefined ? { download: parsed.download } : {}),
542
553
  ...(parsed.continueMode !== undefined ? { continueMode: parsed.continueMode } : {}),
543
554
  forwardArgs: parsed.forwardArgs,
555
+ // isatty, not process.stdin: touching the stream opens it, and a config
556
+ // that throws then reports an extra native stack frame.
557
+ ...(isatty(0) ? { tty: true } : {}),
544
558
  }
545
559
  if (parsed.excludeDependencies === 'all') {
546
560
  opts.excludeDependencies = 'all'
package/src/config.ts CHANGED
@@ -29,6 +29,19 @@ export interface WorkspaceConfig {
29
29
  * HEAD, else `HEAD~1`. Never folded into a cache key.
30
30
  */
31
31
  affectedBase?: string
32
+ /**
33
+ * Where this run's remote cache writes land. `'trusted'` reads and
34
+ * writes the trusted keys, for the default branch's CI. `'read-only'`
35
+ * reads them and writes nothing; omitted, `vx run` off CI takes it, and
36
+ * on CI (or with `--cache` naming the remote) `'trusted'`. Any other
37
+ * name (`'pr-123'`) is an untrusted scope: reads try the scope's keys,
38
+ * then the trusted ones, and writes go to the scope's only, so a PR never
39
+ * writes what the default branch reads. A client-side convention, not a
40
+ * security boundary: only a cache server that scopes writes by token
41
+ * can refuse a run that ignores it. `VX_CACHE_SCOPE` beats it. Never
42
+ * folded into a cache key.
43
+ */
44
+ cacheScope?: string
32
45
  /**
33
46
  * Plugins registered for this workspace, consulted in this order once
34
47
  * per `vx run`: each fills any of the pipeline stages and capabilities
@@ -362,6 +375,16 @@ export interface ExecConfig {
362
375
  * to cache and the work continues after vx's run notion of "done".
363
376
  */
364
377
  persistent?: PersistentConfig
378
+ /**
379
+ * The task reads the terminal: a prompt, a REPL, a watch mode's keys.
380
+ * When vx's stdin is a TTY the task gets vx's stdin, stdout and stderr
381
+ * as they are, and no other task starts while it runs (a persistent one:
382
+ * until it is spawned). It runs in its own session, so `/dev/tty` still
383
+ * cannot be opened. Off a TTY (CI) it runs as any task. Always on this
384
+ * machine, never cached; not allowed with `cache`, `sandbox` or
385
+ * `persistent.readyWhen`.
386
+ */
387
+ interactive?: boolean
365
388
  /**
366
389
  * Confine this task's process: what it may read, write and reach.
367
390
  * Nothing is confined unless this is present.
@@ -5,7 +5,8 @@
5
5
  // every executor list — what a plugin executor declines runs here.
6
6
  // Persistent tasks (`exec.persistent`) never reach an executor: they are local by
7
7
  // construction (a worker cannot hand the submitter a listening port) and
8
- // stay on `runPersistent`.
8
+ // stay on `runPersistent`. An `exec.interactive` task reaches the local one
9
+ // only: no plugin can hand it this terminal.
9
10
  //
10
11
  // Lives in `exec/` (not `orchestrator/`) so the contract depends only on
11
12
  // process primitives — the module-boundary matrix forbids `exec` → `cache`,
@@ -152,6 +153,12 @@ export interface ExecuteRequest {
152
153
  /** See `RunOptions.liveChildren`: the run's SIGINT/SIGTERM registry. */
153
154
  readonly liveChildren?: Set<ReturnType<typeof Bun.spawn>>
154
155
  readonly sandbox?: ExecuteSandbox
156
+ /**
157
+ * The task holds vx's terminal (`exec.interactive` on a TTY): its stdin,
158
+ * stdout and stderr are vx's own, and the callbacks hear nothing. Only
159
+ * core's local executor is ever handed such a task (placement.ts).
160
+ */
161
+ readonly terminal?: true
155
162
  }
156
163
 
157
164
  export interface ExecuteResult extends RunResult {
@@ -45,7 +45,7 @@ export function localExecutor(): TaskExecutor {
45
45
  ...(req.timeoutMs !== undefined ? { timeoutMs: req.timeoutMs } : {}),
46
46
  }
47
47
  if (req.sandbox === undefined) {
48
- const res = await runCommand(common)
48
+ const res = await runCommand(req.terminal === true ? { ...common, terminal: true } : common)
49
49
  return { ...res, violations: [] }
50
50
  }
51
51
  return runSandboxed({
@@ -87,6 +87,19 @@ export interface RunOptions {
87
87
  * handler can SIGTERM everything still alive mid-run.
88
88
  */
89
89
  liveChildren?: Set<ReturnType<typeof Bun.spawn>>
90
+ /**
91
+ * Hand the child vx's own stdin, stdout and stderr (`exec.interactive` on
92
+ * a TTY). Nothing passes through vx: the callbacks hear nothing and the
93
+ * result's streams are empty.
94
+ */
95
+ terminal?: boolean
96
+ }
97
+
98
+ /** A child's descriptors 0–2: vx's own for a task that holds the terminal. */
99
+ function taskStdio(terminal: boolean | undefined, stdin: 'ignore' | 'pipe') {
100
+ return terminal === true
101
+ ? (['inherit', 'inherit', 'inherit'] as const)
102
+ : ([stdin, 'pipe', 'pipe'] as const)
90
103
  }
91
104
 
92
105
  export function shellQuote(arg: string): string {
@@ -492,20 +505,19 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
492
505
  // A pipe vx holds and never writes: stdin stays open while vx
493
506
  // lives and ends when it does. A dev server that exits on stdin
494
507
  // EOF (esbuild --watch, Vite's case in turborepo#8915) became ready
495
- // and exited 0 under 'ignore'. Not the terminal: several servers
496
- // would steal each other's keystrokes, and a CI's /dev/null stdin
497
- // is the same EOF. The one-shot spawn below keeps 'ignore', so a
498
- // task that reads stdin can never hang CI.
508
+ // and exited 0 under 'ignore'. Not the terminal unless the task
509
+ // asks (`terminal`, one per run): several servers would steal each
510
+ // other's keystrokes, and a CI's /dev/null stdin is the same EOF.
511
+ // The one-shot spawn below keeps 'ignore', so a task that reads
512
+ // stdin can never hang CI.
499
513
  stdio: [
500
- 'pipe',
501
- 'pipe',
502
- 'pipe',
514
+ ...taskStdio(opts.terminal, 'pipe'),
503
515
  ...(signalFd ? ['pipe' as const] : []),
504
516
  ...(guard === undefined ? [] : [guard]),
505
517
  ],
506
518
  // Its own session and process group, so a kill reaches what it
507
- // forked (kill-tree.ts). stdin is a pipe, so a background group
508
- // never stops on a terminal read.
519
+ // forked (kill-tree.ts). The terminal, when it is handed over, is
520
+ // not this session's controlling one, so no read stops the group.
509
521
  detached: true,
510
522
  },
511
523
  ),
@@ -712,10 +724,10 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
712
724
  ...SH_ARGV0,
713
725
  cwd: opts.cwd,
714
726
  env: opts.env as Record<string, string>,
715
- stdio: ['ignore', 'pipe', 'pipe', ...(guard === undefined ? [] : [guard])],
727
+ stdio: [...taskStdio(opts.terminal, 'ignore'), ...(guard === undefined ? [] : [guard])],
716
728
  // Its own session and process group, so a kill reaches what it
717
- // forked (kill-tree.ts). stdin is ignored, so a background group
718
- // never stops on a terminal read.
729
+ // forked (kill-tree.ts). A terminal handed over is not this
730
+ // session's controlling one, so no read stops the group.
719
731
  detached: true,
720
732
  }),
721
733
  )
@@ -877,11 +889,8 @@ export async function streamToString(
877
889
  signal?: AbortSignal,
878
890
  retain = true,
879
891
  ): Promise<string> {
880
- // Bun.spawn types stdout/stderr as `ReadableStream | number | undefined`
881
- // — the `number` is for inheritance modes, only present when the caller
882
- // chose `'inherit'` instead of `'pipe'`. We only call this with `'pipe'`,
883
- // so the runtime value is always a ReadableStream; the `number` branch
884
- // is unreachable but typed.
892
+ // Bun.spawn types stdout/stderr as `ReadableStream | number | undefined`;
893
+ // a stream the child inherited (`terminal`) is not one, and holds nothing.
885
894
  if (!stream || typeof stream === 'number') return ''
886
895
  const full = new BoundedCapture()
887
896
  const reader = stream.getReader()
@@ -251,6 +251,13 @@ export interface ScheduleOptions {
251
251
  * pooled tasks hold no local resources and are never asked.
252
252
  */
253
253
  admit?: (id: string, running: ReadonlySet<string>) => boolean
254
+ /**
255
+ * Tasks that hold the terminal (`exec.interactive` on a TTY). One starts
256
+ * only once nothing runs, and nothing starts while it runs. When one is
257
+ * the next to start, dispatch stops so the running tasks drain. A
258
+ * persistent one holds it until `execute` resolves, at spawn.
259
+ */
260
+ exclusive?: ReadonlySet<string>
254
261
  }
255
262
 
256
263
  /**
@@ -477,7 +484,22 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
477
484
  if (pool === undefined) return execRoom()
478
485
  return (poolActive.get(pool.name) ?? 0) < pool.capacity
479
486
  }
487
+ const exclusive = options.exclusive
488
+ let holding = false
489
+ const busy = (): boolean => {
490
+ if (active > 0 || activeRestore > 0) return true
491
+ for (const n of poolActive.values()) if (n > 0) return true
492
+ return false
493
+ }
480
494
  const admit = (id: string): (() => void) => {
495
+ if (exclusive?.has(id) === true) {
496
+ holding = true
497
+ active++
498
+ return () => {
499
+ holding = false
500
+ active--
501
+ }
502
+ }
481
503
  if (inRestoreTier(id)) {
482
504
  activeRestore++
483
505
  return () => {
@@ -643,6 +665,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
643
665
  // task returns without asking (finishing it is free); restore
644
666
  // tasks are never asked, so they never park.
645
667
  const takeFitting = (): string | undefined => {
668
+ if (holding) return undefined
646
669
  // No pools, no policy and a full exec lane: nothing on the
647
670
  // exec queue can be admitted, so it is not scanned — scanning it
648
671
  // would pop and re-park every ready exec task on every tick, and
@@ -656,6 +679,11 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
656
679
  const seq = execReady.peekSeq()
657
680
  const id = execReady.pop() as string
658
681
  if (willSkip(id)) return id
682
+ if (exclusive?.has(id) === true) {
683
+ if (!busy() && (!admitActive || admits(id))) return id
684
+ parked.push([id, seq])
685
+ return undefined
686
+ }
659
687
  if (hasRoom(id)) {
660
688
  if (!admitActive || admits(id)) return id
661
689
  // A free worker, refused by the policy: the hold starts now.
@@ -227,6 +227,8 @@ export interface ExecuteArgs {
227
227
  * walk cost 1,000 read-only misses 1.62 → 1.78 s, item 750).
228
228
  */
229
229
  noDependants?: true
230
+ /** The task holds vx's terminal (`terminalHolders`): its stdio is vx's own. */
231
+ terminal?: true
230
232
  }
231
233
 
232
234
  /**
@@ -362,6 +364,7 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
362
364
  onStderr: serverErr ? (chunk) => serverErr.push(chunk) : (chunk) => log.taskStderr(node, chunk),
363
365
  ...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
364
366
  ...(signalChannel ? { signalChannel } : {}),
367
+ ...(args.terminal === true ? { terminal: true } : {}),
365
368
  }
366
369
  if (step.persistent.readyWhen !== undefined) {
367
370
  persistentOpts.readyWhen = step.persistent.readyWhen
@@ -1146,6 +1149,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1146
1149
  // through this flag — the policy gates above only cover vx's OWN cache.
1147
1150
  ...(cfgCacheable && !(policy.localRead || policy.remoteRead) ? { refresh: true } : {}),
1148
1151
  outputs: { files: outputs, workspaceFiles: wsOutputs },
1152
+ ...(args.terminal === true ? { terminal: true as const } : {}),
1149
1153
  }
1150
1154
  if (!userSandbox) return base
1151
1155
  await args.armSandbox?.()
@@ -178,7 +178,7 @@ export function defaultLogger(
178
178
  colors: ColorSupport = detectColors(),
179
179
  view: OutputView = { mode: 'full' },
180
180
  out: StatusStream = process.stdout,
181
- opts: { forceFloorMs?: number; coalesce?: boolean } = {},
181
+ opts: { forceFloorMs?: number; coalesce?: boolean; tty?: boolean } = {},
182
182
  ): DefaultLogger {
183
183
  // Per-task buffers, split by stream. Splitting lets the framed-output
184
184
  // renderer put stdout under `├─ stdout` and stderr under `├─ stderr`.
@@ -440,8 +440,16 @@ export function defaultLogger(
440
440
  // group (see markSurfacedDeps) — is a "primary" node the focused
441
441
  // view shows. Groups never stream (no output of their own).
442
442
  const isPrimary = (node: TaskNode): boolean => node.requested || node.surfaced === true
443
+ // A task handed the terminal writes to it directly, so the status region
444
+ // goes for good (it would repaint over a prompt) and, in the views that
445
+ // frame executed work, a live frame opens before it and closes after;
446
+ // what vx itself says about it streams inside.
447
+ const holdsTerminal = (node: TaskNode): boolean =>
448
+ opts.tty === true && node.config.exec?.interactive === true
449
+ const framesTerminal = view.mode === 'full' || view.mode === 'focused' || view.mode === 'broad'
443
450
  const streamsLive = (node: TaskNode): boolean =>
444
- view.mode === 'focused' && isPrimary(node) && !isGroupTask(node) && requestedCount <= 1
451
+ (framesTerminal && holdsTerminal(node)) ||
452
+ (view.mode === 'focused' && isPrimary(node) && !isGroupTask(node) && requestedCount <= 1)
445
453
 
446
454
  return {
447
455
  settle() {
@@ -487,6 +495,11 @@ export function defaultLogger(
487
495
  },
488
496
  taskStart(node) {
489
497
  if (isGroupTask(node)) return
498
+ if (holdsTerminal(node)) {
499
+ killStatus()
500
+ // What is coalesced lands before the task writes past it.
501
+ writer.settle()
502
+ }
490
503
  // Focused flow: the status line exists for the dependency
491
504
  // phase only. The moment a requested node starts streaming,
492
505
  // clear it for good — its raw output owns the terminal now.
@@ -647,7 +660,10 @@ export function defaultLogger(
647
660
  // the terminal where a frame would have: a task that traps Ctrl-C
648
661
  // and exits 0 is aborted since item 962, and its frame was dropped
649
662
  // with the count.
650
- const framed = view.mode === 'full' || (view.mode === 'focused' && isPrimary(node))
663
+ const framed =
664
+ view.mode === 'full' ||
665
+ (view.mode === 'focused' && isPrimary(node)) ||
666
+ (framesTerminal && holdsTerminal(node))
651
667
  if (framed && !isGroupTask(node)) {
652
668
  if (streamsLive(node)) {
653
669
  if (streamMidLine) {
@@ -713,6 +729,14 @@ export function defaultLogger(
713
729
  refresh(true)
714
730
  }
715
731
  if (isGroupTask(node)) return
732
+ if (holdsTerminal(node) && streamsLive(node) && outcome.status !== 'skipped') {
733
+ if (streamMidLine) {
734
+ writer.write('\n')
735
+ streamMidLine = false
736
+ }
737
+ emitFrameClose(formatFrameClose(node, outcome, colors))
738
+ return
739
+ }
716
740
  const isHit = isCacheHit(outcome.status)
717
741
  switch (view.mode) {
718
742
  case 'none':
@@ -70,6 +70,12 @@ export interface RunOptions {
70
70
  * saying it is not.
71
71
  */
72
72
  remoteRequested?: boolean
73
+ /**
74
+ * The `cacheScope` a run takes when `vx.workspace` names none. The CLI
75
+ * passes `'read-only'` off CI (`CI` unset, `0` or `false`) unless
76
+ * `--cache` names the remote; omitted → trusted.
77
+ */
78
+ defaultCacheScope?: string
73
79
  /**
74
80
  * CI mode: load configs FROM the committed vx-lock.json instead of
75
81
  * evaluating them (frozen-env reproducibility). Requires the lock
@@ -189,6 +195,12 @@ export interface RunOptions {
189
195
  * is replaced only when the next cycle starts.
190
196
  */
191
197
  holdPersistent?: boolean
198
+ /**
199
+ * vx's stdin is a terminal: each `exec.interactive` task is handed it,
200
+ * alone (placement.ts `terminalHolders`). The CLI sets it from
201
+ * `isatty(0)`; absent, an interactive task runs as any task.
202
+ */
203
+ tty?: boolean
192
204
  log?: Logger
193
205
  /**
194
206
  * Inject the run's event bus. When provided, the orchestrator emits
@@ -2,12 +2,13 @@
2
2
  // executors, and the plan-mode view of it. Split from run.ts on 2026-09-10
3
3
  // (pure motion). A task is pinned to this machine when it is persistent,
4
4
  // depends on a persistent task, is sandboxed, says `exec.remote: false`, or
5
- // folds a runtime probe into its key;
5
+ // folds a runtime probe into its key; an `exec.interactive` task goes to the
6
+ // local floor directly, as only it can hand over this terminal;
6
7
  // everything else asks the executors in declaration order, and the local
7
8
  // floor takes what nothing claimed.
8
9
 
9
- import { machineParallelism } from '../util/index.js'
10
- import { selectExecutor, type TaskExecutor } from '../exec/index.js'
10
+ import { machineParallelism, UserError } from '../util/index.js'
11
+ import { isLocalExecutor, selectExecutor, type TaskExecutor } from '../exec/index.js'
11
12
  import { isGroupTask, type TaskNode } from '../graph/index.js'
12
13
  import { resolveDownloadModes } from './download-policy.js'
13
14
  import type { Logger } from './logger.js'
@@ -75,6 +76,49 @@ function withProbedRuntime(nodes: Map<string, TaskNode>, pinned: Set<string>): S
75
76
  return pinned
76
77
  }
77
78
 
79
+ /**
80
+ * The tasks that hold the terminal in a run on one: every
81
+ * `exec.interactive` task when vx's stdin is a TTY, none otherwise. A
82
+ * persistent one holds it until the run ends, so a run may have one, and
83
+ * every other interactive task must run before it (a dependency); anything
84
+ * else would have two tasks reading the same keys. Refused before any task
85
+ * runs.
86
+ */
87
+ export function terminalHolders(nodes: Map<string, TaskNode>, tty: boolean): Set<string> {
88
+ const holders = new Set<string>()
89
+ if (!tty) return holders
90
+ for (const node of nodes.values()) {
91
+ if (node.config.exec?.interactive === true) holders.add(node.id)
92
+ }
93
+ const servers = [...holders].filter((id) => nodes.get(id)!.config.exec!.persistent !== undefined)
94
+ if (servers.length > 1) {
95
+ throw new UserError(
96
+ `${servers.join(' and ')} are persistent and interactive: a server holds the terminal ` +
97
+ `until the run ends, so one run can hold one — run them in separate terminals`,
98
+ )
99
+ }
100
+ if (servers.length === 0) return holders
101
+ const server = servers[0]!
102
+ const before = new Set<string>()
103
+ const stack = [...nodes.get(server)!.deps]
104
+ while (stack.length > 0) {
105
+ const id = stack.pop()!
106
+ if (before.has(id)) continue
107
+ before.add(id)
108
+ stack.push(...nodes.get(id)!.deps)
109
+ }
110
+ const clash = [...holders].filter((id) => id !== server && !before.has(id))
111
+ if (clash.length > 0) {
112
+ throw new UserError(
113
+ `${server} is persistent and interactive, so it holds the terminal until the run ends, ` +
114
+ `and ${clash.join(', ')} would ask for it too: make ${server} depend on ` +
115
+ `${clash.length === 1 ? 'it' : 'them'}, or run ${clash.length === 1 ? 'it' : 'them'} on ` +
116
+ `${clash.length === 1 ? 'its' : 'their'} own`,
117
+ )
118
+ }
119
+ return holders
120
+ }
121
+
78
122
  export interface Placements {
79
123
  executors: Map<string, TaskExecutor>
80
124
  /**
@@ -118,8 +162,14 @@ export function placeTasks(
118
162
  remoteOnlyNoop: new Set(),
119
163
  remoteOnly: new Set(),
120
164
  }
165
+ // `resolveExecutors` puts it last in every list.
166
+ const floor = executors.find(isLocalExecutor)!
121
167
  for (const node of nodes.values()) {
122
168
  if (isGroupTask(node) || node.config.exec?.persistent !== undefined) continue
169
+ if (node.config.exec?.interactive === true) {
170
+ placements.executors.set(node.id, floor)
171
+ continue
172
+ }
123
173
  const executor = selectExecutor(
124
174
  executors,
125
175
  {
@@ -16,6 +16,7 @@ import {
16
16
  type CacheLayer,
17
17
  type CachePolicy,
18
18
  FULL_CACHE_POLICY,
19
+ scopeCachePolicy,
19
20
  GitFilesCache,
20
21
  LayeredCache,
21
22
  applyGitEnumeration,
@@ -29,6 +30,8 @@ import {
29
30
  computeNestedProjectDirs,
30
31
  computeWorkspaceFingerprints,
31
32
  findWorkspaceRoot,
33
+ cacheScopeEnvError,
34
+ isCacheScope,
32
35
  type LoadReads,
33
36
  loadWorkspace,
34
37
  FROZEN_WITHOUT_LOCK,
@@ -90,6 +93,8 @@ export interface PreparedRun {
90
93
  * would be saved still cleans its outputs before executing.
91
94
  */
92
95
  hasRemoteLayer: boolean
96
+ /** `--cache` with the workspace's `cacheScope` applied: what the layers were built under. */
97
+ cachePolicy: CachePolicy
93
98
  /**
94
99
  * Scheduling priorities from plugins' `schedule` stage (task id → weight,
95
100
  * merged over the structural baseline by the scheduler). Empty when no
@@ -257,7 +262,14 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
257
262
 
258
263
  // The local cache opens BEFORE the configs load: it is also where their
259
264
  // cached evaluations live.
260
- const policy: CachePolicy = options.cache ?? FULL_CACHE_POLICY
265
+ // `VX_CACHE_SCOPE` wins over the workspace (and `github()`'s guess):
266
+ // a CI job that knows its trust level says so without editing config.
267
+ const envScope = process.env['VX_CACHE_SCOPE'] || undefined
268
+ if (envScope !== undefined && !isCacheScope(envScope)) throw cacheScopeEnvError()
269
+ const policy: CachePolicy = scopeCachePolicy(
270
+ options.cache ?? FULL_CACHE_POLICY,
271
+ envScope ?? workspaceConfig?.cacheScope ?? options.defaultCacheScope,
272
+ )
261
273
  const localCache = new Cache(
262
274
  cacheDir,
263
275
  { read: policy.localRead, write: policy.localWrite },
@@ -455,6 +467,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
455
467
  cache,
456
468
  localCache,
457
469
  hasRemoteLayer,
470
+ cachePolicy: policy,
458
471
  priorities: new Map(),
459
472
  nodes: new Map(),
460
473
  keyOnly: new Map(),
@@ -610,6 +623,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
610
623
  cache,
611
624
  localCache,
612
625
  hasRemoteLayer,
626
+ cachePolicy: policy,
613
627
  priorities,
614
628
  nodes,
615
629
  keyOnly,
@@ -5,12 +5,7 @@
5
5
  import type { ProjectEntry } from '../workspace/index.js'
6
6
  import { loadWorkspace, unreachedHint, unreachedPackages } from '../workspace/index.js'
7
7
  import path from 'node:path'
8
- import {
9
- type CacheLayer,
10
- type CachePolicy,
11
- FULL_CACHE_POLICY,
12
- stopRuntimeProbes,
13
- } from '../cache/index.js'
8
+ import { type CacheLayer, type CachePolicy, stopRuntimeProbes } from '../cache/index.js'
14
9
  import { VERSION } from '../version.js'
15
10
  import {
16
11
  resetSandbox,
@@ -77,6 +72,7 @@ import {
77
72
  placeTasks,
78
73
  planExecutorOf,
79
74
  poolOfPlacement,
75
+ terminalHolders,
80
76
  UNPLACED_EXECUTOR,
81
77
  locallyPlaced,
82
78
  } from './placement.js'
@@ -300,7 +296,10 @@ export async function run(options: RunOptions): Promise<RunSummary> {
300
296
  // See docs/design/event-stream-2026-06.md.
301
297
  const terminal =
302
298
  options.log === undefined
303
- ? defaultLogger(colors, resolveOutputView(options), process.stdout, { coalesce: true })
299
+ ? defaultLogger(colors, resolveOutputView(options), process.stdout, {
300
+ coalesce: true,
301
+ ...(options.tty === true ? { tty: true } : {}),
302
+ })
304
303
  : null
305
304
  const sink = options.log ?? terminal!
306
305
  // An injected bus already has surfaces subscribed; we add the terminal
@@ -471,6 +470,7 @@ async function runOnBus(
471
470
  // worker slot. Group tasks run nothing; persistent tasks never reach an
472
471
  // executor (local by construction) — both stay off the map.
473
472
  const placements = await abandoning(() => placeTasks(nodes, executors))
473
+ const holders = await abandoning(() => terminalHolders(nodes, options.tty === true))
474
474
  // A `remote: 'only'` task nobody takes succeeds WITHOUT running. That is
475
475
  // deliberate — on a machine with no remote pool the ambient state already
476
476
  // is what the task would have produced — but it must not be SILENT: a task
@@ -673,10 +673,7 @@ async function runOnBus(
673
673
  // actually governed the run. Reading the raw request here made tasks
674
674
  // clean their outputs before every exec for a save that never
675
675
  // happened.
676
- const policy: CachePolicy = effectiveCachePolicy(
677
- options.cache ?? FULL_CACHE_POLICY,
678
- prepared.hasRemoteLayer,
679
- )
676
+ const policy: CachePolicy = effectiveCachePolicy(prepared.cachePolicy, prepared.hasRemoteLayer)
680
677
  if (options.remoteRequested === true && !prepared.hasRemoteLayer) {
681
678
  log.status(
682
679
  'vx: --cache named the remote cache, but no cache plugin in vx.workspace.* supplies one — the remote axes are off for this run',
@@ -942,6 +939,7 @@ async function runOnBus(
942
939
  ...(upfrontKey !== undefined ? { upfrontKey } : {}),
943
940
  ...(tainted ? { taintedUpstream: true } : {}),
944
941
  ...(dependedOn.has(node.id) ? {} : { noDependants: true as const }),
942
+ ...(holders.has(node.id) ? { terminal: true as const } : {}),
945
943
  fingerprintWatch,
946
944
  ...(sandboxArmer !== null ? { armSandbox: () => sandboxArmer.arm() } : {}),
947
945
  keyedProjects: keyed,
@@ -975,6 +973,7 @@ async function runOnBus(
975
973
  settledOf: (o) => deferredSaves.get(o.node.id),
976
974
  ...(hasPooledExecutor(executors) ? { poolOf: poolOfPlacement(placements) } : {}),
977
975
  ...(admit !== undefined ? { admit } : {}),
976
+ ...(holders.size > 0 ? { exclusive: holders } : {}),
978
977
  ...(options.continueMode !== undefined ? { continueMode: options.continueMode } : {}),
979
978
  serverDied,
980
979
  signal: stopRun.signal,
@@ -1452,10 +1451,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1452
1451
  workspaceRoot: prepared.workspaceRoot,
1453
1452
  workspaceFingerprint: prepared.workspaceFingerprint,
1454
1453
  cache: prepared.cache,
1455
- cachePolicy: effectiveCachePolicy(
1456
- options.cache ?? FULL_CACHE_POLICY,
1457
- prepared.hasRemoteLayer,
1458
- ),
1454
+ cachePolicy: effectiveCachePolicy(prepared.cachePolicy, prepared.hasRemoteLayer),
1459
1455
  forwardArgs: options.forwardArgs,
1460
1456
  nestedDirsByProject: prepared.nestedDirsByProject,
1461
1457
  gitFilesCache: prepared.gitFilesCache,
@@ -30,12 +30,26 @@ import { nonJsonMessage, nonJsonPaths } from './json-data.js'
30
30
  // the same reason the task levels reject them: `plugin: [...]` (singular)
31
31
  // declared no plugins and ran the workspace bare, `cacheDirectory` left the
32
32
  // cache where it was — a config that loads and quietly does nothing it says.
33
+ const CACHE_SCOPE_RULE =
34
+ "must be 'trusted', 'read-only', or a scope name of letters, digits and . _ - / @ (at most 128), like 'pr-123'"
35
+
36
+ /** `cacheScope`'s rule, shared with `VX_CACHE_SCOPE`. */
37
+ export function isCacheScope(v: unknown): v is string {
38
+ return typeof v === 'string' && /^[\w.@/-]{1,128}$/.test(v)
39
+ }
40
+
41
+ /** Why `VX_CACHE_SCOPE` is refused, in `cacheScope`'s words. */
42
+ export function cacheScopeEnvError(): UserError {
43
+ return new UserError(`VX_CACHE_SCOPE ${CACHE_SCOPE_RULE}`)
44
+ }
45
+
33
46
  const WORKSPACE_FIELDS = new Set([
34
47
  'concurrency',
35
48
  'cacheDir',
36
49
  'timeout',
37
50
  'cacheRetention',
38
51
  'affectedBase',
52
+ 'cacheScope',
39
53
  'plugins',
40
54
  ])
41
55
 
@@ -112,6 +126,9 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
112
126
  throw new UserError(`${configPath}: \`affectedBase\` must be a git ref like 'origin/main'`)
113
127
  }
114
128
  }
129
+ if (config.cacheScope !== undefined && !isCacheScope(config.cacheScope)) {
130
+ throw new UserError(`${configPath}: \`cacheScope\` ${CACHE_SCOPE_RULE}`)
131
+ }
115
132
  if (config.plugins !== undefined) {
116
133
  if (!Array.isArray(config.plugins)) {
117
134
  throw new UserError(`${configPath}: \`plugins\` must be an array of plugin objects`)
@@ -400,6 +417,34 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
400
417
  }
401
418
  }
402
419
  }
420
+ const interactive = (exec as { interactive?: unknown }).interactive
421
+ if (interactive !== undefined && typeof interactive !== 'boolean') {
422
+ throw new UserError(`${where}.exec.interactive must be a boolean (or omitted)`)
423
+ }
424
+ if (interactive === true) {
425
+ if (cache !== undefined) {
426
+ throw new UserError(
427
+ `${where}: \`cache\` is not allowed on an interactive task — what it does ` +
428
+ `depends on what is typed, which no key holds`,
429
+ )
430
+ }
431
+ // bwrap's --new-session keeps the terminal usable (probed), but the
432
+ // sandboxed spawn pipes the task's streams: its stderr is read for
433
+ // the tracer's own errors (sandbox-runtime.ts).
434
+ if ((exec as { sandbox?: unknown }).sandbox !== undefined) {
435
+ throw new UserError(
436
+ `${where}: \`sandbox\` is not allowed on an interactive task — a sandboxed ` +
437
+ `task's output passes through vx, and an interactive one writes to the terminal`,
438
+ )
439
+ }
440
+ const ready = (exec as { persistent?: { readyWhen?: unknown } }).persistent
441
+ if (typeof ready === 'object' && ready !== null && ready.readyWhen !== undefined) {
442
+ throw new UserError(
443
+ `${where}: \`persistent.readyWhen\` is not allowed on an interactive task — ` +
444
+ `its output goes to the terminal, so vx reads none of it; it is ready once spawned`,
445
+ )
446
+ }
447
+ }
403
448
  const persistent = (exec as { persistent?: unknown }).persistent
404
449
  if (persistent !== undefined) {
405
450
  if (typeof persistent !== 'object' || persistent === null) {
@@ -691,6 +736,7 @@ const EXEC_FIELDS = new Set([
691
736
  'timeout',
692
737
  'retries',
693
738
  'persistent',
739
+ 'interactive',
694
740
  'remote',
695
741
  'sandbox',
696
742
  ])
@@ -767,8 +813,7 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
767
813
  outputLogs: 'the `--output-logs` flag of `vx run`',
768
814
  // Turbo's `interactive` / `with`, Nx's `cwd` / `parallelism` /
769
815
  // `configurations`: refused with no word on where they went (D-89).
770
- interactive:
771
- 'a command run outside vx: a task never reads the terminal (its stdin is EOF, or a pipe vx holds under `exec.persistent`)',
816
+ interactive: '`exec.interactive: true`',
772
817
  with: '`dependsOn` on each task it runs beside: a persistent one stays up, and this task starts once it is ready',
773
818
  cwd: '`cd <dir> && …` in `exec.command`: a task runs in its project directory',
774
819
  parallelism:
@@ -789,8 +834,6 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
789
834
  commands: 'one `command` (`a && b`, or `a & b; wait` to run them at once), or one task each',
790
835
  parallel: 'one task each, which vx runs at once, or `a & b; wait` in `command`',
791
836
  shell: 'no field: `command` always runs in a shell',
792
- interactive:
793
- 'a command run outside vx: a task never reads the terminal (its stdin is EOF, or a pipe vx holds under `persistent`)',
794
837
  },
795
838
  ],
796
839
  // wireit's `files` / `output` and include-style spellings written into
@@ -34,7 +34,12 @@ export {
34
34
  loadWorkspaceConfig,
35
35
  WORKSPACE_CONFIG_FILENAMES,
36
36
  } from './project-loader.js'
37
- export { validateProjectConfig, validateWorkspace } from './config-schema.js'
37
+ export {
38
+ cacheScopeEnvError,
39
+ isCacheScope,
40
+ validateProjectConfig,
41
+ validateWorkspace,
42
+ } from './config-schema.js'
38
43
  export type { LoadProjectConfigOptions } from './project-loader.js'
39
44
  export {
40
45
  blobOidOf,