@vzn/vx 0.0.646 → 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 (184) hide show
  1. package/README.md +29 -22
  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 +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  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 +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -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/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -20,7 +20,16 @@ import {
20
20
  import { PLUGIN_PACKAGE, type ProjectConfig, type WorkspaceConfig } from '../config.js'
21
21
  import type { TaskExecutor } from '../exec/index.js'
22
22
  import type { TaskNode, TaskOutcome, TaskStatus } from '../graph/index.js'
23
- import { maskedLine, nearest, type PluginSecrets, SecretBroker, UserError } from '../util/index.js'
23
+ import {
24
+ errorCode,
25
+ maskedLine,
26
+ nearest,
27
+ type PluginSecrets,
28
+ SecretBroker,
29
+ thrownMessage,
30
+ ui,
31
+ UserError,
32
+ } from '../util/index.js'
24
33
  import type { ProjectMeta } from '../workspace/index.js'
25
34
  import type { EventBus, Logger, RunStartInfo } from '../report/index.js'
26
35
  import type { CiContext } from './run-context.js'
@@ -60,7 +69,8 @@ export interface VxPlugin {
60
69
 
61
70
  /**
62
71
  * The workspace config, before anything is derived from it (concurrency,
63
- * cacheDir, timeout, …). `plugins` is already fixed by the time this runs.
72
+ * cacheDir, timeout, …). `plugins` is already fixed by the time this runs:
73
+ * an edit to it is refused.
64
74
  * Core re-validates after each plugin, as it does a user's file.
65
75
  * `ctx.setBranch` names what the CI knows of the branch: its cache scope
66
76
  * and the commit it is compared with.
@@ -78,7 +88,7 @@ export interface VxPlugin {
78
88
  * (with no config; named by `name`, else its path). A project named by
79
89
  * its path takes `name`; one its config names keeps that name, and a
80
90
  * different name from an earlier plugin is refused, as is a directory
81
- * outside the root. Two projects of one name are refused once the stage
91
+ * outside the root and the name `//` away from it. Two projects of one name are refused once the stage
82
92
  * ends. `deps` and `reach` from every plugin union then; a target that is
83
93
  * no project is refused.
84
94
  */
@@ -93,7 +103,7 @@ export interface VxPlugin {
93
103
  * applied on top of it live. A project with NO config file (a
94
104
  * `discover` plugin added it) is loaded too, as `{ tasks: {} }`, so a
95
105
  * plugin can give it tasks; with no `project` plugin such a project is
96
- * never visited.
106
+ * never visited. Its `name` is fixed by then: a change is refused.
97
107
  */
98
108
  project?(config: ProjectConfig, ctx: ProjectHookContext): void | Promise<void>
99
109
 
@@ -154,13 +164,13 @@ export interface VxPlugin {
154
164
  * on this machine, with the tasks running here right now. Return `false`
155
165
  * to hold it until something finishes (it is asked again then). Asked
156
166
  * many times per run, so it must be cheap and synchronous; a throw, or
157
- * an answer that is a Promise, is reported once and the plugin admits
167
+ * an answer that is a Promise or not a boolean or undefined, is reported once and the plugin admits
158
168
  * from then on — a policy never
159
169
  * breaks a run. Restore-tier hits, tasks on an executor pool and groups
160
170
  * hold no local resources: never asked, never in `ctx.running`. When
161
171
  * several plugins answer, all must admit. Core keeps no notion of what a task needs: a plugin that
162
172
  * packs memory or CPU learns or declares the numbers itself
163
- * (`@vzn/vx-schedule-history` packs what past executions used).
173
+ * (`@vzn/vx-history` packs what past executions used).
164
174
  */
165
175
  admit?(task: TaskNode, ctx: AdmitContext): boolean
166
176
 
@@ -223,8 +233,10 @@ export interface VxPlugin {
223
233
  * (the contract `run({ log })` takes), or `undefined` to leave core's
224
234
  * renderer in place. The first plugin, in declaration order, that
225
235
  * returns one renders the run; the rest are not asked. Not consulted
226
- * when an embedder passed its own `log`. A logger that throws hands the
227
- * rest of the run back to core's renderer with one warning.
236
+ * when an embedder passed its own `log`. A logger that throws or rejects
237
+ * hands the rest of the run back to core's renderer with one warning; a
238
+ * hook that does not answer within the teardown bound (3 s) is passed
239
+ * over with one.
228
240
  */
229
241
  output?(ctx: OutputContext): Logger | undefined | Promise<Logger | undefined>
230
242
 
@@ -240,8 +252,10 @@ export interface VxPlugin {
240
252
  setup?(ctx: PluginSetupContext): void | Promise<void>
241
253
 
242
254
  /**
243
- * Optional teardown at end-of-run (flush a sink, close a socket). Errors
244
- * are logged, never thrown. A run that ran to its end passes its
255
+ * Optional teardown at end-of-run (flush a sink, close a socket), in
256
+ * declaration order, a plugin with `cache` after every other: the
257
+ * others may still write through its remote. Errors are logged, never
258
+ * thrown. A run that ran to its end passes its
245
259
  * `RunReport`; every other exit (a refused setup, a stop) passes none.
246
260
  */
247
261
  teardown?(run?: RunReport): void | Promise<void>
@@ -358,17 +372,22 @@ export interface WorkspaceHookContext {
358
372
  }
359
373
 
360
374
  /**
361
- * What a CI knows of the run's branch (owner, 2026-10-17): `scope`, the
375
+ * What a CI knows of the run's branch (2026-10-17): `scope`, the
362
376
  * cache scope (`'vznjs/vx:feat-x'`; `VX_CACHE_SCOPE` beats it, and it
363
377
  * beats the one git names), and `base`, the commit the branch is compared
364
378
  * with (a pull request's base), for a bare `--affected`. `trusted`: the
365
- * run has no scope, whatever git names (a merge queue's run, owner
366
- * 2026-10-18). The scope keys plugin entries only. A later plugin's field wins.
379
+ * run has no scope, whatever git names (a merge queue's run,
380
+ * 2026-10-18). The scope keys plugin entries only. `refs`: the refs a
381
+ * remote reads, in order (`CacheContext.refs`; 2026-10-25: a pull
382
+ * request its head branch, then its base, then the default branch; a
383
+ * merge queue or train only its base); unset, git's. A later plugin's
384
+ * field wins.
367
385
  */
368
386
  export interface BranchFacts {
369
387
  scope?: string
370
388
  base?: string
371
389
  trusted?: boolean
390
+ refs?: readonly string[]
372
391
  }
373
392
 
374
393
  export interface ConfigContext extends WorkspaceHookContext {
@@ -452,7 +471,7 @@ export interface FingerprintClaim {
452
471
  * by `--affected` with the file's bytes at the base ref and in the
453
472
  * working tree (`null` where it does not exist). Return `undefined` to
454
473
  * select every project — the answer a claimant gives when it cannot
455
- * tell.
474
+ * tell. A name that is not one of `ctx.projects` is refused.
456
475
  */
457
476
  affected(
458
477
  change: FingerprintChange,
@@ -464,9 +483,14 @@ export interface FingerprintClaim {
464
483
  * path and hashes nowhere, or a `file:` dependency's directory. A change
465
484
  * to one, or under one, asks `affected` about the file naming it, with
466
485
  * `change.extras`. Without it, a `bun.lock` patch edit selects every
467
- * project.
486
+ * project. Asked on every `--affected` diff, so a claimant that memoises
487
+ * its parse answers from `ctx.cacheDir`.
468
488
  */
469
- extraFiles?(file: string, bytes: Uint8Array): readonly string[]
489
+ extraFiles?(
490
+ file: string,
491
+ bytes: Uint8Array,
492
+ ctx: FingerprintContext,
493
+ ): readonly string[] | Promise<readonly string[]>
470
494
  }
471
495
 
472
496
  export interface FingerprintChange {
@@ -507,14 +531,26 @@ export interface ScheduleHookContext extends BaseContext {
507
531
  readonly localCache: Cache
508
532
  /** The run's branch (`currentBranch`), the timings a forecast reads first: pass it to `LocalHistoryProvider`. */
509
533
  readonly branch: string | null
534
+ /** The refs the run reads, in order (`CacheContext.refs`): a remote's timings are read by them too. */
535
+ readonly refs: readonly string[]
510
536
  /** This plugin's entries in the workspace's cache, as `setup` has them. */
511
537
  readonly cache: PluginCache
512
538
  /**
513
539
  * Replace this plugin's weights while the run goes on: the tasks not yet
514
540
  * started re-rank at once. For weights that arrive late (timings fetched
515
541
  * from a remote). Ignored in a plan and once the run's tasks are done.
542
+ * Given a promise, the weights it resolves to, if any, when it does:
543
+ * `refining()` waits for it meanwhile.
544
+ */
545
+ refine(
546
+ weights: ReadonlyMap<string, number> | Promise<ReadonlyMap<string, number> | undefined>,
547
+ ): void
548
+ /**
549
+ * Settles once every refine promised so far (a promise to `refine`) has
550
+ * landed or failed, its listeners called: a plugin that sizes the run by
551
+ * the fetched data waits for it here rather than guessing without it.
516
552
  */
517
- refine(weights: ReadonlyMap<string, number>): void
553
+ refining(): Promise<void>
518
554
  /**
519
555
  * Calls `listener` each time another plugin `refine`s: the late data it
520
556
  * waited for (timings fetched from a remote) has landed in history, so a
@@ -606,7 +642,7 @@ export interface PluginHookHandlers {
606
642
  }
607
643
 
608
644
  export interface Plugin {
609
- /** Logged on errors; the convention is `'org/plugin-name'`. */
645
+ /** The plugin's package name (`definePlugin` sets it), logged on errors. */
610
646
  readonly name: string
611
647
  /**
612
648
  * Called once at the start of every run. Register hooks via
@@ -635,10 +671,15 @@ export interface PluginOrigin {
635
671
 
636
672
  const packageNameByDir = new Map<string, string>()
637
673
 
638
- /** The name of the nearest `package.json` above `dir` — the one that owns it. */
674
+ /** The name of the nearest named `package.json` above `dir` — the one that owns it. */
639
675
  function pluginPackageName(dir: string): string {
640
676
  const memo = packageNameByDir.get(dir)
641
677
  if (memo !== undefined) return memo
678
+ // A package.json with no `name` below the package root is no package:
679
+ // dual builds (tshy) write `{ "type": "module" }` into `dist/esm/`. The
680
+ // walk passes it, as Vite's package lookup does; with no named one
681
+ // above, the nearest is refused.
682
+ let nameless: string | undefined
642
683
  for (let d = dir; ;) {
643
684
  let text: string | undefined
644
685
  try {
@@ -659,18 +700,22 @@ function pluginPackageName(dir: string): string {
659
700
  )
660
701
  }
661
702
  const name = pkg?.name
662
- if (typeof name !== 'string' || name.length === 0) {
703
+ if (name === undefined) nameless ??= path.join(d, 'package.json')
704
+ else if (typeof name !== 'string' || name.length === 0) {
663
705
  throw new UserError(
664
706
  `definePlugin: ${path.join(d, 'package.json')} has no name — a plugin is a package, and its name is the package's`,
665
707
  )
708
+ } else {
709
+ packageNameByDir.set(dir, name)
710
+ return name
666
711
  }
667
- packageNameByDir.set(dir, name)
668
- return name
669
712
  }
670
713
  const parent = path.dirname(d)
671
714
  if (parent === d) {
672
715
  throw new UserError(
673
- `definePlugin: no package.json above ${dir} — a plugin is a package, and its name is the package's`,
716
+ nameless === undefined
717
+ ? `definePlugin: no package.json above ${dir} — a plugin is a package, and its name is the package's`
718
+ : `definePlugin: ${nameless} has no name — a plugin is a package, and its name is the package's`,
674
719
  )
675
720
  }
676
721
  d = parent
@@ -680,10 +725,24 @@ function pluginPackageName(dir: string): string {
680
725
  /**
681
726
  * The one way to make a plugin: `definePlugin(import.meta, { ...hooks })`.
682
727
  * The name is read from the package the calling module belongs to — the
683
- * nearest `package.json` above it — and stamped where the workspace loader
728
+ * nearest `package.json` above it that has a name — and stamped where the workspace loader
684
729
  * checks for it, so a plugin cannot be named anything but its package.
685
730
  */
686
731
  export function definePlugin(origin: PluginOrigin, hooks: PluginHooks): VxPlugin {
732
+ // The hooks are copied by spread, which takes own fields only: a class
733
+ // instance's methods stayed on its prototype and the plugin loaded
734
+ // without them, saying nothing.
735
+ const proto: unknown =
736
+ hooks !== null && typeof hooks === 'object' ? Object.getPrototypeOf(hooks) : undefined
737
+ if (proto !== Object.prototype && proto !== null) {
738
+ const got =
739
+ hooks === null || typeof hooks !== 'object'
740
+ ? String(hooks)
741
+ : `a ${(hooks as object).constructor?.name || 'class instance'}`
742
+ throw new UserError(
743
+ `definePlugin: the hooks must be a plain object ({ setup() { … } }), got ${got}`,
744
+ )
745
+ }
687
746
  if ('name' in hooks) {
688
747
  throw new UserError(`definePlugin: a plugin's name is its package name — drop the 'name' field`)
689
748
  }
@@ -705,9 +764,11 @@ type OptionKind<V> = [V] extends [string]
705
764
  ? 'boolean'
706
765
  : [V] extends [(...args: never[]) => unknown]
707
766
  ? 'function'
708
- : [V] extends [object]
709
- ? 'object'
710
- : 'any'
767
+ : [V] extends [readonly unknown[]]
768
+ ? 'array'
769
+ : [V] extends [object]
770
+ ? 'object'
771
+ : 'any'
711
772
 
712
773
  /**
713
774
  * Every option a factory takes, each with the one kind its type allows
@@ -739,7 +800,8 @@ export function refuseUnknownOptions<T>(
739
800
  const known = kinds as Readonly<Record<string, string>>
740
801
  const names = Object.keys(known)
741
802
  for (const [key, value] of Object.entries(options)) {
742
- const kind = known[key]
803
+ // Own keys only: the record's prototype answers `constructor`.
804
+ const kind = Object.hasOwn(known, key) ? known[key] : undefined
743
805
  if (kind === undefined) {
744
806
  const near = nearest(key, names)
745
807
  throw new UserError(
@@ -751,12 +813,14 @@ export function refuseUnknownOptions<T>(
751
813
  const ok =
752
814
  kind === 'object'
753
815
  ? value !== null && typeof value === 'object' && !Array.isArray(value)
754
- : typeof value === kind
816
+ : kind === 'array'
817
+ ? Array.isArray(value)
818
+ : typeof value === kind
755
819
  if (!ok) {
756
820
  const got =
757
821
  typeof value === 'function' ? 'a function' : (JSON.stringify(value) ?? String(value))
758
822
  throw new UserError(
759
- `${factory} option "${key}" must be ${kind === 'object' ? 'an' : 'a'} ${kind}, got ${got}`,
823
+ `${factory} option "${key}" must be ${kind === 'object' || kind === 'array' ? 'an' : 'a'} ${kind}, got ${got}`,
760
824
  )
761
825
  }
762
826
  }
@@ -768,7 +832,7 @@ export interface InstallPluginsArgs {
768
832
  cacheDir: string
769
833
  bus: EventBus
770
834
  /**
771
- * Where plugin-throw warnings go. Defaults to console.error; passing
835
+ * Where plugin-throw warnings go. Defaults to `ui.say` on stderr; passing
772
836
  * a callback lets the orchestrator funnel into the framed-output
773
837
  * `run:status` channel.
774
838
  */
@@ -794,8 +858,9 @@ export class PluginSetupError extends UserError {
794
858
  constructor(
795
859
  readonly plugin: Plugin,
796
860
  message: string,
861
+ code = 'VX_E_REFUSED',
797
862
  ) {
798
- super(message)
863
+ super(message, code)
799
864
  }
800
865
  }
801
866
 
@@ -811,7 +876,7 @@ export class PluginSetupError extends UserError {
811
876
  */
812
877
  export async function installPlugins(args: InstallPluginsArgs): Promise<() => void> {
813
878
  const { plugins, bus, workspaceRoot, cacheDir } = args
814
- const warn = args.warn ?? ((m) => console.error(m))
879
+ const warn = args.warn ?? ((m) => ui.say('warn', m))
815
880
  const secrets = args.secrets ?? new SecretBroker(args.ci ?? false, warn)
816
881
  const disposers: Array<() => void> = []
817
882
  // By object, not name: one package can export two plugins.
@@ -833,7 +898,7 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
833
898
  if (disabled.has(plugin)) return
834
899
  disabled.add(plugin)
835
900
  warn(
836
- `[vx] plugin '${plugin.name}' threw in ${where}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
901
+ `[vx] plugin '${plugin.name}' threw in ${where}; disabled for this run: ${thrownMessage(err)}`,
837
902
  )
838
903
  }
839
904
  const ctx: PluginContext = {
@@ -850,6 +915,11 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
850
915
  bus: {
851
916
  emit: (event) => bus.emit(event),
852
917
  subscribe(subscriber) {
918
+ if (typeof subscriber !== 'function') {
919
+ throw new Error(
920
+ `ctx.bus.subscribe: the subscriber is ${subscriber === null ? 'null' : typeof subscriber}, not a function`,
921
+ )
922
+ }
853
923
  // Isolated like an `on` handler: the bus itself swallows a throw.
854
924
  const dispose = bus.subscribe((event) => {
855
925
  if (disabled.has(plugin)) return
@@ -874,6 +944,12 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
874
944
  `ctx.on: unknown hook '${String(hook)}' (one of ${Object.keys(HOOK_NAMES).join(', ')})`,
875
945
  )
876
946
  }
947
+ // Taken, it threw at the first event and switched the whole plugin off.
948
+ if (typeof handler !== 'function') {
949
+ throw new Error(
950
+ `ctx.on: the handler for '${hook}' is ${handler === null ? 'null' : typeof handler}, not a function`,
951
+ )
952
+ }
877
953
  const onFail = (err: unknown): void => fail(hook, err)
878
954
  const dispose = bus.subscribe((event) => {
879
955
  if (disabled.has(plugin)) return
@@ -930,9 +1006,9 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
930
1006
  throw new PluginSetupError(
931
1007
  plugin,
932
1008
  // Masked at the source, as plugin-host.ts's `safe()` does (L-11).
933
- maskedLine(
934
- `plugin '${plugin.name}' failed in setup: ${err instanceof Error ? err.message : String(err)}`,
935
- ),
1009
+ maskedLine(`plugin '${plugin.name}' failed in setup: ${thrownMessage(err)}`),
1010
+ // The plugin's own code survives, as `safe()` keeps it.
1011
+ errorCode(err) === 'VX_E_INTERNAL' ? 'VX_E_REFUSED' : errorCode(err),
936
1012
  )
937
1013
  }
938
1014
  }