* feat(cordis): add volatile config references without remounting
* refactor(cordis): use internal prefix for volatile updates
* fix(schemastery): preserve metadata-free serialized schemas
* refactor(config): move volatile updates into an opt-in Loader plugin
* refactor(config): reuse Loader context patch for volatile updates
* refactor(config): let the volatile watcher commit equal candidates
* refactor(config): separate comparison from volatile reconciliation
* refactor(loader): extract pure entry option diff
* refactor(loader): compare volatile config through schema metadata
* refactor(loader): make volatile comparison policy explicit
* refactor: name config reload and volatile HMR events
* refactor(loader): compare volatile config in one schema walk; declare hmr/volatile-update in volatile-hmr
Replace the mask/project pair in vendor/loader/src/config/diff.ts with one recursive schema-guided comparison. Absent objects now compare as their declared schema default at every object node, not only under volatile ancestors; both inputs resolve to the same validated config, so the one changed assertion (undefined vs {} under a non-volatile object schema) becomes unchanged. The isJsExpr special case is dropped: expression keys fall through to raw comparison.
Move the hmr/volatile-update Events declaration from vendored Cordis core to its producer, packages/boot/volatile-hmr, matching how dsh-hmr declares hmr/change and hmr/reload. Vendored cordis events.ts returns to upstream content; the inherited Cordis API catalog and the event matrix are regenerated, and the tutorial example imports the package types.
* fix(cosmokit): compare sparse array slots in deepEqual; extend volatile diff coverage
Array.prototype.every skipped holes, so deepEqual(Array(1), ['new']) was true in one direction. Compare every index with holes read as undefined.
config/diff.ts treats expression objects as opaque values even when a schema field shares the marker name, and looks up field schemas with Object.hasOwn so unknown keys named after Object prototype members compare raw. Tests cover absent-object defaults, sparse arrays in both directions, prototype-named keys, metadata-free serialized schema nodes, and a runtime remount check in volatile-hmr. READMEs and the architecture note describe the object-default rule.
* test(schemastery): build serialized schema fixtures without unknown casts
verify-no-unknown-casts rejects the two as-unknown assertions in scripts/volatile-config.spec.ts. Parse the serialized nodes from JSON text instead, matching scripts/loader-config-diff.spec.ts.
* docs(volatile-hmr): drop the scope-scan tag from hmr/volatile-update
verify-scoped-events rejects @dshScopeScan on an event that carries no scoped keys. Catalogs and the event matrix are regenerated.
* docs: regenerate module graph for the volatile-hmr dependency edge
verify-module-graph reports the merged docs stale: volatile-hmr now lists its app-boot dependency.
* test(volatile-hmr): watch the real YAML through an exact config watch
The Windows coverage lane lost the whole spec: its vitest worker exited without a report once this test started a chokidar directory watch (root: ['.']), the only real directory watch in the test suite. Register the include file through hmr.watchConfig instead, the pattern the HMR package's own real-file tests run on Windows, and emit loader/config-reload from the refresh callback as the root-watch path does. packages/boot/hmr/tests/coordination.spec.ts already covers that root-watch emission with a mocked watcher; volatile-hmr and hmr sources stay at 100% coverage.
* test(volatile-hmr): pass the Include path as a file URL
On Windows the plain absolute path made the include subtree fail to load, so the profile-patch test found no consumer entry. Other Include fixtures in this spec already pass file URLs.
* refactor(loader): commit volatile config in Entry.update and remove volatile-hmr
loader/config-reload was emitted from two places (app-boot profile reconciliation and the HMR root watch) and by hand in tests, because a separate plugin needed a "configuration applied" signal to run its own pass. Every update path already funnels through Entry.update, which is where the Loader decides to skip a remount for a volatile-only change. Entry._commitVolatile now parses the candidate through the fiber's internal/config hook and schema, commits the new values into the running references when the ordinary effective values still match, and emits loader/volatile-update to the owning fiber. A changed ordinary effective value falls back to the ordinary remount instead of leaving the instance stale; an invalid candidate is logged and keeps the running references, with the raw config retained for the next activation. Forced group updates take the same path.
Remove packages/boot/volatile-hmr, its event, the app-boot loader/config-reload declaration and emit, and the HMR emission. Loader runtime and real-file tests move to scripts/loader-volatile-update.spec.ts. Docs, catalogs, the event matrix, module graph and the architecture note follow.
* test: share the volatile schema fixture between the schema and Loader specs
The duplication gate flagged the identical Config fixture in scripts/volatile-config.spec.ts and scripts/loader-volatile-update.spec.ts.
* refactor(loader): reduce config/diff.ts to equalExceptVolatile
Entry.update now computes the strict raw option changes itself and only asks diff.ts whether two raw configs differ at most in volatile fields. The ignoreVolatile flag, the options object and the "config is the sole change" rule leave diff.ts; they were the Loader's decision and now sit next to the commit they gate. The raw-config deepEqual in the volatile branch is gone because the strict change list already answers it.
* refactor(loader): type the volatile comparison against Schemastery's Schema
config/diff.ts narrows the plugin's Standard Schema to Schemastery's own Schema type instead of a hand-written structural interface, which makes vendor/loader a type-only dependent of @deepseek-ai/schemastery. The rescope codemod treats the vendor identifier comparison like schemastery's own source and renames the import through an exact edit.
* fix(loader): address review findings on the volatile commit path
Entry.update only replaces the fiber's pending raw config for a volatile-only change, so a noSave direct update survives a no-op entry update. _commitVolatile logs at debug level when ordinary effective values changed and the ordinary update applies, dispatches loader/volatile-update with a Context receiver like Cordis' own filtered emits, and isolates a throwing listener. Schemastery reports unsupported volatile values as ValidationError with their path and validates a lazy inner once when it is built. The inherited-event catalog gains internal/config and corrected source lines; the Loader spec imports dsh-hmr by package name and covers the new behaviors. Docs record that class-instance ordinary fields always take the remount path and that .default() accepts the raw input type.
description, kind
| description | kind |
|---|---|
| Shared Loader boot support for dsh profiles and the temporary Python SDK runtime: environment layers, patches, diagnostics, and configuration preview. | package-library |
@deepseek-ai/dsh-app-boot
English | 中文
Summary
dsh-app-boot is the shared Loader boot library behind dsh profiles, including the CLI packaged by the Python runtime wheel. It loads environment layers, composes profile bundles and patches, boots every plugin, and returns the running app or identifies the failed plugin and cause. Product applications use the dsh launcher instead of publishing separate bins; direct-config helpers remain only for lower-level embedders and tests. You can preview the effective configuration before booting, configure HMR through profile YAML, and let a terminal-owning app restore its terminal before a fatal exit.
Table of Contents
- Use this package
- Understand the implementation
- Further Exploration
- Model Experience
- Known Limitations and Deferred Work
- Dev Note
Use this package
Starting an app with this package is a small, explicit entry point: you give it a config file and it runs the whole boot. This section covers what you can do and what you get; the helper calls behind each outcome are documented in the folded implementation section.
When to use it
Use it when implementing the shared dsh launcher or embedding its lower-level boot helpers. Product features belong in profile bundles instead of new application bins; code that only adds plugins to an already-running app mounts those plugins directly.
Starting the app
You give your entry point a config file, and the process starts the whole app: it loads your environment layers, applies patches and profiles, boots every plugin, and returns once the app is running. In replay mode it boots the sibling cordis.snapshot.yml instead, so a recorded session reproduces identically. The smallest entry point is two calls:
installFailLoud('dsh')
const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT))
With that entry point, startup keeps every plugin that can activate. An enabled failed plugin produces a labelled warning. A failed required entry makes startup dispose the whole app and exit nonzero; required ids absent from a profile and disabled required entries do not affect startup. The global required list covers shared Agent execution, application endpoints, and Web bootstrap/transport: agent-loop, webserver, modules, connection, headless-runner, acp, and sdk-jsonrpc-server.
Profiles
Import profile and bundle declaration types from @deepseek-ai/dsh-package-manifest. App-boot adapts DshPackageManifest to ProfileManifest with optional package identity because local profiles need no published version. App-boot owns profile loading, JSON validation, and resolved runtime data.
A profile is how one dsh installation ships different app surfaces: web, headless, acp, sdk, and sdk-minimal start distinct compositions from the same launcher. A profile lives at $DSH_HOME/profiles/<name> and combines installable bundles with its own cordis.patch.yml. A bundle's dsh.bundle.patch names one patch file or an ordered list of files; bundlePatchFiles validates the declaration and bundlePatchPaths resolves it to absolute paths; the layer concatenates their patch lists in that order. The YAML composition enables or disables HMR. The shipped web template uses live reload, while the other shipped templates apply patches only at startup. sdk-minimal names only its standalone bundle; the other templates retain base-plus-mode stacks. dsh --profile <name> --from-default-profile <template> creates a custom profile at a new non-shipped name from one shipped template, while dsh plugin initializes a base-backed profile and manages its installed bundles. Bundle resolution, manifest, and patch-loading failures print a diagnostic and skip that bundle without changing its selection. Remaining bundles keep their order; profile and user-patch errors still fail startup. Skipping a bundle does not guarantee that the remaining composition can provide the required services. Application-owned npm projects, such as Electron's reserved Desktop profile, use loadProfileDirectory to load an already initialized directory without exposing it through CLI profile lookup.
Your machine-local preferences also live in the Harness home:
.env— your ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. Variables that decide how the process starts (PATH,DSH_*,XDG_*and similar) are rejected from files: export them instead. The four proxy names (HTTP_PROXY,HTTPS_PROXY,ALL_PROXY,NO_PROXY) are accepted from the Harness-home file only, never from the invoking directory's, which arrives with a clone. For a non-product bin that just wants one directory's.env, a missing file is fine and an unloadable one prints one labelled warning line.cordis.patch.yml— your tweak layer, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): replace one entry's whole config (restating the fields you keep), insert new entries, or interpolate!!jsexpressions at boot. A patch naming an entry that does not exist prints a stderr warning; an empty or comments-only file fails boot — disable the layer with[]instead.
The enabled dsh-hmr plugin watches the profile manifest and both user patch files, re-reads the ordered bundle layers, and applies the reload failure policy. DSH HMR serializes these reloads with Plugin Manager configuration writes; package operations run outside its queue. The launcher does not install HMR or watchers; disabled or absent HMR means changes require restart.
Inserted plugin names may be absolute filesystem paths, file URLs, or package specifiers. Patch loading converts absolute paths and patch-relative ./ or ../ paths to file URLs within insert rows and their nested groups; existing-entry name assertions and replacement config values remain literal.
Before mounting profile rows, the dsh launcher computes one immutable runtime resolution from the installation and ordered bundle dependency graphs. Every profile launcher uses runtime resolution, including plain Node, packaged executables, and the Electron Host. It installs the runtime resolution through Node's ESM and CommonJS resolvers without creating fallback links.
sanitizeProfile(binName, profileDir, bundles) provides filesystem recovery without loading plugins or parsing patches. Desktop uses it for native fatal recovery. Call it only after stopping the profile and excluding concurrent profile writes. It renames the profile’s cordis.patch.yml to a unique .bak-<timestamp> sibling and restores the supplied bundle list, preserving installed packages and other manifest fields. The timestamp is Unix time in milliseconds; collisions append an ordinal (-1, -2, …) without changing it. It returns the backup path, or undefined when no patch exists; missing profiles remain absent. Profile initialization recreates an empty patch on the next launch. The home-level patch is unchanged. Invalid profile JSON fails before mutation; later errors propagate and retain completed changes for retry.
Previewing the effective configuration
Before you boot, you can print the exact configuration the app will mount: the dump shows the composed entry list with !!js expressions verbatim, grouped under comments naming each source file and the patch layers that changed it, as one loadable YAML document. Patches that match no row are reported with their layer label; a missing, unparsable, or invalid config fails the dump.
Reading plugin display metadata
Use readPluginMeta(specifier, parentURL) or ctx.pluginPackages.metaOf(specifier, parentURL) to read installed package display text without importing or activating the plugin. Lookup uses the complete package specifier and the caller's resolution base, respecting Node exports. File paths and file URLs return no metadata without resolving resources. Missing locale fields fall back to the accessible package.json at that address; malformed metadata returns an error diagnostic. Results retain translations for Client-side language selection. The reader also loads package.json.icon as an image data URL, even when locale text is complete; an icon error preserves valid text alongside the diagnostic. See Plugin display metadata for the author format.
Startup and reload failures
Profile reconciliation returns diagnostics for unchanged inactive entries without failing an unrelated mutation. A new inactive entry, a changed configuration or fiber, or a changed diagnostic fails reconciliation; removed fibers must still finish disposal. Explicit enablement targets must activate even when their failure predates the operation. Successful reconciliation returns after lifecycle settlement and diagnostic checks; volatile-only entry changes are committed by Loader during the update.
After the Loader settles, app-boot warns when only optional entries are inactive. If an enabled required entry cannot activate, boot() rejects with StartupError after disposal. An independently owned logger exporter retains warning and error records through asynchronous disposal and is released before boot() settles. Its message groups all failed plugins and pending services, marks required entries, and retains original stacks, nested causes, and aggregate members. The CLI prints that message once and saves full startup diagnostics before exiting with code 1; unrelated exceptions retain their normal stack output. In the table, stopping startup means disposing any mounted plugins and exiting nonzero without reporting readiness; continuing keeps successful plugins running. Later configuration HMR does not repeat the required-startup audit and does not roll back the whole update.
| Failure pattern | Optional entry at startup | Required entry at startup | Later configuration HMR |
|---|---|---|---|
| Root config or required overlay is missing, unreadable, malformed, or contains invalid entries | Stop startup | Stop startup | Malformed or invalid live patches are rejected without changing the running configuration; a valid edit applies |
| Module import fails or module evaluation throws | Warn; continue | Stop startup | Report the error; keep successful siblings; a corrected import can activate |
| Plugin config schema validation fails | Warn; continue | Stop startup | A new entry stays inactive; an existing entry retains its prior instance and config; a valid correction applies |
Config !!js evaluation throws |
Warn; continue | Stop startup | Report the error; keep successful siblings; a valid correction can activate |
disabled: !!js evaluation throws |
Warn; continue | Stop startup | Report the evaluation error rather than treating the entry as disabled; a valid correction can activate |
Synchronous apply() throws |
Warn; continue | Stop startup | Report the error; keep successful siblings; corrected config can activate |
Asynchronous apply() throws |
Warn after settlement; continue | Stop startup after settlement | Report the error after settlement; keep successful siblings; corrected config can activate |
| An injected service is unavailable | Warn; continue while the entry waits for its dependencies | Stop startup | Keep the entry waiting; adding the missing provider can activate it |
| HTTP port binding fails | Warn; continue without that endpoint | Stop startup | Keep the process running without the failed endpoint; corrected config can restore it |
Detached asynchronous work outside the apply() return Promise produces an unhandled rejection |
Fatal: dispose the app and exit nonzero | Fatal: dispose the app and exit nonzero | Fatal: dispose the app and exit nonzero, regardless of entry id |
| Entry is absent or explicitly disabled | Ignore it | Ignore it | Do not activate it; no required-startup audit |
The required list above includes modules and connection; Web startup cannot succeed when either enabled entry fails. Failure of an optional provider can also prevent a required consumer from activating. Schema rejection before an existing entry updates is not a transactional rollback of sibling changes.
The Web process matrix and startup acceptance verify these outcomes through the shipped Web profile; app-boot tests also exercise root Include failures.
If your app owns the terminal, it can hand the terminal back before the process exits, so your shell is never left in raw mode. The handoff is bounded: a stuck cleanup delays the fatal exit but never cancels it.
Telling the agent where the harness lives
When your app boots a model-backed agent, you can tell the agent where the DSH implementation checkout lives: it learns that path and that it must not infer the working directory from it — it should use pwd. The instruction appears once near the top of the system prompt. Apps without a system prompt service skip it; in development, reloading the system prompt drops it until the next boot.
Understand the implementation
Implementation internals — click to expand
This section explains how the outcomes above are realized and points at the code that realizes them; everything here is developer-facing and not needed to use the package.
Design notes
- Profile launch data.
ctx.profileContextcontains only profile locations, startup bundle names, parsed invocation overlays and the telemetry opt-out value.readProfilePatches()composes the supplied startup profile or reads current files at those locations; callers schedule and apply the result. - Process-local module resolution. The launcher installs the runtime resolution as an interception on Node's internal ESM and CommonJS resolvers before profile rows mount. Node still owns exports, conditions, subpaths, module caches, and error codes; routed ESM failures report the original importer instead of the internal lookup anchor.
ctx.pluginPackagesexposes package metadata from the same runtime resolution without recording Entry imports; an installed runtime resolution is authoritative even for a miss, while low-level embedders that install the service without one retain native lookup. - Two Loader builtins.
mountRootIncluderegisterscordis:includeandcordis:groupas Loader builtins: a group row gives oneisolaterealm to a provider and its consumers together, and an agent preset outside this workspace cannot resolve@deepseek-ai/cordis-plugin-groupby name. Both load through the ambient module pipeline rather than the included tree's own specifier resolution. - Consumer-owned strictness. Ordinary Loader groups keep successful siblings. App-boot applies the global required-entry policy after initial settlement; agent presets and dynamic multi-entry compositions own and dispose their separate Loader subtree when they require all-or-nothing setup. App-boot reads failed fibers to report their recorded errors and coalesces duplicate Loader rejection notifications through one process checkpoint.
- One runtime resolution. The installation-first and ordered-bundle breadth-first traversal produces the runtime table. Runtime resolution creates no links; runtime resolution entries occupy their package names at
$DSH_HOME/profiles/node_modules, and every other name sees that directory as an ordinary ancestor. Profile load removes the.dsh-module-fallbackprojections that link-backend releases wrote into a profile; pnpm-installed packages stay. External bare targets selected by packageimportsuse the same package order, while Node retains mapping, conditions, and exact target resolution. A complete successor may add package names atomically, while changing or removing an existing mapping requires restart. - Application-owned profiles. Application-owned profiles use the same runtime resolution. Resolution does not modify their
node_modules; pnpm owns installed packages. - Owned Workers. Worker build banners import
@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrapbefore bundled business code. Each Worker installs the structured-cloned runtime resolution in its own isolate. The bootstrap bundle has no static package imports. Source Worker entries retain their self-contained dependency closure, and third-party Workers receive no injection. - Update completion. App boot observes restart failures through the
internal/updatewaterfall. Live patch reloads wait for the tree's fibers before auditing activation;Fiber.update()andEntry.update()alone do not establish restart success. - One rejection checkpoint.
inactiveEntrieskeeps the exact reasons it folds into the boot diagnostic visible through the next process rejection checkpoint, soinstallFailLoudcoalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. - Two-stage failure labels. Outside startup audit failures,
boot()distinguisheshost preparation failed—preparethrew before any config-tree entry mounted — fromplugin tree failed to load, and appends the deepest plugin error's stack. Plugin diagnostics retain nested causes and aggregate member failures; cyclic causes stop traversal without replacing the original error.
The startup error also retains inactive-entry metadata and raw startup warning/error records without retaining the Loader tree. Its entries and startup fields are non-enumerable: direct access and full diagnostic reports retain them, while ordinary error inspection omits them. Pending-only failures have no cause; failures with recorded errors retain their original values in an AggregateError. Import errors are collected through the logger before the Loader mounts because no failed Fiber exists for those imports. The temporary exporter is removed when boot settles.
Helper behavior
The exports each own one stage of the boot: config resolution and snapshot replay, layered environment loading, fail-loud reporting, activation auditing, patch parsing, root-include mounting, config dump rendering, profile composition, and the harness-source section. Per-export contracts live in the code, not this README — see src/index.ts and src/profile.ts.
Source map
| File | Role |
|---|---|
src/index.ts |
Boot helpers: config resolution, environment loading, fail-loud guard, activation audit, patch parsing, config dump, harness-source section |
src/profile.ts |
Profile discovery, initialization, bundle resolution, runtime resolution construction |
src/profile-plugins.ts |
Installed dependencies, bundle activation policy, and manifest updates |
src/profile-sanitize.ts |
Profile patch backup and recovery bundle activation |
src/profile-resolution/ |
Runtime resolver, package-metadata service, and built Worker bootstrap |
| — | No runtime invariant companion is published; one interception owns each runtime resolution. |
Further Exploration
Read these pages when the package-level contract is not enough. They move from the shared boot mechanics to the composition model and the decision evidence behind it.
- Cordis primer — Loader,
!!jsconfig expressions, and include/group semantics. - dsh app — the
dshbin that consumes these helpers. - dsh-cmdline — the launcher-to-app command-line handoff the bins use.
- Profile bundles — installable patch layers composed into
dsh --profile. - dsh-home-paths — the Harness-home resolver (
resolveDshHome). - Configuration source ownership — why a discovered file may not decide bootstrap behavior.
- Profile plugin bundles — the profile and bundle composition design.
- User-patch HMR tests — ownership of live-patch behavior and native filesystem delivery.
Model Experience
Indirectly, through the loaded plugin tree, which alone contributes model context; the one export that adds model-visible text, addHarnessSourceSection, does so only when a consumer calls it after boot.
KV Cache effect
Boot itself changes no request prefix. addHarnessSourceSection places its source path after first-party reusable instructions, so different checkouts leave those preceding bytes unchanged when tools and configuration match. Provider cache reuse is not guaranteed.
Known Limitations and Deferred Work
These limits describe when this boot library is a poor fit or needs special care. They are current package constraints, not a task backlog.
- Runtime resolution depends on Node internals — supported Node versions require the native builtin-access addon and executable compatibility coverage. Only built Harness-owned Workers receive the runtime resolution bootstrap; third-party Workers and custom
vmlinkers keep native resolution. - Snapshot replay swapping is basename-specific — only a config ending in
cordis.ymlorcordis.yamlmaps to the siblingcordis.snapshot.yml; custom config names require caller-managed selection. - Environment discovery is launch-scoped —
loadLayeredEnvreads only the invocation directory and Harness home once; it does not search parents or follow a workspace selected later.loadEnvremains the one-directory helper for non-product bins. - A user patch replaces the whole matched config — an id-targeted patch does not deep-merge, so a profile override restates the bundle fields it keeps.
Dev Note
Working context for maintainers — click to expand
This Dev Note is working context for maintainers: open design questions and directions that are not decided. It is explicitly non-authoritative — shipped behavior, limits, and accepted rationale live in the sections above, the package code, and the linked Agent Notes.
Open: config dump stability
renderConfigDump output is a loadable YAML document whose # == source comments and !!js-verbatim rendering serve the --dump-config diagnostic. Nothing promises byte stability across package versions; decide whether the dump becomes a serialization contract before anything consumes it programmatically.