English lecture · source-traced

Building a “pluginized agent harness” from scratch (DeepSeek Harness lineage) · full development tutorial

**Against source**: [deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) `v0.1.0-rc.5` @ `47f9438` (MIT) **Its one-liner**: *"It uses an architecture where **everything is a plugin**."* (`README.md:7`) **What this tutorial teaches**: not “write another agent loop,” but **first build a runtime in which any part can be replaced by a plugin, then make the agent loop itself a plugin**. **How to

EN
lecture
source
file:line
no
embellishment
中文
sibling

Against source: deepseek-ai/deepseek-harness v0.1.0-rc.5 @ 47f9438 (MIT) Its one-liner: "It uses an architecture where everything is a plugin." (README.md:7) What this tutorial teaches: not “write another agent loop,” but first build a runtime in which any part can be replaced by a plugin, then make the agent loop itself a plugin. How to read: 20 steps, each with “why → minimal implementation → how to accept → where real dsh lives.” The code is TypeScript and can be run as-is. Prerequisite: any one programming-agent analysis in this series (Claude Code / Codex / Open Design). You do not need to know Cordis—steps 1–5 rebuild its five ideas from zero. Companion: DeepSeek Harness source analysis (241-package roster + collaboration graph + engineering regime)


Part 1

Contents

Part 0 · Look at the end first - Look at the end first: what a “pluginized harness” looks like

Part 1 · Foundations: five ideas (steps 1–5) - Step 0 · The scene: why not write a main() loop - Step 1 · context is a service repository - Step 2 · inject: service requirements instead of boot order - Step 3 · Reversible registration: effect and disposer - Step 4 · Four kinds of dispatch: observe / rewrite / fan-out / queue - Step 5 · waterfall is around-middleware: the next() contract

Part 2 · The source of truth: the session log (steps 6–7) - Step 6 · append-only log + deriveMessages() - Step 7 · Turn “model-visible ⟺ logged” into a runtime assertion

Part 3 · The loop: turn and step (steps 8–13) - Step 8 · turn and step: one loop, two boundary levels - Step 9 · inbox: two delivery ports decide what is owed - Step 10 · agent/pre-step: decide what the model sees - Step 11 · Tool registry and three waterfalls - Step 12 · Monotonic guards: why later plugins cannot widen approval - Step 13 · System prompt: reassemble every step

Part 4 · Capability: the three-role seam (steps 14–17) - Step 14 · capability seam: Definition / Provider / Consumer - Step 15 · Swap one provider, move the whole execution world - Step 16 · Sandbox: wrap argv - Step 17 · Context economics: compaction and spill

Part 5 · Product: assembly and discipline (steps 18–20) - Step 18 · Keep the loop going: agent/turn-stopping and goal - Step 19 · bundle and profile: make assembly a patchable layer - Step 20 · Write discipline as gates: 100% coverage + verify-*

Part 6 · Close - 🎬 Full replay: what “fix a bug for me” actually ran - Appendix A · Acceptance assertions (how to verify each step) - Appendix B · Fourteen easiest ways to crash - Appendix C · Source index - Close: who this route is for


Part 2

Look at the end first: what a “pluginized harness” looks like

Put the end state on the table first. The next 20 steps are filling bricks into this picture.

An ordinary agent project looks like this: one main(), a while loop inside it, call the model, run tools, stitch prompts. Want a different model? Edit main(). Want permissions? Edit main(). Want someone else to extend it—no door.

DeepSeek Harness has a completely different shape:

flowchart TB
    subgraph boot["Boot: YAML becomes a plugin tree"]
        P["profile(web / headless)"] --> B1["bundle: base"]
        P --> B2["bundle: web-app"]
        B1 & B2 --> PATCH["cordis.patch.yml layered overlays"]
        PATCH --> TREE["final plugin tree"]
    end
    subgraph run["Runtime: context is a service repository"]
        TREE --> CTX["ctx"]
        CTX -.provides.-> S1["ctx.sessions"]
        CTX -.provides.-> S2["ctx.llm"]
        CTX -.provides.-> S3["ctx.tools"]
        CTX -.provides.-> S4["ctx.agentLoop"]
        CTX -.provides.-> S5["ctx.shell / fs / sandbox …"]
    end
    S4 --> LOOP["agent loop (itself just a plugin)"]
    LOOP --> LOG["append-only session log"]
    LOG -->|deriveMessages| REQ["next model request"]
    S3 --> PIPE["tool three-stage waterfall"]

Three sentences for this shape:

  1. There is no privileged core to patch. The original: "There is no privileged core to patch: you extend dsh by mounting a plugin beside the others" (docs/architecture.md:13). Model adapters, the tool registry, the session log, and even the agent loop itself are all plugins.
  2. Configuration is architecture. Running dsh --profile web --dump-config prints the tree this machine actually boots, "Any row it prints can be replaced by a patch of your own" (docs/architecture.md:35).
  3. The session log is the only source of truth. Every byte the model sees must be reconstructable from the log; that is written as a runtime assertion.

After these 20 steps you will have a ~700-line minidsh with: a service repository + dependency injection + four event modes + reversible registration + an append-only log + a two-level turn/step loop + a three-stage tool pipeline + one swappable-provider shell seam + layered config assembly. Then reading real dsh will feel like reading something you wrote.

📌 Hint: this tutorial’s code hand-writes a tiny plugin framework in TypeScript; it is not a Cordis API tutorial. The reason is simple—you need to understand why these primitives exist, not their parameter tables. Real APIs are cited at the end of each step under “where real dsh lives.”


Part 1 · steps 1–5Foundations: five ideas
Part 3

Step 0 · The scene: why not write a `main()` loop

Give yourself a concrete task. Every later step will test against it:

The user says: “Fix the bug in src/parser.ts that treats the empty string as valid input, then run the tests.”

The path that sentence must walk: read a file → think → edit a file → run a shell → look at the result → maybe edit again → answer.

If you write a main():

// 每个人第一次都会这么写
async function main(prompt: string) {
  const messages = [{ role: 'user', content: prompt }]
  while (true) {
    const reply = await callModel(messages, TOOLS)
    messages.push(reply)
    if (!reply.toolCalls) return reply.content
    for (const call of reply.toolCalls) {
      messages.push(await runTool(call))
    }
  }
}

This code runs, and many products ship exactly this. The problem is not “can it run,” but that every later requirement comes back to edit it:

Later requirement In the main() design Cost
Add permission confirmation Insert an if before runTool Permission logic tangled with execution
Swap model vendors Change callModel One more branch per vendor
Compact when context overflows Insert compaction in the while Compaction coupled to the loop
Let others write extensions Cannot; they have to fork Ecosystem = zero
Support Web + CLI + automation protocol Three main()s Behavior drift
Resume after disconnect messages lived in memory; gone Start over

DeepSeek Harness solves all six at once by changing the foundation: do not write main(); write a service repository, and let each of those six things become a plugin hung beside the repository.

Its constitution writes this as a hard rule (AGENTS.md):

Plugins, not loop changes: new behavior goes on documented extension points; changing agent-loop requires updating docs/architecture.md.

Translation: the bar for changing the loop is deliberately raised to “you must update the architecture document.” That is a fierce sentence—it means “if you want to change the loop, first convince architecture.”

What this step buys you

For each of the next 20 steps I will first ask “what would happen if this lived in main().” That contrast is the only effective way to understand harness-shaped architecture.


Part 4

Step 1 · context is a service repository

Why

We need things like “the model adapter” to be replaceable. Replacement requires: callers must not import a concrete implementation. If agent-loop.ts writes import { DeepSeekAdapter } from './deepseek', it is bound forever.

The fix is one extra layer of indirection: callers know only a stable name (ctx.llm), not who is behind it.

Cordis’s first idea (docs/cordis-primer.md:10):

A context is a repository of services. A service claims a stable ctx.<key> such as ctx.tools, ctx.llm, or ctx.sessions from a context; other plugins find services via key instead of importing a concrete implementation.

Minimal implementation

// minidsh/context.ts
type Disposer = () => void

/** 一个插件就是一个函数(可选带 inject / name 元数据)。 */
export interface Plugin {
  (ctx: Context): void | Disposer | Promise<void | Disposer>
  inject?: string[]
  name?: string
}

export class Context {
  /** 服务表:键是稳定名字,值是任意实现。 */
  private services = new Map<string, unknown>()
  /** 每个插件挂载后拿到的清理函数。 */
  private disposers: Disposer[] = []

  /** 声明一个服务。重复声明直接抛错——重名是配置错误,不是可容忍状态。 */
  provide<T>(key: string, impl: T): Disposer {
    if (this.services.has(key)) {
      throw new Error(`service "${key}" already provided`)
    }
    this.services.set(key, impl)
    return () => this.services.delete(key)
  }

  /** 按名字取服务。取不到就抛——静默返回 undefined 会让错误跑到很远的地方才炸。 */
  get<T>(key: string): T {
    const found = this.services.get(key)
    if (found === undefined) throw new Error(`service "${key}" not provided`)
    return found as T
  }

  has(key: string): boolean {
    return this.services.has(key)
  }
}

Three deliberate decisions, each mapping to a real dsh rule:

Decision Reason dsh counterpart
Duplicate provide throws Two plugins racing for the same key = assembly error; you must know immediately "mounting both fails loud on a duplicate service registration" (packages/shell/shell/src/index.ts:16-20)
get missing throws Silent undefined surfaces hundreds of lines later Misconfiguration fails loud (AGENTS.md)
provide returns a disposer Hook for step 3’s “reversible” "a registry's register() returns the disposer" (AGENTS.md)

How to accept

const ctx = new Context()
ctx.provide('llm', { name: 'fake' })
console.assert(ctx.get<{ name: string }>('llm').name === 'fake')
try { ctx.provide('llm', {}); console.assert(false, '应该抛错') } catch {}
try { ctx.get('nope'); console.assert(false, '应该抛错') } catch {}

Where real dsh lives

  • 73 service keys all live on ctx: ctx.sessions ctx.llm ctx.tools ctx.agents ctx.agentLoop ctx.systemPrompt ctx.shell ctx.fs ctx.sandbox ctx.goals ctx.skills ctx.spillStore …
  • Service keys are declared via TypeScript declaration merging:
// packages/shell/shell/src/index.ts:40-44
declare module '@deepseek-ai/cordis' {
  interface Context {
    shell: ShellExecutor
  }
}

The clever part: the type of a service key is globally visible, but the implementation is injected at runtime. In any package you can write ctx.shell.run(...) with full type hints and import no implementation.


Part 5

Step 2 · `inject`: service requirements instead of boot order

Why

Once you have a service repository you immediately hit the second problem: order.

tool-bash needs both ctx.shell and ctx.tools before it can register itself. If you hand-sort boot order, you maintain a “who before whom” table—once plugins proliferate that table is a disaster, and an external plugin has no idea where to insert itself.

Cordis’s third idea (docs/cordis-primer.md:11):

Declare service dependency via inject. A plugin that names required services waits until those services exist, so load order is expressed through service requirements rather than manual boot sequencing.

"load order is expressed through service requirements rather than manual boot sequencing" — that sentence is the turning point of the whole architecture.

Minimal implementation

Add a pending-mount queue on Context: wait until dependencies are present, then mount.

// minidsh/context.ts(续)
export class Context {
  // …前略
  private pending: Plugin[] = []

  /** 挂载一个插件。依赖未齐时进等待队列。 */
  async plugin(plug: Plugin): Promise<void> {
    this.pending.push(plug)
    await this.drain()
  }

  /** 反复扫描等待队列,挂载所有依赖已齐的插件,直到没有进展。 */
  private async drain(): Promise<void> {
    let progressed = true
    while (progressed) {
      progressed = false
      for (const plug of [...this.pending]) {
        const needs = plug.inject ?? []
        if (!needs.every(key => this.has(key))) continue
        this.pending.splice(this.pending.indexOf(plug), 1)
        const disposer = await plug(this)
        if (disposer) this.disposers.push(disposer)
        progressed = true   // 新服务可能解锁了别的插件
      }
    }
  }

  /** 依赖永远等不到的插件是装配错误,启动结束时必须报出来。 */
  assertSettled(): void {
    if (this.pending.length === 0) return
    const stuck = this.pending.map(p => `${p.name ?? 'anonymous'} needs [${(p.inject ?? []).join(', ')}]`)
    throw new Error(`plugins never activated:\n  ${stuck.join('\n  ')}`)
  }
}

Note drain()’s progressed loop: mounting one plugin may provide a new service and unlock another. That is the entire mechanism of “order expressed as requirements”—a fixed-point iteration of under 20 lines, replacing the whole hand-sorted table.

assertSettled() is equally important. Without it, a plugin whose inject has a typo silently never loads, and you spend two hours finding out. Real dsh has a whole gate against this (scripts/verify-cordis-config.ts), and the constitution says:

Misconfiguration fails loud at load when self-contained, otherwise at the earliest resolvable point; never silently skip a missing referent.

How to accept

Mount out of order on purpose and watch it sort itself:

const ctx = new Context()
const toolBash: Plugin = (c) => { c.provide('tool:bash', { shell: c.get('shell') }) }
toolBash.inject = ['shell']; toolBash.name = 'tool-bash'

await ctx.plugin(toolBash)                       // 先挂消费者:进等待队列
console.assert(!ctx.has('tool:bash'), '还不该激活')
await ctx.plugin((c) => { c.provide('shell', { run: async () => 'ok' }) })
console.assert(ctx.has('tool:bash'), '依赖到齐后应自动激活')
ctx.assertSettled()

Where real dsh lives

// packages/shell/tool-bash/src/index.ts:30-31
export const name = 'tool-bash'
export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv']

I ran a full census of 241 packages. The in-degree of inject shows where the architecture actually concentrates:

Most-injected service Count Note
tools 31 The tool registry is the largest convergence point
systemPrompt 20 Everyone wants a slice of the prompt
sessions 17 The log is truth
agents 13 Live agent registry
subagents 12 Sub-agent seam
llm 9 Model seam

⚠️ A real incident: docs/postmortem/0001-acp-default-export-drops-inject.md records what happens when this mechanism is bypassed—a package used export default instead of named exports, the inject declaration was dropped, and the plugin mounted before its dependencies were ready. Unit tests were all green; the product was broken. The later countermeasure is an explicit test assertion expect('default' in mod).toBe(false). This pit is worth defending against in your own framework early.


Part 6

Step 3 · Reversible registration: effect and disposer

Why

Plugins can now mount. But an agent harness has a special need: unload.

Three scenes force you to support unload:

  1. Hot reload: change one prompt line in development without restarting the process.
  2. Per-session assembly: session A uses tool set X, session B uses tool set Y—when B ends, Y must come off cleanly.
  3. Sub-agents: a live sub-agent registers a few exclusive tools; when it dies those tools must disappear.

If registration is “push onto a global array,” none of the three is possible—you do not know which rows to delete.

Cordis’s fifth idea (docs/cordis-primer.md:13):

Registrations are reversible effects. Prompt sections, tool schemas, adapters, providers, and listeners are installed through ctx.effect() or ctx.on() so reload and teardown unwind them predictably.

dsh’s constitution puts it at the top (AGENTS.md):

Registrations are effects: every contribution goes through ctx.effect() / ctx.on(); a registry's register() returns the disposer.

Minimal implementation

// minidsh/context.ts(续)
export class Context {
  // …前略

  /**
   * 登记一个可逆副作用。返回的 disposer 既交给调用方,也记在本 context 上,
   * 所以「插件自己撤销」和「整棵树拆卸」两条路都能收干净。
   */
  effect(install: () => Disposer): Disposer {
    const undo = install()
    let done = false
    const disposer = () => {
      if (done) return          // 幂等:重复 dispose 不该炸
      done = true
      undo()
    }
    this.disposers.push(disposer)
    return disposer
  }

  /** 逆序拆卸:后注册的先撤,和资源获取顺序对称。 */
  dispose(): void {
    for (const d of this.disposers.reverse()) d()
    this.disposers = []
    this.services.clear()
  }
}

Two details:

  • Idempotent: the done flag prevents double-undo. In the real world a disposer is called twice (the plugin undoes itself + the tree tears down); if it is not idempotent it explodes on the teardown path, which is the hardest place to debug.
  • Reverse order: reverse(). Registration is A→B→C; teardown must be C→B→A, because C may depend on state B established. dsh’s constitution writes this specifically: “If teardown order matters, keep the related work in one effect so disposal unwinds in the intended sequence” (docs/cordis-primer.md:44).

How to accept

Real dsh requires every registry to have an HMR-safety test (docs/testing.md:9):

Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup).

Copy that idea:

const ctx = new Context()
const log: string[] = []
await ctx.plugin((c) => {
  c.effect(() => { log.push('tool+'); return () => log.push('tool-') })
  c.effect(() => { log.push('prompt+'); return () => log.push('prompt-') })
})
ctx.dispose()
console.assert(log.join(',') === 'tool+,prompt+,prompt-,tool-', '必须逆序撤销')

Where real dsh lives

The registry register() in packages/core/tools/src/index.ts returns a disposer; packages/core/agent-loop/src/index.ts:39 has a dedicated FactoryOwnership class for “factory-level ownership: live-agent teardown + config boot work.”

The @module JSDoc of agent-loop puts it in one sentence:

Concrete agent-loop plugin: creates scoped ReactLoopAgents, publishes them through the agent/session registries, and owns their ordered teardown.

"owns their ordered teardown"—ordered teardown is the first sentence of the module’s job. That is rare in agent projects; most products treat teardown as “the process exited, good enough.”


Part 7

Step 4 · Four kinds of dispatch: observe / rewrite / fan-out / queue

Why

Plugins need to talk. The easiest thought is EventEmitter: emit('tool-call', args).

But “events” in an agent harness have four semantically different needs:

  1. I only want to know what happened (UI updates a log) → no return value, no waiting.
  2. I want to rewrite the thing (a permission plugin turns allow into deny) → needs a return value, needs to be able to stop it.
  3. I want everyone to handle it in parallel (several persistence backends write to disk) → wait for all to finish.
  4. I want everyone to speak in order (several policies decide in sequence) → wait, and order matters.

One EventEmitter cannot hold these four semantics. Mix them and a listener secretly returns a value nobody uses, or an async listener is never awaited and is dropped.

Cordis makes these four dispatch modes, and—this is the crucial sentence—the dispatch mode is part of the event’s public contract (docs/cordis-primer.md:26):

The dispatch mode is part of the event's public contract. New harness events document it with an @mode tag so the generated catalog can check declarations against dispatch sites.

Mode await? Order Has return? Used for
emit no registration order no observe (UI, log, telemetry)
waterfall no registration order yes rewrite, intercept, policy
parallel yes parallel no fan-out (multi-backend persistence)
serial yes registration order yes sequential statements

Minimal implementation

// minidsh/events.ts
type Listener = (...args: any[]) => any

export class EventBus {
  private listeners = new Map<string, Listener[]>()

  /** 注册监听。prepend 用于必须跑在普通注册之前的少数情况。 */
  on(event: string, fn: Listener, prepend = false): () => void {
    const list = this.listeners.get(event) ?? []
    prepend ? list.unshift(fn) : list.push(fn)
    this.listeners.set(event, list)
    return () => {
      const cur = this.listeners.get(event) ?? []
      const i = cur.indexOf(fn)
      if (i >= 0) cur.splice(i, 1)
    }
  }

  private of(event: string): Listener[] {
    return [...(this.listeners.get(event) ?? [])]
  }

  /** 观察:不等、无返回。一个 listener 抛错不该带崩其它 listener。 */
  emit(event: string, ...args: any[]): void {
    for (const fn of this.of(event)) {
      try { fn(...args) } catch (e) { console.warn(`listener of ${event} failed:`, e) }
    }
  }

  /** 扇出:全部并行,等到都结束。 */
  async parallel(event: string, ...args: any[]): Promise<void> {
    await Promise.all(this.of(event).map(fn => fn(...args)))
  }

  /** 依次表态:按注册序等每一个,返回最后一个非 undefined 的结果。 */
  async serial<T>(event: string, ...args: any[]): Promise<T | undefined> {
    let out: T | undefined
    for (const fn of this.of(event)) {
      const r = await fn(...args)
      if (r !== undefined) out = r
    }
    return out
  }

  /** around 中间件:见第 5 步。 */
  async waterfall<T>(event: string, arg: unknown, seed: () => Promise<T>): Promise<T> {
    const chain = this.of(event)
    const run = async (i: number): Promise<T> => {
      if (i >= chain.length) return seed()
      return chain[i](arg, () => run(i + 1))
    }
    return run(0)
  }
}

How to accept

The behavioral differences among the four modes must be testable, or you will mix them eventually:

const bus = new EventBus()
const order: string[] = []
bus.on('x', () => { order.push('a') })
bus.on('x', () => { order.push('b') })
bus.emit('x')
console.assert(order.join('') === 'ab', 'emit 按注册序')

// waterfall 能改写结果
bus.on('cfg', (_arg: unknown, next: () => Promise<any>) => next().then(c => ({ ...c, temp: 0 })))
const cfg = await bus.waterfall('cfg', null, async () => ({ model: 'deepseek-chat' }))
console.assert(cfg.temp === 0 && cfg.model === 'deepseek-chat')

Where real dsh lives

Events are declared with TypeScript declaration merging, each with an @mode tag. Look at the tool registry’s four events (packages/core/tools/src/index.ts:150,161,173,195):

/** @mode waterfall */  'tools/pre-execute'(exec, next): Promise<PreToolDecision>
/** @mode waterfall */  'tools/execute'(exec, next): Promise<...>
/** @mode waterfall */  'tools/post-execute'(exec, result, next): Promise<PostToolDecision>
/** @mode emit     */  'tools/result'(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): undefined

Note the last one’s mode difference: the first three are waterfall (can rewrite); tools/result is emit (observe only), and both parameters are Readonly<>.

This is a design of real taste: "what can be changed" and "what can only be watched" are separated at the type level by different dispatch modes. Once a result is finalized it is frozen; no observer can mutate it. dsh even adds a runtime invariant (packages/core/tools/src/invariant.ts:23-25):

if (!Object.isFrozen(exec)) fail('tools/result execution must be frozen before publication')

💡 Steal this one: in your own framework, for every event answer clearly “can it change the result.” If yes, waterfall; if not, emit + Readonly + freeze. That one line blocks an entire class of “some plugin secretly mutated a finalized result” bugs.


Part 8

Step 5 · waterfall is around-middleware: the `next()` contract

Why

Step 4’s waterfall is only 8 lines, but its semantics need a step of their own, because this is the easiest place in the whole architecture to get wrong.

waterfall is not “notify in sequence”; it is around-middleware (docs/cordis-primer.md:30):

ctx.waterfall is around-middleware. A listener receives (...args, next). Call next() to delegate the possibly wrapped result to the next service; return without next() to short-circuit. Values propagate through next()'s return value.

Together with the constitution’s ALL-CAPS rule (AGENTS.md):

Waterfall listeners MUST call next() to delegate; returning without it short-circuits the chain.

Three correct writings

// ① 协作型:改一改,然后委托(最常见)
ctx.on('agent/request', async (arg, next) => {
  const config = await next()          // 先让下游都表态
  return { ...config, maxTokens: 4096 } // 再包一层
})

// ② 前置型:先改输入,再委托
ctx.on('tools/pre-execute', async (exec, next) => {
  exec.args.path = normalize(exec.args.path)  // 改共享的可变对象
  return next()
})

// ③ 决策型:我拥有这个决定,故意不调 next(短路)
ctx.on('tools/pre-execute', async (exec, next) => {
  if (isDangerous(exec)) return { kind: 'deny', reason: '危险命令' }  // 短路
  return next()                                                      // 不管的就委托
})

The third is intentional, not a bug. The primer is clear:

For single-decision events, short-circuiting is the design. A policy listener can return without next() when it owns the decision, while a listener that only annotates or observes must delegate.

A pit you will hit

// ❌ 错:忘了 next(),整条链后面的插件全被静默跳过
ctx.on('agent/request', async (arg, next) => {
  logRequest(arg)                       // 我只想记个日志
  return { model: 'deepseek-chat' }     // ← 灾难:短路了所有下游策略
})

// ✅ 对:观察者必须委托
ctx.on('agent/request', async (arg, next) => {
  logRequest(arg)
  return next()
})

The terror of this bug is that it does not throw. Your permission plugin, compaction plugin, and telemetry plugin all silently stop working, and the logs look fine.

That is why dsh put the rule in AGENTS.md and used uppercase MUST.

Close: the five ideas are in hand

mindmap
  root((Cordis five ideas))
    A plugin is an object that implements Service
      function + inject + apply
      or a Service subclass
    context is a service repository
      stable ctx.key
      find by name, do not import the implementation
    inject declares dependencies
      service requirements instead of boot order
      fixed-point iteration auto-sorts
    Typed Events for communication
      emit observe
      waterfall rewrite
      parallel fan-out
      serial queue
    Registration is a reversible effect
      every registration returns a disposer
      reverse-order teardown
      the premise of HMR and per-session assembly

At this point your minidsh is ~150 lines and already has all of real dsh’s foundation primitives. The remaining 15 steps hang plugins on this foundation and do not change the foundation—that is exactly what harness-shaped architecture is trying to prove.

Where real dsh lives

docs/cordis-primer.md (44 lines; five minutes; strongly worth reading in the original); vendor/ is a pinned Cordis source copy; the manifest and sync process are in vendor/README.md.

Cordis itself has a paper A Programming Paradigm for Spatiotemporal Composability (linked from README.md:7). “Spatiotemporal composability” sounds mystical; in engineering it is these five: in space a service is replaceable by key; in time registration unwinds in reverse order.


Part 2 · steps 6–7The source of truth: the session log
Part 9

Step 6 · append-only log + `deriveMessages()`

Why

Back to step 0’s main(): const messages = [...]. That array is its entire truth, and it lives in memory.

So none of these requirements are possible: reconnect after disconnect, session fork, audit “what the model actually saw,” UI replay of the stream, feed one step’s input into eval.

dsh’s choice (docs/architecture.md:94):

The session log is the source of the context the model sees. deriveMessages() projects model history from it, and raw assistant/chunk events preserve replay and UI fidelity. Fork, resume, transcripts, telemetry, and persistence all derive from this stream.

The key is direction: not “a message array that also happens to log,” but “the log is truth; the message array is a projection from the log.” That is the correct posture of event sourcing on an agent.

Minimal implementation

// minidsh/session.ts
export interface SessionEvent {
  seq: number
  type: string
  data: any
}

export class Session {
  readonly events: SessionEvent[] = []
  private seq = 0
  constructor(private bus: EventBus, readonly id: string) {}

  /** 唯一的写入口。返回事件(带 seq),供调用方交叉引用。 */
  append(type: string, data: any): SessionEvent {
    const event: SessionEvent = { seq: ++this.seq, type, data }
    this.events.push(event)
    this.bus.emit('session/event', event)   // 持久化 / UI / 遥测都挂这里
    return event
  }

  /**
   * 从日志投影出模型历史。这是「模型看到什么」的唯一计算路径,
   * 所以任何想影响模型输入的东西都必须先成为一个事件。
   */
  deriveMessages(): Message[] {
    const out: Message[] = []
    for (const e of this.events) {
      if (e.type === 'user/message') out.push({ role: 'user', content: e.data.content })
      else if (e.type === 'assistant/message') out.push(e.data.message)
      else if (e.type === 'tool/result') {
        out.push({ role: 'tool', toolCallId: e.data.callId, content: e.data.content })
      }
      // 其它事件(turn/start、assistant/chunk、todo/write…)不进模型历史,
      // 但它们仍在日志里——UI 与重放需要它们。
    }
    return out
  }
}

Note the events skipped inside deriveMessages(). That is what “the log is a superset” means:

  • assistant/chunk (every streaming fragment) → not in history, but UI replay needs it
  • turn/start / step/end → not in history, but structure analysis and telemetry need them
  • todo/write → not in history, but the UI must render a checklist

A detail worth stealing: messages and their source chunks are bidirectional

Real dsh, when appending assistant/message, records which chunk events it was assembled from (packages/core/agent-loop/src/agent.ts:349,381-390):

const chunkSeqs: number[] = []
for await (const chunk of stream) {
  chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq)
  assembler.push(chunk)
}
// …
this.session.append('assistant/message', { turn, step, message, usage },
  { surfaceOp: 'append', sourceEventSeqs: chunkSeqs })

sourceEventSeqs: chunkSeqs lets you walk from any final message back to its original streaming fragments. When you debug “why was this answer truncated,” that is life-saving.

How to accept

const s = new Session(bus, 'sess-1')
s.append('turn/start', { turn: 1 })
s.append('user/message', { content: '修 bug' })
s.append('assistant/chunk', { chunk: '好' })     // 不进历史
s.append('assistant/message', { message: { role: 'assistant', content: '好的' } })
console.assert(s.deriveMessages().length === 2, '只有 user + assistant 进历史')
console.assert(s.events.length === 4, '但日志里四条都在')

Where real dsh lives

packages/core/session (8 401 lines), @module JSDoc:

Event-sourced session service: append-only session log, in-memory store, and the derived LLM message history. Persistence is a plugin concern (subscribe to session/event, drain on session/flush).

"Persistence is a plugin concern"—the session service itself never touches disk. The session group is 13 packages, splitting persistence (JSONL + zstd), projection, title generation, telemetry, and citation resolution into independent plugins. That is why dsh-session is depended on by 84 packages, third in the repo (after dsh-invariants 218 and schemastery 110).


Part 10

Step 7 · Turn “model-visible ⟺ logged” into a runtime assertion

Why

Step 6 laid down a rule: the log is truth. Rules get violated.

The typical violation is stealthy: a plugin wants to add a slice of context to the model, so it pushes a message directly onto request.messages. The feature works, but that message is not in the log—session replay is missing a slice, audit does not match, eval cannot reproduce.

dsh upgrades the rule into a constitution with a runtime assertion (docs/architecture.md:96, AGENTS.md):

Model-visible means logged. Anything that reaches a model request must be reconstructable from the log, and a runtime invariant asserts it. This is why a new model-visible input requires a new session event: extend SessionEventMap and render from the log.

Model-visible ⟺ logged: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event.

Minimal implementation

Idea: a request can only come from deriveMessages(). Want to add something? Append an event first.

// minidsh/invariant.ts
/**
 * 断言这次请求的消息全部来自日志投影。
 * 不这么做的后果:插件绕过日志偷偷加上下文,会话重放与审计静默失真。
 */
export function assertModelVisibleIsLogged(session: Session, request: { messages: Message[] }): void {
  const derived = JSON.stringify(session.deriveMessages())
  const sent = JSON.stringify(request.messages)
  if (derived !== sent) {
    throw new InvariantError(
      `model-visible input not reconstructable from log:\n  derived=${derived}\n  sent=${sent}`,
    )
  }
}

So the only correct posture for “add context to the model” becomes:

// ❌ 绕过日志
request.messages.push({ role: 'user', content: '当前分支是 main' })

// ✅ 先成为事实,再被投影
agent.inject({ role: 'user', content: '当前分支是 main' })  // append 一个 user/message 事件

How real dsh is even stricter

dsh does not make the invariant a global function; it makes it a per-package ownership registration service (docs/subsystems/invariants.md):

  • ctx.invariants.register(packageName, installer)—every workspace package publishes a companion ./invariant plugin and registers checks under its own npm package name.
  • Failure throws InvariantError, with a stable code: 'INVARIANT' and packageName, message prefixed invariant violated by "<package>": …—violations are attributable, and the registry need not import any product package.
  • Checks run in a dedicated sub-fiber; installer.inject declares which services that fiber may access.
  • Regex white/blacklists choose which packages’ checks are enabled; blacklist beats whitelist; illegal config (empty string, spaces, duplicates, invalid regex) throws at service start instead of skipping.

The most careful part is what it allows a check to assert (AGENTS.md):

Runtime invariants assert owned relationships. Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. Without a plausible relationship, an explained empty companion is correct.

Translation: you may not write “check whether ctx.shell exists”—that kind of pretend check. When there is no real relationship to assert, the correct move is to export an empty installer and explain in a comment why this package has nothing to check—the comment must start with No runtime invariant:.

And that rule is machine-enforced: pnpm run verify-package-invariants rejects “generated placeholder markers,” “empty installer with no explanation,” “non-empty installer that ignores the reporter,” and “wrong registration name.”

That is why dsh-invariants is depended on by 218 packages—90% of 241. I have not seen a second project in this series of 21 do this.

How to accept

The way you accept an invariant is unusual: you must first violate it and watch it go red. That is exactly the testing constitution (docs/testing.md:34):

A guard only guards if the regression actually fails it. … introduce the regression, watch red, revert.

// 故意绕过日志
request.messages.push({ role: 'user', content: '偷偷加的' })
try {
  assertModelVisibleIsLogged(session, request)
  console.assert(false, '不变量没抓到违规——它是假的')
} catch (e) { /* 期望:抓到 */ }

Part 3 · steps 8–13The loop: turn and step
Part 11

Step 8 · turn and step: one loop, two boundary levels

Why

Step 0’s main() while has only one level. One is not enough, because two different questions need answers:

  1. “After this model request, do we request again?” (tools finished; results must be fed back)
  2. “Is this whole conversation round over?” (does the user have new input; do plugins still owe work)

Collapsed into one level, you cannot express “this round ended, but a plugin stuffed in something new, so continue.”

dsh’s definition (docs/architecture.md:65):

A step is one model request plus the tools it calls. A turn is zero or more steps: it opens before its first input is claimed and closes once nothing is owed.

"a turn is zero or more steps" — a turn with zero steps is legal; that is a deliberate design (the next step explains why).

The full pipeline

sequenceDiagram
    participant IN as inbox
    participant D as driver (loop)
    participant SP as systemPrompt
    participant L as session log
    participant M as ctx.llm
    participant T as ctx.tools

    D->>L: append turn/start
    loop each step
        D->>IN: claim(target, turn)
        D->>SP: assemble() (prompt sections + tool schemas)
        D->>D: agent/pre-step (waterfall: reject | enter)
        Note over D: reject, or first enter rewritten empty
→ close this zero-step turn D->>L: append step/start D->>L: append user/message ×N D->>L: deriveMessages() D->>D: agent/request (waterfall: provider/model/params) D->>M: stream(request) M-->>L: append assistant/chunk ×N (every fragment logged) D->>L: append assistant/message (with sourceEventSeqs) alt hit max-tokens D->>D: end (sticky) else no tool call D->>D: completed else has tool call D->>T: execute (three waterfalls; see step 11) T-->>L: append tool/call + tool/result end D->>L: append step/end D->>D: agent/turn-stopping (serial) — plugins' last chance Note over D,IN: still owed? (inbox.nextStep nonempty) → next step end D->>L: append turn/end (structured reason)

Minimal implementation

// minidsh/loop.ts
type TurnEndReason =
  | { kind: 'completed' } | { kind: 'max-tokens' } | { kind: 'blocked' }
  | { kind: 'aborted' } | { kind: 'error'; error: unknown }

export class Loop {
  private turnNo = 0
  constructor(private ctx: Context, private session: Session, private inbox: Inbox) {}

  /** 外层:turn 返回 true 表示 inbox 还有东西,继续开新 turn。 */
  async run(): Promise<void> {
    while (await this.turn()) { /* 空 body 是故意的:条件即语义 */ }
  }

  private async turn(): Promise<boolean> {
    const turn = ++this.turnNo
    this.session.append('turn/start', { turn })
    let reason: TurnEndReason | null = null
    let target: InboxTarget = 'next-turn'
    let stepNo = 0
    try {
      while (true) {
        const step = stepNo + 1
        const decision = await this.preStep(target, turn, step)

        if (decision.kind === 'reject') { reason = { kind: 'blocked' }; return false }
        if (reason && decision.messages.length === 0) break
        // 被清掉的唤醒消息、或被改写成空的 enter,仍然拥有这个 turn 边界,
        // 但它不花模型调用——日志因此记下了「这次尝试」。
        if (stepNo === 0 && decision.messages.length === 0) {
          reason = { kind: 'completed' }; return false
        }

        this.session.append('step/start', { turn, step })
        stepNo = step
        try {
          for (const m of decision.messages) this.session.append('user/message', m)
          const stepEnd = await this.step(decision.assembly, turn, step)
          // max-tokens 是 sticky 的:后面正常完成的 step 不能把它降级。
          if (reason === null || reason.kind !== 'max-tokens') reason = stepEnd ?? reason
        } finally {
          this.session.append('step/end', { turn, step })
        }

        // 停之前给插件最后一次机会往 inbox 塞东西(goal / continuation 靠它)
        if (reason && this.inbox.nextStep.length === 0) {
          await this.ctx.bus.serial('agent/turn-stopping', { turn })
        }
        if (reason && this.inbox.nextStep.length === 0) break
        target = 'next-step'
      }
    } catch (error) {
      reason = { kind: 'error', error }
      throw error
    } finally {
      this.session.append('turn/end', { turn, reason: reason ?? { kind: 'completed' } })
    }
    return this.inbox.hasPending
  }
}

Four deliberate designs, explained one by one

① A zero-step turn still goes in the log.

if (stepNo === 0 && decision.messages.length === 0) { reason = { kind: 'completed' }; return false }

The user hit send, but some plugin emptied the messages. You still open a turn and close it normally. Why? Because “the user initiated an attempt” is itself a fact that must be recorded. architecture.md writes: “a rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt.”

If you do not, the UI gets a ghost story—“I clearly sent a message and nothing happened”—and the log has no trace.

② max-tokens is sticky.

In the real source this comment appears twice (agent.ts:285-289); the author clearly cares:

max-tokens is sticky: once any step hits the ceiling, later steps that complete normally must not downgrade the turn outcome.

Step 3 of a turn hits the output ceiling; step 4 finishes normally—the turn’s conclusion must be “hit the ceiling,” not “completed.” Downstream (UI hints, retry policy, eval) uses that to judge whether the result is trustworthy.

③ agent/turn-stopping is checked twice.

if (reason && this.inbox.nextStep.length === 0) { await serial('agent/turn-stopping', …) }
if (reason && this.inbox.nextStep.length === 0) break   // ← 再查一次

First decide “we are stopping,” then fire the serial event; after it fires, check the inbox again. Because an agent/turn-stopping listener is perfectly allowed to stuff new things into the inbox—that is exactly how the goal plugin keeps the loop going (step 18).

This is the standard answer to “how do you keep the loop going without changing the loop”: give the loop a public moment of “I am about to stop,” and anyone can object at that moment.

④ turn/end is in finally; reason is structured.

Whether it completes, is cancelled, or throws, turn/end always fires. And failure is structured (agent.ts:307-314): LlmError keeps its facts; everything else flattens to errorChain text + code: 'UNKNOWN'.

Never let a turn have no ending in the log. Otherwise replay logic must handle “half a turn,” which is a bottomless pit.

Where real dsh lives

packages/core/agent-loop/src/agent.ts (496 lines). The outer while (await this.turn()) {} is at :212, turn() at :246, step() at :332.

A side Easter egg: the source has comments like /* v8 ignore next -- private callers establish the running phase before executing a step */ (agent.ts:333). That is the fingerprint of a 100% per-file coverage gate—unreachable defensive branches must be explicitly annotated and given a reason. See step 20.


Part 12

Step 9 · inbox: two delivery ports decide what is owed

Why

Step 8’s loop keeps saying inbox.nextStep.length === 0. What is this inbox?

It solves a very concrete problem: input does not come only from the user. There are at least four sources:

  1. A message the user typed → should wake the loop immediately
  2. Extra context produced by tool execution (e.g. “the file was modified externally”) → should be seen at the next step
  3. Background-job completion notice → must not interrupt the current step, but should arrive soon
  4. Plugin-injected context (agent.inject()) → should wait until another message takes it in together

The fourth is the most subtle. This sentence in architecture.md names the distinction (:86):

Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does.

Injected context does not actively wake the loop—otherwise every injection of “the current branch is main” would trigger a model call, which would burn money and look mysterious.

Minimal implementation

// minidsh/inbox.ts
export type InboxTarget = 'next-turn' | 'next-step'

export class Inbox {
  /** 下一个 turn 开始时才领取的(普通用户消息)。 */
  private turnQueue: Message[] = []
  /** 当前 turn 的下一个 step 就要领取的(工具上下文、后台通知)。 */
  readonly nextStep: Message[] = []

  /** 唤醒型投递:进 turn 队列,并请求唤醒 driver。 */
  send(message: Message): void {
    this.turnQueue.push(message)
    this.onWake?.()
  }

  /** 注入型投递:进 turn 队列但不唤醒——等别的消息把它带进去。 */
  inject(message: Message): void {
    this.turnQueue.push(message)
  }

  /** step 内投递:当前 turn 的下一个 step 立刻可见。 */
  spliceNextStep(message: Message): void {
    this.nextStep.push(message)
  }

  /** 领取。两个投递口对应两种 target。 */
  claim(target: InboxTarget, _turn: number): Message[] {
    if (target === 'next-step') return this.nextStep.splice(0, this.nextStep.length)
    return this.turnQueue.splice(0, this.turnQueue.length)
  }

  get hasPending(): boolean {
    return this.turnQueue.length > 0 || this.nextStep.length > 0
  }

  onWake?: () => void
}

So the change of target in step 8’s loop now has meaning:

let target: InboxTarget = 'next-turn'   // turn 的第一个 step 领 turn 队列
// …一个 step 结束后…
target = 'next-step'                    // 后续 step 领 step 队列

How tools stuff things in

Real dsh’s executeToolCalls sixth argument is this delivery callback (agent.ts:395-398):

const { concluded } = await executeToolCalls(
  this.loopCtx, turn, step, toolCalls, signal,
  context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]),
)
return concluded ? { kind: 'completed' } : null

That is where the “add context” decision of tools/post-execute in the tool pipeline lands: after a tool runs it can say “also tell the model one thing”; that sentence becomes an inbox next-step item, injected after the recorded tool result.

concluded ? completed : null is also worth looking at: returning null means “the tools still owe one request,” so the outer while continues to the next step. Whether something is owed is reported by the tool batch itself, not guessed by the loop.

How to accept

The three delivery behaviors must be distinguishable in tests:

const inbox = new Inbox()
let woke = 0; inbox.onWake = () => { woke++ }

inbox.inject({ role: 'user', content: '当前分支 main' })
console.assert(woke === 0, 'inject 不该唤醒')
inbox.send({ role: 'user', content: '修 bug' })
console.assert(woke === 1, 'send 应该唤醒')
console.assert(inbox.claim('next-turn', 1).length === 2, '一起被领走')

inbox.spliceNextStep({ role: 'user', content: '文件被外部改了' })
console.assert(inbox.claim('next-step', 1).length === 1, 'step 口独立')

Where real dsh lives

Inbox is in packages/core/agent/src/ (exported by dsh-agent, imported at agent.ts:18).

The real implementation has one more layer I simplified away in minidsh: a wake latch (agent.ts:164-193). When the driver is in the maintenance phase or has already been aborted, it cannot deliver a wake, so it latches the wake and replays it at convergence; disposal never latches—“so teardown waits on no model turn.”

That kind of “teardown must not be held by business logic” is the watershed between a harness that has run in production for a long time and a demo.


Part 13

Step 10 · `agent/pre-step`: decide what the model sees

Why

The loop can now claim messages. But between “claimed messages” and “messages sent to the model” you need a gate, because:

  • Permission: this agent is in plan mode; it may not submit any change
  • Rewrite: expand the user’s relative path to an absolute path
  • Inject: stitch cwd, git branch, and the todo list into a slice of context
  • Intercept: this session was paused by an admin; reject outright

architecture.md’s positioning (:88):

agent/pre-step decides what the model sees. Listeners may rewrite the claimed messages or reject them outright.

Minimal implementation

// minidsh/loop.ts(续)
type PreStepDecision =
  | { kind: 'reject' }
  | { kind: 'enter'; messages: Message[] }

private async preStep(target: InboxTarget, turn: number, step: number) {
  const claimed = this.inbox.claim(target, turn)
  // 每个 step 都重新装配:工具集与提示段可能已经变了(见第 13 步)
  const assembly = await this.ctx.get<SystemPrompt>('systemPrompt').assemble()
  const context = this.runtimeContext.project(assembly.sections)

  const decision = await this.ctx.bus.waterfall<PreStepDecision>(
    'agent/pre-step',
    { messages: claimed, turn, step },
    async () => ({
      kind: 'enter',
      messages: context === undefined ? claimed : [...claimed, context],
    }),
  )
  return decision.kind === 'reject' ? decision : { ...decision, assembly }
}

Three points:

① The seed function is the default behavior. The third argument of waterfall is “what happens when no listener intervenes.” Default = claimed messages + a slice of runtime context. This writing makes “default” explicit, instead of hiding it in some ?? default.

dsh’s constitution has a dedicated line for this (AGENTS.md):

Explicit > implicit at package boundaries: defaulting is an explicit resolve(request): Spec step in the owning implementation, never a hidden ?? default inside run().

② Runtime context is “projected,” not string-concatenated into the system prompt. It is an extra message after claimed. That way it is log-visible (the invariant from step 7).

③ Reassemble every step. Not once per turn. Because the tool set may change mid-turn (a sub-agent came up, plan mode exited, a dynamic plugin mounted new tools).

How to accept

// 一个"计划模式"插件:拒绝一切写操作请求
ctx.bus.on('agent/pre-step', async (arg, next) => {
  if (planMode.active && arg.messages.some(m => /提交|commit/.test(m.content))) {
    return { kind: 'reject' }
  }
  return next()
})

Acceptance: after reject, the log should show a turn/start + turn/end{reason:'blocked'}, with no step/start in between.

Where real dsh lives

packages/core/agent-loop/src/agent.ts:225-243. The real version has two extras: signal.throwIfAborted() once before assemble and once after (cancel must take effect early), and assembly is carried all the way into step() (avoid assembling twice).


Part 14

Step 11 · Tool registry and three waterfalls

Why

Tool execution is the place in an agent that most needs to be pluggable. Too many things want to hang on this path: permission, approval, sandbox, timeout, retry, metrics, result rewriting, UI rendering, filesystem policy, hook bridging…

If you stuff them all into runTool(), that function becomes a thousand-line mudball.

dsh’s move is to split tool execution into three waterfalls + one monotonic guard + one frozen notification. The generated pipeline diagram (docs/tool-execution-pipeline.md) opens with one sentence of design intent:

This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering run without changing the loop.

The full pipeline

flowchart TD
    A["assistant message has a tool-call"] --> B["append tool/call
(log before execute)"] B --> C["UI: presentCall(args) pending card"] B --> D["tools/pre-execute (waterfall)
hooks · permission · sandbox"] D -->|allow| E["registered monotonic guards
deny or abstain only"] D -->|deny| X["skip the tool body"] D -->|ask| F["ctx.approval one-shot ask"] F -->|allowed-once| E F -->|reject / cancel / unavailable| X E -->|allow| G["tools/execute (waterfall · around)
timeout · retry · metrics"] E -->|deny| X G --> H["tool execute() body"] H --> I["fs/write-intent · fs/edit-intent gates"] H --> J["tool-owned events
todo/write · fs/observed · hook/*"] H --> G G --> K["tools/post-execute (waterfall)
accept · block · replace · add context"] X --> K K --> L["registry outer normalize
throw → isError"] L --> M["finalizeContent
last content-only invariant"] M --> N["tools/result (emit · frozen)
observe only, cannot change"] N --> O["append tool/result
single model-visible result"] O --> P["additionalContexts FIFO
→ inbox.next-step"]

Minimal implementation

// minidsh/tools.ts
type PreToolDecision = { kind: 'allow' } | { kind: 'deny'; reason: string } | { kind: 'ask' }
type PostToolDecision =
  | { kind: 'accept' } | { kind: 'block'; reason: string }
  | { kind: 'replace'; content: string } | { kind: 'context'; message: Message }

export class ToolRegistry {
  private tools = new Map<string, ToolDefinition>()
  /** 单调守卫:只能拒,不能放。见第 12 步。 */
  private guards: ((exec: ToolExecution) => 'deny' | undefined)[] = []

  register(def: ToolDefinition): Disposer {
    if (this.tools.has(def.name)) throw new Error(`tool "${def.name}" already registered`)
    this.tools.set(def.name, def)
    return () => this.tools.delete(def.name)
  }

  registerGuard(guard: (exec: ToolExecution) => 'deny' | undefined): Disposer {
    this.guards.push(guard)
    return () => { const i = this.guards.indexOf(guard); if (i >= 0) this.guards.splice(i, 1) }
  }

  /** 模型能看到的 schema 列表——直接进系统提示装配。 */
  schemas(): ToolSchema[] {
    return [...this.tools.values()].map(t => ({ name: t.name, description: t.description, parameters: t.parameters }))
  }

  async execute(exec: ToolExecution, session: Session, bus: EventBus): Promise<ToolResult> {
    session.append('tool/call', { callId: exec.callId, name: exec.name, args: exec.args })

    // ① 可扩展的前置决策
    let pre = await bus.waterfall<PreToolDecision>('tools/pre-execute', exec, async () => ({ kind: 'allow' }))
    if (pre.kind === 'ask') {
      pre = (await this.askApproval(exec)) ? { kind: 'allow' } : { kind: 'deny', reason: 'user rejected' }
    }

    // ② 单调守卫:在可扩展前置之后,谁也放宽不了
    if (pre.kind === 'allow') {
      for (const guard of this.guards) {
        if (guard(exec) === 'deny') { pre = { kind: 'deny', reason: 'guard denied' }; break }
      }
    }

    // ③ around:超时 / 重试 / 指标都是包在本体外面的
    let result: ToolResult
    if (pre.kind === 'deny') {
      result = { isError: true, content: `denied: ${pre.reason}` }
    } else {
      try {
        result = await bus.waterfall<ToolResult>('tools/execute', exec,
          () => this.tools.get(exec.name)!.execute(exec.args))
      } catch (error) {
        // 规范化:流水线里的任何抛出都变成模型可读的错误结果,而不是崩掉循环
        result = { isError: true, content: errorChain(error) }
      }
    }

    // ④ 后置:接受 / 拦 / 换内容 / 追加上下文
    const post = await bus.waterfall<PostToolDecision>('tools/post-execute',
      { exec, result }, async () => ({ kind: 'accept' }))
    if (post.kind === 'replace') result = { ...result, content: post.content }
    if (post.kind === 'block') result = { isError: true, content: post.reason }
    if (post.kind === 'context') this.inbox.spliceNextStep(post.message)

    // ⑤ 定稿:冻结后只广播,不再接受修改
    const frozen = Object.freeze({ ...result })
    bus.emit('tools/result', Object.freeze(exec), frozen)
    session.append('tool/result', { callId: exec.callId, content: frozen.content, isError: frozen.isError })
    return frozen
  }
}

Five details worth stealing

① tool/call is logged before execution. If the process dies during tool execution, the log still has “I once intended to call this tool.” You can look it up later.

② Throws are normalized into isError results, not a crashed loop. A tool author writing buggy code must not kill the whole agent; the model should see an error and decide what to do.

③ tools/result is emit + freeze. After the result is finalized it can only be observed. We already said this; emphasize it again: dsh even uses a runtime invariant to guard “must freeze before publication” (packages/core/tools/src/invariant.ts:23).

④ Event order is also guarded by invariants. In packages/core/tools/src/invariant.ts:94-115 you can see these assertions:

if (stages.has(exec)) fail('tools/pre-execute repeated for one execution')
if (stages.get(exec) !== 'pre') fail('tools/execute must follow tools/pre-execute')
if (…) fail('tools/post-execute must follow tools/pre-execute or tools/execute')

“The order of pipeline stages” itself is a runtime contract, not an implicit guarantee of code structure.

⑤ UI render intent is part of tool design. dsh’s constitution (AGENTS.md):

A tool's UI render intent is part of its design, decided up front (generic/terminal/diff, locations); presentation methods are pure functions of args.

A tool must say at definition time “I should be rendered as a generic card, terminal output, or a diff,” and the render function must be a pure function of args (so a pending card can be drawn before execution).

Where real dsh lives

packages/core/tools (13 743 lines, second-largest single package in the repo). @module in one sentence:

Tool registry, model presentation modes, and pre/guard/around/post/result execution pipeline.

All 47 model-visible tools go through this pipeline. The complete schema catalog is docs/tool-catalog.md (1 873 lines, generated by a script that boots each tool plugin and reads ctx.tools.schemas()—because a tool schema is not statically knowable: runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools).


Part 15

Step 12 · Monotonic guards: why later plugins cannot widen approval

Why

The pipeline in step 11 has a design that looks redundant: given a tools/pre-execute waterfall, why an extra “monotonic guard” layer?

Because waterfall has a fatal flexibility: a later-registered listener can override an earlier decision.

// 一个善意的插件
ctx.on('tools/pre-execute', async (exec, next) => {
  const decision = await next()
  if (decision.kind === 'deny' && isProbablySafe(exec)) {
    return { kind: 'allow' }     // ← 它"帮"你放宽了权限
  }
  return decision
})

This code can pass code review (it looks like it is doing a reasonable thing), but it turns the entire permission system into a prop. Any plugin that can register a listener can privilege-escalate.

dsh’s fix: after the extensible waterfall, run a layer of guards that can only deny, never allow.

The pipeline document (docs/tool-execution-pipeline.md):

Registered monotonic guards
deny or abstain; identity protected

“deny or abstain”—only two options: deny, or say nothing. There is no “allow” return value, so it is mathematically impossible to widen anything. That is what “monotonic” means: safety can only increase in one direction.

Minimal implementation

The key is that the type itself gives no widening:

// ✅ 守卫的返回类型里没有 'allow'
type GuardVerdict = 'deny' | undefined      // deny 或弃权,就这两种

registerGuard(guard: (exec: ToolExecution) => GuardVerdict): Disposer

// 执行时:任何一个 deny 就终局
for (const guard of this.guards) {
  if (guard(exec) === 'deny') { pre = { kind: 'deny', reason: 'guard denied' }; break }
}

Contrast what happens if you use waterfall:

waterfall monotonic guard
Can rewrite others’ decisions yes no
Order-sensitive yes (later wins) no (any deny is final)
A new plugin can accidentally escalate yes impossible
Fits policy collaboration, extra context safety boundary

A real case: read-before-write policy

dsh uses this mechanism for “no write before read” (docs/tool-catalog.md, the dsh-tool-fs row):

The read-before-write/edit policy is added by @deepseek-ai/dsh-fs-observation-policy (an fs/* event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it.

Note "no schema change": the policy hangs on events; the tool schema the model sees is unchanged. That means you can swap this policy across deployments while the model’s behavioral contract stays stable—the prompt cache will not invalidate.

How to accept

Follow step 7’s “violate first, then watch red”:

// 写一个想提权的恶意/善意插件
ctx.bus.on('tools/pre-execute', async (exec, next) => {
  const d = await next()
  return d.kind === 'deny' ? { kind: 'allow' } : d      // 试图放宽
})
registry.registerGuard(exec => exec.name === 'bash' && /rm -rf/.test(exec.args.command) ? 'deny' : undefined)

const r = await registry.execute({ name: 'bash', args: { command: 'rm -rf /' }, callId: '1' }, session, bus)
console.assert(r.isError, '守卫必须挡住,waterfall 的放宽不该生效')

Where real dsh lives

JSDoc at packages/core/tools/src/index.ts:704 and :1101:

A monotonic execution guard evaluated after every tools/pre-execute …
Register a monotonic guard after the extensible tools/pre-execute …

The phrasing “after the extensible” is precise: the extensible part runs first; the non-widenable part runs after. The order itself is the safety design.


Part 16

Step 13 · System prompt: reassemble every step

Why

In most projects the system prompt is a template string plus a few interpolations. In a harness it must be a registry, because there are too many contributors:

  • Core identity and rules
  • Tool schemas (schemas() from step 11)
  • Current working directory, git status
  • Skill list (one skill per line)
  • Todo list
  • Extra constraints of plan mode
  • Exclusive sections of a sub-agent
  • User-defined house rules

And these contributors come and go: a sub-agent comes up, one more section; plan mode exits, one fewer.

Minimal implementation

// minidsh/system-prompt.ts
interface Section { key: string; order: number; render: () => string | undefined }

export class SystemPrompt {
  private sections: Section[] = []
  private variables = new Map<string, () => string>()

  /** 注册一段。返回 disposer——所以段落可以随插件来去。 */
  section(key: string, order: number, render: () => string | undefined): Disposer {
    this.sections.push({ key, order, render })
    return () => { const i = this.sections.findIndex(s => s.key === key); if (i >= 0) this.sections.splice(i, 1) }
  }

  /** 每个 step 调一次:顺序固定,内容动态。 */
  async assemble(): Promise<PromptAssembly> {
    const rendered = this.sections
      .slice().sort((a, b) => a.order - b.order)   // 顺序由 order 决定,不由注册时机决定
      .map(s => ({ key: s.key, text: s.render() }))
      .filter((s): s is { key: string; text: string } => Boolean(s.text))
    return { sections: rendered, tools: this.ctx.get<ToolRegistry>('tools').schemas() }
  }
}

order rather than registration order is the key. Registration order depends on plugin load order, and plugin load order depends on the inject dependency graph—an implementation detail that must not leak into prompt layout. Explicit order decouples the two.

An easy-to-miss benefit: cache-friendly

Stable section order + stable content = prefix-cacheable. In this series Reasonix spent serious effort on “stable prefix + turn tail”; dsh gets half of that for free from “registry + order.”

Where real dsh lives

packages/core/system-prompt (1 582 lines), @module:

Registry for ordered system sections, dynamic context, tool schemas, and prompt variables.

Four kinds of contribution: ordered system sections / dynamic context / tool schemas / prompt variables. Depended on by 30 packages; inject in-degree second (20 times)—half the repo wants to stuff something into the prompt, which is exactly why it must be a registry.

The real assemble call site in the loop is agent.ts:230:

const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))

assembleContextFor(this, signal) passes in “which agent is asking” and the cancel signal together—assembly is per-agent (step 14’s scope explains why).


Part 4 · steps 14–17Capability: the three-role seam
Part 17

Step 14 · capability seam: Definition / Provider / Consumer

Why

At this point your harness can already run. Now you want to add “run a bash command.”

The most direct writing is a tool-bash that child_process.spawns. It works. Three months later you hit:

  • Want to run in a remote sandbox (E2B / container) → have to change tool-bash
  • Windows users want PowerShell → write another tool-pwsh, two copies of the logic
  • Want timeout/approval on commands → change tool-bash again
  • LSP and filesystem also need to live in the same remote environment → every tool has to change

dsh’s answer is to make each capability a three-role seam (docs/architecture.md:100):

A seam is a swappable capability with three roles: a Service Definition declaring the interface, a Service Provider implementing it, and a Consumer using it, commonly a model-facing tool. A package may combine roles, but one role alone is not a seam; adding a capability means designing all three.

The glossary is stricter (docs/glossary.md:9): a Service Definition must be a Cordis Service (abstract class or concrete registry), never a TypeScript interface.

What the three roles look like

Shell is the template dsh itself names:

flowchart LR
    subgraph def["① Service Definition: dsh-shell (476 lines)"]
        D["abstract class ShellExecutor
declare ctx.shell
+ vocab types ShellExecRequest/Spec/Process"] end subgraph prov["② Service Provider (four, same interface)"] P1["dsh-bash-local
local bash"] P2["dsh-bash-sandbox
sandboxed bash"] P3["dsh-pwsh-local
local PowerShell"] P4["dsh-pwsh-sandbox"] end subgraph cons["③ Consumer (model-visible tools)"] C1["dsh-tool-bash → bash"] C2["dsh-tool-pwsh → pwsh"] C3["dsh-tool-bash-persistent → bash (PTY)"] end D -.implements.- prov D -.injects.- cons cons --> M["the model sees only tool names and schemas"]

The division of labor is very clear:

Role Answers Does not answer
Definition what this capability is (interface + vocab) how it is implemented, who uses it
Provider how it is implemented (local / sandbox / remote) how the model sees it
Consumer how the model uses it (tool name, schema, render) whether underneath is local or remote

Minimal implementation

// ① Definition:只声明接口与词汇,不含任何实现
// minidsh/shell/definition.ts
export interface ShellExecRequest { command: string; cwd?: string; timeoutMs?: number }
export interface ShellExecSpec extends ShellExecRequest { cwd: string; timeoutMs: number }  // 默认值已解析
export interface ShellRunResult { stdout: string; stderr: string; exitCode: number }

export abstract class ShellExecutor {
  /** 显式的默认值解析步骤——不是藏在 run() 里的 ?? 默认。 */
  abstract resolve(request: ShellExecRequest): ShellExecSpec
  abstract run(spec: ShellExecSpec): Promise<ShellRunResult>
}

export const definition: Plugin = (ctx) => {
  // Definition 包本身只贡献词汇与设置命名空间,不 provide 实现
  ctx.provide('shellSettingsNamespace', 'shell')
}
// ② Provider:实现它
// minidsh/shell/bash-local.ts
class BashLocal extends ShellExecutor {
  resolve(request: ShellExecRequest): ShellExecSpec {
    return { ...request, cwd: request.cwd ?? process.cwd(), timeoutMs: request.timeoutMs ?? 120_000 }
  }
  async run(spec: ShellExecSpec): Promise<ShellRunResult> {
    const { execFile } = await import('node:child_process')
    return new Promise(resolve => {
      execFile('bash', ['-lc', spec.command], { cwd: spec.cwd, timeout: spec.timeoutMs },
        (err, stdout, stderr) => resolve({ stdout, stderr, exitCode: err ? 1 : 0 }))
    })
  }
}
export const bashLocal: Plugin = (ctx) => ctx.effect(() => ctx.provide('shell', new BashLocal()))
// ③ Consumer:把它变成模型可见的工具
// minidsh/shell/tool-bash.ts
export const toolBash: Plugin = (ctx) => {
  const shell = ctx.get<ShellExecutor>('shell')          // 只知道键,不知道实现
  return ctx.get<ToolRegistry>('tools').register({
    name: 'bash',
    description: 'Run a bash command.',
    parameters: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] },
    render: 'terminal',                                   // UI 渲染意图(第 11 步)
    execute: async (args) => {
      const spec = shell.resolve({ command: args.command })   // 先显式解析
      const r = await shell.run(spec)                          // 再执行
      return { content: r.stdout + r.stderr, isError: r.exitCode !== 0 }
    },
  })
}
toolBash.inject = ['tools', 'shell']

Why that resolve(request): Spec split matters

dsh treats the shell request/spec split as a constitution-level template (AGENTS.md):

Explicit > implicit at package boundaries: defaulting is an explicit resolve(request): Spec step in the owning implementation, never a hidden ?? default inside run()(the dsh-shell request/spec split is the template).

Why so fussy? Consider a concrete scene: what is the default timeout?

  • Hidden ?? 120_000 inside run(): the caller cannot know what was actually used; it is not in the log; it silently changes after a provider swap; tests cannot assert it.
  • Explicit resolve(): Spec is a complete object with no optionals. It can be logged, asserted, and shown to the user.

That is the meaning of splitting ShellExecRequest (fields optional) and ShellExecSpec (fields complete) into two types—“unresolved” and “resolved” are different at the type level; you cannot pass something unfinished into run().

Where real dsh lives

In my census the shell group is 9 packages / 10 405 lines:

Package Role Note
shell Definition ctx.shell, 476 lines, interface and vocab only
bash-local / bash-sandbox Provider local / sandbox
pwsh-local / pwsh-sandbox Provider Windows combination
shell-env support ctx.shellEnv, hosts DSH_* env
tool-bash / tool-pwsh Consumer model-visible bash / pwsh
tool-bash-persistent Consumer PTY version via ctx.terminals

The Definition JSDoc has a sentence that shows “sense of boundary” (packages/shell/shell/src/index.ts:1-5):

Service Definition for the ctx.shell capability seam, covering foreground commands and background process handles. Job ids, ownership, polling, and notices belong to @deepseek-ai/dsh-jobs, keeping executors independent of sessions.

An executor should not know sessions exist. Background job ids, ownership, polling, and notices all belong to ctx.jobs. So the same executor can serve chat, an automation protocol, and a sub-agent.


Part 18

Step 15 · Swap one provider, move the whole execution world

Why

After step 14 the seam’s payoff is still only “bash can swap implementations.” The real leverage is the next layer: several seams share one execution world.

This sentence in architecture.md is the strongest selling point of the whole architecture (:102):

Seams are why one provider swap changes the whole product. Filesystem and subprocess providers share one execution world, so pointing them at a remote sandbox moves Bash, PTY, and LSP with them, with no provider forks.

Translation: point ctx.fs and ctx.subprocess at a remote sandbox, and bash, persistent terminals, and language services all move to remote automatically—because they are all built on those two primitives, rather than each spawning its own process.

Dependency direction decides whether this works

flowchart TB
    subgraph prim["two primitive seams"]
        FS["ctx.fs
read/write files"] SP["ctx.subprocess
start processes"] end subgraph built["capabilities built on the primitives"] SH["ctx.shell → bash / pwsh"] TM["ctx.terminals → persistent PTY"] LSP["ctx.lsp → language services"] SEARCH["glob / grep (packaged ripgrep)"] CR["ctx.codeRuntime → code mode"] end FS --> built SP --> built subgraph swap["swap provider = swap the whole world"] L["local: node:fs + child_process"] E["remote: dsh-e2b (sandbox FS/subprocess adapters)"] end L -.pick one.-> prim E -.pick one.-> prim

The key of this picture is arrow direction: no upper capability directly imports node:child_process. They all inject ctx.subprocess.

I verified this in the census—subprocess is injected by 8 packages (in-degree 7th), and the directory description of the glob/grep search tools says explicitly (docs/tool-catalog.md):

glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (@vscode/ripgrep) through ctx.subprocess as ordinary foreground calls (never background jobs) — no host rg install and no shell layer.

Note “no shell layer”: search does not go through the shell; it goes straight through subprocess. So search does not depend on “does this machine have bash,” and it works consistently in a remote sandbox, on Windows, and in a slim container.

Minimal implementation: one remote provider

// minidsh/e2b/remote-subprocess.ts
class RemoteSubprocess extends SubprocessRunner {
  constructor(private client: SandboxClient) { super() }
  async spawn(spec: SpawnSpec): Promise<ProcessHandle> {
    return this.client.exec(spec.argv, { cwd: spec.cwd, env: spec.env })   // 走 HTTP 到沙箱
  }
}
class RemoteFs extends FileSystem { /* 同理:把读写代理到沙箱 */ }

export const remoteWorld: Plugin = (ctx) => {
  const client = new SandboxClient(ctx.get<Settings>('settings').get('e2b.apiKey'))
  const d1 = ctx.provide('subprocess', new RemoteSubprocess(client))
  const d2 = ctx.provide('fs', new RemoteFs(client))
  return () => { d1(); d2() }
}
remoteWorld.inject = ['settings']

Mount this one plugin (replacing the two local ones) and your bash / PTY / LSP / search all run in the remote sandbox, and those four packages did not change a single line.

How to accept

This claim is worth a real test that nails it down:

// 同一个工具调用,在两个世界里跑,断言它落在正确的世界
for (const world of [localWorld, remoteWorld]) {
  const ctx = await bootHarness([world, toolBash, toolFsSearch, lspStdio])
  const r = await callTool(ctx, 'bash', { command: 'hostname' })
  console.assert(r.content.trim() === expectedHostname[world.name], `${world.name} 应落在对应执行世界`)
}

Where real dsh lives

packages/e2b (3 packages / 6 630 lines) is the empirical proof of this claim. AGENTS.md positions it as:

e2b/ E2B POC: sandbox + FS/subprocess adapters

A POC of 6 630 lines proves the architectural promise—swap two providers, five capabilities move with them.

Related execution-world package sizes (my census): fs 7 packages 13 044 lines, shell 9 packages 10 405 lines, terminal 3 packages 5 721 lines, lsp 3 packages 5 496 lines, subprocess 2 packages 4 256 lines, code-runtime 2 packages 3 963 lines. These six groups are 26 packages, 42 885 lines, all built on two primitive seams.


Part 19

Step 16 · Sandbox: wrap argv

Why

Tools can now run arbitrary commands. Now you must constrain them.

Most projects check inside the tool: regex blacklists, path whitelists. The problem is those checks live in the same process; if the model bypasses the tool (e.g. uses bash to run a script that does the forbidden thing), they fail.

Real isolation must be provided by the OS. dsh’s move is to make the sandbox a seam and let consumers wrap argv before spawn (docs/architecture.md extension-point table):

Confine spawned processes | use a ctx.sandbox backend; consumers wrap argv before spawning

Minimal implementation

The sandbox does not execute commands; it only rewrites argv:

// minidsh/sandbox/definition.ts
export abstract class Sandbox {
  /** 把原始 argv 包成"受限执行"的 argv。 */
  abstract wrap(argv: string[], policy: SandboxPolicy): string[]
  /** 如实上报自己真正提供的隔离强度——不许谎报。 */
  abstract readonly level: 'system' | 'application' | 'off'
}

// Linux Landlock 后端
class LandlockSandbox extends Sandbox {
  readonly level = 'system' as const
  wrap(argv: string[], policy: SandboxPolicy): string[] {
    return [
      'landlock-run',
      ...policy.readPaths.flatMap(p => ['--ro', p]),
      ...policy.writePaths.flatMap(p => ['--rw', p]),
      ...policy.allowNetwork ? [] : ['--no-net'],
      '--', ...argv,
    ]
  }
}

On the consumer side, one extra line:

// bash-sandbox provider
async run(spec: ShellExecSpec): Promise<ShellRunResult> {
  const raw = ['bash', '-lc', spec.command]
  const argv = this.sandbox.wrap(raw, this.policy.for(spec))   // ← 就这一行
  return this.subprocess.spawn({ argv, cwd: spec.cwd })
}

Three design points

① The sandbox does not execute; it only wraps. That lets it compose with any provider: local bash, remote subprocess, LSP child process—anyone can wrap before spawn. If the sandbox itself executed, it would fight the executor for the same job.

② Isolation strength is a queryable value, not an assumption. The level field lets the upper layer authorize by grade. In this series DeepTutor uses the same trick (SYSTEM-level open to everyone, APPLICATION-level admin-only). That is much more honest than “pretend every deployment is equally safe.”

③ Partial failure must be classified correctly. dsh has a dedicated postmortem: docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.md—Landlock’s “partially applied” notice once misclassified the child process’s own failures.

That postmortem is worth reading for anyone who writes a sandbox: when your sandbox can only partially apply (old kernels do not support some rules), the “partially applied” notice you emit must not also count the child process’s own failure, or users will think it is a sandbox problem and turn the sandbox off.

Where real dsh lives

  • packages/sandbox (4 packages / 9 040 lines): Definition + policy + backends
  • native/landlock-run: source home of @deepseek-ai/node-addon-landlock-run, packaged per platform (-linux-x64 / -linux-arm64)
  • pnpm-workspace.yaml has a comment: "The Landlock launcher is developed with its harness consumers but keeps its native build and publication scripts under native/landlock-run."

ctx.sandboxPolicy is an independent service key (in my census sandbox and sandboxPolicy are two keys)—“capability” and “policy” are separate, so policy can be swapped per deployment without swapping the sandbox implementation.


Part 20

Step 17 · Context economics: compaction and spill

Why

Two different overflow problems need two different mechanisms.

Problem A: the conversation is too long. After 20 rounds history exceeds the window. Compact.

Problem B: a single tool output is too large. grep matched 30 000 lines; cat of a 5MB file. That one item can blow the window.

Many projects only do A (compact history), so when B happens the whole session dies. Or only B (truncate output), so the model never sees the truncated part.

dsh makes both into seams: ctx.compaction and ctx.spillStore.

spill: persist oversized output, give the model a retrievable locator

The Definition JSDoc of packages/spill is clear:

Service Definition for the spill storage capability seam (ctx.spillStore): an abstract service defining WHAT a spill backend does — persist a tool's oversized text and return a model-facing locator plus retrieval guidance.

Three words are the key: persist + locator + retrieval guidance.

Not simple truncation: store the full content, give the model a message “I saved the complete result here; you can read it this way.”

// minidsh/spill.ts
export abstract class SpillStore {
  abstract save(text: string, meta: { tool: string; callId: string }): Promise<SpillLocator>
}

/** 一个 post-execute 插件:超过阈值就溢出 */
export const spillPolicy: Plugin = (ctx) => {
  const store = ctx.get<SpillStore>('spillStore')
  return ctx.bus.on('tools/post-execute', async ({ exec, result }, next) => {
    if (result.content.length <= 20_000) return next()
    const locator = await store.save(result.content, { tool: exec.name, callId: exec.callId })
    return {
      kind: 'replace',
      content:
        `${result.content.slice(0, 2_000)}\n\n` +
        `[输出过长,完整 ${result.content.length} 字符已保存]\n` +
        `完整内容:${locator.path}(可用 read 工具按行区间读取)`,
    }
  })
}
spillPolicy.inject = ['spillStore']

Real dsh’s glob/grep use it this way (docs/tool-catalog.md):

Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.

"follow-up-readable/searchable locators"—spilled content is not a grave; it can still be processed by read and grep.

compaction: compact history, but the result must return to the log

The hard part of compaction is not “how to summarize,” but “does the compaction result count as model-visible.”

By step 7’s constitution: yes. So the compaction product must become a session event, not a variable in memory.

// 压缩后 append 一个事件,deriveMessages 据此改变投影
session.append('compaction/applied', {
  replacedRange: [firstSeq, lastSeq],
  summary: summaryText,
})

// deriveMessages 遇到它就跳过被替换的区间,改用摘要
deriveMessages() {
  const compactions = this.events.filter(e => e.type === 'compaction/applied')
  // …被覆盖区间的原始消息不再投影,改投影摘要…
}

Then: compaction is auditable (you can see which range was compacted and what the summary is), replayable (replaying the log yields the same model input), reversible (delete that event and the original is restored).

Where real dsh lives

  • packages/compaction (4 packages / 8 032 lines): Definition + basic provider
  • packages/spill (3 packages / 1 473 lines): Definition + backend + policy
  • docs/subsystems/compaction.md / spill.md are the respective subsystem docs
  • There is also a ctx.toolResultPruner and ctx.tokenMeter (independent service keys in my census)—result pruning and token metering are each their own seam

Four independent service keys (compaction / spillStore / toolResultPruner / tokenMeter) cover four sides of the same problem. This is the finest split of “context economics” in this series.


Part 5 · steps 18–20Product: assembly and discipline
Part 21

Step 18 · Keep the loop going: `agent/turn-stopping` and goal

Why

Step 8 buried a hook: before a turn stops, fire a serial event, then check the inbox again. Use it now.

Scene: the user says “run the tests until they are all green.” The model edits one version, runs tests, still has 3 failures, then says “I fixed some of it” and stops.

You want it to continue. What do you do?

  • ❌ Change the loop: add a “if the goal is not reached, continue” check → violates “Plugins, not loop changes”
  • ❌ Say in the prompt “do not stop midway” → the model does not obey, and there is no objective criterion
  • ✅ Mount an agent/turn-stopping plugin: check goal state; if not reached, stuff a message into the inbox

Minimal implementation

// minidsh/goal.ts
export const goalContinuation: Plugin = (ctx) => {
  const goals = ctx.get<GoalService>('goals')
  return ctx.bus.on('agent/turn-stopping', async ({ turn }) => {
    const goal = goals.active()
    if (!goal || goal.status !== 'open') return
    if (goal.round >= goal.maxRounds) return          // 有界:不能无限续
    goals.bumpRound()
    ctx.inbox.spliceNextStep({
      role: 'user',
      content: `目标未完成:${goal.objective}\n当前第 ${goal.round}/${goal.maxRounds} 轮,请继续。`,
    })
  })
}
goalContinuation.inject = ['goals']

The loop side does not change a single line—it already wrote “announce → re-check inbox” in step 8.

dsh’s goal is stricter: permission grades + bounds

Look at the deployment notes for dsh-tool-goal in docs/tool-catalog.md:

create_goal, get_goal, update_goal … create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds.

Three designs worth stealing:

① Creating / editing / pausing / resuming a goal requires “direct-human root authority.” The model cannot assign itself a goal, nor quietly rewrite one. Otherwise “run the tests green” gets rewritten into “comment the tests out.”

② complete and blocked must carry “the exact current round.” This is compare-and-set: the model says “I completed the goal of round 5”; if it is already round 6, the claim is invalid. It prevents stale declarations.

③ blocked has a lower bound: at least 3 admitted rounds. The model cannot say “I am stuck” on round one. It must actually have tried three rounds.

The @module of packages/goal in one sentence:

Same-session goal domain: event-sourced state, compare-and-set mutations, and process-local continuation activation.

event-sourced + compare-and-set — goal state is also derived from the log; mutations are optimistic-concurrency.

Against other projects in this series

Project Mechanism against “the model claims it is done”
MiMo Code Goal as an independent referee + four deadlock gates
grok-build Goal Mode five-piece set
Reasonix Delivery Profile evidence sign-off + readiness gate
DeepTutor Mastery gate / plan-done gate sunk into tools
DeepSeek Harness agent/turn-stopping + event-sourced goal + CAS + human root authority + bounded rounds

The common sentence: whatever can be computed, do not let the model self-certify. dsh’s distinctive move is to make this one event + one plugin; the loop itself knows nothing about “goals.”

Where real dsh lives

packages/goal (4 packages / 5 786 lines, ctx.goals). One row in the extension-point table (docs/architecture.md):

Manage a same-session objective | use ctx.goals; continue through agent/*


Part 22

Step 19 · bundle and profile: make assembly a patchable layer

Why

You now have 40 plugins. The user wants a Web UI: 35 of them. A one-shot headless task: 22. An automation-protocol service: 25.

How do you express “which plugins + what config”?

  • ❌ Three main.ts files → behavior drift; change one, forget two
  • ❌ One giant if/else → cannot be extended by a third party
  • ✅ Config is assembly, and it is layered and patchable

dsh’s two concepts

docs/architecture.md:19-21:

A profile is a named composition stored in the Harness home. It lists the bundles it stacks, holds any out-of-tree plugins it installs, and keeps the user's own cordis.patch.yml.
A bundle is a distribution format for Cordis config rows and the code they mount, so whatever it inserts stays patchable by the layers above it.

Both self-describe in their package.json via a dsh field: dsh.profile lists the profile’s bundles; dsh.bundle points at the bundle’s patch file.

Layer order

flowchart LR
    E["empty entry list"] --> B1["bundle 1 (profile-listed order)"]
    B1 --> B2["bundle 2"]
    B2 --> B3["bundle N"]
    B3 --> PP["profile's cordis.patch.yml"]
    PP --> HP["home-level cordis.patch.yml"]
    HP --> CLI["--patch CLI overlay"]
    CLI --> FINAL["final plugin tree"]

The rule (docs/architecture.md:27):

Layers apply to an empty entry list in this order: each bundle in the profile's listed order, then the profile's cordis.patch.yml, then the home-level one, then any --patch overlay. A patch targets a row by id and replaces its whole config, or inserts new rows.

"replaces its whole config" rather than deep-merge. That is a deliberate choice: deep-merge semantics under nested config are extremely hard to predict (how do arrays merge? does null mean delete or set empty?). Whole-row replace is verbose, but you always know what the final value is.

Minimal implementation

// minidsh/boot.ts
interface ConfigRow { id: string; plugin: string; config?: Record<string, unknown>; disabled?: boolean }

/** 分层:按序应用每一层的 patch,靠 id 替换整行 config 或插入新行。 */
export function composeTree(bundles: ConfigRow[][], patches: ConfigRow[][]): ConfigRow[] {
  let rows: ConfigRow[] = []
  for (const bundle of bundles) rows = rows.concat(bundle)
  for (const patch of patches) {
    for (const row of patch) {
      const i = rows.findIndex(r => r.id === row.id)
      if (i >= 0) rows[i] = { ...rows[i], config: row.config, disabled: row.disabled }  // 整体替换
      else rows.push(row)                                                                // 插入新行
    }
  }
  return rows.filter(r => !r.disabled)
}

/** 把行变成插件树。 */
export async function boot(rows: ConfigRow[]): Promise<Context> {
  const ctx = new Context()
  for (const row of rows) {
    const mod = await import(row.plugin)
    const plug: Plugin = mod.apply ?? mod.default
    if ('default' in mod && !mod.apply) {
      // 见第 2 步那个 postmortem:default 导出会丢掉 inject
      throw new Error(`plugin "${row.plugin}" must use named exports (apply/inject), not default`)
    }
    await ctx.plugin(withConfig(plug, row.config))
  }
  ctx.assertSettled()
  return ctx
}

A debug exit you must have

dsh provides dsh --profile web --dump-config, and the docs explicitly promise (:29-35):

To see the tree your machine actually boots … Any row it prints can be replaced by a patch of your own.

"Any row it prints can be replaced by a patch of your own" — that sentence is the acceptance criterion of configurability. If your framework cannot “print the actual assembly + every row is overridable,” its configurability is fake.

Add it to minidsh:

if (process.argv.includes('--dump-config')) {
  console.log(JSON.stringify(composeTree(bundles, patches), null, 2))
  process.exit(0)
}

Where real dsh lives

  • packages/bundle (3 packages): dsh-base (first layer of every profile: model adapters, tools, persistence, sandbox and approval policy, settings, credentials, telemetry), dsh-web-app, dsh-headless
  • packages/boot (2 packages / 4 081 lines): the composition mechanism
  • packages/preset (2 packages / 3 794 lines): per-session assembly—ctx.agentPresets, so one session can own a different capability set; its service rows need an isolate realm
  • examples/ are runnable cordis.yml leaves

Fun: the @module JSDoc of the dsh-base package (caught in my census):

package's substance is cordis.patch.yml, declared by the dsh.bundle.patch manifest field and resolved by the profile composer through that field; this module carries no runtime API.

A 117-line package with no runtime API, whose entire substance is a YAML file. That is the literal meaning of “configuration is architecture.”


Part 23

Step 20 · Write discipline as gates: 100% coverage + `verify-*`

Why

The first 19 steps laid down many rules: waterfall must call next(), registration must be reversible, model-visible must be logged, a seam must have all three roles, defaults must be resolved explicitly…

A rule written only in a document = it will be violated eventually. Especially when collaborators are AIs—they read 20 pages of docs, then on the 21st change they forget the rule on page 3.

dsh’s attitude: every mechanically checkable invariant is wired into an executed top-level gate. The constitution (AGENTS.md):

Wire mechanically checkable invariants into an executed top-level gate and prove each changed acceptance path rejects an invalid case.

Six test layers

docs/testing.md:

Layer Command Key point
Unit pnpm run test Every registry needs an HMR-safety test (dispose the contributing fiber, assert cleanup)
Coverage gate pnpm run test:coverage packages/*/*/src 100% per file
Real API e2e pnpm run test:e2e Hit a real model with a key; skip itself when no key
Snapshot pnpm run test:snapshot keyless: replay recorded sessions, diff normalized JSON-RPC + re-persisted logs
Browser snapshot pnpm run test:web Chromium compares replay output (required on Linux PRs)
Gate collection pnpm run doc-sync / hygiene / duplication see below

Per-file 100% coverage I want to talk about separately, because dsh’s reading of it is completely different from most teams:

An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped.

"An uncovered line is often dead code the gate is correctly flagging for deletion" — that is treating coverage as a tool for deleting code, not as a KPI for writing tests.

That also explains the comments we saw in step 8:

/* v8 ignore next -- private callers establish the running phase before executing a step */
if (this.phase.kind !== 'running') throw new Error(...)

Unreachable defensive branches cannot be silently skipped; they must be explicitly exempted + given a reason. And scripts/coverage-exempt.ts + coverage-exempt.spec.ts in turn guard those exemptions themselves.

Three testing rules I think are most worth stealing

① Verify the world, not the self-report (docs/testing.md:29):

An e2e assertion re-runs the command or re-reads the file externally; a keyword probe on the agent's own output lets a cheating agent pass. Assert untouched files are byte-identical.

“A keyword probe on the agent's own output lets a cheating agent pass”—that sentence should be carved at the top of every agent project’s test file.

② A guard must be proven effective (:34):

A guard only guards if the regression actually fails it. … add an explicit assertion, and prove it: introduce the regression, watch red, revert.

③ Test the real entry path (:35):

"Real entry path" means the published artifact: a package bin runs built lib/bin.js under plain node, exposing failures tsx masks (settle races, module resolution, swallowed load failures).

Running source under tsx masks three classes of real failure. So the critical path must be tested once against the published artifact.

28 verify-*: weld the docs down too

scripts/ has 124 .ts files, 28 of them verify-*. I pick a few to show what they turn into machine-checkable:

Gate What it guards
verify-export-jsdoc Every export has JSDoc; function-like exports must have @param/@returns
verify-cordis-config Bare plugins in cordis.yml must appear in the resolver manifest’s dependencies
verify-package-invariants Every package has an ./invariant companion; an empty installer must explain starting with No runtime invariant:
verify-type-equiv ts type-equiv code blocks in docs must be equivalent to source types
verify-mermaid Diagrams must render
verify-md-links / verify-doc-refs Links and refs must not rot
verify-md-wrap markdown wrap discipline (one physical line per paragraph)
verify-doc-budgets document word budgets (to exceed, change the cap in the manifest)
verify-package-readme-limitations every package README must write “limitations” clearly
verify-package-readme-model-experience must write “model experience” clearly
verify-agent-note-classification / -format classification and format of design notes
verify-translation-pairing / -prompt bilingual docs paired paragraph by paragraph
verify-runtime-closure runtime dependency closure
verify-dsh-package-licenses licenses

The last two, verify-package-readme-limitations / -model-experience, I have not seen a second shop in this series do: they force every package README to have a “limitations” section and a “model experience” section. The former prevents overselling; the latter requires the author to describe their package from the model’s point of view.

And—these gate scripts themselves have .spec.ts (coverage-exempt.spec.ts, run-gates.spec.ts, gen-doc-graphs.spec.ts…). Guards must be guarded.

Generated, not hand-written, docs

8 177 lines in docs/ are generated: config-catalog.md (3 151), tool-catalog.md (1 873), module-graph.md (1 638), persistence-catalog.md (944), capability-seams.md (471)… all produced by scripts/gen-*.ts and freshness-checked by doc-sync.

The generation of tool-catalog.md is especially careful (file-header comment):

Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads ctx.tools.schemas(), because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs packages/*/tool-* and fails if any package is missing from the generator's boot manifest, so a new tool cannot be silently undocumented.

"a new tool cannot be silently undocumented" — that turns “doc completeness” into a compile-time problem.

How to land this in your own project

You do not need 124 scripts. Steal these four first; they have the highest ROI:

  1. One HMR test per registry (dispose, then assert cleanup)—blocks a whole class of leaks and ghost registrations.
  2. e2e assertions must re-read the world from outside—blocks “the agent claimed it was done.”
  3. A new guard must first be watched going red—blocks fake guards.
  4. One verify- script for the invariant you care about most, wired into CI.

Part 6Close: replay · acceptance · crash list
Part 24

🎬 Full replay: what “fix a bug for me” actually ran

String the 20 steps together. The user says:

Fix the bug in src/parser.ts that treats the empty string as valid input, then run the tests.

sequenceDiagram
    participant U as user
    participant IN as inbox
    participant D as loop (plugin)
    participant SP as systemPrompt
    participant L as log
    participant M as ctx.llm
    participant T as ctx.tools
    participant G as guards/sandbox
    participant FS as ctx.fs / ctx.shell

    U->>IN: send(message) → wake
    D->>L: turn/start {turn:1}
    D->>IN: claim('next-turn')
    D->>SP: assemble() (sections by order + 47 tool schemas)
    D->>D: agent/pre-step waterfall → enter
    D->>L: step/start {1,1} + user/message
    D->>L: deriveMessages()
    D->>D: agent/request waterfall → {provider, model, maxTokens}
    D->>M: stream()
    M-->>L: assistant/chunk ×N
    D->>L: assistant/message (sourceEventSeqs point back at those chunks)
    Note over D: has tool_call: read(src/parser.ts)
    D->>T: execute
    T->>L: tool/call (log before execute)
    T->>G: tools/pre-execute → allow
    T->>G: monotonic guard → abstain
    T->>FS: read
    T->>L: fs/observed (read happened; write policy lets it through)
    T->>L: tool/result (frozen)
    D->>L: step/end {1,1}
    Note over D,IN: tools still owe one request → target='next-step'
    D->>L: step/start {1,2}
    D->>M: stream() (history already has file contents)
    Note over D: tool_call: edit(...) + bash(pnpm test)
    T->>G: pre-execute → ask (writes need approval)
    G-->>U: approval: allow once
    T->>FS: edit → fs/edit-intent gate → write
    T->>G: bash goes through sandbox: argv wrapped by landlock-run
    FS-->>T: test output 120k characters
    T->>T: tools/post-execute → over threshold → spill
    T->>L: tool/result (first 2000 chars + locator)
    D->>L: step/end {1,2}
    D->>M: stream() (step 3: look at test results)
    Note over D: no tool_call → completed
    D->>D: agent/turn-stopping (serial)
    Note over D: goal plugin checks: goal reached, do not continue
    D->>L: turn/end {turn:1, reason:{kind:'completed'}}

On this trip, not one line of loop code was written for “approval,” “sandbox,” “spill,” or “goal.” They are respectively: a tools/pre-execute listener, an argv wrapper, a tools/post-execute listener, an agent/turn-stopping listener.

That is the entire meaning of harness-shaped architecture: new behavior grows on extension points, not in the loop.


Part 25

Appendix A · Acceptance assertions (how to verify each step)

Step After you finish, this assertion must pass
1 Duplicate provide throws; get of an unregistered key throws
2 Out-of-order mount auto-sorts by dependency; a typo in inject makes assertSettled() report that plugin
3 Two effects unwind in reverse of registration order; calling a disposer twice does not explode
4 emit has no return, waterfall can rewrite, parallel waits for all, serial is ordered
5 When an observe-style listener forgets next(), downstream policy really fails (write a test that nails this consequence)
6 Log has 4 events, deriveMessages() has 2; assistant/message can obtain its chunk seqs
7 Hand-push a message onto the request → invariant throws InvariantError
8 Empty enter: log has turn/start + turn/end, no step/start; max-tokens is not downgraded by a later step
9 inject does not wake, send wakes; the two delivery ports do not interfere
10 After reject, turn/end.reason.kind === 'blocked'
11 A tool throw becomes an isError result, not a crashed loop; tools/result args are frozen
12 When a pre-execute listener tries to widen, the guard still blocks
13 Two sections registered out of order emit by order
14 Spec produced by resolve() has no optional fields; run() has no ?? default
15 The same bash tool lands in the correct execution world under local/remote providers
16 After sandbox wrap(), argv’s first element is the launcher; level honestly reports isolation strength
17 Over-threshold output is replaced with “summary + locator,” and the full content can be read back from the locator
18 When the goal is not reached the turn auto-continues; after it is reached it does not; at the round cap it stops
19 Every row --dump-config prints can be replaced by a patch targeting its id
20 The coverage gate can point at a dead-code stretch you left on purpose; a new guard goes red then green

Part 26

Appendix B · Fourteen easiest ways to crash

  1. A waterfall listener forgets next(). No throw; everything downstream silently dies. Write a lint or a test that “observers must delegate.”
  2. export default swallows inject. dsh postmortem 0001 is this. Use named exports, and assert 'default' in mod === false in tests.
  3. Disposer is not idempotent. Teardown calls it twice and it explodes—the hardest path to reproduce.
  4. Teardown order written as registration order. C depends on B’s state, but B is torn down first.
  5. Bypass the log to add model context. The feature works; replay and audit silently distort. Put an invariant on it.
  6. A zero-step turn is not logged. The UI gets a ghost story: “I sent a message and nothing happened.”
  7. max-tokens is downgraded by a later step. Downstream judges trustworthiness from it; downgrade = lying.
  8. turn/end is not in finally. The exception path leaves half a turn; replay logic becomes a bottomless pit.
  9. Injected messages also wake the loop. Every injection burns a model call.
  10. Use waterfall as a safety boundary. Any plugin can escalate. Safety uses monotonic guards.
  11. Defaults hidden in run()’s ??. Cannot log, cannot assert, silently change after a provider swap.
  12. Upper capabilities directly import child_process. The execution world can never move; a remote sandbox becomes a fantasy.
  13. Oversized output is truncated in place. The model never sees the cut part and cannot ask to look again. Use spill: persist + locator + retrieval guidance.
  14. Treat coverage as a KPI instead of a deletion tool. Write tests for unreachable branches; the code swells. For an uncovered line, first ask “is this line dead.”

Part 27

Appendix C · Source index

You want to understand… Read real dsh here
Five ideas docs/cordis-primer.md (44 lines, 5 minutes)
Architecture panorama docs/architecture.md (129 lines, required reading)
Hard-rule list AGENTS.md (root; also the symlink target of CLAUDE.md)
turn/step loop packages/core/agent-loop/src/agent.ts:212 (outer) :246 (turn) :332 (step)
Agent interface and inbox packages/core/agent/
Session log packages/core/session/ (deriveMessages, SessionEventMap)
Tool pipeline packages/core/tools/src/index.ts; diagram in docs/tool-execution-pipeline.md
Pipeline-order invariants packages/core/tools/src/invariant.ts:94-115
System-prompt assembly packages/core/system-prompt/
per-agent scope packages/core/scope/
Seam template (three roles) packages/shell/ (definition / 4 providers / 3 consumers)
One execution world packages/fs/ + packages/subprocess/; empirical proof in packages/e2b/
Sandbox packages/sandbox/ + native/landlock-run/
Context economics packages/compaction/ packages/spill/
Goal continuation packages/goal/
Assembly layers packages/boot/ packages/bundle/ packages/preset/
Invariant regime docs/subsystems/invariants.md + packages/runtime-diagnostics/invariants/
Testing constitution docs/testing.md (49 lines, all substance)
Gate scripts scripts/verify-*.ts (28), scripts/run-gates.ts
Design-decision archaeology .agents/notes/{proposed,implemented,archived,rejected}/ (686 notes)
Incident postmortems docs/postmortem/0001-0004
Generated catalogs docs/config-catalog.md tool-catalog.md module-graph.md capability-seams.md

Local reproduction

git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
git rev-parse --short HEAD        # 本教程基线 47f9438

pnpm install                      # node ^22.19 || >=24
pnpm run build
pnpm dsh web                      # http://127.0.0.1:3080

# 看你的机器真正启动了什么(第 19 步)
pnpm dsh --profile web --dump-config

# 一次性无头任务(需要 DEEPSEEK_API_KEY)
pnpm dsh --profile headless "列出仓库里最大的三个包"

# 让 agent 修改自己的运行时(第 19 步的极端形态)
pnpm run demo:cordis

Part 28

Close: who this route is for

This is for you if:

  • you are building an agent product that must live a long time, and you have already been tortured by “every feature means changing the main loop”;
  • you need several front ends at once (CLI / Web / automation protocol / SDK) and cannot stand behavior drift;
  • you want third parties to write extensions instead of forking;
  • you will hand the agent to a team (or an AI) for long-term maintenance and need spoken conventions turned into machine gates.

This is not for you if:

  • you need to validate an idea in a week. A 20-step foundation is pure burden for a demo; step 0’s main() is more appropriate.
  • your product shape is already fixed and will not grow a plugin ecosystem. Then this layer of indirection is just a tax.
  • your team has no will to maintain gates. A gate that is not run is dead code, and dead code is worse than no gate—it gives a false sense of safety.

A middle route (I recommend most people start here):

Steal only the first 8 steps + the four testing rules of step 20. That is: service repository + inject + reversible registration + four dispatch modes + append-only log + two-level turn/step loop + “verify the world, not the self-report.”

That combination is about 400 lines and blocks the most expensive classes of rework in agent projects: lost context, permissions bypassed, teardown leaks, and “green tests, broken product.” Seam / sandbox / spill / bundle can wait until you actually hit that problem—and this foundation guarantees that when you do, you will not have to change the loop.


Companion reading: DeepSeek Harness source analysis—full roster of 241 packages, collaboration graph, 73 service keys, 47 tools, and the engineering regime behind 686 design-decision notes. Same-series contrast: Open Design (does not write the main loop; treats someone else’s CLI as the engine) · Reasonix (the same company’s other road: single binary + cache-first) · Claude Code (the classic shape of a tool loop)

↑