Two blocking lifecycle findings from the deep review: - `SessionStore.enter()` is a public cross-package primitive that a caller can separate from `prepare()` by arbitrary work, so it must re-check the id: a stale prepared session could otherwise overwrite a live store entry of the same id, and the stale session's detach disposer would later delete the REAL session. Re-add the duplicate-id throw (removed earlier on a coverage rationale that only held for the back-to-back internal caller). Tests cover the stale-overwrite rejection and the prepare/enter/announce lifecycle (which also covers the throw branch). - `AgentHandle.dispose()` exposed the raw single-shot cordis effect disposer, so a concurrent/second dispose() returned immediately (effect epoch already cleared) instead of awaiting the in-flight teardown — violating the dispose(): Promise<void> contract that every caller observes the same quiescence boundary. Memoize the disposal promise in startOwned. Regression test gates the loop's final flush, fires two dispose() calls, and asserts the second stays pending until the first's teardown completes (fails without the memo).
Packages
Harness packages, all under the @deepseek-ai/dsh-* scope. Each package is a Cordis plugin (microkernel-style): it exports either a default Service subclass or a functional plugin that gets registered via ctx.plugin(), declares its ctx key/events where applicable through declaration merging, and exposes extension points through ctx.effect(), ctx.on(), and ctx.waterfall().
Dependency graph
dsh-llm (no harness deps — pure vocabulary)
dsh-bash (no harness deps — abstract executor seam)
dsh-session ← dsh-llm
dsh-system-prompt ← dsh-llm
dsh-agent ← dsh-llm, dsh-session
dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent
dsh-bash-local ← dsh-bash (BashExecutor impl)
dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas)
dsh-llm-deepseek ← dsh-llm (DeepSeek adapter)
dsh-llm-pi-ai ← dsh-llm (pi-ai-backed adapter)
dsh-agent-loop ← dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-agent
dsh-invariants ← dsh-llm, dsh-session, dsh-agent (dev-mode contract checks)
dsh-acp ← dsh-agent, dsh-llm, dsh-session, dsh-session-persistence (ACP JSON-RPC bridge)
dsh-ui-stdio ← dsh-agent, dsh-llm, dsh-session (stdio readline UI plugin)
dsh-llm-replay ← dsh-llm, dsh-session (record/replay adapter for keyless snapshot tests)
The rule: plugins depend on interfaces, never on the concrete loop. dsh-agent-loop is swappable — UI/hook/tool plugins keep working against the dsh-agent vocabulary if the loop is replaced. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see capability seams).
What goes where
| Package | Role | ctx key |
|---|---|---|
llm/ |
Abstract LLM service + content-block vocabulary + chunk assembler | ctx.llm |
session/ |
Event-sourced session log + in-memory store | ctx.sessions |
system-prompt/ |
Prompt-section + tool-schema assembly registry | ctx.systemPrompt |
tools/ |
Tool registry + tools/execute waterfall |
ctx.tools |
agent/ |
Agent interface, registry, agent/* event vocabulary |
ctx.agents |
agent-loop/ |
THE concrete loop plugin: ReactLoopAgent + the loop driver |
ctx.agentLoop |
bash/ |
Abstract bash executor seam (interface + vocabulary) | ctx.bash |
bash-local/ |
Local-subprocess BashExecutor implementation |
(registers ctx.bash) |
tool-bash/ |
Model-facing bash/bash_output/bash_kill tool schemas |
(registers on ctx.tools) |
llm-deepseek/ |
DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on ctx.llm) |
llm-pi-ai/ |
DeepSeek adapter via @earendil-works/pi-ai (design twin) |
(registers on ctx.llm) |
invariants/ |
Dev-mode event-contract invariants + session-log freeze | (listens on session/*, agent/*) |
acp/ |
Agent Client Protocol bridge: serves the agent to an ACP editor over JSON-RPC stdio | (drives ctx.agents/ctx.sessions) |
ui-stdio/ |
Minimal stdio (readline) UI plugin: renders agent/* events, feeds stdin lines to the agent |
(drives ctx.agents) |
llm-replay/ |
Record/replay adapter: short-circuits llm/stream with chunks from a recorded session JSONL (keyless snapshot tests) |
(listens on llm/stream) |
Each package has its own README.md with purpose, service API, events, extension points, and deliberate non-goals (TODOs).
Conventions (applied across all harness packages)
- Registrations are effects: every contribution (adapter, tool, section, agent, event listener) goes through
ctx.effect()/ctx.on(), so disposal and HMR clean up automatically. Everyregister()returns the disposer. - Declaration merging for events and ctx: services declare their events in
declare module 'cordis' { interface Events { ... } }and their ctx key ininterface Context. - Waterfall semantics:
ctx.waterfalllisteners receive(...args, next)and MUST callnext()to delegate; returning without it short-circuits (the veto mechanism). - Extensible unions:
ContentBlockMap,MessageSourceMap,FinishReasonMap,TurnTriggerMap,TurnEndReasonMap, andSessionEventMapuse the merge-extensible-map pattern so plugins can add variants via declaration merging. - ESM everywhere; imports use package names across package boundaries,
.tsextensions within a package. - Tests: vitest, colocated under
packages/<name>/tests/*.spec.ts. Every registry needs an HMR-safety test. Err on the side of more tests.