Просмотр исходного кода

Merge pull request #220 from deepseek-harness/tool-cordis

feat: add the self-referential cordis toolset (@deepseek-ai/dsh-tool-cordis)
Tianyi Cui 2 месяцев назад
Родитель
Сommit
868b33b8b1
52 измененных файлов с 4556 добавлено и 8 удалено
  1. 2 0
      AGENTS.md
  2. 3 1
      docs/capability-seams.md
  3. 18 0
      docs/config-catalog.md
  4. 1 0
      docs/graph-atlas.md
  5. 5 0
      docs/module-graph.md
  6. 1 0
      docs/rfc/INDEX.md
  7. 32 0
      docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md
  8. 31 0
      docs/tool-catalog.md
  9. 1 0
      examples/AGENTS.md
  10. 6 0
      examples/README.md
  11. 33 0
      examples/cordis-agent/README.md
  12. 49 0
      examples/cordis-agent/composition.md
  13. 83 0
      examples/cordis-agent/cordis.yml
  14. 7 0
      examples/cordis-agent/package.json
  15. 156 0
      examples/cordis-agent/tests/cordis-tools.e2e.ts
  16. 46 0
      examples/cordis-agent/tests/harness.ts
  17. 94 0
      examples/cordis-agent/tests/keyless-smoke.e2e.ts
  18. 1 0
      knip.json
  19. 4 1
      package.json
  20. 1 0
      packages/README.md
  21. 7 0
      packages/cordis/README.md
  22. 33 0
      packages/cordis/tool-cordis/README.md
  23. 42 0
      packages/cordis/tool-cordis/package.json
  24. 849 0
      packages/cordis/tool-cordis/src/api-catalog.ts
  25. 39 0
      packages/cordis/tool-cordis/src/fiber-state.ts
  26. 447 0
      packages/cordis/tool-cordis/src/guard.ts
  27. 236 0
      packages/cordis/tool-cordis/src/index.ts
  28. 191 0
      packages/cordis/tool-cordis/src/inspect.ts
  29. 64 0
      packages/cordis/tool-cordis/src/mount.ts
  30. 51 0
      packages/cordis/tool-cordis/src/present.ts
  31. 201 0
      packages/cordis/tool-cordis/src/sandbox.ts
  32. 143 0
      packages/cordis/tool-cordis/tests/cross-mount.spec.ts
  33. 104 0
      packages/cordis/tool-cordis/tests/helpers.ts
  34. 117 0
      packages/cordis/tool-cordis/tests/inspect.spec.ts
  35. 74 0
      packages/cordis/tool-cordis/tests/integration.spec.ts
  36. 575 0
      packages/cordis/tool-cordis/tests/mount.spec.ts
  37. 41 0
      packages/cordis/tool-cordis/tests/present.spec.ts
  38. 295 0
      packages/cordis/tool-cordis/tests/sandbox-context.spec.ts
  39. 49 0
      packages/cordis/tool-cordis/tests/tool-cordis.spec.ts
  40. 82 0
      packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts
  41. 27 0
      packages/cordis/tool-cordis/tsconfig.json
  42. 1 1
      packages/core/tools/tests/gen-tool-catalog.spec.ts
  43. 34 0
      pnpm-lock.yaml
  44. 3 3
      scripts/doc-budgets.manifest.json
  45. 247 0
      scripts/gen-cordis-api.ts
  46. 1 1
      scripts/gen-cordis-catalog.ts
  47. 12 1
      scripts/gen-doc-graphs.ts
  48. 1 0
      scripts/gen-module-graph.ts
  49. 13 0
      scripts/gen-tool-catalog.ts
  50. 1 0
      tsconfig.base.json
  51. 1 0
      tsconfig.build.json
  52. 1 0
      tsconfig.json

+ 2 - 0
AGENTS.md

@@ -20,6 +20,7 @@ packages/    Harness packages at packages/<group>/<pkg>/, all named @deepseek-ai
   subagent/    subagent seam + spawn/fork/ACP backends + delegation tool
   todo/        the todo_write tool
   guard/       loop-hygiene plugins
+  cordis/      self-referential toolset: the agent inspects/mounts plugins in its own runtime
   hooks/       Claude Code / Codex hook bridges + shared wire-protocol library
   session-persistence/  persistence seam + JSONL/SQLite backends
   ui/          ACP bridge, app-boot glue, stdio/ACP app bins, user-interaction seam, ask-user tool
@@ -48,6 +49,7 @@ pnpm run hygiene        # knip + publint + workspace constraints + NodeNext cons
 pnpm run doc-sync       # all documentation gates; see the doc-sync script in package.json
 pnpm run demo:echo      # mock-model REPL, no key needed
 pnpm run demo:repl      # real REPL coding agent (needs DEEPSEEK_API_KEY)
+pnpm run demo:cordis    # self-referential demo: the agent modifies its own runtime (needs key)
 pnpm run demo:acp       # ACP server agent (needs DEEPSEEK_API_KEY)
 ```
 

+ 3 - 1
docs/capability-seams.md

@@ -32,6 +32,7 @@ flowchart LR
   svc_tools["ctx.tools<br/>Tool registry and execution waterfall"]
   pkg_tool_ask_user["tool-ask-user"]
   pkg_tool_bash["tool-bash"]
+  pkg_tool_cordis["tool-cordis"]
   pkg_tool_subagent["tool-subagent"]
   pkg_tool_todo["tool-todo"]
   pkg_user_interaction["user-interaction"]
@@ -128,6 +129,7 @@ flowchart LR
   svc_tools --> pkg_agent_loop
   svc_tools --> pkg_tool_ask_user
   svc_tools --> pkg_tool_bash
+  svc_tools --> pkg_tool_cordis
   svc_tools --> pkg_tool_fs
   svc_tools --> pkg_tool_subagent
   svc_tools --> pkg_tool_todo
@@ -145,7 +147,7 @@ flowchart LR
 | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. |
 | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
 | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. |
-| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-fs`](../packages/fs/tool-fs), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute. |
+| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute. |
 | `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. |
 | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-agent`](../packages/ui/stdio-agent), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. |
 | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-core`](../packages/core/agent-core) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |

+ 18 - 0
docs/config-catalog.md

@@ -670,6 +670,24 @@ export interface Config {
 
 Source: [`packages/core/system-prompt/src/index.ts:179`](../packages/core/system-prompt/src/index.ts)
 
+## `@deepseek-ai/dsh-tool-cordis`
+
+Requires: `tools`
+
+```ts config-catalog
+/** Config for the tool-cordis plugin: the sandbox evaluation bound. */
+export interface Config {
+  /**
+   * Milliseconds the SYNCHRONOUS portion of mount code may run in the vm
+   * before evaluation is aborted (default 5000). An async body escapes this
+   * bound — see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md for the trust stance.
+   */
+  vmTimeoutMs?: number
+}
+```
+
+Source: [`packages/cordis/tool-cordis/src/index.ts:53`](../packages/cordis/tool-cordis/src/index.ts)
+
 ## `@deepseek-ai/dsh-tool-fs`
 
 Requires: `tools` · `fs` · `systemPrompt`

+ 1 - 0
docs/graph-atlas.md

@@ -14,6 +14,7 @@ The process decision behind this index is recorded in [the documentation graph R
 | [capability seams and core services](capability-seams.md) | `hybrid generated` |
 | [echo-agent app composition](../examples/echo-agent/composition.md) | `hybrid generated` |
 | [coding-agent app composition](../examples/coding-agent/composition.md) | `hybrid generated` |
+| [cordis-agent app composition](../examples/cordis-agent/composition.md) | `hybrid generated` |
 | [acp-agent app composition](../examples/acp-agent/composition.md) | `hybrid generated` |
 | [event producer/consumer matrix](event-producer-consumer.md) | `hybrid generated` |
 | [agent turn and step lifecycle](agent-lifecycle.md) | `curated` |

+ 5 - 0
docs/module-graph.md

@@ -61,6 +61,9 @@ flowchart TD
   subgraph group_todo["packages/todo"]
     pkg_tool_todo["tool-todo"]
   end
+  subgraph group_cordis["packages/cordis"]
+    pkg_tool_cordis["tool-cordis"]
+  end
   subgraph group_hooks["packages/hooks"]
     pkg_hook_protocol["hook-protocol"]
     pkg_hooks_claude["hooks-claude"]
@@ -168,6 +171,7 @@ flowchart TD
   pkg_tool_todo --> pkg_agent
   pkg_tool_todo --> pkg_session
   pkg_tool_todo --> pkg_tools
+  pkg_tool_cordis --> pkg_tools
   pkg_hooks_codex --> pkg_agent
   pkg_hooks_codex --> pkg_hook_protocol
   pkg_hooks_codex --> pkg_llm
@@ -276,6 +280,7 @@ flowchart TD
 | [`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) |
 | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
+| [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`tools`](../packages/core/tools) |
 | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
 | [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
 | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |

+ 1 - 0
docs/rfc/INDEX.md

@@ -64,6 +64,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
 | [Subagent lifecycle enrichment — lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 |
 | [Explicit model-facing tool order](implemented/feature/2026-07-06-explicit-tool-order.md) | 2026-07-06 |
 | [Repeat-tool-call guard plugin](implemented/feature/2026-07-08-repeat-tool-guard.md) | 2026-07-08 |
+| [The self-referential cordis toolset](implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 2026-07-08 |
 
 ### Simplification
 

Разница между файлами не показана из-за своего большого размера
+ 32 - 0
docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md


Разница между файлами не показана из-за своего большого размера
+ 31 - 0
docs/tool-catalog.md


+ 1 - 0
examples/AGENTS.md

@@ -21,6 +21,7 @@ A keyless smoke that spawns the example from a temp cwd must set `TSX_TSCONFIG_P
 |---|---|---|
 | `echo-agent` | `tests/echo.e2e.ts` — boots the real `cordis.yml`, drives the echo tool round-trip and the direct canned reply | **N/A — keyless by nature** (the `mock-echo` model has no real provider) |
 | `coding-agent` | `tests/keyless-smoke.e2e.ts` — boots the full real tree (dummy key, no prompt → no model call), asserts banner + clean exit | `tests/{full-loop,coding-task,resume,compaction,todo-write}.e2e.ts` — real model + real bash + real todo_write, world-verified |
+| `cordis-agent` | `tests/keyless-smoke.e2e.ts` — boots the real tree incl. `@deepseek-ai/dsh-tool-cordis` by package name; the tool logic is unit-tested in `packages/cordis/tool-cordis` | `tests/cordis-tools.e2e.ts` — real model mounts a listener (tagged line fires), builds+calls its own tool, composes two mounts via provide/inject |
 | `acp-agent` | `pnpm run test:snapshot` — boots the real ACP subprocess and replays a recorded session keyless (incl. the hook matrix: a scenario per hook point × outcome for BOTH the Claude and Codex bridges — block, deny, ask, context-fold, force-continue); `tests/acp.e2e.ts` also asserts stdout purity without a key | `tests/acp.e2e.ts` — real ACP prompt, verifies a file the agent wrote; `tests/hooks.e2e.ts` — a real `PreToolUse` hook blocks bash, verifies the file is NOT written |
 
 See [the root AGENTS.md](../AGENTS.md) for repo-wide conventions and [docs/architecture.md](../docs/architecture.md) for the design.

+ 6 - 0
examples/README.md

@@ -19,6 +19,12 @@ A REPL agent demo: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + th
 
 Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [coding-agent/README.md](coding-agent/README.md) for details.
 
+## cordis-agent
+
+The **self-referential** demo: the coding spine plus [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis), whose three tools (`cordis_inspect` / `cordis_mount` / `cordis_unmount`) let the agent inspect the live cordis runtime it runs inside, mount model-written plugins into it (an event listener, a brand-new tool for itself, or a service another mount injects), and dispose them again — all dynamic mounts grouped under one `cordis-dynamic` fiber subtree. The `ctx.fs`/`ctx.web` services ride along provider-only, as the capabilities those plugins build on.
+
+Run with: `pnpm run demo:cordis` (needs `DEEPSEEK_API_KEY`). See [cordis-agent/README.md](cordis-agent/README.md) for the staged demo script and [the toolset RFC](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md) for the design and sandbox caveats.
+
 ## acp-agent
 
 An agent demo exposed as an **Agent Client Protocol (ACP)** server over JSON-RPC stdio, via the [`@deepseek-ai/dsh-acp-agent`](../packages/ui/acp-agent) app — drive it from Zed or any other ACP client. Also the home of the keyless snapshot tests.

+ 33 - 0
examples/cordis-agent/README.md

@@ -0,0 +1,33 @@
+# cordis-agent
+
+The self-referential harness demo: the coding-agent spine (DeepSeek V4 + local bash on the stdio chat app) plus [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md), which hands the model three tools over the **live cordis runtime it is running inside** — inspect it, mount new plugins into it, and dispose them again. The `ctx.fs` and `ctx.web` services are mounted (provider-only, no model-facing file/web tools) so the plugins the agent writes have real capabilities to build on; Node built-ins are trapped in the sandbox and redirect to those services. The design (sandbox semantics, mount lifecycle, cross-mount composition, caveats) lives in [the toolset RFC](../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
+
+## Run it
+
+```sh
+# repo root .env (gitignored) or exported env:
+#   DEEPSEEK_API_KEY=sk-…
+#   DEEPSEEK_BASE_URL=https://…   # optional; defaults to the public API
+pnpm run demo:cordis
+```
+
+The intended demo is staged — verify the listener link first, then let the agent extend itself:
+
+```
+> Mount a plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash.
+  [tool call] cordis_mount({"code": "return { name: 'status-logger', apply(ctx) { ctx.on('agent/status', (agent, status) => console.log('status →', status)) } }"})
+  [tool result] mounted dyn-1 (plugin "status-logger", state: active)
+  [tool call] bash({"command": "echo hi"})
+[cordis:dyn-1] status → …            ← the mounted listener firing, live
+> Now give yourself a reverse_text tool and use it on "harness".
+  [tool call] cordis_mount({"code": "return { name: 'reverse-text', inject: ['tools'], apply(ctx) { ctx.tools.register(harness.defineTool({ name: 'reverse_text', … })) } }"})
+  [tool call] reverse_text({"text": "harness"})   ← a tool the agent built for itself, one step earlier
+> Unmount both.
+  [tool call] cordis_unmount({"id": "dyn-1"})
+```
+
+Ask for `cordis_inspect` with `what: "api"` or `what: "events"` to see the generated service/event reference the agent writes plugin code against, and try two cooperating mounts (`ctx.provide` in one, `inject` in the other) to watch cordis park and revive the consumer.
+
+## End-to-end tests
+
+`tests/keyless-smoke.e2e.ts` boots the real `cordis.yml` through the Loader with a dummy key and asserts the banner + clean EOF exit (the export-shape / real-load-path guard, now across the package-name resolution). `tests/cordis-tools.e2e.ts` is the with-key smoke: a real model mounts a status listener (asserting the tagged console line actually fires — the world, not the agent's claim), builds itself a `reverse_text` tool and uses it, and composes two mounts via provide/inject. The tool logic itself is unit-tested in [`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) under the per-file 100% coverage gate.

+ 49 - 0
examples/cordis-agent/composition.md

@@ -0,0 +1,49 @@
+<!-- Generated by scripts/gen-doc-graphs.ts - do not edit by hand.
+     Run `pnpm run gen-doc-graphs` to regenerate. -->
+
+# Cordis Agent App Composition
+
+The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its own runtime and mount/unmount plugins into it.
+
+```mermaid
+flowchart LR
+  cfg["examples/cordis-agent<br/>cordis.yml"]
+  plugin_cordis_hmr["hmr<br/>@cordisjs/plugin-hmr"]
+  cfg --> plugin_cordis_hmr
+  plugin_cordis_llm_deepseek["llm-deepseek<br/>@deepseek-ai/dsh-llm-deepseek"]
+  cfg --> plugin_cordis_llm_deepseek
+  plugin_cordis_bash["bash<br/>@deepseek-ai/dsh-bash-local"]
+  cfg --> plugin_cordis_bash
+  plugin_cordis_fs_local["fs-local<br/>@deepseek-ai/dsh-fs-local"]
+  cfg --> plugin_cordis_fs_local
+  plugin_cordis_web["web<br/>@deepseek-ai/dsh-web"]
+  cfg --> plugin_cordis_web
+  plugin_cordis_web_fetch_local["web-fetch-local<br/>@deepseek-ai/dsh-web-fetch-local"]
+  cfg --> plugin_cordis_web_fetch_local
+  plugin_cordis_stdio_agent["stdio-agent<br/>@deepseek-ai/dsh-stdio-agent"]
+  cfg --> plugin_cordis_stdio_agent
+  plugin_cordis_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-core"]
+  plugin_cordis_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"]
+  plugin_cordis_stdio_agent --> frontdoor_stdio["readline UI<br/>console logger<br/>pre-created main agent"]
+  bundle_agent_core --> spine_llm["ctx.llm"]
+  bundle_agent_core --> spine_sessions["ctx.sessions"]
+  bundle_agent_core --> spine_tools["ctx.tools + tool-bash"]
+  bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"]
+  plugin_cordis_tool_cordis["tool-cordis<br/>@deepseek-ai/dsh-tool-cordis"]
+  cfg --> plugin_cordis_tool_cordis
+```
+
+| Plugin id | Package / module |
+| --- | --- |
+| `hmr` | `@cordisjs/plugin-hmr` |
+| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` |
+| `bash` | `@deepseek-ai/dsh-bash-local` |
+| `fs-local` | `@deepseek-ai/dsh-fs-local` |
+| `web` | `@deepseek-ai/dsh-web` |
+| `web-fetch-local` | `@deepseek-ai/dsh-web-fetch-local` |
+| `stdio-agent` | `@deepseek-ai/dsh-stdio-agent` |
+| `tool-cordis` | `@deepseek-ai/dsh-tool-cordis` |
+
+Source config: [`examples/cordis-agent/cordis.yml`](cordis.yml).
+
+Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source.

+ 83 - 0
examples/cordis-agent/cordis.yml

@@ -0,0 +1,83 @@
+# The cordis-agent plugin tree: the SELF-REFERENTIAL harness demo. Same spine
+# as coding-agent (DeepSeek V4 + local bash on @deepseek-ai/dsh-stdio-agent),
+# plus @deepseek-ai/dsh-tool-cordis, which gives the model three tools over the
+# live cordis runtime it is running inside: cordis_inspect (services / plugin
+# tree / tools / dynamic mounts / api / events), cordis_mount (evaluate
+# model-written code in a vm sandbox and mount the returned plugin under the
+# `cordis-dynamic` group), and cordis_unmount (dispose one mount by id).
+# Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) — the
+# dsh-stdio-agent bin loads the gitignored repo-root .env first.
+#
+# Trust stance (docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md):
+# the mounted code gets the REAL ctx — the
+# vm sandbox only prevents accidental global pollution. Load the toolset as
+# deliberately as you would grant a bash tool.
+
+# Hot-module reload for the dev/demo loop (needs `node --expose-internals`).
+- id: hmr
+  name: '@cordisjs/plugin-hmr'
+  config:
+    root: ['.']
+
+# The DeepSeek adapter.
+- id: llm-deepseek
+  name: '@deepseek-ai/dsh-llm-deepseek'
+  config:
+    apiKey: !!js process.env.DEEPSEEK_API_KEY
+    baseURL: !!js process.env.DEEPSEEK_BASE_URL
+    models:
+      - deepseek-v4-pro
+      - deepseek-v4-flash
+
+# Local bash executor for agent-core's tool-bash schema — gives the agent an
+# ordinary tool whose calls make the mounted listeners observably fire.
+- id: bash
+  name: '@deepseek-ai/dsh-bash-local'
+  config:
+    timeoutMs: 60000
+
+# Filesystem service for mounted plugins (ctx.fs) — the local provider only.
+# The model-facing read/write/edit tools stay unmounted on purpose: this demo
+# is about the agent building its own tools over the services.
+- id: fs-local
+  name: '@deepseek-ai/dsh-fs-local'
+  config:
+    cwd: !!js process.cwd()
+
+# Web service for mounted plugins (ctx.web): the seam plus the anonymous local
+# fetch provider (keyless). No search provider is loaded — ctx.web search
+# calls fail loud until a deployment adds one.
+- id: web
+  name: '@deepseek-ai/dsh-web'
+
+- id: web-fetch-local
+  name: '@deepseek-ai/dsh-web-fetch-local'
+
+# The stdio chat app: the whole spine + front-door cluster, configured for the
+# self-referential demo driving a pre-created `main` agent.
+- id: stdio-agent
+  name: '@deepseek-ai/dsh-stdio-agent'
+  config:
+    model: deepseek-v4-flash
+    resumeSessionId: !!js process.env.RESUME_SESSION_ID
+    persistenceRoot: './.sessions'
+    welcome: 'cordis-agent ready. Ask it to inspect its runtime, mount a listener, or invent a tool for itself.'
+    persona: |
+      You are cordis-agent, a self-referential harness demo powered by the
+      {{model}} model.
+
+      You run INSIDE a cordis plugin runtime, and your cordis_* tools operate
+      on that live runtime: cordis_inspect to look around (its `api` and
+      `events` sections document the service methods, type shapes, and events
+      your plugin code can use), cordis_mount to add a plugin (an event
+      listener, a brand-new tool for yourself, or a service other mounts
+      inject), cordis_unmount to clean one up. In mounted code, NEVER use Node
+      built-ins (require/setTimeout/fetch) — use the runtime's cordis services
+      via inject: fs, web, bash, and timer (ctx.setTimeout). Prefer small
+      single-purpose plugins, prefer plain notification events over waterfall
+      events unless you intend to intercept, and unmount what you no longer
+      need. Report results briefly.
+
+# The self-referential cordis toolset (loaded after the app so ctx.tools exists).
+- id: tool-cordis
+  name: '@deepseek-ai/dsh-tool-cordis'

+ 7 - 0
examples/cordis-agent/package.json

@@ -0,0 +1,7 @@
+{
+  "name": "cordis-agent-example",
+  "private": true,
+  "version": "0.0.1",
+  "type": "module",
+  "description": "Runnable demo: the self-referential harness — an agent that inspects and modifies its own cordis runtime"
+}

+ 156 - 0
examples/cordis-agent/tests/cordis-tools.e2e.ts

@@ -0,0 +1,156 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { Context } from 'cordis'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import { AgentId } from '@deepseek-ai/dsh-agent'
+import { cordisHarness, waitForIdle } from './harness.ts'
+
+/**
+ * With-key smoke for the self-referential cordis tools: a REAL model drives
+ * cordis_mount/cordis_unmount against the live context the test observes.
+ * World-verified, not self-reported: the mounted listener must actually WRITE
+ * its tagged console line, the self-made tool must actually EXIST in the
+ * registry and appear as a real `tool/call`, the cross-mount service must
+ * actually LAND in the reflect store. Key-gated (see vitest.e2e.config.ts).
+ */
+
+let ctx: Context | undefined
+
+afterEach(async () => {
+  vi.restoreAllMocks()
+  // Always dispose the harness, even on failure/retry/timeout: agent-loop
+  // teardown stops the loop, and disposing the tree unwinds every dynamic
+  // mount the model left behind.
+  await ctx?.fiber.dispose()
+  ctx = undefined
+})
+
+/** The tagged write-through lines (`[cordis:dyn-n] …`) captured by a console spy. */
+function taggedCalls(log: { mock: { calls: unknown[][] } }): unknown[][] {
+  return log.mock.calls.filter(call => typeof call[0] === 'string' && /^\[cordis:dyn-\d+\]$/.test(call[0]))
+}
+
+/** Model-facing text of one tool result, concatenated. */
+function resultText(result: { content: { type: string; text?: string }[] }): string {
+  return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
+}
+
+describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modifies its own runtime', () => {
+  it('mounts a status listener whose tagged output actually fires, then unmounts it', async () => {
+    ctx = await cordisHarness()
+    const log = vi.spyOn(console, 'log').mockImplementation(() => {})
+    const agent = ctx.agentLoop.create(AgentId('cordis-e2e-listener'), { model: 'deepseek-v4-flash' })
+
+    agent.send([{
+      type: 'text',
+      text: 'Use cordis_mount to mount a plugin that listens to the \'agent/status\' '
+        + 'cordis event and logs every change with console.log. Reply "mounted" once done.',
+    }])
+    await waitForIdle(ctx, agent)
+
+    // The WORLD check: the turn's own running→idle transition must have driven
+    // the mounted listener through the tagged sandbox console.
+    expect(taggedCalls(log).length).toBeGreaterThan(0)
+    const mid = await ctx.tools.execute({
+      callId: CallId('verify-mounted'), name: 'cordis_inspect', arguments: { what: 'dynamic' },
+    })
+    expect(resultText(mid)).toContain('dyn-')
+
+    agent.send([{ type: 'text', text: 'Now unmount the plugin you just mounted.' }])
+    await waitForIdle(ctx, agent)
+
+    const after = await ctx.tools.execute({
+      callId: CallId('verify-unmounted'), name: 'cordis_inspect', arguments: { what: 'dynamic' },
+    })
+    expect(resultText(after)).toContain('(no dynamic plugins mounted)')
+  }, 120_000)
+
+  it('builds itself a reverse_text tool and actually calls it', async () => {
+    ctx = await cordisHarness()
+    const agent = ctx.agentLoop.create(AgentId('cordis-e2e-selftool'), { model: 'deepseek-v4-flash' })
+
+    agent.send([{
+      type: 'text',
+      text: 'Give yourself a new tool: use cordis_mount to mount a plugin with '
+        + 'inject ["tools"] that calls harness.registerTool(ctx, harness.defineTool({...})) '
+        + 'to register a tool named reverse_text with one required string parameter '
+        + '"text", returning the text reversed. Then CALL reverse_text with the '
+        + 'exact text "harness" and report its exact output.',
+    }])
+    await waitForIdle(ctx, agent)
+
+    // World checks: the tool exists in the registry, was invoked as a real
+    // tool call, and its RESULT (the self-made execute actually running) is the
+    // reversed string. The model's prose is not asserted — the tool result is
+    // the world; the summary sentence is just the self-report.
+    expect(ctx.tools.get('reverse_text')).toBeDefined()
+    const events = [...agent.session.events]
+    const calls = events.filter(event => event.type === 'tool/call')
+    expect(calls.some(event => event.data.name === 'cordis_mount')).toBe(true)
+    const reverseCalls = calls.filter(event => event.data.name === 'reverse_text')
+    expect(reverseCalls.length).toBeGreaterThan(0)
+    const reverseResults = events
+      .filter(event => event.type === 'tool/result')
+      .filter(event => reverseCalls.some(call => call.data.callId === event.data.callId))
+      .flatMap(event => event.data.content.filter(block => block.type === 'text').map(block => block.text))
+    // On failure, surface what the model actually mounted and what the tool
+    // returned — an e2e failing at a distance is undebuggable without it.
+    const mountCode = calls
+      .filter(event => event.data.name === 'cordis_mount')
+      .map(event => event.data.arguments)
+      .join('\n---\n')
+    const trace = events.map((event) => {
+      switch (event.type) {
+        case 'tool/call': return `tool/call:${event.data.name}`
+        case 'tool/result': return `tool/result:${event.data.isError ? 'ERR:' + JSON.stringify(event.data.content).slice(0, 200) : 'ok'}`
+        case 'turn/end': return `turn/end:${JSON.stringify(event.data.reason)}`
+        default: return event.type
+      }
+    }).join('\n')
+    expect(
+      reverseResults.some(text => text.includes('ssenrah')),
+      `no reversed output in reverse_text results.\nresults: ${JSON.stringify(reverseResults)}\nmount code: ${mountCode}\ntrace:\n${trace}`,
+    ).toBe(true)
+  }, 120_000)
+
+  it('composes two mounts through provide/inject, and unmounting the provider parks the consumer', async () => {
+    ctx = await cordisHarness()
+    const agent = ctx.agentLoop.create(AgentId('cordis-e2e-compose'), { model: 'deepseek-v4-flash' })
+
+    agent.send([{
+      type: 'text',
+      text: 'Mount TWO separate plugins with cordis_mount. First a provider: apply calls '
+        + 'ctx.provide(\'shouter\', { shout: (s) => s.toUpperCase() }). Second a consumer with '
+        + 'inject ["shouter", "tools"] that registers (via harness.registerTool + harness.defineTool) '
+        + 'a tool named shout_text with one required string parameter "text" whose execute returns '
+        + 'ctx.shouter.shout(args.text) as a text content block. Then CALL shout_text with "quiet" '
+        + 'and report the exact output.',
+    }])
+    await waitForIdle(ctx, agent)
+
+    // World checks: the service is really in the store, the tool really ran.
+    expect(ctx.get('shouter')).toBeDefined()
+    expect(ctx.tools.get('shout_text')).toBeDefined()
+    const events = [...agent.session.events]
+    const shoutCalls = events
+      .filter(event => event.type === 'tool/call')
+      .filter(event => event.data.name === 'shout_text')
+    expect(shoutCalls.length).toBeGreaterThan(0)
+    const shoutResults = events
+      .filter(event => event.type === 'tool/result')
+      .filter(event => shoutCalls.some(call => call.data.callId === event.data.callId))
+      .flatMap(event => event.data.content.filter(block => block.type === 'text').map(block => block.text))
+    expect(shoutResults.some(text => text.includes('QUIET'))).toBe(true)
+
+    agent.send([{ type: 'text', text: 'Now unmount ONLY the provider plugin (the one that provided shouter).' }])
+    await waitForIdle(ctx, agent)
+
+    // The consumer must have been parked by cordis itself: service gone,
+    // dependent tool unregistered, dynamic table naming the missing service.
+    expect(ctx.get('shouter')).toBeUndefined()
+    expect(ctx.tools.get('shout_text')).toBeUndefined()
+    const after = await ctx.tools.execute({
+      callId: CallId('verify-parked'), name: 'cordis_inspect', arguments: { what: 'dynamic' },
+    })
+    expect(resultText(after)).toContain('waiting for: shouter')
+  }, 120_000)
+})

+ 46 - 0
examples/cordis-agent/tests/harness.ts

@@ -0,0 +1,46 @@
+import { Context } from 'cordis'
+import LlmService from '@deepseek-ai/dsh-llm'
+import SessionStore from '@deepseek-ai/dsh-session'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
+import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
+import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
+
+/**
+ * Shared harness for the cordis-agent e2e suite: the agent spine with the real
+ * DeepSeek adapter and the real `@deepseek-ai/dsh-tool-cordis` plugin, so a
+ * live model can mount plugins into the very context the test observes. Lives
+ * outside the *.e2e.ts pattern so importing it never re-registers another
+ * file's tests.
+ */
+
+const PERSONA = 'You are cordis-agent, a self-referential harness demo. '
+  + 'Your cordis_* tools operate on the live cordis runtime you run inside: '
+  + 'cordis_inspect to look around, cordis_mount to add a plugin, cordis_unmount '
+  + 'to clean one up. Follow the tool descriptions exactly and report results briefly.'
+
+export async function cordisHarness(): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(LlmService)
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(SystemPrompt, { persona: PERSONA })
+  await ctx.plugin(ToolRegistry)
+  await ctx.plugin(AgentRegistry)
+  await ctx.plugin(AgentLoop, { agents: [] })
+  await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] })
+  await ctx.plugin(ToolCordis)
+  return ctx
+}
+
+export function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+  return new Promise((resolve) => {
+    const dispose = ctx.on('agent/status', (subject, status) => {
+      if (subject === agent && status === 'idle') {
+        dispose()
+        resolve()
+      }
+    })
+  })
+}

+ 94 - 0
examples/cordis-agent/tests/keyless-smoke.e2e.ts

@@ -0,0 +1,94 @@
+import { spawn, type ChildProcessWithoutNullStreams } 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'
+import { afterEach, describe, expect, it } from 'vitest'
+
+/**
+ * Keyless Loader-path smoke for examples/cordis-agent: boot the REAL example
+ * through the `@deepseek-ai/dsh-stdio-agent` bin against its `cordis.yml` —
+ * the cordis Loader, `unwrapExports`, the full plugin tree INCLUDING the
+ * `@deepseek-ai/dsh-tool-cordis` package resolved by name (whose `inject`
+ * would crash a collapsed export shape at load, see docs/postmortem/0001) —
+ * then close stdin with no prompt and assert the ready banner + a clean exit.
+ *
+ * No prompt is ever sent, so the model is NEVER called — that is why it runs
+ * without a real key: `llm-deepseek`'s apply() only requires a key to be
+ * PRESENT, and the absence of any prompt guarantees no network call. The
+ * with-key product proof lives in cordis-tools.e2e.ts.
+ */
+
+// The dsh-stdio-agent bin (the demo:cordis entry) and this example's cordis.yml.
+// The bin resolves its config-path arg from CWD; the test spawns from a temp
+// cwd, so we pass the example config's ABSOLUTE path.
+const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url))
+const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
+const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
+// Dev/test run UNBUILT: resolve `@deepseek-ai/dsh-*` through the root tsconfig
+// `paths` map; tsx searches UP from cwd, and we spawn from a temp dir outside
+// the repo, so point it at the repo tsconfig (root is three levels up).
+const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
+
+let child: ChildProcessWithoutNullStreams | undefined
+let workdir: string | undefined
+
+afterEach(async () => {
+  if (child !== undefined && child.exitCode === null) child.kill('SIGKILL')
+  child = undefined
+  if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
+  workdir = undefined
+})
+
+async function bootAndEof(): Promise<{ stdout: string; code: number }> {
+  workdir = await mkdtemp(join(tmpdir(), 'cordis-smoke-'))
+  const cwd = workdir
+  return new Promise((resolve, reject) => {
+    const proc = spawn(
+      process.execPath,
+      // --expose-internals: cordis.yml loads the HMR plugin (mirrors demo:cordis).
+      ['--expose-internals', '--import', tsxLoader, binScript, configPath],
+      {
+        cwd,
+        env: {
+          ...process.env,
+          TSX_TSCONFIG_PATH: repoTsconfig,
+          // A dummy key so llm-deepseek's apply() (key-PRESENT check only) boots.
+          // No prompt is sent, so the adapter never streams — no network call.
+          DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
+        },
+        stdio: ['pipe', 'pipe', 'pipe'],
+      },
+    )
+    child = proc
+    let stdout = ''
+    let stderr = ''
+    proc.stdout.setEncoding('utf8')
+    proc.stdout.on('data', (chunk: string) => { stdout += chunk })
+    proc.stderr.setEncoding('utf8')
+    proc.stderr.on('data', (chunk: string) => { stderr += chunk })
+
+    const timer = setTimeout(() => {
+      proc.kill('SIGKILL')
+      reject(new Error(`cordis-agent did not exit within 10s. stdout:\n${stdout}\nstderr:\n${stderr}`))
+    }, 10_000)
+
+    proc.on('exit', (code) => {
+      clearTimeout(timer)
+      if (code === 0) resolve({ stdout, code })
+      else reject(new Error(`cordis-agent exited ${code}. stderr:\n${stderr}`))
+    })
+    proc.on('error', (err) => { clearTimeout(timer); reject(err) })
+
+    // No prompt — just EOF, so the stdio UI exits without ever running a turn.
+    proc.stdin.end()
+  })
+}
+
+describe('cordis-agent keyless smoke (real cordis.yml via the Loader)', () => {
+  it('boots the full plugin tree incl. tool-cordis, prints its banner, and exits cleanly on EOF', async () => {
+    const { stdout, code } = await bootAndEof()
+    expect(code).toBe(0)
+    expect(stdout).toContain('cordis-agent ready.')
+  }, 15_000)
+})

+ 1 - 0
knip.json

@@ -8,6 +8,7 @@
         "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"
       ],

+ 4 - 1
package.json

@@ -47,6 +47,8 @@
     "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts",
     "gen-rfc-index": "tsx scripts/gen-rfc-index.ts",
     "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check",
+    "gen-cordis-api": "tsx scripts/gen-cordis-api.ts",
+    "verify-cordis-api": "tsx scripts/gen-cordis-api.ts --check",
     "verify-export-jsdoc": "tsx scripts/verify-export-jsdoc.ts",
     "gen-tool-catalog": "tsx scripts/gen-tool-catalog.ts",
     "verify-tool-catalog": "tsx scripts/gen-tool-catalog.ts --check",
@@ -59,10 +61,11 @@
     "gen-module-graph": "tsx scripts/gen-module-graph.ts",
     "verify-module-graph": "tsx scripts/gen-module-graph.ts --check",
     "constraints": "tsx scripts/check-workspace-constraints.ts",
-    "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets",
+    "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets",
     "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types",
     "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml",
     "demo:repl": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml",
+    "demo:cordis": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/cordis-agent/cordis.yml",
     "demo:acp": "node --import tsx packages/ui/acp-agent/src/bin.ts examples/acp-agent/cordis.yml",
     "postinstall": "node scripts/install-lefthook.mjs"
   },

+ 1 - 0
packages/README.md

@@ -19,6 +19,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
 | [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface |
 | [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool | Product — stable surface |
 | [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface |
+| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
 | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
 | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
 | [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, app packages, user-interaction seam, ask-user tool | Product — stable surface |

+ 7 - 0
packages/cordis/README.md

@@ -0,0 +1,7 @@
+# packages/cordis — the self-referential runtime toolset
+
+Model-facing tools over the live cordis runtime the agent itself runs inside: inspect the loaded plugins and service surface, mount model-written plugins, and dispose them again. Design home: [the toolset RFC](../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
+
+| Package | Role | ctx key |
+|---|---|---|
+| [`tool-cordis/`](tool-cordis/README.md) | The `cordis_inspect` / `cordis_mount` / `cordis_unmount` tools: read the runtime, evaluate model-written plugin code in a `node:vm` sandbox, and manage the dynamic mounts under one group fiber | registers on `ctx.tools` |

+ 33 - 0
packages/cordis/tool-cordis/README.md

@@ -0,0 +1,33 @@
+# @deepseek-ai/dsh-tool-cordis
+
+The self-referential cordis toolset: three model-facing tools over the live runtime the agent runs inside. Design home — sandbox semantics, mount lifecycle, cross-mount composition, the generated API catalog, standing decisions: [the toolset RFC](../../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
+
+## What it does
+
+- `cordis_inspect` — read-only report over the runtime: services, the loaded-plugin list, registered tools, the dynamic-mount table, and the catalog-backed `api` / `events` references.
+- `cordis_mount` — evaluates model-written JavaScript (the body of an async function) in a `node:vm` sandbox; the code must `return` a cordis plugin, which is mounted under the `cordis-dynamic` group fiber and tracked as `dyn-<n>`.
+- `cordis_unmount` — disposes one mount by id, returning only after quiescence.
+
+Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-catalog.md).
+
+## Trust stance
+
+The sandbox isolates the global context only — it is not a security boundary. No Node API is provided: `require`, the timers, and `fetch` are callable traps that throw a redirect to the cordis alternative (`ctx.fs` / `ctx.web` / `ctx.bash` / `inject: ['timer']` + `ctx.setTimeout`); `process` and `Buffer` are `undefined`; `globalThis` writes stay inside. These traps steer honest code onto the cordis services; they do not contain a mount that goes looking — the host-realm helpers on the sandbox global (`harness`, `console`, `btoa`) are reachable functions, so mount code can reach the host realm and Node through one of them, which is fine because `ctx` is fully privileged anyway. The `ctx` a mounted plugin's `apply` receives is a whitelist façade — register tools, observe events, provide/consume services, use timers; framework internals (`ctx.root`, `ctx.fiber`, `ctx.extend`, `ctx.plugin`, …) are withheld — but the capabilities it does expose reach the real runtime, so load this plugin as deliberately as you would grant a bash tool.
+
+## Config
+
+| Field | Default | Meaning |
+|---|---|---|
+| `vmTimeoutMs` | `5000` | Bound on the SYNCHRONOUS portion of mount-code evaluation; an async body escapes it |
+
+## The generated API catalog
+
+`src/api-catalog.ts` is generated by `scripts/gen-cordis-api.ts` from the same AST walk as [docs/cordis-catalog](../../../docs/cordis-catalog/services.md) and freshness-gated by `pnpm run verify-cordis-api` (in `doc-sync`) — never edit it by hand. `cordis_inspect` intersects it with the live service store at call time.
+
+## Rendering
+
+All three tools render `generic` cards (`read` / `execute` / `delete`); `cordis_mount` carries the mount code as `rawInput`. Presenters are pure functions of the args; results keep the default text rendering.
+
+## Export shape
+
+Namespace plugin: named exports `name` / `inject` / `Config` / `apply`, no default export ([docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)).

+ 42 - 0
packages/cordis/tool-cordis/package.json

@@ -0,0 +1,42 @@
+{
+  "name": "@deepseek-ai/dsh-tool-cordis",
+  "description": "Self-referential cordis toolset: inspect the live runtime, mount and dispose model-written plugins",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-tools": "^0.0.1",
+    "cordis": "^4.0.0-rc.6"
+  },
+  "dependencies": {
+    "schemastery": "^3.18.0"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-agent-loop": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-system-prompt": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "@cordisjs/plugin-loader": "^1.0.0-rc.4",
+    "cordis": "^4.0.0-rc.6",
+    "@cordisjs/plugin-timer": "workspace:^"
+  }
+}

+ 849 - 0
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -0,0 +1,849 @@
+/**
+ * Generated by scripts/gen-cordis-api.ts — do not edit by hand; run
+ * `pnpm run gen-cordis-api` to regenerate (freshness-gated by
+ * `pnpm run verify-cordis-api` in doc-sync).
+ *
+ * The machine-readable cordis API catalog `cordis_inspect` serves to the
+ * model: harness services (summary + public method signatures), harness
+ * events (mode + signature), and the inherited `ctx` surface. Produced by
+ * the same AST walk as docs/cordis-catalog, so this data and the rendered
+ * docs cannot diverge.
+ *
+ * @module @deepseek-ai/dsh-tool-cordis/api-catalog
+ */
+
+/** One harness `ctx.<key>` service: its one-line summary and public method signatures. */
+export interface ServiceApiEntry {
+  /** The `ctx.<key>` name, e.g. `tools`. */
+  key: string
+  /** First sentence of the service class JSDoc. */
+  summary: string
+  /** Public method signatures, bodies stripped, in source order. */
+  methods: readonly string[]
+}
+
+/** One harness event: its dispatch mode, exact signature, and one-line summary. */
+export interface EventApiEntry {
+  /** The scoped event name, e.g. `agent/status`. */
+  name: string
+  /** The dispatch mode from the declaration's `@mode` tag. */
+  mode: string
+  /** The exact listener signature, whitespace-normalized. */
+  signature: string
+  /** First sentence of the event JSDoc. */
+  summary: string
+}
+
+/** One inherited (cordis core + loader/hmr/timer) `ctx` member group with its summary. */
+export interface InheritedApiEntry {
+  /** The `ctx` member name(s), e.g. `ctx.on / ctx.once`. */
+  name: string
+  /** One-line summary of what the member does. */
+  summary: string
+}
+
+/** One named type shape the service signatures reference. */
+export interface TypeApiEntry {
+  /** The exported type/interface name, e.g. `BashRunResult`. */
+  name: string
+  /** The full declaration text, comments stripped. */
+  declaration: string
+}
+
+/** Every harness `ctx.<key>` service, sorted by key. */
+export const SERVICE_API: readonly ServiceApiEntry[] = [
+  {
+    key: 'agentLoop',
+    summary: 'The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loops, and registers them in `ctx.agents`.',
+    methods: [
+      'create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent',
+      'createAgent(options: CreateAgentOptions): AgentHandle',
+      'async resume(options: ResumeAgentOptions): Promise<AgentHandle>',
+    ],
+  },
+  {
+    key: 'agents',
+    summary: 'Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package.',
+    methods: [
+      'setFactory(factory: AgentFactory): () => void',
+      'create(options: CreateAgentOptions): AgentHandle',
+      'async resume(options: ResumeAgentOptions): Promise<AgentHandle>',
+      'register(agent: Agent): () => void',
+      'get(id: AgentId): Agent | undefined',
+      'list(): Agent[]',
+    ],
+  },
+  {
+    key: 'bash',
+    summary: 'Abstract bash execution service.',
+    methods: [
+      'abstract resolve(request: BashExecRequest): BashExecSpec',
+      'abstract run(spec: BashExecSpec): Promise<BashRunResult>',
+      'abstract start(spec: BashExecSpec): BashTask',
+      'abstract get(id: BashTaskId): BashTask | undefined',
+      'abstract ownerOf(id: BashTaskId): OwnerToken | undefined',
+      'abstract list(): BashTask[]',
+      'abstract readOutput(id: BashTaskId): BashTaskRead',
+      'abstract kill(id: BashTaskId): boolean',
+      'onTaskDone(listener: BashTaskListener): () => void',
+    ],
+  },
+  {
+    key: 'codeRuntime',
+    summary: 'Abstract code-execution service.',
+    methods: [
+      'abstract run(request: CodeRunRequest): Promise<CodeRunResult>',
+    ],
+  },
+  {
+    key: 'compact',
+    summary: 'Abstract compaction service.',
+    methods: [
+      'abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, signal: AbortSignal, ): Promise<CompactionResult | null>',
+      'abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise<CompactionResult>',
+    ],
+  },
+  {
+    key: 'fs',
+    summary: 'Abstract filesystem provider service.',
+    methods: [
+      'abstract resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget>',
+      'abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>',
+      'abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>',
+      'abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>',
+      'abstract listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]>',
+      'abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>',
+      'abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>',
+    ],
+  },
+  {
+    key: 'llm',
+    summary: 'The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall.',
+    methods: [
+      'registerAdapter(models: string[], adapter: LlmAdapter): () => void',
+      'models(): string[]',
+      'stream(options: GenerateOptions): AsyncIterable<StreamChunk>',
+    ],
+  },
+  {
+    key: 'sessionPersistence',
+    summary: 'Abstract durable session-persistence service.',
+    methods: [
+      'abstract create(meta: SessionHeader): Promise<void>',
+      'abstract append(id: SessionId, events: readonly SessionEvent[]): Promise<void>',
+      'abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>',
+      'abstract list(): Promise<SessionHeader[]>',
+    ],
+  },
+  {
+    key: 'sessions',
+    summary: 'In-memory session store (`ctx.sessions`).',
+    methods: [
+      'create(id?: SessionId, options?: CreateSessionOptions): Session',
+      'prepare(id?: SessionId, options?: CreateSessionOptions): Session',
+      'enter(session: Session): () => void',
+      'announce(session: Session): void',
+      'get(id: SessionId): Session | undefined',
+      'list(): Session[]',
+      'fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session',
+    ],
+  },
+  {
+    key: 'subagents',
+    summary: 'The `subagents` service: a registry of named SubagentProviders and a capability-checked start surface.',
+    methods: [
+      'registerProvider(provider: SubagentProvider): () => void',
+      'getProvider(name: string): SubagentProvider | undefined',
+      'list(): string[]',
+      'start(name: string, request: SubagentStartRequest): SubagentRun',
+    ],
+  },
+  {
+    key: 'systemPrompt',
+    summary: 'Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, and named prompt variables; the agent loop calls `assemble(context)` once per step.',
+    methods: [
+      'section(section: PromptSection): () => void',
+      'tools(provider: () => ToolSchema[]): () => void',
+      'variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void',
+      'async assemble(context: AssembleContext = {}): Promise<PromptAssembly>',
+    ],
+  },
+  {
+    key: 'tools',
+    summary: 'Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → `tools/execute` → `tools/post-execute` pipeline.',
+    methods: [
+      'register(definition: ToolDefinition): () => void',
+      'get(name: string): ToolDefinition | undefined',
+      'schemas(): ToolSchema[]',
+      'async execute(exec: ToolExecution): Promise<ToolExecutionResult>',
+    ],
+  },
+  {
+    key: 'userInteraction',
+    summary: '`ctx.userInteraction`: one active UI provider plus an `ask()` surface.',
+    methods: [
+      'registerProvider(provider: UserInteractionProvider): () => void',
+      'async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>',
+    ],
+  },
+  {
+    key: 'web',
+    summary: 'The web access service.',
+    methods: [
+      'registerSearchProvider(provider: WebSearchProvider): () => void',
+      'registerFetchProvider(provider: WebFetchProvider): () => void',
+      'async search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>',
+      'async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>',
+    ],
+  },
+]
+
+/** Every harness event, sorted by name. */
+export const EVENT_API: readonly EventApiEntry[] = [
+  {
+    name: 'agent/created',
+    mode: 'emit',
+    signature: '\'agent/created\'(agent: Agent): void',
+    summary: 'An agent was registered in the AgentRegistry and is ready to receive messages.',
+  },
+  {
+    name: 'agent/disposed',
+    mode: 'emit',
+    signature: '\'agent/disposed\'(agent: Agent): void',
+    summary: 'An agent was disposed and removed from the registry; its fiber and any in-flight turn have been torn down.',
+  },
+  {
+    name: 'agent/error',
+    mode: 'emit',
+    signature: '\'agent/error\'(agent: Agent, turn: number, step: number, error: Error): void',
+    summary: 'A step or turn errored.',
+  },
+  {
+    name: 'agent/pre-step',
+    mode: 'serial',
+    signature: '\'agent/pre-step\'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise<void> | void',
+    summary: 'Awaited pre-step surface-mutation checkpoint, fired once per step AFTER `turn/start` (and after the prior step closed) but BEFORE this step\'s `step/start` — so anything a listener appends lands OUTSIDE the step, between `turn/start`/`step/end` and the upcoming `step/start`.',
+  },
+  {
+    name: 'agent/prompt-submit',
+    mode: 'waterfall',
+    signature: '\'agent/prompt-submit\'(agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>',
+    summary: 'Waterfall: decide what happens to ONE drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContext`) or block it.',
+  },
+  {
+    name: 'agent/queued',
+    mode: 'emit',
+    signature: '\'agent/queued\'(agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void',
+    summary: 'A message entered the agent\'s inbox (queued or steering).',
+  },
+  {
+    name: 'agent/request',
+    mode: 'waterfall',
+    signature: '\'agent/request\'(agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>',
+    summary: 'Waterfall: shape the step\'s call configuration — model switching, sampling overrides — by returning a replacement LlmCallConfig (the frozen seed is the config the loop would otherwise use).',
+  },
+  {
+    name: 'agent/session-start',
+    mode: 'emit',
+    signature: '\'agent/session-start\'(agent: Agent, source: SessionStartSource): void',
+    summary: 'The agent\'s session lifecycle began, fired once before its first turn.',
+  },
+  {
+    name: 'agent/status',
+    mode: 'emit',
+    signature: '\'agent/status\'(agent: Agent, status: AgentStatus): void',
+    summary: 'Agent status changed (`idle` ⇄ `running`, or → `disposed`).',
+  },
+  {
+    name: 'agent/step-result',
+    mode: 'waterfall',
+    signature: '\'agent/step-result\'(agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message>',
+    summary: 'Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …).',
+  },
+  {
+    name: 'agent/turn-continuation',
+    mode: 'waterfall',
+    signature: '\'agent/turn-continuation\'(agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>',
+    summary: 'Waterfall: override the turn-continuation decision via a typed ContinuationDecision.',
+  },
+  {
+    name: 'fs/edit-intent',
+    mode: 'waterfall',
+    signature: '\'fs/edit-intent\'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>',
+    summary: 'Single-slot decision: produce the optional version guard for the next FileSystem.editText.',
+  },
+  {
+    name: 'fs/observed',
+    mode: 'emit',
+    signature: '\'fs/observed\'(target: FsTarget, version: FsVersion, actor: object | undefined): void',
+    summary: 'Record that an actor observed a target at a version, after a successful read/write/edit.',
+  },
+  {
+    name: 'fs/write-intent',
+    mode: 'waterfall',
+    signature: '\'fs/write-intent\'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>',
+    summary: 'Single-slot decision: produce the write intent for the next FileSystem.writeText.',
+  },
+  {
+    name: 'llm/stream',
+    mode: 'waterfall',
+    signature: '\'llm/stream\'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>',
+    summary: 'Waterfall around every streaming model call (retry, replay, routing).',
+  },
+  {
+    name: 'session/created',
+    mode: 'emit',
+    signature: '\'session/created\'(session: Session): void',
+    summary: 'A session was created in the store.',
+  },
+  {
+    name: 'session/event',
+    mode: 'emit',
+    signature: '\'session/event\'(session: Session, event: SessionEvent): void',
+    summary: 'An event was appended to a session log (sync, fire-and-forget).',
+  },
+  {
+    name: 'session/flush',
+    mode: 'parallel',
+    signature: '\'session/flush\'(session: Session): Promise<void> | void',
+    summary: 'Awaited durability checkpoint.',
+  },
+  {
+    name: 'subagent/end',
+    mode: 'emit',
+    signature: '\'subagent/end\'(info: SubagentRunEndInfo): void',
+    summary: 'A subagent run settled — emitted when SubagentRun.result resolves (any stop reason).',
+  },
+  {
+    name: 'subagent/provider-added',
+    mode: 'emit',
+    signature: '\'subagent/provider-added\'(provider: SubagentProvider): void',
+    summary: 'A provider became resolvable in the SubagentService registry.',
+  },
+  {
+    name: 'subagent/provider-removed',
+    mode: 'emit',
+    signature: '\'subagent/provider-removed\'(name: string): void',
+    summary: 'A provider left the registry (its plugin\'s fiber was disposed — an unload or an HMR reload).',
+  },
+  {
+    name: 'subagent/start',
+    mode: 'emit',
+    signature: '\'subagent/start\'(info: SubagentRunInfo): void',
+    summary: 'A subagent run started — emitted after the provider is resolved and its capabilities validated, as the child run begins.',
+  },
+  {
+    name: 'system-prompt/assemble',
+    mode: 'waterfall',
+    signature: '\'system-prompt/assemble\'(this: SystemPrompt, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>',
+    summary: 'Waterfall around prompt assembly — mutate or extend the PromptAssembly (sections + tools + variables) before it is rendered.',
+  },
+  {
+    name: 'system-prompt/change',
+    mode: 'emit',
+    signature: '\'system-prompt/change\'(): void',
+    summary: 'A section, tool provider, or variable provider was registered or unregistered (the assembly inputs changed).',
+  },
+  {
+    name: 'tools/change',
+    mode: 'emit',
+    signature: '\'tools/change\'(): void',
+    summary: 'A tool was registered or unregistered (the available tool set changed).',
+  },
+  {
+    name: 'tools/execute',
+    mode: 'waterfall',
+    signature: '\'tools/execute\'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>',
+    summary: 'Around-dispatch waterfall wrapping the registry\'s core tool dispatch, between the `tools/pre-execute` gate and the `tools/post-execute` seam.',
+  },
+  {
+    name: 'tools/post-execute',
+    mode: 'waterfall',
+    signature: '\'tools/post-execute\'(this: ToolRegistry, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>',
+    summary: 'Waterfall AFTER a tool runs — where hook plugins inspect the result and accept it (optionally REPLACING the model-facing content, and/or attaching `additionalContext` for the next request) or block it with corrective `feedback` (Claude Code\'s `PostToolUse`).',
+  },
+  {
+    name: 'tools/pre-execute',
+    mode: 'waterfall',
+    signature: '\'tools/pre-execute\'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>',
+    summary: 'Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code\'s `PreToolUse`).',
+  },
+]
+
+/** Shapes of every exported type the SERVICE_API signatures reference (transitively), sorted by name. */
+export const TYPE_API: readonly TypeApiEntry[] = [
+  {
+    name: 'Agent',
+    declaration: 'export interface Agent {\n    readonly id: AgentId;\n    readonly options: AgentOptions;\n    readonly session: Session;\n    readonly status: AgentStatus;\n    send(content: ContentBlock[], options?: SendOptions): void;\n    steer(content: ContentBlock[], options?: SendOptions): void;\n    inject(content: ContentBlock[], options?: SendOptions): void;\n    cancel(reason?: string): void;\n    whenIdle(): Promise<void>;\n}',
+  },
+  {
+    name: 'AgentFactory',
+    declaration: 'export interface AgentFactory {\n    createAgent(options: CreateAgentOptions): AgentHandle;\n    resume(options: ResumeAgentOptions): Promise<AgentHandle>;\n}',
+  },
+  {
+    name: 'AgentHandle',
+    declaration: 'export interface AgentHandle {\n    agent: Agent;\n    dispose(): Promise<void>;\n}',
+  },
+  {
+    name: 'AgentId',
+    declaration: 'export type AgentId = Branded<\'AgentId\'>;',
+  },
+  {
+    name: 'AgentOptions',
+    declaration: 'export interface AgentOptions {\n    model?: string;\n}',
+  },
+  {
+    name: 'AgentStatus',
+    declaration: 'export type AgentStatus = \'idle\' | \'running\' | \'disposed\';',
+  },
+  {
+    name: 'AskUserQuestionAnswer',
+    declaration: 'export interface AskUserQuestionAnswer {\n    answers: AskUserQuestionAnswerItem[];\n}',
+  },
+  {
+    name: 'AskUserQuestionAnswerItem',
+    declaration: 'export interface AskUserQuestionAnswerItem {\n    id: string;\n    selected: string[];\n    custom?: string;\n}',
+  },
+  {
+    name: 'AskUserQuestionItem',
+    declaration: 'export interface AskUserQuestionItem {\n    id: string;\n    question: string;\n    header?: string;\n    options?: AskUserQuestionOption[];\n    multiSelect?: boolean;\n}',
+  },
+  {
+    name: 'AskUserQuestionOption',
+    declaration: 'export interface AskUserQuestionOption {\n    label: string;\n    description?: string;\n}',
+  },
+  {
+    name: 'AskUserQuestionRequest',
+    declaration: 'export interface AskUserQuestionRequest {\n    questions: AskUserQuestionItem[];\n    agent?: Agent;\n    signal?: AbortSignal;\n}',
+  },
+  {
+    name: 'AssembleContext',
+    declaration: 'export interface AssembleContext {\n}',
+  },
+  {
+    name: 'AssembledSection',
+    declaration: 'export interface AssembledSection {\n    name: string;\n    order: number;\n    text: string;\n}',
+  },
+  {
+    name: 'BashExecRequest',
+    declaration: 'export interface BashExecRequest {\n    command: string;\n    workdir?: string | undefined;\n    timeoutMs?: number | undefined;\n    signal?: AbortSignal | undefined;\n    stdin?: string | undefined;\n    env?: Record<string, string> | undefined;\n    owner?: OwnerToken | undefined;\n}',
+  },
+  {
+    name: 'BashExecSpec',
+    declaration: 'export interface BashExecSpec {\n    command: string;\n    workdir: string;\n    timeoutMs: number;\n    signal?: AbortSignal | undefined;\n    stdin?: string | undefined;\n    env?: Record<string, string> | undefined;\n    owner: OwnerToken | undefined;\n}',
+  },
+  {
+    name: 'BashRunResult',
+    declaration: 'export interface BashRunResult {\n    exitCode: number | null;\n    signal: NodeJS.Signals | null;\n    timedOut: boolean;\n    aborted: boolean;\n    timeoutMs: number;\n    stdout: CollectedOutput;\n    stderr: CollectedOutput;\n}',
+  },
+  {
+    name: 'BashTask',
+    declaration: 'export interface BashTask {\n    readonly id: BashTaskId;\n    readonly command: string;\n    status: BashTaskStatus;\n    exitCode: number | null;\n    signal: NodeJS.Signals | null;\n    readonly done: Promise<void>;\n}',
+  },
+  {
+    name: 'BashTaskId',
+    declaration: 'export type BashTaskId = Branded<\'BashTaskId\'>;',
+  },
+  {
+    name: 'BashTaskListener',
+    declaration: 'export type BashTaskListener = (task: BashTask) => void;',
+  },
+  {
+    name: 'BashTaskRead',
+    declaration: 'export interface BashTaskRead {\n    task: BashTask;\n    delta: string;\n    lossy: boolean;\n    stdoutSpillPath?: string;\n    stderrSpillPath?: string;\n}',
+  },
+  {
+    name: 'BashTaskStatus',
+    declaration: 'export type BashTaskStatus = \'running\' | \'completed\' | \'killed\';',
+  },
+  {
+    name: 'Branded',
+    declaration: 'export type Branded<B extends string> = string & {\n    readonly [BRAND]: B;\n};',
+  },
+  {
+    name: 'CallId',
+    declaration: 'export type CallId = Branded<\'CallId\'>;',
+  },
+  {
+    name: 'CodeBindingFunction',
+    declaration: 'export type CodeBindingFunction = (args: unknown) => Promise<unknown>;',
+  },
+  {
+    name: 'CodeBindingNamespace',
+    declaration: 'export interface CodeBindingNamespace {\n    global: string;\n    functions: Record<string, CodeBindingFunction>;\n}',
+  },
+  {
+    name: 'CodeLogEntry',
+    declaration: 'export interface CodeLogEntry {\n    source: \'console\' | \'stdout\' | \'stderr\';\n    level?: \'log\' | \'info\' | \'warn\' | \'error\' | \'debug\';\n    text: string;\n}',
+  },
+  {
+    name: 'CodeRunFailure',
+    declaration: 'export interface CodeRunFailure {\n    kind: \'exception\' | \'timeout\' | \'abort\' | \'worker-exit\';\n    message: string;\n}',
+  },
+  {
+    name: 'CodeRunRequest',
+    declaration: 'export interface CodeRunRequest {\n    program: string;\n    bindings: CodeBindingNamespace[];\n    signal?: AbortSignal;\n}',
+  },
+  {
+    name: 'CodeRunResult',
+    declaration: 'export interface CodeRunResult {\n    value?: unknown;\n    logs: CodeLogEntry[];\n    error?: CodeRunFailure;\n}',
+  },
+  {
+    name: 'CollectedOutput',
+    declaration: 'export interface CollectedOutput {\n    text: string;\n    truncated: boolean;\n    spillPath?: string;\n}',
+  },
+  {
+    name: 'CompactAgentContext',
+    declaration: 'export interface CompactAgentContext {\n    session: Session;\n    options: {\n        model?: string;\n    };\n}',
+  },
+  {
+    name: 'CompactionResult',
+    declaration: 'export interface CompactionResult {\n    startSeq: number;\n    summarySeq: number;\n    endSeq: number;\n    summary: ContentBlock[];\n    shadowedRange: {\n        start: number;\n        end: number;\n    };\n    shadowedSeqs: number[];\n    shadowedTokenCount: number;\n}',
+  },
+  {
+    name: 'ContentBlockMap',
+    declaration: 'export interface ContentBlockMap {\n    \'text\': TextBlock;\n    \'reasoning\': ReasoningBlock;\n    \'tool-call\': ToolCallBlock;\n    \'tool-result\': ToolResultBlock;\n}',
+  },
+  {
+    name: 'ContentBlockType',
+    declaration: 'export type ContentBlockType = keyof ContentBlockMap;',
+  },
+  {
+    name: 'CreateAgentOptions',
+    declaration: 'export interface CreateAgentOptions {\n    agentId: AgentId;\n    sessionId: SessionId;\n    meta?: {\n        cwd?: string;\n        parentSession?: SessionId;\n        seedLength?: number;\n    };\n    seed?: SessionEvent[];\n    agentOptions?: AgentOptions;\n}',
+  },
+  {
+    name: 'CreateSessionOptions',
+    declaration: 'export interface CreateSessionOptions {\n    seed?: SessionEvent[];\n    meta?: {\n        cwd?: string;\n        parentSession?: SessionId;\n        createdAt?: number;\n        seedLength?: number;\n    };\n}',
+  },
+  {
+    name: 'DiffCallView',
+    declaration: 'export interface DiffCallView {\n    card: \'diff\';\n    title: string;\n    diffs: FileDiff[];\n    locations?: FileLocation[];\n}',
+  },
+  {
+    name: 'DiffResultView',
+    declaration: 'export interface DiffResultView {\n    card: \'diff\';\n    title?: string;\n    diffs: FileDiff[];\n}',
+  },
+  {
+    name: 'FileDiff',
+    declaration: 'export interface FileDiff {\n    path: string;\n    oldText: string | null;\n    newText: string;\n}',
+  },
+  {
+    name: 'FileLocation',
+    declaration: 'export interface FileLocation {\n    path: string;\n    line?: number;\n}',
+  },
+  {
+    name: 'FinishReason',
+    declaration: 'export type FinishReason = FinishReasonMap[keyof FinishReasonMap];',
+  },
+  {
+    name: 'FinishReasonMap',
+    declaration: 'export interface FinishReasonMap {\n    \'stop\': {\n        kind: \'stop\';\n    };\n    \'tool-calls\': {\n        kind: \'tool-calls\';\n    };\n    \'max-tokens\': {\n        kind: \'max-tokens\';\n    };\n    \'aborted\': {\n        kind: \'aborted\';\n    };\n    \'error\': {\n        kind: \'error\';\n        message: string;\n        code?: string;\n    };\n}',
+  },
+  {
+    name: 'FsDirEntry',
+    declaration: 'export interface FsDirEntry {\n    name: string;\n    type: \'file\' | \'directory\' | \'other\';\n    target: FsTarget;\n    version?: FsVersion;\n    size?: number;\n}',
+  },
+  {
+    name: 'FsEditOutcome',
+    declaration: 'export interface FsEditOutcome {\n    version: FsVersion;\n    before: string;\n    after: string;\n}',
+  },
+  {
+    name: 'FsEditRequest',
+    declaration: 'export interface FsEditRequest {\n    oldString: string;\n    newString: string;\n    replaceAll: boolean;\n}',
+  },
+  {
+    name: 'FsInfo',
+    declaration: 'export interface FsInfo {\n    version: FsVersion;\n    type: \'file\' | \'directory\' | \'other\';\n    size?: number;\n}',
+  },
+  {
+    name: 'FsTarget',
+    declaration: 'export interface FsTarget {\n    targetKey: FsTargetKey;\n    displayPath: string;\n}',
+  },
+  {
+    name: 'FsTargetKey',
+    declaration: 'export type FsTargetKey = Branded<\'FsTargetKey\'>;',
+  },
+  {
+    name: 'FsVersion',
+    declaration: 'export type FsVersion = Branded<\'FsVersion\'>;',
+  },
+  {
+    name: 'FsWriteIntent',
+    declaration: 'export type FsWriteIntent = {\n    kind: \'createIfAbsent\';\n} | {\n    kind: \'replaceIfVersion\';\n    version: FsVersion;\n};',
+  },
+  {
+    name: 'FsWriteOutcome',
+    declaration: 'export interface FsWriteOutcome {\n    operation: \'create\' | \'update\';\n    version: FsVersion;\n    before: string | null;\n    after: string;\n}',
+  },
+  {
+    name: 'GenerateOptions',
+    declaration: 'export interface GenerateOptions {\n    model: string;\n    messages: Message[];\n    system?: string;\n    tools?: ToolSchema[];\n    temperature?: number;\n    maxTokens?: number;\n    stop?: string[];\n    signal?: AbortSignal;\n    sessionId?: Branded<\'SessionId\'>;\n}',
+  },
+  {
+    name: 'GenericCallView',
+    declaration: 'export interface GenericCallView {\n    card: \'generic\';\n    title: string;\n    kind?: ToolCallKind;\n    rawInput?: unknown;\n    content?: ContentBlock[];\n    locations?: FileLocation[];\n}',
+  },
+  {
+    name: 'GenericResultView',
+    declaration: 'export interface GenericResultView {\n    card: \'generic\';\n    title?: string;\n    content?: ContentBlock[];\n}',
+  },
+  {
+    name: 'HookContext',
+    declaration: 'export interface HookContext {\n    content: ContentBlock[];\n    source: MessageSource;\n}',
+  },
+  {
+    name: 'Message',
+    declaration: 'export interface Message {\n    role: \'system\' | \'user\' | \'assistant\';\n    content: ContentBlock[];\n}',
+  },
+  {
+    name: 'MessageSource',
+    declaration: 'export type MessageSource = MessageSourceMap[keyof MessageSourceMap];',
+  },
+  {
+    name: 'MessageSourceMap',
+    declaration: 'export interface MessageSourceMap {\n    user: {\n        kind: \'user\';\n    };\n    plugin: {\n        kind: \'plugin\';\n        plugin: string;\n    };\n}',
+  },
+  {
+    name: 'OwnerToken',
+    declaration: 'export type OwnerToken = Branded<\'OwnerToken\'>;',
+  },
+  {
+    name: 'PromptAssembly',
+    declaration: 'export interface PromptAssembly {\n    sections: AssembledSection[];\n    tools: ToolSchema[];\n    variables: Record<string, string | undefined>;\n}',
+  },
+  {
+    name: 'PromptSection',
+    declaration: 'export interface PromptSection {\n    name: string;\n    order: number;\n    text: string | ((context: AssembleContext) => string);\n}',
+  },
+  {
+    name: 'ReasoningBlock',
+    declaration: 'export interface ReasoningBlock {\n    type: \'reasoning\';\n    text: string;\n}',
+  },
+  {
+    name: 'ResumeAgentOptions',
+    declaration: 'export interface ResumeAgentOptions {\n    agentId: AgentId;\n    resumeSessionId: SessionId;\n    agentOptions?: AgentOptions;\n}',
+  },
+  {
+    name: 'SendOptions',
+    declaration: 'export interface SendOptions {\n    source?: MessageSource;\n}',
+  },
+  {
+    name: 'SessionEvent',
+    declaration: 'export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n    [K in SessionEventType]: {\n        type: K;\n        seq: number;\n        time: number;\n        data: SessionEventMap[K];\n    } & (K extends SurfaceEventType ? {\n        sourceEventSeqs?: number[];\n        surfaceOp?: SurfaceOp;\n    } : object);\n}[T];',
+  },
+  {
+    name: 'SessionEventMap',
+    declaration: 'export interface SessionEventMap {\n    \'turn/start\': {\n        turn: number;\n        trigger: TurnTrigger;\n    };\n    \'turn/end\': {\n        turn: number;\n        reason: TurnEndReason;\n    };\n    \'step/start\': {\n        turn: number;\n        step: number;\n    };\n    \'step/end\': {\n        turn: number;\n        step: number;\n    };\n    \'user/message\': {\n        content: ContentBlock[];\n        source: MessageSource;\n    };\n    \'prompt/blocked\': {\n        content: ContentBlock[];\n        source: MessageSource;\n        reason: string;\n    };\n    \'context/message\': {\n        content: ContentBlock[];\n        source: MessageSource;\n    };\n    \'assistant/chunk\': {\n        turn: number;\n        step: number;\n        chunk: StreamChunk;\n    };\n    \'assistant/message\': {\n        turn: number;\n        step: number;\n        content: ContentBlock[];\n        usage?: TokenUsage;\n    };\n    \'tool/call\': {\n        turn: number;\n        step: number;\n        callId: CallId;\n        name: string;\n        arguments: string;\n    };\n    \'tool/result\': {\n        turn: number;\n        step: number;\n        callId: CallId;\n        content: ContentBlock[];\n        isError: boolean;\n        error?: {\n            name: string;\n            code: string;\n        };\n        meta?: unknown;\n    };\n    \'steering/message\': {\n        turn: number;\n        content: ContentBlock[];\n        source: MessageSource;\n    };\n    \'todo/write\': {\n        todos: TodoItem[];\n    };\n    \'request/header\': {\n        header: E /* …truncated — full shape in source */',
+  },
+  {
+    name: 'SessionEventType',
+    declaration: 'export type SessionEventType = keyof SessionEventMap;',
+  },
+  {
+    name: 'SessionForkSource',
+    declaration: 'export type SessionForkSource = Session | SessionId;',
+  },
+  {
+    name: 'SessionHeader',
+    declaration: 'export interface SessionHeader {\n    version: number;\n    id: SessionId;\n    createdAt: number;\n    cwd?: string;\n    parentSession?: SessionId;\n    seedLength?: number;\n}',
+  },
+  {
+    name: 'SessionId',
+    declaration: 'export type SessionId = Branded<\'SessionId\'>;',
+  },
+  {
+    name: 'StreamChunk',
+    declaration: 'export type StreamChunk = {\n    type: \'block-start\';\n    index: number;\n    blockType: ContentBlockType;\n} | {\n    type: \'text-delta\';\n    index: number;\n    text: string;\n} | {\n    type: \'reasoning-delta\';\n    index: number;\n    text: string;\n} | {\n    type: \'tool-call-delta\';\n    index: number;\n    id: CallId;\n    name?: string;\n    argumentsDelta: string;\n} | {\n    type: \'block-end\';\n    index: number;\n    block: ContentBlock;\n} | {\n    type: \'usage\';\n    usage: TokenUsage;\n} | {\n    type: \'finish\';\n    reason: FinishReason;\n};',
+  },
+  {
+    name: 'StructuredOutputSchema',
+    declaration: 'export type StructuredOutputSchema = StructuredSchemaNode & {\n    type: \'object\';\n};',
+  },
+  {
+    name: 'StructuredScalar',
+    declaration: 'export type StructuredScalar = string | number | boolean | null;',
+  },
+  {
+    name: 'StructuredSchemaNode',
+    declaration: 'export interface StructuredSchemaNode {\n    type: StructuredSchemaType;\n    properties?: Record<string, StructuredSchemaNode>;\n    required?: string[];\n    additionalProperties?: boolean;\n    items?: StructuredSchemaNode;\n    enum?: StructuredScalar[];\n    const?: StructuredScalar;\n    description?: string;\n    title?: string;\n    default?: unknown;\n    examples?: unknown;\n}',
+  },
+  {
+    name: 'StructuredSchemaType',
+    declaration: 'export type StructuredSchemaType = \'object\' | \'array\' | \'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\';',
+  },
+  {
+    name: 'SubagentCapabilities',
+    declaration: 'export interface SubagentCapabilities {\n    outputSchema: boolean;\n    depthLimit: boolean;\n    toolFilter: boolean;\n}',
+  },
+  {
+    name: 'SubagentProvider',
+    declaration: 'export interface SubagentProvider {\n    readonly name: string;\n    readonly capabilities: SubagentCapabilities;\n    readonly inheritsParentContext: boolean;\n    start(request: SubagentStartRequest): SubagentRun;\n}',
+  },
+  {
+    name: 'SubagentResult',
+    declaration: 'export interface SubagentResult {\n    output: ContentBlock[];\n    structured?: unknown;\n    stopReason: SubagentStopReason;\n}',
+  },
+  {
+    name: 'SubagentRun',
+    declaration: 'export interface SubagentRun {\n    readonly id: AgentId;\n    readonly result: Promise<SubagentResult>;\n    cancel(reason?: string): void;\n    dispose(): Promise<void>;\n    sendMessage?(content: ContentBlock[]): void;\n    resume?(content: ContentBlock[]): SubagentRun;\n}',
+  },
+  {
+    name: 'SubagentStartRequest',
+    declaration: 'export interface SubagentStartRequest {\n    prompt: ContentBlock[];\n    parent: Agent;\n    signal?: AbortSignal;\n    agentOptions?: AgentOptions;\n    outputSchema?: StructuredOutputSchema;\n    maxDepth?: number;\n    toolFilter?: {\n        allow?: string[];\n        deny?: string[];\n    };\n}',
+  },
+  {
+    name: 'SubagentStopReason',
+    declaration: 'export type SubagentStopReason = SubagentStopReasonMap[keyof SubagentStopReasonMap];',
+  },
+  {
+    name: 'SubagentStopReasonMap',
+    declaration: 'export interface SubagentStopReasonMap {\n    completed: \'completed\';\n    aborted: \'aborted\';\n    error: \'error\';\n    \'max-tokens\': \'max-tokens\';\n    refusal: \'refusal\';\n}',
+  },
+  {
+    name: 'SurfaceEventType',
+    declaration: 'export type SurfaceEventType = \'user/message\' | \'assistant/message\' | \'tool/result\' | \'context/message\' | \'steering/message\';',
+  },
+  {
+    name: 'SurfaceOp',
+    declaration: 'export type SurfaceOp = \'append\' | {\n    op: \'replace\';\n    start: number;\n    end: number;\n};',
+  },
+  {
+    name: 'TerminalCallView',
+    declaration: 'export interface TerminalCallView {\n    card: \'terminal\';\n    title: string;\n    description?: string;\n    cwd?: string;\n}',
+  },
+  {
+    name: 'TerminalResultView',
+    declaration: 'export interface TerminalResultView {\n    card: \'terminal\';\n    title?: string;\n    output?: string;\n    exitCode?: number;\n    signal?: string;\n}',
+  },
+  {
+    name: 'TodoItem',
+    declaration: 'export interface TodoItem {\n    content: string;\n    status: \'pending\' | \'in_progress\' | \'completed\';\n}',
+  },
+  {
+    name: 'TokenUsage',
+    declaration: 'export interface TokenUsage {\n    inputTokens: number;\n    outputTokens: number;\n    cacheReadTokens?: number;\n    cacheWriteTokens?: number;\n    reasoningTokens?: number;\n}',
+  },
+  {
+    name: 'ToolCallBlock',
+    declaration: 'export interface ToolCallBlock {\n    type: \'tool-call\';\n    id: CallId;\n    name: string;\n    arguments: string;\n}',
+  },
+  {
+    name: 'ToolCallKind',
+    declaration: 'export type ToolCallKind = \'read\' | \'edit\' | \'delete\' | \'move\' | \'search\' | \'execute\' | \'fetch\' | \'other\';',
+  },
+  {
+    name: 'ToolCallView',
+    declaration: 'export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;',
+  },
+  {
+    name: 'ToolDefinition',
+    declaration: 'export interface ToolDefinition extends ToolSchema {\n    execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn>;\n    timeoutMs?: number;\n    presentCall?(args: unknown): ToolCallView | undefined;\n    presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}',
+  },
+  {
+    name: 'ToolErrorInfo',
+    declaration: 'export interface ToolErrorInfo {\n    name: string;\n    code: string;\n}',
+  },
+  {
+    name: 'ToolExecuteReturn',
+    declaration: 'export type ToolExecuteReturn = ContentBlock[] | {\n    content: ContentBlock[];\n    meta?: unknown;\n};',
+  },
+  {
+    name: 'ToolExecution',
+    declaration: 'export interface ToolExecution {\n    callId: CallId;\n    name: string;\n    arguments: unknown;\n    agent?: Agent;\n    signal?: AbortSignal;\n}',
+  },
+  {
+    name: 'ToolExecutionResult',
+    declaration: 'export interface ToolExecutionResult {\n    callId: CallId;\n    content: ContentBlock[];\n    isError: boolean;\n    error?: ToolErrorInfo;\n    additionalContext?: HookContext;\n    meta?: unknown;\n}',
+  },
+  {
+    name: 'ToolResult',
+    declaration: 'export interface ToolResult {\n    content: ContentBlock[];\n    isError: boolean;\n    meta?: unknown;\n}',
+  },
+  {
+    name: 'ToolResultBlock',
+    declaration: 'export interface ToolResultBlock {\n    type: \'tool-result\';\n    toolCallId: CallId;\n    content: ContentBlock[];\n    isError?: boolean;\n}',
+  },
+  {
+    name: 'ToolResultView',
+    declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;',
+  },
+  {
+    name: 'ToolSchema',
+    declaration: 'export interface ToolSchema {\n    name: string;\n    description: string;\n    parameters: Record<string, unknown>;\n}',
+  },
+  {
+    name: 'TurnEndReason',
+    declaration: 'export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];',
+  },
+  {
+    name: 'TurnEndReasonMap',
+    declaration: 'export interface TurnEndReasonMap {\n    completed: {\n        kind: \'completed\';\n    };\n    aborted: {\n        kind: \'aborted\';\n        reason?: string;\n    };\n    error: {\n        kind: \'error\';\n        step: number;\n        message: string;\n        code?: string;\n    };\n    disposed: {\n        kind: \'disposed\';\n    };\n    \'max-tokens\': {\n        kind: \'max-tokens\';\n    };\n    rejected: {\n        kind: \'rejected\';\n        reason: string;\n    };\n    interrupted: {\n        kind: \'interrupted\';\n    };\n}',
+  },
+  {
+    name: 'TurnTrigger',
+    declaration: 'export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];',
+  },
+  {
+    name: 'TurnTriggerMap',
+    declaration: 'export interface TurnTriggerMap {\n    message: {\n        kind: \'message\';\n        source: MessageSource;\n    };\n    injection: {\n        kind: \'injection\';\n        source: MessageSource;\n    };\n}',
+  },
+  {
+    name: 'UserInteractionProvider',
+    declaration: 'export interface UserInteractionProvider {\n    ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>;\n}',
+  },
+  {
+    name: 'WebExecContext',
+    declaration: 'export interface WebExecContext {\n    readonly signal?: AbortSignal;\n}',
+  },
+  {
+    name: 'WebFetchBody',
+    declaration: 'export type WebFetchBody = {\n    readonly kind: \'html\';\n    readonly content: string;\n} | {\n    readonly kind: \'text\';\n    readonly content: string;\n};',
+  },
+  {
+    name: 'WebFetchProvider',
+    declaration: 'export interface WebFetchProvider {\n    readonly id: string;\n    status(): WebProviderStatus;\n    fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>;\n}',
+  },
+  {
+    name: 'WebFetchRequest',
+    declaration: 'export interface WebFetchRequest {\n    readonly url: string;\n    readonly timeoutMs?: number;\n}',
+  },
+  {
+    name: 'WebFetchResult',
+    declaration: 'export interface WebFetchResult {\n    readonly providerId: string;\n    readonly url: string;\n    readonly statusCode: number;\n    readonly body: WebFetchBody;\n    readonly truncated: boolean;\n}',
+  },
+  {
+    name: 'WebProviderStatus',
+    declaration: 'export type WebProviderStatus = {\n    readonly available: true;\n} | {\n    readonly available: false;\n    readonly reason: \'missing-credential\' | \'misconfigured\';\n};',
+  },
+  {
+    name: 'WebSearchProvider',
+    declaration: 'export interface WebSearchProvider {\n    readonly id: string;\n    status(): WebProviderStatus;\n    search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>;\n}',
+  },
+  {
+    name: 'WebSearchRequest',
+    declaration: 'export interface WebSearchRequest {\n    readonly query: string;\n    readonly maxResults?: number;\n}',
+  },
+  {
+    name: 'WebSearchResult',
+    declaration: 'export interface WebSearchResult {\n    readonly providerId: string;\n    readonly query: string;\n    readonly content?: string;\n    readonly sources: readonly WebSearchSource[];\n    readonly truncated: boolean;\n}',
+  },
+  {
+    name: 'WebSearchSource',
+    declaration: 'export interface WebSearchSource {\n    readonly url: string;\n    readonly title?: string;\n    readonly snippet?: string;\n    readonly publishedAt?: string;\n}',
+  },
+]
+
+/** The inherited `ctx` surface (cordis core + loader/hmr/timer), in curated order. */
+export const INHERITED_CTX_API: readonly InheritedApiEntry[] = [
+  { name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).' },
+  { name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).' },
+  { name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.' },
+  { name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.' },
+  { name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.' },
+  { name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).' },
+  { name: 'ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger', summary: 'Ambient handles onto the running context graph.' },
+  { name: 'ctx.timer (+ interval / timeout / throttle / debounce / setTimeout / setInterval)', summary: 'Disposable timer helpers. The `timer` key is provided at runtime; the six helpers are mixed onto ctx directly (declared via Pick).' },
+  { name: 'ctx.loader', summary: 'The config Loader that booted the app (present under the loader).' },
+  { name: 'ctx.hmr', summary: 'The hot-module-reload watcher (present under the hmr plugin).' },
+]

+ 39 - 0
packages/cordis/tool-cordis/src/fiber-state.ts

@@ -0,0 +1,39 @@
+/**
+ * Runtime mirror of the cordis `FiberState` const enum plus human-readable
+ * labels, shared by the mount lifecycle (state reporting) and the inspect
+ * renderers (plugin-list and mount-table labels).
+ *
+ * Cordis exposes `FiberState` as a `const enum`: there is no runtime object for
+ * Node's type-stripping runner to import, so the members are mirrored here as
+ * values — each typed (via the type-only import) as the cordis enum member it
+ * mirrors, so enum-typed reads like `fiber.state` compare against them under a
+ * shared enum type. Source of truth: vendor/cordis/src/fiber.ts (pinned; drift
+ * only happens through a deliberate vendor sync).
+ *
+ * @module @deepseek-ai/dsh-tool-cordis/fiber-state
+ */
+
+import type { FiberState as FiberStateEnum } from 'cordis'
+
+/** Value mirror of the cordis `FiberState` const enum (see the module doc for why a mirror exists). */
+export const FiberState = {
+  PENDING: 0 as FiberStateEnum.PENDING,
+  LOADING: 1 as FiberStateEnum.LOADING,
+  ACTIVE: 2 as FiberStateEnum.ACTIVE,
+  FAILED: 3 as FiberStateEnum.FAILED,
+  DISPOSED: 4 as FiberStateEnum.DISPOSED,
+  UNLOADING: 5 as FiberStateEnum.UNLOADING,
+} as const
+
+/** The cordis `FiberState` enum type, re-exported so mirror consumers need one import. */
+export type FiberState = FiberStateEnum
+
+/** Human-readable label for each {@link FiberState}, keyed by member (inlining-safe — no reverse mapping). */
+export const STATE_LABELS: Record<FiberState, string> = {
+  [FiberState.PENDING]: 'pending',
+  [FiberState.LOADING]: 'loading',
+  [FiberState.ACTIVE]: 'active',
+  [FiberState.FAILED]: 'failed',
+  [FiberState.DISPOSED]: 'disposed',
+  [FiberState.UNLOADING]: 'unloading',
+}

+ 447 - 0
packages/cordis/tool-cordis/src/guard.ts

@@ -0,0 +1,447 @@
+/**
+ * The registration boundary between sandboxed mount code and the real runtime:
+ * SchemaSpec normalization + validation with teaching errors, the
+ * marker-guarded `harness.defineTool` / `harness.registerTool` pair, the
+ * SANDBOX CONTEXT FAÇADE a mounted plugin's `apply` receives in place of the
+ * real `ctx`, and the plugin-shape helpers the mount lifecycle narrows sandbox
+ * return values with.
+ *
+ * The façade is a WHITELIST, not a pass-through proxy. Mount code needs to do
+ * exactly four things — register a tool, listen to an event, provide a service,
+ * call an injected service (timers included) — so the façade exposes only those
+ * verbs and the injected services, each object-valued service individually
+ * wrapped (a primitive provided value passes through as-is — see
+ * {@link sandboxContext}). Every framework plumbing member (`root`, `parent`, `scope`, `fiber`, `reflect`, `registry`,
+ * `events`, `extend`, `isolate`, `intercept`, `plugin`, `set`, `mixin`, …) is
+ * DENIED with a teaching error rather than passed through. This closes an
+ * entire escape class at once: a pass-through proxy that only special-cased
+ * `ctx.tools` still handed back the raw context through `ctx.root`,
+ * `ctx.extend()`, or a service instance's `.ctx`, and mount code could then
+ * `ctx.root.tools.register({…})` to bypass the marker check and host-realm
+ * normalization — a raw vm-realm result then errors a real agent turn at the
+ * session-log plainness check. The whitelist has no such hole: there is no
+ * context-valued member to reach, and any injected-service method that returns
+ * a `Context` is rejected (harness services never do — see {@link denyContext}).
+ *
+ * Two realm facts drive the tool path. Objects built inside the vm carry the vm
+ * realm's `Object.prototype`, and the session log's append-time plainness check
+ * (`dsh-session`'s `isJsonValue`, a prototype-identity comparison) rejects
+ * foreign-realm data — so every dynamic tool's `execute` return is JSON
+ * round-tripped into the host realm and shape-checked against the two
+ * `ToolExecuteReturn` forms before it reaches the registry (the registry
+ * trusts the shape blindly — it spreads `result.content`, so an unvalidated
+ * `{ content: 'ok' }` would enter the session log as `['o','k']` and silently
+ * corrupt the next model request), and the schema itself is rebuilt as fresh
+ * host-realm objects. And a malformed tool
+ * schema must fail at REGISTRATION, not when a later request assembles it — so
+ * dynamic tool registration accepts only definitions produced by the sandbox's
+ * `harness.defineTool`, which normalizes `parameters` up front.
+ *
+ * Normalize, don't lecture, where the input has exactly one meaning: models
+ * write the JSON-Schema dialect by strong prior (the `{ type: 'object',
+ * properties, required: […] }` wrapper, `type: 'integer'`, `required: false`),
+ * and each rejection costs a model turn — so those convert to the SchemaSpec
+ * DSL silently, and only genuinely meaningless input (an unknown type, a
+ * non-boolean `required`) is rejected, with the error enumerating the valid
+ * vocabulary.
+ *
+ * @module @deepseek-ai/dsh-tool-cordis/guard
+ */
+
+import { Context } from 'cordis'
+import type { Plugin } from 'cordis'
+import { defineTool } from '@deepseek-ai/dsh-tools'
+import type { ToolDefinition, ToolExecuteReturn } from '@deepseek-ai/dsh-tools'
+
+const DYNAMIC_TOOL = Symbol('tool-cordis.dynamic-tool')
+const SCHEMA_TYPES = new Set<unknown>(['string', 'number', 'boolean', 'object', 'array'])
+const VALID_TYPES = '\'string\' | \'number\' | \'boolean\' | \'object\' | \'array\''
+
+type DynamicToolDefinition = ToolDefinition & { [DYNAMIC_TOOL]: true }
+type DynamicToolMarker = { [DYNAMIC_TOOL]?: unknown }
+
+function isPlainRecord(value: unknown): value is Record<string, unknown> {
+  return Object.prototype.toString.call(value) === '[object Object]'
+}
+
+/**
+ * Normalize a sandbox-provided `parameters` value into a fresh host-realm
+ * SchemaSpec. Accepts the DSL directly, or the JSON-Schema-style
+ * `{ type: 'object', properties, required: […] }` wrapper models write by
+ * prior — the wrapper unwraps and its `required` array becomes per-property
+ * flags (see the module doc).
+ */
+function normalizeSchemaSpec(value: unknown, path = 'parameters'): Record<string, unknown> {
+  if (!isPlainRecord(value)) {
+    throw new Error(`harness.defineTool ${path} must be a SchemaSpec object`)
+  }
+  let entries = value
+  const requiredNames = new Set<unknown>()
+  if (value.type === 'object' && isPlainRecord(value.properties)) {
+    if (Array.isArray(value.required)) {
+      for (const name of value.required) requiredNames.add(name)
+    }
+    entries = value.properties
+  }
+  const spec: Record<string, unknown> = {}
+  for (const [key, prop] of Object.entries(entries)) {
+    spec[key] = normalizeSchemaProp(prop, `${path}.${key}`, requiredNames.has(key))
+  }
+  return spec
+}
+
+/** Normalize one property: `integer` → `number`, `required: false` → absent, nested wrappers unwrapped recursively. */
+function normalizeSchemaProp(value: unknown, path: string, forceRequired = false): Record<string, unknown> {
+  if (!isPlainRecord(value)) {
+    throw new Error(`harness.defineTool ${path} must be a SchemaSpec property object`)
+  }
+  const type = value.type === 'integer' ? 'number' : value.type
+  if (!SCHEMA_TYPES.has(type)) {
+    throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES} (got ${JSON.stringify(value.type)})`)
+  }
+  // On an object property a JSON-Schema-style `required` ARRAY names required
+  // children (handled by the nested unwrap below); everywhere else `required`
+  // must be a boolean, and `false` simply reads as optional.
+  const nestedRequiredArray = type === 'object' && Array.isArray(value.required)
+  if (value.required !== undefined && typeof value.required !== 'boolean' && !nestedRequiredArray) {
+    throw new Error(`harness.defineTool ${path}.required must be a boolean when present`)
+  }
+  const prop: Record<string, unknown> = { type }
+  if (forceRequired || value.required === true) prop.required = true
+  if (typeof value.description === 'string') prop.description = value.description
+  if (Array.isArray(value.enum)) prop.enum = [...value.enum as unknown[]]
+  if (value.default !== undefined) prop.default = value.default
+  if (value.properties !== undefined) {
+    if (type !== 'object') {
+      throw new Error(`harness.defineTool ${path}.properties is only valid for type "object"`)
+    }
+    // Re-wrap so the nested unwrap applies a nested `required` array too.
+    prop.properties = normalizeSchemaSpec(
+      { type: 'object', properties: value.properties, required: value.required },
+      `${path}.properties`,
+    )
+  }
+  if (value.items !== undefined) {
+    if (type !== 'array') {
+      throw new Error(`harness.defineTool ${path}.items is only valid for type "array"`)
+    }
+    prop.items = normalizeSchemaProp(value.items, `${path}.items`)
+  }
+  return prop
+}
+
+function markDynamicTool(tool: ToolDefinition): DynamicToolDefinition {
+  Object.defineProperty(tool, DYNAMIC_TOOL, { value: true })
+  return tool as DynamicToolDefinition
+}
+
+function assertDynamicTool(tool: unknown): asserts tool is DynamicToolDefinition {
+  if (!isPlainRecord(tool) || (tool as DynamicToolMarker)[DYNAMIC_TOOL] !== true) {
+    throw new Error('dynamic tool registration must use a tool returned by harness.defineTool(...)')
+  }
+}
+
+/**
+ * Structurally a content block, checked AFTER the JSON round-trip: a plain
+ * object carrying a string `type` tag. Deliberately nothing deeper — the
+ * ContentBlock union is merge-extensible (an unknown tag must pass), and every
+ * downstream consumer dispatches on `type` and falls through unknowns.
+ */
+function isContentBlockShape(value: unknown): boolean {
+  return isPlainRecord(value) && typeof value.type === 'string'
+}
+
+/**
+ * How much of an invalid execute return the teaching error echoes back — a
+ * huge blob would burn the model turn the error is trying to save.
+ */
+const RETURN_PREVIEW_LIMIT = 120
+
+/**
+ * Compact JSON preview of an invalid execute return for the teaching error
+ * (`String(…)` for the un-stringifiable undefined case), truncated to
+ * {@link RETURN_PREVIEW_LIMIT}.
+ */
+function describeReturn(value: unknown): string {
+  // JSON.stringify is TYPED as always returning string, but it yields
+  // undefined for an undefined input (the routed forgot-return case) — the
+  // assertion widens the type back to the runtime truth.
+  const json = JSON.stringify(value) as string | undefined
+  if (json === undefined) return String(value)
+  return json.length > RETURN_PREVIEW_LIMIT ? `${json.slice(0, RETURN_PREVIEW_LIMIT)}…` : json
+}
+
+/**
+ * Validate a round-tripped `execute` return against the two shapes
+ * {@link ToolExecuteReturn} allows: an ARRAY of content blocks, or
+ * `{ content: blocks, meta? }`. The registry trusts the shape blindly — it
+ * spreads `result.content`, so an unvalidated `{ content: 'ok' }` would enter
+ * the session log as `['o','k']` and silently corrupt the next model request —
+ * so a wrong shape fails THIS call with a teaching error instead.
+ */
+function assertExecuteReturn(value: unknown): ToolExecuteReturn {
+  if (Array.isArray(value) && value.every(isContentBlockShape)) {
+    return value as ToolExecuteReturn
+  }
+  if (isPlainRecord(value) && Array.isArray(value.content) && value.content.every(isContentBlockShape)) {
+    return value as ToolExecuteReturn
+  }
+  throw new Error(
+    `execute returned ${describeReturn(value)} — a tool's execute must return an ARRAY of content blocks, never a bare string:\n`
+    + '  ✓ return [{ type: \'text\', text: someString }]\n'
+    + '  ✓ return { content: [{ type: \'text\', text: someString }], meta: anyJsonValue }',
+  )
+}
+
+/**
+ * The `harness.defineTool` handed into the sandbox: the real DSL, with
+ * `parameters` normalized into a fresh host-realm SchemaSpec (JSON-Schema
+ * wrapper unwrapped, `integer` mapped, `required: false` dropped) and the
+ * tool's `execute` return normalized into the host realm via a JSON round-trip
+ * (see the module doc). The round-trip projects the return onto exactly what
+ * the log would durably store, and {@link assertExecuteReturn} then vets that
+ * projection — so a non-JSON-serializable OR wrong-shape return surfaces as
+ * that one call's teaching error instead of poisoning the turn.
+ * @param options - the standard `defineTool` options; `parameters` may be the SchemaSpec DSL or a JSON-Schema-style wrapper.
+ * @returns the marker-tagged definition `harness.registerTool` (and the guarded `ctx.tools.register`) accepts.
+ */
+export function sandboxDefineTool(options: Parameters<typeof defineTool>[0]): ToolDefinition {
+  const parameters = normalizeSchemaSpec((options as { parameters?: unknown }).parameters)
+  const tool = defineTool({ ...options, parameters } as Parameters<typeof defineTool>[0])
+  const execute = tool.execute.bind(tool)
+  return markDynamicTool({
+    ...tool,
+    async execute(args, exec) {
+      // JSON.stringify yields NO JSON for an undefined (or function/symbol)
+      // return despite its string-typed signature — route that into
+      // assertExecuteReturn's teaching error rather than letting JSON.parse
+      // throw its cryptic '"undefined" is not valid JSON'.
+      const json = JSON.stringify(await execute(args, exec)) as string | undefined
+      return assertExecuteReturn(json === undefined ? undefined : JSON.parse(json) as unknown)
+    },
+  })
+}
+
+/**
+ * The `harness.registerTool` handed into the sandbox: registers a
+ * marker-verified dynamic tool on the given context's registry.
+ * @param ctx - the (guarded) context whose `tools` service receives the tool.
+ * @param tool - a definition produced by {@link sandboxDefineTool}; anything else is rejected.
+ * @returns the registry disposer for the registration.
+ */
+export function sandboxRegisterTool(ctx: Context, tool: unknown): () => void {
+  assertDynamicTool(tool)
+  return ctx.tools.register(tool)
+}
+
+/**
+ * The verbs a mounted plugin may reach through the sandbox `ctx` façade,
+ * beyond its injected services. `on`/`once` observe events, `provide` exposes
+ * a service to other mounts, and the timer helpers schedule work — each a
+ * fiber effect that unwinds on unmount. Everything else on a real cordis `ctx`
+ * is framework plumbing and is denied. Forwarded LAZILY: the timer helpers are
+ * mixin accessors that throw `without inject` when read on a plugin that did
+ * not inject `timer`, so the façade reads `ctx[verb]` only at call time — the
+ * plugin that never touches a timer never trips that, and one that does gets
+ * cordis's own inject error at the call site.
+ */
+const CTX_VERBS = new Set(['on', 'once', 'provide', 'timeout', 'interval', 'setTimeout', 'setInterval', 'throttle', 'debounce'])
+
+/**
+ * The tool-registry façade: `register` (marker-guarded) plus READ-ONLY
+ * metadata (`schemas`, and `get` returning a schema view, never the live
+ * `ToolDefinition`). Exposing the raw definition would hand mount code the
+ * tool's `execute` function, letting it call another tool directly and bypass
+ * `ToolRegistry.execute` — the pre/post-execute waterfall (permission gates,
+ * accounting) and result normalization. So `get` returns the same
+ * name/description/parameters view as `schemas()`, and nothing invocable.
+ */
+function sandboxTools(ctx: Context): Record<string, unknown> {
+  return {
+    register: (tool: unknown): (() => void) => sandboxRegisterTool(ctx, tool),
+    schemas: () => ctx.tools.schemas(),
+    get: (name: string) => ctx.tools.schemas().find(schema => schema.name === name),
+  }
+}
+
+/**
+ * Reject any injected-service return that is a cordis `Context`. Harness
+ * services return data, never a context; a value that is one would be a
+ * fresh, unguarded handle back into the runtime — the exact escape the façade
+ * exists to close — so it fails loud instead of reaching sandbox code.
+ */
+function denyContext(value: unknown, service: string): unknown {
+  if (value instanceof Context) {
+    throw new Error(
+      `service "${service}" returned a cordis Context, which the sandbox does not expose. `
+      + 'Operate through your own plugin ctx (ctx.on / ctx.provide / ctx.tools.register) '
+      + 'and the services you inject — never another context.',
+    )
+  }
+  return value
+}
+
+/**
+ * Wrap an injected service so its methods forward to the real instance but
+ * their return values pass through {@link denyContext}. Non-function members
+ * (plain data) pass through as-is; a returned Promise is guarded on resolve.
+ */
+function guardedService(service: object, name: string): unknown {
+  return new Proxy(service, {
+    get(target, prop) {
+      const value = Reflect.get(target, prop, target) as unknown
+      if (typeof value !== 'function') return denyContext(value, name)
+      return (...args: unknown[]): unknown => {
+        const result = Reflect.apply(value, target, args) as unknown
+        if (result instanceof Promise) return result.then(v => denyContext(v, name))
+        return denyContext(result, name)
+      }
+    },
+  })
+}
+
+/**
+ * The service names a plugin declared in `inject`, as a lookup set. Whatever
+ * declaration style the plugin used — an `inject: ['bash', 'tools']` array or
+ * the `{ required, optional }` object form — cordis resolves it into a single
+ * name-keyed map on the fiber before `apply` runs (`{ bash: null, tools: null }`),
+ * so the gate just reads that map's keys. A mount may reach only the services
+ * it declared — that is what lets cordis park the mount when a declared
+ * provider unmounts.
+ */
+function declaredInjects(ctx: Context): Set<string> {
+  return new Set(Object.keys(ctx.fiber.inject))
+}
+
+/**
+ * The sandbox context façade handed to a mounted plugin's `apply` in place of
+ * the real `ctx`. A whitelist (see the module doc): the registration/eventing
+ * verbs, the timer helpers, a guarded `tools`, and injected services resolved
+ * through a guarded `get` / property access. A service is reachable only if the
+ * plugin DECLARED it in `inject` — an undeclared service is denied even when a
+ * global provider exists, so cordis's activation/unload semantics (park the
+ * mount when a declared provider goes away) actually bind. Every
+ * framework-plumbing member is denied with a teaching error; there is no
+ * context-valued member to reach.
+ */
+function sandboxContext(ctx: Context): Context {
+  const tools = sandboxTools(ctx)
+  const declared = declaredInjects(ctx)
+  // A framework member or an undeclared service — distinguish the two so the
+  // error teaches the right fix (declare it in inject vs it is withheld).
+  const denyRead = (prop: string): never => {
+    if (ctx.get(prop) !== undefined) {
+      throw new Error(
+        `service "${prop}" is not injected. Declare it: inject: ['${prop}', …] on your plugin, `
+        + 'so cordis parks this mount if the provider is later unmounted.',
+      )
+    }
+    throw new Error(
+      `sandbox ctx does not expose "${prop}". Available: ctx.tools.register / ctx.on / ctx.provide / `
+      + 'the timer helpers (ctx.setTimeout, ctx.interval, …) and any service you declared in inject. '
+      + 'Framework internals (root, fiber, registry, extend, plugin, …) are withheld by design.',
+    )
+  }
+  // Read a service for either access path (property or `get`). `tools` is the
+  // façade's own surface. An UNDECLARED name is denied with the teaching
+  // error; a DECLARED one resolves to the guarded service. A declared inject
+  // is required in cordis (the fiber only activates once every declared
+  // service is live), so at `apply`/`execute` time `ctx.get(name)` is present
+  // for a declared name — no undefined case to handle here. `provide()`
+  // accepts ANY value though (cross-mount composition advertises
+  // `ctx.provide('name', value)`), so a primitive or null value passes
+  // through unwrapped: Proxy throws on a non-object target, and only an
+  // object can carry a method that hands back a Context.
+  const readService = (name: string): unknown => {
+    if (name === 'tools') return tools
+    if (!declared.has(name)) return denyRead(name)
+    const service = denyContext(ctx.get(name), name)
+    if (service === null || (typeof service !== 'object' && typeof service !== 'function')) return service
+    return guardedService(service, name)
+  }
+  const get = (name: string): unknown => readService(name)
+  return new Proxy({}, {
+    get(_target, prop) {
+      if (prop === 'tools') return tools
+      if (prop === 'get') return get
+      if (typeof prop !== 'string') return undefined
+      // Lazy verb forwarder — reads `ctx[verb]` only when called, so a plugin
+      // that never uses a timer never triggers the timer mixin's inject check
+      // (cordis raises its own "without inject" error there for undeclared timer use).
+      if (CTX_VERBS.has(prop)) {
+        return (...args: unknown[]): unknown => {
+          const method = ctx[prop as keyof Context]
+          return Reflect.apply(method as (...a: unknown[]) => unknown, ctx, args)
+        }
+      }
+      return readService(prop)
+    },
+    // A façade is not the real ctx; block writes rather than let mount code
+    // stash state on a throwaway object and think it persisted.
+    set(_target, prop) {
+      throw new Error(`sandbox ctx is read-only; cannot assign "${String(prop)}"`)
+    },
+    // `in` reflects reachability: the façade surface plus DECLARED services
+    // (whether or not currently live). Does not resolve/wrap — no throw.
+    has: (_target, prop) => prop === 'tools' || prop === 'get'
+      || (typeof prop === 'string' && (CTX_VERBS.has(prop) || declared.has(prop))),
+  }) as unknown as Context
+}
+
+/**
+ * Narrow an arbitrary sandbox return value to a mountable cordis plugin: a
+ * function, or an object with an `apply` function. (A bare function passes the
+ * first arm, so the object arm never sees `Function.prototype.apply`.)
+ * @param value - whatever the mount code returned.
+ * @returns whether the value is mountable via `ctx.plugin`.
+ */
+export function isPlugin(value: unknown): value is Plugin {
+  if (typeof value === 'function') return true
+  return typeof value === 'object' && value !== null
+    && typeof (value as { apply?: unknown }).apply === 'function'
+}
+
+/**
+ * Wrap a plugin so its `apply` receives the sandbox context façade instead of
+ * the real `ctx` (see {@link sandboxContext} and the module doc). Both
+ * function-form and object-form plugins go through the same wrap; the plugin's
+ * own `inject` declaration is preserved (cordis reads it from the plugin
+ * object, and pending/active gating happens on the real fiber before `apply`
+ * runs), so cross-mount provide/inject works unmodified.
+ *
+ * `ctx.effect(customCleanup)` is deliberately absent from the façade for now —
+ * `on` / `provide` / `tools.register` cover every mount seen so far, and each
+ * is already a fiber effect. FIXME(sandbox-effect): expose a guarded `effect`
+ * once a real mount needs a bespoke disposer.
+ * @param plugin - the plugin the mount code returned.
+ * @returns an equivalent plugin whose `apply` sees the sandbox context façade.
+ */
+export function guardedPlugin(plugin: Plugin): Plugin {
+  if (typeof plugin === 'function') {
+    const functionPlugin = plugin as (ctx: Context, config?: unknown) => unknown
+    return {
+      name: pluginName(plugin),
+      apply(ctx: Context, config?: unknown) {
+        return functionPlugin(sandboxContext(ctx), config)
+      },
+    }
+  }
+  const objectPlugin = plugin as { apply(ctx: Context, config?: unknown): unknown }
+  return {
+    ...plugin,
+    apply(ctx: Context, config?: unknown) {
+      return objectPlugin.apply(sandboxContext(ctx), config)
+    },
+  }
+}
+
+/**
+ * Display name for a mounted plugin: its `name` property, else anonymous.
+ * @param plugin - the plugin the mount code returned.
+ * @returns the human-readable name used in mount results and inspect output.
+ */
+export function pluginName(plugin: Plugin): string {
+  const named = (plugin as { name?: unknown }).name
+  if (typeof named === 'string' && named.length > 0) return named
+  return '<anonymous>'
+}

+ 236 - 0
packages/cordis/tool-cordis/src/index.ts

@@ -0,0 +1,236 @@
+/**
+ * The self-referential cordis toolset: three model-facing tools that let the
+ * agent inspect and MODIFY the live cordis runtime it is running inside.
+ *
+ * - `cordis_inspect` — read-only: provided services, the flat plugin list
+ *   with lifecycle states, registered tools, the dynamic mounts, and the
+ *   catalog-backed `api` / `events` references.
+ * - `cordis_mount` — evaluate model-written code in a `node:vm` sandbox; the
+ *   code returns a cordis plugin, which is mounted as a child of a dedicated
+ *   `cordis-dynamic` group fiber and tracked under an id (`dyn-1`, `dyn-2`, …).
+ * - `cordis_unmount` — dispose one dynamic mount by id, awaiting quiescence.
+ *
+ * Everything the model's plugin registers (listeners via `ctx.on`, tools via
+ * `harness.registerTool`, services via `ctx.provide`) is an effect on the
+ * dynamic fiber, so unmounting — or disposing this plugin itself (HMR) — cleans
+ * it all up through the ordinary cordis lifecycle. The group fiber exists
+ * exactly so the dynamic mounts form ONE subtree, disposed as a unit with
+ * this plugin. Design home:
+ * docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md.
+ *
+ * The vm sandbox guards against ACCIDENTAL global pollution only, and the `ctx`
+ * a mounted plugin's `apply` receives is a WHITELIST façade (register a tool,
+ * observe events, provide/consume services, use timers — framework internals
+ * withheld; see the guard module). Neither is a security boundary: the verbs
+ * the façade DOES expose reach the real runtime unsandboxed (a mounted tool can
+ * shell out through `ctx.bash`), so a deployment loads this plugin as
+ * deliberately as it grants a bash tool. Design home:
+ * docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md.
+ *
+ * Plugin export shape: named exports, NO default. The cordis Loader's
+ * `unwrapExports` does `exports.default ?? exports`, so a stray default would
+ * collapse the module to the bare `apply` and drop `inject`, crashing at load
+ * (see docs/postmortem/0001).
+ *
+ * @module @deepseek-ai/dsh-tool-cordis
+ */
+
+import type { Context } from 'cordis'
+import z from 'schemastery'
+import { defineTool } from '@deepseek-ai/dsh-tools'
+import { STATE_LABELS } from './fiber-state.ts'
+import { isPlugin, pluginName } from './guard.ts'
+import { describeApi, describeDynamic, describeEvents, describePlugins, describeServices, describeTools } from './inspect.ts'
+import { missingServices, mountDynamic } from './mount.ts'
+import type { DynamicMount } from './mount.ts'
+import { presentInspectCall, presentMountCall, presentUnmountCall } from './present.ts'
+import { createSandbox, evaluateMountCode } from './sandbox.ts'
+
+export const name = 'tool-cordis'
+export const inject = ['tools']
+
+/** Config for the tool-cordis plugin: the sandbox evaluation bound. */
+export interface Config {
+  /**
+   * Milliseconds the SYNCHRONOUS portion of mount code may run in the vm
+   * before evaluation is aborted (default 5000). An async body escapes this
+   * bound — see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md for the trust stance.
+   */
+  vmTimeoutMs?: number
+}
+
+/** Schemastery validator for {@link Config}: `vmTimeoutMs` must be at least 1 (defaults to 5000). */
+export const Config: z<Config> = z.object({
+  vmTimeoutMs: z.number().min(1).default(5000),
+})
+
+/** {@link Config} with every defaulted field present, as schemastery resolves it at load. */
+type ResolvedConfig = Required<Config>
+
+/**
+ * Mount the three cordis tools on `ctx.tools` and create the `cordis-dynamic`
+ * group fiber every dynamic mount hangs under.
+ * @param ctx - the plugin context (`tools` injected).
+ * @param config - the schemastery-resolved {@link Config}.
+ */
+export function apply(ctx: Context, config: Config): void {
+  const { vmTimeoutMs } = config as ResolvedConfig
+  // The one group fiber every dynamic mount hangs under. Mounted here (a child
+  // of this plugin's fiber) so disposing tool-cordis cascades over the whole
+  // dynamic subtree — the ordinary parent→child fiber lifecycle, nothing extra.
+  const group = ctx.plugin({ name: 'cordis-dynamic', apply: () => {} })
+
+  const mounts = new Map<string, DynamicMount>()
+  let nextId = 1
+
+  ctx.tools.register(defineTool({
+    name: 'cordis_inspect',
+    description:
+      'Inspect the live cordis runtime that is running THIS agent. Read-only. '
+      + 'Sections: `services` (every provided ctx service and the plugin fiber that owns it), '
+      + '`plugins` (a flat list of the loaded plugins with their lifecycle states), '
+      + '`tools` (the model-facing tools currently registered, i.e. what you can call), '
+      + '`dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), '
+      + '`api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), '
+      + '`events` (every harness event with its dispatch mode and exact signature — pick listener targets here). '
+      + 'Omit `what` to get all six sections.',
+    parameters: {
+      what: {
+        type: 'string',
+        enum: ['services', 'plugins', 'tools', 'dynamic', 'api', 'events'],
+        description: 'Limit the report to one section. Omit for all sections.',
+      },
+    },
+    execute(args): Promise<{ type: 'text'; text: string }[]> {
+      const sections: [heading: string, body: () => string[]][] = [
+        ['services', () => describeServices(ctx)],
+        ['plugins', () => describePlugins(ctx)],
+        ['tools', () => describeTools(ctx)],
+        ['dynamic', () => describeDynamic(ctx, mounts)],
+        ['api', () => describeApi(ctx)],
+        ['events', () => describeEvents()],
+      ]
+      const selected = sections.filter(([heading]) => args.what === undefined || args.what === heading)
+      const text = selected
+        .map(([heading, body]) => `## ${heading}\n${body().join('\n')}`)
+        .join('\n\n')
+      return Promise.resolve([{ type: 'text', text }])
+    },
+    presentCall: presentInspectCall,
+  }))
+
+  ctx.tools.register(defineTool({
+    name: 'cordis_mount',
+    description:
+      'Mount a NEW cordis plugin into the live runtime that is running THIS agent '
+      + '(self-modification). `code` runs as the body of an async JavaScript function '
+      + 'in an isolated sandbox and MUST `return` a plugin. Two forms: '
+      + 'FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register '
+      + 'tools, listen to events, and provide services, but reaching ANY service (e.g. '
+      + 'ctx.bash) throws; use it only when you need no services. '
+      + 'OBJECT form `return { name?, inject: [\'bash\', \'llm\', …], apply(ctx) { … } }` '
+      + '— declares dependencies, and cordis activates the plugin only after the '
+      + 'services exist; PREFER this form. You may reach ONLY the services you list in '
+      + 'inject: an undeclared service throws even if it exists, because an undeclared '
+      + 'dependency would not be cleaned up if its provider is unmounted. '
+      + 'BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists '
+      + 'method signatures AND the type shapes of their arguments/returns (do not guess a '
+      + 'field\'s type; e.g. a bash run\'s stdout is an object, not a string). '
+      + 'Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe '
+      + 'events (see cordis_inspect what:"events"), or call '
+      + '`harness.registerTool(ctx, harness.defineTool({ name, description, parameters: '
+      + '{ text: { type: \'string\', required: true } }, async execute(args) { … } }))` '
+      + 'to give yourself a new tool — it becomes callable on your NEXT step. '
+      + 'Tool parameters: each key IS a property — { type: \'string\'|\'number\'|\'boolean\'|\'object\'|\'array\', '
+      + 'required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style '
+      + '{ type: \'object\', properties, required: […] } wrapper and type \'integer\' are also accepted and normalized. A '
+      + 'tool\'s `execute` MUST return an ARRAY of content blocks, e.g. `return '
+      + '[{ type: \'text\', text: someString }]` — never a bare string. '
+      + 'Mounts can COMPOSE: one plugin may `ctx.provide(\'name\', value)` a service and '
+      + 'another may declare `inject: [\'name\']` to consume it — the consumer stays pending '
+      + 'until the provider exists and returns to pending when the provider is unmounted. '
+      + 'Everything registered inside `apply` is cleaned up automatically on unmount. '
+      + 'Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness '
+      + 'terminal), `harness.defineTool`, `harness.registerTool`, '
+      + '`btoa`, `atob`, `TextEncoder`, `TextDecoder`. '
+      + 'Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, '
+      + 'never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect '
+      + 'errors; `process` and `Buffer` are undefined. Instead use inject: [\'fs\'] + ctx.fs for '
+      + 'files, inject: [\'web\'] + ctx.web for HTTP, inject: [\'bash\'] + ctx.bash for processes, '
+      + 'and inject: [\'timer\'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, '
+      + 'auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. '
+      + 'Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). '
+      + 'Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a '
+      + 'trailing `next` callback which MUST be called — returning without `next()` '
+      + 'VETOES the call; prefer plain notification events unless you intend to '
+      + 'intercept. (2) Never await something that only resolves after the current '
+      + 'turn (your code runs INSIDE a tool call of that turn — it would deadlock). '
+      + '(3) Your `ctx` is a restricted façade: you can register tools, observe '
+      + 'events, provide/consume services, and use timers, but framework internals '
+      + '(ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a '
+      + 'security boundary though — the services you inject (e.g. ctx.bash) reach the '
+      + 'real runtime.',
+    parameters: {
+      code: {
+        type: 'string',
+        required: true,
+        description: 'Body of an async JS function; must `return` the plugin to mount.',
+      },
+    },
+    async execute(args) {
+      const id = `dyn-${nextId++}`
+      const sandbox = createSandbox(id)
+      const evaluated = await evaluateMountCode(sandbox, args.code, id, vmTimeoutMs)
+      if (!isPlugin(evaluated)) {
+        if (evaluated === undefined) {
+          throw new Error(
+            'mount code returned `undefined` — did you forget `return`?\n'
+            + '  ✓ return (ctx) => { … }\n'
+            + '  ✓ return { name: \'…\', inject: […], apply(ctx) { … } }',
+          )
+        }
+        throw new Error(
+          'mount code must `return` a plugin: a function, or an object with an `apply(ctx)` method',
+        )
+      }
+      const fiber = await mountDynamic(group, evaluated)
+      mounts.set(id, { fiber, pluginName: pluginName(evaluated) })
+      // A settled fiber that is not ACTIVE is waiting on unsatisfied inject —
+      // legal cordis semantics (it activates when the service appears), so keep
+      // it mounted but tell the model what it is waiting for.
+      const missing = missingServices(ctx, fiber)
+      const state = STATE_LABELS[fiber.state]
+      const note = missing.length > 0
+        ? ` — waiting for service(s): ${missing.join(', ')} (activates when provided)`
+        : ''
+      return [{ type: 'text', text: `mounted ${id} (plugin "${pluginName(evaluated)}", state: ${state}${note})` }]
+    },
+    presentCall: presentMountCall,
+  }))
+
+  ctx.tools.register(defineTool({
+    name: 'cordis_unmount',
+    description:
+      'Dispose a plugin previously mounted with cordis_mount, by id. All its '
+      + 'registrations (event listeners, tools, services) are cleaned up through '
+      + 'the cordis effect lifecycle. Returns only after disposal has fully '
+      + 'completed (quiescence, not just a request to stop).',
+    parameters: {
+      id: {
+        type: 'string',
+        required: true,
+        description: 'The dynamic mount id returned by cordis_mount (e.g. "dyn-1").',
+      },
+    },
+    async execute(args) {
+      const mount = mounts.get(args.id)
+      if (!mount) {
+        throw new Error(`no dynamic plugin with id "${args.id}" (list mounts with cordis_inspect what:"dynamic")`)
+      }
+      await mount.fiber.dispose()
+      mounts.delete(args.id)
+      return [{ type: 'text', text: `unmounted ${args.id} (plugin "${mount.pluginName}")` }]
+    },
+    presentCall: presentUnmountCall,
+  }))
+}

+ 191 - 0
packages/cordis/tool-cordis/src/inspect.ts

@@ -0,0 +1,191 @@
+/**
+ * Read-only renderers over the live runtime for `cordis_inspect`: the service
+ * list, the flat plugin list, the registered tools, the dynamic-mount
+ * table (with per-mount provides/waits), and the catalog-backed `api` /
+ * `events` sections. Every renderer is a pure function of the runtime handles
+ * it receives — no session state, no clock — so inspect output is exactly the
+ * runtime it describes.
+ *
+ * @module @deepseek-ai/dsh-tool-cordis/inspect
+ */
+
+import type { Context, Fiber } from 'cordis'
+import { EVENT_API, INHERITED_CTX_API, SERVICE_API, TYPE_API } from './api-catalog.ts'
+import type { EventApiEntry, InheritedApiEntry, ServiceApiEntry, TypeApiEntry } from './api-catalog.ts'
+import { FiberState, STATE_LABELS } from './fiber-state.ts'
+import { missingServices } from './mount.ts'
+import type { DynamicMount } from './mount.ts'
+
+/** The live service registrations from `ctx.reflect.store` (map + filter keeps the possibly-undefined index read branch-free). */
+function liveImpls(ctx: Context): { name: string; fiber: Fiber }[] {
+  const store = ctx.reflect.store
+  return Object.getOwnPropertySymbols(store)
+    .map(key => store[key])
+    .filter((impl): impl is NonNullable<typeof impl> => impl !== undefined)
+}
+
+/** Whether `fiber` is `root` itself or mounted anywhere inside `root`'s subtree. */
+function withinFiber(fiber: Fiber, root: Fiber): boolean {
+  let current = fiber
+  while (true) {
+    if (current === root) return true
+    const parent = current.parent.fiber
+    if (parent === current) return false
+    current = parent
+  }
+}
+
+/** The service names provided by a mount's fiber subtree, sorted. */
+function providedBy(ctx: Context, fiber: Fiber): string[] {
+  return liveImpls(ctx)
+    .filter(impl => withinFiber(impl.fiber, fiber))
+    .map(impl => impl.name)
+    .sort()
+}
+
+/**
+ * The `services` section: every provided ctx service with its owning fiber,
+ * annotating non-active owners with their lifecycle state.
+ * @param ctx - the runtime to enumerate.
+ * @returns one line per service, or a single placeholder line when none are provided.
+ */
+export function describeServices(ctx: Context): string[] {
+  const lines = liveImpls(ctx).map((impl) => {
+    const active = impl.fiber.state === FiberState.ACTIVE
+    return `- ${impl.name} (provided by ${impl.fiber.name}${active ? '' : `, ${STATE_LABELS[impl.fiber.state]}`})`
+  })
+  return lines.length > 0 ? lines : ['(no services provided)']
+}
+
+/**
+ * The `plugins` section: a flat list of every fiber the registry knows, one
+ * line per fiber with its lifecycle state, sorted by plugin name (a plugin
+ * mounted more than once repeats — one line per instance). Dynamic mounts are
+ * listed like any other plugin; their ids live in the `dynamic` section.
+ * @param ctx - the runtime whose registry is enumerated.
+ * @returns one line per loaded plugin fiber.
+ */
+export function describePlugins(ctx: Context): string[] {
+  const fibers: Fiber[] = []
+  for (const runtime of ctx.registry.values()) {
+    for (const fiber of runtime.fibers) fibers.push(fiber)
+  }
+  return fibers
+    .sort((a, b) => a.name.localeCompare(b.name))
+    .map(fiber => `- ${fiber.name} [${STATE_LABELS[fiber.state]}]`)
+}
+
+/**
+ * The `tools` section: the model-facing tool names currently registered.
+ * @param ctx - the runtime whose tool registry is read.
+ * @returns one line per registered tool.
+ */
+export function describeTools(ctx: Context): string[] {
+  return ctx.tools.schemas().map(schema => `- ${schema.name}`)
+}
+
+/**
+ * The `dynamic` section: one line per mount with id, plugin name, lifecycle
+ * state, the services its subtree provides, and — for a pending mount — the
+ * services it waits for.
+ * @param ctx - the runtime the mounts live in.
+ * @param mounts - the tracked mounts, in mount order.
+ * @returns one line per mount, or a single placeholder line when none exist.
+ */
+export function describeDynamic(ctx: Context, mounts: ReadonlyMap<string, DynamicMount>): string[] {
+  if (mounts.size === 0) return ['(no dynamic plugins mounted)']
+  return [...mounts].map(([id, mount]) => {
+    const provides = providedBy(ctx, mount.fiber)
+    const waiting = missingServices(ctx, mount.fiber)
+    const providesNote = provides.length > 0 ? ` — provides: ${provides.join(', ')}` : ''
+    const waitingNote = waiting.length > 0 ? ` — waiting for: ${waiting.join(', ')}` : ''
+    return `- ${id}: ${mount.pluginName} [${STATE_LABELS[mount.fiber.state]}]${providesNote}${waitingNote}`
+  })
+}
+
+/**
+ * The transitive closure of catalogued type shapes referenced (word-bounded)
+ * by the seed texts — the runtime scoping that keeps the `api` section to the
+ * shapes the LIVE signatures actually mention.
+ */
+function typeClosure(seeds: string[], types: readonly TypeApiEntry[]): TypeApiEntry[] {
+  const included = new Map<string, TypeApiEntry>()
+  let frontier = seeds
+  while (frontier.length > 0) {
+    const next: string[] = []
+    for (const entry of types) {
+      if (included.has(entry.name)) continue
+      const pattern = new RegExp(`\\b${entry.name}\\b`)
+      if (frontier.some(text => pattern.test(text))) {
+        included.set(entry.name, entry)
+        next.push(entry.declaration)
+      }
+    }
+    frontier = next
+  }
+  return [...included.values()].sort((a, b) => a.name.localeCompare(b.name))
+}
+
+/**
+ * The `api` section: the generated service catalog intersected with the LIVE
+ * runtime — catalogued live services render summary + method signatures, live
+ * services without a catalog entry (e.g. ones another mount provides) render
+ * name + owning fiber, catalog services that are not running are listed
+ * tersely, the type shapes the live signatures reference follow, and the
+ * inherited `ctx` surface closes the section.
+ * @param ctx - the runtime to intersect the catalog with.
+ * @param api - the service catalog (the generated one by default; injectable for tests).
+ * @param inherited - the inherited `ctx` surface lines (generated by default; injectable for tests).
+ * @param types - the type-shape catalog (generated by default; injectable for tests).
+ * @returns the section lines.
+ */
+export function describeApi(
+  ctx: Context,
+  api: readonly ServiceApiEntry[] = SERVICE_API,
+  inherited: readonly InheritedApiEntry[] = INHERITED_CTX_API,
+  types: readonly TypeApiEntry[] = TYPE_API,
+): string[] {
+  const live = new Map<string, string>()
+  for (const impl of liveImpls(ctx)) live.set(impl.name, impl.fiber.name)
+  const lines: string[] = []
+  const liveMethodTexts: string[] = []
+  for (const entry of api) {
+    if (!live.has(entry.key)) continue
+    lines.push(`- ${entry.key} — ${entry.summary}`)
+    for (const method of entry.methods) {
+      lines.push(`    ${method}`)
+      liveMethodTexts.push(method)
+    }
+  }
+  const catalogued = new Set(api.map(entry => entry.key))
+  for (const [name, fiber] of [...live].sort(([a], [b]) => a.localeCompare(b))) {
+    if (!catalogued.has(name)) lines.push(`- ${name} (provided by ${fiber}, no catalog entry)`)
+  }
+  const notRunning = api.filter(entry => !live.has(entry.key)).map(entry => entry.key)
+  if (notRunning.length > 0) lines.push(`not running (loadable services with no live provider): ${notRunning.join(', ')}`)
+  const shapes = typeClosure(liveMethodTexts, types)
+  if (shapes.length > 0) {
+    lines.push('type shapes (referenced by the signatures above — read these before assuming a field is a string):')
+    for (const shape of shapes) {
+      for (const declLine of shape.declaration.split('\n')) lines.push(`    ${declLine}`)
+    }
+  }
+  lines.push('inherited ctx API:')
+  for (const entry of inherited) lines.push(`- ${entry.name} — ${entry.summary}`)
+  return lines
+}
+
+/**
+ * The `events` section: every harness event with its dispatch mode, one-line
+ * summary, and exact signature, closed by the waterfall caution.
+ * @param events - the event catalog (the generated one by default; injectable for tests).
+ * @returns the section lines.
+ */
+export function describeEvents(events: readonly EventApiEntry[] = EVENT_API): string[] {
+  const lines = events.flatMap(event => [
+    `- ${event.name} [${event.mode}] — ${event.summary}`,
+    `    ${event.signature}`,
+  ])
+  lines.push('waterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain.')
+  return lines
+}

+ 64 - 0
packages/cordis/tool-cordis/src/mount.ts

@@ -0,0 +1,64 @@
+/**
+ * Dynamic-mount lifecycle over the `cordis-dynamic` group fiber: settle a
+ * sandbox-produced plugin as a child fiber (never leaving a failed fiber
+ * mounted), and report the services a settled-but-pending fiber still waits
+ * for. Disposal needs no helper — a mount unwinds through an ordinary awaited
+ * `fiber.dispose()`, because everything the plugin registered is an effect on
+ * its fiber.
+ *
+ * @module @deepseek-ai/dsh-tool-cordis/mount
+ */
+
+import type { Context, Fiber, Plugin } from 'cordis'
+import { guardedPlugin } from './guard.ts'
+
+/** One tracked dynamic mount: the fiber plus the display name captured at mount time. */
+export interface DynamicMount {
+  /** The child fiber under the `cordis-dynamic` group. */
+  fiber: Fiber
+  /** The plugin's display name at mount time (its `name`, else `<anonymous>`). */
+  pluginName: string
+}
+
+/**
+ * Mount a plugin under the group fiber and settle it. The group fiber loads
+ * asynchronously right after the owning plugin's `apply`, so it is awaited
+ * before hanging a child off its context. The child fiber's `await()` settles
+ * its lifecycle work and rethrows a startup error (e.g. a throwing `apply`);
+ * on error the fiber is disposed first — a failed mount never lingers.
+ * @param group - the `cordis-dynamic` group fiber every mount hangs under.
+ * @param plugin - the plugin the sandbox returned; wrapped with the registration guard before mounting.
+ * @returns the settled child fiber (possibly pending on unsatisfied `inject`).
+ */
+export async function mountDynamic(group: Fiber, plugin: Plugin): Promise<Fiber> {
+  await group.await()
+  const fiber = group.ctx.plugin(guardedPlugin(plugin))
+  try {
+    await fiber.await()
+  } catch (error) {
+    await fiber.dispose()
+    const message = error instanceof Error ? error.message : String(error)
+    // The commonest startup collision is remounting a NEW version of a tool
+    // while the old mount still holds the name — teach the replace recipe.
+    if (message.includes('already registered')) {
+      throw new Error(
+        `${message} — to REPLACE something an earlier mount registered, first cordis_unmount that mount's id `
+        + '(find it with cordis_inspect what:"dynamic"), then mount the new version.',
+      )
+    }
+    throw error instanceof Error ? error : new Error(message)
+  }
+  return fiber
+}
+
+/**
+ * The services a fiber declared in `inject` that do not exist yet — a settled
+ * fiber that is not active is waiting on exactly these (legal cordis
+ * semantics: it activates when the service appears).
+ * @param ctx - the context to resolve service existence against.
+ * @param fiber - the mount fiber whose `inject` declarations are checked.
+ * @returns the missing service names, in declaration order.
+ */
+export function missingServices(ctx: Context, fiber: Fiber): string[] {
+  return Object.keys(fiber.inject).filter(service => ctx.get(service) === undefined)
+}

+ 51 - 0
packages/cordis/tool-cordis/src/present.ts

@@ -0,0 +1,51 @@
+/**
+ * ACP render intents for the three cordis tools — all `generic` cards, decided
+ * up front as part of the tool design. Presenters are pure functions of the
+ * call arguments (they run on replay too): no I/O, no session state, no clock.
+ * No `presentResult` overrides exist — the tools' text results are their
+ * correct completed rendering.
+ *
+ * @module @deepseek-ai/dsh-tool-cordis/present
+ */
+
+import type { GenericCallView } from '@deepseek-ai/dsh-tools'
+
+/**
+ * The `cordis_inspect` call card: a read, titled with the requested section.
+ * @param args - the validated call arguments.
+ * @returns the generic card the ACP bridge renders.
+ */
+export function presentInspectCall(args: { what?: string }): GenericCallView {
+  return {
+    card: 'generic',
+    kind: 'read',
+    title: args.what === undefined ? 'Inspect cordis runtime' : `Inspect cordis runtime: ${args.what}`,
+  }
+}
+
+/**
+ * The `cordis_mount` call card: an execute carrying the mount code as raw input.
+ * @param args - the validated call arguments.
+ * @returns the generic card the ACP bridge renders.
+ */
+export function presentMountCall(args: { code: string }): GenericCallView {
+  return {
+    card: 'generic',
+    kind: 'execute',
+    title: 'Mount plugin into live cordis runtime',
+    rawInput: { code: args.code },
+  }
+}
+
+/**
+ * The `cordis_unmount` call card: a delete, titled with the mount id.
+ * @param args - the validated call arguments.
+ * @returns the generic card the ACP bridge renders.
+ */
+export function presentUnmountCall(args: { id: string }): GenericCallView {
+  return {
+    card: 'generic',
+    kind: 'delete',
+    title: `Unmount ${args.id}`,
+  }
+}

+ 201 - 0
packages/cordis/tool-cordis/src/sandbox.ts

@@ -0,0 +1,201 @@
+/**
+ * The `node:vm` sandbox `cordis_mount` code evaluates in: a fresh realm whose
+ * globals are a tagged write-through console, the `harness` registration
+ * helpers, the encoding primitives a bare vm context lacks, and callable traps
+ * over the Node APIs the sandbox deliberately withholds. Capability access is
+ * routed through cordis services, never Node built-ins: filesystem work goes
+ * through `ctx.fs`, network through `ctx.web`, processes through `ctx.bash`,
+ * timers through the `ctx.timer` helpers (fiber effects, unwound on unmount)
+ * — so a well-behaved mount stays inspectable and disposable. That routing is
+ * STEERING toward the cordis services, not containment: the sandbox guards
+ * against ACCIDENTAL global pollution, and it is not a security boundary. The
+ * host-realm helpers on the sandbox global (`harness`, `console`, `btoa`) are
+ * reachable functions, so a mount that goes looking — e.g. through such a
+ * helper's `.constructor` — can still reach the host realm; that is accepted,
+ * because the `ctx` a mounted plugin's `apply` later receives is the real,
+ * fully privileged runtime handle, and that is the point of the toolset.
+ *
+ * @module @deepseek-ai/dsh-tool-cordis/sandbox
+ */
+
+import { createContext, runInContext } from 'node:vm'
+import { sandboxDefineTool, sandboxRegisterTool } from './guard.ts'
+
+/**
+ * A write-through console for one sandbox, tagging every line with the mount
+ * id. Write-through (host stdout/stderr), NOT buffered into the tool result:
+ * a mounted listener fires long after the mount call returned, and its output
+ * must land somewhere the user can see — for the stdio demo, the terminal.
+ */
+function taggedConsole(id: string): Record<'log' | 'info' | 'warn' | 'error' | 'debug', (...args: unknown[]) => void> {
+  const tag = `[cordis:${id}]`
+  const log = (...args: unknown[]): void => { console.log(tag, ...args) }
+  const error = (...args: unknown[]): void => { console.error(tag, ...args) }
+  return { log, info: log, warn: log, debug: log, error }
+}
+
+/**
+ * Per-sandbox prelude: give the vm realm's own constructors a
+ * `Symbol.hasInstance` that checks BOTH realms. Model code runs against a
+ * fresh vm realm, but most objects it touches are HOST-realm (the `args` a
+ * tool's `execute` receives, event payloads a listener observes, service
+ * return values), so a plain `x instanceof Array` / `instanceof Object` in
+ * sandbox code would silently be false. The patch replaces each vm
+ * constructor's own `[Symbol.hasInstance]` with "ordinary check against the
+ * vm constructor OR the host counterpart" — the ordinary algorithm is a pure
+ * prototype-chain walk, so calling it with the host constructor as receiver
+ * needs no host-side change. ONLY vm-realm globals are modified; host
+ * intrinsics are passed in as values and never touched.
+ */
+const DUAL_REALM_INSTANCEOF_PRELUDE = `
+(hostIntrinsics) => {
+  'use strict'
+  const ordinary = Function.prototype[Symbol.hasInstance]
+  for (const name of Object.keys(hostIntrinsics)) {
+    const VmCtor = globalThis[name]
+    const HostCtor = hostIntrinsics[name]
+    if (typeof VmCtor !== 'function' || typeof HostCtor !== 'function') continue
+    Object.defineProperty(VmCtor, Symbol.hasInstance, {
+      value: (instance) => ordinary.call(VmCtor, instance) || ordinary.call(HostCtor, instance),
+      configurable: true,
+    })
+  }
+}
+`
+
+/** Run {@link DUAL_REALM_INSTANCEOF_PRELUDE} in a freshly created sandbox, handing it the host intrinsics to pair up. */
+function patchDualRealmInstanceof(sandbox: object): void {
+  const patch = runInContext(DUAL_REALM_INSTANCEOF_PRELUDE, sandbox) as (intrinsics: Record<string, unknown>) => void
+  patch({ Object, Array, Function, Error, TypeError, RangeError, SyntaxError, Promise, RegExp, Date, Map, Set })
+}
+
+const TIMER_REDIRECT
+  = 'Node timers are unavailable. Use the cordis timer service instead: declare inject: [\'timer\'] on your plugin '
+    + 'and call ctx.setTimeout / ctx.setInterval — those are fiber effects, cleaned up automatically on unmount.'
+
+/**
+ * The callable Node APIs the sandbox deliberately disables, each mapped to the
+ * cordis alternative its trap error names. Only FUNCTION-shaped globals are
+ * trapped — a data-shaped global like `process` stays `undefined`, because a
+ * throwing accessor would detonate the common `typeof process` feature probe
+ * at resolution time.
+ */
+const NODE_API_REDIRECTS: Record<string, string> = {
+  require:
+    'Node modules are unavailable. Use the cordis services on ctx instead — e.g. inject: [\'fs\'] for files, '
+    + '[\'web\'] for HTTP, [\'bash\'] for processes; cordis_inspect what:"api" lists what THIS runtime provides.',
+  setTimeout: TIMER_REDIRECT,
+  setInterval: TIMER_REDIRECT,
+  setImmediate: TIMER_REDIRECT,
+  clearTimeout: TIMER_REDIRECT,
+  clearInterval: TIMER_REDIRECT,
+  fetch:
+    'Network access goes through the cordis web service: declare inject: [\'web\'] and call ctx.web '
+    + '(see cordis_inspect what:"api" for its methods).',
+}
+
+/** Build the trap functions for {@link NODE_API_REDIRECTS}: calling one throws the redirect. */
+function nodeApiTraps(): Record<string, () => never> {
+  const traps: Record<string, () => never> = {}
+  for (const [name, redirect] of Object.entries(NODE_API_REDIRECTS)) {
+    traps[name] = () => {
+      throw new Error(`${name} is not available in the mount sandbox — ${redirect}`)
+    }
+  }
+  return traps
+}
+
+/**
+ * Build the vm context one `cordis_mount` call evaluates in: the tagged
+ * console, the `harness` registration helpers, the encoding primitives, the
+ * Node-API traps, and the dual-realm `instanceof` patch, already
+ * `createContext`-ed.
+ * @param id - the mount id (`dyn-<n>`), used as the console tag and filename stem.
+ * @returns the contextified sandbox object to pass to {@link evaluateMountCode}.
+ */
+export function createSandbox(id: string): object {
+  const sandbox = {
+    ...nodeApiTraps(),
+    console: taggedConsole(id),
+    harness: { defineTool: sandboxDefineTool, registerTool: sandboxRegisterTool },
+    // Web APIs absent from fresh vm contexts — made available so the model
+    // can encode/decode base64 without Buffer (which is also absent). Host
+    // closures over Buffer, never Buffer itself.
+    btoa: (s: string) => Buffer.from(s, 'utf-8').toString('base64'),
+    atob: (s: string) => Buffer.from(s, 'base64').toString('utf-8'),
+    TextEncoder,
+    TextDecoder,
+  }
+  createContext(sandbox)
+  patchDualRealmInstanceof(sandbox)
+  return sandbox
+}
+
+/**
+ * Cross-realm SyntaxError detection: a compile failure inside `runInContext`
+ * constructs its error in the SANDBOX realm, so a host `instanceof
+ * SyntaxError` is silently false — the `name` property is the realm-safe tag.
+ */
+function isSyntaxError(error: unknown): error is Error {
+  return typeof error === 'object' && error !== null && (error as { name?: unknown }).name === 'SyntaxError'
+}
+
+/**
+ * The parse-failure context a vm `SyntaxError` carries: the vm prints the
+ * offending source line and a caret before the message, which is exactly what
+ * a model needs to self-correct — surface it instead of the bare message.
+ * Falls back to `String(error)` when the stack carries no such prelude.
+ * @param error - the `SyntaxError` (host- or sandbox-realm) thrown while compiling mount code.
+ * @returns the stack prefix up to and including the `SyntaxError: …` line.
+ */
+export function syntaxErrorContext(error: Error): string {
+  const lines = (error.stack ?? '').split('\n')
+  const messageIndex = lines.findIndex(line => line.startsWith('SyntaxError'))
+  if (messageIndex === -1) return String(error)
+  return lines.slice(0, messageIndex + 1).join('\n')
+}
+
+/**
+ * Evaluate mount code as the body of an async function inside the sandbox.
+ * `vmTimeoutMs` only bounds the SYNCHRONOUS portion; an async body escapes it
+ * — acceptable under the module's trust stance. A parse failure is answered
+ * with the offending line + caret and a teaching hint: TypeScript syntax on
+ * the failing line gets the remove-annotations fix, anything else gets the
+ * function-body/bracket-balance reminder (models habitually close the returned
+ * plugin object with `});` as if it were a callback argument).
+ * @param sandbox - the contextified object from {@link createSandbox}.
+ * @param code - the model-written function body; must `return` a plugin.
+ * @param id - the mount id, used as the vm filename (`cordis-mount-<id>.js`).
+ * @param vmTimeoutMs - the synchronous evaluation bound in milliseconds.
+ * @returns whatever the code returned, still un-narrowed (the mount lifecycle checks plugin shape).
+ */
+export async function evaluateMountCode(sandbox: object, code: string, id: string, vmTimeoutMs: number): Promise<unknown> {
+  try {
+    return await runInContext(
+      `(async () => {\n${code}\n})()`,
+      sandbox,
+      { filename: `cordis-mount-${id}.js`, timeout: vmTimeoutMs },
+    )
+  } catch (error) {
+    if (!isSyntaxError(error)) throw error
+    const context = syntaxErrorContext(error)
+    // Scope the TypeScript heuristic to the OFFENDING line, not the whole
+    // code: an ` as ` inside an ordinary description string must not turn a
+    // plain syntax error into a misleading remove-annotations message.
+    const offendingLine = context.split('\n')[1] ?? ''
+    if (/\bas\b/.test(offendingLine)) {
+      throw new Error(
+        `mount code failed to parse:\n${context}\n`
+        + 'The sandbox runs plain JavaScript, not TypeScript. Remove type annotations:\n'
+        + '  ✗ { type: \'text\' as const, text: x }\n'
+        + '  ✓ { type: \'text\', text: x }',
+      )
+    }
+    throw new Error(
+      `mount code failed to parse:\n${context}\n`
+      + 'Note: `code` runs as the BODY of an async function (line numbers are offset by the 1-line wrapper). '
+      + 'Check bracket balance — ending the returned plugin object with `});` closes a call that was never opened; '
+      + 'a plain `return { … }` ends with `}` (an optional `;`), never `)`.',
+    )
+  }
+}

+ 143 - 0
packages/cordis/tool-cordis/tests/cross-mount.spec.ts

@@ -0,0 +1,143 @@
+import { describe, expect, it } from 'vitest'
+import { call, CONSUMER_CODE, PROVIDER_CODE, setup, text } from './helpers.ts'
+
+/**
+ * Cross-mount composition through ordinary cordis provide/inject semantics:
+ * one mount provides a service, another injects it, and mount ids stay the
+ * lifecycle handles. Every assertion is against the WORLD — the registry, the
+ * service store, real tool dispatch — not the tool's own summary line.
+ */
+
+describe('cross-mount provide/inject', () => {
+  it('provider first: the consumer activates immediately and its tool reaches the provided service', async () => {
+    const ctx = await setup()
+    const provider = await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
+    expect(text(provider)).toContain('state: active')
+
+    const consumer = await call(ctx, 'cordis_mount', { code: CONSUMER_CODE })
+    expect(consumer.isError).toBe(false)
+    expect(text(consumer)).toContain('state: active')
+
+    // The vm-realm service value is callable across mounts, and the result
+    // normalizes into the host realm like any dynamic tool result.
+    const greeted = await call(ctx, 'greet', { name: 'harness' })
+    expect(greeted.isError).toBe(false)
+    expect(text(greeted)).toBe('hi harness')
+  })
+
+  it('consumer first: stays pending naming the missing service, then activates when the provider mounts', async () => {
+    const ctx = await setup()
+    const consumer = await call(ctx, 'cordis_mount', { code: CONSUMER_CODE })
+    expect(consumer.isError).toBe(false)
+    expect(text(consumer)).toContain('state: pending')
+    expect(text(consumer)).toContain('waiting for service(s): greeter')
+    expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('waiting for: greeter')
+    expect(ctx.tools.get('greet')).toBeUndefined()
+
+    await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
+    expect(ctx.tools.get('greet')).toBeDefined()
+    expect(text(await call(ctx, 'greet', { name: 'late' }))).toBe('hi late')
+  })
+
+  it('unmounting the provider sends the consumer back to pending and unwinds its registrations', async () => {
+    const ctx = await setup()
+    await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })   // dyn-1
+    await call(ctx, 'cordis_mount', { code: CONSUMER_CODE })   // dyn-2
+    expect(ctx.tools.get('greet')).toBeDefined()
+
+    const unmounted = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
+    expect(unmounted.isError).toBe(false)
+    expect(ctx.tools.get('greet')).toBeUndefined()
+    const report = text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))
+    expect(report).toContain('dyn-2: greeter-consumer [pending] — waiting for: greeter')
+  })
+
+  it('re-providing the service re-runs the consumer through the same guard (active again, tool back)', async () => {
+    const ctx = await setup()
+    await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })   // dyn-1
+    await call(ctx, 'cordis_mount', { code: CONSUMER_CODE })   // dyn-2
+    await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
+    expect(ctx.tools.get('greet')).toBeUndefined()
+
+    await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })   // dyn-3
+    expect(ctx.tools.get('greet')).toBeDefined()
+    expect(text(await call(ctx, 'greet', { name: 'again' }))).toBe('hi again')
+    expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('dyn-2: greeter-consumer [active]')
+  })
+
+  it('a duplicate provide fails loud with the owning fiber named, and the failed mount is disposed', async () => {
+    const ctx = await setup()
+    await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
+    const duplicate = await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
+    expect(duplicate.isError).toBe(true)
+    expect(text(duplicate)).toContain('has been registered')
+    const report = text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))
+    expect(report).toContain('dyn-1: greeter-provider')
+    expect(report).not.toContain('dyn-2')
+  })
+
+  it('inspect surfaces the linkage: provides on the provider row, the service in services and api sections', async () => {
+    const ctx = await setup()
+    await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
+    await call(ctx, 'cordis_mount', { code: CONSUMER_CODE })
+
+    const dynamic = text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))
+    expect(dynamic).toContain('dyn-1: greeter-provider [active] — provides: greeter')
+
+    const services = text(await call(ctx, 'cordis_inspect', { what: 'services' }))
+    expect(services).toContain('- greeter (provided by greeter-provider)')
+
+    const api = text(await call(ctx, 'cordis_inspect', { what: 'api' }))
+    expect(api).toContain('- greeter (provided by greeter-provider, no catalog entry)')
+  })
+
+  it('a primitive (or null) provided value passes through the façade unwrapped, on both read paths', async () => {
+    const ctx = await setup()
+    const provider = await call(ctx, 'cordis_mount', {
+      code: `
+        return {
+          name: 'answer-provider',
+          apply(ctx) {
+            ctx.provide('answer', 42)
+            ctx.provide('nothing', null)
+          },
+        }
+      `,
+    })
+    expect(provider.isError).toBe(false)
+
+    const consumer = await call(ctx, 'cordis_mount', {
+      code: `
+        return {
+          name: 'answer-consumer',
+          inject: ['answer', 'nothing', 'tools'],
+          apply(ctx) {
+            harness.registerTool(ctx, harness.defineTool({
+              name: 'answer',
+              description: 'Read the provided primitive services.',
+              parameters: {},
+              async execute() {
+                return [{ type: 'text', text: ctx.answer + '/' + ctx.get('answer') + '/' + ctx.nothing }]
+              },
+            }))
+          },
+        }
+      `,
+    })
+    expect(consumer.isError).toBe(false)
+    expect(text(consumer)).toContain('state: active')
+    expect(text(await call(ctx, 'answer', {}))).toBe('42/42/null')
+  })
+
+  it('unmounting the consumer leaves the provider and its service intact', async () => {
+    const ctx = await setup()
+    await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })   // dyn-1
+    await call(ctx, 'cordis_mount', { code: CONSUMER_CODE })   // dyn-2
+    await call(ctx, 'cordis_unmount', { id: 'dyn-2' })
+
+    expect(ctx.tools.get('greet')).toBeUndefined()
+    const services = text(await call(ctx, 'cordis_inspect', { what: 'services' }))
+    expect(services).toContain('- greeter (provided by greeter-provider)')
+    expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('dyn-1: greeter-provider [active]')
+  })
+})

+ 104 - 0
packages/cordis/tool-cordis/tests/helpers.ts

@@ -0,0 +1,104 @@
+import { Context } from 'cordis'
+import Timer from '@cordisjs/plugin-timer'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import type { ToolDefinition, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
+import * as tool from '../src/index.ts'
+
+/**
+ * Shared spec helpers: a real `SystemPrompt` + `ToolRegistry` + timer +
+ * tool-cordis tree (only the model is absent — the code strings below stand in
+ * for what it would write), plus the canonical mount-code fixtures the suites
+ * share.
+ */
+
+/** Mount the plugin on a fresh context with a real ToolRegistry and the timer service. */
+export async function setup(config?: tool.Config): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(Timer)
+  await ctx.plugin(SystemPrompt)
+  await ctx.plugin(ToolRegistry)
+  await ctx.plugin(tool, config)
+  return ctx
+}
+
+let callCounter = 0
+
+/** Execute a registered tool through the real registry pipeline. */
+export function call(ctx: Context, name: string, args: unknown): Promise<ToolExecutionResult> {
+  return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args })
+}
+
+/** Concatenated text blocks of one tool result. */
+export function text(result: ToolExecutionResult): string {
+  return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
+}
+
+/** Mount code for a listener plugin: logs on every `tools/change`. */
+export const LISTENER_CODE = `
+  return {
+    name: 'change-logger',
+    apply(ctx) {
+      ctx.on('tools/change', () => console.log('tools changed'))
+    },
+  }
+`
+
+/** Mount code for a self-made tool: registers `reverse_text` via the sandbox's harness helpers. */
+export const REVERSE_TOOL_CODE = `
+  return {
+    name: 'reverse-text',
+    inject: ['tools'],
+    apply(ctx) {
+      harness.registerTool(ctx, harness.defineTool({
+        name: 'reverse_text',
+        description: 'Reverse a string.',
+        parameters: { text: { type: 'string', required: true } },
+        async execute(args) {
+          return [{ type: 'text', text: args.text.split('').reverse().join('') }]
+        },
+      }))
+    },
+  }
+`
+
+/** Mount code providing a `greeter` service other mounts can inject. */
+export const PROVIDER_CODE = `
+  return {
+    name: 'greeter-provider',
+    apply(ctx) {
+      ctx.provide('greeter', { greet: (name) => 'hi ' + name })
+    },
+  }
+`
+
+/** Mount code consuming the `greeter` service through inject, exposing it as a tool. */
+export const CONSUMER_CODE = `
+  return {
+    name: 'greeter-consumer',
+    inject: ['greeter', 'tools'],
+    apply(ctx) {
+      harness.registerTool(ctx, harness.defineTool({
+        name: 'greet',
+        description: 'Greet someone via the greeter service.',
+        parameters: { name: { type: 'string', required: true } },
+        async execute(args) {
+          return [{ type: 'text', text: ctx.greeter.greet(args.name) }]
+        },
+      }))
+    },
+  }
+`
+
+/** A registrable no-op tool the tests use to trigger a real `tools/change`. */
+export function dummyTool(name: string): ToolDefinition {
+  return {
+    name,
+    description: 'test trigger',
+    parameters: { type: 'object' as const, properties: {} },
+    async execute(): Promise<[]> {
+      return []
+    },
+  }
+}

+ 117 - 0
packages/cordis/tool-cordis/tests/inspect.spec.ts

@@ -0,0 +1,117 @@
+import { describe, expect, it } from 'vitest'
+import type { Context, Fiber } from 'cordis'
+import { FiberState } from '../src/fiber-state.ts'
+import { describeApi, describeEvents, describePlugins, describeServices } from '../src/inspect.ts'
+import { call, LISTENER_CODE, setup, text } from './helpers.ts'
+
+/**
+ * The `cordis_inspect` sections: rendered against the real runtime through the
+ * tool, plus direct renderer calls for the states a minimal harness cannot
+ * reach (empty service store, same-named sibling fibers, a fully-live catalog).
+ */
+
+describe('cordis_inspect', () => {
+  it('reports all six sections by default', async () => {
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_inspect', {})
+    expect(result.isError).toBe(false)
+    const report = text(result)
+    for (const heading of ['services', 'plugins', 'tools', 'dynamic', 'api', 'events']) {
+      expect(report).toContain(`## ${heading}`)
+    }
+    // The services section sees the real providers; the plugins list shows
+    // this plugin and its dynamic group flat; the tools section lists the
+    // cordis tools.
+    expect(report).toContain('- tools (provided by ToolRegistry)')
+    expect(report).toContain('- tool-cordis [active]')
+    expect(report).toContain('- cordis-dynamic [active]')
+    expect(report).toContain('- cordis_mount')
+    expect(report).toContain('(no dynamic plugins mounted)')
+  })
+
+  it('limits the report to one section via `what`', async () => {
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_inspect', { what: 'tools' })
+    const report = text(result)
+    expect(report).toContain('## tools')
+    expect(report).not.toContain('## services')
+    expect(report).not.toContain('## plugins')
+  })
+
+  it('shows a mount in the dynamic section and in the flat plugins list', async () => {
+    const ctx = await setup()
+    await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
+    const report = text(await call(ctx, 'cordis_inspect', {}))
+    expect(report).toContain('- dyn-1: change-logger [active]')
+    expect(report).toContain('- change-logger [active]')
+  })
+
+  it('renders the api section from the generated catalog intersected with the LIVE runtime', async () => {
+    const ctx = await setup()
+    const report = text(await call(ctx, 'cordis_inspect', { what: 'api' }))
+    // Live catalogued services render summary + signatures.
+    expect(report).toContain('- tools — ')
+    expect(report).toContain('register(definition: ToolDefinition)')
+    expect(report).toContain('- systemPrompt — ')
+    // Catalogued services with no live provider are listed tersely.
+    expect(report).toMatch(/not running \(loadable services with no live provider\): .*bash/)
+    // The type shapes the LIVE signatures reference follow (closure over the
+    // generated TYPE_API — a consumer can see field types, not just names).
+    expect(report).toContain('type shapes (referenced by the signatures above')
+    expect(report).toContain('export interface ToolExecution')
+    // A type only reachable through a NOT-live service (e.g. bash) is scoped out.
+    expect(report).not.toContain('export interface BashRunResult')
+    // The inherited ctx surface closes the section.
+    expect(report).toContain('inherited ctx API:')
+    expect(report).toContain('- ctx.effect — ')
+  })
+
+  it('renders the events section with mode badges, signatures, and the waterfall caution', async () => {
+    const ctx = await setup()
+    const report = text(await call(ctx, 'cordis_inspect', { what: 'events' }))
+    expect(report).toContain('- tools/change [emit]')
+    expect(report).toContain('- tools/pre-execute [waterfall]')
+    expect(report).toMatch(/'agent\/status'\(/)
+    expect(report).toContain('returning without next() vetoes the chain')
+  })
+})
+
+describe('inspect renderers (direct)', () => {
+  it('describeServices reports an empty store as such, and labels a non-active provider', () => {
+    const empty = { reflect: { store: {} } } as unknown as Context
+    expect(describeServices(empty)).toEqual(['(no services provided)'])
+
+    const pendingFiber = { state: FiberState.PENDING, name: 'half-loaded' } as unknown as Fiber
+    const store: Record<symbol, unknown> = {}
+    store[Symbol('impl')] = { name: 'thing', fiber: pendingFiber }
+    const ctx = { reflect: { store } } as unknown as Context
+    expect(describeServices(ctx)).toEqual(['- thing (provided by half-loaded, pending)'])
+  })
+
+  it('describePlugins lists every fiber flat, sorted by name, one line per instance', () => {
+    const fiber = (name: string): Fiber => ({ name, state: FiberState.ACTIVE }) as unknown as Fiber
+    const ctx = {
+      registry: { values: () => [{ fibers: [fiber('beta'), fiber('alpha')] }, { fibers: [fiber('alpha')] }] },
+    } as unknown as Context
+    expect(describePlugins(ctx)).toEqual([
+      '- alpha [active]',
+      '- alpha [active]',
+      '- beta [active]',
+    ])
+  })
+
+  it('describeApi omits the not-running line and type shapes when nothing applies', async () => {
+    const ctx = await setup()
+    const lines = describeApi(ctx, [{ key: 'tools', summary: 'The registry.', methods: ['register(x): void'] }], [], [])
+    expect(lines[0]).toBe('- tools — The registry.')
+    expect(lines[1]).toBe('    register(x): void')
+    expect(lines.join('\n')).not.toContain('not running')
+    expect(lines.join('\n')).not.toContain('type shapes')
+  })
+
+  it('describeEvents renders an empty catalog as just the waterfall caution', () => {
+    expect(describeEvents([])).toEqual([
+      'waterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain.',
+    ])
+  })
+})

+ 74 - 0
packages/cordis/tool-cordis/tests/integration.spec.ts

@@ -0,0 +1,74 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import LlmService from '@deepseek-ai/dsh-llm'
+import SessionStore from '@deepseek-ai/dsh-session'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
+import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import * as ToolCordis from '../src/index.ts'
+import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
+import { REVERSE_TOOL_CODE } from './helpers.ts'
+
+/**
+ * Full-loop integration: a scripted mock model mounts a plugin that registers
+ * a NEW tool, calls that tool on the very next step (tool schemas are
+ * reassembled per step — the real loop proves the self-extension contract),
+ * and unmounts it again. Only the model is mocked; the sandbox, the fiber
+ * tree, and the session log are real.
+ */
+
+async function harness(adapter: MockAdapter): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(LlmService)
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(SystemPrompt)
+  await ctx.plugin(ToolRegistry)
+  await ctx.plugin(AgentRegistry)
+  await ctx.plugin(AgentLoop, { agents: [] })
+  await ctx.plugin(ToolCordis)
+  ctx.llm.registerAdapter(['mock'], adapter)
+  return ctx
+}
+
+function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+  return new Promise((resolve) => {
+    const dispose = ctx.on('agent/status', (subject, status) => {
+      if (subject === agent && status === 'idle') {
+        dispose()
+        resolve()
+      }
+    })
+  })
+}
+
+describe('cordis tools through the agent loop', () => {
+  it('mounts a tool, calls it on the next step, and unmounts it — all as real tool/call events', async () => {
+    const adapter = new MockAdapter([
+      toolCallResponse('call-1', 'cordis_mount', { code: REVERSE_TOOL_CODE }, 'Extending myself.'),
+      toolCallResponse('call-2', 'reverse_text', { text: 'harness' }),
+      toolCallResponse('call-3', 'cordis_unmount', { id: 'dyn-1' }),
+      textResponse('Done.'),
+    ])
+    const ctx = await harness(adapter)
+    const agent = ctx.agentLoop.create(AgentId('it-cordis'), { model: 'mock' })
+
+    agent.send([{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }])
+    await waitForIdle(ctx, agent)
+
+    const log = agent.session.events
+    const calls = log.filter(event => event.type === 'tool/call').map(event => event.data.name)
+    expect(calls).toEqual(['cordis_mount', 'reverse_text', 'cordis_unmount'])
+
+    const results = log.filter(event => event.type === 'tool/result')
+    expect(results.map(event => event.data.isError)).toEqual([false, false, false])
+    const reversed = results[1]!.data.content
+      .filter(block => block.type === 'text')
+      .map(block => block.text)
+      .join('')
+    expect(reversed).toBe('ssenrah')
+
+    // After the unmount the self-made tool is gone from the registry.
+    expect(ctx.tools.get('reverse_text')).toBeUndefined()
+  })
+})

+ 575 - 0
packages/cordis/tool-cordis/tests/mount.spec.ts

@@ -0,0 +1,575 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { isJsonValue } from '@deepseek-ai/dsh-session'
+import { syntaxErrorContext } from '../src/sandbox.ts'
+import { call, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts'
+
+/**
+ * The `cordis_mount` success/failure family: real plugins land on a genuine
+ * cordis fiber tree, their registrations are observable through the real
+ * registry/event bus, and every rejection path teaches the fix.
+ */
+
+afterEach(() => {
+  vi.restoreAllMocks()
+})
+
+describe('cordis_mount', () => {
+  it('mounts a listener plugin that observes real events, tagged-logging through to the host console', async () => {
+    const ctx = await setup()
+    const log = vi.spyOn(console, 'log').mockImplementation(() => {})
+
+    const result = await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
+    expect(result.isError).toBe(false)
+    expect(text(result)).toContain('mounted dyn-1 (plugin "change-logger", state: active)')
+
+    // Fire a REAL tools/change by registering a tool; the mounted listener logs.
+    ctx.tools.register(dummyTool('trigger_a'))
+    expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'tools changed')
+  })
+
+  it('mounts a bare-function plugin as <anonymous>, and a named function under its name', async () => {
+    const ctx = await setup()
+    const anonymous = await call(ctx, 'cordis_mount', { code: 'return (ctx) => { ctx.on(\'tools/change\', () => {}) }' })
+    expect(anonymous.isError).toBe(false)
+    expect(text(anonymous)).toContain('plugin "<anonymous>"')
+    const named = await call(ctx, 'cordis_mount', { code: 'return function watcher(ctx) {}' })
+    expect(text(named)).toContain('plugin "watcher"')
+  })
+
+  it('lets the agent give ITSELF a new tool, immediately callable through the registry', async () => {
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
+    expect(result.isError).toBe(false)
+
+    expect(ctx.tools.schemas().map(schema => schema.name)).toContain('reverse_text')
+    const reversed = await call(ctx, 'reverse_text', { text: 'harness' })
+    expect(reversed.isError).toBe(false)
+    expect(text(reversed)).toBe('ssenrah')
+  })
+
+  it('normalizes a self-made tool\'s result into the host realm, so the session log accepts it', async () => {
+    // The model's execute builds its content blocks INSIDE the vm, where
+    // Object.prototype is a different object — dsh-session's isJsonValue (the
+    // gate every `tool/result` append runs through) compares prototype
+    // IDENTITY, so a raw foreign-realm result would error the whole turn the
+    // first time the self-made tool runs. harness.defineTool round-trips the
+    // return into host-realm JSON before it reaches the registry.
+    const ctx = await setup()
+    await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
+    const reversed = await call(ctx, 'reverse_text', { text: 'harness' })
+    expect(isJsonValue({ content: reversed.content, isError: reversed.isError })).toBe(true)
+  })
+
+  it('threads the { content, meta } object return form through to the registry result', async () => {
+    const ctx = await setup()
+    await call(ctx, 'cordis_mount', {
+      code: `
+        return {
+          name: 'meta-return',
+          inject: ['tools'],
+          apply(ctx) {
+            harness.registerTool(ctx, harness.defineTool({
+              name: 'meta_tool',
+              description: 'attaches a private presentation payload',
+              parameters: {},
+              async execute() {
+                return { content: [{ type: 'text', text: 'ok' }], meta: { kind: 'demo' } }
+              },
+            }))
+          },
+        }
+      `,
+    })
+    const result = await call(ctx, 'meta_tool', {})
+    expect(result.isError).toBe(false)
+    expect(text(result)).toBe('ok')
+    expect(result.meta).toEqual({ kind: 'demo' })
+  })
+
+  it.each([
+    ['a bare string', 'return \'ok\'', '"ok"'],
+    ['an object whose content is a string', 'return { content: \'ok\' }', '{"content":"ok"}'],
+    ['an array of non-objects', 'return [\'ok\']', '["ok"]'],
+    ['blocks missing the type tag', 'return [{ text: \'hi\' }]', '[{"text":"hi"}]'],
+    ['object-form blocks missing the type tag', 'return { content: [{ text: \'hi\' }] }', '{"content":[{"text":"hi"}]}'],
+    ['undefined — a forgotten return', 'return undefined', 'undefined'],
+  ])('rejects an execute return of %s as that one call\'s teaching error', async (_label, returnStatement, preview) => {
+    // The failure this prevents: the registry trusts the return shape
+    // (postExecute spreads result.content), so an unvalidated { content: 'ok' }
+    // would enter the session log as ['o','k'] and silently corrupt the next
+    // model request. The shape check turns it into THIS call's error instead —
+    // one well-formed text block the log and the model can digest.
+    const ctx = await setup()
+    await call(ctx, 'cordis_mount', {
+      code: `
+        return {
+          name: 'bad-return',
+          inject: ['tools'],
+          apply(ctx) {
+            harness.registerTool(ctx, harness.defineTool({
+              name: 'bad_return_tool',
+              description: 'returns a wrong shape',
+              parameters: {},
+              async execute() { ${returnStatement} },
+            }))
+          },
+        }
+      `,
+    })
+    const result = await call(ctx, 'bad_return_tool', {})
+    expect(result.isError).toBe(true)
+    expect(result.content).toHaveLength(1)
+    expect(result.content[0]!.type).toBe('text')
+    expect(text(result)).toContain(`execute returned ${preview}`)
+    expect(text(result)).toContain('must return an ARRAY of content blocks')
+    expect(text(result)).toContain('✓ return { content: [{ type: \'text\', text: someString }], meta: anyJsonValue }')
+  })
+
+  it('truncates a huge invalid execute return in the teaching error', async () => {
+    const ctx = await setup()
+    await call(ctx, 'cordis_mount', {
+      code: `
+        return {
+          name: 'huge-return',
+          inject: ['tools'],
+          apply(ctx) {
+            harness.registerTool(ctx, harness.defineTool({
+              name: 'huge_return_tool',
+              description: 'returns a huge wrong shape',
+              parameters: {},
+              async execute() { return 'x'.repeat(500) },
+            }))
+          },
+        }
+      `,
+    })
+    const result = await call(ctx, 'huge_return_tool', {})
+    expect(result.isError).toBe(true)
+    expect(text(result)).toContain('…')
+    expect(text(result)).not.toContain('x'.repeat(200))
+  })
+
+  it('accepts a JSON-Schema-style parameters wrapper and normalizes it to the DSL', async () => {
+    // The dialect models write by strong prior: the { type:'object',
+    // properties, required: […] } wrapper, `type: 'integer'`, and
+    // `required: false`. All of it has exactly one meaning — normalize instead
+    // of burning a model turn on a lecture.
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', {
+      code: `
+        return {
+          name: 'json-schema-tool',
+          inject: ['tools'],
+          apply(ctx) {
+            harness.registerTool(ctx, harness.defineTool({
+              name: 'json_schema_tool',
+              description: 'written in the JSON-Schema dialect',
+              parameters: {
+                type: 'object',
+                properties: {
+                  text: { type: 'string', description: 'the text' },
+                  count: { type: 'integer', default: 1 },
+                  mode: { type: 'string', enum: ['fast', 'slow'] },
+                  extra: { type: 'string', required: false },
+                },
+                required: ['text'],
+              },
+              async execute(args) { return [{ type: 'text', text: args.text + ':' + (args.count ?? 0) }] },
+            }))
+          },
+        }
+      `,
+    })
+    expect(result.isError).toBe(false)
+
+    // The registered schema is canonical JSON Schema derived from the DSL:
+    // the required array survived, integer became number, extra is optional.
+    const schema = ctx.tools.schemas().find(s => s.name === 'json_schema_tool')!
+    const parameters = schema.parameters as { properties: Record<string, { type: string; enum?: string[] }>; required?: string[] }
+    expect(parameters.required).toEqual(['text'])
+    expect(parameters.properties.count!.type).toBe('number')
+    expect(parameters.properties.mode!.enum).toEqual(['fast', 'slow'])
+    // Arg validation enforces the normalized spec: text required, extra not.
+    expect((await call(ctx, 'json_schema_tool', { count: 2 })).isError).toBe(true)
+    expect(text(await call(ctx, 'json_schema_tool', { text: 'ok', count: 2 }))).toBe('ok:2')
+  })
+
+  it('normalizes a nested object property carrying a JSON-Schema required array', async () => {
+    // On an object PROPERTY, a JSON-Schema-style `required` array names the
+    // required children — the nested unwrap converts it just like the top level.
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', {
+      code: `
+        return {
+          name: 'nested-json-schema',
+          inject: ['tools'],
+          apply(ctx) {
+            harness.registerTool(ctx, harness.defineTool({
+              name: 'nested_json_schema_tool',
+              description: 'nested dialect',
+              parameters: {
+                cfg: { type: 'object', properties: { label: { type: 'string' } }, required: ['label'] },
+              },
+              async execute(args) { return [{ type: 'text', text: args.cfg.label }] },
+            }))
+          },
+        }
+      `,
+    })
+    expect(result.isError).toBe(false)
+    const schema = ctx.tools.schemas().find(s => s.name === 'nested_json_schema_tool')!
+    const cfg = (schema.parameters as { properties: { cfg: { required?: string[] } } }).properties.cfg
+    expect(cfg.required).toEqual(['label'])
+    expect(text(await call(ctx, 'nested_json_schema_tool', { cfg: { label: 'hi' } }))).toBe('hi')
+  })
+
+  it.each([
+    ['parameters: 42', 'must be a SchemaSpec object'],
+    ['parameters: { text: 42 }', 'parameters.text must be a SchemaSpec property object'],
+    ['parameters: { text: { type: \'str\' } }', 'parameters.text must declare a valid type: \'string\' | \'number\' | \'boolean\' | \'object\' | \'array\' (got "str")'],
+    ['parameters: { text: { type: \'string\', required: \'yes\' } }', 'parameters.text.required must be a boolean when present'],
+    ['parameters: { text: { type: \'string\', properties: {} } }', 'parameters.text.properties is only valid for type "object"'],
+    ['parameters: { text: { type: \'string\', items: { type: \'string\' } } }', 'parameters.text.items is only valid for type "array"'],
+  ])('rejects a malformed SchemaSpec (%s) with a teaching error', async (parameters, message) => {
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', {
+      code: `
+        return {
+          name: 'bad-schema',
+          inject: ['tools'],
+          apply(ctx) {
+            harness.registerTool(ctx, harness.defineTool({
+              name: 'bad_schema_tool',
+              description: 'bad',
+              ${parameters},
+              async execute() { return [] },
+            }))
+          },
+        }
+      `,
+    })
+    expect(result.isError).toBe(true)
+    expect(text(result)).toContain(message)
+  })
+
+  it('accepts a nested object/array SchemaSpec (the DSL recursion)', async () => {
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', {
+      code: `
+        return {
+          name: 'nested-schema',
+          inject: ['tools'],
+          apply(ctx) {
+            harness.registerTool(ctx, harness.defineTool({
+              name: 'nested_schema_tool',
+              description: 'nested',
+              parameters: {
+                item: { type: 'object', required: true, properties: { label: { type: 'string', required: true } } },
+                tags: { type: 'array', items: { type: 'string' } },
+              },
+              async execute(args) { return [{ type: 'text', text: args.item.label }] },
+            }))
+          },
+        }
+      `,
+    })
+    expect(result.isError).toBe(false)
+    const echoed = await call(ctx, 'nested_schema_tool', { item: { label: 'ok' }, tags: ['a'] })
+    expect(text(echoed)).toBe('ok')
+  })
+
+  it('rejects raw dynamic ctx.tools.register calls that bypass harness helpers', async () => {
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', {
+      code: `
+        return {
+          name: 'raw-register',
+          inject: ['tools'],
+          apply(ctx) {
+            ctx.tools.register({
+              name: 'raw_dynamic_tool',
+              description: 'raw',
+              parameters: { type: 'object', properties: {} },
+              async execute() { return [] },
+            })
+          },
+        }
+      `,
+    })
+
+    expect(result.isError).toBe(true)
+    expect(text(result)).toContain('dynamic tool registration must use a tool returned by harness.defineTool')
+    expect(ctx.tools.get('raw_dynamic_tool')).toBeUndefined()
+  })
+
+  it('guards the registry reached through ctx.get(\'tools\') identically', async () => {
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', {
+      code: `
+        return {
+          name: 'raw-register-get',
+          apply(ctx) {
+            ctx.get('tools').register({ name: 'raw_via_get', description: 'raw', parameters: {}, async execute() { return [] } })
+          },
+        }
+      `,
+    })
+    expect(result.isError).toBe(true)
+    expect(text(result)).toContain('dynamic tool registration must use a tool returned by harness.defineTool')
+    expect(ctx.tools.get('raw_via_get')).toBeUndefined()
+  })
+
+  it('passes non-register registry members through the guard with correct binding', async () => {
+    const ctx = await setup()
+    const log = vi.spyOn(console, 'log').mockImplementation(() => {})
+    const result = await call(ctx, 'cordis_mount', {
+      code: `
+        return {
+          name: 'schema-reader',
+          inject: ['tools'],
+          apply(ctx) {
+            console.log('sees', ctx.tools.schemas().length, 'tools; mount is', typeof ctx.tools.get('cordis_mount'))
+          },
+        }
+      `,
+    })
+    expect(result.isError).toBe(false)
+    expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'sees', 3, 'tools; mount is', 'object')
+  })
+
+  it('keeps a plugin with unsatisfied inject mounted as pending and names what it waits for', async () => {
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', {
+      code: 'return { name: \'waiter\', inject: [\'no-such-service\'], apply(ctx) {} }',
+    })
+    expect(result.isError).toBe(false)
+    expect(text(result)).toContain('state: pending')
+    expect(text(result)).toContain('waiting for service(s): no-such-service')
+    // Unmounting a pending mount works like any other.
+    const unmounted = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
+    expect(unmounted.isError).toBe(false)
+  })
+
+  it('rejects code that throws, leaving nothing mounted', async () => {
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', { code: 'throw new Error(\'boom in sandbox\')' })
+    expect(result.isError).toBe(true)
+    expect(text(result)).toContain('boom in sandbox')
+    expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)')
+  })
+
+  it('passes non-Error and null throws through untouched (no SyntaxError misclassification)', async () => {
+    const ctx = await setup()
+    const primitive = await call(ctx, 'cordis_mount', { code: 'throw \'plain-string-throw\'' })
+    expect(primitive.isError).toBe(true)
+    expect(text(primitive)).toContain('plain-string-throw')
+    const nullish = await call(ctx, 'cordis_mount', { code: 'throw null' })
+    expect(nullish.isError).toBe(true)
+  })
+
+  it('rejects code that does not return a plugin', async () => {
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', { code: 'return 42' })
+    expect(result.isError).toBe(true)
+    expect(text(result)).toContain('must `return` a plugin')
+  })
+
+  it('answers a missing return with the two valid plugin forms', async () => {
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', { code: 'const plugin = (ctx) => {}' })
+    expect(result.isError).toBe(true)
+    expect(text(result)).toContain('did you forget `return`?')
+  })
+
+  it('disposes a plugin whose apply throws, and reports the error', async () => {
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', {
+      code: 'return { name: \'broken\', apply(ctx) { throw new Error(\'apply exploded\') } }',
+    })
+    expect(result.isError).toBe(true)
+    expect(text(result)).toContain('apply exploded')
+    expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)')
+  })
+
+  it('rolls back a plugin that collides with an existing tool name, keeping the original tool intact', async () => {
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', {
+      code: `
+        return {
+          name: 'usurper',
+          inject: ['tools'],
+          apply(ctx) {
+            harness.registerTool(ctx, harness.defineTool({
+              name: 'cordis_mount',
+              description: 'dup',
+              parameters: {},
+              async execute() { return [] },
+            }))
+          },
+        }
+      `,
+    })
+    expect(result.isError).toBe(true)
+    expect(text(result)).toContain('already registered')
+    expect(text(result)).toContain('first cordis_unmount')
+    // The original cordis_mount still dispatches — the failed fiber is gone.
+    const retry = await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
+    expect(retry.isError).toBe(false)
+  })
+
+  it('isolates sandbox globals: no process/Buffer, and globalThis writes do not leak to the host', async () => {
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', {
+      code: `
+        globalThis.__cordis_tool_leak = 'leaked'
+        return { name: 'probe-' + typeof process + '-' + typeof Buffer, apply(ctx) {} }
+      `,
+    })
+    expect(result.isError).toBe(false)
+    expect(text(result)).toContain('plugin "probe-undefined-undefined"')
+    expect((globalThis as Record<string, unknown>).__cordis_tool_leak).toBeUndefined()
+  })
+
+  it.each([
+    ['require(\'fs\')', 'require is not available in the mount sandbox', 'inject: [\'fs\']'],
+    ['setTimeout(() => {}, 5)', 'setTimeout is not available in the mount sandbox', 'ctx.setTimeout'],
+    ['fetch(\'https://example.com\')', 'fetch is not available in the mount sandbox', 'ctx.web'],
+  ])('traps the Node API call %s with a redirect to the cordis alternative', async (invocation, trapMessage, redirect) => {
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', { code: `${invocation}\nreturn (ctx) => {}` })
+    expect(result.isError).toBe(true)
+    expect(text(result)).toContain(trapMessage)
+    expect(text(result)).toContain(redirect)
+    expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)')
+  })
+
+  it('lets a mounted plugin schedule through the cordis timer service (inject: [\'timer\'])', async () => {
+    const ctx = await setup()
+    const log = vi.spyOn(console, 'log').mockImplementation(() => {})
+    const result = await call(ctx, 'cordis_mount', {
+      code: `
+        return {
+          name: 'ticker',
+          inject: ['timer'],
+          apply(ctx) {
+            ctx.setTimeout(() => console.log('tick'), 10)
+          },
+        }
+      `,
+    })
+    expect(result.isError).toBe(false)
+    expect(text(result)).toContain('state: active')
+    await new Promise(resolve => setTimeout(resolve, 50))
+    expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'tick')
+  })
+
+  it('provides btoa/atob and the tagged console variants inside the sandbox', async () => {
+    const ctx = await setup()
+    const log = vi.spyOn(console, 'log').mockImplementation(() => {})
+    const error = vi.spyOn(console, 'error').mockImplementation(() => {})
+    const result = await call(ctx, 'cordis_mount', {
+      code: `
+        console.warn('warned')
+        console.error('errored')
+        const round = atob(btoa('hi'))
+        const bytes = new TextEncoder().encode(round)
+        return { name: 'codec-' + new TextDecoder().decode(bytes), apply(ctx) { console.log('applied', typeof ctx.on) } }
+      `,
+    })
+    expect(result.isError).toBe(false)
+    expect(text(result)).toContain('plugin "codec-hi"')
+    expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'warned')
+    expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'applied', 'function')
+    expect(error).toHaveBeenCalledWith('[cordis:dyn-1]', 'errored')
+  })
+
+  it('answers TypeScript syntax in the plain-JS sandbox with the fix', async () => {
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', {
+      code: 'return { name: \'ts\' as const, apply(ctx) {} }',
+    })
+    expect(result.isError).toBe(true)
+    expect(text(result)).toContain('plain JavaScript, not TypeScript')
+  })
+
+  it('surfaces the offending line + caret and the bracket-balance hint on a syntax error', async () => {
+    const ctx = await setup()
+    // The canonical model mistake: closing the returned object with `});` as
+    // if it were a callback argument. The word "as" in a STRING elsewhere must
+    // not trigger the TypeScript hint — the heuristic reads the failing line.
+    const result = await call(ctx, 'cordis_mount', {
+      code: 'const note = \'treat pattern as regex\'\nreturn {\n  name: \'oops\',\n  apply(ctx) {}\n});',
+    })
+    expect(result.isError).toBe(true)
+    const message = text(result)
+    expect(message).toContain('failed to parse')
+    expect(message).toContain('});')
+    expect(message).toContain('^')
+    expect(message).toContain('BODY of an async function')
+    expect(message).not.toContain('TypeScript')
+  })
+
+  it('syntaxErrorContext falls back to String(error) when the stack has no vm prelude', () => {
+    const doctored = new SyntaxError('boom')
+    delete (doctored as { stack?: string }).stack
+    expect(syntaxErrorContext(doctored)).toBe('SyntaxError: boom')
+    const plain = new SyntaxError('bang')
+    plain.stack = 'not-a-vm-stack'
+    expect(syntaxErrorContext(plain)).toBe('SyntaxError: bang')
+  })
+
+  it('handles a runtime-thrown SyntaxError (no source-line prelude) with the generic hint', async () => {
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', { code: 'throw new SyntaxError(\'user-crafted\')' })
+    expect(result.isError).toBe(true)
+    expect(text(result)).toContain('failed to parse')
+    expect(text(result)).toContain('user-crafted')
+  })
+
+  it('honors the configured vmTimeoutMs for the synchronous portion', async () => {
+    const ctx = await setup({ vmTimeoutMs: 50 })
+    const result = await call(ctx, 'cordis_mount', { code: 'while (true) {}' })
+    expect(result.isError).toBe(true)
+    expect(text(result)).toMatch(/timed? ?out/i)
+    expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)')
+  })
+
+  it('makes instanceof inside the sandbox see BOTH realms (patched vm constructors, host untouched)', async () => {
+    // The args a tool's execute receives are HOST-realm objects; without the
+    // dual-realm Symbol.hasInstance prelude, `args.items instanceof Array` in
+    // sandbox code is silently false. The patch lives on the vm realm's own
+    // constructors only — the host realm's must stay pristine.
+    const ctx = await setup()
+    await call(ctx, 'cordis_mount', {
+      code: `
+        return {
+          name: 'probe-instanceof',
+          inject: ['tools'],
+          apply(ctx) {
+            harness.registerTool(ctx, harness.defineTool({
+              name: 'probe_instanceof',
+              description: 'report instanceof checks across realms',
+              parameters: { items: { type: 'array', required: true, items: { type: 'string' } } },
+              async execute(args) {
+                const checks = {
+                  hostArray: args.items instanceof Array,
+                  hostObject: args instanceof Object,
+                  vmArray: [] instanceof Array,
+                  vmObject: ({}) instanceof Object,
+                }
+                return [{ type: 'text', text: JSON.stringify(checks) }]
+              },
+            }))
+          },
+        }
+      `,
+    })
+    const probed = await call(ctx, 'probe_instanceof', { items: ['a'] })
+    expect(probed.isError).toBe(false)
+    expect(JSON.parse(text(probed))).toEqual({ hostArray: true, hostObject: true, vmArray: true, vmObject: true })
+    // The host realm's constructors keep their default instanceof: no own
+    // Symbol.hasInstance was added to them.
+    expect(Object.getOwnPropertySymbols(Object)).not.toContain(Symbol.hasInstance)
+    expect(Object.getOwnPropertySymbols(Array)).not.toContain(Symbol.hasInstance)
+  })
+})

+ 41 - 0
packages/cordis/tool-cordis/tests/present.spec.ts

@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest'
+import { presentInspectCall, presentMountCall, presentUnmountCall } from '../src/present.ts'
+import { setup } from './helpers.ts'
+
+/**
+ * Render-intent presenters: pure functions of the call args (no I/O, no
+ * session state — they run on replay too), wired onto the registered tools.
+ */
+
+describe('presenters', () => {
+  it('cordis_inspect renders a generic read card titled with the section', () => {
+    expect(presentInspectCall({})).toEqual({ card: 'generic', kind: 'read', title: 'Inspect cordis runtime' })
+    expect(presentInspectCall({ what: 'api' })).toEqual({ card: 'generic', kind: 'read', title: 'Inspect cordis runtime: api' })
+  })
+
+  it('cordis_mount renders a generic execute card carrying the code as raw input', () => {
+    expect(presentMountCall({ code: 'return (ctx) => {}' })).toEqual({
+      card: 'generic',
+      kind: 'execute',
+      title: 'Mount plugin into live cordis runtime',
+      rawInput: { code: 'return (ctx) => {}' },
+    })
+  })
+
+  it('cordis_unmount renders a generic delete card titled with the id', () => {
+    expect(presentUnmountCall({ id: 'dyn-1' })).toEqual({ card: 'generic', kind: 'delete', title: 'Unmount dyn-1' })
+  })
+
+  it('is wired onto the registered definitions through the defineTool soft-validation path', async () => {
+    const ctx = await setup()
+    expect(ctx.tools.get('cordis_inspect')!.presentCall!({ what: 'tools' })).toEqual({
+      card: 'generic',
+      kind: 'read',
+      title: 'Inspect cordis runtime: tools',
+    })
+    expect(ctx.tools.get('cordis_mount')!.presentCall!({ code: 'return 1' })).toMatchObject({ kind: 'execute' })
+    expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 'dyn-2' })).toMatchObject({ title: 'Unmount dyn-2' })
+    // Soft validation: presenter args that fail the schema render as no card, never a throw.
+    expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 42 })).toBeUndefined()
+  })
+})

+ 295 - 0
packages/cordis/tool-cordis/tests/sandbox-context.spec.ts

@@ -0,0 +1,295 @@
+import { describe, expect, it } from 'vitest'
+import { call, setup, text } from './helpers.ts'
+
+/**
+ * The sandbox context façade is a whitelist, not a pass-through proxy: mount
+ * code reaches only the registration/eventing verbs, the timer helpers, a
+ * guarded `tools`, and its injected services. Every framework-plumbing member
+ * that could hand back an UNGUARDED context — through which a plugin could
+ * `ctx.<escape>.tools.register({…})` to bypass the marker check and host-realm
+ * normalization — is denied. These are the regression guards for that escape
+ * class (the review finding on the original pass-through proxy).
+ */
+
+/** Mount a plugin whose `apply` touches one framework member, and report the error text. */
+async function mountTouching(ctx: Awaited<ReturnType<typeof setup>>, expr: string): Promise<string> {
+  const result = await call(ctx, 'cordis_mount', {
+    code: `return { name: 'probe', inject: ['tools'], apply(ctx) { ${expr} } }`,
+  })
+  expect(result.isError).toBe(true)
+  return text(result)
+}
+
+describe('sandbox context façade — escape surface is closed', () => {
+  it.each([
+    ['ctx.root', 'const c = ctx.root'],
+    ['ctx.parent', 'const c = ctx.parent'],
+    ['ctx.scope', 'const c = ctx.scope'],
+    ['ctx.fiber', 'const f = ctx.fiber'],
+    ['ctx.reflect', 'const r = ctx.reflect'],
+    ['ctx.registry', 'const r = ctx.registry'],
+    ['ctx.events', 'const e = ctx.events'],
+    ['ctx.extend()', 'ctx.extend({})'],
+    ['ctx.isolate()', 'ctx.isolate("x")'],
+    ['ctx.intercept()', 'ctx.intercept("x", {})'],
+    ['ctx.plugin()', 'ctx.plugin({ apply() {} })'],
+    ['ctx.set()', 'ctx.set("tools", 1)'],
+    ['ctx.mixin()', 'ctx.mixin("x", [])'],
+  ])('denies %s with a teaching error', async (_label, expr) => {
+    const ctx = await setup()
+    const message = await mountTouching(ctx, expr)
+    expect(message).toContain('sandbox ctx does not expose')
+    expect(message).toContain('withheld by design')
+  })
+
+  it('the classic ctx.root.tools.register bypass registers nothing and fails loud', async () => {
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', {
+      code: `
+        return {
+          name: 'root-bypass',
+          inject: ['tools'],
+          apply(ctx) {
+            ctx.root.tools.register({
+              name: 'smuggled',
+              description: 'raw, unguarded',
+              parameters: { type: 'object', properties: {} },
+              async execute() { return [] },
+            })
+          },
+        }
+      `,
+    })
+    expect(result.isError).toBe(true)
+    expect(text(result)).toContain('sandbox ctx does not expose "root"')
+    // The whole point: the bypass never reaches the registry.
+    expect(ctx.tools.get('smuggled')).toBeUndefined()
+  })
+
+  it('rejects assignment to the façade rather than silently dropping it', async () => {
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', {
+      code: 'return { name: \'writer\', apply(ctx) { ctx.stash = 1 } }',
+    })
+    expect(result.isError).toBe(true)
+    expect(text(result)).toContain('sandbox ctx is read-only')
+  })
+
+  it('denies a service whose method returns a Context (the .ctx escape), registering nothing', async () => {
+    // A cordis Service instance carries `.ctx` (a real Context), so
+    // `ctx.systemPrompt.ctx.root.tools.register(…)` would be a fresh unguarded
+    // handle. The service wrapper's return-value guard rejects any Context on
+    // the way back to sandbox code, so the escape never lands. (`systemPrompt`
+    // is in the setup harness, so the plugin activates and its apply runs.)
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', {
+      code: `
+        return {
+          name: 'svc-ctx-escape',
+          inject: ['systemPrompt', 'tools'],
+          apply(ctx) {
+            ctx.systemPrompt.ctx.root.tools.register({
+              name: 'smuggled_via_service',
+              description: 'raw, unguarded',
+              parameters: { type: 'object', properties: {} },
+              async execute() { return [] },
+            })
+          },
+        }
+      `,
+    })
+    expect(result.isError).toBe(true)
+    expect(text(result)).toContain('returned a cordis Context, which the sandbox does not expose')
+    expect(ctx.tools.get('smuggled_via_service')).toBeUndefined()
+  })
+
+  it('guards an async injected-service method: a host-realm Promise resolves through the guard', async () => {
+    // The return guard's Promise arm only fires for a HOST-realm Promise
+    // (a vm-realm one is not `instanceof` the host `Promise`). Provide a
+    // host-realm service from the test, then inject + await it from a mount:
+    // the resolved value is non-Context data and passes through.
+    const ctx = await setup()
+    ctx.plugin({
+      name: 'host-async-svc',
+      apply(c) { c.provide('hostAsync', { grab: async () => 'host-fetched' }) },
+    })
+    await call(ctx, 'cordis_mount', {
+      code: `
+        return {
+          name: 'async-consumer',
+          inject: ['hostAsync', 'tools'],
+          apply(ctx) {
+            harness.registerTool(ctx, harness.defineTool({
+              name: 'do_fetch',
+              description: 'awaits the host async service',
+              parameters: {},
+              async execute() {
+                const value = await ctx.hostAsync.grab()
+                return [{ type: 'text', text: value }]
+              },
+            }))
+          },
+        }
+      `,
+    })
+    const result = await call(ctx, 'do_fetch', {})
+    expect(result.isError).toBe(false)
+    expect(text(result)).toBe('host-fetched')
+  })
+
+  it('reads a symbol property as undefined and answers the `in` operator without throwing', async () => {
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', {
+      code: `
+        return {
+          name: 'introspector',
+          inject: ['tools'],
+          apply(ctx) {
+            const sym = ctx[Symbol.iterator]
+            console.log('probe', sym === undefined, 'tools' in ctx, 'on' in ctx, 'root' in ctx)
+          },
+        }
+      `,
+    })
+    expect(result.isError).toBe(false)
+  })
+})
+
+describe('sandbox context façade — inject gate on services', () => {
+  it('denies an undeclared live service (property access), naming the inject fix', async () => {
+    // `systemPrompt` is a live global service in the setup harness, but this
+    // mount does not declare it — reaching it would let the mount depend on a
+    // provider cordis does not know about, so it is refused.
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', {
+      code: 'return { name: \'undeclared\', inject: [\'tools\'], apply(ctx) { const s = ctx.systemPrompt } }',
+    })
+    expect(result.isError).toBe(true)
+    expect(text(result)).toContain('service "systemPrompt" is not injected')
+    expect(text(result)).toContain('inject: [\'systemPrompt\', …]')
+  })
+
+  it('denies an undeclared live service reached through ctx.get too', async () => {
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', {
+      code: 'return { name: \'undeclared-get\', inject: [\'tools\'], apply(ctx) { ctx.get(\'systemPrompt\') } }',
+    })
+    expect(result.isError).toBe(true)
+    expect(text(result)).toContain('service "systemPrompt" is not injected')
+  })
+
+  it('allows a service the mount DID declare in inject', async () => {
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', {
+      code: `
+        return {
+          name: 'declared',
+          inject: ['systemPrompt', 'tools'],
+          apply(ctx) { console.log('has systemPrompt:', typeof ctx.systemPrompt) }
+        }
+      `,
+    })
+    expect(result.isError).toBe(false)
+    expect(text(result)).toContain('state: active')
+  })
+
+  it('a cross-mount consumer must declare the provider — the undeclared path is refused, not left as a zombie tool', async () => {
+    // The finding's scenario: a consumer registers a tool built on a provider's
+    // service WITHOUT declaring inject. cordis would then never park the
+    // consumer when the provider unmounts, leaving a tool that fails only at
+    // execution. The gate refuses the undeclared access up front, so the
+    // dependency is always visible to cordis.
+    const ctx = await setup()
+    await call(ctx, 'cordis_mount', {
+      code: 'return { name: \'greeter-provider\', apply(ctx) { ctx.provide(\'greeter\', { greet: (n) => \'hi \' + n }) } }',
+    })
+    const undeclared = await call(ctx, 'cordis_mount', {
+      code: `
+        return {
+          name: 'sloppy-consumer',
+          inject: ['tools'],
+          apply(ctx) {
+            harness.registerTool(ctx, harness.defineTool({
+              name: 'greet_undeclared',
+              description: 'uses greeter without declaring it',
+              parameters: { n: { type: 'string', required: true } },
+              async execute(args) { return [{ type: 'text', text: ctx.greeter.greet(args.n) }] },
+            }))
+          },
+        }
+      `,
+    })
+    // The tool registers (its execute is lazy), but calling it hits the gate:
+    // `ctx.greeter` is undeclared, so it fails with the teaching error rather
+    // than silently working and later stranding.
+    expect(undeclared.isError).toBe(false)
+    const called = await call(ctx, 'greet_undeclared', { n: 'x' })
+    expect(called.isError).toBe(true)
+    expect(text(called)).toContain('service "greeter" is not injected')
+  })
+})
+
+describe('sandbox tools façade — get is a read-only schema view', () => {
+  it('ctx.tools.get returns a schema, not the live ToolDefinition with execute', async () => {
+    // The finding: returning the raw ToolDefinition hands mount code the
+    // tool's execute function, letting it bypass ToolRegistry.execute (and its
+    // pre/post hooks). get now returns the same name/description/parameters
+    // view as schemas(), with no execute. Asserted via a self-made tool that
+    // reports the shape it saw — world-checked, not self-reported.
+    const ctx = await setup()
+    await call(ctx, 'cordis_mount', {
+      code: `
+        return {
+          name: 'reporter',
+          inject: ['tools'],
+          apply(ctx) {
+            harness.registerTool(ctx, harness.defineTool({
+              name: 'report_view',
+              description: 'reports the shape of a tool view',
+              parameters: {},
+              async execute() {
+                const view = ctx.tools.get('cordis_mount')
+                return [{ type: 'text', text: JSON.stringify({
+                  hasExecute: 'execute' in view,
+                  hasPresentCall: 'presentCall' in view,
+                  name: view.name,
+                  keys: Object.keys(view).sort(),
+                }) }]
+              },
+            }))
+          },
+        }
+      `,
+    })
+    const reported = await call(ctx, 'report_view', {})
+    expect(reported.isError).toBe(false)
+    const shape = JSON.parse(text(reported)) as { hasExecute: boolean; hasPresentCall: boolean; name: string; keys: string[] }
+    expect(shape.hasExecute).toBe(false)
+    expect(shape.hasPresentCall).toBe(false)
+    expect(shape.name).toBe('cordis_mount')
+    expect(shape.keys).toEqual(['description', 'name', 'parameters'])
+  })
+
+  it('ctx.tools.get returns undefined for an unknown tool', async () => {
+    const ctx = await setup()
+    await call(ctx, 'cordis_mount', {
+      code: `
+        return {
+          name: 'unknown-probe',
+          inject: ['tools'],
+          apply(ctx) {
+            harness.registerTool(ctx, harness.defineTool({
+              name: 'probe_unknown',
+              description: 'reports whether an unknown tool resolves',
+              parameters: {},
+              async execute() {
+                return [{ type: 'text', text: String(ctx.tools.get('no_such_tool') === undefined) }]
+              },
+            }))
+          },
+        }
+      `,
+    })
+    expect(text(await call(ctx, 'probe_unknown', {}))).toBe('true')
+  })
+})

+ 49 - 0
packages/cordis/tool-cordis/tests/tool-cordis.spec.ts

@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest'
+import Loader from '@cordisjs/plugin-loader'
+import * as tool from '../src/index.ts'
+import { setup } from './helpers.ts'
+
+/**
+ * Export-shape and registration surface: the namespace-plugin contract the
+ * real Loader path depends on, the registered tool set, and the Config
+ * validator's defaults and rejections.
+ */
+
+describe('export shape', () => {
+  it('has no default export, and survives the real Loader unwrapExports', () => {
+    // A stray `export default` would make `unwrapExports` (`exports.default ??
+    // exports`) collapse the module to the bare function and DROP `inject`,
+    // crashing at real load (docs/postmortem/0001). Assert directly AND through
+    // the real unwrap so adding `export default apply` fails here.
+    expect('default' in tool).toBe(false)
+    const loader = Object.create(Loader.prototype) as Loader
+    const unwrapped = loader.unwrapExports(tool) as Record<string, unknown>
+    expect(unwrapped).toBe(tool)
+    expect(unwrapped.name).toBe('tool-cordis')
+    expect(unwrapped.inject).toEqual(['tools'])
+    expect(typeof unwrapped.apply).toBe('function')
+    expect(typeof unwrapped.Config).toBe('function')
+  })
+})
+
+describe('tool registration', () => {
+  it('registers the three cordis tools with the documented schemas', async () => {
+    const ctx = await setup()
+    const names = ctx.tools.schemas().map(schema => schema.name)
+    expect(names).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount']))
+    const inspect = ctx.tools.schemas().find(schema => schema.name === 'cordis_inspect')!
+    const props = (inspect.parameters as { properties: Record<string, { enum?: string[] }> }).properties
+    expect(props.what?.enum).toEqual(['services', 'plugins', 'tools', 'dynamic', 'api', 'events'])
+  })
+})
+
+describe('Config', () => {
+  it('defaults vmTimeoutMs to 5000', () => {
+    expect(new tool.Config()).toEqual({ vmTimeoutMs: 5000 })
+  })
+
+  it('rejects a non-positive vmTimeoutMs at validation time (misconfiguration fails loud)', () => {
+    expect(() => new tool.Config({ vmTimeoutMs: 0 })).toThrow()
+    expect(() => new tool.Config({ vmTimeoutMs: -1 })).toThrow()
+  })
+})

+ 82 - 0
packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts

@@ -0,0 +1,82 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Context } from 'cordis'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import * as tool from '../src/index.ts'
+import { call, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts'
+
+/**
+ * Disposal semantics: `cordis_unmount` reaches quiescence before returning,
+ * and disposing the tool-cordis fiber itself (the HMR path) cascades over the
+ * whole dynamic subtree through the ordinary parent→child fiber lifecycle.
+ */
+
+afterEach(() => {
+  vi.restoreAllMocks()
+})
+
+describe('cordis_unmount', () => {
+  it('disposes the mount and its registrations have stopped by the time it returns (quiescence)', async () => {
+    const ctx = await setup()
+    const log = vi.spyOn(console, 'log').mockImplementation(() => {})
+    await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
+
+    ctx.tools.register(dummyTool('trigger_before'))
+    expect(log).toHaveBeenCalledTimes(1)
+
+    const result = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
+    expect(result.isError).toBe(false)
+    expect(text(result)).toContain('unmounted dyn-1')
+
+    // Immediately after the awaited unmount, the listener must be gone — no
+    // grace period, no eventual consistency.
+    ctx.tools.register(dummyTool('trigger_after'))
+    expect(log).toHaveBeenCalledTimes(1)
+    expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)')
+  })
+
+  it('unregisters a self-made tool on unmount', async () => {
+    const ctx = await setup()
+    await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
+    expect(ctx.tools.get('reverse_text')).toBeDefined()
+
+    await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
+    expect(ctx.tools.get('reverse_text')).toBeUndefined()
+  })
+
+  it('rejects an unknown id, and a second unmount of the same id', async () => {
+    const ctx = await setup()
+    const unknown = await call(ctx, 'cordis_unmount', { id: 'dyn-99' })
+    expect(unknown.isError).toBe(true)
+    expect(text(unknown)).toContain('no dynamic plugin with id "dyn-99"')
+
+    await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
+    await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
+    const again = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
+    expect(again.isError).toBe(true)
+  })
+})
+
+describe('HMR safety', () => {
+  it('disposing the tool-cordis fiber cascades over the dynamic subtree and its registrations', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry)
+    const fiber = await ctx.plugin(tool)
+
+    const log = vi.spyOn(console, 'log').mockImplementation(() => {})
+    await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
+    await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
+    expect(ctx.tools.get('reverse_text')).toBeDefined()
+
+    await fiber.dispose()
+
+    // The whole subtree is gone: the self-made tool, the cordis tools, and the
+    // mounted listener (no log on a fresh tools/change).
+    expect(ctx.tools.get('reverse_text')).toBeUndefined()
+    expect(ctx.tools.get('cordis_mount')).toBeUndefined()
+    const calls = log.mock.calls.length
+    ctx.tools.register(dummyTool('trigger_post_dispose'))
+    expect(log).toHaveBeenCalledTimes(calls)
+  })
+})

+ 27 - 0
packages/cordis/tool-cordis/tsconfig.json

@@ -0,0 +1,27 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cosmokit"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/timer"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../../core/tools"
+    }
+  ]
+}

+ 1 - 1
packages/core/tools/tests/gen-tool-catalog.spec.ts

@@ -35,7 +35,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
   it('boots every shipped tool package and harvests its model-facing schemas', async () => {
     const catalog = await collectToolCatalog()
     const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
-    expect(names).toEqual(['ask_user_question', 'bash', 'bash_kill', 'bash_output', 'edit', 'read', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'write'])
+    expect(names).toEqual(['ask_user_question', 'bash', 'bash_kill', 'bash_output', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'read', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'write'])
     // Every tool carries a JSON-Schema `parameters` object (what the model sees).
     for (const entry of catalog) {
       for (const schema of entry.schemas) {

+ 34 - 0
pnpm-lock.yaml

@@ -191,6 +191,40 @@ importers:
         specifier: ^4.0.0-rc.6
         version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
 
+  packages/cordis/tool-cordis:
+    dependencies:
+      schemastery:
+        specifier: ^3.18.0
+        version: 3.18.0
+    devDependencies:
+      '@cordisjs/plugin-loader':
+        specifier: ^1.0.0-rc.4
+        version: 1.0.0-rc.4(cordis@4.0.0-rc.6)
+      '@cordisjs/plugin-timer':
+        specifier: workspace:^
+        version: link:../../../vendor/timer
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
+      '@deepseek-ai/dsh-agent-loop':
+        specifier: workspace:^
+        version: link:../../core/agent-loop
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@deepseek-ai/dsh-system-prompt':
+        specifier: workspace:^
+        version: link:../../core/system-prompt
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:^
+        version: link:../../core/tools
+      cordis:
+        specifier: ^4.0.0-rc.6
+        version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
+
   packages/core/agent:
     devDependencies:
       '@deepseek-ai/dsh-brand':

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

@@ -1,11 +1,11 @@
 {
-  "AGENTS.md": 1691,
+  "AGENTS.md": 1802,
   "docs/AGENTS.md": 1315,
   "docs/architecture.md": 1640,
   "docs/cordis-primer.md": 550,
   "docs/defensive-patterns.md": 550,
   "docs/testing.md": 800,
-  "examples/AGENTS.md": 610,
+  "examples/AGENTS.md": 653,
   "packages/AGENTS.md": 450,
-  "packages/README.md": 610
+  "packages/README.md": 660
 }

+ 247 - 0
scripts/gen-cordis-api.ts

@@ -0,0 +1,247 @@
+/**
+ * Generate (and verify) the runtime cordis API catalog the `cordis_inspect`
+ * tool serves to the model: packages/cordis/tool-cordis/src/api-catalog.ts.
+ *
+ * The artifact is the machine-readable sibling of docs/cordis-catalog: it
+ * reuses `collectServices` / `collectEvents` from `gen-cordis-catalog.ts` (the
+ * same JSDoc-completeness-enforcing AST walk), so the API the model reads at
+ * runtime and the API the docs render cannot diverge. Emitted as a typed
+ * TypeScript data module (not JSON): it compiles under the package tsconfig,
+ * passes lint and the export-JSDoc gate, and is trivially covered by import.
+ *
+ * The data is trimmed for a model-facing text surface: per service the
+ * `ctx.<key>` name, the first sentence of the class doc, and the raw method
+ * signatures; per event the name, `@mode`, signature, and first sentence of
+ * doc; the SHAPES of every exported interface/type-alias the service
+ * signatures reference (transitively — so a model can see that e.g. a
+ * `BashRunResult.stdout` is `{ text, truncated }`, not a string); plus the
+ * curated inherited `ctx` surface shared with the docs catalog. Source
+ * pointers are dropped (a `file:line` means nothing to the model) and entries
+ * are sorted deterministically.
+ *
+ *   `tsx scripts/gen-cordis-api.ts`          → write the artifact
+ *   `tsx scripts/gen-cordis-api.ts --check`  → exit 1 if the committed file is
+ *                                              stale (CI / pre-push gate)
+ */
+
+import { globSync, readFileSync, writeFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import ts from 'typescript'
+import { collectEvents, collectServices, INHERITED_SERVICES } from './gen-cordis-catalog.ts'
+
+const root = resolve(import.meta.dirname, '..')
+const OUT = 'packages/cordis/tool-cordis/src/api-catalog.ts'
+
+/** Declarations longer than this render as a truncated stub — a shape the model cannot skim teaches nothing. */
+const MAX_DECL_CHARS = 1500
+
+/** The first sentence of a (possibly multi-line) JSDoc prose block. */
+function firstSentence(doc: string): string {
+  const line = doc.split('\n', 1)[0] ?? ''
+  const match = /^(.*?[.!?])(?:\s|$)/.exec(line)
+  return (match?.[1] ?? line).trim()
+}
+
+/** Render a string as a single-quoted, lint-clean TS literal. */
+function quote(value: string): string {
+  return `'${value.replace(/\\/g, '\\\\').replace(/'/g, '\\\'').replace(/\n/g, '\\n')}'`
+}
+
+/**
+ * Every exported `interface` / `type` declaration under `packages/<group>/<pkg>/src`,
+ * printed without comments, keyed by name. A name declared in more than one
+ * package (e.g. each plugin's `Config`) is ambiguous and dropped entirely —
+ * serving the wrong package's shape is worse than serving none.
+ */
+function collectTypeDecls(scanRoot: string = root): Map<string, string> {
+  const printer = ts.createPrinter({ removeComments: true })
+  const decls = new Map<string, string>()
+  const ambiguous = new Set<string>()
+  for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).sort()) {
+    const abs = resolve(scanRoot, rel)
+    const sf = ts.createSourceFile(abs, readFileSync(abs, 'utf8'), ts.ScriptTarget.Latest, true)
+    for (const stmt of sf.statements) {
+      if (!ts.isInterfaceDeclaration(stmt) && !ts.isTypeAliasDeclaration(stmt)) continue
+      if (!(stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)) continue
+      const name = stmt.name.text
+      if (decls.has(name)) {
+        ambiguous.add(name)
+        continue
+      }
+      const printed = printer.printNode(ts.EmitHint.Unspecified, stmt, sf).replace(/\r/g, '')
+      decls.set(name, printed.length > MAX_DECL_CHARS
+        ? `${printed.slice(0, MAX_DECL_CHARS)} /* …truncated — full shape in source */`
+        : printed)
+    }
+  }
+  for (const name of ambiguous) decls.delete(name)
+  return decls
+}
+
+/**
+ * The transitive closure of type names referenced by the seed texts: every
+ * collected declaration whose name appears (word-bounded) in a seed or in an
+ * already-included declaration, sorted by name.
+ */
+function referencedTypes(seeds: string[], decls: Map<string, string>): { name: string; declaration: string }[] {
+  const included = new Map<string, string>()
+  let frontier = seeds
+  while (frontier.length > 0) {
+    const next: string[] = []
+    for (const [name, declaration] of decls) {
+      if (included.has(name)) continue
+      const pattern = new RegExp(`\\b${name}\\b`)
+      if (frontier.some(text => pattern.test(text))) {
+        included.set(name, declaration)
+        next.push(declaration)
+      }
+    }
+    frontier = next
+  }
+  return [...included].map(([name, declaration]) => ({ name, declaration })).sort((a, b) => a.name.localeCompare(b.name))
+}
+
+/** Render the whole generated module (pure, deterministic given sorted collector output). */
+function render(): string {
+  const services = collectServices()
+  const events = collectEvents().sort((a, b) => a.name.localeCompare(b.name))
+  const types = referencedTypes(services.flatMap(service => service.methods), collectTypeDecls())
+  const lines: string[] = [
+    '/**',
+    ' * Generated by scripts/gen-cordis-api.ts — do not edit by hand; run',
+    ' * `pnpm run gen-cordis-api` to regenerate (freshness-gated by',
+    ' * `pnpm run verify-cordis-api` in doc-sync).',
+    ' *',
+    ' * The machine-readable cordis API catalog `cordis_inspect` serves to the',
+    ' * model: harness services (summary + public method signatures), harness',
+    ' * events (mode + signature), and the inherited `ctx` surface. Produced by',
+    ' * the same AST walk as docs/cordis-catalog, so this data and the rendered',
+    ' * docs cannot diverge.',
+    ' *',
+    ' * @module @deepseek-ai/dsh-tool-cordis/api-catalog',
+    ' */',
+    '',
+    '/** One harness `ctx.<key>` service: its one-line summary and public method signatures. */',
+    'export interface ServiceApiEntry {',
+    '  /** The `ctx.<key>` name, e.g. `tools`. */',
+    '  key: string',
+    '  /** First sentence of the service class JSDoc. */',
+    '  summary: string',
+    '  /** Public method signatures, bodies stripped, in source order. */',
+    '  methods: readonly string[]',
+    '}',
+    '',
+    '/** One harness event: its dispatch mode, exact signature, and one-line summary. */',
+    'export interface EventApiEntry {',
+    '  /** The scoped event name, e.g. `agent/status`. */',
+    '  name: string',
+    '  /** The dispatch mode from the declaration\'s `@mode` tag. */',
+    '  mode: string',
+    '  /** The exact listener signature, whitespace-normalized. */',
+    '  signature: string',
+    '  /** First sentence of the event JSDoc. */',
+    '  summary: string',
+    '}',
+    '',
+    '/** One inherited (cordis core + loader/hmr/timer) `ctx` member group with its summary. */',
+    'export interface InheritedApiEntry {',
+    '  /** The `ctx` member name(s), e.g. `ctx.on / ctx.once`. */',
+    '  name: string',
+    '  /** One-line summary of what the member does. */',
+    '  summary: string',
+    '}',
+    '',
+    '/** One named type shape the service signatures reference. */',
+    'export interface TypeApiEntry {',
+    '  /** The exported type/interface name, e.g. `BashRunResult`. */',
+    '  name: string',
+    '  /** The full declaration text, comments stripped. */',
+    '  declaration: string',
+    '}',
+    '',
+    '/** Every harness `ctx.<key>` service, sorted by key. */',
+    'export const SERVICE_API: readonly ServiceApiEntry[] = [',
+  ]
+  for (const service of services) {
+    lines.push('  {')
+    lines.push(`    key: ${quote(service.key)},`)
+    lines.push(`    summary: ${quote(firstSentence(service.doc))},`)
+    if (service.methods.length === 0) {
+      lines.push('    methods: [],')
+    } else {
+      lines.push('    methods: [')
+      for (const method of service.methods) lines.push(`      ${quote(method)},`)
+      lines.push('    ],')
+    }
+    lines.push('  },')
+  }
+  lines.push(
+    ']',
+    '',
+    '/** Every harness event, sorted by name. */',
+    'export const EVENT_API: readonly EventApiEntry[] = [',
+  )
+  for (const event of events) {
+    lines.push('  {')
+    lines.push(`    name: ${quote(event.name)},`)
+    lines.push(`    mode: ${quote(event.mode)},`)
+    lines.push(`    signature: ${quote(event.signature)},`)
+    lines.push(`    summary: ${quote(firstSentence(event.doc))},`)
+    lines.push('  },')
+  }
+  lines.push(
+    ']',
+    '',
+    '/** Shapes of every exported type the SERVICE_API signatures reference (transitively), sorted by name. */',
+    'export const TYPE_API: readonly TypeApiEntry[] = [',
+  )
+  for (const type of types) {
+    lines.push('  {')
+    lines.push(`    name: ${quote(type.name)},`)
+    lines.push(`    declaration: ${quote(type.declaration)},`)
+    lines.push('  },')
+  }
+  lines.push(
+    ']',
+    '',
+    '/** The inherited `ctx` surface (cordis core + loader/hmr/timer), in curated order. */',
+    'export const INHERITED_CTX_API: readonly InheritedApiEntry[] = [',
+  )
+  for (const inherited of INHERITED_SERVICES) {
+    lines.push(`  { name: ${quote(inherited.name)}, summary: ${quote(inherited.summary)} },`)
+  }
+  lines.push(']', '')
+  return lines.join('\n')
+}
+
+/** CLI entry: default writes the artifact, `--check` fails if the committed
+ * copy is stale. Guarded behind an entry-point check so importing this module
+ * for tests neither regenerates the committed file nor calls process.exit. */
+function main(): void {
+  const content = render()
+  if (process.argv.includes('--check')) {
+    let committed: string | null = null
+    try {
+      committed = readFileSync(resolve(root, OUT), 'utf8')
+    } catch {
+      // Only ENOENT (not yet generated) is expected; a present-but-unreadable
+      // file is not a state this repo produces. Either way the remedy is the
+      // same — regenerate — so treat a read failure as "stale".
+      committed = null
+    }
+    if (committed === content) {
+      console.log(`gen-cordis-api: ${OUT} is up to date.`)
+      process.exit(0)
+    }
+    console.error(`gen-cordis-api: ${OUT} is stale. Run \`pnpm run gen-cordis-api\` and commit ${OUT}.`)
+    process.exit(1)
+  }
+
+  writeFileSync(resolve(root, OUT), content)
+  console.log(`gen-cordis-api: wrote ${OUT}.`)
+}
+
+// Run only when invoked as a script, not when imported by a test.
+if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
+  main()
+}

+ 1 - 1
scripts/gen-cordis-catalog.ts

@@ -327,7 +327,7 @@ const INHERITED_EVENTS: InheritedEntry[] = [
   { name: 'loader/patch-context', summary: 'A context is being patched during a reload.', source: 'vendor/loader/src/index.ts:27' },
 ]
 
-const INHERITED_SERVICES: InheritedEntry[] = [
+export const INHERITED_SERVICES: InheritedEntry[] = [
   { name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:29' },
   { name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:29' },
   { name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:144' },

+ 12 - 1
scripts/gen-doc-graphs.ts

@@ -75,6 +75,7 @@ const GROUP_ORDER = [
   'subagent',
   'web',
   'todo',
+  'cordis',
   'hooks',
   'session-persistence',
   'support',
@@ -121,7 +122,7 @@ const SERVICE_ROLES: ServiceRole[] = [
     pkg: 'tools',
     title: 'Tool registry and execution waterfall',
     mode: 'core',
-    consumers: ['agent-loop', 'tool-ask-user', 'tool-bash', 'tool-fs', 'tool-subagent', 'tool-todo', 'tool-web', 'acp'],
+    consumers: ['agent-loop', 'tool-ask-user', 'tool-bash', 'tool-cordis', 'tool-fs', 'tool-subagent', 'tool-todo', 'tool-web', 'acp'],
     note: 'Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute.',
   },
   {
@@ -418,6 +419,14 @@ const APP_EXAMPLES = [
     config: 'examples/coding-agent/cordis.yml',
     summary: 'The coding REPL demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package.',
   },
+  {
+    id: 'cordis',
+    rel: 'examples/cordis-agent/composition.md',
+    title: 'Cordis Agent App Composition',
+    label: 'examples/cordis-agent',
+    config: 'examples/cordis-agent/cordis.yml',
+    summary: 'The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its own runtime and mount/unmount plugins into it.',
+  },
   {
     id: 'acp',
     rel: 'examples/acp-agent/composition.md',
@@ -727,6 +736,7 @@ function renderIndex(docs: GraphDoc[]): string {
     'docs/capability-seams.md': 'capability seams and core services',
     'examples/echo-agent/composition.md': 'echo-agent app composition',
     'examples/coding-agent/composition.md': 'coding-agent app composition',
+    'examples/cordis-agent/composition.md': 'cordis-agent app composition',
     'examples/acp-agent/composition.md': 'acp-agent app composition',
     'docs/event-producer-consumer.md': 'event producer/consumer matrix',
     'docs/agent-lifecycle.md': 'agent turn and step lifecycle',
@@ -737,6 +747,7 @@ function renderIndex(docs: GraphDoc[]): string {
     'docs/capability-seams.md': 'hybrid generated',
     'examples/echo-agent/composition.md': 'hybrid generated',
     'examples/coding-agent/composition.md': 'hybrid generated',
+    'examples/cordis-agent/composition.md': 'hybrid generated',
     'examples/acp-agent/composition.md': 'hybrid generated',
     'docs/event-producer-consumer.md': 'hybrid generated',
     'docs/agent-lifecycle.md': 'curated',

+ 1 - 0
scripts/gen-module-graph.ts

@@ -47,6 +47,7 @@ const GROUP_ORDER = [
   'web',
   'timeout',
   'todo',
+  'cordis',
   'hooks',
   'session-persistence',
   'support',

+ 13 - 0
scripts/gen-tool-catalog.ts

@@ -49,6 +49,7 @@ import SubagentService from '@deepseek-ai/dsh-subagent'
 import * as SubagentMock from '@deepseek-ai/dsh-subagent-mock'
 import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
 import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
+import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
 import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
 import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
 import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
@@ -128,6 +129,18 @@ const TOOL_PACKAGES: ToolPackage[] = [
     note:
       'The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam.',
   },
+  {
+    pkg: '@deepseek-ai/dsh-tool-cordis',
+    dir: 'tool-cordis',
+    source: 'packages/cordis/tool-cordis/src/index.ts',
+    requires: ['ctx.tools'],
+    writes: ['tool/call', 'tool/result', 'live plugin-tree mutations (mount/unmount)'],
+    async mount(ctx) {
+      await ctx.plugin(ToolCordis)
+    },
+    note:
+      'Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes.',
+  },
   {
     pkg: '@deepseek-ai/dsh-tool-fs',
     dir: 'tool-fs',

+ 1 - 0
tsconfig.base.json

@@ -51,6 +51,7 @@
         "./packages/web/*/src",
         "./packages/timeout/*/src",
         "./packages/todo/*/src",
+        "./packages/cordis/*/src",
         "./packages/hooks/*/src",
         "./packages/session-persistence/*/src",
         "./packages/ui/*/src",

+ 1 - 0
tsconfig.build.json

@@ -60,6 +60,7 @@
     { "path": "./packages/subagent/subagent-acp" },
     { "path": "./packages/todo/tool-todo" },
     { "path": "./packages/guard/repeat-tool-guard" },
+    { "path": "./packages/cordis/tool-cordis" },
     { "path": "./packages/hooks/hook-protocol" },
     { "path": "./packages/hooks/hooks-claude" },
     { "path": "./packages/hooks/hooks-codex" }

+ 1 - 0
tsconfig.json

@@ -71,6 +71,7 @@
     { "path": "./packages/subagent/subagent-acp" },
     { "path": "./packages/todo/tool-todo" },
     { "path": "./packages/guard/repeat-tool-guard" },
+    { "path": "./packages/cordis/tool-cordis" },
     { "path": "./packages/hooks/hook-protocol" },
     { "path": "./packages/hooks/hooks-claude" },
     { "path": "./packages/hooks/hooks-codex" }

Некоторые файлы не были показаны из-за большого количества измененных файлов