-
Notifications
You must be signed in to change notification settings - Fork 0
feat(hooks): export HookHandler / HookEvent / HookResult — the typed config hook handler contract #533
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
feat(hooks): export HookHandler / HookEvent / HookResult — the typed config hook handler contract #533
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
59a39f2
feat(hooks): export the typed hook handler contract (HookHandler, Hoo…
ScriptedAlchemy 67fbecf
chore: point the changeset at #533
ScriptedAlchemy c3ebaa4
fix(hooks): make the handler contract delivery-portable and scope it …
ScriptedAlchemy 7eeac10
Merge branch 'main' into feat/hook-handler-types
ScriptedAlchemy 6a70b2e
Merge branch 'main' into feat/hook-handler-types
ScriptedAlchemy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| 'agent-bundle': patch | ||
| --- | ||
|
|
||
| Export the typed config hook handler contract from `agent-bundle` and `agent-bundle/config`: `HookHandler<E>`, `HookEvent<E>`, and `HookResult<E>` for every `HookHandlerEventName` (`sessionStart`, `beforeTool`, `afterTool`, `stop`, `agentStart`, `agentStop` — the canonical events every host maps to a plain hook; `CanonicalHookEvent`, `AgentBundleHookEntry`, and `AgentBundleHookInput` are exported too), the per-event payloads (`SessionStartHookEvent`, `BeforeToolHookEvent`, `AfterToolHookEvent`, `StopHookEvent`, `AgentStartHookEvent`, `AgentStopHookEvent`, `HookEventBase`, `HookEventPayloads`), the second handler argument `HookHandlerContext`, and the tables the types derive from (`hookHandlerEventNames`, `hookResultContract`, `hookEventFields`). `export default ((event) => ({ … })) satisfies HookHandler<'sessionStart'>` makes an illegal result — a denying `sessionStart`, `reason` beside `continue`, `updatedInput` on `stop` — a `tsc` error. The types are the portable contract (accepted by every host's wrapper and delivered in its native output), so host-only fields such as `beforeTool` `additionalContext` stay untyped; the generated wrappers' runtime validation is unchanged and a test holds the types to exactly what every host wrapper accepts. Fixes #488. (#533) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,10 @@ | ||
| interface SessionStartEvent { | ||
| readonly cwd?: string; | ||
| readonly sessionId?: string; | ||
| readonly source?: string; | ||
| readonly transcriptPath?: string; | ||
| } | ||
| import type { HookHandler } from 'agent-bundle'; | ||
|
|
||
| export default (event: SessionStartEvent) => ({ | ||
| export default ((event) => ({ | ||
| additionalContext: [ | ||
| `This release preparation session is active for ${event.sessionId ?? 'this session'} from ${event.source ?? 'an unknown source'}.`, | ||
| `This release preparation session is active for ${event.sessionId} from ${event.source ?? 'an unknown source'}.`, | ||
| `Run verify-release from ${event.cwd ?? process.cwd()} to confirm the manifest is ready for packaging.`, | ||
| 'Run detect-risk to surface open high-severity release blockers before publishing.', | ||
| ].join(' '), | ||
| outcome: 'continue' as const, | ||
| }); | ||
| outcome: 'continue', | ||
| })) satisfies HookHandler<'sessionStart'>; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| { | ||
| "extends": "../../tsconfig.json", | ||
| "include": [ | ||
| "agent-bundle.config.ts", | ||
| "src/**/*.ts" | ||
| ] | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,10 @@ | ||
| interface SessionStartEvent { | ||
| readonly cwd?: string; | ||
| readonly sessionId?: string; | ||
| readonly source?: string; | ||
| } | ||
| import type { HookHandler } from 'agent-bundle'; | ||
|
|
||
| export default (event: SessionStartEvent) => ({ | ||
| export default ((event) => ({ | ||
| additionalContext: [ | ||
| `Service readiness session ${event.sessionId ?? 'is active'} from ${event.source ?? 'an unknown source'}.`, | ||
| `Service readiness session ${event.sessionId} from ${event.source ?? 'an unknown source'}.`, | ||
| `Use the service-readiness Skill, then run check-service-fixture from ${event.cwd ?? process.cwd()} before release review.`, | ||
| 'Use show-status for compiler or payments-api when live service evidence is needed.', | ||
| ].join(' '), | ||
| outcome: 'continue' as const, | ||
| }); | ||
| outcome: 'continue', | ||
| })) satisfies HookHandler<'sessionStart'>; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,235 @@ | ||
| import type { CanonicalHookEvent } from '../core/types.ts'; | ||
| import type { JsonObject, JsonValue } from '../dev/types.ts'; | ||
|
|
||
| /** | ||
| * The typed contract of a config-declared hook handler (`hooks.<event>.handler`, | ||
| * #488): the event payload the generated wrapper hands the default export and | ||
| * the result it validates before projecting it into host-native output. | ||
| * | ||
| * Both sides are derived from one table per handler event so that the types | ||
| * and the wrappers' runtime `validateResult` cannot drift: the table is the | ||
| * *portable* contract — a result field it admits is accepted by every host | ||
| * wrapper (`Claude`, `Codex`, `Universal`, and `cursor`) *and* projected into | ||
| * that host's native output — and `tests/hook-handler-contract.test.ts` runs | ||
| * the generated wrappers against it. Host-specific behaviour is not part of | ||
| * the type, because a handler typed here must run unchanged on every host it | ||
| * targets: Cursor admits a denying `agentStart`; Claude and Codex accept | ||
| * `additionalContext` on `beforeTool` and `agentStart` (Cursor validates but | ||
| * has no channel for it there); Claude carries `additionalContext` on | ||
| * `agentStop`. The wrappers keep their runtime validation for untyped and | ||
| * prebuilt handlers. | ||
| */ | ||
|
|
||
| /** | ||
| * The canonical events a config hook (`hooks.<event>.handler`) can be declared | ||
| * for: the events every production host maps to a native plain hook. | ||
| * `workspaceOpen` is a `CanonicalHookEvent` but no host maps it as a plain | ||
| * hook — it is served by an event route — so it has no handler contract. | ||
| */ | ||
| export const hookHandlerEventNames = [ | ||
| 'sessionStart', | ||
| 'beforeTool', | ||
| 'afterTool', | ||
| 'stop', | ||
| 'agentStart', | ||
| 'agentStop', | ||
| ] as const satisfies readonly CanonicalHookEvent[]; | ||
|
|
||
| export type HookHandlerEventName = (typeof hookHandlerEventNames)[number]; | ||
|
|
||
| /** What a hook result may carry for one handler event, on every host. */ | ||
| export interface HookResultRule { | ||
| /** `additionalContext` is accepted and projected into the agent's context on every host. */ | ||
| readonly additionalContext: boolean; | ||
| /** | ||
| * `{ outcome: 'deny', reason }` is a legal result (a denial always needs a | ||
| * non-empty `reason`). Cursor consumes an `agentStop` denial only for a | ||
| * subagent that completed (`nativeInput.status === "completed"`); denying an | ||
| * errored or aborted one fails the hook there, so a handler targeting Cursor | ||
| * checks `context.nativeInput.status` first. | ||
| */ | ||
| readonly deny: boolean; | ||
| /** A continuing result may replace the pending call's input with `updatedInput`. */ | ||
| readonly updatedInput: boolean; | ||
| } | ||
|
|
||
| /** | ||
| * The per-event result contract the generated wrappers enforce, as the | ||
| * portable intersection across hosts. No event admits `outcome: 'stop'`; | ||
| * `reason` is legal only beside `outcome: 'deny'`; `updatedInput` is never | ||
| * legal while denying. | ||
| */ | ||
| export const hookResultContract = { | ||
| afterTool: { additionalContext: true, deny: false, updatedInput: false }, | ||
| // Claude and Codex accept `additionalContext` here; Cursor's subagentStart has no context channel. | ||
| agentStart: { additionalContext: false, deny: false, updatedInput: false }, | ||
| // Claude carries `additionalContext` on SubagentStop; Codex and Cursor reject it. See HookResultRule.deny for Cursor's status rule. | ||
| agentStop: { additionalContext: false, deny: true, updatedInput: false }, | ||
| // Claude and Codex project `additionalContext` on PreToolUse; Cursor's preToolUse output carries only a permission or an input rewrite. | ||
| beforeTool: { additionalContext: false, deny: true, updatedInput: true }, | ||
| sessionStart: { additionalContext: true, deny: false, updatedInput: false }, | ||
| stop: { additionalContext: false, deny: true, updatedInput: false }, | ||
| } as const satisfies Readonly<Record<HookHandlerEventName, HookResultRule>>; | ||
|
|
||
| export type HookResultContract = typeof hookResultContract; | ||
|
|
||
| type WithAdditionalContext<E extends HookHandlerEventName> = HookResultContract[E]['additionalContext'] extends true | ||
| ? { readonly additionalContext?: string } | ||
| : { readonly additionalContext?: never }; | ||
|
|
||
| type WithUpdatedInput<E extends HookHandlerEventName> = HookResultContract[E]['updatedInput'] extends true | ||
| ? { readonly updatedInput?: JsonObject } | ||
| : { readonly updatedInput?: never }; | ||
|
|
||
| /** A continuing result: `outcome` may be omitted, and `reason` is never legal here. */ | ||
| export type HookContinueResult<E extends HookHandlerEventName> = { | ||
| readonly outcome?: 'continue'; | ||
| readonly reason?: never; | ||
| } & WithAdditionalContext<E> & WithUpdatedInput<E>; | ||
|
|
||
| /** A denying result, for the events whose hosts honour a denial; `updatedInput` is never legal while denying. */ | ||
| export type HookDenyResult<E extends HookHandlerEventName> = HookResultContract[E]['deny'] extends true | ||
| ? { | ||
| readonly outcome: 'deny'; | ||
| /** Non-empty: the wrapper rejects a denial without one. */ | ||
| readonly reason: string; | ||
| readonly updatedInput?: never; | ||
| } & WithAdditionalContext<E> | ||
| : never; | ||
|
|
||
| /** | ||
| * The result a handler for event `E` may return. Returning nothing | ||
| * (`void`) continues; the generated wrapper still validates every returned | ||
| * object at hook time, so JavaScript and prebuilt handlers keep the same | ||
| * runtime contract. | ||
| */ | ||
| export type HookResult<E extends HookHandlerEventName> = HookContinueResult<E> | HookDenyResult<E>; | ||
|
|
||
| /** | ||
| * The payload fields every handler event carries. Fields are required | ||
| * only when every host's wrapper guarantees them; `sessionId` is the one | ||
| * identity every host names. `transcriptPath` is `null` on Codex when no | ||
| * rollout exists yet. | ||
| */ | ||
| export interface HookEventBase { | ||
| readonly cwd?: string; | ||
| readonly effort?: string; | ||
| readonly hookEventName?: string; | ||
| readonly model?: string; | ||
| readonly permissionMode?: string; | ||
| readonly promptId?: string; | ||
| readonly sessionId: string; | ||
| readonly transcriptPath?: string | null; | ||
| readonly turnId?: string; | ||
| } | ||
|
|
||
| export interface SessionStartHookEvent extends HookEventBase { | ||
| /** How the session began (`startup`, `resume`, …); absent on Cursor. */ | ||
| readonly source?: string; | ||
| } | ||
|
|
||
| export interface BeforeToolHookEvent extends HookEventBase { | ||
| /** The pending call's input; an object on Claude and Cursor, any JSON value on Codex. */ | ||
| readonly toolInput: JsonValue; | ||
| readonly toolName: string; | ||
| readonly toolUseId: string; | ||
| } | ||
|
|
||
| export interface AfterToolHookEvent extends BeforeToolHookEvent { | ||
| /** The completed call's response; Claude delivers an MCP tool's response as a plain string. */ | ||
| readonly toolResponse: JsonValue; | ||
| } | ||
|
|
||
| export interface StopHookEvent extends HookEventBase { | ||
| readonly lastAssistantMessage?: string | null; | ||
| /** Whether this stop hook is already running for the turn (loop guard). */ | ||
| readonly stopHookActive: boolean; | ||
| } | ||
|
|
||
| export interface AgentStartHookEvent extends HookEventBase { | ||
| readonly agentId: string; | ||
| readonly agentType: string; | ||
| /** The spawning tool call on Cursor. */ | ||
| readonly toolUseId?: string; | ||
| } | ||
|
|
||
| export interface AgentStopHookEvent extends HookEventBase { | ||
| /** Absent on Cursor, whose subagentStop names no agent id. */ | ||
| readonly agentId?: string; | ||
| readonly agentTranscriptPath?: string | null; | ||
| readonly agentType: string; | ||
| readonly lastAssistantMessage?: string | null; | ||
| readonly stopHookActive: boolean; | ||
| } | ||
|
|
||
| /** The per-event payload a config hook handler receives, keyed by handler event. */ | ||
| export interface HookEventPayloads { | ||
| readonly afterTool: AfterToolHookEvent; | ||
| readonly agentStart: AgentStartHookEvent; | ||
| readonly agentStop: AgentStopHookEvent; | ||
| readonly beforeTool: BeforeToolHookEvent; | ||
| readonly sessionStart: SessionStartHookEvent; | ||
| readonly stop: StopHookEvent; | ||
| } | ||
|
|
||
| /** The event payload a handler for event `E` receives. */ | ||
| export type HookEvent<E extends HookHandlerEventName> = HookEventPayloads[E]; | ||
|
|
||
| /** | ||
| * The payload fields per canonical event, as the wrappers decode them: the | ||
| * runtime twin of {@link HookEventPayloads}, so a test can hold a generated | ||
| * wrapper's decoder to the same field set the types declare. `required` | ||
| * fields are present on every host; `optional` ones are host-dependent. | ||
| */ | ||
| export const hookEventFields = { | ||
| afterTool: { | ||
| optional: ['cwd', 'effort', 'hookEventName', 'model', 'permissionMode', 'promptId', 'transcriptPath', 'turnId'], | ||
| required: ['sessionId', 'toolInput', 'toolName', 'toolResponse', 'toolUseId'], | ||
| }, | ||
| agentStart: { | ||
| optional: ['cwd', 'effort', 'hookEventName', 'model', 'permissionMode', 'promptId', 'toolUseId', 'transcriptPath', 'turnId'], | ||
| required: ['agentId', 'agentType', 'sessionId'], | ||
| }, | ||
| agentStop: { | ||
| optional: ['agentId', 'agentTranscriptPath', 'cwd', 'effort', 'hookEventName', 'lastAssistantMessage', 'model', 'permissionMode', 'promptId', 'transcriptPath', 'turnId'], | ||
| required: ['agentType', 'sessionId', 'stopHookActive'], | ||
| }, | ||
| beforeTool: { | ||
| optional: ['cwd', 'effort', 'hookEventName', 'model', 'permissionMode', 'promptId', 'transcriptPath', 'turnId'], | ||
| required: ['sessionId', 'toolInput', 'toolName', 'toolUseId'], | ||
| }, | ||
| sessionStart: { | ||
| optional: ['cwd', 'effort', 'hookEventName', 'model', 'permissionMode', 'promptId', 'source', 'transcriptPath', 'turnId'], | ||
| required: ['sessionId'], | ||
| }, | ||
| stop: { | ||
| optional: ['cwd', 'effort', 'hookEventName', 'lastAssistantMessage', 'model', 'permissionMode', 'promptId', 'transcriptPath', 'turnId'], | ||
| required: ['sessionId', 'stopHookActive'], | ||
| }, | ||
| } as const satisfies Readonly<Record<HookHandlerEventName, { readonly optional: readonly string[]; readonly required: readonly string[] }>>; | ||
|
|
||
| export type HookEventFields = typeof hookEventFields; | ||
|
|
||
| /** | ||
| * What the generated wrapper hands a handler beside the decoded event: the | ||
| * host that invoked it, that host's native event name, and the validated | ||
| * native input verbatim, for a handler that needs a host-specific field. | ||
| */ | ||
| export interface HookHandlerContext { | ||
| readonly nativeEvent: string; | ||
| readonly nativeInput: Readonly<Record<string, unknown>>; | ||
| readonly target: 'claude' | 'codex' | 'cursor'; | ||
| } | ||
|
|
||
| /** | ||
| * A config-declared hook handler module's default export for event `E`. Author it as | ||
| * `export default ((event) => ({ ... })) satisfies HookHandler<'sessionStart'>;` | ||
| * and an illegal result — a denying `sessionStart`, a `reason` beside | ||
| * `continue`, `updatedInput` on a `stop` hook — is rejected by `tsc` instead | ||
| * of by the wrapper at hook time. The wrapper's runtime validation is | ||
| * unchanged, so a JavaScript or prebuilt handler is held to the same contract. | ||
| */ | ||
| export type HookHandler<E extends HookHandlerEventName> = ( | ||
| event: HookEvent<E>, | ||
| context: HookHandlerContext, | ||
| ) => HookResult<E> | undefined | void | Promise<HookResult<E> | undefined | void>; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When Cursor invokes
agentStopwith the validerrororabortedstatus, an unconditional denial allowed by this portable type reachesencodeOutputand throws because Cursor consumesfollowup_messageonly forstatus === "completed". The new contract therefore admits a handler that cannot run across all valid Cursor payloads as advertised; exclude denial from the portable result or model and document the status-dependent restriction.AGENTS.md reference: AGENTS.md:L78-L81
Useful? React with 👍 / 👎.