using System.IO; using System.Text.Json; using ClaudeOverview.Models; namespace ClaudeOverview.Services; /// /// Scans the .claude/projects directory for transcript (.jsonl) files that were /// written recently, and parses each into a with usage totals. /// Only "active" files (modified within the configured window) are fully parsed, so the /// scan stays cheap even with a large history. /// public sealed class SessionScanner { private readonly AppConfig _config; public SessionScanner(AppConfig config) => _config = config; public string ProjectsPath => _config.ProjectsPath; /// /// Start of the current session-limit window (set from usage data). Cold chats are shown back /// to this point, and per-chat session-token shares are measured from here; when null we fall /// back to for the cutoff and count all tokens for shares. /// public DateTime? ColdCutoffUtc { get; set; } /// Returns the active VS Code chats, most recently active first. public IReadOnlyList Scan(out string? error) { error = null; var results = new List(); var root = _config.ProjectsPath; if (!Directory.Exists(root)) { error = $"Projects path not found:\n{root}\n\nIs WSL running? Check config.json."; return results; } var now = DateTime.UtcNow; var activeCutoff = now - TimeSpan.FromMinutes(_config.ActiveMinutes); // Cold chats are listed (dimmed) back to the start of the current session-limit window; // if that's unknown, fall back to the configured cold window. var coldCutoff = ColdCutoffUtc ?? (now - TimeSpan.FromMinutes(_config.ColdMinutes)); if (coldCutoff > activeCutoff) coldCutoff = activeCutoff; // never narrower than the active window // Permission rules are loaded lazily and cached per working directory for this scan, so we // read each project's settings files at most once even when several chats share a cwd. var rulesByCwd = new Dictionary(StringComparer.OrdinalIgnoreCase); IEnumerable files; try { files = Directory.EnumerateFiles(root, "*.jsonl", SearchOption.AllDirectories); } catch (Exception ex) { error = $"Could not read transcripts: {ex.Message}"; return results; } foreach (var file in files) { DateTime mtimeUtc; try { mtimeUtc = File.GetLastWriteTimeUtc(file); } catch { continue; } if (mtimeUtc < coldCutoff) continue; // older than the cold window — skip the expensive parse var session = TryParse(file, mtimeUtc, ColdCutoffUtc); if (session is null) continue; if (_config.OnlyVsCode && !session.IsVsCode) continue; session.IsActive = session.LastActivityUtc >= activeCutoff; ResolvePendingPermission(session, rulesByCwd); results.Add(session); } // Each chat's slice of the total tokens consumed across all listed chats this window. long sessionSum = 0; foreach (var s in results) sessionSum += s.SessionTokens; if (sessionSum > 0) foreach (var s in results) s.SessionSharePercent = 100.0 * s.SessionTokens / sessionSum; // Active chats first, then cold ones. Among active, those waiting on the user lead; // within each group, most recently active first. results.Sort((a, b) => { if (a.IsActive != b.IsActive) return a.IsActive ? -1 : 1; if (a.IsActive && a.IsWaiting != b.IsWaiting) return a.IsWaiting ? -1 : 1; return b.LastActivityUtc.CompareTo(a.LastActivityUtc); }); return results; } private static ClaudeSession? TryParse(string file, DateTime mtimeUtc, DateTime? sessionWindowStartUtc) { var session = new ClaudeSession { SessionId = Path.GetFileNameWithoutExtension(file), FilePath = file, LastActivityUtc = mtimeUtc }; var sawAnything = false; try { // FileShare.ReadWrite so we can read while Claude Code is still appending. using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using var reader = new StreamReader(stream); string? line; while ((line = reader.ReadLine()) is not null) { if (line.Length == 0) continue; JsonDocument doc; try { doc = JsonDocument.Parse(line); } catch { continue; } // tolerate a half-written trailing line using (doc) { ApplyLine(session, doc.RootElement, sessionWindowStartUtc); sawAnything = true; } } } catch { return sawAnything ? session : null; } return sawAnything ? session : null; } private static void ApplyLine(ClaudeSession s, JsonElement root, DateTime? sessionWindowStartUtc) { if (root.ValueKind != JsonValueKind.Object) return; // VS Code writes the generated chat name as its own "ai-title" entry (no message body). if (TryStr(root, "type", out var lineType) && lineType == "ai-title" && TryStr(root, "aiTitle", out var aiTitle) && aiTitle.Length > 0) { s.AiTitle = aiTitle; return; } if (TryStr(root, "entrypoint", out var ep) && ep.Length > 0) s.Entrypoint = ep; if (TryStr(root, "cwd", out var cwd) && cwd.Length > 0) s.Cwd = cwd; if (TryStr(root, "gitBranch", out var gb) && gb.Length > 0) s.GitBranch = gb; DateTime? lineTimeUtc = null; if (TryStr(root, "timestamp", out var ts) && DateTime.TryParse(ts, null, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var dt)) { lineTimeUtc = dt; if (dt > s.LastActivityUtc) s.LastActivityUtc = dt; } if (!root.TryGetProperty("message", out var msg) || msg.ValueKind != JsonValueKind.Object) return; var role = TryStr(msg, "role", out var r) ? r : ""; // Sub-agent (sidechain) turns consume tokens but don't reflect the main thread's // wait state — count their usage, but don't let them drive the status. var isSidechain = root.TryGetProperty("isSidechain", out var sc) && sc.ValueKind == JsonValueKind.True; if (role == "assistant") { if (TryStr(msg, "model", out var model) && model.Length > 0) s.Model = model; if (msg.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object) { var input = GetLong(usage, "input_tokens"); var output = GetLong(usage, "output_tokens"); var cacheRead = GetLong(usage, "cache_read_input_tokens"); var cacheCreate = GetLong(usage, "cache_creation_input_tokens"); s.InputTokens += input; s.OutputTokens += output; s.CacheReadTokens += cacheRead; s.CacheCreationTokens += cacheCreate; s.MessageCount++; // Count toward this session's window only for messages sent after it started // (or everything when the window start is unknown). if (sessionWindowStartUtc is null || (lineTimeUtc is { } lt && lt >= sessionWindowStartUtc)) s.SessionTokens += input + output + cacheRead + cacheCreate; } if (!isSidechain) { s.LastRole = "assistant"; s.LastStopReason = TryStr(msg, "stop_reason", out var sr) ? sr : ""; (s.LastToolNames, s.LastToolArgs, s.LastAssistantText) = ReadAssistantContent(msg); // A turn streams as several lines (think → text → tool_use); only the tool_use line // leaves a call awaiting a result. Any later result/human line clears this below. s.AwaitingToolResult = s.LastToolNames.Count > 0; } } else if (role == "user") { if (!isSidechain) { // Whether it's a tool_result or genuine human input, the prior tool_use is now // resolved — so long thinking after a quick command isn't read as a permission wait. s.AwaitingToolResult = false; if (IsHumanTurn(msg)) s.LastRole = "user"; } if (s.Title.Length == 0) { var text = ExtractUserText(msg); if (!string.IsNullOrWhiteSpace(text) && !text.StartsWith("<") && !text.StartsWith("/")) s.Title = Truncate(text.Trim(), 90); } } } /// Extracts tool-call names, their primary arguments, and the trailing text from an assistant message. private static (List tools, List args, string text) ReadAssistantContent(JsonElement msg) { var tools = new List(); var args = new List(); var text = ""; if (msg.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Array) { foreach (var part in content.EnumerateArray()) { if (part.ValueKind != JsonValueKind.Object) continue; var type = TryStr(part, "type", out var t) ? t : ""; if (type == "tool_use" && TryStr(part, "name", out var name)) { tools.Add(name); args.Add(part.TryGetProperty("input", out var input) ? PrimaryArg(input) : ""); } else if (type == "text" && TryStr(part, "text", out var txt)) text = txt; } } return (tools, args, text); } /// Picks the field of a tool's input that permission rules match on (command / path / url / pattern). private static string PrimaryArg(JsonElement input) { if (input.ValueKind != JsonValueKind.Object) return ""; foreach (var key in new[] { "command", "file_path", "path", "url", "pattern", "notebook_path" }) if (TryStr(input, key, out var v) && v.Length > 0) return v; return ""; } /// /// Decides whether a chat sitting on a pending tool_use is auto-approved by the user's permission /// settings (so a stale file means a long-running tool, not a prompt awaiting approval). /// private void ResolvePendingPermission(ClaudeSession s, Dictionary cache) { if (!s.AwaitingToolResult || s.LastToolNames.Count == 0) return; if (!cache.TryGetValue(s.Cwd, out var rules)) cache[s.Cwd] = rules = PermissionRules.Load(_config, s.Cwd); s.PendingToolsAutoApproved = rules.AllowsEvery(s.LastToolNames, s.LastToolArgs); } /// /// True when a user message is genuine human input (not a tool_result fed back to Claude). /// private static bool IsHumanTurn(JsonElement msg) { if (!msg.TryGetProperty("content", out var content)) return true; if (content.ValueKind == JsonValueKind.String) return true; if (content.ValueKind == JsonValueKind.Array) { foreach (var part in content.EnumerateArray()) { if (part.ValueKind == JsonValueKind.Object && TryStr(part, "type", out var t) && t == "tool_result") return false; // tool output, Claude will continue } } return true; } private static string ExtractUserText(JsonElement msg) { if (!msg.TryGetProperty("content", out var content)) return ""; if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? ""; if (content.ValueKind == JsonValueKind.Array) { foreach (var part in content.EnumerateArray()) { if (part.ValueKind == JsonValueKind.Object && TryStr(part, "type", out var t) && t == "text" && TryStr(part, "text", out var txt)) return txt; } } return ""; } private static bool TryStr(JsonElement e, string name, out string value) { value = ""; if (e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String) { value = p.GetString() ?? ""; return true; } return false; } private static long GetLong(JsonElement e, string name) => e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.Number && p.TryGetInt64(out var v) ? v : 0; private static string Truncate(string s, int max) => s.Length <= max ? s : s[..max].TrimEnd() + "…"; }