1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
|
# 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, awl replays the event log to fully rebuild conversation state. Sessions survive process restarts and can be reviewed later.
## Deliverable
A session persistence system where awl saves and resumes conversations. At the end of this phase, you can:
- Start `awl`, hold a conversation, and find the session saved to disk.
- Restart `awl --resume` and continue the conversation exactly where you left off.
- Restart `awl --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 awl recover gracefully from a crash — the 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 `awl`, converse, quit; session is in `~/.local/share/awl/sessions/` |
| Resume most recent | `awl --resume` — continues the most recent session for the current working directory |
| Resume specific session | `awl --resume <id>` — opens the session with the given ID (or unique prefix) |
| List sessions | `awl sessions` — lists sessions for the current working directory |
| Crash recovery | Kill `awl` mid-turn; restart with `--resume`; corrupted trailing line is removed, 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 (every session is persisted)
---
## 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, awl rebuilds all state from the log alone.
### File Location
```
~/.local/share/awl/sessions/<encoded-cwd>/<timestamp>_<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 base directory respects `XDG_DATA_HOME`, falling back to `~/.local/share` if unset (per the XDG Base Directory specification).
Example:
```
~/.local/share/awl/sessions/--Users-travis-Code-awl--/2026-04-25T17-40-15-990Z_019dc5ba-53f6-71a5-ab8f-b1f8709c2572.jsonl
```
### Crash Recovery
On loading a session, if the last line of the file does not deserialize as a complete JSON object, it is deleted. The process does not refuse to start — it trims the corruption and loads from the last valid entry. This handles the common case where a process is killed mid-write.
---
## Entry Types
### SessionHeader
First line of the file. Metadata only — not part of the tree (no `id`/`parentId`).
```json
{
"type": "session",
"version": 1,
"id": "019dc5ba-53f6-71a5-ab8f-b1f8709c2572",
"timestamp": "2026-04-25T17:40:15.990Z",
"cwd": "/Users/travis/Code/awl",
"provider": "anthropic",
"model": "claude-sonnet-4-20250514"
}
```
| Field | Type | Purpose |
|---|---|---|
| `version` | number | Format version (1 for phase 4). Future format changes increment this. |
| `id` | string | Session UUID. |
| `timestamp` | string | ISO 8601 timestamp of session creation. |
| `cwd` | string | Working directory where the session was started. |
| `provider` | string | Initial provider (`"openai"` or `"anthropic"`). |
| `model` | string | Initial model name. |
The `provider` and `model` fields serve as the baseline for model tracking. If no user messages have been submitted yet, the header's values are the active provider/model.
### 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 awl's internal model (and Anthropic's wire format). This differs from pi's approach of giving `toolResult` its own message role — in awl, 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 awl'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 awl'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 awl'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 header's `provider`/`model` fields serve as the baseline for the first turn — if no user messages have been submitted yet, the header's values are the active 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, awl fully rebuilds conversation state by replaying the event log:
1. **Read the file line by line.** Parse each line as a JSON object.
2. **Crash recovery.** If the last line doesn't parse as a complete JSON object, delete it from the file.
3. **Extract the header.** First line must be a `session` header. Extract version, id, cwd, initial provider/model.
4. **Build the entry index.** Map entry `id` → entry, track `leafId` (the last entry's id).
5. **Walk the tree.** From leaf to root, collect entries in root-to-leaf order.
6. **Reconstruct the Conversation.** For each `message` entry on the path:
- 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.
7. **Reconstruct the active model.** Walk the path and find the last user message entry with `provider`/`model` fields. Those fields (or the header defaults if no user message exists) determine the active provider/model.
8. **Result:** A `Conversation` ready for the agent loop, plus the active provider/model for configuration.
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, awl 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. 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.
**File creation.** The session file is created when the session is initialized (header written). If `awl` starts and the user immediately quits without the session being initialized, no file is created.
**No explicit save command.** Persistence is automatic. Every event is written to disk as it happens.
---
## 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,
id: []const u8, // UUID string, owned
timestamp: []const u8, // ISO 8601, owned
cwd: []const u8, // owned
provider: []const u8, // owned
model: []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
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,
allocator: std.mem.Allocator,
// ── Creation ──
/// Create a new session. Writes the header to disk.
pub fn init(allocator, cwd, provider, model) !SessionManager
/// Open an existing session file, replay the log, rebuild state.
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 (writes to disk immediately) ──
/// Append a message entry as child of the current leaf.
/// 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,
/// falling back to header defaults.
pub fn activeModel(self) struct { provider: []const u8, model: []const u8 }
// ── Session listing ──
/// List sessions for a given cwd.
pub fn listSessions(allocator, cwd) ![]SessionInfo
// ── Crash recovery ──
/// Remove the last line of the file if it doesn't parse as complete JSON.
/// Called during open().
fn trimCorruptedTrailingLine(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/awl/sessions/` (falling back to `~/.local/share/awl/sessions/` if `XDG_DATA_HOME` is unset). Overridable via `AWL_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.
```
awl --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.
```
awl --resume 019dc5ba
```
### `sessions` subcommand
List sessions for the current working directory. Shows session ID (short), creation time, and message count.
```
awl 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 `awl`, hold a multi-turn conversation with tool calls, quit
- Inspect the JSONL file — verify entries are present and well-formed
- Run `awl --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 `awl` mid-turn (e.g., Ctrl+C during streaming), run `awl --resume` — verify session loads from last valid entry
- Run `awl sessions` — verify sessions are listed with correct metadata
---
## Open Questions (to resolve during implementation)
1. **Entry ID generation**: 8-char hex IDs give ~4 billion possibilities. Collision probability within a single session is negligible, but should we verify uniqueness against existing entries, or trust the randomness?
2. **Timestamp precision**: ISO 8601 with millisecond precision is sufficient. Zig's `std.time` provides nanosecond precision — we format to milliseconds.
3. **File locking**: If two `awl` processes try to append to the same session file simultaneously, lines could interleave and corrupt the file. Should we use `flock` for mutual exclusion? Or is this not worth worrying about in phase 4?
4. **Large session files**: A long coding session can produce thousands of entries and megabytes of JSONL. Replaying the entire log on every resume could become slow. Should we cache the rebuilt Conversation state (e.g., as a checkpoint line at the end of the file) and only replay new entries on subsequent loads? Or defer this optimization?
5. **Session directory creation**: Should `awl` create the session directory tree eagerly on startup, or lazily when the first session is created?
|