@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
223 lines (222 loc) • 13.3 kB
TypeScript
import { execFile } from "node:child_process";
import { accountWindows } from "./account-choice.js";
import { type AccountRef, type AuthStatus, type ClaudeHome } from "./claude-accounts.js";
import { type Subscription } from "./subscription.js";
export type { Subscription } from "./subscription.js";
export interface UsageWindow {
id: string;
name: string;
/** A percentage is omitted rather than invented when a service has no limit. */
usedPercent?: number;
/** Local Go-ledger values; quota readers deliberately do not use these. */
usedUSD?: number;
limitUSD?: number;
usedTokens?: number;
/** ElevenLabs' character quota, next to its percentage. */
usedCharacters?: number;
limitCharacters?: number;
resetsAt?: string;
/** The service says this window is refusing requests now (OpenCode Go's `rate-limited`). */
limited?: boolean;
asOf?: string;
/** The window's reset time passed after its report: no percent is known, so none is sent. */
reset?: boolean;
}
/**
* Codex credits: a purchased balance on top of the plan's limits, from the
* `credits` Codex reports beside its windows. `balance` is in Codex's own
* credit unit, not dollars. `inUse` is derived: credits are enabled and a plan
* window is spent (at 100%, or Codex says the rate limit is reached), so
* further work draws on the balance.
*/
export interface UsageCredits {
hasCredits: boolean;
unlimited?: true;
balance?: number;
inUse?: true;
}
export interface UsageSpend {
amountUSD: number;
period: "rolling_7_days" | "rolling_30_days" | "calendar_week";
}
export interface AccountUsage {
source: "codex" | "claude" | "opencode" | "opencode-go" | "openrouter" | "copilot" | "elevenlabs";
windows: UsageWindow[];
updatedAt?: string;
message?: string;
spend?: UsageSpend;
subscription?: Subscription;
/** Codex's credit balance, when Codex reports one. */
credits?: UsageCredits;
/** Opaque key identity used only to avoid counting one OpenRouter key twice. */
accountId?: string;
/** Which Claude report fed this account: the status-line rate_limits payload
* or the OAuth usage endpoint. Per-model windows the status line never
* carries document their own age through each window's asOf instead. */
origin?: "status-line" | "oauth";
/** Which account this row is: a Claude home, or Codex's single one. Older phones ignore it. */
account?: AccountRef;
}
export declare function codexUsage(value: unknown, now?: Date): AccountUsage;
/** Display metadata from the ID token's claims only, never an authentication decision. */
export declare function codexSubscription(idToken: unknown, now?: Date): Subscription | undefined;
export declare function readCodexSubscription(file?: string, now?: Date): Promise<Subscription | undefined>;
/** Parse OpenCode's own cost ledger. `stats --days 7` is a rolling local view. */
export declare function openCodeUsage(output: string, now?: Date): AccountUsage;
/** Read the authoritative cost that OpenCode records for its local sessions. */
export declare function readOpenCodeUsage(executable?: string, now?: Date): Promise<AccountUsage>;
/** Not installed, not signed in and a failing command each say so, with the command's own first line. */
export declare function openCodeFailure(error: unknown): string;
/** OpenCode's OpenRouter key stays local and is used only with OpenRouter. */
export declare function readOpenRouterKey(): Promise<string | undefined>;
/** OpenCode Go's key stays local and is sent only to the Go gateway. */
export declare function readOpenCodeGoKey(): Promise<string | undefined>;
/**
* 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 declare function openCodeGoPlan(value: unknown): UsageWindow[];
export declare function fetchOpenCodeGoPlan(key: string, fetchImpl?: typeof fetch): Promise<UsageWindow[]>;
export interface GoRefusals {
count: number;
first: string;
last: string;
models: string[];
}
/** "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 declare function readGoRefusals(now?: Date, withinMs?: number, root?: string): Promise<GoRefusals | undefined>;
export declare function openCodeGoUsage(windows: UsageWindow[], refusals: GoRefusals | undefined, now: Date, hasKey: boolean, planError?: boolean): AccountUsage;
/** 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 declare function usageForCaller(accounts: AccountUsage[], sources: Set<string>, goPlan: boolean): AccountUsage[];
export declare function readOpenCodeGoUsage(now?: Date, options?: {
readKey?: () => Promise<string | undefined>;
fetchImpl?: typeof fetch;
readRefusals?: (now: Date) => Promise<GoRefusals | undefined>;
}): Promise<AccountUsage>;
/** OpenRouter reports the current UTC calendar week's charged usage per key. */
export declare function fetchOpenRouterUsage(key: string, fetchImpl?: typeof fetch, now?: Date): Promise<AccountUsage>;
/** ElevenLabs' subscription: characters used this billing period against the plan's limit. */
export declare function elevenLabsUsage(value: unknown, now?: Date): AccountUsage;
/** The key the Hook already uses for /v1/speech, sent only to ElevenLabs; the answer carries counts, never the key. */
export declare function fetchElevenLabsUsage(key: string, fetchImpl?: typeof fetch, now?: Date, origin?: string): Promise<AccountUsage>;
/** 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 declare function claudeUsage(value: unknown, now?: Date): AccountUsage;
/**
* 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 declare function claudeScopedWindows(config: unknown, now?: Date): UsageWindow[];
/**
* 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 declare function claudeOAuthUsage(value: unknown, now?: Date): AccountUsage;
/**
* 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 declare function readClaudeToken(execSecurity?: typeof execFile.__promisify__, platform?: NodeJS.Platform, home?: ClaudeHome): Promise<string | undefined>;
/** Fetch live limits; any failure falls back to the local snapshot. */
export declare function fetchClaudeUsage(token: string, fetchImpl?: typeof fetch, now?: Date): Promise<AccountUsage>;
/** Only initialize and read limits. Never create a thread, prompt, or login. */
export declare function readCodexLimits(executable?: string): Promise<AccountUsage>;
/**
* 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 declare function copilotUsage(value: unknown, now?: Date): AccountUsage;
/** Reads through the GitHub CLI's own sign-in; the token never reaches Phren. */
export declare function readCopilotUsage(now?: Date, run?: (file: string, args: string[]) => Promise<string>): Promise<AccountUsage>;
/** Under this much left on any window, an account is near its limit: information for the owner, never a reason to avoid it. */
export declare 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 declare function windowExhausted(window: UsageWindow, now: number): boolean;
/** Percent left on a window at `now`: none once its reset passed or with no percent, 0 while the service refuses requests. */
export declare function windowLeft(window: UsageWindow, now: number): number | 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). Claude rows add their 5-hour and weekly rooms, which
* launch and failover rank accounts by (account-choice.ts). API credits are pooled: separate grants that fund one
* balance, so the account is out only when every live credit is, and a credit's expiry is never a reset. */
export declare function capacityRoom(accounts: readonly AccountUsage[], now: number): Array<{
source: string;
account?: string;
leftPercent?: number;
exhausted?: true;
until?: string;
} & ReturnType<typeof accountWindows>>;
/** A Claude report older than this says nothing about the account now. */
export declare const CLAUDE_REPORT_MAX_AGE_MS: number;
/**
* 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 declare function settleClaudeUsage(usage: AccountUsage, now: number): AccountUsage;
/** `opencode stats` scans OpenCode's whole database, several CPU-seconds on a
* busy machine, for a rolling seven-day cost that barely moves in a minute. */
export declare const OPENCODE_STATS_REUSE_MS: number;
/** Longest one reader may hold its shared slot. A read that never settles (a
* child whose grandchild keeps stdout open, say) would otherwise block every
* later poll until the Hook restarts. */
export declare const USAGE_READ_LIMIT_MS = 30000;
export declare class AccountUsageReader {
private readCodex;
private now;
private readClaudeLive;
private readOpenCode;
private readOpenRouter;
private readOpenCodeGo;
private readCopilot;
private platform;
private readElevenLabs;
private readAuth;
private cached?;
private pending?;
private claudeCached;
private claudePending;
private spendingCached;
private spendingPending;
private openCodeCached?;
private openCodePending?;
constructor(readCodex?: typeof readCodexLimits, now?: () => number, readClaudeLive?: (now: Date, home: ClaudeHome) => Promise<AccountUsage | undefined>, readOpenCode?: (now: Date) => Promise<AccountUsage>, readOpenRouter?: (now: Date) => Promise<AccountUsage | undefined>, readOpenCodeGo?: (now: Date) => Promise<AccountUsage>, readCopilot?: (now: Date) => Promise<AccountUsage>, platform?: NodeJS.Platform, readElevenLabs?: (now: Date) => Promise<AccountUsage | undefined>, readAuth?: (home: ClaudeHome) => Promise<AuthStatus>);
/** `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. */
read(sources?: Set<string>, allAccounts?: boolean): Promise<{
accounts: AccountUsage[];
}>;
/** Codex and every Claude home, without the spend readers: what a dispatch capacity probe asks. */
limits(allAccounts?: boolean): Promise<AccountUsage[]>;
/** ElevenLabs is read only when the caller shows it: every read spends a request against the key's quota. */
private spending;
/** One `opencode stats` run serves every spending key for `OPENCODE_STATS_REUSE_MS`. */
private openCode;
/** 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). */
private claude;
private snapshotClaude;
}
/** Wrap an existing status line, preserving its input, output and options. */
export declare function usageStatusLine(current: unknown, program: string, remove: boolean): unknown;
export declare function captureClaudeUsage(original: string): Promise<void>;