Every packages/*/* README now carries a canonical '## Known Limitations and
Deferred Work' section: condensed, evidence-backed bullets for consumer-visible
gaps (unimplemented features, platform caveats, MVP cuts) and consciously
postponed work (TODO/FIXME/XXX markers, RFC deferrals still open). The ten
pre-existing ad-hoc variants ('What is NOT here (TODO)', 'Deferred',
'Limitations (MVP)', 'Known limitations (tracked TODOs)', ...) are normalized
into the canonical heading.
A new doc-sync gate, scripts/verify-readme-limitations.ts, enforces the shape:
exactly one limitations-like heading per package README, byte-equal to the
canonical h2, with at least one bullet; near-miss headings fail so variants
cannot creep back. Packages with genuinely nothing to declare (dsh-brand,
dsh-timeout, dsh-subagent-mock, dsh-app-boot) are whitelisted in the script and
must NOT carry the section; whitelist entries are validated against the scanned
package set so a rename fails loud.
Wired into the doc-sync chain (package.json) and the run-gates doc-sync leaf
set; the standing rule lands in packages/AGENTS.md and the adding-a-package
cookbook; decision record in
docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md
(RFC index regenerated).
Also fixes two stale '(deferred)' markers claiming dsh-compact-basic is
unimplemented (the dsh-compact seam README's package table and the seam's
module doc comment).
dsh-session
Event-sourced session log and in-memory store. A Session is the append-only source of truth for an agent's whole interaction history — the LLM message history is derived from it. A surface layer (a linked list of message-producing events) is maintained on top of the raw log for efficient derivation and compaction.
Service: SessionStore (ctx key: sessions)
Creates and holds event-sourced Session instances. Persistence is intentionally not implemented here — plugins subscribe to session/event and flush on session/flush.
Public API
ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session— Create a session.options.seedreplays/forks an existing event log;options.metaattaches creation metadata (validated absolutecwd,parentSessionlineage, seed boundary) as the immutableSessionHeader. The store fillsversion/idand defaultscreatedAtto now; a caller reconstructing a persisted session passes the originalcreatedAtand persistedseedLengthto preserve them. Disposed with the calling fiber.ctx.sessions.flush(session: Session): Promise<void>Dispatch the awaitedsession/flushdurability checkpoint with the carrier captured at enter — THE flush entry point (the loop's turn-end checkpoint and idle injection call it; never dispatch a rawctx.parallel). Rejects a prepared, detached, or stale same-id object instead of inventing a subject-less carrier.ctx.sessions.fork(source, boundary?, childSessionId?): Session— Resolve a live session object or id, select a seed through the inclusiveboundaryevent seq (default: current last event), require that boundary to beturn/end, and create a live child session with lineage metadata.ctx.sessions.get(id: SessionId): Session | undefinedctx.sessions.list(): Session[]
Advanced: ordered-teardown lifecycle primitives
create() covers the common case (the session is owned by the calling fiber). When a session must be torn down in order with another resource — so a final flush is captured before onAppend detaches — create()'s self-contained effect is wrong, because a fiber unload disposes sibling effects concurrently. For that, split the lifecycle and fold it into the owner's single effect:
ctx.sessions.prepare(id?, options?): Session— validate the id/cwd and construct theSession, WITHOUT entering it into the store. Same options ascreate.ctx.sessions.enter(session): () => void— wireonAppend→session/event, capture its scope carrier, and add the session to the store; returns the idempotent DETACH disposer, which clears both notification and carrier state. Does NOT emitsession/created(the caller installs the disposer first, then callsannounce, so a throwing listener rolls the attach back). It re-checks the id because publicprepare/entercalls may be interleaved; a stale prepared object must not overwrite a live same-id session.ctx.sessions.announce(session): void— emitsession/createdfor an entered session.
dsh-agent-loop is the canonical consumer: after unpublished agent setup it enters both session and agent before announcing either, then nests loop stop, agent removal, session detach, and scope unwind in one ordered lifecycle. The final flush therefore settles before this package detaches the session, whether teardown starts from an AgentHandle or owner-fiber unload.
Live service events
The store announces creation, publishes each append, and provides an awaited durability checkpoint. Exact session/* signatures, modes, and scope-carrier behavior live in the generated Cordis event catalog; the append-only payload vocabulary is separately generated into the persistence catalog. Persistence consumers write behind from the append notification and drain on the store-owned flush entry point rather than dispatching the event directly.
Class: Session
Plain class (not a Cordis Service). Create via ctx.sessions.create().
session.append(type, data, opts?): SessionEvent— synchronous, never blocks on I/O. Throws ifdatais not losslessly JSON-serializable (BigInt, function, symbol, undefined, non-finite number, circular ref, or an exotic object like Map/Set/Date) — the event log is the durable source of truth, so this invariant is enforced at the source (exported asisJsonValuefor backends to reuse on their replay/fork entry points). A third parameteropts: SurfaceIntentcarries surface metadata:surfaceOpcontrols how the event enters the surface linked list, andsourceEventSeqsrecords provenance (the seq numbers of events this one derives from). It is required for the fiveSurfaceEventTypeevents (every message-producing event must declare how it joins the surface) and rejected by the compiler for non-surface types. The marker requirement is enforced two ways: the typed overload makesoptsmandatory whentypeis a specificSurfaceEventTypeliteral, ANDappendthrows at runtime if a surface-eligible event arrives with nosurfaceOp— covering the case wheretypewidens to theSessionEventTypeunion (a caller iterating raw events, where the conditional overload collapses to optional) so a marker-less message event can never silently land in the log and vanish fromderiveMessages().session.deriveMessages(): Message[]— the LLM message history, CACHED: each surface node is projected exactly once, when first seen (O(new nodes) per call; a surface rewrite rebuilds viasurface.replaceGeneration). Returns a fresh array snapshot per call over SHARED, deep-frozenMessageobjects — cloned once off the log at projection time, so a consumer can never mutate logged data (mutation throws). The surface is the single source of derived history — there is no raw-log fallback.session.deriveEventMessage(event): Message | null— the per-event projectionderiveMessages()folds: one event's derived message (an unfrozen clone), ornullwhen it produces none (a non-surface event, or an empty-contentassistant/messagehosting only usage). External reconstructors and the dev invariant fold the same function over a log prefix's surface, so no two paths can disagree about what a request's messages were (the reconstructability RFC).session.surface: SurfaceManager— the derived surface, lazily rebuilt fromsurfaceOpmarkers in the log. Processes only new events (delta) on each access — the log is append-only, so prior events never change.surface.replaceGenerationis the rewrite signal: bumped by every foldedreplaceand byinvalidate(), never reset, so an incremental consumer comparing generations cannot be fooled.session.events,session.seq,session.idsession.header: SessionHeader— immutable creation metadata (version,id,createdAt, optionalcwd/parentSession/seedLength). Kept out of the event log (a storage concern, not replayable state); a minimal header (stamped with the currentSESSION_FORMAT_VERSION) is synthesized for bareSessionconstruction.
Surface types
SurfaceOp— how a surface node entered the linked list:'append'(normal tail append) or{ op: 'replace', start, end }(replace nodes fromstartthroughendinclusive — both must be valid surface node seqs;start === endreplaces a single node). Used by compaction to shadow old nodes without deleting them.SurfaceIntent—{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }, the required third parameter tosession.append()for surface-eligible types.SurfaceNode—{ seq: number; prev: number | null; next: number | null }, one node in the surface linked list.isSurfaceEvent(event)/isSurfaceEligibleType(type)— the first narrows aSessionEventto a fully-formed surface node (type is surface-eligible ANDsurfaceOppresent); the second is the type-only check (is this one of the fiveSurfaceEventTypevalues?), used to detect a surface-eligible event MISSING its marker — e.g. when validating a seed/load log.
Request-header reconstruction (request-header.ts)
The request/header (full EpochHeader snapshot with a RequestHeaderReason) and request/header-delta (system line-trim / name-keyed tools delta / whole config / whole session prefix) events make the request envelope logged session state, so every conversation request is a pure function of the log. The pure trio reconstructs it: foldRequestHeader(events) folds a log (or any prefix) into the header in force; diffHeader(prev, next) encodes a change (undefined when equal); applyHeaderDelta(prev, delta) replays one. Writer contract: every logged delta is round-trip-verified (apply(prev, delta) deep-equals the new header) with a 'fallback' snapshot when the encoding cannot express the change (a pure tool reordering), so folding never needs error recovery on a well-formed log. canonicalHeader pins the one representation of absence (empty system/tools/messagePrefix ≡ absent fields; a delta's EMPTY prefix array encodes the transition back to absence). EpochHeader.messagePrefix is the durable record of the agent/session-prefix waterfall's product — composed once per loop instance, the request is messagePrefix + derived history, and deriveMessages() never returns it.
Session event vocabulary (types.ts)
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated persistence log event catalog. Token usage rides on assistant/message.usage; an operational error's step is on turn/end.reason for kind: 'error'.
Merge-extensible via SessionEventMap — a plugin declaration-merges its own types (the compaction seam's compact/*, the hook bridges' hook/*); merged members appear in the same catalog.
Also defines TurnTriggerMap and TurnEndReasonMap (merge-extensible sum types for typed turn boundaries — kind-tagged instead of strings).
Every SessionEvent carries two optional top-level fields (structural metadata):
sourceEventSeqs?: number[]— seq numbers of provenance sources (e.g., theassistant/chunkseqs behind anassistant/message, or the shadowed nodes behind a compaction replace node).surfaceOp?: SurfaceOp— how this event entered the surface. Absent for non-surface events (boundaries, chunks, usage, errors).
Metadata types (types.ts)
SessionHeader— immutable session metadata, written once:{ version, id, createdAt, cwd?, parentSession?, seedLength? }. Owned here (besideSessionId) becauseSession.headeris typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
Extension points
- Persistence plugins: subscribe to
session/event(write-behind) and drain onsession/flush(awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (SessionHeader,session.header) is what such a backend stores beside the log. - Replay/fork:
ctx.sessions.create(id, { seed })seeds a new session with an existing event log. The surface rebuilds deterministically fromsurfaceOpmarkers in the seeded events. The seed is validated to the SAME always-on invariantsappendenforces — contiguous seqs, JSON-serializable data, and requiredsurfaceOpmarkers on surface-eligible events — so marker-less message events are rejected at construction rather than silently vanishing fromderiveMessages(). Broader turn-enclosure checks stay indsh-invariantsand persistence repair. Ordinary live-session forks usectx.sessions.fork(source, boundary?, childSessionId?), whereboundaryis the inclusive source event seq to fork through. - Compaction: the
dsh-compact-basicplugin appends auser/messagewithsurfaceOp: { op: 'replace', start, end }to shadow old surface nodes behind a summary checkpoint.
Known Limitations and Deferred Work
- Session branching/tree (pi-style entry tree) — deferred unless needed beyond boundary-based
fork(). fork()cuts only at closed-turn boundaries of live sessions — the boundary must be aturn/endevent and the source must be in the store; forking a persisted-but-unloaded session is excluded from the fork API.SESSION_FORMAT_VERSIONstays pinned at0— pre-release, no compatibility implied: a backend rejects any other version, and no migration path exists until the first release (policy).TurnEndReasonMapomits the ACP-namedrefusal/max_turn_requestsvariants — producer-gated: they land when an adapter or the loop first emits them.