From e8e2f8174251095c2737bbab404ae48c6b4c41f1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 19:34:51 +0000 Subject: [PATCH] feat(events): render native hooks through warm runtime Compile semantic event routes into epoch-bound thin clients so native hooks reuse the generated MCP runtime, while preserving explicit standalone execution and fail-closed transport behavior. --- .changeset/event-route-transport.md | 7 + packages/agent-bundle/rslib.config.ts | 2 + .../src/adapters/capability-state.ts | 8 + packages/agent-bundle/src/adapters/claude.ts | 3 + packages/agent-bundle/src/adapters/codex.ts | 3 + packages/agent-bundle/src/adapters/cursor.ts | 3 + .../src/adapters/hook-contract.ts | 131 ++++++- packages/agent-bundle/src/adapters/plugin.ts | 4 +- packages/agent-bundle/src/build/build.ts | 11 +- packages/agent-bundle/src/build/entries.ts | 85 ++++- .../agent-bundle/src/build/entry-shell.ts | 72 +++- packages/agent-bundle/src/config/normalize.ts | 82 ++++- packages/agent-bundle/src/config/validate.ts | 24 +- packages/agent-bundle/src/core/types.ts | 21 +- packages/agent-bundle/src/events/ipc.ts | 348 ++++++++++++++++++ packages/agent-bundle/src/events/project.ts | 148 ++++++++ .../agent-bundle/tests/entry-shell.test.ts | 27 ++ packages/agent-bundle/tests/event-ipc.test.ts | 83 +++++ .../agent-bundle/tests/event-project.test.ts | 35 ++ .../tests/generated-route-server.test.ts | 171 ++++++++- .../tests/hook-playground-service.test.ts | 5 +- .../agent-bundle/tests/route-graph.test.ts | 24 +- .../tests/target-hook-contract.test.ts | 33 ++ 23 files changed, 1281 insertions(+), 49 deletions(-) create mode 100644 .changeset/event-route-transport.md create mode 100644 packages/agent-bundle/src/events/ipc.ts create mode 100644 packages/agent-bundle/src/events/project.ts create mode 100644 packages/agent-bundle/tests/event-ipc.test.ts create mode 100644 packages/agent-bundle/tests/event-project.test.ts diff --git a/.changeset/event-route-transport.md b/.changeset/event-route-transport.md new file mode 100644 index 000000000..fdbf3aa4e --- /dev/null +++ b/.changeset/event-route-transport.md @@ -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. diff --git a/packages/agent-bundle/rslib.config.ts b/packages/agent-bundle/rslib.config.ts index 87f3d5246..8cec8328e 100644 --- a/packages/agent-bundle/rslib.config.ts +++ b/packages/agent-bundle/rslib.config.ts @@ -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', diff --git a/packages/agent-bundle/src/adapters/capability-state.ts b/packages/agent-bundle/src/adapters/capability-state.ts index 3f46befa7..07f31518c 100644 --- a/packages/agent-bundle/src/adapters/capability-state.ts +++ b/packages/agent-bundle/src/adapters/capability-state.ts @@ -54,6 +54,14 @@ export const eventRouteCapabilitiesFrom = ( }), )); +export const supportedEventRouteNamesFrom = ( + routes: Readonly>, +): Readonly> => 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, diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 88fb14555..b28aa824d 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -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' }; @@ -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, diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts index 0385a19d7..b845da9c7 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -18,6 +18,7 @@ import { capabilityEvidence, capabilityStateFromSupport, eventRouteCapabilitiesFrom, + supportedEventRouteNamesFrom, supportedCapability, unavailableCapability, } from './capability-state.ts'; @@ -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, diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index 92ca646f6..6fbcf9636 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -17,6 +17,7 @@ import { capabilityEvidence, capabilityStateFromSupport, eventRouteCapabilitiesFrom, + supportedEventRouteNamesFrom, supportedCapability, unavailableCapability, } from './capability-state.ts'; @@ -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, diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 40fb09d3b..f912fad52 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -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, @@ -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. @@ -58,7 +60,8 @@ export interface TargetHookContract { canonicalEvent: CanonicalHookEvent, nativeEvent: string, ) => Readonly> | undefined; - readonly eventNames: Readonly>; + readonly eventNames: Readonly>>; + readonly eventRouteNames?: Readonly>>; /** * False when this contract plans host-document wrapper variants of hooks * whose canonical wrappers another contract already indexes; the canonical @@ -189,6 +192,9 @@ const canonicalEventOrder: readonly CanonicalHookEvent[] = [ 'beforeTool', 'afterTool', 'stop', + 'agentStart', + 'agentStop', + 'workspaceOpen', ]; export const canonicalHookEventFor = (event: string): CanonicalHookEvent | undefined => @@ -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)};`, @@ -558,7 +676,9 @@ export const planHooks = ( const groups: Record = Object.create(null) as Record; 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, @@ -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({ diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 9f1f65047..0cc1b92bf 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -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'; @@ -103,6 +103,7 @@ 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. @@ -110,6 +111,7 @@ const bundleHookContract: TargetHookContract = Object.freeze({ encodePlaygroundInput: encodeNativeHookPlaygroundInput, encodePlaygroundOutput: encodeNativeHookPlaygroundOutput, eventNames: claudeCapabilityTable.hooks.events, + eventRouteNames: supportedEventRouteNamesFrom(claudeCapabilityTable.hooks.eventRoutes), manifestPath: claudeArtifactPaths.hooksManifest, matchers: Object.freeze({ ...claudeMatchers, diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index 844958abb..5613b36bd 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -357,10 +357,19 @@ export const build = async (options: BuildOptions): Promise => { { 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, diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 3b8116189..c5f9b4778 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -1,8 +1,15 @@ +import { existsSync } from 'node:fs'; import { readFile, stat } from 'node:fs/promises'; -import { extname, relative, resolve } from 'node:path'; +import { dirname, extname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; -import type { TargetHookEntry } from '../adapters/types.ts'; -import type { AgentBundleToolsConfig, NormalizedMcpServer, NormalizedScript } from '../core/types.ts'; +import { + eventArtifactEpochToken, + eventIpcRuntimeSpecifier, + eventProjectRuntimeSpecifier, + type TargetHookEntry, +} from '../adapters/hook-contract.ts'; +import type { AgentBundleToolsConfig, NormalizedHook, NormalizedMcpServer, NormalizedScript } from '../core/types.ts'; import { mcpEntryAliasPattern } from '../config/normalize.ts'; import { stableJson } from '../core/digest.ts'; import { emitPlanEntries, resolveArtifactDestination } from './emit.ts'; @@ -20,6 +27,21 @@ import type { CompiledMcpApp } from './mcp-apps.ts'; import type { ArtifactOutputKind } from './provenance.ts'; import { buildWithRslib } from './rslib.ts'; +const eventRuntimeModulePath = (module: 'ipc' | 'project'): string => { + for (const candidate of [ + new URL(`./event-${module}.js`, import.meta.url), + new URL(`../../dist/event-${module}.js`, import.meta.url), + new URL(`../events/${module}.ts`, import.meta.url), + ]) { + const path = fileURLToPath(candidate); + if (existsSync(path)) return path; + } + throw new Error(`Unable to locate the compiler-owned event ${module} runtime module.`); +}; + +const eventRuntimeIgnoredRoot = (path: string): string => + resolve(dirname(path), path.replaceAll('\\', '/').includes('/dist/') ? '..' : '../..'); + export interface CompiledEntry { readonly name: string; readonly output: string; @@ -175,7 +197,9 @@ export const compileMcpEntries = async ( servers: readonly NormalizedMcpServer[], options: { readonly apps?: readonly CompiledMcpApp[]; + readonly artifactEpoch: string; readonly cwd: string; + readonly eventHooks: readonly NormalizedHook[]; readonly outDir: string; readonly plugin: { readonly name: string; readonly version: string }; readonly target: string; @@ -183,6 +207,8 @@ export const compileMcpEntries = async ( }, ): Promise => { const compiled = planCompiledMcpEntries(servers, options); + const eventHostId = compiled.find((entry) => + servers.find((server) => server.id === entry.id)?.generatedRoutes !== undefined)?.id; const virtualSources = await Promise.all(compiled.map(async (entry) => { const records = await Promise.all((options.apps ?? []) .filter((app) => app.serverIds.includes(entry.id)) @@ -206,9 +232,12 @@ export const compileMcpEntries = async ( return server?.generatedRoutes === undefined ? undefined : generatedRouteMcpEntrySource({ + artifactEpoch: options.artifactEpoch, + eventRoutes: entry.id === eventHostId ? options.eventHooks : [], plugin: options.plugin, routes: server.generatedRoutes, serverName: server.name, + target: options.target, workerFile: `${entry.name}-flight.mjs`, }); }); @@ -218,6 +247,7 @@ export const compileMcpEntries = async ( ? undefined : generatedRouteFlightWorkerSource({ artifactEpoch: generatedRouteArtifactEpoch(options.plugin), + eventRoutes: entry.id === eventHostId ? options.eventHooks : [], routes: server.generatedRoutes, serverName: server.name, }); @@ -241,11 +271,21 @@ export const compileMcpEntries = async ( : undefined; })); const runtimeShell = entryShells.some((shell) => shell !== undefined) ? mcpEntryRuntimePath() : undefined; + const eventIpcRuntime = options.eventHooks.length === 0 ? undefined : eventRuntimeModulePath('ipc'); + const eventProjectRuntime = options.eventHooks.length === 0 ? undefined : eventRuntimeModulePath('project'); const mainEntries = compiled.map(({ id, name, source, sourceInputs }, index) => ({ ...(entryShells[index] === undefined || runtimeShell === undefined ? {} : { - aliases: { [mcpEntryRuntimeSpecifier]: runtimeShell }, + aliases: { + [mcpEntryRuntimeSpecifier]: runtimeShell, + ...(id !== eventHostId || eventIpcRuntime === undefined || eventProjectRuntime === undefined + ? {} + : { + [eventIpcRuntimeSpecifier]: eventIpcRuntime, + [eventProjectRuntimeSpecifier]: eventProjectRuntime, + }), + }, virtualSource: entryShells[index], }), name, @@ -282,7 +322,14 @@ export const compileMcpEntries = async ( const evidence = await buildWithRslib({ cwd: options.cwd, entries: [...mainEntries, ...workerEntries], - ...(runtimeShell === undefined ? {} : { ignoredSourcePaths: [runtimeShell] }), + ...([runtimeShell, eventIpcRuntime].filter((path): path is string => path !== undefined).length === 0 + ? {} + : { + ignoredSourcePaths: [ + ...(runtimeShell === undefined ? [] : [runtimeShell]), + ...(eventIpcRuntime === undefined ? [] : [eventRuntimeIgnoredRoot(eventIpcRuntime)]), + ], + }), logLevel: 'error', outputRoot: options.outDir, ...(options.tools === undefined ? {} : { tools: options.tools }), @@ -315,9 +362,20 @@ export const planCompiledHooks = ( export const compileHooks = async ( entries: readonly TargetHookEntry[], - options: { readonly cwd: string; readonly outDir: string; readonly tools?: AgentBundleToolsConfig }, + options: { + readonly artifactEpoch: string; + readonly cwd: string; + readonly outDir: string; + readonly tools?: AgentBundleToolsConfig; + }, ): Promise => { const compiled = planCompiledHooks(entries, options); + const routeEntries = entries.filter((entry) => entry.hook.eventRoute !== undefined); + const eventIpcRuntime = routeEntries.length === 0 ? undefined : eventRuntimeModulePath('ipc'); + const eventProjectRuntime = routeEntries.some((entry) => + entry.hook.eventRoute?.runtime === 'standalone' || entry.hook.eventRoute?.fallback === 'standalone') + ? eventRuntimeModulePath('project') + : undefined; const evidence = await buildWithRslib({ cwd: options.cwd, entries: compiled.map((entry, index) => ({ @@ -326,10 +384,23 @@ export const compileHooks = async ( // 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 === undefined || eventIpcRuntime === undefined + ? {} + : { + aliases: { + [eventIpcRuntimeSpecifier]: eventIpcRuntime, + ...(eventProjectRuntime === undefined ? {} : { [eventProjectRuntimeSpecifier]: eventProjectRuntime }), + }, + }), source: entry.source, sourceInputs: entry.sourceInputs, - virtualSource: entries[index]!.virtualSource, + virtualSource: entries[index]!.virtualSource.replaceAll(eventArtifactEpochToken, options.artifactEpoch), })), + ...(eventIpcRuntime === undefined + ? {} + : { + ignoredSourcePaths: [eventRuntimeIgnoredRoot(eventIpcRuntime)], + }), outputRoot: options.outDir, ...(options.tools === undefined ? {} : { tools: options.tools }), }); diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 560680d23..fb8e9c5c2 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -1,7 +1,9 @@ import { existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; +import { eventIpcRuntimeSpecifier, eventProjectRuntimeSpecifier } from '../adapters/hook-contract.ts'; import { stableJson } from '../core/digest.ts'; +import type { NormalizedHook } from '../core/types.ts'; import type { CompiledAgentRoute, CompiledCliCommand } from '../routes/types.ts'; /** @@ -157,14 +159,18 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) }; export interface GeneratedRouteMcpEntryOptions { + readonly artifactEpoch?: string; + readonly eventRoutes?: readonly NormalizedHook[]; readonly plugin: { readonly name: string; readonly version: string }; readonly routes: readonly CompiledAgentRoute[]; readonly serverName: string; + readonly target?: string; readonly workerFile: string; } export interface GeneratedRouteFlightWorkerOptions { readonly artifactEpoch: string; + readonly eventRoutes?: readonly NormalizedHook[]; readonly routes: readonly CompiledAgentRoute[]; readonly serverName: string; } @@ -194,15 +200,29 @@ const routeRecords = (routes: readonly CompiledAgentRoute[]): readonly string[] routes.map((route, index) => ` ${JSON.stringify(route.id)}: Object.freeze({ config: ${stableJson(route.config)}, id: ${JSON.stringify(route.id)}, kind: ${JSON.stringify(route.kind)}, module: route${String(index)}, name: ${JSON.stringify(routeProtocolName(route))} }),`); +const eventRouteImports = ( + routes: readonly NormalizedHook[], + offset: number, +): readonly string[] => routes.map((route, index) => + `import * as route${String(offset + index)} from ${JSON.stringify(route.source)};`); + +const eventRouteRecords = ( + routes: readonly NormalizedHook[], + offset: number, +): readonly string[] => routes.map((route, index) => + ` ${JSON.stringify(route.id)}: Object.freeze({ event: ${JSON.stringify(route.eventRoute!.event)}, id: ${JSON.stringify(route.id)}, kind: 'event-route', module: route${String(offset + index)}, name: ${JSON.stringify(route.eventRoute!.event)} }),`); + /** The long-lived react-server worker used by one generated MCP process. */ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWorkerOptions): string => { const routes = executableMcpRoutes(options.routes); + const eventRoutes = options.eventRoutes ?? []; return [ "import { parentPort } from 'node:worker_threads';", "import { createElement } from 'react';", "import { renderAgentFlight } from '@agent-bundle/runtime/flight/server';", "import { runAgentRequest } from '@agent-bundle/runtime';", ...routeImports(routes), + ...eventRouteImports(eventRoutes, routes.length), '', '// Generated routes contain only intrinsic Agent protocol elements, so no client references exist.', 'globalThis.__rspack_rsc_manifest__ ??= Object.freeze({ clientManifest: Object.freeze({}) });', @@ -212,6 +232,7 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo 'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };', 'const routes = Object.freeze({', ...routeRecords(routes), + ...eventRouteRecords(eventRoutes, routes.length), '});', 'const requests = new Map();', '', @@ -220,21 +241,25 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo " parentPort.postMessage({ code: 'artifact-epoch-mismatch', id: message.id, message: `Runtime artifact epoch ${JSON.stringify(ARTIFACT_EPOCH)} does not match request epoch ${JSON.stringify(message.artifactEpoch)}`, receivedEpoch: message.artifactEpoch, type: 'error' });", ' return;', ' }', - ' const route = routes[message.invocation.props.operationId];', - " if (route === undefined || typeof route.module.default !== 'function') throw new TypeError('Generated MCP route must default-export an async Server Component.');", + " const routeId = message.invocation.kind === 'event' ? `hook:event-route:${message.invocation.props.event.replace('/', '-')}` : message.invocation.props.operationId;", + ' const route = routes[routeId];', + " if (route === undefined || typeof route.module.default !== 'function') throw new TypeError('Generated route must default-export an async Server Component.');", ' const controller = new AbortController();', ' requests.set(message.id, controller);', ' processLifetime.hits += 1;', ' try {', ' const bytes = await runAgentRequest({', ' ...(message.actor === undefined ? {} : { actor: message.actor }),', - ' invocation: { artifactEpoch: ARTIFACT_EPOCH, kind: \'tool\', operationId: route.id, surface: route.name },', + ' invocation: { artifactEpoch: ARTIFACT_EPOCH, kind: message.invocation.kind, operationId: route.id, surface: route.name },', ' progress: { report: async (update) => { parentPort.postMessage({ id: message.id, type: \'progress\', update }); } },', ' providers: { processLifetime: { hits: processLifetime.hits, instanceId: processLifetime.instanceId, pid: processLifetime.pid } },', ' ...(message.session === undefined ? {} : { session: message.session }),', ' signal: controller.signal,', ' }, async () => {', - ' const flight = renderAgentFlight(createElement(route.module.default, { input: message.invocation.props.input, signal: controller.signal }), { signal: controller.signal });', + " const props = message.invocation.kind === 'event'", + ' ? Object.freeze({ canonical: Object.freeze(message.invocation.props.payload.canonical), native: Object.freeze(message.invocation.props.payload.native), signal: controller.signal })', + ' : { input: message.invocation.props.input, signal: controller.signal };', + ' const flight = renderAgentFlight(createElement(route.module.default, props), { signal: controller.signal });', ' return new Uint8Array(await new Response(flight).arrayBuffer());', ' });', ' parentPort.postMessage({ bytes, id: message.id, type: \'complete\' }, [bytes.buffer]);', @@ -319,10 +344,18 @@ const routeRegistrations = (routes: readonly CompiledAgentRoute[]): readonly str export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOptions): string => { const routes = executableMcpRoutes(options.routes); const artifactEpoch = generatedRouteArtifactEpoch(options.plugin); + const hasEvents = (options.eventRoutes?.length ?? 0) > 0; return [ + ...(hasEvents ? ["import { dirname, resolve } from 'node:path';"] : []), "import { Worker } from 'node:worker_threads';", "import { McpServer } from '@modelcontextprotocol/server';", "import { AgentRuntimeError, agent, attachMcpStructuredContent, available, createAgentRenderDispatcher, createWarmFlightHost, projectMcpRenderStream, runAgentRequest } from '@agent-bundle/runtime';", + ...(hasEvents + ? [ + `import { createEventRuntimeServer } from ${JSON.stringify(eventIpcRuntimeSpecifier)};`, + `import { createCanonicalEventProps, projectEventDocument } from ${JSON.stringify(eventProjectRuntimeSpecifier)};`, + ] + : []), "import mcpApps from 'agent-bundle/mcp-apps';", ...routeImports(routes), '', @@ -407,16 +440,43 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti ' return { document: projected.document, result: route.module.resultSchema.parse(projected.document.value), toolResult: projected.result };', '});', '', - 'const createGeneratedRouteServer = () => {', + 'const createGeneratedRouteServer = async () => {', ` const server = new McpServer(${stableJson(options.plugin)});`, ' const workerHost = createWorkerHost();', ' const dispatcher = createAgentRenderDispatcher(workerHost);', + ...(hasEvents + ? [ + ` const artifactEpoch = ${JSON.stringify(options.artifactEpoch ?? 'unknown')};`, + ` const target = ${JSON.stringify(options.target ?? 'unknown')};`, + " const endpointId = `${artifactEpoch}:${target}:${dirname(dirname(resolve(process.argv[1])))}`;", + ' const eventRuntime = await createEventRuntimeServer({', + ' artifactEpoch,', + ' endpointId,', + ' handle: async (request) => {', + " const nativeEvent = typeof request.native.hook_event_name === 'string' ? request.native.hook_event_name : request.event;", + ' const controller = new AbortController();', + ' const props = createCanonicalEventProps(request.event, request.native, target, nativeEvent, request.hostContractRevision, controller.signal);', + ' const sessionId = typeof request.native.session_id === \'string\' ? request.native.session_id : typeof request.native.conversation_id === \'string\' ? request.native.conversation_id : undefined;', + ' return runAgentRequest({', + " host: available({ name: target }, 'native'),", + " invocation: { artifactEpoch, hostContractRevision: request.hostContractRevision, kind: 'event', operationId: `event:${request.event}`, surface: request.event },", + " ...(sessionId === undefined ? {} : { session: available({ sessionId }, 'native') }),", + ' signal: controller.signal,', + " ...(typeof request.native.cwd === 'string' ? { workspace: available({ root: request.native.cwd }, 'native') } : {}),", + ' }, async () => {', + " const document = await dispatcher.dispatch({ invocation: { kind: 'event', props: { event: request.event, payload: { canonical: props.canonical, native: props.native } } }, signal: controller.signal });", + ' return projectEventDocument(document, request.event, target, nativeEvent);', + ' });', + ' },', + ' });', + ] + : []), ...routeRegistrations(routes), ' for (const app of mcpApps) {', ' server.registerResource(app.name, app.resourceUri, { ...(app._meta === undefined ? {} : { _meta: app._meta }), mimeType: app.mimeType }, async (uri) => ({ contents: [{ mimeType: app.mimeType, text: app.html, uri: uri.href }] }));', ' }', ' const close = server.close.bind(server);', - ' server.close = async () => { await workerHost.close(); await close(); };', + ` server.close = async () => { ${hasEvents ? 'await eventRuntime.close(); ' : ''}await workerHost.close(); await close(); };`, ' return server;', '};', '', diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 3d3a8e920..7495dfd29 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -14,7 +14,12 @@ import { } from '../core/runtime.ts'; import { snapshotPackageIdentity } from '../core/project-context.ts'; import { isRecord } from '../core/strict-json.ts'; -import { isPrebuiltEntryInput, parseNativeHookToolSelector, pathTokens } from '../core/types.ts'; +import { + canonicalHookEvents, + isPrebuiltEntryInput, + parseNativeHookToolSelector, + pathTokens, +} from '../core/types.ts'; import type { AgentBundleBinEntry, AgentBundleConfig, @@ -47,6 +52,7 @@ import type { import type { CompiledCliSurface } from '../routes/types.ts'; import { type DiscoveredProject, payloadDeclarationSource } from './discover.ts'; import type { LoadedConfig } from './load.ts'; +import type { CanonicalAgentEvent } from '../routes/public.ts'; import { configuredScriptNames, judgeScriptRoute, scriptRouteName } from './script-routes.ts'; const unique = (values: readonly string[]): string[] => [...new Set(values)]; @@ -54,12 +60,17 @@ const unique = (values: readonly string[]): string[] => [...new Set(values)]; const sortedUnique = (values: readonly string[]): string[] => unique(values).sort((left, right) => left.localeCompare(right)); -const hookEvents: readonly CanonicalHookEvent[] = [ - 'sessionStart', - 'beforeTool', - 'afterTool', - 'stop', -]; +const hookEvents: readonly CanonicalHookEvent[] = canonicalHookEvents; + +const hookEventForRoute: Readonly> = Object.freeze({ + 'agent/start': 'agentStart', + 'agent/stop': 'agentStop', + 'session/start': 'sessionStart', + 'stop': 'stop', + 'tool/after': 'afterTool', + 'tool/before': 'beforeTool', + 'workspace/open': 'workspaceOpen', +}); const knownHookTools = new Set([ 'shell', @@ -396,28 +407,61 @@ const normalizeHook = ( const normalizeHooks = ( loaded: LoadedConfig, + discovered: DiscoveredProject, targetNames: readonly string[], registry: NormalizationTargetRegistry, payloads: readonly NormalizedPayload[], ): readonly NormalizedHook[] => { const hooks: NormalizedHook[] = []; const config = loaded.config.hooks; - if (config === undefined) return hooks; const provenance: SourceProvenance = { kind: 'config', sourcePath: loaded.configPath }; - for (const event of hookEvents) { - const input = config[event]; - if (input === undefined) continue; - for (const entry of asEntries(input)) { - const inherited = typeof entry === 'string' || entry.targets === undefined; - const hookTargets = inherited - ? targetNames.filter((target) => registry.supports(target, 'hooks')) - : targetNames; - hooks.push(normalizeHook(event, entry, loaded.context.projectRoot, hookTargets, provenance, registry, payloads)); + if (config !== undefined) { + for (const event of hookEvents) { + const input = config[event]; + if (input === undefined) continue; + for (const entry of asEntries(input)) { + const inherited = typeof entry === 'string' || entry.targets === undefined; + const hookTargets = inherited + ? targetNames.filter((target) => registry.supports(target, 'hooks')) + : targetNames; + hooks.push(normalizeHook(event, entry, loaded.context.projectRoot, hookTargets, provenance, registry, payloads)); + } } } - return hooks; + for (const route of discovered.routeGraph?.events ?? []) { + const event = route.event!; + const selected = route.config['targets']; + const targets = sortedUnique( + (Array.isArray(selected) ? selected.filter((target): target is string => typeof target === 'string') : targetNames) + .filter((target) => targetNames.includes(target)), + ); + const tools = (Array.isArray(route.config['tools']) ? route.config['tools'] : []) + .filter((tool): tool is CanonicalHookTool => + typeof tool === 'string' && knownHookTools.has(tool as CanonicalHookTool)) + .sort((left, right) => left.localeCompare(right)); + const timeoutMs = route.config['timeoutMs']; + const timeout = typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0 + ? Math.ceil(timeoutMs / 1_000) + : undefined; + const fallback = route.config['fallback'] === 'standalone' ? 'standalone' as const : 'none' as const; + const runtime = route.config['runtime'] === 'standalone' ? 'standalone' as const : 'shared' as const; + const eventName = event.replace('/', '-'); + hooks.push({ + event: hookEventForRoute[event], + eventRoute: Object.freeze({ event, fallback, runtime }), + id: `hook:event-route:${eventName}`, + name: `event-route-${eventName}`, + provenance: { kind: 'conventional', sourcePath: route.source }, + source: route.source, + targets, + ...(timeout === undefined ? {} : { timeout }), + tools, + }); + } + + return hooks.sort((left, right) => left.id.localeCompare(right.id)); }; const normalizeNativeHooks = async ( @@ -896,7 +940,7 @@ export const normalizeProject = async ( }, mcpApps: normalizeMcpApps(loaded, discovered, mcpServers), mcpServers, - hooks: normalizeHooks(loaded, targetNames, registry, payloads), + hooks: normalizeHooks(loaded, discovered, targetNames, registry, payloads), ...(nativeHooks.length === 0 ? {} : { nativeHooks }), ...(packageBuild === undefined ? {} : { packageBuild }), ...(payloads.length === 0 ? {} : { payloads }), diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index c9b7261f7..7c158b575 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -14,7 +14,7 @@ import { parseRuntimeVersion, satisfiesGeneratedRuntimeFloor, } from '../core/runtime.ts'; -import { isPrebuiltEntryInput, parseNativeHookToolSelector } from '../core/types.ts'; +import { canonicalHookEvents, isPrebuiltEntryInput, parseNativeHookToolSelector } from '../core/types.ts'; import { isRenderedCliRoute } from '../routes/cli-commands.ts'; import type { AgentBundleBinEntry, @@ -61,12 +61,7 @@ const nudgeDiagnostic = ( recovery: string, ): Diagnostic => ({ code, message, recovery, severity: 'info', sourcePath }); -const hookEvents: readonly CanonicalHookEvent[] = [ - 'sessionStart', - 'beforeTool', - 'afterTool', - 'stop', -]; +const hookEvents: readonly CanonicalHookEvent[] = canonicalHookEvents; const hookTools = new Set(['shell', 'file.read', 'file.write', 'mcp', 'agent']); @@ -1718,6 +1713,21 @@ export const validateModel = ( }); } } + if (hook.eventRoute?.runtime === 'shared' && hook.eventRoute.fallback === 'none') { + for (const target of hook.targets) { + const runtimeHost = model.mcpServers.some((server) => + server.generatedRoutes !== undefined && server.targets.includes(target)); + if (runtimeHost) continue; + diagnostics.push({ + code: 'AB4816', + message: `Event route ${hook.eventRoute.event} requires the shared runtime on ${target}, but no generated MCP entry hosts it.`, + recovery: 'Add a generated MCP route server, or explicitly set event config.runtime to standalone or config.fallback to standalone.', + severity: 'error', + sourcePath: hook.provenance.sourcePath, + target, + }); + } + } } for (const nativeHook of model.nativeHooks ?? []) { diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index 6e4a72953..ba050cf21 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -1,5 +1,10 @@ import type { EnvironmentConfig } from '@rsbuild/core'; +import type { + AgentEventFallbackMode, + AgentEventRuntimeMode, + CanonicalAgentEvent, +} from '../routes/public.ts'; import type { CompiledAgentRoute, CompiledCliCommand } from '../routes/types.ts'; import type { CapabilityState } from './capabilities.ts'; @@ -10,7 +15,15 @@ export interface AgentBundlePluginConfig { [key: string]: unknown; } -export const canonicalHookEvents = Object.freeze(['sessionStart', 'beforeTool', 'afterTool', 'stop'] as const); +export const canonicalHookEvents = Object.freeze([ + 'sessionStart', + 'beforeTool', + 'afterTool', + 'stop', + 'agentStart', + 'agentStop', + 'workspaceOpen', +] as const); export type CanonicalHookEvent = (typeof canonicalHookEvents)[number]; @@ -383,6 +396,12 @@ export interface NormalizedHook { /** Extra command arguments appended after the handler path; prebuilt hooks only. */ readonly args?: readonly string[]; readonly event: CanonicalHookEvent; + /** Filesystem event-route execution metadata; absent for config-declared hook escape hatches. */ + readonly eventRoute?: Readonly<{ + readonly event: CanonicalAgentEvent; + readonly fallback: AgentEventFallbackMode; + readonly runtime: AgentEventRuntimeMode; + }>; readonly id: string; readonly name: string; /** Host-native tools selected explicitly per target, alongside the canonical selectors. */ diff --git a/packages/agent-bundle/src/events/ipc.ts b/packages/agent-bundle/src/events/ipc.ts new file mode 100644 index 000000000..e632a3df4 --- /dev/null +++ b/packages/agent-bundle/src/events/ipc.ts @@ -0,0 +1,348 @@ +import { createHash } from 'node:crypto'; +import { chmod, mkdir, rm } from 'node:fs/promises'; +import { createConnection, createServer, type Server, type Socket } from 'node:net'; +import { dirname, join } from 'node:path'; + +import { Context, Duration, Effect, Layer } from 'effect'; +import { z } from 'zod'; + +import { makeScopedEffectRuntime, runPromise, type ScopedEffectRuntime } from '../effect/boundary.ts'; +import { liftPromise } from '../effect/lift.ts'; + +const EVENT_RUNTIME_PROTOCOL_VERSION = 1 as const; +const MAX_EVENT_MESSAGE_BYTES = 1024 * 1024; + +export type EventRuntimeTransportErrorCode = + | 'epoch-mismatch' + | 'invalid-message' + | 'runtime-failed' + | 'runtime-timeout' + | 'runtime-unavailable'; + +export class EventRuntimeTransportError extends Error { + readonly code: EventRuntimeTransportErrorCode; + + constructor(code: EventRuntimeTransportErrorCode, message: string, options?: ErrorOptions) { + super(message, options); + this.code = code; + this.name = 'EventRuntimeTransportError'; + } +} + +const eventRequestSchema = z.object({ + artifactEpoch: z.string().min(1), + event: z.string().min(1), + hostContractRevision: z.string().min(1), + native: z.record(z.string(), z.unknown()), + protocolVersion: z.literal(EVENT_RUNTIME_PROTOCOL_VERSION), + target: z.string().min(1), +}).strict(); + +const eventResponseSchema = z.discriminatedUnion('status', [ + z.object({ + artifactEpoch: z.string().min(1), + output: z.unknown().optional(), + protocolVersion: z.literal(EVENT_RUNTIME_PROTOCOL_VERSION), + status: z.literal('ok'), + }).strict(), + z.object({ + artifactEpoch: z.string().min(1), + code: z.enum(['epoch-mismatch', 'invalid-message', 'runtime-failed']), + message: z.string(), + protocolVersion: z.literal(EVENT_RUNTIME_PROTOCOL_VERSION), + status: z.literal('error'), + }).strict(), +]); + +export interface EventRuntimeRequest { + readonly artifactEpoch: string; + readonly event: string; + readonly hostContractRevision: string; + readonly native: Readonly>; + readonly target: string; +} + +export interface CreateEventRuntimeServerOptions { + readonly artifactEpoch: string; + readonly endpointId: string; + readonly handle: (request: EventRuntimeRequest) => Promise; +} + +export interface EventRuntimeServer { + readonly close: () => Promise; + readonly endpoint: string; +} + +export interface RequestEventRuntimeOptions extends EventRuntimeRequest { + readonly endpointId: string; + readonly signal: AbortSignal; + readonly timeoutMs: number; +} + +export const eventRuntimeEndpoint = (endpointId: string): string => { + const hash = createHash('sha256').update(endpointId, 'utf8').digest('hex').slice(0, 32); + if (process.platform === 'win32') return `\\\\.\\pipe\\agent-bundle-event-${hash}`; + const user = typeof process.getuid === 'function' ? String(process.getuid()) : 'user'; + return join('/tmp', `agent-bundle-${user}`, `event-${hash}.sock`); +}; + +const transportError = ( + code: EventRuntimeTransportErrorCode, + message: string, + cause?: unknown, +): EventRuntimeTransportError => new EventRuntimeTransportError( + code, + message, + cause === undefined ? undefined : { cause }, +); + +const writeResponse = (socket: Socket, value: unknown): void => { + if (!socket.destroyed) socket.end(`${JSON.stringify(value)}\n`); +}; + +const readOneMessage = Effect.fnUntraced(function*( + socket: Socket, +): Effect.fn.Return { + return yield* Effect.callback((resume) => { + let raw = ''; + const cleanup = (): void => { + socket.removeListener('data', onData); + socket.removeListener('end', onEnd); + socket.removeListener('error', onError); + }; + const finish = (effect: Effect.Effect): void => { + cleanup(); + resume(effect); + }; + const onData = (chunk: Buffer): void => { + raw += chunk.toString('utf8'); + if (Buffer.byteLength(raw) > MAX_EVENT_MESSAGE_BYTES) { + finish(Effect.fail(transportError('invalid-message', 'Event runtime message exceeds the 1 MiB limit.'))); + socket.destroy(); + } + }; + const onEnd = (): void => { + try { + finish(Effect.succeed(JSON.parse(raw))); + } catch (error) { + finish(Effect.fail(transportError('invalid-message', 'Event runtime message must be one JSON value.', error))); + } + }; + const onError = (error: Error): void => { + finish(Effect.fail(transportError('runtime-failed', 'Event runtime socket failed.', error))); + }; + socket.on('data', onData); + socket.once('end', onEnd); + socket.once('error', onError); + return Effect.sync(() => { + cleanup(); + socket.destroy(); + }); + }); +}); + +const handleConnection = Effect.fnUntraced(function*( + socket: Socket, + options: CreateEventRuntimeServerOptions, +): Effect.fn.Return { + const raw = yield* readOneMessage(socket).pipe(Effect.exit); + if (raw._tag === 'Failure') { + writeResponse(socket, { + artifactEpoch: options.artifactEpoch, + code: 'invalid-message', + message: 'Event runtime request is invalid.', + protocolVersion: EVENT_RUNTIME_PROTOCOL_VERSION, + status: 'error', + }); + return; + } + const parsed = eventRequestSchema.safeParse(raw.value); + if (!parsed.success) { + writeResponse(socket, { + artifactEpoch: options.artifactEpoch, + code: 'invalid-message', + message: 'Event runtime request does not match the wire schema.', + protocolVersion: EVENT_RUNTIME_PROTOCOL_VERSION, + status: 'error', + }); + return; + } + if (parsed.data.artifactEpoch !== options.artifactEpoch) { + writeResponse(socket, { + artifactEpoch: options.artifactEpoch, + code: 'epoch-mismatch', + message: 'Event runtime artifact epoch does not match the hook client.', + protocolVersion: EVENT_RUNTIME_PROTOCOL_VERSION, + status: 'error', + }); + return; + } + const handled = yield* liftPromise(() => options.handle({ + artifactEpoch: parsed.data.artifactEpoch, + event: parsed.data.event, + hostContractRevision: parsed.data.hostContractRevision, + native: parsed.data.native, + target: parsed.data.target, + })).pipe(Effect.exit); + if (handled._tag === 'Failure') { + writeResponse(socket, { + artifactEpoch: options.artifactEpoch, + code: 'runtime-failed', + message: 'Event route rendering failed.', + protocolVersion: EVENT_RUNTIME_PROTOCOL_VERSION, + status: 'error', + }); + return; + } + writeResponse(socket, { + artifactEpoch: options.artifactEpoch, + output: handled.value, + protocolVersion: EVENT_RUNTIME_PROTOCOL_VERSION, + status: 'ok', + }); +}); + +interface EventSocketServiceShape { + readonly endpoint: string; + readonly server: Server; + readonly sockets: Set; +} + +class EventSocketService extends Context.Service()( + 'agent-bundle/events/EventSocketService', +) {} + +const openServer = ( + options: CreateEventRuntimeServerOptions, +): Effect.Effect => Effect.gen(function*() { + const endpoint = eventRuntimeEndpoint(options.endpointId); + if (process.platform !== 'win32') { + yield* liftPromise(async () => { + await mkdir(dirname(endpoint), { mode: 0o700, recursive: true }); + await chmod(dirname(endpoint), 0o700); + await rm(endpoint, { force: true }); + }).pipe( + Effect.mapError((error) => transportError('runtime-failed', 'Unable to prepare the event runtime endpoint.', error)), + ); + } + return yield* Effect.callback((resume) => { + const sockets = new Set(); + const server = createServer({ allowHalfOpen: true }, (socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + void runPromise(handleConnection(socket, options)); + }); + const onError = (error: Error): void => { + resume(Effect.fail(transportError('runtime-failed', 'Unable to listen on the event runtime endpoint.', error))); + }; + server.once('error', onError); + server.listen(endpoint, () => { + server.removeListener('error', onError); + if (process.platform === 'win32') { + resume(Effect.succeed({ endpoint, server, sockets })); + return; + } + void chmod(endpoint, 0o600).then( + () => resume(Effect.succeed({ endpoint, server, sockets })), + (error: unknown) => { + server.close(); + resume(Effect.fail(transportError('runtime-failed', 'Unable to secure the event runtime endpoint.', error))); + }, + ); + }); + return Effect.sync(() => { + for (const socket of sockets) socket.destroy(); + server.close(); + }); + }); +}); + +const closeServer = (service: EventSocketServiceShape): Effect.Effect => + Effect.callback((resume) => { + for (const socket of service.sockets) socket.destroy(); + if (!service.server.listening) { + resume(Effect.void); + return undefined; + } + service.server.close(() => resume(Effect.void)); + return undefined; + }).pipe( + Effect.ensuring( + process.platform === 'win32' + ? Effect.void + : liftPromise(() => rm(service.endpoint, { force: true })).pipe(Effect.ignore), + ), + ); + +const eventSocketLayer = (options: CreateEventRuntimeServerOptions): Layer.Layer => + Layer.effect(EventSocketService, Effect.acquireRelease(openServer(options), closeServer)); + +export const createEventRuntimeServer = async ( + options: CreateEventRuntimeServerOptions, +): Promise => { + const runtime: ScopedEffectRuntime = makeScopedEffectRuntime(eventSocketLayer(options)); + const service = await runtime.run(EventSocketService); + return Object.freeze({ + close: () => runtime.close(), + endpoint: service.endpoint, + }); +}; + +const connect = (endpoint: string): Effect.Effect => + Effect.callback((resume) => { + const socket = createConnection(endpoint); + const onConnect = (): void => { + socket.removeListener('error', onError); + resume(Effect.succeed(socket)); + }; + const onError = (error: Error): void => { + socket.removeListener('connect', onConnect); + resume(Effect.fail(transportError('runtime-unavailable', 'Shared event runtime is unavailable.', error))); + }; + socket.once('connect', onConnect); + socket.once('error', onError); + return Effect.sync(() => { + socket.removeListener('connect', onConnect); + socket.removeListener('error', onError); + socket.destroy(); + }); + }); + +const requestProgram = ( + options: RequestEventRuntimeOptions, +): Effect.Effect => Effect.acquireUseRelease( + connect(eventRuntimeEndpoint(options.endpointId)), + (socket) => Effect.gen(function*() { + socket.end(`${JSON.stringify({ + artifactEpoch: options.artifactEpoch, + event: options.event, + hostContractRevision: options.hostContractRevision, + native: options.native, + protocolVersion: EVENT_RUNTIME_PROTOCOL_VERSION, + target: options.target, + })}\n`); + const raw = yield* readOneMessage(socket); + const response = eventResponseSchema.safeParse(raw); + if (!response.success) { + return yield* Effect.fail(transportError('invalid-message', 'Event runtime response does not match the wire schema.')); + } + if (response.data.artifactEpoch !== options.artifactEpoch || response.data.status === 'error' && response.data.code === 'epoch-mismatch') { + return yield* Effect.fail(transportError('epoch-mismatch', 'Shared event runtime artifact epoch mismatch.')); + } + if (response.data.status === 'error') { + return yield* Effect.fail(transportError('runtime-failed', response.data.message)); + } + return response.data.output; + }), + (socket) => Effect.sync(() => socket.destroy()), +).pipe( + Effect.raceFirst( + Effect.sleep(Duration.millis(options.timeoutMs)).pipe( + Effect.andThen(Effect.fail(transportError('runtime-timeout', 'Shared event runtime exceeded its deadline.'))), + ), + ), +); + +export const requestEventRuntime = async ( + options: RequestEventRuntimeOptions, +): Promise => runPromise(requestProgram(options), { signal: options.signal }); diff --git a/packages/agent-bundle/src/events/project.ts b/packages/agent-bundle/src/events/project.ts new file mode 100644 index 000000000..848fb3ad1 --- /dev/null +++ b/packages/agent-bundle/src/events/project.ts @@ -0,0 +1,148 @@ +import { createHash } from 'node:crypto'; + +import { Children, cloneElement, isValidElement, type ReactNode } from 'react'; +import { z } from 'zod'; + +import { decodeAgentDocument, type AgentDocument, type AgentDocumentNode } from '@agent-bundle/runtime'; +import type { + AgentEventCanonicalIdentity, + AgentEventRouteProps, + CanonicalAgentEvent, +} from '../routes/public.ts'; + +const resultValueSchema = z.object({ + outcome: z.enum(['continue', 'deny']).optional(), + reason: z.string().min(1).optional(), + updatedInput: z.record(z.string(), z.unknown()).optional(), +}).strict(); + +let eventSequence = 0; + +const snapshotNative = (native: Readonly>): Readonly> => + Object.freeze(structuredClone(native)); + +const resolveServerNode = async (node: ReactNode): Promise => { + if (Array.isArray(node)) return Promise.all(node.map(resolveServerNode)); + if (!isValidElement>(node)) return node; + const type: unknown = node.type; + if (type === Symbol.for('react.fragment')) { + return Promise.all(Children.toArray(node.props.children as ReactNode).map(resolveServerNode)); + } + if (typeof type === 'function') { + const component = type as (props: Record) => ReactNode | Promise; + return resolveServerNode(await component(node.props)); + } + if (typeof type !== 'string') { + throw new TypeError('Standalone event routes may render only Server Components and Agent protocol elements.'); + } + const children = await Promise.all(Children.toArray(node.props.children as ReactNode).map(resolveServerNode)); + return cloneElement(node, undefined, ...children); +}; + +export const renderStandaloneEventRoute = async ( + component: (props: AgentEventRouteProps) => ReactNode | Promise, + props: AgentEventRouteProps, +): Promise => decodeAgentDocument(await resolveServerNode(await component(props))); + +export const createCanonicalEventProps = ( + event: CanonicalAgentEvent, + nativeInput: Readonly>, + target: string, + nativeEvent: string, + hostContractRevision: string, + signal: AbortSignal, +): AgentEventRouteProps => { + const native = snapshotNative(nativeInput); + const canonical: AgentEventCanonicalIdentity = Object.freeze({ + event, + idempotencyKey: createHash('sha256') + .update(JSON.stringify({ event, native, target }), 'utf8') + .digest('hex'), + observedAt: new Date().toISOString(), + provenance: Object.freeze({ + host: target, + hostContractRevision, + nativeEvent, + source: 'native', + }), + sequence: ++eventSequence, + }); + return Object.freeze({ canonical, native, signal }); +}; + +const appendContext = (node: AgentDocumentNode, contexts: string[]): void => { + switch (node.kind) { + case 'result': + for (const child of node.children) appendContext(child, contexts); + break; + case 'context': + contexts.push(node.text); + break; + case 'audio': + case 'error': + case 'image': + case 'json': + case 'markdown': + case 'progress': + case 'resource': + case 'text': + break; + default: { + const exhaustive: never = node; + return exhaustive; + } + } +}; + +export const projectEventDocument = ( + document: AgentDocument, + event: CanonicalAgentEvent, + target: string, + nativeEvent: string, +): Readonly> | undefined => { + const contexts: string[] = []; + appendContext(document.root, contexts); + const additionalContext = contexts.length === 0 ? undefined : contexts.join(''); + const parsedValue = document.value === undefined ? undefined : resultValueSchema.parse(document.value); + + if (event === 'stop') { + if (parsedValue?.outcome !== 'deny') return undefined; + return target === 'cursor' + ? Object.freeze({ followup_message: parsedValue.reason }) + : Object.freeze({ decision: 'block', reason: parsedValue.reason }); + } + if (event === 'tool/before') { + if (target === 'cursor') { + if (parsedValue?.outcome === 'deny') { + return Object.freeze({ + agent_message: parsedValue.reason, + permission: 'deny', + user_message: parsedValue.reason, + }); + } + return parsedValue?.updatedInput === undefined + ? undefined + : Object.freeze({ permission: 'allow', updated_input: parsedValue.updatedInput }); + } + const output = { + ...(additionalContext === undefined ? {} : { additionalContext }), + hookEventName: nativeEvent, + permissionDecision: parsedValue?.outcome === 'deny' ? 'deny' : 'allow', + ...(parsedValue?.reason === undefined ? {} : { permissionDecisionReason: parsedValue.reason }), + ...(parsedValue?.updatedInput === undefined ? {} : { updatedInput: parsedValue.updatedInput }), + }; + return Object.freeze({ hookSpecificOutput: Object.freeze(output) }); + } + if (event === 'session/start' || event === 'tool/after') { + if (additionalContext === undefined) return undefined; + return target === 'cursor' + ? Object.freeze({ additional_context: additionalContext }) + : Object.freeze({ + hookSpecificOutput: Object.freeze({ + additionalContext, + hookEventName: nativeEvent, + }), + }); + } + return undefined; +}; diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 51e7f9a1c..e3a47e7e0 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -96,6 +96,17 @@ it('generates one final-only Flight MCP factory from filesystem routes', () => { if (generate === undefined) return; const source = generate({ + artifactEpoch: 'epoch-1', + eventRoutes: [{ + event: 'afterTool', + eventRoute: { event: 'tool/after', fallback: 'none', runtime: 'shared' }, + id: 'hook:event-route:tool-after', + name: 'event-route-tool-after', + provenance: { kind: 'conventional', sourcePath: '/project/src/events/tool/after.tsx' }, + source: '/project/src/events/tool/after.tsx', + targets: ['claude'], + tools: [], + }], plugin: { name: 'route-fixture', version: '1.2.3' }, routes: [ { @@ -118,6 +129,7 @@ it('generates one final-only Flight MCP factory from filesystem routes', () => { }, ], serverName: 'curator', + target: 'claude', workerFile: 'mcp-curator-flight.mjs', }); @@ -131,6 +143,9 @@ it('generates one final-only Flight MCP factory from filesystem routes', () => { expect(source).toContain('{ stderr: true, stdout: true }'); expect(source).toContain('projectMcpRenderStream'); expect(source).toContain('attachMcpStructuredContent'); + expect(source).toContain('createEventRuntimeServer'); + expect(source).toContain("kind: 'event'"); + expect(source).toContain('projectEventDocument'); expect(source).toContain('runAgentRequest'); expect(source).toContain('notifications/progress'); expect(source).toContain('ARTIFACT_EPOCH'); @@ -152,6 +167,16 @@ it('generates the warm react-server Flight worker separately from the MCP dispat if (generate === undefined) return; const source = generate({ artifactEpoch: 'route-fixture@1.2.3', + eventRoutes: [{ + event: 'afterTool', + eventRoute: { event: 'tool/after', fallback: 'none', runtime: 'shared' }, + id: 'hook:event-route:tool-after', + name: 'event-route-tool-after', + provenance: { kind: 'conventional', sourcePath: '/project/src/events/tool/after.tsx' }, + source: '/project/src/events/tool/after.tsx', + targets: ['claude'], + tools: [], + }], routes: [{ config: {}, id: 'tool:curator/inspect', @@ -166,4 +191,6 @@ it('generates the warm react-server Flight worker separately from the MCP dispat expect(source).toContain('processLifetime'); expect(source).toContain('route-fixture@1.2.3'); expect(source).toContain('/project/src/mcp/curator/tools/inspect.tsx'); + expect(source).toContain('/project/src/events/tool/after.tsx'); + expect(source).toContain("message.invocation.kind === 'event'"); }); diff --git a/packages/agent-bundle/tests/event-ipc.test.ts b/packages/agent-bundle/tests/event-ipc.test.ts new file mode 100644 index 000000000..177c7fbd0 --- /dev/null +++ b/packages/agent-bundle/tests/event-ipc.test.ts @@ -0,0 +1,83 @@ +import { stat } from 'node:fs/promises'; + +import { expect, it } from '@rstest/core'; + +import { + createEventRuntimeServer, + EventRuntimeTransportError, + requestEventRuntime, +} from '../src/events/ipc.ts'; + +it('round-trips a bounded event envelope through the epoch-bound runtime socket', async () => { + const endpointId = `event-ipc-${crypto.randomUUID()}`; + const server = await createEventRuntimeServer({ + artifactEpoch: 'epoch-1', + endpointId, + handle: async (request) => ({ + echoed: request.native, + event: request.event, + }), + }); + + try { + if (process.platform !== 'win32') { + expect((await stat(server.endpoint)).mode & 0o777).toBe(0o600); + } + await expect(requestEventRuntime({ + artifactEpoch: 'epoch-1', + endpointId, + event: 'tool/after', + hostContractRevision: '2.1.250', + native: { hook_event_name: 'PostToolUse', tool_name: 'Write' }, + signal: new AbortController().signal, + target: 'claude', + timeoutMs: 1_000, + })).resolves.toEqual({ + echoed: { hook_event_name: 'PostToolUse', tool_name: 'Write' }, + event: 'tool/after', + }); + } finally { + await server.close(); + } +}); + +it('fails closed on artifact epoch mismatch and missing runtimes', async () => { + const endpointId = `event-ipc-${crypto.randomUUID()}`; + const server = await createEventRuntimeServer({ + artifactEpoch: 'epoch-1', + endpointId, + handle: async () => undefined, + }); + + try { + await expect(requestEventRuntime({ + artifactEpoch: 'epoch-2', + endpointId, + event: 'session/start', + hostContractRevision: '2.1.250', + native: { hook_event_name: 'SessionStart' }, + signal: new AbortController().signal, + target: 'claude', + timeoutMs: 1_000, + })).rejects.toMatchObject({ + code: 'epoch-mismatch', + name: EventRuntimeTransportError.name, + }); + } finally { + await server.close(); + } + + await expect(requestEventRuntime({ + artifactEpoch: 'epoch-1', + endpointId, + event: 'session/start', + hostContractRevision: '2.1.250', + native: { hook_event_name: 'SessionStart' }, + signal: new AbortController().signal, + target: 'claude', + timeoutMs: 100, + })).rejects.toMatchObject({ + code: 'runtime-unavailable', + name: EventRuntimeTransportError.name, + }); +}); diff --git a/packages/agent-bundle/tests/event-project.test.ts b/packages/agent-bundle/tests/event-project.test.ts new file mode 100644 index 000000000..502d4be0f --- /dev/null +++ b/packages/agent-bundle/tests/event-project.test.ts @@ -0,0 +1,35 @@ +import { Agent } from '@agent-bundle/runtime'; +import { expect, it } from '@rstest/core'; +import { createElement } from 'react'; + +import { + createCanonicalEventProps, + projectEventDocument, + renderStandaloneEventRoute, +} from '../src/events/project.ts'; + +it('resolves nested Server Components in explicit standalone event routes', async () => { + const NestedContext = async () => createElement(Agent.Context, null, 'standalone'); + const Route = async () => createElement( + Agent.Result, + null, + createElement(NestedContext), + ); + const controller = new AbortController(); + const props = createCanonicalEventProps( + 'tool/after', + { hook_event_name: 'PostToolUse', tool_name: 'Write' }, + 'claude', + 'PostToolUse', + '2.1.250', + controller.signal, + ); + + const document = await renderStandaloneEventRoute(Route, props); + expect(projectEventDocument(document, 'tool/after', 'claude', 'PostToolUse')).toEqual({ + hookSpecificOutput: { + additionalContext: 'standalone', + hookEventName: 'PostToolUse', + }, + }); +}); diff --git a/packages/agent-bundle/tests/generated-route-server.test.ts b/packages/agent-bundle/tests/generated-route-server.test.ts index 266e240ce..82a3258d4 100644 --- a/packages/agent-bundle/tests/generated-route-server.test.ts +++ b/packages/agent-bundle/tests/generated-route-server.test.ts @@ -1,12 +1,14 @@ import { Client } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { afterEach, expect, it } from '@rstest/core'; import { build } from '../src/api.ts'; +import { eventRuntimeEndpoint } from '../src/events/ipc.ts'; const roots: string[] = []; @@ -20,6 +22,26 @@ const writeProjectFile = async (root: string, path: string, contents: string): P await writeFile(output, contents); }; +const runHook = async ( + entry: string, + input: Readonly>, +): Promise> | undefined> => new Promise((resolve, reject) => { + const child = spawn(process.execPath, [entry], { stdio: ['pipe', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += String(chunk); }); + child.stderr.on('data', (chunk) => { stderr += String(chunk); }); + child.once('error', reject); + child.once('close', (code) => { + if (code !== 0) { + reject(new Error(stderr)); + return; + } + resolve(stdout === '' ? undefined : JSON.parse(stdout) as Readonly>); + }); + child.stdin.end(JSON.stringify(input)); +}); + it('lists and calls a generated filesystem tool through final-only Flight', { retry: 2, timeout: 60_000 }, async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-generated-routes-')); roots.push(root); @@ -376,3 +398,148 @@ it('fails closed when the generated runtime worker restarts', { retry: 2, timeou await session.close(); } }); + +it('renders one tool/after event route through two native thin clients', { retry: 2, timeout: 90_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-generated-events-')); + roots.push(root); + await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'); + await Promise.all([ + writeProjectFile(root, 'package.json', JSON.stringify({ + dependencies: { + '@agent-bundle/runtime': 'workspace:*', + '@modelcontextprotocol/server': '2.0.0', + react: '19.2.8', + zod: '4.4.3', + }, + name: 'generated-events-fixture', + type: 'module', + version: '1.0.0', + })), + writeProjectFile(root, 'agent-bundle.config.ts', [ + "import { defineConfig } from 'agent-bundle/config';", + "export default defineConfig({ plugin: { name: 'generated-events-fixture', version: '1.0.0' }, targets: ['claude', 'cursor'] });", + '', + ].join('\n')), + writeProjectFile(root, 'src/mcp/runtime/tools/status.tsx', [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + 'export const inputSchema = z.object({}).strict();', + 'export const resultSchema = z.object({ ready: z.literal(true) }).strict();', + 'export default async function Status() {', + " return createElement(Agent.Result, { value: { ready: true } }, createElement(Agent.Text, null, 'ready'));", + '}', + '', + ].join('\n')), + writeProjectFile(root, 'src/events/tool/after.tsx', [ + "import { Agent, agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "export const config = { targets: ['claude', 'cursor'], tools: ['file.write'], timeoutMs: 5000 };", + 'export default async function AfterTool({ canonical, native }) {', + ' const context = await agent();', + ' const tool = typeof native.tool_name === "string" ? native.tool_name : "unknown";', + ' return createElement(Agent.Result, null, createElement(Agent.Context, null, `${canonical.provenance.host}:${tool}:${context.invocation.kind}`));', + '}', + '', + ].join('\n')), + ]); + + const output = join(root, 'artifact'); + const compiled = await build({ output, root, targets: ['claude', 'cursor'] }); + expect(compiled.model.hooks.filter((hook) => hook.eventRoute !== undefined)).toHaveLength(1); + expect(compiled.build.compiledHooks.filter((hook) => hook.id === 'hook:event-route:tool-after')).toHaveLength(2); + + for (const target of ['claude', 'cursor'] as const) { + const mcp = compiled.build.compiledMcpEntries.find((entry) => entry.target === target)!; + const hook = compiled.build.compiledHooks.find((entry) => entry.target === target && entry.event === 'afterTool')!; + await expect(readFile(mcp.output, 'utf8')).resolves.toContain('agent-bundle-event-'); + const client = new Client({ name: `generated-event-${target}`, version: '0.0.0' }); + const transport = new StdioClientTransport({ args: [mcp.output], command: process.execPath, stderr: 'pipe' }); + await client.connect(transport); + try { + const endpointId = `${compiled.build.manifest.project.revision}:${target}:${dirname(dirname(resolve(mcp.output)))}`; + const expectedEndpoint = eventRuntimeEndpoint(endpointId); + await expect(stat(expectedEndpoint)).resolves.toMatchObject({ mode: expect.any(Number) }); + const native = target === 'cursor' + ? { + conversation_id: 'conversation-1', + cwd: root, + hook_event_name: 'postToolUse', + session_id: 'session-1', + tool_input: { file_path: 'demo.ts' }, + tool_name: 'Write', + tool_output: '{"ok":true}', + tool_use_id: 'tool-1', + } + : { + cwd: root, + hook_event_name: 'PostToolUse', + session_id: 'session-1', + tool_input: { file_path: 'demo.ts' }, + tool_name: 'Write', + tool_response: { ok: true }, + tool_use_id: 'tool-1', + transcript_path: join(root, 'transcript.jsonl'), + }; + const response = await runHook(hook.output, native); + expect(response).toEqual(target === 'cursor' + ? { additional_context: 'cursor:Write:event' } + : { + hookSpecificOutput: { + additionalContext: 'claude:Write:event', + hookEventName: 'PostToolUse', + }, + }); + } finally { + await client.close(); + } + } +}); + +it('runs an explicitly standalone event route without a shared runtime', { timeout: 60_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-standalone-event-')); + roots.push(root); + await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'); + await Promise.all([ + writeProjectFile(root, 'package.json', JSON.stringify({ + dependencies: { + '@agent-bundle/runtime': 'workspace:*', + react: '19.2.8', + }, + name: 'standalone-event-fixture', + type: 'module', + version: '1.0.0', + })), + writeProjectFile(root, 'agent-bundle.config.ts', [ + "import { defineConfig } from 'agent-bundle/config';", + "export default defineConfig({ plugin: { name: 'standalone-event-fixture', version: '1.0.0' }, targets: ['cursor'] });", + '', + ].join('\n')), + writeProjectFile(root, 'src/events/tool/after.tsx', [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "export const config = { runtime: 'standalone', targets: ['cursor'] };", + 'const Context = async ({ tool }) => createElement(Agent.Context, null, `standalone:${tool}`);', + 'export default async function AfterTool({ native }) {', + ' return createElement(Agent.Result, null, createElement(Context, { tool: native.tool_name }));', + '}', + '', + ].join('\n')), + ]); + + const output = join(root, 'artifact'); + const compiled = await build({ output, root, targets: ['cursor'] }); + expect(compiled.build.compiledMcpEntries).toHaveLength(0); + const hook = compiled.build.compiledHooks.find((entry) => entry.event === 'afterTool'); + expect(hook).toBeDefined(); + await expect(runHook(hook!.output, { + conversation_id: 'conversation-1', + cwd: root, + hook_event_name: 'postToolUse', + session_id: 'session-1', + tool_input: { file_path: 'demo.ts' }, + tool_name: 'Write', + tool_output: '{"ok":true}', + tool_use_id: 'tool-1', + })).resolves.toEqual({ additional_context: 'standalone:Write' }); +}); diff --git a/packages/agent-bundle/tests/hook-playground-service.test.ts b/packages/agent-bundle/tests/hook-playground-service.test.ts index cb250b748..dc65d0ba7 100644 --- a/packages/agent-bundle/tests/hook-playground-service.test.ts +++ b/packages/agent-bundle/tests/hook-playground-service.test.ts @@ -49,7 +49,10 @@ const epochFor = ( interface PublishedHookEpoch { readonly epochStore: EpochStore; - readonly hooks: Readonly>>; + readonly hooks: Readonly, + Readonly<{ readonly id: string; readonly name: string }> + >>; } type IsExact = diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index cf1726d22..313d72b2e 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -604,7 +604,7 @@ it('fails unavailable event routes before packaging unless they are target-restr 'agent-bundle.config.ts': configSource, 'package.json': '{"type":"module"}\n', 'src/events/workspace/open.tsx': [ - "export const config = { targets: ['cursor'] };", + "export const config = { runtime: 'standalone', targets: ['cursor'] };", eventSource, ].join('\n'), }); @@ -612,3 +612,25 @@ it('fails unavailable event routes before packaging unless they are target-restr const restricted = await inspect({ root: restrictedRoot }); expect(restricted.state).toBe('ready'); }); + +it('requires an explicit standalone mode when no generated runtime can host an event route', async () => { + const root = await createRoot(); + await writeTree(root, { + 'agent-bundle.config.ts': [ + 'export default {', + " plugin: { name: 'event-runtime-fixture', version: '1.0.0' },", + " targets: ['cursor'],", + '};', + '', + ].join('\n'), + 'package.json': '{"type":"module"}\n', + 'src/events/workspace/open.tsx': 'export default async function WorkspaceOpen() { return undefined; }\n', + }); + + const inspected = await inspect({ root }); + expect(inspected.state).toBe('invalid'); + expect(inspected.diagnostics).toContainEqual(expect.objectContaining({ + code: 'AB4816', + target: 'cursor', + })); +}); diff --git a/packages/agent-bundle/tests/target-hook-contract.test.ts b/packages/agent-bundle/tests/target-hook-contract.test.ts index b96a30cc7..3170f64b9 100644 --- a/packages/agent-bundle/tests/target-hook-contract.test.ts +++ b/packages/agent-bundle/tests/target-hook-contract.test.ts @@ -277,6 +277,39 @@ it('rejects missing and blank native event mappings before creating hook entries } }); +it('plans a thin epoch-bound event-route client and keeps standalone execution explicit', () => { + const contract: TargetHookContract = { + capabilityRevision: 'synthetic-1', + commandRoot: '${SYNTHETIC_PLUGIN_ROOT}', + ...playgroundCodec, + eventNames: {}, + eventRouteNames: { 'tool/after': 'SyntheticAfterTool' }, + manifestPath: 'native-events/registration.json', + matchers: {}, + readNativeCommands: () => ({ commands: [], status: 'found' as const }), + wrapperPath: (hook) => `hooks/${hook.name}.mjs`, + wrapperSource: () => 'config-hook-only\n', + }; + const shared: NormalizedHook = { + ...planningHook('afterTool', []), + eventRoute: { event: 'tool/after', fallback: 'none', runtime: 'shared' }, + }; + const sharedSource = planHooks(planningModel([shared]), 'synthetic', contract).hookEntries[0]!.virtualSource; + + expect(sharedSource).toContain('requestEventRuntime'); + expect(sharedSource).toContain('__AGENT_BUNDLE_EVENT_ARTIFACT_EPOCH__'); + expect(sharedSource).toContain('hostContractRevision: capabilityRevision'); + expect(sharedSource).not.toContain('import * as routeModule'); + + const degraded: NormalizedHook = { + ...shared, + eventRoute: { event: 'tool/after', fallback: 'standalone', runtime: 'shared' }, + }; + const degradedSource = planHooks(planningModel([degraded]), 'synthetic', contract).hookEntries[0]!.virtualSource; + expect(degradedSource).toContain('import * as routeModule'); + expect(degradedSource).toContain('error.code === "runtime-unavailable"'); +}); + it('continues planning valid hooks after a prior hook mapping error', () => { const plan = planHooks(planningModel([ planningHook('beforeTool', ['shell']),