diff --git a/.changeset/remove-legacy-apis.md b/.changeset/remove-legacy-apis.md new file mode 100644 index 000000000..7a708181b --- /dev/null +++ b/.changeset/remove-legacy-apis.md @@ -0,0 +1,7 @@ +--- +"agent-bundle": minor +"@agent-bundle/runtime": minor +"rsc-markdown-stream": minor +--- + +Remove `plugin.version` and mismatch diagnostic AB4008, the install `--force` alias, split tool exports, `ServedApp`, generated CLI `isTty`, and legacy MCP App template resolution; reject the retired version key with AB4001; remove runtime `Hook`/`Mcp` lowerers, `createRscRequestContext`, and `AgentDocumentSnapshot`; require React 19 element and context contracts (#810) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index e85de5c0c..7fbd0aca5 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -14,7 +14,7 @@ even when no error diagnostic was reported. | Family | Area | | --- | --- | | `AB30xx` | Skill documents: Markdown parsing (`AB3000`–`AB3002`: unreadable, missing or malformed frontmatter), rendered-skill compilation (`AB3003`: module failed to load, `AB3004`: missing/invalid default component or `frontmatter` export, `AB3005`: content outside the supported Markdown element subset), and the Skill IR (`AB3006`: unknown frontmatter field; `AB3008`–`AB3010`: per-host lowering of tokens and frontmatter); see below. | -| `AB40xx` | Plugin metadata and Skill source validation (`AB4000`/`AB4001`: name/version; `AB4002`–`AB4007`: Skill fields; `AB4008`–`AB4011` and `AB4013`: release identity; `AB4012`: declared `plugin.logo` is missing, not a file, or outside the project; `AB4014`/`AB4015`: the shared descriptive metadata every host projection reads); see below. | +| `AB40xx` | Plugin metadata and Skill source validation (`AB4000`: name; `AB4001`: retired `plugin.version`; `AB4002`–`AB4007`: Skill fields; `AB4009`–`AB4011` and `AB4013`: release identity; `AB4012`: declared `plugin.logo` is missing, not a file, or outside the project; `AB4014`/`AB4015`: the shared descriptive metadata every host projection reads); see below. | | `AB41xx` | Normalized model invariants (`AB4100`–`AB4102`: unknown targets — the retired `plugin` name included — duplicate IDs and outputs; `AB4103`, `AB4105`, `AB4106`: the composite-root checks — same path with different bytes across selected projections, a host-scoped component leaking through conventional discovery, an advanced-registry adapter selected beside another target; see below). | | `AB42xx` | Hook configuration and native hook sources (`AB4200`–`AB4212`; see below). | | `AB43xx` | MCP server and MCP App configuration (`AB4300`–`AB4339`, see below; `AB4340`: a declaration for a route-generated server redeclares `entry`/`command`/`url`; `AB4341`: the `web` exposure/policy key; see below). | @@ -92,18 +92,20 @@ and is never lowered. `AB3007` is not assigned. ## Plugin metadata and Skill source validation (`AB4000`–`AB4007`, `AB4012`) -`plugin.name` is the host-native slug every manifest carries; `plugin.logo` -is an optional project-relative image. Each discovered skill is checked +`plugin.name` is the host-native slug every manifest carries; `plugin.version` +is retired and rejected because `package.json` is the sole release-version +source; `plugin.logo` is an optional project-relative image. Each discovered skill is checked against the pinned Agent Skills frontmatter schema (`schemas/agent-skills/frontmatter.schema.json`: `name` and `description` required, closed portable field shapes), its directory, its resources, and -the other skills. `AB4001`, `AB4008`–`AB4011`, and `AB4013` are the release +the other skills. `AB4009`–`AB4011` and `AB4013` are the release identity codes (see "Release identity" below). Every row is reported on the -config file (`AB4000`, `AB4012`) or the skill source. +config file (`AB4000`, `AB4001`, `AB4012`) or the skill source. | Code | Severity | Trigger | Recovery | | --- | --- | --- | --- | | `AB4000` | error | `plugin.name` is missing or is not a nonempty string. | Declare `plugin: { name: '' }`. | +| `AB4001` | error | The retired `plugin.version` key is present. Config loading does not run `tsc`, so accepting it as an unknown key would silently ignore the value and stamp `package.json.version` instead. | Remove `plugin.version` and declare the release version only in `package.json`. | | `AB4002` | error | Skill frontmatter `name` fails the pinned schema: missing, not a string, empty, longer than 64 characters, or not lowercase kebab-case (`^[a-z0-9]+(?:-[a-z0-9]+)*$`): `Skill frontmatter name .` | Declare a kebab-case `name` of at most 64 characters. | | `AB4003` | error | Skill frontmatter `description` fails the pinned schema: missing, not a string, blank, or longer than 1024 characters. | Declare a nonblank `description` of at most 1024 characters. | | `AB4004` | error | Skill frontmatter `name` differs from the skill directory's name: `Skill name "" must match directory "".` | Rename the directory or the `name` so they agree. | @@ -588,7 +590,7 @@ readable output. | `AB4771` | warning | One Rspack warning while compiling an App view that the framework's ignore list does not cover; `MCP App "" produced a warning while compiling: ::: `. | Address the warning in the named file; a warning that is bundler noise inside the framework's own dependency graph belongs on the documented ignore list. | | `AB4772` | warning | The emitted App HTML is 1 MiB or larger in a production build, or larger than 2 MiB in any build; `MCP App "" compiled to ( gzip), above the … bound; largest modules: …`. In `agent-bundle dev`, a view whose readable output would exceed 2 MiB was recompiled with the production profile for the preview and that production build fits: `MCP App "" readable development output compiled to , above the 2 MiB bound …; the preview renders the production build (…) instead; largest modules: …` — the only size advisory that view receives; a production build that is itself over 2 MiB gets the ordinary over-bound message instead. | Trim the largest modules the message names — usually a dependency imported whole; a view over 2 MiB does not render in the Workbench or `serve-app` and must shrink before it ships. The development substitution costs only the readable source in the preview. | -## Release identity (`AB4001`, `AB4008`–`AB4011`, `AB4013`) +## Release identity (`AB4009`–`AB4011`, `AB4013`) `package.json` is authoritative for release identity (issue #94): its `name` and `version` become the `packageName` and `packageVersion` axes carried on @@ -596,18 +598,8 @@ the project context, artifact manifests, `inspect` output, and dev status. `plugin.name` stays the host-native slug and is never derived from the npm package name. -`plugin.version` is **deprecated and optional**. New projects declare the -release version only in `package.json`; removal of the compatibility field -follows the normal breaking-change policy rather than a fixed window. When it -is omitted, the version every surface reports — manifests, host projections, -dev status, and the `agent-bundle/meta` constant compiled into plugin code — -is the `package.json` version. When it is declared, the declared value still -wins so a legacy config never changes meaning mid-migration, and a -disagreement reports the `AB4008` **warning**. Declaring it as anything but a -nonempty string is an `AB4001` error. - -A project with neither an authored `plugin.version` nor a valid `package.json` -version has no release identity. Development commands (`dev`, `inspect`, +A project with no valid `package.json` version has no release identity. +Development commands (`dev`, `inspect`, `validate`) keep running on the labeled `0.0.0-dev.` fallback, because an unpackaged scratch project is a normal development state. A development-only fallback can never produce a release artifact, so @@ -615,12 +607,10 @@ development-only fallback can never produce a release artifact, so | Code | Severity | Trigger | | --- | --- | --- | -| `AB4001` | error | `plugin.version` is declared as something other than a nonempty string. Omit the field to derive the version from `package.json`. | -| `AB4008` | warning | A declared `plugin.version` differs from the `package.json` version. Align the two, or drop `plugin.version`. | | `AB4009` | warning | `package.json` `name` is not a valid npm package name; the `packageName` axis is withheld. | | `AB4010` | warning | `package.json` `version` is not a valid semantic version; the `packageVersion` axis is withheld. | | `AB4011` | warning | `package.json` is unusable — unparsable, not a JSON object, or symlinked outside the project root. | -| `AB4013` | error (build) | `agent-bundle build` refuses a project with no release version: `plugin.version` is omitted and `package.json` declares no valid semantic version. | +| `AB4013` | error (build) | `agent-bundle build` refuses a project whose `package.json` declares no valid semantic version. | | `AB4014` | error | A `plugin.metadata` field is not the shape the shared descriptive layer accepts, or the block declares a field beyond `author`, `homepage`, `keywords`, `license`, and `repository`. The config declared it, so it is an error rather than a withheld value — a blank string or empty array included, where `null` is how a field is opted out. | | `AB4015` | warning | A `package.json` descriptive field cannot be shared with any host manifest — a `homepage`, `repository`, or `author.url` the pinned host schemas' `uri` format refuses, an `author.email` their `email` format refuses, or a `repository` in a form this compiler will not convert (`owner/repo` and `github:` shorthands, `git@`/`git://`/`git+ssh`/`git+http` URLs; only `http(s)` and the `git+https://…` URL npm writes, with or without a trailing `.git`, are read). An `author` with any malformed part is withheld whole. The field is withheld rather than guessed at; declare `plugin.metadata.` to share an explicit value. A field the config already overrides is not reported. | @@ -1033,12 +1023,9 @@ forms. A module without a `config` export compiles silently with an empty config. An MCP App route's `config.template` resolves **relative to the route -module**, the way its imports do (`template: './dashboard.html'`). The older -project-root-relative form (`'./src/mcp//apps/dashboard.html'`) is -still accepted, without a diagnostic, while it is the only interpretation -that names an existing file. When both interpretations name different -existing files, or neither exists, `AB4827` names both candidate paths; the -fix is to make the path route-relative. The IR keeps the authored path (so the +module**, the way its imports do (`template: './dashboard.html'`). When that +path names no existing file, `AB4827` names the resolved candidate. The IR +keeps the authored path (so the graph digest stays machine-independent) and the normalized model carries the resolved absolute file. Config-declared Apps (`mcp.servers..apps`) keep resolving `entry` and `template` from the project root, where the config @@ -1183,7 +1170,7 @@ projections; no static provider subset declaration is required. | `AB4807` | retired | The stage-1 rendered-script gate. Rendered script routes ship through the Agent renderer pipeline since #102 stage 3; the code is never reused. | | `AB4808` | error | A conventional `src/scripts/` route nests below the scripts root; conventional scripts ship as direct children only. Move it up, prefix a path segment with `_`, or declare it under `scripts` in config with a flat name. | | `AB4809` | error | A conventional `src/scripts/` route and a configured `scripts` entry share one script identity through different files. Point the config entry at the module to claim it, or rename one of the two. | -| `AB4810` | error | A generated MCP route is missing named `inputSchema`/`resultSchema` declarations or its default export, an event route lacks a default export, or a helper definition is not a supported direct declaration. The bundler resolves explicit re-exports; the runtime checks callability. Sync and async handlers are accepted. | +| `AB4810` | error | A tool route does not default-export a direct `defineTool({ inputSchema, resultSchema, ... }, handler)` call, another generated MCP route is missing named `inputSchema`/`resultSchema` declarations or its default export, an event route lacks a default export, or a helper definition is not a supported direct declaration. | | `AB4811` | error | A generated MCP route exports `execute` or `render`; route mode accepts only the default Server Component contract. | | `AB4812` | error | A generated MCP App route has no non-empty static `config.resourceUri`. | | `AB4813` | error | The command graph collides: a route is both a command module and a command group, an alias collides with a sibling command, group, or alias, an alias is unsafe or duplicated, or an explicit `bin` entry claims the generated CLI executable's name. | @@ -1200,7 +1187,7 @@ projections; no static provider subset declaration is required. | `AB4824` | error | An event route selects an unknown target, requires an event the selected target does not support, or declares a capability row in `config.requires` that no selected host supports. An unmet requirement names the row and every selected host considered. | | `AB4825` | error | An event route declares both `config.targets` and `config.requires`, or either selector is not a nonempty array of nonempty target names or capability row ids. | | `AB4826` | error | A route's static `config` calls `appResourceUri('')` with a reference that matches no App route of the route's own generated server with a static `config.resourceUri`: an unknown name, another server's App (a generated server registers only its own Apps), or a reference from a non-MCP route. The message names the cause and lists the server's known App route ids; reference the App as `''`, `'/'`, `'app:/'`, or a relative module path. | -| `AB4827` | error | An MCP App route's `config.template` is ambiguous or missing: both the route-relative and the project-root-relative interpretation name different existing files, or neither exists. The message names both candidate paths; templates resolve relative to the route module, so rewrite the path as `'./.html'` beside the route. | +| `AB4827` | error | An MCP App route's route-relative `config.template` names no existing file. The message names the resolved candidate; point it at `'./.html'` beside the route. | | `AB4828` | error | A generated MCP route advertises `_meta.ui.resourceUri` of an App on its server (through `appResourceUri()` or a literal) that is not built for every target the server ships to, because the App's `config.targets` (or a config-declared App's `targets`) is narrower. Widen the App's targets or restrict `mcp.servers..targets`. | | `AB4829` | error | Two distinct MCP App routes of one generated server declare the same static `config.resourceUri`. The message names both route files and the server; a generated server registers one App per resource URI and never picks a side. The same URI on App routes of *different* servers is not a collision — each server registers only its own Apps. Give each App route of the server a distinct `config.resourceUri`, or remove the duplicate module. | | `AB4830` | error | A conventional layout module (`src/layout.*`, `src/mcp//layout.*`) does not satisfy the layout contract: it has no default export, it exports the route-only `config`/`inputSchema`/`resultSchema`, or it exports `execute`/`render`. Default-export one component receiving `{ children, route, signal }` that renders `Agent.Result` around `children`. | @@ -1373,7 +1360,7 @@ installed plugin from it; `AB7303` is emitted only when that listing is unusable the host owns those copies, so replacement runs `claude plugin uninstall --keep-data` + `install` or `codex plugin remove` + `add`. -| Installed copy | `install` | `install --replace` (alias `--force`) | Doctor | +| Installed copy | `install` | `install --replace` | Doctor | | --- | --- | --- | --- | | Identical content (receipt / host-managed) | `already-installed` no-op | `already-installed` no-op | `current` | | Identical content (legacy) | `already-installed` no-op | `adopted` — receipt written, no plugin file changes | `current` | diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index bbf503ea5..84240e923 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -71,8 +71,8 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. | `src/cli.ts` | Package bin named after `plugin.name` (skipped when the name is not a safe output name). | `bin: false` | | `src/index.ts` | Library output with declarations. | `lib: false` | | `src/mcp/.ts` | Stdio entry for the declared MCP server `` that names no `entry`, `command`, or `url`. | Declare `entry` explicitly | -| `src/mcp//{tools,resources,prompts}/*.{ts,tsx}` | Generated MCP server routes; path supplies identity and each executable module supplies static `config`, schemas, and one default Server Component. | Set `routes.servers.` to `custom`, `command`, or `remote` | -| `src/mcp//apps/*.{ts,tsx}` | Browser MCP App entry compiled to self-contained HTML and registered on the generated server; static `config.resourceUri` is required (`AB4812`), and two App routes of one server sharing a URI are `AB4829` (the same URI on different servers is not a collision). An optional `config.template` HTML shell resolves relative to the route module like its imports (`'./dashboard.html'`); the legacy project-root-relative form is accepted only while unambiguous (`AB4827` otherwise). Tools, resources, and prompts reference the App from their own static `config` with `appResourceUri('')` from `agent-bundle/routes` or a shared `const` string literal instead of repeating the `ui://` literal. The view talks to its host through `createAppClient()` from the browser-safe `agent-bundle/app` (see [`agent-bundle/app`](#agent-bundleapp--the-app-side-bridge-client)), typed by the generated `AppRegister` augmentation. | Use a custom server or prefix the file with `_` | +| `src/mcp//{tools,resources,prompts}/*.{ts,tsx}` | Generated MCP server routes; path supplies identity. Tool modules default-export a direct `defineTool({ inputSchema, resultSchema, ... }, handler)` call; resources and prompts supply their static `config`, schemas, and default Server Component. | Set `routes.servers.` to `custom`, `command`, or `remote` | +| `src/mcp//apps/*.{ts,tsx}` | Browser MCP App entry compiled to self-contained HTML and registered on the generated server; static `config.resourceUri` is required (`AB4812`), and two App routes of one server sharing a URI are `AB4829` (the same URI on different servers is not a collision). An optional `config.template` HTML shell resolves relative to the route module like its imports (`'./dashboard.html'`) and reports `AB4827` when missing. Tools, resources, and prompts reference the App from their own static `config` with `appResourceUri('')` from `agent-bundle/routes` or a shared `const` string literal instead of repeating the `ui://` literal. The view talks to its host through `createAppClient()` from the browser-safe `agent-bundle/app` (see [`agent-bundle/app`](#agent-bundleapp--the-app-side-bridge-client)), typed by the generated `AppRegister` augmentation. | Use a custom server or prefix the file with `_` | | `src/scripts/.ts` | Plain script compiled once to `scripts/.mjs` in the plugin root, shared by every selected host — the same pipeline explicit `scripts` entries use, with ordinary Node stdout/stderr semantics. A `scripts` entry that references the file claims it. Nested modules are hard errors (`AB4808`). A `bin` entry that references the file does **not** claim it: the module ships as both the npm bin and the artifact script (see [Which config keys claim a conventional module](#which-config-keys-claim-a-conventional-module)); export `main` or make the module self-executing, because a `default`-only module would run as the bin but ship as an inert script (`AB4738`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | | `src/scripts/.tsx` | Rendered script: the default component receives `{ argv, signal }` and renders through the Agent renderer with the CLI output contract (`--json`, `--ndjson`, TTY progress, piped Markdown). Compiles to `scripts/.mjs` plus a `scripts/-flight.mjs` react-server worker. The extension is the explicit, visible contract — plain `.ts` scripts are never wrapped in React behavior, and explicit `scripts` config entries stay plain regardless of extension. A `bin` entry that references a rendered script is `AB4737` unless the module exports both the default component (for the script) and a named `main` (for the bin envelope); with both, the module serves both surfaces. | Rename to `.ts`, prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | | `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project), plus the same executable as `bin/.mjs` in the plugin root whenever a selected host publishes the `cli` capability (all built-in hosts do). Nesting is identity: `src/cli/library/audit.ts` runs as ` library audit`. Plain `.ts` commands execute directly and print one canonical JSON line; `.tsx` commands render through the dispatcher with the four output modes. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` | @@ -1127,12 +1127,11 @@ a hand-written `src/lib/version.ts`: import meta, { name, packageName, packageVersion, version } from 'agent-bundle/meta'; ``` -`version` is the resolved plugin version: the authored `plugin.version` when -declared, otherwise the `package.json` version. `name` is the host-native -plugin slug — never the npm package name. `packageName` and `packageVersion` -are the validated npm axes, `undefined` for an unpackaged development -project. Every value is exactly what artifact manifests, `inspect`, and dev -status report for the same build. +`version` is the `package.json` version. `name` is the host-native plugin slug +— never the npm package name. `packageName` and `packageVersion` are the +validated npm axes, `undefined` for an unpackaged development project. Every +value is exactly what artifact manifests, `inspect`, and dev status report for +the same build. The compiler replaces the specifier in **every** compiled surface: artifact scripts, the routed CLI, MCP entries, hook wrappers, and the package build diff --git a/docs/framework-mode.md b/docs/framework-mode.md index d5aecb4f5..94e41ae91 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -10,8 +10,8 @@ Agent Bundle has one newcomer model: static resources, and `agent-bundle.config.ts` stays at the project root. 2. **One small flat config.** `agent-bundle.config.ts` holds project identity, targets, and policy that no route file can own. The release version is - not repeated there: `package.json` is the single version source - (`plugin.version` is deprecated; see [Diagnostics](diagnostics.md#release-identity-ab4001-ab4008ab4011-ab4013)). + not repeated there: `package.json` is the single version source; see + [Diagnostics](diagnostics.md#release-identity-ab4009ab4011-ab4013). 3. **JSX = rendering.** An executable route is one async default Server Component. It does the work and returns `Agent.*`; there is no public `execute`/`render` split. @@ -627,7 +627,7 @@ content, records an install receipt (`.agent-bundle-install.json`: plugin, version, content hash, owned files and directories), replaces a same-version stale copy of its own plugin in place (owned files only; legacy `state/` survives, while current builds keep framework state outside the plugin root), and accepts -`--replace` (alias `--force`) to replace a different installed version or adopt +`--replace` to replace a different installed version or adopt a pre-receipt copy. Foreign directories are refused with a content-hash comparison. It never invokes sudo or changes PATH. `agent-bundle install [--replace]` applies the same policy for every host, and `agent-bundle doctor diff --git a/fixtures/integration/canonical-collision/agent-bundle.config.ts b/fixtures/integration/canonical-collision/agent-bundle.config.ts index 54835b641..f145ae0eb 100644 --- a/fixtures/integration/canonical-collision/agent-bundle.config.ts +++ b/fixtures/integration/canonical-collision/agent-bundle.config.ts @@ -1,5 +1,5 @@ export default { - plugin: { name: 'canonical-collision', version: '1.0.0' }, + plugin: { name: 'canonical-collision' }, scripts: { bundle: './src/bundle.ts', 'dir/../bundle': './src/bundle.ts', diff --git a/fixtures/integration/canonical-collision/package.json b/fixtures/integration/canonical-collision/package.json new file mode 100644 index 000000000..3e72c21ef --- /dev/null +++ b/fixtures/integration/canonical-collision/package.json @@ -0,0 +1,6 @@ +{ + "name": "agent-bundle-canonical-collision-fixture", + "private": true, + "type": "module", + "version": "1.0.0" +} diff --git a/fixtures/integration/comprehensive/agent-bundle.config.ts b/fixtures/integration/comprehensive/agent-bundle.config.ts index f28282847..1b5f91a55 100644 --- a/fixtures/integration/comprehensive/agent-bundle.config.ts +++ b/fixtures/integration/comprehensive/agent-bundle.config.ts @@ -29,7 +29,6 @@ export default { plugin: { description: 'A complete Agent Bundle compiler integration fixture.', name: 'integration-fixture', - version: '1.0.0', }, scripts: { bundle: './src/bundle.ts', diff --git a/fixtures/integration/comprehensive/package.json b/fixtures/integration/comprehensive/package.json index b73084d85..e453ea48c 100644 --- a/fixtures/integration/comprehensive/package.json +++ b/fixtures/integration/comprehensive/package.json @@ -4,5 +4,6 @@ "@modelcontextprotocol/server": "2.0.0" }, "private": true, - "type": "module" + "type": "module", + "version": "1.0.0" } diff --git a/fixtures/integration/micro-eval/agent-bundle.config.ts b/fixtures/integration/micro-eval/agent-bundle.config.ts index 15c8de473..367d5dc0e 100644 --- a/fixtures/integration/micro-eval/agent-bundle.config.ts +++ b/fixtures/integration/micro-eval/agent-bundle.config.ts @@ -2,7 +2,6 @@ export default { plugin: { description: 'A minimal end-to-end spot-check fixture with one skill and one deterministic eval.', name: 'micro-eval', - version: '1.0.0', }, targets: ['portable'], }; diff --git a/fixtures/integration/micro-eval/package.json b/fixtures/integration/micro-eval/package.json new file mode 100644 index 000000000..48d147b7e --- /dev/null +++ b/fixtures/integration/micro-eval/package.json @@ -0,0 +1,6 @@ +{ + "name": "agent-bundle-micro-eval-fixture", + "private": true, + "type": "module", + "version": "1.0.0" +} diff --git a/fixtures/integration/packed-release/agent-bundle.config.ts b/fixtures/integration/packed-release/agent-bundle.config.ts index 9e586ab78..58d35554d 100644 --- a/fixtures/integration/packed-release/agent-bundle.config.ts +++ b/fixtures/integration/packed-release/agent-bundle.config.ts @@ -20,7 +20,6 @@ export default { plugin: { description: 'Exercises an installed Agent Bundle release without workspace sources.', name: 'packed-release-fixture', - version: '1.0.0', }, scripts: { review: './src/review.ts', diff --git a/fixtures/integration/packed-release/package.json b/fixtures/integration/packed-release/package.json index 55292deb3..aec7266dd 100644 --- a/fixtures/integration/packed-release/package.json +++ b/fixtures/integration/packed-release/package.json @@ -1,5 +1,6 @@ { "name": "agent-bundle-packed-release-fixture", "private": true, - "type": "module" + "type": "module", + "version": "1.0.0" } diff --git a/fixtures/integration/skills-only/agent-bundle.config.ts b/fixtures/integration/skills-only/agent-bundle.config.ts index 01a3a25ff..20ac5c95c 100644 --- a/fixtures/integration/skills-only/agent-bundle.config.ts +++ b/fixtures/integration/skills-only/agent-bundle.config.ts @@ -2,7 +2,6 @@ export default { plugin: { description: 'A portable skills-only fixture.', name: 'portable-skills-only', - version: '1.0.0', }, targets: ['portable'], }; diff --git a/fixtures/integration/skills-only/package.json b/fixtures/integration/skills-only/package.json new file mode 100644 index 000000000..eb553b49a --- /dev/null +++ b/fixtures/integration/skills-only/package.json @@ -0,0 +1,6 @@ +{ + "name": "agent-bundle-skills-only-fixture", + "private": true, + "type": "module", + "version": "1.0.0" +} diff --git a/fixtures/integration/unsupported-capability/agent-bundle.config.ts b/fixtures/integration/unsupported-capability/agent-bundle.config.ts index 629d72405..af4a97b66 100644 --- a/fixtures/integration/unsupported-capability/agent-bundle.config.ts +++ b/fixtures/integration/unsupported-capability/agent-bundle.config.ts @@ -2,6 +2,6 @@ export default { hooks: { sessionStart: { handler: './src/hook.ts', targets: ['portable'] }, }, - plugin: { name: 'unsupported-capability', version: '1.0.0' }, + plugin: { name: 'unsupported-capability' }, targets: ['portable'], }; diff --git a/fixtures/integration/unsupported-capability/package.json b/fixtures/integration/unsupported-capability/package.json new file mode 100644 index 000000000..d4f1a212f --- /dev/null +++ b/fixtures/integration/unsupported-capability/package.json @@ -0,0 +1,6 @@ +{ + "name": "agent-bundle-unsupported-capability-fixture", + "private": true, + "type": "module", + "version": "1.0.0" +} diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 3bf5db550..94693d2e1 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -135,7 +135,7 @@ manifests at files inside those payloads without compiling them. Payload files c | --- | --- | | `agent-bundle build` | Build a validated artifact from source, plus the declared `dist/` package build. | | `agent-bundle prepack` | Run the release build, dry-run npm packing without scripts, and verify packaged outputs, artifact hashes, bins, and versions (`--output` and `--json` supported). | -| `agent-bundle install ` | Install a built bundle into Claude, Codex, or Cursor (`--from`, `--scope`, `--replace`/`--force`, `--mode local\|marketplace` for Cursor, and `--json` supported). Same-version content drift of an agent-bundle-managed install is replaced automatically; identical reruns are a no-op. | +| `agent-bundle install ` | Install a built bundle into Claude, Codex, or Cursor (`--from`, `--scope`, `--replace`, `--mode local\|marketplace` for Cursor, and `--json` supported). Same-version content drift of an agent-bundle-managed install is replaced automatically; identical reruns are a no-op. | | `agent-bundle uninstall ` | Remove a receipt-owned install and nothing else: the receipt's files and directories, the host registrations it recorded (`claude plugin uninstall --keep-data` + `marketplace remove`, `codex plugin remove` + `marketplace remove`, the Cursor local directory or staged marketplace). `--plan` prints the exact paths without changing anything; the effective framework state root, web-data, and legacy `state/` are kept unless `--purge-data --confirm-purge`; a missing receipt or content mismatch is refused unless `--force`; a rerun is `not-installed`. | | `agent-bundle doctor` | Read-only host inspection: host probes, installed inventory, effective and legacy state roots with existence and writability, store receipts cross-checked against the host, and, with `--from`, the installed copy compared against the built artifact by version and content hash (`current`, `stale`, `version-mismatch`, `foreign`, `not-installed`) plus the lifecycle stage (placed → registered → enabled → active, unobservable stages typed `unavailable`). | | `agent-bundle validate` | Validate project source, or an artifact with `--artifact`. | @@ -270,7 +270,7 @@ every host treats it differently. `agent-bundle install` and the emitted version-gated and a plain reinstall reports "already installed" while the cache stays stale. Codex replacement runs `codex plugin remove` before `marketplace add` + `add`, so files a rebuild removed do not linger. -- **`--replace` (alias `--force`).** Also replaces an agent-bundle install of +- **`--replace`.** Also replaces an agent-bundle install of the same plugin at a *different* version, and adopts a Cursor copy that was installed before receipts existed (recognised by its emitted `INSTALL.md` + `install.mjs` and matching manifest name). A legacy copy has no owned-file diff --git a/packages/agent-bundle/fixtures/durable-web-surface/agent-bundle.config.ts b/packages/agent-bundle/fixtures/durable-web-surface/agent-bundle.config.ts index 67bec4d46..2828089e1 100644 --- a/packages/agent-bundle/fixtures/durable-web-surface/agent-bundle.config.ts +++ b/packages/agent-bundle/fixtures/durable-web-surface/agent-bundle.config.ts @@ -21,7 +21,6 @@ export default { plugin: { description: 'A workspace-durable plugin whose MCP App is exposed through web.apps and whose CLI reads the same state.', name: 'durable-web-surface-fixture', - version: '1.0.0', }, targets: ['portable'], web: { apps: [{ allow: ['call-tool'], app: 'journal/status' }] }, diff --git a/packages/agent-bundle/fixtures/durable-web-surface/package.json b/packages/agent-bundle/fixtures/durable-web-surface/package.json index a5879a15f..9f61fdfc9 100644 --- a/packages/agent-bundle/fixtures/durable-web-surface/package.json +++ b/packages/agent-bundle/fixtures/durable-web-surface/package.json @@ -1,5 +1,6 @@ { "name": "durable-web-surface-fixture", "private": true, - "type": "module" + "type": "module", + "version": "1.0.0" } diff --git a/packages/agent-bundle/fixtures/durable-web-surface/src/mcp/journal/tools/record.tsx b/packages/agent-bundle/fixtures/durable-web-surface/src/mcp/journal/tools/record.tsx index 3e4b87998..f2564c5bd 100644 --- a/packages/agent-bundle/fixtures/durable-web-surface/src/mcp/journal/tools/record.tsx +++ b/packages/agent-bundle/fixtures/durable-web-surface/src/mcp/journal/tools/record.tsx @@ -1,7 +1,19 @@ import { Agent, agent } from '@agent-bundle/runtime'; +import { defineTool } from 'agent-bundle/routes'; import { z } from 'zod'; -export const config = { +export const inputSchema = z.object({ note: z.string().min(1) }).strict(); + +export const resultSchema = z.object({ + entries: z.array(z.object({ note: z.string() }).strict()), + revision: z.number().int().nonnegative(), +}).strict(); + +interface JournalState { + readonly entries: readonly { readonly note: string }[]; +} + +export default defineTool({ inputJsonSchema: { "additionalProperties": false, "properties": { @@ -16,21 +28,10 @@ export const config = { }, _meta: { ui: { resourceUri: 'ui://durable-web-surface-fixture/status.html' } }, description: 'Appends one note to the durable journal and reports every entry.', + inputSchema, + resultSchema, title: 'Record', -}; - -export const inputSchema = z.object({ note: z.string().min(1) }).strict(); - -export const resultSchema = z.object({ - entries: z.array(z.object({ note: z.string() }).strict()), - revision: z.number().int().nonnegative(), -}).strict(); - -interface JournalState { - readonly entries: readonly { readonly note: string }[]; -} - -export default async function Record({ input }: { readonly input: z.infer }) { +}, async (input) => { const context = await agent(); if (context.state === undefined) throw new TypeError('Journal state is unavailable.'); // The note is the idempotency key: a replayed note is recorded once. @@ -43,4 +44,4 @@ export default async function Record({ input }: { readonly input: z.infer{`recorded ${String(state.entries.length)} note(s)`} ); -} +}); diff --git a/packages/agent-bundle/fixtures/meta-consumer/agent-bundle.config.ts b/packages/agent-bundle/fixtures/meta-consumer/agent-bundle.config.ts index 15d382976..d208aa314 100644 --- a/packages/agent-bundle/fixtures/meta-consumer/agent-bundle.config.ts +++ b/packages/agent-bundle/fixtures/meta-consumer/agent-bundle.config.ts @@ -1,8 +1,7 @@ // A project whose source imports `agent-bundle/meta` (issue #386). Plain -// object export, like every other repository fixture. `plugin.version` is -// omitted on purpose: the resolved version, `packageName`, and -// `packageVersion` all derive from the sibling package.json, which is the -// identity the Rstest presets must hand to source under test. +// object export, like every other repository fixture. The resolved version, +// `packageName`, and `packageVersion` all derive from the sibling package.json, +// which is the identity the Rstest presets must hand to source under test. export default { plugin: { description: 'Source that reads its own release identity from agent-bundle/meta.', diff --git a/packages/agent-bundle/fixtures/meta-consumer/src/mcp/consumer/tools/identity.ts b/packages/agent-bundle/fixtures/meta-consumer/src/mcp/consumer/tools/identity.ts index d465b0cf7..d00b84bf0 100644 --- a/packages/agent-bundle/fixtures/meta-consumer/src/mcp/consumer/tools/identity.ts +++ b/packages/agent-bundle/fixtures/meta-consumer/src/mcp/consumer/tools/identity.ts @@ -1,20 +1,10 @@ import { Agent } from '@agent-bundle/runtime'; +import { defineTool } from 'agent-bundle/routes'; import { createElement } from 'react'; import { z } from 'zod'; import { banner, identity } from '../../../lib/identity.ts'; -export const config = { - inputJsonSchema: { - "additionalProperties": false, - "properties": {}, - "type": "object" - }, - annotations: { readOnlyHint: true }, - description: 'Reports the identity agent-bundle/meta resolved to.', - title: 'Identity', -}; - export const inputSchema = z.object({}); export const resultSchema = z.object({ @@ -25,7 +15,18 @@ export const resultSchema = z.object({ version: z.string(), }); -export default async function Identity() { +export default defineTool({ + inputJsonSchema: { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + annotations: { readOnlyHint: true }, + description: 'Reports the identity agent-bundle/meta resolved to.', + title: 'Identity', + inputSchema, + resultSchema, +}, async () => { // The document value is JSON; the optional npm axes are omitted when absent // rather than carried as `undefined`. const value = { @@ -36,4 +37,4 @@ export default async function Identity() { version: identity.version, }; return createElement(Agent.Result, { value }, createElement(Agent.Text, null, banner)); -} +}); diff --git a/packages/agent-bundle/fixtures/route-harness/agent-bundle.config.ts b/packages/agent-bundle/fixtures/route-harness/agent-bundle.config.ts index d9bb09fcf..b0c66f126 100644 --- a/packages/agent-bundle/fixtures/route-harness/agent-bundle.config.ts +++ b/packages/agent-bundle/fixtures/route-harness/agent-bundle.config.ts @@ -5,7 +5,6 @@ export default { plugin: { description: 'Conventional route modules for the route-unit test harness.', name: 'route-harness', - version: '1.0.0', }, routes: { mcpCommands: true, diff --git a/packages/agent-bundle/fixtures/route-harness/package.json b/packages/agent-bundle/fixtures/route-harness/package.json new file mode 100644 index 000000000..a0e955605 --- /dev/null +++ b/packages/agent-bundle/fixtures/route-harness/package.json @@ -0,0 +1,5 @@ +{ + "private": true, + "type": "module", + "version": "1.0.0" +} diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/catalog.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/catalog.tsx index 186707400..082717132 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/catalog.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/catalog.tsx @@ -1,23 +1,8 @@ +import { defineTool } from 'agent-bundle/routes'; import { Agent } from '@agent-bundle/runtime'; import { Suspense } from 'react'; import { z } from 'zod'; -export const config = { - inputJsonSchema: { - "additionalProperties": false, - "properties": { - "genre": { - "type": "string" - } - }, - "type": "object" - }, - description: 'Streams the harness catalog behind one Suspense boundary.', - // Its streamed Agent.Progress fallback is what a task reports through tasks/get (#369). - execution: { taskSupport: 'optional' }, - title: 'Catalog', -}; - export const inputSchema = z.object({ genre: z.string().optional() }); export const resultSchema = z.object({ genre: z.string(), titles: z.array(z.string()) }); @@ -32,7 +17,7 @@ const Titles = async ({ genre }: { readonly genre: string }) => { return {`## ${genre}\n\n${titles.map((title) => `- ${title}`).join('\n')}`}; }; -export default async function Catalog({ input }: { readonly input: z.infer }) { +async function Catalog({ input }: { readonly input: z.infer }) { const genre = input.genre ?? 'all'; return ( @@ -43,3 +28,21 @@ export default async function Catalog({ input }: { readonly input: z.infer ); } + +export default defineTool({ +inputJsonSchema: { + "additionalProperties": false, + "properties": { + "genre": { + "type": "string" + } + }, + "type": "object" + }, + description: 'Streams the harness catalog behind one Suspense boundary.', + // Its streamed Agent.Progress fallback is what a task reports through tasks/get (#369). + execution: { taskSupport: 'optional' }, + title: 'Catalog', + inputSchema, + resultSchema, +}, async (input) => Catalog({ input })); diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/context.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/context.tsx index 0b6f1914a..81e9ae9ee 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/context.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/context.tsx @@ -1,23 +1,7 @@ +import { defineTool } from 'agent-bundle/routes'; import { Agent, agent, type JsonValue } from '@agent-bundle/runtime'; import { z } from 'zod'; -export const config = { - inputJsonSchema: { - "additionalProperties": false, - "properties": { - "host": { - "type": "string" - }, - "session": { - "type": "string" - } - }, - "type": "object" - }, - description: 'Returns the request identity axes observed by this route.', - title: 'Context', -}; - export const inputSchema = z.object({ host: z.string().optional(), session: z.string().optional(), @@ -32,7 +16,7 @@ export const resultSchema = z.object({ workspace: z.unknown(), }).strict(); -export default async function Context() { +async function Context() { const context = await agent(); const actor: JsonValue = context.actor.state === 'available' ? { source: context.actor.source, state: context.actor.state, value: { id: context.actor.value.id } } @@ -66,3 +50,22 @@ export default async function Context() { ); } + +export default defineTool({ +inputJsonSchema: { + "additionalProperties": false, + "properties": { + "host": { + "type": "string" + }, + "session": { + "type": "string" + } + }, + "type": "object" + }, + description: 'Returns the request identity axes observed by this route.', + title: 'Context', + inputSchema, + resultSchema, +}, async () => Context()); diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/echo.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/echo.tsx index 75ea40ac4..83cc01010 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/echo.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/echo.tsx @@ -1,21 +1,7 @@ +import { defineTool } from 'agent-bundle/routes'; import { Agent, agent } from '@agent-bundle/runtime'; import { z } from 'zod'; -export const config = { - inputJsonSchema: { - "additionalProperties": false, - "properties": { - "message": { - "type": "string" - } - }, - "type": "object" - }, - annotations: { readOnlyHint: true }, - description: 'Echoes one message back with the observed workspace root.', - title: 'Echo', -}; - export const inputSchema = z.object({ message: z.string().optional() }); export const resultSchema = z.object({ @@ -24,7 +10,7 @@ export const resultSchema = z.object({ workspace: z.string().nullable(), }); -export default async function Echo({ input }: { readonly input: z.infer }) { +async function Echo({ input }: { readonly input: z.infer }) { const context = await agent(); await context.progress.report({ completed: 1, message: 'echoing', total: 1 }); const workspace = context.workspace.state === 'available' ? context.workspace.value.root : null; @@ -36,3 +22,20 @@ export default async function Echo({ input }: { readonly input: z.infer ); } + +export default defineTool({ +inputJsonSchema: { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + }, + annotations: { readOnlyHint: true }, + description: 'Echoes one message back with the observed workspace root.', + title: 'Echo', + inputSchema, + resultSchema, +}, async (input) => Echo({ input })); diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/fault.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/fault.tsx index 13e72e828..59d507f61 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/fault.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/fault.tsx @@ -1,27 +1,8 @@ +import { defineTool } from 'agent-bundle/routes'; import { Agent } from '@agent-bundle/runtime'; import { Suspense } from 'react'; import { z } from 'zod'; -export const config = { - inputJsonSchema: { - "additionalProperties": false, - "properties": { - "mode": { - "enum": [ - "ok", - "throw", - "reject-boundary" - ], - "type": "string", - "default": "ok" - } - }, - "type": "object" - }, - annotations: { readOnlyHint: true }, - description: 'Throws from the route or from a nested Suspense boundary, for thrown-error projection proof.', -}; - export const inputSchema = z.object({ mode: z.enum(['ok', 'throw', 'reject-boundary']).default('ok'), }); @@ -42,7 +23,7 @@ const Rejecting = async () => { * `reject-boundary` streams a shell whose nested Suspense child rejects. * Neither renders `Agent.Error` — that represented path is `unavailable.tsx`. */ -export default async function Fault({ input }: { readonly input: z.infer }) { +async function Fault({ input }: { readonly input: z.infer }) { if (input.mode === 'throw') throw new Error('fault: route threw'); return ( @@ -57,3 +38,25 @@ export default async function Fault({ input }: { readonly input: z.infer ); } + +export default defineTool({ +inputJsonSchema: { + "additionalProperties": false, + "properties": { + "mode": { + "enum": [ + "ok", + "throw", + "reject-boundary" + ], + "type": "string", + "default": "ok" + } + }, + "type": "object" + }, + annotations: { readOnlyHint: true }, + description: 'Throws from the route or from a nested Suspense boundary, for thrown-error projection proof.', + inputSchema, + resultSchema, +}, async (input) => Fault({ input })); diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/journal.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/journal.tsx index 890208561..074fb83ee 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/journal.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/journal.tsx @@ -1,20 +1,7 @@ +import { defineTool } from 'agent-bundle/routes'; import { Agent, agent } from '@agent-bundle/runtime'; import { z } from 'zod'; -export const config = { - inputJsonSchema: { - "additionalProperties": false, - "properties": { - "note": { - "type": "string" - } - }, - "type": "object" - }, - description: 'Records and reads durable route-harness journal entries.', - title: 'Journal', -}; - export const inputSchema = z.object({ note: z.string().optional() }).strict(); export const resultSchema = z.object({ @@ -26,7 +13,7 @@ interface JournalState { readonly entries: readonly { readonly note: string }[]; } -export default async function Journal({ input }: { readonly input: z.infer }) { +async function Journal({ input }: { readonly input: z.infer }) { const context = await agent(); if (context.state === undefined) throw new TypeError('Journal state is unavailable.'); if (input.note !== undefined) { @@ -47,3 +34,19 @@ export default async function Journal({ input }: { readonly input: z.infer ); } + +export default defineTool({ +inputJsonSchema: { + "additionalProperties": false, + "properties": { + "note": { + "type": "string" + } + }, + "type": "object" + }, + description: 'Records and reads durable route-harness journal entries.', + title: 'Journal', + inputSchema, + resultSchema, +}, async (input) => Journal({ input })); diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/layout-probe.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/layout-probe.tsx index a7c6d59fc..a705e99ba 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/layout-probe.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/layout-probe.tsx @@ -1,8 +1,21 @@ +import { defineTool } from 'agent-bundle/routes'; import { Agent } from '@agent-bundle/runtime'; import { z } from 'zod'; -export const config = { - inputJsonSchema: { +export const inputSchema = z.object({ label: z.string().default('probe') }); + +export const resultSchema = z.object({ label: z.string() }); + +async function LayoutProbe({ input }: { readonly input: z.infer }) { + return ( + + {`probe: ${input.label}`} + + ); +} + +export default defineTool({ +inputJsonSchema: { "additionalProperties": false, "properties": { "label": { @@ -15,16 +28,6 @@ export const config = { annotations: { readOnlyHint: true }, description: 'Renders a bare valued result so the layout chain around it is observable.', title: 'Layout probe', -}; - -export const inputSchema = z.object({ label: z.string().default('probe') }); - -export const resultSchema = z.object({ label: z.string() }); - -export default async function LayoutProbe({ input }: { readonly input: z.infer }) { - return ( - - {`probe: ${input.label}`} - - ); -} + inputSchema, + resultSchema, +}, async (input) => LayoutProbe({ input })); diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/lifecycle.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/lifecycle.tsx index d32afaebf..ea339480f 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/lifecycle.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/lifecycle.tsx @@ -1,3 +1,4 @@ +import { defineTool } from 'agent-bundle/routes'; import { setTimeout as delay } from 'node:timers/promises'; import { Agent, agent } from '@agent-bundle/runtime'; @@ -12,47 +13,6 @@ const lifecyclePhaseSchema = z.enum([ 'terminal', ]); -export const config = { - inputJsonSchema: { - "additionalProperties": false, - "properties": { - "action": { - "enum": [ - "exceed-budget", - "observe", - "transition" - ], - "type": "string" - }, - "emitProgress": { - "type": "boolean" - }, - "idempotencyKey": { - "type": "string" - }, - "payload": { - "type": "string" - }, - "phase": { - "enum": [ - "queued", - "running", - "first-progress", - "repeated-progress", - "terminal" - ], - "type": "string" - } - }, - "required": [ - "action" - ], - "type": "object" - }, - description: 'Replays a deterministic durable lifecycle through mounted state.', - title: 'Lifecycle', -}; - export const inputSchema = z.object({ action: z.enum(['exceed-budget', 'observe', 'transition']), emitProgress: z.boolean().optional(), @@ -79,7 +39,7 @@ interface LifecycleState { }; } -export default async function Lifecycle({ input }: { readonly input: z.infer }) { +async function Lifecycle({ input }: { readonly input: z.infer }) { const context = await agent(); if (context.state === undefined) throw new TypeError('Lifecycle state is unavailable.'); @@ -174,3 +134,46 @@ export default async function Lifecycle({ input }: { readonly input: z.infer ); } + +export default defineTool({ +inputJsonSchema: { + "additionalProperties": false, + "properties": { + "action": { + "enum": [ + "exceed-budget", + "observe", + "transition" + ], + "type": "string" + }, + "emitProgress": { + "type": "boolean" + }, + "idempotencyKey": { + "type": "string" + }, + "payload": { + "type": "string" + }, + "phase": { + "enum": [ + "queued", + "running", + "first-progress", + "repeated-progress", + "terminal" + ], + "type": "string" + } + }, + "required": [ + "action" + ], + "type": "object" + }, + description: 'Replays a deterministic durable lifecycle through mounted state.', + title: 'Lifecycle', + inputSchema, + resultSchema, +}, async (input) => Lifecycle({ input })); diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/mutation-probe.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/mutation-probe.tsx index f07fafced..00e844191 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/mutation-probe.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/mutation-probe.tsx @@ -1,22 +1,9 @@ +import { defineTool } from 'agent-bundle/routes'; import { Agent, agent } from '@agent-bundle/runtime'; import { z } from 'zod'; let executions = 0; -export const config = { - inputJsonSchema: { - "additionalProperties": false, - "properties": { - "marker": { - "type": "string" - } - }, - "type": "object" - }, - description: 'Records how many times the mutation probe executed.', - title: 'Mutation probe', -}; - export const inputSchema = z.object({ marker: z.string().optional(), }).strict(); @@ -28,7 +15,7 @@ export const resultSchema = z.object({ operationId: z.string(), }).strict(); -export default async function MutationProbe({ +async function MutationProbe({ input, }: { readonly input: z.infer; @@ -47,3 +34,19 @@ export default async function MutationProbe({ ); } + +export default defineTool({ +inputJsonSchema: { + "additionalProperties": false, + "properties": { + "marker": { + "type": "string" + } + }, + "type": "object" + }, + description: 'Records how many times the mutation probe executed.', + title: 'Mutation probe', + inputSchema, + resultSchema, +}, async (input) => MutationProbe({ input })); diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/plugin-root.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/plugin-root.tsx index c0fc62bca..ab7ebb9dd 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/plugin-root.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/plugin-root.tsx @@ -1,17 +1,7 @@ +import { defineTool } from 'agent-bundle/routes'; import { Agent, agent, type JsonValue } from '@agent-bundle/runtime'; import { z } from 'zod'; -export const config = { - inputJsonSchema: { - "additionalProperties": false, - "properties": {}, - "type": "object" - }, - annotations: { readOnlyHint: true }, - description: 'Reports the plugin root and durable-state anchor this route observes.', - title: 'Plugin root', -}; - export const inputSchema = z.object({}).strict(); export const resultSchema = z.object({ @@ -23,7 +13,7 @@ export const resultSchema = z.object({ * level can assert the anchor a generated scope resolved from * `AGENT_BUNDLE_PLUGIN_ROOT` (or its fallback) reached the request. */ -export default async function PluginRoot() { +async function PluginRoot() { const { plugin } = await agent(); const observed: JsonValue = plugin.state === 'available' ? { source: plugin.source, state: plugin.state, value: { root: plugin.value.root, stateRoot: plugin.value.stateRoot } } @@ -34,3 +24,16 @@ export default async function PluginRoot() { ); } + +export default defineTool({ +inputJsonSchema: { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + annotations: { readOnlyHint: true }, + description: 'Reports the plugin root and durable-state anchor this route observes.', + title: 'Plugin root', + inputSchema, + resultSchema, +}, async () => PluginRoot()); diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/publish-notice.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/publish-notice.tsx index 9837a61df..6995746db 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/publish-notice.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/publish-notice.tsx @@ -1,36 +1,8 @@ +import { defineTool } from 'agent-bundle/routes'; import { Agent, agent } from '@agent-bundle/runtime'; import type { AgentNoticePublishInput } from '@agent-bundle/runtime/notices'; import { z } from 'zod'; -export const config = { - inputJsonSchema: { - "additionalProperties": false, - "properties": { - "message": { - "type": "string" - }, - "recipientSession": { - "type": "string" - }, - "sensitivity": { - "enum": [ - "public", - "internal", - "secret" - ], - "type": "string" - } - }, - "required": [ - "message", - "recipientSession" - ], - "type": "object" - }, - description: 'Publishes a durable notice for a later session event.', - title: 'Publish notice', -}; - export const inputSchema = z.object({ message: z.string(), recipientSession: z.string(), @@ -44,7 +16,7 @@ export const resultSchema = z.object({ state: z.literal('pending'), }).strict(); -export default async function PublishNotice({ input }: { readonly input: z.infer }) { +async function PublishNotice({ input }: { readonly input: z.infer }) { const context = await agent(); if (context.notices === undefined) throw new TypeError('Notice publishing is unavailable.'); // `satisfies` pins the publish API surface: a vocabulary change on @@ -75,3 +47,34 @@ export default async function PublishNotice({ input }: { readonly input: z.infer ); } + +export default defineTool({ +inputJsonSchema: { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + }, + "recipientSession": { + "type": "string" + }, + "sensitivity": { + "enum": [ + "public", + "internal", + "secret" + ], + "type": "string" + } + }, + "required": [ + "message", + "recipientSession" + ], + "type": "object" + }, + description: 'Publishes a durable notice for a later session event.', + title: 'Publish notice', + inputSchema, + resultSchema, +}, async (input) => PublishNotice({ input })); diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/select.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/select.tsx index 9ed1964d2..81b146718 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/select.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/select.tsx @@ -1,12 +1,7 @@ +import { defineTool } from 'agent-bundle/routes'; import { Agent, agent } from '@agent-bundle/runtime'; import { z } from 'zod'; -export const config = { - annotations: { readOnlyHint: false }, - description: 'Selects catalog entries by id or by query, with nested filters no flag grammar can spell.', - title: 'Select', -}; - // A discriminated union under a nested object: the bounded argv grammar has // no flag form for it, so the tool's CLI projection takes canonical JSON. export const inputSchema = z.object({ @@ -29,7 +24,7 @@ export const resultSchema = z.object({ ]), }); -export default async function Select({ input }: { readonly input: z.infer }) { +async function Select({ input }: { readonly input: z.infer }) { const context = await agent(); const value = { filters: input.filters, invocation: context.invocation.kind as 'cli' | 'tool', selection: input.selection }; return ( @@ -38,3 +33,11 @@ export default async function Select({ input }: { readonly input: z.infer ); } + +export default defineTool({ +annotations: { readOnlyHint: false }, + description: 'Selects catalog entries by id or by query, with nested filters no flag grammar can spell.', + title: 'Select', + inputSchema, + resultSchema, +}, async (input) => Select({ input })); diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/strict-report.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/strict-report.tsx index 46d8703ab..8b6dad53a 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/strict-report.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/strict-report.tsx @@ -1,21 +1,7 @@ +import { defineTool } from 'agent-bundle/routes'; import { Agent } from '@agent-bundle/runtime'; import { z } from 'zod'; -export const config = { - inputJsonSchema: { - "additionalProperties": false, - "properties": { - "reportId": { - "type": "string" - } - }, - "type": "object" - }, - _meta: { ui: { resourceUri: 'ui://route-harness/panel.html' } }, - description: 'Returns a closed-object report that rejects unknown serialized keys.', - title: 'Strict report', -}; - export const inputSchema = z.object({ reportId: z.string().optional() }); export const resultSchema = z.strictObject({ @@ -23,7 +9,7 @@ export const resultSchema = z.strictObject({ summary: z.string(), }); -export default async function StrictReport({ input }: { readonly input: z.infer }) { +async function StrictReport({ input }: { readonly input: z.infer }) { const reportId = input.reportId ?? 'report-1'; const value = { reportId, summary: `summary for ${reportId}` }; // The MCP Apps convention stamps the App resource on every result as well @@ -34,3 +20,20 @@ export default async function StrictReport({ input }: { readonly input: z.infer< ); } + +export default defineTool({ +inputJsonSchema: { + "additionalProperties": false, + "properties": { + "reportId": { + "type": "string" + } + }, + "type": "object" + }, + _meta: { ui: { resourceUri: 'ui://route-harness/panel.html' } }, + description: 'Returns a closed-object report that rejects unknown serialized keys.', + title: 'Strict report', + inputSchema, + resultSchema, +}, async (input) => StrictReport({ input })); diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/submit.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/submit.tsx index 045f27bf2..a1ef48206 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/submit.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/submit.tsx @@ -1,8 +1,47 @@ +import { defineTool } from 'agent-bundle/routes'; import { Agent, agent } from '@agent-bundle/runtime'; import { z } from 'zod'; -export const config = { - inputJsonSchema: { +export const inputSchema = z.object({ + argv: z.array(z.string()).min(1).describe('The command line to run.'), + cwd: z.string().min(1).describe('Working directory of the command.'), + laneKey: z.string().min(1).optional().describe('Lane the work is queued under.'), + regions: z.array(z.enum(['eu', 'us'])).optional().describe('Regions the work may run in.'), + tags: z.array(z.string()).optional().describe('Tags attached to the request.'), +}); + +export const resultSchema = z.object({ + argv: z.array(z.string()).min(1), + cwd: z.string().min(1), + laneKey: z.string().optional(), + operation: z.literal('submit'), + regions: z.array(z.string()).optional(), + tags: z.array(z.string()).optional(), +}); + +async function Submit({ input }: { readonly input: z.infer }) { + const context = await agent(); + const value = { + argv: input.argv, + cwd: input.cwd, + ...(input.laneKey === undefined ? {} : { laneKey: input.laneKey }), + operation: 'submit' as const, + ...(input.regions === undefined ? {} : { regions: input.regions }), + ...(input.tags === undefined ? {} : { tags: input.tags }), + }; + const { invocation } = context; + const libraryTooling = await context.provider('libraryTooling'); + return ( + + {`submit: ${input.argv.join(' ')}`} + {`invocation: ${invocation.kind} ${invocation.operationId ?? '(no operation)'} ${invocation.surface ?? '(no surface)'}`} + {`provider: ${JSON.stringify(libraryTooling)}`} + + ); +} + +export default defineTool({ +inputJsonSchema: { "additionalProperties": false, "properties": { "argv": { @@ -48,42 +87,6 @@ export const config = { annotations: { readOnlyHint: false }, description: 'Submits one command line as lane work and echoes the accepted request.', title: 'Submit', -}; - -export const inputSchema = z.object({ - argv: z.array(z.string()).min(1).describe('The command line to run.'), - cwd: z.string().min(1).describe('Working directory of the command.'), - laneKey: z.string().min(1).optional().describe('Lane the work is queued under.'), - regions: z.array(z.enum(['eu', 'us'])).optional().describe('Regions the work may run in.'), - tags: z.array(z.string()).optional().describe('Tags attached to the request.'), -}); - -export const resultSchema = z.object({ - argv: z.array(z.string()).min(1), - cwd: z.string().min(1), - laneKey: z.string().optional(), - operation: z.literal('submit'), - regions: z.array(z.string()).optional(), - tags: z.array(z.string()).optional(), -}); - -export default async function Submit({ input }: { readonly input: z.infer }) { - const context = await agent(); - const value = { - argv: input.argv, - cwd: input.cwd, - ...(input.laneKey === undefined ? {} : { laneKey: input.laneKey }), - operation: 'submit' as const, - ...(input.regions === undefined ? {} : { regions: input.regions }), - ...(input.tags === undefined ? {} : { tags: input.tags }), - }; - const { invocation } = context; - const libraryTooling = await context.provider('libraryTooling'); - return ( - - {`submit: ${input.argv.join(' ')}`} - {`invocation: ${invocation.kind} ${invocation.operationId ?? '(no operation)'} ${invocation.surface ?? '(no surface)'}`} - {`provider: ${JSON.stringify(libraryTooling)}`} - - ); -} + inputSchema, + resultSchema, +}, async (input) => Submit({ input })); diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/ticket.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/ticket.tsx index 460968b8e..3a3d296ad 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/ticket.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/ticket.tsx @@ -1,35 +1,7 @@ +import { defineTool } from 'agent-bundle/routes'; import { Agent } from '@agent-bundle/runtime'; import { z } from 'zod'; -export const config = { - inputJsonSchema: { - "additionalProperties": false, - "properties": { - "includeDiagnostics": { - "type": "boolean" - }, - "includeExecArgv": { - "type": "boolean" - }, - "includeTail": { - "type": "boolean" - }, - "status": { - "enum": [ - "pending", - "running", - "completed", - "failed" - ], - "type": "string" - } - }, - "type": "object" - }, - description: 'Returns a cargo-conductor-shaped ticket status with optional diagnostics fields.', - title: 'Ticket', -}; - export const inputSchema = z.object({ includeDiagnostics: z.boolean().optional(), includeExecArgv: z.boolean().optional(), @@ -44,7 +16,7 @@ export const resultSchema = z.object({ tail: z.string().optional(), }); -export default async function Ticket({ input }: { readonly input: z.infer }) { +async function Ticket({ input }: { readonly input: z.infer }) { const status = input.status ?? 'completed'; const value = { status, @@ -58,3 +30,34 @@ export default async function Ticket({ input }: { readonly input: z.infer ); } + +export default defineTool({ +inputJsonSchema: { + "additionalProperties": false, + "properties": { + "includeDiagnostics": { + "type": "boolean" + }, + "includeExecArgv": { + "type": "boolean" + }, + "includeTail": { + "type": "boolean" + }, + "status": { + "enum": [ + "pending", + "running", + "completed", + "failed" + ], + "type": "string" + } + }, + "type": "object" + }, + description: 'Returns a cargo-conductor-shaped ticket status with optional diagnostics fields.', + title: 'Ticket', + inputSchema, + resultSchema, +}, async (input) => Ticket({ input })); diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/tooling.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/tooling.tsx index 93a6b7ebb..932317917 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/tooling.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/tooling.tsx @@ -1,24 +1,7 @@ +import { defineTool } from 'agent-bundle/routes'; import { Agent, agent, type JsonValue } from '@agent-bundle/runtime'; import { z } from 'zod'; -export const config = { - inputJsonSchema: { - "additionalProperties": false, - "properties": { - "failProvider": { - "type": "boolean" - }, - "inbox": { - "type": "boolean" - } - }, - "type": "object" - }, - annotations: { readOnlyHint: true }, - description: 'Reports the request providers an MCP tool observes.', - title: 'Tooling', -}; - export const inputSchema = z.object({ /** Makes the `library-tooling` provider throw, to prove the request fails closed. */ failProvider: z.boolean().optional(), @@ -33,7 +16,7 @@ export const resultSchema = z.object({ requestView: z.unknown().optional(), }).strict(); -export default async function Tooling() { +async function Tooling() { const context = await agent(); const providers = { processLifetime: context.process, @@ -53,3 +36,23 @@ export default async function Tooling() { ); } + +export default defineTool({ +inputJsonSchema: { + "additionalProperties": false, + "properties": { + "failProvider": { + "type": "boolean" + }, + "inbox": { + "type": "boolean" + } + }, + "type": "object" + }, + annotations: { readOnlyHint: true }, + description: 'Reports the request providers an MCP tool observes.', + title: 'Tooling', + inputSchema, + resultSchema, +}, async () => Tooling()); diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/unavailable.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/unavailable.tsx index a6afb784f..8cf1f63f3 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/unavailable.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/unavailable.tsx @@ -1,24 +1,27 @@ +import { defineTool } from 'agent-bundle/routes'; import { Agent } from '@agent-bundle/runtime'; import { z } from 'zod'; -export const config = { - inputJsonSchema: { - "additionalProperties": false, - "properties": {}, - "type": "object" - }, - description: 'Returns a typed unavailable result for projection checks.', - title: 'Unavailable', -}; - export const inputSchema = z.object({}); export const resultSchema = z.object({ available: z.literal(false) }); -export default async function Unavailable() { +async function Unavailable() { return ( The harness fixture represents this capability as unavailable. ); } + +export default defineTool({ +inputJsonSchema: { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + description: 'Returns a typed unavailable result for projection checks.', + title: 'Unavailable', + inputSchema, + resultSchema, +}, async () => Unavailable()); diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/wait.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/wait.tsx index f42179aa3..af949fdca 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/wait.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/wait.tsx @@ -1,30 +1,9 @@ +import { defineTool } from 'agent-bundle/routes'; import { Agent, agent } from '@agent-bundle/runtime'; import { z } from 'zod'; const maxHoldMs = 5000; -export const config = { - inputJsonSchema: { - "additionalProperties": false, - "properties": { - "holdMs": { - "type": "number" - }, - "tickMs": { - "type": "number" - } - }, - "type": "object" - }, - description: 'Waits until aborted or holdMs elapses, for cancellation contract proof.', - // A long wait a task-aware client may run behind a task (#369) and poll. - execution: { taskSupport: 'optional' }, - // The long-poll shape of #454: a route whose legitimate wait outlives the - // runtime's default render session declares its own budget. - render: { maxElapsedMs: 120_000 }, - title: 'Wait', -}; - export const inputSchema = z.object({ holdMs: z.number().int().positive().optional(), /** Report progress every `tickMs` while holding; omitted means no progress reports. */ @@ -48,7 +27,7 @@ const waitForAbortOrTimeout = async ( }, { once: true }); }); -export default async function Wait({ +async function Wait({ input, signal, }: { @@ -79,3 +58,27 @@ export default async function Wait({ ); } + +export default defineTool({ +inputJsonSchema: { + "additionalProperties": false, + "properties": { + "holdMs": { + "type": "number" + }, + "tickMs": { + "type": "number" + } + }, + "type": "object" + }, + description: 'Waits until aborted or holdMs elapses, for cancellation contract proof.', + // A long wait a task-aware client may run behind a task (#369) and poll. + execution: { taskSupport: 'optional' }, + // The long-poll shape of #454: a route whose legitimate wait outlives the + // runtime's default render session declares its own budget. + render: { maxElapsedMs: 120_000 }, + title: 'Wait', + inputSchema, + resultSchema, +}, async (input, context) => Wait({ input, signal: context.signal })); diff --git a/packages/agent-bundle/fixtures/web-surface/agent-bundle.config.ts b/packages/agent-bundle/fixtures/web-surface/agent-bundle.config.ts index 82ca22d4c..b564bf700 100644 --- a/packages/agent-bundle/fixtures/web-surface/agent-bundle.config.ts +++ b/packages/agent-bundle/fixtures/web-surface/agent-bundle.config.ts @@ -24,7 +24,6 @@ export default { plugin: { description: 'A plugin whose MCP App is exposed through web.apps and opened with web.', name: 'web-surface-fixture', - version: '1.0.0', }, targets: ['portable'], // Omitting `tool` proves unique live-server resolution. diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index d253cd063..d5d02c743 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -41,7 +41,7 @@ import { mcpServerStateDirectory, runMcpForeground } from './services/mcp-run.ts import { serveMcpApp } from './serve-app/serve-mcp-app.ts'; import type { ServedMcpApp, ServeMcpAppPublicOptions } from './serve-app/types.ts'; import { parseAppSelector } from './web-host/select-app.ts'; -export type { McpAppConsentCapability, ServedMcpApp as ServedApp } from './serve-app/types.ts'; +export type { McpAppConsentCapability, ServedMcpApp } from './serve-app/types.ts'; export type { OpenBrowser } from './dev/mcp-apps/mcp-app-preview-host.ts'; export type { McpAppProfileId } from './dev/mcp-app-profile-descriptors.ts'; import { deepFreeze } from './core/freeze.ts'; diff --git a/packages/agent-bundle/src/cli-entry.ts b/packages/agent-bundle/src/cli-entry.ts index edf618872..a8604ae17 100644 --- a/packages/agent-bundle/src/cli-entry.ts +++ b/packages/agent-bundle/src/cli-entry.ts @@ -5,7 +5,6 @@ import { detectProcessTerminal, type AgentTerminal, type ProbedTerminalSurface, - type TerminalStreamProbe, } from './terminal-capability.ts'; export type { AgentTerminal } from './terminal-capability.ts'; @@ -317,24 +316,12 @@ export interface GeneratedCliRenderContext { /** * The terminal capability one shell invocation reports (#511): an explicit * value wins (the in-process harness supplies one), otherwise the process's - * own streams are probed, with the legacy `isTty` knob standing in for - * stdout's TTY-ness so callers that only override that still see a - * consistent capability and output mode. + * own streams are probed. */ const resolveTerminal = ( hostSurface: ProbedTerminalSurface, - options: { readonly isTty?: () => boolean; readonly terminal?: AgentTerminal }, -): AgentTerminal => { - if (options.terminal !== undefined) return options.terminal; - if (options.isTty === undefined) return detectProcessTerminal(hostSurface); - const stdout: TerminalStreamProbe = { - columns: process.stdout.columns, - fd: 1, - isTTY: options.isTty(), - rows: process.stdout.rows, - }; - return detectProcessTerminal(hostSurface, { stdout }); -}; + options: { readonly terminal?: AgentTerminal }, +): AgentTerminal => options.terminal ?? detectProcessTerminal(hostSurface); export interface RunGeneratedCliOptions { readonly argv: readonly string[]; @@ -346,8 +333,6 @@ export interface RunGeneratedCliOptions { input: Readonly>, context: GeneratedCliExecuteContext, ) => Promise; - /** Overrides stdout's TTY-ness only; rendered commands then update progress in place. Prefer `terminal`. */ - readonly isTty?: () => boolean; readonly name: string; /** Opens one rendered run for a resolved `.tsx` command with parsed input. */ readonly render?: ( @@ -1149,8 +1134,6 @@ export interface RunGeneratedRenderedScriptOptions { argv: readonly string[], context: { readonly signal: AbortSignal; readonly terminal: AgentTerminal }, ) => GeneratedCliRenderSession; - /** Overrides stdout's TTY-ness only. Prefer `terminal`. */ - readonly isTty?: () => boolean; readonly name: string; readonly signal?: AbortSignal; /** The terminal capability to report and select the output mode from (#511); probed from the process when omitted. */ diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index d3e2fdece..df98caab8 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -890,10 +890,10 @@ const normalizeMcpApps = ( : server.targets; const metadata = route.config['_meta']; const template = route.config['template']; - // Route-relative first, legacy project-root-relative when unambiguous; - // the route-graph compiler already reported AB4827 for the other cases. + // The route-graph compiler reports AB4827 when the route-relative + // template does not exist. const templatePath = typeof template === 'string' - ? appRouteTemplatePath(resolveAppRouteTemplate(loaded.context.projectRoot, route.source, template)) + ? appRouteTemplatePath(resolveAppRouteTemplate(route.source, template)) : undefined; apps.push({ ...(isRecord(metadata) ? { _meta: structuredClone(metadata) } : {}), @@ -1299,9 +1299,8 @@ export const normalizeProject = async ( const description = loaded.config.plugin.description; const logo = normalizePluginLogo(loaded); // The npm package axes are derived, never authored in config: package.json - // is authoritative for release identity (issue #94), while plugin.version - // remains the host-facing declared version during the migration. The same - // derivation serves `agent-bundle/meta` to rendered skills at discovery. + // is authoritative for release identity (issue #94). The same derivation + // serves `agent-bundle/meta` to rendered skills at discovery. const identity = pluginIdentity(loaded.context.projectRoot, loaded.config); // The descriptive layer every host projection shares (issue #753): resolved // once here so `author`, `homepage`, `keywords`, `license`, and `repository` diff --git a/packages/agent-bundle/src/config/plugin-identity.ts b/packages/agent-bundle/src/config/plugin-identity.ts index f805870fc..cc824d866 100644 --- a/packages/agent-bundle/src/config/plugin-identity.ts +++ b/packages/agent-bundle/src/config/plugin-identity.ts @@ -9,22 +9,6 @@ import { import { isRecord } from '../core/strict-json.ts'; import type { AgentBundleConfig } from '../core/types.ts'; -/** - * The one plugin version every surface agrees on (issue #94 stage 3): an - * authored `plugin.version` still wins so a legacy declaration never changes - * meaning mid-migration (a disagreement with package.json is the AB4008 - * warning), an omitted one derives the release version from package.json, - * and a project with neither carries the development fallback that - * `agent-bundle build` refuses to package (AB4013). - */ -export const resolvePluginVersion = ( - authored: unknown, - packageVersion: string | undefined, -): string => - (typeof authored === 'string' && authored.trim().length > 0 ? authored : undefined) - ?? packageVersion - ?? developmentFallbackVersion; - /** * The plugin identity axes a project carries before normalization: the * host-native slug from `plugin.name`, the npm axes derived from @@ -43,7 +27,7 @@ export const pluginIdentity = ( name: config.plugin.name, packageName: packageIdentity.packageName, packageVersion: packageIdentity.packageVersion, - version: resolvePluginVersion(config.plugin.version, packageIdentity.packageVersion), + version: packageIdentity.packageVersion ?? developmentFallbackVersion, }); }; @@ -110,8 +94,5 @@ export const declaredPluginIdentity = ( ): ProjectMetaSource | undefined => { const plugin = config.plugin; if (!isRecord(plugin) || typeof plugin.name !== 'string' || plugin.name.trim().length === 0) return undefined; - // `resolvePluginVersion` already treats a non-string `version` as absent. - return pluginIdentity(projectRoot, { - plugin: { name: plugin.name, ...(typeof plugin.version === 'string' ? { version: plugin.version } : {}) }, - }); + return pluginIdentity(projectRoot, { plugin: { name: plugin.name } }); }; diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 76d3731ce..c09f92ff2 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -2165,11 +2165,10 @@ export interface ValidateSourceOptions { } /** - * AB4008-AB4011: the package-identity axes derived from `package.json` - * (issue #94). The package version is authoritative release identity, so a - * conflicting `plugin.version` and any invalid derived value surface as - * warnings — never errors: a missing package.json (or missing name/version - * fields) stays a normal, silent development state with a labeled fallback. + * AB4009-AB4011: the package-identity axes derived from `package.json` + * (issue #94). Invalid derived values surface as warnings — never errors: a + * missing package.json (or missing name/version fields) stays a normal, + * silent development state with a labeled fallback. */ const packageIdentityIssueCode = (kind: PackageIdentityIssueKind): string => { switch (kind) { @@ -2200,34 +2199,15 @@ const validatePackageIdentity = (loaded: LoadedConfig, release: boolean): Diagno 'Correct the package.json field so the derived package identity is valid, then validate again.', )); } - const plugin = loaded.config.plugin as unknown; - const pluginVersion = - typeof plugin === 'object' && plugin !== null && !Array.isArray(plugin) - ? (plugin as Record).version - : undefined; - if ( - identity.packageVersion !== undefined && - typeof pluginVersion === 'string' && - pluginVersion.trim().length > 0 && - pluginVersion !== identity.packageVersion - ) { - diagnostics.push(warningDiagnostic( - 'AB4008', - `Config plugin.version ${JSON.stringify(pluginVersion)} differs from package.json version ${JSON.stringify(identity.packageVersion)}; the package version is authoritative for release identity.`, - loaded.configPath, - 'Align plugin.version with the package.json version, or update package.json.', - )); - } - const declared = typeof pluginVersion === 'string' && pluginVersion.trim().length > 0; - if (release && !declared && identity.packageVersion === undefined) { + if (release && identity.packageVersion === undefined) { // A development-only fallback may exist, but it can never produce a - // release artifact (issue #94): with no authored plugin.version and no - // valid package.json version, this project has no release identity to - // stamp into manifests, host projections, or compiled surfaces. + // release artifact (issue #94): without a valid package.json version this + // project has no release identity to stamp into manifests, host + // projections, or compiled surfaces. diagnostics.push({ code: 'AB4013', - message: 'This project has no release version: plugin.version is omitted and package.json declares no valid semantic version, so the build would package the development fallback.', - recovery: `Add a valid semantic "version" to package.json, or declare plugin.version in the config. Development commands keep the labeled ${developmentFallbackVersion} fallback.`, + message: 'This project has no release version: package.json declares no valid semantic version, so the build would package the development fallback.', + recovery: `Add a valid semantic "version" to package.json. Development commands keep the labeled ${developmentFallbackVersion} fallback.`, severity: 'error', sourcePath: packageJsonPath, }); @@ -2430,21 +2410,17 @@ export const validateSource = ( ? (plugin as Record) : undefined; const pluginName = pluginRecord?.name; - const pluginVersion = pluginRecord?.version; if (typeof pluginName !== 'string' || pluginName.trim().length === 0) { diagnostics.push( sourceDiagnostic('AB4000', 'Plugin metadata must define a nonempty name.', loaded.configPath), ); } - // `plugin.version` is optional since #94 stage 3: omitting it derives the - // version from package.json. Declaring it as anything but a nonempty - // string is still a mistake with no defensible reading. - if (pluginVersion !== undefined && (typeof pluginVersion !== 'string' || pluginVersion.trim().length === 0)) { + if (pluginRecord !== undefined && Object.hasOwn(pluginRecord, 'version')) { diagnostics.push( sourceDiagnostic( 'AB4001', - 'Plugin metadata version must be a nonempty string when it is declared; omit it to derive the version from package.json.', + 'Plugin metadata no longer accepts version; declare the release version in package.json.', loaded.configPath, ), ); diff --git a/packages/agent-bundle/src/core/project-context.ts b/packages/agent-bundle/src/core/project-context.ts index 4a5359c25..bb4d7862d 100644 --- a/packages/agent-bundle/src/core/project-context.ts +++ b/packages/agent-bundle/src/core/project-context.ts @@ -200,10 +200,9 @@ export const snapshotPackageDescriptiveMetadata = (root: string): PackageDescrip /** * The version a project carries while it has no release identity at all: - * neither an authored `plugin.version` nor a valid `package.json` version. - * It is a development-only value — `agent-bundle build` refuses to package - * a project resting on it (AB4013), so it can never reach a release - * artifact. + * no valid `package.json` version. It is a development-only value — + * `agent-bundle build` refuses to package a project resting on it (AB4013), + * so it can never reach a release artifact. */ export const developmentFallbackVersion = '0.0.0-dev'; diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index 64cc92967..25d5ce0f0 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -44,18 +44,6 @@ export interface AgentBundlePluginConfig { */ metadata?: AgentBundleSharedMetadata; name: string; - /** - * The host-facing declared version. Omit it to derive the version from the - * project's `package.json` (issue #94 stage 3): package.json is - * authoritative for release identity, and a declared value that disagrees - * with it reports the AB4008 warning. - * - * @deprecated Declare the release version only in `package.json`. This - * compatibility field will be removed through the normal breaking-change - * policy. - */ - version?: string; - [key: string]: unknown; } export const canonicalHookEvents = Object.freeze([ diff --git a/packages/agent-bundle/src/dev/mcp-app-profile-descriptors.ts b/packages/agent-bundle/src/dev/mcp-app-profile-descriptors.ts index 4477b41f7..978c182d3 100644 --- a/packages/agent-bundle/src/dev/mcp-app-profile-descriptors.ts +++ b/packages/agent-bundle/src/dev/mcp-app-profile-descriptors.ts @@ -6,9 +6,6 @@ export { MCP_APP_PROTOCOL_VERSION }; export type McpAppProfileId = 'chatgpt' | 'claude' | 'portable'; -/** @deprecated Use McpAppProfileId. */ -export type McpAppHostProfile = McpAppProfileId; - export interface McpAppProfileDescriptor { readonly claimsRealHostParity: false; readonly evidence: 'simulated'; diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-host-profiles.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-host-profiles.ts index 43c931f59..4eeffa130 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-host-profiles.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-host-profiles.ts @@ -6,8 +6,8 @@ import { isPlainRecord } from '../../core/strict-json.ts'; import { MCP_APP_PROFILE_DESCRIPTORS, - type McpAppHostProfile, type McpAppProfileDescriptor, + type McpAppProfileId, } from '../mcp-app-profile-descriptors.ts'; import { cloneMcpAppFiniteJson, @@ -21,7 +21,7 @@ import type { } from '../../core/types.ts'; export { MCP_APP_PROFILE_DESCRIPTORS } from '../mcp-app-profile-descriptors.ts'; -export type { McpAppHostProfile, McpAppProfileDescriptor, McpAppProfileId } from '../mcp-app-profile-descriptors.ts'; +export type { McpAppProfileDescriptor, McpAppProfileId } from '../mcp-app-profile-descriptors.ts'; export type McpAppCapability = 'camera' | 'clipboardWrite' | 'geolocation' | 'microphone'; @@ -101,7 +101,7 @@ export interface ResolveMcpAppHostProfileOptions { readonly consentedCapabilities?: readonly string[]; readonly declaredCapabilities?: readonly string[]; readonly host: McpAppHostContextInput; - readonly profile: McpAppHostProfile; + readonly profile: McpAppProfileId; readonly resource?: McpAppResourceCandidate; /** Preserved as metadata evidence; it never selects a profile feature. */ readonly toolMetadata?: unknown; @@ -576,7 +576,7 @@ const resolvePermissions = ( }; const resourceFallback = ( - profile: McpAppHostProfile, + profile: McpAppProfileId, configExtensions: McpAppConfigExtensionInspection, reason: McpAppFallbackHostProfile['reason'], warnings: readonly string[] = [], diff --git a/packages/agent-bundle/src/dev/playground/playground-store.ts b/packages/agent-bundle/src/dev/playground/playground-store.ts index f981c1ce9..c47faead9 100644 --- a/packages/agent-bundle/src/dev/playground/playground-store.ts +++ b/packages/agent-bundle/src/dev/playground/playground-store.ts @@ -1995,4 +1995,3 @@ export class PlaygroundService { } /** Extracted-module name; the durable implementation retains its public service identity. */ -export { PlaygroundService as PlaygroundStore }; diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index 6b9f423ea..5f2f269b0 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -52,7 +52,7 @@ import { import { McpSessionService } from './mcp-session/mcp-session-service.ts'; import { NativePlaygroundService } from './playground/native-playground-service.ts'; import { PlaygroundOrchestrationService } from './playground/playground-orchestration-service.ts'; -import { PlaygroundStore as PlaygroundService } from './playground/playground-store.ts'; +import { PlaygroundService } from './playground/playground-store.ts'; import { createDevPlatformRuntime } from './platform-run.ts'; import type { DevPlatformRuntime } from './platform-runtime.ts'; import { ProjectService, type PreparedProject } from './project-service.ts'; diff --git a/packages/agent-bundle/src/install/commands.ts b/packages/agent-bundle/src/install/commands.ts index b2f9d284b..58d423743 100644 --- a/packages/agent-bundle/src/install/commands.ts +++ b/packages/agent-bundle/src/install/commands.ts @@ -31,7 +31,6 @@ export interface LifecycleCommandOptions { } interface InstallCommandOptions { - readonly force?: boolean; readonly from?: string; readonly json?: boolean; readonly replace?: boolean; @@ -107,7 +106,6 @@ export const registerLifecycleCommands = (program: Command, options: LifecycleCo 'Replace an existing agent-bundle install of this plugin even when its version differs; ' + 'same-version content drift is replaced automatically and foreign installs are always refused', ) - .option('--force', 'Alias for --replace') .option('--mode ', 'Cursor delivery mode: local (default) or marketplace', installMode) .option('--json', 'Write one machine-readable JSON document'); installCommand.action(async (host: InstallHost, commandOptions: InstallCommandOptions) => { @@ -115,7 +113,7 @@ export const registerLifecycleCommands = (program: Command, options: LifecycleCo const result = await install({ from: pinned ?? commandOptions.from ?? process.cwd(), host, - replace: commandOptions.replace === true || commandOptions.force === true, + replace: commandOptions.replace === true, ...(commandOptions.mode === undefined ? {} : { mode: commandOptions.mode }), scope: installScope(commandOptions.scope), }); diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index cf4179ec5..f9e74a429 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -87,7 +87,7 @@ export interface InstallBundleOptions { readonly host: InstallHost; /** * Replace an existing agent-bundle install of this plugin even when its - * version differs (`--replace` / `--force`). Same-version content drift is + * version differs (`--replace`). Same-version content drift is * replaced automatically; foreign directories are always refused. */ readonly replace?: boolean; diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 88d610faa..3c223f669 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -38,7 +38,7 @@ const header = (model: NormalizedPlugin): string[] => [ const optionalCliReinstall = (host: 'claude' | 'codex'): string[] => [ `With the optional \`agent-bundle\` CLI, \`agent-bundle install ${host} --from ./\` runs this sequence`, 'automatically when the installed copy has the same version but a different content hash; `--replace`', - '(alias `--force`) forces it.', + 'forces it.', ]; /** @@ -187,8 +187,8 @@ const cursorInstructions = (model: NormalizedPlugin): string[] => [ 'node ./install.mjs --replace # also replace a different installed version, or adopt a pre-receipt copy', '```', '', - '`--force` is an alias for `--replace`. A directory that is not an agent-bundle install of this', - 'plugin is always refused with an installed-versus-artifact content-hash comparison; remove it', + 'A directory that is not an agent-bundle install of this plugin is always refused with an', + 'installed-versus-artifact content-hash comparison; remove it', 'manually. The optional `agent-bundle` CLI applies the same policy through', '`agent-bundle install cursor --from ./ [--replace]`.', '', @@ -414,7 +414,7 @@ const portableInstructions = (planned: readonly string[]): string[] => [ '', `The installer records an install receipt (\`${installReceiptFile}\`) and replaces its owned files in`, 'place when the same version was rebuilt with different content; runtime state (`state/`) is never', - 'touched. Pass `--replace` (alias `--force`) to replace a different installed version or to adopt a', + 'touched. Pass `--replace` to replace a different installed version or to adopt a', 'copy installed before receipts existed. Foreign directories are refused with a content-hash', 'comparison. For a client that manages its own copy, remove and re-add the plugin through that client', 'when only content changed at the same version.', @@ -956,7 +956,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { "const pluginData = join(cursorRoot, 'agent-bundle', 'plugin-data', pluginName);", "const receiptsRoot = join(cursorRoot, 'agent-bundle', 'receipts');", 'const marketplaceReceipt = join(receiptsRoot, `${pluginName}.marketplace.json`);', - "const usage = 'Usage: node install.mjs [--mode local|marketplace] [--replace|--force] [--help]\\n" + + "const usage = 'Usage: node install.mjs [--mode local|marketplace] [--replace] [--help]\\n" + " node install.mjs --uninstall [--mode local|marketplace] [--keep-data | --purge-data --confirm-purge] [--force] [--plan]';", '', 'let replace = false;', @@ -970,7 +970,8 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { 'const argv = process.argv.slice(2);', 'for (let index = 0; index < argv.length; index += 1) {', ' const argument = argv[index];', - " if (argument === '--replace' || argument === '--force') { replace = true; force = argument === '--force'; continue; }", + " if (argument === '--replace') { replace = true; continue; }", + " if (argument === '--force') { force = true; continue; }", " if (argument === '--uninstall') { uninstall = true; continue; }", " if (argument === '--plan') { plan = true; continue; }", " if (argument === '--keep-data') { keepData = true; continue; }", @@ -986,6 +987,8 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' console.error(`Unknown installer argument ${JSON.stringify(argument)}.\\n${usage}`);', ' process.exit(2);', '}', + 'if (!uninstall && force) { console.error(`--force applies to --uninstall only. Use --replace to replace an install.\\n${usage}`); process.exit(2); }', + 'if (uninstall && replace) { console.error(`--replace applies to installation only. Use --force to override uninstall ownership checks.\\n${usage}`); process.exit(2); }', 'if (!uninstall && (plan || keepData || purgeData || confirmPurge)) {', " console.error(`--plan, --keep-data, --purge-data, and --confirm-purge apply to --uninstall only.\\n${usage}`);", ' process.exit(2);', diff --git a/packages/agent-bundle/src/meta.ts b/packages/agent-bundle/src/meta.ts index 28e8b8bfd..bb1eda713 100644 --- a/packages/agent-bundle/src/meta.ts +++ b/packages/agent-bundle/src/meta.ts @@ -19,10 +19,9 @@ export interface AgentBundleMeta { /** The validated semantic release version, absent for unpackaged development projects. */ readonly packageVersion: string | undefined; /** - * The resolved plugin version: the authored `plugin.version` when declared, - * otherwise the package.json version. A release build refuses to package a - * project that has neither (AB4013), so a compiled artifact never carries - * the development fallback. + * The package.json version. A release build refuses to package a project + * without one (AB4013), so a compiled artifact never carries the + * development fallback. */ readonly version: string; } diff --git a/packages/agent-bundle/src/routes/app-template.ts b/packages/agent-bundle/src/routes/app-template.ts index 1bdd1c624..72a2e0bb9 100644 --- a/packages/agent-bundle/src/routes/app-template.ts +++ b/packages/agent-bundle/src/routes/app-template.ts @@ -1,29 +1,16 @@ import { existsSync, statSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; -/** - * How one App route's `config.template` path resolved. `route-relative` is - * the documented form (the path resolves from the route module like its - * imports do); `project-relative` is the legacy form, accepted only while it - * is the sole interpretation that names an existing file. - */ +/** How one App route's `config.template` path resolved from its route module. */ export type AppRouteTemplateResolution = | { - readonly form: 'project-relative' | 'route-relative'; readonly kind: 'resolved'; /** Absolute template path. */ readonly path: string; } | { - /** Both interpretations name different existing files. */ - readonly kind: 'ambiguous'; - readonly projectRelative: string; - readonly routeRelative: string; - } - | { - /** Neither interpretation names an existing file. */ + /** The route-relative interpretation names no existing file. */ readonly kind: 'missing'; - readonly projectRelative: string; readonly routeRelative: string; }; @@ -35,31 +22,15 @@ const isFile = (path: string): boolean => { } }; -/** - * Resolves an App route's `config.template` against both candidate bases. - * An absolute template, or a relative one whose two interpretations coincide, - * has one candidate, which still has to exist; otherwise exactly one existing - * candidate wins, and the ambiguous and missing outcomes are reported for the - * compiler to diagnose (AB4827). - */ +/** Resolves an App route's `config.template` relative to the route module. */ export const resolveAppRouteTemplate = ( - projectRoot: string, routeSource: string, template: string, ): AppRouteTemplateResolution => { const routeRelative = resolve(dirname(routeSource), template); - const projectRelative = resolve(projectRoot, template); - const routeExists = isFile(routeRelative); - if (routeRelative === projectRelative) { - return routeExists - ? { form: 'route-relative', kind: 'resolved', path: routeRelative } - : { kind: 'missing', projectRelative, routeRelative }; - } - const projectExists = isFile(projectRelative); - if (routeExists && projectExists) return { kind: 'ambiguous', projectRelative, routeRelative }; - if (routeExists) return { form: 'route-relative', kind: 'resolved', path: routeRelative }; - if (projectExists) return { form: 'project-relative', kind: 'resolved', path: projectRelative }; - return { kind: 'missing', projectRelative, routeRelative }; + return isFile(routeRelative) + ? { kind: 'resolved', path: routeRelative } + : { kind: 'missing', routeRelative }; }; /** @@ -71,7 +42,6 @@ export const appRouteTemplatePath = (resolution: AppRouteTemplateResolution): st switch (resolution.kind) { case 'resolved': return resolution.path; - case 'ambiguous': case 'missing': return resolution.routeRelative; default: { diff --git a/packages/agent-bundle/src/routes/contract.ts b/packages/agent-bundle/src/routes/contract.ts index b428b2339..6310794e6 100644 --- a/packages/agent-bundle/src/routes/contract.ts +++ b/packages/agent-bundle/src/routes/contract.ts @@ -2,6 +2,7 @@ import ts from 'typescript-5'; import type { Diagnostic } from '../core/diagnostics.ts'; import { readRouteDefinition, type RouteDefinition } from './definition-syntax.ts'; +import type { CompiledRouteKind } from './types.ts'; const modifier = (node: ts.Node, kind: ts.SyntaxKind): boolean => ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some((item) => item.kind === kind) ?? false); @@ -59,13 +60,22 @@ export const validateRouteModuleContract = ( moduleText: string, relativePath: string, sourcePath: string, + kind: CompiledRouteKind, ): readonly Diagnostic[] => { const exports = scanRouteModuleExports(moduleText, relativePath); const { named, splitExport } = exports; const hasDefault = exports.named.has('default'); const missing = ['inputSchema', 'resultSchema'].filter((name) => !named.has(name)); const diagnostics: Diagnostic[] = []; - if (missing.length > 0 || !hasDefault) { + if (kind === 'tool' && exports.definition === undefined) { + diagnostics.push(diagnostic( + 'AB4810', + `Tool route module ${relativePath} does not use defineTool.`, + sourcePath, + 'Default-export one direct defineTool({ inputSchema, resultSchema, ... }, handler) call.', + )); + } + if ((kind !== 'tool' || exports.definition !== undefined) && (missing.length > 0 || !hasDefault)) { const details = [ ...(missing.length === 0 ? [] : [`missing named ${missing.join(' and ')}`]), ...(hasDefault ? [] : ['missing default export']), diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index 478bc849a..26eb73264 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -1010,26 +1010,14 @@ export const compileRouteGraph = async ( } const template = route.config['template']; if (typeof template === 'string') { - const resolution = resolveAppRouteTemplate(projectRoot, route.source, template); + const resolution = resolveAppRouteTemplate(route.source, template); switch (resolution.kind) { case 'resolved': break; - case 'ambiguous': - diagnostics.push(routeError( - 'AB4827', - `MCP App route ${route.provenance.relativePath} declares config.template ${JSON.stringify(template)}, which names two different existing files: ${resolution.routeRelative} (route-relative) and ${resolution.projectRelative} (project-root-relative).`, - 'Templates resolve relative to the route module; rewrite the path so it names the route-relative file only (or remove the project-root-relative duplicate), then inspect again.', - route.source, - )); - break; case 'missing': { - // An absolute template has one candidate; name it once. - const candidates = resolution.routeRelative === resolution.projectRelative - ? `${resolution.routeRelative} does not exist` - : `neither ${resolution.routeRelative} (route-relative) nor ${resolution.projectRelative} (project-root-relative) exists`; diagnostics.push(routeError( 'AB4827', - `MCP App route ${route.provenance.relativePath} declares config.template ${JSON.stringify(template)}, but ${candidates}.`, + `MCP App route ${route.provenance.relativePath} declares config.template ${JSON.stringify(template)}, but ${resolution.routeRelative} does not exist.`, 'Templates resolve relative to the route module; point config.template at an existing HTML file beside the route (for example \'./dashboard.html\'), then inspect again.', route.source, )); @@ -1049,6 +1037,7 @@ export const compileRouteGraph = async ( moduleText, route.provenance.relativePath, route.source, + route.kind, )); } // The generated server inlines the route, so a compiler-carrying diff --git a/packages/agent-bundle/src/routes/public.ts b/packages/agent-bundle/src/routes/public.ts index 76bbc5138..08d3c4a11 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -247,7 +247,7 @@ export interface AgentProviderNoticeWithholding { /** * One notice as a provider reads it from `inbox()` or `published()`: every * field of the runtime's `AgentNotice`, spelled structurally. `content` is the - * persisted Agent Document snapshot (the runtime's `AgentDocumentSnapshot`); + * persisted Agent Document (the runtime's `AgentDocument`); * it is `unknown` here because the Agent Document types ship with the runtime, * so a provider that needs the authored text narrows it with the runtime's * types — a route reads the same notice through `(await agent()).notices`. @@ -499,10 +499,8 @@ export interface AppRouteConfig { readonly targets?: readonly string[]; /** * Optional HTML shell for the compiled App. The path resolves relative to - * the route module, the way its imports do (`'./dashboard.html'`); the - * older project-root-relative form is still accepted while only one of the - * two interpretations names an existing file. When both exist and differ, - * or neither exists, the build fails with `AB4827` naming both candidates. + * the route module, the way its imports do (`'./dashboard.html'`). A missing + * file fails the build with `AB4827`. */ readonly template?: string; } diff --git a/packages/agent-bundle/src/test/manifest.ts b/packages/agent-bundle/src/test/manifest.ts index f7e6f37ce..6123bdf43 100644 --- a/packages/agent-bundle/src/test/manifest.ts +++ b/packages/agent-bundle/src/test/manifest.ts @@ -166,8 +166,8 @@ export const FALLBACK_PLUGIN_IDENTITY: TestManifestPluginIdentity = Object.freez * True when a manifest's identity is the model-less sentinel. The check is * by reference only: `deepFreeze` freezes in place, so the manifest the * compiler pass hands the presets still carries the sentinel object itself, - * while a real project that happens to declare `plugin.name: 'unknown'` and - * `plugin.version: '0.0.0'` is a distinct model-backed object and keeps its + * while a real project whose resolved model identity is + * `{ name: 'unknown', version: '0.0.0' }` is a distinct object and keeps its * identity. A manifest that crossed a JSON boundary has already been handed * to a worker; only the preset, in the runner process, asks this question. */ diff --git a/packages/agent-bundle/tests/adapter-contract.test.ts b/packages/agent-bundle/tests/adapter-contract.test.ts index dad182af9..2d19074e2 100644 --- a/packages/agent-bundle/tests/adapter-contract.test.ts +++ b/packages/agent-bundle/tests/adapter-contract.test.ts @@ -60,7 +60,7 @@ it('delegates selected native hook sources through registered adapters', async ( config: { example: { nativeHooks: './example-hooks.json' }, hooks: { stop: './hooks/stop.ts' }, - plugin: { name: 'example-fixture', version: '1.0.0' }, + plugin: { name: 'example-fixture' }, }, configPath: join(root, 'agent-bundle.config.ts'), context: { @@ -158,7 +158,7 @@ it('normalizes malformed native hook source values into diagnostics without skip try { const loaded: LoadedConfig = { config: { - plugin: { name: 'invalid-source-fixture', version: '1.0.0' }, + plugin: { name: 'invalid-source-fixture' }, targets: ['invalid', 'valid'], }, configPath: join(root, 'agent-bundle.config.ts'), @@ -198,7 +198,7 @@ it('normalizes thrown native hook sources into diagnostics', async () => { }); const loaded: LoadedConfig = { config: { - plugin: { name: 'throwing-source-fixture', version: '1.0.0' }, + plugin: { name: 'throwing-source-fixture' }, targets: ['throws'], }, configPath: join(root, 'agent-bundle.config.ts'), diff --git a/packages/agent-bundle/tests/amp-adapter.test.ts b/packages/agent-bundle/tests/amp-adapter.test.ts index 9ae76e0b4..537626403 100644 --- a/packages/agent-bundle/tests/amp-adapter.test.ts +++ b/packages/agent-bundle/tests/amp-adapter.test.ts @@ -792,7 +792,7 @@ it('runs a relocated standalone event route with its worker inside the Amp plugi try { const registry = createDefaultRegistry(); const routeGraph = await compileRouteGraph(projectRoot, { - plugin: { name: 'amp-review', version: '1.0.0' }, + plugin: { name: 'amp-review' }, }); const built = await build({ model, diff --git a/packages/agent-bundle/tests/api.test.ts b/packages/agent-bundle/tests/api.test.ts index 07d78c87b..3126a9c49 100644 --- a/packages/agent-bundle/tests/api.test.ts +++ b/packages/agent-bundle/tests/api.test.ts @@ -37,7 +37,7 @@ const createProject = async (): Promise => { join(root, 'agent-bundle.config.ts'), [ 'export default ({ command, mode, projectRoot, selectedTargets }) => ({', - " plugin: { name: 'api-fixture', version: '1.0.0' },", + " plugin: { name: 'api-fixture' },", ' targets: selectedTargets.length === 0 ? [\'codex\', \'claude\'] : selectedTargets,', " hooks: { sessionStart: { handler: './src/hook.ts' } },", ' fixtureContext: { command, mode, projectRoot, selectedTargets },', @@ -49,6 +49,7 @@ const createProject = async (): Promise => { join(root, 'src', 'skills', 'review', 'SKILL.md'), '---\nname: review\ndescription: Reviews changes\n---\n# Review\n', ), + writeFile(join(root, 'package.json'), '{"type":"module","version":"1.0.0"}\n'), writeFile(join(root, 'src', 'hook.ts'), 'export default () => undefined;\n'), ]); return root; @@ -147,7 +148,7 @@ it('prepares and inspects a target owned only by the supplied advanced registry' try { await writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', - " plugin: { name: 'synthetic-api-fixture', version: '1.0.0' },", + " plugin: { name: 'synthetic-api-fixture' },", " synthetic: { enabled: true },", " targets: ['synthetic'],", '};', @@ -202,7 +203,7 @@ it('projects only the capability contract fields of adapter-owned rows into insp try { await writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', - " plugin: { name: 'synthetic-api-fixture', version: '1.0.0' },", + " plugin: { name: 'synthetic-api-fixture' },", " hooks: { sessionStart: { handler: './src/hook.ts' } },", " synthetic: { enabled: true },", " targets: ['synthetic'],", @@ -232,7 +233,7 @@ it('accepts claude.userConfig through the public inspection and build APIs', asy try { await writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', - " plugin: { name: 'user-config-api', version: '1.0.0' },", + " plugin: { name: 'user-config-api' },", " targets: ['claude'],", ' claude: {', ' userConfig: {', @@ -295,7 +296,7 @@ it('returns a frozen invalid inspection for opaque source failures', async () => it('attaches a specific recovery to every invalid inspection diagnostic', async () => { const root = await createProject(); try { - await writeFile(join(root, 'agent-bundle.config.ts'), "export default { plugin: { version: '1.0.0' } };\n"); + await writeFile(join(root, 'agent-bundle.config.ts'), 'export default { plugin: {} };\n'); const result = await inspect({ root }); @@ -320,7 +321,7 @@ it('accepts the public claude.dependencies config surface and plans its manifest try { await writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', - " plugin: { name: 'api-fixture', version: '1.0.0' },", + " plugin: { name: 'api-fixture' },", " targets: ['claude'],", ' claude: {', " dependencies: [{ name: 'audit-logger', marketplace: 'acme-shared' }, { name: 'policy-kit', version: '^2.0', marketplace: 'acme-shared' }],", @@ -350,7 +351,7 @@ it('reports one modern-MCP source diagnostic for a legacy SSE declaration', asyn try { await writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', - " plugin: { name: 'codex-sse', version: '1.0.0' },", + " plugin: { name: 'codex-sse' },", " targets: ['codex'],", " mcp: { servers: { events: { transport: 'sse', url: 'https://mcp.example.test/events' } } },", '};', @@ -379,7 +380,7 @@ it('resolves artifact output with CLI, config, and default precedence', async () await writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', " output: { distPath: 'artifact-out' },", - " plugin: { name: 'output-path-fixture', version: '1.0.0' },", + " plugin: { name: 'output-path-fixture' },", " targets: ['portable'],", '};', '', @@ -398,7 +399,7 @@ it('resolves artifact output with CLI, config, and default precedence', async () await writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', - " plugin: { name: 'output-path-fixture', version: '1.0.0' },", + " plugin: { name: 'output-path-fixture' },", " targets: ['portable'],", '};', '', @@ -512,7 +513,7 @@ it('build reports one informational AB6019 skip for all Claude-validated targets try { await writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', - " plugin: { name: 'api-fixture', version: '1.0.0' },", + " plugin: { name: 'api-fixture' },", " targets: ['claude', 'codex'],", '};', '', @@ -600,7 +601,7 @@ it('deduplicates identical adapter diagnostics without collapsing distinct stabl try { await writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', - " plugin: { name: 'adapter-diagnostic-identity', version: '1.0.0' },", + " plugin: { name: 'adapter-diagnostic-identity' },", " synthetic: { enabled: true },", " targets: ['synthetic'],", '};', @@ -627,7 +628,7 @@ it('contains hostile source getters as reusable preparation diagnostics', async await writeFile(join(root, 'agent-bundle.config.ts'), [ "const hostile = { toString() { throw new Error('hostile source getter was stringified'); } };", 'hostile.self = hostile;', - 'const config = { plugin: { name: \'hostile-config\', version: \'1.0.0\' }, targets: [\'codex\'] };', + 'const config = { plugin: { name: \'hostile-config\' }, targets: [\'codex\'] };', "Object.defineProperty(config, 'hooks', { enumerable: true, get() { throw hostile; } });", 'export default config;', '', @@ -655,7 +656,7 @@ it('contains hostile source getters as reusable preparation diagnostics', async await writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', - " plugin: { name: 'recovered-config', version: '1.0.0' },", + " plugin: { name: 'recovered-config' },", " targets: ['codex'],", '};', '', @@ -676,7 +677,7 @@ it('fails closed when routes getter throws during inspection', async () => { writeFile(join(root, 'agent-bundle.config.ts'), [ "const hostile = { toString() { throw new Error('hostile routes getter was stringified'); } };", 'hostile.self = hostile;', - 'const config = { plugin: { name: \'hostile-routes\', version: \'1.0.0\' }, targets: [\'codex\'] };', + 'const config = { plugin: { name: \'hostile-routes\' }, targets: [\'codex\'] };', "Object.defineProperty(config, 'routes', { enumerable: true, get() { throw hostile; } });", 'export default config;', '', @@ -731,7 +732,7 @@ it('contains a throwing adapter plan as a reusable preparation diagnostic', asyn try { await writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', - " plugin: { name: 'hostile-adapter', version: '1.0.0' },", + " plugin: { name: 'hostile-adapter' },", " synthetic: { enabled: true },", " targets: ['synthetic'],", '};', @@ -779,7 +780,7 @@ it('contains an adapter planner that fails after preparation during inspect', as try { await writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', - " plugin: { name: 'inspection-planner', version: '1.0.0' },", + " plugin: { name: 'inspection-planner' },", " synthetic: { enabled: true },", " targets: ['synthetic'],", '};', @@ -863,7 +864,7 @@ it('reports skipped target/component pairs against each target emission surface' writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', " hooks: { sessionStart: { handler: './src/hook.ts' } },", - " plugin: { name: 'api-fixture', version: '1.0.0' },", + " plugin: { name: 'api-fixture' },", " scripts: { report: { entry: './src/report.ts', targets: ['codex'] } },", " targets: ['portable', 'codex', 'claude', 'cursor'],", '};', @@ -963,7 +964,7 @@ it('accounts lsp servers and event routes as distinct canonical kinds with a per ' },', ' },', " hooks: { sessionStart: { handler: './src/hook.ts' } },", - " plugin: { name: 'api-fixture', version: '1.0.0' },", + " plugin: { name: 'api-fixture' },", " targets: ['portable', 'codex', 'claude', 'cursor'],", '};', '', @@ -1107,7 +1108,7 @@ it('accounts an admitted degraded event route as selected, matching the validati ].join('\n')), writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', - " plugin: { name: 'api-fixture', version: '1.0.0' },", + " plugin: { name: 'api-fixture' },", ` targets: ['${syntheticTarget}'],`, '};', '', @@ -1162,7 +1163,7 @@ it('judges event-route admission and lsp emission by the component-emission over writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', " synthetic: { lspServers: { rust: { command: 'rust-analyzer' } } },", - " plugin: { name: 'api-fixture', version: '1.0.0' },", + " plugin: { name: 'api-fixture' },", ` targets: ['${syntheticTarget}'],`, '};', '', @@ -1197,7 +1198,7 @@ it('never reports an lsp component as selected when the declaring planner reject " second: { command: 'second-ls', extensionToLanguage: { '.ts': 'typescript' } },", ' },', ' },', - " plugin: { name: 'api-fixture', version: '1.0.0' },", + " plugin: { name: 'api-fixture' },", " targets: ['claude'],", '};', '', @@ -1227,7 +1228,7 @@ it('reports omitted component features per target from the host feature rows (#1 writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', " hooks: { beforeTool: { handler: './src/hook.ts', timeout: 3, tools: ['shell'] } },", - " plugin: { name: 'api-fixture', version: '1.0.0' },", + " plugin: { name: 'api-fixture' },", " targets: ['claude', 'cursor'],", '};', '', @@ -1303,7 +1304,7 @@ it('never counts an opaque third-party lspServers declaration as emitted by a ho 'export default {', " synthetic: { lspServers: { rust: { command: 'rust-analyzer' } } },", " hooks: { sessionStart: { handler: './src/hook.ts' } },", - " plugin: { name: 'api-fixture', version: '1.0.0' },", + " plugin: { name: 'api-fixture' },", ` targets: [${JSON.stringify(target)}],`, '};', '', @@ -1337,7 +1338,7 @@ it('never counts an opaque third-party lspServers declaration as emitted by a ho 'export default {', " codex: { lspServers: { rust: { command: 'rust-analyzer' } } },", " hooks: { sessionStart: { handler: './src/hook.ts' } },", - " plugin: { name: 'api-fixture', version: '1.0.0' },", + " plugin: { name: 'api-fixture' },", " targets: ['codex', 'claude'],", '};', '', @@ -1365,7 +1366,7 @@ it('reports target exclusion before unsupported capability when both omit a comp await writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', " hooks: { sessionStart: { handler: './src/hook.ts', targets: ['codex'] } },", - " plugin: { name: 'api-fixture', version: '1.0.0' },", + " plugin: { name: 'api-fixture' },", " targets: ['portable', 'codex'],", '};', '', @@ -1391,7 +1392,7 @@ it('surfaces the computed native matcher on inspected hook entries', async () => " beforeTool: { handler: './src/hook.ts', tools: ['shell', 'file.write'] },", " sessionStart: { handler: './src/hook.ts' },", ' },', - " plugin: { name: 'api-fixture', version: '1.0.0' },", + " plugin: { name: 'api-fixture' },", " targets: ['claude'],", '};', '', @@ -1440,7 +1441,7 @@ it('keeps one supplied registry through advanced artifact, hook, and MCP operati 'export default {', " hooks: { sessionStart: { handler: './src/hook.ts' } },", " mcp: { servers: { synthetic: { entry: './src/mcp-server.ts' } } },", - " plugin: { name: 'synthetic-api-fixture', version: '1.0.0' },", + " plugin: { name: 'synthetic-api-fixture' },", " synthetic: { enabled: true },", " targets: ['synthetic'],", '};', @@ -1552,6 +1553,7 @@ it('returns an output-independent project context without absolute project paths 'configDigest', 'configPath', 'modelDigest', + 'packageVersion', 'revision', 'sourceInputs', ]); @@ -1572,7 +1574,7 @@ it('keeps rule and command model digests root-independent and sensitive to conte const [leftRoot, rightRoot] = await Promise.all([createProject(), createProject()]); const config = [ 'export default {', - " plugin: { name: 'rule-digest-fixture', version: '1.0.0' },", + " plugin: { name: 'rule-digest-fixture' },", " targets: ['cursor'],", '};', '', @@ -1660,7 +1662,7 @@ it('rejects an output beneath an escaping symlink before loading source or writi "import { writeFileSync } from 'node:fs';", `writeFileSync(${JSON.stringify(marker)}, 'evaluated\\n');`, 'export default {', - " plugin: { name: 'escaping-output', version: '1.0.0' },", + " plugin: { name: 'escaping-output' },", " targets: ['portable'],", '};', '', @@ -1685,7 +1687,7 @@ it('rejects a dangling output symlink before loading source', async () => { "import { writeFileSync } from 'node:fs';", `writeFileSync(${JSON.stringify(marker)}, 'evaluated\\n');`, 'export default {', - " plugin: { name: 'dangling-output', version: '1.0.0' },", + " plugin: { name: 'dangling-output' },", " targets: ['portable'],", '};', '', @@ -1709,7 +1711,7 @@ it('rejects an output symlink to the project root before loading source', async "import { writeFileSync } from 'node:fs';", `writeFileSync(${JSON.stringify(marker)}, 'evaluated\\n');`, 'export default {', - " plugin: { name: 'root-output', version: '1.0.0' },", + " plugin: { name: 'root-output' },", " targets: ['portable'],", '};', '', @@ -1782,7 +1784,7 @@ it('normalizes named top-level scripts with stable IDs, modes, and sorted target join(root, 'agent-bundle.config.ts'), [ 'export default {', - " plugin: { name: 'script-fixture', version: '1.0.0' },", + " plugin: { name: 'script-fixture' },", " targets: ['codex', 'claude'],", ' scripts: {', " bundle: { entry: './src/bundle.ts', targets: ['codex', 'claude'] },", @@ -1839,11 +1841,12 @@ it('builds conventional src/scripts modules beside explicit entries', async () = const root = join(parent, 'project with spaces'); await mkdir(join(root, 'src', 'scripts'), { recursive: true }); await Promise.all([ + writeFile(join(root, 'package.json'), '{"type":"module","version":"1.0.0"}\n'), writeFile( join(root, 'agent-bundle.config.ts'), [ 'export default {', - " plugin: { name: 'conventional-scripts-fixture', version: '1.0.0' },", + " plugin: { name: 'conventional-scripts-fixture' },", " targets: ['portable'],", " scripts: { claimed: './src/scripts/claimed.ts' },", '};', @@ -1899,7 +1902,7 @@ it('refuses unshippable conventional script routes with actionable diagnostics', join(root, 'agent-bundle.config.ts'), [ 'export default {', - " plugin: { name: 'unshippable-scripts-fixture', version: '1.0.0' },", + " plugin: { name: 'unshippable-scripts-fixture' },", " targets: ['portable'],", " scripts: { audit: './src/tasks/audit.ts' },", '};', @@ -1939,11 +1942,12 @@ it('copies every supported top-level script output suffix byte-for-byte with sou const output = join(root, 'artifact'); await mkdir(join(root, 'src'), { recursive: true }); await Promise.all([ + writeFile(join(root, 'package.json'), '{"type":"module","version":"1.0.0"}\n'), writeFile( join(root, 'agent-bundle.config.ts'), [ 'export default {', - " plugin: { name: 'copy-script-fixture', version: '1.0.0' },", + " plugin: { name: 'copy-script-fixture' },", " targets: ['portable', 'codex', 'claude'],", ' scripts: {', " bash: './src/run.BASH',", @@ -2038,9 +2042,10 @@ it('canonicalizes copied script extensions in emitted artifact paths', async () const output = join(root, 'artifact'); await mkdir(join(root, 'src'), { recursive: true }); await Promise.all([ + writeFile(join(root, 'package.json'), '{"type":"module","version":"1.0.0"}\n'), writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', - " plugin: { name: 'uppercase-script-fixture', version: '1.0.0' },", + " plugin: { name: 'uppercase-script-fixture' },", " targets: ['portable'],", " scripts: { upper: './src/run.SH' },", '};', @@ -2092,7 +2097,7 @@ it('documents a versioned MCP App resource URI accepted by source validation', a join(root, 'agent-bundle.config.ts'), [ 'export default {', - " plugin: { name: 'readme-uri-fixture', version: '1.0.0' },", + " plugin: { name: 'readme-uri-fixture' },", " targets: ['portable'],", ' mcp: { servers: { local: {', " entry: './src/server.ts',", @@ -2124,7 +2129,7 @@ it('rejects unsafe, unsupported, missing, non-file, and unknown-target named scr join(root, 'agent-bundle.config.ts'), [ 'export default {', - " plugin: { name: 'invalid-script-fixture', version: '1.0.0' },", + " plugin: { name: 'invalid-script-fixture' },", " targets: ['portable'],", ' scripts: {', " '../unsafe': './src/run.sh',", diff --git a/packages/agent-bundle/tests/artifact-cli-bin.test.ts b/packages/agent-bundle/tests/artifact-cli-bin.test.ts index 9c3c705b5..8ecfd8332 100644 --- a/packages/agent-bundle/tests/artifact-cli-bin.test.ts +++ b/packages/agent-bundle/tests/artifact-cli-bin.test.ts @@ -79,7 +79,7 @@ const createFixture = async (options: { writeProjectFile(root, 'agent-bundle.config.ts', [ "import { defineConfig } from 'agent-bundle/config';", 'export default defineConfig({', - ` plugin: { description: 'Artifact routed CLI fixture.', name: ${JSON.stringify(pluginName)}, version: '3.8.7' },`, + ` plugin: { description: 'Artifact routed CLI fixture.', name: ${JSON.stringify(pluginName)} },`, ` targets: ${JSON.stringify(options.targets)},`, ...(options.web === true ? [" web: { apps: ['curator/dashboard'] },"] : []), '});', @@ -215,7 +215,7 @@ const createWebOnlyFixture = async (): Promise => { " entry: './src/mcp/status.ts',", " targets: ['claude', 'portable'],", ' } } },', - ` plugin: { description: 'Web-only artifact fixture.', name: ${JSON.stringify(webOnlyPluginName)}, version: '1.0.0' },`, + ` plugin: { description: 'Web-only artifact fixture.', name: ${JSON.stringify(webOnlyPluginName)} },`, " targets: ['claude', 'portable'],", " web: { apps: ['status/claude-only', 'status/status'] },", '});', @@ -490,7 +490,7 @@ it('refuses a host-emitted file that collides with the routed CLI bin (AB4766)', "import { defineConfig } from 'agent-bundle/config';", 'export default defineConfig({', " claude: { bin: './host-bin' },", - ` plugin: { description: 'Artifact routed CLI fixture.', name: ${JSON.stringify(pluginName)}, version: '3.8.7' },`, + ` plugin: { description: 'Artifact routed CLI fixture.', name: ${JSON.stringify(pluginName)} },`, " targets: ['claude'],", '});', '', diff --git a/packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts b/packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts index 04b0e4be5..8c4256ad1 100644 --- a/packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts +++ b/packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts @@ -230,7 +230,7 @@ it('bridges a browser-bound session to a generated stdio artifact without exposi const model = await normalizeProject( loadedProject(root, { mcp: { servers: { fixture: { entry: './src/server.ts' } } }, - plugin: { name: 'browser-bridge-fixture', version: '1.0.0' }, + plugin: { name: 'browser-bridge-fixture' }, targets: [fixture.binding.target], }), { skills: [] }, diff --git a/packages/agent-bundle/tests/build-compose.test.ts b/packages/agent-bundle/tests/build-compose.test.ts index c634a1908..3e2107945 100644 --- a/packages/agent-bundle/tests/build-compose.test.ts +++ b/packages/agent-bundle/tests/build-compose.test.ts @@ -73,7 +73,7 @@ const writeProject = async (root: string, options: FixtureOptions = {}): Promise " codex: { nativeHooks: './native/codex.json' },", " hooks: { sessionStart: './src/hooks/session-start.ts' },", " mcp: { servers: { fixture: { entry: './src/mcp/fixture.ts' } } },", - " plugin: { description: 'Composite root fixture.', name: 'composite-fixture', version: '1.0.0' },", + " plugin: { description: 'Composite root fixture.', name: 'composite-fixture' },", " scripts: { hello: './src/tools/hello.ts' },", targets.trimEnd(), '};', @@ -554,7 +554,7 @@ describe('composite plugin root (#555)', () => { writeProjectFile(root, 'package.json', '{"name":"event-projections","type":"module","version":"1.0.0"}\n'), writeProjectFile(root, 'agent-bundle.config.ts', [ 'export default {', - " plugin: { name: 'event-projections', version: '1.0.0' },", + " plugin: { name: 'event-projections' },", " targets: ['claude', 'codex', 'cursor', 'portable'],", '};', '', diff --git a/packages/agent-bundle/tests/build-reproducibility.test.ts b/packages/agent-bundle/tests/build-reproducibility.test.ts index c3ed6529a..ca2a1bc3a 100644 --- a/packages/agent-bundle/tests/build-reproducibility.test.ts +++ b/packages/agent-bundle/tests/build-reproducibility.test.ts @@ -44,21 +44,24 @@ const writeProject = async (root: string): Promise => { writeProjectFile(root, 'agent-bundle.config.ts', [ "import { defineConfig } from 'agent-bundle/config';", 'export default defineConfig({', - " plugin: { description: 'Reproducible build fixture.', name: 'reproducible-fixture', version: '1.0.0' },", + " plugin: { description: 'Reproducible build fixture.', name: 'reproducible-fixture' },", " targets: ['claude', 'codex', 'cursor', 'portable'],", '});', '', ].join('\n')), writeProjectFile(root, 'src/mcp/harness/tools/lookup.tsx', [ - "import { Agent } from '@agent-bundle/runtime';", - "import { z } from 'zod';", - "export const config = { inputJsonSchema: {\"additionalProperties\":false,\"properties\":{\"message\":{\"type\":\"string\",\"default\":\"ready\"}},\"type\":\"object\"}, annotations: { readOnlyHint: true }, description: 'Looks up one value.' };", - 'export const inputSchema = z.object({ message: z.string().default("ready") }).strict();', - "export const resultSchema = z.object({ message: z.string() }).strict();", - 'export default async function Lookup({ input }) {', - ' return {`Lookup: ${input.message}`};', - '}', - '', + "import { defineTool } from 'agent-bundle/routes';", + "import { Agent } from '@agent-bundle/runtime';", + "import { z } from 'zod';", + "export const inputSchema = z.object({ message: z.string().default(\"ready\") }).strict();", + "export const resultSchema = z.object({ message: z.string() }).strict();", + "export default defineTool({", + "inputJsonSchema: {\"additionalProperties\":false,\"properties\":{\"message\":{\"type\":\"string\",\"default\":\"ready\"}},\"type\":\"object\"}, annotations: { readOnlyHint: true }, description: 'Looks up one value.',", + " inputSchema,", + " resultSchema,", + "}, async (input) => {", + " return {`Lookup: ${input.message}`};", + "});", ].join('\n')), writeProjectFile(root, 'src/events/session/start.tsx', [ "import { Agent } from '@agent-bundle/runtime';", diff --git a/packages/agent-bundle/tests/build-source-publication.test.ts b/packages/agent-bundle/tests/build-source-publication.test.ts index c242887b9..ea910fa58 100644 --- a/packages/agent-bundle/tests/build-source-publication.test.ts +++ b/packages/agent-bundle/tests/build-source-publication.test.ts @@ -57,7 +57,7 @@ const publicationConfig = (includeLibrary: boolean): string => `import { writeFi import { join } from 'node:path'; export default ({ projectRoot }) => ({ - plugin: { name: 'ab7101-publication-fixture', version: '1.0.0' }, + plugin: { name: 'ab7101-publication-fixture' }, targets: ['portable'], mcp: { servers: { echoer: {} } }, ${includeLibrary ? "lib: { dts: false, entry: './src/library.ts' }," : ''} diff --git a/packages/agent-bundle/tests/claude-hooks-schema.test.ts b/packages/agent-bundle/tests/claude-hooks-schema.test.ts index 2384a0ed1..de38d9f81 100644 --- a/packages/agent-bundle/tests/claude-hooks-schema.test.ts +++ b/packages/agent-bundle/tests/claude-hooks-schema.test.ts @@ -238,7 +238,7 @@ it('plans a Claude native hooks document that uses every documented handler type const loaded: LoadedConfig = { config: { claude: { nativeHooks: './claude-hooks.json' }, - plugin: { name: 'review-tools', version: '1.0.0' }, + plugin: { name: 'review-tools' }, targets: ['claude'], }, configPath: join(root, 'agent-bundle.config.ts'), diff --git a/packages/agent-bundle/tests/cli-projection.test.ts b/packages/agent-bundle/tests/cli-projection.test.ts index a40516ca8..38c1f1a55 100644 --- a/packages/agent-bundle/tests/cli-projection.test.ts +++ b/packages/agent-bundle/tests/cli-projection.test.ts @@ -34,7 +34,7 @@ const writeTree = async (root: string, files: Readonly>): }; const fixtureConfig = (extra: Readonly> = {}): AgentBundleConfig => ({ - plugin: { name: 'projection-fixture', version: '1.0.0' }, + plugin: { name: 'projection-fixture' }, ...extra, }); @@ -45,13 +45,22 @@ const toolModule = (options: { readonly config?: string; readonly schema?: string; readonly inputJsonSchema?: RouteInputSchema | null; -} = {}): string => [ - `export const config = ${options.inputJsonSchema === null ? options.config ?? '{}' : (options.config ?? "{ description: 'Submit work.' }").replace('{', '{ inputJsonSchema: ' + JSON.stringify(options.inputJsonSchema ?? { type: 'object', additionalProperties: false, properties: { laneKey: { type: 'string' } }, required: ['laneKey'] }) + ',')};`, - `export const inputSchema = ${options.schema ?? 'z.object({ laneKey: z.string() }).strict()'};`, - 'export const resultSchema = z.object({ ok: z.boolean() });', - 'export default async function Tool() { return undefined; }', - '', -].join('\n'); +} = {}): string => { + const config = options.inputJsonSchema === null + ? options.config ?? '{}' + : (options.config ?? "{ description: 'Submit work.' }").replace( + '{', + `{ inputJsonSchema: ${JSON.stringify(options.inputJsonSchema ?? { type: 'object', additionalProperties: false, properties: { laneKey: { type: 'string' } }, required: ['laneKey'] })},`, + ); + const fields = config.slice(1, -1).trim(); + return [ + "import { defineTool } from 'agent-bundle/routes';", + `export const inputSchema = ${options.schema ?? 'z.object({ laneKey: z.string() }).strict()'};`, + 'export const resultSchema = z.object({ ok: z.boolean() });', + `export default defineTool({ ${fields}${fields.length === 0 ? '' : ', '}inputSchema, resultSchema }, async function Tool() { return undefined; });`, + '', + ].join('\n'); +}; const cliModule = (config: string, mapInput?: string): string => [ `export const config = ${config};`, diff --git a/packages/agent-bundle/tests/cli-routes-build.test.ts b/packages/agent-bundle/tests/cli-routes-build.test.ts index 76b914be5..9942d43b9 100644 --- a/packages/agent-bundle/tests/cli-routes-build.test.ts +++ b/packages/agent-bundle/tests/cli-routes-build.test.ts @@ -67,7 +67,7 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 writeProjectFile(root, 'agent-bundle.config.ts', [ "import { defineConfig } from 'agent-bundle/config';", 'export default defineConfig({', - " plugin: { description: 'Routed CLI fixture.', name: 'cli-bin-fixture', version: '1.0.0' },", + " plugin: { description: 'Routed CLI fixture.', name: 'cli-bin-fixture' },", ' routes: { mcpCommands: true },', " targets: ['portable'],", '});', @@ -203,29 +203,29 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 ].join('\n')), writeProjectFile(root, 'src/mcp/harness/tools/lookup.tsx', [ "import { Agent, agent } from '@agent-bundle/runtime';", + "import { defineTool } from 'agent-bundle/routes';", "import { z } from 'zod';", - "export const config = { inputJsonSchema: {\"additionalProperties\":false,\"properties\":{\"message\":{\"type\":\"string\",\"default\":\"ready\"}},\"type\":\"object\"}, annotations: { readOnlyHint: true }, description: 'Looks up one value.' };", 'export const inputSchema = z.object({ message: z.string().default("ready") }).strict();', "export const resultSchema = z.object({ invocation: z.enum(['cli', 'tool']), message: z.string(), operationId: z.string(), tooling: z.string(), view: z.unknown() }).strict();", - 'export default async function Lookup({ input }) {', + "export default defineTool({ inputJsonSchema: {\"additionalProperties\":false,\"properties\":{\"message\":{\"type\":\"string\",\"default\":\"ready\"}},\"type\":\"object\"}, annotations: { readOnlyHint: true }, description: 'Looks up one value.', inputSchema, resultSchema }, async (input) => {", ' const context = await agent();', " await context.progress.report({ completed: 1, message: 'lookup', total: 1 });", ' const result = { invocation: context.invocation.kind, message: input.message, operationId: context.invocation.operationId, tooling: `${(await context.provider("libraryTooling")).kind}:${(await context.provider("libraryTooling")).tool}`, view: (await context.provider("libraryTooling")).view };', ' return {`Lookup: ${input.message}`};', - '}', + '});', '', ].join('\n')), writeProjectFile(root, 'src/mcp/harness/tools/apply.tsx', [ "import { Agent, agent } from '@agent-bundle/runtime';", + "import { defineTool } from 'agent-bundle/routes';", "import { z } from 'zod';", - "export const config = { inputJsonSchema: {\"additionalProperties\":false,\"properties\":{\"value\":{\"type\":\"string\"}},\"required\":[\"value\"],\"type\":\"object\"}, description: 'Applies one value.' };", 'export const inputSchema = z.object({ value: z.string() }).strict();', "export const resultSchema = z.object({ invocation: z.enum(['cli', 'tool']), operationId: z.string(), value: z.string() }).strict();", - 'export default async function Apply({ input }) {', + "export default defineTool({ inputJsonSchema: {\"additionalProperties\":false,\"properties\":{\"value\":{\"type\":\"string\"}},\"required\":[\"value\"],\"type\":\"object\"}, description: 'Applies one value.', inputSchema, resultSchema }, async (input) => {", ' const context = await agent();', ' const result = { invocation: context.invocation.kind, operationId: context.invocation.operationId, value: input.value };', ' return {`Applied ${input.value}.`};', - '}', + '});', '', ].join('\n')), writeProjectFile(root, 'src/scripts/checksum.ts', [ @@ -464,7 +464,7 @@ it('refuses a routed command that imports agent-bundle/api with AB4837 during de writeProjectFile(root, 'agent-bundle.config.ts', [ "import { defineConfig } from 'agent-bundle/config';", 'export default defineConfig({', - " plugin: { description: 'Routed CLI fixture.', name: 'cli-bin-framework-import-fixture', version: '1.0.0' },", + " plugin: { description: 'Routed CLI fixture.', name: 'cli-bin-framework-import-fixture' },", " targets: ['portable'],", '});', '', @@ -541,7 +541,7 @@ describe('the CLI surface projection in the generated routed-CLI executable', () writeProjectFile(root, 'agent-bundle.config.ts', [ "import { defineConfig } from 'agent-bundle/config';", 'export default defineConfig({', - " plugin: { description: 'CLI projection fixture.', name: 'cli-projection-fixture', version: '1.0.0' },", + " plugin: { description: 'CLI projection fixture.', name: 'cli-projection-fixture' },", ' routes: { mcpCommands: true },', " targets: ['portable'],", '});', @@ -549,25 +549,25 @@ describe('the CLI surface projection in the generated routed-CLI executable', () ].join('\n')), writeProjectFile(root, 'src/mcp/demo/tools/ping.tsx', [ "import { Agent } from '@agent-bundle/runtime';", + "import { defineTool } from 'agent-bundle/routes';", "import { z } from 'zod';", - "export const config = { inputJsonSchema: {\"additionalProperties\":false,\"properties\":{},\"type\":\"object\"}, annotations: { readOnlyHint: true }, description: 'Answers a ping.' };", 'export const inputSchema = z.object({}).strict();', "export const resultSchema = z.object({ pong: z.literal(true) }).strict();", - 'export default async function Ping() {', + "export default defineTool({ inputJsonSchema: {\"additionalProperties\":false,\"properties\":{},\"type\":\"object\"}, annotations: { readOnlyHint: true }, description: 'Answers a ping.', inputSchema, resultSchema }, async () => {", ' return pong;', - '}', + '});', '', ].join('\n')), writeProjectFile(root, 'src/mcp/demo/tools/purge.tsx', [ "import { Agent } from '@agent-bundle/runtime';", + "import { defineTool } from 'agent-bundle/routes';", "import { z } from 'zod';", - "export const config = { inputJsonSchema: {\"additionalProperties\":false,\"properties\":{\"target\":{\"type\":\"string\"}},\"required\":[\"target\"],\"type\":\"object\"}, annotations: { readOnlyHint: false }, description: 'Purges one cache target.' };", 'export const inputSchema = z.object({ target: z.string().min(1) }).strict();', "export const resultSchema = z.object({ operation: z.literal('purge'), target: z.string() }).strict();", - 'export default async function Purge({ input }) {', + "export default defineTool({ inputJsonSchema: {\"additionalProperties\":false,\"properties\":{\"target\":{\"type\":\"string\"}},\"required\":[\"target\"],\"type\":\"object\"}, annotations: { readOnlyHint: false }, description: 'Purges one cache target.', inputSchema, resultSchema }, async (input) => {", " const value = { operation: 'purge', target: input.target };", ' return {`purged: ${input.target}`};', - '}', + '});', '', ].join('\n')), writeProjectFile(root, 'src/mcp/demo/tools/purge.cli.ts', [ @@ -580,8 +580,8 @@ describe('the CLI surface projection in the generated routed-CLI executable', () ].join('\n')), writeProjectFile(root, 'src/mcp/demo/tools/submit.tsx', [ "import { Agent, agent } from '@agent-bundle/runtime';", + "import { defineTool } from 'agent-bundle/routes';", "import { z } from 'zod';", - "export const config = { inputJsonSchema: {\"additionalProperties\":false,\"properties\":{\"argv\":{\"items\":{\"type\":\"string\"},\"type\":\"array\"},\"cwd\":{\"type\":\"string\",\"default\":\".\"},\"laneKey\":{\"type\":\"string\"},\"tags\":{\"items\":{\"type\":\"string\"},\"type\":\"array\"},\"yes\":{\"type\":\"boolean\"}},\"required\":[\"argv\"],\"type\":\"object\"}, annotations: { readOnlyHint: false }, description: 'Submits one command line as lane work.' };", // The application-owned optional `yes` key (#616): the projection // declares confirm: false, so the shell strips nothing and the value // must reach the tool through the canonical schema. @@ -600,7 +600,7 @@ describe('the CLI surface projection in the generated routed-CLI executable', () ' tags: z.array(z.string()).optional(),', ' yes: z.boolean().optional(),', '});', - 'export default async function Submit({ input }) {', + "export default defineTool({ inputJsonSchema: {\"additionalProperties\":false,\"properties\":{\"argv\":{\"items\":{\"type\":\"string\"},\"type\":\"array\"},\"cwd\":{\"type\":\"string\",\"default\":\".\"},\"laneKey\":{\"type\":\"string\"},\"tags\":{\"items\":{\"type\":\"string\"},\"type\":\"array\"},\"yes\":{\"type\":\"boolean\"}},\"required\":[\"argv\"],\"type\":\"object\"}, annotations: { readOnlyHint: false }, description: 'Submits one command line as lane work.', inputSchema, resultSchema }, async (input) => {", ' const { invocation } = await agent();', " const value = { ...input, operation: 'submit' };", ' return (', @@ -609,7 +609,7 @@ describe('the CLI surface projection in the generated routed-CLI executable', () ' {`invocation: ${invocation.kind} ${invocation.operationId} ${invocation.surface}`}', ' ', ' );', - '}', + '});', '', ].join('\n')), writeProjectFile(root, projectionModule, [ diff --git a/packages/agent-bundle/tests/cli-routes.test.ts b/packages/agent-bundle/tests/cli-routes.test.ts index f27011c0a..249ee86a6 100644 --- a/packages/agent-bundle/tests/cli-routes.test.ts +++ b/packages/agent-bundle/tests/cli-routes.test.ts @@ -46,13 +46,20 @@ const writeTree = async (root: string, files: Readonly>): }; const fixtureConfig = (extra: Readonly> = {}): AgentBundleConfig => ({ - plugin: { name: 'cli-fixture', version: '1.0.0' }, + plugin: { name: 'cli-fixture' }, ...extra, }); const codesOf = (diagnostics: readonly { readonly code: string }[]): string[] => diagnostics.map((diagnostic) => diagnostic.code); +const nonInteractiveTerminal = (hostSurface: 'cli' | 'script'): AgentTerminal => ({ + hostSurface, + sharesTarget: false, + stderr: { color: 'none', kind: 'none' }, + stdout: { color: 'none', kind: 'none' }, +}); + const extract = (schema: RouteInputSchema) => projectInputSchemaOptions(schema, 'src/cli/example.ts', '/project/src/cli/example.ts'); it('projects explicit input metadata into scalar, enum, and repeated flags', () => { @@ -98,13 +105,16 @@ const plainCommandModule = (options: { '', ].join('\n'); -const toolModule = (config?: string): string => [ - ...(config === undefined ? [] : [`export const config = ${config};`]), - 'export const inputSchema = operation.inputSchema;', - 'export const resultSchema = operation.resultSchema;', - 'export default async function Tool() { return undefined; }', - '', -].join('\n'); +const toolModule = (config = '{}'): string => { + const fields = config.slice(1, -1).trim(); + return [ + "import { defineTool } from 'agent-bundle/routes';", + 'export const inputSchema = operation.inputSchema;', + 'export const resultSchema = operation.resultSchema;', + `export default defineTool({ ${fields}${fields.length === 0 ? '' : ', '}inputSchema, resultSchema }, async function Tool() { return undefined; });`, + '', + ].join('\n'); +}; describe('compiled command graph', () => { it('compiles nesting, aliases, positionals, and the exit-code policy into graph.cli.commands', async () => { @@ -271,7 +281,7 @@ describe('compiled command graph', () => { const root = await createRoot(); await writeTree(root, { 'agent-bundle.config.ts': [ - "export default { plugin: { name: 'cli-fixture', version: '1.0.0' }, targets: ['portable'] };", + "export default { plugin: { name: 'cli-fixture' }, targets: ['portable'] };", '', ].join('\n'), 'package.json': '{"type":"module"}\n', @@ -298,7 +308,7 @@ describe('compiled command graph', () => { 'agent-bundle.config.ts': [ 'export default {', " bin: { 'cli-fixture': './src/tool.ts' },", - " plugin: { name: 'cli-fixture', version: '1.0.0' },", + " plugin: { name: 'cli-fixture' },", " targets: ['portable'],", '};', '', @@ -794,7 +804,6 @@ describe('generated CLI shell', () => { execute: async () => { throw new Error('plain execute must not run for an MCP command'); }, - isTty: () => false, name: 'curator', render: (command, input) => { calls.push({ command, input, json: argv.includes('--json') }); @@ -808,6 +817,7 @@ describe('generated CLI shell', () => { validate: (value) => value, }; }, + terminal: nonInteractiveTerminal('cli'), version: '1.2.3', writeErr: (text) => void stderr.push(text), writeOut: (text) => void stdout.push(text), @@ -1253,7 +1263,7 @@ describe('rendered command projection (#102 stage 3)', () => { options: { readonly error?: Error; readonly events?: readonly CliRenderedEvent[]; - readonly isTty?: boolean; + readonly terminal?: AgentTerminal; readonly validate?: (value: unknown) => unknown; } = {}, ): Promise => { @@ -1275,9 +1285,9 @@ describe('rendered command projection (#102 stage 3)', () => { execute: async () => { throw new Error('plain execute must not run for a rendered command'); }, - isTty: () => options.isTty ?? false, name: 'curator', render: () => session, + terminal: options.terminal ?? nonInteractiveTerminal('cli'), version: '1.2.3', writeErr: (text) => void stderr.push(text), writeOut: (text) => void stdout.push(text), @@ -1294,7 +1304,14 @@ describe('rendered command projection (#102 stage 3)', () => { }); it('updates progress in place on a TTY before the final document', async () => { - const tty = await runRendered(['report', '/library'], { isTty: true }); + const tty = await runRendered(['report', '/library'], { + terminal: { + hostSurface: 'cli', + sharesTarget: true, + stderr: { color: 'none', kind: 'tty' }, + stdout: { color: 'none', kind: 'tty' }, + }, + }); expect(tty.code).toBe(0); expect(tty.stdout).toBe('\r\u001B[2Kauditing (1/2)\r\u001B[2KFound **2** books.\n'); }); @@ -1347,24 +1364,6 @@ describe('rendered command projection (#102 stage 3)', () => { }); expect(seen).toEqual([terminal]); - // The legacy `isTty` knob still shapes a consistent capability for stdout. - const legacy: AgentTerminal[] = []; - await runGeneratedCliEntry({ - argv: ['report', '/library'], - commands: [renderedCommand], - execute: async () => undefined, - isTty: () => false, - name: 'curator', - render: (_command, _input, context) => { - legacy.push(context.terminal); - return { close: async () => undefined, events: () => eventStream(events), validate: (value) => value }; - }, - version: '1.2.3', - writeErr: () => undefined, - writeOut: () => undefined, - }); - expect(legacy[0]?.hostSurface).toBe('cli'); - expect(legacy[0]?.stdout.kind).not.toBe('tty'); }); it('emits the canonical validated final value under --json', async () => { @@ -1451,8 +1450,8 @@ describe('rendered script projection (#102 stage 3)', () => { validate: (value) => value, }; }, - isTty: () => false, name: 'summarize', + terminal: nonInteractiveTerminal('script'), writeErr: () => undefined, writeOut: (text) => void stdout.push(text), }); @@ -1469,8 +1468,8 @@ describe('rendered script projection (#102 stage 3)', () => { events: () => eventStream([{ document: completeDocument('failed', undefined), sequence: 0, type: 'complete' }]), validate: (value) => value, }), - isTty: () => false, name: 'summarize', + terminal: nonInteractiveTerminal('script'), writeErr: () => undefined, writeOut: () => undefined, }); diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index 3f9e4694f..b80992b60 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -80,12 +80,12 @@ const createCliProject = async ( await mkdir(join(root, 'src', 'skills', 'review'), { recursive: true }); if (mcpApp) await mkdir(join(root, 'views'), { recursive: true }); await Promise.all([ - writeFile(join(root, 'package.json'), '{"type":"module"}\n'), + writeFile(join(root, 'package.json'), '{"type":"module","version":"1.0.0"}\n'), writeFile( join(root, 'agent-bundle.config.ts'), [ 'export default ({ command, mode, projectRoot, selectedTargets }) => ({', - " plugin: { name: 'cli-fixture', version: '1.0.0' },", + " plugin: { name: 'cli-fixture' },", " targets: selectedTargets.length === 0 ? ['portable', 'codex'] : selectedTargets,", ' fixtureContext: { command, mode, projectRoot, selectedTargets },', ...(mcpApp ? [ @@ -118,12 +118,12 @@ const createServiceProject = async (): Promise => { const root = join(parent, 'project with spaces'); await mkdir(join(root, 'src'), { recursive: true }); await Promise.all([ - writeFile(join(root, 'package.json'), '{"type":"module"}\n'), + writeFile(join(root, 'package.json'), '{"type":"module","version":"1.0.0"}\n'), writeFile( join(root, 'agent-bundle.config.ts'), [ 'export default ({ selectedTargets }) => ({', - " plugin: { name: 'service-fixture', version: '1.0.0' },", + " plugin: { name: 'service-fixture' },", " targets: selectedTargets.length === 0 ? ['codex', 'claude'] : selectedTargets,", " hooks: { sessionStart: { handler: './src/hook.ts' } },", " mcp: { servers: { fixture: { entry: './src/server.ts' } } },", @@ -933,7 +933,7 @@ it('dumps the lowered Rspack configuration of every output with inspect --bundle join(project.root, 'agent-bundle.config.ts'), [ 'export default {', - " plugin: { name: 'cli-fixture', version: '1.0.0' },", + " plugin: { name: 'cli-fixture' },", " targets: ['portable'],", " scripts: { tool: './src/tool.ts' },", " tools: { rspack: { resolve: { alias: { '@fixture': './src' } } } },", @@ -1007,7 +1007,7 @@ it('reports source validation diagnostics on stderr before staging an artifact', await writeFile(join(output, 'sentinel.txt'), 'keep\n'); await writeFile( join(project.root, 'agent-bundle.config.ts'), - "export default { plugin: { version: '1.0.0' } };\n", + 'export default { plugin: {} };\n', ); const result = await runCli(project.root, [ diff --git a/packages/agent-bundle/tests/command-config.test.ts b/packages/agent-bundle/tests/command-config.test.ts index 228af8edf..3b3051c48 100644 --- a/packages/agent-bundle/tests/command-config.test.ts +++ b/packages/agent-bundle/tests/command-config.test.ts @@ -21,7 +21,7 @@ const loadedProject = ( targets: readonly string[], ): LoadedConfig => ({ config: { - plugin: { name: 'command-fixture', version: '1.0.0' }, + plugin: { name: 'command-fixture' }, targets: [...targets], }, configPath: join(root, 'agent-bundle.config.ts'), @@ -40,7 +40,7 @@ const withProject = async ( try { await writeFile( join(root, 'agent-bundle.config.ts'), - "export default { plugin: { name: 'command-fixture', version: '1.0.0' } };\n", + "export default { plugin: { name: 'command-fixture' } };\n", ); await run(root); } finally { @@ -163,7 +163,7 @@ it('discovers flat non-ignored commands deterministically and omits the collecti writeFile(join(root, 'src', 'commands', 'ignored.md'), '# Ignored\n'), ]); const config: AgentBundleConfig = { - plugin: { name: 'command-fixture', version: '1.0.0' }, + plugin: { name: 'command-fixture' }, }; const discovered = await discoverProject(root, config); diff --git a/packages/agent-bundle/tests/compiler-evidence.test.ts b/packages/agent-bundle/tests/compiler-evidence.test.ts index c4f74bfa7..9433ccc8d 100644 --- a/packages/agent-bundle/tests/compiler-evidence.test.ts +++ b/packages/agent-bundle/tests/compiler-evidence.test.ts @@ -270,7 +270,7 @@ describe('compiler evidence on host-pack builds', () => { const root = await fixtureRoot({ 'agent-bundle.config.ts': [ 'export default {', - " plugin: { name: 'compiler-evidence-fixture', version: '1.0.0' },", + " plugin: { name: 'compiler-evidence-fixture' },", " targets: ['portable'],", " tools: { rsbuild: { output: { externals: ['left-pad'] } } },", '};', diff --git a/packages/agent-bundle/tests/composite-rules.test.ts b/packages/agent-bundle/tests/composite-rules.test.ts index 2a71c1258..7f5f77821 100644 --- a/packages/agent-bundle/tests/composite-rules.test.ts +++ b/packages/agent-bundle/tests/composite-rules.test.ts @@ -66,7 +66,7 @@ const writeProject = async (root: string, options: FixtureOptions): Promise { await mkdir(join(fixture.root, 'configs'), { recursive: true }); await writeFile( configPath, - "export default { plugin: { name: 'fresh', version: '1.0.0' } };\n", + "export default { plugin: { name: 'fresh' } };\n", ); - expect((await loadConfig(options)).config.plugin).toEqual({ - name: 'fresh', - version: '1.0.0', - }); + expect((await loadConfig(options)).config.plugin).toEqual({ name: 'fresh' }); await writeFile( configPath, - "export default { plugin: { name: 'fresh', version: '2.0.0' } };\n", + "export default { plugin: { name: 'fresh' } };\n", ); await expect(loadConfig(options)).resolves.toMatchObject({ - config: { plugin: { name: 'fresh', version: '2.0.0' } }, + config: { plugin: { name: 'fresh' } }, configPath, }); } finally { @@ -289,30 +286,30 @@ it('reloads an overwritten static TypeScript default config', async () => { try { await expect(loadConfig(options)).resolves.toMatchObject({ - config: { plugin: { name: 'review', version: '1.0.0' } }, + config: { plugin: { name: 'review' } }, }); await writeFile( fixture.configPath, [ - "enum Version { Current = '1.0.0' }", - "export default { plugin: { name: 'typescript-fresh', version: Version.Current } };", + "enum Name { Current = 'typescript-fresh' }", + 'export default { plugin: { name: Name.Current } };', '', ].join('\n'), ); await expect(loadConfig(options)).resolves.toMatchObject({ - config: { plugin: { name: 'typescript-fresh', version: '1.0.0' } }, + config: { plugin: { name: 'typescript-fresh' } }, }); await writeFile( fixture.configPath, [ - "enum Version { Current = '2.0.0' }", - "export default { plugin: { name: 'typescript-fresh', version: Version.Current } };", + "enum Name { Current = 'typescript-fresher' }", + 'export default { plugin: { name: Name.Current } };', '', ].join('\n'), ); await expect(loadConfig(options)).resolves.toMatchObject({ - config: { plugin: { name: 'typescript-fresh', version: '2.0.0' } }, + config: { plugin: { name: 'typescript-fresher' } }, }); } finally { await removeProjectFixture(fixture.root); @@ -331,7 +328,7 @@ it('discovers an explicit non-conventional skill path relative to the project ro ); const discovered = await discoverProject(fixture.root, { - plugin: { name: 'review', version: '1.0.0' }, + plugin: { name: 'review' }, skills: ['custom/selected'], }); @@ -359,7 +356,7 @@ it('expands glob patterns in explicit skills entries and deduplicates overlappin ); const discovered = await discoverProject(fixture.root, { - plugin: { name: 'review', version: '1.0.0' }, + plugin: { name: 'review' }, skills: ['src/skills/*', 'custom/*/SKILL.md', 'custom/selected'], }); @@ -380,7 +377,7 @@ it('discovers conventional root assets and strips the assets/ prefix from destin await writeFile(join(fixture.root, 'assets/fonts/mono.woff'), 'font'); const discovered = await discoverProject(fixture.root, { - plugin: { name: 'review', version: '1.0.0' }, + plugin: { name: 'review' }, }); expect(discovered.assets).toEqual([ @@ -406,7 +403,7 @@ it('expands explicit asset entries as files, directories, and globs while droppi const discovered = await discoverProject(fixture.root, { assets: ['assets/logo.svg', 'branding', 'docs/*.md', 'missing/logo.svg'], - plugin: { name: 'review', version: '1.0.0' }, + plugin: { name: 'review' }, }); expect(discovered.assets).toEqual([ @@ -428,7 +425,7 @@ it('honors an explicit empty assets list instead of conventional discovery', asy const discovered = await discoverProject(fixture.root, { assets: [], - plugin: { name: 'review', version: '1.0.0' }, + plugin: { name: 'review' }, }); expect(discovered.assets).toEqual([]); diff --git a/packages/agent-bundle/tests/dev-artifact-service.test.ts b/packages/agent-bundle/tests/dev-artifact-service.test.ts index 1bd85e6b4..4b66dff19 100644 --- a/packages/agent-bundle/tests/dev-artifact-service.test.ts +++ b/packages/agent-bundle/tests/dev-artifact-service.test.ts @@ -27,7 +27,7 @@ const createProject = async (): Promise => { join(root, 'agent-bundle.config.ts'), [ 'export default {', - " plugin: { name: 'artifact-service-fixture', version: '1.0.0' },", + " plugin: { name: 'artifact-service-fixture' },", " targets: ['portable'],", '};', '', @@ -44,6 +44,7 @@ const createProject = async (): Promise => { '', ].join('\n'), ), + writeFile(join(root, 'package.json'), '{"type":"module","version":"1.0.0"}\n'), ]); return root; }; @@ -460,7 +461,7 @@ it('changes the canonical model digest when a registered extension changes', asy await Promise.all([ writeFile(join(leftRoot, 'agent-bundle.config.ts'), [ 'export default {', - " plugin: { name: 'extension-identity', version: '1.0.0' },", + " plugin: { name: 'extension-identity' },", " targets: ['portable'],", " portable: { compatibility: 'v1' },", '};', @@ -468,7 +469,7 @@ it('changes the canonical model digest when a registered extension changes', asy ].join('\n')), writeFile(join(rightRoot, 'agent-bundle.config.ts'), [ 'export default {', - " plugin: { name: 'extension-identity', version: '1.0.0' },", + " plugin: { name: 'extension-identity' },", " targets: ['portable'],", " portable: { compatibility: 'v2' },", '};', diff --git a/packages/agent-bundle/tests/dev-contract-config.test.ts b/packages/agent-bundle/tests/dev-contract-config.test.ts index 9a5622ab5..89eb64a64 100644 --- a/packages/agent-bundle/tests/dev-contract-config.test.ts +++ b/packages/agent-bundle/tests/dev-contract-config.test.ts @@ -6,7 +6,7 @@ import { createProjectFixture, removeProjectFixture } from './helpers/project-fi const configSource = [ 'export default {', " dev: { contracts: { fixtures: './contract-fixtures.ts', server: 'fixture' } },", - " plugin: { name: 'dev-contract-fixture', version: '1.0.0' },", + " plugin: { name: 'dev-contract-fixture' },", '};', '', ].join('\n'); diff --git a/packages/agent-bundle/tests/dev-coordinator.test.ts b/packages/agent-bundle/tests/dev-coordinator.test.ts index 8535270bf..785bd05f5 100644 --- a/packages/agent-bundle/tests/dev-coordinator.test.ts +++ b/packages/agent-bundle/tests/dev-coordinator.test.ts @@ -24,7 +24,7 @@ import { createProjectFixture } from './helpers/project-fixture.ts'; const createProject = async (): Promise => (await createProjectFixture({ config: [ 'export default {', - " plugin: { name: 'dev-coordinator-fixture', version: '1.0.0' },", + " plugin: { name: 'dev-coordinator-fixture' },", " targets: ['portable'],", '};', '', @@ -632,7 +632,7 @@ it('forwards prepared artifact and eval output roots to its watcher', async () = await writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', " output: { distPath: 'artifact-out' },", - " plugin: { name: 'dev-coordinator-fixture', version: '1.0.0' },", + " plugin: { name: 'dev-coordinator-fixture' },", " targets: ['portable'],", '};', '', @@ -699,7 +699,7 @@ it('adds recovered artifact and eval roots to the live watcher before generated 'export default {', " evals: { runsDir: 'recorded-evals' },", " output: { distPath: 'artifact-out' },", - " plugin: { name: 'dev-coordinator-fixture', version: '1.0.0' },", + " plugin: { name: 'dev-coordinator-fixture' },", " targets: ['portable'],", '};', '', diff --git a/packages/agent-bundle/tests/dev-package-build.test.ts b/packages/agent-bundle/tests/dev-package-build.test.ts index 6c710a41d..751e38027 100644 --- a/packages/agent-bundle/tests/dev-package-build.test.ts +++ b/packages/agent-bundle/tests/dev-package-build.test.ts @@ -17,7 +17,7 @@ it('rebuilds the package build inside the dev loop when its entries change', asy const fixture = await createProjectFixture({ config: [ 'export default {', - " plugin: { name: 'dev-package-fixture', version: '1.0.0' },", + " plugin: { name: 'dev-package-fixture' },", " targets: ['portable'],", '};', '', diff --git a/packages/agent-bundle/tests/dev-services.test.ts b/packages/agent-bundle/tests/dev-services.test.ts index 6eaaeb619..9ae8cad32 100644 --- a/packages/agent-bundle/tests/dev-services.test.ts +++ b/packages/agent-bundle/tests/dev-services.test.ts @@ -41,12 +41,13 @@ const createProject = async (skillMarkdown: string): Promise => { join(root, 'agent-bundle.config.ts'), [ 'export default {', - " plugin: { name: 'dev-service-fixture', version: '1.0.0' },", + " plugin: { name: 'dev-service-fixture' },", " targets: ['portable'],", '};', '', ].join('\n'), ), + writeFile(join(root, 'package.json'), '{"type":"module","version":"1.0.0"}\n'), writeFile(join(root, 'src', 'skills', 'review', 'SKILL.md'), skillMarkdown), ]); return root; @@ -95,12 +96,13 @@ const createRuntimeProject = async (options: Readonly<{ 'export default {', " dev: { runtime: { provider: " + JSON.stringify(options.provider ?? './src/dev/provider.ts') + ' } },', " mcp: { servers: { timeline: { apps: { dashboard: " + appDeclaration + " }, entry: './src/server.ts', targets: ['portable'] } } },", - " plugin: { name: 'dev-runtime-fixture', version: '1.0.0' },", + " plugin: { name: 'dev-runtime-fixture' },", " targets: ['portable'],", ...(options.configExtension === undefined ? [] : [" portable: " + options.configExtension + ',']), '};', '', ].join('\n')), + writeFile(join(root, 'package.json'), '{"type":"module","version":"1.0.0"}\n'), ]); return { metadataSentinel, root, sentinel }; }; @@ -180,6 +182,7 @@ it('surfaces a non-finite registered config extension as the closed AB4500 proje severity: 'error', sourcePath: join(project.root, 'agent-bundle.config.ts'), }], + packageVersion: '1.0.0', revision: expect.any(String), state: 'invalid', }); @@ -248,7 +251,7 @@ it('keeps lookalike proxy failures and control-character extension keys redacted try { await writeFile(keyedProject + '/agent-bundle.config.ts', [ 'const config = {', - " plugin: { name: 'bounded-extension-fixture', version: '1.0.0' },", + " plugin: { name: 'bounded-extension-fixture' },", " targets: ['bounded-extension'],", '};', `config[${JSON.stringify(longKey)}] = Number.NaN;`, @@ -293,7 +296,7 @@ it('keeps hostile config-extension accessors redacted behind AB7001', async () = try { await writeFile(project.root + '/agent-bundle.config.ts', [ 'const config = {', - " plugin: { name: 'hostile-extension-fixture', version: '1.0.0' },", + " plugin: { name: 'hostile-extension-fixture' },", " targets: ['portable'],", '};', "Object.defineProperty(config, 'portable', { enumerable: true, get: () => { throw new Error('hostile-extension-secret'); } });", @@ -463,6 +466,7 @@ it('creates an exact deeply frozen root-independent project context', async () = 'configDigest', 'configPath', 'modelDigest', + 'packageVersion', 'revision', 'sourceInputs', ]); @@ -471,6 +475,7 @@ it('creates an exact deeply frozen root-independent project context', async () = expect(left.projectContext?.sourceInputs.map((input) => input.path)).toEqual([ 'a-first.txt', 'agent-bundle.config.ts', + 'package.json', 'src/skills/review/SKILL.md', 'z-last.txt', ]); @@ -746,7 +751,7 @@ it('resolves, excludes, and falls back from configured artifact output paths', a writeFile(join(configuredRoot, 'agent-bundle.config.ts'), [ 'export default {', " output: { distPath: 'build/artifact' },", - " plugin: { name: 'configured-output', version: '1.0.0' },", + " plugin: { name: 'configured-output' },", " targets: ['portable'],", '};', '', @@ -754,7 +759,7 @@ it('resolves, excludes, and falls back from configured artifact output paths', a writeFile(join(malformedRoot, 'agent-bundle.config.ts'), [ 'export default {', ' output: { distPath: 7 },', - " plugin: { name: 'malformed-output', version: '1.0.0' },", + " plugin: { name: 'malformed-output' },", " targets: ['portable'],", '};', '', @@ -802,7 +807,7 @@ it('treats a configured eval run directory as generated output, not project sour await writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', " evals: { runsDir: 'recorded-evals' },", - " plugin: { name: 'review', version: '1.0.0' },", + " plugin: { name: 'review' },", " targets: ['portable'],", '};', '', @@ -836,7 +841,7 @@ it('reports external configuration symlinks without exposing the underlying path try { await writeFile(externalConfig, [ 'export default {', - " plugin: { name: 'external-config', version: '1.0.0' },", + " plugin: { name: 'external-config' },", " targets: ['portable'],", '};', '', @@ -877,7 +882,7 @@ it('reports snapshot failures as frozen preparation diagnostics', async () => { 'export default ({ projectRoot }) => {', ` symlinkSync(${JSON.stringify(externalOutput)}, \`${'${projectRoot}'}/snapshot-output\`);`, ' return {', - " plugin: { name: 'snapshot-failure', version: '1.0.0' },", + " plugin: { name: 'snapshot-failure' },", " targets: ['portable'],", ' };', '};', @@ -1380,7 +1385,7 @@ it.each([ await writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', ` claude: { ${field}: './payload' },`, - " plugin: { name: 'escaped-payload', version: '1.0.0' },", + " plugin: { name: 'escaped-payload' },", " targets: ['claude'],", '};', '', @@ -1474,7 +1479,7 @@ it('changes a payload source revision when an executable bit is lost', async () await writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', " claude: { bin: './bin' },", - " plugin: { name: 'executable-payload', version: '1.0.0' },", + " plugin: { name: 'executable-payload' },", " targets: ['claude'],", '};', '', diff --git a/packages/agent-bundle/tests/dev-web-host-routes.test.ts b/packages/agent-bundle/tests/dev-web-host-routes.test.ts index 9a917ef1f..3ffcb9449 100644 --- a/packages/agent-bundle/tests/dev-web-host-routes.test.ts +++ b/packages/agent-bundle/tests/dev-web-host-routes.test.ts @@ -49,7 +49,7 @@ it('serves an exposed App and registers its opening call for page binding', asyn " apps: { status: { entry: './views/status.ts', resourceUri: 'ui://fixture/status.html', template: './views/status.html' } },", " entry: './src/server.ts',", ' } } },', - " plugin: { name: 'dev-web-host-fixture', version: '1.0.0' },", + " plugin: { name: 'dev-web-host-fixture' },", " targets: ['portable'],", " web: { apps: [{ app: 'status/status', input: { service: 'compiler' }, tool: 'show-status' }] },", '});', diff --git a/packages/agent-bundle/tests/dev-workbench-packaging.test.ts b/packages/agent-bundle/tests/dev-workbench-packaging.test.ts index f5e41f7e6..081740210 100644 --- a/packages/agent-bundle/tests/dev-workbench-packaging.test.ts +++ b/packages/agent-bundle/tests/dev-workbench-packaging.test.ts @@ -84,8 +84,8 @@ it('serves prebuilt workbench assets from an installed tarball without the repos await execFile('npm', ['install', ...cachedNpmInstallArguments, tarball], { cwd: consumer, env: installedEnvironment() }); await mkdir(join(project, 'skills', 'review'), { recursive: true }); await Promise.all([ - writeFile(join(project, 'package.json'), '{"type":"module"}\n'), - writeFile(join(project, 'agent-bundle.config.ts'), "export default { plugin: { name: 'packed-workbench', version: '1.0.0' }, targets: ['portable'] };\n"), + writeFile(join(project, 'package.json'), '{"type":"module","version":"1.0.0"}\n'), + writeFile(join(project, 'agent-bundle.config.ts'), "export default { plugin: { name: 'packed-workbench' }, targets: ['portable'] };\n"), writeFile(join(project, 'skills', 'review', 'SKILL.md'), '---\nname: review\ndescription: Reviews changes\n---\n# Review\n'), ]); @@ -136,19 +136,24 @@ it('packages both react-server render children and renders a route invocation fr await mkdir(join(project, 'src', 'mcp', 'status', 'tools'), { recursive: true }); await Promise.all([ writeFile(join(project, 'package.json'), '{"type":"module"}\n'), - writeFile(join(project, 'agent-bundle.config.ts'), "export default { plugin: { name: 'packed-route-invocation', version: '1.0.0' }, targets: ['claude'] };\n"), + writeFile(join(project, 'agent-bundle.config.ts'), "export default { plugin: { name: 'packed-route-invocation' }, targets: ['claude'] };\n"), writeFile(join(project, 'src', 'mcp', 'status', 'tools', 'report.tsx'), [ + "import { defineTool } from 'agent-bundle/routes';", "import { Agent } from '@agent-bundle/runtime';", "import { createElement } from 'react';", "import { z } from 'zod';", '', - "export const config = { annotations: { readOnlyHint: true }, description: 'Reports one service.' };", 'export const inputSchema = z.object({ service: z.string().min(1) }).strict();', 'export const resultSchema = z.object({ service: z.string() }).strict();', '', - 'export default async function Report({ input }) {', + 'export default defineTool({', + " annotations: { readOnlyHint: true },", + " description: 'Reports one service.',", + ' inputSchema,', + ' resultSchema,', + '}, async (input) => {', ' return createElement(Agent.Result, { value: { service: input.service } }, createElement(Agent.Text, null, `Service ${input.service}`));', - '}', + '});', '', ].join('\n')), ]); @@ -200,7 +205,7 @@ it('runs the Agent API from an omit-dev installed tarball with its runtime MCP d await mkdir(join(project, 'skills', 'review'), { recursive: true }); await Promise.all([ writeFile(join(project, 'package.json'), '{"type":"module"}\n'), - writeFile(join(project, 'agent-bundle.config.ts'), "export default { plugin: { name: 'packed-agent-api', version: '1.0.0' }, targets: ['portable'] };\n"), + writeFile(join(project, 'agent-bundle.config.ts'), "export default { plugin: { name: 'packed-agent-api' }, targets: ['portable'] };\n"), writeFile(join(project, 'skills', 'review', 'SKILL.md'), '---\nname: review\ndescription: Reviews changes\n---\n# Review\n'), ]); const port = await availablePort(); diff --git a/packages/agent-bundle/tests/dev-workbench.test.ts b/packages/agent-bundle/tests/dev-workbench.test.ts index 62e67c025..d1a568552 100644 --- a/packages/agent-bundle/tests/dev-workbench.test.ts +++ b/packages/agent-bundle/tests/dev-workbench.test.ts @@ -111,7 +111,7 @@ const writeMcpProject = async (root: string): Promise => { ), ]); await Promise.all([ - writeFile(join(root, 'package.json'), '{"type":"module"}\n'), + writeFile(join(root, 'package.json'), '{"type":"module","version":"1.0.0"}\n'), writeFile(join(root, 'src', 'server.ts'), [ "import { McpServer } from '@modelcontextprotocol/server';", "import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';", @@ -132,7 +132,7 @@ const writeMcpProject = async (root: string): Promise => { '', 'export default defineConfig({', " mcp: { servers: { fixture: { entry: './src/server.ts' } } },", - " plugin: { name: 'workbench-mcp-fixture', version: '1.0.0' },", + " plugin: { name: 'workbench-mcp-fixture' },", " skills: ['src/skills/review'],", " targets: ['portable'],", '});', @@ -144,7 +144,7 @@ const writeMcpProject = async (root: string): Promise => { const writeHookProject = async (root: string): Promise => { await mkdir(join(root, 'src', 'hooks'), { recursive: true }); await Promise.all([ - writeFile(join(root, 'package.json'), '{"type":"module"}\n'), + writeFile(join(root, 'package.json'), '{"type":"module","version":"1.0.0"}\n'), writeFile(join(root, 'src', 'hooks', 'session-start.ts'), [ 'export default (event: { source?: string }) => ({', " additionalContext: `workbench:${event.source}`,", @@ -157,7 +157,7 @@ const writeHookProject = async (root: string): Promise => { '', 'export default defineConfig({', " hooks: { sessionStart: './src/hooks/session-start.ts' },", - " plugin: { name: 'workbench-hook-fixture', version: '1.0.0' },", + " plugin: { name: 'workbench-hook-fixture' },", " skills: ['src/skills/review'],", " targets: ['claude'],", '});', @@ -238,7 +238,7 @@ const writeCompilingRuntimeAppProject = async (root: string, stateKey: string): '', 'export default defineConfig({', " dev: { runtime: { provider: './src/dev/provider.ts' } },", - " plugin: { name: 'compiling-runtime-apps', version: '1.0.0' },", + " plugin: { name: 'compiling-runtime-apps' },", " skills: ['src/skills/review'],", " targets: ['portable'],", '});', @@ -455,7 +455,7 @@ it('enables the optional agent API from dev.agentApi when no CLI override is sup '', 'export default defineConfig({', ' dev: { agentApi: true },', - " plugin: { name: 'agent-api-config-fixture', version: '1.0.0' },", + " plugin: { name: 'agent-api-config-fixture' },", " skills: ['src/skills/review'],", " targets: ['portable'],", '});', @@ -509,7 +509,7 @@ it('publishes routes created and deleted below source roots absent at startup', "import { defineConfig } from 'agent-bundle/config';", '', 'export default defineConfig({', - " plugin: { name: 'watcher-route-roots', version: '1.0.0' },", + " plugin: { name: 'watcher-route-roots' },", " targets: ['claude', 'codex'],", '});', '', @@ -588,7 +588,13 @@ it('publishes routes created and deleted below source roots absent at startup', await mkdir(join(project.root, 'src', 'mcp', 'probe', 'tools'), { recursive: true }); await writeFile( toolPath, - 'export const inputSchema = {}; export const resultSchema = {}; export default async () => undefined;\n', + [ + "import { defineTool } from 'agent-bundle/routes';", + 'export const inputSchema = {};', + 'export const resultSchema = {};', + 'export default defineTool({ inputSchema, resultSchema }, async () => undefined);', + '', + ].join('\n'), ); await publication(3); const withTool = await activeManifest(); @@ -643,7 +649,7 @@ it('latches a runtime declaration added to an ordinary Workbench session as rest '', 'export default defineConfig({', " dev: { runtime: { provider: './src/dev/provider.ts' } },", - " plugin: { name: 'review', version: '1.0.0' },", + " plugin: { name: 'review' },", " skills: ['src/skills/review'],", '});', '', @@ -686,7 +692,7 @@ it('keeps the ordinary foreground and artifact lane available when provider star '', 'export default defineConfig({', " dev: { runtime: { provider: './src/dev/provider.ts' } },", - " plugin: { name: 'review', version: '1.0.0' },", + " plugin: { name: 'review' },", " skills: ['src/skills/review'],", '});', '', @@ -736,7 +742,7 @@ it('retains Runtime App routes through invalid config updates and reconciles onl 'export default defineConfig({', ...(includeRuntime ? [" dev: { runtime: { provider: './src/dev/provider.ts' } },"] : []), ` fixtureMarker: ${JSON.stringify(marker)},`, - " plugin: { name: 'late-runtime-apps', version: '1.0.0' },", + " plugin: { name: 'late-runtime-apps' },", " skills: ['src/skills/review'],", ` targets: ${JSON.stringify(targets)},`, ...(extension === undefined ? [] : [` portable: ${extension},`]), @@ -933,7 +939,7 @@ it('fences a closing foreground before a held valid runtime reconcile can attach '', 'export default defineConfig({', " dev: { runtime: { provider: './src/dev/provider.ts' } },", - " plugin: { name: 'late-runtime-close', version: '1.0.0' },", + " plugin: { name: 'late-runtime-close' },", " skills: ['src/skills/review'],", ` targets: ${JSON.stringify(targets)},`, '});', @@ -1033,7 +1039,7 @@ it('does not reconcile a valid preparation released after foreground close begin const declaration = [ " dev: { runtime: { provider: './src/dev/provider.ts' } },", ` fixtureMarker: ${JSON.stringify(marker)},`, - " plugin: { name: 'prepared-runtime-close', version: '1.0.0' },", + " plugin: { name: 'prepared-runtime-close' },", " skills: ['src/skills/review'],", " targets: ['portable'],", ]; @@ -1186,7 +1192,7 @@ it('does not publish a prepared runtime topology after foreground close begins', ' await state.preparationReleased;', ' return defineConfig({', " dev: { runtime: { provider: './src/dev/provider.ts' } },", - " plugin: { name: 'prepared-topology-close', version: '1.0.0' },", + " plugin: { name: 'prepared-topology-close' },", " skills: ['src/skills/review'],", " targets: ['portable'],", ' });', @@ -1360,7 +1366,7 @@ it('prepares the optional runtime once with the development config context befor ` await appendFile(${JSON.stringify(configCallsPath)}, JSON.stringify({ command, mode }) + '\\n');`, ' return {', " dev: { runtime: { provider: './src/dev/provider.ts' } },", - " plugin: { name: 'runtime-fixture', version: '1.0.0' },", + " plugin: { name: 'runtime-fixture' },", " skills: ['src/skills/review'],", ' };', '});', @@ -1460,7 +1466,7 @@ it('builds and serves a target owned only by the workbench registry', async () = writeFile(join(assetsRoot, 'index.html'), 'Agent Bundle workbench'), writeFile(join(project.root, 'agent-bundle.config.ts'), [ 'export default {', - " plugin: { name: 'workbench-custom-registry', version: '1.0.0' },", + " plugin: { name: 'workbench-custom-registry' },", " targets: ['workbench-synthetic'],", ' workbenchSynthetic: { enabled: true },', '};', diff --git a/packages/agent-bundle/tests/event-handler-artifact-graph.test.ts b/packages/agent-bundle/tests/event-handler-artifact-graph.test.ts index ffe566ac3..ee54dab77 100644 --- a/packages/agent-bundle/tests/event-handler-artifact-graph.test.ts +++ b/packages/agent-bundle/tests/event-handler-artifact-graph.test.ts @@ -41,7 +41,7 @@ const projectFiles: Readonly> = { 'agent-bundle.config.ts': [ "import { defineConfig } from 'agent-bundle/config';", 'export default defineConfig({', - " plugin: { description: 'Handler graph fixture.', name: 'handler-graph-fixture', version: '1.0.0' },", + " plugin: { description: 'Handler graph fixture.', name: 'handler-graph-fixture' },", " targets: ['claude'],", '});', '', @@ -222,7 +222,7 @@ describe('handler artifact graph (#595)', () => { }); it('attaches the handler leaf to the event route node, and the leaf\'s source graph is cheap by construction', async () => { - const graph = await compileRouteGraph(root, { plugin: { name: 'handler-graph-fixture', version: '1.0.0' } }); + const graph = await compileRouteGraph(root, { plugin: { name: 'handler-graph-fixture' } }); expect(graph.diagnostics).toEqual([]); expect(graph.events.map((route) => route.id)).toEqual(['event:tool/before']); expect(graph.events[0]!.handler).toEqual({ diff --git a/packages/agent-bundle/tests/examples-contract.test.ts b/packages/agent-bundle/tests/examples-contract.test.ts index 273a26da7..23366c69b 100644 --- a/packages/agent-bundle/tests/examples-contract.test.ts +++ b/packages/agent-bundle/tests/examples-contract.test.ts @@ -300,8 +300,7 @@ it('derives the Audiobook Curator release identity from package.json as the one const inspection = await inspect({ root }); expect(inspection.state).toBe('ready'); if (inspection.state !== 'ready') throw new Error('unreachable'); - // package.json declares 1.0.0 once and the config declares no - // plugin.version, so the model version is inferred and cannot mismatch. + // package.json declares 1.0.0 once, so the model version cannot mismatch. expect(inspection.projectContext.packageName).toBe('@agent-bundle-example/audiobook-curator'); expect(inspection.projectContext.packageVersion).toBe('1.0.0'); expect(inspection.model.metadata).toMatchObject({ @@ -311,7 +310,6 @@ it('derives the Audiobook Curator release identity from package.json as the one version: '1.0.0', }); expect(projectVersionLabel(inspection.projectContext)).toBe('1.0.0'); - expect(inspection.diagnostics.filter((diagnostic) => diagnostic.code === 'AB4008')).toEqual([]); }); diff --git a/packages/agent-bundle/tests/fixtures/host-install-portable/agent-bundle.config.ts b/packages/agent-bundle/tests/fixtures/host-install-portable/agent-bundle.config.ts index 78339bb02..0937ced0d 100644 --- a/packages/agent-bundle/tests/fixtures/host-install-portable/agent-bundle.config.ts +++ b/packages/agent-bundle/tests/fixtures/host-install-portable/agent-bundle.config.ts @@ -10,7 +10,6 @@ export default { plugin: { description: 'Proves portable installer and filesystem/schema conformance.', name: 'host-install-portable-proof', - version: '1.0.0', }, // The §5.4 descriptive metadata is declared once in package.json and shared // with every host; only the §5.6 client extension is portable-specific. diff --git a/packages/agent-bundle/tests/fixtures/host-install-tokens/agent-bundle.config.ts b/packages/agent-bundle/tests/fixtures/host-install-tokens/agent-bundle.config.ts index 48dae91f3..a756d4c31 100644 --- a/packages/agent-bundle/tests/fixtures/host-install-tokens/agent-bundle.config.ts +++ b/packages/agent-bundle/tests/fixtures/host-install-tokens/agent-bundle.config.ts @@ -5,7 +5,6 @@ export default { plugin: { description: 'Proves canonical Skill tokens resolving in a real Claude session.', name: 'host-install-token-proof', - version: '1.0.0', }, skills: ['src/skills/token-probe'], targets: ['claude'], diff --git a/packages/agent-bundle/tests/fixtures/host-install/agent-bundle.config.ts b/packages/agent-bundle/tests/fixtures/host-install/agent-bundle.config.ts index 7a5c38b87..eeccbeff4 100644 --- a/packages/agent-bundle/tests/fixtures/host-install/agent-bundle.config.ts +++ b/packages/agent-bundle/tests/fixtures/host-install/agent-bundle.config.ts @@ -12,7 +12,6 @@ export default { description: 'Proves real host installation of Skills, Hooks, and MCP metadata.', logo: './docs/media/logo.svg', name: 'host-install-proof', - version: '1.0.0', }, routes: { mcpCommands: true, diff --git a/packages/agent-bundle/tests/fixtures/packed-native-smoke/agent-bundle.config.ts b/packages/agent-bundle/tests/fixtures/packed-native-smoke/agent-bundle.config.ts index b61405b13..2425d0f6f 100644 --- a/packages/agent-bundle/tests/fixtures/packed-native-smoke/agent-bundle.config.ts +++ b/packages/agent-bundle/tests/fixtures/packed-native-smoke/agent-bundle.config.ts @@ -3,7 +3,6 @@ export default { plugin: { description: 'Exercises native Eval hosts from an installed Agent Bundle tarball.', name: 'packed-native-smoke', - version: '1.0.0', }, skills: ['src/skills/review'], targets: ['claude', 'codex'], diff --git a/packages/agent-bundle/tests/fixtures/packed-native-smoke/package.json b/packages/agent-bundle/tests/fixtures/packed-native-smoke/package.json index c49133364..05e9cf090 100644 --- a/packages/agent-bundle/tests/fixtures/packed-native-smoke/package.json +++ b/packages/agent-bundle/tests/fixtures/packed-native-smoke/package.json @@ -1,5 +1,6 @@ { "name": "agent-bundle-packed-native-smoke", "private": true, - "type": "module" + "type": "module", + "version": "1.0.0" } diff --git a/packages/agent-bundle/tests/function-authoring-build.test.ts b/packages/agent-bundle/tests/function-authoring-build.test.ts index b79050ecc..b88f87cd0 100644 --- a/packages/agent-bundle/tests/function-authoring-build.test.ts +++ b/packages/agent-bundle/tests/function-authoring-build.test.ts @@ -13,7 +13,7 @@ it('runs compiled function events, explicit JSX views, and CLI metadata and JSON await symlink(join(process.cwd(), 'examples/audiobook-curator/node_modules'), join(root, 'node_modules'), 'dir'); const files = { 'package.json': JSON.stringify({ name: 'function-authoring', type: 'module', version: '1.0.0', dependencies: { '@agent-bundle/runtime': 'workspace:*', react: '19.2.8', zod: '4.5.4' } }), - 'agent-bundle.config.ts': `import { defineConfig } from 'agent-bundle/config'; export default defineConfig({ plugin: { name: 'function-authoring', version: '1.0.0' }, targets: ['claude'] });`, + 'agent-bundle.config.ts': `import { defineConfig } from 'agent-bundle/config'; export default defineConfig({ plugin: { name: 'function-authoring' }, targets: ['claude'] });`, 'src/providers/unused.ts': `throw new Error('unused provider evaluated'); export default () => 'unused';`, 'src/providers/policy.ts': `export default () => 'Writes disabled';`, 'src/events/tool/before.ts': `import { events } from 'agent-bundle/routes'; export default events.tool.before({}, async ({canonical, provider}) => { if (canonical.payload.toolName?.value === 'Write') return { outcome: 'deny', reason: await provider('policy') }; });`, @@ -95,7 +95,7 @@ it('shares one deadline across a lightweight handler and its deferred view', asy await symlink(join(process.cwd(), 'examples/audiobook-curator/node_modules'), join(root, 'node_modules'), 'dir'); const files = { 'package.json': JSON.stringify({ name: 'event-deadline', type: 'module', version: '1.0.0', dependencies: { '@agent-bundle/runtime': 'workspace:*', react: '19.2.8' } }), - 'agent-bundle.config.ts': `import { defineConfig } from 'agent-bundle/config'; export default defineConfig({ plugin: { name: 'event-deadline', version: '1.0.0' }, targets: ['claude'] });`, + 'agent-bundle.config.ts': `import { defineConfig } from 'agent-bundle/config'; export default defineConfig({ plugin: { name: 'event-deadline' }, targets: ['claude'] });`, 'src/events/tool/after.ts': `export const config = { timeoutMs: 1000 }; export default async ({ render }) => { await new Promise((resolve) => setTimeout(resolve, 350)); @@ -157,7 +157,7 @@ it('mounts identities, state, notices, and provider observations in a compiled l await symlink(join(process.cwd(), 'examples/audiobook-curator/node_modules'), join(root, 'node_modules'), 'dir'); const files = { 'package.json': JSON.stringify({ name: 'event-context', type: 'module', version: '1.0.0', dependencies: { '@agent-bundle/runtime': 'workspace:*', zod: '4.5.4' } }), - 'agent-bundle.config.ts': `import { defineConfig } from 'agent-bundle/config'; export default defineConfig({ plugin: { name: 'event-context', version: '1.0.0' }, targets: ['claude'] });`, + 'agent-bundle.config.ts': `import { defineConfig } from 'agent-bundle/config'; export default defineConfig({ plugin: { name: 'event-context' }, targets: ['claude'] });`, 'src/state.ts': `import { defineState } from '@agent-bundle/runtime/state'; import { z } from 'zod'; export default defineState({ id: 'event-context/state', lifetime: 'workspace-durable', initial: { count: 7 }, schema: z.object({ count: z.number() }), events: { tick: z.object({}) }, reduce: (state) => state });`, 'src/providers/context.ts': `export default async (ctx) => JSON.stringify({ session: ctx.session, workspace: ctx.workspace, lineage: ctx.lineage, plugin: ctx.plugin, snapshot: await ctx.state.read(), notices: ctx.notices !== undefined });`, diff --git a/packages/agent-bundle/tests/function-authoring.test.ts b/packages/agent-bundle/tests/function-authoring.test.ts index 8f428379f..bafd19039 100644 --- a/packages/agent-bundle/tests/function-authoring.test.ts +++ b/packages/agent-bundle/tests/function-authoring.test.ts @@ -22,7 +22,7 @@ export default tool({ description: 'Status', inputSchema: z.object({ verbose: z. it('extracts a bounded definition without inspecting or evaluating its handler', () => { expect(extractRouteConfig(source, path, path)).toMatchObject({ config: { description: 'Status' }, diagnostics: [] }); - expect(validateRouteModuleContract(source, path, path)).toEqual([]); + expect(validateRouteModuleContract(source, path, path, 'tool')).toEqual([]); }); it('extracts shorthand schemas in their original module scope', () => { @@ -31,7 +31,7 @@ import { z } from 'zod'; const inputSchema = z.object({ verbose: z.boolean().default(false) }); export default defineTool({ inputSchema, resultSchema: z.string() }, async () => 'ok');`; expect(readRouteDefinition(parseModule(path, shorthand))?.inputSchema).toBeDefined(); - expect(validateRouteModuleContract(shorthand, path, path)).toEqual([]); + expect(validateRouteModuleContract(shorthand, path, path, 'tool')).toEqual([]); }); it('normalizes the same inferred handler for generated and source execution', async () => { diff --git a/packages/agent-bundle/tests/generated-route-server.test.ts b/packages/agent-bundle/tests/generated-route-server.test.ts index d89704ff4..f8b9b252c 100644 --- a/packages/agent-bundle/tests/generated-route-server.test.ts +++ b/packages/agent-bundle/tests/generated-route-server.test.ts @@ -75,7 +75,7 @@ it('lists and calls a generated filesystem tool through final-only Flight', { re })), writeProjectFile(root, 'agent-bundle.config.ts', [ "import { defineConfig } from 'agent-bundle/config';", - "export default defineConfig({ plugin: { name: 'generated-routes-fixture', version: '1.0.0' }, targets: ['portable'] });", + "export default defineConfig({ plugin: { name: 'generated-routes-fixture' }, targets: ['portable'] });", '', ].join('\n')), writeProjectFile(root, 'src/mcp/curator/resources/catalog.tsx', [ @@ -113,22 +113,25 @@ it('lists and calls a generated filesystem tool through final-only Flight', { re // contract reads: the build has to select the automatic JSX runtime, or // the emitted module calls a `React` factory that is not in scope. writeProjectFile(root, 'src/mcp/curator/tools/inspect.tsx', [ - "import { Agent, agent } from '@agent-bundle/runtime';", - "import { z } from 'zod';", - "export const config = { annotations: { readOnlyHint: true }, description: 'Inspect one source.' };", - "export const inputSchema = z.object({ source: z.string() }).strict();", - "export const resultSchema = z.object({ actor: z.unknown(), host: z.unknown(), invocationKind: z.literal('tool'), lineage: z.unknown(), session: z.unknown(), source: z.string(), workspace: z.unknown() }).strict();", - 'export default async function Inspect({ input, signal }) {', - " if (signal.aborted) throw new DOMException('aborted', 'AbortError');", - ' const context = await agent();', - ' const result = { actor: context.actor, host: context.host, invocationKind: context.invocation.kind, lineage: context.lineage, session: context.session, source: input.source, workspace: context.workspace };', - ' return (', - ' ', - ' {`Inspected **${input.source}**.`}', - ' ', - ' );', - '}', - '', + "import { defineTool } from 'agent-bundle/routes';", + "import { Agent, agent } from '@agent-bundle/runtime';", + "import { z } from 'zod';", + "export const inputSchema = z.object({ source: z.string() }).strict();", + "export const resultSchema = z.object({ actor: z.unknown(), host: z.unknown(), invocationKind: z.literal('tool'), lineage: z.unknown(), session: z.unknown(), source: z.string(), workspace: z.unknown() }).strict();", + "export default defineTool({", + "annotations: { readOnlyHint: true }, description: 'Inspect one source.',", + " inputSchema,", + " resultSchema,", + "}, async (input, { signal }) => {", + " if (signal.aborted) throw new DOMException('aborted', 'AbortError');", + " const context = await agent();", + " const result = { actor: context.actor, host: context.host, invocationKind: context.invocation.kind, lineage: context.lineage, session: context.session, source: input.source, workspace: context.workspace };", + " return (", + " ", + " {`Inspected **${input.source}**.`}", + " ", + " );", + "});", ].join('\n')), ]); @@ -233,7 +236,7 @@ const writeGeneratedProject = async ( })), writeProjectFile(root, 'agent-bundle.config.ts', [ "import { defineConfig } from 'agent-bundle/config';", - `export default defineConfig({ plugin: { name: 'generated-routes-fixture', version: '1.0.0' }, targets: ['${target}'] });`, + `export default defineConfig({ plugin: { name: 'generated-routes-fixture' }, targets: ['${target}'] });`, '', ].join('\n')), ...Object.entries(files).map(([path, contents]) => writeProjectFile(root, path, contents)), @@ -359,15 +362,19 @@ const generatedAppClientHtml = '
waiti
 
 const generatedPingTool = [
   "import { Agent } from '@agent-bundle/runtime';",
-  "import { appResourceUri } from 'agent-bundle/routes';",
+  "import { appResourceUri, defineTool } from 'agent-bundle/routes';",
   "import { createElement } from 'react';",
   "import { z } from 'zod';",
-  "export const config = { _meta: { ui: { resourceUri: appResourceUri('dashboard') } }, description: 'Ping from the App.' };",
   'export const inputSchema = z.object({ note: z.string() }).strict();',
   'export const resultSchema = z.object({ note: z.string() }).strict();',
-  'export default async function Ping({ input }: { input: z.infer }) {',
+  'export default defineTool({',
+  "  _meta: { ui: { resourceUri: appResourceUri('dashboard') } },",
+  "  description: 'Ping from the App.',",
+  '  inputSchema,',
+  '  resultSchema,',
+  '}, async (input) => {',
   "  return createElement(Agent.Result, { value: { note: input.note } }, createElement(Agent.Text, null, input.note));",
-  '}',
+  '});',
   '',
 ].join('\n');
 
@@ -397,7 +404,7 @@ it('augments a generated server from config and projects result _meta and text-o
       "    args: ['--strict'],",
       "    env: { CURATOR_MODE: 'strict' },",
       '  } } },',
-      "  plugin: { name: 'generated-augment-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'generated-augment-fixture' },",
       "  targets: ['portable'],",
       '});',
       '',
@@ -405,30 +412,36 @@ it('augments a generated server from config and projects result _meta and text-o
     writeProjectFile(root, 'views/panel.ts', "document.body.textContent = 'Curator panel';\n"),
     // #383: `Agent.Result metadata` is the result-level `_meta`.
     writeProjectFile(root, 'src/mcp/curator/tools/annotated.tsx', [
-      "import { Agent } from '@agent-bundle/runtime';",
-      "import { createElement } from 'react';",
-      "import { z } from 'zod';",
-      "export const config = { _meta: { ui: { resourceUri: 'ui://generated-augment-fixture/panel.html' } }, description: 'Annotated result.' };",
-      'export const inputSchema = z.object({}).strict();',
-      "export const resultSchema = z.object({ status: z.literal('ready') }).strict();",
-      'export default async function Annotated() {',
-      "  return createElement(Agent.Result, { metadata: { ui: { resourceUri: 'ui://generated-augment-fixture/panel.html' } }, value: { status: 'ready' } }, createElement(Agent.Text, null, 'ready'));",
-      '}',
-      '',
+    "import { defineTool } from 'agent-bundle/routes';",
+    "import { Agent } from '@agent-bundle/runtime';",
+    "import { createElement } from 'react';",
+    "import { z } from 'zod';",
+    "export const inputSchema = z.object({}).strict();",
+    "export const resultSchema = z.object({ status: z.literal('ready') }).strict();",
+    "export default defineTool({",
+    "_meta: { ui: { resourceUri: 'ui://generated-augment-fixture/panel.html' } }, description: 'Annotated result.',",
+    "  inputSchema,",
+    "  resultSchema,",
+    "}, async () => {",
+    "  return createElement(Agent.Result, { metadata: { ui: { resourceUri: 'ui://generated-augment-fixture/panel.html' } }, value: { status: 'ready' } }, createElement(Agent.Text, null, 'ready'));",
+    "});",
     ].join('\n')),
     // A text-only tool declares no object result, so it advertises no
     // outputSchema and returns no structuredContent.
     writeProjectFile(root, 'src/mcp/curator/tools/plain.tsx', [
-      "import { Agent } from '@agent-bundle/runtime';",
-      "import { createElement } from 'react';",
-      "import { z } from 'zod';",
-      "export const config = { description: 'Text only.' };",
-      'export const inputSchema = z.object({}).strict();',
-      'export const resultSchema = z.undefined();',
-      'export default async function Plain() {',
-      "  return createElement(Agent.Result, null, createElement(Agent.Text, null, 'plain text'));",
-      '}',
-      '',
+    "import { defineTool } from 'agent-bundle/routes';",
+    "import { Agent } from '@agent-bundle/runtime';",
+    "import { createElement } from 'react';",
+    "import { z } from 'zod';",
+    "export const inputSchema = z.object({}).strict();",
+    "export const resultSchema = z.undefined();",
+    "export default defineTool({",
+    "description: 'Text only.',",
+    "  inputSchema,",
+    "  resultSchema,",
+    "}, async () => {",
+    "  return createElement(Agent.Result, null, createElement(Agent.Text, null, 'plain text'));",
+    "});",
     ].join('\n')),
   ]);
 
@@ -505,17 +518,20 @@ it('compiles appResourceUri() and local literal references to the App route reso
     // The tool references the App through the compile-time helper (run-time
     // import from the light routes subpath, never the compiler root).
     'src/mcp/curator/tools/open.tsx': [
-      "import { Agent } from '@agent-bundle/runtime';",
-      "import { appResourceUri } from 'agent-bundle/routes';",
-      "import { createElement } from 'react';",
-      "import { z } from 'zod';",
-      "export const config = { _meta: { ui: { resourceUri: appResourceUri('dashboard') } }, description: 'Open the dashboard.' };",
-      'export const inputSchema = z.object({}).strict();',
-      "export const resultSchema = z.object({ status: z.literal('ready') }).strict();",
-      'export default async function Open() {',
-      "  return createElement(Agent.Result, { value: { status: 'ready' } }, createElement(Agent.Text, null, 'ready'));",
-      '}',
-      '',
+    "import { defineTool } from 'agent-bundle/routes';",
+    "import { Agent } from '@agent-bundle/runtime';",
+    "import { appResourceUri } from 'agent-bundle/routes';",
+    "import { createElement } from 'react';",
+    "import { z } from 'zod';",
+    "export const inputSchema = z.object({}).strict();",
+    "export const resultSchema = z.object({ status: z.literal('ready') }).strict();",
+    "export default defineTool({",
+    "_meta: { ui: { resourceUri: appResourceUri('dashboard') } }, description: 'Open the dashboard.',",
+    "  inputSchema,",
+    "  resultSchema,",
+    "}, async () => {",
+    "  return createElement(Agent.Result, { value: { status: 'ready' } }, createElement(Agent.Text, null, 'ready'));",
+    "});",
     ].join('\n'),
     // The resource references the same constant module the App reads.
     'src/mcp/curator/resources/catalog.tsx': [
@@ -682,22 +698,25 @@ it('observes one process-lifetime provider across consecutive generated tool cal
       '',
     ].join('\n'),
     'src/mcp/curator/tools/warmth.tsx': [
-      "import { Agent, agent } from '@agent-bundle/runtime';",
-      "import { createElement } from 'react';",
-      "import { z } from 'zod';",
-      "export const config = { annotations: { readOnlyHint: true }, description: 'Observe process lifetime.' };",
-      'export const inputSchema = z.object({}).strict();',
-      'export const resultSchema = z.object({ hits: z.number(), instanceId: z.string(), pid: z.number() }).strict();',
-      'export default async function Warmth() {',
-      '  const context = await agent();',
-      '  const processLifetime = context.process;',
-      "  if (processLifetime === undefined || typeof processLifetime !== 'object' || processLifetime === null) {",
-      "    throw new Error('process-lifetime provider was not installed');",
-      '  }',
-      '  const value = processLifetime as { hits: number; instanceId: string; pid: number };',
-      "  return createElement(Agent.Result, { value }, createElement(Agent.Text, null, `hit ${String(value.hits)}`));",
-      '}',
-      '',
+    "import { defineTool } from 'agent-bundle/routes';",
+    "import { Agent, agent } from '@agent-bundle/runtime';",
+    "import { createElement } from 'react';",
+    "import { z } from 'zod';",
+    "export const inputSchema = z.object({}).strict();",
+    "export const resultSchema = z.object({ hits: z.number(), instanceId: z.string(), pid: z.number() }).strict();",
+    "export default defineTool({",
+    "annotations: { readOnlyHint: true }, description: 'Observe process lifetime.',",
+    "  inputSchema,",
+    "  resultSchema,",
+    "}, async () => {",
+    "  const context = await agent();",
+    "  const processLifetime = context.process;",
+    "  if (processLifetime === undefined || typeof processLifetime !== 'object' || processLifetime === null) {",
+    "    throw new Error('process-lifetime provider was not installed');",
+    "  }",
+    "  const value = processLifetime as { hits: number; instanceId: string; pid: number };",
+    "  return createElement(Agent.Result, { value }, createElement(Agent.Text, null, `hit ${String(value.hits)}`));",
+    "});",
     ].join('\n'),
   });
   const session = await connectGeneratedServer(root);
@@ -749,24 +768,27 @@ it('emits notifications/resources/updated for the durable notice inbox to the su
       '',
     ].join('\n'),
     'src/mcp/curator/tools/notify.tsx': [
-      "import { Agent, agent } from '@agent-bundle/runtime';",
-      "import { createElement } from 'react';",
-      "import { z } from 'zod';",
-      "export const config = { description: 'Publish a notice to a host-scoped recipient.' };",
-      'export const inputSchema = z.object({ host: z.string(), message: z.string() }).strict();',
-      "export const resultSchema = z.object({ noticeId: z.string(), state: z.literal('pending') }).strict();",
-      'export default async function Notify({ input }) {',
-      '  const context = await agent();',
-      "  if (context.notices === undefined) throw new TypeError('notices unavailable');",
-      '  const published = await context.notices.publish({',
-      "    content: { root: { kind: 'text', text: input.message }, status: 'success', version: 1 },",
-      "    priority: 'normal',",
-      '    recipient: { host: { name: input.host } },',
-      '  }, { idempotencyKey: `notice:${input.host}:${input.message}` });',
-      '  const value = { noticeId: published.notice.id, state: published.notice.state };',
-      "  return createElement(Agent.Result, { value }, createElement(Agent.Text, null, `published ${value.noticeId}`));",
-      '}',
-      '',
+    "import { defineTool } from 'agent-bundle/routes';",
+    "import { Agent, agent } from '@agent-bundle/runtime';",
+    "import { createElement } from 'react';",
+    "import { z } from 'zod';",
+    "export const inputSchema = z.object({ host: z.string(), message: z.string() }).strict();",
+    "export const resultSchema = z.object({ noticeId: z.string(), state: z.literal('pending') }).strict();",
+    "export default defineTool({",
+    "description: 'Publish a notice to a host-scoped recipient.',",
+    "  inputSchema,",
+    "  resultSchema,",
+    "}, async (input) => {",
+    "  const context = await agent();",
+    "  if (context.notices === undefined) throw new TypeError('notices unavailable');",
+    "  const published = await context.notices.publish({",
+    "    content: { root: { kind: 'text', text: input.message }, status: 'success', version: 1 },",
+    "    priority: 'normal',",
+    "    recipient: { host: { name: input.host } },",
+    "  }, { idempotencyKey: `notice:${input.host}:${input.message}` });",
+    "  const value = { noticeId: published.notice.id, state: published.notice.state };",
+    "  return createElement(Agent.Result, { value }, createElement(Agent.Text, null, `published ${value.noticeId}`));",
+    "});",
     ].join('\n'),
   });
   const inboxUri = 'agent-bundle://notices/inbox';
@@ -835,19 +857,22 @@ it('emits MCP progress notifications only when a progress token is supplied', {
   roots.push(root);
   await writeGeneratedProject(root, {
     'src/mcp/curator/tools/progress.tsx': [
-      "import { Agent, agent } from '@agent-bundle/runtime';",
-      "import { createElement } from 'react';",
-      "import { z } from 'zod';",
-      "export const config = { annotations: { readOnlyHint: true }, description: 'Report progress.' };",
-      'export const inputSchema = z.object({}).strict();',
-      'export const resultSchema = z.object({ ok: z.literal(true) }).strict();',
-      'export default async function Progress() {',
-      '  const context = await agent();',
-      "  await context.progress.report({ completed: 1, message: 'halfway', total: 2 });",
-      "  await context.progress.report({ completed: 2, message: 'done', total: 2 });",
-      "  return createElement(Agent.Result, { value: { ok: true } }, createElement(Agent.Text, null, 'finished'));",
-      '}',
-      '',
+    "import { defineTool } from 'agent-bundle/routes';",
+    "import { Agent, agent } from '@agent-bundle/runtime';",
+    "import { createElement } from 'react';",
+    "import { z } from 'zod';",
+    "export const inputSchema = z.object({}).strict();",
+    "export const resultSchema = z.object({ ok: z.literal(true) }).strict();",
+    "export default defineTool({",
+    "annotations: { readOnlyHint: true }, description: 'Report progress.',",
+    "  inputSchema,",
+    "  resultSchema,",
+    "}, async () => {",
+    "  const context = await agent();",
+    "  await context.progress.report({ completed: 1, message: 'halfway', total: 2 });",
+    "  await context.progress.report({ completed: 2, message: 'done', total: 2 });",
+    "  return createElement(Agent.Result, { value: { ok: true } }, createElement(Agent.Text, null, 'finished'));",
+    "});",
     ].join('\n'),
   });
   const session = await connectGeneratedServer(root);
@@ -882,24 +907,27 @@ it('streams an authored Suspense fallback to the stdio client before the render
   roots.push(root);
   await writeGeneratedProject(root, {
     'src/mcp/curator/tools/gated.tsx': [
-      "import { existsSync } from 'node:fs';",
-      "import { Agent } from '@agent-bundle/runtime';",
-      "import { createElement, Suspense } from 'react';",
-      "import { z } from 'zod';",
-      "export const config = { annotations: { readOnlyHint: true }, description: 'Wait for a gate.' };",
-      'export const inputSchema = z.object({ gate: z.string() }).strict();',
-      'export const resultSchema = z.object({ ok: z.literal(true) }).strict();',
-      'const Gated = async ({ gate, signal }) => {',
-      '  while (!existsSync(gate)) {',
-      "    if (signal.aborted) throw new DOMException('gate abandoned', 'AbortError');",
-      '    await new Promise((resolve) => setTimeout(resolve, 25));',
-      '  }',
-      "  return createElement(Agent.Text, null, 'released');",
-      '};',
-      'export default async function GatedTool({ input, signal }) {',
-      "  return createElement(Agent.Result, { value: { ok: true } }, createElement(Suspense, { fallback: createElement(Agent.Progress, { completed: 0, message: 'waiting', total: 1 }) }, createElement(Gated, { gate: input.gate, signal })));",
-      '}',
-      '',
+    "import { defineTool } from 'agent-bundle/routes';",
+    "import { existsSync } from 'node:fs';",
+    "import { Agent } from '@agent-bundle/runtime';",
+    "import { createElement, Suspense } from 'react';",
+    "import { z } from 'zod';",
+    "export const inputSchema = z.object({ gate: z.string() }).strict();",
+    "export const resultSchema = z.object({ ok: z.literal(true) }).strict();",
+    "const Gated = async ({ gate, signal }) => {",
+    "  while (!existsSync(gate)) {",
+    "    if (signal.aborted) throw new DOMException('gate abandoned', 'AbortError');",
+    "    await new Promise((resolve) => setTimeout(resolve, 25));",
+    "  }",
+    "  return createElement(Agent.Text, null, 'released');",
+    "};",
+    "export default defineTool({",
+    "annotations: { readOnlyHint: true }, description: 'Wait for a gate.',",
+    "  inputSchema,",
+    "  resultSchema,",
+    "}, async (input, { signal }) => {",
+    "  return createElement(Agent.Result, { value: { ok: true } }, createElement(Suspense, { fallback: createElement(Agent.Progress, { completed: 0, message: 'waiting', total: 1 }) }, createElement(Gated, { gate: input.gate, signal })));",
+    "});",
     ].join('\n'),
   });
   const session = await connectGeneratedServer(root);
@@ -938,14 +966,25 @@ it('streams an authored Suspense fallback to the stdio client before the render
 it('projects thrown route errors as the SDK tool error or a JSON-RPC error, never as a layout-wrapped document', { retry: 2, timeout: 60_000 }, async () => {
   const root = await mkdtemp(join(tmpdir(), 'agent-bundle-generated-thrown-'));
   roots.push(root);
-  const throwing = (kind: string) => [
-    "import { z } from 'zod';",
-    `export const config = ${kind === 'resource' ? "{ mimeType: 'text/plain', uri: 'curator://broken' }" : "{ description: 'Throws.' }"};`,
-    `export const inputSchema = ${kind === 'resource' ? 'z.object({ uri: z.string() })' : 'z.object({}).strict()'};`,
-    'export const resultSchema = z.object({}).passthrough();',
-    `export default async function Broken() { throw new Error('${kind} route threw'); }`,
-    '',
-  ].join('\n');
+  const throwing = (kind: string) => kind === 'tool'
+    ? [
+        "import { defineTool } from 'agent-bundle/routes';",
+        "import { z } from 'zod';",
+        'export const inputSchema = z.object({}).strict();',
+        'export const resultSchema = z.object({}).passthrough();',
+        "export default defineTool({ description: 'Throws.', inputSchema, resultSchema }, async () => {",
+        "  throw new Error('tool route threw');",
+        '});',
+        '',
+      ].join('\n')
+    : [
+        "import { z } from 'zod';",
+        `export const config = ${kind === 'resource' ? "{ mimeType: 'text/plain', uri: 'curator://broken' }" : "{ description: 'Throws.' }"};`,
+        `export const inputSchema = ${kind === 'resource' ? 'z.object({ uri: z.string() })' : 'z.object({}).strict()'};`,
+        'export const resultSchema = z.object({}).passthrough();',
+        `export default async function Broken() { throw new Error('${kind} route threw'); }`,
+        '',
+      ].join('\n');
   await writeGeneratedProject(root, {
     // A layout that would stamp `_meta` on every document, to show it is absent when the route throws.
     'src/layout.tsx': [
@@ -958,14 +997,17 @@ it('projects thrown route errors as the SDK tool error or a JSON-RPC error, neve
     'src/mcp/curator/resources/broken.ts': throwing('resource'),
     'src/mcp/curator/tools/broken.ts': throwing('tool'),
     'src/mcp/curator/tools/fine.tsx': [
-      "import { Agent } from '@agent-bundle/runtime';",
-      "import { createElement } from 'react';",
-      "import { z } from 'zod';",
-      "export const config = { description: 'Renders.' };",
-      'export const inputSchema = z.object({}).strict();',
-      'export const resultSchema = z.object({ ok: z.literal(true) }).strict();',
-      "export default async function Fine() { return createElement(Agent.Result, { value: { ok: true } }, createElement(Agent.Text, null, 'fine')); }",
-      '',
+    "import { defineTool } from 'agent-bundle/routes';",
+    "import { Agent } from '@agent-bundle/runtime';",
+    "import { createElement } from 'react';",
+    "import { z } from 'zod';",
+    "export const inputSchema = z.object({}).strict();",
+    "export const resultSchema = z.object({ ok: z.literal(true) }).strict();",
+    "export default defineTool({",
+    "description: 'Renders.',",
+    "  inputSchema,",
+    "  resultSchema,",
+    "}, async () => { return createElement(Agent.Result, { value: { ok: true } }, createElement(Agent.Text, null, 'fine')); });",
     ].join('\n'),
   });
   const session = await connectGeneratedServer(root);
@@ -1006,21 +1048,24 @@ it('maps notifications/cancelled into the renderer AbortSignal', { retry: 2, tim
   roots.push(root);
   await writeGeneratedProject(root, {
     'src/mcp/curator/tools/hang.tsx': [
-      "import { Agent } from '@agent-bundle/runtime';",
-      "import { createElement } from 'react';",
-      "import { z } from 'zod';",
-      "export const config = { annotations: { readOnlyHint: true }, description: 'Hang until cancelled.' };",
-      'export const inputSchema = z.object({}).strict();',
-      'export const resultSchema = z.object({ ok: z.literal(true) }).strict();',
-      'export default async function Hang({ signal }) {',
-      '  await new Promise((_, reject) => {',
-      "    const fail = () => reject(new DOMException('aborted', 'AbortError'));",
-      '    if (signal.aborted) { fail(); return; }',
-      "    signal.addEventListener('abort', fail, { once: true });",
-      '  });',
-      "  return createElement(Agent.Result, { value: { ok: true } }, createElement(Agent.Text, null, 'should not complete'));",
-      '}',
-      '',
+    "import { defineTool } from 'agent-bundle/routes';",
+    "import { Agent } from '@agent-bundle/runtime';",
+    "import { createElement } from 'react';",
+    "import { z } from 'zod';",
+    "export const inputSchema = z.object({}).strict();",
+    "export const resultSchema = z.object({ ok: z.literal(true) }).strict();",
+    "export default defineTool({",
+    "annotations: { readOnlyHint: true }, description: 'Hang until cancelled.',",
+    "  inputSchema,",
+    "  resultSchema,",
+    "}, async (_input, { signal }) => {",
+    "  await new Promise((_, reject) => {",
+    "    const fail = () => reject(new DOMException('aborted', 'AbortError'));",
+    "    if (signal.aborted) { fail(); return; }",
+    "    signal.addEventListener('abort', fail, { once: true });",
+    "  });",
+    "  return createElement(Agent.Result, { value: { ok: true } }, createElement(Agent.Text, null, 'should not complete'));",
+    "});",
     ].join('\n'),
   });
   const session = await connectGeneratedServer(root);
@@ -1040,31 +1085,37 @@ it('fails closed when the generated runtime worker restarts', { retry: 2, timeou
   roots.push(root);
   await writeGeneratedProject(root, {
     'src/mcp/curator/tools/warmth.tsx': [
-      "import { Agent, agent } from '@agent-bundle/runtime';",
-      "import { createElement } from 'react';",
-      "import { z } from 'zod';",
-      "export const config = { annotations: { readOnlyHint: true }, description: 'Observe process lifetime.' };",
-      'export const inputSchema = z.object({}).strict();',
-      'export const resultSchema = z.object({ hits: z.number(), instanceId: z.string(), pid: z.number() }).strict();',
-      'export default async function Warmth() {',
-      '  const context = await agent();',
-      '  const value = context.process as { hits: number; instanceId: string; pid: number };',
-      "  return createElement(Agent.Result, { value }, createElement(Agent.Text, null, `hit ${String(value.hits)}`));",
-      '}',
-      '',
+    "import { defineTool } from 'agent-bundle/routes';",
+    "import { Agent, agent } from '@agent-bundle/runtime';",
+    "import { createElement } from 'react';",
+    "import { z } from 'zod';",
+    "export const inputSchema = z.object({}).strict();",
+    "export const resultSchema = z.object({ hits: z.number(), instanceId: z.string(), pid: z.number() }).strict();",
+    "export default defineTool({",
+    "annotations: { readOnlyHint: true }, description: 'Observe process lifetime.',",
+    "  inputSchema,",
+    "  resultSchema,",
+    "}, async () => {",
+    "  const context = await agent();",
+    "  const value = context.process as { hits: number; instanceId: string; pid: number };",
+    "  return createElement(Agent.Result, { value }, createElement(Agent.Text, null, `hit ${String(value.hits)}`));",
+    "});",
     ].join('\n'),
     'src/mcp/curator/tools/halt.tsx': [
-      "import { Agent } from '@agent-bundle/runtime';",
-      "import { createElement } from 'react';",
-      "import { z } from 'zod';",
-      "export const config = { description: 'Halt the Flight worker.' };",
-      'export const inputSchema = z.object({}).strict();',
-      'export const resultSchema = z.object({ ok: z.literal(true) }).strict();',
-      'export default async function Halt() {',
-      '  process.exit(1);',
-      "  return createElement(Agent.Result, { value: { ok: true } }, createElement(Agent.Text, null, 'halted'));",
-      '}',
-      '',
+    "import { defineTool } from 'agent-bundle/routes';",
+    "import { Agent } from '@agent-bundle/runtime';",
+    "import { createElement } from 'react';",
+    "import { z } from 'zod';",
+    "export const inputSchema = z.object({}).strict();",
+    "export const resultSchema = z.object({ ok: z.literal(true) }).strict();",
+    "export default defineTool({",
+    "description: 'Halt the Flight worker.',",
+    "  inputSchema,",
+    "  resultSchema,",
+    "}, async () => {",
+    "  process.exit(1);",
+    "  return createElement(Agent.Result, { value: { ok: true } }, createElement(Agent.Text, null, 'halted'));",
+    "});",
     ].join('\n'),
     'src/events/session/start.tsx': [
       "import { Agent } from '@agent-bundle/runtime';",
@@ -1101,16 +1152,19 @@ it('keeps a second generated server from the same install alive while the first
   roots.push(root);
   await writeGeneratedProject(root, {
     'src/mcp/curator/tools/ping.tsx': [
-      "import { Agent } from '@agent-bundle/runtime';",
-      "import { createElement } from 'react';",
-      "import { z } from 'zod';",
-      "export const config = { annotations: { readOnlyHint: true }, description: 'Answer from this process.' };",
-      'export const inputSchema = z.object({}).strict();',
-      'export const resultSchema = z.object({ pid: z.number() }).strict();',
-      'export default async function Ping() {',
-      "  return createElement(Agent.Result, { value: { pid: process.pid } }, createElement(Agent.Text, null, 'pong'));",
-      '}',
-      '',
+    "import { defineTool } from 'agent-bundle/routes';",
+    "import { Agent } from '@agent-bundle/runtime';",
+    "import { createElement } from 'react';",
+    "import { z } from 'zod';",
+    "export const inputSchema = z.object({}).strict();",
+    "export const resultSchema = z.object({ pid: z.number() }).strict();",
+    "export default defineTool({",
+    "annotations: { readOnlyHint: true }, description: 'Answer from this process.',",
+    "  inputSchema,",
+    "  resultSchema,",
+    "}, async () => {",
+    "  return createElement(Agent.Result, { value: { pid: process.pid } }, createElement(Agent.Text, null, 'pong'));",
+    "});",
     ].join('\n'),
     'src/events/session/start.tsx': [
       "import { Agent } from '@agent-bundle/runtime';",
@@ -1182,34 +1236,40 @@ it('renders one tool/after event route through two native thin clients', { retry
     })),
     writeProjectFile(root, 'agent-bundle.config.ts', [
       "import { defineConfig } from 'agent-bundle/config';",
-      "export default defineConfig({ plugin: { name: 'generated-events-fixture', version: '1.0.0' }, targets: ['claude', 'cursor'] });",
+      "export default defineConfig({ plugin: { name: 'generated-events-fixture' }, targets: ['claude', 'cursor'] });",
       '',
     ].join('\n')),
     writeProjectFile(root, 'src/mcp/runtime/tools/status.tsx', [
-      "import { Agent, agent } from '@agent-bundle/runtime';",
-      "import { createElement } from 'react';",
-      "import { z } from 'zod';",
-      'export const inputSchema = z.object({}).strict();',
-      "export const resultSchema = z.object({ providerKind: z.literal('tool'), contextFrozen: z.literal(true) }).strict();",
-      'export default async function Status() {',
-      '  const context = await agent();',
-      '  const requestValue = (await context.provider("requestValue")) as { kind: string };',
-      '  const value = { providerKind: requestValue.kind, contextFrozen: Object.isFrozen(context) };',
-      "  return createElement(Agent.Result, { value }, createElement(Agent.Text, null, `provider:${requestValue.kind}`));",
-      '}',
-      '',
+    "import { defineTool } from 'agent-bundle/routes';",
+    "import { Agent, agent } from '@agent-bundle/runtime';",
+    "import { createElement } from 'react';",
+    "import { z } from 'zod';",
+    "export const inputSchema = z.object({}).strict();",
+    "export const resultSchema = z.object({ providerKind: z.literal('tool'), contextFrozen: z.literal(true) }).strict();",
+    "export default defineTool({",
+    "  inputSchema,",
+    "  resultSchema,",
+    "}, async () => {",
+    "  const context = await agent();",
+    "  const requestValue = (await context.provider(\"requestValue\")) as { kind: string };",
+    "  const value = { providerKind: requestValue.kind, contextFrozen: Object.isFrozen(context) };",
+    "  return createElement(Agent.Result, { value }, createElement(Agent.Text, null, `provider:${requestValue.kind}`));",
+    "});",
     ].join('\n')),
     writeProjectFile(root, 'src/mcp/runtime/tools/explode.tsx', [
-      "import { Agent, agent } from '@agent-bundle/runtime';",
-      "import { createElement } from 'react';",
-      "import { z } from 'zod';",
-      'export const inputSchema = z.object({}).strict();',
-      'export const resultSchema = z.object({ ok: z.literal(true) }).strict();',
-      'export default async function Explode() {',
-      "  await (await agent()).provider('throwing');",
-      "  return createElement(Agent.Result, { value: { ok: true } }, createElement(Agent.Text, null, 'unreachable'));",
-      '}',
-      '',
+    "import { defineTool } from 'agent-bundle/routes';",
+    "import { Agent, agent } from '@agent-bundle/runtime';",
+    "import { createElement } from 'react';",
+    "import { z } from 'zod';",
+    "export const inputSchema = z.object({}).strict();",
+    "export const resultSchema = z.object({ ok: z.literal(true) }).strict();",
+    "export default defineTool({",
+    "  inputSchema,",
+    "  resultSchema,",
+    "}, async () => {",
+    "  await (await agent()).provider('throwing');",
+    "  return createElement(Agent.Result, { value: { ok: true } }, createElement(Agent.Text, null, 'unreachable'));",
+    "});",
     ].join('\n')),
     writeProjectFile(root, 'src/providers/request-value.ts', [
       "import type { AgentProviderFactory } from 'agent-bundle';",
@@ -1368,20 +1428,23 @@ it('renders composite root events through each selected host in one warm runtime
     })),
     writeProjectFile(root, 'agent-bundle.config.ts', [
       "import { defineConfig } from 'agent-bundle/config';",
-      "export default defineConfig({ plugin: { name: 'generated-plugin-events-fixture', version: '1.0.0' }, targets: ['claude', 'codex', 'cursor'] });",
+      "export default defineConfig({ plugin: { name: 'generated-plugin-events-fixture' }, targets: ['claude', 'codex', 'cursor'] });",
       '',
     ].join('\n')),
     writeProjectFile(root, 'src/mcp/runtime/tools/status.tsx', [
-      "import { Agent } from '@agent-bundle/runtime';",
-      "import { createElement } from 'react';",
-      "import { z } from 'zod';",
-      "export const config = { description: 'Keep the shared event runtime warm.' };",
-      'export const inputSchema = z.object({}).strict();',
-      'export const resultSchema = z.object({ ok: z.literal(true) }).strict();',
-      'export default async function Status() {',
-      "  return createElement(Agent.Result, { value: { ok: true } }, createElement(Agent.Text, null, 'ready'));",
-      '}',
-      '',
+    "import { defineTool } from 'agent-bundle/routes';",
+    "import { Agent } from '@agent-bundle/runtime';",
+    "import { createElement } from 'react';",
+    "import { z } from 'zod';",
+    "export const inputSchema = z.object({}).strict();",
+    "export const resultSchema = z.object({ ok: z.literal(true) }).strict();",
+    "export default defineTool({",
+    "description: 'Keep the shared event runtime warm.',",
+    "  inputSchema,",
+    "  resultSchema,",
+    "}, async () => {",
+    "  return createElement(Agent.Result, { value: { ok: true } }, createElement(Agent.Text, null, 'ready'));",
+    "});",
     ].join('\n')),
     writeProjectFile(root, 'src/events/tool/after.tsx', [
       "import { Agent, agent } from '@agent-bundle/runtime';",
@@ -1521,7 +1584,7 @@ it('runs an explicitly standalone event route without a shared runtime', { timeo
     })),
     writeProjectFile(root, 'agent-bundle.config.ts', [
       "import { defineConfig } from 'agent-bundle/config';",
-      "export default defineConfig({ plugin: { name: 'standalone-event-fixture', version: '1.0.0' }, targets: ['cursor'] });",
+      "export default defineConfig({ plugin: { name: 'standalone-event-fixture' }, targets: ['cursor'] });",
       '',
     ].join('\n')),
     writeProjectFile(root, 'src/events/tool/after.tsx', [
@@ -1575,7 +1638,7 @@ it('exits 1 with the message on stderr and no stdout when a standalone event rou
     })),
     writeProjectFile(root, 'agent-bundle.config.ts', [
       "import { defineConfig } from 'agent-bundle/config';",
-      "export default defineConfig({ plugin: { name: 'thrown-event-fixture', version: '1.0.0' }, targets: ['cursor'] });",
+      "export default defineConfig({ plugin: { name: 'thrown-event-fixture' }, targets: ['cursor'] });",
       '',
     ].join('\n')),
     writeProjectFile(root, 'src/events/tool/before.tsx', [
@@ -1628,7 +1691,7 @@ it('replays Claude and Codex subagent fixtures through standalone event-route wr
     })),
     writeProjectFile(root, 'agent-bundle.config.ts', [
       "import { defineConfig } from 'agent-bundle/config';",
-      "export default defineConfig({ plugin: { name: 'subagent-events-fixture', version: '1.0.0' }, targets: ['claude', 'codex'] });",
+      "export default defineConfig({ plugin: { name: 'subagent-events-fixture' }, targets: ['claude', 'codex'] });",
       '',
     ].join('\n')),
     writeProjectFile(root, 'src/events/agent/start.tsx', [
@@ -1697,7 +1760,7 @@ it('dispatches shared event routes through the invoking host contract', { timeou
     })),
     writeProjectFile(root, 'agent-bundle.config.ts', [
       "import { defineConfig } from 'agent-bundle/config';",
-      "export default defineConfig({ plugin: { name: 'plugin-subagent-events-fixture', version: '1.0.0' }, targets: ['claude', 'codex'] });",
+      "export default defineConfig({ plugin: { name: 'plugin-subagent-events-fixture' }, targets: ['claude', 'codex'] });",
       '',
     ].join('\n')),
     writeProjectFile(root, 'src/events/agent/start.tsx', [
diff --git a/packages/agent-bundle/tests/helpers/gated-routes.ts b/packages/agent-bundle/tests/helpers/gated-routes.ts
index b39804163..6f17833b0 100644
--- a/packages/agent-bundle/tests/helpers/gated-routes.ts
+++ b/packages/agent-bundle/tests/helpers/gated-routes.ts
@@ -18,6 +18,7 @@ export const gatedRouteFiles: Readonly> = Object.freeze({
     '',
   ].join('\n'),
   'src/mcp/status/tools/live.tsx': [
+    "import { defineTool } from 'agent-bundle/routes';",
     "import { Agent } from '@agent-bundle/runtime';",
     "import { createElement, Suspense } from 'react';",
     "import { z } from 'zod';",
@@ -30,9 +31,9 @@ export const gatedRouteFiles: Readonly> = Object.freeze({
     "  return createElement(Agent.Text, null, 'stream complete');",
     '};',
     '',
-    'export default async function Live({ input, signal }) {',
+    'export default defineTool({ inputSchema, resultSchema }, async (input, { signal }) => {',
     "  return createElement(Agent.Result, { value: { done: true } }, createElement(Suspense, { fallback: createElement(Agent.Progress, { completed: 0, message: 'streaming', total: 1 }) }, createElement(Slow, { gate: input.gate, signal })));",
-    '}',
+    '});',
     '',
   ].join('\n'),
 });
diff --git a/packages/agent-bundle/tests/helpers/project-fixture.ts b/packages/agent-bundle/tests/helpers/project-fixture.ts
index 9ab4330cc..38d969552 100644
--- a/packages/agent-bundle/tests/helpers/project-fixture.ts
+++ b/packages/agent-bundle/tests/helpers/project-fixture.ts
@@ -30,6 +30,10 @@ const sourceEntryPoint = resolve(
   process.cwd(),
   'packages/agent-bundle/src/core/types.ts',
 );
+const routesEntryPoint = resolve(
+  process.cwd(),
+  'packages/agent-bundle/src/routes/definitions.ts',
+);
 
 export const createProjectFixture = async (
   options: ProjectFixtureOptions = {},
@@ -41,7 +45,22 @@ export const createProjectFixture = async (
   const configPath = join(root, 'agent-bundle.config.ts');
 
   if (options.config !== undefined || options.files !== undefined) {
-    const files = options.files ?? {};
+    const authoredPackage = options.files?.['package.json'];
+    let packageJson = authoredPackage ?? '{"type":"module"}\n';
+    if (typeof packageJson === 'string') {
+      try {
+        const document = JSON.parse(packageJson) as unknown;
+        if (document !== null && typeof document === 'object' && !Array.isArray(document) && !('version' in document)) {
+          packageJson = `${JSON.stringify({ ...document, version: '1.0.0' })}\n`;
+        }
+      } catch {
+        // Malformed package documents belong to the test that authored them.
+      }
+    }
+    const files: Readonly> = {
+      ...options.files,
+      'package.json': packageJson,
+    };
     await Promise.all([configPath, ...Object.keys(files).map((relativePath) => join(root, relativePath))]
       .map((path) => mkdir(dirname(path), { recursive: true })));
     await Promise.all([
@@ -79,18 +98,26 @@ export const createProjectFixture = async (
 
   await Promise.all([
     writeFile(join(root, '.gitignore'), '*.log\n'),
+    writeFile(join(root, 'package.json'), '{"type":"module","version":"1.0.0"}\n'),
     writeFile(
       join(root, 'node_modules/agent-bundle/package.json'),
       JSON.stringify({
         name: 'agent-bundle',
         type: 'module',
-        exports: './index.ts',
+        exports: {
+          '.': './index.ts',
+          './routes': './routes.ts',
+        },
       }),
     ),
     writeFile(
       join(root, 'node_modules/agent-bundle/index.ts'),
       `export { defineConfig } from ${JSON.stringify(sourceEntryPoint)};\n`,
     ),
+    writeFile(
+      join(root, 'node_modules/agent-bundle/routes.ts'),
+      `export { defineTool } from ${JSON.stringify(routesEntryPoint)};\n`,
+    ),
     writeFile(
       configPath,
       [
@@ -102,7 +129,7 @@ export const createProjectFixture = async (
         '  projectRoot,',
         '  selectedTargets,',
         '}) => ({',
-        "  plugin: { name: 'review', version: '1.0.0' },",
+        "  plugin: { name: 'review' },",
         `  skills: ${skills},`,
         '  fixtureContext: { command, mode, projectRoot, selectedTargets },',
         '}));',
diff --git a/packages/agent-bundle/tests/helpers/workspace-paths.ts b/packages/agent-bundle/tests/helpers/workspace-paths.ts
index 32e62dced..78c19cca6 100644
--- a/packages/agent-bundle/tests/helpers/workspace-paths.ts
+++ b/packages/agent-bundle/tests/helpers/workspace-paths.ts
@@ -4,5 +4,6 @@ const workspaceRoot = join(import.meta.dirname, '..', '..', '..', '..');
 
 export const agentBundlePackageRoot = join(workspaceRoot, 'packages', 'agent-bundle');
 export const agentBundleNodeModules = join(agentBundlePackageRoot, 'node_modules');
+export const exampleNodeModules = join(workspaceRoot, 'examples', 'audiobook-curator', 'node_modules');
 export const workbenchNodeModules = join(workspaceRoot, 'packages', 'workbench', 'node_modules');
 export const workspaceNodeModules = join(workspaceRoot, 'node_modules');
diff --git a/packages/agent-bundle/tests/hook-playground-service.test.ts b/packages/agent-bundle/tests/hook-playground-service.test.ts
index 59312058e..107568a2a 100644
--- a/packages/agent-bundle/tests/hook-playground-service.test.ts
+++ b/packages/agent-bundle/tests/hook-playground-service.test.ts
@@ -163,7 +163,7 @@ const publishHookEpoch = async (
         sessionStart: './src/hooks/session-start.ts',
         stop: './src/hooks/stop.ts',
       },
-      plugin: { name: 'hook-playground-fixture', version: '1.0.0' },
+      plugin: { name: 'hook-playground-fixture' },
       targets: ['codex', 'claude'],
     }),
     { skills: [] },
diff --git a/packages/agent-bundle/tests/hook-receipt-pipe.test.ts b/packages/agent-bundle/tests/hook-receipt-pipe.test.ts
index 0614b85f5..070ddd570 100644
--- a/packages/agent-bundle/tests/hook-receipt-pipe.test.ts
+++ b/packages/agent-bundle/tests/hook-receipt-pipe.test.ts
@@ -97,7 +97,7 @@ it('posts a host-invoked hook execution to the dev server as hook.received / hoo
     })),
     writeProjectFile(root, 'agent-bundle.config.ts', [
       "import { defineConfig } from 'agent-bundle/config';",
-      "export default defineConfig({ plugin: { name: 'hook-receipt-fixture', version: '1.0.0' }, targets: ['claude'] });",
+      "export default defineConfig({ plugin: { name: 'hook-receipt-fixture' }, targets: ['claude'] });",
       '',
     ].join('\n')),
     writeProjectFile(root, 'src/events/tool/before.ts', [
diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts
index 4b2726afc..6a26bfe36 100644
--- a/packages/agent-bundle/tests/hooks.test.ts
+++ b/packages/agent-bundle/tests/hooks.test.ts
@@ -626,7 +626,7 @@ it('normalizes a shorthand session-start hook into a frozen stable record', asyn
   const loaded: LoadedConfig = {
     config: {
       hooks: { sessionStart: './src/hooks/session-start.ts' },
-      plugin: { name: 'review-tools', version: '1.0.0' },
+      plugin: { name: 'review-tools' },
     },
     configPath,
     context: {
@@ -666,7 +666,7 @@ it('filters inherited hook targets through adapter hook capabilities', async ()
   const loaded: LoadedConfig = {
     config: {
       hooks: { sessionStart: './src/hooks/session-start.ts' },
-      plugin: { name: 'review-tools', version: '1.0.0' },
+      plugin: { name: 'review-tools' },
       targets: ['portable', 'codex', 'claude'],
     },
     configPath,
@@ -697,7 +697,7 @@ it('loads and deterministically merges target-native hook documents after genera
       claude: { nativeHooks: './claude-hooks.json' },
       codex: { nativeHooks: './codex-hooks.json' },
       hooks: { sessionStart: './src/hooks/session-start.ts' },
-      plugin: { name: 'review-tools', version: '1.0.0' },
+      plugin: { name: 'review-tools' },
       targets: ['codex', 'claude'],
     },
     configPath,
@@ -771,7 +771,7 @@ it('reports stable target-native hook file diagnostics before merge', async () =
     config: {
       claude: { nativeHooks: './claude-broken.json' },
       codex: { nativeHooks: './missing-codex.json' },
-      plugin: { name: 'review-tools', version: '1.0.0' },
+      plugin: { name: 'review-tools' },
       targets: ['codex', 'claude'],
     },
     configPath: join(root, 'agent-bundle.config.ts'),
@@ -1939,7 +1939,7 @@ it('normalizes a mixed hook fixture and reports malformed hook declarations', as
         sessionStart: './src/hooks/session-start.ts',
         stop: './src/hooks/stop.ts',
       },
-      plugin: { name: 'review-tools', version: '1.0.0' },
+      plugin: { name: 'review-tools' },
     },
     configPath,
     context: { command: 'build', mode: 'production', projectRoot: root, selectedTargets: [] },
diff --git a/packages/agent-bundle/tests/host-discovery-dev-server.test.ts b/packages/agent-bundle/tests/host-discovery-dev-server.test.ts
index 38a373b83..996b22fef 100644
--- a/packages/agent-bundle/tests/host-discovery-dev-server.test.ts
+++ b/packages/agent-bundle/tests/host-discovery-dev-server.test.ts
@@ -41,7 +41,7 @@ it.each([
     config: [
       'export default {',
       configOutput.trimEnd(),
-      "  plugin: { name: 'host-discovery-dev-server', version: '1.0.0' },",
+      "  plugin: { name: 'host-discovery-dev-server' },",
       "  targets: ['claude'],",
       '};',
       '',
diff --git a/packages/agent-bundle/tests/host-install-proof.test.ts b/packages/agent-bundle/tests/host-install-proof.test.ts
index 8e721f982..7697513f6 100644
--- a/packages/agent-bundle/tests/host-install-proof.test.ts
+++ b/packages/agent-bundle/tests/host-install-proof.test.ts
@@ -62,7 +62,6 @@ beforeAll(async () => {
           '  plugin: {',
           "    description: 'Proves an MCP-only installed host artifact.',",
           "    name: 'host-install-mcp-only-proof',",
-          "    version: '1.0.0',",
           '  },',
           '  routes: { mcpCommands: true },',
           "  targets: ['claude', 'codex', 'cursor'],",
diff --git a/packages/agent-bundle/tests/host-mcp-proxy.test.ts b/packages/agent-bundle/tests/host-mcp-proxy.test.ts
index 108d3e3c3..74150231a 100644
--- a/packages/agent-bundle/tests/host-mcp-proxy.test.ts
+++ b/packages/agent-bundle/tests/host-mcp-proxy.test.ts
@@ -71,7 +71,7 @@ const writeProxyProject = async (root: string): Promise => {
       '',
       'export default defineConfig({',
       "  mcp: { servers: { fixture: { entry: './src/server.ts' } } },",
-      "  plugin: { name: 'host-proxy-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'host-proxy-fixture' },",
       "  targets: ['portable'],",
       '});',
       '',
diff --git a/packages/agent-bundle/tests/inspect-bundler.test.ts b/packages/agent-bundle/tests/inspect-bundler.test.ts
index 8617f82ac..5582a3431 100644
--- a/packages/agent-bundle/tests/inspect-bundler.test.ts
+++ b/packages/agent-bundle/tests/inspect-bundler.test.ts
@@ -41,7 +41,7 @@ const createProject = async (): Promise => {
       join(root, 'agent-bundle.config.ts'),
       [
         'export default {',
-        "  plugin: { name: 'bundler-fixture', version: '1.0.0' },",
+        "  plugin: { name: 'bundler-fixture' },",
         "  targets: ['portable'],",
         "  scripts: { tool: './src/tool.ts' },",
         '  mcp: {',
@@ -252,7 +252,7 @@ it('wires output.sourceMap into the lowered generated-executable config', async
     join(root, 'agent-bundle.config.ts'),
     [
       'export default {',
-      "  plugin: { name: 'bundler-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'bundler-fixture' },",
       "  targets: ['portable'],",
       "  scripts: { tool: './src/tool.ts' },",
       '  output: { sourceMap: true },',
@@ -297,7 +297,7 @@ it('reports a tools hatch the lowering refuses as an invalid inspection naming t
     join(root, 'agent-bundle.config.ts'),
     [
       'export default {',
-      "  plugin: { name: 'bundler-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'bundler-fixture' },",
       "  targets: ['portable'],",
       "  scripts: { tool: './src/tool.ts' },",
       // Aliasing a reserved specifier is refused only once the invariant hook
diff --git a/packages/agent-bundle/tests/inspect-state.test.ts b/packages/agent-bundle/tests/inspect-state.test.ts
index 1a41aec4b..d92c34347 100644
--- a/packages/agent-bundle/tests/inspect-state.test.ts
+++ b/packages/agent-bundle/tests/inspect-state.test.ts
@@ -27,7 +27,7 @@ const createProject = async (): Promise => {
     writeFile(join(root, 'package.json'), '{"type":"module"}\n'),
     writeFile(join(root, 'agent-bundle.config.ts'), [
       'export default {',
-      "  plugin: { name: 'state-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'state-fixture' },",
       "  targets: ['portable'],",
       '};',
       '',
@@ -174,7 +174,7 @@ it('reports the declared notice retention policy and rejects a malformed one as
     await writeFile(join(root, 'agent-bundle.config.ts'), [
       'export default {',
       "  notices: { retention: { maxTerminal: 12, terminalTtl: '2d' } },",
-      "  plugin: { name: 'state-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'state-fixture' },",
       "  targets: ['portable'],",
       '};',
       '',
@@ -190,7 +190,7 @@ it('reports the declared notice retention policy and rejects a malformed one as
     await writeFile(join(root, 'agent-bundle.config.ts'), [
       'export default {',
       "  notices: { retention: { terminalTtl: 'soon' } },",
-      "  plugin: { name: 'state-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'state-fixture' },",
       "  targets: ['portable'],",
       '};',
       '',
@@ -238,7 +238,7 @@ it('reports stateless inspection and rejects competing state focuses', async ()
     ].join('\n'));
     await writeFile(join(root, 'agent-bundle.config.ts'), [
       'export default {',
-      "  plugin: { name: 'state-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'state-fixture' },",
       "  targets: ['portable'],",
       '  state: false,',
       '};',
diff --git a/packages/agent-bundle/tests/install-cli.test.ts b/packages/agent-bundle/tests/install-cli.test.ts
index 9e8659a7a..4ca600dbc 100644
--- a/packages/agent-bundle/tests/install-cli.test.ts
+++ b/packages/agent-bundle/tests/install-cli.test.ts
@@ -41,7 +41,7 @@ describe('package-bound lifecycle commands', () => {
   it('pins the bundle root and forwards install flags to installBundle', async () => {
     const calls: unknown[] = [];
     const { code, stdout } = await runPinned(
-      ['install', 'cursor', '--mode', 'marketplace', '--scope', 'project', '--force', '--json'],
+      ['install', 'cursor', '--mode', 'marketplace', '--scope', 'project', '--replace', '--json'],
       {
         installBundle: async (options) => {
           calls.push(options);
diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts
index d7bb21a0b..8a5a825a8 100644
--- a/packages/agent-bundle/tests/install-surface.test.ts
+++ b/packages/agent-bundle/tests/install-surface.test.ts
@@ -782,7 +782,7 @@ it('documents the same-version reinstall recipe per host, including Claude\'s ve
   expect(writesFor('cursor').get('INSTALL.md')).toContain('content-hash comparison');
 
   const installer = writesFor('cursor').get('install.mjs') ?? '';
-  expect(installer).toContain("argument === '--replace' || argument === '--force'");
+  expect(installer).toContain("argument === '--replace'");
   expect(installer).toContain(`const receiptFile = ${JSON.stringify(installReceiptFile)};`);
   expect(installer).toContain(`const receiptFormat = ${JSON.stringify(installReceiptFormat)};`);
   expect(installer).toContain(`const legacyReceiptFormat = ${JSON.stringify(legacyInstallReceiptFormat)};`);
@@ -843,7 +843,7 @@ it('emitted install.mjs mirrors the core replace policy: no-op, owned-only repla
 
     const help = await run(installer, ['--help'], home);
     expect(help).toMatchObject({ code: 0 });
-    expect(help.stdout).toContain('[--replace|--force]');
+    expect(help.stdout).toContain('[--replace]');
     const unknown = await run(installer, ['--bogus'], home);
     expect(unknown.code).toBe(2);
     expect(unknown.stderr).toContain('Unknown installer argument "--bogus"');
@@ -1055,7 +1055,7 @@ it('emitted install.mjs mirrors the core replace policy: no-op, owned-only repla
     expect(legacy.stderr).toContain(`content ${legacyHash.slice(0, 12)}`);
     expect(legacy.stderr).toContain('same version, different content');
     expect(legacy.stderr).toContain('--replace');
-    const adopted = await run(installer, ['--force'], home);
+    const adopted = await run(installer, ['--replace'], home);
     expect(adopted).toMatchObject({ code: 0, stderr: '' });
     expect(adopted.stdout).toContain('Replaced install-fixture@1.2.3');
     expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('rebuilt\n');
diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts
index 9672f4883..df9a37eec 100644
--- a/packages/agent-bundle/tests/install.test.ts
+++ b/packages/agent-bundle/tests/install.test.ts
@@ -1876,7 +1876,7 @@ it('dispatches the public CLI install command to the native installer', async ()
   const terminal = captureCliTerminal();
   const calls: unknown[] = [];
   const code = await runCli(
-    ['install', 'claude', '--from', '/tmp/example bundle', '--scope', 'project', '--force', '--json'],
+    ['install', 'claude', '--from', '/tmp/example bundle', '--scope', 'project', '--replace', '--json'],
     terminal.output,
     {
       installBundle: async (options: unknown) => {
diff --git a/packages/agent-bundle/tests/layout-build.test.ts b/packages/agent-bundle/tests/layout-build.test.ts
index c8d859b6f..2bad91528 100644
--- a/packages/agent-bundle/tests/layout-build.test.ts
+++ b/packages/agent-bundle/tests/layout-build.test.ts
@@ -24,27 +24,35 @@ const writeProjectFile = async (root: string, path: string, contents: string): P
 };
 
 const lookupRoute = [
+  "import { defineTool } from 'agent-bundle/routes';",
   "import { Agent, agent } from '@agent-bundle/runtime';",
   "import { z } from 'zod';",
-  "export const config = { inputJsonSchema: {\"additionalProperties\":false,\"properties\":{\"message\":{\"type\":\"string\",\"default\":\"ready\"}},\"type\":\"object\"}, annotations: { readOnlyHint: true }, description: 'Looks up one value.' };",
   'export const inputSchema = z.object({ message: z.string().default("ready") }).strict();',
   "export const resultSchema = z.object({ invocation: z.enum(['cli', 'tool']), message: z.string() }).strict();",
-  'export default async function Lookup({ input }) {',
+  'export default defineTool({',
+  "  inputJsonSchema: {\"additionalProperties\":false,\"properties\":{\"message\":{\"type\":\"string\",\"default\":\"ready\"}},\"type\":\"object\"}, annotations: { readOnlyHint: true }, description: 'Looks up one value.',",
+  '  inputSchema,',
+  '  resultSchema,',
+  '}, async (input) => {',
   '  const context = await agent();',
   '  const result = { invocation: context.invocation.kind, message: input.message };',
   '  return {`Lookup: ${input.message}`};',
-  '}',
+  '});',
   '',
 ].join('\n');
 
 const explodeRoute = [
+  "import { defineTool } from 'agent-bundle/routes';",
   "import { z } from 'zod';",
-  "export const config = { inputJsonSchema: {\"additionalProperties\":false,\"properties\":{},\"type\":\"object\"}, annotations: { readOnlyHint: true }, description: 'Throws before rendering.' };",
   'export const inputSchema = z.object({}).strict();',
   'export const resultSchema = z.object({ ok: z.boolean() }).strict();',
-  'export default async function Explode() {',
+  'export default defineTool({',
+  "  inputJsonSchema: {\"additionalProperties\":false,\"properties\":{},\"type\":\"object\"}, annotations: { readOnlyHint: true }, description: 'Throws before rendering.',",
+  '  inputSchema,',
+  '  resultSchema,',
+  '}, async () => {',
   "  throw new Error('lookup exploded');",
-  '}',
+  '});',
   '',
 ].join('\n');
 
@@ -67,7 +75,7 @@ const writeLayoutProject = async (root: string, layouts: Readonly {
         '    },',
         '  } },',
         "  payload: { runtime: definePrebuilt({ source: './built/runtime', runtimeDependencies: ['zod'] }) },",
-        "  plugin: { description: 'The authoritative manifest combined proof.', name: 'manifest-combined-proof', version: '1.0.0' },",
+        "  plugin: { description: 'The authoritative manifest combined proof.', name: 'manifest-combined-proof' },",
         '  routes: { mcpCommands: true },',
         "  targets: ['claude', 'portable'],",
         "  web: { apps: [{ allow: ['call-tool'], app: 'proof/status', tool: 'show-status' }] },",
@@ -170,13 +170,13 @@ describe('the authoritative manifest combined proof', () => {
         ].join('\n'),
         'src/mcp/proof/tools/show-status.tsx': [
           "import { Agent } from '@agent-bundle/runtime';",
+          "import { defineTool } from 'agent-bundle/routes';",
           "import { z } from 'zod';",
-          "export const config = { inputJsonSchema: {\"additionalProperties\":false,\"properties\":{\"message\":{\"type\":\"string\"}},\"required\":[\"message\"],\"type\":\"object\"}, annotations: { readOnlyHint: true }, description: 'Show combined proof status.', _meta: { ui: { resourceUri: 'ui://manifest-combined-proof/status.html' } } };",
           'export const inputSchema = z.object({ message: z.string().min(1) }).strict();',
           'export const resultSchema = z.object({ message: z.string() }).strict();',
-          'export default async function ShowStatus({ input }) {',
+          "export default defineTool({ inputJsonSchema: {\"additionalProperties\":false,\"properties\":{\"message\":{\"type\":\"string\"}},\"required\":[\"message\"],\"type\":\"object\"}, annotations: { readOnlyHint: true }, description: 'Show combined proof status.', _meta: { ui: { resourceUri: 'ui://manifest-combined-proof/status.html' } }, inputSchema, resultSchema }, async (input) => {",
           '  return {input.message};',
-          '}',
+          '});',
           '',
         ].join('\n'),
         'src/providers/state-probe.ts': [
diff --git a/packages/agent-bundle/tests/mcp-app-host-profiles.test.ts b/packages/agent-bundle/tests/mcp-app-host-profiles.test.ts
index c051ebbf9..c31981398 100644
--- a/packages/agent-bundle/tests/mcp-app-host-profiles.test.ts
+++ b/packages/agent-bundle/tests/mcp-app-host-profiles.test.ts
@@ -70,7 +70,7 @@ const normalizedExtensions = async (extensions: Readonly
   (await normalizeProject({
     config: {
       ...extensions,
-      plugin: { name: 'host-profile-normalized-config', version: '1.0.0' },
+      plugin: { name: 'host-profile-normalized-config' },
     },
     configPath: `${projectRoot}/agent-bundle.config.ts`,
     context: {
diff --git a/packages/agent-bundle/tests/mcp-probe-dev-server.test.ts b/packages/agent-bundle/tests/mcp-probe-dev-server.test.ts
index 45b047e29..f77bccc64 100644
--- a/packages/agent-bundle/tests/mcp-probe-dev-server.test.ts
+++ b/packages/agent-bundle/tests/mcp-probe-dev-server.test.ts
@@ -20,7 +20,7 @@ it('runs an authenticated initialize and tools/list probe against a real built s
       '    },',
       '  },',
       "  payload: { runtime: './prebuilt/runtime' },",
-      "  plugin: { name: 'mcp-probe-dev-server', version: '1.0.0' },",
+      "  plugin: { name: 'mcp-probe-dev-server' },",
       "  targets: ['claude'],",
       '};',
       '',
@@ -115,7 +115,7 @@ it('joins detached probe plugin-data cleanup into Workbench shutdown', { timeout
       '    },',
       '  },',
       "  payload: { runtime: './prebuilt/runtime' },",
-      "  plugin: { name: 'mcp-probe-dev-server-shutdown', version: '1.0.0' },",
+      "  plugin: { name: 'mcp-probe-dev-server-shutdown' },",
       "  targets: ['claude'],",
       '};',
       '',
diff --git a/packages/agent-bundle/tests/mcp-session-service.test.ts b/packages/agent-bundle/tests/mcp-session-service.test.ts
index 5a3c9856a..c443ad454 100644
--- a/packages/agent-bundle/tests/mcp-session-service.test.ts
+++ b/packages/agent-bundle/tests/mcp-session-service.test.ts
@@ -160,7 +160,7 @@ const publishFixtureEpoch = async (
           },
         },
       },
-      plugin: { name: 'persistent-mcp-fixture', version: '1.0.0' },
+      plugin: { name: 'persistent-mcp-fixture' },
       targets: [...targets],
     }),
     { skills: [] },
@@ -199,7 +199,7 @@ const publishRemoteEpoch = async (root: string, id: string): Promise
           },
         },
       },
-      plugin: { name: 'persistent-mcp-remote-fixture', version: '1.0.0' },
+      plugin: { name: 'persistent-mcp-remote-fixture' },
       targets: ['portable'],
     }),
     { skills: [] },
diff --git a/packages/agent-bundle/tests/mcp.test.ts b/packages/agent-bundle/tests/mcp.test.ts
index de12de430..b3cca647f 100644
--- a/packages/agent-bundle/tests/mcp.test.ts
+++ b/packages/agent-bundle/tests/mcp.test.ts
@@ -57,7 +57,7 @@ it('rejects legacy MCP SSE source declarations with one AB4317 diagnostic', () =
         },
       },
     },
-    plugin: { name: 'legacy-sse', version: '1.0.0' },
+    plugin: { name: 'legacy-sse' },
   } as unknown as AgentBundleConfig;
   const diagnostics = validateSource(loadedProject('/workspace', config), { skills: [] }, registry);
 
@@ -104,7 +104,7 @@ it('normalizes local, prebuilt, and HTTP MCP server declarations', async () => {
     const model = await normalizeProject(
       loadedProject(root, {
         mcp: { servers },
-        plugin: { name: 'mcp-fixture', version: '1.0.0' },
+        plugin: { name: 'mcp-fixture' },
         targets: ['portable', 'codex', 'claude'],
       }),
       { skills: [] },
@@ -181,7 +181,7 @@ it('normalizes deeply frozen local MCP App declarations independently of the pro
         },
       },
     },
-    plugin: { name: 'mcp-app-fixture', version: '1.0.0' },
+    plugin: { name: 'mcp-app-fixture' },
     targets: ['portable', 'claude'],
   } as unknown as AgentBundleConfig;
   try {
@@ -237,7 +237,7 @@ it('keeps local MCP server identities and output aliases independent of the proj
     }
     const config: AgentBundleConfig = {
       mcp: { servers: { 'same server': { entry: './src/server.ts' } } },
-      plugin: { name: 'mcp-fixture', version: '1.0.0' },
+      plugin: { name: 'mcp-fixture' },
     };
     const [leftModel, rightModel] = await Promise.all([left, right].map((root) =>
       normalizeProject(loadedProject(root, config), { skills: [] }, registry)));
@@ -276,7 +276,7 @@ it('reports source and model diagnostics before an MCP server can be compiled',
           'unknown target': { command: 'server', targets: ['unknown'] },
         },
       },
-      plugin: { name: 'mcp-fixture', version: '1.0.0' },
+      plugin: { name: 'mcp-fixture' },
     } as unknown as AgentBundleConfig;
     const loaded = loadedProject(root, config);
 
@@ -332,7 +332,7 @@ it('rejects a hostile normalized legacy SSE transport before adapters can plan i
             },
           },
         },
-        plugin: { name: 'hostile-model', version: '1.0.0' },
+        plugin: { name: 'hostile-model' },
       }),
       { skills: [] },
       registry,
@@ -405,7 +405,7 @@ it('rejects unsafe, duplicate, and nonlocal MCP App declarations before browser
           },
         },
       },
-      plugin: { name: 'mcp-app-invalid', version: '1.0.0' },
+      plugin: { name: 'mcp-app-invalid' },
     } as unknown as AgentBundleConfig;
 
     expect(validateSource(loadedProject(root, malformed), { skills: [] }, registry).map(({ code }) => code)).toEqual(
@@ -439,7 +439,7 @@ it('rejects unsafe, duplicate, and nonlocal MCP App declarations before browser
             },
           },
         },
-        plugin: { name: 'mcp-app-invalid', version: '1.0.0' },
+        plugin: { name: 'mcp-app-invalid' },
       }),
       { skills: [] },
       registry,
@@ -484,7 +484,7 @@ it('rejects non-JSON MCP App metadata before normalization', async () => {
             },
           },
         },
-        plugin: { name: 'mcp-app-meta', version: '1.0.0' },
+        plugin: { name: 'mcp-app-meta' },
       } as unknown as AgentBundleConfig;
 
       const diagnostics = validateSource(loadedProject(root, config), { skills: [] }, registry);
@@ -527,7 +527,7 @@ it('bundles each local MCP entry once and maps every target manifest to that art
             },
           },
         },
-        plugin: { name: 'mcp-fixture', version: '1.0.0' },
+        plugin: { name: 'mcp-fixture' },
         targets: ['portable', 'codex', 'claude'],
       }),
       { skills: [] },
@@ -681,7 +681,7 @@ it('inlines agent-bundle/launch-env into a self-connecting entry so it can apply
     const model = await normalizeProject(
       loadedProject(root, {
         mcp: { servers: { probe: { entry: './src/probe.ts' } } },
-        plugin: { name: 'mcp-self-connecting-env', version: '1.0.0' },
+        plugin: { name: 'mcp-self-connecting-env' },
         targets: ['portable'],
       }),
       { skills: [] },
@@ -760,7 +760,7 @@ it('lets the operator .env beat a manifest env default the host passed through,
             },
           },
         },
-        plugin: { name: 'mcp-manifest-env', version: '1.0.0' },
+        plugin: { name: 'mcp-manifest-env' },
         targets: ['claude'],
       }),
       { skills: [] },
@@ -868,7 +868,7 @@ it('redirects stdout written at module scope by the server module to stderr befo
     const model = await normalizeProject(
       loadedProject(root, {
         mcp: { servers: { chatty: { entry: './src/server.ts' } } },
-        plugin: { name: 'mcp-module-scope-stdout', version: '1.0.0' },
+        plugin: { name: 'mcp-module-scope-stdout' },
         targets: ['portable'],
       }),
       { skills: [] },
@@ -940,7 +940,7 @@ it('builds one deterministic self-contained MCP App view and injects it through
             },
           },
         },
-        plugin: { name: 'mcp-app-build', version: '1.0.0' },
+        plugin: { name: 'mcp-app-build' },
         targets: ['portable', 'codex', 'claude'],
       }),
       { skills: [] },
@@ -1027,8 +1027,8 @@ it('injects one release identity into both the Node bundle and the browser MCP A
     await mkdir(join(root, 'src'), { recursive: true });
     await mkdir(join(root, 'views'), { recursive: true });
     await writeFile(join(root, 'agent-bundle.config.ts'), 'export default {};\n');
-    // package.json is the only version source: the config declares no
-    // plugin.version, so every compiled surface must agree on 4.5.6.
+    // package.json is the only version source, so every compiled surface must
+    // agree on 4.5.6.
     await writeFile(
       join(root, 'package.json'),
       `${JSON.stringify({ name: '@scope/meta-fixture', version: '4.5.6' })}\n`,
@@ -1128,7 +1128,7 @@ it('compiles one shared MCP App once and serves it from every identically declar
           public: { apps: { widget: { ...widget } }, entry: './src/public.ts' },
         },
       },
-      plugin: { name: 'mcp-app-shared', version: '1.0.0' },
+      plugin: { name: 'mcp-app-shared' },
       targets: ['portable'],
     };
     // Both fixture entries are deliberately self-connecting registry probes,
@@ -1196,7 +1196,7 @@ it('rejects conflicting same-name MCP App declarations at compilation planning',
             },
           },
         },
-        plugin: { name: 'mcp-app-conflict', version: '1.0.0' },
+        plugin: { name: 'mcp-app-conflict' },
         targets: ['portable'],
       }),
       { skills: [] },
@@ -1301,7 +1301,7 @@ it('uses the selected streamable HTTP manifest with propagated cancellation and
             },
           },
         },
-        plugin: { name: 'mcp-remote-fixture', version: '1.0.0' },
+        plugin: { name: 'mcp-remote-fixture' },
         targets: ['claude'],
       }),
       { skills: [] },
@@ -1373,7 +1373,7 @@ it('rejects a selected projection without its manifest-declared MCP document', a
             },
           },
         },
-        plugin: { name: 'mcp-projection-document', version: '1.0.0' },
+        plugin: { name: 'mcp-projection-document' },
         targets: ['claude', 'codex'],
       }),
       { skills: [] },
@@ -1439,7 +1439,7 @@ it('creates session state only after setup succeeds and always inherits the stdi
             inherited: { entry: './src/server.ts' },
           },
         },
-        plugin: { name: 'mcp-stdio-options', version: '1.0.0' },
+        plugin: { name: 'mcp-stdio-options' },
         targets: ['portable'],
       }),
       { skills: [] },
@@ -1571,7 +1571,7 @@ it('serves compiler-bundled MCP App resources from a copied artifact without pro
             },
           },
         },
-        plugin: { name: 'mcp-app-resource', version: '1.0.0' },
+        plugin: { name: 'mcp-app-resource' },
         targets: ['portable'],
       }),
       { skills: [] },
@@ -1702,7 +1702,7 @@ it('lists tools from a validated copied artifact without reading project source'
             },
           },
         },
-        plugin: { name: 'mcp-service-fixture', version: '1.0.0' },
+        plugin: { name: 'mcp-service-fixture' },
         targets: ['portable'],
       }),
       { skills: [] },
diff --git a/packages/agent-bundle/tests/normalization.test.ts b/packages/agent-bundle/tests/normalization.test.ts
index ddd20f9d3..d09b306ba 100644
--- a/packages/agent-bundle/tests/normalization.test.ts
+++ b/packages/agent-bundle/tests/normalization.test.ts
@@ -114,7 +114,7 @@ it.each([
 ])('normalizes target selection from $label', async (testCase) => {
   const loaded = loadedProject(
     {
-      plugin: { name: 'review-tools', version: '1.0.0' },
+      plugin: { name: 'review-tools' },
       targets: testCase.configTargets,
     },
     { selectedTargets: testCase.selectedTargets },
@@ -133,7 +133,7 @@ it('normalizes registered extensions and validates registered script and hook ta
     example: { nested: { enabled: true } },
     hooks: { sessionStart: { handler: './hooks/start.ts', targets: ['example'] } },
     ignored: { mustNotReachTheModel: true },
-    plugin: { name: 'extension-fixture', version: '1.0.0' },
+    plugin: { name: 'extension-fixture' },
     scripts: {
       run: {
         entry: './packages/agent-bundle/src/config/normalize.ts',
@@ -148,10 +148,7 @@ it('normalizes registered extensions and validates registered script and hook ta
     targetRegistry: NormalizationTargetRegistry,
   ) => Diagnostic[];
 
-  // This fixture is rooted at the workspace, whose package.json version
-  // (0.0.0) differs from the fixture's plugin.version, so the AB4008
-  // mismatch warning (#94) is the only expected diagnostic.
-  expect(sourceValidator(loaded, { skills: [] }, extensionRegistry).map(({ code }) => code)).toEqual(['AB4008']);
+  expect(sourceValidator(loaded, { skills: [] }, extensionRegistry).map(({ code }) => code)).toEqual([]);
 
   const model = await normalizeProject(loaded, { skills: [] }, extensionRegistry);
   const extensions = (model as unknown as {
@@ -188,7 +185,7 @@ it('enumerates lsp components with unambiguous ids for any server name (#100)',
         '\uD800': { command: 'third-ls', extensionToLanguage: { '.c': 'c' } },
       },
     },
-    plugin: { name: 'claude-lsp-fixture', version: '1.0.0' },
+    plugin: { name: 'claude-lsp-fixture' },
     targets: ['claude', 'cursor'],
   }), { skills: [] }, createDefaultRegistry());
 
@@ -220,7 +217,7 @@ it('normalizes the typed Claude LSP source surface through the strict JSON exten
         },
       },
     },
-    plugin: { name: 'claude-lsp-fixture', version: '1.0.0' },
+    plugin: { name: 'claude-lsp-fixture' },
     targets: ['claude'],
   }), { skills: [] }, createDefaultRegistry());
   const extension = model.extensions.claude;
@@ -262,7 +259,7 @@ it('enumerates claude.bin relative to the config file into immutable executable
   const loaded: LoadedConfig = {
     ...loadedProject({
       claude: { bin: '../tools' },
-      plugin: { name: 'claude-bin-fixture', version: '1.0.0' },
+      plugin: { name: 'claude-bin-fixture' },
       targets: ['claude'],
     }, { root }),
     configPath: join(configDir, 'agent-bundle.config.ts'),
@@ -323,7 +320,7 @@ it('enumerates Claude workflows and output styles relative to the config file in
         outputStyles: '../styles',
         workflows: '../workflows',
       },
-      plugin: { name: 'claude-payload-fixture', version: '1.0.0' },
+      plugin: { name: 'claude-payload-fixture' },
       targets: ['claude'],
     }, { root }),
     configPath: join(configDir, 'agent-bundle.config.ts'),
@@ -372,7 +369,7 @@ it.each([
   if (create) await mkdir(binRoot, { recursive: true });
   const loaded = loadedProject({
     claude: { bin: './tools' },
-    plugin: { name: 'claude-bin-diagnostic', version: '1.0.0' },
+    plugin: { name: 'claude-bin-diagnostic' },
     targets: ['claude'],
   }, { root });
 
@@ -404,7 +401,7 @@ it.each([
   if (issue === 'empty') await mkdir(sourceRoot, { recursive: true });
   const loaded = loadedProject({
     claude: { [field]: './payload' },
-    plugin: { name: 'claude-payload-diagnostic', version: '1.0.0' },
+    plugin: { name: 'claude-payload-diagnostic' },
     targets: ['claude'],
   }, { root });
 
@@ -428,7 +425,7 @@ it('rejects a Claude payload directory symlink that resolves outside the project
   await symlink(outside, linked, 'dir');
   const loaded = loadedProject({
     claude: { outputStyles: './styles' },
-    plugin: { name: 'claude-payload-symlink', version: '1.0.0' },
+    plugin: { name: 'claude-payload-symlink' },
     targets: ['claude'],
   }, { root });
 
@@ -480,7 +477,7 @@ it('rejects non-JSON values in registered config extensions before normalization
   for (const value of values) {
     await expect(normalizeProject(loadedProject({
       example: value,
-      plugin: { name: 'extension-json-fixture', version: '1.0.0' },
+      plugin: { name: 'extension-json-fixture' },
     }), { skills: [] }, extensionRegistry)).rejects.toThrow(
       'AB4500: A registered config extension must contain strict finite JSON data.',
     );
@@ -503,7 +500,7 @@ it('detaches and freezes strict JSON extension values with special own keys and
   });
   const model = await normalizeProject(loadedProject({
     example: extension,
-    plugin: { name: 'extension-json-fixture', version: '1.0.0' },
+    plugin: { name: 'extension-json-fixture' },
   }), { skills: [] }, extensionRegistry);
   const value = model.extensions.example!.value as Record;
 
@@ -532,7 +529,7 @@ it('reports unknown hook and script targets through the target registry', () =>
   };
   const loaded = loadedProject({
     hooks: { stop: { handler: './hooks/stop.ts', targets: ['unknown'] } },
-    plugin: { name: 'registry-validation', version: '1.0.0' },
+    plugin: { name: 'registry-validation' },
     scripts: {
       run: {
         entry: './packages/agent-bundle/src/config/normalize.ts',
@@ -546,10 +543,7 @@ it('reports unknown hook and script targets through the target registry', () =>
     registry: NormalizationTargetRegistry,
   ) => Diagnostic[];
 
-  // The workspace root package.json version (0.0.0) differs from the
-  // fixture's plugin.version, adding the AB4008 mismatch warning (#94).
   expect(sourceValidator(loaded, { skills: [] }, targetRegistry).map(({ code }) => code)).toEqual([
-    'AB4008',
     'AB4203',
     'AB4406',
   ]);
@@ -563,7 +557,7 @@ it('validates native hook targets through the target registry', async () => {
     supports: (name, capability) => name === 'example' && capability === 'hooks',
   };
   const loaded = loadedProject({
-    plugin: { name: 'native-hook-targets', version: '1.0.0' },
+    plugin: { name: 'native-hook-targets' },
   });
   const model = await normalizeProject(loaded, { skills: [] }, targetRegistry);
   const nativeHooks = [
@@ -594,7 +588,7 @@ it('produces root-independent IDs, complete provenance, and deeply immutable out
   const leftRoot = '/workspace/left';
   const rightRoot = '/different/right';
   const config: AgentBundleConfig = {
-    plugin: { name: 'review-tools', version: '1.0.0' },
+    plugin: { name: 'review-tools' },
     skills: ['src/skills/review'],
   };
   const leftSkill = skill(leftRoot, 'review', 'review', {
@@ -656,7 +650,7 @@ it('reports stable source diagnostics for malformed and conflicting Skills', ()
     sourcePath: duplicate.source,
   });
   const loaded = loadedProject({
-    plugin: { name: 'review-tools', version: '1.0.0' },
+    plugin: { name: 'review-tools' },
   });
 
   const diagnostics = validateSource(loaded, { skills: [first, duplicate] }, registry);
@@ -681,7 +675,7 @@ it('maps pinned Agent Skills schema issues to stable source diagnostics without
   document.frontmatter.unknown = true;
 
   expect(validateSource(
-    loadedProject({ plugin: { name: 'review-tools', version: '1.0.0' } }),
+    loadedProject({ plugin: { name: 'review-tools' } }),
     { skills: [document] },
     registry,
   )).toEqual([
@@ -719,7 +713,7 @@ it('validates raw optional portable Skill fields before sanitizing the Skill IR'
   document.frontmatter.compatibility = false;
 
   expect(validateSource(
-    loadedProject({ plugin: { name: 'review-tools', version: '1.0.0' } }),
+    loadedProject({ plugin: { name: 'review-tools' } }),
     { skills: [document] },
     registry,
   )).toEqual([
@@ -735,8 +729,6 @@ it('validates raw optional portable Skill fields before sanitizing the Skill IR'
 it('diagnoses a missing plugin object instead of throwing', () => {
   const loaded = loadedProject({} as AgentBundleConfig);
 
-  // Only the name is required: an omitted version is inferred from
-  // package.json, so AB4001 fires on a declared-but-invalid value alone.
   expect(validateSource(loaded, { skills: [] }, registry)).toMatchObject([
     { code: 'AB4000', sourcePath: loaded.configPath },
   ]);
@@ -761,7 +753,7 @@ it('validates reference-style links while ignoring Markdown code examples', () =
   });
 
   const diagnostics = validateSource(
-    loadedProject({ plugin: { name: 'review-tools', version: '1.0.0' } }),
+    loadedProject({ plugin: { name: 'review-tools' } }),
     { skills: [document] },
     registry,
   );
@@ -783,7 +775,7 @@ it('accepts decoded inline Skill resource paths that contain spaces', () => {
   });
 
   expect(validateSource(
-    loadedProject({ plugin: { name: 'review-tools', version: '1.0.0' } }),
+    loadedProject({ plugin: { name: 'review-tools' } }),
     { skills: [document] },
     registry,
   )).toEqual([]);
@@ -805,7 +797,7 @@ it('uses the first definition when validating shortcut Markdown references', ()
     resources: ['SKILL.md', 'references/existing.md'],
   });
   const loaded = loadedProject({
-    plugin: { name: 'review-tools', version: '1.0.0' },
+    plugin: { name: 'review-tools' },
   });
 
   expect(validateSource(loaded, { skills: [missing] }, registry)).toMatchObject([
@@ -832,7 +824,7 @@ it('does not treat definitions as uses and reserves an external first definition
     ].join('\n'),
   });
   const loaded = loadedProject({
-    plugin: { name: 'review-tools', version: '1.0.0' },
+    plugin: { name: 'review-tools' },
   });
 
   expect(validateSource(loaded, { skills: [unusedDefinition] }, registry)).toEqual([]);
@@ -849,7 +841,7 @@ it('normalizes explicit host-native hook tool selectors alongside canonical tool
           tools: ['shell', 'codex:view_image', 'claude:WebSearch', 'claude:WebSearch', 'portable:Nope'],
         },
       },
-      plugin: { name: 'review-tools', version: '1.0.0' },
+      plugin: { name: 'review-tools' },
       targets: ['claude', 'codex'],
     }),
     { skills: [] },
@@ -865,7 +857,7 @@ it('normalizes explicit host-native hook tool selectors alongside canonical tool
   const withoutSelectors = await normalizeProject(
     loadedProject({
       hooks: { beforeTool: { handler: './hooks/guard.ts', tools: ['shell'] } },
-      plugin: { name: 'review-tools', version: '1.0.0' },
+      plugin: { name: 'review-tools' },
       targets: ['claude', 'codex'],
     }),
     { skills: [] },
@@ -880,7 +872,7 @@ it('validates host-native hook tool selectors against the registry and hook targ
     validateSource(
       loadedProject({
         hooks: { beforeTool: { handler: './hooks/guard.ts', ...(targets === undefined ? {} : { targets: [...targets] }), tools: [...tools] } },
-        plugin: { name: 'review-tools', version: '1.0.0' },
+        plugin: { name: 'review-tools' },
       }),
       { skills: [] },
       registry,
@@ -890,7 +882,7 @@ it('validates host-native hook tool selectors against the registry and hook targ
   expect(validateSource(
     loadedProject({
       hooks: { beforeTool: { handler: './hooks/guard.ts', tools: ['claude:WebSearch'] } },
-      plugin: { name: 'review-tools', version: '1.0.0' },
+      plugin: { name: 'review-tools' },
       targets: ['codex'],
     }),
     { skills: [] },
@@ -905,7 +897,7 @@ it('validates host-native hook tool selectors against the registry and hook targ
 
 it('normalizes output.sourceMap as a generated-executable opt-in', async () => {
   const defaulted = await normalizeProject(
-    loadedProject({ plugin: { name: 'review-tools', version: '1.0.0' } }),
+    loadedProject({ plugin: { name: 'review-tools' } }),
     { skills: [] },
     registry,
   );
@@ -914,7 +906,7 @@ it('normalizes output.sourceMap as a generated-executable opt-in', async () => {
   const optedIn = await normalizeProject(
     loadedProject({
       output: { sourceMap: true },
-      plugin: { name: 'review-tools', version: '1.0.0' },
+      plugin: { name: 'review-tools' },
     }),
     { skills: [] },
     registry,
@@ -924,7 +916,7 @@ it('normalizes output.sourceMap as a generated-executable opt-in', async () => {
   const optedOut = await normalizeProject(
     loadedProject({
       output: { distPath: 'artifact', sourceMap: false },
-      plugin: { name: 'review-tools', version: '1.0.0' },
+      plugin: { name: 'review-tools' },
     }),
     { skills: [] },
     registry,
@@ -934,7 +926,7 @@ it('normalizes output.sourceMap as a generated-executable opt-in', async () => {
 
 it('normalizes the generated-executable runtime floor and validates raises only', async () => {
   const defaulted = await normalizeProject(
-    loadedProject({ plugin: { name: 'review-tools', version: '1.0.0' } }),
+    loadedProject({ plugin: { name: 'review-tools' } }),
     { skills: [] },
     registry,
   );
@@ -942,7 +934,7 @@ it('normalizes the generated-executable runtime floor and validates raises only'
 
   const raised = await normalizeProject(
     loadedProject({
-      plugin: { name: 'review-tools', version: '1.0.0' },
+      plugin: { name: 'review-tools' },
       runtime: { node: '24.1' },
     }),
     { skills: [] },
@@ -952,7 +944,7 @@ it('normalizes the generated-executable runtime floor and validates raises only'
 
   const diagnosticsFor = (runtime: unknown): readonly string[] =>
     validateSource(
-      loadedProject({ plugin: { name: 'review-tools', version: '1.0.0' }, runtime } as AgentBundleConfig),
+      loadedProject({ plugin: { name: 'review-tools' }, runtime } as AgentBundleConfig),
       { skills: [] },
       registry,
     ).map(({ code }) => code);
@@ -973,7 +965,7 @@ it('normalizes the generated-executable runtime floor and validates raises only'
 it('reports unknown targets, duplicate IDs, and portable output collisions', async () => {
   const root = '/workspace/project';
   const loaded = loadedProject({
-    plugin: { name: 'review-tools', version: '1.0.0' },
+    plugin: { name: 'review-tools' },
     targets: ['portable', 'future-host'],
   });
   const model = await normalizeProject(
@@ -1010,7 +1002,7 @@ it('rejects the retired plugin target as unknown (#555 acceptance 3)', async ()
   // The composite root is the only output shape; `plugin` is not a target
   // that selects anything inside it, so it fails like any other unknown name.
   const model = await normalizeProject(loadedProject({
-    plugin: { name: 'review-tools', version: '1.0.0' },
+    plugin: { name: 'review-tools' },
     targets: ['plugin'],
   }), { skills: [] }, registry);
 
@@ -1032,7 +1024,7 @@ it('normalizes discovered assets with stable IDs, provenance, and all selected t
     skills: [],
   };
   const conventional = await normalizeProject(
-    loadedProject({ plugin: { name: 'review-tools', version: '1.0.0' }, targets: ['portable', 'claude'] }),
+    loadedProject({ plugin: { name: 'review-tools' }, targets: ['portable', 'claude'] }),
     discovered,
     registry,
   );
@@ -1059,14 +1051,14 @@ it('normalizes discovered assets with stable IDs, provenance, and all selected t
   ]);
 
   const explicit = await normalizeProject(
-    loadedProject({ assets: ['assets/logo.svg'], plugin: { name: 'review-tools', version: '1.0.0' } }),
+    loadedProject({ assets: ['assets/logo.svg'], plugin: { name: 'review-tools' } }),
     discovered,
     registry,
   );
   expect(explicit.assets?.every((asset) => asset.provenance.kind === 'explicit')).toBe(true);
 
   const withoutAssets = await normalizeProject(
-    loadedProject({ plugin: { name: 'review-tools', version: '1.0.0' } }),
+    loadedProject({ plugin: { name: 'review-tools' } }),
     { skills: [] },
     registry,
   );
@@ -1075,7 +1067,7 @@ it('normalizes discovered assets with stable IDs, provenance, and all selected t
 
 it('reports duplicate asset destinations as duplicate IDs and output collisions', async () => {
   const model = await normalizeProject(
-    loadedProject({ plugin: { name: 'review-tools', version: '1.0.0' }, targets: ['portable'] }),
+    loadedProject({ plugin: { name: 'review-tools' }, targets: ['portable'] }),
     {
       assets: [
         { bytes: 6, relativePath: 'logo.svg', source: '/workspace/project/assets/logo.svg' },
@@ -1099,7 +1091,7 @@ it('validates the assets configuration shape, containment, and literal existence
   const diagnosticsFor = (assets: unknown, root?: string): readonly string[] =>
     validateSource(
       loadedProject(
-        { assets, plugin: { name: 'review-tools', version: '1.0.0' } } as AgentBundleConfig,
+        { assets, plugin: { name: 'review-tools' } } as AgentBundleConfig,
         root === undefined ? {} : { root },
       ),
       { skills: [] },
@@ -1110,12 +1102,10 @@ it('validates the assets configuration shape, containment, and literal existence
   expect(diagnosticsFor('assets')).toEqual(['AB4600']);
   expect(diagnosticsFor([''])).toEqual(['AB4600']);
   expect(diagnosticsFor([42])).toEqual(['AB4600']);
-  // Fixtures rooted at the workspace also report the AB4008
-  // plugin.version/package version mismatch warning (#94).
-  expect(diagnosticsFor(['../outside'], process.cwd())).toEqual(['AB4601', 'AB4008']);
-  expect(diagnosticsFor(['definitely-missing-asset-entry'], process.cwd())).toEqual(['AB4602', 'AB4008']);
-  expect(diagnosticsFor(['definitely-missing/*.svg'], process.cwd())).toEqual(['AB4008']);
-  expect(diagnosticsFor(['package.json'], process.cwd())).toEqual(['AB4008']);
+  expect(diagnosticsFor(['../outside'], process.cwd())).toEqual(['AB4601']);
+  expect(diagnosticsFor(['definitely-missing-asset-entry'], process.cwd())).toEqual(['AB4602']);
+  expect(diagnosticsFor(['definitely-missing/*.svg'], process.cwd())).toEqual([]);
+  expect(diagnosticsFor(['package.json'], process.cwd())).toEqual([]);
 });
 
 const scriptRouteFixture = (root: string, relativePath: string): CompiledAgentRoute => {
@@ -1145,7 +1135,7 @@ const routeGraphWithScripts = (root: string, relativePaths: readonly string[]):
 it('normalizes shippable conventional script routes through the explicit scripts pipeline', async () => {
   const root = '/workspace/project';
   const loaded = loadedProject({
-    plugin: { name: 'review-tools', version: '1.0.0' },
+    plugin: { name: 'review-tools' },
     scripts: { 'detect-risk': { entry: './src/tasks/detect-risk.ts', targets: ['claude'] } },
   });
   const discovered: DiscoveredProject = {
@@ -1193,7 +1183,7 @@ it('normalizes shippable conventional script routes through the explicit scripts
 it('normalizes conventional scripts when config declares none', async () => {
   const root = '/workspace/project';
   const model = await normalizeProject(
-    loadedProject({ plugin: { name: 'review-tools', version: '1.0.0' } }),
+    loadedProject({ plugin: { name: 'review-tools' } }),
     { routeGraph: routeGraphWithScripts(root, ['src/scripts/verify-release.ts']), skills: [] },
     registry,
   );
@@ -1213,7 +1203,7 @@ it('normalizes conventional scripts when config declares none', async () => {
 it('gates nested and conflicting conventional script routes as AB4808/AB4809 and ships rendered ones', () => {
   const root = '/workspace/project';
   const loaded = loadedProject({
-    plugin: { name: 'review-tools', version: '1.0.0' },
+    plugin: { name: 'review-tools' },
     scripts: { 'detect-risk': './src/tasks/detect-risk.ts' },
   });
   const discovered: DiscoveredProject = {
@@ -1258,7 +1248,7 @@ it('ships a bin-claimed plain conventional script as both surfaces and refuses a
       notes: { entry: './src/scripts/render-notes.tsx' },
       'notes-again': './src/scripts/render-notes.tsx',
     },
-    plugin: { name: 'review-tools', version: '1.0.0' },
+    plugin: { name: 'review-tools' },
   });
   const discovered: DiscoveredProject = {
     routeGraph: routeGraphWithScripts(root, ['src/scripts/hauler.ts', 'src/scripts/render-notes.tsx']),
@@ -1286,7 +1276,7 @@ it('ships a bin-claimed plain conventional script as both surfaces and refuses a
 it('normalizes rendered conventional script routes onto the renderer pipeline (#102 stage 3)', async () => {
   const root = '/workspace/project';
   const model = await normalizeProject(
-    loadedProject({ plugin: { name: 'review-tools', version: '1.0.0' } }),
+    loadedProject({ plugin: { name: 'review-tools' } }),
     { routeGraph: routeGraphWithScripts(root, ['src/scripts/render-notes.tsx']), skills: [] },
     registry,
   );
@@ -1307,7 +1297,7 @@ it('normalizes rendered conventional script routes onto the renderer pipeline (#
 it('keeps shippable conventional script routes free of stage-1 gate diagnostics', () => {
   const root = '/workspace/project';
   const diagnostics = validateSource(
-    loadedProject({ plugin: { name: 'review-tools', version: '1.0.0' } }),
+    loadedProject({ plugin: { name: 'review-tools' } }),
     { routeGraph: routeGraphWithScripts(root, ['src/scripts/verify-release.ts']), skills: [] },
     registry,
   );
@@ -1369,7 +1359,7 @@ it('carries event-route handler provenance and provider selection into normalize
 
   const model = await normalizeProject(
     loadedProject({
-      plugin: { name: 'review-tools', version: '1.0.0' },
+      plugin: { name: 'review-tools' },
       targets: ['claude'],
     }),
     { routeGraph, skills: [] },
@@ -1394,7 +1384,7 @@ it('normalizes generated MCP route servers without a handwritten server declarat
   const root = '/workspace/project';
   const graph = routeGraphWithGeneratedServer(root);
   const model = await normalizeProject(
-    loadedProject({ plugin: { name: 'review-tools', version: '1.0.0' } }),
+    loadedProject({ plugin: { name: 'review-tools' } }),
     { routeGraph: graph, skills: [] },
     registry,
   );
diff --git a/packages/agent-bundle/tests/notice-retention-config.test.ts b/packages/agent-bundle/tests/notice-retention-config.test.ts
index e94e36a3b..d7c4ad297 100644
--- a/packages/agent-bundle/tests/notice-retention-config.test.ts
+++ b/packages/agent-bundle/tests/notice-retention-config.test.ts
@@ -11,7 +11,7 @@ import type { AgentBundleConfig } from '../src/core/types.ts';
 
 const config = (notices: unknown): AgentBundleConfig => ({
   notices,
-  plugin: { name: 'fixture', version: '1.0.0' },
+  plugin: { name: 'fixture' },
 } as AgentBundleConfig);
 
 describe('notices.retention config (AB4833)', () => {
@@ -50,7 +50,7 @@ describe('notices.retention config (AB4833)', () => {
     expect(Object.isFrozen(result.retention)).toBe(true);
     expect(Object.isFrozen(result.retention?.resolved)).toBe(true);
     // No config at all, or `notices: {}`, means the runtime defaults and nothing to report.
-    expect(normalizeNoticeRetention({ plugin: { name: 'f', version: '1.0.0' } }, '/p/c.ts', true)).toEqual({ diagnostics: [] });
+    expect(normalizeNoticeRetention({ plugin: { name: 'f' } }, '/p/c.ts', true)).toEqual({ diagnostics: [] });
     expect(normalizeNoticeRetention(config({}), '/p/c.ts', false)).toEqual({ diagnostics: [] });
   });
 
diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts
index 6da897c99..348797a2c 100644
--- a/packages/agent-bundle/tests/package-build.test.ts
+++ b/packages/agent-bundle/tests/package-build.test.ts
@@ -45,12 +45,12 @@ const conventionFixture = (): Readonly> => ({
   'agent-bundle.config.ts': [
     'export default {',
     "  mcp: { servers: { echoer: {} } },",
-    "  plugin: { name: 'package-build-fixture', version: '1.0.0' },",
+    "  plugin: { name: 'package-build-fixture' },",
     "  targets: ['portable'],",
     '};',
     '',
   ].join('\n'),
-  'package.json': '{"name":"package-build-fixture","type":"module","private":true}\n',
+  'package.json': '{"name":"package-build-fixture","type":"module","private":true,"version":"1.0.0"}\n',
   'node_modules/evidence-package/index.js': 'globalThis.__evidencePackageLoaded = true;\n',
   'node_modules/evidence-package/package.json': '{"name":"evidence-package","type":"module","version":"1.0.0"}\n',
   'tsconfig.json': JSON.stringify({
@@ -130,7 +130,7 @@ describe('framework-owned package build', () => {
     const root = await fixtureRoot({
       ...conventionFixture(),
       'AGENTS.md': '# Authored repository instructions\n',
-      'package.json': '{"name":"package-build-fixture","type":"module","private":true,"imports":{"#fixture":"./dist/index.js"},"scripts":{"prepack":"agent-bundle prepack","test":"rstest"}}\n',
+      'package.json': '{"name":"package-build-fixture","type":"module","private":true,"version":"1.0.0","imports":{"#fixture":"./dist/index.js"},"scripts":{"prepack":"agent-bundle prepack","test":"rstest"}}\n',
     });
     await installTypescriptToolchain(root);
     const result = await build({ output: 'artifact', packageOutputs: true, root });
@@ -195,6 +195,7 @@ describe('framework-owned package build', () => {
         private: true,
         scripts: { postinstall: 'node setup.js' },
         type: 'module',
+        version: '1.0.0',
       }),
     });
     await installTypescriptToolchain(root);
@@ -213,7 +214,7 @@ describe('framework-owned package build', () => {
       'agent-bundle.config.ts': [
         'export default {',
         "  bin: { hauler: './src/scripts/hauler.ts' },",
-        "  plugin: { name: 'package-build-fixture', version: '1.0.0' },",
+        "  plugin: { name: 'package-build-fixture' },",
         "  targets: ['portable'],",
         '};',
         '',
@@ -224,6 +225,7 @@ describe('framework-owned package build', () => {
         private: true,
         scripts: { postinstall: 'node ./dist/bin/hauler.js' },
         type: 'module',
+        version: '1.0.0',
       }),
       'src/scripts/hauler.ts': [
         'export const main = async (argv: readonly string[]): Promise => {',
@@ -267,7 +269,7 @@ describe('framework-owned package build', () => {
         '    echoer: {},',
         "    plain: { entry: './src/plain.ts' },",
         '  } },',
-        "  plugin: { name: 'package-build-fixture', version: '1.0.0' },",
+        "  plugin: { name: 'package-build-fixture' },",
         "  targets: ['portable'],",
         '};',
         '',
@@ -299,7 +301,7 @@ describe('framework-owned package build', () => {
         'let sawAsyncChunks;',
         'export default {',
         '  lib: false,',
-        "  plugin: { name: 'package-build-fixture', version: '1.0.0' },",
+        "  plugin: { name: 'package-build-fixture' },",
         "  targets: ['portable'],",
         '  tools: {',
         "    rsbuild: { source: { define: { __HATCH_DEFINE__: JSON.stringify('hatched') } } },",
@@ -336,7 +338,7 @@ describe('framework-owned package build', () => {
         'export default {',
         "  lib: { entry: './src/index.ts', dts: false },",
         "  mcp: { servers: { echoer: {} } },",
-        "  plugin: { name: 'package-build-fixture', version: '1.0.0' },",
+        "  plugin: { name: 'package-build-fixture' },",
         "  targets: ['portable'],",
         "  tools: { rsbuild: { output: { externals: ['left-pad', 'right-pad'] } } },",
         '};',
@@ -377,7 +379,7 @@ describe('framework-owned package build', () => {
         'export default {',
         "  lib: { entry: './src/index.ts', dts: false },",
         "  mcp: { servers: { echoer: {} } },",
-        "  plugin: { name: 'package-build-fixture', version: '1.0.0' },",
+        "  plugin: { name: 'package-build-fixture' },",
         "  targets: ['portable'],",
         rspackExternalsMutator(['left-pad', 'right-pad']),
         '};',
@@ -418,7 +420,7 @@ describe('framework-owned package build', () => {
         'export default {',
         '  lib: false,',
         "  mcp: { servers: { echoer: {} } },",
-        "  plugin: { name: 'package-build-fixture', version: '1.0.0' },",
+        "  plugin: { name: 'package-build-fixture' },",
         "  targets: ['portable'],",
         '  tools: {',
         '    rspack: (config) => {',
@@ -457,7 +459,7 @@ describe('framework-owned package build', () => {
         'export default {',
         '  lib: false,',
         "  mcp: { servers: { echoer: {} } },",
-        "  plugin: { name: 'package-build-fixture', version: '1.0.0' },",
+        "  plugin: { name: 'package-build-fixture' },",
         "  targets: ['portable'],",
         rspackExternalsMutator(['left-pad'], ["      config.externalsType = 'node-commonjs';"]),
         '};',
@@ -489,7 +491,7 @@ describe('framework-owned package build', () => {
         'export default {',
         '  lib: false,',
         "  mcp: { servers: { echoer: {} } },",
-        "  plugin: { name: 'package-build-fixture', version: '1.0.0' },",
+        "  plugin: { name: 'package-build-fixture' },",
         "  targets: ['portable'],",
         '};',
         '',
@@ -534,7 +536,7 @@ describe('framework-owned package build', () => {
         'export default {',
         "  lib: { entry: './src/index.ts', dts: false },",
         "  mcp: { servers: { echoer: {} } },",
-        "  plugin: { name: 'package-build-fixture', version: '1.0.0' },",
+        "  plugin: { name: 'package-build-fixture' },",
         "  targets: ['portable'],",
         '};',
         '',
@@ -579,7 +581,7 @@ describe('framework-owned package build', () => {
         'export default {',
         '  lib: false,',
         "  mcp: { servers: { echoer: {} } },",
-        "  plugin: { name: 'package-build-fixture', version: '1.0.0' },",
+        "  plugin: { name: 'package-build-fixture' },",
         "  targets: ['portable'],",
         '};',
         '',
@@ -614,7 +616,7 @@ describe('framework-owned package build', () => {
         'export default {',
         '  lib: false,',
         "  mcp: { servers: { echoer: {} } },",
-        "  plugin: { name: 'package-build-fixture', version: '1.0.0' },",
+        "  plugin: { name: 'package-build-fixture' },",
         "  targets: ['portable'],",
         '};',
         '',
@@ -755,7 +757,7 @@ describe('mcp run', () => {
         "    SHARED: 'manifest',",
         "    STATE_DIR: 'agent-bundle:path:plugin-root/.runtime',",
         '  } } } },',
-        "  plugin: { name: 'package-build-fixture', version: '1.0.0' },",
+        "  plugin: { name: 'package-build-fixture' },",
         "  targets: ['codex', 'portable'],",
         '};',
         '',
@@ -850,12 +852,12 @@ describe('mcp run', () => {
       'agent-bundle.config.ts': [
         'export default {',
         "  mcp: { servers: { pinner: { entry: './src/pin.ts' } } },",
-        "  plugin: { name: 'package-build-fixture', version: '1.0.0' },",
+        "  plugin: { name: 'package-build-fixture' },",
         "  targets: ['portable'],",
         '};',
         '',
       ].join('\n'),
-      'package.json': '{"name":"package-build-fixture","type":"module","private":true}\n',
+      'package.json': '{"name":"package-build-fixture","type":"module","private":true,"version":"1.0.0"}\n',
       '.env': 'MCP_RUN_TRACKER_COOKIE=secret\n',
       // A consumer trusting the documented anchor exactly as PR #49 intends.
       'src/pin.ts': [
@@ -904,12 +906,12 @@ describe('mcp run', () => {
       'agent-bundle.config.ts': [
         'export default {',
         "  mcp: { servers: { exiter: { entry: './src/exit.ts' } } },",
-        "  plugin: { name: 'package-build-fixture', version: '1.0.0' },",
+        "  plugin: { name: 'package-build-fixture' },",
         "  targets: ['portable'],",
         '};',
         '',
       ].join('\n'),
-      'package.json': '{"name":"package-build-fixture","type":"module","private":true}\n',
+      'package.json': '{"name":"package-build-fixture","type":"module","private":true,"version":"1.0.0"}\n',
       'src/exit.ts': 'process.exitCode = 7;\nexport const marker = true;\n',
     });
     await build({ output: 'artifact', root });
@@ -940,12 +942,12 @@ describe('mcp run', () => {
         "    transport: 'streamable-http',",
         "    url: 'https://mcp.example.test/stream',",
         '  } } },',
-        "  plugin: { name: 'package-build-fixture', version: '1.0.0' },",
+        "  plugin: { name: 'package-build-fixture' },",
         "  targets: ['portable'],",
         '};',
         '',
       ].join('\n'),
-      'package.json': '{"name":"package-build-fixture","type":"module","private":true}\n',
+      'package.json': '{"name":"package-build-fixture","type":"module","private":true,"version":"1.0.0"}\n',
     });
     await build({ output: 'artifact', root });
     await expect(runMcp({
diff --git a/packages/agent-bundle/tests/package-conventions.test.ts b/packages/agent-bundle/tests/package-conventions.test.ts
index 4fedf1c8a..7129cebd2 100644
--- a/packages/agent-bundle/tests/package-conventions.test.ts
+++ b/packages/agent-bundle/tests/package-conventions.test.ts
@@ -59,7 +59,7 @@ describe('package build normalization', () => {
     const model = await normalizeProject(loadedProject({
       bin: { 'my-tool': './src/entry.ts', other: { entry: './src/entry.ts' } },
       lib: { dts: false, entry: './src/library.ts' },
-      plugin: { name: 'review-tools', version: '1.0.0' },
+      plugin: { name: 'review-tools' },
     }, root), { skills: [] }, registry);
 
     expect(model.packageBuild).toEqual({
@@ -91,7 +91,7 @@ describe('package build normalization', () => {
   it('defaults lib dts to true for the string form', async () => {
     const root = await projectRoot({ 'src/index.ts': 'export const a = 1;\n' });
     const packageBuild = normalizePackageBuild(
-      { lib: './src/index.ts', plugin: { name: 'p', version: '1.0.0' } },
+      { lib: './src/index.ts', plugin: { name: 'p' } },
       root,
       `${root}/agent-bundle.config.ts`,
     );
@@ -104,7 +104,7 @@ describe('package build normalization', () => {
       'src/index.ts': 'export const answer = 42;\n',
     });
     const model = await normalizeProject(loadedProject({
-      plugin: { name: 'review-tools', version: '1.0.0' },
+      plugin: { name: 'review-tools' },
     }, root), { skills: [] }, registry);
 
     expect(model.packageBuild).toEqual({
@@ -134,7 +134,7 @@ describe('package build normalization', () => {
     const explicit = await normalizeProject(loadedProject({
       bin: { renamed: './src/other.ts' },
       lib: false,
-      plugin: { name: 'review-tools', version: '1.0.0' },
+      plugin: { name: 'review-tools' },
     }, root), { skills: [] }, registry);
     expect(explicit.packageBuild?.bins.map((bin) => bin.name)).toEqual(['renamed']);
     expect(explicit.packageBuild?.lib).toBeUndefined();
@@ -142,7 +142,7 @@ describe('package build normalization', () => {
     const disabled = await normalizeProject(loadedProject({
       bin: false,
       lib: false,
-      plugin: { name: 'review-tools', version: '1.0.0' },
+      plugin: { name: 'review-tools' },
     }, root), { skills: [] }, registry);
     expect(disabled.packageBuild).toBeUndefined();
   });
@@ -150,7 +150,7 @@ describe('package build normalization', () => {
   it('skips the bin convention when the plugin name is not a safe output name', async () => {
     const root = await projectRoot({ 'src/cli.ts': 'export const main = async () => 0;\n' });
     const model = await normalizeProject(loadedProject({
-      plugin: { name: '@scope/unsafe name', version: '1.0.0' },
+      plugin: { name: '@scope/unsafe name' },
     }, root), { skills: [] }, registry);
     expect(model.packageBuild).toBeUndefined();
   });
@@ -158,7 +158,7 @@ describe('package build normalization', () => {
   it('leaves projects without package entries untouched', async () => {
     const root = await projectRoot({});
     const model = await normalizeProject(loadedProject({
-      plugin: { name: 'plain', version: '1.0.0' },
+      plugin: { name: 'plain' },
     }, root), { skills: [] }, registry);
     expect(model.packageBuild).toBeUndefined();
   });
@@ -171,7 +171,7 @@ describe('conventional MCP entries', () => {
     });
     const loaded = loadedProject({
       mcp: { servers: { curator: {} } },
-      plugin: { name: 'review-tools', version: '1.0.0' },
+      plugin: { name: 'review-tools' },
     }, root);
 
     expect(validateSource(loaded, { skills: [] }, registry)).toEqual([]);
@@ -196,7 +196,7 @@ describe('conventional MCP entries', () => {
     });
     const model = await normalizeProject(loadedProject({
       mcp: { servers: { curator: { entry: './src/explicit.ts' } } },
-      plugin: { name: 'review-tools', version: '1.0.0' },
+      plugin: { name: 'review-tools' },
     }, root), { skills: [] }, registry);
     expect(model.mcpServers[0]).toMatchObject({
       provenance: { kind: 'config' },
@@ -208,7 +208,7 @@ describe('conventional MCP entries', () => {
     const root = await projectRoot({});
     const diagnostics = validateSource(loadedProject({
       mcp: { servers: { curator: {} } },
-      plugin: { name: 'review-tools', version: '1.0.0' },
+      plugin: { name: 'review-tools' },
     }, root), { skills: [] }, registry);
     expect(diagnostics).toEqual([expect.objectContaining({
       code: 'AB4304',
@@ -225,7 +225,7 @@ describe('bin, lib, and tools validation', () => {
     const root = await projectRoot(files);
     return validateSource(loadedProject({
       ...config,
-      plugin: { name: 'review-tools', version: '1.0.0' },
+      plugin: { name: 'review-tools' },
     }, root), { skills: [] }, registry);
   };
 
@@ -460,7 +460,7 @@ describe('artifact output validation', () => {
     const root = await projectRoot({});
     return validateSource(loadedProject({
       output: output as never,
-      plugin: { name: 'review-tools', version: '1.0.0' },
+      plugin: { name: 'review-tools' },
     }, root), { skills: [] }, registry);
   };
 
@@ -525,7 +525,7 @@ describe('migration nudges (AB473x)', () => {
     return {
       diagnostics: validateSource(loadedProject({
         ...config,
-        plugin: { name: 'review-tools', version: '1.0.0' },
+        plugin: { name: 'review-tools' },
       }, root), { skills: [] }, registry),
       root,
     };
@@ -680,7 +680,7 @@ describe('migration nudges (AB473x)', () => {
     const root = await projectRoot(files);
     const loaded = loadedProject({
       ...config,
-      plugin: { name: 'review-tools', version: '1.0.0' },
+      plugin: { name: 'review-tools' },
     }, root);
     const discovered = await discoverProject(root, loaded.config);
     return { diagnostics: validateSource(loaded, discovered, registry), root };
@@ -743,7 +743,7 @@ describe('migration nudges (AB473x)', () => {
     });
 
     const discovered = await discoverProject(root, {
-      plugin: { name: 'review-tools', version: '1.0.0' },
+      plugin: { name: 'review-tools' },
     });
 
     expect(discovered.skills).toMatchObject([{
diff --git a/packages/agent-bundle/tests/package-identity.test.ts b/packages/agent-bundle/tests/package-identity.test.ts
index 7ca18451d..e6b062a69 100644
--- a/packages/agent-bundle/tests/package-identity.test.ts
+++ b/packages/agent-bundle/tests/package-identity.test.ts
@@ -25,20 +25,12 @@ const registry: NormalizationTargetRegistry = {
   supports: () => false,
 };
 
-/** `undeclared` omits `plugin.version` the way an inference-era config does. */
-const undeclared = Symbol('undeclared plugin.version');
-
-const config = (version: string | typeof undeclared = '1.0.0'): AgentBundleConfig => ({
-  plugin: version === undeclared
-    ? { name: 'identity-fixture' }
-    : { name: 'identity-fixture', version },
-});
+const config = (): AgentBundleConfig => ({ plugin: { name: 'identity-fixture' } });
 
 const loadedProject = (
   root: string,
-  pluginVersion: string | typeof undeclared = '1.0.0',
 ): LoadedConfig => ({
-  config: config(pluginVersion),
+  config: config(),
   configPath: join(root, 'agent-bundle.config.ts'),
   context: {
     command: 'build',
@@ -124,7 +116,7 @@ it('labels the development fallback distinctly from a release version', () => {
 
 it('carries the derived axes through the normalized model into the project context', async () => {
   await withProject(JSON.stringify({ name: '@scope/pkg', version: '2.3.4' }), async (root) => {
-    const loaded = loadedProject(root, '2.3.4');
+    const loaded = loadedProject(root);
     const model = await normalizeProject(loaded, { skills: [] }, registry);
     expect(model.metadata).toMatchObject({ packageName: '@scope/pkg', packageVersion: '2.3.4', version: '2.3.4' });
 
@@ -161,31 +153,6 @@ it('omits both axes from the model and context for unpackaged projects', async (
   });
 });
 
-it('warns with AB4008 when plugin.version differs from the package version', async () => {
-  await withProject(JSON.stringify({ name: '@scope/pkg', version: '2.0.0' }), async (root) => {
-    const diagnostics = validateSource(loadedProject(root, '1.0.0'), { skills: [] }, registry);
-    expect(diagnostics).toMatchObject([{
-      code: 'AB4008',
-      severity: 'warning',
-      sourcePath: join(root, 'agent-bundle.config.ts'),
-    }]);
-    expect(diagnostics[0]!.message).toContain('"1.0.0"');
-    expect(diagnostics[0]!.message).toContain('"2.0.0"');
-  });
-});
-
-it('stays silent when plugin.version matches the package version or no package version exists', async () => {
-  await withProject(JSON.stringify({ name: '@scope/pkg', version: '1.0.0' }), async (root) => {
-    expect(validateSource(loadedProject(root, '1.0.0'), { skills: [] }, registry)).toEqual([]);
-  });
-  await withProject(JSON.stringify({ name: '@scope/pkg' }), async (root) => {
-    expect(validateSource(loadedProject(root, '1.0.0'), { skills: [] }, registry)).toEqual([]);
-  });
-  await withProject(undefined, async (root) => {
-    expect(validateSource(loadedProject(root, '1.0.0'), { skills: [] }, registry)).toEqual([]);
-  });
-});
-
 it('warns with AB4009/AB4010/AB4011 for invalid package identity values', async () => {
   await withProject(JSON.stringify({ name: 'Not Valid!', version: 'one.two' }), async (root) => {
     expect(validateSource(loadedProject(root), { skills: [] }, registry)).toMatchObject([
@@ -218,7 +185,7 @@ it('ignores a package.json symlinked outside the project root', async () => {
 
 it('infers the plugin version from package.json when the config omits it', async () => {
   await withProject(JSON.stringify({ name: '@scope/pkg', version: '2.3.4' }), async (root) => {
-    const loaded = loadedProject(root, undeclared);
+    const loaded = loadedProject(root);
     expect(validateSource(loaded, { skills: [] }, registry, { release: true })).toEqual([]);
 
     const model = await normalizeProject(loaded, { skills: [] }, registry);
@@ -230,20 +197,29 @@ it('infers the plugin version from package.json when the config omits it', async
   });
 });
 
-it('rejects a declared plugin.version that is not a nonempty string', async () => {
+it('rejects the removed plugin.version key instead of silently ignoring it', async () => {
   await withProject(JSON.stringify({ name: '@scope/pkg', version: '2.3.4' }), async (root) => {
-    for (const declared of ['', '   ', 3 as unknown as string]) {
-      const loaded = loadedProject(root, declared);
-      expect(validateSource(loaded, { skills: [] }, registry)).toMatchObject([
-        { code: 'AB4001', severity: 'error', sourcePath: join(root, 'agent-bundle.config.ts') },
-      ]);
-    }
+    const loaded = loadedProject(root);
+    const withRemovedVersion: LoadedConfig = {
+      ...loaded,
+      config: {
+        plugin: { name: 'identity-fixture', version: '9.9.9' },
+      } as unknown as AgentBundleConfig,
+    };
+    expect(validateSource(withRemovedVersion, { skills: [] }, registry)).toMatchObject([
+      {
+        code: 'AB4001',
+        message: 'Plugin metadata no longer accepts version; declare the release version in package.json.',
+        severity: 'error',
+        sourcePath: loaded.configPath,
+      },
+    ]);
   });
 });
 
 it('keeps the development fallback for an unpackaged project with no declared version', async () => {
   await withProject(undefined, async (root) => {
-    const loaded = loadedProject(root, undeclared);
+    const loaded = loadedProject(root);
     // Development preparation reports nothing: this is a normal dev state.
     expect(validateSource(loaded, { skills: [] }, registry)).toEqual([]);
 
@@ -254,7 +230,7 @@ it('keeps the development fallback for an unpackaged project with no declared ve
 
 it('fails a release build closed when no source declares a release version', async () => {
   const releaseDiagnostics = (root: string) =>
-    validateSource(loadedProject(root, undeclared), { skills: [] }, registry, { release: true });
+    validateSource(loadedProject(root), { skills: [] }, registry, { release: true });
 
   await withProject(undefined, async (root) => {
     expect(releaseDiagnostics(root)).toMatchObject([
@@ -269,11 +245,6 @@ it('fails a release build closed when no source declares a release version', asy
       { code: 'AB4013', severity: 'error' },
     ]);
   });
-  // A declared plugin.version is a release version on its own.
-  await withProject(undefined, async (root) => {
-    expect(validateSource(loadedProject(root, '1.0.0'), { skills: [] }, registry, { release: true }))
-      .toEqual([]);
-  });
 });
 
 it('separates the release refusal from the development fallback across commands', async () => {
@@ -300,7 +271,7 @@ it('exposes the derived axes on the development source status', async () => {
   await withProject(JSON.stringify({ name: '@scope/pkg', version: '2.3.4' }), async (root) => {
     await writeFile(
       join(root, 'agent-bundle.config.ts'),
-      "export default { plugin: { name: 'identity-fixture', version: '2.3.4' }, targets: ['portable'] };\n",
+      "export default { plugin: { name: 'identity-fixture' }, targets: ['portable'] };\n",
     );
     const prepared = await new ProjectService({ root, targets: ['portable'] }).prepare('inspect');
     expect(prepared.source).toMatchObject({
@@ -313,7 +284,7 @@ it('exposes the derived axes on the development source status', async () => {
   await withProject(JSON.stringify({ name: '@scope/pkg' }), async (root) => {
     await writeFile(
       join(root, 'agent-bundle.config.ts'),
-      "export default { plugin: { name: 'identity-fixture', version: '1.0.0' }, targets: ['portable'] };\n",
+      "export default { plugin: { name: 'identity-fixture' }, targets: ['portable'] };\n",
     );
     const prepared = await new ProjectService({ root, targets: ['portable'] }).prepare('inspect');
     expect(prepared.source.packageName).toBe('@scope/pkg');
diff --git a/packages/agent-bundle/tests/packed-consumer-typescript.test.ts b/packages/agent-bundle/tests/packed-consumer-typescript.test.ts
index 610bb4ceb..8d1c98464 100644
--- a/packages/agent-bundle/tests/packed-consumer-typescript.test.ts
+++ b/packages/agent-bundle/tests/packed-consumer-typescript.test.ts
@@ -70,10 +70,10 @@ it('never shadows the consumer\'s tsc bin from a packed npm install', async () =
         '',
       ].join('\n')),
       writeFile(routePath, [
-        "export const config = { annotations: { readOnlyHint: true }, description: 'Read status.' } satisfies { description: string };",
+        "import { defineTool } from 'agent-bundle/routes';",
         'export const inputSchema = {};',
         'export const resultSchema = {};',
-        'export default async () => undefined;',
+        "export default defineTool({ annotations: { readOnlyHint: true }, description: 'Read status.', inputSchema, resultSchema }, async () => undefined);",
         '',
       ].join('\n')),
     ]);
diff --git a/packages/agent-bundle/tests/packed-consumer.test.ts b/packages/agent-bundle/tests/packed-consumer.test.ts
index 32e6556bb..1e4bb7f70 100644
--- a/packages/agent-bundle/tests/packed-consumer.test.ts
+++ b/packages/agent-bundle/tests/packed-consumer.test.ts
@@ -164,7 +164,7 @@ it('uses only an installed tarball after source deletion', async () => {
       writeFile(join(scriptProjectRoot, 'package.json'), '{"type":"module"}\n'),
       writeFile(
         join(scriptProjectRoot, 'agent-bundle.config.ts'),
-        "export default { plugin: { name: 'packed-script-run', version: '1.0.0' }, scripts: { shell: './shell.sh' }, targets: ['portable'] };\n",
+        "export default { plugin: { name: 'packed-script-run' }, scripts: { shell: './shell.sh' }, targets: ['portable'] };\n",
       ),
       writeFile(join(scriptProjectRoot, 'shell.sh'), "printf 'packed script stdout\\n'\nprintf 'packed script stderr\\n' >&2\n"),
     ]);
@@ -377,7 +377,7 @@ it('uses only an installed tarball after source deletion', async () => {
     const frameworkRoot = join(consumerRoot, 'framework-build-project');
     await mkdir(join(frameworkRoot, 'src', 'mcp'), { recursive: true });
     await Promise.all([
-      writeFile(join(frameworkRoot, 'package.json'), '{"name":"framework-build-fixture","type":"module","private":true}\n'),
+      writeFile(join(frameworkRoot, 'package.json'), '{"name":"framework-build-fixture","type":"module","private":true,"version":"1.0.0"}\n'),
       writeFile(join(frameworkRoot, 'tsconfig.json'), JSON.stringify({
         compilerOptions: {
           module: 'esnext',
@@ -390,7 +390,7 @@ it('uses only an installed tarball after source deletion', async () => {
       writeFile(join(frameworkRoot, 'agent-bundle.config.ts'), [
         'export default {',
         '  mcp: { servers: { greeter: {} } },',
-        "  plugin: { name: 'framework-build-fixture', version: '1.0.0' },",
+        "  plugin: { name: 'framework-build-fixture' },",
         "  targets: ['claude', 'codex', 'portable'],",
         '};',
         '',
diff --git a/packages/agent-bundle/tests/packed-stdio-projection.test.ts b/packages/agent-bundle/tests/packed-stdio-projection.test.ts
index 68d92b9b7..00d9f296d 100644
--- a/packages/agent-bundle/tests/packed-stdio-projection.test.ts
+++ b/packages/agent-bundle/tests/packed-stdio-projection.test.ts
@@ -69,21 +69,26 @@ it.each([
     // shared fixture's lists stay untouched, and the packed level is the one
     // that spawns the real entry whose shell applies the layer.
     await writeFile(join(project, 'src', 'mcp', 'harness', 'tools', 'env-probe.tsx'), [
+      "import { defineTool } from 'agent-bundle/routes';",
       "import { Agent } from '@agent-bundle/runtime';",
       "import { z } from 'zod';",
       '',
-      "export const config = { annotations: { readOnlyHint: true }, description: 'Reports one environment variable as the server process sees it.' };",
       'export const inputSchema = z.object({ name: z.string().min(1) }).strict();',
       'export const resultSchema = z.object({ name: z.string(), value: z.string().nullable() }).strict();',
       '',
-      'export default async function EnvProbe({ input }: { readonly input: z.infer }) {',
+      'export default defineTool({',
+      "  annotations: { readOnlyHint: true },",
+      "  description: 'Reports one environment variable as the server process sees it.',",
+      '  inputSchema,',
+      '  resultSchema,',
+      '}, async (input) => {',
       '  const value = process.env[input.name] ?? null;',
       '  return (',
       '    ',
       '      {`${input.name}: ${value ?? "(unset)"}`}',
       '    ',
       '  );',
-      '}',
+      '});',
       '',
     ].join('\n'));
     await execFile('npm', ['install', ...cachedNpmInstallArguments,
diff --git a/packages/agent-bundle/tests/path-token-resolver.test.ts b/packages/agent-bundle/tests/path-token-resolver.test.ts
index f4dbfa8ac..a0f423105 100644
--- a/packages/agent-bundle/tests/path-token-resolver.test.ts
+++ b/packages/agent-bundle/tests/path-token-resolver.test.ts
@@ -255,7 +255,7 @@ it('resolves Claude path tokens outside command when launching a generated artif
             },
           },
         },
-        plugin: { name: 'path-token-fixture', version: '1.0.0' },
+        plugin: { name: 'path-token-fixture' },
         targets: ['claude'],
       }),
       { skills: [] },
diff --git a/packages/agent-bundle/tests/playground-orchestration-service.test.ts b/packages/agent-bundle/tests/playground-orchestration-service.test.ts
index 2e907c629..86d7b0794 100644
--- a/packages/agent-bundle/tests/playground-orchestration-service.test.ts
+++ b/packages/agent-bundle/tests/playground-orchestration-service.test.ts
@@ -28,7 +28,7 @@ import type {
   PlaygroundSubscription,
   PlaygroundTraceEvent,
 } from '../src/dev/playground/playground-store.ts';
-import { PlaygroundStore } from '../src/dev/playground/playground-store.ts';
+import { PlaygroundService } from '../src/dev/playground/playground-store.ts';
 import type { ProjectStatus } from '../src/dev/types.ts';
 import { eventuallyPasses } from './support/eventually.ts';
 import { deepFreeze } from '../src/core/freeze.ts';
@@ -553,7 +553,7 @@ it('exports and promotes the real durable response event reference from a native
   const root = await mkdtemp(join(tmpdir(), 'agent-bundle-native-playground-durable-'));
   const projectRoot = join(root, 'project');
   await mkdir(projectRoot, { recursive: true });
-  const trace = new PlaygroundStore({
+  const trace = new PlaygroundService({
     projectId: 'native-durable-project',
     projectRoot,
     storageRoot: join(projectRoot, '.agent-bundle', 'playground'),
diff --git a/packages/agent-bundle/tests/playground-service.test.ts b/packages/agent-bundle/tests/playground-service.test.ts
index 86971c354..5ed93bbf6 100644
--- a/packages/agent-bundle/tests/playground-service.test.ts
+++ b/packages/agent-bundle/tests/playground-service.test.ts
@@ -7,7 +7,7 @@ import { expect, it } from '@rstest/core';
 
 import { timeScale } from './support/time-scale.ts';
 import {
-  PlaygroundStore as PlaygroundService,
+  PlaygroundService,
   PlaygroundServiceCloseError,
   PlaygroundSessionCloseError,
   type PlaygroundEventInput,
diff --git a/packages/agent-bundle/tests/plugin-logo.test.ts b/packages/agent-bundle/tests/plugin-logo.test.ts
index dbac38888..c1d8e8ccf 100644
--- a/packages/agent-bundle/tests/plugin-logo.test.ts
+++ b/packages/agent-bundle/tests/plugin-logo.test.ts
@@ -71,7 +71,6 @@ it('rejects a missing or invalid plugin.logo with AB4012', async () => {
   const missing = await loadedProject({
     logo: 'docs/media/missing.svg',
     name: 'logo-fixture',
-    version: '1.0.0',
   });
   expect(validateSource(missing, { skills: [] }, registry).filter(({ code }) => code === 'AB4012')).toMatchObject([{
     code: 'AB4012',
@@ -82,7 +81,6 @@ it('rejects a missing or invalid plugin.logo with AB4012', async () => {
   const empty = await loadedProject({
     logo: '   ',
     name: 'logo-fixture',
-    version: '1.0.0',
   });
   expect(validateSource(empty, { skills: [] }, registry).filter(({ code }) => code === 'AB4012')).toMatchObject([{
     code: 'AB4012',
@@ -93,7 +91,6 @@ it('rejects a missing or invalid plugin.logo with AB4012', async () => {
   const directory = await loadedProject({
     logo: 'docs/media',
     name: 'logo-fixture',
-    version: '1.0.0',
   }, { 'docs/media/.keep': '' });
   expect(validateSource(directory, { skills: [] }, registry).filter(({ code }) => code === 'AB4012')).toMatchObject([{
     code: 'AB4012',
@@ -104,7 +101,6 @@ it('rejects a missing or invalid plugin.logo with AB4012', async () => {
   const outside = await loadedProject({
     logo: '../outside.svg',
     name: 'logo-fixture',
-    version: '1.0.0',
   });
   expect(validateSource(outside, { skills: [] }, registry).filter(({ code }) => code === 'AB4012')).toMatchObject([{
     code: 'AB4012',
@@ -118,7 +114,6 @@ it('accepts an existing in-project plugin.logo and normalizes it onto metadata',
     description: 'Logo fixture',
     logo: 'docs/media/logo.svg',
     name: 'logo-fixture',
-    version: '1.0.0',
   }, { 'docs/media/logo.svg': logoSvg });
 
   expect(validateSource(loaded, { skills: [] }, registry).filter(({ code }) => code === 'AB4012')).toEqual([]);
@@ -134,7 +129,6 @@ it('accepts an existing in-project plugin.logo and normalizes it onto metadata',
 it('omits logo from the normalized model when the field is absent', async () => {
   const loaded = await loadedProject({
     name: 'logo-fixture',
-    version: '1.0.0',
   });
   const model = await normalizeProject(loaded, { skills: [] }, registry);
   expect(model.metadata).not.toHaveProperty('logo');
diff --git a/packages/agent-bundle/tests/prebuilt-payload.test.ts b/packages/agent-bundle/tests/prebuilt-payload.test.ts
index 64787d9a5..997f63971 100644
--- a/packages/agent-bundle/tests/prebuilt-payload.test.ts
+++ b/packages/agent-bundle/tests/prebuilt-payload.test.ts
@@ -14,7 +14,7 @@ import { createProjectFixture, removeProjectFixture } from './helpers/project-fi
 
 const configSource = (options: { readonly payload?: string; readonly hooks?: string; readonly mcp?: string }): string => [
   'export default {',
-  "  plugin: { name: 'prebuilt-fixture', version: '1.0.0', description: 'Prebuilt payload fixture.' },",
+  "  plugin: { name: 'prebuilt-fixture', description: 'Prebuilt payload fixture.' },",
   "  targets: ['claude', 'codex', 'portable'],",
   ...(options.payload === undefined ? [] : [options.payload]),
   ...(options.hooks === undefined ? [] : [options.hooks]),
diff --git a/packages/agent-bundle/tests/project-context-walk-bound.test.ts b/packages/agent-bundle/tests/project-context-walk-bound.test.ts
index c21acccd9..474b15ac6 100644
--- a/packages/agent-bundle/tests/project-context-walk-bound.test.ts
+++ b/packages/agent-bundle/tests/project-context-walk-bound.test.ts
@@ -15,12 +15,13 @@ const createProject = async (): Promise => {
       join(root, 'agent-bundle.config.ts'),
       [
         'export default {',
-        "  plugin: { name: 'dev-service-fixture', version: '1.0.0' },",
+        "  plugin: { name: 'dev-service-fixture' },",
         "  targets: ['portable'],",
         '};',
         '',
       ].join('\n'),
     ),
+    writeFile(join(root, 'package.json'), '{"type":"module","version":"1.0.0"}\n'),
     writeFile(join(root, 'src', 'skills', 'review', 'SKILL.md'), [
       '---',
       'name: review',
diff --git a/packages/agent-bundle/tests/projection/script-dispatch.test.ts b/packages/agent-bundle/tests/projection/script-dispatch.test.ts
index 725c35ed2..5e453237e 100644
--- a/packages/agent-bundle/tests/projection/script-dispatch.test.ts
+++ b/packages/agent-bundle/tests/projection/script-dispatch.test.ts
@@ -454,8 +454,8 @@ describe('plain scripts at the script dispatch level', () => {
 
     expect(run.exitCode).toBe(0);
     expect(run.stderr).toBe('');
-    // The route-harness fixture declares plugin name/version and has no package.json.
-    expect(run.stdout).toBe('route-harness@1.0.0 - -\n');
+    // The route-harness fixture has a package version but no npm package name.
+    expect(run.stdout).toBe('route-harness@1.0.0 - 1.0.0\n');
 
     const compiled = testManifest();
     const packaged = await runScript('identity', [], {
diff --git a/packages/agent-bundle/tests/provider-typegen.test.ts b/packages/agent-bundle/tests/provider-typegen.test.ts
index 697e64d4c..4cde18ecd 100644
--- a/packages/agent-bundle/tests/provider-typegen.test.ts
+++ b/packages/agent-bundle/tests/provider-typegen.test.ts
@@ -55,7 +55,7 @@ it('types await context.provider(key) from the generated provider declarations',
     writeProjectFile(root, 'agent-bundle.config.ts', [
       "import { defineConfig } from 'agent-bundle/config';",
       'export default defineConfig({',
-      "  plugin: { name: 'provider-typegen-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'provider-typegen-fixture' },",
       "  targets: ['portable'],",
       '});',
       '',
@@ -88,11 +88,14 @@ it('types await context.provider(key) from the generated provider declarations',
       '',
     ].join('\n')),
     writeProjectFile(root, 'src/mcp/curator/tools/status.ts', [
-      "import { z } from 'zod';",
-      'export const inputSchema = z.object({}).strict();',
-      "export const resultSchema = z.object({ status: z.literal('ready') }).strict();",
-      "export default async function Status() { return { status: 'ready' as const }; }",
-      '',
+    "import { defineTool } from 'agent-bundle/routes';",
+    "import { z } from 'zod';",
+    "export const inputSchema = z.object({}).strict();",
+    "export const resultSchema = z.object({ status: z.literal('ready') }).strict();",
+    "export default defineTool({",
+    "  inputSchema,",
+    "  resultSchema,",
+    "}, async () => undefined);",
     ].join('\n')),
     writeProjectFile(root, 'assertions.ts', [
       "import { agent } from '@agent-bundle/runtime';",
diff --git a/packages/agent-bundle/tests/public-api-packed.test.ts b/packages/agent-bundle/tests/public-api-packed.test.ts
index 90a62ba09..a47c621f5 100644
--- a/packages/agent-bundle/tests/public-api-packed.test.ts
+++ b/packages/agent-bundle/tests/public-api-packed.test.ts
@@ -137,10 +137,10 @@ const createBuildProject = async (root: string): Promise<{ readonly output: stri
   const output = join(project, 'manifest-version-artifact');
   await mkdir(join(project, 'src', 'skills', 'review'), { recursive: true });
   await Promise.all([
-    writeFile(join(project, 'package.json'), '{"type":"module"}\n'),
+    writeFile(join(project, 'package.json'), '{"type":"module","version":"1.0.0"}\n'),
     writeFile(
       join(project, 'agent-bundle.config.ts'),
-      "export default { plugin: { name: 'manifest-version-fixture', version: '1.0.0' }, targets: ['portable'] };\n",
+      "export default { plugin: { name: 'manifest-version-fixture' }, targets: ['portable'] };\n",
     ),
     writeFile(
       join(project, 'src', 'skills', 'review', 'SKILL.md'),
@@ -259,7 +259,7 @@ it('writes the package version as the producer of a packed CLI manifest', async
   } finally {
     await rm(consumerRoot, { force: true, recursive: true });
   }
-}, 30_000);
+}, 60_000);
 
 /**
  * The compiler (`@rslib/core`) and MCP Apps (`@rsbuild/core`) must run one
@@ -331,7 +331,7 @@ it('imports the externalized config entry from a packed npm consumer', async ()
       'const config: AgentBundleConfig = {',
       "  claude: { nativeHooks: './claude-hooks.json' },",
       "  codex: { nativeHooks: './codex-hooks.json' },",
-      "  plugin: { name: 'packed-config-types', version: '1.0.0' },",
+      "  plugin: { name: 'packed-config-types' },",
       "  portable: { compatibility: 'v1' },",
       '};',
       '',
@@ -378,7 +378,7 @@ it('imports the externalized config entry from a packed npm consumer', async ()
   } finally {
     await rm(consumerRoot, { force: true, recursive: true });
   }
-}, 30_000);
+}, 60_000);
 
 it('runs the packed App client through a dynamic-origin parent', async () => {
   const { tarball } = await sharedPackedTarball('agent-bundle');
diff --git a/packages/agent-bundle/tests/public-api.test.ts b/packages/agent-bundle/tests/public-api.test.ts
index c99f77693..79451294c 100644
--- a/packages/agent-bundle/tests/public-api.test.ts
+++ b/packages/agent-bundle/tests/public-api.test.ts
@@ -79,10 +79,10 @@ const createBuildProject = async (root: string): Promise<{ readonly output: stri
   const output = join(project, 'manifest-version-artifact');
   await mkdir(join(project, 'src', 'skills', 'review'), { recursive: true });
   await Promise.all([
-    writeFile(join(project, 'package.json'), '{"type":"module"}\n'),
+    writeFile(join(project, 'package.json'), '{"type":"module","version":"1.0.0"}\n'),
     writeFile(
       join(project, 'agent-bundle.config.ts'),
-      "export default { plugin: { name: 'manifest-version-fixture', version: '1.0.0' }, targets: ['portable'] };\n",
+      "export default { plugin: { name: 'manifest-version-fixture' }, targets: ['portable'] };\n",
     ),
     writeFile(
       join(project, 'src', 'skills', 'review', 'SKILL.md'),
@@ -405,7 +405,7 @@ it('keeps bundled config extension types in emitted root declarations', async ()
       'const config: AgentBundleConfig = {',
       "  claude: { nativeHooks: './claude-hooks.json' },",
       "  codex: { nativeHooks: './codex-hooks.json' },",
-      "  plugin: { name: 'packed-root-types', version: '1.0.0' },",
+      "  plugin: { name: 'packed-root-types' },",
       "  portable: { compatibility: 'v1' },",
       '};',
       '',
diff --git a/packages/agent-bundle/tests/rendered-skills.test.ts b/packages/agent-bundle/tests/rendered-skills.test.ts
index c87e6ef76..3a45d5d66 100644
--- a/packages/agent-bundle/tests/rendered-skills.test.ts
+++ b/packages/agent-bundle/tests/rendered-skills.test.ts
@@ -164,7 +164,7 @@ describe('rendered skill compilation', () => {
   });
 
   it('discovers rendered-only skill directories by convention and validates them cleanly', async () => {
-    const loaded = loadedProject({ plugin: { name: 'rendered', version: '1.0.0' } }, fixtureRoot);
+    const loaded = loadedProject({ plugin: { name: 'rendered' } }, fixtureRoot);
     const discovered = await discoverProject(fixtureRoot, loaded.config);
     expect(discovered.skills.map((skill) => skill.frontmatter.name)).toEqual(['deploy-checklist']);
     expect(validateSource(loaded, discovered, registry)).toEqual([]);
@@ -177,7 +177,7 @@ describe('rendered skill compilation', () => {
   });
 
   it('emits the compiled SKILL.md as a generated write entry in artifact plans', async () => {
-    const loaded = loadedProject({ plugin: { name: 'rendered', version: '1.0.0' } }, fixtureRoot);
+    const loaded = loadedProject({ plugin: { name: 'rendered' } }, fixtureRoot);
     const model = await normalizeProject(loaded, await discoverProject(fixtureRoot, loaded.config), registry);
     const plan = standardPluginArtifactPlan({
       diagnostics: [],
@@ -272,10 +272,6 @@ describe('rendered skill compilation', () => {
       version: '3.4.5',
     });
 
-    // An authored plugin.version wins over package.json, for the skill too.
-    const authored = await discoverProject(root, { plugin: { name: 'identity-plugin', version: '9.0.0' } });
-    expect(authored.skills[0]?.body).toContain('Version `9.0.0`; package `@acme/identity-plugin@3.4.5`.');
-
     // Without a caller-supplied identity the reserved specifier is not aliased:
     // it resolves however the project resolves `agent-bundle` — here, not at all.
     const direct = await parseSkill(join(root, 'src', 'skills', 'identity'), root);
diff --git a/packages/agent-bundle/tests/route-caller-input-types.test.ts b/packages/agent-bundle/tests/route-caller-input-types.test.ts
index 131276485..b0eddb642 100644
--- a/packages/agent-bundle/tests/route-caller-input-types.test.ts
+++ b/packages/agent-bundle/tests/route-caller-input-types.test.ts
@@ -8,6 +8,11 @@ import { afterEach, expect, it } from '@rstest/core';
 import ts from 'typescript-5';
 
 import { build, validate } from '../src/api.ts';
+import {
+  agentBundleNodeModules,
+  agentBundlePackageRoot,
+  workspaceNodeModules,
+} from './helpers/workspace-paths.ts';
 
 const roots: string[] = [];
 
@@ -30,19 +35,22 @@ const equality = [
 
 /** The tool records every invocation it actually ran, so a rejected input provably ran nothing. */
 const tool = (name: string, schema: string, result: string, body: string): string => [
+  "import { defineTool } from 'agent-bundle/routes';",
   "import { Agent } from '@agent-bundle/runtime';",
-  "import type { ToolRouteProps } from 'agent-bundle';",
   "import { appendFile } from 'node:fs/promises';",
   "import { createElement } from 'react';",
   "import { z } from 'zod';",
-  `export const config = { description: '${name}' };`,
   `export const inputSchema = ${schema};`,
   `export const resultSchema = ${result};`,
-  `export default async function Route({ input }: ToolRouteProps) {`,
+  'export default defineTool({',
+  `  description: '${name}',`,
+  '  inputSchema,',
+  '  resultSchema,',
+  '}, async (input) => {',
   `  await appendFile(process.env['INVOCATIONS']!, JSON.stringify({ input, tool: '${name}' }) + '\\n');`,
   `  const value = ${body};`,
   "  return createElement(Agent.Result, { value }, createElement(Agent.Text, null, JSON.stringify(value)));",
-  '}',
+  '});',
   '',
 ].join('\n');
 
@@ -57,7 +65,7 @@ const tsconfig = (include: readonly string[], lib: readonly string[]): string =>
     skipLibCheck: true,
     strict: true,
     target: 'ES2022',
-    types: [],
+    types: ['node'],
   },
   include,
 }, null, 2)}\n`;
@@ -98,7 +106,15 @@ const callTool = async (client: Client, name: string, input: Record {
   const root = await mkdtemp(join(tmpdir(), 'agent-bundle-caller-input-types-'));
   roots.push(root);
-  await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir');
+  await mkdir(join(root, 'node_modules', '@types'), { recursive: true });
+  await Promise.all([
+    symlink(join(agentBundleNodeModules, '@agent-bundle'), join(root, 'node_modules', '@agent-bundle'), 'dir'),
+    symlink(join(workspaceNodeModules, '@types', 'node'), join(root, 'node_modules', '@types', 'node')),
+    symlink(join(agentBundleNodeModules, '@types', 'react'), join(root, 'node_modules', '@types', 'react')),
+    symlink(join(agentBundleNodeModules, 'react'), join(root, 'node_modules', 'react'), 'dir'),
+    symlink(agentBundlePackageRoot, join(root, 'node_modules', 'agent-bundle'), 'dir'),
+    symlink(join(agentBundleNodeModules, 'zod'), join(root, 'node_modules', 'zod'), 'dir'),
+  ]);
   const invocations = join(root, 'invocations.ndjson');
   await Promise.all([
     writeProjectFile(root, 'package.json', JSON.stringify({
@@ -109,7 +125,7 @@ it('types callers by schema input and components by schema output in a clean gen
     })),
     writeProjectFile(root, 'agent-bundle.config.ts', [
       "import { defineConfig } from 'agent-bundle/config';",
-      "export default defineConfig({ plugin: { name: 'caller-input-types-fixture', version: '1.0.0' }, targets: ['portable'] });",
+      "export default defineConfig({ plugin: { name: 'caller-input-types-fixture' }, targets: ['portable'] });",
       '',
     ].join('\n')),
     // A defaulted field: optional to the caller, present for the component.
diff --git a/packages/agent-bundle/tests/route-contract-imports.test.ts b/packages/agent-bundle/tests/route-contract-imports.test.ts
index 1f27c6b4c..a11ec651d 100644
--- a/packages/agent-bundle/tests/route-contract-imports.test.ts
+++ b/packages/agent-bundle/tests/route-contract-imports.test.ts
@@ -104,31 +104,40 @@ const cliRoute = (variant: FixtureVariant): string => variant === 'inline'
 
 const toolRoute = (variant: FixtureVariant): string => variant === 'inline'
   ? [
+      "import { defineTool } from 'agent-bundle/routes';",
       "import { Agent } from '@agent-bundle/runtime';",
       "import { z } from 'zod';",
       '',
-      "export const config = { inputJsonSchema: { type: 'object', additionalProperties: false, properties: { laneKey: { type: 'string' }, limit: { type: 'number', description: 'Recent rows' }, statuses: { type: 'array', items: { type: 'string', enum: ['requested', 'queued', 'running', 'done'] } }, tickets: { type: 'array', items: { type: 'string' } } } }, description: 'Show the queue.' };",
       `export const inputSchema = ${inlineInputSchema};`,
       "export const resultSchema = z.object({ filters: inputSchema, operation: z.literal('status') });",
-      'export default async function HaulerStatus({ input }: { input: z.infer }) {',
+      'export default defineTool({',
+      "  inputJsonSchema: { type: 'object', additionalProperties: false, properties: { laneKey: { type: 'string' }, limit: { type: 'number', description: 'Recent rows' }, statuses: { type: 'array', items: { type: 'string', enum: ['requested', 'queued', 'running', 'done'] } }, tickets: { type: 'array', items: { type: 'string' } } } },",
+      "  description: 'Show the queue.',",
+      '  inputSchema,',
+      '  resultSchema,',
+      '}, async (input) => {',
       "  const result = { filters: input, operation: 'status' };",
       '  return Queue status.;',
-      '}',
+      '});',
       '',
     ].join('\n')
   : [
+      "import { defineTool } from 'agent-bundle/routes';",
       "import { Agent } from '@agent-bundle/runtime';",
-      "import type { z } from 'zod';",
       '',
       "import { statusInputSchema, statusResultSchema } from '../../../lib/protocol-schemas.js';",
       '',
-      "export const config = { inputJsonSchema: { type: 'object', additionalProperties: false, properties: { laneKey: { type: 'string' }, limit: { type: 'number', description: 'Recent rows' }, statuses: { type: 'array', items: { type: 'string', enum: ['requested', 'queued', 'running', 'done'] } }, tickets: { type: 'array', items: { type: 'string' } } } }, description: 'Show the queue.' };",
       'export const inputSchema = statusInputSchema;',
       'export const resultSchema = statusResultSchema;',
-      'export default async function HaulerStatus({ input }: { input: z.infer }) {',
+      'export default defineTool({',
+      "  inputJsonSchema: { type: 'object', additionalProperties: false, properties: { laneKey: { type: 'string' }, limit: { type: 'number', description: 'Recent rows' }, statuses: { type: 'array', items: { type: 'string', enum: ['requested', 'queued', 'running', 'done'] } }, tickets: { type: 'array', items: { type: 'string' } } } },",
+      "  description: 'Show the queue.',",
+      '  inputSchema,',
+      '  resultSchema,',
+      '}, async (input) => {',
       "  const result = { filters: input, operation: 'status' };",
       '  return Queue status.;',
-      '}',
+      '});',
       '',
     ].join('\n');
 
@@ -155,7 +164,7 @@ const writeFixture = async (variant: FixtureVariant): Promise => {
     writeProjectFile(root, 'agent-bundle.config.ts', [
       "import { defineConfig } from 'agent-bundle/config';",
       'export default defineConfig({',
-      `  plugin: { description: 'Route contract fixture.', name: '${name}', version: '1.0.0' },`,
+      `  plugin: { description: 'Route contract fixture.', name: '${name}' },`,
       '  routes: { mcpCommands: true },',
       "  targets: ['portable'],",
       '});',
@@ -181,7 +190,7 @@ const writeFixture = async (variant: FixtureVariant): Promise => {
 
 const routeGraph = async (root: string): Promise => {
   const graph = await compileRouteGraph(root, {
-    plugin: { name: 'route-contract-fixture', version: '1.0.0' },
+    plugin: { name: 'route-contract-fixture' },
     routes: { mcpCommands: true },
     targets: ['portable'],
   });
@@ -296,7 +305,7 @@ it('uses explicit metadata with imported runtime schemas and preserves generated
   expect(importedTypes).toEqual([]);
 
   const bareGraph = await compileRouteGraph(bareRoot, {
-    plugin: { name: 'route-contract-bare-fixture', version: '1.0.0' },
+    plugin: { name: 'route-contract-bare-fixture' },
     routes: { mcpCommands: true },
     targets: ['portable'],
   }) as RouteContractGraph;
diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts
index 7c03be454..f2d830742 100644
--- a/packages/agent-bundle/tests/route-graph.test.ts
+++ b/packages/agent-bundle/tests/route-graph.test.ts
@@ -29,6 +29,15 @@ const createRoot = async (): Promise => {
 };
 
 const moduleSource = 'export const inputSchema = {}; export const resultSchema = {}; export default async () => undefined;\n';
+const toolSource = (fields = '', imports: readonly string[] = []): string => [
+  ...imports,
+  "import { defineTool } from 'agent-bundle/routes';",
+  'export const inputSchema = {};',
+  'export const resultSchema = {};',
+  `export default defineTool({ ${fields}${fields.length === 0 ? '' : ', '}inputSchema, resultSchema }, async () => undefined);`,
+  '',
+].join('\n');
+const toolModuleSource = toolSource();
 
 const writeTree = async (root: string, files: Readonly>): Promise => {
   for (const [path, contents] of Object.entries(files)) {
@@ -39,7 +48,7 @@ const writeTree = async (root: string, files: Readonly>):
 };
 
 const fixtureConfig = (extra: Readonly> = {}): AgentBundleConfig => ({
-  plugin: { name: 'routes-fixture', version: '1.0.0' },
+  plugin: { name: 'routes-fixture' },
   ...extra,
 });
 
@@ -63,7 +72,7 @@ const conventionalTree: Readonly> = {
   'src/mcp/curator/apps/dashboard.tsx': `export const config = { resourceUri: 'ui://curator/dashboard.html' }; ${moduleSource}`,
   'src/mcp/curator/prompts/curate.tsx': moduleSource,
   'src/mcp/curator/resources/catalog.ts': moduleSource,
-  'src/mcp/curator/tools/inspect.tsx': moduleSource,
+  'src/mcp/curator/tools/inspect.tsx': toolModuleSource,
   'src/providers/git-worktree.ts': moduleSource,
   'src/scripts/rebuild-index.ts': moduleSource,
 };
@@ -94,12 +103,12 @@ const createInspectProject = async (files: Readonly>): Pr
   await writeTree(root, {
     'agent-bundle.config.ts': [
       'export default {',
-      "  plugin: { name: 'routes-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'routes-fixture' },",
       "  targets: ['portable'],",
       '};',
       '',
     ].join('\n'),
-    'package.json': '{"type":"module"}\n',
+    'package.json': '{"type":"module","version":"1.0.0"}\n',
     ...files,
   });
   return root;
@@ -139,12 +148,12 @@ it('compiles the conventional tree into one frozen graph with a machine-independ
     source: join(root, 'src/providers/git-worktree.ts'),
   }]);
 
-  // The IR is immutable and every route carries the shared frozen empty config.
+  // The IR is immutable and routes without authored metadata carry empty config.
   expect(Object.isFrozen(graph)).toBe(true);
   expect(Object.isFrozen(graph.servers)).toBe(true);
   expect(Object.isFrozen(curator!.routes[0])).toBe(true);
   expect(Object.isFrozen(graph.cli!.routes)).toBe(true);
-  expect(curator!.routes.filter((route) => route.kind !== 'app').every((route) => route.config === emptyRouteConfig)).toBe(true);
+  expect(curator!.routes.filter((route) => route.kind !== 'app').every((route) => Object.keys(route.config).length === 0)).toBe(true);
   expect(curator!.routes.find((route) => route.kind === 'app')?.config).toEqual({ resourceUri: 'ui://curator/dashboard.html' });
   expect(graph.events[0]!.config).toEqual({});
 
@@ -167,18 +176,26 @@ it('populates bounded input schemas for every route kind and includes them in th
     'export default async () => undefined;',
     '',
   ].join('\n');
+  const boundedTool = [
+    "import { defineTool } from 'agent-bundle/routes';",
+    `export const inputSchema = z.object({ count: z.number().optional(), root: z.string().describe('Project root.') }).strict();`,
+    'export const resultSchema = {};',
+    `export default defineTool({ inputJsonSchema: ${JSON.stringify(metadata)}, inputSchema, resultSchema }, async () => undefined);`,
+    '',
+  ].join('\n');
   const tree = {
     'src/cli/audit.ts': bounded(),
     'src/events/stop.ts': bounded(),
     'src/mcp/curator/apps/dashboard.tsx': bounded("export const config = { resourceUri: 'ui://curator/dashboard.html' };"),
     'src/mcp/curator/prompts/curate.ts': bounded(),
     'src/mcp/curator/resources/catalog.ts': bounded(),
-    'src/mcp/curator/tools/inspect.ts': bounded(),
+    'src/mcp/curator/tools/inspect.ts': boundedTool,
     'src/mcp/curator/tools/rich.ts': [
+      "import { defineTool } from 'agent-bundle/routes';",
       'const sharedSchema = z.object({ nested: z.object({ value: z.string() }) });',
       'export const inputSchema = sharedSchema;',
       'export const resultSchema = {};',
-      'export default async () => undefined;',
+      'export default defineTool({ inputSchema, resultSchema }, async () => undefined);',
       '',
     ].join('\n'),
     'src/scripts/rebuild.ts': bounded(),
@@ -208,7 +225,7 @@ it('populates bounded input schemas for every route kind and includes them in th
   const changedRoot = await createRoot();
   await writeTree(changedRoot, tree);
   const inspectPath = join(changedRoot, 'src/mcp/curator/tools/inspect.ts');
-  await writeFile(inspectPath, (await readFile(inspectPath, 'utf8')).replace('Project root.', 'Workspace root.'));
+  await writeFile(inspectPath, (await readFile(inspectPath, 'utf8')).replaceAll('Project root.', 'Workspace root.'));
   const changed = await compileRouteGraph(changedRoot, fixtureConfig());
   expect(changed.digest).not.toBe(graph.digest);
 
@@ -223,17 +240,20 @@ it('does not infer metadata by following imported runtime schemas', async () =>
     '}).strict();',
     '',
   ].join('\n');
-  const route = (specifier: string): string => [
+  const route = (specifier: string, tool = false): string => [
+    ...(tool ? ["import { defineTool } from 'agent-bundle/routes';"] : []),
     `import { statusInputSchema } from '${specifier}';`,
     'export const inputSchema = statusInputSchema;',
     'export const resultSchema = {};',
-    'export default async () => undefined;',
+    tool
+      ? 'export default defineTool({ inputSchema, resultSchema }, async () => undefined);'
+      : 'export default async () => undefined;',
     '',
   ].join('\n');
   await writeTree(root, {
     'src/cli/status.ts': route('../lib/protocol-schemas.js'),
     'src/lib/protocol-schemas.ts': schema,
-    'src/mcp/hauler/tools/hauler_status.tsx': route('../../../lib/protocol-schemas.js'),
+    'src/mcp/hauler/tools/hauler_status.tsx': route('../../../lib/protocol-schemas.js', true),
   });
   const graph = await compileRouteGraph(root, fixtureConfig());
   expect(graph.diagnostics).toEqual([]);
@@ -327,8 +347,8 @@ it('skips ignored paths, private segments, and declaration files', async () => {
   await writeTree(root, {
     '.gitignore': 'src/scripts/generated.ts\n',
     'src/events/.internal/probe.ts': moduleSource,
-    'src/mcp/curator/tools/_draft.ts': moduleSource,
-    'src/mcp/curator/tools/inspect.ts': moduleSource,
+    'src/mcp/curator/tools/_draft.ts': toolModuleSource,
+    'src/mcp/curator/tools/inspect.ts': toolModuleSource,
     'src/mcp/curator/tools/types.d.ts': 'export type Probe = string;\n',
     'src/scripts/generated.ts': moduleSource,
   });
@@ -436,7 +456,7 @@ it('gates a bin-claimed rendered script with AB4737 only when it exports no main
       "    tool: './src/scripts/render-tool.tsx',",
       "    typed: './src/scripts/render-typed.tsx',",
       '  },',
-      "  plugin: { name: 'routes-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'routes-fixture' },",
       "  targets: ['portable'],",
       '};',
       '',
@@ -524,7 +544,7 @@ it('gates a bin-claimed plain script with AB4738 only when its bin would run a d
       "    hauler: './src/scripts/hauler.ts',",
       "    plain: './src/scripts/plain.ts',",
       '  },',
-      "  plugin: { name: 'routes-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'routes-fixture' },",
       "  targets: ['portable'],",
       '};',
       '',
@@ -551,7 +571,7 @@ it('gates a bin-claimed plain script with AB4738 only when its bin would run a d
 
 it('errors with AB4800 when a declared entry, command, or url claims a routed server', async () => {
   const root = await createRoot();
-  await writeTree(root, { 'src/mcp/curator/tools/inspect.ts': moduleSource });
+  await writeTree(root, { 'src/mcp/curator/tools/inspect.ts': toolModuleSource });
   const graph = await compileRouteGraph(root, fixtureConfig({
     mcp: { servers: { curator: { url: 'https://example.test/mcp' } } },
   }));
@@ -564,7 +584,7 @@ it('errors with AB4800 when a declared entry, command, or url claims a routed se
 it('errors with AB4800 when an entry module and route modules claim one MCP server, and inspect turns invalid', async () => {
   const files = {
     'src/mcp/curator.ts': moduleSource,
-    'src/mcp/curator/tools/inspect.ts': moduleSource,
+    'src/mcp/curator/tools/inspect.ts': toolModuleSource,
   };
   const root = await createRoot();
   await writeTree(root, files);
@@ -584,7 +604,7 @@ it('keeps routes and silences AB4800 under an explicit generated mode', async ()
   const root = await createRoot();
   await writeTree(root, {
     'src/mcp/curator.ts': moduleSource,
-    'src/mcp/curator/tools/inspect.ts': moduleSource,
+    'src/mcp/curator/tools/inspect.ts': toolModuleSource,
   });
   const graph = await compileRouteGraph(root, fixtureConfig({
     routes: { servers: { curator: 'generated' } },
@@ -606,12 +626,12 @@ it('accepts a config declaration that augments a route-generated server with env
       "    targets: ['portable'],",
       "    transport: 'stdio',",
       '  } } },',
-      "  plugin: { name: 'routes-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'routes-fixture' },",
       "  targets: ['portable', 'claude'],",
       '};',
       '',
     ].join('\n'),
-    'src/mcp/curator/tools/inspect.ts': moduleSource,
+    'src/mcp/curator/tools/inspect.ts': toolModuleSource,
     'views/panel.ts': "document.body.textContent = 'panel';\n",
   });
 
@@ -641,14 +661,14 @@ it('errors with AB4340 when a declaration for a route-generated server redeclare
     'agent-bundle.config.ts': [
       'export default {',
       "  mcp: { servers: { curator: { entry: './src/mcp/curator.ts', env: { CURATOR_MODE: 'strict' } } } },",
-      "  plugin: { name: 'routes-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'routes-fixture' },",
       "  routes: { servers: { curator: 'generated' } },",
       "  targets: ['portable'],",
       '};',
       '',
     ].join('\n'),
     'src/mcp/curator.ts': moduleSource,
-    'src/mcp/curator/tools/inspect.ts': moduleSource,
+    'src/mcp/curator/tools/inspect.ts': toolModuleSource,
   });
 
   const validation = await validate({ root: project });
@@ -664,14 +684,14 @@ it('checks an augmenting declaration\'s Apps against the route-declared Apps of
   const configWithApps = (apps: string): string => [
     'export default {',
     `  mcp: { servers: { curator: { apps: { ${apps} } } } },`,
-    "  plugin: { name: 'routes-fixture', version: '1.0.0' },",
+    "  plugin: { name: 'routes-fixture' },",
     "  targets: ['portable'],",
     '};',
     '',
   ].join('\n');
   const routes = {
     'src/mcp/curator/apps/dashboard.tsx': `export const config = { resourceUri: 'ui://curator/dashboard.html' }; ${moduleSource}`,
-    'src/mcp/curator/tools/inspect.ts': moduleSource,
+    'src/mcp/curator/tools/inspect.ts': toolModuleSource,
     'views/panel.ts': "document.body.textContent = 'panel';\n",
   };
 
@@ -707,12 +727,12 @@ it('applies the local-entry field rules to an augmenting declaration', async ()
     'agent-bundle.config.ts': [
       'export default {',
       "  mcp: { servers: { curator: { cwd: './elsewhere', headers: { a: 'b' }, transport: 'streamable-http' } } },",
-      "  plugin: { name: 'routes-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'routes-fixture' },",
       "  targets: ['portable'],",
       '};',
       '',
     ].join('\n'),
-    'src/mcp/curator/tools/inspect.ts': moduleSource,
+    'src/mcp/curator/tools/inspect.ts': toolModuleSource,
   });
 
   const validation = await validate({ root: project });
@@ -727,7 +747,7 @@ it('omits a server\'s routes and silences AB4800 under an explicit custom mode',
   const root = await createRoot();
   await writeTree(root, {
     'src/mcp/curator.ts': moduleSource,
-    'src/mcp/curator/tools/inspect.ts': moduleSource,
+    'src/mcp/curator/tools/inspect.ts': toolModuleSource,
   });
   const graph = await compileRouteGraph(root, fixtureConfig({
     mcp: { servers: { curator: { entry: './src/mcp/curator.ts' } } },
@@ -750,7 +770,7 @@ it('skips a server layout entirely when routes.servers pins that server to a non
     'src/mcp/curator.ts': moduleSource,
     'src/mcp/curator/layout.ts': 'export default { children: undefined };\n',
     'src/mcp/curator/layout.tsx': 'export default { children: undefined };\n',
-    'src/mcp/curator/tools/inspect.ts': moduleSource,
+    'src/mcp/curator/tools/inspect.ts': toolModuleSource,
     'src/mcp/relay/layout.tsx': 'export default ({ children }) => children;\n',
   });
   const graph = await compileRouteGraph(root, fixtureConfig({
@@ -793,8 +813,8 @@ it('errors with AB4801 when the conventional CLI entry and command routes both e
 it('errors with AB4802 when two route modules derive one id', async () => {
   const root = await createRoot();
   await writeTree(root, {
-    'src/mcp/curator/tools/inspect.ts': moduleSource,
-    'src/mcp/curator/tools/inspect.tsx': moduleSource,
+    'src/mcp/curator/tools/inspect.ts': toolModuleSource,
+    'src/mcp/curator/tools/inspect.tsx': toolModuleSource,
   });
   const graph = await compileRouteGraph(root, fixtureConfig());
 
@@ -808,7 +828,7 @@ it('errors with AB4803 on unsafe identity segments', async () => {
   const root = await createRoot();
   await writeTree(root, {
     'src/cli/-doctor.ts': moduleSource,
-    'src/mcp/bad name/tools/inspect.ts': moduleSource,
+    'src/mcp/bad name/tools/inspect.ts': toolModuleSource,
   });
   const graph = await compileRouteGraph(root, fixtureConfig());
 
@@ -819,7 +839,7 @@ it('errors with AB4803 on unsafe identity segments', async () => {
 
 it('errors with AB4804 on invalid routes mode overrides', async () => {
   const root = await createRoot();
-  await writeTree(root, { 'src/mcp/curator/tools/inspect.ts': moduleSource });
+  await writeTree(root, { 'src/mcp/curator/tools/inspect.ts': toolModuleSource });
   const graph = await compileRouteGraph(root, fixtureConfig({
     routes: { cli: 42, servers: { curator: 'bogus' } },
   }));
@@ -859,7 +879,7 @@ it('serves the shared empty graph under the routes focus of a route-free project
 
 it('selects the compiled graph under the routes inspect focus', async () => {
   const root = await createInspectProject({
-    'src/mcp/curator/tools/inspect.ts': moduleSource,
+    'src/mcp/curator/tools/inspect.ts': toolModuleSource,
     'src/scripts/rebuild-index.ts': moduleSource,
   });
   const result = await inspect({ focus: 'routes', root });
@@ -877,18 +897,20 @@ it('shows projected MCP command provenance and safety in the routes inspect focu
   await writeTree(root, {
     'agent-bundle.config.ts': [
       'export default {',
-      "  plugin: { name: 'routes-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'routes-fixture' },",
       '  routes: { mcpCommands: true },',
       "  targets: ['portable'],",
       '};',
       '',
     ].join('\n'),
-    'package.json': '{"type":"module"}\n',
+    'package.json': '{"type":"module","version":"1.0.0"}\n',
     'src/mcp/curator/tools/read_item.tsx': [
-      "export const config = { annotations: { readOnlyHint: true }, description: 'Read one item.' };",
-      moduleSource,
+      "import { defineTool } from 'agent-bundle/routes';",
+      'export const inputSchema = {};',
+      'export const resultSchema = {};',
+      "export default defineTool({ annotations: { readOnlyHint: true }, description: 'Read one item.', inputSchema, resultSchema }, async () => undefined);",
     ].join('\n'),
-    'src/mcp/curator/tools/write_item.tsx': moduleSource,
+    'src/mcp/curator/tools/write_item.tsx': toolModuleSource,
   });
 
   const result = await inspect({ focus: 'routes', root });
@@ -916,7 +938,7 @@ it('shows projected MCP command provenance and safety in the routes inspect focu
 
 it('dumps the graph through the CLI --routes focus and rejects ambiguous focuses', async () => {
   const root = await createInspectProject({
-    'src/mcp/curator/tools/inspect.ts': moduleSource,
+    'src/mcp/curator/tools/inspect.ts': toolModuleSource,
   });
   const terminal = captureCliTerminal();
   const code = await runCli(['inspect', '--root', root, '--routes', '--json'], terminal.output);
@@ -942,15 +964,15 @@ it('evaluates a stateful config factory once when inspecting the routes focus',
       "  const path = join(ctx.projectRoot, 'config-load-count.txt');",
       "  writeFileSync(path, `${Number(readFileSync(path, 'utf8')) + 1}\\n`);",
       '  return {',
-      "    plugin: { name: 'routes-fixture', version: '1.0.0' },",
+      "    plugin: { name: 'routes-fixture' },",
       "    targets: ['portable'],",
       '  };',
       '};',
       '',
     ].join('\n'),
     'config-load-count.txt': '0\n',
-    'package.json': '{"type":"module"}\n',
-    'src/mcp/curator/tools/inspect.ts': moduleSource,
+    'package.json': '{"type":"module","version":"1.0.0"}\n',
+    'src/mcp/curator/tools/inspect.ts': toolModuleSource,
   });
 
   const result = await inspect({ focus: 'routes', root });
@@ -971,8 +993,10 @@ it('extracts each route module static config export into the graph', async () =>
   const root = await createRoot();
   await writeTree(root, {
     'src/mcp/curator/tools/search.ts': [
-      "export const config = { annotations: { readOnlyHint: true }, title: 'Search' } as const;",
-      moduleSource,
+      "import { defineTool } from 'agent-bundle/routes';",
+      'export const inputSchema = {};',
+      'export const resultSchema = {};',
+      "export default defineTool({ annotations: { readOnlyHint: true }, inputSchema, resultSchema, title: 'Search' }, async () => undefined);",
     ].join('\n'),
     'src/scripts/rebuild.ts': moduleSource,
   });
@@ -989,9 +1013,11 @@ it('compiles dynamic-config routes with an empty config beside the named error',
   const root = await createRoot();
   await writeTree(root, {
     'src/mcp/curator/tools/search.ts': [
+      "import { defineTool } from 'agent-bundle/routes';",
       'const title = process.env.TITLE;',
-      'export const config = { title };',
-      moduleSource,
+      'export const inputSchema = {};',
+      'export const resultSchema = {};',
+      'export default defineTool({ inputSchema, resultSchema, title }, async () => undefined);',
     ].join('\n'),
   });
 
@@ -1021,11 +1047,10 @@ it('resolves appResourceUri() references and local const strings to the App rout
       "export const config = { _meta: { ui: { resourceUri: URI } }, uri: 'catalog://books' };",
       moduleSource,
     ].join('\n'),
-    'src/mcp/curator/tools/inspect.ts': [
-      "import { appResourceUri as app } from 'agent-bundle/routes';",
-      "export const config = { _meta: { ui: { resourceUri: app('dashboard') } }, related: [app('../apps/dashboard'), app('curator/dashboard')] };",
-      moduleSource,
-    ].join('\n'),
+    'src/mcp/curator/tools/inspect.ts': toolSource(
+      "_meta: { ui: { resourceUri: app('dashboard') } }, related: [app('../apps/dashboard'), app('curator/dashboard')]",
+      ["import { appResourceUri as app } from 'agent-bundle/routes';"],
+    ),
   });
 
   const graph = await compileRouteGraph(root, fixtureConfig());
@@ -1047,11 +1072,10 @@ it('resolves appResourceUri() references and local const strings to the App rout
       "export const config = { resourceUri: 'ui://curator/panel.html' };",
       moduleSource,
     ].join('\n'),
-    'src/mcp/curator/tools/inspect.ts': [
-      "import { appResourceUri } from 'agent-bundle/routes';",
-      "export const config = { _meta: { ui: { resourceUri: appResourceUri('dashboard') } } };",
-      moduleSource,
-    ].join('\n'),
+    'src/mcp/curator/tools/inspect.ts': toolSource(
+      "_meta: { ui: { resourceUri: appResourceUri('dashboard') } }",
+      ["import { appResourceUri } from 'agent-bundle/routes';"],
+    ),
   });
   const renamedGraph = await compileRouteGraph(renamed, fixtureConfig());
   expect(renamedGraph.diagnostics).toEqual([]);
@@ -1066,16 +1090,14 @@ it('diagnoses an appResourceUri() reference to an unknown App with AB4826 and ke
       "export const config = { resourceUri: 'ui://curator/dashboard.html' };",
       moduleSource,
     ].join('\n'),
-    'src/mcp/curator/tools/inspect.ts': [
-      "import { appResourceUri } from 'agent-bundle/routes';",
-      "export const config = { _meta: { ui: { resourceUri: appResourceUri('panel') } } };",
-      moduleSource,
-    ].join('\n'),
-    'src/mcp/curator/tools/search.ts': [
-      "import { APP_RESOURCE_URI } from '../constants.ts';",
-      'export const config = { _meta: { ui: { resourceUri: APP_RESOURCE_URI } } };',
-      moduleSource,
-    ].join('\n'),
+    'src/mcp/curator/tools/inspect.ts': toolSource(
+      "_meta: { ui: { resourceUri: appResourceUri('panel') } }",
+      ["import { appResourceUri } from 'agent-bundle/routes';"],
+    ),
+    'src/mcp/curator/tools/search.ts': toolSource(
+      '_meta: { ui: { resourceUri: APP_RESOURCE_URI } }',
+      ["import { APP_RESOURCE_URI } from '../constants.ts';"],
+    ),
     'src/mcp/curator/constants.ts': "export const APP_RESOURCE_URI = process.env.APP_URI ?? 'ui://curator/dashboard.html';\n",
   });
 
@@ -1097,11 +1119,10 @@ it('diagnoses an appResourceUri() reference to an unknown App with AB4826 and ke
 
 it('resolves appResourceUri() references only against Apps of the same generated server', async () => {
   const app = "export const config = { resourceUri: 'ui://curator/dashboard.html' };\n" + moduleSource;
-  const referencing = (reference: string): string => [
-    "import { appResourceUri } from 'agent-bundle/routes';",
-    `export const config = { _meta: { ui: { resourceUri: appResourceUri('${reference}') } } };`,
-    moduleSource,
-  ].join('\n');
+  const referencing = (reference: string): string => toolSource(
+    `_meta: { ui: { resourceUri: appResourceUri('${reference}') } }`,
+    ["import { appResourceUri } from 'agent-bundle/routes';"],
+  );
 
   // A generated server registers only its own Apps, so a route on another
   // server can never serve this URI: the qualified form is rejected too.
@@ -1164,7 +1185,7 @@ it('resolves appResourceUri() references only against Apps of the same generated
     .toEqual({ _meta: { ui: { resourceUri: 'ui://curator/dashboard.html' } } });
 });
 
-it('resolves an App route template relative to the route module, accepting the legacy root-relative form only when unambiguous', async () => {
+it('resolves an App route template relative to the route module', async () => {
   const app = (template: string): string => [
     `export const config = { resourceUri: 'ui://curator/dashboard.html', template: '${template}' };`,
     moduleSource,
@@ -1182,37 +1203,12 @@ it('resolves an App route template relative to the route module, accepting the l
   expect(routeRelativeGraph.diagnostics).toEqual([]);
   expect(appOf(routeRelativeGraph).config).toEqual({ resourceUri: 'ui://curator/dashboard.html', template: './dashboard.html' });
 
-  // Legacy project-root-relative: still accepted while it is the only match, without a diagnostic.
-  const rootRelative = await createRoot();
-  await writeTree(rootRelative, {
-    'src/mcp/curator/apps/dashboard.tsx': app('./views/dashboard.html'),
-    'views/dashboard.html': html,
-  });
-  expect((await compileRouteGraph(rootRelative, fixtureConfig())).diagnostics).toEqual([]);
-
-  // Both interpretations name different existing files: AB4827 names both.
-  const ambiguous = await createRoot();
-  await writeTree(ambiguous, {
-    'src/mcp/curator/apps/dashboard.tsx': app('./views/dashboard.html'),
-    'src/mcp/curator/apps/views/dashboard.html': html,
-    'views/dashboard.html': html,
-  });
-  const ambiguousGraph = await compileRouteGraph(ambiguous, fixtureConfig());
-  expect(codesOf(ambiguousGraph.diagnostics)).toEqual(['AB4827']);
-  expect(ambiguousGraph.diagnostics[0]).toMatchObject({ severity: 'error', sourcePath: join(ambiguous, 'src/mcp/curator/apps/dashboard.tsx') });
-  expect(ambiguousGraph.diagnostics[0]!.message).toContain('names two different existing files');
-  expect(ambiguousGraph.diagnostics[0]!.message).toContain(`${join(ambiguous, 'src/mcp/curator/apps/views/dashboard.html')} (route-relative)`);
-  expect(ambiguousGraph.diagnostics[0]!.message).toContain(`${join(ambiguous, 'views/dashboard.html')} (project-root-relative)`);
-  expect(ambiguousGraph.diagnostics[0]!.recovery).toContain('relative to the route module');
-
-  // Neither exists: AB4827 names both candidates and the fix.
+  // A missing route-relative template reports the resolved candidate and fix.
   const missing = await createRoot();
   await writeTree(missing, { 'src/mcp/curator/apps/dashboard.tsx': app('./dashboard.html') });
   const missingGraph = await compileRouteGraph(missing, fixtureConfig());
   expect(codesOf(missingGraph.diagnostics)).toEqual(['AB4827']);
-  expect(missingGraph.diagnostics[0]!.message).toContain('but neither');
-  expect(missingGraph.diagnostics[0]!.message).toContain(`${join(missing, 'src/mcp/curator/apps/dashboard.html')} (route-relative)`);
-  expect(missingGraph.diagnostics[0]!.message).toContain(`${join(missing, 'dashboard.html')} (project-root-relative)`);
+  expect(missingGraph.diagnostics[0]!.message).toContain(`but ${join(missing, 'src/mcp/curator/apps/dashboard.html')} does not exist`);
 
   // An absolute template has a single candidate, which still has to exist.
   const absolute = await createRoot();
@@ -1238,15 +1234,13 @@ it('resolves an App route template relative to the route module, accepting the l
 });
 
 it('rejects a route that advertises an App the server does not build for every target with AB4828', async () => {
-  const tool = (resourceUri: string): string => [
-    `export const config = { _meta: { ui: { resourceUri: ${resourceUri} } } };`,
-    moduleSource,
-  ].join('\n');
+  const tool = (resourceUri: string, imports: readonly string[] = []): string =>
+    toolSource(`_meta: { ui: { resourceUri: ${resourceUri} } }`, imports);
   const restrictedApp = "export const config = { resourceUri: 'ui://curator/dashboard.html', targets: ['codex'] };\ndocument.body.textContent = 'dashboard';\n";
   const configWith = (lines: readonly string[]): string => [
     'export default {',
     ...lines,
-    "  plugin: { name: 'routes-fixture', version: '1.0.0' },",
+    "  plugin: { name: 'routes-fixture' },",
     "  targets: ['portable', 'codex'],",
     '};',
     '',
@@ -1256,7 +1250,7 @@ it('rejects a route that advertises an App the server does not build for every t
   const referenced = await createInspectProject({
     'agent-bundle.config.ts': configWith([]),
     'src/mcp/curator/apps/dashboard.tsx': restrictedApp,
-    'src/mcp/curator/tools/open.ts': tool("appResourceUri('dashboard')").replace('export const config', "import { appResourceUri } from 'agent-bundle/routes';\nexport const config"),
+    'src/mcp/curator/tools/open.ts': tool("appResourceUri('dashboard')", ["import { appResourceUri } from 'agent-bundle/routes';"]),
   });
   const referencedErrors = (await validate({ root: referenced })).diagnostics.filter((diagnostic) => diagnostic.severity === 'error');
   expect(codesOf(referencedErrors)).toEqual(['AB4828']);
@@ -1276,7 +1270,7 @@ it('rejects a route that advertises an App the server does not build for every t
   const restrictedServer = await createInspectProject({
     'agent-bundle.config.ts': configWith(["  mcp: { servers: { curator: { targets: ['codex'] } } },"]),
     'src/mcp/curator/apps/dashboard.tsx': restrictedApp,
-    'src/mcp/curator/tools/open.ts': tool("appResourceUri('dashboard')").replace('export const config', "import { appResourceUri } from 'agent-bundle/routes';\nexport const config"),
+    'src/mcp/curator/tools/open.ts': tool("appResourceUri('dashboard')", ["import { appResourceUri } from 'agent-bundle/routes';"]),
   });
   expect((await validate({ root: restrictedServer })).diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]);
 
@@ -1302,7 +1296,7 @@ it('rejects two App routes of one server that declare the same resourceUri with
   await writeTree(sameServer, {
     'src/mcp/curator/apps/dashboard.tsx': app('ui://curator/dashboard.html'),
     'src/mcp/curator/apps/panel.tsx': app('ui://curator/dashboard.html'),
-    'src/mcp/curator/tools/inspect.ts': moduleSource,
+    'src/mcp/curator/tools/inspect.ts': toolModuleSource,
   });
   const sameServerGraph = await compileRouteGraph(sameServer, fixtureConfig());
   expect(codesOf(sameServerGraph.diagnostics)).toEqual(['AB4829']);
@@ -1338,9 +1332,9 @@ it('rejects two App routes of one server that declare the same resourceUri with
   const acrossServers = await createRoot();
   await writeTree(acrossServers, {
     'src/mcp/archive/apps/dashboard.tsx': app('ui://shared/dashboard.html'),
-    'src/mcp/archive/tools/list.ts': moduleSource,
+    'src/mcp/archive/tools/list.ts': toolModuleSource,
     'src/mcp/curator/apps/dashboard.tsx': app('ui://shared/dashboard.html'),
-    'src/mcp/curator/tools/inspect.ts': moduleSource,
+    'src/mcp/curator/tools/inspect.ts': toolModuleSource,
   });
   const acrossServersGraph = await compileRouteGraph(acrossServers, fixtureConfig());
   expect(acrossServersGraph.diagnostics).toEqual([]);
@@ -1358,7 +1352,7 @@ it('rejects two App routes of one server that declare the same resourceUri with
   const project = await createInspectProject({
     'src/mcp/curator/apps/dashboard.tsx': app('ui://curator/dashboard.html'),
     'src/mcp/curator/apps/panel.tsx': app('ui://curator/dashboard.html'),
-    'src/mcp/curator/tools/inspect.ts': moduleSource,
+    'src/mcp/curator/tools/inspect.ts': toolModuleSource,
   });
   const validation = await validate({ root: project });
   expect(codesOf(validation.diagnostics.filter((diagnostic) => diagnostic.severity === 'error'))).toEqual(['AB4829']);
@@ -1370,30 +1364,12 @@ it('normalizes the App route template to its resolved path for the build', async
   const routeRelative = await createInspectProject({
     'src/mcp/curator/apps/dashboard.html': html,
     'src/mcp/curator/apps/dashboard.tsx': "export const config = { resourceUri: 'ui://curator/dashboard.html', template: './dashboard.html' };\ndocument.body.textContent = 'dashboard';\n",
-    'src/mcp/curator/tools/inspect.ts': moduleSource,
+    'src/mcp/curator/tools/inspect.ts': toolModuleSource,
   });
   const ready = (await inspect({ root: routeRelative })) as ReadyInspectResult;
   expect(ready.state).toBe('ready');
   expect(ready.model.mcpApps?.map((app) => app.template)).toEqual([join(routeRelative, 'src/mcp/curator/apps/dashboard.html')]);
 
-  const legacy = await createInspectProject({
-    'src/mcp/curator/apps/dashboard.tsx': "export const config = { resourceUri: 'ui://curator/dashboard.html', template: './views/dashboard.html' };\ndocument.body.textContent = 'dashboard';\n",
-    'src/mcp/curator/tools/inspect.ts': moduleSource,
-    'views/dashboard.html': html,
-  });
-  const legacyReady = (await inspect({ root: legacy })) as ReadyInspectResult;
-  expect(legacyReady.state).toBe('ready');
-  expect(legacyReady.model.mcpApps?.map((app) => app.template)).toEqual([join(legacy, 'views/dashboard.html')]);
-
-  const ambiguous = await createInspectProject({
-    'src/mcp/curator/apps/dashboard.tsx': "export const config = { resourceUri: 'ui://curator/dashboard.html', template: './views/dashboard.html' };\ndocument.body.textContent = 'dashboard';\n",
-    'src/mcp/curator/apps/views/dashboard.html': html,
-    'src/mcp/curator/tools/inspect.ts': moduleSource,
-    'views/dashboard.html': html,
-  });
-  const validation = await validate({ root: ambiguous });
-  expect(codesOf(validation.diagnostics.filter((diagnostic) => diagnostic.severity === 'error'))).toEqual(['AB4827']);
-  expect((await inspect({ root: ambiguous })).state).toBe('invalid');
 });
 
 it('covers the route config in the graph digest', async () => {
@@ -1655,13 +1631,14 @@ it('resolves generated helper types for schema and event route contracts and the
       '',
     ].join('\n'),
     'src/mcp/curator/tools/inspect.ts': [
+      "import { defineTool } from 'agent-bundle/routes';",
       '// What a caller sends (`_input`: the limit is optional) versus what the component receives (`_output`).',
       'export interface InspectInput { readonly source: string; readonly limit?: number; }',
       'export interface InspectParsedInput { readonly source: string; readonly limit: number; }',
       'export interface InspectResult { readonly accepted: boolean; }',
       'export const inputSchema = {} as { readonly _input: InspectInput; readonly _output: InspectParsedInput };',
       'export const resultSchema = {} as { readonly _output: InspectResult };',
-      'export default async function Inspect() { return undefined; }',
+      'export default defineTool({ inputSchema, resultSchema }, async function Inspect() { return undefined; });',
       '',
     ].join('\n'),
   });
@@ -1685,8 +1662,9 @@ it('resolves generated helper types for schema and event route contracts and the
     'tool-free/routes.d.ts': routesModule.generateRouteTypes(toolFree),
     // Stand-ins for the runtime's empty `Register` and `agent-bundle/app`'s empty `AppRegister`, so each
     // augmentation has a declaration to merge into.
-    'runtime-stub.d.ts': 'export interface Register {}\n',
+    'runtime-stub.d.ts': 'export interface AgentRequestContext {}\nexport interface Register {}\n',
     'app-stub.d.ts': 'export interface AppRegister {}\n',
+    'routes-stub.d.ts': 'export declare const defineTool: (config: unknown, handler: (...args: never[]) => unknown) => unknown;\n',
     'assertions.ts': [
       "import type { Register } from '@agent-bundle/runtime';",
       "import type { AppRegister } from 'agent-bundle/app';",
@@ -1752,6 +1730,7 @@ it('resolves generated helper types for schema and event route contracts and the
       paths: {
         '@agent-bundle/runtime': [join(root, 'runtime-stub.d.ts')],
         'agent-bundle/app': [join(root, 'app-stub.d.ts')],
+        'agent-bundle/routes': [join(root, 'routes-stub.d.ts')],
       },
       skipLibCheck: false,
       strict: true,
@@ -1772,17 +1751,17 @@ it('validates the single async route-module authoring contract statically', asyn
   const root = await createRoot();
   await writeTree(root, {
     'src/mcp/curator/tools/valid.tsx': [
+      "import { defineTool } from 'agent-bundle/routes';",
       'export const inputSchema = {};',
       'export const resultSchema = {};',
-      'export default async function Valid() { return undefined; }',
+      'export default defineTool({ inputSchema, resultSchema }, async function Valid() { return undefined; });',
       '',
     ].join('\n'),
     'src/mcp/curator/tools/split.tsx': [
-      'export const resultSchema = {};',
-      'export const execute = async () => ({});',
-      'export const render = () => undefined;',
-      'export default function Split() { return undefined; }',
-      '',
+    "export const resultSchema = {};",
+    "export const execute = async () => ({});",
+    "export const render = () => undefined;",
+    "export default function Split() { return undefined; }",
     ].join('\n'),
   });
 
@@ -1794,92 +1773,6 @@ it('validates the single async route-module authoring contract statically', asyn
   ]);
 });
 
-it('accepts explicit default re-exports when one tool is placed on two servers (#446)', async () => {
-  const root = await createRoot();
-  await writeTree(root, {
-    // The primary placement: a full route module.
-    'src/mcp/public/tools/search.tsx': [
-      "export const config = { description: 'Search.' };",
-      'export const inputSchema = {};',
-      'export const resultSchema = {};',
-      'export default async function Search() { return undefined; }',
-      '',
-    ].join('\n'),
-    // The second placement carries its own config and re-exports the rest.
-    'src/mcp/library/tools/search.tsx': [
-      "export const config = { description: 'Search from the widget server.' };",
-      "export { default, inputSchema, resultSchema } from '../../public/tools/search.tsx';",
-      '',
-    ].join('\n'),
-    // A named component aliased to default, through a shared page module.
-    'src/pages/download.tsx': 'export async function DownloadPage() { return undefined; }\n',
-    'src/mcp/library/tools/download.tsx': [
-      "export const config = { description: 'Download.' };",
-      "export { DownloadPage as default } from '../../../pages/download.tsx';",
-      "export { inputSchema, resultSchema } from '../../public/tools/search.tsx';",
-      '',
-    ].join('\n'),
-    // A chain: the shared module itself re-exports its default, and a
-    // `.js` specifier names the emitted extension of a `.tsx` source.
-    'src/pages/_delete-impl.tsx': 'export default async () => undefined;\n',
-    'src/pages/delete.tsx': "export { default } from './_delete-impl.js';\n",
-    'src/mcp/library/tools/delete.tsx': [
-      "export const config = { description: 'Delete.' };",
-      "export { default } from '../../../pages/delete.tsx';",
-      "export { inputSchema, resultSchema } from '../../public/tools/search.tsx';",
-      '',
-    ].join('\n'),
-    // A default from a package the scan cannot read is verified at run time.
-    'src/mcp/library/tools/external.tsx': [
-      "export const config = { description: 'External.' };",
-      'export const inputSchema = {};',
-      'export const resultSchema = {};',
-      "export { default } from '@shared/routes/external';",
-      '',
-    ].join('\n'),
-    // A synchronous component satisfies the same contract as an async component.
-    'src/pages/sync.tsx': 'export default function SyncPage() { return undefined; }\n',
-    'src/mcp/library/tools/sync.tsx': [
-      "export const config = { description: 'Sync.' };",
-      "export { default } from '../../../pages/sync.tsx';",
-      "export { inputSchema, resultSchema } from '../../public/tools/search.tsx';",
-      '',
-    ].join('\n'),
-    // A type-only default re-export emits no binding and never satisfies the contract.
-    'src/mcp/library/tools/typed.tsx': [
-      "export const config = { description: 'Typed.' };",
-      "export { type default } from '../../public/tools/search.tsx';",
-      "export { inputSchema, resultSchema } from '../../public/tools/search.tsx';",
-      '',
-    ].join('\n'),
-  });
-
-  const graph = await compileRouteGraph(root, fixtureConfig());
-
-  expect(graph.diagnostics.map(({ code, message, sourcePath }) => ({
-    code,
-    message,
-    source: sourcePath?.slice(root.length + 1).replaceAll('\\', '/'),
-  }))).toEqual([
-    {
-      code: 'AB4810',
-      message: 'Route module src/mcp/library/tools/typed.tsx does not satisfy the public route contract: missing default export.',
-      source: 'src/mcp/library/tools/typed.tsx',
-    },
-  ]);
-  expect(graph.servers.map((server) => [server.name, server.routes.map((route) => route.id)])).toEqual([
-    ['library', [
-      'tool:library/delete',
-      'tool:library/download',
-      'tool:library/external',
-      'tool:library/search',
-      'tool:library/sync',
-      'tool:library/typed',
-    ]],
-    ['public', ['tool:public/search']],
-  ]);
-});
-
 it('reads declared runtime exports without following re-exports or requiring async', () => {
   const scan = routesModule.scanRouteModuleExports;
   expect([...scan("export { default, value } from './missing.js';", 'route.ts').named]).toEqual(['default', 'value']);
@@ -2100,7 +1993,7 @@ it('fails unavailable event routes before packaging while admitting supported ta
   const eventSource = 'export default async function WorkspaceOpen() { return undefined; }\n';
   const configSource = [
     'export default {',
-    "  plugin: { name: 'event-capability-fixture', version: '1.0.0' },",
+    "  plugin: { name: 'event-capability-fixture' },",
     "  targets: ['claude', 'cursor'],",
     '};',
     '',
@@ -2143,7 +2036,7 @@ it('selects event-route projections by required capability rows', async () => {
   await writeTree(root, {
     'agent-bundle.config.ts': [
       'export default {',
-      "  plugin: { name: 'event-requires-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'event-requires-fixture' },",
       "  targets: ['claude', 'codex', 'cursor', 'portable'],",
       '};',
       '',
@@ -2169,7 +2062,7 @@ it('rejects event routes that declare both targets and requires', async () => {
   await writeTree(root, {
     'agent-bundle.config.ts': [
       'export default {',
-      "  plugin: { name: 'event-selection-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'event-selection-fixture' },",
       "  targets: ['cursor'],",
       '};',
       '',
@@ -2196,7 +2089,7 @@ it('names every selected host and capability row when no projection satisfies a
   await writeTree(root, {
     'agent-bundle.config.ts': [
       'export default {',
-      "  plugin: { name: 'event-requirement-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'event-requirement-fixture' },",
       "  targets: ['cursor', 'portable'],",
       '};',
       '',
@@ -2224,7 +2117,7 @@ it('rejects malformed event route targets with AB4825', async () => {
   await writeTree(root, {
     'agent-bundle.config.ts': [
       'export default {',
-      "  plugin: { name: 'event-targets-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'event-targets-fixture' },",
       "  targets: ['cursor'],",
       '};',
       '',
@@ -2250,7 +2143,7 @@ it('preserves sub-second event route timeout precision in the normalized model',
   await writeTree(root, {
     'agent-bundle.config.ts': [
       'export default {',
-      "  plugin: { name: 'event-timeout-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'event-timeout-fixture' },",
       "  targets: ['cursor'],",
       '};',
       '',
@@ -2276,7 +2169,7 @@ it('rejects explicit shared mode when no generated runtime can host an event rou
   await writeTree(root, {
     'agent-bundle.config.ts': [
       'export default {',
-      "  plugin: { name: 'event-runtime-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'event-runtime-fixture' },",
       "  targets: ['cursor'],",
       '};',
       '',
diff --git a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts
index fc41aea6d..69e792b57 100644
--- a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts
+++ b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts
@@ -24,7 +24,7 @@ import { createWorkbenchAssetSource } from '../src/dev/workbench-assets.ts';
 import { startDevServer } from '../src/dev/workbench-server.ts';
 import { gatedRouteFiles } from './helpers/gated-routes.ts';
 import { createProjectFixture } from './helpers/project-fixture.ts';
-import { agentBundleNodeModules } from './helpers/workspace-paths.ts';
+import { exampleNodeModules } from './helpers/workspace-paths.ts';
 import { replaceWatchedSourceAndAwaitRebuild } from './support/watched-files.ts';
 import { runNodeScript } from './support/run-node-script.ts';
 
@@ -114,7 +114,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim
       "import { join } from 'node:path';",
       '',
       'export default {',
-      "  plugin: { name: 'route-invocation-dev-server', version: '1.0.0' },",
+      "  plugin: { name: 'route-invocation-dev-server' },",
       "  targets: ['claude'],",
       '  tools: {',
       "    rsbuild: { source: { define: { __ROUTE_INVOCATION_DEFINE__: JSON.stringify('defined') } } },",
@@ -251,136 +251,163 @@ it('invokes compiled tool and event routes through the foreground server', { tim
         '',
       ].join('\n'),
       'src/mcp/alpha/tools/fail.tsx': [
-        "import { appendFileSync } from 'node:fs';",
-        "import { z } from 'zod';",
-        '',
-        "appendFileSync('.agent-bundle/alpha-worker.marker', 'load\\n');",
-        'export const inputSchema = z.object({}).strict();',
-        'export const resultSchema = z.object({ failed: z.boolean() }).strict();',
-        '',
-        'export default async function Fail() {',
-        "  appendFileSync('.agent-bundle/alpha-handler.marker', 'run\\n');",
-        "  throw new Error('Generated route must default-export an async Server Component.');",
-        '}',
-        '',
+      "import { defineTool } from 'agent-bundle/routes';",
+      "import { appendFileSync } from 'node:fs';",
+      "import { z } from 'zod';",
+      "",
+      "appendFileSync('.agent-bundle/alpha-worker.marker', 'load\\n');",
+      "export const inputSchema = z.object({}).strict();",
+      "export const resultSchema = z.object({ failed: z.boolean() }).strict();",
+      "",
+      "export default defineTool({",
+      "  inputSchema,",
+      "  resultSchema,",
+      "}, async () => {",
+      "  appendFileSync('.agent-bundle/alpha-handler.marker', 'run\\n');",
+      "  throw new Error('Generated route must default-export an async Server Component.');",
+      "});",
       ].join('\n'),
       'src/mcp/omega/tools/pass.tsx': [
-        "import { appendFileSync } from 'node:fs';",
-        "import { Agent } from '@agent-bundle/runtime';",
-        "import { createElement } from 'react';",
-        "import { z } from 'zod';",
-        '',
-        "appendFileSync('.agent-bundle/omega-worker.marker', 'load\\n');",
-        'export const inputSchema = z.object({}).strict();',
-        "export const resultSchema = z.object({ selected: z.literal('omega') }).strict();",
-        '',
-        'export default async function Pass() {',
-        "  return createElement(Agent.Result, { value: { selected: 'omega' } });",
-        '}',
-        '',
+      "import { defineTool } from 'agent-bundle/routes';",
+      "import { appendFileSync } from 'node:fs';",
+      "import { Agent } from '@agent-bundle/runtime';",
+      "import { createElement } from 'react';",
+      "import { z } from 'zod';",
+      "",
+      "appendFileSync('.agent-bundle/omega-worker.marker', 'load\\n');",
+      "export const inputSchema = z.object({}).strict();",
+      "export const resultSchema = z.object({ selected: z.literal('omega') }).strict();",
+      "",
+      "export default defineTool({",
+      "  inputSchema,",
+      "  resultSchema,",
+      "}, async () => {",
+      "  return createElement(Agent.Result, { value: { selected: 'omega' } });",
+      "});",
       ].join('\n'),
       'src/mcp/importer/tools/fail.tsx': [
-        "import { appendFileSync } from 'node:fs';",
-        "import { z } from 'zod';",
-        '',
-        "appendFileSync('.agent-bundle/importer-worker.marker', 'load\\n');",
-        "throw new Error('Generated route must default-export from import.');",
-        'export const inputSchema = z.object({}).strict();',
-        'export const resultSchema = z.object({ failed: z.boolean() }).strict();',
-        'export default async function Fail() { return undefined; }',
-        '',
+      "import { defineTool } from 'agent-bundle/routes';",
+      "import { appendFileSync } from 'node:fs';",
+      "import { z } from 'zod';",
+      "",
+      "appendFileSync('.agent-bundle/importer-worker.marker', 'load\\n');",
+      "throw new Error('Generated route must default-export from import.');",
+      "export const inputSchema = z.object({}).strict();",
+      "export const resultSchema = z.object({ failed: z.boolean() }).strict();",
+      "export default defineTool({",
+      "  inputSchema,",
+      "  resultSchema,",
+      "}, async () => { return undefined; });",
       ].join('\n'),
       'src/mcp/status/tools/counter.tsx': [
-        "import { Agent, agent } from '@agent-bundle/runtime';",
-        "import { createElement } from 'react';",
-        "import { z } from 'zod';",
-        '',
-        'export const inputSchema = z.object({ key: z.string() }).strict();',
-        'export const resultSchema = z.object({ count: z.number() }).strict();',
-        'export default async function Counter({ input }) {',
-        '  const context = await agent();',
-        "  if (context.state === undefined) throw new Error('state unavailable');",
-        "  const committed = await context.state.dispatch('incremented', { by: 1 }, { idempotencyKey: `${input.key}:${crypto.randomUUID()}` });",
-        '  return createElement(Agent.Result, { value: { count: committed.state.count } });',
-        '}',
-        '',
+      "import { defineTool } from 'agent-bundle/routes';",
+      "import { Agent, agent } from '@agent-bundle/runtime';",
+      "import { createElement } from 'react';",
+      "import { z } from 'zod';",
+      "",
+      "export const inputSchema = z.object({ key: z.string() }).strict();",
+      "export const resultSchema = z.object({ count: z.number() }).strict();",
+      "export default defineTool({",
+      "  inputSchema,",
+      "  resultSchema,",
+      "}, async (input) => {",
+      "  const context = await agent();",
+      "  if (context.state === undefined) throw new Error('state unavailable');",
+      "  const committed = await context.state.dispatch('incremented', { by: 1 }, { idempotencyKey: `${input.key}:${crypto.randomUUID()}` });",
+      "  return createElement(Agent.Result, { value: { count: committed.state.count } });",
+      "});",
       ].join('\n'),
       'src/mcp/status/tools/refuse.tsx': [
-        "import { Agent } from '@agent-bundle/runtime';",
-        "import { createElement } from 'react';",
-        "import { z } from 'zod';",
-        '',
-        'export const inputSchema = z.object({ reason: z.string() }).strict();',
-        'export const resultSchema = z.object({ refused: z.boolean() }).strict();',
-        'export default async function Refuse({ input }) {',
-        "  return createElement(Agent.Result, { value: { refused: true } }, createElement(Agent.Error, { code: 'refused' }, `Refused: ${input.reason}`));",
-        '}',
-        '',
+      "import { defineTool } from 'agent-bundle/routes';",
+      "import { Agent } from '@agent-bundle/runtime';",
+      "import { createElement } from 'react';",
+      "import { z } from 'zod';",
+      "",
+      "export const inputSchema = z.object({ reason: z.string() }).strict();",
+      "export const resultSchema = z.object({ refused: z.boolean() }).strict();",
+      "export default defineTool({",
+      "  inputSchema,",
+      "  resultSchema,",
+      "}, async (input) => {",
+      "  return createElement(Agent.Result, { value: { refused: true } }, createElement(Agent.Error, { code: 'refused' }, `Refused: ${input.reason}`));",
+      "});",
       ].join('\n'),
       'src/mcp/status/tools/report.tsx': [
-        "import { Agent, agent } from '@agent-bundle/runtime';",
-        "import { ALIAS_VALUE } from '@fixture/value';",
-        "import { createElement } from 'react';",
-        "import { z } from 'zod';",
-        "import { Panel } from './panel.js';",
-        'declare const __ROUTE_INVOCATION_DEFINE__: string;',
-        '',
-        "export const config = { inputJsonSchema: {\"additionalProperties\":false,\"properties\":{\"service\":{\"type\":\"string\"},\"source\":{\"type\":\"string\"}},\"required\":[\"service\",\"source\"],\"type\":\"object\"}, annotations: { readOnlyHint: true }, description: 'Reports one service.' };",
-        "export const inputSchema = z.object({ service: z.string().min(1), source: z.string() }).strict();",
-        'export const resultSchema = z.object({ alias: z.string(), define: z.string(), pluginRoot: z.string(), service: z.string(), source: z.string(), stateRoot: z.string() }).strict();',
-        '',
-        'export default async function Report({ input }) {',
-        '  const context = await agent();',
-        '  await context.provider("clock");',
-        "  if (context.plugin.state !== 'available') throw new Error('plugin unavailable');",
-        '  const value = { alias: ALIAS_VALUE, define: __ROUTE_INVOCATION_DEFINE__, pluginRoot: context.plugin.value.root, service: input.service, source: input.source, stateRoot: context.plugin.value.stateRoot };',
-        "  return createElement(Agent.Result, { value }, createElement(Panel), createElement(Agent.Text, null, './panel.js'), createElement(Agent.Text, null, `Service ${input.service}`));",
-        '}',
-        '',
+      "import { defineTool } from 'agent-bundle/routes';",
+      "import { Agent, agent } from '@agent-bundle/runtime';",
+      "import { ALIAS_VALUE } from '@fixture/value';",
+      "import { createElement } from 'react';",
+      "import { z } from 'zod';",
+      "import { Panel } from './panel.js';",
+      "declare const __ROUTE_INVOCATION_DEFINE__: string;",
+      "",
+      "export const inputSchema = z.object({ service: z.string().min(1), source: z.string() }).strict();",
+      "export const resultSchema = z.object({ alias: z.string(), define: z.string(), pluginRoot: z.string(), service: z.string(), source: z.string(), stateRoot: z.string() }).strict();",
+      "",
+      "export default defineTool({",
+      "inputJsonSchema: {\"additionalProperties\":false,\"properties\":{\"service\":{\"type\":\"string\"},\"source\":{\"type\":\"string\"}},\"required\":[\"service\",\"source\"],\"type\":\"object\"}, annotations: { readOnlyHint: true }, description: 'Reports one service.',",
+      "  inputSchema,",
+      "  resultSchema,",
+      "}, async (input) => {",
+      "  const context = await agent();",
+      "  await context.provider(\"clock\");",
+      "  if (context.plugin.state !== 'available') throw new Error('plugin unavailable');",
+      "  const value = { alias: ALIAS_VALUE, define: __ROUTE_INVOCATION_DEFINE__, pluginRoot: context.plugin.value.root, service: input.service, source: input.source, stateRoot: context.plugin.value.stateRoot };",
+      "  return createElement(Agent.Result, { value }, createElement(Panel), createElement(Agent.Text, null, './panel.js'), createElement(Agent.Text, null, `Service ${input.service}`));",
+      "});",
       ].join('\n'),
       'src/mcp/status/tools/panel.tsx': [
-        "import { Agent } from '@agent-bundle/runtime';",
-        "import { createElement } from 'react';",
-        "import { z } from 'zod';",
-        '',
-        "export const Panel = () => createElement(Agent.Text, null, 'panel rendered');",
-        "export const inputSchema = z.object({}).strict();",
-        "export const resultSchema = z.object({ panel: z.literal(true) }).strict();",
-        'export default async function PanelRoute() {',
-        "  return createElement(Agent.Result, { value: { panel: true } }, createElement(Panel));",
-        '}',
-        '',
+      "import { defineTool } from 'agent-bundle/routes';",
+      "import { Agent } from '@agent-bundle/runtime';",
+      "import { createElement } from 'react';",
+      "import { z } from 'zod';",
+      "",
+      "export const Panel = () => createElement(Agent.Text, null, 'panel rendered');",
+      "export const inputSchema = z.object({}).strict();",
+      "export const resultSchema = z.object({ panel: z.literal(true) }).strict();",
+      "export default defineTool({",
+      "  inputSchema,",
+      "  resultSchema,",
+      "}, async () => {",
+      "  return createElement(Agent.Result, { value: { panel: true } }, createElement(Panel));",
+      "});",
       ].join('\n'),
       'src/mcp/status/tools/crash.tsx': [
-        "import { Agent } from '@agent-bundle/runtime';",
-        "import { createElement, Suspense } from 'react';",
-        "import { z } from 'zod';",
-        '',
-        'export const inputSchema = z.object({}).strict();',
-        'export const resultSchema = z.object({ ok: z.boolean() }).strict();',
-        "const Boom = async () => { throw new Error('render exploded'); };",
-        '',
-        'export default async function Crash() {',
-        "  return createElement(Agent.Result, { value: { ok: true } }, createElement(Suspense, { fallback: createElement(Agent.Progress, { completed: 0, message: 'crashing', total: 1 }) }, createElement(Boom)));",
-        '}',
-        '',
+      "import { defineTool } from 'agent-bundle/routes';",
+      "import { Agent } from '@agent-bundle/runtime';",
+      "import { createElement, Suspense } from 'react';",
+      "import { z } from 'zod';",
+      "",
+      "export const inputSchema = z.object({}).strict();",
+      "export const resultSchema = z.object({ ok: z.boolean() }).strict();",
+      "const Boom = async () => { throw new Error('render exploded'); };",
+      "",
+      "export default defineTool({",
+      "  inputSchema,",
+      "  resultSchema,",
+      "}, async () => {",
+      "  return createElement(Agent.Result, { value: { ok: true } }, createElement(Suspense, { fallback: createElement(Agent.Progress, { completed: 0, message: 'crashing', total: 1 }) }, createElement(Boom)));",
+      "});",
       ].join('\n'),
       'src/mcp/status/tools/burst.tsx': [
-        "import { Agent, agent } from '@agent-bundle/runtime';",
-        "import { createElement } from 'react';",
-        "import { z } from 'zod';",
-        '',
-        'export const inputSchema = z.object({ messageBytes: z.number().int().min(1).max(65_536), ticks: z.number().int().min(1).max(900) }).strict();',
-        'export const resultSchema = z.object({ ticks: z.number() }).strict();',
-        '',
-        'export default async function Burst({ input }) {',
-        '  const { progress } = await agent();',
-        '  for (let step = 1; step <= input.ticks; step += 1) {',
-        "    await progress.report({ completed: step, message: `${step}:${'p'.repeat(input.messageBytes)}`, total: input.ticks });",
-        '  }',
-        "  return createElement(Agent.Result, { value: { ticks: input.ticks } }, createElement(Agent.Text, null, `Reported ${input.ticks} ticks.`));",
-        '}',
-        '',
+      "import { defineTool } from 'agent-bundle/routes';",
+      "import { Agent, agent } from '@agent-bundle/runtime';",
+      "import { createElement } from 'react';",
+      "import { z } from 'zod';",
+      "",
+      "export const inputSchema = z.object({ messageBytes: z.number().int().min(1).max(65_536), ticks: z.number().int().min(1).max(900) }).strict();",
+      "export const resultSchema = z.object({ ticks: z.number() }).strict();",
+      "",
+      "export default defineTool({",
+      "  inputSchema,",
+      "  resultSchema,",
+      "}, async (input) => {",
+      "  const { progress } = await agent();",
+      "  for (let step = 1; step <= input.ticks; step += 1) {",
+      "    await progress.report({ completed: step, message: `${step}:${'p'.repeat(input.messageBytes)}`, total: input.ticks });",
+      "  }",
+      "  return createElement(Agent.Result, { value: { ticks: input.ticks } }, createElement(Agent.Text, null, `Reported ${input.ticks} ticks.`));",
+      "});",
       ].join('\n'),
       'src/mcp/status/tools/report.cli.ts': [
         "export const config = { command: ['report'], confirm: true, flags: { service: { name: 'name' }, source: { required: false } } };",
@@ -389,14 +416,17 @@ it('invokes compiled tool and event routes through the foreground server', { tim
       ].join('\n'),
       'src/mcp/status/tools/plain.cli.ts': "export const config = { command: ['plain-tool'] };\n",
       'src/mcp/status/tools/plain.ts': [
-        "import { Agent } from '@agent-bundle/runtime';",
-        "import { createElement } from 'react';",
-        "import { z } from 'zod';",
-        '',
-        'export const inputSchema = z.object({}).strict();',
-        "export const resultSchema = z.object({ selected: z.literal('plain-tool') }).strict();",
-        "export default async function PlainTool() { return createElement(Agent.Result, { value: { selected: 'plain-tool' } }); }",
-        '',
+      "import { defineTool } from 'agent-bundle/routes';",
+      "import { Agent } from '@agent-bundle/runtime';",
+      "import { createElement } from 'react';",
+      "import { z } from 'zod';",
+      "",
+      "export const inputSchema = z.object({}).strict();",
+      "export const resultSchema = z.object({ selected: z.literal('plain-tool') }).strict();",
+      "export default defineTool({",
+      "  inputSchema,",
+      "  resultSchema,",
+      "}, async () => { return createElement(Agent.Result, { value: { selected: 'plain-tool' } }); });",
       ].join('\n'),
       'src/providers/clock.ts': [
         'export default () => ({ now: 0 });',
@@ -431,7 +461,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim
   let server: Awaited> | undefined;
   await mkdir(assetsRoot, { recursive: true });
   await Promise.all([
-    symlink(agentBundleNodeModules, join(project.root, 'node_modules'), 'dir'),
+    symlink(exampleNodeModules, join(project.root, 'node_modules'), 'dir'),
     writeFile(join(assetsRoot, 'index.html'), 'Route invocation'),
   ]);
   try {
@@ -1364,20 +1394,25 @@ it('invokes compiled tool and event routes through the foreground server', { tim
       project.root,
       reportRoutePath,
       [
+        "import { defineTool } from 'agent-bundle/routes';",
         "import { Agent } from '@agent-bundle/runtime';",
         "import { createElement } from 'react';",
         "import { z } from 'zod';",
         "import './missing.js';",
         '',
-        "export const config = { inputJsonSchema: {\"additionalProperties\":false,\"properties\":{\"service\":{\"type\":\"string\"}},\"required\":[\"service\"],\"type\":\"object\"}, annotations: { readOnlyHint: true }, description: 'Reports one service.' };",
-        "export const config = { inputJsonSchema: { type: 'object', additionalProperties: false, properties: { service: { type: 'string' } }, required: ['service'] } };",
         "export const inputSchema = z.object({ service: z.string().min(1) }).strict();",
         'export const resultSchema = z.object({ service: z.string() }).strict();',
         '',
-        'export default async function Report({ input }) {',
+        'export default defineTool({',
+        "  inputJsonSchema: {\"additionalProperties\":false,\"properties\":{\"service\":{\"type\":\"string\"}},\"required\":[\"service\"],\"type\":\"object\"},",
+        "  annotations: { readOnlyHint: true },",
+        "  description: 'Reports one service.',",
+        '  inputSchema,',
+        '  resultSchema,',
+        '}, async (input) => {',
         '  const service = `rebuilt-${input.service}`;',
         '  return createElement(Agent.Result, { value: { service } }, createElement(Agent.Text, null, `Service ${service}`));',
-        '}',
+        '});',
         '',
       ].join('\n'),
       { timeoutMs: 20_000 },
@@ -1409,18 +1444,24 @@ it('invokes compiled tool and event routes through the foreground server', { tim
       project.root,
       reportRoutePath,
       [
+        "import { defineTool } from 'agent-bundle/routes';",
         "import { Agent } from '@agent-bundle/runtime';",
         "import { createElement } from 'react';",
         "import { z } from 'zod';",
         '',
-        "export const config = { inputJsonSchema: {\"additionalProperties\":false,\"properties\":{\"service\":{\"type\":\"string\"},\"source\":{\"type\":\"string\"}},\"required\":[\"service\"],\"type\":\"object\"}, annotations: { readOnlyHint: true }, description: 'Reports one service.' };",
         "export const inputSchema = z.object({ service: z.string().min(1), source: z.string().optional() }).strict();",
         'export const resultSchema = z.object({ service: z.string() }).strict();',
         '',
-        'export default async function Report({ input }) {',
+        'export default defineTool({',
+        "  inputJsonSchema: {\"additionalProperties\":false,\"properties\":{\"service\":{\"type\":\"string\"},\"source\":{\"type\":\"string\"}},\"required\":[\"service\"],\"type\":\"object\"},",
+        "  annotations: { readOnlyHint: true },",
+        "  description: 'Reports one service.',",
+        '  inputSchema,',
+        '  resultSchema,',
+        '}, async (input) => {',
         '  const service = `rebuilt-${input.service}`;',
         '  return createElement(Agent.Result, { value: { service } }, createElement(Agent.Text, null, `Service ${service}`));',
-        '}',
+        '});',
         '',
       ].join('\n'),
       { timeoutMs: 20_000 },
@@ -1458,7 +1499,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim
 
 it('fails closed when a valid host is ineligible for the compiled event route', { timeout: 180_000 }, async () => {
   const project = await createProjectFixture({
-    config: "export default { plugin: { name: 'route-invocation-host-binding', version: '1.0.0' }, targets: ['claude', 'codex'] };\n",
+    config: "export default { plugin: { name: 'route-invocation-host-binding' }, targets: ['claude', 'codex'] };\n",
     files: {
       'package.json': '{"dependencies":{"@agent-bundle/runtime":"workspace:*"},"type":"module"}\n',
       'src/events/tool/before.ts': "export const config = { runtime: 'standalone', targets: ['claude'] }; export default () => ({ outcome: 'deny', reason: 'blocked' });\n",
@@ -1479,7 +1520,7 @@ it('fails closed when a valid host is ineligible for the compiled event route',
   let server: Awaited> | undefined;
   await mkdir(assetsRoot, { recursive: true });
   await Promise.all([
-    symlink(agentBundleNodeModules, join(project.root, 'node_modules'), 'dir'),
+    symlink(exampleNodeModules, join(project.root, 'node_modules'), 'dir'),
     writeFile(join(assetsRoot, 'index.html'), 'Route invocation host binding'),
   ]);
   try {
@@ -1560,7 +1601,7 @@ it('fails closed when a valid host is ineligible for the compiled event route',
 
 it('enforces compiled handler, MCP schemas, and operator env across production surfaces', { timeout: 180_000 }, async () => {
   const project = await createProjectFixture({
-    config: "export default { plugin: { name: 'route-parity', version: '1.0.0' }, targets: ['claude'] };\n",
+    config: "export default { plugin: { name: 'route-parity' }, targets: ['claude'] };\n",
     files: {
       'package.json': '{"dependencies":{"@agent-bundle/runtime":"workspace:*","react":"19.2.8","zod":"4.5.4"},"type":"module"}\n',
       'src/events/tool/before.ts': "export default () => ({ outcome: 'deny', reason: 'blocked' });\n",
@@ -1573,20 +1614,23 @@ it('enforces compiled handler, MCP schemas, and operator env across production s
         '',
       ].join('\n'),
       'src/mcp/status/tools/report.tsx': [
-        "import { writeFileSync } from 'node:fs';",
-        "import { Agent } from '@agent-bundle/runtime';",
-        "import { createElement } from 'react';",
-        "import { z } from 'zod';",
-        '',
-        "export const config = { inputJsonSchema: { type: 'object', additionalProperties: false, properties: { service: { type: 'string' } }, required: ['service'] } };",
-        "export const inputSchema = z.object({ service: z.string().min(1) }).strict();",
-        "export const resultSchema = z.object({ operator: z.string(), service: z.string() }).strict();",
-        '',
-        'export default async function Report({ input }) {',
-        "  writeFileSync('.agent-bundle/handler-ran', 'yes');",
-        "  return createElement(Agent.Result, { value: { operator: process.env.OPERATOR_VALUE ?? 'missing', service: input.service } });",
-        '}',
-        '',
+      "import { defineTool } from 'agent-bundle/routes';",
+      "import { writeFileSync } from 'node:fs';",
+      "import { Agent } from '@agent-bundle/runtime';",
+      "import { createElement } from 'react';",
+      "import { z } from 'zod';",
+      "",
+      "export const inputSchema = z.object({ service: z.string().min(1) }).strict();",
+      "export const resultSchema = z.object({ operator: z.string(), service: z.string() }).strict();",
+      "",
+      "export default defineTool({",
+      "inputJsonSchema: { type: 'object', additionalProperties: false, properties: { service: { type: 'string' } }, required: ['service'] },",
+      "  inputSchema,",
+      "  resultSchema,",
+      "}, async (input) => {",
+      "  writeFileSync('.agent-bundle/handler-ran', 'yes');",
+      "  return createElement(Agent.Result, { value: { operator: process.env.OPERATOR_VALUE ?? 'missing', service: input.service } });",
+      "});",
       ].join('\n'),
     },
     prefix: 'agent-bundle-route-parity-',
@@ -1595,7 +1639,7 @@ it('enforces compiled handler, MCP schemas, and operator env across production s
   let server: Awaited> | undefined;
   await mkdir(assetsRoot, { recursive: true });
   await Promise.all([
-    symlink(agentBundleNodeModules, join(project.root, 'node_modules'), 'dir'),
+    symlink(exampleNodeModules, join(project.root, 'node_modules'), 'dir'),
     writeFile(join(assetsRoot, 'index.html'), 'Route parity'),
   ]);
   try {
@@ -1696,7 +1740,7 @@ it('publishes invocation routes only after a successful initial or recovered bui
   const project = await createProjectFixture({
     config: [
       'export default {',
-      "  plugin: { name: 'route-invocation-publication-gate', version: '1.0.0' },",
+      "  plugin: { name: 'route-invocation-publication-gate' },",
       "  targets: ['claude'],",
       '};',
       '',
@@ -1704,18 +1748,21 @@ it('publishes invocation routes only after a successful initial or recovered bui
     files: {
       'package.json': '{"dependencies":{"@agent-bundle/runtime":"workspace:*","react":"19.2.8","zod":"4.5.4"},"type":"module"}\n',
       'src/mcp/status/tools/report.tsx': [
-        "import { Agent } from '@agent-bundle/runtime';",
-        "import { createElement } from 'react';",
-        "import { z } from 'zod';",
-        "import './missing.js';",
-        '',
-        "export const inputSchema = z.object({}).strict();",
-        'export const resultSchema = z.object({ version: z.string() }).strict();',
-        '',
-        'export default async function Report() {',
-        "  return createElement(Agent.Result, { value: { version: 'published' } }, createElement(Agent.Text, null, 'Published route.'));",
-        '}',
-        '',
+      "import { defineTool } from 'agent-bundle/routes';",
+      "import { Agent } from '@agent-bundle/runtime';",
+      "import { createElement } from 'react';",
+      "import { z } from 'zod';",
+      "import './missing.js';",
+      "",
+      "export const inputSchema = z.object({}).strict();",
+      "export const resultSchema = z.object({ version: z.string() }).strict();",
+      "",
+      "export default defineTool({",
+      "  inputSchema,",
+      "  resultSchema,",
+      "}, async () => {",
+      "  return createElement(Agent.Result, { value: { version: 'published' } }, createElement(Agent.Text, null, 'Published route.'));",
+      "});",
       ].join('\n'),
     },
     prefix: 'agent-bundle-route-invocation-publication-gate-',
@@ -1725,7 +1772,7 @@ it('publishes invocation routes only after a successful initial or recovered bui
   let server: Awaited> | undefined;
   await mkdir(assetsRoot, { recursive: true });
   await Promise.all([
-    symlink(agentBundleNodeModules, join(project.root, 'node_modules'), 'dir'),
+    symlink(exampleNodeModules, join(project.root, 'node_modules'), 'dir'),
     writeFile(join(assetsRoot, 'index.html'), 'Route publication gate'),
   ]);
   try {
@@ -1769,6 +1816,7 @@ it('publishes invocation routes only after a successful initial or recovered bui
       project.root,
       reportRoutePath,
       [
+        "import { defineTool } from 'agent-bundle/routes';",
         "import { Agent } from '@agent-bundle/runtime';",
         "import { createElement } from 'react';",
         "import { z } from 'zod';",
@@ -1776,9 +1824,9 @@ it('publishes invocation routes only after a successful initial or recovered bui
         'export const inputSchema = z.object({}).strict();',
         'export const resultSchema = z.object({ version: z.string() }).strict();',
         '',
-        'export default async function Report() {',
+        'export default defineTool({ inputSchema, resultSchema }, async () => {',
         "  return createElement(Agent.Result, { value: { version: 'published' } }, createElement(Agent.Text, null, 'Published route.'));",
-        '}',
+        '});',
         '',
       ].join('\n'),
       { timeoutMs: 10_000 },
@@ -1799,6 +1847,7 @@ it('publishes invocation routes only after a successful initial or recovered bui
     await server.close();
     server = undefined;
     await writeFile(reportRoutePath, [
+      "import { defineTool } from 'agent-bundle/routes';",
       "import { Agent } from '@agent-bundle/runtime';",
       "import { createElement } from 'react';",
       "import { z } from 'zod';",
@@ -1807,9 +1856,9 @@ it('publishes invocation routes only after a successful initial or recovered bui
       'export const inputSchema = z.object({}).strict();',
       'export const resultSchema = z.object({ version: z.string() }).strict();',
       '',
-      'export default async function Report() {',
+      'export default defineTool({ inputSchema, resultSchema }, async () => {',
       "  return createElement(Agent.Result, { value: { version: 'unpublished' } }, createElement(Agent.Text, null, 'Unpublished route.'));",
-      '}',
+      '});',
       '',
     ].join('\n'));
     server = await startDevServer({
@@ -1859,7 +1908,7 @@ it('bounds the render history a compiled child produces by count and bytes acros
   const project = await createProjectFixture({
     config: [
       'export default {',
-      "  plugin: { name: 'route-invocation-retention', version: '1.0.0' },",
+      "  plugin: { name: 'route-invocation-retention' },",
       "  targets: ['claude'],",
       '};',
       '',
@@ -1905,7 +1954,7 @@ it('bounds the render history a compiled child produces by count and bytes acros
   let server: Awaited> | undefined;
   await mkdir(assetsRoot, { recursive: true });
   await Promise.all([
-    symlink(agentBundleNodeModules, join(project.root, 'node_modules'), 'dir'),
+    symlink(exampleNodeModules, join(project.root, 'node_modules'), 'dir'),
     writeFile(join(assetsRoot, 'index.html'), 'Route invocation retention'),
   ]);
   try {
diff --git a/packages/agent-bundle/tests/route-invocation-service.test.ts b/packages/agent-bundle/tests/route-invocation-service.test.ts
index 0c2a57eb5..4c9740427 100644
--- a/packages/agent-bundle/tests/route-invocation-service.test.ts
+++ b/packages/agent-bundle/tests/route-invocation-service.test.ts
@@ -1310,15 +1310,14 @@ const tsxSiblingProject = async (): Promise => routeProject(
       '',
     ].join('\n'),
     'src/mcp/fixture/tools/report.tsx': [
-      "import { Agent } from '@agent-bundle/runtime';",
-      "import { createElement } from 'react';",
-      '',
-      "import { Panel } from './panel.js';",
-      '',
-      'export default async function Report() {',
-      "  return createElement(Agent.Result, null, createElement(Panel), createElement(Agent.Text, null, './panel.js'));",
-      '}',
-      '',
+    "import { Agent } from '@agent-bundle/runtime';",
+    "import { createElement } from 'react';",
+    "",
+    "import { Panel } from './panel.js';",
+    "",
+    "export default async function Report() {",
+    "  return createElement(Agent.Result, null, createElement(Panel), createElement(Agent.Text, null, './panel.js'));",
+    "}",
     ].join('\n'),
   },
 );
@@ -1349,21 +1348,20 @@ const burstRouteProject = async (): Promise => routeProject(
   'burst',
   {
     'src/mcp/fixture/tools/burst.tsx': [
-      "import { Agent, agent } from '@agent-bundle/runtime';",
-      "import { createElement, Suspense } from 'react';",
-      '',
-      'const Chunk = async ({ index }) => {',
-      '  await new Promise((resolve) => setTimeout(resolve, 20 * (index + 1)));',
-      "  return createElement(Agent.Text, null, `${index}:${'x'.repeat(150 * 1024)}`);",
-      '};',
-      '',
-      'export default async function Burst() {',
-      '  const { progress } = await agent();',
-      '  for (let step = 1; step <= 400; step += 1) await progress.report({ completed: step, total: 400 });',
-      '  return createElement(Agent.Result, null, ...Array.from({ length: 5 }, (_, index) =>',
-      '    createElement(Suspense, { fallback: createElement(Agent.Progress, { completed: index, total: 5 }), key: index }, createElement(Chunk, { index }))));',
-      '}',
-      '',
+    "import { Agent, agent } from '@agent-bundle/runtime';",
+    "import { createElement, Suspense } from 'react';",
+    "",
+    "const Chunk = async ({ index }) => {",
+    "  await new Promise((resolve) => setTimeout(resolve, 20 * (index + 1)));",
+    "  return createElement(Agent.Text, null, `${index}:${'x'.repeat(150 * 1024)}`);",
+    "};",
+    "",
+    "export default async function Burst() {",
+    "  const { progress } = await agent();",
+    "  for (let step = 1; step <= 400; step += 1) await progress.report({ completed: step, total: 400 });",
+    "  return createElement(Agent.Result, null, ...Array.from({ length: 5 }, (_, index) =>",
+    "    createElement(Suspense, { fallback: createElement(Agent.Progress, { completed: index, total: 5 }), key: index }, createElement(Chunk, { index }))));",
+    "}",
     ].join('\n'),
   },
 );
diff --git a/packages/agent-bundle/tests/route-register-typegen.test.ts b/packages/agent-bundle/tests/route-register-typegen.test.ts
index 841ce590f..128f4fa0a 100644
--- a/packages/agent-bundle/tests/route-register-typegen.test.ts
+++ b/packages/agent-bundle/tests/route-register-typegen.test.ts
@@ -86,32 +86,41 @@ it('types every route-aware public surface from the generated route registration
     writeProjectFile(root, 'agent-bundle.config.ts', [
       "import { defineConfig } from 'agent-bundle/config';",
       'export default defineConfig({',
-      "  plugin: { name: 'route-register-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'route-register-fixture' },",
       "  targets: ['claude'],",
       '});',
       '',
     ].join('\n')),
     writeProjectFile(root, 'src/mcp/curator/tools/status.ts', [
-      "import { z } from 'zod';",
-      'export const inputSchema = z.object({}).strict();',
-      "export const resultSchema = z.object({ status: z.literal('ready') }).strict();",
-      "export default async function Status() { return { status: 'ready' as const }; }",
-      '',
+    "import { defineTool } from 'agent-bundle/routes';",
+    "import { z } from 'zod';",
+    "export const inputSchema = z.object({}).strict();",
+    "export const resultSchema = z.object({ status: z.literal('ready') }).strict();",
+    "export default defineTool({",
+    "  inputSchema,",
+    "  resultSchema,",
+    "}, async () => undefined);",
     ].join('\n')),
     writeProjectFile(root, 'src/mcp/curator/tools/find.ts', [
-      "import { z } from 'zod';",
-      'export const inputSchema = z.object({ query: z.string() }).strict();',
-      'export const resultSchema = z.object({ hits: z.number() }).strict();',
-      'export default async function Find() { return { hits: 1 }; }',
-      '',
+    "import { defineTool } from 'agent-bundle/routes';",
+    "import { z } from 'zod';",
+    "export const inputSchema = z.object({ query: z.string() }).strict();",
+    "export const resultSchema = z.object({ hits: z.number() }).strict();",
+    "export default defineTool({",
+    "  inputSchema,",
+    "  resultSchema,",
+    "}, async () => undefined);",
     ].join('\n')),
     // A second server registering the same tool name: the wire helpers see the union of both inputs.
     writeProjectFile(root, 'src/mcp/shelf/tools/find.ts', [
-      "import { z } from 'zod';",
-      'export const inputSchema = z.object({ isbn: z.string() }).strict();',
-      'export const resultSchema = z.object({ shelved: z.boolean() }).strict();',
-      'export default async function Find() { return { shelved: true }; }',
-      '',
+    "import { defineTool } from 'agent-bundle/routes';",
+    "import { z } from 'zod';",
+    "export const inputSchema = z.object({ isbn: z.string() }).strict();",
+    "export const resultSchema = z.object({ shelved: z.boolean() }).strict();",
+    "export default defineTool({",
+    "  inputSchema,",
+    "  resultSchema,",
+    "}, async () => undefined);",
     ].join('\n')),
     writeProjectFile(root, 'src/mcp/curator/prompts/brief.ts', [
       "import { z } from 'zod';",
diff --git a/packages/agent-bundle/tests/route-task-support.test.ts b/packages/agent-bundle/tests/route-task-support.test.ts
index cc461c9b8..41deebc3c 100644
--- a/packages/agent-bundle/tests/route-task-support.test.ts
+++ b/packages/agent-bundle/tests/route-task-support.test.ts
@@ -35,16 +35,19 @@ const writeTree = async (root: string, files: Readonly>):
   }
 };
 
-const config: AgentBundleConfig = { plugin: { name: 'task-support-fixture', version: '1.0.0' } };
-
-const toolModule = (routeConfig?: string): string => [
-  "import { z } from 'zod';",
-  ...(routeConfig === undefined ? [] : [`export const config = ${routeConfig};`]),
-  'export const inputSchema = z.object({});',
-  'export const resultSchema = z.object({ ok: z.boolean() });',
-  'export default async function Tool() { return undefined; }',
-  '',
-].join('\n');
+const config: AgentBundleConfig = { plugin: { name: 'task-support-fixture' } };
+
+const toolModule = (routeConfig = '{}'): string => {
+  const fields = routeConfig.slice(1, -1).trim();
+  return [
+    "import { defineTool } from 'agent-bundle/routes';",
+    "import { z } from 'zod';",
+    'export const inputSchema = z.object({});',
+    'export const resultSchema = z.object({ ok: z.boolean() });',
+    `export default defineTool({ ${fields}${fields.length === 0 ? '' : ', '}inputSchema, resultSchema }, async function Tool() { return undefined; });`,
+    '',
+  ].join('\n');
+};
 
 const resourceModule = (routeConfig: string): string => [
   "import { z } from 'zod';",
diff --git a/packages/agent-bundle/tests/route-unit/load-route-module.test.ts b/packages/agent-bundle/tests/route-unit/load-route-module.test.ts
index d9c73acc2..b383289f3 100644
--- a/packages/agent-bundle/tests/route-unit/load-route-module.test.ts
+++ b/packages/agent-bundle/tests/route-unit/load-route-module.test.ts
@@ -17,6 +17,12 @@ const rejection = async (load: Promise): Promise => {
   throw new Error('The load resolved, so no harness diagnostic was produced.');
 };
 
+const configOf = (
+  module: Readonly>,
+  config: Readonly>,
+): Readonly> =>
+  Object.fromEntries(Object.keys(config).map((key) => [key, module[key]]));
+
 /**
  * `loadRouteModule` (#493) is the supported replacement for a hand-maintained
  * list of static route imports in a schema-identity suite: the evaluated
@@ -35,7 +41,8 @@ describe('loadRouteModule', () => {
     expect(module.resultSchema).toBeInstanceOf(z.ZodObject);
     expect(typeof module.default).toBe('function');
     // The static config the compiler extracted into the manifest is the module's.
-    expect(module.config).toEqual(testManifest().routes['tool:harness/catalog']!.config);
+    const config = testManifest().routes['tool:harness/catalog']!.config;
+    expect(configOf(module, config)).toEqual(config);
   });
 
   it('loads every renderable route id the manifest reports, including CLI commands and scripts', async () => {
@@ -51,8 +58,8 @@ describe('loadRouteModule', () => {
     for (const route of loadable) {
       const module = await loadRouteModule(route.id);
       expect(typeof module.default, route.id).toBe('function');
-      // The compiler records `{}` for a module that exports no static config.
-      expect(module.config ?? {}, route.id).toEqual(route.config);
+      expect(route.kind === 'tool' ? configOf(module, route.config) : module.config ?? {}, route.id)
+        .toEqual(route.config);
     }
 
     const report = await loadRouteModule('cli:report');
@@ -76,7 +83,9 @@ describe('loadRouteModule', () => {
       loadRouteModule('tool:harness/echo'),
       loadRouteModule('tool:harness/echo'),
     ]);
-    expect(first).toBe(second);
+    expect(first.default).toBe(second.default);
+    expect(first.inputSchema).toBe(second.inputSchema);
+    expect(first.resultSchema).toBe(second.resultSchema);
 
     // The rendered document's value parses through the very schema the loaded
     // module exports, so the harness and the consumer agree on one contract.
diff --git a/packages/agent-bundle/tests/route-unit/workbench-surface-rendered-skill.test.ts b/packages/agent-bundle/tests/route-unit/workbench-surface-rendered-skill.test.ts
index f932219c6..58a391647 100644
--- a/packages/agent-bundle/tests/route-unit/workbench-surface-rendered-skill.test.ts
+++ b/packages/agent-bundle/tests/route-unit/workbench-surface-rendered-skill.test.ts
@@ -38,13 +38,13 @@ it('inspects the Workbench surface of a project with a rendered skill under the
       'src/mcp/demo/tools/ping.tsx': [
         "import React from 'react';",
         "import { Agent } from '@agent-bundle/runtime';",
+        "import { defineTool } from 'agent-bundle/routes';",
         "import { z } from 'zod';",
-        "export const config = { annotations: { readOnlyHint: true }, description: 'Ping.' };",
         'export const inputSchema = z.object({}).strict();',
         'export const resultSchema = z.object({ ok: z.literal(true) }).strict();',
-        'export default async function Ping() {',
+        "export default defineTool({ annotations: { readOnlyHint: true }, description: 'Ping.', inputSchema, resultSchema }, async () => {",
         '  return pong;',
-        '}',
+        '});',
         '',
       ].join('\n'),
       'src/skills/demo/SKILL.tsx': [
diff --git a/packages/agent-bundle/tests/rule-config.test.ts b/packages/agent-bundle/tests/rule-config.test.ts
index f0e735b0a..85b8b0ebe 100644
--- a/packages/agent-bundle/tests/rule-config.test.ts
+++ b/packages/agent-bundle/tests/rule-config.test.ts
@@ -21,7 +21,7 @@ const loadedProject = (
   targets: readonly string[],
 ): LoadedConfig => ({
   config: {
-    plugin: { name: 'rule-fixture', version: '1.0.0' },
+    plugin: { name: 'rule-fixture' },
     targets: [...targets],
   },
   configPath: join(root, 'agent-bundle.config.ts'),
@@ -40,7 +40,7 @@ const withProject = async (
   try {
     await writeFile(
       join(root, 'agent-bundle.config.ts'),
-      "export default { plugin: { name: 'rule-fixture', version: '1.0.0' } };\n",
+      "export default { plugin: { name: 'rule-fixture' } };\n",
     );
     await run(root);
   } finally {
@@ -184,7 +184,7 @@ it('discovers flat non-ignored rules deterministically and omits the collection
       writeFile(join(root, 'src', 'rules', 'ignored.mdc'), '# Ignored\n'),
     ]);
     const config: AgentBundleConfig = {
-      plugin: { name: 'rule-fixture', version: '1.0.0' },
+      plugin: { name: 'rule-fixture' },
     };
 
     const discovered = await discoverProject(root, config);
diff --git a/packages/agent-bundle/tests/skill-document-service.test.ts b/packages/agent-bundle/tests/skill-document-service.test.ts
index 01c4280b4..6f57cb204 100644
--- a/packages/agent-bundle/tests/skill-document-service.test.ts
+++ b/packages/agent-bundle/tests/skill-document-service.test.ts
@@ -29,7 +29,7 @@ class TrackingEpochStore extends EpochStore {
 const createProject = async (): Promise => (await createProjectFixture({
   config: [
     'export default {',
-    "  plugin: { name: 'skill-document-fixture', version: '1.0.0' },",
+    "  plugin: { name: 'skill-document-fixture' },",
     "  targets: ['portable'],",
     '};',
     '',
diff --git a/packages/agent-bundle/tests/skill-ir.test.ts b/packages/agent-bundle/tests/skill-ir.test.ts
index 0e23e5171..fd25a1db5 100644
--- a/packages/agent-bundle/tests/skill-ir.test.ts
+++ b/packages/agent-bundle/tests/skill-ir.test.ts
@@ -77,7 +77,7 @@ const loadedProject = (config: AgentBundleConfig, root: string): LoadedConfig =>
 });
 
 const pluginConfig = (targets: readonly string[]): AgentBundleConfig => ({
-  plugin: { name: 'skill-ir', version: '0.0.0' },
+  plugin: { name: 'skill-ir' },
   targets: [...targets],
 });
 
diff --git a/packages/agent-bundle/tests/support/dev-contract-project.ts b/packages/agent-bundle/tests/support/dev-contract-project.ts
index 632006954..a7124d144 100644
--- a/packages/agent-bundle/tests/support/dev-contract-project.ts
+++ b/packages/agent-bundle/tests/support/dev-contract-project.ts
@@ -18,16 +18,20 @@ const fixtureNodeModules = join(import.meta.dirname, '..', '..', '..', '..', 'ex
 
 export const devContractToolSource = (version: string, projectRoot: string): string => [
   `// fixture: ${projectRoot}`,
+  "import { defineTool } from 'agent-bundle/routes';",
   "import { createElement } from 'react';",
   "import { z } from 'zod';",
   '',
-  "export const config = { description: 'Reports the generated epoch version.' };",
   'export const inputSchema = z.object({ token: z.string() });',
   'export const resultSchema = z.object({ version: z.string() });',
   '',
-  'export default async function Version() {',
+  'export default defineTool({',
+  "  description: 'Reports the generated epoch version.',",
+  '  inputSchema,',
+  '  resultSchema,',
+  '}, async () => {',
   `  return createElement('agent-result', { value: { version: ${JSON.stringify(version)} } }, createElement('agent-text', null, ${JSON.stringify(version)}));`,
-  '}',
+  '});',
   '',
 ].join('\n');
 
@@ -68,7 +72,7 @@ export const writeDevContractProject = async (
       ...(options.contracts
         ? [`  dev: { contracts: { fixtures: './contract-fixtures.ts', server: ${JSON.stringify(DEV_CONTRACT_SERVER)} } },`]
         : []),
-      "  plugin: { name: 'dev-contract-gate', version: '1.0.0' },",
+      "  plugin: { name: 'dev-contract-gate' },",
       '  routes: { mcpCommands: true },',
       "  targets: ['portable'],",
       '};',
diff --git a/packages/agent-bundle/tests/support/eval-project.ts b/packages/agent-bundle/tests/support/eval-project.ts
index f465aae07..79365483e 100644
--- a/packages/agent-bundle/tests/support/eval-project.ts
+++ b/packages/agent-bundle/tests/support/eval-project.ts
@@ -117,7 +117,7 @@ export const seedEvalProject = async (
         ? ["  evals: { semanticGrader: { harness: 'claude', model: 'claude-sonnet-4-5' } },"]
         : []),
       ...(options.marketplace === true ? ['  marketplace: true,'] : []),
-      "  plugin: { name: 'review', version: '1.0.0' },",
+      "  plugin: { name: 'review' },",
       "  skills: ['src/skills/review'],",
       `  targets: ${JSON.stringify(options.targets ?? ['portable'])},`,
       '});',
diff --git a/packages/agent-bundle/tests/support/mcp-conformance.ts b/packages/agent-bundle/tests/support/mcp-conformance.ts
index f7a1b4ce4..573f176ac 100644
--- a/packages/agent-bundle/tests/support/mcp-conformance.ts
+++ b/packages/agent-bundle/tests/support/mcp-conformance.ts
@@ -333,7 +333,7 @@ export const runMcpConformance = async (): Promise => {
       rm(join(project, 'src/state.ts'), { force: true }),
       writeFile(join(project, 'agent-bundle.config.ts'), [
         'export default {',
-        "  plugin: { name: 'route-harness', version: '1.0.0' },",
+        "  plugin: { name: 'route-harness' },",
         '  routes: { mcpCommands: true },',
         "  targets: ['claude'],",
         '};',
diff --git a/packages/agent-bundle/tests/target-hook-contract.test.ts b/packages/agent-bundle/tests/target-hook-contract.test.ts
index f3f349b73..b86b78623 100644
--- a/packages/agent-bundle/tests/target-hook-contract.test.ts
+++ b/packages/agent-bundle/tests/target-hook-contract.test.ts
@@ -571,7 +571,7 @@ it('keeps a mismatched native selector visible so normalized planning fails clos
   };
   const config = {
     hooks: { beforeTool: { handler: './hooks/guard.ts', tools: ['claude:WebSearch'] } },
-    plugin: { name: 'native-selector-fixture', version: '1.0.0' },
+    plugin: { name: 'native-selector-fixture' },
     targets: ['codex'],
   } satisfies AgentBundleConfig;
   const model = await normalizeProject({
diff --git a/packages/agent-bundle/tests/trace-dev-server.test.ts b/packages/agent-bundle/tests/trace-dev-server.test.ts
index cd3aaf35f..cc16f74cf 100644
--- a/packages/agent-bundle/tests/trace-dev-server.test.ts
+++ b/packages/agent-bundle/tests/trace-dev-server.test.ts
@@ -11,7 +11,7 @@ import type { EventTraceReceipt } from '../src/events/trace-receipt.ts';
 import { createWorkbenchAssetSource } from '../src/dev/workbench-assets.ts';
 import { startDevServer } from '../src/dev/workbench-server.ts';
 import { createProjectFixture } from './helpers/project-fixture.ts';
-import { agentBundleNodeModules } from './helpers/workspace-paths.ts';
+import { exampleNodeModules } from './helpers/workspace-paths.ts';
 import { replaceWatchedSourceAndAwaitRebuild } from './support/watched-files.ts';
 
 const runHook = (
@@ -32,7 +32,7 @@ it('serves replay and live trace entries and lowers build failures', { timeout:
   const project = await createProjectFixture({
     config: [
       'export default {',
-      "  plugin: { name: 'trace-dev-server', version: '1.0.0' },",
+      "  plugin: { name: 'trace-dev-server' },",
       "  targets: ['claude'],",
       '};',
       '',
@@ -51,16 +51,19 @@ it('serves replay and live trace entries and lowers build failures', { timeout:
         '',
       ].join('\n'),
       'src/mcp/status/tools/report.tsx': [
-        "import { Agent } from '@agent-bundle/runtime';",
-        "import { createElement } from 'react';",
-        "import { z } from 'zod';",
-        '',
-        'export const inputSchema = z.object({}).strict();',
-        'export const resultSchema = z.object({ ready: z.boolean() }).strict();',
-        'export default async function Report() {',
-        "  return createElement(Agent.Text, null, 'Ready.');",
-        '}',
-        '',
+      "import { defineTool } from 'agent-bundle/routes';",
+      "import { Agent } from '@agent-bundle/runtime';",
+      "import { createElement } from 'react';",
+      "import { z } from 'zod';",
+      "",
+      "export const inputSchema = z.object({}).strict();",
+      "export const resultSchema = z.object({ ready: z.boolean() }).strict();",
+      "export default defineTool({",
+      "  inputSchema,",
+      "  resultSchema,",
+      "}, async () => {",
+      "  return createElement(Agent.Text, null, 'Ready.');",
+      "});",
       ].join('\n'),
     },
     prefix: 'agent-bundle-trace-dev-server-',
@@ -71,7 +74,7 @@ it('serves replay and live trace entries and lowers build failures', { timeout:
   let trace: TraceHub | undefined;
   await mkdir(assetsRoot, { recursive: true });
   await Promise.all([
-    symlink(agentBundleNodeModules, join(project.root, 'node_modules'), 'dir'),
+    symlink(exampleNodeModules, join(project.root, 'node_modules'), 'dir'),
     writeFile(join(assetsRoot, 'index.html'), 'Trace'),
   ]);
   try {
diff --git a/packages/agent-bundle/tests/web-config.test.ts b/packages/agent-bundle/tests/web-config.test.ts
index 7116258c2..f2e966a93 100644
--- a/packages/agent-bundle/tests/web-config.test.ts
+++ b/packages/agent-bundle/tests/web-config.test.ts
@@ -114,7 +114,7 @@ const baseConfig = (web: AgentBundleConfig['web']): AgentBundleConfig => ({
       },
     },
   },
-  plugin: { name: 'catalog-tools', version: '1.0.0' },
+  plugin: { name: 'catalog-tools' },
   web,
 });
 
@@ -227,7 +227,7 @@ it('skips static tool validation for hand-written MCP server factories', async (
         },
       },
     },
-    plugin: { name: 'catalog-tools', version: '1.0.0' },
+    plugin: { name: 'catalog-tools' },
     web: { apps: [{ app: 'catalog/details', tool: 'runtime-tool' }] },
   };
   const { diagnostics } = await webDiagnostics(config, discovered({ generated: false }));
diff --git a/packages/agent-bundle/tests/workbench-surface-dev-server.test.ts b/packages/agent-bundle/tests/workbench-surface-dev-server.test.ts
index 1485fc07e..be03232b4 100644
--- a/packages/agent-bundle/tests/workbench-surface-dev-server.test.ts
+++ b/packages/agent-bundle/tests/workbench-surface-dev-server.test.ts
@@ -9,7 +9,7 @@ import { createWorkbenchAssetSource } from '../src/dev/workbench-assets.ts';
 import { startDevServer } from '../src/dev/workbench-server.ts';
 import { inspectWorkbenchSurface, workbenchLeafPath } from '../src/test/index.ts';
 import { createProjectFixture } from './helpers/project-fixture.ts';
-import { agentBundleNodeModules } from './helpers/workspace-paths.ts';
+import { exampleNodeModules } from './helpers/workspace-paths.ts';
 
 /**
  * The workbench-surface level claims to hand a consumer exactly what the dev
@@ -21,7 +21,7 @@ it('matches the route manifest and lifecycle inventory a real dev server serves'
   const project = await createProjectFixture({
     config: [
       'export default {',
-      "  plugin: { name: 'workbench-surface-dev-server', version: '1.0.0' },",
+      "  plugin: { name: 'workbench-surface-dev-server' },",
       "  targets: ['claude'],",
       '};',
       '',
@@ -52,18 +52,21 @@ it('matches the route manifest and lifecycle inventory a real dev server serves'
         '',
       ].join('\n'),
       'src/mcp/status/tools/report.tsx': [
-        "import { Agent } from '@agent-bundle/runtime';",
-        "import { createElement } from 'react';",
-        "import { z } from 'zod';",
-        '',
-        "export const config = { inputJsonSchema: {\"additionalProperties\":false,\"properties\":{\"service\":{\"type\":\"string\"}},\"required\":[\"service\"],\"type\":\"object\"}, annotations: { readOnlyHint: true }, description: 'Reports one service.' };",
-        "export const inputSchema = z.object({ service: z.string().min(1) }).strict();",
-        'export const resultSchema = z.object({ service: z.string() }).strict();',
-        '',
-        'export default async function Report({ input }) {',
-        "  return createElement(Agent.Result, { value: { service: input.service } }, createElement(Agent.Text, null, input.service));",
-        '}',
-        '',
+      "import { defineTool } from 'agent-bundle/routes';",
+      "import { Agent } from '@agent-bundle/runtime';",
+      "import { createElement } from 'react';",
+      "import { z } from 'zod';",
+      "",
+      "export const inputSchema = z.object({ service: z.string().min(1) }).strict();",
+      "export const resultSchema = z.object({ service: z.string() }).strict();",
+      "",
+      "export default defineTool({",
+      "inputJsonSchema: {\"additionalProperties\":false,\"properties\":{\"service\":{\"type\":\"string\"}},\"required\":[\"service\"],\"type\":\"object\"}, annotations: { readOnlyHint: true }, description: 'Reports one service.',",
+      "  inputSchema,",
+      "  resultSchema,",
+      "}, async (input) => {",
+      "  return createElement(Agent.Result, { value: { service: input.service } }, createElement(Agent.Text, null, input.service));",
+      "});",
       ].join('\n'),
       'src/providers/clock.ts': [
         'export default () => ({ now: 0 });',
@@ -76,7 +79,7 @@ it('matches the route manifest and lifecycle inventory a real dev server serves'
   let server: Awaited> | undefined;
   await mkdir(assetsRoot, { recursive: true });
   await Promise.all([
-    symlink(agentBundleNodeModules, join(project.root, 'node_modules'), 'dir'),
+    symlink(exampleNodeModules, join(project.root, 'node_modules'), 'dir'),
     writeFile(join(assetsRoot, 'index.html'), 'Workbench surface'),
   ]);
   try {
diff --git a/packages/agent-bundle/tests/workbench-surface.test.ts b/packages/agent-bundle/tests/workbench-surface.test.ts
index 9b2b5c5fb..599e7de36 100644
--- a/packages/agent-bundle/tests/workbench-surface.test.ts
+++ b/packages/agent-bundle/tests/workbench-surface.test.ts
@@ -255,7 +255,7 @@ describe('capability counts', () => {
     const project = await createProjectFixture({
       config: [
         'export default {',
-        "  plugin: { name: 'workbench-surface-counts', version: '1.0.0' },",
+        "  plugin: { name: 'workbench-surface-counts' },",
         "  targets: ['claude', 'codex'],",
         "  hooks: { PostToolUse: [{ handler: 'src/hooks/audit.ts', targets: [] }] },",
         '  scripts: {',
@@ -294,7 +294,7 @@ describe('capability counts', () => {
     const project = await createProjectFixture({
       config: [
         'export default {',
-        "  plugin: { name: 'workbench-surface-prebuilt-hooks', version: '1.0.0' },",
+        "  plugin: { name: 'workbench-surface-prebuilt-hooks' },",
         "  targets: ['claude', 'codex'],",
         "  payload: { runtime: './built/runtime' },",
         '  hooks: { afterTool: [',
@@ -321,7 +321,7 @@ describe('capability counts', () => {
       const prebuiltOnly = await createProjectFixture({
         config: [
           'export default {',
-          "  plugin: { name: 'workbench-surface-prebuilt-only', version: '1.0.0' },",
+          "  plugin: { name: 'workbench-surface-prebuilt-only' },",
           "  targets: ['claude'],",
           "  payload: { runtime: './built/runtime' },",
           "  hooks: { afterTool: [{ args: ['--host', 'claude'], handler: { prebuilt: './built/runtime/hook.js' }, targets: ['claude'], tools: ['file.write'] }] },",
@@ -351,7 +351,7 @@ describe('capability counts', () => {
     const project = await createProjectFixture({
       config: [
         'export default {',
-        "  plugin: { name: 'workbench-surface-nothing-emitted', version: '1.0.0' },",
+        "  plugin: { name: 'workbench-surface-nothing-emitted' },",
         "  targets: ['claude'],",
         "  hooks: { PostToolUse: [{ handler: 'src/hooks/audit.ts', targets: [] }] },",
         "  scripts: { nowhere: { entry: 'src/tools/nowhere.ts', targets: [] } },",
@@ -388,7 +388,7 @@ describe('preparation parity with the Workbench server', () => {
     const suiteModule = resolve(import.meta.dirname, '../src/eval/suite.ts');
     const configFactory = (name: string, evalsDir: string): string => [
       'export default (context) => ({',
-      `  plugin: { name: ${JSON.stringify(name)}, version: '1.0.0' },`,
+      `  plugin: { name: ${JSON.stringify(name)} },`,
       // Production selects claude alone; the Workbench (development) sees both.
       "  targets: context.mode === 'development' ? ['claude', 'codex'] : ['claude'],",
       `  evals: { include: [${JSON.stringify(`${evalsDir}/**/*.eval.ts`)}] },`,
@@ -457,7 +457,7 @@ describe('an unusable project', () => {
     const project = await createProjectFixture({
       config: [
         'export default {',
-        "  plugin: { name: 'workbench-surface-invalid', version: '1.0.0' },",
+        "  plugin: { name: 'workbench-surface-invalid' },",
         "  targets: ['claude', 'no-such-host'],",
         '};',
         '',
diff --git a/packages/rsc-markdown-stream/src/resolve.js b/packages/rsc-markdown-stream/src/resolve.js
index 8b21eae1e..b7b44de9b 100644
--- a/packages/rsc-markdown-stream/src/resolve.js
+++ b/packages/rsc-markdown-stream/src/resolve.js
@@ -5,10 +5,7 @@
 
 import * as React from 'react';
 
-const ELEMENT_TYPES = new Set([
-  Symbol.for('react.transitional.element'), // React 19
-  Symbol.for('react.element'), // React <= 18
-]);
+const ELEMENT_TYPE = Symbol.for('react.transitional.element');
 
 const FRAGMENT = Symbol.for('react.fragment');
 const STRICT_MODE = Symbol.for('react.strict_mode');
@@ -19,7 +16,6 @@ const LAZY = Symbol.for('react.lazy');
 const MEMO = Symbol.for('react.memo');
 const FORWARD_REF = Symbol.for('react.forward_ref');
 const CONTEXT = Symbol.for('react.context');
-const PROVIDER = Symbol.for('react.provider'); // React <= 18 
 const CONSUMER = Symbol.for('react.consumer'); // React 19 
 const ACTIVITY = Symbol.for('react.activity'); // React 19.2 
 const VIEW_TRANSITION = Symbol.for('react.view_transition');
@@ -30,16 +26,15 @@ const DROPPED_TAGS = new Set([
   'script', 'style', 'template', 'noscript', 'head', 'title', 'meta', 'link', 'base',
 ]);
 
-// Which internals key exists depends on the react build: client (React 19),
-// react-server (React 19, e.g. inside an RSC server module graph), or legacy
-// (React <= 18). Located with computed access because a static namespace
+// Which internals key exists depends on the React 19 build: client or
+// react-server (inside an RSC server module graph). Located with computed
+// access because a static namespace
 // property access on a missing export is an ESM linking error under strict
 // bundlers (rspack/webpack) when the react-server condition selects a build
 // without the client export.
 const internalsKey = [
   '__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE',
   '__SERVER_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE',
-  '__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED',
 ].find((key) => React[key] !== undefined && React[key] !== null);
 const ReactSharedInternals = internalsKey === undefined ? null : React[internalsKey];
 
@@ -224,7 +219,7 @@ export async function* resolveStream(node, ctx = null, config = null) {
     return;
   }
   if (typeof node === 'object') {
-    if (ELEMENT_TYPES.has(node.$$typeof)) {
+    if (node.$$typeof === ELEMENT_TYPE) {
       yield* resolveElement(node.type, node.props, ctx, config);
       return;
     }
@@ -315,19 +310,7 @@ async function* resolveElement(type, props, ctx, config) {
           config,
         );
         return;
-      case PROVIDER:
-        yield* resolveStream(
-          props.children,
-          { context: type._context, value: props.value, parent: ctx },
-          config,
-        );
-        return;
       case CONTEXT:
-        if (typeof props.children === 'function') {
-          // React <= 18  renders the context object directly.
-          yield* resolveStream(props.children(readContextValue(type, ctx)), ctx, config);
-          return;
-        }
         // React 19: the context object itself is the provider.
         yield* resolveStream(
           props.children,
diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md
index a6190ca3b..04746edc8 100644
--- a/packages/rsc-runtime/README.md
+++ b/packages/rsc-runtime/README.md
@@ -24,8 +24,7 @@ the other — shell/replace content stays internal, and the request resolves to
 one final `CallToolResult`. Image, audio, and resource
 blocks are capability-gated — unsupported rich content uses a declared
 fallback or a typed `McpProjectionError`, never a silent drop. The existing
-`lowerMcpResult` / `lowerHookResult` helpers remain synchronous compatibility
-APIs for the operations-model path.
+`documentToCallToolResult` helper projects an already-complete Agent Document.
 
 Task-augmented tool calls (`CreateTaskResult`, `tasks/get`, `tasks/result`,
 `tasks/cancel`, `tasks/list` — the MCP 2025-11-25 Tasks utility) are served by
@@ -35,27 +34,16 @@ what feeds a task's `tasks/get` status as well as `notifications/progress`.
 See
 [MCP conformance evidence](https://github.com/ScriptedAlchemy/agent-bundle/blob/main/docs/mcp-conformance.md#task-augmented-requests-served-2026-09-04).
 
-```tsx
-import { Mcp, lowerMcpResult } from '@agent-bundle/runtime';
-
-const result = lowerMcpResult(
-  
-    Ready.
-  ,
-);
-```
-
 The package also exports the protocol-oriented `Agent.*` vocabulary and the
 versioned `AgentDocument`/`AgentRenderEvent` contracts. `createAgentDocument`
 detaches and freezes a v1 document, enforcing finite depth, node-count, and
 byte limits. `createAgentRenderEventSequence` assigns monotonic sequence
 numbers to `shell | progress | replace | error | complete`, applies event
 bounds, and rejects post-completion writes with a typed `handoff-required`
-error. These contracts land beside the existing `Hook`/`Mcp` lowerers; those
-synchronous compatibility APIs remain operative.
+error.
 
-The package exports `Hook`, `Mcp`, `Agent`, both lowerers, the request-store
-APIs, the Agent Document contracts, `createAgentRenderDispatcher`,
+The package exports `Agent`, the request-store APIs, the Agent Document
+contracts, `createAgentRenderDispatcher`,
 `projectMcpRenderStream`, `createWarmFlightHost`, `decodeAgentFlightStream`,
 and the `@agent-bundle/runtime/flight/server` render entry. Rich Markdown
 authoring rides `rsc-markdown-stream`, the Markdown renderer published from
@@ -183,7 +171,7 @@ integration.
 ## Notices (optional)
 
 `@agent-bundle/runtime/notices` is the narrow recipient-scoped notice core.
-It stores detached, finite `AgentDocumentSnapshot` content in one ordinary
+It stores detached, finite `AgentDocument` content in one ordinary
 state-kernel definition; host wiring opens that definition with the
 workspace-durable SQLite driver and passes the resulting ledger as
 `runAgentRequest({ noticeLedger })`. Stateless projects import neither
@@ -304,7 +292,7 @@ unavailable outcome when none is supported; it never fabricates a channel.
 
 ### Redaction (#99 acceptance item 7)
 
-A notice's free text lives only in its detached `AgentDocumentSnapshot` —
+A notice's free text lives only in its detached `AgentDocument` —
 `text`, `markdown`, `context`, `progress.message`, `error.message`,
 `resource.name`/`uri`, and every string inside `json.value`, `result.metadata`,
 and the document `value`. Recipient, priority, dedupe key, timestamps, and
diff --git a/packages/rsc-runtime/src/agent-document.ts b/packages/rsc-runtime/src/agent-document.ts
index 4ad5bef43..a837a4c56 100644
--- a/packages/rsc-runtime/src/agent-document.ts
+++ b/packages/rsc-runtime/src/agent-document.ts
@@ -88,8 +88,6 @@ export interface AgentDocument {
   readonly version: typeof AGENT_DOCUMENT_VERSION;
 }
 
-export type AgentDocumentSnapshot = AgentDocument;
-
 export interface AgentRenderError {
   readonly code: string;
   readonly data?: JsonValue;
@@ -97,7 +95,7 @@ export interface AgentRenderError {
 }
 
 export type AgentRenderEvent =
-  | { readonly document: AgentDocumentSnapshot; readonly sequence: number; readonly type: 'shell' }
+  | { readonly document: AgentDocument; readonly sequence: number; readonly type: 'shell' }
   | {
     readonly completed: number;
     readonly message?: string;
@@ -107,7 +105,7 @@ export type AgentRenderEvent =
   }
   | {
     readonly boundaryId: string;
-    readonly document: AgentDocumentSnapshot;
+    readonly document: AgentDocument;
     readonly sequence: number;
     readonly type: 'replace';
   }
@@ -117,7 +115,7 @@ export type AgentRenderEvent =
     readonly sequence: number;
     readonly type: 'error';
   }
-  | { readonly document: AgentDocumentSnapshot; readonly sequence: number; readonly type: 'complete' };
+  | { readonly document: AgentDocument; readonly sequence: number; readonly type: 'complete' };
 
 export type AgentRenderEventInput =
   | { readonly document: AgentDocument; readonly type: 'shell' }
diff --git a/packages/rsc-runtime/src/elements.ts b/packages/rsc-runtime/src/elements.ts
index b05583b90..73ee5c296 100644
--- a/packages/rsc-runtime/src/elements.ts
+++ b/packages/rsc-runtime/src/elements.ts
@@ -117,59 +117,3 @@ export const Agent = Object.freeze({
   Result: AgentResult,
   Text: AgentText,
 });
-
-const Result = ({ children }: PropsWithChildren): ReactElement =>
-  createElement('agent-hook-result', null, children);
-
-const AdditionalContext = ({ children }: PropsWithChildren): ReactElement =>
-  createElement('agent-hook-additional-context', null, children);
-
-export const Hook = { AdditionalContext, Result };
-
-export interface McpResultProps extends PropsWithChildren {
-  _meta?: unknown;
-  structuredContent?: unknown;
-  isError?: boolean;
-}
-
-export interface McpDataProps {
-  data: string;
-  mimeType: string;
-}
-
-export interface McpResourceLinkProps {
-  uri: string;
-  name: string;
-  mimeType?: string;
-}
-
-export interface McpEmbeddedResourceProps extends PropsWithChildren {
-  uri: string;
-  mimeType?: string;
-  text?: string;
-  blob?: string;
-}
-
-const McpResult = ({ _meta, children, isError, structuredContent }: McpResultProps): ReactElement =>
-  createElement('mcp-result', { _meta, isError, structuredContent }, children);
-
-const McpText = ({ children }: PropsWithChildren): ReactElement => createElement('mcp-text', null, children);
-
-const McpImage = ({ data, mimeType }: McpDataProps): ReactElement => createElement('mcp-image', { data, mimeType });
-
-const McpAudio = ({ data, mimeType }: McpDataProps): ReactElement => createElement('mcp-audio', { data, mimeType });
-
-const McpResourceLink = ({ mimeType, name, uri }: McpResourceLinkProps): ReactElement =>
-  createElement('mcp-resource-link', { mimeType, name, uri });
-
-const McpEmbeddedResource = ({ blob, children, mimeType, text, uri }: McpEmbeddedResourceProps): ReactElement =>
-  createElement('mcp-embedded-resource', { blob, mimeType, text, uri }, children);
-
-export const Mcp = {
-  Audio: McpAudio,
-  EmbeddedResource: McpEmbeddedResource,
-  Image: McpImage,
-  ResourceLink: McpResourceLink,
-  Result: McpResult,
-  Text: McpText,
-};
diff --git a/packages/rsc-runtime/src/index.ts b/packages/rsc-runtime/src/index.ts
index ad7caa984..13ebee3e2 100644
--- a/packages/rsc-runtime/src/index.ts
+++ b/packages/rsc-runtime/src/index.ts
@@ -1,4 +1,4 @@
-export { Agent, Hook, Mcp } from './elements.js';
+export { Agent } from './elements.js';
 export type {
   AgentErrorProps,
   AgentJsonProps,
@@ -10,10 +10,6 @@ export type {
   AgentResourceProps,
   AgentResultProps,
   AgentTextProps,
-  McpDataProps,
-  McpEmbeddedResourceProps,
-  McpResourceLinkProps,
-  McpResultProps,
 } from './elements.js';
 export {
   AGENT_DOCUMENT_VERSION,
@@ -28,7 +24,6 @@ export type {
   AgentContextNode,
   AgentDocument,
   AgentDocumentNode,
-  AgentDocumentSnapshot,
   AgentDocumentStatus,
   AgentErrorNode,
   AgentImageNode,
@@ -91,11 +86,7 @@ export type {
   MarkdownSerializer,
   MarkdownSerializerHelpers,
 } from './markdown-content.js';
-export { lowerHookResult } from './lower-hook.js';
-export type { NativePostToolUseOutput } from './lower-hook.js';
-export { lowerMcpResult } from './lower-mcp.js';
 export type { JsonObject, JsonValue, McpCallToolResult, McpContentBlock } from './lower-mcp.js';
-export { createRscRequestContext } from './request-context.js';
 export type { AgentRenderInvocation } from './agent-request.js';
 // Registry-free lineage helpers: what a payload proves on its own. The
 // registry itself ships behind the './lineage' subpath with the state kernel.
@@ -113,5 +104,4 @@ export type {
   AgentNoticesHandle,
   AgentRecipient,
 } from './notices/contract.js';
-export type { RscRequestContext } from './request-context.js';
 export * from './plugin.js';
diff --git a/packages/rsc-runtime/src/lower-hook.ts b/packages/rsc-runtime/src/lower-hook.ts
deleted file mode 100644
index 28143bcf6..000000000
--- a/packages/rsc-runtime/src/lower-hook.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { Children, isValidElement, type ReactElement, type ReactNode } from 'react';
-
-import { Hook } from './elements.js';
-
-export interface NativePostToolUseOutput {
-  hookSpecificOutput: {
-    additionalContext: string;
-    hookEventName: 'PostToolUse';
-  };
-}
-
-type AgentElementProps = { children?: ReactNode };
-
-const hookComponents = new Set(Object.values(Hook));
-
-const isHookComponent = (value: unknown): value is ((props: AgentElementProps) => ReactElement) =>
-  hookComponents.has(value);
-
-const resolveHookElement = (node: ReactNode): ReactNode => {
-  let element = node;
-  while (isValidElement(element) && isHookComponent(element.type)) {
-    element = element.type(element.props);
-  }
-  return element;
-};
-
-const isAgentElement = (node: ReactNode, name: string): node is React.ReactElement => {
-  const resolved = resolveHookElement(node);
-  return isValidElement(resolved) && resolved.type === name;
-};
-
-const flattenText = (node: ReactNode): string => {
-  if (typeof node === 'string' || typeof node === 'number') {
-    return String(node);
-  }
-
-  if (Array.isArray(node)) {
-    return node.map(flattenText).join('');
-  }
-
-  if (isAgentElement(node, 'agent-hook-result')) {
-    throw new Error('Hook result contains duplicate roots');
-  }
-
-  throw new Error('Hook additional context may contain only string or number children');
-};
-
-export const lowerHookResult = (node: ReactNode): NativePostToolUseOutput => {
-  const roots = Children.toArray(node).map(resolveHookElement);
-  if (roots.length !== 1 || !isAgentElement(roots[0] as ReactNode, 'agent-hook-result')) {
-    throw new Error('Expected exactly one agent-hook-result root');
-  }
-
-  const result = roots[0] as React.ReactElement;
-  const contexts = Children.toArray(result.props.children).map(resolveHookElement).map((child) => {
-    if (isAgentElement(child, 'agent-hook-result')) {
-      throw new Error('Hook result contains duplicate roots');
-    }
-
-    if (!isAgentElement(child, 'agent-hook-additional-context')) {
-      throw new Error('Hook result may contain only agent-hook-additional-context elements');
-    }
-
-    return flattenText(child.props.children);
-  });
-
-  if (contexts.length === 0) {
-    throw new Error('Hook result requires additional context');
-  }
-
-  return {
-    hookSpecificOutput: {
-      additionalContext: contexts.join(''),
-      hookEventName: 'PostToolUse',
-    },
-  };
-};
diff --git a/packages/rsc-runtime/src/lower-mcp.ts b/packages/rsc-runtime/src/lower-mcp.ts
index 25f0741a1..21ef8fd8e 100644
--- a/packages/rsc-runtime/src/lower-mcp.ts
+++ b/packages/rsc-runtime/src/lower-mcp.ts
@@ -1,45 +1,6 @@
 import { Buffer } from 'node:buffer';
 
 import type { CallToolResult } from '@modelcontextprotocol/server';
-import { Children, isValidElement, type ReactElement, type ReactNode } from 'react';
-
-type McpElement = {
-  type: string;
-  props: Record;
-};
-
-// Every Mcp.* export is itself a function component, so a single function
-// check unwraps both framework and user server components.
-const isServerComponent = (value: unknown): value is ((props: Record) => ReactNode) =>
-  typeof value === 'function';
-
-const asMcpElement = (node: ReactNode): McpElement => {
-  let element = node;
-  while (isValidElement(element) && isServerComponent(element.type)) {
-    element = element.type(element.props as Record);
-  }
-
-  if (!isValidElement(element) || typeof element.type !== 'string' || !element.type.startsWith('mcp-')) {
-    throw new Error('Expected an MCP result element');
-  }
-
-  return { props: element.props as Record, type: element.type };
-};
-
-const requiredString = (value: unknown, message: string): string => {
-  if (typeof value !== 'string' || value.trim() === '') {
-    throw new Error(message);
-  }
-  return value;
-};
-
-const textChild = (children: unknown, message: string): string => {
-  const values = Children.toArray(children as ReactNode);
-  if (values.length !== 1 || typeof values[0] !== 'string') {
-    throw new Error(message);
-  }
-  return values[0];
-};
 
 export interface JsonObject {
   readonly [key: string]: JsonValue;
@@ -53,7 +14,7 @@ export type McpContentBlock = CallToolResult['content'][number];
 /**
  * The MCP `CallToolResult` this package emits. The content blocks are the
  * SDK's own; `_meta` and `structuredContent` are the finite JSON objects the
- * lowerers copy through the wire boundary, stated as such rather than as the
+ * projectors copy through the wire boundary, stated as such rather than as the
  * SDK's `unknown`. That keeps the value assignable to the `CallToolResult` of
  * `@modelcontextprotocol/server` 2.x and of the SDK's 1.x line (which types
  * `structuredContent` as a record) alike. A type alias, not an interface: only
@@ -66,7 +27,6 @@ export type McpCallToolResult = {
   readonly isError?: boolean;
   readonly structuredContent?: JsonObject;
 };
-
 /** Incremental depth / node / byte checks while cloning JSON (Agent Document bounds). */
 export interface JsonSnapshotBudget {
   readonly addBytes: (n: number) => void;
@@ -162,18 +122,6 @@ const cloneJsonValue = (
   }
 };
 
-const jsonRecord = (value: unknown, message: string): JsonObject => {
-  try {
-    if (value === null || typeof value !== 'object' || Array.isArray(value)) {
-      throw new Error('not a plain object');
-    }
-    // cloneJsonValue either throws or returns a clone of the guarded plain object.
-    return cloneJsonValue(value, new Set(), '') as JsonObject;
-  } catch (error) {
-    throw new Error(`${message} (${error instanceof Error ? error.message : String(error)})`, { cause: error });
-  }
-};
-
 const deepFreezeJson = (value: JsonValue): JsonValue => {
   if (typeof value === 'object' && value !== null) {
     for (const child of Object.values(value)) deepFreezeJson(child);
@@ -200,93 +148,3 @@ export const snapshotJsonValue = (
     throw new Error(`${message} (${error instanceof Error ? error.message : String(error)})`, { cause: error });
   }
 };
-
-/**
- * Copies declaration-level metadata through the same JSON wire boundary as
- * MCP results and deep-freezes the copy, so frozen definitions cannot be
- * mutated through their metadata after the fact.
- */
-export const frozenJsonRecord = (value: unknown, message: string): Readonly> => {
-  const clone = jsonRecord(value, message);
-  deepFreezeJson(clone);
-  return clone;
-};
-
-const lowerContent = (node: ReactNode): McpContentBlock => {
-  const element = asMcpElement(node);
-  const { props } = element;
-  switch (element.type) {
-    case 'mcp-text':
-      return { text: textChild(props.children, 'mcp-text requires one text child'), type: 'text' };
-    case 'mcp-image':
-      return {
-        data: requiredString(props.data, 'mcp-image requires non-empty data and mimeType'),
-        mimeType: requiredString(props.mimeType, 'mcp-image requires non-empty data and mimeType'),
-        type: 'image',
-      };
-    case 'mcp-audio':
-      return {
-        data: requiredString(props.data, 'mcp-audio requires non-empty data and mimeType'),
-        mimeType: requiredString(props.mimeType, 'mcp-audio requires non-empty data and mimeType'),
-        type: 'audio',
-      };
-    case 'mcp-resource-link': {
-      const mimeType = props.mimeType;
-      if (mimeType !== undefined && typeof mimeType !== 'string') {
-        throw new Error('mcp-resource-link mimeType must be a string');
-      }
-      return {
-        ...(mimeType === undefined ? {} : { mimeType }),
-        name: requiredString(props.name, 'mcp-resource-link requires non-empty uri and name'),
-        type: 'resource_link',
-        uri: requiredString(props.uri, 'mcp-resource-link requires non-empty uri and name'),
-      };
-    }
-    case 'mcp-embedded-resource': {
-      const hasText = props.text !== undefined;
-      const hasTextChild = props.children !== undefined;
-      const hasBlob = props.blob !== undefined;
-      if (Number(hasText) + Number(hasTextChild) + Number(hasBlob) !== 1) {
-        throw new Error('mcp-embedded-resource accepts exactly one text or blob value');
-      }
-      const mimeType = props.mimeType;
-      if (mimeType !== undefined && typeof mimeType !== 'string') {
-        throw new Error('mcp-embedded-resource mimeType must be a string');
-      }
-      const resource = {
-        ...(mimeType === undefined ? {} : { mimeType }),
-        uri: requiredString(props.uri, 'mcp-embedded-resource requires a non-empty uri'),
-        ...(hasBlob
-          ? { blob: requiredString(props.blob, 'mcp-embedded-resource blob must be non-empty') }
-          : {
-              text: hasTextChild
-                ? textChild(props.children, 'mcp-embedded-resource requires one text child')
-                : requiredString(props.text, 'mcp-embedded-resource text must be non-empty'),
-            }),
-      };
-      return { resource, type: 'resource' };
-    }
-    case 'mcp-result':
-      throw new Error('mcp-result may not be nested');
-    default:
-      throw new Error(`Unsupported MCP result element: ${element.type}`);
-  }
-};
-
-export const lowerMcpResult = (node: ReactNode): McpCallToolResult => {
-  const root = asMcpElement(node);
-  if (root.type !== 'mcp-result') throw new Error('Expected mcp-result as the root element');
-  if (root.props.isError !== undefined && typeof root.props.isError !== 'boolean') {
-    throw new Error('mcp-result isError must be a boolean');
-  }
-  const structuredContent = root.props.structuredContent;
-  const metadata = root.props._meta;
-  return {
-    content: Children.toArray(root.props.children as ReactNode).map(lowerContent),
-    ...(metadata === undefined ? {} : { _meta: jsonRecord(metadata, 'mcp-result _meta must be JSON-serializable') }),
-    ...(structuredContent === undefined
-      ? {}
-      : { structuredContent: jsonRecord(structuredContent, 'mcp-result structuredContent must be JSON-serializable') }),
-    ...(root.props.isError === undefined ? {} : { isError: root.props.isError }),
-  };
-};
diff --git a/packages/rsc-runtime/src/notices/contract.ts b/packages/rsc-runtime/src/notices/contract.ts
index 5515f8597..746590c05 100644
--- a/packages/rsc-runtime/src/notices/contract.ts
+++ b/packages/rsc-runtime/src/notices/contract.ts
@@ -1,4 +1,4 @@
-import type { AgentDocumentSnapshot } from '../agent-document.js';
+import type { AgentDocument } from '../agent-document.js';
 import type {
   AgentActorIdentity,
   AgentHostIdentity,
@@ -181,7 +181,7 @@ export interface AgentNotice {
   readonly availability?: AgentNoticeAvailability;
   readonly availabilityReservation?: AgentNoticeAvailabilityReservation;
   /** The persisted snapshot as authored; routes disclose it per {@link AgentNotice.sensitivity}. */
-  readonly content: AgentDocumentSnapshot;
+  readonly content: AgentDocument;
   readonly createdAt: string;
   readonly dedupeKey?: string;
   readonly expiredAt?: string;
@@ -245,7 +245,7 @@ export interface AgentNoticeLedgerSnapshot {
 }
 
 export interface AgentNoticePublishInput {
-  readonly content: AgentDocumentSnapshot;
+  readonly content: AgentDocument;
   readonly dedupeKey?: string;
   readonly expiresAt?: string;
   readonly nextAttemptAt?: string;
diff --git a/packages/rsc-runtime/src/notices/redaction.ts b/packages/rsc-runtime/src/notices/redaction.ts
index a5be4a83a..bbed71509 100644
--- a/packages/rsc-runtime/src/notices/redaction.ts
+++ b/packages/rsc-runtime/src/notices/redaction.ts
@@ -1,13 +1,13 @@
 import { RedactionLimitError, compilePolicy } from 'flare-redact';
 
 import { DEFAULT_AGENT_RENDER_LIMITS } from '../agent-document.js';
-import type { AgentDocumentNode, AgentDocumentSnapshot } from '../agent-document.js';
+import type { AgentDocumentNode, AgentDocument } from '../agent-document.js';
 import type { JsonValue } from '../lower-mcp.js';
 
 /**
  * Notice content redaction (#99 acceptance item 7).
  *
- * A notice's free text lives only in its detached `AgentDocumentSnapshot`
+ * A notice's free text lives only in its detached `AgentDocument`
  * (`text`, `markdown`, `context`, `progress.message`, `error.message`,
  * `resource.name`/`uri`, and every string inside `json.value`,
  * `result.metadata`, and the document `value`). Recipient, priority, dedupe
@@ -158,13 +158,13 @@ const redactNode = (node: AgentDocumentNode): AgentDocumentNode => {
  * The document a route hands out in place of content it may not disclose:
  * one text node carrying the mark, with the original status and version.
  */
-export const noticeRedactionPlaceholder = (snapshot: AgentDocumentSnapshot): AgentDocumentSnapshot => Object.freeze({
+export const noticeRedactionPlaceholder = (snapshot: AgentDocument): AgentDocument => Object.freeze({
   root: Object.freeze({ kind: 'text' as const, text: NOTICE_REDACTION_MARK }),
   status: snapshot.status,
   version: snapshot.version,
 });
 
-const documentBytes = (document: AgentDocumentSnapshot): number =>
+const documentBytes = (document: AgentDocument): number =>
   new TextEncoder().encode(JSON.stringify(document)).byteLength;
 
 /**
@@ -177,8 +177,8 @@ const documentBytes = (document: AgentDocumentSnapshot): number =>
  * out oversized: the bound is a promise to hosts, made at publish and kept on
  * egress.
  */
-export const redactNoticeDocument = (snapshot: AgentDocumentSnapshot): AgentDocumentSnapshot => {
-  const redacted: AgentDocumentSnapshot = Object.freeze({
+export const redactNoticeDocument = (snapshot: AgentDocument): AgentDocument => {
+  const redacted: AgentDocument = Object.freeze({
     ...snapshot,
     root: redactNode(snapshot.root),
     ...(snapshot.value === undefined ? {} : { value: redactJson(snapshot.value) }),
@@ -226,7 +226,7 @@ export const NOTICE_TITLE_MAX_LENGTH = 120;
  * {@link NOTICE_TITLE_MAX_LENGTH}. A document without prose has no title and
  * yields an empty string, which title-only routes treat as nothing to show.
  */
-export const noticeTitle = (snapshot: AgentDocumentSnapshot): string => {
+export const noticeTitle = (snapshot: AgentDocument): string => {
   const prose = firstProse(snapshot.root) ?? '';
   const line = prose.split(/\r?\n/u).map((part) => part.trim()).find((part) => part.length > 0) ?? '';
   return line.length <= NOTICE_TITLE_MAX_LENGTH ? line : `${line.slice(0, NOTICE_TITLE_MAX_LENGTH - 1)}…`;
@@ -255,9 +255,9 @@ export type AgentNoticeDisclosure =
  * `signal` route carries no content and gets `undefined`.
  */
 export const disclosedNoticeContent = (
-  content: AgentDocumentSnapshot,
+  content: AgentDocument,
   disclosure: Extract,
-): AgentDocumentSnapshot | undefined => {
+): AgentDocument | undefined => {
   switch (disclosure.shape) {
     case 'body':
       return disclosure.redacted ? redactNoticeDocument(content) : content;
diff --git a/packages/rsc-runtime/src/notices/state.ts b/packages/rsc-runtime/src/notices/state.ts
index 4e27d3719..6d4a7b369 100644
--- a/packages/rsc-runtime/src/notices/state.ts
+++ b/packages/rsc-runtime/src/notices/state.ts
@@ -3,7 +3,6 @@ import { z } from 'zod';
 import {
   createAgentDocument,
   type AgentDocument,
-  type AgentDocumentSnapshot,
 } from '../agent-document.js';
 import type {
   AgentStateDefinition,
@@ -90,7 +89,7 @@ const principalSchema = z.object({
   workspace: observed(z.object({ root: z.string().min(1) }).strict()),
 }).strict();
 
-const documentSchema = z.custom((value) => {
+const documentSchema = z.custom((value) => {
   try {
     createAgentDocument(value as AgentDocument);
     return true;
diff --git a/packages/rsc-runtime/src/request-context.ts b/packages/rsc-runtime/src/request-context.ts
deleted file mode 100644
index bd6981761..000000000
--- a/packages/rsc-runtime/src/request-context.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { AsyncLocalStorage } from 'node:async_hooks';
-
-export interface RscRequestContext {
-  run(value: T, operation: () => TResult): TResult;
-  use(): T;
-}
-
-export const createRscRequestContext = (label: string): Readonly> => {
-  const storage = new AsyncLocalStorage();
-  return Object.freeze({
-    run(value: T, operation: () => TResult): TResult {
-      return storage.run(value, operation);
-    },
-    use(): T {
-      const value = storage.getStore();
-      if (value === undefined) {
-        throw new Error(`${label} used outside a render request`);
-      }
-      return value;
-    },
-  });
-};
diff --git a/packages/rsc-runtime/tests/docs-contract.test.ts b/packages/rsc-runtime/tests/docs-contract.test.ts
deleted file mode 100644
index e7a789490..000000000
--- a/packages/rsc-runtime/tests/docs-contract.test.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { expect, it } from '@rstest/core';
-import { createElement } from 'react';
-import { Mcp, lowerMcpResult } from '../src/index.js';
-
-it('rejects the multi-child `Mcp.Text` the documented template literal avoids', () => {
-    expect(() => lowerMcpResult(createElement(
-      Mcp.Result,
-      null,
-      createElement(Mcp.Text, null, 'Runtime is ', 'ready', '.'),
-    ))).toThrow('mcp-text requires one text child');
-  });
diff --git a/packages/rsc-runtime/tests/mcp-projector.test.ts b/packages/rsc-runtime/tests/mcp-projector.test.ts
index 88a9864b2..96b317fcd 100644
--- a/packages/rsc-runtime/tests/mcp-projector.test.ts
+++ b/packages/rsc-runtime/tests/mcp-projector.test.ts
@@ -9,6 +9,7 @@ import {
   type AgentRenderEvent,
   type McpProgressNotificationParams,
 } from '../src/index.js';
+import { snapshotJsonValue } from '../src/lower-mcp.js';
 
 const document = (overrides: Partial = {}): AgentDocument =>
   createAgentDocument({
@@ -30,6 +31,19 @@ const eventsOf = (events: readonly AgentRenderEvent[]): ReadableStream {
+  expect(snapshotJsonValue(
+    { items: [1, undefined], omitted: undefined },
+    'invalid JSON',
+  )).toEqual({ items: [1, null] });
+
+  const cyclic: Record = {};
+  cyclic.self = cyclic;
+  expect(() => snapshotJsonValue(cyclic, 'invalid JSON')).toThrow('cyclic value at self');
+  expect(() => snapshotJsonValue({ value: Number.POSITIVE_INFINITY }, 'invalid JSON'))
+    .toThrow('non-finite number at value');
+});
+
 describe('projectMcpRenderStream', () => {
   it('emits notifications/progress only when the caller supplied a progress token', async () => {
     const sent: McpProgressNotificationParams[] = [];
diff --git a/packages/rsc-runtime/tests/notices-redaction.test.ts b/packages/rsc-runtime/tests/notices-redaction.test.ts
index 3862a3060..391278264 100644
--- a/packages/rsc-runtime/tests/notices-redaction.test.ts
+++ b/packages/rsc-runtime/tests/notices-redaction.test.ts
@@ -26,7 +26,7 @@ import {
   type AgentNoticeSensitivity,
   type AgentNoticePrincipal,
 } from '../src/notices/index.js';
-import type { AgentDocumentSnapshot } from '../src/index.js';
+import type { AgentDocument } from '../src/index.js';
 import {
   DEFAULT_AGENT_RENDER_LIMITS,
   agent,
@@ -37,7 +37,7 @@ import {
 } from '../src/index.js';
 import { createMemoryStateDriver } from '../src/state/index.js';
 
-const document = (text: string): AgentDocumentSnapshot => ({
+const document = (text: string): AgentDocument => ({
   root: { kind: 'text' as const, text },
   status: 'success' as const,
   version: 1 as const,
@@ -151,7 +151,7 @@ describe('secret pass (flare-redact)', () => {
   });
 
   it('redacts every prose field of a document and nothing else', () => {
-    const snapshot: AgentDocumentSnapshot = {
+    const snapshot: AgentDocument = {
       root: {
         children: [
           { kind: 'markdown', text: 'password: p4ss' },
diff --git a/packages/rsc-runtime/tests/runtime.test.ts b/packages/rsc-runtime/tests/runtime.test.ts
deleted file mode 100644
index b60fc6b40..000000000
--- a/packages/rsc-runtime/tests/runtime.test.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-import { describe, expect, it } from '@rstest/core';
-import { createElement } from 'react';
-
-import {
-  Hook,
-  Mcp,
-  createRscRequestContext,
-  lowerHookResult,
-  lowerMcpResult,
-} from '../src/index.js';
-
-describe('@agent-bundle/runtime', () => {
-  it('lowers one Hook result', () => {
-    expect(lowerHookResult(
-      createElement(Hook.Result, null, createElement(Hook.AdditionalContext, null, 'ready')),
-    )).toEqual({
-      hookSpecificOutput: {
-        additionalContext: 'ready',
-        hookEventName: 'PostToolUse',
-      },
-    });
-  });
-
-  it('lowers one MCP result without sharing mutable structured content', () => {
-    const structuredContent = { status: 'ready' };
-    const result = lowerMcpResult(
-      createElement(Mcp.Result, { structuredContent }, createElement(Mcp.Text, null, 'ready')),
-    );
-
-    structuredContent.status = 'changed';
-    expect(result).toEqual({
-      content: [{ text: 'ready', type: 'text' }],
-      structuredContent: { status: 'ready' },
-    });
-  });
-
-  it('follows SDK wire semantics for undefined inside structured content and _meta', () => {
-    // Repro from issue #44: handlers written against SDK serialization leave
-    // optional fields undefined; JSON.stringify drops them from objects and
-    // lowers them to null inside arrays.
-    const structuredContent = {
-      count: 3,
-      items: [1, undefined, 'x', { keep: true, nested: undefined }],
-      note: undefined,
-      report: { deep: { drop: undefined }, keep: 'y' },
-    };
-    const result = lowerMcpResult(createElement(
-      Mcp.Result,
-      {
-        _meta: { note: undefined, ui: { resourceUri: 'ui://demo/widget.html', subtitle: undefined } },
-        structuredContent,
-      },
-      createElement(Mcp.Text, null, 'ok'),
-    ));
-
-    expect(result.structuredContent).toEqual(JSON.parse(JSON.stringify(structuredContent)));
-    expect(JSON.stringify(result.structuredContent)).toBe(
-      '{"count":3,"items":[1,null,"x",{"keep":true}],"report":{"deep":{},"keep":"y"}}',
-    );
-    expect(Object.hasOwn(result.structuredContent!, 'note')).toBe(false);
-    expect(result._meta).toEqual({ ui: { resourceUri: 'ui://demo/widget.html' } });
-    expect(Object.hasOwn(result._meta!, 'note')).toBe(false);
-  });
-
-  it('keeps an all-undefined structured content object as an empty record', () => {
-    const result = lowerMcpResult(createElement(
-      Mcp.Result,
-      { structuredContent: { note: undefined } },
-      createElement(Mcp.Text, null, 'ok'),
-    ));
-    expect(JSON.stringify(result.structuredContent)).toBe('{}');
-  });
-
-  it('still rejects non-wire JSON shapes and points at the offending key path', () => {
-    const lower = (structuredContent: unknown) => () => lowerMcpResult(
-      createElement(Mcp.Result, { structuredContent }, createElement(Mcp.Text, null, 'ok')),
-    );
-
-    const cyclic: Record = { name: 'loop' };
-    cyclic.self = { inner: cyclic };
-    expect(lower(cyclic)).toThrow(
-      'mcp-result structuredContent must be JSON-serializable (cyclic value at self.inner)',
-    );
-    expect(lower({ report: { get bad() { return 1; } } })).toThrow(
-      'mcp-result structuredContent must be JSON-serializable (non-enumerable or accessor property at report.bad)',
-    );
-    expect(lower({ items: Object.assign([], { 0: 1, 2: 2, length: 3 }) })).toThrow(
-      'mcp-result structuredContent must be JSON-serializable (sparse or decorated array at items)',
-    );
-    expect(lower({ callback: () => undefined })).toThrow(
-      'mcp-result structuredContent must be JSON-serializable (non-JSON value at callback)',
-    );
-    expect(lower({ ratio: Number.POSITIVE_INFINITY })).toThrow(
-      'mcp-result structuredContent must be JSON-serializable (non-finite number at ratio)',
-    );
-    expect(lower({ wrapped: new Map() })).toThrow(
-      'mcp-result structuredContent must be JSON-serializable (non-plain object at wrapped)',
-    );
-  });
-
-  it('resolves synchronous server components around MCP protocol elements', () => {
-    const Status = ({ value }: { readonly value: string }) => createElement(
-      Mcp.Result,
-      { structuredContent: { value } },
-      createElement(Mcp.Text, null, value),
-    );
-
-    expect(lowerMcpResult(createElement(Status, { value: 'ready' }))).toEqual({
-      content: [{ text: 'ready', type: 'text' }],
-      structuredContent: { value: 'ready' },
-    });
-  });
-
-  it('isolates request context across concurrent work', async () => {
-    const context = createRscRequestContext('test request');
-    const barrier = Promise.withResolvers();
-
-    const first = context.run('a', async () => {
-      await barrier.promise;
-      return context.use();
-    });
-    const second = context.run('b', async () => {
-      barrier.resolve();
-      return context.use();
-    });
-
-    await expect(Promise.all([first, second])).resolves.toEqual(['a', 'b']);
-  });
-
-  it('rejects request context access outside a request', () => {
-    const context = createRscRequestContext('test request');
-    expect(() => context.use()).toThrow('test request used outside a render request');
-  });
-});
diff --git a/packages/workbench/src/main.tsx b/packages/workbench/src/main.tsx
index fe8e44466..25ffa9c7b 100644
--- a/packages/workbench/src/main.tsx
+++ b/packages/workbench/src/main.tsx
@@ -306,6 +306,7 @@ const Workbench = () => {
         scheduleRuntimeBootstrap();
       });
     };
+    const unsubscribeEvents = next.subscribeEvents((event) => { runtimeEvents.receive(event); });
     void next.connect(
       (nextStatus) => {
         if (!mounted) return;
@@ -321,7 +322,6 @@ const Workbench = () => {
       (reason) => {
         if (mounted) setConnectionError(connectionFailure(reason));
       },
-      (event) => { runtimeEvents.receive(event); },
     ).catch((reason: unknown) => {
       if (mounted) setConnectionError(connectionFailure(reason));
     });
@@ -330,6 +330,7 @@ const Workbench = () => {
       if (resetRuntimeInstance.current === resetRuntimeBootstrap) resetRuntimeInstance.current = () => undefined;
       if (runtimeRetry !== undefined) clearTimeout(runtimeRetry);
       runtimeEvents.close();
+      unsubscribeEvents();
       unsubscribeConnection();
       void (async () => {
         try {
@@ -402,9 +403,9 @@ const Workbench = () => {
     client: clients.invocationClient,
     events: { subscribe: (listener) => projectClient.subscribeEvents(listener) },
   }), [clients, projectClient]);
-  const runtimeBackend = useMemo(() => capabilities?.features.runtime === true && runtimeControllerState !== undefined
+  const runtimeBackend = useMemo(() => capabilities?.runtime === true && runtimeControllerState !== undefined
     ? createRuntimeBackend({ controller: runtimeControllerState, runtimeClient: clients.runtimeClient })
-    : undefined, [capabilities?.features.runtime, clients, runtimeControllerState]);
+    : undefined, [capabilities?.runtime, clients, runtimeControllerState]);
   const backends = useMemo(() => Object.freeze(
     runtimeBackend === undefined ? [devServerBackend] : [runtimeBackend, devServerBackend],
   ), [devServerBackend, runtimeBackend]);
diff --git a/packages/workbench/src/mcp/mcp-page.tsx b/packages/workbench/src/mcp/mcp-page.tsx
index f461e34eb..fbedd507d 100644
--- a/packages/workbench/src/mcp/mcp-page.tsx
+++ b/packages/workbench/src/mcp/mcp-page.tsx
@@ -75,7 +75,7 @@ interface McpPageCommonProps {
   readonly initialToolPrefill?: McpToolPrefill;
   /** Absent when the host has no Inspector launcher; the section then offers only the config download. */
   readonly inspectorLaunch?: McpPageInspectorLaunch;
-  readonly onDownloadConfig?: (download: McpConfigDownload) => void;
+  readonly onDownloadConfig?: (download: McpDownload) => void;
   readonly onDownloadTrace?: (download: McpDownload) => void;
   /** The shell router; a frame's lifted `correlationId` links to the unified Trace through it. */
   readonly onNavigate?: (location: WorkbenchLocation) => void;
@@ -158,11 +158,9 @@ export interface McpPageRuntimeProps extends McpPageCommonProps {
   readonly source: Extract;
 }
 
-/** Legacy artifact props remain source-compatible; runtime callers supply only the discriminated source. */
+/** Artifact inspection and runtime sessions are separate Workbench product modes. */
 export type McpPageProps = McpPageArtifactProps | McpPageRuntimeProps;
 
-export type McpConfigDownload = McpDownload;
-
 export interface McpProtocolEvidenceProps {
   readonly ariaLabel: string;
   readonly onNavigate?: (location: WorkbenchLocation) => void;
@@ -988,7 +986,7 @@ const connectionSummary = (connection: McpBrowserSessionModel['connection']): st
     .join(' · ') || 'Connection negotiated.';
 };
 
-export const mcpConfigDownload = (config: McpSessionInspectorConfig, sessionId: string): McpConfigDownload => ({
+export const mcpConfigDownload = (config: McpSessionInspectorConfig, sessionId: string): McpDownload => ({
   blob: new Blob([`${JSON.stringify(config, null, 2)}\n`], { type: 'application/json' }),
   filename: `mcp-${sessionId}-inspector.json`,
 });
diff --git a/packages/workbench/src/project-client.ts b/packages/workbench/src/project-client.ts
index 9ccf1268f..eb01f6ab8 100644
--- a/packages/workbench/src/project-client.ts
+++ b/packages/workbench/src/project-client.ts
@@ -360,7 +360,6 @@ export class ProjectClient {
   #activity = activityFor([]);
   readonly #activityListeners = new Set();
   #eventDrainPromise: Promise | undefined;
-  #eventListener: ProjectEventListener | undefined;
   readonly #eventSubscribers = new Set();
   #eventQueue: QueuedProjectEvent[] = [];
   #eventSource: EventSourceLike | undefined;
@@ -415,12 +414,10 @@ export class ProjectClient {
   async connect(
     listener: (status: ProjectStatus) => void,
     onError?: ProjectClientErrorListener,
-    onEvent?: ProjectEventListener,
   ): Promise {
     if (this.#closed) throw new ProjectClientError('Workbench client is closed.');
     this.#listener = listener;
     this.#errorListener = onError;
-    this.#eventListener = onEvent;
     let snapshot;
     try {
       snapshot = await this.#foreground.sessionSnapshot();
@@ -521,7 +518,6 @@ export class ProjectClient {
     this.#recoveryVersion += 1;
     this.#statusGeneration += 1;
     this.#errorListener = undefined;
-    this.#eventListener = undefined;
     this.#eventSubscribers.clear();
     this.#listener = undefined;
   }
@@ -765,11 +761,7 @@ export class ProjectClient {
   }
 
   #publishEvent(event: ProjectEventMessage): void {
-    const listeners = [
-      ...(this.#eventListener === undefined ? [] : [this.#eventListener]),
-      ...this.#eventSubscribers,
-    ];
-    for (const listener of listeners) {
+    for (const listener of this.#eventSubscribers) {
       try {
         listener(event);
       } catch (error) {
diff --git a/packages/workbench/src/workbench-capabilities.ts b/packages/workbench/src/workbench-capabilities.ts
index f3128c05c..3a3654ed9 100644
--- a/packages/workbench/src/workbench-capabilities.ts
+++ b/packages/workbench/src/workbench-capabilities.ts
@@ -22,22 +22,6 @@ export interface WorkbenchRouteCatalog {
   readonly state: RouteCatalogState;
 }
 
-/**
- * Which optional surfaces this build declares. Navigation no longer derives
- * from these — the application tree does — but the shell still gates what it
- * wires: the runtime backend exists only for a project with a `devRuntime`
- * provider, and Advanced sections read the flags for their empty states.
- */
-export interface WorkbenchFeatures {
-  readonly evals: boolean;
-  readonly hooks: boolean;
-  readonly mcp: boolean;
-  /** The foreground owns a development Runtime controller (`ProjectStatus.runtime`). */
-  readonly runtime: boolean;
-  readonly scripts: boolean;
-  readonly skills: boolean;
-}
-
 export interface WorkbenchCapabilities {
   readonly buildId: string;
   readonly counts: Readonly<{
@@ -48,9 +32,10 @@ export interface WorkbenchCapabilities {
     readonly skills: number;
     readonly targets: number;
   }>;
-  readonly features: WorkbenchFeatures;
   readonly inspection: ArtifactInspection;
   readonly routes: WorkbenchRouteCatalog;
+  /** The foreground owns a development Runtime controller (`ProjectStatus.runtime`). */
+  readonly runtime: boolean;
   readonly skillTree: SkillDocumentTree;
 }
 
@@ -92,26 +77,6 @@ const routeCatalog = async (
   }
 };
 
-const manifestHas = (manifest: RouteManifest | undefined, select: (manifest: RouteManifest) => number): boolean =>
-  manifest !== undefined && select(manifest) > 0;
-
-/**
- * Feature detection unions the compiled route graph with the artifact catalog:
- * a project may declare a surface through either, and neither may hide the other.
- */
-const featuresFor = (
-  counts: WorkbenchCapabilities['counts'],
-  routes: WorkbenchRouteCatalog,
-  runtime: boolean,
-): WorkbenchFeatures => Object.freeze({
-  evals: counts.evalSuites > 0,
-  hooks: counts.hooks > 0 || manifestHas(routes.manifest, (manifest) => manifest.events.length),
-  mcp: counts.mcpServers > 0 || manifestHas(routes.manifest, (manifest) => manifest.servers.length),
-  runtime,
-  scripts: counts.scripts > 0 || manifestHas(routes.manifest, (manifest) => manifest.scripts.length),
-  skills: counts.skills > 0,
-});
-
 /** Composes existing strict route catalogs into one build-scoped Workbench view. */
 export const loadWorkbenchCapabilities = async ({
   artifactClient,
@@ -145,9 +110,9 @@ export const loadWorkbenchCapabilities = async ({
   return Object.freeze({
     buildId,
     counts,
-    features: featuresFor(counts, routes, runtime),
     inspection,
     routes,
+    runtime,
     skillTree,
   });
 };
diff --git a/packages/workbench/tests/build-status-model.test.ts b/packages/workbench/tests/build-status-model.test.ts
index ee3e4fd4d..4da52db60 100644
--- a/packages/workbench/tests/build-status-model.test.ts
+++ b/packages/workbench/tests/build-status-model.test.ts
@@ -197,7 +197,7 @@ it('lists source, build, contract-gate, catalog, host, and runtime problems, err
       state: 'stale',
     },
     runtimeDiagnostic: 'AB8200 — provider failed to load',
-    status: { ...gatedStatus, source: { ...gatedStatus.source, diagnostics: [{ code: 'AB4001', message: 'Description is missing.', severity: 'info', sourcePath: 'agent-bundle.config.ts' }] } },
+    status: { ...gatedStatus, source: { ...gatedStatus.source, diagnostics: [{ code: 'AB4002', message: 'Description is missing.', severity: 'info', sourcePath: 'agent-bundle.config.ts' }] } },
     tree: treeWith('src/scripts/sync.ts'),
   });
 
@@ -208,7 +208,7 @@ it('lists source, build, contract-gate, catalog, host, and runtime problems, err
     ['error', 'runtime', undefined],
     ['warning', 'route-catalog', 'AB5101'],
     ['warning', 'route-catalog', undefined],
-    ['info', 'source', 'AB4001'],
+    ['info', 'source', 'AB4002'],
   ]);
   const contractFailure = problems[1]!;
   expect(contractFailure.node).toEqual({ kind: 'tool', name: 'version', server: 'fixture' });
diff --git a/packages/workbench/tests/mcp-app-real.e2e.test.ts b/packages/workbench/tests/mcp-app-real.e2e.test.ts
index a8510ccbd..957af1c97 100644
--- a/packages/workbench/tests/mcp-app-real.e2e.test.ts
+++ b/packages/workbench/tests/mcp-app-real.e2e.test.ts
@@ -79,7 +79,7 @@ const writeRealAppProject = async (root: string): Promise => {
     symlink(join(workbenchNodeModules, 'zod'), join(root, 'node_modules', 'zod'), 'dir'),
   ]);
   await Promise.all([
-    writeFile(join(root, 'package.json'), '{"type":"module"}\n'),
+    writeFile(join(root, 'package.json'), '{"type":"module","version":"1.0.0"}\n'),
     writeFile(join(root, 'src', 'server.ts'), [
       "import { McpServer } from '@modelcontextprotocol/server';",
       "import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';",
@@ -104,7 +104,7 @@ const writeRealAppProject = async (root: string): Promise => {
       '',
       'export default defineConfig({',
       "  mcp: { servers: { fixture: { entry: './src/server.ts' } } },",
-      "  plugin: { name: 'real-app-e2e-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'real-app-e2e-fixture' },",
       "  skills: ['src/skills/review'],",
       "  targets: ['portable'],",
       '});',
@@ -121,7 +121,7 @@ const writeBundledAppProject = async (root: string): Promise => {
     symlink(join(workbenchNodeModules, 'zod'), join(root, 'node_modules', 'zod'), 'dir'),
   ]);
   await Promise.all([
-    writeFile(join(root, 'package.json'), '{"type":"module"}\n'),
+    writeFile(join(root, 'package.json'), '{"type":"module","version":"1.0.0"}\n'),
     writeFile(join(root, 'views', 'dashboard.css'), '#view { color: rgb(18, 52, 86); font-weight: 700; }\n'),
     writeFile(join(root, 'views', 'dashboard.ts'), [
       "import { createAppClient } from 'agent-bundle/app';",
@@ -179,7 +179,7 @@ const writeBundledAppProject = async (root: string): Promise => {
       "    apps: { dashboard: { entry: './views/dashboard.ts', resourceUri: 'ui://packed-release/dashboard.html', template: './views/shell.html' } },",
       "    entry: './src/server.ts',",
       '  } } },',
-      "  plugin: { name: 'bundled-app-e2e-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'bundled-app-e2e-fixture' },",
       "  skills: ['src/skills/review'],",
       "  targets: ['portable'],",
       '});',
@@ -217,19 +217,18 @@ const writeLifecycleServer = async (root: string): Promise => {
     writeFile(join(server, 'tools', 'show-lifecycle.tsx'), [
       "import { Agent } from '@agent-bundle/runtime';",
       "import React from 'react';",
-      "import type { ToolConfig, ToolRouteProps } from 'agent-bundle';",
-      "import { appResourceUri } from 'agent-bundle/routes';",
+      "import { appResourceUri, defineTool } from 'agent-bundle/routes';",
       "import { z } from 'zod';",
       '',
-      'export const config = {',
-      "  _meta: { ui: { resourceUri: appResourceUri('lifecycle') } },",
-      "  description: 'Waits as long as it is told, then succeeds or fails on request.',",
-      '} satisfies ToolConfig;',
-      '',
       'export const inputSchema = z.object({ delayMs: z.number().int().min(0), fail: z.boolean().optional() });',
       'export const resultSchema = z.object({ waited: z.number() });',
       '',
-      'export default async function ShowLifecycle({ input }: ToolRouteProps) {',
+      'export default defineTool({',
+      "  _meta: { ui: { resourceUri: appResourceUri('lifecycle') } },",
+      "  description: 'Waits as long as it is told, then succeeds or fails on request.',",
+      '  inputSchema,',
+      '  resultSchema,',
+      '}, async (input) => {',
       '  await new Promise((resolve) => setTimeout(resolve, input.delayMs));',
       '  return (',
       '    ',
@@ -238,7 +237,7 @@ const writeLifecycleServer = async (root: string): Promise => {
       '        : {`Lifecycle waited ${String(input.delayMs)}ms.`}}',
       '    ',
       '  );',
-      '}',
+      '});',
       '',
     ].join('\n')),
   ]);
diff --git a/packages/workbench/tests/mcp-session-timeout.e2e.test.ts b/packages/workbench/tests/mcp-session-timeout.e2e.test.ts
index f29cb73a8..7d32c5535 100644
--- a/packages/workbench/tests/mcp-session-timeout.e2e.test.ts
+++ b/packages/workbench/tests/mcp-session-timeout.e2e.test.ts
@@ -19,7 +19,7 @@ const writeTimeoutProject = async (root: string): Promise => {
     symlink(join(agentBundleNodeModules, '@modelcontextprotocol'), join(root, 'node_modules', '@modelcontextprotocol'), 'dir'),
   ]);
   await Promise.all([
-    writeFile(join(root, 'package.json'), '{"type":"module"}\n'),
+    writeFile(join(root, 'package.json'), '{"type":"module","version":"1.0.0"}\n'),
     writeFile(join(root, 'src', 'server.ts'), [
       "import { McpServer } from '@modelcontextprotocol/server';",
       "import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';",
@@ -34,7 +34,7 @@ const writeTimeoutProject = async (root: string): Promise => {
       '',
       'export default defineConfig({',
       "  mcp: { servers: { fixture: { entry: './src/server.ts' } } },",
-      "  plugin: { name: 'timeout-e2e-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'timeout-e2e-fixture' },",
       "  targets: ['portable'],",
       '});',
       '',
diff --git a/packages/workbench/tests/overview.e2e.test.ts b/packages/workbench/tests/overview.e2e.test.ts
index 074d2b8b9..cdf0a375c 100644
--- a/packages/workbench/tests/overview.e2e.test.ts
+++ b/packages/workbench/tests/overview.e2e.test.ts
@@ -33,7 +33,7 @@ const writeMcpPlaygroundProject = async (root: string): Promise => {
     symlink(join(workbenchNodeModules, 'zod'), join(root, 'node_modules', 'zod'), 'dir'),
   ]);
   await Promise.all([
-    writeFile(join(root, 'package.json'), '{"type":"module"}\n'),
+    writeFile(join(root, 'package.json'), '{"type":"module","version":"1.0.0"}\n'),
     writeFile(join(root, 'src', 'server.ts'), [
       "import { McpServer } from '@modelcontextprotocol/server';",
       "import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';",
@@ -61,7 +61,7 @@ const writeMcpPlaygroundProject = async (root: string): Promise => {
       '  codex: {},',
       "  mcp: { servers: { fixture: { entry: './src/server.ts', env: { NO_COLOR: '1', SECRET_TOKEN: 'fixture-secret' } } } },",
       "  portable: { fixtureMarker: 'artifact-extension-initial' },",
-      "  plugin: { name: 'workbench-mcp-fixture', version: '1.0.0' },",
+      "  plugin: { name: 'workbench-mcp-fixture' },",
       "  skills: ['src/skills/review'],",
       "  targets: ['portable'],",
       '});',
diff --git a/packages/workbench/tests/project-client.test.ts b/packages/workbench/tests/project-client.test.ts
index a59a86083..33add0c51 100644
--- a/packages/workbench/tests/project-client.test.ts
+++ b/packages/workbench/tests/project-client.test.ts
@@ -485,15 +485,12 @@ it('delivers synchronous runtime events once in FIFO order and refreshes after a
     }),
   });
 
-  await client.connect(
-    () => undefined,
-    (reason) => errors.push(reason),
-    (event) => {
-      if (event.type !== 'runtime.event') return;
-      received.push(event.sequence);
-      frozen.push(Object.isFrozen(event) && Object.isFrozen(event.payload));
-    },
-  );
+  client.subscribeEvents((event) => {
+    if (event.type !== 'runtime.event') return;
+    received.push(event.sequence);
+    frozen.push(Object.isFrozen(event) && Object.isFrozen(event.payload));
+  });
+  await client.connect(() => undefined, (reason) => errors.push(reason));
   stream.emit('runtime.event', runtimeEvent(8));
   stream.emit('runtime.event', runtimeEvent(9));
   stream.emit('runtime.event', runtimeEvent(10));
@@ -529,7 +526,7 @@ it('delivers route.invocation events to subscribers without refreshing project s
       return Response.json({ status: status() });
     }),
   });
-  await client.connect(() => undefined, undefined, (event) => received.push(`legacy:${event.type}`));
+  await client.connect(() => undefined);
   client.subscribeEvents((event) => {
     if (event.type !== 'route.invocation') return;
     received.push(`${event.type}:${String(event.sequence)}:${event.payload.invocation.id}:${String(Object.isFrozen(event.payload.invocation))}`);
@@ -566,8 +563,8 @@ it('delivers route.invocation events to subscribers without refreshing project s
   await flushEvents();
 
   expect(received).toEqual([
-    'legacy:route.invocation', 'route.invocation:1:inv_1:true',
-    'legacy:route.invocation', 'route.invocation:2:inv_2:true',
+    'route.invocation:1:inv_1:true',
+    'route.invocation:2:inv_2:true',
   ]);
   expect(client.lastEventId).toBe(2);
   expect(requests).toEqual(['/api/project/status']);
@@ -586,11 +583,8 @@ it('preserves a synchronous runtime event after replay gap delivery', async () =
     }),
   });
 
-  await client.connect(
-    () => undefined,
-    undefined,
-    (event) => received.push(event.type === 'replay.gap' ? event.type : `${event.type}:${event.sequence}`),
-  );
+  client.subscribeEvents((event) => received.push(event.type === 'replay.gap' ? event.type : `${event.type}:${event.sequence}`));
+  await client.connect(() => undefined);
   stream.emit('replay.gap', {
     data: JSON.stringify({
       earliestAvailableSequence: 14,
@@ -617,15 +611,12 @@ it('reports a runtime listener exception and still delivers later queued runtime
     fetch: withForegroundSession(async () => Response.json({ status: status() })),
   });
 
-  await client.connect(
-    () => undefined,
-    (reason) => errors.push(reason),
-    (event) => {
-      if (event.type !== 'runtime.event') return;
-      received.push(event.sequence);
-      if (event.sequence === 8) throw new Error('listener failure');
-    },
-  );
+  client.subscribeEvents((event) => {
+    if (event.type !== 'runtime.event') return;
+    received.push(event.sequence);
+    if (event.sequence === 8) throw new Error('listener failure');
+  });
+  await client.connect(() => undefined, (reason) => errors.push(reason));
   stream.emit('runtime.event', runtimeEvent(8));
   stream.emit('runtime.event', runtimeEvent(9));
   await flushEvents();
@@ -644,13 +635,10 @@ it('clears queued runtime delivery and ignores late callbacks after close', asyn
     fetch: withForegroundSession(async () => Response.json({ status: status() })),
   });
 
-  await client.connect(
-    () => undefined,
-    undefined,
-    (event) => {
-      if (event.type === 'runtime.event') received.push(event.sequence);
-    },
-  );
+  client.subscribeEvents((event) => {
+    if (event.type === 'runtime.event') received.push(event.sequence);
+  });
+  await client.connect(() => undefined);
   stream.emit('runtime.event', runtimeEvent(8));
   client.close();
   stream.emit('runtime.event', runtimeEvent(9));
@@ -1087,7 +1075,6 @@ it('delivers subscribed replay and live events FIFO, exposing gaps before refres
   await client.connect(
     () => statuses.push('status'),
     (error) => errors.push(error),
-    (event) => received.push(`legacy:${eventName(event)}`),
   );
   received.length = 0;
   statuses.length = 0;
@@ -1098,9 +1085,9 @@ it('delivers subscribed replay and live events FIFO, exposing gaps before refres
   await flushEvents();
 
   expect(received).toEqual([
-    'legacy:runtime.event:1', 'first:runtime.event:1', 'third:runtime.event:1',
-    'legacy:gap:none', 'first:gap:none', 'third:gap:none',
-    'legacy:runtime.event:3', 'first:runtime.event:3', 'third:runtime.event:3',
+    'first:runtime.event:1', 'third:runtime.event:1',
+    'first:gap:none', 'third:gap:none',
+    'first:runtime.event:3', 'third:runtime.event:3',
   ]);
   expect(errors).toHaveLength(1);
   expect(errors[0]).toMatchObject({ message: 'subscriber failure' });
diff --git a/packages/workbench/tests/streaming-render.e2e.test.ts b/packages/workbench/tests/streaming-render.e2e.test.ts
index 91c94f71f..e9a057790 100644
--- a/packages/workbench/tests/streaming-render.e2e.test.ts
+++ b/packages/workbench/tests/streaming-render.e2e.test.ts
@@ -7,7 +7,7 @@ import { expect } from '@rstest/playwright';
 import { inspectWorkbenchSurface } from '../../agent-bundle/src/test/index.ts';
 import { gatedRouteFiles } from '../../agent-bundle/tests/helpers/gated-routes.ts';
 import { createProjectFixture, removeProjectFixture } from '../../agent-bundle/tests/helpers/project-fixture.ts';
-import { agentBundleNodeModules } from '../../agent-bundle/tests/helpers/workspace-paths.ts';
+import { exampleNodeModules } from '../../agent-bundle/tests/helpers/workspace-paths.ts';
 import { timeScale } from '../../agent-bundle/tests/support/time-scale.ts';
 import { applicationLeafForRouteId } from '../src/application/application-tree-model.ts';
 import {
@@ -33,21 +33,21 @@ e2e('renders a compiled MCP tool\'s Suspense fallback before its gated child rel
     createProject: () => createProjectFixture({
       config: [
         'export default {',
-        "  plugin: { name: 'streaming-render-e2e', version: '1.0.0' },",
+        "  plugin: { name: 'streaming-render-e2e' },",
         "  targets: ['portable'],",
         '};',
         '',
       ].join('\n'),
       files: {
         ...gatedRouteFiles,
-        'package.json': '{"dependencies":{"@agent-bundle/runtime":"workspace:*","react":"19.2.8","zod":"4.5.4"},"type":"module"}\n',
+        'package.json': '{"dependencies":{"@agent-bundle/runtime":"workspace:*","react":"19.2.8","zod":"4.5.4"},"type":"module","version":"1.0.0"}\n',
       },
       prefix: 'agent-bundle-streaming-render-e2e-',
     }),
     dispose: (project) => removeProjectFixture(project.root),
     setup: async (project) => {
       await mkdir(join(project.root, '.agent-bundle'), { recursive: true });
-      await symlink(agentBundleNodeModules, join(project.root, 'node_modules'), 'dir');
+      await symlink(exampleNodeModules, join(project.root, 'node_modules'), 'dir');
     },
     start: (project) => startWorkbenchDevServer(project),
   }, async (server, project) => {
diff --git a/packages/workbench/tests/support/workbench-e2e.ts b/packages/workbench/tests/support/workbench-e2e.ts
index 27a5013f0..c4b36e290 100644
--- a/packages/workbench/tests/support/workbench-e2e.ts
+++ b/packages/workbench/tests/support/workbench-e2e.ts
@@ -35,42 +35,9 @@ export const e2e = test.extend({
   } satisfies PlaywrightOptions,
 });
 
-/**
- * Deleted hash-page names → PR 1 destinations. Callers that still pass
- * `'logs'` / `'evals'` / `'mcp'` keep compiling; new suites should pass a
- * pathname (`/`, `/advanced/evals`, a `workbenchLeafPath` result).
- */
-const legacyPagePath = Object.freeze({
-  artifacts: '/advanced/artifact',
-  comparisons: '/advanced/evals',
-  discovery: '/advanced/hosts',
-  evals: '/advanced/evals',
-  hooks: '/',
-  hosts: '/advanced/hosts',
-  lifecycles: '/trace',
-  logs: '/advanced/logs',
-  mcp: '/advanced/protocol',
-  overview: '/',
-  playground: '/',
-  routes: '/',
-  runtime: '/',
-  skills: '/',
-} as const);
-
-export type WorkbenchLegacyPage = keyof typeof legacyPagePath;
-
-/** Pathname the shell should show for a primary area or a leftover hash-page name. */
-export const workbenchPathname = (pageOrPath = '/'): string => {
-  if (pageOrPath.startsWith('/')) return pageOrPath;
-  return legacyPagePath[pageOrPath as WorkbenchLegacyPage] ?? `/${pageOrPath}`;
-};
-
-/**
- * Canonical Workbench URL. Pathnames are the URL model (`/routes/…`,
- * `/advanced/evals`). Hash-only `#page` routing is gone.
- */
-export const workbenchUrl = (origin: string, pageOrPath = '/'): string =>
-  new URL(workbenchPathname(pageOrPath), origin.endsWith('/') ? origin : `${origin}/`).href;
+/** Canonical Workbench URL using the pathname route model. */
+export const workbenchUrl = (origin: string, path = '/'): string =>
+  new URL(path, origin.endsWith('/') ? origin : `${origin}/`).href;
 
 const idleTimeout = 15_000 * timeScale;
 const buildSettleTimeout = 60_000 * timeScale;
diff --git a/packages/workbench/tests/workbench-capabilities.test.ts b/packages/workbench/tests/workbench-capabilities.test.ts
index f43010288..803c6750a 100644
--- a/packages/workbench/tests/workbench-capabilities.test.ts
+++ b/packages/workbench/tests/workbench-capabilities.test.ts
@@ -183,38 +183,27 @@ const clientsFor = ({
   },
 });
 
-it('derives the Skills Starter features from its validated catalogs', async () => {
+it('derives the Skills Starter counts from its validated catalogs', async () => {
   const capabilities = await loadWorkbenchCapabilities({
     buildId: 'build-a',
     ...clientsFor({ evalSuites: 1, skills: 1, targets: 3 }),
   });
 
-  expect(capabilities.features).toEqual({ evals: true, hooks: false, mcp: false, runtime: false, scripts: false, skills: true });
   expect(capabilities.counts).toEqual({ evalSuites: 1, hooks: 0, mcpServers: 0, scripts: 0, skills: 1, targets: 3 });
   expect(capabilities.routes.state).toBe('current');
   expect(capabilities.routes.manifest?.servers).toEqual([]);
   expect(Object.isFrozen(capabilities)).toBe(true);
   expect(Object.isFrozen(capabilities.counts)).toBe(true);
-  expect(Object.isFrozen(capabilities.features)).toBe(true);
 });
 
-it('detects hooks and scripts from the artifact catalog without advertising unrelated features', async () => {
-  const capabilities = await loadWorkbenchCapabilities({
-    buildId: 'build-a',
-    ...clientsFor({ hooks: 1, scripts: 2, targets: 3 }),
-  });
-
-  expect(capabilities.features).toEqual({ evals: false, hooks: true, mcp: false, runtime: false, scripts: true, skills: false });
-});
-
-it('detects the complete feature set for a full bundle and carries the runtime topology flag', async () => {
+it('carries the runtime topology flag', async () => {
   const capabilities = await loadWorkbenchCapabilities({
     buildId: 'build-a',
     runtime: true,
     ...clientsFor({ evalSuites: 1, hooks: 1, mcpServers: 1, scripts: 1, skills: 1, targets: 3 }),
   });
 
-  expect(capabilities.features).toEqual({ evals: true, hooks: true, mcp: true, runtime: true, scripts: true, skills: true });
+  expect(capabilities.runtime).toBe(true);
   expect(capabilities.inspection.epochId).toBe('build-a');
 });
 
@@ -231,7 +220,6 @@ it('detects hooks, MCP, and scripts from the compiled route graph alone', async
     ...clientsFor({ cliRoutes: 1, events: 1, routeScripts: 2, routeServers: 2 }),
   });
 
-  expect(capabilities.features).toEqual({ evals: false, hooks: true, mcp: true, runtime: false, scripts: true, skills: false });
   expect(capabilities.counts.hooks).toBe(0);
   expect(capabilities.counts.mcpServers).toBe(0);
   expect(capabilities.routes.manifest?.servers.map((server) => server.name)).toEqual(['server-0', 'server-1']);
@@ -248,7 +236,6 @@ it('reports a manifest compiled from newer source than the published build as st
 
   expect(capabilities.routes.state).toBe('stale');
   expect(capabilities.routes.manifest).toBeDefined();
-  expect(capabilities.features.hooks).toBe(true);
 });
 
 it('keeps every artifact-derived feature when the manifest route is unavailable', async () => {
@@ -258,7 +245,6 @@ it('keeps every artifact-derived feature when the manifest route is unavailable'
     routeManifestClient: { manifest: async () => { throw new Error('Route manifest is not available.'); } },
   });
 
-  expect(capabilities.features).toEqual({ evals: true, hooks: true, mcp: true, runtime: false, scripts: true, skills: true });
   expect(capabilities.routes).toEqual({ message: 'Route manifest is not available.', state: 'unavailable' });
   expect(applicationTreeSourcesFor(capabilities)).toEqual({
     inspection: capabilities.inspection,
diff --git a/scripts/measure-hook-cold-start.mjs b/scripts/measure-hook-cold-start.mjs
index a69da22de..cde6b8771 100644
--- a/scripts/measure-hook-cold-start.mjs
+++ b/scripts/measure-hook-cold-start.mjs
@@ -72,13 +72,13 @@ const measure = async () => {
   try {
     await mkdir(join(root, 'src', 'hooks'), { recursive: true });
     await Promise.all([
-      writeFile(join(root, 'package.json'), '{"type":"module"}\n'),
+      writeFile(join(root, 'package.json'), '{"type":"module","version":"0.0.0"}\n'),
       writeFile(
         join(root, 'agent-bundle.config.ts'),
         [
           'export default {',
           '  hooks: { sessionStart: { handler: "./src/hooks/session-start.ts" } },',
-          "  plugin: { name: 'cold-start', version: '0.0.0' },",
+          "  plugin: { name: 'cold-start' },",
           "  targets: ['claude'],",
           '};',
           '',
diff --git a/website/docs/en/examples/audiobook-curator.mdx b/website/docs/en/examples/audiobook-curator.mdx
index decbcaa08..dc1dab5bc 100644
--- a/website/docs/en/examples/audiobook-curator.mdx
+++ b/website/docs/en/examples/audiobook-curator.mdx
@@ -65,8 +65,8 @@ those features, not the build. This example has no hooks.
   projected commands compile into `dist/bin/audiobook-curator.mjs` for `package.json` `bin`, and
   `dist/index.js` plus declarations serve `exports`. See
   [CLI and library package entries](../guide/authoring/package-entries.mdx).
-- **`package.json` is the single version source.** The config declares no `plugin.version`; the
-  resolved version flows into project context, artifact manifests, `inspect` output, dev status,
+- **`package.json` is the single version source.** The resolved version flows into project
+  context, artifact manifests, `inspect` output, dev status,
   and the `agent-bundle/meta` constant this plugin imports.
 
 ## Every command renders its tool's document
diff --git a/website/docs/en/guide/authoring/index.mdx b/website/docs/en/guide/authoring/index.mdx
index 85317e6f2..a363fe3a0 100644
--- a/website/docs/en/guide/authoring/index.mdx
+++ b/website/docs/en/guide/authoring/index.mdx
@@ -29,11 +29,9 @@ export default defineConfig({
 | `name` | The host-native plugin slug. Never the npm package name. |
 | `description` | One sentence shown by hosts and written into generated manifests. |
 | `logo` | Project-relative path to an image copied into host artifacts that support it. |
-| `version` | **Deprecated.** The release version belongs in `package.json`. |
 
-`package.json` is authoritative for release identity. A declared `plugin.version` that disagrees
-with it reports the `AB4008` warning, and a release build with no version anywhere is refused
-outright (`AB4013`) rather than packaging the development fallback. Plugin code reads its own
+`package.json` is authoritative for release identity. A release build with no valid package
+version is refused outright (`AB4013`) rather than packaging the development fallback. Plugin code reads its own
 identity from `agent-bundle/meta` instead of a hand-maintained version module.
 
 ## Targets
diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx
index ea76b20af..2641a614d 100644
--- a/website/docs/en/guide/authoring/mcp.mdx
+++ b/website/docs/en/guide/authoring/mcp.mdx
@@ -59,41 +59,7 @@ src/mcp/curator/
 The path supplies identity: `src/mcp/curator/tools/status.tsx` is the `status` tool of the
 `curator` server. No declaration is needed for the server to exist.
 
-### Compatibility: split tool exports
-
-Existing tools can still export `config`, schemas, and a default Server Component
-separately. This is a compatibility format; use `defineTool` for new tools. Resources, prompts,
-and apps retain their existing contracts. A legacy tool looks like this:
-
-```tsx
-// src/mcp/curator/tools/status.tsx
-import React from 'react';
-import type { ToolConfig, ToolRouteProps } from 'agent-bundle';
-import { Agent, agent } from '@agent-bundle/runtime';
-import { z } from 'zod';
-
-export const config = {
-  annotations: { readOnlyHint: true },
-  description: 'Read runtime status.',
-} satisfies ToolConfig;
-export const inputSchema = z.object({ verbose: z.boolean().optional() }).strict();
-export const resultSchema = z.object({ status: z.literal('ready') }).strict();
-
-export default async function Status(
-  { input, signal }: ToolRouteProps,
-) {
-  if (signal.aborted) throw new DOMException('aborted', 'AbortError');
-  if (input.verbose) await agent();
-  const result = { status: 'ready' as const };
-  return (
-    
-      Runtime is ready.
-    
-  );
-}
-```
-
-The compiler statically reads `config`, imports schemas and implementations only into generated
+The compiler statically reads the `defineTool` options, imports schemas and implementations only into generated
 entries, installs `runAgentRequest`, and derives the real MCP server from the route graph. Each
 call renders through a warm internal Flight dispatcher and lowers the final Agent Document to
 legal MCP output. Flight is an implementation transport inside the generated runtime — never a
@@ -122,9 +88,10 @@ same way under both, `$defs` is reached through JSON-pointer `$ref`s under eithe
 2020-12-only keyword such as `unevaluatedProperties` or `dependentRequired` is ignored by a lax
 draft-07 validator rather than misapplied.
 
-Runtime schemas may be imported or built by factories. Optional literal `config.inputJsonSchema`
-provides execution-free forms and CLI flag metadata; `tools/list` and runtime validation use the
-actual schema. See [input metadata](./package-entries.mdx#share-one-schema-between-mcp-and-cli).
+Runtime schemas may be imported or built by factories. The optional literal `inputJsonSchema`
+property in `defineTool` provides execution-free forms and CLI flag metadata; `tools/list` and
+runtime validation use the actual schema. See
+[input metadata](./package-entries.mdx#share-one-schema-between-mcp-and-cli).
 A generated tool can also expose an idiomatic CLI command without a second route: a colocated
 `.cli.ts` projection module is a CLI surface projection of the same operation
 (`tool:/`), never a route. A route observes `kind: 'cli'` whenever it runs from
@@ -132,37 +99,8 @@ the generated CLI executable, whichever projection mechanism produced the comman
 generated MCP server still passes `kind: 'tool'`. See
 [Project one tool as an idiomatic command](./package-entries.mdx#project-one-tool-as-an-idiomatic-command).
 
-A route may re-export its component and schemas from another module. This is how one tool is
-placed on two generated servers when only `config` differs between the placements — an MCP App
-`tools/call`, for example, reaches the server that served the widget:
-
-```tsx
-// src/mcp/widgets/tools/status.tsx
-import type { ToolConfig } from 'agent-bundle';
-
-export const config = {
-  _meta: { ui: { resourceUri: 'ui://widgets/status.html' } },
-  annotations: { readOnlyHint: true },
-  description: 'Read runtime status.',
-} satisfies ToolConfig;
-
-export { default, inputSchema, resultSchema } from '../../curator/tools/status.tsx';
-```
-
-The route contract check (`AB4810`) checks that the required exports are declared.
-The bundler resolves re-exports, and the runtime checks the loaded component is callable.
-Both sync and async components are accepted.
-
-`ToolConfig` and `ToolRouteProps` are public types:
-
-```ts twoslash
-import type { ToolConfig } from 'agent-bundle';
-
-export const config = {
-  annotations: { readOnlyHint: true },
-  description: 'Read runtime status.',
-} satisfies ToolConfig;
-```
+`ToolConfig` describes the metadata and execution options accepted by `defineTool`;
+`ToolRouteProps` describes the component that `defineTool` produces. Both are public types.
 
 Call `await agent()` only when the route needs context. The handle exposes the invocation plus
 the `host`, `session`, `actor`, `workspace`, and `plugin` identity axes. Each axis is *observed*: a
@@ -351,11 +289,12 @@ framework's streaming progress surface on MCP, in the rendered CLI, and in the W
 // src/mcp/curator/tools/search.tsx
 import React, { Suspense } from 'react';
 import { Agent } from '@agent-bundle/runtime';
+import { defineTool } from 'agent-bundle/routes';
 
-export default async function Search(
-  { input, signal }: ToolRouteProps,
-) {
-  return (
+export default defineTool({
+  inputSchema,
+  resultSchema,
+}, async (input, { signal }) => (
     
@@ -363,8 +302,7 @@ export default async function Search(
     >
       
     
-  );
-}
+));
 ```
 
 On MCP, the projector reads the `Agent.Progress` node out of each streamed `shell` and `replace`
@@ -398,21 +336,23 @@ that sets `_meta.progressToken` on `callTool` receives them over the real protoc
 Every call renders inside one render session bounded by the runtime's default limits, and the
 wall-clock one is 60 seconds (`DEFAULT_AGENT_RENDER_LIMITS.maxElapsedMs`). A route whose
 legitimate work outlives that — awaiting a build, long-polling a daemon — declares its own budget
-in `config.render` instead of clamping its waits and asking the caller to poll:
+in `defineTool`'s `render` option instead of clamping its waits and asking the caller to poll:
 
-```ts twoslash
-import type { ToolConfig } from 'agent-bundle';
+```ts
+import { defineTool } from 'agent-bundle/routes';
 
-export const config = {
+export default defineTool({
   annotations: { readOnlyHint: true },
   description: 'Wait for a ticket to settle.',
+  inputSchema,
   render: { maxElapsedMs: 7_200_000 },
-} satisfies ToolConfig;
+  resultSchema,
+}, handler);
 ```
 
 `maxElapsedMs` is a positive integer of milliseconds up to the framework ceiling of 24 hours
 (`MAX_ROUTE_RENDER_ELAPSED_MS`, exported from `agent-bundle`); anything else is `AB4835` at build
-time. The compiler reads the value statically with the rest of `config`, so the generated MCP
+time. The compiler reads the value statically with the rest of the `defineTool` options, so the generated MCP
 server applies it to that route's render session on every `tools/call`, `resources/read`, and
 `prompts/get`; a `routes.mcpCommands` projection of the tool and a rendered `src/cli/**` command
 carry the same key on the compiled command (`config.render` on `CliRouteConfig`; a plain `.ts`
@@ -435,17 +375,19 @@ The render budget bounds how long a render may run; it does not change that an o
 `tools/call` blocks the client until the render ends. The MCP `2025-11-25` Tasks utility is the
 complementary mechanism: a client that asks for task-augmented execution gets a task handle at
 once and collects the result later, so its own request deadline stops mattering for the render. A
-tool route opts in with `config.execution.taskSupport`, the tool's `execution` block as
+tool route opts in with `defineTool`'s `execution.taskSupport`, the tool's `execution` block as
 `tools/list` advertises it:
 
-```ts twoslash
-import type { ToolConfig } from 'agent-bundle';
+```ts
+import { defineTool } from 'agent-bundle/routes';
 
-export const config = {
+export default defineTool({
   annotations: { readOnlyHint: true },
   description: 'Convert the selected audiobook.',
   execution: { taskSupport: 'optional' },
-} satisfies ToolConfig;
+  inputSchema,
+  resultSchema,
+}, handler);
 ```
 
 `taskSupport` is `'forbidden'` (the wire default when the key is absent: every call is an
@@ -560,7 +502,9 @@ shell, `_meta`, `structuredContent`, and the `[code] message` text all survive,
 carries `isError: true`:
 
 ```tsx
-export default async function Lookup({ input }: ToolRouteProps) {
+import { defineTool } from 'agent-bundle/routes';
+
+export default defineTool({ inputSchema, resultSchema }, async (input) => {
   const hit = await catalog.find(input.id);
   if (hit === undefined) {
     return (
@@ -570,7 +514,7 @@ export default async function Lookup({ input }: ToolRouteProps` and the standalone `in
 shares one replace policy. An identical copy is an `already-installed`
 no-op. A copy of the **same version whose content hash differs** is replaced automatically, so
 rebuilding without a version bump no longer needs an uninstall and `rm -rf`. A different version is
-refused with `AB7005` unless you pass `--replace` (alias `--force`), and a foreign directory — one
+refused with `AB7005` unless you pass `--replace`, and a foreign directory — one
 this plugin's installer did not place — is refused either way. Cursor and Amp copies carry an install receipt
 (`.agent-bundle-install.json`: plugin, version, host, content hash, owned files); replacement is in
 place and touches owned files only, never unowned entries such as legacy or in-place `state/`, and
diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx
index a1c10a8ab..2e4728f20 100644
--- a/website/docs/en/guide/distribution/validation.mdx
+++ b/website/docs/en/guide/distribution/validation.mdx
@@ -316,9 +316,9 @@ names or runs, or a prebuilt payload declares in `runtimeDependencies` (`defineP
 | `AB7014` | A `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages a consumer never needs installed. Use requires one of the three evidence sources above; compiled bundles that inline a package do not count. The diagnostic names any `dist` bundles that inlined an otherwise unused package. It is an error for `dependencies` and `optionalDependencies`, and a warning for `peerDependencies`; optional peers are skipped, and an `optionalDependencies` entry supersedes the same name under `dependencies`. |
 | `AB7015` | A `dependencies`, `optionalDependencies`, or `peerDependencies` entry a consumer's npm cannot resolve through a registry. Name and specifier are read with `npm-package-arg`, npm's own parser, and come out as one of three kinds: registry (a version, range, dist-tag, or `npm:` alias of one), fetched (a git, GitHub-shorthand, remote-tarball, or path source — npm 12 refuses git and remote fetches by default (`allow-git`, `allow-remote`), and a path never exists on the consumer's disk), or unparseable (a name npm rejects, a scheme it lacks such as `link:`, `portal:`, or a typo, a selector that is neither a range nor a URL-safe dist-tag, an alias of a non-registry target, or an invalid URL — the manifest read itself fails, so this is reported even on an optional peer). A fetched `optionalDependencies` entry warns because npm can continue without it, but escalates to an error when an install script needs the package: its `bin` command appears in command position, a `node_modules//` file is run, or a bare package is preloaded with `-r`/`--require`/`--import`/`--loader` (including through `NODE_OPTIONS`). A peer also present in `dependencies` or `optionalDependencies` is judged by that concrete entry. The canonical root is validated for `npm publish ./dist`, so `workspace:` and `catalog:` are always rejected; packed `bundleDependencies` and installable in-package `file:` sources are not reported. Depend on a published version, or bundle the package and declare it under `devDependencies`. |
 
-A release build also refuses a project with **no** release version at all (`AB4013`), so a
-published artifact never carries the `0.0.0-dev.` development fallback. A
-declared `plugin.version` that disagrees with `package.json` reports the `AB4008` warning.
+A release build also refuses a project whose `package.json` has **no valid release version**
+(`AB4013`), so a published artifact never carries the
+`0.0.0-dev.` development fallback.
 
 ## Next
 
diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx
index 2d492e70f..8bf00d31a 100644
--- a/website/docs/en/reference/cli.mdx
+++ b/website/docs/en/reference/cli.mdx
@@ -190,7 +190,7 @@ agent-bundle install  [--from ] [--scope ] [--mode ] \
 | `--from ` | `process.cwd()` | The composite root holding `agent-bundle.manifest.json`. Identity comes from `application` and `projections[host]`; other hosts use `documents.plugin`, while Amp copies only the directory containing `documents.entry`. `AB7001` covers a missing/invalid manifest, projection, required host file, unsafe local name, or missing Claude/Codex marketplace identity. |
 | `--scope ` | `user` | `user`, `project`, or `local`. Claude accepts all three; Amp accepts `project` and `user` (its system root); Codex and Cursor are user-scoped. Run Amp project-scope installation from the destination project root. |
 | `--mode ` | `local` | Cursor only. `local` copies the bundle into `~/.cursor/plugins/local/`; `marketplace` stages a committed local marketplace repository under `~/.cursor/agent-bundle/marketplaces/` and prints the Customize → Plugins → "Add Plugins from Local Repository" step that makes Cursor manage the plugin as a marketplace install. |
-| `--replace` (alias `--force`) | off | Replace an existing agent-bundle install of this plugin even when its version differs. Without it, an identical copy is an `already-installed` no-op, a same-version copy whose content hash differs is replaced automatically, and a different version is `AB7005`. Foreign directories are always refused (`AB7005`). |
+| `--replace` | off | Replace an existing agent-bundle install of this plugin even when its version differs. Without it, an identical copy is an `already-installed` no-op, a same-version copy whose content hash differs is replaced automatically, and a different version is `AB7005`. Foreign directories are always refused (`AB7005`). |
 
 The emitted standalone `install.mjs` accepts the same `--replace`. Cursor copies carry an install
 receipt (`.agent-bundle-install.json`), replacement
diff --git a/website/docs/en/reference/configuration.mdx b/website/docs/en/reference/configuration.mdx
index 1acb69aac..c3c6fe524 100644
--- a/website/docs/en/reference/configuration.mdx
+++ b/website/docs/en/reference/configuration.mdx
@@ -1,5 +1,5 @@
 ---
-description: 'Field reference for agent-bundle.config.ts: every top-level key, its accepted shapes, defaults, and validation rules, including the web exposure key (AB4341) and the deprecated plugin.version.'
+description: 'Field reference for agent-bundle.config.ts: every top-level key, its accepted shapes, defaults, and validation rules, including the web exposure key (AB4341).'
 ---
 
 # Configuration
@@ -84,7 +84,6 @@ also compiled for that operation. See
 | `description` | One sentence written into generated manifests. |
 | `logo` | Project-relative image path. Missing, non-file, or outside the project reports `AB4012`; missing from the built artifact or escaping the deploy tree reports `AB6025`. |
 | `metadata` | The shared descriptive layer: `author`, `homepage`, `keywords`, `license`, `repository`. Each field defaults to the same field in `package.json`; a value here overrides it, and `null` shares nothing. A malformed value reports `AB4014`. |
-| `version` | **Deprecated.** |
 
 ### Shared descriptive metadata
 
@@ -124,11 +123,10 @@ Claude Code's `.claude-plugin/plugin.json` carries no descriptive fields — its
 admits none — and its `author.name` stays the generated plugin name. The shared values reach
 Claude Code through the marketplace entry.
 
-`package.json` is authoritative for release identity. Declare the release version only there:
-a `plugin.version` that disagrees with `package.json` reports the `AB4008` warning, and a release
-build with no version anywhere is refused with `AB4013` rather than shipping the
-`0.0.0-dev.` development fallback. The field remains only for compatibility and
-will be removed under the normal breaking-change policy.
+`package.json` is authoritative for release identity. Declare the release version there. A
+release build with no valid `package.json` version is refused with `AB4013` rather than shipping
+the `0.0.0-dev.` development fallback. The retired `plugin.version` key is
+rejected with `AB4001` so an un-typechecked config cannot silently ignore it.
 
 ## targets
 
diff --git a/website/docs/en/reference/targets-artifacts.mdx b/website/docs/en/reference/targets-artifacts.mdx
index d289b09e7..7fa619f56 100644
--- a/website/docs/en/reference/targets-artifacts.mdx
+++ b/website/docs/en/reference/targets-artifacts.mdx
@@ -141,7 +141,7 @@ proof. npm installation does not automatically register a plugin with a host.
 
 ## agent-bundle.manifest.json
 
-The current closed artifact contract is **`manifestVersion: 5`**. It is the index of the built
+The current closed artifact contract is **`manifestVersion: 6`**. It is the index of the built
 application, not another file the plugin author maintains.
 
 | Section | Responsibility |
diff --git a/website/docs/zh/examples/audiobook-curator.mdx b/website/docs/zh/examples/audiobook-curator.mdx
index 75f21202d..4ecb67ea6 100644
--- a/website/docs/zh/examples/audiobook-curator.mdx
+++ b/website/docs/zh/examples/audiobook-curator.mdx
@@ -54,7 +54,7 @@ description: '有声书策展器示例:一个由路由模块、请求上下文
 - **包构建归框架所有。** 配置没有声明 `bin`,也没有声明 `scripts`。投影出的命令编译为
   `dist/bin/audiobook-curator.mjs` 以服务 `package.json` 的 `bin`,而 `dist/index.js` 加声明文件服务
   `exports`。见 [CLI 与库包入口](../guide/authoring/package-entries.mdx)。
-- **`package.json` 是唯一的版本来源。** 配置没有声明 `plugin.version`;解析出的版本会流入项目上下文、
+- **`package.json` 是唯一的版本来源。** 解析出的版本会流入项目上下文、
   产物清单、`inspect` 输出、dev 状态,以及这个插件导入的 `agent-bundle/meta` 常量。
 
 ## 每条命令都渲染其工具的文档
diff --git a/website/docs/zh/guide/authoring/index.mdx b/website/docs/zh/guide/authoring/index.mdx
index fe866febb..7e04a6e92 100644
--- a/website/docs/zh/guide/authoring/index.mdx
+++ b/website/docs/zh/guide/authoring/index.mdx
@@ -28,10 +28,9 @@ export default defineConfig({
 | `name` | 宿主原生的插件 slug。绝不是 npm 包名。 |
 | `description` | 一句话说明,宿主会展示它,它也会写入生成的清单。 |
 | `logo` | 相对项目根目录的图片路径,会复制到支持该字段的宿主产物中。 |
-| `version` | **已废弃。** 发布版本号属于 `package.json`。 |
 
-`package.json` 对发布标识具有权威性。声明的 `plugin.version` 与之不一致时会报告 `AB4008` 警告;
-而任何地方都没有版本号的发布构建会被直接拒绝(`AB4013`),而不是打包开发期回退值。插件代码通过
+`package.json` 对发布标识具有权威性。没有有效包版本号的发布构建会被直接拒绝(`AB4013`),
+而不是打包开发期回退值。插件代码通过
 `agent-bundle/meta` 读取自身标识,而不是手工维护一个版本模块。
 
 ## Targets
diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx
index eeb7323d0..a4795b201 100644
--- a/website/docs/zh/guide/authoring/mcp.mdx
+++ b/website/docs/zh/guide/authoring/mcp.mdx
@@ -54,40 +54,7 @@ src/mcp/curator/
 路径提供身份:`src/mcp/curator/tools/status.tsx` 就是 `curator` 服务器的 `status` 工具。服务器的存在
 不需要任何声明。
 
-### 兼容格式:拆分工具导出
-
-旧工具仍可分别导出 `config`、schema 和 默认 Server Component。这是兼容格式;
-新工具请使用 `defineTool`。资源、提示和应用继续使用各自现有的契约。旧格式示例:
-
-```tsx
-// src/mcp/curator/tools/status.tsx
-import React from 'react';
-import type { ToolConfig, ToolRouteProps } from 'agent-bundle';
-import { Agent, agent } from '@agent-bundle/runtime';
-import { z } from 'zod';
-
-export const config = {
-  annotations: { readOnlyHint: true },
-  description: 'Read runtime status.',
-} satisfies ToolConfig;
-export const inputSchema = z.object({ verbose: z.boolean().optional() }).strict();
-export const resultSchema = z.object({ status: z.literal('ready') }).strict();
-
-export default async function Status(
-  { input, signal }: ToolRouteProps,
-) {
-  if (signal.aborted) throw new DOMException('aborted', 'AbortError');
-  if (input.verbose) await agent();
-  const result = { status: 'ready' as const };
-  return (
-    
-      Runtime is ready.
-    
-  );
-}
-```
-
-编译器静态读取 `config`,只把 schema 与实现导入生成的入口,装配 `runAgentRequest`,并从路由图推导出
+编译器静态读取 `defineTool` 选项,只把 schema 与实现导入生成的入口,装配 `runAgentRequest`,并从路由图推导出
 真实的 MCP 服务器。每次调用都经由一个常驻的内部 Flight 分发器渲染,并把最终的 Agent Document 降级为
 合法的 MCP 输出。Flight 是生成运行时内部的实现传输层——绝不是面向宿主的公开线上协议,原始 Flight
 字节也永远不会跨越 MCP 线路。
@@ -111,39 +78,12 @@ draft-07 校验器都接受每一个合法取值。zod 元组(`z.tuple([...])`
 `dependentRequired` 这样仅属于 2020-12 的关键字会被宽松模式的 draft-07 校验器忽略,而不会被错误地
 套用。
 
-运行时 schema 可以导入或由工厂构建。可选的字面量 `config.inputJsonSchema` 提供无需执行模块的表单和
-CLI flag 元数据;`tools/list` 和运行时校验仍使用真实 schema。参见
+运行时 schema 可以导入或由工厂构建。`defineTool` 中可选的字面量 `inputJsonSchema` 属性提供无需执行
+模块的表单和 CLI flag 元数据;`tools/list` 和运行时校验仍使用真实 schema。参见
 [输入元数据](./package-entries.mdx#在-mcp-与-cli-之间共享同一份-schema)。
 
-路由可以从另一个模块重新导出自己的组件与 schema。当同一个工具需要放在两个生成的服务器上、而两处
-只有 `config` 不同时,就用这种写法——例如 MCP App 的 `tools/call` 会到达提供该 widget 的那台服务器:
-
-```tsx
-// src/mcp/widgets/tools/status.tsx
-import type { ToolConfig } from 'agent-bundle';
-
-export const config = {
-  _meta: { ui: { resourceUri: 'ui://widgets/status.html' } },
-  annotations: { readOnlyHint: true },
-  description: 'Read runtime status.',
-} satisfies ToolConfig;
-
-export { default, inputSchema, resultSchema } from '../../curator/tools/status.tsx';
-```
-
-路由契约检查(`AB4810`)确认必要的导出已声明。打包器负责解析重新导出,
-运行时检查加载的组件是否可调用。同步和异步组件都受支持。
-
-`ToolConfig` 与 `ToolRouteProps` 都是公开类型:
-
-```ts twoslash
-import type { ToolConfig } from 'agent-bundle';
-
-export const config = {
-  annotations: { readOnlyHint: true },
-  description: 'Read runtime status.',
-} satisfies ToolConfig;
-```
+`ToolConfig` 描述 `defineTool` 接受的元数据与执行选项;`ToolRouteProps` 描述 `defineTool` 生成的组件。
+两者都是公开类型。
 
 只有在路由确实需要上下文时才调用 `await agent()`。该句柄暴露本次调用,以及 `host`、`session`、
 `actor`、`workspace`、`plugin` 五个身份轴。每个轴都是*被观察到的*:传输层知道时会发布一个 `available` 取值及
@@ -311,11 +251,12 @@ boundary 替换掉对应的回退。回退内容应使用 `Agent.Progress`——
 // src/mcp/curator/tools/search.tsx
 import React, { Suspense } from 'react';
 import { Agent } from '@agent-bundle/runtime';
+import { defineTool } from 'agent-bundle/routes';
 
-export default async function Search(
-  { input, signal }: ToolRouteProps,
-) {
-  return (
+export default defineTool({
+  inputSchema,
+  resultSchema,
+}, async (input, { signal }) => (
     
@@ -323,8 +264,7 @@ export default async function Search(
     >
       
     
-  );
-}
+));
 ```
 
 在 MCP 上,投影器会从每个流式传输的 `shell` 与 `replace` 文档中读取 `Agent.Progress` 节点,并向请求携带了
@@ -352,20 +292,23 @@ route-unit 与 `mcp-in-memory` 两个测试层级无需宿主即可证明这一
 
 每次调用都在一个受运行时默认上限约束的渲染会话中进行,其中挂钟上限是 60 秒
 (`DEFAULT_AGENT_RENDER_LIMITS.maxElapsedMs`)。一条合理工作时间超过这一上限的路由——等待一次构建、
-对守护进程长轮询——应在 `config.render` 中声明自己的预算,而不是截短自己的等待再让调用方反复轮询:
+对守护进程长轮询——应在 `defineTool` 的 `render` 选项中声明自己的预算,而不是截短自己的等待再让
+调用方反复轮询:
 
-```ts twoslash
-import type { ToolConfig } from 'agent-bundle';
+```ts
+import { defineTool } from 'agent-bundle/routes';
 
-export const config = {
+export default defineTool({
   annotations: { readOnlyHint: true },
   description: 'Wait for a ticket to settle.',
+  inputSchema,
   render: { maxElapsedMs: 7_200_000 },
-} satisfies ToolConfig;
+  resultSchema,
+}, handler);
 ```
 
 `maxElapsedMs` 是一个以毫秒计的正整数,上限为框架天花板 24 小时(从 `agent-bundle` 导出的
-`MAX_ROUTE_RENDER_ELAPSED_MS`);其他任何值都会在构建时报 `AB4835`。编译器随 `config` 的其余部分静态读取
+`MAX_ROUTE_RENDER_ELAPSED_MS`);其他任何值都会在构建时报 `AB4835`。编译器随 `defineTool` 的其余选项静态读取
 该值,因此生成式 MCP 服务器会在该路由的每次 `tools/call`、`resources/read` 与 `prompts/get` 上把它应用到
 渲染会话;该工具的 `routes.mcpCommands` 投影以及渲染式 `src/cli/**` 命令在编译后的命令上携带同一个键
 (`CliRouteConfig` 上的 `config.render`;纯 `.ts` 命令没有渲染会话,会拒绝它)。`renderRoute` 与
@@ -383,17 +326,19 @@ Claude Code 默认的单次调用挂钟约为 28 小时(`MCP_TOOL_TIMEOUT`,
 
 渲染预算约束的是一次渲染最多能跑多久;它并不改变普通 `tools/call` 会一直阻塞客户端直到渲染结束这一点。
 MCP `2025-11-25` 的 Tasks 工具是与之互补的机制:请求任务增强执行的客户端会立刻拿到一个任务句柄,稍后再
-取结果,于是它自身的请求期限不再影响渲染。工具路由通过 `config.execution.taskSupport` 选择加入,这正是
+取结果,于是它自身的请求期限不再影响渲染。工具路由通过 `defineTool` 的 `execution.taskSupport` 选择加入,这正是
 `tools/list` 宣告的工具 `execution` 块:
 
-```ts twoslash
-import type { ToolConfig } from 'agent-bundle';
+```ts
+import { defineTool } from 'agent-bundle/routes';
 
-export const config = {
+export default defineTool({
   annotations: { readOnlyHint: true },
   description: 'Convert the selected audiobook.',
   execution: { taskSupport: 'optional' },
-} satisfies ToolConfig;
+  inputSchema,
+  resultSchema,
+}, handler);
 ```
 
 `taskSupport` 取 `'forbidden'`(省略该键时的线上默认值:每次调用都是普通请求)、`'optional'`(客户端
@@ -493,7 +438,9 @@ export default function Layout({ children, route }: AgentLayoutProps) {
 `[code] message` 文本都得以保留,线上结果携带 `isError: true`:
 
 ```tsx
-export default async function Lookup({ input }: ToolRouteProps) {
+import { defineTool } from 'agent-bundle/routes';
+
+export default defineTool({ inputSchema, resultSchema }, async (input) => {
   const hit = await catalog.find(input.id);
   if (hit === undefined) {
     return (
@@ -503,7 +450,7 @@ export default async function Lookup({ input }: ToolRouteProps` 与独立的 `install.mjs`——共用同一套替换策略。
 内容完全相同的副本是 `already-installed` 空操作。**版本相同但内容哈希不同**的副本会被自动替换,因此不升版本
-地重建不再需要卸载加 `rm -rf`。版本不同则以 `AB7005` 拒绝,除非传入 `--replace`(别名 `--force`);外来目录
+地重建不再需要卸载加 `rm -rf`。版本不同则以 `AB7005` 拒绝,除非传入 `--replace`;外来目录
 ——不是本插件安装器放置的——无论如何都会被拒绝。Cursor 与 Amp 副本携带安装回执(`.agent-bundle-install.json`:
 插件、版本、宿主、内容哈希、归属文件);替换就地进行,只触碰归属文件,绝不动旧版或就地的 `state/` 之类的非归属条目,
 `--replace` 会接管回执出现之前的副本。本发行版构建的产物把框架状态放在
diff --git a/website/docs/zh/guide/distribution/validation.mdx b/website/docs/zh/guide/distribution/validation.mdx
index 655fdf0ee..00a4bfa69 100644
--- a/website/docs/zh/guide/distribution/validation.mdx
+++ b/website/docs/zh/guide/distribution/validation.mdx
@@ -272,9 +272,8 @@ CLI、MCP 入口、钩子包装层以及包构建的 JavaScript bundle——都
 | `AB7014` | `dependencies`、`optionalDependencies` 或 `peerDependencies` 字段点名了消费者无需安装的包。必须有上列三类证据之一才算使用;已编译 bundle 内联该包不算。诊断会点名内联了这个未使用包的所有 `dist` bundle。对 `dependencies` 与 `optionalDependencies` 是 error,对 `peerDependencies` 是 warning;可选 peer 会跳过,`optionalDependencies` 中的同名项优先于 `dependencies`。 |
 | `AB7015` | `dependencies`、`optionalDependencies` 或 `peerDependencies` 中的某一项无法被消费者的 npm 经由注册表解析。包名与说明符一并交给 npm 自己的解析器 `npm-package-arg` 读取,结果分为三类:注册表类(版本、范围、dist-tag,或指向它们的 `npm:` 别名)、抓取类(git、GitHub 简写、远程 tarball 或路径来源——npm 12 默认拒绝 git 与远程抓取(`allow-git`、`allow-remote`),而路径在消费者磁盘上并不存在),以及不可解析类(npm 不接受的包名、它不支持的协议如 `link:`、`portal:` 或拼写错误、既非范围也非 URL 安全 dist-tag 的选择器、指向非注册表目标的别名,或非法 URL——清单本身就读不下去,因此即使出现在可选 peer 上也会被报告)。抓取类的 `optionalDependencies` 项只发出警告,因为 npm 可以在没有它时继续;但消费者安装脚本需要该包时会升级为错误:其 `bin` 命令位于命令位置、运行了 `node_modules//` 文件,或通过 `-r`/`--require`/`--import`/`--loader`(包括 `NODE_OPTIONS`)预加载了裸包。若 `dependencies` 或 `optionalDependencies` 也声明了同名 peer,则只按该具体条目判断。规范根目录按 `npm publish ./dist` 校验,因此 `workspace:` 与 `catalog:` 一律拒绝;已打包的 `bundleDependencies` 与包内可安装的 `file:` 来源不会被报告。请依赖已发布的版本,或把该包打包进产物并声明在 `devDependencies` 下。 |
 
-发布构建同样会拒绝**完全没有**发布版本的项目(`AB4013`),因此已发布的产物绝不会携带
-`0.0.0-dev.` 这个开发期回退值。声明的 `plugin.version` 与 `package.json` 不一致时会
-报告 `AB4008` 警告。
+发布构建同样会拒绝 `package.json` 中**没有有效发布版本**的项目(`AB4013`),因此已发布的产物绝不会携带
+`0.0.0-dev.` 这个开发期回退值。
 
 ## 下一步
 
diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx
index b6327b821..9b3cf49d4 100644
--- a/website/docs/zh/reference/cli.mdx
+++ b/website/docs/zh/reference/cli.mdx
@@ -180,7 +180,7 @@ agent-bundle install  [--from ] [--scope ] [--mode ] \
 | `--from ` | `process.cwd()` | 存放 `agent-bundle.manifest.json` 的组合根。身份来自 `application` 与 `projections[host]`;其他宿主使用 `documents.plugin`,Amp 只复制 `documents.entry` 所在目录。缺少清单、投影、所需宿主文件、不安全本地名称或 Claude/Codex 市场身份时为 `AB7001`。 |
 | `--scope ` | `user` | `user`、`project` 或 `local`。Claude 接受全部三种;Amp 接受 `project` 与 `user`(系统根;项目安装应从目标项目根运行);Codex 与 Cursor 是 user 作用域。 |
 | `--mode ` | `local` | 仅限 Cursor。`local` 把捆绑包复制到 `~/.cursor/plugins/local/`;`marketplace` 在 `~/.cursor/agent-bundle/marketplaces/` 下暂存一个已提交的本地市场仓库,并打印 Customize → Plugins →“Add Plugins from Local Repository”这一步,让 Cursor 把该插件当作市场安装来管理。 |
-| `--replace`(别名 `--force`) | 关闭 | 即使版本不同,也替换该插件已有的 agent-bundle 安装。不带它时,内容完全相同的副本是 `already-installed` 空操作,版本相同但内容哈希不同的副本会被自动替换,版本不同则为 `AB7005`。外来目录总是被拒绝(`AB7005`)。 |
+| `--replace` | 关闭 | 即使版本不同,也替换该插件已有的 agent-bundle 安装。不带它时,内容完全相同的副本是 `already-installed` 空操作,版本相同但内容哈希不同的副本会被自动替换,版本不同则为 `AB7005`。外来目录总是被拒绝(`AB7005`)。 |
 
 输出的独立 `install.mjs` 接受同样的 `--replace`。Cursor 副本携带安装回执
 (`.agent-bundle-install.json`),替换只触碰归属文件,`--replace` 会接管回执出现之前的副本;Claude 的替换
diff --git a/website/docs/zh/reference/configuration.mdx b/website/docs/zh/reference/configuration.mdx
index 1c73f0ba4..72695c0a5 100644
--- a/website/docs/zh/reference/configuration.mdx
+++ b/website/docs/zh/reference/configuration.mdx
@@ -1,5 +1,5 @@
 ---
-description: 'agent-bundle.config.ts 字段参考:每个顶层键、可接受的形态、默认值与校验规则,包括 web 暴露键(AB4341)与已废弃的 plugin.version 字段。'
+description: 'agent-bundle.config.ts 字段参考:每个顶层键、可接受的形态、默认值与校验规则,包括 web 暴露键(AB4341)。'
 ---
 
 # 配置
@@ -78,7 +78,6 @@ export default defineConfig({
 | `description` | 一句话,会写进生成的清单中。 |
 | `logo` | 项目相对的图片路径。缺失、不是文件或位于项目之外报告 `AB4012`;在已构建产物中缺失或逃逸出部署树报告 `AB6025`。 |
 | `metadata` | 共享描述层:`author`、`homepage`、`keywords`、`license`、`repository`。每个字段默认取 `package.json` 中的同名字段;此处的值会覆盖它,而 `null` 表示不共享。值格式错误报告 `AB4014`。 |
-| `version` | **已废弃。** |
 
 ### 共享描述元数据
 
@@ -112,9 +111,9 @@ export default defineConfig({
 Claude Code 的 `.claude-plugin/plugin.json` 不携带描述字段——其固定 schema 不允许——且其
 `author.name` 仍是生成的插件名。共享值经由 marketplace 条目到达 Claude Code。
 
-`package.json` 对发布标识具有权威性。发布版本只在那里声明:与 `package.json` 不一致的 `plugin.version`
-会报告 `AB4008` 警告,而任何地方都没有版本的发布构建会被 `AB4013` 拒绝,而不是交付
-`0.0.0-dev.` 这个开发期回退值。该字段仅为兼容而保留,并将按照正常的破坏性变更策略移除。
+`package.json` 对发布标识具有权威性。发布版本只在那里声明。若 `package.json` 没有有效版本,
+发布构建会被 `AB4013` 拒绝,而不是交付 `0.0.0-dev.` 这个开发期回退值。已退役的
+`plugin.version` 键会被 `AB4001` 拒绝,避免未经过类型检查的配置悄悄忽略它。
 
 ## targets
 
diff --git a/website/docs/zh/reference/targets-artifacts.mdx b/website/docs/zh/reference/targets-artifacts.mdx
index 071468ffb..eca9aab48 100644
--- a/website/docs/zh/reference/targets-artifacts.mdx
+++ b/website/docs/zh/reference/targets-artifacts.mdx
@@ -136,7 +136,7 @@ npm 安装不会自动将插件注册到宿主。
 
 ## agent-bundle.manifest.json
 
-当前封闭产物契约为 **`manifestVersion: 5`**。
+当前封闭产物契约为 **`manifestVersion: 6`**。
 它是构建应用的索引,不是另一份需要插件作者维护的文件。
 
 | 部分 | 职责 |