Files
kit/internal/session/store.go
Ed Zynda 4c1bda9541 feat: auto-cleanup empty sessions on shutdown and /resume
Empty sessions (no messages) are now automatically cleaned up:

1. On shutdown: When kit exits cleanly, if the current session has no
   messages, the session file is deleted.

2. On /resume: When listing sessions for the resume picker, any empty
   session files are deleted and not shown in the list.

This prevents accumulation of orphaned empty session files when users
start sessions but don't send any messages.

Changes:
- internal/session/tree_manager.go: add IsEmpty() helper
- internal/app/app.go: delete empty session on Close()
- internal/session/store.go: filter and delete empty sessions in listSessionsInDir()
2026-03-26 15:46:51 +03:00

262 lines
6.5 KiB
Go

package session
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// SessionInfo contains metadata about a discovered session, used for listing
// and session picker display.
type SessionInfo struct {
// Path is the absolute path to the JSONL session file.
Path string
// ID is the session UUID from the header.
ID string
// Cwd is the working directory the session was created in.
Cwd string
// Name is the user-defined display name (from session_info entries).
Name string
// ParentSessionPath is the parent session path if this session was forked.
ParentSessionPath string
// ParentSessionID is the UUID of the parent session (for subagent sessions).
ParentSessionID string
// SubagentTask is the original task prompt (for subagent sessions).
SubagentTask string
// Created is when the session was first created.
Created time.Time
// Modified is the timestamp of the last activity (latest message).
Modified time.Time
// MessageCount is the number of message entries in the session.
MessageCount int
// FirstMessage is a preview of the first user message.
FirstMessage string
}
// ListSessions finds all sessions for a given working directory, sorted by
// modification time (newest first).
func ListSessions(cwd string) ([]SessionInfo, error) {
sessionDir := DefaultSessionDir(cwd)
return listSessionsInDir(sessionDir)
}
// ListAllSessions finds all sessions across all working directories, sorted
// by modification time (newest first).
func ListAllSessions() ([]SessionInfo, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to find home directory: %w", err)
}
sessionsRoot := filepath.Join(home, ".kit", "sessions")
if _, err := os.Stat(sessionsRoot); os.IsNotExist(err) {
return nil, nil
}
var allSessions []SessionInfo
dirs, err := os.ReadDir(sessionsRoot)
if err != nil {
return nil, fmt.Errorf("failed to read sessions directory: %w", err)
}
for _, dir := range dirs {
if !dir.IsDir() {
continue
}
dirPath := filepath.Join(sessionsRoot, dir.Name())
sessions, err := listSessionsInDir(dirPath)
if err != nil {
continue // skip unreadable directories
}
allSessions = append(allSessions, sessions...)
}
// Sort by modification time, newest first.
sort.Slice(allSessions, func(i, j int) bool {
return allSessions[i].Modified.After(allSessions[j].Modified)
})
return allSessions, nil
}
// listSessionsInDir reads all .jsonl files in a directory and extracts session info.
// Empty sessions (no messages) are automatically cleaned up and not returned.
func listSessionsInDir(dir string) ([]SessionInfo, error) {
if _, err := os.Stat(dir); os.IsNotExist(err) {
return nil, nil
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("failed to read directory %s: %w", dir, err)
}
var sessions []SessionInfo
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
continue
}
path := filepath.Join(dir, entry.Name())
info, err := extractSessionInfo(path)
if err != nil {
continue // skip malformed session files
}
// Clean up and skip empty sessions (no messages)
if info.MessageCount == 0 {
_ = os.Remove(path)
continue
}
sessions = append(sessions, *info)
}
// Sort by modification time, newest first.
sort.Slice(sessions, func(i, j int) bool {
return sessions[i].Modified.After(sessions[j].Modified)
})
return sessions, nil
}
// extractSessionInfo reads a JSONL session file and extracts metadata.
// It only reads enough of the file to get the header and scan for messages.
func extractSessionInfo(path string) (*SessionInfo, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
info := &SessionInfo{
Path: path,
}
scanner := bufio.NewScanner(f)
// Increase scanner buffer for large lines.
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
lineNum := 0
var lastTimestamp time.Time
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "" {
continue
}
lineNum++
if lineNum == 1 {
// Parse header.
var h SessionHeader
if err := json.Unmarshal([]byte(line), &h); err != nil {
return nil, fmt.Errorf("failed to parse header: %w", err)
}
if h.Type != EntryTypeSession {
return nil, fmt.Errorf("first line is not a session header")
}
info.ID = h.ID
info.Cwd = h.Cwd
info.Created = h.Timestamp
info.Modified = h.Timestamp
info.ParentSessionPath = h.ParentSession
info.ParentSessionID = h.ParentSessionID
info.SubagentTask = h.SubagentTask
continue
}
// For subsequent lines, only parse enough to get type and timestamp.
var env struct {
Type EntryType `json:"type"`
Timestamp time.Time `json:"timestamp"`
Role string `json:"role,omitempty"`
Name string `json:"name,omitempty"`
}
if err := json.Unmarshal([]byte(line), &env); err != nil {
continue
}
if !env.Timestamp.IsZero() && env.Timestamp.After(lastTimestamp) {
lastTimestamp = env.Timestamp
}
switch env.Type {
case EntryTypeMessage:
info.MessageCount++
// Capture first user message as preview.
if env.Role == "user" && info.FirstMessage == "" {
var msgEntry struct {
Parts json.RawMessage `json:"parts"`
}
if err := json.Unmarshal([]byte(line), &msgEntry); err == nil {
info.FirstMessage = extractTextPreview(msgEntry.Parts)
}
}
case EntryTypeSessionInfo:
if env.Name != "" {
info.Name = env.Name
}
}
}
if !lastTimestamp.IsZero() {
info.Modified = lastTimestamp
}
// Fall back to file modification time if no timestamps found.
if info.Modified.IsZero() {
fi, err := os.Stat(path)
if err == nil {
info.Modified = fi.ModTime()
}
}
return info, nil
}
// extractTextPreview extracts a short text preview from type-tagged parts JSON.
func extractTextPreview(partsJSON json.RawMessage) string {
var parts []struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(partsJSON, &parts); err != nil {
return ""
}
for _, p := range parts {
if p.Type == "text" {
var text struct {
Text string `json:"text"`
}
if err := json.Unmarshal(p.Data, &text); err == nil && text.Text != "" {
preview := text.Text
if len(preview) > 100 {
preview = preview[:100] + "..."
}
return preview
}
}
}
return ""
}
// DeleteSession removes a session file from disk.
func DeleteSession(path string) error {
return os.Remove(path)
}