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:
100
packages/ui/acp-agent/src/bin.ts
Normal file
100
packages/ui/acp-agent/src/bin.ts
Normal 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
packages/ui/acp-agent/src/index.ts
Normal file
70
packages/ui/acp-agent/src/index.ts
Normal 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 })
|
||||
}
|
||||
Reference in New Issue
Block a user