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
+8 -2
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()