The Session System
One-liner: a session is event-sourced:
Sessionis 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 ofSessionEvents. 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)
surfaceOpmarks how an event enters the surface;append/replace
Two reading conventions, don't mix them:
| who reads | which one | |
|---|---|---|
| model (request context) | surface()/deriveMessages() | surface (what it sees is the replaced face) |
| human transcript (debug/replay) | append-origin events | original 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.
| API | Contract |
|---|---|
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
| Method | Contract |
|---|---|
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.events | cached frozen snapshot (invalidated on append); accepted events stay deeply frozen |
session.seq / session.id | current sequence number / readonly identifier |
session.header | detached, deeply frozen creation metadata |
session.surface | readonly surface view |
Header vs event separation
| content | replayable | |
|---|---|---|
SessionHeader | version, id, createdAt, optional cwd/parentSession/seedLength/delegationDepth | creation metadata, detached + deeply frozen at write, immutable at runtime |
SessionEvent | replayable conversation state | replayable |
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
adapterDefaultsmap: marks the effectivereasoningEffort/maxTokensas 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 removedfallbackreason 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)
| Field | Meaning |
|---|---|
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?: SurfaceOp | how the event enters the surface; absent on non-surface events (boundary/chunk/usage/error) |
ignorable?: true | an 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.tsand the generated persistence catalog SessionEventMapis declaratively merged: plugins usedeclare moduleto add their own types (compaction/*, the hook bridge'shook/*, etc.), merging members into the same catalog- For a plugin to have its own persisted facts:
session.appendthenawait 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:
| kind | timing |
|---|---|
aborted | a live turn was interrupted, reason: AgentCancelCause (keeps the typed cancel reason). Older formats import as { kind:'aborted', reason:{ kind:'legacy' } } |
error | the turn failed, { kind:'error', error } |
interrupted | synthesized 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 predicatesnapshotJsonValue(value): one-pass iterative validation and copy; rejects cycles, unsupported scalars, exotic prototypes; accepts finite JSON numbers (but-0is rewritten to0); no call-stack depth limitsnapshotSessionEvent(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:
| Config | Default | Effect |
|---|---|---|
root | :(required, no default) | session log root directory, usually $DSH_HOME/sessions |
packChunks | true | pack chunk lines, about a 60% slimming |
compression | zstd | zstd (compressed by default) or none (plain-text JSONL) |
preparedSessionCacheSize | : | cold-read LRU cache size |
writeBatchMaxDelayMs | 200 | write-coalescing window (write coordinator) |
Disk layout:
~/.dsh/sessions/--<normalized-cwd>--/<encoded-id>/session.jsonl.zstd
- zstd-compressed by default; to
head/jqdirectly, configurecompression: 'none'orzstdcatfirst - two-level dirs:
--<cwd>--(workspace) +<encoded-id>(session) packChunksmakes 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.
| Config | Default | Effect |
|---|---|---|
path | none (required) | SQLite database file path, or :memory: for an in-process database |
journalMode | wal | the journal_mode pragma |
preparedSessionCacheSize | 5 | cold-read cache |
writeBatchMaxDelayMs | 200 | write-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:
| Boundary | Guarantee |
|---|---|
| before the model adapter receives a request | the request's buffered events are already durable |
| before a top-level tool body can have external side effects | the recorded calls are durable before the body runs |
every agent/pre-step | the 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
vernot matching the live unit'sstateVersion→ 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:
| Trigger | Nature |
|---|---|
turn/end | forced (a cold read wants the turn-final value) |
| session disposal (detach) | forced (the live→cold moment) |
every writeEveryEvents committed events | configured throttle (count) |
writeIntervalMs after the first dirty event | configured 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 readFrom → restore → 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:
| Package | Form | cadence | Mount |
|---|---|---|---|
session-title-llm | library (not a cordis plugin) | shared implementation strategy; providers call registerSessionTitleLlmProvider() to register | — |
session-title-first-prompt-llm | plugin | first-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() retries | mounted by default |
session-title-all-prompts-llm | plugin | all-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 work | opt-in |
Shared config (all required except a route override; no library-level default):
| Key | Contract |
|---|---|
targetWords | target word count for non-CJK titles |
targetCjkCharacters | target character count for CJK titles |
maxInputBytes | UTF-8 byte ceiling for the final JSON-framed user prompt |
maxOutputTokens | auxiliary generation token ceiling |
timeoutMs | end-to-end deadline |
provider,model | optional 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:
| mode | behavior |
|---|---|
FULL | default. every projected record (including lifecycle ops) goes to the OTel SDK immediately |
FEEDBACK_ONLY | every feedback/record replays, projects, and redacts the canonical log suffix; subsequent records wait for the next feedback, staying local otherwise |
DISABLED | no pipeline is constructed, no record leaves the process; feedback stays in the local session log |
It composes the OTel JS SDK as-is (LoggerProvider → BatchLogRecordProcessor → 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
| Scenario | Policy |
|---|---|
| torn tail fragment | drop it |
| committed corrupted / misformatted | reject 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
- Agent Main Loop: how the session is driven inside the loop
- Event System: the event vocabulary and extension points
- Model Routing: how a session picks a model
- Web UI: how surface/projection is consumed by the frontend
- Data & Privacy: what telemetry leaves the machine and how to turn it off