Skip to content

Commit 2e7949c

Browse files
halfaipgclaude
andcommitted
feat: add session persistence across restarts
Conversation history saved to ~/.codebase/sessions/ after each agent round. Keyed by working directory hash. Restored on next launch in the same directory with the same model. - Atomic writes (temp file + rename) - 7-day session expiry with background cleanup - Model mismatch = fresh session - Graceful degradation (corrupt/missing files silently ignored) 38 tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6e8b432 commit 2e7949c

3 files changed

Lines changed: 183 additions & 0 deletions

File tree

chat.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,16 @@ func (m *chatModel) startAgent(prompt string) {
259259
if m.agent == nil {
260260
client := NewLLMClient(m.config.APIKey, m.config.BaseURL, m.config.Model)
261261
m.agent = NewAgent(client, m.config.WorkDir, m.eventCh, m.stopCh)
262+
263+
// Try to restore a previous session
264+
if session := LoadSession(m.config.WorkDir, m.config.Model); session != nil {
265+
m.agent.history = session.History
266+
m.tokens = session.Tokens
267+
m.segments = append(m.segments, segment{
268+
kind: "text",
269+
text: styleMuted.Render(" Session restored from previous conversation.\n\n"),
270+
})
271+
}
262272
} else {
263273
m.agent.events = m.eventCh
264274
m.agent.stopCh = m.stopCh
@@ -328,6 +338,8 @@ func (m *chatModel) handleAgentEvent(evt AgentEvent) tea.Cmd {
328338
m.files = 0
329339
if m.agent != nil {
330340
m.files = m.agent.FilesChanged()
341+
// Persist session to disk
342+
SaveSession(m.agent, m.tokens)
331343
}
332344
m.state = chatDoneFlash
333345
m.flashFrames = 3

main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ func main() {
8888
os.Exit(1)
8989
}
9090

91+
// Clean up stale sessions in the background
92+
go CleanStaleSessions()
93+
9194
p := tea.NewProgram(
9295
newAppModel(cfg),
9396
tea.WithAltScreen(),

session.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package main
2+
3+
import (
4+
"crypto/sha256"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"time"
10+
)
11+
12+
// ──────────────────────────────────────────────────────────────
13+
// Session Persistence
14+
//
15+
// Saves conversation history to ~/.codebase/sessions/ so users
16+
// can quit and resume. Sessions are keyed by working directory.
17+
// ──────────────────────────────────────────────────────────────
18+
19+
const maxSessionAge = 7 * 24 * time.Hour // 7 days
20+
21+
type SessionData struct {
22+
WorkDir string `json:"work_dir"`
23+
Model string `json:"model"`
24+
History []ChatMessage `json:"history"`
25+
Tokens TokenUsage `json:"tokens"`
26+
Files int `json:"files"`
27+
UpdatedAt time.Time `json:"updated_at"`
28+
}
29+
30+
// sessionsDir returns the path to the sessions directory, creating it if needed.
31+
func sessionsDir() (string, error) {
32+
home, err := os.UserHomeDir()
33+
if err != nil {
34+
return "", err
35+
}
36+
dir := filepath.Join(home, ".codebase", "sessions")
37+
if err := os.MkdirAll(dir, 0700); err != nil {
38+
return "", err
39+
}
40+
return dir, nil
41+
}
42+
43+
// sessionFile returns the session file path for a given working directory.
44+
func sessionFile(workDir string) (string, error) {
45+
dir, err := sessionsDir()
46+
if err != nil {
47+
return "", err
48+
}
49+
// Hash the workdir to get a stable filename
50+
h := sha256.Sum256([]byte(workDir))
51+
name := fmt.Sprintf("%x.json", h[:8])
52+
return filepath.Join(dir, name), nil
53+
}
54+
55+
// SaveSession persists the agent's conversation to disk.
56+
func SaveSession(agent *Agent, tokens TokenUsage) error {
57+
if agent == nil || len(agent.history) <= 1 {
58+
return nil // nothing to save (just system prompt)
59+
}
60+
61+
path, err := sessionFile(agent.workDir)
62+
if err != nil {
63+
return err
64+
}
65+
66+
data := SessionData{
67+
WorkDir: agent.workDir,
68+
Model: agent.client.Model,
69+
History: agent.history,
70+
Tokens: tokens,
71+
Files: agent.files,
72+
UpdatedAt: time.Now(),
73+
}
74+
75+
jsonData, err := json.MarshalIndent(data, "", " ")
76+
if err != nil {
77+
return err
78+
}
79+
80+
// Atomic write: write to temp file, then rename
81+
tmpPath := path + ".tmp"
82+
if err := os.WriteFile(tmpPath, jsonData, 0600); err != nil {
83+
return err
84+
}
85+
return os.Rename(tmpPath, path)
86+
}
87+
88+
// LoadSession restores a previous conversation for the given working directory.
89+
// Returns nil if no session exists or it's too old.
90+
func LoadSession(workDir, model string) *SessionData {
91+
path, err := sessionFile(workDir)
92+
if err != nil {
93+
return nil
94+
}
95+
96+
data, err := os.ReadFile(path)
97+
if err != nil {
98+
return nil
99+
}
100+
101+
var session SessionData
102+
if err := json.Unmarshal(data, &session); err != nil {
103+
return nil
104+
}
105+
106+
// Check age
107+
if time.Since(session.UpdatedAt) > maxSessionAge {
108+
os.Remove(path) // clean up stale session
109+
return nil
110+
}
111+
112+
// Check model match (different model = different conversation)
113+
if session.Model != model {
114+
return nil
115+
}
116+
117+
// Check workdir match
118+
if session.WorkDir != workDir {
119+
return nil
120+
}
121+
122+
return &session
123+
}
124+
125+
// ClearSession removes the session file for a working directory.
126+
func ClearSession(workDir string) error {
127+
path, err := sessionFile(workDir)
128+
if err != nil {
129+
return err
130+
}
131+
err = os.Remove(path)
132+
if os.IsNotExist(err) {
133+
return nil
134+
}
135+
return err
136+
}
137+
138+
// CleanStaleSessions removes sessions older than maxSessionAge.
139+
func CleanStaleSessions() {
140+
dir, err := sessionsDir()
141+
if err != nil {
142+
return
143+
}
144+
145+
entries, err := os.ReadDir(dir)
146+
if err != nil {
147+
return
148+
}
149+
150+
for _, e := range entries {
151+
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
152+
continue
153+
}
154+
path := filepath.Join(dir, e.Name())
155+
data, err := os.ReadFile(path)
156+
if err != nil {
157+
continue
158+
}
159+
var session SessionData
160+
if err := json.Unmarshal(data, &session); err != nil {
161+
os.Remove(path) // corrupt, remove
162+
continue
163+
}
164+
if time.Since(session.UpdatedAt) > maxSessionAge {
165+
os.Remove(path)
166+
}
167+
}
168+
}

0 commit comments

Comments
 (0)