RFC: Persistent Sessions For Keen Code¶
Purpose¶
This RFC defines a persistent session system for Keen Code so users can stop a REPL session, reopen it later, and continue work without losing context.
The design explicitly separates:
- The rendered transcript needed to restore the visible REPL state
- The conversation state needed to rebuild
AppState.messagesfor the next LLM turn
That distinction is necessary because Keen should resume the full visible session, including tool activity, while only sending user and assistant messages back to the model.
Solution Overview¶
Keen will persist each saved session as a single append-only JSONL transcript file stored under a working-directory-scoped session folder:
~/.keen/sessions/<working-dir-sanitized>-<hash>/<timestamp>-<session-id>/transcript_events.jsonl
The core model is:
- A session is created lazily, only after the first conversation message is persisted
- Every persisted event receives a strictly monotonic
seq - The session transcript is the source of truth for both:
- replaying the visible REPL state on resume
- rebuilding the conversation state for the next LLM request
- Compaction is represented as a
compaction_appliedevent that includes the replacement conversation state - Permission prompts are never persisted
- Provider and model are never persisted; the resumed session uses the current runtime configuration
On resume, Keen will:
- Discover sessions only for the current working directory
- Let the user pick a prior session with
/sessions - Replay
transcript_events.jsonlto rebuild the visible REPL transcript - Rebuild
AppState.messagesby projecting only the conversation-relevant events and applying compaction replacement events when present
This preserves the user-visible session history without polluting future model context with UI-only artifacts.
Goals¶
- Preserve REPL state across process exits
- Restore the visible transcript as closely as possible
- Preserve enough conversation state to continue the session correctly
- Keep session storage inspectable and deterministic
- Scope session discovery to the current working directory
- Support future evolution of the event format without redesigning the system
Non-Goals¶
- Persisting provider or model selection
- Persisting context window calculations
- Persisting permission prompts or permission decisions
- Truncating large bash outputs or diffs for v1
- Merging sessions across working directories
- Supporting cross-session branching in v1
Storage Layout¶
Each working directory gets its own session namespace:
~/.keen/sessions/<working-dir-sanitized>-<hash>/
Each session lives in a child directory:
<timestamp>-<session-id>/
Where:
working-dir-sanitizedis the absolute working directory transformed into a filesystem-safe readable slughashis a short hash suffix derived from the absolute working directorytimestampis the session creation timestampsession-idis a generated UUID
Each session directory contains:
transcript_events.jsonl
No additional files are required for v1.
Session Discovery¶
Session discovery is scoped to the current working directory only.
Keen will:
- Derive the current working-directory namespace from the current absolute cwd
- Scan only that directory under
~/.keen/sessions/ - Load sessions by reading
transcript_events.jsonl - Sort sessions by last updated sequence or timestamp, newest first
Because discovery is already cwd-scoped, resume does not need to warn about cross-directory mismatches in v1.
User Experience¶
Creating A Session¶
A session is created only when there is at least one conversation message.
That means:
- starting the REPL does not create a session
- viewing help does not create a session
- permission prompts do not create a session
- the first persisted user message causes the session directory and transcript file to be created
Listing Sessions¶
Users invoke /sessions to open a session picker.
The picker will:
- show only sessions for the current working directory
- sort most recently updated sessions first
- allow navigation with arrow keys
- resume the selected session on Enter
Each item shows:
- created-at timestamp
- updated-at timestamp
- preview of the first user message, trimmed for display
If no sessions exist for the current directory, Keen shows an empty-state message instead of opening the picker.
Resume Flow¶
When a session is resumed, Keen restores the visible REPL transcript, including:
- user messages
- assistant messages
- interrupted assistant messages
- thinking/reasoning text
- tool interactions
- diffs
- bash commands and outputs
- compaction status entries
The resumed session does not restore:
- provider
- model
- context status
- permission prompts
After replay completes, the user can continue the session immediately. The next
LLM request uses the rebuilt AppState.messages derived from the transcript
projection rules in this RFC.
Event Log Format¶
transcript_events.jsonl is an append-only JSONL file. Each line is a single
self-contained event.
Every event has:
seq: strictly monotonic sequence number within the sessionkind: event typepayload: event-specific data
Recommended envelope:
{
"seq": 7,
"kind": "assistant_message",
"payload": {
"content": "Updated the parser and added tests."
}
}
The event stream is the only persisted state for the session.
Event Types¶
The v1 event kinds are:
session_starteduser_messageassistant_messageassistant_interruptedreasoning_messagetool_starttool_endbash_startbash_enddiffcompaction_applied
session_started¶
Contains static session metadata:
session_idcreated_atcwd
This is the first event in every session file.
user_message¶
Contains:
content
Used for transcript replay and conversation-state rebuilding.
assistant_message¶
Contains:
content
Used for transcript replay and conversation-state rebuilding.
assistant_interrupted¶
Contains:
content
Represents a partial assistant response persisted when the user interrupts the stream. This event is rendered in the transcript and included in rebuilt conversation state because that matches current runtime behavior.
reasoning_message¶
Contains:
content
Used only for transcript replay. This event does not contribute to
AppState.messages.
tool_start¶
Contains enough data to reproduce the visible tool-start line:
nameinput
This event is transcript-only.
tool_end¶
Contains enough data to reproduce the visible tool-end line:
nameinputoutputerrorduration_ns
This event is transcript-only.
bash_start¶
Contains:
commandsummary
This event is transcript-only.
bash_end¶
Contains:
commandsummaryoutputerrorduration_ns
This event is transcript-only.
diff¶
Contains the rendered diff lines needed to reproduce the visible diff output.
This event is transcript-only.
compaction_applied¶
Contains:
- a status message or compacted summary representation for transcript replay
- the full replacement conversation state as
[]llm.Message
This event is special:
- transcript replay shows a compaction status entry
- conversation rebuild replaces the accumulated message slice with the payload in this event
This is how Keen preserves compaction semantics without rendering the compacted message content as if it were ordinary visible chat.
Replay Model¶
The session file supports two separate projections.
Transcript Projection¶
The transcript projection rebuilds the visible REPL state.
It:
- replays user and assistant messages
- replays interrupted assistant messages
- replays reasoning text
- replays tool start/end entries
- replays bash start/end entries
- replays diffs
- replays compaction status entries
- skips permission prompts entirely
This projection is responsible for rebuilding the output shown in the viewport when a session resumes.
Conversation Projection¶
The conversation projection rebuilds AppState.messages.
It:
- appends
user_messageasllm.RoleUser - appends
assistant_messageasllm.RoleAssistant - appends
assistant_interruptedasllm.RoleAssistant - ignores reasoning events
- ignores tool events
- ignores bash events
- ignores diff events
- ignores permission prompts
- replaces the accumulated message slice when a
compaction_appliedevent is encountered
This is the state used for the next StreamChat() call.
Rendering Semantics¶
To make replay reliable, persisted events must capture the content needed to reconstruct the UI without depending on live streaming state.
That means:
- tool events are persisted as completed transcript events, not as in-flight UI state
- bash events capture the final rendered output
- diff events store the same diff lines that were shown in the REPL
- interrupted assistant events capture the exact persisted text that the user saw after interruption
- compaction replay uses a stable synthetic status line
The replay layer should not attempt to restore spinner state, cursor state, or streaming state.
Ordering And Durability¶
Events are ordered only by seq.
Requirements:
seqstarts at1for a new session- each appended event increments
seqby1 - replay uses file order and
seqconsistency checks - event appends are atomic at the line level from Keen's point of view
Keen should write events promptly after the corresponding state change so a crash loses as little session history as possible.
Corruption Handling¶
Session replay must be tolerant of malformed lines.
On load:
- read the file line by line
- attempt to decode each JSON object independently
- if a line is malformed, skip it and continue
- do not fail the whole session because of one bad event
Replay must also tolerate orphaned transcript events, for example:
tool_endwithout a matchingtool_startbash_endwithout a matchingbash_start
In those cases Keen should still render the surviving event as sensibly as possible instead of failing the resume flow.
Current Runtime Mapping¶
The design maps cleanly to the current REPL architecture:
AppStateremains the source of in-memory conversation messagesOutputBuilderremains the source of persisted non-stream transcript linesStreamHandleralready knows how to represent reasoning, tool activity, bash output, permission requests, and diffs while streaming- persistent sessions add a storage and replay layer around those structures
The key architectural change is that Keen will persist transcript events at the same points where it currently mutates in-memory state or renders durable transcript output.
Concrete Implementation Plan¶
1. Add A Session Package¶
Create a new package under internal/session to encapsulate:
- working-directory namespace derivation
- session path generation
- session creation
- JSONL event append
- session discovery
- session loading
- transcript and conversation projection logic
This keeps persistence concerns out of the REPL view code.
2. Define Event Types And Codecs¶
Add Go types for:
- event envelope
- each event payload
- session summary used by the picker
Implement:
- JSON encoding for appends
- line-by-line JSON decoding for replay
- tolerant malformed-line handling
3. Integrate Session Writing Into REPL State Changes¶
Persist events at the durable state boundaries:
- when the first user message is added
- when an assistant message completes
- when an assistant message is interrupted
- when reasoning text needs to be part of the replayed transcript
- when tool start/end events are rendered
- when bash start/end events are rendered
- when diffs are emitted
- when compaction succeeds
Permission requests are intentionally not persisted.
4. Add Session Replay¶
On resume:
- load and decode the JSONL event stream
- rebuild the transcript projection into the REPL output
- rebuild the conversation projection into
AppState.messages - recompute context status from the rebuilt
AppState
The current provider/model configuration is used after replay completes.
5. Add Session Picker UI¶
Add a new /sessions command that:
- loads session summaries for the current directory
- shows the picker in the REPL
- supports up/down navigation and Enter to resume
Optional follow-up:
/resumecan become an alias that opens the same picker
6. Add Tests¶
Add tests for:
- working-directory slug and hash generation
- lazy session creation
- event append ordering
- malformed-line tolerance
- transcript replay
- conversation rebuild
- compaction replacement semantics
- interrupted assistant persistence
- picker sorting and preview generation
Granular Todo List¶
Session Storage¶
- Create
internal/session/package - Add helper to derive the session namespace from the current cwd
- Add helper to sanitize the cwd for use in a directory name
- Add helper to compute the short cwd hash suffix
- Add helper to generate session directory names from timestamp and UUID
- Add helper to lazily create the session directory on first persisted message
- Add helper to resolve
transcript_events.jsonl - Add append logic for writing one JSON event per line
- Add load logic for reading one JSON event per line
- Add malformed-line skip behavior in the loader
Event Schema¶
- Define event envelope with
seq,kind, and typed payload - Define payload for
session_started - Define payload for
user_message - Define payload for
assistant_message - Define payload for
assistant_interrupted - Define payload for
reasoning_message - Define payload for
tool_start - Define payload for
tool_end - Define payload for
bash_start - Define payload for
bash_end - Define payload for
diff - Define payload for
compaction_applied - Add encode/decode tests for each payload type
Replay And Projections¶
- Add transcript replay projection
- Add conversation rebuild projection
- Add compaction replacement handling in the conversation projection
- Add transcript rendering for reasoning events
- Add transcript rendering for tool events
- Add transcript rendering for bash events
- Add transcript rendering for diff events
- Add transcript rendering for compaction status entries
- Add orphaned-event tolerance for replay
REPL Integration¶
- Add session manager/state to the REPL model
- Initialize session state lazily instead of at REPL startup
- Persist
session_startedwhen the first conversation message is saved - Persist
user_messageafter the user submits a normal prompt - Persist
assistant_messagewhen streaming completes - Persist
assistant_interruptedwhen the stream is cancelled by the user - Persist reasoning text so it can be replayed on resume
- Persist tool interaction events from the stream handlers
- Persist bash events including final output
- Persist diff events emitted during edits
- Persist
compaction_appliedwith replacement[]llm.Message - Rebuild
AppState.messagesfrom a loaded session - Rebuild the visible output from a loaded session
- Recompute context status after session replay
Commands And UI¶
- Add
/sessionsto the slash command list - Add picker model/state for saved sessions
- Add empty-state handling when no sessions exist
- Add session item rendering with created-at, updated-at, and preview
- Add keyboard navigation for the picker
- Add Enter-to-resume behavior
- Add
/resumeas an alias or dedicated entry point to the picker
Testing¶
- Add unit tests for the session package
- Add replay tests for malformed JSONL lines
- Add tests for session ordering by most recent update
- Add tests for first-message preview generation
- Add tests for lazy creation semantics
- Add tests for compaction replacing prior conversation state
- Add tests for interrupted assistant replay
- Add REPL tests for
/sessions - Add REPL tests for successful resume into an existing transcript
Verification¶
- Run
go mod tidy - Run
go test ./... - Review the RFC and implementation plan against the current REPL flow before starting code changes
Open Questions¶
These do not block the RFC, but they should be settled during implementation:
- Should
/resumebe a visible alias for/sessions, or should only one command remain in the final UX? - I think
/resumecan be an alias for/sessions. - Should
updated_atbe derived from the last event when listing sessions, or should the session package maintain a cached summary during append? - We should derive the
updated_atfrom the last event when listing sessions. - Should the session picker render local times using the terminal locale or a fixed format?
- We should render local times using the terminal locale.
Recommendation¶
Proceed with the single-file JSONL session log design described here.
It matches Keen's current architecture well:
- it preserves full visible session replay
- it keeps future LLM context limited to meaningful conversation state
- it handles compaction cleanly
- it avoids persisting transient permission UI
- it stays inspectable and debuggable on disk
This is the right foundation for persistent sessions in Keen Code.