Files
vmux/session.go
Pierre Martin e7ced9c3a3 test(01-02): add failing tests then implement session + state
- TailReadJSONL: reverse-read JSONL without loading entire file
- FindSessionForProcess: match PID to most recent JSONL via EncodePath
- DetectState: heuristic based on last message stop_reason + tool name
- ExtractPreview: extract first 3 lines of last assistant text
- All 17 tests pass (session_test.go + state_test.go)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:29:19 +01:00

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, 5)
if err != nil {
return "", nil, err
}
return newest, messages, nil
}