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 0e6b0c1eecd6..33165a0c2254 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,51 @@ 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 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. + yield* pushDir("claude", yield* resolveClaudeTranscriptDir(home)); + } + for (const dir of homes.codexSessionDirs) { + yield* pushDir("codex", dir); + } + yield* pushDir("grok", homes.grokSessionsDir, "updates.jsonl"); + return dirs; }); /** @@ -482,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 }, @@ -512,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/apps/server/src/usage/usageProviderHomes.test.ts b/apps/server/src/usage/usageProviderHomes.test.ts new file mode 100644 index 000000000000..17e31baeb0e0 --- /dev/null +++ b/apps/server/src/usage/usageProviderHomes.test.ts @@ -0,0 +1,242 @@ +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: 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") }, + ], + }, + }, + }); + + 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-env", "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("expands configured tilde homes and ignores workspace-relative homes", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const settings = decodeSettings({ + providerInstances: { + // 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" }], + }, + codex_relative: { + 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.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"), + ]); + }), + ); + + 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("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"); + + const homes = yield* resolveUsageProviderHomes(settings, { + 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")]); + }), + ); + + 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, { + 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([ + 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..1963d15dbf0d --- /dev/null +++ b/apps/server/src/usage/usageProviderHomes.ts @@ -0,0 +1,115 @@ +/** + * 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` wins; without one, an absolute home from the instance's effective + * environment (`CLAUDE_CONFIG_DIR` / `CODEX_HOME`) wins, and otherwise the + * default home. The effective environment starts with the server process's + * environment and applies per-instance overrides on top. + * + * @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); + }; + + /** + * 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() ?? ""; + 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)) { + 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, hostEnvironment); + 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); + // 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, hostEnvironment); + const environmentHome = + layout.effectiveHomePath === undefined + ? environmentHomePath(environment["CODEX_HOME"]) + : null; + pushUnique(codexSessionDirs, path.join(environmentHome ?? 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/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, diff --git a/docs/user/usage.md b/docs/user/usage.md index a0231856ef69..d5d0f962b79a 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 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. 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 6c706395c6ff..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" }], ), ), @@ -146,6 +150,72 @@ describe("mergeUsage", () => { ).toEqual({ claude: 1, codex: 1 }); }); + it("does not double count when environments overlap on some homes but not all", () => { + // 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: 10, records: 5 }), + bucket({ sourceIndex: 1, costUsd: 6, records: 3 }), + ], + [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-a", "env-b"]); + expect(merged.duplicateSources).toEqual(["env-b: /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({ costUsd: 1 }), bucket({ sourceIndex: 1, costUsd: 2 })], [h1, h2]), + ), + environment( + "env-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.sessions).toBe(4); + expect(merged.contributingEnvironments).toEqual(["env-a", "env-b", "env-c"]); + expect(merged.duplicateSources).toEqual([ + "env-b: /home/theo/.claude-1", + "env-c: /home/theo/.claude-2", + ]); + }); + it("excludes an environment reporting an older contract version", () => { const merged = mergeUsage( [ @@ -169,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( @@ -191,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", () => { @@ -203,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 95982bf507da..e8e348c50586 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[]; @@ -105,9 +105,9 @@ function fingerprintKey(fingerprint: UsageSourceFingerprint): string { * * 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. + * 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 ownerByFingerprint: ReadonlyMap; @@ -115,7 +115,6 @@ function claimSources(environments: readonly EnvironmentUsage[]): { } { const ownerByFingerprint = new Map(); const duplicates: string[] = []; - const ordered = [...environments].sort((a, b) => a.environmentId.localeCompare(b.environmentId)); for (const environment of ordered) { @@ -141,24 +140,25 @@ function ownedContribution( readonly buckets: readonly UsageBucket[]; readonly sessionsByProvider: ReadonlyMap; } { - const ownedProviders = new Set(); + 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) { - 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, - ); - } + if (ownerByFingerprint.get(key) !== environment.environmentId) continue; + ownedSourceIndexes.add(sourceIndex); + const provider = source.fingerprint.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, + ); } return { - buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)), + buckets: environment.summary.buckets.filter((bucket) => + ownedSourceIndexes.has(bucket.sourceIndex), + ), sessionsByProvider, }; } @@ -208,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[],