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/dist/main.js CHANGED
@@ -1042,7 +1042,17 @@ var import_node_events = require("node:events");
1042
1042
  var import_node_fs = __toESM(require("node:fs"));
1043
1043
  var import_node_os = __toESM(require("node:os"));
1044
1044
  var import_node_path = __toESM(require("node:path"));
1045
- var DEFAULT_DASHBOARD_URL = "https://codex.chenli.dev";
1045
+
1046
+ // src/version.ts
1047
+ var APP_VERSION = true ? "0.2.1" : "0.0.0-dev";
1048
+ var APP_CHANNEL = true ? "prod" : "dev";
1049
+ var IS_DEV_BUILD = APP_CHANNEL === "dev";
1050
+ var APP_NAME = IS_DEV_BUILD ? "Codex Tracker (dev)" : "Codex Tracker";
1051
+
1052
+ // src/core/config.ts
1053
+ var PROD_DASHBOARD_URL = "https://codex.chenli.dev";
1054
+ var DEV_DASHBOARD_URL = "http://localhost:3000";
1055
+ var DEFAULT_DASHBOARD_URL = IS_DEV_BUILD ? DEV_DASHBOARD_URL : PROD_DASHBOARD_URL;
1046
1056
  var SOURCE_FORMATS = ["codex", "pi", "generic", "opencode", "cline"];
1047
1057
  var DEFAULT_SOURCES = { codex: true, pi: true, hermes: true, opencode: true, cline: true, roo: true, kilo: true };
1048
1058
  var SOURCE_IDS = Object.keys(DEFAULT_SOURCES);
@@ -1062,10 +1072,11 @@ var DEFAULT_CONFIG = {
1062
1072
  trackAllProviders: false,
1063
1073
  liveRateLimits: true,
1064
1074
  usageRefreshSec: 60,
1065
- checkUpdates: true
1075
+ // a dev build must never offer to replace itself with the published package
1076
+ checkUpdates: !IS_DEV_BUILD
1066
1077
  };
1067
1078
  function configDir() {
1068
- return process.env.CODEX_TRACKER_HOME || import_node_path.default.join(import_node_os.default.homedir(), ".codex-tracker");
1079
+ return process.env.CODEX_TRACKER_HOME || import_node_path.default.join(import_node_os.default.homedir(), IS_DEV_BUILD ? ".codex-tracker-dev" : ".codex-tracker");
1069
1080
  }
1070
1081
  function readJson(file, fallback) {
1071
1082
  try {
@@ -1116,7 +1127,7 @@ function loadConfig() {
1116
1127
  cfg.sources = normalizeSources(stored.sources);
1117
1128
  cfg.trackAllProviders = stored.trackAllProviders === true;
1118
1129
  cfg.liveRateLimits = stored.liveRateLimits !== false;
1119
- cfg.checkUpdates = stored.checkUpdates !== false;
1130
+ cfg.checkUpdates = typeof stored.checkUpdates === "boolean" ? stored.checkUpdates : DEFAULT_CONFIG.checkUpdates;
1120
1131
  if (!(cfg.usageRefreshSec >= 15)) cfg.usageRefreshSec = DEFAULT_CONFIG.usageRefreshSec;
1121
1132
  if (!["auto", "en", "zh"].includes(cfg.language)) cfg.language = "auto";
1122
1133
  if (!(cfg.uploadIntervalSec >= 10)) cfg.uploadIntervalSec = DEFAULT_CONFIG.uploadIntervalSec;
@@ -1230,6 +1241,7 @@ var en = {
1230
1241
  estimatedPricingHint: "Pricing estimated: model not in the price table",
1231
1242
  other: "Other",
1232
1243
  lastUpload: "Last upload {time}",
1244
+ lastSync: "Last full sync {time}",
1233
1245
  neverUploaded: "Not uploaded yet",
1234
1246
  uploadError: "Upload error: {message}",
1235
1247
  allDevicesToday: "All devices today",
@@ -1248,10 +1260,26 @@ var en = {
1248
1260
  launchAtLogin: "Launch at login",
1249
1261
  refresh: "Refresh",
1250
1262
  quit: "Quit",
1263
+ // full sync
1264
+ syncNow: "Sync now",
1265
+ syncing: "Syncing\u2026",
1266
+ syncTitle: "Rescan every agent on this device and re-upload its full usage history",
1267
+ syncScanning: "Rescanning every agent's transcripts\u2026",
1268
+ syncComputing: "Recomputing token usage\u2026",
1269
+ syncUploading: "Re-uploading this device's history\u2026",
1270
+ syncDownloading: "Fetching other devices\u2026",
1271
+ syncLimits: "Refreshing rate limits\u2026",
1272
+ syncDone: "Synced {agents} \xB7 {files} files \xB7 {sessions} sessions \xB7 {buckets} hours re-uploaded",
1273
+ syncDoneLocal: "Rescanned {agents} \xB7 {files} files \xB7 {sessions} sessions \u2014 sign in to upload",
1274
+ syncFailed: "Sync failed: {message}",
1251
1275
  sessions: "{n} sessions",
1252
1276
  files: "{n} files",
1253
1277
  noData: "No usage found yet. Run Codex and this will fill in.",
1254
1278
  version: "v{version}",
1279
+ // dev builds
1280
+ devBuild: "DEV",
1281
+ devBuildTitle: "Local build \u2014 uploading to {url} (dev environment). Config: {dir}",
1282
+ devBuildMenu: "Dev build \u2192 {url}",
1255
1283
  justNow: "just now",
1256
1284
  never: "never",
1257
1285
  local: "Local",
@@ -1279,6 +1307,7 @@ Commands:
1279
1307
  login Connect this device to your dashboard (--dashboard <url>)
1280
1308
  logout Disconnect this device
1281
1309
  status Print today's usage, live session and rate limits
1310
+ sync Rescan every agent and re-upload this device's full history (calibrate)
1282
1311
  paths Show detected session directories (Codex, pi, OpenCode, Cline/Roo/Kilo, Hermes, custom)
1283
1312
  update Install the newest published version (--check only reports)
1284
1313
  config get|set Read or change settings (config set uploadIntervalSec 30, config set sources.pi false)
@@ -1333,7 +1362,15 @@ Options:
1333
1362
  cliUpdateRunning: "Running: {command}",
1334
1363
  cliUpdateDone: "Updated to {version}. Restart the menu bar app (or rerun the CLI) to use it.",
1335
1364
  cliUpdateFailed: "Update failed (exit {code}). Run it yourself: {command}",
1336
- cliUpdateCheckFailed: "Could not reach the npm registry: {message}"
1365
+ cliUpdateCheckFailed: "Could not reach the npm registry: {message}",
1366
+ cliSyncStart: "Full sync: rescanning every agent on this device\u2026",
1367
+ cliSyncScanned: " scanned {files} files in {roots} directories ({agents})",
1368
+ cliSyncUploaded: " re-uploaded {buckets} hour buckets and {sessions} sessions",
1369
+ cliSyncLocal: " not signed in \u2014 nothing uploaded (run `codex-tracker login` first)",
1370
+ cliSyncDone: "Sync complete in {seconds}s \u2014 {sessions} sessions, today {tokens} tok \xB7 {cost}",
1371
+ cliSyncFailed: "Sync failed: {message}",
1372
+ cliChannelDev: "Dev build \u2014 dashboard {url} \xB7 config {dir}",
1373
+ cliUpdateDevBuild: "This is a local dev build; `update` would install the published package over it. Run `pnpm build` in the repo instead."
1337
1374
  };
1338
1375
 
1339
1376
  // src/i18n/zh.ts
@@ -1381,6 +1418,7 @@ var zh = {
1381
1418
  estimatedPricingHint: "\u4EF7\u683C\u4E3A\u4F30\u7B97\u503C\uFF1A\u8BE5\u6A21\u578B\u4E0D\u5728\u4EF7\u76EE\u8868\u4E2D",
1382
1419
  other: "\u5176\u4ED6",
1383
1420
  lastUpload: "\u4E0A\u6B21\u4E0A\u4F20 {time}",
1421
+ lastSync: "\u4E0A\u6B21\u5B8C\u6574\u540C\u6B65 {time}",
1384
1422
  neverUploaded: "\u5C1A\u672A\u4E0A\u4F20",
1385
1423
  uploadError: "\u4E0A\u4F20\u5931\u8D25\uFF1A{message}",
1386
1424
  allDevicesToday: "\u6240\u6709\u8BBE\u5907\u4ECA\u65E5",
@@ -1399,10 +1437,26 @@ var zh = {
1399
1437
  launchAtLogin: "\u5F00\u673A\u81EA\u542F\u52A8",
1400
1438
  refresh: "\u5237\u65B0",
1401
1439
  quit: "\u9000\u51FA",
1440
+ // 完整同步
1441
+ syncNow: "\u7ACB\u5373\u540C\u6B65",
1442
+ syncing: "\u540C\u6B65\u4E2D\u2026",
1443
+ 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",
1444
+ syncScanning: "\u6B63\u5728\u91CD\u65B0\u626B\u63CF\u6240\u6709\u667A\u80FD\u4F53\u7684\u4F1A\u8BDD\u8BB0\u5F55\u2026",
1445
+ syncComputing: "\u6B63\u5728\u91CD\u65B0\u8BA1\u7B97 token \u7528\u91CF\u2026",
1446
+ syncUploading: "\u6B63\u5728\u91CD\u65B0\u4E0A\u4F20\u672C\u8BBE\u5907\u7684\u5386\u53F2\u6570\u636E\u2026",
1447
+ syncDownloading: "\u6B63\u5728\u62C9\u53D6\u5176\u4ED6\u8BBE\u5907\u7684\u6570\u636E\u2026",
1448
+ syncLimits: "\u6B63\u5728\u5237\u65B0\u9650\u989D\u2026",
1449
+ 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",
1450
+ syncDoneLocal: "\u5DF2\u91CD\u65B0\u626B\u63CF {agents} \xB7 {files} \u4E2A\u6587\u4EF6 \xB7 {sessions} \u4E2A\u4F1A\u8BDD \u2014 \u767B\u5F55\u540E\u53EF\u4E0A\u4F20",
1451
+ syncFailed: "\u540C\u6B65\u5931\u8D25\uFF1A{message}",
1402
1452
  sessions: "{n} \u4E2A\u4F1A\u8BDD",
1403
1453
  files: "{n} \u4E2A\u6587\u4EF6",
1404
1454
  noData: "\u8FD8\u6CA1\u6709\u7528\u91CF\u6570\u636E\u3002\u8FD0\u884C Codex \u540E\u8FD9\u91CC\u4F1A\u81EA\u52A8\u66F4\u65B0\u3002",
1405
1455
  version: "v{version}",
1456
+ // 开发版
1457
+ devBuild: "\u5F00\u53D1\u7248",
1458
+ 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}",
1459
+ devBuildMenu: "\u5F00\u53D1\u7248 \u2192 {url}",
1406
1460
  justNow: "\u521A\u521A",
1407
1461
  never: "\u4ECE\u672A",
1408
1462
  local: "\u672C\u673A",
@@ -1428,6 +1482,7 @@ var zh = {
1428
1482
  login \u5C06\u672C\u8BBE\u5907\u8FDE\u63A5\u5230\u4EEA\u8868\u76D8\uFF08--dashboard <url>\uFF09
1429
1483
  logout \u65AD\u5F00\u672C\u8BBE\u5907
1430
1484
  status \u6253\u5370\u4ECA\u65E5\u7528\u91CF\u3001\u5B9E\u65F6\u4F1A\u8BDD\u4E0E\u9650\u989D
1485
+ 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
1431
1486
  paths \u663E\u793A\u68C0\u6D4B\u5230\u7684\u4F1A\u8BDD\u76EE\u5F55\uFF08Codex\u3001pi\u3001OpenCode\u3001Cline/Roo/Kilo\u3001Hermes\u3001\u81EA\u5B9A\u4E49\uFF09
1432
1487
  update \u5B89\u88C5\u6700\u65B0\u53D1\u5E03\u7248\u672C\uFF08--check \u53EA\u68C0\u67E5\u4E0D\u5B89\u88C5\uFF09
1433
1488
  config get|set \u8BFB\u53D6\u6216\u4FEE\u6539\u8BBE\u7F6E\uFF08config set uploadIntervalSec 30\uFF0Cconfig set sources.pi false\uFF09
@@ -1482,7 +1537,15 @@ var zh = {
1482
1537
  cliUpdateRunning: "\u6B63\u5728\u6267\u884C\uFF1A{command}",
1483
1538
  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",
1484
1539
  cliUpdateFailed: "\u66F4\u65B0\u5931\u8D25\uFF08\u9000\u51FA\u7801 {code}\uFF09\u3002\u8BF7\u624B\u52A8\u6267\u884C\uFF1A{command}",
1485
- cliUpdateCheckFailed: "\u65E0\u6CD5\u8BBF\u95EE npm registry\uFF1A{message}"
1540
+ cliUpdateCheckFailed: "\u65E0\u6CD5\u8BBF\u95EE npm registry\uFF1A{message}",
1541
+ cliSyncStart: "\u5B8C\u6574\u540C\u6B65\uFF1A\u6B63\u5728\u91CD\u65B0\u626B\u63CF\u672C\u8BBE\u5907\u4E0A\u7684\u6240\u6709\u667A\u80FD\u4F53\u2026",
1542
+ cliSyncScanned: " \u5DF2\u626B\u63CF {roots} \u4E2A\u76EE\u5F55\u4E2D\u7684 {files} \u4E2A\u6587\u4EF6\uFF08{agents}\uFF09",
1543
+ cliSyncUploaded: " \u5DF2\u91CD\u65B0\u4E0A\u4F20 {buckets} \u4E2A\u5C0F\u65F6\u6BB5\u4E0E {sessions} \u4E2A\u4F1A\u8BDD",
1544
+ cliSyncLocal: " \u672A\u767B\u5F55 \u2014 \u6CA1\u6709\u4E0A\u4F20\u4EFB\u4F55\u6570\u636E\uFF08\u8BF7\u5148\u8FD0\u884C `codex-tracker login`\uFF09",
1545
+ cliSyncDone: "\u540C\u6B65\u5B8C\u6210\uFF0C\u7528\u65F6 {seconds} \u79D2 \u2014 {sessions} \u4E2A\u4F1A\u8BDD\uFF0C\u4ECA\u65E5 {tokens} tok \xB7 {cost}",
1546
+ cliSyncFailed: "\u540C\u6B65\u5931\u8D25\uFF1A{message}",
1547
+ cliChannelDev: "\u5F00\u53D1\u7248 \u2014\u2014 \u4EEA\u8868\u76D8 {url} \xB7 \u914D\u7F6E\u76EE\u5F55 {dir}",
1548
+ 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"
1486
1549
  };
1487
1550
 
1488
1551
  // src/i18n/index.ts
@@ -2289,6 +2352,15 @@ var SessionStore = class {
2289
2352
  onChange = null;
2290
2353
  debounceTimer = null;
2291
2354
  sessionCache = null;
2355
+ /**
2356
+ * Forget the parsed-file index so the next deep refresh re-reads and re-parses every transcript.
2357
+ * Used by the full sync: files are otherwise skipped while their size and mtime are unchanged, which
2358
+ * would keep stale numbers around after a parser or pricing change.
2359
+ */
2360
+ reset() {
2361
+ this.files.clear();
2362
+ this.sessionCache = null;
2363
+ }
2292
2364
  async refreshDeep() {
2293
2365
  const o = this.getOptions();
2294
2366
  this.roots = discoverSessionRoots({ extraSessionDirs: o.extraSessionDirs, sources: o.sources });
@@ -8847,11 +8919,25 @@ var Uploader = class {
8847
8919
  throw err;
8848
8920
  }
8849
8921
  }
8850
- /** Upload changed hour buckets and session summaries. Returns counts of pushed items. */
8851
- async pushAll(buckets, sessions, sessionCosts) {
8922
+ /** Wait for an in-flight incremental push to finish so a full sync isn't skipped by the guard. */
8923
+ async awaitIdle(timeoutMs = 15e3) {
8924
+ const deadline = Date.now() + timeoutMs;
8925
+ while (this.inFlight && Date.now() < deadline) await new Promise((r) => setTimeout(r, 100));
8926
+ }
8927
+ /**
8928
+ * Upload changed hour buckets and session summaries. Returns counts of pushed items.
8929
+ * With `full`, the record of what was already pushed is dropped first so *everything* is re-sent —
8930
+ * that is what recalibrates this device's totals on the dashboard after a parser or pricing change.
8931
+ */
8932
+ async pushAll(buckets, sessions, sessionCosts, opts = {}) {
8933
+ if (opts.full) await this.awaitIdle();
8852
8934
  if (this.inFlight) return { buckets: 0, sessions: 0 };
8853
8935
  this.inFlight = true;
8854
8936
  try {
8937
+ if (opts.full) {
8938
+ this.state.pushedBuckets = {};
8939
+ this.state.pushedSessions = {};
8940
+ }
8855
8941
  const changed = [];
8856
8942
  const hashes = /* @__PURE__ */ new Map();
8857
8943
  for (const b of buckets.sort((a, b2) => a.hourStart - b2.hourStart)) {
@@ -9061,12 +9147,6 @@ async function fetchLiveRateLimits(appVersion, timeoutMs = 1e4) {
9061
9147
  var import_node_fs8 = __toESM(require("node:fs"));
9062
9148
  var import_node_path10 = __toESM(require("node:path"));
9063
9149
  var import_node_child_process3 = require("node:child_process");
9064
-
9065
- // src/version.ts
9066
- var APP_VERSION = true ? "0.2.0" : "0.0.0-dev";
9067
- var APP_NAME = "Codex Tracker";
9068
-
9069
- // src/core/update.ts
9070
9150
  var NPM_PACKAGE = "codex-token-tracker";
9071
9151
  var CHECK_TTL_MS = 6 * 60 * 60 * 1e3;
9072
9152
  var REQUEST_TIMEOUT_MS = 8e3;
@@ -9090,7 +9170,7 @@ function compareVersions(a, b) {
9090
9170
  return A.pre < B.pre ? -1 : 1;
9091
9171
  }
9092
9172
  function isDevBuild(version2) {
9093
- return version2.startsWith("0.0.0");
9173
+ return IS_DEV_BUILD || version2.startsWith("0.0.0");
9094
9174
  }
9095
9175
  function registryBase() {
9096
9176
  const r = process.env.CODEX_TRACKER_REGISTRY || process.env.npm_config_registry || process.env.NPM_CONFIG_REGISTRY || "https://registry.npmjs.org";
@@ -9233,6 +9313,7 @@ var REMOTE_MS = 6e4;
9233
9313
  var LIVE_LIMITS_DEBOUNCE_MS = 1e4;
9234
9314
  var LIVE_LIMITS_MIN_GAP_MS = 2e4;
9235
9315
  var UPDATE_CHECK_MS = 6 * 60 * 60 * 1e3;
9316
+ var SYNC_BANNER_MS = 25e3;
9236
9317
  var Engine = class extends import_node_events.EventEmitter {
9237
9318
  constructor(opts) {
9238
9319
  super();
@@ -9272,6 +9353,13 @@ var Engine = class extends import_node_events.EventEmitter {
9272
9353
  update = null;
9273
9354
  updateStatus = "idle";
9274
9355
  updateLog = null;
9356
+ syncStatus = "idle";
9357
+ syncPhase = null;
9358
+ syncStartedAt = null;
9359
+ syncFinishedAt = null;
9360
+ syncError = null;
9361
+ syncResult = null;
9362
+ syncBannerTimer = null;
9275
9363
  get heatmapWeeks() {
9276
9364
  return this.opts.heatmapWeeks ?? 16;
9277
9365
  }
@@ -9322,6 +9410,8 @@ var Engine = class extends import_node_events.EventEmitter {
9322
9410
  this.timers = [];
9323
9411
  if (this.liveLimitsTimer) clearTimeout(this.liveLimitsTimer);
9324
9412
  this.liveLimitsTimer = null;
9413
+ if (this.syncBannerTimer) clearTimeout(this.syncBannerTimer);
9414
+ this.syncBannerTimer = null;
9325
9415
  this.store.stopWatching();
9326
9416
  this.pending?.abort.abort();
9327
9417
  }
@@ -9425,6 +9515,112 @@ var Engine = class extends import_node_events.EventEmitter {
9425
9515
  await this.uploader.fetchRemote(this.heatmapWeeks);
9426
9516
  this.recompute();
9427
9517
  }
9518
+ /**
9519
+ * Full sync ("calibrate this device"). Unlike the periodic incremental upload this deliberately
9520
+ * throws away every cache on the way:
9521
+ * 1. re-read the config so sources enabled since start-up are picked up,
9522
+ * 2. drop the parsed-file index and re-discover + re-parse every transcript of every agent
9523
+ * (Codex plus the agents running on the Codex subscription: pi, OpenCode, Cline/Roo/Kilo,
9524
+ * Hermes and any custom `extraSessionDirs`),
9525
+ * 3. recompute the aggregates with the current pricing table,
9526
+ * 4. re-upload *everything* — not just what changed — so the dashboard's totals for this device
9527
+ * are replaced by the freshly computed ones,
9528
+ * 5. pull the other devices' rows and the live rate limits back down.
9529
+ *
9530
+ * Returns null when a sync is already running. Never throws: failures land in the returned state.
9531
+ */
9532
+ async syncNow() {
9533
+ if (this.syncStatus === "running") return null;
9534
+ if (this.syncBannerTimer) {
9535
+ clearTimeout(this.syncBannerTimer);
9536
+ this.syncBannerTimer = null;
9537
+ }
9538
+ const startedAt = Date.now();
9539
+ this.syncStatus = "running";
9540
+ this.syncPhase = null;
9541
+ this.syncStartedAt = startedAt;
9542
+ this.syncFinishedAt = null;
9543
+ this.syncError = null;
9544
+ const phase = (p) => {
9545
+ this.syncPhase = p;
9546
+ this.emitSnapshot();
9547
+ };
9548
+ try {
9549
+ phase("scanning");
9550
+ this.reloadConfig();
9551
+ this.store.reset();
9552
+ await this.store.refreshDeep();
9553
+ phase("computing");
9554
+ this.recompute();
9555
+ let uploadedBuckets = 0;
9556
+ let uploadedSessions = 0;
9557
+ const uploaded = this.opts.upload && this.signedIn;
9558
+ if (uploaded) {
9559
+ phase("uploading");
9560
+ const r = await this.uploader.pushAll(this.stats.buckets, this.stats.sessions, this.stats.sessionCosts, { full: true });
9561
+ uploadedBuckets = r.buckets;
9562
+ uploadedSessions = r.sessions;
9563
+ await this.heartbeatNow().catch(() => {
9564
+ });
9565
+ phase("downloading");
9566
+ await this.fetchRemoteNow();
9567
+ }
9568
+ phase("limits");
9569
+ await this.refreshLiveLimits(true);
9570
+ const finishedAt = Date.now();
9571
+ this.syncResult = {
9572
+ startedAt,
9573
+ finishedAt,
9574
+ durationMs: finishedAt - startedAt,
9575
+ files: this.store.fileCount,
9576
+ sessions: this.store.sessions().length,
9577
+ roots: this.store.roots.length,
9578
+ agents: [...new Set(this.store.roots.map((r) => r.agent))].sort(),
9579
+ uploadedBuckets,
9580
+ uploadedSessions,
9581
+ uploaded
9582
+ };
9583
+ this.syncStatus = "done";
9584
+ this.syncFinishedAt = finishedAt;
9585
+ this.opts.log?.(
9586
+ `sync: ${this.syncResult.files} files, ${this.syncResult.sessions} sessions, ${uploadedBuckets} buckets / ${uploadedSessions} sessions uploaded in ${this.syncResult.durationMs} ms`
9587
+ );
9588
+ return this.syncResult;
9589
+ } catch (err) {
9590
+ this.syncStatus = "error";
9591
+ this.syncFinishedAt = Date.now();
9592
+ this.syncError = err instanceof SignedOutError ? "not signed in" : errorMessage(err);
9593
+ this.opts.log?.(`sync failed: ${this.syncError}`);
9594
+ return null;
9595
+ } finally {
9596
+ this.syncPhase = null;
9597
+ this.emitSnapshot();
9598
+ this.scheduleSyncBannerClear();
9599
+ }
9600
+ }
9601
+ /** Return the sync banner to "idle" a little after it finished, keeping `last` for the footer. */
9602
+ scheduleSyncBannerClear() {
9603
+ if (this.opts.watch === false) return;
9604
+ if (this.syncBannerTimer) clearTimeout(this.syncBannerTimer);
9605
+ this.syncBannerTimer = setTimeout(() => {
9606
+ this.syncBannerTimer = null;
9607
+ if (this.syncStatus === "done" || this.syncStatus === "error") {
9608
+ this.syncStatus = "idle";
9609
+ this.emitSnapshot();
9610
+ }
9611
+ }, SYNC_BANNER_MS);
9612
+ this.syncBannerTimer.unref?.();
9613
+ }
9614
+ syncState() {
9615
+ return {
9616
+ status: this.syncStatus,
9617
+ phase: this.syncPhase,
9618
+ startedAt: this.syncStartedAt,
9619
+ finishedAt: this.syncFinishedAt,
9620
+ error: this.syncError,
9621
+ last: this.syncResult
9622
+ };
9623
+ }
9428
9624
  /**
9429
9625
  * Ask the npm registry for the newest published version (cached for 6 h unless `force`).
9430
9626
  * Best-effort: a failure is recorded on the snapshot, never thrown.
@@ -9542,6 +9738,7 @@ ${r.output}`.trim();
9542
9738
  const sessions = this.store.sessions();
9543
9739
  return {
9544
9740
  version: APP_VERSION,
9741
+ channel: APP_CHANNEL,
9545
9742
  generatedAt: Date.now(),
9546
9743
  language: this.language(),
9547
9744
  languageSetting: this.config.language,
@@ -9560,6 +9757,7 @@ ${r.output}`.trim();
9560
9757
  rateLimitsError: this.config.liveRateLimits ? this.liveLimitsError : null,
9561
9758
  rateLimitsUpdatedAt: this.liveLimits ? this.liveLimitsAt : s.logRateLimits?.observedAt ?? null,
9562
9759
  update: this.updateState(),
9760
+ sync: this.syncState(),
9563
9761
  modelsToday: s.modelsToday,
9564
9762
  modelsMonth: s.modelsMonth,
9565
9763
  byAgentToday: s.byAgentToday,
@@ -9576,6 +9774,7 @@ ${r.output}`.trim();
9576
9774
  },
9577
9775
  sessionDirs: SessionStore.rootDirs(this.store.roots),
9578
9776
  sessionRoots: this.store.roots.map((r) => ({ dir: r.dir, agent: r.agent, format: r.format, origin: r.origin })),
9777
+ configDir: configDir(),
9579
9778
  launchAtLogin: this.config.launchAtLogin,
9580
9779
  trayTitle: this.config.trayTitle
9581
9780
  };
@@ -9622,14 +9821,15 @@ function updateTray(s) {
9622
9821
  mb.tray.setToolTip(`${APP_NAME} \u2014 ${t(L, "today")}: ${formatTokens(s.today.usage.total)} \xB7 ${formatUSD(s.today.cost)}${live}`);
9623
9822
  if (isMac) mb.tray.setTitle(trayText(s), { fontType: "monospacedDigit" });
9624
9823
  }
9625
- var launchAgentPath = import_node_path11.default.join(import_node_os7.default.homedir(), "Library", "LaunchAgents", "dev.codex-tracker.menubar.plist");
9824
+ var launchAgentLabel = IS_DEV_BUILD ? "dev.codex-tracker.menubar.dev" : "dev.codex-tracker.menubar";
9825
+ var launchAgentPath = import_node_path11.default.join(import_node_os7.default.homedir(), "Library", "LaunchAgents", `${launchAgentLabel}.plist`);
9626
9826
  function setLaunchAtLogin(enabled) {
9627
9827
  if (isMac) {
9628
9828
  if (enabled) {
9629
9829
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
9630
9830
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
9631
9831
  <plist version="1.0"><dict>
9632
- <key>Label</key><string>dev.codex-tracker.menubar</string>
9832
+ <key>Label</key><string>${launchAgentLabel}</string>
9633
9833
  <key>ProgramArguments</key><array><string>${process.execPath}</string><string>${import_node_path11.default.join(__dirname, "main.js")}</string></array>
9634
9834
  <key>RunAtLoad</key><true/>
9635
9835
  <key>KeepAlive</key><false/>
@@ -9664,6 +9864,7 @@ function buildMenu(s) {
9664
9864
  });
9665
9865
  const template = [
9666
9866
  { label: `${APP_NAME} ${t(L, "version", { version: APP_VERSION })}`, enabled: false },
9867
+ ...IS_DEV_BUILD ? [{ label: t(L, "devBuildMenu", { url: s.auth.dashboardUrl }), enabled: false }] : [],
9667
9868
  {
9668
9869
  label: s.auth.status === "signedIn" ? t(L, "signedInAs", { name: s.auth.user?.name || s.auth.user?.email || "?" }) : t(L, "signedOut"),
9669
9870
  enabled: false
@@ -9688,6 +9889,11 @@ function buildMenu(s) {
9688
9889
  }
9689
9890
  ] : [],
9690
9891
  { label: t(L, "refresh"), click: () => void engine?.refresh(true) },
9892
+ {
9893
+ label: s.sync.status === "running" ? t(L, "syncing") : t(L, "syncNow"),
9894
+ enabled: s.sync.status !== "running",
9895
+ click: () => void engine?.syncNow()
9896
+ },
9691
9897
  ...s.update ? [
9692
9898
  s.update.available ? { label: t(L, "updateAvailable", { version: s.update.latest ?? "?" }), click: () => void engine?.installUpdate() } : { label: t(L, "checkForUpdates"), click: () => void engine?.checkUpdate(true) }
9693
9899
  ] : [],
@@ -9704,10 +9910,10 @@ async function startLogin() {
9704
9910
  log2("login failed", errorMessage(err));
9705
9911
  }
9706
9912
  }
9913
+ import_electron.app.setName(APP_NAME);
9707
9914
  if (!import_electron.app.requestSingleInstanceLock()) {
9708
9915
  import_electron.app.quit();
9709
9916
  } else {
9710
- import_electron.app.setName(APP_NAME);
9711
9917
  import_electron.app.on("second-instance", () => mb?.showWindow());
9712
9918
  import_electron.app.on("window-all-closed", () => {
9713
9919
  });
@@ -9747,6 +9953,7 @@ if (!import_electron.app.requestSingleInstanceLock()) {
9747
9953
  import_electron.ipcMain.handle("auth:cancel", () => engine?.cancelLogin());
9748
9954
  import_electron.ipcMain.handle("auth:logout", () => engine?.logout());
9749
9955
  import_electron.ipcMain.handle("refresh", () => engine?.refresh(true).then(() => void 0));
9956
+ import_electron.ipcMain.handle("sync:now", () => engine?.syncNow().then(() => void 0));
9750
9957
  import_electron.ipcMain.handle("update:check", () => engine?.checkUpdate(true).then(() => void 0));
9751
9958
  import_electron.ipcMain.handle("update:install", () => engine?.installUpdate().then(() => void 0));
9752
9959
  import_electron.ipcMain.handle("quit", () => import_electron.app.quit());
package/dist/preload.js CHANGED
@@ -16,6 +16,7 @@ var bridge = {
16
16
  cancelLogin: () => import_electron.ipcRenderer.invoke("auth:cancel"),
17
17
  logout: () => import_electron.ipcRenderer.invoke("auth:logout"),
18
18
  refresh: () => import_electron.ipcRenderer.invoke("refresh"),
19
+ syncNow: () => import_electron.ipcRenderer.invoke("sync:now"),
19
20
  checkUpdate: () => import_electron.ipcRenderer.invoke("update:check"),
20
21
  installUpdate: () => import_electron.ipcRenderer.invoke("update:install"),
21
22
  quit: () => import_electron.ipcRenderer.invoke("quit")