@deepseek-ai/dsh-app-boot 0.2.2-rc.1 → 0.2.2-rc.2

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 (73) hide show
  1. package/README.i18n.yaml +4 -4
  2. package/README.md +4 -2
  3. package/README.zh.md +4 -2
  4. package/lib/index.js +37 -17
  5. package/lib/types/bundle-platform.d.ts +4 -2
  6. package/lib/types/bundle-platform.d.ts.map +1 -0
  7. package/lib/types/bundle-platform.js.map +1 -0
  8. package/lib/types/compatibility-preflight.d.ts.map +1 -0
  9. package/lib/types/compatibility-preflight.js.map +1 -0
  10. package/lib/types/config-schema/collect.d.ts.map +1 -0
  11. package/lib/types/config-schema/collect.js.map +1 -0
  12. package/lib/types/config-schema/document.d.ts.map +1 -0
  13. package/lib/types/config-schema/document.js.map +1 -0
  14. package/lib/types/config-schema/index.d.ts.map +1 -0
  15. package/lib/types/config-schema/index.js.map +1 -0
  16. package/lib/types/config-schema/native.d.ts.map +1 -0
  17. package/lib/types/config-schema/native.js.map +1 -0
  18. package/lib/types/config-schema/pattern.d.ts.map +1 -0
  19. package/lib/types/config-schema/pattern.js.map +1 -0
  20. package/lib/types/config-schema/projector.d.ts.map +1 -0
  21. package/lib/types/config-schema/projector.js.map +1 -0
  22. package/lib/types/config-schema/types.d.ts.map +1 -0
  23. package/lib/types/config-schema/types.js.map +1 -0
  24. package/lib/types/index.d.ts +1 -1
  25. package/lib/types/index.d.ts.map +1 -0
  26. package/lib/types/index.js.map +1 -0
  27. package/lib/types/package-meta.d.ts.map +1 -0
  28. package/lib/types/package-meta.js.map +1 -0
  29. package/lib/types/plugin-compatibility.d.ts.map +1 -0
  30. package/lib/types/plugin-compatibility.js.map +1 -0
  31. package/lib/types/profile-compatibility.d.ts.map +1 -0
  32. package/lib/types/profile-compatibility.js.map +1 -0
  33. package/lib/types/profile-context.d.ts.map +1 -0
  34. package/lib/types/profile-context.js.map +1 -0
  35. package/lib/types/profile-plugins.d.ts.map +1 -0
  36. package/lib/types/profile-plugins.js.map +1 -0
  37. package/lib/types/profile-resolution/legacy-links.d.ts.map +1 -0
  38. package/lib/types/profile-resolution/legacy-links.js.map +1 -0
  39. package/lib/types/profile-resolution/resolver.d.ts.map +1 -0
  40. package/lib/types/profile-resolution/resolver.js.map +1 -0
  41. package/lib/types/profile-resolution/service.d.ts.map +1 -0
  42. package/lib/types/profile-resolution/service.js.map +1 -0
  43. package/lib/types/profile-resolution/worker-bootstrap.d.ts.map +1 -0
  44. package/lib/types/profile-resolution/worker-bootstrap.js.map +1 -0
  45. package/lib/types/profile-sanitize.d.ts.map +1 -0
  46. package/lib/types/profile-sanitize.js.map +1 -0
  47. package/lib/types/profile.d.ts.map +1 -0
  48. package/lib/types/profile.js.map +1 -0
  49. package/lib/types/startup-diagnostics.d.ts.map +1 -0
  50. package/lib/types/startup-diagnostics.js.map +1 -0
  51. package/package.json +15 -11
  52. package/src/bundle-platform.ts +35 -0
  53. package/src/compatibility-preflight.ts +187 -0
  54. package/src/config-schema/collect.ts +249 -0
  55. package/src/config-schema/document.ts +169 -0
  56. package/src/config-schema/index.ts +36 -0
  57. package/src/config-schema/native.ts +16 -0
  58. package/src/config-schema/pattern.ts +62 -0
  59. package/src/config-schema/projector.ts +455 -0
  60. package/src/config-schema/types.ts +95 -0
  61. package/src/index.ts +1286 -0
  62. package/src/package-meta.ts +200 -0
  63. package/src/plugin-compatibility.ts +117 -0
  64. package/src/profile-compatibility.ts +141 -0
  65. package/src/profile-context.ts +85 -0
  66. package/src/profile-plugins.ts +133 -0
  67. package/src/profile-resolution/legacy-links.ts +33 -0
  68. package/src/profile-resolution/resolver.ts +1072 -0
  69. package/src/profile-resolution/service.ts +148 -0
  70. package/src/profile-resolution/worker-bootstrap.ts +12 -0
  71. package/src/profile-sanitize.ts +34 -0
  72. package/src/profile.ts +762 -0
  73. package/src/startup-diagnostics.ts +104 -0
package/src/index.ts ADDED
@@ -0,0 +1,1286 @@
1
+ /**
2
+ * Shared boot glue for `dsh` profiles, including the CLI packaged by the Python runtime wheel: load the gitignored
3
+ * `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the
4
+ * optional user patch layers from the Harness home (`~/.dsh`), expose its path resolver to
5
+ * config expressions, and drive the Cordis Loader against a leaf `cordis.yml` until the tree settles.
6
+ * @module @deepseek-ai/dsh-app-boot
7
+ */
8
+
9
+ import { pathToFileURL } from 'node:url'
10
+ import { readFileSync } from 'node:fs'
11
+ import { inspect, parseEnv } from 'node:util'
12
+ import { captureStartupComposition, type StartupCompositionDiagnostic } from './startup-diagnostics.ts'
13
+ export type { StartupCompositionDiagnostic, StartupPluginState, StartupServiceState } from './startup-diagnostics.ts'
14
+ import { basename, dirname, isAbsolute, resolve } from 'node:path'
15
+ import * as yaml from 'js-yaml'
16
+ import { Context, type Fiber, type FiberState } from '@deepseek-ai/cordis'
17
+ import Loader, { type Entry, type EntryGroup, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
18
+ import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
19
+ import Group from '@deepseek-ai/cordis-plugin-group'
20
+ import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
21
+ import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
22
+ export { readBundlePatches, readProfilePatches, resolveTelemetryPatch, type ProfileContext, type ProfilePnpmInvocation } from './profile-context.ts'
23
+ export { sanitizeProfile } from './profile-sanitize.ts'
24
+ export { getDshRuntimeVersion, evaluatePluginCompatibility, pluginCompatibilityWarning, type PluginCompatibility } from './plugin-compatibility.ts'
25
+ export {
26
+ PROFILE_COMPATIBILITY_FILENAME, readProfileCompatibility, readProfileVersionExemptions,
27
+ setProfileVersionExemption, validatePluginVersionExemption, type ProfileCompatibility,
28
+ } from './profile-compatibility.ts'
29
+ import { prepareProfilePatches } from './compatibility-preflight.ts'
30
+ import { ProfileRuntimeResolution, type RuntimeResolution } from './profile.ts'
31
+ import { ResolutionRestartRequired } from './profile-resolution/resolver.ts'
32
+ export { prepareProfileEntries, prepareProfilePatches } from './compatibility-preflight.ts'
33
+ export { readPluginMeta, resolvePluginResource } from './package-meta.ts'
34
+ export { generateConfigSchema, type ConfigSchemaDump, type NativeConfigSchema } from './config-schema/index.ts'
35
+ export { createConfigProjector, LOADER_EXPRESSION_SCHEMA, type ConfigProjection } from './config-schema/projector.ts'
36
+ export { isNativeConfigSchema } from './config-schema/native.ts'
37
+
38
+ export {
39
+ readProfilePlugins, reconcileProfilePlugins, writeProfileBundles,
40
+ type ProfilePluginLocation, type ProfilePluginDependency, type ProfilePluginInventory, type ProfilePluginReconciliation,
41
+ } from './profile-plugins.ts'
42
+
43
+ declare module '@deepseek-ai/cordis' {
44
+ interface Context {
45
+ /** Harness-home path resolver available to Loader `!!js` config expressions. */
46
+ dshHomePath?: typeof dshHomePath
47
+ }
48
+
49
+ interface Events {
50
+ /**
51
+ * Profile patches were reconciled into the running Loader tree: every entry update settled and no new
52
+ * inactive entry was introduced. Carries no diff; listeners re-read Loader entries.
53
+ * @mode emit
54
+ */
55
+ 'app-boot/config-reload'(): void
56
+ }
57
+ }
58
+
59
+ export { assertBundlePlatform, UnsupportedBundlePlatformError } from './bundle-platform.ts'
60
+ export {
61
+ composeEntries,
62
+ createRuntimeResolution,
63
+ ProfileRuntimeResolution,
64
+ bundlePatchFiles,
65
+ bundlePatchPaths,
66
+ initProfile,
67
+ removeLinkProjections,
68
+ loadProfile,
69
+ loadProfileDirectory,
70
+ PROFILE_PATCH_FILENAME,
71
+ PROFILES_DIR,
72
+ readProfileManifest,
73
+ reportSkippedBundles,
74
+ resolveBundleDir,
75
+ resolveProfileDir,
76
+ writeProfileManifest,
77
+ type Profile,
78
+ type ProfileLayer,
79
+ type SkippedBundle,
80
+ type ProfileManifest,
81
+ type LinkedRoot,
82
+ type RuntimeResolutionOptions,
83
+ type RuntimeResolutionEntry,
84
+ type RuntimeResolution,
85
+ } from './profile.ts'
86
+ export {
87
+ PluginPackages,
88
+ type PluginPackage,
89
+ type PluginPackagesConfig,
90
+ } from './profile-resolution/service.ts'
91
+ export { ResolutionRestartRequired } from './profile-resolution/resolver.ts'
92
+
93
+ /**
94
+ * Resolve the config to boot. Replay swaps a `cordis.yml` basename for
95
+ * `cordis.snapshot.yml` in the same directory; every other mode keeps the path.
96
+ * @param configPath - the requested config path (absolute, or relative to `cwd`).
97
+ * @param snapshotMode - the bin's `$DSH_SNAPSHOT` value; only `'replay'` swaps the
98
+ * basename.
99
+ * @param cwd - the base a relative `configPath` resolves against.
100
+ * @returns the absolute path of the config to boot.
101
+ */
102
+ export function resolveConfigPath(
103
+ configPath: string, snapshotMode: string | undefined, cwd: string = process.cwd(),
104
+ ): string {
105
+ const absolute = resolve(cwd, configPath)
106
+ if (snapshotMode !== 'replay') return absolute
107
+ const dir = dirname(absolute)
108
+ const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml')
109
+ return resolve(dir, replayName)
110
+ }
111
+
112
+ /**
113
+ * Load the optional gitignored `.env` from `dir`. Missing files fall back to the
114
+ * ambient environment; other read failures are reported through `warn`.
115
+ * @param binName - the diagnostic prefix on the warn line.
116
+ * @param dir - the directory whose `.env` to load.
117
+ * @param warn - sink for the one-line misconfiguration diagnostic.
118
+ */
119
+ export function loadEnv(
120
+ binName: string, dir: string = process.cwd(),
121
+ warn: (line: string) => void = line => void process.stderr.write(line),
122
+ ): void {
123
+ try {
124
+ process.loadEnvFile(resolve(dir, '.env'))
125
+ } catch (error) {
126
+ if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
127
+ warn(`${binName}: failed to load .env: ${String(error)}\n`)
128
+ }
129
+ // ENOENT (no .env) is fine — rely on the ambient environment.
130
+ }
131
+ }
132
+
133
+ /** Exact names no discovered file may set. */
134
+ const BOOTSTRAP_NAMES = new Set([
135
+ // Process launch and module resolution.
136
+ 'PATH', 'HOME', 'USERPROFILE', 'SHELL',
137
+ 'NODE_OPTIONS', 'NODE_PATH', 'NODE_EXTRA_CA_CERTS',
138
+ 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'LD_AUDIT',
139
+ // Interpreter startup hooks.
140
+ 'BASH_ENV', 'ENV', 'SHELLOPTS', 'BASHOPTS',
141
+ 'PERL5OPT', 'PERL5LIB', 'PYTHONSTARTUP', 'PYTHONPATH', 'RUBYOPT', 'RUBYLIB',
142
+ 'JAVA_TOOL_OPTIONS', '_JAVA_OPTIONS', 'JDK_JAVA_OPTIONS',
143
+ 'PYTHONHOME',
144
+ // Version-control hooks, config redirects, and ambient command selectors.
145
+ 'GIT_SSH', 'GIT_SSH_COMMAND', 'GIT_EXTERNAL_DIFF', 'GIT_PAGER', 'GIT_EDITOR',
146
+ 'GIT_ASKPASS', 'SSH_ASKPASS',
147
+ 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_COUNT',
148
+ 'EDITOR', 'VISUAL', 'PAGER', 'BROWSER',
149
+ // Network reach and trust.
150
+ 'DEEPSEEK_BASE_URL', 'DEEPSEEK_SEARCH_BASE_URL',
151
+ 'SSL_CERT_FILE', 'SSL_CERT_DIR',
152
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
153
+ 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE',
154
+ 'NODE_TLS_REJECT_UNAUTHORIZED',
155
+ ])
156
+
157
+ /** Name prefixes no discovered file may set. */
158
+ const BOOTSTRAP_PREFIXES = ['DSH_', 'XDG_', 'DYLD_', 'BASH_FUNC_']
159
+
160
+ /** Proxy names the Harness-home `.env` may set in any letter case: proxy clients read both spellings. */
161
+ const HOME_LAYER_PROXY_NAMES = new Set(['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY'])
162
+
163
+ /**
164
+ * DeepSeek endpoint names the Harness-home `.env` may set only as spelled here: their consumers look them up by
165
+ * exact name on POSIX, so another spelling would load without effect.
166
+ */
167
+ const HOME_LAYER_ENDPOINT_NAMES = new Set(['DEEPSEEK_BASE_URL', 'DEEPSEEK_SEARCH_BASE_URL'])
168
+
169
+ /**
170
+ * Whether the Harness-home `.env` alone may set a bootstrap-only name. Each such name chooses where requests go: a
171
+ * proxy for every request, a DeepSeek endpoint for the model or search requests that carry the user's credential.
172
+ * The invoking directory's file arrives with a clone and keeps refusing them. The home file is the user's own, and
173
+ * the profile patches beside it can already set these endpoints as plugin `baseURL` fields; `DSH_HOME` is itself
174
+ * bootstrap-only, so no `.env` can relocate this exemption. The CA and TLS names in the same group stay refused
175
+ * everywhere: they change what is trusted, not where traffic goes.
176
+ * @param name - the variable name as the file spells it.
177
+ * @returns true for a {@link HOME_LAYER_PROXY_NAMES} entry in any letter case or a
178
+ * {@link HOME_LAYER_ENDPOINT_NAMES} entry in its exact spelling.
179
+ */
180
+ function isHomeLayerRouteName(name: string): boolean {
181
+ return HOME_LAYER_PROXY_NAMES.has(name.toUpperCase()) || HOME_LAYER_ENDPOINT_NAMES.has(name)
182
+ }
183
+
184
+ /**
185
+ * Whether a variable may come only from the inherited process environment
186
+ * because it changes process, runtime, VCS, or network bootstrap. The Harness-home
187
+ * file is additionally allowed the names {@link isHomeLayerRouteName} accepts.
188
+ * @param name - the variable name.
189
+ * @returns true when only the inherited environment may supply it.
190
+ */
191
+ function isBootstrapOnly(name: string): boolean {
192
+ const upper = name.toUpperCase()
193
+ return BOOTSTRAP_NAMES.has(upper) || BOOTSTRAP_PREFIXES.some(prefix => upper.startsWith(prefix))
194
+ }
195
+
196
+ /**
197
+ * Parse one directory's `.env` without applying it, rejecting bootstrap-only
198
+ * names before any value is materialized.
199
+ * @param binName - the diagnostic prefix on the thrown error.
200
+ * @param dir - the directory whose `.env` to read.
201
+ * @param warn - sink for the one-line unreadable-file diagnostic.
202
+ * @param home - the resolved Harness home; when `dir` is it, names {@link isHomeLayerRouteName} accepts are allowed.
203
+ * @returns the parsed entries, or `undefined` when the file is absent or unreadable.
204
+ * @throws when the file declares a name {@link isBootstrapOnly} rejects and this layer may not set.
205
+ */
206
+ function readEnvLayer(
207
+ binName: string, dir: string, warn: (line: string) => void, home: string,
208
+ ): { path: string; values: Record<string, string> } | undefined {
209
+ const path = resolve(dir, '.env')
210
+ const isHome = resolve(dir) === home
211
+ let content: string
212
+ try {
213
+ content = readFileSync(path, 'utf8')
214
+ } catch (error) {
215
+ if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
216
+ warn(`${binName}: failed to load .env: ${String(error)}\n`)
217
+ }
218
+ // ENOENT (no .env) is fine — rely on the ambient environment.
219
+ return undefined
220
+ }
221
+ // Parse once so validation and materialization use exactly the same entries.
222
+ const values = parseEnv(content) as Record<string, string>
223
+ for (const name of Object.keys(values)) {
224
+ if (!isBootstrapOnly(name)) continue
225
+ const homeLayerName = isHomeLayerRouteName(name)
226
+ if (isHome && homeLayerName) continue
227
+ // A home-layer name, or an endpoint name spelled another way, has a second way out that the other bootstrap names
228
+ // do not, so its message says so.
229
+ const homeEnv = resolve(home, '.env')
230
+ const upper = name.toUpperCase()
231
+ const remedy = homeLayerName
232
+ ? `export ${name}, or put it in ${homeEnv}, which does not travel with a repository`
233
+ : HOME_LAYER_ENDPOINT_NAMES.has(upper)
234
+ ? `spell it ${upper}, then export it or put it in ${homeEnv}, which does not travel with a repository`
235
+ : `export ${name} instead of putting it in a .env file`
236
+ throw new Error(
237
+ `${binName}: ${path} sets "${name}", which only the launching environment may set`
238
+ + ' (it decides how this process starts, where its code and instructions load from, or how it'
239
+ + ` reaches the network); ${remedy}`,
240
+ )
241
+ }
242
+ return { path, values }
243
+ }
244
+
245
+ /**
246
+ * Load the product CLI's inherited > invoking-directory `.env` > Harness-home
247
+ * `.env` snapshot. The Harness home resolves before either file; both files
248
+ * are checked before either is applied, and accepted values are materialized
249
+ * without replacing inherited ones. The snapshot preserves which layer supplied each value.
250
+ * @param binName - the diagnostic prefix on the diagnostics.
251
+ * @param cwd - the invoking directory whose `.env` is the project layer.
252
+ * @param warn - sink for the one-line misconfiguration diagnostics.
253
+ * @returns this run's frozen environment snapshot.
254
+ * @throws when either file declares a bootstrap-only variable, except names {@link isHomeLayerRouteName} accepts in the Harness-home file.
255
+ */
256
+ export function loadLayeredEnv(
257
+ binName: string, cwd: string = process.cwd(),
258
+ warn: (line: string) => void = line => void process.stderr.write(line),
259
+ ): LaunchEnvironmentSnapshot {
260
+ const home = resolveDshHome()
261
+ const inherited = { ...process.env } as Record<string, string>
262
+ // Parse both layers first: a rejection must not leave one file applied.
263
+ const project = readEnvLayer(binName, cwd, warn, home)
264
+ const user = home === resolve(cwd) ? undefined : readEnvLayer(binName, home, warn, home)
265
+ // Apply the checked values without replacing a higher-ranked name.
266
+ for (const layer of [project, user]) {
267
+ if (layer === undefined) continue
268
+ for (const [name, value] of Object.entries(layer.values)) {
269
+ if (process.env[name] === undefined) process.env[name] = value
270
+ }
271
+ }
272
+ return createLaunchEnvironmentSnapshot([
273
+ { source: 'process', values: inherited },
274
+ ...project === undefined ? [] : [{ source: 'project-env' as const, path: project.path, values: project.values }],
275
+ ...user === undefined ? [] : [{ source: 'user-env' as const, path: user.path, values: user.values }],
276
+ ])
277
+ }
278
+
279
+ const bootstrapIncludes = new WeakMap<Context, Entry>()
280
+
281
+ // The include's YAML dialect (`!!js` scalars become expression nodes the
282
+ // Loader interpolates against each entry's injection-ready context), imported
283
+ // from the include itself so patch parsing and config dumping can never drift
284
+ // from what the include mounts. User patch layers share it so they may
285
+ // reference `process.env`.
286
+ const userPatchesSchema = entryListSchema
287
+
288
+ /** What a caller of {@link recomposeProfile} already did and requires. */
289
+ export interface ProfileRecompositionOptions {
290
+ /** Explicit enablement targets whose existing failures also reject the recomposition. */
291
+ readonly requiredIds?: readonly string[]
292
+ /**
293
+ * Loader entries that ran before the caller stopped some of them. Each that no longer runs starts again, and each that
294
+ * failed afterwards counts as a new failure.
295
+ */
296
+ readonly running?: readonly Entry[]
297
+ }
298
+
299
+ /** The outcome of a {@link recomposeProfile} that applied or kept the composition. */
300
+ export interface ProfileRecomposition {
301
+ /** Diagnostics for unchanged pre-existing inactive entries. */
302
+ warnings: string[]
303
+ /** Present when the runtime resolution could not publish a moved package or a changed installation mapping. */
304
+ restartReason?: 'package-moved'
305
+ }
306
+
307
+ /**
308
+ * Recompose the profile from its complete patch list, together with its runtime resolution. The rows the generation no
309
+ * longer lists are removed first and their disposal finishes while their packages are still mapped; the latest
310
+ * generation of an installed, recomputable resolution is published; the compatibility preflight reads the published
311
+ * packages; the generation is applied to the root Include; entries that ran, and enabled entries whose import failed,
312
+ * start afterwards when the update left them without a running fiber; and activation is judged. A failure before the generation applies,
313
+ * including a publication the resolution rejects, which reports `package-moved`, puts the removed rows back at their
314
+ * places, starting them again, and starts the entries that ran again. An entry waiting for a service is reported as a
315
+ * warning unless it is required. Patches are copied with `structuredClone`, so they hold no functions.
316
+ * @param ctx Booted root context.
317
+ * @param patches Complete ordered patch list.
318
+ * @param binName Diagnostic prefix.
319
+ * @param options What the caller already did and requires.
320
+ * @returns Diagnostics for unchanged pre-existing inactive entries, and the restart reason of a rejected publication.
321
+ * @throws When the root Include is missing; the latest resolution cannot be read or published; preparation or the
322
+ * Loader update rejects; or for a new or changed failure, an inactive required entry, a failed entry that ran, or a
323
+ * healthy fiber whose disposal rejected.
324
+ */
325
+ export function recomposeProfile(
326
+ ctx: Context, patches: PatchOptions[], binName: string, options: ProfileRecompositionOptions = {},
327
+ ): Promise<ProfileRecomposition> {
328
+ return recompose(ctx, patches, binName, options, true)
329
+ }
330
+
331
+ /**
332
+ * Apply a complete patch list to the root Include and judge activation, leaving the runtime resolution and entries
333
+ * whose import failed as they are.
334
+ * @param ctx Booted root context.
335
+ * @param patches Complete ordered patch list.
336
+ * @param binName Diagnostic prefix.
337
+ * @param requiredIds Explicit enablement targets whose existing failures also reject reconciliation.
338
+ * @returns Diagnostics for unchanged pre-existing inactive entries; new or changed failures reject.
339
+ */
340
+ export async function reconcileProfilePatches(
341
+ ctx: Context, patches: PatchOptions[], binName: string, requiredIds: readonly string[] = [],
342
+ ): Promise<string[]> {
343
+ return (await recompose(ctx, patches, binName, { requiredIds }, false)).warnings
344
+ }
345
+
346
+ async function recompose(
347
+ ctx: Context, patches: PatchOptions[], binName: string, options: ProfileRecompositionOptions, withResolution: boolean,
348
+ ): Promise<ProfileRecomposition> {
349
+ const { requiredIds = [], running = [] } = options
350
+ let applying = false
351
+ // The rows stopped first, which a failure before the generation applies puts back.
352
+ let stopped: StoppedRow[] = []
353
+ try {
354
+ const entry = bootstrapIncludes.get(ctx)
355
+ if (entry === undefined) throw new Error(`${binName}: profile reload requires the root Include entry`)
356
+ // Reading the prior state imports nothing, so plugin code does not run before the next resolution publishes.
357
+ const previousFailures = (await inactiveEntries(ctx, false)).map((failure): RecordedFailure => ({
358
+ entry: failure.entry, fiber: failure.entry.fiber, options: JSON.stringify(failure.entry.options),
359
+ // An entry without a running fiber has no import error yet; the judgment after the update reads it.
360
+ diagnostic: failure.entry.fiber?.uid ? reloadDiagnostic(failure) : undefined,
361
+ }))
362
+ // Removed entries leave the Loader store before their async disposers finish.
363
+ const previousFibers = [...ctx.loader.entries()].flatMap(row => row.fiber === undefined ? [] : [row.fiber])
364
+ const { patches: previous = [], ...includeConfig } = entry.options.config as Include.Config
365
+ const current = withResolution ? ctx.get('pluginPackages')?.resolution : undefined
366
+ // A plain-data resolution has no inputs to recompute from.
367
+ const successor = current instanceof ProfileRuntimeResolution ? await current.computeLatestResolution() : undefined
368
+ if (successor !== undefined) {
369
+ // Rows the generation no longer lists stop, and their disposal finishes, while their packages are still mapped.
370
+ const listed = rowIds(structuredClone(patches))
371
+ const composed = rowIds(structuredClone(previous))
372
+ // Only rows of the root Include's tree; a nested Include file has its own ids.
373
+ const tree = entry.subgroup?.tree
374
+ const dropped = [...ctx.loader.entries()]
375
+ .filter(row => row.parent.tree === tree && composed.has(row.options.id) && !listed.has(row.options.id))
376
+ // A dropped group takes its rows with it.
377
+ const ids = new Set(dropped.map(row => row.options.id))
378
+ const topmost = dropped.filter(row => !ids.has(row.parent.ctx.fiber.entry?.options.id as string))
379
+ // The Loader marks the options of a row it stops, so the copy taken before is the one restored.
380
+ stopped = topmost.map(row => ({
381
+ group: row.parent, options: structuredClone(row.options),
382
+ index: row.parent.data.indexOf(row.options),
383
+ }))
384
+ if (topmost.length > 0) {
385
+ const fibers = dropped.flatMap(row => row.fiber === undefined ? [] : [row.fiber])
386
+ for (const row of topmost) {
387
+ // A disabled row's disposal is the Loader's own, so it does not rewrite the profile's configuration file.
388
+ row.options.disabled = true
389
+ row.parent.remove(row.options.id)
390
+ }
391
+ await Promise.allSettled(fibers.map(fiber => fiber.await()))
392
+ await ctx.loader.await()
393
+ }
394
+ try {
395
+ publish(ctx, successor)
396
+ } catch (error) {
397
+ if (!(error instanceof ResolutionRestartRequired)) throw error
398
+ // The generation waits for the mappings the rejected publication would change.
399
+ return { warnings: [], restartReason: 'package-moved' }
400
+ }
401
+ }
402
+ // The recomposition judges the rows the launch judged, resolved from the file this Include read.
403
+ const parentURL = new URL('.', new URL(includeConfig.path, entry.parent.tree.ctx.baseUrl)).href
404
+ const prepared = prepareProfilePatches(ctx, patches, parentURL, binName)
405
+ applying = true
406
+ const retried = [...new Set([...running, ...withResolution ? [...ctx.loader.entries()].filter(importFailed) : []])]
407
+ // Group.update replaces its data array and attempts every row it still lists, including failed imports.
408
+ const priorData = new Map(retried.map(row => [row, row.parent.data]))
409
+ try {
410
+ await entry.update({ config: { ...includeConfig, patches: prepared } })
411
+ } finally {
412
+ // Only rows whose Group did not update still need an explicit retry after the awaited update.
413
+ await resume(ctx, retried.filter(row => row.parent.data === priorData.get(row)))
414
+ }
415
+ await Promise.allSettled(previousFibers.map(fiber => fiber.await()))
416
+ await ctx.loader.await()
417
+ const failures = await inactiveEntries(ctx)
418
+ const introduced = failures.filter((failure) => {
419
+ if (requiredIds.includes(failure.entry.options.id)) return true
420
+ // An entry waiting for a service starts once a provider appears.
421
+ if (failure.outcome.kind === 'pending') return false
422
+ // An entry that ran before the caller stopped it is a new failure even when it failed the same way before.
423
+ if (running.includes(failure.entry)) return true
424
+ return !previousFailures.some(previous => sameFailure(previous, failure))
425
+ })
426
+ if (introduced.length > 0) throw new Error(activationDiagnostic(binName, introduced, false).trimEnd())
427
+ ctx.emit('app-boot/config-reload')
428
+ return { warnings: failures.map(reloadDiagnostic) }
429
+ } finally {
430
+ // Before the generation applies, the rows removed first and the entries the caller stopped run again.
431
+ if (!applying && (stopped.length > 0 || running.length > 0)) {
432
+ try {
433
+ // Ascending original positions put each row back where it was.
434
+ for (const { group, options, index } of stopped.toSorted((left, right) => left.index - right.index)) {
435
+ // `create` starts the entry but leaves the group's option list as it is; that list is what the next update
436
+ // compares against to remove the row.
437
+ group.data.splice(index, 0, options)
438
+ await group.create(options)
439
+ }
440
+ await resume(ctx, running)
441
+ // A restored row is a new Loader entry with the id of the one that ran.
442
+ const ran = new Set(running.map(row => row.options.id))
443
+ for (const failure of await inactiveEntries(ctx)) {
444
+ if (ran.has(failure.entry.options.id)) ctx.logger.warn(`${binName}: ${reloadDiagnostic(failure)}`)
445
+ }
446
+ } catch (error) {
447
+ // The recomposition's own failure is the outcome.
448
+ ctx.logger.warn(error)
449
+ }
450
+ }
451
+ }
452
+ }
453
+
454
+ /** An inactive entry as a recomposition found it before changing anything. */
455
+ interface RecordedFailure {
456
+ entry: Entry
457
+ fiber: Fiber | undefined
458
+ /** The entry's options as JSON. */
459
+ options: string
460
+ /** The reload diagnostic; `undefined` for an import failure, which matches any diagnostic. */
461
+ diagnostic: string | undefined
462
+ }
463
+
464
+ /** Whether an inactive entry is the failure recorded before: the same entry, fiber and options, and a matching diagnostic. */
465
+ function sameFailure(previous: RecordedFailure, failure: InactiveEntry): boolean {
466
+ return previous.entry === failure.entry && previous.fiber === failure.entry.fiber
467
+ && previous.options === JSON.stringify(failure.entry.options)
468
+ && (previous.diagnostic === undefined || previous.diagnostic === reloadDiagnostic(failure))
469
+ }
470
+
471
+ /** A row a recomposition removed before publishing, with its place in its group. */
472
+ interface StoppedRow {
473
+ group: EntryGroup
474
+ options: EntryOptions
475
+ index: number
476
+ }
477
+
478
+ /** The ids of the rows a patch list inserts, including the rows of groups. */
479
+ function rowIds(patches: readonly PatchOptions[]): Set<string> {
480
+ const ids = new Set<string>()
481
+ const visit = (rows: readonly EntryOptions[]): void => {
482
+ for (const row of rows) {
483
+ ids.add(row.id)
484
+ // A group keeps its rows in its configuration.
485
+ if ((row.group === true || row.name === 'cordis:group') && Array.isArray(row.config)) visit(row.config as EntryOptions[])
486
+ }
487
+ }
488
+ // Patch warnings are logged when the generation itself is prepared.
489
+ visit(applyEntryPatches([], patches as PatchOptions[], () => {}))
490
+ return ids
491
+ }
492
+
493
+ /** Publish a generation unless it maps exactly what is published, which would only drop the package caches. */
494
+ function publish(ctx: Context, next: RuntimeResolution): void {
495
+ const table = ({ localPackageNames, entries, linkedRoots }: RuntimeResolution): string =>
496
+ JSON.stringify([localPackageNames, entries, linkedRoots])
497
+ const packages = ctx.pluginPackages
498
+ if (table(packages.resolution as RuntimeResolution) !== table(next)) packages.replace(next)
499
+ }
500
+
501
+ /** Whether an enabled entry below a running parent has no fiber or keeps the disposed fiber of a failed import. */
502
+ function importFailed(entry: Entry): boolean {
503
+ return enabledEntry(entry) && entry.parent.ctx.fiber.uid !== null
504
+ && (entry.fiber === undefined || entry.fiber.state === FIBER_DISPOSED)
505
+ }
506
+
507
+ /** Whether an entry is enabled now; a throwing `disabled` expression counts as disabled. */
508
+ function enabledEntry(entry: Entry): boolean {
509
+ try {
510
+ return !entry.disabled
511
+ } catch (_error) {
512
+ // Judgment reports the expression failure.
513
+ return false
514
+ }
515
+ }
516
+
517
+ /** Start invalidated entries the Loader still holds without a running fiber, then wait until it settles. */
518
+ async function resume(ctx: Context, invalidated: readonly Entry[]): Promise<void> {
519
+ const entries = new Set(ctx.loader.entries())
520
+ // A stopped entry starts again on update even with unchanged options; an unchanged Include does not update its rows.
521
+ for (const row of invalidated) if (entries.has(row) && !row.fiber?.uid) await row.update({}, false, true)
522
+ await ctx.loader.await()
523
+ }
524
+
525
+ /**
526
+ * Load an optional patch-list file: a top-level YAML array of loader patch
527
+ * entries (`@deepseek-ai/cordis-plugin-include`'s `PatchOptions`): id-targeted config
528
+ * overrides and `insert` lists, with `!!js` expressions allowed. A missing
529
+ * file means "no layer"; an unreadable, unparsable, or non-array file throws —
530
+ * a present patch file that cannot apply is a misconfiguration and must fail
531
+ * loud at boot, never be silently skipped.
532
+ * @param binName - the diagnostic prefix on the thrown error.
533
+ * @param file - absolute path of the patch file.
534
+ * @returns the parsed patches, or `undefined` when the file does not exist.
535
+ */
536
+ export function loadOptionalPatches(binName: string, file: string): PatchOptions[] | undefined {
537
+ let content: string
538
+ try {
539
+ content = readFileSync(file, 'utf8')
540
+ } catch (error) {
541
+ if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined
542
+ throw new Error(`${binName}: failed to read patches ${file}: ${String(error)}`)
543
+ }
544
+ return parsePatchList(binName, file, content, 'patches')
545
+ }
546
+
547
+ /**
548
+ * Load a required overlay patch list: a bundle's `cordis.patch.yml` or a
549
+ * `--patch <path>` overlay. Same file format as {@link loadOptionalPatches},
550
+ * but a missing file throws, because the caller named this file — its absence
551
+ * is a misconfiguration, not "no overlay".
552
+ * @param binName - the diagnostic prefix on the thrown error.
553
+ * @param file - absolute path of the overlay file.
554
+ * @returns the parsed patch list.
555
+ */
556
+ export function loadOverlayPatches(binName: string, file: string): PatchOptions[] {
557
+ let content: string
558
+ try {
559
+ content = readFileSync(file, 'utf8')
560
+ } catch (error) {
561
+ throw new Error(`${binName}: failed to read overlay ${file}: ${String(error)}`)
562
+ }
563
+ return parsePatchList(binName, file, content, 'overlay')
564
+ }
565
+
566
+ /** Convert inserted filesystem paths to file URLs, anchoring relative paths beside the patch; keep assertion names literal. */
567
+ function anchorInsertedPluginNames(patches: PatchOptions[], file: string): PatchOptions[] {
568
+ const base = dirname(resolve(file))
569
+ const visit = (entry: EntryOptions): void => {
570
+ if (typeof entry.name === 'string' && (isAbsolute(entry.name) || entry.name.startsWith('./') || entry.name.startsWith('../'))) {
571
+ entry.name = pathToFileURL(resolve(base, entry.name)).href
572
+ }
573
+ if (entry.group && Array.isArray(entry.config)) entry.config.forEach(visit)
574
+ }
575
+ for (const patch of patches) patch.insert?.forEach(visit)
576
+ return patches
577
+ }
578
+ /**
579
+ * Parse one loader patch list: a top-level YAML array of
580
+ * `@deepseek-ai/cordis-plugin-include` `PatchOptions` (id-targeted config overrides and
581
+ * `insert` lists, `!!js` expressions allowed). Every invalid field or value throws,
582
+ * because a patch file that cannot be applied at all is a misconfiguration; a
583
+ * single patch whose target row is absent stays a per-entry Loader warning, so
584
+ * one overlay shared across surfaces does not have to match every tree.
585
+ * @param binName - the diagnostic prefix on the thrown error.
586
+ * @param file - the source path, quoted in errors.
587
+ * @param content - the file's text.
588
+ * @param label - what to call this list in errors (`patches`, `overlay`).
589
+ * @returns the parsed patch list.
590
+ */
591
+ function parsePatchList(
592
+ binName: string, file: string, content: string, label: string,
593
+ ): PatchOptions[] {
594
+ let parsed: unknown
595
+ try {
596
+ parsed = yaml.load(content, { schema: userPatchesSchema })
597
+ } catch (error) {
598
+ throw new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`)
599
+ }
600
+ if (!Array.isArray(parsed)) {
601
+ throw new Error(`${binName}: ${label} ${file} must be a top-level YAML array of loader patch entries`)
602
+ }
603
+ parsed.forEach((entry, index) => {
604
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
605
+ throw new Error(`${binName}: ${label} entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`)
606
+ }
607
+ })
608
+ return anchorInsertedPluginNames(parsed as PatchOptions[], file)
609
+ }
610
+
611
+ /** One overlay patch list with the source label printed in dump comments. */
612
+ export interface ConfigDumpLayer {
613
+ /** Source name shown in dump comments (a file basename or path). */
614
+ label: string
615
+ /** The layer's patches, from {@link loadOverlayPatches} / {@link loadOptionalPatches}. */
616
+ patches: PatchOptions[]
617
+ }
618
+
619
+ /**
620
+ * Compose the configured entry list: parse the base config file with the
621
+ * include's entry-list dialect, apply every layer's patches as ONE flattened
622
+ * list through the include's own patch algorithm (`applyEntryPatches`) — the
623
+ * same call `boot()` makes, so even patch-visibility corner cases (a later
624
+ * layer targeting a group child a plain config replacement introduced, which
625
+ * the single-pass id index never sees) compose identically — then render the
626
+ * result as YAML in the same dialect (`!!js` expressions print verbatim,
627
+ * unevaluated). Row admission is a later stage: a plugin row the compatibility
628
+ * policy denies still appears here, while a denied bundle contributes no layer.
629
+ *
630
+ * Every run of rows from the same file and patch layers is preceded by a `# ==` comment
631
+ * naming the file that contributed the rows and any layers that patched them,
632
+ * so the output stays a loadable YAML document while showing which section
633
+ * comes from which file. The file and patch labels are derived from single-call prefix
634
+ * snapshots (base + layers 1..k), diffed positionally: the patch algorithm
635
+ * only rewrites rows in place or appends, so a top-level index identifies one
636
+ * row across snapshots, and a layer whose addition changes the row (config
637
+ * replacement, disable, group insert) is listed as having patched it.
638
+ *
639
+ * A patch that matches no row is reported through `warn` with its layer
640
+ * label, mirroring the Loader's boot-time warning. Earlier layers' patches
641
+ * see an identical preceding state in every snapshot that includes them, so
642
+ * each snapshot's warning list extends the previous one and the new tail
643
+ * belongs to the added layer.
644
+ * @param binName - the diagnostic prefix on read/parse errors.
645
+ * @param absoluteConfigPath - the base config file `boot()` would include.
646
+ * @param layers - overlay layers in application order (later wins).
647
+ * @param warn - sink for skipped-patch diagnostics; defaults to stderr.
648
+ * @returns the composed entry list rendered as a YAML document with
649
+ * source comment separators.
650
+ */
651
+ export function renderConfigDump(
652
+ binName: string,
653
+ absoluteConfigPath: string,
654
+ layers: ConfigDumpLayer[],
655
+ warn: (line: string) => void = line => void process.stderr.write(`${line}\n`),
656
+ ): string {
657
+ let content: string
658
+ try {
659
+ content = readFileSync(absoluteConfigPath, 'utf8')
660
+ } catch (error) {
661
+ throw new Error(`${binName}: failed to read config ${absoluteConfigPath}: ${String(error)}`)
662
+ }
663
+ let parsed: unknown
664
+ try {
665
+ parsed = yaml.load(content, { schema: entryListSchema })
666
+ } catch (error) {
667
+ throw new Error(`${binName}: failed to parse config ${absoluteConfigPath}: ${String(error)}`)
668
+ }
669
+ if (!Array.isArray(parsed)) {
670
+ throw new Error(`${binName}: config ${absoluteConfigPath} must be a top-level YAML array of entries`)
671
+ }
672
+ const baseLabel = basename(absoluteConfigPath)
673
+ // YAML parsing yields untyped rows; the include validates each entry
674
+ // at mount, and the dump prints whatever the file holds, so `EntryOptions`
675
+ // here is structural trust in the same file `boot()` would include.
676
+ const base = parsed as Parameters<typeof applyEntryPatches>[0]
677
+ // snapshot_k = ONE application of layers 1..k flattened, using the exact
678
+ // arguments boot passes for that prefix. snapshot_N is the mounted composition.
679
+ // The patches are cloned per call: applyEntryPatches clones entries and
680
+ // inserted rows but assigns override values such as `config` by reference,
681
+ // so each snapshot keeps its own copies of those values.
682
+ const snapshot = (count: number, warnings: string[]): ReturnType<typeof applyEntryPatches> => {
683
+ const flattened = structuredClone(layers.slice(0, count).flatMap(layer => layer.patches))
684
+ return applyEntryPatches(base, flattened, (message: string, ...args: unknown[]) => {
685
+ // The include logs through cordis's printf-style logger (`%C` = code); a
686
+ // dump has no logger, so substitute inline for a plain line.
687
+ let index = 0
688
+ warnings.push(message.replace(/%C/g, () => JSON.stringify(args[index++])))
689
+ })
690
+ }
691
+ let previous = base
692
+ let previousWarnings: string[] = []
693
+ const entryOrigins: { origin: string; patchedBy: string[] }[] = base.map(() => ({ origin: baseLabel, patchedBy: [] }))
694
+ let composed = base
695
+ for (let count = 1; count <= layers.length; count += 1) {
696
+ const layer = layers[count - 1]
697
+ /* v8 ignore next -- count iterates 1..length, so the slot exists */
698
+ if (layer === undefined) continue
699
+ const warnings: string[] = []
700
+ composed = snapshot(count, warnings)
701
+ for (const line of warnings.slice(previousWarnings.length)) {
702
+ warn(`${binName}: [${layer.label}] ${line}`)
703
+ }
704
+ const before = previous.map(entry => JSON.stringify(entry))
705
+ for (let index = 0; index < composed.length; index += 1) {
706
+ if (index >= before.length) entryOrigins.push({ origin: layer.label, patchedBy: [] })
707
+ else if (JSON.stringify(composed[index]) !== before[index]) entryOrigins[index]?.patchedBy.push(layer.label)
708
+ }
709
+ previous = composed
710
+ previousWarnings = warnings
711
+ }
712
+ return groupedDump(composed, entryOrigins)
713
+ }
714
+
715
+ /** Render the composed rows grouped under one source-and-patches comment per contiguous run. */
716
+ function groupedDump(
717
+ composed: readonly unknown[],
718
+ entryOrigins: readonly { origin: string; patchedBy: string[] }[],
719
+ ): string {
720
+ const lines: string[] = []
721
+ let currentLabel: string | undefined
722
+ let group: unknown[] = []
723
+ const flush = (): void => {
724
+ if (currentLabel === undefined || group.length === 0) return
725
+ lines.push(`# == ${currentLabel}`)
726
+ lines.push(yaml.dump(group, { schema: entryListSchema, noRefs: true }).trimEnd())
727
+ group = []
728
+ }
729
+ for (let index = 0; index < composed.length; index += 1) {
730
+ const record = entryOrigins[index]
731
+ /* v8 ignore next -- this array is index-aligned with composed by construction */
732
+ if (record === undefined) continue
733
+ const label = record.patchedBy.length === 0
734
+ ? record.origin
735
+ : `${record.origin}, patched by ${record.patchedBy.join(', ')}`
736
+ if (label !== currentLabel) {
737
+ flush()
738
+ currentLabel = label
739
+ }
740
+ group.push(composed[index])
741
+ }
742
+ flush()
743
+ return lines.join('\n') + '\n'
744
+ }
745
+
746
+ /**
747
+ * Mount and remember the exact root Include entry used by app boot and user patch-layer HMR.
748
+ * @param ctx - context carrying an initialized Loader service.
749
+ * @param absoluteConfigPath - absolute YAML or JSON configuration path.
750
+ * @param patches - initial app and user patches, applied in order.
751
+ * @param bareModuleBaseUrl - optional installed-host base for bare package
752
+ * names; relative names continue to resolve beside the configuration file.
753
+ * @param binName - diagnostic prefix for a profile plugin denied by compatibility policy; defaults to `dsh`.
754
+ * @returns the created root Include entry, or `undefined` when a surface
755
+ * disposed the whole tree (taking the Loader service with it) while the
756
+ * entry creation was in flight.
757
+ */
758
+ export async function mountRootInclude(
759
+ ctx: Context,
760
+ absoluteConfigPath: string,
761
+ patches: readonly PatchOptions[] = [],
762
+ bareModuleBaseUrl?: string,
763
+ binName = 'dsh',
764
+ ): Promise<Entry | undefined> {
765
+ ctx.loader.builtins.include = bareModuleBaseUrl === undefined
766
+ ? Include
767
+ : class HostResolvedRootInclude extends Include {
768
+ override import(name: string, getOuterStack?: () => string[]): unknown {
769
+ const specifier = isAbsolute(name) ? pathToFileURL(name).href : name
770
+ if (name.startsWith('.') || name.startsWith('cordis:')) return super.import(specifier, getOuterStack)
771
+ const internal = this.ctx.loader.internal
772
+ /* v8 ignore next -- Node supplies the internal loader; this preserves the
773
+ original diagnostic for hypothetical embedders without it. */
774
+ if (internal === undefined) return super.import(specifier, getOuterStack)
775
+ return internal.import(specifier, bareModuleBaseUrl, {})
776
+ }
777
+ }
778
+ // `cordis:group` alongside it: a group row is how a composition gives one
779
+ // `isolate` realm to a provider and its consumers together, and an agent
780
+ // preset living outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group`
781
+ // by name. Both builtins load through the ambient module pipeline, so neither
782
+ // depends on the included tree's own specifier resolution.
783
+ ctx.loader.builtins.group = Group
784
+ // Pinned id: the bootstrap include is app glue, not a config row, and its
785
+ // id appears in Loader failure chains — a random id would make startup
786
+ // diagnostics unstable across runs (and snapshot fixtures).
787
+ // The launcher's own copy is prepared here: compatibility decisions must be made before the root
788
+ // Include imports anything, and they change no profile patch layer, manifest, or bundle list.
789
+ const prepared = prepareProfilePatches(ctx, [...patches], pathToFileURL(dirname(absoluteConfigPath)).href + '/', binName)
790
+ const includeConfig: Include.Config = {
791
+ path: pathToFileURL(absoluteConfigPath).href,
792
+ ...prepared.length > 0 ? { patches: prepared } : {},
793
+ }
794
+ const rootInclude: EntryOptions = {
795
+ id: 'include',
796
+ name: 'cordis:include',
797
+ config: includeConfig,
798
+ }
799
+ const includeId = await ctx.loader.create(rootInclude)
800
+ const loader = ctx.get('loader')
801
+ if (loader === undefined) return undefined
802
+ const entry = loader.resolve(includeId)
803
+ bootstrapIncludes.set(ctx, entry)
804
+ return entry
805
+ }
806
+
807
+ /** The two process events {@link installFailLoud} turns into a fatal exit. */
808
+ export type FailLoudEvent = 'unhandledRejection' | 'uncaughtException'
809
+
810
+ /**
811
+ * The slice of `process` {@link installFailLoud} needs — injectable so tests
812
+ * exercise the handler without registering on (or exiting) the real process.
813
+ */
814
+ export interface FailLoudProcess {
815
+ on(event: FailLoudEvent, handler: (err: unknown) => void): unknown
816
+ off(event: FailLoudEvent, handler: (err: unknown) => void): unknown
817
+ stderr: { write(chunk: string): unknown }
818
+ /**
819
+ * Terminate the process. Callers treat this as the end of the run, as
820
+ * `process.exit` is; a fake that returns lets the caller continue, which only
821
+ * a test observes.
822
+ */
823
+ exit(code: number): void
824
+ }
825
+
826
+ // An activation rejection already folded into the boot diagnostic can also
827
+ // reach the process rejection handler. Keep exact reasons visible through the
828
+ // next process rejection checkpoint so the process guard can coalesce them.
829
+ const assembledActivationRejections = new Map<unknown, number>()
830
+
831
+ function retainAssembledRejection(reason: unknown): void {
832
+ assembledActivationRejections.set(reason, (assembledActivationRejections.get(reason) ?? 0) + 1)
833
+ }
834
+
835
+ function releaseAssembledRejection(reason: unknown): void {
836
+ const count = assembledActivationRejections.get(reason)
837
+ if (count === undefined || count === 1) {
838
+ assembledActivationRejections.delete(reason)
839
+ } else {
840
+ assembledActivationRejections.set(reason, count - 1)
841
+ }
842
+ }
843
+
844
+ async function observeLoaderRejectionCheckpoint(reasons: readonly unknown[]): Promise<void> {
845
+ for (const reason of reasons) retainAssembledRejection(reason)
846
+ try {
847
+ await new Promise<void>(resolve => setImmediate(resolve))
848
+ } finally {
849
+ for (const reason of reasons) releaseAssembledRejection(reason)
850
+ }
851
+ }
852
+
853
+ /**
854
+ * How long {@link installFailLoud} waits for its `release` hook before exiting
855
+ * anyway. A wedged disposer must delay the fatal exit, never cancel it.
856
+ */
857
+ export const FAIL_LOUD_RELEASE_TIMEOUT_MS = 2_000
858
+
859
+ /**
860
+ * Install before boot to turn an unhandled rejection or an uncaught exception,
861
+ * at any point in the process lifetime, into one labelled stderr diagnostic and
862
+ * `exit(1)`. A rejection already included by {@link auditStartupEntries} is
863
+ * ignored during its process checkpoint; every other rejection and every
864
+ * uncaught exception remains fatal. Control never returns to the failed
865
+ * operation after either: only the throw site knows which state is intact, and
866
+ * a listener that threw mid-update (a stream `'data'` handler, a half-applied
867
+ * registry write) leaves silently wrong results behind if it were resumed. The
868
+ * event loop keeps running only until the release hook settles or times out.
869
+ * Stdout remains untouched for ACP; the returned function removes both handlers.
870
+ *
871
+ * The diagnostic is `util.inspect(err)`, not `err.stack`: a `node:fs` error's
872
+ * `code`, `syscall`, and `path` and any `cause` chain are enumerable properties
873
+ * that the stack line omits, and they are what a crash report needs. Once a
874
+ * handler is installed Node prints nothing of its own, so this line is the
875
+ * only record of the failure.
876
+ *
877
+ * The Loader mounts entries concurrently, so a surface that owns the terminal
878
+ * can already hold it when a sibling entry rejects. Exiting straight from the
879
+ * handler would strand raw mode, bracketed paste, and the keyboard protocol on
880
+ * the user's shell, and leave an in-flight terminal query's reply to land as
881
+ * literal text at the next prompt. `release` is the terminal owner's chance to
882
+ * hand it back; it is awaited under {@link FAIL_LOUD_RELEASE_TIMEOUT_MS}, whose
883
+ * timer stays referenced so a never-settling disposer cannot let Node reach an
884
+ * empty event loop and exit 0 instead of failing.
885
+ *
886
+ * The diagnostic is written before the release so a hanging or failing disposer
887
+ * cannot swallow the reason. The handler stays installed while the release runs
888
+ * — removing it would let a second concurrent rejection become uncaught and kill
889
+ * the process mid-teardown, stranding exactly the terminal state this restores —
890
+ * so a latch keeps the first rejection the reported one and lets later
891
+ * rejections (including the release's own) fall through to the pending exit.
892
+ * @param binName - the diagnostic prefix on the fatal-failure line.
893
+ * @param proc - the process slice to register on; tests inject a fake.
894
+ * @param release - optional teardown awaited before exit, used by a
895
+ * terminal-owning surface to restore the terminal. Its own failure is
896
+ * swallowed because the pending fatal exit already owns the outcome.
897
+ * @returns the uninstaller that removes both handlers.
898
+ */
899
+ export function installFailLoud(
900
+ binName: string,
901
+ proc: FailLoudProcess = process,
902
+ release?: () => Promise<void> | void,
903
+ ): () => void {
904
+ let exiting = false
905
+ const report = (err: unknown, label: string): void => {
906
+ // A release in flight already owns the exit. Swallow later failures
907
+ // (teardown's own included) rather than reporting a second failure over the
908
+ // real one or letting Node kill the process before the terminal is back.
909
+ if (exiting) return
910
+ exiting = true
911
+ proc.stderr.write(`${binName}: ${label}: ${inspect(err, { depth: 4, maxArrayLength: 50 })}\n`)
912
+ if (release === undefined) {
913
+ proc.exit(1)
914
+ return
915
+ }
916
+ void (async () => {
917
+ // Definitely assigned: the timeout promise's executor runs synchronously
918
+ // while the race is being constructed, before the first await.
919
+ let timer!: ReturnType<typeof setTimeout>
920
+ try {
921
+ await Promise.race([
922
+ (async () => release())(),
923
+ new Promise<void>((resolve) => {
924
+ timer = setTimeout(resolve, FAIL_LOUD_RELEASE_TIMEOUT_MS)
925
+ }),
926
+ ])
927
+ } catch {
928
+ // The terminal release failed; the fatal exit below is the outcome that
929
+ // matters, and no reporter runs after it.
930
+ }
931
+ clearTimeout(timer)
932
+ proc.exit(1)
933
+ })()
934
+ }
935
+ const onRejection = (err: unknown): void => {
936
+ if (assembledActivationRejections.has(err)) return
937
+ // Label kept stable: the Web profile expected-output e2e tests match it.
938
+ report(err, 'fatal load failure')
939
+ }
940
+ const onException = (err: unknown): void => { report(err, 'fatal uncaught exception') }
941
+ const uninstall = (): void => {
942
+ proc.off('unhandledRejection', onRejection)
943
+ proc.off('uncaughtException', onException)
944
+ }
945
+ proc.on('unhandledRejection', onRejection)
946
+ proc.on('uncaughtException', onException)
947
+ return uninstall
948
+ }
949
+
950
+ /**
951
+ * Value mirrors used because Cordis's const enum has no runtime object to import.
952
+ * Keep aligned with `packages/client/web/src/loader-status.ts`.
953
+ */
954
+ const FIBER_PENDING = 0 as FiberState.PENDING
955
+ const FIBER_ACTIVE = 2 as FiberState.ACTIVE
956
+ const FIBER_FAILED = 3 as FiberState.FAILED
957
+ const FIBER_DISPOSED = 4 as FiberState.DISPOSED
958
+
959
+ /**
960
+ * Entry ids whose presence defines a usable DSH application.
961
+ *
962
+ * The list is global rather than profile metadata. Missing or disabled ids do
963
+ * not affect startup; an enabled listed entry must activate. The list covers
964
+ * shared Agent execution, application endpoints, and Web bootstrap/transport.
965
+ */
966
+ const requiredStartupEntryIds = new Set<string>([
967
+ 'agent-loop',
968
+ 'tool-executor-native',
969
+ 'tool-executor-ptc',
970
+ 'webserver',
971
+ 'web-runtime',
972
+ 'modules',
973
+ 'connection',
974
+ 'headless-runner',
975
+ 'acp',
976
+ 'sdk-jsonrpc-server',
977
+ ])
978
+
979
+ /** Render plugin stacks, nested causes, and aggregate member failures once per error. */
980
+ function formatActivationError(error: unknown, stacks = true): string {
981
+ const details: string[] = []
982
+ const seen = new Set<Error>()
983
+ function visit(value: unknown): void {
984
+ if (!(value instanceof Error)) {
985
+ details.push(String(value))
986
+ return
987
+ }
988
+ if (seen.has(value)) return
989
+ seen.add(value)
990
+ details.push(stacks ? value.stack ?? value.message : String(value))
991
+ if (value.cause !== undefined) visit(value.cause)
992
+ if (value instanceof AggregateError) value.errors.forEach(visit)
993
+ }
994
+ visit(error)
995
+ return details.join('\n')
996
+ }
997
+
998
+ interface InactiveEntry {
999
+ /** Loader entry used to identify the bootstrap Include and required ids. */
1000
+ entry: Entry
1001
+ /** Activation errors and missing services remain distinct for presentation. */
1002
+ outcome: { kind: 'failed'; error: unknown; phase?: string }
1003
+ | { kind: 'pending'; missing: string[] }
1004
+ }
1005
+
1006
+ /** Inactive plugin metadata without retaining its Context or Fiber. */
1007
+ interface StartupEntryDiagnostic {
1008
+ id: string
1009
+ module: string
1010
+ required: boolean
1011
+ fiberState: FiberState | undefined
1012
+ outcome: InactiveEntry['outcome']
1013
+ }
1014
+
1015
+ /** Startup warning or error arguments, including import errors with no Fiber. */
1016
+ interface StartupLogRecord {
1017
+ ts: number
1018
+ name: string
1019
+ type: string
1020
+ args: readonly unknown[]
1021
+ }
1022
+
1023
+ /** Startup audit failure with non-enumerable metadata and original failures as its cause. */
1024
+ export class StartupError extends Error {
1025
+ /** Plugin states captured before disposal, excluding configuration values. */
1026
+ composition?: StartupCompositionDiagnostic
1027
+ /** Root configuration and startup logs, attached by boot after disposal. */
1028
+ startup?: { configurationPath: string; messages: readonly StartupLogRecord[] }
1029
+
1030
+ /**
1031
+ * @param message - concise terminal diagnostic.
1032
+ * @param entries - inactive plugin metadata and original failure values.
1033
+ */
1034
+ constructor(message: string, readonly entries: readonly StartupEntryDiagnostic[]) {
1035
+ const failures = entries.flatMap(({ outcome }) => outcome.kind === 'failed' ? [outcome.error] : [])
1036
+ super(message, failures.length > 0 ? { cause: new AggregateError(failures, 'Plugin activation failures') } : undefined)
1037
+ Object.defineProperties(this, {
1038
+ entries: { enumerable: false },
1039
+ startup: { enumerable: false },
1040
+ composition: { enumerable: false },
1041
+ })
1042
+ }
1043
+ }
1044
+
1045
+ /** The error importing an entry's module raises now, which Node returns from its cache for a module that failed before. */
1046
+ async function importFailure(entry: Entry): Promise<unknown> {
1047
+ try {
1048
+ await entry.parent.tree.import(entry.options.name)
1049
+ return undefined
1050
+ } catch (error) {
1051
+ return error
1052
+ }
1053
+ }
1054
+
1055
+ /**
1056
+ * Collect Loader activation failures and disabled-expression errors. Failed
1057
+ * fibers are awaited to recover their recorded rejection reason and coalesce
1058
+ * duplicate Loader notifications through the next process rejection checkpoint.
1059
+ * An entry whose import failed reports the error importing it raises now; without
1060
+ * `importNow` nothing is imported and the entry reports a failed import without the import error.
1061
+ */
1062
+ async function inactiveEntries(ctx: Context, importNow = true): Promise<InactiveEntry[]> {
1063
+ const failures: InactiveEntry[] = []
1064
+ const rejectionReasons: unknown[] = []
1065
+ for (const entry of ctx.loader.entries()) {
1066
+ try {
1067
+ if (entry.disabled) continue
1068
+ } catch (error) {
1069
+ failures.push({ entry, outcome: { kind: 'failed', error, phase: 'disabled expression failed' } })
1070
+ continue
1071
+ }
1072
+ const fiber = entry.fiber
1073
+ // An entry whose import failed has no fiber, or keeps the disposed fiber of its previous activation.
1074
+ if (fiber === undefined || fiber.state === FIBER_DISPOSED) {
1075
+ const error = importNow ? await importFailure(entry) : undefined
1076
+ failures.push({ entry, outcome: error === undefined
1077
+ ? { kind: 'failed', error: fiber === undefined ? 'failed to import' : `fiber state ${String(fiber.state)}` }
1078
+ : { kind: 'failed', error, phase: 'failed to import' } })
1079
+ continue
1080
+ }
1081
+ const state = fiber.state
1082
+ if (state === FIBER_ACTIVE) continue
1083
+ if (state === FIBER_FAILED) {
1084
+ try {
1085
+ await fiber.await()
1086
+ } catch (error) {
1087
+ rejectionReasons.push(error)
1088
+ failures.push({ entry, outcome: { kind: 'failed', error } })
1089
+ }
1090
+ continue
1091
+ }
1092
+ if (state === FIBER_PENDING) {
1093
+ const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
1094
+ failures.push({
1095
+ entry,
1096
+ outcome: { kind: 'pending', missing },
1097
+ })
1098
+ } else {
1099
+ failures.push({ entry, outcome: { kind: 'failed', error: `fiber state ${String(state)}` } })
1100
+ }
1101
+ }
1102
+ if (rejectionReasons.length > 0) await observeLoaderRejectionCheckpoint(rejectionReasons)
1103
+ return failures
1104
+ }
1105
+
1106
+ /** Render one failed plugin's original error and activation phase. */
1107
+ function failureDetail(outcome: Extract<InactiveEntry['outcome'], { kind: 'failed' }>, stacks = true): string {
1108
+ return `${outcome.phase === undefined ? '' : `${outcome.phase}: `}${formatActivationError(outcome.error, stacks)}`
1109
+ }
1110
+
1111
+ /** Render optional-only warnings without changing startup policy. */
1112
+ function activationDiagnostic(
1113
+ binName: string,
1114
+ failures: readonly InactiveEntry[],
1115
+ stacks = true,
1116
+ ): string {
1117
+ const noun = failures.length === 1 ? 'entry' : 'entries'
1118
+ return `${binName}: warning: ${String(failures.length)} ${noun} did not activate\n${failures.map(failure => inactiveDiagnostic(failure, stacks)).join('\n')}\n`
1119
+ }
1120
+
1121
+ /** Per-entry reload text, which people read through the Web UI and agents, so it carries error messages without stack frames. */
1122
+ function reloadDiagnostic(failure: InactiveEntry): string {
1123
+ return inactiveDiagnostic(failure, false)
1124
+ }
1125
+
1126
+ /** Stable per-entry text for reload comparisons and optional warnings. */
1127
+ function inactiveDiagnostic({ entry, outcome }: InactiveEntry, stacks = true): string {
1128
+ const detail = outcome.kind === 'failed' ? failureDetail(outcome, stacks)
1129
+ : `pending (waiting for ${outcome.missing.length === 1 ? 'service' : 'services'}: ${outcome.missing.join(', ') || 'unknown'})`
1130
+ return `${entry.options.id} (${entry.options.name}): ${detail}`
1131
+ }
1132
+
1133
+ /** Group startup failures and pending services, marking every required entry. */
1134
+ function startupDiagnostic(binName: string, failures: readonly InactiveEntry[], required: ReadonlySet<Entry>): string {
1135
+ const lines = [`${binName}: startup failed: ${String(required.size)} required ${required.size === 1 ? 'plugin' : 'plugins'} did not activate`]
1136
+ const failed = failures.flatMap(({ entry, outcome }) => outcome.kind === 'failed' ? [{ entry, outcome }] : [])
1137
+ const pending = failures.flatMap(({ entry, outcome }) => outcome.kind === 'pending' ? [{ entry, outcome }] : [])
1138
+ pending.sort((left, right) => Number(required.has(right.entry)) - Number(required.has(left.entry)))
1139
+ const label = (entry: Entry): string => `${entry.options.id}${required.has(entry) ? ' (required)' : ''}`
1140
+ if (failed.length > 0) {
1141
+ lines.push('', `Failed plugins (${String(failed.length)}):`)
1142
+ for (const { entry, outcome } of failed) {
1143
+ lines.push(` ${label(entry)}`, ` Package: ${entry.options.name}`)
1144
+ lines.push(...failureDetail(outcome).split('\n').map(line => ` ${line}`))
1145
+ }
1146
+ }
1147
+ if (pending.length > 0) {
1148
+ const width = Math.max('Plugin'.length, ...pending.map(({ entry }) => label(entry).length)) + 2
1149
+ lines.push('', `Plugins waiting for services (${String(pending.length)}):`, ` ${'Plugin'.padEnd(width)}Missing services`)
1150
+ for (const { entry, outcome } of pending) {
1151
+ lines.push(` ${label(entry).padEnd(width)}${outcome.missing.join(', ') || 'unknown'}`)
1152
+ }
1153
+ }
1154
+ return lines.join('\n')
1155
+ }
1156
+
1157
+ /**
1158
+ * Apply DSH startup policy to a settled Loader tree.
1159
+ *
1160
+ * Inactive entries from the global required list reject startup. Other
1161
+ * inactive entries join that failure diagnostic, or produce one warning when
1162
+ * no required entry failed and leave successful siblings running.
1163
+ * Required ids absent from the tree, and disabled required entries, are ignored.
1164
+ * A throwing disabled expression is an entry failure, not a disabled entry.
1165
+ * The bootstrap Include must activate so unreadable or invalid root config is fatal.
1166
+ * @param ctx - the settled context whose Loader entries to audit.
1167
+ * @param binName - the prefix on startup diagnostics.
1168
+ * @param warn - sink for optional-entry warnings.
1169
+ * @returns after optional warnings if required startup checks pass.
1170
+ * @throws {@link StartupError} when the bootstrap Include or a required entry is inactive or its disabled expression throws;
1171
+ * its message includes optional failures too.
1172
+ */
1173
+ export async function auditStartupEntries(
1174
+ ctx: Context,
1175
+ binName: string,
1176
+ warn: (line: string) => void = line => void process.stderr.write(line),
1177
+ ): Promise<void> {
1178
+ const failures = await inactiveEntries(ctx)
1179
+ const required = new Set(failures.filter(({ entry }) => entry === bootstrapIncludes.get(ctx)
1180
+ || requiredStartupEntryIds.has(entry.options.id)).map(({ entry }) => entry))
1181
+ if (required.size > 0) {
1182
+ throw new StartupError(startupDiagnostic(binName, failures, required), failures.map(({ entry, outcome }) => ({
1183
+ id: entry.options.id, module: entry.options.name, required: required.has(entry), fiberState: entry.fiber?.state, outcome,
1184
+ })))
1185
+ }
1186
+ if (failures.length > 0) warn(activationDiagnostic(binName, failures))
1187
+ }
1188
+
1189
+ /**
1190
+ * Boot the Loader against `absoluteConfigPath` and return only after the whole
1191
+ * tree settles. Relative entry names resolve against the config directory;
1192
+ * bare package names resolve there by default or against an explicit
1193
+ * `bareModuleBaseUrl` for closed packaged runtimes. The bootstrap include
1194
+ * is statically imported and mounted as the `cordis:include` builtin, loading
1195
+ * through the ambient module pipeline (vite/tsx/plain ESM). The package build
1196
+ * embeds Include while leaving Loader external, so the built include tree and
1197
+ * host share one Loader peer. Loader settlement drains entry work without
1198
+ * rejecting the whole tree. The final {@link auditStartupEntries} call rejects
1199
+ * failures in the global required list and warns about other failed, missing,
1200
+ * and pending entries while successful siblings remain active. Later unhandled
1201
+ * rejections remain covered by {@link installFailLoud}. Built bins need the Loader's native
1202
+ * helper for bare plugin specifiers; relative specifiers do not.
1203
+ * @param binName - the diagnostic prefix for load-failure errors.
1204
+ * @param absoluteConfigPath - the config to include; must already be absolute
1205
+ * (see {@link resolveConfigPath}).
1206
+ * @param patches - optional overlay patches applied over the included tree
1207
+ * (see {@link loadOptionalPatches}); an empty list mounts none.
1208
+ * @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts.
1209
+ * @param bareModuleBaseUrl - optional installed-host base for bare package
1210
+ * names; use it when the host, rather than the configuration project, owns the
1211
+ * complete plugin set.
1212
+ * @returns the root context after the initial startup audit, or as soon as a
1213
+ * surface disposed the tree while startup was still in flight.
1214
+ * @throws {@link StartupError} for an inactive required entry, including all inactive plugins in its message;
1215
+ * otherwise a labelled error after disposing the partial context — `host
1216
+ * preparation failed` when `prepare` threw before any config-tree entry
1217
+ * mounted, `plugin tree failed to load` afterwards. Cyclic causes terminate
1218
+ * diagnostic traversal without replacing the original cause.
1219
+ */
1220
+ export async function boot(
1221
+ binName: string,
1222
+ absoluteConfigPath: string,
1223
+ patches?: PatchOptions[],
1224
+ prepare?: (ctx: Context) => Promise<void> | void,
1225
+ bareModuleBaseUrl?: string,
1226
+ ): Promise<Context> {
1227
+ const ctx = new Context()
1228
+ const startupLogs: StartupLogRecord[] = []
1229
+ // The collector must outlive root disposal to retain asynchronous cleanup errors.
1230
+ const diagnostics = new Context()
1231
+ diagnostics.logger = ctx.logger
1232
+ diagnostics.logger.exporter({
1233
+ levels: { default: 2 },
1234
+ export: ({ ts, name, type, args }) => {
1235
+ if (type === 'warn' || type === 'error') startupLogs.push({ ts, name, type, args })
1236
+ },
1237
+ })
1238
+ // Two failure labels: `prepare` runs before any config-tree entry mounts,
1239
+ // so its failure is host setup, not the plugin tree.
1240
+ let stage = 'host preparation failed'
1241
+ try {
1242
+ ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
1243
+ ctx.provide('dshHomePath', dshHomePath)
1244
+ await ctx.plugin(Loader)
1245
+ await prepare?.(ctx)
1246
+ stage = 'plugin tree failed to load'
1247
+ await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl, binName)
1248
+ // A surface can finish and dispose the whole tree while startup is still
1249
+ // in flight, before the last entry settles. The Loader service goes with
1250
+ // it, and the activation audit describes a live tree — reading `ctx.loader`
1251
+ // past this point would throw a TypeError over an app that exited exactly
1252
+ // as asked. Re-check after settlement before auditing the tree.
1253
+ await ctx.get('loader')?.await()
1254
+ if (ctx.get('loader') === undefined) return ctx
1255
+ await auditStartupEntries(ctx, binName)
1256
+ return ctx
1257
+ } catch (cause) {
1258
+ if (cause instanceof StartupError) {
1259
+ try { cause.composition = captureStartupComposition(ctx) } catch (error) {
1260
+ ctx.logger.warn('Startup composition diagnostic unavailable', error)
1261
+ }
1262
+ }
1263
+ // Root-fiber disposal contains cleanup failures per observer (Cordis
1264
+ // fiber.ts hardening) and a repeated call returns the settled single-shot
1265
+ // result, so this await cannot reject and replace `cause`.
1266
+ await ctx.fiber.dispose()
1267
+ if (cause instanceof StartupError) {
1268
+ cause.startup = { configurationPath: absoluteConfigPath, messages: startupLogs }
1269
+ throw cause
1270
+ }
1271
+ const detail = cause instanceof Error ? cause.message : String(cause)
1272
+ // A wrapper can carry an activation error whose original stack names the failed plugin.
1273
+ let deepest: unknown = cause
1274
+ const seen = new Set<Error>()
1275
+ while (deepest instanceof Error && !seen.has(deepest) && deepest.cause !== undefined) {
1276
+ seen.add(deepest)
1277
+ deepest = deepest.cause
1278
+ }
1279
+ const stack = deepest instanceof AggregateError
1280
+ ? `\n${deepest.stack ?? deepest.message}\n${deepest.errors.map(error => formatActivationError(error)).join('\n')}`
1281
+ : deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : ''
1282
+ throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause })
1283
+ } finally {
1284
+ await diagnostics.fiber.dispose()
1285
+ }
1286
+ }