@deepseek-ai/dsh-app-boot 0.1.6-alpha.1 → 0.1.6-alpha.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.
package/lib/index.js CHANGED
@@ -1,17 +1,16 @@
1
1
  import { createRequire, isBuiltin } from "node:module";
2
2
  import { fileURLToPath, pathToFileURL } from "node:url";
3
- import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { parseEnv } from "node:util";
5
5
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
6
  import * as yaml from "js-yaml";
7
7
  import { Context, Service } from "@deepseek-ai/cordis";
8
8
  import Loader, { EntryGroup, EntryTree, isJsExpr } from "@deepseek-ai/cordis-plugin-loader";
9
- import { access, constants, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
9
+ import { access, constants, readFile, rename, writeFile } from "node:fs/promises";
10
10
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
11
11
  import Group from "@deepseek-ai/cordis-plugin-group";
12
12
  import { dshHomePath, resolveDshHome } from "@deepseek-ai/dsh-home-paths";
13
13
  import { createLaunchEnvironmentSnapshot } from "@deepseek-ai/dsh-launch-environment";
14
- import { watch } from "chokidar";
15
14
  import { withFileLock } from "@deepseek-ai/dsh-atomic-write";
16
15
  import { imports, resolve as resolve$1 } from "resolve.exports";
17
16
  import { getEnvironmentData, setEnvironmentData } from "node:worker_threads";
@@ -247,100 +246,6 @@ var Include = class extends EntryTree {
247
246
  }
248
247
  };
249
248
  //#endregion
250
- //#region lib/types/watch-config.js
251
- /** Exact-path watching for live profile patch files outside Cordis module roots. */
252
- const registrations = /* @__PURE__ */ new WeakMap();
253
- async function findWatchRoot(filename) {
254
- let root = dirname(filename);
255
- let depth = 0;
256
- while (true) try {
257
- if (!(await stat(root)).isDirectory()) throw new Error(`config watch parent is not a directory: ${root}`);
258
- const canonicalRoot = await realpath(root);
259
- return {
260
- filename: resolve(canonicalRoot, relative(root, filename)),
261
- root: canonicalRoot,
262
- depth
263
- };
264
- } catch (error) {
265
- if (error.code !== "ENOENT") throw error;
266
- const parent = dirname(root);
267
- if (parent === root) throw error;
268
- root = parent;
269
- depth += 1;
270
- }
271
- }
272
- /**
273
- * Watch one patch path, including missing parents, and serialize refresh callbacks.
274
- * @param ctx Context that owns watcher disposal and receives refresh failures.
275
- * @param filename Absolute patch-file path.
276
- * @param options Deployment watcher options inherited from the HMR configuration.
277
- * @param refresh Callback for additions, changes, and removals.
278
- * @returns A disposer that closes the watcher and drains its current refresh.
279
- * @throws When path resolution, watcher startup, or effect registration fails.
280
- */
281
- async function watchConfig(ctx, filename, options, refresh) {
282
- const target = await findWatchRoot(filename);
283
- const paths = registrations.get(ctx) ?? /* @__PURE__ */ new Set();
284
- registrations.set(ctx, paths);
285
- if (paths.has(target.filename)) throw new Error(`config path already registered: ${filename}`);
286
- const { cwd: _cwd, ignored: _ignored, ...watchOptions } = options;
287
- const watcher = watch(target.root, {
288
- ...watchOptions,
289
- depth: target.depth,
290
- ignoreInitial: false
291
- });
292
- paths.add(target.filename);
293
- const state = { dirty: false };
294
- let running;
295
- const onChange = (path) => {
296
- const observed = resolve(path);
297
- if (observed !== filename && observed !== target.filename) return;
298
- state.dirty = true;
299
- if (running) return;
300
- running = (async () => {
301
- while (state.dirty) {
302
- state.dirty = false;
303
- try {
304
- await refresh();
305
- } catch (reason) {
306
- const error = reason instanceof Error ? reason : new Error(String(reason), { cause: reason });
307
- ctx.logger.warn("config reload at %C failed", filename);
308
- ctx.logger.warn(error);
309
- }
310
- }
311
- })().finally(() => {
312
- running = void 0;
313
- });
314
- };
315
- watcher.on("add", onChange);
316
- watcher.on("change", onChange);
317
- watcher.on("unlink", onChange);
318
- const ready = Promise.withResolvers();
319
- let pending = true;
320
- watcher.once("ready", () => {
321
- pending = false;
322
- ready.resolve();
323
- });
324
- watcher.on("error", (error) => {
325
- if (pending) {
326
- pending = false;
327
- ready.reject(error);
328
- } else ctx.logger.warn(error);
329
- });
330
- const dispose = async () => {
331
- await watcher.close();
332
- paths.delete(target.filename);
333
- await running;
334
- };
335
- try {
336
- await ready.promise;
337
- return ctx.effect(() => dispose, "app-boot.watchConfig()");
338
- } catch (error) {
339
- await dispose();
340
- throw error;
341
- }
342
- }
343
- //#endregion
344
249
  //#region lib/types/profile-resolution/legacy-links.js
345
250
  /** Legacy profile-link inspection shared by the disk materializer and runtime resolver. */
346
251
  /** Profile-private package links projected into its pnpm-managed node_modules. */
@@ -446,26 +351,11 @@ function resolveProfileDir(name, home = resolveDshHome()) {
446
351
  }
447
352
  /** The shipped profile templates auto-initialized on first use, by name. */
448
353
  const PROFILE_TEMPLATES = {
449
- acp: {
450
- bundles: ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-acp-app"],
451
- patchReload: "startup"
452
- },
453
- web: {
454
- bundles: ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"],
455
- patchReload: "live"
456
- },
457
- headless: {
458
- bundles: ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-headless"],
459
- patchReload: "startup"
460
- },
461
- sdk: {
462
- bundles: ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-sdk-app"],
463
- patchReload: "startup"
464
- },
465
- "sdk-minimal": {
466
- bundles: ["@deepseek-ai/dsh-sdk-minimal"],
467
- patchReload: "startup"
468
- }
354
+ acp: { bundles: ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-acp-app"] },
355
+ web: { bundles: ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"] },
356
+ headless: { bundles: ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-headless"] },
357
+ sdk: { bundles: ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-sdk-app"] },
358
+ "sdk-minimal": { bundles: ["@deepseek-ai/dsh-sdk-minimal"] }
469
359
  };
470
360
  /** Installation-owned bundle tuples normalized to the shipped template. */
471
361
  const INSTALLATION_OWNED_PROFILE_TUPLES = { headless: [
@@ -475,8 +365,13 @@ const INSTALLATION_OWNED_PROFILE_TUPLES = { headless: [
475
365
  ] };
476
366
  /** The bundle list a `dsh plugin` init uses for a name with no shipped template. */
477
367
  const DEFAULT_PROFILE_BUNDLES = ["@deepseek-ai/dsh-base"];
478
- /** Custom profiles retain the historical live patch-file behavior. */
479
- const DEFAULT_PROFILE_PATCH_RELOAD = "live";
368
+ /**
369
+ * The bundles the dsh installation ships for a person to switch on: each a
370
+ * runtime dependency of the installation that declares `dsh.bundle.patch`,
371
+ * selected by no shipped template, and offered switched off by the plugin
372
+ * manager ([rationale](../../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md)).
373
+ */
374
+ const OPTIONAL_BUNDLES = ["@deepseek-ai/dsh-experimental-agent-team-profile", "@deepseek-ai/dsh-experimental-agent-team-web-profile"];
480
375
  const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied after every bundle layer:
481
376
  # a top-level YAML array of loader patch entries (id-targeted config
482
377
  # overrides, disables, and insert lists; \`!!js\` expressions allowed).
@@ -494,9 +389,8 @@ autoInstallPeers: false
494
389
  * so re-running is a no-op on an initialized profile.
495
390
  * @param dir - the profile directory from {@link resolveProfileDir}.
496
391
  * @param bundles - the initial `dsh.profile.bundles` layer list.
497
- * @param patchReload - user patch-file lifecycle; custom profiles default to live reload.
498
392
  */
499
- function initProfile(dir, bundles, patchReload = DEFAULT_PROFILE_PATCH_RELOAD) {
393
+ function initProfile(dir, bundles) {
500
394
  mkdirSync(dir, { recursive: true });
501
395
  const manifestPath = join(dir, "package.json");
502
396
  if (!existsSync(manifestPath)) {
@@ -504,10 +398,7 @@ function initProfile(dir, bundles, patchReload = DEFAULT_PROFILE_PATCH_RELOAD) {
504
398
  name: `dsh-profile-${basename(dir)}`,
505
399
  private: true,
506
400
  dependencies: {},
507
- dsh: { profile: {
508
- bundles: [...bundles],
509
- patchReload
510
- } }
401
+ dsh: { profile: { bundles: [...bundles] } }
511
402
  };
512
403
  writeFileSync(manifestPath, JSON.stringify(manifest, void 0, 2) + "\n");
513
404
  }
@@ -823,6 +714,26 @@ function createProfileResolutionGeneration(options) {
823
714
  materialize: false
824
715
  });
825
716
  }
717
+ /**
718
+ * Supply an application-owned profile with filesystem packages from its installation and selected bundles.
719
+ * All fallback links belong to the profile; no shared Harness-home directory is written.
720
+ * Existing pnpm-managed packages remain authoritative. The caller serializes profile mutations.
721
+ * @param options - owning installation package.json and the loaded application profile.
722
+ */
723
+ function healIsolatedProfileModuleFallback(options) {
724
+ const installationLinks = resolveModuleFallbackEntries(options.installAnchor, false).packageDirs;
725
+ healProfileModuleFallback(options.profile, new Set(installationLinks.keys()), true, void 0, void 0, installationLinks);
726
+ }
727
+ /**
728
+ * Detach this profile's fallback links before a package-manager mutation.
729
+ * Installed packages and links replaced by pnpm remain untouched; the next profile launch restores fallbacks.
730
+ * @param profileDir - profile directory whose package mutation is serialized by the caller.
731
+ */
732
+ function unlinkProfileModuleFallback(profileDir) {
733
+ const ownedModulesDir = join(profileDir, PROFILE_MODULE_FALLBACK_DIR, "node_modules");
734
+ if (!existsSync(ownedModulesDir)) return;
735
+ for (const name of ownedPackageNames(ownedModulesDir)) removeProfileSymlink(join(profileDir, "node_modules"), ownedModulesDir, name);
736
+ }
826
737
  /** Heal one module-fallback generation while the cross-process writer lock is held. */
827
738
  function healProfilesModuleFallbackLocked(entries, modulesDir) {
828
739
  for (const entry of entries) {
@@ -872,7 +783,7 @@ function dependencyClosure(anchors, reserved, exclude, declarers, versions) {
872
783
  return links;
873
784
  }
874
785
  /** Reconcile packages carried only by selected bundles into one profile. */
875
- function healProfileModuleFallback(profile, installationPackageNames, materialize = true, declarers, versions) {
786
+ function healProfileModuleFallback(profile, installationPackageNames, materialize = true, declarers, versions, installationLinks = /* @__PURE__ */ new Map()) {
876
787
  const profileModulesDir = join(profile.dir, "node_modules");
877
788
  const ownedModulesDir = join(profile.dir, PROFILE_MODULE_FALLBACK_DIR, "node_modules");
878
789
  if (materialize) {
@@ -892,9 +803,10 @@ function healProfileModuleFallback(profile, installationPackageNames, materializ
892
803
  }
893
804
  }, declarers, versions);
894
805
  for (const layer of profile.layers) bundleLinks.delete(layer.packageName);
895
- if (!materialize) return bundleLinks;
896
- for (const packageName of ownedPackageNames(ownedModulesDir)) if (!bundleLinks.has(packageName)) removeProfileSymlink(profileModulesDir, ownedModulesDir, packageName);
897
- for (const [packageName, target] of bundleLinks) {
806
+ const links = new Map([...installationLinks, ...bundleLinks]);
807
+ if (!materialize) return links;
808
+ for (const packageName of ownedPackageNames(ownedModulesDir)) if (!links.has(packageName)) removeProfileSymlink(profileModulesDir, ownedModulesDir, packageName);
809
+ for (const [packageName, target] of links) {
898
810
  const ownedLink = join(ownedModulesDir, packageName);
899
811
  mkdirSync(dirname(ownedLink), { recursive: true });
900
812
  ensureSymlink(ownedLink, target);
@@ -902,7 +814,7 @@ function healProfileModuleFallback(profile, installationPackageNames, materializ
902
814
  mkdirSync(dirname(profileLink), { recursive: true });
903
815
  ensureProfileSymlink(profileLink, ownedLink);
904
816
  }
905
- return bundleLinks;
817
+ return links;
906
818
  }
907
819
  /**
908
820
  * Read a profile's manifest.
@@ -936,27 +848,21 @@ function sameBundles(left, right) {
936
848
  }
937
849
  /**
938
850
  * Normalize an exact installation-owned bundle tuple to its shipped template,
939
- * or add the shipped reload default to an exact current tuple. A changed value
940
- * is written back during profile loading while every other manifest field is
941
- * preserved; any other bundle list is user-owned and remains untouched.
851
+ * preserving all other manifest fields. Other bundle lists remain untouched.
942
852
  */
943
853
  function normalizeShippedProfile(name, dir, manifest) {
944
854
  const installationOwned = INSTALLATION_OWNED_PROFILE_TUPLES[name];
945
855
  const template = PROFILE_TEMPLATES[name];
946
856
  const bundles = manifest.dsh?.profile?.bundles;
947
857
  if (template === void 0 || bundles === void 0) return manifest;
948
- const isRetiredTuple = installationOwned !== void 0 && sameBundles(bundles, installationOwned);
949
- const isCurrentTuple = sameBundles(bundles, template.bundles);
950
- const needsReloadDefault = manifest.dsh?.profile?.patchReload === void 0 && isCurrentTuple;
951
- if (!isRetiredTuple && !needsReloadDefault) return manifest;
858
+ if (!(installationOwned !== void 0 && sameBundles(bundles, installationOwned))) return manifest;
952
859
  const normalized = {
953
860
  ...manifest,
954
861
  dsh: {
955
862
  ...manifest.dsh,
956
863
  profile: {
957
864
  ...manifest.dsh?.profile,
958
- bundles: [...template.bundles],
959
- patchReload: manifest.dsh?.profile?.patchReload ?? template.patchReload
865
+ bundles: [...template.bundles]
960
866
  }
961
867
  }
962
868
  };
@@ -1008,12 +914,7 @@ function resolveBundleDir(binName, packageName, installAnchor, profileDir) {
1008
914
  * @returns the resolved bundle layers and optional user patch layer.
1009
915
  */
1010
916
  function loadProfileDirectory(binName, dir, installAnchor, options = {}) {
1011
- const manifest = readProfileManifest(binName, dir);
1012
- const bundles = manifest.dsh?.profile?.bundles ?? [];
1013
- const rawPatchReload = manifest.dsh?.profile?.patchReload;
1014
- if (rawPatchReload !== void 0 && rawPatchReload !== "live" && rawPatchReload !== "startup") throw new Error(`${binName}: profile manifest ${join(dir, "package.json")} dsh.profile.patchReload must be "live" or "startup"`);
1015
- const patchReload = rawPatchReload ?? "live";
1016
- const layers = bundles.map((packageName) => {
917
+ const layers = (readProfileManifest(binName, dir).dsh?.profile?.bundles ?? []).map((packageName) => {
1017
918
  const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir);
1018
919
  const declared = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")).dsh?.bundle?.patch;
1019
920
  if (declared === void 0) throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`);
@@ -1032,8 +933,7 @@ function loadProfileDirectory(binName, dir, installAnchor, options = {}) {
1032
933
  dir,
1033
934
  layers,
1034
935
  patchPath,
1035
- patches,
1036
- patchReload
936
+ patches
1037
937
  };
1038
938
  }
1039
939
  /**
@@ -1055,7 +955,7 @@ function loadProfile(binName, name, installAnchor, home = resolveDshHome(), opti
1055
955
  if (!existsSync(join(dir, "package.json"))) {
1056
956
  const template = PROFILE_TEMPLATES[name];
1057
957
  if (template === void 0) throw new Error(`${binName}: profile ${JSON.stringify(name)} does not exist; create it with 'dsh plugin --profile ${name} add <package>'`);
1058
- initProfile(dir, template.bundles, template.patchReload);
958
+ initProfile(dir, template.bundles);
1059
959
  }
1060
960
  normalizeShippedProfile(name, dir, readProfileManifest(binName, dir));
1061
961
  return loadProfileDirectory(binName, dir, installAnchor, options);
@@ -1075,6 +975,166 @@ function composeEntries(layers, warn = () => {}) {
1075
975
  });
1076
976
  }
1077
977
  //#endregion
978
+ //#region lib/types/profile-context.js
979
+ /** Launcher-owned profile locations and composition inputs. */
980
+ const TELEMETRY_ROW_ID = "session-telemetry-otel";
981
+ /**
982
+ * Resolve the telemetry opt-out switch into its boot patch. ANY non-empty
983
+ * value (including `'0'`/`'false'`) disables: a privacy switch prefers
984
+ * off-by-mistake over on-by-mistake. A composition without the telemetry row
985
+ * exports nothing, so the switch is then trivially satisfied and no patch is
986
+ * generated — custom profiles need not mount telemetry to run with the
987
+ * switch set.
988
+ * @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset).
989
+ * @param hasRow - whether the composition carries the telemetry row.
990
+ * @returns the disable patch, or `undefined` when no hard-disable patch is required.
991
+ */
992
+ function resolveTelemetryPatch(disabledEnv, hasRow) {
993
+ if ((disabledEnv ?? "") === "" || !hasRow) return void 0;
994
+ return {
995
+ id: TELEMETRY_ROW_ID,
996
+ disabled: true
997
+ };
998
+ }
999
+ /** Read current bundle and user layers with the launch-time overlays.
1000
+ * @param binName Diagnostic prefix for malformed or missing configuration.
1001
+ * @param context Data supplied by the profile launcher.
1002
+ * @param initialProfile Already loaded startup profile; omitted reads the current files.
1003
+ * @returns Detached ordered patches; this function does not update the Loader.
1004
+ */
1005
+ function readProfilePatches(binName, context, initialProfile) {
1006
+ const profile = initialProfile ?? loadProfileDirectory(binName, context.dir, context.installAnchor, { userLayer: false });
1007
+ const patches = structuredClone([
1008
+ ...profile.layers.flatMap((layer) => layer.patches),
1009
+ ...initialProfile?.patches ?? loadOptionalPatches(binName, context.patchPath) ?? [],
1010
+ ...loadOptionalPatches(binName, join(context.home, "cordis.patch.yml")) ?? [],
1011
+ ...context.overlays
1012
+ ]);
1013
+ const telemetryPatch = resolveTelemetryPatch(context.telemetryDisabledEnv, composeEntries([patches]).some((row) => row.id === TELEMETRY_ROW_ID));
1014
+ if (telemetryPatch !== void 0) patches.push(telemetryPatch);
1015
+ return patches;
1016
+ }
1017
+ //#endregion
1018
+ //#region lib/types/profile-plugins.js
1019
+ /** Installed profile dependencies and their bundle activation after package-manager operations. */
1020
+ /** Unavailable installed metadata must not prevent listing or removing dependencies. */
1021
+ function optionalManifest(binName, packageDir) {
1022
+ try {
1023
+ return readProfileManifest(binName, packageDir);
1024
+ } catch {
1025
+ return;
1026
+ }
1027
+ }
1028
+ /** Resolve activation metadata with the same installation precedence as profile loading. */
1029
+ function bundleManifest(location, name) {
1030
+ let packageDir;
1031
+ try {
1032
+ packageDir = resolveBundleDir(location.binName, name, location.installAnchor, location.profileDir);
1033
+ } catch {
1034
+ return;
1035
+ }
1036
+ return optionalManifest(location.binName, packageDir);
1037
+ }
1038
+ /**
1039
+ * Read installed versions and bundle declarations without requiring loadable plugin code.
1040
+ * Missing or unreadable installed metadata leaves the dependency visible for repair or removal.
1041
+ * @param location - profile and installation resolution inputs.
1042
+ * @returns dependency records in package.json order and the profile manifest.
1043
+ */
1044
+ function readProfilePlugins(location) {
1045
+ const manifest = readProfileManifest(location.binName, location.profileDir);
1046
+ const bundles = manifest.dsh?.profile?.bundles ?? [];
1047
+ return {
1048
+ manifest,
1049
+ dependencies: Object.entries(manifest.dependencies ?? {}).map(([name, spec]) => {
1050
+ const installed = optionalManifest(location.binName, join(location.profileDir, "node_modules", name));
1051
+ return {
1052
+ name,
1053
+ version: typeof installed?.version === "string" ? installed.version : spec,
1054
+ bundle: bundleManifest(location, name)?.dsh?.bundle?.patch !== void 0,
1055
+ enabled: bundles.includes(name)
1056
+ };
1057
+ })
1058
+ };
1059
+ }
1060
+ /**
1061
+ * Write a bundle list while preserving the supplied profile's other metadata.
1062
+ * @param profileDir - directory whose package.json is updated.
1063
+ * @param manifest - current profile manifest, read after the package operation when applicable.
1064
+ * @param bundles - ordered active bundle names, including any template entries.
1065
+ * @returns the written manifest.
1066
+ */
1067
+ function writeProfileBundles(profileDir, manifest, bundles) {
1068
+ const updated = {
1069
+ ...manifest,
1070
+ dsh: {
1071
+ ...manifest.dsh,
1072
+ profile: {
1073
+ ...manifest.dsh?.profile,
1074
+ bundles: [...bundles]
1075
+ }
1076
+ }
1077
+ };
1078
+ writeProfileManifest(profileDir, updated);
1079
+ return updated;
1080
+ }
1081
+ /**
1082
+ * Reconcile installed bundle declarations after a successful package-manager operation.
1083
+ * Dependency-managed entries disappear when removed or when their package loses its declaration;
1084
+ * template entries retain their order and duplicates. New bundle dependencies activate automatically.
1085
+ * @param options - location, inventory captured before pnpm, and whether explicitly disabled bundles remain disabled.
1086
+ * @returns updated inventory and newly added ordinary dependencies for caller-owned warnings.
1087
+ */
1088
+ function reconcileProfilePlugins(options) {
1089
+ const after = readProfilePlugins(options);
1090
+ const beforeNames = new Set(options.before.dependencies.map((dependency) => dependency.name));
1091
+ const afterNames = new Set(after.dependencies.map((dependency) => dependency.name));
1092
+ const bundleNames = new Set(after.dependencies.filter((dependency) => dependency.bundle).map((dependency) => dependency.name));
1093
+ const disabled = new Set(options.preserveDisabled ? options.before.dependencies.filter((dependency) => dependency.bundle && !dependency.enabled).map((dependency) => dependency.name) : []);
1094
+ const previous = after.manifest.dsh?.profile?.bundles ?? [];
1095
+ const bundles = previous.filter((name) => !(beforeNames.has(name) || afterNames.has(name)) || bundleNames.has(name));
1096
+ for (const dependency of after.dependencies) if (dependency.bundle && !disabled.has(dependency.name) && !bundles.includes(dependency.name)) bundles.push(dependency.name);
1097
+ return {
1098
+ plugins: {
1099
+ manifest: bundles.length !== previous.length || bundles.some((name, index) => name !== previous[index]) ? writeProfileBundles(options.profileDir, after.manifest, bundles) : after.manifest,
1100
+ dependencies: after.dependencies.map((dependency) => ({
1101
+ ...dependency,
1102
+ enabled: bundles.includes(dependency.name)
1103
+ }))
1104
+ },
1105
+ addedPlainDependencies: after.dependencies.filter((dependency) => !dependency.bundle && !beforeNames.has(dependency.name)).map((dependency) => dependency.name)
1106
+ };
1107
+ }
1108
+ //#endregion
1109
+ //#region lib/types/profile-sanitize.js
1110
+ /** Filesystem recovery for callers that own profile shutdown and write exclusion. */
1111
+ /**
1112
+ * Back up the profile patch and retain only the caller's recovery bundles.
1113
+ * The caller must stop the profile and exclude concurrent profile writes.
1114
+ * Installed packages and other manifest fields are preserved; patches are never parsed.
1115
+ * Failures propagate and may leave completed changes in place for a retry.
1116
+ * @param binName - Diagnostic prefix for invalid profile manifests.
1117
+ * @param profileDir - Profile directory to recover without loading its plugins.
1118
+ * @param bundles - Ordered bundles to enable after recovery.
1119
+ * @returns Backup path with a Unix millisecond timestamp and optional collision ordinal, or undefined if absent.
1120
+ */
1121
+ function sanitizeProfile(binName, profileDir, bundles) {
1122
+ const manifest = existsSync(join(profileDir, "package.json")) ? readProfileManifest(binName, profileDir) : void 0;
1123
+ const patchPath = join(profileDir, PROFILE_PATCH_FILENAME);
1124
+ const backupBase = `${patchPath}.bak-${Date.now()}`;
1125
+ let backupPath = backupBase;
1126
+ let ordinal = 0;
1127
+ while (existsSync(backupPath)) backupPath = `${backupBase}-${++ordinal}`;
1128
+ try {
1129
+ renameSync(patchPath, backupPath);
1130
+ } catch (error) {
1131
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
1132
+ backupPath = void 0;
1133
+ }
1134
+ if (manifest !== void 0) writeProfileBundles(profileDir, manifest, bundles);
1135
+ return backupPath;
1136
+ }
1137
+ //#endregion
1078
1138
  //#region lib/types/profile-resolution/resolver.js
1079
1139
  /** In-memory profile package routing for Node's default ESM and CommonJS loaders. */
1080
1140
  const WORKER_RESOLUTION_KEY = "@deepseek-ai/dsh-app-boot/profile-resolution";
@@ -2061,37 +2121,38 @@ function loadLayeredEnv(binName, cwd = process.cwd(), warn = (line) => void proc
2061
2121
  }
2062
2122
  const bootstrapIncludes = /* @__PURE__ */ new WeakMap();
2063
2123
  const userPatchesSchema = entryListSchema;
2064
- /**
2065
- * Watch the user patch layer and reapply it to the boot Include without rollback.
2066
- * @param ctx - settled app context containing the root Include and an active HMR service.
2067
- * @param options - diagnostic, file, and patch-composition inputs.
2068
- * @returns an asynchronous disposer after the exact-path watcher is ready.
2069
- * @throws when HMR or the root Include is absent, watcher setup fails, or initial path resolution fails.
2124
+ /** Apply one complete patch generation and wait for Loader activation diagnostics.
2125
+ * @param ctx Booted root context.
2126
+ * @param patches Complete ordered patch list.
2127
+ * @param binName Diagnostic prefix.
2128
+ * @param requiredIds Explicit enablement targets whose existing failures also reject reconciliation.
2129
+ * @returns Diagnostics for unchanged pre-existing inactive entries; new or changed failures reject.
2070
2130
  */
2071
- async function watchUserPatches(ctx, options) {
2072
- const { binName, filename, compose = (patches) => patches } = options;
2073
- const hmr = ctx.get("hmr");
2074
- if (hmr === void 0) throw new Error(`${binName}: user patch-layer watching requires the Cordis HMR service`);
2131
+ async function reconcileProfilePatches(ctx, patches, binName, requiredIds = []) {
2075
2132
  const entry = bootstrapIncludes.get(ctx);
2076
- if (entry === void 0) throw new Error(`${binName}: user patch-layer watching requires the root Include entry`);
2077
- const register = watchConfig(ctx, filename, hmr.config, async () => {
2078
- const { patches: _previousPatches, ...includeConfig } = entry.options.config;
2079
- const patches = compose(loadOptionalPatches(binName, filename) ?? []);
2080
- await entry.update({ config: {
2081
- ...includeConfig,
2082
- patches
2083
- } });
2084
- await ctx.loader.await();
2085
- await Promise.allSettled([...ctx.loader.entries()].map((entry) => Promise.resolve(entry.fiber?.await())));
2086
- const failures = await inactiveEntries(ctx);
2087
- if (failures.length > 0) throw new Error(activationDiagnostic(binName, "warning", failures).trimEnd());
2088
- });
2089
- try {
2090
- return await register;
2091
- } catch (error) {
2092
- if (error?.code === "INACTIVE_EFFECT") return async () => {};
2093
- throw error;
2094
- }
2133
+ if (entry === void 0) throw new Error(`${binName}: profile reload requires the root Include entry`);
2134
+ const previousFailures = (await inactiveEntries(ctx)).map((failure) => ({
2135
+ ...failure,
2136
+ diagnostic: inactiveDiagnostic(failure),
2137
+ fiber: failure.entry.fiber,
2138
+ options: JSON.stringify(failure.entry.options)
2139
+ }));
2140
+ const previousFibers = [...ctx.loader.entries()].flatMap((row) => row.fiber === void 0 ? [] : [{
2141
+ fiber: row.fiber,
2142
+ failed: row.fiber.state === FIBER_FAILED || row.fiber.state === FIBER_DISPOSED
2143
+ }]);
2144
+ const { patches: _previous, ...includeConfig } = entry.options.config;
2145
+ await entry.update({ config: {
2146
+ ...includeConfig,
2147
+ patches
2148
+ } });
2149
+ const results = await Promise.allSettled(previousFibers.map(({ fiber }) => fiber.await()));
2150
+ await ctx.loader.await();
2151
+ const failures = await inactiveEntries(ctx);
2152
+ const introduced = failures.filter((failure) => requiredIds.includes(failure.entry.options.id) || !previousFailures.some((previous) => previous.entry === failure.entry && previous.fiber === failure.entry.fiber && previous.options === JSON.stringify(failure.entry.options) && previous.diagnostic === inactiveDiagnostic(failure)));
2153
+ if (introduced.length > 0) throw new Error(activationDiagnostic(binName, introduced).trimEnd());
2154
+ for (const [index, result] of results.entries()) if (result.status === "rejected" && !previousFibers[index]?.failed) throw result.reason;
2155
+ return failures.map(inactiveDiagnostic);
2095
2156
  }
2096
2157
  /**
2097
2158
  * Load an optional patch-list file: a top-level YAML array of loader patch
@@ -2392,12 +2453,12 @@ function installFailLoud(binName, proc = process, release) {
2392
2453
  }
2393
2454
  /**
2394
2455
  * Value mirrors used because Cordis's const enum has no runtime object to import.
2395
- * Keep aligned with `packages/extensions/tool-cordis/src/fiber-state.ts` and
2396
- * `packages/client/web/src/loader-status.ts`.
2456
+ * Keep aligned with `packages/client/web/src/loader-status.ts`.
2397
2457
  */
2398
2458
  const FIBER_PENDING = 0;
2399
2459
  const FIBER_ACTIVE = 2;
2400
2460
  const FIBER_FAILED = 3;
2461
+ const FIBER_DISPOSED = 4;
2401
2462
  /**
2402
2463
  * Entry ids whose presence defines a usable DSH application.
2403
2464
  *
@@ -2432,6 +2493,25 @@ function formatActivationError(error) {
2432
2493
  visit(error);
2433
2494
  return details.join("\n");
2434
2495
  }
2496
+ /** Startup audit failure with non-enumerable metadata and original failures as its cause. */
2497
+ var StartupError = class extends Error {
2498
+ entries;
2499
+ /** Root configuration and startup logs, attached by boot after disposal. */
2500
+ startup;
2501
+ /**
2502
+ * @param message - concise terminal diagnostic.
2503
+ * @param entries - inactive plugin metadata and original failure values.
2504
+ */
2505
+ constructor(message, entries) {
2506
+ const failures = entries.flatMap(({ outcome }) => outcome.kind === "failed" ? [outcome.error] : []);
2507
+ super(message, failures.length > 0 ? { cause: new AggregateError(failures, "Plugin activation failures") } : void 0);
2508
+ this.entries = entries;
2509
+ Object.defineProperties(this, {
2510
+ entries: { enumerable: false },
2511
+ startup: { enumerable: false }
2512
+ });
2513
+ }
2514
+ };
2435
2515
  /**
2436
2516
  * Collect Loader activation failures and disabled-expression errors. Failed
2437
2517
  * fibers are awaited to recover their recorded rejection reason and coalesce
@@ -2441,13 +2521,16 @@ async function inactiveEntries(ctx) {
2441
2521
  const failures = [];
2442
2522
  const rejectionReasons = [];
2443
2523
  for (const entry of ctx.loader.entries()) {
2444
- const subject = `${entry.options.id} (${entry.options.name})`;
2445
2524
  try {
2446
2525
  if (entry.disabled) continue;
2447
2526
  } catch (error) {
2448
2527
  failures.push({
2449
2528
  entry,
2450
- diagnostic: `${subject}: disabled expression failed: ${formatActivationError(error)}`
2529
+ outcome: {
2530
+ kind: "failed",
2531
+ error,
2532
+ phase: "disabled expression failed"
2533
+ }
2451
2534
  });
2452
2535
  continue;
2453
2536
  }
@@ -2455,7 +2538,10 @@ async function inactiveEntries(ctx) {
2455
2538
  if (fiber === void 0) {
2456
2539
  failures.push({
2457
2540
  entry,
2458
- diagnostic: `${subject}: failed to import`
2541
+ outcome: {
2542
+ kind: "failed",
2543
+ error: "failed to import"
2544
+ }
2459
2545
  });
2460
2546
  continue;
2461
2547
  }
@@ -2468,7 +2554,10 @@ async function inactiveEntries(ctx) {
2468
2554
  rejectionReasons.push(error);
2469
2555
  failures.push({
2470
2556
  entry,
2471
- diagnostic: `${subject}: ${formatActivationError(error)}`
2557
+ outcome: {
2558
+ kind: "failed",
2559
+ error
2560
+ }
2472
2561
  });
2473
2562
  }
2474
2563
  continue;
@@ -2477,42 +2566,90 @@ async function inactiveEntries(ctx) {
2477
2566
  const missing = Object.keys(fiber.inject).filter((service) => fiber.ctx.get(service) === void 0);
2478
2567
  failures.push({
2479
2568
  entry,
2480
- diagnostic: `${subject}: pending (waiting for ${missing.length === 1 ? "service" : "services"}: ${missing.join(", ") || "unknown"})`
2569
+ outcome: {
2570
+ kind: "pending",
2571
+ missing
2572
+ }
2481
2573
  });
2482
2574
  } else failures.push({
2483
2575
  entry,
2484
- diagnostic: `${subject}: fiber state ${String(state)}`
2576
+ outcome: {
2577
+ kind: "failed",
2578
+ error: `fiber state ${String(state)}`
2579
+ }
2485
2580
  });
2486
2581
  }
2487
2582
  if (rejectionReasons.length > 0) await observeLoaderRejectionCheckpoint(rejectionReasons);
2488
2583
  return failures;
2489
2584
  }
2490
- /** Render an inactive-entry diagnostic with a count and severity label. */
2491
- function activationDiagnostic(binName, severity, failures) {
2585
+ /** Render one failed plugin's original error and activation phase. */
2586
+ function failureDetail(outcome) {
2587
+ return `${outcome.phase === void 0 ? "" : `${outcome.phase}: `}${formatActivationError(outcome.error)}`;
2588
+ }
2589
+ /** Render optional-only warnings without changing startup policy. */
2590
+ function activationDiagnostic(binName, failures) {
2492
2591
  const noun = failures.length === 1 ? "entry" : "entries";
2493
- return `${binName === "" ? "" : `${binName}: `}${severity}: ${String(failures.length)} ${noun} did not activate\n${failures.map((failure) => failure.diagnostic).join("\n")}\n`;
2592
+ return `${binName}: warning: ${String(failures.length)} ${noun} did not activate\n${failures.map(inactiveDiagnostic).join("\n")}\n`;
2593
+ }
2594
+ /** Stable per-entry text for reload comparisons and optional warnings. */
2595
+ function inactiveDiagnostic({ entry, outcome }) {
2596
+ const detail = outcome.kind === "failed" ? failureDetail(outcome) : `pending (waiting for ${outcome.missing.length === 1 ? "service" : "services"}: ${outcome.missing.join(", ") || "unknown"})`;
2597
+ return `${entry.options.id} (${entry.options.name}): ${detail}`;
2598
+ }
2599
+ /** Group startup failures and pending services, marking every required entry. */
2600
+ function startupDiagnostic(binName, failures, required) {
2601
+ const lines = [`${binName}: startup failed: ${String(required.size)} required ${required.size === 1 ? "plugin" : "plugins"} did not activate`];
2602
+ const failed = failures.flatMap(({ entry, outcome }) => outcome.kind === "failed" ? [{
2603
+ entry,
2604
+ outcome
2605
+ }] : []);
2606
+ const pending = failures.flatMap(({ entry, outcome }) => outcome.kind === "pending" ? [{
2607
+ entry,
2608
+ outcome
2609
+ }] : []);
2610
+ pending.sort((left, right) => Number(required.has(right.entry)) - Number(required.has(left.entry)));
2611
+ const label = (entry) => `${entry.options.id}${required.has(entry) ? " (required)" : ""}`;
2612
+ if (failed.length > 0) {
2613
+ lines.push("", `Failed plugins (${String(failed.length)}):`);
2614
+ for (const { entry, outcome } of failed) {
2615
+ lines.push(` ${label(entry)}`, ` Package: ${entry.options.name}`);
2616
+ lines.push(...failureDetail(outcome).split("\n").map((line) => ` ${line}`));
2617
+ }
2618
+ }
2619
+ if (pending.length > 0) {
2620
+ const width = Math.max(6, ...pending.map(({ entry }) => label(entry).length)) + 2;
2621
+ lines.push("", `Plugins waiting for services (${String(pending.length)}):`, ` ${"Plugin".padEnd(width)}Missing services`);
2622
+ for (const { entry, outcome } of pending) lines.push(` ${label(entry).padEnd(width)}${outcome.missing.join(", ") || "unknown"}`);
2623
+ }
2624
+ return lines.join("\n");
2494
2625
  }
2495
2626
  /**
2496
2627
  * Apply DSH startup policy to a settled Loader tree.
2497
2628
  *
2498
2629
  * Inactive entries from the global required list reject startup. Other
2499
- * inactive entries produce one warning and leave successful siblings running.
2630
+ * inactive entries join that failure diagnostic, or produce one warning when
2631
+ * no required entry failed and leave successful siblings running.
2500
2632
  * Required ids absent from the tree, and disabled required entries, are ignored.
2501
2633
  * A throwing disabled expression is an entry failure, not a disabled entry.
2502
2634
  * The bootstrap Include must activate so unreadable or invalid root config is fatal.
2503
2635
  * @param ctx - the settled context whose Loader entries to audit.
2504
- * @param binName - the diagnostic prefix on optional-entry warnings.
2636
+ * @param binName - the prefix on startup diagnostics.
2505
2637
  * @param warn - sink for optional-entry warnings.
2506
2638
  * @returns after optional warnings if required startup checks pass.
2507
- * @throws when the bootstrap Include or a required entry is inactive or its disabled expression throws.
2639
+ * @throws {@link StartupError} when the bootstrap Include or a required entry is inactive or its disabled expression throws;
2640
+ * its message includes optional failures too.
2508
2641
  */
2509
2642
  async function auditStartupEntries(ctx, binName, warn = (line) => void process.stderr.write(line)) {
2510
2643
  const failures = await inactiveEntries(ctx);
2511
- const required = [];
2512
- const optional = [];
2513
- for (const failure of failures) (failure.entry === bootstrapIncludes.get(ctx) || requiredStartupEntryIds.has(failure.entry.options.id) ? required : optional).push(failure);
2514
- if (optional.length > 0) warn(activationDiagnostic(binName, "warning", optional));
2515
- if (required.length > 0) throw new Error(activationDiagnostic("", "required startup failure", required).trimEnd());
2644
+ const required = new Set(failures.filter(({ entry }) => entry === bootstrapIncludes.get(ctx) || requiredStartupEntryIds.has(entry.options.id)).map(({ entry }) => entry));
2645
+ if (required.size > 0) throw new StartupError(startupDiagnostic(binName, failures, required), failures.map(({ entry, outcome }) => ({
2646
+ id: entry.options.id,
2647
+ module: entry.options.name,
2648
+ required: required.has(entry),
2649
+ fiberState: entry.fiber?.state,
2650
+ outcome
2651
+ })));
2652
+ if (failures.length > 0) warn(activationDiagnostic(binName, failures));
2516
2653
  }
2517
2654
  /**
2518
2655
  * Boot the Loader against `absoluteConfigPath` and return only after the whole
@@ -2539,13 +2676,28 @@ async function auditStartupEntries(ctx, binName, warn = (line) => void process.s
2539
2676
  * complete plugin set.
2540
2677
  * @returns the root context after the initial startup audit, or as soon as a
2541
2678
  * surface disposed the tree while startup was still in flight.
2542
- * @throws a labelled error after disposing the partial context — `host
2679
+ * @throws {@link StartupError} for an inactive required entry, including all inactive plugins in its message;
2680
+ * otherwise a labelled error after disposing the partial context — `host
2543
2681
  * preparation failed` when `prepare` threw before any config-tree entry
2544
2682
  * mounted, `plugin tree failed to load` afterwards. Cyclic causes terminate
2545
2683
  * diagnostic traversal without replacing the original cause.
2546
2684
  */
2547
2685
  async function boot(binName, absoluteConfigPath, patches, prepare, bareModuleBaseUrl) {
2548
2686
  const ctx = new Context();
2687
+ const startupLogs = [];
2688
+ const diagnostics = new Context();
2689
+ diagnostics.logger = ctx.logger;
2690
+ diagnostics.logger.exporter({
2691
+ levels: { default: 2 },
2692
+ export: ({ ts, name, type, args }) => {
2693
+ if (type === "warn" || type === "error") startupLogs.push({
2694
+ ts,
2695
+ name,
2696
+ type,
2697
+ args
2698
+ });
2699
+ }
2700
+ });
2549
2701
  let stage = "host preparation failed";
2550
2702
  try {
2551
2703
  ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + "/";
@@ -2568,6 +2720,13 @@ async function boot(binName, absoluteConfigPath, patches, prepare, bareModuleBas
2568
2720
  return ctx;
2569
2721
  } catch (cause) {
2570
2722
  await ctx.fiber.dispose();
2723
+ if (cause instanceof StartupError) {
2724
+ cause.startup = {
2725
+ configurationPath: absoluteConfigPath,
2726
+ messages: startupLogs
2727
+ };
2728
+ throw cause;
2729
+ }
2571
2730
  const detail = cause instanceof Error ? cause.message : String(cause);
2572
2731
  let deepest = cause;
2573
2732
  const seen = /* @__PURE__ */ new Set();
@@ -2577,6 +2736,8 @@ async function boot(binName, absoluteConfigPath, patches, prepare, bareModuleBas
2577
2736
  }
2578
2737
  const stack = deepest instanceof AggregateError ? `\n${deepest.stack ?? deepest.message}\n${deepest.errors.map(formatActivationError).join("\n")}` : deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : "";
2579
2738
  throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause });
2739
+ } finally {
2740
+ await diagnostics.fiber.dispose();
2580
2741
  }
2581
2742
  }
2582
2743
  /** Prompt-section name for the harness-source location line an app bin adds after boot. */
@@ -2606,4 +2767,4 @@ function addHarnessSourceSection(ctx, sourceRoot) {
2606
2767
  });
2607
2768
  }
2608
2769
  //#endregion
2609
- export { DEFAULT_PROFILE_BUNDLES, DEFAULT_PROFILE_PATCH_RELOAD, FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION, PROFILES_DIR, PROFILE_PATCH_FILENAME, PROFILE_TEMPLATES, PluginPackages, addHarnessSourceSection, auditStartupEntries, boot, composeEntries, createProfileResolutionGeneration, healProfilesModuleFallback, initProfile, installFailLoud, loadEnv, loadLayeredEnv, loadOptionalPatches, loadOverlayPatches, loadProfile, loadProfileDirectory, mountRootInclude, readProfileManifest, renderConfigDump, resolveBundleDir, resolveConfigPath, resolveProfileDir, watchUserPatches, writeProfileManifest };
2770
+ export { DEFAULT_PROFILE_BUNDLES, FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION, OPTIONAL_BUNDLES, PROFILES_DIR, PROFILE_PATCH_FILENAME, PROFILE_TEMPLATES, PluginPackages, StartupError, addHarnessSourceSection, auditStartupEntries, boot, composeEntries, createProfileResolutionGeneration, healIsolatedProfileModuleFallback, healProfilesModuleFallback, initProfile, installFailLoud, loadEnv, loadLayeredEnv, loadOptionalPatches, loadOverlayPatches, loadProfile, loadProfileDirectory, mountRootInclude, readProfileManifest, readProfilePatches, readProfilePlugins, reconcileProfilePatches, reconcileProfilePlugins, renderConfigDump, resolveBundleDir, resolveConfigPath, resolveProfileDir, resolveTelemetryPatch, sanitizeProfile, unlinkProfileModuleFallback, writeProfileBundles, writeProfileManifest };