2026-07-22 16:36:48 +08:00
# 4. Events
2026-07-26 02:33:29 +08:00
English | [中文 ](04-events.zh.md )
2026-07-22 16:36:48 +08:00
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:
```ts
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:06 +08:00
import { Service, type Context } from '@deepseek -ai/cordis'
2026-07-22 16:36:48 +08:00
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:06 +08:00
declare module '@deepseek -ai/cordis' {
2026-07-22 16:36:48 +08:00
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` :
```ts ignore-check
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:06 +08:00
import type { Context } from '@deepseek -ai/cordis'
2026-07-22 16:36:48 +08:00
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:
```yaml
- 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
2026-07-22 17:44:31 +08:00
`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:
2026-07-22 16:36:48 +08:00
| 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. |
2026-07-30 21:40:58 +08:00
Every harness event documents its mode in the generated reference on its owning [subsystem page ](../subsystems/core.md ).
2026-07-22 16:36:48 +08:00
2026-07-22 17:44:31 +08:00
## Waterfall: transform or short-circuit
2026-07-22 16:36:48 +08:00
2026-07-22 17:44:31 +08:00
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` :
2026-07-22 16:36:48 +08:00
```ts
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:06 +08:00
import type { Context } from '@deepseek -ai/cordis'
2026-07-22 16:36:48 +08:00
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:06 +08:00
declare module '@deepseek -ai/cordis' {
2026-07-22 16:36:48 +08:00
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()
})
2026-07-22 17:44:31 +08:00
// Listener 2: short-circuit when it owns the decision.
2026-07-22 16:36:48 +08:00
ctx.on('demo/transform', async (input, next) => {
2026-07-22 17:44:31 +08:00
if (input.includes('blocked')) return '** blocked ** '
2026-07-22 16:36:48 +08:00
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
2026-07-22 17:44:31 +08:00
** BLOCKED **
2026-07-22 16:36:48 +08:00
```
2026-07-22 17:44:31 +08:00
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.
2026-07-22 16:36:48 +08:00
docs: purge chain-of-thought leakage from prose
Delete design-session citations (decision/audit/plan ordinals, stack
positions), change narration, review choreography, and reviewer-addressed
justification from comments, JSDoc, docs, READMEs, Agent Notes, tests, and
generator templates; restate every affected fact as current-state contract
prose. Fix generated docs at their sources and regenerate the catalogs and
cordis-surface regions; re-paste type-equiv blocks; update every bilingual
counterpart and re-record the pairs. Record the citation rule in the
committed-artifact-citations Agent Note.
2026-08-09 15:09:19 +08:00
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 ](../cordis-primer.md#cordis-waterfall-semantics )).
2026-07-22 16:36:48 +08:00
2026-07-30 21:40:58 +08:00
The harness uses waterfalls for decisions that cooperating plugins may wrap or answer: [`agent/request` ](../subsystems/core.md#agentrequest--waterfall ) lets a plugin replace the model-call config, and [`approval/request` ](../subsystems/approval.md#approvalrequest--waterfall ) lets a policy answer instead of the user.
2026-07-22 16:36:48 +08:00
Next: [Configuration ](05-config.md ) — plugin options from `cordis.yml` .
2026-07-22 17:07:15 +08:00
2026-08-10 21:36:20 +08:00
[](https://github.com/deepseek-ai/deepseek-harness)