Files
2026-06-25 09:59:54 +02:00

336 lines
13 KiB
C#

using System.IO;
using System.Text.Json;
using ClaudeOverview.Models;
namespace ClaudeOverview.Services;
/// <summary>
/// Scans the .claude/projects directory for transcript (.jsonl) files that were
/// written recently, and parses each into a <see cref="ClaudeSession"/> with usage totals.
/// Only "active" files (modified within the configured window) are fully parsed, so the
/// scan stays cheap even with a large history.
/// </summary>
public sealed class SessionScanner
{
private readonly AppConfig _config;
public SessionScanner(AppConfig config) => _config = config;
public string ProjectsPath => _config.ProjectsPath;
/// <summary>
/// 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 <see cref="AppConfig.ColdMinutes"/> for the cutoff and count all tokens for shares.
/// </summary>
public DateTime? ColdCutoffUtc { get; set; }
/// <summary>Returns the active VS Code chats, most recently active first.</summary>
public IReadOnlyList<ClaudeSession> Scan(out string? error)
{
error = null;
var results = new List<ClaudeSession>();
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<string, PermissionRules>(StringComparer.OrdinalIgnoreCase);
IEnumerable<string> 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);
}
}
}
/// <summary>Extracts tool-call names, their primary arguments, and the trailing text from an assistant message.</summary>
private static (List<string> tools, List<string> args, string text) ReadAssistantContent(JsonElement msg)
{
var tools = new List<string>();
var args = new List<string>();
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);
}
/// <summary>Picks the field of a tool's input that permission rules match on (command / path / url / pattern).</summary>
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 "";
}
/// <summary>
/// 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).
/// </summary>
private void ResolvePendingPermission(ClaudeSession s, Dictionary<string, PermissionRules> 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);
}
/// <summary>
/// True when a user message is genuine human input (not a tool_result fed back to Claude).
/// </summary>
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() + "…";
}