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.
This commit is contained in:
Tianyi Cui
2026-06-21 12:06:38 +08:00
parent 4209e4af3f
commit e2bde2902c
54 changed files with 1631 additions and 465 deletions
+19 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
@@ -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).
@@ -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
@@ -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).
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
-33
View File
@@ -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
View File
@@ -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.
+42 -24
View File
@@ -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'
# 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'
# The DeepSeek adapter.
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
path: '../base.yml'
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
models:
- deepseek-v4-flash
- deepseek-v4-pro
# 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'
# 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: 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:
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.
-63
View File
@@ -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
View File
@@ -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
View File
@@ -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'] },
)
-37
View File
@@ -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'
-37
View File
@@ -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
+46 -46
View File
@@ -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'
# 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:
path: '../base.yml'
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
models:
- deepseek-v4-flash
- deepseek-v4-pro
# 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'
# Local bash executor (the model's only tool, via agent-core's tool-bash schema).
- id: bash
name: '@deepseek-ai/dsh-bash-local'
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.
timeoutMs: 60000
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'
config:
root: './.sessions'
- 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.
-30
View File
@@ -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',
},
})
@@ -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
View File
@@ -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).
+23 -42
View File
@@ -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'
-16
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 })
}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 })
}
@@ -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()
})
})
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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' })
}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.