The loop recorded every model token delta as a durable `assistant/chunk` session event AND emitted an identical live `agent/stream-chunk` Cordis event one line later. Same StreamChunk, same turn/step; the emit added only the live Agent handle, which the sole consumer discarded. This is the boundary-mirror duplication the event-domain work removed for turn/step boundaries, applied to the token stream — a follow-up the boundary RFC explicitly deferred. The premise is settled: chunk persistence is authoritative (the proposal to stop persisting chunks was rejected — replay/snapshots depend on it), so `assistant/chunk` on `session/event` is the load-bearing token stream and `agent/stream-chunk` is pure redundancy. - Remove the `agent/stream-chunk` declaration + emit; drop the now-unused StreamChunk import from dsh-agent's types. - Migrate `dsh-ui-stdio` (the only live consumer; ACP already reads assistant/chunk off session/event) to render assistant/chunk in its existing session/event listener. Consolidating to one listener also makes the inReasoning dim-SGR flag deterministic across chunk/boundary events (they no longer race across two listeners). - Repoint the agent-loop tests (cancel/loop) and ui-stdio tests to the session/event assistant/chunk feed. - New RFC (implemented/simplification/2026-07-02-remove-stream-chunk-mirror); amend the boundary RFC's retained-list entry to cross-link; update architecture, cookbook, event-domain-semantics, the ACP proposal, and the regenerated cordis catalog. Snapshot goldens unchanged (ACP never used the mirror), confirming no editor-facing transcript change.
5.2 KiB
Cookbook: extension plugin shapes
The three plugin shapes you write against the harness extension surface, as illustrative snippets (elided imports and helper stubs — not copy-paste-complete). For the full step-by-step guides see adding a package, adding a tool, and adding an LLM adapter; for the seams these hook into see docs/architecture.md.
A tool plugin
A tool registers on ctx.tools. The annotated defineTool example (typed execute args, result shaping, the run_in_background pattern) lives in adding-a-tool.md — that guide is the source of truth for the tool shape. Raw JSON-Schema ToolDefinitions are also accepted by ctx.tools.register() directly (that is how MCP-sourced tools arrive); defineTool is the typed sugar for first-party tools.
A hook plugin (permission gate)
A hook returns a typed decision from the tools/pre-execute gate to allow or deny a call — the seam where sandbox, permission, and plan-mode plugins live. (A "native hook" is just this: an ordinary cordis plugin on the interception seams, returning typed decisions — no external protocol needed.)
import type { Context } from 'cordis'
import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
declare function isAllowed(exec: ToolExecution): Promise<boolean>
export const name = 'permission-gate'
export function apply(ctx: Context) {
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (!(await isAllowed(exec))) {
return { kind: 'deny', reason: 'Denied by policy.' }
}
return next()
})
}
A UI plugin
A UI plugin renders from the session/event feed (the assistant token stream as assistant/chunk, plus turn/step boundaries and tool activity), and drives input back in via agent.send() / agent.steer().
import type { Context } from 'cordis'
import { AgentId } from '@deepseek-ai/dsh-agent'
declare function render(text: string): void
declare function onUserInput(handler: (text: string) => void): void
export const name = 'my-ui'
export const inject = ['agents']
export function apply(ctx: Context) {
ctx.on('session/event', (_session, event) => {
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') {
render(event.data.chunk.text)
}
})
onUserInput(text => ctx.agents.get(AgentId('main'))?.send([{ type: 'text', text }]))
}
A client-driver plugin (external protocol bridge)
A client driver is a UI plugin whose "user" is another program speaking a wire protocol rather than a human at a terminal. It owns the process's stdio (so it must run with no stdout logger — every non-protocol byte corrupts the stream), creates/resumes agents on demand through the dsh-agent factory seam, translates harness events (session/event, agent/*) into outbound protocol messages, and translates inbound requests back into agent.send() / agent.cancel(). Two harness-specific contracts make it correct: resolve each request exactly once off a settle signal (settle from the durable turn/end session event — the boundary is a session event, not an agent/* mirror — with agent/status as the fallback if a peer listener starved yours), and tear each agent down through its AgentHandle.dispose() (which stops the loop, awaits its exit, and unregisters), not just cancel() — disposal must reach quiescence, not merely request it.
packages/ui/acp is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the deferred-permission-gate note.
import type { Context } from 'cordis'
export const name = 'my-protocol-bridge'
export const inject = ['agents', 'sessions', 'sessionPersistence']
export function apply(ctx: Context) {
// Stream every logged assistant text/reasoning delta out to the client.
ctx.on('session/event', (_session, event) => {
if (event.type === 'assistant/chunk') {
const chunk = event.data.chunk
if (chunk.type === 'text-delta') {
// sendToClient({ kind: 'message_chunk', text: chunk.text })
}
}
})
// Inbound "prompt": create/resume an agent and feed it; settle on turn end.
// Teardown reaches quiescence via AgentHandle.dispose() (stop + await exit).
}
Runnable wirings
Three complete examples load their plugin trees from cordis.yml: examples/echo-agent (mock model + echo tool — the all-mock skeleton check, pnpm run demo:echo), examples/coding-agent (DeepSeek V4 + the bash tool suite — the real thing, pnpm run demo:coding), and examples/acp-agent (the same coding agent exposed as an ACP server over JSON-RPC stdio — the client-driver shape, pnpm run demo:acp). Each leaf is now just its swappable backends plus an app-package entry: the stdio demos load @deepseek-ai/dsh-stdio-agent, the ACP demo loads @deepseek-ai/dsh-acp-agent, and both app packages share the spine via the @deepseek-ai/dsh-agent-core bundle.