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:03:44 +08:00
parent 4209e4af3f
commit e2bde2902c
54 changed files with 1631 additions and 465 deletions

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 */