Usage panel: Claude + Codex session/weekly usage in popup

Add an AI Usage card to the action popup showing Claude and Codex
session/weekly % used + reset time, a refresh button, and a link to
console.hanzo.ai/ai-accounts.

- shared/usage.ts: browser UsageHost (fetch with credentials:'include',
  no-op fs) running the @hanzo/usage engine in the background context,
  where host permissions attach live claude.ai/chatgpt.com cookies and
  bypass CORS. Claude via the package web strategy (empty cookieHeader
  passes its gate; real cookie rides credentials:'include'); Codex via a
  local fetch to /backend-api/wham/usage mapped onto the package's public
  UsageSnapshot/RateWindow types (its provider only ships an auth.json
  OAuth lane, unusable without disk).
- background.ts + background-firefox.ts: usage.fetch message handler.
- popup: AI Usage card + renderer, refresh, accounts link.
- build.js: alias @hanzo/usage to the sibling repo's built ESM for both
  background bundles (same pattern as the @hanzo/gui shim).
- manifests: host_permissions for claude.ai + chatgpt.com (Chrome MV3 +
  Firefox).
- version 1.9.31 -> 1.9.32 (patch).
This commit is contained in:
Hanzo AI
2026-07-07 15:14:45 -07:00
parent 300337a57b
commit 967297f2dc
11 changed files with 296 additions and 4 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/extension",
"version": "1.9.31",
"version": "1.9.32",
"private": true,
"description": "Hanzo AI Extension",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/browser-extension",
"version": "1.9.31",
"version": "1.9.32",
"description": "Hanzo AI Browser Extension",
"main": "dist/background.js",
"scripts": {
@@ -27,6 +27,7 @@ import {
type ZapManager,
} from './shared/zap.js';
import { connectNativeZap, type NativeZapState } from './shared/native-zap.js';
import { fetchAllUsage } from './shared/usage.js';
import { pickEvaluable, wrapEvaluable } from './shared/evaluable.js';
import {
getRagConfig,
@@ -3000,6 +3001,17 @@ browser.runtime.onMessage.addListener((request: any, sender: any, sendResponse:
sendResponse({ success: false, error: 'Tab filesystem requires CDP bridge.' });
break;
// AI provider usage (Claude + Codex, via @hanzo/usage)
case 'usage.fetch': {
try {
const providers = await fetchAllUsage();
sendResponse({ success: true, providers });
} catch (e: any) {
sendResponse({ success: false, error: e.message });
}
break;
}
default:
sendResponse({ success: false, error: `Unknown action: ${request.action}` });
break;
+14
View File
@@ -38,6 +38,7 @@ import { BrowserControl } from './browser-control';
import { WebGPUAI } from './webgpu-ai';
import { getCDPBridge, CDPBridge } from './browser-dispatch';
import * as auth from './auth';
import { fetchAllUsage } from './shared/usage.js';
import { listModels, chatCompletion, ChatMessage } from './chat-client';
import { PageMonitor } from './page-monitor';
import {
@@ -1787,6 +1788,19 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
break;
}
// --- AI provider usage (Claude + Codex, via @hanzo/usage) ---
case 'usage.fetch': {
(async () => {
try {
const providers = await fetchAllUsage();
sendResponse({ success: true, providers });
} catch (e: any) {
sendResponse({ success: false, error: e.message });
}
})();
break;
}
}
}
+12 -2
View File
@@ -16,6 +16,14 @@ async function build() {
const hanzoGuiShim = path.join(__dirname, 'gui-primitives.tsx');
console.log('Using @hanzo/gui local primitives shim:', hanzoGuiShim);
// The Usage panel runs the headless @hanzo/usage engine in the background
// context. The package lives in the sibling repo (hanzoai/usage); alias its
// built ESM entry so esbuild bundles it into the background scripts. Same
// pattern as the @hanzo/gui shim above — one specifier, one source of truth.
const hanzoUsage = path.join(__dirname, '..', '..', '..', '..', 'usage', 'packages', 'core', 'dist', 'index.js');
const backgroundAliases = { '@hanzo/usage': hanzoUsage };
console.log('Using @hanzo/usage engine:', hanzoUsage);
// Ensure dist directories exist
fs.mkdirSync('dist/browser-extension', { recursive: true });
fs.mkdirSync('dist/browser-extension/chrome', { recursive: true });
@@ -40,7 +48,8 @@ async function build() {
platform: 'browser',
target: ['chrome90', 'safari14'],
format: 'esm',
external: ['chrome', 'browser']
external: ['chrome', 'browser'],
alias: backgroundAliases
});
// Build Firefox-specific background script (no ESM export, uses browser.* APIs)
@@ -51,7 +60,8 @@ async function build() {
platform: 'browser',
target: ['firefox91'],
format: 'iife', // Immediately-invoked function expression (no exports)
external: ['browser']
external: ['browser'],
alias: backgroundAliases
});
// Build WebGPU AI module
@@ -17,6 +17,8 @@
"host_permissions": [
"http://localhost/*",
"https://localhost/*",
"https://claude.ai/*",
"https://chatgpt.com/*",
"<all_urls>"
],
"content_security_policy": {
+2
View File
@@ -19,6 +19,8 @@
"host_permissions": [
"http://localhost/*",
"https://localhost/*",
"https://claude.ai/*",
"https://chatgpt.com/*",
"<all_urls>"
],
"content_security_policy": {
+16
View File
@@ -96,6 +96,22 @@
<div class="divider"></div>
<!-- AI Usage (Claude + Codex, from the current browser session) -->
<div class="usage-header" style="display:flex;align-items:center;justify-content:space-between;">
<h3>AI Usage</h3>
<button id="usage-refresh" class="btn-icon" title="Refresh usage">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M23 4v6h-6M1 20v-6h6"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
</button>
</div>
<div id="usage-list" class="usage-list" style="display:flex;flex-direction:column;gap:8px;"></div>
<a href="https://console.hanzo.ai/ai-accounts" target="_blank"
style="display:inline-block;margin-top:8px;font-size:11px;color:var(--text-muted,#888);">Manage AI accounts →</a>
<div class="divider"></div>
<!-- Tools -->
<h3>Tools</h3>
<div class="tools-grid">
+84
View File
@@ -693,6 +693,90 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
// --- AI Usage (Claude + Codex) — data fetched by the background context
// where host permissions attach the live claude.ai / chatgpt.com cookies. ---
const usageList = document.getElementById('usage-list');
const usageRefresh = document.getElementById('usage-refresh');
interface UsageWindow { usedPercent: number; resetsAt?: string }
interface UsageSnapshotLite {
primary?: UsageWindow;
secondary?: UsageWindow;
identity?: { plan?: string };
}
interface ProviderUsageLite {
providerId: string;
displayName: string;
color?: string;
snapshot?: UsageSnapshotLite;
error?: string;
}
const esc = (s: string) =>
s.replace(/[&<>"]/g, (c) => (({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }) as Record<string, string>)[c]);
function resetLabel(iso?: string): string {
if (!iso) return '';
const ms = new Date(iso).getTime() - Date.now();
if (!Number.isFinite(ms)) return '';
if (ms <= 0) return 'resets now';
const m = Math.round(ms / 60000);
if (m < 60) return `resets in ${m}m`;
const h = Math.round(m / 60);
if (h < 24) return `resets in ${h}h`;
return `resets in ${Math.round(h / 24)}d`;
}
function lane(label: string, w: UsageWindow | undefined, color?: string): string {
if (!w || typeof w.usedPercent !== 'number') return '';
const pct = Math.max(0, Math.min(100, Math.round(w.usedPercent)));
return `
<div class="usage-lane" style="display:flex;align-items:center;gap:8px;font-size:11px;margin-top:4px;">
<span style="width:52px;color:var(--text-muted,#888);">${label}</span>
<span style="flex:1;height:6px;border-radius:3px;background:var(--surface-2,#222);overflow:hidden;">
<span style="display:block;height:100%;width:${pct}%;background:${color || 'var(--accent,#d97757)'};"></span>
</span>
<span style="width:34px;text-align:right;">${pct}%</span>
</div>`;
}
function renderUsage(providers: ProviderUsageLite[]) {
if (!usageList) return;
usageList.innerHTML = providers
.map((p) => {
const body = p.error
? `<div style="font-size:11px;color:var(--text-muted,#888);margin-top:4px;">${esc(p.error)}</div>`
: `${lane('Session', p.snapshot?.primary, p.color)}${lane('Weekly', p.snapshot?.secondary, p.color)}
<div style="font-size:10px;color:var(--text-muted,#888);margin-top:3px;">${esc(resetLabel(p.snapshot?.primary?.resetsAt))}</div>`;
const plan = p.snapshot?.identity?.plan;
return `
<div class="usage-row" style="padding:8px;border:1px solid var(--border,#2a2a2a);border-radius:8px;">
<div style="display:flex;align-items:center;gap:6px;">
<span style="width:8px;height:8px;border-radius:50%;background:${p.color || '#888'};"></span>
<strong style="font-size:12px;">${esc(p.displayName)}</strong>
${plan ? `<span style="font-size:10px;color:var(--text-muted,#888);">${esc(plan)}</span>` : ''}
</div>
${body}
</div>`;
})
.join('');
}
function loadUsage() {
if (!usageList) return;
usageList.innerHTML = '<div style="font-size:11px;color:var(--text-muted,#888);">Loading…</div>';
chrome.runtime.sendMessage({ action: 'usage.fetch' }, (resp) => {
if (chrome.runtime.lastError || !resp?.success) {
usageList.innerHTML = '<div style="font-size:11px;color:var(--text-muted,#888);">Usage unavailable</div>';
return;
}
renderUsage(resp.providers as ProviderUsageLite[]);
});
}
usageRefresh?.addEventListener('click', loadUsage);
loadUsage();
// Init
checkAuth();
});
+148
View File
@@ -0,0 +1,148 @@
// Usage engine bridge — runs the @hanzo/usage providers inside the extension
// background context, where host permissions let fetch() attach the user's
// claude.ai / chatgpt.com session cookies (credentials: 'include') and bypass
// CORS. There is no filesystem in a browser, so file ops are no-ops and the
// OAuth/CLI strategies simply report "unavailable": the web strategy carries
// Claude, and a small local fetch carries Codex (whose provider only ships an
// auth.json OAuth lane, unusable without disk access).
import {
runPipeline,
claudeProvider,
codexProvider,
type UsageHost,
type HttpRequest,
type HttpResponse,
type UsageSnapshot,
type RateWindow,
type ProviderFetchOutcome,
} from '@hanzo/usage';
/** Serialisable per-provider result sent to the popup. */
export interface ProviderUsage {
providerId: string;
displayName: string;
color?: string;
dashboardUrl?: string;
snapshot?: UsageSnapshot;
/** User-facing failure reason (e.g. "Open chatgpt.com to sign in"). */
error?: string;
}
const http = async (req: HttpRequest): Promise<HttpResponse> => {
const controller = new AbortController();
const timeout = req.timeoutMs
? setTimeout(() => controller.abort(), req.timeoutMs)
: undefined;
try {
const res = await fetch(req.url, {
method: req.method ?? 'GET',
headers: req.headers,
body: req.body,
credentials: 'include',
signal: controller.signal,
});
const headers: Record<string, string> = {};
res.headers.forEach((v, k) => {
headers[k] = v;
});
return { status: res.status, headers, text: await res.text() };
} finally {
if (timeout) clearTimeout(timeout);
}
};
/** Browser host: network only; no fs, env, or home directory in an extension. */
const browserHost: UsageHost = {
readTextFile: async () => undefined,
listDir: async () => [],
writeTextFile: async () => {},
http,
env: () => undefined,
homeDir: () => '',
now: () => new Date(),
};
const isAuthError = (text: string): boolean => /\b(401|403)\b/.test(text);
const outcomeError = (providerHost: string, outcome: ProviderFetchOutcome): string => {
const attempt = [...outcome.attempts].reverse().find((a) => a.error);
const detail = attempt?.error ?? 'no data';
return isAuthError(detail) ? `Open ${providerHost} to sign in` : detail;
};
const fetchClaude = async (): Promise<ProviderUsage> => {
const { displayName, color, dashboardUrl } = claudeProvider.metadata;
const base: ProviderUsage = { providerId: 'claude', displayName, color, dashboardUrl };
try {
// Empty cookieHeader passes the web strategy's `typeof === 'string'` gate;
// the real session cookie is attached by credentials: 'include'.
const outcome = await runPipeline(claudeProvider, {
host: browserHost,
sourceMode: 'web',
settings: { cookieHeader: '' },
});
if (outcome.result) return { ...base, snapshot: outcome.result.usage };
return { ...base, error: outcomeError('claude.ai', outcome) };
} catch (e) {
return { ...base, error: e instanceof Error ? e.message : String(e) };
}
};
// Codex wire shape (chatgpt.com/backend-api/wham/usage) — mirrors the private
// interface in @hanzo/usage's codex provider, mapped onto the package's public
// UsageSnapshot / RateWindow types.
interface CodexWindow {
used_percent?: number;
reset_at?: number;
limit_window_seconds?: number;
}
interface CodexUsageResponse {
plan_type?: string;
rate_limit?: { primary_window?: CodexWindow; secondary_window?: CodexWindow };
}
const toWindow = (w: CodexWindow | undefined): RateWindow | undefined => {
if (!w || typeof w.used_percent !== 'number') return undefined;
return {
usedPercent: w.used_percent,
windowMinutes: w.limit_window_seconds ? Math.round(w.limit_window_seconds / 60) : undefined,
resetsAt: w.reset_at ? new Date(w.reset_at * 1000).toISOString() : undefined,
};
};
const fetchCodex = async (): Promise<ProviderUsage> => {
const { displayName, color, dashboardUrl } = codexProvider.metadata;
const base: ProviderUsage = { providerId: 'codex', displayName, color, dashboardUrl };
try {
const res = await http({
url: 'https://chatgpt.com/backend-api/wham/usage',
headers: { Accept: 'application/json' },
timeoutMs: 30_000,
});
if (res.status !== 200) {
return {
...base,
error: isAuthError(String(res.status))
? 'Open chatgpt.com to sign in'
: `HTTP ${res.status}`,
};
}
const body = JSON.parse(res.text) as CodexUsageResponse;
const snapshot: UsageSnapshot = {
providerId: 'codex',
primary: toWindow(body.rate_limit?.primary_window),
secondary: toWindow(body.rate_limit?.secondary_window),
identity: { providerId: 'codex', plan: body.plan_type, loginMethod: 'web' },
dataConfidence: 'percentOnly',
updatedAt: browserHost.now().toISOString(),
};
return { ...base, snapshot };
} catch (e) {
return { ...base, error: e instanceof Error ? e.message : String(e) };
}
};
/** Fetch Claude + Codex usage in parallel from the current browser session. */
export const fetchAllUsage = async (): Promise<ProviderUsage[]> =>
Promise.all([fetchClaude(), fetchCodex()]);
+4
View File
@@ -9,6 +9,10 @@
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@hanzo/usage": ["../../../../usage/packages/core/dist/index.d.ts"]
},
"declaration": true,
"declarationMap": true,
"sourceMap": true,