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 files changed
+416 -650

No files matched your search

+57 -38
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) {
+74 -39
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 {
+3 -3
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:
+7 -18
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))
}
+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()
+16 -32
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) {
+1 -2
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 },