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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/extension",
|
||||
"version": "1.9.31",
|
||||
"version": "1.9.32",
|
||||
"private": true,
|
||||
"description": "Hanzo AI Extension",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"host_permissions": [
|
||||
"http://localhost/*",
|
||||
"https://localhost/*",
|
||||
"https://claude.ai/*",
|
||||
"https://chatgpt.com/*",
|
||||
"<all_urls>"
|
||||
],
|
||||
"content_security_policy": {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) => (({ '&': '&', '<': '<', '>': '>', '"': '"' }) 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();
|
||||
});
|
||||
|
||||
@@ -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()]);
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user