Skip to content

Commit 9b873b4

Browse files
yepzdkclaude
andcommitted
Add process detection and session summaries
- Detect running Claude processes to distinguish active vs inactive sessions - Show session summary from JSONL log entries - Add Inactive status for sessions without a running Claude process - Sort sessions by status priority (Working > Needs Input > Waiting > Idle > Inactive) - Live view only shows active sessions, with inactive count in summary Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent fd377c0 commit 9b873b4

File tree

2 files changed

+154
-23
lines changed

2 files changed

+154
-23
lines changed

internal/session/session.go

Lines changed: 115 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package session
22

33
import (
44
"bufio"
5+
"bytes"
56
"encoding/json"
67
"os"
8+
"os/exec"
79
"path/filepath"
810
"sort"
911
"strings"
@@ -18,6 +20,7 @@ const (
1820
StatusNeedsInput Status = "Needs Input"
1921
StatusWaiting Status = "Waiting"
2022
StatusIdle Status = "Idle"
23+
StatusInactive Status = "Inactive"
2124
)
2225

2326
// Session represents a Claude Code session
@@ -26,7 +29,9 @@ type Session struct {
2629
Status Status `json:"status"`
2730
LastActivity time.Time `json:"last_activity"`
2831
Task string `json:"task"`
32+
Summary string `json:"summary,omitempty"`
2933
LogFile string `json:"-"`
34+
ProjectPath string `json:"-"` // Full path to the project directory
3035
}
3136

3237
// LogEntry represents a single line in the JSONL log
@@ -35,6 +40,7 @@ type LogEntry struct {
3540
Subtype string `json:"subtype,omitempty"`
3641
Timestamp time.Time `json:"timestamp"`
3742
Message *Message `json:"message,omitempty"`
43+
Summary string `json:"summary,omitempty"` // For type: "summary" entries
3844
}
3945

4046
// Message represents the message field in a log entry
@@ -56,6 +62,52 @@ func ClaudeProjectsDir() string {
5662
return filepath.Join(home, ".claude", "projects")
5763
}
5864

65+
// getRunningClaudeDirs returns a set of encoded directory names where Claude processes are running
66+
// The keys are in the same format as the project directory names (e.g., -Users-username-Projects-...)
67+
func getRunningClaudeDirs() map[string]bool {
68+
dirs := make(map[string]bool)
69+
70+
// Use ps to get Claude process IDs (more reliable than pgrep)
71+
cmd := exec.Command("sh", "-c", "ps ax -o pid,comm | grep '[c]laude$' | awk '{print $1}'")
72+
output, err := cmd.Output()
73+
if err != nil {
74+
return dirs
75+
}
76+
77+
pids := strings.Fields(string(output))
78+
for _, pid := range pids {
79+
// Get cwd for each process using lsof
80+
lsofCmd := exec.Command("lsof", "-p", pid)
81+
lsofOutput, err := lsofCmd.Output()
82+
if err != nil {
83+
continue
84+
}
85+
86+
// Parse lsof output to find cwd
87+
lines := bytes.Split(lsofOutput, []byte("\n"))
88+
for _, line := range lines {
89+
if bytes.Contains(line, []byte(" cwd ")) {
90+
fields := bytes.Fields(line)
91+
if len(fields) >= 9 {
92+
// Last field is the path
93+
path := string(fields[len(fields)-1])
94+
// Convert to encoded format (same as project directory names)
95+
encoded := encodeProjectPath(path)
96+
dirs[encoded] = true
97+
}
98+
}
99+
}
100+
}
101+
102+
return dirs
103+
}
104+
105+
// encodeProjectPath converts a filesystem path to the encoded directory name format
106+
func encodeProjectPath(path string) string {
107+
// /Users/username/Projects/org/project -> -Users-username-Projects-org-project
108+
return strings.ReplaceAll(path, "/", "-")
109+
}
110+
59111
// Discover finds all active Claude sessions
60112
func Discover() ([]Session, error) {
61113
projectsDir := ClaudeProjectsDir()
@@ -65,6 +117,9 @@ func Discover() ([]Session, error) {
65117
return nil, err
66118
}
67119

120+
// Get directories where Claude is currently running
121+
runningDirs := getRunningClaudeDirs()
122+
68123
var sessions []Session
69124

70125
for _, entry := range entries {
@@ -83,22 +138,45 @@ func Discover() ([]Session, error) {
83138
continue
84139
}
85140

86-
session, err := parseSession(entry.Name(), logFile)
141+
session, err := parseSession(entry.Name(), logFile, runningDirs)
87142
if err != nil {
88143
continue
89144
}
90145

91146
sessions = append(sessions, session)
92147
}
93148

94-
// Sort by last activity (most recent first)
149+
// Sort by status priority, then by last activity
95150
sort.Slice(sessions, func(i, j int) bool {
151+
// Priority: Working > NeedsInput > Waiting > Idle > Inactive
152+
pi, pj := statusPriority(sessions[i].Status), statusPriority(sessions[j].Status)
153+
if pi != pj {
154+
return pi < pj
155+
}
96156
return sessions[i].LastActivity.After(sessions[j].LastActivity)
97157
})
98158

99159
return sessions, nil
100160
}
101161

162+
// statusPriority returns the sort priority for a status (lower = higher priority)
163+
func statusPriority(s Status) int {
164+
switch s {
165+
case StatusWorking:
166+
return 0
167+
case StatusNeedsInput:
168+
return 1
169+
case StatusWaiting:
170+
return 2
171+
case StatusIdle:
172+
return 3
173+
case StatusInactive:
174+
return 4
175+
default:
176+
return 5
177+
}
178+
}
179+
102180
// findMostRecentLog finds the most recently modified .jsonl file in a directory
103181
func findMostRecentLog(dir string) (string, error) {
104182
entries, err := os.ReadDir(dir)
@@ -139,13 +217,18 @@ func findMostRecentLog(dir string) (string, error) {
139217
}
140218

141219
// parseSession parses a session from its log file
142-
func parseSession(projectName, logFile string) (Session, error) {
220+
func parseSession(projectName, logFile string, runningDirs map[string]bool) (Session, error) {
143221
session := Session{
144-
Project: decodeProjectName(projectName),
145-
LogFile: logFile,
146-
Status: StatusIdle,
222+
Project: decodeProjectName(projectName),
223+
LogFile: logFile,
224+
Status: StatusInactive, // Default to inactive
225+
ProjectPath: projectName, // Store the encoded name for matching
147226
}
148227

228+
// Check if Claude is running in this project directory
229+
// runningDirs keys are in the same encoded format as projectName
230+
isRunning := runningDirs[projectName]
231+
149232
// Get file modification time as fallback for last activity
150233
info, err := os.Stat(logFile)
151234
if err != nil {
@@ -154,7 +237,7 @@ func parseSession(projectName, logFile string) (Session, error) {
154237
session.LastActivity = info.ModTime()
155238

156239
// Read last N lines of the file to determine status
157-
entries, err := readLastEntries(logFile, 50)
240+
entries, err := readLastEntries(logFile, 100)
158241
if err != nil {
159242
return session, nil // Return with defaults
160243
}
@@ -163,8 +246,11 @@ func parseSession(projectName, logFile string) (Session, error) {
163246
return session, nil
164247
}
165248

249+
// Extract summary from entries
250+
session.Summary = extractSummary(entries)
251+
166252
// Determine status from log entries
167-
session.Status, session.Task = determineStatus(entries)
253+
session.Status, session.Task = determineStatus(entries, isRunning)
168254

169255
// Get actual last activity timestamp from entries
170256
for i := len(entries) - 1; i >= 0; i-- {
@@ -177,6 +263,17 @@ func parseSession(projectName, logFile string) (Session, error) {
177263
return session, nil
178264
}
179265

266+
// extractSummary finds the most recent summary entry
267+
func extractSummary(entries []LogEntry) string {
268+
// Look for the most recent summary entry
269+
for i := len(entries) - 1; i >= 0; i-- {
270+
if entries[i].Type == "summary" && entries[i].Summary != "" {
271+
return entries[i].Summary
272+
}
273+
}
274+
return ""
275+
}
276+
180277
// decodeProjectName converts the directory name to a readable project name
181278
func decodeProjectName(name string) string {
182279
// Format: -Users-username-Projects-org-project
@@ -253,9 +350,12 @@ func readLastEntries(filePath string, count int) ([]LogEntry, error) {
253350
}
254351

255352
// determineStatus analyzes log entries to determine session status
256-
func determineStatus(entries []LogEntry) (Status, string) {
353+
func determineStatus(entries []LogEntry, isRunning bool) (Status, string) {
257354
if len(entries) == 0 {
258-
return StatusIdle, "-"
355+
if isRunning {
356+
return StatusIdle, "-"
357+
}
358+
return StatusInactive, "-"
259359
}
260360

261361
var lastAssistant *LogEntry
@@ -292,6 +392,11 @@ func determineStatus(entries []LogEntry) (Status, string) {
292392
}
293393
}
294394

395+
// If Claude is not running, session is inactive
396+
if !isRunning {
397+
return StatusInactive, "-"
398+
}
399+
295400
// Check for idle (5+ minutes since last activity)
296401
if time.Since(lastTimestamp) > 5*time.Minute {
297402
return StatusIdle, "-"

internal/ui/ui.go

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const (
2828
SymbolNeedsInput = "⚠"
2929
SymbolWaiting = "◉"
3030
SymbolIdle = "○"
31+
SymbolInactive = "◌"
3132
)
3233

3334
// RenderList renders sessions as a simple list (for -l flag)
@@ -38,18 +39,24 @@ func RenderList(sessions []session.Session) {
3839
}
3940

4041
// Header
41-
fmt.Printf("%-15s %-35s %-15s %s\n", "STATUS", "PROJECT", "LAST ACTIVITY", "TASK")
42-
fmt.Println(strings.Repeat("─", 80))
42+
fmt.Printf("%-15s %-35s %-15s %s\n", "STATUS", "PROJECT", "LAST ACTIVITY", "SUMMARY")
43+
fmt.Println(strings.Repeat("─", 100))
4344

4445
for _, s := range sessions {
4546
symbol, color := getStatusDisplay(s.Status)
4647
elapsed := formatElapsed(time.Since(s.LastActivity))
4748

49+
// Use summary if available, otherwise task
50+
desc := s.Summary
51+
if desc == "" {
52+
desc = s.Task
53+
}
54+
4855
fmt.Printf("%s%s %-13s%s %-35s %-15s %s\n",
4956
color, symbol, s.Status, Reset,
5057
truncate(s.Project, 35),
5158
elapsed,
52-
truncate(s.Task, 30))
59+
truncate(desc, 40))
5360
}
5461
}
5562

@@ -68,29 +75,46 @@ func RenderLive(sessions []session.Session) {
6875
// Header
6976
fmt.Printf("%s🤖 Claude Code Sessions%s\n\n", Bold, Reset)
7077

71-
// Status summary
72-
counts := countByStatus(sessions)
78+
// Split sessions into active and inactive
79+
var active, inactive []session.Session
80+
for _, s := range sessions {
81+
if s.Status == session.StatusInactive {
82+
inactive = append(inactive, s)
83+
} else {
84+
active = append(active, s)
85+
}
86+
}
87+
88+
// Status summary (only active sessions)
89+
counts := countByStatus(active)
7390
fmt.Printf("%s%s Working: %d%s ", Green, SymbolWorking, counts[session.StatusWorking], Reset)
7491
fmt.Printf("%s%s Needs Input: %d%s ", Yellow, SymbolNeedsInput, counts[session.StatusNeedsInput], Reset)
7592
fmt.Printf("%s%s Waiting: %d%s ", Blue, SymbolWaiting, counts[session.StatusWaiting], Reset)
76-
fmt.Printf("%s%s Idle: %d%s\n\n", Gray, SymbolIdle, counts[session.StatusIdle], Reset)
93+
fmt.Printf("%s%s Idle: %d%s ", Gray, SymbolIdle, counts[session.StatusIdle], Reset)
94+
fmt.Printf("%s%s Inactive: %d%s\n\n", Dim, SymbolInactive, len(inactive), Reset)
7795

78-
if len(sessions) == 0 {
79-
fmt.Printf("%sNo active Claude sessions found.%s\n", Dim, Reset)
96+
if len(active) == 0 {
97+
fmt.Printf("%sNo active Claude sessions.%s\n", Dim, Reset)
8098
} else {
8199
// Column headers
82-
fmt.Printf(" %-15s %-35s %-15s %s\n", "STATUS", "PROJECT", "LAST ACTIVITY", "CURRENT TASK")
83-
fmt.Printf(" %s\n", strings.Repeat("─", 78))
100+
fmt.Printf(" %-15s %-35s %-15s %s\n", "STATUS", "PROJECT", "LAST ACTIVITY", "SUMMARY")
101+
fmt.Printf(" %s\n", strings.Repeat("─", 95))
84102

85-
for _, s := range sessions {
103+
for _, s := range active {
86104
symbol, color := getStatusDisplay(s.Status)
87105
elapsed := formatElapsed(time.Since(s.LastActivity))
88106

107+
// Use summary if available, otherwise task
108+
desc := s.Summary
109+
if desc == "" {
110+
desc = s.Task
111+
}
112+
89113
fmt.Printf(" %s%s %-13s%s %-35s %-15s %s\n",
90114
color, symbol, s.Status, Reset,
91115
truncate(s.Project, 35),
92116
elapsed,
93-
truncate(s.Task, 25))
117+
truncate(desc, 35))
94118
}
95119
}
96120

@@ -123,8 +147,10 @@ func getStatusDisplay(status session.Status) (string, string) {
123147
return SymbolWaiting, Blue
124148
case session.StatusIdle:
125149
return SymbolIdle, Gray
150+
case session.StatusInactive:
151+
return SymbolInactive, Dim
126152
default:
127-
return SymbolIdle, Reset
153+
return SymbolInactive, Reset
128154
}
129155
}
130156

0 commit comments

Comments
 (0)