Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ After changing GNOME source, re-install and re-login (Wayland) or `Alt+F2 → r`
The platform-agnostic core lives in `shared/` (single source of truth, pure ESM + DI). Each platform adds a thin adapter layer and consumes the core at build time.

```
shared/core/scheduler.js → Polls providers on 180s interval, serial queue per provider
shared/core/scheduler.js → Polls providers on 600s interval, serial queue per provider
shared/core/aggregate.js → Computes minRemainingPct across all providers
shared/core/state.js → Per-provider state machine (OK/PARTIAL_DATA/AUTH_EXPIRED/RATE_LIMITED/NETWORK_ERROR/SCHEMA_CHANGED)
shared/core/backoff.js → Exponential backoff (30s initial, 15m cap, on 2+ consecutive network errors or 429)
Expand Down Expand Up @@ -89,7 +89,7 @@ kde/plasma{5,6}/contents/code/ → bundled brainusage-app.mjs + runtime.js (buil

## Testing

Tests use `bun:test` (describe/test/expect + vi for fake timers). Tests mock fetch and readTextFile via DI — no network or filesystem calls. Scheduler tests use `vi.useFakeTimers()` and `vi.advanceTimersByTime()` to control polling. Provider tests use deferred promises to simulate async resolution order.
Tests use `bun:test` (describe/test/expect). Tests mock fetch and readTextFile via DI — no network or filesystem calls. Scheduler tests inject `setIntervalFn` to capture the poll callback and drive ticks manually (bun 1.3 removed `vi.advanceTimersByTime`). Provider tests use deferred promises to simulate async resolution order.

## Credentials

Expand Down
9 changes: 8 additions & 1 deletion extension/lib/runtime/fetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ function createResponse(status, bytes) {

export function createFetch() {
const session = new Soup.Session();
// Without a timeout a hung request never resolves, and the scheduler
// chains polls on a per-provider queue — one hang kills the provider
// until the shell restarts.
session.timeout = 30;

function sendMessage(message) {
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -95,7 +99,10 @@ export function createFetch() {
}

const bytes = await sendMessage(message);
return createResponse(message.get_status(), bytes);
// Read the GObject property instead of get_status(): GJS validates the
// return value of get_status() against the Soup.Status enum, which has
// no entry for some valid HTTP codes (e.g. 429) and throws.
return createResponse(message.status_code, bytes);
}

function dispose() {
Expand Down
56 changes: 47 additions & 9 deletions shared/core/normalize.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,57 @@ export function normalizeClaudeUsage(payload) {
};
}

const DAY_SECONDS = 86_400;

// The usage API does not guarantee window positions: payloads have been seen
// with the 7-day window as primary_window and secondary_window set to null.
// Classify each window by its duration instead of trusting its position.
function classifyCodexWindows(rateLimit) {
const primary = rateLimit?.primary_window ?? null;
const secondary = rateLimit?.secondary_window ?? null;

let sessionWindow = null;
let weeklyWindow = null;

for (const window of [primary, secondary]) {
if (!window || typeof window !== 'object')
continue;

const seconds = Number(window.limit_window_seconds);
if (!Number.isFinite(seconds))
continue;

if (seconds >= DAY_SECONDS)
weeklyWindow ??= window;
else
sessionWindow ??= window;
}

// Legacy payloads without limit_window_seconds: fall back to positions.
if (!sessionWindow && !weeklyWindow) {
sessionWindow = primary;
weeklyWindow = secondary;
}

return {sessionWindow, weeklyWindow};
}

export function normalizeCodexUsage(payload) {
const primaryWindow = payload?.rate_limit?.primary_window;
const secondaryWindow = payload?.rate_limit?.secondary_window;
const {sessionWindow, weeklyWindow} = classifyCodexWindows(payload?.rate_limit);

return {
data: {
sessionRemainingPct: clampPercent(100 - Number(primaryWindow?.used_percent)),
weeklyRemainingPct: clampPercent(100 - Number(secondaryWindow?.used_percent)),
sessionResetsAtIso: unixSecondsToIso(primaryWindow?.reset_at),
weeklyResetsAtIso: unixSecondsToIso(secondaryWindow?.reset_at),
sessionRemainingPct: sessionWindow
? clampPercent(100 - Number(sessionWindow.used_percent))
: null,
weeklyRemainingPct: weeklyWindow
? clampPercent(100 - Number(weeklyWindow.used_percent))
: null,
sessionResetsAtIso: unixSecondsToIso(sessionWindow?.reset_at),
weeklyResetsAtIso: unixSecondsToIso(weeklyWindow?.reset_at),
},
hasPrimaryWindow: Boolean(primaryWindow),
hasSecondaryWindow: Boolean(secondaryWindow),
hasPartialData: !primaryWindow || !secondaryWindow,
hasPrimaryWindow: Boolean(sessionWindow),
hasSecondaryWindow: Boolean(weeklyWindow),
hasPartialData: !sessionWindow || !weeklyWindow,
};
}
4 changes: 3 additions & 1 deletion shared/core/scheduler.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import {computeSummary} from './aggregate.js';
import {createBackoffManager} from './backoff.js';
import {applyProviderResult, createProviderState} from './state.js';

export const DEFAULT_POLL_INTERVAL_MS = 180_000;
// 10 minutes: the Claude usage endpoint rate-limits aggressive polling
// (observed 429 with retry-after ~340s against a 3-minute interval).
export const DEFAULT_POLL_INTERVAL_MS = 600_000;

function normalizeProviders(providersInput) {
if (Array.isArray(providersInput)) {
Expand Down
4 changes: 3 additions & 1 deletion shared/core/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ export function applyProviderResult(state, result, requestId, updatedAtIso) {
return true;
}

state.data = result?.data ?? null;
// Keep the last known usage on failure so the UI can render stale data
// alongside the warning instead of going blank (e.g. while rate limited).
state.data = result?.data ?? state.data;
state.error = {
code: state.code,
providerCode: result?.error?.code ?? null,
Expand Down
5 changes: 2 additions & 3 deletions shared/providers/codex.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,8 @@ export function createCodexProvider(options = {}) {
if (!normalized.hasPrimaryWindow && !normalized.hasSecondaryWindow)
return fail('schema_changed', 'Usage payload is missing expected rate_limit windows');

if (normalized.hasPartialData)
return fail('partial_data', 'Usage payload is missing primary_window or secondary_window', normalized.data);

// A single window is a valid payload: the API may report only
// the weekly window, so render what exists instead of failing.
return ok(normalized.data);
} catch {
return fail('network_error', 'Network request failed while calling Codex APIs');
Expand Down
3 changes: 3 additions & 0 deletions shared/ui/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ function toWarningText(providerLabel, code) {
if (code === 'NETWORK_ERROR')
return `${providerLabel}: network error`;

if (code === 'RATE_LIMITED')
return `${providerLabel}: rate limited`;

if (code === 'SCHEMA_CHANGED')
return `${providerLabel}: schema changed`;

Expand Down
58 changes: 54 additions & 4 deletions test/unit/codex-provider.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ describe('Codex provider', () => {
expect(result.data.sessionRemainingPct).toBe(90);
});

test('returns partial_data when secondary_window is missing', async () => {
test('returns ok with null weekly when only the session window exists', async () => {
const provider = createCodexProvider({
readTextFile: async () => JSON.stringify({
tokens: {
Expand All @@ -142,16 +142,66 @@ describe('Codex provider', () => {

const result = await provider.getUsage();

expect(result.ok).toBe(false);
expect(result.error.code).toBe('partial_data');
expect(result.ok).toBe(true);
expect(result.data).toEqual({
sessionRemainingPct: 65,
weeklyRemainingPct: 0,
weeklyRemainingPct: null,
sessionResetsAtIso: '2026-02-08T00:00:00.000Z',
weeklyResetsAtIso: null,
});
});

test('returns ok when the weekly window arrives as primary_window and secondary_window is null', async () => {
const provider = createCodexProvider({
readTextFile: async () => JSON.stringify({
tokens: {
access_token: 'access-token',
refresh_token: 'refresh-token',
},
}),
fetch: async () => createJsonResponse(200, {
rate_limit: {
allowed: true,
limit_reached: false,
primary_window: {
used_percent: 2,
limit_window_seconds: 604_800,
reset_after_seconds: 587_758,
reset_at: 1_784_850_454,
},
secondary_window: null,
},
}),
});

const result = await provider.getUsage();

expect(result.ok).toBe(true);
expect(result.data).toEqual({
sessionRemainingPct: null,
weeklyRemainingPct: 98,
sessionResetsAtIso: null,
weeklyResetsAtIso: '2026-07-23T23:47:34.000Z',
});
});

test('returns schema_changed when no rate_limit windows exist', async () => {
const provider = createCodexProvider({
readTextFile: async () => JSON.stringify({
tokens: {
access_token: 'access-token',
refresh_token: 'refresh-token',
},
}),
fetch: async () => createJsonResponse(200, {rate_limit: {}}),
});

const result = await provider.getUsage();

expect(result.ok).toBe(false);
expect(result.error.code).toBe('schema_changed');
});

test('returns auth_expired when refresh is rejected', async () => {
const provider = createCodexProvider({
readTextFile: async () => JSON.stringify({
Expand Down
71 changes: 71 additions & 0 deletions test/unit/normalize.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {describe, expect, test} from 'bun:test';

import {normalizeCodexUsage} from '../../shared/core/normalize.js';

describe('normalizeCodexUsage', () => {
test('classifies windows by duration when the weekly window arrives as primary_window', () => {
const normalized = normalizeCodexUsage({
rate_limit: {
allowed: true,
limit_reached: false,
primary_window: {
used_percent: 2,
limit_window_seconds: 604_800,
reset_after_seconds: 587_758,
reset_at: 1_784_850_454,
},
secondary_window: null,
},
});

expect(normalized.data).toEqual({
sessionRemainingPct: null,
weeklyRemainingPct: 98,
sessionResetsAtIso: null,
weeklyResetsAtIso: '2026-07-23T23:47:34.000Z',
});
expect(normalized.hasPartialData).toBe(true);
});

test('keeps the classic mapping for both-windows payloads carrying durations', () => {
const normalized = normalizeCodexUsage({
rate_limit: {
primary_window: {
used_percent: 18,
limit_window_seconds: 18_000,
reset_at: 1_770_508_800,
},
secondary_window: {
used_percent: 5,
limit_window_seconds: 604_800,
reset_at: 1_770_768_000,
},
},
});

expect(normalized.data).toEqual({
sessionRemainingPct: 82,
weeklyRemainingPct: 95,
sessionResetsAtIso: '2026-02-08T00:00:00.000Z',
weeklyResetsAtIso: '2026-02-11T00:00:00.000Z',
});
expect(normalized.hasPartialData).toBe(false);
});

test('falls back to positional mapping for legacy payloads without durations', () => {
const normalized = normalizeCodexUsage({
rate_limit: {
primary_window: {used_percent: 42, reset_at: 1_770_508_800},
secondary_window: {used_percent: 64, reset_at: 1_770_768_000},
},
});

expect(normalized.data).toEqual({
sessionRemainingPct: 58,
weeklyRemainingPct: 36,
sessionResetsAtIso: '2026-02-08T00:00:00.000Z',
weeklyResetsAtIso: '2026-02-11T00:00:00.000Z',
});
expect(normalized.hasPartialData).toBe(false);
});
});
30 changes: 15 additions & 15 deletions test/unit/scheduler.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {afterEach, describe, expect, setSystemTime, test, vi} from 'bun:test';
import {describe, expect, test} from 'bun:test';

import {createBackoffManager} from '../../shared/core/backoff.js';
import {createScheduler, DEFAULT_POLL_INTERVAL_MS} from '../../shared/core/scheduler.js';
Expand All @@ -20,19 +20,24 @@ function deferred() {
return {promise, resolve, reject};
}

afterEach(() => {
vi.useRealTimers();
});

describe('scheduler', () => {
test('polls all providers every 180000ms', async () => {
vi.useFakeTimers();
setSystemTime(new Date('2026-02-08T00:00:00.000Z'));
test('defaults to a 10 minute poll interval', () => {
expect(DEFAULT_POLL_INTERVAL_MS).toBe(600_000);
});

test('polls all providers once per poll interval', async () => {
let claudeCalls = 0;
let codexCalls = 0;
let intervalMs = null;
let tick = null;

const scheduler = createScheduler({
setIntervalFn: (callback, ms) => {
tick = callback;
intervalMs = ms;
return 1;
},
clearIntervalFn: () => {},
providers: {
claude: {
async getUsage() {
Expand All @@ -52,16 +57,11 @@ describe('scheduler', () => {
scheduler.start();
await flushMicrotasks();

expect(intervalMs).toBe(DEFAULT_POLL_INTERVAL_MS);
expect(claudeCalls).toBe(1);
expect(codexCalls).toBe(1);

vi.advanceTimersByTime(DEFAULT_POLL_INTERVAL_MS - 1);
await flushMicrotasks();

expect(claudeCalls).toBe(1);
expect(codexCalls).toBe(1);

vi.advanceTimersByTime(1);
tick();
await flushMicrotasks();

expect(claudeCalls).toBe(2);
Expand Down
Loading