Skip to content

feat(hooks): export HookHandler / HookEvent / HookResult — the typed config hook handler contract - #533

Merged
ScriptedAlchemy merged 5 commits into
mainfrom
feat/hook-handler-types
Sep 4, 2026
Merged

feat(hooks): export HookHandler / HookEvent / HookResult — the typed config hook handler contract#533
ScriptedAlchemy merged 5 commits into
mainfrom
feat/hook-handler-types

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Fixes #488.

What

agent-bundle (root) and agent-bundle/config now export the typed contract of a hooks.<event>.handler module, from the new packages/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 payloads SessionStartHookEvent, BeforeToolHookEvent, AfterToolHookEvent, StopHookEvent, AgentStartHookEvent, AgentStopHookEvent (+ HookEventBase, HookEventPayloads). A field is required only when every host's wrapper guarantees it (sessionId always; toolName/toolInput/toolUseId on tool events; stopHookActive on stop events; agentId/agentType on agentStart; agentType on agentStop).
  • HookResult<E> = HookContinueResult<E> | HookDenyResult<E>, derived from the exported per-event table hookResultContract (deny / additionalContext / updatedInput per event). Illegal fields are typed ?: never, so satisfies rejects them regardless of excess-property rules: a denying sessionStart, outcome: 'stop' anywhere, reason beside continue, a denial without reason, updatedInput while denying or on any event but beforeTool, additionalContext on beforeTool/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 by CanonicalHookEvent, because workspaceOpen is 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, AgentBundleHookInput are 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: additionalContext on beforeTool (every validator accepts it, but Cursor's preToolUse output carries only a permission or an input rewrite, so it is delivered on Claude/Codex alone); Cursor admits a denying agentStart; Claude/Codex admit additionalContext on agentStart; Claude admits additionalContext on agentStop. One caveat the type cannot carry is documented instead (rule JSDoc + guide): Cursor consumes an agentStop denial only when the subagent's status is completed.

Can't-drift guarantee. The wrappers' runtime validateResult is unchanged (the issue asks for that: JS and prebuilt handlers keep the same runtime contract). packages/agent-bundle/tests/hook-handler-contract.test.ts loads the generated Claude, Codex, and Cursor wrapper sources with their validators exported and:

  • runs all 32 result shapes over the four admitted keys × 6 events × 3 hosts through the real 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 to hook-contract.ts validateResult or to the table fails the test);
  • runs each host's native fixture through validateNativeInput + the wrapper decoder and checks the decoded field set against hookEventFields (required present, nothing undeclared);
  • compile-time (via pnpm typecheck): keyof HookEvent<E> with its required/optional split equals hookEventFields[E]; seven legal handlers type-check; ten illegal results are @ts-expect-error (including beforeTool context and a workspaceOpen handler).

Consumer code replaced

  • examples/hooks-and-scripts/src/hooks/session-start.ts, examples/mcp-app/src/hooks/session-start.ts: the hand-written interface SessionStartEvent and outcome: 'continue' as const are gone; both are export default ((event) => ({ … })) satisfies HookHandler<'sessionStart'> (event.sessionId is now known to be a string).
  • examples/hooks-and-scripts gains a tsconfig.json and a typecheck script wired into check, so a sessionStart handler returning { outcome: 'deny', reason } fails pnpm typecheck there 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 AgentEventRouteProps for 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 *HookEvent interfaces, keyed by 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 lint clean; pnpm test:unit 3249/3249 (incl. the new contract test, 4 cases); pnpm build.
  • examples/hooks-and-scripts pnpm check (validate + build + typecheck, Claude host validation passed); examples/mcp-app validate + build + typecheck.
  • pnpm docs:site:build passes (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 on hook-handler.ts, all addressed in df51483d8:
    • P1 "Exclude Cursor-dropped before-tool context": beforeTool.additionalContext is now false in the portable table (Cursor validates the field but its preToolUse output 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.
    • P2 "Model Cursor's conditional agent-stop denial": denial stays in the portable agentStop result (it is a Claude/Codex/Cursor feature) and the status rule is documented where a reader meets it — HookResultRule.deny JSDoc, the table comment, and the hooks guide (en + zh): Cursor consumes an agentStop denial only for status === "completed", so a handler targeting Cursor checks context.nativeInput.status first.
    • P2 "Exclude the unmapped workspace-open handler": the contract is keyed by the new HookHandlerEventName (six events); workspaceOpen has no HookHandler/HookEvent/HookResult, the test derives the event set from the hosts' plain-hook event maps and asserts workspaceOpen is absent, and HookHandler<'workspaceOpen'> is an @ts-expect-error.
    • Re-verified: pnpm typecheck, pnpm lint, the contract test (4/4), examples typecheck, pnpm docs:site:build. Rebased onto current main.
  • Awaiting the reviewer on the current head. Per this task's rules no PR comments are posted; review threads are answered by commits and recorded here.

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 lint clean; pnpm test:unit 3249/3249 (incl. the new contract test, 4 cases); pnpm build.
  • examples/hooks-and-scripts pnpm check (validate + build + typecheck, Claude host validation passed); examples/mcp-app validate + build + typecheck.
  • pnpm docs:site:build passes (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 on hook-handler.ts, all addressed in df51483d8:
    • P1 "Exclude Cursor-dropped before-tool context": beforeTool.additionalContext is now false in the portable table (Cursor validates the field but its preToolUse output 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.
    • P2 "Model Cursor's conditional agent-stop denial": denial stays in the portable agentStop result (it is a Claude/Codex/Cursor feature) and the status rule is documented where a reader meets it — HookResultRule.deny JSDoc, the table comment, and the hooks guide (en + zh): Cursor consumes an agentStop denial only for status === "completed", so a handler targeting Cursor checks context.nativeInput.status first.
    • P2 "Exclude the unmapped workspace-open handler": the contract is keyed by the new HookHandlerEventName (six events); workspaceOpen has no HookHandler/HookEvent/HookResult, the test derives the event set from the hosts' plain-hook event maps and asserts workspaceOpen is absent, and HookHandler<'workspaceOpen'> is an @ts-expect-error.
    • Re-verified: pnpm typecheck, pnpm lint, the contract test (4/4), examples typecheck, pnpm docs:site:build. Rebased onto current main.
  • Awaiting the reviewer on the current head. Per this task's rules no PR comments are posted; review threads are answered by commits and recorded here.

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 lint clean; pnpm test:unit 3249/3249 (incl. the new contract test, 4 cases); pnpm build.
  • examples/hooks-and-scripts pnpm check (validate + build + typecheck, Claude host validation passed); examples/mcp-app validate + build + typecheck.
  • pnpm docs:site:build passes (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 on hook-handler.ts, all addressed in df51483d8:
    • P1 "Exclude Cursor-dropped before-tool context": beforeTool.additionalContext is now false in the portable table (Cursor validates the field but its preToolUse output 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.
    • P2 "Model Cursor's conditional agent-stop denial": denial stays in the portable agentStop result (it is a Claude/Codex/Cursor feature) and the status rule is documented where a reader meets it — HookResultRule.deny JSDoc, the table comment, and the hooks guide (en + zh): Cursor consumes an agentStop denial only for status === "completed", so a handler targeting Cursor checks context.nativeInput.status first.
    • P2 "Exclude the unmapped workspace-open handler": the contract is keyed by the new HookHandlerEventName (six events); workspaceOpen has no HookHandler/HookEvent/HookResult, the test derives the event set from the hosts' plain-hook event maps and asserts workspaceOpen is absent, and HookHandler<'workspaceOpen'> is an @ts-expect-error.
    • Re-verified: pnpm typecheck, pnpm lint, the contract test (4/4), examples typecheck, pnpm docs:site:build. Rebased onto current main.
  • Awaiting the reviewer on the current head. Per this task's rules no PR comments are posted; review threads are answered by commits and recorded here.

@changeset-bot

changeset-bot Bot commented Sep 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6a70b2e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
agent-bundle Patch

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

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-04T06:25:49.850771Z 9236269 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

ScriptedAlchemy added a commit that referenced this pull request Sep 4, 2026
@pkg-pr-new

pkg-pr-new Bot commented Sep 4, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle@533
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@533
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@533

commit: 6a70b2e

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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 },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

…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.
@ScriptedAlchemy
ScriptedAlchemy enabled auto-merge (squash) September 4, 2026 09:32
@ScriptedAlchemy
ScriptedAlchemy merged commit 0554f01 into main Sep 4, 2026
14 checks passed
@ScriptedAlchemy
ScriptedAlchemy deleted the feat/hook-handler-types branch September 4, 2026 12:56
ScriptedAlchemy added a commit that referenced this pull request Sep 4, 2026
…l payload and pin the HookEvent field overlap (rebase over #542, #533)
ScriptedAlchemy added a commit that referenced this pull request Sep 4, 2026
…l payload and pin the HookEvent field overlap (rebase over #542, #533)
ScriptedAlchemy added a commit that referenced this pull request Sep 4, 2026
… 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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Typed hook handler contract: export HookHandler<'sessionStart'> / HookEvent / HookResult instead of restating the interface in docs and every example

1 participant