Sfoglia il codice sorgente

refactor(examples): extract the app spine into dsh-agent-core + app packages

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.
Tianyi Cui 2 mesi fa
parent
commit
e2bde2902c
54 ha cambiato i file con 1632 aggiunte e 466 eliminazioni
  1. 19 9
      AGENTS.md
  2. 1 1
      docs/cookbook/extension-cookbook.md
  3. 19 0
      docs/module-graph.md
  4. 1 1
      docs/rfc/README.md
  5. 53 0
      docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md
  6. 1 1
      docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md
  7. 0 44
      docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md
  8. 6 6
      docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md
  9. 1 1
      examples/AGENTS.md
  10. 12 9
      examples/README.md
  11. 2 2
      examples/acp-agent/README.md
  12. 0 33
      examples/acp-agent/acp-tail.yml
  13. 30 23
      examples/acp-agent/cordis.snapshot.yml
  14. 41 23
      examples/acp-agent/cordis.yml
  15. 0 63
      examples/acp-agent/start.ts
  16. 7 3
      examples/acp-agent/tests/acp.e2e.ts
  17. 7 2
      examples/acp-agent/tests/snapshot-harness.ts
  18. 0 37
      examples/base-core.yml
  19. 0 37
      examples/base.yml
  20. 47 47
      examples/coding-agent/cordis.yml
  21. 0 30
      examples/coding-agent/start.ts
  22. 15 8
      examples/coding-agent/tests/keyless-smoke.e2e.ts
  23. 12 12
      examples/echo-agent/README.md
  24. 24 43
      examples/echo-agent/cordis.yml
  25. 0 16
      examples/echo-agent/start.ts
  26. 18 11
      examples/echo-agent/tests/echo.e2e.ts
  27. 4 0
      knip.json
  28. 3 3
      package.json
  29. 6 0
      packages/README.md
  30. 3 0
      packages/core/README.md
  31. 44 0
      packages/core/agent-core/README.md
  32. 46 0
      packages/core/agent-core/package.json
  33. 88 0
      packages/core/agent-core/src/index.ts
  34. 57 0
      packages/core/agent-core/tests/agent-core.spec.ts
  35. 42 0
      packages/core/agent-core/tsconfig.json
  36. 4 0
      packages/ui/README.md
  37. 39 0
      packages/ui/acp-agent/README.md
  38. 47 0
      packages/ui/acp-agent/package.json
  39. 100 0
      packages/ui/acp-agent/src/bin.ts
  40. 70 0
      packages/ui/acp-agent/src/index.ts
  41. 52 0
      packages/ui/acp-agent/tests/acp-agent.spec.ts
  42. 147 0
      packages/ui/acp-agent/tests/load-path.e2e.ts
  43. 30 0
      packages/ui/acp-agent/tsconfig.json
  44. 18 0
      packages/ui/acp-agent/tsdown.config.ts
  45. 60 0
      packages/ui/stdio-agent/README.md
  46. 53 0
      packages/ui/stdio-agent/package.json
  47. 70 0
      packages/ui/stdio-agent/src/bin.ts
  48. 98 0
      packages/ui/stdio-agent/src/index.ts
  49. 71 0
      packages/ui/stdio-agent/tests/stdio-agent.spec.ts
  50. 39 0
      packages/ui/stdio-agent/tsconfig.json
  51. 18 0
      packages/ui/stdio-agent/tsdown.config.ts
  52. 98 0
      pnpm-lock.yaml
  53. 3 0
      tsconfig.build.json
  54. 6 1
      vitest.config.ts

+ 19 - 9
AGENTS.md

@@ -52,6 +52,9 @@ packages/    Harness packages, grouped by role at packages/<group>/<pkg>/.
     tools/          tool registry + tools/execute waterfall
     agent/          Agent interface, registry, agent/* event vocabulary
     agent-loop/     THE concrete plugin: ReactLoopAgent + the loop driver
+    agent-core/     bundle plugin: the providerless/executor-less/UI-less spine
+                    (timer+llm+sessions+system-prompt+tools+agents+invariants+
+                    tool-bash+agent-loop) as code; forwards agent-loop's `agents`
   llm/            LLM capability family
     llm/            abstract LLM service + content-block vocabulary
     llm-deepseek/   DeepSeek API adapter (hand-rolled fetch/SSE)
@@ -67,21 +70,28 @@ packages/    Harness packages, grouped by role at packages/<group>/<pkg>/.
   ui/             product integration surfaces
     acp/            Agent Client Protocol bridge: drive the agent from an ACP
                     editor (Zed) over JSON-RPC stdio
+    stdio-agent/    stdio chat APP: agent-core spine + console logger + readline
+                    UI + a pre-created main agent + a bin (the demo:echo/coding
+                    front door)
+    acp-agent/      ACP server APP: agent-core spine + JSONL persistence + the
+                    acp bridge, NO stdout logger + a bin (the demo:acp front door)
   support/        dev/test/example infrastructure (lower compat expectations)
     invariants/     dev-mode event-contract invariants + session-log freeze
     ui-stdio/       minimal stdio (readline) UI plugin: renders agent/* events,
                     feeds stdin lines to the agent (shared by the demos)
     llm-replay/     record/replay adapter: short-circuits llm/stream from a
                     recorded session JSONL (keyless snapshot tests)
-examples/    Runnable demos (not workspaces; see examples/AGENTS.md). echo-agent
-             = mock model + echo tool + stdio UI + JSONL persistence, wired via
-             cordis.yml. coding-agent = the real thing: DeepSeek V4 + bash tools
-             (pnpm run demo:coding, needs DEEPSEEK_API_KEY).
-             acp-agent = the coding agent exposed as an ACP server over
-             JSON-RPC stdio (pnpm run demo:acp, needs DEEPSEEK_API_KEY).
-             base.yml = shared provider/tool core both real demos include
-             (= base-core.yml, the providerless core, + the llm-deepseek adapter;
-             base-core.yml is reused by the acp-agent snapshot-replay config).
+examples/    Runnable demos (not workspaces; see examples/AGENTS.md). Each is a
+             THIN leaf cordis.yml: it picks the swappable backends (an LLM adapter,
+             a bash executor) and loads ONE app package (dsh-stdio-agent or
+             dsh-acp-agent), which bundles the agent-core spine + front-door
+             cluster + boot glue (a bin). No start.ts. echo-agent = mock model +
+             echo tool on dsh-stdio-agent (pnpm run demo:echo, no key).
+             coding-agent = the real thing: DeepSeek V4 + bash tools on the same
+             app (pnpm run demo:coding, needs DEEPSEEK_API_KEY). acp-agent = the
+             coding agent as an ACP server on dsh-acp-agent (pnpm run demo:acp,
+             needs DEEPSEEK_API_KEY). cordis.snapshot.yml = the acp leaf with
+             llm-replay for keyless snapshot replay.
 docs/        architecture.md — the design doc. module-graph.md — generated
              inter-package dependency graph (Mermaid; `pnpm run gen-module-graph`).
              rfc/ — design decisions and proposals, one kind of doc grouped by

+ 1 - 1
docs/cookbook/extension-cookbook.md

@@ -83,4 +83,4 @@ export function apply(ctx: Context) {
 
 ## Runnable wirings
 
-Three complete examples load their plugin trees from `cordis.yml` with HMR: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check, `pnpm run demo:echo`), [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + the bash tool suite — the real thing, `pnpm run demo:coding`), and [`examples/acp-agent`](../../examples/acp-agent) (the same coding agent exposed as an ACP server over JSON-RPC stdio — the client-driver shape, `pnpm run demo:acp`). The two real demos share their provider/tool core via [`examples/base.yml`](../../examples/base.yml).
+Three complete examples load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check, `pnpm run demo:echo`), [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + the bash tool suite — the real thing, `pnpm run demo:coding`), and [`examples/acp-agent`](../../examples/acp-agent) (the same coding agent exposed as an ACP server over JSON-RPC stdio — the client-driver shape, `pnpm run demo:acp`). Each leaf is now just its swappable backends plus an app-package entry: the stdio demos load [`@deepseek-ai/dsh-stdio-agent`](../../packages/ui/stdio-agent), the ACP demo loads [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent), and both app packages share the spine via the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) bundle.

+ 19 - 0
docs/module-graph.md

@@ -49,6 +49,22 @@ graph TD
   tool-bash --> bash
   tool-bash --> llm
   tool-bash --> tools
+  agent-core --> agent
+  agent-core --> agent-loop
+  agent-core --> invariants
+  agent-core --> llm
+  agent-core --> session
+  agent-core --> system-prompt
+  agent-core --> tool-bash
+  agent-core --> tools
+  acp-agent --> acp
+  acp-agent --> agent-core
+  acp-agent --> session-persistence-jsonl
+  stdio-agent --> agent
+  stdio-agent --> agent-core
+  stdio-agent --> session
+  stdio-agent --> session-persistence-jsonl
+  stdio-agent --> ui-stdio
 ```
 
 | Package | Depends on |
@@ -72,3 +88,6 @@ graph TD
 | `acp` | `agent`, `llm`, `session`, `session-persistence`, `tools` |
 | `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` |
 | `tool-bash` | `agent`, `bash`, `llm`, `tools` |
+| `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` |
+| `acp-agent` | `acp`, `agent-core`, `session-persistence-jsonl` |
+| `stdio-agent` | `agent`, `agent-core`, `session`, `session-persistence-jsonl`, `ui-stdio` |

+ 1 - 1
docs/rfc/README.md

@@ -58,7 +58,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
 |---|---|
 | [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 |
 | [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 |
-| [Extract example apps into packages](proposed/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 |
 
 ### Process
 
@@ -115,6 +114,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
 | [Agent lifecycle and ownership seams](implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md) | 2026-06-18 |
 | [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 |
 | [Branded IDs everywhere they belong](implemented/architecture/2026-06-20-branded-ids.md) | 2026-06-20 |
+| [Extract example apps into packages](implemented/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 |
 
 ### Process
 

+ 53 - 0
docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md

@@ -0,0 +1,53 @@
+# 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](../feature/2026-06-18-acp-terminal-and-tool-rendering.md) 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](2026-06-13-capability-seams.md): 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](../../../../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 its `apply(ctx)` via `ctx.plugin(...)`. This is the old `base-core.yml` **minus** `bash-local`, **plus** `timer` and the loop, as code instead of a YAML include. The bundle **forwards** `agent-loop`'s `agents` list as its own config (`export const Config = AgentLoop.Config`, default `[]`, the existing `AgentLoop.Config` shape in [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)) — so each app supplies its own pre-created agents. This is precisely the reason the old `base-core.yml` gave for keeping `agent-loop` *out* of the shared core ("the examples disagree — stdio needs a pre-created `main`, 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 nested `plugin-include` subtree's services were seen before.
+- **`@deepseek-ai/dsh-stdio-agent`** ([packages/ui/stdio-agent](../../../../packages/ui/stdio-agent)) and **`@deepseek-ai/dsh-acp-agent`** ([packages/ui/acp-agent](../../../../packages/ui/acp-agent)) — app packages, each consuming `dsh-agent-core` and **baking in its coupled front-door cluster**: stdio = `ui-stdio` + console logger + a pre-created `main`; acp = the `acp` bridge + JSONL persistence + **no stdout logger** + no pre-created agents. The coupling becomes structurally unreachable from the leaf. They land under the existing `ui` group alongside `acp`, so no new package group (and no `tsconfig`/`packages/README` group plumbing) was needed.
+- **`start.ts` is gone.** Each app package exposes a `bin` (`dsh-stdio-agent` / `dsh-acp-agent`); the `demo:*` scripts invoke it (e.g. `dsh-stdio-agent ./cordis.yml`). The Loader-boot tail, `.env` loading, snapshot-mode selection, and stdin-dispose lifecycle moved into that bin, owned by the app. The `bin.ts` files are coverage-excluded (a self-executing CLI entry, like the old `start.ts`) and driven by the keyless Loader-path tests.
+- **Each leaf `cordis.yml` collapses** to backends + config: the LLM adapter (`llm-deepseek` with apiKey/models, or `llm-replay`), the bash executor (`bash-local`), `hmr` for 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 own `Config`, 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 local `mock-llm` and adding the local `echo-tool` (plus `bash-local`, which the spine's `tool-bash` injects) at the leaf — the clean demonstration of "swap the backend, keep the app". `mock-llm.ts` / `echo-tool.ts` stay as example-local teaching plugins.
+- **`base.yml`, `base-core.yml`, and `acp-agent/acp-tail.yml` are retired** — the spine they shared now lives in `dsh-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:
+
+1. `@cordisjs/plugin-hmr` is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader` service, so it can only run in the real `demo:*`/bin subprocess, never in the in-process unit/coverage tier.
+2. The in-process test tier (vitest) cannot even *import* the vendored `hmr` module (its class-decorator `@Inject` form fails under Vite's transform), so a package whose `apply` statically 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 acp `cordis.snapshot.yml`) + `README.md` + tests only — no `start.ts`, no infra preamble; `base.yml`/`base-core.yml`/`acp-tail.yml` are gone.
+- `demo:echo` / `demo:coding` / `demo:acp` run via the app-package `bin`s.
+- 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 its `bin` + the cordis Loader (not a hand-built `ctx.plugin({...})` mount), guarding the `unwrapExports` export-shape bug class ([postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)).
+- The ACP snapshot **replay** transcript is unchanged: the boot restructuring preserved the plugin set + load order, so `pnpm run test:snapshot` stays green against the committed goldens with no re-record.
+
+## What we give up
+
+- **The bare-plugin-tree pedagogy.** echo-agent's inlined `cordis.yml` showed every plugin at once; the spine now lives behind a bundle, so seeing the whole tree means opening `dsh-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](../../rejected/architecture/2026-06-20-providerless-example-base.md): renaming `base.yml` to the providerless core is moot once the spine moves into `dsh-agent-core` and the `base*.yml` files are deleted.
+- Builds on the [capability-seams](2026-06-13-capability-seams.md) interface/implementation/consumer split — backends and presentation stay leaf choices; the spine is the shared bundle.
+- Complements [Reorganize packages into a modular hierarchy](2026-06-20-package-hierarchy.md): the new app/core packages slot into existing groups under that hierarchy (`core` for the reusable spine bundle, `ui` for the app-specific front doors).

+ 1 - 1
docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md

@@ -46,7 +46,7 @@ Replay is positional: the Nth `stream()` call serves the Nth `ReplayEntry`. This
 
 Recording runs the scenario with the real `llm-deepseek` adapter and the JSONL persistence backend, then copies the produced `.jsonl` into the scenario dir. Per-event appends are durable, but the harness shuts the subprocess down gracefully (close stdin → `await ctx.dispose()`) before harvesting so the final events are flushed. `llm-replay` itself does no recording — it is replay-only.
 
-`examples/base.yml` always loads `@deepseek-ai/dsh-llm-deepseek`, whose `apply` throws when no API key is present ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts)). So replay cannot reuse the normal config — it uses a dedicated `examples/acp-agent/cordis.snapshot.yml` that installs `llm-replay` in place of the adapter. To avoid duplicating the rest of the tree, the providerless core is factored into `examples/base-core.yml` (shared by `base.yml = base-core + llm-deepseek` and the replay config = `base-core + llm-replay`), and the agent-loop/persistence/ACP-bridge tail into `examples/acp-agent/acp-tail.yml` (shared by `cordis.yml` and the replay config). Recording reuses the normal `cordis.yml` (real adapter) — its persistence root reads `$DSH_SNAPSHOT_SESSIONS_ROOT` when the harness sets it — so there is no separate record config. In replay mode `start.ts` skips `.env` loading so a stray key cannot trigger a live call.
+The ACP server app loads `@deepseek-ai/dsh-llm-deepseek`, whose `apply` throws when no API key is present ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts)). So replay cannot reuse the normal config — it uses a dedicated `examples/acp-agent/cordis.snapshot.yml` that installs `llm-replay` in place of the adapter. The rest of the tree is not duplicated: both the normal `examples/acp-agent/cordis.yml` and the replay config load the same `@deepseek-ai/dsh-acp-agent` app entry (which bundles the agent-core spine + JSONL persistence + the ACP bridge), differing only in the LLM backend (`llm-deepseek` vs `llm-replay`) and the bash executor line. Recording reuses the normal `cordis.yml` (real adapter) — its persistence root reads `$DSH_SNAPSHOT_SESSIONS_ROOT` when the harness sets it — so there is no separate record config. The `dsh-acp-agent` bin selects `cordis.snapshot.yml` for `DSH_SNAPSHOT=replay` and skips `.env` loading in that mode so a stray key cannot trigger a live call.
 
 ### Two surfaces: normalize, then compare
 

+ 0 - 44
docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md

@@ -1,44 +0,0 @@
-# RFC: Extract example apps into packages
-
-Status: proposed
-
-## Problem
-
-An example folder is supposed to be *thin* — the variable wiring of a demo, not the demo's machinery. Today it is thick. Each example carries 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, and per-example `agent-loop`/persistence/system-prompt config. The actual app — the spine of services every agent needs — is spread across the leaf and the [base.yml](../../../../examples/base.yml) / [base-core.yml](../../../../examples/base-core.yml) / [acp-tail.yml](../../../../examples/acp-agent/acp-tail.yml) includes.
-
-The deeper problem is a **coupled front-door cluster** that lives at the leaf with nothing enforcing it. Choosing the ACP bridge over `ui-stdio` is 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), omit `hmr` (the editor owns the subprocess), and pre-create **no** agents (ACP `session/new` creates them on demand), whereas the stdio app needs a console logger, `hmr`, 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.) Today that coupling is enforced only by prose warnings in [acp-agent/cordis.yml](../../../../examples/acp-agent/cordis.yml) and [base-core.yml](../../../../examples/base-core.yml). A leaf that wires a console logger into the ACP config is a one-line, comment-only mistake away — exactly the [stdout-purity footgun](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) the examples guard by hand. The three `start.ts` files also duplicate the Loader-boot tail, the `.env` loader, and (for ACP) snapshot-mode branching and the stdin-dispose lifecycle.
-
-## Proposal
-
-Make each example **mostly an invocation of an app package**, splitting the wiring along the existing [interface / implementation / consumer seam](../../implemented/architecture/2026-06-13-capability-seams.md): 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`** — a Cordis bundle plugin for the providerless, executor-less, UI-less spine: `timer` + `llm` + sessions + system-prompt + tools + agents + invariants + `tool-bash` + `agent-loop`. This is today's [base-core.yml](../../../../examples/base-core.yml) **minus** `bash-local`, **plus** `timer` and the loop, as code instead of a YAML include. The bundle **forwards** `agent-loop`'s `agents` list as its own config (default `[]`, exactly the existing `AgentLoop.Config` shape in [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)) — so each app supplies its own pre-created agents. This is precisely the reason [base-core.yml](../../../../examples/base-core.yml) gives today for keeping `agent-loop` *out* of the shared core ("the examples disagree — stdio needs a pre-created `main`, acp needs none"); forwarding the config dissolves that objection — the loop is shared, the agents list is per-app.
-- **`@deepseek-ai/dsh-stdio-agent`** and **`@deepseek-ai/dsh-acp-agent`** — app packages, each consuming `dsh-agent-core` and **baking in its coupled front-door cluster**: stdio = `ui-stdio` + console logger + `hmr` + a pre-created `main`; acp = the `acp` bridge + **no stdout logger** + no `hmr` + no pre-created agents. The coupling becomes structurally unreachable from the leaf.
-- **Drop `start.ts`.** Each app package exposes a `bin`; the `demo:*` scripts invoke it (e.g. `dsh-stdio-agent ./cordis.yml`). The Loader-boot tail, `.env` loading, snapshot-mode selection, and stdin-dispose lifecycle move into that bin, owned by the app.
-- **Collapse each leaf `cordis.yml`** to backends + config: the LLM adapter (`llm-deepseek` with apiKey/models, or `llm-replay`), the bash executor (`bash-local`), and one app-bundle entry carrying the app's config (model, system prompt, persistence root — surfaced as the app package's own `Config`, which routes each value to wherever the app wires it: stdio onto its pre-created agent, acp onto the bridge plugin). A handful of entries, no infra preamble.
-- **Fold echo-agent onto `dsh-stdio-agent`**, swapping the LLM backend to the local `mock-llm` and adding the local `echo-tool` at the leaf — the clean demonstration of "swap the backend, keep the app". `mock-llm.ts` / `echo-tool.ts` stay as example-local teaching plugins.
-- **Retire** [base.yml](../../../../examples/base.yml), [base-core.yml](../../../../examples/base-core.yml), and [acp-tail.yml](../../../../examples/acp-agent/acp-tail.yml) — the spine they shared now lives in `dsh-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.
-
-## Why not keep the wiring in shared YAML includes?
-
-The `base*.yml`/`acp-tail.yml` includes already dedupe 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 stays 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.
-
-## Acceptance criteria
-
-- Each example directory is `cordis.yml` + `README.md` + tests only — no `start.ts`, no infra preamble; `base.yml`/`base-core.yml`/`acp-tail.yml` are gone.
-- `demo:echo` / `demo:coding` / `demo:acp` run via the app-package `bin`s.
-- `pnpm run test`, `pnpm run test:snapshot` (re-recorded), `pnpm run typecheck`, `pnpm run knip`, `pnpm run publint`, and `pnpm run doc-sync` are green; the new packages carry the per-file 100% coverage gate and a README like every `@deepseek-ai/dsh-*`.
-
-## What we give up
-
-- **The bare-plugin-tree pedagogy.** echo-agent's inlined `cordis.yml` showed every plugin at once; the spine now lives behind a bundle, so seeing the whole tree means opening `dsh-agent-core`. The app package's README must carry that teaching weight.
-- **A layer of indirection.** "What does this demo load?" becomes a package read, not a single YAML scan.
-- **Migration cost** (the implementing PR, not this one): three new packages, three leaf rewrites, the boot glue moved into bins, re-recorded ACP snapshots, and rewritten example READMEs + [examples/AGENTS.md](../../../../examples/AGENTS.md).
-
-## Related
-
-- Supersedes [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md): renaming `base.yml` to the providerless core is moot once the spine moves into `dsh-agent-core` and the `base*.yml` files are deleted.
-- Builds on the [capability-seams](../../implemented/architecture/2026-06-13-capability-seams.md) interface/implementation/consumer split — backends and presentation stay leaf choices; the spine is the shared bundle.
-- Complements [Reorganize packages into a modular hierarchy](../../implemented/architecture/2026-06-20-package-hierarchy.md): the new app/core packages slot into a group under that hierarchy (a product group for the reusable core bundle, or alongside the examples for app-specific wiring).

+ 6 - 6
docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md

@@ -1,23 +1,23 @@
 # RFC: Make the shared example base providerless
 
-Status: rejected — superseded by [Extract example apps into packages](../../proposed/architecture/2026-06-20-extract-example-app-packages.md), which moves the spine into a `dsh-agent-core` bundle and deletes the `base*.yml` files, so there is no shared base YAML left to rename.
+Status: rejected — superseded by [Extract example apps into packages](../../implemented/architecture/2026-06-20-extract-example-app-packages.md), which moves the spine into a `dsh-agent-core` bundle and deletes the `base*.yml` files, so there is no shared base YAML left to rename.
 
 ## Problem
 
-The examples have two shared base files: [examples/base-core.yml](../../../../examples/base-core.yml) is providerless, while [examples/base.yml](../../../../examples/base.yml) includes that core plus the real `llm-deepseek` adapter. Snapshot replay needs the providerless core with `llm-replay`, because loading the real adapter without a key throws. The normal demos need the real adapter. The result is a naming inversion: the file named `base.yml` is not the reusable base for all examples, while the true base is `base-core.yml`.
+The examples had two shared base files: `examples/base-core.yml` was providerless, while `examples/base.yml` included that core plus the real `llm-deepseek` adapter. Snapshot replay needs the providerless core with `llm-replay`, because loading the real adapter without a key throws. The normal demos need the real adapter. The result was a naming inversion: the file named `base.yml` was not the reusable base for all examples, while the true base was `base-core.yml`.
 
-The split is understandable, but it makes every config explanation longer. It also leads to awkward test setup like a keyless smoke test carrying a dummy API key so an adapter can boot even though the model is not called.
+The split was understandable, but it made every config explanation longer. It also led to awkward test setup like a keyless smoke test carrying a dummy API key so an adapter could boot even though the model is not called.
 
 ## Proposal
 
-Rename the providerless core to [examples/base.yml](../../../../examples/base.yml) and make adapter selection explicit in each concrete example. The coding and ACP real configs add a tiny `llm-deepseek` include or local block; snapshot config adds `llm-replay`. Delete [examples/base-core.yml](../../../../examples/base-core.yml).
+Rename the providerless core to `examples/base.yml` and make adapter selection explicit in each concrete example. The coding and ACP real configs add a tiny `llm-deepseek` include or local block; snapshot config adds `llm-replay`. Delete `examples/base-core.yml`.
 
 The shared base should contain only provider-neutral services and tools: `llm`, sessions, system prompt, tools, agents, invariants, bash executor, and bash tool schemas. Anything that chooses a model provider belongs at the leaf config.
 
 ## Acceptance criteria
 
-- [examples/base.yml](../../../../examples/base.yml) is providerless.
-- [examples/base-core.yml](../../../../examples/base-core.yml) is deleted.
+- `examples/base.yml` is providerless.
+- `examples/base-core.yml` is deleted.
 - Real demo configs explicitly add the DeepSeek adapter.
 - Snapshot replay config includes the same providerless base and its replay adapter.
 - The [examples README](../../../../examples/README.md), example-specific READMEs, and RFC references stop explaining "base = base-core plus adapter".

+ 1 - 1
examples/AGENTS.md

@@ -2,7 +2,7 @@
 
 Runnable demos that show how the harness is wired. **Examples are NOT workspaces** — each `examples/*/package.json` is a private, dependency-free stub with no build. They are booted as unbuilt `tsx` subprocesses via the cordis Loader reading a `cordis.yml`; the `@deepseek-ai/dsh-*` plugin names in those YAML files resolve through the root `tsconfig.json` `paths` map, not through `node_modules`.
 
-Because examples are not under the `packages/*/src` coverage gate, an example that grows real, reusable *logic* should extract it into a `packages/` package (where it gets the per-file 100% gate and a README). Keep only example-specific glue here: `start.ts`, the `cordis.yml` wiring, demo-only mocks/teaching artifacts, and the e2e/snapshot scenarios.
+Because examples are not under the `packages/*/src` coverage gate, an example that grows real, reusable *logic* should extract it into a `packages/` package (where it gets the per-file 100% gate and a README). Keep only example-specific glue here: the `cordis.yml` wiring, demo-only mocks/teaching artifacts, and the e2e/snapshot scenarios. There is no `start.ts` — the boot glue (Loader tail, `.env` load, snapshot-mode selection, stdin-dispose lifecycle) lives in each app package's `bin` (`@deepseek-ai/dsh-stdio-agent`, `@deepseek-ai/dsh-acp-agent`), which the `demo:*` scripts invoke against the leaf `cordis.yml`.
 
 ## Every example ships e2e smokes (keyless + with-key)
 

+ 12 - 9
examples/README.md

@@ -1,23 +1,26 @@
 # Examples
 
-Runnable demos (not workspaces) that showcase how the harness is wired.
+Runnable demos (not workspaces) that showcase how the harness is wired. Each example is now a **thin leaf**: a `cordis.yml` that picks the swappable backends (an LLM adapter, a bash executor) and loads ONE app package, plus any demo-only mocks. The composition — the spine, the front-door cluster, and the boot glue — lives in the app packages ([`@deepseek-ai/dsh-stdio-agent`](../packages/ui/stdio-agent), [`@deepseek-ai/dsh-acp-agent`](../packages/ui/acp-agent)) and the [`@deepseek-ai/dsh-agent-core`](../packages/core/agent-core) bundle they share. There is no `start.ts`; the `demo:*` scripts invoke each app package's `bin`.
 
 ## echo-agent
 
-A mock model + echo tool + stdio UI + JSONL persistence demo. Demonstrates:
+A mock model + echo tool on the stdio chat app — the all-mock skeleton. The leaf swaps `dsh-stdio-agent`'s LLM backend to a local `mock-echo` adapter and adds a local `echo` tool. Demonstrates:
 
-- Loading plugins from a `cordis.yml` via `@cordisjs/plugin-loader` + `@cordisjs/plugin-include`
+- A thin leaf `cordis.yml` loading the `@deepseek-ai/dsh-stdio-agent` app
 - Registering a mock `LlmAdapter` (streaming scripted responses)
 - Registering a tool via `ctx.tools.register()`
-- Persisting session events to JSONL via the `session/event` + `session/flush` pattern
-- A minimal stdio UI consuming `agent/stream-chunk` and session events
+- "Swap the backend, keep the app" — the only difference from `coding-agent` is the adapter
 
-Run with: `pnpm run demo:echo`
-
-When prompted, type "echo <something>" to trigger a tool call round-trip.
+Run with: `pnpm run demo:echo`. When prompted, type "echo <something>" to trigger a tool call round-trip.
 
 ## coding-agent
 
-The real thing: DeepSeek V4 + the bash tool suite + stdio chat + JSONL persistence, wired from `cordis.yml`. Where echo-agent proves the skeleton with mocks, this is a usable coding assistant.
+The real thing: DeepSeek V4 + the bash tool suite on the same `@deepseek-ai/dsh-stdio-agent` app. Where echo-agent proves the skeleton with mocks, this is a usable coding assistant.
 
 Run with: `pnpm run demo:coding` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [coding-agent/README.md](coding-agent/README.md) for details.
+
+## acp-agent
+
+The same coding agent exposed as an **Agent Client Protocol (ACP)** server over JSON-RPC stdio, via the [`@deepseek-ai/dsh-acp-agent`](../packages/ui/acp-agent) app — drive it from Zed or any other ACP client. Also the home of the keyless snapshot tests.
+
+Run with: `pnpm run demo:acp` (needs `DEEPSEEK_API_KEY`). See [acp-agent/README.md](acp-agent/README.md) for the Zed setup and the snapshot-test design.

+ 2 - 2
examples/acp-agent/README.md

@@ -6,11 +6,11 @@ The DeepSeek Harness coding agent exposed as an **Agent Client Protocol (ACP)**
 pnpm run demo:acp          # needs DEEPSEEK_API_KEY (repo-root .env or env)
 ```
 
-This boots `@deepseek-ai/dsh-acp` over the shared provider/tool core (`../base.yml`), with `agent-loop` configured with **no pre-created agents** (ACP `session/new` creates them on demand) and JSONL session persistence (so `session/load` works).
+This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent) app (which bundles the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) spine, JSONL session persistence, and the `@deepseek-ai/dsh-acp` bridge — with **no pre-created agents**, since ACP `session/new` creates them on demand) plus the two swappable backends (`llm-deepseek`, `bash-local`). The app package bakes in the no-stdout-logger cluster, so the stdout-purity guarantee is a property of the artifact, not a leaf convention.
 
 ## stdout is the protocol
 
-This example loads **no stdout logger** — `stdout` carries the JSON-RPC frames, and any other write corrupts them. Do not add `@cordisjs/plugin-logger-console` or a stdio UI here. Use a stderr exporter if you need logs.
+This example loads **no stdout logger** — `stdout` carries the JSON-RPC frames, and any other write corrupts them. `@deepseek-ai/dsh-acp-agent` contains no logger entry, so the footgun is structurally unreachable from this leaf. Use a stderr exporter if you need logs.
 
 ## Zed configuration
 

+ 0 - 33
examples/acp-agent/acp-tail.yml

@@ -1,33 +0,0 @@
-# The acp-agent "tail" shared by every acp-agent config (the normal demo, the
-# snapshot RECORD path which reuses cordis.yml, and the snapshot REPLAY config):
-# agent-loop (no pre-created agents — ACP session/new creates them on demand),
-# JSONL session persistence, and the ACP bridge with its system prompt. The
-# providerless core + an LLM adapter are included BEFORE this tail by each
-# config; nothing here loads an adapter, so the tail is provider-agnostic.
-#
-# Persistence root: $DSH_SNAPSHOT_SESSIONS_ROOT when the snapshot harness sets
-# it (so it can harvest / isolate the log), else ./.sessions for the demo.
-
-- id: agent-loop
-  name: '@deepseek-ai/dsh-agent-loop'
-  config:
-    agents: []
-
-- id: session-persistence
-  name: '@deepseek-ai/dsh-session-persistence-jsonl'
-  config:
-    root: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
-
-- id: acp
-  name: '@deepseek-ai/dsh-acp'
-  config:
-    model: deepseek-v4-flash
-    systemPrompt: |
-      You are a coding assistant driven over the Agent Client Protocol.
-
-      Your only tools are bash (plus bash_output/bash_kill for background
-      tasks). Do ALL file operations through bash: read with cat/sed/head,
-      search with grep, write with heredocs (cat <<'EOF' > file), edit with
-      sed or a rewrite. Each bash call runs in a fresh shell — pass workdir
-      instead of cd. Check the [exit code: N] marker; verify your work. Keep
-      answers brief and factual.

+ 30 - 23
examples/acp-agent/cordis.snapshot.yml

@@ -1,32 +1,39 @@
-# Snapshot-test REPLAY config: the acp-agent plugin tree with the model replaced
-# by llm-replay (serves a recorded session JSONL — no API key, no network).
+# Snapshot-test REPLAY config: the acp-agent plugin tree with the model backend
+# swapped to llm-replay (serves a recorded session JSONL — no API key, no
+# network). The dsh-acp-agent bin selects this file for DSH_SNAPSHOT=replay.
 #
-# It reuses ../base-core.yml (the providerless core) + ./acp-tail.yml (agent-
-# loop + persistence + the ACP bridge), the SAME pieces cordis.yml shares — only
-# the LLM adapter differs: llm-replay here, llm-deepseek there. It can't reuse
-# ../base.yml because that loads llm-deepseek, whose apply() throws without
-# DEEPSEEK_API_KEY, killing a keyless replay run at boot.
+# Same app as cordis.yml (@deepseek-ai/dsh-acp-agent: the agent-core spine +
+# JSONL persistence + the ACP bridge) — only the LLM backend differs: llm-replay
+# here, llm-deepseek there. It can't reuse the real adapter because llm-deepseek's
+# apply() throws without DEEPSEEK_API_KEY, killing a keyless replay run at boot.
 #
-# stdout is reserved for the ACP JSON-RPC protocol — no stdout logger (see
-# cordis.yml). The replay fixture path comes from $DSH_SNAPSHOT_FILE (and an
-# optional $DSH_SNAPSHOT_OVERRIDE sidecar), set by the snapshot harness.
-
-- id: timer
-  name: '@cordisjs/plugin-timer'
-
-# Providerless core (everything base.yml has EXCEPT the llm-deepseek adapter).
-- id: base-core
-  name: '@cordisjs/plugin-include'
-  config:
-    path: '../base-core.yml'
+# stdout is reserved for the ACP JSON-RPC protocol — no stdout logger (the app
+# package omits it). The replay fixture path comes from $DSH_SNAPSHOT_FILE (and
+# an optional $DSH_SNAPSHOT_OVERRIDE sidecar), set by the snapshot harness.
 
 # The replay adapter: short-circuits llm/stream with the recorded log's chunks,
 # in place of llm-deepseek.
 - id: llm-replay
   name: '@deepseek-ai/dsh-llm-replay'
 
-# agent-loop + persistence + the ACP bridge — shared with cordis.yml.
-- id: acp-tail
-  name: '@cordisjs/plugin-include'
+# Local bash executor (the agent's only tool, via agent-core's tool-bash schema).
+- id: bash
+  name: '@deepseek-ai/dsh-bash-local'
   config:
-    path: './acp-tail.yml'
+    timeoutMs: 60000
+
+# The ACP server app — identical to cordis.yml's entry.
+- id: acp-agent
+  name: '@deepseek-ai/dsh-acp-agent'
+  config:
+    model: deepseek-v4-flash
+    persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
+    systemPrompt: |
+      You are a coding assistant driven over the Agent Client Protocol.
+
+      Your only tools are bash (plus bash_output/bash_kill for background
+      tasks). Do ALL file operations through bash: read with cat/sed/head,
+      search with grep, write with heredocs (cat <<'EOF' > file), edit with
+      sed or a rewrite. Each bash call runs in a fresh shell — pass workdir
+      instead of cd. Check the [exit code: N] marker; verify your work. Keep
+      answers brief and factual.

+ 41 - 23
examples/acp-agent/cordis.yml

@@ -1,30 +1,48 @@
-# The acp-agent plugin tree, loaded via @cordisjs/plugin-include. Also the
-# snapshot RECORD config (start.ts selects it for DSH_SNAPSHOT=record): a real
-# llm-deepseek run whose persisted log the snapshot harness harvests.
+# The acp-agent plugin tree: the ACP server. Also the snapshot RECORD config
+# (the dsh-acp-agent bin selects it for DSH_SNAPSHOT=record): a real llm-deepseek
+# run whose persisted log the snapshot harness harvests. Just the two swappable
+# backends — the DeepSeek adapter and the local bash executor — plus the ACP
+# server app (@deepseek-ai/dsh-acp-agent), which bundles the agent-core spine,
+# JSONL persistence, and the ACP bridge.
 #
-# CRITICAL: this example loads NO stdout logger (no @cordisjs/plugin-logger-
-# console, no stdio-chat). stdout is reserved for the ACP JSON-RPC protocol —
-# anything else written there corrupts the frames (see packages/acp, RFC 010 §
-# Risks). Use a stderr exporter if you need logging. The timer plugin is loaded
-# (no stdout writes); hmr is omitted (an editor manages the subprocess).
+# CRITICAL: this tree loads NO stdout logger and NO hmr — stdout is reserved for
+# the ACP JSON-RPC protocol (see packages/ui/acp). That guarantee is now a
+# property of @deepseek-ai/dsh-acp-agent (it contains no logger entry), not a
+# leaf convention: there is no logger here to get wrong.
 #
-# Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) in the
-# environment — start.ts loads the gitignored repo-root .env first.
+# Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL)  the
+# dsh-acp-agent bin loads the gitignored repo-root .env first (on STDERR only).
 
-- id: timer
-  name: '@cordisjs/plugin-timer'
+# The DeepSeek adapter.
+- id: llm-deepseek
+  name: '@deepseek-ai/dsh-llm-deepseek'
+  config:
+    apiKey: !!js process.env.DEEPSEEK_API_KEY
+    baseURL: !!js process.env.DEEPSEEK_BASE_URL
+    models:
+      - deepseek-v4-flash
+      - deepseek-v4-pro
 
-# Shared provider/tool core, INCLUDING the real llm-deepseek adapter. Nested
-# include resolved relative to THIS file's directory.
-- id: base
-  name: '@cordisjs/plugin-include'
+# Local bash executor (the agent's only tool, via agent-core's tool-bash schema).
+- id: bash
+  name: '@deepseek-ai/dsh-bash-local'
   config:
-    path: '../base.yml'
+    timeoutMs: 60000
 
-# agent-loop (no pre-created agents) + JSONL persistence + the ACP bridge.
-# Shared with the snapshot REPLAY config (cordis.snapshot.yml) so the three
-# acp-agent configs don't drift.
-- id: acp-tail
-  name: '@cordisjs/plugin-include'
+# The ACP server app: the agent-core spine + JSONL persistence + the ACP bridge.
+# Persistence root: $DSH_SNAPSHOT_SESSIONS_ROOT when the snapshot harness sets it
+# (so it can harvest / isolate the log), else ./.sessions for the demo.
+- id: acp-agent
+  name: '@deepseek-ai/dsh-acp-agent'
   config:
-    path: './acp-tail.yml'
+    model: deepseek-v4-flash
+    persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
+    systemPrompt: |
+      You are a coding assistant driven over the Agent Client Protocol.
+
+      Your only tools are bash (plus bash_output/bash_kill for background
+      tasks). Do ALL file operations through bash: read with cat/sed/head,
+      search with grep, write with heredocs (cat <<'EOF' > file), edit with
+      sed or a rewrite. Each bash call runs in a fresh shell — pass workdir
+      instead of cd. Check the [exit code: N] marker; verify your work. Keep
+      answers brief and factual.

+ 0 - 63
examples/acp-agent/start.ts

@@ -1,63 +0,0 @@
-import { fileURLToPath, pathToFileURL } from 'node:url'
-import { Context } from 'cordis'
-import Loader from '@cordisjs/plugin-loader'
-
-// Snapshot-test modes (set by the snapshot harness via env):
-//   DSH_SNAPSHOT=replay  — load cordis.snapshot.yml (providerless; llm-replay
-//                          serves a recorded session log). Skip .env so a stray
-//                          key can never trigger a live model call.
-//   DSH_SNAPSHOT=record  — load the normal cordis.yml (the real llm-deepseek
-//                          adapter + persistence) so a real run can be harvested
-//                          (the persistence root is redirected by env).
-// Absent — the normal demo (cordis.yml), driven by a real editor.
-const snapshotMode = process.env.DSH_SNAPSHOT
-const configPath = snapshotMode === 'replay' ? './cordis.snapshot.yml' : './cordis.yml'
-
-// Load DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL from a gitignored repo-root .env
-// (Node native). Absent file is fine — the environment may already carry them.
-// In REPLAY mode we deliberately skip this: replay must never reach the network,
-// so we don't want a present .env to enable a live call.
-//
-// IMPORTANT: this server speaks ACP JSON-RPC on stdout. Do NOT add any
-// stdout logging here or in cordis.yml — it would corrupt the protocol frames.
-// A present-but-unreadable/malformed .env is a real misconfiguration: surface
-// it on STDERR (never stdout) rather than silently running with the wrong env.
-if (snapshotMode !== 'replay') {
-  try {
-    process.loadEnvFile(new URL('../../.env', import.meta.url).pathname)
-  } catch (error) {
-    if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
-      process.stderr.write(`acp-agent: failed to load .env: ${String(error)}\n`)
-    }
-    // ENOENT (no .env) is fine — rely on the ambient environment.
-  }
-}
-
-// Resolve relative cordis.yml paths from the repo root no matter where the
-// editor launches this demo command.
-process.chdir(fileURLToPath(new URL('../..', import.meta.url)))
-
-const ctx = new Context()
-ctx.baseUrl = pathToFileURL(import.meta.dirname).href + '/'
-
-await ctx.plugin(Loader)
-await ctx.loader.create({
-  name: '@cordisjs/plugin-include',
-  config: {
-    path: configPath,
-  },
-})
-
-// Graceful shutdown for snapshot runs (both replay and record): when the client
-// closes our stdin (it is done driving the session), dispose the whole context.
-// Disposal awaits the agent-loop teardown and the persistence backend's final
-// `session/flush`, so the session `.jsonl` is fully written before the process
-// exits and the harness harvests it (and the subprocess exits cleanly so the
-// harness's waitForExit resolves). (In a normal editor session stdin stays open
-// for the connection's lifetime; the editor kills the process, so this never
-// fires.)
-if (snapshotMode !== undefined) {
-  process.stdin.on('end', () => {
-    void ctx.fiber.dispose().then(() => { process.exit(0) })
-  })
-}

+ 7 - 3
examples/acp-agent/tests/acp.e2e.ts

@@ -26,7 +26,11 @@ import {
  * WITHOUT a key, since it only needs the server to boot and answer initialize.
  */
 
-const startScript = fileURLToPath(new URL('../start.ts', import.meta.url))
+// The dsh-acp-agent bin (the demo:acp entry) and this example's cordis.yml. The
+// bin resolves its config-path arg from CWD; the subprocess runs from a temp
+// workdir, so pass the example config's ABSOLUTE path.
+const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url))
+const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
 // Resolve tsx's loader to an ABSOLUTE path: the subprocess runs with cwd set to
 // a temp workdir (this test launches there and uses it as the session cwd; the
 // bridge no longer requires cwd === the launch dir, but a temp dir keeps the
@@ -55,7 +59,7 @@ interface Spawned {
 function spawnAcpAgent(cwd: string, env: NodeJS.ProcessEnv = process.env): Spawned {
   const child = spawn(
     process.execPath,
-    ['--import', tsxLoader, startScript],
+    ['--import', tsxLoader, binScript, configPath],
     { cwd, env: { ...env, TSX_TSCONFIG_PATH: repoTsconfig }, stdio: ['pipe', 'pipe', 'pipe'] },
   )
   const stderr: string[] = []
@@ -101,7 +105,7 @@ describe('acp-agent over real stdio (no key required)', () => {
     // A dummy key lets the deepseek adapter APPLY (it only checks the key is
     // present at boot, not valid — the key is used only on a real model call,
     // which this purity test never triggers). So this runs WITHOUT real creds.
-    const child = spawn(process.execPath, ['--import', tsxLoader, startScript], {
+    const child = spawn(process.execPath, ['--import', tsxLoader, binScript, configPath], {
       cwd: workdir,
       env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot', TSX_TSCONFIG_PATH: repoTsconfig },
       stdio: ['pipe', 'pipe', 'pipe'],

+ 7 - 2
examples/acp-agent/tests/snapshot-harness.ts

@@ -31,7 +31,12 @@ import {
   type SessionNotification,
 } from '@agentclientprotocol/sdk'
 
-const startScript = fileURLToPath(new URL('../start.ts', import.meta.url))
+// The dsh-acp-agent bin (the demo:acp entry) and this example's cordis.yml.
+// The bin resolves its config-path arg from CWD and, under DSH_SNAPSHOT=replay,
+// swaps it for the sibling cordis.snapshot.yml. The child's cwd is a temp dir
+// OUTSIDE the repo, so pass the example config's ABSOLUTE path.
+const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url))
+const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
 const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
 // The repo-root tsconfig: dev/test run UNBUILT and the `@deepseek-ai/dsh-*`
 // imports resolve through its `paths` map. The child's cwd is a temp dir
@@ -130,7 +135,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
 
     child = spawn(
       process.execPath,
-      ['--import', tsxLoader, startScript],
+      ['--import', tsxLoader, binScript, configPath],
       { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] },
     )
 

+ 0 - 37
examples/base-core.yml

@@ -1,37 +0,0 @@
-# Providerless provider/tool core — everything the model and tools need EXCEPT
-# an LLM adapter. Split out of base.yml so two consumers can share it:
-#   - base.yml = base-core.yml + the real llm-deepseek adapter (the demos).
-#   - acp-agent/cordis.snapshot.yml = base-core.yml + llm-replay (keyless
-#     snapshot replay — base.yml can't be reused there because llm-deepseek's
-#     apply() throws without DEEPSEEK_API_KEY).
-#
-# Plugin entries use package names (resolved from node_modules), so they are
-# insensitive to the baseUrl reset that plugin-include performs per file.
-
-- id: llm
-  name: '@deepseek-ai/dsh-llm'
-
-- id: sessions
-  name: '@deepseek-ai/dsh-session'
-
-- id: system-prompt
-  name: '@deepseek-ai/dsh-system-prompt'
-
-- id: tools
-  name: '@deepseek-ai/dsh-tools'
-
-- id: agents
-  name: '@deepseek-ai/dsh-agent'
-
-# Dev-mode event-contract assertions + session-log freeze (off in prod).
-- id: invariants
-  name: '@deepseek-ai/dsh-invariants'
-
-# Bash execution: the local executor implementation + the tool schemas.
-- id: bash
-  name: '@deepseek-ai/dsh-bash-local'
-  config:
-    timeoutMs: 60000
-
-- id: tool-bash
-  name: '@deepseek-ai/dsh-tool-bash'

+ 0 - 37
examples/base.yml

@@ -1,37 +0,0 @@
-# Shared provider/tool core for the example agents, loaded via a nested
-# @cordisjs/plugin-include from each example's cordis.yml. This is
-# base-core.yml (the providerless core: llm, sessions, system-prompt, tools,
-# agents, invariants, bash-local, tool-bash) PLUS the real llm-deepseek adapter.
-#
-# The providerless core lives in base-core.yml so the keyless snapshot-replay
-# config (acp-agent/cordis.snapshot.yml) can reuse it with llm-replay in place
-# of the adapter — it can't reuse THIS file, because llm-deepseek's apply()
-# throws without DEEPSEEK_API_KEY.
-#
-# Deliberately EXCLUDES:
-#  - the console logger: it writes to stdout, which the acp-agent reserves for
-#    the JSON-RPC protocol (see packages/acp). Each example loads logging itself.
-#  - agent-loop: AgentLoop pre-creates its configured `agents` in its
-#    constructor, and the examples disagree — coding-agent needs a pre-created
-#    `main` (its stdio-chat calls ctx.agents.get('main')), while acp-agent must
-#    pre-create NONE (ACP session/new creates agents on demand). So each example
-#    declares agent-loop with its own `agents` list.
-#
-# Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) in the env.
-
-# The providerless core (resolved relative to THIS file's directory).
-- id: base-core
-  name: '@cordisjs/plugin-include'
-  config:
-    path: './base-core.yml'
-
-# The DeepSeek adapter. Swap to '@deepseek-ai/dsh-llm-pi-ai' for the pi-ai-backed
-# twin (same config shape; `reasoning: high` replaces thinking/reasoningEffort).
-- id: llm-deepseek
-  name: '@deepseek-ai/dsh-llm-deepseek'
-  config:
-    apiKey: !!js process.env.DEEPSEEK_API_KEY
-    baseURL: !!js process.env.DEEPSEEK_BASE_URL
-    models:
-      - deepseek-v4-flash
-      - deepseek-v4-pro

+ 47 - 47
examples/coding-agent/cordis.yml

@@ -1,59 +1,59 @@
-# The coding-agent plugin tree, loaded via @cordisjs/plugin-include.
-# Infra (logger/timer/hmr) first, then the shared provider/tool core (nested
-# include of ../base.yml), then this example's agent-loop config + UI.
+# The coding-agent plugin tree: the real coding agent. The two swappable
+# backends — the DeepSeek adapter and the local bash executor — plus `hmr` for
+# the dev/demo reload loop, then the stdio chat app (@deepseek-ai/dsh-stdio-
+# agent), which bundles the whole agent-core spine (timer, llm, sessions,
+# system-prompt, tools, agents, invariants, tool-bash, agent-loop), the console
+# logger, JSONL persistence, the readline UI, and a pre-created `main` agent.
 #
-# Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) in the
-# environment — start.ts loads the gitignored repo-root .env first.
-
-- id: logger
-  name: '@cordisjs/plugin-logger-console'
-
-- id: timer
-  name: '@cordisjs/plugin-timer'
+# `hmr` is a leaf entry (not baked into dsh-stdio-agent): it is a Loader-only
+# dev plugin that needs `--expose-internals` — the `demo:coding` script passes
+# it. Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) in the
+# environment — the dsh-stdio-agent bin loads the gitignored repo-root .env
+# first. cordis.yml reads them via the `!!js` tag.
 
+# Hot-module reload for the dev/demo loop (needs `node --expose-internals`).
 - id: hmr
   name: '@cordisjs/plugin-hmr'
   config:
     root: ['.']
 
-# Shared provider/tool core (llm, sessions, system-prompt, tools, agents,
-# invariants, llm-deepseek, bash-local, tool-bash). Nested include: the path is
-# resolved relative to THIS file's directory.
-- id: base
-  name: '@cordisjs/plugin-include'
-  config:
-    path: '../base.yml'
-
-# agent-loop is per-example (NOT in base.yml): coding-agent pre-creates a `main`
-# agent its stdio-chat drives via ctx.agents.get('main').
-- id: agent-loop
-  name: '@deepseek-ai/dsh-agent-loop'
+# The DeepSeek adapter. Swap to '@deepseek-ai/dsh-llm-pi-ai' for the pi-ai-backed
+# twin (same config shape; `reasoning: high` replaces thinking/reasoningEffort).
+- id: llm-deepseek
+  name: '@deepseek-ai/dsh-llm-deepseek'
   config:
-    agents:
-      - id: main
-        model: deepseek-v4-flash
-        # Set RESUME_SESSION_ID to continue a prior persisted session (the ids
-        # live under ./.sessions); unset starts a fresh session each run.
-        resumeSessionId: !!js process.env.RESUME_SESSION_ID
-        systemPrompt: |
-          You are coding-agent, a CLI coding assistant.
-
-          Your only tools are bash (plus bash_output/bash_kill for background
-          tasks). Do ALL file operations through bash: read with cat/sed/head,
-          search with grep, write with heredocs (cat <<'EOF' > file), edit
-          with sed or a rewrite. Each bash call runs in a fresh shell — pass
-          workdir instead of cd, and never rely on shell state between calls.
-
-          Check the [exit code: N] marker on every command; investigate
-          failures before moving on. Verify your work by running the code or
-          tests. Keep answers brief and factual.
-
-- id: session-persistence
-  name: '@deepseek-ai/dsh-session-persistence-jsonl'
+    apiKey: !!js process.env.DEEPSEEK_API_KEY
+    baseURL: !!js process.env.DEEPSEEK_BASE_URL
+    models:
+      - deepseek-v4-flash
+      - deepseek-v4-pro
+
+# Local bash executor (the model's only tool, via agent-core's tool-bash schema).
+- id: bash
+  name: '@deepseek-ai/dsh-bash-local'
   config:
-    root: './.sessions'
+    timeoutMs: 60000
 
-- id: stdio-chat
-  name: '@deepseek-ai/dsh-ui-stdio'
+# The stdio chat app: the whole spine + front-door cluster, configured for a
+# real coding agent driving a pre-created `main` agent.
+- id: stdio-agent
+  name: '@deepseek-ai/dsh-stdio-agent'
   config:
+    model: deepseek-v4-flash
+    # Set RESUME_SESSION_ID to continue a prior persisted session (the ids live
+    # under ./.sessions); unset starts a fresh session each run.
+    resumeSessionId: !!js process.env.RESUME_SESSION_ID
+    persistenceRoot: './.sessions'
     welcome: 'coding-agent ready. Give it a coding task (bash is its only tool).'
+    systemPrompt: |
+      You are coding-agent, a CLI coding assistant.
+
+      Your only tools are bash (plus bash_output/bash_kill for background
+      tasks). Do ALL file operations through bash: read with cat/sed/head,
+      search with grep, write with heredocs (cat <<'EOF' > file), edit
+      with sed or a rewrite. Each bash call runs in a fresh shell — pass
+      workdir instead of cd, and never rely on shell state between calls.
+
+      Check the [exit code: N] marker on every command; investigate
+      failures before moving on. Verify your work by running the code or
+      tests. Keep answers brief and factual.

+ 0 - 30
examples/coding-agent/start.ts

@@ -1,30 +0,0 @@
-import { pathToFileURL } from 'node:url'
-import { Context } from 'cordis'
-import Loader from '@cordisjs/plugin-loader'
-
-// Load DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL from a gitignored repo-root .env
-// (Node >= 21.7 native). Absent file is fine — the environment may already
-// carry the variables; cordis.yml reads them via the `!!js` tag. A
-// present-but-unreadable/malformed .env is a real misconfiguration: surface it
-// rather than silently running with the wrong environment.
-try {
-  process.loadEnvFile(new URL('../../.env', import.meta.url).pathname)
-} catch (error) {
-  if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
-    process.stderr.write(`coding-agent: failed to load .env: ${String(error)}\n`)
-  }
-  // ENOENT (no .env) is fine — rely on the ambient environment.
-}
-
-// Boot a Cordis app from this example's cordis.yml — the same shape as the
-// upstream `cordis` bin, pinned to this directory.
-const ctx = new Context()
-ctx.baseUrl = pathToFileURL(import.meta.dirname).href + '/'
-
-await ctx.plugin(Loader)
-await ctx.loader.create({
-  name: '@cordisjs/plugin-include',
-  config: {
-    path: './cordis.yml',
-  },
-})

+ 15 - 8
examples/coding-agent/tests/keyless-smoke.e2e.ts

@@ -7,21 +7,28 @@ import { afterEach, describe, expect, it } from 'vitest'
 
 /**
  * Keyless Loader-path smoke for examples/coding-agent: boot the REAL example
- * through its `cordis.yml` (the cordis Loader, `unwrapExports`, the full plugin
- * tree incl. the extracted `@deepseek-ai/dsh-ui-stdio`), then close stdin with
- * no prompt and assert the ready banner + a clean exit.
+ * through the `@deepseek-ai/dsh-stdio-agent` bin against its `cordis.yml` (the
+ * cordis Loader, `unwrapExports`, the full plugin tree incl. the
+ * `@deepseek-ai/dsh-agent-core` bundle and the extracted
+ * `@deepseek-ai/dsh-ui-stdio`), then close stdin with no prompt and assert the
+ * ready banner + a clean exit.
  *
  * No prompt is ever sent, so the model is NEVER called — this is why it runs
  * without a real key. coding-agent's `cordis.yml` loads `llm-deepseek`, whose
  * `apply()` only requires a key to be PRESENT (it does not validate it and only
  * uses it when a stream actually starts), so a dummy key lets the tree boot
  * while the absence of any prompt guarantees no network call. The value is the
- * real-Loader-path guard for the shared UI plugin's export shape (a broken
- * `export default` that drops `inject` would crash here — see postmortem 0001),
- * complementing coding-agent's with-key e2e suites which prove the real product.
+ * real-Loader-path guard for the app + bundle + UI plugin export shapes (a broken
+ * `export default` that drops `inject`/`Config` would crash here — see postmortem
+ * 0001), complementing coding-agent's with-key e2e suites which prove the real
+ * product.
  */
 
-const startScript = fileURLToPath(new URL('../start.ts', import.meta.url))
+// The dsh-stdio-agent bin (the demo:coding entry) and this example's cordis.yml.
+// The bin resolves its config-path arg from CWD; the test spawns from a temp
+// cwd, so we pass the example config's ABSOLUTE path.
+const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url))
+const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
 const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
 // Dev/test run UNBUILT: resolve `@deepseek-ai/dsh-*` through the root tsconfig
 // `paths` map; tsx searches UP from cwd, and we spawn from a temp dir outside
@@ -45,7 +52,7 @@ async function bootAndEof(): Promise<{ stdout: string; code: number }> {
     const proc = spawn(
       process.execPath,
       // --expose-internals: cordis.yml loads the HMR plugin (mirrors demo:coding).
-      ['--expose-internals', '--import', tsxLoader, startScript],
+      ['--expose-internals', '--import', tsxLoader, binScript, configPath],
       {
         cwd,
         env: {

+ 12 - 12
examples/echo-agent/README.md

@@ -1,32 +1,32 @@
 # echo-agent
 
-Runnable demo: stdin chat with a scripted mock model and an echo tool.
+Runnable demo: stdin chat with a scripted mock model and an echo tool. The all-mock skeleton — "swap the backend, keep the app".
 
 ## What it shows
 
-- A complete Cordis app loaded from `cordis.yml` — the standard "stack of plugins" pattern
-- `mock-llm.ts` — a mock `LlmAdapter` that streams scripted responses and calls the `echo` tool when the user types "echo <something>"
-- `echo-tool.ts` — a tool registered via `ctx.tools.register()` that echoes text back uppercased
-- `@deepseek-ai/dsh-session-persistence-jsonl` — the durable JSONL persistence backend (loaded from `cordis.yml`, `root: ./.sessions`): append-only event log per session with crash-safe atomic writes, replacing the old write-only example plugin
-- `stdio-chat.ts` — a minimal UI plugin: reads stdin lines and `send`/`steer`s the agent, renders stream deltas, tool calls, and tool results
+This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-stdio-agent`](../../packages/ui/stdio-agent) app (which bundles the whole [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) spine, the console logger, JSONL persistence, the readline UI, and a pre-created `main` agent), and swaps in two example-local backends plus `hmr`:
+
+- `mock-llm.ts` — a mock `LlmAdapter` that streams scripted responses and calls the `echo` tool when the user types "echo <something>". Registered with `ctx.llm.registerAdapter(['mock-echo'], …)`.
+- `echo-tool.ts` — a tool registered via `ctx.tools.register(defineTool(…))` with typed `execute` args; echoes text back uppercased.
+
+Swapping `mock-llm` for the real `llm-deepseek` adapter is all that separates this from `coding-agent` — the same app, a different backend.
 
 ## Plugin files
 
 | File | Role | Key patterns demonstrated |
 |---|---|---|
-| `mock-llm.ts` | `LlmAdapter` registration | `ctx.llm.registerAdapter(['mock-echo'], …)`, streaming chunks with proper `block-start`/`block-end` protocol |
-| `echo-tool.ts` | Tool registration | `ctx.tools.register(defineTool(…))` with typed `execute` args, tool execution returning `ContentBlock[]` |
-| `stdio-chat.ts` | UI | `agent/stream-chunk`, `session/event` (tool/*), stdin→send/steer |
-| `start.ts` | Bootstrap | `Context` + `Loader` + `plugin-include` wired to `cordis.yml` |
+| `src/mock-llm.ts` | `LlmAdapter` registration | `ctx.llm.registerAdapter(['mock-echo'], …)`, streaming chunks with the proper `block-start`/`block-end` protocol |
+| `src/echo-tool.ts` | Tool registration | `ctx.tools.register(defineTool(…))` with typed `execute` args, returning `ContentBlock[]` |
+| `cordis.yml` | Leaf wiring | the two backends + `hmr` + one `@deepseek-ai/dsh-stdio-agent` entry carrying the app config |
 
-Persistence is the shared `@deepseek-ai/dsh-session-persistence-jsonl` plugin (not a per-example file).
+The spine, UI, persistence, and boot glue all live in `@deepseek-ai/dsh-stdio-agent` and the bundle it loads — this folder holds only the demo-specific mocks and the leaf wiring.
 
 ## Run
 
 ```sh
 pnpm run demo:echo
 # or:
-node --expose-internals --import tsx examples/echo-agent/start.ts
+node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml
 ```
 
 Type a message and press Enter. "echo <text>" triggers a tool call round-trip (the mock model requests the `echo` tool, which echoes the text uppercased, and the next model step acknowledges it).

+ 24 - 43
examples/echo-agent/cordis.yml

@@ -1,57 +1,38 @@
-# The echo-agent plugin tree, loaded via @cordisjs/plugin-include.
-# Core services first, then the demo plugins, then the agent itself.
-
-- id: logger
-  name: '@cordisjs/plugin-logger-console'
-
-- id: timer
-  name: '@cordisjs/plugin-timer'
-
+# The echo-agent plugin tree: the stdio chat app with its LLM backend swapped to
+# the local `mock-echo` mock and the local `echo` tool added. The clean
+# demonstration of "swap the backend, keep the app" — every service the agent
+# needs lives in @deepseek-ai/dsh-stdio-agent (which bundles @deepseek-ai/dsh-
+# agent-core); this leaf only picks the backends, `hmr`, and the app config.
+#
+# No API key: the `mock-echo` adapter never touches the network.
+
+# Hot-module reload for the dev/demo loop (a leaf entry, not baked into
+# dsh-stdio-agent — it needs `node --expose-internals`, which `demo:echo` passes).
 - id: hmr
   name: '@cordisjs/plugin-hmr'
   config:
     root: ['.']
 
-- id: llm
-  name: '@deepseek-ai/dsh-llm'
-
-- id: sessions
-  name: '@deepseek-ai/dsh-session'
-
-- id: system-prompt
-  name: '@deepseek-ai/dsh-system-prompt'
-
-- id: tools
-  name: '@deepseek-ai/dsh-tools'
-
-- id: agents
-  name: '@deepseek-ai/dsh-agent'
-
-# Dev-mode event-contract assertions + session-log freeze (off in prod;
-# on here so the demo smoke test exercises the contract).
-- id: invariants
-  name: '@deepseek-ai/dsh-invariants'
-
-- id: agent-loop
-  name: '@deepseek-ai/dsh-agent-loop'
-  config:
-    agents:
-      - id: main
-        model: mock-echo
-        systemPrompt: 'You are echo-agent, a demo agent.'
-
+# The mock model (registers the `mock-echo` adapter) and the demo `echo` tool —
+# example-local teaching plugins, resolved relative to THIS file's directory.
 - id: mock-llm
   name: './src/mock-llm.ts'
 
 - id: echo-tool
   name: './src/echo-tool.ts'
 
-- id: session-persistence
-  name: '@deepseek-ai/dsh-session-persistence-jsonl'
-  config:
-    root: './.sessions'
+# Local bash executor: agent-core ships the `tool-bash` consumer schema, so the
+# leaf provides the executor it runs on (the echo demo doesn't drive bash, but
+# the tool is part of the shared spine).
+- id: bash
+  name: '@deepseek-ai/dsh-bash-local'
 
-- id: stdio-chat
-  name: '@deepseek-ai/dsh-ui-stdio'
+# The stdio chat app: console logger + the agent-core spine (pre-creating the
+# `main` agent on the mock model) + JSONL persistence + the readline UI.
+- id: stdio-agent
+  name: '@deepseek-ai/dsh-stdio-agent'
   config:
+    model: mock-echo
+    systemPrompt: 'You are echo-agent, a demo agent.'
     welcome: 'echo-agent ready. Type a message ("echo <text>" triggers the tool).'
+    persistenceRoot: './.sessions'

+ 0 - 16
examples/echo-agent/start.ts

@@ -1,16 +0,0 @@
-import { pathToFileURL } from 'node:url'
-import { Context } from 'cordis'
-import Loader from '@cordisjs/plugin-loader'
-
-// Boot a Cordis app from this example's cordis.yml — the same shape as the
-// upstream `cordis` bin, pinned to this directory.
-const ctx = new Context()
-ctx.baseUrl = pathToFileURL(import.meta.dirname).href + '/'
-
-await ctx.plugin(Loader)
-await ctx.loader.create({
-  name: '@cordisjs/plugin-include',
-  config: {
-    path: './cordis.yml',
-  },
-})

+ 18 - 11
examples/echo-agent/tests/echo.e2e.ts

@@ -7,22 +7,29 @@ import { afterEach, describe, expect, it } from 'vitest'
 
 /**
  * Keyless Loader-path smoke for examples/echo-agent: boot the REAL example
- * through its `cordis.yml` (the cordis Loader, `unwrapExports`, the whole
- * plugin tree), pipe a script of stdin lines, and assert the rendered stdout.
+ * through the `@deepseek-ai/dsh-stdio-agent` bin against this example's
+ * `cordis.yml` (the cordis Loader, `unwrapExports`, the whole plugin tree),
+ * pipe a script of stdin lines, and assert the rendered stdout.
  *
  * This is the guard the per-file unit suite structurally cannot be: it drives
- * the extracted `@deepseek-ai/dsh-ui-stdio` plugin AND the example-local
- * `mock-llm.ts` / `echo-tool.ts` through their REAL load path, so a broken
- * plugin export shape (a stray `export default` that `unwrapExports` would
- * collapse, dropping `inject`) fails here even though hand-mounted unit tests
- * stay green (see docs/postmortem/0001). It needs no API key — the `mock-echo`
- * adapter never touches the network — so it runs in the default e2e gate.
+ * the `@deepseek-ai/dsh-stdio-agent` app plugin, the `@deepseek-ai/dsh-agent-core`
+ * bundle it loads, the extracted `@deepseek-ai/dsh-ui-stdio` plugin, AND the
+ * example-local `mock-llm.ts` / `echo-tool.ts` through their REAL load path, so
+ * a broken plugin export shape (a stray `export default` that `unwrapExports`
+ * would collapse, dropping `inject`/`Config`) fails here even though hand-mounted
+ * unit tests stay green (see docs/postmortem/0001). It needs no API key — the
+ * `mock-echo` adapter never touches the network — so it runs in the default e2e
+ * gate.
  *
  * Both branches of mock-llm.ts are exercised: an `echo …` line (the tool
  * round-trip → `ECHO: …`) and a plain line (the direct canned reply).
  */
 
-const startScript = fileURLToPath(new URL('../start.ts', import.meta.url))
+// The dsh-stdio-agent bin (the demo:echo entry) and this example's cordis.yml.
+// The bin resolves its config-path arg from CWD; the test spawns from a temp
+// cwd, so we pass the example config's ABSOLUTE path.
+const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url))
+const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
 const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
 // Dev/test run UNBUILT: `@deepseek-ai/dsh-*` imports resolve through the root
 // tsconfig `paths` map, which tsx finds by searching UP from cwd. We spawn from
@@ -53,8 +60,8 @@ async function runEcho(lines: string[]): Promise<{ stdout: string; code: number
       process.execPath,
       // --expose-internals: the example's cordis.yml loads the HMR plugin, which
       // requires it (mirrors the `demo:echo` script). The whole point is to boot
-      // the example EXACTLY as it really runs, through the Loader.
-      ['--expose-internals', '--import', tsxLoader, startScript],
+      // the example EXACTLY as it really runs, through the bin + Loader.
+      ['--expose-internals', '--import', tsxLoader, binScript, configPath],
       { cwd, env: { ...process.env, TSX_TSCONFIG_PATH: repoTsconfig }, stdio: ['pipe', 'pipe', 'pipe'] },
     )
     child = proc

+ 4 - 0
knip.json

@@ -28,6 +28,10 @@
     "packages/llm/llm-pi-ai": {
       "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
       "project": ["src/**/*.ts", "tests/**/*.ts"]
+    },
+    "packages/ui/acp-agent": {
+      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
+      "project": ["src/**/*.ts", "tests/**/*.ts"]
     }
   }
 }

+ 3 - 3
package.json

@@ -37,9 +37,9 @@
     "constraints": "tsx scripts/check-workspace-constraints.ts",
     "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-rfc-classification && pnpm run verify-type-equiv",
     "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints",
-    "demo:echo": "node --expose-internals --import tsx examples/echo-agent/start.ts",
-    "demo:coding": "node --expose-internals --import tsx examples/coding-agent/start.ts",
-    "demo:acp": "node --expose-internals --import tsx examples/acp-agent/start.ts",
+    "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml",
+    "demo:coding": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml",
+    "demo:acp": "node --import tsx packages/ui/acp-agent/src/bin.ts examples/acp-agent/cordis.yml",
     "postinstall": "node scripts/install-lefthook.mjs"
   },
   "devDependencies": {

+ 6 - 0
packages/README.md

@@ -35,6 +35,9 @@ dsh-invariants    ← dsh-llm, dsh-session, dsh-agent (dev-mode contract checks)
 dsh-acp           ← dsh-agent, dsh-llm, dsh-session, dsh-session-persistence  (ACP JSON-RPC bridge)
 dsh-ui-stdio      ← dsh-agent, dsh-llm, dsh-session (stdio readline UI plugin)
 dsh-llm-replay    ← dsh-llm, dsh-session            (record/replay adapter for keyless snapshot tests)
+dsh-agent-core    ← timer, dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-agent, dsh-invariants, dsh-tool-bash, dsh-agent-loop  (the providerless spine, as one bundle plugin)
+dsh-stdio-agent   ← dsh-agent-core, dsh-ui-stdio, dsh-session-persistence-jsonl, dsh-agent, dsh-session  (stdio chat APP + bin)
+dsh-acp-agent     ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl     (ACP server APP + bin)
 ```
 
 The rule: plugins depend on interfaces, never on the concrete loop. `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)).
@@ -49,6 +52,7 @@ The rule: plugins depend on interfaces, never on the concrete loop. `dsh-agent-l
 | `tools/` | `core` | Tool registry + `tools/execute` waterfall | `ctx.tools` |
 | `agent/` | `core` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
 | `agent-loop/` | `core` | THE concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` |
+| `agent-core/` | `core` | Bundle plugin: the providerless/executor-less/UI-less spine as code (forwards `agent-loop`'s `agents`) | (loads the spine) |
 | `bash/` | `bash` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` |
 | `bash-local/` | `bash` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) |
 | `tool-bash/` | `bash` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) |
@@ -59,6 +63,8 @@ The rule: plugins depend on interfaces, never on the concrete loop. `dsh-agent-l
 | `session-persistence-sqlite/` | `session-persistence` | SQLite persistence backend | (registers `ctx.sessionPersistence`) |
 | `invariants/` | `support` | Dev-mode event-contract invariants + session-log freeze | (listens on `session/*`, `agent/*`) |
 | `acp/` | `ui` | Agent Client Protocol bridge: serves the agent to an ACP editor over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) |
+| `stdio-agent/` | `ui` | Terminal stdio chat APP: agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) |
+| `acp-agent/` | `ui` | ACP server APP: agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) |
 | `ui-stdio/` | `support` | Minimal stdio (readline) UI plugin: renders `agent/*` events, feeds stdin lines to the agent | (drives `ctx.agents`) |
 | `llm-replay/` | `support` | Record/replay adapter: short-circuits `llm/stream` with chunks from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) |
 

+ 3 - 0
packages/core/README.md

@@ -9,5 +9,8 @@ The packages every harness build is assembled from: the session log, the system-
 | `tools/` | Tool registry + `tools/execute` waterfall | `ctx.tools` |
 | `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
 | `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` |
+| `agent-core/` | Bundle plugin: the providerless/executor-less/UI-less spine as code | (loads the spine) |
 
 `agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop; everything else in `core/` is interface/vocabulary. Plugins depend on the `agent` vocabulary, never on `agent-loop` directly, so the loop stays swappable.
+
+`agent-core` is the composition counterpart: one bundle plugin that loads the whole providerless spine (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + `tool-bash` + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. App packages (`ui/stdio-agent`, `ui/acp-agent`) consume it and add only a front door; a leaf adds only the swappable backends. It lives in `core/` because it composes exclusively `core/` + interface packages and ships no provider, executor, or UI of its own.

+ 44 - 0
packages/core/agent-core/README.md

@@ -0,0 +1,44 @@
+# @deepseek-ai/dsh-agent-core
+
+The **providerless, executor-less, UI-less agent spine** as ONE Cordis bundle plugin. It loads the fixed set of services every harness agent needs and forwards the loop's `agents` list as its own config — so an app package composes a working agent by adding only a front door and the swappable backends.
+
+This is the package to read to see **the whole plugin tree at once** — the teaching role the inlined `echo-agent` `cordis.yml` used to play before the spine moved behind this bundle.
+
+## The tree it loads
+
+`apply(ctx, config)` mounts each of these as a child of the bundle fiber:
+
+```
+@cordisjs/plugin-timer            timer service (writes nothing to stdout)
+@deepseek-ai/dsh-llm              abstract LLM service + content-block vocabulary
+@deepseek-ai/dsh-session          event-sourced session log + store
+@deepseek-ai/dsh-system-prompt    prompt-section + tool-schema assembly
+@deepseek-ai/dsh-tools            tool registry + tools/execute waterfall
+@deepseek-ai/dsh-agent            agent registry + agent/* event vocabulary
+@deepseek-ai/dsh-invariants       dev-mode event-contract assertions
+@deepseek-ai/dsh-tool-bash        the model-facing bash/bash_output/bash_kill schemas
+@deepseek-ai/dsh-agent-loop       THE concrete loop (gets the forwarded `agents`)
+```
+
+## What it deliberately leaves OUTSIDE the bundle
+
+The spine is everything COMMON to every front door. The swappable and front-door-coupled pieces stay out, picked by whatever loads the bundle:
+
+- **the LLM adapter** — the bundle ships the abstract `llm` service; the leaf registers a concrete adapter on `ctx.llm` (`llm-deepseek`, `llm-pi-ai`, `llm-replay`).
+- **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl).
+- **presentation + per-app infra** — the stdio UI / ACP bridge, a console logger, `hmr`. These form the coupled "front-door cluster" that the app packages ([`dsh-stdio-agent`](../../ui/stdio-agent/README.md), [`dsh-acp-agent`](../../ui/acp-agent/README.md)) bake in. `timer` is in the spine (common to both, stdout-silent); a console logger is NOT (it writes to stdout, which the ACP bridge reserves for JSON-RPC).
+
+This is the [interface/implementation/consumer seam](../../../docs/rfc/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.
+
+## Config
+
+```ts
+import type { Config } from '@deepseek-ai/dsh-agent-core'
+// Config === AgentLoop.Config — the `agents` list, default [].
+```
+
+The bundle FORWARDS `agent-loop`'s `agents` list as its own (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`). Forwarding the list is exactly why the loop can live in the shared spine even though the apps disagree on which agents to pre-create.
+
+## Why a code bundle, not a shared YAML include
+
+A YAML include can dedupe the config, but it cannot OWN a `bin`, and it can only *describe* the front-door coupling in a comment and trust each leaf to obey. Moving the spine into a package, and the front-door cluster into the app packages, turns "the ACP app never logs to stdout" from a prose warning into a property of the artifact. Services register in the root store keyed by their isolate symbol, so a child loaded here is visible to the bundle's siblings (the leaf's adapter and executor) exactly as a nested `plugin-include` subtree's services were — cordis gates every read on `inject`, never on load order.

+ 46 - 0
packages/core/agent-core/package.json

@@ -0,0 +1,46 @@
+{
+  "name": "@deepseek-ai/dsh-agent-core",
+  "description": "The providerless/executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + agents + invariants + tool-bash + agent-loop)",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@cordisjs/plugin-timer": "^1.1.2",
+    "@deepseek-ai/dsh-agent": "^0.0.1",
+    "@deepseek-ai/dsh-agent-loop": "^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-system-prompt": "^0.0.1",
+    "@deepseek-ai/dsh-tool-bash": "^0.0.1",
+    "@deepseek-ai/dsh-tools": "^0.0.1",
+    "cordis": "^4.0.0-rc.6"
+  },
+  "devDependencies": {
+    "@cordisjs/plugin-timer": "workspace:^",
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-agent-loop": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-system-prompt": "workspace:^",
+    "@deepseek-ai/dsh-tool-bash": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "cordis": "^4.0.0-rc.6"
+  }
+}

+ 88 - 0
packages/core/agent-core/src/index.ts

@@ -0,0 +1,88 @@
+/**
+ * The providerless, executor-less, UI-less agent spine as ONE bundle plugin.
+ *
+ * Loads the fixed set of services every harness agent needs — `timer`, the LLM
+ * service, the session store, system-prompt assembly, the tool registry, the
+ * agent registry, the dev-mode invariants, the model-facing `bash` tool
+ * schemas, and the concrete `agent-loop` — and forwards the loop's `agents`
+ * list as its OWN config (default `[]`), so each app supplies its own
+ * pre-created agents.
+ *
+ * It is deliberately NOT the whole app: the swappable choices stay OUTSIDE the
+ * bundle, picked by whatever loads it.
+ *  - the LLM ADAPTER (`llm-deepseek`/`llm-pi-ai`/`llm-replay`) — the bundle
+ *    ships the abstract `llm` service + `tool-bash` consumer schema; the leaf
+ *    registers a concrete adapter on `ctx.llm`.
+ *  - the bash EXECUTOR (`bash-local` or a sandboxed impl) — the bundle ships
+ *    the `bash` tool consumer; the leaf provides `ctx.bash`.
+ *  - the PRESENTATION (stdio UI / ACP bridge / a logger) and the per-app infra
+ *    (a console logger, `hmr`) — these are the coupled "front-door cluster" the
+ *    app packages ({@link @deepseek-ai/dsh-stdio-agent},
+ *    {@link @deepseek-ai/dsh-acp-agent}) bake in, NOT the shared spine.
+ *
+ * This is the interface/implementation/consumer seam at the composition level:
+ * the bundle owns the shared spine, the leaf owns the backends, the app package
+ * owns the front door. `timer` is in the spine (common to every front door — it
+ * writes nothing to stdout); the console logger is NOT (it writes to stdout,
+ * which the ACP bridge reserves for its JSON-RPC channel).
+ *
+ * Services register in the root store keyed by their isolate symbol, so a child
+ * loaded here via `ctx.plugin(...)` is visible to the bundle's SIBLINGS (the
+ * leaf's adapter and executor) exactly as a nested `plugin-include` subtree's
+ * services were before this bundle existed — cordis gates every read on
+ * `inject`, never on load order, so the fixed child set resolves regardless of
+ * which entry loads first.
+ *
+ * Plugin export shape: named `name`/`Config`/`apply`, NO default export — the
+ * cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray
+ * default would collapse the module to the bare `apply` function and drop the
+ * `Config` schema (see docs/postmortem/0001). The keyless Loader-path smokes in
+ * the app packages guard this end-to-end.
+ *
+ * @module @deepseek-ai/dsh-agent-core
+ */
+
+import type { Context } from 'cordis'
+import Timer from '@cordisjs/plugin-timer'
+import LlmService from '@deepseek-ai/dsh-llm'
+import SessionStore from '@deepseek-ai/dsh-session'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
+import * as invariants from '@deepseek-ai/dsh-invariants'
+import * as toolBash from '@deepseek-ai/dsh-tool-bash'
+import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agent-loop'
+
+export const name = 'agent-core'
+
+/**
+ * Bundle config: the agent-loop `agents` list, forwarded verbatim. Default `[]`
+ * — an app that pre-creates no agents (the ACP bridge creates them on demand at
+ * `session/new`) simply omits it; an app that needs a pre-created `main` (the
+ * stdio chat) supplies one. This IS {@link AgentLoopConfig}, so the schema and
+ * the forwarded shape can never drift.
+ */
+export type Config = AgentLoopConfig
+
+/** Forward the loop's own schema so validation + defaulting stay identical. */
+export const Config = AgentLoop.Config
+
+/**
+ * Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber;
+ * `agent-loop` receives the forwarded `agents` list. Load order is irrelevant
+ * (cordis pends each fiber on its `inject` until the services it needs exist),
+ * but the listing mirrors the dependency layering for readability: the LLM
+ * vocabulary and core registries first, then the dev tripwire and the bash tool
+ * consumer, then the loop that drives them.
+ */
+export function apply(ctx: Context, config: Config): void {
+  ctx.plugin(Timer)
+  ctx.plugin(LlmService)
+  ctx.plugin(SessionStore)
+  ctx.plugin(SystemPrompt)
+  ctx.plugin(ToolRegistry)
+  ctx.plugin(AgentRegistry)
+  ctx.plugin(invariants)
+  ctx.plugin(toolBash)
+  ctx.plugin(AgentLoop, { agents: config.agents })
+}

+ 57 - 0
packages/core/agent-core/tests/agent-core.spec.ts

@@ -0,0 +1,57 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import * as agentCore from '../src/index.ts'
+import { AgentId } from '@deepseek-ai/dsh-agent'
+
+/**
+ * Unit coverage for the @deepseek-ai/dsh-agent-core bundle: mounting it brings
+ * up the whole providerless spine in one `ctx.plugin`, and the forwarded
+ * `agents` config reaches the loop (default `[]`, or a pre-created agent).
+ *
+ * The bundle is exercised through `ctx.plugin(agentCore, …)` — the NAMESPACE
+ * import, the same shape the Loader builds from `unwrapExports`. The real
+ * Loader-path guard (export shape, `unwrapExports`) is the app packages' keyless
+ * bin smokes; here we assert the composition + config forwarding.
+ */
+async function mount(config?: agentCore.Config): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(agentCore, config)
+  // The bundle mounts its children inside apply() (not awaited there); let their
+  // fibers settle so the spine services and any pre-created agent are ready.
+  await new Promise(resolve => setTimeout(resolve, 50))
+  return ctx
+}
+
+describe('dsh-agent-core bundle', () => {
+  it('brings up the full providerless spine', async () => {
+    const ctx = await mount()
+    // One service from each layer of the spine proves the children loaded.
+    expect(ctx.get('timer')).toBeDefined()
+    expect(ctx.get('llm')).toBeDefined()
+    expect(ctx.get('sessions')).toBeDefined()
+    expect(ctx.get('systemPrompt')).toBeDefined()
+    expect(ctx.get('tools')).toBeDefined()
+    expect(ctx.get('agents')).toBeDefined()
+    expect(ctx.get('agentLoop')).toBeDefined()
+    await ctx.fiber.dispose()
+  })
+
+  it('defaults the agents list to empty (no pre-created agents)', async () => {
+    const ctx = await mount()
+    expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
+    await ctx.fiber.dispose()
+  })
+
+  it('forwards a pre-created agent to the loop', async () => {
+    const ctx = await mount({
+      agents: [{ id: AgentId('main'), model: 'mock', systemPrompt: 'hi' }],
+    })
+    expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
+    await ctx.fiber.dispose()
+  })
+
+  it('re-exports the loop config schema as its own', () => {
+    expect(agentCore.Config).toBeDefined()
+    expect(agentCore.name).toBe('agent-core')
+  })
+})

+ 42 - 0
packages/core/agent-core/tsconfig.json

@@ -0,0 +1,42 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/timer"
+    },
+    {
+      "path": "../../llm/llm"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../../core/system-prompt"
+    },
+    {
+      "path": "../../core/tools"
+    },
+    {
+      "path": "../../core/agent"
+    },
+    {
+      "path": "../../core/agent-loop"
+    },
+    {
+      "path": "../../support/invariants"
+    },
+    {
+      "path": "../../bash/tool-bash"
+    }
+  ]
+}

+ 4 - 0
packages/ui/README.md

@@ -5,5 +5,9 @@ Integrations that expose the agent to an external editor or client. These are **
 | Package | Role | ctx key |
 |---|---|---|
 | `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) |
+| `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) |
+| `acp-agent/` | ACP server APP: the agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) |
 
 A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The readline `ui-stdio` plugin is the unstructured analogue but lives in `support/` because it exists chiefly for the examples and the coverage gate — `ui/` is reserved for surfaces shipped as product.
+
+`stdio-agent` and `acp-agent` are the two **app packages**: each composes the [`core/agent-core`](../core/agent-core/README.md) spine with its coupled front-door cluster (and owns the boot `bin`), so a leaf `cordis.yml` is just the swappable backends plus one app entry. They live in `ui/` because each IS a user-facing front door; the stdout-purity coupling (logger vs. no logger) becomes a property of the artifact rather than a leaf convention.

+ 39 - 0
packages/ui/acp-agent/README.md

@@ -0,0 +1,39 @@
+# @deepseek-ai/dsh-acp-agent
+
+The **ACP server app**: a Cordis app plugin that composes the providerless agent spine ([`@deepseek-ai/dsh-agent-core`](../../core/agent-core/README.md)) with the front-door cluster an [Agent Client Protocol](../acp/README.md) server needs, and a `bin` that boots a leaf `cordis.yml` speaking ACP JSON-RPC on stdio.
+
+It is the structured counterpart to [`@deepseek-ai/dsh-stdio-agent`](../stdio-agent/README.md): both consume the same spine, but this one bakes in the OPPOSITE front-door cluster.
+
+## What it bakes in — and what it deliberately omits
+
+stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it LEAVES OUT as what it includes:
+
+| Plugin | Why |
+|---|---|
+| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating **no** agents (ACP `session/new` creates them on demand) |
+| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) |
+| `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC |
+| ~~console logger~~ | **omitted** — it writes to stdout and would corrupt the protocol frames ([the stdout-purity footgun](../acp/README.md)) |
+| ~~`hmr`~~ | **omitted** — the editor owns the subprocess |
+
+Because there is no logger entry in the package, the footgun is **structurally unreachable from the leaf**: a leaf author cannot wire a stdout logger into the ACP config, because the leaf only picks backends, not the front door.
+
+## Config
+
+| Key | Default | Routed to |
+|---|---|---|
+| `model` | (required) | the per-session agent template the bridge creates agents from |
+| `systemPrompt` | (required) | the per-session agent's system prompt |
+| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
+
+The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor (`bash-local`).
+
+## The bin
+
+`dsh-acp-agent [path-to-cordis.yml]` (default `./cordis.yml`):
+
+- loads a gitignored `.env` from the cwd — **skipped** in snapshot REPLAY so a stray key can never trigger a live call;
+- honors `DSH_SNAPSHOT=replay` by booting the sibling `cordis.snapshot.yml` (the keyless replay tree, `llm-replay` in place of `llm-deepseek`);
+- in a snapshot run, disposes the context on stdin EOF so the session log is fully flushed before exit.
+
+All diagnostics go to **stderr** — stdout is the protocol.

+ 47 - 0
packages/ui/acp-agent/package.json

@@ -0,0 +1,47 @@
+{
+  "name": "@deepseek-ai/dsh-acp-agent",
+  "description": "ACP server app: the agent-core spine + JSONL persistence + the ACP bridge (no stdout logger, no hmr, no pre-created agents), with a bin to boot a leaf cordis.yml over JSON-RPC stdio",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/index.d.ts",
+  "bin": {
+    "dsh-acp-agent": "lib/bin.js"
+  },
+  "exports": {
+    ".": {
+      "types": "./lib/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./bin": {
+      "types": "./lib/bin.d.ts",
+      "default": "./lib/bin.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@cordisjs/plugin-include": "^1.0.4",
+    "@cordisjs/plugin-loader": "^1.0.0-rc.4",
+    "@deepseek-ai/dsh-acp": "^0.0.1",
+    "@deepseek-ai/dsh-agent-core": "^0.0.1",
+    "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
+    "cordis": "^4.0.0-rc.6",
+    "schemastery": "^3.17.0"
+  },
+  "devDependencies": {
+    "@cordisjs/plugin-include": "workspace:^",
+    "@cordisjs/plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-acp": "workspace:^",
+    "@deepseek-ai/dsh-agent-core": "workspace:^",
+    "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
+    "cordis": "^4.0.0-rc.6",
+    "schemastery": "^3.17.0"
+  }
+}

+ 100 - 0
packages/ui/acp-agent/src/bin.ts

@@ -0,0 +1,100 @@
+#!/usr/bin/env node
+/**
+ * The `dsh-acp-agent` bin: boot the ACP server from a leaf `cordis.yml` that
+ * loads the {@link @deepseek-ai/dsh-acp-agent} app plugin (plus an LLM adapter
+ * and a bash executor), speaking ACP JSON-RPC on stdio.
+ *
+ * Owns the ACP-specific boot glue the example's `start.ts` once held:
+ *  - `.env` loading (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`) — SKIPPED in
+ *    snapshot REPLAY so a stray key can never trigger a live model call.
+ *  - snapshot-mode config selection: `DSH_SNAPSHOT=replay` swaps the given
+ *    `cordis.yml` for its sibling `cordis.snapshot.yml` (the keyless replay
+ *    tree: `llm-replay` in place of `llm-deepseek`).
+ *  - the stdin-dispose lifecycle: in a snapshot run the harness closes stdin
+ *    when done, so dispose the context (flushing persistence) and exit cleanly.
+ *
+ * IMPORTANT: stdout is the ACP JSON-RPC channel. This bin writes diagnostics to
+ * STDERR only; the app plugin loads no stdout logger. A stray stdout write
+ * corrupts the protocol frames.
+ *
+ * Usage: `dsh-acp-agent [path-to-cordis.yml]` (default `./cordis.yml`).
+ *
+ * @module @deepseek-ai/dsh-acp-agent/bin
+ */
+
+import { pathToFileURL } from 'node:url'
+import { basename, dirname, resolve } from 'node:path'
+import { Context } from 'cordis'
+import Loader from '@cordisjs/plugin-loader'
+
+/**
+ * Resolve the config to boot, honoring snapshot REPLAY. Given the requested
+ * path, replay mode swaps a `cordis.yml` basename for `cordis.snapshot.yml` in
+ * the SAME directory (the keyless replay tree). Other modes use the path as-is.
+ * Returns an absolute path resolved from the cwd.
+ */
+export function resolveConfigPath(configPath: string, snapshotMode: string | undefined): string {
+  const absolute = resolve(process.cwd(), configPath)
+  if (snapshotMode !== 'replay') return absolute
+  const dir = dirname(absolute)
+  const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml')
+  return resolve(dir, replayName)
+}
+
+/**
+ * Load `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` from a gitignored `.env` in the
+ * cwd (Node native). Diagnostics go to STDERR (stdout is the protocol). In
+ * REPLAY mode the caller skips this entirely — replay must never reach the
+ * network, so a present `.env` must not enable a live call.
+ */
+function loadEnv(): void {
+  try {
+    process.loadEnvFile(resolve(process.cwd(), '.env'))
+  } catch (error) {
+    if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
+      process.stderr.write(`dsh-acp-agent: failed to load .env: ${String(error)}\n`)
+    }
+    // ENOENT (no .env) is fine — rely on the ambient environment.
+  }
+}
+
+/**
+ * Boot the Loader against `absoluteConfigPath`. `baseUrl` is pinned to the
+ * config's directory and the include gets only the basename, so the config's
+ * relative plugin/include paths resolve as the upstream `cordis` bin does.
+ * Returns the root context.
+ */
+export async function boot(absoluteConfigPath: string): Promise<Context> {
+  const ctx = new Context()
+  ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
+  await ctx.plugin(Loader)
+  await ctx.loader.create({
+    name: '@cordisjs/plugin-include',
+    config: { path: `./${basename(absoluteConfigPath)}` },
+  })
+  return ctx
+}
+
+/**
+ * Entry point. Selects the config (snapshot-aware), loads `.env` outside replay,
+ * boots, and — in a snapshot run — disposes the context on stdin EOF so the
+ * session log is fully flushed before exit and the harness's `waitForExit`
+ * resolves. In a normal editor session stdin stays open for the connection's
+ * lifetime (the editor kills the process), so the EOF handler never fires.
+ */
+export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
+  const snapshotMode = process.env.DSH_SNAPSHOT
+  const configPath = resolveConfigPath(argv[0] ?? './cordis.yml', snapshotMode)
+  if (snapshotMode !== 'replay') loadEnv()
+  const ctx = await boot(configPath)
+  if (snapshotMode !== undefined) {
+    process.stdin.on('end', () => {
+      void ctx.fiber.dispose().then(() => { process.exit(0) })
+    })
+  }
+}
+
+/* v8 ignore start -- top-level CLI invocation; the testable core is
+   resolveConfigPath()/boot()/main(), driven by the keyless snapshot + Loader-path tests */
+await main()
+/* v8 ignore stop */

+ 70 - 0
packages/ui/acp-agent/src/index.ts

@@ -0,0 +1,70 @@
+/**
+ * The ACP server app: the providerless agent spine ({@link
+ * @deepseek-ai/dsh-agent-core}) plus the coupled front-door cluster an ACP
+ * server needs — JSONL session persistence and the {@link @deepseek-ai/dsh-acp}
+ * bridge, and DELIBERATELY NOTHING that writes to stdout.
+ *
+ * The cluster is the OPPOSITE of {@link @deepseek-ai/dsh-stdio-agent}'s, and
+ * baking it in is the whole point: an ACP server speaks JSON-RPC on stdout, so
+ * a stray console logger would corrupt the protocol frames (the [stdout-purity
+ * footgun]). This package contains NO console-logger entry, NO `hmr` (the editor
+ * owns the subprocess), and pre-creates NO agents (ACP `session/new` creates
+ * them on demand) — so the footgun is structurally unreachable from the leaf:
+ * there is no logger entry to get wrong.
+ *
+ * The leaf supplies only the swappable backends: the LLM adapter (`llm-deepseek`
+ * for the real model, `llm-replay` for keyless snapshot replay) and the bash
+ * executor (`bash-local`). This app's {@link Config} (model, system prompt,
+ * persistence root) routes each value to where it is wired — model/prompt onto
+ * the bridge's per-session agent template, the root onto the JSONL backend.
+ *
+ * Plugin export shape: named `name`/`Config`/`apply`, NO default export — the
+ * cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray
+ * default would collapse the module to the bare `apply` and drop the `Config`
+ * namespace (see docs/postmortem/0001 — the exact bug that shipped here once).
+ * The keyless ACP snapshot/Loader-path tests guard this end-to-end.
+ *
+ * @module @deepseek-ai/dsh-acp-agent
+ */
+
+import type { Context } from 'cordis'
+import z from 'schemastery'
+import * as acp from '@deepseek-ai/dsh-acp'
+import * as agentCore from '@deepseek-ai/dsh-agent-core'
+import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
+
+export const name = 'acp-agent'
+
+/**
+ * App config: the swappable per-deployment values. `model`/`systemPrompt`
+ * configure the agent template the ACP bridge creates each session's agent from
+ * (NOT a pre-created agent — ACP creates agents at `session/new`);
+ * `persistenceRoot` is the JSONL backend's directory.
+ */
+export interface Config {
+  /** Model name for ACP-created agents (must have a registered adapter). */
+  model: string
+  /** Per-agent system prompt for ACP-created agents. */
+  systemPrompt: string
+  /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
+  persistenceRoot?: string
+}
+
+export const Config: z<Config> = z.object({
+  model: z.string().required(),
+  systemPrompt: z.string().required(),
+  persistenceRoot: z.string().default('./.sessions'),
+})
+
+/**
+ * Compose the spine with the ACP front door. The agent-core bundle pre-creates
+ * NO agents (its `agents` list defaults to `[]`); the JSONL backend persists
+ * under `persistenceRoot`; the ACP bridge owns stdout for JSON-RPC and creates
+ * one agent per `session/new` from `model`/`systemPrompt`. No logger, no `hmr` —
+ * stdout stays pure.
+ */
+export function apply(ctx: Context, config: Config): void {
+  ctx.plugin(agentCore)
+  ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
+  ctx.plugin(acp, { model: config.model, systemPrompt: config.systemPrompt })
+}

+ 52 - 0
packages/ui/acp-agent/tests/acp-agent.spec.ts

@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import * as acpAgent from '../src/index.ts'
+
+/**
+ * In-process unit coverage for the @deepseek-ai/dsh-acp-agent composition:
+ * mounting it brings up the agent-core spine + JSONL persistence + the ACP
+ * bridge in one `ctx.plugin`. Unlike the stdio app, this one loads NO
+ * Loader-only plugin (no hmr), so it mounts in a plain Context.
+ *
+ * The REAL Loader-path guard (export shape via `unwrapExports`, the headline
+ * ACP operations end-to-end) is the keyless bin smoke in `load-path.e2e.ts`;
+ * this spec asserts the composition and the persistenceRoot default branch.
+ */
+async function mount(config: acpAgent.Config): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(acpAgent, config)
+  // The bundle mounts its children inside apply() (not awaited there); let their
+  // fibers settle so the spine services are ready.
+  await new Promise(resolve => setTimeout(resolve, 50))
+  return ctx
+}
+
+describe('dsh-acp-agent composition', () => {
+  it('brings up the spine + persistence + the ACP bridge', async () => {
+    const ctx = await mount({ model: 'mock', systemPrompt: 'hi', persistenceRoot: '/tmp/dsh-acp-agent-test' })
+    expect(ctx.get('agents')).toBeDefined()
+    expect(ctx.get('sessions')).toBeDefined()
+    expect(ctx.get('sessionPersistence')).toBeDefined()
+    expect(ctx.get('agentLoop')).toBeDefined()
+    // No pre-created agents — ACP session/new creates them on demand.
+    expect(ctx.get('agents')!.list()).toHaveLength(0)
+    await ctx.fiber.dispose()
+  })
+
+  it('defaults the persistence root when omitted', async () => {
+    // Exercises the `?? './.sessions'` fallback for a direct-apply caller that
+    // bypasses the schema's `.default(...)`: call `apply` directly (not via
+    // `ctx.plugin`, which validates+defaults the config first) with no
+    // persistenceRoot, so the runtime fallback is the one that fires.
+    const ctx = new Context()
+    acpAgent.apply(ctx, { model: 'mock', systemPrompt: 'hi' })
+    await new Promise(resolve => setTimeout(resolve, 50))
+    expect(ctx.get('sessionPersistence')).toBeDefined()
+    await ctx.fiber.dispose()
+  })
+
+  it('exposes its plugin shape', () => {
+    expect(acpAgent.name).toBe('acp-agent')
+    expect(acpAgent.Config).toBeDefined()
+  })
+})

+ 147 - 0
packages/ui/acp-agent/tests/load-path.e2e.ts

@@ -0,0 +1,147 @@
+import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
+import { Readable, Writable } from 'node:stream'
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+  ClientSideConnection,
+  ndJsonStream,
+  PROTOCOL_VERSION,
+  type Agent as AcpAgent,
+  type Client,
+  type RequestPermissionRequest,
+  type RequestPermissionResponse,
+  type SessionNotification,
+} from '@agentclientprotocol/sdk'
+
+/**
+ * REAL-load-path smoke for @deepseek-ai/dsh-acp-agent: boot the app through its
+ * own `bin` (the demo:acp entry) as a subprocess, driving the cordis Loader and
+ * `unwrapExports` over a minimal `cordis.yml` that loads THIS package. This is
+ * the guard a hand-built `ctx.plugin({...})` mount structurally cannot be — that
+ * bypasses `unwrapExports`, the exact path that once dropped the bridge's
+ * `inject` and shipped (docs/postmortem/0001). It exercises the headline ACP
+ * operations end-to-end: `initialize` → `session/new` → `session/load`.
+ *
+ * KEYLESS: `session/new` and `session/load` reach the agent FACTORY but never
+ * the model (no prompt is sent), so no DEEPSEEK_API_KEY is needed. A dummy key
+ * lets `llm-deepseek`'s `apply()` (key-PRESENT check only) boot the tree.
+ *
+ * The config is written into a temp dir whose cwd IS the session workspace, so
+ * the bash workdir validation passes. We point tsx at the repo-root tsconfig
+ * (TSX_TSCONFIG_PATH) because the child's cwd is outside the repo and the
+ * unbuilt `paths` map is found by searching UP from cwd.
+ */
+
+const binScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
+const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
+// Repo root is four levels up from packages/ui/acp-agent/tests.
+const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
+
+// A minimal leaf that loads this app + the two backends — the same shape as
+// examples/acp-agent/cordis.yml, inlined so the package test owns its fixture.
+const CORDIS_YML = `
+- id: llm-deepseek
+  name: '@deepseek-ai/dsh-llm-deepseek'
+  config:
+    apiKey: !!js process.env.DEEPSEEK_API_KEY
+    models: [deepseek-v4-flash]
+- id: bash
+  name: '@deepseek-ai/dsh-bash-local'
+- id: acp-agent
+  name: '@deepseek-ai/dsh-acp-agent'
+  config:
+    model: deepseek-v4-flash
+    systemPrompt: 'You are a test agent.'
+`
+
+interface Spawned {
+  child: ChildProcessWithoutNullStreams
+  client: ClientSideConnection
+  stderr: string[]
+}
+
+let spawned: Spawned | undefined
+let workdir: string | undefined
+
+afterEach(async () => {
+  if (spawned !== undefined) {
+    spawned.child.kill('SIGKILL')
+    spawned = undefined
+  }
+  if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
+  workdir = undefined
+})
+
+async function boot(): Promise<Spawned & { cwd: string }> {
+  workdir = await mkdtemp(join(tmpdir(), 'acp-agent-pkg-'))
+  const cwd = workdir
+  const configPath = join(cwd, 'cordis.yml')
+  await writeFile(configPath, CORDIS_YML)
+  const child = spawn(
+    process.execPath,
+    ['--import', tsxLoader, binScript, configPath],
+    {
+      cwd,
+      env: {
+        ...process.env,
+        TSX_TSCONFIG_PATH: repoTsconfig,
+        // Key-present check only; no prompt is sent, so the model is never called.
+        DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'keyless-acp-agent-smoke',
+      },
+      stdio: ['pipe', 'pipe', 'pipe'],
+    },
+  )
+  const stderr: string[] = []
+  child.stderr.setEncoding('utf8')
+  child.stderr.on('data', (chunk: string) => stderr.push(chunk))
+  const stream = ndJsonStream(
+    Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
+    Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
+  )
+  const makeClient = (_agent: AcpAgent): Client => ({
+    sessionUpdate(_params: SessionNotification): Promise<void> {
+      return Promise.resolve()
+    },
+    requestPermission(_params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
+      return Promise.resolve({ outcome: { outcome: 'cancelled' } })
+    },
+  })
+  const client = new ClientSideConnection(makeClient, stream)
+  spawned = { child, client, stderr }
+  return { ...spawned, cwd }
+}
+
+describe('dsh-acp-agent real-load-path smoke (bin + Loader, keyless)', () => {
+  it('boots via its bin and answers initialize → session/new → session/load', async () => {
+    const { client, cwd, stderr } = await boot()
+    // initialize: a broken export shape (collapsed bridge plugin, dropped inject)
+    // crashes the tree on the first service read here — see postmortem 0001.
+    const init = await client.initialize({
+      protocolVersion: PROTOCOL_VERSION,
+      clientCapabilities: {},
+    })
+    expect(init.agentCapabilities?.loadSession).toBe(true)
+
+    // session/new reaches the agent FACTORY (create) without the model.
+    const { sessionId } = await client.newSession({ cwd, mcpServers: [] })
+    expect(sessionId).toBeTruthy()
+
+    // session/load reaches the resume FACTORY + persistence without the model:
+    // load an UNKNOWN id (loading the live `sessionId` would correctly reject as
+    // "already loaded"). The bridge consults `sessionPersistence.list()` then
+    // `agents.resume()`, both of which run from the JSON-RPC read loop OUTSIDE
+    // the bridge's inject scope — the exact path postmortem 0001 crashed. A
+    // healthy tree rejects with a not-found error; a broken export shape would
+    // instead throw "cannot get property … without inject" before reaching it.
+    const unknownId = '00000000-0000-4000-8000-000000000000'
+    await client.loadSession({ sessionId: unknownId, cwd, mcpServers: [] }).then(
+      () => { throw new Error('expected session/load of an unknown id to reject') },
+      (error: unknown) => { expect(String(error)).not.toContain('without inject') },
+    )
+
+    expect(stderr.join('')).not.toContain('without inject')
+  }, 30_000)
+})

+ 30 - 0
packages/ui/acp-agent/tsconfig.json

@@ -0,0 +1,30 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../../../vendor/loader"
+    },
+    {
+      "path": "../acp"
+    },
+    {
+      "path": "../../core/agent-core"
+    },
+    {
+      "path": "../../session-persistence/session-persistence-jsonl"
+    }
+  ]
+}

+ 18 - 0
packages/ui/acp-agent/tsdown.config.ts

@@ -0,0 +1,18 @@
+import { defineConfig } from 'tsdown'
+
+/**
+ * acp-agent ships TWO entries: the plugin (`index`) and the CLI `bin` (`bin`),
+ * the latter referenced by package.json `bin`/`exports["./bin"]`. The root
+ * tsdown builds only `src/index.ts`, so this override adds `bin.ts`.
+ * Declarations come from `tsc -b` (dts: false), matching every package.
+ */
+export default defineConfig({
+  entry: ['src/index.ts', 'src/bin.ts'],
+  outDir: 'lib',
+  format: ['esm'],
+  platform: 'node',
+  target: 'es2024',
+  fixedExtension: false,
+  dts: false,
+  clean: false,
+})

+ 60 - 0
packages/ui/stdio-agent/README.md

@@ -0,0 +1,60 @@
+# @deepseek-ai/dsh-stdio-agent
+
+The **terminal stdio chat app**: a Cordis app plugin that composes the providerless agent spine ([`@deepseek-ai/dsh-agent-core`](../../core/agent-core/README.md)) with the front-door cluster a terminal chat needs, and a `bin` that boots a leaf `cordis.yml`.
+
+It is the readline counterpart to [`@deepseek-ai/dsh-acp-agent`](../acp-agent/README.md): both consume the same spine, but each bakes in the OPPOSITE front-door cluster.
+
+## What it bakes in
+
+A terminal chat always wants the same cluster, so the package owns it rather than trusting each leaf to re-wire it:
+
+| Plugin | Why it is here |
+|---|---|
+| `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) |
+| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model`/`systemPrompt` |
+| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` |
+| `@deepseek-ai/dsh-ui-stdio` | the readline UI, bound to the `main` agent |
+
+`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in 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. The `demo:echo` / `demo:coding` leaves load it and pass `--expose-internals`.
+
+The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapter (`llm-deepseek` for the real model, or the mock `mock-llm` for a demo) and a bash executor (`bash-local`) — `hmr`, plus this app's [`Config`](#config). The whole plugin tree a run loads is therefore: this app's cluster, the spine inside `agent-core`, `hmr`, and the two leaf backends.
+
+## Config
+
+| Key | Default | Routed to |
+|---|---|---|
+| `model` | (required) | the pre-created `main` agent's model |
+| `systemPrompt` | (required) | the `main` agent's system prompt |
+| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
+| `welcome` | `ready.` | the stdin-chat banner |
+| `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |
+
+## The bin
+
+`dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config — the boot glue the `examples/*/start.ts` files once each duplicated. The `demo:echo` / `demo:coding` scripts invoke it.
+
+## Example leaf `cordis.yml`
+
+```yaml
+# A real coding agent: hmr + the DeepSeek adapter + local bash, then this app.
+- id: hmr
+  name: '@cordisjs/plugin-hmr'
+  config:
+    root: ['.']
+- id: llm-deepseek
+  name: '@deepseek-ai/dsh-llm-deepseek'
+  config:
+    apiKey: !!js process.env.DEEPSEEK_API_KEY
+    models: [deepseek-v4-flash]
+- id: bash
+  name: '@deepseek-ai/dsh-bash-local'
+  config:
+    timeoutMs: 60000
+- id: stdio-agent
+  name: '@deepseek-ai/dsh-stdio-agent'
+  config:
+    model: deepseek-v4-flash
+    systemPrompt: 'You are a CLI coding assistant. Your only tools are bash…'
+```
+
+Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo — "swap the backend, keep the app".

+ 53 - 0
packages/ui/stdio-agent/package.json

@@ -0,0 +1,53 @@
+{
+  "name": "@deepseek-ai/dsh-stdio-agent",
+  "description": "Terminal stdio chat app: the agent-core spine + console logger + readline UI + a pre-created main agent, with a bin to boot a leaf cordis.yml",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/index.d.ts",
+  "bin": {
+    "dsh-stdio-agent": "lib/bin.js"
+  },
+  "exports": {
+    ".": {
+      "types": "./lib/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./bin": {
+      "types": "./lib/bin.d.ts",
+      "default": "./lib/bin.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@cordisjs/plugin-include": "^1.0.4",
+    "@cordisjs/plugin-loader": "^1.0.0-rc.4",
+    "@cordisjs/plugin-logger-console": "^1.0.0",
+    "@deepseek-ai/dsh-agent": "^0.0.1",
+    "@deepseek-ai/dsh-agent-core": "^0.0.1",
+    "@deepseek-ai/dsh-session": "^0.0.1",
+    "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
+    "@deepseek-ai/dsh-ui-stdio": "^0.0.1",
+    "cordis": "^4.0.0-rc.6",
+    "schemastery": "^3.17.0"
+  },
+  "devDependencies": {
+    "@cordisjs/plugin-include": "workspace:^",
+    "@cordisjs/plugin-loader": "workspace:^",
+    "@cordisjs/plugin-logger-console": "workspace:^",
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-agent-core": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
+    "@deepseek-ai/dsh-ui-stdio": "workspace:^",
+    "cordis": "^4.0.0-rc.6",
+    "schemastery": "^3.17.0"
+  }
+}

+ 70 - 0
packages/ui/stdio-agent/src/bin.ts

@@ -0,0 +1,70 @@
+#!/usr/bin/env node
+/**
+ * The `dsh-stdio-agent` bin: boot a Cordis app from a leaf `cordis.yml` that
+ * loads the {@link @deepseek-ai/dsh-stdio-agent} app plugin (plus a backend LLM
+ * adapter and a bash executor). Owns the boot glue the three `examples/*` once
+ * duplicated in their `start.ts`: load the gitignored repo-root `.env`, then
+ * drive the cordis Loader against the config path (default `./cordis.yml`).
+ *
+ * Usage: `dsh-stdio-agent [path-to-cordis.yml]`. The `demo:echo` / `demo:coding`
+ * scripts invoke it with the example's config.
+ *
+ * @module @deepseek-ai/dsh-stdio-agent/bin
+ */
+
+import { pathToFileURL } from 'node:url'
+import { basename, dirname, resolve } from 'node:path'
+import { Context } from 'cordis'
+import Loader from '@cordisjs/plugin-loader'
+
+/**
+ * Load `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` from a gitignored `.env` in the
+ * CURRENT WORKING DIRECTORY (Node native `process.loadEnvFile`). An absent file
+ * is fine — the environment may already carry the variables; the leaf
+ * `cordis.yml` reads them via the `!!js` tag. A present-but-unreadable/malformed
+ * `.env` is a real misconfiguration: surface it on stderr rather than silently
+ * running with the wrong environment. The mock-model demo (echo) ships no key
+ * and simply has no `.env`.
+ */
+function loadEnv(): void {
+  try {
+    process.loadEnvFile(resolve(process.cwd(), '.env'))
+  } catch (error) {
+    if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
+      process.stderr.write(`dsh-stdio-agent: failed to load .env: ${String(error)}\n`)
+    }
+    // ENOENT (no .env) is fine — rely on the ambient environment.
+  }
+}
+
+/**
+ * Boot the Loader against `configPath` (resolved from the CWD). `baseUrl` is
+ * pinned to the config's directory and the include is handed only the basename,
+ * so the config's relative plugin/include paths resolve exactly as the upstream
+ * `cordis` bin does. Returns the root context (the process owns its lifetime).
+ */
+export async function boot(configPath: string): Promise<Context> {
+  const absolute = resolve(process.cwd(), configPath)
+  const ctx = new Context()
+  ctx.baseUrl = pathToFileURL(dirname(absolute)).href + '/'
+  await ctx.plugin(Loader)
+  await ctx.loader.create({
+    name: '@cordisjs/plugin-include',
+    config: { path: `./${basename(absolute)}` },
+  })
+  return ctx
+}
+
+/**
+ * Entry point: load `.env`, then boot the config named on argv (default
+ * `./cordis.yml`). Awaited at the module top level by the published bin
+ * (`#!/usr/bin/env node` shebang via the package's `bin` field).
+ */
+export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
+  loadEnv()
+  await boot(argv[0] ?? './cordis.yml')
+}
+
+/* v8 ignore start -- top-level CLI invocation; the testable core is boot()/main(), driven by the keyless Loader-path smoke */
+await main()
+/* v8 ignore stop */

+ 98 - 0
packages/ui/stdio-agent/src/index.ts

@@ -0,0 +1,98 @@
+/**
+ * The stdio chat app: the providerless agent spine ({@link
+ * @deepseek-ai/dsh-agent-core}) plus the coupled front-door cluster a terminal
+ * chat needs — a console logger, the readline `ui-stdio` UI, JSONL session
+ * persistence, and a pre-created `main` agent the UI drives.
+ *
+ * The cluster is BAKED IN, not left to the leaf: a stdio app always logs to the
+ * console (stdout is just the terminal) and always pre-creates the `main` agent
+ * `ui-stdio` sends to. The leaf supplies only the swappable backends (the LLM
+ * adapter, the bash executor), the optional `hmr` dev-reload plugin, and this
+ * app's {@link Config} (model, prompt, persistence root, welcome banner).
+ *
+ * `hmr` is deliberately a LEAF entry, not baked in here: it is a Loader-only,
+ * subprocess-only dev plugin (its constructor throws without `--expose-internals`
+ * + a live `loader`, and the in-process test tier cannot even import it), so a
+ * package whose `apply` statically pulled it in could never be unit-tested or
+ * 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, while
+ * baking the LOGGER in (the real coupling) keeps stdout-vs-no-stdout a property
+ * of the artifact.
+ *
+ * Counterpart to {@link @deepseek-ai/dsh-acp-agent}, which bakes in the OPPOSITE
+ * cluster (no stdout logger, no pre-created agents — the ACP bridge reserves
+ * stdout for JSON-RPC and creates agents on demand). Splitting the two front
+ * doors into two packages makes each cluster a property of the artifact: there
+ * is no logger entry in the ACP leaf to get wrong.
+ *
+ * Plugin export shape: named `name`/`Config`/`apply`, NO default export — the
+ * cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray
+ * default would collapse the module to the bare `apply` and drop the `Config`
+ * namespace (see docs/postmortem/0001). The keyless Loader-path smoke in the
+ * echo example guards this end-to-end.
+ *
+ * @module @deepseek-ai/dsh-stdio-agent
+ */
+
+import type { Context } from 'cordis'
+import ConsoleExporter from '@cordisjs/plugin-logger-console'
+import z from 'schemastery'
+import { AgentId } from '@deepseek-ai/dsh-agent'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import * as agentCore from '@deepseek-ai/dsh-agent-core'
+import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
+import * as uiStdio from '@deepseek-ai/dsh-ui-stdio'
+
+export const name = 'stdio-agent'
+
+/**
+ * App config: the swappable per-demo values, each routed to where the app wires
+ * it. `model`/`systemPrompt`/`resumeSessionId` configure the pre-created `main`
+ * agent (through {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list);
+ * `persistenceRoot` is the JSONL backend's directory; `welcome` is the UI banner.
+ */
+export interface Config {
+  /** Model name for the `main` agent (must have a registered adapter). */
+  model: string
+  /** System prompt for the `main` agent. */
+  systemPrompt: string
+  /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
+  persistenceRoot?: string
+  /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
+  welcome?: string
+  /**
+   * If set, the `main` agent RESUMES this persisted session id instead of
+   * starting fresh. Sourced from an env var in the leaf `cordis.yml`
+   * (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`).
+   */
+  resumeSessionId?: string
+}
+
+export const Config: z<Config> = z.object({
+  model: z.string().required(),
+  systemPrompt: z.string().required(),
+  persistenceRoot: z.string().default('./.sessions'),
+  welcome: z.string().default('ready.'),
+  resumeSessionId: z.string(),
+})
+
+/**
+ * Compose the spine with the stdio front door. The console logger comes first
+ * (infra), then the agent-core bundle pre-creating the `main` agent from this
+ * app's `model`/`systemPrompt`/`resumeSessionId`, then the JSONL backend, then
+ * the `ui-stdio` UI bound to `main`. The `hmr` dev-reload plugin is a leaf
+ * concern (see the module doc), so it is not mounted here.
+ */
+export function apply(ctx: Context, config: Config): void {
+  ctx.plugin(ConsoleExporter)
+  ctx.plugin(agentCore, {
+    agents: [{
+      id: AgentId('main'),
+      model: config.model,
+      systemPrompt: config.systemPrompt,
+      ...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {},
+    }],
+  })
+  ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
+  ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', agent: 'main' })
+}

+ 71 - 0
packages/ui/stdio-agent/tests/stdio-agent.spec.ts

@@ -0,0 +1,71 @@
+import { describe, it, expect } from 'vitest'
+import { Context } from 'cordis'
+import { AgentId } from '@deepseek-ai/dsh-agent'
+import * as stdioAgent from '../src/index.ts'
+
+/**
+ * Unit coverage for the @deepseek-ai/dsh-stdio-agent app plugin: mounting it
+ * composes the console logger, the agent-core spine (pre-creating the `main`
+ * agent from the app config), the JSONL backend, and the readline UI in one
+ * `ctx.plugin`. The forwarded `model`/`systemPrompt` reach the pre-created
+ * agent; `persistenceRoot`/`welcome`/`resumeSessionId` route to their backends.
+ *
+ * `hmr` is NOT part of this plugin (it is a leaf entry — a Loader-only dev
+ * plugin the in-process tier cannot import); the REAL Loader-path guard (export
+ * shape, `unwrapExports`, the whole subprocess tree incl. `hmr`) is the keyless
+ * echo smoke in `examples/echo-agent`. Here we assert the composition + config
+ * forwarding the unit tier can reach.
+ */
+async function mount(config: stdioAgent.Config): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(stdioAgent, config)
+  // The app mounts its children inside apply() (not awaited there); let their
+  // fibers settle so the spine services + the pre-created agent are ready.
+  await new Promise(resolve => setTimeout(resolve, 80))
+  return ctx
+}
+
+describe('dsh-stdio-agent app', () => {
+  it('composes the spine + front-door cluster and pre-creates the main agent', async () => {
+    const ctx = await mount({ model: 'mock', systemPrompt: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec' })
+    // The spine services (brought up by the agent-core bundle) are all present.
+    expect(ctx.get('agents')).toBeDefined()
+    expect(ctx.get('agentLoop')).toBeDefined()
+    expect(ctx.get('sessionPersistence')).toBeDefined()
+    // The pre-created `main` agent the UI drives.
+    expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
+    await ctx.fiber.dispose()
+  })
+
+  it('defaults persistenceRoot and welcome when omitted', async () => {
+    // Direct apply (NOT via ctx.plugin, which validates+defaults the config
+    // first) so the runtime `?? './.sessions'` / `?? 'ready.'` fallbacks on
+    // apply()'s last two lines are the ones that fire — covering a
+    // schema-bypassing direct-mount caller.
+    const ctx = new Context()
+    stdioAgent.apply(ctx, { model: 'mock', systemPrompt: 'hi' })
+    await new Promise(resolve => setTimeout(resolve, 80))
+    expect(ctx.get('sessionPersistence')).toBeDefined()
+    expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
+    await ctx.fiber.dispose()
+  })
+
+  it('forwards resumeSessionId onto the pre-created agent when set', async () => {
+    // A resume id defers agent creation until persistence loads; with no backing
+    // session the resume is contained + logged, so no `main` agent registers —
+    // the branch that maps resumeSessionId through is what this covers.
+    const ctx = await mount({
+      model: 'mock',
+      systemPrompt: 'hi',
+      persistenceRoot: '/tmp/dsh-stdio-agent-spec-resume',
+      resumeSessionId: 'no-such-session',
+    })
+    expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
+    await ctx.fiber.dispose()
+  })
+
+  it('exposes its name and Config schema', () => {
+    expect(stdioAgent.name).toBe('stdio-agent')
+    expect(stdioAgent.Config).toBeDefined()
+  })
+})

+ 39 - 0
packages/ui/stdio-agent/tsconfig.json

@@ -0,0 +1,39 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../../../vendor/loader"
+    },
+    {
+      "path": "../../../vendor/logger-console"
+    },
+    {
+      "path": "../../core/agent"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../../core/agent-core"
+    },
+    {
+      "path": "../../session-persistence/session-persistence-jsonl"
+    },
+    {
+      "path": "../../support/ui-stdio"
+    }
+  ]
+}

+ 18 - 0
packages/ui/stdio-agent/tsdown.config.ts

@@ -0,0 +1,18 @@
+import { defineConfig } from 'tsdown'
+
+/**
+ * stdio-agent ships TWO entries: the plugin (`index`) and the CLI `bin`
+ * (`bin`), the latter referenced by package.json `bin`/`exports["./bin"]`.
+ * The root tsdown builds only `src/index.ts`, so this override adds `bin.ts`.
+ * Declarations come from `tsc -b` (dts: false), matching every package.
+ */
+export default defineConfig({
+  entry: ['src/index.ts', 'src/bin.ts'],
+  outDir: 'lib',
+  format: ['esm'],
+  platform: 'node',
+  target: 'es2024',
+  fixedExtension: false,
+  dts: false,
+  clean: false,
+})

+ 98 - 0
pnpm-lock.yaml

@@ -133,6 +133,39 @@ importers:
         specifier: ^4.0.0-rc.6
         version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
 
+  packages/core/agent-core:
+    devDependencies:
+      '@cordisjs/plugin-timer':
+        specifier: workspace:^
+        version: link:../../../vendor/timer
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../agent
+      '@deepseek-ai/dsh-agent-loop':
+        specifier: workspace:^
+        version: link:../agent-loop
+      '@deepseek-ai/dsh-invariants':
+        specifier: workspace:^
+        version: link:../../support/invariants
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../session
+      '@deepseek-ai/dsh-system-prompt':
+        specifier: workspace:^
+        version: link:../system-prompt
+      '@deepseek-ai/dsh-tool-bash':
+        specifier: workspace:^
+        version: link:../../bash/tool-bash
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:^
+        version: link:../tools
+      cordis:
+        specifier: ^4.0.0-rc.6
+        version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
+
   packages/core/agent-loop:
     dependencies:
       schemastery:
@@ -377,6 +410,63 @@ importers:
         specifier: ^4.0.0-rc.6
         version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
 
+  packages/ui/acp-agent:
+    devDependencies:
+      '@cordisjs/plugin-include':
+        specifier: workspace:^
+        version: link:../../../vendor/include
+      '@cordisjs/plugin-loader':
+        specifier: workspace:^
+        version: link:../../../vendor/loader
+      '@deepseek-ai/dsh-acp':
+        specifier: workspace:^
+        version: link:../acp
+      '@deepseek-ai/dsh-agent-core':
+        specifier: workspace:^
+        version: link:../../core/agent-core
+      '@deepseek-ai/dsh-session-persistence-jsonl':
+        specifier: workspace:^
+        version: link:../../session-persistence/session-persistence-jsonl
+      cordis:
+        specifier: ^4.0.0-rc.6
+        version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader)
+      schemastery:
+        specifier: ^3.17.0
+        version: 3.18.0
+
+  packages/ui/stdio-agent:
+    devDependencies:
+      '@cordisjs/plugin-include':
+        specifier: workspace:^
+        version: link:../../../vendor/include
+      '@cordisjs/plugin-loader':
+        specifier: workspace:^
+        version: link:../../../vendor/loader
+      '@cordisjs/plugin-logger-console':
+        specifier: workspace:^
+        version: link:../../../vendor/logger-console
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
+      '@deepseek-ai/dsh-agent-core':
+        specifier: workspace:^
+        version: link:../../core/agent-core
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@deepseek-ai/dsh-session-persistence-jsonl':
+        specifier: workspace:^
+        version: link:../../session-persistence/session-persistence-jsonl
+      '@deepseek-ai/dsh-ui-stdio':
+        specifier: workspace:^
+        version: link:../../support/ui-stdio
+      cordis:
+        specifier: ^4.0.0-rc.6
+        version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader)
+      schemastery:
+        specifier: ^3.17.0
+        version: 3.18.0
+
   packages/util/brand:
     devDependencies:
       cordis:
@@ -3893,6 +3983,14 @@ snapshots:
       '@cordisjs/plugin-include': 1.0.4(@cordisjs/plugin-loader@1.0.0-rc.4)(cordis@4.0.0-rc.6)
       '@cordisjs/plugin-loader': 1.0.0-rc.4(cordis@4.0.0-rc.6)
 
+  cordis@4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader):
+    dependencies:
+      '@standard-schema/spec': 1.1.0
+      cosmokit: 1.8.1
+    optionalDependencies:
+      '@cordisjs/plugin-include': link:vendor/include
+      '@cordisjs/plugin-loader': link:vendor/loader
+
   cosmokit@1.8.1: {}
 
   cross-spawn@7.0.6:

+ 3 - 0
tsconfig.build.json

@@ -20,6 +20,7 @@
     { "path": "./packages/core/agent" },
     { "path": "./packages/core/tools" },
     { "path": "./packages/core/agent-loop" },
+    { "path": "./packages/core/agent-core" },
     { "path": "./packages/bash/bash" },
     { "path": "./packages/llm/llm-deepseek" },
     { "path": "./packages/llm/llm-pi-ai" },
@@ -27,6 +28,8 @@
     { "path": "./packages/bash/tool-bash" },
     { "path": "./packages/support/invariants" },
     { "path": "./packages/ui/acp" },
+    { "path": "./packages/ui/acp-agent" },
+    { "path": "./packages/ui/stdio-agent" },
     { "path": "./packages/support/ui-stdio" },
     { "path": "./packages/support/llm-replay" }
   ]

+ 6 - 1
vitest.config.ts

@@ -26,7 +26,12 @@ export default defineConfig({
       // executable code; vendor/ and examples/ are out of scope (examples are
       // exercised by the demo smoke test instead).
       include: ['packages/*/*/src/**/*.ts'],
-      exclude: ['packages/*/*/src/types.ts'],
+      // Types-only files carry no executable code. `bin.ts` files are
+      // self-executing CLI entrypoints (a top-level `await main()`): a spec
+      // can't import one without booting it, so they are driven by the keyless
+      // Loader-path smoke (a real subprocess) instead of the in-process unit
+      // suite — the same reason `examples/start.ts` sat out of coverage scope.
+      exclude: ['packages/*/*/src/types.ts', 'packages/*/*/src/bin.ts'],
       // 100% or it doesn't merge (AGENTS.md: excessive tests are welcome).
       // Per-file so a well-covered big file can't subsidize a bare one.
       // Every v8 ignore comment must carry a reason — see AGENTS.md.