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/semantic-lifecycle-replay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Add stage 3 semantic lifecycle replay: authenticated dev-server lifecycle replay routes, single-sourced native event validation, and the Workbench lifecycle page.
1 change: 1 addition & 0 deletions packages/agent-bundle/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export default defineConfig({
'event-ipc': './src/events/ipc.ts',
'event-project': './src/events/project.ts',
index: './src/index.ts',
'lifecycle-render-child': './src/dev/playground/lifecycle-render-child.ts',
'mcp-apps': './src/mcp-apps.ts',
'mcp-entry': './src/mcp-entry.ts',
meta: './src/meta.ts',
Expand Down
8 changes: 7 additions & 1 deletion packages/agent-bundle/src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from './capability-state.ts';
import capabilityTable from './capabilities/claude-2.1.250.json' with { type: 'json' };
import {
createNativeEventStarter,
mergeHookDocuments,
encodeNativeHookPlaygroundInput,
encodeNativeHookPlaygroundOutput,
Expand Down Expand Up @@ -125,16 +126,21 @@ const validateLsp = validator.compile(lspSchema);

/** The pinned Claude hooks validator, shared with the unified bundle adapter. */
export const claudeHooksValidator = validateHooks;
const eventRouteNames = supportedEventRouteNamesFrom(capabilityTable.hooks.eventRoutes);
const hookContract = Object.freeze({
hostContractRevision: capabilityTable.observedCliVersion,
commandRoot: '${CLAUDE_PLUGIN_ROOT}',
encodePlaygroundInput: encodeNativeHookPlaygroundInput,
encodePlaygroundOutput: (result, event, nativeEvent) =>
encodeNativeHookPlaygroundOutput(result, event, nativeEvent, 'claude'),
eventNames: capabilityTable.hooks.events,
eventRouteNames: supportedEventRouteNamesFrom(capabilityTable.hooks.eventRoutes),
eventRouteNames,
manifestPath: 'hooks/hooks.json',
matchers: capabilityTable.hooks.matchers,
nativeEventStarter: (event) => {
const nativeEvent = eventRouteNames[event];
return nativeEvent === undefined ? undefined : createNativeEventStarter('claude', event, nativeEvent);
},
readNativeCommands: readStandardNativeHookCommands,
wrapperPath: (hook: NormalizedPlugin['hooks'][number]) => `hooks/${hook.name}.mjs`,
wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'),
Expand Down
8 changes: 7 additions & 1 deletion packages/agent-bundle/src/adapters/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from './capability-state.ts';
import capabilityTable from './capabilities/codex-0.147.0.json' with { type: 'json' };
import {
createNativeEventStarter,
mergeHookDocuments,
encodeNativeHookPlaygroundInput,
encodeNativeHookPlaygroundOutput,
Expand Down Expand Up @@ -107,16 +108,21 @@ const pluginValidatorFor = (mcpRelativePath: string): ValidateFunction => {
export const codexPluginDocumentValidator = (mcpRelativePath: string): TargetArtifactDocumentValidator =>
validateJsonSchemaDocument(pluginValidatorFor(mcpRelativePath));
const validateHooks = validator.compile(hooksSchema);
const eventRouteNames = supportedEventRouteNamesFrom(capabilityTable.hooks.eventRoutes);
const hookContract = Object.freeze({
hostContractRevision: capabilityTable.observedCliVersion,
commandRoot: '${PLUGIN_ROOT}',
encodePlaygroundInput: encodeNativeHookPlaygroundInput,
encodePlaygroundOutput: (result, event, nativeEvent) =>
encodeNativeHookPlaygroundOutput(result, event, nativeEvent, 'codex'),
eventNames: capabilityTable.hooks.events,
eventRouteNames: supportedEventRouteNamesFrom(capabilityTable.hooks.eventRoutes),
eventRouteNames,
manifestPath: 'hooks/hooks.json',
matchers: capabilityTable.hooks.matchers,
nativeEventStarter: (event) => {
const nativeEvent = eventRouteNames[event];
return nativeEvent === undefined ? undefined : createNativeEventStarter('codex', event, nativeEvent);
},
readNativeCommands: readStandardNativeHookCommands,
wrapperPath: (hook: NormalizedPlugin['hooks'][number]) => `hooks/${hook.name}.mjs`,
wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Codex'),
Expand Down
8 changes: 7 additions & 1 deletion packages/agent-bundle/src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from './capability-state.ts';
import capabilityTable from './capabilities/cursor-2026-08-28.json' with { type: 'json' };
import {
createNativeEventStarter,
cursorHookWrapperSource,
encodeCursorPlaygroundInput,
encodeCursorPlaygroundOutput,
Expand Down Expand Up @@ -121,6 +122,7 @@ export const emptyCursorHooksDocument = Object.freeze({ hooks: {}, version: 1 })
const cursorHookDocumentEntry = (input: TargetHookDocumentEntryInput): Record<string, unknown> => ({ ...input });

const cursorHookDocumentEnvelope = (hooks: Record<string, unknown[]>): Record<string, unknown> => ({ hooks, version: 1 });
const eventRouteNames = supportedEventRouteNamesFrom(capabilityTable.hooks.eventRoutes);

export interface CursorHookContractOptions {
/** See TargetHookContract.indexedWrappers; the bundle's Cursor wrappers are document variants. */
Expand All @@ -144,10 +146,14 @@ export const createCursorHookContract = (options: CursorHookContractOptions): Ta
encodePlaygroundInput: encodeCursorPlaygroundInput,
encodePlaygroundOutput: (result, canonicalEvent) => encodeCursorPlaygroundOutput(result, canonicalEvent),
eventNames: capabilityTable.hooks.events,
eventRouteNames: supportedEventRouteNamesFrom(capabilityTable.hooks.eventRoutes),
eventRouteNames,
...(options.indexedWrappers === false ? { indexedWrappers: false as const } : {}),
manifestPath: options.manifestPath,
matchers: capabilityTable.hooks.matchers,
nativeEventStarter: (event) => {
const nativeEvent = eventRouteNames[event];
return nativeEvent === undefined ? undefined : createNativeEventStarter('cursor', event, nativeEvent);
},
readNativeCommands: readCursorNativeHookCommands,
wrapperPath: options.wrapperPath,
wrapperSource: cursorHookWrapperSource,
Expand Down
138 changes: 85 additions & 53 deletions packages/agent-bundle/src/adapters/hook-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,94 @@ export interface TargetHookContract {
readonly indexedWrappers?: false;
readonly manifestPath: string;
readonly matchers: Readonly<Partial<Record<CanonicalHookTool, string>>>;
/** Minimal checked-in host envelope for semantic lifecycle replay. */
readonly nativeEventStarter?: (
canonicalEvent: CanonicalAgentEvent,
) => Readonly<Record<string, unknown>> | undefined;
readonly readNativeCommands?: (document: unknown) => TargetNativeHookCommandsReadResult;
readonly wrapperPath: (hook: NormalizedHook) => string;
readonly wrapperSource: (entry: TargetHookWrapper) => string;
}

export type NativeEventStarterTarget = 'claude' | 'codex' | 'cursor';

/** Creates only fields required by the shared native event envelope validator. */
export const createNativeEventStarter = (
target: NativeEventStarterTarget,
canonicalEvent: CanonicalAgentEvent,
nativeEvent: string,
): Readonly<Record<string, unknown>> => {
const base = target === 'cursor'
? {
hook_event_name: nativeEvent,
session_id: 'lifecycle-replay',
}
: {
cwd: '/tmp',
hook_event_name: nativeEvent,
session_id: 'lifecycle-replay',
transcript_path: target === 'codex' ? null : '/tmp/agent-bundle-lifecycle-replay-transcript.jsonl',
};
const toolInput = target === 'codex'
? { command: '*** Begin Patch\n*** Add File: lifecycle-replay.txt\n+Lifecycle replay\n*** End Patch' }
: { file_path: 'lifecycle-replay.txt' };
const toolName = target === 'codex' ? 'apply_patch' : 'Write';
switch (canonicalEvent) {
case 'session/start':
return deepFreeze(target === 'cursor' ? base : { ...base, source: 'startup' });
case 'tool/before':
return deepFreeze({
...base,
tool_input: toolInput,
tool_name: toolName,
tool_use_id: 'lifecycle-replay-tool',
});
case 'tool/after':
return deepFreeze({
...base,
tool_input: toolInput,
tool_name: toolName,
...(target === 'cursor' ? { tool_output: '{}' } : { tool_response: {} }),
tool_use_id: 'lifecycle-replay-tool',
});
case 'stop':
return deepFreeze(target === 'cursor'
? { ...base, loop_count: 0 }
: { ...base, last_assistant_message: 'Lifecycle replay stopped.', stop_hook_active: false });
case 'agent/start':
return deepFreeze(target === 'cursor'
? base
: {
...base,
agent_id: 'lifecycle-replay-agent',
agent_type: 'general-purpose',
...(target === 'codex'
? { model: 'default', permission_mode: 'default', turn_id: 'lifecycle-replay-turn' }
: {}),
});
case 'agent/stop':
return deepFreeze(target === 'cursor'
? base
: {
...base,
agent_id: 'lifecycle-replay-agent',
agent_transcript_path: null,
agent_type: 'general-purpose',
last_assistant_message: null,
...(target === 'codex'
? { model: 'default', permission_mode: 'default', turn_id: 'lifecycle-replay-turn' }
: {}),
stop_hook_active: false,
});
case 'workspace/open':
return deepFreeze(base);
default: {
const exhaustive: never = canonicalEvent;
return exhaustive;
}
}
};

const snapshotNativeHookCommands = (value: unknown): readonly TargetNativeHookCommand[] | undefined => {
const candidates = dataArrayValues(value);
if (candidates === undefined) return undefined;
Expand Down Expand Up @@ -333,9 +416,9 @@ const eventRouteHookWrapperSource = (
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 { createCanonicalEventProps, projectEventDocument, renderStandaloneEventRoute } from ${JSON.stringify(eventProjectRuntimeSpecifier)};`,
`import * as routeModule from ${JSON.stringify(entry.hook.source)};`,
]
: []),
Expand All @@ -351,58 +434,7 @@ const eventRouteHookWrapperSource = (
`const timeoutMs = ${String(entry.hook.timeoutMs ?? 5_000)};`,
"const endpointId = `${artifactEpoch}:${artifactTarget}:${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");',
' if (target === "codex") {',
' if (input.transcript_path !== null && typeof input.transcript_path !== "string") fail("native transcript_path must be a string or null");',
' } else {',
' 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 === "agent/start" || canonicalEvent === "agent/stop") {',
' requireString(input, "agent_id");',
' requireString(input, "agent_type");',
' if (target === "codex") {',
' requireString(input, "turn_id");',
' requireString(input, "model");',
' requireString(input, "permission_mode");',
' if (!["default", "acceptEdits", "plan", "dontAsk", "bypassPermissions"].includes(input.permission_mode)) fail("native permission_mode is invalid");',
' }',
' if (canonicalEvent === "agent/stop") {',
' if (typeof input.stop_hook_active !== "boolean") fail("native stop_hook_active must be a boolean");',
' if (input.agent_transcript_path !== null && typeof input.agent_transcript_path !== "string") fail("native agent_transcript_path must be a string or null");',
' if (input.last_assistant_message !== null && typeof input.last_assistant_message !== "string") fail("native last_assistant_message must be a string or null");',
' }',
' }',
' 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) => {',
Expand All @@ -423,7 +455,7 @@ const eventRouteHookWrapperSource = (
' }',
' 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 native = validateNativeEventEnvelope(parsed, { canonicalEvent, nativeEvent, target });',
' const controller = new AbortController();',
' let output;',
' if (runtimeMode === "standalone") {',
Expand Down
5 changes: 1 addition & 4 deletions packages/agent-bundle/src/build/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,10 +497,7 @@ export const compileHooks = async (
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 eventProjectRuntime = routeEntries.length === 0 ? undefined : eventRuntimeModulePath('project');
const evidence = await buildWithRslib({
cwd: options.cwd,
entries: compiled.map((entry, index) => ({
Expand Down
77 changes: 77 additions & 0 deletions packages/agent-bundle/src/contracts/lifecycles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { AgentDocument, AgentRenderEvent } from '@agent-bundle/runtime';

import type {
AgentEventCanonicalIdentity,
CanonicalAgentEvent,
} from '../routes/public.ts';

export interface LifecycleBinding {
readonly manifestDigest: string;
readonly routeId: string;
readonly target: string;
}

export interface LifecycleDiagnostic {
readonly code: string;
readonly message: string;
readonly severity: 'error' | 'warning';
readonly target?: string;
}

export interface LifecycleReplayDiagnostic extends LifecycleDiagnostic {
readonly event: CanonicalAgentEvent;
readonly target: string;
}

export interface LifecycleTarget {
readonly fixture?: Readonly<{
readonly label: string;
readonly native: Readonly<Record<string, unknown>>;
}>;
readonly hostContractRevision: string;
readonly nativeEvent: string;
readonly target: string;
}

export interface Lifecycle {
readonly diagnostics: readonly LifecycleDiagnostic[];
readonly event: CanonicalAgentEvent;
readonly routeId: string;
readonly routePath: string;
readonly targets: readonly LifecycleTarget[];
}

export interface LifecycleListResponse {
readonly lifecycles: readonly Lifecycle[];
readonly manifestDigest: string;
}

export type LifecycleReplaySource = 'fixture' | 'observed';

export interface LifecycleReplayRequest {
readonly binding: LifecycleBinding;
readonly native: Readonly<Record<string, unknown>>;
readonly source: LifecycleReplaySource;
}

export interface LifecycleReplay {
readonly binding: LifecycleBinding;
readonly canonical: AgentEventCanonicalIdentity;
readonly document?: AgentDocument;
readonly events: readonly AgentRenderEvent[];
readonly nativeInput: Readonly<Record<string, unknown>>;
readonly nativeResponse?: Readonly<Record<string, unknown>>;
readonly projectionDiagnostic?: Readonly<{ readonly code: string; readonly message: string }>;
readonly requestContext: Readonly<{
readonly hostContractRevision: string;
readonly invocationKind: 'event';
readonly nativeEvent: string;
readonly routeId: string;
readonly target: string;
}>;
readonly source: LifecycleReplaySource;
}

export interface LifecycleReplayDiagnosticResult {
readonly diagnostics: readonly LifecycleReplayDiagnostic[];
}
Loading
Loading