返回 CodeWhale
EXTENSIONS.md
根目录 / docs / EXTENSIONS.md
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
585 lines MARKDOWN