From fa7061b47a9f3eb3672d01ecee0a151ae435fe82 Mon Sep 17 00:00:00 2001 From: Donovan Montoya <5290597+DonovanMontoya@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:21:17 -0400 Subject: [PATCH 1/8] fix(server): usage scans every configured provider instance home The usage scan resolved one transcript directory per provider from the legacy single-instance settings, so Claude and Codex instances configured through providerInstances (separate accounts with their own config directories) reported zero usage. Enumerate instance homes with the same settings merge the runtime registry uses, honor CLAUDE_CONFIG_DIR for homeless instances, and dedupe instances that share a directory. --- apps/server/src/usage/UsageService.ts | 52 ++++---- .../src/usage/usageProviderHomes.test.ts | 115 ++++++++++++++++++ apps/server/src/usage/usageProviderHomes.ts | 100 +++++++++++++++ docs/user/usage.md | 3 + 4 files changed, 246 insertions(+), 24 deletions(-) create mode 100644 apps/server/src/usage/usageProviderHomes.test.ts create mode 100644 apps/server/src/usage/usageProviderHomes.ts diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 0e6b0c1eecd6..689996b191d7 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -40,12 +40,10 @@ import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../config.ts"; -import { expandHomePath } from "../pathExpansion.ts"; import * as ServerSettings from "../serverSettings.ts"; -import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; -import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { UsageAggregator } from "./usageAggregation.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; +import { resolveUsageProviderHomes } from "./usageProviderHomes.ts"; import { listTranscriptFiles, readDirectoryVolumeId, @@ -245,30 +243,36 @@ export const make = Effect.gen(function* () { ), ); - /** Resolves the transcript directory for each provider. */ + /** + * Resolves the transcript directories for each provider. Claude and Codex + * can be configured multiple times via provider instances, so both may + * contribute several directories; distinct instances sharing a home + * collapse to one entry so their records are not double counted. + */ const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* ( settings: ServerSettingsValue, ) { - const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent); - const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome); - const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex); - // Grok Settings only expose the binary path; home is `$GROK_HOME` or `~/.grok`. - // Empty/whitespace GROK_HOME must fall back: coalescing alone would scan cwd. - const grokHomeEnv = hostEnvironment["GROK_HOME"]?.trim() ?? ""; - const grokHome = - grokHomeEnv.length > 0 - ? path.resolve(expandHomePath(grokHomeEnv)) - : path.join(NodeOS.homedir(), ".grok"); - - return [ - { provider: "claude" as const, dir: claudeDir }, - { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") }, - { - provider: "grok" as const, - dir: path.join(grokHome, "sessions"), - fileName: "updates.jsonl", - }, - ]; + const homes = yield* resolveUsageProviderHomes(settings, hostEnvironment); + + const dirs: Array<{ + provider: UsageProviderKind; + dir: string; + fileName?: string; + }> = []; + const claudeDirs = new Set(); + for (const home of homes.claudeHomePaths) { + // Distinct homes can probe to the same transcript dir (e.g. `~/x` with + // a nested `.claude` next to `~/x/.claude` itself), so dedupe post-probe. + const dir = yield* resolveClaudeTranscriptDir(home); + if (claudeDirs.has(dir)) continue; + claudeDirs.add(dir); + dirs.push({ provider: "claude", dir }); + } + for (const dir of homes.codexSessionDirs) { + dirs.push({ provider: "codex", dir }); + } + dirs.push({ provider: "grok", dir: homes.grokSessionsDir, fileName: "updates.jsonl" }); + return dirs; }); /** diff --git a/apps/server/src/usage/usageProviderHomes.test.ts b/apps/server/src/usage/usageProviderHomes.test.ts new file mode 100644 index 000000000000..14eeb29ed529 --- /dev/null +++ b/apps/server/src/usage/usageProviderHomes.test.ts @@ -0,0 +1,115 @@ +import * as NodeOS from "node:os"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { ServerSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { resolveUsageProviderHomes } from "./usageProviderHomes.ts"; + +const decodeSettings = Schema.decodeUnknownSync(ServerSettings); + +it.layer(NodeServices.layer)("usageProviderHomes", (it) => { + describe("resolveUsageProviderHomes", () => { + it.effect("scans every configured Claude instance home, not just the default", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const settings = decodeSettings({ + providerInstances: { + claude_max: { driver: "claudeAgent", config: { homePath: "~/.claude-max" } }, + claude_pro: { + driver: "claudeAgent", + environment: [{ name: "CLAUDE_CONFIG_DIR", value: "~/.claude-pro" }], + }, + codex_work: { driver: "codex", config: { homePath: "~/.codex-work" } }, + }, + }); + + const homes = yield* resolveUsageProviderHomes(settings, {}); + + expect(homes.claudeHomePaths).toEqual([ + path.resolve(NodeOS.homedir(), ".claude-max"), + path.resolve(NodeOS.homedir(), ".claude-pro"), + // Synthesized legacy `claudeAgent` instance: the default home. + path.resolve(NodeOS.homedir()), + ]); + expect(homes.codexSessionDirs).toEqual([ + path.join(path.resolve(NodeOS.homedir(), ".codex-work"), "sessions"), + path.join(NodeOS.homedir(), ".codex", "sessions"), + ]); + expect(homes.grokSessionsDir).toBe(path.join(NodeOS.homedir(), ".grok", "sessions")); + }), + ); + + it.effect("collapses instances that resolve to the same home", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const settings = decodeSettings({ + providerInstances: { + claude_alias: { driver: "claudeAgent", config: { homePath: "" } }, + }, + }); + + const homes = yield* resolveUsageProviderHomes(settings, {}); + + // `claude_alias` and the synthesized legacy instance share the + // default home; scanning it twice would double count every record. + expect(homes.claudeHomePaths).toEqual([path.resolve(NodeOS.homedir())]); + }), + ); + + it.effect("skips instances whose config fails to decode", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const settings = decodeSettings({ + providerInstances: { + claude_bad: { driver: "claudeAgent", config: { homePath: 42 } }, + }, + }); + + const homes = yield* resolveUsageProviderHomes(settings, {}); + + expect(homes.claudeHomePaths).toEqual([path.resolve(NodeOS.homedir())]); + }), + ); + + it.effect("ignores the server's ambient CLAUDE_CONFIG_DIR", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const settings = decodeSettings({}); + + // What usage scans must be determined by settings alone, not by the + // environment this particular server process was launched with. + const homes = yield* resolveUsageProviderHomes(settings, { + CLAUDE_CONFIG_DIR: "~/.claude-ambient", + }); + + expect(homes.claudeHomePaths).toEqual([path.resolve(NodeOS.homedir())]); + }), + ); + + it.effect("still resolves legacy single-instance homes from providers settings", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const settings = decodeSettings({ + providers: { + claudeAgent: { homePath: "~/.claude-legacy" }, + codex: { homePath: "~/.codex-legacy" }, + }, + }); + + const homes = yield* resolveUsageProviderHomes(settings, { GROK_HOME: "~/.grok-custom" }); + + expect(homes.claudeHomePaths).toEqual([path.resolve(NodeOS.homedir(), ".claude-legacy")]); + expect(homes.codexSessionDirs).toEqual([ + path.join(path.resolve(NodeOS.homedir(), ".codex-legacy"), "sessions"), + ]); + expect(homes.grokSessionsDir).toBe( + path.join(path.resolve(NodeOS.homedir(), ".grok-custom"), "sessions"), + ); + }), + ); + }); +}); diff --git a/apps/server/src/usage/usageProviderHomes.ts b/apps/server/src/usage/usageProviderHomes.ts new file mode 100644 index 000000000000..6e2829c82420 --- /dev/null +++ b/apps/server/src/usage/usageProviderHomes.ts @@ -0,0 +1,100 @@ +/** + * usageProviderHomes - enumerates the transcript homes the usage scan reads. + * + * Providers can be configured multiple times through `providerInstances` + * (e.g. `claude_pro` + `claude_max`), each isolated in its own home. The scan + * must read every configured home, not just the legacy single-instance + * settings, or secondary accounts silently report zero usage. + * + * Resolution mirrors what the spawned CLI actually uses: an explicit + * `homePath` becomes `CLAUDE_CONFIG_DIR`; without one, a `CLAUDE_CONFIG_DIR` + * configured on the instance itself wins, and otherwise the default home. + * The server process's own ambient environment is deliberately not consulted + * for Claude, so what usage scans is determined by settings alone rather + * than by how this particular server happened to be launched. + * + * @module usageProviderHomes + */ +import * as NodeOS from "node:os"; + +import { ClaudeSettings, CodexSettings, type ServerSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { expandHomePath } from "../pathExpansion.ts"; +import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; +import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { deriveProviderInstanceConfigMap } from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; +import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; + +const decodeClaudeSettings = Schema.decodeUnknownEffect(ClaudeSettings); +const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings); + +export interface UsageProviderHomes { + /** One entry per distinct Claude home; transcripts nest under it. */ + readonly claudeHomePaths: readonly string[]; + /** One entry per distinct Codex `sessions` directory. */ + readonly codexSessionDirs: readonly string[]; + readonly grokSessionsDir: string; +} + +export const resolveUsageProviderHomes = Effect.fn("resolveUsageProviderHomes")(function* ( + settings: ServerSettings, + hostEnvironment: NodeJS.ProcessEnv, +): Effect.fn.Return { + const path = yield* Path.Path; + const instances = deriveProviderInstanceConfigMap(settings); + + const claudeHomePaths: string[] = []; + const codexSessionDirs: string[] = []; + const pushUnique = (list: string[], value: string) => { + if (!list.includes(value)) list.push(value); + }; + + // Disabled instances still scan: usage covers turns driven outside T3 Code, + // and a paused instance's transcripts are still this machine's spend. + for (const envelope of Object.values(instances)) { + if (envelope.driver === "claudeAgent") { + // An envelope whose config fails to decode is already surfaced as an + // unavailable instance by the registry; usage just skips it. + const config = yield* decodeClaudeSettings(envelope.config ?? {}).pipe( + Effect.catchCause(() => Effect.succeed(null)), + ); + if (config === null) continue; + if (config.homePath.trim().length > 0) { + pushUnique(claudeHomePaths, yield* resolveClaudeHomePath(config)); + continue; + } + const environment = mergeProviderInstanceEnvironment(envelope.environment, {}); + const configDir = environment["CLAUDE_CONFIG_DIR"]?.trim() ?? ""; + pushUnique( + claudeHomePaths, + configDir.length > 0 + ? path.resolve(expandHomePath(configDir)) + : yield* resolveClaudeHomePath(config), + ); + } else if (envelope.driver === "codex") { + const config = yield* decodeCodexSettings(envelope.config ?? {}).pipe( + Effect.catchCause(() => Effect.succeed(null)), + ); + if (config === null) continue; + const layout = yield* resolveCodexHomeLayout(config); + pushUnique(codexSessionDirs, path.join(layout.sharedHomePath, "sessions")); + } + } + + // Grok Settings only expose the binary path; home is `$GROK_HOME` or `~/.grok`. + // Empty/whitespace GROK_HOME must fall back: coalescing alone would scan cwd. + const grokHomeEnv = hostEnvironment["GROK_HOME"]?.trim() ?? ""; + const grokHome = + grokHomeEnv.length > 0 + ? path.resolve(expandHomePath(grokHomeEnv)) + : path.join(NodeOS.homedir(), ".grok"); + + return { + claudeHomePaths, + codexSessionDirs, + grokSessionsDir: path.join(grokHome, "sessions"), + }; +}); diff --git a/docs/user/usage.md b/docs/user/usage.md index a0231856ef69..d8d7d42e3b08 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -6,6 +6,9 @@ environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent cost. These estimates are not your subscription bill. +If you have configured multiple instances of a provider (for example separate Claude accounts +with their own config directories), each instance's history is included. + Totals depend on the history available on each server. Grok turns without a saved completed-turn record are missing from the totals. From ab5e862d9af27fc4975fea7d1438152605a5c56b Mon Sep 17 00:00:00 2001 From: Donovan Montoya <5290597+DonovanMontoya@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:36:42 -0400 Subject: [PATCH 2/8] fix(server): resolve instance environment homes the way the CLI does Environment-provided homes (CLAUDE_CONFIG_DIR, CODEX_HOME) reach the spawned CLI verbatim, so usage no longer tilde-expands them and only honors absolute values; relative ones depend on each workspace's cwd and have no single scan directory. Codex instances isolated purely through an instance-level CODEX_HOME are now scanned too, matching the runtime's config-over-environment precedence. --- .../src/usage/usageProviderHomes.test.ts | 36 +++++++++++++++++- apps/server/src/usage/usageProviderHomes.ts | 37 +++++++++++++------ 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/apps/server/src/usage/usageProviderHomes.test.ts b/apps/server/src/usage/usageProviderHomes.test.ts index 14eeb29ed529..c09b30c51e09 100644 --- a/apps/server/src/usage/usageProviderHomes.test.ts +++ b/apps/server/src/usage/usageProviderHomes.test.ts @@ -21,9 +21,17 @@ it.layer(NodeServices.layer)("usageProviderHomes", (it) => { claude_max: { driver: "claudeAgent", config: { homePath: "~/.claude-max" } }, claude_pro: { driver: "claudeAgent", - environment: [{ name: "CLAUDE_CONFIG_DIR", value: "~/.claude-pro" }], + environment: [ + { name: "CLAUDE_CONFIG_DIR", value: path.join(NodeOS.homedir(), ".claude-pro") }, + ], }, codex_work: { driver: "codex", config: { homePath: "~/.codex-work" } }, + codex_env: { + driver: "codex", + environment: [ + { name: "CODEX_HOME", value: path.join(NodeOS.homedir(), ".codex-env") }, + ], + }, }, }); @@ -37,6 +45,7 @@ it.layer(NodeServices.layer)("usageProviderHomes", (it) => { ]); expect(homes.codexSessionDirs).toEqual([ path.join(path.resolve(NodeOS.homedir(), ".codex-work"), "sessions"), + path.join(NodeOS.homedir(), ".codex-env", "sessions"), path.join(NodeOS.homedir(), ".codex", "sessions"), ]); expect(homes.grokSessionsDir).toBe(path.join(NodeOS.homedir(), ".grok", "sessions")); @@ -75,6 +84,31 @@ it.layer(NodeServices.layer)("usageProviderHomes", (it) => { }), ); + it.effect("ignores non-absolute environment homes, which the CLI gets verbatim", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const settings = decodeSettings({ + providerInstances: { + // Env vars are never shell-expanded, so a tilde or relative value + // depends on each workspace's cwd and has no single scan dir. + claude_tilde: { + driver: "claudeAgent", + environment: [{ name: "CLAUDE_CONFIG_DIR", value: "~/.claude-tilde" }], + }, + codex_relative: { + driver: "codex", + environment: [{ name: "CODEX_HOME", value: "codex-home" }], + }, + }, + }); + + const homes = yield* resolveUsageProviderHomes(settings, {}); + + expect(homes.claudeHomePaths).toEqual([path.resolve(NodeOS.homedir())]); + expect(homes.codexSessionDirs).toEqual([path.join(NodeOS.homedir(), ".codex", "sessions")]); + }), + ); + it.effect("ignores the server's ambient CLAUDE_CONFIG_DIR", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/usage/usageProviderHomes.ts b/apps/server/src/usage/usageProviderHomes.ts index 6e2829c82420..9f0cc20686f9 100644 --- a/apps/server/src/usage/usageProviderHomes.ts +++ b/apps/server/src/usage/usageProviderHomes.ts @@ -7,10 +7,10 @@ * settings, or secondary accounts silently report zero usage. * * Resolution mirrors what the spawned CLI actually uses: an explicit - * `homePath` becomes `CLAUDE_CONFIG_DIR`; without one, a `CLAUDE_CONFIG_DIR` - * configured on the instance itself wins, and otherwise the default home. - * The server process's own ambient environment is deliberately not consulted - * for Claude, so what usage scans is determined by settings alone rather + * `homePath` wins; without one, an absolute home configured on the instance + * environment (`CLAUDE_CONFIG_DIR` / `CODEX_HOME`) wins, and otherwise the + * default home. The server process's own ambient environment is deliberately + * not consulted, so what usage scans is determined by settings alone rather * than by how this particular server happened to be launched. * * @module usageProviderHomes @@ -52,6 +52,18 @@ export const resolveUsageProviderHomes = Effect.fn("resolveUsageProviderHomes")( if (!list.includes(value)) list.push(value); }; + /** + * Home taken from an instance environment variable. The spawned CLI + * receives env vars verbatim (never shell-expanded), so no tilde expansion + * here — see `resolveClaudeConfigDirPath` in ClaudeSkills. A relative value + * resolves against each workspace's own cwd and therefore has no single + * scan directory; only absolute values are honored. + */ + const environmentHomePath = (value: string | undefined): string | null => { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 && path.isAbsolute(trimmed) ? path.resolve(trimmed) : null; + }; + // Disabled instances still scan: usage covers turns driven outside T3 Code, // and a paused instance's transcripts are still this machine's spend. for (const envelope of Object.values(instances)) { @@ -67,20 +79,21 @@ export const resolveUsageProviderHomes = Effect.fn("resolveUsageProviderHomes")( continue; } const environment = mergeProviderInstanceEnvironment(envelope.environment, {}); - const configDir = environment["CLAUDE_CONFIG_DIR"]?.trim() ?? ""; - pushUnique( - claudeHomePaths, - configDir.length > 0 - ? path.resolve(expandHomePath(configDir)) - : yield* resolveClaudeHomePath(config), - ); + const configDir = environmentHomePath(environment["CLAUDE_CONFIG_DIR"]); + pushUnique(claudeHomePaths, configDir ?? (yield* resolveClaudeHomePath(config))); } else if (envelope.driver === "codex") { const config = yield* decodeCodexSettings(envelope.config ?? {}).pipe( Effect.catchCause(() => Effect.succeed(null)), ); if (config === null) continue; const layout = yield* resolveCodexHomeLayout(config); - pushUnique(codexSessionDirs, path.join(layout.sharedHomePath, "sessions")); + // The runtime only exports CODEX_HOME from `homePath` when it is set, + // so with an empty `homePath` an instance-level CODEX_HOME reaches the + // CLI and decides where sessions land. + const environment = mergeProviderInstanceEnvironment(envelope.environment, {}); + const environmentHome = + config.homePath.trim().length === 0 ? environmentHomePath(environment["CODEX_HOME"]) : null; + pushUnique(codexSessionDirs, path.join(environmentHome ?? layout.sharedHomePath, "sessions")); } } From d88027f5501087df33c7966b67019e85bfb6b373 Mon Sep 17 00:00:00 2001 From: Donovan Montoya <5290597+DonovanMontoya@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:40:54 -0400 Subject: [PATCH 3/8] fix(server): keep shadow-overlay Codex homes over inert CODEX_HOME With a shadow overlay the runtime overrides CODEX_HOME and the shadow's sessions symlink back to the shared home, so an instance-level CODEX_HOME must only decide the scan directory when the layout yields no effective home. --- .../src/usage/usageProviderHomes.test.ts | 23 +++++++++++++++++++ apps/server/src/usage/usageProviderHomes.ts | 12 ++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/apps/server/src/usage/usageProviderHomes.test.ts b/apps/server/src/usage/usageProviderHomes.test.ts index c09b30c51e09..78c276bec2b2 100644 --- a/apps/server/src/usage/usageProviderHomes.test.ts +++ b/apps/server/src/usage/usageProviderHomes.test.ts @@ -109,6 +109,29 @@ it.layer(NodeServices.layer)("usageProviderHomes", (it) => { }), ); + it.effect("prefers the shadow-overlay shared home over an inert instance CODEX_HOME", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const settings = decodeSettings({ + providerInstances: { + // With a shadow overlay the runtime overrides CODEX_HOME, and + // the shadow's sessions symlink back to the shared home. + codex_shadow: { + driver: "codex", + config: { shadowHomePath: "~/.codex-shadow" }, + environment: [ + { name: "CODEX_HOME", value: path.join(NodeOS.homedir(), ".codex-env") }, + ], + }, + }, + }); + + const homes = yield* resolveUsageProviderHomes(settings, {}); + + expect(homes.codexSessionDirs).toEqual([path.join(NodeOS.homedir(), ".codex", "sessions")]); + }), + ); + it.effect("ignores the server's ambient CLAUDE_CONFIG_DIR", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/usage/usageProviderHomes.ts b/apps/server/src/usage/usageProviderHomes.ts index 9f0cc20686f9..0feaf203c3ee 100644 --- a/apps/server/src/usage/usageProviderHomes.ts +++ b/apps/server/src/usage/usageProviderHomes.ts @@ -87,12 +87,16 @@ export const resolveUsageProviderHomes = Effect.fn("resolveUsageProviderHomes")( ); if (config === null) continue; const layout = yield* resolveCodexHomeLayout(config); - // The runtime only exports CODEX_HOME from `homePath` when it is set, - // so with an empty `homePath` an instance-level CODEX_HOME reaches the - // CLI and decides where sessions land. + // The runtime overrides CODEX_HOME whenever the layout yields an + // effective home (explicit homePath, or a shadow overlay whose + // sessions symlink back to the shared home). Only without one does an + // instance-level CODEX_HOME reach the CLI and decide where sessions + // land. const environment = mergeProviderInstanceEnvironment(envelope.environment, {}); const environmentHome = - config.homePath.trim().length === 0 ? environmentHomePath(environment["CODEX_HOME"]) : null; + layout.effectiveHomePath === undefined + ? environmentHomePath(environment["CODEX_HOME"]) + : null; pushUnique(codexSessionDirs, path.join(environmentHome ?? layout.sharedHomePath, "sessions")); } } From 1da540a25331e084acf7ab5627d1919274e259cd Mon Sep 17 00:00:00 2001 From: Donovan Date: Fri, 28 Aug 2026 00:16:46 -0400 Subject: [PATCH 4/8] fix(shared): usage merge claims provider homes atomically per environment With multiple homes per provider, an environment's buckets aggregate every home it scans, but the cross-environment merge claimed ownership per directory. An environment owning only part of another's home set still contributed its full per-provider aggregate, double counting the shared directories. Claims are now the environment's whole fingerprint set for a provider, with larger sets winning so a superset environment keeps its unique homes. --- packages/shared/src/usageMerge.test.ts | 49 ++++++++++++ packages/shared/src/usageMerge.ts | 105 ++++++++++++++++--------- 2 files changed, 118 insertions(+), 36 deletions(-) diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 6c706395c6ff..ddcdac737bd5 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -146,6 +146,55 @@ describe("mergeUsage", () => { ).toEqual({ claude: 1, codex: 1 }); }); + it("does not double count when environments overlap on some homes but not all", () => { + // env-b scans two instance homes; env-a scans only one of them. env-a's + // buckets aggregate H1 while env-b's aggregate H1+H2, so letting both + // contribute would count H1 twice. The larger set wins regardless of id + // order and the smaller environment's provider is dropped entirely. + const h1 = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" }; + const h2 = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude-max" }; + const merged = mergeUsage( + [ + environment("env-a", summary([bucket({ costUsd: 10, records: 5 })], [h1])), + environment("env-b", summary([bucket({ costUsd: 16, records: 8 })], [h1, h2])), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(16); + expect(merged.records).toBe(8); + expect(merged.sessions).toBe(2); + expect(merged.contributingEnvironments).toEqual(["env-b"]); + expect(merged.duplicateSources).toEqual(["env-a: /home/theo/.claude"]); + }); + + it("reports every dropped home when overlapping sets tie on size", () => { + // Neither set covers the other, so whichever loses the deterministic + // tie-break loses its unique home too; both of its paths are surfaced so + // the UI can say coverage is partial. + const shared = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" }; + const merged = mergeUsage( + [ + environment( + "env-a", + summary([bucket()], [shared, { ...shared, homePath: "/home/theo/.claude-a" }]), + ), + environment( + "env-b", + summary([bucket()], [shared, { ...shared, homePath: "/home/theo/.claude-b" }]), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(10); + expect(merged.contributingEnvironments).toEqual(["env-a"]); + expect(merged.duplicateSources).toEqual([ + "env-b: /home/theo/.claude", + "env-b: /home/theo/.claude-b", + ]); + }); + it("excludes an environment reporting an older contract version", () => { const merged = mergeUsage( [ diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 95982bf507da..7b7ab59c650e 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -77,7 +77,7 @@ export interface MergedUsage { readonly daily: readonly DailyTotals[]; readonly hourly: readonly HourlyTotals[]; readonly costQuality: CostQuality; - /** Environments whose data was dropped as a duplicate of another's. */ + /** Sources dropped because another environment claimed their directories. */ readonly duplicateSources: readonly string[]; readonly contributingEnvironments: readonly EnvironmentId[]; readonly staleEnvironments: readonly EnvironmentId[]; @@ -101,61 +101,91 @@ function fingerprintKey(fingerprint: UsageSourceFingerprint): string { } /** - * Decides which environment owns each physical transcript directory. + * Decides which environment owns each provider's transcript directories. * * 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. + * the same provider home and would otherwise double count every token. Buckets + * are per-provider aggregates over every directory an environment scans, so + * the claim unit is an environment's whole set of fingerprints for a provider: + * owning only part of the set would still contribute the full aggregate and + * double count the shared directories. A group that overlaps an already + * claimed fingerprint is dropped entirely and every one of its paths is + * reported, so the UI can say coverage is partial. Groups with more + * directories claim first — an environment configured with a superset of + * another's homes must win or its unique homes' data would be lost — with + * environment id breaking ties so the winner does not change between renders. */ function claimSources(environments: readonly EnvironmentUsage[]): { - readonly ownerByFingerprint: ReadonlyMap; + readonly ownedProvidersByEnvironment: 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) { + const groups: Array<{ + environmentId: EnvironmentId; + label: string; + provider: UsageProviderKind; + keys: string[]; + paths: string[]; + }> = []; + for (const environment of environments) { + const byProvider = new Map(); 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); + const provider = source.fingerprint.provider; + const group = byProvider.get(provider) ?? { keys: [], paths: [] }; + group.keys.push(fingerprintKey(source.fingerprint)); + group.paths.push(source.fingerprint.resolvedHomePath); + byProvider.set(provider, group); + } + for (const [provider, group] of byProvider) { + groups.push({ + environmentId: environment.environmentId, + label: environment.label, + provider, + ...group, + }); + } + } + groups.sort( + (a, b) => b.keys.length - a.keys.length || a.environmentId.localeCompare(b.environmentId), + ); + + const claimed = new Set(); + const ownedProvidersByEnvironment = new Map>(); + const duplicates: string[] = []; + + for (const group of groups) { + if (group.keys.some((key) => claimed.has(key))) { + for (const path of group.paths) duplicates.push(`${group.label}: ${path}`); + continue; } + for (const key of group.keys) claimed.add(key); + const owned = ownedProvidersByEnvironment.get(group.environmentId) ?? new Set(); + owned.add(group.provider); + ownedProvidersByEnvironment.set(group.environmentId, owned); } - return { ownerByFingerprint, duplicates }; + return { ownedProvidersByEnvironment, duplicates }; } -/** Sources this environment owns after fingerprint claims, plus their buckets. */ +/** Providers this environment owns after group claims, plus their buckets. */ function ownedContribution( environment: EnvironmentUsage, - ownerByFingerprint: ReadonlyMap, + ownedProviders: ReadonlySet, ): { readonly buckets: readonly UsageBucket[]; readonly sessionsByProvider: ReadonlyMap; } { - const ownedProviders = new Set(); const sessionsByProvider = new Map(); for (const source of environment.summary.sources) { if (source.status === "missing") continue; - const key = fingerprintKey(source.fingerprint); - if (ownerByFingerprint.get(key) === environment.environmentId) { - const provider = source.fingerprint.provider; - ownedProviders.add(provider); - // Distinct within a directory. Summing per-bucket session counts instead - // would count a session once per day and model it spans. - sessionsByProvider.set( - provider, - (sessionsByProvider.get(provider) ?? 0) + source.distinctSessions, - ); - } + const provider = source.fingerprint.provider; + if (!ownedProviders.has(provider)) continue; + // Distinct within a directory. Summing per-bucket session counts instead + // would count a session once per day and model it spans. + sessionsByProvider.set( + provider, + (sessionsByProvider.get(provider) ?? 0) + source.distinctSessions, + ); } return { buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)), @@ -229,7 +259,7 @@ export function mergeUsage( } } - const { ownerByFingerprint, duplicates } = claimSources(current); + const { ownedProvidersByEnvironment, duplicates } = claimSources(current); let costUsd = 0; let uncachedInputTokens = 0; @@ -272,7 +302,10 @@ export function mergeUsage( const contributingEnvironments: EnvironmentId[] = []; for (const environment of current) { - const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint); + const { buckets, sessionsByProvider } = ownedContribution( + environment, + ownedProvidersByEnvironment.get(environment.environmentId) ?? new Set(), + ); if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); for (const [providerKind, providerSessions] of sessionsByProvider) { From 521b16dc85755a83aa68c2f0b1041ef5626d80d1 Mon Sep 17 00:00:00 2001 From: Donovan Date: Sun, 30 Aug 2026 15:15:46 -0400 Subject: [PATCH 5/8] fix(server): align usage homes with provider environment Usage scans ignored server-level CODEX_HOME and CLAUDE_CONFIG_DIR even though provider processes inherit them. Resolve usage homes from the same effective environment while preserving configured-path precedence.\n\nEnvironment precedence informed by #5806. --- .../src/usage/usageProviderHomes.test.ts | 70 +++++++++++++++++-- apps/server/src/usage/usageProviderHomes.ts | 11 ++- 2 files changed, 70 insertions(+), 11 deletions(-) diff --git a/apps/server/src/usage/usageProviderHomes.test.ts b/apps/server/src/usage/usageProviderHomes.test.ts index 78c276bec2b2..cafcdc09f987 100644 --- a/apps/server/src/usage/usageProviderHomes.test.ts +++ b/apps/server/src/usage/usageProviderHomes.test.ts @@ -132,18 +132,74 @@ it.layer(NodeServices.layer)("usageProviderHomes", (it) => { }), ); - it.effect("ignores the server's ambient CLAUDE_CONFIG_DIR", () => + it.effect("inherits absolute provider homes from the server environment", () => Effect.gen(function* () { const path = yield* Path.Path; const settings = decodeSettings({}); + const claudeHome = path.join(NodeOS.homedir(), ".claude-ambient"); + const codexHome = path.join(NodeOS.homedir(), ".codex-ambient"); - // What usage scans must be determined by settings alone, not by the - // environment this particular server process was launched with. const homes = yield* resolveUsageProviderHomes(settings, { - CLAUDE_CONFIG_DIR: "~/.claude-ambient", + CLAUDE_CONFIG_DIR: claudeHome, + CODEX_HOME: codexHome, + }); + + expect(homes.claudeHomePaths).toEqual([claudeHome]); + expect(homes.codexSessionDirs).toEqual([path.join(codexHome, "sessions")]); + }), + ); + + it.effect("lets instance environment homes override the server environment", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHome = path.join(NodeOS.homedir(), ".claude-instance"); + const codexHome = path.join(NodeOS.homedir(), ".codex-instance"); + const settings = decodeSettings({ + providerInstances: { + claudeAgent: { + driver: "claudeAgent", + environment: [{ name: "CLAUDE_CONFIG_DIR", value: claudeHome }], + }, + codex: { + driver: "codex", + environment: [{ name: "CODEX_HOME", value: codexHome }], + }, + }, + }); + + const homes = yield* resolveUsageProviderHomes(settings, { + CLAUDE_CONFIG_DIR: path.join(NodeOS.homedir(), ".claude-ambient"), + CODEX_HOME: path.join(NodeOS.homedir(), ".codex-ambient"), + }); + + expect(homes.claudeHomePaths).toEqual([claudeHome]); + expect(homes.codexSessionDirs).toEqual([path.join(codexHome, "sessions")]); + }), + ); + + it.effect("lets blank instance variables suppress inherited provider homes", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const settings = decodeSettings({ + providerInstances: { + claudeAgent: { + driver: "claudeAgent", + environment: [{ name: "CLAUDE_CONFIG_DIR", value: "" }], + }, + codex: { + driver: "codex", + environment: [{ name: "CODEX_HOME", value: "" }], + }, + }, + }); + + const homes = yield* resolveUsageProviderHomes(settings, { + CLAUDE_CONFIG_DIR: path.join(NodeOS.homedir(), ".claude-ambient"), + CODEX_HOME: path.join(NodeOS.homedir(), ".codex-ambient"), }); expect(homes.claudeHomePaths).toEqual([path.resolve(NodeOS.homedir())]); + expect(homes.codexSessionDirs).toEqual([path.join(NodeOS.homedir(), ".codex", "sessions")]); }), ); @@ -157,7 +213,11 @@ it.layer(NodeServices.layer)("usageProviderHomes", (it) => { }, }); - const homes = yield* resolveUsageProviderHomes(settings, { GROK_HOME: "~/.grok-custom" }); + const homes = yield* resolveUsageProviderHomes(settings, { + CLAUDE_CONFIG_DIR: path.join(NodeOS.homedir(), ".claude-ambient"), + CODEX_HOME: path.join(NodeOS.homedir(), ".codex-ambient"), + GROK_HOME: "~/.grok-custom", + }); expect(homes.claudeHomePaths).toEqual([path.resolve(NodeOS.homedir(), ".claude-legacy")]); expect(homes.codexSessionDirs).toEqual([ diff --git a/apps/server/src/usage/usageProviderHomes.ts b/apps/server/src/usage/usageProviderHomes.ts index 0feaf203c3ee..cce4a52090e7 100644 --- a/apps/server/src/usage/usageProviderHomes.ts +++ b/apps/server/src/usage/usageProviderHomes.ts @@ -7,11 +7,10 @@ * settings, or secondary accounts silently report zero usage. * * Resolution mirrors what the spawned CLI actually uses: an explicit - * `homePath` wins; without one, an absolute home configured on the instance + * `homePath` wins; without one, an absolute home from the instance's effective * environment (`CLAUDE_CONFIG_DIR` / `CODEX_HOME`) wins, and otherwise the - * default home. The server process's own ambient environment is deliberately - * not consulted, so what usage scans is determined by settings alone rather - * than by how this particular server happened to be launched. + * default home. The effective environment starts with the server process's + * environment and applies per-instance overrides on top. * * @module usageProviderHomes */ @@ -78,7 +77,7 @@ export const resolveUsageProviderHomes = Effect.fn("resolveUsageProviderHomes")( pushUnique(claudeHomePaths, yield* resolveClaudeHomePath(config)); continue; } - const environment = mergeProviderInstanceEnvironment(envelope.environment, {}); + const environment = mergeProviderInstanceEnvironment(envelope.environment, hostEnvironment); const configDir = environmentHomePath(environment["CLAUDE_CONFIG_DIR"]); pushUnique(claudeHomePaths, configDir ?? (yield* resolveClaudeHomePath(config))); } else if (envelope.driver === "codex") { @@ -92,7 +91,7 @@ export const resolveUsageProviderHomes = Effect.fn("resolveUsageProviderHomes")( // sessions symlink back to the shared home). Only without one does an // instance-level CODEX_HOME reach the CLI and decide where sessions // land. - const environment = mergeProviderInstanceEnvironment(envelope.environment, {}); + const environment = mergeProviderInstanceEnvironment(envelope.environment, hostEnvironment); const environmentHome = layout.effectiveHomePath === undefined ? environmentHomePath(environment["CODEX_HOME"]) From c1e1a5489f0cfccbc240f0b2731c1ae5611502ce Mon Sep 17 00:00:00 2001 From: Donovan Date: Thu, 3 Sep 2026 23:00:47 -0400 Subject: [PATCH 6/8] fix(usage): preserve unique overlapping homes Canonicalize resolved transcript directories before local deduplication while retaining unresolved paths as missing sources. Associate each usage bucket with its source so cross-environment merging drops only duplicate directories and keeps every unique home's usage. --- apps/server/src/usage/UsageService.test.ts | 73 +++++++++++++- apps/server/src/usage/UsageService.ts | 32 +++++-- .../server/src/usage/usageAggregation.test.ts | 25 ++++- apps/server/src/usage/usageAggregation.ts | 15 +-- packages/contracts/src/usage.test.ts | 32 +++++++ packages/contracts/src/usage.ts | 23 +++-- packages/shared/src/usageMerge.test.ts | 71 +++++++++----- packages/shared/src/usageMerge.ts | 96 ++++++------------- 8 files changed, 252 insertions(+), 115 deletions(-) create mode 100644 packages/contracts/src/usage.test.ts diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 9d728c88cf42..4147505a39e9 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -6,7 +6,7 @@ import * as NodePath from "node:path"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; @@ -218,6 +218,77 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("canonicalizes aliased homes and keeps missing homes visible", () => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const home = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-service-homes-test-")), + ); + yield* Effect.addFinalizer(() => + Effect.promise(() => NodeFSP.rm(home, { recursive: true, force: true })), + ); + + const claudeHome = NodePath.join(home, "claude"); + const aliasHome = NodePath.join(home, "claude-alias"); + const missingHome = NodePath.join(home, "claude-missing"); + const transcriptDir = NodePath.join(claudeHome, "projects", "proj"); + yield* Effect.promise(() => NodeFSP.mkdir(transcriptDir, { recursive: true })); + yield* Effect.promise(() => + NodeFSP.writeFile(NodePath.join(transcriptDir, "session.jsonl"), claudeLine(1, 5)), + ); + yield* Effect.promise(() => + NodeFSP.symlink(claudeHome, aliasHome, platform === "win32" ? "junction" : "dir"), + ); + + const settings = { + providerInstances: { + claudeAgent: { + driver: "claudeAgent" as const, + config: { homePath: claudeHome }, + }, + claude_alias: { + driver: "claudeAgent" as const, + config: { homePath: aliasHome }, + }, + claude_missing: { + driver: "claudeAgent" as const, + config: { homePath: missingHome }, + }, + codex: { + driver: "codex" as const, + config: { homePath: NodePath.join(home, "codex") }, + }, + }, + }; + const service = yield* UsageService.make.pipe( + Effect.provide(serviceLayers({ prefix: "usage-service-homes-test", home, settings })), + ); + + const summary = yield* service.readSummary(WINDOW); + const canonicalDir = yield* Effect.promise(() => + NodeFSP.realpath(NodePath.join(claudeHome, "projects")), + ); + const claudeSources = summary.sources.filter( + (source) => source.fingerprint.provider === "claude", + ); + const canonicalSourceIndex = summary.sources.findIndex( + (source) => source.fingerprint.resolvedHomePath === canonicalDir, + ); + + assert.strictEqual(claudeSources.length, 2); + assert.strictEqual(claudeSources[0]?.fingerprint.resolvedHomePath, canonicalDir); + assert.strictEqual(claudeSources[0]?.status, "ok"); + assert.strictEqual( + claudeSources[1]?.fingerprint.resolvedHomePath, + NodePath.join(missingHome, "projects"), + ); + assert.strictEqual(claudeSources[1]?.status, "missing"); + assert.strictEqual(summary.buckets.length, 1); + assert.strictEqual(summary.buckets[0]?.sourceIndex, canonicalSourceIndex); + assert.strictEqual(totalOutputTokens(summary), 5); + }).pipe(Effect.scoped), + ); + it.live("shares one scan between concurrent identical requests", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 689996b191d7..33165a0c2254 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -259,19 +259,34 @@ export const make = Effect.gen(function* () { dir: string; fileName?: string; }> = []; - const claudeDirs = new Set(); + const seen = new Set(); + // Two configured homes can name one physical transcript directory through + // symlinks (including Codex shadow overlays). Canonicalize the final + // transcript directory before de-duplicating it. If the directory does not + // exist, keep its configured path so the scan reports a missing source. + const pushDir = Effect.fn("UsageService.pushTranscriptDir")(function* ( + provider: UsageProviderKind, + dir: string, + fileName?: string, + ) { + const canonical = yield* fileSystem + .realPath(dir) + .pipe(Effect.catchCause(() => Effect.succeed(dir))); + const key = `${provider}\u0000${canonical}`; + if (seen.has(key)) return; + seen.add(key); + dirs.push({ provider, dir: canonical, ...(fileName === undefined ? {} : { fileName }) }); + }); + for (const home of homes.claudeHomePaths) { // Distinct homes can probe to the same transcript dir (e.g. `~/x` with // a nested `.claude` next to `~/x/.claude` itself), so dedupe post-probe. - const dir = yield* resolveClaudeTranscriptDir(home); - if (claudeDirs.has(dir)) continue; - claudeDirs.add(dir); - dirs.push({ provider: "claude", dir }); + yield* pushDir("claude", yield* resolveClaudeTranscriptDir(home)); } for (const dir of homes.codexSessionDirs) { - dirs.push({ provider: "codex", dir }); + yield* pushDir("codex", dir); } - dirs.push({ provider: "grok", dir: homes.grokSessionsDir, fileName: "updates.jsonl" }); + yield* pushDir("grok", homes.grokSessionsDir, "updates.jsonl"); return dirs; }); @@ -486,6 +501,7 @@ export const make = Effect.gen(function* () { const walkedRoots: string[] = []; for (const { provider, dir, volumeId, files } of scannedDirs) { + const sourceIndex = sources.length; if (files === null) { sources.push({ fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, @@ -516,7 +532,7 @@ export const make = Effect.gen(function* () { for (const record of file.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) { + if (aggregator.add(record, sourceIndex) && record.sessionId.length > 0) { sessionIds.add(record.sessionId); } } diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 8da4e920ac06..78c71413d3ea 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -56,7 +56,7 @@ function aggregate( ...hourlyBounds, rates, }); - for (const item of records) aggregator.add(item); + for (const item of records) aggregator.add(item, 0); return aggregator.finish(); } @@ -83,6 +83,7 @@ describe("UsageAggregator", () => { expect(result.duplicatesDropped).toBe(2); expect(result.buckets).toHaveLength(1); + expect(result.buckets[0]?.sourceIndex).toBe(0); expect(result.buckets[0]?.records).toBe(1); expect(result.buckets[0]?.totals.outputTokens).toBe(50); }); @@ -187,9 +188,25 @@ describe("UsageAggregator", () => { rates, }); - expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(true); - expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(false); - expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") }))).toBe(false); + expect(aggregator.add(record({ dedupeKey: "msg_1:" }), 0)).toBe(true); + expect(aggregator.add(record({ dedupeKey: "msg_1:" }), 0)).toBe(false); + expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") }), 0)).toBe( + false, + ); + }); + + it("keeps otherwise identical buckets separate by transcript source", () => { + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + }); + + aggregator.add(record({ sessionId: "session-a" }), 0); + aggregator.add(record({ sessionId: "session-b" }), 1); + + expect(aggregator.finish().buckets.map((bucket) => bucket.sourceIndex)).toEqual([0, 1]); }); it("separates providers and models into their own buckets", () => { diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 01a1195efb60..6f77e4dfc78f 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -1,7 +1,7 @@ // @effect-diagnostics globalDate:off /** - * Folds parsed transcript records into `(day, hourStart?, provider, model)` - * buckets. + * Folds parsed transcript records into + * `(sourceIndex, day, hourStart?, 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` @@ -112,7 +112,7 @@ export class UsageAggregator { * 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 { + add(record: UsageRecord, sourceIndex: number): boolean { if (record.dedupeKey !== null) { if (this.#seen.has(record.dedupeKey)) { this.#duplicatesDropped += 1; @@ -146,7 +146,7 @@ export class UsageAggregator { this.#hourlyWindow.sinceTimeMs + Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS) * HOUR_MS, ).toISOString(); - const key = `${day}\u0000${hourStart}\u0000${record.provider}\u0000${record.model}`; + const key = `${day}\u0000${hourStart}\u0000${record.provider}\u0000${record.model}\u0000${sourceIndex}`; let bucket = this.#buckets.get(key); if (bucket === undefined) { bucket = { @@ -187,8 +187,10 @@ export class UsageAggregator { finish(): AggregateResult { const buckets: UsageBucket[] = []; for (const [key, bucket] of this.#buckets) { - const [day = "", hourStart = "", provider = "", model = ""] = key.split("\u0000"); + const [day = "", hourStart = "", provider = "", model = "", sourceIndex = ""] = + key.split("\u0000"); buckets.push({ + sourceIndex: Number(sourceIndex), day: day as UsageDay, ...(hourStart === "" ? {} : { hourStart }), provider: provider as UsageBucket["provider"], @@ -208,7 +210,8 @@ export class UsageAggregator { a.day.localeCompare(b.day) || (a.hourStart ?? "").localeCompare(b.hourStart ?? "") || a.provider.localeCompare(b.provider) || - a.model.localeCompare(b.model), + a.model.localeCompare(b.model) || + a.sourceIndex - b.sourceIndex, ); return { diff --git a/packages/contracts/src/usage.test.ts b/packages/contracts/src/usage.test.ts new file mode 100644 index 000000000000..784ecf2beba6 --- /dev/null +++ b/packages/contracts/src/usage.test.ts @@ -0,0 +1,32 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { UsageBucket } from "./usage.ts"; + +const decodeUsageBucket = Schema.decodeUnknownEffect(UsageBucket); + +it.effect("defaults sourceIndex when decoding an older usage bucket", () => + Effect.gen(function* () { + const decoded = yield* decodeUsageBucket({ + day: "2026-08-07", + provider: "claude", + model: "claude-fable-5", + totals: { + uncachedInputTokens: 1, + cachedInputTokens: 2, + cacheCreationTokens: 3, + outputTokens: 4, + reasoningTokens: 0, + }, + costUsd: 0.01, + cacheSavingsUsd: 0.02, + costSource: "modelPriced", + records: 1, + unpricedRecords: 0, + sessions: 1, + }); + + assert.strictEqual(decoded.sourceIndex, 0); + }), +); diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index c36a4557c294..a1145a62c723 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -7,11 +7,13 @@ * orchestration projections, so usage stays complete even for turns that were * never driven through T3 Code. This mirrors the approach `ccusage` takes. * - * Environments return pre-aggregated `(day, hourStart?, provider, model)` - * buckets. Raw transcript records never cross the wire. + * Environments return pre-aggregated + * `(sourceIndex, day, hourStart?, provider, model)` buckets. Raw transcript + * records never cross the wire. * * @module usage */ +import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; @@ -21,16 +23,16 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; * client renders partial coverage when an environment reports an older version * rather than failing the whole page. */ -export const USAGE_CONTRACT_VERSION = 5 as const; +export const USAGE_CONTRACT_VERSION = 6 as const; /** * Oldest {@link UsageSummary} version a current client will still merge. * - * v5 only adds `grok` to {@link UsageProviderKind}; v4 Claude/Codex buckets - * remain valid, so mixed-version environments keep those totals instead of - * treating every older server as stale. + * v6 associates every bucket with one source. Older summaries aggregate a + * provider's sources together, so they cannot safely participate in + * source-level de-duplication when environments partially overlap. */ -export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const; +export const USAGE_MERGE_COMPATIBLE_SINCE = 6 as const; export const UsageProviderKind = Schema.Literals(["claude", "codex", "grok"]); export type UsageProviderKind = typeof UsageProviderKind.Type; @@ -80,8 +82,9 @@ export const UsageTokenTotals = Schema.Struct({ export type UsageTokenTotals = typeof UsageTokenTotals.Type; /** - * One `(day, hourStart?, provider, model)` cell. `hourStart` is the UTC start - * instant of a rolling bucket and is present only for hourly requests. + * One `(sourceIndex, day, hourStart?, provider, model)` cell. `sourceIndex` + * points into the enclosing summary's `sources` array. `hourStart` is the UTC + * start instant of a rolling bucket and is present only for hourly requests. * * `costUsd` is the raw API-equivalent cost of these tokens. It is not money * spent: subscription plans bill separately. `unpricedRecords` counts records @@ -89,6 +92,8 @@ export type UsageTokenTotals = typeof UsageTokenTotals.Type; * to `costUsd`. */ export const UsageBucket = Schema.Struct({ + /** Defaults only so an older summary can decode before its version is rejected. */ + sourceIndex: NonNegativeInt.pipe(Schema.withDecodingDefault(Effect.succeed(0))), day: UsageDay, hourStart: Schema.optional(TrimmedNonEmptyString), provider: UsageProviderKind, diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index ddcdac737bd5..cbc91885f16f 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -12,6 +12,7 @@ import { mergeUsage, type EnvironmentUsage } from "./usageMerge.ts"; function bucket(overrides: Partial = {}): UsageBucket { return { + sourceIndex: 0, day: "2026-08-07" as UsageDay, provider: "claude", model: "claude-fable-5", @@ -124,7 +125,10 @@ describe("mergeUsage", () => { environment( "env-b", summary( - [bucket(), bucket({ provider: "codex", model: "gpt-5.6-sol", costUsd: 4 })], + [ + bucket(), + bucket({ sourceIndex: 1, provider: "codex", model: "gpt-5.6-sol", costUsd: 4 }), + ], [sharedClaude, { provider: "codex", hostId: "mac", homePath: "/home/theo/.codex" }], ), ), @@ -147,16 +151,23 @@ describe("mergeUsage", () => { }); it("does not double count when environments overlap on some homes but not all", () => { - // env-b scans two instance homes; env-a scans only one of them. env-a's - // buckets aggregate H1 while env-b's aggregate H1+H2, so letting both - // contribute would count H1 twice. The larger set wins regardless of id - // order and the smaller environment's provider is dropped entirely. + // env-a owns shared H1; env-b's H1 bucket is dropped while its unique H2 + // bucket survives. const h1 = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" }; const h2 = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude-max" }; const merged = mergeUsage( [ environment("env-a", summary([bucket({ costUsd: 10, records: 5 })], [h1])), - environment("env-b", summary([bucket({ costUsd: 16, records: 8 })], [h1, h2])), + environment( + "env-b", + summary( + [ + bucket({ costUsd: 10, records: 5 }), + bucket({ sourceIndex: 1, costUsd: 6, records: 3 }), + ], + [h1, h2], + ), + ), ], USAGE_CONTRACT_VERSION, ); @@ -164,34 +175,44 @@ describe("mergeUsage", () => { expect(merged.costUsd).toBe(16); expect(merged.records).toBe(8); expect(merged.sessions).toBe(2); - expect(merged.contributingEnvironments).toEqual(["env-b"]); - expect(merged.duplicateSources).toEqual(["env-a: /home/theo/.claude"]); + expect(merged.contributingEnvironments).toEqual(["env-a", "env-b"]); + expect(merged.duplicateSources).toEqual(["env-b: /home/theo/.claude"]); }); - it("reports every dropped home when overlapping sets tie on size", () => { - // Neither set covers the other, so whichever loses the deterministic - // tie-break loses its unique home too; both of its paths are surfaced so - // the UI can say coverage is partial. - const shared = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" }; + it("retains every unique home across intersecting environment source sets", () => { + const source = (homePath: string) => ({ + provider: "claude" as const, + hostId: "mac", + homePath, + }); + const h1 = source("/home/theo/.claude-1"); + const h2 = source("/home/theo/.claude-2"); + const h3 = source("/home/theo/.claude-3"); + const h4 = source("/home/theo/.claude-4"); const merged = mergeUsage( [ environment( "env-a", - summary([bucket()], [shared, { ...shared, homePath: "/home/theo/.claude-a" }]), + summary([bucket({ costUsd: 1 }), bucket({ sourceIndex: 1, costUsd: 2 })], [h1, h2]), ), environment( "env-b", - summary([bucket()], [shared, { ...shared, homePath: "/home/theo/.claude-b" }]), + summary([bucket({ costUsd: 1 }), bucket({ sourceIndex: 1, costUsd: 3 })], [h1, h3]), + ), + environment( + "env-c", + summary([bucket({ costUsd: 2 }), bucket({ sourceIndex: 1, costUsd: 4 })], [h2, h4]), ), ], USAGE_CONTRACT_VERSION, ); expect(merged.costUsd).toBe(10); - expect(merged.contributingEnvironments).toEqual(["env-a"]); + expect(merged.sessions).toBe(4); + expect(merged.contributingEnvironments).toEqual(["env-a", "env-b", "env-c"]); expect(merged.duplicateSources).toEqual([ - "env-b: /home/theo/.claude", - "env-b: /home/theo/.claude-b", + "env-b: /home/theo/.claude-1", + "env-c: /home/theo/.claude-2", ]); }); @@ -218,7 +239,7 @@ describe("mergeUsage", () => { expect(merged.staleEnvironments).toEqual(["env-b"]); }); - it("keeps the previous compatible contract version so additive provider expansions still merge", () => { + it("rejects the previous contract version because its buckets lack source ownership", () => { const merged = mergeUsage( [ environment( @@ -240,8 +261,8 @@ describe("mergeUsage", () => { USAGE_CONTRACT_VERSION, ); - expect(merged.costUsd).toBe(14); - expect(merged.staleEnvironments).toEqual([]); + expect(merged.costUsd).toBe(10); + expect(merged.staleEnvironments).toEqual(["env-b"]); }); it("derives provider shares and cost quality", () => { @@ -252,7 +273,13 @@ describe("mergeUsage", () => { summary( [ bucket({ costUsd: 75 }), - bucket({ provider: "codex", model: "gpt-5.6-sol", costUsd: 25, unpricedRecords: 5 }), + bucket({ + sourceIndex: 1, + provider: "codex", + model: "gpt-5.6-sol", + costUsd: 25, + unpricedRecords: 5, + }), ], [ { provider: "claude", hostId: "mac", homePath: "/a/.claude" }, diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 7b7ab59c650e..e8e348c50586 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -101,85 +101,53 @@ function fingerprintKey(fingerprint: UsageSourceFingerprint): string { } /** - * Decides which environment owns each provider's transcript directories. + * 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. Buckets - * are per-provider aggregates over every directory an environment scans, so - * the claim unit is an environment's whole set of fingerprints for a provider: - * owning only part of the set would still contribute the full aggregate and - * double count the shared directories. A group that overlaps an already - * claimed fingerprint is dropped entirely and every one of its paths is - * reported, so the UI can say coverage is partial. Groups with more - * directories claim first — an environment configured with a superset of - * another's homes must win or its unique homes' data would be lost — with - * environment id breaking ties so the winner does not change between renders. + * the same provider home and would otherwise double count every token. The + * first environment in stable id order claims a fingerprint; the rest drop + * only buckets from that source. Source-level ownership preserves unique homes + * when environments partially overlap. */ function claimSources(environments: readonly EnvironmentUsage[]): { - readonly ownedProvidersByEnvironment: ReadonlyMap>; + readonly ownerByFingerprint: ReadonlyMap; readonly duplicates: readonly string[]; } { - const groups: Array<{ - environmentId: EnvironmentId; - label: string; - provider: UsageProviderKind; - keys: string[]; - paths: string[]; - }> = []; - for (const environment of environments) { - const byProvider = new Map(); - for (const source of environment.summary.sources) { - if (source.status === "missing") continue; - const provider = source.fingerprint.provider; - const group = byProvider.get(provider) ?? { keys: [], paths: [] }; - group.keys.push(fingerprintKey(source.fingerprint)); - group.paths.push(source.fingerprint.resolvedHomePath); - byProvider.set(provider, group); - } - for (const [provider, group] of byProvider) { - groups.push({ - environmentId: environment.environmentId, - label: environment.label, - provider, - ...group, - }); - } - } - groups.sort( - (a, b) => b.keys.length - a.keys.length || a.environmentId.localeCompare(b.environmentId), - ); - - const claimed = new Set(); - const ownedProvidersByEnvironment = new Map>(); + const ownerByFingerprint = new Map(); const duplicates: string[] = []; + const ordered = [...environments].sort((a, b) => a.environmentId.localeCompare(b.environmentId)); - for (const group of groups) { - if (group.keys.some((key) => claimed.has(key))) { - for (const path of group.paths) duplicates.push(`${group.label}: ${path}`); - continue; + 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); } - for (const key of group.keys) claimed.add(key); - const owned = ownedProvidersByEnvironment.get(group.environmentId) ?? new Set(); - owned.add(group.provider); - ownedProvidersByEnvironment.set(group.environmentId, owned); } - return { ownedProvidersByEnvironment, duplicates }; + return { ownerByFingerprint, duplicates }; } -/** Providers this environment owns after group claims, plus their buckets. */ +/** Sources this environment owns after fingerprint claims, plus their buckets. */ function ownedContribution( environment: EnvironmentUsage, - ownedProviders: ReadonlySet, + ownerByFingerprint: ReadonlyMap, ): { readonly buckets: readonly UsageBucket[]; readonly sessionsByProvider: ReadonlyMap; } { + const ownedSourceIndexes = new Set(); const sessionsByProvider = new Map(); - for (const source of environment.summary.sources) { + for (const [sourceIndex, source] of environment.summary.sources.entries()) { if (source.status === "missing") continue; + const key = fingerprintKey(source.fingerprint); + if (ownerByFingerprint.get(key) !== environment.environmentId) continue; + ownedSourceIndexes.add(sourceIndex); const provider = source.fingerprint.provider; - if (!ownedProviders.has(provider)) continue; // Distinct within a directory. Summing per-bucket session counts instead // would count a session once per day and model it spans. sessionsByProvider.set( @@ -188,7 +156,9 @@ function ownedContribution( ); } return { - buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)), + buckets: environment.summary.buckets.filter((bucket) => + ownedSourceIndexes.has(bucket.sourceIndex), + ), sessionsByProvider, }; } @@ -238,8 +208,7 @@ const EMPTY_MERGED: MergedUsage = { * `expectedContractVersion` guards against an environment running older server * code: rather than blocking the page, incompatible data is excluded and its * id is reported so the UI can say coverage is partial. Versions in - * [{@link USAGE_MERGE_COMPATIBLE_SINCE}, expected] still merge, so an additive - * provider expansion does not drop Claude/Codex totals from older servers. + * Versions in [{@link USAGE_MERGE_COMPATIBLE_SINCE}, expected] still merge. */ export function mergeUsage( environments: readonly EnvironmentUsage[], @@ -259,7 +228,7 @@ export function mergeUsage( } } - const { ownedProvidersByEnvironment, duplicates } = claimSources(current); + const { ownerByFingerprint, duplicates } = claimSources(current); let costUsd = 0; let uncachedInputTokens = 0; @@ -302,10 +271,7 @@ export function mergeUsage( const contributingEnvironments: EnvironmentId[] = []; for (const environment of current) { - const { buckets, sessionsByProvider } = ownedContribution( - environment, - ownedProvidersByEnvironment.get(environment.environmentId) ?? new Set(), - ); + const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint); if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); for (const [providerKind, providerSessions] of sessionsByProvider) { From 3d392a13f732babdd6391f5b45f1ab3ac7c92ea4 Mon Sep 17 00:00:00 2001 From: Donovan Date: Thu, 3 Sep 2026 23:14:30 -0400 Subject: [PATCH 7/8] docs(usage): clarify multi-instance coverage Limit the multi-instance history claim to Claude Code and Codex, and document that Grok Build reads the server's single Grok home. --- docs/user/usage.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user/usage.md b/docs/user/usage.md index d8d7d42e3b08..d5d0f962b79a 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -6,8 +6,8 @@ environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent cost. These estimates are not your subscription bill. -If you have configured multiple instances of a provider (for example separate Claude accounts -with their own config directories), each instance's history is included. +If you have configured multiple Claude Code or Codex instances with separate homes, each +instance's history is included. Grok Build history is read from the server's single Grok home. Totals depend on the history available on each server. Grok turns without a saved completed-turn record are missing from the totals. From 8ec5450e181f8ea46d7502017f393fd2c0782627 Mon Sep 17 00:00:00 2001 From: Donovan Date: Sat, 5 Sep 2026 15:10:43 -0400 Subject: [PATCH 8/8] test(usage): align fixtures with current provider behavior --- .../src/usage/usageProviderHomes.test.ts | 20 ++++++++++++++----- apps/server/src/usage/usageProviderHomes.ts | 9 ++++----- apps/web/src/state/usage.test.tsx | 1 + 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/server/src/usage/usageProviderHomes.test.ts b/apps/server/src/usage/usageProviderHomes.test.ts index cafcdc09f987..17e31baeb0e0 100644 --- a/apps/server/src/usage/usageProviderHomes.test.ts +++ b/apps/server/src/usage/usageProviderHomes.test.ts @@ -84,13 +84,13 @@ it.layer(NodeServices.layer)("usageProviderHomes", (it) => { }), ); - it.effect("ignores non-absolute environment homes, which the CLI gets verbatim", () => + it.effect("expands configured tilde homes and ignores workspace-relative homes", () => Effect.gen(function* () { const path = yield* Path.Path; const settings = decodeSettings({ providerInstances: { - // Env vars are never shell-expanded, so a tilde or relative value - // depends on each workspace's cwd and has no single scan dir. + // The runtime expands configured tilde homes before spawning the + // CLI, while other relative values still depend on workspace cwd. claude_tilde: { driver: "claudeAgent", environment: [{ name: "CLAUDE_CONFIG_DIR", value: "~/.claude-tilde" }], @@ -99,13 +99,23 @@ it.layer(NodeServices.layer)("usageProviderHomes", (it) => { driver: "codex", environment: [{ name: "CODEX_HOME", value: "codex-home" }], }, + codex_tilde: { + driver: "codex", + environment: [{ name: "CODEX_HOME", value: "~/.codex-tilde" }], + }, }, }); const homes = yield* resolveUsageProviderHomes(settings, {}); - expect(homes.claudeHomePaths).toEqual([path.resolve(NodeOS.homedir())]); - expect(homes.codexSessionDirs).toEqual([path.join(NodeOS.homedir(), ".codex", "sessions")]); + expect(homes.claudeHomePaths).toEqual([ + path.join(NodeOS.homedir(), ".claude-tilde"), + path.resolve(NodeOS.homedir()), + ]); + expect(homes.codexSessionDirs).toEqual([ + path.join(NodeOS.homedir(), ".codex", "sessions"), + path.join(NodeOS.homedir(), ".codex-tilde", "sessions"), + ]); }), ); diff --git a/apps/server/src/usage/usageProviderHomes.ts b/apps/server/src/usage/usageProviderHomes.ts index cce4a52090e7..1963d15dbf0d 100644 --- a/apps/server/src/usage/usageProviderHomes.ts +++ b/apps/server/src/usage/usageProviderHomes.ts @@ -52,11 +52,10 @@ export const resolveUsageProviderHomes = Effect.fn("resolveUsageProviderHomes")( }; /** - * Home taken from an instance environment variable. The spawned CLI - * receives env vars verbatim (never shell-expanded), so no tilde expansion - * here — see `resolveClaudeConfigDirPath` in ClaudeSkills. A relative value - * resolves against each workspace's own cwd and therefore has no single - * scan directory; only absolute values are honored. + * Home taken from the effective environment, after the shared environment + * merger expands tildes in configured provider-home overrides. Remaining + * relative values depend on each workspace's cwd and therefore have no + * single scan directory; only absolute values are honored. */ const environmentHomePath = (value: string | undefined): string | null => { const trimmed = value?.trim() ?? ""; diff --git a/apps/web/src/state/usage.test.tsx b/apps/web/src/state/usage.test.tsx index e01478e6a71a..2ac1bc805c11 100644 --- a/apps/web/src/state/usage.test.tsx +++ b/apps/web/src/state/usage.test.tsx @@ -32,6 +32,7 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU readAt: "2026-09-04T12:00:00Z", buckets: [ { + sourceIndex: 0, day: input.sinceDay, provider: "codex", model: id,