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

Promote canonical `session/end` and `prompt/submit` event-route families across
Claude Code, Codex, Cursor, and composite plugin artifacts. Keep both families
route-only, validate their native envelopes, and fail closed when a rendered
result requests a host output channel that the pinned contract cannot express.
9 changes: 9 additions & 0 deletions examples/rsc-agent-runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,8 @@ Host/Origin allowlists mitigate DNS rebinding and cross-origin requests, but the
| Event family | Cursor | Claude Code 2.1.250 | Codex 0.147.0 |
| --- | --- | --- | --- |
| `session/start` | Supported | `SessionStart` | `SessionStart` |
| `session/end` | `sessionEnd` (observe-only; desktop only) | `SessionEnd` (observe-only) | `SessionEnd` (observe-only) |
| `prompt/submit` | `beforeSubmitPrompt` (deny) | `UserPromptSubmit` (deny + context) | `UserPromptSubmit` (deny + context) |
| `tool/before` | Supported | `PreToolUse` | `PreToolUse` |
| `tool/after` | Supported | `PostToolUse` | `PostToolUse` |
| `stop` | Supported | `Stop` | `Stop` |
Expand All @@ -248,6 +250,13 @@ 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.
`session/end` and `prompt/submit` are event-route-only families and do not add
`config.hooks.sessionEnd` or `config.hooks.promptSubmit` handler keys.
`session/end` rejects every result effect because each native event is
observe-only. `prompt/submit` maps canonical deny to each host's blocking
contract; Claude Code and Codex also accept `Agent.Context`, while Cursor has
no context or prompt-rewrite channel. Cursor cloud agents do not expose
`sessionEnd`.

## Extension-author guide

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
"eventRoutes": {
"agent/start": { "nativeEvent": "SubagentStart", "state": "supported" },
"agent/stop": { "nativeEvent": "SubagentStop", "state": "supported" },
"prompt/submit": { "nativeEvent": "UserPromptSubmit", "state": "supported" },
"session/end": { "nativeEvent": "SessionEnd", "state": "supported" },
"session/start": { "nativeEvent": "SessionStart", "state": "supported" },
"stop": { "nativeEvent": "Stop", "state": "supported" },
"tool/after": { "nativeEvent": "PostToolUse", "state": "supported" },
Expand Down Expand Up @@ -625,6 +627,10 @@
"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.",
"retrieved 2026-09-02: https://code.claude.com/docs/en/hooks documents UserPromptSubmit input as the common hook envelope plus prompt; decision:block with reason or exit code 2 blocks and erases the prompt, while hookSpecificOutput.additionalContext adds context. It documents no prompt-rewrite channel.",
"2026-09-02: live Claude Code 2.1.257 non-interactive capture recorded UserPromptSubmit with session_id, transcript_path, cwd, prompt_id, permission_mode, hook_event_name, and prompt; the scrubbed envelope is tests/fixtures/events/claude-user-prompt-submit.json.",
"retrieved 2026-09-02: https://code.claude.com/docs/en/hooks documents SessionEnd input as the common hook envelope plus reason, with matcher filtering by reason; SessionEnd has no decision control, discards JSON output, and does not honor exit code 2 as a block.",
"2026-09-02: live Claude Code 2.1.257 non-interactive capture recorded SessionEnd with session_id, transcript_path, cwd, prompt_id, hook_event_name, and reason; the scrubbed envelope is tests/fixtures/events/claude-session-end.json.",
"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.",
"2026-09-01: https://code.claude.com/docs/en/plugins-reference documents an agents/ component with name, description, model, effort, maxTurns, tools, disallowedTools, skills, memory, background, and isolation: worktree; #100 stage 2 defers the agents component per the G5 narrowing in #107, so no agents capability row is published until a later stage admits it.",
"2026-09-01: https://code.claude.com/docs/en/plugins \"Ship default settings with your plugin\": a plugin may include settings.json at the plugin root \"to apply default configuration when the plugin is enabled. Currently, only the `agent` and `subagentStatusLine` keys are supported.\" The file-locations table of https://code.claude.com/docs/en/plugins-reference repeats the same bound: \"Settings | settings.json | Default configuration applied when the plugin is enabled. Only the agent and subagentStatusLine keys are supported\".",
Expand Down
20 changes: 16 additions & 4 deletions packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
"eventRoutes": {
"agent/start": { "nativeEvent": "SubagentStart", "state": "supported" },
"agent/stop": { "nativeEvent": "SubagentStop", "state": "supported" },
"prompt/submit": { "nativeEvent": "UserPromptSubmit", "state": "supported" },
"session/end": { "nativeEvent": "SessionEnd", "state": "supported" },
"session/start": { "nativeEvent": "SessionStart", "state": "supported" },
"stop": { "nativeEvent": "Stop", "state": "supported" },
"tool/after": { "nativeEvent": "PostToolUse", "state": "supported" },
Expand Down Expand Up @@ -154,16 +156,22 @@
"liveRevision": "codex-cli 0.147.0 app-server protocol",
"matchingRelativePaths": [],
"missingPinnedRelativePaths": [
"session-end.command.input.schema.json",
"subagent-start.command.input.schema.json",
"subagent-start.command.output.schema.json",
"subagent-stop.command.input.schema.json",
"subagent-stop.command.output.schema.json"
"subagent-stop.command.output.schema.json",
"user-prompt-submit.command.input.schema.json",
"user-prompt-submit.command.output.schema.json"
],
"pinnedRepositorySha256": {
"session-end.command.input.schema.json": "99bf6e75091525b96926dd85a0adf6589dea93fe0d181394027fabda8139402e",
"subagent-start.command.input.schema.json": "e1cacc5cd92217e96e327cf182038fa93099d194c3107439b4dad4b806d414cc",
"subagent-start.command.output.schema.json": "531f7a457ad8430de82388319ff2bf030fd3a1dc0e9a0d4078447bc30948448b",
"subagent-stop.command.input.schema.json": "27842578768e74fb8bcd86b30156b207011829a81dc01b00cfd55340df8b079f",
"subagent-stop.command.output.schema.json": "a3987dab22b8684ab108bbb76ec5306471d9e01b1b7cac007b1ed90bc9e055cf"
"subagent-stop.command.output.schema.json": "a3987dab22b8684ab108bbb76ec5306471d9e01b1b7cac007b1ed90bc9e055cf",
"user-prompt-submit.command.input.schema.json": "31d3d5e094ae9baa9478bc8e3d09f689de5a74228a404d2dffe60fc8fa89e3a0",
"user-prompt-submit.command.output.schema.json": "12a741340d3a5acaf139721ad086d79d6dc0d961f752c6d82bd7a315f374e641"
},
"pinnedRevision": "rust-v0.147.0 hook command schemas",
"reason": "The command emits app-server protocol schemas, not the vendored hook command schemas. There are no matching relative paths to hash-compare, so this output is unpinned for plugin conformance.",
Expand All @@ -176,7 +184,7 @@
"workspaceRoot": false
},
"provenance": {
"observedAt": "2026-09-01",
"observedAt": "2026-09-02",
"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.",
Expand All @@ -187,7 +195,11 @@
"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)."
"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).",
"retrieved 2026-09-02: https://github.com/openai/codex/blob/rust-v0.147.0/codex-rs/hooks/schema/generated/user-prompt-submit.command.input.schema.json pins UserPromptSubmit input (sha256 e6b923bc519896197c44c4fc267a9d115cef24ac418dde9c27db699f4e3b65fd).",
"retrieved 2026-09-02: https://github.com/openai/codex/blob/rust-v0.147.0/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json pins decision:block plus reason and hookSpecificOutput.additionalContext, with no prompt-rewrite field (sha256 5e290303db710f3ccc12f4a2744e8586e7749b3ca2b6bf9f57781ed75bf17b2b).",
"retrieved 2026-09-02: https://github.com/openai/codex/blob/rust-v0.147.0/codex-rs/hooks/schema/generated/session-end.command.input.schema.json pins observe-only SessionEnd input with reason const other (sha256 23b1b69f92fa8ac29f8319478984b5aa5aaf09e5ca355ce90aa010452937e41c); the same generated directory has no session-end.command.output.schema.json (GitHub contents API returned 404), so no output is projected.",
"2026-09-02: live codex-cli 0.147.0 non-interactive capture recorded UserPromptSubmit and SessionEnd; the scrubbed envelopes are tests/fixtures/events/codex-user-prompt-submit.json and tests/fixtures/events/codex-session-end.json."
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@
"eventRoutes": {
"agent/start": { "nativeEvent": "subagentStart", "state": "supported" },
"agent/stop": { "nativeEvent": "subagentStop", "state": "supported" },
"prompt/submit": { "nativeEvent": "beforeSubmitPrompt", "state": "supported" },
"session/end": {
"availability": {
"cloud": {
"reason": "retrieved 2026-09-02: https://cursor.com/docs/hooks states cloud agents have no editor-lifetime session boundary, so sessionEnd is unavailable there.",
"state": "unavailable"
},
"desktop": { "state": "supported" }
},
"nativeEvent": "sessionEnd",
"state": "supported"
},
"session/start": { "nativeEvent": "sessionStart", "state": "supported" },
"stop": { "nativeEvent": "stop", "state": "supported" },
"tool/after": { "nativeEvent": "postToolUse", "state": "supported" },
Expand Down Expand Up @@ -93,6 +105,9 @@
"2026-08-31: cursor/plugins@070189284e702e8a4d2e3cc8913994b204c5337a schemas/plugin.schema.json defines the rules component pointer; https://cursor.com/docs/plugins documents the rules component.",
"2026-09-02: https://cursor.com/docs/hooks#workspaceopen documents workspaceOpen input as sessionless ({ hook_event_name, cursor_version, workspace_roots, user_email }; conversation_id/generation_id/model/session_id/transcript_path omitted) and its output pluginPaths as string[] (optional), so an empty response is legal; the generated event-route wrapper validates that envelope and projects the observation-only canonical workspace/open family to no output — the native pluginPaths return channel is deliberately not modeled and never emitted.",
"2026-09-02: live native capture on Cursor CLI (cursor-agent 2026.08.31-4057e58, trusted project hooks, non-interactive -p run): workspaceOpen fired on workspace open with exactly the documented sessionless envelope (hook_event_name, cursor_version, workspace_roots, user_email; no conversation_id/generation_id/model/session_id/transcript_path) while the same run's sessionStart carried session and conversation ids; the capture hook exited 0 with no stdout and the workspace open plus agent session proceeded, proving the pluginPaths-omitted empty response is legal on the real binary.",
"retrieved 2026-09-02: https://cursor.com/docs/hooks documents beforeSubmitPrompt input as prompt plus file/rule attachments, matcher evaluation against the literal UserPromptSubmit, and output as continue plus optional user_message. continue:false blocks submission; no additional-context or prompt-rewrite channel is documented.",
"retrieved 2026-09-02: https://cursor.com/docs/hooks documents sessionEnd input as session_id, reason, duration_ms, is_background_agent, final_status, and optional error_message. It is fire-and-forget; output is logged but unused. The same source states cloud agents have no sessionEnd because they lack an editor-lifetime session boundary.",
"2026-09-02: local cursor-agent 2026.08.31-4057e58 capture was attempted with trusted project hooks, but the binary required Cursor authentication unavailable in the capture environment; the pinned official contract is the evidence source for beforeSubmitPrompt and sessionEnd.",
"2026-09-01: https://cursor.com/docs/plugins and https://prod.cursor.com/docs/reference/plugins document agents as a full Cursor Plugin component alongside rules and commands; #100 stage 2 defers the agents component per the G5 narrowing in #107, so no agents capability row is published until a later stage admits it."
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
"eventRoutes": {
"agent/start": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" },
"agent/stop": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" },
"prompt/submit": {
"reason": "2026-09-02: Agent Plugins 1.0.0 defines no hooks and therefore no native UserPromptSubmit/beforeSubmitPrompt equivalent (https://agent-plugins.org/).",
"state": "unavailable"
},
"session/end": {
"reason": "2026-09-02: Agent Plugins 1.0.0 defines no hooks and therefore no native SessionEnd/sessionEnd equivalent (https://agent-plugins.org/).",
"state": "unavailable"
},
"session/start": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" },
"stop": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" },
"tool/after": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" },
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,7 @@ const hookContract = Object.freeze({
wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'),
} satisfies TargetHookContract);
const metadata = Object.freeze({
adapterRevision: '1.19.0',
adapterRevision: '1.20.0',
observedVersion: capabilityTable.observedCliVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
});
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/src/adapters/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ const hookContract = Object.freeze({
wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Codex'),
} satisfies TargetHookContract);
const metadata = Object.freeze({
adapterRevision: '1.3.0',
adapterRevision: '1.4.0',
observedVersion: capabilityTable.observedCliVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
});
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ export const cursorManifest = (
});

const metadata = Object.freeze({
adapterRevision: '1.5.0',
adapterRevision: '1.6.0',
observedVersion: capabilityTable.observedCliVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
});
Expand Down
46 changes: 36 additions & 10 deletions packages/agent-bundle/src/adapters/hook-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@ 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,
NormalizedHook,
NormalizedNativeHook,
NormalizedPlugin,
import {
canonicalHookEvents,
type CanonicalHookEvent,
type CanonicalHookTool,
type NormalizedHook,
type NormalizedHookEvent,
type NormalizedNativeHook,
type NormalizedPlugin,
} from '../core/types.ts';
import { deepFreeze } from '../core/freeze.ts';


export interface TargetHookWrapper {
readonly event: CanonicalHookEvent;
readonly event: NormalizedHookEvent;
readonly hook: NormalizedHook;
/** False when this wrapper is a host-document variant of an indexed hook rather than its canonical entry. */
readonly indexed?: false;
Expand Down Expand Up @@ -109,6 +111,27 @@ export const createNativeEventStarter = (
switch (canonicalEvent) {
case 'session/start':
return deepFreeze(target === 'cursor' ? base : { ...base, source: 'startup' });
case 'session/end':
return deepFreeze(target === 'cursor'
? {
...base,
duration_ms: 0,
final_status: 'completed',
is_background_agent: false,
reason: 'completed',
}
: { ...base, reason: 'other' });
case 'prompt/submit':
return deepFreeze(target === 'cursor'
? { ...base, attachments: [], prompt: 'Lifecycle replay prompt.' }
: {
...base,
permission_mode: 'default',
prompt: 'Lifecycle replay prompt.',
...(target === 'codex'
? { model: 'default', turn_id: 'lifecycle-replay-turn' }
: {}),
});
case 'tool/before':
return deepFreeze({
...base,
Expand Down Expand Up @@ -289,18 +312,20 @@ const nativeHookInputFields = deepFreeze([
const defined = (value: Record<string, unknown>): Record<string, unknown> =>
Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));

const canonicalEventOrder: readonly CanonicalHookEvent[] = [
const canonicalEventOrder: readonly NormalizedHookEvent[] = [
'sessionStart',
'promptSubmit',
'beforeTool',
'afterTool',
'stop',
'agentStart',
'agentStop',
'workspaceOpen',
'sessionEnd',
];

export const canonicalHookEventFor = (event: string): CanonicalHookEvent | undefined =>
canonicalEventOrder.find((candidate) => candidate === event);
canonicalHookEvents.find((candidate) => candidate === event);

export const encodeNativeHookPlaygroundInput = (
input: Readonly<Record<string, unknown>>,
Expand Down Expand Up @@ -826,8 +851,9 @@ export const planHooks = (
const groups: Record<string, unknown[]> = Object.create(null) as Record<string, unknown[]>;
const hookEntries: TargetHookEntry[] = [];
for (const hook of selected) {
const configEvent = canonicalHookEventFor(hook.event);
const nativeEvent = hook.eventRoute === undefined
? contract.eventNames[hook.event]
? configEvent === undefined ? undefined : contract.eventNames[configEvent]
: contract.eventRouteNames?.[hook.eventRoute.event];
if (typeof nativeEvent !== 'string' || nativeEvent.trim().length === 0) {
diagnostics.push(error(
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/src/adapters/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ const artifactValidation = deepFreeze({
});

const metadata = Object.freeze({
adapterRevision: '1.18.0',
adapterRevision: '1.19.0',
observedVersion: `${claudeAdapter.metadata.observedVersion}+${codexAdapter.metadata.observedVersion}+${cursorAdapter.metadata.observedVersion}`,
// Metadata schemas must exactly match the validation contract: each host's
// documents, with one shared Claude-format hook schema (the pinned Codex
Expand Down
Loading
Loading