Generator (all four structural gaps):
- harness service pages now render public properties/accessors, not just
methods (ctx.codeRuntime.language/isolation were missing);
- the class page merges the same-named interface half, so ctx.root/baseUrl/
events/logger/reflect/registry appear on Context (vendor root JSDoc gains
prose alongside @experimental);
- Pick<…> heritage on a Context merge resolves to the picked class members,
giving ctx.effect a documented signature on the Fiber page;
- {@link} tags normalize to code spans; merge sections get their own h2 so
reflect members no longer nest under 'Static members'.
verify-website-yaml: reject the unloadable 'group:' pseudo-name (tree.import
only special-cases 'cordis:'; no builtin is registered here) and recurse into
@cordisjs/plugin-group nested entry lists instead.
Prose corrected against loader/cordis source: service.md isolation example
uses the real group plugin + group: true + the required isolate map;
config.md documents concurrent entry startup (Promise.all; order via inject)
and the real hmr defaults (root ['.'], base/ignored/debounce); events.md
fixes emit (synchronous, not parallel), bail (null/false also delegate), and
serial (stops at the first bail value).
28 KiB
Harness events
Every event the harness packages declare on the cordis event bus (35 total), grouped by scope. The mode is the dispatch semantics (emit fire-and-forget, parallel awaited, serial first-bail, waterfall veto-chain — a waterfall listener MUST call next() to delegate).
agent/*
agent/created
Mode: emit
'agent/created'(agent: Agent): void
An agent was registered in the AgentRegistry and is ready to receive messages.
agent— the newly registered agent, already resolvable in the registry.
agent/disposed
Mode: emit
'agent/disposed'(agent: Agent): void
An agent was disposed and removed from the registry; its fiber and any in-flight turn have been torn down.
agent— the agent that was torn down; its handle is now inert.
agent/error
Mode: emit
'agent/error'(agent: Agent, turn: number, step: number, error: Error): void
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— the agent whose turn errored.turn— the turn in which the failure surfaced.step— the step at which the failure surfaced.error— the failure, verbatim.
agent/pre-step
Mode: serial
'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise<void> | void
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), and sessionPrefix is the instance's composed agent/session-prefix product for the same reason — every request carries it in front of the derived history, and it is composed BEFORE this seam fires precisely so a pressure gate counts the prefix the request will actually send (never a stale logged one). signal cancels any in-flight work a listener starts (e.g. a summarization model call).
agent— the agent about to open the step.turn— the already-open turn this step belongs to.step— the number of the step about to start.fullSystemPrompt— the assembled prompt, for measuring token pressure.sessionPrefix— the instance's frozen session prefix, for the same measurement.signal— aborts in-flight listener work when the turn is torn down.
agent/prompt-submit
Mode: waterfall
'agent/prompt-submit'(agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>
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— the agent draining its inbox.content— the drained message's blocks, as queued.source— the message's resolved source.
agent/queued
Mode: emit
'agent/queued'(agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
A message entered the agent's inbox (queued or steering). source is the resolved source (defaults applied), not the caller's raw options.
agent— the agent whose inbox received the message.content— the enqueued content blocks, verbatim.info— the resolved source plus whether it entered as steering.
agent/request
Mode: waterfall
'agent/request'(agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
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— the agent making the model call.turn— the open turn number.step— the step whose request this is.config— the config the loop would use (frozen); return a replacement to switch.
agent/session-prefix
Mode: waterfall
'agent/session-prefix'(agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>
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 before its first step's agent/pre-step seam — BEFORE the pre-step so a token-pressure gate (compaction) counts the prefix this instance will actually send, never a previous instance's logged one. 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). Composition runs outside the step, before the boundary snapshot: a composing listener's session append joins the CURRENT request's derived history. A composition interrupted by a cancel/dispose landing inside the waterfall is discarded — never cached, logged, or sent — and the next turn recomposes under a live signal, so an abort-aware listener's degraded fallback cannot leak into later requests.
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 — never an in-place push. The canonical contribution is a PREPEND, [mine, ...await next()]: the waterfall unwinds innermost-first (the LAST-registered listener's next() resolves first), so prepending yields registration order on the wire, and every plugin using it composes deterministically. The append form [...await next(), mine] is legal but places a contribution AFTER every later-registered plugin's — reverse registration order when all contributors append. Call next() to delegate, or return a list without it to short-circuit.
agent— the agent whose session prefix is being composed.prefix— the frozen empty seed; return an extended replacement to contribute.signal— aborts in-flight listener work (e.g. a discovery scan) when the step is torn down.
agent/session-start
Mode: emit
'agent/session-start'(agent: Agent, source: SessionStartSource): void
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— the agent whose session lifecycle began.source— why the session started (fresh startup, resume, …).
agent/status
Mode: emit
'agent/status'(agent: Agent, status: AgentStatus): void
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— the agent whose status flipped.status— the status just entered (the transition's destination).
agent/step-result
Mode: waterfall
'agent/step-result'(agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message>
Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …).
agent— the agent that received the step's response.turn— the open turn number.step— the step that produced the message.message— the assistant message as assembled from the stream.
agent/turn-continuation
Mode: waterfall
'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
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— the agent deciding whether to run another step.turn— the turn being continued or stopped.defaultDecision— what the loop would do absent an override.
fs/*
fs/edit-intent
Mode: waterfall
'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
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').
target— the resolved target about to be edited.actor— the opaque tool-execution context the decider keys off.
fs/observed
Mode: emit
'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void
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.
target— the target that was read/written/edited.version— the version the actor now holds as its observation.actor— the observing tool-execution context; undefined records nothing useful.
fs/write-intent
Mode: waterfall
'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>
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.
target— the resolved target about to be written.actor— the opaque tool-execution context the decider keys off.
llm/*
llm/stream
Mode: waterfall
'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>
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.
options— the full request. A LOOP-built request arrives deep-frozen (mutation throws): its content is a pure function of the session log (the reconstructability RFC), so listeners read it, never rewrite it. A hand-built one-shot (compaction summarize) is the caller's own object and stays mutable here.
session/*
session/created
Mode: emit
'session/created'(session: Session): void
A session was created in the store.
session— the session just entered and announced.
session/event
Mode: emit
'session/event'(session: Session, event: SessionEvent): void
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— the session whose log grew.event— the appended event, exactly as recorded.
session/flush
Mode: parallel
'session/flush'(session: Session): Promise<void> | void
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— the session whose buffered events must reach durable storage.
subagent/*
subagent/end
Mode: emit
'subagent/end'(info: SubagentRunEndInfo): void
A subagent run settled — emitted when SubagentRun.result resolves (any stop reason). Paired with Events['subagent/start'].
info— the run identity plus stop reason and final output.
subagent/provider-added
Mode: emit
'subagent/provider-added'(provider: SubagentProvider): void
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".
provider— the provider that just registered, live in the registry.
subagent/provider-removed
Mode: emit
'subagent/provider-removed'(name: string): void
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.
name— the registry name that no longer resolves.
subagent/start
Mode: emit
'subagent/start'(info: SubagentRunInfo): void
A subagent run started — emitted after the provider is resolved and its capabilities validated, as the child run begins. Paired with Events['subagent/end'].
info— which provider started which child agent.
system-prompt/*
system-prompt/assemble
Mode: waterfall
'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
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.
assembly— the assembly built from the registered sections, tool providers, and variable providers; listeners may mutate it or return a replacement.context— the per-assemblyAssembleContextthe caller passed toSystemPrompt.assemble(e.g. which agent the prompt is for), so a listener can filter or extend per agent.
system-prompt/change
Mode: emit
'system-prompt/change'(): void
A section, tool provider, or variable provider was registered or unregistered (the assembly inputs changed).
tools/*
tools/change
Mode: emit
'tools/change'(): void
A tool was registered or unregistered (the available tool set changed).
tools/execute
Mode: waterfall
'tools/execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
Around-dispatch waterfall wrapping the registry's core tool dispatch, between the tools/pre-execute gate and the tools/post-execute seam. A listener receives (exec, next): call next() to delegate to dispatch (returning its ToolExecutionResult, optionally wrapped), or return a replacement result without calling next() to short-circuit dispatch. The base next() IS the dispatch-with-normalization thunk — a thrown tool (or unknown tool) is already normalized to an isError result by the time a listener's await next() returns, so a wrapper never sees a raw throw from the tool body. This is the seam a timeout/retry/metrics plugin wraps: it can mutate exec (e.g. replace exec.signal with a per-call deadline) BEFORE next() and inspect the result AFTER. (Cordis next() ignores any passed arguments and re-invokes downstream with the shared payload, so a wrapper mutates exec in place rather than passing a new object to next().) Multiple listeners compose by registration order — an outer one wraps the inner ones plus dispatch.
exec— the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
tools/post-execute
Mode: waterfall
'tools/post-execute'(this: ToolRegistry, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
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. Core tool dispatch runs earlier as the base next() of the tools/execute waterfall, 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).
exec— the call that just ran (name, parsed arguments, caller agent).result— the dispatch outcome a listener may accept, replace, or block.
tools/pre-execute
Mode: waterfall
'tools/pre-execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
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)).
exec— the pending call (name, parsed arguments, caller agent).
workflow/*
workflow/agent-end
Mode: emit
'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void
One agent() call settled (clean result, child failure, or run cancellation). Paired with Events['workflow/agent-start'] by agent.seq, exactly once per started call on every stop path — on an engine termination path (a worker killed past its grace) the end is engine-synthesized with outcome 'cancelled'.
info— the run's identity snapshot.agent— the call identity plus its outcome.
workflow/agent-start
Mode: emit
'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void
One agent() call started a child run. Paired with Events['workflow/agent-end'] by agent.seq.
info— the run's identity snapshot.agent— the call's sequence number, label, phase, and child id.
workflow/end
Mode: emit
'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void
A workflow run settled (any stop reason). Fired when WorkflowRun.result resolves. Paired with Events['workflow/start'].
info— the run's identity snapshot.result— the outcome data (stop reason, error, agent count) — deliberately WITHOUT the result value (seeWorkflowResultInfo).
workflow/log
Mode: emit
'workflow/log'(info: WorkflowRunInfo, message: string): void
The script emitted a narration line (a log(message) call).
info— the run's identity snapshot.message— the logged message, verbatim.
workflow/phase
Mode: emit
'workflow/phase'(info: WorkflowRunInfo, title: string): void
The script entered a phase (a phase(title) call) — progress grouping for observers; no execution semantics.
info— the run's identity snapshot.title— the phase title, verbatim.
workflow/start
Mode: emit
'workflow/start'(info: WorkflowRunInfo): void
A workflow run started — the script's meta block validated, the body about to execute. Paired with Events['workflow/end'].
info— the run's identity snapshot (id + meta).