tokelytics 0.3.4 → 0.3.6

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
@@ -9,7 +9,7 @@ prompts or responses.
9
9
 
10
10
  ```
11
11
  npx tokelytics@latest login # opens your browser to approve — no setup, no keys
12
- npx tokelytics@latest watch # local live updates; cloud backup every 30m
12
+ npx tokelytics@latest watch # one machine-wide watcher; cloud backup every 30m
13
13
  ```
14
14
 
15
15
  Keep `@latest` in install and onboarding commands. Agents older than 0.3.1
@@ -85,9 +85,20 @@ function mergeDashboardSnapshot(previous, turns, limits, device, now = /* @__PUR
85
85
  const next = structuredClone(previous ?? emptyDashboardSnapshot(now));
86
86
  next.version = 2;
87
87
  if (turns.length > 0) {
88
- next.recentTurns = mergeRecentTurns(next.recentTurns, turns);
89
- next.sessions = mergeSessions(next.sessions, turns);
90
- next.rollups = mergeRollups(next.rollups, turns, now);
88
+ const previousById = new Map(next.recentTurns.map((turn) => [turn.turnId, turn]));
89
+ const replacements = /* @__PURE__ */ new Map();
90
+ const changedTurns = turns.filter((turn) => {
91
+ const prior = previousById.get(turn.turnId);
92
+ if (!prior)
93
+ return true;
94
+ if (sameTurn(prior, turn))
95
+ return false;
96
+ replacements.set(turn.turnId, prior);
97
+ return true;
98
+ });
99
+ next.recentTurns = mergeRecentTurns(next.recentTurns, changedTurns);
100
+ next.sessions = mergeSessions(next.sessions, changedTurns, replacements);
101
+ next.rollups = mergeRollups(next.rollups, changedTurns, now, replacements);
91
102
  }
92
103
  if (limits.length > 0)
93
104
  next.limits = mergeLimits(next.limits, limits);
@@ -130,6 +141,9 @@ function mergeRecentTurns(existing, incoming) {
130
141
  byId.set(turn.turnId, turn);
131
142
  return [...byId.values()].sort((a, b) => a.ts.localeCompare(b.ts)).slice(-SNAPSHOT_RECENT_TURNS);
132
143
  }
144
+ function sameTurn(left, right) {
145
+ return JSON.stringify(left) === JSON.stringify(right);
146
+ }
133
147
  function sanitizeTurn(turn) {
134
148
  return {
135
149
  ...turn,
@@ -156,17 +170,30 @@ function safePath(value) {
156
170
  const parts = normalized.split("/").filter((part) => part && part !== "." && part !== ".." && !/^[A-Za-z]:$/.test(part));
157
171
  return parts.slice(-3).join("/").slice(-200);
158
172
  }
159
- function mergeSessions(existing, turns) {
173
+ function mergeSessions(existing, turns, replacements = /* @__PURE__ */ new Map()) {
160
174
  const byId = new Map(existing.map((session) => [session.sessionId, { ...session }]));
161
175
  for (const turn of turns) {
162
- const session = byId.get(turn.sessionId) ?? emptySession(turn);
163
- session.turnCount++;
164
- session.inputTokens += turn.inputTokens;
165
- session.outputTokens += turn.outputTokens;
166
- session.cacheReadTokens += turn.cacheReadTokens;
167
- session.cacheCreationTokens += turn.cacheCreationTokens;
168
- session.cachedInputTokens += turn.cachedInputTokens;
169
- session.reasoningTokens += turn.reasoningTokens;
176
+ const prior = replacements.get(turn.turnId);
177
+ if (prior)
178
+ applySessionTurn(byId, prior, -1);
179
+ applySessionTurn(byId, turn, 1);
180
+ }
181
+ return [...byId.values()].sort((a, b) => b.lastTs.localeCompare(a.lastTs)).slice(0, SNAPSHOT_SESSIONS);
182
+ }
183
+ function applySessionTurn(byId, turn, direction) {
184
+ const session = byId.get(turn.sessionId) ?? emptySession(turn);
185
+ session.turnCount += direction;
186
+ session.inputTokens = Math.max(0, session.inputTokens + direction * turn.inputTokens);
187
+ session.outputTokens = Math.max(0, session.outputTokens + direction * turn.outputTokens);
188
+ session.cacheReadTokens = Math.max(0, session.cacheReadTokens + direction * turn.cacheReadTokens);
189
+ session.cacheCreationTokens = Math.max(0, session.cacheCreationTokens + direction * turn.cacheCreationTokens);
190
+ session.cachedInputTokens = Math.max(0, session.cachedInputTokens + direction * turn.cachedInputTokens);
191
+ session.reasoningTokens = Math.max(0, session.reasoningTokens + direction * turn.reasoningTokens);
192
+ if (session.turnCount <= 0) {
193
+ byId.delete(turn.sessionId);
194
+ return;
195
+ }
196
+ if (direction > 0) {
170
197
  if (!session.firstTs || turn.ts < session.firstTs)
171
198
  session.firstTs = turn.ts;
172
199
  if (!session.lastTs || turn.ts > session.lastTs)
@@ -178,9 +205,8 @@ function mergeSessions(existing, turns) {
178
205
  if (turn.model && (!session.model || modelPriority(turn.model) > modelPriority(session.model))) {
179
206
  session.model = turn.model;
180
207
  }
181
- byId.set(turn.sessionId, session);
182
208
  }
183
- return [...byId.values()].sort((a, b) => b.lastTs.localeCompare(a.lastTs)).slice(0, SNAPSHOT_SESSIONS);
209
+ byId.set(turn.sessionId, session);
184
210
  }
185
211
  function emptySession(turn) {
186
212
  return {
@@ -200,48 +226,62 @@ function emptySession(turn) {
200
226
  reasoningTokens: 0
201
227
  };
202
228
  }
203
- function mergeRollups(existing, turns, now) {
229
+ function mergeRollups(existing, turns, now, replacements = /* @__PURE__ */ new Map()) {
204
230
  const byId = new Map(existing.map((rollup) => [rollup.id, structuredClone(rollup)]));
205
231
  for (const turn of turns) {
206
- const day = dayOf(turn.ts);
207
- if (!day)
208
- continue;
209
- const id = rollupId(turn.provider, day);
210
- const rollup = byId.get(id) ?? { id, provider: turn.provider, day, models: {}, hourly: [] };
211
- const model = turn.model || "unknown";
212
- const totals = rollup.models[model] ??= {
213
- model,
214
- turns: 0,
215
- inputTokens: 0,
216
- outputTokens: 0,
217
- cacheReadTokens: 0,
218
- cacheCreationTokens: 0,
219
- cachedInputTokens: 0,
220
- reasoningTokens: 0
221
- };
222
- totals.turns++;
223
- totals.inputTokens += turn.inputTokens;
224
- totals.outputTokens += turn.outputTokens;
225
- totals.cacheReadTokens += turn.cacheReadTokens;
226
- totals.cacheCreationTokens += turn.cacheCreationTokens;
227
- totals.cachedInputTokens += turn.cachedInputTokens;
228
- totals.reasoningTokens += turn.reasoningTokens;
229
- const hour = hourOf(turn.ts);
230
- if (hour !== null) {
231
- const bucket = rollup.hourly.find((item) => item.hour === hour) ?? { hour, output: 0, turns: 0 };
232
- bucket.output += turn.outputTokens;
233
- bucket.turns++;
234
- if (!rollup.hourly.some((item) => item.hour === hour))
235
- rollup.hourly.push(bucket);
236
- rollup.hourly.sort((a, b) => a.hour - b.hour);
237
- }
238
- byId.set(id, rollup);
232
+ const prior = replacements.get(turn.turnId);
233
+ if (prior)
234
+ applyRollupTurn(byId, prior, -1);
235
+ applyRollupTurn(byId, turn, 1);
239
236
  }
240
237
  const cutoff = new Date(now);
241
238
  cutoff.setUTCDate(cutoff.getUTCDate() - (SNAPSHOT_RETENTION_DAYS - 1));
242
239
  const cutoffDay = cutoff.toISOString().slice(0, 10);
243
240
  return [...byId.values()].filter((rollup) => rollup.day >= cutoffDay).sort((a, b) => a.day.localeCompare(b.day) || a.provider.localeCompare(b.provider));
244
241
  }
242
+ function applyRollupTurn(byId, turn, direction) {
243
+ const day = dayOf(turn.ts);
244
+ if (!day)
245
+ return;
246
+ const id = rollupId(turn.provider, day);
247
+ const rollup = byId.get(id) ?? { id, provider: turn.provider, day, models: {}, hourly: [] };
248
+ const model = turn.model || "unknown";
249
+ const totals = rollup.models[model] ??= {
250
+ model,
251
+ turns: 0,
252
+ inputTokens: 0,
253
+ outputTokens: 0,
254
+ cacheReadTokens: 0,
255
+ cacheCreationTokens: 0,
256
+ cachedInputTokens: 0,
257
+ reasoningTokens: 0
258
+ };
259
+ totals.turns += direction;
260
+ totals.inputTokens = Math.max(0, totals.inputTokens + direction * turn.inputTokens);
261
+ totals.outputTokens = Math.max(0, totals.outputTokens + direction * turn.outputTokens);
262
+ totals.cacheReadTokens = Math.max(0, totals.cacheReadTokens + direction * turn.cacheReadTokens);
263
+ totals.cacheCreationTokens = Math.max(0, totals.cacheCreationTokens + direction * turn.cacheCreationTokens);
264
+ totals.cachedInputTokens = Math.max(0, totals.cachedInputTokens + direction * turn.cachedInputTokens);
265
+ totals.reasoningTokens = Math.max(0, totals.reasoningTokens + direction * turn.reasoningTokens);
266
+ if (totals.turns <= 0)
267
+ delete rollup.models[model];
268
+ const hour = hourOf(turn.ts);
269
+ if (hour !== null) {
270
+ const bucket = rollup.hourly.find((item) => item.hour === hour) ?? { hour, output: 0, turns: 0 };
271
+ bucket.output = Math.max(0, bucket.output + direction * turn.outputTokens);
272
+ bucket.turns += direction;
273
+ if (bucket.turns <= 0) {
274
+ rollup.hourly = rollup.hourly.filter((item) => item.hour !== hour);
275
+ } else if (!rollup.hourly.some((item) => item.hour === hour)) {
276
+ rollup.hourly.push(bucket);
277
+ }
278
+ rollup.hourly.sort((a, b) => a.hour - b.hour);
279
+ }
280
+ if (Object.keys(rollup.models).length === 0)
281
+ byId.delete(id);
282
+ else
283
+ byId.set(id, rollup);
284
+ }
245
285
  function mergeLimits(existing, incoming) {
246
286
  const byProvider = new Map(existing.map((limit) => [limit.provider, limit]));
247
287
  for (const limit of incoming)
@@ -1014,13 +1054,34 @@ async function loadUpdateState() {
1014
1054
  async function saveUpdateState(state) {
1015
1055
  await writeJson(updatePath(), state);
1016
1056
  }
1017
- async function acquireWatchLock() {
1057
+ var AgentAlreadyRunningError = class extends Error {
1058
+ lock;
1059
+ constructor(lock, cause) {
1060
+ super(`Tokelytics is already running (PID ${lock.pid}).`, { cause });
1061
+ this.name = "AgentAlreadyRunningError";
1062
+ this.lock = lock;
1063
+ }
1064
+ };
1065
+ async function activeAgentLock() {
1066
+ const lock = await readJson(watchLockPath());
1067
+ if (!lock?.pid || !processIsAlive(lock.pid)) return null;
1068
+ return lock;
1069
+ }
1070
+ async function acquireWatchLock(owner = "watch") {
1018
1071
  await ensureDir();
1019
1072
  const file = watchLockPath();
1020
1073
  for (let attempt = 0; attempt < 2; attempt++) {
1021
1074
  try {
1022
1075
  const handle = await fs3.open(file, "wx");
1023
- await handle.writeFile(JSON.stringify({ pid: process.pid, startedAt: (/* @__PURE__ */ new Date()).toISOString() }), "utf-8");
1076
+ await handle.writeFile(
1077
+ JSON.stringify({
1078
+ pid: process.pid,
1079
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1080
+ owner,
1081
+ scope: "machine"
1082
+ }),
1083
+ "utf-8"
1084
+ );
1024
1085
  await handle.close();
1025
1086
  let released = false;
1026
1087
  return {
@@ -1036,7 +1097,7 @@ async function acquireWatchLock() {
1036
1097
  if (code !== "EEXIST") throw error;
1037
1098
  const lock = await readJson(file);
1038
1099
  if (lock?.pid && processIsAlive(lock.pid)) {
1039
- throw new Error(`Tokelytics is already running (PID ${lock.pid}).`, { cause: error });
1100
+ throw new AgentAlreadyRunningError(lock, error);
1040
1101
  }
1041
1102
  await fs3.rm(file, { force: true });
1042
1103
  }
@@ -1781,7 +1842,7 @@ var FirestoreSink = class {
1781
1842
  };
1782
1843
 
1783
1844
  // src/version.ts
1784
- var AGENT_VERSION = "0.3.4";
1845
+ var AGENT_VERSION = "0.3.6";
1785
1846
 
1786
1847
  // src/sync.ts
1787
1848
  async function runSync(connectors, sink, state, device, limitCollectors = [], options = {}) {
@@ -1796,15 +1857,11 @@ async function runSync(connectors, sink, state, device, limitCollectors = [], op
1796
1857
  let st = state;
1797
1858
  const collected = [];
1798
1859
  const limits = [];
1799
- const byProvider = {};
1800
1860
  for (const c of connectors) {
1801
1861
  const { turns: turns2, limits: nativeLimits2 = [], state: next } = await c.collect(st);
1802
1862
  st = next;
1803
1863
  if (refreshLimits) limits.push(...nativeLimits2);
1804
- for (const t of turns2) {
1805
- collected.push(t);
1806
- byProvider[t.provider] = (byProvider[t.provider] ?? 0) + 1;
1807
- }
1864
+ collected.push(...turns2);
1808
1865
  }
1809
1866
  if (refreshLimits) {
1810
1867
  const providerLimits = await Promise.all(
@@ -1820,7 +1877,16 @@ async function runSync(connectors, sink, state, device, limitCollectors = [], op
1820
1877
  }
1821
1878
  const unique = /* @__PURE__ */ new Map();
1822
1879
  for (const t of collected) unique.set(t.turnId, t);
1823
- const turns = [...unique.values()];
1880
+ const collectedTurns = [...unique.values()];
1881
+ const previousTurns = new Map((options.dashboard?.recentTurns ?? []).map((turn) => [turn.turnId, turn]));
1882
+ const turns = collectedTurns.filter((turn) => {
1883
+ const previous = previousTurns.get(turn.turnId);
1884
+ return !previous || !sameTurn(previous, turn);
1885
+ });
1886
+ const changedByProvider = {};
1887
+ for (const turn of turns) {
1888
+ changedByProvider[turn.provider] = (changedByProvider[turn.provider] ?? 0) + 1;
1889
+ }
1824
1890
  const nativeLimits = refreshLimits ? dedupeLimits(limits) : [];
1825
1891
  const changedLimits = nativeLimits.filter(
1826
1892
  (limit) => publication.limitFingerprints?.[limit.provider] !== limitFingerprint(limit)
@@ -1886,14 +1952,18 @@ async function runSync(connectors, sink, state, device, limitCollectors = [], op
1886
1952
  publicationError = error;
1887
1953
  }
1888
1954
  }
1955
+ const scheduledCloudWrite = publication.dashboardDirty && hasBudget && !options.suppressCloudPublish ? Number.isFinite(Date.parse(publication.lastCloudWriteAt ?? "")) ? new Date(
1956
+ Math.max(now.getTime(), Date.parse(publication.lastCloudWriteAt) + cloudSyncIntervalMs)
1957
+ ).toISOString() : now.toISOString() : void 0;
1889
1958
  return {
1890
1959
  processedTurns: turns.length,
1891
1960
  processedLimits: changedLimits.length,
1892
- byProvider,
1961
+ byProvider: changedByProvider,
1893
1962
  state: st,
1894
1963
  dashboard,
1895
1964
  publication,
1896
1965
  published,
1966
+ nextCloudWriteAt: scheduledCloudWrite,
1897
1967
  publicationError
1898
1968
  };
1899
1969
  }
@@ -1984,6 +2054,7 @@ async function createRunner() {
1984
2054
  byProvider: res.byProvider,
1985
2055
  published: res.published,
1986
2056
  cloudWritesToday: res.publication.cloudWritesToday ?? 0,
2057
+ nextCloudWriteAt: res.nextCloudWriteAt,
1987
2058
  publicationError: res.publicationError
1988
2059
  };
1989
2060
  }
@@ -3777,7 +3848,7 @@ function close(server) {
3777
3848
  }
3778
3849
 
3779
3850
  // src/watch.ts
3780
- var DEBOUNCE_MS = 1200;
3851
+ var TURN_SETTLE_MS = 2500;
3781
3852
  var LIMIT_REFRESH_MS = 6e4;
3782
3853
  var FALLBACK_SCAN_MS = 5e3;
3783
3854
  var DEVICE_HEARTBEAT_MS = 60 * 6e4;
@@ -3786,6 +3857,7 @@ var MAX_CLOUD_WRITES_PER_DAY = 16;
3786
3857
  var QUOTA_RETRY_MS = 15 * 6e4;
3787
3858
  var QUOTA_RETRY_JITTER_MS = 5 * 6e4;
3788
3859
  async function watch2(runner) {
3860
+ const logState = {};
3789
3861
  let localServer;
3790
3862
  try {
3791
3863
  localServer = await startLocalDashboardServer(() => runner.localSnapshot(), runner.uid);
@@ -3800,7 +3872,7 @@ async function watch2(runner) {
3800
3872
  deviceHeartbeatMs: DEVICE_HEARTBEAT_MS,
3801
3873
  cloudSyncIntervalMs: CLOUD_SYNC_INTERVAL_MS,
3802
3874
  maxCloudWritesPerDay: MAX_CLOUD_WRITES_PER_DAY
3803
- }, false);
3875
+ }, false, logState);
3804
3876
  const paths = runner.watchPaths();
3805
3877
  const watcher = esm_default.watch(paths, {
3806
3878
  ignoreInitial: true,
@@ -3814,7 +3886,12 @@ async function watch2(runner) {
3814
3886
  let running = false;
3815
3887
  let scheduledAt = 0;
3816
3888
  let cloudBlockedUntil = startupRetryAfter ? Date.now() + startupRetryAfter : 0;
3817
- const schedule = (request, delay = DEBOUNCE_MS) => {
3889
+ const takePending = () => {
3890
+ const request = pending;
3891
+ pending = void 0;
3892
+ return request;
3893
+ };
3894
+ const schedule = (request, delay = TURN_SETTLE_MS) => {
3818
3895
  pending = mergeRequests(pending, request);
3819
3896
  if (running) return;
3820
3897
  const target = Date.now() + delay;
@@ -3824,21 +3901,19 @@ async function watch2(runner) {
3824
3901
  timer = setTimeout(async () => {
3825
3902
  timer = void 0;
3826
3903
  scheduledAt = 0;
3827
- const next = pending;
3828
- pending = void 0;
3904
+ const next = takePending();
3829
3905
  if (!next) return;
3830
3906
  running = true;
3831
- const retryAfterMs = await safeSync(runner, next, Date.now() < cloudBlockedUntil);
3907
+ const retryAfterMs = await safeSync(runner, next, Date.now() < cloudBlockedUntil, logState);
3832
3908
  running = false;
3833
3909
  if (retryAfterMs) {
3834
3910
  cloudBlockedUntil = Date.now() + retryAfterMs;
3835
3911
  } else if (Date.now() >= cloudBlockedUntil) {
3836
3912
  cloudBlockedUntil = 0;
3837
3913
  }
3838
- if (pending) {
3839
- const queued = pending;
3840
- pending = void 0;
3841
- schedule(queued, 0);
3914
+ const queued = takePending();
3915
+ if (queued) {
3916
+ schedule(queued, queued.reason === "change" ? TURN_SETTLE_MS : 0);
3842
3917
  }
3843
3918
  }, Math.max(0, target - Date.now()));
3844
3919
  };
@@ -3878,21 +3953,37 @@ Press Ctrl+C to stop.`);
3878
3953
  process.once("SIGTERM", stop);
3879
3954
  });
3880
3955
  }
3881
- async function safeSync(runner, request, suppressCloudPublish) {
3956
+ async function safeSync(runner, request, suppressCloudPublish, logState) {
3882
3957
  try {
3883
3958
  const { reason, ...options } = request;
3884
- const { processedTurns, byProvider, published, cloudWritesToday, publicationError } = await runner.syncOnce({
3959
+ const {
3960
+ processedTurns,
3961
+ byProvider,
3962
+ published,
3963
+ cloudWritesToday,
3964
+ nextCloudWriteAt,
3965
+ publicationError
3966
+ } = await runner.syncOnce({
3885
3967
  ...options,
3886
3968
  suppressCloudPublish
3887
3969
  });
3888
3970
  if (processedTurns > 0) {
3889
3971
  const detail = Object.entries(byProvider).map(([p, n]) => `${p}:${n}`).join(" ");
3890
3972
  console.log(
3891
- `[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] processed ${processedTurns} turn(s) locally (${detail}); ` + (published ? `cloud snapshot published (${cloudWritesToday}/${MAX_CLOUD_WRITES_PER_DAY} today)` : "cloud backup queued")
3973
+ `[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] processed ${processedTurns} completed turn(s) locally (${detail}).`
3892
3974
  );
3893
3975
  } else if (reason === "startup") {
3976
+ console.log(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] local snapshot ready.`);
3977
+ }
3978
+ if (published) {
3979
+ logState.cloudSchedule = void 0;
3980
+ console.log(
3981
+ `[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] cloud snapshot published (${cloudWritesToday}/${MAX_CLOUD_WRITES_PER_DAY} writes today).`
3982
+ );
3983
+ } else if (nextCloudWriteAt && nextCloudWriteAt !== logState.cloudSchedule) {
3984
+ logState.cloudSchedule = nextCloudWriteAt;
3894
3985
  console.log(
3895
- `[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] local snapshot ready; ` + (published ? `cloud snapshot published (${cloudWritesToday}/${MAX_CLOUD_WRITES_PER_DAY} today)` : "cloud backup queued")
3986
+ `Cloud backup scheduled for ${new Date(nextCloudWriteAt).toLocaleTimeString()} (new turns will be combined into this snapshot).`
3896
3987
  );
3897
3988
  }
3898
3989
  if (publicationError) return cloudRetryDelay(publicationError);
@@ -3979,7 +4070,7 @@ var USAGE = `Tokelytics agent
3979
4070
  Usage:
3980
4071
  tokelytics login Sign in by approving in your browser
3981
4072
  tokelytics sync Run one incremental sync to your dashboard
3982
- tokelytics watch Watch usage and refresh the cloud snapshot
4073
+ tokelytics watch Watch Claude Code and Codex usage across this machine
3983
4074
  tokelytics status Show current sign-in and device
3984
4075
  tokelytics logout Forget stored credentials
3985
4076
  `;
@@ -4001,7 +4092,7 @@ async function main(argv) {
4001
4092
  case "sync": {
4002
4093
  const update = await agentUpdateMessage();
4003
4094
  if (update) console.warn(update);
4004
- const lock = await acquireWatchLock();
4095
+ const lock = await acquireWatchLock("sync");
4005
4096
  try {
4006
4097
  const runner = await createRunner();
4007
4098
  const { processedTurns, processedLimits, byProvider, publicationError } = await runner.syncOnce();
@@ -4014,7 +4105,17 @@ async function main(argv) {
4014
4105
  return 0;
4015
4106
  }
4016
4107
  case "watch": {
4017
- const lock = await acquireWatchLock();
4108
+ let lock;
4109
+ try {
4110
+ lock = await acquireWatchLock("watch");
4111
+ } catch (err) {
4112
+ if (err instanceof AgentAlreadyRunningError && err.lock.owner !== "sync") {
4113
+ console.log(`Tokelytics is already watching this machine (PID ${err.lock.pid}).`);
4114
+ console.log("Claude Code and Codex usage from every folder and repository is already covered.");
4115
+ return 0;
4116
+ }
4117
+ throw err;
4118
+ }
4018
4119
  try {
4019
4120
  const update = await agentUpdateMessage();
4020
4121
  if (update) console.warn(update);
@@ -4028,7 +4129,15 @@ async function main(argv) {
4028
4129
  case "status": {
4029
4130
  const creds = await loadCredentials();
4030
4131
  const state = await loadState();
4132
+ const lock = await activeAgentLock();
4031
4133
  console.log(`Agent version: ${AGENT_VERSION}`);
4134
+ if (lock && lock.owner !== "sync") {
4135
+ console.log(`Watcher: active machine-wide (PID ${lock.pid}); all folders and repositories are covered.`);
4136
+ } else if (lock?.owner === "sync") {
4137
+ console.log(`Watcher: sync currently in progress (PID ${lock.pid}).`);
4138
+ } else {
4139
+ console.log('Watcher: not running. Run "tokelytics watch" once on this machine.');
4140
+ }
4032
4141
  if (!creds) {
4033
4142
  console.log('Not signed in. Run "tokelytics login".');
4034
4143
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokelytics",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Tokelytics sync agent — streams local AI CLI usage logs (Claude Code, Codex) to your Tokelytics dashboard.",
5
5
  "license": "MIT",
6
6
  "type": "module",