UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

916 lines (915 loc) • 49.2 kB
import { execFile, spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { statSync } from "node:fs"; import { lstat, open, readFile, readdir } from "node:fs/promises"; import { homedir } from "node:os"; import { claudeConfigDir, codexHome } from "../home-paths.js"; import path from "node:path"; import { promisify } from "node:util"; import { atomicInPrivateDir, bridgeRoot, object } from "./protocol.js"; import { stripTerminal } from "../terminal-text.js"; import { codexExecutable } from "./codex-binary.js"; import { readSpeechKey } from "./speech-key.js"; import { resolveSpeechRegion, SPEECH_REGIONS } from "./speech-voice.js"; import { CODEX_ACCOUNT, claudeAccountSubscription, claudeAccountEmail, claudeAccountRef, claudeHomeOfEnv, claudeHomes } from "./claude-accounts.js"; import { planName, subscriptionDate } from "./subscription.js"; const exec = promisify(execFile); /** The status-line snapshot: `usage/claude.json` for the default home, `usage/claude-<id>.json` for others. */ const claudeFile = (home) => path.join(bridgeRoot(), "usage", home && !home.isDefault ? `claude-${home.id}.json` : "claude.json"); const safeText = (v) => typeof v === "string" ? v.replace(/[\x00-\x1f\x7f]/g, "").slice(0, 100) : undefined; function window(id, name, percent, reset) { if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100) return; const resetsAt = typeof reset === "number" && Number.isSafeInteger(reset) && reset > 0 && reset < 32_503_680_000 ? new Date(reset * 1000).toISOString() : undefined; return { id, name, usedPercent: percent, resetsAt }; } function duration(value, fallback) { if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0 || value > 525600) return fallback; return value % 1440 === 0 ? `${value / 1440}-day limit` : value % 60 === 0 ? `${value / 60}-hour limit` : `${value}-minute limit`; } export function codexUsage(value, now = new Date()) { const result = object(value), multiple = object(result.rateLimitsByLimitId); const buckets = Object.keys(multiple).length ? Object.entries(multiple).sort(([a], [b]) => a === "codex" ? -1 : b === "codex" ? 1 : a.localeCompare(b)).slice(0, 16) : [["codex", result.rateLimits]]; const windows = []; for (const [key, raw] of buckets) { const bucket = object(raw), name = safeText(bucket.limitName); // Spark is a separate, lightweight lane nobody budgets by; leave it out. if (/spark/i.test(String(key)) || /spark/i.test(name ?? "")) continue; for (const field of ["primary", "secondary"]) { const item = object(bucket[field]); const label = duration(item.windowDurationMins, field === "primary" ? "Primary limit" : "Secondary limit"); const entry = window(`${safeText(key)}:${field}`, name ? `${name} · ${label}` : label, item.usedPercent, item.resetsAt); if (entry) windows.push(entry); } } return { source: "codex", windows, updatedAt: now.toISOString(), ...(!windows.length ? { message: "Codex has not reported account limits. Sign in with your ChatGPT account in Codex on this computer." } : {}) }; } /** Display metadata from the ID token's claims only, never an authentication decision. */ export function codexSubscription(idToken, now = new Date()) { if (typeof idToken !== "string" || idToken.length > 65_536) return undefined; const parts = idToken.split("."); if (parts.length !== 3 || !/^[A-Za-z0-9_-]+$/.test(parts[1])) return undefined; try { const claims = object(JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"))); const auth = object(claims["https://api.openai.com/auth"]); const plan = planName(auth.chatgpt_plan_type); if (!plan) return undefined; const startedAt = subscriptionDate(auth.chatgpt_subscription_active_start); const renewsAt = subscriptionDate(auth.chatgpt_subscription_active_until); return { plan, ...(startedAt ? { startedAt } : {}), ...(renewsAt ? { renewsAt } : {}), checkedAt: now.toISOString() }; } catch { return undefined; } } export async function readCodexSubscription(file = path.join(codexHome(), "auth.json"), now = new Date()) { try { const handle = await open(file, "r"); try { const stat = await handle.stat(); if (!stat.isFile() || stat.size > 131_072) return undefined; return codexSubscription(object(object(JSON.parse(await handle.readFile("utf8"))).tokens).id_token, now); } finally { await handle.close(); } } catch { return undefined; } } /** Parse OpenCode's own cost ledger. `stats --days 7` is a rolling local view. */ export function openCodeUsage(output, now = new Date()) { const plain = stripTerminal(output); const match = /Total Cost\s+\$([0-9][0-9,]*(?:\.[0-9]+)?)/i.exec(plain); const amountUSD = match ? Number(match[1].replace(/,/g, "")) : Number.NaN; if (!Number.isFinite(amountUSD) || amountUSD < 0 || amountUSD > 1_000_000_000) { return { source: "opencode", windows: [], message: "Could not read OpenCode's seven-day cost. Run opencode stats --days 7 on this computer." }; } return { source: "opencode", windows: [], spend: { amountUSD, period: "rolling_7_days" }, updatedAt: now.toISOString() }; } /** Read the authoritative cost that OpenCode records for its local sessions. */ export async function readOpenCodeUsage(executable = "opencode", now = new Date()) { try { const { stdout } = await exec(executable, ["stats", "--days", "7", "--pure"], { timeout: 12_000, maxBuffer: 1_048_576 }); return openCodeUsage(stdout, now); } catch (error) { return { source: "opencode", windows: [], message: openCodeFailure(error) }; } } /** Not installed, not signed in and a failing command each say so, with the command's own first line. */ export function openCodeFailure(error) { const failure = error; if (failure?.code === "ENOENT") return "OpenCode is not installed on this computer (opencode was not found on PATH)."; const said = stripTerminal(String(failure?.stderr ?? "")).split(/\r?\n/) .map(line => line.replace(/[\x00-\x1f\x7f]/g, " ").trim()).find(Boolean)?.slice(0, 200); if (/not (logged|signed) in|no (credentials|providers?)|unauthori[sz]ed|opencode auth login/i.test(said ?? "")) return `OpenCode is not signed in on this computer. Run opencode auth login. (${said})`; if (failure?.killed || failure?.signal) return "opencode stats --days 7 timed out on this computer."; const exit = typeof failure?.code === "number" ? ` (exit ${failure.code})` : ""; return `opencode stats --days 7 failed${exit}${said ? `: ${said}` : ""}. Run it on this computer to see why.`; } const openCodeAuthFile = () => path.join(process.env.OPENCODE_DATA_DIR || path.join(process.env.XDG_DATA_HOME || path.join(homedir(), ".local", "share"), "opencode"), "auth.json"); /** OpenCode's OpenRouter key stays local and is used only with OpenRouter. */ export async function readOpenRouterKey() { try { const handle = await open(openCodeAuthFile(), "r"); try { const stat = await handle.stat(); if (!stat.isFile() || stat.size > 65_536) return undefined; const key = object(object(JSON.parse(await handle.readFile("utf8"))).openrouter).key; return typeof key === "string" && key.length >= 16 && key.length <= 4_096 && !/[\x00-\x1f\x7f]/.test(key) ? key : undefined; } finally { await handle.close(); } } catch { return undefined; } } /** OpenCode Go's key stays local and is sent only to the Go gateway. */ export async function readOpenCodeGoKey() { try { const handle = await open(openCodeAuthFile(), "r"); try { const metadata = await handle.stat(); if (!metadata.isFile() || metadata.size > 65_536) return undefined; const key = object(object(JSON.parse(await handle.readFile("utf8")))["opencode-go"]).key; return typeof key === "string" && key.length >= 16 && key.length <= 4_096 && !/[\x00-\x1f\x7f]/.test(key) ? key : undefined; } finally { await handle.close(); } } catch { return undefined; } } const GO_GATEWAY = "https://opencode.ai/zen/go/v1"; const GO_KEY_MESSAGE = "Connect OpenCode Go on this computer to see its usage."; const GO_WINDOWS = [ { key: "rolling", id: "opencode-go:plan:5h", name: "5-hour limit" }, { key: "weekly", id: "opencode-go:plan:7d", name: "Weekly limit" }, { key: "monthly", id: "opencode-go:plan:30d", name: "Monthly limit" }, ]; /** * Go's own report of the plan, `GET /zen/go/v1/usage`: * `{usage: {rolling|weekly|monthly: {status, percent, resetsAt}}}`. It is * account-wide, so it already counts every computer's use, and it is what * Go enforces: `status: "rate-limited"` is the window that refuses requests. * It carries no dollar amounts, so none are shown. */ export function openCodeGoPlan(value) { const usage = object(object(value).usage); return GO_WINDOWS.flatMap(({ key, id, name }) => { const item = object(usage[key]); const percent = item.percent; if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100) return []; const resetsAt = typeof item.resetsAt === "string" && Number.isFinite(Date.parse(item.resetsAt)) ? new Date(item.resetsAt).toISOString() : undefined; const limited = typeof item.status === "string" && item.status !== "ok"; return [{ id, name, usedPercent: Math.round(percent * 10) / 10, ...(resetsAt ? { resetsAt } : {}), ...(limited ? { limited: true } : {}) }]; }); } export async function fetchOpenCodeGoPlan(key, fetchImpl = fetch) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 8_000); try { const response = await fetchImpl(`${GO_GATEWAY}/usage`, { headers: { authorization: `Bearer ${key}`, accept: "application/json" }, redirect: "error", signal: controller.signal }); if (!response.ok) throw new Error(`OpenCode Go usage returned ${response.status}.`); return openCodeGoPlan(await response.json()); } finally { clearTimeout(timer); } } const openCodeLogDir = () => path.join(process.env.OPENCODE_DATA_DIR || path.join(process.env.XDG_DATA_HOME || path.join(homedir(), ".local/share"), "opencode"), "log"); /** Refusals already read from each log, so a poll reads only what OpenCode * appended since (this log grows by tens of MB a day). */ const refusalScans = new Map(); const FIRST_SCAN_BYTES = 64 * 1_048_576; /** "Go usage limit exceeded" refusals in OpenCode's own log over the last * `withinMs`. The first read of a log covers at most its last 64 MB. */ export async function readGoRefusals(now = new Date(), withinMs = 24 * 3_600_000, root = openCodeLogDir()) { const since = now.getTime() - withinMs; const events = []; for (const name of (await readdir(root).catch(() => [])).filter(name => name.endsWith(".log")).slice(0, 32)) { const file = path.join(root, name); const info = await lstat(file).catch(() => undefined); if (!info?.isFile() || info.isSymbolicLink() || info.mtimeMs < since) continue; let scan = refusalScans.get(file); // A rotated or truncated log starts over. if (!scan || scan.ino !== info.ino || scan.offset > info.size) scan = { ino: info.ino, offset: Math.max(0, info.size - FIRST_SCAN_BYTES), events: [] }; if (info.size > scan.offset) { const handle = await open(file, "r"); try { const length = Math.min(info.size - scan.offset, FIRST_SCAN_BYTES), bytes = Buffer.alloc(length); await handle.read(bytes, 0, length, info.size - length); const text = bytes.toString("utf8"); // Only whole lines: a line still being written is read next time. const complete = text.lastIndexOf("\n") + 1; for (const line of text.slice(0, complete).split("\n")) { if (!line.includes("Go usage limit exceeded")) continue; const stamp = /timestamp=(\S+)/.exec(line)?.[1] ?? /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/.exec(line)?.[0]; const at = stamp ? Date.parse(stamp) : NaN; if (Number.isFinite(at)) scan.events.push({ at, model: /modelID=([A-Za-z0-9._\/-]{1,120})/.exec(line)?.[1] }); } scan.offset = info.size - length + complete; } finally { await handle.close(); } } scan.events = scan.events.filter(event => event.at >= now.getTime() - 7 * 24 * 3_600_000).slice(-10_000); refusalScans.set(file, scan); events.push(...scan.events.filter(event => event.at >= since && event.at <= now.getTime() + 60_000)); } if (!events.length) return undefined; events.sort((a, b) => a.at - b.at); const models = [...new Set(events.flatMap(event => event.model ? [`opencode-go/${event.model}`] : []))].sort().slice(0, 8); return { count: events.length, first: new Date(events[0].at).toISOString(), last: new Date(events.at(-1).at).toISOString(), models }; } const clock = (iso) => `${iso.slice(11, 16)} UTC`; export function openCodeGoUsage(windows, refusals, now, hasKey, planError) { const refused = refusals ? `OpenCode refused ${refusals.count} Go request${refusals.count === 1 ? "" : "s"} with "usage limit exceeded" in the last day (${refusals.first.slice(0, 10) === refusals.last.slice(0, 10) ? `${clock(refusals.first)} to ${clock(refusals.last)}` : `${refusals.first.slice(0, 16).replace("T", " ")} to ${refusals.last.slice(0, 16).replace("T", " ")} UTC`}).` : undefined; const reached = windows.filter(window => window.limited).map(window => window.name.replace(" limit", "").toLowerCase()); const message = [ !hasKey ? GO_KEY_MESSAGE : planError ? "Could not read Go's usage report; showing what OpenCode's log says." : undefined, reached.length ? `Go is refusing requests: the ${reached.join(" and ")} limit is reached.` : undefined, refused, ].filter(Boolean).join(" "); const renewsAt = windows.find(w => w.id === "opencode-go:plan:30d")?.resetsAt; const subscription = hasKey ? { plan: "Go", ...(renewsAt ? { renewsAt } : {}), checkedAt: now.toISOString() } : undefined; return { source: "opencode-go", windows, ...(subscription ? { subscription } : {}), updatedAt: now.toISOString(), ...(message ? { message } : {}) }; } /** The accounts one caller can read: the sources it names, and Go's plan * windows only for a caller that asked for them (`goPlan=1`). */ export function usageForCaller(accounts, sources, goPlan) { return accounts.filter(account => sources.has(account.source)).map(account => goPlan || account.source !== "opencode-go" ? account : { ...account, windows: account.windows.filter(window => !window.id.startsWith("opencode-go:plan:")) }); } let goPlanCached; let goPlanPending; async function cachedOpenCodeGoPlan(key, now) { if (!goPlanCached || now.getTime() - goPlanCached.at >= 60_000) { goPlanPending ??= fetchOpenCodeGoPlan(key) .then(windows => { goPlanCached = { at: now.getTime(), windows }; return windows; }) .finally(() => { goPlanPending = undefined; }); } return goPlanPending ? await goPlanPending : goPlanCached.windows; } export async function readOpenCodeGoUsage(now = new Date(), options = {}) { const [key, refusals] = await Promise.all([(options.readKey ?? readOpenCodeGoKey)(), (options.readRefusals ?? readGoRefusals)(now).catch(() => undefined)]); if (!key) return openCodeGoUsage([], refusals, now, false); try { const windows = options.fetchImpl ? await fetchOpenCodeGoPlan(key, options.fetchImpl) : await cachedOpenCodeGoPlan(key, now); return openCodeGoUsage(windows, refusals, now, true); } catch { return openCodeGoUsage([], refusals, now, true, true); } } /** OpenRouter reports the current UTC calendar week's charged usage per key. */ export async function fetchOpenRouterUsage(key, fetchImpl = fetch, now = new Date()) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 8_000); try { const response = await fetchImpl("https://openrouter.ai/api/v1/key", { headers: { authorization: `Bearer ${key}`, accept: "application/json" }, redirect: "error", signal: controller.signal, }); if (!response.ok) throw new Error(`OpenRouter usage endpoint returned ${response.status}.`); const amountUSD = object(object(await response.json()).data).usage_weekly; if (typeof amountUSD !== "number" || !Number.isFinite(amountUSD) || amountUSD < 0 || amountUSD > 1_000_000_000) { throw new Error("OpenRouter returned invalid usage."); } return { source: "openrouter", accountId: createHash("sha256").update(key).digest("hex"), windows: [], spend: { amountUSD, period: "calendar_week" }, updatedAt: now.toISOString(), }; } finally { clearTimeout(timer); } } async function liveOpenRouterUsage(now) { if (typeof fetch !== "function") return undefined; const key = await readOpenRouterKey(); if (!key) return undefined; try { return await fetchOpenRouterUsage(key, fetch, now); } catch { return { source: "openrouter", windows: [], message: "Could not read OpenRouter spend. Check its key in OpenCode." }; } } /** ElevenLabs' subscription: characters used this billing period against the plan's limit. */ export function elevenLabsUsage(value, now = new Date()) { const sub = object(value); const used = sub.character_count, limit = sub.character_limit; const plan = planName(sub.tier); const renewsAt = subscriptionDate(object(sub.next_invoice).next_payment_attempt_unix); const subscription = plan ? { plan, ...(renewsAt ? { renewsAt } : {}), checkedAt: now.toISOString() } : undefined; const metadata = subscription ? { subscription } : {}; const count = (n) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0; if (!count(used) || !count(limit) || limit === 0) { return { source: "elevenlabs", ...metadata, windows: [], updatedAt: now.toISOString(), message: "ElevenLabs did not report a character limit for this key." }; } const entry = window("elevenlabs:characters", "Characters this period", Math.min(100, Math.round(used / limit * 1000) / 10), sub.next_character_count_reset_unix); return { source: "elevenlabs", ...metadata, windows: entry ? [{ ...entry, usedCharacters: used, limitCharacters: limit }] : [], updatedAt: now.toISOString() }; } /** The key the Hook already uses for /v1/speech, sent only to ElevenLabs; the answer carries counts, never the key. */ export async function fetchElevenLabsUsage(key, fetchImpl = fetch, now = new Date(), origin = SPEECH_REGIONS.global) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 8_000); try { const response = await fetchImpl(`${origin}/v1/user/subscription`, { headers: { "xi-api-key": key, accept: "application/json" }, redirect: "error", signal: controller.signal, }); if (!response.ok) throw new Error(`ElevenLabs subscription endpoint returned ${response.status}.`); return elevenLabsUsage(await response.json(), now); } finally { clearTimeout(timer); } } /** No row without a key: most computers have no spoken replies set up. `PHREN_ELEVENLABS_USAGE=off` skips it (the test suite does). */ async function liveElevenLabsUsage(now) { if (typeof fetch !== "function" || process.env.PHREN_ELEVENLABS_USAGE === "off") return undefined; const key = await readSpeechKey().catch(() => undefined); if (!key) return undefined; try { return await fetchElevenLabsUsage(key, fetch, now, (await resolveSpeechRegion()).origin); } catch { return { source: "elevenlabs", windows: [], updatedAt: now.toISOString(), message: "Could not read ElevenLabs usage. Check this computer's ElevenLabs key." }; } } /** "seven_day_fable" → "7-day, Fable"; weekly-all stays explicit. */ function claudeWindowName(key) { const spans = [["five_hour", "5-hour"], ["seven_day", "7-day"], ["one_hour", "1-hour"], ["one_day", "1-day"]]; for (const [prefix, label] of spans) { if (key === prefix) return key === "seven_day" ? "7-day, all models" : `${label} limit`; if (key.startsWith(prefix + "_")) { const model = key.slice(prefix.length + 1).split("_").filter(Boolean) .map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(" "); return `${label}, ${model}`; } } return key.split("_").filter(Boolean).map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(" "); } /** Claude Code's documented status-line payload: rate_limits carries the * five_hour and seven_day windows (the 5h/7d unified limits) and, when the * build emits them, per-model keys such as seven_day_fable. Every window * keeps its own reset time; a per-model window is its own weekly allowance * with its own denominator, not a subset of seven_day, so it can show a * higher percentage without contradicting the all-models window. */ export function claudeUsage(value, now = new Date()) { const limits = object(object(value).rate_limits); // Every window Claude Code reports, each on its own line: the overall // ones first, then per-model windows (Fable, Opus...) in a stable order. const order = (key) => key === "five_hour" ? 0 : key === "seven_day" ? 1 : 2; const windows = Object.keys(limits).sort((a, b) => order(a) - order(b) || a.localeCompare(b)).slice(0, 16).flatMap(key => { const item = object(limits[key]); const entry = window(key, claudeWindowName(key), item.used_percentage, item.resets_at); return entry ? [entry] : []; }); return { source: "claude", windows, updatedAt: now.toISOString(), origin: "status-line", ...(!windows.length ? { message: "Usage appears after Claude Code replies with a subscription account on this computer." } : {}) }; } /** * Claude's per-model weekly windows (Fable today) never reach the status * line, so they come from the snapshot Claude Code itself keeps of its usage * endpoint in ~/.claude.json — refreshed whenever Claude opens /usage or * checks a limit. Each window carries the snapshot's own time so the phone * can say how old it is. No sign-in token is read or sent. */ export function claudeScopedWindows(config, now = new Date()) { const cached = object(object(config).cachedUsageUtilization); const fetched = typeof cached.fetchedAtMs === "number" && Number.isFinite(cached.fetchedAtMs) && cached.fetchedAtMs > 0 && cached.fetchedAtMs <= now.getTime() + 60_000 ? new Date(cached.fetchedAtMs).toISOString() : undefined; if (!fetched) return []; const limits = object(cached.utilization).limits; if (!Array.isArray(limits)) return []; const windows = []; for (const raw of limits.slice(0, 16)) { const limit = object(raw); if (limit.kind !== "weekly_scoped") continue; const model = safeText(object(object(object(limit.scope).model)).display_name); if (!model) continue; const reset = Date.parse(String(limit.resets_at ?? "")); const entry = window(`seven_day_${model.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`, `7-day, ${model}`, limit.percent, Number.isFinite(reset) ? Math.round(reset / 1000) : undefined); if (entry) windows.push({ ...entry, asOf: fetched }); } return windows; } const claudeCredentialsFile = (home) => path.join(home?.dir ?? claudeConfigDir(), ".credentials.json"); /** * The OAuth usage endpoint Claude Code itself reads, mapped to the same * windows the status-line snapshot produces. `limits` is the structured * list (session, weekly_all, weekly_scoped with the model name); the older * per-key fields are ignored because `limits` already carries them. */ export function claudeOAuthUsage(value, now = new Date()) { const limits = object(value).limits; const windows = []; if (Array.isArray(limits)) { for (const raw of limits.slice(0, 16)) { const limit = object(raw); const percent = limit.percent; const reset = typeof limit.resets_at === "string" && Number.isFinite(Date.parse(limit.resets_at)) ? Math.round(Date.parse(limit.resets_at) / 1000) : undefined; if (limit.kind === "session") { const entry = window("five_hour", "5-hour limit", percent, reset); if (entry) windows.push(entry); } else if (limit.kind === "weekly_all") { const entry = window("seven_day", "7-day, all models", percent, reset); if (entry) windows.push(entry); } else if (limit.kind === "weekly_scoped") { const model = safeText(object(object(object(limit.scope).model)).display_name); if (!model) continue; const entry = window(`seven_day_${model.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`, `7-day, ${model}`, percent, reset); if (entry) windows.push(entry); } } } return { source: "claude", windows, updatedAt: now.toISOString(), origin: "oauth", ...(!windows.length ? { message: "Claude has not reported account limits on this computer." } : {}) }; } /** * Claude Code's own sign-in token, read locally and used only against * Anthropic's usage endpoint — never persisted, logged, or sent elsewhere. * The file is authoritative off macOS; macOS keeps it in the login keychain, * with the file left stale after a refresh. A non-default `home` reads only its * own file, and nothing on macOS (its keychain item name is unverified). */ export async function readClaudeToken(execSecurity = exec, platform = process.platform, home) { const parse = (raw) => { if (raw.length > 16_384) return undefined; const oauth = object(object(JSON.parse(raw)).claudeAiOauth); const token = typeof oauth.accessToken === "string" && oauth.accessToken.length > 0 ? oauth.accessToken : undefined; const expires = typeof oauth.expiresAt === "number" ? oauth.expiresAt : undefined; if (!token || (expires !== undefined && expires <= Date.now() + 60_000)) return undefined; return token; }; const other = home !== undefined && !home.isDefault; if (other && platform === "darwin") return undefined; if (platform === "darwin") { try { const { stdout } = await execSecurity("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { timeout: 5_000, maxBuffer: 16_384 }); const token = parse(stdout.trim()); if (token) return token; } catch { /* Headless installs can keep credentials in the file instead. */ } } try { return parse(await readFile(claudeCredentialsFile(home), "utf8")); } catch { return undefined; } } /** Fetch live limits; any failure falls back to the local snapshot. */ export async function fetchClaudeUsage(token, fetchImpl = fetch, now = new Date()) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 8_000); try { const response = await fetchImpl("https://api.anthropic.com/api/oauth/usage", { headers: { authorization: `Bearer ${token}`, "anthropic-beta": "oauth-2025-04-20", accept: "application/json" }, redirect: "error", signal: controller.signal, }); if (!response.ok) throw new Error(`Claude usage endpoint returned ${response.status}.`); return claudeOAuthUsage(await response.json(), now); } finally { clearTimeout(timer); } } async function liveClaudeUsage(now, home) { if (typeof fetch !== "function") return undefined; const token = await readClaudeToken(exec, process.platform, home); if (!token) return undefined; try { return await fetchClaudeUsage(token, fetch, now); } catch { return undefined; } } /** Only initialize and read limits. Never create a thread, prompt, or login. */ export function readCodexLimits(executable = codexExecutable()) { return new Promise(resolve => { const child = spawn(executable, ["app-server"], { cwd: homedir(), stdio: ["pipe", "pipe", "ignore"] }); let pending = "", bytes = 0, initialized = false, done = false; const finish = (result) => { if (done) return; done = true; clearTimeout(timer); child.stdin.destroy(); child.stdout.destroy(); child.kill("SIGTERM"); const kill = setTimeout(() => { if (child.exitCode === null) child.kill("SIGKILL"); }, 1000); kill.unref(); child.once("exit", () => clearTimeout(kill)); resolve(result || { source: "codex", windows: [], message: "Could not read Codex limits. Open Codex on this computer, check its sign-in, then refresh." }); }; const timer = setTimeout(() => finish(), 12_000); const send = (value) => { if (!done) child.stdin.write(JSON.stringify(value) + "\n"); }; child.on("error", () => finish()); child.on("exit", () => finish()); child.stdin.on("error", () => finish()); child.stdout.setEncoding("utf8"); child.stdout.on("data", (chunk) => { bytes += Buffer.byteLength(chunk); if (bytes > 1_048_576) { finish(); return; } pending += chunk; let newline; while (!done && (newline = pending.indexOf("\n")) >= 0) { const line = pending.slice(0, newline); pending = pending.slice(newline + 1); try { const response = object(JSON.parse(line)); if (response.id === 0 && !initialized) { if (response.error || !response.result) { finish(); return; } initialized = true; send({ method: "initialized", params: {} }); send({ id: 1, method: "account/rateLimits/read" }); } else if (response.id === 1 && initialized) { finish(response.error ? undefined : codexUsage(response.result)); } else if (response.method && response.id !== undefined) { // Reject server-initiated requests, including auth refresh. No // provider credential or capability token is handled by Phren. send({ id: response.id, error: { code: -32601, message: "Unsupported request" } }); } } catch { finish(); } } }); send({ id: 0, method: "initialize", params: { clientInfo: { name: "phren_usage", title: "Phren Usage", version: "1" } } }); }); } /** * GitHub Copilot's own quota report (`gh api /copilot_internal/user`): one * window per limited quota (premium requests, usually) with the monthly * reset. Unlimited quotas are named in the message, not drawn as 0%. */ export function copilotUsage(value, now = new Date()) { const result = object(value), snapshots = object(result.quota_snapshots); const reset = typeof result.quota_reset_date_utc === "string" && Number.isFinite(Date.parse(result.quota_reset_date_utc)) ? new Date(Date.parse(result.quota_reset_date_utc)).toISOString() : undefined; const names = { premium_interactions: "Premium requests · monthly", chat: "Chat · monthly", completions: "Completions · monthly" }; const windows = [], unlimited = []; for (const [id, raw] of Object.entries(snapshots).slice(0, 8)) { const quota = object(raw), key = safeText(id) ?? "quota"; if (quota.unlimited === true) { unlimited.push((names[key] ?? key).replace(" · monthly", "").toLowerCase()); continue; } const remaining = quota.percent_remaining; if (typeof remaining !== "number" || !Number.isFinite(remaining) || remaining < 0 || remaining > 100) continue; windows.push({ id: key, name: names[key] ?? key, usedPercent: Math.round((100 - remaining) * 10) / 10, ...(reset ? { resetsAt: reset } : {}) }); } const plan = planName(result.copilot_plan); const notes = [safeText(result.copilot_plan) ? `Plan: ${safeText(result.copilot_plan)}.` : "", unlimited.length ? `Unlimited: ${unlimited.join(", ")}.` : ""].filter(Boolean).join(" "); return { source: "copilot", windows, updatedAt: now.toISOString(), ...(plan ? { subscription: { plan, ...(reset ? { renewsAt: reset } : {}), checkedAt: now.toISOString() } } : {}), ...(notes || !windows.length ? { message: notes || "GitHub reported no Copilot quotas for this account." } : {}) }; } /** The first GitHub CLI found; the Hook's service PATH may not include Homebrew. */ function ghExecutable() { for (const candidate of ["/opt/homebrew/bin/gh", "/usr/local/bin/gh", "/usr/bin/gh"]) { try { if (statSync(candidate).isFile()) return candidate; } catch { /* next */ } } return "gh"; } /** Reads through the GitHub CLI's own sign-in; the token never reaches Phren. */ export async function readCopilotUsage(now = new Date(), run = async (file, args) => (await exec(file, args, { timeout: 10_000, maxBuffer: 1_048_576, cwd: homedir() })).stdout) { try { return copilotUsage(JSON.parse(await run(ghExecutable(), ["api", "/copilot_internal/user"])), now); } catch (error) { const failure = object(error); const detail = [failure.stderr, failure.message].filter((part) => typeof part === "string").join(" "); return { source: "copilot", windows: [], message: /\bHTTP 404\b/i.test(detail) ? "No Copilot subscription on this GitHub account" : "Could not read Copilot usage. Sign in with gh auth login on this computer (Copilot reports its quotas through GitHub)." }; } } /** Under this much left on any window, an account is near its limit: information for the owner, never a reason to avoid it. */ export const NEAR_LIMIT_LEFT = 20; /** No quota on this window now: at 100% or refused by the service, and its reset not yet passed. */ export function windowExhausted(window, now) { const reset = window.resetsAt ? Date.parse(window.resetsAt) : NaN; if (window.reset || Number.isFinite(reset) && reset <= now) return false; return window.limited === true || typeof window.usedPercent === "number" && window.usedPercent >= 100; } /** Percent left on a window at `now`: none once its reset passed or with no percent, 0 while the service refuses requests. */ export function windowLeft(window, now) { const reset = window.resetsAt ? Date.parse(window.resetsAt) : NaN; if (window.reset || Number.isFinite(reset) && reset <= now) return undefined; if (window.limited) return 0; return typeof window.usedPercent === "number" ? Math.max(0, Math.min(100, Math.round(100 - window.usedPercent))) : undefined; } /** What a capacity probe says about room: each Codex and Claude account's least room left, and whether it has none * (`exhausted`, with `until` the last reset that frees it). */ export function capacityRoom(accounts, now) { return accounts.map(usage => { const left = usage.windows.map(window => windowLeft(window, now)).filter((value) => value !== undefined); const out = usage.windows.filter(window => windowExhausted(window, now)); const until = out.map(window => window.resetsAt).filter((value) => Boolean(value)).sort().at(-1); return { source: usage.source, ...(usage.account?.id ? { account: usage.account.id } : {}), ...(left.length ? { leftPercent: Math.min(...left) } : {}), ...(out.length ? { exhausted: true, ...(until ? { until } : {}) } : {}) }; }); } /** A Claude report older than this says nothing about the account now. */ export const CLAUDE_REPORT_MAX_AGE_MS = 3 * 86_400_000; /** * What a saved Claude report still says at `now`: a window whose reset time * has passed is reported as reset without its old percent, and a report * older than three days is dropped, so a stale number never reaches the * phone's cards, rings or widgets. */ export function settleClaudeUsage(usage, now) { if (!usage.windows.length) return usage; const reported = (w) => Date.parse(w.asOf ?? usage.updatedAt ?? ""); const windows = usage.windows .filter(w => { const at = reported(w); return !Number.isFinite(at) || now - at < CLAUDE_REPORT_MAX_AGE_MS; }) .map(w => { const reset = w.resetsAt ? Date.parse(w.resetsAt) : NaN; if (!Number.isFinite(reset) || reset > now) return w; const { usedPercent: _old, ...rest } = w; return { ...rest, reset: true }; }); if (windows.length) return { ...usage, windows }; const since = usage.updatedAt?.slice(0, 10); return { ...usage, windows, message: `No usage report from Claude on this computer${since ? ` since ${since}` : ""}. It updates when Claude Code runs here.` }; } export class AccountUsageReader { readCodex; now; readClaudeLive; readOpenCode; readOpenRouter; readOpenCodeGo; readCopilot; platform; readElevenLabs; cached; pending; claudeCached = new Map(); claudePending = new Map(); spendingCached = new Map(); spendingPending = new Map(); constructor(readCodex = readCodexLimits, now = Date.now, readClaudeLive = liveClaudeUsage, readOpenCode = now => readOpenCodeUsage("opencode", now), readOpenRouter = liveOpenRouterUsage, readOpenCodeGo = readOpenCodeGoUsage, readCopilot = readCopilotUsage, platform = process.platform, readElevenLabs = liveElevenLabsUsage) { this.readCodex = readCodex; this.now = now; this.readClaudeLive = readClaudeLive; this.readOpenCode = readOpenCode; this.readOpenRouter = readOpenRouter; this.readOpenCodeGo = readOpenCodeGo; this.readCopilot = readCopilot; this.platform = platform; this.readElevenLabs = readElevenLabs; } /** `allAccounts`: one Claude row per home. Otherwise only the default home's, as * before accounts existed, since older phones refuse two rows of one source. */ async read(sources, allAccounts = false) { const [limits, spending] = await Promise.all([this.limits(allAccounts), this.spending(sources?.has("copilot") ?? true, sources?.has("elevenlabs") ?? true)]); return { accounts: [...limits, ...spending] }; } /** Codex and every Claude home, without the spend readers: what a dispatch capacity probe asks. */ async limits(allAccounts = true) { if (!this.cached || this.now() - this.cached.at >= 60_000) { this.pending ??= this.readCodex().then(value => { this.cached = { at: this.now(), value }; return value; }).finally(() => { this.pending = undefined; }); } const codex = this.pending ?? Promise.resolve(this.cached.value); // Every home, default first; the labels and keys are read once per poll. const homes = allAccounts ? claudeHomes() : claudeHomes().slice(0, 1); const claudeRow = async (home) => { const email = claudeAccountEmail(home); const subscription = claudeAccountSubscription(home, new Date(this.now())); return { ...settleClaudeUsage(await this.claude(home), this.now()), ...(subscription ? { subscription } : {}), account: { ...claudeAccountRef(home), ...(email ? { email } : {}) } }; }; const [codexValue, claude, subscription] = await Promise.all([codex, Promise.all(homes.map(claudeRow)), readCodexSubscription(undefined, new Date(this.now()))]); return [{ ...codexValue, ...(subscription ? { subscription } : {}), account: CODEX_ACCOUNT }, ...claude]; } /** ElevenLabs is read only when the caller shows it: every read spends a request against the key's quota. */ async spending(includeCopilot, includeElevenLabs) { const key = `${includeCopilot}:${includeElevenLabs}`; const cached = this.spendingCached.get(key); if (!cached || this.now() - cached.at >= 60_000) { const at = this.now(); if (!this.spendingPending.has(key)) { const pending = Promise.all([this.readOpenCode(new Date(at)), this.readOpenCodeGo(new Date(at)), this.readOpenRouter(new Date(at)), includeCopilot ? this.readCopilot(new Date(at)) : Promise.resolve(undefined), includeElevenLabs ? this.readElevenLabs(new Date(at)).catch(() => undefined) : Promise.resolve(undefined)]) .then(([openCode, openCodeGo, openRouter, copilot, elevenLabs]) => [openCode, openCodeGo, ...(openRouter ? [openRouter] : []), ...(copilot ? [copilot] : []), ...(elevenLabs ? [elevenLabs] : [])]) .then(value => { this.spendingCached.set(key, { at, value }); return value; }) .finally(() => { this.spendingPending.delete(key); }); this.spendingPending.set(key, pending); } } return this.spendingPending.get(key) ?? cached.value; } /** Live first, so the phone's minute-by-minute poll keeps Claude current * even when Claude Code is not running; the local snapshot is the backup. * Off macOS a non-default home reads its own credentials file; on macOS it * has no live read (the keychain item name for custom homes is unverified). */ async claude(home) { if (home.isDefault || this.platform !== "darwin") { const cached = this.claudeCached.get(home.dir); if (!cached || this.now() - cached.at >= 60_000) { const at = this.now(); if (!this.claudePending.has(home.dir)) this.claudePending.set(home.dir, this.readClaudeLive(new Date(at), home) .then(value => { this.claudeCached.set(home.dir, { at, value }); return value; }) .catch(() => { this.claudeCached.set(home.dir, { at, value: undefined }); return undefined; }) .finally(() => { this.claudePending.delete(home.dir); })); } const pending = this.claudePending.get(home.dir); const live = pending ? await pending : this.claudeCached.get(home.dir)?.value; if (live?.windows.length) return live; } return this.snapshotClaude(home); } async snapshotClaude(home) { let claude = { source: "claude", windows: [], message: "Usage appears after Claude Code replies on this computer. Run phren bridge install if usage reporting is not set up yet." }; try { const handle = await open(claudeFile(home), "r"); try { const stat = await handle.stat(); if (!stat.isFile() || stat.size > 16_384) throw new Error("Invalid usage snapshot"); const saved = object(JSON.parse(await handle.readFile("utf8"))); const date = new Date(String(saved.updatedAt)); if (!Number.isFinite(date.getTime()) || date.getTime() > this.now() + 60_000) throw new Error("Invalid usage timestamp"); claude = claudeUsage(saved, date); } finally { await handle.close(); } } catch { /* No status-line report yet. Keep unavailable explicit. */ } try { const handle = await open(home.configFile, "r"); try { const stat = await handle.stat(); if (!stat.isFile() || stat.size > 16_777_216) throw new Error("Invalid Claude config"); const scoped = claudeScopedWindows(JSON.parse(await handle.readFile("utf8")), new Date(this.now())) .filter(entry => !claude.windows.some(existing => existing.id === entry.id)); if (scoped.length) claude = { ...claude, windows: [...claude.windows, ...scoped], message: undefined }; } finally { await handle.close(); } } catch { /* No snapshot from Claude Code; the status-line windows stand alone. */ } return claude; } } const quote = (value) => "'" + value.replace(/'/g, "'\\''") + "'"; /** Wrap an existing status line, preserving its input, output and options. */ export function usageStatusLine(current, program, remove) { const prefix = `${quote(process.execPath)} ${quote(program)} usage-statusline `; const command = object(current).command; if (typeof command === "string" && command.startsWith(prefix)) { if (!remove) return current; const encoded = command.slice(prefix.length); if (!/^[A-Za-z0-9+/=]+$/.test(encoded)) throw new Error("Invalid Phren status line"); return JSON.parse(Buffer.from(encoded, "base64").toString()) ?? undefined; } if (remove) return current; if (current !== undefined && (object(current).type !== "command" || typeof command !== "string")) return current; const original = Buffer.from(JSON.stringify(current ?? null)).toString("base64"); if (original.length > 32_768) return current; return { ...object(current), type: "command", command: prefix + original }; } export async function captureClaudeUsage(original) { if (!/^[A-Za-z0-9+/=]{1,32768}$/.test(original)) return; let input = ""; for await (const chunk of process.stdin) { input += chunk.toString(); if (Buffer.byteLength(input) > 1_048_576) return; } try { // The home Claude runs in names the file; an unknown CLAUDE_CONFIG_DIR records nothing. // Homes are resolved without this process's CLAUDE_CONFIG_DIR: that is the account's own dir here, not the default. const home = claudeHomeOfEnv(process.env.CLAUDE_CONFIG_DIR, { ...process.env, CLAUDE_CONFIG_DIR: undefined }); if (!home) throw new Error("Unknown Claude home"); const snapshot = claudeUsage(JSON.parse(input)); // Persist only normalized percentages/reset times; never session content. const rate_limits = Object.fromEntries(snapshot.windows.map(w => [w.id, { used_percentage: w.usedPercent, resets_at: w.resetsAt ? Date.parse(w.resetsAt) / 1000 : undefined, }])); await atomicInPrivateDir(claudeFile(home), JSON.stringify({ rate_limits, updatedAt: snapshot.updatedAt })); } catch { /* Status line rendering must survive unavailable usage storage. */ } const previous = object(JSON.parse(Buffer.from(original, "base64").toString())); if (typeof previous.command === "string") { await new Promise(resolve => { // This is the user's original status-line command, invoked just as // Claude did before installing the observer. The phone cannot set it. const child = spawn("/bin/sh", ["-c", previous.command], { stdio: ["pipe", "inherit", "inherit"] }); child.on("error", () => resolve()); child.on("exit", () => resolve()); child.stdin.on("error", () => { }); child.stdin.end(input); }); } }