coding-usage-bar 0.3.1 → 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/notifier.js CHANGED
@@ -6,6 +6,14 @@ import { readJsonFile, writeJsonAtomic } from "./fs-util.js";
6
6
  import { buildPaths, installedAssetPath } from "./paths.js";
7
7
  import { formatDurationUntil } from "./format.js";
8
8
  import { writeNotificationCard } from "./card.js";
9
+ // State is keyed by provider. Legacy records were keyed `provider:state` and
10
+ // re-notified on a cooldown, which spammed osascript spawns all day; dropping
11
+ // those keys makes each provider re-notify once after upgrade, then follow the
12
+ // transition-based rules.
13
+ function readNotificationState(paths) {
14
+ const raw = readJsonFile(paths.notificationStateFile) ?? {};
15
+ return Object.fromEntries(Object.entries(raw).filter(([key]) => !key.includes(":")));
16
+ }
9
17
  export function commandExists(command) {
10
18
  const result = spawnSync("/bin/sh", ["-c", `command -v ${command}`], {
11
19
  stdio: "ignore",
@@ -24,33 +32,56 @@ export function notificationBackend() {
24
32
  function cooldownMinutes(profile) {
25
33
  return profile === "high" ? 15 : 30;
26
34
  }
27
- function notificationKey(analysis) {
28
- return `${analysis.provider}:${analysis.state}`;
29
- }
35
+ // Notify only on a state transition or when the 5h window resets while the
36
+ // alert persists — never on a periodic cooldown. The cooldown only acts as a
37
+ // per-provider floor between notifications so a flapping state cannot spam.
30
38
  export function shouldNotify(analysis, paths = buildPaths(), now = new Date()) {
31
39
  if (analysis.state === "RAW" || analysis.state === "ON_TRACK") {
32
40
  return false;
33
41
  }
34
- const state = readJsonFile(paths.notificationStateFile) ?? {};
35
- const key = notificationKey(analysis);
36
- const previous = state[key];
37
- const resetAt = analysis.fiveHour?.resetsAt;
42
+ const previous = readNotificationState(paths)[analysis.provider];
38
43
  if (!previous) {
39
44
  return true;
40
45
  }
41
- if (previous.resetAt && resetAt && previous.resetAt !== resetAt) {
46
+ if (previous.state === analysis.state) {
47
+ const resetAt = analysis.fiveHour?.resetsAt;
48
+ const sameWindow = !previous.resetAt || !resetAt || previous.resetAt === resetAt;
49
+ if (sameWindow) {
50
+ return false;
51
+ }
52
+ }
53
+ if (!previous.notifiedAt) {
42
54
  return true;
43
55
  }
44
56
  const elapsedMinutes = (now.getTime() - Date.parse(previous.notifiedAt)) / 60_000;
45
57
  return elapsedMinutes >= cooldownMinutes(analysis.profile);
46
58
  }
47
59
  export function markNotified(analysis, paths = buildPaths(), now = new Date()) {
48
- const state = readJsonFile(paths.notificationStateFile) ?? {};
49
- state[notificationKey(analysis)] = {
50
- notifiedAt: now.toISOString(),
51
- resetAt: analysis.fiveHour?.resetsAt,
52
- };
53
- writeJsonAtomic(paths.notificationStateFile, state);
60
+ const state = readNotificationState(paths);
61
+ writeJsonAtomic(paths.notificationStateFile, {
62
+ ...state,
63
+ [analysis.provider]: {
64
+ state: analysis.state,
65
+ notifiedAt: now.toISOString(),
66
+ resetAt: analysis.fiveHour?.resetsAt,
67
+ },
68
+ });
69
+ }
70
+ // Record recovery so the next entry into an alert state counts as a transition
71
+ // and notifies again. Keeps notifiedAt so the anti-flap floor still applies.
72
+ export function markRecovered(analysis, paths = buildPaths()) {
73
+ if (analysis.state !== "ON_TRACK") {
74
+ return;
75
+ }
76
+ const state = readNotificationState(paths);
77
+ const previous = state[analysis.provider];
78
+ if (!previous || previous.state === "ON_TRACK") {
79
+ return;
80
+ }
81
+ writeJsonAtomic(paths.notificationStateFile, {
82
+ ...state,
83
+ [analysis.provider]: { ...previous, state: "ON_TRACK" },
84
+ });
54
85
  }
55
86
  function notificationText(analysis) {
56
87
  const provider = analysis.provider === "claude" ? "Claude" : analysis.provider === "glm" ? "GLM" : analysis.provider === "minimax" ? "MiniMax" : analysis.provider === "deepseek" ? "DeepSeek" : analysis.provider === "kimi" ? "Kimi" : "Codex";
package/dist/paths.js CHANGED
@@ -22,6 +22,7 @@ export function buildPaths(homeDir = os.homedir()) {
22
22
  launchAgentFile: path.join(homeDir, "Library", "LaunchAgents", "com.duying.coding-usage-bar.plist"),
23
23
  claudeSettingsFile: path.join(homeDir, ".claude", "settings.json"),
24
24
  claudeStatusLineScript: path.join(stateDir, "claude", "statusline.sh"),
25
+ glyphsDir: path.join(stateDir, "glyphs"),
25
26
  };
26
27
  }
27
28
  export function installedAssetPath(homeDir, assetName) {
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/dist/types.d.ts CHANGED
@@ -78,6 +78,7 @@ export interface RuntimePaths {
78
78
  launchAgentFile: string;
79
79
  claudeSettingsFile: string;
80
80
  claudeStatusLineScript: string;
81
+ glyphsDir: string;
81
82
  }
82
83
  export interface ProviderStatus {
83
84
  usage: ProviderUsage;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coding-usage-bar",
3
- "version": "0.3.1",
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",