diff --git a/daemon/internal/scanner/scanner.go b/daemon/internal/scanner/scanner.go index 8fa7552..2425f8f 100644 --- a/daemon/internal/scanner/scanner.go +++ b/daemon/internal/scanner/scanner.go @@ -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 } @@ -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) @@ -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 .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 @@ -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 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 { diff --git a/daemon/internal/scanner/scanner_test.go b/daemon/internal/scanner/scanner_test.go index 8c489e3..cd9a232 100644 --- a/daemon/internal/scanner/scanner_test.go +++ b/daemon/internal/scanner/scanner_test.go @@ -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) + } +} diff --git a/daemon/internal/state/state.go b/daemon/internal/state/state.go index 3c8dd13..637f3f8 100644 --- a/daemon/internal/state/state.go +++ b/daemon/internal/state/state.go @@ -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 diff --git a/daemon/internal/state/state_test.go b/daemon/internal/state/state_test.go index 3347a99..6099438 100644 --- a/daemon/internal/state/state_test.go +++ b/daemon/internal/state/state_test.go @@ -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) {