# AgentHarness hooks design Final design. ## Core model Events carry their result type as a type-only phantom: ```ts declare const HookResult: unique symbol; interface HookEvent { type: TType; readonly [HookResult]?: TResult; } type ResultOf = E extends { readonly [HookResult]?: infer R } ? R : void; type HookHandler = ( event: E, ctx: Ctx, signal?: AbortSignal, ) => ResultOf | void | Promise | void>; type HookObserver = ( event: E, ctx: Ctx, signal?: AbortSignal, ) => void | Promise; ``` Example: ```ts interface ContextEvent extends HookEvent<"context", { messages?: AgentMessage[] }> { type: "context"; messages: AgentMessage[]; } interface ToolCallEvent extends HookEvent<"tool_call", { block?: boolean; reason?: string }> { type: "tool_call"; toolName: string; input: Record; } interface MessageEndEvent extends HookEvent<"message_end"> { type: "message_end"; message: AgentMessage; } ``` No result map. No spec table. The event type defines its own result. ## Hooks interface ```ts interface AgentHarnessHooks, Ctx> { context: Ctx; setContext(ctx: Ctx): void; observe(handler: HookObserver): () => void; on( type: TType, handler: HookHandler, Ctx>, ): () => void; emit( event: TEvent, signal?: AbortSignal, ): Promise | undefined>; addCleanup(cleanup: () => void | Promise): () => void; clear(): Promise; dispose(): Promise; } ``` Important split: - `observe()` sees all events, read-only, return ignored. - `on(type, handler)` participates in that event’s semantics. - `emit(event)` is the only thing `AgentHarness` calls. - `clear()` removes observers/handlers and runs cleanups. ## Default implementation internals ```ts class DefaultAgentHarnessHooks, Ctx> implements AgentHarnessHooks { context: Ctx; private observers = new Set>(); private handlers = new Map>>(); private cleanups = new Set<() => void | Promise>(); constructor(ctx: Ctx) { this.context = ctx; } setContext(ctx: Ctx): void { this.context = ctx; } observe(handler: HookObserver): () => void { this.observers.add(handler); return () => this.observers.delete(handler); } on(type, handler): () => void { let handlers = this.handlers.get(type); if (!handlers) { handlers = new Set(); this.handlers.set(type, handlers); } handlers.add(handler); return () => handlers.delete(handler); } async emit(event, signal?) { for (const observer of this.observers) { await observer(event, this.context, signal); } switch (event.type) { case "context": return this.emitContext(event, signal); case "before_provider_request": return this.emitBeforeProviderRequest(event, signal); case "before_provider_payload": return this.emitBeforeProviderPayload(event, signal); case "before_agent_start": return this.emitBeforeAgentStart(event, signal); case "tool_call": return this.emitToolCall(event, signal); case "tool_result": return this.emitToolResult(event, signal); case "session_before_compact": case "session_before_tree": return this.emitFirstCancelOrLast(event, signal); default: await this.emitObservationHandlers(event, signal); return undefined; } } } ``` Internal casts are acceptable inside the implementation because `Map` loses specificity. Public API remains typed. ## Mutation semantics ### Observation ```ts await hooks.emit({ type: "message_end", message }, signal); ``` Observers run. `message_end` handlers run. Return ignored unless that event later gets a result type. ### Context transform Handlers run in order. Each sees current messages. ```ts let current = event; for (const handler of handlers("context")) { const result = await handler(current, ctx, signal); if (result?.messages) { current = { ...current, messages: result.messages }; } } return current.messages === event.messages ? undefined : { messages: current.messages }; ``` ### Provider request / payload Sequential transform. Each handler sees previous output. ```ts let current = event; for (const handler of handlers("before_provider_payload")) { const result = await handler(current, ctx, signal); if (result !== undefined) { current = { ...current, payload: result.payload }; } } return changed ? { payload: current.payload } : undefined; ``` ### Before agent start Collect injected messages, chain system prompt. ```ts let systemPrompt = event.systemPrompt; const messages = []; for (const handler of handlers("before_agent_start")) { const result = await handler({ ...event, systemPrompt }, ctx, signal); if (result?.messages) messages.push(...result.messages); if (result?.systemPrompt !== undefined) systemPrompt = result.systemPrompt; } return messages.length || systemPrompt !== event.systemPrompt ? { messages, systemPrompt } : undefined; ``` ### Tool call Sequential, early exit on block. ```ts for (const handler of handlers("tool_call")) { const result = await handler(event, ctx, signal); if (result?.block) return result; } ``` ### Tool result Sequential patch accumulation. Each handler sees current patched result. ```ts let current = event; let modified = false; for (const handler of handlers("tool_result")) { const result = await handler(current, ctx, signal); if (!result) continue; current = { ...current, content: result.content ?? current.content, details: result.details ?? current.details, isError: result.isError ?? current.isError, }; modified = true; } return modified ? { content: current.content, details: current.details, isError: current.isError } : undefined; ``` ### Session-before events Sequential, early exit on cancel. ```ts let last; for (const handler of handlers(event.type)) { const result = await handler(event, ctx, signal); if (!result) continue; last = result; if (result.cancel) return result; } return last; ``` ## Harness usage Harness only does this: ```ts await this.hooks.emit(event, signal); ``` or: ```ts const result = await this.hooks.emit({ type: "context", messages }, signal); return result?.messages ?? messages; ``` Harness does not store handlers, chain listeners, or know extension policy. ## Context Context is a normal object, not rebuilt per emit. ```ts const hooks = new CodingAgentHooks({ harness: harnessFacade, session: sessionFacade, ui: noUiFacade, }); ``` Later: ```ts hooks.setContext({ ...hooks.context, ui: tuiFacade, }); ``` For dynamic state, prefer stable facades/methods over getter maze: ```ts interface CodingAgentHookContext { harness: HarnessFacade; session: SessionFacade; ui: UiFacade; models: ModelFacade; } ``` Per-run `signal` is passed as the third handler arg. ## Extension loading later Extension loading can live next to harness and construct hooks: ```ts const hooks = await loadExtensions({ paths, context, hooks: new CodingAgentHooks(context), }); const harness = new AgentHarness({ ..., hooks }); ``` The loader registers into hooks: ```ts hooks.on("context", handler); hooks.on("tool_call", handler); hooks.addCleanup(cleanup); ``` For reload: ```ts await hooks.clear(); const nextHooks = await loadExtensions(...); harness.setHooks(nextHooks); // idle-only if supported ``` ## Poking holes ### 1. Error policy must be explicit Existing coding-agent catches extension errors, reports them, and continues. New hooks need the same policy, likely: ```ts errorMode: "continue" | "throw" onError(error) ``` For coding-agent, default should be `"continue"`. ### 2. Source metadata matters Existing runner knows which extension produced an error/resource/tool. Plain `on()` loses that unless we add registration metadata or scopes. Probably needed: ```ts const scope = hooks.createScope({ sourceInfo }); scope.on("context", handler); scope.addCleanup(...); ``` Or `on(type, handler, { sourceInfo })`. ### 3. Some extension capabilities are registries, not hooks These are not covered by `emit()` and should stay as registries on `CodingAgentHooks` or an extension host: - tools - commands - shortcuts - flags - message renderers - provider registrations - OAuth providers - custom model providers That is fine. They do not belong in `AgentHarness`. ### 4. Existing coding-agent events can be represented No blocker for: - `context` - `before_provider_request` - `after_provider_response` - `before_agent_start` - `message_end` - `tool_call` - `tool_result` - `input` - `user_bash` - `resources_discover` - `session_before_*` - `session_*` - model/thinking selection events - agent/turn/message/tool lifecycle events They become additional event types handled by `CodingAgentHooks`. ### 5. Need to preserve exact old semantics When porting coding-agent, special cases must be copied: - `input`: transform chain, `handled` short-circuits. - `user_bash`: first meaningful result wins. - `message_end`: replacement must keep same role. - `before_agent_start`: `ctx.getSystemPrompt()` must reflect current chained prompt. - `resources_discover`: aggregate paths and keep extension source. - `tool_call`: argument mutation remains visible to later handlers. - `tool_result`: later handlers see prior patches. The design allows all of that, but the default/coding hooks implementation must encode it. ### 6. `emit()` switch can miss custom mutation events If a subclass adds a result-producing event but forgets to override `emit()`, it will behave observationally. Tests should catch this. Could add a protected strategy registry later if this becomes error-prone, but not initially. ### 7. Observer semantics are intentionally limited Observers see the original emitted event once. They do not see every intermediate mutation. If something needs final transformed state, emit a separate final event or use an event-specific handler. ## Verdict This design can implement a new coding-agent. It is simpler than the current runner, keeps harness clean, and preserves the important extension capabilities as long as `CodingAgentHooks` adds source-aware scopes, registries, cleanup, and the exact old event semantics. --- Comments --- Thread hn2xk0tzhj on "addCleanup(cleanup" [tmluyaub9v] Owner (2026-05-14T12:55:45.500Z): cleanup should be passed along optionally to on/observe