Hooks
Hooks are the colon-named event taxonomy an addon subscribes to. They are not a
separate system from extensions — in this rebuild, lifecycle observation, payload
transformation, and veto are unified into one EventDispatcher that an addon feeds
through surface.on(...). The dotted-name "hook factory" model from older docs no
longer exists.
The implementation is indus-code-rebuild/src/addons/dispatch/event-dispatcher.ts,
with the types declared in src/addons/contract.ts. See extensions
for how to register a handler.
Status: The dispatcher is fully implemented and tested, but the events are not yet emitted by the conductor — the engine exists and runs, but the coding-agent loop does not currently dispatch these events through it. There is no
--hookflag and nohooksdirectory. This page documents the contract; seeFEATURE_GAP_ROADMAP.mdfor the integration status.
The Three Handler Kinds
Every handler picks exactly one HookKind, which selects what its run callback
returns:
type HookHandler<TPayload> =
| { kind: "observe"; run(p: TPayload): void | Promise<void> }
| { kind: "transform"; run(p: TPayload): TPayload | Promise<TPayload> }
| { kind: "gate"; run(p: TPayload): GateDecision | void | Promise<GateDecision | void> };
| Kind | Returns | Effect |
|---|---|---|
observe |
nothing | fire-and-forget; cannot alter or veto |
transform |
a replacement payload | threaded into later handlers and back to the caller |
gate |
a GateDecision |
{ stop: true } short-circuits the event |
A GateDecision is { stop: boolean; reason?: string }.
The Events
The full HookEvent set (src/addons/contract.ts). The "Guards" column marks the
reserved, gate-bearing events — the ones that front a side-effecting step the
host can refuse to take:
| Event | Guards | Description |
|---|---|---|
session:start |
a session opened | |
session:end |
a session closed | |
turn:start |
an assistant turn began | |
turn:end |
an assistant turn settled | |
tool:before |
✓ | straddles a tool execution — a gate blocks the call |
tool:after |
a tool finished — observe/transform the result | |
chat:params |
the model request options are being built | |
chat:message |
an assistant message was assembled | |
shell:env |
the environment for a shell action is being prepared | |
input:submit |
✓ | user input entering the loop — a gate drops the turn |
context:build |
the message context is being assembled | |
compact:build |
the transcript condense input is being built | |
compact:before |
✓ | the transcript is about to be condensed — a gate refuses it |
The reserved set is derived from a data table (EVENT_TRAITS in
src/addons/dispatch/event-dispatcher.ts), not a separate hand-maintained
constant. AddonEventDispatcher.reserved exposes the derived set. A gate handler
may attach to any event, but only the reserved events front an action the host is
expected to actually stop.
Subscribing
A handler is recorded against an event with surface.on(event, handler) inside an
addon's register:
import type { addons } from "indusagi-coding-agent";
const addon: addons.AddonManifest = {
id: "logger",
register(surface) {
// observe: log every assistant turn boundary
surface.on("turn:start", {
kind: "observe",
run() {
console.log("turn started");
},
});
// transform: prepend context to an assembled assistant message
surface.on<{ content: { type: string; text?: string }[] }>("chat:message", {
kind: "transform",
run(payload) {
return {
...payload,
content: [{ type: "text", text: "[audited] " }, ...payload.content],
};
},
});
// gate: refuse a compaction during a critical section
surface.on("compact:before", {
kind: "gate",
run() {
return { stop: true, reason: "compaction paused" };
},
});
},
};
export default addon;
Dispatch Semantics
When an event is dispatched, the engine walks that event's subscriptions in registration (load) order:
observehandlers see the current payload and return nothing.transformhandlers fold their return value forward (a transform that throws is isolated and treated as a no-op — the prior payload is kept).- The first
gatethat returns{ stop: true }short-circuits the walk; its decision is surfaced asDispatchOutcome.gate.
Because a gate stops the walk, a gate registered earlier wins over a transform registered later — precedence is controlled purely through load order, with no priority field.
interface DispatchOutcome<TPayload> {
payload: TPayload; // after every transform ran
gate?: GateDecision; // the first stopping decision, if any
}
Fault Isolation
A handler that throws is converted to an AddonFault (kind: "handler") routed to
the dispatcher's onFault listeners and then swallowed — observe/transform
continue with the prior payload, and a gate fails open (no veto) rather than
wedging the agent. A throwing fault listener cannot break fan-out to the others.
Construction Helper
For hosts/tests assembling subscriptions by hand without the full surface builder,
subscription(addon, event, handler) mints the recorded EventSubscription shape,
and AddonEventDispatcher.from(subscriptions) builds the engine.
Related
- Extensions — the addon API and registration surface
- Loading Extensions — discovery and the loader
