Symptom in cargo-hauler
cargo-hauler's four event routes (src/events/**) cannot use the payload the framework hands them without a host-specific bridge. ScriptedAlchemy/cargo-hauler#43 (framework-mode audit, finding 7) records it as a framework gap, not plugin drift: AgentEventRouteProps.native is the raw host envelope and no canonical payload accessor is exported, so the plugin keeps src/lib/event-support.ts to paper over:
- session id:
session_id (Claude/Codex) vs conversation_id (Cursor) — event-support.ts:56, :99
- tool result:
tool_response object (Claude/Codex) vs tool_output JSON string (Cursor) — event-support.ts:69-77
- stop re-entry:
stop_hook_active boolean vs Cursor loop_count number — event-support.ts:87-95
tool_name, tool_input, tool_use_id, cwd read by key from native — event-support.ts:55-59
Every cross-host plugin with a tool/before, tool/after, or stop route has to rediscover and re-implement the same table.
Root cause in agent-bundle (main 10a98a0fb)
packages/agent-bundle/src/routes/public.ts:53 — AgentEventNativePayload = Readonly<Record<string, unknown>>; :64-68 — AgentEventRouteProps is { canonical, native, signal } where canonical (:44-50) carries only identity (event, idempotencyKey, observedAt, provenance, sequence), never payload.
- The framework already knows every per-host field: the wrapper validates the envelope per host and per event (
website/docs/en/guide/authoring/hooks.mdx:229 "validates it per host and per event"), the pinned capability tables enumerate the input fields (capabilities/codex-0.147.0.json:812-815, capabilities/cursor-2026-08-28.json:593), and hook-contract.ts:148-149 builds tool_input/tool_name when encoding playground input. Only the read side is left to the route.
await agent() gives host/session/actor/workspace as Observed axes, so session identity is partly covered; tool name/input/response and stop re-entry are not.
Workaround currently in cargo-hauler
src/lib/event-support.ts (whole module): beforeShellEventFrom, afterShellEventFrom, stopHoldEventFrom, hookContextFrom, consumed by src/events/tool/before.tsx, src/events/tool/after.tsx, src/events/stop.tsx, src/events/session/start.tsx.
Proposed fix
Add a typed, per-family canonical payload next to native (keep native for anything not modelled):
interface AgentEventRouteProps<F extends CanonicalAgentEvent = CanonicalAgentEvent> {
readonly canonical: AgentEventCanonicalIdentity;
readonly payload: CanonicalEventPayload[F]; // new
readonly native: AgentEventNativePayload;
readonly signal: AbortSignal;
}
// tool/before: { toolName, toolInput: JsonValue, toolUseId?, cwd? }
// tool/after: tool/before + { toolResponse: JsonValue } (Cursor's string parsed when it is JSON, else kept as string)
// stop / agent/stop: { reentry: boolean } (stop_hook_active || loop_count > 0)
// session/start: { source? }
- Derived in the generated wrapper from the already-validated envelope, using the per-host tables the adapters own; fields a host does not report are typed
Observed/optional, never fabricated (same rule as canonical).
- Surfaced in the Workbench playground and
agent-bundle/test runEventRoute so fixtures exercise payload, not raw keys.
- Docs:
website/docs/{en,zh}/guide/authoring/hooks.mdx route props section; generated events reference unchanged.
Acceptance
- A
tool/before route reading only payload.toolName / payload.toolInput behaves identically under the checked-in Claude, Codex, and Cursor native fixtures.
- A
stop route reading payload.reentry sees true for Claude/Codex stop_hook_active: true and Cursor loop_count: 1.
- cargo-hauler can delete
src/lib/event-support.ts (except decisionValue) once pinned to the fix.
Symptom in cargo-hauler
cargo-hauler's four event routes (
src/events/**) cannot use the payload the framework hands them without a host-specific bridge. ScriptedAlchemy/cargo-hauler#43 (framework-mode audit, finding 7) records it as a framework gap, not plugin drift:AgentEventRouteProps.nativeis the raw host envelope and no canonical payload accessor is exported, so the plugin keepssrc/lib/event-support.tsto paper over:session_id(Claude/Codex) vsconversation_id(Cursor) —event-support.ts:56,:99tool_responseobject (Claude/Codex) vstool_outputJSON string (Cursor) —event-support.ts:69-77stop_hook_activeboolean vs Cursorloop_countnumber —event-support.ts:87-95tool_name,tool_input,tool_use_id,cwdread by key fromnative—event-support.ts:55-59Every cross-host plugin with a
tool/before,tool/after, orstoproute has to rediscover and re-implement the same table.Root cause in agent-bundle (main
10a98a0fb)packages/agent-bundle/src/routes/public.ts:53—AgentEventNativePayload = Readonly<Record<string, unknown>>;:64-68—AgentEventRoutePropsis{ canonical, native, signal }wherecanonical(:44-50) carries only identity (event,idempotencyKey,observedAt,provenance,sequence), never payload.website/docs/en/guide/authoring/hooks.mdx:229"validates it per host and per event"), the pinned capability tables enumerate the input fields (capabilities/codex-0.147.0.json:812-815,capabilities/cursor-2026-08-28.json:593), andhook-contract.ts:148-149buildstool_input/tool_namewhen encoding playground input. Only the read side is left to the route.await agent()gives host/session/actor/workspace asObservedaxes, so session identity is partly covered; tool name/input/response and stop re-entry are not.Workaround currently in cargo-hauler
src/lib/event-support.ts(whole module):beforeShellEventFrom,afterShellEventFrom,stopHoldEventFrom,hookContextFrom, consumed bysrc/events/tool/before.tsx,src/events/tool/after.tsx,src/events/stop.tsx,src/events/session/start.tsx.Proposed fix
Add a typed, per-family canonical payload next to
native(keepnativefor anything not modelled):Observed/optional, never fabricated (same rule ascanonical).agent-bundle/testrunEventRouteso fixtures exercisepayload, not raw keys.website/docs/{en,zh}/guide/authoring/hooks.mdxroute props section; generated events reference unchanged.Acceptance
tool/beforeroute reading onlypayload.toolName/payload.toolInputbehaves identically under the checked-in Claude, Codex, and Cursor native fixtures.stoproute readingpayload.reentryseestruefor Claude/Codexstop_hook_active: trueand Cursorloop_count: 1.src/lib/event-support.ts(exceptdecisionValue) once pinned to the fix.