feat(ui): share the app bins' boot glue in @deepseek-ai/dsh-app-boot

The four near-twin helpers the two published bins carried — loadEnv,
installFailLoud, assertEntriesLoaded, boot — live once in
packages/ui/app-boot, parameterized by the bin's diagnostic prefix and
injectable at their side-effect seams (warn sink, process slice), so
every branch sits under the per-file 100% coverage gate: the unit suite
drives boot() in-process against the real Loader (relative-specifier
configs) through both the settled-tree path and the fiber-less-entry
rejection, and exercises the ENOENT/unloadable .env split, the
Error/non-Error/stackless fail-loud arms, and the disabled-entry
exclusion. resolveConfigPath (snapshot-aware) becomes the single path
resolver for both bins.

Each bin.ts is now a thin self-executing composition plus its
app-specific lifecycle (acp: replay env-skip + stdin-EOF dispose;
stdio: nothing extra), exports nothing, and stays coverage-excluded;
the built-bin smokes still prove both artifacts under plain node in the
node_modules-shaped temp dir (now symlinking ui/app-boot), including
the missing-config non-zero exit.

Implements docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md
(moved from proposed/ and amended); the extract-example-app-packages
RFC's bin-ownership facts are amended in the same change.
This commit is contained in:
Tianyi Cui
2026-07-04 16:43:06 +08:00
parent 6df70c2079
commit 4a0941fb4b
24 changed files with 510 additions and 313 deletions
+2
View File
@@ -112,6 +112,8 @@ packages/ Harness packages, grouped by role at packages/<group>/<pkg>/.
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)
app-boot/ shared boot glue for the two app bins: .env loading,
fail-loud Loader guards, config resolution, boot sequence
support/ dev/test/example infrastructure (lower compat expectations)
invariants/ dev-mode event-contract invariants + session-log freeze
llm-replay/ record/replay adapter: short-circuits llm/stream from a
+5 -2
View File
@@ -113,9 +113,11 @@ graph TD
tool-subagent --> tools
acp-agent --> acp
acp-agent --> agent-core
acp-agent --> app-boot
acp-agent --> session-persistence-jsonl
stdio-agent --> agent
stdio-agent --> agent-core
stdio-agent --> app-boot
stdio-agent --> session
stdio-agent --> session-persistence-jsonl
subagent-fork --> agent
@@ -128,6 +130,7 @@ graph TD
| Package | Depends on |
| --- | --- |
| `app-boot` | — |
| `brand` | — |
| `bash` | `brand` |
| `llm` | `brand` |
@@ -168,7 +171,7 @@ graph TD
| `subagent-inprocess` | `agent`, `llm`, `session`, `subagent` |
| `subagent-mock` | `agent`, `llm`, `subagent` |
| `tool-subagent` | `agent`, `llm`, `subagent`, `tools` |
| `acp-agent` | `acp`, `agent-core`, `session-persistence-jsonl` |
| `stdio-agent` | `agent`, `agent-core`, `session`, `session-persistence-jsonl` |
| `acp-agent` | `acp`, `agent-core`, `app-boot`, `session-persistence-jsonl` |
| `stdio-agent` | `agent`, `agent-core`, `app-boot`, `session`, `session-persistence-jsonl` |
| `subagent-fork` | `agent`, `session`, `subagent`, `subagent-inprocess` |
| `subagent-spawn` | `subagent`, `subagent-inprocess` |
+1 -1
View File
@@ -59,7 +59,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
| [Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger)](proposed/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) | 2026-07-04 |
| [Prune write-only fields and a dead routing knob from the fs seam](proposed/simplification/2026-07-04-prune-write-only-fs-surface.md) | 2026-07-04 |
| [Remove the `agent/steering` mirror emit](proposed/simplification/2026-07-04-remove-agent-steering-mirror.md) | 2026-07-04 |
| [Share the app bins' boot glue instead of maintaining twin copies](proposed/simplification/2026-07-04-share-app-bin-boot-glue.md) | 2026-07-04 |
| [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](proposed/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 |
| [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](proposed/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 |
@@ -120,6 +119,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
| [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 |
| [Stop mirroring the token stream as an agent event](implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) | 2026-07-02 |
| [Fold the stdio UI helper into the stdio app](implemented/simplification/2026-07-04-fold-stdio-ui-helper.md) | 2026-07-04 |
| [Share the app bins' boot glue instead of maintaining twin copies](implemented/simplification/2026-07-04-share-app-bin-boot-glue.md) | 2026-07-04 |
### Architecture
@@ -14,7 +14,7 @@ Each example is now **mostly an invocation of an app package**, splitting the wi
- **`@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. Depending on the CONCRETE `dsh-agent-loop` (not just the `dsh-agent` interface) is deliberate and is the sanctioned exception to the "extension plugins depend on interfaces, never on the concrete loop" rule (packages/README.md, docs/architecture.md § Layering): the rule constrains plugins that EXTEND the system, whereas this bundle's whole job is to COMPOSE the concrete spine. Swapping the loop means publishing a different bundle, not rewiring every extension.
- **`@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 leaf no longer carries the cluster, so it has no logger entry to copy wrong by default — the common stdout-purity mistake loses its foothold. (A leaf can still *add* a sibling logger entry — a package cannot forbid what a leaf author writes — so the rule "never add a stdout logger to an ACP leaf" stays documented at the leaf; what changed is that the default leaf has nothing to get wrong.) 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.
- **`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, and fail-loud guards live in the shared [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) package (unit-tested under the per-file coverage gate — see [share the app bins' boot glue](../simplification/2026-07-04-share-app-bin-boot-glue.md)); each bin is a thin self-executing composition over those helpers plus its app-specific lifecycle (the ACP bin: snapshot-mode selection and stdin-dispose). The `bin.ts` files themselves stay coverage-excluded (self-executing CLI entries, like the old `start.ts`) and are 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`.
@@ -0,0 +1,23 @@
# RFC: Share the app bins' boot glue instead of maintaining twin copies
Status: implemented
## Problem
`packages/ui/stdio-agent/src/bin.ts` and `packages/ui/acp-agent/src/bin.ts` carried four near-twin helpers — `loadEnv`, `installFailLoud`, `assertEntriesLoaded`, `boot` — whose bodies differed essentially in the diagnostic prefix, plus two copies of the hardest-won boot lore in the repo: the `Promise.allSettled` swallow inside `loader.await()`, the silent-exit-0 import-failure guard, and the `--expose-internals` resolution note. The copies had drifted (`boot(configPath)` resolved the path internally in one bin but required a pre-resolved absolute path in the other, with forked JSDoc prose), and all of it sat outside the per-file 100% gate — `vitest.config.ts` excludes `packages/*/*/src/bin.ts` because importing a self-executing bin runs it — which also made the helpers' `export` keywords decorative: no spec could import them, so the only exercisers were subprocess smokes.
## Decision
The helpers live once, in [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) (`packages/ui/app-boot`, in the `ui` group because the bins are published artifacts whose runtime dependency must itself be published, not `support/`): `resolveConfigPath` (snapshot-aware, the single path resolver for both bins), `loadEnv`, `installFailLoud`, `assertEntriesLoaded`, and `boot`, each parameterized by the bin's diagnostic prefix and injectable at its side-effect seams (the warn sink, the process slice) so the unit suite covers every branch — including `boot()` driven in-process against the real Loader with relative-specifier configs, both the settled-tree happy path and the fiber-less-entry rejection. The package carries the per-file 100% coverage gate; the loader-failure lore has one home.
Each `bin.ts` is a thin self-executing composition over the shared helpers plus its app-specific lifecycle (the ACP bin: replay-mode env skipping and the stdin-EOF dispose; the stdio bin: nothing extra). The bins stay coverage-excluded and export nothing; the published-artifact guards are unchanged — the built-bin smokes still run each bin under plain node in a node_modules-shaped temp dir (now symlinking `ui/app-boot` too) and still assert the missing-config non-zero exit, per the "real entry path means the published artifact" defensive pattern. The [extract-example-app-packages RFC](../architecture/2026-06-20-extract-example-app-packages.md)'s bin-ownership facts are amended accordingly.
## Why not keep the duplication?
The bins were framed as independently-owned published artifacts, and a new package carries fixed overhead (manifest, README, tsconfig reference, publint surface) comparable to the deduplicated line count. But app-vs-app sharing was never weighed by the RFC that created the bins — it consolidated three example `start.ts` copies INTO the bins and stopped there; the drift was observed fact; and the coverage-gap argument is independent of the dedup argument: this was the only nontrivial runtime logic in the repo exempt from the per-file 100% gate. The recorded fallback (extracting only the pure logic into per-app modules) would have ended the exemption but kept two homes for the lore.
## Consequences
- A boot-glue change (a new guard, a resolution fix) lands once and both published bins inherit it; the bins cannot drift apart again.
- `dsh-app-boot` stays dependency-light (cordis + the loader/include pair) — it is boot machinery, not app surface.
- The bins' own files are near-trivial compositions; everything with branches lives under the coverage gate.
@@ -1,27 +0,0 @@
# RFC: Share the app bins' boot glue instead of maintaining twin copies
Status: proposed
## Problem
`packages/ui/stdio-agent/src/bin.ts` and `packages/ui/acp-agent/src/bin.ts` carry four near-twin helpers — `loadEnv`, `installFailLoud`, `assertEntriesLoaded`, `boot` — whose bodies differ essentially in the diagnostic prefix, plus two copies of the hardest-won boot lore in the repo: the `Promise.allSettled` swallow inside `loader.await()`, the silent-exit-0 import-failure guard, and the `--expose-internals` resolution note (the failure classes behind AGENTS.md's "real entry path means the published artifact" pattern). Drift has already begun: `boot(configPath)` resolves the path internally in one bin but requires a pre-resolved absolute path in the other, and the twin JSDoc prose has forked.
The duplication is aggravated by a coverage hole: all of this logic sits OUTSIDE the per-file 100% gate — `vitest.config.ts` excludes `packages/*/*/src/bin.ts` because importing a self-executing bin (top-level `await main()`) runs it — which also makes the `export` keywords on these helpers decorative: no spec can import them, so the only exercisers are the subprocess smokes, and the two `built-bin.e2e.ts` suites duplicate their temp-node_modules scaffolding as well. The genuinely per-app pieces are small and real: the ACP bin owns snapshot-mode config selection (`resolveConfigPath`), replay-mode env skipping, the stdin-EOF dispose lifecycle, and stdout purity; the stdio bin owns nothing extra.
## Proposal
Extract the four helpers, parameterized by the bin's diagnostic prefix, into an importable non-bin module shared by both apps — a small published package in the `ui` group (the bins are published artifacts, so their runtime dependency must be published too, not `support/`). Each `bin.ts` becomes a thin self-executing `main()` plus its app-specific glue. The shared module gains unit tests and falls under the coverage gate; the loader-failure lore gets one home; the subprocess smokes remain the artifact-level guard — the published-bin smoke is NOT replaced by unit tests, per the "real entry path" defensive pattern. The implementing PR amends the [extract example app packages RFC](../../implemented/architecture/2026-06-20-extract-example-app-packages.md)'s facts ("boot glue moved into that bin, owned by the app" is the sentence that changes).
## Why not keep the duplication?
The bins were framed as independently-owned published artifacts, and a new package carries fixed overhead (manifest, README, tsconfig reference, publint surface) that rivals the deduplicated line count. But app-vs-app sharing was never weighed by that RFC — it consolidated three example `start.ts` copies INTO the bins and stopped there; the drift is now observed fact rather than speculation; and the coverage-gap argument is independent of the dedup argument: this is the only nontrivial runtime logic in the repo exempt from the per-file 100% gate. The alternative of a copy-by-convention shared source file is the current state with extra steps.
## Acceptance criteria
- The four helpers exist once, unit-tested, under the coverage gate; both bins are thin mains plus app-specific glue.
- Both built-bin smokes still pass under plain node in the node_modules-shaped temp dir, including the missing-config non-zero exit.
- The app-packages RFC's facts are amended in the same change.
## Risks
Churn in two published bins and one new package boundary; the shared module must stay dependency-light (cordis plus the loader). If the implementing PR finds the package overhead genuinely exceeds the dedup — the honest failure mode of this proposal — the fallback that still pays is extracting only the coverage-exempt pure logic (`assertEntriesLoaded`, `resolveConfigPath`) into an importable module within each app package, ending the coverage exemption without a new package.
+4 -2
View File
@@ -63,8 +63,9 @@ dsh-subagent-acp ← dsh-subagent, dsh-agent, dsh-llm, @agentclientprotocol/sdk
dsh-tool-subagent ← dsh-subagent, dsh-tools, dsh-agent, dsh-llm (model-facing delegation tool)
dsh-tool-todo ← dsh-tools, dsh-agent, dsh-session (model-facing todo_write tool; whole list on the session log)
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-session-persistence-jsonl, dsh-agent, dsh-session (stdio chat APP + readline UI + bin)
dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl (ACP server APP + bin)
dsh-app-boot ← (cordis + loader only) (shared bin boot glue: .env, fail-loud guards, boot sequence)
dsh-stdio-agent ← dsh-agent-core, dsh-session-persistence-jsonl, dsh-agent, dsh-session, dsh-app-boot (stdio chat APP + readline UI + bin)
dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl, dsh-app-boot (ACP server APP + bin)
```
The rule: **extension** 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. The sanctioned exception is a **composition/bundle** package like `dsh-agent-core`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it — swapping the loop means shipping a different bundle, not rewiring every extension. 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)).
@@ -104,6 +105,7 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop
| `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`) |
| `app-boot/` | `ui` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the boot sequence | (library for the bins) |
| `llm-replay/` | `support` | Record/replay adapter: short-circuits `llm/stream` with chunks from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) |
| `subagent/` | `subagent` | Abstract subagent seam: named-provider registry for delegating to child agents | `ctx.subagents` |
| `subagent-inprocess/` | `subagent` | Shared in-process subagent run driver used by spawn/fork; pure library, registers nothing | (none) |
+1
View File
@@ -7,6 +7,7 @@ Integrations that expose the agent to an external editor or client. These are **
| `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`) |
| `app-boot/` | Shared boot glue for the two app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
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 is the unstructured analogue of the `acp` bridge and lives INSIDE the stdio app (the `stdio-chat` module of [`stdio-agent/`](stdio-agent/README.md)): it is scaffolding for that one front door, not an independently swappable integration, so it carries no package boundary of its own.
+2
View File
@@ -32,6 +32,7 @@
"peerDependencies": {
"@cordisjs/plugin-include": "^1.0.4",
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
"@deepseek-ai/dsh-app-boot": "^0.0.1",
"@deepseek-ai/dsh-acp": "^0.0.1",
"@deepseek-ai/dsh-agent-core": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
@@ -41,6 +42,7 @@
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-acp": "workspace:^",
"@deepseek-ai/dsh-agent-core": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
+28 -150
View File
@@ -2,167 +2,45 @@
/**
* 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.
* and a bash executor), speaking ACP JSON-RPC on stdio. The shared boot glue —
* `.env` loading, the fail-loud Loader guards, snapshot-aware config
* resolution, the settle-the-tree boot sequence — lives in
* {@link @deepseek-ai/dsh-app-boot}; this bin owns only the ACP-specific
* lifecycle:
*
* 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.
* - `.env` loading is SKIPPED in snapshot REPLAY so a stray key can never
* trigger a live model call.
* - `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`).
* - In a snapshot run the harness closes stdin when done, so dispose the
* context (flushing persistence) and exit cleanly. In a normal editor
* session stdin stays open for the connection's lifetime (the editor kills
* the process), so the EOF handler never fires.
*
* 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.
* STDERR only (the app plugin loads no stdout logger, and the shared guards
* write to stderr); 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'
import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
/**
* 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)
}
const NAME = 'dsh-acp-agent'
/**
* 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.
}
}
/**
* Make a load failure fail loud with a clear message on stderr. Covers the
* failure path the entry-tree check below cannot: when the include's
* `[Service.init]` throws (e.g. a config FILE missing in a real directory), the
* cordis Loader surfaces it as an unhandled promise rejection AFTER `boot()`
* resolves — `loader.await()` does NOT rethrow it (`EntryTree.await()` uses
* `Promise.allSettled`, which swallows rejections). Node's default handler
* already exits non-zero on an unhandled rejection, so this does not change the
* exit code; it replaces the noisy stack dump with a single labelled line (on
* STDERR — stdout is the ACP JSON-RPC channel) and guarantees `process.exit(1)`.
* Install before `boot()`.
*/
export function installFailLoud(): void {
process.on('unhandledRejection', (err: unknown) => {
process.stderr.write(`dsh-acp-agent: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
process.exit(1)
/* v8 ignore start -- thin self-executing composition over the unit-tested
dsh-app-boot helpers; exercised end-to-end by the snapshot suite and the
built-bin smoke */
installFailLoud(NAME)
const snapshotMode = process.env['DSH_SNAPSHOT']
if (snapshotMode !== 'replay') loadEnv(NAME)
const ctx = await boot(NAME, resolveConfigPath(process.argv[2] ?? './cordis.yml', snapshotMode))
if (snapshotMode !== undefined) {
process.stdin.on('end', () => {
void ctx.fiber.dispose().then(() => { process.exit(0) })
})
}
/**
* After the tree settles, assert every loader entry actually started. This is
* the load-bearing guard against the SILENT-exit-0 bug: a plugin module that
* fails to IMPORT (e.g. a config path in a non-existent directory) is caught and
* only LOGGED by the cordis Loader (`entry._init`), leaving the entry with no
* `fiber` and producing no rejection — so the process would otherwise exit 0. A
* started entry has a `fiber`; throw on any entry still missing one so `boot()`
* rejects.
*
* A `disabled` entry is the one legitimate fiber-less state: `Entry.refresh()`
* deliberately skips `init()` for it, so it settles without a fiber by design —
* a valid "plugin turned off" config, not a failed import. Exclude it.
*/
function assertEntriesLoaded(ctx: Context): void {
const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)
if (failed.length > 0) {
const names = failed.map(entry => entry.options.name).join(', ')
throw new Error(`dsh-acp-agent: plugin(s) failed to load: ${names} (see the error(s) logged above)`)
}
}
/**
* Boot the Loader against `absoluteConfigPath`. The include is handed the
* config's ABSOLUTE `file://` URL as its `path`, so resolution never depends on
* `ctx.baseUrl` (an absolute URL ignores the base) and can never fall back to
* the cwd. `baseUrl` is still pinned to the config's directory so the config's
* OWN relative plugin/include paths resolve against it. Returns the root context
* once the whole tree has settled.
*
* The `await ctx.loader.await()` is load-bearing: `loader.create()` returns once
* the include ENTRY is registered, but the include then loads its child plugins
* asynchronously. Without awaiting the tree, `boot()` would resolve while the ACP
* bridge is still mounting — the process would have no stdin handle attached yet
* and could exit 0 silently. Awaiting keeps the process alive until the bridge
* is up.
*
* `loader.await()` does NOT rethrow load errors (`EntryTree.await()` uses
* `Promise.allSettled`), so failures are surfaced two ways: a plugin that fails
* to IMPORT leaves an entry with no fiber, caught here by
* {@link assertEntriesLoaded} (this `boot()` rejects); a plugin whose init THROWS
* surfaces as an unhandled rejection caught by {@link installFailLoud} (installed
* by `main()` before this runs). Together any load failure exits non-zero.
*
* Bare plugin specifiers in the config (`@deepseek-ai/dsh-*`, npm packages) are
* resolved by the cordis Loader's internal module loader, which is only active
* under `node --expose-internals`. The `demo:acp` script runs under tsx (whose
* tsconfig `paths` map resolves the workspace plugins instead), but a consumer
* running the built bin under plain node must pass `--expose-internals` so the
* Loader resolves the config's plugins from the config directory rather than
* relative to its own module.
*/
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: pathToFileURL(absoluteConfigPath).href },
})
await ctx.loader.await()
assertEntriesLoaded(ctx)
return ctx
}
/**
* Entry point. Installs the fail-loud guard, 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> {
installFailLoud()
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 */
+1 -1
View File
@@ -40,7 +40,7 @@ const acpBin = join(repoRoot, 'packages/ui/acp-agent/lib/bin.js')
const dshPackages = [
'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt',
'core/tools', 'core/agent-loop', 'llm/llm', 'llm/llm-deepseek', 'bash/bash',
'bash/bash-local', 'bash/tool-bash', 'support/invariants',
'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot',
'session-persistence/session-persistence',
'session-persistence/session-persistence-jsonl', 'ui/acp', 'ui/acp-agent',
]
+3
View File
@@ -17,6 +17,9 @@
{
"path": "../../../vendor/loader"
},
{
"path": "../app-boot"
},
{
"path": "../acp"
},
+15
View File
@@ -0,0 +1,15 @@
# `@deepseek-ai/dsh-app-boot`
Shared boot glue for the app bins ([`dsh-stdio-agent`](../stdio-agent/README.md), [`dsh-acp-agent`](../acp-agent/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between two published artifacts.
| Export | Role |
|---|---|
| `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` |
| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) |
| `boot(binName, absoluteConfigPath)` | Mount the Loader, include the config by absolute `file://` URL, await the whole tree, assert entries loaded, return the root context |
Two failure classes the guards close — both would otherwise exit 0 with a usable config typo reported only as a log line: `loader.await()` swallows init rejections (`Promise.allSettled`), surfaced instead by `installFailLoud`; a failed plugin IMPORT is only logged by the Loader, leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection.
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`) resolve through the cordis Loader's internal module loader, active only under `node --expose-internals`; the bins' subprocess smokes exercise that path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@deepseek-ai/dsh-app-boot",
"description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@cordisjs/plugin-include": "^1.0.4",
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}
+152
View File
@@ -0,0 +1,152 @@
/**
* Shared boot glue for the app bins (`dsh-stdio-agent`, `dsh-acp-agent`): load
* the gitignored `.env`, install the fail-loud Loader guards, resolve the
* config path (snapshot-aware), and drive the cordis Loader against a leaf
* `cordis.yml` until the whole tree has settled. Each bin stays a thin
* self-executing `main()` over these helpers, parameterized by its diagnostic
* prefix; the loader-failure lore lives here, once, under the per-file
* coverage gate.
*
* Two failure classes the guards close, both of which would otherwise exit 0
* with a usable config typo reported only as a log line:
*
* - `loader.await()` does NOT rethrow a load error (`EntryTree.await()` uses
* `Promise.allSettled`, which swallows rejections). A plugin whose
* `[Service.init]` throws surfaces as an unhandled rejection AFTER `boot()`
* resolves — {@link installFailLoud} turns that into one labelled stderr
* line and a guaranteed non-zero exit.
* - A plugin module that fails to IMPORT is caught and only LOGGED by the
* cordis Loader (`entry._init`), leaving the entry with no `fiber` and
* producing no rejection — {@link assertEntriesLoaded} makes `boot()` reject
* on any such entry instead of returning a half-empty context.
*
* @module @deepseek-ai/dsh-app-boot
*/
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 — including no
* snapshot mode at all — use the path as-is. Returns an absolute path resolved
* from `cwd`.
*/
export function resolveConfigPath(
configPath: string, snapshotMode: string | undefined, cwd: string = process.cwd(),
): string {
const absolute = resolve(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
* `dir` (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 `.env` is a real
* misconfiguration: surface it via `warn` (one line, default stderr) rather
* than silently running with the wrong environment.
*/
export function loadEnv(
binName: string, dir: string = process.cwd(),
warn: (line: string) => void = line => void process.stderr.write(line),
): void {
try {
process.loadEnvFile(resolve(dir, '.env'))
} catch (error) {
if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
warn(`${binName}: failed to load .env: ${String(error)}\n`)
}
// ENOENT (no .env) is fine — rely on the ambient environment.
}
}
/**
* The slice of `process` {@link installFailLoud} needs — injectable so tests
* exercise the handler without registering on (or exiting) the real process.
*/
export interface FailLoudProcess {
on(event: 'unhandledRejection', handler: (err: unknown) => void): unknown
off(event: 'unhandledRejection', handler: (err: unknown) => void): unknown
stderr: { write(chunk: string): unknown }
exit(code: number): void
}
/**
* Make a load failure fail loud with a clear message on stderr. Covers the
* failure path {@link assertEntriesLoaded} cannot: an include whose
* `[Service.init]` throws (e.g. a config FILE that does not exist in a real
* directory) surfaces as an unhandled promise rejection AFTER `boot()`
* resolves. Node's default handler already exits non-zero on an unhandled
* rejection; this replaces the noisy stack dump with a single labelled line on
* STDERR (never stdout — for the ACP bin that channel carries JSON-RPC) and
* guarantees `exit(1)`. Install before `boot()`. Returns the uninstaller
* (tests use it; the bins run until exit and never do).
*/
export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void {
const handler = (err: unknown): void => {
proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
proc.exit(1)
}
proc.on('unhandledRejection', handler)
return () => void proc.off('unhandledRejection', handler)
}
/**
* After the tree settles, assert every loader entry actually started. A
* started entry has a `fiber`; an entry with `fiber === undefined` after the
* tree settled never loaded (its module failed to import), so throw and let
* `boot()` reject instead of returning a half-empty context. A `disabled`
* entry is the one legitimate fiber-less state: `Entry.refresh()` deliberately
* skips `init()` for it — a valid "plugin turned off" config, not a failed
* import — so it is excluded.
*/
export function assertEntriesLoaded(ctx: Context, binName: string): void {
const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)
if (failed.length > 0) {
const names = failed.map(entry => entry.options.name).join(', ')
throw new Error(`${binName}: plugin(s) failed to load: ${names} (see the error(s) logged above)`)
}
}
/**
* Boot the Loader against `absoluteConfigPath` and return the root context
* once the whole tree has settled. The include is handed the config's ABSOLUTE
* `file://` URL as its `path`, so resolution never depends on `ctx.baseUrl`
* (an absolute URL ignores the base) and can never fall back to the cwd;
* `baseUrl` is still pinned to the config's directory so the config's OWN
* relative plugin/include paths resolve against it.
*
* The `await ctx.loader.await()` is load-bearing: `loader.create()` returns
* once the include ENTRY is registered, but the include then loads its child
* plugins asynchronously — without awaiting the tree, `boot()` would resolve
* while the app's plugins are still mounting, and a CLI process with no
* attached handles yet exits 0 silently. Failures surface two ways: an entry
* whose module failed to import is caught here by {@link assertEntriesLoaded}
* (this `boot()` rejects); an init that THROWS surfaces as an unhandled
* rejection caught by {@link installFailLoud} (installed by the bin first).
*
* Bare plugin specifiers in the config (`@deepseek-ai/dsh-*`, npm packages)
* are resolved by the cordis Loader's internal module loader, which is only
* active under `node --expose-internals`; a consumer running a built bin must
* pass that flag (or install the plugins where node hoists them). Relative
* specifiers resolve against the config directory with no flag.
*/
export async function boot(binName: string, 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: pathToFileURL(absoluteConfigPath).href },
})
await ctx.loader.await()
assertEntriesLoaded(ctx, binName)
return ctx
}
+178
View File
@@ -0,0 +1,178 @@
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve, sep } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import type { Context } from 'cordis'
import {
assertEntriesLoaded, boot, installFailLoud, loadEnv, resolveConfigPath,
type FailLoudProcess,
} from '../src/index.ts'
const NAME = 'dsh-test-bin'
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-app-boot-'))
describe('resolveConfigPath', () => {
it('resolves relative to the given cwd outside replay mode', () => {
expect(resolveConfigPath('./cordis.yml', undefined, `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.yml'))
expect(resolveConfigPath('conf/app.yaml', 'record', `${sep}base`)).toBe(resolve(`${sep}base`, 'conf/app.yaml'))
})
it('swaps a cordis.yml/.yaml basename for cordis.snapshot.yml in replay mode', () => {
expect(resolveConfigPath('./cordis.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.snapshot.yml'))
expect(resolveConfigPath('deep/cordis.yaml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'deep/cordis.snapshot.yml'))
})
it('leaves a non-cordis basename alone in replay mode and defaults cwd to the process cwd', () => {
expect(resolveConfigPath('custom.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'custom.yml'))
expect(resolveConfigPath('./x.yml', undefined)).toBe(resolve(process.cwd(), 'x.yml'))
})
})
describe('loadEnv', () => {
it('loads variables from .env in the given dir', () => {
const dir = tmp()
writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_VAR=loaded\n')
const warn = vi.fn()
loadEnv(NAME, dir, warn)
expect(process.env['DSH_APP_BOOT_SPEC_VAR']).toBe('loaded')
expect(warn).not.toHaveBeenCalled()
delete process.env['DSH_APP_BOOT_SPEC_VAR']
})
it('stays silent when no .env exists (ambient environment wins)', () => {
const warn = vi.fn()
loadEnv(NAME, tmp(), warn)
expect(warn).not.toHaveBeenCalled()
})
it('warns (labelled, single line) when .env exists but cannot be loaded', () => {
const dir = tmp()
mkdirSync(join(dir, '.env')) // a directory named .env: present, unreadable as a file
const warn = vi.fn()
loadEnv(NAME, dir, warn)
expect(warn).toHaveBeenCalledTimes(1)
expect(warn.mock.calls[0]?.[0]).toMatch(new RegExp(`^${NAME}: failed to load \\.env: `))
})
it('defaults dir to the process cwd and warn to a stderr write', () => {
const dir = tmp()
writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_DEFAULTS=yes\n')
const previous = process.cwd()
process.chdir(dir)
try {
loadEnv(NAME) // happy path: the default warn sink is never invoked
} finally {
process.chdir(previous)
}
expect(process.env['DSH_APP_BOOT_SPEC_DEFAULTS']).toBe('yes')
delete process.env['DSH_APP_BOOT_SPEC_DEFAULTS']
// The default warn sink itself: point it at a broken .env with stderr
// spied, so the arrow body runs without polluting the test output.
const broken = tmp()
mkdirSync(join(broken, '.env'))
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
let written: string[]
try {
loadEnv(NAME, broken)
written = write.mock.calls.map(call => String(call[0]))
} finally {
write.mockRestore()
}
expect(written).toHaveLength(1)
expect(written[0]).toContain(`${NAME}: failed to load .env: `)
})
})
describe('installFailLoud', () => {
function fakeProc(): FailLoudProcess & { handlers: Array<(err: unknown) => void>; written: string[]; exits: number[] } {
const handlers: Array<(err: unknown) => void> = []
const written: string[] = []
const exits: number[] = []
return {
handlers, written, exits,
on: (_event, handler) => { handlers.push(handler) },
off: (_event, handler) => { handlers.splice(handlers.indexOf(handler), 1) },
stderr: { write: (chunk: string) => { written.push(chunk) } },
exit: (code: number) => { exits.push(code) },
}
}
it('writes one labelled line with the stack and exits 1 on an Error rejection', () => {
const proc = fakeProc()
installFailLoud(NAME, proc)
const error = new Error('boom')
proc.handlers[0]!(error)
expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `)
expect(proc.written[0]).toContain(error.stack)
expect(proc.exits).toEqual([1])
})
it('stringifies a non-Error rejection and an Error without a stack falls back to its message', () => {
const proc = fakeProc()
installFailLoud(NAME, proc)
proc.handlers[0]!('plain failure')
expect(proc.written[0]).toContain('plain failure')
const stackless = new Error('no stack')
delete (stackless as { stack?: string }).stack
proc.handlers[0]!(stackless)
expect(proc.written[1]).toContain('no stack')
expect(proc.exits).toEqual([1, 1])
})
it('returns an uninstaller that removes the handler (and defaults to the real process)', () => {
const proc = fakeProc()
const uninstall = installFailLoud(NAME, proc)
expect(proc.handlers).toHaveLength(1)
uninstall()
expect(proc.handlers).toHaveLength(0)
// Default-proc arm: install on the real process, then immediately uninstall
// so the suite leaks no handler and can never exit the runner.
const before = process.listenerCount('unhandledRejection')
const uninstallReal = installFailLoud(NAME)
expect(process.listenerCount('unhandledRejection')).toBe(before + 1)
uninstallReal()
expect(process.listenerCount('unhandledRejection')).toBe(before)
})
})
describe('assertEntriesLoaded', () => {
const ctxWith = (entries: Array<{ fiber?: unknown; disabled?: boolean; options: { name?: string } }>): Context =>
({ loader: { entries: () => entries } }) as unknown as Context
it('passes when every enabled entry has a fiber', () => {
expect(() => { assertEntriesLoaded(ctxWith([
{ fiber: {}, options: { name: 'a' } },
{ disabled: true, options: { name: 'off' } },
]), NAME) }).not.toThrow()
})
it('throws naming every enabled fiber-less entry', () => {
expect(() => { assertEntriesLoaded(ctxWith([
{ fiber: {}, options: { name: 'ok' } },
{ options: { name: 'broken-a' } },
{ options: { name: 'broken-b' } },
]), NAME) }).toThrow(`${NAME}: plugin(s) failed to load: broken-a, broken-b`)
})
})
describe('boot', () => {
it('boots a leaf config through the real Loader and settles the tree', async () => {
const dir = tmp()
writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
try {
const entries = [...ctx.loader.entries()]
expect(entries.some(entry => entry.options.name === './noop.mjs' && entry.fiber !== undefined)).toBe(true)
} finally {
await ctx.fiber.dispose()
}
})
it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
const dir = tmp()
writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
})
})
+21
View File
@@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/loader"
},
{
"path": "../../../vendor/include"
}
]
}
+2
View File
@@ -32,6 +32,7 @@
"peerDependencies": {
"@cordisjs/plugin-include": "^1.0.4",
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
"@deepseek-ai/dsh-app-boot": "^0.0.1",
"@cordisjs/plugin-logger-console": "^1.0.0",
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-core": "^0.0.1",
@@ -43,6 +44,7 @@
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@cordisjs/plugin-logger-console": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-core": "workspace:^",
+13 -128
View File
@@ -2,139 +2,24 @@
/**
* 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`).
* adapter and a bash executor). The boot glue — `.env` loading, the fail-loud
* Loader guards, the settle-the-tree boot sequence — lives in
* {@link @deepseek-ai/dsh-app-boot}, shared with the ACP bin.
*
* Usage: `dsh-stdio-agent [path-to-cordis.yml]`. The `demo:echo` / `demo:repl`
* scripts invoke it with the example's config.
* Usage: `dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`). The
* `demo:echo` / `demo:repl` scripts invoke it with the example's config.
*
* @module @deepseek-ai/dsh-stdio-agent/bin
*/
import { pathToFileURL } from 'node:url'
import { dirname, resolve } from 'node:path'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
/**
* 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.
}
}
const NAME = 'dsh-stdio-agent'
/**
* Make a load failure fail loud with a clear message on stderr. Covers the
* failure path the entry-tree check below cannot: when the include's
* `[Service.init]` throws (e.g. a config FILE that does not exist in a real
* directory), the cordis Loader surfaces it as an unhandled promise rejection
* AFTER `boot()` has resolved — `loader.await()` does NOT rethrow it, because
* `EntryTree.await()` uses `Promise.allSettled`, which swallows rejections.
* Node's default handler already exits non-zero on an unhandled rejection, so
* this does not change the exit code; it replaces Node's noisy stack dump with a
* single labelled line and guarantees `process.exit(1)`. Install before `boot()`.
*/
export function installFailLoud(): void {
process.on('unhandledRejection', (err: unknown) => {
process.stderr.write(`dsh-stdio-agent: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
process.exit(1)
})
}
/**
* After the tree settles, assert every loader entry actually started. This is
* the load-bearing guard against the SILENT-exit-0 bug: when a plugin module
* fails to IMPORT (e.g. a config path in a non-existent directory, so the include
* plugin itself cannot be resolved), the cordis Loader catches the import error
* and only LOGS it (`entry._init`), leaving the entry with no `fiber` and
* producing no rejection — so the process would otherwise exit 0 with a usable
* config typo reported only as a log line. A started entry has a `fiber`; an
* entry with `fiber === undefined` after the tree settled never loaded. Throw on
* any such entry so `boot()` rejects (and the top-level `await` fails the process
* non-zero) instead of returning a half-empty context.
*
* A `disabled` entry is the one legitimate fiber-less state: `Entry.refresh()`
* deliberately skips `init()` for it, so it settles without a fiber by design.
* That is a valid config (a consumer turning an optional plugin off), not a
* failed import — exclude it so the guard catches only real load failures.
*/
function assertEntriesLoaded(ctx: Context): void {
const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)
if (failed.length > 0) {
const names = failed.map(entry => entry.options.name).join(', ')
throw new Error(`dsh-stdio-agent: plugin(s) failed to load: ${names} (see the error(s) logged above)`)
}
}
/**
* Boot the Loader against `configPath` (resolved from the CWD). The include is
* handed the config's ABSOLUTE `file://` URL as its `path`, so resolution never
* depends on `ctx.baseUrl` (an absolute URL ignores the base) and can never fall
* back to the cwd. `baseUrl` is still pinned to the config's directory so the
* config's OWN relative plugin/include paths (e.g. `./src/mock-llm.ts`) resolve
* against it. Returns the root context once the whole tree has settled.
*
* The `await ctx.loader.await()` is load-bearing: `loader.create()` returns once
* the include ENTRY is registered, but the include then loads its child plugins
* asynchronously. Without awaiting the tree, `boot()` (and `main()`) would
* resolve while the app plugins — the stdin reader, the agent loop — are still
* mounting, and a CLI process with no attached handles yet exits 0 silently.
* Awaiting the tree keeps the process alive until the app's handles are attached.
*
* `loader.await()` does NOT, however, rethrow load errors (`EntryTree.await()`
* uses `Promise.allSettled`), so failures are surfaced two ways: a plugin that
* fails to IMPORT leaves an entry with no fiber, caught here by
* {@link assertEntriesLoaded} (this `boot()` rejects); a plugin whose init
* THROWS surfaces as an unhandled rejection caught by {@link installFailLoud}
* (installed by `main()` before this runs). Together they make any load failure
* exit non-zero with a clear message.
*
* Bare plugin specifiers in the config (`@deepseek-ai/dsh-*`, npm packages) are
* resolved by the cordis Loader's internal module loader, which is only active
* under `node --expose-internals` (the flag the `demo:echo`/`demo:repl` scripts
* pass). Without it the Loader falls back to resolving relative to its own module
* and cannot find the config's plugins, so a consumer running the built bin must
* pass `--expose-internals` (or install the plugins where node hoists them).
*/
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: pathToFileURL(absolute).href },
})
await ctx.loader.await()
assertEntriesLoaded(ctx)
return ctx
}
/**
* Entry point: install the fail-loud guard, 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> {
installFailLoud()
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 start -- thin self-executing composition over the unit-tested
dsh-app-boot helpers; exercised end-to-end by the keyless Loader-path and
built-bin smokes */
installFailLoud(NAME)
loadEnv(NAME)
await boot(NAME, resolveConfigPath(process.argv[2] ?? './cordis.yml', undefined))
/* v8 ignore stop */
@@ -35,7 +35,7 @@ const stdioBin = join(repoRoot, 'packages/ui/stdio-agent/lib/bin.js')
const dshPackages = [
'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt',
'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local',
'bash/tool-bash', 'support/invariants',
'bash/tool-bash', 'support/invariants', 'ui/app-boot',
'session-persistence/session-persistence',
'session-persistence/session-persistence-jsonl', 'ui/stdio-agent',
]
+3
View File
@@ -17,6 +17,9 @@
{
"path": "../../../vendor/loader"
},
{
"path": "../app-boot"
},
{
"path": "../../../vendor/logger-console"
},
+18
View File
@@ -870,6 +870,9 @@ importers:
'@deepseek-ai/dsh-agent-core':
specifier: workspace:^
version: link:../../core/agent-core
'@deepseek-ai/dsh-app-boot':
specifier: workspace:^
version: link:../app-boot
'@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:^
version: link:../../session-persistence/session-persistence-jsonl
@@ -880,6 +883,18 @@ importers:
specifier: ^3.17.0
version: 3.18.0
packages/ui/app-boot:
devDependencies:
'@cordisjs/plugin-include':
specifier: workspace:^
version: link:../../../vendor/include
'@cordisjs/plugin-loader':
specifier: workspace:^
version: link:../../../vendor/loader
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader)
packages/ui/stdio-agent:
devDependencies:
'@cordisjs/plugin-include':
@@ -897,6 +912,9 @@ importers:
'@deepseek-ai/dsh-agent-core':
specifier: workspace:^
version: link:../../core/agent-core
'@deepseek-ai/dsh-app-boot':
specifier: workspace:^
version: link:../app-boot
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+1
View File
@@ -41,6 +41,7 @@
{ "path": "./packages/support/invariants" },
{ "path": "./packages/ui/acp" },
{ "path": "./packages/ui/acp-agent" },
{ "path": "./packages/ui/app-boot" },
{ "path": "./packages/ui/stdio-agent" },
{ "path": "./packages/support/llm-replay" },
{ "path": "./packages/subagent/subagent" },
+1
View File
@@ -52,6 +52,7 @@
{ "path": "./packages/support/invariants" },
{ "path": "./packages/ui/acp" },
{ "path": "./packages/ui/acp-agent" },
{ "path": "./packages/ui/app-boot" },
{ "path": "./packages/ui/stdio-agent" },
{ "path": "./packages/support/llm-replay" },
{ "path": "./packages/subagent/subagent" },