9.3 KiB
RFC: Interception seams — the typed-Decision surface a hook programs against
Status: implemented
Problem
The harness needs a hooks subsystem: users extend or gate the agent at lifecycle points the way Claude Code (CC) and Codex do. The key reframe driving this design is that "native hooks" are not a package — a native hook is just an ordinary Cordis plugin subscribing to the canonical lifecycle events. So the real product is a powerful, well-typed canonical event surface; the CC/Codex bridges (the dsh-hooks-claude / dsh-hooks-codex packages) are merely translators that map an external shell-hook protocol onto that same surface. Anything a bridge can do, a plain plugin can do directly — more powerfully (no serialization boundary, full ctx, typed returns).
The surface needs distinct contracts for per-prompt policy (CC's UserPromptSubmit), session-start observation (CC's SessionStart), pre-tool policy, around-dispatch control, post-tool transformation, final-result observation, and continuation with a model-facing reason. Conflating those phases gives plugins mutation channels they do not need and makes finality depend on listener ordering. The event-domain-semantics RFC supplies the three-domain rule and the typed-Decision idiom; this RFC applies them to the lifecycle seams.
Decision
The canonical surface separates transformable policy, around-dispatch control, and observe-only notification. Policy waterfalls return small seam-specific typed Decision unions; wrappers return normalized results; notifications receive immutable snapshots and cannot affect the outcome. The set covers the hook points in scope (session-start, prompt-submit, pre-tool, post-tool, stop-via-continuation) while leaving non-hook execution policy independently composable.
Agent events (dsh-agent):
agent/session-start(agent, source)— emit, once before turn 1, carrying aSessionStartSource(startupfor a fresh/forked create,resumefor a reloaded persisted session;clear/compactreserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context viaagent.inject().agent/prompt-submit(agent, content, source, next) → PromptDecision— waterfall, fired per drained queued message inside the open turn, before theuser/messageappend.allow(optionally rewriting the promptcontentor attachingadditionalContext) orblock(dropping the prompt; the loop appends a durableprompt/blockedin its place — see the dispatch note below).
agent/turn-continuation receives and returns a ContinuationDecision. A {action:'continue', reason?} may carry model-facing context recorded as next-step steering in the same turn — the typed twin of the /goal step-end-steer pattern.
The tool pipeline gives each phase one kind of authority
Every call follows tools/pre-execute → guards → tools/execute → dispatch → tools/post-execute → tools/result. The registry snapshots caller input, materializes and freezes arguments, and assigns an opaque token. Nested calls carry only the parent token. Identity remains immutable; only signal may change around dispatch. The log, UI, and tool body therefore agree on what ran.
tools/pre-executeis the extensible waterfall gate. ItsPreToolDecisionallows, denies, or asks. Deny skipstools/executeand core dispatch. Ask resolves through the optional approval seam: onlyallowed-oncecontinues through guards and dispatch; rejection, cancellation, an unavailable channel, a missing approval service, or an agent-less call becomes a normalized denial. Every outcome still reaches post-policy and final observers.ctx.tools.guard()installs synchronous scope-aware policy after the whole pre-execute waterfall. A guard may deny or abstain, never force-allow, so listener ordering cannot resurrect an operation that a final invariant forbids.tools/executeis the around-dispatch waterfall for timeout, retry, and metrics plugins. A wrapper delegates to core dispatch withnext(), may add, replace, or remove onlyexec.signalbefore doing so, and receives the already-normalized result of a thrown or unknown tool; returning its own valid result short-circuits dispatch.tools/post-executeis the inspect/transform waterfall. ItsPostToolDecisionaccepts, blocks with feedback, optionally replaces content, or attachesadditionalContext; in-place mutation of the result is not a transform channel, because the registry rebuilds the outcome from a protected snapshot plus the returned decision.tools/resultis the synchronous contained notification after every transform, lossless-JSON materialization, and the outer error boundary. It receives the same frozen execution identity and an immutable snapshot of the authoritative result; observer failures are contained per listener and cannot change or rejectToolRegistry.execute()'s returned outcome.
Core dispatch and the tool body sit inside normalization boundaries, so tool, listener, malformed-result, non-JSON result, and identity-shape failures resolve as JSON-safe isError results rather than escaping the turn. A post-execute listener can therefore inspect a thrown tool, and a final observer sees exactly what the caller receives and the session log can persist.
TurnEndReason.rejected (dsh-session): a turn whose entire prompt batch was blocked by prompt-submit.
Three load-bearing loop decisions
-
Open the turn before prompt policy. A fully blocked batch becomes a zero-step
rejectedturn, preserving enclosure and giving ACP a durable terminal event. Every veto also recordsprompt/blockedwith the original prompt and reason, so mixed batches retain blocked inputs. AllowedadditionalContextis injected into the open turn. -
Post-tool
additionalContextis buffered and appended AFTER alltool/results.content/feedbackshape the resultexecute()returns, butadditionalContextis a SEPARATEcontext/message, and a single step can carry multiple tool calls. Appending context right after each result would interleaveresult(c1) → context → result(c2)and break tool-call/result adjacency. Soexecute()surfacesadditionalContexton itsToolExecutionResult, and the loop buffers every per-call context for the step and appends them ascontext/message(s) only after everytool/resultis appended. -
A forced
continuereasonis enqueued through the steering channel, so the next step's top-of-loop drain records it as steering for the continued turn — next-step steering within the SAME turn, not a next-turn prompt (matching the existinghasSteeringforce-continue override).
Pre-tool input rewrite is a separate consistency decision
PreToolDecision cannot rewrite arguments. History and the audit call are logged before execution, and ACP presentation reads the same input, so the registry seals arguments before policy. A valid rewrite must update history, audit, presentation, and execution before identity is created; that contract belongs to the input-rewrite proposal.
Boundaries
The seam package does not declare hook/* session events (the durable hook-invocation log); those belong to dsh-hook-protocol, because a native plugin uses typed decisions without an external hook log. The native-plugin integration test (packages/core/agent-loop/tests/interception.spec.ts) composes the seams through the real loop with no hook/* protocol. Compaction (PreCompact/PostCompact), Notification, and Codex PermissionRequest remain outside this decision. The approval seam resolves ask decisions through ctx.approval, while terminal monotonic stopping is owned separately by agent/turn-stop.
Alternatives considered
- Shipping pre-tool INPUT rewrite as part of this seam set — deferred as the over-reach signal; the section above carries the consistency problem (audit, history, and presentation all read
tool/call.argumentslogged before execution), and the pre-tool input-rewrite proposal owns the design. - Declaring the durable
hook/*SessionEvents alongside the seams — rejected: a native plugin uses the typed Decisions with no hook log at all (the worked example proves it), so the durable log belongs to the hook-protocol library, not the seam surface.
Consequences
The canonical interception surface is uniformly typed without giving every extension the same power: hooks return decisions, execution wrappers wrap, terminal guards only deny, and final observers only observe. The loop owns session-start, prompt-submit, post-tool context buffering, and continuation; dsh-tools owns identity sealing and the five-phase execution pipeline. Their contracts are documented in architecture.md, package READMEs, core interception decisions, and tool structures. The ACP bridge maps rejected turns to its cancelled codec value, while hook-driven snapshots verify the observable bridge behavior end to end.