session persistence: /save, /resume, cwd-scoped /sessions

- Add CWD field to PersistedSession for per-directory filtering
- /sessions now shows only sessions matching current cwd (-a for all)
- /save <name> creates a named checkpoint
- /resume <id> restores a saved session (messages + task state)
- Autosave (already existed) now persists cwd with each save
This commit is contained in:
Levi Neely 2026-05-21 15:08:16 +02:00
parent 9d4818af8d
commit 8ae0fad8a3
4 changed files with 78 additions and 22 deletions

View File

@ -342,11 +342,13 @@ func (s *agent) handleCommand(ctx context.Context, input string) bool {
},
"/sessions": func(args []string) {
allFlag := len(args) > 0 && args[0] == "-a"
entries, err := os.ReadDir(s.sessionsDir)
if err != nil {
s.emit(infoEvent(fmt.Sprintf("sessions: %v", err)))
return
}
cwd := s.CWD()
found := false
for i := len(entries) - 1; i >= 0; i-- {
e := entries[i]
@ -354,35 +356,85 @@ func (s *agent) handleCommand(ctx context.Context, input string) bool {
continue
}
id := strings.TrimSuffix(e.Name(), ".json")
data, readErr := os.ReadFile(s.sessionsDir + "/" + e.Name())
if readErr != nil {
continue
}
var ps PersistedSession
if json.Unmarshal(data, &ps) != nil {
continue
}
if !allFlag && ps.CWD != "" && ps.CWD != cwd {
continue
}
marker := " "
if id == s.sessionID {
marker = "* "
}
label := id
if data, readErr := os.ReadFile(s.sessionsDir + "/" + e.Name()); readErr == nil {
var ps PersistedSession
if json.Unmarshal(data, &ps) == nil {
goal := ""
for _, msg := range ps.Messages {
if msg.Role == "user" {
goal = msg.Content
break
}
}
if len(goal) > 60 {
goal = goal[:60] + "..."
}
label = fmt.Sprintf("%-24s [%s] %q", id, ps.Agent, goal)
goal := ""
for _, msg := range ps.Messages {
if msg.Role == "user" {
goal = msg.Content
break
}
}
s.emit(infoEvent(marker + label))
if len(goal) > 60 {
goal = goal[:60] + "..."
}
s.emit(infoEvent(marker + fmt.Sprintf("%-24s [%s] %q", id, ps.Agent, goal)))
found = true
}
if !found {
s.emit(infoEvent("no sessions found in " + s.sessionsDir))
s.emit(infoEvent("no sessions for " + cwd))
}
},
"/save": func(args []string) {
if s.session == nil {
s.emit(infoEvent("error: no active session"))
return
}
if len(args) == 0 {
s.emit(infoEvent("error: /save requires a name"))
return
}
name := args[0]
path := s.sessionsDir + "/" + name + ".json"
if err := s.session.saveTo(path, name, s.agentName, s.CWD()); err != nil {
s.emit(infoEvent("error: " + err.Error()))
return
}
s.emit(infoEvent("saved: " + path))
},
"/resume": func(args []string) {
if len(args) == 0 {
s.emit(infoEvent("error: /resume requires a session id or name"))
return
}
if s.IsRunning() {
s.emit(infoEvent("error: cannot resume while agent is running"))
return
}
name := args[0]
path := s.sessionsDir + "/" + name + ".json"
data, err := os.ReadFile(path)
if err != nil {
s.emit(infoEvent(fmt.Sprintf("error: %v", err)))
return
}
var ps PersistedSession
if err := json.Unmarshal(data, &ps); err != nil {
s.emit(infoEvent(fmt.Sprintf("error: %v", err)))
return
}
s.session = RestoreSession(ps.Messages)
if ps.TaskState != nil {
s.session.TaskState = ps.TaskState
}
s.emit(infoEvent(fmt.Sprintf("resumed session %s (%d messages)", name, len(ps.Messages))))
},
"/cwd": func(args []string) {
if len(args) == 0 {
s.emit(infoEvent("cwd: " + s.CWD()))
@ -407,7 +459,9 @@ func (s *agent) handleCommand(ctx context.Context, input string) bool {
lines := []string{
"Available commands:",
" /agents - list available agent configs",
" /sessions - list saved sessions",
" /sessions [-a] - list saved sessions (current cwd; -a for all)",
" /save <name> - save session as a named checkpoint",
" /resume <id> - resume a saved session",
" /agent [name] - show or switch active agent",
" /backend [type] - show current backend, or switch to <type>",
" /model [name] - show current model, or switch to <name>",

View File

@ -682,7 +682,7 @@ func (s *agent) saveSession() {
return
}
path := s.sessionsDir + "/" + s.sessionID + ".json"
if err := s.session.saveTo(path, s.sessionID, s.agentName); err != nil {
if err := s.session.saveTo(path, s.sessionID, s.agentName, s.CWD()); err != nil {
s.log.Error("session save: %v", err)
}
}

View File

@ -1250,7 +1250,7 @@ func TestCommand_Sessions_Empty(t *testing.T) {
evs := collectEvents(context.Background(), c, "/sessions")
found := false
for _, s := range byRole(evs, "info") {
if strings.Contains(s, "no sessions found") {
if strings.Contains(s, "no sessions") {
found = true
}
}
@ -2106,7 +2106,7 @@ func TestRestoreSession_RoundTrip(t *testing.T) {
s.appendUserMessage("second message")
s.messages = append(s.messages, backend.Message{Role: "assistant", Content: "reply"})
if err := s.saveTo(path, "test-id", "test-agent"); err != nil {
if err := s.saveTo(path, "test-id", "test-agent", "/tmp/test"); err != nil {
t.Fatalf("saveTo: %v", err)
}

View File

@ -37,6 +37,7 @@ Be concise. Capture what another LLM needs to seamlessly continue.`
type PersistedSession struct {
ID string `json:"id"`
Agent string `json:"agent,omitempty"`
CWD string `json:"cwd,omitempty"`
Messages []backend.Message `json:"messages"`
TaskState *TaskState `json:"taskState,omitempty"`
}
@ -81,10 +82,11 @@ func (ts *TaskState) render() string {
}
// SaveTo writes the full message history to path as JSON.
func (s *Session) saveTo(path, id, agentName string) error {
func (s *Session) saveTo(path, id, agentName, cwd string) error {
ps := PersistedSession{
ID: id,
Agent: agentName,
CWD: cwd,
Messages: s.messages,
TaskState: s.TaskState,
}