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/burn.d.ts +1 -0
- package/dist/burn.js +8 -2
- package/dist/cli.js +14 -0
- package/dist/daemon.js +4 -1
- package/dist/glm.d.ts +1 -1
- package/dist/glm.js +39 -9
- package/dist/glyphs.d.ts +68 -0
- package/dist/glyphs.js +176 -0
- package/dist/install.js +19 -0
- package/dist/menubar-card.d.ts +30 -0
- package/dist/menubar-card.js +189 -0
- package/dist/menubar-render.d.ts +19 -0
- package/dist/menubar-render.js +46 -10
- package/dist/menubar.d.ts +1 -0
- package/dist/menubar.js +250 -104
- package/dist/notifier.d.ts +1 -0
- package/dist/notifier.js +45 -14
- package/dist/paths.js +1 -0
- package/dist/runtime.d.ts +6 -0
- package/dist/runtime.js +29 -4
- package/dist/status.d.ts +4 -0
- package/dist/status.js +122 -3
- package/dist/types.d.ts +1 -0
- package/package.json +1 -1
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
|
-
|
|
28
|
-
|
|
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
|
|
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.
|
|
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 =
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
284
|
+
return commitCollectedStatusSnapshot(usages, issues, { profile });
|
|
260
285
|
}
|
|
261
|
-
return
|
|
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
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
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