Address review on the interception-seams PR: PromptDecision.reason is documented as the durable record of why a prompt was blocked, but the loop only surfaced it via the fully-blocked batch's `rejected` turn/end. In a MIXED batch — one queued prompt blocked, another allowed — the turn does not end `rejected`, so the blocked prompt and its reason vanished from the session log entirely. Add a `prompt/blocked` SessionEventMap variant (content + source + reason), appended in the open turn at the veto point in place of the user/message the prompt would have become. It is a non-surface, turn-enclosed event (like todo/write): it never reaches deriveMessages(). The fully-blocked batch still also ends `rejected` for boundary balance + ACP settlement. Regression test drives a mixed batch and asserts the blocked prompt is recorded while the allowed one runs — proven red without the append.
8.6 KiB
RFC: Interception seams — the typed-Decision surface a hook programs against
Status: implemented (accepted 2026-06-30)
Context
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).
Before this change the interception surface was incomplete and inconsistent for that goal: there was no per-prompt seam (CC's UserPromptSubmit), no session-start signal (CC's SessionStart), the single tools/execute waterfall conflated the pre-gate and post-inspect phases (CC splits PreToolUse/PostToolUse), and agent/turn-continuation returned a bare boolean with no room for a force-continue reason. The event-domain-semantics RFC pinned down the three-domain rule and the typed-Decision idiom as the interception convention; this RFC builds the actual seams on top of it.
Decision
Add/reshape the interception seams so every one returns a small, seam-specific typed Decision union, and the set covers the hook points in scope (session-start, prompt-submit, pre-tool, post-tool, stop-via-continuation).
New 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).
Reshaped agent/turn-continuation from (…, defaultDecision: boolean) → boolean to (…, defaultDecision: ContinuationDecision) → ContinuationDecision. A {action:'continue', reason?} may carry model-facing context recorded as next-step steering in the same turn — the typed twin of the existing /goal step-end-steer pattern.
Split the single tools/execute waterfall into tools/pre-execute (→ PreToolDecision allow/deny/ask gate) and tools/post-execute (→ PostToolDecision accept/block, optionally replacing content or attaching additionalContext). Core dispatch sits between them as plain code inside ToolRegistry.execute's outer try/catch, and the tool body keeps its own inner try/catch so a thrown tool still becomes an isError result that post-execute listeners can inspect.
New TurnEndReason variant rejected (dsh-session): a turn whose entire prompt batch was blocked by prompt-submit.
Three load-bearing loop decisions
-
Always open the turn first; a fully-blocked batch is a zero-step
rejectedturn; every veto is recorded asprompt/blocked.prompt-submitfires AFTERturn/start, per message. A batch whose every prompt is blocked does NOT skip the turn — it opens a zero-step turn that closes withrejected. This one move resolves three problems at once: (1) turn-enclosure holds (every event has an open turn to live in); (2) the durableturn/endis appended and the ACP bridge settles normally off it (mappingrejected→cancelled) instead of hanging; (3) the block reason is a durable in-turn fact. Independently, each individual veto appends aprompt/blockedsession event (the originalcontent,source, andreason) in place of theuser/messagethe prompt would have become — necessary because a MIXED batch (one prompt blocked, another allowed) does NOT endrejected, so the boundary reason alone would silently lose the blocked prompt on replay. Anallow'sadditionalContextisinject()ed into this now-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 DEFERRED (the over-reach signal)
PreToolDecision is allow/deny/ask only — no arguments rewrite. Output replacement (PostToolDecision.accept.content) is safe because tool/result is logged AFTER execution (one source of truth). Input rewrite is NOT safe today: assistant/message (the model-history source) and tool/call (the audit record) are both logged BEFORE execution, and live consumers READ tool/call.arguments for presentation (the ACP bridge remembers them for presentResult; dsh-tool-bash derives the title/cwd/terminal-vs-background from them). A rewrite that changed only execution would make the UI show one command while another RAN. Designing that consistently (rewriting the audit + history + presentation as one unit) is a real consistency-design problem CC itself warns is racy — so it gets its own proposed RFC, and TODO(pre-tool-input-rewrite) anchors it at the loop's pre-execute call site. This does not regress any production consumer (no production tools/execute listener mutated exec.arguments). The low-level capability to mutate exec in a pre-execute listener still exists (unadvertised — a test shim uses it to thread a generated id), but it is not a first-class advertised contract.
What this PR does NOT do
It does not declare hook/* SessionEvents (the durable hook-invocation log) — those belong to the dsh-hook-protocol library, because a native plugin can already use the typed Decisions without a durable hook log. A worked native-plugin example/test in this PR (packages/core/agent-loop/tests/interception.spec.ts) proves all the seams compose end-to-end through the REAL loop with NO hook/* involved — the concrete proof that "native hooks are just a plugin". Compaction (PreCompact/PostCompact), the Notification hook, Codex PermissionRequest, the permission/ask system, and the Stop loop-guard remain deferred (FIXME(permissions) marks the ask→deny degrade).
Consequences
The canonical interception surface is now complete and uniformly typed: a native plugin returns typed decisions directly, and a CC/Codex bridge maps its protocol fields onto the same unions. The loop gained four firing points (session-start emit, prompt-submit waterfall, the post-tool context buffer, the continuation reshape) and the dsh-tools registry runs a two-waterfall pipeline; both are documented in architecture.md and the package READMEs, and the decision types in core-data-structures + tools.md. All existing tools/execute and turn-continuation listeners (tests, docs) migrated to the new seams. The ACP bridge maps the new rejected reason to cancelled (its codec). A pure internal change with no editor-visible transcript shift for the existing scenarios — the new behavior only fires when a hook is registered — so the snapshot goldens are unchanged; a hook-driven snapshot scenario lands with the dsh-hooks-claude bridge, which is what makes a hook observable end-to-end through ACP.