5.5 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, then --patch overlays, then flag patches — one applyEntryPatches call, identical for boot, flag derivation, and --dump-config.
The shipped bundles are @deepseek-ai/dsh-base (shared core rows), @deepseek-ai/dsh-web-app (browser Host rows and Web runtime glue), and @deepseek-ai/dsh-headless (a direct one-shot runner over base, without web-app). dsh web is the Web-flag alias for --profile web; dsh run [--profile <name>] "task" owns one-shot execution and defaults to the headless profile; generic dsh --profile <name> boots without a task. Patch overlays use --patch. dsh plugin --profile <name> <args...> is a thin pnpm forwarder that initializes the 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.
The dsh run command decision owns the one-shot grammar; this note owns the profile composition it selects.
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 — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback $DSH_HOME/profiles/node_modules (one symlink per package the installation's app and bundles depend on, healed on every launch).
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-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. The two-anchor resolution plus healed symlink fallback gives the same guarantee ("bundles come from the installation") without ceremony.- 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 the launcher patches, so the composition stays fully dumpable and the manifest stays data-only. The launcher-ownedctx.headlessIohost hook is the one host-provided slot, and it is 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.
Consequences
- New composition surfaces (a TUI, provider packs) ship as ordinary npm packages installable per profile; the repository no longer needs a row for every deployment shape.
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, including the profiles module fallback, so composition drift between test and product fails loudly.
- Backends reject nothing old on disk (pre-release stance):
$DSH_HOME/config.yamlis simply no longer read.