coding-usage-bar 0.4.0 → 0.4.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/burn.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  import { BurnAnalysis, BurnProfile, ProviderUsage } from "./types.js";
2
2
  export declare function estimateConversionRate(samples: ProviderUsage[]): number | null;
3
+ export declare function analyzeUsageWithConversionRate(usage: ProviderUsage, conversionRate: number | null, profile?: BurnProfile, now?: Date): BurnAnalysis;
3
4
  export declare function analyzeUsage(usage: ProviderUsage, samples: ProviderUsage[], profile?: BurnProfile, now?: Date): BurnAnalysis;
package/dist/burn.js CHANGED
@@ -74,7 +74,7 @@ function messageForState(provider, state, fiveUsed, target) {
74
74
  }
75
75
  return `${label} burn pace is on track (${round(fiveUsed)}%, target ${round(target.min)}%-${round(target.max)}%).`;
76
76
  }
77
- export function analyzeUsage(usage, samples, profile = "low", now = new Date()) {
77
+ function analyzeUsageWithRateResolver(usage, resolveConversionRate, profile = "low", now = new Date()) {
78
78
  const fiveHour = findWindow(usage, "five_hour");
79
79
  const sevenDay = findWindow(usage, "seven_day");
80
80
  if (usage.balance && usage.windows.length === 0) {
@@ -118,7 +118,7 @@ export function analyzeUsage(usage, samples, profile = "low", now = new Date())
118
118
  message,
119
119
  };
120
120
  }
121
- const conversionRate = estimateConversionRate(samples);
121
+ const conversionRate = resolveConversionRate();
122
122
  if (conversionRate === null) {
123
123
  return {
124
124
  provider: usage.provider,
@@ -162,3 +162,9 @@ export function analyzeUsage(usage, samples, profile = "low", now = new Date())
162
162
  }),
163
163
  };
164
164
  }
165
+ export function analyzeUsageWithConversionRate(usage, conversionRate, profile = "low", now = new Date()) {
166
+ return analyzeUsageWithRateResolver(usage, () => conversionRate, profile, now);
167
+ }
168
+ export function analyzeUsage(usage, samples, profile = "low", now = new Date()) {
169
+ return analyzeUsageWithRateResolver(usage, () => estimateConversionRate(samples), profile, now);
170
+ }
package/dist/cli.js CHANGED
@@ -10,6 +10,7 @@ import { installMenuBar, renderMenuBar, uninstallMenuBar, toggleCompactMode } fr
10
10
  import { buildPaths } from "./paths.js";
11
11
  import { bakeGlyphAtlases } from "./glyphs.js";
12
12
  import { maybePromptForStar } from "./star.js";
13
+ import { updateStatusSnapshotUsage } from "./status.js";
13
14
  function hasFlag(args, flag) {
14
15
  return args.includes(flag);
15
16
  }
@@ -119,6 +120,13 @@ async function main() {
119
120
  const parsed = JSON.parse(input);
120
121
  const usage = usageFromClaudeStatusLine(parsed);
121
122
  saveUsage(usage);
123
+ try {
124
+ updateStatusSnapshotUsage(usage);
125
+ }
126
+ catch {
127
+ // The provider cache is authoritative. A later daemon run can rebuild
128
+ // status.json if this best-effort display update loses lock contention.
129
+ }
122
130
  return;
123
131
  }
124
132
  if (command === "daemon") {
package/dist/runtime.d.ts CHANGED
@@ -13,6 +13,12 @@ export declare function analyzeUsages(usages: ProviderUsage[], profile: BurnProf
13
13
  export declare function readProfile(): BurnProfile;
14
14
  export declare function loadFixtureSamples(provider: string): ProviderUsage[];
15
15
  export declare function analyzeFixtureUsages(usages: ProviderUsage[], profile: BurnProfile): BurnAnalysis[];
16
+ export declare function commitCollectedStatusSnapshot(usages: ProviderUsage[], issues: StatusIssue[], options?: {
17
+ paths?: RuntimePaths;
18
+ profile?: BurnProfile;
19
+ generatedAt?: Date;
20
+ fixtureSamples?: Map<string, ProviderUsage[]>;
21
+ }): StatusSnapshot;
16
22
  export declare function collectStatusSnapshot(options?: {
17
23
  fixtures?: boolean;
18
24
  }): Promise<StatusSnapshot>;
package/dist/runtime.js CHANGED
@@ -10,7 +10,7 @@ import { collectKimiUsage, resolveKimiConfig } from "./kimi.js";
10
10
  import { collectMinimaxUsage } from "./minimax.js";
11
11
  import { buildPaths } from "./paths.js";
12
12
  import { loadLatestUsage, saveUsage } from "./store.js";
13
- import { createStatusSnapshot, loadStatusSnapshot, refreshStatusSnapshotFreshness, saveStatusSnapshot, } from "./status.js";
13
+ import { createStatusSnapshot, loadStatusSnapshot, reconcileLatestUsages, replaceStatusSnapshotUsage, refreshStatusSnapshotFreshness, saveStatusSnapshot, withStatusSnapshotLock, } from "./status.js";
14
14
  const FIXTURES_DIR = fileURLToPath(new URL("../fixtures", import.meta.url));
15
15
  export function loadFixtureUsages(fixturesDir) {
16
16
  const providers = ["claude", "codex", "glm", "deepseek", "minimax", "kimi"];
@@ -248,17 +248,42 @@ export function analyzeFixtureUsages(usages, profile) {
248
248
  const fixtureSamples = new Map(usages.map((usage) => [usage.provider, loadFixtureSamples(usage.provider)]));
249
249
  return createStatusSnapshot(usages, profile, { fixtureSamples }).providers.map((provider) => provider.analysis);
250
250
  }
251
+ export function commitCollectedStatusSnapshot(usages, issues, options = {}) {
252
+ const paths = options.paths ?? buildPaths();
253
+ const profile = options.profile ?? readProfile();
254
+ const now = options.generatedAt ?? new Date();
255
+ const candidate = createStatusSnapshot(usages, profile, {
256
+ paths,
257
+ generatedAt: now,
258
+ fixtureSamples: options.fixtureSamples,
259
+ issues,
260
+ });
261
+ return withStatusSnapshotLock(paths, () => {
262
+ const existing = loadStatusSnapshot(paths);
263
+ const cached = reconcileLatestUsages(usages, paths);
264
+ let snapshot = candidate;
265
+ for (const usage of cached) {
266
+ snapshot = replaceStatusSnapshotUsage(snapshot, usage, now);
267
+ }
268
+ if (existing) {
269
+ for (const provider of existing.providers) {
270
+ snapshot = replaceStatusSnapshotUsage(snapshot, provider.usage, now);
271
+ }
272
+ }
273
+ saveStatusSnapshot(snapshot, paths);
274
+ return snapshot;
275
+ });
276
+ }
251
277
  export async function collectStatusSnapshot(options = {}) {
252
278
  const { usages, issues } = await collectLocalState(options);
253
279
  const profile = readProfile();
254
280
  const fixtureSamples = options.fixtures
255
281
  ? new Map(usages.map((usage) => [usage.provider, loadFixtureSamples(usage.provider)]))
256
282
  : undefined;
257
- const snapshot = createStatusSnapshot(usages, profile, { fixtureSamples, issues });
258
283
  if (!options.fixtures) {
259
- saveStatusSnapshot(snapshot);
284
+ return commitCollectedStatusSnapshot(usages, issues, { profile });
260
285
  }
261
- return snapshot;
286
+ return createStatusSnapshot(usages, profile, { fixtureSamples, issues });
262
287
  }
263
288
  export function loadDisplayStatusSnapshot(options = {}) {
264
289
  if (options.fixtures || options.refresh) {
package/dist/status.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { BurnProfile, ProviderUsage, RuntimePaths, StatusIssue, StatusSnapshot } from "./types.js";
2
+ export declare function withStatusSnapshotLock<T>(paths: RuntimePaths, action: () => T): T;
3
+ export declare function reconcileLatestUsages(usages: ProviderUsage[], paths: RuntimePaths): ProviderUsage[];
2
4
  export declare function createStatusSnapshot(usages: ProviderUsage[], profile: BurnProfile, options?: {
3
5
  paths?: RuntimePaths;
4
6
  generatedAt?: Date;
@@ -12,3 +14,5 @@ export declare function refreshStatusSnapshotFreshness(snapshot: StatusSnapshot,
12
14
  }): StatusSnapshot;
13
15
  export declare function saveStatusSnapshot(snapshot: StatusSnapshot, paths?: RuntimePaths): void;
14
16
  export declare function loadStatusSnapshot(paths?: RuntimePaths): StatusSnapshot | null;
17
+ export declare function replaceStatusSnapshotUsage(existing: StatusSnapshot | null, usage: ProviderUsage, now?: Date): StatusSnapshot;
18
+ export declare function updateStatusSnapshotUsage(usage: ProviderUsage, paths?: RuntimePaths): StatusSnapshot;
package/dist/status.js CHANGED
@@ -1,8 +1,91 @@
1
- import { analyzeUsage } from "./burn.js";
2
- import { readJsonFile, writeJsonAtomic } from "./fs-util.js";
3
- import { buildPaths } from "./paths.js";
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { analyzeUsage, analyzeUsageWithConversionRate } from "./burn.js";
4
+ import { ensureDir, readJsonFile, writeJsonAtomic } from "./fs-util.js";
5
+ import { buildPaths, providerLatestPath } from "./paths.js";
4
6
  import { loadSamples } from "./store.js";
5
7
  const DEFAULT_STALE_AFTER_SECONDS = 10 * 60;
8
+ const STATUS_LOCK_RETRY_MS = 5;
9
+ const STATUS_LOCK_TIMEOUT_MS = 2_000;
10
+ const STATUS_LOCK_STALE_MS = 10_000;
11
+ const lockWaitArray = new Int32Array(new SharedArrayBuffer(4));
12
+ function waitForStatusLock() {
13
+ Atomics.wait(lockWaitArray, 0, 0, STATUS_LOCK_RETRY_MS);
14
+ }
15
+ export function withStatusSnapshotLock(paths, action) {
16
+ const lockFile = `${paths.statusFile}.lock`;
17
+ const deadline = Date.now() + STATUS_LOCK_TIMEOUT_MS;
18
+ ensureDir(path.dirname(lockFile));
19
+ let descriptor = null;
20
+ while (descriptor === null) {
21
+ try {
22
+ descriptor = fs.openSync(lockFile, "wx", 0o600);
23
+ fs.writeFileSync(descriptor, `${process.pid}\n`, "utf8");
24
+ }
25
+ catch (error) {
26
+ if (error.code !== "EEXIST") {
27
+ throw error;
28
+ }
29
+ try {
30
+ const ageMs = Date.now() - fs.statSync(lockFile).mtimeMs;
31
+ if (ageMs > STATUS_LOCK_STALE_MS) {
32
+ fs.unlinkSync(lockFile);
33
+ continue;
34
+ }
35
+ }
36
+ catch (statError) {
37
+ if (statError.code === "ENOENT") {
38
+ continue;
39
+ }
40
+ throw statError;
41
+ }
42
+ if (Date.now() >= deadline) {
43
+ throw new Error(`Timed out waiting for status snapshot lock at ${lockFile}`);
44
+ }
45
+ waitForStatusLock();
46
+ }
47
+ }
48
+ try {
49
+ return action();
50
+ }
51
+ finally {
52
+ fs.closeSync(descriptor);
53
+ try {
54
+ fs.unlinkSync(lockFile);
55
+ }
56
+ catch (error) {
57
+ if (error.code !== "ENOENT") {
58
+ throw error;
59
+ }
60
+ }
61
+ }
62
+ }
63
+ function observedAtMs(usage) {
64
+ const value = Date.parse(usage.observedAt);
65
+ return Number.isFinite(value) ? value : null;
66
+ }
67
+ function newestUsage(usages) {
68
+ return usages.reduce((latest, candidate) => {
69
+ const latestTime = observedAtMs(latest);
70
+ const candidateTime = observedAtMs(candidate);
71
+ return candidateTime !== null && (latestTime === null || candidateTime > latestTime)
72
+ ? candidate
73
+ : latest;
74
+ });
75
+ }
76
+ export function reconcileLatestUsages(usages, paths) {
77
+ return usages.map((usage) => {
78
+ const cached = readJsonFile(providerLatestPath(paths, usage.provider));
79
+ if (!cached || cached.provider !== usage.provider) {
80
+ return usage;
81
+ }
82
+ const cachedTime = observedAtMs(cached);
83
+ const usageTime = observedAtMs(usage);
84
+ return cachedTime !== null && (usageTime === null || cachedTime > usageTime)
85
+ ? cached
86
+ : usage;
87
+ });
88
+ }
6
89
  function buildProviderMeta(usage, generatedAt, staleAfterSeconds) {
7
90
  const ageSeconds = Math.max(0, Math.round((generatedAt.getTime() - Date.parse(usage.observedAt)) / 1000));
8
91
  return {
@@ -79,3 +162,39 @@ export function saveStatusSnapshot(snapshot, paths = buildPaths()) {
79
162
  export function loadStatusSnapshot(paths = buildPaths()) {
80
163
  return readJsonFile(paths.statusFile);
81
164
  }
165
+ export function replaceStatusSnapshotUsage(existing, usage, now = new Date()) {
166
+ if (!existing) {
167
+ return createStatusSnapshot([usage], "low", {
168
+ generatedAt: now,
169
+ fixtureSamples: new Map([[usage.provider, []]]),
170
+ });
171
+ }
172
+ const previous = existing.providers.find((provider) => provider.usage.provider === usage.provider);
173
+ const selectedUsage = previous ? newestUsage([previous.usage, usage]) : usage;
174
+ const conversionRate = previous?.analysis.target?.conversionRate ?? null;
175
+ const provider = {
176
+ usage: selectedUsage,
177
+ analysis: analyzeUsageWithConversionRate(selectedUsage, conversionRate, existing.profile, now),
178
+ meta: buildProviderMeta(selectedUsage, now, DEFAULT_STALE_AFTER_SECONDS),
179
+ };
180
+ const providers = previous
181
+ ? existing.providers.map((item) => item.usage.provider === usage.provider ? provider : item)
182
+ : [...existing.providers, provider];
183
+ return refreshStatusSnapshotFreshness({
184
+ ...existing,
185
+ generatedAt: now.toISOString(),
186
+ providers,
187
+ issues: existing.issues.filter((issue) => (issue.code !== "USAGE_STALE"
188
+ && !(issue.provider === usage.provider && issue.code === "CLAUDE_INGEST_MISSING"))),
189
+ }, { now });
190
+ }
191
+ export function updateStatusSnapshotUsage(usage, paths = buildPaths()) {
192
+ return withStatusSnapshotLock(paths, () => {
193
+ const now = new Date();
194
+ const existing = loadStatusSnapshot(paths);
195
+ const cached = reconcileLatestUsages([usage], paths)[0];
196
+ const snapshot = replaceStatusSnapshotUsage(existing, newestUsage([usage, cached]), now);
197
+ saveStatusSnapshot(snapshot, paths);
198
+ return snapshot;
199
+ });
200
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coding-usage-bar",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "macOS menu bar usage monitor for Claude Code, Codex, GLM, DeepSeek, and MiniMax.",
5
5
  "author": "hanzhangzzz",
6
6
  "license": "MIT",