feat(hooks): export HookHandler / HookEvent / HookResult — the typed config hook handler contract - #533
Conversation
🦋 Changeset detectedLatest commit: 6a70b2e The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
commit: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9236269b61
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| agentStart: { additionalContext: false, deny: false, updatedInput: false }, | ||
| // Claude carries `additionalContext` on SubagentStop; Codex and Cursor reject it. | ||
| agentStop: { additionalContext: false, deny: true, updatedInput: false }, | ||
| beforeTool: { additionalContext: true, deny: true, updatedInput: true }, |
There was a problem hiding this comment.
Exclude Cursor-dropped before-tool context
When a beforeTool handler targets Cursor, this contract permits additionalContext, but cursorHookWrapperSource accepts the field and then its encodeOutput emits only a denial or an input rewrite for this event, silently dropping the context. This makes a result accepted by HookHandler<'beforeTool'> fail to provide the promised context on one supported host; either exclude this field from the portable contract or preserve it in Cursor's projection.
AGENTS.md reference: AGENTS.md:L78-L81
Useful? React with 👍 / 👎.
| // 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. | ||
| agentStop: { additionalContext: false, deny: true, updatedInput: false }, |
There was a problem hiding this comment.
Model Cursor's conditional agent-stop denial
When Cursor invokes agentStop with the valid error or aborted status, an unconditional denial allowed by this portable type reaches encodeOutput and throws because Cursor consumes followup_message only for status === "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 👍 / 👎.
| beforeTool: { additionalContext: true, deny: true, updatedInput: true }, | ||
| sessionStart: { additionalContext: true, deny: false, updatedInput: false }, | ||
| stop: { additionalContext: false, deny: true, updatedInput: false }, | ||
| workspaceOpen: { additionalContext: true, deny: false, updatedInput: false }, |
There was a problem hiding this comment.
Exclude the unmapped workspace-open handler
Any consumer can now compile a HookHandler<'workspaceOpen'>, yet every production target's plain-hook event map omits workspaceOpen, and the documented supported form is an event route with a different component contract; a config-declared handler therefore always produces a build diagnostic and never reaches a generated handler wrapper. Remove workspaceOpen from this config-handler contract unless a production hook mapping is added.
AGENTS.md reference: AGENTS.md:L11-L16
Useful? React with 👍 / 👎.
3e4e268 to
df51483
Compare
df51483 to
2fc2b2d
Compare
2fc2b2d to
073e953
Compare
073e953 to
0c92b4f
Compare
…kEvent, HookResult) The payload a config hook handler receives and the result the generated wrapper admits, per canonical event, derived from one per-event table (hookResultContract / hookEventFields). A test runs the generated Claude, Codex, and Cursor wrappers against the table so the types and the runtime validateResult cannot drift. The two example session-start hooks and the hooks guide use `satisfies HookHandler<'sessionStart'>`. Fixes #488
…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.
0c92b4f to
c3ebaa4
Compare
… native envelope (#466) (#545) * feat(events): give event routes a canonical per-family payload beside the native envelope (#466) AgentEventRouteProps<E>.canonical.payload carries the fields at least two hosts report for the route's family — tool name/input/response, session id, transcript path, cwd, prompt, agent id/type, stop re-entry, … — each as { value, nativeKey } naming the host key it was read from and absent when the host did not send it. The per-family table (agentEventPayloadFields) and the per-host key table (agentEventPayloadNativeKeys) live in routes/events.ts; events/payload.ts projects through them inside createCanonicalEventProps, so the standalone wrapper, the shared runtime, agent-bundle/test, and the Workbench replay all agree. Each pinned capability table mirrors its host's mapping under hooks.eventRoutes.<event>.payload (held equal by tests/event-payload.test.ts) and the generated events reference renders the field × host → native key matrix per family. agent-bundle/test gains createEventRouteInput; worktree-proximity and rsc-agent-runtime read canonical.payload instead of hand-parsing native; host-test keeps recording native as the host-specific example. * fix(events): freeze the exported payload tables through and mark the required canonical.payload a minor bump (review) * feat(events): admit the 2.1.260 model-switch families to the canonical payload and pin the HookEvent field overlap (rebase over #542, #533)
Fixes #488.
What
agent-bundle(root) andagent-bundle/confignow export the typed contract of ahooks.<event>.handlermodule, from the newpackages/agent-bundle/src/adapters/hook-handler.ts:HookHandler<E>—(event: HookEvent<E>, context: HookHandlerContext) => HookResult<E> | void | Promise<…>; the second argument is what the generated wrapper already passes ({ nativeEvent, nativeInput, target }).HookEvent<E>and the named payloadsSessionStartHookEvent,BeforeToolHookEvent,AfterToolHookEvent,StopHookEvent,AgentStartHookEvent,AgentStopHookEvent(+HookEventBase,HookEventPayloads). A field is required only when every host's wrapper guarantees it (sessionIdalways;toolName/toolInput/toolUseIdon tool events;stopHookActiveon stop events;agentId/agentTypeonagentStart;agentTypeonagentStop).HookResult<E>=HookContinueResult<E> | HookDenyResult<E>, derived from the exported per-event tablehookResultContract(deny/additionalContext/updatedInputper event). Illegal fields are typed?: never, sosatisfiesrejects them regardless of excess-property rules: a denyingsessionStart,outcome: 'stop'anywhere,reasonbesidecontinue, a denial withoutreason,updatedInputwhile denying or on any event butbeforeTool,additionalContextonbeforeTool/stop/agentStart/agentStop.HookHandlerEventName/hookHandlerEventNames— the six canonical events every host maps to a plain hook (sessionStart,beforeTool,afterTool,stop,agentStart,agentStop); the contract is keyed by it, not byCanonicalHookEvent, becauseworkspaceOpenis served by an event route and a config-declared handler for it is a build error on every host.hookEventFields— the runtime twin of the payload types (required/optional field names per event).CanonicalHookEvent,AgentBundleHookEntry,AgentBundleHookInputare exported alongside (they were not public before, contrary to the issue text).The table is the portable contract — a field it admits is accepted by every host's wrapper and reaches that host's native output — because a handler typed once must run unchanged, with the same effect, on every host it targets. Host-specific behaviour stays runtime-only and is pinned by the test:
additionalContextonbeforeTool(every validator accepts it, but Cursor'spreToolUseoutput carries only a permission or an input rewrite, so it is delivered on Claude/Codex alone); Cursor admits a denyingagentStart; Claude/Codex admitadditionalContextonagentStart; Claude admitsadditionalContextonagentStop. One caveat the type cannot carry is documented instead (rule JSDoc + guide): Cursor consumes anagentStopdenial only when the subagent'sstatusiscompleted.Can't-drift guarantee. The wrappers' runtime
validateResultis unchanged (the issue asks for that: JS and prebuilt handlers keep the same runtime contract).packages/agent-bundle/tests/hook-handler-contract.test.tsloads the generated Claude, Codex, and Cursor wrapper sources with their validators exported and:validateResult, asserting that everything the table admits every host accepts, and that the set of shapes a host accepts beyond the table equals exactly the documented leniency set (so any change tohook-contract.tsvalidateResultor to the table fails the test);validateNativeInput+ the wrapper decoder and checks the decoded field set againsthookEventFields(required present, nothing undeclared);pnpm typecheck):keyof HookEvent<E>with its required/optional split equalshookEventFields[E]; seven legal handlers type-check; ten illegal results are@ts-expect-error(includingbeforeToolcontext and aworkspaceOpenhandler).Consumer code replaced
examples/hooks-and-scripts/src/hooks/session-start.ts,examples/mcp-app/src/hooks/session-start.ts: the hand-writteninterface SessionStartEventandoutcome: 'continue' as constare gone; both areexport default ((event) => ({ … })) satisfies HookHandler<'sessionStart'>(event.sessionIdis now known to be a string).examples/hooks-and-scriptsgains atsconfig.jsonand atypecheckscript wired intocheck, so asessionStarthandler returning{ outcome: 'deny', reason }failspnpm typecheckthere as the issue's acceptance requires (it previously had no typecheck at all; the wrapper still fails it at runtime).website/docs/{en,zh}/guide/authoring/hooks.mdx"The handler contract": the typed form replaces the hand-written interface; the four-key table and per-event rules stay as the runtime contract.Relationship to #466
#466 asks for a canonical per-family payload on
AgentEventRoutePropsfor RSC event routes (src/events/**). This PR types the plain config hook handler (hooks.<event>.handler): its event parameter and its result, including the per-event legality the wrapper enforces. The per-family field table here (hookEventFields/ the*HookEventinterfaces, keyed byCanonicalHookEvent) is the natural shared source for #466's payloads, but #466 is not implemented here: event routes still receive{ canonical, native, signal }, and a handler result has no counterpart on that side.Checks
pnpm typecheck,pnpm lintclean;pnpm test:unit3249/3249 (incl. the new contract test, 4 cases);pnpm build.examples/hooks-and-scriptspnpm check(validate + build + typecheck, Claude host validation passed);examples/mcp-appvalidate + build + typecheck.pnpm docs:site:buildpasses (TypeDoc renders the new root/config pages; parity ok).Changeset
.changeset/488-hook-handler-types.md(agent-bundle: patch).Review status
9236269b6— Codex reviewed; three threads onhook-handler.ts, all addressed indf51483d8:beforeTool.additionalContextis nowfalsein the portable table (Cursor validates the field but itspreToolUseoutput has no context channel). The contract's definition is tightened to accepted and delivered on every host; the agreement test pins the five before-tool context shapes as validator-only leniencies on all three hosts, and{ additionalContext } satisfies HookResult<'beforeTool'>is an@ts-expect-error.agentStopresult (it is a Claude/Codex/Cursor feature) and the status rule is documented where a reader meets it —HookResultRule.denyJSDoc, the table comment, and the hooks guide (en + zh): Cursor consumes anagentStopdenial only forstatus === "completed", so a handler targeting Cursor checkscontext.nativeInput.statusfirst.HookHandlerEventName(six events);workspaceOpenhas noHookHandler/HookEvent/HookResult, the test derives the event set from the hosts' plain-hook event maps and assertsworkspaceOpenis absent, andHookHandler<'workspaceOpen'>is an@ts-expect-error.pnpm typecheck,pnpm lint, the contract test (4/4), examples typecheck,pnpm docs:site:build. Rebased onto currentmain.CanonicalHookEvent) is the natural shared source for #466's payloads, but #466 is not implemented here: event routes still receive{ canonical, native, signal }, and a handler result has no counterpart on that side.Checks
pnpm typecheck,pnpm lintclean;pnpm test:unit3249/3249 (incl. the new contract test, 4 cases);pnpm build.examples/hooks-and-scriptspnpm check(validate + build + typecheck, Claude host validation passed);examples/mcp-appvalidate + build + typecheck.pnpm docs:site:buildpasses (TypeDoc renders the new root/config pages; parity ok).Changeset
.changeset/488-hook-handler-types.md(agent-bundle: patch).Review status
9236269b6— Codex reviewed; three threads onhook-handler.ts, all addressed indf51483d8:beforeTool.additionalContextis nowfalsein the portable table (Cursor validates the field but itspreToolUseoutput has no context channel). The contract's definition is tightened to accepted and delivered on every host; the agreement test pins the five before-tool context shapes as validator-only leniencies on all three hosts, and{ additionalContext } satisfies HookResult<'beforeTool'>is an@ts-expect-error.agentStopresult (it is a Claude/Codex/Cursor feature) and the status rule is documented where a reader meets it —HookResultRule.denyJSDoc, the table comment, and the hooks guide (en + zh): Cursor consumes anagentStopdenial only forstatus === "completed", so a handler targeting Cursor checkscontext.nativeInput.statusfirst.HookHandlerEventName(six events);workspaceOpenhas noHookHandler/HookEvent/HookResult, the test derives the event set from the hosts' plain-hook event maps and assertsworkspaceOpenis absent, andHookHandler<'workspaceOpen'>is an@ts-expect-error.pnpm typecheck,pnpm lint, the contract test (4/4), examples typecheck,pnpm docs:site:build. Rebased onto currentmain.CanonicalHookEvent) is the natural shared source for #466's payloads, but #466 is not implemented here: event routes still receive{ canonical, native, signal }, and a handler result has no counterpart on that side.Checks
pnpm typecheck,pnpm lintclean;pnpm test:unit3249/3249 (incl. the new contract test, 4 cases);pnpm build.examples/hooks-and-scriptspnpm check(validate + build + typecheck, Claude host validation passed);examples/mcp-appvalidate + build + typecheck.pnpm docs:site:buildpasses (TypeDoc renders the new root/config pages; parity ok).Changeset
.changeset/488-hook-handler-types.md(agent-bundle: patch).Review status
9236269b6— Codex reviewed; three threads onhook-handler.ts, all addressed indf51483d8:beforeTool.additionalContextis nowfalsein the portable table (Cursor validates the field but itspreToolUseoutput has no context channel). The contract's definition is tightened to accepted and delivered on every host; the agreement test pins the five before-tool context shapes as validator-only leniencies on all three hosts, and{ additionalContext } satisfies HookResult<'beforeTool'>is an@ts-expect-error.agentStopresult (it is a Claude/Codex/Cursor feature) and the status rule is documented where a reader meets it —HookResultRule.denyJSDoc, the table comment, and the hooks guide (en + zh): Cursor consumes anagentStopdenial only forstatus === "completed", so a handler targeting Cursor checkscontext.nativeInput.statusfirst.HookHandlerEventName(six events);workspaceOpenhas noHookHandler/HookEvent/HookResult, the test derives the event set from the hosts' plain-hook event maps and assertsworkspaceOpenis absent, andHookHandler<'workspaceOpen'>is an@ts-expect-error.pnpm typecheck,pnpm lint, the contract test (4/4), examples typecheck,pnpm docs:site:build. Rebased onto currentmain.