Sfoglia il codice sorgente

feat(cordis): @deepseek-ai/dsh-tool-cordis — inspect/mount/unmount over the live runtime

New top-level packages/cordis/ group with the self-referential toolset:
cordis_inspect (services / plugin tree / tools / dynamic mounts / api / events,
the api section intersecting the generated catalog with the live service store),
cordis_mount (model-written code evaluated in a node:vm sandbox, mounted under
one cordis-dynamic group fiber as dyn-<n>), cordis_unmount (awaited disposal to
quiescence). Boundary mechanisms: dual-realm instanceof, JSON realm
normalization of dynamic tool results, marker-guarded registration, SchemaSpec
teaching errors, parse failures surfaced with the offending line + caret and a
line-scoped TypeScript hint, and the unmount-first recipe on tool-name
collisions. Config: vmTimeoutMs (schemastery, default 5000). Design record:
docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md.

The tool-catalog boot manifest, its regenerated output, and the pinned
tool-name list land here rather than with the other repo registration: the
completeness guard globs packages/*/tool-* and fails the generator (and the
core/tools spec) the moment the package directory exists.
imccyu 3 mesi fa
parent
commit
ee1da1ce5b

File diff suppressed because it is too large
+ 31 - 0
docs/tool-catalog.md


+ 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 plugin tree 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 plugin fiber tree (ASCII), 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. The `ctx` a mounted plugin's `apply` receives is the real, fully privileged runtime handle; 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:^"
+  }
+}

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

@@ -0,0 +1,786 @@
+/**
+ * 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: '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: '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: '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: '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: '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 (tree 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',
+}

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

@@ -0,0 +1,199 @@
+/**
+ * The registration boundary between sandboxed mount code and the real runtime:
+ * SchemaSpec validation with teaching errors, the marker-guarded
+ * `harness.defineTool` / `harness.registerTool` pair, the guarded `ctx` proxy a
+ * mounted plugin receives, and the plugin-shape helpers the mount lifecycle
+ * narrows sandbox return values with.
+ *
+ * Two realm facts drive the design. 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 before it reaches the registry. And a
+ * malformed tool schema must fail at REGISTRATION, not when a later request
+ * assembles it — so dynamic `ctx.tools.register` calls accept only definitions
+ * produced by the sandbox's `harness.defineTool`, which asserts the SchemaSpec
+ * DSL up front.
+ *
+ * @module @deepseek-ai/dsh-tool-cordis/guard
+ */
+
+import type { Context, 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'])
+
+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]'
+}
+
+/** Assert a sandbox-provided `parameters` value is a SchemaSpec object, with a teaching error for the common JSON-Schema mistake. */
+function assertSchemaSpec(value: unknown): void {
+  if (!isPlainRecord(value)) {
+    throw new Error('harness.defineTool parameters must be a SchemaSpec object')
+  }
+  if (value.type === 'object' && isPlainRecord(value.properties)) {
+    throw new Error(
+      'harness.defineTool parameters use the SchemaSpec DSL (NOT JSON Schema).\n'
+      + '  ✗ { type: \'object\', properties: { name: { type: \'string\' } }, required: [\'name\'] }\n'
+      + '  ✓ { name: { type: \'string\', required: true } }\n'
+      + 'Remove the outer { type: \'object\', properties, required } wrapper; '
+      + 'each key IS a property directly on the parameters object.',
+    )
+  }
+  for (const [key, prop] of Object.entries(value)) {
+    assertSchemaProp(prop, `parameters.${key}`)
+  }
+}
+
+function assertSchemaProp(value: unknown, path: string): void {
+  if (!isPlainRecord(value)) {
+    throw new Error(`harness.defineTool ${path} must be a SchemaSpec property object`)
+  }
+  if (!SCHEMA_TYPES.has(value.type)) {
+    throw new Error(`harness.defineTool ${path} must declare a valid type`)
+  }
+  if (value.required !== undefined && value.required !== true) {
+    throw new Error(`harness.defineTool ${path}.required must be true when present`)
+  }
+  if (value.properties !== undefined) {
+    if (value.type !== 'object') {
+      throw new Error(`harness.defineTool ${path}.properties is only valid for type "object"`)
+    }
+    assertSchemaSpec(value.properties)
+  }
+  if (value.items !== undefined) {
+    if (value.type !== 'array') {
+      throw new Error(`harness.defineTool ${path}.items is only valid for type "array"`)
+    }
+    assertSchemaProp(value.items, `${path}.items`)
+  }
+}
+
+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(...)')
+  }
+}
+
+/**
+ * The `harness.defineTool` handed into the sandbox: the real DSL, with the
+ * tool's `execute` return normalized into the host realm via a JSON round-trip
+ * (see the module doc). The round-trip also projects the return onto exactly
+ * what the log would durably store, so a non-JSON-serializable return surfaces
+ * as that one call's error instead of poisoning the turn.
+ * @param options - the standard `defineTool` options, with `parameters` asserted against the SchemaSpec DSL before the DSL sees them.
+ * @returns the marker-tagged definition `harness.registerTool` (and the guarded `ctx.tools.register`) accepts.
+ */
+export function sandboxDefineTool(options: Parameters<typeof defineTool>[0]): ToolDefinition {
+  assertSchemaSpec((options as { parameters?: unknown }).parameters)
+  const tool = defineTool(options)
+  const execute = tool.execute.bind(tool)
+  return markDynamicTool({
+    ...tool,
+    async execute(args, exec) {
+      return JSON.parse(JSON.stringify(await execute(args, exec))) as ToolExecuteReturn
+    },
+  })
+}
+
+/**
+ * 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)
+}
+
+function bindMethod(value: unknown, target: object): unknown {
+  if (typeof value !== 'function') return value
+  return (...args: unknown[]): unknown => Reflect.apply(value, target, args) as unknown
+}
+
+function guardedContext(ctx: Context): Context {
+  const tools = new Proxy(ctx.tools, {
+    get(target, prop) {
+      if (prop === 'register') {
+        return (tool: unknown): () => void => sandboxRegisterTool(ctx, tool)
+      }
+      const value = Reflect.get(target, prop, target) as unknown
+      return bindMethod(value, target)
+    },
+  })
+  return new Proxy(ctx, {
+    get(target, prop) {
+      if (prop === 'tools') return tools
+      if (prop === 'get') {
+        return (service: string): unknown => service === 'tools' ? tools : target.get(service)
+      }
+      const value = Reflect.get(target, prop, target) as unknown
+      return bindMethod(value, target)
+    },
+  })
+}
+
+/**
+ * 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 a guarded context (`tools.register`
+ * only accepts tools from `harness.defineTool`). Both function-form and
+ * object-form plugins go through the same guard; everything else on the
+ * context — `on`, `provide`, `inject` resolution — passes through with correct
+ * `this` binding, so cross-mount provide/inject works unmodified.
+ * @param plugin - the plugin the mount code returned.
+ * @returns an equivalent plugin whose `apply` sees the guarded context.
+ */
+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(guardedContext(ctx), config)
+      },
+    }
+  }
+  const objectPlugin = plugin as { apply(ctx: Context, config?: unknown): unknown }
+  return {
+    ...plugin,
+    apply(ctx: Context, config?: unknown) {
+      return objectPlugin.apply(guardedContext(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>'
+}

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

@@ -0,0 +1,228 @@
+/**
+ * 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 plugin fiber tree
+ *   (rendered as an ASCII tree), 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: visible as a unit in the
+ * inspect tree and 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 — it is not a
+ * security boundary. The `ctx` handed to the mounted plugin's `apply` is the
+ * real, fully privileged runtime handle; that is the point of the toolset, so
+ * a deployment loads this plugin as deliberately as it grants a bash tool.
+ *
+ * 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, Fiber } 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, describePluginTree, 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
+
+  /** The dynamic-mount id for a fiber, when that fiber is a tracked mount. */
+  function mountIdOf(fiber: Fiber): string | undefined {
+    for (const [id, mount] of mounts) {
+      if (mount.fiber === fiber) return id
+    }
+    return undefined
+  }
+
+  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` (the whole plugin fiber tree with lifecycle states, as an ASCII tree — '
+      + 'dynamic mounts appear under the `cordis-dynamic` group with their ids), '
+      + '`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', () => describePluginTree(ctx, mountIdOf)],
+        ['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) => { … }` — cannot declare inject, uses whatever '
+      + 'services are on the parent context, and accessing a service without inject '
+      + '(e.g. ctx.bash) throws; use it only when you need no injected 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 for any plugin that needs bash, llm, sessions, etc. '
+      + '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. 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`; '
+      + 'there is no `require`, `process`, `Buffer`, or network. '
+      + '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) The sandbox prevents accidental global pollution, not malice: `ctx` is '
+      + 'the real, fully privileged runtime handle.',
+    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,
+  }))
+}

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

@@ -0,0 +1,225 @@
+/**
+ * Read-only renderers over the live runtime for `cordis_inspect`: the service
+ * list, the plugin fiber tree (ASCII), 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 tree node shape {@link renderTree} draws: one line per fiber, children indented. */
+interface TreeNode {
+  label: string
+  children: TreeNode[]
+}
+
+/** Render a node list as an ASCII tree (`├─`/`└─` box drawing). */
+function renderTree(nodes: TreeNode[], prefix = ''): string[] {
+  return nodes.flatMap((node, index) => {
+    const last = index === nodes.length - 1
+    const line = `${prefix}${last ? '└─' : '├─'} ${node.label}`
+    const childPrefix = `${prefix}${last ? '   ' : '│  '}`
+    return [line, ...renderTree(node.children, childPrefix)]
+  })
+}
+
+/**
+ * The `plugins` section: every fiber the registry knows, rebuilt into the
+ * parent→child tree from each fiber's mounting context and rendered as an
+ * ASCII tree with lifecycle states. Fibers whose parent fiber is outside the
+ * registry (i.e. mounted on the root context) become roots.
+ * @param ctx - the runtime whose registry is walked.
+ * @param mountIdOf - resolves a fiber to its dynamic-mount id, so mounts render as `dyn-<n>: name`.
+ * @returns the tree lines, starting at the synthetic `root` line.
+ */
+export function describePluginTree(ctx: Context, mountIdOf: (fiber: Fiber) => string | undefined): string[] {
+  const fibers = new Set<Fiber>()
+  for (const runtime of ctx.registry.values()) {
+    for (const fiber of runtime.fibers) fibers.add(fiber)
+  }
+  const childrenOf = new Map<Fiber, Fiber[]>()
+  const roots: Fiber[] = []
+  for (const fiber of fibers) {
+    const parent = fiber.parent.fiber
+    if (fibers.has(parent)) {
+      const siblings = childrenOf.get(parent) ?? []
+      siblings.push(fiber)
+      childrenOf.set(parent, siblings)
+    } else {
+      roots.push(fiber)
+    }
+  }
+  const byUid = (a: Fiber, b: Fiber): number => (a.uid ?? Infinity) - (b.uid ?? Infinity)
+  const toNode = (fiber: Fiber): TreeNode => {
+    const id = mountIdOf(fiber)
+    const label = `${id ? `${id}: ` : ''}${fiber.name} [${STATE_LABELS[fiber.state]}]`
+    const children = (childrenOf.get(fiber) ?? []).sort(byUid).map(toNode)
+    return { label, children }
+  }
+  return ['root', ...renderTree(roots.sort(byUid).map(toNode))]
+}
+
+/**
+ * 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}`,
+  }
+}

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

@@ -0,0 +1,153 @@
+/**
+ * The `node:vm` sandbox `cordis_mount` code evaluates in: a fresh realm whose
+ * globals are a tagged write-through console, the `harness` registration
+ * helpers, and the encoding primitives a bare vm context lacks. The sandbox
+ * guards against ACCIDENTAL global pollution only — it is not a security
+ * boundary; 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 })
+}
+
+/**
+ * Build the vm context one `cordis_mount` call evaluates in: the tagged
+ * console, the `harness` registration helpers, the encoding primitives, 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 = {
+    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).
+    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 `)`.',
+    )
+  }
+}

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

@@ -0,0 +1,105 @@
+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('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 []
+    },
+  }
+}

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

@@ -0,0 +1,123 @@
+import { describe, expect, it } from 'vitest'
+import type { Context, Fiber } from 'cordis'
+import { FiberState } from '../src/fiber-state.ts'
+import { describeApi, describeEvents, describePluginTree, 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, uid-less 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 tree shows the dynamic
+    // group under this plugin; the tools section lists the cordis tools.
+    expect(report).toContain('- tools (provided by ToolRegistry)')
+    expect(report).toMatch(/tool-cordis \[active\]/)
+    expect(report).toMatch(/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 as an annotated child of the group in the tree', 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).toMatch(/dyn-1: 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('describePluginTree sorts uid-less fibers last and renders sibling branches', () => {
+    // The parent fiber is OUTSIDE the registry set, so all three are roots.
+    const rootFiber = { uid: 0, name: 'root' } as unknown as Fiber
+    const fiber = (uid: number | null, name: string): Fiber =>
+      ({ uid, name, state: FiberState.ACTIVE, parent: { fiber: rootFiber } }) as unknown as Fiber
+    const a = fiber(2, 'beta')
+    const b = fiber(1, 'alpha')
+    const c = fiber(null, 'rootless')
+    const d = fiber(null, 'rootless-too')
+    const ctx = { registry: { values: () => [{ fibers: [a, b, c, d] }] } } as unknown as Context
+    expect(describePluginTree(ctx, () => undefined)).toEqual([
+      'root',
+      '├─ alpha [active]',
+      '├─ beta [active]',
+      '├─ rootless [active]',
+      '└─ rootless-too [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()
+  })
+})

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

@@ -0,0 +1,409 @@
+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('rejects JSON Schema passed to harness.defineTool with the SchemaSpec teaching error', async () => {
+    const ctx = await setup()
+    const result = await call(ctx, 'cordis_mount', {
+      code: `
+        return {
+          name: 'bad-json-schema-tool',
+          inject: ['tools'],
+          apply(ctx) {
+            harness.registerTool(ctx, harness.defineTool({
+              name: 'bad_json_schema_tool',
+              description: 'bad',
+              parameters: {
+                type: 'object',
+                properties: { text: { type: 'string' } },
+                required: ['text'],
+              },
+              async execute() { return [{ type: 'text', text: 'bad' }] },
+            }))
+          },
+        }
+      `,
+    })
+
+    expect(result.isError).toBe(true)
+    expect(text(result)).toContain('harness.defineTool parameters use the SchemaSpec DSL')
+    expect(ctx.tools.get('bad_json_schema_tool')).toBeUndefined()
+  })
+
+  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'],
+    ['parameters: { text: { type: \'string\', required: false } }', 'parameters.text.required must be true 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) {
+            const sp = ctx.get('systemPrompt')
+            console.log('systemPrompt is', typeof sp)
+            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/require, 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 require, 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('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.fiber) } }
+      `,
+    })
+    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', 'object')
+    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()
+  })
+})

+ 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(['bash', 'bash_kill', 'bash_output', 'edit', 'read', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'write'])
+    expect(names).toEqual(['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':

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

@@ -47,6 +47,7 @@ import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local'
 import SubagentService from '@deepseek-ai/dsh-subagent'
 import * as SubagentMock from '@deepseek-ai/dsh-subagent-mock'
 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'
@@ -113,6 +114,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

@@ -58,6 +58,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

@@ -69,6 +69,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" }

Some files were not shown because too many files changed in this diff