usagemax 0.2.0 → 0.3.1

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,24 +78,45 @@ 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
+ Interrupted uploads resume with `bunx usagemax sync`. Server upload runs expire
82
+ after 30 idle days. If the server reports an expired run, use `sync --restart`:
83
+ this preserves the last committed checkpoint and performs a fresh full scan.
84
+ It never automatically replays an old authoritative deletion against newer data.
85
+
86
+ - The sync payload contains aggregate token counts,
87
+ model/provider names, costs, source names, dates, coverage state, and opaque
88
+ SHA-256 session identities.
64
89
  - It does not upload prompts, completions, source code, file contents, project
65
90
  paths, or provider credentials.
66
91
  - Sync is one-shot. There is no resident scanner or high-frequency polling loop.
67
- - A metadata inventory exits without parsing logs or using the network when
68
- nothing changed.
92
+ - A successful previous parse with a complete, unchanged metadata inventory skips
93
+ parsing and uploading again that day. Inventory stability is independent of
94
+ deletion authority; a no-change result retains the previous partial coverage
95
+ label. New sources, date rollover, explicit full/archive requests and weekly
96
+ reconciliation still trigger the appropriate scan.
69
97
  - Normal changed syncs parse today or today plus yesterday. A bounded weekly
70
98
  full reconciliation catches restored files, parser changes, and older logs.
71
- - Full history means all retained local history from 2024 onward. Deleted or
72
- never-persisted usage requires a provider export; no local tool can reconstruct it.
99
+ - Full history scans retained local history from 2024 onward. Inventory success
100
+ does not prove every file parsed. Until the parser certifies source/day coverage,
101
+ uploads are marked partial and any row with a decreasing counter retains its
102
+ entire previous vector. This includes explicit zeros and missing sources.
103
+ Older checkpoints survive incremental windows. Authoritative corrections remain
104
+ supported by the planner but are not claimed by this parser integration.
105
+ Deleted or never-persisted usage requires a provider export.
73
106
  - Source totals that cannot be assigned to a model are retained as
74
107
  `unattributed` rather than silently discarded.
75
108
  - The collector key is written with user-only permissions where the operating
76
109
  system supports them.
110
+ - The package contains no shared service credential or private deployment URL.
111
+ It talks to the versioned `https://usagemax.com/api` contract. The random
112
+ per-installation write token is the only local secret; the service stores only
113
+ its hash and applies device binding, replay checks, payload caps, and quotas.
77
114
  - A private random installation ID survives collector rotation, relinking, and
78
115
  display-name changes. It is not a hardware fingerprint; the server stores only
79
- its SHA-256 hash. Concurrent and repeated syncs are idempotent.
116
+ its SHA-256 hash. A local config lock prevents overlapping commands from
117
+ overwriting pending runs; server receipts make repeated uploads idempotent.
118
+ - The server, not the local checkpoint, owns the accounting baseline. A lost
119
+ response or interrupted run can be retried without adding the same partition twice.
80
120
  - Do not point two different installations at the same copied or network-mounted
81
121
  log tree. Cross-installation copied-history deduplication is inherently
82
122
  ambiguous and intentionally not guessed.
@@ -84,5 +124,36 @@ connector. UsageMax never invents usage that the source did not retain.
84
124
  Use `USAGEMAX_CONFIG_DIR` to select another config directory. Development and
85
125
  self-hosted installations may set `USAGEMAX_LINK_ENDPOINT` before linking.
86
126
 
127
+ ## Interrupted uploads and protocol 0.3.1
128
+
129
+ Before uploading, the CLI saves the exact run, ordered request payloads and next
130
+ checkpoint in the private config. `sync` resumes this journal before scanning new
131
+ data. Local snapshots advance only after completion is acknowledged. A resumed
132
+ command finishes the saved scan; run `sync` again to collect subsequent changes.
133
+ `sync --dry-run` reports pending work without uploading. Do not delete config.json
134
+ to retry a failed upload. A successful relink or unlink replaces/removes the local
135
+ journal along with its credential.
136
+
137
+ Network failures, malformed success responses, HTTP 429 and 5xx get at most five
138
+ attempts per request with exponential jitter. Retry-After seconds and HTTP dates
139
+ are honored up to 60 seconds; longer waits stop with an instruction to retry later.
140
+ Only completion's `snapshot_run_incomplete` HTTP 409 is retried, because server
141
+ cleanup can still be pending. Authentication, validation and other conflicts fail
142
+ with an actionable message and retain the journal. JSON `accepted` is null for
143
+ uploads because lost responses/replays cannot reliably reconstruct that count;
144
+ `changedRows` is the local planned count, not a server accounting receipt.
145
+
146
+ Each sorted source/day is sent as ordered chunks of at most 100 rows. Each has a
147
+ unique partitionId, zero-based chunkIndex and shared chunkCount. The payload hash
148
+ covers `{source, day, complete, pricingVersion, chunkIndex, chunkCount, rows}` in
149
+ that order. `partitionCount` counts transmitted chunks. The server must accept
150
+ this protocol, receipt replays, and finish omitted-row cleanup before completing
151
+ an authoritative run.
152
+
153
+ An abrupt process kill can leave `collector.lock` in the config directory. The
154
+ next command reports the owner PID and exact path. Confirm that process has exited
155
+ before removing only that lock file, then rerun sync. Never remove an active lock
156
+ or the saved config to recover. Normal completion and handled failures release it.
157
+
87
158
  See the [collector coverage audit](../../docs/collector-coverage-audit.md) for
88
159
  the full support matrix and known boundaries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usagemax",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Link local coding-agent usage to your UsageMax profile",
5
5
  "license": "MIT",
6
6
  "author": "UsageMax",
@@ -18,10 +18,13 @@
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",
24
25
  "src/sources.js",
26
+ "src/transport.js",
27
+ "src/resume.js",
25
28
  "README.md",
26
29
  "LICENSE"
27
30
  ],
@@ -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,17 +9,20 @@ 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, scanPolicy, 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 { requestSnapshot } from "./transport.js";
16
+ import { resumeUpload, restartExpiredUpload, withConfigLock } from "./resume.js";
17
+ import { CCUSAGE_VERSION, ccusageEnvironment, ccusageHome, discoverProviderArchives, SOURCE_INVENTORY_VERSION, sourceInventory, SUPPORTED_SOURCES } from "./sources.js";
15
18
 
16
19
  const require = createRequire(import.meta.url);
17
20
  const executeFile = promisify(execFile);
18
- const VERSION = "0.2.0";
19
- const DEFAULT_LINK_ENDPOINT = "https://terrific-bobcat-522.convex.site/v1/devices/link";
21
+ const VERSION = "0.3.1";
22
+ const PUBLIC_API_ORIGIN = "https://usagemax.com/api";
23
+ const DEFAULT_LINK_ENDPOINT = `${PUBLIC_API_ORIGIN}/v1/devices/link`;
20
24
  const CONFIG_FILE = "config.json";
21
25
  const MAX_REPORT_BYTES = 100 * 1024 * 1024;
22
- const FULL_RECONCILE_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
23
26
 
24
27
  function configDirectory() {
25
28
  if (process.env.USAGEMAX_CONFIG_DIR) return process.env.USAGEMAX_CONFIG_DIR;
@@ -31,6 +34,18 @@ function configPath() {
31
34
  return join(configDirectory(), CONFIG_FILE);
32
35
  }
33
36
 
37
+ function isLegacyDirectApi(config) {
38
+ try {
39
+ const ingest = new URL(config.ingestUrl);
40
+ const profile = new URL(config.profileUrl || "https://usagemax.com");
41
+ return profile.hostname === "usagemax.com"
42
+ && ingest.hostname.endsWith(".convex.site")
43
+ && ingest.pathname === "/v1/telemetry/llm";
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
34
49
  async function readConfig() {
35
50
  try {
36
51
  const parsed = JSON.parse(await readFile(configPath(), "utf8"));
@@ -38,6 +53,16 @@ async function readConfig() {
38
53
  if (!validHttpsUrl(parsed.ingestUrl, { allowLocalhost: true })) return null;
39
54
  if (typeof parsed.deviceId !== "string" || !parsed.deviceId) return null;
40
55
  parsed.snapshots = parsed.snapshots && typeof parsed.snapshots === "object" ? parsed.snapshots : {};
56
+ if (isLegacyDirectApi(parsed)) {
57
+ parsed.ingestUrl = `${PUBLIC_API_ORIGIN}/v1/telemetry/llm`;
58
+ parsed.snapshotUrl = `${PUBLIC_API_ORIGIN}/v2/usage/snapshots`;
59
+ parsed.revokeUrl = `${PUBLIC_API_ORIGIN}/v1/devices/revoke`;
60
+ await writeConfig(parsed);
61
+ }
62
+ parsed.snapshotUrl = validHttpsUrl(parsed.snapshotUrl, { allowLocalhost: true })
63
+ || parsed.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v2/usage/snapshots");
64
+ parsed.revokeUrl = validHttpsUrl(parsed.revokeUrl, { allowLocalhost: true })
65
+ || parsed.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/revoke");
41
66
  return parsed;
42
67
  } catch {
43
68
  return null;
@@ -79,19 +104,22 @@ function help() {
79
104
  process.stdout.write(" usagemax Sync changed local usage\n");
80
105
  process.stdout.write(" usagemax link <one-use-code> Link and sync this computer\n");
81
106
  process.stdout.write(" [--no-sync] [--name <name>]\n");
82
- process.stdout.write(" usagemax sync [--full] [--json] Sync usage once, then exit\n");
107
+ process.stdout.write(" usagemax sync [--full] [--archives] [--restart] [--dry-run] [--explain] [--json]\n");
108
+ process.stdout.write(" Reconcile once; --archives performs one-time recovery\n");
83
109
  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");
110
+ process.stdout.write(" usagemax doctor [--deep] [--json]\n");
111
+ process.stdout.write(" Check source coverage; --deep parses full history\n");
85
112
  process.stdout.write(" usagemax report [...args] Run a local ccusage report\n");
86
- process.stdout.write(" usagemax unlink Remove the local collector key\n");
113
+ process.stdout.write(" usagemax unlink [--revoke] Remove locally; --revoke also disables uploads\n");
87
114
  }
88
115
 
89
116
  function ccusageCliPath() {
90
117
  return join(dirname(require.resolve("ccusage/package.json")), "src", "cli.js");
91
118
  }
92
119
 
93
- async function ccusageJson(config, { full = false } = {}) {
120
+ async function ccusageJson(config, { full = false, env } = {}) {
94
121
  const args = [ccusageCliPath(), "daily", "--json", "--offline", "--mode", "calculate", "--timezone", "UTC", "--by-agent", "--order", "asc"];
122
+ args.push("--sections", "daily,session");
95
123
  // Reconcile yesterday once after the UTC date changes. All other incremental
96
124
  // scans parse only today; a metadata fingerprint avoids invoking ccusage when
97
125
  // no supported local source changed at all.
@@ -104,11 +132,33 @@ async function ccusageJson(config, { full = false } = {}) {
104
132
  const { stdout } = await executeFile(process.execPath, args, {
105
133
  encoding: "utf8",
106
134
  maxBuffer: MAX_REPORT_BYTES,
107
- env: { ...await ccusageEnvironment(), NO_COLOR: "1" },
135
+ env: { ...(env || await ccusageEnvironment()), NO_COLOR: "1" },
108
136
  });
109
137
  return JSON.parse(stdout);
110
138
  }
111
139
 
140
+ function snapshotEndpoint(config) {
141
+ return validHttpsUrl(config.snapshotUrl, { allowLocalhost: true })
142
+ || config.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v2/usage/snapshots");
143
+ }
144
+
145
+ async function snapshotRequest(config, operation, payload, timeout = 30_000) {
146
+ return requestSnapshot(snapshotEndpoint(config), config, operation, payload, { timeout });
147
+ }
148
+
149
+ function newerVersion(recommended) {
150
+ if (!/^\d+\.\d+\.\d+$/.test(recommended || "")) return false;
151
+ const current = VERSION.split(".").map(Number);
152
+ const next = recommended.split(".").map(Number);
153
+ return next.some((value, index) => value > current[index] && next.slice(0, index).every((prior, priorIndex) => prior === current[priorIndex]));
154
+ }
155
+
156
+ function warnVersion(body) {
157
+ if (newerVersion(body?.recommendedCliVersion)) {
158
+ process.stderr.write(`UsageMax ${body.recommendedCliVersion} is available. Run \`bunx usagemax@latest\` for current coverage fixes.\n`);
159
+ }
160
+ }
161
+
112
162
  async function link(args) {
113
163
  const code = normalizeLinkCode(args[0]);
114
164
  if (!code) throw new Error("Paste the one-use UMX link code shown at usagemax.com/account.");
@@ -129,13 +179,17 @@ async function link(args) {
129
179
  const body = await response.json().catch(() => ({}));
130
180
  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
181
  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.");
182
+ const snapshotUrl = validHttpsUrl(body.snapshotUrl, { allowLocalhost: true }) || ingestUrl?.replace(/\/v1\/telemetry\/llm$/, "/v2/usage/snapshots");
183
+ const revokeUrl = validHttpsUrl(body.revokeUrl, { allowLocalhost: true }) || ingestUrl?.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/revoke");
184
+ if (!/^umx_[a-f0-9]{64}$/.test(body.token || "") || !ingestUrl || !snapshotUrl || !revokeUrl) throw new Error("UsageMax returned an invalid link response.");
133
185
  const profileHandle = typeof body.profileHandle === "string" ? body.profileHandle : undefined;
134
186
  const sameAccount = Boolean(previous && previous.profileHandle && previous.profileHandle === profileHandle);
135
187
  const config = {
136
188
  version: 1,
137
189
  token: body.token,
138
190
  ingestUrl,
191
+ snapshotUrl,
192
+ revokeUrl,
139
193
  profileUrl: validHttpsUrl(body.profileUrl) || "https://usagemax.com/account",
140
194
  profileHandle,
141
195
  deviceId,
@@ -144,6 +198,7 @@ async function link(args) {
144
198
  snapshots: sameAccount ? previous.snapshots : {},
145
199
  };
146
200
  await writeConfig(config);
201
+ warnVersion(body);
147
202
  process.stdout.write(`Linked ${name} to ${config.profileHandle ? `@${config.profileHandle}` : "UsageMax"}.\n`);
148
203
  if (args.includes("--no-sync")) {
149
204
  process.stdout.write("No usage was uploaded. Run `bunx usagemax sync --full` when you are ready.\n");
@@ -154,67 +209,145 @@ async function link(args) {
154
209
  }
155
210
 
156
211
  async function sync(args, suppliedConfig) {
212
+ const baseEnv = await ccusageEnvironment();
213
+ const recovery = args.includes("--archives")
214
+ ? await prepareArchiveRecovery(baseEnv)
215
+ : { archives: 0, cleanup: async () => undefined, env: baseEnv, unsupported: 0 };
216
+ try {
217
+ return await syncPrepared(args, suppliedConfig, recovery);
218
+ } finally {
219
+ await recovery.cleanup();
220
+ }
221
+ }
222
+
223
+ async function syncPrepared(args, suppliedConfig, recovery) {
157
224
  const config = suppliedConfig || await readConfig();
158
225
  if (!config) throw new Error("This computer is not linked. Open https://usagemax.com/account and create a link code.");
159
226
  config.deviceId = await stableInstallationId(configDirectory(), config.deviceId);
160
227
  const requestedFull = args.includes("--full");
161
- const inventory = await sourceInventory();
228
+ const requestedArchives = args.includes("--archives");
229
+ const dryRun = args.includes("--dry-run");
230
+ const explain = args.includes("--explain");
231
+ const json = args.includes("--json");
232
+ if (args.includes("--restart") && !dryRun) {
233
+ restartExpiredUpload(config);
234
+ await writeConfig(config);
235
+ }
236
+ if (config.pendingSync) {
237
+ if (dryRun) {
238
+ const result = { ...config.pendingSync.result, dryRun: true, pendingRunId: config.pendingSync.runId };
239
+ process.stdout.write(json ? `${JSON.stringify(result)}\n` : `Dry run: saved run ${config.pendingSync.runId} awaits resume; no upload.\n`);
240
+ return result;
241
+ }
242
+ const result = await resumeUpload(config, { save: writeConfig, request: snapshotRequest, warn: warnVersion });
243
+ process.stdout.write(json ? `${JSON.stringify(result)}\n` : "Resumed and completed the saved sync. Run sync again to scan newer local changes.\n");
244
+ return result;
245
+ }
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
- const foundNewSource = inventory.sources.some((source) => !knownSources.includes(source));
165
- const lastFullSync = Date.parse(config.lastFullSyncAt || "");
166
- const fullDue = config.sourceInventoryVersion !== SOURCE_INVENTORY_VERSION
167
- || !Number.isFinite(lastFullSync)
168
- || Date.now() - lastFullSync >= FULL_RECONCILE_INTERVAL_MS
169
- || foundNewSource;
170
- const full = requestedFull || fullDue;
171
- 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`);
249
+ const { bootstrap, full, skip, inventoryStable } = scanPolicy(config, inventory, {
250
+ today, now: Date.now(), inventoryVersion: SOURCE_INVENTORY_VERSION, requestedFull, requestedArchives,
251
+ });
252
+ if (skip) {
253
+ const result = { accepted: 0, changedRows: 0, sessions: 0, sources: inventory.sources, corrections: 0, scanned: false, full: false, coverage: config.lastCoverage || "partial" };
254
+ if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
174
255
  else process.stdout.write("Already up to date. Local usage files have not changed; no logs were parsed or uploaded.\n");
175
256
  return;
176
257
  }
177
- const report = await ccusageJson(config, { full });
178
- const { plan, regressions } = buildDeltaPlan(report, config.snapshots, config.deviceId, `ccusage@${CCUSAGE_VERSION}`);
179
- config.lastSyncComplete = false;
180
- 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),
195
- });
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;
200
- config.lastSyncAt = new Date().toISOString();
201
- await writeConfig(config);
258
+ const report = await ccusageJson(config, { env: recovery.env, full });
259
+ // ccusage v20 exposes aggregates, not proof that every discovered file was
260
+ // parsed. Inventory success alone cannot authorize destructive corrections.
261
+ const authoritative = false;
262
+ const legacySnapshotBootstrap = bootstrap
263
+ && Object.keys(config.snapshots || {}).length > 0;
264
+ const runId = randomUUID();
265
+ const revision = Date.now();
266
+ const pricingVersion = `ccusage@${CCUSAGE_VERSION}`;
267
+ const { partitions, nextSnapshots, regressions } = buildSnapshotPlan(report, config.snapshots, {
268
+ bootstrap,
269
+ complete: authoritative,
270
+ full,
271
+ pricingVersion,
272
+ revision,
273
+ runId,
274
+ });
275
+ const sessions = buildSessionPlan(report, config.deviceId);
276
+ const sources = sourceSummary(report);
277
+ const days = Array.isArray(report?.daily)
278
+ ? report.daily.map((row) => row?.period).filter((day) => /^\d{4}-\d{2}-\d{2}$/.test(day || "")).sort()
279
+ : [];
280
+ const result = {
281
+ accepted: null,
282
+ changedRows: partitions.reduce((sum, partition) => sum + partition.rows.filter((row) => JSON.stringify(row.previous) !== JSON.stringify(row.current)).length, 0),
283
+ sessions: sessions.length,
284
+ sources,
285
+ corrections: authoritative ? regressions.length : 0,
286
+ protectedRegressions: authoritative ? 0 : regressions.length,
287
+ partitions: partitions.length,
288
+ scanned: true,
289
+ full,
290
+ coverage: authoritative ? "complete" : "partial",
291
+ coverageReason: "Parser does not certify complete source/day coverage; decreases and deletions are protected.",
292
+ range: { from: days[0], to: days.at(-1) },
293
+ };
294
+ if (requestedArchives) {
295
+ result.archives = recovery.archives;
296
+ result.unsupportedArchives = recovery.unsupported;
202
297
  }
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;
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} protected regression(s). ${result.coverageReason}\n`);
303
+ }
304
+ return result;
305
+ }
306
+ const requests = [{ operation: "begin", payload: {
307
+ runId,
308
+ mode: requestedArchives ? "archives" : full ? "full" : "incremental",
309
+ baselineMode: legacySnapshotBootstrap ? "adopt-current" : "apply",
310
+ sourceCount: sources.length,
311
+ partitionCount: partitions.length,
312
+ inventoryComplete: authoritative,
313
+ inventoryErrors: inventory.errors,
314
+ inventoryTruncated: inventory.truncated,
315
+ coverageStartDay: days[0],
316
+ coverageEndDay: days.at(-1),
317
+ } }];
318
+ for (let offset = 0; offset < sessions.length; offset += 100) {
319
+ requests.push({ operation: "sessions", payload: { runId, sessions: sessions.slice(offset, offset + 100) } });
320
+ }
321
+ for (let offset = 0; offset < partitions.length; offset += 10) {
322
+ requests.push({ operation: "partitions", payload: { runId, partitions: partitions.slice(offset, offset + 10) } });
323
+ }
324
+ requests.push({ operation: "complete", payload: { runId } });
325
+ const syncedAt = new Date().toISOString();
326
+ config.pendingSync = {
327
+ version: 1, runId, cursor: 0, requests, result,
328
+ checkpoint: {
329
+ snapshots: nextSnapshots, snapshotProtocolVersion: 2,
330
+ lastSyncAt: syncedAt, lastReconciledDay: today, lastSyncComplete: authoritative,
331
+ lastScanSucceeded: true, lastCoverage: result.coverage,
332
+ sourceInventoryVersion: SOURCE_INVENTORY_VERSION,
333
+ knownSources: [...new Set([...knownSources, ...inventory.sources, ...sources])].sort(),
334
+ sourceFingerprint: inventoryStable ? inventory.fingerprint : null,
335
+ ...(full ? { lastFullSyncAt: syncedAt } : {}),
336
+ },
337
+ };
338
+ config.lastSyncComplete = false;
211
339
  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`);
340
+ await resumeUpload(config, { save: writeConfig, request: snapshotRequest, warn: warnVersion });
341
+ if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
214
342
  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`);
343
+ process.stdout.write(partitions.length || sessions.length
344
+ ? `Completed ${partitions.length} usage chunk(s) and ${sessions.length} session identifier(s) from ${sources.join(", ") || "local agents"}${full ? " across retained history" : ""}.\n`
345
+ : `Already up to date. No usage rows changed${full ? " after a full-history reconciliation" : ""}.\n`);
346
+ if (regressions.length) process.stdout.write(`${regressions.length} local row(s) moved backward; prior counter dimensions were preserved because coverage is incomplete.\n`);
347
+ if (!authoritative) process.stdout.write(`${result.coverageReason}\n`);
348
+ 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
349
  }
350
+ return result;
218
351
  }
219
352
 
220
353
  async function status() {
@@ -231,23 +364,48 @@ async function status() {
231
364
  process.stdout.write(`Linked: ${config.deviceName || deviceLabel()}${config.profileHandle ? ` → @${config.profileHandle}` : ""}\n`);
232
365
  process.stdout.write(`Last sync: ${config.lastSyncAt || "never"}\n`);
233
366
  process.stdout.write(`Last full reconciliation: ${config.lastFullSyncAt || "never"}\n`);
367
+ if (config.pendingSync) process.stdout.write(`Pending sync: ${config.pendingSync.runId}; rerun sync to resume\n`);
234
368
  process.stdout.write(`Profile: ${config.profileUrl || "https://usagemax.com/account"}\n`);
235
369
  }
236
370
 
237
371
  async function doctor(args = []) {
238
372
  const config = await readConfig();
239
- const inventory = await sourceInventory();
373
+ const env = await ccusageEnvironment();
374
+ const inventory = await sourceInventory({ env, home: ccusageHome(env) });
375
+ const archives = await discoverProviderArchives({ env, home: ccusageHome(env) });
376
+ const homes = String(env.USAGEMAX_DISCOVERED_HOMES || ccusageHome(env)).split(",").filter(Boolean);
377
+ const result = {
378
+ linked: Boolean(config),
379
+ homes,
380
+ detectedSources: inventory.sources,
381
+ files: inventory.files,
382
+ inventoryComplete: inventory.complete,
383
+ inventoryErrors: inventory.errors,
384
+ inventoryTruncated: inventory.truncated,
385
+ supportedSources: SUPPORTED_SOURCES,
386
+ archives: archives.length,
387
+ environment: platform() === "linux" && process.env.WSL_DISTRO_NAME ? `WSL ${process.env.WSL_DISTRO_NAME}` : platform(),
388
+ mode: args.includes("--deep") ? "deep" : "metadata-only",
389
+ };
390
+ if (args.includes("--deep")) {
391
+ const report = await ccusageJson(config, { env, full: true });
392
+ result.parsedSources = sourceSummary(report);
393
+ result.sessions = buildSessionPlan(report, config?.deviceId || "unlinked").length;
394
+ }
395
+ if (args.includes("--json")) {
396
+ process.stdout.write(`${JSON.stringify(result)}\n`);
397
+ return;
398
+ }
240
399
  process.stdout.write(`Collector: ${config ? "linked" : "not linked"}\n`);
400
+ process.stdout.write(`Discovered homes: ${homes.length} (${homes.join(", ")})\n`);
241
401
  process.stdout.write(`Detected sources: ${inventory.sources.join(", ") || "none"} (${inventory.files}${inventory.truncated ? "+" : ""} data files)\n`);
242
402
  process.stdout.write(`Supported sources: ${SUPPORTED_SOURCES.join(", ")} (+ named pi-format stores)\n`);
243
403
  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`);
404
+ process.stdout.write(`Environment: WSL ${process.env.WSL_DISTRO_NAME}; readable Windows provider homes are included automatically\n`);
245
405
  }
406
+ 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
407
  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
- }
408
+ if (result.parsedSources) process.stdout.write(`Parsed sources: ${result.parsedSources.join(", ") || "none"}; ${result.sessions} private session identifiers\n`);
251
409
  process.stdout.write(`Mode: one-shot, metadata no-op check, ${args.includes("--deep") ? "deep local parse" : "no log parsing"}\n`);
252
410
  }
253
411
 
@@ -264,15 +422,28 @@ async function report(args) {
264
422
  if (code !== 0) process.exitCode = code;
265
423
  }
266
424
 
267
- async function removeLink() {
425
+ async function removeLink(args = []) {
268
426
  const path = configPath();
269
427
  const config = await readConfig();
270
428
  if (!config) {
271
429
  process.stdout.write("This computer is not linked.\n");
272
430
  return;
273
431
  }
432
+ if (args.includes("--revoke")) {
433
+ const endpoint = validHttpsUrl(config.revokeUrl, { allowLocalhost: true });
434
+ if (!endpoint) throw new Error("This collector does not have a valid revoke endpoint. Revoke it at https://usagemax.com/account.");
435
+ const response = await fetch(endpoint, {
436
+ method: "POST",
437
+ headers: { authorization: `Bearer ${config.token}`, "content-type": "application/json" },
438
+ body: JSON.stringify({ deviceId: config.deviceId }),
439
+ signal: AbortSignal.timeout(15_000),
440
+ });
441
+ if (!response.ok) throw new Error("UsageMax could not revoke this collector. It remains linked locally.");
442
+ }
274
443
  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");
444
+ process.stdout.write(args.includes("--revoke")
445
+ ? "Revoked this collector and removed its local key. Existing usage totals were retained.\n"
446
+ : "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
447
  }
277
448
 
278
449
  async function main() {
@@ -280,12 +451,16 @@ async function main() {
280
451
  const command = args[0] || "sync";
281
452
  if (["--help", "-h", "help"].includes(command)) return help();
282
453
  if (["--version", "-v"].includes(command)) return process.stdout.write(`${VERSION}\n`);
283
- if (command === "link") return link(args.slice(1));
284
- if (command === "sync") return sync(args.slice(1));
285
- if (command === "status") return status();
286
- if (command === "doctor") return doctor(args.slice(1));
287
454
  if (command === "report") return report(args.slice(1));
288
- if (command === "unlink") return removeLink();
455
+ if (["link", "sync", "status", "doctor", "unlink"].includes(command)) {
456
+ return withConfigLock(configDirectory(), async () => {
457
+ if (command === "link") return link(args.slice(1));
458
+ if (command === "sync") return sync(args.slice(1));
459
+ if (command === "status") return status();
460
+ if (command === "doctor") return doctor(args.slice(1));
461
+ return removeLink(args.slice(1));
462
+ });
463
+ }
289
464
  throw new Error(`Unknown command: ${command}. Run usagemax --help.`);
290
465
  }
291
466
 
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
  }
@@ -186,7 +262,7 @@ export function buildDeltaPlan(report, priorSnapshots, deviceId, pricingVersion
186
262
  pricingVersion,
187
263
  status: "ok",
188
264
  state: "synced",
189
- occurredAt: `${row.period}T12:00:00.000Z`,
265
+ occurredAt: `${row.period}T00:00:00.000Z`,
190
266
  completeness: "estimated",
191
267
  },
192
268
  });
@@ -195,6 +271,149 @@ 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
+ // Carry forward history outside this scan's window. Only reconciled keys may
304
+ // replace or remove a checkpoint, including legacy three-part identities.
305
+ const nextSnapshots = Object.fromEntries([...priorRows].map(([key, row]) => [key, row.current]));
306
+ const regressions = [];
307
+ for (const key of allKeys) {
308
+ const currentRow = rows.get(key);
309
+ const priorRow = priorRows.get(key);
310
+ const identity = currentRow || priorRow;
311
+ if (!identity) continue;
312
+ const previous = priorRow?.current ?? snapshotCounters();
313
+ let current = currentRow?.current ?? snapshotCounters();
314
+ const regressed = snapshotCounterFields.some((field) => current[field] < previous[field]);
315
+ if (regressed) regressions.push(key);
316
+ // A larger total can conceal a missing dimension. Keep the coherent prior
317
+ // vector (including in the checkpoint) until coverage is authoritative.
318
+ if (!complete && regressed) current = { ...previous };
319
+ if (!sameCounters(current, snapshotCounters())) nextSnapshots[key] = current;
320
+ else delete nextSnapshots[key];
321
+ const partitionKey = `${identity.source}\u001f${identity.period}`;
322
+ const rowsForPartition = grouped.get(partitionKey) ?? [];
323
+ rowsForPartition.push({
324
+ snapshotKey: key,
325
+ provider: identity.provider,
326
+ model: identity.model,
327
+ previous,
328
+ current,
329
+ costBasis: "estimated",
330
+ contentHash: sha256(`${key}\u001f${JSON.stringify(current)}`),
331
+ // A daily aggregate has no exact event time. Use the start of its UTC day
332
+ // so today's partition is valid even when the collector runs before noon.
333
+ lastUsedAt: Date.parse(`${identity.period}T00:00:00.000Z`),
334
+ });
335
+ grouped.set(partitionKey, rowsForPartition);
336
+ }
337
+
338
+ const partitions = [];
339
+ for (const [partitionKey, partitionRows] of grouped) {
340
+ const [source, day] = partitionKey.split("\u001f");
341
+ const changed = partitionRows.some((row) => !sameCounters(row.previous, row.current));
342
+ if (!bootstrap && !full && !changed) continue;
343
+ partitionRows.sort((left, right) => `${left.provider}/${left.model}`.localeCompare(`${right.provider}/${right.model}`));
344
+ const wireRows = partitionRows.map(({ provider, model, previous, current, costBasis, contentHash, lastUsedAt }) => ({
345
+ provider,
346
+ model,
347
+ previous,
348
+ current,
349
+ costBasis,
350
+ contentHash,
351
+ lastUsedAt,
352
+ }));
353
+ const chunkCount = Math.ceil(wireRows.length / 100);
354
+ for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex += 1) {
355
+ const rows = wireRows.slice(chunkIndex * 100, (chunkIndex + 1) * 100);
356
+ const payloadHash = sha256(JSON.stringify({ source, day, complete, pricingVersion, chunkIndex, chunkCount, rows }));
357
+ partitions.push({
358
+ partitionId: `${runId}:${sha256(partitionKey).slice(0, 24)}:${chunkIndex}`,
359
+ payloadHash,
360
+ revision,
361
+ source,
362
+ day,
363
+ complete,
364
+ pricingVersion,
365
+ chunkIndex,
366
+ chunkCount,
367
+ rows,
368
+ });
369
+ }
370
+ }
371
+ partitions.sort((left, right) => left.day === right.day ? left.source.localeCompare(right.source) : left.day.localeCompare(right.day));
372
+ return { partitions, nextSnapshots, regressions };
373
+ }
374
+
375
+ function randomPlanId() {
376
+ return `run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
377
+ }
378
+
379
+ // Inventory stability certifies that repeating a successful parse is unnecessary;
380
+ // it does not certify authority to remove server history. Bootstrap is separate.
381
+ export function scanPolicy(config, inventory, { today, now, inventoryVersion, requestedFull = false, requestedArchives = false }) {
382
+ const knownSources = Array.isArray(config.knownSources) ? config.knownSources : [];
383
+ const lastFull = Date.parse(config.lastFullSyncAt || "");
384
+ const bootstrap = config.snapshotProtocolVersion !== 2;
385
+ const full = requestedFull || requestedArchives || bootstrap
386
+ || config.sourceInventoryVersion !== inventoryVersion
387
+ || !Number.isFinite(lastFull) || now - lastFull >= 7 * 24 * 60 * 60 * 1000
388
+ || inventory.sources.some((source) => !knownSources.includes(source));
389
+ const inventoryStable = inventory.complete && !inventory.truncated && inventory.errors === 0;
390
+ const skip = !config.pendingSync && !full && inventoryStable && config.lastScanSucceeded === true
391
+ && config.lastReconciledDay === today && config.sourceFingerprint === inventory.fingerprint;
392
+ return { bootstrap, full, skip, inventoryStable };
393
+ }
394
+
395
+ export function buildSessionPlan(report, deviceId) {
396
+ const sessionRows = Array.isArray(report?.session) ? report.session : [];
397
+ const sessions = new Map();
398
+ for (const row of sessionRows) {
399
+ if (!row || typeof row !== "object") continue;
400
+ const source = text(row.agent, "unknown", 60).toLowerCase();
401
+ const stableIdentity = text(row.period ?? row.sessionId ?? row.id, "", 1000);
402
+ if (!stableIdentity) continue;
403
+ const metadata = row.metadata && typeof row.metadata === "object" ? row.metadata : {};
404
+ const firstActivityAt = Date.parse(metadata.firstActivity ?? metadata.createdAt ?? "");
405
+ const lastActivityAt = Date.parse(metadata.lastActivity ?? row.lastActivity ?? "");
406
+ const sessionKey = sha256(`${deviceId}\u001f${source}\u001f${stableIdentity}`);
407
+ sessions.set(`${source}\u001f${sessionKey}`, {
408
+ source,
409
+ sessionKey,
410
+ ...(Number.isFinite(firstActivityAt) ? { firstActivityAt } : {}),
411
+ ...(Number.isFinite(lastActivityAt) ? { lastActivityAt } : {}),
412
+ });
413
+ }
414
+ return [...sessions.values()].sort((left, right) => `${left.source}/${left.sessionKey}`.localeCompare(`${right.source}/${right.sessionKey}`));
415
+ }
416
+
198
417
  export function batchId(deviceId, events) {
199
418
  const identity = events.map((event) => event.eventKey).join("\u001f");
200
419
  return `cli:${deviceId}:${sha256(identity).slice(0, 32)}`;
package/src/resume.js ADDED
@@ -0,0 +1,58 @@
1
+ // A pending run is saved before network I/O. Persisting the acknowledged cursor
2
+ // after each request permits replays when the response or local write is lost.
3
+ // The server must receipt begin, chunks, sessions and complete idempotently.
4
+ export async function resumeUpload(config, { save, request, warn = () => {} }) {
5
+ const pending = config.pendingSync;
6
+ if (!pending || pending.version !== 1 || !Array.isArray(pending.requests)) {
7
+ throw new Error("Invalid saved sync; preserve the config for recovery.");
8
+ }
9
+ try {
10
+ for (let index = pending.cursor; index < pending.requests.length; index += 1) {
11
+ const { operation, payload } = pending.requests[index];
12
+ const response = await request(config, operation, payload, operation === "partitions" ? 60_000 : 30_000);
13
+ warn(response);
14
+ pending.cursor = index + 1;
15
+ await save(config);
16
+ }
17
+ // Commit local baseline and remove the journal in the same atomic write.
18
+ const next = { ...config, ...pending.checkpoint };
19
+ delete next.pendingSync;
20
+ await save(next);
21
+ Object.assign(config, next);
22
+ delete config.pendingSync;
23
+ return { ...pending.result, resumed: true };
24
+ } catch (error) {
25
+ if (error?.code === "snapshot_run_expired") {
26
+ pending.terminalError = "snapshot_run_expired";
27
+ await save(config);
28
+ throw new Error("Saved upload expired. Run usagemax sync --restart to rescan retained history while preserving the last committed checkpoint. Already accepted usage remains on the server.", { cause: error });
29
+ }
30
+ throw new Error(`${error instanceof Error ? error.message : "Upload failed."} Run usagemax sync again to resume saved run ${pending.runId}; its payload and checkpoints have been retained.`, { cause: error });
31
+ }
32
+ }
33
+
34
+ export function restartExpiredUpload(config) {
35
+ if (config.pendingSync?.terminalError !== "snapshot_run_expired") throw new Error("Only an expired upload can restart. Run sync normally to resume or verify its status first.");
36
+ delete config.pendingSync;
37
+ config.lastFullSyncAt = undefined;
38
+ config.sourceFingerprint = null;
39
+ }
40
+ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
41
+ import { join } from "node:path";
42
+
43
+ export async function withConfigLock(directory, action) {
44
+ await mkdir(directory, { recursive: true, mode: 0o700 });
45
+ const path = join(directory, "collector.lock");
46
+ try {
47
+ await writeFile(path, `${process.pid}\n`, { flag: "wx", mode: 0o600 });
48
+ } catch (error) {
49
+ if (error.code !== "EEXIST") throw error;
50
+ const owner = (await readFile(path, "utf8").catch(() => "unknown")).trim();
51
+ throw new Error(`Collector config is locked by PID ${/^\d+$/.test(owner) ? owner : "unknown"}. If that process has exited, remove only ${path} and rerun sync; keep config.json for resume.`);
52
+ }
53
+ try {
54
+ return await action();
55
+ } finally {
56
+ await unlink(path);
57
+ }
58
+ }
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
 
@@ -0,0 +1,54 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+
3
+ export function retryAfterMs(value, now = Date.now()) {
4
+ if (value == null || value === "") return 0;
5
+ if (/^\d+(?:\.\d+)?$/.test(value)) return Number(value) * 1000;
6
+ const date = Date.parse(value);
7
+ return Number.isFinite(date) ? Math.max(0, date - now) : 0;
8
+ }
9
+
10
+ // Only snapshot operations have server receipts. Never automatically replay a
11
+ // one-use link request or apply this policy to arbitrary POST operations.
12
+ export async function requestSnapshot(endpoint, config, operation, payload, {
13
+ timeout = 30_000, attempts = 5, maxDelayMs = 60_000,
14
+ fetchImpl = fetch, sleep = delay, random = Math.random, now = Date.now,
15
+ } = {}) {
16
+ const body = JSON.stringify({ operation, ...payload });
17
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
18
+ let response;
19
+ let result;
20
+ let failure;
21
+ let retryable = true;
22
+ try {
23
+ response = await fetchImpl(endpoint, {
24
+ method: "POST",
25
+ headers: { authorization: `Bearer ${config.token}`, "content-type": "application/json", "x-usagemax-device-id": config.deviceId },
26
+ body,
27
+ signal: AbortSignal.timeout(timeout),
28
+ });
29
+ result = await response.json().catch(() => null);
30
+ if (response.ok && result?.ok === true) return result;
31
+ retryable = response.ok || response.status === 429 || response.status >= 500
32
+ || (operation === "complete" && response.status === 409
33
+ && String(result?.error).toLowerCase() === "snapshot_run_incomplete");
34
+ const code = typeof result?.error === "string" ? result.error.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 80) : "invalid_response";
35
+ const advice = response.status === 401 ? " Link this computer again."
36
+ : response.status === 403 ? " Check workspace membership and collector permissions."
37
+ : response.status === 409 ? " The saved run needs reconciliation; do not delete its checkpoint or repeatedly start new runs."
38
+ : response.status === 400 ? " Check CLI/server protocol compatibility."
39
+ : "";
40
+ failure = new Error(`Snapshot ${operation} failed (HTTP ${response.status}: ${code}).${advice}`);
41
+ failure.code = code;
42
+ } catch {
43
+ failure = new Error(`Snapshot ${operation} could not reach UsageMax or timed out. Check your network connection.`);
44
+ }
45
+ if (!retryable || attempt + 1 >= attempts) throw failure;
46
+ const retryAfter = retryAfterMs(response?.headers?.get("retry-after"), now());
47
+ // Never retry earlier than the server's requested interval. Long backoffs
48
+ // remain durably resumable instead of blocking the command indefinitely.
49
+ if (retryAfter > maxDelayMs) throw new Error(`${failure.message} Server requested a longer wait; retry sync after ${Math.ceil(retryAfter / 1000)} seconds.`);
50
+ const jitter = Math.min(maxDelayMs, 1000 * 2 ** attempt) * (0.5 + random() * 0.5);
51
+ await sleep(Math.max(retryAfter, jitter));
52
+ }
53
+ throw new Error("Snapshot retry budget is empty.");
54
+ }