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

@@ -86,7 +86,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore
Bringing up a new `packages/client/<name>` plugin package (ui-workspace is the latest walked example; ui-sidebar/ui-question are good skeletons to copy):
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/base.cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else.
4. **Registering into another package's slot**: if the declaring host provides no waitable service, your apply's order relative to the host's is unconstrained — a bare `slots.register` into its slot races boot (intermittent `slot "..." is not declared` page failures). Register with declaration-aware deferral: check `ctx.slots.spec(name)`, otherwise `ctx.slots.subscribe(name)` and register on the declaration event (SlotCore supports subscribing ahead of declaration); make the registration idempotent, and unsubscribe + dispose in the effect disposer. Only take a service edge in `inject` when the host actually provides one (ui-question → `'conversation'` is that case).
5. Rebuild the bundle (`pnpm --filter <pkg> bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources.

View File

@@ -134,6 +134,15 @@ interface PreparedAgent {
declare module 'cordis' {
interface Context {
agentLoop: AgentLoop
/**
* Launcher-owned exact session identities for configured agents, keyed by
* the agent's config `id` and set with `ctx.provide()` before any Loader
* entry mounts (see {@link CONFIGURED_AGENT_IDENTITIES_KEY}). A launcher
* owns identity because only it knows whether the session already exists,
* while the `cordis.yml` row keeps the model route as ordinary patchable
* config. An entry with no matching key keeps its configured identity.
*/
configuredAgentIdentities?: ConfiguredAgentIdentities
}
interface Events {
/**
@@ -151,6 +160,53 @@ declare module 'cordis' {
export { DEFAULT_MAX_PARALLEL_TOOL_CALLS }
/**
* One launcher-selected session identity for a configured agent. `resume`
* distinguishes rehydrating existing persisted history from creating the
* session fresh under that exact id, which the two config keys express as
* `resumeSessionId` and `sessionId`.
*/
export interface LauncherAgentIdentity {
/** Exact session id to create fresh or resume. */
id: SessionId
/** Resume existing persisted history instead of creating the session fresh. */
resume: boolean
}
/** Launcher-selected identities keyed by the configured agent's `id`. */
export type ConfiguredAgentIdentities = Readonly<Record<string, LauncherAgentIdentity>>
/**
* Context key a launcher sets before any Loader entry mounts
* (`ctx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, identities)`) to fix
* configured agents' session identities without a config key, so an overlay
* repointing the row's model route cannot drop them.
*/
export const CONFIGURED_AGENT_IDENTITIES_KEY = 'configuredAgentIdentities'
/**
* Apply launcher-owned identities over the configured agents, replacing both
* identity keys for every entry the launcher named so a config-supplied
* identity can never survive alongside a launcher-supplied one.
* @param agents - the configured agent entries.
* @param identities - launcher identities keyed by configured agent `id`, or `undefined`.
* @returns the entries with launcher-owned identities applied.
*/
function applyLauncherIdentities(
agents: Config['agents'],
identities: ConfiguredAgentIdentities | undefined,
): Config['agents'] {
if (identities === undefined) return agents
return agents.map((agent) => {
const identity = identities[agent.id]
if (identity === undefined) return agent
const { sessionId: _sessionId, resumeSessionId: _resumeSessionId, ...rest } = agent
return identity.resume
? { ...rest, resumeSessionId: identity.id }
: { ...rest, sessionId: identity.id }
})
}
/** Agent-loop plugin configuration. */
export interface Config {
/**
@@ -220,6 +276,7 @@ export class AgentLoop extends Service implements AgentFactory {
super(ctx, 'agentLoop')
this.config = {
...config,
agents: applyLauncherIdentities(config.agents, ctx.get(CONFIGURED_AGENT_IDENTITIES_KEY)),
maxParallelToolCalls: resolveMaxParallelToolCalls(config.maxParallelToolCalls),
}
validateConfiguredAgents(this.config.agents)

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/examples/README.md
<<<<<<< HEAD
README.md: c229cef22087ac290bf862d6b3e31fdb533858c4
README.zh.md: 208b9a138506785ea1dd2d83ddfbba29e4b7968e
=======
README.md: d3ad432e71036db0d21f059e52f5d32e58010c42
README.zh.md: 0218e60df2511974b8eb222e23331c5a70c9df60
>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays)

View File

@@ -7,12 +7,11 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling
| Package | npm name | Role |
|---|---|---|
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin, with fallback session titles and an opt-in persisted-goal stack |
| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app bundle: the spine + persisted goals + `/goal` command + JSONL persistence + `dsh-tui` + a pre-created `main` agent; no bin, booted by the [`dsh`](../../apps/cli/README.md) CLI |
| `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output |
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP automation server app: the spine + persisted goals + JSONL persistence + the [`acp`](../acp/acp/README.md) bridge (no stdout logger), with a boot `bin` |
| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client |
`agent-spine-demo` is the shared bundle; `tui-demo`, `cli-demo`, and `acp-demo` compose it with full-screen terminal, headless one-shot, and ACP automation front doors. `cli-demo` and `acp-demo` own their boot bins; `tui-demo` ships only the bundle plugin, and the product [`dsh`](../../apps/cli/README.md) CLI is its terminal front door. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
`agent-spine-demo` is the shared bundle; `cli-demo` and `acp-demo` compose it with headless one-shot and ACP automation front doors, and own their boot bins. The product [`dsh`](../../apps/cli/README.md) CLI uses no bundle: its TUI and web surfaces are a shared `base.cordis.yml` plus one overlay each. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
These are **not** product API. The spine pieces they bundle live in [`core/`](../core/README.md), human/SDK channels and boot glue in [`ui/`](../ui/README.md), the automation transport in [`acp/`](../acp/README.md), and swappable backends in their capability groups; a demo bundle just picks one concrete composition of them. Swap or fork one freely.

View File

@@ -6,13 +6,21 @@
| 包 | npm 名称 | 角色 |
|---|---|---|
<<<<<<< HEAD
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | 不含执行器和 UI 的 agent智能体主干打包为一个组合包插件带后备会话标题和可选择启用的持久化目标栈 |
| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | 全屏终端应用组合包:主干 + 持久化目标 + `/goal` 命令 + JSONL 持久化 + `dsh-tui` + 预创建的 `main` agent没有 bin由 [`dsh`](../../apps/cli/README.md) CLI命令行界面启动 |
=======
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | 不含执行器和 UI 的 agent 主干,打包为一个组合包插件,带后备会话标题和选用的持久目标栈 |
>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays)
| `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | 无头单次应用:主干 + JSONL 持久化 + 预创建的 `main` agent提供文本和 DSH 原生 JSON 输出 |
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACPAgent Client Protocol自动化服务器应用主干 + 持久化目标 + JSONL 持久化 + [`acp`](../acp/acp/README.md) 桥接层(无 stdout logger带启动 `bin` |
| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | 只有 bin 的运行时,用于启动外部 `cordis.yml`,供 stdio JSON-RPC SDK 客户端使用 |
<<<<<<< HEAD
`agent-spine-demo` 是共享组合包;`tui-demo``cli-demo``acp-demo` 分别将它与全屏终端、无头单次和 ACP 自动化前端入口组合。`cli-demo``acp-demo` 拥有各自的启动 bin`tui-demo` 只交付组合包插件,产品 [`dsh`](../../apps/cli/README.md) CLI 是它的终端前端入口。`jsonrpc-demo` 自身不挂载任何组合,而是启动部署的 `cordis.yml` 所指名的任意插件树Python SDK 运行时会启动它。
=======
`agent-spine-demo` 是共享组合包;`cli-demo``acp-demo` 分别将它与无头单次和 ACP 自动化前端入口组合,并拥有各自的启动 bin。产品 [`dsh`](../../apps/cli/README.md) CLI 不使用组合包:其 TUI 与 web surface 都是一份共享的 `base.cordis.yml` 加各自一份 overlay。`jsonrpc-demo` 自身不挂载任何组合,而是启动部署的 `cordis.yml` 所指名的任意插件树Python SDK runtime 会启动它。
>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays)
这些 **不是** 产品 API。它们打包的主干组件位于 [`core/`](../core/README.md)人类SDK 通道和启动粘合代码位于 [`ui/`](../ui/README.md),自动化传输位于 [`acp/`](../acp/README.md),可替换后端位于各自能力组;演示组合包只选定其中一种具体组合。可以自由替换或 fork。

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/examples/agent-spine-demo/README.md
<<<<<<< HEAD
README.md: 359e7153be2f480ba3fea4b06782acdc9f89ebb9
README.zh.md: acd8c06940b03e90e368314cd725846a2b92b656
=======
README.md: 6fde5c64052e369541787f90c12c33a7b0b8f9eb
README.zh.md: d5b0e2b50707750132bdeae9d7e56d513b0f40a7
>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays)

View File

@@ -47,7 +47,7 @@ The spine is everything COMMON to every front door. The swappable and front-door
- **model-backed session-title providers** — the bundle mounts the fallback service with overridable example limits (5 words, 40 fallback bytes, 80 accepted-title bytes); a leaf may opt into exactly one first-message or all-messages LLM provider.
- **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl).
- **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings.
- **front-door + per-app infra** — the terminal TUI or ACP automation transport and `hmr`. App packages ([`dsh-tui-demo`](../tui-demo/README.md), [`dsh-acp-demo`](../acp-demo/README.md)) own those choices. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside.
- **front-door + per-app infra** — the terminal TUI or ACP automation transport and `hmr`. App packages ([`dsh-cli-demo`](../cli-demo/README.md), [`dsh-acp-demo`](../acp-demo/README.md)) own those choices. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside.
This is the [interface/implementation/consumer seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) raised to the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door.

View File

@@ -47,7 +47,11 @@
- **基于模型的会话标题提供方**组合包挂载带可覆盖示例限制的后备服务5 个词、40 个后备字节、80 个可接受标题字节);叶节点可以恰好选用一个首消息或全消息 LLM 提供方。
- **bash 执行器**:组合包交付 `tool-bash`(消费方 schema叶节点提供 `ctx.bash``bash-local` 或沙箱化实现)。
- **非本地 skill 提供方**:组合包交付 skill 注册表、本地文件系统提供方和 `skill` 工具;部署可以把嵌入式目录或远程目录等其他提供方作为同级插件添加。
<<<<<<< HEAD
- **前端入口与各应用基础设施**:终端 TUI 或 ACPAgent Client Protocol自动化传输以及 `hmr`。应用包([`dsh-tui-demo`](../tui-demo/README.md)、[`dsh-acp-demo`](../acp-demo/README.md))拥有这些选择。`timer` 位于主干中,因为它是共有组件且不写 stdout前端入口拥有 stdout因此留在组合包外。
=======
- **前端入口与各应用基础设施**:终端 TUI 或 ACP 自动化传输,以及 `hmr`。应用包([`dsh-cli-demo`](../cli-demo/README.md)、[`dsh-acp-demo`](../acp-demo/README.md))拥有这些选择。`timer` 位于主干中,因为它是共有组件且不写 stdout前端入口拥有 stdout因此留在组合包外。
>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays)
这把[接口/实现/消费方 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) 提升到组合层:组合包拥有共享主干,叶节点拥有后端,应用包拥有前端入口。

View File

@@ -1,112 +0,0 @@
# @deepseek-ai/dsh-tui-demo
English | [中文](README.zh.md)
The full-screen terminal app bundle: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), persisted same-session goals, the human-command registry and `/goal` producer, JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). A `cordis.yml` mounts it as one entry; the [`dsh`](../../../apps/cli/README.md) CLI is the front door that boots such a config.
Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and other non-interactive runs. This bundle requires a TTY pair and has no line-oriented fallback.
## What it bakes in
| Plugin | Why it is here |
|---|---|
| `@deepseek-ai/dsh-agent-spine-demo` | Shared services, model-facing tools, and one configured `main` agent |
| `@deepseek-ai/dsh-commands` | Human-only discovery and dispatch consumed by the TUI and command plugins |
| `@deepseek-ai/dsh-command-goal` | Direct `/goal` status and mutation over the spine's persisted-goal stack |
| `@deepseek-ai/dsh-session-persistence-jsonl` | Durable session log under `persistenceRoot` |
| `@deepseek-ai/dsh-session-checkpoint-policy` | Semantic durability barriers before model requests and top-level tool effects, plus completed-step checkpoints |
| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | Combined exact/FTS session queries and bounded `@session` snapshots consumed by the TUI; model-facing query tools remain a leaf opt-in |
| `@deepseek-ai/dsh-user-interaction` | Provider-neutral human question service |
| `@deepseek-ai/dsh-tui` | Full-screen transcript, editor, tool cards, plan, and question overlays |
| `@deepseek-ai/dsh-tool-ask-user` | Model-facing `ask_user_question` tool |
Swappable LLM, bash, filesystem, and other capability providers remain in the leaf config. `@cordisjs/plugin-hmr` also remains a leaf-only development entry because it requires Loader internals.
## Config
| Key | Default | Routed to |
|---|---|---|
| `provider` | required | Configured `main` agent provider |
| `model` | required | Configured `main` agent model |
| `maxParallelToolCalls` | agent-loop default | Bundled loop concurrency cap |
| `persona` | — | System-prompt persona template |
| `toolOrder` | lexicographic | Explicit model-facing tool order |
| `tools` | owner default | Tool presentation mode |
| `dshHome` | owner default | Harness home used by bash and skills |
| `sessionTitle` | spine example limits | Fallback title word/byte limits |
| `skills` | owner defaults | Skill registry, local provider, and tool config |
| `toolBash` | owner defaults | Model-facing bash tool config |
| `toolTasks` | owner defaults | Background-task control-tool config, or `false` |
| `goals` | owner defaults | Persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer |
| `workspaceContext` | required | Workspace-instruction config, or `false` |
| `persistenceRoot` | `./.sessions` (launcher boot slot overrides) | JSONL persistence root and parent of the derived `session-query.db` index |
| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) |
| `sessionReferences` | service defaults | Cross-session candidate and snapshot limits routed to `dsh-session-reference` |
| `welcome` | `ready.` | TUI subtitle |
| `ui` | owner defaults | TUI presentation settings such as reasoning, color, and card height |
Session identity is launcher-owned rather than configurable: a launcher provides `MAIN_SESSION_ID_KEY` on the boot context, and this app binds both the TUI and the configured agent to that id, loading persisted history only when the launcher also set `resume`. With no such slot the app mints a `main-session-<uuid>` and creates it fresh. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. The app composes persistence and session query for `/resume`; a launcher may additionally provide `tuiResumeHost` for in-place process handoff and `TUI_GOODBYE_MESSAGE_KEY` for the line printed on exit.
`persistenceRoot` defaults to project-local `./.sessions`: an app bundle must not assume the user's shared session store. A launcher that wants one store across every cwd states that policy through the `SESSIONS_ROOT_KEY` boot slot (`ctx.provide` before any Loader entry mounts) — the dsh CLI provides its Harness-home root there, so its `/resume` lists sessions from every workspace. Precedence is explicit config, then the launcher slot, then the project-local default.
## Front door
This package ships no bin. The [`dsh`](../../../apps/cli/README.md) CLI is the terminal front door: bare `dsh` boots the shipped `examples/tui-agent/cordis.yml` (which mounts this bundle), and `dsh --config <path-to-cordis.yml>` boots an alternate leaf config that mounts it. It loads the optional cwd `.env`, drives the Cordis Loader, and waits for the full plugin tree. The repository installs Loader's optional native helper, so bare package specifiers resolve under plain Node.
## Example leaf
```yaml
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
- id: bash
name: '@deepseek-ai/dsh-bash-local'
- id: tui-agent
name: '@deepseek-ai/dsh-tui-demo'
config:
provider: deepseek
model: deepseek-v4-flash
workspaceContext:
maxBytes: 65536
welcome: 'Coding agent ready.'
ui:
showReasoning: true
```
## Model Experience
### Interactive terminal turn
#### What the model sees
Each non-empty non-command editor submission becomes a user message; a submission during a running turn becomes steering. Slash-command input and output remain human-only, while accepted `/goal` mutations append domain-owned model-visible state. The shared spine contributes the configured persona, workspace instructions, skill catalog, goal controls, and visible tool schemas. TUI rendering itself is not model-visible.
#### Token effect
User, assistant, and tool history grows under the normal session and compaction rules. Headers, cards, plans, Markdown styling, and keybindings add no tokens.
#### KV Cache effect
Append-only while the composed prompt, schemas, route, and retained history prefix remain stable. Composition changes and compaction can invalidate reuse from the first changed token.
### Human-question answer
#### What the model sees
`ask_user_question` retains the tool call and the compact answer or stable interruption error defined by `dsh-tool-ask-user`. The question overlay is terminal-only.
#### Token effect
Only the completed or failed tool result adds retained tokens.
#### KV Cache effect
Append-only; the answer follows the reusable request prefix.
## Known Limitations and Deferred Work
- **TTY-only** — stdin and stdout must both be terminals; automation uses `dsh-cli-demo`.
- **One configured terminal session** — the transcript and editor bind to one exact session id.
- **The app cluster is fixed** — JSONL persistence and ask-user tooling are baked in; different policy requires another composition.
- **Approval is separate** — this app answers `ctx.userInteraction`, not `ctx.approval`; permission prompts require an approval service and answerer.

View File

@@ -1,76 +0,0 @@
{
"name": "@deepseek-ai/dsh-tui-demo",
"description": "Full-screen TUI app bundle plugin: agent spine + persisted goals + human commands + JSONL persistence + pi-tui front door + pre-created main agent (mounted by the dsh CLI's config)",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
"@deepseek-ai/dsh-command-goal": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
"@deepseek-ai/dsh-session-query": "^0.0.1",
"@deepseek-ai/dsh-session-query-sqlite": "^0.0.1",
"@deepseek-ai/dsh-session-reference": "^0.0.1",
"@deepseek-ai/dsh-tool-ask-user": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-tui": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"schemastery": "^3.17.0"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
"@deepseek-ai/dsh-command-goal": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-tui": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"cordis": "^4.0.0-rc.7",
"schemastery": "^3.17.0"
}
}

View File

@@ -1,169 +0,0 @@
/**
* Full-screen terminal app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo})
* plus persisted goals, human commands, JSONL persistence, keyboard-backed
* user interaction, and one pre-created agent whose exact session identity the
* TUI drives. Swappable adapters, executors, optional tools, and HMR stay in the leaf. This Loader plugin
* intentionally exposes named exports only; a default export would hide its
* `Config` schema (see docs/postmortem/0001).
* @module @deepseek-ai/dsh-tui-demo
*/
import type { Context } from 'cordis'
import { randomUUID } from 'node:crypto'
import { join } from 'node:path'
import z from 'schemastery'
import { SessionId } from '@deepseek-ai/dsh-session'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
import CommandService from '@deepseek-ai/dsh-commands'
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import SessionPersistenceJsonl, {
JsonlCompressionSchema,
type JsonlCompression,
} from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite'
import SessionReferenceService, { type Config as SessionReferenceConfig } from '@deepseek-ai/dsh-session-reference'
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
import * as uiTui from '@deepseek-ai/dsh-tui'
export const name = 'tui-demo'
// The bundle's own fallback stays project-local: a plugin must never assume
// the user's shared session store. The dsh launcher's SESSIONS_ROOT_KEY slot
// (opaque here — the CLI resolves it to DSH_HOME/sessions) carries any
// shared-store policy, and explicit config wins over both.
const DEFAULT_PERSISTENCE_ROOT = './.sessions'
// Each front door keeps a complete Loader contract so its deployment config is
// readable without a cross-package facade.
/* jscpd:ignore-start */
/** App config routed to the spine, TUI, configured agent, and JSONL backend. */
export interface Config {
/** Provider route for the `main` agent. */
provider: string
/** Model name for the `main` agent; a matching adapter must be registered. */
model: string
/** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */
maxParallelToolCalls?: number
/** Deployment persona forwarded to the system-prompt plugin. */
persona?: string
/** Explicit model-facing tool order forwarded to the system-prompt plugin. */
toolOrder?: string[]
/** Tool-registry presentation config forwarded through agent-spine-demo. */
tools?: ToolsConfig
/** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
dshHome?: string
/** Fallback session-title limits forwarded through agent-spine-demo. */
sessionTitle?: NonNullable<agentCore.Config['sessionTitle']>
/**
* Directory for JSONL sessions and the derived query index. Precedence:
* this explicit config, then the launcher's opaque `SESSIONS_ROOT_KEY` boot
* slot (the dsh CLI resolves it to `DSH_HOME/sessions`), then a project-local
* `./.sessions` fallback — the bundle itself never assumes a global store.
*/
persistenceRoot?: string
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
persistenceCompression?: JsonlCompression
/** Cross-session reference discovery and snapshot byte budgets. */
sessionReferences?: SessionReferenceConfig
/** TUI transcript's optional first line; absent renders nothing on start. */
welcome?: string
/** Full-screen TUI presentation settings. */
ui?: uiTui.TuiConfig
/** Skill registry, local-provider, and model-facing consumer config. */
skills?: agentCore.SkillConfig
/** Model-facing bash tool config forwarded through agent-spine-demo. */
toolBash?: NonNullable<agentCore.Config['toolBash']>
/** Generic background-task controls forwarded through agent-spine-demo; set false to omit them. */
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
/** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */
goals?: agentCore.GoalConfig | false
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
workspaceContext: agentCore.Config['workspaceContext']
}
export const Config: z<Config> = z.object({
provider: z.string().required(),
model: z.string().required(),
maxParallelToolCalls: z.number().step(1).min(1),
persona: z.string(),
// Absent means lexicographic order; schemastery's native array default is [].
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
tools: ToolRegistry.Config,
dshHome: z.string(),
sessionTitle: agentCore.SessionTitleConfigSchema,
// No schema default: schemastery would materialize it before composeTuiApp
// runs, shadowing the launcher's SESSIONS_ROOT_KEY slot for a Loader mount.
persistenceRoot: z.string(),
persistenceCompression: JsonlCompressionSchema,
sessionReferences: SessionReferenceService.Config,
welcome: z.string(),
ui: uiTui.TuiConfigSchema,
skills: agentCore.SkillConfigSchema,
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
goals: z.union([z.const(false), agentCore.GoalConfigSchema]),
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
})
/* jscpd:ignore-end */
/**
* Compose the spine, TUI, JSONL persistence, and user-question tool around one
* exact fresh or resumed session identity, taken from the launcher's
* {@link uiTui.MAIN_SESSION_ID_KEY} slot. The TUI subscribes to startup failures
* before the spine creates the agent.
* @param ctx - context receiving the app's child plugins.
* @param config - validated app configuration.
*/
export function composeTuiApp(ctx: Context, config: Config): void {
// The launcher, not the deployment config, owns `main`'s session identity: it
// reaches a Loader-mounted bundle only through this context slot. A launcher
// that supplies an id knows whether that session already exists, so it also
// states whether to load persisted history. No launcher means mint one here.
const identity = ctx.get(uiTui.MAIN_SESSION_ID_KEY)
const sessionId = SessionId(identity?.id ?? `main-session-${randomUUID()}`)
const goals = config.goals ?? {}
const persistenceRoot = config.persistenceRoot ?? ctx.get(uiTui.SESSIONS_ROOT_KEY) ?? DEFAULT_PERSISTENCE_ROOT
ctx.plugin(CommandService)
if (goals !== false) ctx.plugin(commandGoal)
ctx.plugin(SessionPersistenceJsonl, {
root: persistenceRoot,
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
})
ctx.plugin(sessionCheckpointPolicy)
ctx.plugin(SessionQuerySqlite, { path: join(persistenceRoot, 'session-query.db') })
ctx.plugin(SessionReferenceService, config.sessionReferences ?? {})
ctx.plugin(UserInteractionService)
ctx.plugin(uiTui.TuiPromptService)
ctx.plugin(uiTui, {
...config.ui,
...config.welcome === undefined ? {} : { welcome: config.welcome },
sessionId,
})
ctx.plugin(agentCore, {
...agentCore.pickSpineConfig(config),
goals,
agents: [{
id: SessionId('main'),
provider: config.provider,
model: config.model,
cwd: process.cwd(),
// `resumeSessionId` requires existing persisted history and rejects a
// missing log, so only a launcher that asked to resume takes that path.
...identity?.resume === true ? { resumeSessionId: sessionId } : { sessionId },
}],
})
ctx.plugin(toolAskUser)
}
/**
* Compose the configured full-screen terminal app.
* @param ctx - context receiving the app's child plugins.
* @param config - validated app configuration.
*/
export function apply(ctx: Context, config: Config): void {
composeTuiApp(ctx, config)
}

View File

@@ -1,30 +0,0 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tui-demo`.
* @module @deepseek-ai/dsh-tui-demo/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-tui-demo'
/** Cordis companion plugin name. */
export const name = 'tui-demo-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this composition-only package delegates mutable state and event streams
* to the agent spine, persistence, and TUI packages that own their checks.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -1,203 +0,0 @@
import { describe, expect, it } from 'vitest'
import { join } from 'node:path'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import { SessionId } from '@deepseek-ai/dsh-session'
import { MAIN_SESSION_ID_KEY, SESSIONS_ROOT_KEY, type MainSessionIdentity } from '@deepseek-ai/dsh-tui'
import * as tuiAgent from '../src/index.ts'
interface PluginCall {
readonly name: string
readonly config: unknown
}
/**
* Record the composed plugin tree. `identity` stands in for the launcher-owned
* {@link MAIN_SESSION_ID_KEY} slot; omitting it means no launcher chose a session.
*/
function recordingContext(
identity?: MainSessionIdentity,
sessionsRoot?: string,
): { readonly ctx: Context; readonly calls: PluginCall[] } {
const calls: PluginCall[] = []
const ctx = {
plugin(plugin: { name?: string }, config?: unknown) {
calls.push({ name: plugin.name ?? '', config })
},
get: (key: string) => key === MAIN_SESSION_ID_KEY ? identity
: key === SESSIONS_ROOT_KEY ? sessionsRoot : undefined,
} as unknown as Context
return { ctx, calls }
}
describe('dsh-tui-demo app', () => {
it('composes the TUI cluster around one fresh exact session identity', () => {
const { ctx, calls } = recordingContext()
tuiAgent.composeTuiApp(ctx, {
provider: 'mock',
model: 'mock-model',
maxParallelToolCalls: 3,
persona: 'test persona',
toolOrder: ['zulu', TOOL_ORDER_REST],
tools: { mode: 'code' },
dshHome: '/tmp/dsh-home',
persistenceRoot: '/tmp/tui-sessions',
persistenceCompression: 'none',
sessionReferences: {
maxReferences: 2,
candidateLimit: 7,
maxReferenceBytes: 1234,
},
welcome: 'TUI ready',
ui: { theme: { color: false }, maxToolOutputLines: 3 },
skills: { tool: { catalogDescriptionMaxLength: 8 } },
toolBash: { enableRunInBackground: false },
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
workspaceContext: false,
})
expect(calls.map(call => call.name)).toEqual([
'CommandService',
'command-goal',
'SessionPersistenceJsonl',
'session-checkpoint-policy',
'SessionQuerySqlite',
'SessionReferenceService',
'UserInteractionService',
'TuiPromptService',
'ui-tui',
'agent-spine-demo',
'tool-ask-user',
])
expect(calls[0]?.config).toBeUndefined()
expect(calls[2]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' })
expect(calls[4]?.config).toEqual({ path: join('/tmp/tui-sessions', 'session-query.db') })
expect(calls[5]?.config).toEqual({
maxReferences: 2,
candidateLimit: 7,
maxReferenceBytes: 1234,
})
const tuiConfig = calls[8]?.config as { sessionId: string }
expect(tuiConfig).toMatchObject({
welcome: 'TUI ready',
theme: { color: false },
maxToolOutputLines: 3,
})
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
const spineConfig = calls[9]?.config as {
readonly agents: Array<Record<string, unknown>>
readonly goals: Record<string, never>
readonly maxParallelToolCalls: number
readonly persona: string
readonly toolOrder: string[]
readonly tools: { mode: string }
}
expect(spineConfig).toMatchObject({
maxParallelToolCalls: 3,
persona: 'test persona',
toolOrder: ['zulu', TOOL_ORDER_REST],
tools: { mode: 'code' },
goals: {},
})
expect(spineConfig.agents[0]).toMatchObject({
id: 'main',
provider: 'mock',
model: 'mock-model',
cwd: process.cwd(),
sessionId: tuiConfig.sessionId,
})
})
it('uses the launcher sessions-root slot through schema-normalized config', () => {
// The Loader normalizes config through the schemastery Config BEFORE apply
// runs. A schema .default() on persistenceRoot would materialize here and
// permanently shadow the launcher slot — the regression this test pins.
const normalized = tuiAgent.Config({
provider: 'mock',
model: 'mock-model',
workspaceContext: false,
} as never)
expect(normalized.persistenceRoot).toBeUndefined()
const { ctx, calls } = recordingContext(undefined, '/launcher/sessions')
tuiAgent.composeTuiApp(ctx, normalized)
expect(calls[2]?.config).toMatchObject({ root: '/launcher/sessions' })
expect(calls[4]?.config).toEqual({ path: join('/launcher/sessions', 'session-query.db') })
})
it('lets an explicit persistenceRoot win over the launcher slot', () => {
const { ctx, calls } = recordingContext(undefined, '/launcher/sessions')
tuiAgent.composeTuiApp(ctx, {
provider: 'mock',
model: 'mock-model',
persistenceRoot: '/explicit/root',
workspaceContext: false,
})
expect(calls[2]?.config).toEqual({ root: '/explicit/root' })
})
it('loads persisted history for a launcher-selected resume identity', () => {
// The bundle default stays project-local: shared-store policy is the
// launcher's, which patches `persistenceRoot` itself (the dsh CLI does).
const { ctx, calls } = recordingContext({ id: SessionId('persisted-session'), resume: true })
tuiAgent.composeTuiApp(ctx, {
provider: 'mock',
model: 'mock-model',
workspaceContext: false,
})
expect(calls[2]?.config).toEqual({ root: './.sessions' })
expect(calls[4]?.config).toEqual({ path: join('./.sessions', 'session-query.db') })
expect(calls[5]?.config).toEqual({})
// No configured welcome forwards none: the TUI banner sweeps in without a subtitle.
expect(calls[8]?.config).toEqual({ sessionId: 'persisted-session' })
expect((calls[9]?.config as { agents: Array<Record<string, unknown>> }).agents[0]).toMatchObject({
id: 'main',
resumeSessionId: 'persisted-session',
})
})
it('creates a launcher-minted identity fresh rather than loading history', () => {
const { ctx, calls } = recordingContext({ id: SessionId('minted-session'), resume: false })
tuiAgent.composeTuiApp(ctx, {
provider: 'mock',
model: 'mock-model',
workspaceContext: false,
})
expect(calls[8]?.config).toEqual({ sessionId: 'minted-session' })
expect((calls[9]?.config as { agents: Array<Record<string, unknown>> }).agents[0])
.toMatchObject({ id: 'main', sessionId: 'minted-session' })
})
it('mints a fresh session with no launcher slot and routes apply through the same composition', () => {
const { ctx, calls } = recordingContext()
tuiAgent.apply(ctx, {
provider: 'mock',
model: 'mock-model',
goals: false,
workspaceContext: false,
})
const tuiConfig = calls[7]?.config as { sessionId: string }
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
expect((calls[8]?.config as { agents: Array<Record<string, unknown>> }).agents[0])
.toMatchObject({ sessionId: tuiConfig.sessionId })
expect(calls.map(call => call.name)).not.toContain('command-goal')
expect(calls[8]?.config).toMatchObject({ goals: false })
})
it('has the namespace-plugin export shape so the Loader keeps its schema', () => {
expect(tuiAgent.name).toBe('tui-demo')
expect(tuiAgent.Config).toBeDefined()
expect('default' in tuiAgent).toBe(false)
expect(typeof tuiAgent.apply).toBe('function')
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(tuiAgent) as Record<string, unknown>
expect(unwrapped).toBe(tuiAgent)
expect(unwrapped.name).toBe('tui-demo')
expect(unwrapped.Config).toBeDefined()
})
})

View File

@@ -1,66 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/session"
},
{
"path": "../../util/paths"
},
{
"path": "../../session-query/session-query"
},
{
"path": "../../session-query/session-query-sqlite"
},
{
"path": "../../context/session-reference"
},
{
"path": "../../ui/commands"
},
{
"path": "../../goal/command-goal"
},
{
"path": "../agent-spine-demo"
},
{
"path": "../../context/workspace-context"
},
{
"path": "../../ui/user-interaction"
},
{
"path": "../../ui/tui"
},
{
"path": "../../ui/tool-ask-user"
},
{
"path": "../../session-persistence/session-checkpoint-policy"
},
{
"path": "../../session-persistence/session-persistence-jsonl"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -1,19 +0,0 @@
import { defineConfig } from 'tsdown'
/**
* tui-demo ships the plugin (`index`) and its invariant companion; the CLI
* front door is `dsh` (apps/cli), which mounts this bundle through its config.
* The root tsdown builds only `lib/types/index.js`, so this override adds the
* invariant entry. Declarations come from `tsc -b` (dts: false), matching
* every package.
*/
export default defineConfig({
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/guard/source-guard/README.md
README.md: f083ef0af53c4d4f7c4a0875837ac3a3c851c54c
README.zh.md: 7c9efa7b8d3aec1a95b62416ef624a887fd48aa0
README.md: a7406d71c6591f78d12b6c02ec22bc4b0b3d517f
README.zh.md: 916d0513b77e17989730fdf27ef50d21013f4e58

View File

@@ -19,7 +19,7 @@ Every field fails loud at plugin load: an empty `tools` list, a blank `requiredS
`protectedCheckout` names a path inside the checkout to guard, and its worktree supplies BOTH protected identities: the repository and the exact branch. Its default is this module's own file, which resolves the checkout the running harness was launched from — the live deployment, whatever its branch is named. Nothing about the branch is configured or pattern-matched, so a maintainer whose staging branch follows no naming convention is protected identically. A harness running from an installed copy resolves a different repository, or none, and therefore guards nothing; the rule is meaningless outside a source checkout.
The shipped TUI composition (`examples/tui-agent/cordis.yml`) loads this plugin with defaults. It is inert for anyone whose workspace is not the launcher's own checkout, so an ordinary project sees no change.
The shipped TUI composition (`apps/cli/base.cordis.yml`) loads this plugin with defaults. It is inert for anyone whose workspace is not the launcher's own checkout, so an ordinary project sees no change.
## Which paths are protected

View File

@@ -19,7 +19,7 @@
`protectedCheckout` 指定位于待保护检出目录内的一条路径;其 worktree 会提供两项受保护身份:仓库和确切分支。其默认值是本模块自己的文件,由此解析出运行中 harness 启动来源的检出目录——当前运行的部署,无论其分支采用什么名称。分支既无需配置,也不会通过模式匹配,因此 staging 分支不遵循任何命名约定的维护者同样会受到保护。若 harness 从已安装副本运行,则会解析到另一个仓库,或根本解析不到仓库,因此不会保护任何内容;这条规则在源码检出目录之外没有意义。
已交付的 TUI 组合(`examples/tui-agent/cordis.yml`)会以默认配置加载本插件。若用户的工作区并非启动器自身所在的检出目录,本插件不会生效,因此普通项目不会发生任何变化。
已交付的 TUI 组合(`apps/cli/base.cordis.yml`)会以默认配置加载本插件。若用户的工作区并非启动器自身所在的检出目录,本插件不会生效,因此普通项目不会发生任何变化。
## 受保护的路径

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: ca4471454f5be5d3fcba38ce665d4fb3fbd85e74
README.zh.md: 953539e1198a52b2bf7cdd9ca1b0d263cc2ae6f9
README.md: c9325d7f0a0feacbc1198d0b737c744ee2075472
README.zh.md: 1d755bbc2852d6bebed5bd738c19c793657d0afa

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/cordis.yml`](../../../apps/cli/cordis.yml).
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/base.cordis.yml`](../../../apps/cli/base.cordis.yml).
## Contract layer (`/api`)

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
所有客户端形态共用的 API 网关TS 契约(`src/api/`,不依赖 Node可从浏览器导入、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts``createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`。该包package在设计上与传输方式无关不注册任何路由载体目前为 HTTP未来可以是 IPC自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`apps/cli/cordis.yml`](../../../apps/cli/cordis.yml)。
所有客户端形态共用的 API 网关TS 契约(`src/api/`,不依赖 Node可从浏览器导入、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts``createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`。该包package在设计上与传输方式无关不注册任何路由载体目前为 HTTP未来可以是 IPC自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`apps/cli/base.cordis.yml`](../../../apps/cli/base.cordis.yml)。
## 契约层(`/api`

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/todo/README.md
<<<<<<< HEAD
README.md: 1e5ae9a1583b9e9d3913fcd1dca7ef11a5f391fe
README.zh.md: a77f788a41353ea547059864dc7cf73ac5025219
=======
README.md: 495851a13f70bc8e3cb2dc99da47ab305ca205fa
README.zh.md: abdac592a312cb36d145e7d16e01821561090dcb
>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays)

View File

@@ -8,4 +8,4 @@ The model-facing todo tool. A single **product** package — there is no interfa
|---|---|---|
| `tool-todo/` | Model-facing `todo_write` tool; writes the whole list to the session log (`todo/write`) | (registers on `ctx.tools`) |
The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs such as the [TUI app](../examples/tui-demo) and the host/client runtime render the durable list from session events.
The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs such as the [TUI front door](../ui/tui) and the host/client runtime render the durable list from session events.

View File

@@ -8,4 +8,8 @@
|---|---|---|
| `tool-todo/` | 面向模型的 `todo_write` 工具;将完整列表写入会话日志(`todo/write` | (注册到 `ctx.tools` |
<<<<<<< HEAD
列表存在于事件溯源会话日志中(`SessionEventMap['todo/write']`,由 [`dsh-session`](../core/session) 拥有);本包是追加快照的轻量消费方。[TUI 应用](../examples/tui-demo)等 UI 以及宿主/客户端运行时会根据会话事件渲染该持久化列表。
=======
列表存在于事件溯源会话日志中(`SessionEventMap['todo/write']`,由 [`dsh-session`](../core/session) 拥有);本包是追加快照的轻量消费方。[TUI 前端入口](../ui/tui)等 UI 以及宿主/客户端运行时会根据会话事件渲染该持久列表。
>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays)

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/todo/tool-todo/README.md
<<<<<<< HEAD
README.md: b05ef43e7137dcf5678b1f1ad6d8c00b8a43baef
README.zh.md: 88a9f5d69ff52dcedbd8f27a4ace5fde3e0dc5a2
=======
README.md: f86a0d7331cc822a6568eeb14b89a64abaa23378
README.zh.md: 778c920198599546890c90d21badf2d7bb0f2551
>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays)

View File

@@ -20,7 +20,7 @@ Beyond the schema's type/required/enum checks, `execute` rejects an empty or dup
## Rendering
The canonical result is `{ todos, counts: { pending, inProgress, completed } }`; its Native renderer returns the compact update acknowledgement. The tool also writes the full `todo/write` session event. UIs subscribe to the event stream and render that durable list themselves: the [TUI app](../../examples/tui-demo) and the [web client](../../client/ui-conversation) show a plan strip (plus a dedicated web tool row) off the standing plan — latest `todo/write` with no later `turn/start` ([display](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md), [lifetime](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md)).
The canonical result is `{ todos, counts: { pending, inProgress, completed } }`; its Native renderer returns the compact update acknowledgement. The tool also writes the full `todo/write` session event. UIs subscribe to the event stream and render that durable list themselves: the [TUI front door](../../ui/tui) and the [web client](../../client/ui-conversation) show a plan strip (plus a dedicated web tool row) off the standing plan — latest `todo/write` with no later `turn/start` ([display](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md), [lifetime](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md)).
## Session projection

View File

@@ -20,7 +20,11 @@
## 渲染
<<<<<<< HEAD
规范结果为 `{ todos, counts: { pending, inProgress, completed } }`;其 Native 渲染器返回精简的更新确认。工具还会写入完整 `todo/write` 会话事件。UI 订阅事件流,并自行渲染该持久化列表:[TUI 应用](../../examples/tui-demo)与 [web 客户端](../../client/ui-conversation)基于当前有效计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`显示计划条web 另有专属工具行)([展示](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md)、[生命周期](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md))。
=======
规范结果为 `{ todos, counts: { pending, inProgress, completed } }`;其 Native 渲染器返回精简的更新确认。工具还会写入完整 `todo/write` 会话事件。UI 订阅事件流,并自行渲染该持久列表:[TUI 前端入口](../../ui/tui)与 [web 客户端](../../client/ui-conversation)基于站立计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`显示计划条web 另有专属工具行)([展示](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md)、[生命周期](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md))。
>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays)
## 会话投影

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` 粘合层;仅供自动化使用的 ACPAgent 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,