Interception extension points — the typed-Decision surface a hook programs against
拦截扩展点——钩子编程所面对的类型化 Decision 接口
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-co
English
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 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.
Decision
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.
The tool pipeline gives each phase one kind of authority
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.
Three load-bearing loop decisions
-
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.
Pre-tool input rewrite is a separate consistency decision
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.
Boundaries
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.
Alternatives considered
- 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.
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, 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.
中文
问题
harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那样在生命周期节点扩展或管控 agent(智能体)。驱动本设计的关键视角转换是:「原生钩子」不是一个包——原生钩子只是一个普通的 Cordis 插件,订阅规范的生命周期事件。因此真正的产品是一个强大、类型完备的规范事件接口;CC/Codex 桥接(dsh-hooks-claude-code / dsh-hooks-codex 包)只是将外部 shell 钩子协议映射到同一接口的翻译层。桥接能做的事,普通插件可以直接做——而且更强大(无序列化边界、完整 ctx、类型化返回值)。
该接口需要为以下场景提供各自独立的约定:逐提示词策略(CC 的 UserPromptSubmit)、会话启动观测(CC 的 SessionStart)、工具执行前策略、环绕调度控制、工具执行后变换、最终结果观测,以及携带面向模型的原因的继续执行。如果把这些阶段混为一谈,插件就会获得不需要的 mutation 通道,而终结性将依赖监听器的注册顺序。事件域语义 Agent Note 提供了三域规则与类型化 Decision 惯用法;本 Agent Note 将其应用于生命周期扩展点。
决策
规范接口将可变换策略、环绕调度控制与仅观测通知分离。策略 waterfall(瀑布式事件)返回小型的、扩展点专属的类型化 Decision 联合类型;包装层返回规范化结果;通知接收不可变快照,无法影响结果。覆盖的钩子点包括 session-start、prompt-submit、pre-tool、post-tool、通过 continuation 实现的 stop,同时将非钩子的执行策略留作独立可组合。
Agent 事件(dsh-agent):
agent/session-start({ agent, source })——emit,在第 1 轮次之前触发一次,携带SessionStartSource(startup表示全新/fork 创建,resume表示重新加载的持久化会话;clear/compact保留)。纯通知,不能阻塞启动(这是有意的空白:桥接可以记录/注入,但不管控启动)。监听器通过agent.inject()注入上下文。agent/pre-step({ agent, messages, turn, step, signal }, next) → PreStepDecision——waterfall,在每个拟议步骤之前、循环原子移除其独占 inbox 批次后触发。payload 携带该请求的turn、step与取消signal(已退役的PreStepContext字段位于 payload 中;参见 payload-object 事件决策);没有中途输入的工具续步会收到空批次。enter返回完整消息批次,其中包括监听器为当前请求贡献的上下文;reject不打开步骤,并让已领取消息保持已删除。
agent/turn-stopping 是自然停止边界上的一次 awaited 通知。需要再执行一步的监听器调用 agent.steer(),传入来源显式的 steering(中途引导)内容供模型使用;循环随后重新读取 outbox,继续执行或关闭轮次。
工具流水线为每个阶段赋予一种权限
每次调用遵循 tools/pre-execute → guards → tools/execute → dispatch → tools/post-execute → 由工具定义负责的 finalizeContent → tools/result。注册表对调用方输入创建快照、实体化并冻结参数、分配一个不透明 token,并在策略开始前对可见定义的最终内容回调创建快照。嵌套调用仅携带父 token。身份始终不可变;只有 signal 可在环绕调度时改变。日志、UI 和工具体因此对「执行了什么」达成一致。
tools/pre-execute是可扩展的 waterfall 门禁。其PreToolDecision允许、拒绝或询问。拒绝跳过tools/execute与核心调度。询问通过可选的审批 seam 解析:只有allowed-once继续通过 guards 和调度;拒绝、取消、通道不可用、审批服务缺失或无 agent 调用均规范化为拒绝。每个已解析的 decision 仍会到达后置策略;监听器抛出的异常会成为最终的规范化失败。ctx.tools.guard()在整个 pre-execute waterfall 之后安装同步的、作用域感知的策略。guard 可以拒绝或弃权,永远不能强制允许,因此监听器顺序无法复活一个被最终不变式禁止的操作。tools/execute是用于超时、重试和指标插件的环绕调度 waterfall。包装层通过next()委托给核心调度,在此之前可以替换并恢复必需的exec.signal,但不能移除它;包装层接收抛出异常或未知工具产生的、已完成规范化的规范成功/失败结果。包装层自行产生的成功结果会短路调度,并通过已解析的输出声明重新规范化。tools/post-execute是检查/变换 waterfall。其PostToolDecision接受、以反馈阻止、替换呈现内容或规范值,或附加additionalContexts。替换值会重新校验并重新计算呈现;替换内容会保留程序化值,且不构成保密边界。返回的 decision 是受支持的变换通道。ToolDefinition.finalizeContent是一个可选、同步、对所有输入都有定义且仅能处理内容的边界,在调用创建时随可见定义一起被快照。注册表将候选结果规范化并创建无损快照后,它恰好运行一次;候选结果包括绕过后续 waterfall 的 pre、around 或 post 监听器失败,以及为另一个结果字段创建快照时发现的错误。它可以替换content,也可返回undefined保留原内容,但不能重写isError、结构化错误身份、上下文或呈现元数据。工具在此执行自身最后一道内容不变式,而无需将策略失败转换为更弱的阻止 decision。tools/result是在所有变换、无损 JSON 实体化和外层错误边界之后的同步且故障受控的通知。它接收相同的冻结执行身份和权威结果的不可变快照;观测者的失败按监听器隔离,无法改变或拒绝ToolRuntime.execute()返回的结果。
核心调度与工具体位于规范化边界内部,因此工具、监听器、无效规范值、渲染器/投影器、非 JSON 呈现和身份形状错误均解析为 JSON 安全的 isError 结果,而非逃逸出轮次。post-execute 监听器因此可以检查一个抛出异常的工具;由工具定义负责的最终内容不变式也会覆盖外层流水线与候选结果实体化失败;最终观测者会同时看到执行期间的规范值,以及会话日志能够持久化的确切呈现字段。规范工具输出约定定义值/投影与持久性规则。
三个承重的循环决策
-
在每个拟议步骤运行 pre-step 策略。 循环会在首次领取和决策之前打开轮次,因此 reject 会关闭一个持久、blocked 且不含步骤或模型可见消息的轮次。即使工具续步没有新取得所有权的输入,也会提交空批次,使逐请求上下文生产方可以把带日志的消息加入这一次请求。enter 时,循环先开启步骤,再把返回批次作为
user/message追加,然后派生请求。依照一次 send 对应一个轮次的简化,每个已领取 follow-up 仍是其轮次中唯一的直接提示词。 -
工具执行后的
additionalContexts与异步注入进入活跃批次 FIFO,并在该批次结算时追加。content/feedback塑造execute()返回的结果,但每项上下文都是一条独立的带来源user/message,而单个步骤或组合工具可以产生许多上下文。立即追加上下文会产生result(c1) → context → result(c2)的交错,或把嵌套上下文放在外层结果之前,破坏工具调用/工具结果邻接性。因此ToolRunContext.deferContext()会在失败路径上也收集嵌套调度上下文,execute()在ToolExecutionResult上暴露有序数组,循环再把它接纳到与执行期间agent.inject()调用相同的 FIFO 中。FIFO 在批次结算时,在所有已记录结果之后追加,其中也包括被中断轮次关闭之前。被接受的外层调用将 deferred contexts 保留在 decision contexts 之前;被外层阻止时则丢弃 deferred contexts,只暴露阻止 decision 显式提供的上下文。 -
stopping 监听器通过 steering 通道请求继续执行,使得下一步骤在循环顶部排空时将其记录为当前轮次的 steering——同一轮次内的下一步骤 steering,而非下一轮次的提示词。
工具执行前输入重写是一个独立的一致性决策
PreToolDecision 不能重写参数。历史和审计调用在执行前记录,UI 展示读取相同的输入,因此注册表在策略之前封存参数。有效的重写必须在身份创建之前同时更新历史、审计、展示和执行;该约定属于输入重写提案。
边界
Service Definition 包不声明 hook/* 会话事件(持久的钩子调用日志);那些属于 dsh-hook-protocol,因为原生插件使用类型化 decision 而无需外部钩子日志。原生插件集成测试(packages/core/agent-loop/tests/interception.spec.ts)通过真实循环组合这些扩展点,不涉及 hook/* 协议。压缩(compaction)(PreCompact/PostCompact)、Notification 和 Codex PermissionRequest 不在本决策范围内。审批 seam 通过 ctx.approval 解析 ask decision;终结性的单调停止由工具结果数据表达,而 agent/turn-stopping 是引导再执行一步的最后机会。
曾考虑的替代方案
- 将工具执行前输入重写作为本扩展点集合的一部分发布:推迟,视为越界信号;上文已阐述一致性问题(审计、历史和展示都读取执行前记录的
tool/call.arguments),工具执行前输入重写提案负责该设计。 - 将持久的
hook/*SessionEvents 与扩展点一起声明:否决。原生插件使用类型化 Decision 而完全不需要钩子日志(实际示例已证明),因此持久日志属于钩子协议库,而非扩展接口。
后果
规范拦截接口具有统一的类型化,同时不给每个扩展相同的权力:钩子返回 decision,执行包装层做包装,终结 guard 只能拒绝,最终观测者只能观测。循环负责 session-start、pre-step 领取结算、工具执行后上下文缓冲和 stopping;dsh-tools 负责身份封存与五阶段执行流水线。它们的约定记录在 architecture.md、各包 README、核心拦截 decision 与工具结构中。ACP 桥接会把 blocked 无步骤轮次中的首次 pre-step reject 结算为 end_turn,而钩子驱动的快照端到端验证可观测的桥接行为。