diff --git a/.changeset/correct-subagent-event-support.md b/.changeset/correct-subagent-event-support.md
new file mode 100644
index 000000000..465e3e670
--- /dev/null
+++ b/.changeset/correct-subagent-event-support.md
@@ -0,0 +1,9 @@
+---
+"agent-bundle": minor
+---
+
+Correct Claude Code and Codex event-route capabilities for native
+`SubagentStart` and `SubagentStop` hooks, including host-specific input
+validation, result projection, plugin packaging, and pinned Codex wire-schema
+evidence. Resolve the actual Claude or Codex invoker before a composite plugin
+route validates input or projects output.
diff --git a/examples/rsc-agent-runtime/README.md b/examples/rsc-agent-runtime/README.md
index 252440f82..ad579712f 100644
--- a/examples/rsc-agent-runtime/README.md
+++ b/examples/rsc-agent-runtime/README.md
@@ -225,6 +225,25 @@ Host/Origin allowlists mitigate DNS rebinding and cross-origin requests, but the
| Claude Code | Native package contract: `${CLAUDE_PLUGIN_ROOT}` MCP/hook paths and `Write|Edit` hook | Real host run is a skip-gated manual opt-in; no attached native evidence in this snapshot; never an iframe renderer |
| Codex CLI | Native package contract: marketplace, relative MCP path, `${PLUGIN_ROOT}` hook, and `apply_patch` matcher | Real host run is a skip-gated manual opt-in; no attached native evidence in this snapshot; native PostToolUse/shared state remains unproven under `exec --ephemeral` |
+### Semantic event-route support
+
+| Event family | Cursor | Claude Code 2.1.250 | Codex 0.147.0 |
+| --- | --- | --- | --- |
+| `session/start` | Supported | `SessionStart` | `SessionStart` |
+| `tool/before` | Supported | `PreToolUse` | `PreToolUse` |
+| `tool/after` | Supported | `PostToolUse` | `PostToolUse` |
+| `stop` | Supported | `Stop` | `Stop` |
+| `agent/start` | `subagentStart` | `SubagentStart` | `SubagentStart` |
+| `agent/stop` | `subagentStop` | `SubagentStop` | `SubagentStop` |
+| `workspace/open` | `workspaceOpen` | Unavailable | Unavailable |
+
+`agent/start` is context-injection-only on Claude Code and Codex and cannot
+block subagent creation. Their `agent/stop` routes can continue the subagent
+with the native `decision: "block"` plus `reason` contract. Codex
+`SubagentStop` exit-0 output is always JSON; its generated 0.147.0 output
+schema has no `additionalContext` field, so the route projection rejects that
+unsupported effect rather than silently fabricating one.
+
## Extension-author guide
The definition and lowerer keep serializable namespaced descriptor, resource, and result `_meta` opaque. Add a vendor extension as `_meta["vendor.example/feature"]`, merge it with `mergeSerializableMetadata`, and keep it out of model-visible text unless it belongs there. The optional Claude resource domain is added only to `resources/read` content metadata when an explicit public MCP URL is supplied; it is not added to resource registration by default.
diff --git a/examples/rsc-agent-runtime/rstest.config.ts b/examples/rsc-agent-runtime/rstest.config.ts
index ee62079f1..d036d31ae 100644
--- a/examples/rsc-agent-runtime/rstest.config.ts
+++ b/examples/rsc-agent-runtime/rstest.config.ts
@@ -8,6 +8,7 @@ export default defineConfig({
// swept into this plain Node pool where no test manifest is registered.
exclude: ['tests/route-unit/**'],
include: ['tests/**/*.test.{ts,tsx}'],
+ exclude: ['tests/route-unit/**/*.test.{ts,tsx}'],
pool: { maxWorkers: 1 },
testEnvironment: 'node',
// Every suite here runs real rsbuild compiles and spawned children, which a
diff --git a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json
index 55d11b301..b843d43ea 100644
--- a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json
+++ b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json
@@ -3,20 +3,16 @@
"hooks": {
"config": "hooks/hooks.json",
"events": {
+ "agentStart": "SubagentStart",
+ "agentStop": "SubagentStop",
"afterTool": "PostToolUse",
"beforeTool": "PreToolUse",
"sessionStart": "SessionStart",
"stop": "Stop"
},
"eventRoutes": {
- "agent/start": {
- "reason": "The pinned Claude Code 2.1.250 hooks contract has no subagent-start event.",
- "state": "unavailable"
- },
- "agent/stop": {
- "reason": "The pinned Claude Code 2.1.250 hooks contract has no subagent-stop event.",
- "state": "unavailable"
- },
+ "agent/start": { "nativeEvent": "SubagentStart", "state": "supported" },
+ "agent/stop": { "nativeEvent": "SubagentStop", "state": "supported" },
"session/start": { "nativeEvent": "SessionStart", "state": "supported" },
"stop": { "nativeEvent": "Stop", "state": "supported" },
"tool/after": { "nativeEvent": "PostToolUse", "state": "supported" },
@@ -92,7 +88,11 @@
"Codex and Cursor publish no plugin LSP surface at their pinned revisions, so the unified bundle's .lsp.json reaches Claude Code only.",
"Plugin developer tools reference: `claude plugin validate
` checks plugin.json, hooks/hooks.json, and default-directory Skill, agent, and command frontmatter; manifest-less component directories require 2.1.233 or later.",
"`claude plugin validate --strict` promotes tolerated warnings such as unrecognized or near-miss fields and non-object experimental/metadata values to exit failure; the reference recommends strict mode in CI.",
- "Development tools include `claude --plugin-dir plugin list --json` for registration proof, `claude plugin details ` for component inventory and host-owned token estimates, `claude plugin tag`, and `claude --debug` for loading diagnostics."
+ "Development tools include `claude --plugin-dir plugin list --json` for registration proof, `claude plugin details ` for component inventory and host-owned token estimates, `claude plugin tag`, and `claude --debug` for loading diagnostics.",
+ "https://code.claude.com/docs/en/hooks documents SubagentStart when Agent spawns a subagent and SubagentStop when it finishes; both match agent_type, including anchored plugin-scoped identifiers such as ^my-plugin:reviewer$.",
+ "SubagentStart adds agent_id and agent_type to common hook fields. It cannot block subagent creation; hookSpecificOutput.additionalContext injects context before the first subagent prompt, and exit-2 stderr is only a non-blocking notice in the subagent transcript.",
+ "SubagentStop adds stop_hook_active, agent_id, agent_type, agent_transcript_path, and last_assistant_message. decision:block plus reason or exit 2 keeps the subagent running; hookSpecificOutput.additionalContext provides non-error feedback that also continues it.",
+ "The hooks reference states prompt_id requires Claude Code 2.1.196 or later; the pinned 2.1.250 release covers that input field without a version bump."
]
}
}
diff --git a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json
index c8eb29fe8..f70b12abd 100644
--- a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json
+++ b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json
@@ -3,20 +3,16 @@
"hooks": {
"config": "hooks/hooks.json",
"events": {
+ "agentStart": "SubagentStart",
+ "agentStop": "SubagentStop",
"afterTool": "PostToolUse",
"beforeTool": "PreToolUse",
"sessionStart": "SessionStart",
"stop": "Stop"
},
"eventRoutes": {
- "agent/start": {
- "reason": "The pinned Codex 0.147.0 hooks contract has no subagent-start event.",
- "state": "unavailable"
- },
- "agent/stop": {
- "reason": "The pinned Codex 0.147.0 hooks contract has no subagent-stop event.",
- "state": "unavailable"
- },
+ "agent/start": { "nativeEvent": "SubagentStart", "state": "supported" },
+ "agent/stop": { "nativeEvent": "SubagentStop", "state": "supported" },
"session/start": { "nativeEvent": "SessionStart", "state": "supported" },
"stop": { "nativeEvent": "Stop", "state": "supported" },
"tool/after": { "nativeEvent": "PostToolUse", "state": "supported" },
@@ -46,5 +42,20 @@
"pluginData": false,
"pluginRoot": "relative-with-plugin-root-cwd",
"workspaceRoot": false
+ },
+ "provenance": {
+ "observedAt": "2026-09-01",
+ "source": "https://learn.chatgpt.com/docs/hooks",
+ "evidence": [
+ "The Codex 0.147.0 hooks reference lists SubagentStart and SubagentStop as lifecycle events whose matcher filters agent_type; subagent hook session_id is the parent session id.",
+ "SubagentStart adds turn_id, agent_id, agent_type, and permission_mode. Plain stdout or hookSpecificOutput.additionalContext adds developer context to the subagent; continue:false is parsed but does not stop creation.",
+ "SubagentStop adds turn_id, agent_id, agent_type, agent_transcript_path, stop_hook_active, and last_assistant_message. Exit-0 stdout must be JSON; decision:block plus reason or exit 2 continues the subagent flow, while continue:false takes precedence.",
+ "Codex discovers enabled plugin hooks at plugin-root hooks/hooks.json by default or from a ./-prefixed path, path array, inline object, or inline-object array in .codex-plugin/plugin.json. Paths must remain inside the plugin root; installation does not auto-trust non-managed hooks.",
+ "Plugin hook commands receive PLUGIN_ROOT and PLUGIN_DATA plus CLAUDE_PLUGIN_ROOT and CLAUDE_PLUGIN_DATA compatibility aliases.",
+ "Pinned generated schema: https://github.com/openai/codex/blob/rust-v0.147.0/codex-rs/hooks/schema/generated/subagent-start.command.input.schema.json (sha256 ce7dc9b5ae8826d1e0c59ffcea793e558aebceb7917a2eb9bb2edd8a7ac37aa9).",
+ "Pinned generated schema: https://github.com/openai/codex/blob/rust-v0.147.0/codex-rs/hooks/schema/generated/subagent-start.command.output.schema.json (sha256 34e8ec95393d2aa930d7932a34c3fb29a5e5f90c264fdbcc581393c5838b4660).",
+ "Pinned generated schema: https://github.com/openai/codex/blob/rust-v0.147.0/codex-rs/hooks/schema/generated/subagent-stop.command.input.schema.json (sha256 94dc8df29f4691195ac2338ae6de876230e5100a10b94ef48df4e732424b5df5).",
+ "Pinned generated schema: https://github.com/openai/codex/blob/rust-v0.147.0/codex-rs/hooks/schema/generated/subagent-stop.command.output.schema.json (sha256 8ba2cd7899ae4544193764e67e988235edebe984abe5788634d123bbf13e3e3a)."
+ ]
}
}
diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts
index d4bb783ea..a7a616717 100644
--- a/packages/agent-bundle/src/adapters/claude.ts
+++ b/packages/agent-bundle/src/adapters/claude.ts
@@ -121,7 +121,8 @@ const hookContract = Object.freeze({
capabilityRevision: capabilityTable.observedCliVersion,
commandRoot: '${CLAUDE_PLUGIN_ROOT}',
encodePlaygroundInput: encodeNativeHookPlaygroundInput,
- encodePlaygroundOutput: encodeNativeHookPlaygroundOutput,
+ encodePlaygroundOutput: (result, event, nativeEvent) =>
+ encodeNativeHookPlaygroundOutput(result, event, nativeEvent, 'claude'),
eventNames: capabilityTable.hooks.events,
eventRouteNames: supportedEventRouteNamesFrom(capabilityTable.hooks.eventRoutes),
manifestPath: 'hooks/hooks.json',
@@ -131,9 +132,9 @@ const hookContract = Object.freeze({
wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'),
} satisfies TargetHookContract);
const metadata = Object.freeze({
- adapterRevision: '1.2.0',
+ adapterRevision: '1.3.0',
capabilityRevision: capabilityTable.observedCliVersion,
- capabilitySha256: '13bc41224c5343b33d259986a66feb279e15431c5019bb2a1c443eaa60e9a9ea',
+ capabilitySha256: 'a9c8821ee5cbc6aef65816c5025170389fd490b6d1c4e4e893599aa6a36f2265',
observedVersion: capabilityTable.observedCliVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
});
diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts
index b845da9c7..96bf547ff 100644
--- a/packages/agent-bundle/src/adapters/codex.ts
+++ b/packages/agent-bundle/src/adapters/codex.ts
@@ -108,7 +108,8 @@ const hookContract = Object.freeze({
capabilityRevision: capabilityTable.observedCliVersion,
commandRoot: '${PLUGIN_ROOT}',
encodePlaygroundInput: encodeNativeHookPlaygroundInput,
- encodePlaygroundOutput: encodeNativeHookPlaygroundOutput,
+ encodePlaygroundOutput: (result, event, nativeEvent) =>
+ encodeNativeHookPlaygroundOutput(result, event, nativeEvent, 'codex'),
eventNames: capabilityTable.hooks.events,
eventRouteNames: supportedEventRouteNamesFrom(capabilityTable.hooks.eventRoutes),
manifestPath: 'hooks/hooks.json',
@@ -118,9 +119,9 @@ const hookContract = Object.freeze({
wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Codex'),
} satisfies TargetHookContract);
const metadata = Object.freeze({
- adapterRevision: '1.1.0',
+ adapterRevision: '1.2.0',
capabilityRevision: capabilityTable.observedCliVersion,
- capabilitySha256: 'f2c7109e3572ebde8739e08f6fdfa7a7b5d7817e3e216406b9c855f1570e2bd9',
+ capabilitySha256: '44e697be71a29db9ec029ed7d9eb8807b90e95d6a15f3a71a47148125c902194',
observedVersion: capabilityTable.observedCliVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
});
diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts
index 02e3d7891..7c65ebbfe 100644
--- a/packages/agent-bundle/src/adapters/hook-contract.ts
+++ b/packages/agent-bundle/src/adapters/hook-contract.ts
@@ -173,9 +173,16 @@ export const readCursorNativeHookCommands = (document: unknown): TargetNativeHoo
};
const nativeHookInputFields = Object.freeze([
+ Object.freeze({ canonical: 'agentId', native: 'agent_id' }),
+ Object.freeze({ canonical: 'agentTranscriptPath', native: 'agent_transcript_path' }),
+ Object.freeze({ canonical: 'agentType', native: 'agent_type' }),
Object.freeze({ canonical: 'cwd', native: 'cwd' }),
+ Object.freeze({ canonical: 'effort', native: 'effort' }),
Object.freeze({ canonical: 'hookEventName', native: 'hook_event_name' }),
Object.freeze({ canonical: 'lastAssistantMessage', native: 'last_assistant_message' }),
+ Object.freeze({ canonical: 'model', native: 'model' }),
+ Object.freeze({ canonical: 'permissionMode', native: 'permission_mode' }),
+ Object.freeze({ canonical: 'promptId', native: 'prompt_id' }),
Object.freeze({ canonical: 'sessionId', native: 'session_id' }),
Object.freeze({ canonical: 'source', native: 'source' }),
Object.freeze({ canonical: 'stopHookActive', native: 'stop_hook_active' }),
@@ -184,6 +191,7 @@ const nativeHookInputFields = Object.freeze([
Object.freeze({ canonical: 'toolResponse', native: 'tool_response' }),
Object.freeze({ canonical: 'toolUseId', native: 'tool_use_id' }),
Object.freeze({ canonical: 'transcriptPath', native: 'transcript_path' }),
+ Object.freeze({ canonical: 'turnId', native: 'turn_id' }),
]);
const defined = (value: Record): Record =>
@@ -216,9 +224,20 @@ export const encodeNativeHookPlaygroundOutput = (
result: Readonly> | undefined,
canonicalEvent: CanonicalHookEvent,
nativeEvent: string,
+ target?: 'claude' | 'codex',
): Readonly> | undefined => {
if (result === undefined) return undefined;
- if (canonicalEvent === 'stop') {
+ if (canonicalEvent === 'stop' || canonicalEvent === 'agentStop') {
+ if (canonicalEvent === 'agentStop' && target === 'claude' && result.outcome !== 'deny') {
+ return result.additionalContext === undefined
+ ? undefined
+ : {
+ hookSpecificOutput: defined({
+ additionalContext: result.additionalContext,
+ hookEventName: nativeEvent,
+ }),
+ };
+ }
return result.outcome === 'deny'
? defined({ decision: 'block', reason: result.reason })
: undefined;
@@ -301,6 +320,14 @@ const eventRouteHookWrapperSource = (
): string => {
const route = entry.hook.eventRoute!;
const standalone = route.runtime === 'standalone' || route.fallback === 'standalone';
+ const targetSource = entry.target === 'plugin'
+ ? [
+ 'const declaredHost = process.env.AGENT_BUNDLE_HOOK_HOST;',
+ 'const target = declaredHost === "claude" || declaredHost === "codex"',
+ ' ? declaredHost',
+ ' : process.env.PLUGIN_ROOT === undefined ? "claude" : "codex";',
+ ]
+ : ['const target = artifactTarget;'];
return [
"import { dirname, resolve } from 'node:path';",
`import { EventRuntimeTransportError, requestEventRuntime } from ${JSON.stringify(eventIpcRuntimeSpecifier)};`,
@@ -315,11 +342,12 @@ const eventRouteHookWrapperSource = (
`const canonicalEvent = ${JSON.stringify(route.event)};`,
`const capabilityRevision = ${JSON.stringify(capabilityRevision)};`,
`const nativeEvent = ${JSON.stringify(entry.nativeEvent)};`,
- `const target = ${JSON.stringify(entry.target)};`,
+ `const artifactTarget = ${JSON.stringify(entry.target)};`,
+ ...targetSource,
`const runtimeMode = ${JSON.stringify(route.runtime)};`,
`const fallbackMode = ${JSON.stringify(route.fallback)};`,
`const timeoutMs = ${String(entry.hook.timeoutMs ?? 5_000)};`,
- "const endpointId = `${artifactEpoch}:${target}:${dirname(dirname(resolve(process.argv[1])))}`;",
+ "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}`); };',
@@ -339,7 +367,11 @@ const eventRouteHookWrapperSource = (
' return input;',
' }',
' requireString(input, "session_id");',
- ' requireString(input, "transcript_path");',
+ ' 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") {',
@@ -348,6 +380,21 @@ const eventRouteHookWrapperSource = (
' 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");',
@@ -811,16 +858,23 @@ export const nativeHookWrapperSource = (
' if (result.reason !== undefined && typeof result.reason !== "string") fail("handler result reason must be a string");',
' if (result.additionalContext !== undefined && typeof result.additionalContext !== "string") fail("handler result additionalContext must be a string");',
' if (result.updatedInput !== undefined && !isRecord(result.updatedInput)) fail("handler result updatedInput must be an object");',
- ' if (result.reason !== undefined && !(result.outcome === "deny" && (canonicalEvent === "beforeTool" || canonicalEvent === "stop"))) fail("reason is only valid for a denied beforeTool or stop hook");',
- ' if (result.outcome === "deny" && (canonicalEvent === "beforeTool" || canonicalEvent === "stop") && (typeof result.reason !== "string" || result.reason.trim().length === 0)) fail(`denied ${canonicalEvent} hook requires a nonempty reason`);',
- ' if ((canonicalEvent === "sessionStart" || canonicalEvent === "afterTool") && (result.outcome === "deny" || result.outcome === "stop" || result.updatedInput !== undefined)) fail(`${canonicalEvent} cannot deny, stop, or replace input`);',
+ ' const supportsDeniedReason = canonicalEvent === "beforeTool" || canonicalEvent === "stop" || canonicalEvent === "agentStop";',
+ ' if (result.reason !== undefined && !(result.outcome === "deny" && supportsDeniedReason)) fail("reason is only valid for a denied beforeTool, stop, or agentStop hook");',
+ ' if (result.outcome === "deny" && supportsDeniedReason && (typeof result.reason !== "string" || result.reason.trim().length === 0)) fail(`denied ${canonicalEvent} hook requires a nonempty reason`);',
+ ' if ((canonicalEvent === "sessionStart" || canonicalEvent === "afterTool" || canonicalEvent === "agentStart") && (result.outcome === "deny" || result.outcome === "stop" || result.updatedInput !== undefined)) fail(`${canonicalEvent} cannot deny, stop, or replace input`);',
' if (canonicalEvent === "beforeTool" && (result.outcome === "stop" || (result.outcome === "deny" && result.updatedInput !== undefined))) fail("beforeTool cannot stop or replace input while denying");',
' if (canonicalEvent === "stop" && (result.outcome === "stop" || result.updatedInput !== undefined || result.additionalContext !== undefined)) fail("stop only accepts continue or deny with a reason");',
+ ' if (canonicalEvent === "agentStop" && (result.outcome === "stop" || result.updatedInput !== undefined)) fail("agentStop cannot stop the parent flow or replace input");',
+ ' if (canonicalEvent === "agentStop" && target === "codex" && result.additionalContext !== undefined) fail("Codex SubagentStop does not support additionalContext");',
' return result;',
'};',
'const encodeOutput = (result) => {',
' if (result === undefined) return undefined;',
- ' if (canonicalEvent === "stop") return result.outcome === "deny" ? defined({ decision: "block", reason: result.reason }) : undefined;',
+ ' if (canonicalEvent === "stop" || canonicalEvent === "agentStop") {',
+ ' if (result.outcome === "deny") return defined({ decision: "block", reason: result.reason });',
+ ' if (canonicalEvent === "agentStop" && target === "claude" && result.additionalContext !== undefined) return { hookSpecificOutput: { additionalContext: result.additionalContext, hookEventName: nativeEvent } };',
+ ' return undefined;',
+ ' }',
' const output = defined({',
' additionalContext: result.additionalContext,',
' hookEventName: nativeEvent,',
@@ -832,7 +886,11 @@ export const nativeHookWrapperSource = (
'};',
'const decodeOutput = (nativeOutput) => {',
' if (nativeOutput === undefined) return undefined;',
- ' if (canonicalEvent === "stop") return nativeOutput.decision === "block" ? defined({ outcome: "deny", reason: nativeOutput.reason }) : undefined;',
+ ' if (canonicalEvent === "stop" || canonicalEvent === "agentStop") {',
+ ' if (nativeOutput.decision === "block") return defined({ outcome: "deny", reason: nativeOutput.reason });',
+ ' if (canonicalEvent === "agentStop" && target === "claude" && isRecord(nativeOutput.hookSpecificOutput)) return defined({ additionalContext: nativeOutput.hookSpecificOutput.additionalContext, outcome: "continue" });',
+ ' return undefined;',
+ ' }',
' const output = nativeOutput.hookSpecificOutput;',
' if (!isRecord(output)) fail("native hook output is malformed");',
' return defined({',
@@ -845,11 +903,17 @@ export const nativeHookWrapperSource = (
'const requireString = (input, field) => {',
' if (typeof input[field] !== "string") fail(`native ${field} must be a string`);',
'};',
+ 'const requireNullableString = (input, field) => {',
+ ' if (input[field] !== null && typeof input[field] !== "string") fail(`native ${field} must be a string or null`);',
+ '};',
'const validateNativeInput = (input) => {',
' requireString(input, "session_id");',
- ' requireString(input, "transcript_path");',
+ ' if (target === "codex") requireNullableString(input, "transcript_path"); else requireString(input, "transcript_path");',
' requireString(input, "cwd");',
' if (input.hook_event_name !== nativeEvent) fail(`native hook_event_name must equal ${nativeEvent}`);',
+ ' if (input.prompt_id !== undefined) requireString(input, "prompt_id");',
+ ' if (input.permission_mode !== undefined) requireString(input, "permission_mode");',
+ ' if (input.model !== undefined) requireString(input, "model");',
' if (canonicalEvent === "sessionStart") { requireString(input, "source"); return; }',
' if (canonicalEvent === "beforeTool" || canonicalEvent === "afterTool") {',
' requireString(input, "tool_name");',
@@ -858,6 +922,21 @@ export const nativeHookWrapperSource = (
' if (canonicalEvent === "afterTool" && !isRecord(input.tool_response)) fail("native PostToolUse tool_response must be an object");',
' return;',
' }',
+ ' if (canonicalEvent === "agentStart" || canonicalEvent === "agentStop") {',
+ ' 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 === "agentStart") return;',
+ ' if (typeof input.stop_hook_active !== "boolean") fail("native SubagentStop stop_hook_active must be a boolean");',
+ ' requireNullableString(input, "agent_transcript_path");',
+ ' requireNullableString(input, "last_assistant_message");',
+ ' return;',
+ ' }',
' if (typeof input.stop_hook_active !== "boolean") fail("native Stop stop_hook_active must be a boolean");',
' requireString(input, "last_assistant_message");',
'};',
diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts
index 0cc1b92bf..6f3e7d188 100644
--- a/packages/agent-bundle/src/adapters/plugin.ts
+++ b/packages/agent-bundle/src/adapters/plugin.ts
@@ -167,7 +167,7 @@ const artifactValidation = Object.freeze({
});
const metadata = Object.freeze({
- adapterRevision: '1.2.0',
+ adapterRevision: '1.3.0',
capabilityRevision: `claude ${claudeAdapter.metadata.observedVersion} + codex ${codexAdapter.metadata.observedVersion}`,
capabilitySha256: claudeAdapter.metadata.capabilitySha256,
observedVersion: `${claudeAdapter.metadata.observedVersion}+${codexAdapter.metadata.observedVersion}`,
diff --git a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json
index 3f96307d1..9ea03b134 100644
--- a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json
+++ b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json
@@ -1,8 +1,8 @@
{
"observedCliVersion": "2.1.250",
- "retrievedAt": "2026-08-28",
+ "retrievedAt": "2026-09-01",
"schemaSource": "https://docs.anthropic.com/en/docs/claude-code/plugins",
- "notes": "lsp.schema.json and plugin.json's `lspServers` property were pinned from the Claude Code 2.1.x plugin reference LSP servers section (retrieved 2026-09-01), which documents `.lsp.json` at the plugin root or inline `lspServers` in the manifest, required `command` / `extensionToLanguage`, and the optional `args`, `transport`, `env`, `initializationOptions`, `settings`, `workspaceFolder`, `startupTimeout`, `shutdownTimeout`, `restartOnCrash`, `maxRestarts`, and `diagnostics` fields. `restartOnCrash` and `shutdownTimeout` require Claude Code 2.1.205 or later, which the pinned 2.1.250 revision satisfies. Manifest `lspServers` keeps the documented `string|array|object` union rather than being narrowed to the one emitted form the way `hooks` is; the emitted document itself is `.lsp.json` at the plugin root. Two agent-bundle tightenings over the documented text: a server map and an `extensionToLanguage` map must both be nonempty, because an empty map claims no extension and can never start a server.",
+ "notes": "lsp.schema.json and plugin.json's `lspServers` property were pinned from the Claude Code 2.1.x plugin reference LSP servers section (retrieved 2026-09-01), which documents `.lsp.json` at the plugin root or inline `lspServers` in the manifest, required `command` / `extensionToLanguage`, and the optional `args`, `transport`, `env`, `initializationOptions`, `settings`, `workspaceFolder`, `startupTimeout`, `shutdownTimeout`, `restartOnCrash`, `maxRestarts`, and `diagnostics` fields. `restartOnCrash` and `shutdownTimeout` require Claude Code v2.1.205 or later, which the pinned 2.1.250 revision satisfies. Manifest `lspServers` keeps the documented `string|array|object` union rather than being narrowed to the one emitted form the way `hooks` is; the emitted document itself is `.lsp.json` at the plugin root. Two agent-bundle tightenings over the documented text: a server map and an `extensionToLanguage` map must both be nonempty, because an empty map claims no extension and can never start a server. The current hooks reference at https://code.claude.com/docs/en/hooks supplies the SubagentStart/SubagentStop wire and decision evidence recorded in claude-2.1.250.json.",
"schemas": {
"hooks.schema.json": {
"bytes": 1108,
diff --git a/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json
index 3f7ca881a..6f2e2aa45 100644
--- a/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json
+++ b/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json
@@ -1,7 +1,8 @@
{
"observedCliVersion": "0.147.0",
- "retrievedAt": "2026-08-14",
+ "retrievedAt": "2026-09-01",
"schemaSource": "https://github.com/openai/codex/blob/main/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md",
+ "notes": "The generated/subagent-{start,stop}.command.{input,output}.schema.json evidence snapshots are byte-pinned from the rust-v0.147.0 tag at https://github.com/openai/codex/tree/rust-v0.147.0/codex-rs/hooks/schema/generated. Repository text files add one POSIX trailing newline; adapter-metadata.test.ts removes only that byte before comparing the authoritative upstream SHA-256 digests recorded in codex-0.147.0.json.",
"schemas": {
"hooks.schema.json": {
"bytes": 1107,
diff --git a/packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-start.command.input.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-start.command.input.schema.json
new file mode 100644
index 000000000..a7bbe8a70
--- /dev/null
+++ b/packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-start.command.input.schema.json
@@ -0,0 +1,63 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "additionalProperties": false,
+ "definitions": {
+ "NullableString": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "properties": {
+ "agent_id": {
+ "type": "string"
+ },
+ "agent_type": {
+ "type": "string"
+ },
+ "cwd": {
+ "type": "string"
+ },
+ "hook_event_name": {
+ "const": "SubagentStart",
+ "type": "string"
+ },
+ "model": {
+ "type": "string"
+ },
+ "permission_mode": {
+ "enum": [
+ "default",
+ "acceptEdits",
+ "plan",
+ "dontAsk",
+ "bypassPermissions"
+ ],
+ "type": "string"
+ },
+ "session_id": {
+ "type": "string"
+ },
+ "transcript_path": {
+ "$ref": "#/definitions/NullableString"
+ },
+ "turn_id": {
+ "description": "Codex extension: expose the active turn id to internal turn-scoped hooks.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "agent_id",
+ "agent_type",
+ "cwd",
+ "hook_event_name",
+ "model",
+ "permission_mode",
+ "session_id",
+ "transcript_path",
+ "turn_id"
+ ],
+ "title": "subagent-start.command.input",
+ "type": "object"
+}
diff --git a/packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-start.command.output.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-start.command.output.schema.json
new file mode 100644
index 000000000..eb1fffda1
--- /dev/null
+++ b/packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-start.command.output.schema.json
@@ -0,0 +1,51 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "additionalProperties": false,
+ "definitions": {
+ "SubagentStartHookSpecificOutputWire": {
+ "additionalProperties": false,
+ "properties": {
+ "additionalContext": {
+ "default": null,
+ "type": "string"
+ },
+ "hookEventName": {
+ "const": "SubagentStart",
+ "type": "string"
+ }
+ },
+ "required": [
+ "hookEventName"
+ ],
+ "type": "object"
+ }
+ },
+ "properties": {
+ "continue": {
+ "default": true,
+ "type": "boolean"
+ },
+ "hookSpecificOutput": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/SubagentStartHookSpecificOutputWire"
+ }
+ ],
+ "default": null
+ },
+ "stopReason": {
+ "default": null,
+ "type": "string"
+ },
+ "suppressOutput": {
+ "default": false,
+ "type": "boolean"
+ },
+ "systemMessage": {
+ "default": null,
+ "type": "string"
+ }
+ },
+ "title": "subagent-start.command.output",
+ "type": "object"
+}
diff --git a/packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-stop.command.input.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-stop.command.input.schema.json
new file mode 100644
index 000000000..32b34ca6b
--- /dev/null
+++ b/packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-stop.command.input.schema.json
@@ -0,0 +1,75 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "additionalProperties": false,
+ "definitions": {
+ "NullableString": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "properties": {
+ "agent_id": {
+ "type": "string"
+ },
+ "agent_transcript_path": {
+ "$ref": "#/definitions/NullableString"
+ },
+ "agent_type": {
+ "type": "string"
+ },
+ "cwd": {
+ "type": "string"
+ },
+ "hook_event_name": {
+ "const": "SubagentStop",
+ "type": "string"
+ },
+ "last_assistant_message": {
+ "$ref": "#/definitions/NullableString"
+ },
+ "model": {
+ "type": "string"
+ },
+ "permission_mode": {
+ "enum": [
+ "default",
+ "acceptEdits",
+ "plan",
+ "dontAsk",
+ "bypassPermissions"
+ ],
+ "type": "string"
+ },
+ "session_id": {
+ "type": "string"
+ },
+ "stop_hook_active": {
+ "type": "boolean"
+ },
+ "transcript_path": {
+ "$ref": "#/definitions/NullableString"
+ },
+ "turn_id": {
+ "description": "Codex extension: expose the active turn id to internal turn-scoped hooks.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "agent_id",
+ "agent_transcript_path",
+ "agent_type",
+ "cwd",
+ "hook_event_name",
+ "last_assistant_message",
+ "model",
+ "permission_mode",
+ "session_id",
+ "stop_hook_active",
+ "transcript_path",
+ "turn_id"
+ ],
+ "title": "subagent-stop.command.input",
+ "type": "object"
+}
diff --git a/packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-stop.command.output.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-stop.command.output.schema.json
new file mode 100644
index 000000000..ff9619275
--- /dev/null
+++ b/packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-stop.command.output.schema.json
@@ -0,0 +1,45 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "additionalProperties": false,
+ "definitions": {
+ "BlockDecisionWire": {
+ "enum": [
+ "block"
+ ],
+ "type": "string"
+ }
+ },
+ "properties": {
+ "continue": {
+ "default": true,
+ "type": "boolean"
+ },
+ "decision": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/BlockDecisionWire"
+ }
+ ],
+ "default": null
+ },
+ "reason": {
+ "default": null,
+ "description": "Claude requires `reason` when `decision` is `block`; we enforce that semantic rule during output parsing rather than in the JSON schema.",
+ "type": "string"
+ },
+ "stopReason": {
+ "default": null,
+ "type": "string"
+ },
+ "suppressOutput": {
+ "default": false,
+ "type": "boolean"
+ },
+ "systemMessage": {
+ "default": null,
+ "type": "string"
+ }
+ },
+ "title": "subagent-stop.command.output",
+ "type": "object"
+}
diff --git a/packages/agent-bundle/src/events/project.ts b/packages/agent-bundle/src/events/project.ts
index 848fb3ad1..ff45f002d 100644
--- a/packages/agent-bundle/src/events/project.ts
+++ b/packages/agent-bundle/src/events/project.ts
@@ -100,16 +100,68 @@ export const projectEventDocument = (
target: string,
nativeEvent: string,
): Readonly> | undefined => {
+ if (target === 'plugin') {
+ throw new TypeError('Composite plugin event projection must resolve the invoking host before projecting output.');
+ }
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);
+ const requireDenyReason = (): string => {
+ if (parsedValue?.outcome !== 'deny') {
+ throw new TypeError(`${event} did not request a blocking outcome.`);
+ }
+ if (parsedValue.reason === undefined) {
+ throw new TypeError(`${event} requires a nonempty reason when outcome is deny.`);
+ }
+ return parsedValue.reason;
+ };
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 });
+ ? Object.freeze({ followup_message: requireDenyReason() })
+ : Object.freeze({ decision: 'block', reason: requireDenyReason() });
+ }
+ if (event === 'agent/start') {
+ if (parsedValue?.outcome === 'deny') {
+ throw new TypeError('agent/start cannot block subagent creation on any supported host.');
+ }
+ if (parsedValue?.updatedInput !== undefined) {
+ throw new TypeError('agent/start cannot replace native input.');
+ }
+ if (additionalContext === undefined) return undefined;
+ return target === 'cursor'
+ ? Object.freeze({ additional_context: additionalContext })
+ : Object.freeze({
+ hookSpecificOutput: Object.freeze({
+ additionalContext,
+ hookEventName: nativeEvent,
+ }),
+ });
+ }
+ if (event === 'agent/stop') {
+ if (parsedValue?.updatedInput !== undefined) {
+ throw new TypeError('agent/stop cannot replace native input.');
+ }
+ if (parsedValue?.outcome === 'deny') {
+ if (target === 'cursor') {
+ throw new TypeError('agent/stop cannot block subagent completion on cursor.');
+ }
+ return Object.freeze({ decision: 'block', reason: requireDenyReason() });
+ }
+ if (additionalContext === undefined) return undefined;
+ if (target === 'codex') {
+ throw new TypeError('agent/stop additional context is not supported by the Codex SubagentStop output schema.');
+ }
+ return target === 'cursor'
+ ? Object.freeze({ additional_context: additionalContext })
+ : Object.freeze({
+ hookSpecificOutput: Object.freeze({
+ additionalContext,
+ hookEventName: nativeEvent,
+ }),
+ });
}
if (event === 'tool/before') {
if (target === 'cursor') {
diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts
index 7dfd51510..ebe83b465 100644
--- a/packages/agent-bundle/tests/adapter-capability-states.test.ts
+++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts
@@ -190,17 +190,24 @@ it('surfaces built-in adapter metadata as immutable capability evidence', () =>
it('reports the evidence-backed G10 event family matrix without inferred support', () => {
const registry = createDefaultRegistry();
- const existing = ['event:session/start', 'event:tool/before', 'event:tool/after', 'event:stop'];
- const cursorOnly = ['event:agent/start', 'event:agent/stop', 'event:workspace/open'];
-
- for (const capability of [...existing, ...cursorOnly]) {
+ const allNativeHosts = [
+ 'event:agent/start',
+ 'event:agent/stop',
+ 'event:session/start',
+ 'event:stop',
+ 'event:tool/after',
+ 'event:tool/before',
+ ];
+ const cursorOnly = ['event:workspace/open'];
+
+ for (const capability of [...allNativeHosts, ...cursorOnly]) {
expect(registry.get('cursor').capabilities[capability]).toMatchObject({
evidence: { observedVersion: '2026-08-28', target: 'cursor' },
state: 'supported',
});
}
for (const target of ['claude', 'codex'] as const) {
- for (const capability of existing) {
+ for (const capability of allNativeHosts) {
expect(registry.get(target).capabilities[capability]).toMatchObject({
evidence: { target },
state: 'supported',
@@ -213,6 +220,12 @@ it('reports the evidence-backed G10 event family matrix without inferred support
});
}
}
+ for (const capability of ['event:agent/start', 'event:agent/stop']) {
+ expect(registry.get('plugin').capabilities[capability]).toMatchObject({
+ evidence: { target: 'claude+codex' },
+ state: 'supported',
+ });
+ }
expect(registry.get('plugin').capabilities['event:workspace/open']).toMatchObject({
state: 'unavailable',
});
diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts
index ed81f8548..66d21ec51 100644
--- a/packages/agent-bundle/tests/adapter-metadata.test.ts
+++ b/packages/agent-bundle/tests/adapter-metadata.test.ts
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
import { expect, it } from '@rstest/core';
import { TargetRegistry, createDefaultRegistry } from '../src/adapters/registry.ts';
+import { createDraft7AdapterValidator } from '../src/adapters/types.ts';
import { sha256Hex } from '../src/core/digest.ts';
const validMetadata = () => ({
@@ -72,9 +73,9 @@ it('records exact immutable metadata for every built-in target', () => {
],
});
expect(registryMetadata(registry, 'codex')).toEqual({
- adapterRevision: '1.1.0',
+ adapterRevision: '1.2.0',
capabilityRevision: '0.147.0',
- capabilitySha256: 'f2c7109e3572ebde8739e08f6fdfa7a7b5d7817e3e216406b9c855f1570e2bd9',
+ capabilitySha256: '44e697be71a29db9ec029ed7d9eb8807b90e95d6a15f3a71a47148125c902194',
observedVersion: '0.147.0',
schemas: [
{
@@ -100,9 +101,9 @@ it('records exact immutable metadata for every built-in target', () => {
],
});
expect(registryMetadata(registry, 'claude')).toEqual({
- adapterRevision: '1.2.0',
+ adapterRevision: '1.3.0',
capabilityRevision: '2.1.250',
- capabilitySha256: '13bc41224c5343b33d259986a66feb279e15431c5019bb2a1c443eaa60e9a9ea',
+ capabilitySha256: 'a9c8821ee5cbc6aef65816c5025170389fd490b6d1c4e4e893599aa6a36f2265',
observedVersion: '2.1.250',
schemas: [
{
@@ -196,6 +197,50 @@ it('rehashes every declared capability and schema snapshot against its pinned pr
}
});
+it('pins and validates the Codex 0.147.0 subagent wire schemas', async () => {
+ const schemas = [
+ {
+ fixture: 'codex-subagent-start.json',
+ name: 'subagent-start.command.input.schema.json',
+ sha256: 'ce7dc9b5ae8826d1e0c59ffcea793e558aebceb7917a2eb9bb2edd8a7ac37aa9',
+ },
+ {
+ name: 'subagent-start.command.output.schema.json',
+ output: {
+ hookSpecificOutput: {
+ additionalContext: 'Review the repository test conventions first.',
+ hookEventName: 'SubagentStart',
+ },
+ },
+ sha256: '34e8ec95393d2aa930d7932a34c3fb29a5e5f90c264fdbcc581393c5838b4660',
+ },
+ {
+ fixture: 'codex-subagent-stop.json',
+ name: 'subagent-stop.command.input.schema.json',
+ sha256: '94dc8df29f4691195ac2338ae6de876230e5100a10b94ef48df4e732424b5df5',
+ },
+ {
+ name: 'subagent-stop.command.output.schema.json',
+ output: { decision: 'block', reason: 'Run one more focused pass.' },
+ sha256: '8ba2cd7899ae4544193764e67e988235edebe984abe5788634d123bbf13e3e3a',
+ },
+ ] as const;
+ const validator = createDraft7AdapterValidator();
+
+ for (const schema of schemas) {
+ const bytes = await readFile(new URL(`../src/adapters/schemas/codex/generated/${schema.name}`, import.meta.url));
+ // Repository text files carry one POSIX trailing newline; the pinned
+ // upstream generated files do not. Hash the byte-identical upstream body.
+ expect(bytes.at(-1)).toBe(10);
+ expect(sha256Hex(bytes.subarray(0, -1))).toBe(schema.sha256);
+ const validate = validator.compile(JSON.parse(bytes.toString()));
+ const value = 'fixture' in schema
+ ? JSON.parse(await readFile(new URL(`./fixtures/events/${schema.fixture}`, import.meta.url), 'utf8'))
+ : schema.output;
+ expect(validate(value), JSON.stringify(validate.errors)).toBe(true);
+ }
+});
+
it('returns frozen, detached metadata snapshots while retaining the original adapter methods', () => {
const metadata = validMetadata();
const registered = adapter('custom', metadata);
diff --git a/packages/agent-bundle/tests/event-project.test.ts b/packages/agent-bundle/tests/event-project.test.ts
index 502d4be0f..cc79c033c 100644
--- a/packages/agent-bundle/tests/event-project.test.ts
+++ b/packages/agent-bundle/tests/event-project.test.ts
@@ -33,3 +33,95 @@ it('resolves nested Server Components in explicit standalone event routes', asyn
},
});
});
+
+it('projects subagent-start context without fabricating a blocking effect', async () => {
+ const document = await renderStandaloneEventRoute(
+ async () => createElement(
+ Agent.Result,
+ null,
+ createElement(Agent.Context, null, 'Review the repository test conventions first.'),
+ ),
+ createCanonicalEventProps(
+ 'agent/start',
+ { agent_id: 'agent-1', agent_type: 'Explore', hook_event_name: 'SubagentStart', session_id: 'parent-1' },
+ 'codex',
+ 'SubagentStart',
+ '0.147.0',
+ new AbortController().signal,
+ ),
+ );
+
+ for (const target of ['claude', 'codex']) {
+ expect(projectEventDocument(document, 'agent/start', target, 'SubagentStart')).toEqual({
+ hookSpecificOutput: {
+ additionalContext: 'Review the repository test conventions first.',
+ hookEventName: 'SubagentStart',
+ },
+ });
+ }
+
+ const blocked = await renderStandaloneEventRoute(
+ async () => createElement(Agent.Result, { value: { outcome: 'deny', reason: 'Do not start.' } }),
+ createCanonicalEventProps(
+ 'agent/start',
+ { agent_id: 'agent-1', agent_type: 'Explore', hook_event_name: 'SubagentStart', session_id: 'parent-1' },
+ 'codex',
+ 'SubagentStart',
+ '0.147.0',
+ new AbortController().signal,
+ ),
+ );
+ expect(() => projectEventDocument(blocked, 'agent/start', 'codex', 'SubagentStart'))
+ .toThrow(/agent\/start cannot block subagent creation/u);
+});
+
+it('projects subagent-stop continuation only through supported host contracts', async () => {
+ const blocked = await renderStandaloneEventRoute(
+ async () => createElement(Agent.Result, { value: { outcome: 'deny', reason: 'Run one more focused pass.' } }),
+ createCanonicalEventProps(
+ 'agent/stop',
+ {
+ agent_id: 'agent-1',
+ agent_transcript_path: '/workspace/subagents/agent-1.jsonl',
+ agent_type: 'Explore',
+ hook_event_name: 'SubagentStop',
+ last_assistant_message: 'Done.',
+ session_id: 'parent-1',
+ stop_hook_active: false,
+ },
+ 'codex',
+ 'SubagentStop',
+ '0.147.0',
+ new AbortController().signal,
+ ),
+ );
+
+ for (const target of ['claude', 'codex']) {
+ expect(projectEventDocument(blocked, 'agent/stop', target, 'SubagentStop')).toEqual({
+ decision: 'block',
+ reason: 'Run one more focused pass.',
+ });
+ }
+
+ const feedback = await renderStandaloneEventRoute(
+ async () => createElement(Agent.Result, null, createElement(Agent.Context, null, 'Check the final result.')),
+ createCanonicalEventProps(
+ 'agent/stop',
+ { agent_id: 'agent-1', agent_type: 'Explore', hook_event_name: 'SubagentStop', session_id: 'session-1' },
+ 'claude',
+ 'SubagentStop',
+ '2.1.250',
+ new AbortController().signal,
+ ),
+ );
+ expect(projectEventDocument(feedback, 'agent/stop', 'claude', 'SubagentStop')).toEqual({
+ hookSpecificOutput: {
+ additionalContext: 'Check the final result.',
+ hookEventName: 'SubagentStop',
+ },
+ });
+ expect(() => projectEventDocument(feedback, 'agent/stop', 'codex', 'SubagentStop'))
+ .toThrow(/not supported by the Codex SubagentStop output schema/u);
+ expect(() => projectEventDocument(feedback, 'agent/stop', 'plugin', 'SubagentStop'))
+ .toThrow(/must resolve the invoking host/u);
+});
diff --git a/packages/agent-bundle/tests/fixtures/events/claude-subagent-start.json b/packages/agent-bundle/tests/fixtures/events/claude-subagent-start.json
new file mode 100644
index 000000000..c0f3a26f6
--- /dev/null
+++ b/packages/agent-bundle/tests/fixtures/events/claude-subagent-start.json
@@ -0,0 +1,8 @@
+{
+ "agent_id": "agent-claude-1",
+ "agent_type": "Explore",
+ "cwd": "/workspace",
+ "hook_event_name": "SubagentStart",
+ "session_id": "parent-session-claude",
+ "transcript_path": "/workspace/.claude/projects/session.jsonl"
+}
diff --git a/packages/agent-bundle/tests/fixtures/events/claude-subagent-stop.json b/packages/agent-bundle/tests/fixtures/events/claude-subagent-stop.json
new file mode 100644
index 000000000..d66166266
--- /dev/null
+++ b/packages/agent-bundle/tests/fixtures/events/claude-subagent-stop.json
@@ -0,0 +1,14 @@
+{
+ "agent_id": "agent-claude-1",
+ "agent_transcript_path": "/workspace/.claude/projects/session/subagents/agent-agent-claude-1.jsonl",
+ "agent_type": "Explore",
+ "background_tasks": [],
+ "cwd": "/workspace",
+ "hook_event_name": "SubagentStop",
+ "last_assistant_message": "Analysis complete.",
+ "permission_mode": "default",
+ "session_crons": [],
+ "session_id": "parent-session-claude",
+ "stop_hook_active": false,
+ "transcript_path": "/workspace/.claude/projects/session.jsonl"
+}
diff --git a/packages/agent-bundle/tests/fixtures/events/codex-subagent-start.json b/packages/agent-bundle/tests/fixtures/events/codex-subagent-start.json
new file mode 100644
index 000000000..d8bec8bdc
--- /dev/null
+++ b/packages/agent-bundle/tests/fixtures/events/codex-subagent-start.json
@@ -0,0 +1,11 @@
+{
+ "agent_id": "agent-codex-1",
+ "agent_type": "explore",
+ "cwd": "/workspace",
+ "hook_event_name": "SubagentStart",
+ "model": "gpt-5.6-codex",
+ "permission_mode": "default",
+ "session_id": "parent-session-codex",
+ "transcript_path": "/workspace/.codex/rollout.jsonl",
+ "turn_id": "turn-codex-1"
+}
diff --git a/packages/agent-bundle/tests/fixtures/events/codex-subagent-stop.json b/packages/agent-bundle/tests/fixtures/events/codex-subagent-stop.json
new file mode 100644
index 000000000..94814fa59
--- /dev/null
+++ b/packages/agent-bundle/tests/fixtures/events/codex-subagent-stop.json
@@ -0,0 +1,14 @@
+{
+ "agent_id": "agent-codex-1",
+ "agent_transcript_path": "/workspace/.codex/subagents/agent-codex-1.jsonl",
+ "agent_type": "explore",
+ "cwd": "/workspace",
+ "hook_event_name": "SubagentStop",
+ "last_assistant_message": "Analysis complete.",
+ "model": "gpt-5.6-codex",
+ "permission_mode": "default",
+ "session_id": "parent-session-codex",
+ "stop_hook_active": false,
+ "transcript_path": "/workspace/.codex/rollout.jsonl",
+ "turn_id": "turn-codex-1"
+}
diff --git a/packages/agent-bundle/tests/generated-route-server.test.ts b/packages/agent-bundle/tests/generated-route-server.test.ts
index 08556d4c6..a4fe7abb7 100644
--- a/packages/agent-bundle/tests/generated-route-server.test.ts
+++ b/packages/agent-bundle/tests/generated-route-server.test.ts
@@ -25,8 +25,16 @@ const writeProjectFile = async (root: string, path: string, contents: string): P
const runHook = async (
entry: string,
input: Readonly>,
+ env: Readonly = {},
): Promise> | undefined> => new Promise((resolve, reject) => {
- const child = spawn(process.execPath, [entry], { stdio: ['pipe', 'pipe', 'pipe'] });
+ const childEnv = { ...process.env, ...env };
+ for (const [key, value] of Object.entries(env)) {
+ if (value === undefined) delete childEnv[key];
+ }
+ const child = spawn(process.execPath, [entry], {
+ env: childEnv,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ });
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => { stdout += String(chunk); });
@@ -549,3 +557,158 @@ it('runs an explicitly standalone event route without a shared runtime', { timeo
tool_use_id: 'tool-1',
})).resolves.toEqual({ additional_context: 'standalone:Write' });
});
+
+it('replays Claude and Codex subagent fixtures through standalone event-route wrappers', { timeout: 60_000 }, async () => {
+ const root = await mkdtemp(join(tmpdir(), 'agent-bundle-subagent-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:*',
+ react: '19.2.8',
+ },
+ name: 'subagent-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: 'subagent-events-fixture', version: '1.0.0' }, targets: ['claude', 'codex'] });",
+ '',
+ ].join('\n')),
+ writeProjectFile(root, 'src/events/agent/start.tsx', [
+ "import { Agent } from '@agent-bundle/runtime';",
+ "import { createElement } from 'react';",
+ "export const config = { runtime: 'standalone', targets: ['claude', 'codex'] };",
+ 'export default async function AgentStart({ native }) {',
+ ' return createElement(Agent.Result, null, createElement(Agent.Context, null, `${native.session_id}:${native.agent_id}:${native.agent_type}`));',
+ '}',
+ '',
+ ].join('\n')),
+ writeProjectFile(root, 'src/events/agent/stop.tsx', [
+ "import { Agent } from '@agent-bundle/runtime';",
+ "import { createElement } from 'react';",
+ "export const config = { runtime: 'standalone', targets: ['claude', 'codex'] };",
+ 'export default async function AgentStop({ native }) {',
+ " return createElement(Agent.Result, { value: { outcome: 'deny', reason: `Review ${native.agent_id} once more.` } });",
+ '}',
+ '',
+ ].join('\n')),
+ ]);
+
+ const output = join(root, 'artifact');
+ const compiled = await build({ output, root, targets: ['claude', 'codex'] });
+ expect(compiled.build.compiledHooks.filter((hook) => hook.event === 'agentStart')).toHaveLength(2);
+ expect(compiled.build.compiledHooks.filter((hook) => hook.event === 'agentStop')).toHaveLength(2);
+
+ for (const target of ['claude', 'codex'] as const) {
+ const start = compiled.build.compiledHooks.find((hook) => hook.target === target && hook.event === 'agentStart')!;
+ const stop = compiled.build.compiledHooks.find((hook) => hook.target === target && hook.event === 'agentStop')!;
+ const startInput = JSON.parse(await readFile(
+ new URL(`./fixtures/events/${target}-subagent-start.json`, import.meta.url),
+ 'utf8',
+ )) as Record;
+ const stopInput = JSON.parse(await readFile(
+ new URL(`./fixtures/events/${target}-subagent-stop.json`, import.meta.url),
+ 'utf8',
+ )) as Record;
+
+ await expect(runHook(start.output, startInput)).resolves.toEqual({
+ hookSpecificOutput: {
+ additionalContext: `${String(startInput.session_id)}:${String(startInput.agent_id)}:${String(startInput.agent_type)}`,
+ hookEventName: 'SubagentStart',
+ },
+ });
+ await expect(runHook(stop.output, stopInput)).resolves.toEqual({
+ decision: 'block',
+ reason: `Review ${String(stopInput.agent_id)} once more.`,
+ });
+ }
+});
+
+it('dispatches composite plugin event routes through the invoking host contract', { timeout: 60_000 }, async () => {
+ const root = await mkdtemp(join(tmpdir(), 'agent-bundle-plugin-subagent-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:*',
+ react: '19.2.8',
+ },
+ name: 'plugin-subagent-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: 'plugin-subagent-events-fixture', version: '1.0.0' }, targets: ['plugin'] });",
+ '',
+ ].join('\n')),
+ writeProjectFile(root, 'src/events/agent/start.tsx', [
+ "import { Agent } from '@agent-bundle/runtime';",
+ "import { createElement } from 'react';",
+ "export const config = { runtime: 'standalone', targets: ['plugin'] };",
+ 'export default async function AgentStart({ canonical, native }) {',
+ ' return createElement(Agent.Result, null, createElement(Agent.Context, null, `${canonical.provenance.host}:${native.agent_id}`));',
+ '}',
+ '',
+ ].join('\n')),
+ writeProjectFile(root, 'src/events/agent/stop.tsx', [
+ "import { Agent } from '@agent-bundle/runtime';",
+ "import { createElement } from 'react';",
+ "export const config = { runtime: 'standalone', targets: ['plugin'] };",
+ 'export default async function AgentStop() {',
+ " return createElement(Agent.Result, null, createElement(Agent.Context, null, 'Check the final result.'));",
+ '}',
+ '',
+ ].join('\n')),
+ ]);
+
+ const output = join(root, 'artifact');
+ const compiled = await build({ output, root, targets: ['plugin'] });
+ const start = compiled.build.compiledHooks.find((hook) => hook.event === 'agentStart')!;
+ const stop = compiled.build.compiledHooks.find((hook) => hook.event === 'agentStop')!;
+
+ for (const target of ['claude', 'codex'] as const) {
+ const input = JSON.parse(await readFile(
+ new URL(`./fixtures/events/${target}-subagent-start.json`, import.meta.url),
+ 'utf8',
+ )) as Record;
+ if (target === 'codex') input.transcript_path = null;
+ const env = target === 'codex'
+ ? { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: output }
+ : { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: undefined };
+ await expect(runHook(start.output, input, env)).resolves.toEqual({
+ hookSpecificOutput: {
+ additionalContext: `${target}:${String(input.agent_id)}`,
+ hookEventName: 'SubagentStart',
+ },
+ });
+ }
+
+ const claudeStop = JSON.parse(await readFile(
+ new URL('./fixtures/events/claude-subagent-stop.json', import.meta.url),
+ 'utf8',
+ )) as Record;
+ await expect(runHook(stop.output, claudeStop, {
+ AGENT_BUNDLE_HOOK_HOST: undefined,
+ PLUGIN_ROOT: undefined,
+ })).resolves.toEqual({
+ hookSpecificOutput: {
+ additionalContext: 'Check the final result.',
+ hookEventName: 'SubagentStop',
+ },
+ });
+
+ const codexStop = JSON.parse(await readFile(
+ new URL('./fixtures/events/codex-subagent-stop.json', import.meta.url),
+ 'utf8',
+ )) as Record;
+ codexStop.transcript_path = null;
+ await expect(runHook(stop.output, codexStop, {
+ AGENT_BUNDLE_HOOK_HOST: undefined,
+ PLUGIN_ROOT: output,
+ })).rejects.toThrow(/not supported by the Codex SubagentStop output schema/u);
+});
diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts
index edc8dfa90..60f1ce334 100644
--- a/packages/agent-bundle/tests/hooks.test.ts
+++ b/packages/agent-bundle/tests/hooks.test.ts
@@ -1001,6 +1001,87 @@ it('runs the embedded Codex and Claude native codecs through their published wra
}
}, 15_000);
+it('round-trips Claude and Codex subagent fields through published wrappers', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'agent-bundle-subagent-hook-codecs-'));
+ const sourceRoot = join(root, 'src', 'hooks');
+ const outputRoot = join(root, 'dist');
+ const base = hookModel(root);
+ const model: NormalizedPlugin = {
+ ...base,
+ hooks: [
+ {
+ ...base.hooks[0]!,
+ event: 'agentStart',
+ id: 'hook:agent-start:subagent-start',
+ name: 'subagent-start',
+ source: join(sourceRoot, 'subagent-start.ts'),
+ },
+ {
+ ...base.hooks[3]!,
+ event: 'agentStop',
+ id: 'hook:agent-stop:subagent-stop',
+ name: 'subagent-stop',
+ source: join(sourceRoot, 'subagent-stop.ts'),
+ },
+ ],
+ };
+
+ try {
+ await mkdir(sourceRoot, { recursive: true });
+ await Promise.all([
+ writeFile(join(root, 'agent-bundle.config.ts'), 'export default {};\n'),
+ writeFile(join(root, 'package.json'), '{"type":"module"}\n'),
+ writeFile(
+ join(sourceRoot, 'subagent-start.ts'),
+ "export default (event: Record) => ({ outcome: 'continue' as const, additionalContext: `${String(event.sessionId)}:${String(event.agentId)}:${String(event.agentType)}:${String(event.turnId)}` });\n",
+ ),
+ writeFile(
+ join(sourceRoot, 'subagent-stop.ts'),
+ "export default (event: Record) => ({ outcome: 'deny' as const, reason: `${String(event.agentTranscriptPath)}:${String(event.stopHookActive)}:${String(event.lastAssistantMessage)}` });\n",
+ ),
+ ]);
+ await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() });
+
+ for (const target of ['codex', 'claude'] as const) {
+ const manifest = JSON.parse(await readFile(join(outputRoot, target, 'hooks', 'hooks.json'), 'utf8')) as {
+ readonly hooks: Readonly>;
+ };
+ expect(manifest.hooks.SubagentStart).toHaveLength(1);
+ expect(manifest.hooks.SubagentStop).toHaveLength(1);
+
+ const startInput = JSON.parse(await readFile(
+ new URL(`./fixtures/events/${target}-subagent-start.json`, import.meta.url),
+ 'utf8',
+ )) as Record;
+ const stopInput = JSON.parse(await readFile(
+ new URL(`./fixtures/events/${target}-subagent-stop.json`, import.meta.url),
+ 'utf8',
+ )) as Record;
+ const expectedTurn = target === 'codex' ? 'turn-codex-1' : 'undefined';
+ await expect(runNativeHook(join(outputRoot, target, 'hooks', 'subagent-start.mjs'), startInput)).resolves.toEqual({
+ code: 0,
+ stderr: '',
+ stdout: JSON.stringify({
+ hookSpecificOutput: {
+ additionalContext: `${String(startInput.session_id)}:${String(startInput.agent_id)}:${String(startInput.agent_type)}:${expectedTurn}`,
+ hookEventName: 'SubagentStart',
+ },
+ }),
+ });
+ await expect(runNativeHook(join(outputRoot, target, 'hooks', 'subagent-stop.mjs'), stopInput)).resolves.toEqual({
+ code: 0,
+ stderr: '',
+ stdout: JSON.stringify({
+ decision: 'block',
+ reason: `${String(stopInput.agent_transcript_path)}:${String(stopInput.stop_hook_active)}:${String(stopInput.last_assistant_message)}`,
+ }),
+ });
+ }
+ } finally {
+ await rm(root, { force: true, recursive: true });
+ }
+}, 15_000);
+
it('rejects malformed event-specific native input before calling generated Codex and Claude hooks', async () => {
const root = await mkdtemp(join(tmpdir(), 'agent-bundle-hooks-native-input-'));
const sourceRoot = join(root, 'src', 'hooks');
@@ -1091,11 +1172,11 @@ it('rejects canonical reason combinations whose selected native hook cannot repr
await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() });
const hooksRoot = join(outputRoot, 'codex', 'hooks');
const assertions: readonly [string, Record, string][] = [
- ['session-reason-00000001.mjs', { ...common, hook_event_name: 'SessionStart', source: 'startup' }, 'reason is only valid for a denied beforeTool or stop hook'],
- ['before-allow-reason-00000002.mjs', { ...common, hook_event_name: 'PreToolUse', tool_input: {}, tool_name: 'Bash', tool_use_id: 'use-1' }, 'reason is only valid for a denied beforeTool or stop hook'],
+ ['session-reason-00000001.mjs', { ...common, hook_event_name: 'SessionStart', source: 'startup' }, 'reason is only valid for a denied beforeTool, stop, or agentStop hook'],
+ ['before-allow-reason-00000002.mjs', { ...common, hook_event_name: 'PreToolUse', tool_input: {}, tool_name: 'Bash', tool_use_id: 'use-1' }, 'reason is only valid for a denied beforeTool, stop, or agentStop hook'],
['before-deny-reason-00000003.mjs', { ...common, hook_event_name: 'PreToolUse', tool_input: {}, tool_name: 'Bash', tool_use_id: 'use-2' }, 'denied beforeTool hook requires a nonempty reason'],
- ['after-reason-00000004.mjs', { ...common, hook_event_name: 'PostToolUse', tool_input: {}, tool_name: 'Write', tool_response: {}, tool_use_id: 'use-3' }, 'reason is only valid for a denied beforeTool or stop hook'],
- ['stop-continue-reason-00000005.mjs', { ...common, hook_event_name: 'Stop', last_assistant_message: 'done', stop_hook_active: false }, 'reason is only valid for a denied beforeTool or stop hook'],
+ ['after-reason-00000004.mjs', { ...common, hook_event_name: 'PostToolUse', tool_input: {}, tool_name: 'Write', tool_response: {}, tool_use_id: 'use-3' }, 'reason is only valid for a denied beforeTool, stop, or agentStop hook'],
+ ['stop-continue-reason-00000005.mjs', { ...common, hook_event_name: 'Stop', last_assistant_message: 'done', stop_hook_active: false }, 'reason is only valid for a denied beforeTool, stop, or agentStop hook'],
['stop-deny-reason-00000006.mjs', { ...common, hook_event_name: 'Stop', last_assistant_message: 'done', stop_hook_active: false }, 'denied stop hook requires a nonempty reason'],
];
for (const [name, input, message] of assertions) {
diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts
index 9686dec75..cdaa0caf5 100644
--- a/packages/agent-bundle/tests/plugin-bundle.test.ts
+++ b/packages/agent-bundle/tests/plugin-bundle.test.ts
@@ -158,6 +158,39 @@ it('lays both host manifests over one shared bundle root', () => {
});
});
+it('bundles subagent hooks at Codex default hooks/hooks.json location', () => {
+ const model: NormalizedPlugin = {
+ ...bundleModel,
+ hooks: [
+ {
+ ...bundleModel.hooks[0]!,
+ event: 'agentStart',
+ id: 'hook:agent-start',
+ name: 'agent-start',
+ source: '/workspace/src/hooks/agent-start.ts',
+ },
+ {
+ ...bundleModel.hooks[0]!,
+ event: 'agentStop',
+ id: 'hook:agent-stop',
+ name: 'agent-stop',
+ source: '/workspace/src/hooks/agent-stop.ts',
+ },
+ ],
+ };
+ const documents = writeContents(model);
+ const codexManifest = JSON.parse(documents['.codex-plugin/plugin.json']!) as Record;
+ const hooks = JSON.parse(documents['hooks/hooks.json']!) as {
+ readonly hooks: Readonly>;
+ };
+
+ // Codex discovers this plugin-root path by convention when the manifest
+ // omits `hooks`; both documented plugin-bundled forms are compliant.
+ expect(codexManifest).not.toHaveProperty('hooks');
+ expect(hooks.hooks.SubagentStart).toHaveLength(1);
+ expect(hooks.hooks.SubagentStop).toHaveLength(1);
+});
+
it('emits Claude-only LSP configuration at the shared composite root', () => {
const model = {
...bundleModel,