feat(cli)!: complete --config on every surface and delete the personal overlay

$DSH_HOME/config.yaml was an implicit composition layer: if the file existed,
every launch applied an arbitrary Loader patch graph over the shipped tree,
kept live by a dedicated HMR watcher. Three costs came from the implicitness,
not the capability. A patch replaces its target row's whole config, so a file
written months ago pins that row to the field set it knew and every default
the shipped tree later adds silently stops applying. It competed with the
typed settings namespaces llm-deepseek and llm-pi-ai already register, so
which one wins was a function of layer order rather than meaning. And the
explicit escape hatch it was supposedly redundant with did not exist on every
surface: dsh -p, dsh meta, and dsh upgrade all rejected --config, so for them
the implicit file was the only composition route at all.

Complete the explicit layer first: --config and --config-replace now work on
every booting surface. A headless --config-replace tree must still mount a
webserver row, because that surface reaches its own agent over the same HTTP
gateway the browser uses; AppCLIEntry names that contract in the failure
instead of reporting a bare missing service.

Then delete the implicit one. PERSONAL_CONFIG_FILENAME, loadPersonalPatches,
watchPersonalPatches, and the config-only HMR row mounted for it are gone; a
file left at that path is inert, and --dump-config no longer reads the Harness
home. --config therefore stops *replacing* the personal overlay and simply
*is* the user overlay.

No migration: a user who wants the old behavior names the same file
(dsh --config ~/.dsh/config.yaml), which a shell alias makes permanent.
This commit is contained in:
Yichen Jiang
2026-08-04 15:25:04 +08:00
parent 03b534de16
commit 8ddc53f7a0
39 changed files with 416 additions and 650 deletions

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: 76d9ed65398322cb9244a31661ee59b60c23f793
README.zh.md: 16a7a4ec52b830e45c32a61a103d87be5941ab3b
README.md: 3195fb4856ec794186658afd5e329cd58e6a3b28
README.zh.md: 011cacb347aff88f6a04544dcd9b5e9b8d434c18

View File

@@ -7,11 +7,11 @@ Argv is parsed once through a [Commander](https://github.com/tj/commander.js) ad
The TUI surface:
- boots `base.cordis.yml` plus `tui.cordis.yml` through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); `--config <path>` applies a patch-list overlay instead of the personal overlay, while `--config-replace <path>` boots that file as the complete tree;
- boots `base.cordis.yml` plus `tui.cordis.yml` through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); `--config <path>` applies a patch-list overlay over that tree, while `--config-replace <path>` boots the named file as the complete tree; every booting surface takes both flags;
- resumes a persisted session with `dsh --resume <session-id>` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume invocation; runtimes without process replacement leave the session running and say so. This CLI owns session identity and the exit line rather than the config: it mints or selects the `main` session id and provides it, plus the exact command that reproduces this invocation, on the boot context ([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) and `TUI_GOODBYE_MESSAGE_KEY`). No `cordis.yml` key can drop resume, and a missing or unreadable id fails loud instead of creating a fresh session;
- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh meta` is the sole exception, below);
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`. The shipped tree's Cordis HMR keeps `config.yaml` live; an explicit `--config` tree replaces that overlay, and a tree without HMR reads it at startup only.
- reads the Harness home (`~/.dsh`) for user state only (see [app-boot's Harness home](../../packages/ui/app-boot/README.md#the-harness-home)): `.env` is the user environment layer and `.credentials.yaml` is the credential provider's own store, never hoisted into the environment, so keys stay rotatable. Environment precedence is ambient > project `.env` > user `.env`. No composition file is discovered there: an overlay reaches a launch only through `--config`.
- presents the [versioned first-run welcome](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md) through the mounted TUI overlay service when its immutable marker is absent under `DSH_HOME`; only Enter creates that version's marker, while Escape, disposal, or process exit leaves it eligible. The official DeepSeek icon, responsive terminal rasters, all-locale Chinese copy, and notice version are static local owners; the overlay never writes a session event or model context.
- registers bare `/compact`: while the agent is idle, it summarizes useful older history even below automatic pressure, rejects arguments, and reports success only after the standalone replacement bracket is durable. A prompt submitted during compaction keeps its queue identity and starts after that checkpoint; injected context remains visible.
@@ -19,13 +19,13 @@ The TUI surface:
`dsh upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:<name>`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. The command takes no options beyond the experimental gate — `--config`, `-p`, and `--resume` fail loud — and seeds only on this first launch, so a later `dsh --resume <id>` of the session is an ordinary TUI session with no re-injection.
`dsh --dump-config` and `dsh web --dump-config` print the composed config tree — the shipped base, the surface overlay, and the `--config` or personal overlay, exactly the layers that surface would boot — as YAML on stdout and exit without booting; `--dump-default-config` stops at the surface overlay, so diffing the two shows precisely what the user layer changes. Each run of rows is preceded by a `# ==` comment naming the file it comes from and the layers that patched it (e.g. `# == base.cordis.yml, patched by tui.cordis.yml`), so the output shows provenance while staying one loadable document. Composition runs through the include's own patch algorithm and YAML dialect (`applyEntryPatches`/`entryListSchema` from `@cordisjs/plugin-include`), so the dump cannot drift from what boots; `!!js` expressions print verbatim and unevaluated, and a patch whose target row is absent is reported on stderr with its layer, mirroring the Loader's boot-time warning. Launcher-owned boot-context values (session identity, CLI-flag patches) are per-invocation facts outside the config tree and do not appear. The dump flags reject boot-only flags (`-p`, `--resume`, `--config-replace`) rather than silently ignoring them, and `--dump-default-config` takes no `--config`.
`dsh --dump-config` and `dsh web --dump-config` print the composed config tree — the shipped base, the surface overlay, and any `--config` overlay, exactly the layers that surface would boot — as YAML on stdout and exit without booting; `--dump-default-config` stops at the surface overlay, so diffing the two shows precisely what the user layer changes. Each run of rows is preceded by a `# ==` comment naming the file it comes from and the layers that patched it (e.g. `# == base.cordis.yml, patched by tui.cordis.yml`), so the output shows provenance while staying one loadable document. Composition runs through the include's own patch algorithm and YAML dialect (`applyEntryPatches`/`entryListSchema` from `@cordisjs/plugin-include`), so the dump cannot drift from what boots; `!!js` expressions print verbatim and unevaluated, and a patch whose target row is absent is reported on stderr with its layer, mirroring the Loader's boot-time warning. Launcher-owned boot-context values (session identity, CLI-flag patches) are per-invocation facts outside the config tree and do not appear. The dump flags reject boot-only flags (`-p`, `--resume`, `--config-replace`) rather than silently ignoring them, and `--dump-default-config` takes no `--config`.
The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config <path>` replaces that personal overlay. Both surfaces otherwise share the same composition: both tell the coding agent its resolved model and session working directory, treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, use the same bounded transient model-request retry policy as the TUI, and mount a disposable in-memory SQLite content-index service. Web additionally names the DeepSeek Harness Web GUI as the interaction surface, this checkout as its own source location, and the process's canonical local URL and mode in both the prompt and managed `$DSH_WEB_URL`/`$DSH_WEB_MODE`; references such as “this page” therefore identify the GUI without claiming access to implicit DOM, route, or screenshot state. In production mode the host reads rebuilt frontend dist and client bundles on the next request, so refreshing the existing URL updates that GUI without replacing its process. `dsh web --dev` mounts the client-plugin HMR receiver, but no-refresh updates additionally require `pnpm run dev:web` in the same checkout to watch and rebuild plugin bundles; shell and ordinary package changes still require a rebuild and page refresh. Bare `apps/web` Vite serving fails before listening because it cannot inject `window.__DSH_BOOT__`. The index service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then any `--config <path>` overlay. Both surfaces otherwise share the same composition: both tell the coding agent its resolved model and session working directory, treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, use the same bounded transient model-request retry policy as the TUI, and mount a disposable in-memory SQLite content-index service. Web additionally names the DeepSeek Harness Web GUI as the interaction surface, this checkout as its own source location, and the process's canonical local URL and mode in both the prompt and managed `$DSH_WEB_URL`/`$DSH_WEB_MODE`; references such as “this page” therefore identify the GUI without claiming access to implicit DOM, route, or screenshot state. In production mode the host reads rebuilt frontend dist and client bundles on the next request, so refreshing the existing URL updates that GUI without replacing its process. `dsh web --dev` mounts the client-plugin HMR receiver, but no-refresh updates additionally require `pnpm run dev:web` in the same checkout to watch and rebuild plugin bundles; shell and ordinary package changes still require a rebuild and page refresh. Bare `apps/web` Vite serving fails before listening because it cannot inject `window.__DSH_BOOT__`. The index service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
The shared composition defaults new TUI, Web, and headless sessions to the `workspace-write` permission preset (`workspace-write` file mode plus `ask` approval policy). Sandbox-enforced bash and filesystem mutations may write only under the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. The browser answers one-shot approval requests and exposes the Access picker; the TUI exposes `/permission`, but has no approval-request answerer, so an automatic wider retry there fails closed until the user deliberately changes the session preset. `DSH_PERMISSION_MODE` changes the process fallback, while a stored General-settings Permission value applies to later sessions without changing an open one.
All three surfaces consume `$DSH_HOME/config.yaml`; the TUI and Web apply valid edits live, while one-shot headless runs read it at startup. The shipped trees include an empty `repository-plugins` row, so a standalone user can add prepared GitHub Plugins without an SDK project or install command:
Every surface reads its `--config` overlay once at startup. The shipped trees include an empty `repository-plugins` row, so a standalone user can add prepared GitHub Plugins without an SDK project or install command, by naming an overlay such as `dsh --config ~/.dsh/plugins.yml`:
```yaml
- id: repository-plugins
@@ -53,7 +53,7 @@ pnpm run dsh web --config apps/cli/config/core-web.cordis.yml
Every `dsh` surface — TUI, Web, and headless — reports session telemetry by default (the row lives in the shared `base.cordis.yml`): every session-log event streams as OTLP/HTTP log records to `https://harness-telemetry.deepseeksvc.com/v1/logs` on a 10-second batch cadence. `DSH_TELEMETRY_OTLP_URL` points the exporter at a different collector; setting `DSH_TELEMETRY_DISABLED` to ANY non-empty value — including `0` or `false` — disables the row before it loads (a privacy switch prefers off-by-mistake over on-by-mistake). No redaction rule is mounted in this composition yet: exported records are the raw captured copy, including message text, tool arguments and results, and the session's working-directory path. The deployment rulings live in the [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md).
MCP servers are not a shipped default, because a default would have to name one: `@deepseek-ai/dsh-mcp-client` mounts exactly one server per row and spawns it as a child process, outside `ctx.bash` and so outside the sandbox policy. The package is a runtime dependency of this CLI, so an installed `dsh` can mount your own servers from `$DSH_HOME/config.yaml` or a `--config` overlay without a source checkout:
MCP servers are not a shipped default, because a default would have to name one: `@deepseek-ai/dsh-mcp-client` mounts exactly one server per row and spawns it as a child process, outside `ctx.bash` and so outside the sandbox policy. The package is a runtime dependency of this CLI, so an installed `dsh` can mount your own servers from a `--config` overlay without a source checkout:
```yaml
- insert:

View File

@@ -7,11 +7,11 @@ Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`
TUI 界面:
- 通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 启动 `base.cordis.yml``tui.cordis.yml``--config <path>` 应用一个补丁列表覆盖并替代个人覆盖,而 `--config-replace <path>` 将指定文件作为完整配置树启动;
- 通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 启动 `base.cordis.yml``tui.cordis.yml``--config <path>` 在该树之上应用一个补丁列表覆盖,而 `--config-replace <path>` 将指定文件作为完整配置树启动;每个会启动的界面都接受这两个标志;
- 使用 `dsh --resume <session-id>` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的恢复调用替换进程;不支持进程替换的运行时会让会话继续运行并给出提示。会话身份与退出行由本 CLI 拥有,而非由配置指定:它创建或选定 `main` 会话 id并把该 id 以及可复现本次调用的确切命令一起提供到启动上下文([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) 与 `TUI_GOODBYE_MESSAGE_KEY`)。任何 `cordis.yml` 键都无法移除恢复能力;缺失或无法读取的 id 会明确报错,而不会创建新会话;
-**调用目录** 视为 workspace会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文);
- 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它;
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储绝不会被提升进环境,因此密钥始终可轮换。环境优先级为环境中已有的值 > 项目 `.env`。已交付配置树中的 Cordis HMR 会持续应用 `config.yaml` 的变更;显式 `--config` 配置树会替代该个人覆盖,未包含 HMR 的配置树只在启动时读取该文件
- 只把 Harness home`~/.dsh`)当作用户状态来读取(参见 [app-boot 的 Harness home](../../packages/ui/app-boot/README.md#the-harness-home)`.env` 是用户环境层,`.credentials.yaml` 是凭据 provider 自己的存储绝不会被提升进环境,因此密钥始终可轮换。环境优先级为环境中已有的值 > 项目 `.env` > 用户 `.env`。那里不会发现任何 composition 文件overlay 只能通过 `--config` 抵达一次启动
-`DSH_HOME` 下不存在不可变确认标记时,通过已挂载的 TUI overlay 服务呈现[版本化首次运行欢迎页](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md);只有 Enter 会创建该版本的标记Escape、资源释放或进程退出仍保留展示资格。官方 DeepSeek 图标、响应式终端栅格图、所有 locale 共用的中文文案和通知版本均由静态本地文件持有overlay 不会写入会话事件或模型上下文。
- 注册裸 `/compact`agent 空闲时即使未达到自动压力也会摘要有效的较早历史该命令拒绝参数并只在独立替换标记对持久化后报告成功。压缩compaction期间提交的提示词保留其队列身份并在该检查点之后启动注入的上下文仍保持可见。
@@ -19,13 +19,13 @@ TUI 界面:
`dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:<name>`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)TUI 在聊天就绪后自动调用它。该命令除实验性门槛外不接受任何选项——`--config``-p``--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume <id>` 恢复该会话时是普通 TUI 会话,不会重复注入。
`dsh --dump-config``dsh web --dump-config` 把合成后的配置树——已交付的基础配置、界面覆盖层,以及 `--config` 或个人覆盖层,恰好是该界面启动时组装的那些层——以 YAML 打印到 stdout 后退出,不启动任何东西;`--dump-default-config` 止步于界面覆盖层,因此对两份输出做 diff 就能精确看出用户层改了什么。每段连续的行之前都有一条 `# ==` 注释,标明该段来自哪个文件以及被哪些层修补过(例如 `# == base.cordis.yml, patched by tui.cordis.yml`),因此输出既展示来源,又仍是一份可加载的文档。合成通过 include 自己的补丁算法和 YAML 方言(`@cordisjs/plugin-include``applyEntryPatches`/`entryListSchema`)完成,因此 dump 不可能与实际启动漂移;`!!js` 表达式原样打印、不求值,目标行不存在的补丁会连同其所在层报到 stderr与 Loader 启动时的警告一致。由启动器持有的启动上下文值会话身份、CLI 标志补丁是每次调用的事实位于配置树之外不会出现。dump 标志会拒绝仅用于启动的标志(`-p``--resume``--config-replace`)而不是静默忽略它们,`--dump-default-config` 不接受 `--config`
`dsh --dump-config``dsh web --dump-config` 把合成后的配置树——已交付的基础配置、界面覆盖层,以及任何 `--config` 覆盖层,恰好是该界面启动时组装的那些层——以 YAML 打印到 stdout 后退出,不启动任何东西;`--dump-default-config` 止步于界面覆盖层,因此对两份输出做 diff 就能精确看出用户层改了什么。每段连续的行之前都有一条 `# ==` 注释,标明该段来自哪个文件以及被哪些层修补过(例如 `# == base.cordis.yml, patched by tui.cordis.yml`),因此输出既展示来源,又仍是一份可加载的文档。合成通过 include 自己的补丁算法和 YAML 方言(`@cordisjs/plugin-include``applyEntryPatches`/`entryListSchema`)完成,因此 dump 不可能与实际启动漂移;`!!js` 表达式原样打印、不求值,目标行不存在的补丁会连同其所在层报到 stderr与 Loader 启动时的警告一致。由启动器持有的启动上下文值会话身份、CLI 标志补丁是每次调用的事实位于配置树之外不会出现。dump 标志会拒绝仅用于启动的标志(`-p``--resume``--config-replace`)而不是静默忽略它们,`--dump-default-config` 不接受 `--config`
Web 和无头界面启动 `base.cordis.yml``web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config <path>` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都会告知编码 agent 所用模型和会话工作目录,将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,选用首条消息模型标题,采用与 TUI 相同的有界暂时性模型请求重试策略,并挂载一个可丢弃的内存 SQLite 内容索引服务。Web 还会明确说明交互界面是 DeepSeek Harness Web GUI、当前 checkout 是自身源码位置,并在提示词及受管的 `$DSH_WEB_URL`/`$DSH_WEB_MODE` 中提供该进程的规范本地 URL 和模式;因此,「这个页面」等表述会指向该 GUI但 agent 不会声称可以访问未显式提供的 DOM、路由或截图状态。在生产模式下宿主会在下次请求时读取重新构建的前端 dist 和客户端 bundle因此刷新现有 URL 即可更新该 GUI无须替换其进程。`dsh web --dev` 会挂载客户端插件的 HMR热模块替换接收端但要实现无刷新更新还需在同一 checkout 中运行 `pnpm run dev:web`,以监视并重新构建插件 bundleshell 和普通包package的更改仍需重新构建并刷新页面。直接使用裸 `apps/web` Vite 服务会在开始监听前失败,因为它无法注入 `window.__DSH_BOOT__`。索引服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle`pnpm run build && pnpm run build:web`)。
Web 和无头界面启动 `base.cordis.yml``web.cordis.yml`,随后应用任何 `--config <path>` 覆盖。除此之外,两者共享同一套组合:两者都会告知编码 agent 所用模型和会话工作目录,将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,选用首条消息模型标题,采用与 TUI 相同的有界暂时性模型请求重试策略,并挂载一个可丢弃的内存 SQLite 内容索引服务。Web 还会明确说明交互界面是 DeepSeek Harness Web GUI、当前 checkout 是自身源码位置,并在提示词及受管的 `$DSH_WEB_URL`/`$DSH_WEB_MODE` 中提供该进程的规范本地 URL 和模式;因此,「这个页面」等表述会指向该 GUI但 agent 不会声称可以访问未显式提供的 DOM、路由或截图状态。在生产模式下宿主会在下次请求时读取重新构建的前端 dist 和客户端 bundle因此刷新现有 URL 即可更新该 GUI无须替换其进程。`dsh web --dev` 会挂载客户端插件的 HMR热模块替换接收端但要实现无刷新更新还需在同一 checkout 中运行 `pnpm run dev:web`,以监视并重新构建插件 bundleshell 和普通包package的更改仍需重新构建并刷新页面。直接使用裸 `apps/web` Vite 服务会在开始监听前失败,因为它无法注入 `window.__DSH_BOOT__`。索引服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle`pnpm run build && pnpm run build:web`)。
共享组合把新建 TUI、Web 和无头会话的权限默认设为 `workspace-write` preset`workspace-write` 文件模式加 `ask` 审批策略)。由沙箱强制约束的 bash 与文件系统修改只能写入会话工作区和平台临时根目录;读取、网络访问和进程可见性不受该策略约束。浏览器可以应答一次性审批请求,并提供 Access 选择器TUI 提供 `/permission`,但没有审批请求应答者,因此自动请求更宽权限的重试会以拒绝方式关闭,直到用户主动更改会话 preset。`DSH_PERMISSION_MODE` 会更改进程回退值,而「通用」设置中已存储的「权限」值只适用于之后的会话,不会更改已打开的会话。
个界面都会使用 `$DSH_HOME/config.yaml`TUI 和 Web 实时应用有效编辑,而一次性无头运行只在启动时读取。已交付的配置树包含一个空的 `repository-plugins` 配置项,因此独立用户无需 SDK 项目或安装命令,只需配置即可添加已准备的 GitHub 插件:
个界面都只在启动时读取自己的 `--config` 覆盖。已交付的配置树包含一个空的 `repository-plugins` 配置项,因此独立用户无需 SDK 项目或安装命令,只要点名一个覆盖文件(例如 `dsh --config ~/.dsh/plugins.yml`即可添加已准备的 GitHub 插件:
```yaml
- id: repository-plugins
@@ -53,7 +53,7 @@ pnpm run dsh web --config apps/cli/config/core-web.cordis.yml
每个 `dsh` 界面——TUI、Web 与无头——都默认上报会话遥测(该行位于共享的 `base.cordis.yml`):每条会话日志事件以 OTLP/HTTP 日志记录的形式、按 10 秒批处理节奏流向 `https://harness-telemetry.deepseeksvc.com/v1/logs``DSH_TELEMETRY_OTLP_URL` 可将 exporter 指向其他 collector`DSH_TELEMETRY_DISABLED` 设为**任意非空值**——包括 `0``false`——都会在该行加载前将其关停(隐私开关取「宁可误关、不可误开」)。该组合当前未挂载任何脱敏规则:导出记录即原始捕获副本,包含消息正文、工具参数与结果、以及会话工作目录路径。部署口径见 [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)。
MCP 服务器不是交付默认值,因为默认值必须点名一台:`@deepseek-ai/dsh-mcp-client` 每一行只挂载一台服务器,并把它作为子进程 spawn,该进程不经 `ctx.bash`,因此也不受沙箱策略约束。该包是本 CLI 的运行时依赖,所以已安装的 `dsh` 无需源码检出即可从 `$DSH_HOME/config.yaml``--config` 覆盖层挂载你自己的服务器:
MCP 服务器不是交付默认值,因为默认值必须点名一台:`@deepseek-ai/dsh-mcp-client` 每一行只挂载一台服务器,并把它作为子进程 spawn,该进程不经 `ctx.bash`,因此也不受沙箱策略约束。该包是本 CLI 的运行时依赖,所以已安装的 `dsh` 无需源码检出即可从 `--config` 覆盖层挂载你自己的服务器:
```yaml
- insert:

View File

@@ -1,7 +1,7 @@
# The shared `dsh` core: every row both the TUI (`tui.cordis.yml`) and the web
# surface (`web.cordis.yml`) mount identically. Neither surface includes the
# other — each is a patch list applied over THIS file at one include level, so a
# surface overlay, a `--config` overlay, and the personal `~/.dsh/config.yaml`
# surface overlay and an explicit `--config` overlay
# all address these rows by id. Patch lists stack in that order, last write
# winning per row.
#
@@ -22,7 +22,7 @@
config:
root: ['.']
# `$DSH_HOME/config.yaml` replaces this row's config to select exact GitHub
# A `--config` overlay replaces this row's config to select exact GitHub
# repository Plugin generations. The app registers the DSH-owned runtime even
# when the list is empty so a later personal-config edit can load
# transactionally; one-shot headless runs consume the startup value only.

View File

@@ -16,13 +16,7 @@ import { resolve } from 'node:path'
import { Context } from 'cordis'
import type { PatchOptions } from '@cordisjs/plugin-include'
import yaml from 'js-yaml'
import {
boot,
installFailLoud,
loadOverlayPatches,
loadPersonalPatches,
watchPersonalPatches,
} from '@deepseek-ai/dsh-app-boot'
import { boot, installFailLoud, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
// Empty type import carries the httpServer Context merge for the port read below.
import type {} from '@deepseek-ai/dsh-host-webserver'
@@ -117,16 +111,18 @@ export interface AppCLIEntryOptions {
* fields on the same row.
*/
overlayPath: string
/**
* Optional explicit overlay applied after {@link overlayPath} and before
* this entry's own flag patches. When absent, the personal
* `$DSH_HOME/config.yaml` overlay is applied instead.
*/
/** Optional `--config` overlay applied after {@link overlayPath} and before this entry's own flag patches. */
extraOverlayPath?: string
/**
* Optional `--config-replace` tree: booted INSTEAD of {@link configPath},
* {@link overlayPath}, {@link extraOverlayPath}, and every generated patch,
* so the caller's file is the whole composition. It must still supply the
* serving rows this entry needs — {@link run} rejects a settled tree with no
* `httpServer`.
*/
configReplacePath?: string
/** Whether to append client-bundle HMR (the Web surface's prod/dev difference). */
dev: boolean
/** Whether `$DSH_HOME/config.yaml` remains live after the initial boot. */
watchPersonalConfig: boolean
/** --host when explicitly passed; undefined keeps the yml engineering default. */
host?: string
/**
@@ -176,8 +172,15 @@ export class AppCLIEntry {
await this.bootTree()
this.assertBoot()
const port = this.ctx.get('httpServer')?.port
/* v8 ignore next -- the sweep above guarantees an ACTIVE webserver row */
if (port === undefined) throw new Error('dsh: httpServer service missing after settled boot')
if (port === undefined) {
// The shipped tree always carries the webserver row, so this is only
// reachable through --config-replace: name the missing contract rather
// than report a bare missing service.
throw new Error(
`dsh: no httpServer after booting ${this.bootConfigPath()}; this surface serves over HTTP, so a`
+ ' --config-replace tree must mount a webserver row',
)
}
return { ctx: this.ctx, port }
}
@@ -188,6 +191,16 @@ export class AppCLIEntry {
*/
private composePatches(): void {
const rows = this.parseYmlRows()
if (this.options.configReplacePath !== undefined) {
// A replacement tree is the caller's whole composition: the generated
// patches target shipped row ids this file cannot assume exist, and a
// patch whose id is absent is a silent no-op rather than a diagnostic.
// Telemetry stays, judged against the tree actually booting, because a
// privacy switch that silently no-ops is worse than a loud one.
const replaceTelemetry = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
this.patches = replaceTelemetry === undefined ? [] : [replaceTelemetry]
return
}
const overrides = new Map<string, Record<string, unknown>>()
const put = (entryId: string, key: string, value: unknown): void => {
const bag = overrides.get(entryId) ?? {}
@@ -230,31 +243,26 @@ export class AppCLIEntry {
// One include of the shared base with every overlay as a sibling patch
// list: patches never cross an include boundary, so nesting them would
// silently stop reaching base rows. The surface overlay applies first, then
// this entry's CLI-flag patches, which therefore win.
const compose = (overlay: PatchOptions[]): PatchOptions[] => [
...loadOverlayPatches('dsh', this.options.overlayPath),
...overlay,
...this.patches,
]
// An explicit --config overlay REPLACES the personal overlay, so there is
// then no personal layer to keep live — the watcher is personal-only.
const watchPersonal = this.options.watchPersonalConfig && this.options.extraOverlayPath === undefined
const patches = compose(
this.options.extraOverlayPath === undefined
? loadPersonalPatches('dsh') ?? []
: loadOverlayPatches('dsh', this.options.extraOverlayPath),
)
this.ctx = await boot('dsh', resolve(this.options.configPath), patches, async (ctx) => {
// any --config overlay, then this entry's CLI-flag patches, which win.
// --config-replace discards all three and boots the named file alone.
const patches = this.options.configReplacePath !== undefined
? this.patches
: [
...loadOverlayPatches('dsh', this.options.overlayPath),
...this.options.extraOverlayPath === undefined
? []
: loadOverlayPatches('dsh', this.options.extraOverlayPath),
...this.patches,
]
this.ctx = await boot('dsh', resolve(this.bootConfigPath()), patches, async (ctx) => {
await this.options.prepare?.(ctx)
// Config-only HMR for the personal overlay: module reload stays off for
// this surface (web.cordis.yml disables the shared `hmr` row until its
// reload lifecycle is tested), so this row watches no module roots.
if (watchPersonal) await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } })
if (this.options.dev) await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' })
})
if (watchPersonal) {
await watchPersonalPatches(this.ctx, { binName: 'dsh', compose })
}
}
/** The file the Loader includes: the replacement tree when named, otherwise the shared base. */
private bootConfigPath(): string {
return this.options.configReplacePath ?? this.options.configPath
}
/** Install the diagnostic for plugin rejections that happen after settled boot. */
@@ -270,6 +278,17 @@ export class AppCLIEntry {
*/
private parseYmlRows(): Map<string, { config?: unknown }> {
const rows = new Map<string, { config?: unknown }>()
// A replacement tree stands alone, so only its own rows are indexed —
// the telemetry-row check must judge the tree that actually boots.
if (this.options.configReplacePath !== undefined) {
for (const row of this.parseRowList(this.options.configReplacePath)) {
if (typeof row.id === 'string') rows.set(row.id, row)
for (const inserted of row.insert ?? []) {
if (typeof inserted.id === 'string') rows.set(inserted.id, inserted)
}
}
return rows
}
const files = [this.options.configPath, this.options.overlayPath]
if (this.options.extraOverlayPath !== undefined) files.push(this.options.extraOverlayPath)
for (const file of files) {

View File

@@ -16,8 +16,8 @@ import { Command, CommanderError } from 'commander'
/**
* Interactive TUI: the default mode. `--config` applies an overlay over the
* shipped composition in place of the personal one, `--config-replace` boots a
* file as the whole tree instead, and `--resume <id>` rehydrates a session.
* shipped composition, `--config-replace` boots a file as the whole tree
* instead, and `--resume <id>` rehydrates a session.
*/
interface TuiInvocation {
mode: 'tui'
@@ -28,40 +28,49 @@ interface TuiInvocation {
/**
* Print the composed config tree and exit, without booting: `--dump-config`
* composes the shipped base, the surface overlay, and the `--config` or
* personal overlay — exactly the layers that surface would boot;
* `--dump-default-config` stops at the surface overlay (the shipped tree, no
* user layer).
* composes the shipped base, the surface overlay, and any `--config` overlay —
* exactly the layers that surface would boot; `--dump-default-config` stops at
* the surface overlay (the shipped tree, no user layer).
*/
interface DumpConfigInvocation {
mode: 'dump-config'
surface: 'tui' | 'web'
/** Omit the `--config`/personal layer and print only the shipped composition. */
/** Omit the `--config` layer and print only the shipped composition. */
defaultOnly: boolean
/** The `--config` overlay to compose instead of the personal one. */
/** The `--config` overlay to compose over the shipped tree. */
config?: string
}
/** Headless one-shot: `dsh -p "task"`. */
/**
* Headless one-shot: `dsh -p "task"`. `--config` and `--config-replace` mean
* exactly what they mean for the TUI, so an automated run can name its
* composition instead of depending on whatever the machine happens to hold.
*/
interface HeadlessInvocation {
mode: 'headless'
prompt: string
config?: string
configReplace?: string
}
/** Interactive fresh TUI over this harness checkout; accepts no default-surface options, only the experimental gate. */
/** Interactive fresh TUI over this harness checkout; takes the composition flags and the experimental gate. */
interface MetaInvocation {
mode: 'meta'
config?: string
configReplace?: string
}
/**
* Guided fresh-session entry: `dsh upgrade` seeds the first turn
* with the `dsh-upgrade` skill. It always mints a
* fresh session in the invoking directory and takes no options beyond the
* experimental gate — `--resume`, `--config`, and `-p` are rejected as
* mistyped, so there is nothing to carry.
* with the `dsh-upgrade` skill. It always mints a fresh session in the
* invoking directory, so `--resume` and `-p` are rejected as mistyped; the
* composition flags are accepted because the update runs against whatever
* tree the caller names.
*/
interface SkillSessionInvocation {
mode: 'upgrade'
config?: string
configReplace?: string
}
/**
@@ -184,9 +193,9 @@ Examples:
// subcommand without a positional collision.
.option('-p, --prompt <task>', 'answer this task without the interactive UI, then exit')
.option('--resume <id>', 'continue a past session by id')
.option('--config <path>', 'apply this overlay of loader patches instead of the personal one')
.option('--config-replace <path>', 'boot this file as the entire tree, ignoring the shipped and personal configuration')
.option('--dump-config', 'print the composed config tree (base + surface + --config/personal overlay) and exit')
.option('--config <path>', 'apply this overlay of loader patches over the shipped configuration')
.option('--config-replace <path>', 'boot this file as the entire tree, ignoring the shipped configuration')
.option('--dump-config', 'print the composed config tree (base + surface + --config overlay) and exit')
.option('--dump-default-config', 'print the shipped config tree (base + surface overlay, no user layer) and exit')
.action((options: {
config?: string
@@ -208,23 +217,24 @@ Examples:
}
if (options.prompt !== undefined) {
// A headless prompt owns the invocation; an empty task has nothing to
// run, and --config/--resume are TUI inputs that must not silently
// vanish from a headless run.
// run, and --resume is a TUI input that must not silently vanish from
// a one-shot run. The composition flags DO apply: naming a tree is how
// an automated run pins its composition.
if (options.prompt === '') program.error('error: --prompt needs a task')
if (options.config !== undefined || options.configReplace !== undefined || options.resume !== undefined) {
program.error('error: --prompt takes no --config, --config-replace, or --resume')
if (options.resume !== undefined) program.error('error: --prompt takes no --resume')
assertOneConfigFlag(options)
resolved = {
mode: 'headless',
prompt: options.prompt,
...options.config !== undefined && { config: options.config },
...options.configReplace !== undefined && { configReplace: options.configReplace },
}
resolved = { mode: 'headless', prompt: options.prompt }
return
}
// An empty --resume= id would silently start a fresh session downstream
// (agent-loop treats '' as no-resume), so a mistyped resume must fail loud.
if (options.resume === '') program.error('error: --resume needs a session id')
// The two config flags are mutually exclusive: one layers over the shipped
// tree, the other discards it, so accepting both would silently drop one.
if (options.config !== undefined && options.configReplace !== undefined) {
program.error('error: --config and --config-replace are mutually exclusive')
}
assertOneConfigFlag(options)
resolved = {
mode: 'tui',
...options.config !== undefined && { config: options.config },
@@ -233,10 +243,27 @@ Examples:
}
})
/**
* The two config flags are mutually exclusive on every surface that takes
* them: one layers over the shipped tree, the other discards it, so
* accepting both would silently drop one.
* @param options - the parsed options of the surface being resolved.
*/
function assertOneConfigFlag(options: { config?: string; configReplace?: string }): void {
if (options.config !== undefined && options.configReplace !== undefined) {
program.error('error: --config and --config-replace are mutually exclusive')
}
}
/** The composition flags every booting surface registers, in one place so their help text cannot drift. */
const withConfigFlags = (command: Command): Command => command
.option('--config <path>', 'apply this overlay of loader patches over the shipped configuration')
.option('--config-replace <path>', 'boot this file as the entire tree, ignoring the shipped configuration')
// Commander parses the parent (default-surface) options on either side of a
// subcommand into `program.opts()`. For a subcommand that shares none of them,
// a leaked config/prompt/resume option is a mistyped invocation that must fail
// loud rather than silently run and drop the input.
// subcommand into `program.opts()`. A subcommand takes its own flags after
// its own name, so a leaked parent config/prompt/resume option is a mistyped
// invocation that must fail loud rather than silently run and drop the input.
const rejectParentOptions = (command: string): void => {
const parent = program.opts<{
config?: string
@@ -267,14 +294,18 @@ Examples:
// come last. `upgrade` is a guided fresh-session entry: beyond the
// experimental gate it takes no options and always mints a fresh session,
// so nothing is left to carry.
program
.command('upgrade')
withConfigFlags(program.command('upgrade'))
.description('update this dsh installation to the latest version (experimental)')
.option('--experimental', 'acknowledge this subcommand is experimental')
.action((options: { experimental?: boolean }) => {
.action((options: { experimental?: boolean; config?: string; configReplace?: string }) => {
rejectParentOptions('upgrade')
requireExperimental('upgrade', options.experimental)
resolved = { mode: 'upgrade' }
assertOneConfigFlag(options)
resolved = {
mode: 'upgrade',
...options.config !== undefined && { config: options.config },
...options.configReplace !== undefined && { configReplace: options.configReplace },
}
})
// Host and port name no default: the CLI passes neither through when the flag
@@ -288,7 +319,7 @@ Examples:
.option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)')
.option('--workspace-root <path>', 'parent directory for workspaces created from the browser UI')
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
.option('--dump-config', 'print the composed config tree (base + web + --config/personal overlay) and exit')
.option('--dump-config', 'print the composed config tree (base + web + --config overlay) and exit')
.option('--dump-default-config', 'print the shipped config tree (base + web overlay, no user layer) and exit')
.action((options: WebOptions) => {
rejectParentOptions('web')
@@ -300,14 +331,18 @@ Examples:
resolved = resolveWeb(options)
})
program
.command('meta')
withConfigFlags(program.command('meta'))
.description('work on the dsh source that runs this command, from any directory (experimental)')
.option('--experimental', 'acknowledge this subcommand is experimental')
.action((options: { experimental?: boolean }) => {
.action((options: { experimental?: boolean; config?: string; configReplace?: string }) => {
rejectParentOptions('meta')
requireExperimental('meta', options.experimental)
resolved = { mode: 'meta' }
assertOneConfigFlag(options)
resolved = {
mode: 'meta',
...options.config !== undefined && { config: options.config },
...options.configReplace !== undefined && { configReplace: options.configReplace },
}
})
try {

View File

@@ -36,7 +36,7 @@ switch (invocation.mode) {
}
case 'headless': {
const { runHeadless } = await import('./headless.ts')
await runHeadless(invocation.prompt)
await runHeadless(invocation.prompt, invocation.config, invocation.configReplace)
break
}
case 'tui': {
@@ -51,12 +51,12 @@ switch (invocation.mode) {
}
case 'meta': {
const { runTui, SOURCE_ROOT } = await import('./tui.ts')
await runTui(undefined, undefined, SOURCE_ROOT)
await runTui(invocation.config, undefined, SOURCE_ROOT, undefined, invocation.configReplace)
break
}
case 'upgrade': {
const { runTui } = await import('./tui.ts')
await runTui(undefined, undefined, undefined, `dsh-${invocation.mode}`)
await runTui(invocation.config, undefined, undefined, `dsh-${invocation.mode}`, invocation.configReplace)
break
}
default:

View File

@@ -1,7 +1,7 @@
/**
* `dsh --dump-config` / `dsh web --dump-config` — print the composed config
* tree without booting: the shipped base, the surface overlay, and (unless
* `--dump-default-config`) the `--config` or personal overlay, composed
* `--dump-default-config`) any `--config` overlay, composed
* through the include's own patch algorithm so the printed tree is exactly
* what that surface would mount. `!!js` expressions print verbatim,
* unevaluated — the dump shows composition, not one process's environment.
@@ -10,16 +10,13 @@
* @module @deepseek-ai/dsh/dump-config
*/
import { basename, join } from 'node:path'
import { basename } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
loadOverlayPatches,
loadPersonalPatches,
PERSONAL_CONFIG_FILENAME,
renderConfigDump,
type ConfigDumpLayer,
} from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
const NAME = 'dsh'
@@ -36,25 +33,17 @@ const SURFACE_OVERLAYS = {
* separator naming the file each section of rows comes from (and the layers
* that patched it).
* @param surface - which surface overlay to compose over the shared base.
* @param defaultOnly - stop at the surface overlay (no `--config`/personal layer).
* @param config - the `--config` overlay path composed instead of the personal
* one, or `undefined` to use `$DSH_HOME/config.yaml`.
* @param defaultOnly - stop at the surface overlay (no `--config` layer).
* @param config - the `--config` overlay path to compose over the shipped
* tree, or `undefined` for the shipped composition alone.
*/
export function runDumpConfig(surface: 'tui' | 'web', defaultOnly: boolean, config?: string): void {
const overlay = SURFACE_OVERLAYS[surface]
const layers: ConfigDumpLayer[] = [
{ label: basename(overlay), patches: loadOverlayPatches(NAME, overlay) },
]
if (!defaultOnly) {
if (config === undefined) {
const personal = loadPersonalPatches(NAME)
// The personal file may be absent; the shipped layers still print.
if (personal !== undefined) {
layers.push({ label: join(resolveDshHome(), PERSONAL_CONFIG_FILENAME), patches: personal })
}
} else {
layers.push({ label: config, patches: loadOverlayPatches(NAME, config) })
}
if (!defaultOnly && config !== undefined) {
layers.push({ label: config, patches: loadOverlayPatches(NAME, config) })
}
process.stdout.write(renderConfigDump(NAME, BASE_CONFIG, layers))
}

View File

@@ -9,6 +9,7 @@
*/
import { fileURLToPath } from 'node:url'
import { resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
@@ -71,14 +72,19 @@ async function consumeUntilTurnEnd(frames: AsyncIterable<RpcRequest<MuxFrame>>,
* is the non-empty prompt the argument adapter parsed from `-p`/`--prompt`
* (the adapter rejects an empty task, so no guard is needed here).
* @param task - the prompt text for the single turn.
* @param config - a `--config` overlay applied over the shipped composition, or `undefined`.
* @param configReplace - a `--config-replace` tree booted instead of the
* shipped composition, or `undefined`. It must mount a webserver row: this
* surface reaches its own agent over the same HTTP gateway the browser uses.
*/
export async function runHeadless(task: string): Promise<void> {
export async function runHeadless(task: string, config?: string, configReplace?: string): Promise<void> {
// A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
const entry = new AppCLIEntry({
configPath: fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)),
overlayPath: fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)),
...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) },
...configReplace !== undefined && { configReplacePath: resolveConfigPath(configReplace, undefined) },
dev: false,
watchPersonalConfig: false,
port: 0,
})
const { ctx, port } = await entry.run()

View File

@@ -1,9 +1,9 @@
/**
* `dsh` default surface — the interactive TUI coding agent. Boots the shipped
* shared base and TUI overlay, followed by either `--config` or the personal overlay
* from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence:
* ambient environment, then the invoking directory's `.env`, then the personal one)
* and its `config.yaml` patches the booted tree. The workspace is the invoking
* shared base and TUI overlay, followed by any `--config` overlay. The Harness
* home (`~/.dsh`) contributes the user environment layer only: its `.env` fills
* environment gaps (precedence: ambient environment, then the invoking
* directory's `.env`, then the user one). The workspace is the invoking
* directory: the session cwd, relative paths, and workspace instructions resolve
* from it, so `dsh` acts on whatever project it is launched in. Session storage
* is the exception — it lives under the Harness home so `/resume` reaches every
@@ -26,9 +26,7 @@ import {
boot,
installFailLoud,
loadOverlayPatches,
loadPersonalPatches,
resolveConfigPath,
watchPersonalPatches,
} from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import type { PatchOptions } from '@cordisjs/plugin-include'
@@ -77,13 +75,12 @@ const SESSION_QUERY_DB = `session-query-${String(process.pid)}-${randomUUID()}.d
export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
/* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers;
the CLI PTY smoke drives this path end to end, personal overlay included */
the CLI PTY smoke drives this path end to end, --config overlay included */
/**
* Run the interactive TUI from the invoking directory.
* @param config - an overlay patch list applied over the shared base and the
* TUI overlay, REPLACING the personal `~/.dsh/config.yaml` so a named tree never
* inherits the user's route, or `undefined` to use the personal overlay;
* already parsed from `--config`.
* TUI overlay, or `undefined` for the shipped composition alone; already
* parsed from `--config`.
* @param resumeSessionId - a persisted session id to resume, or `undefined` to
* mint a fresh one; already parsed and non-empty-validated from `--resume`.
* Either way the resulting identity reaches the booted app through
@@ -95,9 +92,9 @@ export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
* first turn, or `undefined`. Set only by `dsh upgrade` and
* ignored on a resume, so it never re-fires; reaches the app through
* {@link INITIAL_SKILL_KEY}.
* @param configReplace - a config path to boot as the ENTIRE tree, bypassing the
* shared base, the TUI overlay, and the personal overlay alike, or `undefined`
* to compose them; already parsed from `--config-replace`.
* @param configReplace - a config path to boot as the ENTIRE tree, bypassing
* the shared base and the TUI overlay alike, or `undefined` to compose them;
* already parsed from `--config-replace`.
*/
export async function runTui(
config: string | undefined,
@@ -202,10 +199,8 @@ export async function runTui(
// patch list: patches never cross an include boundary, so stacking these as
// nested includes would silently stop reaching base rows. Later lists win.
//
// `--config` REPLACES the personal overlay rather than layering under it: an
// explicitly named tree must not inherit `~/.dsh/config.yaml`'s route, or a
// demo or test config would silently run on the user's provider and model.
// `--config-replace` additionally discards the base and the surface overlay.
// `--config` layers over the shipped base and TUI overlay; `--config-replace`
// discards both and boots the named file alone.
const replaceTree = configReplace !== undefined
const bootConfig = resolvedConfigReplace === undefined ? BASE_CONFIG : resolveConfigPath(resolvedConfigReplace, undefined)
// Same opt-out semantics as the web surface (resolveTelemetryPatch: any
@@ -214,16 +209,13 @@ export async function runTui(
// presence is checked against the tree actually booting, so a
// --config-replace tree is judged on its own rows, not the shipped base's.
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, configHasTelemetryRow(bootConfig))
const composePatches = (personalPatches: PatchOptions[]): PatchOptions[] => [
const patches: PatchOptions[] = [
...replaceTree ? [] : [
...loadOverlayPatches(NAME, TUI_OVERLAY),
...resolvedConfig === undefined
? personalPatches
: loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)),
...resolvedConfig === undefined ? [] : loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)),
],
...telemetryPatch === undefined ? [] : [telemetryPatch],
]
const patches = composePatches(loadPersonalPatches(NAME) ?? [])
const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB)
const ctx = await boot(
NAME,
@@ -243,8 +235,8 @@ export async function runTui(
// the Harness home across every cwd, so /resume sees every workspace.
// The bundle treats the slot as opaque.
// The agent-loop row reads this to bind `main`, and the tui row reads the
// same id, so a personal overlay repointing the model route cannot drop
// the session identity or desynchronise the two.
// same id, so an overlay repointing the model route cannot drop the
// session identity or desynchronise the two.
hostCtx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, { [MAIN_AGENT_ID]: identity })
// The query database is a disposable derived index with single-process
// ownership. Keep it process-local while it indexes the shared logs.
@@ -264,14 +256,6 @@ export async function runTui(
}
},
)
// The shipped tree includes HMR and keeps personal config live. An explicit
// --config tree replaces the personal overlay (so there is nothing to keep
// live), and a --config-replace or HMR-less tree remains a valid composition
// that still receives the startup overlay but deliberately has no hidden
// watcher.
if (resolvedConfig === undefined && !replaceTree && ctx.get('hmr') !== undefined) {
await watchPersonalPatches(ctx, { binName: NAME, compose: composePatches })
}
app.current = ctx
addHarnessSourceSection(ctx, SOURCE_ROOT)
if (showFirstRunWelcome) {

View File

@@ -91,7 +91,7 @@ export function prepareWebRuntimeContext(ctx: Context, sourceRoot: string, mode:
* @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback.
* @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone.
* @param config - an overlay of loader patches applied over the shipped web
* composition instead of `$DSH_HOME/config.yaml`, or `undefined` to use the
* composition, or `undefined` to boot the
* personal overlay; already parsed from `--config`.
*/
export async function runWeb(
@@ -109,7 +109,6 @@ export async function runWeb(
...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) },
dev,
prepare: (ctx) => { prepareWebRuntimeContext(ctx, SOURCE_ROOT, mode) },
watchPersonalConfig: true,
...host !== undefined && { host },
...port !== undefined && { port },
...workspaceRoot !== undefined && { workspaceRoot },

View File

@@ -30,6 +30,17 @@ describe('parseDshArgs', () => {
expect(parse(['--config-replace', 'tree.yml'])).toEqual({ mode: 'tui', configReplace: 'tree.yml' })
expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' })
expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' })
// Every booting surface takes the composition flags: with the personal
// overlay gone, naming a tree is the only way to compose one, so a
// surface that could not name one would have no composition path at all.
expect(parse(['-p', 'task', '--config', 'c.yml']))
.toEqual({ mode: 'headless', prompt: 'task', config: 'c.yml' })
expect(parse(['-p', 'task', '--config-replace', 'tree.yml']))
.toEqual({ mode: 'headless', prompt: 'task', configReplace: 'tree.yml' })
expect(parse(['meta', '--experimental', '--config', 'c.yml']))
.toEqual({ mode: 'meta', config: 'c.yml' })
expect(parse(['upgrade', '--experimental', '--config-replace', 'tree.yml']))
.toEqual({ mode: 'upgrade', configReplace: 'tree.yml' })
// Experimental subcommands run under the per-invocation flag or the env opt-in.
expect(parse(['meta', '--experimental'])).toEqual({ mode: 'meta' })
expect(parse(['meta'], true)).toEqual({ mode: 'meta' })
@@ -77,9 +88,8 @@ describe('parseDshArgs', () => {
// schema at boot, not here.)
expect(exitCode(['--resume='])).toBe(1)
expect(exitCode(['-p', ''])).toBe(1)
expect(exitCode(['-p', 'x', '--config', 'c.yml'])).toBe(1)
expect(exitCode(['-p', 'x', '--config-replace', 'tree.yml'])).toBe(1)
expect(exitCode(['--config', 'c.yml', '--config-replace', 'tree.yml'])).toBe(1)
expect(exitCode(['-p', 'x', '--config', 'c.yml', '--config-replace', 'tree.yml'])).toBe(1)
expect(exitCode(['-p', 'x', '--resume', 's'])).toBe(1)
expect(exitCode(['--bogus'])).toBe(1)
expect(exitCode(['bogus-positional'])).toBe(1)
@@ -91,16 +101,14 @@ describe('parseDshArgs', () => {
expect(exitCode(['--config-replace', 'tree.yml', 'web'])).toBe(1)
// Same rule for each subcommand that shares no option with the default
// surface, so a leaked flag is a typo, not something to ignore.
// `meta` fixes its own config tree and always starts fresh,
// so every default-surface option is rejected.
// `meta` always starts fresh, so the session options are rejected; the
// composition flags are its own and only their combination is rejected.
expect(exitCode(['meta', '--experimental', '--resume', 's'])).toBe(1)
expect(exitCode(['meta', '--experimental', '--config', 'c.yml'])).toBe(1)
expect(exitCode(['meta', '--experimental', '--config-replace', 'tree.yml'])).toBe(1)
expect(exitCode(['meta', '--experimental', '-p', 'task'])).toBe(1)
// `upgrade` takes no options beyond the gate: any leaked default-surface
// flag is a mistyped invocation, not a silently-dropped input.
expect(exitCode(['meta', '--experimental', '--config', 'c.yml', '--config-replace', 't.yml'])).toBe(1)
// `upgrade` always mints a fresh session, so `--resume` and a leaked
// parent flag are mistyped invocations; its own composition flags are not.
expect(exitCode(['upgrade', '--experimental', '--resume', 's'])).toBe(1)
expect(exitCode(['upgrade', '--experimental', '--config', 'c.yml'])).toBe(1)
expect(exitCode(['-p', 'task', 'upgrade', '--experimental'])).toBe(1)
// The pre-release command names have no compatibility aliases.
expect(exitCode(['experimental-meta'])).toBe(1)

View File

@@ -107,8 +107,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
expect(stdout).toContain('# == tui.cordis.yml')
}, 30_000)
it('layers the personal overlay in --dump-config and reports an unmatched patch on stderr', async () => {
writeFileSync(join(home, 'config.yaml'), [
it('layers a --config overlay in --dump-config and reports an unmatched patch on stderr', async () => {
const overlay = join(home, 'overlay.yml')
writeFileSync(overlay, [
'- id: agent-loop',
' config:',
' agents:',
@@ -120,16 +121,19 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
' value: 1',
'',
].join('\n'))
const { stdout, code, stderr } = await runBuiltBin(['--dump-config'], { DSH_HOME: home })
const { stdout, code, stderr } = await runBuiltBin(['--dump-config', '--config', overlay], { DSH_HOME: home })
expect(code).toBe(0)
expect(stdout).toContain('provider: custom-provider')
expect(stdout).not.toContain('model: deepseek-v4-pro')
// The personal layer appears in the patched row's provenance and the
// The named layer appears in the patched row's provenance and the
// skipped-patch warning carries its label.
expect(stdout).toContain(`patched by tui.cordis.yml, ${join(home, 'config.yaml')}`)
expect(stdout).toContain(`patched by tui.cordis.yml, ${overlay}`)
expect(stderr).toContain('patch: entry "only-on-web" not found')
// The shipped view ignores the personal overlay entirely.
// An unnamed dump composes the shipped tree only: a file sitting in the
// Harness home is not a layer any more.
const unnamed = await runBuiltBin(['--dump-config'], { DSH_HOME: home })
expect(unnamed.stdout).not.toContain('custom-provider')
const shipped = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home })
expect(shipped.stdout).not.toContain('custom-provider')
expect(shipped.stdout).toContain('model: deepseek-v4-pro')

View File

@@ -40,7 +40,7 @@ const PTY_SMOKE_TEST_TIMEOUT_MS = process.env.DSH_EXAMPLE_MODE === 'lib'
: LOADER_SMOKE_TEST_TIMEOUT_MS
/**
* Seed the isolated process workspace: ordinary files land in `cwd`, personal
* Seed the isolated process workspace: ordinary files land in `cwd`, harness
* files in the Harness home (`.dsh`), and skill bundles under the agents
* home's `skills/` root — the same trees `$DSH_HOME` /
* `$DSH_AGENTS_HOME` point the child at.
@@ -48,7 +48,7 @@ const PTY_SMOKE_TEST_TIMEOUT_MS = process.env.DSH_EXAMPLE_MODE === 'lib'
function seedWorkspace(
files: {
workspace?: Record<string, string>
personal?: Record<string, string>
harnessHome?: Record<string, string>
skills?: Record<string, string>
},
): (cwd: string) => Promise<void> {
@@ -58,7 +58,7 @@ function seedWorkspace(
await mkdir(dirname(file), { recursive: true })
await writeFile(file, content)
}
for (const [name, content] of Object.entries(files.personal ?? {})) {
for (const [name, content] of Object.entries(files.harnessHome ?? {})) {
const file = join(cwd, '.dsh', name)
await mkdir(dirname(file), { recursive: true })
await writeFile(file, content)
@@ -652,7 +652,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
expect(output).toContain('Preserve restored state')
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('boots the shipped default config with no arguments and no personal overlay', async () => {
it('boots the shipped default config with no arguments and no overlay', async () => {
const output = await smoke({
label: 'dsh default boot',
tempDirPrefix: 'dsh-default-boot-',
@@ -667,9 +667,9 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
expect(output).toContain('\u001B[?2004l')
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('applies the personal overlay: config.yaml patches an overlay-inserted row, and both .env layers feed its !!js with the project one winning', async () => {
// The whole personal-config chain in one boot, plus the environment
// layering underneath it. config.yaml patches the `tui` row — a row the
it('applies a --config overlay: it patches an overlay-inserted row, and both .env layers feed its !!js with the project one winning', async () => {
// The whole explicit-overlay chain in one boot, plus the environment
// layering underneath it. The named file patches the `tui` row — a row the
// SURFACE OVERLAY inserted, not one the base declares — proving a later
// patch list reaches a row an earlier one inserted. The `!!js` expression
// renders both halves of the layering in one line: `DSH_LAYER_WELCOME` is
@@ -678,13 +678,13 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
// arrive. Credentials are not part of this: they live in
// `.credentials.yaml`, which is never hoisted into `process.env`.
const output = await smoke({
label: 'dsh personal overlay',
tempDirPrefix: 'dsh-personal-overlay-',
label: 'dsh explicit overlay',
tempDirPrefix: 'dsh-explicit-overlay-',
binScript: dshBinScript,
configArgs: [],
configArgs: ['--config', '.dsh/config.yaml'],
prepare: seedWorkspace({
workspace: { '.env': 'DSH_LAYER_WELCOME=PROJECT WINS.\n' },
personal: {
harnessHome: {
'.env': 'DSH_LAYER_WELCOME=USER LAYER LOST.\nDSH_USER_ONLY=USER LAYER LOADED.\n',
'config.yaml': [
'- id: workspace-context',
@@ -705,7 +705,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
expect(output).toContain('\u001B[?2004l')
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('loads a cached repository Plugin from personal config alone', async () => {
it('loads a cached repository Plugin from a --config overlay alone', async () => {
const source = 'github:fixture/repository#fixed-ref'
const specifier = `${source}&path:/.dsh-plugin`
const key = createHash('sha256').update(specifier).digest('hex')
@@ -717,12 +717,12 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
// deliberate external pin of the durable on-disk format.
const wrapper = await generatePreparedWrapper('config-only-fixture')
const output = await smoke({
label: 'dsh personal repository Plugin',
tempDirPrefix: 'dsh-personal-repository-plugin-',
label: 'dsh overlay repository Plugin',
tempDirPrefix: 'dsh-overlay-repository-plugin-',
binScript: dshBinScript,
configArgs: [],
configArgs: ['--config', '.dsh/config.yaml'],
prepare: seedWorkspace({
personal: {
harnessHome: {
'config.yaml': [
'- id: repository-plugins',
" name: '@deepseek-ai/dsh-repository-plugin'",
@@ -753,13 +753,13 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('fails loud instead of booting when the personal config.yaml is invalid', async () => {
it('fails loud instead of booting when a named --config overlay is invalid', async () => {
const output = await smoke({
label: 'dsh invalid personal config',
tempDirPrefix: 'dsh-invalid-personal-',
label: 'dsh invalid overlay',
tempDirPrefix: 'dsh-invalid-overlay-',
binScript: dshBinScript,
configArgs: [],
prepare: seedWorkspace({ personal: { 'config.yaml': 'id: not-a-list\n' } }),
configArgs: ['--config', '.dsh/config.yaml'],
prepare: seedWorkspace({ harnessHome: { 'config.yaml': 'id: not-a-list\n' } }),
expectedExitCode: 1,
})
expect(output).toContain('must be a top-level YAML array of loader patch entries')
@@ -793,18 +793,18 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36} --config/)
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('keeps resume working when the personal overlay replaces the whole agent-loop config', async () => {
// Loader patches replace a targeted `config` key wholesale, so a personal
// overlay repointing the model route drops every identity key the shipped
it('keeps resume working when a --config overlay replaces the whole agent-loop config', async () => {
// Loader patches replace a targeted `config` key wholesale, so an overlay
// repointing the model route drops every identity key the shipped
// row declared. Launcher-owned identity makes that unreachable: agent-loop
// applies the launcher's id over whatever route survives.
const output = await smoke({
label: 'dsh overlay keeps resume',
tempDirPrefix: 'dsh-overlay-resume-',
binScript: dshBinScript,
configArgs: [],
configArgs: ['--config', '.dsh/config.yaml'],
prepare: seedWorkspace({
personal: {
harnessHome: {
'config.yaml': [
'- id: workspace-context',
' disabled: true',