Reshape the agent's interception surface so every seam returns a small, typed
Decision union, and the set covers the hook points a CC/Codex bridge (and a
native plugin) needs. "Native hooks" are not a package — a native hook is just a
cordis plugin on these canonical events; the bridges (a later PR) only translate
an external protocol onto the same surface.
dsh-agent:
- NEW agent/session-start(agent, source) emit (once before turn 1; SessionStartSource
startup|resume|clear|compact) — a pure notification, seeds context via inject().
- NEW agent/prompt-submit waterfall → PromptDecision (allow, optionally rewriting the
prompt or attaching additionalContext, or block).
- RESHAPE agent/turn-continuation boolean → ContinuationDecision ({action:'stop'} |
{action:'continue', reason?}; a continue reason is recorded as next-step steering).
- New HookContext envelope (required source — inject() would mislabel a missing one).
dsh-tools: 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 as plain code; the tool body keeps its inner try/catch so a
thrown tool still reaches post-execute as an isError. ToolExecutionResult gains
additionalContext (ferried to the loop's per-step buffer). Input rewrite is
deliberately NOT offered (a proposed RFC designs it consistently).
dsh-session: new `rejected` TurnEndReason — a turn whose whole prompt batch was
blocked by prompt-submit.
agent-loop firing points: session-start emitted at create (source threaded —
startup for create/fork, resume for resume()); prompt-submit per drained message
with the always-open-turn rule (a fully-blocked batch is a zero-step rejected
turn); the continuation reshape; post-tool additionalContext buffered and appended
after all tool/results (adjacency). ACP codec maps rejected→cancelled.
A worked native-plugin example (interception.spec.ts) proves all four seams compose
end-to-end through the real loop with NO hook/* events (those belong to the bridge
lib). All existing tools/execute + turn-continuation tests migrated. The
tool-subagent abort test now aborts after a microtask so it still exercises the
live onAbort bridge (execute() awaits pre-execute before the body runs).
RFCs: implemented/feature/2026-06-30-interception-seams.md (the reshape) +
proposed/feature/2026-06-30-pre-tool-input-rewrite.md (the deferred rewrite design).
3.7 KiB
RFC: Pre-tool input rewrite — a consistent design (proposed)
Status: proposed (2026-06-30)
Context
The interception-seams RFC added tools/pre-execute returning a PreToolDecision (allow/deny/ask) — but deliberately NOT input rewrite (a hook changing a tool call's arguments before it runs). Claude Code's PreToolUse hook offers an updatedInput, so a faithful CC bridge wants the same. This RFC designs that, separately, because doing it consistently is a real problem — not a field to bolt onto the allow decision.
The problem: three readers of pre-execution arguments
In the loop, a tool call's arguments are committed to the log and read by live consumers BEFORE the tool executes:
assistant/messageis appended before tool dispatch — it is the model-history sourcederiveMessages()replays, so it carries the tool-call arguments the model itself emitted.tool/callis the durable AUDIT record, appended beforectx.tools.execute().- Live presentation reads
tool/call.arguments: the ACP bridge remembers them and passes them topresentResult;dsh-tool-bashderives the card title, the rawInput, the cwd, and the terminal-vs-background treatment from them.
So an "input rewrite" that changes ONLY what executes would make the UI show one command while another RAN, and render result state against the wrong arguments — a real inconsistency, not a documentable gap. (The existing low-level capability to mutate exec.arguments in a listener has exactly this latent inconsistency; it is unadvertised precisely because of this.)
Proposed design (sketch — to validate against the code when built)
Treat input rewrite as a consistency unit: when a pre-execute hook supplies updatedInput, the rewrite must be reflected in ALL three readers, atomically, before execution:
- The
tool/callaudit event records the REWRITTEN arguments (with the original retained in a sidecar field for the audit trail — a hook changed the call, and both the original and the effective arguments are facts worth keeping). - The
assistant/messagein derived history must agree with what executed — options to evaluate: rewrite the assistant message's tool-call block in place (changes what the model "sees it said"), or record a separate correction the next request carries. The CC model is that the model sees the rewrite took effect. - Presentation (
presentCall/presentResult) reads the rewritten arguments, so the UI shows what actually ran.
The shape would extend PreToolDecision with an allow-variant arguments (or a dedicated {kind:'rewrite', arguments}), and the loop would thread the rewrite through the three readers above rather than only into ctx.tools.execute().
Why not now
The interception-seams RFC notes input rewrite "fought the code across two review rounds" — the signal AGENTS.md names for an over-reaching change. Shipping allow/deny/ask first keeps the seam honest (no advertised contract that silently desyncs the UI), and a CC/Codex bridge that receives an updatedInput logs it and surfaces a faithful-but-degraded warning (like ask→deny) until this lands. This RFC is the home for the consistent design; TODO(pre-tool-input-rewrite) in the loop's pre-execute call site anchors it.
Open questions
- Does rewriting the
assistant/messagetool-call block corrupt any provider's expectation on replay, or is a separate correction safer? - Should the original arguments be preserved on the
tool/callevent (audit) and, if so, under what field? - How does this interact with a future permission
askflow (a user approving a rewritten call)?