Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 73 additions & 41 deletions daemon/internal/scanner/scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,35 +51,11 @@ func (s *Scanner) Discover() []*model.Session {
var sessions []*model.Session
seenJSONL := make(map[string]bool)

// Phase 1: Running processes.
// Phase 1: Running processes — each process gets its own session.
procs := s.findClaudeProcesses()
cwdToPID := make(map[string]procInfo)
for _, p := range procs {
if existing, ok := cwdToPID[p.cwd]; !ok || p.pid > existing.pid {
cwdToPID[p.cwd] = p
}
}

for cwd, proc := range cwdToPID {
var jsonlPath string
// If we have a session ID from --resume, try to match to a specific JSONL file.
if proc.sessionID != "" {
encoded := encodeProjectPath(cwd)
projectDir := filepath.Join(s.claudeProjectsDir, encoded)
candidate := filepath.Join(projectDir, proc.sessionID+".jsonl")
if _, err := os.Stat(candidate); err == nil {
jsonlPath = candidate
}
}
if jsonlPath == "" {
encoded := encodeProjectPath(cwd)
projectDir := filepath.Join(s.claudeProjectsDir, encoded)
jsonlPath = findLatestJSONL(projectDir)
}
if jsonlPath == "" {
continue
}
if seenJSONL[jsonlPath] {
for _, proc := range procs {
jsonlPath := s.resolveJSONL(proc)
if jsonlPath == "" || seenJSONL[jsonlPath] {
continue
}

Expand All @@ -91,7 +67,7 @@ func (s *Scanner) Discover() []*model.Session {
seenJSONL[jsonlPath] = true
}

// Phase 2: Dead/historical sessions.
// Phase 2: Dead/historical sessions — all recent JSONLs, not just latest.
entries, err := os.ReadDir(s.claudeProjectsDir)
if err == nil {
cutoff := time.Now().Add(-recentThresholdHours * time.Hour)
Expand All @@ -100,25 +76,43 @@ func (s *Scanner) Discover() []*model.Session {
continue
}
projectDir := filepath.Join(s.claudeProjectsDir, entry.Name())
jsonlPath := findLatestJSONL(projectDir)
if jsonlPath == "" || seenJSONL[jsonlPath] {
continue
}
info, err := os.Stat(jsonlPath)
if err != nil || info.ModTime().Before(cutoff) {
continue
}
session := sessionFromJSONL(jsonlPath, 0, "")
if session == nil {
continue
for _, jsonlPath := range findRecentJSONLs(projectDir, cutoff) {
if seenJSONL[jsonlPath] {
continue
}
session := sessionFromJSONL(jsonlPath, 0, "")
if session == nil {
continue
}
sessions = append(sessions, session)
seenJSONL[jsonlPath] = true
}
sessions = append(sessions, session)
}
}

return sessions
}

// resolveJSONL finds the JSONL file for a running process.
// Resolution order: (1) direct <sessionID>.jsonl, (2) slug-based scan, (3) latest.
func (s *Scanner) resolveJSONL(proc procInfo) string {
encoded := encodeProjectPath(proc.cwd)
projectDir := filepath.Join(s.claudeProjectsDir, encoded)

if proc.sessionID != "" {
// Try direct file match (session UUID).
candidate := filepath.Join(projectDir, proc.sessionID+".jsonl")
if _, err := os.Stat(candidate); err == nil {
return candidate
}
// Try slug-based scan (--resume uses slug, not UUID).
if match := findJSONLBySlug(projectDir, proc.sessionID); match != "" {
return match
}
}
return findLatestJSONL(projectDir)
}

type procInfo struct {
pid int
cwd string
Expand Down Expand Up @@ -285,6 +279,44 @@ func decodeProjectPath(encoded string) string {
return parts[len(parts)-1]
}

// findRecentJSONLs returns all JSONL files in dir modified since cutoff.
func findRecentJSONLs(dir string, cutoff time.Time) []string {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var result []string
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
continue
}
info, err := entry.Info()
if err != nil || info.ModTime().Before(cutoff) {
continue
}
result = append(result, filepath.Join(dir, entry.Name()))
}
return result
}

// findJSONLBySlug scans recent JSONL files looking for one whose slug or
// customTitle matches the given slug. Used for --resume <slug> resolution
// since JSONL filenames are UUIDs, not slugs.
func findJSONLBySlug(dir, slug string) string {
cutoff := time.Now().Add(-recentThresholdHours * time.Hour)
for _, path := range findRecentJSONLs(dir, cutoff) {
entries, err := ReadTail(path, 50)
if err != nil {
continue
}
_, fileSlug, _, _, customTitle := ExtractMetadata(entries)
if customTitle == slug || fileSlug == slug {
return path
}
}
return ""
}

func findLatestJSONL(dir string) string {
entries, err := os.ReadDir(dir)
if err != nil {
Expand Down
78 changes: 78 additions & 0 deletions daemon/internal/scanner/scanner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -532,3 +532,81 @@ type mockUpdater struct {
func (m *mockUpdater) UpdateSessionFromScanner(s *model.Session) {
m.count++
}

// --- findRecentJSONLs ---

func TestFindRecentJSONLs_ReturnsAll(t *testing.T) {
dir := t.TempDir()
cutoff := time.Now().Add(-1 * time.Hour)

// Create 3 JSONL files — all recent.
for _, name := range []string{"a.jsonl", "b.jsonl", "c.jsonl"} {
_ = os.WriteFile(filepath.Join(dir, name), []byte("{}"), 0o644)
}
// Create 1 old file.
oldPath := filepath.Join(dir, "old.jsonl")
_ = os.WriteFile(oldPath, []byte("{}"), 0o644)
_ = os.Chtimes(oldPath, cutoff.Add(-2*time.Hour), cutoff.Add(-2*time.Hour))

got := findRecentJSONLs(dir, cutoff)
if len(got) != 3 {
t.Errorf("expected 3 recent files, got %d", len(got))
}
}

func TestFindRecentJSONLs_EmptyDir(t *testing.T) {
dir := t.TempDir()
got := findRecentJSONLs(dir, time.Now().Add(-time.Hour))
if len(got) != 0 {
t.Errorf("expected 0 files, got %d", len(got))
}
}

func TestFindRecentJSONLs_NonexistentDir(t *testing.T) {
got := findRecentJSONLs("/nonexistent/dir", time.Now())
if got != nil {
t.Errorf("expected nil, got %v", got)
}
}

// --- findJSONLBySlug ---

func TestFindJSONLBySlug_MatchesCustomTitle(t *testing.T) {
dir := t.TempDir()
// File with a custom-title matching the slug.
content := `{"type":"system","subtype":"init","sessionId":"uuid-123","slug":"auto-slug"}
{"type":"custom-title","customTitle":"my-session"}
`
path := filepath.Join(dir, "uuid-123.jsonl")
_ = os.WriteFile(path, []byte(content), 0o644)

got := findJSONLBySlug(dir, "my-session")
if got != path {
t.Errorf("expected %q, got %q", path, got)
}
}

func TestFindJSONLBySlug_MatchesSlug(t *testing.T) {
dir := t.TempDir()
content := `{"type":"system","subtype":"init","sessionId":"uuid-456","slug":"my-slug"}
`
path := filepath.Join(dir, "uuid-456.jsonl")
_ = os.WriteFile(path, []byte(content), 0o644)

got := findJSONLBySlug(dir, "my-slug")
if got != path {
t.Errorf("expected %q, got %q", path, got)
}
}

func TestFindJSONLBySlug_NoMatch(t *testing.T) {
dir := t.TempDir()
content := `{"type":"system","subtype":"init","sessionId":"uuid-789","slug":"other-slug"}
`
_ = os.WriteFile(filepath.Join(dir, "uuid-789.jsonl"), []byte(content), 0o644)

got := findJSONLBySlug(dir, "nonexistent")
if got != "" {
t.Errorf("expected empty, got %q", got)
}
}
12 changes: 8 additions & 4 deletions daemon/internal/state/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,11 +271,15 @@ func (m *Manager) ShouldAutoApprove(sid string, safety model.ToolSafety) (bool,
m.mu.RLock()
defer m.mu.RUnlock()

s, ok := m.sessions[sid]
if !ok {
return false, false
var mode string
if s, ok := m.sessions[sid]; ok {
mode = s.AutopilotMode
} else if persisted, ok := m.autopilot[sid]; ok {
// Fallback: session not yet discovered by scanner, but autopilot
// state was persisted from a previous run.
mode = persisted
}
switch s.AutopilotMode {
switch mode {
case model.AutopilotOn:
// ON: approve safe+unknown, block destructive.
return safety != model.SafetyDestructive, false
Expand Down
38 changes: 38 additions & 0 deletions daemon/internal/state/state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,44 @@ func TestShouldAutoApproveUnknownSession(t *testing.T) {
}
}

func TestShouldAutoApprovePersistedFallback(t *testing.T) {
m := newTestManager()
// Set persisted autopilot without registering session.
m.mu.Lock()
m.autopilot["s1"] = model.AutopilotOn
m.mu.Unlock()

// Session not in m.sessions — should fallback to persisted state.
approve, grace := m.ShouldAutoApprove("s1", model.SafetySafe)
if !approve {
t.Error("persisted ON + safe: should approve")
}
if grace {
t.Error("persisted ON + safe: should not grace")
}

// Destructive should be blocked even with persisted ON.
approve, grace = m.ShouldAutoApprove("s1", model.SafetyDestructive)
if approve {
t.Error("persisted ON + destructive: should not approve")
}
}

func TestShouldAutoApprovePersistedYolo(t *testing.T) {
m := newTestManager()
m.mu.Lock()
m.autopilot["s1"] = model.AutopilotYolo
m.mu.Unlock()

approve, grace := m.ShouldAutoApprove("s1", model.SafetyDestructive)
if approve {
t.Error("persisted YOLO + destructive: should not immediately approve")
}
if !grace {
t.Error("persisted YOLO + destructive: should grace")
}
}

// --- SetSlug / SetGhosttyTab ---

func TestSetSlug(t *testing.T) {
Expand Down
Loading