Problem
A config-declared hook (hooks: { sessionStart: { handler: './src/hooks/session-start.ts' } }) is a plain function module, and the framework exports no type for either side of it: neither the event payload the generated wrapper hands the handler nor the four-key result the wrapper validates. Every author restates both by hand, so the documented contract lives in prose and in three copies of the same interface, and a handler that returns an illegal combination (e.g. sessionStart denying) is caught only at runtime by the wrapper.
Evidence (main 833e48fdc)
Three restatement sites, identical shape:
website/docs/en/guide/authoring/hooks.mdx:96-107 — the "handler contract" section itself hand-writes interface SessionStartEvent { cwd?, sessionId?, source?, transcriptPath? } and returns { additionalContext, outcome: 'continue' as const }.
examples/hooks-and-scripts/src/hooks/session-start.ts:1-15 — same interface, same result literal.
examples/mcp-app/src/hooks/session-start.ts:1-14 — same interface (minus transcriptPath), same result literal.
The framework already owns the contract it asks authors to guess:
packages/agent-bundle/src/adapters/hook-contract.ts:833-848 — the generated validateResult admits exactly outcome | reason | updatedInput | additionalContext and enforces the per-event rules (sessionStart/afterTool cannot deny/stop/replace input; beforeTool cannot stop; stop only continue-or-deny-with-reason; subagent events cannot stop or replace input).
packages/agent-bundle/src/adapters/hook-contract.ts:820-830 — the wrapper builds the native input from canonicalInput (sessionId, toolInput, toolName, toolUseId, toolResponse, transcriptPath, …), so the per-family fields are known at compile time.
packages/agent-bundle/src/core/types.ts:31-41 — canonicalHookEvents / CanonicalHookEvent already name the families.
packages/agent-bundle/src/index.ts / config/index.ts — export AgentBundleHookEntry, AgentBundleHookInput, CanonicalHookEvent, but nothing describing the handler function.
Found while auditing the examples for hand-rolled equivalents of public surface (#471): this was the one recurring restatement with no public type to point at.
Relationship to #466
#466 asks for a canonical per-family payload on AgentEventRouteProps for RSC event routes (src/events/**). This issue is the plain config hook handler surface (hooks.<event>.handler): its event parameter and its result type, including the per-event legality rules the wrapper enforces. They should share one per-family payload table, but #466 does not give a config hook handler a type, and a handler's result contract has no counterpart in #466.
Proposed shape
Export from agent-bundle (and re-export from agent-bundle/config, where handlers are declared):
type HookEvent<E extends CanonicalHookEvent> = /* per-family payload, e.g. */
E extends 'sessionStart' ? { readonly cwd?: string; readonly sessionId?: string; readonly source?: string; readonly transcriptPath?: string }
: E extends 'beforeTool' | 'afterTool' ? { readonly cwd?: string; readonly sessionId?: string; readonly toolName: string; readonly toolInput: JsonValue; readonly toolUseId?: string; /* afterTool: */ readonly toolResponse?: JsonValue }
: /* stop, agentStart, agentStop … */;
type HookResult<E extends CanonicalHookEvent> = /* the legal subset per family, e.g. */
E extends 'sessionStart' | 'afterTool' | 'agentStart' ? { readonly outcome?: 'continue'; readonly additionalContext?: string }
: E extends 'beforeTool' ? { readonly outcome?: 'continue'; readonly additionalContext?: string; readonly updatedInput?: JsonObject } | { readonly outcome: 'deny'; readonly reason: string; readonly additionalContext?: string }
: E extends 'stop' ? { readonly outcome?: 'continue' } | { readonly outcome: 'deny'; readonly reason: string }
: /* agentStop … */;
type HookHandler<E extends CanonicalHookEvent> = (event: HookEvent<E>) => HookResult<E> | void | Promise<HookResult<E> | void>;
Authoring becomes export default ((event) => ({ … })) satisfies HookHandler<'sessionStart'>; with the illegal combinations rejected by tsc instead of by the wrapper at hook time. Keep the wrapper's runtime validation unchanged (JS authors, prebuilt handlers).
Acceptance
HookHandler, HookEvent, HookResult are public exports of agent-bundle and agent-bundle/config, and appear in the TypeDoc API pages.
hooks.mdx "The handler contract" (en + zh) shows the typed form and drops the hand-written interface; the four-key table stays as the runtime contract.
examples/hooks-and-scripts/src/hooks/session-start.ts and examples/mcp-app/src/hooks/session-start.ts use satisfies HookHandler<'sessionStart'> with no local interface.
- A
sessionStart handler returning { outcome: 'deny', reason: 'x' } fails pnpm typecheck in the examples; the same module still fails the wrapper at runtime.
- The per-family field set is derived from the same source the adapters use to encode
canonicalInput, so it cannot drift from hook-contract.ts.
Problem
A config-declared hook (
hooks: { sessionStart: { handler: './src/hooks/session-start.ts' } }) is a plain function module, and the framework exports no type for either side of it: neither the event payload the generated wrapper hands the handler nor the four-key result the wrapper validates. Every author restates both by hand, so the documented contract lives in prose and in three copies of the sameinterface, and a handler that returns an illegal combination (e.g.sessionStartdenying) is caught only at runtime by the wrapper.Evidence (main
833e48fdc)Three restatement sites, identical shape:
website/docs/en/guide/authoring/hooks.mdx:96-107— the "handler contract" section itself hand-writesinterface SessionStartEvent { cwd?, sessionId?, source?, transcriptPath? }and returns{ additionalContext, outcome: 'continue' as const }.examples/hooks-and-scripts/src/hooks/session-start.ts:1-15— same interface, same result literal.examples/mcp-app/src/hooks/session-start.ts:1-14— same interface (minustranscriptPath), same result literal.The framework already owns the contract it asks authors to guess:
packages/agent-bundle/src/adapters/hook-contract.ts:833-848— the generatedvalidateResultadmits exactlyoutcome | reason | updatedInput | additionalContextand enforces the per-event rules (sessionStart/afterToolcannot deny/stop/replace input;beforeToolcannot stop;stoponly continue-or-deny-with-reason; subagent events cannot stop or replace input).packages/agent-bundle/src/adapters/hook-contract.ts:820-830— the wrapper builds the native input fromcanonicalInput(sessionId,toolInput,toolName,toolUseId,toolResponse,transcriptPath, …), so the per-family fields are known at compile time.packages/agent-bundle/src/core/types.ts:31-41—canonicalHookEvents/CanonicalHookEventalready name the families.packages/agent-bundle/src/index.ts/config/index.ts— exportAgentBundleHookEntry,AgentBundleHookInput,CanonicalHookEvent, but nothing describing the handler function.Found while auditing the examples for hand-rolled equivalents of public surface (#471): this was the one recurring restatement with no public type to point at.
Relationship to #466
#466 asks for a canonical per-family payload on
AgentEventRoutePropsfor RSC event routes (src/events/**). This issue is the plain config hook handler surface (hooks.<event>.handler): its event parameter and its result type, including the per-event legality rules the wrapper enforces. They should share one per-family payload table, but #466 does not give a config hook handler a type, and a handler's result contract has no counterpart in #466.Proposed shape
Export from
agent-bundle(and re-export fromagent-bundle/config, where handlers are declared):Authoring becomes
export default ((event) => ({ … })) satisfies HookHandler<'sessionStart'>;with the illegal combinations rejected bytscinstead of by the wrapper at hook time. Keep the wrapper's runtime validation unchanged (JS authors, prebuilt handlers).Acceptance
HookHandler,HookEvent,HookResultare public exports ofagent-bundleandagent-bundle/config, and appear in the TypeDoc API pages.hooks.mdx"The handler contract" (en + zh) shows the typed form and drops the hand-written interface; the four-key table stays as the runtime contract.examples/hooks-and-scripts/src/hooks/session-start.tsandexamples/mcp-app/src/hooks/session-start.tsusesatisfies HookHandler<'sessionStart'>with no local interface.sessionStarthandler returning{ outcome: 'deny', reason: 'x' }failspnpm typecheckin the examples; the same module still fails the wrapper at runtime.canonicalInput, so it cannot drift fromhook-contract.ts.