فهرست منبع

Merge pull request #976 from deepseek-harness/fix/nodeboot-dep

fix(cli): expose source-launch activation errors
Turtle 1 ماه پیش
والد
کامیت
7239a2201e
25فایلهای تغییر یافته به همراه441 افزوده شده و 78 حذف شده
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml
  2. 2 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
  3. 2 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml
  5. 2 2
      .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md
  6. 2 2
      .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md
  7. 2 2
      apps/cli/src/app-cli-entry.ts
  8. 2 2
      docs/cordis-catalog/services.md
  9. 6 0
      examples/headless-agent/tests/fixtures/startup-activation-error/activation-error.mjs
  10. 2 0
      examples/headless-agent/tests/fixtures/startup-activation-error/cordis.yml
  11. 1 0
      examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml
  12. 16 0
      examples/headless-agent/tests/headless.snapshot.ts
  13. 3 0
      examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt
  14. 2 2
      packages/client/modules/README.i18n.yaml
  15. 2 0
      packages/client/modules/README.md
  16. 2 0
      packages/client/modules/README.zh.md
  17. 60 6
      packages/client/modules/src/index.ts
  18. 87 0
      packages/client/modules/tests/node-half.spec.ts
  19. 1 0
      packages/host/directory-picker-auto/tests/loader-composition.spec.ts
  20. 2 2
      packages/ui/app-boot/README.i18n.yaml
  21. 4 4
      packages/ui/app-boot/README.md
  22. 4 4
      packages/ui/app-boot/README.zh.md
  23. 89 20
      packages/ui/app-boot/src/index.ts
  24. 125 24
      packages/ui/app-boot/tests/app-boot.spec.ts
  25. 19 0
      packages/ui/app-boot/tsdown.config.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
-2026-07-23-client-plugin-loading-model.md: b0873b7aa7bccd3d613f3113fa18207770952e5d
-2026-07-23-client-plugin-loading-model.zh.md: f3472dbfc5a78924e77337bf92ce5983c8492c4c
+2026-07-23-client-plugin-loading-model.md: 02347f2964942b89ec1f0a6ec483f4c2b2f9e68c
+2026-07-23-client-plugin-loading-model.zh.md: ea927d35860fbbba567c47cea0ee3a45133ce0f4

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md

@@ -56,8 +56,8 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
 
 **Host side — compose the graph.**
 
-1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the settle/sweep so the fail-loud triple covers it. A roster row that fails to import is caught by the boot's `assertEntriesLoaded`.
-2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses a declared plugin without a built `./client` bundle, and any malformed declaration field — activation-time fail loud (a FAILED fiber the sweep reports).
+1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the host activation audit so the same check covers it. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)).
+2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the host audit reports either error from the FAILED fiber.
 3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Each bundle's content hash is its `rev` (cache busting + HMR diff anchor), the row set hashes into `graph.rev`, and every row is fetch-served: `/plugins/<id>/client.js?rev=…`. The graph types are single-sourced in the modules package's `./impl` export — the webserver knows nothing about the graph (it is a plain route-registration plugin; modules registers the bundle route and taps the index render itself).
 
 Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a dshClient package existing in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md

@@ -56,8 +56,8 @@ vendored Loader 经其 `internal` seam 消费模块系统——唯一调用点
 
 **host 侧——组合这张图。**
 
-1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 settle/sweep 之前追加 `client-hmr` 行,使 fail-loud 三件套一并覆盖它。名册行 import 失败由 boot 的 `assertEntriesLoaded` 捕获
-2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它拒绝声明了插件却没有已构建 `./client` bundle 的包,也拒绝任何畸形的声明字段——激活期大声失败(FAILED fiber,由 sweep 上报)
+1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 host 激活检查之前追加 `client-hmr` 行,使同一项检查覆盖它。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.md))
+2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,host 检查会从 FAILED fiber 报告这两类错误
 3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。每个 bundle 的内容哈希是其 `rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`,每一行都经 fetch 供给:`/plugins/<id>/client.js?rev=…`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知(它是朴素路由注册插件;bundle 路由和 index 渲染 tap 都由 modules 自己注册)。
 
 为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dshClient 包存在于仓库里,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md
-2026-07-24-web-config-tree-boot-and-transport-layering.md: a2080024d36d54162f4f4aa79896e51efd708f59
-2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 1c430bf2939f9f556f378bdeb78872a0091a592d
+2026-07-24-web-config-tree-boot-and-transport-layering.md: 88f94b1f58ae7a3451c7772f4a9ff7d6564254c0
+2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: ea2a8f70a6c2d4207d4388a9303fbc6ce6e94238

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md

@@ -12,9 +12,9 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)
 
 ## Decision
 
-**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle sweep — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven, and the boot compensates with a fail-loud triple: `assertEntriesLoaded` (import failures), `installFailLoud` (late apply rejections), and an all-ACTIVE sweep (PENDING fibers — cordis inject waiting has no timeout).
+**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle audit — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits only failed fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; before throwing, it marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations.
 
-**Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the triple. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep.
+**Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep.
 
 **Config sources have one declaration place each.** yml static values are engineering defaults; the profile json (`./.dsh-tmp-profile/config.json`, read-only, never created, cwd-anchored until the `$DSH_HOME` migration) is user config mapped through a static `PROFILE_MAPPINGS` table onto target rows (`provider`/`model` → the `api-gateway` row, `persistenceRoot` → the jsonl row); CLI flags map onto the `webserver` row with a field set disjoint from the json's; env values enter through yml `!!js` expressions, never through the mapping table. Patches replace a row's config wholesale, so the entry class re-reads the yml row's static values (bypass parse) and merges overrides on top. An unmapped json key fails loud. The resolved frontend `distIndex` rides the same patch channel — an assembly fact, not user config.
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md

@@ -12,9 +12,9 @@ Status: implemented
 
 ## 决策
 
-**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle sweep 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动,boot 以 fail-loud 三件套补偿:`assertEntriesLoaded`(import 失败)、`installFailLoud`(迟到的 apply 拒绝)、all-ACTIVE sweep(PENDING fiber——cordis inject 等待没有超时)
+**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、仅等待失败的 fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现
 
-**boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加三件套。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。
+**boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。
 
 **每个配置源有唯一声明位置。** yml 静态值是工程默认;profile json(`./.dsh-tmp-profile/config.json`,只读、绝不创建、暂锚 cwd 直至 `$DSH_HOME` 迁移)是用户配置,经静态 `PROFILE_MAPPINGS` 表映射到目标行(`provider`/`model` → `api-gateway` 行,`persistenceRoot` → jsonl 行);CLI flags 映射到 `webserver` 行、字段集与 json 不相交;env 值经 yml `!!js` 表达式进入,绝不进映射表。patch 整体替换行 config,故 entry 类旁路 parse 重读 yml 行静态值再叠加覆盖。未映射的 json 键 fail loud。解析出的前端 `distIndex` 走同一 patch 通道——装配事实,不是用户配置。
 

+ 2 - 2
apps/cli/src/app-cli-entry.ts

@@ -3,7 +3,7 @@
  * for the Web/headless surface.
  * Everything here is what must exist before the Loader runs: the patch
  * composition over the shipped base and surface overlay (profile json + CLI
- * flags + the resolved frontend dist), and the fail-loud triple after the tree
+ * flags + the resolved frontend dist), and the fail-loud activation audit after the tree
  * settles. The environment is what the bin already loaded (ambient plus the
  * invoking directory's `.env`); `$DSH_HOME/.env` belongs to the credential
  * provider and is never hoisted here.
@@ -246,7 +246,7 @@ export class AppCLIEntry {
     if (telemetryPatch !== undefined) this.patches.push(telemetryPatch)
   }
 
-  /** Shared Loader boot; the dev HMR row mounts before await so the fail-loud sweep covers it. */
+  /** Shared Loader boot; the dev HMR row mounts before await so the activation audit covers it. */
   private async bootTree(): Promise<void> {
     // One include of the shared base with every overlay as a sibling patch
     // list: patches never cross an include boundary, so nesting them would

+ 2 - 2
docs/cordis-catalog/services.md

@@ -328,7 +328,7 @@ Source: [`packages/bash/tool-bash/src/index.ts:104`](../../packages/bash/tool-ba
 
 ## `ctx.clientModuleHost` — `ClientModuleHostService`
 
-The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot sweep reports it).
+The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
 
 ```ts cordis-catalog
 /**
@@ -368,7 +368,7 @@ onRebuilt(listener: (id: string, rev: string) => void): () => void
 onGraphChanged(listener: () => void): () => void
 ```
 
-Source: [`packages/client/modules/src/index.ts:143`](../../packages/client/modules/src/index.ts)
+Source: [`packages/client/modules/src/index.ts:184`](../../packages/client/modules/src/index.ts)
 
 ## `ctx.codeRuntime` — `CodeRuntime` (abstract seam)
 

+ 6 - 0
examples/headless-agent/tests/fixtures/startup-activation-error/activation-error.mjs

@@ -0,0 +1,6 @@
+/** Fail activation with a deterministic stack so the user-visible startup diagnostic is snapshot-stable. */
+export function apply() {
+  const failure = new Error('startup activation snapshot failure')
+  failure.stack = 'Error: startup activation snapshot failure\n    at activation-error-fixture'
+  throw failure
+}

+ 2 - 0
examples/headless-agent/tests/fixtures/startup-activation-error/cordis.yml

@@ -0,0 +1,2 @@
+- id: activation-error
+  name: ./activation-error.mjs

+ 1 - 0
examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml

@@ -8,6 +8,7 @@
 - id: telemetry-redact-rule
   name: './telemetry-redact-rule.ts'
 
+# Managed child-process groups required by the bash executor.
 - id: subprocess
   name: '@deepseek-ai/dsh-subprocess-local'
 

+ 16 - 0
examples/headless-agent/tests/headless.snapshot.ts

@@ -33,6 +33,8 @@ const credentialsScenarioDir = join(snapshotsDir, 'missing-credential')
 const credentialsConfigPath = fileURLToPath(new URL('../credentials.cordis.snapshot.yml', import.meta.url))
 const ralphScenarioDir = join(snapshotsDir, 'ralph-loop')
 const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url))
+const startupFailureConfigPath = fileURLToPath(new URL('./fixtures/startup-activation-error/cordis.yml', import.meta.url))
+const startupFailureExpected = join(snapshotsDir, 'startup-activation-error', 'stderr.expected.txt')
 const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url))
 const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
 const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url))
@@ -167,6 +169,20 @@ async function persistedLogs(cwd: string): Promise<PersistedLog[]> {
 }
 
 describe('headless stream-json snapshots', () => {
+  it('prints the original Loader activation error through the assembled one-shot app', async () => {
+    const result = await runLoaderSmoke({
+      label: 'headless startup activation error snapshot',
+      tempDirPrefix: 'headless-snapshot-startup-error-',
+      binScript,
+      configPath: startupFailureConfigPath,
+      binArgs: ['--config', startupFailureConfigPath, '--output-format', 'stream-json', 'unreachable task'],
+      tsconfigPath,
+      expectedExitCode: 1,
+    })
+    expect(result.stdout).toBe('')
+    await expect(result.stderr).toMatchFileSnapshot(startupFailureExpected)
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
+
   it('retries a transient provider failure through the one-shot app', async () => {
     const prompt = await scenarioPrompt(retryScenarioDir, 'provider-retry')
     const streamExpected = join(retryScenarioDir, 'stream-json.expected.jsonl')

+ 3 - 0
examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt

@@ -0,0 +1,3 @@
+dsh-cli-demo: dsh-cli-demo: 1 entry did not activate
+./activation-error.mjs: Error: startup activation snapshot failure
+    at activation-error-fixture

+ 2 - 2
packages/client/modules/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/modules/README.md
-README.md: efba9e2eb0b148677fc7ac18bfad6333fb6f80da
-README.zh.md: b057bfdd8c0a269252496d0c6a0fc4184932fd72
+README.md: 99565b349d782c58752ac3e73ce7c0be527f78a8
+README.zh.md: a8ed0a4949ccefce53933b4f2fb8f51f5291684f

+ 2 - 0
packages/client/modules/README.md

@@ -8,6 +8,8 @@ Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`wi
 
 Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → fetch + execute + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the fetch branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (fetch + execute, registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and the materialized record so the next prefetch/import refetches (the HMR hook).
 
+The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
+
 ## Model Experience
 
 None, as the module loader is browser-side kernel machinery; nothing here reaches a model request.

+ 2 - 0
packages/client/modules/README.zh.md

@@ -8,6 +8,8 @@
 
 解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段加载钩子(抓取 + 执行,只注册;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取;它是 HMR(热模块替换)钩子。
 
+Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费客户端导出的构建产物;缺失文件共享一条构建要求,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
+
 ## 模型体验
 
 无。模块 loader 属于浏览器侧内核机制;这里没有任何内容进入模型请求。

+ 60 - 6
packages/client/modules/src/index.ts

@@ -58,6 +58,47 @@ interface PkgMeta {
   immediately: boolean
 }
 
+/** Recovery instruction shared by grouped startup and steady-state bundle diagnostics. */
+const CLIENT_BUNDLE_BUILD_INSTRUCTION = 'run `pnpm run build` before launch'
+
+/** Missing built client export, retained as structured data for activation-error grouping. */
+class MissingClientBundleError extends Error {
+  constructor(
+    readonly packageName: string,
+    readonly clientPath: string,
+    cause: unknown,
+  ) {
+    super(
+      [
+        `client-modules: client bundle not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`,
+        `  package: ${packageName}`,
+        `  path: ${clientPath}`,
+      ].join('\n'),
+      { cause },
+    )
+  }
+}
+
+/** Activation failures grouped by actionable package-build errors and unrelated failures. */
+class ClientPackageCompositionError extends AggregateError {
+  constructor(failures: Error[]) {
+    const missingBundles = failures.filter((error): error is MissingClientBundleError => error instanceof MissingClientBundleError)
+    const otherFailures = failures.filter(error => !(error instanceof MissingClientBundleError))
+    const packageNoun = failures.length === 1 ? 'package' : 'packages'
+    const lines = [`client-modules: ${String(failures.length)} client ${packageNoun} failed to compose:`]
+    if (missingBundles.length > 0) {
+      lines.push(`  client bundles not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`)
+      for (const error of missingBundles) {
+        lines.push(`    - package: ${error.packageName}`, `      path: ${error.clientPath}`)
+      }
+    }
+    if (otherFailures.length > 0) {
+      lines.push('  other failures:', ...otherFailures.map(error => `    - ${error.message}`))
+    }
+    super(failures, lines.join('\n'))
+  }
+}
+
 /** One composed table row: the wire entry plus its bundle path. */
 interface WebPluginRecord {
   entry: WebBootEntry
@@ -138,7 +179,7 @@ export function injectBootManifest(html: string, graph: WebBootGraph): string {
  * + bundle route + index tap. Construction runs the activation scan
  * synchronously — a malformed declaration or missing bundle among the
  * already-loaded entries aggregates into one loud throw (FAILED fiber; the
- * boot sweep reports it).
+ * boot activation audit reports it).
  */
 export class ClientModuleHostService extends Service {
   static inject = ['httpServer', 'loader']
@@ -194,10 +235,7 @@ export class ClientModuleHostService extends Service {
     const failures: Error[] = []
     this.flush(err => failures.push(err))
     if (failures.length > 0) {
-      throw new AggregateError(
-        failures,
-        `client-modules: ${String(failures.length)} client package(s) failed to compose:\n${failures.map(e => `  - ${e.message}`).join('\n')}`,
-      )
+      throw new ClientPackageCompositionError(failures)
     }
 
     ctx.effect(
@@ -322,6 +360,22 @@ export class ClientModuleHostService extends Service {
     return meta
   }
 
+  /**
+   * Read the activation-time bundle revision.
+   * @param pkgName - package that declares the client bundle.
+   * @param clientPath - absolute path of the built client artifact.
+   * @returns the bundle content's short hash for use as its revision.
+   * @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged.
+   */
+  private initialBundleRevision(pkgName: string, clientPath: string): string {
+    try {
+      return shortHash(readFileSync(clientPath))
+    } catch (error) {
+      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+      throw new MissingClientBundleError(pkgName, clientPath, error)
+    }
+  }
+
   /** Reconcile one entry name against the live loader entries. @returns whether the table changed. */
   private processOne(entryName: string): boolean {
     let qualifies = false
@@ -337,7 +391,7 @@ export class ClientModuleHostService extends Service {
     if (meta === null) return false
     // The rev rides the row from here on: a fiber restart reuses the row (and
     // its rev) untouched; only rebuilt() re-reads the bundle.
-    const rev = shortHash(readFileSync(meta.clientPath))
+    const rev = this.initialBundleRevision(entryName, meta.clientPath)
     this.table.set(entryName, { entry: graphRow(entryName, rev, meta.inject, meta.immediately), clientPath: meta.clientPath })
     return true
   }

+ 87 - 0
packages/client/modules/tests/node-half.spec.ts

@@ -0,0 +1,87 @@
+/** Node-half composition diagnostics for package metadata and built client bundles. */
+
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { Context } from 'cordis'
+import { afterEach, describe, expect, it } from 'vitest'
+import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver'
+import { ClientModuleHostService } from '../src/index.ts'
+
+let root: string | undefined
+
+afterEach(() => {
+  if (root !== undefined) rmSync(root, { recursive: true, force: true })
+  root = undefined
+})
+
+/** Create a resolvable dshClient package whose client export points at the returned path. */
+function writePackage(packageName: string): string {
+  root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-modules-')))
+  const pkgRoot = join(root, 'node_modules', ...packageName.split('/'))
+  const clientPath = join(pkgRoot, 'lib', 'client.js')
+  mkdirSync(pkgRoot, { recursive: true })
+  writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({
+    name: packageName,
+    exports: {
+      './client': './lib/client.js',
+      './package.json': './package.json',
+    },
+    dshClient: { platform: 'web' },
+  }))
+  return clientPath
+}
+
+/** Construct the node-half service over the enabled fixture entries. */
+function construct(packageNames: string[]): ClientModuleHostService {
+  const ctx = new Context()
+  ctx.baseUrl = pathToFileURL(root!).href + '/'
+  ctx.provide('loader', {
+    *entries() {
+      for (const packageName of packageNames) {
+        yield { options: { name: packageName }, fiber: {}, disabled: false }
+      }
+    },
+  })
+  const httpServer: Pick<HttpServerService, 'port' | 'register' | 'tapIndex'> = {
+    port: 0,
+    register: () => () => {},
+    tapIndex: () => () => {},
+  }
+  ctx.provide('httpServer', httpServer as HttpServerService)
+  return new ClientModuleHostService(ctx)
+}
+
+describe('client bundle activation', () => {
+  it('groups missing bundles under one source-build instruction with a package/path list', () => {
+    const firstName = '@fixture/missing-first'
+    const secondName = '@fixture/missing-second'
+    const firstPath = writePackage(firstName)
+    const secondPath = writePackage(secondName)
+    expect(() => construct([firstName, secondName])).toThrow([
+      'client-modules: 2 client packages failed to compose:',
+      '  client bundles not found; run `pnpm run build` before launch:',
+      `    - package: ${firstName}`,
+      `      path: ${firstPath}`,
+      `    - package: ${secondName}`,
+      `      path: ${secondPath}`,
+    ].join('\n'))
+  })
+
+  it('does not report other bundle read failures as missing builds', () => {
+    const packageName = '@fixture/unreadable-client'
+    const clientPath = writePackage(packageName)
+    mkdirSync(clientPath, { recursive: true })
+    let thrown: unknown
+    try {
+      construct([packageName])
+    } catch (error) {
+      thrown = error
+    }
+    expect(String(thrown)).toContain('client-modules: 1 client package failed to compose:')
+    expect(String(thrown)).toContain('  other failures:')
+    expect(String(thrown)).toContain('EISDIR')
+    expect(String(thrown)).not.toContain('pnpm run build')
+  })
+})

+ 1 - 0
packages/host/directory-picker-auto/tests/loader-composition.spec.ts

@@ -56,6 +56,7 @@ async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx
     '  config:',
     `    host: '${bindHost}'`,
     '    port: 0',
+    '    portConflict: increment',
     `    distIndex: '${distIndex}'`,
     `- name: '${AUTO}'`,
     '',

+ 2 - 2
packages/ui/app-boot/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md
-README.md: 1beffd6fbff2b84202683b010cd104f7c84297c7
-README.zh.md: d9ce9774b9b492a98556bbd9aa4564b711dbe40e
+README.md: 54f754842d9a6673ed6791b94656139f0f1be6a3
+README.zh.md: dd56084812e8241f0db24601ce2baeba51252d42

+ 4 - 4
packages/ui/app-boot/README.md

@@ -10,16 +10,16 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
 | `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, reporting every unresolved plugin name as a Cordis startup failure |
-| `assertEntriesActive(ctx, binName)` | Throw when a settled enabled fiber is not ACTIVE, including missing injected services for PENDING entries |
+| `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services |
 | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
 | `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error |
-| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and ACTIVE, and return the root context |
+| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context |
 | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
 | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
 
-Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin import is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every failed plugin.
+Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal.
 
-Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
+Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
 
 This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution.
 

+ 4 - 4
packages/ui/app-boot/README.zh.md

@@ -10,16 +10,16 @@
 | `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) |
 | `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) |
 | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 |
-| `assertEntriesActive(ctx, binName)` | 树结算后,如果已启用的 fiber 未处于 ACTIVE 状态,则抛出异常;对于 PENDING 条目还会列出缺失的注入服务 |
+| `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 |
 | `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 |
 | `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 |
-| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载且处于 ACTIVE 状态,最后返回根上下文 |
+| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文 |
 | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 |
 | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
 
-这些保护处理两类故障。`loader.await()` 会吞掉初始化 rejection(`Promise.allSettled`);Node 仍会因随后产生的未处理 rejection 以非零状态退出,而 `installFailLoud` 会把冗长转储替换为一行带标签的消息,并确保执行 `exit(1)`。插件导入失败则只会由 Loader 记录日志(否则,即使配置存在拼写错误,进程也会以代码 0 退出),并留下没有 fiber 的条目;`assertEntriesLoaded` 会将其转换为 `boot()` rejection,并在其中列出每个导入失败插件的名称
+Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命
 
-配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。
+配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。
 
 此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。
 

+ 89 - 20
packages/ui/app-boot/src/index.ts

@@ -160,16 +160,46 @@ export interface FailLoudProcess {
   exit(code: number): void
 }
 
+// Loader rc.5 derives and drops a rejected promise after a fiber fails. Keep
+// exact reasons already folded into the boot diagnostic visible through the
+// next process rejection checkpoint so the process guard can coalesce them.
+const assembledActivationRejections = new Map<unknown, number>()
+
+function retainAssembledRejection(reason: unknown): void {
+  assembledActivationRejections.set(reason, (assembledActivationRejections.get(reason) ?? 0) + 1)
+}
+
+function releaseAssembledRejection(reason: unknown): void {
+  const count = assembledActivationRejections.get(reason)
+  if (count === undefined || count === 1) {
+    assembledActivationRejections.delete(reason)
+  } else {
+    assembledActivationRejections.set(reason, count - 1)
+  }
+}
+
+async function observeLoaderRejectionCheckpoint(reasons: readonly unknown[]): Promise<void> {
+  for (const reason of reasons) retainAssembledRejection(reason)
+  try {
+    await new Promise<void>(resolve => setImmediate(resolve))
+  } finally {
+    for (const reason of reasons) releaseAssembledRejection(reason)
+  }
+}
+
 /**
  * Install before boot to turn a late unhandled plugin-init rejection into one
- * labelled stderr diagnostic and `exit(1)`. Stdout remains untouched for ACP;
- * the returned function removes the handler.
+ * labelled stderr diagnostic and `exit(1)`. A rejection already included by
+ * {@link assertEntriesActivated} is ignored during its process checkpoint;
+ * every other rejection remains fatal. Stdout remains untouched for ACP; the
+ * returned function removes the handler.
  * @param binName - the diagnostic prefix on the fatal-failure line.
  * @param proc - the process slice to register on; tests inject a fake.
  * @returns the uninstaller that removes the rejection handler.
  */
 export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void {
   const handler = (err: unknown): void => {
+    if (assembledActivationRejections.has(err)) return
     proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
     proc.exit(1)
   }
@@ -192,28 +222,64 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
   }
 }
 
-/** Runtime mirrors for Cordis's erased const-enum fiber states. */
-const FIBER_ACTIVE = 2 as FiberState.ACTIVE
+/**
+ * Value mirrors used because Cordis's const enum has no runtime object to import.
+ * Keep aligned with `packages/cordis/tool-cordis/src/fiber-state.ts` and
+ * `packages/client/web/src/loader-status.ts`.
+ */
 const FIBER_PENDING = 0 as FiberState.PENDING
+const FIBER_ACTIVE = 2 as FiberState.ACTIVE
+const FIBER_FAILED = 3 as FiberState.FAILED
+
+/** Render a thrown plugin value without discarding an Error's original stack. */
+function formatActivationError(error: unknown): string {
+  return error instanceof Error ? error.stack ?? error.message : String(error)
+}
 
 /**
- * Reject enabled Loader entries whose fibers did not reach ACTIVE after settle.
- * @param ctx - The settled application root.
- * @param binName - Diagnostic prefix.
+ * Reject a settled Loader tree when an enabled entry failed or remains inactive.
+ * Plugin failures include the original thrown stack; pending entries name their
+ * unresolved services because no plugin error exists for that state. Active
+ * entries require no further wait; only failed fibers are awaited to recover
+ * their private rejection reason.
+ * @param ctx - the settled context whose Loader entries to audit.
+ * @param binName - the diagnostic prefix on the thrown error.
+ * @returns nothing when every enabled entry is active.
+ * @throws after one process rejection checkpoint when an entry failed to
+ * import, rejected during activation, or did not become active.
  */
-export function assertEntriesActive(ctx: Context, binName: string): void {
+export async function assertEntriesActivated(ctx: Context, binName: string): Promise<void> {
+  assertEntriesLoaded(ctx, binName)
   const failures: string[] = []
+  const rejectionReasons: unknown[] = []
   for (const entry of ctx.loader.entries()) {
-    if (entry.fiber === undefined || entry.disabled || entry.fiber.state === FIBER_ACTIVE) continue
-    if (entry.fiber.state === FIBER_PENDING) {
-      const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined)
-      failures.push(`${entry.options.name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
+    const fiber = entry.fiber
+    if (fiber === undefined || entry.disabled) continue
+    const state = fiber.state
+    if (state === FIBER_ACTIVE) continue
+    if (state === FIBER_FAILED) {
+      try {
+        await fiber.await()
+      } catch (error) {
+        rejectionReasons.push(error)
+        failures.push(`${entry.options.name}: ${formatActivationError(error)}`)
+      }
+      continue
+    }
+    if (state === FIBER_PENDING) {
+      const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
+      const subject = missing.length === 1 ? 'service' : 'services'
+      failures.push(`${entry.options.name}: pending (waiting for ${subject}: ${missing.join(', ') || 'unknown'})`)
     } else {
-      failures.push(`${entry.options.name}: fiber state ${String(entry.fiber.state)}`)
+      failures.push(`${entry.options.name}: fiber state ${String(state)}`)
     }
   }
   if (failures.length > 0) {
-    throw new Error(`${binName}: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
+    if (rejectionReasons.length > 0) {
+      await observeLoaderRejectionCheckpoint(rejectionReasons)
+    }
+    const noun = failures.length === 1 ? 'entry' : 'entries'
+    throw new Error(`${binName}: ${String(failures.length)} ${noun} did not activate\n${failures.join('\n')}`)
   }
 }
 
@@ -225,9 +291,13 @@ export function assertEntriesActive(ctx: Context, binName: string): void {
  * bootstrap include is therefore statically imported and mounted as the
  * `cordis:include` builtin, loading through the ambient module pipeline
  * (vite/tsx/plain ESM) while the included tree's own specifiers stay
- * config-relative. A missing fiber rejects here; a later init rejection is
- * handled by {@link installFailLoud}. Built bins need the Loader's native
- * helper for bare plugin specifiers; relative specifiers do not.
+ * config-relative. The package build embeds Include while leaving Loader
+ * external, so the built include tree and host share one Loader peer. A
+ * missing fiber rejects here; a later init rejection is rethrown with its
+ * original stack by {@link assertEntriesActivated}; later unhandled
+ * rejections remain covered by {@link installFailLoud}. Built bins need the
+ * Loader's native helper for bare plugin specifiers; relative specifiers do
+ * not.
  * @param binName - the diagnostic prefix for load-failure errors.
  * @param absoluteConfigPath - the config to include; must already be absolute
  * (see {@link resolveConfigPath}).
@@ -259,12 +329,11 @@ export async function boot(
   // A surface can finish and dispose the whole tree while that await is still
   // pending: the TUI renders as soon as its own fiber starts, so an `/exit`
   // typed before the last entry settles tears the context down under us. The
-  // Loader service goes with it, and both assertions below describe a live
+  // Loader service goes with it, and the activation audit describes a live
   // tree — reading `ctx.loader` here would throw a TypeError over an app that
   // exited exactly as asked.
   if (ctx.get('loader') === undefined) return ctx
-  assertEntriesLoaded(ctx, binName)
-  assertEntriesActive(ctx, binName)
+  await assertEntriesActivated(ctx, binName)
   return ctx
 }
 

+ 125 - 24
packages/ui/app-boot/tests/app-boot.spec.ts

@@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest'
 import { Context } from 'cordis'
 import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
 import {
-  addHarnessSourceSection, assertEntriesActive, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
+  addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
   installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
 } from '../src/index.ts'
 
@@ -135,6 +135,33 @@ describe('installFailLoud', () => {
     uninstallReal()
     expect(process.listenerCount('unhandledRejection')).toBe(before)
   })
+
+  it('does not report an activation rejection shared by entries in the boot audit', async () => {
+    const proc = fakeProc()
+    installFailLoud(NAME, proc)
+    const error = new Error('assembled activation failure')
+    const audit = assertEntriesActivated({
+      loader: {
+        entries: () => ['broken-a', 'broken-b'].map(name => ({
+          options: { name },
+          fiber: {
+            state: 3,
+            inject: {},
+            ctx: { get: () => undefined },
+            await: async () => { throw error },
+          },
+        })),
+      },
+    } as unknown as Context, NAME)
+    await Promise.resolve()
+    await Promise.resolve()
+    proc.handlers[0]!(error)
+    expect(proc.written).toEqual([])
+    expect(proc.exits).toEqual([])
+    await expect(audit).rejects.toThrow('assembled activation failure')
+    proc.handlers[0]!(error)
+    expect(proc.exits).toEqual([1])
+  })
 })
 
 describe('assertEntriesLoaded', () => {
@@ -157,6 +184,97 @@ describe('assertEntriesLoaded', () => {
   })
 })
 
+describe('assertEntriesActivated', () => {
+  interface FakeFiber {
+    state: number
+    inject: Record<string, unknown>
+    ctx: { get(name: string): unknown }
+    await(): Promise<unknown>
+  }
+
+  const ctxWith = (entries: Array<{ fiber?: FakeFiber; disabled?: boolean; options: { name: string } }>): Context => ({
+    loader: { entries: () => entries },
+  }) as unknown as Context
+
+  const fiber = (
+    state: number,
+    error?: unknown,
+    inject: Record<string, unknown> = {},
+    services: string[] = [],
+  ): FakeFiber => ({
+    state,
+    inject,
+    ctx: { get: name => services.includes(name) ? {} : undefined },
+    await: error === undefined ? async () => undefined : async () => { throw error },
+  })
+
+  it('passes active entries and ignores disabled entries', async () => {
+    let awaitCalls = 0
+    const active = fiber(2)
+    active.await = async () => {
+      awaitCalls++
+      return undefined
+    }
+    const disabled = fiber(3, new Error('disabled failure'))
+    disabled.await = async () => {
+      awaitCalls++
+      throw new Error('disabled failure')
+    }
+    await expect(assertEntriesActivated(ctxWith([
+      { fiber: active, options: { name: 'active' } },
+      { fiber: disabled, disabled: true, options: { name: 'disabled' } },
+    ]), NAME)).resolves.toBeUndefined()
+    expect(awaitCalls).toBe(0)
+  })
+
+  it('reports the plugin name and original activation stack instead of fiber state 3', async () => {
+    const original = new Error('actual plugin failure')
+    await expect(assertEntriesActivated(ctxWith([
+      { fiber: fiber(3, original), options: { name: 'broken-plugin' } },
+    ]), NAME)).rejects.toThrow(`${NAME}: 1 entry did not activate\nbroken-plugin: ${original.stack!}`)
+  })
+
+  it('formats stackless and non-Error activation failures', async () => {
+    const stackless = new Error('stackless failure')
+    delete (stackless as { stack?: string }).stack
+    await expect(assertEntriesActivated(ctxWith([
+      { fiber: fiber(3, stackless), options: { name: 'stackless' } },
+      { fiber: fiber(3, 'plain failure'), options: { name: 'plain' } },
+    ]), NAME)).rejects.toThrow(`${NAME}: 2 entries did not activate\nstackless: stackless failure\nplain: plain failure`)
+  })
+
+  it('reports unresolved services for pending entries', async () => {
+    let awaitCalls = 0
+    const expected = [
+      `${NAME}: 3 entries did not activate`,
+      'waiting: pending (waiting for services: missingA, missingB)',
+      'single-wait: pending (waiting for service: missing)',
+      'unknown-wait: pending (waiting for services: unknown)',
+    ].join('\n')
+    const waiting = fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready'])
+    const singleWait = fiber(0, undefined, { missing: {} })
+    const unknownWait = fiber(0)
+    for (const item of [waiting, singleWait, unknownWait]) {
+      item.await = async () => {
+        awaitCalls++
+        return undefined
+      }
+    }
+    await expect(assertEntriesActivated(ctxWith([
+      { fiber: waiting, options: { name: 'waiting' } },
+      { fiber: singleWait, options: { name: 'single-wait' } },
+      { fiber: unknownWait, options: { name: 'unknown-wait' } },
+    ]), NAME)).rejects.toThrow(expected)
+    expect(awaitCalls).toBe(0)
+  })
+
+  it('retains the numeric diagnostic for a settled unexpected state', async () => {
+    await expect(assertEntriesActivated(ctxWith([
+      { fiber: fiber(4), options: { name: 'disposed' } },
+    ]), NAME)).rejects.toThrow('disposed: fiber state 4')
+  })
+})
+
 describe('loadOverlayPatches', () => {
   it('loads expressions and rejects missing, malformed, non-array, and non-mapping overlays', () => {
     const dir = tmp()
@@ -232,31 +350,14 @@ describe('boot', () => {
     await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
   })
 
-  it('rejects a settled tree with a pending inject and names every missing service', async () => {
+  it('reports a pending real Loader fiber and the service unresolved in its own context', async () => {
     const dir = tmp()
-    writeFileSync(join(dir, 'waiting.mjs'), "export const inject = ['alpha', 'beta']\nexport function apply() {}\n")
+    writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n')
     writeFileSync(join(dir, 'cordis.yml'), '- id: waiting\n  name: ./waiting.mjs\n')
-    await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow('./waiting.mjs: pending (waiting for services: alpha, beta)')
-  })
-
-  it('uses singular diagnostics for one missing pending dependency', () => {
-    const ctx = {
-      loader: { entries: () => [{ disabled: false, options: { name: 'waiting' }, fiber: { state: 0, inject: { alpha: {} } } }] },
-      get: () => undefined,
-    } as unknown as Context
-    expect(() =>{  assertEntriesActive(ctx, NAME) }).toThrow('waiting: pending (waiting for service: alpha)')
-  })
-
-  it('reports unknown pending dependencies and unexpected fiber states', () => {
-    const entries = [
-      { disabled: false, options: { name: 'unknown' }, fiber: { state: 0, inject: {} } },
-      { disabled: false, options: { name: 'failed' }, fiber: { state: 3, inject: {} } },
-    ]
-    const ctx = {
-      loader: { entries: () => entries },
-      get: () => undefined,
-    } as unknown as Context
-    expect(() =>{  assertEntriesActive(ctx, NAME) }).toThrow(`${NAME}: 2 entries did not activate\nunknown: pending (waiting for services: unknown)\nfailed: fiber state 3`)
+    await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow([
+      `${NAME}: 1 entry did not activate`,
+      './waiting.mjs: pending (waiting for service: neverProvided)',
+    ].join('\n'))
   })
 })
 

+ 19 - 0
packages/ui/app-boot/tsdown.config.ts

@@ -0,0 +1,19 @@
+import { defineConfig } from 'tsdown'
+
+/**
+ * Embed Include while keeping Loader external so the built include tree and
+ * app host bind to one Loader peer.
+ */
+export default defineConfig({
+  entry: ['lib/types/index.js', 'lib/types/invariant.js'],
+  outDir: 'lib',
+  format: ['esm'],
+  platform: 'node',
+  target: 'es2024',
+  fixedExtension: false,
+  dts: false,
+  clean: false,
+  deps: {
+    alwaysBundle: ['@cordisjs/plugin-include'],
+  },
+})