ソースを参照

Merge pull request #361 from deepseek-harness/worktree-example-lib

feat(examples): run CI example execution and testing from built lib
Tianyi Cui 1 ヶ月 前
コミット
f07ae453cc
38 ファイル変更821 行追加191 行削除
  1. 8 2
      .github/workflows/e2e.yml
  2. 1 1
      AGENTS.md
  3. 1 0
      docs/rfc/INDEX.md
  4. 6 0
      docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.i18n.yaml
  5. 42 0
      docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.md
  6. 42 0
      docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.zh.md
  7. 6 1
      docs/testing.md
  8. 2 2
      examples/AGENTS.md
  9. 22 22
      examples/acp-agent/tests/acp.e2e.ts
  10. 14 12
      examples/acp-agent/tests/escalation.e2e.ts
  11. 10 4
      examples/acp-agent/tests/hooks.e2e.ts
  12. 1 1
      examples/echo-agent/tests/fixtures/context/time-context/cordis.yml
  13. 46 0
      examples/package.json
  14. 9 9
      knip.json
  15. 1 0
      packages/context/time-context/package.json
  16. 23 17
      packages/context/time-context/tests/time-context.e2e.ts
  17. 4 1
      packages/context/time-context/tsconfig.json
  18. 1 1
      packages/mcp/mcp-client/tests/fixture-server.ts
  19. 6 8
      packages/mcp/mcp-client/tests/mcp-client.e2e.ts
  20. 1 0
      packages/subagent/subagent-acp/package.json
  21. 2 2
      packages/subagent/subagent-acp/tests/mock-acp-server.ts
  22. 21 19
      packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts
  23. 14 18
      packages/subagent/subagent-acp/tests/subagent-acp.spec.ts
  24. 3 0
      packages/subagent/subagent-acp/tsconfig.json
  25. 1 1
      packages/support/acp-snapshot/package.json
  26. 30 29
      packages/support/acp-snapshot/src/harness.ts
  27. 4 2
      packages/support/acp-snapshot/tests/harness.spec.ts
  28. 4 2
      packages/support/acp-snapshot/tests/suite.spec.ts
  29. 3 1
      packages/support/acp-snapshot/tsconfig.json
  30. 4 4
      packages/support/loader-smoke/README.md
  31. 127 21
      packages/support/loader-smoke/src/index.ts
  32. 111 0
      packages/support/loader-smoke/tests/example-launch.spec.ts
  33. 3 0
      packages/support/loader-smoke/tests/loader-smoke.spec.ts
  34. 123 3
      pnpm-lock.yaml
  35. 7 0
      pnpm-workspace.yaml
  36. 2 2
      scripts/doc-budgets.manifest.json
  37. 17 3
      scripts/run-gates.ts
  38. 99 3
      scripts/verify-cordis-config.ts

+ 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 - 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: the package `bin` points at built `lib/bin.js` under plain `node`, which tsx masks (settle races, module resolution, a swallowed load failure exiting 0). The same applies to any non-index runtime entry the built package resolves at run time (the worker-thread runtime's sibling `lib/worker.cjs`). 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.
 

+ 22 - 22
examples/acp-agent/tests/acp.e2e.ts

@@ -15,6 +15,7 @@ import {
   type RequestPermissionResponse,
   type SessionNotification,
 } from '@agentclientprotocol/sdk'
+import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
 
 /**
  * Boots examples/acp-agent as an ACP subprocess. The key-gated prompt leg
@@ -25,8 +26,6 @@ import {
 // The child runs from a temp cwd, so its bin and config path are absolute.
 const binScript = fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url))
 const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
-// Resolve tsx absolutely because the subprocess runs outside the repo.
-const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
 // The root tsconfig supplies unbuilt workspace `paths`; making it explicit
 // avoids accidental resolution through stale built output.
 const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
@@ -38,24 +37,21 @@ interface Spawned {
   stderr: string[]
 }
 
-// TODO(acp-test-harness): this subprocess/client boot glue is duplicated with
-// hooks.e2e.ts and partly with dsh-acp-snapshot's harness. Migrate both e2e
-// files onto that launcher before the TSX/env/permission-stub details drift.
 function spawnAcpAgent(cwd: string, env: NodeJS.ProcessEnv = process.env): Spawned {
-  const child = spawn(
-    process.execPath,
-    ['--import', tsxLoader, binScript, '--config', configPath],
-    {
-      cwd,
-      env: {
-        ...env,
-        TSX_TSCONFIG_PATH: repoTsconfig,
-        DSH_PERMISSION_MODE: 'danger-full-access',
-        DSH_HOME: join(cwd, '.dsh'),
-        DSH_AGENTS_HOME: join(cwd, '.agents'),
-      },
-      stdio: ['pipe', 'pipe', 'pipe'],
+  const launch = resolveExampleLaunch({
+    srcBin: binScript,
+    configArgs: ['--config', configPath],
+    tsconfigPath: repoTsconfig,
+    env: {
+      DSH_PERMISSION_MODE: 'danger-full-access',
+      DSH_HOME: join(cwd, '.dsh'),
+      DSH_AGENTS_HOME: join(cwd, '.agents'),
     },
+  })
+  const child = spawn(
+    launch.command,
+    launch.args,
+    { cwd, env: { ...env, ...launch.env }, stdio: ['pipe', 'pipe', 'pipe'] },
   )
   const stderr: string[] = []
   child.stderr.setEncoding('utf8')
@@ -138,16 +134,20 @@ describe('acp-agent over real stdio (no key required)', () => {
     workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-'))
     // Collect raw stdout bytes directly (bypass the SDK framing) to inspect.
     // A dummy key boots the adapter; this purity test sends no prompt and makes no model call.
-    const child = spawn(process.execPath, ['--import', tsxLoader, binScript, '--config', configPath], {
-      cwd: workdir,
+    const launch = resolveExampleLaunch({
+      srcBin: binScript,
+      configArgs: ['--config', configPath],
+      tsconfigPath: repoTsconfig,
       env: {
-        ...process.env,
         DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
-        TSX_TSCONFIG_PATH: repoTsconfig,
         DSH_PERMISSION_MODE: 'danger-full-access',
         DSH_HOME: join(workdir, '.dsh'),
         DSH_AGENTS_HOME: join(workdir, '.agents'),
       },
+    })
+    const child = spawn(launch.command, launch.args, {
+      cwd: workdir,
+      env: { ...process.env, ...launch.env },
       stdio: ['pipe', 'pipe', 'pipe'],
     })
     const out: string[] = []

+ 14 - 12
examples/acp-agent/tests/escalation.e2e.ts

@@ -15,6 +15,7 @@ import {
   type RequestPermissionResponse,
   type SessionNotification,
 } from '@agentclientprotocol/sdk'
+import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
 
 /**
  * Exercises the default ACP composition through the real bin and Loader. The
@@ -28,9 +29,8 @@ import {
 
 const binScript = fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url))
 const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
-const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
 // The subprocess runs from a temp cwd outside the repo; point tsx at the repo
-// tsconfig so the unbuilt `paths` map resolves (see examples/AGENTS.md).
+// tsconfig so the unbuilt `paths` map resolves in src mode (see examples/AGENTS.md).
 const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
 
 // Without a usable bwrap/Seatbelt runner, the strict attempt fails closed with
@@ -55,17 +55,19 @@ interface Spawned {
 
 /** Boot the example as an ACP subprocess; the scripted client answers every permission prompt with `answer`. */
 function spawnAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once'): Spawned {
+  const launch = resolveExampleLaunch({
+    srcBin: binScript,
+    configArgs: ['--config', configPath],
+    tsconfigPath: repoTsconfig,
+    // A dummy key lets the deepseek adapter boot keyless (presence-checked at
+    // apply, used only on a real model call); the with-key tests carry the
+    // real key, so the fallback is inert there.
+    env: { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY || 'sk-dummy-for-boot' },
+  })
   const child = spawn(
-    process.execPath,
-    ['--import', tsxLoader, binScript, '--config', configPath],
-    {
-      cwd,
-      // A dummy key lets the deepseek adapter boot keyless (presence-checked at
-      // apply, used only on a real model call); the with-key tests carry the
-      // real key, so the fallback is inert there.
-      env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY || 'sk-dummy-for-boot', TSX_TSCONFIG_PATH: repoTsconfig },
-      stdio: ['pipe', 'pipe', 'pipe'],
-    },
+    launch.command,
+    launch.args,
+    { cwd, env: { ...process.env, ...launch.env }, stdio: ['pipe', 'pipe', 'pipe'] },
   )
   const stderr: string[] = []
   child.stderr.setEncoding('utf8')

+ 10 - 4
examples/acp-agent/tests/hooks.e2e.ts

@@ -15,6 +15,7 @@ import {
   type RequestPermissionResponse,
   type SessionNotification,
 } from '@agentclientprotocol/sdk'
+import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
 
 /**
  * With-key e2e for the Claude hook bridge. The process-level `./hooks.json` is
@@ -25,7 +26,6 @@ import {
 
 const binScript = fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url))
 const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
-const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
 const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
 
 interface Spawned {
@@ -36,10 +36,16 @@ interface Spawned {
 }
 
 function spawnAcpAgent(cwd: string): Spawned {
+  const launch = resolveExampleLaunch({
+    srcBin: binScript,
+    configArgs: ['--config', configPath],
+    tsconfigPath: repoTsconfig,
+    env: { DSH_PERMISSION_MODE: 'danger-full-access' },
+  })
   const child = spawn(
-    process.execPath,
-    ['--import', tsxLoader, binScript, '--config', configPath],
-    { cwd, env: { ...process.env, TSX_TSCONFIG_PATH: repoTsconfig, DSH_PERMISSION_MODE: 'danger-full-access' }, stdio: ['pipe', 'pipe', 'pipe'] },
+    launch.command,
+    launch.args,
+    { cwd, env: { ...process.env, ...launch.env }, stdio: ['pipe', 'pipe', 'pipe'] },
   )
   const stderr: string[] = []
   child.stderr.setEncoding('utf8')

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

@@ -35,6 +35,7 @@
   "devDependencies": {
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-loader-smoke": "workspace:^",
     "@deepseek-ai/dsh-subagent": "workspace:^",
     "@deepseek-ai/dsh-subagent-subprocess": "workspace:^",
     "@cordisjs/plugin-loader": "^1.0.0-rc.5",

+ 2 - 2
packages/subagent/subagent-acp/tests/mock-acp-server.ts

@@ -2,8 +2,8 @@
  * Minimal no-network ACP child process for keyless backend tests. Environment variables script its
  * text and stop reason, a cancel-cooperative or cancel-ignoring hang, permission requests, and a
  * readiness marker. Disposal fixtures can delay an EOF flush, ignore EOF but exit and mark
- * SIGTERM, or trap SIGTERM to require SIGKILL. The specs spawn this non-test module under tsx with
- * an explicit tsconfig, mirroring real example boot.
+ * SIGTERM, or trap SIGTERM to require SIGKILL. The specs run this protocol-only fixture directly
+ * with Node's type stripping; 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', {

+ 14 - 18
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
 }
@@ -197,10 +193,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.
@@ -240,7 +236,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
@@ -249,7 +245,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,
@@ -280,12 +276,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,
@@ -404,9 +400,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,
       })
@@ -455,10 +451,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 }) },
@@ -493,10 +489,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 - 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": {

+ 30 - 29
packages/support/acp-snapshot/src/harness.ts

@@ -11,7 +11,6 @@ import { cp, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
 import { existsSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join, delimiter } from 'node:path'
-import { fileURLToPath } from 'node:url'
 import { Readable, Writable } from 'node:stream'
 import {
   ClientSideConnection,
@@ -23,12 +22,7 @@ import {
   type RequestPermissionResponse,
   type SessionNotification,
 } from '@agentclientprotocol/sdk'
-
-// Resolve tsx's ESM loader to an ABSOLUTE path once: the child runs with its
-// cwd in a temp dir OUTSIDE the repo, where a bare `--import tsx` would not
-// resolve from node_modules. import.meta.resolve gives this package's tsx
-// regardless of the child cwd.
-const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
+import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
 
 /**
  * The agent composition a scenario runs against: which bin to boot and which
@@ -37,8 +31,10 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
  * them from its own `import.meta.url`.
  */
 export interface AgentUnderTest {
-  /** The agent bin entry (e.g. `packages/examples/acp-demo/src/bin.ts`), run unbuilt via tsx. */
+  /** The agent bin's SOURCE entry (e.g. `packages/examples/acp-demo/src/bin.ts`); the `lib` bin is derived from it. */
   binScript: string
+  /** Explicit plain-Node entry for `lib` mode; intended for test fixtures outside a package `src/` tree. */
+  libBinScript?: string | undefined
   /**
    * The example's live `cordis.yml`. Under `DSH_SNAPSHOT=replay` the bin swaps
    * it for the sibling `cordis.snapshot.yml` (the keyless replay overlay), so
@@ -47,10 +43,8 @@ export interface AgentUnderTest {
   configPath: string
   /**
    * The repo-root tsconfig whose `paths` map resolves the unbuilt workspace
-   * imports. Passed to the child as `TSX_TSCONFIG_PATH`: tsx finds a tsconfig
-   * by searching UP from the child's cwd — a temp dir outside the repo — so
-   * without the explicit pin the dsh-* imports fail before the bin writes a
-   * byte.
+   * imports in `src` mode (passed to the child as `TSX_TSCONFIG_PATH`). Ignored
+   * in `lib` mode, where the example resolves plugins through real `exports`.
    */
   tsconfigPath: string
 }
@@ -184,25 +178,32 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
     if (opts.workspaceDir !== undefined && existsSync(opts.workspaceDir)) {
       await cp(opts.workspaceDir, cwd, { recursive: true })
     }
-    const env: NodeJS.ProcessEnv = {
-      ...process.env,
-      TSX_TSCONFIG_PATH: opts.agent.tsconfigPath,
-      DSH_SNAPSHOT: opts.mode,
-      DSH_SNAPSHOT_FILE: opts.fixtureFile,
-      DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot,
-      DSH_SNAPSHOT_SPILL_ROOT: spillRoot,
-      DSH_HOME: join(cwd, '.dsh'),
-      DSH_AGENTS_HOME: join(cwd, '.agents'),
-      ...opts.overrideFile !== undefined ? { DSH_SNAPSHOT_OVERRIDE: opts.overrideFile } : {},
-      ...opts.childFiles !== undefined && opts.childFiles.length > 0
-        ? { DSH_SNAPSHOT_CHILD_FILES: opts.childFiles.join(delimiter) }
-        : {},
-    }
+    // Boot the agent in the environment's mode (DSH_EXAMPLE_MODE): `src` runs the
+    // source bin under tsx with the paths map; `lib` runs the built bin under plain
+    // Node, resolving plugins through the example's workspace node_modules → lib.
+    const launch = resolveExampleLaunch({
+      srcBin: opts.agent.binScript,
+      libBin: opts.agent.libBinScript,
+      configArgs: ['--config', opts.configPath ?? opts.agent.configPath],
+      tsconfigPath: opts.agent.tsconfigPath,
+      env: {
+        DSH_SNAPSHOT: opts.mode,
+        DSH_SNAPSHOT_FILE: opts.fixtureFile,
+        DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot,
+        DSH_SNAPSHOT_SPILL_ROOT: spillRoot,
+        DSH_HOME: join(cwd, '.dsh'),
+        DSH_AGENTS_HOME: join(cwd, '.agents'),
+        ...opts.overrideFile !== undefined ? { DSH_SNAPSHOT_OVERRIDE: opts.overrideFile } : {},
+        ...opts.childFiles !== undefined && opts.childFiles.length > 0
+          ? { DSH_SNAPSHOT_CHILD_FILES: opts.childFiles.join(delimiter) }
+          : {},
+      },
+    })
 
     child = spawn(
-      process.execPath,
-      ['--import', tsxLoader, opts.agent.binScript, '--config', opts.configPath ?? opts.agent.configPath],
-      { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] },
+      launch.command,
+      launch.args,
+      { cwd, env: { ...process.env, ...launch.env }, stdio: ['pipe', 'pipe', 'pipe'] },
     )
 
     child.stderr.setEncoding('utf8')

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

@@ -14,10 +14,12 @@ import { runScenario, type AgentUnderTest, type InputStep } from '../src/harness
  * 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)),
 }
 

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

+ 123 - 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
@@ -1352,6 +1469,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-subagent':
         specifier: workspace:^
         version: link:../subagent
@@ -1532,9 +1652,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