Status: implemented
English | 中文
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 API; the CC/Codex bridges (the dsh-hooks-claude-code / dsh-hooks-codex packages) are merely translators that map an external shell-hook protocol onto that same API. 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 Agent Note supplies the three-domain rule and the typed-Decision idiom; this Agent Note applies them to the lifecycle extension points.
The canonical surface separates transformable policy, around-dispatch control, and observe-only notification. Policy waterfalls return small extension-point-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/pre-step({ agent, messages, turn, step, signal }, next) → PreStepDecision— waterfall, fired before every proposed step after the loop has atomically removed its exclusive inbox batch. The payload carries the request'sturn,step, and cancellationsignal(the retiredPreStepContextfields live in the payload; see the payload-object events decision);messagesis empty for a tool continuation with no intervening input.enterreturns the complete message batch, including any current-request context a listener contributes;rejectopens no step and leaves the claimed messages removed.
agent/turn-stopping is an awaited notification at the natural stop boundary. A listener that needs another step calls agent.steer() with explicitly sourced model-facing content; the loop then re-reads the outbox and either continues or closes the turn.
Every call follows tools/pre-execute → guards → tools/execute → dispatch → tools/post-execute → definition-owned finalizeContent → tools/result. The registry snapshots caller input, materializes and freezes arguments, assigns an opaque token, and snapshots the visible definition's final-content callback before policy begins. 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 resolved decision still reaches post-policy; a throwing listener becomes a final normalized failure.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 replace and restore the requiredexec.signalbefore doing so but cannot remove it, and receives the already-normalized canonical success/failure result of a thrown or unknown tool; a wrapper-authored success short-circuits dispatch and is re-normalized through the resolved output declaration.tools/post-executeis the inspect/transform waterfall. ItsPostToolDecisionaccepts, blocks with feedback, replaces either presentation content or canonical value, or attachesadditionalContexts. Value replacement revalidates and recomputes presentation; content replacement preserves programmatic value and is not a confidentiality boundary. The returned decision is the supported transform channel.ToolDefinition.finalizeContentis an optional synchronous, total, content-only boundary snapshotted with the visible definition at call creation. It runs exactly once after the registry has normalized and losslessly snapshotted the candidate outcome, including pre-, around-, or post-listener failures that bypass later waterfalls and errors discovered while snapshotting another result field. It may replacecontentor preserve it withundefined, but cannot rewriteisError, structured error identity, contexts, or presentation metadata. This is where a tool enforces its own last-mile content invariant without converting policy failures into weaker block decisions.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 rejectToolRuntime.execute()'s returned outcome.
Core dispatch and the tool body sit inside normalization boundaries, so tool, listener, invalid canonical value, renderer/projector, non-JSON presentation, 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; definition-owned final content invariants also cover outer pipeline and candidate-materialization failures; and a final observer sees the execution-local canonical value beside exactly the presentation fields the session log can persist. The canonical tool-output contract owns the value/projection and durability rules.
-
Run pre-step policy at every proposed step. The loop opens the turn before the initial claim and decision, so rejection closes a durable blocked turn with no step or model-visible message. A tool continuation with no newly claimed input still submits an empty batch, allowing per-request context producers to add logged messages to that exact request. On enter, the loop opens the step and appends the returned batch as
user/messageevents before request derivation. Each claimed follow-up remains the sole direct prompt in its turn under the one-send-one-turn simplification. -
Post-tool
additionalContextsand asynchronous injections enter the active-batch FIFO and append when that batch settles.content/feedbackshape the resultexecute()returns, but each context is a separate sourceduser/message, and a single step or composite tool can produce many. Appending context immediately would interleaveresult(c1) → context → result(c2)or place nested context before its outer result, breaking tool-call/result adjacency.ToolRunContext.deferContext()therefore collects nested-dispatch context through failures,execute()surfaces the ordered array onToolExecutionResult, and the loop accepts it into the same FIFO asagent.inject()calls made during execution. The FIFO appends after every recorded result when the batch settles, including before an interrupted turn closes. An accepted outer call preserves deferred contexts before decision contexts; an outer block discards deferred contexts and exposes only contexts explicitly supplied by the blocking decision. -
A stopping listener requests continuation 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.
PreToolDecision cannot rewrite arguments. History and the audit call are logged before execution, and UI 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.
The Service Definition 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 extension points 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; terminal monotonic stopping is expressed by tool-result data, while agent/turn-stopping is the last chance to steer another step.
- Shipping pre-tool INPUT rewrite as part of this extension-point 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 extension points — 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 extension surface.
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, pre-step claim settlement, post-tool context buffering, and stopping; 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 settles an initial pre-step rejection from its blocked no-step turn as end_turn, while hook-driven snapshots verify the observable bridge behavior end to end.