Files
deepseek-harness/docs/cordis-tutorial/04-events.md
T
imccyu ec601ca13d build(vendor): rescope the vendored Cordis packages into @deepseek-ai
Machine-produced by `pnpm run rescope-vendor --apply` plus the regeneration it
prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`,
`verify-translation-pairing --write` for the touched bilingual pairs,
`gen-doc-graphs`, and one typert snapshot whose ids embed character offsets.
`pnpm run rescope-vendor --check` verifies the result.

Renames nine vendored packages (cordis, cosmokit, schemastery and the six
@cordisjs plugins) and every reference that resolves them: manifest names and
dependency keys, module specifiers including declare-module merges, cordis.yml
plugin names, tsconfig paths, every Markdown fence, and `docs/` prose.
Directory names, upstream versions, and dependency ranges are unchanged, so
vendor/README.md still reads as an upstream snapshot; its manifest table gains
an upstream-name column so THIRD_PARTY_NOTICES keeps MIT attribution pointed
at each fork's origin.

The tutorial tier follows the rename end to end: its yaml fences named plugins
the Loader can no longer resolve, its `ts ignore-check` fences disagreed with
the compiled fences beside them, and its prose quoted both. The contracts that
told readers to keep upstream names — the root convention and the vendoring
cookbook's tree comment and manifest invariant — now say to rescope instead.

Two rules read `@deepseek-ai/` as "another workspace plugin": the client bundle
purity gate now names the vendored libraries a browser bundle inlines, and the
files where a bare `cordis` is an agent-preset id keep that product data.
2026-08-10 22:04:13 +08:00

5.4 KiB

4. Events

English | 中文

Services support direct calls; events let a plugin announce something without knowing which plugins listen. The harness uses events for interactions such as tool results, model requests, and approval decisions.

Declare, emit, listen

Create stats.ts in tmp/cordis-tutorial — a service that counts things and announces each change:

import { Service, type Context } from '@deepseek-ai/cordis'

declare module '@deepseek-ai/cordis' {
  interface Context {
    stats: StatsService
  }
  interface Events {
    'stats/report'(name: string, count: number): void
  }
}

export class StatsService extends Service {
  private counts = new Map<string, number>()

  constructor(ctx: Context) {
    super(ctx, 'stats')
  }

  bump(name: string) {
    const next = (this.counts.get(name) ?? 0) + 1
    this.counts.set(name, next)
    this.ctx.emit('stats/report', name, next)
  }
}

export const name = 'stats'

export function apply(ctx: Context) {
  ctx.plugin(StatsService)
}

The interface Events merge is the event-system twin of the interface Context merge from chapter 3: it declares the event name and its listener signature, so ctx.emit and ctx.on are fully typed. The namespace/action naming convention keeps the flat event namespace readable.

Create reporter.ts:

import type { Context } from '@deepseek-ai/cordis'
import type {} from './stats.ts'

export const name = 'reporter'
export const inject = ['stats']

export function apply(ctx: Context) {
  ctx.on('stats/report', (name, count) => {
    console.log(`[stats] ${name} -> ${count}`)
  })
  ctx.stats.bump('tool_call')
  ctx.stats.bump('tool_call')
  ctx.stats.bump('prompt')
}

The import type {} from './stats.ts' line imports nothing at runtime; it exists so TypeScript sees the declaration merges. Compose and run:

- name: './stats.ts'
- name: './reporter.ts'
[stats] tool_call -> 1
[stats] tool_call -> 2
[stats] prompt -> 1

Because ctx.on() is an effect, the listener disappears with the plugin — no manual removeListener bookkeeping, ever.

Dispatch modes

emit is one of five dispatch modes. Which one an event uses is part of its contract — it decides whether listeners can return values, run concurrently, or short-circuit each other:

Mode Call Semantics
emit ctx.emit(name, ...args) Synchronous broadcast; returned promises and values are not awaited or collected.
parallel await ctx.parallel(name, ...args) All listeners run concurrently; awaited together.
serial await ctx.serial(name, ...args) Listeners run in order, awaited; the first non-null/false/undefined return wins and stops the rest.
bail ctx.bail(name, ...args) Synchronous version of serial.
waterfall ctx.waterfall(name, ...args, next) Around-middleware; see below.

Every harness event documents its mode in the generated reference on its owning subsystem page.

Waterfall: transform or short-circuit

Waterfall is the mode that powers interception. Each listener receives the arguments plus a next() continuation; it can transform what next() returns, or return without calling next() and short-circuit the rest of the chain — what the Cordis docs call the veto. Create waterfall-demo.ts:

import type { Context } from '@deepseek-ai/cordis'

declare module '@deepseek-ai/cordis' {
  interface Events {
    'demo/transform'(input: string, next: () => Promise<string>): Promise<string>
  }
}

export const name = 'waterfall-demo'

export function apply(ctx: Context) {
  // Listener 1: wrap the downstream result.
  ctx.on('demo/transform', async (input, next) => {
    const downstream = await next()
    return downstream.toUpperCase()
  })

  // Listener 2: short-circuit when it owns the decision.
  ctx.on('demo/transform', async (input, next) => {
    if (input.includes('blocked')) return '** blocked **'
    return next()
  })

  void (async () => {
    console.log(await ctx.waterfall('demo/transform', 'hello', async () => 'hello'))
    console.log(await ctx.waterfall('demo/transform', 'blocked words', async () => 'blocked words'))
  })()
}

Point cordis.yml at just this file and run:

HELLO
** BLOCKED **

Walk through the second line: listener 1 runs first, calls next(), which invokes listener 2; listener 2 sees blocked and returns without calling next() — the innermost default (the function passed to ctx.waterfall) never runs — and listener 1 uppercases the replacement message on the way out.

The discipline that follows: a waterfall listener that only observes or annotates must call next(); returning without it is a deliberate short-circuit. Forgetting next() in a logging listener silently swallows the default behavior for everyone downstream. It is a standing rule of this repository (waterfall semantics).

The harness uses waterfalls for decisions that cooperating plugins may wrap or answer: agent/request lets a plugin replace the model-call config, and approval/request lets a policy answer instead of the user.

Next: Configuration — plugin options from cordis.yml.