Sfoglia il codice sorgente

Merge branch 'codex/simp-ui-identity-residue' into codex/simp-name-front-door-defaults

Tianyi Cui 2 mesi fa
parent
commit
5aba647d48
60 ha cambiato i file con 1041 aggiunte e 242 eliminazioni
  1. 8 2
      .github/workflows/e2e.yml
  2. 1 1
      AGENTS.md
  3. 1 1
      docs/config-catalog.md
  4. 4 4
      docs/cordis-catalog/events.md
  5. 1 1
      docs/cordis-catalog/services.md
  6. 3 2
      docs/core-data-structures/subagent.md
  7. 4 4
      docs/event-producer-consumer.md
  8. 2 1
      docs/module-graph.md
  9. 1 0
      docs/rfc/INDEX.md
  10. 6 0
      docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.i18n.yaml
  11. 42 0
      docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.md
  12. 42 0
      docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.zh.md
  13. 6 1
      docs/testing.md
  14. 2 2
      examples/AGENTS.md
  15. 1 1
      examples/echo-agent/tests/fixtures/context/time-context/cordis.yml
  16. 46 0
      examples/package.json
  17. 9 9
      knip.json
  18. 1 0
      packages/context/time-context/package.json
  19. 23 17
      packages/context/time-context/tests/time-context.e2e.ts
  20. 4 1
      packages/context/time-context/tsconfig.json
  21. 1 1
      packages/cordis/tool-cordis/src/api-catalog.ts
  22. 4 3
      packages/hooks/hooks-claude/tests/bridge.spec.ts
  23. 5 4
      packages/hooks/hooks-claude/tests/coverage-cases.ts
  24. 1 1
      packages/mcp/mcp-client/tests/fixture-server.ts
  25. 6 8
      packages/mcp/mcp-client/tests/mcp-client.e2e.ts
  26. 1 0
      packages/subagent/subagent-acp/package.json
  27. 1 0
      packages/subagent/subagent-acp/src/run.ts
  28. 3 4
      packages/subagent/subagent-acp/tests/mock-acp-server.ts
  29. 21 19
      packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts
  30. 16 21
      packages/subagent/subagent-acp/tests/subagent-acp.spec.ts
  31. 3 0
      packages/subagent/subagent-acp/tsconfig.json
  32. 1 0
      packages/subagent/subagent-inprocess/src/index.ts
  33. 2 2
      packages/subagent/subagent/README.md
  34. 2 0
      packages/subagent/subagent/package.json
  35. 21 4
      packages/subagent/subagent/src/index.ts
  36. 19 0
      packages/subagent/subagent/src/types.ts
  37. 21 4
      packages/subagent/subagent/tests/service.spec.ts
  38. 15 0
      packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
  39. 1 1
      packages/support/acp-snapshot/package.json
  40. 21 18
      packages/support/acp-snapshot/src/launcher.ts
  41. 22 3
      packages/support/acp-snapshot/tests/harness.spec.ts
  42. 4 2
      packages/support/acp-snapshot/tests/suite.spec.ts
  43. 3 1
      packages/support/acp-snapshot/tsconfig.json
  44. 4 4
      packages/support/loader-smoke/README.md
  45. 127 21
      packages/support/loader-smoke/src/index.ts
  46. 111 0
      packages/support/loader-smoke/tests/example-launch.spec.ts
  47. 3 0
      packages/support/loader-smoke/tests/loader-smoke.spec.ts
  48. 1 0
      packages/support/subagent-mock/src/index.ts
  49. 1 1
      packages/ui/jsonrpc/README.md
  50. 5 40
      packages/ui/jsonrpc/src/server.ts
  51. 2 1
      packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts
  52. 118 12
      packages/ui/jsonrpc/tests/server.spec.ts
  53. 8 0
      packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts
  54. 126 3
      pnpm-lock.yaml
  55. 7 0
      pnpm-workspace.yaml
  56. 2 2
      scripts/doc-budgets.manifest.json
  57. 17 3
      scripts/run-gates.ts
  58. 99 3
      scripts/verify-cordis-config.ts
  59. 4 4
      website/zh-CN/api/harness/events.md
  60. 5 5
      website/zh-CN/api/harness/subagents.md

+ 8 - 2
.github/workflows/e2e.yml

@@ -110,9 +110,14 @@ jobs:
           fi
           echo "DEEPSEEK_API_KEY present."
 
+      # The e2e suites boot the example bins in `lib` mode (DSH_EXAMPLE_MODE=lib):
+      # the built artifact under plain Node, resolving plugins through real package
+      # exports — the shape a real consumer runs. That requires a prior build.
+      - name: Build (lib for the e2e example bins)
+        run: pnpm run build
+
       # Real-API end-to-end tests only. The keyless gates (lint/typecheck/
-      # coverage/snapshot/build/etc.) already run in ci.yml on every push/PR;
-      # no need to repeat them or build first (tests run unbuilt via tsx).
+      # coverage/snapshot/etc.) already run in ci.yml on every push/PR.
       # DEEPSEEK_BASE_URL is pinned to the external API; the secret is scoped to
       # this step (and preflight) only — never exposed to checkout/setup/install.
       - name: E2E tests (real DeepSeek API)
@@ -120,4 +125,5 @@ jobs:
           DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}
           DEEPSEEK_BASE_URL: https://api.deepseek.com
           DSH_E2E_MAX_WORKERS: 14
+          DSH_EXAMPLE_MODE: lib
         run: pnpm run test:e2e

+ 1 - 1
AGENTS.md

@@ -99,7 +99,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
 ## Conventions
 
 - Every npm package is `@deepseek-ai/dsh-<name>`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package.
-- ESM everywhere (`"type": "module"`). Cross-package imports use package names, never relative paths; in-package relative imports use explicit `.ts` extensions. Dev/test/demo run unbuilt via tsx + the root tsconfig `paths` map; builds are for outside consumers only.
+- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. CI subprocesses that boot examples or Cordis configs run built `lib/` under plain Node; only explicit source-path regressions use tsx ([testing policy](docs/testing.md#test-subprocess-launch-modes)).
 - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer.
 - **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns.
 - **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default.

+ 1 - 1
docs/config-catalog.md

@@ -918,7 +918,7 @@ export interface Config {
 
 Depends on: [`SubagentCapabilities`](../packages/subagent/subagent/src/index.ts) · [`SubagentStopReason`](../packages/subagent/subagent/src/index.ts)
 
-Source: [`packages/support/subagent-mock/src/index.ts:86`](../packages/support/subagent-mock/src/index.ts)
+Source: [`packages/support/subagent-mock/src/index.ts:87`](../packages/support/subagent-mock/src/index.ts)
 
 ## `@deepseek-ai/dsh-subagent-spawn`
 

+ 4 - 4
docs/cordis-catalog/events.md

@@ -301,7 +301,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c
 'subagent/end'(this: Scoped<SubagentService>, info: SubagentRunEndInfo): void
 ```
 
-Source: [`packages/subagent/subagent/src/index.ts:109`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:112`](../../packages/subagent/subagent/src/index.ts)
 
 ### `subagent/provider-added` — emit
 
@@ -311,7 +311,7 @@ A provider became resolvable in the registry.
 'subagent/provider-added'(provider: SubagentProvider): void
 ```
 
-Source: [`packages/subagent/subagent/src/index.ts:83`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:86`](../../packages/subagent/subagent/src/index.ts)
 
 ### `subagent/provider-removed` — emit
 
@@ -321,7 +321,7 @@ A provider left the registry. Accepted runs remain holder-owned.
 'subagent/provider-removed'(name: string): void
 ```
 
-Source: [`packages/subagent/subagent/src/index.ts:89`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:92`](../../packages/subagent/subagent/src/index.ts)
 
 ### `subagent/start` — emit
 
@@ -331,7 +331,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get(
 'subagent/start'(this: Scoped<SubagentService>, info: SubagentRunInfo): void
 ```
 
-Source: [`packages/subagent/subagent/src/index.ts:100`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:103`](../../packages/subagent/subagent/src/index.ts)
 
 ## `system-prompt/*`
 

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

@@ -264,7 +264,7 @@ list(): string[]
 async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
 ```
 
-Source: [`packages/subagent/subagent/src/index.ts:142`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/subagent/src/index.ts)
 
 ## `ctx.systemPrompt` — `SystemPrompt`
 

+ 3 - 2
docs/core-data-structures/subagent.md

@@ -69,6 +69,7 @@ interface SubagentStopReasonMap {
 ```ts type-equiv
 interface SubagentRun {
   readonly id: SessionId
+  readonly localAgent: Agent | undefined
   readonly result: Promise<SubagentResult>
   dispose(): Promise<void>
   sendMessage?(content: ContentBlock[]): void
@@ -76,7 +77,7 @@ interface SubagentRun {
 }
 ```
 
-A local run MUST publish an ordinary child agent/session before `start()` fulfills, return that child session id as `SubagentRun.id`, and record `request.parent.session.id` in the child's `parentSession` header. Runtime ownership may place the child under the parent, provider, or root scope; `parentSession` is the durable transport-neutral lineage. A remote provider instead returns a parent-scoped lifecycle id and does not publish a local child.
+A local run MUST publish an ordinary child agent/session before `start()` fulfills, return that child session id as `SubagentRun.id`, expose the exact child as `localAgent`, and record `request.parent.session.id` in the child's `parentSession` header. Runtime ownership may place the child under the parent, provider, or root scope. A remote provider instead returns a parent-scoped lifecycle id and `localAgent: undefined`.
 
 ## The provider seam: `SubagentProvider`
 
@@ -91,7 +92,7 @@ interface SubagentProvider {
 }
 ```
 
-`start()` fulfills only with a ready run. The service observes its result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. In-process children are discoverable through `ctx.agents`, while remote children need not be. `subagent/end` reports final output or infrastructure failure. Both events are observe-only and contain listener exceptions.
+`start()` fulfills only with a ready run. The service mints a unique `runId`, snapshots `local` from the provider's exact `localAgent`, observes the result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. The paired `subagent/end` carries the same identity and the final output or infrastructure failure. Both events are observe-only and contain listener exceptions.
 
 ## In-process backends: depth and seed
 

+ 4 - 4
docs/event-producer-consumer.md

@@ -30,10 +30,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) |
 | `session/event` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`workspace-context`](../packages/context/workspace-context) |
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:78`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
-| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:109`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) |
-| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:89`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:100`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) |
+| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:112`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) |
+| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:86`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:92`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:103`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
 | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`acp`](../packages/ui/acp) |
 | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
 | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |

+ 2 - 1
docs/module-graph.md

@@ -279,6 +279,7 @@ flowchart TD
   pkg_tool_skill --> pkg_skill
   pkg_tool_skill --> pkg_tools
   pkg_subagent --> pkg_agent
+  pkg_subagent --> pkg_brand
   pkg_subagent --> pkg_llm
   pkg_subagent --> pkg_scope
   pkg_subagent --> pkg_session
@@ -493,7 +494,7 @@ flowchart TD
 | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
-| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
+| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
 | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
 | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) |
 | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |

+ 1 - 0
docs/rfc/INDEX.md

@@ -191,6 +191,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
 | [A gated Known-Limitations section in every package README](implemented/process/2026-07-10-readme-known-limitations-gate.md) | 2026-07-10 |
 | [Package Model Experience contract](implemented/process/2026-07-12-package-model-experience-contract.md) | 2026-07-12 |
 | [TypeScript Program-backed semantic gates](implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md) | 2026-07-14 |
+| [Run CI examples from built lib](implemented/process/2026-07-17-run-ci-examples-from-built-lib.md) | 2026-07-17 |
 
 ### Testing
 

+ 6 - 0
docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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
+2026-07-17-run-ci-examples-from-built-lib.md: aae88ee965b4e2211f3a53aeb9c0ee4944d95c2a
+2026-07-17-run-ci-examples-from-built-lib.zh.md: 0cd3822a71b8f7399be93beaac74b2177fcbe7ca

+ 42 - 0
docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.md

@@ -0,0 +1,42 @@
+# RFC: Run CI examples from built lib
+
+Status: implemented
+
+English | [中文](2026-07-17-run-ci-examples-from-built-lib.zh.md)
+
+## Problem
+
+CI boots examples and Cordis-backed test projects through `node --import tsx` and the root tsconfig `paths` map. This adds TypeScript transformation cost and changes package resolution: imports resolve to workspace source instead of following package `exports` into built `lib/`.
+
+These runs therefore do not test the same code or resolution behavior as an installed consumer. A package can pass CI while its built export graph is incomplete or resolves differently.
+
+## Decision
+
+Execution has two modes. `src` is the default local-development mode and uses tsx; `lib` is the strict CI mode and starts built bins with plain Node, without tsx or tsconfig path mapping.
+
+- CI subprocesses that boot an example or a checked-in `cordis.yml` use `lib` mode.
+- TypeScript fixtures that only implement an ACP or MCP peer and do not load Cordis run directly with Node. An explicit source-path regression may remain in `src` mode.
+
+### Resolution topology
+
+Every test Cordis config must resolve its bare modules by walking upward from the config directory.
+
+- `examples/` is one pnpm workspace member and provides the shared `examples/node_modules` resolution root.
+- Every checked-in test Cordis config, including snapshot configs and package-owned fixtures, lives under its corresponding `examples/<agent>/` tree. A config owned by `packages/<group>/<package>/` maps to `examples/<agent>/tests/fixtures/<group>/<package>/cordis.yml`; the test driver and assertions remain package-local.
+- Every package named by an example Cordis config is declared in both `examples/package.json` for `lib` resolution and the root `tsconfig.json` references for `src` mode.
+
+### Launch policy
+
+The shared Loader test harness selects `src` or `lib` from `DSH_EXAMPLE_MODE`. CI builds first and selects `lib`; an unset mode keeps the fast local source loop.
+
+## Alternatives considered
+
+- **Keep CI on tsx** — rejected because it preserves transformation overhead and source-only resolution behavior.
+- **Use lib everywhere** — rejected because local development would require a build before every run. Dual mode keeps that cost out of the development loop.
+- **Build a private `node_modules` tree per test** — rejected because it duplicates consumer scaffolding. The `examples/` workspace root gives every Cordis config one real and declared resolution path.
+
+## Consequences
+
+- CI validates built package exports without tsx changing module resolution; local development retains the no-build source loop.
+- CI must build before these tests, and manual `lib` runs can observe stale local artifacts.
+- Cordis config dependencies are not visible to normal TypeScript import analysis, so `examples/package.json` and the root tsconfig references must stay synchronized with the configs.

+ 42 - 0
docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.zh.md

@@ -0,0 +1,42 @@
+# RFC: 在 CI 中从构建后的 lib 运行示例
+
+Status: implemented
+
+[English](2026-07-17-run-ci-examples-from-built-lib.md) | 中文
+
+## 问题
+
+CI 通过 `node --import tsx` 和根 tsconfig 的 `paths` 映射启动示例与加载 Cordis 配置的测试项目。这种方式既增加了 TypeScript 转换开销,也改变了包解析行为:import 会解析到 workspace 源码,而不是经包的 `exports` 进入构建后的 `lib/`。
+
+因此,这些测试没有覆盖已安装消费方实际运行的代码和解析路径。即使包的构建导出图不完整或解析结果不同,CI 仍可能通过。
+
+## 决策
+
+执行机制包含两种模式。`src` 是本地开发的默认模式并使用 tsx;`lib` 是严格的 CI 模式,通过 plain Node 启动构建后的 bin,不加载 tsx,也不使用 tsconfig 路径映射。
+
+- CI 中启动示例或签入仓库的 `cordis.yml` 的子进程使用 `lib` 模式。
+- 仅实现 ACP 或 MCP 对端、且不加载 Cordis 的 TypeScript fixture(测试前置数据)直接由 Node 运行。只有显式验证源码路径的回归测试可以保留 `src` 模式。
+
+### 解析拓扑
+
+每个测试 Cordis 配置都必须能从配置文件所在目录向上解析裸模块。
+
+- `examples/` 作为一个 pnpm workspace 成员,提供统一的 `examples/node_modules` 解析根目录。
+- 所有签入仓库的测试 Cordis 配置,包括快照配置和包内测试 fixture,都放在对应的 `examples/<agent>/` 目录树下。归属 `packages/<group>/<package>/` 的配置映射到 `examples/<agent>/tests/fixtures/<group>/<package>/cordis.yml`;测试驱动和断言仍留在包内。
+- 示例 Cordis 配置中引用的每个包都同时登记在 `examples/package.json` 和根 `tsconfig.json` 的 references 中,分别支持 `lib` 与 `src` 解析。
+
+### 启动策略
+
+共享 Loader 测试 harness 通过 `DSH_EXAMPLE_MODE` 选择 `src` 或 `lib`。CI 先构建再选择 `lib`;未设置模式时保留快速的本地源码开发回路。
+
+## 曾考虑的替代方案
+
+- **CI 继续使用 tsx**:不予采纳,因为它会保留转换开销和仅适用于源码的解析行为。
+- **所有环境只使用 lib**:不予采纳,因为本地开发每次运行前都必须构建。双模式避免把这项成本带入开发回路。
+- **每个测试单独构造 `node_modules`**:不予采纳,因为它会重复消费方脚手架。以 `examples/` 作为 workspace 根,可让每个 Cordis 配置通过同一条真实且显式声明的路径解析模块。
+
+## 后果
+
+- CI 可以验证构建后的包导出,不再受 tsx 模块解析影响;本地开发仍保留免构建的源码回路。
+- CI 必须先构建再运行这些测试;手动执行 `lib` 模式时可能读取陈旧的本地产物。
+- 常规 TypeScript import 分析无法识别 Cordis 配置依赖,因此 `examples/package.json`、根 tsconfig references 与配置文件必须保持同步。

+ 6 - 1
docs/testing.md

@@ -26,7 +26,12 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword
 - Product-visible plugins require a non-unit REAL-composition test. Hand-built `ctx.plugin(...)` suites are insufficient: boot test-only `cordis.yml` through Loader and app/process, mock only external/nondeterministic boundaries, and assert model-visible request/log, durable state, or user-visible output. Keep opt-ins out of shipped defaults.
 - A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green under a broken export shape — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert.
 - "Real entry path" means the published artifact: a package `bin` runs built `lib/bin.js` under plain `node`, exposing failures tsx masks (settle races, module resolution, swallowed load failures). The same applies to non-index runtime entries (the worker-thread sibling `lib/worker.cjs`) and singleton modules shared across bundles (`packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero.
-- An e2e that spawns an example from a temp cwd sets `TSX_TSCONFIG_PATH` to the repo-root tsconfig, or it silently falls back to stale built `lib/` ([examples/AGENTS.md](../examples/AGENTS.md)).
+
+## Test subprocess launch modes
+
+- CI and build-having test lanes run every example or Cordis-config subprocess from built `lib/` through the shared dual-mode launcher. Do not hand-write `--import tsx` for these subprocesses.
+- Protocol and operating-system fixtures that do not load Cordis run erasable `.ts` directly with Node, without tsx or the root paths map.
+- Only a test whose subject is source-path resolution may select `src`; state that contract in the test.
 
 ## When a snapshot test is required
 

+ 2 - 2
examples/AGENTS.md

@@ -1,6 +1,6 @@
 # AGENTS.md — Examples
 
-Runnable harness compositions. **Examples are NOT workspaces**: their private `package.json` files are dependency-free stubs, and the cordis Loader boots each `cordis.yml` unbuilt through `tsx` plus the root tsconfig paths.
+Runnable harness compositions. `examples/` is one workspace member and the module-resolution root for runnable and test Cordis configs; it is not a build target. [package.json](package.json) declares the packages loaded by those configs, while each leaf's private `package.json` remains metadata only.
 
 Extract reusable logic into `packages/`, where per-file coverage and README gates apply. Examples keep only `cordis.yml` wiring, demo artifacts, and e2e/snapshot scenarios; app package bins own boot glue.
 
@@ -13,7 +13,7 @@ Each example has both:
 
 Mock-only examples require only the keyless tier; state that exception in the test.
 
-Keyless stdio smokes use `@deepseek-ai/dsh-loader-smoke` for isolation, root-tsconfig loading, subprocess lifecycle, diagnostics, EOF, and cleanup; tests supply paths, environment, input, and assertions.
+Keyless stdio smokes use `@deepseek-ai/dsh-loader-smoke`; tests supply paths, environment, input, and assertions. Every checked-in test Cordis config lives under its corresponding `examples/<agent>/` leaf. Map a package-owned config to `examples/<agent>/tests/fixtures/<group>/<package>/cordis.yml`, keep its driver and assertions package-local, and declare every package it names in both root `tsconfig.json` references and `examples/package.json`.
 
 Do not inventory example tests here; the `tests/` trees and root scripts are authoritative.
 

+ 1 - 1
packages/context/time-context/tests/fixtures/cordis.yml → examples/echo-agent/tests/fixtures/context/time-context/cordis.yml

@@ -1,6 +1,6 @@
 # Test-only composition: keep time-context opt-in while exercising its real Loader/app path.
 - id: mock-llm
-  name: '../../../../../examples/echo-agent/src/mock-llm.ts'
+  name: '../../../../src/mock-llm.ts'
 
 - id: bash
   name: '@deepseek-ai/dsh-bash-local'

+ 46 - 0
examples/package.json

@@ -0,0 +1,46 @@
+{
+  "name": "dsh-examples",
+  "private": true,
+  "version": "0.0.1",
+  "type": "module",
+  "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports→lib. Not a build target.",
+  "dependencies": {
+    "@cordisjs/plugin-hmr": "workspace:*",
+    "@cordisjs/plugin-include": "workspace:*",
+    "@deepseek-ai/dsh-acp-demo": "workspace:*",
+    "@deepseek-ai/dsh-bash-local": "workspace:*",
+    "@deepseek-ai/dsh-bash-sandbox": "workspace:*",
+    "@deepseek-ai/dsh-code-runtime-worker": "workspace:*",
+    "@deepseek-ai/dsh-compact-basic": "workspace:*",
+    "@deepseek-ai/dsh-fs-local": "workspace:*",
+    "@deepseek-ai/dsh-fs-policy": "workspace:*",
+    "@deepseek-ai/dsh-hooks-claude": "workspace:*",
+    "@deepseek-ai/dsh-hooks-codex": "workspace:*",
+    "@deepseek-ai/dsh-llm": "workspace:*",
+    "@deepseek-ai/dsh-llm-deepseek": "workspace:*",
+    "@deepseek-ai/dsh-llm-replay": "workspace:*",
+    "@deepseek-ai/dsh-permission": "workspace:*",
+    "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*",
+    "@deepseek-ai/dsh-sandbox-local": "workspace:*",
+    "@deepseek-ai/dsh-spill-local": "workspace:*",
+    "@deepseek-ai/dsh-spill-policy": "workspace:*",
+    "@deepseek-ai/dsh-stdio-demo": "workspace:*",
+    "@deepseek-ai/dsh-subagent": "workspace:*",
+    "@deepseek-ai/dsh-subagent-fork": "workspace:*",
+    "@deepseek-ai/dsh-subagent-spawn": "workspace:*",
+    "@deepseek-ai/dsh-time-context": "workspace:*",
+    "@deepseek-ai/dsh-timeout-policy": "workspace:*",
+    "@deepseek-ai/dsh-token-meter": "workspace:*",
+    "@deepseek-ai/dsh-tool-cordis": "workspace:*",
+    "@deepseek-ai/dsh-tool-fs": "workspace:*",
+    "@deepseek-ai/dsh-tool-fs-search": "workspace:*",
+    "@deepseek-ai/dsh-tool-subagent": "workspace:*",
+    "@deepseek-ai/dsh-tool-todo": "workspace:*",
+    "@deepseek-ai/dsh-tool-workflow": "workspace:*",
+    "@deepseek-ai/dsh-tools": "workspace:*",
+    "@deepseek-ai/dsh-user-approval": "workspace:*",
+    "@deepseek-ai/dsh-web": "workspace:*",
+    "@deepseek-ai/dsh-web-fetch-local": "workspace:*",
+    "@deepseek-ai/dsh-workflow-workerthread": "workspace:*"
+  }
+}

+ 9 - 9
knip.json

@@ -5,15 +5,16 @@
   "ignoreWorkspaces": ["vendor/*", "python/sdk-runtime", "website"],
   "workspaces": {
     ".": {
+      "project": ["scripts/**/*.ts"]
+    },
+    "examples": {
       "entry": [
-        "examples/echo-agent/src/*.ts",
-        "examples/echo-agent/tests/**/*.e2e.ts",
-        "examples/coding-agent/tests/**/*.e2e.ts",
-        "examples/cordis-agent/tests/**/*.e2e.ts",
-        "examples/acp-agent/tests/**/*.e2e.ts",
-        "examples/*/tests/**/*.snapshot.ts"
+        "echo-agent/src/*.ts",
+        "*/tests/**/*.e2e.ts",
+        "*/tests/**/*.snapshot.ts"
       ],
-      "project": ["scripts/**/*.ts", "examples/**/*.ts"]
+      "project": ["**/*.ts"],
+      "ignoreDependencies": ["@deepseek-ai/.+", "@cordisjs/.+"]
     },
     "packages/*/*": {
       "entry": ["tests/**/*.spec.ts"],
@@ -52,8 +53,7 @@
     },
     "packages/support/acp-snapshot": {
       "entry": ["tests/**/*.spec.ts", "tests/fixtures/fake-acp-agent.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"],
-      "ignoreDependencies": ["cordis"]
+      "project": ["src/**/*.ts", "tests/**/*.ts"]
     },
     "packages/support/loader-smoke": {
       "entry": ["tests/**/*.spec.ts", "tests/fixtures/*.ts"],

+ 1 - 0
packages/context/time-context/package.json

@@ -33,6 +33,7 @@
     "@deepseek-ai/dsh-agent-loop": "workspace:^",
     "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-loader-smoke": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",

+ 23 - 17
packages/context/time-context/tests/time-context.e2e.ts

@@ -4,12 +4,17 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { afterEach, describe, expect, it } from 'vitest'
-import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import { type SessionEvent } from '@deepseek-ai/dsh-session'
+import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
 
+// Keep the Loader config under examples so both modes exercise the same deployable
+// topology: local fixture source plus bare plugins owned by the examples workspace.
 const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url))
-const configPath = fileURLToPath(new URL('./fixtures/cordis.yml', import.meta.url))
+const configPath = fileURLToPath(new URL(
+  '../../../../examples/echo-agent/tests/fixtures/context/time-context/cordis.yml',
+  import.meta.url,
+))
 const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
-const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
 const PROCESS_TIMEOUT_MS = 30_000
 const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000
 const FIRST_REPLY = '[main turn 1] You said: "Time sampled while preparing turn 1, step 1:'
@@ -39,21 +44,22 @@ async function runTwoTurns(): Promise<{ stdout: string; stderr: string }> {
   workdir = await mkdtemp(join(tmpdir(), 'time-context-e2e-'))
   const cwd = workdir
   return new Promise((resolve, reject) => {
-    const proc = spawn(
-      process.execPath,
-      ['--expose-internals', '--import', tsxLoader, binScript, configPath],
-      {
-        cwd,
-        env: {
-          ...process.env,
-          TZ: 'Asia/Shanghai',
-          TSX_TSCONFIG_PATH: repoTsconfig,
-          DSH_HOME: join(cwd, '.dsh'),
-          DSH_AGENTS_HOME: join(cwd, '.agents'),
-        },
-        stdio: ['pipe', 'pipe', 'pipe'],
+    const launch = resolveExampleLaunch({
+      srcBin: binScript,
+      configArgs: [configPath],
+      tsconfigPath: repoTsconfig,
+      exposeInternals: true,
+      env: {
+        TZ: 'Asia/Shanghai',
+        DSH_HOME: join(cwd, '.dsh'),
+        DSH_AGENTS_HOME: join(cwd, '.agents'),
       },
-    )
+    })
+    const proc = spawn(launch.command, launch.args, {
+      cwd,
+      env: { ...process.env, ...launch.env },
+      stdio: ['pipe', 'pipe', 'pipe'],
+    })
     child = proc
     let stdout = ''
     let stderr = ''

+ 4 - 1
packages/context/time-context/tsconfig.json

@@ -10,6 +10,9 @@
     { "path": "../../../vendor/cordis" },
     { "path": "../../../vendor/schemastery" },
     { "path": "../../llm/llm" },
-    { "path": "../../core/agent" }
+    { "path": "../../core/agent" },
+    { "path": "../../core/system-prompt" },
+    { "path": "../../core/agent" },
+    { "path": "../../support/loader-smoke" }
   ]
 }

+ 1 - 1
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -1028,7 +1028,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'SubagentRun',
-    declaration: 'export interface SubagentRun {\n    readonly id: SessionId;\n    readonly result: Promise<SubagentResult>;\n    dispose(): Promise<void>;\n    sendMessage?(content: ContentBlock[]): void;\n    resume?(content: ContentBlock[]): Promise<SubagentRun>;\n}',
+    declaration: 'export interface SubagentRun {\n    readonly id: SessionId;\n    readonly localAgent: Agent | undefined;\n    readonly result: Promise<SubagentResult>;\n    dispose(): Promise<void>;\n    sendMessage?(content: ContentBlock[]): void;\n    resume?(content: ContentBlock[]): Promise<SubagentRun>;\n}',
   },
   {
     name: 'SubagentStartRequest',

+ 4 - 3
packages/hooks/hooks-claude/tests/bridge.spec.ts

@@ -9,6 +9,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
 import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
 import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
 import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
+import { SubagentRunId } from '@deepseek-ai/dsh-subagent'
 import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
 import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
 
@@ -288,8 +289,8 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () =>
     // Drive the observe-only lifecycle events directly (no real child needed — the
     // bridge just listens). No child agent is registered, so SubagentStart's
     // child lookup yields undefined and it simply runs the hook.
-    ctx.emit('subagent/start', { provider: 'inproc', id: SessionId('child-1') })
-    ctx.emit('subagent/end', { provider: 'inproc', id: SessionId('child-1'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] })
+    ctx.emit('subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false })
+    ctx.emit('subagent/end', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] })
 
     // Both hooks run async (detached .then); poll for their marker files rather
     // than a fixed sleep that flakes under load.
@@ -324,7 +325,7 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () =>
     const { ctx, hooks } = await harnessWithFiber(dir, new MockAdapter([]))
     const warn = vi.fn()
     ctx.logger.warn = warn as never
-    ctx.emit('subagent/start', { provider: 'inproc', id: SessionId('child-1') })
+    ctx.emit('subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false })
     await waitFor(() => existsSync(marker))
     const pid = Number(readFileSync(pidFile, 'utf8').trim())
     await hooks.dispose()

+ 5 - 4
packages/hooks/hooks-claude/tests/coverage-cases.ts

@@ -9,6 +9,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
 import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
 import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
 import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
+import { SubagentRunId } from '@deepseek-ai/dsh-subagent'
 import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
 import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
 
@@ -231,7 +232,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       const injected: string[] = []
       const child = { id: SessionId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { id: SessionId('child-x'), header: { id: 'child-x' } } } as unknown as Parameters<typeof ctx.agents.register>[0]
       ctx.agents.register(child)
-      ctx.emit('subagent/start', { provider: 'p', id: SessionId('child-x') })
+      ctx.emit('subagent/start', { runId: SubagentRunId('run-x'), provider: 'p', id: SessionId('child-x'), local: true })
       await waitFor(() => injected.includes('child guidance'))
       expect(injected).toContain('child guidance')
     })
@@ -247,7 +248,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       const warn = vi.fn(); ctx.logger.warn = warn as never
       const child = { id: SessionId('child-y'), inject: () => { throw new Error('inject boom') }, session: { id: SessionId('child-y'), header: { id: 'child-y' } } } as unknown as Parameters<typeof ctx.agents.register>[0]
       ctx.agents.register(child)
-      ctx.emit('subagent/start', { provider: 'p', id: SessionId('child-y') })
+      ctx.emit('subagent/start', { runId: SubagentRunId('run-y'), provider: 'p', id: SessionId('child-y'), local: true })
       await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed')))
       expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed'))
     })
@@ -291,7 +292,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
       const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] })
       const ctx = await harness(path, new MockAdapter([]))
-      ctx.emit('subagent/end', { provider: 'p', id: SessionId('child-z'), stopReason: 'completed' })
+      ctx.emit('subagent/end', { runId: SubagentRunId('run-z'), provider: 'p', id: SessionId('child-z'), local: false, stopReason: 'completed' })
       await waitFor(() => existsSync(marker))
       expect(existsSync(marker)).toBe(true)
     })
@@ -691,7 +692,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       // Register a live child on its own session cwd; emit subagent/end with its id.
       const { SessionId } = await import('@deepseek-ai/dsh-session')
       const childHandle = await ctx.agents.create({ sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { provider: 'mock', model: 'mock' } })
-      ctx.emit('subagent/end', { provider: 'inproc', id: childHandle.agent.id, stopReason: 'completed' })
+      ctx.emit('subagent/end', { runId: SubagentRunId('run-stop'), provider: 'inproc', id: childHandle.agent.id, local: true, stopReason: 'completed' })
 
       await waitFor(() => existsSync(marker))
       expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir

+ 1 - 1
packages/mcp/mcp-client/tests/fixture-server.ts

@@ -2,7 +2,7 @@
  * Minimal MCP server over stdio for e2e testing of the dsh-mcp-client plugin.
  * Registers controlled tools with predictable behavior for asserting edge cases.
  *
- * Run: node --import tsx fixture-server.ts
+ * Run: node fixture-server.ts
  */
 
 import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'

+ 6 - 8
packages/mcp/mcp-client/tests/mcp-client.e2e.ts

@@ -26,9 +26,7 @@ import { apply } from '@deepseek-ai/dsh-mcp-client/src/index.ts'
 import { publicToolName } from '@deepseek-ai/dsh-mcp-client/src/tools.ts'
 import type { Config } from '@deepseek-ai/dsh-mcp-client'
 
-const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
 const fixtureServerPath = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
-const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
 
 // Resolve package-local .bin for pnpm-hoisted MCP server binaries.
 const packageDir = fileURLToPath(new URL('..', import.meta.url))
@@ -86,8 +84,8 @@ describe('fixture server — controlled scenarios', () => {
     transport: 'stdio',
     serverName: 'fixture',
     command: process.execPath,
-    args: ['--import', tsxLoader, fixtureServerPath],
-    env: { TSX_TSCONFIG_PATH: repoTsconfig },
+    args: [fixtureServerPath],
+    env: {},
     cwd: packageDir,
     toolCallTimeoutMs: 15_000,
   }
@@ -170,8 +168,8 @@ describe('fixture server — duplicate serverName', () => {
       transport: 'stdio',
       serverName: 'dup',
       command: process.execPath,
-      args: ['--import', tsxLoader, fixtureServerPath],
-      env: { TSX_TSCONFIG_PATH: repoTsconfig },
+      args: [fixtureServerPath],
+      env: {},
       cwd: packageDir,
       toolCallTimeoutMs: 15_000,
     }
@@ -191,8 +189,8 @@ describe('fixture server — disposal', () => {
       transport: 'stdio',
       serverName: 'fixture',
       command: process.execPath,
-      args: ['--import', tsxLoader, fixtureServerPath],
-      env: { TSX_TSCONFIG_PATH: repoTsconfig },
+      args: [fixtureServerPath],
+      env: {},
       cwd: packageDir,
       toolCallTimeoutMs: 15_000,
     })

+ 1 - 0
packages/subagent/subagent-acp/package.json

@@ -36,6 +36,7 @@
   "devDependencies": {
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-loader-smoke": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-subagent": "workspace:^",
     "@deepseek-ai/dsh-subagent-subprocess": "workspace:^",

+ 1 - 0
packages/subagent/subagent-acp/src/run.ts

@@ -294,6 +294,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
   let disposal: Promise<void> | undefined
   return {
     id,
+    localAgent: undefined,
     result,
     dispose(): Promise<void> {
       if (disposal !== undefined) return disposal

+ 3 - 4
packages/subagent/subagent-acp/tests/mock-acp-server.ts

@@ -36,10 +36,9 @@
  *                         grace, before the SIGKILL escalation). Touches
  *                         MOCK_READY_FILE once armed.
  *
- * It is NOT a test spec (no `describe`/`it`) — it is spawned BY the specs as the
- * child process the ACP backend drives. Kept as a `.ts` run under tsx by the
- * spec (which passes its own tsconfig), mirroring how the snapshot harness boots
- * the real example.
+ * It is not a test spec: the specs launch this protocol-only fixture through
+ * the mode-aware example resolver (tsx in source mode, Node type stripping in
+ * built mode). It imports no harness code or workspace paths.
  *
  * @module @deepseek-ai/dsh-subagent-acp/tests/mock-acp-server
  */

+ 21 - 19
packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts

@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest'
 import { Context } from 'cordis'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import SubagentService from '@deepseek-ai/dsh-subagent'
+import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
 import * as acp from '../src/index.ts'
 
 /**
@@ -17,9 +18,22 @@ import * as acp from '../src/index.ts'
 // The real acp-agent example: its bin + cordis.yml (the live DeepSeek config).
 const binScript = fileURLToPath(new URL('../../../examples/acp-demo/src/bin.ts', import.meta.url))
 const exampleConfig = fileURLToPath(new URL('../../../../examples/acp-agent/cordis.yml', import.meta.url))
-const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
 const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
 
+// How to launch the child acp-agent (src via tsx / lib via plain node, per DSH_EXAMPLE_MODE).
+// buildChildEnv scrubs ambient creds but keeps these extras, so the model key is
+// forwarded explicitly; TSX_TSCONFIG_PATH is added by the resolver in src mode only.
+const childLaunch = resolveExampleLaunch({
+  srcBin: binScript,
+  configArgs: ['--config', exampleConfig],
+  tsconfigPath: repoTsconfig,
+  env: {
+    ...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {},
+    ...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {},
+    DSH_PERMISSION_MODE: 'danger-full-access',
+  },
+})
+
 /** The ACP backend ignores the parent, but the seam requires one. */
 const fakeParent = { id: 'parent', session: { header: {} } } as unknown as Agent
 
@@ -40,18 +54,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
     await ctx.plugin(SubagentService)
     await ctx.plugin(acp, {
       providerName: 'acp',
-      command: process.execPath,
-      args: ['--import', tsxLoader, binScript, '--config', exampleConfig],
+      command: childLaunch.command,
+      args: childLaunch.args,
       cwd: workdir,
       permission: 'reject',
-      // The child harness needs the key to reach the model; forward it
-      // explicitly (buildChildEnv scrubs ambient creds but keeps these extras).
-      env: {
-        ...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {},
-        ...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {},
-        TSX_TSCONFIG_PATH: repoTsconfig,
-        DSH_PERMISSION_MODE: 'danger-full-access',
-      },
+      env: childLaunch.env as Record<string, string>,
     })
 
     const run = await ctx.subagents.start('acp', {
@@ -76,17 +83,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
     await ctx.plugin(SubagentService)
     await ctx.plugin(acp, {
       providerName: 'acp',
-      command: process.execPath,
-      args: ['--import', tsxLoader, binScript, '--config', exampleConfig],
+      command: childLaunch.command,
+      args: childLaunch.args,
       cwd: workdir,
       // The child needs to act (run bash), so approve its permission prompts.
       permission: 'allow',
-      env: {
-        ...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {},
-        ...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {},
-        TSX_TSCONFIG_PATH: repoTsconfig,
-        DSH_PERMISSION_MODE: 'danger-full-access',
-      },
+      env: childLaunch.env as Record<string, string>,
     })
 
     const run = await ctx.subagents.start('acp', {

+ 16 - 21
packages/subagent/subagent-acp/tests/subagent-acp.spec.ts

@@ -21,8 +21,6 @@ import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DI
  */
 
 const mockServer = fileURLToPath(new URL('./mock-acp-server.ts', import.meta.url))
-const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
-const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
 
 /** A throwaway parent Agent — the ACP backend ignores it, but the seam requires one. */
 const fakeParent = { id: 'parent', session: { header: {} } } as unknown as Agent
@@ -46,11 +44,9 @@ async function setup(mockEnv: SetupEnv = {}, permission: 'allow' | 'reject' = 'r
   await ctx.plugin(acp, {
     providerName: 'acp',
     command: process.execPath,
-    args: ['--import', tsxLoader, mockServer],
+    args: [mockServer],
     permission,
-    // The mock-server scripting vars must reach the child; TSX_TSCONFIG_PATH lets
-    // tsx resolve @deepseek-ai/* from a child cwd outside the repo.
-    env: { ...mockEnv, TSX_TSCONFIG_PATH: repoTsconfig },
+    env: mockEnv,
   })
   return ctx
 }
@@ -62,7 +58,7 @@ function text(blocks: { type: string; text?: string }[]): string {
 /**
  * Poll until `file` exists (the mock touches it once its prompt is in flight),
  * so a cancel test waits on a CONDITION rather than an arbitrary timeout — the
- * subprocess cold-start under tsx is variable, and a fixed sleep both flakes and
+ * subprocess cold-start is variable, and a fixed sleep both flakes and
  * slows the suite. Fails loud if the child never signals readiness.
  */
 async function waitForFile(file: string, timeoutMs = 5000): Promise<void> {
@@ -201,14 +197,13 @@ describe('dsh-subagent-acp', () => {
     try {
       await expect(startAcpRun(request(), {
         command: process.execPath,
-        args: ['--import', tsxLoader, mockServer],
+        args: [mockServer],
         cwd: process.cwd(),
         permission: 'reject',
         env: {
           MOCK_MISSING_SESSION_ID: '1',
           MOCK_FLUSH_ON_EOF: flushed,
           MOCK_FLUSH_DELAY_MS: '20',
-          TSX_TSCONFIG_PATH: repoTsconfig,
         },
         disposeEofGraceMs: 1000,
         disposeGraceMs: 100,
@@ -230,10 +225,10 @@ describe('dsh-subagent-acp', () => {
     try {
       const spec: AcpRunSpec = {
         command: process.execPath,
-        args: ['--import', tsxLoader, mockServer],
+        args: [mockServer],
         cwd: process.cwd(),
         permission: 'reject',
-        env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, TSX_TSCONFIG_PATH: repoTsconfig },
+        env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready },
         // Short on BOTH tiers: the trap ignores EOF and SIGTERM, so dispose must
         // burn the EOF window, then the SIGTERM window, then SIGKILL — keep each
         // small so the whole ladder finishes well within the 4000ms bound.
@@ -273,7 +268,7 @@ describe('dsh-subagent-acp', () => {
     try {
       const spec: AcpRunSpec = {
         command: process.execPath,
-        args: ['--import', tsxLoader, mockServer],
+        args: [mockServer],
         cwd: process.cwd(),
         permission: 'reject',
         // MOCK_HANG so the prompt never resolves on its own — we tear down a live
@@ -282,7 +277,7 @@ describe('dsh-subagent-acp', () => {
         // wider grace.
         env: {
           MOCK_HANG: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready,
-          MOCK_FLUSH_ON_EOF: flushed, MOCK_FLUSH_DELAY_MS: '400', TSX_TSCONFIG_PATH: repoTsconfig,
+          MOCK_FLUSH_ON_EOF: flushed, MOCK_FLUSH_DELAY_MS: '400',
         },
         disposeEofGraceMs: 2000,
         disposeGraceMs: 50,
@@ -313,12 +308,12 @@ describe('dsh-subagent-acp', () => {
     try {
       const spec: AcpRunSpec = {
         command: process.execPath,
-        args: ['--import', tsxLoader, mockServer],
+        args: [mockServer],
         cwd: process.cwd(),
         permission: 'reject',
         env: {
           MOCK_HANG: '1', MOCK_IGNORE_EOF: '1', MOCK_TEXT: 'x',
-          MOCK_READY_FILE: ready, MOCK_SIGTERM_FILE: sigterm, TSX_TSCONFIG_PATH: repoTsconfig,
+          MOCK_READY_FILE: ready, MOCK_SIGTERM_FILE: sigterm,
         },
         // Tiny EOF grace so the ignored-EOF window elapses fast, then SIGTERM.
         disposeEofGraceMs: 150,
@@ -437,9 +432,9 @@ describe('dsh-subagent-acp', () => {
       await ctx.plugin(acp, {
         providerName: 'acp',
         command: process.execPath,
-        args: ['--import', tsxLoader, mockServer],
+        args: [mockServer],
         permission: 'reject',
-        env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, TSX_TSCONFIG_PATH: repoTsconfig },
+        env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready },
         disposeEofGraceMs: 150,
         disposeGraceMs: 150,
       })
@@ -488,10 +483,10 @@ describe('dsh-subagent-acp', () => {
       request(),
       {
         command: process.execPath,
-        args: ['--import', tsxLoader, mockServer],
+        args: [mockServer],
         cwd: process.cwd(),
         permission: 'reject',
-        env: { MOCK_CRASH_ON_PROMPT: '1', TSX_TSCONFIG_PATH: repoTsconfig },
+        env: { MOCK_CRASH_ON_PROMPT: '1' },
         disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
         disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
         onError: (error, stopReason) => { errors.push({ message: error.message, stopReason }) },
@@ -526,10 +521,10 @@ describe('dsh-subagent-acp', () => {
       request(),
       {
         command: process.execPath,
-        args: ['--import', tsxLoader, mockServer],
+        args: [mockServer],
         cwd: process.cwd(),
         permission: 'reject',
-        env: { MOCK_CRASH_ON_PROMPT: '1', TSX_TSCONFIG_PATH: repoTsconfig },
+        env: { MOCK_CRASH_ON_PROMPT: '1' },
         disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
         disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
         onError: () => { throw new Error('sink boom') },

+ 3 - 0
packages/subagent/subagent-acp/tsconfig.json

@@ -28,6 +28,9 @@
     },
     {
       "path": "../subagent-subprocess"
+    },
+    {
+      "path": "../../support/loader-smoke"
     }
   ]
 }

+ 1 - 0
packages/subagent/subagent-inprocess/src/index.ts

@@ -175,6 +175,7 @@ export async function startInProcessRun(
 
   return {
     id: childId,
+    localAgent: child,
     result,
     dispose(): Promise<void> {
       request.signal.removeEventListener('abort', onAbort)

+ 2 - 2
packages/subagent/subagent/README.md

@@ -50,9 +50,9 @@ Runtime features are optional methods on `SubagentRun`: `sendMessage?` steers a
 
 `SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce.
 
-A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, and records `request.parent.session.id` in the child's `parentSession` header. The child may be owned by the parent scope or by a provider/root scope; durable lineage is the transport-neutral local-child relation. Remote providers instead mint a parent-scoped lifecycle id without publishing a local child.
+A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`.
 
-The service emits `subagent/start` only after `start()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. In-process start observers can resolve the published child through `ctx.agents.get(info.id)`; remote providers need not publish a local agent.
+The service emits `subagent/start` only after `start()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. The pair shares a service-minted `runId`; its `local` flag is snapshotted from the provider's exact `localAgent`, so observers never infer run identity or locality from reusable provider/session names.
 
 Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run.
 

+ 2 - 0
packages/subagent/subagent/package.json

@@ -23,6 +23,7 @@
   "license": "BSD-3-Clause",
   "peerDependencies": {
     "@deepseek-ai/dsh-agent": "^0.0.1",
+    "@deepseek-ai/dsh-brand": "^0.0.1",
     "@deepseek-ai/dsh-llm": "^0.0.1",
     "@deepseek-ai/dsh-scope": "^0.0.1",
     "@deepseek-ai/dsh-session": "^0.0.1",
@@ -31,6 +32,7 @@
   },
   "devDependencies": {
     "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-brand": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-scope": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",

+ 21 - 4
packages/subagent/subagent/src/index.ts

@@ -28,6 +28,7 @@
  * @module @deepseek-ai/dsh-subagent
  */
 
+import { randomUUID } from 'node:crypto'
 import { Context, Service } from 'cordis'
 import { scopeTarget } from '@deepseek-ai/dsh-scope'
 import type { Scoped } from '@deepseek-ai/dsh-scope'
@@ -43,7 +44,9 @@ import type {
   SubagentRun,
   SubagentStartRequest,
 } from './types.ts'
+import { SubagentRunId } from './types.ts'
 
+export { SubagentRunId } from './types.ts'
 export type {
   SubagentCapabilities,
   SubagentProvider,
@@ -112,18 +115,26 @@ declare module 'cordis' {
 
 /** Observe-only identifying detail for a ready subagent run. */
 export interface SubagentRunInfo {
+  /** Unique identity shared with the paired terminal event. */
+  readonly runId: SubagentRunId
   /** The provider that established the run. */
   readonly provider: string
   /** The child agent's id. */
   readonly id: SessionId
+  /** Whether the provider exposed an exact published in-process child. */
+  readonly local: boolean
 }
 
 /** Observe-only outcome detail for a settled subagent run. */
 export interface SubagentRunEndInfo {
+  /** Unique identity shared with the paired start event. */
+  readonly runId: SubagentRunId
   /** The provider that ran it. */
   readonly provider: string
   /** The child agent's id. */
   readonly id: SessionId
+  /** Whether the provider exposed an exact published in-process child. */
+  readonly local: boolean
   /** The terminal stop reason. */
   readonly stopReason: SubagentResult['stopReason']
   /** The child's final assistant output, absent on infrastructure rejection. */
@@ -208,22 +219,28 @@ export class SubagentService extends Service {
 
     const parent = request.parent
     const run = await provider.start(request)
+    const runId = SubagentRunId(randomUUID())
+    const lifecycleIdentity = {
+      runId,
+      provider: name,
+      id: run.id,
+      local: run.localAgent !== undefined,
+    }
     // Attach the terminal observer before dispatching start. Promise reactions
     // still run after this synchronous start emission, preserving start → end.
     void run.result.then(
       (result) => {
         this.emitLifecycle('subagent/end', {
-          provider: name,
-          id: run.id,
+          ...lifecycleIdentity,
           stopReason: result.stopReason,
           lastAssistantMessage: result.output,
         }, parent)
       },
       () => {
-        this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }, parent)
+        this.emitLifecycle('subagent/end', { ...lifecycleIdentity, stopReason: 'error' }, parent)
       },
     )
-    this.emitLifecycle('subagent/start', { provider: name, id: run.id }, parent)
+    this.emitLifecycle('subagent/start', lifecycleIdentity, parent)
     return run
   }
 

+ 19 - 0
packages/subagent/subagent/src/types.ts

@@ -7,10 +7,23 @@
  */
 
 import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
+import type { Branded } from '@deepseek-ai/dsh-brand'
 import type { ContentBlock } from '@deepseek-ai/dsh-llm'
 import type { SessionId } from '@deepseek-ai/dsh-session'
 import type { StructuredOutputSchema, ToolRestriction } from '@deepseek-ai/dsh-tools'
 
+/** Identifies one accepted subagent run across its lifecycle event pair. */
+export type SubagentRunId = Branded<'SubagentRunId'>
+
+/**
+ * Brand a string as a {@link SubagentRunId}.
+ * @param id - the raw id string (the service mints UUIDs; tests may pass fixtures).
+ * @returns the same string, branded.
+ */
+export function SubagentRunId(id: string): SubagentRunId {
+  return id as SubagentRunId
+}
+
 /**
  * Which START-TIME features a provider supports. Checked by the service before delegating to
  * {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks
@@ -139,6 +152,12 @@ export interface SubagentRun {
    * remote provider mints an id unique in the parent namespace.
    */
   readonly id: SessionId
+  /**
+   * The exact published in-process child, or `undefined` for a remote run.
+   * When present, its id is {@link id}; the provider retains no ownership
+   * implication beyond the run's ordinary {@link dispose} contract.
+   */
+  readonly localAgent: Agent | undefined
   /**
    * Resolves with the child's terminal {@link SubagentResult} when the run
    * settles. Does NOT reject on a child-level failure — a model/transport

+ 21 - 4
packages/subagent/subagent/tests/service.spec.ts

@@ -48,6 +48,7 @@ class StubProvider implements SubagentProvider {
     this.startCount += 1
     return {
       id: SessionId(`child:${this.name}:${request.parent.id}`),
+      localAgent: undefined,
       result: Promise.resolve(this.outcome),
       async dispose() {},
     }
@@ -137,13 +138,14 @@ describe('SubagentService', () => {
     const parent = fakeParent('delegator')
     const events: string[] = []
     const keys: unknown[] = []
-    ctx.on('subagent/start', function () { events.push('start'); keys.push(carrierKeyOf(this)) })
-    ctx.on('subagent/end', function () { events.push('end'); keys.push(carrierKeyOf(this)) })
+    const runIds: string[] = []
+    ctx.on('subagent/start', function (info) { events.push('start'); keys.push(carrierKeyOf(this)); runIds.push(info.runId) })
+    ctx.on('subagent/end', function (info) { events.push('end'); keys.push(carrierKeyOf(this)); runIds.push(info.runId) })
 
     const starting = subagents.start('deferred', baseRequest({ parent }))
     await Promise.resolve()
     expect(events).toEqual([])
-    ready.resolve({ id: SessionId('child'), result: result.promise, async dispose() {} })
+    ready.resolve({ id: SessionId('child'), localAgent: undefined, result: result.promise, async dispose() {} })
     const run = await starting
     expect(events).toEqual(['start'])
     result.resolve({ output: [{ type: 'text', text: 'answer' }], stopReason: 'completed' })
@@ -151,6 +153,21 @@ describe('SubagentService', () => {
     await Promise.resolve()
     expect(events).toEqual(['start', 'end'])
     expect(keys).toEqual([parent, parent])
+    expect(runIds[0]).toBe(runIds[1])
+  })
+
+  it('mints distinct lifecycle identities when provider and child ids repeat', async () => {
+    const { ctx, subagents } = await service()
+    subagents.registerProvider(new StubProvider('reused'))
+    const runIds: string[] = []
+    ctx.on('subagent/start', info => void runIds.push(info.runId))
+
+    const first = await subagents.start('reused', baseRequest())
+    const second = await subagents.start('reused', baseRequest())
+    await Promise.all([first.result, second.result])
+
+    expect(runIds).toHaveLength(2)
+    expect(new Set(runIds).size).toBe(2)
   })
 
   it('emits no run lifecycle when provider startup rejects', async () => {
@@ -192,7 +209,7 @@ describe('SubagentService', () => {
       capabilities: NO_CAPS,
       inheritsParentContext: false,
       async start() {
-        return { id: SessionId('infra-child'), result: failure.promise, async dispose() {} }
+        return { id: SessionId('infra-child'), localAgent: undefined, result: failure.promise, async dispose() {} }
       },
     })
     const failedRun = await subagents.start('infra', baseRequest())

+ 15 - 0
packages/subagent/tool-subagent/tests/tool-subagent.spec.ts

@@ -144,6 +144,7 @@ describe('dsh-tool-subagent', () => {
       inheritsParentContext: false,
       start: async () => ({
         id: SessionId('weird-child'),
+        localAgent: undefined,
         result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }),
         dispose: async () => {},
       }),
@@ -171,6 +172,7 @@ describe('dsh-tool-subagent', () => {
         seen = request
         return {
           id: SessionId('capture-child'),
+          localAgent: undefined,
           result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
           dispose: async () => {},
         }
@@ -200,6 +202,7 @@ describe('dsh-tool-subagent', () => {
         seen = request
         return {
           id: SessionId('bare-child'),
+          localAgent: undefined,
           result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
           dispose: async () => {},
         }
@@ -327,6 +330,7 @@ describe('dsh-tool-subagent', () => {
       inheritsParentContext: false,
       start: async () => ({
         id: SessionId('spy-child'),
+        localAgent: undefined,
         result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
         dispose: async () => void disposed(),
       }),
@@ -349,6 +353,7 @@ describe('dsh-tool-subagent', () => {
       inheritsParentContext: false,
       start: async () => ({
         id: SessionId('spy-child'),
+        localAgent: undefined,
         result: Promise.resolve({ output: [], stopReason: 'error' as const }),
         dispose: async () => void disposed(),
       }),
@@ -380,6 +385,7 @@ describe('dsh-tool-subagent', () => {
         }, { once: true })
         return {
           id: SessionId('spy-child'),
+          localAgent: undefined,
           result,
           dispose: async () => {},
         }
@@ -472,6 +478,7 @@ describe('dsh-tool-subagent', () => {
         seen = request
         return {
           id: SessionId('capture2-child'),
+          localAgent: undefined,
           result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
           dispose: async () => {},
         }
@@ -529,6 +536,7 @@ describe('dsh-tool-subagent', () => {
         seen = request
         return {
           id: SessionId('capture3-child'),
+          localAgent: undefined,
           result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
           dispose: async () => {},
         }
@@ -558,6 +566,7 @@ describe('dsh-tool-subagent', () => {
         seen = request
         return {
           id: SessionId('capture4-child'),
+          localAgent: undefined,
           result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
           dispose: async () => {},
         }
@@ -732,6 +741,7 @@ describe('dsh-tool-subagent background mode', () => {
         }, { once: true })
         return {
           id,
+          localAgent: undefined,
           result,
           dispose: () => Promise.resolve(),
         }
@@ -771,6 +781,7 @@ describe('dsh-tool-subagent background mode', () => {
     const order: string[] = []
     const completed = await settleRun({
       id: SessionId('child-1'),
+      localAgent: undefined,
       result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }),
       dispose() { order.push('dispose'); return Promise.resolve() },
     })
@@ -782,6 +793,7 @@ describe('dsh-tool-subagent background mode', () => {
     let disposed = false
     const failed = await settleRun({
       id: SessionId('child-2'),
+      localAgent: undefined,
       result: Promise.reject(new Error('transport gone')),
       dispose() { disposed = true; return Promise.resolve() },
     })
@@ -790,6 +802,7 @@ describe('dsh-tool-subagent background mode', () => {
 
     const disposeFailed = await settleRun({
       id: SessionId('child-3'),
+      localAgent: undefined,
       result: Promise.resolve({ output: [], stopReason: 'completed' }),
       dispose: () => Promise.reject(new Error('reap failed')),
     })
@@ -797,6 +810,7 @@ describe('dsh-tool-subagent background mode', () => {
 
     const bothFailed = await settleRun({
       id: SessionId('child-4'),
+      localAgent: undefined,
       result: Promise.reject(new Error('result failed')),
       dispose: () => Promise.reject(new Error('reap failed')),
     })
@@ -832,6 +846,7 @@ describe('background preflight failure (no orphaned child, by construction)', ()
         starts += 1
         return {
           id: SessionId('probe-child'),
+          localAgent: undefined,
           result: Promise.resolve({ output: [], stopReason: 'completed' as const }),
           dispose: () => Promise.resolve(),
         }

+ 1 - 1
packages/support/acp-snapshot/package.json

@@ -23,7 +23,7 @@
   "license": "BSD-3-Clause",
   "dependencies": {
     "@agentclientprotocol/sdk": "0.25.1",
-    "tsx": "^4.22.4",
+    "@deepseek-ai/dsh-loader-smoke": "workspace:*",
     "vitest": "^4.1.8"
   },
   "peerDependencies": {

+ 21 - 18
packages/support/acp-snapshot/src/launcher.ts

@@ -1,6 +1,6 @@
 /**
- * Shared launcher for ACP tests that drive an unbuilt agent subprocess over
- * JSON-RPC stdio. It owns the tsx loader, workspace-resolution environment,
+ * Shared launcher for ACP tests that drive an agent subprocess over JSON-RPC
+ * stdio. It owns source-or-built launch resolution, workspace environment,
  * stdout tee, SDK client, update collection, permission fallback, and process
  * shutdown so e2e and snapshot suites do not each reconstruct that boundary.
  *
@@ -9,7 +9,6 @@
 
 import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
 import { join } from 'node:path'
-import { fileURLToPath } from 'node:url'
 import { Readable, Writable } from 'node:stream'
 import {
   ClientSideConnection,
@@ -20,15 +19,14 @@ import {
   type RequestPermissionResponse,
   type SessionNotification,
 } from '@agentclientprotocol/sdk'
+import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
 
-// The child runs from a temp directory outside the repo, where a bare
-// `--import tsx` cannot resolve. Resolve this package's loader once instead.
-const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
-
-/** The unbuilt agent entry, leaf config, and workspace tsconfig an ACP test boots. */
+/** The source/built agent entry, leaf config, and workspace tsconfig an ACP test boots. */
 export interface AgentUnderTest {
-  /** The agent bin entry (for example `packages/examples/acp-demo/src/bin.ts`). */
+  /** The agent source bin entry (for example `packages/examples/acp-demo/src/bin.ts`). */
   binScript: string
+  /** Explicit built-mode entry for fixtures whose source path is not under `src/`. */
+  libBinScript?: string | undefined
   /** The leaf `cordis.yml` loaded by the bin. */
   configPath: string
   /** The repo tsconfig whose paths resolve unbuilt workspace imports. */
@@ -77,18 +75,23 @@ export interface LaunchedAcpTestAgent {
  */
 export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTestAgent {
   const { agent, cwd } = options
+  const launch = resolveExampleLaunch({
+    srcBin: agent.binScript,
+    libBin: agent.libBinScript,
+    configArgs: ['--config', options.configPath ?? agent.configPath],
+    tsconfigPath: agent.tsconfigPath,
+    env: {
+      ...options.env,
+      DSH_HOME: join(cwd, '.dsh'),
+      DSH_AGENTS_HOME: join(cwd, '.agents'),
+    },
+  })
   const child = spawn(
-    process.execPath,
-    ['--import', tsxLoader, agent.binScript, '--config', options.configPath ?? agent.configPath],
+    launch.command,
+    launch.args,
     {
       cwd,
-      env: {
-        ...process.env,
-        ...options.env,
-        TSX_TSCONFIG_PATH: agent.tsconfigPath,
-        DSH_HOME: join(cwd, '.dsh'),
-        DSH_AGENTS_HOME: join(cwd, '.agents'),
-      },
+      env: { ...process.env, ...launch.env },
       stdio: ['pipe', 'pipe', 'pipe'],
     },
   )

+ 22 - 3
packages/support/acp-snapshot/tests/harness.spec.ts

@@ -28,17 +28,19 @@ vi.mock('node:fs/promises', async (importOriginal) => {
 
 /**
  * Unit tests for the subprocess harness, driven through the REAL spawn path
- * (tsx loader, temp cwd, env plumbing) against the scripted fake ACP bin in
+ * (mode-aware launcher, temp cwd, env plumbing) against the scripted fake ACP bin in
  * ./fixtures/fake-acp-agent.ts. Each case writes a `behavior.json` next to a
  * throwaway fixture path; the fake bin echoes observable facts (env, seeded
  * workspace, permission outcomes) into `agent_message_chunk` text, so the
  * assertions read plain `rawStdout`.
  */
 
+const fakeAgent = fileURLToPath(new URL('./fixtures/fake-acp-agent.ts', import.meta.url))
 const AGENT: AgentUnderTest = {
-  binScript: fileURLToPath(new URL('./fixtures/fake-acp-agent.ts', import.meta.url)),
+  binScript: fakeAgent,
+  libBinScript: fakeAgent,
   // The fake bin ignores its config argv; any real path documents the shape.
-  configPath: fileURLToPath(new URL('./fixtures/fake-acp-agent.ts', import.meta.url)),
+  configPath: fakeAgent,
   tsconfigPath: fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)),
 }
 
@@ -242,6 +244,23 @@ describe('runScenario', () => {
     )).rejects.toThrow(/agent stderr:\nfake agent requested startup failure/)
   })
 
+  it('preserves launch-resolution errors when no child process exists', async () => {
+    const { dir, fixtureFile } = await scenario({})
+    vi.stubEnv('DSH_EXAMPLE_MODE', 'lib')
+    try {
+      await expect(runScenario(
+        { steps: [] },
+        {
+          agent: { ...AGENT, binScript: join(dir, 'outside-src.ts'), libBinScript: undefined },
+          mode: 'replay',
+          fixtureFile,
+        },
+      )).rejects.toThrow(/expected a "\/src\/" segment/)
+    } finally {
+      vi.unstubAllEnvs()
+    }
+  })
+
   it('drives a full turn: initialize (terminal caps), session, prompt, permission stub, harvest', { timeout: 20_000 }, async () => {
     const { fixtureFile } = await scenario({
       permissionProbe: true,

+ 4 - 2
packages/support/acp-snapshot/tests/suite.spec.ts

@@ -32,9 +32,11 @@ import {
  * spec once with `ACP_SNAPSHOT_SPEC_BOOTSTRAP=1`, then review and commit the resulting tree.
  */
 
+const fakeAgent = fileURLToPath(new URL('./fixtures/fake-acp-agent.ts', import.meta.url))
 const AGENT = {
-  binScript: fileURLToPath(new URL('./fixtures/fake-acp-agent.ts', import.meta.url)),
-  configPath: fileURLToPath(new URL('./fixtures/fake-acp-agent.ts', import.meta.url)),
+  binScript: fakeAgent,
+  libBinScript: fakeAgent,
+  configPath: fakeAgent,
   tsconfigPath: fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)),
 }
 

+ 3 - 1
packages/support/acp-snapshot/tsconfig.json

@@ -7,5 +7,7 @@
   "include": [
     "src"
   ],
-  "references": []
+  "references": [
+    { "path": "../loader-smoke" }
+  ]
 }

+ 4 - 4
packages/support/loader-smoke/README.md

@@ -1,10 +1,10 @@
 # `@deepseek-ai/dsh-loader-smoke`
 
-Shared subprocess harness for keyless example smokes that boot the real stdio-agent bin and a real `cordis.yml` through the Cordis Loader. A test supplies absolute bin/config/tsconfig paths, optional environment overrides, and stdin lines; `runLoaderSmoke` owns the isolated cwd, DSH homes, tsx path resolution, 30-second process deadline, captured diagnostics, forced kill, EOF, and cleanup.
+Shared subprocess harness for tests that boot an app and `cordis.yml` through the Cordis Loader. `resolveExampleLaunch` selects local `src` mode (tsx and root tsconfig paths) or CI `lib` mode (plain Node and package exports) from an explicit mode or `DSH_EXAMPLE_MODE`.
 
-Successful runs return stdout and stderr only after a zero exit. Non-zero exits and deadlines reject with both captured streams. `LOADER_SMOKE_TEST_TIMEOUT_MS` leaves Vitest enough room for the process-owned diagnostic timeout to fire first.
+`runLoaderSmoke` owns the isolated cwd, DSH homes, stdin, diagnostics, deadline, termination, and cleanup. It returns both streams after a zero exit and rejects with both streams on failure.
 
-This is support-tier test infrastructure, not product API. The consumers are the Loader-path smokes under `examples/{echo-agent,coding-agent,cordis-agent}`.
+This is support-tier test infrastructure, not product API.
 
 ## Model Experience
 
@@ -12,6 +12,6 @@ None, as this test-only harness boots example processes and inspects their strea
 
 ## Known Limitations and Deferred Work
 
-- **Only the unbuilt tsx/Loader path is exercised** — built-bin artifacts remain the responsibility of their separate e2e smokes.
+- **Built mode requires a prior build** — the config must also resolve every named package upward through `examples/node_modules`.
 - **Captured stdout and stderr are unbounded** — a runaway child can consume memory until the deadline kills it.
 - **Timeout kills only the direct child** — a process tree spawned by a faulty fixture can outlive the smoke and needs external cleanup.

+ 127 - 21
packages/support/loader-smoke/src/index.ts

@@ -2,6 +2,14 @@
  * Shared subprocess harness for keyless example smokes that boot a real
  * `cordis.yml` through the stdio-agent bin and Cordis Loader.
  *
+ * It also owns the mode-aware launch resolver every example subprocess harness shares
+ * ({@link resolveExampleLaunch}): booting an example bin from TypeScript source under `tsx` (the
+ * zero-build dev path, resolving `@deepseek-ai/dsh-*` / `@cordisjs/*` through the tsconfig `paths`
+ * map) or from built `lib/` under plain Node (resolving bare packages through real `exports`, as an
+ * installed consumer does, while Node type-strips relative example-local TypeScript plugins).
+ * Consolidating that spawn glue here retires the copies in the ACP snapshot harness and the example
+ * e2e drivers (the `TODO(acp-test-harness)`).
+ *
  * @module @deepseek-ai/dsh-loader-smoke
  */
 
@@ -9,26 +17,125 @@ import { spawn } from 'node:child_process'
 import { mkdtemp, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { fileURLToPath } from 'node:url'
 
 const DEFAULT_PROCESS_TIMEOUT_MS = 30_000
-const TSX_LOADER = fileURLToPath(import.meta.resolve('tsx'))
 
 /** Vitest deadline that leaves room for the subprocess-owned 30-second diagnostic timeout. */
 export const LOADER_SMOKE_TEST_TIMEOUT_MS = DEFAULT_PROCESS_TIMEOUT_MS + 15_000
 
+/** Which artifact an example bin is booted from: unbuilt `src` via tsx, or built `lib` via plain Node. */
+export type ExampleMode = 'src' | 'lib'
+
+/** Environment variable selecting the mode; CI and pre-push set it to `lib`, dev leaves it unset (`src`). */
+export const EXAMPLE_MODE_ENV = 'DSH_EXAMPLE_MODE'
+
+/**
+ * Parse an {@link ExampleMode} from a raw string, defaulting to `src` when absent so an unset
+ * environment reproduces the dev/tsx behavior. Throws on any other value rather than silently
+ * falling back, so a typo in a gate's env fails loud.
+ * @param raw - the raw value; defaults to `process.env.DSH_EXAMPLE_MODE`.
+ * @returns the validated mode.
+ */
+export function resolveExampleMode(raw: string | undefined = process.env[EXAMPLE_MODE_ENV]): ExampleMode {
+  switch (raw) {
+    case undefined:
+    case '':
+    case 'src':
+      return 'src'
+    case 'lib':
+      return 'lib'
+    default:
+      throw new Error(`${EXAMPLE_MODE_ENV} must be 'src' or 'lib', got ${JSON.stringify(raw)}.`)
+  }
+}
+
+/** Inputs to {@link resolveExampleLaunch}. */
+export interface ExampleLaunchOptions {
+  /** Absolute path to the example bin's TypeScript source entry (`<pkg>/src/bin.ts`); the `lib` bin is derived from it. */
+  readonly srcBin: string
+  /** Explicit plain-Node entry for `lib` mode; test fixtures may point this at Node-type-strippable TypeScript. */
+  readonly libBin?: string | undefined
+  /** Arguments passed after the bin — the config, positional (`[configPath]`) or flagged (`['--config', configPath]`). */
+  readonly configArgs?: readonly string[]
+  /** The mode to launch in; defaults to {@link resolveExampleMode} of the environment. */
+  readonly mode?: ExampleMode
+  /** Absolute repo tsconfig whose `paths` map resolves unbuilt workspace imports. Required in `src` mode, ignored in `lib`. */
+  readonly tsconfigPath?: string
+  /** Prepend `--expose-internals` (the Cordis Loader's bare-plugin resolver needs it for some bins); defaults to `false`. */
+  readonly exposeInternals?: boolean
+  /** Extra environment entries the mode-specific ones layer over; the caller then merges the result over `process.env`. */
+  readonly env?: NodeJS.ProcessEnv
+}
+
+/** The resolved spawn: `spawn(command, args, { env: { ...process.env, ...env } })`. */
+export interface ExampleLaunch {
+  /** The executable to spawn — always the current Node binary. */
+  readonly command: string
+  /** Node flags, the resolved bin, then the caller's `configArgs`. */
+  readonly args: string[]
+  /** Mode-specific environment (`TSX_TSCONFIG_PATH` in `src`, nothing added in `lib`) layered over the caller's `env`. */
+  readonly env: NodeJS.ProcessEnv
+}
+
+/** Derive the built-lib bin (`<pkg>/lib/<name>.js`) from a source bin (`<pkg>/src/<name>.ts`). */
+function toLibBin(srcBin: string): string {
+  const markerLength = '/src/'.length
+  const cut = Math.max(srcBin.lastIndexOf('/src/'), srcBin.lastIndexOf('\\src\\'))
+  if (cut === -1) {
+    throw new Error(`resolveExampleLaunch: expected a "/src/" segment or Windows equivalent in bin path ${JSON.stringify(srcBin)}.`)
+  }
+  const separator = srcBin.slice(cut, cut + 1)
+  const tail = srcBin.slice(cut + markerLength).replace(/\.ts$/, '.js')
+  return `${srcBin.slice(0, cut)}${separator}lib${separator}${tail}`
+}
+
+/**
+ * Resolve how to spawn an example bin in the selected mode.
+ *
+ * `src` yields `node [--expose-internals] --import <tsx> <srcBin> <configArgs>` with `TSX_TSCONFIG_PATH`
+ * set so the tsconfig `paths` map resolves workspace imports to source. `lib` yields
+ * `node [--expose-internals] <libBin> <configArgs>` under plain Node with no tsx and no paths map, so
+ * bare package plugins resolve through real package `exports` into built `lib/`; relative example-local
+ * TypeScript plugins remain source files loaded through Node's built-in type stripping. Bare resolution
+ * requires the config to live below a workspace that declares its `cordis.yml` package dependencies.
+ *
+ * @param options - the source bin, config arguments, mode, and environment.
+ * @returns the command, argument vector, and mode-specific environment to spawn with.
+ */
+export function resolveExampleLaunch(options: ExampleLaunchOptions): ExampleLaunch {
+  const mode = options.mode ?? resolveExampleMode()
+  const configArgs = options.configArgs ?? []
+  const flags = options.exposeInternals === true ? ['--expose-internals'] : []
+  const env: NodeJS.ProcessEnv = { ...options.env }
+
+  if (mode === 'src') {
+    if (options.tsconfigPath === undefined) {
+      throw new Error("resolveExampleLaunch: 'src' mode needs tsconfigPath for the workspace paths map.")
+    }
+    const tsxLoader = import.meta.resolve('tsx')
+    env.TSX_TSCONFIG_PATH = options.tsconfigPath
+    return { command: process.execPath, args: [...flags, '--import', tsxLoader, options.srcBin, ...configArgs], env }
+  }
+
+  return { command: process.execPath, args: [...flags, options.libBin ?? toLibBin(options.srcBin), ...configArgs], env }
+}
+
 /** Inputs that vary between real-Loader example smokes. */
 export interface LoaderSmokeOptions {
   /** Human-readable example name used in failure diagnostics. */
   readonly label: string
   /** Prefix for the isolated temporary process cwd. */
   readonly tempDirPrefix: string
-  /** Absolute stdio-agent bin path. */
+  /** Absolute stdio-agent bin SOURCE path (`<pkg>/src/bin.ts`); the `lib` bin is derived from it. */
   readonly binScript: string
+  /** Explicit plain-Node entry for `lib` mode; intended for test fixtures outside a package `src/` tree. */
+  readonly libBinScript?: string | undefined
   /** Absolute real Loader config path. */
   readonly configPath: string
-  /** Absolute repo tsconfig path used for unbuilt workspace-package resolution. */
+  /** Absolute repo tsconfig path used for unbuilt workspace-package resolution (required in `src` mode). */
   readonly tsconfigPath: string
+  /** Boot from source via tsx (`src`) or built lib via plain Node (`lib`); defaults to the environment's mode. */
+  readonly mode?: ExampleMode
   /** Environment overrides layered over the parent and isolated DSH homes. */
   readonly env?: Readonly<NodeJS.ProcessEnv>
   /** Lines written to stdin before EOF; omitted means immediate EOF. */
@@ -48,30 +155,29 @@ export interface LoaderSmokeResult {
 /**
  * Boot one real Loader tree from an isolated cwd, write the requested stdin
  * script, close stdin, and await a clean exit. The helper owns process kill and
- * temp-directory cleanup on every outcome.
- * @param options - example paths, environment, stdin, and diagnostic identity.
+ * temp-directory cleanup on every outcome, and picks src/lib via {@link resolveExampleLaunch}.
+ * @param options - example paths, mode, environment, stdin, and diagnostic identity.
  * @returns captured stdout and stderr after a zero exit.
  */
 export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise<LoaderSmokeResult> {
   const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix))
   const processTimeoutMs = options.processTimeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS
+  const launch = resolveExampleLaunch({
+    srcBin: options.binScript,
+    libBin: options.libBinScript,
+    configArgs: [options.configPath],
+    ...options.mode !== undefined ? { mode: options.mode } : {},
+    tsconfigPath: options.tsconfigPath,
+    exposeInternals: true,
+    env: { DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents'), ...options.env },
+  })
   try {
     return await new Promise((resolve, reject) => {
-      const child = spawn(
-        process.execPath,
-        ['--expose-internals', '--import', TSX_LOADER, options.binScript, options.configPath],
-        {
-          cwd,
-          env: {
-            ...process.env,
-            DSH_HOME: join(cwd, '.dsh'),
-            DSH_AGENTS_HOME: join(cwd, '.agents'),
-            ...options.env,
-            TSX_TSCONFIG_PATH: options.tsconfigPath,
-          },
-          stdio: ['pipe', 'pipe', 'pipe'],
-        },
-      )
+      const child = spawn(launch.command, launch.args, {
+        cwd,
+        env: { ...process.env, ...launch.env },
+        stdio: ['pipe', 'pipe', 'pipe'],
+      })
       let stdout = ''
       let stderr = ''
       let deferredFailure: Error | undefined

+ 111 - 0
packages/support/loader-smoke/tests/example-launch.spec.ts

@@ -0,0 +1,111 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+  EXAMPLE_MODE_ENV,
+  resolveExampleLaunch,
+  resolveExampleMode,
+} from '@deepseek-ai/dsh-loader-smoke'
+
+const SRC_BIN = '/repo/packages/examples/stdio-demo/src/bin.ts'
+const TSCONFIG = '/repo/tsconfig.json'
+
+const originalMode = process.env[EXAMPLE_MODE_ENV]
+afterEach(() => {
+  if (originalMode === undefined) Reflect.deleteProperty(process.env, EXAMPLE_MODE_ENV)
+  else process.env[EXAMPLE_MODE_ENV] = originalMode
+})
+
+describe('resolveExampleMode', () => {
+  it('defaults absent/empty/src to src', () => {
+    Reflect.deleteProperty(process.env, EXAMPLE_MODE_ENV)
+    expect(resolveExampleMode()).toBe('src')
+    expect(resolveExampleMode('')).toBe('src')
+    expect(resolveExampleMode('src')).toBe('src')
+  })
+
+  it('accepts lib', () => {
+    expect(resolveExampleMode('lib')).toBe('lib')
+  })
+
+  it('throws on any other value', () => {
+    expect(() => resolveExampleMode('prod')).toThrow(/must be 'src' or 'lib'/)
+  })
+
+  it('reads the environment when no argument is given', () => {
+    process.env[EXAMPLE_MODE_ENV] = 'lib'
+    expect(resolveExampleMode()).toBe('lib')
+    Reflect.deleteProperty(process.env, EXAMPLE_MODE_ENV)
+    expect(resolveExampleMode()).toBe('src')
+  })
+})
+
+describe('resolveExampleLaunch', () => {
+  it('src mode: --import tsx on the source bin with the tsconfig paths env', () => {
+    const { command, args, env } = resolveExampleLaunch({
+      srcBin: SRC_BIN,
+      configArgs: ['./cordis.yml'],
+      mode: 'src',
+      tsconfigPath: TSCONFIG,
+    })
+    expect(command).toBe(process.execPath)
+    expect(args).toContain('--import')
+    expect(args).toContain(SRC_BIN)
+    expect(args[args.length - 1]).toBe('./cordis.yml')
+    expect(args).not.toContain('--expose-internals')
+    expect(env.TSX_TSCONFIG_PATH).toBe(TSCONFIG)
+  })
+
+  it('src mode: throws without a tsconfig path', () => {
+    expect(() => resolveExampleLaunch({ srcBin: SRC_BIN, mode: 'src' })).toThrow(/needs tsconfigPath/)
+  })
+
+  it('lib mode: plain node on the derived lib bin, no tsx and no paths env', () => {
+    const { args, env } = resolveExampleLaunch({
+      srcBin: SRC_BIN,
+      configArgs: ['--config', './cordis.yml'],
+      mode: 'lib',
+      env: { DSH_HOME: '/tmp/home' },
+    })
+    expect(args).not.toContain('--import')
+    expect(args).toContain('/repo/packages/examples/stdio-demo/lib/bin.js')
+    expect(args.slice(-2)).toEqual(['--config', './cordis.yml'])
+    expect(env.TSX_TSCONFIG_PATH).toBeUndefined()
+    expect(env.DSH_HOME).toBe('/tmp/home')
+  })
+
+  it('lib mode: uses an explicit plain-Node bin when provided', () => {
+    const fixture = '/repo/fixture.ts'
+    const { args } = resolveExampleLaunch({ srcBin: fixture, libBin: fixture, mode: 'lib' })
+    expect(args).toContain(fixture)
+  })
+
+  it('prepends --expose-internals when requested', () => {
+    const { args } = resolveExampleLaunch({ srcBin: SRC_BIN, mode: 'lib', exposeInternals: true })
+    expect(args[0]).toBe('--expose-internals')
+  })
+
+  it('lib mode: rewrites only the last /src/ segment', () => {
+    const { args } = resolveExampleLaunch({
+      srcBin: '/repo/src/packages/examples/acp-demo/src/bin.ts',
+      mode: 'lib',
+    })
+    expect(args).toContain('/repo/src/packages/examples/acp-demo/lib/bin.js')
+  })
+
+  it('lib mode: derives the built bin from a Windows source path', () => {
+    const { args } = resolveExampleLaunch({
+      srcBin: String.raw`D:\repo\src\packages\examples\acp-demo\src\bin.ts`,
+      mode: 'lib',
+    })
+    expect(args).toContain(String.raw`D:\repo\src\packages\examples\acp-demo\lib\bin.js`)
+  })
+
+  it('lib mode: throws when the bin has no /src/ segment', () => {
+    expect(() => resolveExampleLaunch({ srcBin: '/repo/lib/bin.js', mode: 'lib' })).toThrow(/"\/src\/" segment/)
+  })
+
+  it('defaults the mode from the environment', () => {
+    process.env[EXAMPLE_MODE_ENV] = 'lib'
+    const { args } = resolveExampleLaunch({ srcBin: SRC_BIN })
+    expect(args).toContain('/repo/packages/examples/stdio-demo/lib/bin.js')
+  })
+})

+ 3 - 0
packages/support/loader-smoke/tests/loader-smoke.spec.ts

@@ -16,6 +16,7 @@ describe('runLoaderSmoke', () => {
       binScript: fixture('success'),
       configPath,
       tsconfigPath,
+      mode: 'src',
       env: { LOADER_SMOKE_MARKER: 'present' },
       stdinLines: ['one', 'two'],
     })
@@ -43,6 +44,7 @@ describe('runLoaderSmoke', () => {
       label: 'failure fixture',
       tempDirPrefix: 'loader-smoke-fail-',
       binScript: fixture('fail'),
+      libBinScript: fixture('fail'),
       configPath,
       tsconfigPath,
     })).rejects.toThrow('failure fixture exited 7. stdout:\n\nstderr:\nfixture failed')
@@ -53,6 +55,7 @@ describe('runLoaderSmoke', () => {
       label: 'hanging fixture',
       tempDirPrefix: 'loader-smoke-hang-',
       binScript: fixture('hang'),
+      libBinScript: fixture('hang'),
       configPath,
       tsconfigPath,
       processTimeoutMs: 100,

+ 1 - 0
packages/support/subagent-mock/src/index.ts

@@ -69,6 +69,7 @@ class MockSubagentProvider implements SubagentProvider {
     })
     return {
       id,
+      localAgent: undefined,
       result,
       dispose(): Promise<void> {
         flags.cancelled = true

+ 1 - 1
packages/ui/jsonrpc/README.md

@@ -4,7 +4,7 @@ The **SDK server plugin** (`jsonrpc`): mounting it serves a stdio JSON-RPC serve
 
 ## Wiring
 
-`inject: ['agents']`. The server gets or creates one agent per `sessionId` from the `initialize.provider`/`initialize.model` pair. It classifies subagent completions through live parent ownership or durable parent lineage and retains parent-scoped provider/id counts after child disposal. A registered owner for the provider route wins; an unowned `deepseek` route mounts `dsh-llm-deepseek` using `$DEEPSEEK_API_KEY` and `$DEEPSEEK_BASE_URL`, while any other unowned provider fails initialization. Persistence, tools, and other adapters come from the surrounding `cordis.yml`.
+`inject: ['agents']`. The server gets or creates one agent per `sessionId` from the `initialize.provider`/`initialize.model` pair. It forwards subagent completions only when the lifecycle payload's `local` flag was snapshotted from the provider's exact in-process child; reusable provider names, child ids, and durable lineage never establish locality. A registered owner for the provider route wins; an unowned `deepseek` route mounts `dsh-llm-deepseek` using `$DEEPSEEK_API_KEY` and `$DEEPSEEK_BASE_URL`, while any other unowned provider fails initialization. Persistence, tools, and other adapters come from the surrounding `cordis.yml`.
 
 ## Config
 

+ 5 - 40
packages/ui/jsonrpc/src/server.ts

@@ -19,7 +19,7 @@ import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
 import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope'
 import { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
 import type SubagentService from '@deepseek-ai/dsh-subagent'
-import type { SubagentRunEndInfo, SubagentRunInfo } from '@deepseek-ai/dsh-subagent'
+import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
 import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
 import type { JsonRpcTransportPeer } from './transport.ts'
 
@@ -68,15 +68,6 @@ function subagentParentOf(carrier: Scoped<SubagentService>): Agent {
   return carrierKeyOf(carrier) as Agent
 }
 
-/** Whether the live id names a local child related to this exact delegating parent. */
-function isLocalChild(ctx: Context, id: SessionId, parent: Agent): boolean {
-  const child = ctx.agents.get(id)
-  return child !== undefined && (
-    ctx.agents.isOwnedBy(id, parent)
-    || child.session.header.parentSession === parent.session.id
-  )
-}
-
 /**
  * The SDK server over a booted harness context. Constructing it subscribes to
  * session and subagent lifecycle events, forwarding durable session
@@ -92,7 +83,6 @@ export class HarnessSdkServer {
   private llmFiber: { dispose(): Promise<void> } | undefined
   private readonly sessions = new Map<string, SessionRecord>()
   private readonly sessionCreations = new Map<string, Promise<SessionRecord>>()
-  private readonly localRuns = new Map<string, Map<SessionId, Map<Agent, number>>>()
   private readonly disposers: (() => void)[] = []
   private shutdownTask: Promise<Record<string, never>> | undefined
   private shuttingDown = false
@@ -116,36 +106,12 @@ export class HarnessSdkServer {
         childSessionId: String(session.id),
       })
     }))
-    // In-process providers publish the child before start. Count starts related
-    // by exact runtime ownership or durable parent lineage so provider-owned
-    // roots remain local, completions survive child disposal, and reused ids
-    // need no settlement-order assumption.
-    const localRuns = this.localRuns
-    this.disposers.push(ctx.on('subagent/start', function (this: Scoped<SubagentService>, info: SubagentRunInfo) {
-      const parent = subagentParentOf(this)
-      if (!isLocalChild(ctx, info.id, parent)) return
-      const providerRuns = localRuns.get(info.provider) ?? new Map<SessionId, Map<Agent, number>>()
-      const parentRuns = providerRuns.get(info.id) ?? new Map<Agent, number>()
-      parentRuns.set(parent, (parentRuns.get(parent) ?? 0) + 1)
-      providerRuns.set(info.id, parentRuns)
-      localRuns.set(info.provider, providerRuns)
-    }))
     this.disposers.push(ctx.on('subagent/end', function (this: Scoped<SubagentService>, info: SubagentRunEndInfo) {
       const parent = subagentParentOf(this)
-      const providerRuns = localRuns.get(info.provider)
-      const parentRuns = providerRuns?.get(info.id)
-      const pendingCount = parentRuns?.get(parent)
-      if (pendingCount !== undefined) {
-        if (pendingCount === 1) parentRuns?.delete(parent)
-        else parentRuns?.set(parent, pendingCount - 1)
-        if (parentRuns?.size === 0) providerRuns?.delete(info.id)
-        if (providerRuns?.size === 0) localRuns.delete(info.provider)
-      }
-      // This protocol reports LOCAL child sessions. A lineage-bearing child
-      // has the session/created-driven start notification above. A remote run
-      // has neither a cached local start nor a live child related to this
-      // parent; an unrelated local agent with the same id never makes it local.
-      if (pendingCount === undefined && !isLocalChild(ctx, info.id, parent)) return
+      // This protocol reports only in-process child sessions. The service
+      // snapshots the provider's exact run provenance through child disposal;
+      // matching ids or parent lineage alone never establishes locality.
+      if (!info.local) return
       transport.notify('subagent.finished', {
         provider: info.provider,
         agentId: String(info.id),
@@ -223,7 +189,6 @@ export class HarnessSdkServer {
     this.sessionCreations.clear()
     const records = [...this.sessions.values()]
     this.sessions.clear()
-    this.localRuns.clear()
     const failures: unknown[] = []
     while (this.disposers.length > 0) {
       try {

+ 2 - 1
packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts

@@ -43,7 +43,7 @@ const [
 const storageRoot = await mkdtemp(join(tmpdir(), "jsonrpc-built-scope-"));
 const ctx = new Context();
 try {
-  await ctx.plugin(agentCore);
+  await ctx.plugin(agentCore, { workspaceContext: false });
   await ctx.plugin(SubagentService);
   await ctx.plugin(SessionPersistenceJsonl, { root: storageRoot });
   await new Promise((ready) => setTimeout(ready, 50));
@@ -71,6 +71,7 @@ try {
     start() {
       return Promise.resolve({
         id: child.agent.id,
+        localAgent: child.agent,
         result: result.promise,
         dispose() { return Promise.resolve(); },
       });

+ 118 - 12
packages/ui/jsonrpc/tests/server.spec.ts

@@ -70,7 +70,7 @@ async function makeHarness(storageDir: string) {
 async function settleSubagent(
   ctx: Context,
   parent: Agent,
-  info: SubagentRunEndInfo,
+  info: Omit<SubagentRunEndInfo, 'runId' | 'local'> & { localAgent: Agent | undefined },
   beforeSettle?: () => Promise<void>,
 ): Promise<void> {
   const result = Promise.withResolvers<SubagentResult>()
@@ -81,6 +81,7 @@ async function settleSubagent(
     async start() {
       return {
         id: info.id,
+        localAgent: info.localAgent,
         result: result.promise,
         dispose: () => Promise.resolve(),
       }
@@ -294,12 +295,14 @@ describe('HarnessSdkServer', () => {
       await settleSubagent(ctx, parentHandle.agent, {
         provider: 'spawn',
         id: SessionId('child-session'),
+        localAgent: handle.agent,
         stopReason: 'completed',
         lastAssistantMessage: [{ type: 'text', text: 'child done' }],
       }, () => handle.dispose())
       await settleSubagent(ctx, parentHandle.agent, {
         provider: 'spawn',
         id: SessionId('parentless-child-session'),
+        localAgent: parentlessHandle.agent,
         stopReason: 'error',
       }, () => parentlessHandle.dispose())
 
@@ -335,7 +338,7 @@ describe('HarnessSdkServer', () => {
     }
   })
 
-  it('ignores a remote run id that collides with an unrelated local agent', async () => {
+  it('ignores a remote run id that collides with a local child of the same parent', async () => {
     const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-remote-collision-'))
     const ctx = await makeHarness(storageDir)
     try {
@@ -346,24 +349,26 @@ describe('HarnessSdkServer', () => {
         meta: { cwd: storageDir },
         agentOptions: { model: 'deepseek' },
       })
-      const unrelatedHandle = await ctx.agents.create({
+      const collidingChild = await parentHandle.agent.ctx.agents.create({
         sessionId: SessionId('remote-run-id'),
-        meta: { cwd: storageDir },
+        meta: { cwd: storageDir, parentSession: SessionId('collision-parent') },
         agentOptions: { model: 'deepseek' },
       })
 
       await settleSubagent(ctx, parentHandle.agent, {
         provider: 'remote',
         id: SessionId('remote-run-id'),
+        localAgent: undefined,
         stopReason: 'completed',
         lastAssistantMessage: [],
-      }, () => unrelatedHandle.dispose())
+      })
 
       expect(transport.notifications.some(notification =>
         notification.method === 'subagent.finished'
         && notification.params?.agentId === 'remote-run-id',
       )).toBe(false)
 
+      await collidingChild.dispose()
       await parentHandle.dispose()
       await server.shutdown()
     } finally {
@@ -392,12 +397,14 @@ describe('HarnessSdkServer', () => {
       await settleSubagent(ctx, parentHandle.agent, {
         provider: 'continuation',
         id: SessionId('continuation-child'),
+        localAgent: childHandle.agent,
         stopReason: 'completed',
         lastAssistantMessage: [{ type: 'text', text: 'first' }],
       })
       await settleSubagent(ctx, parentHandle.agent, {
         provider: 'continuation',
         id: SessionId('continuation-child'),
+        localAgent: childHandle.agent,
         stopReason: 'completed',
         lastAssistantMessage: [{ type: 'text', text: 'second' }],
       }, () => childHandle.dispose())
@@ -436,6 +443,7 @@ describe('HarnessSdkServer', () => {
       const replacement = Promise.withResolvers<SubagentResult>()
       const results = [first.promise, sameLifetime.promise, replacement.promise]
       let starts = 0
+      let currentLocalAgent = oldChild.agent
       const disposeProvider = ctx.subagents.registerProvider({
         name: 'reused',
         capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
@@ -444,7 +452,7 @@ describe('HarnessSdkServer', () => {
           const result = results[starts]
           starts += 1
           if (result === undefined) throw new Error('unexpected fourth reused-id run')
-          return Promise.resolve({ id: SessionId('reused-child'), result, dispose: () => Promise.resolve() })
+          return Promise.resolve({ id: SessionId('reused-child'), localAgent: currentLocalAgent, result, dispose: () => Promise.resolve() })
         },
       })
 
@@ -471,6 +479,7 @@ describe('HarnessSdkServer', () => {
         meta: { cwd: storageDir, parentSession: SessionId('new-parent') },
         agentOptions: { model: 'deepseek' },
       })
+      currentLocalAgent = newChild.agent
       const secondRun = await ctx.subagents.start('reused', {
         parent: newParent.agent,
         prompt: [],
@@ -512,7 +521,99 @@ describe('HarnessSdkServer', () => {
     }
   })
 
-  it('falls back to live lineage and ignores runs without a local child session', async () => {
+  it('keeps locality bound to the accepted run across provider re-registration', async () => {
+    const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-provider-reuse-'))
+    const ctx = await makeHarness(storageDir)
+    try {
+      const transport = new FakeTransport()
+      const server = new HarnessSdkServer(ctx, transport)
+      const parent = await ctx.agents.create({
+        sessionId: SessionId('provider-reuse-parent'),
+        meta: { cwd: storageDir },
+        agentOptions: { model: 'deepseek' },
+      })
+      const child = await parent.agent.ctx.agents.create({
+        sessionId: SessionId('provider-reuse-child'),
+        meta: { cwd: storageDir, parentSession: SessionId('provider-reuse-parent') },
+        agentOptions: { model: 'deepseek' },
+      })
+      const localResult = Promise.withResolvers<SubagentResult>()
+      const remoteResult = Promise.withResolvers<SubagentResult>()
+      const unregisterLocal = ctx.subagents.registerProvider({
+        name: 'reused-provider',
+        capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
+        inheritsParentContext: false,
+        start: () => Promise.resolve({
+          id: SessionId('provider-reuse-child'),
+          localAgent: child.agent,
+          result: localResult.promise,
+          dispose: () => Promise.resolve(),
+        }),
+      })
+      const localRun = await ctx.subagents.start('reused-provider', {
+        parent: parent.agent,
+        prompt: [],
+        signal: new AbortController().signal,
+      })
+      unregisterLocal()
+
+      const unregisterRemote = ctx.subagents.registerProvider({
+        name: 'reused-provider',
+        capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
+        inheritsParentContext: false,
+        start: () => Promise.resolve({
+          id: SessionId('provider-reuse-child'),
+          localAgent: undefined,
+          result: remoteResult.promise,
+          dispose: () => Promise.resolve(),
+        }),
+      })
+      const remoteRun = await ctx.subagents.start('reused-provider', {
+        parent: parent.agent,
+        prompt: [],
+        signal: new AbortController().signal,
+      })
+
+      remoteResult.resolve({ output: [{ type: 'text', text: 'remote' }], stopReason: 'completed' })
+      await remoteRun.result
+      await Promise.resolve()
+      expect(transport.notifications.some(notification =>
+        notification.method === 'subagent.finished'
+        && notification.params?.lastAssistantMessage !== undefined,
+      )).toBe(false)
+
+      await child.dispose()
+      localResult.resolve({ output: [{ type: 'text', text: 'local' }], stopReason: 'completed' })
+      await localRun.result
+      await Promise.resolve()
+      expect(transport.notifications.filter(notification =>
+        notification.method === 'subagent.finished'
+        && notification.params?.childSessionId === 'provider-reuse-child',
+      )).toEqual([{
+        method: 'subagent.finished',
+        params: {
+          provider: 'reused-provider',
+          agentId: 'provider-reuse-child',
+          parentSessionId: 'provider-reuse-parent',
+          childSessionId: 'provider-reuse-child',
+          status: 'ok',
+          stopReason: 'completed',
+          lastAssistantMessage: [{ type: 'text', text: 'local' }],
+        },
+      }])
+
+      await localRun.dispose()
+      await remoteRun.dispose()
+      unregisterRemote()
+      await parent.dispose()
+      await server.shutdown()
+    } finally {
+      await ctx.fiber.dispose()
+      await rm(storageDir, { recursive: true, force: true })
+    }
+  })
+
+  it('uses explicit local provenance when start was missed and ignores remote runs', async () => {
     const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-fallback-'))
     const ctx = await makeHarness(storageDir)
     let parentHandle: AgentHandle | undefined
@@ -529,6 +630,7 @@ describe('HarnessSdkServer', () => {
         meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') },
         agentOptions: { provider: 'deepseek', model: 'deepseek' },
       })
+      const fallbackChild = handle.agent
       failedHandle = await parentHandle.agent.ctx.agents.create({
         sessionId: SessionId('failed-child-session'),
         meta: { cwd: storageDir },
@@ -541,12 +643,13 @@ describe('HarnessSdkServer', () => {
         inheritsParentContext: true,
         start: () => Promise.resolve({
           id: SessionId('fallback-child-session'),
+          localAgent: fallbackChild,
           result: missedStartResult.promise,
           dispose: () => Promise.resolve(),
         }),
       })
-      // Start before the server subscribes, so the terminal fallback must use
-      // the still-live registry entry rather than a cached start record.
+      // Start before the server subscribes. The terminal payload still carries
+      // this run's exact local child without reconstructing it from ids.
       const missedStartRun = await ctx.subagents.start('fork', {
         parent: parentHandle.agent,
         prompt: [],
@@ -560,22 +663,25 @@ describe('HarnessSdkServer', () => {
       await Promise.resolve()
       await missedStartRun.dispose()
       disposeMissedStartProvider()
-      // The server also missed this agent's creation, but observes the start;
-      // recover its lineage from the still-live registry entry.
+      // The server also missed this agent's creation but sees the exact child
+      // on the run lifecycle payload.
       await settleSubagent(ctx, parentHandle.agent, {
         provider: 'fork-live-fallback',
         id: SessionId('fallback-child-session'),
+        localAgent: fallbackChild,
         stopReason: 'completed',
         lastAssistantMessage: [],
       })
       await settleSubagent(ctx, parentHandle.agent, {
         provider: 'fork',
         id: SessionId('failed-child-session'),
+        localAgent: failedHandle.agent,
         stopReason: 'error',
       })
       await settleSubagent(ctx, parentHandle.agent, {
         provider: 'fork',
         id: SessionId('missing-child-agent'),
+        localAgent: undefined,
         stopReason: 'error',
       })
 
@@ -800,6 +906,6 @@ describe('HarnessSdkServer', () => {
     const server = new HarnessSdkServer(ctx, new FakeTransport())
 
     await expect(server.shutdown()).rejects.toBe(listenerFailure)
-    expect(on).toHaveBeenCalledTimes(4)
+    expect(on).toHaveBeenCalledTimes(3)
   })
 })

+ 8 - 0
packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts

@@ -105,6 +105,7 @@ class StubProvider implements SubagentProvider {
     if (request.signal.aborted) throw new Error('child start aborted before publication')
     return {
       id: SessionId(`stub-child-${index}`),
+      localAgent: undefined,
       result: terminal.promise,
       dispose: () => {
         controlled.disposeCalls += 1
@@ -362,6 +363,7 @@ describe('dsh-workflow-workerthread', () => {
         inheritsParentContext: false,
         start: async () => ({
           id: SessionId('reject-child'),
+          localAgent: undefined,
           result: Promise.reject(new Error('backend exploded')),
           dispose: () => Promise.resolve(),
         }),
@@ -396,6 +398,7 @@ describe('dsh-workflow-workerthread', () => {
       } as unknown as SubagentResult
       const start = vi.spyOn(ctx.subagents, 'start').mockResolvedValue({
         id: SessionId('raw-invalid-child'),
+        localAgent: undefined,
         result: Promise.resolve(invalid),
         dispose: () => Promise.resolve(),
       })
@@ -419,6 +422,7 @@ describe('dsh-workflow-workerthread', () => {
         inheritsParentContext: false,
         start: async () => ({
           id: SessionId('bad-dispose-child'),
+          localAgent: undefined,
           result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }),
           cancel: () => { /* settled already */ },
           dispose: () => { throw new Error('dispose exploded') },
@@ -440,6 +444,7 @@ describe('dsh-workflow-workerthread', () => {
         inheritsParentContext: false,
         start: async () => ({
           id: SessionId('trap-child'),
+          localAgent: undefined,
           result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }),
           cancel: () => { /* settled already */ },
           // The rejection VALUE's own coercion throws: a warn built with bare
@@ -767,6 +772,7 @@ describe('dsh-workflow-workerthread', () => {
           }, { once: true })
           return {
             id: SessionId('signal-only-child'),
+            localAgent: undefined,
             result,
             dispose: () => Promise.resolve(),
           }
@@ -1088,6 +1094,7 @@ describe('dsh-workflow-workerthread', () => {
 
       ready.resolve({
         id: SessionId('late-ready-child'),
+        localAgent: undefined,
         result: Promise.resolve({ output: [], stopReason: 'aborted' }),
         dispose: () => {
           disposeCalls += 1
@@ -1124,6 +1131,7 @@ describe('dsh-workflow-workerthread', () => {
           }, { once: true })
           return {
             id: SessionId('doomed-child'),
+            localAgent: undefined,
             result: new Promise(() => { /* never settles; the reap is the teardown */ }),
             dispose: () => Promise.reject(new Error('dispose exploded during reap')),
           }

+ 126 - 3
pnpm-lock.yaml

@@ -90,6 +90,120 @@ importers:
         specifier: ^4.1.8
         version: 4.1.8(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
 
+  examples:
+    dependencies:
+      '@cordisjs/plugin-hmr':
+        specifier: workspace:*
+        version: link:../vendor/hmr
+      '@cordisjs/plugin-include':
+        specifier: workspace:*
+        version: link:../vendor/include
+      '@deepseek-ai/dsh-acp-demo':
+        specifier: workspace:*
+        version: link:../packages/examples/acp-demo
+      '@deepseek-ai/dsh-bash-local':
+        specifier: workspace:*
+        version: link:../packages/bash/bash-local
+      '@deepseek-ai/dsh-bash-sandbox':
+        specifier: workspace:*
+        version: link:../packages/bash/bash-sandbox
+      '@deepseek-ai/dsh-code-runtime-worker':
+        specifier: workspace:*
+        version: link:../packages/code-runtime/code-runtime-worker
+      '@deepseek-ai/dsh-compact-basic':
+        specifier: workspace:*
+        version: link:../packages/compact/compact-basic
+      '@deepseek-ai/dsh-fs-local':
+        specifier: workspace:*
+        version: link:../packages/fs/fs-local
+      '@deepseek-ai/dsh-fs-policy':
+        specifier: workspace:*
+        version: link:../packages/fs/fs-policy
+      '@deepseek-ai/dsh-hooks-claude':
+        specifier: workspace:*
+        version: link:../packages/hooks/hooks-claude
+      '@deepseek-ai/dsh-hooks-codex':
+        specifier: workspace:*
+        version: link:../packages/hooks/hooks-codex
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:*
+        version: link:../packages/llm/llm
+      '@deepseek-ai/dsh-llm-deepseek':
+        specifier: workspace:*
+        version: link:../packages/llm/llm-deepseek
+      '@deepseek-ai/dsh-llm-replay':
+        specifier: workspace:*
+        version: link:../packages/support/llm-replay
+      '@deepseek-ai/dsh-permission':
+        specifier: workspace:*
+        version: link:../packages/ui/permission
+      '@deepseek-ai/dsh-repeat-tool-guard':
+        specifier: workspace:*
+        version: link:../packages/guard/repeat-tool-guard
+      '@deepseek-ai/dsh-sandbox-local':
+        specifier: workspace:*
+        version: link:../packages/sandbox/sandbox-local
+      '@deepseek-ai/dsh-spill-local':
+        specifier: workspace:*
+        version: link:../packages/spill/spill-local
+      '@deepseek-ai/dsh-spill-policy':
+        specifier: workspace:*
+        version: link:../packages/spill/spill-policy
+      '@deepseek-ai/dsh-stdio-demo':
+        specifier: workspace:*
+        version: link:../packages/examples/stdio-demo
+      '@deepseek-ai/dsh-subagent':
+        specifier: workspace:*
+        version: link:../packages/subagent/subagent
+      '@deepseek-ai/dsh-subagent-fork':
+        specifier: workspace:*
+        version: link:../packages/subagent/subagent-fork
+      '@deepseek-ai/dsh-subagent-spawn':
+        specifier: workspace:*
+        version: link:../packages/subagent/subagent-spawn
+      '@deepseek-ai/dsh-time-context':
+        specifier: workspace:*
+        version: link:../packages/context/time-context
+      '@deepseek-ai/dsh-timeout-policy':
+        specifier: workspace:*
+        version: link:../packages/timeout/timeout-policy
+      '@deepseek-ai/dsh-token-meter':
+        specifier: workspace:*
+        version: link:../packages/llm/token-meter
+      '@deepseek-ai/dsh-tool-cordis':
+        specifier: workspace:*
+        version: link:../packages/cordis/tool-cordis
+      '@deepseek-ai/dsh-tool-fs':
+        specifier: workspace:*
+        version: link:../packages/fs/tool-fs
+      '@deepseek-ai/dsh-tool-fs-search':
+        specifier: workspace:*
+        version: link:../packages/fs/tool-fs-search
+      '@deepseek-ai/dsh-tool-subagent':
+        specifier: workspace:*
+        version: link:../packages/subagent/tool-subagent
+      '@deepseek-ai/dsh-tool-todo':
+        specifier: workspace:*
+        version: link:../packages/todo/tool-todo
+      '@deepseek-ai/dsh-tool-workflow':
+        specifier: workspace:*
+        version: link:../packages/workflow/tool-workflow
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:*
+        version: link:../packages/core/tools
+      '@deepseek-ai/dsh-user-approval':
+        specifier: workspace:*
+        version: link:../packages/ui/user-approval
+      '@deepseek-ai/dsh-web':
+        specifier: workspace:*
+        version: link:../packages/web/web
+      '@deepseek-ai/dsh-web-fetch-local':
+        specifier: workspace:*
+        version: link:../packages/web/web-fetch-local
+      '@deepseek-ai/dsh-workflow-workerthread':
+        specifier: workspace:*
+        version: link:../packages/workflow/workflow-workerthread
+
   packages/bash/bash:
     devDependencies:
       '@deepseek-ai/dsh-sandbox':
@@ -293,6 +407,9 @@ importers:
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../../llm/llm
+      '@deepseek-ai/dsh-loader-smoke':
+        specifier: workspace:^
+        version: link:../../support/loader-smoke
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
@@ -1324,6 +1441,9 @@ importers:
       '@deepseek-ai/dsh-agent':
         specifier: workspace:^
         version: link:../../core/agent
+      '@deepseek-ai/dsh-brand':
+        specifier: workspace:^
+        version: link:../../util/brand
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../../llm/llm
@@ -1358,6 +1478,9 @@ importers:
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../../llm/llm
+      '@deepseek-ai/dsh-loader-smoke':
+        specifier: workspace:^
+        version: link:../../support/loader-smoke
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
@@ -1541,9 +1664,9 @@ importers:
       '@agentclientprotocol/sdk':
         specifier: 0.25.1
         version: 0.25.1(zod@4.4.3)
-      tsx:
-        specifier: ^4.22.4
-        version: 4.22.4
+      '@deepseek-ai/dsh-loader-smoke':
+        specifier: workspace:*
+        version: link:../loader-smoke
       vitest:
         specifier: ^4.1.8
         version: 4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))

+ 7 - 0
pnpm-workspace.yaml

@@ -2,6 +2,13 @@ packages:
   - vendor/*
   - packages/*/*
   - website
+  # The runnable demo leaves join as ONE workspace member: examples/package.json
+  # declares the union of every leaf's cordis.yml plugins as workspace:*, so a
+  # plain-node (`:lib`) boot of any leaf (examples/<leaf>/cordis.yml) resolves its
+  # plugins through real package `exports`→lib by walking up to examples/node_modules.
+  # Members for DEPENDENCY RESOLUTION only — NOT build targets: tsdown's explicit
+  # globs (vendor/*, packages/*/*) exclude them. See the example-execute-over-tsx RFC.
+  - examples
   # Deploy root of the single-exe build: a pure dependency manifest whose
   # closure is what the exe bundles and what the Python runtime distributes.
   - python/sdk-runtime

+ 2 - 2
scripts/doc-budgets.manifest.json

@@ -4,8 +4,8 @@
   "docs/architecture.md": 1790,
   "docs/cordis-primer.md": 600,
   "docs/defensive-patterns.md": 550,
-  "docs/testing.md": 800,
-  "examples/AGENTS.md": 200,
+  "docs/testing.md": 960,
+  "examples/AGENTS.md": 310,
   "packages/AGENTS.md": 290,
   "packages/README.md": 760
 }

+ 17 - 3
scripts/run-gates.ts

@@ -163,11 +163,13 @@ function gatesForMode(selected: Mode): Gate[] {
       ]
     case 'ci-coverage':
       return [
+        pnpmScript('build', 'build'),
         coverageGate(),
       ]
     case 'ci-snapshot':
       return [
-        pnpmScript('snapshot', 'test:snapshot'),
+        pnpmScript('build', 'build'),
+        snapshotGate(),
       ]
     case 'ci-artifacts':
       return ciArtifactGates()
@@ -186,7 +188,7 @@ function gatesForMode(selected: Mode): Gate[] {
         pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
         pnpmScript('test', 'test'),
         pnpmScript('duplication', 'duplication'),
-        pnpmScript('snapshot', 'test:snapshot'),
+        snapshotGate(),
         pnpmScript('build', 'build'),
         ...hygieneLeafGates({ artifactNeeds: ['build'] }),
         ...docSyncLeafGates({
@@ -207,7 +209,7 @@ function ciPrimaryGates(): Gate[] {
     lintGate(),
     pnpmScript('duplication', 'duplication'),
     coverageGate(),
-    pnpmScript('snapshot', 'test:snapshot'),
+    snapshotGate(),
     demoSmokeGate({ needs: ['lint'] }),
     ...docSyncLeafGates(),
     pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
@@ -281,6 +283,18 @@ function coverageGate(): Gate {
     ...positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers'),
   ], {
     label: 'test:coverage',
+    env: { DSH_EXAMPLE_MODE: 'lib' },
+    needs: ['build'],
+  })
+}
+
+// The snapshot suite boots the example bins in `lib` mode (built artifact under plain Node,
+// plugins via real exports) — CI and pre-push already build, so they exercise what ships rather
+// than the tsx/source path dev uses. It therefore waits on `build`.
+function snapshotGate(): Gate {
+  return pnpmScript('snapshot', 'test:snapshot', {
+    env: { DSH_EXAMPLE_MODE: 'lib' },
+    needs: ['build'],
   })
 }
 

+ 99 - 3
scripts/verify-cordis-config.ts

@@ -1,18 +1,32 @@
 /**
- * Reject JavaScript expressions in Cordis Loader entry metadata.
+ * Validate Cordis Loader entry metadata and example package resolution.
  *
  * The Loader interpolates only a plugin entry's `config`; expression objects in
  * fields such as `disabled` remain truthy data and silently change composition.
+ * Example configs run from built packages, so every named package must resolve
+ * from the examples workspace and every local package must be in the root
+ * TypeScript project graph.
  */
 
 import { globSync, readFileSync } from 'node:fs'
-import { resolve } from 'node:path'
+import { dirname, relative, resolve } from 'node:path'
 import * as yaml from 'js-yaml'
+import ts from 'typescript'
 
 interface JsExpr {
   __jsExpr: string
 }
 
+interface PackageManifest {
+  name?: string
+  dependencies?: Record<string, string>
+}
+
+interface PluginReference {
+  file: string
+  name: string
+}
+
 const root = resolve(import.meta.dirname, '..')
 const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const
 const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
@@ -30,6 +44,7 @@ const files = globSync(['**/*cordis*.yml', '**/*cordis*.yaml'], {
   exclude: ['.claude/**', 'node_modules/**', 'vendor/**'],
 }).sort()
 const errors: string[] = []
+const examplePluginReferences: PluginReference[] = []
 
 for (const file of files) {
   const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema })
@@ -42,8 +57,10 @@ for (const file of files) {
   }
 }
 
+errors.push(...validateExampleResolution())
+
 if (errors.length > 0) {
-  console.error('verify-cordis-config: Loader entry metadata is static; move !!js under plugin config or select an explicit overlay.')
+  console.error('verify-cordis-config: invalid Loader metadata or example package resolution:')
   for (const error of errors) console.error(`- ${error}`)
   process.exitCode = 1
 } else {
@@ -55,6 +72,7 @@ function validateEntry(value: unknown, file: string, path: string): void {
     errors.push(`${file}${path}: entry must be an object`)
     return
   }
+  recordExamplePlugin(value, file)
   validateMetadata(value, file, path)
   if ((value.group === true || value.name === '@cordisjs/plugin-group') && isUnknownArray(value.config)) {
     for (let index = 0; index < value.config.length; index++) {
@@ -68,6 +86,7 @@ function validateEntry(value: unknown, file: string, path: string): void {
     const patch = config.patches[index]
     const patchPath = `${path}.config.patches[${index}]`
     if (!isRecord(patch)) continue
+    recordExamplePlugin(patch, file)
     validateMetadata(patch, file, patchPath)
     if (!isUnknownArray(patch.insert)) continue
     for (let insertIndex = 0; insertIndex < patch.insert.length; insertIndex++) {
@@ -76,6 +95,83 @@ function validateEntry(value: unknown, file: string, path: string): void {
   }
 }
 
+function recordExamplePlugin(entry: Record<string, unknown>, file: string): void {
+  if (file.startsWith('examples/') && typeof entry.name === 'string') {
+    examplePluginReferences.push({ file, name: entry.name })
+  }
+}
+
+function validateExampleResolution(): string[] {
+  const violations: string[] = []
+  const exampleManifest = readManifest('examples/package.json')
+  const dependencies = exampleManifest.dependencies ?? {}
+  const localPackages = localPackageDirectories()
+  const rootReferences = rootProjectReferences()
+  const requiredPackages = new Map<string, Set<string>>()
+
+  for (const reference of examplePluginReferences) {
+    const packageName = packageNameFromSpecifier(reference.name)
+    if (packageName === undefined) continue
+    const locations = requiredPackages.get(packageName) ?? new Set<string>()
+    locations.add(reference.file)
+    requiredPackages.set(packageName, locations)
+  }
+
+  for (const [packageName, locations] of requiredPackages) {
+    if (!(packageName in dependencies)) {
+      violations.push(`${[...locations].join(', ')}: ${packageName} must be declared in examples/package.json dependencies`)
+    }
+  }
+
+  const localExamplePackages = new Set([
+    ...Object.keys(dependencies),
+    ...requiredPackages.keys(),
+  ])
+  for (const packageName of localExamplePackages) {
+    const packageDirectory = localPackages.get(packageName)
+    if (packageDirectory === undefined || rootReferences.has(packageDirectory)) continue
+    const repoPath = relative(root, packageDirectory).replaceAll('\\', '/')
+    violations.push(`tsconfig.json: missing project reference for ${packageName} (${repoPath})`)
+  }
+
+  return violations
+}
+
+function readManifest(path: string): PackageManifest {
+  return JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest
+}
+
+function localPackageDirectories(): Map<string, string> {
+  const manifests = globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root })
+  const packages = new Map<string, string>()
+  for (const manifestPath of manifests) {
+    const manifest = readManifest(manifestPath)
+    if (manifest.name !== undefined) packages.set(manifest.name, resolve(root, dirname(manifestPath)))
+  }
+  return packages
+}
+
+function rootProjectReferences(): Set<string> {
+  const config = ts.readConfigFile(resolve(root, 'tsconfig.json'), path => ts.sys.readFile(path))
+  if (config.error !== undefined) {
+    throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'))
+  }
+  const references = (config.config as { references?: Array<{ path?: unknown }> }).references ?? []
+  return new Set(references.flatMap((reference) => {
+    if (typeof reference.path !== 'string') return []
+    return [resolve(root, reference.path)]
+  }))
+}
+
+function packageNameFromSpecifier(specifier: string): string | undefined {
+  if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('file:')) return undefined
+  const segments = specifier.split('/')
+  if (specifier.startsWith('@')) {
+    return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined
+  }
+  return segments[0] || undefined
+}
+
 function validateMetadata(entry: Record<string, unknown>, file: string, path: string): void {
   for (const field of metadataFields) {
     if (!(field in entry)) continue

+ 4 - 4
website/zh-CN/api/harness/events.md

@@ -383,7 +383,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c
 
 - `info` — the run identity and terminal outcome.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L109)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L112)
 
 ### subagent/provider-added
 
@@ -397,7 +397,7 @@ A provider became resolvable in the registry.
 
 - `provider` — the registered provider.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L83)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L86)
 
 ### subagent/provider-removed
 
@@ -411,7 +411,7 @@ A provider left the registry. Accepted runs remain holder-owned.
 
 - `name` — the provider name that no longer resolves.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L89)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L92)
 
 ### subagent/start
 
@@ -425,7 +425,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get(
 
 - `info` — the provider and ready child identity.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L100)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L103)
 
 ## system-prompt/*
 

+ 5 - 5
website/zh-CN/api/harness/subagents.md

@@ -6,7 +6,7 @@
 
 Named provider registry and capability-checked start surface.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L142)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L153)
 
 ### ctx.subagents.registerProvider(provider)
 
@@ -20,7 +20,7 @@ Register a provider under its name. Registration is effect-scoped and HMR safe;
 
 **Returns** the exact Cordis effect disposer.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L156)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L167)
 
 ### ctx.subagents.getProvider(name)
 
@@ -34,7 +34,7 @@ Look up a provider by name.
 
 **Returns** the provider, or undefined when absent.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L179)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L190)
 
 ### ctx.subagents.list()
 
@@ -46,7 +46,7 @@ List registered provider names in insertion order.
 
 **Returns** the registered names.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L187)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L198)
 
 ### ctx.subagents.start(name, request)
 
@@ -61,4 +61,4 @@ Establish a ready child on the named provider. Capability and semantic checks ru
 
 **Returns** the ready holder-owned run.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L200)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L211)