336 lines
13 KiB
C#
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() + "…";
|
|
}
|