feat(ui): share the app bins' boot glue in @deepseek-ai/dsh-app-boot
The four near-twin helpers the two published bins carried — loadEnv, installFailLoud, assertEntriesLoaded, boot — live once in packages/ui/app-boot, parameterized by the bin's diagnostic prefix and injectable at their side-effect seams (warn sink, process slice), so every branch sits under the per-file 100% coverage gate: the unit suite drives boot() in-process against the real Loader (relative-specifier configs) through both the settled-tree path and the fiber-less-entry rejection, and exercises the ENOENT/unloadable .env split, the Error/non-Error/stackless fail-loud arms, and the disabled-entry exclusion. resolveConfigPath (snapshot-aware) becomes the single path resolver for both bins. Each bin.ts is now a thin self-executing composition plus its app-specific lifecycle (acp: replay env-skip + stdin-EOF dispose; stdio: nothing extra), exports nothing, and stays coverage-excluded; the built-bin smokes still prove both artifacts under plain node in the node_modules-shaped temp dir (now symlinking ui/app-boot), including the missing-config non-zero exit. Implements docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md (moved from proposed/ and amended); the extract-example-app-packages RFC's bin-ownership facts are amended in the same change.
This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -59,7 +59,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
|
||||
| [Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger)](proposed/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) | 2026-07-04 |
|
||||
| [Prune write-only fields and a dead routing knob from the fs seam](proposed/simplification/2026-07-04-prune-write-only-fs-surface.md) | 2026-07-04 |
|
||||
| [Remove the `agent/steering` mirror emit](proposed/simplification/2026-07-04-remove-agent-steering-mirror.md) | 2026-07-04 |
|
||||
| [Share the app bins' boot glue instead of maintaining twin copies](proposed/simplification/2026-07-04-share-app-bin-boot-glue.md) | 2026-07-04 |
|
||||
| [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](proposed/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 |
|
||||
| [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](proposed/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 |
|
||||
|
||||
@@ -120,6 +119,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
|
||||
| [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 |
|
||||
| [Stop mirroring the token stream as an agent event](implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) | 2026-07-02 |
|
||||
| [Fold the stdio UI helper into the stdio app](implemented/simplification/2026-07-04-fold-stdio-ui-helper.md) | 2026-07-04 |
|
||||
| [Share the app bins' boot glue instead of maintaining twin copies](implemented/simplification/2026-07-04-share-app-bin-boot-glue.md) | 2026-07-04 |
|
||||
|
||||
### Architecture
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Each example is now **mostly an invocation of an app package**, splitting the wi
|
||||
|
||||
- **`@deepseek-ai/dsh-agent-core`** ([packages/core/agent-core](../../../../packages/core/agent-core)) — a Cordis bundle plugin for the providerless, executor-less, UI-less spine: `timer` + `llm` + sessions + system-prompt + tools + agents + invariants + `tool-bash` + `agent-loop`, mounted as child plugins inside its `apply(ctx)` via `ctx.plugin(...)`. This is the old `base-core.yml` **minus** `bash-local`, **plus** `timer` and the loop, as code instead of a YAML include. The bundle **forwards** `agent-loop`'s `agents` list as its own config (`export const Config = AgentLoop.Config`, default `[]`, the existing `AgentLoop.Config` shape in [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)) — so each app supplies its own pre-created agents. This is precisely the reason the old `base-core.yml` gave for keeping `agent-loop` *out* of the shared core ("the examples disagree — stdio needs a pre-created `main`, acp needs none"); forwarding the config dissolves that objection — the loop is shared, the agents list is per-app. The bundle children register into the root service store, so a leaf-mounted sibling (the adapter, the executor) sees them exactly as a nested `plugin-include` subtree's services were seen before. Depending on the CONCRETE `dsh-agent-loop` (not just the `dsh-agent` interface) is deliberate and is the sanctioned exception to the "extension plugins depend on interfaces, never on the concrete loop" rule (packages/README.md, docs/architecture.md § Layering): the rule constrains plugins that EXTEND the system, whereas this bundle's whole job is to COMPOSE the concrete spine. Swapping the loop means publishing a different bundle, not rewiring every extension.
|
||||
- **`@deepseek-ai/dsh-stdio-agent`** ([packages/ui/stdio-agent](../../../../packages/ui/stdio-agent)) and **`@deepseek-ai/dsh-acp-agent`** ([packages/ui/acp-agent](../../../../packages/ui/acp-agent)) — app packages, each consuming `dsh-agent-core` and **baking in its coupled front-door cluster**: stdio = `ui-stdio` + console logger + a pre-created `main`; acp = the `acp` bridge + JSONL persistence + **no stdout logger** + no pre-created agents. The leaf no longer carries the cluster, so it has no logger entry to copy wrong by default — the common stdout-purity mistake loses its foothold. (A leaf can still *add* a sibling logger entry — a package cannot forbid what a leaf author writes — so the rule "never add a stdout logger to an ACP leaf" stays documented at the leaf; what changed is that the default leaf has nothing to get wrong.) They land under the existing `ui` group alongside `acp`, so no new package group (and no `tsconfig`/`packages/README` group plumbing) was needed.
|
||||
- **`start.ts` is gone.** Each app package exposes a `bin` (`dsh-stdio-agent` / `dsh-acp-agent`); the `demo:*` scripts invoke it (e.g. `dsh-stdio-agent ./cordis.yml`). The Loader-boot tail, `.env` loading, snapshot-mode selection, and stdin-dispose lifecycle moved into that bin, owned by the app. The `bin.ts` files are coverage-excluded (a self-executing CLI entry, like the old `start.ts`) and driven by the keyless Loader-path tests.
|
||||
- **`start.ts` is gone.** Each app package exposes a `bin` (`dsh-stdio-agent` / `dsh-acp-agent`); the `demo:*` scripts invoke it (e.g. `dsh-stdio-agent ./cordis.yml`). The Loader-boot tail, `.env` loading, and fail-loud guards live in the shared [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) package (unit-tested under the per-file coverage gate — see [share the app bins' boot glue](../simplification/2026-07-04-share-app-bin-boot-glue.md)); each bin is a thin self-executing composition over those helpers plus its app-specific lifecycle (the ACP bin: snapshot-mode selection and stdin-dispose). The `bin.ts` files themselves stay coverage-excluded (self-executing CLI entries, like the old `start.ts`) and are driven by the keyless Loader-path tests.
|
||||
- **Each leaf `cordis.yml` collapses** to backends + config: the LLM adapter (`llm-deepseek` with apiKey/models, or `llm-replay`), the bash executor (`bash-local`), `hmr` for the stdio demos (see the amendment below), and one app entry carrying the app's config (model, system prompt, persistence root — surfaced as the app package's own `Config`, which routes each value to wherever the app wires it: stdio onto its pre-created agent, acp onto the bridge plugin).
|
||||
- **echo-agent folds onto `dsh-stdio-agent`**, swapping the LLM backend to the local `mock-llm` and adding the local `echo-tool` (plus `bash-local`, which the spine's `tool-bash` injects) at the leaf — the clean demonstration of "swap the backend, keep the app". `mock-llm.ts` / `echo-tool.ts` stay as example-local teaching plugins.
|
||||
- **`base.yml`, `base-core.yml`, and `acp-agent/acp-tail.yml` are retired** — the spine they shared now lives in `dsh-agent-core`.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# RFC: Share the app bins' boot glue instead of maintaining twin copies
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
`packages/ui/stdio-agent/src/bin.ts` and `packages/ui/acp-agent/src/bin.ts` carried four near-twin helpers — `loadEnv`, `installFailLoud`, `assertEntriesLoaded`, `boot` — whose bodies differed essentially in the diagnostic prefix, plus two copies of the hardest-won boot lore in the repo: the `Promise.allSettled` swallow inside `loader.await()`, the silent-exit-0 import-failure guard, and the `--expose-internals` resolution note. The copies had drifted (`boot(configPath)` resolved the path internally in one bin but required a pre-resolved absolute path in the other, with forked JSDoc prose), and all of it sat outside the per-file 100% gate — `vitest.config.ts` excludes `packages/*/*/src/bin.ts` because importing a self-executing bin runs it — which also made the helpers' `export` keywords decorative: no spec could import them, so the only exercisers were subprocess smokes.
|
||||
|
||||
## Decision
|
||||
|
||||
The helpers live once, in [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) (`packages/ui/app-boot`, in the `ui` group because the bins are published artifacts whose runtime dependency must itself be published, not `support/`): `resolveConfigPath` (snapshot-aware, the single path resolver for both bins), `loadEnv`, `installFailLoud`, `assertEntriesLoaded`, and `boot`, each parameterized by the bin's diagnostic prefix and injectable at its side-effect seams (the warn sink, the process slice) so the unit suite covers every branch — including `boot()` driven in-process against the real Loader with relative-specifier configs, both the settled-tree happy path and the fiber-less-entry rejection. The package carries the per-file 100% coverage gate; the loader-failure lore has one home.
|
||||
|
||||
Each `bin.ts` is a thin self-executing composition over the shared helpers plus its app-specific lifecycle (the ACP bin: replay-mode env skipping and the stdin-EOF dispose; the stdio bin: nothing extra). The bins stay coverage-excluded and export nothing; the published-artifact guards are unchanged — the built-bin smokes still run each bin under plain node in a node_modules-shaped temp dir (now symlinking `ui/app-boot` too) and still assert the missing-config non-zero exit, per the "real entry path means the published artifact" defensive pattern. The [extract-example-app-packages RFC](../architecture/2026-06-20-extract-example-app-packages.md)'s bin-ownership facts are amended accordingly.
|
||||
|
||||
## Why not keep the duplication?
|
||||
|
||||
The bins were framed as independently-owned published artifacts, and a new package carries fixed overhead (manifest, README, tsconfig reference, publint surface) comparable to the deduplicated line count. But app-vs-app sharing was never weighed by the RFC that created the bins — it consolidated three example `start.ts` copies INTO the bins and stopped there; the drift was observed fact; and the coverage-gap argument is independent of the dedup argument: this was the only nontrivial runtime logic in the repo exempt from the per-file 100% gate. The recorded fallback (extracting only the pure logic into per-app modules) would have ended the exemption but kept two homes for the lore.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A boot-glue change (a new guard, a resolution fix) lands once and both published bins inherit it; the bins cannot drift apart again.
|
||||
- `dsh-app-boot` stays dependency-light (cordis + the loader/include pair) — it is boot machinery, not app surface.
|
||||
- The bins' own files are near-trivial compositions; everything with branches lives under the coverage gate.
|
||||
@@ -1,27 +0,0 @@
|
||||
# RFC: Share the app bins' boot glue instead of maintaining twin copies
|
||||
|
||||
Status: proposed
|
||||
|
||||
## Problem
|
||||
|
||||
`packages/ui/stdio-agent/src/bin.ts` and `packages/ui/acp-agent/src/bin.ts` carry four near-twin helpers — `loadEnv`, `installFailLoud`, `assertEntriesLoaded`, `boot` — whose bodies differ essentially in the diagnostic prefix, plus two copies of the hardest-won boot lore in the repo: the `Promise.allSettled` swallow inside `loader.await()`, the silent-exit-0 import-failure guard, and the `--expose-internals` resolution note (the failure classes behind AGENTS.md's "real entry path means the published artifact" pattern). Drift has already begun: `boot(configPath)` resolves the path internally in one bin but requires a pre-resolved absolute path in the other, and the twin JSDoc prose has forked.
|
||||
|
||||
The duplication is aggravated by a coverage hole: all of this logic sits OUTSIDE the per-file 100% gate — `vitest.config.ts` excludes `packages/*/*/src/bin.ts` because importing a self-executing bin (top-level `await main()`) runs it — which also makes the `export` keywords on these helpers decorative: no spec can import them, so the only exercisers are the subprocess smokes, and the two `built-bin.e2e.ts` suites duplicate their temp-node_modules scaffolding as well. The genuinely per-app pieces are small and real: the ACP bin owns snapshot-mode config selection (`resolveConfigPath`), replay-mode env skipping, the stdin-EOF dispose lifecycle, and stdout purity; the stdio bin owns nothing extra.
|
||||
|
||||
## Proposal
|
||||
|
||||
Extract the four helpers, parameterized by the bin's diagnostic prefix, into an importable non-bin module shared by both apps — a small published package in the `ui` group (the bins are published artifacts, so their runtime dependency must be published too, not `support/`). Each `bin.ts` becomes a thin self-executing `main()` plus its app-specific glue. The shared module gains unit tests and falls under the coverage gate; the loader-failure lore gets one home; the subprocess smokes remain the artifact-level guard — the published-bin smoke is NOT replaced by unit tests, per the "real entry path" defensive pattern. The implementing PR amends the [extract example app packages RFC](../../implemented/architecture/2026-06-20-extract-example-app-packages.md)'s facts ("boot glue moved into that bin, owned by the app" is the sentence that changes).
|
||||
|
||||
## Why not keep the duplication?
|
||||
|
||||
The bins were framed as independently-owned published artifacts, and a new package carries fixed overhead (manifest, README, tsconfig reference, publint surface) that rivals the deduplicated line count. But app-vs-app sharing was never weighed by that RFC — it consolidated three example `start.ts` copies INTO the bins and stopped there; the drift is now observed fact rather than speculation; and the coverage-gap argument is independent of the dedup argument: this is the only nontrivial runtime logic in the repo exempt from the per-file 100% gate. The alternative of a copy-by-convention shared source file is the current state with extra steps.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- The four helpers exist once, unit-tested, under the coverage gate; both bins are thin mains plus app-specific glue.
|
||||
- Both built-bin smokes still pass under plain node in the node_modules-shaped temp dir, including the missing-config non-zero exit.
|
||||
- The app-packages RFC's facts are amended in the same change.
|
||||
|
||||
## Risks
|
||||
|
||||
Churn in two published bins and one new package boundary; the shared module must stay dependency-light (cordis plus the loader). If the implementing PR finds the package overhead genuinely exceeds the dedup — the honest failure mode of this proposal — the fallback that still pays is extracting only the coverage-exempt pure logic (`assertEntriesLoaded`, `resolveConfigPath`) into an importable module within each app package, ending the coverage exemption without a new package.
|
||||
Reference in New Issue
Block a user