DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

xinyuehtx /

dsh-plugin-hooks-ordering

Verified

为 deepseek harness 的 waterfall 和 serial 进行确定性 hooks 监听排序

★ 1 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@d4d6e82d

dsh-plugin-hooks-ordering

English | 简体中文

Deterministic before/after ordering for Cordis hooks whose participants are contributed by independent, mutually-unaware plugins — for both waterfall and serial dispatch, with an optional DeepSeek-Harness layer that controls real dsh hooks out of the box.

  • Playground (live): https://xinyuehtx.github.io/dsh-plugin-hooks-ordering/
  • Runnable demo: pnpm run demo

The problem

Cordis dispatches waterfall listeners in registration order — their position in the internal listener array, with prepend as the only lever. Registration order is in turn driven by inject-dependency activation, which is non-deterministic between unrelated plugins.

The consequence: a plugin cannot reliably state a relative order such as "run me after that other plugin." Concretely:

  • There is no before/after/stage declaration on ctx.on — only a boolean prepend, and prepend is last-prepender-wins, so it is not a stable "always first" either.
  • The only robust ordering primitive Cordis offers is inject: if plugin B injects a service plugin A provides, B activates after A. That pins order globally and single-directionally, and forces B to depend on A — impossible across vendors that must not depend on each other.
  • Two plugins may need opposite relative order on different hooks. A single activation order cannot express that.

So ordering that matters — auth before logging, sanitizer before serializer, metrics last — silently depends on load order that nobody controls. Change an unrelated inject, and the order flips.

raw ctx.on, load order [auth, logging, metrics]  ->  auth, logging, metrics
raw ctx.on, load order [metrics, logging, auth]  ->  metrics, logging, auth   ← same plugins, flipped

This is not hypothetical. In deepseek-harness the waterfall hook agent/pre-step is subscribed by a dozen+ independent packages, tools/post-execute by six, llm/stream by five — and several places document that the relative order is load-bearing yet decided only by prepend and registration timing (see How ordering is done in dsh today).

The solution

You do not modify or fork Cordis. This package is an ordinary Cordis plugin that brackets a chosen hook and decides participant order itself, independent of plugin load timing.

Waterfall (HookOrdering) brackets the hook with a single prepended listener and exploits the onion model:

  • Code before the listener's next() runs ahead of the entire native chain — the front phase.
  • Code after next() runs behind everything, including the hook's built-in default — the back phase.

Serial (SerialHookOrdering) has no next() to wrap, so it brackets with two coordinators: a prepended front coordinator that runs ahead of the native chain (a bail there short-circuits the whole dispatch), and an appended back coordinator that runs best-effort last.

In both, participants register into the coordinator (not the raw hook) with before/after names, and a stable topological sort decides their order.

HookOrdering, load order [auth, logging, metrics]  ->  auth, logging, <host default>, metrics
HookOrdering, load order [metrics, logging, auth]  ->  auth, logging, <host default>, metrics   ← stable

Why this belongs in a plugin, not in Cordis

Cordis is deliberately minimal: it provides ordering primitives (array position, prepend, the next() chain). Ordering policy — numeric orders, before/after, topological sort — differs per hook and is not a kernel concern. Building it as a plugin means zero framework modification and no fork to maintain.

Layers

The package is layered so you can use it at the altitude you need:

Layer Entry point What it gives you
1. Algorithm @tengxiaohtx/dsh-plugin-hooks-ordering/topo-sort, /dag The pure stable topological sort and the constraint-graph (JSON) renderer. Zero dependencies, no Cordis.
2. Cordis services @tengxiaohtx/dsh-plugin-hooks-ordering (root), /waterfall, /serial HookOrdering and SerialHookOrdering — control any hook in any Cordis app.
3. DeepSeek-Harness @tengxiaohtx/dsh-plugin-hooks-ordering/dsh A dsh plugin + cordis.patch.yml that controls the real dsh hooks for you.

Install

pnpm add @tengxiaohtx/dsh-plugin-hooks-ordering
# peer dependency:
pnpm add @deepseek-ai/cordis

Usage

Waterfall hooks

import HookOrdering from '@tengxiaohtx/dsh-plugin-hooks-ordering'

ctx.plugin(HookOrdering)

// The hook owner (or app composition) takes control ONCE. This installs the
// single bracket listener; controlling twice throws, so the prepend race
// cannot come back.
ctx.hooksOrdering.control('request/assemble')

// Vendor A — names only its own constraint, imports nothing from vendor B.
ctx.hooksOrdering.register('request/assemble', 'front', {
  name: 'auth',
  before: ['logging'],
  run: (req) => authenticate(req),
})

// Vendor B — a different package, unaware of A.
ctx.hooksOrdering.register('request/assemble', 'front', {
  name: 'logging',
  run: (req) => log(req),
})

// Vendor C — must run after everything, even the host default.
ctx.hooksOrdering.register('request/assemble', 'back', {
  name: 'metrics',
  run: (req) => emitMetrics(req),
})

Regardless of the order these three plugins load or register, auth runs before logging, and metrics runs last.

Logging the constraint DAG

Pass a log file to write the constraint graph (JSON) on every registration change, so it always reflects current state — handy when debugging an unexpected order or a cycle:

ctx.plugin(HookOrdering, { log: './hooks-ordering-dag.json' })
{
  "sections": [
    {
      "hook": "request/assemble",
      "phase": "front",
      "nodes": ["auth", "logging"],
      "edges": [{ "from": "auth", "to": "logging" }]   // auth runs before logging
    },
    {
      "hook": "request/assemble",
      "phase": "back",
      "nodes": ["metrics"],
      "edges": []
    }
  ]
}

The graph is rendered without topological sorting, so a cycle is shown faithfully rather than throwing. You can also read it programmatically at any time via ctx.hooksOrdering.dumpDag() (returns the JSON string). Write failures are reported via console.warn and never thrown back into the fiber.

Serial hooks

import { SerialHookOrdering } from '@tengxiaohtx/dsh-plugin-hooks-ordering'

ctx.plugin(SerialHookOrdering)
ctx.serialHooksOrdering.control('turn/stopping')

// front: runs ahead of the native chain. Returning a bail value (anything but
// null/false/undefined) short-circuits the whole serial dispatch.
ctx.serialHooksOrdering.register('turn/stopping', 'front', {
  name: 'guard',
  run: (turn) => (isAllowed(turn) ? undefined : 'DENIED'),
})

// back: best-effort last (see Semantics and limits).
ctx.serialHooksOrdering.register('turn/stopping', 'back', {
  name: 'audit',
  run: (turn) => recordAudit(turn),
})

In DeepSeek-Harness

The /dsh entry is a dsh plugin that mounts both services and takes control of the dsh hooks that multiple packages contribute to (agent/pre-step, tools/pre-execute, tools/post-execute, system-prompt/assemble, llm/stream, … and the serial agent/turn-stopping). Add one row to your profile patch:

- insert:
    - id: hooks-ordering
      name: '@tengxiaohtx/dsh-plugin-hooks-ordering/dsh'
      config:
        # hooks: ['agent/pre-step', 'tools/post-execute']   # default: all known dsh waterfall hooks
        # serialHooks: ['agent/turn-stopping']              # default: [agent/turn-stopping]
        # log: './hooks-ordering-dag.json'                  # optional DAG log

Controlling a hook with no registered participants is a transparent pass-through, so this row changes nothing until a plugin registers with before/after. A package cordis.patch.yml is shipped at the root (declared via the dsh.bundle.patch manifest field) with the same row.

Recommended assembly order

Ordering is enforced by the coordinator, not by load position — but the coordinator's bracket must be prepended after the native listeners are registered, so mount this plugin last:

  • The plugin brackets each controlled hook with a single prepended listener; prepend places it ahead of every listener registered so far. Mounted last, its next() encloses the whole native chain — front runs before all native listeners, back after all of them (and after the host default).
  • Mounted earlier, a native plugin that registers later with { prepend: true } lands ahead of the bracket and escapes ordering — it "overwrites" the coordinator's placement.

Contributors are unaffected by load order: they register() into the coordinator with before/after names rather than racing on ctx.on, so they never compete for the prepend position.

In a dsh profile this means placing the hooks-ordering row in the user cordis.patch.yml, which is applied after every bundle layer — so the plugin loads last by construction. See examples/dsh-profile for a complete profile.

How ordering is done in dsh today (and the limits of each)

These are the existing ways to influence hook order in deepseek-harness — the "bypasses" this plugin supersedes. Each is real and each falls short of a declarative relative order:

  1. { prepend: true } on ctx.on(...) — the engine's only placement lever (unshift vs push, vendor/cordis/src/events.ts:143). It is binary and last-prepender-wins: two plugins that both prepend race, and neither can say "first among the front." Used e.g. by packages/spill/spill-policy/src/index.ts:209 and packages/llm/llm/src/invariant.ts:88.
  2. Registration / ctx.plugin(...) call order — default append makes call order the execution order. It works only while one composition site controls every call, and breaks the moment independent vendors load in an order nobody owns. Relied on deliberately (and fragilely) in packages/skill/tool-skill/src/index.ts:164 and packages/examples/agent-spine-demo/src/index.ts:257.
  3. inject dependencies — gates a plugin's activation on service availability (e.g. static inject = [...] in packages/core/agent-loop/src/index.ts:297). This orders a plugin relative to services, globally and single-directionally, and forces a dependency edge. It cannot express the relative order of two listeners already on the same hook, nor opposite orders on different hooks.
  4. Order invariant assertions — detect a wrong order after the fact (packages/context/time-context/src/invariant.ts:66) but do not enforce one.
  5. Profile cordis.patch.yml row order — explicitly carries no load semantics ("activation is service-availability driven", packages/bundle/base/cordis.patch.yml:13), so it cannot sequence listeners at all.

HookOrdering/SerialHookOrdering replace all five with a single declarative primitive: name your before/after, register into the coordinator, and the order is stable regardless of load timing — with the constraint graph available as a JSON DAG when it goes wrong.

API

ctx.hooksOrdering — the HookOrdering service (waterfall)

Method Description
control(hook) Install the bracket on a waterfall hook. Call once per hook. Returns a disposer. Throws HookControlError if already controlled.
register(hook, phase, entry) Add a participant to 'front' or 'back'. Returns a disposer. Throws HookControlError if the hook is not controlled.
plan(hook, phase) Return the participant names in the order they would run — for tests and diagnostics.
dumpDag() Return the constraint DAG of every controlled hook as a JSON string.

Config: ctx.plugin(HookOrdering, { log?: string }).

ctx.serialHooksOrdering — the SerialHookOrdering service (serial)

Same surface as HookOrdering (control / register / plan / dumpDag, same config). The entry's run may return a value: a bail value (anything but null/false/undefined) short-circuits the serial dispatch and becomes its result.

HookEntry / SerialHookEntry

Field Meaning
name Unique within a (hook, phase). Referenced by other entries' before/after.
before? Names this entry must precede.
after? Names this entry must follow.
run(...payload) Called with the hook payload (waterfall: dispatched args without Cordis' trailing next). Awaited. Serial run may return a bail value.

topoSort(entries) (.../topo-sort) and buildDag(sections) (.../dag)

The zero-dependency stable topological sort, exported standalone. Ties keep input order; an unknown before/after target is a no-op; a cycle throws OrderingCycleError. buildDag renders the constraint graph as a plain object ({ sections: [{ hook, phase, nodes, edges }] }) for JSON.stringify — it never sorts and never throws on a cycle.

Semantics and limits

  • Waterfall and serial only. The waterfall bracket needs next(); serial uses two coordinators with bail semantics. emit/parallel/bail hooks have no ordered chain to coordinate.
  • One coordinator per hook. A second prepend would reintroduce the race, so control rejects a double-take.
  • It orders what it owns. The coordinator controls its front/back registries and their internal order, and places them relative to the native chain. It does not reorder foreign listeners among themselves.
  • Waterfall back is exact; serial back is best-effort. Waterfall back runs after the whole native chain via next(). Serial has no next(), so its back coordinator is appended at control() time and runs after the listeners present then — a native listener added after control() will run after it, and any bail (native or front) skips it entirely.
  • Unknown reference = no-op. A cross-vendor after: ['maybe-absent'] imposes no constraint when that peer is not loaded — cross-vendor plugins cannot assume each other's presence.
  • Cycles fail loud at dispatch. Conflicting constraints throw OrderingCycleError naming the blocked entries (and dumpDag() still renders the cycle for inspection).

Development

pnpm install
pnpm test            # vitest, unit + real-cordis integration
pnpm test:coverage   # 100% per-file gate
pnpm typecheck
pnpm lint
pnpm build           # tsdown -> lib/ (ESM + d.ts)
pnpm demo            # the problem and the fix, waterfall + serial, side by side
pnpm playground:build   # -> playground/dist (deployed to GitHub Pages)

CI installs with pnpm against the public npm registry (see .npmrc).

License

MIT

—/ 5

No ratings yet

Verified DSH bundle

Commit d4d6e82d720e

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout