Extract example apps into packages
将示例应用提取为独立包
An example folder is supposed to be *thin* — the variable wiring of a demo, not the demo's machinery. Before this change it was thick. Each example carried a hand-rolled `start.ts` boot bootstrap, an infra preamble (`timer`, and — for the stdio demos — `logger` + `hmr`), nested includes of three shared YAML fragments (`base.yml` / `base-core.yml` / `acp-agent/acp-tail.yml`), and per-example `agent-loop`/persistence/s
English
Problem
An example folder is supposed to be thin — the variable wiring of a demo, not the demo's machinery. Before this change it was thick. Each example carried a hand-rolled start.ts boot bootstrap, an infra preamble (timer, and — for the stdio demos — logger + hmr), nested includes of three shared YAML fragments (base.yml / base-core.yml / acp-agent/acp-tail.yml), and per-example agent-loop/persistence/system-prompt config. The actual app — the spine of services every agent needs — was spread across the leaf and those includes.
The leaf configs also owned coupled front doors. ACP requires stdout purity and creates agents through session/new; terminal and Headless apps pre-create main but have different process I/O contracts. Prose warnings were the only guard against combining these incorrectly, while three start.ts files duplicated the Loader bootstrap and lifecycle code.
Decision
Each example is now mostly an invocation of an app package, splitting the wiring along the existing interface / implementation / consumer seam: the app package owns the composition, the leaf cordis.yml owns only the swappable choices (which LLM adapter, which bash executor, model, prompt, persistence root).
@deepseek-ai/dsh-agent-spine-demo(packages/examples/agent-spine-demo) composes the providerless, executor-less, UI-less spine and forwards the loop's agent-list config. Its dependency on the concrete loop is intentional because this package composes the spine rather than extending it; swapping the loop means supplying another bundle.@deepseek-ai/dsh-tui-demo,@deepseek-ai/dsh-cli-demo, and@deepseek-ai/dsh-acp-demobake in their process roles. TUI includes the full-screen UI and a pre-createdmain; Headless includes the one-shot driver and a pre-createdmain; ACP includes the bridge and no pre-created agent. All three include JSONL persistence and omit stdout loggers.start.tsis gone. Each app package exposes a bin; thedemo:*scripts invoke it. Loader boot,.envloading, and fail-loud guards live in the shared@deepseek-ai/dsh-app-bootpackage (unit-tested under the per-file coverage gate — see share the app bins' boot glue); the thin self-executing entries are driven by keyless Loader-path tests.- Each leaf
cordis.ymlcollapses to backends, optional product tools, and one app entry carrying the app config. TUI and Headless route model/session choices onto a pre-created agent; ACP routes the initial provider/model onto its bridge. base.yml,base-core.yml, andacp-agent/acp-tail.ymlare retired — the spine they shared now lives indsh-agent-spine-demo.
bash-local and the LLM adapter stay leaf choices: the bundle ships tool-bash (the consumer schema), the leaf picks the executor implementation, so a sandboxed executor or replay adapter swaps in without touching the app.
Amendment on implementation: hmr stays a leaf entry
The proposal listed hmr among the interactive app's baked-in front-door cluster. Validating against the code, baking hmr into the app package fights Cordis in two ways, so it ships as a leaf cordis.yml entry instead:
@cordisjs/plugin-hmris a Loader-only, subprocess-only dev plugin — it requires the liveloaderservice and its internal module access, so it can only run in the realdemo:*/bin subprocess, never in the in-process unit/coverage tier.- The in-process test tier (vitest) cannot even import the vendored
hmrmodule (its class-decorator@Injectform fails under Vite's transform), so a package whoseapplystatically imported it could never satisfy the per-file 100% coverage gate on its headline function.
Crucially, hmr is not a stdout-purity footgun: a stray entry in the ACP config does not corrupt JSON-RPC frames. Every shipped app omits a stdout console logger; the app or protocol driver alone owns stdout.
Alternatives considered
Why not keep the wiring in shared YAML includes?
The old base*.yml/acp-tail.yml includes already deduped the config, but a YAML include cannot encapsulate the front-door coupling — it can only describe it in a comment and trust every leaf to obey. It also cannot own a bin, so the boot glue stayed copied across three start.ts files. A package turns "the ACP app never logs to stdout" from a prose warning into a property of the artifact: there is no logger entry in the leaf to get wrong.
Verification
- Example directories contain only their config, README, and tests:
start.ts, the infrastructure preamble, and the shared YAML includes are gone. demo:tui,demo:headless, anddemo:acpinvoke the app-package bins.- Each new package has a README and per-file 100% coverage; each app package also has a keyless real-Loader-path bin smoke that catches export-shape failures described in postmortem 0001.
- The ACP replay suite boots through the app-package bin, so protocol wiring and assembled backend behavior cross the real Loader boundary.
Consequences
- The bare-plugin-tree pedagogy. The spine lives behind a bundle, so seeing the whole tree means opening
dsh-agent-spine-demo. The app package's README carries that teaching weight. - A layer of indirection. "What does this demo load?" becomes a package read, not a single YAML scan.
Related
- Supersedes Make the shared example base providerless: renaming
base.ymlto the providerless core is moot once the spine moves intodsh-agent-spine-demoand thebase*.ymlfiles are deleted. - Builds on the capability-seams interface/implementation/consumer split — backends and presentation stay leaf choices; the spine is the shared bundle.
- Complements Reorganize packages into a modular hierarchy: the new app/core packages slot into existing groups under that hierarchy (
corefor the reusable spine bundle,uifor the app-specific front doors). - The later redundant-agent removal owns the final TUI/Headless split and removes the line-oriented and mock-only leaves.
中文
问题
示例目录本应是精简的——只包含演示的可变接线,而非演示的基础设施。在此次变更之前,它是臃肿的。每个示例都携带一份手写的 start.ts 启动引导、一段基础设施前导(timer,以及 stdio 演示所需的 logger + hmr(热模块替换))、三个共享 YAML 片段的嵌套引用(base.yml / base-core.yml / acp-agent/acp-tail.yml),还有各示例自身的 agent-loop/持久化/系统提示词配置。真正的应用——每个 agent(智能体)都需要的服务主干——散落在叶子配置和那些 include 中。
叶子配置还拥有耦合的前门。ACP(Agent Client Protocol)要求 stdout 纯净,并通过 session/new 创建 agent;终端应用和 Headless 应用则预创建 main,但进程 I/O 契约不同。防止错误组合的唯一屏障是文档中的文字警告,而三个 start.ts 文件重复着 Loader 引导和生命周期代码。
决策
每个示例现在主要是对一个应用包(package)的调用,沿着既有的接口 / 实现 / 消费方 seam 拆分接线:应用包拥有组合,叶子 cordis.yml 只拥有可替换的选择(哪个 LLM(大语言模型)适配器、哪个 bash 执行器、模型、提示词、持久化根目录)。
@deepseek-ai/dsh-agent-spine-demo(packages/examples/agent-spine-demo)组合了不含提供方、不含执行器、不含 UI 的主干,并转发 agent loop(智能体循环)的 agent 列表配置。它对具体 loop 的依赖是有意为之,因为该包组合的是主干而非扩展主干;替换 loop 意味着提供另一个 bundle。@deepseek-ai/dsh-tui-demo、@deepseek-ai/dsh-cli-demo和@deepseek-ai/dsh-acp-demo各自内置其进程角色。TUI 包含全屏 UI 和预创建的main;Headless 包含 one-shot driver 和预创建的main;ACP 包含 bridge 且不预创建 agent。三者都包含 JSONL 持久化,并省略 stdout logger。start.ts已移除。 每个应用包都暴露一个 bin;demo:*脚本调用它。Loader 引导、.env加载和快速失败守卫位于共享的@deepseek-ai/dsh-app-boot包(在逐文件覆盖率门禁下有单元测试——见共享应用 bin 的启动胶水);精简的自执行入口由 keyless 的 Loader 路径测试驱动。- 每个叶子
cordis.yml精简为后端、可选产品工具,以及一个承载应用配置的 app 条目。TUI 和 Headless 把模型/会话选择路由到预创建的 agent;ACP 把初始提供方/模型路由到 bridge。 base.yml、base-core.yml和acp-agent/acp-tail.yml已退役——它们共享的主干现在位于dsh-agent-spine-demo中。
bash-local 和 LLM 适配器仍然是叶子选择:bundle 提供 tool-bash(消费方 schema),叶子选择执行器实现,因此沙箱执行器或回放适配器无需触碰应用即可替换。
实现修正:hmr 保留为叶子条目
提案最初将 hmr 列入交互式应用内置的前门集群。对照代码验证后发现,将 hmr 内置到应用包中会在两个方面与 Cordis 冲突,因此改为作为叶子 cordis.yml 条目交付:
@cordisjs/plugin-hmr是一个仅限 Loader、仅限子进程的开发插件——它需要活跃的loader服务及其内部模块访问权限,因此只能在真实的demo:*/bin 子进程中运行,不能在进程内的单元/覆盖率测试层运行。- 进程内测试层(vitest)甚至无法导入 vendor 的
hmr模块(其 class-decorator@Inject形式在 Vite 的 transform 下会失败),因此一个apply静态导入了它的包永远无法满足其主函数的逐文件 100% 覆盖率门禁。
关键在于,hmr 不是 stdout 纯净隐患:ACP 配置中误加该条目不会破坏 JSON-RPC 帧。所有已交付应用都省略 stdout 控制台 logger;stdout 只归应用或协议 driver 所有。
曾考虑的替代方案
为什么不继续用共享 YAML include 来管理接线?
旧的 base*.yml/acp-tail.yml include 已经去重了配置,但 YAML include 无法封装前门耦合——它只能在注释中描述,并信任每个叶子遵守。它也无法拥有 bin,因此启动胶水一直在三个 start.ts 文件中重复。包将「ACP 应用绝不向 stdout 输出日志」从文字警告变成了产物的属性:叶子中不存在可以写错的 logger 条目。
验证
- 示例目录只包含配置、README 和测试:
start.ts、基础设施前导和共享 YAML include 已移除。 demo:tui、demo:headless和demo:acp调用应用包的 bin。- 每个新包都有 README 和逐文件 100% 覆盖率;每个应用包还有一个 keyless 的真实 Loader 路径 bin 冒烟测试,用于捕获事后分析 0001 中描述的导出形状故障。
- ACP 回放套件通过应用包的 bin 启动,因此协议接线与组装后的后端行为都跨越真实的 Loader 边界。
后果
- 裸插件树的教学性。 主干现在隐藏在 bundle 之后,查看完整树意味着打开
dsh-agent-spine-demo。应用包的 README 承担了这份教学职责。 - 多了一层间接。「这个演示加载了什么?」从扫描单个 YAML 变成了阅读一个包。
相关
- 取代使共享示例基础配置与提供方无关:一旦主干移入
dsh-agent-spine-demo且base*.yml文件被删除,将base.yml重命名为无提供方核心便不再有意义。 - 基于能力 seam的接口/实现/消费方拆分——后端和展示层保持为叶子选择;主干是共享 bundle。
- 与将包重组为模块化层级结构互补:新的 app/core 包按该层级结构归入既有分组(
core放可复用的主干 bundle,ui放应用特有的前门)。 - 后续的冗余 agent 移除拥有最终的 TUI/Headless 拆分,并移除行式与仅 mock 的叶子。