Implements docs/rfc/.../2026-06-20-extract-example-app-packages.md. Each
example was thick — a hand-rolled start.ts, an infra preamble, nested
base.yml/base-core.yml/acp-tail.yml includes, and a coupled front-door
cluster enforced only by prose. This moves the composition into packages so
each example is a thin leaf cordis.yml: pick the swappable backends, load one
app package.
New packages:
- @deepseek-ai/dsh-agent-core (packages/core/agent-core): one bundle plugin
that loads the providerless/executor-less/UI-less spine (timer + llm +
sessions + system-prompt + tools + agents + invariants + tool-bash +
agent-loop) via ctx.plugin(...) inside apply(), and forwards agent-loop's
`agents` list as its own Config (export const Config = AgentLoop.Config,
default []).
- @deepseek-ai/dsh-stdio-agent (packages/ui/stdio-agent): terminal chat APP —
agent-core + console logger + readline UI + a pre-created `main` agent, with
a bin. The demo:echo/coding front door.
- @deepseek-ai/dsh-acp-agent (packages/ui/acp-agent): ACP server APP —
agent-core + JSONL persistence + the acp bridge, NO stdout logger, with a
bin. The stdout-purity footgun is structurally unreachable from the leaf.
Amendment to the RFC: hmr stays a LEAF cordis.yml entry, not baked into
dsh-stdio-agent. hmr is a Loader-only dev plugin (throws without
--expose-internals; the in-process test tier can't even import its decorator
form), so a package statically importing it could never carry the per-file
coverage gate. Unlike the console logger, a stray hmr is not a stdout-purity
footgun, so leaving it at the leaf costs no safety. With hmr out, all three new
packages carry in-process unit specs at 100%.
Boot glue (Loader tail, .env load, snapshot-mode selection, stdin-dispose
lifecycle) moves into each app's bin; start.ts and base.yml/base-core.yml/
acp-tail.yml are deleted. Each app package gets a keyless real-load-path test
that boots through its bin + the cordis Loader (guarding the unwrapExports
export-shape bug class, postmortem 0001). ACP snapshot replay stays green
against the existing committed goldens (pure boot restructuring). RFC moved
proposed->implemented with the amendment recorded; package/example/architecture
docs and the module graph updated.
9.0 KiB
RFC: Extract example apps into packages
Status: implemented
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 deeper problem was a coupled front-door cluster that lived at the leaf with nothing enforcing it. Choosing the ACP bridge over ui-stdio was not one swappable line: an ACP server must drop the stdout console logger (stdout is the JSON-RPC channel — a stray log corrupts the frames) and pre-create no agents (ACP session/new creates them on demand), whereas the stdio app needs a console logger and a pre-created main. (timer is the one infra plugin common to both — it writes nothing to stdout — so it belongs in the shared spine, not the cluster.) That coupling was enforced only by prose warnings in the leaf YAML. A leaf that wired a console logger into the ACP config was a one-line, comment-only mistake away — exactly the stdout-purity footgun the examples guarded by hand. The three start.ts files also duplicated the Loader-boot tail, the .env loader, and (for ACP) snapshot-mode branching and the stdin-dispose lifecycle.
What shipped
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-core(packages/core/agent-core) — a Cordis bundle plugin for the providerless, executor-less, UI-less spine:timer+llm+ sessions + system-prompt + tools + agents + invariants +tool-bash+agent-loop, mounted as child plugins inside itsapply(ctx)viactx.plugin(...). This is the oldbase-core.ymlminusbash-local, plustimerand the loop, as code instead of a YAML include. The bundle forwardsagent-loop'sagentslist as its own config (export const Config = AgentLoop.Config, default[], the existingAgentLoop.Configshape in packages/core/agent-loop/src/index.ts) — so each app supplies its own pre-created agents. This is precisely the reason the oldbase-core.ymlgave for keepingagent-loopout of the shared core ("the examples disagree — stdio needs a pre-createdmain, acp needs none"); forwarding the config dissolves that objection — the loop is shared, the agents list is per-app. The bundle children register into the root service store, so a leaf-mounted sibling (the adapter, the executor) sees them exactly as a nestedplugin-includesubtree's services were seen before.@deepseek-ai/dsh-stdio-agent(packages/ui/stdio-agent) and@deepseek-ai/dsh-acp-agent(packages/ui/acp-agent) — app packages, each consumingdsh-agent-coreand baking in its coupled front-door cluster: stdio =ui-stdio+ console logger + a pre-createdmain; acp = theacpbridge + JSONL persistence + no stdout logger + no pre-created agents. The coupling becomes structurally unreachable from the leaf. They land under the existinguigroup alongsideacp, so no new package group (and notsconfig/packages/READMEgroup plumbing) was needed.start.tsis gone. Each app package exposes abin(dsh-stdio-agent/dsh-acp-agent); thedemo:*scripts invoke it (e.g.dsh-stdio-agent ./cordis.yml). The Loader-boot tail,.envloading, snapshot-mode selection, and stdin-dispose lifecycle moved into that bin, owned by the app. Thebin.tsfiles are coverage-excluded (a self-executing CLI entry, like the oldstart.ts) and driven by the keyless Loader-path tests.- Each leaf
cordis.ymlcollapses to backends + config: the LLM adapter (llm-deepseekwith apiKey/models, orllm-replay), the bash executor (bash-local),hmrfor the stdio demos (see the amendment below), and one app entry carrying the app's config (model, system prompt, persistence root — surfaced as the app package's ownConfig, which routes each value to wherever the app wires it: stdio onto its pre-created agent, acp onto the bridge plugin). - echo-agent folds onto
dsh-stdio-agent, swapping the LLM backend to the localmock-llmand adding the localecho-tool(plusbash-local, which the spine'stool-bashinjects) at the leaf — the clean demonstration of "swap the backend, keep the app".mock-llm.ts/echo-tool.tsstay as example-local teaching plugins. base.yml,base-core.yml, andacp-agent/acp-tail.ymlare retired — the spine they shared now lives indsh-agent-core.
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 stdio app's baked-in front-door cluster. Validating against the code, baking hmr into the dsh-stdio-agent 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 — its constructor throws withoutnode --expose-internals+ a liveloaderservice, 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 the way the console logger is — a stray hmr in the ACP config would not corrupt the JSON-RPC frames — so leaving it at the leaf costs none of the safety the coupling argument is about. The logger (the real coupling) stays baked in: the stdio app has it, the ACP app structurally cannot.
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
- Each example directory is
cordis.yml(+ the acpcordis.snapshot.yml) +README.md+ tests only — nostart.ts, no infra preamble;base.yml/base-core.yml/acp-tail.ymlare gone. demo:echo/demo:coding/demo:acprun via the app-packagebins.- The new packages carry the per-file 100% coverage gate and a README like every
@deepseek-ai/dsh-*. Each app package has a keyless real-load-path smoke that boots it through itsbin+ the cordis Loader (not a hand-builtctx.plugin({...})mount), guarding theunwrapExportsexport-shape bug class (postmortem 0001). - The ACP snapshot replay transcript is unchanged: the boot restructuring preserved the plugin set + load order, so
pnpm run test:snapshotstays green against the committed goldens with no re-record.
What we give up
- The bare-plugin-tree pedagogy. echo-agent's inlined
cordis.ymlshowed every plugin at once; the spine now lives behind a bundle, so seeing the whole tree means openingdsh-agent-core. 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-coreand 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).