//go:build !windows package infra import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" ) // ResumableClaude describes a CLOSED Claude Code session that still has a saved // goal and can therefore be reopened with `claude --resume `. The // fleetview TUI consumes these for its "resume" picker. type ResumableClaude struct { SessionID string `json:"session_id"` Goal string `json:"goal"` // from goals/.json .goal ("" if absent) Emojis string `json:"emojis"` // from goals/.json .emojis ("" if absent) Name string `json:"name"` // from goals/.json .rename ("" if absent) LastActive int64 `json:"last_active"` // mtime of the goal.json file, epoch seconds } // maxResumable caps the number of resumable sessions returned, keeping only the // most recently touched ones. const maxResumable = 40 // ListResumableClaudes scans the current user's ~/.claude directory and returns // the closed sessions that can be reopened with `claude --resume`. It is a thin // wrapper over ListResumableClaudesFrom resolving the home directory. func ListResumableClaudes() ([]ResumableClaude, error) { home, err := os.UserHomeDir() if err != nil { return nil, fmt.Errorf("resolve home dir: %w", err) } return ListResumableClaudesFrom(filepath.Join(home, ".claude")) } // ListResumableClaudesFrom scans claudeDir (e.g. ~/.claude) and returns the // sessions that have a goal (goals/.json) whose process is NOT alive — i.e. // candidates to reopen with `claude --resume `. // // A session is considered live (and thus excluded) when sessions/.json // reports a PID whose /proc starttime matches the recorded procStart, using the // exact same liveness criterion as ListClaudeFleetFrom (procIsAlive). Goals // without a non-empty goal string are skipped. Results are ordered by // LastActive descending and capped at maxResumable. func ListResumableClaudesFrom(claudeDir string) ([]ResumableClaude, error) { sessionsDir := filepath.Join(claudeDir, "sessions") goalsDir := filepath.Join(claudeDir, "goals") // 1. Build the set of LIVE sessionIds from sessions/*.json. live := liveSessionIDs(sessionsDir) // 2. Scan goals/*.json. entries, err := os.ReadDir(goalsDir) if err != nil { if os.IsNotExist(err) { return []ResumableClaude{}, nil } return nil, fmt.Errorf("read goals dir %q: %w", goalsDir, err) } out := make([]ResumableClaude, 0, len(entries)) for _, entry := range entries { name := entry.Name() if entry.IsDir() || !strings.HasSuffix(name, ".json") { continue } sessionID := strings.TrimSuffix(name, ".json") if sessionID == "" { continue } // Skip sessions that are alive (already in the fleet, not resumable). if live[sessionID] { continue } path := filepath.Join(goalsDir, name) raw, readErr := os.ReadFile(path) if readErr != nil { continue } var g goalFile if json.Unmarshal(raw, &g) != nil { continue } // No real work to resume without a goal. if strings.TrimSpace(g.Goal) == "" { continue } info, statErr := os.Stat(path) if statErr != nil { continue } out = append(out, ResumableClaude{ SessionID: sessionID, Goal: g.Goal, Emojis: g.Emojis, Name: g.Rename, LastActive: info.ModTime().Unix(), }) } // 3. Order by LastActive descending (most recent first). sort.SliceStable(out, func(i, j int) bool { return out[i].LastActive > out[j].LastActive }) // 4. Cap at maxResumable. if len(out) > maxResumable { out = out[:maxResumable] } return out, nil } // liveSessionIDs scans sessionsDir (sessions/*.json) and returns the set of // sessionIds whose process is currently alive, applying the same anti-PID- // recycling check as ListClaudeFleetFrom (procIsAlive matches /proc starttime // against the recorded procStart). Missing or unparseable files are ignored. func liveSessionIDs(sessionsDir string) map[string]bool { live := make(map[string]bool) entries, err := os.ReadDir(sessionsDir) if err != nil { return live } for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { continue } raw, readErr := os.ReadFile(filepath.Join(sessionsDir, entry.Name())) if readErr != nil { continue } var sess sessionFile if json.Unmarshal(raw, &sess) != nil { continue } if sess.PID == 0 || sess.SessionID == "" { continue } if procIsAlive(sess.PID, sess.ProcStart) { live[sess.SessionID] = true } } return live }