One context to rule them all
A running dsh is a plugin tree composed at boot from ordered layers. Every capability lives behind a service key on a shared ctx — plugins find each other by key, never by import. There is no privileged core to patch: you extend dsh by mounting a plugin beside the others.
flowchart TB
subgraph BOOT["Boot Composition (Profiles × Bundles)"]
P1["profile: web"] --> B1["bundle: dsh-base"]
P1 --> B2["bundle: dsh-web-app"]
P2["profile: headless"] --> B1
P2 --> B3["bundle: dsh-headless"]
B1 --> PATCH["cordis.patch.yml layers
(profile → home → --patch)"]
end
subgraph CTX["Shared Cordis Context (ctx.*)"]
C1["ctx.sessions
append-only event log"]
C2["ctx.systemPrompt
prompt section registry"]
C3["ctx.tools
tool registry + guarded pipeline"]
C4["ctx.agents
live agent registry"]
C5["ctx.agentLoop
default driver (ReactLoop)"]
C6["ctx.llm
adapter registry + stream waterfall"]
end
subgraph SEAMS["Swappable Capability Seams"]
S1["ctx.fs → fs-local / fs-sandbox / fs-e2b"]
S2["ctx.subprocess → local / e2b"]
S3["ctx.shell → bash-local / bash-sandbox / pwsh"]
S4["ctx.sessionPersistence → jsonl / sqlite"]
S5["ctx.subagents → in-process / acp / codex / claude-code"]
S6["ctx.web → exa / perplexity / deepseek search"]
end
BOOT --> CTX
CTX --> SEAMS
C5 --> C1 & C2 & C3 & C4 & C6
Everything is a plugin
Model adapters, tool registries, session logs, even the agent loop itself are plugins contributing services, typed events and reversible effects. Registrations unwind when a plugin unloads.
The log is the truth
“Model-visible means logged.” Everything that reaches a model request must be reconstructable from the append-only session log. A runtime invariant asserts it.
Seams, not forks
One provider swap changes the whole product. Point filesystem & subprocess at a remote sandbox and Bash, PTY and LSP all move with them — no provider forks.
Cordis in five ideas
Cordis is the vendored plugin framework underneath dsh — a “programming paradigm for spatiotemporal composability.” These five ideas define how every plugin in the harness works.
1 · Plugin = Service object
A plugin is a function with optional inject and apply(ctx), or a Service subclass Cordis mounts into the current context. Lifecycle is framework-managed.
2 · Context = service repository
A service claims a stable ctx.<key> like ctx.tools or ctx.llm. Plugins find each other by key instead of importing concrete implementations — dependency inversion baked into the framework.
3 · inject = dependency order
A plugin that names required services waits until those services exist. Load order is expressed through service requirements rather than manual boot sequencing — the tree self-assembles.
4 · Typed events, 4 dispatch modes
Events are declared via TypeScript declaration merging and dispatched as emit, waterfall, parallel or serial — observe, wrap, fan out, or run in order. The mode is part of the public contract.
5 · Registrations are reversible effects
Prompt sections, tool schemas, adapters, providers and listeners are installed through ctx.effect() or ctx.on() — so reload and teardown unwind them predictably. This is what makes hot plugin replacement safe and is the foundation of dsh's live-config story.
The session log: append-only, replayable, authoritative
Every fact in a conversation — turn boundaries, user messages, streaming chunks, tool calls, compactions — is a durable SessionEvent appended to a per-session log. The model's context window is a projection of that log, not a separately managed buffer.
Durable events (replay = truth)
These event types survive reload and drive everything downstream:
Raw assistant/chunk events preserve replay and UI fidelity — the UI can render the exact token stream again, and sourceEventSeqs links each assistant message to the chunks that produced it.
The surface: model-visible projection
Only 3 event types produce LLM messages: user/message, assistant/message, tool/result. The surface is an ordered view of those nodes. Compaction replaces a surface range with a summary — the old nodes are shadowed in the log, never deleted.
⚡ “Model-visible means logged” — a runtime invariant asserts any new model-visible input requires a new session event.
flowchart LR
subgraph LOG["Append-Only SessionEvent Log (seq 1 → n)"]
E1["turn/start"]
E2["user/message"]
E3["assistant/chunk × k"]
E4["assistant/message"]
E5["tool/call"]
E6["tool/result"]
E7["compaction/start"]
E8["summary user/message (replace)"]
E9["compaction/end"]
end
LOG --> FOLD["Surface Fold
replay + replace ops"]
FOLD --> SURF["Surface Nodes
(3 eligible types)"]
SURF --> DERIVE["deriveMessages()
projection rule"]
DERIVE --> REQ["Model request messages"]
SURF -. shadowed .-> OLD["shadowed range stays in log
for replay & audit"]
A turn is zero or more steps; a step is one model request + its tools
The ReactLoopAgent drives the loop. A step is one model request plus the tools it calls. A turn opens before its first input is claimed and closes once nothing is owed. Every boundary is a durable event; every interception is a typed event.
sequenceDiagram
participant U as User
participant A as Agent (ReactLoop)
participant H as Hook listeners
participant P as ctx.systemPrompt
participant L as ctx.llm
participant T as ctx.tools
participant S as Session
U->>A: followup(content)
A->>S: turn/start
Note over A: claim next-step input + one queued message
A->>H: agent/pre-step (waterfall)
H-->>A: enter(messages) | reject
A->>S: step/start + user/message*
A->>P: system-prompt/assemble (waterfall)
A->>L: agent/request → llm/stream (waterfall)
L-->>A: StreamChunk*
A->>S: assistant/chunk* (durable)
A->>S: assistant/message
A->>T: classify calls → executeToolCalls
T->>S: tool/call + tool/result (model order)
A->>S: step/end
Note over A: tools owe another request? → next step
A->>H: agent/turn-stopping (serial checkpoint)
A->>S: turn/end
🎯 agent/pre-step
Waterfall that decides what the model sees. Listeners may rewrite claimed messages or reject them outright. A rejected first claim still closes a durable turn that spent no step.
🔄 agent/request
Waterfall over the request config (provider, model, reasoning effort, max tokens). Plugins can propose changes; the adapter resolves exact-model defaults at prepareCall().
🧯 agent/request-error
Recovery waterfall for failed streams. Listeners return a retry action or preserve the original error. Compaction hooks here for context overflow recovery.
The driver state machine
Three phases — idle → maintenance → running. Input reaches the driver through one inbox with splice/claim semantics. Some messages wake it immediately (followup, steer); injected context (agent.inject()) waits in the inbox until another message does. max-tokens is sticky: once any step hits the ceiling, later completed steps cannot downgrade the turn outcome.
System prompt as a merge-extensible registry
Instead of a single hard-coded system string, dsh assembles the system prompt from named, ordered sections contributed by any plugin — plus dynamic contexts, tool schemas and typed variables. Assembly runs a system-prompt/assemble waterfall so any plugin can transform the final result.
flowchart TB
subgraph SECTIONS["PromptSections (ordered, named)"]
S1["order -100 · harness identity"]
S2["order 0 · deployment persona
(PERSONA_ORDER)"]
S3["order 100–199 · tool guidance"]
S4["order n · plugin sections"]
end
subgraph OTHER["Other providers"]
C1["PromptContexts
dynamic runtime snapshots"]
T1["Tool schemas
from ctx.tools"]
V1["Variables {{provider}} {{model}} {{cwd}}"]
end
SECTIONS --> ASSEMBLE["system-prompt/assemble
waterfall (scope-filtered)"]
OTHER --> ASSEMBLE
ASSEMBLE --> RENDER["renderPrompt()
interpolate → filter empty → join \\n\\n"]
RENDER --> MODEL["model system prompt"]
subgraph SCOPE["Scoped assembly"]
SC1["global providers"]
SC2["agent.ctx providers
(per-agent overrides)"]
end
SCOPE --> ASSEMBLE
Section contract
A complete section becomes the sole prompt section (restored after the waterfall so listeners can't mutate it). More than one effective complete section fails assembly.
Strict variable interpolation
Variables are resolved once — substituted values are not scanned again. A lone {{ without a closing }} is treated as literal prose.
Runtime context snapshot
Dynamic context (time, agent instructions, session references, tmux state) is materialized as a durable user-role snapshot — not a system-prompt append. It lands as its own message with a supersede marker: “Current runtime context. This snapshot supersedes earlier runtime-context snapshots.” Because it's a logged event, replay and fork stay faithful.
The guarded tool pipeline
Every tool call flows through policy waterfalls, monotonic guards, an approval seam, an around-dispatch waterfall, filesystem intent gates and a frozen final-result notification — all without changing the agent loop.
flowchart TD M["assistant message has tool-call"] TC["session event: tool/call"] PRE["tools/pre-execute waterfall
hooks · permission · sandbox"] G["monotonic guards
deny or abstain"] APP["ctx.approval one-shot prompt
absent/unanswerable → deny"] EX["tools/execute waterfall
timeout · retry · metrics"] BODY["tool execute() body"] FSG["fs/write-intent · fs/edit-intent
tool-fs mutations only"] POST["tools/post-execute waterfall
accept · block · replace · add context"] NORM["registry normalization
snapshot failures → isError"] FIN["ToolDefinition.finalizeContent"] RES["tools/result
frozen authoritative outcome"] TR["session event: tool/result"] M --> TC --> PRE -->|allow| G PRE -->|ask| APP -->|allowed-once| G PRE -->|deny| DEN["denied — body skipped"] G -->|deny| DEN G -->|allow| EX --> BODY --> FSG FSG --> EX BODY --> POST --> NORM --> FIN --> RES --> TR PRE -. throw .-> NORM G -. throw .-> NORM EX -. wrapper throws .-> NORM POST -. throw .-> NORM DEN --> POST
🚦 Monotonic guards
Owner policy that must not be reordered stays as a guard — deny or abstain, never reordered by later registrations. Identity-protected.
⏱️ Around-dispatch
Timeout, retry and metrics wrap tools/execute — around-dispatch concerns live in the waterfall, so hooks span tool families without coupling tools to one policy service.
🧊 Frozen outcomes
tools/result observes the immutable, lossless-JSON outcome. additionalContexts flow into the next step's inbox as FIFO-injected user messages.
Parallel tool scheduler
Exclusive calls form barriers; parallel calls run in a bounded rolling pool (maxParallelToolCalls, default cap, live-adjustable). Dispatch may overlap, but policy, results and result context commit in model order — the model always sees its calls answered in the order it issued them. Abort records synthetic error results for skipped calls so replay stays valid.
Compaction & spill: keeping the window honest
dsh treats context as a priced, measured resource. A replay-based token meter measures pressure; region-based compaction summarizes the head while keeping a priced tail; a spill policy keeps oversized tool output out of the window entirely.
flowchart TB P["post-step pressure event
or request-error (context overflow)"] M["token-meter replay fold
priced surface nodes"] SEL["selectCompactableRange()
retain priced recent tail"] PAIR["never split tool-call/result pair
toolPairingBalancedBefore"] START["session event: compaction/start
(the durable lock)"] SUM["summarize span → summary user/message"] STAB["stability check
whole-surface or selected-span"] CHG["SurfaceChangedError —
summary invalidated"] COMMIT["commit: replace surface span
shadow old nodes"] END["session event: compaction/end"] P --> M --> SEL --> PAIR --> START --> SUM --> STAB STAB -->|changed| CHG STAB -->|stable| COMMIT --> END
📏 Region selection
Walks the surface from the tail, accumulating token cost until the retainTokens budget is met; everything before stays compactable. The cutoff backs up to the nearest balanced tool-call/result boundary so a summary never splits a pair.
🛡️ Stability check
Summarization is async, so the surface may move mid-flight. Before commit, dsh verifies the replacement boundaries are still the ones the summary was built from — otherwise it throws SurfaceChangedError, and the original request error remains authoritative.
A failed close deliberately leaves an unmatched compaction/start detectable — audit never lies.
💧 Spill policy: bounded tool output
When a plain-text tool result exceeds maxInlineBytes, the full text is saved to a session-scoped spill artifact and the model-facing result becomes a bounded head/tail preview plus a locator and retrieval hint. It's deliberately best-effort — a spill failure never turns a successful tool call into an error. The read tool is skipped to avoid a read → spill → read-again loop; the durable log copy is bounded by a second arm on tools/code-dispatch-log.
31 seams — one provider swap changes the whole product
A seam is a swappable capability with three roles: a Service Definition declaring the interface, a Service Provider implementing it, and a Consumer using it (commonly a model-facing tool). One role alone is not a seam.
| ctx key | Role | Implementations | Consumers |
|---|---|---|---|
| ctx.llm | seam | llm-deepseek · llm-pi-ai · llm-replay | agent-loop · compaction-basic |
| ctx.fs | seam | fs-local · fs-sandbox · fs-e2b | tool-fs (+ fs-observation-policy gate) |
| ctx.subprocess | seam | subprocess-local · subprocess-e2b | bash-local · terminal-bash · lsp-stdio · subagent-acp/codex/claude-code |
| ctx.shell | seam | bash-local · bash-sandbox · pwsh-local | tool-bash · tool-pwsh · hooks-claude-code · hooks-codex |
| ctx.sessionPersistence | seam | jsonl · sqlite | agent-loop · session-query · feedback |
| ctx.subagents | seam | spawn/fork-in-process · acp · codex · claude-code · dsh-sdk | tool-subagent · tool-subagent-control · tool-ralph |
| ctx.sandbox | seam | sandbox-local · (remote) | bash-sandbox · terminal-bash · fs-sandbox |
| ctx.web | seam | web-search-exa · perplexity · deepseek · web-fetch-http | tool-web |
| ctx.compaction | seam | compaction-basic (+tool-result-pruner) | pressure events · request-error recovery |
| ctx.approval | seam | acp bridge · (human) | tools · tool-bash |
| ctx.credentials | seam | credentials-local | llm-deepseek · llm-pi-ai · apiproxy |
| ctx.sessionTitle | seam | first-prompt-llm · all-prompts-llm | UI / session query |
| ctx.spillStore | seam | spill-local | spill-policy (post-execute consumer) |
Why seams matter
Filesystem and subprocess providers share one execution world — pointing them at a remote E2B sandbox moves Bash, PTY and LSP with them, with no provider forks. Subagent providers vary just as widely behind one interface: from a fresh child agent to a delegated turn in another product (Claude Code, Codex, ACP). This is how a harness stays future-proof: adding a capability means designing all three roles, and swapping one provider changes the whole product.
The nine ideas that make dsh different
No privileged core — everything is a plugin
The agent loop itself is a plugin. There is no fork point, no “core vs extension” boundary: you extend dsh by mounting a plugin beside the others, and registrations are effects that unwind when the plugin unloads.
The append-only session log as single source of truth
Model-visible means logged. Fork, resume, transcripts, telemetry and persistence all derive from one event stream. Raw chunks preserved for byte-perfect replay.
System prompt as a merge-extensible registry
Named, ordered sections + dynamic contexts + tool schemas + strict variables, assembled through a scope-filtered waterfall. Persona is a replaceable slot (deployment:persona at order 0).
31 capability seams with three-role contracts
Service Definition + Provider + Consumer. One provider swap (e.g. local fs → remote sandbox) cascades across the whole product.
Waterfall events as around-middleware
llm/stream, tools/pre-execute, agent/pre-step are all waterfalls: listeners receive (...args, next) and delegate, short-circuit, or wrap. Retry, replay, routing and policy all live here.
Model-order tool scheduling with bounded parallelism
Exclusive barriers + bounded rolling pool. Dispatch overlaps, but policy, results and context commit in model order. Abort records synthetic results to keep replay valid.
Region-based compaction with stability verification
Token-metered selection, tool-pair safety, async summarization guarded by a surface-stability check, and durable compaction/start–end brackets.
Spill policy — model-free context budgeting
Oversized tool results become head/tail previews + locators, spilled to session-scoped storage. Best-effort: failure never breaks the tool call.
Scoped per-agent registration
Each agent gets its own agent.ctx scope. Capability sets, prompt sections and tool sets can differ per agent — presets compose an agent by mounting scoped rows.
Forensic code gems
The highest-signal excerpts from the analysis — the seams where dsh's design decisions live.
The turn loop — one model call per step, durable boundaries everywhere
Request construction — the config waterfall and prepared calls
Compaction transaction — durable lock, stability guard, one close attempt
The DeepSeek adapter — transport-only, credentials resolved per request
Boot — snapshot-aware config, fail-loud loader guards
Analysis trail
All findings verified against a fresh git clone --depth=1 of deepseek-ai/deepseek-harness (MIT).
Primary sources:
docs/architecture.md— profiles, bundles, events, turn flowdocs/cordis-primer.md— the five ideas + dispatch modesdocs/agent-lifecycle.md— curated sequence diagramdocs/tool-execution-pipeline.md— curated pipeline flowchartdocs/capability-seams.md— 31-seam service tablepackages/core/agent-loop/src/{index,agent,tool-calls}.ts— the driverpackages/core/system-prompt/src/index.ts— prompt seampackages/core/session/src/{index,surface}.ts— event log + surfacepackages/compaction/compaction-basic/src/region.ts— compactionpackages/spill/spill-policy/src/index.ts— spill policypackages/llm/llm-deepseek/src/adapter.ts— provider adapterpackages/boot/app-boot/src/index.ts— boot glue