Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/468-plugin-root-request-axis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@agent-bundle/runtime': patch
'agent-bundle': patch
---

Expose the resolved plugin root on the request context: `(await agent()).plugin` is an observed `{ root, stateRoot }` — `source: 'native'` from an expanded `AGENT_BUNDLE_PLUGIN_ROOT`, `'derived'` from the shell's fallback (the artifact root, or `$PWD/.agent-bundle` for the npm bin) — and conventional providers receive the same value as `plugin` beside `invocation` and `signal` (`AgentProviderContext.plugin`). Every generated shell (MCP entry and Flight worker, routed CLI executable and render worker, hook wrappers) now resolves the anchor once through the new `resolvePluginRoot` export of `@agent-bundle/runtime` and mounts its SQLite state, notice ledger, and lineage journal on that one `stateRoot`, so `plugin.stateRoot` is the directory they use by construction; an unexpanded `${…}` token is treated as unset and reported once on stderr instead of being joined into a path. `renderRoute`, `invokeCli`, `runScript`, and `openInMemoryMcpServer` publish the axis the same way and accept `context.plugin`; `createGeneratedRouteMcpServer` takes `pluginRoot`. `AGENT_REQUEST_STORE_VERSION` is 4. Fixes #468. (#532)
2 changes: 1 addition & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -776,7 +776,7 @@ schema constants), unions, nested objects, transforms, coercions — raises
| `AB4833` | error | `notices.retention` is malformed: `notices` or `retention` is not an object, carries an unknown key, `terminalTtl` is not a positive integer of milliseconds or a duration such as `"7d"`, `"12h"`, `"30m"`, or `"90s"`, `maxTerminal` / `maxJournalBytes` is not a positive integer — or the policy is declared by a project without a conventional `src/state.ts`, which has no co-mounted notice ledger to retain. Omit a field to keep the runtime default (`7d`, `500`, `16777216`). |
| `AB4834` | warning | `agent-bundle validate` published `.agent-bundle/routes.d.ts` (the project compiles routes or providers) but the root `tsconfig.json` program — resolved like `tsc -p`, including `extends` and one level of project `references` — does not compile it, so `renderRoute` / `renderRouteEvents` type-check route ids as `string` and `input` / `result` as `unknown`. Reported on `tsconfig.json`; never for a project without one. | Add `".agent-bundle/routes.d.ts"` to `tsconfig.json` `include` (not `files`: an `include` entry is inert until the first build publishes the file, while a missing `files` entry is a `tsc` error); `build`, `dev`, and `validate` keep the file current and it stays gitignored. |
| `AB4835` | error | A route's static `config.render` (the render budget of one call, #454) is malformed: `render` is not an object, carries a key other than `maxElapsedMs`, `maxElapsedMs` is not a positive integer of milliseconds, or it exceeds the framework ceiling of `86400000` (24 hours) — or a plain `.ts` CLI command declares one, although it executes without a render session. Reported once per route: on an MCP tool, resource, or prompt route with its server (the tool's projected CLI command inherits the value), or on a `src/cli/**` command route; a route with a rejected budget compiles no command. Omit `render` to keep the runtime default (`60000`). Declare `config.render = { maxElapsedMs: <positive integer ≤ 86400000> }` on a rendered route, or remove it. The budget bounds the framework's render session only: Codex's `tool_timeout_sec` (60 s by default) and any per-server host timeout must be raised by the operator separately, while Claude Code's default per-call wall clock is about 28 hours and its idle timer is kept alive by the `notifications/progress` the projector forwards. |
| `AB4940` | error | A conventional provider module has no default export or its default export is not a function. Default-export a factory receiving `{ invocation, signal }`. |
| `AB4940` | error | A conventional provider module has no default export or its default export is not a function. Default-export a factory receiving `{ invocation, plugin, signal }`. |
| `AB4941` | error | Two provider filenames derive the same camel-cased provider key. Rename one file so every provider key is unique. |
| `AB4942` | error | A provider filename derives the reserved `processLifetime` key. Rename the file so its camel-cased key does not collide with the framework-owned provider. |

Expand Down
20 changes: 15 additions & 5 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ entries carry `provenance.kind: 'conventional'` in the normalized model.
| `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/<plugin-name>.mjs` in every selected host artifact whose target publishes the `cli` capability (all built-in targets). Nesting is identity: `src/cli/library/audit.ts` runs as `<bin> 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 `_` |
| `src/events/<family>/<event>.{ts,tsx}`, `src/events/stop.{ts,tsx}` | Semantic event route: the path is the canonical event family (`src/events/tool/after.tsx` is `tool/after`; `stop` is the one top-level family) and must be one of the admitted `canonicalAgentEvents`. The optional static `config` (`AgentEventRouteConfig`: `targets`, `tools`, `runtime: 'shared' \| 'standalone'`, `fallback`, `delivery`, `timeoutMs`) restricts hosts and selects the execution mode; the async default Server Component receives `AgentEventRouteProps` (`{ canonical, native, signal }`) and returns `Agent.*` output that the selected host adapter encodes into its native hook envelope. Application code never branches on host JSON or emits native hook documents; per-host support is a capability state (`supported`/`degraded`/`unavailable`/`prohibited`) surfaced by `inspect` and enforced at build time (`AB4817`, `AB4823`–`AB4825`). | Restrict `config.targets`, or prefix a path segment with `_` |
| `src/state.ts` | Project state definition: default-exports `defineState({ ... })`; generated MCP, routed-CLI, and rendered-script request scopes mount `(await agent()).state` and `.notices`. | `state: false`, or rename the file to `_state.ts` |
| `src/providers/<name>.{ts,tsx}` | Request context provider: default-exports a factory receiving `{ invocation, signal }`; its value is mounted at `(await agent()).providers.<camelCaseName>` for generated MCP and event routes, projected MCP commands, plain and rendered routed CLI commands, and rendered scripts. | Prefix the file with `_` |
| `src/providers/<name>.{ts,tsx}` | Request context provider: default-exports a factory receiving `{ invocation, plugin, signal }`; its value is mounted at `(await agent()).providers.<camelCaseName>` for generated MCP and event routes, projected MCP commands, plain and rendered routed CLI commands, and rendered scripts. | Prefix the file with `_` |
| `src/layout.{ts,tsx}` | Shared document layout: default-exports one component receiving `{ children, route, signal }` that renders `Agent.Result` around every rendered route — generated MCP tools, resources, and prompts, rendered routed CLI commands, projected MCP commands, and rendered scripts. Event routes are never wrapped. | Rename to `_layout.tsx` |
| `src/mcp/<server>/layout.{ts,tsx}` | Per-server layout nested inside the root layout for that generated server's routes. | Rename to `_layout.tsx`, or set `routes.servers.<server>` to a non-generated mode |

Expand Down Expand Up @@ -156,7 +156,16 @@ directory. The npm package's routed CLI bin and rendered scripts use
`$AGENT_BUNDLE_PLUGIN_ROOT/state` when present and otherwise
`$PWD/.agent-bundle/state`; the artifact-hosted routed CLI bin
(`<target>/bin/<name>.mjs`) derives the artifact root from the parent of its
own `bin/` directory instead, like the MCP worker. Notice authorization is deliberately permissive
own `bin/` directory instead, like the MCP worker. Each generated process
resolves that anchor exactly once (`resolvePluginRoot` from
`@agent-bundle/runtime`, #468): the state kernel, the notice ledger, the
lineage journal, and every request scope the process opens read the same
value, published as `(await agent()).plugin` — `{ root, stateRoot }` with
`source: 'native'` from `AGENT_BUNDLE_PLUGIN_ROOT` or `'derived'` from the
fallback — and handed to conventional providers as `plugin` beside
`invocation` and `signal`. An anchor still carrying an unexpanded `${…}`
token is treated as unset (reported once on stderr), never joined into a
path. Notice authorization is deliberately permissive
in generated mounting v1 (`authorized`); recipient/principal matching remains
enforced by the ledger — every generated scope mounts the request's `lineage`
on the notice principal, so `recipient.conversation` / `recipient.root` are
Expand Down Expand Up @@ -252,9 +261,10 @@ an otherwise valid migration.
Each direct child of `src/providers/` derives its key by camel-casing the file
stem: for example, `src/providers/project-auth.ts` mounts at
`(await agent()).providers.projectAuth`. Every module default-exports a factory
with the contract `(context: { invocation, signal }) => value |
Promise<value>`, where `invocation` is the current route invocation and
`signal` is its request abort signal.
with the contract `(context: { invocation, plugin, signal }) => value |
Promise<value>`, where `invocation` is the current route invocation, `plugin`
is the observed plugin root the request will publish as
`(await agent()).plugin` (#468), and `signal` is its request abort signal.

Every generated request scope — the shared Flight worker behind generated MCP
and event routes, the react-server worker behind rendered routed CLI commands
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Agent, agent, type JsonValue } from '@agent-bundle/runtime';
import { z } from 'zod';

export const config = {
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({
plugin: z.unknown(),
}).strict();

/**
* The #468 probe: `(await agent()).plugin` as the route sees it, so every proof
* 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() {
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 } }
: { reason: plugin.reason, state: plugin.state };
return (
<Agent.Result value={{ plugin: observed }}>
<Agent.Text>{plugin.state === 'available' ? `plugin root: ${plugin.value.root}` : `plugin root unavailable: ${plugin.reason}`}</Agent.Text>
</Agent.Result>
);
}
15 changes: 10 additions & 5 deletions packages/agent-bundle/src/adapters/hook-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -651,12 +651,13 @@ const eventRouteHookWrapperSource = (
"import { dirname, resolve } from 'node:path';",
...(standalone ? ["import { Worker } from 'node:worker_threads';"] : []),
...(standalone
? ["import { agent, available, createAgentRenderDispatcher, resolveStandaloneLineage, runAgentRequest, unavailable } from '@agent-bundle/runtime';"]
? [
"import { fileURLToPath } from 'node:url';",
"import { agent, available, createAgentRenderDispatcher, resolvePluginRoot, resolveStandaloneLineage, runAgentRequest, unavailable } from '@agent-bundle/runtime';",
]
: []),
...(retiresLineage
? [
"import { join } from 'node:path';",
"import { fileURLToPath } from 'node:url';",
"import { agentLineageStateDefinition, createAgentLineageRegistry } from '@agent-bundle/runtime/lineage';",
"import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite';",
]
Expand All @@ -679,6 +680,9 @@ const eventRouteHookWrapperSource = (
'const fail = (message) => { throw new Error(`Agent Bundle event route error: ${message}`); };',
...(standalone
? [
// The wrapper lives in `hooks/`, so its artifact root is the parent
// directory — the same anchor the generated MCP entry resolves (#468).
"const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL('..', import.meta.url)) });",
'const renderStandalone = async (invocation, signal) => {',
' const worker = new Worker(new URL(/* webpackIgnore: true */ "./hooks-flight.mjs", import.meta.url), { stderr: true, stdout: true });',
" worker.stdout?.on('data', (chunk) => process.stderr.write(chunk));",
Expand Down Expand Up @@ -714,6 +718,7 @@ const eventRouteHookWrapperSource = (
' id,',
' invocation: dispatch.invocation,',
' lineage: context.lineage,',
' plugin: context.plugin,',
' requestInvocation: context.invocation,',
' session: context.session,',
' terminal: context.terminal,',
Expand All @@ -733,8 +738,7 @@ const eventRouteHookWrapperSource = (
? [
'const retireLineage = async (native, idempotencyKey, observedAt) => {',
" if (target !== 'claude' && target !== 'codex' && target !== 'cursor') return;",
" const anchor = process.env.AGENT_BUNDLE_PLUGIN_ROOT ?? fileURLToPath(new URL('..', import.meta.url));",
" const driver = createSqliteStateDriver({ root: join(anchor, 'state') });",
' const driver = createSqliteStateDriver({ root: pluginRoot.stateRoot });',
' try {',
' const store = await driver.open(agentLineageStateDefinition());',
' try {',
Expand Down Expand Up @@ -762,6 +766,7 @@ const eventRouteHookWrapperSource = (
' host: available({ name: target }, "native"),',
' invocation: { artifactEpoch, hostContractRevision: capabilityRevision, kind: "event", operationId: `event:${canonicalEvent}`, surface: canonicalEvent },',
' lineage,',
' plugin: pluginRoot.identity,',
' ...(sessionId === undefined ? {} : { session: available({ sessionId }, "native") }),',
' signal,',
// A hook's stdout is its host envelope: no terminal, never probed (#511).
Expand Down
Loading
Loading