Skip to main content
PathDocs

The Session System

One-liner: a session is event-sourced: Session is the append-only single source of truth for the conversation history, and the model's LLM message history is derived from it; persistence, projection, and telemetry are all built around the same string of SessionEvents. No parallel "persisted message" type exists.

This is the core of understanding "how a conversation is recorded, recovered, and displayed". After reading it you can answer: what exact shape a message is stored as in the log, what the relationship between surface and the raw log is, and how recovery after a crash works.

1. Event sourcing: the log is the single source of truth

The core mental model: a Session stores an immutable event stream, not a "list of messages".

SessionEvent (append-only event stream)
├── session-persistence store / reload / list (JSONL or SQLite backend)
├── session-projection derived view (cached)
├── session-telemetry telemetry export (OTel)
└── session-title title generation

the "LLM message history" the model sees = **derived** from this event stream (the surface layer)

From the source verbatim:

Event-sourcing model: the log is the single source of truth, therefore no parallel 『persisted message』 type exists.

This boundary is the foundation of everything: whatever you want to "add information" to a session, it's either a new event type (append) or a derived view (surface/projection) — not a separate message table.

2. surface: the derived layer for messages

The raw log doesn't only hold "messages"; it also has boundaries, chunks, usage, errors, and other lifecycle events. The model needs an ordered message projection — this is surface, an ordered projection layer over the raw log.

  • surface only projects "message-producing" events: accepted user messages, assistant messages, tool calls and results, injected context, etc.
  • raw chunks, lifecycle boundaries, errors, etc. are excluded from surface (but stay in the log)
  • surfaceOp marks how an event enters the surface; append / replace

Two reading conventions, don't mix them:

who readswhich one
model (request context)surface()/deriveMessages()surface (what it sees is the replaced face)
human transcript (debug/replay)append-origin eventsoriginal events (a landed replacement has shadowed the old history)

One rule of thumb: to make "the model see a summarized version", land a surface replace; to see "what actually happened", look at the raw log events.

3. SessionStore: ctx.sessions

ctx.sessions creates and holds the event-sourced Session instances. Persistence is not what it implements: plugins subscribe to session/event, flush at session/flush, and may mirror the session/created/session/disposed lifecycle.

APIContract
create(id?, { seed?, meta? }?)validate and detach the persistent seed/header, fill version/id, createdAt defaults to now, publish and bind to the calling fiber
flush(session)publish the awaited parallel persistence checkpoint; objects that weren't published / already detached / stale are rejected
fork(source, boundary?, childSessionId?)resolve the session, select a seed (default: the current last event seq), require the prefix to end outside a turn, create a live child session with lineage metadata
get(id)get it or undefined
list()list them

The split lifecycle (only when teardown must be ordered against other resources)

For most cases create() is enough; but when teardown must be ordered with other resources, use the three-phase form:

prepare(id?, opts?) // validate and construct, don't publish
enter(session) // collision check + publish (no announce), returns entry-bound idempotent detach
// concurrent same-id may all prepare, but only one enter succeeds; stale detach can't delete the replacement
announce(session) // emit the single creation edge; duplicate/reentrant announce rejected

dsh-agent-loop uses this split so the last loop flush happens before the session detach.

4. The Session class

Note: Session is a plain class, not a Cordis Service. Live sessions go through ctx.sessions.create(); detached replay/inspect sessions use Session.create() (the latter emits no lifecycle events and binds no fiber).

Key methods

MethodContract
session.append(type, data, opts?)snapshot and freeze the persisted data and surface metadata, validate the marker shape, referenced source-event seqs, complete-replace coverage, single-result tool/result rewrites; notify observers synchronously after commit (isolated failure). reentrant append to a session being attached is rejected
session.deriveMessages()incrementally projects every new surface entry, returns a frozen message array
session.eventscached frozen snapshot (invalidated on append); accepted events stay deeply frozen
session.seq / session.idcurrent sequence number / readonly identifier
session.headerdetached, deeply frozen creation metadata
session.surfacereadonly surface view

Header vs event separation

contentreplayable
SessionHeaderversion, id, createdAt, optional cwd/parentSession/seedLength/delegationDepthcreation metadata, detached + deeply frozen at write, immutable at runtime
SessionEventreplayable conversation statereplayable

5. Request-header reconstruction (request/header)

request/header records a complete canonical snapshot of one request (not a historical request envelope), with a three-valued reason: initial / resume / change.

  • An optional adapterDefaults map: marks the effective reasoningEffort/maxTokens as values materialized by exact model resolution, so a next request proposal can distinguish them from explicit session settings: this is the persistence landing point of the "adapter-default marker" in Agent Main Loop
  • foldRequestHeader() picks the most recent snapshot; legacy delta events and the removed fallback reason are rejected

user/message stores the full UserMessage (identity is created even before inbox routing / step entry); its content is rendered as-is, and source is the only channel distinguishing "human prompt / synthetic injection / an incoming goal round".

6. Event envelope fields (present on any SessionEvent)

FieldMeaning
sourceEventSeqs?: number[]referenced source-event seqs (e.g. the chunk seqs behind assistant/message; the shadowed entries behind a compaction replacement). [] on assistant/message = known-empty stream; must be non-empty when present on other surface events
surfaceOp?: SurfaceOphow the event enters the surface; absent on non-surface events (boundary/chunk/usage/error)
ignorable?: truean unrecognized type can be safely skipped when reading; absent = required, and unknown types reject session reconstruction

Event vocabulary and extension

  • The full catalog lives in known-event-types.ts and the generated persistence catalog
  • SessionEventMap is declaratively merged: plugins use declare module to add their own types (compaction/*, the hook bridge's hook/*, etc.), merging members into the same catalog
  • For a plugin to have its own persisted facts: session.append then await ctx.sessions.flush(session) — don't fake an execution turn

7. Crash recovery and turn-end reasons

turn/start carries only the turn number; the user/message batches that follow record inputs, llm/retry records request recovery. turn/end's TurnEndReasonMap is a kind-tagged union:

kindtiming
aborteda live turn was interrupted, reason: AgentCancelCause (keeps the typed cancel reason). Older formats import as { kind:'aborted', reason:{ kind:'legacy' } }
errorthe turn failed, { kind:'error', error }
interruptedsynthesized only for crash recovery (no other evidence found)

Crash recovery: a cold load uses synthesized events to close interrupted turns: so a recovered session never has a "dangling turn".

8. Event-sourced validation: snapshot and immutability

Persisted values must be "accepted in one pass", not "check once then read again":

  • isJsonValue(value): boolean predicate
  • snapshotJsonValue(value): one-pass iterative validation and copy; rejects cycles, unsupported scalars, exotic prototypes; accepts finite JSON numbers (but -0 is rewritten to 0); no call-stack depth limit
  • snapshotSessionEvent(event) / adoptSessionEvent(event): clone a borrowed one / take sole ownership of a mutable one in place (request-header)

9. Persistence backend: JSONL

session-persistence-jsonl's engine config determines the storage shape:

ConfigDefaultEffect
root:(required, no default)session log root directory, usually $DSH_HOME/sessions
packChunkstruepack chunk lines, about a 60% slimming
compressionzstdzstd (compressed by default) or none (plain-text JSONL)
preparedSessionCacheSize:cold-read LRU cache size
writeBatchMaxDelayMs200write-coalescing window (write coordinator)

Disk layout:

~/.dsh/sessions/--<normalized-cwd>--/<encoded-id>/session.jsonl.zstd
  • zstd-compressed by default; to head/jq directly, configure compression: 'none' or zstdcat first
  • two-level dirs: --<cwd>-- (workspace) + <encoded-id> (session)
  • packChunks makes fewer lines (packs chunks); the two backends are swappable

10. Persistence backend: SQLite (opt-in)

session-persistence-sqlite is the second SessionPersistence provider, satisfying the same contract as JSONL (append-only, consecutive seq, lazy materialization, interrupted turns closed on load), only landed on node:sqlite rows instead of file bytes.

Mount status: opt-in — the default composition mounts JSONL (see previous section); the SQLite backend loads only when explicitly configured.

Storage model: each SessionEvent maps 1:1 to one events table row (session_id, seq, type, time, data, source_event_seqs, surface_op); data is the event's JSON text, so the row is the verbatim form of the event (including assistant/chunk, consecutive seq). The source_event_seqs and surface_op columns are nullable and hold the surface metadata; SessionHeader, the materialized incarnation id, and per-log revision live in the sessions row. The database defaults to wal journal mode, PRAGMA application_id identifies the database, PRAGMA user_version stores the layout version; initialization creates all tables and sets both pragmas in one transaction. On POSIX the default directory is 0700 and the database file 0600 (the database is created first, then handed to SQLite to open); a new sidecar inherits owner-only permissions.

ConfigDefaultEffect
pathnone (required)SQLite database file path, or :memory: for an in-process database
journalModewalthe journal_mode pragma
preparedSessionCacheSize5cold-read cache
writeBatchMaxDelayMs200write-coalescing window

Key semantics: an append is one transaction (BEGIN/COMMIT); a mid-batch failure rolls back wholesale; create materializes lazily (the sessions row is only written on the first append, so a session that never appends after create isn't in list()); load closes interrupted turns per the shared crash-recovery contract; locate(meta) returns undefined (all sessions share one database; there is no per-session transcript path).

Known limits: DatabaseSync is synchronous (each append transaction blocks the event loop), write contention has no wait/retry, only pristine new databases or the current layout version are opened, and sessions are never deleted.

11. When to persist: checkpoint-policy

session-checkpoint-policy is a zero-config semantic persistence policy that decides "when it must hit disk". It consumes ctx.sessions/ctx.llm/ctx.tools and takes effect when ctx.sessionPersistence exists (pairs with either persistence backend).

It checkpoints at three boundaries:

BoundaryGuarantee
before the model adapter receives a requestthe request's buffered events are already durable
before a top-level tool body can have external side effectsthe recorded calls are durable before the body runs
every agent/pre-stepthe previous response and ordered tool results are durable before the next request

Persistence and checkpoint scheduling are two deliberately separated plugins: the persistence backend runs bounded background batched appends and turns every session/flush into an immediate quiescence barrier; this policy only selects the request / tool-dispatch / next-step three barriers. Mounting only the backend without this policy is legal, but a crash can lose writes within the batching window or writes that haven't completed. A checkpoint rejection is fail-closed: if the model and tool boundaries reject, the adapter / tool body doesn't run; a step-boundary rejection fails the turn. Concurrent tool checkpoints share the session store's serial drain, so seqs are never duplicated.

Mount status: mounted by default (together with the JSONL backend).

12. Projection persistence cache: projection-cache

session-projection-cache provides ctx.sessionProjectionCache: a durable checkpoint of each registered projection unit's state, one per session, in the domain data form (session_projcache domain; with a json backend it sits beside workspace.json under the configured storage root).

One stored row (key → {ver, seq, val}) is a fold shortcut, not authoritative: it may be stale (seq states exactly how stale), but never wrong. The promises that follow:

  • every background write is fail-soft: on failure it logs a warning and stays stale, self-healing on the next write or cold read; a crash only costs a tail replay, never a wrong value
  • ver not matching the live unit's stateVersion → dropped at read, no migration, the key is re-folded from the log
  • whole-record write: each write replaces the session's complete checkpoint, snapshotted across a lossless-JSON boundary; unit state that isn't plain-JSON fails loud
  • record binds to the log's lifecycle (storing the header's createdAt/cwd), validated on every read; a deleted-and-recreated id or a swapped store discards irrelevant records rather than planting phantom values
  • log first, cache follows: a live checkpoint flushes the buffered events durable before landing the cache row; a crash can only leave the cache behind the log, never ahead of it

The write policy is two forced points plus two configured throttles:

TriggerNature
turn/endforced (a cold read wants the turn-final value)
session disposal (detach)forced (the live→cold moment)
every writeEveryEvents committed eventsconfigured throttle (count)
writeIntervalMs after the first dirty eventconfigured throttle (interval)

Both writeEveryEvents / writeIntervalMs are required with no default. Read ladder: zero-I/O cachedSnapshot(meta) (reads a stored record only on identity + version match) and coldSnapshot(id) (cache → restoreFloor → persistence readFromrestore → fail-soft write-back).

Mount status: mounted by default (in the Web composition writeEveryEvents: 200, writeIntervalMs: 5000; without it the projection system only runs live-only).

13. Session title: the trio

The session title comes from the ctx.sessionTitle seam; the model-backed title providers share one implementation strategy (the session-title-llm library), and what's actually optional are the two provider plugins:

PackageFormcadenceMount
session-title-llmlibrary (not a cordis plugin)shared implementation strategy; providers call registerSessionTitleLlmProvider() to register
session-title-first-prompt-llmpluginfirst-prompt: summarizes the first qualifying user prompt, runs automatically only on the initial fallback of a fresh non-forked session; an automatic failure keeps the fallback, only ctx.sessionTitle.refresh() retriesmounted by default
session-title-all-prompts-llmpluginall-prompts: a new revision starts after every new human prompt (including seeded history and child-session prompts); a new revision aborts and supersedes the old workopt-in

Shared config (all required except a route override; no library-level default):

KeyContract
targetWordstarget word count for non-CJK titles
targetCjkCharacterstarget character count for CJK titles
maxInputBytesUTF-8 byte ceiling for the final JSON-framed user prompt
maxOutputTokensauxiliary generation token ceiling
timeoutMsend-to-end deadline
provider,modeloptional explicit route; either both or neither

The route and failure contract: with the provider/model pair unset it uses the exact route recorded in the current session's request/header; an explicit refresh when there's no route yet requires an override. The JSON-framed user prompt (with seq field, wrapping, escaping) is validated before dispatch against maxInputBytes, rejected rather than truncated on overflow; malformed/empty output, tool calls, and non-stop finish reasons are all rejected. Before dispatch it directly appends a log-only session/title-llm-request event through the Session (with provider id, exact source seqs, route, system prompt, message list, output-token cap), persisted via eager observation.

The title request is completely separate from the main conversation: the main agent request costs zero extra tokens; the title purpose maps to thinking-disabled (DeepSeek adapter), while the main conversation keeps its configured thinking mode.

14. Telemetry: session-telemetry-otel

session-telemetry-otel is the only mounting entry point of the telemetry seam (OpenTelemetry backend); mode decides whether it follows session events, replays only on recorded feedback, or stays local:

modebehavior
FULLdefault. every projected record (including lifecycle ops) goes to the OTel SDK immediately
FEEDBACK_ONLYevery feedback/record replays, projects, and redacts the canonical log suffix; subsequent records wait for the next feedback, staying local otherwise
DISABLEDno pipeline is constructed, no record leaves the process; feedback stays in the local session log

It composes the OTel JS SDK as-is (LoggerProviderBatchLogRecordProcessor → OTLP/HTTP log exporter), mapping every record to logger.emit(), over two instrumentation scopes (ledger / ops). The resource identity carries service.name/service.version (from dsh-llm's APP_IDENTITY) plus an anonymous user.id ($DSH_HOME/.anonymous-user-id, a random UUID).

Upload authorization is fail-closed: an unknown mode fails before reading the transport config; only FULL accepts direct ctx.sessionTelemetry.emit(); FEEDBACK_ONLY only treats a feedback/record that already exists at session.events[event.seq] as consent; DISABLED doesn't construct the SDK pipeline even with an exporter configured. The seam discloses the mode through TelemetrySharingStatus.sharing (full/feedback-only/disabled).

What leaves the machine: in upload mode a record carries the full event.data (the telemetry seam's raw copy) — full user/assistant message text, tool arguments and results (command output, file content), system prompt, tool schemas, todo, compaction summary, hook stderrSummary, feedback text, session cwd. The seam has no redaction rules; a deployment crossing a trust boundary must mount its own session-telemetry/record redaction rules. See Data & Privacy.

Mount status: mounted by default (mode defaults to FULL; the endpoint defaults to https://harness-telemetry.deepseeksvc.com/v1/logs, overridable with DSH_TELEMETRY_OTLP_URL, disabled by any non-empty DSH_TELEMETRY_DISABLED).

15. Corruption and format policy

ScenarioPolicy
torn tail fragmentdrop it
committed corrupted / misformattedreject with SessionPersistenceCorruptionError
unknown event type (non-ignorable)reject with SessionFormatUnsupportedError
unknown event type (ignorable)skip it

16. Verification

# look at the session-related plugin mounts (mount status: persistence/checkpoint/projection-cache/title/telemetry)
dsh web --dump-config | grep -iE "persistence|checkpoint|projection-cache|session-title|telemetry-otel"

# session logs are compressed JSONL (zstd); decompress to read, one line per event
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | head -3

# type distribution (see which events one session stores)
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | jq -r .type | sort | uniq -c

# look at request-header reconstruction sources
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | grep "request/header" | head -1

# look at turn-end reasons
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | grep "turn/end" | tail

Next steps