Преглед изворни кода

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.
Tianyi Cui пре 2 месеци
родитељ
комит
4a0941fb4b

+ 2 - 0
AGENTS.md

@@ -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
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` |

+ 1 - 1
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
 

+ 1 - 1
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`.

+ 23 - 0
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.

+ 0 - 27
docs/rfc/proposed/simplification/2026-07-04-share-app-bin-boot-glue.md

@@ -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
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) |

+ 1 - 0
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.
 

+ 2 - 0
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:^",

+ 29 - 151
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.
- *
- * 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.
+ * 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:
+ *
+ *  - `.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'
-
-/**
- * Resolve the config to boot, honoring snapshot REPLAY. Given the requested
- * path, replay mode swaps a `cordis.yml` basename for `cordis.snapshot.yml` in
- * the SAME directory (the keyless replay tree). Other modes use the path as-is.
- * Returns an absolute path resolved from the cwd.
- */
-export function resolveConfigPath(configPath: string, snapshotMode: string | undefined): string {
-  const absolute = resolve(process.cwd(), configPath)
-  if (snapshotMode !== 'replay') return absolute
-  const dir = dirname(absolute)
-  const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml')
-  return resolve(dir, replayName)
-}
-
-/**
- * Load `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` from a gitignored `.env` in the
- * cwd (Node native). Diagnostics go to STDERR (stdout is the protocol). In
- * REPLAY mode the caller skips this entirely — replay must never reach the
- * network, so a present `.env` must not enable a live call.
- */
-function loadEnv(): void {
-  try {
-    process.loadEnvFile(resolve(process.cwd(), '.env'))
-  } catch (error) {
-    if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
-      process.stderr.write(`dsh-acp-agent: failed to load .env: ${String(error)}\n`)
-    }
-    // ENOENT (no .env) is fine — rely on the ambient environment.
-  }
-}
-
-/**
- * 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)
-  })
-}
+import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
 
-/**
- * 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)`)
-  }
-}
+const NAME = 'dsh-acp-agent'
 
-/**
- * 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 },
+/* 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) })
   })
-  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
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',
 ]

+ 3 - 0
packages/ui/acp-agent/tsconfig.json

@@ -17,6 +17,9 @@
     {
       "path": "../../../vendor/loader"
     },
+    {
+      "path": "../app-boot"
+    },
     {
       "path": "../acp"
     },

+ 15 - 0
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.

+ 34 - 0
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"
+  }
+}

+ 152 - 0
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<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 - 0
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`)
+  })
+})

+ 21 - 0
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"
+    }
+  ]
+}

+ 2 - 0
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:^",

+ 13 - 128
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.
-  }
-}
-
-/**
- * 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')
-}
+const NAME = 'dsh-stdio-agent'
 
-/* 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 */

+ 1 - 1
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',
 ]

+ 3 - 0
packages/ui/stdio-agent/tsconfig.json

@@ -17,6 +17,9 @@
     {
       "path": "../../../vendor/loader"
     },
+    {
+      "path": "../app-boot"
+    },
     {
       "path": "../../../vendor/logger-console"
     },

+ 18 - 0
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

+ 1 - 0
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" },

+ 1 - 0
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" },