Init
This commit is contained in:
140
Services/AppConfig.cs
Normal file
140
Services/AppConfig.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ClaudeOverview.Services;
|
||||
|
||||
/// <summary>Settings loaded from config.json next to the executable.</summary>
|
||||
public sealed class AppConfig
|
||||
{
|
||||
public string WslDistro { get; set; } = "Ubuntu";
|
||||
public string LinuxHome { get; set; } = "/home/philipp";
|
||||
public string ProjectsPathOverride { get; set; } = "";
|
||||
public int ActiveMinutes { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Fallback window (minutes) for showing "cold" (inactive) chats when the session-limit reset
|
||||
/// time isn't available. Normally the cold cutoff is the start of the current session-limit
|
||||
/// window (its reset time minus the 5-hour block); this is only used when that's unknown.
|
||||
/// </summary>
|
||||
public int ColdMinutes { get; set; } = 1440;
|
||||
public int RefreshSeconds { get; set; } = 4;
|
||||
|
||||
/// <summary>Seconds a pending tool may sit before we treat it as a permission prompt (vs still running).</summary>
|
||||
public int PendingPermissionSeconds { get; set; } = 8;
|
||||
public int MonitorIndex { get; set; } = -1;
|
||||
public int EdgeMargin { get; set; } = 12;
|
||||
public bool OnlyVsCode { get; set; } = true;
|
||||
public bool StartMinimized { get; set; } = true;
|
||||
public bool FlashOnAttention { get; set; } = true;
|
||||
public bool SoundOnAttention { get; set; } = false;
|
||||
public bool ShowSessionLimit { get; set; } = true;
|
||||
|
||||
/// <summary>How often to poll the subscription usage endpoint (changes slowly).</summary>
|
||||
public int UsageRefreshSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>Path to .credentials.json (sibling of the projects dir, i.e. inside .claude).</summary>
|
||||
public string CredentialsPath
|
||||
{
|
||||
get
|
||||
{
|
||||
var claudeDir = Path.GetDirectoryName(ProjectsPath);
|
||||
return claudeDir is null
|
||||
? ""
|
||||
: Path.Combine(claudeDir, ".credentials.json");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Resolves the Windows path to the .claude/projects directory.</summary>
|
||||
public string ProjectsPath
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(ProjectsPathOverride))
|
||||
return ProjectsPathOverride;
|
||||
|
||||
// \\wsl.localhost\<Distro>\<linuxHome>\.claude\projects
|
||||
var home = LinuxHome.Replace('/', '\\').TrimStart('\\');
|
||||
return $@"\\wsl.localhost\{WslDistro}\{home}\.claude\projects";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Translates a Linux path from a transcript (e.g. <c>/home/philipp/git/foo</c>) to the Windows
|
||||
/// UNC path that reaches it over the WSL share (<c>\\wsl.localhost\Ubuntu\home\philipp\git\foo</c>).
|
||||
/// </summary>
|
||||
public string ToWindowsPath(string linuxPath)
|
||||
{
|
||||
var rel = linuxPath.Replace('/', '\\').TrimStart('\\');
|
||||
return $@"\\wsl.localhost\{WslDistro}\{rel}";
|
||||
}
|
||||
|
||||
public static AppConfig Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "config.json");
|
||||
if (!File.Exists(path)) return new AppConfig();
|
||||
|
||||
var json = File.ReadAllText(path);
|
||||
var opts = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
return JsonSerializer.Deserialize<AppConfig>(json, opts) ?? new AppConfig();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new AppConfig();
|
||||
}
|
||||
}
|
||||
|
||||
private static string ConfigFilePath => Path.Combine(AppContext.BaseDirectory, "config.json");
|
||||
|
||||
/// <summary>
|
||||
/// Persists the runtime-editable settings back to config.json, updating values in place
|
||||
/// so the file's comments and other keys are preserved.
|
||||
/// </summary>
|
||||
public void Save()
|
||||
{
|
||||
string text = File.Exists(ConfigFilePath)
|
||||
? File.ReadAllText(ConfigFilePath)
|
||||
: "{\n}\n";
|
||||
|
||||
text = UpsertInt(text, "monitorIndex", MonitorIndex);
|
||||
text = UpsertInt(text, "activeMinutes", ActiveMinutes);
|
||||
text = UpsertInt(text, "refreshSeconds", RefreshSeconds);
|
||||
text = UpsertBool(text, "flashOnAttention", FlashOnAttention);
|
||||
text = UpsertBool(text, "soundOnAttention", SoundOnAttention);
|
||||
|
||||
File.WriteAllText(ConfigFilePath, text);
|
||||
}
|
||||
|
||||
private static string UpsertBool(string json, string key, bool value)
|
||||
{
|
||||
var rx = new Regex($"(?<!//\\s)(\"{Regex.Escape(key)}\"\\s*:\\s*)(?:true|false)");
|
||||
var literal = value ? "true" : "false";
|
||||
if (rx.IsMatch(json))
|
||||
return rx.Replace(json, m => m.Groups[1].Value + literal, 1);
|
||||
|
||||
int brace = json.IndexOf('{');
|
||||
if (brace < 0) return json;
|
||||
return json.Insert(brace + 1, $"\n \"{key}\": {literal},");
|
||||
}
|
||||
|
||||
/// <summary>Replaces the integer value of a JSON key, or inserts the key if missing.</summary>
|
||||
private static string UpsertInt(string json, string key, int value)
|
||||
{
|
||||
// Match the *real* key, not a "// key" comment line: require it isn't preceded by "// ".
|
||||
var rx = new Regex($"(?<!//\\s)(\"{Regex.Escape(key)}\"\\s*:\\s*)-?\\d+");
|
||||
if (rx.IsMatch(json))
|
||||
return rx.Replace(json, m => m.Groups[1].Value + value, 1);
|
||||
|
||||
// Not present — insert right after the opening brace.
|
||||
int brace = json.IndexOf('{');
|
||||
if (brace < 0) return json;
|
||||
return json.Insert(brace + 1, $"\n \"{key}\": {value},");
|
||||
}
|
||||
}
|
||||
169
Services/PermissionRules.cs
Normal file
169
Services/PermissionRules.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ClaudeOverview.Services;
|
||||
|
||||
/// <summary>
|
||||
/// A merged view of the user's Claude Code permission settings, used to decide whether a given
|
||||
/// tool call would run silently (auto-approved) or pop a permission prompt. This is an
|
||||
/// approximation of Claude Code's own matcher — good enough to keep the overview widget from
|
||||
/// mislabelling an allowlisted long-running tool as "needs permission".
|
||||
/// </summary>
|
||||
public sealed class PermissionRules
|
||||
{
|
||||
// Tools allowed for any input, e.g. an "Edit" rule with no parens.
|
||||
private readonly HashSet<string> _allowAll = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Tool -> specifiers it's allowed for, e.g. Bash -> ["npm run build:*", "ls:*"].
|
||||
private readonly Dictionary<string, List<string>> _allowSpec = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private bool _bypassAll; // defaultMode = bypassPermissions
|
||||
private bool _acceptEdits; // defaultMode = acceptEdits (file edits don't prompt)
|
||||
|
||||
private static readonly string[] EditTools =
|
||||
{ "Edit", "Write", "MultiEdit", "NotebookEdit" };
|
||||
|
||||
/// <summary>True when <paramref name="tool"/> with <paramref name="arg"/> would run without prompting.</summary>
|
||||
public bool Allows(string tool, string arg)
|
||||
{
|
||||
if (_bypassAll) return true;
|
||||
if (_acceptEdits && Array.Exists(EditTools, t => t.Equals(tool, StringComparison.OrdinalIgnoreCase)))
|
||||
return true;
|
||||
if (_allowAll.Contains(tool)) return true;
|
||||
|
||||
if (_allowSpec.TryGetValue(tool, out var specs))
|
||||
foreach (var spec in specs)
|
||||
if (Matches(tool, spec, arg))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>True only when every pending tool/arg pair is auto-approved.</summary>
|
||||
public bool AllowsEvery(IReadOnlyList<string> tools, IReadOnlyList<string> args)
|
||||
{
|
||||
if (tools.Count == 0) return false;
|
||||
for (int i = 0; i < tools.Count; i++)
|
||||
{
|
||||
var arg = i < args.Count ? args[i] : "";
|
||||
if (!Allows(tools[i], arg)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool Matches(string tool, string spec, string arg)
|
||||
{
|
||||
if (spec is "*" or "") return true;
|
||||
|
||||
// Bash specifiers are command prefixes; "git push:*" allows any command starting "git push".
|
||||
if (tool.Equals("Bash", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (spec.EndsWith(":*"))
|
||||
return arg.StartsWith(spec[..^2], StringComparison.Ordinal);
|
||||
return arg.Equals(spec, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// Everything else (paths, urls): treat the specifier as a glob over the argument.
|
||||
return GlobMatch(spec, arg);
|
||||
}
|
||||
|
||||
private static bool GlobMatch(string pattern, string text)
|
||||
{
|
||||
// Translate a gitignore-ish glob to a regex: ** = any, * = any except '/'.
|
||||
var rx = "^" + Regex.Escape(pattern)
|
||||
.Replace(@"\*\*", ".*")
|
||||
.Replace(@"\*", "[^/]*")
|
||||
.Replace(@"\?", ".") + "$";
|
||||
try { return Regex.IsMatch(text, rx); }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
// ----- Loading -----
|
||||
|
||||
/// <summary>
|
||||
/// Loads and merges permission rules for a chat with the given working directory: user-level
|
||||
/// settings plus the project's settings.json and settings.local.json. Missing files are ignored.
|
||||
/// </summary>
|
||||
public static PermissionRules Load(AppConfig config, string cwd)
|
||||
{
|
||||
var rules = new PermissionRules();
|
||||
|
||||
// ~/.claude/settings.json
|
||||
rules.MergeFile(config.ToWindowsPath($"{config.LinuxHome}/.claude/settings.json"));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(cwd))
|
||||
{
|
||||
var projectDir = config.ToWindowsPath($"{cwd.TrimEnd('/')}/.claude");
|
||||
rules.MergeFile(Path.Combine(projectDir, "settings.json"));
|
||||
rules.MergeFile(Path.Combine(projectDir, "settings.local.json"));
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
private void MergeFile(string path)
|
||||
{
|
||||
JsonDocument doc;
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path)) return;
|
||||
doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
}
|
||||
catch { return; }
|
||||
|
||||
using (doc)
|
||||
{
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object) return;
|
||||
|
||||
if (root.TryGetProperty("permissions", out var perms) && perms.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
AddRules(perms, "allow");
|
||||
if (perms.TryGetProperty("defaultMode", out var modeEl) &&
|
||||
modeEl.ValueKind == JsonValueKind.String)
|
||||
ApplyMode(modeEl.GetString());
|
||||
}
|
||||
|
||||
// Legacy / top-level forms.
|
||||
AddRules(root, "allowedTools");
|
||||
if (root.TryGetProperty("defaultMode", out var topMode) &&
|
||||
topMode.ValueKind == JsonValueKind.String)
|
||||
ApplyMode(topMode.GetString());
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyMode(string? mode)
|
||||
{
|
||||
if (string.Equals(mode, "bypassPermissions", StringComparison.OrdinalIgnoreCase)) _bypassAll = true;
|
||||
else if (string.Equals(mode, "acceptEdits", StringComparison.OrdinalIgnoreCase)) _acceptEdits = true;
|
||||
}
|
||||
|
||||
private void AddRules(JsonElement parent, string key)
|
||||
{
|
||||
if (!parent.TryGetProperty(key, out var arr) || arr.ValueKind != JsonValueKind.Array) return;
|
||||
|
||||
foreach (var item in arr.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.String) continue;
|
||||
var rule = item.GetString();
|
||||
if (string.IsNullOrWhiteSpace(rule)) continue;
|
||||
|
||||
// "Tool" or "Tool(specifier)".
|
||||
int open = rule.IndexOf('(');
|
||||
if (open < 0)
|
||||
{
|
||||
_allowAll.Add(rule.Trim());
|
||||
continue;
|
||||
}
|
||||
|
||||
var tool = rule[..open].Trim();
|
||||
int close = rule.LastIndexOf(')');
|
||||
var spec = close > open ? rule[(open + 1)..close].Trim() : "";
|
||||
|
||||
if (!_allowSpec.TryGetValue(tool, out var list))
|
||||
_allowSpec[tool] = list = new List<string>();
|
||||
list.Add(spec);
|
||||
}
|
||||
}
|
||||
}
|
||||
335
Services/SessionScanner.cs
Normal file
335
Services/SessionScanner.cs
Normal file
@@ -0,0 +1,335 @@
|
||||
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() + "…";
|
||||
}
|
||||
113
Services/UsageService.cs
Normal file
113
Services/UsageService.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using ClaudeOverview.Models;
|
||||
|
||||
namespace ClaudeOverview.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches subscription usage (session / weekly limits) from the same endpoint Claude
|
||||
/// Code's /usage command uses, authenticating with the OAuth token stored in
|
||||
/// ~/.claude/.credentials.json (read fresh each call so it tracks token refreshes).
|
||||
/// </summary>
|
||||
public sealed class UsageService
|
||||
{
|
||||
private const string Endpoint = "https://api.anthropic.com/api/oauth/usage";
|
||||
|
||||
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(12) };
|
||||
|
||||
private readonly AppConfig _config;
|
||||
|
||||
public UsageService(AppConfig config) => _config = config;
|
||||
|
||||
public async Task<UsageInfo> FetchAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var token = ReadAccessToken(out var tokenError);
|
||||
if (token is null) return new UsageInfo { Error = tokenError };
|
||||
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, Endpoint);
|
||||
req.Headers.TryAddWithoutValidation("Authorization", "Bearer " + token);
|
||||
req.Headers.TryAddWithoutValidation("anthropic-beta", "oauth-2025-04-20");
|
||||
|
||||
using var resp = await Http.SendAsync(req);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
if (resp.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
return new UsageInfo
|
||||
{
|
||||
Error = "rate limited (429 — backing off)",
|
||||
RateLimited = true,
|
||||
RetryAfter = resp.Headers.RetryAfter?.Delta
|
||||
?? (resp.Headers.RetryAfter?.Date is { } retryAt
|
||||
? retryAt.ToUniversalTime() - DateTimeOffset.UtcNow
|
||||
: null),
|
||||
};
|
||||
}
|
||||
|
||||
var hint = resp.StatusCode == System.Net.HttpStatusCode.Unauthorized
|
||||
? " (token expired — open Claude Code to refresh)"
|
||||
: "";
|
||||
return new UsageInfo { Error = $"usage HTTP {(int)resp.StatusCode}{hint}" };
|
||||
}
|
||||
|
||||
await using var stream = await resp.Content.ReadAsStreamAsync();
|
||||
using var doc = await JsonDocument.ParseAsync(stream);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var info = new UsageInfo();
|
||||
if (root.TryGetProperty("five_hour", out var fh) && fh.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
info.SessionPercent = GetDouble(fh, "utilization");
|
||||
info.SessionResetsAt = GetDate(fh, "resets_at");
|
||||
}
|
||||
if (root.TryGetProperty("seven_day", out var sd) && sd.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
info.WeeklyPercent = GetDouble(sd, "utilization");
|
||||
info.WeeklyResetsAt = GetDate(sd, "resets_at");
|
||||
}
|
||||
return info;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UsageInfo { Error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
private string? ReadAccessToken(out string? error)
|
||||
{
|
||||
error = null;
|
||||
var path = _config.CredentialsPath;
|
||||
if (!File.Exists(path)) { error = "not signed in (no credentials)"; return null; }
|
||||
|
||||
try
|
||||
{
|
||||
using var fs = File.OpenRead(path);
|
||||
using var doc = JsonDocument.Parse(fs);
|
||||
if (!doc.RootElement.TryGetProperty("claudeAiOauth", out var oauth))
|
||||
{
|
||||
error = "no OAuth session";
|
||||
return null;
|
||||
}
|
||||
var token = oauth.TryGetProperty("accessToken", out var t) ? t.GetString() : null;
|
||||
if (string.IsNullOrEmpty(token)) { error = "no access token"; return null; }
|
||||
return token;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"credentials read failed: {ex.Message}";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static double GetDouble(JsonElement e, string name) =>
|
||||
e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.Number ? p.GetDouble() : 0;
|
||||
|
||||
private static DateTime? GetDate(JsonElement e, string name) =>
|
||||
e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String &&
|
||||
DateTime.TryParse(p.GetString(), null,
|
||||
System.Globalization.DateTimeStyles.AdjustToUniversal, out var dt)
|
||||
? dt : null;
|
||||
}
|
||||
Reference in New Issue
Block a user