2026-06-20 16:24:56 +08:00
# Sessions
2026-07-24 14:05:33 +08:00
English | [中文 ](session.zh.md )
2026-06-20 23:12:14 +08:00
The in-memory, event-sourced model of [dsh-session ](../../packages/core/session ). A `Session` is an **append-only log** of typed `SessionEvent` s — the single source of truth for an agent's whole interaction history. The LLM message history is *derived* from the log, never stored separately; replay is re-derivation from the same events. How the log is made **durable** (the persistence seam, backends, crash recovery) is the sibling concern on [persistence.md ](persistence.md ).
2026-06-20 16:24:56 +08:00
2026-06-20 23:12:14 +08:00
Source: [`packages/core/session/src/types.ts` ](../../packages/core/session/src/types.ts )
2026-06-20 16:24:56 +08:00
## `SessionEventMap` — the event vocabulary
2026-07-06 22:26:06 +08:00
The append-only event types. Merge-extensible: a plugin declares extra event types via declaration merging — e.g. the [compaction seam ](compaction.md ) adds `compact/start` / `compact/summary` / `compact/end` , and `@deepseek-ai/dsh-hook-protocol` adds log-only `hook/invoked` / `hook/result` provenance for a hook bridge. Like `compact/*` , these are NOT `SurfaceEventType` s (no `surfaceOp` ). The generated [persistence log event catalog ](../persistence-catalog.md ) enumerates every member — core and merged — with its payload, surface badge, and declaration site.
2026-06-20 16:24:56 +08:00
2026-07-22 17:34:31 +08:00
```ts type-equiv
2026-07-28 13:55:59 +08:00
/** A user-role specialization of the one shared message representation. */
interface UserMessage extends Message {
readonly role: 'user'
2026-07-22 17:34:31 +08:00
}
```
2026-06-20 16:24:56 +08:00
```ts type-equiv
2026-07-19 12:25:40 +08:00
/**
* The merge-extensible, append-only source of truth for an agent interaction.
* Message history is derived from this log. Every event is lossless JSON and
* sequence numbers stay contiguous, including raw chunks, so persistence can
* store the canonical log verbatim.
*/
2026-06-20 16:24:56 +08:00
interface SessionEventMap {
2026-07-19 12:25:40 +08:00
/**
2026-07-24 17:23:33 +08:00
* Opens turn `turn` . `trigger` records what started the model loop.
2026-07-19 12:25:40 +08:00
*/
2026-06-20 16:24:56 +08:00
'turn/start': { turn: number; trigger: TurnTrigger }
2026-07-19 12:25:40 +08:00
/**
* Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop
2026-07-20 11:53:49 +08:00
* awaits `session/flush` after an ordinary turn ends before claiming the next
* queued item. Success commits the turn; rejection is reported live and does
* not prevent later work.
2026-07-19 12:25:40 +08:00
*/
2026-06-20 16:24:56 +08:00
'turn/end': { turn: number; reason: TurnEndReason }
2026-07-19 12:25:40 +08:00
/** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */
2026-06-20 16:24:56 +08:00
'step/start': { turn: number; step: number }
2026-07-19 12:25:40 +08:00
/** Closes step `step` of turn `turn` . */
2026-06-20 16:24:56 +08:00
'step/end': { turn: number; step: number }
2026-07-23 19:15:45 +08:00
/**
* A user-role message on the model-visible surface: a direct human prompt
* (the queued message claimed for this turn), a synthetic `agent.inject()`
* context (file-change notices, subdir AGENTS.md, skill content, cron
* notifications, …), or an admitted goal continuation round. All three
2026-07-24 17:23:33 +08:00
* project their `content` verbatim; `source` tells them apart. An idle
* injection may append this event between turns without running the model.
2026-07-23 19:15:45 +08:00
*/
2026-07-28 13:55:59 +08:00
'user/message': UserMessage
2026-06-20 16:24:56 +08:00
/** Raw stream chunk — token-level replay fidelity. */
'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
2026-06-21 10:00:06 +08:00
/**
* Assembled assistant message for one step (derived history uses this).
* Carries the step's `usage` when the adapter reported token accounting, so
* the model output and its accounting travel together (there is no separate
* usage record). `usage` is absent when the adapter reported none.
*/
2026-07-28 13:55:59 +08:00
'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage }
2026-07-19 12:25:40 +08:00
/**
* The model requested one tool invocation: `name` with the raw `arguments`
* JSON string exactly as the model produced it (unparsed). `callId` pairs the
* call with its `tool/result` .
*/
2026-06-20 16:24:56 +08:00
'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
2026-07-19 12:25:40 +08:00
/**
2026-07-21 18:03:01 +08:00
* A completed tool call's model-facing result, optional internal failure
* identity, and optional tool-private `meta` presentation payload. `meta` is
* opaque to the core (the producing tool owns its shape and reads it back in
* `presentResult` ) but MUST be JSON-serializable: `Session.append`
* runtime-validates all event data with `isJsonValue` , so a non-serializable
* `meta` is rejected at the source, and the durable log reproduces the
* identical card on replay. Absent
2026-07-21 03:08:35 +08:00
* unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time
* contextual diff here).
2026-07-19 12:25:40 +08:00
*/
2026-07-21 03:08:35 +08:00
'tool/result': {
turn: number
step: number
2026-07-28 13:55:59 +08:00
message: ToolResultMessage
2026-07-21 18:03:01 +08:00
error?: { name: string; code: string }
2026-07-21 03:08:35 +08:00
meta?: JsonValue
}
2026-06-20 16:24:56 +08:00
/** Steering content injected between steps of a running turn. */
2026-07-28 13:55:59 +08:00
'steering/message': { turn: number; message: UserMessage }
2026-07-19 12:25:40 +08:00
/** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */
2026-06-29 01:39:25 +08:00
'todo/write': { todos: TodoItem[] }
2026-07-06 02:42:51 +08:00
/**
2026-07-19 12:25:40 +08:00
* Full header for the next request, appended inside its step before dispatch.
* It is log-only; the latest snapshot reconstructs the request header.
2026-07-06 02:42:51 +08:00
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
2026-07-30 14:48:19 +08:00
/**
2026-07-30 17:22:15 +08:00
* Registration-bound context metadata for the route a request resolved to,
2026-07-30 14:48:19 +08:00
* appended inside its step beside `request/header` and only when the route
* or capacity differs from the last record. It is log-only and deliberately
* NOT part of {@link EpochHeader}: capacity is adapter metadata about a
* route, not an input the request was built from, so it must not participate
2026-07-30 17:22:15 +08:00
* in request reconstruction or header equality. `contextWindow` is absent
* when the route's adapter advertises no capacity.
2026-07-30 14:48:19 +08:00
*/
'request/context': RequestContext
feat(session): project the inherited-history boundary into the log
A plugin owning a standalone open/close bracket cannot tell a dead marker
from a live one: an unmatched `compact/start` reads identically whether the
previous writer died mid-compaction or a compaction is running now.
`Session.firstLiveSeq` already holds that answer exactly, but only in memory.
Append the log-only `session/inherited` event at that seq from the seeded
constructor — the single waist all six seeded-start paths pass through
(resume, configured startup on a persisted id, `sessions.fork()`, a subagent
fork child, `adopt()`'s live prefix, and a bare seeded `create`). Read it
through the new `isInheritedSeq(events, seq)`.
The constructor placement means persistence needs no changes: the marker is
already in `events` when a backend captures the creation seed, so it rides
the ordinary seed path with no load-time write. It also covers fork, where
the inherited bracket's owner may still be running — the case a
persistence-layer boundary could not reach.
Activity ordering excludes the boundary through `lastActivityTime()`, since
lazy resume makes browsing a pickup and the three call sites would otherwise
float every opened session to the top of a picker or list.
2026-07-30 11:38:51 +08:00
/**
2026-07-30 15:32:06 +08:00
* Marks the end of a constructor seed. Events before it have smaller seq
* values and came from the seed (resume, fork, or replay); this lifecycle
2026-07-31 13:24:30 +08:00
* produced none of them. An explicitly supplied empty seed puts the marker
* at seq 0, distinguishing an empty resumed session from a fresh session.
* This log-only event is the durable projection of
2026-07-30 15:32:06 +08:00
* {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
* carry the meaning.
feat(session): project the inherited-history boundary into the log
A plugin owning a standalone open/close bracket cannot tell a dead marker
from a live one: an unmatched `compact/start` reads identically whether the
previous writer died mid-compaction or a compaction is running now.
`Session.firstLiveSeq` already holds that answer exactly, but only in memory.
Append the log-only `session/inherited` event at that seq from the seeded
constructor — the single waist all six seeded-start paths pass through
(resume, configured startup on a persisted id, `sessions.fork()`, a subagent
fork child, `adopt()`'s live prefix, and a bare seeded `create`). Read it
through the new `isInheritedSeq(events, seq)`.
The constructor placement means persistence needs no changes: the marker is
already in `events` when a backend captures the creation seed, so it rides
the ordinary seed path with no load-time write. It also covers fork, where
the inherited bracket's owner may still be running — the case a
persistence-layer boundary could not reach.
Activity ordering excludes the boundary through `lastActivityTime()`, since
lazy resume makes browsing a pickup and the three call sites would otherwise
float every opened session to the top of a picker or list.
2026-07-30 11:38:51 +08:00
*
2026-07-30 15:32:06 +08:00
* Locate the LAST one in stored history. A seed already ending in one is not
* re-marked, so reopening an untouched session does not grow its log per
* pickup and the event need not be at the current `firstLiveSeq` .
fix(session): close the review gaps the boundary opened
- `SessionSummary.updatedAt`'s wire doc still said "Persisted file mtime",
which stopped being true for attached sessions.
- The core invariant let `session/inherited` fall through the merge-extensible
default. It is core-owned, so it gets an explicit case; an unbalanced seed
legally places it inside an open turn, which the relation permits.
- The Agent Note claimed the boundary reaches disk via `live.pending`/
`scheduleDrain`. Verified false: the constructor append precedes `enter()`,
so it never publishes on `session/event` and rides the creation seed instead.
Attaching is therefore a write where none happened before — recorded, since
only `load()` stays a pure read.
- The deferred-index proposal asserted this change documented the cold-mtime
skew on `dsh-host-apiproxy`. It did not; the README entry now exists.
- `firstLiveSeq`'s firehose gap runs through its own seq, not below it.
- The boundary is not always at `firstLiveSeq` (the idempotence guard), so
consumers scan for the last one.
- `lastActivityTime` excludes by type, so a pickup time still leaks onto a
synthetic closer when a boundary ends an open turn. Documented.
- Pin the fork claim end-to-end: a child inherits a still-running parent's
open bracket below its own boundary, while the parent has none. Fails if the
write moves back to the load path.
- Fix the telemetry title that contradicted its own assertions.
The `/status` call site cannot be pinned the way the other two are: the
command appends its own `command/run` before rendering, so the boundary is
never the log tail there. Its fixture now at least renders over a
boundary-bearing log.
2026-07-30 13:59:08 +08:00
*
2026-07-30 14:46:38 +08:00
* `Session` 's constructor is the only legitimate writer. The invariant
* companion deliberately constrains nothing here, so a plugin appending one
2026-07-30 15:32:06 +08:00
* would silently classify every live bracket before it as seed history.
2026-07-30 14:46:38 +08:00
*
2026-07-30 12:01:45 +08:00
* An owner of a standalone open/close bracket (`compact/start` …
2026-07-30 15:32:06 +08:00
* `compact/end` ) reads it because seed history and live work are otherwise
* byte-identical: an unmatched opening marker before this event belongs to
* an ended lifecycle, whatever ended it. NOT a liveness signal about other
* writers — a concurrently live session holds its own boundary elsewhere,
* so tolerating concurrent writers needs a signal beyond the log.
feat(session): project the inherited-history boundary into the log
A plugin owning a standalone open/close bracket cannot tell a dead marker
from a live one: an unmatched `compact/start` reads identically whether the
previous writer died mid-compaction or a compaction is running now.
`Session.firstLiveSeq` already holds that answer exactly, but only in memory.
Append the log-only `session/inherited` event at that seq from the seeded
constructor — the single waist all six seeded-start paths pass through
(resume, configured startup on a persisted id, `sessions.fork()`, a subagent
fork child, `adopt()`'s live prefix, and a bare seeded `create`). Read it
through the new `isInheritedSeq(events, seq)`.
The constructor placement means persistence needs no changes: the marker is
already in `events` when a backend captures the creation seed, so it rides
the ordinary seed path with no load-time write. It also covers fork, where
the inherited bracket's owner may still be running — the case a
persistence-layer boundary could not reach.
Activity ordering excludes the boundary through `lastActivityTime()`, since
lazy resume makes browsing a pickup and the three call sites would otherwise
float every opened session to the top of a picker or list.
2026-07-30 11:38:51 +08:00
*/
2026-07-30 15:32:06 +08:00
'session/end-seed': Record< string , never >
2026-06-29 01:39:25 +08:00
}
```
2026-07-28 13:55:59 +08:00
`UserMessage` is the identified, frozen user-role value shared by ordinary prompts, injected context, steering, and live inbox events. Event wrappers add only event-local position or outcome facts; the loop adds only driver-owned routing state while an item remains pending.
2026-07-22 17:34:31 +08:00
2026-06-29 01:39:25 +08:00
### `TodoItem` — one todo-list entry
2026-07-24 01:40:25 +08:00
The unit of the `todo/write` event's whole-list snapshot. Deliberately minimal — a `content` line and a three-state `status` (no id, priority, or `activeForm` ): the list is replaced wholesale on every write, so entries need no stable identity. See the [todo_write Agent Note ](../../.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md ).
2026-06-29 01:39:25 +08:00
```ts type-equiv
2026-07-19 12:25:40 +08:00
/**
* One entry in an agent's todo list — the unit of the `todo/write`
* {@link SessionEventMap} event's whole-list snapshot.
*
* Deliberately minimal: a human-readable `content` line and a three-state
* `status` . No id, priority, or `activeForm` — the list is replaced wholesale
2026-07-24 01:40:25 +08:00
* on every write (last-write-wins), so entries need no stable identity. The
* three statuses describe the complete portable lifecycle needed by model and
* UI consumers.
2026-07-19 12:25:40 +08:00
*/
interface TodoItem {
/** What this task is — a short imperative line shown in the UI. */
2026-06-29 01:39:25 +08:00
content: string
2026-07-19 12:25:40 +08:00
/** Lifecycle state. `in_progress` marks the single task being worked now. */
2026-06-29 01:39:25 +08:00
status: 'pending' | 'in_progress' | 'completed'
2026-06-20 16:24:56 +08:00
}
```
2026-07-13 23:56:10 +08:00
### The request header event: `request/header`
2026-07-06 02:42:51 +08:00
2026-07-30 21:49:58 +08:00
The request envelope — the `EpochHeader` (call config + adapter-default provenance + rendered system prompt + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability Agent Note). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a later changed request records another full snapshot with reason `'change'` . `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType` : it produces no LLM message.
2026-07-06 02:42:51 +08:00
```ts type-equiv
2026-07-19 12:25:40 +08:00
/**
2026-07-24 22:38:50 +08:00
* Logged request state outside derived history: call config, system prompt, and
* tools. The latest full `request/header` snapshot reconstructs it; canonical
* empty optional fields are absent.
2026-07-19 12:25:40 +08:00
*/
interface EpochHeader {
2026-07-25 07:47:51 +08:00
/** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */
2026-07-06 02:42:51 +08:00
config: LlmCallConfig
2026-07-30 21:49:58 +08:00
/** Effective config fields materialized from the exact adapter rather than proposed by a caller. */
adapterDefaults?: LlmCallConfigAdapterDefaults
2026-07-06 02:42:51 +08:00
/** Rendered system prompt text; absent for a system-less request. */
system?: string
/** Assembled tool schemas; absent for a tool-less request. */
tools?: ToolSchema[]
}
```
2026-07-24 22:38:50 +08:00
Canonical form represents an empty system prompt or tool list as an absent field, matching how requests are built. Legacy v0 logs containing the removed `request/header-delta` event or its full-snapshot `fallback` reason are rejected at seed, append, and persistence-load boundaries rather than replayed incompletely.
2026-07-06 02:42:51 +08:00
2026-07-30 14:48:19 +08:00
### The route capacity event: `request/context`
2026-07-30 17:22:15 +08:00
The context metadata of the route a request resolved to is separate logged state, appended beside `request/header` inside the same step and only when the provider, model, or capacity differs from the previous record. It stays outside `EpochHeader` because that type is the reconstruction contract compared field-wise by `headerEquals` : capacity describes a route, not a request input, so folding it in would let a capacity change register as a request-envelope `change` and would pull adapter metadata into the loop's reconstruction invariant. Like `request/header` , it is not a `SurfaceEventType` and produces no LLM message. `session.requestContext()` folds the latest record incrementally. A route whose adapter advertises no capacity is recorded with `contextWindow` absent, so the new record clears an older route's capacity.
2026-07-30 14:48:19 +08:00
```ts type-equiv
/**
2026-07-30 17:22:15 +08:00
* Registration-bound context metadata of one resolved model route. Adapter
2026-07-30 14:48:19 +08:00
* metadata about a route rather than a request input, which is why it lives
* outside {@link EpochHeader}.
*/
interface RequestContext {
2026-07-30 17:22:15 +08:00
/** Registered provider route the metadata was resolved through. */
2026-07-30 14:48:19 +08:00
provider: string
2026-07-30 17:22:15 +08:00
/** Provider-owned model id the metadata belongs to. */
2026-07-30 14:48:19 +08:00
model: string
2026-07-30 17:22:15 +08:00
/** Maximum combined request and response context in tokens; absent when the adapter advertises none. */
contextWindow?: number
2026-07-30 14:48:19 +08:00
}
```
2026-06-20 16:24:56 +08:00
## `SessionEvent<T>` — one log entry
A proper discriminated union over `type` (not independent `type` /`data` unions), so `switch (event.type)` narrows `event.data` without casts. `seq` is the monotonic position in the log (`seq = log.length` ); `time` is epoch ms.
```ts type-equiv
2026-07-19 12:25:40 +08:00
/**
* One immutable entry in the session log.
*
* A proper discriminated union over `type` (not independent `type` /`data`
* unions), so `switch (event.type)` narrows `event.data` without casts.
*
* The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:
* they only exist on {@link SurfaceEventType} variants (`user/message` ,
2026-07-23 19:15:45 +08:00
* `assistant/message` , `tool/result` , `steering/message` ).
2026-07-19 12:25:40 +08:00
* Non-surface events (boundary markers, chunks, usage, errors) never carry
* surface metadata — the compiler enforces this at `Session.append()`
* call sites.
*/
2026-06-20 16:24:56 +08:00
type SessionEvent< T extends SessionEventType = SessionEventType > = {
[K in SessionEventType]: {
type: K
/** Monotonic sequence number within the session. */
seq: number
/** Unix epoch milliseconds. */
time: number
data: SessionEventMap[K]
2026-06-22 10:35:59 +08:00
} & (K extends SurfaceEventType ? {
/**
* Seq numbers of events that are provenance sources of this event
* (e.g. the `assistant/chunk` seqs that built an `assistant/message` ,
2026-07-19 12:25:40 +08:00
* or the surface nodes shadowed by a compaction replace node). An
* `assistant/message` may carry a present empty array for a known empty
* provider stream; omission means unrecorded provenance.
2026-06-22 10:35:59 +08:00
*/
sourceEventSeqs?: number[]
/** How this event entered the surface; absent for non-surface events. */
surfaceOp?: SurfaceOp
} : object)
2026-06-20 16:24:56 +08:00
}[T]
```
`SessionEventType = keyof SessionEventMap` . Because `SessionEventMap` is merge-extensible, switches over `SessionEvent` must NOT use `assertNever` — a plugin-added variant is a valid unknown value; handle the known cases and fall through `default` .
2026-07-15 14:47:29 +08:00
For `assistant/message` , a present `sourceEventSeqs: []` is a complete known-empty provider stream, while an absent field means legacy or otherwise unrecorded provenance. The loop writes the field for every successful model call; every other surface event requires a non-empty list when the field is present.
2026-06-23 13:26:45 +08:00
## Surface types
2026-07-23 19:15:45 +08:00
The four message-producing types (`SurfaceEventType` — `user/message` , `assistant/message` , `tool/result` , `steering/message` ) carry surface metadata declaring how they join the ordered derived surface. See the [session surface Agent Note ](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md ).
2026-06-23 13:26:45 +08:00
### `SurfaceEventType` — the message-producing subset of event types
```ts type-equiv
2026-07-19 12:25:40 +08:00
/**
* The subset of {@link SessionEventType} values whose events produce LLM
* messages and are eligible to appear on the ordered surface. Only these
* event types may carry {@link SurfaceOp} and {@link SessionEvent.sourceEventSeqs}.
*/
type SurfaceEventType =
2026-06-23 13:26:45 +08:00
| 'user/message'
| 'assistant/message'
| 'tool/result'
| 'steering/message'
```
### `SurfaceOp` — how an event entered the surface
```ts type-equiv
2026-07-19 12:25:40 +08:00
/**
* How a session event entered the ordered surface. Only valid on
* {@link SurfaceEventType} events.
*
2026-07-23 19:15:45 +08:00
* - `'append'` : added to the tail — normal path for user/assistant/tool/steering
2026-07-19 12:25:40 +08:00
* messages.
* - `{ op: 'replace', start, end }` : replaces surface nodes from `start`
* (inclusive) through `end` (inclusive) with this node. Both must exist as
* surface nodes in the current surface. `start === end` replaces a single
* node. The node's {@link SessionEvent.sourceEventSeqs} must include every
* shadowed surface node. Used by compaction and possible other manipulations.
*/
type SurfaceOp =
2026-06-23 13:26:45 +08:00
| 'append'
| { op: 'replace'; start: number; end: number }
```
2026-07-13 23:56:10 +08:00
`'append'` is the normal tail-append path. `replace` shadows surface entries from `start` through `end` inclusive (both must be valid surface seqs; `start === end` replaces a single entry) and inserts the new event in their place.
2026-06-23 13:26:45 +08:00
### `SurfaceIntent` — the parameter to `session.append()`
```ts type-equiv
2026-07-19 12:25:40 +08:00
/**
* Surface placement and provenance for {@link Session.append}. Required on
* message-producing events and forbidden on log-only events.
*/
interface SurfaceIntent {
2026-06-23 13:26:45 +08:00
surfaceOp: SurfaceOp
2026-07-19 12:25:40 +08:00
/**
* Complete known provenance source set. `assistant/message` may use a
* present empty array for a known empty provider stream; omission means its
* provenance was not recorded. Other surface events require a non-empty set
* when this field is present.
*/
2026-06-23 13:26:45 +08:00
sourceEventSeqs?: number[]
}
```
fix(tui,host): pin replayed compaction and correct projection wording
Review follow-ups on the append-origin transcript projection.
The live/replay equivalence claim was stated unconditionally but does not
cover `tool/call`: only replay re-derives call pairing, because a call
event carries no `surfaceOp` of its own and inherits transcript
membership from the `assistant/message` that advertised it — which the
live listener has necessarily just rendered. Narrow the claim in the TUI
README and Agent Note, and record at `rebuildTranscript` why the filter
is replay-only rather than a missing live branch.
Add `surface-replayed-compaction`: the three existing fixtures all come
from the live path, leaving the resume case the bug report leads with
pinned only by a unit test. The new checkpoint mounts with the
replacement already stored and records byte-identical to
`surface-after-compaction-wide`, so the two fixtures now pin the
equivalence they assert. The shared fixture appends move into
`appendPreCompactionLog` / `appendCompactionCheckpoint`.
`MESSAGE_TYPES` is not "human message event types" — it includes
`assistant/message`. Say what the code distinguishes (append-origin
conversation messages vs. model-only replacement copies) at the const,
the `paginate` and `session.history` JSDoc, the apiproxy README, and the
Agent Note.
Also: spell the replace shape as `Extract<SurfaceOp, { op: 'replace' }>`
for symmetry with the module's two other uses; document why
`isCompactCheckpoint` keeps a replacement check that is redundant at both
call sites; say that Ctrl+R toggles reasoning, which rebuilds the
transcript; and qualify "the sole source of derived history" as derived
*model* history now that the transcript is the other projection.
2026-07-29 17:17:19 +08:00
Required for `SurfaceEventType` events — every message-producing event must declare how it joins the surface, the sole source of derived model history. A human-facing transcript is the other projection and reads the log's append-origin events instead, because the surface deliberately shadows the ranges a replacement summarizes (`isAppendSurfaceEvent` in [dsh-session ](../../packages/core/session/README.md )). Non-surface types reject it at compile time.
2026-06-23 13:26:45 +08:00
2026-07-15 14:47:29 +08:00
The same provenance distinction applies here: only `assistant/message` may carry a present empty `sourceEventSeqs` ; omission does not assert that its source stream was empty.
2026-07-19 11:36:07 +08:00
### `SessionSurface` — the live readonly surface projection
`Session.surface` returns the session's stable `SessionSurface` view. The same incremental manager validates append candidates before commit and advances this projection from committed events; callers can observe membership and replacement generation but cannot invoke validation.
```ts type-equiv
2026-07-19 12:25:40 +08:00
/** Readonly live projection of the message-producing session events. */
interface SessionSurface {
/** Current surface event sequences in model-visible order. */
2026-07-19 11:36:07 +08:00
readonly nodes: readonly number[]
2026-07-19 12:25:40 +08:00
/** Monotonic count of committed positional replacements. */
2026-07-19 11:36:07 +08:00
readonly replaceGeneration: number
}
```
2026-07-10 17:29:52 +08:00
### `SurfaceFoldReplacement` and `SurfaceFoldResult` — a complete surface replay
2026-07-19 11:36:07 +08:00
`foldSurface(events)` returns detached current event sequences together with the actual sequences shadowed by each declared replacement range. The live manager uses the same transitions without retaining replacement history. Its `replaceGeneration` increments for each committed replacement so incremental consumers can distinguish pure tail growth from a rewrite.
2026-07-10 17:29:52 +08:00
```ts type-equiv
2026-07-19 12:25:40 +08:00
/** One replacement operation observed while folding a session surface. */
interface SurfaceFoldReplacement {
/** Seq of the event that replaced the prior surface range. */
2026-07-10 17:29:52 +08:00
seq: number
2026-07-19 12:25:40 +08:00
/** Declared inclusive start seq of the replaced surface range. */
2026-07-10 17:29:52 +08:00
start: number
2026-07-19 12:25:40 +08:00
/** Declared inclusive end seq of the replaced surface range. */
2026-07-10 17:29:52 +08:00
end: number
2026-07-19 12:25:40 +08:00
/** Actual surface entries removed by the operation, in surface order. */
2026-07-10 17:29:52 +08:00
shadowedSeqs: number[]
}
```
```ts type-equiv
2026-07-19 12:25:40 +08:00
/** Complete result of replaying the surface operations in a session log. */
interface SurfaceFoldResult {
/** Current surface event sequences in model-visible order. */
2026-07-14 11:49:46 +08:00
nodes: number[]
2026-07-19 12:25:40 +08:00
/** Replacement operations in event order. */
2026-07-10 17:29:52 +08:00
replacements: SurfaceFoldReplacement[]
}
```
2026-07-19 13:59:05 +08:00
## `Session` public API
The body-stripped declaration keeps the plain class's public constructor, state accessors, append boundary, and history projections synchronized with source. Store operations remain in the generated [`ctx.sessions` service catalog ](../cordis-catalog/services.md#ctxsessions--sessionstore ).
2026-07-19 14:23:59 +08:00
```ts public-api
2026-07-19 13:59:05 +08:00
/**
* An event-sourced session: an append-only log of {@link SessionEvent}s.
*
* Plain class (not a Service) — create instances via `ctx.sessions.create()` .
* Seeding with an existing event log replays/forks a session.
2026-07-28 23:47:57 +08:00
* @typert object
2026-07-19 13:59:05 +08:00
*/
declare class Session {
/** The ordered surface over this session's event log. */
get surface(): SessionSurface;
/**
* Detached, deep-frozen creation metadata (format version, cwd, lineage,
* seed boundary). Supplied by the store via `ctx.sessions.create()` . When a
* `Session` is constructed bare (tests, ad-hoc replay), a minimal header is
* synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so
* `session.header` is always present. Kept out of the event log — it is a
* storage concern, not replayable conversation state.
*/
readonly header: SessionHeader;
/** The session identity, derived from its durable header's single copy. */
get id(): SessionId;
feat(telemetry): adopt from the construction boundary — constructor seeds never re-export
A cursor-less adoption (process restart + resume, fork, seam-module
reload) replayed the session's full log from seq 0, re-exporting
history that already left the process — a resume re-billed its entire
stored log on every restart, and a fork re-shipped the parent's prefix
under the child's id, doubling query-time counts on OTLP backends with
no native ingest dedupe.
dsh-session now exposes the fact the constructor already validated but
discarded: Session.firstLiveSeq, the constructor-seed length — the
first seq appended in this process. header.seedLength cannot serve
here: it is the durable fork-lineage boundary, and a resumed session's
constructor seed is its full stored log while the header keeps the
original fork value (llm-replay and session-query-sqlite depend on
that meaning). Constructor seeds also never publish on the
session/event firehose, so adoption replaying them was inconsistent
with the system's own publication semantics.
Adoption's cursor-less fallback starts at firstLiveSeq; seed events
still feed the chunk projection, so mid-step continuations re-drop
after a resume. Fork streams are no longer self-contained: records now
carry session.seed_length (with the existing session.parent_id) so
receivers stitch the child's stream onto the parent's. Accepted cost,
consistent with at-most-once delivery and recorded in the revival
Agent Note: a resume no longer backfills records a previous process
failed to deliver — a deployment with that requirement needs the
deferred outbox, not replay.
Pinned red-first: seeded adoption exports nothing (assertion reversed
from the prior seed-readback test, obsolete behavior changed with its
test), resume-shaped seed rebuilds the projection without exporting,
and fork records carry the stitch attributes.
2026-07-27 18:35:03 +08:00
/**
* The first seq appended IN THIS PROCESS: the length of the constructor
2026-07-30 15:32:06 +08:00
* seed (0 without one). Events with smaller seq values entered through
* construction — replay, fork, or resume — and were never published on the
* `session/event` firehose (constructor seeds do not emit), so consumers
* that replay the log as a publication substitute (telemetry adoption)
* start here. Distinct from `header.seedLength` , the DURABLE fork-lineage
* boundary: a resumed session's constructor seed is its full stored log,
* while its header keeps the original fork value — this field is the
2026-07-31 13:24:30 +08:00
* in-process construction fact. An explicitly supplied empty seed has the
* same value as no seed (0); its `session/end-seed` event preserves the
* lifecycle distinction.
feat(session): project the inherited-history boundary into the log
A plugin owning a standalone open/close bracket cannot tell a dead marker
from a live one: an unmatched `compact/start` reads identically whether the
previous writer died mid-compaction or a compaction is running now.
`Session.firstLiveSeq` already holds that answer exactly, but only in memory.
Append the log-only `session/inherited` event at that seq from the seeded
constructor — the single waist all six seeded-start paths pass through
(resume, configured startup on a persisted id, `sessions.fork()`, a subagent
fork child, `adopt()`'s live prefix, and a bare seeded `create`). Read it
through the new `isInheritedSeq(events, seq)`.
The constructor placement means persistence needs no changes: the marker is
already in `events` when a backend captures the creation seed, so it rides
the ordinary seed path with no load-time write. It also covers fork, where
the inherited bracket's owner may still be running — the case a
persistence-layer boundary could not reach.
Activity ordering excludes the boundary through `lastActivityTime()`, since
lazy resume makes browsing a pickup and the three call sites would otherwise
float every opened session to the top of a picker or list.
2026-07-30 11:38:51 +08:00
*
2026-07-30 14:46:38 +08:00
* Not persisted itself: a seeded session projects it into the log as the
2026-07-30 15:32:06 +08:00
* `session/end-seed` event, which is what a consumer reading STORED history
* reads. Locate the LAST such event, not necessarily one at this seq — a
2026-07-30 14:46:38 +08:00
* seed already ending in one is not re-marked, so reopening an untouched
2026-07-30 15:32:06 +08:00
* session leaves that event at a smaller seq than `firstLiveSeq` . Prefer
* this field in-process: it is exact before the marker reaches storage.
fix(session): close the review gaps the boundary opened
- `SessionSummary.updatedAt`'s wire doc still said "Persisted file mtime",
which stopped being true for attached sessions.
- The core invariant let `session/inherited` fall through the merge-extensible
default. It is core-owned, so it gets an explicit case; an unbalanced seed
legally places it inside an open turn, which the relation permits.
- The Agent Note claimed the boundary reaches disk via `live.pending`/
`scheduleDrain`. Verified false: the constructor append precedes `enter()`,
so it never publishes on `session/event` and rides the creation seed instead.
Attaching is therefore a write where none happened before — recorded, since
only `load()` stays a pure read.
- The deferred-index proposal asserted this change documented the cold-mtime
skew on `dsh-host-apiproxy`. It did not; the README entry now exists.
- `firstLiveSeq`'s firehose gap runs through its own seq, not below it.
- The boundary is not always at `firstLiveSeq` (the idempotence guard), so
consumers scan for the last one.
- `lastActivityTime` excludes by type, so a pickup time still leaks onto a
synthetic closer when a boundary ends an open turn. Documented.
- Pin the fork claim end-to-end: a child inherits a still-running parent's
open bracket below its own boundary, while the parent has none. Fails if the
write moves back to the load path.
- Fix the telemetry title that contradicted its own assertions.
The `/status` call site cannot be pinned the way the other two are: the
command appends its own `command/run` before rendering, so the boundary is
never the log tail there. Its fixture now at least renders over a
boundary-bearing log.
2026-07-30 13:59:08 +08:00
*
2026-07-30 15:32:06 +08:00
* When this lifecycle appends the marker, it occupies this seq before the
* store attaches and therefore does not publish either. Otherwise this seq
* holds an ordinary published write.
feat(telemetry): adopt from the construction boundary — constructor seeds never re-export
A cursor-less adoption (process restart + resume, fork, seam-module
reload) replayed the session's full log from seq 0, re-exporting
history that already left the process — a resume re-billed its entire
stored log on every restart, and a fork re-shipped the parent's prefix
under the child's id, doubling query-time counts on OTLP backends with
no native ingest dedupe.
dsh-session now exposes the fact the constructor already validated but
discarded: Session.firstLiveSeq, the constructor-seed length — the
first seq appended in this process. header.seedLength cannot serve
here: it is the durable fork-lineage boundary, and a resumed session's
constructor seed is its full stored log while the header keeps the
original fork value (llm-replay and session-query-sqlite depend on
that meaning). Constructor seeds also never publish on the
session/event firehose, so adoption replaying them was inconsistent
with the system's own publication semantics.
Adoption's cursor-less fallback starts at firstLiveSeq; seed events
still feed the chunk projection, so mid-step continuations re-drop
after a resume. Fork streams are no longer self-contained: records now
carry session.seed_length (with the existing session.parent_id) so
receivers stitch the child's stream onto the parent's. Accepted cost,
consistent with at-most-once delivery and recorded in the revival
Agent Note: a resume no longer backfills records a previous process
failed to deliver — a deployment with that requirement needs the
deferred outbox, not replay.
Pinned red-first: seeded adoption exports nothing (assertion reversed
from the prior seed-readback test, obsolete behavior changed with its
test), resume-shaped seed rebuilds the projection without exporting,
and fork records carry the stitch attributes.
2026-07-27 18:35:03 +08:00
*/
readonly firstLiveSeq: number;
2026-07-19 13:59:05 +08:00
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);
/**
* An immutable snapshot of the append-only event log. The snapshot is reused
* until the next append; a previously returned array does not grow later.
* Events and their nested data are deep-frozen at acceptance, so neither a
* cast nor ordinary JavaScript can rewrite durable history.
*/
get events(): readonly SessionEvent[];
/** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */
get seq(): number;
/**
* Append one typed event to the log and synchronously notify observers via
* the store-owned, module-private publication hooks. The hot path never blocks
* on I/O — persistence plugins buffer asynchronously. Once the event enters
* the log, the append is committed: observer failures are logged and
* contained per listener, so they do not change the return value or prevent
* later listeners from observing the same accepted event.
*
* @param type - The event type (key of {@link SessionEventMap}).
* @param data - The event payload; must be JSON-serializable.
* @param opts - Surface metadata: `surfaceOp` controls how the event enters
* the ordered surface; `sourceEventSeqs` records provenance (the seq
* numbers of events this one derives from). REQUIRED for
* {@link SurfaceEventType} events (every message-producing event must
fix(tui,host): pin replayed compaction and correct projection wording
Review follow-ups on the append-origin transcript projection.
The live/replay equivalence claim was stated unconditionally but does not
cover `tool/call`: only replay re-derives call pairing, because a call
event carries no `surfaceOp` of its own and inherits transcript
membership from the `assistant/message` that advertised it — which the
live listener has necessarily just rendered. Narrow the claim in the TUI
README and Agent Note, and record at `rebuildTranscript` why the filter
is replay-only rather than a missing live branch.
Add `surface-replayed-compaction`: the three existing fixtures all come
from the live path, leaving the resume case the bug report leads with
pinned only by a unit test. The new checkpoint mounts with the
replacement already stored and records byte-identical to
`surface-after-compaction-wide`, so the two fixtures now pin the
equivalence they assert. The shared fixture appends move into
`appendPreCompactionLog` / `appendCompactionCheckpoint`.
`MESSAGE_TYPES` is not "human message event types" — it includes
`assistant/message`. Say what the code distinguishes (append-origin
conversation messages vs. model-only replacement copies) at the const,
the `paginate` and `session.history` JSDoc, the apiproxy README, and the
Agent Note.
Also: spell the replace shape as `Extract<SurfaceOp, { op: 'replace' }>`
for symmetry with the module's two other uses; document why
`isCompactCheckpoint` keeps a replacement check that is redundant at both
call sites; say that Ctrl+R toggles reasoning, which rebuilds the
transcript; and qualify "the sole source of derived history" as derived
*model* history now that the transcript is the other projection.
2026-07-29 17:17:19 +08:00
* declare how it joins the surface, the sole source of derived model
* history) and
2026-07-19 13:59:05 +08:00
* rejected by the compiler for non-surface types like `turn/start` or
* `assistant/chunk` .
* @returns the logged event — its assigned `seq` /`time` plus the SNAPSHOT of
* `data` that entered the log, so reading `event.data` back sees the logged
* value, never the caller's still-mutable input.
* @throws if `data` or surface metadata is not losslessly JSON-serializable
* (BigInt, function, symbol, undefined, negative zero, non-finite number,
* circular reference, sparse array, or an exotic object such as
* Map/Set/Date/class instance), or when the candidate violates the
* canonical surface contract (marker shape and eligibility, unique
* earlier provenance, positional replacement validity, and complete
* shadowed-node coverage). One recursive pass reads, validates, and
* copies each nested value once, so a stateful getter cannot supply one value
* to validation and another to storage. The event log is the durable source
* of truth, so a bad event fails at the append site rather than later during
* a backend flush. A synchronous internal dispatch validation failure or an
* append reentered while this acceptance/publication boundary is open also
* rejects before the log changes.
*/
append< T extends SessionEventType > (
type: T,
data: SessionEventMap[T],
...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []
): SessionEvent< T > ;
/**
* The {@link EpochHeader} in force after the log's last header event — the
* header the NEXT request will be compared against — or undefined before
* the first `request/header` snapshot. The live, incrementally-maintained
* form of `foldRequestHeader(session.events)` : each header event is folded
* once, when first seen, so a per-step read costs O(new events).
* @returns the folded header, or undefined when no header event exists yet.
*/
requestHeader(): EpochHeader | undefined;
2026-07-30 14:48:19 +08:00
/**
2026-07-30 17:22:15 +08:00
* The route metadata in force after the log's last `request/context` event —
2026-07-30 14:48:19 +08:00
* what the NEXT request deduplicates against — or undefined before any such
* record. Maintained incrementally like {@link requestHeader}, so a per-step
* read costs O(new events).
2026-07-30 17:22:15 +08:00
* @returns the folded context record, or undefined when none exists yet.
2026-07-30 14:48:19 +08:00
*/
requestContext(): RequestContext | undefined;
2026-07-19 13:59:05 +08:00
/**
* Derive the LLM message history by walking the ordered sequences of
* message-producing events maintained by `surfaceOp` markers. The
* surface is the single source of derived history: every message-producing
* append records its `surfaceOp` , so a raw event with no marker (a chunk, a
* turn boundary) is correctly absent, and a compaction `replace` deletes the
* shadowed nodes from the derivation. The projection rules are
* {@link deriveEventMessage}, folded per node.
*
* CACHED: each surface node is projected exactly once, when first seen — a
* call costs O(new nodes), and a surface rewrite (a `replace` ;
* {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is
* a fresh snapshot per call (later appends never grow an array a caller
* already holds); the `Message` objects in it are SHARED and **deep-frozen** .
* Their content reuses the already frozen durable event data, so the cache
* needs no second deep clone and consumers still cannot mutate the log.
* @returns a fresh array of the shared, frozen derived history.
*/
deriveMessages(): Message[];
/**
* Project a single event into the LLM message it derives to, or null when
* it produces none — a non-surface event (chunk, boundary, log-only record)
* or an empty-content assistant/message (which exists only to host usage).
* The per-node pure function {@link deriveMessages} folds over the surface;
* an external reconstructor (or the dev invariant) folds the same function
* over a log prefix's surface to rebuild the exact messages any request was
2026-07-28 13:55:59 +08:00
* built from (the reconstructability Agent Note). The returned message is
* the already frozen message nested in the event wrapper and shared by
* delivery, durable history, and model requests.
2026-07-19 13:59:05 +08:00
* @param event - the event to project.
* @returns the derived message, or null when the event produces none.
*/
deriveEventMessage(event: SessionEvent): Message | null;
}
```
2026-07-06 02:51:20 +08:00
## Derived history: `deriveMessages()` and `deriveEventMessage()`
2026-06-20 16:24:56 +08:00
2026-07-06 02:51:20 +08:00
`Session.deriveMessages()` projects the event log into the `Message[]` the model sees — cached (each surface node projected once, when first seen; a surface rewrite rebuilds) and frozen (a fresh array per call over shared, deep-frozen messages, so mutating logged history through a projection is unrepresentable). `deriveEventMessage(event)` is the per-node pure function the fold applies — public so external reconstructors and the dev invariant project a log prefix with exactly the same rules and cannot disagree with the cache. The projection rules:
2026-06-20 16:24:56 +08:00
2026-07-22 17:34:31 +08:00
- `user/message` → a user message carrying exact `content` ; an optional envelope remains log-only display metadata.
2026-07-14 21:57:52 +08:00
- `assistant/message` → an assistant message with the event's provider/model provenance and optional adapter-private replay state. Raw `assistant/chunk` events are replay/UI data and are **skipped** in derivation (the assembled message is authoritative). An **empty-content** `assistant/message` is also skipped — a max-tokens step cut off with no content still records an `assistant/message` to host its usage/provenance, but a content-less assistant turn must not enter the provider transcript.
2026-06-20 16:24:56 +08:00
- `tool/result` → a user message carrying a `tool-result` block.
2026-07-24 14:05:33 +08:00
- `user/message` (injected context, i.e. non-`user` source) → a user-role message carrying its `content` verbatim at its chronological position; provenance and domain data live in its typed source.
2026-07-22 17:34:31 +08:00
- `steering/message` → a user-role message carrying exact `content` at its chronological position; an optional envelope remains log-only display metadata.
2026-06-20 16:24:56 +08:00
2026-07-20 22:11:26 +08:00
Everything else (`turn/*` , `step/*` , plugin-owned `llm/retry` ) is structural and does not project into a message. Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message, so their usage chunk is the durable accounting record. An operational error's step number is on `turn/end.reason` for `kind: 'error'` , with normalized `LlmFailure` facts for a final model-request failure and message/code for other live errors. Because this unreleased format intentionally has no compatibility promise, seed/load validation rejects request headers without provider+model and assistant messages without provider/model provenance instead of guessing a route for historical data.
2026-06-20 16:24:56 +08:00
2026-07-06 13:57:59 +08:00
## Live-session fork API
2026-07-06 12:35:34 +08:00
2026-07-06 13:57:59 +08:00
`ctx.sessions.create(id, { seed, meta })` is the low-level replay/fork primitive. For ordinary live-session forks, `SessionStore` exposes one policy API:
2026-07-06 12:35:34 +08:00
2026-07-28 14:41:51 +08:00
- `fork(source, boundary?, childSessionId?)` accepts a live `Session` object or live `SessionId` , selects source events through the inclusive `boundary` seq (default: current last event), requires the selected prefix to end outside an open turn, then creates a live child session with deep-cloned seed events plus child metadata (`parentSession` , `seedLength` , and inherited `cwd` ).
2026-07-06 12:35:34 +08:00
2026-07-28 14:41:51 +08:00
An explicit `boundary` lets callers fork from any stable between-turn position, including a previous `turn/end` or a later standalone log-only event, even if the source has newer events or an open current turn. The API rejects a prefix that ends inside an open turn instead of clipping silently. Broader execution-relation sanity stays in the existing `dsh-invariants` plugin and persistence repair path rather than being duplicated in `fork()` . `dsh-subagent-fork` keeps its completed-prefix clipping because tool-time delegation usually starts while the parent turn is open; ordinary session branching should make the requested boundary explicit.
2026-07-06 12:35:34 +08:00
2026-06-20 16:24:56 +08:00
## What started a turn: `TurnTriggerMap`
```ts type-equiv
2026-07-19 12:25:40 +08:00
/**
* What started a turn.
* Merge-extensible sum type (same pattern as MessageSourceMap).
*/
2026-06-20 16:24:56 +08:00
interface TurnTriggerMap {
message: { kind: 'message'; source: MessageSource }
2026-07-24 21:18:48 +08:00
/** Recovery turn reopened over the repaired current session log. */
retry: { kind: 'retry' }
2026-06-20 16:24:56 +08:00
/**
2026-07-24 21:18:48 +08:00
* An out-of-band producer explicitly enclosed injected context in a one-shot
* turn. `Agent.inject()` appends idle context directly and does not use this
* trigger; the source mirrors the producer of the enclosed `user/message` .
2026-06-20 16:24:56 +08:00
*/
injection: { kind: 'injection'; source: MessageSource }
}
```
## Why a turn ended: `TurnEndReasonMap`
2026-07-16 18:12:34 +08:00
`aborted` is intentionally a coarse durable outcome: it records that cancellation interrupted the live turn, not which runtime caller requested it. The runtime-only caller vocabulary belongs to [`AgentCancelCause` ](core.md#the-agent-handle ); a future audit requirement would use a separate control-request event rather than overloading the terminal result.
2026-06-20 16:24:56 +08:00
```ts type-equiv
2026-07-19 12:25:40 +08:00
/**
* Why a turn ended. Merge-extensible sum type.
*/
2026-06-20 16:24:56 +08:00
interface TurnEndReasonMap {
completed: { kind: 'completed' }
2026-07-16 18:12:34 +08:00
/** A cancellation request interrupted the live turn. */
aborted: { kind: 'aborted' }
2026-06-21 10:00:06 +08:00
/**
* The turn failed: a step threw or the model reported a failure. `step` is the
* step number the failure occurred on (the operational error's location — the
* single durable record of an in-turn failure; live diagnostics also fire via
2026-07-20 03:34:19 +08:00
* `agent/error` ). Final model-request failures retain their normalized facts
2026-07-24 17:11:58 +08:00
* as one `failure` ; other thrown values retain their rendered message and a
* real `HarnessError` code when present.
2026-06-21 10:00:06 +08:00
*/
2026-07-20 03:34:19 +08:00
error: { kind: 'error'; step: number } & (
| { failure: LlmFailure; message?: never; code?: never }
| { message: string; code?: string; failure?: never }
)
2026-06-20 16:24:56 +08:00
disposed: { kind: 'disposed' }
2026-07-19 12:25:40 +08:00
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
2026-06-20 16:24:56 +08:00
'max-tokens': { kind: 'max-tokens' }
/**
2026-07-19 12:25:40 +08:00
* A persistence backend closed a crash-orphaned turn on reload. The loop never
* emits this marker, and the events recorded before the crash remain intact.
2026-06-20 16:24:56 +08:00
*/
interrupted: { kind: 'interrupted' }
}
```
2026-07-24 22:38:50 +08:00
`max-tokens` mirrors the model-call `FinishReason` of the same name: any `max-tokens` step in a turn makes the whole turn end `max-tokens` rather than `completed` (the cut-short fact wins over a later continuation), so a consumer can tell a clean stop from a truncated one — but only over `completed` : the `disposed` /`aborted` /`error` outcomes take precedence. `interrupted` is the one reason no loop emits — it is synthesized by crash recovery (see [persistence.md ](persistence.md )). Both maps are merge-extensible.
2026-06-20 16:24:56 +08:00
2026-07-28 14:41:51 +08:00
## Execution enclosure and standalone events
A turn encloses one model-loop execution, not the whole session log. Idle injected `user/message` events and plugin-owned log-only events may appear between `turn/end` and the next `turn/start` ; they consume event seqs without incrementing turn numbers. Persistence eagerly records every contiguous accepted event, while crash repair closes only a genuinely open trailing turn. A producer that needs a durability barrier explicitly awaits `ctx.sessions.flush(session)` .
2026-06-20 16:24:56 +08:00
2026-07-28 14:41:51 +08:00
The optional `dsh-session/invariant` companion enforces the relations owned by core: turn and step numbering, execution-event enclosure, and same-step tool call/result pairing. Merge-extensible event relations belong to the plugin that declares them, so core does not reject an unknown event merely because no turn is open. See [the standalone-event decision ](../../.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md ).
2026-06-20 16:24:56 +08:00
2026-07-30 15:32:06 +08:00
## The end-seed boundary: `session/end-seed`
feat(session): project the inherited-history boundary into the log
A plugin owning a standalone open/close bracket cannot tell a dead marker
from a live one: an unmatched `compact/start` reads identically whether the
previous writer died mid-compaction or a compaction is running now.
`Session.firstLiveSeq` already holds that answer exactly, but only in memory.
Append the log-only `session/inherited` event at that seq from the seeded
constructor — the single waist all six seeded-start paths pass through
(resume, configured startup on a persisted id, `sessions.fork()`, a subagent
fork child, `adopt()`'s live prefix, and a bare seeded `create`). Read it
through the new `isInheritedSeq(events, seq)`.
The constructor placement means persistence needs no changes: the marker is
already in `events` when a backend captures the creation seed, so it rides
the ordinary seed path with no load-time write. It also covers fork, where
the inherited bracket's owner may still be running — the case a
persistence-layer boundary could not reach.
Activity ordering excludes the boundary through `lastActivityTime()`, since
lazy resume makes browsing a pickup and the three call sites would otherwise
float every opened session to the top of a picker or list.
2026-07-30 11:38:51 +08:00
2026-07-30 15:32:06 +08:00
A seeded session — resume, fork, or replay — appends this log-only event immediately after its constructor seed, as its first live write. Events before it have smaller seq values and came from the seed. It is the durable projection of `firstLiveSeq` : that field answers where this lifecycle's writes start for a consumer holding the object, while the event answers the same question for one holding only stored bytes. The payload is empty, so position and `time` carry the whole meaning, and it produces no message. `Session` 's constructor is the only legitimate writer.
2026-07-30 14:46:38 +08:00
2026-07-31 13:24:30 +08:00
An explicitly supplied empty seed writes `session/end-seed` at seq 0, which distinguishes an empty resumed session from a fresh one. A seed already ending in `session/end-seed` is not re-marked, so reopening an untouched session does not grow its log per pickup. Locate the LAST `session/end-seed` in stored history rather than assuming one exists at `firstLiveSeq` : after a pickup with no work, the event has a smaller seq than the next lifecycle's `firstLiveSeq` .
feat(session): project the inherited-history boundary into the log
A plugin owning a standalone open/close bracket cannot tell a dead marker
from a live one: an unmatched `compact/start` reads identically whether the
previous writer died mid-compaction or a compaction is running now.
`Session.firstLiveSeq` already holds that answer exactly, but only in memory.
Append the log-only `session/inherited` event at that seq from the seeded
constructor — the single waist all six seeded-start paths pass through
(resume, configured startup on a persisted id, `sessions.fork()`, a subagent
fork child, `adopt()`'s live prefix, and a bare seeded `create`). Read it
through the new `isInheritedSeq(events, seq)`.
The constructor placement means persistence needs no changes: the marker is
already in `events` when a backend captures the creation seed, so it rides
the ordinary seed path with no load-time write. It also covers fork, where
the inherited bracket's owner may still be running — the case a
persistence-layer boundary could not reach.
Activity ordering excludes the boundary through `lastActivityTime()`, since
lazy resume makes browsing a pickup and the three call sites would otherwise
float every opened session to the top of a picker or list.
2026-07-30 11:38:51 +08:00
2026-07-30 15:32:06 +08:00
It exists because seed history and live work are otherwise byte-identical, which defeats any plugin owning a standalone open/close bracket: an unmatched `compact/start` reads the same whether the writer crashed mid-compaction or is compacting right now. An opening marker before `session/end-seed` came from the constructor seed and belongs to an ended lifecycle, whatever ended it (a crash, a succeeding process, or a fork out of a still-running parent), so its owner may treat it as dead. That covers only brackets *this* session inherited: a concurrently live session holding an open bracket over the same history has its own boundary elsewhere, so tolerating concurrent writers needs a liveness signal beyond the log. Core writes the boundary and reads nothing from it — a bracket's vocabulary stays with its owning plugin, which is why crash repair closes turn/step/tool boundaries and never `compact/*` .
feat(session): project the inherited-history boundary into the log
A plugin owning a standalone open/close bracket cannot tell a dead marker
from a live one: an unmatched `compact/start` reads identically whether the
previous writer died mid-compaction or a compaction is running now.
`Session.firstLiveSeq` already holds that answer exactly, but only in memory.
Append the log-only `session/inherited` event at that seq from the seeded
constructor — the single waist all six seeded-start paths pass through
(resume, configured startup on a persisted id, `sessions.fork()`, a subagent
fork child, `adopt()`'s live prefix, and a bare seeded `create`). Read it
through the new `isInheritedSeq(events, seq)`.
The constructor placement means persistence needs no changes: the marker is
already in `events` when a backend captures the creation seed, so it rides
the ordinary seed path with no load-time write. It also covers fork, where
the inherited bracket's owner may still be running — the case a
persistence-layer boundary could not reach.
Activity ordering excludes the boundary through `lastActivityTime()`, since
lazy resume makes browsing a pickup and the three call sites would otherwise
float every opened session to the top of a picker or list.
2026-07-30 11:38:51 +08:00
Activity ordering excludes the boundary through `lastActivityTime(events)` : picking a session up is not work, and lazy resume means browsing writes one, so a resume picker or session list ordering by log tail would float every opened session to the top.
2026-07-01 01:12:04 +08:00
## Plugin-contributed log-only events
2026-07-28 14:41:51 +08:00
A plugin may declaration-merge extra `SessionEventMap` types. These are **log-only** : NOT `SurfaceEventType` s (they carry no `surfaceOp` and contribute nothing to derived history). Their owner decides whether they belong to an open execution turn or may stand between turns, and enforces any relation in its own invariant companion. The full per-event enumeration — core and plugin-contributed alike, with payloads and provenance — is the generated [persistence log event catalog ](../persistence-catalog.md ); the compaction seam's `compact/*` semantics are discussed on [compaction.md ](compaction.md ).
2026-07-01 01:12:04 +08:00
2026-07-24 22:38:50 +08:00
The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepseek-ai/dsh-hook-protocol` ) correlate by `handlerId` . The mid-turn hook points (`PreToolUse` /`PostToolUse` /`Stop` ) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` and the pre-turn `UserPromptSubmit` admission seam get no `hook/*` record because neither has an open turn to enclose one; allowed context is instead evidenced by its sourced `user/message` (see [the hook-bridges Agent Note ](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md )).
2026-07-01 01:12:04 +08:00
2026-06-20 16:24:56 +08:00
## Durability contract
2026-07-28 14:41:51 +08:00
What a persistence backend relies on: the durable log persists every event losslessly, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. A backend may choose its own storage encoding for an event batch as long as `load` returns the exact appended events (the JSONL backend's default packed chunk rows are such an encoding — see [persistence.md ](persistence.md )). All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.events` always equals what a backend can persist. Adding an event type that carries non-serializable data, corrupts core execution nesting, or violates its owner's declared relation is a breaking change to the on-disk format.
2026-06-20 16:24:56 +08:00
The backends that consume this contract are on [persistence.md ](persistence.md ).