diff --git a/AGENTS.md b/AGENTS.md index 004fef4611..184c75d798 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,6 +112,8 @@ packages/ Harness packages, grouped by role at packages///. 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 diff --git a/docs/module-graph.md b/docs/module-graph.md index c2c2c97bf7..64b89c294f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -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` | diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 9a4aff345e..c207d50e76 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -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 diff --git a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md index 02517d7322..380586699b 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md +++ b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md @@ -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`. diff --git a/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md new file mode 100644 index 0000000000..faa7b3f106 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md @@ -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. diff --git a/docs/rfc/proposed/simplification/2026-07-04-share-app-bin-boot-glue.md b/docs/rfc/proposed/simplification/2026-07-04-share-app-bin-boot-glue.md deleted file mode 100644 index adc3ec6da3..0000000000 --- a/docs/rfc/proposed/simplification/2026-07-04-share-app-bin-boot-glue.md +++ /dev/null @@ -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. diff --git a/packages/README.md b/packages/README.md index f016f1e149..2f87d57d0d 100644 --- a/packages/README.md +++ b/packages/README.md @@ -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) | diff --git a/packages/ui/README.md b/packages/ui/README.md index 1a3c45727d..8b0293cf9e 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -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. diff --git a/packages/ui/acp-agent/package.json b/packages/ui/acp-agent/package.json index 72eb95b2f7..71f8fe0171 100644 --- a/packages/ui/acp-agent/package.json +++ b/packages/ui/acp-agent/package.json @@ -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:^", diff --git a/packages/ui/acp-agent/src/bin.ts b/packages/ui/acp-agent/src/bin.ts index 24e31f3251..1cbdff64d1 100644 --- a/packages/ui/acp-agent/src/bin.ts +++ b/packages/ui/acp-agent/src/bin.ts @@ -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 { - 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 { - 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 */ diff --git a/packages/ui/acp-agent/tests/built-bin.e2e.ts b/packages/ui/acp-agent/tests/built-bin.e2e.ts index 51c5d53c0b..d900c14152 100644 --- a/packages/ui/acp-agent/tests/built-bin.e2e.ts +++ b/packages/ui/acp-agent/tests/built-bin.e2e.ts @@ -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', ] diff --git a/packages/ui/acp-agent/tsconfig.json b/packages/ui/acp-agent/tsconfig.json index ffea8ec6f6..6cc211087c 100644 --- a/packages/ui/acp-agent/tsconfig.json +++ b/packages/ui/acp-agent/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/loader" }, + { + "path": "../app-boot" + }, { "path": "../acp" }, diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md new file mode 100644 index 0000000000..7a03b2b72b --- /dev/null +++ b/packages/ui/app-boot/README.md @@ -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. diff --git a/packages/ui/app-boot/package.json b/packages/ui/app-boot/package.json new file mode 100644 index 0000000000..67b8b00dbc --- /dev/null +++ b/packages/ui/app-boot/package.json @@ -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" + } +} diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts new file mode 100644 index 0000000000..5322ca7eb6 --- /dev/null +++ b/packages/ui/app-boot/src/index.ts @@ -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 { + 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 +} diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts new file mode 100644 index 0000000000..510186ebb0 --- /dev/null +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -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`) + }) +}) diff --git a/packages/ui/app-boot/tsconfig.json b/packages/ui/app-boot/tsconfig.json new file mode 100644 index 0000000000..3171312de4 --- /dev/null +++ b/packages/ui/app-boot/tsconfig.json @@ -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" + } + ] +} diff --git a/packages/ui/stdio-agent/package.json b/packages/ui/stdio-agent/package.json index f6db23a232..d219248f41 100644 --- a/packages/ui/stdio-agent/package.json +++ b/packages/ui/stdio-agent/package.json @@ -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:^", diff --git a/packages/ui/stdio-agent/src/bin.ts b/packages/ui/stdio-agent/src/bin.ts index a07bb2e600..7056486996 100644 --- a/packages/ui/stdio-agent/src/bin.ts +++ b/packages/ui/stdio-agent/src/bin.ts @@ -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 { - 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 { - 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 */ diff --git a/packages/ui/stdio-agent/tests/built-bin.e2e.ts b/packages/ui/stdio-agent/tests/built-bin.e2e.ts index 30bc952673..cda8b41b1f 100644 --- a/packages/ui/stdio-agent/tests/built-bin.e2e.ts +++ b/packages/ui/stdio-agent/tests/built-bin.e2e.ts @@ -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', ] diff --git a/packages/ui/stdio-agent/tsconfig.json b/packages/ui/stdio-agent/tsconfig.json index 948813c370..8b76352d11 100644 --- a/packages/ui/stdio-agent/tsconfig.json +++ b/packages/ui/stdio-agent/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/loader" }, + { + "path": "../app-boot" + }, { "path": "../../../vendor/logger-console" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8950475e0c..22f2119329 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/tsconfig.build.json b/tsconfig.build.json index 477c68d31e..b6ba7901f2 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -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" }, diff --git a/tsconfig.json b/tsconfig.json index 7d1209b42e..9cd7aa8a6d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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" },