diff --git a/electron/ipc/antigravity-usage.test.ts b/electron/ipc/antigravity-usage.test.ts new file mode 100644 index 00000000..f2de62fd --- /dev/null +++ b/electron/ipc/antigravity-usage.test.ts @@ -0,0 +1,470 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + antigravityFallbackCachePaths, + antigravityQuotaCachePath, + discoverAllLanguageServerCredentials, + discoverLanguageServerCredentials, + fetchAntigravityUsage, + parseAntigravityUsageResponse, + parseQuotaWindow, +} from './antigravity-usage.js'; + +const NOW = 1_700_000_000_000; + +describe('antigravityQuotaCachePath', () => { + it('honours ANTIGRAVITY_QUOTA_CACHE when set', () => { + expect(antigravityQuotaCachePath({ ANTIGRAVITY_QUOTA_CACHE: '/custom/quota.json' })).toBe( + '/custom/quota.json', + ); + }); + + it('honours XDG_CACHE_HOME and falls back to ~/.cache', () => { + expect(antigravityQuotaCachePath({ XDG_CACHE_HOME: '/custom/cache' })).toBe( + '/custom/cache/agy-hud/quota_cache.json', + ); + expect(antigravityQuotaCachePath({})).toBe( + path.join(os.homedir(), '.cache', 'agy-hud', 'quota_cache.json'), + ); + }); +}); + +describe('antigravityFallbackCachePaths', () => { + it('returns fallback path under .gemini', () => { + const paths = antigravityFallbackCachePaths({ HOME: '/home/tester' }); + expect(paths).toContain( + path.join( + '/home/tester', + '.gemini', + 'antigravity-cli', + 'scratch', + 'agy-hud', + 'quota_cache.json', + ), + ); + }); +}); + +describe('parseQuotaWindow', () => { + it('parses direct usedPercent and resetsAt', () => { + expect(parseQuotaWindow({ usedPercent: 45, resetsAt: 1_738_425_600_000 })).toEqual({ + usedPercent: 45, + resetsAt: 1_738_425_600_000, + }); + }); + + it('converts remainingFraction to usedPercent', () => { + expect( + parseQuotaWindow({ remainingFraction: 0.61, resetTime: '2026-09-29T03:40:56Z' }), + ).toEqual({ + usedPercent: 39, + resetsAt: Date.parse('2026-09-29T03:40:56Z'), + }); + }); + + it('handles reset_in_seconds', () => { + expect(parseQuotaWindow({ remaining_fraction: 0.25, reset_in_seconds: 120 }, NOW)).toEqual({ + usedPercent: 75, + resetsAt: NOW + 120_000, + }); + }); + + it('clamps usedPercent to 0-100', () => { + expect(parseQuotaWindow({ usedPercent: 120 })).toEqual({ + usedPercent: 100, + resetsAt: null, + }); + expect(parseQuotaWindow({ remainingFraction: -0.5 })).toEqual({ + usedPercent: 100, + resetsAt: null, + }); + expect(parseQuotaWindow({ remainingFraction: 1.5 })).toEqual({ + usedPercent: 0, + resetsAt: null, + }); + }); + + it('returns null on invalid shapes', () => { + expect(parseQuotaWindow(null)).toBeNull(); + expect(parseQuotaWindow({})).toBeNull(); + expect(parseQuotaWindow('nope')).toBeNull(); + }); +}); + +describe('parseAntigravityUsageResponse', () => { + it('parses models map and prioritizes the most consumed Gemini model', () => { + const response = { + timestamp: '2026-09-28T20:00:00Z', + plan_name: 'Pro', + models: { + 'Gemini 3.8 Flash (Low)': { remainingFraction: 0.8, resetTime: '2026-09-29T03:00:00Z' }, + 'Gemini 3.8 Flash (High)': { remainingFraction: 0.5, resetTime: '2026-09-29T03:00:00Z' }, + 'Claude Sonnet 4.6 (Thinking)': { + remainingFraction: 0.1, + resetTime: '2026-09-29T04:00:00Z', + }, + }, + }; + const result = parseAntigravityUsageResponse(response, NOW); + expect(result).toEqual({ + status: 'ok', + fiveHour: { + usedPercent: 50, + resetsAt: Date.parse('2026-09-29T03:00:00Z'), + }, + sevenDay: null, + fetchedAt: Date.parse('2026-09-28T20:00:00Z'), + }); + }); + + it('parses LanguageServer clientModelConfigs from userStatus', () => { + const response = { + userStatus: { + cascadeModelConfigData: { + clientModelConfigs: [ + { + label: 'Gemini 3.8 Flash (High)', + quotaInfo: { remainingFraction: 0.6, resetTime: '2026-09-29T03:00:00Z' }, + }, + ], + }, + }, + }; + const result = parseAntigravityUsageResponse(response, NOW); + expect(result).toEqual({ + status: 'ok', + fiveHour: { + usedPercent: 40, + resetsAt: Date.parse('2026-09-29T03:00:00Z'), + }, + sevenDay: null, + fetchedAt: NOW, + }); + }); + + it('parses official 5h and weekly bucket format', () => { + const response = { + 'gemini-5h': { remaining_fraction: 0.4, reset_in_seconds: 3600 }, + 'gemini-weekly': { remaining_fraction: 0.9, reset_in_seconds: 86400 }, + }; + const result = parseAntigravityUsageResponse(response, NOW); + expect(result).toEqual({ + status: 'ok', + fiveHour: { usedPercent: 60, resetsAt: NOW + 3_600_000 }, + sevenDay: { usedPercent: 10, resetsAt: NOW + 86_400_000 }, + fetchedAt: NOW, + }); + }); + + it('parses direct fiveHour and sevenDay objects', () => { + const response = { + fiveHour: { usedPercent: 30, resetsAt: 1_738_425_600_000 }, + sevenDay: { usedPercent: 5, resetsAt: 1_738_900_000_000 }, + fetchedAt: 9999, + }; + const result = parseAntigravityUsageResponse(response, NOW); + expect(result).toEqual({ + status: 'ok', + fiveHour: { usedPercent: 30, resetsAt: 1_738_425_600_000 }, + sevenDay: { usedPercent: 5, resetsAt: 1_738_900_000_000 }, + fetchedAt: 9999, + }); + }); + + it('parses nested quota object from statusline payload', () => { + const response = { + product: 'antigravity', + quota: { + 'gemini-5h': { remaining_fraction: 0.35, reset_time: '2026-09-29T03:40:56Z' }, + 'gemini-weekly': { remaining_fraction: 0.45, reset_time: '2026-10-01T02:57:22Z' }, + }, + }; + const result = parseAntigravityUsageResponse(response, NOW); + expect(result).toEqual({ + status: 'ok', + fiveHour: { usedPercent: 65, resetsAt: Date.parse('2026-09-29T03:40:56Z') }, + sevenDay: { usedPercent: 55, resetsAt: Date.parse('2026-10-01T02:57:22Z') }, + fetchedAt: NOW, + }); + }); + + it('returns null when no windows can be derived', () => { + expect(parseAntigravityUsageResponse({})).toBeNull(); + expect(parseAntigravityUsageResponse({ models: {} })).toBeNull(); + expect(parseAntigravityUsageResponse(null)).toBeNull(); + }); +}); + +describe('fetchAntigravityUsage', () => { + const dirs: string[] = []; + + function tempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agy-usage-')); + dirs.push(dir); + return dir; + } + + afterEach(() => { + for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('is unavailable when cache file does not exist and no env vars set', async () => { + const missing = path.join(tempDir(), 'quota_cache.json'); + const result = await fetchAntigravityUsage(missing, {}); + expect(result.status).toBe('unavailable'); + if (result.status === 'unavailable') { + expect(result.reason).toContain('No Antigravity quota found'); + } + }); + + it('reports error when cache file has invalid JSON', async () => { + const dir = tempDir(); + const cacheFile = path.join(dir, 'quota_cache.json'); + fs.writeFileSync(cacheFile, '{ not valid json'); + const result = await fetchAntigravityUsage(cacheFile, {}); + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.message).toContain('invalid JSON'); + } + }); + + it('reads and parses valid quota cache file', async () => { + const dir = tempDir(); + const cacheFile = path.join(dir, 'quota_cache.json'); + fs.writeFileSync( + cacheFile, + JSON.stringify({ + timestamp: '2026-09-28T22:00:00Z', + models: { + 'Gemini 3.8 Flash (High)': { + remainingFraction: 0.75, + resetTime: '2026-09-29T03:00:00Z', + }, + }, + }), + ); + const result = await fetchAntigravityUsage(cacheFile, {}); + expect(result.status).toBe('ok'); + if (result.status === 'ok') { + expect(result.fiveHour?.usedPercent).toBe(25); + expect(result.fiveHour?.resetsAt).toBe(Date.parse('2026-09-29T03:00:00Z')); + } + }); + + it('queries language server via loopback when credentials are provided in env', async () => { + const http = await import('node:http'); + let receivedToken: string | undefined; + + const server = http.createServer((req, res) => { + receivedToken = req.headers['x-codeium-csrf-token'] as string | undefined; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + userStatus: { + cascadeModelConfigData: { + clientModelConfigs: [ + { + label: 'Gemini 3.1 Pro (High)', + quotaInfo: { remainingFraction: 0.5, resetTime: '2026-09-29T03:40:56Z' }, + }, + ], + }, + }, + }), + ); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + + try { + const dir = tempDir(); + const cachePath = path.join(dir, 'quota_cache.json'); + const result = await fetchAntigravityUsage(cachePath, { + ANTIGRAVITY_LS_ADDRESS: `127.0.0.1:${port}`, + ANTIGRAVITY_CSRF_TOKEN: 'test-csrf-123', + }); + + expect(receivedToken).toBe('test-csrf-123'); + expect(result.status).toBe('ok'); + if (result.status === 'ok') { + expect(result.fiveHour?.usedPercent).toBe(50); + expect(result.sevenDay).toBeNull(); + } + expect(fs.existsSync(cachePath)).toBe(false); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + // Layout agy-hud writes to ~/.cache/agy-hud/quota_cache.json. + const agyHudCache = { + timestamp: '2026-09-29T01:28:29.778Z', + plan_name: 'Google AI Pro', + '3p-5h': { remaining_fraction: 1, reset_time: '2026-09-29T05:11:15Z' }, + '3p-weekly': { remaining_fraction: 1, reset_time: '2026-10-06T00:11:15Z' }, + 'gemini-5h': { remaining_fraction: 0, reset_time: '2026-09-29T03:40:56Z' }, + 'gemini-weekly': { remaining_fraction: 0.25, reset_time: '2026-10-01T02:57:22Z' }, + models: { + 'Gemini 3.1 Pro (High)': { remainingFraction: 0, resetTime: '2026-09-29T03:40:56Z' }, + }, + }; + + it('reads the weekly window from the agy-hud cache', async () => { + const cacheFile = path.join(tempDir(), 'quota_cache.json'); + fs.writeFileSync(cacheFile, JSON.stringify(agyHudCache)); + const result = await fetchAntigravityUsage(cacheFile, {}); + expect(result).toMatchObject({ + status: 'ok', + fiveHour: { usedPercent: 100, resetsAt: Date.parse('2026-09-29T03:40:56Z') }, + sevenDay: { usedPercent: 75, resetsAt: Date.parse('2026-10-01T02:57:22Z') }, + }); + }); + + it('fills the weekly window from the cache when the language server has none', async () => { + const http = await import('node:http'); + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + userStatus: { + cascadeModelConfigData: { + clientModelConfigs: [ + { + label: 'Gemini 3.1 Pro (High)', + quotaInfo: { remainingFraction: 0.5, resetTime: '2026-09-29T03:40:56Z' }, + }, + ], + }, + }, + }), + ); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + + try { + const cacheFile = path.join(tempDir(), 'quota_cache.json'); + const cacheJson = JSON.stringify(agyHudCache); + fs.writeFileSync(cacheFile, cacheJson); + const result = await fetchAntigravityUsage(cacheFile, { + ANTIGRAVITY_LS_ADDRESS: `127.0.0.1:${port}`, + ANTIGRAVITY_CSRF_TOKEN: 'test-csrf-123', + }); + + expect(result).toMatchObject({ + status: 'ok', + fiveHour: { usedPercent: 50 }, + sevenDay: { usedPercent: 75 }, + }); + // The cache belongs to agy-hud; a live read must not drop its weekly buckets. + expect(fs.readFileSync(cacheFile, 'utf8')).toBe(cacheJson); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('does not overwrite the five-hour cache window when language server reports weekly quota for Gemini', async () => { + const http = await import('node:http'); + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + userStatus: { + cascadeModelConfigData: { + clientModelConfigs: [ + { + label: 'Gemini 3.8 Flash (High)', + quotaInfo: { remainingFraction: 0.15, resetTime: '2026-10-01T02:57:22Z' }, + }, + ], + }, + }, + }), + ); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + + try { + const cacheFile = path.join(tempDir(), 'quota_cache.json'); + fs.writeFileSync(cacheFile, JSON.stringify(agyHudCache)); + const result = await fetchAntigravityUsage(cacheFile, { + ANTIGRAVITY_LS_ADDRESS: `127.0.0.1:${port}`, + ANTIGRAVITY_CSRF_TOKEN: 'test-csrf-123', + }); + + expect(result).toMatchObject({ + status: 'ok', + fiveHour: { usedPercent: 100, resetsAt: Date.parse('2026-09-29T03:40:56Z') }, + sevenDay: { usedPercent: 85, resetsAt: Date.parse('2026-10-01T02:57:22Z') }, + }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('uses discoverCredentials when provided', async () => { + const mockDiscover = vi.fn().mockReturnValue(null); + const missing = path.join(tempDir(), 'quota_cache.json'); + const result = await fetchAntigravityUsage(missing, {}, mockDiscover); + expect(mockDiscover).toHaveBeenCalled(); + expect(result.status).toBe('unavailable'); + }); + + it('tries candidate credentials in order until an active language server responds', async () => { + const http = await import('node:http'); + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + userStatus: { + cascadeModelConfigData: { + clientModelConfigs: [ + { + label: 'Gemini 3.8 Flash (High)', + quotaInfo: { remainingFraction: 0.7, resetTime: '2026-09-29T03:00:00Z' }, + }, + ], + }, + }, + }), + ); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + + try { + const mockDiscover = vi.fn().mockReturnValue([ + { address: '127.0.0.1:1', token: 'dead-token' }, + { address: `127.0.0.1:${port}`, token: 'live-token' }, + ]); + const missing = path.join(tempDir(), 'quota_cache.json'); + const result = await fetchAntigravityUsage(missing, {}, mockDiscover); + + expect(result).toMatchObject({ + status: 'ok', + fiveHour: { usedPercent: 30 }, + }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('discoverAllLanguageServerCredentials returns empty array when /proc does not exist', () => { + const origPlatform = process.platform; + try { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + expect(discoverAllLanguageServerCredentials()).toEqual([]); + expect(discoverLanguageServerCredentials()).toBeNull(); + } finally { + Object.defineProperty(process, 'platform', { value: origPlatform }); + } + }); +}); diff --git a/electron/ipc/antigravity-usage.ts b/electron/ipc/antigravity-usage.ts new file mode 100644 index 00000000..4b72fb2c --- /dev/null +++ b/electron/ipc/antigravity-usage.ts @@ -0,0 +1,500 @@ +import fs from 'fs'; +import http from 'node:http'; +import https from 'node:https'; +import os from 'os'; +import path from 'path'; +import type { UsageResult, UsageWindow } from './shared-types.js'; +import { warn as logWarn, debug as logDebug, errMessage } from '../log.js'; +import { clampPercent, finite, parseResetsAt } from './usage-shared.js'; + +/** + * Reads rate-limit windows for Google Antigravity CLI. + * + * Supports live loopback queries to the Antigravity language server service + * (via HTTP/HTTPS and `X-Codeium-Csrf-Token`), active process discovery on Linux, + * and reading local cache/statusline files (e.g. `quota_cache.json` or `statusline_payload.json`). + */ + +export function antigravityQuotaCachePath(env: NodeJS.ProcessEnv = process.env): string { + if (env.ANTIGRAVITY_QUOTA_CACHE) return env.ANTIGRAVITY_QUOTA_CACHE; + const cacheHome = env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'); + return path.join(cacheHome, 'agy-hud', 'quota_cache.json'); +} + +export function antigravityFallbackCachePaths(env: NodeJS.ProcessEnv = process.env): string[] { + const cacheHome = + env.XDG_CACHE_HOME || + (env.HOME + ? path.join(env.HOME, '.cache') + : env === process.env + ? path.join(os.homedir(), '.cache') + : ''); + const home = env.HOME || (env === process.env ? os.homedir() : ''); + const paths: string[] = []; + if (cacheHome) { + paths.push(path.join(cacheHome, 'agy-hud', 'statusline_payload.json')); + paths.push(path.join(cacheHome, 'agy-hud', 'quota_cache.json')); + } + if (home) { + paths.push( + path.join( + home, + '.gemini', + 'antigravity-cli', + 'scratch', + 'agy-hud', + 'statusline_payload.json', + ), + ); + paths.push( + path.join(home, '.gemini', 'antigravity-cli', 'scratch', 'agy-hud', 'quota_cache.json'), + ); + paths.push(path.join(home, '.gemini', 'antigravity-cli', 'statusline_payload.json')); + paths.push(path.join(home, '.gemini', 'antigravity-cli', 'quota_cache.json')); + } + return paths; +} + +interface RawQuotaWindow { + remainingFraction?: unknown; + remaining_fraction?: unknown; + resetTime?: unknown; + reset_time?: unknown; + reset_after_seconds?: unknown; + reset_in_seconds?: unknown; + usedPercent?: unknown; + used_percent?: unknown; + resetsAt?: unknown; + resets_at?: unknown; +} + +export function parseQuotaWindow(value: unknown, now = Date.now()): UsageWindow | null { + if (typeof value !== 'object' || value === null) return null; + const raw = value as RawQuotaWindow; + + // Direct used percent + const directUsed = finite(raw.usedPercent) ?? finite(raw.used_percent); + if (directUsed !== null) { + const rawReset = raw.resetsAt ?? raw.resets_at ?? raw.resetTime ?? raw.reset_time; + const resetSeconds = finite(raw.reset_in_seconds) ?? finite(raw.reset_after_seconds); + const resetsAt = + parseResetsAt(rawReset) ?? (resetSeconds !== null ? now + resetSeconds * 1000 : null); + return { usedPercent: clampPercent(directUsed), resetsAt }; + } + + // Remaining fraction (0.0 to 1.0) + const remaining = finite(raw.remainingFraction) ?? finite(raw.remaining_fraction); + if (remaining !== null) { + const clampedRemaining = Math.max(0, Math.min(1, remaining)); + const usedPercent = clampPercent(Math.round((1 - clampedRemaining) * 100)); + const rawReset = raw.resetsAt ?? raw.resets_at ?? raw.resetTime ?? raw.reset_time; + const resetSeconds = finite(raw.reset_in_seconds) ?? finite(raw.reset_after_seconds); + const resetsAt = + parseResetsAt(rawReset) ?? (resetSeconds !== null ? now + resetSeconds * 1000 : null); + return { usedPercent, resetsAt }; + } + + return null; +} + +function selectPrimaryModelWindow( + models: Record, + now: number, +): UsageWindow | null { + const entries = Object.entries(models); + if (entries.length === 0) return null; + + const parsedModels: Array<{ label: string; window: UsageWindow }> = []; + for (const [label, val] of entries) { + const win = parseQuotaWindow(val, now); + if (win) parsedModels.push({ label, window: win }); + } + if (parsedModels.length === 0) return null; + + // Prioritize Gemini models first + const geminiModels = parsedModels.filter((m) => /gemini/i.test(m.label)); + const pool = geminiModels.length > 0 ? geminiModels : parsedModels; + + // Pick the most constrained window (highest usedPercent) + let best = pool[0]; + for (let i = 1; i < pool.length; i++) { + if (pool[i].window.usedPercent > best.window.usedPercent) { + best = pool[i]; + } + } + return best.window; +} + +/** Parses Antigravity quota payload from cache, statusline, or local server. */ +type UsageSnapshot = Extract; + +export function parseAntigravityUsageResponse( + body: unknown, + now = Date.now(), +): UsageSnapshot | null { + if (typeof body !== 'object' || body === null) return null; + const raw = body as Record; + const quotaObj = + raw.quota && typeof raw.quota === 'object' ? (raw.quota as Record) : raw; + + let fiveHour: UsageWindow | null = null; + let sevenDay: UsageWindow | null = null; + let fetchedAt = now; + + if (raw.timestamp) { + const ts = parseResetsAt(raw.timestamp); + if (ts !== null) fetchedAt = ts; + } else if (finite(raw.fetchedAt)) { + fetchedAt = raw.fetchedAt as number; + } + + // Format 1: direct fiveHour / sevenDay + if ( + raw.fiveHour || + raw.five_hour || + raw.sevenDay || + raw.seven_day || + quotaObj.fiveHour || + quotaObj.five_hour || + quotaObj.sevenDay || + quotaObj.seven_day + ) { + fiveHour = parseQuotaWindow( + raw.fiveHour ?? raw.five_hour ?? quotaObj.fiveHour ?? quotaObj.five_hour, + now, + ); + sevenDay = parseQuotaWindow( + raw.sevenDay ?? raw.seven_day ?? quotaObj.sevenDay ?? quotaObj.seven_day, + now, + ); + } + + // Format 2: official bucket format (e.g. gemini-5h, gemini-weekly, 3p-5h, 3p-weekly) + const modelId = + (typeof raw.model === 'object' && + raw.model !== null && + typeof (raw.model as Record).id === 'string' + ? ((raw.model as Record).id as string) + : '') || (typeof raw.model === 'string' ? raw.model : ''); + const is3pModel = /claude|gpt|oss/i.test(modelId); + + if (!fiveHour) { + const first5h = is3pModel + ? (raw['3p-5h'] ?? quotaObj['3p-5h'] ?? raw['gemini-5h'] ?? quotaObj['gemini-5h']) + : (raw['gemini-5h'] ?? quotaObj['gemini-5h'] ?? raw['3p-5h'] ?? quotaObj['3p-5h']); + if (first5h) { + fiveHour = parseQuotaWindow(first5h, now); + } + } + if (!sevenDay) { + const firstWeekly = is3pModel + ? (raw['3p-weekly'] ?? + quotaObj['3p-weekly'] ?? + raw['gemini-weekly'] ?? + quotaObj['gemini-weekly'] ?? + raw.weekly ?? + quotaObj.weekly) + : (raw['gemini-weekly'] ?? + quotaObj['gemini-weekly'] ?? + raw['3p-weekly'] ?? + quotaObj['3p-weekly'] ?? + raw.weekly ?? + quotaObj.weekly); + if (firstWeekly) { + sevenDay = parseQuotaWindow(firstWeekly, now); + } + } + + // Format 3: language server userStatus payload: userStatus.cascadeModelConfigData.clientModelConfigs + let modelsObj: Record | null = null; + if (raw.models && typeof raw.models === 'object') { + modelsObj = raw.models as Record; + } else if (quotaObj.models && typeof quotaObj.models === 'object') { + modelsObj = quotaObj.models as Record; + } else if (raw.userStatus && typeof raw.userStatus === 'object') { + const userStatus = raw.userStatus as Record; + const cascade = userStatus.cascadeModelConfigData as Record | undefined; + const configs = cascade?.clientModelConfigs; + if (Array.isArray(configs)) { + modelsObj = {}; + for (const item of configs) { + if ( + typeof item === 'object' && + item !== null && + typeof item.label === 'string' && + item.quotaInfo + ) { + modelsObj[item.label] = item.quotaInfo; + } + } + } + } + + // Extract from models if fiveHour not yet found + if (!fiveHour && modelsObj) { + fiveHour = selectPrimaryModelWindow(modelsObj, now); + } + + if (!fiveHour && !sevenDay) return null; + + return { + status: 'ok', + fiveHour, + sevenDay, + fetchedAt, + }; +} + +export async function queryAntigravityLanguageServer( + address: string, + csrfToken: string, + timeoutMs = 2_000, +): Promise { + let initialProto = 'http:'; + let host = '127.0.0.1'; + let port = 0; + + if (address.startsWith('http://') || address.startsWith('https://')) { + try { + const url = new URL(address); + initialProto = url.protocol; + host = url.hostname; + port = Number(url.port); + } catch { + return null; + } + } else { + const [h, p] = address.split(':'); + host = h === 'localhost' ? '127.0.0.1' : h; + port = Number(p); + } + + if (!host || !port) return null; + + const tryRequest = (protocol: string): Promise => + new Promise((resolve) => { + const mod = protocol === 'https:' ? https : http; + const req = mod.request( + { + protocol, + hostname: host, + port, + path: '/exa.language_server_pb.LanguageServerService/GetUserStatus', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Connect-Protocol-Version': '1', + 'X-Codeium-Csrf-Token': csrfToken, + }, + rejectUnauthorized: false, + timeout: timeoutMs, + }, + (res) => { + if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) { + resolve(null); + return; + } + const chunks: Buffer[] = []; + res.on('data', (chunk) => + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)), + ); + res.on('end', () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + } catch { + resolve(null); + } + }); + }, + ); + + req.on('timeout', () => { + req.destroy(); + resolve(null); + }); + req.on('error', () => resolve(null)); + req.write('{}'); + req.end(); + }); + + let res = await tryRequest(initialProto); + if (!res && initialProto === 'http:') { + res = await tryRequest('https:'); + } + return res; +} + +/** Discovers active Language Server address and CSRF token from running processes on Linux. */ +export function discoverAllLanguageServerCredentials(): Array<{ address: string; token: string }> { + if (process.platform !== 'linux') return []; + const credentials: Array<{ address: string; token: string }> = []; + const seenAddresses = new Set(); + + try { + const entries = fs + .readdirSync('/proc') + .filter((entry) => /^\d+$/.test(entry)) + .map(Number) + .sort((a, b) => b - a); + + for (const pid of entries) { + try { + const envBuf = fs.readFileSync(`/proc/${pid}/environ`); + if (envBuf.includes(Buffer.from('ANTIGRAVITY_CSRF_TOKEN='))) { + const str = envBuf.toString('utf8'); + const addrMatch = str.match(/ANTIGRAVITY_LS_ADDRESS=([^\0]+)/); + const csrfMatch = str.match(/ANTIGRAVITY_CSRF_TOKEN=([^\0]+)/); + if (addrMatch && csrfMatch) { + const address = addrMatch[1]; + const token = csrfMatch[1]; + if (!seenAddresses.has(address)) { + seenAddresses.add(address); + credentials.push({ address, token }); + } + } + } + } catch { + // Skip unreadable processes or processes that exit during iteration + } + } + } catch { + // /proc unreadable + } + return credentials; +} + +export function discoverLanguageServerCredentials(): { address: string; token: string } | null { + const all = discoverAllLanguageServerCredentials(); + return all.length > 0 ? all[0] : null; +} + +export type DiscoverCredentialsFn = () => + | { address: string; token: string } + | Array<{ address: string; token: string }> + | null; + +async function readQuotaCacheFile( + primaryPath: string, + env: NodeJS.ProcessEnv, +): Promise { + const isCustomPath = primaryPath !== antigravityQuotaCachePath(env); + const candidates = isCustomPath + ? [primaryPath] + : [primaryPath, ...antigravityFallbackCachePaths(env)]; + const existingFiles: Array<{ filePath: string; mtimeMs: number }> = []; + + for (const filePath of candidates) { + try { + const stat = await fs.promises.stat(filePath); + existingFiles.push({ filePath, mtimeMs: stat.mtimeMs }); + } catch { + // File does not exist + } + } + + // Prioritize the newest file by mtime + existingFiles.sort((a, b) => b.mtimeMs - a.mtimeMs); + + for (const { filePath } of existingFiles) { + try { + return await fs.promises.readFile(filePath, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + logWarn('antigravity-usage', 'quota cache unreadable', { + file: filePath, + err: errMessage(err), + }); + } + } + } + return null; +} + +export async function fetchAntigravityUsage( + cachePath = antigravityQuotaCachePath(), + env = process.env, + discoverCredentials?: DiscoverCredentialsFn, +): Promise { + const lsAddress = env.ANTIGRAVITY_LS_ADDRESS; + const csrfToken = env.ANTIGRAVITY_CSRF_TOKEN; + + let candidateList: Array<{ address: string; token: string }> = []; + + if (lsAddress && csrfToken) { + candidateList = [{ address: lsAddress, token: csrfToken }]; + } else if (discoverCredentials) { + const discovered = discoverCredentials(); + if (discovered) { + candidateList = Array.isArray(discovered) ? discovered : [discovered]; + } + } else if (env === process.env && !env.ANTIGRAVITY_DISABLE_DISCOVERY) { + candidateList = discoverAllLanguageServerCredentials(); + } + + // Query discovered candidates in order until an active language server responds + let live: UsageSnapshot | null = null; + for (const cred of candidateList) { + live = await queryLiveUsage(cred); + if (live) break; + } + const cached = await readCachedUsage(cachePath, env); + if (!live) return cached; + if (cached.status !== 'ok') return live; + + // The Antigravity language server reports weekly quota for Gemini models in clientModelConfigs. + // If the live parsed "fiveHour" window has the same reset timestamp as the weekly window, + // it is actually the weekly window and must not overwrite the real five-hour window from cache. + const isLiveWeekly = + Boolean(live.fiveHour?.resetsAt) && + (live.fiveHour?.resetsAt === cached.sevenDay?.resetsAt || + live.fiveHour?.resetsAt === live.sevenDay?.resetsAt); + + const fiveHour = isLiveWeekly ? (cached.fiveHour ?? null) : (live.fiveHour ?? cached.fiveHour); + const sevenDay = isLiveWeekly + ? (live.fiveHour ?? cached.sevenDay) + : (live.sevenDay ?? cached.sevenDay); + + return { + ...live, + fiveHour, + sevenDay, + }; +} + +async function queryLiveUsage(credentials: { + address: string; + token: string; +}): Promise { + try { + const serverResponse = await queryAntigravityLanguageServer( + credentials.address, + credentials.token, + ); + return serverResponse ? parseAntigravityUsageResponse(serverResponse) : null; + } catch (err) { + logDebug('antigravity-usage', 'language server query failed, falling back to cache', { + err: errMessage(err), + }); + return null; + } +} + +async function readCachedUsage(cachePath: string, env: NodeJS.ProcessEnv): Promise { + const json = await readQuotaCacheFile(cachePath, env); + if (!json) { + return { status: 'unavailable', reason: 'No Antigravity quota found' }; + } + + let data: unknown; + try { + data = JSON.parse(json); + } catch { + return { status: 'error', message: 'Antigravity quota cache contains invalid JSON' }; + } + + const result = parseAntigravityUsageResponse(data); + if (!result) { + return { status: 'unavailable', reason: 'No rate-limit windows in response' }; + } + return result; +} diff --git a/electron/ipc/channel-manifest.json b/electron/ipc/channel-manifest.json index 2ebfca44..b0aa7f53 100644 --- a/electron/ipc/channel-manifest.json +++ b/electron/ipc/channel-manifest.json @@ -96,6 +96,7 @@ "MCP_PublishTourRequest": "mcp_publish_tour_request", "Remote_GetNotesRequest": "remote_get_notes_request", "Remote_SetNotesRequest": "remote_set_notes_request", + "Remote_GetUsageRequest": "remote_get_usage_request", "Remote_UpdateTaskStatus": "remote_update_task_status", "Remote_RendererReply": "remote_renderer_reply", "PlanContent": "plan_content", @@ -146,6 +147,7 @@ "GetMCPLogs": "get_mcp_logs", "GetClaudeUsage": "get_claude_usage", "GetCodexUsage": "get_codex_usage", + "GetAntigravityUsage": "get_antigravity_usage", "MCP_TaskCreated": "mcp_task_created", "MCP_TaskClosed": "mcp_task_closed", "MCP_TaskStateSync": "mcp_task_state_sync", diff --git a/electron/ipc/register-wiring.test.ts b/electron/ipc/register-wiring.test.ts index 533e49f3..ecf94286 100644 --- a/electron/ipc/register-wiring.test.ts +++ b/electron/ipc/register-wiring.test.ts @@ -84,6 +84,7 @@ const NOT_HANDLED_HERE: readonly string[] = [ IPC.Remote_CreateTaskRequest, IPC.Remote_GetNotesRequest, IPC.Remote_GetProjectsRequest, + IPC.Remote_GetUsageRequest, IPC.Remote_SetNotesRequest, IPC.StepsContent, IPC.SuperProductivityOpenTaskRequested, diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index 374da300..238141b2 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -70,6 +70,7 @@ import { readCoverageSummary } from './coverage.js'; import { loadEslintQualityFindings } from './eslint-quality-findings.js'; import { buildVerifyEnv, validateVerifyCommand, verificationRunner } from './verify.js'; import { startRemoteServer, getMCPLogs, type RemoteProject } from '../remote/server.js'; +import type { UsageProvider, UsageState } from './shared-types.js'; import type { RemoteAttentionState, RemoteAgent } from '../remote/protocol.js'; import { atomicWriteFileSync } from '../mcp/atomic.js'; import { getUserDataDir } from '../user-data-dir.js'; @@ -142,6 +143,7 @@ import { listCodexModels } from './codex-models.js'; import { getSystemMonospaceFonts } from './system-fonts.js'; import { fetchClaudeUsage } from './claude-usage.js'; import { fetchCodexUsage } from './codex-usage.js'; +import { fetchAntigravityUsage } from './antigravity-usage.js'; import path from 'path'; import { assertString, @@ -1666,6 +1668,7 @@ export function registerAllHandlers(win: BrowserWindow): void { callRenderer<{ notes: string }>(IPC.Remote_GetNotesRequest, { taskId }).then((r) => r.notes), setTaskNotes: (taskId: string, notes: string) => callRenderer<{ ok: boolean }>(IPC.Remote_SetNotesRequest, { taskId, notes }).then(() => {}), + getUsage: () => callRenderer>(IPC.Remote_GetUsageRequest, {}), getTaskAttention: (taskId: string): RemoteAttentionState => taskAttention.get(taskId) ?? 'idle', getTaskContext: (taskId: string) => taskContext.get(taskId), }; @@ -2214,6 +2217,7 @@ export function registerAllHandlers(win: BrowserWindow): void { ipcMain.handle(IPC.GetClaudeUsage, () => fetchClaudeUsage()); ipcMain.handle(IPC.GetCodexUsage, () => fetchCodexUsage()); + ipcMain.handle(IPC.GetAntigravityUsage, () => fetchAntigravityUsage()); // --- Forward window events to renderer --- win.on('focus', () => { diff --git a/electron/ipc/shared-types.ts b/electron/ipc/shared-types.ts index 715fd8ff..efa81f4d 100644 --- a/electron/ipc/shared-types.ts +++ b/electron/ipc/shared-types.ts @@ -201,7 +201,7 @@ export interface StepEntry { } /** Agents whose subscription rate limits the app can read. */ -export type UsageProvider = 'claude' | 'codex'; +export type UsageProvider = 'claude' | 'codex' | 'antigravity'; export interface UsageWindow { /** Percent of the window consumed, 0–100. */ @@ -222,6 +222,17 @@ export type UsageResult = /** Transient failure — the renderer keeps its last good snapshot. */ | { status: 'error'; message: string }; +/** The renderer's view of one provider's usage, also served to the phone. */ +export interface UsageState { + fiveHour: UsageWindow | null; + sevenDay: UsageWindow | null; + /** When the current windows were fetched; null until the first success. */ + fetchedAt: number | null; + /** `unavailable` means no subscription login — the bar hides. `error` keeps the last snapshot. */ + status: 'idle' | 'ok' | 'error' | 'unavailable'; + error: string | null; +} + export type UpdatePhase = | 'unsupported' | 'idle' diff --git a/electron/preload.cjs b/electron/preload.cjs index 1278a0f7..cd680b2e 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -103,6 +103,7 @@ const ALLOWED_CHANNELS = new Set([ 'mcp_publish_tour_request', 'remote_get_notes_request', 'remote_set_notes_request', + 'remote_get_usage_request', 'remote_update_task_status', 'remote_renderer_reply', 'plan_content', @@ -153,6 +154,7 @@ const ALLOWED_CHANNELS = new Set([ 'get_mcp_logs', 'get_claude_usage', 'get_codex_usage', + 'get_antigravity_usage', 'mcp_task_created', 'mcp_task_closed', 'mcp_task_state_sync', diff --git a/electron/remote/notes-route.test.ts b/electron/remote/notes-route.test.ts index 4d086fb7..404f3629 100644 --- a/electron/remote/notes-route.test.ts +++ b/electron/remote/notes-route.test.ts @@ -245,3 +245,48 @@ describe('notes route without a renderer bridge', () => { expect(res.status).toBe(503); }); }); + +describe('GET /api/mobile/usage', () => { + const usage = { + claude: { + fiveHour: { usedPercent: 40, resetsAt: null }, + sevenDay: null, + fetchedAt: 1, + status: 'ok' as const, + error: null, + }, + codex: { + fiveHour: null, + sevenDay: null, + fetchedAt: null, + status: 'idle' as const, + error: null, + }, + antigravity: { + fiveHour: null, + sevenDay: null, + fetchedAt: null, + status: 'idle' as const, + error: null, + }, + }; + const getUsage = vi.fn(async () => usage); + beforeEach(() => start({ getUsage })); + + it('returns the desktop snapshot for mobile and paired tokens', async () => { + for (const token of [mobileToken, await pair()]) { + const res = await request('GET', '/api/mobile/usage', { token }); + expect(res.status).toBe(200); + expect(res.json).toEqual(usage); + } + }); + + it('rejects agent tokens and non-GET methods', async () => { + getUsage.mockClear(); + for (const token of [coordinatorToken, subtaskToken]) { + expect((await request('GET', '/api/mobile/usage', { token })).status).toBe(403); + } + expect((await request('POST', '/api/mobile/usage', { token: mobileToken })).status).toBe(405); + expect(getUsage).not.toHaveBeenCalled(); + }); +}); diff --git a/electron/remote/server.ts b/electron/remote/server.ts index 6d95159f..c1274aa5 100644 --- a/electron/remote/server.ts +++ b/electron/remote/server.ts @@ -38,6 +38,7 @@ import { parseCanvasView, type CanvasView } from '../shared/canvas-view.js'; import { parseAgentTourPayload, type AgentTourPayload } from '../shared/agent-tour.js'; import type { SessionCaller, SessionCapabilities } from '../shared/delegation-types.js'; import type { ReasoningDocument } from '../shared/reasoning.js'; +import type { UsageProvider, UsageState } from '../ipc/shared-types.js'; import type { ReasoningUpdate } from '../shared/reasoning-state.js'; import type { Coordinator } from '../mcp/coordinator.js'; import { validateBranchName } from '../mcp/validation.js'; @@ -934,6 +935,8 @@ export function startRemoteServer(opts: { getTaskNotes?: (taskId: string) => Promise; /** Persist a task's notes (renderer-backed). */ setTaskNotes?: (taskId: string, notes: string) => Promise; + /** The desktop's last agent-subscription usage snapshot (renderer-backed). */ + getUsage?: () => Promise>; /** Renderer-derived task attention state (needs input, working, ready, …). */ getTaskAttention?: (taskId: string) => RemoteAttentionState; getTaskContext?: ( @@ -1353,6 +1356,21 @@ export function startRemoteServer(opts: { return jsonEnd(405, { error: 'method not allowed' }); } + // --- Subscription usage (read-only: mobile + paired) --- + // Serves the snapshot the desktop status bar already polls, so phones + // never add requests to the rate-limited usage endpoints. + if (url.pathname === '/api/mobile/usage') { + if (tokenClass !== 'mobile' && tokenClass !== 'paired') + return jsonEnd(403, { error: 'forbidden' }); + if (req.method !== 'GET') return jsonEnd(405, { error: 'method not allowed' }); + if (!opts.getUsage) return jsonEnd(503, { error: 'usage unavailable' }); + opts + .getUsage() + .then((usage) => jsonEnd(200, usage)) + .catch((err) => jsonEnd(500, { error: String(err) })); + return; + } + // --- Task notes (read: mobile + paired; write: paired) --- // The notes textarea shown on the desktop task panel. The QR-code mobile // token may read notes; writing them (text that lands in the desktop UI diff --git a/src/components/UsageStatusBar.client.test.tsx b/src/components/UsageStatusBar.client.test.tsx index 739dee86..e45c647a 100644 --- a/src/components/UsageStatusBar.client.test.tsx +++ b/src/components/UsageStatusBar.client.test.tsx @@ -23,6 +23,7 @@ const { mockRefreshUsage, usage } = vi.hoisted(() => { error: null, } satisfies UsageState, codex: idle, + antigravity: idle, }, }; }); @@ -30,7 +31,7 @@ const { mockRefreshUsage, usage } = vi.hoisted(() => { vi.mock('../store/store', () => ({ store: { usage }, refreshUsage: mockRefreshUsage, - USAGE_PROVIDERS: ['claude', 'codex'], + USAGE_PROVIDERS: ['claude', 'codex', 'antigravity'], })); const disposers: Array<() => void> = []; @@ -91,4 +92,19 @@ describe('UsageStatusBar', () => { container.querySelector('[role="status"]')?.click(); expect(mockRefreshUsage).toHaveBeenCalledWith('claude', { force: true }); }); + + it('does not display reset due when a window has 100% left', () => { + usage.antigravity = { + fiveHour: { usedPercent: 0, resetsAt: Date.now() - 60_000 }, + sevenDay: null, + fetchedAt: Date.now(), + status: 'ok', + error: null, + }; + const container = mount(); + const entries = container.querySelectorAll('[role="status"]'); + const agyEntry = Array.from(entries).find((e) => e.textContent?.includes('Antigravity')); + expect(agyEntry?.textContent).toContain('100% left'); + expect(agyEntry?.textContent).not.toContain('reset due'); + }); }); diff --git a/src/components/UsageStatusBar.tsx b/src/components/UsageStatusBar.tsx index ee7d3468..59811760 100644 --- a/src/components/UsageStatusBar.tsx +++ b/src/components/UsageStatusBar.tsx @@ -14,15 +14,19 @@ import { usageVisible, } from './usage-format'; -const PROVIDER_LABELS: Record = { claude: 'Claude', codex: 'Codex' }; +const PROVIDER_LABELS: Record = { + claude: 'Claude', + codex: 'Codex', + antigravity: 'Antigravity', +}; const POPOVER_WIDTH = 300; function UsageMeter(props: { label: string; window: UsageWindow; width?: number }) { const warn = () => props.window.usedPercent >= USAGE_WARN_PERCENT; const color = () => (warn() ? theme.warning : theme.accent); - const reset = () => formatReset(props.window.resetsAt); // The bar drains: filled means budget still available, matching the "% left" readout. const left = () => remainingPercent(props.window); + const reset = () => (left() === 100 ? '' : formatReset(props.window.resetsAt)); return ( @@ -188,7 +192,7 @@ function ProviderUsage(props: { provider: UsageProvider }) { /** * Bottom bar with the rate-limit windows of every agent subscription the app - * can read (Claude Code, Codex). Hidden until the first successful read, and + * can read (Claude Code, Codex, Antigravity). Hidden until the first successful read, and * permanently when no agent has a subscription login (API-key users). */ export function UsageStatusBar() { diff --git a/src/ipc/types.ts b/src/ipc/types.ts index 9257a558..9a0e3510 100644 --- a/src/ipc/types.ts +++ b/src/ipc/types.ts @@ -23,6 +23,7 @@ export type { StepEntry, UsageProvider, UsageResult, + UsageState, UsageWindow, VerificationRun, VerificationRunStatus, diff --git a/src/remote/AgentList.client.test.tsx b/src/remote/AgentList.client.test.tsx index 02638ca0..bfc6baac 100644 --- a/src/remote/AgentList.client.test.tsx +++ b/src/remote/AgentList.client.test.tsx @@ -4,6 +4,9 @@ import { AgentList } from './AgentList'; import { connect, reconnect } from './ws'; import type { RemoteAgent } from '../../electron/remote/protocol'; +// Usage meters poll over HTTP; they have their own tests. +vi.mock('./UsageStrip', () => ({ UsageStrip: () => null })); + class Socket { static OPEN = 1; static CONNECTING = 0; diff --git a/src/remote/AgentList.tsx b/src/remote/AgentList.tsx index 9663c794..c5032613 100644 --- a/src/remote/AgentList.tsx +++ b/src/remote/AgentList.tsx @@ -2,6 +2,7 @@ import { For, Show, createEffect, createMemo, createSignal } from 'solid-js'; import { agents, status, canControl } from './ws'; import { agentStatusDisplay } from './attention'; import { ConnectionBanner } from './ConnectionBanner'; +import { UsageStrip } from './UsageStrip'; import { readLocal, writeLocal } from './storage'; import { ProjectSwatch } from '../components/ProjectSwatch'; import type { RemoteAgent } from '../../electron/remote/protocol'; @@ -72,6 +73,7 @@ export function AgentList(props: AgentListProps) {
+ 0}>
({ UsageStrip: () => null })); + class Socket { static OPEN = 1; static CONNECTING = 0; diff --git a/src/remote/UsageStrip.client.test.tsx b/src/remote/UsageStrip.client.test.tsx new file mode 100644 index 00000000..5c905c47 --- /dev/null +++ b/src/remote/UsageStrip.client.test.tsx @@ -0,0 +1,67 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { render } from 'solid-js/web'; +import { UsageStrip } from './UsageStrip'; +import { fetchUsage } from './api'; + +vi.mock('./ws', () => ({ status: () => 'connected' })); +vi.mock('./api', () => ({ fetchUsage: vi.fn() })); + +let dispose: (() => void) | undefined; +afterEach(() => { + dispose?.(); + document.body.innerHTML = ''; +}); + +async function mount(): Promise { + const host = document.createElement('div'); + document.body.append(host); + dispose = render(() => , host); + await vi.waitFor(() => expect(fetchUsage).toHaveBeenCalled()); + await Promise.resolve(); + return host; +} + +it('shows the providers the desktop has a snapshot for', async () => { + vi.mocked(fetchUsage).mockResolvedValue({ + claude: { + fiveHour: { usedPercent: 85, resetsAt: null }, + sevenDay: { usedPercent: 20, resetsAt: null }, + fetchedAt: 1, + status: 'ok', + error: null, + }, + codex: { fiveHour: null, sevenDay: null, fetchedAt: null, status: 'unavailable', error: 'x' }, + antigravity: { + fiveHour: { usedPercent: 30, resetsAt: null }, + sevenDay: null, + fetchedAt: 1, + status: 'ok', + error: null, + }, + }); + const host = await mount(); + await vi.waitFor(() => expect(host.querySelector('.mobile-usage')).not.toBeNull()); + expect(host.textContent).toContain('Claude'); + expect(host.textContent).toContain('Antigravity'); + expect(host.textContent).not.toContain('Codex'); + expect(host.textContent).toContain('15% left'); + expect(host.textContent).toContain('80% left'); + expect(host.textContent).toContain('70% left'); + expect(host.querySelector('.usage-meter.warn')).not.toBeNull(); +}); + +it('stays hidden when no subscription usage is readable', async () => { + vi.mocked(fetchUsage).mockResolvedValue({ + claude: { fiveHour: null, sevenDay: null, fetchedAt: null, status: 'unavailable', error: 'x' }, + codex: { fiveHour: null, sevenDay: null, fetchedAt: null, status: 'idle', error: null }, + antigravity: { + fiveHour: null, + sevenDay: null, + fetchedAt: null, + status: 'unavailable', + error: null, + }, + }); + const host = await mount(); + expect(host.querySelector('.mobile-usage')).toBeNull(); +}); diff --git a/src/remote/UsageStrip.tsx b/src/remote/UsageStrip.tsx new file mode 100644 index 00000000..e965864c --- /dev/null +++ b/src/remote/UsageStrip.tsx @@ -0,0 +1,96 @@ +import { For, Show, createEffect, createSignal, onCleanup, onMount, untrack } from 'solid-js'; +import { fetchUsage } from './api'; +import { status } from './ws'; +import { + USAGE_WARN_PERCENT, + formatReset, + hasUsageSnapshot, + remainingPercent, + usageVisible, +} from '../components/usage-format'; +import type { UsageProvider, UsageState, UsageWindow } from '../../electron/ipc/shared-types'; + +// The desktop polls the usage endpoints itself; this only re-reads its snapshot. +const POLL_INTERVAL_MS = 60_000; + +const PROVIDER_LABELS: Record = { + claude: 'Claude', + codex: 'Codex', + antigravity: 'Antigravity', +}; + +function Meter(props: { label: string; window: UsageWindow }) { + const left = () => remainingPercent(props.window); + const reset = () => (left() === 100 ? '' : formatReset(props.window.resetsAt)); + return ( +
= USAGE_WARN_PERCENT }}> + {props.label} + + + + {left()}% left + + {reset()} + +
+ ); +} + +/** + * The desktop status bar's agent-subscription meters (Claude Code, Codex, Antigravity). + * Hidden until the desktop has a snapshot, like the bar itself; tap to re-read. + */ +export function UsageStrip() { + const [usage, setUsage] = createSignal | null>(null); + const providers = () => { + const u = usage(); + return u + ? (Object.keys(PROVIDER_LABELS) as UsageProvider[]).filter((p) => u[p] && usageVisible(u[p])) + : []; + }; + const load = () => { + if (status() !== 'connected') return; + fetchUsage().then(setUsage, () => { + // Keep the last snapshot; the connection banner already reports outages. + }); + }; + + // Also reads on every (re)connect, which covers the first load. + createEffect(() => { + if (status() === 'connected') untrack(load); + }); + onMount(() => { + const timer = setInterval(load, POLL_INTERVAL_MS); + onCleanup(() => clearInterval(timer)); + }); + + return ( + 0}> + + + ); +} diff --git a/src/remote/api.ts b/src/remote/api.ts index 6b17ade3..5a4cca28 100644 --- a/src/remote/api.ts +++ b/src/remote/api.ts @@ -1,8 +1,9 @@ // REST helpers for the mobile SPA. Data flows over the WebSocket (see ws.ts); -// these cover the request/response actions: pairing, task creation, and -// reading/saving task notes. +// these cover the request/response actions: pairing, task creation, task +// notes, and the subscription usage meters. import { getToken, getPairedToken } from './auth'; +import type { UsageProvider, UsageState } from '../../electron/ipc/shared-types'; export class ApiError extends Error { status: number; @@ -98,3 +99,10 @@ export async function saveNotes(taskId: string, notes: string): Promise { token, }); } + +/** The desktop's agent-subscription usage. Works with the base connection token. */ +export function fetchUsage(): Promise> { + const token = getToken(); + if (!token) throw new ApiError('Not connected', 401); + return request>('/api/mobile/usage', { token }); +} diff --git a/src/remote/mobile.css b/src/remote/mobile.css index ebcbd97a..cd72540b 100644 --- a/src/remote/mobile.css +++ b/src/remote/mobile.css @@ -246,6 +246,64 @@ select:focus-visible { textarea.mobile-input { resize: vertical; } +.mobile-usage { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + margin-bottom: 12px; + padding: 10px 12px; + border: 1px solid var(--mobile-border); + border-radius: 10px; + background: var(--mobile-panel); + color: var(--mobile-muted); + font: inherit; + font-size: 12px; + text-align: left; +} +.usage-provider { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 12px; +} +.usage-provider.stale { + opacity: 0.6; +} +.usage-name { + min-width: 52px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; +} +.usage-meter { + display: inline-flex; + align-items: center; + gap: 6px; +} +.usage-bar { + width: 48px; + height: 5px; + overflow: hidden; + border: 1px solid var(--mobile-border); + border-radius: 3px; + background: var(--mobile-bg); +} +.usage-bar span { + display: block; + height: 100%; + background: var(--mobile-accent); +} +.usage-left { + color: #e0eaf3; + font-weight: 500; +} +.usage-meter.warn .usage-bar span { + background: #ffdd98; +} +.usage-meter.warn .usage-left { + color: #ffdd98; +} .mobile-group { margin-top: 16px; } diff --git a/src/store/agents.test.ts b/src/store/agents.test.ts index 2eec0cd2..15dbce22 100644 --- a/src/store/agents.test.ts +++ b/src/store/agents.test.ts @@ -262,6 +262,16 @@ describe('markAgentExited', () => { expect(mockRefreshUsage).toHaveBeenCalledWith('codex'); }); + it('refreshes Antigravity usage for an Antigravity agent', () => { + mockAgents = { + 'agent-1': exitedAgent({ status: 'running', def: { ...codexDef, id: 'antigravity' } }), + }; + + markAgentExited('agent-1', exitInfo); + + expect(mockRefreshUsage).toHaveBeenCalledWith('antigravity'); + }); + it('leaves usage alone when an agent without a tracked meter exits', () => { mockAgents = { 'agent-1': exitedAgent({ status: 'running', def: { ...codexDef, id: 'gemini' } }), diff --git a/src/store/core.ts b/src/store/core.ts index 956b6597..70e5c02a 100644 --- a/src/store/core.ts +++ b/src/store/core.ts @@ -106,7 +106,11 @@ export const [store, setStore] = createStore({ customThemes: {}, activeCustomThemeId: null, mcpStatus: { running: false, port: null, coordinatorTaskId: null, mcpConfigPath: null }, - usage: { claude: { ...EMPTY_USAGE }, codex: { ...EMPTY_USAGE } }, + usage: { + claude: { ...EMPTY_USAGE }, + codex: { ...EMPTY_USAGE }, + antigravity: { ...EMPTY_USAGE }, + }, }); type CleanupPanelStore = Pick< diff --git a/src/store/remoteTaskHandler.ts b/src/store/remoteTaskHandler.ts index 8fa411f8..fc1ed100 100644 --- a/src/store/remoteTaskHandler.ts +++ b/src/store/remoteTaskHandler.ts @@ -7,6 +7,7 @@ import { publishAgentTour } from './agent-tour'; import { getTaskMindMap, openCanvasViewFromAgent, updateTaskMindMapFromAgent } from './canvas'; import { getTaskReasoning, updateTaskReasoningFromAgent } from './reasoning'; +import { unwrap } from 'solid-js/store'; import { store } from './core'; import { codeProjects } from './projects'; import { createTask, updateTaskNotes } from './tasks'; @@ -191,6 +192,13 @@ export function startRemoteTaskHandlers(): () => void { if (data && typeof data === 'object') handleGetNotes(data as GetNotesRequest); }, ); + const offGetUsage = window.electron.ipcRenderer.on( + IPC.Remote_GetUsageRequest, + (data: unknown) => { + if (data && typeof data === 'object') + reply((data as RendererRequest).reqId, true, unwrap(store.usage)); + }, + ); const offSetNotes = window.electron.ipcRenderer.on( IPC.Remote_SetNotesRequest, (data: unknown) => { @@ -254,5 +262,6 @@ export function startRemoteTaskHandlers(): () => void { offCreate(); offGetNotes(); offSetNotes(); + offGetUsage(); }; } diff --git a/src/store/types.ts b/src/store/types.ts index bda1861c..98723296 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -16,10 +16,11 @@ import type { AgentDef, StepEntry, UsageProvider, - UsageWindow, + UsageState, VerificationRun, WorktreeStatus, } from '../ipc/types'; +export type { UsageState } from '../ipc/types'; import type { ChatPermissionMode, ChatSession } from '../../electron/shared/agent-chat-types'; import type { AskCodeProvider } from '../../electron/shared/ask-code-models'; import type { DockerSource } from '../lib/docker'; @@ -497,16 +498,6 @@ export interface MCPStatus { mcpConfigPath: string | null; } -export interface UsageState { - fiveHour: UsageWindow | null; - sevenDay: UsageWindow | null; - /** When the current windows were fetched; null until the first success. */ - fetchedAt: number | null; - /** `unavailable` means no subscription login — the bar hides. `error` keeps the last snapshot. */ - status: 'idle' | 'ok' | 'error' | 'unavailable'; - error: string | null; -} - // Panel cell IDs. Shell terminals use "shell:0", "shell:1", etc. // Shell toolbar buttons use "shell-toolbar:0", "shell-toolbar:1", etc. export type PanelId = string; diff --git a/src/store/usage.test.ts b/src/store/usage.test.ts index 78ceb06d..03e99f30 100644 --- a/src/store/usage.test.ts +++ b/src/store/usage.test.ts @@ -18,12 +18,18 @@ const IDLE: UsageState = { vi.mock('./core', async () => { const { createMockStoreHarness } = await import('./test-helpers'); - core.harness = createMockStoreHarness({ usage: { claude: { ...IDLE }, codex: { ...IDLE } } }); + core.harness = createMockStoreHarness({ + usage: { claude: { ...IDLE }, codex: { ...IDLE }, antigravity: { ...IDLE } }, + }); return core.harness.moduleMock(); }); vi.mock('../lib/ipc', () => ({ invoke: mockInvoke })); vi.mock('../../electron/ipc/channels', () => ({ - IPC: { GetClaudeUsage: 'get_claude_usage', GetCodexUsage: 'get_codex_usage' }, + IPC: { + GetClaudeUsage: 'get_claude_usage', + GetCodexUsage: 'get_codex_usage', + GetAntigravityUsage: 'get_antigravity_usage', + }, })); type Slice = typeof import('./usage'); @@ -129,17 +135,19 @@ describe('usage store slice', () => { expect(mockInvoke.mock.calls.map(([channel]) => channel)).toEqual([ 'get_claude_usage', 'get_codex_usage', + 'get_antigravity_usage', ]); await vi.advanceTimersByTimeAsync(5 * 60_000); - expect(mockInvoke).toHaveBeenCalledTimes(4); + expect(mockInvoke).toHaveBeenCalledTimes(6); slice.stopUsagePolling(); await vi.advanceTimersByTimeAsync(10 * 60_000); - expect(mockInvoke).toHaveBeenCalledTimes(4); + expect(mockInvoke).toHaveBeenCalledTimes(6); }); - it('maps the bundled Claude Code and Codex agents to their meters', () => { + it('maps the bundled Claude Code, Codex, and Antigravity agents to their meters', () => { expect(slice.usageProviderForAgent('claude-code')).toBe('claude'); expect(slice.usageProviderForAgent('codex')).toBe('codex'); + expect(slice.usageProviderForAgent('antigravity')).toBe('antigravity'); expect(slice.usageProviderForAgent('gemini')).toBeNull(); }); }); diff --git a/src/store/usage.ts b/src/store/usage.ts index 800bac83..51ea6215 100644 --- a/src/store/usage.ts +++ b/src/store/usage.ts @@ -9,18 +9,23 @@ const POLL_INTERVAL_MS = 5 * 60_000; const MIN_REFRESH_GAP_MS = 30_000; /** Render order of the providers in the status bar. */ -export const USAGE_PROVIDERS: readonly UsageProvider[] = ['claude', 'codex']; +export const USAGE_PROVIDERS: readonly UsageProvider[] = ['claude', 'codex', 'antigravity']; const CHANNELS: Record = { claude: IPC.GetClaudeUsage, codex: IPC.GetCodexUsage, + antigravity: IPC.GetAntigravityUsage, }; -// Only the bundled Claude Code and Codex agents log in with a subscription the app can read. -const AGENT_PROVIDERS: Record = { 'claude-code': 'claude', codex: 'codex' }; +// Agents whose CLI sessions move tracked rate-limit windows. +const AGENT_PROVIDERS: Record = { + 'claude-code': 'claude', + codex: 'codex', + antigravity: 'antigravity', +}; let pollTimer: ReturnType | null = null; -const lastRequestAt: Record = { claude: 0, codex: 0 }; +const lastRequestAt: Record = { claude: 0, codex: 0, antigravity: 0 }; const inFlight: Partial>> = {}; /** The usage meter an agent's sessions move, if the app tracks one for it. */