usagemax 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,9 +14,10 @@ bunx usagemax@latest link UMX-XXXX-XXXX-XXXX-XXXX
14
14
 
15
15
  The link code expires after ten minutes and can be used once. Create a new code
16
16
  for each Mac, Windows PC, Linux computer, and WSL distribution. All linked
17
- collectors roll up into the same profile. Windows and WSL have separate home
18
- directories, so run UsageMax once in Windows and once inside WSL when both have
19
- agent history.
17
+ collectors roll up into the same profile. When run inside WSL, UsageMax includes
18
+ readable supported-provider homes under `/mnt/c/Users` automatically. Use the
19
+ WSL collector as the single collector for that Windows PC instead of linking the
20
+ same host history again from Windows.
20
21
 
21
22
  The CLI stores the resulting collector key in a user-only config file, then runs
22
23
  a one-shot full-history sync. Running it again, changing the display name, or
@@ -29,13 +30,17 @@ installation identity remains stable.
29
30
  bunx usagemax # sync changed usage
30
31
  bunx usagemax sync # same as above
31
32
  bunx usagemax sync --full # reconcile all retained local history
33
+ bunx usagemax sync --archives # one-time compressed-history recovery
34
+ bunx usagemax sync --dry-run --explain
35
+ # inspect the exact bounded plan; upload nothing
32
36
  bunx usagemax link UMX-… --no-sync # link without uploading yet
33
37
  bunx usagemax status # show link and last-sync state
34
38
  bunx usagemax doctor # metadata-only source check
35
- bunx usagemax doctor --deep # parse and verify all retained history
39
+ bunx usagemax doctor --deep --json # machine-readable retained-history audit
36
40
  bunx usagemax report # open ccusage's local daily report
37
41
  bunx usagemax report session --breakdown
38
42
  bunx usagemax unlink # remove the local collector key
43
+ bunx usagemax unlink --revoke # disable future uploads, then remove locally
39
44
  ```
40
45
 
41
46
  UsageMax pins [ccusage v20.0.20](https://github.com/ccusage/ccusage/releases/tag/v20.0.20)
@@ -51,6 +56,20 @@ The source inventory follows ccusage's environment overrides, including
51
56
  `QWEN_DATA_DIR`, `COPILOT_OTEL_FILE_EXPORTER_PATH`, `GEMINI_DATA_DIR`, and
52
57
  `GROK_HOME`. It also follows XDG Claude configuration and Windows Goose storage.
53
58
 
59
+ UsageMax also discovers Claude Desktop local-agent sessions, `.cc-mirror`,
60
+ recognizable renamed Claude/Codex backup folders, and supported Windows homes
61
+ from WSL. Discovery is bounded to known locations and immediate home entries;
62
+ normal syncs do not crawl the whole disk.
63
+ On a multi-user WSL host, automatic Windows-home discovery stays off unless
64
+ there is exactly one provider-bearing profile; set `USAGEMAX_ADDITIONAL_HOME`
65
+ to the intended mounted home explicitly.
66
+
67
+ If `doctor` reports compressed provider archives, run `sync --archives` once.
68
+ Recovery extracts only safe Claude `projects/*.jsonl` members into a private
69
+ temporary directory, performs one full deduplicated reconciliation, and removes
70
+ the temporary files before exit. Weekly and incremental syncs never unpack
71
+ archives.
72
+
54
73
  Local files are only one coverage layer. Cursor, Windsurf, Aider, Continue,
55
74
  Cline, Roo Code, direct provider API traffic, hosted agents, and enterprise
56
75
  billing systems do not all expose a stable local token ledger. Capture those
@@ -59,8 +78,9 @@ connector. UsageMax never invents usage that the source did not retain.
59
78
 
60
79
  ## Privacy, correctness, and load
61
80
 
62
- - The sync payload contains aggregate token counts, model/provider names, costs,
63
- source names, and dates.
81
+ - The sync payload contains authoritative aggregate token counts,
82
+ model/provider names, costs, source names, dates, coverage state, and opaque
83
+ SHA-256 session identities.
64
84
  - It does not upload prompts, completions, source code, file contents, project
65
85
  paths, or provider credentials.
66
86
  - Sync is one-shot. There is no resident scanner or high-frequency polling loop.
@@ -68,15 +88,24 @@ connector. UsageMax never invents usage that the source did not retain.
68
88
  nothing changed.
69
89
  - Normal changed syncs parse today or today plus yesterday. A bounded weekly
70
90
  full reconciliation catches restored files, parser changes, and older logs.
71
- - Full history means all retained local history from 2024 onward. Deleted or
91
+ - Full history means all retained local history from 2024 onward. UsageMax
92
+ publishes complete source/day partitions, so decreased or removed local rows
93
+ correct the server-owned contribution instead of being silently retained.
94
+ Deleted or
72
95
  never-persisted usage requires a provider export; no local tool can reconstruct it.
73
96
  - Source totals that cannot be assigned to a model are retained as
74
97
  `unattributed` rather than silently discarded.
75
98
  - The collector key is written with user-only permissions where the operating
76
99
  system supports them.
100
+ - The package contains no shared service credential or private deployment URL.
101
+ It talks to the versioned `https://usagemax.com/api` contract. The random
102
+ per-installation write token is the only local secret; the service stores only
103
+ its hash and applies device binding, replay checks, payload caps, and quotas.
77
104
  - A private random installation ID survives collector rotation, relinking, and
78
105
  display-name changes. It is not a hardware fingerprint; the server stores only
79
106
  its SHA-256 hash. Concurrent and repeated syncs are idempotent.
107
+ - The server, not the local checkpoint, owns the accounting baseline. A lost
108
+ response or interrupted run can be retried without adding the same partition twice.
80
109
  - Do not point two different installations at the same copied or network-mounted
81
110
  log tree. Cross-installation copied-history deduplication is inherently
82
111
  ambiguous and intentionally not guessed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usagemax",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Link local coding-agent usage to your UsageMax profile",
5
5
  "license": "MIT",
6
6
  "author": "UsageMax",
@@ -18,6 +18,7 @@
18
18
  "usagemax": "src/cli.js"
19
19
  },
20
20
  "files": [
21
+ "src/archives.js",
21
22
  "src/cli.js",
22
23
  "src/core.js",
23
24
  "src/installation.js",
@@ -0,0 +1,86 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
+
7
+ import { ccusageHome, discoverProviderArchives } from "./sources.js";
8
+
9
+ const executeFile = promisify(execFile);
10
+ const MAX_ARCHIVE_LIST_BYTES = 100 * 1024 * 1024;
11
+
12
+ function archiveFlags(filePath, mode) {
13
+ const compressed = /(?:\.tar\.gz|\.tgz)$/i.test(filePath);
14
+ return mode === "list" ? (compressed ? "-tzf" : "-tf") : (compressed ? "-xzf" : "-xf");
15
+ }
16
+
17
+ export function safeArchiveMember(value, source = "claude") {
18
+ const normalized = String(value || "").replaceAll("\\", "/");
19
+ const parts = normalized.split("/").filter((item) => item && item !== ".");
20
+ const providerDirectory = source === "codex"
21
+ ? parts.includes("sessions") || parts.includes("archived_sessions")
22
+ : parts.includes("projects");
23
+ return !normalized.startsWith("/") && !parts.includes("..") && providerDirectory && normalized.endsWith(".jsonl");
24
+ }
25
+
26
+ async function findProviderRoots(root, maxDirectories = 10_000) {
27
+ const claude = [];
28
+ const codex = [];
29
+ const stack = [root];
30
+ let visited = 0;
31
+ while (stack.length && visited < maxDirectories) {
32
+ const current = stack.pop();
33
+ visited += 1;
34
+ let children;
35
+ try {
36
+ children = await readdir(current, { withFileTypes: true });
37
+ } catch {
38
+ continue;
39
+ }
40
+ for (const child of children) {
41
+ if (!child.isDirectory()) continue;
42
+ const childPath = join(current, child.name);
43
+ if (child.name === "projects") claude.push(childPath);
44
+ else if (child.name === "sessions" || child.name === "archived_sessions") codex.push(current);
45
+ else stack.push(childPath);
46
+ }
47
+ }
48
+ return { claude, codex };
49
+ }
50
+
51
+ export async function prepareArchiveRecovery(baseEnv) {
52
+ const detected = await discoverProviderArchives({ env: baseEnv, home: ccusageHome(baseEnv) });
53
+ const archives = detected.filter((item) => /(?:\.tar(?:\.gz)?|\.tgz)$/i.test(item.path));
54
+ const unsupported = detected.length - archives.length;
55
+ if (!archives.length) return { archives: 0, cleanup: async () => undefined, env: baseEnv, unsupported };
56
+
57
+ const temporary = await mkdtemp(join(tmpdir(), "usagemax-recover-"));
58
+ try {
59
+ for (let index = 0; index < archives.length; index += 1) {
60
+ const archive = archives[index];
61
+ const destination = join(temporary, `archive-${index + 1}`);
62
+ await mkdir(destination, { recursive: true, mode: 0o700 });
63
+ const { stdout } = await executeFile("tar", [archiveFlags(archive.path, "list"), archive.path], { encoding: "utf8", maxBuffer: MAX_ARCHIVE_LIST_BYTES });
64
+ const members = stdout.split(/\r?\n/).filter((member) => safeArchiveMember(member, archive.source));
65
+ for (let offset = 0; offset < members.length; offset += 80) {
66
+ await executeFile("tar", [archiveFlags(archive.path, "extract"), archive.path, "-C", destination, "--", ...members.slice(offset, offset + 80)], { maxBuffer: 4 * 1024 * 1024 });
67
+ }
68
+ }
69
+ const roots = await findProviderRoots(temporary);
70
+ const existingClaude = String(baseEnv.CLAUDE_CONFIG_DIR || "").split(",").map((item) => item.trim()).filter(Boolean);
71
+ const existingCodex = String(baseEnv.CODEX_HOME || "").split(",").map((item) => item.trim()).filter(Boolean);
72
+ return {
73
+ archives: archives.length,
74
+ cleanup: async () => rm(temporary, { force: true, recursive: true }),
75
+ env: {
76
+ ...baseEnv,
77
+ CLAUDE_CONFIG_DIR: [...new Set([...existingClaude, ...roots.claude])].join(","),
78
+ CODEX_HOME: [...new Set([...existingCodex, ...roots.codex])].join(","),
79
+ },
80
+ unsupported,
81
+ };
82
+ } catch (error) {
83
+ await rm(temporary, { force: true, recursive: true });
84
+ throw error;
85
+ }
86
+ }
package/src/cli.js CHANGED
@@ -9,14 +9,16 @@ import { dirname, join } from "node:path";
9
9
  import process from "node:process";
10
10
  import { promisify } from "node:util";
11
11
 
12
- import { batchId, buildDeltaPlan, normalizeLinkCode, sourceSummary, validHttpsUrl } from "./core.js";
12
+ import { prepareArchiveRecovery } from "./archives.js";
13
+ import { buildSessionPlan, buildSnapshotPlan, normalizeLinkCode, sourceSummary, validHttpsUrl } from "./core.js";
13
14
  import { stableInstallationId } from "./installation.js";
14
- import { CCUSAGE_VERSION, ccusageEnvironment, SOURCE_INVENTORY_VERSION, sourceInventory, SUPPORTED_SOURCES } from "./sources.js";
15
+ import { CCUSAGE_VERSION, ccusageEnvironment, ccusageHome, discoverProviderArchives, SOURCE_INVENTORY_VERSION, sourceInventory, SUPPORTED_SOURCES } from "./sources.js";
15
16
 
16
17
  const require = createRequire(import.meta.url);
17
18
  const executeFile = promisify(execFile);
18
- const VERSION = "0.2.0";
19
- const DEFAULT_LINK_ENDPOINT = "https://terrific-bobcat-522.convex.site/v1/devices/link";
19
+ const VERSION = "0.3.0";
20
+ const PUBLIC_API_ORIGIN = "https://usagemax.com/api";
21
+ const DEFAULT_LINK_ENDPOINT = `${PUBLIC_API_ORIGIN}/v1/devices/link`;
20
22
  const CONFIG_FILE = "config.json";
21
23
  const MAX_REPORT_BYTES = 100 * 1024 * 1024;
22
24
  const FULL_RECONCILE_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
@@ -31,6 +33,18 @@ function configPath() {
31
33
  return join(configDirectory(), CONFIG_FILE);
32
34
  }
33
35
 
36
+ function isLegacyDirectApi(config) {
37
+ try {
38
+ const ingest = new URL(config.ingestUrl);
39
+ const profile = new URL(config.profileUrl || "https://usagemax.com");
40
+ return profile.hostname === "usagemax.com"
41
+ && ingest.hostname.endsWith(".convex.site")
42
+ && ingest.pathname === "/v1/telemetry/llm";
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
34
48
  async function readConfig() {
35
49
  try {
36
50
  const parsed = JSON.parse(await readFile(configPath(), "utf8"));
@@ -38,6 +52,16 @@ async function readConfig() {
38
52
  if (!validHttpsUrl(parsed.ingestUrl, { allowLocalhost: true })) return null;
39
53
  if (typeof parsed.deviceId !== "string" || !parsed.deviceId) return null;
40
54
  parsed.snapshots = parsed.snapshots && typeof parsed.snapshots === "object" ? parsed.snapshots : {};
55
+ if (isLegacyDirectApi(parsed)) {
56
+ parsed.ingestUrl = `${PUBLIC_API_ORIGIN}/v1/telemetry/llm`;
57
+ parsed.snapshotUrl = `${PUBLIC_API_ORIGIN}/v2/usage/snapshots`;
58
+ parsed.revokeUrl = `${PUBLIC_API_ORIGIN}/v1/devices/revoke`;
59
+ await writeConfig(parsed);
60
+ }
61
+ parsed.snapshotUrl = validHttpsUrl(parsed.snapshotUrl, { allowLocalhost: true })
62
+ || parsed.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v2/usage/snapshots");
63
+ parsed.revokeUrl = validHttpsUrl(parsed.revokeUrl, { allowLocalhost: true })
64
+ || parsed.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/revoke");
41
65
  return parsed;
42
66
  } catch {
43
67
  return null;
@@ -79,19 +103,22 @@ function help() {
79
103
  process.stdout.write(" usagemax Sync changed local usage\n");
80
104
  process.stdout.write(" usagemax link <one-use-code> Link and sync this computer\n");
81
105
  process.stdout.write(" [--no-sync] [--name <name>]\n");
82
- process.stdout.write(" usagemax sync [--full] [--json] Sync usage once, then exit\n");
106
+ process.stdout.write(" usagemax sync [--full] [--archives] [--dry-run] [--explain] [--json]\n");
107
+ process.stdout.write(" Reconcile once; --archives performs one-time recovery\n");
83
108
  process.stdout.write(" usagemax status Show local link status\n");
84
- process.stdout.write(" usagemax doctor [--deep] Check source coverage; --deep parses full history\n");
109
+ process.stdout.write(" usagemax doctor [--deep] [--json]\n");
110
+ process.stdout.write(" Check source coverage; --deep parses full history\n");
85
111
  process.stdout.write(" usagemax report [...args] Run a local ccusage report\n");
86
- process.stdout.write(" usagemax unlink Remove the local collector key\n");
112
+ process.stdout.write(" usagemax unlink [--revoke] Remove locally; --revoke also disables uploads\n");
87
113
  }
88
114
 
89
115
  function ccusageCliPath() {
90
116
  return join(dirname(require.resolve("ccusage/package.json")), "src", "cli.js");
91
117
  }
92
118
 
93
- async function ccusageJson(config, { full = false } = {}) {
119
+ async function ccusageJson(config, { full = false, env } = {}) {
94
120
  const args = [ccusageCliPath(), "daily", "--json", "--offline", "--mode", "calculate", "--timezone", "UTC", "--by-agent", "--order", "asc"];
121
+ args.push("--sections", "daily,session");
95
122
  // Reconcile yesterday once after the UTC date changes. All other incremental
96
123
  // scans parse only today; a metadata fingerprint avoids invoking ccusage when
97
124
  // no supported local source changed at all.
@@ -104,11 +131,48 @@ async function ccusageJson(config, { full = false } = {}) {
104
131
  const { stdout } = await executeFile(process.execPath, args, {
105
132
  encoding: "utf8",
106
133
  maxBuffer: MAX_REPORT_BYTES,
107
- env: { ...await ccusageEnvironment(), NO_COLOR: "1" },
134
+ env: { ...(env || await ccusageEnvironment()), NO_COLOR: "1" },
108
135
  });
109
136
  return JSON.parse(stdout);
110
137
  }
111
138
 
139
+ function snapshotEndpoint(config) {
140
+ return validHttpsUrl(config.snapshotUrl, { allowLocalhost: true })
141
+ || config.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v2/usage/snapshots");
142
+ }
143
+
144
+ async function snapshotRequest(config, operation, payload, timeout = 30_000) {
145
+ const response = await fetch(snapshotEndpoint(config), {
146
+ method: "POST",
147
+ headers: {
148
+ authorization: `Bearer ${config.token}`,
149
+ "content-type": "application/json",
150
+ "x-usagemax-device-id": config.deviceId,
151
+ },
152
+ body: JSON.stringify({ operation, ...payload }),
153
+ signal: AbortSignal.timeout(timeout),
154
+ });
155
+ const body = await response.json().catch(() => ({}));
156
+ if (!response.ok) {
157
+ if (body?.error === "unauthorized") throw new Error("This collector key is no longer valid. Link the computer again.");
158
+ throw new Error(`UsageMax rejected ${operation} (${response.status}${body?.error ? `: ${body.error}` : ""}).`);
159
+ }
160
+ return body;
161
+ }
162
+
163
+ function newerVersion(recommended) {
164
+ if (!/^\d+\.\d+\.\d+$/.test(recommended || "")) return false;
165
+ const current = VERSION.split(".").map(Number);
166
+ const next = recommended.split(".").map(Number);
167
+ return next.some((value, index) => value > current[index] && next.slice(0, index).every((prior, priorIndex) => prior === current[priorIndex]));
168
+ }
169
+
170
+ function warnVersion(body) {
171
+ if (newerVersion(body?.recommendedCliVersion)) {
172
+ process.stderr.write(`UsageMax ${body.recommendedCliVersion} is available. Run \`bunx usagemax@latest\` for current coverage fixes.\n`);
173
+ }
174
+ }
175
+
112
176
  async function link(args) {
113
177
  const code = normalizeLinkCode(args[0]);
114
178
  if (!code) throw new Error("Paste the one-use UMX link code shown at usagemax.com/account.");
@@ -129,13 +193,17 @@ async function link(args) {
129
193
  const body = await response.json().catch(() => ({}));
130
194
  if (!response.ok) throw new Error(body?.error === "invalid_or_expired_link_code" ? "That link code is invalid, expired, or already used." : "UsageMax could not link this computer.");
131
195
  const ingestUrl = validHttpsUrl(body.ingestUrl, { allowLocalhost: true });
132
- if (!/^umx_[a-f0-9]{64}$/.test(body.token || "") || !ingestUrl) throw new Error("UsageMax returned an invalid link response.");
196
+ const snapshotUrl = validHttpsUrl(body.snapshotUrl, { allowLocalhost: true }) || ingestUrl?.replace(/\/v1\/telemetry\/llm$/, "/v2/usage/snapshots");
197
+ const revokeUrl = validHttpsUrl(body.revokeUrl, { allowLocalhost: true }) || ingestUrl?.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/revoke");
198
+ if (!/^umx_[a-f0-9]{64}$/.test(body.token || "") || !ingestUrl || !snapshotUrl || !revokeUrl) throw new Error("UsageMax returned an invalid link response.");
133
199
  const profileHandle = typeof body.profileHandle === "string" ? body.profileHandle : undefined;
134
200
  const sameAccount = Boolean(previous && previous.profileHandle && previous.profileHandle === profileHandle);
135
201
  const config = {
136
202
  version: 1,
137
203
  token: body.token,
138
204
  ingestUrl,
205
+ snapshotUrl,
206
+ revokeUrl,
139
207
  profileUrl: validHttpsUrl(body.profileUrl) || "https://usagemax.com/account",
140
208
  profileHandle,
141
209
  deviceId,
@@ -144,6 +212,7 @@ async function link(args) {
144
212
  snapshots: sameAccount ? previous.snapshots : {},
145
213
  };
146
214
  await writeConfig(config);
215
+ warnVersion(body);
147
216
  process.stdout.write(`Linked ${name} to ${config.profileHandle ? `@${config.profileHandle}` : "UsageMax"}.\n`);
148
217
  if (args.includes("--no-sync")) {
149
218
  process.stdout.write("No usage was uploaded. Run `bunx usagemax sync --full` when you are ready.\n");
@@ -154,67 +223,139 @@ async function link(args) {
154
223
  }
155
224
 
156
225
  async function sync(args, suppliedConfig) {
226
+ const baseEnv = await ccusageEnvironment();
227
+ const recovery = args.includes("--archives")
228
+ ? await prepareArchiveRecovery(baseEnv)
229
+ : { archives: 0, cleanup: async () => undefined, env: baseEnv, unsupported: 0 };
230
+ try {
231
+ return await syncPrepared(args, suppliedConfig, recovery);
232
+ } finally {
233
+ await recovery.cleanup();
234
+ }
235
+ }
236
+
237
+ async function syncPrepared(args, suppliedConfig, recovery) {
157
238
  const config = suppliedConfig || await readConfig();
158
239
  if (!config) throw new Error("This computer is not linked. Open https://usagemax.com/account and create a link code.");
159
240
  config.deviceId = await stableInstallationId(configDirectory(), config.deviceId);
160
241
  const requestedFull = args.includes("--full");
161
- const inventory = await sourceInventory();
242
+ const requestedArchives = args.includes("--archives");
243
+ const dryRun = args.includes("--dry-run");
244
+ const explain = args.includes("--explain");
245
+ const json = args.includes("--json");
246
+ const inventory = await sourceInventory({ env: recovery.env, home: ccusageHome(recovery.env) });
162
247
  const today = new Date().toISOString().slice(0, 10);
163
248
  const knownSources = Array.isArray(config.knownSources) ? config.knownSources : [];
164
249
  const foundNewSource = inventory.sources.some((source) => !knownSources.includes(source));
165
250
  const lastFullSync = Date.parse(config.lastFullSyncAt || "");
166
- const fullDue = config.sourceInventoryVersion !== SOURCE_INVENTORY_VERSION
251
+ const fullDue = config.snapshotProtocolVersion !== 2
252
+ || config.sourceInventoryVersion !== SOURCE_INVENTORY_VERSION
167
253
  || !Number.isFinite(lastFullSync)
168
254
  || Date.now() - lastFullSync >= FULL_RECONCILE_INTERVAL_MS
169
255
  || foundNewSource;
170
- const full = requestedFull || fullDue;
256
+ const full = requestedFull || requestedArchives || fullDue;
171
257
  if (!full && inventory.complete && config.lastSyncComplete && config.lastReconciledDay === today && config.sourceFingerprint === inventory.fingerprint) {
172
- const result = { accepted: 0, changedRows: 0, sources: inventory.sources, regressions: 0, scanned: false, full: false };
173
- if (args.includes("--json")) process.stdout.write(`${JSON.stringify(result)}\n`);
258
+ const result = { accepted: 0, changedRows: 0, sessions: 0, sources: inventory.sources, corrections: 0, scanned: false, full: false, coverage: inventory.complete ? "complete" : "partial" };
259
+ if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
174
260
  else process.stdout.write("Already up to date. Local usage files have not changed; no logs were parsed or uploaded.\n");
175
261
  return;
176
262
  }
177
- const report = await ccusageJson(config, { full });
178
- const { plan, regressions } = buildDeltaPlan(report, config.snapshots, config.deviceId, `ccusage@${CCUSAGE_VERSION}`);
263
+ const report = await ccusageJson(config, { env: recovery.env, full });
264
+ const legacySnapshotBootstrap = config.snapshotProtocolVersion !== 2
265
+ && Object.keys(config.snapshots || {}).length > 0;
266
+ const runId = randomUUID();
267
+ const revision = Date.now();
268
+ const pricingVersion = `ccusage@${CCUSAGE_VERSION}`;
269
+ const { partitions, nextSnapshots, regressions } = buildSnapshotPlan(report, config.snapshots, {
270
+ bootstrap: config.snapshotProtocolVersion !== 2,
271
+ complete: inventory.complete,
272
+ full,
273
+ pricingVersion,
274
+ revision,
275
+ runId,
276
+ });
277
+ const sessions = buildSessionPlan(report, config.deviceId);
278
+ const sources = sourceSummary(report);
279
+ const days = Array.isArray(report?.daily)
280
+ ? report.daily.map((row) => row?.period).filter((day) => /^\d{4}-\d{2}-\d{2}$/.test(day || "")).sort()
281
+ : [];
282
+ const result = {
283
+ accepted: 0,
284
+ changedRows: partitions.reduce((sum, partition) => sum + partition.rows.filter((row) => JSON.stringify(row.previous) !== JSON.stringify(row.current)).length, 0),
285
+ sessions: sessions.length,
286
+ sources,
287
+ corrections: regressions.length,
288
+ partitions: partitions.length,
289
+ scanned: true,
290
+ full,
291
+ coverage: inventory.complete && !inventory.truncated && inventory.errors === 0 ? "complete" : "partial",
292
+ range: { from: days[0], to: days.at(-1) },
293
+ };
294
+ if (requestedArchives) {
295
+ result.archives = recovery.archives;
296
+ result.unsupportedArchives = recovery.unsupported;
297
+ }
298
+ if (dryRun) {
299
+ if (json) process.stdout.write(`${JSON.stringify({ ...result, dryRun: true })}\n`);
300
+ else {
301
+ process.stdout.write(`Dry run: ${partitions.length} partition(s), ${result.changedRows} changed row(s), ${sessions.length} private session identifiers, no upload.\n`);
302
+ if (explain) process.stdout.write(`Coverage ${result.coverage}; ${sources.length} source(s); ${days[0] || "unknown"} to ${days.at(-1) || "unknown"}; ${regressions.length} downward correction(s).\n`);
303
+ }
304
+ return result;
305
+ }
179
306
  config.lastSyncComplete = false;
180
307
  await writeConfig(config);
181
- let accepted = 0;
182
- for (let offset = 0; offset < plan.length; offset += 100) {
183
- const batch = plan.slice(offset, offset + 100);
184
- const events = batch.map((item) => item.event);
185
- const response = await fetch(config.ingestUrl, {
186
- method: "POST",
187
- headers: {
188
- authorization: `Bearer ${config.token}`,
189
- "content-type": "application/json",
190
- "idempotency-key": batchId(config.deviceId, events),
191
- "x-usagemax-device-id": config.deviceId,
192
- },
193
- body: JSON.stringify({ events }),
194
- signal: AbortSignal.timeout(30_000),
308
+ try {
309
+ const begin = await snapshotRequest(config, "begin", {
310
+ runId,
311
+ mode: requestedArchives ? "archives" : full ? "full" : "incremental",
312
+ baselineMode: legacySnapshotBootstrap ? "adopt-current" : "apply",
313
+ sourceCount: sources.length,
314
+ partitionCount: partitions.length,
315
+ inventoryComplete: inventory.complete,
316
+ inventoryErrors: inventory.errors,
317
+ inventoryTruncated: inventory.truncated,
318
+ coverageStartDay: days[0],
319
+ coverageEndDay: days.at(-1),
195
320
  });
196
- const body = await response.json().catch(() => ({}));
197
- if (!response.ok) throw new Error(body?.error === "unauthorized" ? "This collector key is no longer valid. Link the computer again." : `UsageMax rejected a sync batch (${response.status}).`);
198
- accepted += Number(body.accepted || 0);
199
- for (const item of batch) config.snapshots[item.snapshotKey] = item.snapshot;
321
+ warnVersion(begin);
322
+ for (let offset = 0; offset < sessions.length; offset += 100) {
323
+ await snapshotRequest(config, "sessions", { runId, sessions: sessions.slice(offset, offset + 100) });
324
+ }
325
+ for (let offset = 0; offset < partitions.length; offset += 10) {
326
+ const response = await snapshotRequest(config, "partitions", { runId, partitions: partitions.slice(offset, offset + 10) }, 60_000);
327
+ result.accepted += Number(response.changedRows || 0);
328
+ if (!json && process.stderr.isTTY && partitions.length > 10) {
329
+ process.stderr.write(`UsageMax: uploaded ${Math.min(offset + 10, partitions.length)}/${partitions.length} history partitions\r`);
330
+ }
331
+ }
332
+ if (!json && process.stderr.isTTY && partitions.length > 10) process.stderr.write("\n");
333
+ const completed = await snapshotRequest(config, "complete", { runId });
334
+ warnVersion(completed);
335
+ config.snapshots = nextSnapshots;
336
+ config.snapshotProtocolVersion = 2;
200
337
  config.lastSyncAt = new Date().toISOString();
338
+ config.lastReconciledDay = today;
339
+ config.lastSyncComplete = true;
340
+ config.sourceInventoryVersion = SOURCE_INVENTORY_VERSION;
341
+ config.knownSources = [...new Set([...knownSources, ...inventory.sources, ...sources])].sort();
342
+ if (inventory.complete) config.sourceFingerprint = inventory.fingerprint;
343
+ else delete config.sourceFingerprint;
344
+ if (full) config.lastFullSyncAt = config.lastSyncAt;
201
345
  await writeConfig(config);
346
+ } catch (error) {
347
+ await snapshotRequest(config, "fail", { runId, failureCode: error instanceof Error ? error.message.slice(0, 80) : "sync_failed" }).catch(() => undefined);
348
+ throw error;
202
349
  }
203
- config.lastSyncAt = new Date().toISOString();
204
- config.lastReconciledDay = today;
205
- config.lastSyncComplete = true;
206
- config.sourceInventoryVersion = SOURCE_INVENTORY_VERSION;
207
- config.knownSources = [...new Set([...knownSources, ...inventory.sources, ...sourceSummary(report)])].sort();
208
- if (inventory.complete) config.sourceFingerprint = inventory.fingerprint;
209
- else delete config.sourceFingerprint;
210
- if (full) config.lastFullSyncAt = config.lastSyncAt;
211
- await writeConfig(config);
212
- const result = { accepted, changedRows: plan.length, sources: sourceSummary(report), regressions: regressions.length, scanned: true, full };
213
- if (args.includes("--json")) process.stdout.write(`${JSON.stringify(result)}\n`);
350
+ if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
214
351
  else {
215
- process.stdout.write(plan.length ? `Synced ${accepted} changed usage rows from ${result.sources.join(", ") || "local agents"}${full ? " (full history)" : ""}.\n` : `Already up to date. No usage rows were uploaded${full ? " after a full-history reconciliation" : ""}.\n`);
216
- if (regressions.length) process.stdout.write(`${regressions.length} local row(s) moved backward; UsageMax kept the prior high-water mark to prevent double counting.\n`);
352
+ process.stdout.write(partitions.length || sessions.length
353
+ ? `Reconciled ${result.accepted} changed usage row(s) and ${sessions.length} session identifier(s) from ${sources.join(", ") || "local agents"}${full ? " across retained history" : ""}.\n`
354
+ : `Already up to date. No usage rows changed${full ? " after a full-history reconciliation" : ""}.\n`);
355
+ if (regressions.length) process.stdout.write(`${regressions.length} local row(s) moved backward and were submitted as authoritative corrections.\n`);
356
+ if (explain) process.stdout.write(`Coverage ${result.coverage}; ${sources.length} source(s); ${days[0] || "unknown"} to ${days.at(-1) || "unknown"}; ${partitions.length} atomic partition(s).\n`);
217
357
  }
358
+ return result;
218
359
  }
219
360
 
220
361
  async function status() {
@@ -236,18 +377,42 @@ async function status() {
236
377
 
237
378
  async function doctor(args = []) {
238
379
  const config = await readConfig();
239
- const inventory = await sourceInventory();
380
+ const env = await ccusageEnvironment();
381
+ const inventory = await sourceInventory({ env, home: ccusageHome(env) });
382
+ const archives = await discoverProviderArchives({ env, home: ccusageHome(env) });
383
+ const homes = String(env.USAGEMAX_DISCOVERED_HOMES || ccusageHome(env)).split(",").filter(Boolean);
384
+ const result = {
385
+ linked: Boolean(config),
386
+ homes,
387
+ detectedSources: inventory.sources,
388
+ files: inventory.files,
389
+ inventoryComplete: inventory.complete,
390
+ inventoryErrors: inventory.errors,
391
+ inventoryTruncated: inventory.truncated,
392
+ supportedSources: SUPPORTED_SOURCES,
393
+ archives: archives.length,
394
+ environment: platform() === "linux" && process.env.WSL_DISTRO_NAME ? `WSL ${process.env.WSL_DISTRO_NAME}` : platform(),
395
+ mode: args.includes("--deep") ? "deep" : "metadata-only",
396
+ };
397
+ if (args.includes("--deep")) {
398
+ const report = await ccusageJson(config, { env, full: true });
399
+ result.parsedSources = sourceSummary(report);
400
+ result.sessions = buildSessionPlan(report, config?.deviceId || "unlinked").length;
401
+ }
402
+ if (args.includes("--json")) {
403
+ process.stdout.write(`${JSON.stringify(result)}\n`);
404
+ return;
405
+ }
240
406
  process.stdout.write(`Collector: ${config ? "linked" : "not linked"}\n`);
407
+ process.stdout.write(`Discovered homes: ${homes.length} (${homes.join(", ")})\n`);
241
408
  process.stdout.write(`Detected sources: ${inventory.sources.join(", ") || "none"} (${inventory.files}${inventory.truncated ? "+" : ""} data files)\n`);
242
409
  process.stdout.write(`Supported sources: ${SUPPORTED_SOURCES.join(", ")} (+ named pi-format stores)\n`);
243
410
  if (platform() === "linux" && process.env.WSL_DISTRO_NAME) {
244
- process.stdout.write(`Environment: WSL ${process.env.WSL_DISTRO_NAME}; its Linux home is collected separately from Windows\n`);
411
+ process.stdout.write(`Environment: WSL ${process.env.WSL_DISTRO_NAME}; readable Windows provider homes are included automatically\n`);
245
412
  }
413
+ if (archives.length) process.stdout.write(`Recovery: ${archives.length} compressed provider archive(s) detected; run \`bunx usagemax sync --archives\` once to reconcile them\n`);
246
414
  if (!inventory.complete) process.stdout.write(`Inventory: incomplete (${inventory.errors} read error(s)${inventory.truncated ? ", file limit reached" : ""}); no-change shortcut disabled\n`);
247
- if (args.includes("--deep")) {
248
- const report = await ccusageJson(config, { full: true });
249
- process.stdout.write(`Parsed sources: ${sourceSummary(report).join(", ") || "none"}\n`);
250
- }
415
+ if (result.parsedSources) process.stdout.write(`Parsed sources: ${result.parsedSources.join(", ") || "none"}; ${result.sessions} private session identifiers\n`);
251
416
  process.stdout.write(`Mode: one-shot, metadata no-op check, ${args.includes("--deep") ? "deep local parse" : "no log parsing"}\n`);
252
417
  }
253
418
 
@@ -264,15 +429,28 @@ async function report(args) {
264
429
  if (code !== 0) process.exitCode = code;
265
430
  }
266
431
 
267
- async function removeLink() {
432
+ async function removeLink(args = []) {
268
433
  const path = configPath();
269
434
  const config = await readConfig();
270
435
  if (!config) {
271
436
  process.stdout.write("This computer is not linked.\n");
272
437
  return;
273
438
  }
439
+ if (args.includes("--revoke")) {
440
+ const endpoint = validHttpsUrl(config.revokeUrl, { allowLocalhost: true });
441
+ if (!endpoint) throw new Error("This collector does not have a valid revoke endpoint. Revoke it at https://usagemax.com/account.");
442
+ const response = await fetch(endpoint, {
443
+ method: "POST",
444
+ headers: { authorization: `Bearer ${config.token}`, "content-type": "application/json" },
445
+ body: JSON.stringify({ deviceId: config.deviceId }),
446
+ signal: AbortSignal.timeout(15_000),
447
+ });
448
+ if (!response.ok) throw new Error("UsageMax could not revoke this collector. It remains linked locally.");
449
+ }
274
450
  await unlink(path);
275
- process.stdout.write("Removed the local UsageMax collector key. This computer's private installation identity was retained so relinking cannot duplicate its usage. Revoke the collector in your account if this computer is no longer trusted.\n");
451
+ process.stdout.write(args.includes("--revoke")
452
+ ? "Revoked this collector and removed its local key. Existing usage totals were retained.\n"
453
+ : "Removed the local UsageMax collector key. This computer's private installation identity was retained so relinking cannot duplicate its usage. Revoke the collector in your account if this computer is no longer trusted.\n");
276
454
  }
277
455
 
278
456
  async function main() {
@@ -285,7 +463,7 @@ async function main() {
285
463
  if (command === "status") return status();
286
464
  if (command === "doctor") return doctor(args.slice(1));
287
465
  if (command === "report") return report(args.slice(1));
288
- if (command === "unlink") return removeLink();
466
+ if (command === "unlink") return removeLink(args.slice(1));
289
467
  throw new Error(`Unknown command: ${command}. Run usagemax --help.`);
290
468
  }
291
469
 
package/src/core.js CHANGED
@@ -43,6 +43,82 @@ function providerFor(model, source) {
43
43
  return source;
44
44
  }
45
45
 
46
+ const snapshotCounterFields = [
47
+ "inputTokens",
48
+ "outputTokens",
49
+ "cacheReadTokens",
50
+ "cacheWriteTokens",
51
+ "reasoningTokens",
52
+ "unclassifiedTokens",
53
+ "totalTokens",
54
+ "costMicros",
55
+ "requests",
56
+ "errors",
57
+ ];
58
+
59
+ function snapshotCounters(value = {}) {
60
+ const inputTokens = number(value.inputTokens);
61
+ const outputTokens = number(value.outputTokens);
62
+ const cacheReadTokens = number(value.cacheReadTokens);
63
+ const cacheWriteTokens = number(value.cacheWriteTokens);
64
+ const reasoningTokens = number(value.reasoningTokens);
65
+ const suppliedTotal = number(value.totalTokens);
66
+ const classified = inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + reasoningTokens;
67
+ const unclassifiedTokens = value.unclassifiedTokens === undefined
68
+ ? Math.max(0, suppliedTotal - classified)
69
+ : number(value.unclassifiedTokens);
70
+ return {
71
+ inputTokens,
72
+ outputTokens,
73
+ cacheReadTokens,
74
+ cacheWriteTokens,
75
+ reasoningTokens,
76
+ unclassifiedTokens,
77
+ totalTokens: classified + unclassifiedTokens,
78
+ costMicros: number(value.costMicros, MAX_SAFE_COST_MICROS),
79
+ requests: number(value.requests),
80
+ errors: number(value.errors),
81
+ };
82
+ }
83
+
84
+ function sameCounters(left, right) {
85
+ return snapshotCounterFields.every((field) => left[field] === right[field]);
86
+ }
87
+
88
+ function snapshotKey(source, period, provider, model) {
89
+ return `${source}\u001f${period}\u001f${provider}\u001f${model}`;
90
+ }
91
+
92
+ function readSnapshotKey(key) {
93
+ const parts = String(key).split("\u001f");
94
+ if (parts.length === 4) return { source: parts[0], period: parts[1], provider: parts[2], model: parts[3] };
95
+ if (parts.length === 3) return { source: parts[0], period: parts[1], provider: providerFor(parts[2], parts[0]), model: parts[2] };
96
+ return null;
97
+ }
98
+
99
+ function normalizedSnapshotRows(report) {
100
+ const rows = new Map();
101
+ for (const row of currentRows(report)) {
102
+ const provider = providerFor(row.model, row.source);
103
+ const key = snapshotKey(row.source, row.period, provider, row.model);
104
+ const current = snapshotCounters(row.current);
105
+ const existing = rows.get(key);
106
+ if (existing) {
107
+ for (const field of snapshotCounterFields) existing.current[field] += current[field];
108
+ } else {
109
+ rows.set(key, {
110
+ key,
111
+ source: row.source,
112
+ period: row.period,
113
+ provider,
114
+ model: row.model,
115
+ current,
116
+ });
117
+ }
118
+ }
119
+ return rows;
120
+ }
121
+
46
122
  function sha256(value) {
47
123
  return createHash("sha256").update(value).digest("hex");
48
124
  }
@@ -195,6 +271,118 @@ export function buildDeltaPlan(report, priorSnapshots, deviceId, pricingVersion
195
271
  return { plan, regressions };
196
272
  }
197
273
 
274
+ export function buildSnapshotPlan(report, priorSnapshots, {
275
+ bootstrap = false,
276
+ complete = true,
277
+ full = false,
278
+ pricingVersion = "ccusage",
279
+ revision = Date.now(),
280
+ runId = randomPlanId(),
281
+ } = {}) {
282
+ const rows = normalizedSnapshotRows(report);
283
+ const priorRows = new Map();
284
+ const snapshots = priorSnapshots && typeof priorSnapshots === "object" ? priorSnapshots : {};
285
+ for (const [rawKey, rawValue] of Object.entries(snapshots)) {
286
+ const identity = readSnapshotKey(rawKey);
287
+ if (!identity || !/^\d{4}-\d{2}-\d{2}$/.test(identity.period)) continue;
288
+ const key = snapshotKey(identity.source, identity.period, identity.provider, identity.model);
289
+ priorRows.set(key, {
290
+ key,
291
+ ...identity,
292
+ current: snapshotCounters(rawValue),
293
+ });
294
+ }
295
+
296
+ const currentPartitions = new Set([...rows.values()].map((row) => `${row.source}\u001f${row.period}`));
297
+ const allKeys = new Set(rows.keys());
298
+ for (const [key, prior] of priorRows) {
299
+ if (full || currentPartitions.has(`${prior.source}\u001f${prior.period}`)) allKeys.add(key);
300
+ }
301
+
302
+ const grouped = new Map();
303
+ const nextSnapshots = {};
304
+ const regressions = [];
305
+ for (const key of allKeys) {
306
+ const currentRow = rows.get(key);
307
+ const priorRow = priorRows.get(key);
308
+ const identity = currentRow || priorRow;
309
+ if (!identity) continue;
310
+ const previous = priorRow?.current ?? snapshotCounters();
311
+ const current = currentRow?.current ?? snapshotCounters();
312
+ if (snapshotCounterFields.some((field) => current[field] < previous[field])) regressions.push(key);
313
+ if (!sameCounters(current, snapshotCounters())) nextSnapshots[key] = current;
314
+ const partitionKey = `${identity.source}\u001f${identity.period}`;
315
+ const rowsForPartition = grouped.get(partitionKey) ?? [];
316
+ rowsForPartition.push({
317
+ snapshotKey: key,
318
+ provider: identity.provider,
319
+ model: identity.model,
320
+ previous,
321
+ current,
322
+ costBasis: "estimated",
323
+ contentHash: sha256(`${key}\u001f${JSON.stringify(current)}`),
324
+ lastUsedAt: Date.parse(`${identity.period}T12:00:00.000Z`),
325
+ });
326
+ grouped.set(partitionKey, rowsForPartition);
327
+ }
328
+
329
+ const partitions = [];
330
+ for (const [partitionKey, partitionRows] of grouped) {
331
+ const [source, day] = partitionKey.split("\u001f");
332
+ const changed = partitionRows.some((row) => !sameCounters(row.previous, row.current));
333
+ if (!bootstrap && !full && !changed) continue;
334
+ partitionRows.sort((left, right) => `${left.provider}/${left.model}`.localeCompare(`${right.provider}/${right.model}`));
335
+ const wireRows = partitionRows.map(({ provider, model, previous, current, costBasis, contentHash, lastUsedAt }) => ({
336
+ provider,
337
+ model,
338
+ previous,
339
+ current,
340
+ costBasis,
341
+ contentHash,
342
+ lastUsedAt,
343
+ }));
344
+ const payloadHash = sha256(JSON.stringify({ source, day, complete, pricingVersion, rows: wireRows }));
345
+ partitions.push({
346
+ partitionId: `${runId}:${sha256(partitionKey).slice(0, 24)}`,
347
+ payloadHash,
348
+ revision,
349
+ source,
350
+ day,
351
+ complete,
352
+ pricingVersion,
353
+ rows: wireRows,
354
+ });
355
+ }
356
+ partitions.sort((left, right) => left.day === right.day ? left.source.localeCompare(right.source) : left.day.localeCompare(right.day));
357
+ return { partitions, nextSnapshots, regressions };
358
+ }
359
+
360
+ function randomPlanId() {
361
+ return `run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
362
+ }
363
+
364
+ export function buildSessionPlan(report, deviceId) {
365
+ const sessionRows = Array.isArray(report?.session) ? report.session : [];
366
+ const sessions = new Map();
367
+ for (const row of sessionRows) {
368
+ if (!row || typeof row !== "object") continue;
369
+ const source = text(row.agent, "unknown", 60).toLowerCase();
370
+ const stableIdentity = text(row.period ?? row.sessionId ?? row.id, "", 1000);
371
+ if (!stableIdentity) continue;
372
+ const metadata = row.metadata && typeof row.metadata === "object" ? row.metadata : {};
373
+ const firstActivityAt = Date.parse(metadata.firstActivity ?? metadata.createdAt ?? "");
374
+ const lastActivityAt = Date.parse(metadata.lastActivity ?? row.lastActivity ?? "");
375
+ const sessionKey = sha256(`${deviceId}\u001f${source}\u001f${stableIdentity}`);
376
+ sessions.set(`${source}\u001f${sessionKey}`, {
377
+ source,
378
+ sessionKey,
379
+ ...(Number.isFinite(firstActivityAt) ? { firstActivityAt } : {}),
380
+ ...(Number.isFinite(lastActivityAt) ? { lastActivityAt } : {}),
381
+ });
382
+ }
383
+ return [...sessions.values()].sort((left, right) => `${left.source}/${left.sessionKey}`.localeCompare(`${right.source}/${right.sessionKey}`));
384
+ }
385
+
198
386
  export function batchId(deviceId, events) {
199
387
  const identity = events.map((event) => event.eventKey).join("\u001f");
200
388
  return `cli:${deviceId}:${sha256(identity).slice(0, 32)}`;
package/src/sources.js CHANGED
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
4
4
  import path from "node:path";
5
5
 
6
6
  export const CCUSAGE_VERSION = "20.0.20";
7
- export const SOURCE_INVENTORY_VERSION = 2;
7
+ export const SOURCE_INVENTORY_VERSION = 3;
8
8
  export const SUPPORTED_SOURCES = [
9
9
  "amp",
10
10
  "claude",
@@ -25,6 +25,10 @@ export const SUPPORTED_SOURCES = [
25
25
  ];
26
26
 
27
27
  const MAX_FINGERPRINT_FILES = 50_000;
28
+ const WINDOWS_SYSTEM_PROFILES = /^(?:all users|default(?: user)?|defaultuser0|public|temp(?:\.|$)|umfd-)/i;
29
+ const BACKUP_DIRECTORY = /(?:claude|codex).*(?:backup|archive|old|copy|mirror)|(?:backup|archive|old|copy|mirror).*(?:claude|codex)|superclaude/i;
30
+ const ARCHIVE_EXTENSION = /(?:\.tar(?:\.gz)?|\.tgz|\.zip|\.7z)$/i;
31
+ const ARCHIVE_FILE = /(?:claude|codex).*(?:\.tar(?:\.gz)?|\.tgz|\.zip|\.7z)$/i;
28
32
 
29
33
  function has(env, name) {
30
34
  return Object.prototype.hasOwnProperty.call(env, name);
@@ -45,6 +49,127 @@ function expandTilde(value, home, pathApi) {
45
49
  return value;
46
50
  }
47
51
 
52
+ function uniquePaths(values, pathApi) {
53
+ const seen = new Set();
54
+ return values.filter((value) => {
55
+ if (typeof value !== "string" || !value.trim()) return false;
56
+ const normalized = pathApi.normalize(value.trim());
57
+ if (seen.has(normalized)) return false;
58
+ seen.add(normalized);
59
+ return true;
60
+ });
61
+ }
62
+
63
+ async function entries(value) {
64
+ try {
65
+ return await readdir(value, { withFileTypes: true });
66
+ } catch {
67
+ return [];
68
+ }
69
+ }
70
+
71
+ async function providerBearingHome(home, pathApi) {
72
+ const markers = [".claude", ".codex", ".factory", ".gemini", ".openclaw", ".hermes", ".grok"];
73
+ const checks = await Promise.all(markers.map((marker) => existsDirectory(pathApi.join(home, marker))));
74
+ return checks.some(Boolean);
75
+ }
76
+
77
+ async function discoverWslWindowsHomes(env, platform, pathApi) {
78
+ if (platform !== "linux" || !String(env.WSL_DISTRO_NAME ?? "").trim()) return [];
79
+ const usersRoot = String(env.USAGEMAX_WSL_USERS_DIR ?? "/mnt/c/Users").trim();
80
+ const candidates = [];
81
+ for (const entry of await entries(usersRoot)) {
82
+ if (!entry.isDirectory() || WINDOWS_SYSTEM_PROFILES.test(entry.name)) continue;
83
+ const candidate = pathApi.join(usersRoot, entry.name);
84
+ if (await providerBearingHome(candidate, pathApi)) candidates.push(candidate);
85
+ }
86
+ // A WSL distro can see every Windows profile. Auto-select only when there is
87
+ // one unambiguous provider-bearing profile; multi-user systems opt in with
88
+ // USAGEMAX_ADDITIONAL_HOME so one employee never absorbs another's usage.
89
+ return candidates.length === 1 ? candidates : [];
90
+ }
91
+
92
+ async function discoverBackupRoots(home, pathApi) {
93
+ const claude = [];
94
+ const codex = [];
95
+ const containers = [
96
+ { path: home, requireProviderName: true },
97
+ { path: pathApi.join(home, ".claude", "backups"), requireProviderName: false },
98
+ { path: pathApi.join(home, ".codex", "backups"), requireProviderName: false },
99
+ ];
100
+ for (const container of containers) {
101
+ for (const entry of await entries(container.path)) {
102
+ if (!entry.isDirectory() || (container.requireProviderName && !BACKUP_DIRECTORY.test(entry.name))) continue;
103
+ const candidate = pathApi.join(container.path, entry.name);
104
+ const claudeRoots = [candidate, pathApi.join(candidate, ".claude"), pathApi.join(candidate, "config")];
105
+ for (const root of claudeRoots) {
106
+ if (await existsDirectory(pathApi.join(root, "projects"))) claude.push(root);
107
+ }
108
+ const codexRoots = [candidate, pathApi.join(candidate, ".codex")];
109
+ for (const root of codexRoots) {
110
+ if (await existsDirectory(pathApi.join(root, "sessions")) || await existsDirectory(pathApi.join(root, "archived_sessions"))) codex.push(root);
111
+ }
112
+ }
113
+ }
114
+ return { claude, codex };
115
+ }
116
+
117
+ async function discoverNestedClaudeRoots(home, pathApi, maxDirectories = 4_096) {
118
+ const bases = [
119
+ pathApi.join(home, "Library", "Application Support", "Claude", "local-agent-mode-sessions"),
120
+ pathApi.join(home, "AppData", "Roaming", "Claude", "local-agent-mode-sessions"),
121
+ ];
122
+ const roots = [];
123
+ for (const base of bases) {
124
+ if (!await existsDirectory(base)) continue;
125
+ const stack = [{ directory: base, depth: 0 }];
126
+ let visited = 0;
127
+ while (stack.length && visited < maxDirectories) {
128
+ const { directory, depth } = stack.pop();
129
+ visited += 1;
130
+ for (const entry of await entries(directory)) {
131
+ if (!entry.isDirectory() || entry.name === "node_modules" || entry.name === ".git") continue;
132
+ const child = pathApi.join(directory, entry.name);
133
+ if (entry.name === ".claude") {
134
+ if (await existsDirectory(pathApi.join(child, "projects"))) roots.push(child);
135
+ } else if (depth < 7) {
136
+ stack.push({ directory: child, depth: depth + 1 });
137
+ }
138
+ }
139
+ }
140
+ }
141
+ return roots;
142
+ }
143
+
144
+ function setDiscoveredList(effective, name, defaults, additions, pathApi) {
145
+ if (has(effective, name) && !String(effective[name] ?? "").trim()) return;
146
+ const configured = has(effective, name) ? commaList(effective[name]) : defaults;
147
+ effective[name] = uniquePaths([...configured, ...additions], pathApi).join(",");
148
+ }
149
+
150
+ export async function discoverProviderArchives({ env = process.env, home = ccusageHome(env), pathApi = path } = {}) {
151
+ const homes = uniquePaths([home, ...commaList(env.USAGEMAX_DISCOVERED_HOMES), ...commaList(env.USAGEMAX_ADDITIONAL_HOME)], pathApi);
152
+ const archives = [];
153
+ for (const candidateHome of homes) {
154
+ const roots = [
155
+ { path: candidateHome, source: null },
156
+ { path: pathApi.join(candidateHome, ".claude", "backups"), source: "claude" },
157
+ { path: pathApi.join(candidateHome, ".codex", "backups"), source: "codex" },
158
+ ];
159
+ for (const root of roots) {
160
+ for (const entry of await entries(root.path)) {
161
+ if (!entry.isFile() || !ARCHIVE_EXTENSION.test(entry.name) || (!root.source && !ARCHIVE_FILE.test(entry.name))) continue;
162
+ const filePath = pathApi.join(root.path, entry.name);
163
+ try {
164
+ const metadata = await stat(filePath);
165
+ archives.push({ path: filePath, size: metadata.size, source: root.source || (/codex/i.test(entry.name) ? "codex" : "claude") });
166
+ } catch {}
167
+ }
168
+ }
169
+ }
170
+ return archives.filter((archive, index) => archives.findIndex((item) => item.path === archive.path) === index);
171
+ }
172
+
48
173
  export function ccusageHome(env = process.env, fallback = homedir()) {
49
174
  for (const value of [env.HOME, env.USERPROFILE]) {
50
175
  if (typeof value === "string" && value.trim()) return value;
@@ -156,13 +281,52 @@ export function sourceDefinitions({ env = process.env, home = ccusageHome(env),
156
281
 
157
282
  export async function ccusageEnvironment({ env = process.env, platform = process.platform, pathApi = path } = {}) {
158
283
  const effective = { ...env };
159
- if (platform !== "win32" || String(effective.GOOSE_PATH_ROOT ?? "").trim() || !String(effective.APPDATA ?? "").trim()) {
160
- return effective;
284
+ const home = ccusageHome(effective);
285
+ const configuredHomes = commaList(effective.USAGEMAX_ADDITIONAL_HOME).map((item) => expandTilde(item, home, pathApi));
286
+ const wslHomes = await discoverWslWindowsHomes(effective, platform, pathApi);
287
+ const additionalHomes = uniquePaths([...configuredHomes, ...wslHomes], pathApi).filter((item) => pathApi.normalize(item) !== pathApi.normalize(home));
288
+ const homes = [home, ...additionalHomes];
289
+ const backups = await Promise.all(homes.map((candidate) => discoverBackupRoots(candidate, pathApi)));
290
+ const nestedClaude = (await Promise.all(homes.map((candidate) => discoverNestedClaudeRoots(candidate, pathApi)))).flat();
291
+ const mirroredClaude = [];
292
+ for (const candidate of homes) {
293
+ const mirror = pathApi.join(candidate, ".cc-mirror", "mclaude", "config");
294
+ if (await existsDirectory(pathApi.join(mirror, "projects"))) mirroredClaude.push(mirror);
295
+ }
296
+
297
+ const xdgClaude = pathApi.join(has(effective, "XDG_CONFIG_HOME") ? String(effective.XDG_CONFIG_HOME) : pathApi.join(home, ".config"), "claude");
298
+ setDiscoveredList(effective, "CLAUDE_CONFIG_DIR", [xdgClaude, pathApi.join(home, ".claude")], [
299
+ ...additionalHomes.flatMap((candidate) => [pathApi.join(candidate, ".config", "claude"), pathApi.join(candidate, ".claude")]),
300
+ ...mirroredClaude,
301
+ ...backups.flatMap((item) => item.claude),
302
+ ...nestedClaude,
303
+ ], pathApi);
304
+ setDiscoveredList(effective, "CODEX_HOME", [pathApi.join(home, ".codex")], [
305
+ ...additionalHomes.map((candidate) => pathApi.join(candidate, ".codex")),
306
+ ...backups.flatMap((item) => item.codex),
307
+ ], pathApi);
308
+
309
+ const additions = (segments) => additionalHomes.map((candidate) => pathApi.join(candidate, ...segments));
310
+ setDiscoveredList(effective, "OPENCODE_DATA_DIR", [pathApi.join(home, ".local", "share", "opencode")], additions([".local", "share", "opencode"]), pathApi);
311
+ setDiscoveredList(effective, "AMP_DATA_DIR", [pathApi.join(home, ".local", "share", "amp")], additions([".local", "share", "amp"]), pathApi);
312
+ setDiscoveredList(effective, "DROID_SESSIONS_DIR", [pathApi.join(home, ".factory", "sessions")], additions([".factory", "sessions"]), pathApi);
313
+ setDiscoveredList(effective, "CODEBUFF_DATA_DIR", ["manicode", "manicode-dev", "manicode-staging"].map((channel) => pathApi.join(home, ".config", channel)), additionalHomes.flatMap((candidate) => ["manicode", "manicode-dev", "manicode-staging"].map((channel) => pathApi.join(candidate, ".config", channel))), pathApi);
314
+ setDiscoveredList(effective, "HERMES_HOME", [pathApi.join(home, ".hermes")], additions([".hermes"]), pathApi);
315
+ setDiscoveredList(effective, "PI_AGENT_DIR", [pathApi.join(home, ".pi", "agent", "sessions")], additions([".pi", "agent", "sessions"]), pathApi);
316
+ setDiscoveredList(effective, "OPENCLAW_DIR", [".openclaw", ".clawdbot", ".moltbot", ".moldbot"].map((name) => pathApi.join(home, name)), additionalHomes.flatMap((candidate) => [".openclaw", ".clawdbot", ".moltbot", ".moldbot"].map((name) => pathApi.join(candidate, name))), pathApi);
317
+ setDiscoveredList(effective, "KILO_DATA_DIR", [pathApi.join(home, ".local", "share", "kilo")], additions([".local", "share", "kilo"]), pathApi);
318
+ setDiscoveredList(effective, "KIMI_DATA_DIR", [pathApi.join(home, ".kimi"), pathApi.join(home, ".kimi-code")], additionalHomes.flatMap((candidate) => [pathApi.join(candidate, ".kimi"), pathApi.join(candidate, ".kimi-code")]), pathApi);
319
+ setDiscoveredList(effective, "QWEN_DATA_DIR", [pathApi.join(home, ".qwen")], additions([".qwen"]), pathApi);
320
+ setDiscoveredList(effective, "GEMINI_DATA_DIR", [pathApi.join(home, ".gemini", "tmp")], additions([".gemini", "tmp"]), pathApi);
321
+
322
+ effective.USAGEMAX_DISCOVERED_HOMES = homes.join(",");
323
+
324
+ if (platform === "win32" && !String(effective.GOOSE_PATH_ROOT ?? "").trim() && String(effective.APPDATA ?? "").trim()) {
325
+ const gooseRoot = pathApi.join(String(effective.APPDATA).trim(), "Block", "goose");
326
+ try {
327
+ if ((await stat(pathApi.join(gooseRoot, "data", "sessions", "sessions.db"))).isFile()) effective.GOOSE_PATH_ROOT = gooseRoot;
328
+ } catch {}
161
329
  }
162
- const gooseRoot = pathApi.join(String(effective.APPDATA).trim(), "Block", "goose");
163
- try {
164
- if ((await stat(pathApi.join(gooseRoot, "data", "sessions", "sessions.db"))).isFile()) effective.GOOSE_PATH_ROOT = gooseRoot;
165
- } catch {}
166
330
  return effective;
167
331
  }
168
332