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/composite-plugin-event-hosts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'agent-bundle': patch
---

Resolve the concrete invoking host for composite plugin shared event routes so Claude, Codex, and Cursor wrappers validate and project their native envelopes correctly.
6 changes: 6 additions & 0 deletions .changeset/standalone-event-flight.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"agent-bundle": patch
---

Render standalone event routes through a local react-server Flight worker while
preserving each host's native hook input and output envelopes.
80 changes: 70 additions & 10 deletions packages/agent-bundle/src/adapters/hook-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,14 +398,18 @@ export const encodeCursorPlaygroundOutput = (
export const eventIpcRuntimeSpecifier = 'agent-bundle/event-ipc';
export const eventProjectRuntimeSpecifier = 'agent-bundle/event-project';
export const eventArtifactEpochToken = '__AGENT_BUNDLE_EVENT_ARTIFACT_EPOCH__';
export const eventFlightArtifactEpochToken = '__AGENT_BUNDLE_EVENT_FLIGHT_ARTIFACT_EPOCH__';

const eventRouteHookWrapperSource = (
entry: TargetHookWrapper,
hostContractRevision: string,
concreteTarget?: string,
): string => {
const route = entry.hook.eventRoute!;
const standalone = route.runtime === 'standalone' || route.fallback === 'standalone';
const targetSource = entry.target === 'plugin'
const targetSource = concreteTarget !== undefined
? [`const target = ${JSON.stringify(concreteTarget)};`]
: entry.target === 'plugin'
? [
'const declaredHost = process.env.AGENT_BUNDLE_HOOK_HOST;',
'const target = declaredHost === "claude" || declaredHost === "codex"',
Expand All @@ -415,15 +419,15 @@ const eventRouteHookWrapperSource = (
: ['const target = artifactTarget;'];
return [
"import { dirname, resolve } from 'node:path';",
`import { EventRuntimeTransportError, requestEventRuntime } from ${JSON.stringify(eventIpcRuntimeSpecifier)};`,
`import { ${standalone ? 'createCanonicalEventProps, projectEventDocument, renderStandaloneEventRoute, ' : ''}validateNativeEventEnvelope } from ${JSON.stringify(eventProjectRuntimeSpecifier)};`,
...(standalone ? ["import { Worker } from 'node:worker_threads';"] : []),
...(standalone
? [
`import * as routeModule from ${JSON.stringify(entry.hook.source)};`,
]
? ["import { agent, available, createAgentRenderDispatcher, runAgentRequest } from '@agent-bundle/runtime';"]
: []),
`import { EventRuntimeTransportError, requestEventRuntime } from ${JSON.stringify(eventIpcRuntimeSpecifier)};`,
`import { ${standalone ? 'createCanonicalEventProps, projectEventDocument, ' : ''}validateNativeEventEnvelope } from ${JSON.stringify(eventProjectRuntimeSpecifier)};`,
'',
`const artifactEpoch = ${JSON.stringify(eventArtifactEpochToken)};`,
...(standalone ? [`const flightArtifactEpoch = ${JSON.stringify(eventFlightArtifactEpochToken)};`] : []),
`const canonicalEvent = ${JSON.stringify(route.event)};`,
`const capabilityRevision = ${JSON.stringify(hostContractRevision)};`,
`const nativeEvent = ${JSON.stringify(entry.nativeEvent)};`,
Expand All @@ -437,11 +441,66 @@ const eventRouteHookWrapperSource = (
'const fail = (message) => { throw new Error(`Agent Bundle event route error: ${message}`); };',
...(standalone
? [
'const renderStandalone = async (invocation, signal) => {',
' const worker = new Worker(new URL("./hooks-flight.mjs", import.meta.url), { stderr: true, stdout: true });',
" worker.stdout?.on('data', (chunk) => process.stderr.write(chunk));",
" worker.stderr?.on('data', (chunk) => process.stderr.write(chunk));",
' let sequence = 0;',
' const host = Object.freeze({',
' execute: async (dispatch) => {',
' const context = await agent();',
' const id = ++sequence;',
' return new Promise((resolvePromise, rejectPromise) => {',
' const abort = () => { worker.postMessage({ id, type: "cancel" }); rejectPromise(new DOMException("Agent render was aborted", "AbortError")); };',
' const cleanup = () => { dispatch.signal.removeEventListener("abort", abort); worker.off("error", onError); worker.off("exit", onExit); worker.off("message", onMessage); };',
' const reject = (error) => { cleanup(); rejectPromise(error); };',
' const onError = (error) => { reject(error); };',
' const onExit = (code) => { reject(new Error(`Generated hook Flight worker exited with code ${String(code)}.`)); };',
' const onMessage = (message) => {',
' if (message.id !== id) return;',
' if (message.type === "progress") { Promise.resolve(dispatch.progress?.report(message.update)).catch(reject); return; }',
' if (message.type === "error") { reject(new Error(message.message)); return; }',
' if (message.type !== "complete") return;',
' cleanup();',
' resolvePromise(new ReadableStream({ start(controller) { controller.enqueue(message.bytes); controller.close(); } }));',
' };',
' worker.on("error", onError);',
' worker.on("exit", onExit);',
' worker.on("message", onMessage);',
' dispatch.signal.addEventListener("abort", abort, { once: true });',
' if (dispatch.signal.aborted) { abort(); return; }',
' worker.postMessage({',
' actor: context.actor,',
' artifactEpoch: flightArtifactEpoch,',
' host: context.host,',
' id,',
' invocation: dispatch.invocation,',
' requestInvocation: context.invocation,',
' session: context.session,',
' type: "render",',
' workspace: context.workspace,',
' });',
' });',
' },',
' });',
' try {',
' return await createAgentRenderDispatcher(host).dispatch({ invocation, signal });',
' } finally {',
' await worker.terminate();',
' }',
'};',
'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 sessionId = typeof native.session_id === "string" ? native.session_id : typeof native.conversation_id === "string" ? native.conversation_id : undefined;',
' const workspaceRoot = typeof native.cwd === "string" ? native.cwd : undefined;',
' const document = await runAgentRequest({',
' host: available({ name: target }, "native"),',
' invocation: { artifactEpoch, hostContractRevision: capabilityRevision, kind: "event", operationId: `event:${canonicalEvent}`, surface: canonicalEvent },',
' ...(sessionId === undefined ? {} : { session: available({ sessionId }, "native") }),',
' signal,',
' ...(workspaceRoot === undefined ? {} : { workspace: available({ root: workspaceRoot }, "native") }),',
' }, async () => renderStandalone({ kind: "event", props: { event: canonicalEvent, payload: { canonical: props.canonical, native: props.native } } }, signal));',
' return projectEventDocument(document, canonicalEvent, target, nativeEvent);',
'};',
]
: []),
Expand Down Expand Up @@ -743,6 +802,7 @@ export const planHooks = (
model: NormalizedPlugin,
target: string,
contract: TargetHookContract,
concreteEventTarget?: string,
): HookPlan => {
const diagnostics: Diagnostic[] = [];
const selected = model.hooks
Expand Down Expand Up @@ -811,7 +871,7 @@ export const planHooks = (
...wrapper,
virtualSource: hook.eventRoute === undefined
? contract.wrapperSource(wrapper)
: eventRouteHookWrapperSource(wrapper, contract.hostContractRevision ?? target),
: eventRouteHookWrapperSource(wrapper, contract.hostContractRevision ?? target, concreteEventTarget),
});
}

Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/src/adapters/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => {
// schema-collision guard when no hook lowers to Cursor.
let cursorHooksDocument: Record<string, unknown> = emptyCursorHooksDocument;
if (emitCursorHooks) {
const cursorHooks = planHooks(model, pluginName, cursorBundleHookContract);
const cursorHooks = planHooks(model, pluginName, cursorBundleHookContract, 'cursor');
diagnostics.push(...cursorHooks.diagnostics);
if (cursorHooks.document !== undefined) {
const cursorHooksDocumentValid = cursorHooksValidator(cursorHooks.document);
Expand Down
14 changes: 11 additions & 3 deletions packages/agent-bundle/src/build/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ const plannedDestinations = (targets: readonly StagedTarget[]): readonly string[
resolveArtifactDestination(target.root, entry.relativePath),
),
...target.compiledEntries.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]),
...target.compiledHooks.map((entry) => entry.output),
...target.compiledHooks.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]),
...target.compiledMcpApps.map((entry) => entry.output),
...target.compiledMcpEntries.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]),
]);
Expand Down Expand Up @@ -235,11 +235,15 @@ const outputCandidatesFor = (options: {
path: entry.workerOutput,
sourceInputs: entry.workerSourceInputs ?? entry.sourceInputs,
}])]),
...options.compiledHooks.map((entry) => ({
...options.compiledHooks.flatMap((entry) => [{
kind: 'bundle' as const,
path: entry.output,
sourceInputs: entry.sourceInputs,
})),
}, ...(entry.workerOutput === undefined ? [] : [{
kind: 'bundle' as const,
path: entry.workerOutput,
sourceInputs: entry.workerSourceInputs ?? entry.sourceInputs,
}])]),
...options.compiledMcpApps.map((entry) => ({
kind: 'bundle' as const,
path: entry.output,
Expand Down Expand Up @@ -377,6 +381,9 @@ export const build = async (options: BuildOptions): Promise<BuildResult> => {
cwd: options.projectRoot,
meta,
outDir: target.root,
plugin: { name: options.model.metadata.name, version: options.model.metadata.version },
providers: options.model.providers ?? [],
...(options.model.state === undefined ? {} : { state: options.model.state }),
...tools,
})));
compiledMcpEntries.push(...(await compileMcpEntries(options.model.mcpServers, {
Expand Down Expand Up @@ -462,6 +469,7 @@ export const build = async (options: BuildOptions): Promise<BuildResult> => {
compiledHooks: Object.freeze(compiledHooks.map((entry) => Object.freeze({
...entry,
output: publishedOutput(entry),
...(entry.workerOutput === undefined ? {} : { workerOutput: publishedOutput({ output: entry.workerOutput }) }),
}))),
compiledMcpApps: Object.freeze(compiledMcpApps.map((entry) => Object.freeze({
...entry,
Expand Down
89 changes: 74 additions & 15 deletions packages/agent-bundle/src/build/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';

import {
eventArtifactEpochToken,
eventFlightArtifactEpochToken,
eventIpcRuntimeSpecifier,
eventProjectRuntimeSpecifier,
type TargetHookEntry,
Expand Down Expand Up @@ -471,18 +472,32 @@ export const compileMcpEntries = async (
export const planCompiledHooks = (
entries: readonly TargetHookEntry[],
options: { readonly outDir: string },
): readonly CompiledHookEntry[] => deepFreeze(entries.map((entry) => ({
event: entry.event,
id: entry.hook.id,
...(entry.indexed === false ? { indexed: false as const } : {}),
name: entry.hook.name,
output: resolveArtifactDestination(options.outDir, entry.relativePath),
outputKind: 'bundle',
source: entry.hook.source,
sourceInputs: Object.freeze([entry.hook.provenance.sourcePath, entry.hook.source]),
target: entry.target,
...(entry.timeout === undefined ? {} : { timeout: entry.timeout }),
})));
): readonly CompiledHookEntry[] => {
const workerOwner = entries.findIndex((entry) =>
entry.hook.eventRoute?.runtime === 'standalone' || entry.hook.eventRoute?.fallback === 'standalone');
const workerSourceInputs = Object.freeze([...new Set(entries
.filter((entry) =>
entry.hook.eventRoute?.runtime === 'standalone' || entry.hook.eventRoute?.fallback === 'standalone')
.flatMap((entry) => [entry.hook.provenance.sourcePath, entry.hook.source]))]);
return deepFreeze(entries.map((entry, index) => ({
event: entry.event,
id: entry.hook.id,
...(entry.indexed === false ? { indexed: false as const } : {}),
name: entry.hook.name,
output: resolveArtifactDestination(options.outDir, entry.relativePath),
outputKind: 'bundle',
source: entry.hook.source,
sourceInputs: Object.freeze([entry.hook.provenance.sourcePath, entry.hook.source]),
target: entry.target,
...(entry.timeout === undefined ? {} : { timeout: entry.timeout }),
...(index === workerOwner
? {
workerOutput: resolveArtifactDestination(resolve(options.outDir, 'hooks'), 'hooks-flight.mjs'),
workerSourceInputs,
}
: {}),
})));
};

export const compileHooks = async (
entries: readonly TargetHookEntry[],
Expand All @@ -491,21 +506,58 @@ export const compileHooks = async (
readonly cwd: string;
readonly meta: AgentBundleMeta;
readonly outDir: string;
readonly plugin: { readonly name: string; readonly version: string };
readonly providers?: readonly CompiledProvider[];
readonly state?: NormalizedStateDefinition;
readonly tools?: AgentBundleToolsConfig;
},
): Promise<readonly CompiledHookEntry[]> => {
const compiled = planCompiledHooks(entries, options);
const routeEntries = entries.filter((entry) => entry.hook.eventRoute !== undefined);
const standaloneEventRoutes = [...new Map(routeEntries
.filter((entry) =>
entry.hook.eventRoute?.runtime === 'standalone' || entry.hook.eventRoute?.fallback === 'standalone')
.map((entry) => [entry.hook.id, entry.hook])).values()];
const workerArtifactEpoch = generatedRouteArtifactEpoch(options.plugin);
const eventIpcRuntime = routeEntries.length === 0 ? undefined : eventRuntimeModulePath('ipc');
const eventProjectRuntime = routeEntries.length === 0 ? undefined : eventRuntimeModulePath('project');
const workerEntry = standaloneEventRoutes.length === 0
? undefined
: {
name: 'hooks-flight',
outputRelativePath: 'hooks/hooks-flight.mjs',
reactServer: true as const,
rscManifest: true as const,
source: standaloneEventRoutes[0]!.source,
sourceInputs: Object.freeze([
...new Set([
...standaloneEventRoutes.flatMap((hook) => [hook.provenance.sourcePath, hook.source]),
...(options.providers ?? []).map((provider) => provider.source),
...(options.state === undefined ? [] : [options.state.provenance.sourcePath, options.state.source]),
]),
]),
virtualSource: generatedRouteFlightWorkerSource({
artifactEpoch: workerArtifactEpoch,
eventRoutes: standaloneEventRoutes,
providers: options.providers ?? [],
routes: [],
serverName: 'hooks',
...(options.state === undefined ? {} : { state: options.state }),
}),
};
const evidence = await buildWithRslib({
cwd: options.cwd,
entries: compiled.map((entry, index) => ({
entries: [
...compiled.map((entry, index) => ({
// One hook can compile into several host wrappers (for example a shared
// Claude/Codex wrapper plus a Cursor-codec wrapper), so the bundler
// library id derives from the unique output path, not the hook name.
name: entries[index]!.relativePath.replaceAll('/', '-').replace(/\.mjs$/u, ''),
outputRelativePath: entries[index]!.relativePath,
...(entries[index]!.hook.eventRoute?.runtime === 'standalone'
|| entries[index]!.hook.eventRoute?.fallback === 'standalone'
? { rscManifest: true as const }
: {}),
...(entries[index]!.hook.eventRoute === undefined || eventIpcRuntime === undefined
? {}
: {
Expand All @@ -516,8 +568,12 @@ export const compileHooks = async (
}),
source: entry.source,
sourceInputs: entry.sourceInputs,
virtualSource: entries[index]!.virtualSource.replaceAll(eventArtifactEpochToken, options.artifactEpoch),
})),
virtualSource: entries[index]!.virtualSource
.replaceAll(eventArtifactEpochToken, options.artifactEpoch)
.replaceAll(eventFlightArtifactEpochToken, workerArtifactEpoch),
})),
...(workerEntry === undefined ? [] : [workerEntry]),
],
...(eventIpcRuntime === undefined
? {}
: {
Expand All @@ -531,5 +587,8 @@ export const compileHooks = async (
return Object.freeze(compiled.map((entry, index) => Object.freeze({
...entry,
sourceInputs: evidenceByPath.get(entries[index]!.relativePath) ?? (() => { throw new Error(`Missing bundled hook evidence for ${JSON.stringify(entry.name)}.`); })(),
...(entry.workerOutput === undefined ? {} : {
workerSourceInputs: evidenceByPath.get('hooks/hooks-flight.mjs') ?? (() => { throw new Error('Missing bundled hook Flight worker evidence.'); })(),
}),
})));
};
8 changes: 7 additions & 1 deletion packages/agent-bundle/src/build/entry-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,10 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti
assertRegistrableMcpRoutes(routes, options.state !== undefined);
const artifactEpoch = generatedRouteArtifactEpoch(options.plugin);
const hasEvents = (options.eventRoutes?.length ?? 0) > 0;
const eventTarget = options.target ?? 'unknown';
const allowedEventTargets = eventTarget === 'plugin'
? ['claude', 'codex', 'cursor']
: [eventTarget];
return [
...(hasEvents ? ["import { dirname, resolve } from 'node:path';"] : []),
`import { createFlightWorkerHost, createGeneratedRouteMcpServer } from ${JSON.stringify(mcpServerRuntimeSpecifier)};`,
Expand All @@ -737,8 +741,10 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti
// The endpoint identity is artifact-location dependent, so it stays
// in the artifact rather than the shared runtime.
`const EVENT_ARTIFACT_EPOCH = ${JSON.stringify(options.artifactEpoch ?? 'unknown')};`,
`const EVENT_TARGET = ${JSON.stringify(options.target ?? 'unknown')};`,
`const EVENT_TARGET = ${JSON.stringify(eventTarget)};`,
`const EVENT_ALLOWED_TARGETS = Object.freeze(${JSON.stringify(allowedEventTargets)});`,
'const events = Object.freeze({',
' allowedTargets: EVENT_ALLOWED_TARGETS,',
' artifactEpoch: EVENT_ARTIFACT_EPOCH,',
' createCanonicalEventProps,',
' createEventRuntimeServer,',
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-bundle/src/build/provenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,12 @@ const isIgnoredModule = (path: string, ignoredSourcePaths: readonly string[]): b
// identified by moduleType and externals by their readable identifier prefix
// (an external's moduleType is "javascript/dynamic", never "external").
const isKnownNoAuthorSourceModule = (module: JsonRecord): boolean => {
const identifier = stringAt(module, 'identifier');
const moduleType = stringAt(module, 'moduleType');
const name = stringAt(module, 'name');
return recordsAt(module.modules).length > 0 ||
moduleType === 'runtime' ||
identifier?.endsWith('|sync') === true ||
name?.startsWith('external ') === true;
};

Expand Down
Loading
Loading