diff --git a/docs/explore-notes/usage-monitoring.md b/docs/explore-notes/usage-monitoring.md
index 4ba1d7b1..f775d108 100644
--- a/docs/explore-notes/usage-monitoring.md
+++ b/docs/explore-notes/usage-monitoring.md
@@ -162,6 +162,17 @@ The per-model / per-task / cost view comes from **TokenTracker**, an external MI
- **Fail-open everywhere:** `TokenTrackerState` mirrors `UsageState` — a failed or unparseable
fetch never overwrites a good export, it only sets `LastError`. CLI missing → empty analytics
plus a hint card in the UI. Nothing here can throw into the queue.
+- **Local dashboard:** `tokentracker serve --port
--no-open --no-sync`, started by the worker on
+ demand (`ITokenTrackerClient.StartDashboardAsync`) for the "Open TokenTracker dashboard" button.
+ Three things this path has to get right: TokenTracker's default port **7680 is not free** on
+ Windows (Delivery Optimization holds `[::]:7680`) and `serve` exits instead of falling back, so
+ we scan 7680–7689 with a **dual-stack** bind probe (an IPv4-only probe calls 7680 free and the
+ spawn then dies); `--no-open` because the CLI would open the browser before the server answers,
+ so we poll `GET http://127.0.0.1:
/` (30 s cap) and let the **UI** open the URL; and the child
+ is a `cmd.exe` shim, so shutdown kills the **process tree** or node keeps listening. The
+ spawned server is reused while alive and dies with the worker (`TokenTrackerClient` is
+ `IDisposable`, registered as a singleton) — a worker *crash* leaks it until the port is scanned
+ past.
- **Limits, gate and throttle do *not* come from TokenTracker.** They are the OAuth poll above.
A broken or absent TokenTracker costs you the cost columns, nothing else.
@@ -199,7 +210,9 @@ The per-model / per-task / cost view comes from **TokenTracker**, an external MI
(`GetTaskUsageAsync`) over a 7d/30d preset or custom range — both with a **cost** column, the
task table additionally with retries.
- **Analytics header vs. hint card** — the modal shows exactly one of the two. With TokenTracker
- present: total tokens + total cost, a freshness stamp, and a manual `RefreshAnalytics` button.
+ present: total tokens + total cost, a freshness stamp, a manual `RefreshAnalytics` button and an
+ **Open TokenTracker dashboard** button (spinner while the server boots; everything this modal
+ deliberately doesn't show lives there).
Without it: a hint card explaining what TokenTracker is (local only, no `init`, no cloud sync)
plus an `Install TokenTracker` button — which is hidden and replaced by a Node-version hint when
Node < 20 or Node is missing, because the install would just fail. Install progress and every
@@ -223,6 +236,9 @@ The per-model / per-task / cost view comes from **TokenTracker**, an external MI
NodeVersion, LastFetchedUtc, LastError, FormatVersion, SessionCount)`. Never throws; an
unconfigured worker comes back as not-installed with a reason.
- `RefreshTokenTracker()` — forces a fetch, then re-probes and returns the status.
+- `OpenTokenTrackerDashboard()` — brings up the local dashboard server and returns
+ `TokenTrackerDashboardDto(Ok, Url, Error)`; the UI opens the browser (`ShellOpen.Url`, which
+ refuses anything that isn't http/https). Never throws — a failure also lands in `WorkerLog`.
- `InstallTokenTracker()` — `npm i -g tokentracker-cli`, streaming npm's output line by line into
`WorkerLog` (npm writes progress to stderr; forwarding it is what keeps a multi-minute global
install from looking hung), then force-re-probes.
diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json
index d9e89b21..fb62675d 100644
--- a/src/ClaudeDo.Localization/locales/de.json
+++ b/src/ClaudeDo.Localization/locales/de.json
@@ -535,6 +535,8 @@
"analyticsStampFormat": "Auswertung: Stand {0}",
"analyticsNever": "Auswertung noch nicht geladen",
"analyticsRefresh": "Auswertung aktualisieren",
+ "openDashboard": "TokenTracker-Dashboard öffnen",
+ "openDashboardTip": "Startet das lokale TokenTracker-Dashboard (nur localhost, kein Cloud-Sync) und öffnet es im Browser. Der erste Start dauert ein paar Sekunden.",
"analyticsTotalsFormat": "{0} Tokens · {1} $",
"ttMissingTitle": "Die Token-Auswertung braucht TokenTracker",
"ttMissingBody": "TokenTracker (MIT) liest deine lokalen Claude-Transkripte und sieht keine Prompts. ClaudeDo ruft nur den lokalen Export auf — kein `init`, keine Hooks, kein Cloud-Sync.",
@@ -750,7 +752,9 @@
"thresholdSaveFailed": "Grenze konnte nicht gespeichert werden: {0}",
"installStarted": "TokenTracker wird installiert — der Fortschritt läuft in der Log-Leiste.",
"installFailed": "TokenTracker konnte nicht installiert werden: {0}",
- "analyticsRefreshFailed": "Die Auswertung konnte nicht aktualisiert werden: {0}"
+ "analyticsRefreshFailed": "Die Auswertung konnte nicht aktualisiert werden: {0}",
+ "dashboardFailed": "Das TokenTracker-Dashboard konnte nicht geöffnet werden: {0}",
+ "dashboardUnavailable": "der lokale Dashboard-Server ist nicht gestartet"
},
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "resetToDefault": "Auf den mitgelieferten Standard zurückgesetzt." },
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" },
diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json
index ffc6ff06..cf0514e4 100644
--- a/src/ClaudeDo.Localization/locales/en.json
+++ b/src/ClaudeDo.Localization/locales/en.json
@@ -535,6 +535,8 @@
"analyticsStampFormat": "Analytics as of {0}",
"analyticsNever": "Analytics not loaded yet",
"analyticsRefresh": "Refresh analytics",
+ "openDashboard": "Open TokenTracker dashboard",
+ "openDashboardTip": "Starts TokenTracker's local dashboard (localhost only, no cloud sync) and opens it in your browser. The first start takes a few seconds.",
"analyticsTotalsFormat": "{0} tokens · ${1}",
"ttMissingTitle": "Token analytics need TokenTracker",
"ttMissingBody": "TokenTracker (MIT) reads your local Claude transcripts and never sees prompts. ClaudeDo only calls its local export — it does not run `init`, installs no hooks and enables no cloud sync.",
@@ -750,7 +752,9 @@
"thresholdSaveFailed": "Couldn't save the threshold: {0}",
"installStarted": "Installing TokenTracker — watch the log strip for progress.",
"installFailed": "Couldn't install TokenTracker: {0}",
- "analyticsRefreshFailed": "Couldn't refresh the analytics: {0}"
+ "analyticsRefreshFailed": "Couldn't refresh the analytics: {0}",
+ "dashboardFailed": "Couldn't open the TokenTracker dashboard: {0}",
+ "dashboardUnavailable": "the local dashboard server did not start"
},
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "resetToDefault": "Reset to the bundled default." },
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" },
diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs
index 24f961df..62b9cce8 100644
--- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs
+++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs
@@ -210,4 +210,8 @@ public interface IWorkerClient : INotifyPropertyChanged
Task GetTokenTrackerStatusAsync();
Task RefreshTokenTrackerAsync();
Task InstallTokenTrackerAsync();
+
+ /// Starts TokenTracker's local dashboard on the worker and returns its URL; opening
+ /// the browser is the caller's job.
+ Task OpenTokenTrackerDashboardAsync();
}
diff --git a/src/ClaudeDo.Ui/Services/ShellOpen.cs b/src/ClaudeDo.Ui/Services/ShellOpen.cs
index e4168349..406d5654 100644
--- a/src/ClaudeDo.Ui/Services/ShellOpen.cs
+++ b/src/ClaudeDo.Ui/Services/ShellOpen.cs
@@ -1,3 +1,4 @@
+using System;
using System.Diagnostics;
using System.IO;
@@ -7,6 +8,27 @@ public static class ShellOpen
{
// "nothing to open" (blank/missing path) is not an error — callers stay silent, matching the
// existing Directory.Exists/File.Exists guards this helper replaces.
+ /// Opens an http(s) URL in the default browser. Anything else is refused rather than
+ /// handed to the shell — a URL comes from the worker, and `UseShellExecute` would happily run
+ /// a local executable path.
+ public static (bool Ok, string? Error) Url(string? url)
+ {
+ if (string.IsNullOrWhiteSpace(url)) return (false, null);
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
+ (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
+ return (false, $"Not a web URL: {url}");
+
+ try
+ {
+ Process.Start(new ProcessStartInfo { FileName = uri.AbsoluteUri, UseShellExecute = true });
+ return (true, null);
+ }
+ catch (Exception ex)
+ {
+ return (false, ex.Message);
+ }
+ }
+
public static (bool Ok, string? Error) Path(string? path)
{
if (string.IsNullOrWhiteSpace(path)) return (false, null);
diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs
index dcc2b98e..c0a3ab0d 100644
--- a/src/ClaudeDo.Ui/Services/WorkerClient.cs
+++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs
@@ -698,6 +698,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public Task InstallTokenTrackerAsync()
=> TryInvokeAsync("InstallTokenTracker");
+ public Task OpenTokenTrackerDashboardAsync()
+ => TryInvokeAsync("OpenTokenTrackerDashboard");
+
// IWorkerClient explicit implementations (drop typed return values)
async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct)
=> await StartPlanningSessionAsync(taskId, ct);
@@ -872,6 +875,8 @@ public sealed record TaskUsageRowDto(
bool? Productive = null,
bool? OneShot = null);
+public sealed record TokenTrackerDashboardDto(bool Ok, string? Url, string? Error);
+
public sealed record TokenTrackerStatusDto(
bool Installed,
string? Version,
diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs
index 0a9268fc..0d358aa8 100644
--- a/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs
@@ -68,6 +68,8 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
[NotifyPropertyChangedFor(nameof(ShowInstallButton))]
private bool _isInstalling;
+ [ObservableProperty] private bool _isOpeningDashboard;
+
public bool ModelsEmpty => !IsBusy && ModelRows.Count == 0;
public bool TasksEmpty => !IsBusy && TaskRows.Count == 0;
@@ -229,6 +231,35 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
finally { IsInstalling = false; }
}
+ /// Hands off to TokenTracker's own dashboard for everything this modal deliberately
+ /// doesn't show. The worker starts the local server (first start takes a few seconds), we only
+ /// open the browser.
+ [RelayCommand]
+ private async Task OpenDashboard()
+ {
+ IsOpeningDashboard = true;
+ try
+ {
+ var dashboard = await _worker.OpenTokenTrackerDashboardAsync();
+ if (dashboard is not { Ok: true, Url: { Length: > 0 } url })
+ {
+ ErrorReported?.Invoke(Loc.T("vm.usageMonitor.dashboardFailed",
+ dashboard?.Error ?? Loc.T("vm.usageMonitor.dashboardUnavailable")));
+ return;
+ }
+
+ var (ok, error) = ShellOpen.Url(url);
+ if (!ok)
+ ErrorReported?.Invoke(Loc.T("vm.usageMonitor.dashboardFailed",
+ error ?? Loc.T("vm.usageMonitor.dashboardUnavailable")));
+ }
+ catch (Exception ex)
+ {
+ ErrorReported?.Invoke(Loc.T("vm.usageMonitor.dashboardFailed", ex.Message));
+ }
+ finally { IsOpeningDashboard = false; }
+ }
+
private Task SetPreset(int days)
{
ApplyPresetRange(days);
diff --git a/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml
index 2fff755b..8512d352 100644
--- a/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml
+++ b/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml
@@ -180,6 +180,12 @@
+
+
diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs
index 99df0591..90f6770c 100644
--- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs
+++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs
@@ -184,6 +184,8 @@ public record TaskUsageRowDto(
bool? Productive = null,
bool? OneShot = null);
+public record TokenTrackerDashboardDto(bool Ok, string? Url, string? Error);
+
public record TokenTrackerStatusDto(
bool Installed,
string? Version,
@@ -1233,6 +1235,26 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
return BuildTokenTrackerStatus(await _tokenTracker.ProbeAsync(ct: Context.ConnectionAborted));
});
+ ///
+ /// Brings up TokenTracker's own local dashboard (tokentracker serve) and hands the URL
+ /// back — the UI opens the browser, since that is a user-session concern. The server is a
+ /// child of this worker and dies with it. Never throws: a failure is a message the caller
+ /// flashes in the footer.
+ ///
+ public Task OpenTokenTrackerDashboard() => HubGuard(async () =>
+ {
+ if (_tokenTracker is null)
+ return new TokenTrackerDashboardDto(false, null, "TokenTracker is not configured on this worker.");
+
+ var dashboard = await _tokenTracker.OpenDashboardAsync(Context.ConnectionAborted);
+ if (!dashboard.Ok)
+ await _broadcaster.WorkerLog(
+ dashboard.Error ?? "TokenTracker dashboard failed to start.",
+ WorkerLogLevel.Error, DateTime.UtcNow);
+
+ return new TokenTrackerDashboardDto(dashboard.Ok, dashboard.Url, dashboard.Error);
+ });
+
private TokenTrackerStatusDto BuildTokenTrackerStatus(TokenTrackerProbe probe)
{
var state = _tokenTracker!.State;
diff --git a/src/ClaudeDo.Worker/Usage/TokenTracker/Interfaces/ITokenTrackerClient.cs b/src/ClaudeDo.Worker/Usage/TokenTracker/Interfaces/ITokenTrackerClient.cs
index 8840b9da..2f54ebf0 100644
--- a/src/ClaudeDo.Worker/Usage/TokenTracker/Interfaces/ITokenTrackerClient.cs
+++ b/src/ClaudeDo.Worker/Usage/TokenTracker/Interfaces/ITokenTrackerClient.cs
@@ -11,4 +11,8 @@ public interface ITokenTrackerClient
/// Runs npm i -g tokentracker-cli, reporting each output line.
Task InstallAsync(IProgress? output = null, CancellationToken ct = default);
+
+ /// Makes sure the local dashboard server is up and returns its URL. Idempotent — a
+ /// server this worker already started is reused instead of spawning a second one.
+ Task StartDashboardAsync(CancellationToken ct = default);
}
diff --git a/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerArgs.cs b/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerArgs.cs
index 612b730a..bd305c7c 100644
--- a/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerArgs.cs
+++ b/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerArgs.cs
@@ -16,6 +16,14 @@ public static class TokenTrackerArgs
public const string NpmCommand = "npm";
public const string PackageName = "tokentracker-cli";
+ /// TokenTracker's own default dashboard port. It is not reliably free —
+ /// Windows Delivery Optimization listens on 7680 — and serve does not fall back on its
+ /// own, it just exits with "port is still in use", so we scan
+ /// ports upward from here.
+ public const int DefaultDashboardPort = 7680;
+
+ public const int DashboardPortSpan = 10;
+
public static string[] Export(DateOnly from, DateOnly to) =>
[
"sessions",
@@ -27,5 +35,17 @@ public static class TokenTrackerArgs
public static string[] Version() => ["-v"];
+ /// The local dashboard server. --no-open because we open the browser
+ /// ourselves once the port answers (the CLI would open it before the server is up), and
+ /// --no-sync for the same reason we never call init: we do not push the user's
+ /// session data to TokenTracker's cloud on their behalf.
+ public static string[] Serve(int port) =>
+ [
+ "serve",
+ "--port", port.ToString(CultureInfo.InvariantCulture),
+ "--no-open",
+ "--no-sync",
+ ];
+
public static string[] Install() => ["i", "-g", PackageName];
}
diff --git a/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerClient.cs b/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerClient.cs
index 8f02becd..eda3bc1a 100644
--- a/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerClient.cs
+++ b/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerClient.cs
@@ -1,5 +1,8 @@
using System.Diagnostics;
using System.Globalization;
+using System.Net;
+using System.Net.Http;
+using System.Net.Sockets;
using ClaudeDo.Data.Environment;
using ClaudeDo.Worker.Usage.TokenTracker.Interfaces;
@@ -11,13 +14,20 @@ namespace ClaudeDo.Worker.Usage.TokenTracker;
/// directly. We deliberately never fall back to npx — that would silently download a
/// package behind the user's back.
///
-public sealed class TokenTrackerClient : ITokenTrackerClient
+public sealed class TokenTrackerClient : ITokenTrackerClient, IDisposable
{
private static readonly TimeSpan ExportTimeout = TimeSpan.FromSeconds(120);
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(20);
private static readonly TimeSpan InstallTimeout = TimeSpan.FromMinutes(5);
+ private static readonly TimeSpan DashboardReadyTimeout = TimeSpan.FromSeconds(30);
private const int MinimumNodeMajor = 20;
+ private static readonly HttpClient ReadinessProbe = new() { Timeout = TimeSpan.FromSeconds(2) };
+
+ private readonly SemaphoreSlim _dashboardLock = new(1, 1);
+ private Process? _dashboardProcess;
+ private string? _dashboardUrl;
+
public async Task ProbeAsync(CancellationToken ct = default)
{
var node = await RunAsync("node", ["--version"], ProbeTimeout, null, ct);
@@ -36,6 +46,201 @@ public sealed class TokenTrackerClient : ITokenTrackerClient
public Task InstallAsync(IProgress? output = null, CancellationToken ct = default) =>
RunAsync(TokenTrackerArgs.NpmCommand, TokenTrackerArgs.Install(), InstallTimeout, output, ct);
+ public async Task StartDashboardAsync(CancellationToken ct = default)
+ {
+ await _dashboardLock.WaitAsync(ct);
+ try
+ {
+ if (_dashboardProcess is { HasExited: false } && _dashboardUrl is { } running)
+ return new TokenTrackerDashboard(true, running, null);
+
+ KillDashboard();
+
+ var resolved = ExecutableResolver.Resolve(TokenTrackerArgs.Command);
+ if (resolved is null)
+ return new TokenTrackerDashboard(false, null,
+ $"'{TokenTrackerArgs.Command}' not found on PATH.");
+
+ var port = FindFreePort();
+ if (port is null)
+ return new TokenTrackerDashboard(false, null,
+ $"No free port between {TokenTrackerArgs.DefaultDashboardPort} and " +
+ $"{TokenTrackerArgs.DefaultDashboardPort + TokenTrackerArgs.DashboardPortSpan - 1}.");
+
+ var arguments = TokenTrackerArgs.Serve(port.Value);
+ var startInfo = new ProcessStartInfo
+ {
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+
+ if (resolved.IsShim)
+ {
+ var shim = ExecutableResolver.BuildShimStartInfo(resolved.Path, arguments);
+ startInfo.FileName = shim.FileName;
+ startInfo.Arguments = shim.Arguments;
+ }
+ else
+ {
+ startInfo.FileName = resolved.Path;
+ foreach (var arg in arguments) startInfo.ArgumentList.Add(arg);
+ }
+
+ Process process;
+ try
+ {
+ process = Process.Start(startInfo)
+ ?? throw new InvalidOperationException("Process.Start returned null.");
+ }
+ catch (Exception ex)
+ {
+ return new TokenTrackerDashboard(false, null, $"Could not start the dashboard: {ex.Message}");
+ }
+
+ // The server is long-lived, so both pipes must keep draining — a full pipe buffer
+ // would block the very server we are waiting for. The head is kept for the error
+ // message ("port is still in use" arrives on stdout before the process exits).
+ var output = new DashboardOutput();
+ _ = DrainAsync(process.StandardOutput, output);
+ _ = DrainAsync(process.StandardError, output);
+
+ var url = $"http://127.0.0.1:{port.Value}/";
+ bool serving;
+ try
+ {
+ serving = await WaitUntilServingAsync(process, url, ct);
+ }
+ catch (OperationCanceledException)
+ {
+ // A caller that walked away must not leave a server we no longer track behind.
+ KillProcessTree(process);
+ throw;
+ }
+
+ if (!serving)
+ {
+ var reason = output.Head() is { Length: > 0 } head
+ ? head
+ : $"the dashboard did not answer on port {port.Value} within " +
+ $"{DashboardReadyTimeout.TotalSeconds:0}s.";
+ KillProcessTree(process);
+ return new TokenTrackerDashboard(false, null, $"TokenTracker dashboard failed: {reason}");
+ }
+
+ _dashboardProcess = process;
+ _dashboardUrl = url;
+ return new TokenTrackerDashboard(true, url, null);
+ }
+ finally
+ {
+ _dashboardLock.Release();
+ }
+ }
+
+ public void Dispose() => KillDashboard();
+
+ private void KillDashboard()
+ {
+ if (_dashboardProcess is { } process) KillProcessTree(process);
+ _dashboardProcess = null;
+ _dashboardUrl = null;
+ }
+
+ /// An npm CLI is launched through cmd.exe, so the process we hold is the shim,
+ /// not node — killing it alone would leave the server listening.
+ private static void KillProcessTree(Process process)
+ {
+ try
+ {
+ if (!process.HasExited) process.Kill(entireProcessTree: true);
+ }
+ catch { /* already gone, or we never had rights to it */ }
+ finally { process.Dispose(); }
+ }
+
+ private static async Task WaitUntilServingAsync(Process process, string url, CancellationToken ct)
+ {
+ var deadline = DateTime.UtcNow + DashboardReadyTimeout;
+ while (DateTime.UtcNow < deadline)
+ {
+ if (process.HasExited) return false;
+ try
+ {
+ using var response = await ReadinessProbe.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
+ if (response.IsSuccessStatusCode) return true;
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch { /* not listening yet */ }
+
+ await Task.Delay(250, ct);
+ }
+ return false;
+ }
+
+ /// First free port at or above TokenTracker's default. The probe binds dual-stack
+ /// because node listens on [::]: an IPv4-only check calls 7680 free on a machine where
+ /// Windows Delivery Optimization already holds [::]:7680, and serve then dies.
+ private static int? FindFreePort()
+ {
+ for (var port = TokenTrackerArgs.DefaultDashboardPort;
+ port < TokenTrackerArgs.DefaultDashboardPort + TokenTrackerArgs.DashboardPortSpan;
+ port++)
+ {
+ if (IsPortFree(port)) return port;
+ }
+ return null;
+ }
+
+ private static bool IsPortFree(int port)
+ {
+ try
+ {
+ using var socket = new Socket(AddressFamily.InterNetworkV6, SocketType.Stream, ProtocolType.Tcp)
+ {
+ DualMode = true,
+ ExclusiveAddressUse = true,
+ };
+ socket.Bind(new IPEndPoint(IPAddress.IPv6Any, port));
+ return true;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
+ private static async Task DrainAsync(StreamReader reader, DashboardOutput sink)
+ {
+ try
+ {
+ while (await reader.ReadLineAsync() is { } line) sink.Add(line);
+ }
+ catch { /* the process went away; nothing left to drain */ }
+ }
+
+ /// Keeps only the first few lines — enough to explain a failed start, bounded so a
+ /// server running for hours cannot grow it.
+ private sealed class DashboardOutput
+ {
+ private const int MaxLines = 10;
+ private readonly List _lines = [];
+
+ public void Add(string line)
+ {
+ lock (_lines)
+ {
+ if (_lines.Count < MaxLines && !string.IsNullOrWhiteSpace(line)) _lines.Add(line.Trim());
+ }
+ }
+
+ public string Head()
+ {
+ lock (_lines) return string.Join(" ", _lines);
+ }
+ }
+
/// Visible for the probe's version parsing; returns 0 when unreadable.
internal static int NodeMajor(string? version)
{
diff --git a/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerModels.cs b/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerModels.cs
index b5eb6137..2102a98b 100644
--- a/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerModels.cs
+++ b/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerModels.cs
@@ -39,6 +39,10 @@ public sealed record TokenTrackerProbe(
public static TokenTrackerProbe Unknown { get; } = new(false, null, false, null, null);
}
+/// Where the local dashboard is reachable, or why it isn't. Same contract as
+/// : a failure is a value, never an exception.
+public sealed record TokenTrackerDashboard(bool Ok, string? Url, string? Error);
+
/// Outcome of one CLI invocation. false always carries an
/// — callers turn that into state, never into an exception.
public sealed record TokenTrackerRunResult(bool Ok, string StdOut, string? Error);
diff --git a/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerService.cs b/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerService.cs
index 8173ec4c..06aae24d 100644
--- a/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerService.cs
+++ b/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerService.cs
@@ -88,6 +88,18 @@ public sealed class TokenTrackerService
}
}
+ /// Starts (or reuses) the local dashboard server. Probes first so a missing CLI comes
+ /// back as a message instead of a failed process spawn.
+ public async Task OpenDashboardAsync(CancellationToken ct = default)
+ {
+ var probe = await ProbeAsync(ct: ct);
+ if (!probe.Installed)
+ return new TokenTrackerDashboard(false, null,
+ probe.Error ?? "TokenTracker CLI is not installed.");
+
+ return await _client.StartDashboardAsync(ct);
+ }
+
public async Task InstallAsync(
IProgress? output = null, CancellationToken ct = default)
{
diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs
index 6d6f5b1e..e13c30ea 100644
--- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs
+++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs
@@ -194,6 +194,10 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task RefreshTokenTrackerAsync() => GetTokenTrackerStatusAsync();
public virtual Task InstallTokenTrackerAsync() => GetTokenTrackerStatusAsync();
+
+ public virtual Task OpenTokenTrackerDashboardAsync() =>
+ Task.FromResult(new TokenTrackerDashboardDto(true, "http://127.0.0.1:7681/", null));
+
public void RaiseUsageUpdated(UsageSnapshotDto snapshot) => UsageUpdatedEvent?.Invoke(snapshot);
protected void RaisePropertyChanged(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
index 7210719b..025b4f99 100644
--- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
+++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
@@ -180,6 +180,9 @@ sealed class FakeWorkerClient : IWorkerClient
public Task RefreshTokenTrackerAsync() => GetTokenTrackerStatusAsync();
public Task InstallTokenTrackerAsync() => GetTokenTrackerStatusAsync();
+
+ public Task OpenTokenTrackerDashboardAsync() =>
+ Task.FromResult(new TokenTrackerDashboardDto(false, null, "not configured"));
}
// ── Helper to build VM with pre-seeded Items ──────────────────────────────────
diff --git a/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/FakeTokenTrackerClient.cs b/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/FakeTokenTrackerClient.cs
index 648cee39..03c8d768 100644
--- a/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/FakeTokenTrackerClient.cs
+++ b/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/FakeTokenTrackerClient.cs
@@ -38,6 +38,15 @@ public sealed class FakeTokenTrackerClient : ITokenTrackerClient
return ExportResult;
}
+ public TokenTrackerDashboard DashboardResult { get; set; } = new(true, "http://127.0.0.1:7680/", null);
+ public int DashboardCalls;
+
+ public Task StartDashboardAsync(CancellationToken ct = default)
+ {
+ Interlocked.Increment(ref DashboardCalls);
+ return Task.FromResult(DashboardResult);
+ }
+
public Task InstallAsync(IProgress? output = null, CancellationToken ct = default)
{
Interlocked.Increment(ref InstallCalls);
diff --git a/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerArgsTests.cs b/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerArgsTests.cs
index 613ee6d7..4c026b5f 100644
--- a/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerArgsTests.cs
+++ b/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerArgsTests.cs
@@ -39,4 +39,12 @@ public sealed class TokenTrackerArgsTests
{
Assert.Equal(new[] { "i", "-g", "tokentracker-cli" }, TokenTrackerArgs.Install());
}
+
+ [Fact]
+ public void Serve_PinsThePortAndOptsOutOfBrowserAndCloudSync()
+ {
+ Assert.Equal(
+ new[] { "serve", "--port", "7681", "--no-open", "--no-sync" },
+ TokenTrackerArgs.Serve(7681));
+ }
}
diff --git a/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerServiceTests.cs
index 1cf686af..84319a51 100644
--- a/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerServiceTests.cs
+++ b/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerServiceTests.cs
@@ -13,6 +13,32 @@ public sealed class TokenTrackerServiceTests
return (new TokenTrackerService(client, new TokenTrackerState(), () => clock), client);
}
+ [Fact]
+ public async Task OpenDashboardAsync_ReturnsTheUrlWhenTheCliIsInstalled()
+ {
+ var (service, client) = Build();
+
+ var dashboard = await service.OpenDashboardAsync();
+
+ Assert.True(dashboard.Ok);
+ Assert.Equal("http://127.0.0.1:7680/", dashboard.Url);
+ Assert.Equal(1, client.DashboardCalls);
+ }
+
+ [Fact]
+ public async Task OpenDashboardAsync_MissingCli_ReportsTheProbeErrorWithoutSpawning()
+ {
+ var (service, client) = Build();
+ client.Probe = new TokenTrackerProbe(false, null, true, "v22.1.0", "'tokentracker' not found on PATH.");
+
+ var dashboard = await service.OpenDashboardAsync();
+
+ Assert.False(dashboard.Ok);
+ Assert.Null(dashboard.Url);
+ Assert.Equal("'tokentracker' not found on PATH.", dashboard.Error);
+ Assert.Equal(0, client.DashboardCalls);
+ }
+
[Fact]
public async Task RefreshAsync_StoresParsedExport()
{