diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
index ea7746800..94a63d3ba 100644
--- a/apps/server/src/server.test.ts
+++ b/apps/server/src/server.test.ts
@@ -86,6 +86,7 @@ import {
type ProjectionSnapshotQueryShape,
} from "./orchestration/Services/ProjectionSnapshotQuery.ts";
import { ThreadSearch, type ThreadSearchShape } from "./orchestration/Services/ThreadSearch.ts";
+import { UsageService } from "./usage/UsageService.ts";
import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";
import { PersistenceSqlError } from "./persistence/Errors.ts";
import {
@@ -672,6 +673,9 @@ const buildAppUnderTest = (options?: {
search: () => Effect.succeed({ matches: [], truncated: false }),
...options?.layers?.threadSearch,
}),
+ // Usage scans real provider transcript directories; the empty summary
+ // keeps the RPC surface resolvable without touching the host's files.
+ UsageService.layerTest,
),
),
Layer.provide(
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index e964ef173..591021149 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -93,6 +93,7 @@ import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts
import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
import { ThreadSearchLive } from "./orchestration/Layers/ThreadSearch.ts";
+import { UsageServiceLive } from "./usage/UsageService.ts";
import {
clearPersistedServerRuntimeState,
makePersistedServerRuntimeState,
@@ -356,6 +357,10 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(
const RuntimeServicesLive = Layer.mergeAll(
ServerRuntimeStartupLive.pipe(Layer.provideMerge(RuntimeDependenciesLive)),
ThreadSearchLive.pipe(Layer.provide(PersistenceLayerLive)),
+ // Usage reads provider transcripts straight off disk, so it needs only
+ // settings (for the resolved provider homes) plus the platform services and
+ // HTTP client provided further out.
+ UsageServiceLive.pipe(Layer.provide(ServerSettingsLive)),
);
export const makeRoutesLayer = Layer.mergeAll(
diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts
new file mode 100644
index 000000000..db5da67ec
--- /dev/null
+++ b/apps/server/src/usage/UsageService.test.ts
@@ -0,0 +1,343 @@
+// @effect-diagnostics nodeBuiltinImport:off
+import * as NodeFSP from "node:fs/promises";
+import * as NodeOS from "node:os";
+import * as NodePath from "node:path";
+
+import { DEFAULT_SERVER_SETTINGS, UsageDay, type UsageSummary } from "@threadlines/contracts";
+import { NodeServices } from "@effect/platform-node";
+import { describe, expect, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Stream from "effect/Stream";
+import { HttpClient, HttpClientResponse } from "effect/unstable/http";
+
+import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "../config.ts";
+import { ServerSettingsService } from "../serverSettings.ts";
+import { UsageService, UsageServiceLive } from "./UsageService.ts";
+
+const CLAUDE_MODEL = "claude-fable-5";
+const CODEX_MODEL = "gpt-5.6-sol";
+
+/** Minimal LiteLLM document covering just the two models the fixtures use. */
+const RATE_DOCUMENT = {
+ [CLAUDE_MODEL]: {
+ input_cost_per_token: 1e-5,
+ output_cost_per_token: 5e-5,
+ cache_read_input_token_cost: 1e-6,
+ cache_creation_input_token_cost: 1.25e-5,
+ },
+ [CODEX_MODEL]: {
+ input_cost_per_token: 2e-6,
+ output_cost_per_token: 1e-5,
+ cache_read_input_token_cost: 2e-7,
+ },
+};
+
+const ratesHttpClientLayer = Layer.succeed(
+ HttpClient.HttpClient,
+ HttpClient.make((request) =>
+ Effect.succeed(
+ HttpClientResponse.fromWeb(
+ request,
+ new Response(JSON.stringify(RATE_DOCUMENT), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ ),
+ ),
+ ),
+);
+
+function claudeAssistantLine(input: {
+ readonly messageId: string;
+ readonly requestId: string;
+ readonly timestamp: string;
+ readonly sessionId: string;
+ readonly outputTokens: number;
+}): string {
+ return JSON.stringify({
+ type: "assistant",
+ timestamp: input.timestamp,
+ sessionId: input.sessionId,
+ requestId: input.requestId,
+ message: {
+ id: input.messageId,
+ role: "assistant",
+ model: CLAUDE_MODEL,
+ usage: {
+ input_tokens: 10,
+ cache_creation_input_tokens: 100,
+ cache_read_input_tokens: 1000,
+ output_tokens: input.outputTokens,
+ },
+ },
+ });
+}
+
+function codexRollout(input: {
+ readonly sessionId: string;
+ readonly forkedFromId?: string;
+ /** `[timestamp, inputTokens, outputTokens]` per usage event. */
+ readonly events: readonly (readonly [string, number, number])[];
+ readonly openedAt: string;
+}): string {
+ const lines = [
+ JSON.stringify({
+ type: "session_meta",
+ timestamp: input.openedAt,
+ payload: {
+ id: input.sessionId,
+ forked_from_id: input.forkedFromId ?? null,
+ source: "vscode",
+ },
+ }),
+ JSON.stringify({
+ type: "turn_context",
+ timestamp: input.openedAt,
+ payload: { model: CODEX_MODEL },
+ }),
+ ];
+ for (const [timestamp, inputTokens, outputTokens] of input.events) {
+ lines.push(
+ JSON.stringify({
+ type: "event_msg",
+ timestamp,
+ payload: {
+ type: "token_count",
+ info: {
+ last_token_usage: {
+ input_tokens: inputTokens,
+ cached_input_tokens: 0,
+ cache_write_input_tokens: 0,
+ output_tokens: outputTokens,
+ reasoning_output_tokens: 0,
+ },
+ },
+ },
+ }),
+ );
+ }
+ return `${lines.join("\n")}\n`;
+}
+
+/**
+ * Lays out a throwaway provider home pair and the server state dir the scan
+ * cache lives in. Real files on disk: the whole point of the service is what it
+ * finds by walking them.
+ */
+const makeFixture = Effect.fn("makeFixture")(function* () {
+ const root = yield* Effect.promise(() =>
+ NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "threadlines-usage-")),
+ );
+ const claudeHome = NodePath.join(root, "claude-home");
+ const claudeProjects = NodePath.join(claudeHome, ".claude", "projects", "-tmp-project");
+ const codexHome = NodePath.join(root, "codex-home");
+ const codexSessions = NodePath.join(codexHome, "sessions", "2026", "08", "07");
+
+ yield* Effect.promise(async () => {
+ await NodeFSP.mkdir(claudeProjects, { recursive: true });
+ await NodeFSP.mkdir(codexSessions, { recursive: true });
+
+ // Two content blocks of one message repeat the same usage, and the same
+ // message is replayed into a second transcript: one record must survive.
+ const repeated = [
+ claudeAssistantLine({
+ messageId: "msg_1",
+ requestId: "req_1",
+ timestamp: "2026-08-07T12:00:00.000Z",
+ sessionId: "claude-session",
+ outputTokens: 40,
+ }),
+ claudeAssistantLine({
+ messageId: "msg_1",
+ requestId: "req_1",
+ timestamp: "2026-08-07T12:00:00.000Z",
+ sessionId: "claude-session",
+ outputTokens: 40,
+ }),
+ ];
+ await NodeFSP.writeFile(
+ NodePath.join(claudeProjects, "session-a.jsonl"),
+ `${repeated.join("\n")}\n`,
+ );
+ await NodeFSP.writeFile(NodePath.join(claudeProjects, "session-b.jsonl"), `${repeated[0]!}\n`);
+
+ await NodeFSP.writeFile(
+ NodePath.join(codexSessions, "rollout-parent.jsonl"),
+ codexRollout({
+ sessionId: "codex-parent",
+ openedAt: "2026-08-07T12:00:00.000Z",
+ events: [["2026-08-07T12:00:10.000Z", 500, 20]],
+ }),
+ );
+ // The fork replays the parent's turn re-stamped to the fork instant, then
+ // runs one turn of its own. Only the second may be counted.
+ await NodeFSP.writeFile(
+ NodePath.join(codexSessions, "rollout-fork.jsonl"),
+ codexRollout({
+ sessionId: "codex-fork",
+ forkedFromId: "codex-parent",
+ openedAt: "2026-08-07T13:00:00.000Z",
+ events: [
+ ["2026-08-07T13:00:00.001Z", 500, 20],
+ ["2026-08-07T13:00:30.000Z", 700, 35],
+ ],
+ }),
+ );
+ });
+
+ const derivedPaths = yield* deriveServerPaths(NodePath.join(root, "state"), undefined).pipe(
+ Effect.provide(NodeServices.layer),
+ );
+ const config: ServerConfigShape = {
+ appVersion: "0.0.0-test",
+ logLevel: "Info",
+ traceMinLevel: "Info",
+ traceTimingEnabled: false,
+ traceBatchWindowMs: 200,
+ traceMaxBytes: 1024,
+ traceMaxFiles: 1,
+ otlpTracesUrl: undefined,
+ otlpMetricsUrl: undefined,
+ otlpExportIntervalMs: 10_000,
+ otlpServiceName: "threadlines-server",
+ mode: "web",
+ port: 0,
+ host: "127.0.0.1",
+ cwd: root,
+ baseDir: NodePath.join(root, "state"),
+ ...derivedPaths,
+ staticDir: undefined,
+ devUrl: undefined,
+ noBrowser: true,
+ startupPresentation: "browser",
+ desktopBootstrapToken: undefined,
+ autoBootstrapProjectFromCwd: false,
+ logWebSocketEvents: false,
+ tailscaleServeEnabled: false,
+ tailscaleServePort: 443,
+ };
+ yield* Effect.promise(() => NodeFSP.mkdir(config.stateDir, { recursive: true }));
+
+ const settings = {
+ ...DEFAULT_SERVER_SETTINGS,
+ providers: {
+ ...DEFAULT_SERVER_SETTINGS.providers,
+ claudeAgent: { ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, homePath: claudeHome },
+ codex: { ...DEFAULT_SERVER_SETTINGS.providers.codex, homePath: codexHome },
+ },
+ };
+
+ const layer = UsageServiceLive.pipe(
+ Layer.provide(
+ Layer.mock(ServerSettingsService)({
+ start: Effect.void,
+ ready: Effect.void,
+ getSettings: Effect.succeed(settings),
+ updateSettings: () => Effect.succeed(settings),
+ streamChanges: Stream.empty,
+ }),
+ ),
+ Layer.provide(Layer.succeed(ServerConfig, config)),
+ Layer.provide(ratesHttpClientLayer),
+ Layer.provide(NodeServices.layer),
+ );
+
+ return { root, claudeProjects, config, layer } as const;
+});
+
+const window = {
+ sinceDay: UsageDay.make("2026-08-01"),
+ untilDay: UsageDay.make("2026-08-31"),
+ timeZone: "UTC",
+};
+
+function bucketFor(summary: UsageSummary, provider: "claude" | "codex") {
+ return summary.buckets.find((bucket) => bucket.provider === provider);
+}
+
+describe("UsageService.readSummary", () => {
+ // it.live throughout: the service walks real directories and reads the real
+ // clock, both of which stall under it.effect's TestClock.
+ it.live("prices a window, dropping repeated Claude usage and copied fork turns", () =>
+ Effect.gen(function* () {
+ const fixture = yield* makeFixture();
+ const summary = yield* Effect.provide(
+ Effect.flatMap(UsageService, (usage) => usage.readSummary(window)),
+ fixture.layer,
+ );
+
+ const claude = bucketFor(summary, "claude");
+ // One message, repeated twice in one file and once more in a second.
+ expect(claude?.records).toBe(1);
+ expect(claude?.totals.outputTokens).toBe(40);
+ // 10*1e-5 + 1000*1e-6 + 100*1.25e-5 + 40*5e-5
+ expect(claude?.costUsd).toBeCloseTo(0.00435, 9);
+ expect(claude?.costSource).toBe("modelPriced");
+
+ const codex = bucketFor(summary, "codex");
+ // Parent's single turn plus the fork's own turn; the fork's replayed copy
+ // of the parent turn must not be counted a second time.
+ expect(codex?.records).toBe(2);
+ expect(codex?.totals.outputTokens).toBe(55);
+ expect(codex?.sessions).toBe(2);
+
+ expect(summary.pricing.status).toBe("fresh");
+ expect(summary.sources.map((source) => source.status)).toEqual(["ok", "ok"]);
+ expect(summary.sources.every((source) => source.lastScannedAt.length > 0)).toBe(true);
+ yield* Effect.promise(() => NodeFSP.rm(fixture.root, { recursive: true, force: true }));
+ }),
+ );
+
+ it.live("picks up a transcript that changed and leaves the rest memoised", () =>
+ Effect.gen(function* () {
+ const fixture = yield* makeFixture();
+ const read = Effect.provide(
+ Effect.flatMap(UsageService, (usage) => usage.readSummary(window)),
+ fixture.layer,
+ );
+
+ const before = yield* read;
+ expect(bucketFor(before, "claude")?.totals.outputTokens).toBe(40);
+
+ yield* Effect.promise(() =>
+ NodeFSP.appendFile(
+ NodePath.join(fixture.claudeProjects, "session-a.jsonl"),
+ `${claudeAssistantLine({
+ messageId: "msg_2",
+ requestId: "req_2",
+ timestamp: "2026-08-08T12:00:00.000Z",
+ sessionId: "claude-session",
+ outputTokens: 7,
+ })}\n`,
+ ),
+ );
+
+ const after = yield* read;
+ const claudeBuckets = after.buckets.filter((bucket) => bucket.provider === "claude");
+ expect(claudeBuckets.map((bucket) => bucket.day)).toEqual(["2026-08-07", "2026-08-08"]);
+ expect(claudeBuckets[1]?.totals.outputTokens).toBe(7);
+
+ yield* Effect.promise(() => NodeFSP.rm(fixture.root, { recursive: true, force: true }));
+ }),
+ );
+
+ it.live("rejects a window longer than the cache retains", () =>
+ Effect.gen(function* () {
+ const fixture = yield* makeFixture();
+ const failure = yield* Effect.provide(
+ Effect.flatMap(UsageService, (usage) =>
+ usage.readSummary({
+ sinceDay: UsageDay.make("2025-08-01"),
+ untilDay: UsageDay.make("2026-08-31"),
+ timeZone: "UTC",
+ }),
+ ),
+ fixture.layer,
+ ).pipe(Effect.flip);
+
+ expect(failure.reason).toBe("invalidWindow");
+ yield* Effect.promise(() => NodeFSP.rm(fixture.root, { recursive: true, force: true }));
+ }),
+ );
+});
diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts
new file mode 100644
index 000000000..929325267
--- /dev/null
+++ b/apps/server/src/usage/UsageService.ts
@@ -0,0 +1,461 @@
+/**
+ * UsageService - scans provider transcripts and returns priced daily usage.
+ *
+ * The scan reads the provider CLIs' own session files rather than Threadlines'
+ * orchestration projections, so usage covers turns driven from the CLI, an
+ * editor extension, or any other tool sharing the same provider home. This is
+ * the approach `ccusage` takes.
+ *
+ * Transcripts are append-only, so parsed records are memoised per file by
+ * `(size, mtime)`; warm scans only reparse files that changed.
+ *
+ * Cost figures are API list-price equivalents, never billed spend. Subscription
+ * plans bill on their own terms and the transcripts carry no invoice.
+ *
+ * @module UsageService
+ */
+// @effect-diagnostics nodeBuiltinImport:off
+import * as NodeOS from "node:os";
+
+import {
+ USAGE_CONTRACT_VERSION,
+ USAGE_MAX_WINDOW_DAYS,
+ type UsageProviderKind,
+ type UsageSource,
+ type UsageSummary,
+ type UsageSummaryInput,
+ UsageReadError,
+} from "@threadlines/contracts";
+import * as Cause from "effect/Cause";
+import * as Clock from "effect/Clock";
+import * as Context from "effect/Context";
+import * as DateTime from "effect/DateTime";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import { HttpClient, HttpClientResponse } from "effect/unstable/http";
+
+import { writeFileStringAtomically } from "../atomicWrite.ts";
+import { ServerConfig } from "../config.ts";
+import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts";
+import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";
+import { ServerSettingsService } from "../serverSettings.ts";
+import { UsageAggregator } from "./usageAggregation.ts";
+import { parseRateTable, type RateTable } from "./usagePricing.ts";
+import {
+ decodeScanCache,
+ dedupeWithinFile,
+ encodeScanCache,
+ pruneScanCache,
+ type ScanCache,
+} from "./usageScanCache.ts";
+import {
+ listTranscriptFiles,
+ readDirectoryVolumeId,
+ readTranscriptRecords,
+} from "./usageTranscriptReader.ts";
+import type { UsageRecord } from "./usageTranscripts.ts";
+
+const LITELLM_RATES_URL =
+ "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
+
+/** Rates move rarely; a day-old table keeps the page working offline. */
+const RATES_TTL_MS = 24 * 60 * 60 * 1000;
+
+const RATES_FETCH_TIMEOUT_MS = 10_000;
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+/**
+ * Files are filtered by mtime before opening. The slack covers a session whose
+ * last write lands just before local midnight on the window's first day.
+ */
+const MTIME_SLACK_MS = 36 * 60 * 60 * 1000;
+
+/** Longest window the UI offers, plus slack. Older entries are pruned. */
+const CACHE_RETENTION_DAYS = USAGE_MAX_WINDOW_DAYS;
+
+interface RatesCacheFile {
+ readonly fetchedAtMs: number;
+ readonly document: unknown;
+}
+
+/**
+ * Both on-disk caches hold documents that are narrowed by hand downstream
+ * (`decodeScanCache`, `parseRateTable`), so a schema here would only restate
+ * `unknown`. A parse failure means one cold scan, never a failed read.
+ */
+const parseJsonFile = (raw: string): Effect.Effect =>
+ Effect.try(() => JSON.parse(raw) as unknown);
+
+function readRatesCacheFile(document: unknown): RatesCacheFile | null {
+ if (typeof document !== "object" || document === null) return null;
+ const record = document as Partial;
+ if (typeof record.fetchedAtMs !== "number" || !Number.isFinite(record.fetchedAtMs)) return null;
+ return { fetchedAtMs: record.fetchedAtMs, document: record.document };
+}
+
+export interface UsageServiceShape {
+ readonly readSummary: (input: UsageSummaryInput) => Effect.Effect;
+}
+
+export class UsageService extends Context.Service()(
+ "threadlines/usage/UsageService",
+) {
+ /** Empty summary, for suites that only need the RPC surface to resolve. */
+ static readonly layerTest = Layer.succeed(
+ UsageService,
+ UsageService.of({
+ readSummary: (input) =>
+ Effect.succeed({
+ contractVersion: USAGE_CONTRACT_VERSION,
+ readAt: "1970-01-01T00:00:00.000Z",
+ timeZone: input.timeZone,
+ sinceDay: input.sinceDay,
+ untilDay: input.untilDay,
+ buckets: [],
+ sources: [],
+ pricing: {
+ status: "unavailable",
+ source: LITELLM_RATES_URL,
+ fetchedAt: null,
+ knownModels: 0,
+ },
+ scanDurationMs: 0,
+ }),
+ }),
+ );
+}
+
+export const make = Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const config = yield* ServerConfig;
+ const settingsService = yield* ServerSettingsService;
+ const httpClient = yield* HttpClient.HttpClient;
+
+ const fileCache: ScanCache = new Map();
+ let cacheDirty = false;
+
+ /**
+ * Both caches are rebuildable, so a failed write costs one cold scan and must
+ * never surface. Written atomically so a crash mid-write cannot leave a
+ * truncated document that the next start would reject wholesale.
+ */
+ const writeCacheFile = (filePath: string, contents: string): Effect.Effect =>
+ writeFileStringAtomically({ filePath, contents }).pipe(
+ Effect.provideService(FileSystem.FileSystem, fileSystem),
+ Effect.provideService(Path.Path, path),
+ Effect.as(true),
+ Effect.catchCause(() => Effect.succeed(false)),
+ );
+
+ const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json");
+ const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json");
+ let rates: RateTable = new Map();
+ let ratesFetchedAtMs: number | null = null;
+ let ratesStatus: UsageSummary["pricing"]["status"] = "unavailable";
+
+ /**
+ * Loads the LiteLLM rate table, preferring a fresh copy and falling back to
+ * the on-disk snapshot. With neither, every model reports as unpriced rather
+ * than the page failing.
+ */
+ const ensureRates = Effect.fn("UsageService.ensureRates")(function* () {
+ const now = yield* Clock.currentTimeMillis;
+ if (ratesFetchedAtMs !== null && now - ratesFetchedAtMs < RATES_TTL_MS) return;
+
+ if (ratesFetchedAtMs === null) {
+ const fromDisk = yield* fileSystem.readFileString(ratesCachePath).pipe(
+ Effect.flatMap(parseJsonFile),
+ Effect.map(readRatesCacheFile),
+ Effect.catchCause(() => Effect.succeed(null)),
+ );
+ if (fromDisk !== null) {
+ const parsed = parseRateTable(fromDisk.document);
+ if (parsed.size > 0) {
+ rates = parsed;
+ ratesFetchedAtMs = fromDisk.fetchedAtMs;
+ ratesStatus = "cached";
+ if (now - fromDisk.fetchedAtMs < RATES_TTL_MS) return;
+ }
+ }
+ }
+
+ const fetched = yield* httpClient.get(LITELLM_RATES_URL).pipe(
+ Effect.flatMap(HttpClientResponse.filterStatusOk),
+ Effect.flatMap((response) => response.json),
+ Effect.timeout(RATES_FETCH_TIMEOUT_MS),
+ Effect.catchCause(() => Effect.succeed(null)),
+ );
+ if (fetched === null) {
+ // The refresh failed; whatever we are serving is now past its TTL and
+ // must not keep claiming to be fresh.
+ if (rates.size > 0) ratesStatus = "cached";
+ return;
+ }
+
+ const parsed = parseRateTable(fetched);
+ if (parsed.size === 0) return;
+
+ rates = parsed;
+ ratesFetchedAtMs = now;
+ ratesStatus = "fresh";
+
+ yield* writeCacheFile(
+ ratesCachePath,
+ JSON.stringify({ fetchedAtMs: now, document: fetched } satisfies RatesCacheFile),
+ );
+ });
+
+ /**
+ * `resolveClaudeHomePath` returns the process home the CLI runs under, which
+ * is the home itself when overridden. A default install nests transcripts
+ * under `~/.claude/projects`, so probe that before falling back.
+ */
+ const resolveClaudeTranscriptDir = (homePath: string) =>
+ Effect.gen(function* () {
+ const nested = path.join(homePath, ".claude", "projects");
+ const nestedExists = yield* fileSystem
+ .exists(nested)
+ .pipe(Effect.catchCause(() => Effect.succeed(false)));
+ return nestedExists ? nested : path.join(homePath, "projects");
+ });
+
+ /** Resolves the transcript directory for each provider. */
+ const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* () {
+ // A settings failure must surface as an error: swallowing it here would
+ // present "zero usage from every provider" as a valid answer.
+ const settings = yield* settingsService.getSettings.pipe(
+ Effect.catchCause(
+ (cause) =>
+ new UsageReadError({
+ reason: "scanFailed",
+ // Bounded description; the squashed failure travels as the cause.
+ // Squashed, not the Cause tree: a full tree in a Defect field is
+ // the unbounded wire payload the bounded detail exists to avoid.
+ detail: "Server settings could not be read.",
+ cause: Cause.squash(cause),
+ }),
+ ),
+ );
+
+ const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent);
+ const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome);
+ // The shared home, not the shadow overlay: the overlay only isolates
+ // `auth.json`, and `sessions` is a symlink back to the shared directory.
+ const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex);
+
+ return [
+ { provider: "claude" as const, dir: claudeDir },
+ { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") },
+ ];
+ });
+
+ /**
+ * Loads the persisted scan cache exactly once per process.
+ *
+ * `Effect.cached` makes concurrent first readers await the same load rather
+ * than each seeing a "loaded" flag set before the read finished and cold
+ * scanning against an empty cache.
+ */
+ const ensureScanCacheLoaded = yield* Effect.cached(
+ Effect.gen(function* () {
+ const document = yield* fileSystem.readFileString(scanCachePath).pipe(
+ Effect.flatMap(parseJsonFile),
+ Effect.catchCause(() => Effect.succeed(null)),
+ );
+ if (document === null) return;
+ for (const [cachedPath, entry] of decodeScanCache(document)) fileCache.set(cachedPath, entry);
+ }),
+ );
+
+ const persistScanCache = Effect.fn("UsageService.persistScanCache")(function* () {
+ if (!cacheDirty) return;
+ // Cleared only after the write lands, so a failed persist is retried on the
+ // next scan instead of leaving disk permanently stale.
+ const written = yield* writeCacheFile(
+ scanCachePath,
+ JSON.stringify(encodeScanCache(fileCache)),
+ );
+ if (written) cacheDirty = false;
+ });
+
+ /** Parses one transcript, reusing the cached result when it is unchanged. */
+ const readFileRecords = (
+ filePath: string,
+ size: number,
+ mtimeMs: number,
+ provider: UsageProviderKind,
+ ): Effect.Effect =>
+ Effect.gen(function* () {
+ const cached = fileCache.get(filePath);
+ // Provider is part of the identity: if both providers were ever pointed
+ // at one directory, a hit parsed by the other parser must not be reused.
+ if (
+ cached &&
+ cached.size === size &&
+ cached.mtimeMs === mtimeMs &&
+ cached.provider === provider
+ ) {
+ return cached.records;
+ }
+
+ const parsed = yield* Effect.promise(() => readTranscriptRecords(filePath, provider));
+ // A read failure is not an empty transcript: caching it under this
+ // (size, mtime) would silently drop the file's usage until it changes.
+ if (parsed === null) return [];
+ // Stored already de-duplicated within the file, which is the bulk of all
+ // duplicates. The aggregator still runs the cross-file dedupe pass.
+ const records = dedupeWithinFile(parsed);
+
+ fileCache.set(filePath, { size, mtimeMs, provider, records });
+ cacheDirty = true;
+ return records;
+ });
+
+ const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) {
+ if (input.sinceDay > input.untilDay) {
+ return yield* new UsageReadError({
+ reason: "invalidWindow",
+ detail: `sinceDay '${input.sinceDay}' is after untilDay '${input.untilDay}'`,
+ });
+ }
+
+ const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`);
+ const windowEnd = DateTime.make(`${input.untilDay}T00:00:00Z`);
+ if (Option.isNone(windowStart) || Option.isNone(windowEnd)) {
+ return yield* new UsageReadError({
+ reason: "invalidWindow",
+ detail: `'${input.sinceDay}'..'${input.untilDay}' is not a valid date range`,
+ });
+ }
+
+ const sinceMs = DateTime.toEpochMillis(windowStart.value);
+ const spanDays = Math.round((DateTime.toEpochMillis(windowEnd.value) - sinceMs) / DAY_MS) + 1;
+ // The scan cache only retains this far back, so a longer window would cold
+ // scan every call. Reject it rather than silently taking minutes.
+ if (spanDays > USAGE_MAX_WINDOW_DAYS) {
+ return yield* new UsageReadError({
+ reason: "invalidWindow",
+ detail: `window of ${spanDays} days exceeds the ${USAGE_MAX_WINDOW_DAYS}-day maximum`,
+ });
+ }
+
+ const startedAtMs = yield* Clock.currentTimeMillis;
+ yield* ensureRates();
+ yield* ensureScanCacheLoaded;
+
+ const hostId = NodeOS.hostname();
+ // The home resolvers ask for `Path` themselves; satisfy them from the
+ // instance we already hold so `readSummary` stays context-free.
+ const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path));
+ const windowStartMs = sinceMs - MTIME_SLACK_MS;
+
+ const aggregator = new UsageAggregator({
+ timeZone: input.timeZone,
+ sinceDay: input.sinceDay,
+ untilDay: input.untilDay,
+ rates,
+ });
+
+ const sources: UsageSource[] = [];
+ const livePaths = new Set();
+ const walkedRoots: string[] = [];
+
+ for (const { provider, dir } of dirs) {
+ const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir));
+ const exists = yield* fileSystem
+ .exists(dir)
+ .pipe(Effect.catchCause(() => Effect.succeed(false)));
+ const scannedAt = yield* DateTime.now;
+
+ if (!exists) {
+ sources.push({
+ fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId },
+ status: "missing",
+ lastScannedAt: DateTime.formatIso(scannedAt),
+ scannedFiles: 0,
+ skippedFiles: 0,
+ distinctSessions: 0,
+ message: "No transcript directory on this environment.",
+ });
+ continue;
+ }
+
+ walkedRoots.push(dir);
+ const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs));
+ let scannedFiles = 0;
+ let skippedFiles = 0;
+ // Distinct per directory. Buckets carry per-cell session counts, but a
+ // session spans days and models, so clients total this figure instead.
+ const sessionIds = new Set();
+
+ for (const file of files) {
+ livePaths.add(file.path);
+ const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider);
+ if (records.length === 0) {
+ skippedFiles += 1;
+ continue;
+ }
+ scannedFiles += 1;
+ for (const record of records) {
+ // Only sessions that contributed in-window count: the mtime slack
+ // admits boundary files whose records fall outside the range.
+ if (aggregator.add(record) && record.sessionId.length > 0) {
+ sessionIds.add(record.sessionId);
+ }
+ }
+ }
+
+ sources.push({
+ fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId },
+ status: "ok",
+ lastScannedAt: DateTime.formatIso(scannedAt),
+ scannedFiles,
+ skippedFiles,
+ distinctSessions: sessionIds.size,
+ message: null,
+ });
+ }
+
+ const pruned = pruneScanCache(fileCache, {
+ livePaths,
+ walkedRoots,
+ windowStartMs,
+ retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * DAY_MS,
+ });
+ if (pruned > 0) cacheDirty = true;
+ yield* persistScanCache();
+
+ const aggregated = aggregator.finish();
+ const readAt = yield* DateTime.now;
+ const finishedAtMs = yield* Clock.currentTimeMillis;
+
+ return {
+ contractVersion: USAGE_CONTRACT_VERSION,
+ readAt: DateTime.formatIso(readAt),
+ timeZone: input.timeZone,
+ sinceDay: input.sinceDay,
+ untilDay: input.untilDay,
+ buckets: aggregated.buckets,
+ sources,
+ pricing: {
+ status: ratesStatus,
+ source: LITELLM_RATES_URL,
+ fetchedAt:
+ ratesFetchedAtMs === null
+ ? null
+ : DateTime.formatIso(DateTime.makeUnsafe(ratesFetchedAtMs)),
+ knownModels: rates.size,
+ },
+ scanDurationMs: Math.max(0, finishedAtMs - startedAtMs),
+ } satisfies UsageSummary;
+ });
+
+ return { readSummary } as const;
+});
+
+export const UsageServiceLive = Layer.effect(UsageService, make);
diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts
new file mode 100644
index 000000000..b9a4a70b9
--- /dev/null
+++ b/apps/server/src/usage/usageAggregation.test.ts
@@ -0,0 +1,131 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { UsageAggregator } from "./usageAggregation.ts";
+import type { RateTable } from "./usagePricing.ts";
+import type { UsageRecord } from "./usageTranscripts.ts";
+
+const rates: RateTable = new Map([
+ [
+ "claude-fable-5",
+ {
+ inputCostPerToken: 1e-5,
+ outputCostPerToken: 5e-5,
+ cacheReadCostPerToken: 1e-6,
+ cacheCreationCostPerToken: 1.25e-5,
+ },
+ ],
+]);
+
+function record(overrides: Partial = {}): UsageRecord {
+ return {
+ provider: "claude",
+ // 2026-08-07T04:05Z is still Aug 6 in Los Angeles.
+ timestampMs: Date.parse("2026-08-07T04:05:13.944Z"),
+ model: "claude-fable-5",
+ sessionId: "session-a",
+ totals: {
+ uncachedInputTokens: 100,
+ cachedInputTokens: 1000,
+ cacheCreationTokens: 10,
+ outputTokens: 50,
+ reasoningTokens: 0,
+ },
+ reportedCostUsd: null,
+ dedupeKey: null,
+ ...overrides,
+ };
+}
+
+function aggregate(records: readonly UsageRecord[], timeZone = "UTC") {
+ const aggregator = new UsageAggregator({
+ timeZone,
+ sinceDay: "2026-08-01",
+ untilDay: "2026-08-31",
+ rates,
+ });
+ for (const item of records) aggregator.add(item);
+ return aggregator.finish();
+}
+
+describe("UsageAggregator", () => {
+ it("keeps only the first record for a repeated dedupe key", () => {
+ const result = aggregate([
+ record({ dedupeKey: "msg_1:req_1" }),
+ record({ dedupeKey: "msg_1:req_1" }),
+ record({ dedupeKey: "msg_1:req_1" }),
+ ]);
+
+ expect(result.duplicatesDropped).toBe(2);
+ expect(result.buckets[0]?.records).toBe(1);
+ expect(result.buckets[0]?.totals.outputTokens).toBe(50);
+ });
+
+ it("still sums records that carry no dedupe key", () => {
+ const result = aggregate([record(), record()]);
+
+ expect(result.duplicatesDropped).toBe(0);
+ expect(result.buckets[0]?.totals.outputTokens).toBe(100);
+ });
+
+ it("buckets by the day in the requested time zone", () => {
+ const utc = aggregate([record()], "UTC");
+ const losAngeles = aggregate([record()], "America/Los_Angeles");
+
+ expect(utc.buckets[0]?.day).toBe("2026-08-07");
+ expect(losAngeles.buckets[0]?.day).toBe("2026-08-06");
+ });
+
+ it("prices against the rate table", () => {
+ const result = aggregate([record()]);
+
+ // 100*1e-5 + 1000*1e-6 + 10*1.25e-5 + 50*5e-5
+ expect(result.buckets[0]?.costUsd).toBeCloseTo(0.004625, 9);
+ expect(result.buckets[0]?.costSource).toBe("modelPriced");
+ // Cached input at full input rates minus what it actually cost.
+ expect(result.buckets[0]?.cacheSavingsUsd).toBeCloseTo(0.009, 9);
+ });
+
+ it("counts tokens but not cost for a model with no rate", () => {
+ const result = aggregate([record({ model: "kimi-k3" })]);
+
+ expect(result.buckets[0]?.costUsd).toBe(0);
+ expect(result.buckets[0]?.costSource).toBe("unpriced");
+ expect(result.buckets[0]?.unpricedRecords).toBe(1);
+ expect(result.buckets[0]?.totals.outputTokens).toBe(50);
+ });
+
+ it("prefers a reported cost over the rate table", () => {
+ const result = aggregate([record({ reportedCostUsd: 1.25 })]);
+
+ expect(result.buckets[0]?.costUsd).toBe(1.25);
+ expect(result.buckets[0]?.costSource).toBe("providerReported");
+ });
+
+ it("drops records outside the window and reports whether one contributed", () => {
+ const aggregator = new UsageAggregator({
+ timeZone: "UTC",
+ sinceDay: "2026-08-01",
+ untilDay: "2026-08-31",
+ rates,
+ });
+
+ expect(aggregator.add(record({ dedupeKey: "msg_1:req_1" }))).toBe(true);
+ expect(aggregator.add(record({ dedupeKey: "msg_1:req_1" }))).toBe(false);
+ // The mtime prefilter admits boundary files whose records fall outside.
+ expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-31T23:59:59Z") }))).toBe(false);
+ expect(aggregator.add(record({ timestampMs: Date.parse("2026-09-01T00:00:00Z") }))).toBe(false);
+ expect(aggregator.add(record({ timestampMs: Date.parse("2026-08-01T00:00:00Z") }))).toBe(true);
+
+ expect(aggregator.finish().outOfWindow).toBe(2);
+ });
+
+ it("separates providers and models into their own buckets", () => {
+ const result = aggregate([
+ record(),
+ record({ provider: "codex", model: "gpt-5.6-sol" }),
+ record({ model: "claude-opus-5" }),
+ ]);
+
+ expect(result.buckets).toHaveLength(3);
+ });
+});
diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts
new file mode 100644
index 000000000..02a7a75f5
--- /dev/null
+++ b/apps/server/src/usage/usageAggregation.ts
@@ -0,0 +1,185 @@
+// @effect-diagnostics globalDate:off
+/**
+ * Folds parsed transcript records into `(day, provider, model)` buckets.
+ *
+ * `Intl.DateTimeFormat` is the only reliable way to resolve a wall-clock day in
+ * an arbitrary IANA zone, and it takes a `Date`. That is why the raw `Date`
+ * construction is allowed here; nothing in this module reads the clock.
+ *
+ * Pure, so the bucketing and de-duplication rules are testable without touching
+ * the filesystem or the network.
+ *
+ * @module usageAggregation
+ */
+import type { UsageBucket, UsageDay, UsageTokenTotals } from "@threadlines/contracts";
+
+import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts";
+import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts";
+
+/**
+ * Formats an instant as a `YYYY-MM-DD` day in `timeZone`.
+ *
+ * `en-CA` yields ISO-ordered parts, which is why it is used here rather than
+ * assembling the day from `Date` getters (those are host-local only).
+ */
+export function makeDayFormatter(timeZone: string): (timestampMs: number) => string {
+ let format: Intl.DateTimeFormat;
+ try {
+ format = new Intl.DateTimeFormat("en-CA", {
+ timeZone,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ });
+ } catch {
+ // An unknown zone should degrade to UTC rather than fail the whole scan.
+ format = new Intl.DateTimeFormat("en-CA", {
+ timeZone: "UTC",
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ });
+ }
+ return (timestampMs) => format.format(new Date(timestampMs));
+}
+
+interface MutableBucket {
+ totals: UsageTokenTotals;
+ costUsd: number;
+ cacheSavingsUsd: number;
+ records: number;
+ unpricedRecords: number;
+ providerReportedRecords: number;
+ sessions: Set;
+}
+
+export interface AggregateOptions {
+ readonly timeZone: string;
+ readonly sinceDay: string;
+ readonly untilDay: string;
+ readonly rates: RateTable;
+}
+
+export interface AggregateResult {
+ readonly buckets: readonly UsageBucket[];
+ /** Records dropped because an earlier record carried the same dedupe key. */
+ readonly duplicatesDropped: number;
+ /** Records whose day fell outside the requested window. */
+ readonly outOfWindow: number;
+}
+
+/**
+ * Accumulates records across many files.
+ *
+ * De-duplication is global across the whole scan, not per file: Claude Code
+ * copies a message's records forward when a session is resumed or relocated, so
+ * the same `dedupeKey` legitimately appears in several transcripts.
+ */
+export class UsageAggregator {
+ readonly #buckets = new Map();
+ readonly #seen = new Set();
+ readonly #toDay: (timestampMs: number) => string;
+ readonly #options: AggregateOptions;
+ #duplicatesDropped = 0;
+ #outOfWindow = 0;
+
+ constructor(options: AggregateOptions) {
+ this.#options = options;
+ this.#toDay = makeDayFormatter(options.timeZone);
+ }
+
+ /**
+ * Folds one record in. Returns whether it actually contributed, so callers
+ * can derive per-window facts (distinct sessions, for one) from the records
+ * that landed rather than everything the mtime prefilter happened to admit.
+ */
+ add(record: UsageRecord): boolean {
+ if (record.dedupeKey !== null) {
+ if (this.#seen.has(record.dedupeKey)) {
+ this.#duplicatesDropped += 1;
+ return false;
+ }
+ this.#seen.add(record.dedupeKey);
+ }
+
+ const day = this.#toDay(record.timestampMs);
+ if (day < this.#options.sinceDay || day > this.#options.untilDay) {
+ this.#outOfWindow += 1;
+ return false;
+ }
+
+ const key = `${day} ${record.provider} ${record.model}`;
+ let bucket = this.#buckets.get(key);
+ if (bucket === undefined) {
+ bucket = {
+ totals: EMPTY_TOTALS,
+ costUsd: 0,
+ cacheSavingsUsd: 0,
+ records: 0,
+ unpricedRecords: 0,
+ providerReportedRecords: 0,
+ sessions: new Set(),
+ };
+ this.#buckets.set(key, bucket);
+ }
+
+ const priced = priceUsage(
+ this.#options.rates,
+ record.model,
+ record.totals,
+ record.reportedCostUsd,
+ );
+
+ bucket.totals = addTotals(bucket.totals, record.totals);
+ bucket.costUsd += priced.costUsd;
+ bucket.cacheSavingsUsd += cacheSavingsUsd(this.#options.rates, record.model, record.totals);
+ bucket.records += 1;
+ if (priced.costSource === "unpriced") bucket.unpricedRecords += 1;
+ if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1;
+ if (record.sessionId.length > 0) bucket.sessions.add(record.sessionId);
+ return true;
+ }
+
+ finish(): AggregateResult {
+ const buckets: UsageBucket[] = [];
+ for (const [key, bucket] of this.#buckets) {
+ const [day = "", provider = "", model = ""] = key.split(" ");
+ buckets.push({
+ day: day as UsageDay,
+ provider: provider as UsageBucket["provider"],
+ model,
+ totals: bucket.totals,
+ costUsd: bucket.costUsd,
+ cacheSavingsUsd: bucket.cacheSavingsUsd,
+ costSource: resolveCostSource(bucket),
+ records: bucket.records,
+ unpricedRecords: bucket.unpricedRecords,
+ sessions: bucket.sessions.size,
+ });
+ }
+ // Stable ordering keeps payloads diffable and snapshot tests meaningful.
+ buckets.sort(
+ (a, b) =>
+ a.day.localeCompare(b.day) ||
+ a.provider.localeCompare(b.provider) ||
+ a.model.localeCompare(b.model),
+ );
+
+ return {
+ buckets,
+ duplicatesDropped: this.#duplicatesDropped,
+ outOfWindow: this.#outOfWindow,
+ };
+ }
+}
+
+/**
+ * A bucket mixes records from one model, but their cost provenance can differ
+ * when only some records carried a reported cost. The weakest provenance in the
+ * bucket wins so the UI never overstates confidence.
+ */
+function resolveCostSource(bucket: MutableBucket): UsageBucket["costSource"] {
+ if (bucket.unpricedRecords === bucket.records) return "unpriced";
+ if (bucket.providerReportedRecords === bucket.records) return "providerReported";
+ return "modelPriced";
+}
diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts
new file mode 100644
index 000000000..de891e0b7
--- /dev/null
+++ b/apps/server/src/usage/usagePricing.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { lookupRate, parseRateTable } from "./usagePricing.ts";
+
+describe("parseRateTable", () => {
+ it("prefers the un-prefixed entry over routing-prefixed duplicates", () => {
+ // LiteLLM publishes the same model many times over with a routing prefix,
+ // sometimes at different rates. They all normalise to one key, so the
+ // canonical entry has to win regardless of enumeration order.
+ const table = parseRateTable({
+ "azure/gpt-5.6-sol": { input_cost_per_token: 9e-6, output_cost_per_token: 9e-5 },
+ "gpt-5.6-sol": { input_cost_per_token: 5e-6, output_cost_per_token: 3e-5 },
+ "openrouter/gpt-5.6-sol": { input_cost_per_token: 7e-6, output_cost_per_token: 7e-5 },
+ });
+
+ expect(lookupRate(table, "gpt-5.6-sol")?.inputCostPerToken).toBe(5e-6);
+ });
+
+ it("drops entries missing either half of the base rate", () => {
+ // A half-priced model under-reports silently, which is worse than saying
+ // the model is unpriced.
+ const table = parseRateTable({
+ "half-priced": { input_cost_per_token: 1e-6 },
+ priced: { input_cost_per_token: 1e-6, output_cost_per_token: 2e-6 },
+ });
+
+ expect(lookupRate(table, "half-priced")).toBeNull();
+ // Cache rates fall back to the plain input rate rather than to free.
+ expect(lookupRate(table, "priced")?.cacheReadCostPerToken).toBe(1e-6);
+ });
+
+ it("never prices an ambiguous family name", () => {
+ // "opus" spans generations; guessing one would be a fabricated number.
+ const table = parseRateTable({
+ opus: { input_cost_per_token: 1e-6, output_cost_per_token: 2e-6 },
+ });
+
+ expect(lookupRate(table, "opus")).toBeNull();
+ });
+});
diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts
new file mode 100644
index 000000000..a8b041db7
--- /dev/null
+++ b/apps/server/src/usage/usagePricing.ts
@@ -0,0 +1,158 @@
+/**
+ * Model rate lookup and cost arithmetic.
+ *
+ * Rates come from LiteLLM's `model_prices_and_context_window.json`, the same
+ * table `ccusage` prices against. Everything here is pure: fetching and caching
+ * the table lives in `UsageService`.
+ *
+ * Every figure produced here is an API list-price equivalent, not billed spend.
+ *
+ * @module usagePricing
+ */
+import type { UsageCostSource, UsageTokenTotals } from "@threadlines/contracts";
+
+/**
+ * The subset of a LiteLLM entry we price against. All values are USD per token.
+ *
+ * LiteLLM also publishes tiered variants (`*_above_200k_tokens`, `*_flex`,
+ * `*_priority`, `*_batches`) and separate 1h cache-write rates. We deliberately
+ * price at the base tier: the transcripts don't record which tier served a
+ * request, so anything else would be a guess dressed up as precision.
+ */
+export interface ModelRate {
+ readonly inputCostPerToken: number;
+ readonly outputCostPerToken: number;
+ readonly cacheReadCostPerToken: number;
+ readonly cacheCreationCostPerToken: number;
+}
+
+export type RateTable = ReadonlyMap;
+
+/** Raw shape of one LiteLLM entry, narrowed to the fields we read. */
+interface LiteLlmEntry {
+ readonly input_cost_per_token?: unknown;
+ readonly output_cost_per_token?: unknown;
+ readonly cache_read_input_token_cost?: unknown;
+ readonly cache_creation_input_token_cost?: unknown;
+}
+
+function finiteNumber(value: unknown): number | null {
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
+}
+
+/**
+ * Projects the LiteLLM document into a rate table.
+ *
+ * Entries without both an input and an output rate are dropped: a half-priced
+ * model would silently under-report cost, which is worse than reporting the
+ * model as unpriced.
+ */
+export function parseRateTable(document: unknown): RateTable {
+ const table = new Map();
+ if (typeof document !== "object" || document === null) return table;
+
+ for (const [name, raw] of Object.entries(document as Record)) {
+ if (typeof raw !== "object" || raw === null) continue;
+ const entry = raw as LiteLlmEntry;
+ const input = finiteNumber(entry.input_cost_per_token);
+ const output = finiteNumber(entry.output_cost_per_token);
+ if (input === null || output === null) continue;
+
+ // LiteLLM publishes the same model many times over with a routing prefix
+ // (`azure/gpt-5.6-sol`, `openrouter/...`), sometimes at different rates.
+ // They all normalise to one key, so the un-prefixed entry wins outright
+ // rather than whichever happened to be enumerated last.
+ const normalized = normalizeModelName(name);
+ const prefixed = name.includes("/");
+ if (prefixed && table.has(normalized)) continue;
+
+ table.set(normalized, {
+ inputCostPerToken: input,
+ outputCostPerToken: output,
+ // Anthropic bills cache reads at a discount and cache writes at a
+ // premium. When a model omits them, cached input is priced as plain input
+ // rather than as free.
+ cacheReadCostPerToken: finiteNumber(entry.cache_read_input_token_cost) ?? input,
+ cacheCreationCostPerToken: finiteNumber(entry.cache_creation_input_token_cost) ?? input,
+ });
+ }
+ return table;
+}
+
+/**
+ * Canonicalises a model name for lookup.
+ *
+ * Strips a `provider/` prefix (LiteLLM publishes both `claude-opus-4-5` and
+ * `anthropic/claude-opus-4-5`) and lowercases, since transcripts are
+ * inconsistent about casing.
+ */
+export function normalizeModelName(model: string): string {
+ const trimmed = model.trim().toLowerCase();
+ const slash = trimmed.lastIndexOf("/");
+ return slash === -1 ? trimmed : trimmed.slice(slash + 1);
+}
+
+/**
+ * Models we never price, regardless of the table.
+ *
+ * `` marks locally generated messages that were never billed. Bare
+ * family names ("opus", "sonnet") are genuinely ambiguous across generations, so
+ * we report them as unpriced instead of guessing a generation.
+ */
+const UNPRICEABLE_MODELS = new Set([
+ "",
+ "synthetic",
+ "opus",
+ "sonnet",
+ "haiku",
+ "fable",
+]);
+
+export function lookupRate(table: RateTable, model: string): ModelRate | null {
+ const normalized = normalizeModelName(model);
+ if (normalized.length === 0 || UNPRICEABLE_MODELS.has(normalized)) return null;
+ return table.get(normalized) ?? null;
+}
+
+export interface PricedUsage {
+ readonly costUsd: number;
+ readonly costSource: UsageCostSource;
+}
+
+/**
+ * Prices one record's tokens.
+ *
+ * `reasoningTokens` is intentionally not charged separately: it is already
+ * counted inside `outputTokens`.
+ */
+export function priceUsage(
+ table: RateTable,
+ model: string,
+ totals: UsageTokenTotals,
+ reportedCostUsd: number | null,
+): PricedUsage {
+ if (reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) {
+ return { costUsd: reportedCostUsd, costSource: "providerReported" };
+ }
+
+ const rate = lookupRate(table, model);
+ if (rate === null) return { costUsd: 0, costSource: "unpriced" };
+
+ const costUsd =
+ totals.uncachedInputTokens * rate.inputCostPerToken +
+ totals.cachedInputTokens * rate.cacheReadCostPerToken +
+ totals.cacheCreationTokens * rate.cacheCreationCostPerToken +
+ totals.outputTokens * rate.outputCostPerToken;
+
+ return { costUsd, costSource: "modelPriced" };
+}
+
+/**
+ * What the cached input would have cost at full input rates, minus what it
+ * actually cost. Drives the "cache savings" figure.
+ */
+export function cacheSavingsUsd(table: RateTable, model: string, totals: UsageTokenTotals): number {
+ const rate = lookupRate(table, model);
+ if (rate === null) return 0;
+ return totals.cachedInputTokens * (rate.inputCostPerToken - rate.cacheReadCostPerToken);
+}
diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts
new file mode 100644
index 000000000..3c5913eee
--- /dev/null
+++ b/apps/server/src/usage/usageScanCache.test.ts
@@ -0,0 +1,187 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ decodeScanCache,
+ dedupeWithinFile,
+ encodeScanCache,
+ pruneScanCache,
+ type ScanCache,
+} from "./usageScanCache.ts";
+import type { UsageRecord } from "./usageTranscripts.ts";
+
+function record(overrides: Partial = {}): UsageRecord {
+ return {
+ provider: "claude",
+ timestampMs: 1_786_000_000_000,
+ model: "claude-fable-5",
+ sessionId: "session-a",
+ totals: {
+ uncachedInputTokens: 2,
+ cachedInputTokens: 1000,
+ cacheCreationTokens: 10,
+ outputTokens: 50,
+ reasoningTokens: 0,
+ },
+ reportedCostUsd: null,
+ dedupeKey: "msg_1:req_1",
+ ...overrides,
+ };
+}
+
+function cacheWith(entries: readonly [string, number, readonly UsageRecord[]][]): ScanCache {
+ const cache: ScanCache = new Map();
+ for (const [path, mtimeMs, records] of entries) {
+ cache.set(path, { size: records.length * 10, mtimeMs, provider: "claude", records });
+ }
+ return cache;
+}
+
+describe("scan cache round trip", () => {
+ it("restores records unchanged", () => {
+ const original = cacheWith([
+ ["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:req_2", model: "claude-opus-5" })]],
+ ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]],
+ ]);
+
+ const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original))));
+
+ expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl"));
+ expect(restored.get("/b.jsonl")).toEqual(original.get("/b.jsonl"));
+ });
+
+ it("interns repeated model and session strings", () => {
+ const encoded = encodeScanCache(
+ cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:req_2" }), record()]]]),
+ );
+
+ expect(encoded.models).toEqual(["claude-fable-5"]);
+ expect(encoded.sessions).toEqual(["session-a"]);
+ });
+
+ it("treats a corrupt or foreign document as an empty cache", () => {
+ // A bad cache should cost one cold scan, never a broken page.
+ expect(decodeScanCache(null).size).toBe(0);
+ expect(decodeScanCache("nonsense").size).toBe(0);
+ expect(decodeScanCache({ version: 999, models: [], sessions: [], files: {} }).size).toBe(0);
+ });
+
+ it("rejects the whole cache when an intern table holds a non-string", () => {
+ // models: [1] would pass the undefined guard, put a number in a record's
+ // model, and crash normalizeModelName at aggregate time.
+ const poisoned = {
+ ...encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])),
+ models: [1],
+ };
+
+ expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).size).toBe(0);
+ });
+
+ it("drops the whole entry when any row is corrupt, forcing a cold re-parse", () => {
+ // Keeping the surviving rows under the original (size, mtime) would read as
+ // a valid warm hit and the file would never be re-parsed.
+ const encoded = encodeScanCache(
+ cacheWith([
+ ["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:req_2" })]],
+ ["/good.jsonl", 100, [record()]],
+ ]),
+ );
+ const rows = encoded.files["/a.jsonl"]!.r;
+ const poisoned = {
+ ...encoded,
+ files: {
+ ...encoded.files,
+ "/a.jsonl": {
+ ...encoded.files["/a.jsonl"]!,
+ r: [rows[0]!, [...rows[1]!.slice(0, 3), "not-a-number", ...rows[1]!.slice(4)]],
+ },
+ },
+ };
+
+ const restored = decodeScanCache(JSON.parse(JSON.stringify(poisoned)));
+ expect([...restored.keys()]).toEqual(["/good.jsonl"]);
+ });
+});
+
+describe("pruneScanCache", () => {
+ const retentionCutoffMs = 1000;
+
+ it("drops entries older than retention", () => {
+ const cache = cacheWith([["/old.jsonl", 500, [record()]]]);
+
+ const removed = pruneScanCache(cache, {
+ livePaths: new Set(),
+ walkedRoots: ["/"],
+ windowStartMs: 400,
+ retentionCutoffMs,
+ });
+
+ expect(removed).toBe(1);
+ expect(cache.size).toBe(0);
+ });
+
+ it("drops in-window entries whose file has disappeared but keeps the ones seen", () => {
+ const cache = cacheWith([
+ ["/gone.jsonl", 5000, [record()]],
+ ["/live.jsonl", 5000, [record()]],
+ ]);
+
+ pruneScanCache(cache, {
+ livePaths: new Set(["/live.jsonl"]),
+ walkedRoots: ["/"],
+ windowStartMs: 4000,
+ retentionCutoffMs,
+ });
+
+ expect([...cache.keys()]).toEqual(["/live.jsonl"]);
+ });
+
+ it("keeps entries outside the walked window that are still within retention", () => {
+ // Viewing 7 days must not evict the 90-day entries, which that walk never
+ // looked for and so cannot prove are gone.
+ const cache = cacheWith([["/older-but-valid.jsonl", 2000, [record()]]]);
+
+ const removed = pruneScanCache(cache, {
+ livePaths: new Set(),
+ walkedRoots: ["/"],
+ windowStartMs: 4000,
+ retentionCutoffMs,
+ });
+
+ expect(removed).toBe(0);
+ });
+
+ it("keeps in-window entries for a provider whose directory was not walked", () => {
+ // A missing provider root or failed settings read leaves livePaths without
+ // that provider's files. Its warm entries must survive the pass.
+ const cache = cacheWith([["/codex/sessions/a.jsonl", 5000, [record()]]]);
+
+ const removed = pruneScanCache(cache, {
+ livePaths: new Set(),
+ walkedRoots: ["/claude/projects"],
+ windowStartMs: 4000,
+ retentionCutoffMs,
+ });
+
+ expect(removed).toBe(0);
+ });
+});
+
+describe("dedupeWithinFile", () => {
+ it("keeps the first record per dedupe key", () => {
+ const kept = dedupeWithinFile([
+ record({ totals: { ...record().totals, outputTokens: 1 } }),
+ record({ totals: { ...record().totals, outputTokens: 999 } }),
+ record({ dedupeKey: "msg_2:req_2" }),
+ ]);
+
+ expect(kept).toHaveLength(2);
+ expect(kept[0]?.totals.outputTokens).toBe(1);
+ });
+
+ it("keeps every record that has no dedupe key", () => {
+ // Codex records are unique per rollout and carry no key.
+ expect(
+ dedupeWithinFile([record({ dedupeKey: null }), record({ dedupeKey: null })]),
+ ).toHaveLength(2);
+ });
+});
diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts
new file mode 100644
index 000000000..29de3fa14
--- /dev/null
+++ b/apps/server/src/usage/usageScanCache.ts
@@ -0,0 +1,254 @@
+/**
+ * Durable per-file scan cache.
+ *
+ * Transcripts are append-only and a file that has not changed can never yield
+ * different usage, so parsed records are keyed by `(size, mtime)` and reused.
+ * Without this every server restart re-parses the whole window.
+ *
+ * Caching *per file* rather than per day is deliberate. It is timezone
+ * independent, so changing the reporting zone does not invalidate anything, and
+ * it keeps cross-file de-duplication exact: cached entries are de-duplicated
+ * within their own file only, and the aggregator still applies the global
+ * dedupe pass over the small surviving key set.
+ *
+ * @module usageScanCache
+ */
+import type { UsageProviderKind } from "@threadlines/contracts";
+
+import type { UsageRecord } from "./usageTranscripts.ts";
+
+/**
+ * Bump whenever a parser change alters what a given file parses to. Stale
+ * entries would otherwise keep serving the old interpretation forever, since a
+ * file that has not changed is never re-read.
+ */
+export const USAGE_SCAN_CACHE_VERSION = 1 as const;
+
+export interface CachedFile {
+ readonly size: number;
+ readonly mtimeMs: number;
+ readonly provider: UsageProviderKind;
+ readonly records: readonly UsageRecord[];
+}
+
+export type ScanCache = Map;
+
+/**
+ * Row layout for the serialised form. Positional and interned rather than
+ * object-per-record: on a long window that is the difference between a cache
+ * file measured in tens of megabytes and one a fraction of that size.
+ */
+type SerializedRecord = readonly [
+ timestampMs: number,
+ modelIndex: number,
+ sessionIndex: number,
+ uncachedInputTokens: number,
+ cachedInputTokens: number,
+ cacheCreationTokens: number,
+ outputTokens: number,
+ reasoningTokens: number,
+ dedupeKey: string | null,
+ reportedCostUsd: number | null,
+];
+
+interface SerializedFile {
+ readonly s: number;
+ readonly m: number;
+ readonly p: UsageProviderKind;
+ readonly r: readonly SerializedRecord[];
+}
+
+export interface SerializedCache {
+ readonly version: number;
+ readonly models: readonly string[];
+ readonly sessions: readonly string[];
+ readonly files: Readonly>;
+}
+
+/** Serialises the cache, interning the repeated model and session strings. */
+export function encodeScanCache(cache: ScanCache): SerializedCache {
+ const models: string[] = [];
+ const sessions: string[] = [];
+ const modelIndex = new Map();
+ const sessionIndex = new Map();
+
+ const intern = (table: string[], index: Map, value: string): number => {
+ const existing = index.get(value);
+ if (existing !== undefined) return existing;
+ const next = table.length;
+ table.push(value);
+ index.set(value, next);
+ return next;
+ };
+
+ const files: Record = {};
+ for (const [path, entry] of cache) {
+ files[path] = {
+ s: entry.size,
+ m: entry.mtimeMs,
+ p: entry.provider,
+ r: entry.records.map((record) => [
+ record.timestampMs,
+ intern(models, modelIndex, record.model),
+ intern(sessions, sessionIndex, record.sessionId),
+ record.totals.uncachedInputTokens,
+ record.totals.cachedInputTokens,
+ record.totals.cacheCreationTokens,
+ record.totals.outputTokens,
+ record.totals.reasoningTokens,
+ record.dedupeKey,
+ record.reportedCostUsd,
+ ]),
+ };
+ }
+
+ return { version: USAGE_SCAN_CACHE_VERSION, models, sessions, files };
+}
+
+function isArray(value: unknown): value is readonly unknown[] {
+ return Array.isArray(value);
+}
+
+/**
+ * Rebuilds the cache from a parsed document.
+ *
+ * Anything malformed yields an empty cache rather than an error: a corrupt
+ * cache should cost one cold scan, never a broken page.
+ */
+export function decodeScanCache(document: unknown): ScanCache {
+ const cache: ScanCache = new Map();
+ if (typeof document !== "object" || document === null) return cache;
+
+ const root = document as Partial;
+ if (root.version !== USAGE_SCAN_CACHE_VERSION) return cache;
+ if (!isArray(root.models) || !isArray(root.sessions)) return cache;
+ if (typeof root.files !== "object" || root.files === null) return cache;
+
+ // The intern tables must be all strings: a numeric entry would pass the
+ // undefined guard below, land in a record's model, and crash the aggregate at
+ // normalizeModelName. A corrupt table rejects the whole cache.
+ if (!root.models.every((value) => typeof value === "string")) return cache;
+ if (!root.sessions.every((value) => typeof value === "string")) return cache;
+ const models = root.models as readonly string[];
+ const sessions = root.sessions as readonly string[];
+
+ for (const [path, raw] of Object.entries(root.files)) {
+ if (typeof raw !== "object" || raw === null) continue;
+ const entry = raw as Partial;
+ if (typeof entry.s !== "number" || typeof entry.m !== "number") continue;
+ if (entry.p !== "claude" && entry.p !== "codex") continue;
+ if (!isArray(entry.r)) continue;
+
+ const provider: UsageProviderKind = entry.p;
+ const records: UsageRecord[] = [];
+ // Any corrupt row disqualifies the whole entry. Keeping the survivors under
+ // the original (size, mtime) would read as a valid warm hit and the file
+ // would never be re-parsed, silently losing the dropped rows' usage.
+ let corrupt = false;
+ for (const row of entry.r) {
+ if (!isArray(row) || row.length < 10) {
+ corrupt = true;
+ break;
+ }
+ const [
+ timestampMs,
+ modelIndex,
+ sessionIndex,
+ uncached,
+ cached,
+ cacheCreation,
+ output,
+ reasoning,
+ dedupeKey,
+ reportedCostUsd,
+ ] = row as SerializedRecord;
+
+ const model = typeof modelIndex === "number" ? models[modelIndex] : undefined;
+ if (
+ typeof timestampMs !== "number" ||
+ !Number.isFinite(timestampMs) ||
+ model === undefined ||
+ !Number.isFinite(uncached) ||
+ !Number.isFinite(cached) ||
+ !Number.isFinite(cacheCreation) ||
+ !Number.isFinite(output) ||
+ !Number.isFinite(reasoning)
+ ) {
+ corrupt = true;
+ break;
+ }
+
+ records.push({
+ provider,
+ timestampMs,
+ model,
+ sessionId: (typeof sessionIndex === "number" ? sessions[sessionIndex] : undefined) ?? "",
+ totals: {
+ uncachedInputTokens: uncached,
+ cachedInputTokens: cached,
+ cacheCreationTokens: cacheCreation,
+ outputTokens: output,
+ reasoningTokens: reasoning,
+ },
+ reportedCostUsd: typeof reportedCostUsd === "number" ? reportedCostUsd : null,
+ dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null,
+ });
+ }
+
+ if (corrupt) continue;
+ cache.set(path, { size: entry.s, mtimeMs: entry.m, provider, records });
+ }
+
+ return cache;
+}
+
+export interface PruneOptions {
+ /** Files the walk just saw. Only meaningful inside the walked window. */
+ readonly livePaths: ReadonlySet;
+ /**
+ * Roots the walk actually completed. Absence from `livePaths` only proves a
+ * file is gone when its root was walked: a provider whose directory failed to
+ * resolve this pass must not have its warm entries purged.
+ */
+ readonly walkedRoots: readonly string[];
+ /** Start of the walked window; entries older than this were not looked for. */
+ readonly windowStartMs: number;
+ /** Entries older than this are dropped regardless. */
+ readonly retentionCutoffMs: number;
+}
+
+/**
+ * Drops aged-out entries, and entries for files that have disappeared.
+ *
+ * The walk only covers the requested window, so absence from `livePaths` only
+ * proves deletion for entries *inside* that window. Pruning everything the walk
+ * missed would evict the 90-day entries every time someone looked at 7 days.
+ */
+export function pruneScanCache(cache: ScanCache, options: PruneOptions): number {
+ let removed = 0;
+ for (const [path, entry] of cache) {
+ const agedOut = entry.mtimeMs < options.retentionCutoffMs;
+ const underWalkedRoot = options.walkedRoots.some((root) => path.startsWith(root));
+ const deleted =
+ underWalkedRoot && entry.mtimeMs >= options.windowStartMs && !options.livePaths.has(path);
+ if (agedOut || deleted) {
+ cache.delete(path);
+ removed += 1;
+ }
+ }
+ return removed;
+}
+
+/** Within-file de-duplication, applied before an entry is cached. */
+export function dedupeWithinFile(records: readonly UsageRecord[]): readonly UsageRecord[] {
+ const seen = new Set();
+ const kept: UsageRecord[] = [];
+ for (const record of records) {
+ if (record.dedupeKey !== null) {
+ if (seen.has(record.dedupeKey)) continue;
+ seen.add(record.dedupeKey);
+ }
+ kept.push(record);
+ }
+ return kept;
+}
diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts
new file mode 100644
index 000000000..e4ba441ad
--- /dev/null
+++ b/apps/server/src/usage/usageTranscriptReader.ts
@@ -0,0 +1,142 @@
+// @effect-diagnostics nodeBuiltinImport:off
+/**
+ * Raw filesystem access for transcript scanning.
+ *
+ * Isolated here so the rest of the usage code stays on Effect's `FileSystem`.
+ * The direct `node:fs` streaming is deliberate: a cold 90-day window is on the
+ * order of a gigabyte across thousands of files, and `readline` over a read
+ * stream is roughly an order of magnitude cheaper than materialising each file.
+ * The equivalent Effect stream pipeline is idiomatic but not fast enough to sit
+ * behind a page load.
+ *
+ * @module usageTranscriptReader
+ */
+import * as NodeFS from "node:fs";
+import * as NodeFSP from "node:fs/promises";
+import * as NodePath from "node:path";
+import * as NodeReadline from "node:readline";
+
+import type { UsageProviderKind } from "@threadlines/contracts";
+
+import {
+ initialCodexScanState,
+ mightCarryUsage,
+ parseClaudeLine,
+ parseCodexLine,
+ type UsageRecord,
+} from "./usageTranscripts.ts";
+
+export interface TranscriptFile {
+ readonly path: string;
+ readonly size: number;
+ readonly mtimeMs: number;
+}
+
+/**
+ * Lists `.jsonl` transcripts under `root` last modified at or after `sinceMs`.
+ *
+ * Errors on individual entries are swallowed: session files rotate and get
+ * removed while the walk is in flight, and a partial listing is far better than
+ * failing the page.
+ */
+export async function listTranscriptFiles(
+ root: string,
+ sinceMs: number,
+): Promise {
+ const found: TranscriptFile[] = [];
+
+ const walk = async (dir: string): Promise => {
+ let entries;
+ try {
+ entries = await NodeFSP.readdir(dir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ const child = NodePath.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ await walk(child);
+ continue;
+ }
+ if (!entry.name.endsWith(".jsonl")) continue;
+ try {
+ const stats = await NodeFSP.stat(child);
+ if (stats.mtimeMs >= sinceMs) {
+ found.push({ path: child, size: stats.size, mtimeMs: stats.mtimeMs });
+ }
+ } catch {
+ // Vanished between readdir and stat.
+ }
+ }
+ };
+
+ await walk(root);
+ return found;
+}
+
+/**
+ * Filesystem identity of a directory, as `device:inode`.
+ *
+ * Used to tell "two servers reading the same transcript directory" apart from
+ * "two machines whose hostname and home path happen to match". Returns an empty
+ * string when the directory cannot be stat'd.
+ */
+export async function readDirectoryVolumeId(path: string): Promise {
+ try {
+ const stats = await NodeFSP.stat(path);
+ return `${stats.dev}:${stats.ino}`;
+ } catch {
+ return "";
+ }
+}
+
+/**
+ * Streams one transcript and returns the usage records it contains, or `null`
+ * when the file could not be read.
+ *
+ * The distinction matters to the caller's cache: a genuinely empty transcript
+ * is a stable fact worth memoising, while a transient read failure memoised
+ * under the same `(size, mtime)` key would silently drop that file's usage
+ * until the file next changes.
+ *
+ * Codex carries the active model on `turn_context` lines and its fork marker on
+ * `session_meta`, neither of which holds usage of its own, so those still have
+ * to pass through the reducer to keep attribution and fork suppression correct.
+ */
+export async function readTranscriptRecords(
+ filePath: string,
+ provider: UsageProviderKind,
+): Promise {
+ const records: UsageRecord[] = [];
+ const codexState = initialCodexScanState();
+
+ try {
+ const lines = NodeReadline.createInterface({
+ input: NodeFS.createReadStream(filePath, { encoding: "utf8" }),
+ crlfDelay: Infinity,
+ });
+
+ for await (const line of lines) {
+ if (provider === "codex") {
+ if (
+ !mightCarryUsage(line, provider) &&
+ !line.includes('"turn_context"') &&
+ !line.includes('"session_meta"')
+ ) {
+ continue;
+ }
+ const record = parseCodexLine(line, codexState);
+ if (record !== null) records.push(record);
+ continue;
+ }
+
+ if (!mightCarryUsage(line, provider)) continue;
+ const record = parseClaudeLine(line);
+ if (record !== null) records.push(record);
+ }
+ } catch {
+ return null;
+ }
+
+ return records;
+}
diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts
new file mode 100644
index 000000000..48bc60477
--- /dev/null
+++ b/apps/server/src/usage/usageTranscripts.test.ts
@@ -0,0 +1,267 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ initialCodexScanState,
+ parseClaudeLine,
+ parseCodexLine,
+ totalTokens,
+} from "./usageTranscripts.ts";
+
+/** Shaped after a real Claude Code assistant record. */
+function claudeLine(overrides: {
+ messageId: string;
+ contentType: string;
+ requestId?: string;
+ model?: string;
+ outputTokens?: number;
+}): string {
+ return JSON.stringify({
+ type: "assistant",
+ timestamp: "2026-08-10T03:50:03.336Z",
+ sessionId: "bf5b7ee7-6a3b-41b2-b47c-ba3ef4375b46",
+ requestId: overrides.requestId ?? "req_011CdtPMPBEdPXGLpcmo3hze",
+ cwd: "/Users/will/badcode",
+ message: {
+ id: overrides.messageId,
+ role: "assistant",
+ model: overrides.model ?? "claude-fable-5",
+ content: [{ type: overrides.contentType }],
+ usage: {
+ input_tokens: 2,
+ cache_creation_input_tokens: 15501,
+ cache_read_input_tokens: 17070,
+ output_tokens: overrides.outputTokens ?? 499,
+ service_tier: "standard",
+ cache_creation: { ephemeral_1h_input_tokens: 15501, ephemeral_5m_input_tokens: 0 },
+ },
+ },
+ });
+}
+
+describe("parseClaudeLine", () => {
+ it("extracts token totals and a dedupe key", () => {
+ const record = parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text" }));
+
+ expect(record?.provider).toBe("claude");
+ expect(record?.model).toBe("claude-fable-5");
+ expect(record?.sessionId).toBe("bf5b7ee7-6a3b-41b2-b47c-ba3ef4375b46");
+ expect(record?.totals).toEqual({
+ uncachedInputTokens: 2,
+ cachedInputTokens: 17070,
+ cacheCreationTokens: 15501,
+ outputTokens: 499,
+ reasoningTokens: 0,
+ });
+ expect(record?.dedupeKey).toBe("msg_1:req_011CdtPMPBEdPXGLpcmo3hze");
+ });
+
+ it("gives every content block of one message the same dedupe key", () => {
+ // The CLI writes one record per content block, each repeating the parent
+ // message's full usage. Summing them would overcount several times over.
+ const text = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "text" }));
+ const toolUse = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "tool_use" }));
+
+ expect(text?.dedupeKey).toBe(toolUse?.dedupeKey);
+ expect(text?.totals).toEqual(toolUse?.totals);
+ });
+
+ it("reads a reported cost only when the CLI wrote one", () => {
+ const withoutCost = parseClaudeLine(claudeLine({ messageId: "msg_3", contentType: "text" }));
+ const withCost = parseClaudeLine(
+ JSON.stringify({
+ ...(JSON.parse(claudeLine({ messageId: "msg_3", contentType: "text" })) as object),
+ costUSD: 0.42,
+ }),
+ );
+
+ expect(withoutCost?.reportedCostUsd).toBeNull();
+ expect(withCost?.reportedCostUsd).toBe(0.42);
+ });
+
+ it("ignores records that are not assistant messages", () => {
+ expect(parseClaudeLine(JSON.stringify({ type: "user", message: {} }))).toBeNull();
+ expect(parseClaudeLine("not json")).toBeNull();
+ });
+});
+
+describe("parseCodexLine", () => {
+ const sessionMeta = JSON.stringify({
+ type: "session_meta",
+ timestamp: "2026-08-10T04:41:36.122Z",
+ payload: {
+ session_id: "019fe9f9-df22-7720-8727-262a9a10e5e9",
+ id: "019fe9f9-df22-7720-8727-262a9a10e5e9",
+ // Ordinary rollouts carry `source` as a plain string and a null fork id.
+ source: "vscode",
+ forked_from_id: null,
+ },
+ });
+ // `turn_context` payloads carry `model` at the top level and no payload type.
+ const turnContext = JSON.stringify({
+ type: "turn_context",
+ timestamp: "2026-08-10T04:41:38.694Z",
+ payload: { model: "gpt-5.6-sol", cwd: "/Users/will/badcode" },
+ });
+ const tokenCount = (inputTokens: number, cached: number, output: number, reasoning: number) =>
+ JSON.stringify({
+ type: "event_msg",
+ timestamp: "2026-08-10T04:41:46.840Z",
+ payload: {
+ type: "token_count",
+ info: {
+ total_token_usage: { input_tokens: inputTokens, output_tokens: output },
+ last_token_usage: {
+ input_tokens: inputTokens,
+ cached_input_tokens: cached,
+ cache_write_input_tokens: 0,
+ output_tokens: output,
+ reasoning_output_tokens: reasoning,
+ },
+ },
+ },
+ });
+
+ it("attributes usage to the model from the preceding turn context", () => {
+ const state = initialCodexScanState();
+ parseCodexLine(sessionMeta, state);
+ parseCodexLine(turnContext, state);
+ const record = parseCodexLine(tokenCount(20642, 11008, 420, 169), state);
+
+ expect(record?.provider).toBe("codex");
+ expect(record?.model).toBe("gpt-5.6-sol");
+ expect(record?.sessionId).toBe("019fe9f9-df22-7720-8727-262a9a10e5e9");
+ // Codex reports input_tokens inclusive of the cached portion.
+ expect(record?.totals.uncachedInputTokens).toBe(20642 - 11008);
+ expect(record?.totals.cachedInputTokens).toBe(11008);
+ expect(record?.totals.reasoningTokens).toBe(169);
+ });
+
+ it("skips a repeated token_count so deltas are not double counted", () => {
+ const state = initialCodexScanState();
+ parseCodexLine(turnContext, state);
+ const first = parseCodexLine(tokenCount(100, 0, 10, 0), state);
+ const repeat = parseCodexLine(tokenCount(100, 0, 10, 0), state);
+
+ expect(first).not.toBeNull();
+ expect(repeat).toBeNull();
+ });
+
+ it("does not let a pre-model event poison the duplicate signature", () => {
+ // A token_count before its turn_context is dropped; the identical event
+ // re-emitted once the model is known must still be counted.
+ const state = initialCodexScanState();
+ expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull();
+ parseCodexLine(turnContext, state);
+ expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).not.toBeNull();
+ });
+
+ // A forked or subagent rollout opens with the parent's history copied in and
+ // every line re-stamped to the fork instant, then the ancestors' session
+ // metas. Threadlines forks Codex threads natively, so counting those copies
+ // again would multiply the prefix turns by the number of forks taken.
+ describe("forked rollouts", () => {
+ const meta = (overrides: {
+ id: string;
+ timestamp: string;
+ forkedFromId?: string;
+ spawnParentId?: string;
+ }) =>
+ JSON.stringify({
+ type: "session_meta",
+ timestamp: overrides.timestamp,
+ payload: {
+ id: overrides.id,
+ forked_from_id: overrides.forkedFromId ?? null,
+ ...(overrides.spawnParentId === undefined
+ ? { source: "vscode" }
+ : {
+ source: {
+ subagent: { thread_spawn: { parent_thread_id: overrides.spawnParentId } },
+ },
+ }),
+ },
+ });
+ const stamped = (timestamp: string, line: string) => {
+ const parsed = JSON.parse(line) as { timestamp: string };
+ parsed.timestamp = timestamp;
+ return JSON.stringify(parsed);
+ };
+
+ it("keeps the child session id over copied ancestor metas", () => {
+ const state = initialCodexScanState();
+ parseCodexLine(meta({ id: "child", timestamp: "2026-07-21T05:11:54.120Z" }), state);
+ parseCodexLine(meta({ id: "parent", timestamp: "2026-07-21T05:11:54.121Z" }), state);
+ parseCodexLine(turnContext, state);
+ const record = parseCodexLine(tokenCount(100, 0, 10, 0), state);
+
+ expect(record?.sessionId).toBe("child");
+ });
+
+ it("drops the re-stamped copied burst and keeps the first real event", () => {
+ // Timings taken from a real Threadlines Codex fork: the copied prefix
+ // lands within a millisecond of the fork, the child's own turn 2.7s later.
+ const state = initialCodexScanState();
+ const forkInstant = "2026-07-21T05:11:54.120Z";
+ parseCodexLine(meta({ id: "child", timestamp: forkInstant, forkedFromId: "parent" }), state);
+ parseCodexLine(meta({ id: "parent", timestamp: "2026-07-21T05:11:54.121Z" }), state);
+ parseCodexLine(stamped("2026-07-21T05:11:54.121Z", turnContext), state);
+
+ expect(
+ parseCodexLine(stamped("2026-07-21T05:11:54.121Z", tokenCount(17821, 1408, 20, 0)), state),
+ ).toBeNull();
+ expect(
+ parseCodexLine(stamped("2026-07-21T05:11:54.140Z", tokenCount(17830, 1408, 25, 0)), state),
+ ).toBeNull();
+
+ const real = parseCodexLine(
+ stamped("2026-07-21T05:11:56.826Z", tokenCount(17859, 1408, 11, 0)),
+ state,
+ );
+ expect(real?.totals.outputTokens).toBe(11);
+
+ // Suppression never restarts, even for closely spaced later events.
+ expect(
+ parseCodexLine(stamped("2026-07-21T05:11:56.926Z", tokenCount(17900, 1408, 40, 0)), state),
+ ).not.toBeNull();
+ });
+
+ it("recognizes subagent spawns without forked_from_id", () => {
+ const state = initialCodexScanState();
+ const spawnInstant = "2026-08-01T20:36:12.311Z";
+ parseCodexLine(
+ meta({ id: "child", timestamp: spawnInstant, spawnParentId: "parent" }),
+ state,
+ );
+ parseCodexLine(stamped(spawnInstant, turnContext), state);
+ expect(
+ parseCodexLine(stamped("2026-08-01T20:36:12.312Z", tokenCount(100, 0, 10, 0)), state),
+ ).toBeNull();
+ });
+
+ it("does not suppress anything in a rollout that is not a fork", () => {
+ const state = initialCodexScanState();
+ parseCodexLine(meta({ id: "root", timestamp: "2026-08-01T20:36:12.311Z" }), state);
+ parseCodexLine(stamped("2026-08-01T20:36:12.411Z", turnContext), state);
+ const record = parseCodexLine(
+ stamped("2026-08-01T20:36:12.511Z", tokenCount(100, 0, 10, 0)),
+ state,
+ );
+
+ expect(record).not.toBeNull();
+ });
+ });
+});
+
+describe("totalTokens", () => {
+ it("does not add reasoning on top of output", () => {
+ expect(
+ totalTokens({
+ uncachedInputTokens: 10,
+ cachedInputTokens: 20,
+ cacheCreationTokens: 30,
+ outputTokens: 40,
+ reasoningTokens: 25,
+ }),
+ ).toBe(100);
+ });
+});
diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts
new file mode 100644
index 000000000..d4aea728a
--- /dev/null
+++ b/apps/server/src/usage/usageTranscripts.ts
@@ -0,0 +1,313 @@
+/**
+ * Pure parsers for the provider CLIs' on-disk session transcripts.
+ *
+ * Both parsers are line-at-a-time reducers so callers can stream large files
+ * without materialising them. Neither touches the filesystem.
+ *
+ * @module usageTranscripts
+ */
+import type { UsageProviderKind, UsageTokenTotals } from "@threadlines/contracts";
+
+export interface UsageRecord {
+ readonly provider: UsageProviderKind;
+ readonly timestampMs: number;
+ readonly model: string;
+ readonly sessionId: string;
+ readonly totals: UsageTokenTotals;
+ readonly reportedCostUsd: number | null;
+ /**
+ * Key for cross-file de-duplication, or `null` when the record is inherently
+ * unique and needs no dedup.
+ */
+ readonly dedupeKey: string | null;
+}
+
+export const EMPTY_TOTALS: UsageTokenTotals = {
+ uncachedInputTokens: 0,
+ cachedInputTokens: 0,
+ cacheCreationTokens: 0,
+ outputTokens: 0,
+ reasoningTokens: 0,
+};
+
+function int(value: unknown): number {
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
+}
+
+function parseTimestampMs(value: unknown): number | null {
+ if (typeof value !== "string") return null;
+ const parsed = Date.parse(value);
+ return Number.isNaN(parsed) ? null : parsed;
+}
+
+export function addTotals(a: UsageTokenTotals, b: UsageTokenTotals): UsageTokenTotals {
+ return {
+ uncachedInputTokens: a.uncachedInputTokens + b.uncachedInputTokens,
+ cachedInputTokens: a.cachedInputTokens + b.cachedInputTokens,
+ cacheCreationTokens: a.cacheCreationTokens + b.cacheCreationTokens,
+ outputTokens: a.outputTokens + b.outputTokens,
+ reasoningTokens: a.reasoningTokens + b.reasoningTokens,
+ };
+}
+
+export function totalTokens(totals: UsageTokenTotals): number {
+ // reasoningTokens is a subset of outputTokens and must not be added again.
+ return (
+ totals.uncachedInputTokens +
+ totals.cachedInputTokens +
+ totals.cacheCreationTokens +
+ totals.outputTokens
+ );
+}
+
+/**
+ * Cheap substring gate applied before `JSON.parse`.
+ *
+ * Transcripts are mostly tool output; only a minority of lines carry usage, and
+ * the lines that do not are frequently the largest. Skipping them without
+ * parsing is worth roughly an order of magnitude on a 30-day window.
+ */
+export function mightCarryUsage(line: string, provider: UsageProviderKind): boolean {
+ return provider === "claude" ? line.includes('"usage"') : line.includes('"token_count"');
+}
+
+/* -------------------------------------------------------------------------- */
+/* Claude Code */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * Parses one line of a Claude Code transcript.
+ *
+ * The CLI writes one record per assistant *content block*, and every one of
+ * those records repeats the same complete `usage` object for the parent
+ * message. Summing them overcounts badly, so the caller must drop repeats by
+ * `dedupeKey` and keep the first.
+ */
+export function parseClaudeLine(line: string): UsageRecord | null {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(line);
+ } catch {
+ return null;
+ }
+ if (typeof parsed !== "object" || parsed === null) return null;
+
+ const record = parsed as Record;
+ if (record["type"] !== "assistant") return null;
+
+ const message = record["message"];
+ if (typeof message !== "object" || message === null) return null;
+ const messageRecord = message as Record;
+
+ const usage = messageRecord["usage"];
+ if (typeof usage !== "object" || usage === null) return null;
+ const usageRecord = usage as Record;
+
+ const timestampMs = parseTimestampMs(record["timestamp"]);
+ if (timestampMs === null) return null;
+
+ const model = typeof messageRecord["model"] === "string" ? messageRecord["model"] : "";
+ if (model.length === 0) return null;
+
+ const messageId = typeof messageRecord["id"] === "string" ? messageRecord["id"] : null;
+ const requestId = typeof record["requestId"] === "string" ? record["requestId"] : null;
+ // Matches ccusage: prefer the message/request pair, fall back to whichever
+ // half exists. Records with neither cannot be de-duplicated.
+ const dedupeKey =
+ messageId === null && requestId === null ? null : `${messageId ?? ""}:${requestId ?? ""}`;
+
+ // Older CLI builds wrote a `costUSD` per record; current ones do not. Read it
+ // when present rather than assuming either way.
+ const cost = record["costUSD"];
+
+ return {
+ provider: "claude",
+ timestampMs,
+ model,
+ sessionId: typeof record["sessionId"] === "string" ? record["sessionId"] : "",
+ totals: {
+ uncachedInputTokens: int(usageRecord["input_tokens"]),
+ cachedInputTokens: int(usageRecord["cache_read_input_tokens"]),
+ // The nested `cache_creation` object splits this into 5m and 1h ephemeral
+ // buckets, which bill at different multipliers. The rate table publishes
+ // one cache-write rate, so the split would not change the arithmetic;
+ // the flat total is what we price.
+ cacheCreationTokens: int(usageRecord["cache_creation_input_tokens"]),
+ outputTokens: int(usageRecord["output_tokens"]),
+ // Anthropic folds thinking tokens into output and does not break them out.
+ reasoningTokens: 0,
+ },
+ reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null,
+ dedupeKey,
+ };
+}
+
+/* -------------------------------------------------------------------------- */
+/* Codex */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * Rolling state for a single Codex rollout file.
+ *
+ * Codex `token_count` events carry no model, so the model is carried forward
+ * from the most recent `turn_context`. Sessions that switch models mid-run
+ * attribute correctly from the switch onward.
+ */
+export interface CodexScanState {
+ model: string;
+ sessionId: string;
+ lastUsageSignature: string | null;
+ sawSessionMeta: boolean;
+ /** While true, leading usage events are re-stamped copies of parent history. */
+ suppressingForkCopies: boolean;
+ forkCopyAnchorMs: number;
+}
+
+export function initialCodexScanState(): CodexScanState {
+ return {
+ model: "",
+ sessionId: "",
+ lastUsageSignature: null,
+ sawSessionMeta: false,
+ suppressingForkCopies: false,
+ forkCopyAnchorMs: 0,
+ };
+}
+
+/**
+ * A forked or subagent rollout opens with the parent's full history copied in,
+ * every line re-stamped to the fork instant. Those copies are written in one
+ * synchronous burst (observed gaps 0-40ms in real Threadlines forks), while the
+ * child's first genuine usage event only lands after a real model turn
+ * (observed seconds). One second of separation splits the two cleanly;
+ * `ccusage` uses the same threshold.
+ *
+ * This matters more for Threadlines than for a plain CLI install: forking a
+ * Codex thread is a first-class feature here, so forked rollouts are common and
+ * naive summing would multiply their prefix turns by the fork count.
+ */
+const FORK_COPY_MAX_GAP_MS = 1000;
+
+/** Whether a `session_meta` payload marks the rollout as a fork or subagent. */
+function isForkedSessionMeta(payload: Record): boolean {
+ // Present but `null` on ordinary rollouts, so the type check is load-bearing.
+ if (typeof payload["forked_from_id"] === "string") return true;
+ const source = payload["source"];
+ // `source` is a plain string ("vscode", "cli") on ordinary rollouts.
+ if (typeof source !== "object" || source === null) return false;
+ const subagent = (source as Record)["subagent"];
+ if (typeof subagent !== "object" || subagent === null) return false;
+ const spawn = (subagent as Record)["thread_spawn"];
+ if (typeof spawn !== "object" || spawn === null) return false;
+ return typeof (spawn as Record)["parent_thread_id"] === "string";
+}
+
+/**
+ * Feeds one line of a Codex rollout into `state`, returning a record when the
+ * line was a usage event.
+ *
+ * Deltas come from `last_token_usage`. Summing those across a session
+ * reconciles with the session's final `total_token_usage`, provided consecutive
+ * duplicate events are dropped, which this does.
+ */
+export function parseCodexLine(line: string, state: CodexScanState): UsageRecord | null {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(line);
+ } catch {
+ return null;
+ }
+ if (typeof parsed !== "object" || parsed === null) return null;
+
+ const record = parsed as Record;
+ const payload = record["payload"];
+ if (typeof payload !== "object" || payload === null) return null;
+ const payloadRecord = payload as Record;
+ const payloadType = payloadRecord["type"];
+
+ if (record["type"] === "session_meta") {
+ // Only the first meta describes this file's own session. A forked rollout
+ // repeats the ancestors' metas right after it; letting those through would
+ // reassign every subsequent record to an ancestor session.
+ if (state.sawSessionMeta) return null;
+ state.sawSessionMeta = true;
+ const id = payloadRecord["id"] ?? payloadRecord["session_id"];
+ if (typeof id === "string") state.sessionId = id;
+ const metaTimestampMs = parseTimestampMs(record["timestamp"]);
+ if (metaTimestampMs !== null && isForkedSessionMeta(payloadRecord)) {
+ state.suppressingForkCopies = true;
+ state.forkCopyAnchorMs = metaTimestampMs;
+ }
+ return null;
+ }
+
+ if (record["type"] === "turn_context") {
+ // `turn_context` payloads carry `model` at the top level and no `type` of
+ // their own, so the envelope type is the only reliable discriminator.
+ if (typeof payloadRecord["model"] === "string") state.model = payloadRecord["model"];
+ return null;
+ }
+
+ if (payloadType !== "token_count") return null;
+
+ const info = payloadRecord["info"];
+ if (typeof info !== "object" || info === null) return null;
+ const last = (info as Record)["last_token_usage"];
+ if (typeof last !== "object" || last === null) return null;
+ const lastRecord = last as Record;
+
+ // Only an event that is otherwise eligible may consume the duplicate
+ // signature. A token_count arriving before its turn_context (no model yet)
+ // must not poison it, or the re-emitted copy after the model is known would
+ // be skipped as a duplicate and those tokens never counted.
+ const timestampMs = parseTimestampMs(record["timestamp"]);
+ if (timestampMs === null) return null;
+ if (state.model.length === 0) return null;
+
+ // Codex re-emits an unchanged token_count on some stream boundaries. Summing
+ // those would double count, so identical consecutive payloads are skipped.
+ const signature = JSON.stringify(lastRecord);
+ if (signature === state.lastUsageSignature) return null;
+ state.lastUsageSignature = signature;
+
+ // In a forked rollout the copied parent history was already counted from the
+ // parent's own file. Drop the leading burst; the first usage event separated
+ // from its predecessor by a real turn's worth of time ends it for good.
+ if (state.suppressingForkCopies) {
+ if (timestampMs - state.forkCopyAnchorMs < FORK_COPY_MAX_GAP_MS) {
+ state.forkCopyAnchorMs = timestampMs;
+ return null;
+ }
+ state.suppressingForkCopies = false;
+ }
+
+ const inputTokens = int(lastRecord["input_tokens"]);
+ const cachedInputTokens = int(lastRecord["cached_input_tokens"]);
+ const cacheCreationTokens = int(lastRecord["cache_write_input_tokens"]);
+ const outputTokens = int(lastRecord["output_tokens"]);
+
+ const totals: UsageTokenTotals = {
+ // Codex reports `input_tokens` inclusive of the cached portion.
+ uncachedInputTokens: Math.max(0, inputTokens - cachedInputTokens - cacheCreationTokens),
+ cachedInputTokens,
+ cacheCreationTokens,
+ outputTokens,
+ // Reported inside output_tokens, surfaced separately for the token mix.
+ reasoningTokens: Math.min(outputTokens, int(lastRecord["reasoning_output_tokens"])),
+ };
+
+ if (totalTokens(totals) === 0) return null;
+
+ return {
+ provider: "codex",
+ timestampMs,
+ model: state.model,
+ sessionId: state.sessionId,
+ totals,
+ // Codex does not report cost in the rollout.
+ reportedCostUsd: null,
+ // Events surviving the fork-copy suppression above are unique to this
+ // rollout, so they need no global dedup.
+ dedupeKey: null,
+ };
+}
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index c83205fc3..154f3f264 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -68,6 +68,7 @@ import { coalesceLatestAggregateEvents } from "./orchestration/shellStreamCoales
import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts";
import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
import { ThreadSearch } from "./orchestration/Services/ThreadSearch.ts";
+import { UsageService } from "./usage/UsageService.ts";
import {
observeRpcEffect,
observeRpcStream,
@@ -233,6 +234,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
Effect.gen(function* () {
const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
const threadSearch = yield* ThreadSearch;
+ const usage = yield* UsageService;
const orchestrationEngine = yield* OrchestrationEngineService;
const checkpointDiffQuery = yield* CheckpointDiffQuery;
const checkpointRevert = yield* CheckpointRevert;
@@ -1416,6 +1418,10 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
"rpc.aggregate": "server",
},
),
+ [WS_METHODS.usageSummary]: (input) =>
+ observeRpcEffect(WS_METHODS.usageSummary, usage.readSummary(input), {
+ "rpc.aggregate": "usage",
+ }),
[WS_METHODS.sourceControlLookupRepository]: (input) =>
observeRpcEffect(
WS_METHODS.sourceControlLookupRepository,
diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx
index ef4d1a56b..0ec109393 100644
--- a/apps/web/src/components/CommandPalette.tsx
+++ b/apps/web/src/components/CommandPalette.tsx
@@ -24,6 +24,7 @@ import {
CornerLeftUpIcon,
FolderIcon,
FolderPlusIcon,
+ GaugeIcon,
HomeIcon,
LinkIcon,
MessageSquareIcon,
@@ -1615,6 +1616,17 @@ function OpenCommandPaletteDialog() {
});
}
+ actionItems.push({
+ kind: "action",
+ value: "action:usage",
+ searchTerms: ["usage", "tokens", "cost", "spend", "spending", "price", "billing"],
+ title: "Open usage",
+ icon: ,
+ run: async () => {
+ await navigate({ to: "/usage" });
+ },
+ });
+
actionItems.push({
kind: "action",
value: "action:settings",
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index c6484077c..ecb6a4e00 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -117,6 +117,7 @@ import { SidebarHoverCardGroup } from "./sidebar/hoverCard";
import { ThreadHoverCardProvider } from "./sidebar/ThreadHoverCard";
import { resolveThreadActionProjectRef, startNewGeneralChatThread } from "../lib/chatThreadActions";
import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
+import { SidebarUsageMeter } from "./sidebar/SidebarUsageMeter";
import { SidebarVersionTag } from "./sidebar/SidebarVersionTag";
import { readEnvironmentApi } from "../environmentApi";
import { useSettings } from "~/hooks/useSettings";
@@ -320,6 +321,9 @@ const SidebarChromeFooter = memo(function SidebarChromeFooter() {
+
+
+
setIsExpanded(event.currentTarget.open)}
>
-
+ {
+ event.preventDefault();
+ setIsExpanded((expanded) => !expanded);
+ }}
+ >
Advanced: headless chat token
diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx
index 1eabb8c63..22fad5891 100644
--- a/apps/web/src/components/settings/SettingsPanels.browser.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx
@@ -17,12 +17,13 @@ import {
type ServerProvider,
type SourceControlDiscoveryResult,
} from "@threadlines/contracts";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as DateTime from "effect/DateTime";
import * as Option from "effect/Option";
import { page } from "vite-plus/test/browser";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { render } from "vitest-browser-react";
-import type { ReactNode } from "react";
+import { useState, type ReactNode } from "react";
import {
RouterProvider,
createMemoryHistory,
@@ -40,6 +41,23 @@ import { DiagnosticsSettingsPanel } from "./DiagnosticsSettings";
import { GeneralSettingsPanel, ProviderSettingsPanel } from "./SettingsPanels";
import { SourceControlSettingsPanel } from "./SourceControlSettings";
+/**
+ * The app-wide providers these panels are always mounted under. Settings rows
+ * read cached data (usage, updates), so a bare render would crash on the
+ * missing query client rather than on anything the test is about.
+ */
+function TestAppProviders({ children }: { children: ReactNode }) {
+ const [queryClient] = useState(
+ () => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
function renderWithTestRouter(children: ReactNode) {
const rootRoute = createRootRoute({
component: () => children,
@@ -52,7 +70,6 @@ function renderWithTestRouter(children: ReactNode) {
routeTree: rootRoute.addChildren([indexRoute]),
history: createMemoryHistory({ initialEntries: ["/"] }),
});
-
return render( );
}
@@ -787,10 +804,10 @@ describe("GeneralSettingsPanel observability", () => {
});
vi.stubGlobal("fetch", fetchMock);
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect.element(page.getByText("Connect your phone or tablet")).toBeInTheDocument();
@@ -835,10 +852,10 @@ describe("GeneralSettingsPanel observability", () => {
});
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByRole("button", { name: "Create link", exact: true }).click();
@@ -875,10 +892,10 @@ describe("GeneralSettingsPanel observability", () => {
});
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await vi.waitFor(() => {
@@ -915,10 +932,10 @@ describe("GeneralSettingsPanel observability", () => {
});
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByRole("button", { name: "Create link", exact: true }).click();
@@ -964,10 +981,10 @@ describe("GeneralSettingsPanel observability", () => {
});
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByRole("button", { name: "Create link", exact: true }).click();
@@ -1001,10 +1018,10 @@ describe("GeneralSettingsPanel observability", () => {
});
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByRole("button", { name: "Create link", exact: true }).click();
@@ -1075,10 +1092,10 @@ describe("GeneralSettingsPanel observability", () => {
});
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect
@@ -1168,10 +1185,10 @@ describe("GeneralSettingsPanel observability", () => {
});
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect.element(page.getByText("http://192.168.86.39:3773/")).toBeInTheDocument();
@@ -1194,9 +1211,9 @@ describe("GeneralSettingsPanel observability", () => {
setServerConfigSnapshot(createBaseServerConfig());
mounted = await renderWithTestRouter(
-
+
- ,
+ ,
);
await expect.element(page.getByText("About")).toBeInTheDocument();
@@ -1217,9 +1234,9 @@ describe("GeneralSettingsPanel observability", () => {
setServerConfigSnapshot(createBaseServerConfig());
mounted = await renderWithTestRouter(
-
+
- ,
+ ,
);
await expect
@@ -1252,9 +1269,9 @@ describe("GeneralSettingsPanel observability", () => {
setServerConfigSnapshot(createBaseServerConfig());
mounted = await renderWithTestRouter(
-
+
- ,
+ ,
);
const analyticsSwitch = page.getByRole("switch", {
@@ -1364,10 +1381,10 @@ describe("GeneralSettingsPanel observability", () => {
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect.element(page.getByText("Connected devices")).toBeInTheDocument();
@@ -1457,10 +1474,10 @@ describe("GeneralSettingsPanel observability", () => {
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect.element(page.getByText("Julius iPhone")).toBeInTheDocument();
@@ -1523,10 +1540,10 @@ describe("GeneralSettingsPanel observability", () => {
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect.element(page.getByText("Loading devices...")).toBeInTheDocument();
@@ -1552,10 +1569,10 @@ describe("GeneralSettingsPanel observability", () => {
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
const networkAccessToggle = page.getByLabelText("Allow phone and tablet access");
@@ -1606,10 +1623,10 @@ describe("GeneralSettingsPanel observability", () => {
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByRole("button", { name: "Add computer", exact: true }).click();
@@ -1699,10 +1716,10 @@ describe("GeneralSettingsPanel observability", () => {
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
const openLogsButton = page.getByLabelText("Open logs folder");
@@ -1714,10 +1731,10 @@ describe("GeneralSettingsPanel observability", () => {
it("shows Claude configuration fields in provider settings", async () => {
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByLabelText("Toggle Claude details").click();
@@ -1754,10 +1771,10 @@ describe("GeneralSettingsPanel observability", () => {
],
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect.element(page.getByText(/Credential configured ยท Claude Max/)).toBeInTheDocument();
@@ -1779,10 +1796,10 @@ describe("GeneralSettingsPanel observability", () => {
],
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByLabelText("Toggle Claude details").click();
@@ -1839,10 +1856,10 @@ describe("GeneralSettingsPanel observability", () => {
providers: [createCodexProviderWithResetCredits()],
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByLabelText("Choose a reset credit for Codex usage").click();
@@ -1874,10 +1891,10 @@ describe("GeneralSettingsPanel observability", () => {
providers: [createClaudeProvider()],
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByLabelText("Toggle Claude details").click();
@@ -1916,10 +1933,10 @@ describe("GeneralSettingsPanel observability", () => {
providers: [createClaudeProvider()],
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByLabelText("Toggle Claude details").click();
@@ -1953,10 +1970,10 @@ describe("GeneralSettingsPanel observability", () => {
providers: [createOutdatedProvider("codex")],
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByRole("button", { name: "Update available โ view details" }).click();
@@ -1988,10 +2005,10 @@ describe("GeneralSettingsPanel observability", () => {
providers: [createMissingClaudeProvider({ canInstall: true })],
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
// The button replaces the manual recipe; the diagnosis sentence stays.
@@ -2037,10 +2054,10 @@ describe("GeneralSettingsPanel observability", () => {
],
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect.element(page.getByText("Installingโฆ added 1 package")).toBeVisible();
@@ -2065,10 +2082,10 @@ describe("GeneralSettingsPanel observability", () => {
providers: [createMissingClaudeProvider()],
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
// No derived install command: the full guide sentence and its link stay.
@@ -2099,10 +2116,10 @@ describe("GeneralSettingsPanel observability", () => {
providers: [createVerifiedNativeOutdatedClaudeProvider()],
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByRole("button", { name: "Update available โ view details" }).click();
@@ -2158,10 +2175,10 @@ describe("GeneralSettingsPanel observability", () => {
providers: [provider],
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByRole("button", { name: "Update available โ view details" }).click();
@@ -2192,10 +2209,10 @@ describe("GeneralSettingsPanel observability", () => {
providers: [createOutdatedProvider("codex", longUpdateCommand)],
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByRole("button", { name: "Update available โ view details" }).click();
@@ -2264,10 +2281,10 @@ describe("SourceControlSettingsPanel discovery states", () => {
it("shows skeleton sections while the first source control scan is pending", async () => {
setSourceControlDiscoveryStub(() => new Promise(() => {}));
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect.element(page.getByText("Version Control")).toBeInTheDocument();
@@ -2284,10 +2301,10 @@ describe("SourceControlSettingsPanel discovery states", () => {
sourceControlProviders: [],
}));
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect.element(page.getByText("Nothing detected yet")).toBeInTheDocument();
@@ -2318,10 +2335,10 @@ describe("SourceControlSettingsPanel discovery states", () => {
sourceControlProviders: [],
}));
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled();
@@ -2364,10 +2381,10 @@ describe("SourceControlSettingsPanel discovery states", () => {
],
}));
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect
@@ -2396,10 +2413,10 @@ describe("SourceControlSettingsPanel discovery states", () => {
sourceControlProviders: [],
}));
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
const toggle = page.getByRole("button", { name: "Toggle Git details" });
@@ -2437,10 +2454,10 @@ describe("SourceControlSettingsPanel discovery states", () => {
};
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled();
@@ -2451,10 +2468,10 @@ describe("SourceControlSettingsPanel discovery states", () => {
mounted = null;
document.body.innerHTML = "";
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled();
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index f3ba3f524..55ff539da 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -1,13 +1,15 @@
import {
ArchiveIcon,
ArchiveX,
+ ChevronRightIcon,
LoaderIcon,
PlusIcon,
RefreshCwIcon,
Trash2Icon,
} from "lucide-react";
-import { useQueryClient } from "@tanstack/react-query";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
+import { formatTokens, formatUsd } from "@threadlines/shared/usageFormat";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AUTO_ARCHIVE_INACTIVE_THREADS_DAY_OPTIONS,
@@ -19,6 +21,7 @@ import {
type ProviderInstanceConfig,
type ProviderInstanceId,
type ScopedThreadRef,
+ type UsageWindowDays,
} from "@threadlines/contracts";
import { scopeThreadRef } from "@threadlines/client-runtime";
import { DEFAULT_UNIFIED_SETTINGS } from "@threadlines/contracts/settings";
@@ -37,6 +40,11 @@ import { useSettings, useUpdateSettings } from "../../hooks/useSettings";
import { useThreadActions } from "../../hooks/useThreadActions";
import { readEnvironmentApi } from "../../environmentApi";
import { setDesktopUpdateStateQueryData } from "../../lib/desktopUpdateReactQuery";
+import {
+ deriveUsageWindow,
+ usageSummaryQueryOptions,
+ useUsageEnvironmentTargets,
+} from "../../lib/usageReactQuery";
import {
resolveDefaultTextGenerationBackupModelSelectionState,
resolveAppModelSelectionState,
@@ -133,6 +141,8 @@ const MAINTAINED_PROVIDER_DRIVER_KINDS = DRIVER_OPTIONS.map((definition) => defi
const INACTIVE_THREAD_ARCHIVE_COMMAND_DELAY_MS = 25;
const ARCHIVED_THREAD_DELETE_COMMAND_DELAY_MS = 25;
const DEFAULT_ARCHIVED_THREAD_DELETE_AGE_DAYS: ArchivedThreadDeleteAgeDays = 90;
+/** A month reads as "recently" without being a single noisy week. */
+const USAGE_SETTINGS_WINDOW_DAYS: UsageWindowDays = 30;
function waitForInactiveThreadArchiveCommandSlot(): Promise {
return new Promise((resolve) =>
@@ -1060,6 +1070,55 @@ export function GeneralSettingsPanel({ surface = "full" }: { surface?: "full" |
);
}
+/**
+ * The bridge from provider setup to what those providers have actually cost:
+ * a clickable tile in the same card family as the provider entries below it,
+ * named for its window so the figures cannot be mistaken for all-time totals.
+ */
+function ProviderUsageLinkRow() {
+ const targets = useUsageEnvironmentTargets();
+ // The same scan the usage page reads, narrowed here rather than re-fetched.
+ const usageQuery = useQuery(usageSummaryQueryOptions({ targets }));
+ const scan = usageQuery.data ?? null;
+ const merged = useMemo(
+ () => (scan ? deriveUsageWindow(scan, USAGE_SETTINGS_WINDOW_DAYS).merged : null),
+ [scan],
+ );
+
+ return (
+
+
+
+
+ Usage ยท Last {USAGE_SETTINGS_WINDOW_DAYS} days
+
+ {/* One typeface AND one color: mixing brightness on a line of small
+ mono type erodes the dim glyphs' anti-aliased bottom edge, so the
+ muted words read a pixel higher than the numbers beside them. The
+ caps label above carries the hierarchy instead. */}
+ {merged ? (
+
+ {formatTokens(merged.totalTokens)} tokens ยท {formatUsd(merged.costUsd)} API-equivalent
+
+ ) : (
+ Reading provider transcriptsโฆ
+ )}
+
+ {/* Bottom-aligned, not centered: the affordance sits level with the
+ figures line rather than floating between the two left rows. */}
+
+ View usage
+
+
+
+
+ );
+}
+
export function ProviderSettingsPanel({
focusedInstanceId = null,
}: {
@@ -1387,6 +1446,7 @@ export function ProviderSettingsPanel({
Account, usage, and configuration apply to the paired computer. Favorites and model
ordering are saved on this device.
+
{rows.map((row) => {
const driverOption = getDriverOption(row.driver);
const liveProvider = serverProviders.find(
diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx
index 059cc3fc5..a97af5bd3 100644
--- a/apps/web/src/components/settings/SettingsSidebarNav.tsx
+++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx
@@ -11,6 +11,7 @@ import {
SidebarMenuItem,
SidebarSeparator,
} from "../ui/sidebar";
+import { SidebarUsageMeter } from "../sidebar/SidebarUsageMeter";
import { SidebarVersionTag } from "../sidebar/SidebarVersionTag";
import { isHostedStaticApp } from "../../hostedPairing";
import {
@@ -87,6 +88,11 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
+ {/* The same meter the main sidebar keeps above Settings, so the
+ bottom-left corner means "today's usage" on both surfaces. */}
+
+
+
{
+ if (isMobile) {
+ setOpenMobile(false);
+ }
+ void navigate({ to: "/usage" });
+ }, [isMobile, navigate, setOpenMobile]);
+
+ return (
+
+
+ Usage
+ {totalTokens === null ? null : (
+
+ {formatTokensCompact(totalTokens)} today
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/usage/UsageView.browser.tsx b/apps/web/src/components/usage/UsageView.browser.tsx
new file mode 100644
index 000000000..2f8c80948
--- /dev/null
+++ b/apps/web/src/components/usage/UsageView.browser.tsx
@@ -0,0 +1,400 @@
+import "../../index.css";
+
+import {
+ EnvironmentId,
+ USAGE_CONTRACT_VERSION,
+ type UsageBucket,
+ type UsageDay,
+ type UsageProviderKind,
+ type UsageSummary,
+ type UsageSummaryInput,
+} from "@threadlines/contracts";
+import { enumerateDays } from "@threadlines/shared/usageFormat";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ RouterProvider,
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+} from "@tanstack/react-router";
+import type { ReactNode } from "react";
+import { page } from "vite-plus/test/browser";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+import { render } from "vitest-browser-react";
+
+import {
+ __resetEnvironmentApiOverridesForTests,
+ __setEnvironmentApiOverrideForTests,
+} from "../../environmentApi";
+import {
+ resetPrimaryEnvironmentDescriptorForTests,
+ writePrimaryEnvironmentDescriptor,
+} from "../../environments/primary";
+import {
+ resetSavedEnvironmentRegistryStoreForTests,
+ useSavedEnvironmentRegistryStore,
+} from "../../environments/runtime";
+import { SidebarProvider } from "../ui/sidebar";
+import { SidebarUsageMeter } from "../sidebar/SidebarUsageMeter";
+import { UsageView } from "./UsageView";
+
+const REPORTING_ENVIRONMENT_ID = EnvironmentId.make("env-studio");
+const OFFLINE_ENVIRONMENT_ID = EnvironmentId.make("env-laptop");
+
+function tokens(total: number): UsageBucket["totals"] {
+ return {
+ uncachedInputTokens: Math.round(total / 2),
+ cachedInputTokens: Math.round(total / 2),
+ cacheCreationTokens: 0,
+ outputTokens: 0,
+ reasoningTokens: 0,
+ };
+}
+
+function bucket(input: {
+ readonly day: string;
+ readonly provider: UsageProviderKind;
+ readonly model: string;
+ readonly costUsd: number;
+ readonly totalTokens: number;
+ readonly cacheSavingsUsd?: number;
+}): UsageBucket {
+ return {
+ day: input.day as UsageDay,
+ provider: input.provider,
+ model: input.model,
+ totals: tokens(input.totalTokens),
+ costUsd: input.costUsd,
+ cacheSavingsUsd: input.cacheSavingsUsd ?? 1.5,
+ costSource: "modelPriced",
+ records: 4,
+ unpricedRecords: 0,
+ sessions: 2,
+ };
+}
+
+function source(
+ provider: UsageProviderKind,
+ resolvedHomePath: string,
+): UsageSummary["sources"][number] {
+ return {
+ fingerprint: {
+ hostId: "studio",
+ provider,
+ resolvedHomePath,
+ volumeId: `1:${provider}`,
+ },
+ status: "ok",
+ lastScannedAt: new Date().toISOString(),
+ scannedFiles: 12,
+ skippedFiles: 0,
+ distinctSessions: 3,
+ message: null,
+ };
+}
+
+function summaryFor(
+ input: UsageSummaryInput,
+ buckets: (days: readonly string[]) => readonly UsageBucket[],
+): UsageSummary {
+ return {
+ contractVersion: USAGE_CONTRACT_VERSION,
+ readAt: new Date().toISOString(),
+ timeZone: input.timeZone,
+ sinceDay: input.sinceDay,
+ untilDay: input.untilDay,
+ buckets: buckets(enumerateDays(input.sinceDay, input.untilDay)),
+ sources: [source("claude", "/Users/dev/.claude"), source("codex", "/Users/dev/.codex")],
+ pricing: {
+ status: "cached",
+ source: "litellm",
+ fetchedAt: new Date().toISOString(),
+ knownModels: 400,
+ },
+ scanDurationMs: 42,
+ };
+}
+
+function registerEnvironments(summary: (input: UsageSummaryInput) => Promise): void {
+ writePrimaryEnvironmentDescriptor({
+ environmentId: REPORTING_ENVIRONMENT_ID,
+ label: "Studio Mac",
+ platform: { os: "darwin", arch: "arm64" },
+ serverVersion: "0.1.0",
+ capabilities: { repositoryIdentity: false },
+ });
+ // A saved backend that is not connected: the page must name it rather than
+ // quietly leaving it out of the totals.
+ useSavedEnvironmentRegistryStore.setState({
+ byId: {
+ [OFFLINE_ENVIRONMENT_ID]: {
+ environmentId: OFFLINE_ENVIRONMENT_ID,
+ label: "Laptop",
+ wsBaseUrl: "ws://laptop.local",
+ httpBaseUrl: "http://laptop.local",
+ createdAt: new Date().toISOString(),
+ lastConnectedAt: null,
+ },
+ },
+ });
+ __setEnvironmentApiOverrideForTests(REPORTING_ENVIRONMENT_ID, {
+ usage: { summary },
+ } as never);
+}
+
+function renderWithProviders(children: ReactNode) {
+ const rootRoute = createRootRoute({ component: () => children });
+ const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/" });
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute]),
+ history: createMemoryHistory({ initialEntries: ["/"] }),
+ });
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ return render(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ resetPrimaryEnvironmentDescriptorForTests();
+ resetSavedEnvironmentRegistryStoreForTests();
+ __resetEnvironmentApiOverridesForTests();
+});
+
+afterEach(() => {
+ resetPrimaryEnvironmentDescriptorForTests();
+ resetSavedEnvironmentRegistryStoreForTests();
+ __resetEnvironmentApiOverridesForTests();
+});
+
+describe("UsageView", () => {
+ it("renders the hero, the stat band, the priced models, and the silent machine", async () => {
+ const summary = vi.fn(async (input: UsageSummaryInput) =>
+ summaryFor(input, (days) => {
+ const day = days[days.length - 1] ?? input.untilDay;
+ return [
+ bucket({
+ day,
+ provider: "claude",
+ model: "claude-fable-5",
+ costUsd: 12.5,
+ totalTokens: 2_000_000,
+ }),
+ bucket({
+ day,
+ provider: "codex",
+ model: "gpt-5.6-sol",
+ costUsd: 4.5,
+ totalTokens: 400_000,
+ }),
+ // Placeholder rows carry records but no tokens and no cost; the table
+ // has nothing to say about them.
+ bucket({
+ day,
+ provider: "codex",
+ model: "",
+ costUsd: 0,
+ totalTokens: 0,
+ cacheSavingsUsd: 0,
+ }),
+ ];
+ }),
+ );
+ registerEnvironments(summary);
+
+ renderWithProviders( );
+
+ // The one display-size figure on the page, asterisked to its footnote.
+ await expect.element(page.getByTestId("usage-total-cost")).toHaveTextContent("$17.00*");
+ await expect
+ .element(page.getByText("* if billed at full API rates. Subscription plans bill separately."))
+ .toBeInTheDocument();
+
+ const providerRows = page.getByTestId("usage-provider-row").elements();
+ expect(providerRows).toHaveLength(2);
+ expect(providerRows[0]?.textContent).toContain("Claude Code");
+ expect(providerRows[0]?.textContent).toContain("$12.50");
+ expect(providerRows[0]?.textContent).toContain("73.5% of cost ยท 2M tokens");
+
+ const stats = page.getByTestId("usage-stat").elements();
+ expect(stats.map((stat) => stat.textContent)).toEqual([
+ // Compact figures here: the stat band is one-off numbers, not a column.
+ "Processed tokens2.4M2.4M per active day",
+ "Cached input1.2M50.0% of observed input",
+ "Uncached input1.2M0 cache writes",
+ "Output0",
+ "Cache savings$3.000.2x the API-equivalent cost",
+ ]);
+
+ const modelRows = page.getByTestId("usage-model-row").elements();
+ expect(modelRows).toHaveLength(2);
+ // Sorted by cost, so the expensive model leads.
+ expect(modelRows[0]?.textContent).toContain("claude-fable-5");
+ expect(modelRows[1]?.textContent).toContain("gpt-5.6-sol");
+
+ const machineRows = page.getByTestId("usage-machine-row").elements();
+ expect(machineRows).toHaveLength(2);
+ const laptopRow = machineRows.find((row) => row.textContent?.includes("Laptop"));
+ expect(laptopRow?.textContent).toContain("Not reporting");
+ const studioRow = machineRows.find((row) => row.textContent?.includes("Studio Mac"));
+ expect(studioRow?.textContent).toContain("/Users/dev/.claude");
+
+ // Hovering a day raises the tracking card: both providers, then the total.
+ await page.getByTestId("usage-chart-day").nth(29).hover();
+ const card = page.getByTestId("usage-chart-card");
+ await expect.element(card).toBeVisible();
+ const cardText = card.element().textContent ?? "";
+ expect(cardText).toContain("Claude Code");
+ expect(cardText).toContain("$12.50");
+ expect(cardText).toContain("Codex");
+ expect(cardText).toContain("$4.50");
+ expect(cardText).toContain("Total");
+ expect(cardText).toContain("$17.00");
+ });
+
+ it("switches the hero to tokens with the chart mode and the breakdown to days", async () => {
+ const summary = vi.fn(async (input: UsageSummaryInput) =>
+ summaryFor(input, (days) => {
+ const day = days[days.length - 1] ?? input.untilDay;
+ return [
+ bucket({
+ day,
+ provider: "claude",
+ model: "claude-fable-5",
+ costUsd: 12.5,
+ totalTokens: 2_000_000,
+ }),
+ bucket({
+ day,
+ provider: "codex",
+ model: "gpt-5.6-sol",
+ costUsd: 4.5,
+ totalTokens: 400_000,
+ }),
+ ];
+ }),
+ );
+ registerEnvironments(summary);
+
+ renderWithProviders( );
+ await expect.element(page.getByTestId("usage-total-cost")).toHaveTextContent("$17.00*");
+
+ await page.getByTestId("usage-chart-mode-tokens").click();
+
+ // The whole hero follows the toggle: headline figure, label, provider rows.
+ // The cost asterisk and its footnote only make sense against dollars.
+ await expect.element(page.getByTestId("usage-total-cost")).toHaveTextContent("2.4M");
+ await expect.element(page.getByText("Processed tokens").first()).toBeInTheDocument();
+ expect(
+ page
+ .getByText("* if billed at full API rates. Subscription plans bill separately.")
+ .elements(),
+ ).toHaveLength(0);
+ const providerRows = page.getByTestId("usage-provider-row").elements();
+ expect(providerRows[0]?.textContent).toContain("2M");
+ expect(providerRows[0]?.textContent).toContain("83.3% of tokens ยท $12.50");
+
+ await page.getByTestId("usage-breakdown-days").click();
+
+ // Only the day with activity earns a row, and it carries both measures.
+ const dayRows = page.getByTestId("usage-day-row").elements();
+ expect(dayRows).toHaveLength(1);
+ expect(dayRows[0]?.textContent).toContain("$17.00");
+ expect(page.getByTestId("usage-model-row").elements()).toHaveLength(0);
+
+ await page.getByTestId("usage-breakdown-models").click();
+ expect(page.getByTestId("usage-model-row").elements()).toHaveLength(2);
+ });
+
+ it("opens on 30 days and switches windows without another scan", async () => {
+ const summary = vi.fn(async (input: UsageSummaryInput) =>
+ summaryFor(input, (days) =>
+ days.map((day) =>
+ bucket({
+ day,
+ provider: "claude",
+ model: "claude-fable-5",
+ costUsd: 1,
+ totalTokens: 1_000,
+ }),
+ ),
+ ),
+ );
+ registerEnvironments(summary);
+
+ renderWithProviders( );
+
+ await vi.waitFor(() => {
+ expect(page.getByTestId("usage-chart-day").elements()).toHaveLength(30);
+ });
+ // One scan, and it covers the longest window the selector offers.
+ expect(summary).toHaveBeenCalledTimes(1);
+ expect(windowLengthOf(summary.mock.calls[0]?.[0])).toBe(90);
+
+ await page.getByTestId("usage-window-7").click();
+ await vi.waitFor(() => {
+ expect(page.getByTestId("usage-chart-day").elements()).toHaveLength(7);
+ });
+
+ await page.getByTestId("usage-window-90").click();
+ await vi.waitFor(() => {
+ expect(page.getByTestId("usage-chart-day").elements()).toHaveLength(90);
+ });
+
+ // Narrowing and widening are arithmetic on the scan already in hand.
+ expect(summary).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("SidebarUsageMeter", () => {
+ it("names itself and carries today's compact total once it arrives", async () => {
+ const summary = vi.fn(async (input: UsageSummaryInput) =>
+ summaryFor(input, (days) =>
+ days.map((day) =>
+ bucket({
+ day,
+ provider: "claude",
+ model: "claude-fable-5",
+ costUsd: 3,
+ totalTokens: 2_400_000,
+ }),
+ ),
+ ),
+ );
+ registerEnvironments(summary);
+
+ renderWithProviders(
+
+
+ ,
+ );
+
+ // Compact form: trailing zeros are table alignment, not chip copy.
+ await expect
+ .element(page.getByTestId("sidebar-usage-meter"))
+ .toHaveTextContent("Usage2.4M today");
+ });
+
+ it("stays the plain label when no environment answers", async () => {
+ renderWithProviders(
+
+
+ ,
+ );
+
+ await expect.element(page.getByTestId("sidebar-usage-meter")).toHaveTextContent("Usage");
+ });
+});
+
+function windowLengthOf(input: UsageSummaryInput | undefined): number {
+ if (!input) return 0;
+ const since = Date.parse(`${input.sinceDay}T00:00:00Z`);
+ const until = Date.parse(`${input.untilDay}T00:00:00Z`);
+ return Math.round((until - since) / 86_400_000) + 1;
+}
diff --git a/apps/web/src/components/usage/UsageView.tsx b/apps/web/src/components/usage/UsageView.tsx
new file mode 100644
index 000000000..34c042d9a
--- /dev/null
+++ b/apps/web/src/components/usage/UsageView.tsx
@@ -0,0 +1,645 @@
+import {
+ USAGE_WINDOW_DAY_OPTIONS,
+ type UsageProviderKind,
+ type UsageWindowDays,
+} from "@threadlines/contracts";
+import {
+ formatCount,
+ formatTokens,
+ formatTokensCompact,
+ formatUsd,
+ formatPercent,
+} from "@threadlines/shared/usageFormat";
+import { useQuery } from "@tanstack/react-query";
+import { RotateCwIcon } from "lucide-react";
+import { useMemo, useState } from "react";
+
+import { useRelativeTimeTick } from "../../hooks/useRelativeTimeTick";
+import { cn } from "../../lib/utils";
+import {
+ deriveUsageWindow,
+ usageSummaryQueryOptions,
+ useUsageEnvironmentTargets,
+} from "../../lib/usageReactQuery";
+import { formatRelativeTimeLabel } from "../../timestampFormat";
+import { ClaudeAI, OpenAI, type Icon } from "../Icons";
+import {
+ buildUsageAreaChart,
+ buildUsageDayRows,
+ buildUsageMachineRows,
+ buildUsageStats,
+ formatCostQualityFootnote,
+ formatUsageDateRange,
+ USAGE_BREAKDOWN_LABELS,
+ USAGE_BREAKDOWNS,
+ USAGE_CHART_MODE_LABELS,
+ USAGE_CHART_MODES,
+ USAGE_CHART_TITLES,
+ USAGE_HERO_LABELS,
+ USAGE_MACHINE_STATE_LABELS,
+ USAGE_PROVIDER_COLORS,
+ USAGE_PROVIDER_LABELS,
+ USAGE_PROVIDER_READING_ORDER,
+ USAGE_PROVIDER_SHORT_LABELS,
+ visibleUsageModels,
+ type UsageAreaChart,
+ type UsageBreakdown,
+ type UsageChartMode,
+ type UsageMachineRow,
+ type UsageStat,
+} from "./usageView.logic";
+
+const SECTION_LABEL_CLASS =
+ "font-mono text-[10px] uppercase tracking-wider text-muted-foreground/55 select-none";
+const NUMBER_CLASS = "font-mono tabular-nums";
+/** Scan freshness is a relative label, so it needs a slow clock of its own. */
+const FRESHNESS_TICK_MS = 30_000;
+/** The window a first-time reader gets: a week is too short to see a pattern. */
+const DEFAULT_WINDOW_DAYS: UsageWindowDays = 30;
+
+/** The same glyphs the model picker draws, keyed by the usage contract's names. */
+const USAGE_PROVIDER_ICONS: Record = {
+ claude: ClaudeAI,
+ codex: OpenAI,
+};
+
+/**
+ * What the provider CLIs' own transcripts say this account has spent, merged
+ * across every computer Threadlines knows about.
+ *
+ * The figures are API list prices for the tokens, never billed spend, and the
+ * page says so under the headline: a subscription bills on its own terms and the
+ * transcripts carry no invoice.
+ *
+ * One scan covers the longest window on offer and the selector narrows it here,
+ * so switching between 7, 30 and 90 days is a recompute rather than a wait.
+ */
+export function UsageView() {
+ const [windowDays, setWindowDays] = useState(DEFAULT_WINDOW_DAYS);
+ const [chartMode, setChartMode] = useState("cost");
+ const [breakdown, setBreakdown] = useState("models");
+ // One slow clock for the whole page: scan freshness is the only honest signal
+ // about a warm scan, and it must not go stale on screen.
+ useRelativeTimeTick(FRESHNESS_TICK_MS);
+ const targets = useUsageEnvironmentTargets();
+ const usageQuery = useQuery(usageSummaryQueryOptions({ targets }));
+ const scan = usageQuery.data ?? null;
+
+ const view = useMemo(
+ () => (scan ? deriveUsageWindow(scan, windowDays) : null),
+ [scan, windowDays],
+ );
+ const merged = view?.merged ?? null;
+
+ const chart = useMemo(
+ () =>
+ view
+ ? buildUsageAreaChart({
+ daily: view.merged.daily,
+ sinceDay: view.window.sinceDay,
+ untilDay: view.window.untilDay,
+ mode: chartMode,
+ })
+ : null,
+ [chartMode, view],
+ );
+ // The machines answered for the whole scan, not for the selected window, so
+ // they are read from the scan and never move when the selector does.
+ const machines = useMemo(
+ () =>
+ scan
+ ? buildUsageMachineRows({
+ environments: scan.environments,
+ staleEnvironments: scan.merged.staleEnvironments,
+ })
+ : [],
+ [scan],
+ );
+ const models = useMemo(() => (merged ? visibleUsageModels(merged) : []), [merged]);
+ const dayRows = useMemo(() => (merged ? buildUsageDayRows(merged) : []), [merged]);
+ const stats = useMemo(() => (merged ? buildUsageStats(merged) : []), [merged]);
+
+ return (
+
+ {/* Wraps as a whole row on narrow screens; the range never breaks internally. */}
+
+
Usage
+ {view ? (
+
+ {formatUsageDateRange(view.window.sinceDay, view.window.untilDay)}
+
+ ) : null}
+
+
+ {USAGE_WINDOW_DAY_OPTIONS.map((option) => (
+ setWindowDays(option)}
+ >
+ {option}d
+
+ ))}
+
+
void usageQuery.refetch()}
+ >
+
+ Refresh
+
+
+
+ {targets.length === 0 ? (
+
+ No computers are connected, so there is nothing to report yet.
+
+ ) : !merged || !view || !chart ? (
+
+ {usageQuery.isError ? "Usage could not be read." : "Reading provider transcriptsโฆ"}
+
+ ) : (
+ <>
+
+
+
{USAGE_HERO_LABELS[chartMode]}
+
+ {chartMode === "cost" ? (
+ <>
+ {formatUsd(merged.costUsd)}
+
+ *
+
+ >
+ ) : (
+ formatTokensCompact(merged.totalTokens)
+ )}
+
+ {/* The asterisk and its footnote are the cost figure's caveat; a
+ token count needs no disclaimer. */}
+ {chartMode === "cost" ? (
+
+ * if billed at full API rates. Subscription plans bill separately.
+
+ ) : null}
+
+ {USAGE_PROVIDER_READING_ORDER.flatMap((provider) => {
+ const totals = merged.providers.find((entry) => entry.provider === provider);
+ return totals
+ ? [ ]
+ : [];
+ })}
+
+
+
+
+
+
+
+ {stats.map((stat) => (
+
+ ))}
+
+
+
+
+
+ {breakdown === "models" ? "Models" : "Days"} ยท {formatCount(merged.sessions)}{" "}
+ sessions
+
+
+
+ {USAGE_BREAKDOWNS.map((option, index) => (
+
+ {index > 0 ? | : null}
+ setBreakdown(option)}
+ >
+ {USAGE_BREAKDOWN_LABELS[option]}
+
+
+ ))}
+
+
+ {breakdown === "days" ? (
+ dayRows.length === 0 ? (
+
+ No recorded activity in this window.
+
+ ) : (
+
+ {dayRows.map((row) => (
+
+
+ {row.label}
+
+
+ {formatTokens(row.totalTokens)}
+
+
+ {formatUsd(row.costUsd)}
+
+
+ {formatPercent(row.costShare, 0)}
+
+
+ ))}
+
+ )
+ ) : models.length === 0 ? (
+
+ No recorded activity in this window.
+
+ ) : (
+
+ {models.map((model) => {
+ const ProviderIcon = USAGE_PROVIDER_ICONS[model.provider];
+ return (
+
+
+
+
+ {model.model}
+
+
+
+ {formatTokens(model.totalTokens)}
+
+
+ {formatUsd(model.costUsd)}
+
+ {/* Share is the first column to go on a phone: the model
+ name is the row's point, and share is derivable from
+ cost at a glance. */}
+
+ {formatPercent(model.costShare, 0)}
+
+
+ );
+ })}
+
+ )}
+
+
+
+ Machines
+
+ {machines.map((machine) => (
+
+ ))}
+
+ {merged.duplicateSources.length > 0 ? (
+
+ Counted once: {merged.duplicateSources.join(", ")} is the same folder another
+ computer already reported.
+
+ ) : null}
+
+
+
+ {formatCostQualityFootnote(merged.costQuality)}
+
+ >
+ )}
+
+ );
+}
+
+/**
+ * Provider name, its slice of the selected measure, and how wide that slice is.
+ * The row follows the page's cost|tokens mode: the leading figure and the bar
+ * show the selected measure, and the sub-line carries the other one.
+ */
+function UsageProviderRow({
+ totals,
+ mode,
+}: {
+ readonly totals: {
+ readonly provider: UsageProviderKind;
+ readonly costUsd: number;
+ readonly costShare: number;
+ readonly totalTokens: number;
+ readonly tokenShare: number;
+ };
+ readonly mode: UsageChartMode;
+}) {
+ const ProviderIcon = USAGE_PROVIDER_ICONS[totals.provider];
+ const share = mode === "cost" ? totals.costShare : totals.tokenShare;
+ return (
+
+
+
+
+ {USAGE_PROVIDER_LABELS[totals.provider]}
+
+
+ {mode === "cost" ? formatUsd(totals.costUsd) : formatTokensCompact(totals.totalTokens)}
+
+
+
+
+ {mode === "cost"
+ ? `${formatPercent(totals.costShare)} of cost ยท ${formatTokensCompact(totals.totalTokens)} tokens`
+ : `${formatPercent(totals.tokenShare)} of tokens ยท ${formatUsd(totals.costUsd)}`}
+
+
+ );
+}
+
+/**
+ * Overlapping areas, one per provider, drawn from the zero baseline.
+ *
+ * The SVG carries only the curves: gridlines, labels and the hover targets are
+ * HTML, so nothing has to survive the horizontal stretch that lets the plot fill
+ * whatever width the hero gives it.
+ */
+function UsageChart({
+ chart,
+ mode,
+ onModeChange,
+}: {
+ readonly chart: UsageAreaChart;
+ readonly mode: UsageChartMode;
+ readonly onModeChange: (mode: UsageChartMode) => void;
+}) {
+ // The hovered day, held as an index so a window switch under the cursor
+ // cannot leave the card describing a day the chart no longer shows.
+ const [hoveredIndex, setHoveredIndex] = useState(null);
+ const hoveredColumn = hoveredIndex === null ? null : (chart.columns[hoveredIndex] ?? null);
+ const hoveredCenterPercent =
+ hoveredIndex === null || chart.columns.length === 0
+ ? 0
+ : ((hoveredIndex + 0.5) / chart.columns.length) * 100;
+ // The card sits on whichever side has room, flipping past the midline.
+ const hoveredOnLeftHalf = hoveredCenterPercent <= 50;
+ return (
+
+
+
{USAGE_CHART_TITLES[mode]}
+
+
+ {USAGE_CHART_MODES.map((option, index) => (
+
+ {index > 0 ? | : null}
+ onModeChange(option)}
+ >
+ {USAGE_CHART_MODE_LABELS[option]}
+
+
+ ))}
+
+
+ {USAGE_PROVIDER_READING_ORDER.map((provider) => {
+ const ProviderIcon = USAGE_PROVIDER_ICONS[provider];
+ return (
+
+ {/* The glyphs' built-in brand fills already match the series
+ hues, so they replace the swatch dots without a legend key
+ being lost. */}
+
+ {USAGE_PROVIDER_LABELS[provider]}
+
+ );
+ })}
+
+
+
+
+ {chart.gridlines.map((gridline) => (
+
+
+ {gridline.label}
+
+
+ ))}
+
+ {chart.series.map((series) => (
+
+
+
+
+ ))}
+
+ {/* Transparent per-day columns drive the tracking line and the card. */}
+
setHoveredIndex(null)}>
+ {chart.columns.map((column, index) => (
+
setHoveredIndex(index)}
+ />
+ ))}
+
+ {hoveredColumn ? (
+ <>
+
+
+
{hoveredColumn.label}
+
+ {hoveredColumn.entries.map((entry) => {
+ const ProviderIcon = USAGE_PROVIDER_ICONS[entry.provider];
+ return (
+
+
+
+ {USAGE_PROVIDER_LABELS[entry.provider]}
+
+
+ {entry.valueLabel}
+
+
+ );
+ })}
+
+
+ Total
+
+ {hoveredColumn.totalLabel}
+
+
+
+ >
+ ) : null}
+ {chart.isEmpty ? (
+
+ No recorded activity in this window.
+
+ ) : null}
+
+
+ {chart.axisLabels.map((label) => (
+
+ {label}
+
+ ))}
+
+
+ );
+}
+
+function UsageStatCell({ stat }: { readonly stat: UsageStat }) {
+ return (
+
+ {stat.label}
+
+ {stat.value}
+
+ {stat.context ? (
+ // Wraps on a phone, truncates on desktop where the band is one row and
+ // a two-line cell would stagger its neighbours.
+ {stat.context}
+ ) : null}
+
+ );
+}
+
+function UsageMachineRowView({ machine }: { readonly machine: UsageMachineRow }) {
+ return (
+
+
+ {machine.label}
+
+ {USAGE_MACHINE_STATE_LABELS[machine.state]}
+
+
+ {machine.detail ? (
+ {machine.detail}
+ ) : null}
+ {machine.sources.map((source) => (
+
+
+ {USAGE_PROVIDER_SHORT_LABELS[source.fingerprint.provider]}
+
+
+ {source.fingerprint.resolvedHomePath}
+
+
+ {source.status === "missing"
+ ? "not found"
+ : `scanned ${formatRelativeTimeLabel(source.lastScannedAt)}`}
+
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/components/usage/usageView.logic.ts b/apps/web/src/components/usage/usageView.logic.ts
new file mode 100644
index 000000000..c11d9a9b8
--- /dev/null
+++ b/apps/web/src/components/usage/usageView.logic.ts
@@ -0,0 +1,449 @@
+import type { UsageProviderKind, UsageSource } from "@threadlines/contracts";
+import {
+ enumerateDays,
+ formatDayShort,
+ formatPercent,
+ formatTokens,
+ formatTokensCompact,
+ formatUsd,
+} from "@threadlines/shared/usageFormat";
+import type { CostQuality, DailyTotals, MergedUsage } from "@threadlines/shared/usageMerge";
+
+import type { UsageEnvironmentReport } from "~/lib/usageReactQuery";
+
+/** Product names, as the providers write them. */
+export const USAGE_PROVIDER_LABELS: Record
= {
+ claude: "Claude Code",
+ codex: "Codex",
+};
+
+/** For the narrow columns where the product name would not fit. */
+export const USAGE_PROVIDER_SHORT_LABELS: Record = {
+ claude: "Claude",
+ codex: "Codex",
+};
+
+/**
+ * The only brand hues in the app, defined in `index.css` for both themes.
+ *
+ * Held as CSS variables rather than utility classes because the chart paints
+ * them into SVG fill/stroke attributes as well as into HTML.
+ */
+export const USAGE_PROVIDER_COLORS: Record = {
+ claude: "var(--provider-claude)",
+ codex: "var(--provider-codex)",
+};
+
+/**
+ * Paint order, not stacking order: the series overlap, and Claude is usually the
+ * larger of the two, so it draws last and keeps its outline unbroken.
+ */
+export const USAGE_PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"];
+
+/** Reading order for legends and rows: the same order every time. */
+export const USAGE_PROVIDER_READING_ORDER: readonly UsageProviderKind[] = ["claude", "codex"];
+
+export type UsageChartMode = "cost" | "tokens";
+
+export const USAGE_CHART_MODES: readonly UsageChartMode[] = ["cost", "tokens"];
+
+export const USAGE_CHART_MODE_LABELS: Record = {
+ cost: "Cost",
+ tokens: "Tokens",
+};
+
+export const USAGE_CHART_TITLES: Record = {
+ cost: "Daily cost",
+ tokens: "Daily tokens",
+};
+
+/** The hero follows the chart's cost|tokens mode; these are its labels. */
+export const USAGE_HERO_LABELS: Record = {
+ cost: "API-equivalent cost",
+ tokens: "Processed tokens",
+};
+
+export type UsageBreakdown = "models" | "days";
+
+export const USAGE_BREAKDOWNS: readonly UsageBreakdown[] = ["models", "days"];
+
+export const USAGE_BREAKDOWN_LABELS: Record = {
+ models: "Model",
+ days: "Day",
+};
+
+export interface UsageDayRow {
+ readonly day: string;
+ readonly label: string;
+ readonly totalTokens: number;
+ readonly costUsd: number;
+ readonly costShare: number;
+}
+
+/**
+ * The days breakdown, newest first. Days without activity are dropped: unlike
+ * the chart, where a gap is information, a table of zero rows buries the days
+ * that had something to say.
+ */
+export function buildUsageDayRows(merged: MergedUsage): readonly UsageDayRow[] {
+ return merged.daily
+ .filter((entry) => entry.totalTokens > 0 || entry.costUsd > 0)
+ .map((entry) => ({
+ day: entry.day,
+ label: formatDayShort(entry.day),
+ totalTokens: entry.totalTokens,
+ costUsd: entry.costUsd,
+ costShare: merged.costUsd === 0 ? 0 : entry.costUsd / merged.costUsd,
+ }))
+ .toReversed();
+}
+
+/**
+ * Chart user units. Height matches the rendered pixel height so vertical
+ * geometry is 1:1; width is nominal, since the SVG stretches horizontally to
+ * whatever the hero column gives it.
+ */
+const CHART_WIDTH = 1000;
+const CHART_HEIGHT = 200;
+const GRIDLINE_COUNT = 4;
+
+export interface UsageChartSeries {
+ readonly provider: UsageProviderKind;
+ readonly color: string;
+ /** Closed shape from the zero baseline, for the translucent fill. */
+ readonly areaPath: string;
+ /** The same curve without the baseline, for the stroke on top. */
+ readonly linePath: string;
+}
+
+export interface UsageChartGridline {
+ readonly value: number;
+ readonly label: string;
+ /** Distance from the top of the plot, as a percentage. */
+ readonly topPercent: number;
+}
+
+export interface UsageChartColumnEntry {
+ readonly provider: UsageProviderKind;
+ /** Formatted in the chart's mode, so the card needs no arithmetic. */
+ readonly valueLabel: string;
+}
+
+/** One hoverable day: everything the tracking card says about it. */
+export interface UsageChartColumn {
+ readonly day: string;
+ readonly label: string;
+ readonly entries: readonly UsageChartColumnEntry[];
+ readonly totalLabel: string;
+ readonly hasActivity: boolean;
+}
+
+export interface UsageAreaChart {
+ readonly viewBoxWidth: number;
+ readonly viewBoxHeight: number;
+ readonly series: readonly UsageChartSeries[];
+ readonly gridlines: readonly UsageChartGridline[];
+ /** Window start, midpoint and end. Three ticks, whatever the window length. */
+ readonly axisLabels: readonly string[];
+ readonly columns: readonly UsageChartColumn[];
+ readonly isEmpty: boolean;
+}
+
+function chartValue(
+ totals: { readonly costUsd: number; readonly totalTokens: number } | undefined,
+ mode: UsageChartMode,
+): number {
+ if (!totals) return 0;
+ return mode === "cost" ? totals.costUsd : totals.totalTokens;
+}
+
+/**
+ * One smooth area per provider across every calendar day of the window,
+ * including the days with no activity โ gaps are information, and dropping them
+ * would compress the axis into a lie about when the work happened.
+ */
+export function buildUsageAreaChart(input: {
+ readonly daily: readonly DailyTotals[];
+ readonly sinceDay: string;
+ readonly untilDay: string;
+ readonly mode: UsageChartMode;
+}): UsageAreaChart {
+ const byDay = new Map(input.daily.map((entry) => [entry.day, entry]));
+ const days = enumerateDays(input.sinceDay, input.untilDay);
+
+ const valuesByProvider = new Map(
+ USAGE_PROVIDER_ORDER.map((provider) => [
+ provider,
+ days.map((day) => chartValue(byDay.get(day)?.byProvider.get(provider), input.mode)),
+ ]),
+ );
+
+ const peak = [...valuesByProvider.values()]
+ .flat()
+ .reduce((highest, value) => Math.max(highest, value), 0);
+ const axisMax = niceAxisMax(peak);
+
+ const series: UsageChartSeries[] = [];
+ for (const provider of USAGE_PROVIDER_ORDER) {
+ const values = valuesByProvider.get(provider) ?? [];
+ if (values.every((value) => value === 0)) continue;
+ const points = monotonePoints(values, axisMax);
+ series.push({
+ provider,
+ color: USAGE_PROVIDER_COLORS[provider],
+ areaPath: `${points} L ${CHART_WIDTH} ${CHART_HEIGHT} L 0 ${CHART_HEIGHT} Z`,
+ linePath: points,
+ });
+ }
+
+ return {
+ viewBoxWidth: CHART_WIDTH,
+ viewBoxHeight: CHART_HEIGHT,
+ series,
+ gridlines: axisMax === 0 ? [] : buildGridlines(axisMax, input.mode),
+ axisLabels: axisTickDays(days).map((day) => formatDayShort(day).toUpperCase()),
+ columns: days.map((day) => {
+ const entry = byDay.get(day);
+ const formatValue = input.mode === "cost" ? formatUsd : formatTokensCompact;
+ return {
+ day,
+ label: formatDayShort(day),
+ // Every charted provider gets a row, zeros included: the card answers
+ // "who was quiet that day" as much as "who was busy". Reading order,
+ // not paint order, so the card matches the legend and the hero.
+ entries: USAGE_PROVIDER_READING_ORDER.filter((provider) =>
+ series.some((line) => line.provider === provider),
+ ).map((provider) => ({
+ provider,
+ valueLabel: formatValue(chartValue(entry?.byProvider.get(provider), input.mode)),
+ })),
+ totalLabel: formatValue(chartValue(entry, input.mode)),
+ hasActivity: (entry?.totalTokens ?? 0) > 0,
+ };
+ }),
+ isEmpty: series.length === 0,
+ };
+}
+
+/** Start, middle and end, de-duplicated for windows too short to have three. */
+function axisTickDays(days: readonly string[]): readonly string[] {
+ if (days.length === 0) return [];
+ const ticks = [days[0], days[Math.floor((days.length - 1) / 2)], days[days.length - 1]];
+ return [...new Set(ticks.filter((day): day is string => day !== undefined))];
+}
+
+function buildGridlines(axisMax: number, mode: UsageChartMode): readonly UsageChartGridline[] {
+ return Array.from({ length: GRIDLINE_COUNT }, (_unused, index) => {
+ const fraction = (GRIDLINE_COUNT - index) / GRIDLINE_COUNT;
+ const value = axisMax * fraction;
+ return {
+ value,
+ label: mode === "cost" ? formatUsd(value) : formatTokens(value),
+ topPercent: (1 - fraction) * 100,
+ };
+ });
+}
+
+const AXIS_STEPS = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10] as const;
+
+/**
+ * The smallest round ceiling above the peak that also divides evenly into
+ * {@link GRIDLINE_COUNT} bands, so every gridline label is a number a reader can
+ * hold in their head.
+ */
+export function niceAxisMax(peak: number): number {
+ if (!(peak > 0)) return 0;
+ const band = peak / GRIDLINE_COUNT;
+ const magnitude = 10 ** Math.floor(Math.log10(band));
+ const normalized = band / magnitude;
+ const step = AXIS_STEPS.find((candidate) => normalized <= candidate + 1e-9) ?? 10;
+ return step * magnitude * GRIDLINE_COUNT;
+}
+
+/**
+ * A monotone cubic through the daily values, as an SVG path.
+ *
+ * Fritsch-Carlson tangents rather than a plain Catmull-Rom: a smooth curve that
+ * overshoots would draw days below zero and peaks the data never reached, which
+ * on a spend chart is not a stylistic quibble.
+ */
+function monotonePoints(values: readonly number[], axisMax: number): string {
+ if (values.length === 0) return "M 0 " + String(CHART_HEIGHT);
+ const stepX = values.length > 1 ? CHART_WIDTH / (values.length - 1) : 0;
+ const toY = (value: number) =>
+ axisMax === 0 ? CHART_HEIGHT : CHART_HEIGHT - (value / axisMax) * CHART_HEIGHT;
+ const ys = values.map(toY);
+ if (ys.length === 1) {
+ // One day: a flat segment, so the fill still has a shape to close.
+ return `M 0 ${round(ys[0] ?? CHART_HEIGHT)} L ${CHART_WIDTH} ${round(ys[0] ?? CHART_HEIGHT)}`;
+ }
+
+ const tangents = monotoneTangents(ys, stepX);
+ let path = `M 0 ${round(ys[0] ?? 0)}`;
+ for (let index = 0; index < ys.length - 1; index += 1) {
+ const x0 = index * stepX;
+ const x1 = (index + 1) * stepX;
+ const y0 = ys[index] ?? 0;
+ const y1 = ys[index + 1] ?? 0;
+ const m0 = tangents[index] ?? 0;
+ const m1 = tangents[index + 1] ?? 0;
+ const control = stepX / 3;
+ path += ` C ${round(x0 + control)} ${round(y0 + m0 * control)} ${round(x1 - control)} ${round(y1 - m1 * control)} ${round(x1)} ${round(y1)}`;
+ }
+ return path;
+}
+
+function monotoneTangents(ys: readonly number[], stepX: number): readonly number[] {
+ const secants = ys.slice(0, -1).map((y, index) => ((ys[index + 1] ?? y) - y) / stepX);
+ return ys.map((_unused, index) => {
+ const before = secants[index - 1];
+ const after = secants[index];
+ if (before === undefined) return after ?? 0;
+ if (after === undefined) return before;
+ // A local extremum: a flat tangent is the only one that cannot overshoot.
+ if (before * after <= 0) return 0;
+ const average = (before + after) / 2;
+ const limit = 3 * Math.min(Math.abs(before), Math.abs(after));
+ return Math.sign(average) * Math.min(Math.abs(average), limit);
+ });
+}
+
+function round(value: number): number {
+ return Math.round(value * 100) / 100;
+}
+
+/** `2026-07-12` and `2026-08-10` to `Jul 12 to Aug 10`. */
+export function formatUsageDateRange(sinceDay: string, untilDay: string): string {
+ return `${formatDayShort(sinceDay)} to ${formatDayShort(untilDay)}`;
+}
+
+export interface UsageStat {
+ readonly label: string;
+ readonly value: string;
+ /** One line of context, or nothing when the figure has none worth adding. */
+ readonly context: string | null;
+}
+
+/**
+ * The band under the hero: what the tokens were, not just how many.
+ *
+ * Every sub-line is a ratio the totals alone do not give, so a reader can tell a
+ * heavy day from a heavy month, and cache reads from cache writes.
+ */
+export function buildUsageStats(merged: MergedUsage): readonly UsageStat[] {
+ // A day with any tokens at all. Dividing by calendar days instead would make
+ // a week off look like a drop in intensity rather than a week off.
+ const activeDays = merged.daily.filter((entry) => entry.totalTokens > 0).length;
+ const observedInput =
+ merged.cachedInputTokens + merged.uncachedInputTokens + merged.cacheCreationTokens;
+
+ return [
+ {
+ label: "Processed tokens",
+ value: formatTokensCompact(merged.totalTokens),
+ context:
+ activeDays === 0
+ ? null
+ : `${formatTokensCompact(merged.totalTokens / activeDays)} per active day`,
+ },
+ {
+ label: "Cached input",
+ value: formatTokensCompact(merged.cachedInputTokens),
+ context:
+ observedInput === 0
+ ? null
+ : `${formatPercent(merged.cachedInputTokens / observedInput)} of observed input`,
+ },
+ {
+ label: "Uncached input",
+ value: formatTokensCompact(merged.uncachedInputTokens),
+ context: `${formatTokensCompact(merged.cacheCreationTokens)} cache writes`,
+ },
+ {
+ label: "Output",
+ value: formatTokensCompact(merged.outputTokens),
+ context:
+ merged.reasoningTokens === 0
+ ? null
+ : `includes ${formatTokensCompact(merged.reasoningTokens)} reasoning`,
+ },
+ {
+ label: "Cache savings",
+ value: formatUsd(merged.costQuality.cacheSavingsUsd),
+ context:
+ merged.costUsd === 0
+ ? null
+ : `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the API-equivalent cost`,
+ },
+ ];
+}
+
+/**
+ * Models worth a row.
+ *
+ * A scan turns up placeholder model names (`` among them) carrying
+ * neither tokens nor cost. They are real records, but a row of zeros tells a
+ * reader nothing except that the table is noisy.
+ */
+export function visibleUsageModels(merged: MergedUsage): MergedUsage["models"] {
+ return merged.models.filter((model) => model.totalTokens > 0 || model.costUsd > 0);
+}
+
+export type UsageMachineState = "reporting" | "stale" | "not-reporting";
+
+export interface UsageMachineRow {
+ readonly environmentId: string;
+ readonly label: string;
+ readonly state: UsageMachineState;
+ readonly detail: string | null;
+ readonly sources: readonly UsageSource[];
+}
+
+/**
+ * A row per environment the app knows about, including the ones that answered
+ * nothing. Silently dropping an unreachable machine would make the totals look
+ * complete when they are not.
+ */
+export function buildUsageMachineRows(input: {
+ readonly environments: readonly UsageEnvironmentReport[];
+ readonly staleEnvironments: readonly string[];
+}): readonly UsageMachineRow[] {
+ const stale = new Set(input.staleEnvironments);
+
+ return [...input.environments]
+ .sort((left, right) => left.label.localeCompare(right.label))
+ .map((environment) => {
+ if (!environment.summary) {
+ return {
+ environmentId: environment.environmentId,
+ label: environment.label,
+ state: "not-reporting" as const,
+ detail: environment.error,
+ sources: [],
+ };
+ }
+ const isStale = stale.has(environment.environmentId);
+ return {
+ environmentId: environment.environmentId,
+ label: environment.label,
+ state: isStale ? ("stale" as const) : ("reporting" as const),
+ detail: isStale ? "Running an older usage format; excluded from the totals." : null,
+ sources: environment.summary.sources,
+ };
+ });
+}
+
+export const USAGE_MACHINE_STATE_LABELS: Record = {
+ reporting: "Reporting",
+ stale: "Out of date",
+ "not-reporting": "Not reporting",
+};
+
+/** How trustworthy the cost column is, in one line. */
+export function formatCostQualityFootnote(costQuality: CostQuality): string {
+ return [
+ `Priced from provider records ${formatPercent(costQuality.providerReportedShare, 0)}`,
+ `model rates ${formatPercent(costQuality.modelPricedShare, 0)}`,
+ `unpriced ${formatPercent(costQuality.unpricedShare, 0)}`,
+ ].join(" ยท ");
+}
diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts
index 49aedab3e..feae4ed51 100644
--- a/apps/web/src/environmentApi.ts
+++ b/apps/web/src/environmentApi.ts
@@ -45,6 +45,9 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {
filesystem: {
browse: rpcClient.filesystem.browse,
},
+ usage: {
+ summary: rpcClient.usage.summary,
+ },
sourceControl: {
lookupRepository: rpcClient.sourceControl.lookupRepository,
listRepositories: rpcClient.sourceControl.listRepositories,
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 3a69ae638..1cc0aa3e2 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -70,6 +70,8 @@
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
+ --color-provider-claude: var(--provider-claude);
+ --color-provider-codex: var(--provider-codex);
--color-primary-graph: var(--primary-graph);
--color-primary-readable: var(--primary-readable);
--color-primary-foreground: var(--primary-foreground);
@@ -207,6 +209,13 @@
--primary: var(--app-accent-blue);
--primary-readable: oklch(0.488 0.217 264);
--primary-graph: oklch(0.488 0.217 264);
+ /* The two providers charted on the usage page, and the only brand hues in the
+ app. Anthropic's terracotta reads as itself in both modes; Codex has no
+ usable brand color, so it takes a near-foreground neutral, which is also how
+ its own mark is drawn. Darkened on light, lifted on dark, so a 1.5px chart
+ stroke stays legible against either canvas. */
+ --provider-claude: #c96442;
+ --provider-codex: oklch(0.32 0 0);
--primary-foreground: var(--color-white);
--secondary: --alpha(var(--color-black) / 4%);
--secondary-foreground: var(--foreground);
@@ -265,6 +274,8 @@
--primary: var(--app-accent-blue);
--primary-readable: oklch(0.65 0.19 264);
--primary-graph: oklch(0.7 0.17 264);
+ --provider-claude: #e08b66;
+ --provider-codex: oklch(0.9 0 0);
--primary-foreground: var(--color-white);
--secondary: --alpha(var(--color-white) / 8%);
--secondary-foreground: var(--foreground);
diff --git a/apps/web/src/lib/usageReactQuery.ts b/apps/web/src/lib/usageReactQuery.ts
new file mode 100644
index 000000000..98822d228
--- /dev/null
+++ b/apps/web/src/lib/usageReactQuery.ts
@@ -0,0 +1,251 @@
+import {
+ USAGE_CONTRACT_VERSION,
+ USAGE_MAX_WINDOW_DAYS,
+ UsageDay,
+ type EnvironmentId,
+ type UsageSummary,
+ type UsageSummaryInput,
+ type UsageWindowDays,
+} from "@threadlines/contracts";
+import {
+ makeTodayUsageWindow,
+ makeUsageWindow,
+ windowStartDay,
+} from "@threadlines/shared/usageFormat";
+import {
+ filterSummaryWindow,
+ mergeUsage,
+ type EnvironmentUsage,
+ type MergedUsage,
+} from "@threadlines/shared/usageMerge";
+import { keepPreviousData, queryOptions } from "@tanstack/react-query";
+import { useMemo } from "react";
+
+import { readEnvironmentApi } from "~/environmentApi";
+import { readPrimaryEnvironmentDescriptor, usePrimaryEnvironmentId } from "~/environments/primary";
+import {
+ useSavedEnvironmentRegistryStore,
+ useSavedEnvironmentRuntimeStore,
+} from "~/environments/runtime";
+import { resolveEnvironmentOptionLabel } from "~/components/BranchToolbar.logic";
+
+/** An environment the page will ask for usage, connected or not. */
+export interface UsageEnvironmentTarget {
+ readonly environmentId: EnvironmentId;
+ readonly label: string;
+}
+
+/**
+ * What one environment contributed.
+ *
+ * A machine that could not be reached keeps its row with `summary: null` rather
+ * than disappearing: a total that quietly omits a computer is worse than one
+ * that says which computer is missing.
+ */
+export interface UsageEnvironmentReport {
+ readonly environmentId: EnvironmentId;
+ readonly label: string;
+ readonly summary: UsageSummary | null;
+ readonly error: string | null;
+}
+
+export interface UsageAcrossEnvironments {
+ readonly window: UsageSummaryInput;
+ readonly merged: MergedUsage;
+ readonly environments: readonly UsageEnvironmentReport[];
+}
+
+/** A few minutes: a transcript scan is disk work, and usage is not live data. */
+const USAGE_STALE_TIME_MS = 5 * 60_000;
+
+function normalizeTargets(
+ targets: ReadonlyArray,
+): ReadonlyArray {
+ return [...targets].sort((left, right) => left.environmentId.localeCompare(right.environmentId));
+}
+
+async function readUsageAcrossEnvironments(
+ targets: ReadonlyArray,
+ window: UsageSummaryInput,
+): Promise {
+ const reads = await Promise.allSettled(
+ targets.map(async (target) => {
+ const api = readEnvironmentApi(target.environmentId);
+ if (!api?.usage) {
+ throw new Error(`Environment ${target.environmentId} is unavailable.`);
+ }
+ return api.usage.summary(window);
+ }),
+ );
+
+ const environments: UsageEnvironmentReport[] = targets.map((target, index) => {
+ const read = reads[index];
+ if (read?.status === "fulfilled") {
+ return {
+ environmentId: target.environmentId,
+ label: target.label,
+ summary: read.value,
+ error: null,
+ };
+ }
+ return {
+ environmentId: target.environmentId,
+ label: target.label,
+ summary: null,
+ error: read?.status === "rejected" ? describeUsageReadFailure(read.reason) : "Unavailable",
+ };
+ });
+
+ const contributions: EnvironmentUsage[] = environments.flatMap((environment) =>
+ environment.summary
+ ? [
+ {
+ environmentId: environment.environmentId,
+ label: environment.label,
+ summary: environment.summary,
+ },
+ ]
+ : [],
+ );
+
+ return {
+ window,
+ merged: mergeUsage(contributions, USAGE_CONTRACT_VERSION),
+ environments,
+ };
+}
+
+function describeUsageReadFailure(reason: unknown): string {
+ if (reason instanceof Error && reason.message.trim().length > 0) {
+ return reason.message;
+ }
+ return "Unavailable";
+}
+
+/**
+ * The page's query. Every environment is asked in parallel and the failures are
+ * kept alongside the successes, so the merge can be honest about coverage.
+ *
+ * Always the longest window the page offers, whatever the selector says. A scan
+ * is disk work measured in seconds, and every shorter selection is a subset of
+ * this one, so the 7- and 30-day views are derived rather than fetched.
+ */
+export function usageSummaryQueryOptions(input: {
+ readonly targets: ReadonlyArray;
+}) {
+ const targets = normalizeTargets(input.targets);
+ return queryOptions({
+ queryKey: [
+ "usage",
+ "summary",
+ USAGE_MAX_WINDOW_DAYS,
+ targets.map((target) => target.environmentId),
+ ] as const,
+ // The window is built inside the fetch rather than in the key so a session
+ // left open past midnight rolls onto the new day at the next refetch.
+ queryFn: () => readUsageAcrossEnvironments(targets, makeUsageWindow(USAGE_MAX_WINDOW_DAYS)),
+ enabled: targets.length > 0,
+ staleTime: USAGE_STALE_TIME_MS,
+ // A refresh keeps the page on screen: the numbers move, the layout does not.
+ placeholderData: keepPreviousData,
+ });
+}
+
+/**
+ * Narrows a scanned result to the selected window, client-side.
+ *
+ * Anchored on the scan's own last day rather than on "now", so the derived
+ * window always lines up with the days the scan actually covered.
+ */
+export function deriveUsageWindow(
+ scan: UsageAcrossEnvironments,
+ windowDays: UsageWindowDays,
+): UsageAcrossEnvironments {
+ const untilDay = scan.window.untilDay;
+ const sinceDay = UsageDay.make(windowStartDay(untilDay, windowDays));
+ if (sinceDay <= scan.window.sinceDay) return scan;
+
+ const environments: UsageEnvironmentReport[] = scan.environments.map((environment) =>
+ environment.summary
+ ? { ...environment, summary: filterSummaryWindow(environment.summary, sinceDay, untilDay) }
+ : environment,
+ );
+ const contributions: EnvironmentUsage[] = environments.flatMap((environment) =>
+ environment.summary
+ ? [
+ {
+ environmentId: environment.environmentId,
+ label: environment.label,
+ summary: environment.summary,
+ },
+ ]
+ : [],
+ );
+
+ return {
+ window: { ...scan.window, sinceDay },
+ merged: mergeUsage(contributions, USAGE_CONTRACT_VERSION),
+ environments,
+ };
+}
+
+/**
+ * Today alone, for the sidebar meter. Same key family as the page so the two
+ * never collide, and a different window so neither refetches for the other.
+ */
+export function usageTodayQueryOptions(input: {
+ readonly targets: ReadonlyArray;
+}) {
+ const targets = normalizeTargets(input.targets);
+ return queryOptions({
+ queryKey: ["usage", "summary", "today", targets.map((target) => target.environmentId)] as const,
+ queryFn: () => readUsageAcrossEnvironments(targets, makeTodayUsageWindow()),
+ enabled: targets.length > 0,
+ staleTime: USAGE_STALE_TIME_MS,
+ });
+}
+
+/**
+ * Every environment the app knows about: this device plus each saved backend,
+ * whether or not it is currently connected.
+ */
+export function useUsageEnvironmentTargets(): ReadonlyArray {
+ const primaryEnvironmentId = usePrimaryEnvironmentId();
+ const primaryLabel = readPrimaryEnvironmentDescriptor()?.label ?? null;
+ const savedEnvironmentsById = useSavedEnvironmentRegistryStore((state) => state.byId);
+ const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((state) => state.byId);
+
+ return useMemo(() => {
+ const targets: UsageEnvironmentTarget[] = [];
+ const seen = new Set();
+
+ if (primaryEnvironmentId) {
+ seen.add(primaryEnvironmentId);
+ targets.push({
+ environmentId: primaryEnvironmentId,
+ label: resolveEnvironmentOptionLabel({
+ isPrimary: true,
+ environmentId: primaryEnvironmentId,
+ runtimeLabel: primaryLabel,
+ }),
+ });
+ }
+
+ for (const record of Object.values(savedEnvironmentsById)) {
+ if (seen.has(record.environmentId)) continue;
+ seen.add(record.environmentId);
+ targets.push({
+ environmentId: record.environmentId,
+ label: resolveEnvironmentOptionLabel({
+ isPrimary: false,
+ environmentId: record.environmentId,
+ runtimeLabel:
+ savedEnvironmentRuntimeById[record.environmentId]?.descriptor?.label ?? null,
+ savedLabel: record.label,
+ }),
+ });
+ }
+
+ return normalizeTargets(targets);
+ }, [primaryEnvironmentId, primaryLabel, savedEnvironmentRuntimeById, savedEnvironmentsById]);
+}
diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts
index 6991a5f06..2f7b7be7b 100644
--- a/apps/web/src/localApi.test.ts
+++ b/apps/web/src/localApi.test.ts
@@ -68,6 +68,9 @@ const rpcClientMock = {
filesystem: {
browse: vi.fn(),
},
+ usage: {
+ summary: vi.fn(),
+ },
sourceControl: {
lookupRepository: vi.fn(),
listRepositories: vi.fn(),
diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
index f4c57490c..b8c92dbf6 100644
--- a/apps/web/src/routeTree.gen.ts
+++ b/apps/web/src/routeTree.gen.ts
@@ -14,6 +14,7 @@ import { Route as PairRouteImport } from './routes/pair'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as ChatIndexRouteImport } from './routes/_chat.index'
import { Route as ChatChatsRouteImport } from './routes/_chat.chats'
+import { Route as ChatUsageRouteImport } from './routes/_chat.usage'
import { Route as SettingsIndexRouteImport } from './routes/settings.index'
import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'
@@ -51,6 +52,11 @@ const ChatChatsRoute = ChatChatsRouteImport.update({
path: '/chats',
getParentRoute: () => ChatRoute,
} as any)
+const ChatUsageRoute = ChatUsageRouteImport.update({
+ id: '/usage',
+ path: '/usage',
+ getParentRoute: () => ChatRoute,
+} as any)
const SettingsIndexRoute = SettingsIndexRouteImport.update({
id: '/',
path: '/',
@@ -118,6 +124,7 @@ export interface FileRoutesByFullPath {
'/pair': typeof PairRoute
'/settings': typeof SettingsRouteWithChildren
'/chats': typeof ChatChatsRoute
+ '/usage': typeof ChatUsageRoute
'/settings/archived': typeof SettingsArchivedRoute
'/settings/connections': typeof SettingsConnectionsRoute
'/settings/diagnostics': typeof SettingsDiagnosticsRoute
@@ -134,6 +141,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/pair': typeof PairRoute
'/chats': typeof ChatChatsRoute
+ '/usage': typeof ChatUsageRoute
'/settings/archived': typeof SettingsArchivedRoute
'/settings/connections': typeof SettingsConnectionsRoute
'/settings/diagnostics': typeof SettingsDiagnosticsRoute
@@ -154,6 +162,7 @@ export interface FileRoutesById {
'/pair': typeof PairRoute
'/settings': typeof SettingsRouteWithChildren
'/_chat/chats': typeof ChatChatsRoute
+ '/_chat/usage': typeof ChatUsageRoute
'/settings/archived': typeof SettingsArchivedRoute
'/settings/connections': typeof SettingsConnectionsRoute
'/settings/diagnostics': typeof SettingsDiagnosticsRoute
@@ -175,6 +184,7 @@ export interface FileRouteTypes {
| '/pair'
| '/settings'
| '/chats'
+ | '/usage'
| '/settings/archived'
| '/settings/connections'
| '/settings/diagnostics'
@@ -191,6 +201,7 @@ export interface FileRouteTypes {
to:
| '/pair'
| '/chats'
+ | '/usage'
| '/settings/archived'
| '/settings/connections'
| '/settings/diagnostics'
@@ -210,6 +221,7 @@ export interface FileRouteTypes {
| '/pair'
| '/settings'
| '/_chat/chats'
+ | '/_chat/usage'
| '/settings/archived'
| '/settings/connections'
| '/settings/diagnostics'
@@ -268,6 +280,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ChatChatsRouteImport
parentRoute: typeof ChatRoute
}
+ '/_chat/usage': {
+ id: '/_chat/usage'
+ path: '/usage'
+ fullPath: '/usage'
+ preLoaderRoute: typeof ChatUsageRouteImport
+ parentRoute: typeof ChatRoute
+ }
'/settings/': {
id: '/settings/'
path: '/'
@@ -357,6 +376,7 @@ declare module '@tanstack/react-router' {
interface ChatRouteChildren {
ChatChatsRoute: typeof ChatChatsRoute
+ ChatUsageRoute: typeof ChatUsageRoute
ChatIndexRoute: typeof ChatIndexRoute
ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute
ChatDraftDraftIdRoute: typeof ChatDraftDraftIdRoute
@@ -364,6 +384,7 @@ interface ChatRouteChildren {
const ChatRouteChildren: ChatRouteChildren = {
ChatChatsRoute: ChatChatsRoute,
+ ChatUsageRoute: ChatUsageRoute,
ChatIndexRoute: ChatIndexRoute,
ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute,
ChatDraftDraftIdRoute: ChatDraftDraftIdRoute,
diff --git a/apps/web/src/routes/_chat.usage.tsx b/apps/web/src/routes/_chat.usage.tsx
new file mode 100644
index 000000000..e894bfc95
--- /dev/null
+++ b/apps/web/src/routes/_chat.usage.tsx
@@ -0,0 +1,7 @@
+import { createFileRoute } from "@tanstack/react-router";
+
+import { UsageView } from "../components/usage/UsageView";
+
+export const Route = createFileRoute("/_chat/usage")({
+ component: UsageView,
+});
diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts
index e44357e21..b6788c5c1 100644
--- a/apps/web/src/rpc/wsRpcClient.ts
+++ b/apps/web/src/rpc/wsRpcClient.ts
@@ -113,6 +113,9 @@ export interface WsRpcClient {
readonly filesystem: {
readonly browse: RpcUnaryMethod;
};
+ readonly usage: {
+ readonly summary: RpcUnaryMethod;
+ };
readonly sourceControl: {
readonly lookupRepository: RpcUnaryMethod;
readonly listRepositories: RpcUnaryMethod;
@@ -438,6 +441,9 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {
filesystem: {
browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)),
},
+ usage: {
+ summary: (input) => transport.request((client) => client[WS_METHODS.usageSummary](input)),
+ },
sourceControl: {
lookupRepository: (input) =>
transport.request((client) => client[WS_METHODS.sourceControlLookupRepository](input)),
diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts
index 86661a7ac..150ac186b 100644
--- a/packages/contracts/src/index.ts
+++ b/packages/contracts/src/index.ts
@@ -23,4 +23,5 @@ export * from "./orchestration.ts";
export * from "./editor.ts";
export * from "./project.ts";
export * from "./filesystem.ts";
+export * from "./usage.ts";
export * from "./rpc.ts";
diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts
index 99c33ecaa..3ed6eae82 100644
--- a/packages/contracts/src/ipc.ts
+++ b/packages/contracts/src/ipc.ts
@@ -52,6 +52,7 @@ import type {
VcsStatusResult,
} from "./git.ts";
import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts";
+import type { UsageSummary, UsageSummaryInput } from "./usage.ts";
import type {
ProjectFaviconInput,
ProjectFaviconResult,
@@ -1178,6 +1179,14 @@ export interface EnvironmentApi {
filesystem: {
browse: (input: FilesystemBrowseInput) => Promise;
};
+ /**
+ * Token/cost reporting for this environment's provider transcripts. Optional
+ * so a client can still talk to a server predating the usage scan; callers
+ * treat its absence the same way they treat an unreachable environment.
+ */
+ usage?: {
+ summary: (input: UsageSummaryInput) => Promise;
+ };
sourceControl: {
lookupRepository: (
input: SourceControlRepositoryLookupInput,
diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts
index 02960820c..dcbb4034b 100644
--- a/packages/contracts/src/rpc.ts
+++ b/packages/contracts/src/rpc.ts
@@ -14,6 +14,7 @@ import {
FilesystemBrowseResult,
FilesystemBrowseError,
} from "./filesystem.ts";
+import { UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts";
import {
GitActionProgressEvent,
VcsCommitDetailsInput,
@@ -345,6 +346,9 @@ export const WS_METHODS = {
serverGetProviderInstructionFiles: "server.getProviderInstructionFiles",
serverWriteProviderInstructionFile: "server.writeProviderInstructionFile",
+ // Usage reporting methods
+ usageSummary: "usage.summary",
+
// Source control methods
sourceControlLookupRepository: "sourceControl.lookupRepository",
sourceControlListRepositories: "sourceControl.listRepositories",
@@ -688,6 +692,12 @@ export const WsSourceControlPublishRepositoryRpc = Rpc.make(
},
);
+export const WsUsageSummaryRpc = Rpc.make(WS_METHODS.usageSummary, {
+ payload: UsageSummaryInput,
+ success: UsageSummary,
+ error: UsageReadError,
+});
+
export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, {
payload: ProjectSearchEntriesInput,
success: ProjectSearchEntriesResult,
@@ -1144,6 +1154,7 @@ export const WsRpcGroup = RpcGroup.make(
WsServerReadProviderExtensionMcpResourceRpc,
WsServerGetProviderInstructionFilesRpc,
WsServerWriteProviderInstructionFileRpc,
+ WsUsageSummaryRpc,
WsSourceControlLookupRepositoryRpc,
WsSourceControlListRepositoriesRpc,
WsSourceControlCloneRepositoryRpc,
diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts
new file mode 100644
index 000000000..efb3ab53f
--- /dev/null
+++ b/packages/contracts/src/usage.ts
@@ -0,0 +1,215 @@
+/**
+ * Usage reporting contract.
+ *
+ * Each environment scans the provider CLIs' own on-disk session transcripts
+ * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`) rather
+ * than Threadlines' orchestration projections, so usage stays complete even for
+ * turns that were driven by the CLI, an editor extension, or another tool
+ * entirely. `ccusage` takes the same approach.
+ *
+ * Environments return pre-aggregated `(day, provider, model)` buckets. Raw
+ * transcript records never cross the wire.
+ *
+ * Cost semantics: every `costUsd` in this module is the *API list-price
+ * equivalent* of the tokens, not billed spend. Subscription plans bill on their
+ * own terms and the transcripts carry no invoice, so a figure here answers
+ * "what would this have cost on the API" and nothing else. UI copy must say so.
+ *
+ * @module usage
+ */
+import * as Schema from "effect/Schema";
+
+import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
+
+/**
+ * Bumped whenever the shape of {@link UsageSummary} changes incompatibly. A
+ * client merging several environments renders partial coverage when one reports
+ * an older version rather than failing the whole page.
+ */
+export const USAGE_CONTRACT_VERSION = 1 as const;
+
+export const UsageProviderKind = Schema.Literals(["claude", "codex"]);
+export type UsageProviderKind = typeof UsageProviderKind.Type;
+
+/**
+ * A calendar day in the reporting time zone, formatted `YYYY-MM-DD`.
+ *
+ * Days are bucketed server-side so a turn always lands on the day the user
+ * experienced it, not the UTC day.
+ */
+const USAGE_DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
+
+export const UsageDay = TrimmedNonEmptyString.check(Schema.isPattern(USAGE_DAY_PATTERN)).pipe(
+ Schema.brand("UsageDay"),
+);
+export type UsageDay = typeof UsageDay.Type;
+
+/**
+ * Window lengths the UI offers. The scan cache retains 90 days, so a longer
+ * window would cold-scan every time.
+ */
+export const USAGE_WINDOW_DAY_OPTIONS = [7, 30, 90] as const;
+
+export const UsageWindowDays = Schema.Literals(USAGE_WINDOW_DAY_OPTIONS);
+export type UsageWindowDays = typeof UsageWindowDays.Type;
+
+/** Longest window the server will scan, in days. Matches the cache retention. */
+export const USAGE_MAX_WINDOW_DAYS = 90 as const;
+
+/**
+ * Why a bucket's cost is what it is.
+ *
+ * - `providerReported` โ the transcript carried an explicit cost figure.
+ * - `modelPriced` โ we matched the model against the LiteLLM rate table.
+ * - `unpriced` โ tokens are known, rates are not. Counted in token totals,
+ * excluded from cost.
+ */
+export const UsageCostSource = Schema.Literals(["providerReported", "modelPriced", "unpriced"]);
+export type UsageCostSource = typeof UsageCostSource.Type;
+
+/**
+ * Token counts for a bucket.
+ *
+ * `cachedInputTokens` and `cacheCreationTokens` are disjoint from
+ * `uncachedInputTokens`; summing all three gives total input. `reasoningTokens`
+ * is a *subset* of `outputTokens` (Codex reports it that way, and Anthropic
+ * folds thinking into output), so it must never be added on top.
+ */
+export const UsageTokenTotals = Schema.Struct({
+ uncachedInputTokens: NonNegativeInt,
+ cachedInputTokens: NonNegativeInt,
+ cacheCreationTokens: NonNegativeInt,
+ outputTokens: NonNegativeInt,
+ reasoningTokens: NonNegativeInt,
+});
+export type UsageTokenTotals = typeof UsageTokenTotals.Type;
+
+/**
+ * One `(day, provider, model)` cell.
+ *
+ * `costUsd` is the API list-price equivalent of these tokens, not money spent.
+ * `unpricedRecords` counts records whose tokens are included in the token
+ * totals but which contributed nothing to `costUsd`.
+ */
+export const UsageBucket = Schema.Struct({
+ day: UsageDay,
+ provider: UsageProviderKind,
+ model: TrimmedNonEmptyString,
+ totals: UsageTokenTotals,
+ costUsd: Schema.Number,
+ /**
+ * What the cached input would have cost at full input rates minus what it
+ * actually cost. Requires the rate table, so it is computed alongside cost
+ * rather than derived on the client.
+ */
+ cacheSavingsUsd: Schema.Number,
+ costSource: UsageCostSource,
+ /** Distinct assistant responses, after de-duplication. */
+ records: NonNegativeInt,
+ unpricedRecords: NonNegativeInt,
+ /** Distinct transcript sessions that contributed to this cell. */
+ sessions: NonNegativeInt,
+});
+export type UsageBucket = typeof UsageBucket.Type;
+
+/**
+ * Identifies the physical transcript directory a source read from.
+ *
+ * Two environments on one machine (worktree servers, for example) resolve the
+ * same provider home and would otherwise double count. The client drops
+ * duplicate fingerprints before merging.
+ */
+export const UsageSourceFingerprint = Schema.Struct({
+ hostId: TrimmedNonEmptyString,
+ provider: UsageProviderKind,
+ resolvedHomePath: TrimmedNonEmptyString,
+ /**
+ * Filesystem identity of the transcript directory, as `device:inode`.
+ *
+ * Hostname and path alone are not enough: every Mac resolves
+ * `/Users//.claude`, so two machines that happen to share a hostname
+ * would look like one source and have one of them silently dropped. The
+ * device/inode pair is stable for two servers reading the same directory and
+ * effectively never collides across machines. Empty when it cannot be read.
+ */
+ volumeId: Schema.String,
+});
+export type UsageSourceFingerprint = typeof UsageSourceFingerprint.Type;
+
+export const UsageSourceStatus = Schema.Literals(["ok", "missing", "partial", "failed"]);
+export type UsageSourceStatus = typeof UsageSourceStatus.Type;
+
+export const UsageSource = Schema.Struct({
+ fingerprint: UsageSourceFingerprint,
+ status: UsageSourceStatus,
+ /**
+ * When this directory was last walked, as an ISO instant. Warm scans reuse
+ * memoised per-file results, so this is the only honest freshness signal the
+ * client has for a source.
+ */
+ lastScannedAt: Schema.String,
+ scannedFiles: NonNegativeInt,
+ skippedFiles: NonNegativeInt,
+ /**
+ * Distinct transcript sessions seen under this directory. Buckets also carry
+ * per-bucket session counts, but a session spans days and models, so summing
+ * those overcounts; this is the figure clients should total.
+ */
+ distinctSessions: NonNegativeInt,
+ message: Schema.NullOr(TrimmedNonEmptyString),
+});
+export type UsageSource = typeof UsageSource.Type;
+
+export const UsagePricingStatus = Schema.Literals(["fresh", "cached", "unavailable"]);
+export type UsagePricingStatus = typeof UsagePricingStatus.Type;
+
+/**
+ * Provenance for the rate table, so the UI can be honest about how good the
+ * cost figures are.
+ */
+export const UsagePricing = Schema.Struct({
+ status: UsagePricingStatus,
+ source: TrimmedNonEmptyString,
+ fetchedAt: Schema.NullOr(Schema.String),
+ knownModels: NonNegativeInt,
+});
+export type UsagePricing = typeof UsagePricing.Type;
+
+export const UsageSummaryInput = Schema.Struct({
+ /** Inclusive first day of the window, in `timeZone`. */
+ sinceDay: UsageDay,
+ /** Inclusive last day of the window, in `timeZone`. */
+ untilDay: UsageDay,
+ /**
+ * IANA zone the client wants days bucketed in. A fixed offset would be wrong
+ * for any window that crosses a DST boundary, and the viewer's machine is
+ * often not the machine holding the transcripts.
+ */
+ timeZone: TrimmedNonEmptyString,
+});
+export type UsageSummaryInput = typeof UsageSummaryInput.Type;
+
+export const UsageSummary = Schema.Struct({
+ contractVersion: Schema.Number,
+ readAt: Schema.String,
+ timeZone: TrimmedNonEmptyString,
+ sinceDay: UsageDay,
+ untilDay: UsageDay,
+ buckets: Schema.Array(UsageBucket),
+ sources: Schema.Array(UsageSource),
+ pricing: UsagePricing,
+ /** Wall-clock cost of the scan, surfaced in diagnostics. */
+ scanDurationMs: NonNegativeInt,
+});
+export type UsageSummary = typeof UsageSummary.Type;
+
+export class UsageReadError extends Schema.TaggedErrorClass()("UsageReadError", {
+ reason: Schema.Literals(["scanFailed", "invalidWindow"]),
+ /** Stable, bounded description. The underlying failure travels in `cause`. */
+ detail: TrimmedNonEmptyString,
+ cause: Schema.optional(Schema.Defect()),
+}) {
+ override get message(): string {
+ return `Usage read failed (${this.reason}): ${this.detail}`;
+ }
+}
diff --git a/packages/shared/package.json b/packages/shared/package.json
index b64a2b328..316bfeb7c 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -174,6 +174,14 @@
"./uuid": {
"types": "./src/uuid.ts",
"import": "./src/uuid.ts"
+ },
+ "./usageMerge": {
+ "types": "./src/usageMerge.ts",
+ "import": "./src/usageMerge.ts"
+ },
+ "./usageFormat": {
+ "types": "./src/usageFormat.ts",
+ "import": "./src/usageFormat.ts"
}
},
"scripts": {
diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts
new file mode 100644
index 000000000..5323add58
--- /dev/null
+++ b/packages/shared/src/usageFormat.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ enumerateDays,
+ formatDayShort,
+ formatTokens,
+ formatTokensCompact,
+ makeTodayUsageWindow,
+ makeUsageWindow,
+ windowStartDay,
+} from "./usageFormat.ts";
+
+describe("formatTokens", () => {
+ it("compacts to three significant figures with a unit suffix", () => {
+ expect(formatTokens(999)).toBe("999");
+ expect(formatTokens(1_040)).toBe("1.04K");
+ expect(formatTokens(804_000)).toBe("804K");
+ expect(formatTokens(2_400_000)).toBe("2.40M");
+ expect(formatTokens(2_000_000)).toBe("2M");
+ expect(formatTokens(19_900_000_000)).toBe("19.9B");
+ });
+});
+
+describe("formatTokensCompact", () => {
+ it("drops trailing zeros but keeps significant fraction digits", () => {
+ expect(formatTokensCompact(2_400_000)).toBe("2.4M");
+ expect(formatTokensCompact(2_000_000)).toBe("2M");
+ expect(formatTokensCompact(1_040)).toBe("1.04K");
+ expect(formatTokensCompact(999)).toBe("999");
+ });
+});
+
+describe("formatDayShort", () => {
+ it("renders a calendar day without shifting it into another zone", () => {
+ expect(formatDayShort("2026-08-07")).toBe("Aug 7");
+ expect(formatDayShort("2026-01-01")).toBe("Jan 1");
+ });
+});
+
+describe("enumerateDays", () => {
+ it("includes both bounds and spans month ends", () => {
+ expect(enumerateDays("2026-01-30", "2026-02-02")).toEqual([
+ "2026-01-30",
+ "2026-01-31",
+ "2026-02-01",
+ "2026-02-02",
+ ]);
+ });
+
+ it("returns nothing when the range is inverted", () => {
+ expect(enumerateDays("2026-02-02", "2026-01-30")).toEqual([]);
+ });
+});
+
+describe("windowStartDay", () => {
+ it("counts the end day itself and walks back across a month boundary", () => {
+ expect(windowStartDay("2026-08-10", 30)).toBe("2026-07-12");
+ expect(windowStartDay("2026-03-03", 7)).toBe("2026-02-25");
+ expect(windowStartDay("2026-08-10", 1)).toBe("2026-08-10");
+ });
+});
+
+describe("makeUsageWindow", () => {
+ it("ends today and spans the requested number of days inclusively", () => {
+ const window = makeUsageWindow(7, new Date("2026-03-10T18:00:00Z"));
+ expect(enumerateDays(window.sinceDay, window.untilDay)).toHaveLength(7);
+ expect(window.timeZone.length).toBeGreaterThan(0);
+ });
+
+ it("walks back across a month boundary", () => {
+ const window = makeUsageWindow(7, new Date("2026-03-03T18:00:00Z"));
+ expect(enumerateDays(window.sinceDay, window.untilDay)).toHaveLength(7);
+ expect(window.sinceDay < window.untilDay).toBe(true);
+ });
+});
+
+describe("makeTodayUsageWindow", () => {
+ it("covers a single day", () => {
+ const window = makeTodayUsageWindow(new Date("2026-03-10T18:00:00Z"));
+ expect(window.sinceDay).toBe(window.untilDay);
+ });
+});
diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts
new file mode 100644
index 000000000..96dc25035
--- /dev/null
+++ b/packages/shared/src/usageFormat.ts
@@ -0,0 +1,155 @@
+// @effect-diagnostics globalDate:off -- Usage windows are calendar days in the viewer's zone, derived from wall-clock "now" via Intl.
+/**
+ * Display formatting and window construction for the usage page.
+ *
+ * Costs formatted here are API list-price equivalents, not billed spend; the
+ * surrounding UI copy has to say so.
+ *
+ * @module usageFormat
+ */
+import { UsageDay, type UsageSummaryInput, type UsageWindowDays } from "@threadlines/contracts";
+
+const CURRENCY = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+});
+
+const INTEGER = new Intl.NumberFormat("en-US");
+
+const DAY_MS = 86_400_000;
+
+export function formatUsd(value: number): string {
+ return CURRENCY.format(value);
+}
+
+export function formatCount(value: number): string {
+ return INTEGER.format(Math.round(value));
+}
+
+/**
+ * Compacts a token count to three significant figures with a unit suffix, so
+ * columns of numbers line up at a glance (`19.9B`, `76.7M`, `804K`).
+ */
+export function formatTokens(value: number): string {
+ const abs = Math.abs(value);
+ if (abs >= 1e12) return `${trim(value / 1e12)}T`;
+ if (abs >= 1e9) return `${trim(value / 1e9)}B`;
+ if (abs >= 1e6) return `${trim(value / 1e6)}M`;
+ if (abs >= 1e3) return `${trim(value / 1e3)}K`;
+ return INTEGER.format(Math.round(value));
+}
+
+function trim(value: number): string {
+ const abs = Math.abs(value);
+ const digits = abs >= 100 ? 0 : abs >= 10 ? 1 : 2;
+ return value.toFixed(digits).replace(/\.0+$/, "");
+}
+
+/**
+ * {@link formatTokens} without trailing zeros ("2.4M", never "2.40M"), for
+ * one-off figures like the sidebar meter. Tables keep the padded form so
+ * fraction digits line up.
+ */
+export function formatTokensCompact(value: number): string {
+ return formatTokens(value)
+ .replace(/(\.\d*?)0+(?=[A-Z]?$)/, "$1")
+ .replace(/\.(?=[A-Z]?$)/, "");
+}
+
+export function formatPercent(share: number, digits = 1): string {
+ return `${(share * 100).toFixed(digits)}%`;
+}
+
+const MONTHS = [
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+] as const;
+
+/** `2026-08-07` to `Aug 7`. */
+export function formatDayShort(day: string): string {
+ const [year, month, dayOfMonth] = day.split("-").map((part) => Number(part));
+ if (year === undefined || month === undefined || dayOfMonth === undefined) return day;
+ return `${MONTHS[month - 1] ?? ""} ${dayOfMonth}`;
+}
+
+/** Inclusive day list between two `YYYY-MM-DD` bounds. */
+export function enumerateDays(sinceDay: string, untilDay: string): readonly string[] {
+ const days: string[] = [];
+ const start = Date.parse(`${sinceDay}T00:00:00Z`);
+ const end = Date.parse(`${untilDay}T00:00:00Z`);
+ if (Number.isNaN(start) || Number.isNaN(end) || end < start) return days;
+
+ for (let cursor = start; cursor <= end; cursor += DAY_MS) {
+ days.push(new Date(cursor).toISOString().slice(0, 10));
+ }
+ return days;
+}
+
+/**
+ * Inclusive first day of a window of `windowDays` ending on `untilDay`.
+ *
+ * The page fetches one long window and narrows it on the client, so the shorter
+ * selections have to be anchored to the day the scan actually ended on rather
+ * than to a fresh "now" that may already have rolled over.
+ */
+export function windowStartDay(untilDay: string, windowDays: number): string {
+ const [year = 0, month = 1, dayOfMonth = 1] = untilDay
+ .split("-")
+ .map((part) => Number.parseInt(part, 10));
+ // Calendar arithmetic in UTC, where every day is the same length.
+ const start = new Date(Date.UTC(year, month - 1, dayOfMonth - (windowDays - 1)));
+ return start.toISOString().slice(0, 10);
+}
+
+/**
+ * The window the page requests for a 7/30/90-day selection, expressed in the
+ * viewer's own time zone so days line up with what they actually experienced.
+ *
+ * The zone travels with the request rather than being inferred server-side: the
+ * machine holding the transcripts is often not the machine looking at them.
+ */
+export function makeUsageWindow(windowDays: UsageWindowDays, now = new Date()): UsageSummaryInput {
+ return buildUsageWindow(windowDays, now);
+}
+
+/**
+ * Today alone, for the compact live figure in the sidebar footer.
+ *
+ * Separate from {@link makeUsageWindow} because one day is not one of the
+ * windows the page offers, and widening that function's type would invite
+ * arbitrary windows the scan cache is not sized for.
+ */
+export function makeTodayUsageWindow(now = new Date()): UsageSummaryInput {
+ return buildUsageWindow(1, now);
+}
+
+function buildUsageWindow(windowDays: number, now: Date): UsageSummaryInput {
+ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
+ const format = new Intl.DateTimeFormat("en-CA", {
+ timeZone,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ });
+ const untilDay = format.format(now);
+ // Subtracting fixed milliseconds from `now` lands on the wrong calendar day
+ // around a DST transition. Only "today" needs the zone; the window start is
+ // pure calendar arithmetic on that day, done in UTC where days are uniform.
+ return {
+ sinceDay: UsageDay.make(windowStartDay(untilDay, windowDays)),
+ untilDay: UsageDay.make(untilDay),
+ timeZone,
+ };
+}
diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts
new file mode 100644
index 000000000..38bacaa79
--- /dev/null
+++ b/packages/shared/src/usageMerge.test.ts
@@ -0,0 +1,316 @@
+import {
+ USAGE_CONTRACT_VERSION,
+ type EnvironmentId,
+ type UsageBucket,
+ type UsageDay,
+ type UsageProviderKind,
+ type UsageSummary,
+} from "@threadlines/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import { filterSummaryWindow, mergeUsage, type EnvironmentUsage } from "./usageMerge.ts";
+
+function bucket(overrides: Partial = {}): UsageBucket {
+ return {
+ day: "2026-08-07" as UsageDay,
+ provider: "claude",
+ model: "claude-fable-5",
+ totals: {
+ uncachedInputTokens: 100,
+ cachedInputTokens: 1000,
+ cacheCreationTokens: 10,
+ outputTokens: 50,
+ reasoningTokens: 0,
+ },
+ costUsd: 10,
+ cacheSavingsUsd: 2,
+ costSource: "modelPriced",
+ records: 5,
+ unpricedRecords: 0,
+ sessions: 1,
+ ...overrides,
+ };
+}
+
+interface SourceSpec {
+ readonly provider: UsageProviderKind;
+ readonly hostId: string;
+ readonly homePath: string;
+ readonly volumeId?: string;
+ readonly distinctSessions?: number;
+ readonly lastScannedAt?: string;
+}
+
+function summary(
+ buckets: readonly UsageBucket[],
+ sources: readonly SourceSpec[],
+ contractVersion: number = USAGE_CONTRACT_VERSION,
+): UsageSummary {
+ return {
+ contractVersion,
+ readAt: "2026-08-07T00:00:00.000Z",
+ timeZone: "UTC",
+ sinceDay: "2026-08-01" as UsageDay,
+ untilDay: "2026-08-31" as UsageDay,
+ buckets,
+ sources: sources.map((source) => ({
+ fingerprint: {
+ hostId: source.hostId,
+ provider: source.provider,
+ resolvedHomePath: source.homePath,
+ volumeId: source.volumeId ?? `vol-${source.hostId}`,
+ },
+ status: "ok" as const,
+ lastScannedAt: source.lastScannedAt ?? "2026-08-07T00:00:00.000Z",
+ scannedFiles: 1,
+ skippedFiles: 0,
+ distinctSessions: source.distinctSessions ?? 1,
+ message: null,
+ })),
+ pricing: { status: "fresh", source: "litellm", fetchedAt: null, knownModels: 10 },
+ scanDurationMs: 1,
+ };
+}
+
+function environment(id: string, usageSummary: UsageSummary): EnvironmentUsage {
+ return { environmentId: id as EnvironmentId, label: id, summary: usageSummary };
+}
+
+const claudeSource = (hostId: string): SourceSpec => ({
+ provider: "claude",
+ hostId,
+ homePath: "/Users/will/.claude/projects",
+});
+
+describe("mergeUsage", () => {
+ it("sums environments that read different transcript directories", () => {
+ const merged = mergeUsage(
+ [
+ environment("env-a", summary([bucket()], [claudeSource("mac")])),
+ environment(
+ "env-b",
+ summary([bucket({ costUsd: 4 })], [{ ...claudeSource("windows"), distinctSessions: 3 }]),
+ ),
+ ],
+ USAGE_CONTRACT_VERSION,
+ );
+
+ expect(merged.costUsd).toBe(14);
+ expect(merged.records).toBe(10);
+ expect(merged.sessions).toBe(4);
+ expect(merged.contributingEnvironments).toEqual(["env-a", "env-b"]);
+ expect(merged.duplicateSources).toEqual([]);
+ });
+
+ it("counts a directory once when two environments on one machine scan it", () => {
+ // Worktree servers resolve the same provider home; summing both would
+ // double every token on that machine.
+ const merged = mergeUsage(
+ [
+ environment("env-a", summary([bucket()], [claudeSource("mac")])),
+ environment("env-b", summary([bucket()], [claudeSource("mac")])),
+ ],
+ USAGE_CONTRACT_VERSION,
+ );
+
+ expect(merged.costUsd).toBe(10);
+ expect(merged.sessions).toBe(1);
+ expect(merged.contributingEnvironments).toEqual(["env-a"]);
+ expect(merged.duplicateSources).toEqual(["env-b: /Users/will/.claude/projects"]);
+ });
+
+ it("keeps two machines apart when hostname and home path collide", () => {
+ // Every Mac with the default computer name resolves the same path, so only
+ // the filesystem identity can tell them apart.
+ const merged = mergeUsage(
+ [
+ environment(
+ "env-a",
+ summary([bucket()], [{ ...claudeSource("macbook-pro"), volumeId: "1:2" }]),
+ ),
+ environment(
+ "env-b",
+ summary([bucket()], [{ ...claudeSource("macbook-pro"), volumeId: "3:4" }]),
+ ),
+ ],
+ USAGE_CONTRACT_VERSION,
+ );
+
+ expect(merged.costUsd).toBe(20);
+ expect(merged.duplicateSources).toEqual([]);
+ });
+
+ it("drops only the duplicated provider, not the whole environment", () => {
+ const merged = mergeUsage(
+ [
+ environment("env-a", summary([bucket()], [claudeSource("mac")])),
+ environment(
+ "env-b",
+ summary(
+ [bucket(), bucket({ provider: "codex", model: "gpt-5.6-sol", costUsd: 3 })],
+ [claudeSource("mac"), { provider: "codex", hostId: "mac", homePath: "/x/.codex" }],
+ ),
+ ),
+ ],
+ USAGE_CONTRACT_VERSION,
+ );
+
+ expect(merged.costUsd).toBe(13);
+ expect(merged.providers.map((provider) => provider.provider)).toEqual(["claude", "codex"]);
+ });
+
+ it("excludes an environment on an older contract instead of failing", () => {
+ const merged = mergeUsage(
+ [
+ environment("env-a", summary([bucket()], [claudeSource("mac")])),
+ environment(
+ "env-b",
+ summary([bucket()], [claudeSource("windows")], USAGE_CONTRACT_VERSION - 1),
+ ),
+ ],
+ USAGE_CONTRACT_VERSION,
+ );
+
+ expect(merged.costUsd).toBe(10);
+ expect(merged.staleEnvironments).toEqual(["env-b"]);
+ });
+
+ it("splits cost quality across the three provenances", () => {
+ const merged = mergeUsage(
+ [
+ environment(
+ "env-a",
+ summary(
+ [
+ bucket({ records: 2, costSource: "providerReported" }),
+ bucket({
+ day: "2026-08-08" as UsageDay,
+ records: 6,
+ unpricedRecords: 3,
+ costSource: "modelPriced",
+ }),
+ ],
+ [claudeSource("mac")],
+ ),
+ ),
+ ],
+ USAGE_CONTRACT_VERSION,
+ );
+
+ expect(merged.records).toBe(8);
+ expect(merged.costQuality.providerReportedShare).toBeCloseTo(2 / 8, 9);
+ expect(merged.costQuality.unpricedShare).toBeCloseTo(3 / 8, 9);
+ expect(merged.costQuality.modelPricedShare).toBeCloseTo(3 / 8, 9);
+ expect(merged.costQuality.cacheSavingsUsd).toBe(4);
+ });
+
+ it("reports the oldest scan across contributing sources", () => {
+ const merged = mergeUsage(
+ [
+ environment(
+ "env-a",
+ summary([bucket()], [{ ...claudeSource("mac"), lastScannedAt: "2026-08-07T09:00:00Z" }]),
+ ),
+ environment(
+ "env-b",
+ summary(
+ [bucket()],
+ [{ ...claudeSource("windows"), lastScannedAt: "2026-08-07T06:00:00Z" }],
+ ),
+ ),
+ ],
+ USAGE_CONTRACT_VERSION,
+ );
+
+ expect(merged.oldestScanAt).toBe("2026-08-07T06:00:00Z");
+ });
+
+ it("totals tokens without adding reasoning on top of output", () => {
+ const merged = mergeUsage(
+ [
+ environment(
+ "env-a",
+ summary(
+ [
+ bucket({
+ totals: {
+ uncachedInputTokens: 1,
+ cachedInputTokens: 2,
+ cacheCreationTokens: 3,
+ outputTokens: 4,
+ reasoningTokens: 2,
+ },
+ }),
+ ],
+ [claudeSource("mac")],
+ ),
+ ),
+ ],
+ USAGE_CONTRACT_VERSION,
+ );
+
+ expect(merged.totalTokens).toBe(10);
+ expect(merged.reasoningTokens).toBe(2);
+ });
+});
+
+describe("filterSummaryWindow", () => {
+ it("keeps the days on both bounds and drops the ones outside", () => {
+ const scanned = summary(
+ [
+ bucket({ day: "2026-08-04" as UsageDay }),
+ bucket({ day: "2026-08-05" as UsageDay }),
+ bucket({ day: "2026-08-07" as UsageDay }),
+ bucket({ day: "2026-08-08" as UsageDay }),
+ ],
+ [claudeSource("mac")],
+ );
+
+ const narrowed = filterSummaryWindow(
+ scanned,
+ "2026-08-05" as UsageDay,
+ "2026-08-07" as UsageDay,
+ );
+
+ expect(narrowed.buckets.map((entry) => entry.day)).toEqual(["2026-08-05", "2026-08-07"]);
+ expect(narrowed.sinceDay).toBe("2026-08-05");
+ expect(narrowed.untilDay).toBe("2026-08-07");
+ });
+
+ it("passes the scan's own sources and pricing through untouched", () => {
+ const scanned = summary([bucket()], [claudeSource("mac")]);
+
+ const narrowed = filterSummaryWindow(
+ scanned,
+ "2026-08-07" as UsageDay,
+ "2026-08-07" as UsageDay,
+ );
+
+ expect(narrowed.sources).toBe(scanned.sources);
+ expect(narrowed.pricing).toBe(scanned.pricing);
+ expect(narrowed.readAt).toBe(scanned.readAt);
+ });
+
+ it("re-merges to the totals the narrower window alone would have produced", () => {
+ const scanned = summary(
+ [
+ bucket({ day: "2026-08-01" as UsageDay, costUsd: 10 }),
+ bucket({ day: "2026-08-07" as UsageDay, costUsd: 4 }),
+ ],
+ [claudeSource("mac")],
+ );
+
+ const merged = mergeUsage(
+ [
+ environment(
+ "env-a",
+ filterSummaryWindow(scanned, "2026-08-05" as UsageDay, "2026-08-07" as UsageDay),
+ ),
+ ],
+ USAGE_CONTRACT_VERSION,
+ );
+
+ expect(merged.costUsd).toBe(4);
+ expect(merged.daily.map((entry) => entry.day)).toEqual(["2026-08-07"]);
+ });
+});
diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts
new file mode 100644
index 000000000..e3ffa99c5
--- /dev/null
+++ b/packages/shared/src/usageMerge.ts
@@ -0,0 +1,395 @@
+/**
+ * Merges per-environment usage summaries into the single view the page renders.
+ *
+ * Pure, so the de-duplication and derivation rules can be tested without a
+ * connected environment.
+ *
+ * Every `costUsd` here is an API list-price equivalent, not billed spend.
+ *
+ * @module usageMerge
+ */
+import type {
+ EnvironmentId,
+ UsageBucket,
+ UsageDay,
+ UsageProviderKind,
+ UsageSourceFingerprint,
+ UsageSummary,
+} from "@threadlines/contracts";
+
+/**
+ * Narrows a scanned summary to a shorter day range.
+ *
+ * The page scans once for the longest window it offers and derives the 7- and
+ * 30-day views from that result, so switching windows is arithmetic rather than
+ * another disk walk. Sources, pricing and freshness describe the scan itself and
+ * pass through untouched: they do not become less true for a shorter window.
+ */
+export function filterSummaryWindow(
+ summary: UsageSummary,
+ sinceDay: UsageDay,
+ untilDay: UsageDay,
+): UsageSummary {
+ return {
+ ...summary,
+ sinceDay,
+ untilDay,
+ // `YYYY-MM-DD` days with fixed width compare correctly as strings.
+ buckets: summary.buckets.filter((bucket) => bucket.day >= sinceDay && bucket.day <= untilDay),
+ };
+}
+
+export interface EnvironmentUsage {
+ readonly environmentId: EnvironmentId;
+ readonly label: string;
+ readonly summary: UsageSummary;
+}
+
+export interface ProviderTotals {
+ readonly provider: UsageProviderKind;
+ readonly costUsd: number;
+ readonly totalTokens: number;
+ readonly records: number;
+ readonly costShare: number;
+ readonly tokenShare: number;
+}
+
+export interface ModelTotals {
+ readonly model: string;
+ readonly provider: UsageProviderKind;
+ readonly costUsd: number;
+ readonly totalTokens: number;
+ readonly records: number;
+ readonly costShare: number;
+}
+
+export interface DailyTotals {
+ readonly day: string;
+ readonly costUsd: number;
+ readonly totalTokens: number;
+ readonly byProvider: ReadonlyMap;
+}
+
+export interface CostQuality {
+ readonly providerReportedShare: number;
+ readonly modelPricedShare: number;
+ readonly unpricedShare: number;
+ readonly cacheSavingsUsd: number;
+}
+
+export interface MergedUsage {
+ readonly costUsd: number;
+ readonly uncachedInputTokens: number;
+ readonly cachedInputTokens: number;
+ readonly cacheCreationTokens: number;
+ readonly outputTokens: number;
+ readonly reasoningTokens: number;
+ readonly totalTokens: number;
+ readonly records: number;
+ readonly sessions: number;
+ readonly providers: readonly ProviderTotals[];
+ readonly models: readonly ModelTotals[];
+ readonly daily: readonly DailyTotals[];
+ readonly costQuality: CostQuality;
+ /** Environments whose data was dropped as a duplicate of another's. */
+ readonly duplicateSources: readonly string[];
+ readonly contributingEnvironments: readonly EnvironmentId[];
+ readonly staleEnvironments: readonly EnvironmentId[];
+ /** Oldest `lastScannedAt` across contributing sources, or `null` when none. */
+ readonly oldestScanAt: string | null;
+}
+
+/**
+ * Two sources are the same physical transcript directory only when host,
+ * provider, path and filesystem identity all agree.
+ *
+ * `volumeId` is what stops two machines that happen to share a hostname and a
+ * home path, which is every Mac with the default computer name, from collapsing
+ * into one source and having one of them silently dropped.
+ */
+function fingerprintKey(fingerprint: UsageSourceFingerprint): string {
+ return [
+ fingerprint.hostId,
+ fingerprint.provider,
+ fingerprint.resolvedHomePath,
+ fingerprint.volumeId,
+ ].join(" ");
+}
+
+/**
+ * Decides which environment owns each physical transcript directory.
+ *
+ * Several environments on one machine (worktree servers, for instance) resolve
+ * the same provider home and would otherwise double count every token. The
+ * first environment in a stable order claims a fingerprint; the rest have that
+ * provider's buckets dropped. Environments are sorted by id so the winner does
+ * not change between renders.
+ */
+function claimSources(environments: readonly EnvironmentUsage[]): {
+ readonly ownerByFingerprint: ReadonlyMap;
+ readonly duplicates: readonly string[];
+} {
+ const ownerByFingerprint = new Map();
+ const duplicates: string[] = [];
+
+ const ordered = [...environments].sort((a, b) => a.environmentId.localeCompare(b.environmentId));
+
+ for (const environment of ordered) {
+ for (const source of environment.summary.sources) {
+ if (source.status === "missing") continue;
+ const key = fingerprintKey(source.fingerprint);
+ if (ownerByFingerprint.has(key)) {
+ duplicates.push(`${environment.label}: ${source.fingerprint.resolvedHomePath}`);
+ continue;
+ }
+ ownerByFingerprint.set(key, environment.environmentId);
+ }
+ }
+
+ return { ownerByFingerprint, duplicates };
+}
+
+/** Sources this environment owns after fingerprint claims, plus their buckets. */
+function ownedContribution(
+ environment: EnvironmentUsage,
+ ownerByFingerprint: ReadonlyMap,
+): {
+ readonly buckets: readonly UsageBucket[];
+ readonly sessions: number;
+ readonly oldestScanAt: string | null;
+} {
+ const ownedProviders = new Set();
+ let sessions = 0;
+ let oldestScanAt: string | null = null;
+ for (const source of environment.summary.sources) {
+ if (source.status === "missing") continue;
+ const key = fingerprintKey(source.fingerprint);
+ if (ownerByFingerprint.get(key) !== environment.environmentId) continue;
+ ownedProviders.add(source.fingerprint.provider);
+ // Distinct within a directory. Summing per-bucket session counts instead
+ // would count a session once per day and model it spans.
+ sessions += source.distinctSessions;
+ // ISO instants with the same precision sort lexicographically.
+ if (oldestScanAt === null || source.lastScannedAt < oldestScanAt) {
+ oldestScanAt = source.lastScannedAt;
+ }
+ }
+ return {
+ buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)),
+ sessions,
+ oldestScanAt,
+ };
+}
+
+function bucketTokens(bucket: UsageBucket): number {
+ // reasoningTokens is a subset of outputTokens and must not be added again.
+ return (
+ bucket.totals.uncachedInputTokens +
+ bucket.totals.cachedInputTokens +
+ bucket.totals.cacheCreationTokens +
+ bucket.totals.outputTokens
+ );
+}
+
+const EMPTY_MERGED: MergedUsage = {
+ costUsd: 0,
+ uncachedInputTokens: 0,
+ cachedInputTokens: 0,
+ cacheCreationTokens: 0,
+ outputTokens: 0,
+ reasoningTokens: 0,
+ totalTokens: 0,
+ records: 0,
+ sessions: 0,
+ providers: [],
+ models: [],
+ daily: [],
+ costQuality: {
+ providerReportedShare: 0,
+ modelPricedShare: 0,
+ unpricedShare: 0,
+ cacheSavingsUsd: 0,
+ },
+ duplicateSources: [],
+ contributingEnvironments: [],
+ staleEnvironments: [],
+ oldestScanAt: null,
+};
+
+/**
+ * Merges every connected environment's summary.
+ *
+ * `expectedContractVersion` guards against an environment running older server
+ * code: rather than blocking the page, its data is excluded and its id is
+ * reported so the UI can say coverage is partial.
+ */
+export function mergeUsage(
+ environments: readonly EnvironmentUsage[],
+ expectedContractVersion: number,
+): MergedUsage {
+ if (environments.length === 0) return EMPTY_MERGED;
+
+ const current: EnvironmentUsage[] = [];
+ const staleEnvironments: EnvironmentId[] = [];
+ for (const environment of environments) {
+ if (environment.summary.contractVersion === expectedContractVersion) {
+ current.push(environment);
+ } else {
+ staleEnvironments.push(environment.environmentId);
+ }
+ }
+
+ const { ownerByFingerprint, duplicates } = claimSources(current);
+
+ let costUsd = 0;
+ let uncachedInputTokens = 0;
+ let cachedInputTokens = 0;
+ let cacheCreationTokens = 0;
+ let outputTokens = 0;
+ let reasoningTokens = 0;
+ let records = 0;
+ let sessions = 0;
+ let cacheSavingsUsd = 0;
+ let providerReportedRecords = 0;
+ let unpricedRecords = 0;
+ let oldestScanAt: string | null = null;
+
+ const providerAccumulator = new Map<
+ UsageProviderKind,
+ { costUsd: number; totalTokens: number; records: number }
+ >();
+ const modelAccumulator = new Map<
+ string,
+ { provider: UsageProviderKind; costUsd: number; totalTokens: number; records: number }
+ >();
+ const dailyAccumulator = new Map<
+ string,
+ {
+ costUsd: number;
+ totalTokens: number;
+ byProvider: Map;
+ }
+ >();
+ const contributingEnvironments: EnvironmentId[] = [];
+
+ for (const environment of current) {
+ const contribution = ownedContribution(environment, ownerByFingerprint);
+ if (contribution.buckets.length > 0) contributingEnvironments.push(environment.environmentId);
+ sessions += contribution.sessions;
+ if (
+ contribution.oldestScanAt !== null &&
+ (oldestScanAt === null || contribution.oldestScanAt < oldestScanAt)
+ ) {
+ oldestScanAt = contribution.oldestScanAt;
+ }
+
+ for (const bucket of contribution.buckets) {
+ const tokens = bucketTokens(bucket);
+
+ costUsd += bucket.costUsd;
+ cacheSavingsUsd += bucket.cacheSavingsUsd;
+ uncachedInputTokens += bucket.totals.uncachedInputTokens;
+ cachedInputTokens += bucket.totals.cachedInputTokens;
+ cacheCreationTokens += bucket.totals.cacheCreationTokens;
+ outputTokens += bucket.totals.outputTokens;
+ reasoningTokens += bucket.totals.reasoningTokens;
+ records += bucket.records;
+ unpricedRecords += bucket.unpricedRecords;
+ if (bucket.costSource === "providerReported") providerReportedRecords += bucket.records;
+
+ const provider = providerAccumulator.get(bucket.provider) ?? {
+ costUsd: 0,
+ totalTokens: 0,
+ records: 0,
+ };
+ provider.costUsd += bucket.costUsd;
+ provider.totalTokens += tokens;
+ provider.records += bucket.records;
+ providerAccumulator.set(bucket.provider, provider);
+
+ const modelKey = `${bucket.provider} ${bucket.model}`;
+ const model = modelAccumulator.get(modelKey) ?? {
+ provider: bucket.provider,
+ costUsd: 0,
+ totalTokens: 0,
+ records: 0,
+ };
+ model.costUsd += bucket.costUsd;
+ model.totalTokens += tokens;
+ model.records += bucket.records;
+ modelAccumulator.set(modelKey, model);
+
+ const day = dailyAccumulator.get(bucket.day) ?? {
+ costUsd: 0,
+ totalTokens: 0,
+ byProvider: new Map(),
+ };
+ day.costUsd += bucket.costUsd;
+ day.totalTokens += tokens;
+ const dayProvider = day.byProvider.get(bucket.provider) ?? { costUsd: 0, totalTokens: 0 };
+ dayProvider.costUsd += bucket.costUsd;
+ dayProvider.totalTokens += tokens;
+ day.byProvider.set(bucket.provider, dayProvider);
+ dailyAccumulator.set(bucket.day, day);
+ }
+ }
+
+ const totalTokens = uncachedInputTokens + cachedInputTokens + cacheCreationTokens + outputTokens;
+
+ const providers: ProviderTotals[] = [...providerAccumulator.entries()]
+ .map(([provider, totals]) => ({
+ provider,
+ costUsd: totals.costUsd,
+ totalTokens: totals.totalTokens,
+ records: totals.records,
+ costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,
+ tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens,
+ }))
+ .sort((a, b) => b.costUsd - a.costUsd);
+
+ const models: ModelTotals[] = [...modelAccumulator.entries()]
+ .map(([key, totals]) => ({
+ model: key.slice(key.indexOf(" ") + 1),
+ provider: totals.provider,
+ costUsd: totals.costUsd,
+ totalTokens: totals.totalTokens,
+ records: totals.records,
+ costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,
+ }))
+ .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens);
+
+ const daily: DailyTotals[] = [...dailyAccumulator.entries()]
+ .map(([day, totals]) => ({
+ day,
+ costUsd: totals.costUsd,
+ totalTokens: totals.totalTokens,
+ byProvider: totals.byProvider,
+ }))
+ .sort((a, b) => a.day.localeCompare(b.day));
+
+ return {
+ costUsd,
+ uncachedInputTokens,
+ cachedInputTokens,
+ cacheCreationTokens,
+ outputTokens,
+ reasoningTokens,
+ totalTokens,
+ records,
+ sessions,
+ providers,
+ models,
+ daily,
+ costQuality: {
+ providerReportedShare: records === 0 ? 0 : providerReportedRecords / records,
+ unpricedShare: records === 0 ? 0 : unpricedRecords / records,
+ modelPricedShare:
+ records === 0 ? 0 : (records - providerReportedRecords - unpricedRecords) / records,
+ cacheSavingsUsd,
+ },
+ duplicateSources: duplicates,
+ contributingEnvironments,
+ staleEnvironments,
+ oldestScanAt,
+ };
+}