codex-token-tracker 0.2.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -2,6 +2,58 @@
2
2
 
3
3
  All notable changes to `codex-token-tracker`. This project follows [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## 0.2.1 — 2026-09-01
6
+
7
+ A manual **Sync** button that recalibrates this device's numbers on the dashboard.
8
+
9
+ ### Added
10
+
11
+ - **Full sync.** The popover header gets a **⟳** button (also *Sync now* in the Account card and the tray
12
+ menu, and `codex-token-tracker sync` on the CLI). Unlike the background upload — which every 60 s pushes
13
+ only the hour buckets and sessions that changed — a full sync throws away every cache on the way:
14
+ 1. re-reads the config, so agents enabled since start-up are picked up;
15
+ 2. re-discovers every session directory and re-parses every transcript from scratch — Codex plus the
16
+ coding agents running on your Codex subscription (pi, OpenCode, Cline / Roo / Kilo, Hermes) and any
17
+ configured `extraSessionDirs` — instead of skipping files whose size and mtime are unchanged;
18
+ 3. recomputes the aggregates with the current pricing table;
19
+ 4. re-uploads **everything**, so the dashboard's totals for this device are replaced by the freshly
20
+ computed ones;
21
+ 5. pulls the other devices' rows and the live rate limits back down.
22
+
23
+ Use it when a machine's numbers look wrong, after installing a new agent, or after an update that
24
+ changes the pricing table. It is manual only — never on a timer — because it re-sends the whole history.
25
+ - A banner in the popover reports the phase while a sync runs (*Rescanning every agent's transcripts…*,
26
+ *Re-uploading this device's history…*) and then the result, e.g. `Synced codex, pi · 75 files ·
27
+ 75 sessions · 70 hours re-uploaded`. Signed out, the rescan still runs and nothing leaves the machine.
28
+ - `codex-token-tracker sync` prints the same summary and exits non-zero if the sync failed.
29
+
30
+ - **Dev builds target the dev environment.** A build now carries its channel, stamped by
31
+ `scripts/build.mjs`: only `--release` (what `prepack` runs) produces a production build, so every
32
+ published tarball is prod and every local `pnpm build` / `pnpm dev` is dev. A dev build
33
+ - defaults to the local dashboard `http://localhost:3000` instead of `https://codex.chenli.dev`, and
34
+ therefore uploads to whatever Convex deployment that dashboard advertises — the dev one;
35
+ - keeps its config, device token and upload state in `~/.codex-tracker-dev`, so a local run can
36
+ never overwrite the production token or the record of what was already pushed to production;
37
+ - defaults `checkUpdates` to `false` and refuses `codex-token-tracker update`, which would otherwise
38
+ install the published package over the checkout;
39
+ - runs as `Codex Tracker (dev)` with its own Electron userData directory and macOS LaunchAgent, so it
40
+ can run **beside** an installed copy instead of fighting it for the single-instance lock;
41
+ - shows an orange **DEV** badge in the popover header and names its dashboard and config directory in
42
+ `--version`, `status` and `paths`.
43
+
44
+ `--dashboard <url>`, `config set dashboardUrl` and `CODEX_TRACKER_HOME` still override all of it.
45
+
46
+ ### Changed
47
+
48
+ - `Uploader.pushAll()` takes a `{ full }` option that clears the pushed-buckets/pushed-sessions record
49
+ before building the payload, and waits for an in-flight incremental push instead of skipping the call.
50
+ - `SessionStore.reset()` drops the parsed-file index so a following deep refresh re-reads everything.
51
+ - Packaging scripts: `build:release` builds with the prod channel; `prepack` runs it (so `npm pack` and
52
+ `npm publish` always ship a prod build) and `postpack` restores the dev build in `dist/`.
53
+ `prepublishOnly` now only typechecks.
54
+ - `app.setName()` moved ahead of `requestSingleInstanceLock()` — the lock is keyed on the userData
55
+ directory, which is derived from the name.
56
+
5
57
  ## 0.2.0 — 2026-09-01
6
58
 
7
59
  Pricing accuracy, an honest tokens/s number, and self-update.
package/README.md CHANGED
@@ -61,6 +61,7 @@ The tracker discovers the Convex deployment through `<dashboard>/api/config`.
61
61
  | `codex-tracker login [--dashboard <url>]` | Device-code login |
62
62
  | `codex-tracker logout` | Forget the device token (local data stays) |
63
63
  | `codex-tracker status [--json]` | Today / 7d / 30d usage, live session, rate limits, models |
64
+ | `codex-tracker sync` | Full sync: rescan every agent and re-upload this device's whole history |
64
65
  | `codex-tracker paths` | Detected Codex session directories |
65
66
  | `codex-tracker config get [key]` / `config set <key> <value>` | Settings (see below) |
66
67
  | `codex-tracker lang <en\|zh\|auto>` | Display language |
@@ -71,10 +72,35 @@ The tracker discovers the Convex deployment through `<dashboard>/api/config`.
71
72
 
72
73
  - Tray title shows today's tokens (`12.4k`) — `config set trayTitle tokens|cost|none`
73
74
  - Click: popover with Today, Live session (tokens/s, context window, rate limits), Activity heatmap, Models, Account
74
- - Right-click: Open dashboard, Sign in/out, Language, Launch at login, Refresh, Check for updates, Quit
75
+ - Right-click: Open dashboard, Sign in/out, Language, Launch at login, Refresh, **Sync now**, Check for updates, Quit
75
76
  - A banner appears at the top of the popover when a newer version is published; **Update** installs it and the app asks you to restart
76
77
  - Launch at login uses a LaunchAgent on macOS and the registry run key on Windows
77
78
 
79
+ ## Sync
80
+
81
+ The tracker uploads continuously in the background: every 60 s it sends the hour buckets and sessions
82
+ that *changed* since the last push. **Sync** does the full version instead — use it when the dashboard's
83
+ numbers for this machine look wrong, after you install a new agent, or after an update that changes the
84
+ pricing table.
85
+
86
+ Press the **⟳** button in the popover header (or *Sync now* in the Account card and the tray menu, or run
87
+ `codex-tracker sync`). It:
88
+
89
+ 1. re-reads the config, so agents enabled since the app started are picked up;
90
+ 2. re-discovers **every** session directory and re-parses **every** transcript from scratch — Codex plus
91
+ the coding agents running on your Codex subscription (pi, OpenCode, Cline / Roo / Kilo, Hermes) and any
92
+ `extraSessionDirs` you configured — instead of skipping files whose size and mtime are unchanged;
93
+ 3. recomputes all aggregates with the current pricing table;
94
+ 4. re-uploads **everything**, not just what changed, so the dashboard's totals for this device are
95
+ replaced by the freshly computed ones — this is the calibration step;
96
+ 5. pulls the other devices' rows and the live rate limits back down.
97
+
98
+ A banner reports the phase while it runs and then what it found (`Synced codex, pi · 75 files ·
99
+ 75 sessions · 70 hours re-uploaded`). Signed out, steps 1-3 still run and nothing leaves the machine.
100
+
101
+ A full sync re-sends your whole history, so it costs more bandwidth than a normal push — it is a manual
102
+ action, never on a timer.
103
+
78
104
  ## Windows and WSL2
79
105
 
80
106
  **Native Windows**: `npm i -g codex-token-tracker` in PowerShell, then `codex-tracker`. The tray app also scans every WSL distro (`\\wsl$\<distro>\home\*\.codex\sessions`) so sessions run inside WSL are counted.
@@ -199,7 +225,42 @@ CODEX_TRACKER_DEVTOOLS=1 ... # op
199
225
  pnpm --filter codex-token-tracker build:icons # regenerate tray icons
200
226
  ```
201
227
 
202
- Publishing (from the repo root): `pnpm release:menubar` — `prepublishOnly` rebuilds and typechecks first.
228
+ ### Dev builds vs published builds
229
+
230
+ A build knows which environment it belongs to, so a local test run can never write into production.
231
+ `scripts/build.mjs` stamps the channel at bundle time: **only `--release` produces a prod build**, which
232
+ is what `prepack` runs — so every tarball and every `npm publish` is prod, and every `pnpm build`,
233
+ `pnpm dev` and watch-mode rebuild is dev.
234
+
235
+ | | dev build (`pnpm build`) | published build (`npm i -g codex-token-tracker`) |
236
+ | --- | --- | --- |
237
+ | Dashboard default | `http://localhost:3000` | `https://codex.chenli.dev` |
238
+ | Convex deployment | whatever the local dashboard's `/api/config` advertises — the **dev** one | production |
239
+ | Config, device token, upload state | `~/.codex-tracker-dev` | `~/.codex-tracker` |
240
+ | `checkUpdates` default | `false` (`update` refuses to run) | `true` |
241
+ | App name / Electron userData | `Codex Tracker (dev)` | `Codex Tracker` |
242
+ | macOS LaunchAgent | `dev.codex-tracker.menubar.dev` | `dev.codex-tracker.menubar` |
243
+ | Popover | orange **DEV** badge in the header | — |
244
+
245
+ Because the two differ in app name and config directory, **a dev build and an installed one can run at
246
+ the same time**, each with its own tray icon, device token and upload state.
247
+
248
+ To test against a dev environment, start the dashboard and Convex, then run the local build:
249
+
250
+ ```bash
251
+ cd apps/dashboard && npx convex dev # terminal 1
252
+ pnpm dev # terminal 2 — http://localhost:3000
253
+ pnpm --filter codex-token-tracker build # terminal 3
254
+ node packages/menubar/bin/codex-tracker.js login # → localhost:3000, no --dashboard needed
255
+ node packages/menubar/bin/codex-tracker.js menubar
256
+ ```
257
+
258
+ Both defaults are only *defaults*: `--dashboard <url>` and `config set dashboardUrl <url>` still point
259
+ either build anywhere, and `CODEX_TRACKER_HOME` still overrides the config directory.
260
+
261
+ Publishing (from the repo root): `pnpm release:menubar` — `prepublishOnly` typechecks and `prepack`
262
+ makes the release build, so a published tarball is never accidentally a dev build. `postpack` restores
263
+ the dev build in `dist/` afterwards, so your working copy keeps pointing at localhost.
203
264
 
204
265
  ## License
205
266
 
package/dist/cli.js CHANGED
@@ -1138,9 +1138,20 @@ var init_src = __esm({
1138
1138
  }
1139
1139
  });
1140
1140
 
1141
+ // src/version.ts
1142
+ var APP_VERSION, APP_CHANNEL, IS_DEV_BUILD;
1143
+ var init_version = __esm({
1144
+ "src/version.ts"() {
1145
+ "use strict";
1146
+ APP_VERSION = true ? "0.2.1" : "0.0.0-dev";
1147
+ APP_CHANNEL = true ? "prod" : "dev";
1148
+ IS_DEV_BUILD = APP_CHANNEL === "dev";
1149
+ }
1150
+ });
1151
+
1141
1152
  // src/core/config.ts
1142
1153
  function configDir() {
1143
- return process.env.CODEX_TRACKER_HOME || import_node_path2.default.join(import_node_os2.default.homedir(), ".codex-tracker");
1154
+ return process.env.CODEX_TRACKER_HOME || import_node_path2.default.join(import_node_os2.default.homedir(), IS_DEV_BUILD ? ".codex-tracker-dev" : ".codex-tracker");
1144
1155
  }
1145
1156
  function readJson(file, fallback) {
1146
1157
  try {
@@ -1193,7 +1204,7 @@ function loadConfig() {
1193
1204
  cfg.sources = normalizeSources(stored.sources);
1194
1205
  cfg.trackAllProviders = stored.trackAllProviders === true;
1195
1206
  cfg.liveRateLimits = stored.liveRateLimits !== false;
1196
- cfg.checkUpdates = stored.checkUpdates !== false;
1207
+ cfg.checkUpdates = typeof stored.checkUpdates === "boolean" ? stored.checkUpdates : DEFAULT_CONFIG.checkUpdates;
1197
1208
  if (!(cfg.usageRefreshSec >= 15)) cfg.usageRefreshSec = DEFAULT_CONFIG.usageRefreshSec;
1198
1209
  if (!["auto", "en", "zh"].includes(cfg.language)) cfg.language = "auto";
1199
1210
  if (!(cfg.uploadIntervalSec >= 10)) cfg.uploadIntervalSec = DEFAULT_CONFIG.uploadIntervalSec;
@@ -1300,14 +1311,17 @@ function loadPricingOverrides() {
1300
1311
  }
1301
1312
  return Object.keys(out).length ? out : void 0;
1302
1313
  }
1303
- var import_node_fs2, import_node_os2, import_node_path2, DEFAULT_DASHBOARD_URL, SOURCE_FORMATS, DEFAULT_SOURCES, SOURCE_IDS, DEFAULT_CONFIG, EDITABLE_KEYS, AGENT_NAME_RE;
1314
+ var import_node_fs2, import_node_os2, import_node_path2, PROD_DASHBOARD_URL, DEV_DASHBOARD_URL, DEFAULT_DASHBOARD_URL, SOURCE_FORMATS, DEFAULT_SOURCES, SOURCE_IDS, DEFAULT_CONFIG, EDITABLE_KEYS, AGENT_NAME_RE;
1304
1315
  var init_config = __esm({
1305
1316
  "src/core/config.ts"() {
1306
1317
  "use strict";
1307
1318
  import_node_fs2 = __toESM(require("node:fs"));
1308
1319
  import_node_os2 = __toESM(require("node:os"));
1309
1320
  import_node_path2 = __toESM(require("node:path"));
1310
- DEFAULT_DASHBOARD_URL = "https://codex.chenli.dev";
1321
+ init_version();
1322
+ PROD_DASHBOARD_URL = "https://codex.chenli.dev";
1323
+ DEV_DASHBOARD_URL = "http://localhost:3000";
1324
+ DEFAULT_DASHBOARD_URL = IS_DEV_BUILD ? DEV_DASHBOARD_URL : PROD_DASHBOARD_URL;
1311
1325
  SOURCE_FORMATS = ["codex", "pi", "generic", "opencode", "cline"];
1312
1326
  DEFAULT_SOURCES = { codex: true, pi: true, hermes: true, opencode: true, cline: true, roo: true, kilo: true };
1313
1327
  SOURCE_IDS = Object.keys(DEFAULT_SOURCES);
@@ -1327,7 +1341,8 @@ var init_config = __esm({
1327
1341
  trackAllProviders: false,
1328
1342
  liveRateLimits: true,
1329
1343
  usageRefreshSec: 60,
1330
- checkUpdates: true
1344
+ // a dev build must never offer to replace itself with the published package
1345
+ checkUpdates: !IS_DEV_BUILD
1331
1346
  };
1332
1347
  EDITABLE_KEYS = [
1333
1348
  "dashboardUrl",
@@ -1397,6 +1412,7 @@ var init_en = __esm({
1397
1412
  estimatedPricingHint: "Pricing estimated: model not in the price table",
1398
1413
  other: "Other",
1399
1414
  lastUpload: "Last upload {time}",
1415
+ lastSync: "Last full sync {time}",
1400
1416
  neverUploaded: "Not uploaded yet",
1401
1417
  uploadError: "Upload error: {message}",
1402
1418
  allDevicesToday: "All devices today",
@@ -1415,10 +1431,26 @@ var init_en = __esm({
1415
1431
  launchAtLogin: "Launch at login",
1416
1432
  refresh: "Refresh",
1417
1433
  quit: "Quit",
1434
+ // full sync
1435
+ syncNow: "Sync now",
1436
+ syncing: "Syncing\u2026",
1437
+ syncTitle: "Rescan every agent on this device and re-upload its full usage history",
1438
+ syncScanning: "Rescanning every agent's transcripts\u2026",
1439
+ syncComputing: "Recomputing token usage\u2026",
1440
+ syncUploading: "Re-uploading this device's history\u2026",
1441
+ syncDownloading: "Fetching other devices\u2026",
1442
+ syncLimits: "Refreshing rate limits\u2026",
1443
+ syncDone: "Synced {agents} \xB7 {files} files \xB7 {sessions} sessions \xB7 {buckets} hours re-uploaded",
1444
+ syncDoneLocal: "Rescanned {agents} \xB7 {files} files \xB7 {sessions} sessions \u2014 sign in to upload",
1445
+ syncFailed: "Sync failed: {message}",
1418
1446
  sessions: "{n} sessions",
1419
1447
  files: "{n} files",
1420
1448
  noData: "No usage found yet. Run Codex and this will fill in.",
1421
1449
  version: "v{version}",
1450
+ // dev builds
1451
+ devBuild: "DEV",
1452
+ devBuildTitle: "Local build \u2014 uploading to {url} (dev environment). Config: {dir}",
1453
+ devBuildMenu: "Dev build \u2192 {url}",
1422
1454
  justNow: "just now",
1423
1455
  never: "never",
1424
1456
  local: "Local",
@@ -1446,6 +1478,7 @@ Commands:
1446
1478
  login Connect this device to your dashboard (--dashboard <url>)
1447
1479
  logout Disconnect this device
1448
1480
  status Print today's usage, live session and rate limits
1481
+ sync Rescan every agent and re-upload this device's full history (calibrate)
1449
1482
  paths Show detected session directories (Codex, pi, OpenCode, Cline/Roo/Kilo, Hermes, custom)
1450
1483
  update Install the newest published version (--check only reports)
1451
1484
  config get|set Read or change settings (config set uploadIntervalSec 30, config set sources.pi false)
@@ -1500,7 +1533,15 @@ Options:
1500
1533
  cliUpdateRunning: "Running: {command}",
1501
1534
  cliUpdateDone: "Updated to {version}. Restart the menu bar app (or rerun the CLI) to use it.",
1502
1535
  cliUpdateFailed: "Update failed (exit {code}). Run it yourself: {command}",
1503
- cliUpdateCheckFailed: "Could not reach the npm registry: {message}"
1536
+ cliUpdateCheckFailed: "Could not reach the npm registry: {message}",
1537
+ cliSyncStart: "Full sync: rescanning every agent on this device\u2026",
1538
+ cliSyncScanned: " scanned {files} files in {roots} directories ({agents})",
1539
+ cliSyncUploaded: " re-uploaded {buckets} hour buckets and {sessions} sessions",
1540
+ cliSyncLocal: " not signed in \u2014 nothing uploaded (run `codex-tracker login` first)",
1541
+ cliSyncDone: "Sync complete in {seconds}s \u2014 {sessions} sessions, today {tokens} tok \xB7 {cost}",
1542
+ cliSyncFailed: "Sync failed: {message}",
1543
+ cliChannelDev: "Dev build \u2014 dashboard {url} \xB7 config {dir}",
1544
+ cliUpdateDevBuild: "This is a local dev build; `update` would install the published package over it. Run `pnpm build` in the repo instead."
1504
1545
  };
1505
1546
  }
1506
1547
  });
@@ -1554,6 +1595,7 @@ var init_zh = __esm({
1554
1595
  estimatedPricingHint: "\u4EF7\u683C\u4E3A\u4F30\u7B97\u503C\uFF1A\u8BE5\u6A21\u578B\u4E0D\u5728\u4EF7\u76EE\u8868\u4E2D",
1555
1596
  other: "\u5176\u4ED6",
1556
1597
  lastUpload: "\u4E0A\u6B21\u4E0A\u4F20 {time}",
1598
+ lastSync: "\u4E0A\u6B21\u5B8C\u6574\u540C\u6B65 {time}",
1557
1599
  neverUploaded: "\u5C1A\u672A\u4E0A\u4F20",
1558
1600
  uploadError: "\u4E0A\u4F20\u5931\u8D25\uFF1A{message}",
1559
1601
  allDevicesToday: "\u6240\u6709\u8BBE\u5907\u4ECA\u65E5",
@@ -1572,10 +1614,26 @@ var init_zh = __esm({
1572
1614
  launchAtLogin: "\u5F00\u673A\u81EA\u542F\u52A8",
1573
1615
  refresh: "\u5237\u65B0",
1574
1616
  quit: "\u9000\u51FA",
1617
+ // 完整同步
1618
+ syncNow: "\u7ACB\u5373\u540C\u6B65",
1619
+ syncing: "\u540C\u6B65\u4E2D\u2026",
1620
+ syncTitle: "\u91CD\u65B0\u626B\u63CF\u672C\u8BBE\u5907\u4E0A\u7684\u6240\u6709\u667A\u80FD\u4F53\uFF0C\u5E76\u91CD\u65B0\u4E0A\u4F20\u5B8C\u6574\u7528\u91CF\u5386\u53F2",
1621
+ syncScanning: "\u6B63\u5728\u91CD\u65B0\u626B\u63CF\u6240\u6709\u667A\u80FD\u4F53\u7684\u4F1A\u8BDD\u8BB0\u5F55\u2026",
1622
+ syncComputing: "\u6B63\u5728\u91CD\u65B0\u8BA1\u7B97 token \u7528\u91CF\u2026",
1623
+ syncUploading: "\u6B63\u5728\u91CD\u65B0\u4E0A\u4F20\u672C\u8BBE\u5907\u7684\u5386\u53F2\u6570\u636E\u2026",
1624
+ syncDownloading: "\u6B63\u5728\u62C9\u53D6\u5176\u4ED6\u8BBE\u5907\u7684\u6570\u636E\u2026",
1625
+ syncLimits: "\u6B63\u5728\u5237\u65B0\u9650\u989D\u2026",
1626
+ syncDone: "\u5DF2\u540C\u6B65 {agents} \xB7 {files} \u4E2A\u6587\u4EF6 \xB7 {sessions} \u4E2A\u4F1A\u8BDD \xB7 \u91CD\u65B0\u4E0A\u4F20 {buckets} \u4E2A\u5C0F\u65F6\u6BB5",
1627
+ syncDoneLocal: "\u5DF2\u91CD\u65B0\u626B\u63CF {agents} \xB7 {files} \u4E2A\u6587\u4EF6 \xB7 {sessions} \u4E2A\u4F1A\u8BDD \u2014 \u767B\u5F55\u540E\u53EF\u4E0A\u4F20",
1628
+ syncFailed: "\u540C\u6B65\u5931\u8D25\uFF1A{message}",
1575
1629
  sessions: "{n} \u4E2A\u4F1A\u8BDD",
1576
1630
  files: "{n} \u4E2A\u6587\u4EF6",
1577
1631
  noData: "\u8FD8\u6CA1\u6709\u7528\u91CF\u6570\u636E\u3002\u8FD0\u884C Codex \u540E\u8FD9\u91CC\u4F1A\u81EA\u52A8\u66F4\u65B0\u3002",
1578
1632
  version: "v{version}",
1633
+ // 开发版
1634
+ devBuild: "\u5F00\u53D1\u7248",
1635
+ devBuildTitle: "\u672C\u5730\u6784\u5EFA \u2014\u2014 \u6570\u636E\u4E0A\u4F20\u81F3 {url}\uFF08\u5F00\u53D1\u73AF\u5883\uFF09\u3002\u914D\u7F6E\u76EE\u5F55\uFF1A{dir}",
1636
+ devBuildMenu: "\u5F00\u53D1\u7248 \u2192 {url}",
1579
1637
  justNow: "\u521A\u521A",
1580
1638
  never: "\u4ECE\u672A",
1581
1639
  local: "\u672C\u673A",
@@ -1601,6 +1659,7 @@ var init_zh = __esm({
1601
1659
  login \u5C06\u672C\u8BBE\u5907\u8FDE\u63A5\u5230\u4EEA\u8868\u76D8\uFF08--dashboard <url>\uFF09
1602
1660
  logout \u65AD\u5F00\u672C\u8BBE\u5907
1603
1661
  status \u6253\u5370\u4ECA\u65E5\u7528\u91CF\u3001\u5B9E\u65F6\u4F1A\u8BDD\u4E0E\u9650\u989D
1662
+ sync \u91CD\u65B0\u626B\u63CF\u6240\u6709\u667A\u80FD\u4F53\u5E76\u91CD\u65B0\u4E0A\u4F20\u672C\u8BBE\u5907\u5B8C\u6574\u5386\u53F2\uFF08\u6821\u51C6\uFF09
1604
1663
  paths \u663E\u793A\u68C0\u6D4B\u5230\u7684\u4F1A\u8BDD\u76EE\u5F55\uFF08Codex\u3001pi\u3001OpenCode\u3001Cline/Roo/Kilo\u3001Hermes\u3001\u81EA\u5B9A\u4E49\uFF09
1605
1664
  update \u5B89\u88C5\u6700\u65B0\u53D1\u5E03\u7248\u672C\uFF08--check \u53EA\u68C0\u67E5\u4E0D\u5B89\u88C5\uFF09
1606
1665
  config get|set \u8BFB\u53D6\u6216\u4FEE\u6539\u8BBE\u7F6E\uFF08config set uploadIntervalSec 30\uFF0Cconfig set sources.pi false\uFF09
@@ -1655,7 +1714,15 @@ var init_zh = __esm({
1655
1714
  cliUpdateRunning: "\u6B63\u5728\u6267\u884C\uFF1A{command}",
1656
1715
  cliUpdateDone: "\u5DF2\u66F4\u65B0\u5230 {version}\u3002\u8BF7\u91CD\u542F\u83DC\u5355\u680F\u5E94\u7528\uFF08\u6216\u91CD\u65B0\u8FD0\u884C CLI\uFF09\u4EE5\u751F\u6548\u3002",
1657
1716
  cliUpdateFailed: "\u66F4\u65B0\u5931\u8D25\uFF08\u9000\u51FA\u7801 {code}\uFF09\u3002\u8BF7\u624B\u52A8\u6267\u884C\uFF1A{command}",
1658
- cliUpdateCheckFailed: "\u65E0\u6CD5\u8BBF\u95EE npm registry\uFF1A{message}"
1717
+ cliUpdateCheckFailed: "\u65E0\u6CD5\u8BBF\u95EE npm registry\uFF1A{message}",
1718
+ cliSyncStart: "\u5B8C\u6574\u540C\u6B65\uFF1A\u6B63\u5728\u91CD\u65B0\u626B\u63CF\u672C\u8BBE\u5907\u4E0A\u7684\u6240\u6709\u667A\u80FD\u4F53\u2026",
1719
+ cliSyncScanned: " \u5DF2\u626B\u63CF {roots} \u4E2A\u76EE\u5F55\u4E2D\u7684 {files} \u4E2A\u6587\u4EF6\uFF08{agents}\uFF09",
1720
+ cliSyncUploaded: " \u5DF2\u91CD\u65B0\u4E0A\u4F20 {buckets} \u4E2A\u5C0F\u65F6\u6BB5\u4E0E {sessions} \u4E2A\u4F1A\u8BDD",
1721
+ cliSyncLocal: " \u672A\u767B\u5F55 \u2014 \u6CA1\u6709\u4E0A\u4F20\u4EFB\u4F55\u6570\u636E\uFF08\u8BF7\u5148\u8FD0\u884C `codex-tracker login`\uFF09",
1722
+ cliSyncDone: "\u540C\u6B65\u5B8C\u6210\uFF0C\u7528\u65F6 {seconds} \u79D2 \u2014 {sessions} \u4E2A\u4F1A\u8BDD\uFF0C\u4ECA\u65E5 {tokens} tok \xB7 {cost}",
1723
+ cliSyncFailed: "\u540C\u6B65\u5931\u8D25\uFF1A{message}",
1724
+ cliChannelDev: "\u5F00\u53D1\u7248 \u2014\u2014 \u4EEA\u8868\u76D8 {url} \xB7 \u914D\u7F6E\u76EE\u5F55 {dir}",
1725
+ cliUpdateDevBuild: "\u8FD9\u662F\u672C\u5730\u5F00\u53D1\u7248\uFF1B`update` \u4F1A\u7528\u5DF2\u53D1\u5E03\u7684\u5305\u8986\u76D6\u5B83\u3002\u8BF7\u6539\u4E3A\u5728\u4ED3\u5E93\u4E2D\u8FD0\u884C `pnpm build`\u3002"
1659
1726
  };
1660
1727
  }
1661
1728
  });
@@ -8630,11 +8697,25 @@ var init_uploader = __esm({
8630
8697
  throw err;
8631
8698
  }
8632
8699
  }
8633
- /** Upload changed hour buckets and session summaries. Returns counts of pushed items. */
8634
- async pushAll(buckets, sessions, sessionCosts) {
8700
+ /** Wait for an in-flight incremental push to finish so a full sync isn't skipped by the guard. */
8701
+ async awaitIdle(timeoutMs = 15e3) {
8702
+ const deadline = Date.now() + timeoutMs;
8703
+ while (this.inFlight && Date.now() < deadline) await new Promise((r) => setTimeout(r, 100));
8704
+ }
8705
+ /**
8706
+ * Upload changed hour buckets and session summaries. Returns counts of pushed items.
8707
+ * With `full`, the record of what was already pushed is dropped first so *everything* is re-sent —
8708
+ * that is what recalibrates this device's totals on the dashboard after a parser or pricing change.
8709
+ */
8710
+ async pushAll(buckets, sessions, sessionCosts, opts = {}) {
8711
+ if (opts.full) await this.awaitIdle();
8635
8712
  if (this.inFlight) return { buckets: 0, sessions: 0 };
8636
8713
  this.inFlight = true;
8637
8714
  try {
8715
+ if (opts.full) {
8716
+ this.state.pushedBuckets = {};
8717
+ this.state.pushedSessions = {};
8718
+ }
8638
8719
  const changed = [];
8639
8720
  const hashes = /* @__PURE__ */ new Map();
8640
8721
  for (const b of buckets.sort((a, b2) => a.hourStart - b2.hourStart)) {
@@ -9708,6 +9789,15 @@ var SessionStore = class {
9708
9789
  onChange = null;
9709
9790
  debounceTimer = null;
9710
9791
  sessionCache = null;
9792
+ /**
9793
+ * Forget the parsed-file index so the next deep refresh re-reads and re-parses every transcript.
9794
+ * Used by the full sync: files are otherwise skipped while their size and mtime are unchanged, which
9795
+ * would keep stale numbers around after a parser or pricing change.
9796
+ */
9797
+ reset() {
9798
+ this.files.clear();
9799
+ this.sessionCache = null;
9800
+ }
9711
9801
  async refreshDeep() {
9712
9802
  const o = this.getOptions();
9713
9803
  this.roots = discoverSessionRoots({ extraSessionDirs: o.extraSessionDirs, sources: o.sources });
@@ -10107,11 +10197,7 @@ var import_node_fs9 = __toESM(require("node:fs"));
10107
10197
  var import_node_path11 = __toESM(require("node:path"));
10108
10198
  var import_node_child_process4 = require("node:child_process");
10109
10199
  init_config();
10110
-
10111
- // src/version.ts
10112
- var APP_VERSION = true ? "0.2.0" : "0.0.0-dev";
10113
-
10114
- // src/core/update.ts
10200
+ init_version();
10115
10201
  var NPM_PACKAGE = "codex-token-tracker";
10116
10202
  var CHECK_TTL_MS = 6 * 60 * 60 * 1e3;
10117
10203
  var REQUEST_TIMEOUT_MS = 8e3;
@@ -10135,7 +10221,7 @@ function compareVersions(a, b) {
10135
10221
  return A.pre < B.pre ? -1 : 1;
10136
10222
  }
10137
10223
  function isDevBuild(version2) {
10138
- return version2.startsWith("0.0.0");
10224
+ return IS_DEV_BUILD || version2.startsWith("0.0.0");
10139
10225
  }
10140
10226
  function registryBase() {
10141
10227
  const r = process.env.CODEX_TRACKER_REGISTRY || process.env.npm_config_registry || process.env.NPM_CONFIG_REGISTRY || "https://registry.npmjs.org";
@@ -10271,6 +10357,7 @@ function runUpdate(opts = {}) {
10271
10357
  }
10272
10358
 
10273
10359
  // src/core/engine.ts
10360
+ init_version();
10274
10361
  var SHALLOW_MS = 3e3;
10275
10362
  var DEEP_MS = 6e4;
10276
10363
  var TICK_MS = 2e3;
@@ -10278,6 +10365,7 @@ var REMOTE_MS = 6e4;
10278
10365
  var LIVE_LIMITS_DEBOUNCE_MS = 1e4;
10279
10366
  var LIVE_LIMITS_MIN_GAP_MS = 2e4;
10280
10367
  var UPDATE_CHECK_MS = 6 * 60 * 60 * 1e3;
10368
+ var SYNC_BANNER_MS = 25e3;
10281
10369
  var Engine = class extends import_node_events.EventEmitter {
10282
10370
  constructor(opts) {
10283
10371
  super();
@@ -10317,6 +10405,13 @@ var Engine = class extends import_node_events.EventEmitter {
10317
10405
  update = null;
10318
10406
  updateStatus = "idle";
10319
10407
  updateLog = null;
10408
+ syncStatus = "idle";
10409
+ syncPhase = null;
10410
+ syncStartedAt = null;
10411
+ syncFinishedAt = null;
10412
+ syncError = null;
10413
+ syncResult = null;
10414
+ syncBannerTimer = null;
10320
10415
  get heatmapWeeks() {
10321
10416
  return this.opts.heatmapWeeks ?? 16;
10322
10417
  }
@@ -10367,6 +10462,8 @@ var Engine = class extends import_node_events.EventEmitter {
10367
10462
  this.timers = [];
10368
10463
  if (this.liveLimitsTimer) clearTimeout(this.liveLimitsTimer);
10369
10464
  this.liveLimitsTimer = null;
10465
+ if (this.syncBannerTimer) clearTimeout(this.syncBannerTimer);
10466
+ this.syncBannerTimer = null;
10370
10467
  this.store.stopWatching();
10371
10468
  this.pending?.abort.abort();
10372
10469
  }
@@ -10470,6 +10567,112 @@ var Engine = class extends import_node_events.EventEmitter {
10470
10567
  await this.uploader.fetchRemote(this.heatmapWeeks);
10471
10568
  this.recompute();
10472
10569
  }
10570
+ /**
10571
+ * Full sync ("calibrate this device"). Unlike the periodic incremental upload this deliberately
10572
+ * throws away every cache on the way:
10573
+ * 1. re-read the config so sources enabled since start-up are picked up,
10574
+ * 2. drop the parsed-file index and re-discover + re-parse every transcript of every agent
10575
+ * (Codex plus the agents running on the Codex subscription: pi, OpenCode, Cline/Roo/Kilo,
10576
+ * Hermes and any custom `extraSessionDirs`),
10577
+ * 3. recompute the aggregates with the current pricing table,
10578
+ * 4. re-upload *everything* — not just what changed — so the dashboard's totals for this device
10579
+ * are replaced by the freshly computed ones,
10580
+ * 5. pull the other devices' rows and the live rate limits back down.
10581
+ *
10582
+ * Returns null when a sync is already running. Never throws: failures land in the returned state.
10583
+ */
10584
+ async syncNow() {
10585
+ if (this.syncStatus === "running") return null;
10586
+ if (this.syncBannerTimer) {
10587
+ clearTimeout(this.syncBannerTimer);
10588
+ this.syncBannerTimer = null;
10589
+ }
10590
+ const startedAt = Date.now();
10591
+ this.syncStatus = "running";
10592
+ this.syncPhase = null;
10593
+ this.syncStartedAt = startedAt;
10594
+ this.syncFinishedAt = null;
10595
+ this.syncError = null;
10596
+ const phase = (p) => {
10597
+ this.syncPhase = p;
10598
+ this.emitSnapshot();
10599
+ };
10600
+ try {
10601
+ phase("scanning");
10602
+ this.reloadConfig();
10603
+ this.store.reset();
10604
+ await this.store.refreshDeep();
10605
+ phase("computing");
10606
+ this.recompute();
10607
+ let uploadedBuckets = 0;
10608
+ let uploadedSessions = 0;
10609
+ const uploaded = this.opts.upload && this.signedIn;
10610
+ if (uploaded) {
10611
+ phase("uploading");
10612
+ const r = await this.uploader.pushAll(this.stats.buckets, this.stats.sessions, this.stats.sessionCosts, { full: true });
10613
+ uploadedBuckets = r.buckets;
10614
+ uploadedSessions = r.sessions;
10615
+ await this.heartbeatNow().catch(() => {
10616
+ });
10617
+ phase("downloading");
10618
+ await this.fetchRemoteNow();
10619
+ }
10620
+ phase("limits");
10621
+ await this.refreshLiveLimits(true);
10622
+ const finishedAt = Date.now();
10623
+ this.syncResult = {
10624
+ startedAt,
10625
+ finishedAt,
10626
+ durationMs: finishedAt - startedAt,
10627
+ files: this.store.fileCount,
10628
+ sessions: this.store.sessions().length,
10629
+ roots: this.store.roots.length,
10630
+ agents: [...new Set(this.store.roots.map((r) => r.agent))].sort(),
10631
+ uploadedBuckets,
10632
+ uploadedSessions,
10633
+ uploaded
10634
+ };
10635
+ this.syncStatus = "done";
10636
+ this.syncFinishedAt = finishedAt;
10637
+ this.opts.log?.(
10638
+ `sync: ${this.syncResult.files} files, ${this.syncResult.sessions} sessions, ${uploadedBuckets} buckets / ${uploadedSessions} sessions uploaded in ${this.syncResult.durationMs} ms`
10639
+ );
10640
+ return this.syncResult;
10641
+ } catch (err) {
10642
+ this.syncStatus = "error";
10643
+ this.syncFinishedAt = Date.now();
10644
+ this.syncError = err instanceof SignedOutError ? "not signed in" : errorMessage(err);
10645
+ this.opts.log?.(`sync failed: ${this.syncError}`);
10646
+ return null;
10647
+ } finally {
10648
+ this.syncPhase = null;
10649
+ this.emitSnapshot();
10650
+ this.scheduleSyncBannerClear();
10651
+ }
10652
+ }
10653
+ /** Return the sync banner to "idle" a little after it finished, keeping `last` for the footer. */
10654
+ scheduleSyncBannerClear() {
10655
+ if (this.opts.watch === false) return;
10656
+ if (this.syncBannerTimer) clearTimeout(this.syncBannerTimer);
10657
+ this.syncBannerTimer = setTimeout(() => {
10658
+ this.syncBannerTimer = null;
10659
+ if (this.syncStatus === "done" || this.syncStatus === "error") {
10660
+ this.syncStatus = "idle";
10661
+ this.emitSnapshot();
10662
+ }
10663
+ }, SYNC_BANNER_MS);
10664
+ this.syncBannerTimer.unref?.();
10665
+ }
10666
+ syncState() {
10667
+ return {
10668
+ status: this.syncStatus,
10669
+ phase: this.syncPhase,
10670
+ startedAt: this.syncStartedAt,
10671
+ finishedAt: this.syncFinishedAt,
10672
+ error: this.syncError,
10673
+ last: this.syncResult
10674
+ };
10675
+ }
10473
10676
  /**
10474
10677
  * Ask the npm registry for the newest published version (cached for 6 h unless `force`).
10475
10678
  * Best-effort: a failure is recorded on the snapshot, never thrown.
@@ -10587,6 +10790,7 @@ ${r.output}`.trim();
10587
10790
  const sessions = this.store.sessions();
10588
10791
  return {
10589
10792
  version: APP_VERSION,
10793
+ channel: APP_CHANNEL,
10590
10794
  generatedAt: Date.now(),
10591
10795
  language: this.language(),
10592
10796
  languageSetting: this.config.language,
@@ -10605,6 +10809,7 @@ ${r.output}`.trim();
10605
10809
  rateLimitsError: this.config.liveRateLimits ? this.liveLimitsError : null,
10606
10810
  rateLimitsUpdatedAt: this.liveLimits ? this.liveLimitsAt : s.logRateLimits?.observedAt ?? null,
10607
10811
  update: this.updateState(),
10812
+ sync: this.syncState(),
10608
10813
  modelsToday: s.modelsToday,
10609
10814
  modelsMonth: s.modelsMonth,
10610
10815
  byAgentToday: s.byAgentToday,
@@ -10621,6 +10826,7 @@ ${r.output}`.trim();
10621
10826
  },
10622
10827
  sessionDirs: SessionStore.rootDirs(this.store.roots),
10623
10828
  sessionRoots: this.store.roots.map((r) => ({ dir: r.dir, agent: r.agent, format: r.format, origin: r.origin })),
10829
+ configDir: configDir(),
10624
10830
  launchAtLogin: this.config.launchAtLogin,
10625
10831
  trayTitle: this.config.trayTitle
10626
10832
  };
@@ -10632,6 +10838,7 @@ init_platform();
10632
10838
  init_config();
10633
10839
  init_uploader();
10634
10840
  init_i18n();
10841
+ init_version();
10635
10842
  function parseArgs2(argv) {
10636
10843
  const out = { command: null, positional: [], flags: {} };
10637
10844
  for (let i2 = 0; i2 < argv.length; i2++) {
@@ -10797,6 +11004,7 @@ async function runStatus(json) {
10797
11004
  s.auth.status === "signedIn" ? `${t2("signedInAs", { name: s.auth.user?.name || s.auth.user?.email || "?" })} \xB7 ${s.auth.dashboardUrl}` : t2("cliNotSignedIn")
10798
11005
  );
10799
11006
  line(t2("cliStatusDirs"), s.sessionDirs.join("\n" + " ".repeat(15)) || "-");
11007
+ if (IS_DEV_BUILD) console.log(t2("cliChannelDev", { url: s.auth.dashboardUrl, dir: configDir() }));
10800
11008
  const byAgent = Object.entries(s.counts.byAgent).sort((a, b) => b[1].sessions - a[1].sessions).map(([agent, c]) => `${agent} ${c.sessions}`).join(", ");
10801
11009
  console.log(t2("sessions", { n: s.counts.sessions }) + " \xB7 " + t2("files", { n: s.counts.files }) + (byAgent ? ` (${byAgent})` : ""));
10802
11010
  if (cfg.checkUpdates) {
@@ -10805,6 +11013,36 @@ async function runStatus(json) {
10805
11013
  }
10806
11014
  return 0;
10807
11015
  }
11016
+ async function runSync(flags) {
11017
+ const cfg = applyDashboardFlag(flags);
11018
+ const L = lang(cfg);
11019
+ const t2 = makeT(L);
11020
+ const engine = new Engine({
11021
+ upload: true,
11022
+ watch: false,
11023
+ systemLocale: systemLocale(),
11024
+ log: process.env.CODEX_TRACKER_DEBUG ? (m) => console.error("[sync]", m) : void 0
11025
+ });
11026
+ console.log(t2("cliSyncStart"));
11027
+ const result = await engine.syncNow();
11028
+ if (!result) {
11029
+ console.error(t2("cliSyncFailed", { message: engine.snapshot().sync.error ?? "?" }));
11030
+ return 1;
11031
+ }
11032
+ console.log(t2("cliSyncScanned", { files: result.files, roots: result.roots, agents: result.agents.join(", ") || "-" }));
11033
+ if (result.uploaded) console.log(t2("cliSyncUploaded", { buckets: result.uploadedBuckets, sessions: result.uploadedSessions }));
11034
+ else console.log(t2("cliSyncLocal"));
11035
+ const s = engine.snapshot();
11036
+ console.log(
11037
+ t2("cliSyncDone", {
11038
+ seconds: (result.durationMs / 1e3).toFixed(1),
11039
+ sessions: result.sessions,
11040
+ tokens: formatTokens(s.today.usage.total),
11041
+ cost: formatUSD(s.today.cost)
11042
+ })
11043
+ );
11044
+ return 0;
11045
+ }
10808
11046
  async function runLogin(flags) {
10809
11047
  const cfg = applyDashboardFlag(flags);
10810
11048
  const L = lang(cfg);
@@ -10908,6 +11146,7 @@ function runPaths() {
10908
11146
  const off = SOURCE_IDS.filter((id) => !cfg.sources[id]);
10909
11147
  if (off.length) console.log(` (disabled: ${off.join(", ")})`);
10910
11148
  console.log(t2("cliConfigDir", { dir: configDir() }));
11149
+ if (IS_DEV_BUILD) console.log(t2("cliChannelDev", { url: cfg.dashboardUrl, dir: configDir() }));
10911
11150
  return 0;
10912
11151
  }
10913
11152
  function runConfig(positional) {
@@ -10954,6 +11193,10 @@ function runConfig(positional) {
10954
11193
  }
10955
11194
  async function runUpdateCommand(flags) {
10956
11195
  const t2 = makeT(lang());
11196
+ if (IS_DEV_BUILD) {
11197
+ console.error(t2("cliUpdateDevBuild"));
11198
+ return 1;
11199
+ }
10957
11200
  const info2 = await checkForUpdate({ force: true });
10958
11201
  if (info2.error && !info2.latest) {
10959
11202
  console.error(t2("cliUpdateCheckFailed", { message: info2.error }));
@@ -10992,6 +11235,7 @@ async function main() {
10992
11235
  const t2 = makeT(lang());
10993
11236
  if (args.flags.version) {
10994
11237
  console.log(t2("cliVersion", { version: APP_VERSION }));
11238
+ if (IS_DEV_BUILD) console.log(t2("cliChannelDev", { url: loadConfig().dashboardUrl, dir: configDir() }));
10995
11239
  return 0;
10996
11240
  }
10997
11241
  if (args.flags.help || args.command === "help") {
@@ -11028,6 +11272,8 @@ async function main() {
11028
11272
  }
11029
11273
  case "status":
11030
11274
  return runStatus(args.flags.json === true);
11275
+ case "sync":
11276
+ return runSync(args.flags);
11031
11277
  case "paths":
11032
11278
  return runPaths();
11033
11279
  case "config":