Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 9 additions & 27 deletions apps/server/src/usage/UsageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 =
Expand Down Expand Up @@ -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.
Expand All @@ -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);
});

/**
Expand Down Expand Up @@ -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({
Expand Down
135 changes: 135 additions & 0 deletions apps/server/src/usage/usageTranscriptDirs.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, ProviderInstanceConfig> = {},
): 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);
}),
);
});
});
91 changes: 91 additions & 0 deletions apps/server/src/usage/usageTranscriptDirs.ts
Original file line number Diff line number Diff line change
@@ -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<string, never, Path.Path | FileSystem.FileSystem> {
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.<kind>` 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<ReadonlyArray<UsageTranscriptDir>, never, Path.Path | FileSystem.FileSystem> {
const path = yield* Path.Path;
const dirs: UsageTranscriptDir[] = [];
const seen = new Set<string>();
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;
});
5 changes: 3 additions & 2 deletions docs/user/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading