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/637-state-root.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"agent-bundle": minor
"@agent-bundle/runtime": minor
---

Separate the generated plugin's state root from its code root: make every artifact-hosted shell (the stdio MCP entry, Flight worker, artifact CLI bin and render worker, rendered script workers, and standalone hook wrappers) keep the SQLite state kernel, notice ledger, and lineage journal under `~/.agent-bundle/state/<plugin>-<digest>` (`$XDG_STATE_HOME/agent-bundle/<plugin>-<digest>` when `XDG_STATE_HOME` is an absolute path) instead of `<plugin root>/state`, so a read-only install launches and two installs never share state; keep `AGENT_BUNDLE_PLUGIN_ROOT` naming the installed code root and let `AGENT_BUNDLE_STATE_ROOT` override the state root; add `stateAnchor` and `home` options and `stateSource` to `resolvePluginRoot`, and export `PLUGIN_STATE_ROOT_ENV_ANCHOR`, `pluginStateSegment`, `userStateHome`, and `userDataStateRoot` from `@agent-bundle/runtime` and `pluginStateRootEnvAnchor` from `agent-bundle`. State existing installs wrote beneath the plugin root is not migrated: after upgrading and rebuilding, an installed plugin starts from an empty state root; set `AGENT_BUNDLE_STATE_ROOT=<old root>/state` to keep using it. `agent-bundle dev` and Workbench MCP sessions pin `AGENT_BUNDLE_STATE_ROOT` to `<epoch>/state`, so their state still lives beside the build epoch; `uninstall --purge-data` and `doctor` still address only the legacy `<root>/state` directory (#641). (#640)
62 changes: 39 additions & 23 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,23 +150,33 @@ Volatile lifetimes use the memory driver. Request lifetime opens and releases
fresh project and notice stores per invocation; process lifetime shares them
for the generated worker or executable process.

Workspace-durable generated MCP workers store under
`$AGENT_BUNDLE_PLUGIN_ROOT/state`. If that host-provided anchor is absent,
the worker derives the artifact root from the parent of its own `mcp/`
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
(`bin/<name>.mjs` in the plugin root) derives the artifact root from the parent of its
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
Workspace-durable artifact shells — generated MCP workers, artifact CLI bins
and their render workers, rendered script workers, and standalone hook
wrappers — call `resolvePluginRoot`
with `stateAnchor: 'user-data'`. `AGENT_BUNDLE_PLUGIN_ROOT` still names the
code root and otherwise falls back to the artifact root derived from the
shell's own location. An expanded, non-blank `AGENT_BUNDLE_STATE_ROOT`
independently overrides the framework state root and is made absolute with
`resolve()`; otherwise the state root is
`~/.agent-bundle/state/<plugin>-<digest>`, or
`$XDG_STATE_HOME/agent-bundle/<plugin>-<digest>` when `XDG_STATE_HOME` is an absolute path (a relative value is ignored).
`<plugin>` is the code root's safe basename (or `plugin`) and `<digest>`
is the first 16 hexadecimal characters of SHA-256 over that code root's
realpath, so symlinked spellings share one state root while distinct installs
do not. `resolvePluginRoot` uses `os.homedir()` unless its `home` test seam is
supplied. The npm package's routed CLI bin and rendered scripts keep the default
`stateAnchor: 'root'`: `$AGENT_BUNDLE_PLUGIN_ROOT/state` when supplied and
otherwise `$PWD/.agent-bundle/state`.

Each generated process resolves both roots exactly once
(`resolvePluginRoot` from `@agent-bundle/runtime`, #468): the state kernel,
notice ledger, lineage journal, and every request scope the process opens read
the same `stateRoot`, published with the code `root` as
`(await agent()).plugin` and handed to conventional providers as `plugin`
beside `invocation` and `signal`. `source` records whether the code root was
native or derived; `stateSource` does the same independently for the state
root. An unexpanded `${…}` token in either root override is treated as unset
(and 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 @@ -272,7 +282,7 @@ interface AgentProviderContext {
host: Observed<{ name }>; // exactly what the route reads on `await agent()`
session: Observed<{ sessionId }>;
workspace: Observed<{ root }>;
plugin: Observed<{ root; stateRoot }>; // the resolved plugin root (#468)
plugin: Observed<{ root; stateRoot }>; // resolved code and framework state roots (#468)
lineage: Observed<AgentLineage>; // own chain plus the live `tree` (#457)
state?: { lifetime; read(options?) }; // the mounted state handle, `read` only
notices?: { inbox(); published() }; // the request's notice handle, reads only
Expand Down Expand Up @@ -911,10 +921,12 @@ executable bit — invoke it as `node <plugin-root>/bin/<plugin-name>.mjs
<args>`, exactly like `scripts/*.mjs`. Help, argv parsing, output modes,
exit codes, and signals are identical to the package bin. One deliberate
difference: workspace-durable state without a host-supplied
`AGENT_BUNDLE_PLUGIN_ROOT` anchors on the **artifact root** (the parent of
`bin/`, the same fallback the generated MCP worker beside it uses) rather
than `$PWD/.agent-bundle/state`, so a co-installed CLI and server observe
one store. The npm package bin keeps its `cwd` fallback.
`AGENT_BUNDLE_STATE_ROOT` uses `stateAnchor: 'user-data'`, deriving
`~/.agent-bundle/state/<plugin>-<digest>` (or the `XDG_STATE_HOME` equivalent)
from the artifact code root. The generated MCP worker beside it makes the
same derivation, so a co-installed CLI and server observe one store without
writing beneath a read-only artifact. The npm package bin keeps
`stateAnchor: 'root'` and its `cwd` fallback, `$PWD/.agent-bundle/state`.

Reaching the bin from the other surfaces:

Expand Down Expand Up @@ -1652,7 +1664,11 @@ input, and a refresh rebinds that retained result. `<plugin> web` keeps the
installed artifact immutable: framework-owned per-server web state
(`${PLUGIN_DATA}` in declared env) lives under the user's home
(`~/.agent-bundle/web-data/<plugin>-<digest>/<server>`), never inside the
plugin root, so a read-only install still launches.
plugin root. The spawned server's SQLite state kernel, notice ledger, and
lineage journal likewise use `stateAnchor: 'user-data'` and live under
`~/.agent-bundle/state/<plugin>-<digest>` (or the `XDG_STATE_HOME` equivalent)
unless `AGENT_BUNDLE_STATE_ROOT` overrides it, so a read-only install still
launches.

## `agent-bundle/app` — the App-side bridge client

Expand Down
6 changes: 4 additions & 2 deletions docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,8 @@ manifest. A root whose selection includes `cursor` or `portable` also
includes a standalone `install.mjs`. Its staged copy is idempotent for identical
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; `state/` survives), and accepts
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
a pre-receipt copy. Foreign directories are refused with a content-hash
comparison. It never invokes sudo or changes PATH. `agent-bundle install <host>
Expand Down Expand Up @@ -669,7 +670,8 @@ node artifact/install.mjs --uninstall [--plan] [--mode marketplace]

Uninstall removes exactly what the receipt owns and reverses exactly the
registrations it recorded; anything else stays and is listed as retained.
Durable runtime state (`state/`) is kept unless `--purge-data --confirm-purge`;
Legacy durable runtime state (`state/`) is kept unless `--purge-data --confirm-purge`
(current builds keep framework state outside the plugin root);
the typed `data.outcome` says what the host itself decided where Agent Bundle
cannot (`retained-by-host` for Claude's ~14-day orphaned copy,
`removed-by-host` / `unavailable` for Codex, which has no keep-data option). A
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Plain object export keeps this packed fixture independent of the package
// build, like web-surface. The generated `journal` server (src/mcp/journal)
// carries the config-declared status App, and src/state.ts makes the project
// workspace-durable: the packed read-only-install proof spawns its entry and
// its CLI bin against an artifact nothing may write beneath (#637).
export default {
mcp: {
servers: {
journal: {
apps: {
status: {
entry: './views/status.ts',
resourceUri: 'ui://durable-web-surface-fixture/status.html',
targets: ['portable'],
template: './views/status.html',
},
},
},
},
},
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' }] },
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "durable-web-surface-fixture",
"private": true,
"type": "module"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { agent } from '@agent-bundle/runtime';
import type { CliRouteConfig } from 'agent-bundle';
import { z } from 'zod';

export const config = {
description: 'Lists the journal entries the MCP record tool has written.',
} satisfies CliRouteConfig;

export const inputSchema = z.object({}).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 entries() {
const context = await agent();
if (context.state === undefined) throw new TypeError('Journal state is unavailable.');
const snapshot = await context.state.read();
return { entries: (snapshot.state as JournalState).entries, revision: snapshot.revision };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Agent, agent } from '@agent-bundle/runtime';
import { z } from 'zod';

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.',
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<typeof inputSchema> }) {
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.
await context.state.dispatch('recorded', { note: input.note }, { idempotencyKey: `record:${input.note}` });
const snapshot = await context.state.read();
const state = snapshot.state as JournalState;
const result = { entries: state.entries, revision: snapshot.revision };
return (
<Agent.Result value={result}>
<Agent.Text>{`recorded ${String(state.entries.length)} note(s)`}</Agent.Text>
</Agent.Result>
);
}
15 changes: 15 additions & 0 deletions packages/agent-bundle/fixtures/durable-web-surface/src/state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { defineState } from '@agent-bundle/runtime/state';
import { z } from 'zod';

const entrySchema = z.object({ note: z.string() }).strict();

export default defineState({
events: {
recorded: entrySchema,
},
id: 'durable-web-surface/journal',
initial: { entries: [] },
lifetime: 'workspace-durable',
reduce: (state, event) => ({ entries: [...state.entries, event.payload] }),
schema: z.object({ entries: z.array(entrySchema) }).strict(),
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Status</title>
</head>
<body>
<main id="view"></main>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
document.querySelector('#view')!.textContent = 'durable-web-surface fixture status';
8 changes: 5 additions & 3 deletions packages/agent-bundle/src/adapters/hook-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -708,9 +708,11 @@ 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)) });",
// The wrapper lives in `hooks/`, so its code root is the parent
// directory; the state root derives from it in the user state
// directory — the same two roots the generated MCP entry resolves
// (#468, #637), so the lineage journal it retires is the server's.
"const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL('..', import.meta.url)), stateAnchor: 'user-data' });",
'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
23 changes: 12 additions & 11 deletions packages/agent-bundle/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -672,11 +672,11 @@ export interface RunMcpOptions extends ArtifactOperationOptions {
/** Set false to launch the server without any `.env` layer. */
readonly loadEnvFiles?: boolean;
/**
* Root the env-declared plugin-root anchors (for example
* `AGENT_BUNDLE_PLUGIN_ROOT`) expand to. Defaults to the project root so
* durable server state survives artifact rebuilds; point it at the
* artifact target root for a byte-faithful rehearsal of a copied-artifact
* launch.
* Root the env-declared `AGENT_BUNDLE_PLUGIN_ROOT` expands to. Defaults to
* the project root so the derived state root (keyed by that root) survives
* artifact rebuilds; `AGENT_BUNDLE_STATE_ROOT` in the operator environment
* overrides the state location. Point it at the artifact target root for a
* byte-faithful rehearsal of a copied-artifact launch.
*/
readonly pluginRoot?: string;
readonly server: string;
Expand Down Expand Up @@ -1478,12 +1478,13 @@ export const invokeMcp = async (options: InvokeMcpOptions): Promise<McpInvokeRes
/**
* Runs one built stdio MCP server in the foreground with inherited stdio,
* resolving its content-hashed generated entry from the target manifest.
* Both durable-state anchors point at the project root: plugin-data state
* persists under `.agent-bundle/mcp-run/<target>/<server>`, and env-declared
* plugin-root anchors expand to the project root itself (override with
* `pluginRoot`). The launch environment layers, lowest to highest: manifest
* env, the project-root `.env` set (or `envFiles`), the operator's real
* `process.env`.
* Env-declared `AGENT_BUNDLE_PLUGIN_ROOT` expands to the project root by
* default so the derived state root (keyed by that root) survives artifact
* rebuilds; `AGENT_BUNDLE_STATE_ROOT` in the operator environment overrides
* the state location (override the plugin-root expansion with `pluginRoot`).
* Plugin-data state persists under `.agent-bundle/mcp-run/<target>/<server>`.
* The launch environment layers, lowest to highest: manifest env, the
* project-root `.env` set (or `envFiles`), the operator's real `process.env`.
*/
export const runMcp = async (options: RunMcpOptions): Promise<number> => {
const registry = registryFor(options);
Expand Down
7 changes: 4 additions & 3 deletions packages/agent-bundle/src/build/cli-bins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,10 @@ export const cliBinRslibEntries = (
...(cli.projectionSources === undefined ? {} : { projectionSources: cli.projectionSources }),
routes: cli.routes,
...(model.state === undefined ? {} : { state: model.state }),
// Durable state anchors on the artifact root (the parent of `bin/`),
// the same fallback the generated MCP worker beside it uses, so a
// co-installed CLI and server observe one store.
// The code root falls back to the artifact root (the parent of `bin/`)
// and the state root derives from it, the same two roots the generated
// MCP worker beside it resolves, so a co-installed CLI and server
// observe one store.
stateFallback: 'artifact',
...(entry.bin.web === true
? {
Expand Down
6 changes: 5 additions & 1 deletion packages/agent-bundle/src/build/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,11 @@ export const planScriptsSurface = async (
source,
}],
...(options.noticeRetention === undefined ? {} : { noticeRetention: options.noticeRetention }),
...(options.state === undefined ? {} : { state: options.state }),
...(options.state === undefined ? {} : { state: options.state }),
// The worker lives in `scripts/`, one directory below the
// artifact root, like `bin/` and `mcp/`: same code root, same
// derived state root as the MCP worker of the install.
stateFallback: 'artifact',
}),
}),
];
Expand Down
Loading
Loading