Architecture: the plugin model, the turn loop, and the session log

"Everything is a plugin" — what it concretely means

The claim (docs/architecture.md:11-13: "Every part of the product is a plugin, including the model adapter, the tool registry, the session log, and the agent loop itself") rests on three Cordis mechanisms:

  1. Services claim a ctx.<key> and unregister with their fiber. A service provides itself into the context tree (vendor/cordis/src/service.ts:57: self.ctx.reflect.provide(name, self, ...)), and harness packages declare their keys through TypeScript declaration merging (packages/core/tools/src/index.ts:137-140: declare module '@deepseek-ai/cordis' { interface Context { tools: ToolRuntime } }).
  2. inject expresses load order as service requirements, not boot sequencing (packages/core/agent-loop/src/index.ts:297: static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']).
  3. Registrations are reversible effects that unwind when the plugin unloads (packages/core/agent-loop/src/index.ts:349-350: ctx.effect(() => ctx.agents.setFactory(this), ...)).

The claim is real, not aspirational. The agent loop is literally a row in a YAML file (packages/bundle/base/cordis.patch.yml:436-437), and the loop interface (dsh-agent) is a separate package from the driver (dsh-agent-loop), connected by a swappable factory (packages/core/agent/src/index.ts:372-379). You could mount a different agent loop from configuration.

The tradeoff: everything-is-a-plugin means everything-is-composition. Understanding what a given dsh web process contains requires mentally merging four YAML patch layers plus platform-conditional disabled: !!js expressions. Upstream knows this — their own postmortem 0002 documents the filesystem tools being silently disabled for a while because a !!js expression appeared in a field the loader does not interpolate (docs/postmortem/0002-js-expression-disabled-filesystem-tools.md:9, :32). Composition errors are configuration-shaped, invisible to the type system, and they fail silently unless a gate catches them.

Scopes: how one agent gets its own world

dsh-scope (~200 lines) tags a Cordis context with an opaque key. Registrations inherit down the scope chain; events flow up it (packages/core/scope/src/index.ts:172-181). Each agent mints one scope and extends it with itself (packages/core/agent-loop/src/agent.ts:94-95). This single small primitive is what makes per-session tool sets, per-session prompt sections, and per-session listeners possible — it is the mechanism behind the web surface's per-session agent presets.

A fragility to know about: scope bookkeeping lives in module-level WeakMaps (packages/core/scope/src/index.ts:30,39). If two copies of dsh-scope ever load in one process, scoping silently splits — which is exactly the duplicate-instance hazard the boot symlink farm exists to prevent (packages/boot/app-boot/src/profile.ts:133-137).

The session log is the single source of truth — and it is enforced

durable events agent/inbox/spliced · turn/start step/start · stream chunks tool/call · turn/end session log append-only, replayed on construction LLM request = deriveMessages() derived at dispatch time, never accumulated runtime invariant (prepend: true) re-derives from the log at every dispatch; divergence ⇒ the run fails append compare replay · resume · UI rendering all read the same substrate Caveat: comparison is JSON.stringify — key-order-sensitive, so a benign reorder is a false failure.
Requests are derived from the log, and a test-mounted invariant makes drift a hard failure instead of a heisenbug.

Every model-visible thing is a durable event in an append-only session log. The inbox is not in-memory state; every mutation is an agent/inbox/spliced event, and the in-memory projection is replayed from the log on construction (packages/core/agent/src/inbox.ts:186, :32-35). LLM request messages are never accumulated in variables; they are derived from the log at dispatch time (packages/core/agent-loop/src/agent.ts:341: this.session.deriveMessages()), with an incremental cached fold invalidated by compaction (packages/core/session/src/index.ts:730-733).

The remarkable part is that this is machine-checked at runtime, not just documented. A test-mounted invariant re-derives the messages from the log at every LLM dispatch and fails the run if they diverge (packages/core/agent-loop/src/invariant.ts:39-42):

const expected = session.deriveMessages()
if (JSON.stringify(options.messages) !== JSON.stringify(expected)) {
  fail(`llm request ... diverges from the dispatch-time durable derivation (log-reconstruction desync)`)
}

It registers with prepend: true so a replay listener cannot silence it (invariant.ts:20-21). This is the single best design decision in the codebase: replay, resume, and UI rendering all read the same substrate, and drift between "what the model saw" and "what the log says" is a hard failure instead of a heisenbug.

Two caveats. The invariant compares by JSON.stringify, which is key-order-sensitive — a benign object-construction reorder produces a false failure (invariant.ts:40, :48-49). And persistence refuses old formats rather than migrating them (packages/session/session-persistence/src/coordinator.ts:278 throws on legacy events; SESSION_FORMAT_VERSION = 0, docs/persistence-catalog.md:10) — correct for a pre-release, but it means stored sessions have no future.

The turn loop, end to end

session log append-only JSONL — the single source of truth 1 · Prompt arrives followup / steer / inject — one inbox, three targets 2 · turn/start appended — durable before input is claimed 3 · Step preparation (before step/start) claim inbox · assemble system prompt + tool schemas agent/pre-step waterfall — 13 listener packages; compaction here 4 · Step executes step/start + claimed user/message appended agent/request → llm/stream → adapter every stream chunk appended durably before assembly agent/request-error → retry loops the same step — while (true), no ceiling in core 5 · Tool calls execute exclusive-or-parallel, fail-closed · tool/call logged first tools/pre-execute → tools/execute → tools/post-execute results commit in model order · tool output → next-step inbox 6 · agent/turn-stopping → turn/end (reason) 7 · UI session/event wire frames every append broadcast Caveat: the driver swallows every failure at its boundary — error goes to agent/error + turn/end.reason, but no logger call; with no listener mounted, a failed turn is invisible.
One turn, end to end. Prompt assembly happens in step preparation, before step/start is appended. Teal arrows are durable appends.

What happens between a user message and a response, with the real file:line trail:

  1. Prompt arrives. The web prompt RPC builds a durable UserMessage and calls agent.followup(...) or agent.steer(...) (packages/host/apiproxy/src/api-proxy.ts:2497). followup, steer, and inject are one inbox with three targets — next-turn vs next-step, wake vs not (packages/core/agent-loop/src/agent.ts:122).
  2. Turn opens durably before input is claimed (agent.ts:255: this.session.append('turn/start', { turn })).
  3. Step preparation. Claim inbox messages, assemble the system prompt and tool schemas, then run the agent/pre-step waterfall (agent.ts:229-239). Thirteen packages listen here (docs/event-producer-consumer.md:20) — this is the main policy seam, and it is where compaction applies pressure (packages/compaction/compaction-basic/src/index.ts:153). A synthetic runtime-context message (time, cwd, instructions) is appended only when it differs from the last snapshot (packages/core/agent-loop/src/runtime-context.ts:64-67).
  4. Step executes. step/start and each claimed user/message are appended (agent.ts:279-283); request config passes the agent/request waterfall (agent.ts:438); the call dispatches through LlmRuntime.stream → the llm/stream waterfall → the adapter (packages/llm/llm/src/index.ts:921-926). Every stream chunk is appended durably before assembly (agent.ts:349-350), so a crash mid-stream loses nothing. Failures re-enter through the agent/request-error waterfall; retry loops the same step (agent.ts:355-370).
  5. Tool calls execute. Calls are classified exclusive-or-parallel with a fail-closed default (packages/core/tools/src/index.ts:1278-1284: unknown or throwing classifier means exclusive). tool/call is logged before the pipeline runs (packages/core/agent-loop/src/tool-calls.ts:167-169); the three documented waterfalls fire in order — tools/pre-execute (tools/src/index.ts:1475), tools/execute (:1573), tools/post-execute (:1744) — and results commit in model order even though dispatch overlaps (tool-calls.ts:146-158). Tool-produced context is staged into the next-step inbox rather than concatenated into the request (agent.ts:397).
  6. Turn closes. agent/turn-stopping runs serially when the inbox is empty, then turn/end with a reason (agent.ts:295-299, :319).
  7. The UI hears about it. Every append is broadcast synchronously and converted to a session/event wire frame (packages/host/apiproxy/src/api-proxy.ts:3493).

The loop's rough edges

Host vs client split

dsh-api-gateway packages/api dsh-api-remotes packages/api dsh-client-connection client half dsh-host-apiproxy host half — 3,744-line god file type-only value import: toFetchHandler client depending on host type-only type-only
The one manifest-level dependency cycle in the workspace, and it crosses the host/client line. Three edges are type-only; the orange one is a real value import. docs/module-graph.md hides it by drawing only peer-dependency edges.

The host half (packages/host/*) owns a transport-independent API proxy plus a plain HTTP server (packages/host/README.md:16); the client half (packages/client/*) is a three-layer stack with declared one-way knowledge — React-free data objects, shell-only render glue, pure-props presentation components (packages/client/AGENTS.md:46-48). The split is enforced structurally: two disjoint TypeScript programs whose Context merges never meet (tsconfig.json:6).

Two blemishes. First, packages/host/apiproxy/src/api-proxy.ts is a 3,744-line god file holding the entire host API surface — bigger than core/agent-loop + core/agent + core/scope combined (2,841 lines). packages/core/tools/src/index.ts is the other god file at 1,946 lines. Second, there is exactly one manifest-level dependency cycle in the workspace, and it crosses the host/client line: dsh-api-gateway → dsh-client-connection → dsh-host-apiproxy → dsh-api-remotes → dsh-api-gateway (edges at packages/api/gateway/package.json:62, packages/client/connection/package.json:42, packages/host/apiproxy/package.json:51, packages/api/remotes/package.json:63). Three of the four edges are type-only, but client-connection → host-apiproxy is a value import (packages/client/connection/src/index.ts:7: import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy') — a client package depending on a host package, which is the layering smell the split's own framing denies. The generated docs/module-graph.md hides this because it only draws peer-dependency edges (docs/module-graph.md:6), omitting six real dependencies on client-connection alone (packages/client/connection/package.json:40-47).

The vendored framework

Cordis is not consumed; it is owned. Nine framework packages live in vendor/, renamed to the @deepseek-ai scope for registry hygiene (docs/rescope.md:5 — note this doc is a namespace rename, not a product pivot), with a maintained ledger of 18 local modifications, each with rationale and a covering test (vendor/README.md:29-51). Several are deep behavioral rewrites (fiber-lifecycle hardening, transactional loader reconciliation), and half the vendored set already comes from DeepSeek's own fork of the upstream (vendor/README.md:17-23 cites both cordiverse/cordis and deepseek-harness/cordis as sources). This is a fork that will never go back.

The oddest artifact of ownership: vendored Cordis's emit starves later listeners when one throws synchronously and discards returned promise rejections (vendor/cordis/src/events.ts:194-196), and instead of fixing it in the framework they control, harness code hand-rolls the same containment workaround five times (packages/core/agent/src/dispatch.ts:125-136, packages/core/agent/src/index.ts:529-539 and :561-571, packages/core/agent-loop/src/index.ts:393-403, packages/core/session/src/index.ts:382-397). The duplication detector misses it because the identifiers differ.

Boot

app-boot loads .env (refusing entries that try to set process/network bootstrap variables — packages/boot/app-boot/src/index.ts:157-160), resolves the profile, applies patch layers, and drives the loader to settlement, failing loudly if any plugin cannot resolve (index.ts:662). The design is sound; the wart is the write-on-every-boot behavior described in what-is-dsh.html.