| 1 | # Writing an extension tool or command |
| 2 | |
| 3 | The experimental TypeScript extension host runs reviewed plugin code in a |
| 4 | shared Node process (or, as an opt-in, Bun). Rust still owns sessions, tool admission, approval and |
| 5 | execution. Extensions currently contribute tools and slash commands; they |
| 6 | cannot provide an approval service, run a second agent loop or replace |
| 7 | built-in tools or commands. |
| 8 | |
| 9 | ## Quick start |
| 10 | |
| 11 | Start with [hello-extension](examples/plugins/hello-extension/hello.mts) and its |
| 12 | [plugin.json](examples/plugins/hello-extension/plugin.json). The example is |
| 13 | executed unchanged by the host tests. It returns a greeting and does not read |
| 14 | files or use the network. |
| 15 | |
| 16 | 1. Explicitly enable `[features] extension_host = true` in your configuration, |
| 17 | or start Codewhale with `codewhale --enable extension_host` (the flag goes |
| 18 | before any subcommand). |
| 19 | 2. Install the example's directory using `/plugin install <local-directory>`. |
| 20 | 3. Run `/plugin validate hello-extension`, then `/plugin show hello-extension`. |
| 21 | 4. Run `/plugin enable hello-extension` to open its content/capability review. |
| 22 | Read the review and personally run its exact `/plugin trust <token>` command, |
| 23 | then enable the plugin. Trust alone does not execute the code. |
| 24 | 5. Ask Codewhale to use `hello_greet`. The tool follows the normal approval gate. |
| 25 | 6. Run `/hello-greet Codewhale`. The command runs when you type it, and shows |
| 26 | its answer in the transcript with its origin, `extension:hello-extension`. |
| 27 | |
| 28 | An authoring agent should stop after installation and validation and present |
| 29 | the review to the person. Do not trust or enable a bundle automatically. |
| 30 | |
| 31 | ## Locations, installation and trust |
| 32 | |
| 33 | User bundles live under `~/.codewhale/plugins/`; workspace bundles live under |
| 34 | `<workspace>/.codewhale/plugins/`. New bundles use Agent Plugins v1.0.0 |
| 35 | `plugin.json`. Declare `extensions["net.codewhale"].native.path` as one regular |
| 36 | `.mjs`, `.js` or `.mts` file inside the bundle. Directories and other extensions |
| 37 | fail validation when the host is enabled. With the flag off, native entries |
| 38 | remain inventory-only. |
| 39 | |
| 40 | A plugin may split its code across several entries with `native.paths` (up to |
| 41 | 64, alone or beside `path`). They are activated in the order declared, as the |
| 42 | fibers of one plugin: they share one owner, so one disable, review change or |
| 43 | crash tears all of them down together. Each entry is a separate module with its |
| 44 | own `apply`, and tool and command names must be unique across them. If an entry |
| 45 | fails to activate (throws, requires a service the host does not provide, or has |
| 46 | a registration refused), only that entry is withdrawn: entries that already |
| 47 | activated stay registered, later entries still activate, and the plugin fails |
| 48 | only when no entry activates. Listing the same file twice is one entry. |
| 49 | |
| 50 | ## Runtime |
| 51 | |
| 52 | `[extension_host] runtime` selects what runs the host. Node is the default. |
| 53 | Bun is an opt-in: it is qualified on macOS only, and becomes the default only |
| 54 | after an explicit, recorded cutover. |
| 55 | |
| 56 | ```toml |
| 57 | [extension_host] |
| 58 | runtime = "node" # default: Node only |
| 59 | # runtime = "bun" # Bun only; fails rather than falling back to Node |
| 60 | # runtime = "auto" # Bun >= 1.4.0 if one is found and starts, otherwise Node |
| 61 | # bun = "~/.bun/bin/bun" # the only Bun tried when set |
| 62 | # node = "/opt/homebrew/bin/node" # the only Node tried when set |
| 63 | # mcp_backend = "host" # experimental SDK backend; default is "rust" |
| 64 | ``` |
| 65 | |
| 66 | `mcp_backend = "host"` selects the pinned MCP SDK independently of |
| 67 | `[features] extension_host`. That feature controls optional Native extensions; |
| 68 | selecting Host MCP does not activate them. The SDK for existing stdio, HTTP and legacy SSE server connections in a separate |
| 69 | builtin host process. Rust retains process/network authority, credential |
| 70 | resolution, catalog admission, tool approval and cancellation. HTTP/SSE uses |
| 71 | Rust's guarded HTTP client; the SDK receives opaque session selectors and |
| 72 | exact operation grants. Request IDs remain the Rust client's strings through |
| 73 | an adapter bound to each grant. Explicit Host selection never falls back to |
| 74 | Rust or replays an uncertain operation. The default remains `"rust"` while |
| 75 | recorded transport parity is qualified. The Host path uses the same Rust |
| 76 | GET session preflight and bounded reactive OAuth refresh as the default |
| 77 | backend. An explicit incompatible HTTP response can negotiate the real SDK |
| 78 | SSE transport only with a fresh exact Rust operation grant; a stale-session |
| 79 | refusal remains a typed Rust recovery decision. Credentials, configured URLs, |
| 80 | OAuth browser/login operations and Computer Use decision keys stay in Rust. |
| 81 | Both backends run against the same recorded MCP goldens. Passing that suite |
| 82 | and the actual broker acceptance tests is required before any default cutover. |
| 83 | The default also requires the platform isolation and measured Phase 3 gates |
| 84 | in Ops CURRENT_DECISIONS §26. The native Rust protocol adapters remain for one |
| 85 | release after the accepted default flip, then retire. Rust retains the catalog, |
| 86 | session, permission and credential authority. Missing or unsupported Node uses |
| 87 | the existing doctor runtime diagnostic; selected Host failures never choose |
| 88 | Rust automatically. |
| 89 | |
| 90 | The stock finance, data and speech adapters have independent experimental flags: |
| 91 | `[features] finance_host = true`, `data_host = true` and `speech_host = true`. |
| 92 | Each defaults to Rust. Speech preparation is shared by CLI `speech`/`tts` and |
| 93 | the model tools; clone samples, provider requests and output writes stay in Rust. |
| 94 | Selecting either uses the pinned Builtin harness and its existing operation |
| 95 | broker, even when Native extensions are disabled. Rust retains network/file |
| 96 | access, parsing diagnostics and permissions. A selected Host failure is reported; |
| 97 | it never silently switches to the Rust adapter. |
| 98 | |
| 99 | Node must satisfy `^22.19 || >=24`; Bun must be 1.4.0 or newer. Leaving |
| 100 | `runtime` unset means `node`, except that a table which sets only `bun` means |
| 101 | `bun`. A configured `node` or `bun` path is the only candidate for that |
| 102 | runtime: if it does not run or is below the floor, the host fails with that |
| 103 | reason instead of searching `PATH`. Without one, every `node` (or `bun`, then |
| 104 | `$BUN_INSTALL/bin`, default `~/.bun/bin`) on `PATH` is tried in order, and one |
| 105 | inside a `node_modules` directory or the working directory is skipped without |
| 106 | being run; set the path explicitly to use such a runtime. The working-directory |
| 107 | check does not apply when Codewhale starts in a directory that contains your |
| 108 | home directory, such as `~` or `/`. |
| 109 | |
| 110 | Under `auto`, if the Bun it found fails to start the host, `/plugin` reports |
| 111 | why once and Node runs the host for the rest of the session. The runtime is |
| 112 | pinned once a host on it completes its handshake, and restarts reuse it. A |
| 113 | runtime binary replaced at that path mid-session is refused; restart Codewhale |
| 114 | to use it. `codewhale doctor` and `/plugin` show which runtime runs the host, |
| 115 | its version and path, how its memory cap is enforced, and, when `auto` used |
| 116 | Node, why Bun was not used. |
| 117 | |
| 118 | When the host cannot take a call, `/plugin` and the failing extension tool |
| 119 | call give the same reason: not started, starting, unresponsive, restarting |
| 120 | after a crash (with how it exited), failed to start or out of crash budget |
| 121 | (with the reason; change or reload a plugin to retry), or disabled by config. |
| 122 | Every call into the host has a deadline, 120 s for a tool call; past it |
| 123 | Codewhale cancels the call, reports a timeout, and the host keeps serving |
| 124 | other calls. A plugin that ignores cancellation keeps running inside the host |
| 125 | until the host is torn down. |
| 126 | |
| 127 | Write extensions for both runtimes. Use Node's APIs (Bun implements them) and |
| 128 | only erasable TypeScript in `.mts`. Enums, decorators and syntax that needs |
| 129 | transformation require a separate author build to JavaScript. Bun would accept |
| 130 | more, but Node would not. Node never reads a `tsconfig.json` for the host. Bun |
| 131 | does: a `tsconfig.json` next to or above an extension's files applies its |
| 132 | `paths`, `baseUrl` and JSX settings to that extension's imports, so an import |
| 133 | that resolves under Bun can fail under Node. Do not rely on it. Known limit: |
| 134 | that includes a `tsconfig.json` in a directory above the reviewed bundle, |
| 135 | which is not part of what you reviewed. See |
| 136 | [Node's TypeScript rules](https://nodejs.org/docs/latest-v22.x/api/typescript.html). |
| 137 | Under Bun the host runs with `--no-install`: a missing package fails the import; |
| 138 | it is never downloaded. |
| 139 | |
| 140 | Reviewed DSH compositions admit every local module against its recorded path |
| 141 | and SHA-256 before importing it. Node checks runtime resolution; Bun prepares |
| 142 | the reviewed JavaScript syntax and checks computed imports before they run. |
| 143 | Unreviewed files and ambient package imports are refused. Bun currently cannot |
| 144 | preserve query/fragment module identity, and computed CommonJS resolution also |
| 145 | requires Node. These cases stop with a diagnostic: select |
| 146 | `[extension_host] runtime = "node"` for that composition. Ordinary UTF-8 source |
| 147 | and reviewed computed file/JSON imports work on both runtimes. |
| 148 | |
| 149 | Extensions cannot run native code inside the host. On both runtimes |
| 150 | `process.dlopen`, `process.execve` and Worker threads are unavailable (a Worker |
| 151 | is a new JavaScript realm that would start without these restrictions). Under |
| 152 | Bun, `bun:ffi`, `Bun.FFI`, `bun:sqlite`, `node:sqlite` and `ShadowRealm` are |
| 153 | unavailable too; under Node, `node:sqlite` and `node:ffi` are switched off |
| 154 | (SQLite extensions and FFI load native libraries). The host refuses to start |
| 155 | when one of these restrictions does not hold on the installed runtime. Known |
| 156 | limit: these are the native-code entry points found so far (Bun 1.4, Node 22 |
| 157 | and 26); one a newer runtime adds is not covered until it is added. A |
| 158 | process an extension starts is outside this policy; on macOS and Linux it runs |
| 159 | under the same sandbox as the host. |
| 160 | Include local imports in the bundle; trust stages reviewed content, and the |
| 161 | entry is rehashed before import. Changes to reviewed bytes or capabilities |
| 162 | require another review. See [bundle rules](PLUGIN_BUNDLES.md). |
| 163 | |
| 164 | ## Imports and services |
| 165 | |
| 166 | Export a Cordis plugin function or an object with `apply`. The host supplies |
| 167 | one shared Cordis and the supported DSH compatibility services. The example's |
| 168 | `inject = ['tools', 'commands']` asks for the tool and command registries. The |
| 169 | supplied service names are `tools`, `commands`, `prompt`, `storage`, `skills`, `shellHooks`, `mcp`, `logger`, |
| 170 | `events`, `reflect` and `registry`. A tool can ask the core to run a core tool through |
| 171 | `exec.core` (see [Asking the core to run a tool](#asking-the-core-to-run-a-tool)); |
| 172 | Reviewed Native entries can propose MCP definitions through `ctx.mcp` as |
| 173 | described below. Programmable pre-execute listeners use `ctx.on` as described below. A required |
| 174 | service that is unavailable fails activation with a diagnostic. |
| 175 | |
| 176 | Package runtime dependencies and local imports within the reviewed bundle. |
| 177 | Do not install packages or fetch code during activation. Register cleanup |
| 178 | through `ctx.effect`; asynchronous disposers are awaited with a deadline. |
| 179 | |
| 180 | `ctx.prompt.registerSection({ id, text })` contributes an attributed plain-text |
| 181 | section and returns its disposer. Rust delivers the complete current snapshot |
| 182 | as a user-role runtime message; changes replace earlier snapshots and an empty |
| 183 | snapshot explicitly withdraws earlier sections. Rust admits |
| 184 | sections only for the caller's live reviewed owners, sorts them by owner/id, |
| 185 | and withdraws them on unregister, disable, revoke or host exit. Each section |
| 186 | is limited to 4 KiB UTF-8 text, each owner to 32 KiB and 128 sections, and the |
| 187 | host to 128 KiB and 1,024 sections. The final attributed prompt has the same |
| 188 | 128 KiB limit. IDs use lower-case letters, digits, `_` and `-`, start with a |
| 189 | letter, and contain at most 64 characters. Duplicate owner-local IDs require |
| 190 | disposing the earlier section first. The author cannot replace the core |
| 191 | system prompt or select another session's sections. |
| 192 | |
| 193 | To interpolate Core's accepted turn facts, register |
| 194 | `{id, text, interpolate: 'model-cwd'}`. Only `{{model}}` and `{{cwd}}` are |
| 195 | supported; Rust expands them once when it captures the turn's prompt. Literal |
| 196 | sections retain braces unchanged. Both source and expanded text must fit the |
| 197 | section and snapshot limits. Scoped contributions keep their exact selected |
| 198 | entry identity through capture and withdrawal. The fixed DSH persona bridge |
| 199 | uses this path for additive prefix/suffix text; complete prompt replacement and |
| 200 | runtime-context suppression are refused. |
| 201 | |
| 202 | Reviewed DSH compositions can register Claude Code and Codex command hooks |
| 203 | through `shellHooks`. Codewhale's existing fifteen firepoints use the same |
| 204 | pinned Builtin runner and Rust-owned process driver when the host feature is |
| 205 | enabled. Rust retains hook approval, process environment and final verdicts; |
| 206 | ShellEnv values remain in Rust. Forced continuation, observer steering, |
| 207 | noncommand hooks and asynchronous dialect commands are unsupported and |
| 208 | reported explicitly. A Native hook's `allow` cannot approve a tool. |
| 209 | |
| 210 | `ctx.storage.get(key)`, `set(key, json)` and `delete(key)` persist owner-local |
| 211 | JSON under the `dataDir` Rust assigned. The API refuses access once owner |
| 212 | disposal begins, symlinked storage, corrupt data and writes that exceed its |
| 213 | bounded key/value/owner limits. It does not expose session history or secrets. |
| 214 | Tool and command invocations also expose frozen `sessionId`, `agentId` and |
| 215 | `originTurnId` strings when Rust supplies them for that particular call. |
| 216 | |
| 217 | ## MCP definitions |
| 218 | |
| 219 | `ctx.mcp.registerServer({ serverName, server })` returns an idempotent disposer |
| 220 | and proposes a literal MCP definition to the existing Rust catalog. Include |
| 221 | `mcp` in the entry's `inject` list. Rust owns transport connections, tool |
| 222 | admission, permissions and authentication; this service does not expose |
| 223 | credentials or grant extensions direct process or network access. |
| 224 | |
| 225 | Definitions retain their exact reviewed, selected Native entry receipt. |
| 226 | The limits are 64 servers per owner, 256 per host and 64 KiB per definition. |
| 227 | Literal stdio, streamable HTTP and SSE definitions are supported by the bridge; |
| 228 | credential values, endpoint query data and unsupported startup/reconnect |
| 229 | controls are refused. Disposing an entry, changing its caller selection, |
| 230 | disabling its plugin or changing reviewed bytes withdraws its definitions and |
| 231 | cancels affected calls. An uncertain write is never replayed. |
| 232 | |
| 233 | The raw DSH skill-filesystem bridge uses the same reviewed skill-root service |
| 234 | below. Its configuration must explicitly set `includeDefaultRoots: false` and |
| 235 | `watch: false`, and list bundle-relative `customSkillDirs`. Ambient filesystem |
| 236 | roots and independent watchers remain unsupported. |
| 237 | |
| 238 | ## Skill roots |
| 239 | |
| 240 | `ctx.skills.registerRoot({ path: 'profiles/review-skills' })` returns an |
| 241 | idempotent disposer and contributes reviewed instructions to Rust's existing |
| 242 | skill catalog. Include `skills` in the entry's `inject` list. For example: |
| 243 | |
| 244 | ```js |
| 245 | export const inject = ['skills'] |
| 246 | export function apply(ctx) { |
| 247 | ctx.skills.registerRoot({ path: 'profiles/review-skills' }) |
| 248 | } |
| 249 | ``` |
| 250 | |
| 251 | Place each skill in a child package directory such as |
| 252 | `profiles/review-skills/quick-check/SKILL.md`. Paths are bundle-relative, |
| 253 | at most 512 UTF-8 bytes, with normal slash-separated components. Absolute |
| 254 | paths, links, empty components, `.` and `..` are refused. Rust parses only |
| 255 | files covered by the reviewed bundle inventory, through the existing |
| 256 | [frontmatter and invocation contract](SKILLS.md#invocation-and-alias-metadata). |
| 257 | The existing nesting rules apply: hidden child directories are skipped, and |
| 258 | a package containing `SKILL.md` claims its nested examples. An invalid skill |
| 259 | or an empty root refuses admission. Native review covers this registration; |
| 260 | the entry does not need a separate declarative Skills component. |
| 261 | |
| 262 | Pending and admitted roots count toward the host shim's limits: 8 roots per |
| 263 | owner and 64 per host. Rust stops each root proposal at 128 candidate |
| 264 | `SKILL.md` files or 4 MiB of raw input. Retained instruction fields are also |
| 265 | limited to 128 skills / 4 MiB per owner and 1,024 skills / 32 MiB per host. |
| 266 | These are logical catalog limits. Duplicate owner-local root paths require |
| 267 | disposing the earlier root first. |
| 268 | |
| 269 | Disposal, disable, revocation and host exit remove the root from later |
| 270 | discovery. Loading a selected skill rechecks the reviewed Native receipt and |
| 271 | the live registration. Queued selections also recheck it; selections saved |
| 272 | before a process or host restart require selecting the skill again. Completed |
| 273 | session history remains the record of instructions already used. Roots have |
| 274 | no watcher, companion-file access or tool permission grant; core approval and |
| 275 | sandbox policy continue to apply. |
| 276 | |
| 277 | ## Tool rules |
| 278 | |
| 279 | Register a unique name with `ctx.tools.register`, a description, a JSON object |
| 280 | input schema and an `execute(input, exec)` function. Names start with a letter, |
| 281 | contain only letters, digits, `_` and `-`, and are at most 64 characters. Use a |
| 282 | plugin-specific prefix, such as `hello_greet`. Core names, core approval-name |
| 283 | families, and `mcp_`/`ext_` prefixes are reserved. Refusals explain the rule. |
| 284 | |
| 285 | Every extension tool is `Required` and never treated as read-only based on a |
| 286 | plugin's claim. Full Access, Bypass or an exact session grant for the reviewed |
| 287 | plugin receipt may satisfy that requirement without another prompt. Tools are |
| 288 | deferred by default; `[tools].always_load` can pin a tool through the normal |
| 289 | tool configuration. Registration never grants permission to execute it. |
| 290 | |
| 291 | The core checks every call's input against that schema (JSON Schema, draft |
| 292 | 2020-12 unless the schema names another) before it asks for approval and again |
| 293 | before anything is sent to the host, so `additionalProperties: false`, |
| 294 | `required`, types and bounds hold even when `execute` does not check them. A |
| 295 | call that fails is returned to the model as an invalid-input error naming what to |
| 296 | correct, and the host never sees it. A schema that cannot be compiled (an |
| 297 | invalid keyword value, or a `$ref` to anything outside the schema itself; |
| 298 | nothing is fetched) is refused at registration, which fails activation with the |
| 299 | reason. Declare the narrowest schema you can: it is both what the model sees |
| 300 | and what is enforced. |
| 301 | |
| 302 | Return a JSON value or text; the host renders it into ordinary tool output. |
| 303 | Avoid secrets in descriptions, logs and results. An approval card's wording |
| 304 | and identity come from Rust, never from plugin-supplied labels. |
| 305 | |
| 306 | ## Command rules |
| 307 | |
| 308 | Register a slash command with `ctx.commands.register`, which returns an |
| 309 | idempotent disposer (the registration is also removed when the plugin unloads): |
| 310 | |
| 311 | ```ts |
| 312 | ctx.commands.register({ |
| 313 | name: 'hello-greet', // /hello-greet |
| 314 | description: 'Greet someone.', // one line, shown in the palette and /help |
| 315 | argumentHint: '[name]', // optional; a command with a hint waits in |
| 316 | // the composer for arguments |
| 317 | handler({ args, signal }) { |
| 318 | return { kind: 'success', text: `Hello, ${args || 'world'}!` } |
| 319 | }, |
| 320 | }) |
| 321 | ``` |
| 322 | |
| 323 | Names are lower case, start with a letter and use only `a-z`, `0-9`, `_` and |
| 324 | `-` (at most 64 characters). A command can never take the name of a built-in |
| 325 | command (or one of its aliases) or of another plugin's command: the |
| 326 | registration is refused, activation fails, and the reason is in `/plugin`. |
| 327 | A user, workspace or plugin-manifest markdown command with the same name wins |
| 328 | the spelling and the extension command is left out of the registry. Descriptions |
| 329 | (1 KiB) and hints (256 bytes) are single-line text. |
| 330 | |
| 331 | A handler runs only when the **user** types the command; invoking it is the |
| 332 | user's own action and needs no approval. It can only return an answer: |
| 333 | |
| 334 | - a string, or `{ kind: 'success', text? }`: shown in the transcript; |
| 335 | - `{ kind: 'error', text }` or a thrown error: shown as a failure; |
| 336 | - `{ kind: 'submit', prompt, text? }`: `prompt` is sent as the user's next |
| 337 | message, visibly, through the ordinary turn. Whatever the model then does, |
| 338 | including every tool call, is gated as usual. `text` is shown beside it. |
| 339 | |
| 340 | The handler cannot call the model, a tool or approval. Output is stripped of |
| 341 | terminal escape sequences and cut at 64 KiB; a prompt over 128 KiB is refused, |
| 342 | not truncated. A command has 30 seconds: past it Codewhale cancels the call |
| 343 | (`signal` aborts) and reports a timeout. If the host is down, the command |
| 344 | fails at once with why. |
| 345 | |
| 346 | `args` is what follows the command name, trimmed. The invocation also carries |
| 347 | `signal`, a `commandId` and an empty `attachments`. DSH plugins using |
| 348 | `ctx.commands.register({ name, description, input: { hint }, handler })` and |
| 349 | returning `{ kind: 'success' | 'error', text }` run unchanged, including |
| 350 | `import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand'` (the |
| 351 | only `dsh-commands` import the host resolves; `definitionId` is accepted and |
| 352 | ignored). DSH's `rawInput` is provided (it keeps the leading separator, |
| 353 | `' name'`). Not |
| 354 | provided: DSH's `agent` (the host has no agent or session handle), |
| 355 | attachments (`input.attachments: true` fails activation), a `list`/`find`/ |
| 356 | `execute` surface, and command lifecycle events in a session log. Commands are |
| 357 | TUI-only: the Runtime API does not list or run them. |
| 358 | |
| 359 | ## Execution context |
| 360 | |
| 361 | A tool's `execute(input, exec)` gets, in `exec`: |
| 362 | |
| 363 | - `signal`, the call's cancellation signal; check it and propagate it to |
| 364 | asynchronous operations; |
| 365 | - `callId`, which identifies the call, and `args`, its input (also the first |
| 366 | argument); |
| 367 | - `workspace`, the root path of the workspace of the session that made the |
| 368 | call, and no other (it is absent only if that path is not valid UTF-8); |
| 369 | - `dataDir`, the plugin's own directory; |
| 370 | - `core`, only while the call runs under the turn's permission gate: |
| 371 | [`exec.core.call`](#asking-the-core-to-run-a-tool). |
| 372 | |
| 373 | A command's handler gets `workspace` (where the user ran it) and `dataDir` in |
| 374 | its invocation beside `args`, `signal` and `commandId`. All of these are |
| 375 | read-only strings (`exec` and the invocation are frozen). Nothing else about the |
| 376 | machine is passed: no home directory, no other workspace, no credential path. |
| 377 | A call id or plugin trust grants no new privileges. |
| 378 | |
| 379 | The workspace is where the *call* comes from, so read it per call: one host |
| 380 | serves every session in the Codewhale process, and a tool called from another |
| 381 | workspace sees that one. The host process's working directory is its data |
| 382 | directory, not the caller's workspace; do not use `process.chdir` in this |
| 383 | shared process, and resolve relative paths against `exec.workspace` yourself. |
| 384 | Reading a workspace file is your code's own filesystem access, under the host's |
| 385 | sandbox, not something the core checks per file. |
| 386 | |
| 387 | `dataDir` is `~/.codewhale/extension-host/data/plugins/<name>-<id>`, created by |
| 388 | Codewhale (mode 0700 on Unix) before the plugin activates, and the same |
| 389 | directory for every generation and every entry of that plugin, so it keeps |
| 390 | files across restarts and updates. It sits inside the host's one writable |
| 391 | root, which is where the sandbox allows writes. It is not a boundary between |
| 392 | plugins: they share a process, and one can write into another's directory. |
| 393 | Codewhale never deletes it, including on uninstall. |
| 394 | |
| 395 | ## Configuration |
| 396 | |
| 397 | A user configures a plugin in their own `config.toml`, keyed by the plugin's |
| 398 | manifest name: |
| 399 | |
| 400 | ```toml |
| 401 | [plugins."hello-extension".config] |
| 402 | greeting = "Howdy" |
| 403 | ``` |
| 404 | |
| 405 | That table is delivered as the second argument of `apply(ctx, config)` |
| 406 | (`{}` when there is none). If the entry module exports a `Config` schema |
| 407 | (`import Schema from '@deepseek-ai/schemastery'`; `export const Config = |
| 408 | Schema.object({ ... })`), the host validates the table against it before |
| 409 | `apply` runs, applies its defaults, and a mismatch fails activation with the |
| 410 | reason, so the plugin only ever sees a config its own schema accepts. Every |
| 411 | entry of a multi-entry plugin gets the same table and checks it against its own |
| 412 | `Config`. [hello.mts](examples/plugins/hello-extension/hello.mts) reads one |
| 413 | such setting. |
| 414 | |
| 415 | - It is the user's data, not part of what was reviewed: the user can change |
| 416 | it without a new trust review, which is why only the user's config can set it |
| 417 | (a project's `.codewhale/config.toml` cannot). |
| 418 | - Plain TOML values only (a date-time is refused), at most 16 KiB serialized |
| 419 | and 16 levels deep. A table over a limit fails that plugin's activation with |
| 420 | the reason, in `/plugin show`; it is never delivered cut short. |
| 421 | - `/plugin show <name>` lists the configured keys (not the values) and says |
| 422 | when the config is refused. Do not put a secret in it: the plugin's code |
| 423 | reads every value, and so does any other plugin in the shared process. |
| 424 | - Codewhale reads `[plugins]` at start, and again at `/plugin reload` (or any |
| 425 | plugin command that changes plugins). A plugin whose table changed is revoked |
| 426 | and activated again as a new generation, with the new values; one whose table |
| 427 | did not change is left alone. A file that cannot be read keeps the previous |
| 428 | settings. |
| 429 | |
| 430 | ## Programmable tool admission |
| 431 | |
| 432 | A reviewed native mod may register `ctx.on('tools/pre-execute', async (exec, next) => ...)`. The frozen call view carries `name`, `callId`, `arguments`, `signal`, `workspace`, `mode`, and `model`. It has no session, agent, tool, or approval handle. `next()` returns an abstention; Rust evaluates the remaining listeners and gates. |
| 433 | |
| 434 | Return `{kind:'deny', reason}`, `{kind:'ask', reason?}`, `{kind:'revise', input}` (a JSON object), `{kind:'annotate', text}`, or `{kind:'abstain'}`. `undefined` also abstains. DSH-shaped `allow` is an abstention and logs a warning once per owner. A malformed answer, thrown error, timeout, or withdrawn owner fails the call closed. The listener batch has a five-second deadline and observes cancellation through `exec.signal`. |
| 435 | |
| 436 | Native hooks run first, then mod listeners in core registration order. Denial always wins; the last accepted input revision wins. Rust re-prepares revised arguments and reruns the existing authority, policy, and approval checks. Input revisions use the existing 32 KiB hook limit; context and reasons use the existing sanitizers. Unloading a fiber removes its listener, and disabling or revoking an owner retires its admitted handles before host teardown. Other workspaces do not receive the call. |
| 437 | |
| 438 | This bridge runs on Engine sessions, including model tools, gated code-mode calls, and `exec.core` calls. The standalone ACP execution path has no TypeScript host attachment. Prepend/global listener ordering, DSH runtime/agent handles, around-execution wrappers, and post-result rewriting are not provided. Native `tool_call_after` remains an observer contract. |
| 439 | |
| 440 | ## Asking the core to run a tool |
| 441 | |
| 442 | A tool can ask the core to run one of the core's tools for it: |
| 443 | |
| 444 | ```ts |
| 445 | async execute({ path }, exec) { |
| 446 | if (!exec.core) return { error: 'not run under the turn gate' } |
| 447 | try { |
| 448 | const { content, isError, structured } = await exec.core.call('read', { path }) |
| 449 | return { content, isError } |
| 450 | } catch (error) { |
| 451 | // error.name === 'CoreCallError'; error.code is 'refused' | 'denied' | |
| 452 | // 'cancelled' | 'unavailable' | 'failed' |
| 453 | return { error: error.code, message: error.message } |
| 454 | } |
| 455 | } |
| 456 | ``` |
| 457 | |
| 458 | The call is planned and approved exactly like a call the model makes: the |
| 459 | allow and deny lists, hooks, Auto-Review, repo law, the worker authority |
| 460 | envelope and the approval card all apply. **You never decide any of that.** |
| 461 | There is no way to approve, to supply a card's text, an argv, a URL or a |
| 462 | ticket. Rust composes the card ("Requested by `extension:<plugin>` from inside |
| 463 | its tool `<tool>`") and decides whether one is shown. |
| 464 | |
| 465 | **When `exec.core` exists.** Only when the model called your tool directly and |
| 466 | the turn loop is serving its permission gate for that call. It is absent for a |
| 467 | command, a timer, activation code, a sub-agent's call, and a tool run from |
| 468 | inside `execute_tools` (code mode gives nested tools no gate); the core refuses |
| 469 | a `core/call` from any of them. It also ends with the call: when your `execute` |
| 470 | returns, fails, times out or is cancelled, or your plugin is disabled, or the |
| 471 | host exits, pending core calls are cancelled and a waiting approval card is |
| 472 | withdrawn (recorded as cancelled; an answer given afterwards changes nothing). |
| 473 | Cancelling your `exec.signal` cancels them too, and `call(name, input, {signal})` |
| 474 | can cancel one. |
| 475 | |
| 476 | **Refused outright** (`error.code === 'refused'`, nothing runs, no card): every |
| 477 | tool code mode refuses (`execute_tools`, the interpreters, `agent`, `workflow`, |
| 478 | `rlm`, `request_user_input`, interactive shells, sandbox escalation, Computer |
| 479 | Use consent and scripts, MCP sign-in); any extension tool, yours included (no |
| 480 | recursion); tool search and tool-result retrieval; the memory writer |
| 481 | (`remember`) and tools that change what the session may do or schedule work |
| 482 | (`request_plugin_install`, goals, automations, `send_later`, starting MCP |
| 483 | servers); and **every MCP tool, Computer Use included** (not in v1). Names match |
| 484 | case-insensitively and after the core resolves aliases and after any hook |
| 485 | rewrites the call. |
| 486 | |
| 487 | **What prompts.** Every call needs approval except a read-only, workspace-local |
| 488 | tool from a short list (`read`, `read_file`, `list_dir`, `file_search`, |
| 489 | `grep_files`) when nothing else asks for one. Your plugin's own approvals are separate: the user approved your |
| 490 | tool, not what it asks the core to do. Approval keys are scoped to your plugin |
| 491 | build: a grant the user gave the model for a tool never covers your call of it, |
| 492 | and a grant for your call never covers the model's. **Shell and network calls |
| 493 | force a prompt**: a session grant is not consulted. Ask and Full Access |
| 494 | both ask the user every time; explicit session denials still refuse the call. |
| 495 | Auto-Review and Never refuse it without opening a card. In Full Access every |
| 496 | other call an extension makes is auto-approved, as it is for the model. |
| 497 | `error.code === 'denied'` is the user's "no": do not retry it. |
| 498 | |
| 499 | **Limits**, per invocation: 50 core calls in all, 4 at once, and one approval |
| 500 | card at a time; per host, 256 requests in flight. A host that presents invalid |
| 501 | tickets in a burst is ended as a protocol violation. Your `tool/call` deadline |
| 502 | (120 s) stops while a core call waits on an approval card. The result is the |
| 503 | tool's text (`content`) and, when it was JSON, the parsed value |
| 504 | (`structured`); a long one is cut with a note, and images are dropped. What your |
| 505 | tool asked the core to run is recorded (bounded) in your tool result's |
| 506 | `core_calls` metadata, with each call's decision and outcome. |
| 507 | |
| 508 | ## Lifecycle, diagnostics and restarts |
| 509 | |
| 510 | Use `/plugin show <name>` for that plugin's owner state, live tool names and |
| 511 | up to 20 recent retained diagnostic messages. The host retains a bounded 64-entry |
| 512 | shared diagnostic ring; this view is not a persistent per-plugin log. Warnings, |
| 513 | errors, activation/refusal/fault messages and teardown outcomes are attributed |
| 514 | to the plugin when known. Display text is escaped. `/plugin list` includes |
| 515 | overall host health and bounded diagnostics. |
| 516 | |
| 517 | Do not block the event loop. Heartbeats mark an unanswered host Unresponsive |
| 518 | after 3 seconds and kill it after 10 seconds. Unexpected exits restart with a |
| 519 | short backoff; the third crash within 5 minutes stops automatic recovery. |
| 520 | Opening another engine does not reset that budget. Explicit plugin changes or |
| 521 | reload retry deliberately. Launch failures also allow a newly attached engine |
| 522 | to retry after a one-minute cooldown. |
| 523 | |
| 524 | Recovery verifies current attachments and persisted trust again, then creates |
| 525 | fresh owner tokens and tool handles. Outstanding calls fail and are never |
| 526 | replayed; failed/faulted receipts remain suppressed until explicit retry or |
| 527 | changed authority. Two dirty teardowns within 10 minutes request maintenance |
| 528 | when active calls finish. This restart preserves the unexpected-crash budget. |
| 529 | Dispose within 2 seconds and avoid leaving background work behind. |
| 530 | |
| 531 | ## Sandbox |
| 532 | |
| 533 | Trust is not a complete security boundary. Plugins share one process and can |
| 534 | interfere with each other. On macOS the existing Seatbelt profile, and on Linux |
| 535 | bubblewrap (`/usr/bin/bwrap`), deny direct network access, writes outside the |
| 536 | host's data and temp paths and reads of the protected credential locations. |
| 537 | Other user-readable files, including project `.env` files, remain readable. |
| 538 | |
| 539 | The macOS profile is the one every Seatbelt-sandboxed Codewhale command gets, |
| 540 | under a workspace-write policy rooted at the host's data directory |
| 541 | (`~/.codewhale/extension-host/data`). Besides that directory and the temp |
| 542 | directories it therefore also allows writes to: |
| 543 | |
| 544 | - the per-user Darwin cache directory (`confstr(_CS_DARWIN_USER_CACHE_DIR)`, |
| 545 | under `/var/folders/`); |
| 546 | - `~/.cargo/registry` and `~/.cargo/git` (under `$CARGO_HOME` when set); |
| 547 | - the npm cache, `~/.npm` (or `$NPM_CONFIG_CACHE`). |
| 548 | |
| 549 | They exist in the shared profile so that `cargo` and `npx`-launched tools work |
| 550 | inside the shell sandbox (`sandbox/seatbelt.rs`). The extension host needs none |
| 551 | of them and the sandbox tests do not probe them, but a plugin, or a process it |
| 552 | starts, can write there. The cargo and npm entries are present only when |
| 553 | `CARGO_HOME`/`NPM_CONFIG_CACHE` or `HOME` is set in Codewhale's environment. |
| 554 | The bubblewrap sandbox on Linux has no equivalent |
| 555 | allowances. |
| 556 | |
| 557 | Native extensions require a verified OS wrapper at each launch. On Linux, |
| 558 | missing bwrap or a failed namespace probe refuses Native activation and reports |
| 559 | the concrete error. Windows Native activation is likewise refused while its |
| 560 | filesystem/network isolation is unavailable. The pinned Builtin tier retains |
| 561 | an explicitly diagnosed unsandboxed exception; every effect still requires |
| 562 | Rust operation tickets. That exception does not qualify Native extensions or |
| 563 | the mandatory-host/default-runtime cutover. Planning creates the sibling |
| 564 | Builtin data directory before masking it for Native launch. Review the |
| 565 | [current design limits](design/TS_EXTENSION_HOST.md) before enabling |
| 566 | third-party code. |
| 567 | |
| 568 | ## Limits |
| 569 | |
| 570 | The host process has a 1 GiB memory cap. On Linux it is `RLIMIT_DATA` (or a |
| 571 | lower hard limit Codewhale itself inherited) and on Windows the Job Object's |
| 572 | per-process limit, so an allocation past it fails; both also apply to each |
| 573 | process an extension starts. Those two have been tested with a Node host only. |
| 574 | On macOS the Bun host applies a jetsam limit to itself before any extension |
| 575 | loads, and the kernel kills it past the cap; processes it starts are not |
| 576 | covered. A Node host on macOS has no kernel limit: Codewhale checks its |
| 577 | resident size at each 3-second heartbeat and kills it past the cap. Under Node |
| 578 | the JavaScript heap is also limited to 256 MiB. The host has a |
| 579 | 32 MiB frame limit, 256 in-flight request |
| 580 | limit (plus a reserved heartbeat), 128 tools per owner and 1024 per host, and |
| 581 | 64 commands per owner and 256 per host. Tool |
| 582 | descriptions are at most 4 KiB and schemas 64 KiB. Tool calls have a 120-second |
| 583 | deadline and commands a 30-second one. The feature stays Experimental and off by default; local fixture |
| 584 | tests do not establish sandbox parity, provider behavior or release readiness. |
| 585 |