package main import ( "encoding/json" "errors" "io" "os" "path/filepath" "sort" ) // JSONLMessage represents a single entry in a Claude Code JSONL file. type JSONLMessage struct { Type string `json:"type"` Timestamp string `json:"timestamp"` SessionID string `json:"sessionId"` Cwd string `json:"cwd"` GitBranch string `json:"gitBranch"` Message *MessagePayload `json:"message,omitempty"` Data *ProgressData `json:"data,omitempty"` } type MessagePayload struct { Role string `json:"role"` Content []ContentBlock `json:"content"` StopReason string `json:"stop_reason"` } type ContentBlock struct { Type string `json:"type"` Text string `json:"text,omitempty"` Name string `json:"name,omitempty"` } type ProgressData struct { Type string `json:"type"` } const tailBlockSize = 8192 // TailReadJSONL reads the last n complete JSONL lines from path without loading // the entire file. Incomplete trailing lines (race condition with writer) are ignored. func TailReadJSONL(path string, n int) ([]JSONLMessage, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() size, err := f.Seek(0, io.SeekEnd) if err != nil { return nil, err } if size == 0 { return nil, nil } // Read backwards in blocks, collecting complete lines var rawLines [][]byte remaining := size var buf []byte for remaining > 0 && len(rawLines) < n+1 { blockSize := int64(tailBlockSize) if blockSize > remaining { blockSize = remaining } remaining -= blockSize if _, err := f.Seek(remaining, io.SeekStart); err != nil { return nil, err } block := make([]byte, blockSize) if _, err := io.ReadFull(f, block); err != nil { return nil, err } // Prepend to accumulated buffer buf = append(block, buf...) // Extract complete lines from buf rawLines = extractCompleteLines(buf, n+1) } if rawLines == nil { rawLines = extractCompleteLines(buf, n+1) } // Drop the first "line" if file didn't start at position 0 in our buffer // Actually, re-extract properly: buf now contains from 'remaining' to end of file. // If remaining == 0, buf is the entire file. Lines are all complete. // If remaining > 0, the first partial content before the first \n is not a complete line. // Re-parse: split buf on \n, take last n complete lines lines := splitLines(buf) // If buf doesn't end with \n, the last element is a truncated line: discard it if len(buf) > 0 && buf[len(buf)-1] != '\n' { if len(lines) > 0 { lines = lines[:len(lines)-1] } } // Take the last n lines if len(lines) > n { lines = lines[len(lines)-n:] } // Parse each line var messages []JSONLMessage for _, line := range lines { if len(line) == 0 { continue } var msg JSONLMessage if err := json.Unmarshal(line, &msg); err != nil { continue // skip corrupt lines } messages = append(messages, msg) } return messages, nil } // splitLines splits data by \n, returning non-empty line contents. func splitLines(data []byte) [][]byte { var lines [][]byte start := 0 for i, b := range data { if b == '\n' { line := data[start:i] if len(line) > 0 { lines = append(lines, line) } start = i + 1 } } // Remaining after last \n (possibly truncated) if start < len(data) { lines = append(lines, data[start:]) } return lines } // extractCompleteLines is a helper used during block reading (unused in final approach). func extractCompleteLines(data []byte, max int) [][]byte { lines := splitLines(data) if len(lines) > max { lines = lines[len(lines)-max:] } return lines } // FindSessionForProcess finds the most recently modified JSONL file matching // the process's cwd in claudeDir (typically ~/.claude/projects/). // Only top-level *.jsonl files are considered (subagent directories are excluded). func FindSessionForProcess(claudeDir string, proc Process) (string, []JSONLMessage, error) { encoded := EncodePath(proc.Cwd) pattern := filepath.Join(claudeDir, encoded, "*.jsonl") matches, err := filepath.Glob(pattern) if err != nil { return "", nil, err } // Filter out files in subdirectories (Glob with *.jsonl at one level shouldn't // match deeper, but be defensive) var topLevel []string expectedDir := filepath.Join(claudeDir, encoded) for _, m := range matches { if filepath.Dir(m) == expectedDir { topLevel = append(topLevel, m) } } if len(topLevel) == 0 { return "", nil, errors.New("no JSONL files found for " + proc.Cwd) } // Sort by mtime, most recent first sort.Slice(topLevel, func(i, j int) bool { si, _ := os.Stat(topLevel[i]) sj, _ := os.Stat(topLevel[j]) if si == nil || sj == nil { return false } return si.ModTime().After(sj.ModTime()) }) newest := topLevel[0] messages, err := TailReadJSONL(newest, 5) if err != nil { return "", nil, err } return newest, messages, nil }