Review discussion converged on the industry shape (Claude Code caches user context per conversation; Codex separates initial context from diffs; Kimi appends at continuation boundaries to protect prompt caching): stable openers belong in a compose-once prefix, mid-session changes belong in append-only history — not in a per-request slot. agent/session-prefix fires ONCE per loop instance, lazily on its first request-building step: the composed Message[] is deep-frozen, cached on the transmission bookkeeping, recorded as EpochHeader.messagePrefix on the anchoring 'initial'/'resume' snapshot, and reused verbatim for every request the instance sends — prefix stability is structural, not a producer discipline, and a resume recomposes with attributable drift. The request is messagePrefix + boundary snapshot. The per-step RequestAdvice/RequestAdviceContext surface and the messageSuffix header field are dropped: the tail slot had no consumer, and every current update pattern (new AGENTS.md discovered, memory update, skills change) routes through the existing append-only history channels — inject(), tools/post-execute additionalContext, prompt-submit additionalContext — each paid once and prefix-cached thereafter. The messagePrefix delta arm stays for codec totality; the loop never produces one in practice.
23 KiB
Cordis Events Catalog
Every cordis event a plugin can listen to: exact signature, dispatch mode, and the declaration's JSDoc. This is one axis of the wiring reference a plugin author works against — the callable ctx.<key> surface is the sibling services catalog, and core-data-structures/ catalogs the data structures these signatures move around.
This file is GENERATED from source (scripts/gen-cordis-catalog.ts) and verified fresh by pnpm run verify-cordis-catalog (part of doc-sync) — do not edit it by hand. Signature blocks use a ts cordis-catalog fence (skipped by doc-typecheck, since a bare signature is not standalone-compilable). Type names in a signature link to the page that documents them.
The harness tier below (the @deepseek-ai/dsh-* packages) is the vocabulary this repo owns, grouped by scope. The inherited tier at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely.
Dispatch modes: emit (fire-and-forget), waterfall (each listener gets next() and may transform or veto — see waterfall semantics), parallel (awaited fan-out; all listeners run), serial (awaited in registration order until one returns a bail value — anything other than null, false, or undefined).
agent/*
agent/created — emit
An agent was registered in the AgentRegistry and is ready to receive messages.
'agent/created'(agent: Agent): void
Types: Agent
Source: packages/core/agent/src/types.ts:265
agent/disposed — emit
An agent was disposed and removed from the registry; its fiber and any in-flight turn have been torn down.
'agent/disposed'(agent: Agent): void
Types: Agent
Source: packages/core/agent/src/types.ts:272
agent/error — emit
A step or turn errored. The loop reports a failure here (plus the logger) even when the error has no in-turn position for a session error event.
'agent/error'(agent: Agent, turn: number, step: number, error: Error): void
Types: Agent
Source: packages/core/agent/src/types.ts:455
agent/pre-step — serial
Awaited pre-step surface-mutation checkpoint, fired once per step AFTER turn/start (and after the prior step closed) but BEFORE this step's step/start — so anything a listener appends lands OUTSIDE the step, between turn/start/step/end and the upcoming step/start. step is the number of the step about to start. The loop awaits ctx.serial('agent/pre-step', …) after assembling the system prompt, then opens the step and derives the request history ONCE from whatever the surface now holds. This is where compaction belongs: it mutates the session surface in place (shadowing an older range with a summary node) with its log-only compact/* records cleanly outside any step, and the single subsequent derive reflects the mutation — so there is no double-derive and no listener can see (or be expected to act on) an assembled messages array that does not exist yet.
Serial (awaited in registration order), not a waterfall: a listener mutates the surface as a side effect; there is nothing to transform, but the loop must wait for the mutation to complete before opening the step and deriving. Cordis serial bails early if a listener returns a bail value; this event is typed and documented as void, so listeners must not return a semantic veto value. fullSystemPrompt is the assembled prompt a listener needs to measure pressure (the system prompt counts toward the budget). signal cancels any in-flight work a listener starts (e.g. a summarization model call).
'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise<void> | void
Types: Agent
Source: packages/core/agent/src/types.ts:350
agent/prompt-submit — waterfall
Waterfall: decide what happens to ONE drained queued message before it becomes a user/message — allow (optionally rewriting the prompt bytes or attaching additionalContext) or block it. Fires inside the already-open turn, per drained message. Maps onto Claude Code's UserPromptSubmit hook. Call next() to delegate to the default (allow unchanged), or return a PromptDecision without calling next() to short-circuit.
'agent/prompt-submit'(agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>
Types: Agent · ContentBlock · MessageSource
Source: packages/core/agent/src/types.ts:363
agent/queued — emit
A message entered the agent's inbox (queued or steering). source is the resolved source (defaults applied), not the caller's raw options.
'agent/queued'(agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
Types: Agent · ContentBlock · MessageSource
Source: packages/core/agent/src/types.ts:290
agent/request — waterfall
Waterfall: shape the step's call configuration — model switching, sampling overrides — by returning a replacement LlmCallConfig (the frozen seed is the config the loop would otherwise use). Config is ALL a listener shapes here: every request is a pure function of the session log (the reconstructability RFC), so model-visible content flows through the log channels — inject(), steering, prompt-submit additionalContext, prompt sections via system-prompt/assemble, or the header-logged session prefix via agent/session-prefix — never through request mutation, and the loop records whatever config the request actually uses as a request/header* event before dispatch. The step's messages are already snapshotted when this fires (the step/start boundary): an inject() from a listener here lands in the log but joins the NEXT request. For surface mutation that must precede the snapshot (compaction), use agent/pre-step. Call next() to delegate, or return an LlmCallConfig without it to short-circuit.
'agent/request'(agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
Types: Agent · LlmCallConfig
Source: packages/core/agent/src/types.ts:387
agent/session-prefix — waterfall
Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the ENTIRE derived history (directly after the provider's system slot) on every request this loop instance sends. Fired ONCE per loop instance, lazily on its first request-building step; the composed result is deep-frozen, recorded as EpochHeader.messagePrefix on the instance's anchoring 'initial'/'resume' header snapshot, and reused verbatim for every subsequent request — never recomputed mid-session, so the provider prefix cache holds by construction (a process restart or ctx.agents.resume() is a new instance: it recomposes, and any drift lands attributably on the 'resume' snapshot).
This is the home for session-stable openers the model must always see but that must NOT become durable history — a skills catalog, an AGENTS.md digest, a workspace baseline: Session.deriveMessages() never returns the prefix, and the header events are its only durable record, so the request stays reconstructable from the log. Content that CHANGES mid-session belongs in the append-only history channels instead — agent.inject(), a tools/post-execute decision's additionalContext, prompt-submit additionalContext — each a durable context/message paid once and prefix-cached thereafter.
The seed is a frozen empty list; a contributing listener returns a NEW array extending await next() ([...prefix, mine] — never an in-place push), so contributions compose across plugins in registration order and compose deterministically for a fixed plugin set. Call next() to delegate, or return a list without it to short-circuit.
'agent/session-prefix'(agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>
Source: packages/core/agent/src/types.ts:420
agent/session-start — emit
The agent's session lifecycle began, fired once before its first turn. source says why (SessionStartSource: fresh startup, a resumed persisted session, …). A pure NOTIFICATION (emit, not waterfall): it carries no veto — a session-start listener that wants to seed context does so via agent.inject() (a context/message the first request sees), not by returning a decision. Cannot block the session from starting; that gap is deliberate (a bridge logs/injects, it does not gate startup).
'agent/session-start'(agent: Agent, source: SessionStartSource): void
Types: Agent
Source: packages/core/agent/src/types.ts:305
agent/status — emit
Agent status changed (idle ⇄ running, or → disposed). Drive lifecycle off this transition, never off a status you just requested — send() does not flip status to running before it returns.
'agent/status'(agent: Agent, status: AgentStatus): void
Types: Agent
Source: packages/core/agent/src/types.ts:281
agent/step-result — waterfall
Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …).
'agent/step-result'(agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message>
Source: packages/core/agent/src/types.ts:430
agent/turn-continuation — waterfall
Waterfall: override the turn-continuation decision via a typed ContinuationDecision. The loop's defaultDecision is continue when the step had tool calls or steering was injected, else stop. Listeners force-continue (/goal, /loop — optionally attaching a reason recorded as next-step steering) or force-stop (budget guards). Call next() to delegate to the default, or return a decision to override.
'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
Types: Agent
Source: packages/core/agent/src/types.ts:443
fs/*
fs/edit-intent — waterfall
Single-slot decision: produce the optional version guard for the next FileSystem.editText. The tool dispatches this as an unbound waterfall and supplies a default thunk returning undefined (unconditional edit of the current content — the bare provider; no stat). The @deepseek-ai/dsh-fs-policy policy listener returns { version: vObserved }, or throws FS_NOT_OBSERVED if the actor is unset or has not observed the target. Does NOT call next(): one decision, first-wins (see Events.'fs/write-intent').
'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
Source: packages/fs/fs/src/index.ts:123
fs/observed — emit
Record that an actor observed a target at a version, after a successful read/write/edit. Fire-and-forget (plain emit). A listener MUST be a synchronous, side-effect-only recorder (@deepseek-ai/dsh-fs-policy's is a WeakMap.set): the tool does not guard the emit, so a listener that throws surfaces as the tool's isError result, and cordis emit does not await listener promises — async or fallible audit/telemetry does not belong here. No listener ⇒ nothing recorded. actor is the opaque tool-execution context.
'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void
Source: packages/fs/fs/src/index.ts:138
fs/write-intent — waterfall
Single-slot decision: produce the write intent for the next FileSystem.writeText. The tool dispatches this as an unbound waterfall (no this) and supplies a default thunk returning undefined (unconditional create-or-overwrite — the bare provider). The @deepseek-ai/dsh-fs-policy policy listener returns createIfAbsent (unobserved actor) or { kind: 'replaceIfVersion', version: vObserved } (observed) and does NOT call next() — one decision, not a composable chain. The slot is first-wins: the first non-next() decider (registration order, or prepend) occupies it; a second decider is a misconfiguration, not layering. actor is the opaque tool-execution context, never read here.
'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>
Types: FsTarget · FsWriteIntent
Source: packages/fs/fs/src/index.ts:109
llm/*
llm/stream — waterfall
Waterfall around every streaming model call (retry, replay, routing). Bound to the LlmService; call next() to reach the resolved adapter's stream, or yield your own chunks to short-circuit.
'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>
Types: GenerateOptions · StreamChunk
Source: packages/llm/llm/src/index.ts:39
session/*
session/created — emit
A session was created in the store.
'session/created'(session: Session): void
Source: packages/core/session/src/index.ts:39
session/event — emit
An event was appended to a session log (sync, fire-and-forget). This is the per-append feed a UI or invariant plugin tails.
'session/event'(session: Session, event: SessionEvent): void
Types: SessionEvent
Source: packages/core/session/src/index.ts:47
session/flush — parallel
Awaited durability checkpoint. The agent loop awaits ctx.parallel('session/flush', session) at every turn end; persistence plugins (JSONL, SQLite) drain their write-behind buffers here and on fiber dispose. Awaited (parallel), not a waterfall: every listener runs and the loop waits for all of them, but none can veto.
'session/flush'(session: Session): Promise<void> | void
Source: packages/core/session/src/index.ts:57
subagent/*
subagent/end — emit
A subagent run settled — emitted when SubagentRun.result resolves (any stop reason). Paired with Events['subagent/start'].
'subagent/end'(info: SubagentRunEndInfo): void
Source: packages/subagent/subagent/src/index.ts:98
subagent/provider-added — emit
A provider became resolvable in the SubagentService registry. Consumers that derive state from a named provider (e.g. the model-facing tool wording in dsh-tool-subagent) react HERE instead of assuming load order — the cordis Loader starts sibling plugins concurrently, so "listed earlier in cordis.yml" does not mean "registered earlier".
'subagent/provider-added'(provider: SubagentProvider): void
Source: packages/subagent/subagent/src/index.ts:72
subagent/provider-removed — emit
A provider left the registry (its plugin's fiber was disposed — an unload or an HMR reload). Consumers holding provider-derived state drop it here; a reload re-fires subagent/provider-added with the fresh provider. Delivered with per-listener containment: a throwing subscriber is logged, never starves later subscribers, and never disrupts the provider's teardown.
'subagent/provider-removed'(name: string): void
Source: packages/subagent/subagent/src/index.ts:83
subagent/start — emit
A subagent run started — emitted after the provider is resolved and its capabilities validated, as the child run begins. Paired with Events['subagent/end'].
'subagent/start'(info: SubagentRunInfo): void
Source: packages/subagent/subagent/src/index.ts:91
system-prompt/*
system-prompt/assemble — waterfall
Waterfall around prompt assembly — mutate or extend the PromptAssembly (sections + tools + variables) before it is rendered. Bound to the SystemPrompt service; call next() to delegate.
'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
Source: packages/core/system-prompt/src/index.ts:38
system-prompt/change — emit
A section, tool provider, or variable provider was registered or unregistered (the assembly inputs changed).
'system-prompt/change'(): void
Source: packages/core/system-prompt/src/index.ts:44
tools/*
tools/change — emit
A tool was registered or unregistered (the available tool set changed).
'tools/change'(): void
Source: packages/core/tools/src/index.ts:97
tools/post-execute — waterfall
Waterfall AFTER a tool runs — where hook plugins inspect the result and accept it (optionally REPLACING the model-facing content, and/or attaching additionalContext for the next request) or block it with corrective feedback (Claude Code's PostToolUse). Listeners receive (exec, result, next): call next() to delegate to the default (accept unchanged), or return a PostToolDecision to override. The core tool dispatch sits between the two waterfalls as plain code, all inside execute's outer try/catch (and the tool body keeps its own inner try/catch, so a thrown tool still reaches post-execute as an isError result).
'tools/post-execute'(this: ToolRegistry, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
Types: ToolExecution · ToolExecutionResult
Source: packages/core/tools/src/index.ts:92
tools/pre-execute — waterfall
Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code's PreToolUse). Listeners receive (exec, next): call next() to delegate to the default (allow), or return a PreToolDecision without calling next() to short-circuit. A deny skips dispatch and yields an isError result; the tool body never runs. Input rewrite is deliberately NOT offered here (see PreToolDecision); ask degrades to deny until the permission system lands (FIXME(permissions)).
'tools/pre-execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
Types: ToolExecution
Source: packages/core/tools/src/index.ts:76
Inherited events (cordis core + loader/hmr/timer)
The framework events every plugin also sees, beyond the harness vocabulary above. This is pinned vendor source (vendoring policy); it is summarized here so the page is a complete picture of the event bus, without elevating framework internals to the harness tier's prominence.
internal/plugin— A plugin fiber was created. (vendor/cordis/src/events.ts:197)internal/status— A fiber changed lifecycle state. (vendor/cordis/src/events.ts:198)internal/service— Interception hook for a service binding (no core producer). (vendor/cordis/src/events.ts:199)internal/update— Waterfall: a fiber config update is being applied. (vendor/cordis/src/events.ts:200)internal/get— Waterfall: a service is being read from the store. (vendor/cordis/src/events.ts:201)internal/set— Waterfall: a service is being written to the store. (vendor/cordis/src/events.ts:202)internal/listener— A listener was registered. (vendor/cordis/src/events.ts:203)internal/dispatch— An event is being dispatched to listeners. (vendor/cordis/src/events.ts:204)hmr/change— A watched source file changed on disk. (vendor/hmr/src/index.ts:20)hmr/reload— Plugins are being reloaded after a change. (vendor/hmr/src/index.ts:21)exit— The process is exiting on a signal. (vendor/loader/src/index.ts:23)loader/config-update— The loader config tree changed. (vendor/loader/src/index.ts:24)loader/entry-init— A config entry is being initialized. (vendor/loader/src/index.ts:25)loader/partial-dispose— An entry is being partially disposed on reload. (vendor/loader/src/index.ts:26)loader/patch-context— A context is being patched during a reload. (vendor/loader/src/index.ts:27)