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

Compile semantic event routes into native hook clients that render through the
generated MCP entry's epoch-bound local runtime, with explicit standalone
fallback and fail-closed transport behavior.
2 changes: 2 additions & 0 deletions packages/agent-bundle/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export default defineConfig({
'cli-entry': './src/cli-entry.ts',
config: './src/config/index.ts',
eval: './src/eval/index.ts',
'event-ipc': './src/events/ipc.ts',
'event-project': './src/events/project.ts',
index: './src/index.ts',
'mcp-apps': './src/mcp-apps.ts',
'mcp-entry': './src/mcp-entry.ts',
Expand Down
8 changes: 8 additions & 0 deletions packages/agent-bundle/src/adapters/capability-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ export const eventRouteCapabilitiesFrom = (
}),
));

export const supportedEventRouteNamesFrom = (
routes: Readonly<Record<string, EventRouteCapabilityTableEntry>>,
): Readonly<Record<string, string>> => Object.freeze(Object.fromEntries(
Object.entries(routes)
.filter(([, capability]) => capability.state === 'supported' && typeof capability.nativeEvent === 'string')
.map(([event, capability]) => [event, capability.nativeEvent!]),
));

export const capabilityStateFromSupport = (
supported: boolean,
evidence: CapabilityEvidence,
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-bundle/src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
capabilityEvidence,
capabilityStateFromSupport,
eventRouteCapabilitiesFrom,
supportedEventRouteNamesFrom,
supportedCapability,
} from './capability-state.ts';
import capabilityTable from './capabilities/claude-2.1.250.json' with { type: 'json' };
Expand Down Expand Up @@ -117,10 +118,12 @@ const validateLsp = validator.compile(lspSchema);
/** The pinned Claude hooks validator, shared with the unified bundle adapter. */
export const claudeHooksValidator = validateHooks;
const hookContract = Object.freeze({
capabilityRevision: capabilityTable.observedCliVersion,
commandRoot: '${CLAUDE_PLUGIN_ROOT}',
encodePlaygroundInput: encodeNativeHookPlaygroundInput,
encodePlaygroundOutput: encodeNativeHookPlaygroundOutput,
eventNames: capabilityTable.hooks.events,
eventRouteNames: supportedEventRouteNamesFrom(capabilityTable.hooks.eventRoutes),
manifestPath: 'hooks/hooks.json',
matchers: capabilityTable.hooks.matchers,
readNativeCommands: readStandardNativeHookCommands,
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-bundle/src/adapters/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
capabilityEvidence,
capabilityStateFromSupport,
eventRouteCapabilitiesFrom,
supportedEventRouteNamesFrom,
supportedCapability,
unavailableCapability,
} from './capability-state.ts';
Expand Down Expand Up @@ -104,10 +105,12 @@ export const codexPluginDocumentValidator = (mcpRelativePath: string): TargetArt
validateJsonSchemaDocument(pluginValidatorFor(mcpRelativePath));
const validateHooks = validator.compile(hooksSchema);
const hookContract = Object.freeze({
capabilityRevision: capabilityTable.observedCliVersion,
commandRoot: '${PLUGIN_ROOT}',
encodePlaygroundInput: encodeNativeHookPlaygroundInput,
encodePlaygroundOutput: encodeNativeHookPlaygroundOutput,
eventNames: capabilityTable.hooks.events,
eventRouteNames: supportedEventRouteNamesFrom(capabilityTable.hooks.eventRoutes),
manifestPath: 'hooks/hooks.json',
matchers: capabilityTable.hooks.matchers,
readNativeCommands: readStandardNativeHookCommands,
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-bundle/src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
capabilityEvidence,
capabilityStateFromSupport,
eventRouteCapabilitiesFrom,
supportedEventRouteNamesFrom,
supportedCapability,
unavailableCapability,
} from './capability-state.ts';
Expand Down Expand Up @@ -127,12 +128,14 @@ export interface CursorHookContractOptions {
* Claude/Codex format; see cursorHookWrapperSource).
*/
export const createCursorHookContract = (options: CursorHookContractOptions): TargetHookContract => Object.freeze({
capabilityRevision: capabilityTable.observedCliVersion,
commandRoot: '${CURSOR_PLUGIN_ROOT}',
documentEntry: cursorHookDocumentEntry,
documentEnvelope: cursorHookDocumentEnvelope,
encodePlaygroundInput: encodeCursorPlaygroundInput,
encodePlaygroundOutput: (result, canonicalEvent) => encodeCursorPlaygroundOutput(result, canonicalEvent),
eventNames: capabilityTable.hooks.events,
eventRouteNames: supportedEventRouteNamesFrom(capabilityTable.hooks.eventRoutes),
...(options.indexedWrappers === false ? { indexedWrappers: false as const } : {}),
manifestPath: options.manifestPath,
matchers: capabilityTable.hooks.matchers,
Expand Down
131 changes: 128 additions & 3 deletions packages/agent-bundle/src/adapters/hook-contract.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Diagnostic } from '../core/diagnostics.ts';
import { dataArrayValues, hasDataKeys, isPlainDataRecord, isRecord, ownDataValue } from '../core/strict-json.ts';
import { escapeRegExp } from '../core/strings.ts';
import type { CanonicalAgentEvent } from '../routes/public.ts';
import type {
CanonicalHookEvent,
CanonicalHookTool,
Expand Down Expand Up @@ -40,6 +41,7 @@ export interface TargetHookDocumentEntryInput {
}

export interface TargetHookContract {
readonly capabilityRevision?: string;
readonly commandRoot: string;
/**
* Shapes one generated hook command into the host's per-event array entry.
Expand All @@ -58,7 +60,8 @@ export interface TargetHookContract {
canonicalEvent: CanonicalHookEvent,
nativeEvent: string,
) => Readonly<Record<string, unknown>> | undefined;
readonly eventNames: Readonly<Record<CanonicalHookEvent, string>>;
readonly eventNames: Readonly<Partial<Record<CanonicalHookEvent, string>>>;
readonly eventRouteNames?: Readonly<Partial<Record<CanonicalAgentEvent, string>>>;
/**
* False when this contract plans host-document wrapper variants of hooks
* whose canonical wrappers another contract already indexes; the canonical
Expand Down Expand Up @@ -189,6 +192,9 @@ const canonicalEventOrder: readonly CanonicalHookEvent[] = [
'beforeTool',
'afterTool',
'stop',
'agentStart',
'agentStop',
'workspaceOpen',
];

export const canonicalHookEventFor = (event: string): CanonicalHookEvent | undefined =>
Expand Down Expand Up @@ -283,6 +289,118 @@ export const encodeCursorPlaygroundOutput = (
: { additional_context: result.additionalContext };
};

export const eventIpcRuntimeSpecifier = 'agent-bundle/event-ipc';
export const eventProjectRuntimeSpecifier = 'agent-bundle/event-project';
export const eventArtifactEpochToken = '__AGENT_BUNDLE_EVENT_ARTIFACT_EPOCH__';

const eventRouteHookWrapperSource = (
entry: TargetHookWrapper,
capabilityRevision: string,
): string => {
const route = entry.hook.eventRoute!;
const standalone = route.runtime === 'standalone' || route.fallback === 'standalone';
return [
"import { dirname, resolve } from 'node:path';",
`import { EventRuntimeTransportError, requestEventRuntime } from ${JSON.stringify(eventIpcRuntimeSpecifier)};`,
...(standalone
? [
`import { createCanonicalEventProps, projectEventDocument, renderStandaloneEventRoute } from ${JSON.stringify(eventProjectRuntimeSpecifier)};`,
`import * as routeModule from ${JSON.stringify(entry.hook.source)};`,
]
: []),
'',
`const artifactEpoch = ${JSON.stringify(eventArtifactEpochToken)};`,
`const canonicalEvent = ${JSON.stringify(route.event)};`,
`const capabilityRevision = ${JSON.stringify(capabilityRevision)};`,
`const nativeEvent = ${JSON.stringify(entry.nativeEvent)};`,
`const target = ${JSON.stringify(entry.target)};`,
`const runtimeMode = ${JSON.stringify(route.runtime)};`,
`const fallbackMode = ${JSON.stringify(route.fallback)};`,
`const timeoutMs = ${String((entry.hook.timeout ?? 5) * 1_000)};`,
"const endpointId = `${artifactEpoch}:${target}:${dirname(dirname(resolve(process.argv[1])))}`;",
'',
'const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);',
'const fail = (message) => { throw new Error(`Agent Bundle event route error: ${message}`); };',
'const requireString = (input, field) => { if (typeof input[field] !== "string" || input[field].trim() === "") fail(`native ${field} must be a nonempty string`); };',
'const validateNative = (input) => {',
' if (!isRecord(input)) fail("stdin JSON value must be an object");',
' if (input.hook_event_name !== nativeEvent) fail(`native hook_event_name must equal ${nativeEvent}`);',
' if (target === "cursor") {',
' if (typeof input.session_id !== "string" && typeof input.conversation_id !== "string") fail("native session_id or conversation_id must be a string");',
' if (canonicalEvent === "tool/before" || canonicalEvent === "tool/after") {',
' requireString(input, "tool_name");',
' if (!isRecord(input.tool_input)) fail("native tool_input must be an object");',
' requireString(input, "tool_use_id");',
' if (canonicalEvent === "tool/after") requireString(input, "tool_output");',
' }',
' if (canonicalEvent === "stop" && typeof input.loop_count !== "number") fail("native loop_count must be a number");',
' return input;',
' }',
' requireString(input, "session_id");',
' requireString(input, "transcript_path");',
' requireString(input, "cwd");',
' if (canonicalEvent === "session/start") requireString(input, "source");',
' if (canonicalEvent === "tool/before" || canonicalEvent === "tool/after") {',
' requireString(input, "tool_name");',
' if (!isRecord(input.tool_input)) fail("native tool_input must be an object");',
' requireString(input, "tool_use_id");',
' if (canonicalEvent === "tool/after" && !isRecord(input.tool_response)) fail("native tool_response must be an object");',
' }',
' if (canonicalEvent === "stop") {',
' if (typeof input.stop_hook_active !== "boolean") fail("native stop_hook_active must be a boolean");',
' requireString(input, "last_assistant_message");',
' }',
' return input;',
'};',
...(standalone
? [
'const runStandalone = async (native, signal) => {',
' const component = Reflect.get(routeModule, "default");',
' if (typeof component !== "function") fail("default export must be an async Server Component");',
' const props = createCanonicalEventProps(canonicalEvent, native, target, nativeEvent, capabilityRevision, signal);',
' return projectEventDocument(await renderStandaloneEventRoute(component, props), canonicalEvent, target, nativeEvent);',
'};',
]
: []),
'const run = async () => {',
' const chunks = [];',
' let bytes = 0;',
' for await (const chunk of process.stdin) {',
' bytes += chunk.length;',
' if (bytes > 1024 * 1024) fail("stdin exceeds the 1 MiB native-payload limit");',
' chunks.push(chunk);',
' }',
' let parsed;',
' try { parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { fail("stdin must contain exactly one JSON value"); }',
' const native = validateNative(parsed);',
' const controller = new AbortController();',
' let output;',
' if (runtimeMode === "standalone") {',
...(standalone ? [' output = await runStandalone(native, controller.signal);'] : [' fail("standalone runtime was not compiled");']),
' } else {',
' try {',
' output = await requestEventRuntime({ artifactEpoch, endpointId, event: canonicalEvent, hostContractRevision: capabilityRevision, native, signal: controller.signal, target, timeoutMs });',
' } catch (error) {',
...(standalone
? [
' if (!(fallbackMode === "standalone" && error instanceof EventRuntimeTransportError && error.code === "runtime-unavailable")) throw error;',
' output = await runStandalone(native, controller.signal);',
]
: [' throw error;']),
' }',
' }',
' if (output !== undefined) process.stdout.write(JSON.stringify(output));',
'};',
'if (import.meta.main) {',
' await run().catch((error) => {',
' process.stderr.write(`${error instanceof Error ? error.message : String(error)}\\n`);',
' process.exitCode = 1;',
' });',
'}',
'',
].join('\n');
};

/** Emits the published Cursor hook wrapper source; see encodeCursorPlaygroundInput for the envelope contract. */
export const cursorHookWrapperSource = (entry: TargetHookWrapper): string => [
`import * as handlerModule from ${JSON.stringify(entry.hook.source)};`,
Expand Down Expand Up @@ -558,7 +676,9 @@ export const planHooks = (
const groups: Record<string, unknown[]> = Object.create(null) as Record<string, unknown[]>;
const hookEntries: TargetHookEntry[] = [];
for (const hook of selected) {
const nativeEvent = contract.eventNames[hook.event];
const nativeEvent = hook.eventRoute === undefined
? contract.eventNames[hook.event]
: contract.eventRouteNames?.[hook.eventRoute.event];
if (typeof nativeEvent !== 'string' || nativeEvent.trim().length === 0) {
diagnostics.push(error(
target,
Expand Down Expand Up @@ -602,7 +722,12 @@ export const planHooks = (
relativePath,
target,
};
hookEntries.push({ ...wrapper, virtualSource: contract.wrapperSource(wrapper) });
hookEntries.push({
...wrapper,
virtualSource: hook.eventRoute === undefined
? contract.wrapperSource(wrapper)
: eventRouteHookWrapperSource(wrapper, contract.capabilityRevision ?? target),
});
}

return Object.freeze({
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-bundle/src/adapters/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
standardMcpPathTokens,
} from '../services/mcp-path-tokens.ts';
import { createTargetMcpRuntime } from '../services/mcp-runtime.ts';
import { intersectCapabilityStates } from './capability-state.ts';
import { intersectCapabilityStates, supportedEventRouteNamesFrom } from './capability-state.ts';
import claudeCapabilityTable from './capabilities/claude-2.1.250.json' with { type: 'json' };
import codexCapabilityTable from './capabilities/codex-0.147.0.json' with { type: 'json' };
import { claudeAdapter, claudeArtifactPaths, claudeHooksValidator, planClaudeArtifacts } from './claude.ts';
Expand Down Expand Up @@ -103,13 +103,15 @@ for (const key of new Set([...Object.keys(claudeMatchers), ...Object.keys(codexM
}

const bundleHookContract: TargetHookContract = Object.freeze({
capabilityRevision: `${claudeCapabilityTable.observedCliVersion}+${codexCapabilityTable.observedCliVersion}`,
// ${CLAUDE_PLUGIN_ROOT} reaches both hosts: Claude substitutes its own
// token and Codex exports the variable as a documented compatibility alias
// into a real shell.
commandRoot: '${CLAUDE_PLUGIN_ROOT}',
encodePlaygroundInput: encodeNativeHookPlaygroundInput,
encodePlaygroundOutput: encodeNativeHookPlaygroundOutput,
eventNames: claudeCapabilityTable.hooks.events,
eventRouteNames: supportedEventRouteNamesFrom(claudeCapabilityTable.hooks.eventRoutes),
manifestPath: claudeArtifactPaths.hooksManifest,
matchers: Object.freeze({
...claudeMatchers,
Expand Down
11 changes: 10 additions & 1 deletion packages/agent-bundle/src/build/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,10 +357,19 @@ export const build = async (options: BuildOptions): Promise<BuildResult> => {
{ cwd: options.projectRoot, outDir: target.root, ...tools },
)),
);
compiledHooks.push(...(await compileHooks(target.hookEntries, { cwd: options.projectRoot, outDir: target.root, ...tools })));
compiledHooks.push(...(await compileHooks(target.hookEntries, {
artifactEpoch: options.projectContext.revision,
cwd: options.projectRoot,
outDir: target.root,
...tools,
})));
compiledMcpEntries.push(...(await compileMcpEntries(options.model.mcpServers, {
apps: targetMcpApps,
artifactEpoch: options.projectContext.revision,
cwd: options.projectRoot,
eventHooks: target.hookEntries
.filter((entry) => entry.hook.eventRoute !== undefined)
.map((entry) => entry.hook),
outDir: target.root,
plugin: { name: options.model.metadata.name, version: options.model.metadata.version },
target: target.name,
Expand Down
Loading
Loading