summaryrefslogtreecommitdiff
path: root/docs/archive/phase-4.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/archive/phase-4.md')
-rw-r--r--docs/archive/phase-4.md643
1 files changed, 643 insertions, 0 deletions
diff --git a/docs/archive/phase-4.md b/docs/archive/phase-4.md
new file mode 100644
index 0000000..e211e1f
--- /dev/null
+++ b/docs/archive/phase-4.md
@@ -0,0 +1,643 @@
+# Phase 4: Conversation Serialization
+
+## Goal
+
+Persistent session storage as an append-only event log. Every event in a session — messages, which provider/model handled each request — is recorded to disk as it happens. On load, pantograph reads the log and rebuilds the in-memory conversation. Sessions survive process restarts and can be reviewed later.
+
+Unlike systems that derive state by folding a stream of typed events (model-change entries, thinking-level-change entries, etc.), pantograph's load is mechanically simple: walk the entries, instantiate one in-memory `Message` per `message` entry, copy provider/model stamps as-is. There is no event replay or state derivation — the leaf entry's stamp _is_ the active model. The format leaves room for richer event types in the future (compaction, branching), but phase 4 doesn't introduce any.
+
+## Deliverable
+
+A session persistence system where pantograph saves and resumes conversations. At the end of this phase, you can:
+
+- Start `panto`, hold a conversation, and find the session saved to disk.
+- Restart `panto --resume` and continue the conversation exactly where you left off.
+- Restart `panto --resume <id>` to resume a specific session.
+- Inspect the JSONL session file to see every event that occurred, including which provider/model handled each turn.
+- Have pantograph recover gracefully from a crash — any corrupted trailing line is removed, and the session loads from the last valid entry.
+
+## What is usable at the end
+
+| Capability | How to exercise it |
+|---|---|
+| Auto-save session | Start `panto`, converse, quit; session is in `~/.local/share/panto/sessions/` |
+| Resume most recent | `panto --resume` — continues the most recent session for the current working directory |
+| Resume specific session | `panto --resume <id>` — opens the session with the given ID (or unique prefix) |
+| List sessions | `panto sessions` — lists sessions for the current working directory |
+| Crash recovery | Kill `panto` mid-turn; restart with `--resume`; corrupted trailing line is truncated, session loads from last valid entry |
+
+## What is explicitly out of scope
+
+- Compaction / context pruning (phase 6 — the format is designed to allow future entry types like `compaction` without breaking older readers, but phase 4 does not implement any form of compaction)
+- Branching / tree navigation (the format supports it via `id`/`parentId`, but no CLI commands for branching exist yet)
+- Session forking or cloning
+- Custom entry types (for extensions — future phase)
+- Labels, bookmarks, session naming
+- Session export or format migration beyond version 1
+- In-memory-only sessions from the CLI (every CLI session is persisted; libpanto embedders can opt out by passing a no-op store)
+
+---
+
+## Library / CLI Boundary
+
+The storage interface lives in libpanto. The default `fs_jsonl` implementation also lives in libpanto. The CLI decides _where_ a session is stored and constructs the store with that path.
+
+### libpanto owns
+
+- The `SessionStore` interface (vtable: append entry, load entries, etc.).
+- An `fs_jsonl` implementation that takes a base directory at construction time and writes the JSONL format defined below.
+- The agent loop's interaction with the store: which events get appended, at what points in a turn.
+
+### CLI owns
+
+- Selecting the base directory: XDG resolution (`$XDG_DATA_HOME` or `~/.local/share`), the `panto/sessions/` subpath, and the per-project `<encoded-cwd>/` grouping.
+- Selecting the session file: new-file-per-invocation by default; `--resume` / `--resume <id>` to pick an existing file.
+- Constructing `fs_jsonl` with the resolved directory and passing it to libpanto.
+
+Other embedders (a TUI, a daemon, a Lua host, a test harness) bring their own directory policy or their own `SessionStore` implementation. A no-op store is a valid choice for embedders that don't want persistence.
+
+### Mid-turn writes
+
+The in-memory `Conversation` is the source of truth during a turn. The store is written between discrete events — never as an inline dependency of streaming. A crash mid-turn loses the in-flight assistant message but never corrupts prior state. (Phase 4 writes per-message; finer-grained streaming-event entries are out of scope.)
+
+---
+
+## Event Log Format
+
+Sessions are stored as JSONL (JSON Lines) files. Each line is a JSON object with a `type` field. Entries form a tree structure via `id`/`parentId` fields, enabling in-place branching in future phases without creating new files.
+
+The event log is the authoritative record of a session. It captures everything that happened — not just the conversation content, but the full context of how it happened (which model was active, when it changed, etc.). On load, pantograph rebuilds all state from the log alone.
+
+### File Location
+
+```
+~/.local/share/panto/sessions/<encoded-cwd>/<uuid>.jsonl
+```
+
+Where `<encoded-cwd>` is the working directory with `/` replaced by `--`. This groups sessions by project directory, making it easy to find sessions for a given project.
+
+The filename is just the session's UUIDv7. Because UUIDv7 is time-ordered, sorting filenames lexicographically sorts sessions chronologically — no separate timestamp prefix is needed.
+
+The base directory respects `XDG_DATA_HOME`, falling back to `~/.local/share` if unset (per the XDG Base Directory specification).
+
+Example:
+```
+~/.local/share/panto/sessions/--Users-travis-Code-pantograph--/019dc5ba-53f6-71a5-ab8f-b1f8709c2572.jsonl
+```
+
+### Crash Recovery
+
+On loading a session, pantograph parses lines one by one from the top. As soon as a line fails to parse as a complete JSON object, **that line and every line after it are truncated from the file**. The session loads from the last fully valid entry.
+
+This is deliberately stricter than "skip malformed lines." Tool-use/tool-result pairs are stateful — a missing or partial entry in the middle would leave subsequent entries referring to a `toolUseId` that doesn't exist, which providers (notably Anthropic) reject outright. The only safe resume point is immediately before the first corruption.
+
+In practice the corruption window is the tail of the file (a crash mid-write), so truncation is effectively the same as "drop the partial last line." Defining the rule by position-of-first-error rather than "last line" handles edge cases (e.g., a partial write followed by a clean flush by another process) without ambiguity.
+
+---
+
+## Entry Types
+
+### SessionHeader
+
+First (and only) header line of the file. Metadata only — not part of the tree (no `id`/`parentId`). Exactly one header per file, always at the top.
+
+```json
+{
+ "type": "session",
+ "version": 1,
+ "id": "019dc5ba-53f6-71a5-ab8f-b1f8709c2572",
+ "timestamp": "2026-04-25T17:40:15.990Z",
+ "cwd": "/Users/travis/Code/pantograph"
+}
+```
+
+| Field | Type | Purpose |
+|---|---|---|
+| `version` | number | Format version. `1` for phase 4. |
+| `id` | string | Session UUIDv7. |
+| `timestamp` | string | ISO 8601 timestamp of session creation. |
+| `cwd` | string | Working directory where the session was started. |
+
+There are no provider/model fields on the header. The session file isn't created until the first assistant message flushes, at which point a user message is already buffered — so every persisted session contains at least one user-message stamp, and that stamp is the authoritative initial model.
+
+### Versioning and migration
+
+The `version` field exists from day one so future format changes are explicit. Phase 4 is version 1.
+
+When a future pantograph version bumps the format to 2+, the load path runs `migrate(entries) -> bool`. If migration mutates anything, the entire file is rewritten once with the new version stamped on the header. Migration is eager: every v1 file encountered by a v2-capable pantograph is upgraded immediately on first load. There is never a mixed-version file.
+
+### EntryBase
+
+All entries (except the header) extend this base:
+
+```
+EntryBase = {
+ type: string, // entry type discriminator
+ id: string, // 8-char hex ID, random
+ parentId: string | null, // parent entry ID (null for first entry)
+ timestamp: string, // ISO 8601
+}
+```
+
+The first entry has `parentId: null`. Each subsequent entry points to its parent, forming a linked path from root to leaf. In phase 4 this is always a linear chain; the tree structure exists to support branching in future phases.
+
+### MessageEntry
+
+A message in the conversation. The `message` field contains role, content blocks, and (for assistant messages) provider metadata.
+
+**System message:**
+```json
+{
+ "type": "message",
+ "id": "s1s2s3s4",
+ "parentId": null,
+ "timestamp": "2026-04-25T14:00:00.000Z",
+ "message": {
+ "role": "system",
+ "content": [
+ { "type": "text", "text": "You are a helpful assistant." }
+ ]
+ }
+}
+```
+
+**User message (human prompt):**
+```json
+{
+ "type": "message",
+ "id": "a1b2c3d4",
+ "parentId": "s1s2s3s4",
+ "timestamp": "2026-04-25T14:00:01.000Z",
+ "message": {
+ "role": "user",
+ "content": [
+ { "type": "text", "text": "What files are in this directory?" }
+ ]
+ }
+}
+```
+
+**Assistant message (with tool call):**
+```json
+{
+ "type": "message",
+ "id": "b2c3d4e5",
+ "parentId": "a1b2c3d4",
+ "timestamp": "2026-04-25T14:00:02.000Z",
+ "message": {
+ "role": "assistant",
+ "content": [
+ { "type": "thinking", "thinking": "The user wants to list files..." },
+ { "type": "text", "text": "I'll check that for you." },
+ { "type": "toolUse", "id": "tool_abc123", "name": "bash", "input": "{\"command\":\"ls\"}" }
+ ],
+ "provider": "anthropic",
+ "model": "claude-sonnet-4-20250514",
+ "stopReason": "toolUse",
+ "usage": { "input": 1500, "output": 85 }
+ }
+}
+```
+
+**User message (tool results):**
+```json
+{
+ "type": "message",
+ "id": "c3d4e5f6",
+ "parentId": "b2c3d4e5",
+ "timestamp": "2026-04-25T14:00:03.000Z",
+ "message": {
+ "role": "user",
+ "content": [
+ { "type": "toolResult", "toolUseId": "tool_abc123", "content": "file1.txt\nfile2.txt" }
+ ]
+ }
+}
+```
+
+Note that tool results are content blocks on a user message, consistent with pantograph's internal model (and Anthropic's wire format). This differs from pi's approach of giving `toolResult` its own message role — in pantograph, tool results are content blocks that happen to live on user messages, just as they do in the Conversation model.
+
+### Content Block Serialization
+
+The on-disk content block types correspond directly to pantograph's internal `ContentBlock` tagged union:
+
+| Internal type | On-disk `type` | Fields |
+|---|---|---|
+| `Text` (`TextualBlock`) | `"text"` | `text: string` |
+| `Thinking` (`TextualBlock`) | `"thinking"` | `thinking: string` |
+| `ToolUse` (`ToolUseBlock`) | `"toolUse"` | `id: string`, `name: string`, `input: string` (raw JSON bytes) |
+| `ToolResult` (`ToolResultBlock`) | `"toolResult"` | `toolUseId: string`, `content: string` |
+
+Notable decisions:
+
+- **`input` is a string, not a parsed object.** Consistent with pantograph's internal model where tool input is stored as raw JSON bytes. The on-disk format does not parse or interpret tool schemas.
+- **`content` in `toolResult` is a string.** Consistent with pantograph's model where tool result content is a single `TextualBlock`. If structured content (e.g., images) is needed later, the format can evolve.
+- **`thinking` stores full thinking content.** The event log records everything that happened. Thinking blocks are never omitted.
+
+### Assistant message metadata
+
+Assistant messages carry additional fields beyond `role` and `content`:
+
+| Field | Type | Purpose |
+|---|---|---|
+| `provider` | string | Which provider handled this request |
+| `model` | string | Which model was used |
+| `stopReason` | string | Why the model stopped: `"stop"`, `"length"`, `"toolUse"`, `"error"` |
+| `usage` | object | Token counts: `{ "input": number, "output": number }` — optional, omitted if unavailable |
+
+These fields are metadata about the provider response. They are recorded in the event log but do **not** become part of the in-memory `Conversation` model. The `Conversation` stores only `role` and `content` — the fields needed for provider serialization. Metadata is accessed through the session entry layer when needed (display, review, debugging).
+
+The assistant metadata is a response-side confirmation of what the user message's `provider`/`model` already declared on the request side. It's useful for quick inspection — you can see which model produced a response without scanning backward to the preceding user message — and for detecting discrepancies (e.g., a provider routing layer returned a different model than requested).
+
+### Model tracking on user messages
+
+Every entry with `type: "message"` and `message.role: "user"` carries top-level `provider` and `model` fields recording which provider/model the request was submitted to. This includes both human-authored prompts and user messages containing tool results — both are submissions to a provider API.
+
+```json
+{
+ "type": "message",
+ "id": "a1b2c3d4",
+ "parentId": "s1s2s3s4",
+ "timestamp": "2026-04-25T14:00:01.000Z",
+ "provider": "anthropic",
+ "model": "claude-sonnet-4-20250514",
+ "message": {
+ "role": "user",
+ "content": [
+ { "type": "text", "text": "What files are in this directory?" }
+ ]
+ }
+}
+```
+
+```json
+{
+ "type": "message",
+ "id": "c3d4e5f6",
+ "parentId": "b2c3d4e5",
+ "timestamp": "2026-04-25T14:00:03.000Z",
+ "provider": "openai",
+ "model": "gpt-4o",
+ "message": {
+ "role": "user",
+ "content": [
+ { "type": "toolResult", "toolUseId": "tool_abc123", "content": "file1.txt\nfile2.txt" }
+ ]
+ }
+}
+```
+
+The model history is self-evident from the log: scan user messages in order and you see exactly which provider/model handled each request. The first user message's stamp is the session's initial model.
+
+---
+
+## Tree Structure
+
+Entries form a tree via `id`/`parentId`:
+
+- First entry has `parentId: null`
+- Each subsequent entry points to its parent
+- In phase 4, the tree is always a simple linear chain (one path from root to leaf)
+- Branching creates new children from an earlier entry (future phase)
+- The "leaf" is the current position — always the last entry
+
+```
+[system] ─── [user] ─── [assistant] ─── [user (tool results)] ─── [assistant] ─┬─ [user] ← current leaf
+ │
+ └─ (future branch point)
+```
+
+The tree structure is defined now so that the on-disk format supports branching from day one. Phase 4 simply doesn't exercise it — every session is a linear chain.
+
+---
+
+## Rebuilding State from the Log
+
+On resume, pantograph reads the log top-to-bottom and rebuilds the in-memory state directly:
+
+1. **Read the file line by line.** Parse each line as a JSON object.
+2. **Crash recovery.** If a line fails to parse, truncate the file at the start of that line (see "Crash Recovery" above).
+3. **Extract the header.** First line must be a `session` header. Extract version, id, cwd.
+4. **Build the entry index.** Map entry `id` → entry, track `leafId` (the last entry's id). In phase 4 entries form a linear chain, so the read order _is_ the path from root to leaf.
+5. **Reconstruct the Conversation.** For each `message` entry, in order:
+ - Construct a `Message { role, content }` from the entry's `message` field.
+ - For each content block in the JSON, create the corresponding `ContentBlock` variant.
+ - `TextualBlock` fields (Text, Thinking) are initialized with their complete content in a single append — no streaming involved.
+ - `ToolUseBlock.input` is initialized with the raw JSON string from the `input` field.
+ - `ToolResultBlock.content` is initialized with the string from the `content` field.
+6. **Reconstruct the active model.** Walk entries leaf-to-root and find the last user-message entry with `provider`/`model` fields. Those fields determine the active provider/model. A valid persisted session always contains at least one user message (the deferred-file-creation rule guarantees it).
+7. **Result:** A `Conversation` ready for the agent loop, plus the active provider/model for configuration.
+
+There is no "event replay" — entries map 1:1 to in-memory messages, and the active model is read directly off the leaf's stamp. Compaction (phase 6) will introduce a single derived-state entry (`compaction` summarizes a prefix of the log), but that's still not event-folding; it's a special entry whose content stands in for the prefix it summarizes.
+
+The Conversation stores only `role` and `content` per message — the data needed for provider serialization. Entry metadata (provider, model, usage, stopReason, timestamps) lives in the session entry layer and is accessible for display or review without polluting the Conversation model.
+
+### Handling incomplete turns on resume
+
+If the session was interrupted mid-turn, the log may contain an assistant message with `ToolUse` blocks but no corresponding user message with `ToolResult` blocks. On resume, pantograph does **not** automatically re-execute the tools. The conversation is rebuilt as-is and presented to the user. The agent loop waits for user input — the user decides how to proceed.
+
+---
+
+## Persistence Strategy
+
+**Append-only.** Entries are appended to the JSONL file as they occur. No full-file rewrite during normal operation (except one-time format migration). Each agent turn appends one or more entries:
+
+- A user message entry (when the user submits a prompt, or when tool results are assembled)
+- Provider/model stamped on user message entries (top-level fields on every user message)
+- An assistant message entry (when the provider response is complete)
+
+A single agent turn with tool calls appends multiple rounds of these entries.
+
+**Flush-per-entry.** Once the file exists, every completed entry is written and flushed to disk as soon as it is assembled. This maximizes recoverability — at most one in-flight entry is ever lost to a crash.
+
+**Deferred file creation.** The session file is **not** created at startup. Pantograph buffers the header and any pre-assistant entries in memory until the first assistant message completes. At that moment the buffered entries are flushed together and persistence switches to append-per-entry mode. If the user quits before the first assistant response, no file is created — the sessions directory stays clean of empty/single-prompt detritus.
+
+This is tracked by a `flushed: bool` flag on the `SessionManager`:
+- `flushed = false`: entries accumulate in memory; nothing on disk yet.
+- First assistant message completes → write header + all buffered entries → set `flushed = true`.
+- `flushed = true`: every subsequent entry is appended and flushed immediately on completion.
+
+**No explicit save command.** Persistence is automatic.
+
+---
+
+## Module Changes
+
+### New files
+
+```
+src/session.zig // On-disk entry types, JSON serialization/deserialization
+src/session_manager.zig // SessionManager: file management, append, load, tree traversal, crash recovery
+```
+
+### `session.zig`
+
+Defines the on-disk types and their JSON serialization. These are separate from the in-memory `Message`/`ContentBlock` types — the on-disk types are the schema of what goes in the JSONL file, and include metadata that doesn't belong in the Conversation model.
+
+```
+SessionHeader = struct {
+ version: u32, // always 1 in phase 4
+ id: []const u8, // UUIDv7 string, owned
+ timestamp: []const u8, // ISO 8601, owned
+ cwd: []const u8, // owned
+
+ pub fn deinit(self) void
+};
+
+EntryBase = struct {
+ id: []const u8, // 8-char hex, owned
+ parent_id: ?[]const u8, // owned
+ timestamp: []const u8, // ISO 8601, owned
+
+ pub fn deinit(self) void
+};
+
+SessionEntry = union(enum) {
+ message: MessageEntry,
+ pub fn base(self) EntryBase
+ pub fn deinit(self) void
+};
+
+MessageEntry = struct {
+ base: EntryBase,
+ message: DiskMessage,
+ // Present on user messages only. Null for system/assistant messages.
+ provider: ?[]const u8, // owned
+ model: ?[]const u8, // owned
+};
+
+DiskMessage = struct {
+ role: enum { system, user, assistant },
+ content: []DiskContentBlock,
+ // Assistant-only metadata (null for system/user messages):
+ provider: ?[]const u8,
+ model: ?[]const u8,
+ stop_reason: ?[]const u8,
+ usage: ?Usage,
+
+ pub fn deinit(self) void
+};
+
+DiskContentBlock = union(enum) {
+ text: DiskTextBlock,
+ thinking: DiskThinkingBlock,
+ tool_use: DiskToolUseBlock,
+ tool_result: DiskToolResultBlock,
+
+ pub fn deinit(self) void
+};
+
+DiskTextBlock = struct {
+ text: []const u8, // owned
+};
+
+DiskThinkingBlock = struct {
+ thinking: []const u8, // owned
+};
+
+DiskToolUseBlock = struct {
+ id: []const u8, // owned
+ name: []const u8, // owned
+ input: []const u8, // raw JSON bytes, owned
+};
+
+DiskToolResultBlock = struct {
+ tool_use_id: []const u8, // owned
+ content: []const u8, // owned
+};
+
+Usage = struct {
+ input: u64,
+ output: u64,
+};
+
+FileEntry = union(enum) {
+ header: SessionHeader,
+ entry: SessionEntry,
+};
+```
+
+Serialization functions:
+
+- `serializeHeader(allocator, header) ![]const u8` — header → JSON line
+- `serializeEntry(allocator, entry) ![]const u8` — entry → JSON line
+- `parseLine(allocator, line: []const u8) !FileEntry` — JSON line → header or entry
+
+Conversion functions (bridge between on-disk and in-memory types):
+
+- `contentBlockToDisk(allocator, block: ContentBlock) !DiskContentBlock` — in-memory → on-disk
+- `diskContentBlockToInternal(allocator, disk: DiskContentBlock) !ContentBlock` — on-disk → in-memory
+
+All `[]const u8` fields in on-disk types are owned copies. `deinit()` frees them. The conversion from `ContentBlock` to `DiskContentBlock` extracts the content from `TextualBlock` buffers as owned string copies. The reverse conversion creates new `TextualBlock` instances initialized with the complete content.
+
+### `session_manager.zig`
+
+```
+SessionManager = struct {
+ session_id: []const u8,
+ session_file: ?[]const u8, // path to JSONL file (set even before flush)
+ session_dir: []const u8, // directory containing session files for this cwd
+ cwd: []const u8,
+ entries: std.ArrayList(SessionEntry),
+ by_id: std.StringHashMap(*const SessionEntry),
+ leaf_id: ?[]const u8,
+ flushed: bool, // false until first assistant message persists
+ allocator: std.mem.Allocator,
+
+ // ── Creation ──
+
+ /// Create a new session. Allocates a UUIDv7, computes the target file
+ /// path, but does NOT write to disk yet (see deferred-file-creation).
+ pub fn init(allocator, cwd) !SessionManager
+
+ /// Open an existing session file, replay the log, rebuild state.
+ /// Truncates from the first corrupted line onward.
+ /// Runs format migration if needed and rewrites the file once.
+ pub fn open(allocator, path) !SessionManager
+
+ /// Find and open the most recent session for the given cwd.
+ /// Returns null if no sessions exist.
+ pub fn continueRecent(allocator, cwd) ?SessionManager
+
+ pub fn deinit(self) void
+
+ // ── Appending ──
+
+ /// Append a message entry as child of the current leaf.
+ /// Generates an 8-char hex id (collision-checked against by_id).
+ /// If flushed: writes and fsyncs the new line immediately.
+ /// If not flushed and the message is an assistant message: writes the
+ /// header + all buffered entries + the new entry, then sets flushed.
+ /// Otherwise (not flushed, non-assistant): buffers in memory only.
+ /// Returns the new entry's id.
+ pub fn appendMessage(self, message: DiskMessage) ![]const u8
+
+ // ── Tree traversal ──
+
+ pub fn getLeafId(self) ?[]const u8
+ pub fn getEntry(self, id: []const u8) ?*const SessionEntry
+
+ /// Walk from an entry to root, returning entries in root-to-leaf order.
+ pub fn getBranch(self, from_id: ?[]const u8) []const SessionEntry
+
+ // ── Rebuilding ──
+
+ /// Rebuild a Conversation from the event log.
+ /// Walks from leaf to root, collects message entries,
+ /// converts DiskContentBlocks → ContentBlocks.
+ pub fn rebuildConversation(self) !Conversation
+
+ /// Determine the active provider/model by walking the path
+ /// and finding the last user message with provider/model fields.
+ /// Returns null only for sessions with no user messages, which can
+ /// only occur in-memory before the first flush.
+ pub fn activeModel(self) ?struct { provider: []const u8, model: []const u8 }
+
+ // ── Session listing ──
+
+ /// List sessions for a given cwd. Loads files concurrently via Io.concurrent.
+ /// `on_progress` is called as each file finishes parsing (loaded, total).
+ pub fn listSessions(
+ allocator,
+ cwd,
+ on_progress: ?*const fn (loaded: usize, total: usize) void,
+ ) ![]SessionInfo
+
+ // ── Crash recovery ──
+
+ /// Truncate the file at the first line that fails to parse as a complete
+ /// JSON object. Called during open() before parsing entries.
+ fn truncateFromFirstCorruption(file_path: []const u8) !void
+};
+
+SessionInfo = struct {
+ path: []const u8, // owned
+ id: []const u8, // owned
+ cwd: []const u8, // owned
+ created: []const u8, // ISO 8601, owned
+ modified: []const u8, // from file mtime, ISO 8601, owned
+ message_count: usize,
+};
+```
+
+### Modified files
+
+- **`agent.zig`** — Receives a `SessionManager` reference. After each message is assembled, calls `appendMessage()` with the current provider/model (user messages get stamped; assistant/system messages pass null). The agent loop is now a producer of session entries.
+- **`config.zig`** — Session directory path added. Default: `$XDG_DATA_HOME/panto/sessions/` (falling back to `~/.local/share/panto/sessions/` if `XDG_DATA_HOME` is unset). Overridable via `PANTO_SESSION_DIR` environment variable.
+- **`main.zig`** — `--resume` and `--resume <id>` flags. `sessions` subcommand. On startup with `--resume`, load session, rebuild conversation, continue agent loop. Without `--resume`, create a new session.
+
+---
+
+## CLI Changes
+
+### `--resume`
+
+Resume the most recent session for the current working directory. If no sessions exist, create a new one.
+
+```
+panto --resume
+```
+
+### `--resume <id>`
+
+Resume a specific session by its ID (or a unique prefix of the ID). Errors if no session matches, or if the prefix is ambiguous.
+
+```
+panto --resume 019dc5ba
+```
+
+### `sessions` subcommand
+
+List sessions for the current working directory. Shows session ID (short), creation time, and message count.
+
+```
+panto sessions
+```
+
+Output:
+```
+019dc5ba 2026-04-25 17:40 14 messages
+019dc80a 2026-04-26 04:27 38 messages
+```
+
+---
+
+## Testing Strategy
+
+### Unit tests
+
+| What | How |
+|---|---|
+| Entry serialization round-trip | Create entries of each type, serialize to JSON, parse back, verify equivalence |
+| Content block conversion | Convert each `ContentBlock` variant to/from `DiskContentBlock`, verify content preserved |
+| Crash recovery | Write a JSONL file with a corrupted trailing line, load it, verify the line is removed and valid entries are intact |
+| Session rebuild | Create a session with system, user, assistant, tool result messages; rebuild conversation; verify messages match original content |
+| Model tracking on user messages | Create a session with multiple turns, change model mid-session, verify `provider`/`model` fields appear on every user message entry and reflect the model used for that request |
+| Tree traversal | Create entries with parent chain, walk from leaf to root, verify correct root-to-leaf order |
+
+### Integration test (manual)
+
+- Start `panto`, hold a multi-turn conversation with tool calls, quit
+- Inspect the JSONL file — verify entries are present and well-formed
+- Run `panto --resume` — verify conversation continues from where it left off
+- Change the model mid-session, send a prompt, verify the user message entry in the log carries the updated `provider`/`model` fields
+- Kill `pantograph` mid-turn (e.g., Ctrl+C during streaming), run `panto --resume` — verify session loads from last valid entry
+- Run `panto sessions` — verify sessions are listed with correct metadata
+
+---
+
+## Resolved Design Decisions
+
+The following were open questions in an earlier draft. Recording the resolutions here so they don't get re-litigated.
+
+1. **Entry ID generation**: 8-char hex IDs, generated randomly, **collision-checked** against `by_id` (the in-memory index of every entry in the session). Retry up to 100 times; in the vanishingly unlikely event of repeated collisions, fall back to a full UUID. `by_id` lookup is O(1), so the check is effectively free.
+2. **Timestamp precision**: ISO 8601 with millisecond precision. Format Zig's nanoseconds down to milliseconds.
+3. **Session ID**: UUIDv7 (time-ordered). Filenames are `<uuid>.jsonl` with no separate timestamp prefix — lexicographic filename sort equals chronological sort.
+4. **File locking**: Not needed. The crash-recovery rule (truncate from first corruption) handles interleaved writes safely: any garbled tail is discarded on next open. Two pantograph processes resuming the same session simultaneously is rare and self-corrects to whichever process wrote last cleanly.
+5. **Session directory creation**: Lazy. Created on first flush (alongside the file itself). Avoids creating empty directory trees when pantograph is invoked transiently (e.g., `panto --help`, `panto sessions` on a fresh install).
+
+## Resolved at implementation time
+
+- **Large session files / replay cost**: not a concern — there's no event replay, just a 1:1 entry-to-message rebuild. Bounded by what we'd already send to the provider on every turn. The remediation for genuinely-too-many-tokens is compaction (phase 6), not a session-load optimization.
+- **`fsync` cadence**: `fsync` after every completed entry. Cheap on common filesystems, and we'd rather take the crash safety than the throughput.
+- **Concurrent listing**: deferred. Single-threaded for phase 4. Disks are fast, the listing path is rarely-traversed, and the public `listSessions` API already accepts a progress callback so we can parallelize later without breaking the signature.