Skip to content

Commit 2fc2b2d

Browse files
fix(hooks): make the handler contract delivery-portable and scope it to mapped events
- beforeTool additionalContext leaves the portable table: every validator accepts it but Cursor's preToolUse output carries only a permission or an input rewrite, so it is delivered on Claude and Codex alone. - HookHandlerEventName (the six events every host maps to a plain hook) replaces CanonicalHookEvent as the contract's key; workspaceOpen has no handler contract because no host maps it as a plain hook. - Cursor's status-conditional agentStop denial is documented on the rule, in the JSDoc, and in the guide. - The agreement test derives the event set from the host event maps and pins the before-tool context shapes as validator-only leniencies.
1 parent 7d8b2c3 commit 2fc2b2d

7 files changed

Lines changed: 126 additions & 71 deletions

File tree

‎.changeset/488-hook-handler-types.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
'agent-bundle': patch
33
---
44

5-
Export the typed config hook handler contract from `agent-bundle` and `agent-bundle/config`: `HookHandler<E>`, `HookEvent<E>`, and `HookResult<E>` for every `CanonicalHookEvent` (now exported too, with `AgentBundleHookEntry` and `AgentBundleHookInput`), the per-event payloads (`SessionStartHookEvent`, `BeforeToolHookEvent`, `AfterToolHookEvent`, `StopHookEvent`, `AgentStartHookEvent`, `AgentStopHookEvent`, `WorkspaceOpenHookEvent`, `HookEventBase`, `HookEventPayloads`), the second handler argument `HookHandlerContext`, and the tables the types derive from (`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 generated wrappers' runtime validation is unchanged and a test holds the types to exactly what every host wrapper accepts. Fixes #488. (#533)
5+
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)

‎packages/agent-bundle/src/adapters/hook-handler.ts‎

Lines changed: 55 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,48 @@ import type { JsonObject, JsonValue } from '../dev/types.ts';
66
* #488): the event payload the generated wrapper hands the default export and
77
* the result it validates before projecting it into host-native output.
88
*
9-
* Both sides are derived from one table per canonical event so that the
10-
* types and the wrappers' runtime `validateResult` cannot drift: the table is
11-
* the *portable* contract — what every host wrapper (`Claude`, `Codex`,
12-
* `Universal`, and `cursor`) accepts for that event — and
13-
* `tests/hook-handler-contract.test.ts` runs the generated wrappers against
14-
* it. Host-specific leniencies (Cursor admits a denying `agentStart`; Claude
15-
* carries `additionalContext` on `agentStop`) are not part of the type,
16-
* because a handler typed here must run unchanged on every host it targets.
17-
* The wrappers keep their runtime validation for untyped and prebuilt handlers.
9+
* Both sides are derived from one table per handler event so that the types
10+
* and the wrappers' runtime `validateResult` cannot drift: the table is the
11+
* *portable* contract — a result field it admits is accepted by every host
12+
* wrapper (`Claude`, `Codex`, `Universal`, and `cursor`) *and* projected into
13+
* that host's native output — and `tests/hook-handler-contract.test.ts` runs
14+
* the generated wrappers against it. Host-specific behaviour is not part of
15+
* the type, because a handler typed here must run unchanged on every host it
16+
* targets: Cursor admits a denying `agentStart`; Claude and Codex accept
17+
* `additionalContext` on `beforeTool` and `agentStart` (Cursor validates but
18+
* has no channel for it there); Claude carries `additionalContext` on
19+
* `agentStop`. The wrappers keep their runtime validation for untyped and
20+
* prebuilt handlers.
1821
*/
1922

20-
/** What a hook result may carry for one canonical event, on every host. */
23+
/**
24+
* The canonical events a config hook (`hooks.<event>.handler`) can be declared
25+
* for: the events every production host maps to a native plain hook.
26+
* `workspaceOpen` is a `CanonicalHookEvent` but no host maps it as a plain
27+
* hook — it is served by an event route — so it has no handler contract.
28+
*/
29+
export const hookHandlerEventNames = [
30+
'sessionStart',
31+
'beforeTool',
32+
'afterTool',
33+
'stop',
34+
'agentStart',
35+
'agentStop',
36+
] as const satisfies readonly CanonicalHookEvent[];
37+
38+
export type HookHandlerEventName = (typeof hookHandlerEventNames)[number];
39+
40+
/** What a hook result may carry for one handler event, on every host. */
2141
export interface HookResultRule {
22-
/** `additionalContext` is projected into the agent's context. */
42+
/** `additionalContext` is accepted and projected into the agent's context on every host. */
2343
readonly additionalContext: boolean;
24-
/** `{ outcome: 'deny', reason }` is a legal result (a denial always needs a non-empty `reason`). */
44+
/**
45+
* `{ outcome: 'deny', reason }` is a legal result (a denial always needs a
46+
* non-empty `reason`). Cursor consumes an `agentStop` denial only for a
47+
* subagent that completed (`nativeInput.status === "completed"`); denying an
48+
* errored or aborted one fails the hook there, so a handler targeting Cursor
49+
* checks `context.nativeInput.status` first.
50+
*/
2551
readonly deny: boolean;
2652
/** A continuing result may replace the pending call's input with `updatedInput`. */
2753
readonly updatedInput: boolean;
@@ -37,32 +63,32 @@ export const hookResultContract = {
3763
afterTool: { additionalContext: true, deny: false, updatedInput: false },
3864
// Claude and Codex accept `additionalContext` here; Cursor's subagentStart has no context channel.
3965
agentStart: { additionalContext: false, deny: false, updatedInput: false },
40-
// Claude carries `additionalContext` on SubagentStop; Codex and Cursor reject it.
66+
// Claude carries `additionalContext` on SubagentStop; Codex and Cursor reject it. See HookResultRule.deny for Cursor's status rule.
4167
agentStop: { additionalContext: false, deny: true, updatedInput: false },
42-
beforeTool: { additionalContext: true, deny: true, updatedInput: true },
68+
// Claude and Codex project `additionalContext` on PreToolUse; Cursor's preToolUse output carries only a permission or an input rewrite.
69+
beforeTool: { additionalContext: false, deny: true, updatedInput: true },
4370
sessionStart: { additionalContext: true, deny: false, updatedInput: false },
4471
stop: { additionalContext: false, deny: true, updatedInput: false },
45-
workspaceOpen: { additionalContext: true, deny: false, updatedInput: false },
46-
} as const satisfies Readonly<Record<CanonicalHookEvent, HookResultRule>>;
72+
} as const satisfies Readonly<Record<HookHandlerEventName, HookResultRule>>;
4773

4874
export type HookResultContract = typeof hookResultContract;
4975

50-
type WithAdditionalContext<E extends CanonicalHookEvent> = HookResultContract[E]['additionalContext'] extends true
76+
type WithAdditionalContext<E extends HookHandlerEventName> = HookResultContract[E]['additionalContext'] extends true
5177
? { readonly additionalContext?: string }
5278
: { readonly additionalContext?: never };
5379

54-
type WithUpdatedInput<E extends CanonicalHookEvent> = HookResultContract[E]['updatedInput'] extends true
80+
type WithUpdatedInput<E extends HookHandlerEventName> = HookResultContract[E]['updatedInput'] extends true
5581
? { readonly updatedInput?: JsonObject }
5682
: { readonly updatedInput?: never };
5783

5884
/** A continuing result: `outcome` may be omitted, and `reason` is never legal here. */
59-
export type HookContinueResult<E extends CanonicalHookEvent> = {
85+
export type HookContinueResult<E extends HookHandlerEventName> = {
6086
readonly outcome?: 'continue';
6187
readonly reason?: never;
6288
} & WithAdditionalContext<E> & WithUpdatedInput<E>;
6389

6490
/** A denying result, for the events whose hosts honour a denial; `updatedInput` is never legal while denying. */
65-
export type HookDenyResult<E extends CanonicalHookEvent> = HookResultContract[E]['deny'] extends true
91+
export type HookDenyResult<E extends HookHandlerEventName> = HookResultContract[E]['deny'] extends true
6692
? {
6793
readonly outcome: 'deny';
6894
/** Non-empty: the wrapper rejects a denial without one. */
@@ -72,15 +98,15 @@ export type HookDenyResult<E extends CanonicalHookEvent> = HookResultContract[E]
7298
: never;
7399

74100
/**
75-
* The result a handler for canonical event `E` may return. Returning nothing
101+
* The result a handler for event `E` may return. Returning nothing
76102
* (`void`) continues; the generated wrapper still validates every returned
77103
* object at hook time, so JavaScript and prebuilt handlers keep the same
78104
* runtime contract.
79105
*/
80-
export type HookResult<E extends CanonicalHookEvent> = HookContinueResult<E> | HookDenyResult<E>;
106+
export type HookResult<E extends HookHandlerEventName> = HookContinueResult<E> | HookDenyResult<E>;
81107

82108
/**
83-
* The payload fields every canonical hook event carries. Fields are required
109+
* The payload fields every handler event carries. Fields are required
84110
* only when every host's wrapper guarantees them; `sessionId` is the one
85111
* identity every host names. `transcriptPath` is `null` on Codex when no
86112
* rollout exists yet.
@@ -136,21 +162,18 @@ export interface AgentStopHookEvent extends HookEventBase {
136162
readonly stopHookActive: boolean;
137163
}
138164

139-
export type WorkspaceOpenHookEvent = HookEventBase;
140-
141-
/** The per-event payload a config hook handler receives, keyed by canonical event. */
165+
/** The per-event payload a config hook handler receives, keyed by handler event. */
142166
export interface HookEventPayloads {
143167
readonly afterTool: AfterToolHookEvent;
144168
readonly agentStart: AgentStartHookEvent;
145169
readonly agentStop: AgentStopHookEvent;
146170
readonly beforeTool: BeforeToolHookEvent;
147171
readonly sessionStart: SessionStartHookEvent;
148172
readonly stop: StopHookEvent;
149-
readonly workspaceOpen: WorkspaceOpenHookEvent;
150173
}
151174

152-
/** The event payload a handler for canonical event `E` receives. */
153-
export type HookEvent<E extends CanonicalHookEvent> = HookEventPayloads[E];
175+
/** The event payload a handler for event `E` receives. */
176+
export type HookEvent<E extends HookHandlerEventName> = HookEventPayloads[E];
154177

155178
/**
156179
* The payload fields per canonical event, as the wrappers decode them: the
@@ -183,11 +206,7 @@ export const hookEventFields = {
183206
optional: ['cwd', 'effort', 'hookEventName', 'lastAssistantMessage', 'model', 'permissionMode', 'promptId', 'transcriptPath', 'turnId'],
184207
required: ['sessionId', 'stopHookActive'],
185208
},
186-
workspaceOpen: {
187-
optional: ['cwd', 'effort', 'hookEventName', 'model', 'permissionMode', 'promptId', 'transcriptPath', 'turnId'],
188-
required: ['sessionId'],
189-
},
190-
} as const satisfies Readonly<Record<CanonicalHookEvent, { readonly optional: readonly string[]; readonly required: readonly string[] }>>;
209+
} as const satisfies Readonly<Record<HookHandlerEventName, { readonly optional: readonly string[]; readonly required: readonly string[] }>>;
191210

192211
export type HookEventFields = typeof hookEventFields;
193212

@@ -203,15 +222,14 @@ export interface HookHandlerContext {
203222
}
204223

205224
/**
206-
* A config-declared hook handler module's default export for canonical
207-
* event `E`. Author it as
225+
* A config-declared hook handler module's default export for event `E`. Author it as
208226
* `export default ((event) => ({ ... })) satisfies HookHandler<'sessionStart'>;`
209227
* and an illegal result — a denying `sessionStart`, a `reason` beside
210228
* `continue`, `updatedInput` on a `stop` hook — is rejected by `tsc` instead
211229
* of by the wrapper at hook time. The wrapper's runtime validation is
212230
* unchanged, so a JavaScript or prebuilt handler is held to the same contract.
213231
*/
214-
export type HookHandler<E extends CanonicalHookEvent> = (
232+
export type HookHandler<E extends HookHandlerEventName> = (
215233
event: HookEvent<E>,
216234
context: HookHandlerContext,
217235
) => HookResult<E> | undefined | void | Promise<HookResult<E> | undefined | void>;

‎packages/agent-bundle/src/config/index.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export { lowerSkillIr } from '../skills/lower.ts';
3535
export type { SkillIr, SkillHostDocument, SkillTreeLayoutDecision } from '../skills/ir.ts';
3636
export { validateModel, validateSource } from './validate.ts';
3737
// The config hook handler contract (#488), re-exported where `hooks.<event>.handler` is declared.
38-
export { hookEventFields, hookResultContract } from '../adapters/hook-handler.ts';
38+
export { hookEventFields, hookHandlerEventNames, hookResultContract } from '../adapters/hook-handler.ts';
3939
export type {
4040
AfterToolHookEvent,
4141
AgentStartHookEvent,
@@ -48,12 +48,12 @@ export type {
4848
HookEventPayloads,
4949
HookHandler,
5050
HookHandlerContext,
51+
HookHandlerEventName,
5152
HookResult,
5253
HookResultContract,
5354
HookResultRule,
5455
SessionStartHookEvent,
5556
StopHookEvent,
56-
WorkspaceOpenHookEvent,
5757
} from '../adapters/hook-handler.ts';
5858
export type { AgentBundleHookEntry, AgentBundleHookInput, CanonicalHookEvent } from '../core/types.ts';
5959
export type AgentBundleConfig = CoreAgentBundleConfig

‎packages/agent-bundle/src/index.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export type { PortableAuthorConfig, PortableManifestConfig } from './adapters/po
117117

118118
// The config hook handler contract (#488): the payload a `hooks.<event>.handler`
119119
// receives and the result the generated wrapper admits, per canonical event.
120-
export { hookEventFields, hookResultContract } from './adapters/hook-handler.ts';
120+
export { hookEventFields, hookHandlerEventNames, hookResultContract } from './adapters/hook-handler.ts';
121121
export type {
122122
AfterToolHookEvent,
123123
AgentStartHookEvent,
@@ -130,12 +130,12 @@ export type {
130130
HookEventPayloads,
131131
HookHandler,
132132
HookHandlerContext,
133+
HookHandlerEventName,
133134
HookResult,
134135
HookResultContract,
135136
HookResultRule,
136137
SessionStartHookEvent,
137138
StopHookEvent,
138-
WorkspaceOpenHookEvent,
139139
} from './adapters/hook-handler.ts';
140140
export type { AgentBundleHookEntry, AgentBundleHookInput, CanonicalHookEvent } from './core/types.ts';
141141

0 commit comments

Comments
 (0)