@@ -2,8 +2,10 @@ package session
22
33import (
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
60112func 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
103181func 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
181278func 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 , "-"
0 commit comments