diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 0bf131ac973b..05c448688b7c 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -35,8 +35,6 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../config.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 { parseRateTable, type RateTable } from "./usagePricing.ts"; import { @@ -51,6 +49,7 @@ import { pruneScanCache, type ScanCache, } from "./usageScanCache.ts"; +import { resolveUsageTranscriptDirs } from "./usageTranscriptDirs.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; const LITELLM_RATES_URL = @@ -184,20 +183,7 @@ export const make = Effect.gen(function* () { ); }); - /** - * Claude's config dir is the home itself when overridden, but a default - * install nests transcripts under `~/.claude/projects`. Probe both. - */ - const resolveClaudeTranscriptDir = (homePath: string) => - Effect.gen(function* () { - const nested = path.join(homePath, ".claude", "projects"); - const nestedExists = yield* fileSystem - .exists(nested) - .pipe(Effect.catchCause(() => Effect.succeed(false))); - return nestedExists ? nested : path.join(homePath, "projects"); - }); - - /** Resolves the transcript directory for each provider. */ + /** Resolves the transcript directory for every configured provider instance. */ const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* () { // A settings failure must surface as an error: swallowing it here would // present "zero usage from every provider" as a valid answer. @@ -215,14 +201,7 @@ export const make = Effect.gen(function* () { ), ); - const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent); - const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome); - const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex); - - return [ - { provider: "claude" as const, dir: claudeDir }, - { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") }, - ]; + return yield* resolveUsageTranscriptDirs(settings); }); /** @@ -327,9 +306,12 @@ export const make = Effect.gen(function* () { yield* ensureScanCacheLoaded; const hostId = NodeOS.hostname(); - // The home resolvers ask for `Path` themselves; satisfy them from the - // instance we already hold so `readSummary` stays context-free. - const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path)); + // The home resolvers ask for `Path` and `FileSystem` themselves; satisfy + // them from the instances we already hold so `readSummary` stays context-free. + const dirs = yield* resolveTranscriptDirs().pipe( + Effect.provideService(Path.Path, path), + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); if (Option.isNone(windowStart)) { return yield* new UsageReadError({ diff --git a/apps/server/src/usage/usageTranscriptDirs.test.ts b/apps/server/src/usage/usageTranscriptDirs.test.ts new file mode 100644 index 000000000000..c04992d7bcaf --- /dev/null +++ b/apps/server/src/usage/usageTranscriptDirs.test.ts @@ -0,0 +1,135 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { + DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + type ProviderInstanceConfig, + ProviderInstanceId, + type ServerSettings, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { resolveUsageTranscriptDirs } from "./usageTranscriptDirs.ts"; + +const CLAUDE = ProviderDriverKind.make("claudeAgent"); +const CODEX = ProviderDriverKind.make("codex"); + +/** + * Legacy homes are pinned to paths that do not exist so the Claude nested + * `.claude/projects` probe resolves the same way on every machine. + */ +const makeSettings = ( + root: string, + providerInstances: Record = {}, +): ServerSettings => ({ + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + claudeAgent: { ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, homePath: `${root}/claude` }, + codex: { ...DEFAULT_SERVER_SETTINGS.providers.codex, homePath: `${root}/codex` }, + }, + providerInstances: providerInstances as ServerSettings["providerInstances"], +}); + +it.layer(NodeServices.layer)("usageTranscriptDirs", (it) => { + describe("resolveUsageTranscriptDirs", () => { + it.effect("scans the legacy Claude and Codex homes when no instances are configured", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-usage-dirs-" }); + + const dirs = yield* resolveUsageTranscriptDirs(makeSettings(root)); + + expect(dirs).toEqual([ + { provider: "codex", dir: path.resolve(root, "codex", "sessions") }, + { provider: "claude", dir: path.resolve(root, "claude", "projects") }, + ]); + }), + ); + + it.effect("adds a transcript directory for every extra provider instance", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-usage-dirs-" }); + + const dirs = yield* resolveUsageTranscriptDirs( + makeSettings(root, { + [ProviderInstanceId.make("claude_work")]: { + driver: CLAUDE, + config: { homePath: `${root}/claude-work` }, + }, + [ProviderInstanceId.make("codex_personal")]: { + driver: CODEX, + config: { homePath: `${root}/codex-personal` }, + }, + }), + ); + + expect(dirs).toEqual([ + { provider: "claude", dir: path.resolve(root, "claude-work", "projects") }, + { provider: "codex", dir: path.resolve(root, "codex-personal", "sessions") }, + { provider: "codex", dir: path.resolve(root, "codex", "sessions") }, + { provider: "claude", dir: path.resolve(root, "claude", "projects") }, + ]); + }), + ); + + it.effect("prefers a nested .claude/projects directory when the home has one", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-usage-dirs-" }); + const nested = path.join(root, "claude", ".claude", "projects"); + yield* fileSystem.makeDirectory(nested, { recursive: true }); + + const dirs = yield* resolveUsageTranscriptDirs(makeSettings(root)); + + expect(dirs.find((entry) => entry.provider === "claude")?.dir).toBe(path.resolve(nested)); + }), + ); + + it.effect("collapses instances that share a home so nothing is counted twice", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-usage-dirs-" }); + + const dirs = yield* resolveUsageTranscriptDirs( + makeSettings(root, { + [ProviderInstanceId.make("claude_chrome")]: { + driver: CLAUDE, + config: { homePath: `${root}/claude`, launchArgs: "--chrome" }, + }, + }), + ); + + expect(dirs.filter((entry) => entry.provider === "claude")).toHaveLength(1); + }), + ); + + it.effect("skips drivers without transcripts and configs that fail to decode", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-usage-dirs-" }); + + const dirs = yield* resolveUsageTranscriptDirs( + makeSettings(root, { + [ProviderInstanceId.make("cursor_main")]: { + driver: ProviderDriverKind.make("cursor"), + config: {}, + }, + [ProviderInstanceId.make("claude_broken")]: { + driver: CLAUDE, + config: { homePath: 42 }, + }, + }), + ); + + expect(dirs).toHaveLength(2); + }), + ); + }); +}); diff --git a/apps/server/src/usage/usageTranscriptDirs.ts b/apps/server/src/usage/usageTranscriptDirs.ts new file mode 100644 index 000000000000..15dff744c496 --- /dev/null +++ b/apps/server/src/usage/usageTranscriptDirs.ts @@ -0,0 +1,91 @@ +/** + * Resolves the transcript directories the usage scan walks. + * + * Every configured provider instance contributes its own directory. A second + * Claude instance pointed at `~/.claude-work` writes transcripts there, not + * under `~/.claude`, so scanning only the default instance under-reports. + * Instances that resolve to the same directory collapse to one entry so a + * shared home is never counted twice. + * + * @module usageTranscriptDirs + */ +import { + ClaudeSettings, + CodexSettings, + ProviderDriverKind, + type ServerSettings, + type UsageProviderKind, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; +import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { deriveProviderInstanceConfigMap } from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; + +export interface UsageTranscriptDir { + readonly provider: UsageProviderKind; + readonly dir: string; +} + +const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); +const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); + +const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); +const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); + +/** + * Claude's config dir is the home itself when overridden, but a default + * install nests transcripts under `~/.claude/projects`. Probe both. + */ +const resolveClaudeTranscriptDir = Effect.fn("resolveClaudeTranscriptDir")(function* ( + homePath: string, +): Effect.fn.Return { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nested = path.join(homePath, ".claude", "projects"); + const nestedExists = yield* fileSystem + .exists(nested) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + return nestedExists ? nested : path.join(homePath, "projects"); +}); + +/** + * One transcript directory per distinct provider home. Walks the same merged + * instance map the registry hydrates from, so explicit `providerInstances` + * entries and the legacy `providers.` mirrors are both covered. + * Instances whose config fails to decode are skipped; the registry already + * reports those as unavailable. + */ +export const resolveUsageTranscriptDirs = Effect.fn("resolveUsageTranscriptDirs")(function* ( + settings: ServerSettings, +): Effect.fn.Return, never, Path.Path | FileSystem.FileSystem> { + const path = yield* Path.Path; + const dirs: UsageTranscriptDir[] = []; + const seen = new Set(); + const add = (provider: UsageProviderKind, dir: string) => { + const key = `${provider}\0${dir}`; + if (seen.has(key)) return; + seen.add(key); + dirs.push({ provider, dir }); + }; + + for (const instance of Object.values(deriveProviderInstanceConfigMap(settings))) { + if (instance.driver === CLAUDE_DRIVER_KIND) { + const config = decodeClaudeSettings(instance.config ?? {}); + if (Option.isNone(config)) continue; + const homePath = yield* resolveClaudeHomePath(config.value); + add("claude", yield* resolveClaudeTranscriptDir(homePath)); + } else if (instance.driver === CODEX_DRIVER_KIND) { + const config = decodeCodexSettings(instance.config ?? {}); + if (Option.isNone(config)) continue; + const layout = yield* resolveCodexHomeLayout(config.value); + add("codex", path.join(layout.sharedHomePath, "sessions")); + } + } + + return dirs; +}); diff --git a/docs/user/usage.md b/docs/user/usage.md index 72d19ba77f37..27f65a156667 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -2,8 +2,9 @@ The Usage page combines Codex and Claude Code activity from your connected environments. It reads the providers' local session history and shows API-equivalent token cost, processed tokens, cache -savings, provider shares, and model breakdowns. Subscription billing is separate from the raw token -cost shown here. +savings, provider shares, and model breakdowns. Every configured Codex and Claude Code instance +counts, including extra instances that use their own home directory. Subscription billing is +separate from the raw token cost shown here. Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**, **30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the