coding-usage-bar 0.2.3 → 0.3.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/format.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { BurnAnalysis, StatusIssue, ProviderUsage, UsageWindow, StatusSnapshot } from "./types.js";
2
2
  export declare function formatDurationUntil(iso: string, now?: Date): string;
3
+ export declare function formatResetTime(iso: string, now?: Date): string;
3
4
  export declare function formatProviderLabel(provider: string): "Claude" | "GLM" | "DeepSeek" | "MiniMax" | "Kimi" | "Codex";
4
5
  export declare function formatWindowLabel(window: UsageWindow): "5h" | "7d";
5
6
  export declare function usageBar(usedPercent: number, width?: number): string;
package/dist/format.js CHANGED
@@ -11,6 +11,31 @@ export function formatDurationUntil(iso, now = new Date()) {
11
11
  }
12
12
  return `${mins}m`;
13
13
  }
14
+ // Absolute reset label for surfaces that re-render on a timer (SwiftBar).
15
+ // A countdown changes every minute even when the underlying usage data is
16
+ // unchanged, which forces SwiftBar to rebuild the menu and repaint the menu
17
+ // bar on every refresh. An absolute clock time is a pure function of the
18
+ // reset timestamp, so unchanged data renders to identical bytes and SwiftBar
19
+ // skips the update entirely. `now` only decides "due" and whether the day
20
+ // prefix is needed, both of which change at most once per window/day.
21
+ export function formatResetTime(iso, now = new Date()) {
22
+ const resetMs = Date.parse(iso);
23
+ if (!Number.isFinite(resetMs)) {
24
+ return "due";
25
+ }
26
+ if (resetMs <= now.getTime()) {
27
+ return "due";
28
+ }
29
+ const reset = new Date(resetMs);
30
+ const sameDay = reset.getFullYear() === now.getFullYear()
31
+ && reset.getMonth() === now.getMonth()
32
+ && reset.getDate() === now.getDate();
33
+ const clock = `${String(reset.getHours()).padStart(2, "0")}:${String(reset.getMinutes()).padStart(2, "0")}`;
34
+ if (sameDay) {
35
+ return clock;
36
+ }
37
+ return `${reset.getMonth() + 1}/${reset.getDate()} ${clock}`;
38
+ }
14
39
  function windowLabel(window) {
15
40
  return window.name === "five_hour" ? "5h" : "7d";
16
41
  }
package/dist/kimi.d.ts CHANGED
@@ -28,5 +28,6 @@ export declare function usageFromKimiUsages(usages: KimiUsagesResponse, options:
28
28
  }): ProviderUsage | null;
29
29
  export declare function readClaudeLanesKimiConfig(configFile: string): KimiConfig | null;
30
30
  export declare function resolveKimiConfig(config: KimiConfig | undefined, homeDir?: string): KimiConfig;
31
+ export declare function probeKimiAccessFrozen(config: KimiConfig): Promise<boolean>;
31
32
  export declare function collectKimiUsage(config: KimiConfig): Promise<ProviderUsage>;
32
33
  export {};
package/dist/kimi.js CHANGED
@@ -16,11 +16,16 @@ function asNumber(value) {
16
16
  }
17
17
  // Kimi reports limit/used/remaining per window. Prefer the used/limit ratio;
18
18
  // when limit is 0 or missing, derive the total from used + remaining instead of
19
- // falling back to 0% while a real signal exists.
19
+ // falling back to 0% while a real signal exists. Kimi also omits zero-valued
20
+ // fields entirely (proto3 JSON): a fresh window with no usage reports only
21
+ // limit + remaining, so derive used from limit - remaining instead of failing.
20
22
  function percentFromDetail(detail) {
21
23
  const limit = asNumber(detail.limit);
22
- const used = asNumber(detail.used);
24
+ let used = asNumber(detail.used);
23
25
  const remaining = asNumber(detail.remaining);
26
+ if (used === null && limit !== null && remaining !== null) {
27
+ used = Math.max(0, limit - remaining);
28
+ }
24
29
  if (limit !== null && limit > 0 && used !== null) {
25
30
  return Math.max(0, Math.min(100, (used / limit) * 100));
26
31
  }
@@ -132,6 +137,41 @@ export function resolveKimiConfig(config, homeDir = os.homedir()) {
132
137
  apiKey: config?.apiKey || lanes?.apiKey,
133
138
  };
134
139
  }
140
+ // Kimi's monthly billing-cycle quota can freeze all chat access while
141
+ // /v1/usages still reports headroom in the 5h/weekly windows (the monthly
142
+ // number itself is not exposed on any API-key-authenticated endpoint; it only
143
+ // shows up in the logged-in web console). Detect the freeze with a
144
+ // zero-cost probe: the billing-cycle gate runs before model validation, so a
145
+ // chat request naming a nonexistent model returns 403 access_terminated_error
146
+ // when frozen — and a "model not found" error (no inference, no quota use)
147
+ // when healthy.
148
+ const ACCESS_PROBE_MODEL = "coding-usage-bar-access-probe";
149
+ export async function probeKimiAccessFrozen(config) {
150
+ const baseUrl = (config.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
151
+ const response = await fetch(`${baseUrl}/v1/chat/completions`, {
152
+ method: "POST",
153
+ headers: {
154
+ "Authorization": `Bearer ${config.apiKey}`,
155
+ "Content-Type": "application/json",
156
+ },
157
+ body: JSON.stringify({
158
+ model: ACCESS_PROBE_MODEL,
159
+ max_tokens: 1,
160
+ messages: [{ role: "user", content: "hi" }],
161
+ }),
162
+ signal: AbortSignal.timeout(10_000),
163
+ });
164
+ if (response.status !== 403) {
165
+ return false;
166
+ }
167
+ try {
168
+ const body = (await response.json());
169
+ return body?.error?.type === "access_terminated_error";
170
+ }
171
+ catch {
172
+ return false;
173
+ }
174
+ }
135
175
  export async function collectKimiUsage(config) {
136
176
  if (!config.apiKey) {
137
177
  throw new Error("Kimi API key not configured. Edit ~/.coding-usage-bar/config.json to set kimi.apiKey, or configure a kimi.com lane in ~/.config/claude-lanes/config.env.");
@@ -144,5 +184,15 @@ export async function collectKimiUsage(config) {
144
184
  if (!usage) {
145
185
  throw new Error("Kimi usages response did not contain expected 5h/7d quota data");
146
186
  }
187
+ // The probe must never fail the collection: on network errors the window
188
+ // data stays valid and simply renders unblocked.
189
+ try {
190
+ if (await probeKimiAccessFrozen(config)) {
191
+ usage.blocked = { reason: "monthly quota exhausted; access frozen until the next billing cycle" };
192
+ }
193
+ }
194
+ catch {
195
+ // Probe unavailable; keep the usage data as-is.
196
+ }
147
197
  return usage;
148
198
  }
package/dist/menubar.d.ts CHANGED
@@ -14,7 +14,7 @@ export declare function openSwiftBar(): string[];
14
14
  export declare function addSwiftBarToLoginItems(options?: {
15
15
  dryRun?: boolean;
16
16
  }): string[];
17
- export declare function renderMenuBar(snapshot?: StatusSnapshot, paths?: RuntimePaths): string;
17
+ export declare function renderMenuBar(snapshot?: StatusSnapshot, paths?: RuntimePaths, now?: Date): string;
18
18
  export declare function swiftBarStatusItemVisibilityKeys(defaultsOutput: string): string[];
19
19
  export declare function installMenuBar(options?: {
20
20
  dryRun?: boolean;
package/dist/menubar.js CHANGED
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { execFileSync } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { ensureDir, isFile } from "./fs-util.js";
6
- import { formatDurationUntil, formatProviderLabel } from "./format.js";
6
+ import { formatProviderLabel, formatResetTime } from "./format.js";
7
7
  import { buildPaths } from "./paths.js";
8
8
  import { stableNodeExecutable } from "./node-runtime.js";
9
9
  import { loadDisplayStatusSnapshot } from "./runtime.js";
@@ -338,7 +338,9 @@ function titleBars(provider) {
338
338
  return [{ pct: isAvailable ? 100 : 0, lightColor, darkColor }];
339
339
  }
340
340
  const state = provider.analysis.state;
341
- const [lightColor, darkColor] = STATE_COLOR[state].split(",");
341
+ // Blocked (Kimi billing-cycle freeze): grey bars say "no access" without
342
+ // lying about the 5h/7d window numbers.
343
+ const [lightColor, darkColor] = (provider.usage.blocked ? MUTED_COLOR : STATE_COLOR[state]).split(",");
342
344
  const fiveHour = provider.analysis.fiveHour;
343
345
  const sevenDay = provider.analysis.sevenDay;
344
346
  const bars = [];
@@ -450,10 +452,10 @@ function coloredMeter(usedPercent, fillColor, width = METER_WIDTH) {
450
452
  return (filled > 0 ? ansiRGB(fillColor, "█".repeat(filled)) : "")
451
453
  + (empty > 0 ? ansiRGB(MUTED_COLOR, "░".repeat(empty)) : "");
452
454
  }
453
- function usageLine(label, usedPercent, resetsAt, color, barImage) {
455
+ function usageLine(label, usedPercent, resetsAt, color, now, barImage) {
454
456
  const percent = `${Math.round(usedPercent).toString().padStart(3)}%`;
455
457
  if (barImage) {
456
- return line(`${label} ${percent} reset ${formatDurationUntil(resetsAt)}`, {
458
+ return line(`${label} ${percent} reset ${formatResetTime(resetsAt, now)}`, {
457
459
  image: barImage.image,
458
460
  width: barImage.width,
459
461
  height: barImage.height,
@@ -463,7 +465,7 @@ function usageLine(label, usedPercent, resetsAt, color, barImage) {
463
465
  });
464
466
  }
465
467
  const bar = coloredMeter(usedPercent, color);
466
- return line(`${label} ${bar} ${ansiRGB(color, percent)} reset ${formatDurationUntil(resetsAt)}`, {
468
+ return line(`${label} ${bar} ${ansiRGB(color, percent)} reset ${formatResetTime(resetsAt, now)}`, {
467
469
  ansi: true,
468
470
  font: ROW_FONT,
469
471
  size: 12,
@@ -490,8 +492,8 @@ function titleIconParams() {
490
492
  }
491
493
  return { sfimage: "flame.fill", sfcolor: RAW_COLOR };
492
494
  }
493
- function maxProviderAge(snapshot) {
494
- return Math.max(0, ...snapshot.providers.map((item) => item.meta.ageSeconds));
495
+ function anyProviderStale(snapshot) {
496
+ return snapshot.providers.some((item) => item.meta.stale);
495
497
  }
496
498
  function issueLabel(code) {
497
499
  if (code === "CLAUDE_INGEST_MISSING") {
@@ -517,7 +519,11 @@ function issueLabel(code) {
517
519
  }
518
520
  return code.replaceAll("_", " ").toLowerCase();
519
521
  }
520
- export function renderMenuBar(snapshot = loadDisplayStatusSnapshot(), paths = buildPaths()) {
522
+ // `now` only feeds low-frequency decisions (reset due/day prefix). The output
523
+ // must stay byte-identical across renders while the snapshot is unchanged, so
524
+ // SwiftBar's content guard can skip the menu rebuild and menu bar repaint;
525
+ // never render anything derived from wall-clock age or countdowns here.
526
+ export function renderMenuBar(snapshot = loadDisplayStatusSnapshot(), paths = buildPaths(), now = new Date()) {
521
527
  const compact = readCompactMode(paths);
522
528
  const providers = titleProviders(snapshot);
523
529
  const title = providers.map(titleSegment).join(` ${TITLE_SEPARATOR} `);
@@ -571,7 +577,7 @@ export function renderMenuBar(snapshot = loadDisplayStatusSnapshot(), paths = bu
571
577
  sfcolor: STATE_COLOR[topState],
572
578
  badge: snapshot.profile.toUpperCase(),
573
579
  }),
574
- muted(`Data age ${maxProviderAge(snapshot)}s`),
580
+ muted(anyProviderStale(snapshot) ? "Data stale" : "Data fresh"),
575
581
  "---",
576
582
  ];
577
583
  const barDataList = [];
@@ -579,7 +585,7 @@ export function renderMenuBar(snapshot = loadDisplayStatusSnapshot(), paths = bu
579
585
  if (item.usage.balance && item.usage.windows.length === 0) {
580
586
  continue;
581
587
  }
582
- const [lc, dc] = STATE_COLOR[item.analysis.state].split(",");
588
+ const [lc, dc] = (item.usage.blocked ? MUTED_COLOR : STATE_COLOR[item.analysis.state]).split(",");
583
589
  const f = windowByName(item, "five_hour");
584
590
  const s = windowByName(item, "seven_day");
585
591
  if (f) {
@@ -614,17 +620,20 @@ export function renderMenuBar(snapshot = loadDisplayStatusSnapshot(), paths = bu
614
620
  lines.push("---");
615
621
  continue;
616
622
  }
617
- lines.push(line(`${formatProviderLabel(item.usage.provider)} ${STATE_LABEL[item.analysis.state]}`, {
623
+ lines.push(line(`${formatProviderLabel(item.usage.provider)} ${item.usage.blocked ? "Blocked" : STATE_LABEL[item.analysis.state]}`, {
618
624
  ...providerIconParams(item.usage.provider),
619
625
  color: TEXT_COLOR,
620
626
  size: 14,
621
- badge: providerBadge(item),
627
+ badge: item.usage.blocked ? "Frozen" : providerBadge(item),
622
628
  }));
623
629
  if (five) {
624
- lines.push(usageLine("5h", five.usedPercent, five.resetsAt, TEXT_COLOR, dropdownBars[barIdx++]));
630
+ lines.push(usageLine("5h", five.usedPercent, five.resetsAt, TEXT_COLOR, now, dropdownBars[barIdx++]));
625
631
  }
626
632
  if (seven) {
627
- lines.push(usageLine("7d", seven.usedPercent, seven.resetsAt, TEXT_COLOR, dropdownBars[barIdx++]));
633
+ lines.push(usageLine("7d", seven.usedPercent, seven.resetsAt, TEXT_COLOR, now, dropdownBars[barIdx++]));
634
+ }
635
+ if (item.usage.blocked) {
636
+ lines.push(muted(`Blocked ${item.usage.blocked.reason}. 5h/7d numbers above still reflect the window quotas.`));
628
637
  }
629
638
  lines.push(muted(targetLabel(item)));
630
639
  lines.push(line(item.analysis.message, { color: MUTED_COLOR, size: 12, length: 84 }));
package/dist/status.js CHANGED
@@ -12,6 +12,22 @@ function buildProviderMeta(usage, generatedAt, staleAfterSeconds) {
12
12
  stale: ageSeconds > staleAfterSeconds,
13
13
  };
14
14
  }
15
+ // Coarse buckets keep the message stable between renders: an exact age changes
16
+ // every second, which would defeat SwiftBar's unchanged-content guard and force
17
+ // a menu rebuild each refresh while the data itself is unchanged.
18
+ function staleAgeLabel(ageSeconds) {
19
+ const buckets = [
20
+ { seconds: 86_400, label: "1d" },
21
+ { seconds: 43_200, label: "12h" },
22
+ { seconds: 10_800, label: "3h" },
23
+ { seconds: 3_600, label: "1h" },
24
+ { seconds: 1_800, label: "30m" },
25
+ { seconds: 600, label: "10m" },
26
+ { seconds: 60, label: "1m" },
27
+ ];
28
+ const hit = buckets.find((bucket) => ageSeconds >= bucket.seconds);
29
+ return hit ? `over ${hit.label}` : "under 1m";
30
+ }
15
31
  function staleIssuesFor(providers) {
16
32
  return providers
17
33
  .filter((provider) => provider.meta.stale)
@@ -19,7 +35,7 @@ function staleIssuesFor(providers) {
19
35
  provider: provider.usage.provider,
20
36
  severity: "warning",
21
37
  code: "USAGE_STALE",
22
- message: `${provider.usage.provider} usage is stale; latest observation is ${provider.meta.ageSeconds}s old.`,
38
+ message: `${provider.usage.provider} usage is stale; latest observation is ${staleAgeLabel(provider.meta.ageSeconds)} old.`,
23
39
  }));
24
40
  }
25
41
  export function createStatusSnapshot(usages, profile, options = {}) {
package/dist/types.d.ts CHANGED
@@ -19,6 +19,9 @@ export interface ProviderUsage {
19
19
  currency: string;
20
20
  isAvailable: boolean;
21
21
  };
22
+ blocked?: {
23
+ reason: string;
24
+ };
22
25
  }
23
26
  export interface BurnAnalysis {
24
27
  provider: ProviderId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coding-usage-bar",
3
- "version": "0.2.3",
3
+ "version": "0.3.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",