diff --git a/src/ClaudeDo.Worker/Usage/TokenTracker/Interfaces/ITokenTrackerClient.cs b/src/ClaudeDo.Worker/Usage/TokenTracker/Interfaces/ITokenTrackerClient.cs
new file mode 100644
index 00000000..8840b9da
--- /dev/null
+++ b/src/ClaudeDo.Worker/Usage/TokenTracker/Interfaces/ITokenTrackerClient.cs
@@ -0,0 +1,14 @@
+namespace ClaudeDo.Worker.Usage.TokenTracker.Interfaces;
+
+public interface ITokenTrackerClient
+{
+ /// Checks whether the CLI and a usable Node runtime are present. Never throws.
+ Task ProbeAsync(CancellationToken ct = default);
+
+ /// Runs the sessions export. Never throws — a failure comes back as
+ /// false plus an error message.
+ Task ExportAsync(DateOnly from, DateOnly to, CancellationToken ct = default);
+
+ /// Runs npm i -g tokentracker-cli, reporting each output line.
+ Task InstallAsync(IProgress? output = null, CancellationToken ct = default);
+}
diff --git a/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerClient.cs b/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerClient.cs
new file mode 100644
index 00000000..8f02becd
--- /dev/null
+++ b/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerClient.cs
@@ -0,0 +1,129 @@
+using System.Diagnostics;
+using System.Globalization;
+using ClaudeDo.Data.Environment;
+using ClaudeDo.Worker.Usage.TokenTracker.Interfaces;
+
+namespace ClaudeDo.Worker.Usage.TokenTracker;
+
+///
+/// Starts the TokenTracker CLI. Resolution goes through because
+/// an npm-installed CLI is a .cmd shim, which UseShellExecute = false cannot exec
+/// directly. We deliberately never fall back to npx — that would silently download a
+/// package behind the user's back.
+///
+public sealed class TokenTrackerClient : ITokenTrackerClient
+{
+ 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 const int MinimumNodeMajor = 20;
+
+ public async Task ProbeAsync(CancellationToken ct = default)
+ {
+ var node = await RunAsync("node", ["--version"], ProbeTimeout, null, ct);
+ var nodeVersion = node.Ok ? node.StdOut.Trim() : null;
+ var nodeOk = NodeMajor(nodeVersion) >= MinimumNodeMajor;
+
+ var cli = await RunAsync(TokenTrackerArgs.Command, TokenTrackerArgs.Version(), ProbeTimeout, null, ct);
+ return cli.Ok
+ ? new TokenTrackerProbe(true, cli.StdOut.Trim(), nodeOk, nodeVersion, null)
+ : new TokenTrackerProbe(false, null, nodeOk, nodeVersion, cli.Error);
+ }
+
+ public Task ExportAsync(DateOnly from, DateOnly to, CancellationToken ct = default) =>
+ RunAsync(TokenTrackerArgs.Command, TokenTrackerArgs.Export(from, to), ExportTimeout, null, ct);
+
+ public Task InstallAsync(IProgress? output = null, CancellationToken ct = default) =>
+ RunAsync(TokenTrackerArgs.NpmCommand, TokenTrackerArgs.Install(), InstallTimeout, output, ct);
+
+ /// Visible for the probe's version parsing; returns 0 when unreadable.
+ internal static int NodeMajor(string? version)
+ {
+ if (string.IsNullOrWhiteSpace(version)) return 0;
+
+ var trimmed = version.Trim().TrimStart('v', 'V');
+ var major = trimmed.Split('.', 2)[0];
+ return int.TryParse(major, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : 0;
+ }
+
+ private static async Task RunAsync(
+ string command,
+ IReadOnlyList arguments,
+ TimeSpan timeout,
+ IProgress? output,
+ CancellationToken ct)
+ {
+ var resolved = ExecutableResolver.Resolve(command);
+ if (resolved is null)
+ return new TokenTrackerRunResult(false, "", $"'{command}' not found on PATH.");
+
+ 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);
+ }
+
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ timeoutCts.CancelAfter(timeout);
+
+ try
+ {
+ using var process = Process.Start(startInfo);
+ if (process is null)
+ return new TokenTrackerRunResult(false, "", $"Could not start '{command}'.");
+
+ // Both pipes are drained concurrently — reading one to the end first deadlocks as
+ // soon as the other fills its buffer, and a full-history export is ~1 MB of stdout.
+ var stdoutTask = process.StandardOutput.ReadToEndAsync(timeoutCts.Token);
+ var stderrTask = ReadLinesAsync(process, output, timeoutCts.Token);
+
+ await process.WaitForExitAsync(timeoutCts.Token);
+ var stdout = await stdoutTask;
+ var stderr = await stderrTask;
+
+ return process.ExitCode == 0
+ ? new TokenTrackerRunResult(true, stdout, null)
+ : new TokenTrackerRunResult(false, stdout,
+ $"'{command}' exited with code {process.ExitCode}. {Head(stderr)}".TrimEnd());
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ return new TokenTrackerRunResult(false, "", $"'{command}' timed out after {timeout.TotalSeconds:0}s.");
+ }
+ catch (Exception ex)
+ {
+ return new TokenTrackerRunResult(false, "", $"'{command}' failed: {ex.Message}");
+ }
+ }
+
+ private static async Task ReadLinesAsync(Process process, IProgress? output, CancellationToken ct)
+ {
+ var lines = new List();
+ while (await process.StandardError.ReadLineAsync(ct) is { } line)
+ {
+ lines.Add(line);
+ output?.Report(line);
+ }
+ return string.Join(System.Environment.NewLine, lines);
+ }
+
+ private static string Head(string text)
+ {
+ var trimmed = text.Trim();
+ return trimmed.Length <= 300 ? trimmed : trimmed[..300] + "…";
+ }
+}