refactor(cli)!: one shared base config with per-surface overlays

`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml
composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml
whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a
twenty-key pass-through Config. Neither file was what its location claimed —
apps/cli hardcoded the "example" as the product default and the "demo" bundle
was the application — and every capability change had to be made twice.

- apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and
  web.cordis.yml are patch lists stating only what differs per surface
- overlays apply as SIBLING patch lists at one include level, because include
  patches never cross an include boundary. Precedence: base < surface <
  (--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches
- `--config` now applies an overlay INSTEAD OF the personal one, so a demo or
  test tree never inherits the user's route; new `--config-replace` boots a file
  as the entire tree (the old `--config` behaviour). Both survive /resume
- vendor/include: index each `insert`ed row as it is added so a later patch can
  configure or disable it. Upstream built the id index once before the patch
  loop, leaving every surface-only row — the whole TUI front door — silently
  unpatchable from user config. Logged as local modification 8
- session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY;
  dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it)
- delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo;
  TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests,
  examples/code-mode survives as an overlay leaf
- `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay

Three latent defects surfaced and are fixed here: the TUI captured the optional
sessionQuery service once at construction and could permanently disable /resume
when it won the mount race; the session-store root silently reverted to a
project-local ./.sessions; --config-replace was dropped by the resume handoff.

Verified by booting each tree through the real Loader (TUI 55 entries, web 75,
zero unsettled) rather than reading YAML. All eight terminal snapshots replay
byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene
and lint clean.
This commit is contained in:
Turtle
2026-07-29 13:58:21 +08:00
parent e7c0a5b794
commit f290a8b851
182 changed files with 1659 additions and 2400 deletions

View File

@@ -2,5 +2,10 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/README.md
<<<<<<< HEAD
README.md: f08157d411a018141cdc21c487f81ae198f4de56
README.zh.md: ed4fbf576224a61e680fca337ac5e60829f8a90e
=======
README.md: 307ae14709b59e5c233aa930f20e30c87cdbab69
README.zh.md: f02f9d902d0b61ad0299fb3fb24b97cf99c56b21
>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays)

View File

@@ -19,4 +19,4 @@ A UI integration is a client-driver plugin, not a loop change: it consumes the e
`user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with the channel or automation transport that owns the agent. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and interactive app packages provide concrete providers.
The runnable app bundles composed over [`agent-spine-demo`](../examples/agent-spine-demo/README.md) live in [`examples/`](../examples/README.md) (`tui-demo`, `acp-demo`, `jsonrpc-demo`). `acp-demo` and `jsonrpc-demo` own boot bins; the `tui-demo` bundle is booted by the product [`dsh`](../../apps/cli/README.md) CLI. `ui/` keeps the reusable human/SDK channel plugins and shared `app-boot` glue; the automation-only ACP transport lives in [`acp/`](../acp/README.md). Each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools.
The runnable app bundles composed over [`agent-spine-demo`](../examples/agent-spine-demo/README.md) live in [`examples/`](../examples/README.md) (`cli-demo`, `acp-demo`, `jsonrpc-demo`). `acp-demo` and `jsonrpc-demo` own boot bins; The product [`dsh`](../../apps/cli/README.md) CLI uses no bundle: it boots the flat config trees in `apps/cli`. `ui/` keeps the reusable human/SDK channel plugins and shared `app-boot` glue; the automation-only ACP transport lives in [`acp/`](../acp/README.md). Each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools.

View File

@@ -19,4 +19,8 @@ UI 集成属于由客户端驱动的插件,而非对循环的修改:它使
`user-approval`、`user-interaction` 和 `tool-ask-user` 位于此处,因为向用户提问是由 UI 支持的产品功能,并不属于无提供方的核心主干。`user-approval` 负责一次性的 `ctx.approval` 决策机制及其策略层级;应答逻辑仍由负责 agent(智能体)的通道或自动化传输层提供。`user-interaction` 保持提供方无关(`ctx.userInteraction`),`tool-ask-user` 是其模型侧消费方,而交互式 app 包提供具体的提供方。
<<<<<<< HEAD
基于 [`agent-spine-demo`](../examples/agent-spine-demo/README.md) 组合的可运行 app bundle 位于 [`examples/`](../examples/README.md)(`tui-demo`、`acp-demo`、`jsonrpc-demo`)。`acp-demo` 和 `jsonrpc-demo` 各自提供启动 bin;`tui-demo` bundle 则由产品 [`dsh`](../../apps/cli/README.md) CLI(命令行界面)启动。`ui/` 保留可复用的用户/SDK 通道插件和共享 `app-boot` 粘合层;仅供自动化使用的 ACP(Agent Client Protocol)传输层位于 [`acp/`](../acp/README.md)。每个入口都负责自己的 stdout 策略,叶子 `cordis.yml` 则提供后端与可选工具。
=======
基于 [`agent-spine-demo`](../examples/agent-spine-demo/README.md) 组合的可运行 app bundle 位于 [`examples/`](../examples/README.md)(`cli-demo`、`acp-demo`、`jsonrpc-demo`)。`acp-demo` 和 `jsonrpc-demo` 持有启动 bin;产品 [`dsh`](../../apps/cli/README.md) CLI 不使用 bundle:它启动 `apps/cli` 中的平铺 config tree。`ui/` 保留可复用的用户/SDK 通道插件和共享 `app-boot` 粘合层;仅供自动化使用的 ACP 传输层位于 [`acp/`](../acp/README.md)。每个入口都持有自己的 stdout 策略,叶子 `cordis.yml` 则提供后端与可选工具。
>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays)

View File

@@ -94,20 +94,56 @@ export function loadPersonalPatches(
if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined
throw new Error(`${binName}: failed to read personal patches ${file}: ${String(error)}`)
}
return parsePatchList(binName, file, content, 'personal patches')
}
/**
* Load a required overlay patch list: a surface overlay (`tui.cordis.yml`) or a
* `--config <path>` overlay applied over the shared base. Same file format as
* {@link loadPersonalPatches}, but a missing file throws, because the caller
* named this file — its absence is a misconfiguration, not "no overlay".
* @param binName - the diagnostic prefix on the thrown error.
* @param file - absolute path of the overlay file.
* @returns the parsed patch list.
*/
export function loadOverlayPatches(binName: string, file: string): PatchOptions[] {
let content: string
try {
content = readFileSync(file, 'utf8')
} catch (error) {
throw new Error(`${binName}: failed to read overlay ${file}: ${String(error)}`)
}
return parsePatchList(binName, file, content, 'overlay')
}
/**
* Parse one loader patch list: a top-level YAML array of
* `@cordisjs/plugin-include` `PatchOptions` (id-targeted config overrides and
* `insert` lists, `!!js` expressions allowed). Every shape failure throws,
* because a patch file that cannot be applied at all is a misconfiguration; a
* single patch whose target row is absent stays a per-entry Loader warning, so
* one overlay shared across surfaces does not have to match every tree.
* @param binName - the diagnostic prefix on the thrown error.
* @param file - the source path, quoted in errors.
* @param content - the file's text.
* @param label - what to call this list in errors (`personal patches`, `overlay`).
* @returns the parsed patch list.
*/
function parsePatchList(
binName: string, file: string, content: string, label: string,
): PatchOptions[] {
let parsed: unknown
try {
parsed = yaml.load(content, { schema: personalPatchesSchema })
} catch (error) {
throw new Error(`${binName}: failed to parse personal patches ${file}: ${String(error)}`)
throw new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`)
}
if (!Array.isArray(parsed)) {
throw new Error(`${binName}: personal patches ${file} must be a top-level YAML array of loader patch entries`)
throw new Error(`${binName}: ${label} ${file} must be a top-level YAML array of loader patch entries`)
}
// A present personal config that cannot apply is a misconfiguration and must
// fail loud here — the include only warns per entry at mount.
parsed.forEach((entry, index) => {
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
throw new Error(`${binName}: personal patches entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`)
throw new Error(`${binName}: ${label} entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`)
}
})
return parsed as PatchOptions[]

View File

@@ -126,3 +126,52 @@ describe('include refresh with overlay patches', () => {
}
})
})
describe('include patches layered over one base', () => {
it('lets a later patch configure or disable a row an earlier patch inserted', async () => {
// The surface/`--config`/personal composition: `dsh` includes one shared
// base and applies each source as its own patch list at the SAME include
// level, because patches never cross an include boundary. A later layer
// must therefore be able to reach a row an earlier layer inserted —
// otherwise every surface-only row (the whole TUI front door) would be
// invisible to the user's `~/.dsh/config.yaml`.
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-'))
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
writeFileSync(join(dir, 'base.yml'), '- id: shared\n name: ./noop.mjs\n config:\n value: base\n')
writeFileSync(join(dir, 'cordis.yml'), [
'- id: base',
" name: 'cordis:include'",
' config:',
' path: ./base.yml',
' patches:',
// Layer 1 (a surface overlay): patch a base row and add two of its own.
' - id: shared',
' config:',
' value: surface',
' - insert:',
' - id: surface-kept',
' name: ./noop.mjs',
' config:',
' value: surface-default',
' - id: surface-dropped',
' name: ./noop.mjs',
// Layer 2 (the user): reconfigure one inserted row and disable the other.
' - id: surface-kept',
' config:',
' value: personal',
' - id: surface-dropped',
' disabled: true',
'',
].join('\n'))
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
try {
expect(entryConfig(ctx, 'shared')).toEqual({ value: 'surface' })
expect(entryConfig(ctx, 'surface-kept')).toEqual({ value: 'personal' })
const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'surface-dropped')
expect(dropped?.options.disabled).toBe(true)
expect(dropped?.fiber).toBeUndefined()
} finally {
await ctx.fiber.dispose()
}
})
})

View File

@@ -29,7 +29,13 @@ import type { ChannelNotice, ChatChannelDeps } from './channel.ts'
export interface ResumeControllerDeps extends ChatChannelDeps, ChannelNotice {
readonly agent: Agent
readonly runtime: TuiRuntime
readonly sessionQuery: SessionQueryService | undefined
/**
* The optional session-query service, re-read at each use. `sessionQuery` is
* mounted by an independent plugin, and a flat config tree gives no ordering
* guarantee between it and this front door, so a value captured once at
* construction can be `undefined` even though the service arrives moments later.
*/
readonly sessionQuery: (this: void) => SessionQueryService | undefined
readonly ui: TUI
readonly editor: HintEditor
/** Current agent status, re-read at each resume precondition point. */
@@ -75,8 +81,9 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
}
} else {
/* v8 ignore next -- caller checks the optional service before mapping records */
if (sessionQuery === undefined) throw new Error('session query is unavailable')
snapshot = await sessionQuery.readSession(record.header.id)
const readQuery = sessionQuery()
if (readQuery === undefined) throw new Error('session query is unavailable')
snapshot = await readQuery.readSession(record.header.id)
}
return summarizeResumeCandidate(
record,
@@ -105,10 +112,11 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
*/
const preflightResume = async (sessionId: SessionId): Promise<{ id: SessionId; cwd: string }> => {
/* v8 ignore next -- only showResume can call this closure, after proving the optional service exists */
if (sessionQuery === undefined) throw new Error('Resume is unavailable: session query is not mounted.')
const query = sessionQuery()
if (query === undefined) throw new Error('Resume is unavailable: session query is not mounted.')
const initialStatus = deps.agentStatus()
if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`)
const record = (await sessionQuery.listSessions()).find(candidate => candidate.header.id === sessionId)
const record = (await query.listSessions()).find(candidate => candidate.header.id === sessionId)
if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`)
const candidate = await readResumeCandidate(
record,
@@ -177,13 +185,14 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
deps.appendNotice('Resume requires the current turn to finish or be cancelled first.', 'warning')
return
}
if (sessionQuery === undefined) {
const listQuery = sessionQuery()
if (listQuery === undefined) {
deps.appendNotice('Resume is not available: session query is not mounted.', 'warning')
return
}
const scan = ++resumeScan
void resumeOverlay?.close()
void sessionQuery.listSessions().then(async (records) => {
void listQuery.listSessions().then(async (records) => {
if (deps.isDisposed() || scan !== resumeScan) return
// Every workspace in the store is summarized; the picker owns the
// current-workspace/all-workspaces scope split over the whole set.

View File

@@ -306,7 +306,6 @@ export function createTuiChat(
const sessionId = SessionId(config.sessionId ?? 'main')
const agent = ctx.agents.get(sessionId)
if (agent === undefined) throw new Error(`ui-tui: session "${sessionId}" is not running`)
const sessionQuery = ctx.get('sessionQuery')
const resolved = resolveTuiConfig(config)
const palette = createPalette(resolved.theme.color)
const mdTheme = markdownTheme(palette)
@@ -853,7 +852,9 @@ export function createTuiChat(
resolved,
palette,
overlayManager,
sessionQuery,
// Optional and independently mounted: read at each use so config row order
// cannot decide whether /resume works.
sessionQuery: () => ctx.get('sessionQuery'),
ui,
editor,
appendNotice,