//go:build !windows package infra import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "strconv" "strings" ) // sessionFile mirrors the on-disk shape of ~/.claude/sessions/.json // written by Claude Code 2.1.x. Only the fields we consume are declared. type sessionFile struct { PID int `json:"pid"` SessionID string `json:"sessionId"` Cwd string `json:"cwd"` ProcStart string `json:"procStart"` Status string `json:"status"` UpdatedAt int64 `json:"updatedAt"` } // goalFile mirrors the on-disk shape of ~/.claude/goals/.json. type goalFile struct { Goal string `json:"goal"` Phase string `json:"phase"` Emojis string `json:"emojis"` Rename string `json:"rename"` } // runtimeFile mirrors ~/.claude/runtime/.json written by statusline.sh // with the live context-window usage of that session. type runtimeFile struct { CtxPct int `json:"ctx_pct"` } // ListClaudeFleet scans the current user's ~/.claude directory and returns the // fleet of Claude Code sessions known to the machine. It is a thin wrapper over // ListClaudeFleetFrom resolving the home directory. func ListClaudeFleet() ([]ClaudeFleet, error) { home, err := os.UserHomeDir() if err != nil { return nil, fmt.Errorf("resolve home dir: %w", err) } return ListClaudeFleetFrom(filepath.Join(home, ".claude")) } // ListClaudeFleetFrom scans claudeDir (e.g. ~/.claude) and returns the fleet of // Claude Code sessions. It reads sessions/*.json, joins each against its // goals/.json sidecar, validates liveness against /proc (guarding // against PID recycling), and derives the display fields. // // Every session that produced a parseable JSON is returned; the Alive flag // reflects whether the underlying process is actually running. The caller is // expected to filter on Alive as needed. Records are ordered by status // (idle, waiting, busy, other) and within a status by UpdatedAt descending. func ListClaudeFleetFrom(claudeDir string) ([]ClaudeFleet, error) { sessionsDir := filepath.Join(claudeDir, "sessions") goalsDir := filepath.Join(claudeDir, "goals") runtimeDir := filepath.Join(claudeDir, "runtime") entries, err := os.ReadDir(sessionsDir) if err != nil { if os.IsNotExist(err) { return []ClaudeFleet{}, nil } return nil, fmt.Errorf("read sessions dir %q: %w", sessionsDir, err) } fleet := make([]ClaudeFleet, 0, len(entries)) 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 } f := ClaudeFleet{ PID: sess.PID, SessionID: sess.SessionID, Status: sess.Status, Cwd: sess.Cwd, UpdatedAt: sess.UpdatedAt, TmuxWindow: "", } // Liveness + anti-PID-recycling: the process must exist AND its // /proc starttime must match the procStart recorded in the JSON. f.Alive = procIsAlive(sess.PID, sess.ProcStart) // KITTY_PID from the process environ (0 if unreadable / absent). f.KittyPID = readKittyPID(sess.PID) // Join goal/phase/emojis/name from goals/.json (optional). f.Goal, f.Phase, f.Emojis, f.Name = readGoal(goalsDir, sess.SessionID) // Context usage from runtime/.json (written by statusline). f.CtxPct = readCtxPct(runtimeDir, sess.SessionID) // Derived display fields. f.Target = deriveTarget(sess.SessionID, sess.Cwd) f.Rename = deriveRename(f.Goal, sess.Cwd) fleet = append(fleet, f) } sortFleet(fleet) return fleet, nil } // procIsAlive reports whether pid is running and its kernel starttime matches // procStartJSON. An empty procStartJSON only requires the process to exist. func procIsAlive(pid int, procStartJSON string) bool { real, ok := procStartTime(pid) if !ok { return false } if procStartJSON == "" { return true } return strings.TrimSpace(procStartJSON) == strings.TrimSpace(real) } // procStartTime returns field 22 (starttime, in clock ticks) of // /proc//stat. The comm field (field 2) is wrapped in parentheses and may // itself contain spaces and ')' characters, so we parse the portion after the // LAST ')' and index from there: starttime is index 20 of that remainder // (fields 3..n), which is field 22 globally. func procStartTime(pid int) (string, bool) { data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) if err != nil { return "", false } s := string(data) close := strings.LastIndex(s, ")") if close < 0 || close+1 >= len(s) { return "", false } rest := strings.Fields(s[close+1:]) // rest[0] = state (field 3); starttime (field 22) is index 19 here: // field N maps to rest[N-3]. 22 - 3 = 19. const startTimeIdx = 19 if len(rest) <= startTimeIdx { return "", false } return rest[startTimeIdx], true } // readKittyPID parses /proc//environ (NUL-separated KEY=VALUE pairs) and // returns the KITTY_PID value. Returns 0 if the environ is unreadable, the key // is absent, or the value is not an integer. func readKittyPID(pid int) int { data, err := os.ReadFile(fmt.Sprintf("/proc/%d/environ", pid)) if err != nil { return 0 } for _, kv := range strings.Split(string(data), "\x00") { if v, ok := strings.CutPrefix(kv, "KITTY_PID="); ok { n, convErr := strconv.Atoi(strings.TrimSpace(v)) if convErr != nil { return 0 } return n } } return 0 } // readGoal reads goals/.json and returns its goal, phase, emojis and // manual rename. If the file is absent or unparseable, all are "". func readGoal(goalsDir, sessionID string) (goal, phase, emojis, rename string) { raw, err := os.ReadFile(filepath.Join(goalsDir, sessionID+".json")) if err != nil { return "", "", "", "" } var g goalFile if json.Unmarshal(raw, &g) != nil { return "", "", "", "" } return g.Goal, g.Phase, g.Emojis, g.Rename } // readCtxPct reads runtime/.json and returns the context-window used // percentage. Returns -1 if the file is absent or unparseable (unknown). func readCtxPct(runtimeDir, sessionID string) int { raw, err := os.ReadFile(filepath.Join(runtimeDir, sessionID+".json")) if err != nil { return -1 } var r runtimeFile if json.Unmarshal(raw, &r) != nil { return -1 } return r.CtxPct } // deriveTarget builds sessionID[:8] + "@" + basename(cwd). If sessionID is // shorter than 8 runes it is used whole. func deriveTarget(sessionID, cwd string) string { short := sessionID if r := []rune(sessionID); len(r) >= 8 { short = string(r[:8]) } return short + "@" + filepath.Base(cwd) } // deriveRename returns goal truncated to 48 runes if non-empty, else // basename(cwd). func deriveRename(goal, cwd string) string { if goal != "" { return truncateRunes(goal, 48) } return filepath.Base(cwd) } // truncateRunes returns s capped at max runes (no ellipsis). func truncateRunes(s string, max int) string { r := []rune(s) if len(r) <= max { return s } return string(r[:max]) } // sortFleet orders the fleet by status rank then by UpdatedAt descending. func sortFleet(fleet []ClaudeFleet) { rank := func(status string) int { switch status { case "idle": return 0 case "waiting": return 1 case "busy": return 2 default: return 3 } } sort.SliceStable(fleet, func(i, j int) bool { ri, rj := rank(fleet[i].Status), rank(fleet[j].Status) if ri != rj { return ri < rj } return fleet[i].UpdatedAt > fleet[j].UpdatedAt }) }