Reject case variants of shipped profile names, detect a concurrent winner by the target's state instead of errno, warn instead of failing when staging cleanup fails after a successful replacement, and cover the restore paths with deterministic tests. Update the profile Agent Note, reference recovery path, and upgrade-guide migration steps.
6.7 KiB
Agent Note: Profile plugin bundles replace the fixed surface overlays
Status: implemented
English | 中文
Problem
The dsh launcher hardcoded its compositions: base.cordis.yml + web.cordis.yml shipped inside apps/cli, three bespoke entry modes (--config, web, -p) each with its own layer stack, and a single global personal overlay ($DSH_HOME/config.yaml). There was no way to install an out-of-tree plugin (a TUI, a provider pack) into a shipped surface without editing the repository, and no place where a third-party package could contribute a default composition.
Decision
Everything becomes a profile: a directory $DSH_HOME/profiles/<name> with a package.json (pnpm-managed out-of-tree plugin dependencies plus the profile manifest dsh.profile with its ordered bundles layer list) and a user cordis.patch.yml. A bundle is an npm package declaring "dsh": { "bundle": { "patch": "./cordis.patch.yml" } }; the two manifest kinds live under distinct dsh.profile / dsh.bundle keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in dsh.profile.bundles order, then the user layer and --patch overlays — one applyEntryPatches call shared by boot and --dump-config. App invocation values later moved from launcher-derived patches to startup services in the app-owned command-line decision.
The default Profile templates use @deepseek-ai/dsh-base as the shared core for web, headless, sdk, and acp, with one mode bundle above it. The standalone sdk-minimal profile instead lists one bundle that owns its complete explicit tree. Generic dsh --profile <name> hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, headless owns its task positional, and the protocol profiles accept no app options. Patch overlays use launcher-owned --patch. A non-shipped target can use --from-default-profile <template> to replace itself with a copy of one default template's bundle list and patch-reload policy before boot or config dump. This creates an independent profile with empty dependencies and an empty user patch: it neither reads a local profile named by the template nor records an inheritance relationship. The launcher assembles the copy in a staging directory beside the target and renames it into place, so repeated rescue runs reset the profile; a failed rename restores the previous directory. dsh plugin --profile <name> <args...> is a thin pnpm forwarder that initializes a base-backed profile and reconciles dsh.profile.bundles with installed bundle declarations; a package without a bundle declaration remains a plain dependency. Headless as a direct core entry point owns the headless composition contract.
Resolution is two-anchored by construction: dsh.profile.bundles names resolve from the dsh installation first, then the profile directory, so in-box bundles always come from the same installation as the running dsh and pnpm never manages them. Bare plugin names in patch rows use the immutable runtime resolution, which applies installation-first and ordered-bundle rules in memory and expands dependencies from real package directories.
Two supporting refactors: the webserver's built-in static dist serving became the single-owner fallback seat (registerFallback/applyIndexTaps), with the SPA server extracted to @deepseek-ai/dsh-host-frontend-static so the web bundle owns its dist as composition, not launcher code; and the personal-overlay machinery of the dsh CLI personal-config decision (loadPersonalPatches, $DSH_HOME/config.yaml) was retargeted to the per-profile and home-level cordis.patch.yml layers (loadOptionalPatches, watchUserPatches taking a filename), superseding that note's entry modes and file location while keeping its Harness-home root, patch semantics, and fail-loud parsing.
Alternatives considered
- Dependency-scan plus partial
patchOrder(the original sketch): scanningdependenciesfor bundles and ordering unlisted ones alphabetically has two sources of truth and an implicit tie-break; one explicit ordereddsh.profile.bundleslist is smaller and fully deterministic. A rawpnpm addinside the profile installs a library without activating any patch — explicit, no spooky scan. link:entries for in-box bundles: pnpm cannot version, install, or update alink:into the installation, it embeds a machine path in a user file, and it breaks when the installation moves. Two-anchor resolution plus runtime fallback keeps bundles installation-owned without persisting those links.- A pre-boot
contextmodule in the bundle manifest for boot-time values (dist path, flag facts): rejected in favor of pure plugins — the glue is ordinary rows and app-owned startup services, so the composition stays fully dumpable and the manifest stays data-only. The launcher-provided host slots (ctx.cmdlineArgs,ctx.appExit, and the environment snapshot) are provided inboot()'spreparehook, before any config-tree entry mounts. - Transitive bundle auto-application: only direct
dsh.profile.bundlesentries contribute layers; a meta-bundle wanting to re-export another bundle's patch must do so explicitly in its own patch file. - Dynamic template inheritance or cloning a local profile: recording a parent would require merge and upgrade rules for bundle membership, dependencies, and user patches, while copying local state would duplicate machine-specific choices. Template-based creation copies only installation-owned defaults once.
Consequences
- New composition surfaces (a TUI, provider packs) ship as ordinary npm packages installable per profile, without a repository row for every deployment shape.
- Users can start an independent custom profile from any shipped application template without copying machine-local profile state.
apps/clishrank to argv parsing, profile machinery consumption, and the pnpm forwarder;AppCLIEntryand the per-surface boot paths are gone.- The keyless web e2e scaffold boots the same bundle layers over the same empty-root shape as production and exercises the same profile package-selection rules, so composition drift between test and product fails loudly.
- Under the pre-release stance, backends carry no compatibility behavior for old on-disk configuration;
$DSH_HOME/config.yamlis ignored.