The first real LlmAdapter implementations, shipped as a deliberate pair:
same models and wire protocol, completely different internals, so the
StreamChunk protocol is verified across independent implementations.
- dsh-llm-deepseek: hand-rolled fetch + SSE parser + chunk-translation
state machine against the official chat-completions format (thinking
mode via top-level thinking/reasoning_effort; the empty-string
reasoning_content first chunk; usage attached to the finish chunk or
trailing; reasoning_content passback on tool-call turns; disjoint
cache-token accounting).
- dsh-llm-pi-ai: the same endpoint through @earendil-works/pi-ai,
mapping its event vocabulary (parsed tool arguments, in-stream error
events, folded reasoning tokens) onto the same chunks.
The agent loop now honors the in-band error path: an adapter that ends
its stream with finish {kind:error|aborted} (the only option for
adapters that can't throw mid-stream, like pi-ai) is translated into a
step error, so the turn ends error/aborted with a logged error event
instead of a normal completed assistant message. This makes the
StreamChunk error contract real for both adapters; docs/architecture.md
and the StreamChunk doc are updated accordingly.
New yarn test:e2e (vitest.e2e.config.ts, *.e2e.ts) runs key-gated
real-API matrices for both adapters across V4 Flash/Pro and all
thinking/effort levels; it self-skips without DEEPSEEK_API_KEY. Unit
suites run against local node:http mock SSE servers at 100% per-file
coverage.
Three packages following the new capability-seam pattern (interface /
implementation / consumer, now documented in docs/architecture.md):
- dsh-bash: abstract BashExecutor service (ctx.bash) + vocabulary types.
- dsh-bash-local: local subprocesses — bash -c per call in a detached
process group, SIGTERM→SIGKILL group kills, tail-keep truncation with
full-stream spill files, model-friendly env, background task registry.
- dsh-tool-bash: the bash / bash_output / bash_kill tool schemas with
runtime arg validation and background completion notices via
agent.inject(). Non-zero exits are reported, not errored.
Design surveyed against the bash tools of Claude Code, OpenCode, Codex,
and pi (notes in the package READMEs). Permissions/sandbox stay TODO on
the tools/execute waterfall seam; stateful-shell alternatives recorded
in run.ts.
Vite >=8 warns the plugin is replaceable by the native experimental
resolve.tsconfigPaths option. It isn't for this repo: the native option
applies the nearest tsconfig.json's own paths per importing file, while
our paths map lives only in the root tsconfig — per-workspace tsconfigs
under packages/* and vendor/* have none, so native resolution falls
through to package.json exports (lib/, absent until yarn build) and
every unbuilt test import fails (verified on vite 8.0.16/vitest 4.1.8).
CI failed at Lint with 1519 no-unsafe-* errors on every cross-package
import. Three fresh-checkout issues, invisible locally because lib/
persists between runs:
- Lint ran before Typecheck, but the type-aware ESLint config resolves
vendor packages via their built declarations (tsconfig.typecheck.json
-> vendor/*/lib), which Typecheck emits. Reordered.
- The first-ever tsc -b resolved sibling vendor plugins through their
package.json types (lib/index.d.ts, not yet emitted) — TS2307 until a
second run. The source-level paths map moves from the root
tsconfig.json (dev-only, not inherited by package builds) into
tsconfig.base.json so the whole build graph resolves source-first;
tsconfig.typecheck.json still overrides wholesale to lib resolution.
- Hygiene ran publint (validates packed lib/index.js bundles) before
Build emitted them. Reordered.
Also: checkout/setup-node bumped v4 -> v6 (node20 runners are
force-switched to node24 on 2026-06-16), the Build step name catches up
with tsdown, and AGENTS.md documents the one case where a fresh clone
needs `yarn typecheck` before `yarn lint`.
Eight proposals grouped by category, each with problem statement,
concrete plan, and risks: property-based testing over the
protocol-shaped core (chunk streams, event logs, schema DSL);
mutation testing as the counterweight to the 100%-coverage gate;
deterministic tests + a universal replay-invariant fixture + nightly
race stress; architectural conformance (dependency-cruiser rules and
the LlmAdapter conformance kit); runtime arg validation at the model
boundary with a structured error taxonomy and dev-mode invariants;
doc-sync enforcement (typechecked doc snippets, API reports);
supply-chain checks and nightly vendor-drift verification against the
manifest; and deep-readonly public surfaces (logged-vs-in-flight
mutability boundary). AGENTS.md points at docs/adr and docs/rfc.
Seven ADRs capturing the why behind decisions already made: vendoring
Cordis as source with a guarded manifest; the microkernel event
taxonomy with one swappable concrete loop; event-sourced sessions
with derived history and the append-before-emit ordering contract;
the provider-neutral content-block vocabulary (and why not
OpenAI/Anthropic shapes); the custom tool-schema DSL over schemastery;
tool schemas living in the prompt assembly; and mechanical quality
gates over prose guidelines (the agents-write-the-code rationale).
assertNever (dsh-llm) marks unreachable defaults on CLOSED unions:
adding a StreamChunk variant now breaks compilation at
BlockAssembler.push, and a value escaping its type at runtime throws
with diagnostics. The module doc and a new AGENTS.md convention spell
out the dividing line: merge-extensible unions (SessionEventMap,
ContentBlockMap, …) must NOT use assertNever — plugin-added variants
are valid unknown values there; handle known cases and fall through
with a comment.
Nominal string types via a unique-symbol brand (zero runtime cost):
an AgentId can no longer be passed where a CallId is expected. Each
core package brands the IDs it owns — CallId in dsh-llm (tool-call
correlation across blocks, chunks, session events, and execution
results), SessionId in dsh-session, AgentId in dsh-agent. Construction
goes through same-named factory functions; public string-in APIs
(sessions.create, agentLoop.create) keep accepting plain strings and
brand internally. Policy note in the brand module: brand IDs that
cross package boundaries, not every string.
GitHub Actions on push/PR: immutable install, constraints, lint,
typecheck (src + tests + examples), tests with the per-file 100%
coverage gate, knip + publint, full build, and a demo smoke test that
drives the echo-agent over scripted stdin asserting the tool-call
round-trip and the JSONL session dump — the same commands the local
scripts and git hooks run.
pre-commit: ESLint --fix on staged files (vendored source excluded),
incremental typecheck, and the vendor-manifest guard — any staged
change under vendor/*/src must be accompanied by a vendor/README.md
update in the same commit, mechanizing the local-modification log
discipline. pre-push: tests + hygiene (knip/publint/constraints).
Hooks call the same package.json scripts CI runs (single source of
truth); installed automatically via postinstall.
Flat config with two layers. Correctness (type-checked): the headline
rules for this codebase are no-floating-promises / no-misused-promises
(a lost promise in the agent loop is our primary bug class),
switch-exhaustiveness-check (we switch over merge-extensible unions
everywhere), no-unnecessary-condition, require-await, and
no-explicit-any. Style (@stylistic): 2-space, no semicolons, single
quotes, trailing commas, max-len 140 — the existing house style, now
enforced instead of drifting between agents. vendor/ is excluded
(vendored source keeps upstream style); tests relax the rules that
fight test ergonomics (non-null assertions after expects, async mock
signatures, non-Error throws).
Code adjusted to pass: registry disposers wrap ctx.effect's
promise-returning disposer behind a sync () => void (our public API),
BlockAssembler gains an invariant-checking mustGet instead of non-null
assertions, lastTurnNumber uses findLast, waterfall tails return
Promise.resolve instead of async-without-await arrows, and the two
deliberate suppressions (non-exhaustive derivation switch, unbound
execute pass-through) carry justification comments.
yarn lint / yarn lint:fix added.
tsconfig.base.json adds noUncheckedIndexedAccess,
exactOptionalPropertyTypes, noImplicitOverride,
noFallthroughCasesInSwitch, noUnusedLocals, and noUnusedParameters on
top of strict. Vendored packages opt out of the new flags locally
(their tsconfigs are ours to regenerate; their source is not), keeping
upstream-sync friendliness.
Our code fixed accordingly: index accesses acknowledge undefined
(assembler flush cursors, lastTurnNumber); optional properties are
omitted instead of set-to-undefined (GenerateResult.usage,
ToolDefinition.strict, GenerateOptions.system/tools, error payloads
via an errorData helper); Session.onAppend is explicitly
`(…) => void | undefined`; tests and examples updated for unused
parameters and indexed access.
The architecture cookbook's tool-plugin example now uses defineTool
with typed args (the raw-JSON-Schema + `args: any` example contradicted
the type-safety policy it sits next to); a note explains raw schemas
remain the MCP interop path. The echo-agent README's mock-llm row now
matches the code (registerAdapter(['mock-echo'])) and the echo-tool
row mentions the typed registration.
InferArgs now produces genuinely optional keys: required/optional
properties are split at the key level (RequiredKeys + mapped `?`), so
{ limit: { type: 'number' } } infers as { limit?: number } and callers
can omit it — previously the key stayed required with `| undefined`.
Array item inference recurses (arrays of objects infer their element
shape instead of Record<string, unknown>), matching the generated
JSON Schema.
Tool execution error reporting handles non-Error throws again:
`throw { message: 'denied' }` reports the message instead of
"[object Object]" (errorMessage helper).
The new schema tests now actually typecheck: schema literals use
`satisfies SchemaSpec` (the standalone-literal widening made
schemaSpecToJsonSchema reject the suite's own examples), and the
ToolSchema probe cast goes through unknown. Tests-and-examples
typechecking is now part of `yarn typecheck` via the new
tsconfig.typecheck.json (resolves vendor packages by their built
declarations, so vendor's relaxed-strictness source stays out of
scope) — vitest never typechecks, so this gate is what catches such
breakage. +4 regression tests (typed omission, array-of-objects
inference both type- and runtime-level, non-Error throw message).
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
High (loop pipeline): agent/step-result now runs before the
assistant/message append so the session log records what tool dispatch
actually uses; abort is honored between tool calls, not just
mid-stream; steering drains at step start, pending steering overrides
a negative turn-continuation decision (/goal pattern), and leftover
steering is re-enqueued as queued messages so it is never stranded;
exceptions from turn-continuation listeners and session/flush are
contained to the turn (error event + agent/error) instead of killing
the driver loop.
Medium: disposal emits agent/status('disposed') and mid-turn disposal
records reason 'disposed'; duplicate LLM adapter registration throws
(all-or-nothing); SessionEvent is a real discriminated union (casts
removed); model-less agents fail with a clear actionable error unless
agent/request supplies a model.
Low: agent/queued and agent/steering carry the resolved MessageSource;
streamBlocks() yields strictly in stream order and flushes delta-only
blocks (matches generate()); BlockAssembler freezes blocks on
block-end and ignores stragglers from malformed streams; turn
numbering is a counter seeded from the log (fork-safe); LoopAgent's
stop disposer is infallible (a throwing status listener cannot skip
registry cleanup); AgentLoop.create uses a generator effect so stop
and unregister are independent disposables; SessionStore wires
onAppend inside its effect.
21 regression tests added (review-fixes.spec.ts), organized by
finding. Docs updated: loop pseudocode (status emissions, ordering,
error containment, steering guarantees) and waterfall composition
caveat in docs/architecture.md; AGENTS.md notes that excessive tests
are welcome.
docs/architecture.md: layering, service map, event taxonomy, the
session/turn/step lifecycle, Cordis waterfall semantics, an extension
cookbook, the plugin sanity checklist mapping every MVP feature to its
extension mechanism, and the deferred-work TODO list (sub-agents,
persistence backends, compaction, DeepSeek V4 adapter, parallel tool
execution, streaming-protocol review).
AGENTS.md: repo layout, commands, conventions (dsh-* naming, ESM,
effect-based registrations, declaration merging, waterfall semantics),
and the vendoring policy pointer.
cordis.yml-wired demo proving the full stack end to end: mock-echo
LlmAdapter (streams text; calls the echo tool on "echo <text>"),
echo tool, stdio chat UI plugin (consumes only the agent/* taxonomy),
and a JSONL persistence plugin demonstrating the write-behind +
session/flush checkpoint pattern. Runs unbuilt via tsx with loader,
include, and HMR live-reload all active (yarn demo).
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.