Claude Code emits many progress lines during work, burying the last assistant text. 5 lines was not enough to reach it. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
198 lines
4.8 KiB
Go
198 lines
4.8 KiB
Go
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, 200)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
return newest, messages, nil
|
|
}
|