Files
deepseek-harness/docs/rfc/implemented/feature/2026-06-30-interception-seams.md
Tianyi Cui e6fad266a6 docs(rfc): define and enforce a uniform RFC format; adopt it across the corpus
Define the in-file RFC contract in docs/rfc/README.md § The file format:
the header block (`# RFC: <title>` plus a dateless Status enum
cross-checked against the lifecycle folder), the per-lifecycle body
skeleton (a Problem opener everywhere; Proposal/Alternatives considered/
Acceptance criteria/Risks in proposed/; present-tense Decision/
Consequences with proposal-era headings banned in implemented/; the
frozen proposal shape in rejected/), and a mandatory Alternatives
considered section with a date-fenced grandfather comment for pre-format
RFCs whose alternatives are not reconstructible from the record.

Enforce it with a new doc-sync gate, scripts/verify-rfc-format.ts, and
normalize all 112 RFCs to it: ~15 Status-line spellings collapse to the
enum, 29 Context openers become Problem, the 39 legacy-format XXX debt
markers are resolved and banned from reappearing, proposal-era sections
in implemented RFCs are rewritten to shipped reality (including the
web/fs/subagent seam RFCs' migration plans and test checklists, closing
the doc-tiers deferred-work item on the web seam), every RFC gains an
Alternatives considered section or the grandfather comment, and the
bilingual pair is re-mirrored and re-recorded.

Move the generated index tables out of README.md into a fully generated
docs/rfc/INDEX.md — gen-rfc-index now writes the whole file, and
verify-rfc-classification checks its freshness and rejects index-shaped
rows in the curated README — which makes room for the format contract to
live in the README front door instead of a separate FORMAT.md.

The decision record, and the first RFC written in the new format, is
docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md.
2026-07-05 22:58:25 +08:00

9.1 KiB
Raw Blame History

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).

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 a SessionStartSource (startup for a fresh/forked create, resume for a reloaded persisted session; clear/compact reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via agent.inject().
  • agent/prompt-submit(agent, content, source, next) → PromptDecision — waterfall, fired per drained queued message inside the open turn, before the user/message append. allow (optionally rewriting the prompt content or attaching additionalContext) or block (dropping the prompt; the loop appends a durable prompt/blocked in 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

  1. Always open the turn first; a fully-blocked batch is a zero-step rejected turn; every veto is recorded as prompt/blocked. prompt-submit fires AFTER turn/start, per message. A batch whose every prompt is blocked does NOT skip the turn — it opens a zero-step turn that closes with rejected. This one move resolves three problems at once: (1) turn-enclosure holds (every event has an open turn to live in); (2) the durable turn/end is appended and the ACP bridge settles normally off it (mapping rejectedcancelled) instead of hanging; (3) the block reason is a durable in-turn fact. Independently, each individual veto appends a prompt/blocked session event (the original content, source, and reason) in place of the user/message the prompt would have become — necessary because a MIXED batch (one prompt blocked, another allowed) does NOT end rejected, so the boundary reason alone would silently lose the blocked prompt on replay. An allow's additionalContext is inject()ed into this now-open turn.

  2. Post-tool additionalContext is buffered and appended AFTER all tool/results. content/feedback shape the result execute() returns, but additionalContext is a SEPARATE context/message, and a single step can carry multiple tool calls. Appending context right after each result would interleave result(c1) → context → result(c2) and break tool-call/result adjacency. So execute() surfaces additionalContext on its ToolExecutionResult, and the loop buffers every per-call context for the step and appends them as context/message(s) only after every tool/result is appended.

  3. A forced continue reason is 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 existing hasSteering force-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).

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.arguments logged 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 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.