From 8ae0fad8a302b93d3e13f5b6b6656508f7137b1d Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Thu, 21 May 2026 15:08:16 +0200 Subject: [PATCH] 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 creates a named checkpoint - /resume restores a saved session (messages + task state) - Autosave (already existed) now persists cwd with each save --- pkg/agent/commands.go | 90 +++++++++++++++++++++++++++++++++--------- pkg/agent/core.go | 2 +- pkg/agent/core_test.go | 4 +- pkg/agent/session.go | 4 +- 4 files changed, 78 insertions(+), 22 deletions(-) diff --git a/pkg/agent/commands.go b/pkg/agent/commands.go index 64f4b32..326a0bb 100644 --- a/pkg/agent/commands.go +++ b/pkg/agent/commands.go @@ -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 - save session as a named checkpoint", + " /resume - resume a saved session", " /agent [name] - show or switch active agent", " /backend [type] - show current backend, or switch to ", " /model [name] - show current model, or switch to ", diff --git a/pkg/agent/core.go b/pkg/agent/core.go index c8185cd..ab6cf32 100644 --- a/pkg/agent/core.go +++ b/pkg/agent/core.go @@ -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) } } diff --git a/pkg/agent/core_test.go b/pkg/agent/core_test.go index 8f9e021..f94cd2f 100644 --- a/pkg/agent/core_test.go +++ b/pkg/agent/core_test.go @@ -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) } diff --git a/pkg/agent/session.go b/pkg/agent/session.go index 780d2c5..f79f5a0 100644 --- a/pkg/agent/session.go +++ b/pkg/agent/session.go @@ -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, }