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
5 changes: 5 additions & 0 deletions .changeset/466-canonical-event-payload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": minor
---

Give every event route a canonical, per-family `canonical.payload` beside the raw `native` envelope (`AgentEventRouteProps<E>`): the fields at least two hosts report — `toolName`, `toolInput`, `toolUseId`, `toolResponse`, `sessionId` (Claude and Codex `session_id`, Cursor `conversation_id`), `transcriptPath`, `cwd`, `model`, `permissionMode`, `agentId`/`agentType`, `agentTranscriptPath`, `prompt`, `reason`, `source`, `trigger`, `error`/`isInterrupt`, `lastAssistantMessage`, and `reentry` (Claude and Codex `stop_hook_active`, Cursor `loop_count > 0`) — each delivered as `{ value, nativeKey }` naming the host key it was read from, and absent when the host did not send it, never fabricated. Cursor's `tool_output` JSON string is parsed into `toolResponse` (kept as the string when it is not valid JSON); the Claude-only `model-switch/before` and `model-switch/after` families admitted by the 2.1.260 re-pin carry `fromModel`, `toModel`, `requestedModel`, and `source`. Where a family is also a config hook handler event, `canonical.payload` uses the same field names as `HookEvent<E>` (`reentry` is the one renaming of `stopHookActive`). Type a route to its family (`AgentEventRouteProps<'tool/after'>`) and `payload` narrows to that family's fields, in the route and in the generated `.agent-bundle/routes.d.ts` that `renderRoute` reads; `AgentEventCanonicalIdentity<E>` gains the same parameter. The per-family table ships as `agentEventPayloadFields`, the per-host key table as `agentEventPayloadNativeKeys` (with `AgentEventPayload`, `AgentEventPayloadField`, `AgentEventPayloadFieldName`, `AgentEventPayloadNativeKey`, and `agentEventPayloadFieldKinds`), and each pinned capability table mirrors its host's mapping under `hooks.eventRoutes.<event>.payload`, so the generated events reference documents field × host → native key per family. `agent-bundle/test` gains `createEventRouteInput(event, native, { host })`, which validates a host envelope and builds the `{ canonical, native }` input the harness takes, payload included; the Workbench Lifecycles view lists the mapped payload beside the canonical identity. `native` is unchanged, `idempotencyKey` still hashes only the envelope, and a route typed with the bare `AgentEventRouteProps` keeps working with every field optional. Breaking for one shape of consumer code: `payload` is a required property of `AgentEventCanonicalIdentity`, so a test or harness that constructs the identity by hand no longer compiles until it adds one — build the input with `createEventRouteInput` instead. (#466)
2 changes: 1 addition & 1 deletion docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ entries carry `provenance.kind: 'conventional'` in the normalized model.
| `src/scripts/<name>.ts` | Plain script compiled to `scripts/<name>.mjs` in every selected target artifact — 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/<name>.tsx` | Rendered script: the async 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/<name>.mjs` plus a `scripts/<name>-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/<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/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<E>` (`{ canonical, native, signal }`) and returns `Agent.*` output that the selected host adapter encodes into its native hook envelope. `canonical.payload` is the family's cross-host reading of the envelope (#466) — the fields at least two hosts report (`toolName`, `toolInput`, `toolResponse`, `sessionId`, `transcriptPath`, `cwd`, `prompt`, `agentId`/`agentType`, `reentry`, …), each as `{ value, nativeKey }` naming the host key it came from and absent when the host did not send it; `E` narrows it to the route's family. The per-family field table is `agentEventPayloadFields` and the per-host key table `agentEventPayloadNativeKeys` (`routes/events.ts`), mirrored under `hooks.eventRoutes.<event>.payload` in each pinned capability table so the generated events reference documents the mapping per host. 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, 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` |
Expand Down
29 changes: 14 additions & 15 deletions examples/host-test/tests/route-unit/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { join } from 'node:path';

import { afterEach, beforeEach, expect, it } from '@rstest/core';
import { available, type AgentLineage } from '@agent-bundle/runtime';
import { expectDocument, mountTestState, renderRoute, testManifest, type MountedTestState } from 'agent-bundle/test';
import { createEventRouteInput, expectDocument, mountTestState, renderRoute, testManifest, type MountedTestState } from 'agent-bundle/test';

import { LOG_DIR_ENV } from '../../src/log.js';
import { DEFAULT_DUMP_LIMIT } from '../../src/mcp/host-test/tools/dump.js';
Expand All @@ -19,25 +19,24 @@ let logDir: string;
let mounted: MountedTestState<CapturesState, CaptureEvents>;
let sequence = 0;

// The probe records the whole envelope, so its tests hand it partial ones
// (`validate: false`); the harness still projects `canonical.payload` from them.
const eventInput = (
event: 'agent/start' | 'agent/stop' | 'session/start' | 'tool/before',
native: Record<string, unknown>,
host = 'claude',
) => ({
canonical: {
event,
idempotencyKey: `${event}:${String(sequence)}`,
observedAt: `2026-09-03T08:00:${String(sequence++).padStart(2, '0')}.000Z`,
provenance: {
host,
hostContractRevision: 'route-unit',
nativeEvent: native.hook_event_name as string,
source: 'native',
) => {
const built = createEventRouteInput(event, native, { host, validate: false });
return {
canonical: {
...built.canonical,
idempotencyKey: `${event}:${String(sequence)}`,
observedAt: `2026-09-03T08:00:${String(sequence++).padStart(2, '0')}.000Z`,
sequence,
},
sequence,
},
native,
});
native: built.native,
};
};

const render = async (
route: string,
Expand Down
8 changes: 5 additions & 3 deletions examples/rsc-agent-runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,18 @@ This private, opt-in example shows one React Server Components (RSC) runtime sha
Native hooks are fresh requests: the compiler-generated client validates one host event, invokes `src/events/tool/after.tsx` in its explicit standalone mode, projects the final Agent Document, and exits. The durable kernel—not a Node module cache or React state—connects later hook processes and MCP calls.

```tsx
// The semantic event route receives canonical identity plus the complete native payload.
// The semantic event route receives canonical identity — including the family's
// cross-host `payload` — plus the complete native envelope for host-specific fields.
import { Agent } from '@agent-bundle/runtime';
import type { AgentEventRouteProps } from 'agent-bundle';

export const config = { runtime: 'standalone', targets: ['claude', 'codex'] };

export default async function AfterFileEdit({ canonical, native }: AgentEventRouteProps) {
export default async function AfterFileEdit({ canonical }: AgentEventRouteProps<'tool/after'>) {
const toolName = canonical.payload.toolName?.value ?? 'an unnamed tool';
return (
<Agent.Result>
<Agent.Context>{`Recorded ${String(native.tool_name)} from ${canonical.provenance.host}.`}</Agent.Context>
<Agent.Context>{`Recorded ${toolName} from ${canonical.provenance.host}.`}</Agent.Context>
</Agent.Result>
);
}
Expand Down
46 changes: 35 additions & 11 deletions examples/rsc-agent-runtime/src/events/tool/after.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import type { AgentEventRouteProps } from 'agent-bundle';
import * as React from 'react';

import { writeEvalProbe } from '../../hook/eval-probe.js';
import { normalizeClaudeHook, normalizeCodexHook } from '../../hook/normalize.js';
import { editedPath } from '../../hook/normalize.js';
import type { CanonicalPostToolUse } from '../../runtime/contracts.js';
import { createFileRuntimeKernel, resolveImplicitRuntimeStateFile } from '../../runtime/state-file.js';

export const config = {
Expand All @@ -15,21 +16,44 @@ export const config = {
tools: ['file.write'],
};

const requiredField = <Value,>(field: string, mapped: { readonly value: Value } | undefined): Value => {
if (mapped === undefined) {
throw new Error(`Native hook input requires ${field}`);
}
return mapped.value;
};

/**
* The shared fields come from `canonical.payload`, which the framework
* projects the same way from Claude's and Codex's PostToolUse envelopes; only
* the edited-path reading stays host-specific (see `editedPath`).
*/
const normalizedEvent = (
{ idempotencyKey, payload, provenance }: AgentEventRouteProps<'tool/after'>['canonical'],
): CanonicalPostToolUse => {
const host = provenance.host;
if (host !== 'claude' && host !== 'codex') {
throw new Error(`Unsupported event-route host ${JSON.stringify(host)}`);
}
const cwd = requiredField('cwd', payload.cwd);
const toolName = requiredField('tool_name', payload.toolName);
return {
cwd,
host,
idempotencyKey,
path: editedPath(host, cwd, toolName, requiredField('tool_input', payload.toolInput)),
sessionId: requiredField('session_id', payload.sessionId),
toolName,
};
};

export default async function AfterFileEdit({
canonical,
native,
signal,
}: AgentEventRouteProps) {
}: AgentEventRouteProps<'tool/after'>) {
try {
const host = canonical.provenance.host;
const normalized = host === 'claude'
? normalizeClaudeHook(native)
: host === 'codex'
? normalizeCodexHook(native)
: undefined;
if (normalized === undefined) {
throw new Error(`Unsupported event-route host ${JSON.stringify(host)}`);
}
const normalized = normalizedEvent(canonical);

const configuredStateFile = process.env.AGENT_RUNTIME_STATE_FILE;
const stateFile = configuredStateFile === undefined || configuredStateFile.trim() === ''
Expand Down
50 changes: 28 additions & 22 deletions examples/rsc-agent-runtime/src/hook/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,39 +55,45 @@ const resolveNativePath = (cwd: string, path: string): string => {
return resolve(cwd, path);
};

export const normalizeClaudeHook = (input: NativeHookInput): CanonicalPostToolUse => {
const event = readBaseEvent('claude', input);
if (event.toolName !== 'Write' && event.toolName !== 'Edit') {
/**
* The edited file a tool call names. This is the one genuinely host-specific
* reading left in the hook: Claude's `Write`/`Edit` carry `file_path`, while
* Codex's `apply_patch` names the file inside its patch header. The tool name
* and input themselves arrive host-independently on `canonical.payload`.
*/
export const editedPath = (
host: CanonicalPostToolUse['host'],
cwd: string,
toolName: string,
rawToolInput: unknown,
): string => {
if (host === 'claude' && toolName !== 'Write' && toolName !== 'Edit') {
throw new Error('Claude hook supports only Write and Edit');
}

const toolInput = asRecord(input.tool_input);
if (toolInput === undefined) {
throw new Error('Native hook input requires tool_input');
}

return {
...event,
path: resolveNativePath(event.cwd, readRequiredString(toolInput, 'file_path')),
};
};

export const normalizeCodexHook = (input: NativeHookInput): CanonicalPostToolUse => {
const event = readBaseEvent('codex', input);
if (event.toolName !== 'apply_patch') {
if (host === 'codex' && toolName !== 'apply_patch') {
throw new Error('Codex hook supports only apply_patch');
}

const toolInput = asRecord(input.tool_input);
const toolInput = asRecord(rawToolInput);
if (toolInput === undefined) {
throw new Error('Native hook input requires tool_input');
}

if (host === 'claude') {
return resolveNativePath(cwd, readRequiredString(toolInput, 'file_path'));
}
const command = readRequiredString(toolInput, 'command');
const path = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+?)\s*$/m.exec(command)?.[1];
if (path === undefined) {
throw new Error('Codex apply_patch command requires a file header');
}
return resolveNativePath(cwd, path);
};

return { ...event, path: resolveNativePath(event.cwd, path) };
export const normalizeClaudeHook = (input: NativeHookInput): CanonicalPostToolUse => {
const event = readBaseEvent('claude', input);
return { ...event, path: editedPath('claude', event.cwd, event.toolName, input.tool_input) };
};

export const normalizeCodexHook = (input: NativeHookInput): CanonicalPostToolUse => {
const event = readBaseEvent('codex', input);
return { ...event, path: editedPath('codex', event.cwd, event.toolName, input.tool_input) };
};
38 changes: 7 additions & 31 deletions examples/rsc-agent-runtime/tests/route-unit/event-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';

import { afterEach, beforeEach, expect, it } from '@rstest/core';
import { expectDocument, renderRoute, testManifest } from 'agent-bundle/test';
import { createEventRouteInput, expectDocument, renderRoute, testManifest } from 'agent-bundle/test';

/**
* The route-unit proof level for the demo's PostToolUse migration: the hook is
Expand Down Expand Up @@ -46,21 +46,9 @@ it('compiles the PostToolUse hook as a real event route rather than configuratio
it('renders a native Claude PostToolUse envelope into the document the host projects from', async () => {
const native = JSON.parse(await readFile(fixture, 'utf8')) as Record<string, unknown>;
const rendered = await renderRoute('event:tool/after', {
input: {
canonical: {
event: 'tool/after',
idempotencyKey: 'route-unit-claude-write',
observedAt: '2026-09-01T00:00:00.000Z',
provenance: {
host: 'claude',
hostContractRevision: 'route-unit',
nativeEvent: 'PostToolUse',
source: 'native',
},
sequence: 1,
},
native: { ...native, cwd: workspace },
},
// The harness validates the Claude envelope and projects `canonical.payload`
// (cwd, sessionId, toolName, toolInput) exactly as the artifact's wrapper does.
input: createEventRouteInput('tool/after', { ...native, cwd: workspace }, { host: 'claude' }),
});

expect(rendered.invocation.kind).toBe('event');
Expand All @@ -77,21 +65,9 @@ it('appends a value-free eval hook probe when AGENT_RUNTIME_HOOK_PROBE_FILE is s
const native = JSON.parse(await readFile(fixture, 'utf8')) as Record<string, unknown>;

await renderRoute('event:tool/after', {
input: {
canonical: {
event: 'tool/after',
idempotencyKey: 'route-unit-claude-write',
observedAt: '2026-09-01T00:00:00.000Z',
provenance: {
host: 'claude',
hostContractRevision: 'route-unit',
nativeEvent: 'PostToolUse',
source: 'native',
},
sequence: 1,
},
native: { ...native, cwd: workspace },
},
// The harness validates the Claude envelope and projects `canonical.payload`
// (cwd, sessionId, toolName, toolInput) exactly as the artifact's wrapper does.
input: createEventRouteInput('tool/after', { ...native, cwd: workspace }, { host: 'claude' }),
});

const probe = JSON.parse(await readFile(probeFile, 'utf8'));
Expand Down
Loading
Loading