feat(cli): make profile patch reload policy explicit

Add a patchReload field to built-in profile metadata and carry it through CLI profile resolution into app boot. Live profiles install the existing patch watcher; startup profiles freeze every layer after boot and apply later edits only on the next launch. Missing or invalid metadata fails before the plugin tree starts.

The implementation keeps reload policy with the profile that owns it instead of inferring behavior from an entrypoint. Unit tests cover metadata validation and both lifecycle modes, while the CLI and app-boot references document which built-ins are live versus startup.
This commit is contained in:
Tianyi Cui
2026-08-23 10:59:00 +08:00
parent 3fa19b3b30
commit 2c9da6eb5b
14 changed files with 148 additions and 51 deletions
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/README.md
README.md: 9a8d722b044ed5d8e31e3c27e54f8c9ef0839f82
README.zh.md: c092414e2d15e90133d8ea27f0af5cadbe527b22
README.md: eb44939213f62060b6d0be87d113cddf582849ca
README.zh.md: 60e9cf084038fc52cff4dd27f54e2bdb46c61efd
+1 -1
View File
@@ -29,7 +29,7 @@ dsh --help # the launcher's own help
## Profiles
A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer).
A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list and `patchReload` lifecycle) and a `cordis.patch.yml` (the user's own patch layer). `patchReload: live` watches the profile and home-level patch files; `startup` applies them once.
The tree composes over an empty root:
- each bundle's patch in `dsh.profile.bundles` order
+1 -1
View File
@@ -31,7 +31,7 @@ dsh --help # the launcher's own help
## Profile
profile 目录包含一个 `package.json`,其中记录树外插件依赖,以及 profile manifest(元数据清单)`dsh.profile`其中按顺序排列的 `bundles` 列表;还包含一个 `cordis.patch.yml`,其中保存用户自己的 patch 层。
profile 目录包含一个 `package.json`,其中记录树外插件依赖,以及 profile manifest(元数据清单)`dsh.profile`其中按顺序排列的 `bundles` 列表`patchReload` 生命周期;还包含一个 `cordis.patch.yml`,其中保存用户自己的 patch 层。`patchReload: live` 监视 profile 与 home 级 patch 文件,`startup` 则只应用一次。
配置树以空根为起点,依次叠加以下配置层:
- `dsh.profile.bundles` 中各组合包的 patch
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/reference/README.md
README.md: 2a5740f87c76a184c3100461ea0c86343b734b93
README.zh.md: dd25f413c663fcc836f9b50d53dee26463a939bd
README.md: bc32f2a2f97c15e20686f4368d6bed2622e78b2e
README.zh.md: 39fb2e77242152ac4a591804f4319968fcdc9bb2
+3 -3
View File
@@ -6,17 +6,17 @@ This reference defines the profile, web-alias, plugin-management, and config-dum
## Profile boot
`dsh --profile <name>` boots the profile at `$DSH_HOME/profiles/<name>`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch <path>` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
`dsh --profile <name>` boots the profile at `$DSH_HOME/profiles/<name>`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch <path>` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. `dsh.profile.patchReload` selects `live` patch-file watching or `startup` one-time loading; omission defaults a custom profile to `live`. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent-walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch).
The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app; `headless`: base + headless). Any other missing profile fails loud with a hint to run `dsh plugin --profile <name> add <package>`.
The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app with live patches; `headless`: base + headless with startup-only patches). Any other missing profile fails loud with a hint to run `dsh plugin --profile <name> add <package>`.
### App arguments
The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where any injected app plugin may parse it ([`dsh-cmdline`](../../../packages/boot/cmdline/README.md)). `dsh --profile web --port 8080` therefore reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary.
A composition mounts once. An ordinary plugin injects `cmdlineArgs`, parses this app's arguments, and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the provider's service. A live `cordis.patch.yml` edit re-evaluates expressions against services that are still up, so it cannot reset a served port.
A composition mounts once. An ordinary plugin injects `cmdlineArgs`, parses this app's arguments, and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the provider's service. In a `patchReload: live` profile, a patch-file edit re-evaluates expressions against services that are still up, so it cannot reset a served port.
Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. A first app argument equal to `web` or `plugin` selects that subcommand instead. `ctx.cmdlineArgs.get()` is a shared immutable read: multiple plugins may parse the same snapshot, while a profile with no reader ignores its app arguments.
+3 -3
View File
@@ -6,17 +6,17 @@
## Profile 启动
`dsh --profile <name>` 启动位于 `$DSH_HOME/profiles/<name>` 的 profile。生效配置树以空根节点为起点,依次叠加 profile manifest(元数据清单)的 `dsh.profile.bundles` 列表中指定的各组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(这是各 profile 共享的机器本地偏好,因此优先于逐 profile 配置层),以及按 argv 顺序指定的各个 `--patch <path>` 覆盖层。对同一配置行,后应用的层优先。patch 会替换目标行的整个 `config` 值,而不是深度合并其中的键;patch 也可以插入新行。配置解析、schema 校验、模块解析或插件启动失败时,系统会报告错误并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。
`dsh --profile <name>` 启动位于 `$DSH_HOME/profiles/<name>` 的 profile。生效配置树以空根节点为起点,依次叠加 profile manifest(元数据清单)的 `dsh.profile.bundles` 列表中指定的各组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(这是各 profile 共享的机器本地偏好,因此优先于逐 profile 配置层),以及按 argv 顺序指定的各个 `--patch <path>` 覆盖层。对同一配置行,后应用的层优先。patch 会替换目标行的整个 `config` 值,而不是深度合并其中的键;patch 也可以插入新行。`dsh.profile.patchReload` 可选择 `live` patch 文件监视或 `startup` 单次加载;自定义 profile 省略该值时默认使用 `live`配置解析、schema 校验、模块解析或插件启动失败时,系统会报告错误并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。
组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base``@deepseek-ai/dsh-web-app``@deepseek-ai/dsh-headless`)始终来自当前运行的 `dsh` 所属的安装;树外组合包则来自 profile 中由 pnpm 管理的 `node_modules`。patch 行中的裸插件 `name` 会从 profile 目录开始,按照 Node 的模块解析规则逐级向父目录查找,直至由 dsh 维护的安装后备目录 `$DSH_HOME/profiles/node_modules`。该目录为 dsh 安装中的应用和组合包所依赖的每个包各维护一个符号链接,并在每次启动时修复这些链接。
`web``headless` profile 首次使用时会从随附模板自动初始化(`web`base + web-app`headless`base + headless)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile <name> add <package>`
`web``headless` profile 首次使用时会从随附模板自动初始化(`web`base + web-app,实时应用 patch`headless`base + headless,只在启动时应用 patch)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile <name> add <package>`
### 应用参数
启动器自身的 flag 必须写在最前面,并在遇到第一个无法识别的 token 时结束;从该 token 开始的所有内容都会通过 `ctx.cmdlineArgs` 原样交给已启动的 profile,注入该 profile 的任意应用插件都可以解析这些内容([`dsh-cmdline`](../../../packages/boot/cmdline/README.zh.md))。因此,`dsh --profile web --port 8080` 会将 `--port` 交给 web 应用;`dsh --profile web --help` 只打印该应用的帮助信息,不启动应用;`dsh --help` 没有可供交付参数的 profile,因此会打印启动器自身的帮助信息。`-V`/`--version` 位于应用参数边界之前时,会打印启动器的版本。
每套组合只会挂载一次。普通插件注入 `cmdlineArgs`,解析所属应用的参数,并将解析结果作为服务提供。每个从 flag 取值的配置行都会注入该服务;Loader 会等到服务激活后,再对该行的配置求值(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 的优先级高于配置行中写明的值。要维持这一优先级,配置行必须保留该表达式;如果用户 patch 用字面量替换整个 `config`,也会随之移除运行时读取。帮助参数和被拒绝的参数都会请求退出:参数被拒绝时以非零状态退出,显示帮助时以 0 退出;依赖该提供方服务的配置行不会激活。在线编辑 `cordis.patch.yml` 时,系统会根据仍在运行的服务重新计算表达式,因此不会重置当前正在使用的端口。
每套组合只会挂载一次。普通插件注入 `cmdlineArgs`,解析所属应用的参数,并将解析结果作为服务提供。每个从 flag 取值的配置行都会注入该服务;Loader 会等到服务激活后,再对该行的配置求值(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 的优先级高于配置行中写明的值。要维持这一优先级,配置行必须保留该表达式;如果用户 patch 用字面量替换整个 `config`,也会随之移除运行时读取。帮助参数和被拒绝的参数都会请求退出:参数被拒绝时以非零状态退出,显示帮助时以 0 退出;依赖该提供方服务的配置行不会激活。在 `patchReload: live` profile 中,编辑 patch 文件会根据仍在运行的服务重新计算表达式,因此不会重置当前正在使用的端口。
启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。如果应用的第一个参数恰好等于 `web``plugin`,会选择对应的子命令。`ctx.cmdlineArgs.get()` 是共享的不可变读取:多个插件可以解析同一份快照,没有读取方的 profile 则会忽略自己的应用参数。
+6 -1
View File
@@ -120,7 +120,12 @@ function anchorPathSpec(argument: string, cwd: string): string {
export function runPlugin(profile: string, args: readonly string[]): number {
const dir = resolveProfileDir(profile)
if (!existsSync(join(dir, 'package.json'))) {
initProfile(dir, PROFILE_TEMPLATES[profile] ?? DEFAULT_PROFILE_BUNDLES)
const template = PROFILE_TEMPLATES[profile]
initProfile(
dir,
template?.bundles ?? DEFAULT_PROFILE_BUNDLES,
template?.patchReload,
)
process.stderr.write(`${NAME}: initialized profile ${profile} at ${dir}\n`)
}
const before = readProfileManifest(NAME, dir)
+11 -12
View File
@@ -2,8 +2,8 @@
* Shared profile boot for every `dsh` surface: resolve the profile, stack its
* patch layers (bundle layers in `dsh.profile.bundles` order, the profile's
* own `cordis.patch.yml`, `--patch` overlays, the telemetry switch), mount the
* tree over the profile's empty root config, keep the profile patch layer
* live, and wire fail-loud plus bounded shutdown.
* tree over the profile's empty root config, apply its selected patch-reload
* lifecycle, and wire fail-loud plus bounded shutdown.
*
* App flags are not the launcher's business: the invocation's inner arguments
* are provided to the tree through `ctx.cmdlineArgs`, where any injected app
@@ -258,14 +258,13 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
})
})
app.current = ctx
// A surface can dispose the whole tree while boot or this post-boot watcher
// setup is still in flight — a signal, or a fast one-shot's appExit. Loader
// presence and fiber state own liveness; the initial check skips a tree
// that already exited, and the catch below re-checks for an exit that
// landed mid-setup. Watching is unconditional: a one-shot surface exits
// through its bounded shutdown, which disposes the watchers before the
// loop drains.
if (!signalShutdown.signal.aborted
// A live-reload profile can dispose the whole tree while post-boot watcher
// setup is in flight — a signal or appExit. Loader presence and fiber state
// own liveness; the initial check skips a tree that already exited, and the
// catch below re-checks for an exit that landed mid-setup. Startup-frozen
// profiles apply every user layer above but install no HMR fallback or watcher.
if (composed.profile.patchReload === 'live'
&& !signalShutdown.signal.aborted
&& ctx.fiber.state === FiberState.ACTIVE
&& ctx.get('loader') !== undefined) {
try {
@@ -273,8 +272,8 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
// disables the shared module-reload `hmr` row (its reload lifecycle is
// untested), so when the composition leaves no HMR service, mount a
// watch-only instance with no module roots — cordis.patch.yml edits stay
// live on every long-lived surface. A silent skip would break the
// documented hot-reload contract. HMR injects the timer service, which a
// live for the profiles that select it. A silent skip would break their
// documented reload contract. HMR injects the timer service, which a
// bare custom profile may not mount either.
if (ctx.get('hmr') === undefined) {
if (ctx.get('timer') === undefined) {
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
README.md: 300e291c7d32e48477d58931036ea5d44fb0a0ae
README.zh.md: 6d41b68195d5926739027e73c392b0384b96adef
README.md: 142f5145d34bf461232e7083fdbebf3f242e651f
README.zh.md: 3139edd4e77ea50ec129c36df15ee5aaf8df6d64
+3 -3
View File
@@ -17,7 +17,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md) and [`ds
| `loadOverlayPatches(binName, file)` | Parse a required top-level YAML array containing the same include `PatchOptions` entries described above; relative plugin names in inserted rows resolve beside this file, while a patch `name` used to assert an existing row stays literal; a missing file also throws because the caller named it |
| `mountRootInclude(ctx, absoluteConfigPath, patches?, bareModuleBaseUrl?)` | Register the statically imported `cordis:include` and `cordis:group` builtins, mount the include, and retain the exact root entry used by user patch-layer HMR; an optional module base anchors bare package names to the installed host while relative names stay config-relative |
| `watchUserPatches(ctx, options)` | Register the named patch file with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current user layer) and returns an async disposer |
| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile machinery (see [Profiles](#profiles)) |
| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `DEFAULT_PROFILE_PATCH_RELOAD` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile machinery and patch-file lifecycle (see [Profiles](#profiles)) |
| `boot(binName, absoluteConfigPath, patches?, prepare?, bareModuleBaseUrl?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error; the optional module base has the same resolution semantics as `mountRootInclude` |
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline with the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts, and render YAML with `!!js` expressions verbatim; each run of rows that shares one source file and the same patch layers is preceded by a `# ==` comment naming that file and those layers, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), and read, parse, or field validation failures throw |
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
@@ -35,14 +35,14 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](..
## Profiles
A profile is a directory under `$DSH_HOME/profiles/<name>` (the Harness home resolves through [`resolveDshHome`](../../util/home-paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list — and the user's own `cordis.patch.yml`. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory — one symlink per package the installation's app and bundles depend on — so bare plugin names in any profile resolve through Node's ordinary parent-walk without pnpm managing in-box packages. `PROFILE_TEMPLATES` (`web`, `headless`) auto-initialize on first use; other names fail loud until `initProfile` creates them (the `dsh plugin` path). `loadProfile` normalizes an exact installation-owned bundle tuple to its shipped template while preserving every other manifest field; any extra, missing, or reordered entry makes the list user-owned and leaves it unchanged.
A profile is a directory under `$DSH_HOME/profiles/<name>` (the Harness home resolves through [`resolveDshHome`](../../util/home-paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list and `patchReload: live | startup` — and the user's own `cordis.patch.yml`. `live` watches the profile and home-level patch files after boot; `startup` applies every layer once. A missing value keeps the historical `live` default for custom profiles. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory — one symlink per package the installation's app and bundles depend on — so bare plugin names in any profile resolve through Node's ordinary parent-walk without pnpm managing in-box packages. `PROFILE_TEMPLATES` auto-initializes `web` with live reload and `headless` with startup-only patches; other names fail loud until `initProfile` creates them through `dsh plugin`. `loadProfile` normalizes an exact installation-owned bundle tuple and a missing reload choice to its shipped template while preserving every explicit reload choice and every other manifest field; any extra, missing, or reordered bundle makes the list user-owned and leaves it unchanged.
User-level machine-local preferences also live in the Harness home:
- **`.env`** — the product CLI's ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. `loadLayeredEnv` snapshots each value's source, rejects [bootstrap-only file variables](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision) case-insensitively, and materializes accepted values into `process.env` for Loader expressions and third-party libraries. Managed credentials live separately in [`.credentials.yaml`](../../credentials/credentials-local/README.md); a credential left in either `.env` remains a lower-priority fallback.
- **`cordis.patch.yml`** (home level) and **`profiles/<name>/cordis.patch.yml`** — the user patch layers, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the composed tree is a stderr warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the layer with `[]`.
Every profile boot keeps `cordis.patch.yml` live through `watchUserPatches` (a one-shot surface disposes the watcher through its bounded shutdown). The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlays above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh.
Every `patchReload: live` profile keeps both user patch files live through `watchUserPatches`. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlays above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh. A `startup` profile installs neither these watchers nor the launcher's watch-only HMR fallback.
## Model Experience
+3 -3
View File
@@ -17,7 +17,7 @@
| `loadOverlayPatches(binName, file)` | 解析必需的顶层 YAML 数组,其中包含与上文相同的 include `PatchOptions` 条目;插入行中的相对插件名以该文件所在目录解析,而用于断言已有行的 patch `name` 保持字面值;文件缺失也会抛出异常,因为该文件是调用方指名的 |
| `mountRootInclude(ctx, absoluteConfigPath, patches?, bareModuleBaseUrl?)` | 注册静态导入的 `cordis:include``cordis:group` builtin,挂载 include,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项;可选模块基准会把裸包名锚定到已安装宿主,而相对名称仍以配置目录为基准 |
| `watchUserPatches(ctx, options)` | 向现有 Cordis HMR 服务注册指名的 patch 文件;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前用户层)以事务方式重新组合完整 patch 列表,并返回异步 disposer |
| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile 机制(见 [Profile](#profiles) |
| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `DEFAULT_PROFILE_PATCH_RELOAD` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile 机制与 patch 文件生命周期(见 [Profile](#profiles) |
| `boot(binName, absoluteConfigPath, patches?, prepare?, bareModuleBaseUrl?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject;可选模块基准与 `mountRootInclude` 的解析语义相同 |
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`)离线合成基础配置与带标签的覆盖层,使结果与 `boot()` 挂载的内容一致,再渲染为 YAML,并原样保留 `!!js` 表达式;每段来源于同一文件且由相同补丁层修改的连续行之前都有一条 `# ==` 注释,标明该文件和这些补丁层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取、解析或字段验证失败则抛出 |
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR 重新加载系统提示词后,它会消失直至下次启动 |
@@ -35,14 +35,14 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面
## Profiles
profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和用户自己的 `cordis.patch.yml`。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录(安装目录的应用与各组合包依赖的每个包对应一个符号链接),使任意 profile 中的裸插件名都能经 Node 常规的逐级向上查找解析,而无需由 pnpm 管理随安装内置的包。`PROFILE_TEMPLATES``web``headless`)在首次使用时自动初始化;其他名称在 `initProfile` 创建前都会明确报错(即 `dsh plugin` 路径)`loadProfile`将与安装自有组合包元组完全一致的列表规范化为随发行版交付的模板,同时保留 manifest 中其他所有字段;一旦条目有任何额外、缺失或重排,列表就归用户所有并保持不变。
profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表`patchReload: live | startup`)和用户自己的 `cordis.patch.yml``live` 会在启动后监视 profile 与 home 级 patch 文件;`startup` 只应用每层一次。缺失值为自定义 profile 保留历史 `live` 默认值。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录(安装目录的应用与各组合包依赖的每个包对应一个符号链接),使任意 profile 中的裸插件名都能经 Node 常规的逐级向上查找解析,而无需由 pnpm 管理随安装内置的包。`PROFILE_TEMPLATES` 首次使用时以实时重载初始化 `web`,以仅启动时 patch 初始化 `headless`;其他名称在通过 `dsh plugin` `initProfile` 创建前都会明确报错。`loadProfile`安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。
用户级的机器本地偏好同样位于 harness home 中:
- **`.env`**:产品 CLI 的普通环境层;调用目录的文件优先于 harness home 的文件,两者都低于继承环境。`loadLayeredEnv` 记录每个值的来源,按不区分大小写的方式拒绝 [bootstrap-only 文件变量](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md#decision),并把其余值物化进 `process.env`,供 Loader 表达式和第三方库使用。受管凭据另存于 [`.credentials.yaml`](../../credentials/credentials-local/README.zh.md);留在任一 `.env` 中的凭据仍是低优先级后备值。
- **`cordis.patch.yml`**home 级)与 **`profiles/<name>/cordis.patch.yml`**:用户 patch 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在组合后的树中,则输出一条 stderr 警告。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用该层,请使用 `[]`
profile 启动都由 `watchUserPatches` 持续应用 `cordis.patch.yml` 的变更(一次性 surface 经由有界关闭 dispose 监视器)。即使文件或其直接父目录不存在,监视器仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离观察方失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。
`patchReload: live` profile 都通过 `watchUserPatches` 保持两个用户 patch 文件实时生效。即使文件或其直接父目录不存在,watcher 仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离观察方失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。`startup` profile 不安装这些 watcher,也不安装启动器的仅监视 HMR fallback。
## 模型体验
+3
View File
@@ -31,6 +31,7 @@ declare module '@deepseek-ai/cordis' {
export {
composeEntries,
DEFAULT_PROFILE_BUNDLES,
DEFAULT_PROFILE_PATCH_RELOAD,
healProfilesModuleFallback,
initProfile,
loadProfile,
@@ -47,6 +48,8 @@ export {
type Profile,
type ProfileLayer,
type ProfileManifest,
type ProfilePatchReload,
type ProfileTemplate,
} from './profile.ts'
/**
+58 -13
View File
@@ -48,6 +48,19 @@ export interface DshBundleManifest {
export interface DshProfileManifest {
/** Ordered bundle layer list (package names). */
bundles?: string[]
/** Whether user patch files reload while this profile remains active. */
patchReload?: ProfilePatchReload
}
/** User patch-file lifecycle selected by a profile. */
export type ProfilePatchReload = 'live' | 'startup'
/** Installation-owned defaults used when a shipped profile is first opened. */
export interface ProfileTemplate {
/** Ordered bundle layer list. */
bundles: readonly string[]
/** User patch-file lifecycle for the generated profile. */
patchReload: ProfilePatchReload
}
/**
@@ -93,6 +106,8 @@ export interface Profile {
patchPath: string
/** The profile's own patches; empty when the file is absent. */
patches: PatchOptions[]
/** Whether the launcher watches user patch files after boot. */
patchReload: ProfilePatchReload
}
/**
@@ -111,9 +126,15 @@ export function resolveProfileDir(name: string, home: string = resolveDshHome())
}
/** The shipped profile templates auto-initialized on first use, by name. */
export const PROFILE_TEMPLATES: Record<string, readonly string[]> = {
web: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
headless: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'],
export const PROFILE_TEMPLATES: Record<string, ProfileTemplate> = {
web: {
bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
patchReload: 'live',
},
headless: {
bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'],
patchReload: 'startup',
},
}
/** Installation-owned bundle tuples normalized to the shipped template. */
@@ -124,6 +145,9 @@ const INSTALLATION_OWNED_PROFILE_TUPLES: Record<string, readonly string[]> = {
/** The bundle list a `dsh plugin` init uses for a name with no shipped template. */
export const DEFAULT_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-base']
/** Custom profiles retain the historical live patch-file behavior. */
export const DEFAULT_PROFILE_PATCH_RELOAD: ProfilePatchReload = 'live'
const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied after every bundle layer:
# a top-level YAML array of loader patch entries (id-targeted config
# overrides, disables, and insert lists; \`!!js\` expressions allowed).
@@ -148,8 +172,13 @@ autoInstallPeers: false
* so re-running is a no-op on an initialized profile.
* @param dir - the profile directory from {@link resolveProfileDir}.
* @param bundles - the initial `dsh.profile.bundles` layer list.
* @param patchReload - user patch-file lifecycle; custom profiles default to live reload.
*/
export function initProfile(dir: string, bundles: readonly string[]): void {
export function initProfile(
dir: string,
bundles: readonly string[],
patchReload: ProfilePatchReload = DEFAULT_PROFILE_PATCH_RELOAD,
): void {
mkdirSync(dir, { recursive: true })
const manifestPath = join(dir, 'package.json')
if (!existsSync(manifestPath)) {
@@ -157,7 +186,7 @@ export function initProfile(dir: string, bundles: readonly string[]): void {
name: `dsh-profile-${basename(dir)}`,
private: true,
dependencies: {},
dsh: { profile: { bundles: [...bundles] } },
dsh: { profile: { bundles: [...bundles], patchReload } },
}
writeFileSync(manifestPath, JSON.stringify(manifest, undefined, 2) + '\n')
}
@@ -291,20 +320,29 @@ function sameBundles(left: readonly string[], right: readonly string[]): boolean
}
/**
* Normalize an exact installation-owned bundle tuple to its shipped template
* while preserving every other manifest field. Any other list is user-owned.
* Normalize an exact installation-owned bundle tuple to its shipped template,
* or add the shipped reload default to an exact current tuple. A changed value
* is written back during profile loading while every other manifest field is
* preserved; any other bundle list is user-owned and remains untouched.
*/
function normalizeShippedProfile(name: string, dir: string, manifest: ProfileManifest): ProfileManifest {
const installationOwned = INSTALLATION_OWNED_PROFILE_TUPLES[name]
const current = PROFILE_TEMPLATES[name]
const template = PROFILE_TEMPLATES[name]
const bundles = manifest.dsh?.profile?.bundles
if (installationOwned === undefined || current === undefined || bundles === undefined
|| !sameBundles(bundles, installationOwned)) return manifest
if (template === undefined || bundles === undefined) return manifest
const isRetiredTuple = installationOwned !== undefined && sameBundles(bundles, installationOwned)
const isCurrentTuple = sameBundles(bundles, template.bundles)
const needsReloadDefault = manifest.dsh?.profile?.patchReload === undefined && isCurrentTuple
if (!isRetiredTuple && !needsReloadDefault) return manifest
const normalized: ProfileManifest = {
...manifest,
dsh: {
...manifest.dsh,
profile: { ...manifest.dsh?.profile, bundles: [...current] },
profile: {
...manifest.dsh?.profile,
bundles: [...template.bundles],
patchReload: manifest.dsh?.profile?.patchReload ?? template.patchReload,
},
},
}
writeProfileManifest(dir, normalized)
@@ -380,11 +418,18 @@ export function loadProfile(
`${binName}: profile ${JSON.stringify(name)} does not exist; create it with 'dsh plugin --profile ${name} add <package>'`,
)
}
initProfile(dir, template)
initProfile(dir, template.bundles, template.patchReload)
}
const manifest = normalizeShippedProfile(name, dir, readProfileManifest(binName, dir))
// A hand-written profile manifest may omit the dsh section entirely.
const bundles = manifest.dsh?.profile?.bundles ?? []
const rawPatchReload: unknown = manifest.dsh?.profile?.patchReload
if (rawPatchReload !== undefined && rawPatchReload !== 'live' && rawPatchReload !== 'startup') {
throw new Error(
`${binName}: profile manifest ${join(dir, 'package.json')} dsh.profile.patchReload must be "live" or "startup"`,
)
}
const patchReload = rawPatchReload ?? DEFAULT_PROFILE_PATCH_RELOAD
const layers = bundles.map((packageName): ProfileLayer => {
const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir)
const bundleManifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as ProfileManifest
@@ -399,7 +444,7 @@ export function loadProfile(
const patches = options.userLayer !== false && existsSync(patchPath)
? loadOverlayPatches(binName, patchPath)
: []
return { name, dir, layers, patchPath, patches }
return { name, dir, layers, patchPath, patches, patchReload }
}
/**
+50 -5
View File
@@ -62,12 +62,14 @@ describe('initProfile', () => {
initProfile(dir, ['@deepseek-ai/dsh-base'])
const manifest = readProfileManifest('t', dir)
expect(manifest.dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base'])
expect(manifest.dsh?.profile?.patchReload).toBe('live')
expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('[]')
expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted')
// Re-init keeps user edits.
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n config: {}\n')
initProfile(dir, ['other'])
initProfile(dir, ['other'], 'startup')
expect(readProfileManifest('t', dir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base'])
expect(readProfileManifest('t', dir).dsh?.profile?.patchReload).toBe('live')
expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('- id: x')
})
})
@@ -130,6 +132,7 @@ describe('loadProfile', () => {
const profile = loadProfile('t', 'demo', anchor, home)
expect(profile.layers.map(layer => layer.packageName)).toEqual(['bundle-a', 'bundle-b'])
expect(profile.patches).toHaveLength(1)
expect(profile.patchReload).toBe('live')
const entries = composeEntries([
...profile.layers.map(layer => layer.patches),
profile.patches,
@@ -141,6 +144,7 @@ describe('loadProfile', () => {
writeProfileManifest(dir, { name: 'bare' })
const bare = loadProfile('t', 'demo', anchor, home)
expect(bare.layers).toEqual([])
expect(bare.patchReload).toBe('live')
})
it('auto-initializes only shipped templates and fails loud otherwise', () => {
@@ -151,14 +155,18 @@ describe('loadProfile', () => {
// The web template auto-initializes on first load. Bundle resolution
// cannot be asserted to fail here: the source-plane test runner resolves
// @deepseek-ai/* through tsconfig paths regardless of the staged anchor.
expect(PROFILE_TEMPLATES.web).toContain('@deepseek-ai/dsh-base')
expect(PROFILE_TEMPLATES.web?.bundles).toContain('@deepseek-ai/dsh-base')
expect(PROFILE_TEMPLATES.web?.patchReload).toBe('live')
expect(PROFILE_TEMPLATES.headless?.patchReload).toBe('startup')
try {
loadProfile('t', 'web', anchor, home)
} catch {
// Resolution failure is the plain-Node outcome for this empty anchor.
}
expect(readProfileManifest('t', resolveProfileDir('web', home)).dsh?.profile?.bundles)
.toEqual([...PROFILE_TEMPLATES.web ?? []])
.toEqual([...PROFILE_TEMPLATES.web?.bundles ?? []])
expect(readProfileManifest('t', resolveProfileDir('web', home)).dsh?.profile?.patchReload)
.toBe('live')
})
it('normalizes only the exact installation-owned headless bundle tuple', () => {
@@ -173,9 +181,14 @@ describe('loadProfile', () => {
initProfile(stock, [
'@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless',
])
const retiredManifest = readProfileManifest('t', stock)
delete retiredManifest.dsh!.profile!.patchReload
writeProfileManifest(stock, retiredManifest)
loadProfile('t', 'headless', anchor, home)
expect(readProfileManifest('t', stock).dsh?.profile?.bundles)
.toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'])
expect(readProfileManifest('t', stock).dsh?.profile).toEqual({
bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'],
patchReload: 'startup',
})
const customHome = tmp()
const custom = resolveProfileDir('headless', customHome)
@@ -188,6 +201,38 @@ describe('loadProfile', () => {
])
})
it('adds a shipped reload default only to an exact stock tuple and preserves explicit choices', () => {
const anchor = stageInstallation({
'@deepseek-ai/dsh-base': { patch: '[]\n' },
'@deepseek-ai/dsh-web-app': { patch: '[]\n' },
})
const stockHome = tmp()
const stock = resolveProfileDir('web', stockHome)
initProfile(stock, PROFILE_TEMPLATES.web?.bundles ?? [])
const stockManifest = readProfileManifest('t', stock)
delete stockManifest.dsh!.profile!.patchReload
writeProfileManifest(stock, stockManifest)
expect(loadProfile('t', 'web', anchor, stockHome).patchReload).toBe('live')
expect(readProfileManifest('t', stock).dsh?.profile?.patchReload).toBe('live')
const explicitHome = tmp()
const explicit = resolveProfileDir('web', explicitHome)
initProfile(explicit, PROFILE_TEMPLATES.web?.bundles ?? [], 'startup')
expect(loadProfile('t', 'web', anchor, explicitHome).patchReload).toBe('startup')
})
it('fails loud on an unknown patch reload value from disk', () => {
const anchor = stageInstallation({})
const home = tmp()
const dir = resolveProfileDir('demo', home)
initProfile(dir, [])
const manifest = readProfileManifest('t', dir)
const rawProfile = manifest.dsh!.profile as { patchReload?: string }
rawProfile.patchReload = 'sometimes'
writeProfileManifest(dir, manifest)
expect(() => loadProfile('t', 'demo', anchor, home)).toThrow('patchReload must be "live" or "startup"')
})
it('fails loud when a listed bundle declares no dsh.bundle', () => {
const anchor = stageInstallation({ 'not-a-bundle': {} })
const home = tmp()