packages/agent/docs/hooks.md
Final design.
Events carry their result type as a type-only phantom:
declare const HookResult: unique symbol;
interface HookEvent<TType extends string, TResult = void> {
type: TType;
readonly [HookResult]?: TResult;
}
type ResultOf<E> = E extends { readonly [HookResult]?: infer R } ? R : void;
type HookHandler<E, Ctx> = (
event: E,
ctx: Ctx,
signal?: AbortSignal,
) => ResultOf<E> | void | Promise<ResultOf<E> | void>;
type HookObserver<E, Ctx> = (
event: E,
ctx: Ctx,
signal?: AbortSignal,
) => void | Promise<void>;
Example:
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<string, unknown>;
}
interface MessageEndEvent extends HookEvent<"message_end"> {
type: "message_end";
message: AgentMessage;
}
No result map. No spec table. The event type defines its own result.
interface AgentHarnessHooks<E extends HookEvent<string, unknown>, Ctx> {
context: Ctx;
setContext(ctx: Ctx): void;
observe(handler: HookObserver<E, Ctx>): () => void;
on<TType extends E["type"]>(
type: TType,
handler: HookHandler<Extract<E, { type: TType }>, Ctx>,
): () => void;
emit<TEvent extends E>(
event: TEvent,
signal?: AbortSignal,
): Promise<ResultOf<TEvent> | undefined>;
addCleanup(cleanup: () => void | Promise<void>): () => void;
clear(): Promise<void>;
dispose(): Promise<void>;
}
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.class DefaultAgentHarnessHooks<E extends HookEvent<string, unknown>, Ctx>
implements AgentHarnessHooks<E, Ctx> {
context: Ctx;
private observers = new Set<HookObserver<E, Ctx>>();
private handlers = new Map<string, Set<HookHandler<any, Ctx>>>();
private cleanups = new Set<() => void | Promise<void>>();
constructor(ctx: Ctx) {
this.context = ctx;
}
setContext(ctx: Ctx): void {
this.context = ctx;
}
observe(handler: HookObserver<E, Ctx>): () => 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<string, ...> loses specificity. Public API remains typed.
await hooks.emit({ type: "message_end", message }, signal);
Observers run. message_end handlers run. Return ignored unless that event later gets a result type.
Handlers run in order. Each sees current messages.
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 };
Sequential transform. Each handler sees previous output.
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;
Collect injected messages, chain system prompt.
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;
Sequential, early exit on block.
for (const handler of handlers("tool_call")) {
const result = await handler(event, ctx, signal);
if (result?.block) return result;
}
Sequential patch accumulation. Each handler sees current patched result.
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;
Sequential, early exit on cancel.
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 only does this:
await this.hooks.emit(event, signal);
or:
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 is a normal object, not rebuilt per emit.
const hooks = new CodingAgentHooks({
harness: harnessFacade,
session: sessionFacade,
ui: noUiFacade,
});
Later:
hooks.setContext({
...hooks.context,
ui: tuiFacade,
});
For dynamic state, prefer stable facades/methods over getter maze:
interface CodingAgentHookContext {
harness: HarnessFacade;
session: SessionFacade;
ui: UiFacade;
models: ModelFacade;
}
Per-run signal is passed as the third handler arg.
Extension loading can live next to harness and construct hooks:
const hooks = await loadExtensions({
paths,
context,
hooks: new CodingAgentHooks(context),
});
const harness = new AgentHarness({ ..., hooks });
The loader registers into hooks:
hooks.on("context", handler);
hooks.on("tool_call", handler);
hooks.addCleanup(cleanup);
For reload:
await hooks.clear();
const nextHooks = await loadExtensions(...);
harness.setHooks(nextHooks); // idle-only if supported
Existing coding-agent catches extension errors, reports them, and continues. New hooks need the same policy, likely:
errorMode: "continue" | "throw"
onError(error)
For coding-agent, default should be "continue".
Existing runner knows which extension produced an error/resource/tool. Plain on() loses that unless we add registration metadata or scopes.
Probably needed:
const scope = hooks.createScope({ sourceInfo });
scope.on("context", handler);
scope.addCleanup(...);
Or on(type, handler, { sourceInfo }).
These are not covered by emit() and should stay as registries on CodingAgentHooks or an extension host:
That is fine. They do not belong in AgentHarness.
No blocker for:
contextbefore_provider_requestafter_provider_responsebefore_agent_startmessage_endtool_calltool_resultinputuser_bashresources_discoversession_before_*session_*They become additional event types handled by CodingAgentHooks.
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.
emit() switch can miss custom mutation eventsIf 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.
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.
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