summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-03 00:55:23 +0900
committerAdam Malczewski <[email protected]>2026-06-03 00:55:23 +0900
commitae672fd4f5542a2c217cf97657bf81eeebdaabbd (patch)
tree5274fa0c777991dad5e36d058d669b67b6a69c9e
parent66e5d3b105bfd2b34c6f35876bf33dbb3cb9dcae (diff)
parent2e3c108119012546d72ca6746fbe8e9b6b49c229 (diff)
downloaddispatch-ae672fd4f5542a2c217cf97657bf81eeebdaabbd.tar.gz
dispatch-ae672fd4f5542a2c217cf97657bf81eeebdaabbd.zip
Merge branch 'dev' into img8/image-attachments
-rw-r--r--packages/api/src/agent-manager.ts25
-rw-r--r--packages/api/tests/agent-manager.test.ts30
-rw-r--r--packages/core/src/credentials/claude.ts69
-rw-r--r--packages/core/src/credentials/index.ts2
-rw-r--r--packages/core/src/index.ts1
-rw-r--r--packages/core/src/tools/key-usage.ts322
-rw-r--r--packages/core/src/tools/summon.ts1
-rw-r--r--packages/core/tests/tools/key-usage.test.ts317
-rw-r--r--packages/frontend/src/lib/components/TabBar.svelte34
-rw-r--r--packages/frontend/src/lib/components/ToolPermissions.svelte6
-rw-r--r--packages/frontend/src/lib/settings.svelte.ts2
11 files changed, 795 insertions, 14 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index 3b12a80..c1b46b9 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -13,6 +13,7 @@ import {
clearSpillForTab,
configToRuleset,
createConfigWatcher,
+ createKeyUsageTool,
createListFilesTool,
createLspTool,
createReadFileSliceTool,
@@ -85,6 +86,8 @@ const TOOL_DESCRIPTIONS: Record<string, string> = {
search_code:
"Search the codebase by query using the 'cs' code search engine (relevance-ranked, structure-aware). Returns the most relevant files first with matching snippets and line numbers. Better than grep/find for exploratory 'where is X / how does Y work' searches; use run_shell with rg for exhaustive exact-match lists.",
todo: "Create/maintain a todo list to plan and track work. Declarative whole-list write: send the entire list in `todos` each call (it replaces the previous list). Statuses: pending, in_progress, completed, cancelled.",
+ key_usage:
+ "Report current usage levels for configured API keys: provider, active/exhausted status, remaining rate-limit headroom and reset times per window (5-hour, weekly, monthly where available), and whether the figures are live or cached. Pass key_id for one key; omit to report all. Supported for anthropic and opencode-go keys.",
summon:
"Spawn a child agent to work on a task independently. By default blocks until the child finishes. Set background=true to return immediately with an agent_id for later retrieval.",
retrieve:
@@ -516,10 +519,11 @@ export class AgentManager {
const permReadTab = getSetting("perm_read_tab") === "allow";
const permWebSearch = getSetting("perm_web_search") === "allow";
const permSearchCode = getSetting("perm_search_code") === "allow";
+ const permKeyUsage = getSetting("perm_key_usage") === "allow";
const permYoutubeTranscribe = getSetting("perm_youtube_transcribe") === "allow";
const permLsp = getSetting("perm_lsp") === "allow";
const sysPrompt = getSetting("system_prompt") ?? "";
- const permKey = `${permRead}:${permEdit}:${permBash}:${permSummon}:${permUserAgent}:${permSendToTab}:${permReadTab}:${permWebSearch}:${permYoutubeTranscribe}:${permSearchCode}:${permLsp}:${sysPrompt}`;
+ const permKey = `${permRead}:${permEdit}:${permBash}:${permSummon}:${permUserAgent}:${permSendToTab}:${permReadTab}:${permWebSearch}:${permYoutubeTranscribe}:${permSearchCode}:${permKeyUsage}:${permLsp}:${sysPrompt}`;
// If the override differs or permissions changed, invalidate the cached agent
if (
@@ -611,6 +615,9 @@ export class AgentManager {
if (allowed.has("web_search")) {
toolEntries.push({ name: "web_search", tool: createWebSearchTool() });
}
+ if (allowed.has("key_usage")) {
+ toolEntries.push({ name: "key_usage", tool: this.buildKeyUsageTool() });
+ }
if (allowed.has("lsp") && lspServers.length > 0) {
toolEntries.push({
name: "lsp",
@@ -716,6 +723,9 @@ export class AgentManager {
if (permWebSearch) {
toolEntries.push({ name: "web_search", tool: createWebSearchTool() });
}
+ if (permKeyUsage) {
+ toolEntries.push({ name: "key_usage", tool: this.buildKeyUsageTool() });
+ }
// The `lsp` tool exposes diagnostics + navigation on demand. It is
// gated by `perm_lsp` AND requires at least one server configured
// in the working directory's `dispatch.toml`.
@@ -1406,6 +1416,19 @@ export class AgentManager {
// `deliverMessage`), so an agent message behaves identically to a user one.
/**
+ * Build the `key_usage` tool, wired to the live model registry (key states)
+ * and the discovered Claude accounts. The tool fetches usage live with a
+ * cache fallback (anthropic) or a live scrape (opencode-go), reporting
+ * remaining headroom, reset times, and data freshness per key.
+ */
+ private buildKeyUsageTool(): ReturnType<typeof createKeyUsageTool> {
+ return createKeyUsageTool({
+ listKeys: () => this.modelRegistry?.getKeys() ?? [],
+ listClaudeAccounts: () => this.claudeAccounts,
+ });
+ }
+
+ /**
* Build the `send_to_tab` + `read_tab` tool entries for `tabId`. Shared by
* both tool-construction paths (child whitelist + permission-gated parent).
* `selfHandle` is computed once so the calling tab can stamp provenance and
diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts
index dbbcc65..788106e 100644
--- a/packages/api/tests/agent-manager.test.ts
+++ b/packages/api/tests/agent-manager.test.ts
@@ -472,6 +472,14 @@ vi.mock("@dispatch/core", () => ({
execute: async () => "mock",
};
},
+ createKeyUsageTool(_callbacks: unknown) {
+ return {
+ name: "key_usage",
+ description: "key usage",
+ parameters: { _type: "z.ZodObject", shape: {} },
+ execute: async () => "mock",
+ };
+ },
createSearchCodeTool(_wd: string) {
return {
name: "search_code",
@@ -1568,6 +1576,28 @@ describe("AgentManager", () => {
});
});
+ describe("key_usage permission gate", () => {
+ // The key_usage tool is conditionally useful, so it must be COMPLETELY
+ // absent from the toolset (and thus the model's context) unless
+ // perm_key_usage is explicitly allowed.
+ async function toolsForPerms(tabId: string, perms: Record<string, string>): Promise<string[]> {
+ for (const [k, v] of Object.entries(perms)) setFakeSetting(k, v);
+ const manager = new AgentManager();
+ await manager.processMessage(tabId, "go");
+ return constructedAgents.at(-1)?.toolNames ?? [];
+ }
+
+ it("registers key_usage when perm_key_usage is allowed", async () => {
+ const tools = await toolsForPerms("tab-key-usage-on", { perm_key_usage: "allow" });
+ expect(tools).toContain("key_usage");
+ });
+
+ it("omits key_usage when perm_key_usage is not allowed", async () => {
+ const tools = await toolsForPerms("tab-key-usage-off", {});
+ expect(tools).not.toContain("key_usage");
+ });
+ });
+
// Regression: granted tab-messaging tools must also be ADVERTISED in the
// agent's system prompt. The tools were registered in the API tool payload
// but `buildSystemPrompt` filtered its "You have access to the following
diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts
index 7818222..050a0fc 100644
--- a/packages/core/src/credentials/claude.ts
+++ b/packages/core/src/credentials/claude.ts
@@ -441,6 +441,22 @@ export interface ClaudeUsageReport {
orgId?: string;
}
+/**
+ * A usage report paired with provenance: whether it came back from a fresh
+ * live fetch against Anthropic's `/api/oauth/usage` endpoint or was served
+ * from the local `usage_cache` table after a failed/skipped live fetch.
+ *
+ * `source: "cache"` carries `cachedAt` — the epoch-ms timestamp recording when
+ * that cached payload was last fetched FROM the source (the `usage_cache.cached_at`
+ * column). `source: "live"` omits `cachedAt` (the data is current as of now).
+ */
+export interface ClaudeUsageResult {
+ report: ClaudeUsageReport;
+ source: "live" | "cache";
+ /** Epoch-ms the cached report was last fetched from source. Only on `source: "cache"`. */
+ cachedAt?: number;
+}
+
// ─── Well-known Anthropic models ──────────────────────────────
/**
@@ -602,14 +618,23 @@ async function fetchClaudeUsage(accessToken: string): Promise<ClaudeUsageReport
}
}
-function getCachedUsage(keyId: string): ClaudeUsageReport | null {
+/**
+ * Read a cached usage report plus the epoch-ms it was last fetched from source.
+ * Returns `null` when there is no cached row (or on any DB/parse error).
+ */
+function getCachedUsageWithMeta(
+ keyId: string,
+): { report: ClaudeUsageReport; cachedAt: number } | null {
try {
const db = getDatabase();
const row = db
- .query("SELECT report_json FROM usage_cache WHERE key_id = $keyId")
- .get({ $keyId: keyId }) as { report_json: string } | null;
+ .query("SELECT report_json, cached_at FROM usage_cache WHERE key_id = $keyId")
+ .get({ $keyId: keyId }) as { report_json: string; cached_at: number } | null;
if (!row) return null;
- return JSON.parse(row.report_json) as ClaudeUsageReport;
+ return {
+ report: JSON.parse(row.report_json) as ClaudeUsageReport,
+ cachedAt: row.cached_at,
+ };
} catch {
return null;
}
@@ -635,13 +660,35 @@ function setCachedUsage(keyId: string, provider: string, report: ClaudeUsageRepo
}
}
-export async function getAccountUsage(account: ClaudeAccount): Promise<ClaudeUsageReport | null> {
+/**
+ * Fetch an account's usage report along with its provenance (live vs cache).
+ *
+ * Resolution: refresh credentials and hit the live `/api/oauth/usage` endpoint;
+ * on success the fresh report is cached and returned as `source: "live"`. If
+ * credentials cannot be refreshed OR the live fetch returns nothing, fall back
+ * to the local `usage_cache` row and return it as `source: "cache"` with the
+ * `cachedAt` timestamp recording when that payload was last fetched from source.
+ * Returns `null` only when neither a live report nor a cached row is available.
+ */
+export async function getAccountUsageWithSource(
+ account: ClaudeAccount,
+): Promise<ClaudeUsageResult | null> {
const creds = await refreshAccountCredentialsAsync(account);
- if (!creds) return getCachedUsage(account.id);
- const report = await fetchClaudeUsage(creds.accessToken);
- if (report) {
- setCachedUsage(account.id, "anthropic", report);
- return report;
+ if (creds) {
+ const report = await fetchClaudeUsage(creds.accessToken);
+ if (report) {
+ setCachedUsage(account.id, "anthropic", report);
+ return { report, source: "live" };
+ }
}
- return getCachedUsage(account.id);
+ const cached = getCachedUsageWithMeta(account.id);
+ if (cached) {
+ return { report: cached.report, source: "cache", cachedAt: cached.cachedAt };
+ }
+ return null;
+}
+
+export async function getAccountUsage(account: ClaudeAccount): Promise<ClaudeUsageReport | null> {
+ const result = await getAccountUsageWithSource(account);
+ return result?.report ?? null;
}
diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts
index 5221dc6..131f035 100644
--- a/packages/core/src/credentials/index.ts
+++ b/packages/core/src/credentials/index.ts
@@ -15,9 +15,11 @@ export {
type ClaudeProfile,
type ClaudeUsageBucket,
type ClaudeUsageReport,
+ type ClaudeUsageResult,
discoverClaudeAccounts,
fetchAnthropicModels,
getAccountUsage,
+ getAccountUsageWithSource,
getAnthropicBetas,
getAnthropicHeaders,
getClaudeAccountsFromDB,
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 50012f1..f58209f 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -116,6 +116,7 @@ export {
} from "./skills/index.js";
export { prefix as bashArityPrefix } from "./tools/bash-arity.js";
// Tools
+export { createKeyUsageTool, type KeyUsageCallbacks } from "./tools/key-usage.js";
export { createListFilesTool } from "./tools/list-files.js";
export { createLspTool, type LspToolContext } from "./tools/lsp.js";
export { createReadFileTool } from "./tools/read-file.js";
diff --git a/packages/core/src/tools/key-usage.ts b/packages/core/src/tools/key-usage.ts
new file mode 100644
index 0000000..0655ad7
--- /dev/null
+++ b/packages/core/src/tools/key-usage.ts
@@ -0,0 +1,322 @@
+import { z } from "zod";
+import type { ClaudeAccount, ClaudeUsageReport, ClaudeUsageResult } from "../credentials/claude.js";
+import { getAccountUsageWithSource } from "../credentials/claude.js";
+import type { OpencodeUsageReport } from "../credentials/opencode.js";
+import { fetchOpencodeUsage as defaultFetchOpencodeUsage } from "../credentials/opencode.js";
+import type { KeyState, ToolDefinition } from "../types/index.js";
+
+/**
+ * Collaborators the `key_usage` tool needs from the API layer (which owns the
+ * live `ModelRegistry` and the discovered Claude accounts). The two `fetch*`
+ * hooks default to the real credential fetchers but are injectable so tests can
+ * exercise the tool without network or DB access.
+ */
+export interface KeyUsageCallbacks {
+ /** Current key states from the model registry (definition + active/exhausted status). */
+ listKeys(): KeyState[];
+ /** Discovered Claude accounts, used to resolve `anthropic` keys to credentials. */
+ listClaudeAccounts(): ClaudeAccount[];
+ /**
+ * Fetch an anthropic account's usage with provenance (live vs cache).
+ * Defaults to `getAccountUsageWithSource`.
+ */
+ fetchAnthropicUsage?: (account: ClaudeAccount) => Promise<ClaudeUsageResult | null>;
+ /**
+ * Fetch an opencode-go key's usage (always a live scrape — OpenCode keeps no
+ * local cache). Defaults to `fetchOpencodeUsage`.
+ */
+ fetchOpencodeUsage?: (keyId: string) => Promise<OpencodeUsageReport | null>;
+}
+
+/** A single normalized usage window (5-hour / week / month). */
+interface UsageWindow {
+ label: string;
+ /** Remaining headroom as a 0–100 percentage. Omitted when the source gives no utilization. */
+ remainingPercent?: number;
+ /** Epoch-ms the window resets. Omitted when the source gives no reset time. */
+ resetsAt?: number;
+}
+
+/** Fully normalized per-key usage, ready for rendering. */
+interface KeyUsageEntry {
+ keyId: string;
+ provider: string;
+ status: "active" | "exhausted";
+ lastError?: string;
+ exhaustedAt?: number;
+ /** Provenance of the usage figures: a fresh live fetch or a cached payload. */
+ dataSource?: "live" | "cache";
+ /** Epoch-ms the cached payload was last fetched from source (only on `dataSource: "cache"`). */
+ cachedAt?: number;
+ windows: UsageWindow[];
+ /** Set when no usage figures could be obtained for an otherwise-supported key. */
+ unavailableReason?: string;
+ /** Set when the provider has no usage-reporting support. */
+ unsupported?: boolean;
+}
+
+function clampPercent(value: number): number {
+ if (value < 0) return 0;
+ if (value > 100) return 100;
+ return value;
+}
+
+/** Convert a raw `{ utilization, resetsAt }` bucket into a normalized window. */
+function toWindow(
+ label: string,
+ bucket?: { utilization?: number; resetsAt?: number },
+): UsageWindow | null {
+ if (!bucket) return null;
+ const hasUtil = typeof bucket.utilization === "number";
+ const hasReset = typeof bucket.resetsAt === "number";
+ if (!hasUtil && !hasReset) return null;
+ return {
+ label,
+ ...(hasUtil
+ ? { remainingPercent: clampPercent(Math.round((1 - (bucket.utilization as number)) * 100)) }
+ : {}),
+ ...(hasReset ? { resetsAt: bucket.resetsAt } : {}),
+ };
+}
+
+function anthropicWindows(report: ClaudeUsageReport): UsageWindow[] {
+ const windows: UsageWindow[] = [];
+ const fiveHour = toWindow("5-hour", report.fiveHour);
+ if (fiveHour) windows.push(fiveHour);
+ const week = toWindow("week", report.sevenDay);
+ if (week) windows.push(week);
+ return windows;
+}
+
+function opencodeWindows(report: OpencodeUsageReport): UsageWindow[] {
+ const windows: UsageWindow[] = [];
+ const fiveHour = toWindow("5-hour", report.fiveHour);
+ if (fiveHour) windows.push(fiveHour);
+ const week = toWindow("week", report.weekly);
+ if (week) windows.push(week);
+ const month = toWindow("month", report.monthly);
+ if (month) windows.push(month);
+ return windows;
+}
+
+/**
+ * Resolve which Claude account backs an `anthropic` key. Matches by key id or by
+ * the account's source file (the key's `credentials_file`), falling back to the
+ * first available account — mirrors the existing `/models/key-usage` route.
+ */
+function matchAnthropicAccount(
+ accounts: ClaudeAccount[],
+ keyId: string,
+ credFile?: string,
+): ClaudeAccount | undefined {
+ const matched = accounts.find(
+ (a) => a.id === keyId || (credFile != null && a.source === credFile),
+ );
+ return matched ?? accounts[0];
+}
+
+function iso(ms: number): string {
+ return new Date(ms).toISOString();
+}
+
+/** Human-readable coarse duration, e.g. "3h 12m", "5d 8h", "0m". */
+function formatDuration(ms: number): string {
+ const totalSec = Math.round(Math.abs(ms) / 1000);
+ const days = Math.floor(totalSec / 86400);
+ const hours = Math.floor((totalSec % 86400) / 3600);
+ const minutes = Math.floor((totalSec % 3600) / 60);
+ const parts: string[] = [];
+ if (days > 0) parts.push(`${days}d`);
+ if (hours > 0) parts.push(`${hours}h`);
+ if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`);
+ return parts.join(" ");
+}
+
+function formatRelative(targetMs: number, nowMs: number): string {
+ const delta = targetMs - nowMs;
+ return delta >= 0 ? `in ${formatDuration(delta)}` : `${formatDuration(delta)} ago`;
+}
+
+function formatWindow(window: UsageWindow, now: number): string {
+ const parts: string[] = [];
+ if (typeof window.remainingPercent === "number") {
+ parts.push(`${window.remainingPercent}% remaining`);
+ }
+ if (typeof window.resetsAt === "number") {
+ parts.push(`resets ${iso(window.resetsAt)} (${formatRelative(window.resetsAt, now)})`);
+ }
+ return `${window.label}: ${parts.join(", ")}`;
+}
+
+/**
+ * Render normalized usage entries into an AI-friendly text block. Pure — `now`
+ * is injected so relative timestamps are deterministic under test.
+ */
+export function formatKeyUsage(entries: KeyUsageEntry[], now: number): string {
+ if (entries.length === 0) return "No API keys matched.";
+
+ const lines: string[] = [];
+ lines.push(`API key usage — ${entries.length} key${entries.length === 1 ? "" : "s"}:`);
+
+ for (const entry of entries) {
+ lines.push("");
+ lines.push(`[${entry.keyId}] provider: ${entry.provider}`);
+
+ if (entry.status === "exhausted") {
+ const since =
+ typeof entry.exhaustedAt === "number"
+ ? ` (since ${iso(entry.exhaustedAt)}, ${formatRelative(entry.exhaustedAt, now)})`
+ : "";
+ lines.push(`status: EXHAUSTED${since}`);
+ if (entry.lastError) lines.push(`last error: ${entry.lastError}`);
+ } else {
+ lines.push("status: active");
+ }
+
+ if (entry.unsupported) {
+ lines.push(
+ `usage: not supported for provider "${entry.provider}" (only anthropic and opencode-go report usage)`,
+ );
+ continue;
+ }
+
+ if (entry.dataSource === "live") {
+ lines.push("data: live (fetched just now)");
+ } else if (entry.dataSource === "cache") {
+ lines.push(
+ typeof entry.cachedAt === "number"
+ ? `data: cached — last fetched from source ${iso(entry.cachedAt)} (${formatRelative(entry.cachedAt, now)})`
+ : "data: cached (source timestamp unknown)",
+ );
+ }
+
+ for (const window of entry.windows) {
+ lines.push(formatWindow(window, now));
+ }
+
+ if (entry.unavailableReason) {
+ lines.push(`usage: unavailable — ${entry.unavailableReason}`);
+ }
+ }
+
+ return lines.join("\n");
+}
+
+async function buildEntry(
+ key: KeyState,
+ accounts: ClaudeAccount[],
+ fetchAnthropic: (account: ClaudeAccount) => Promise<ClaudeUsageResult | null>,
+ fetchOpencode: (keyId: string) => Promise<OpencodeUsageReport | null>,
+): Promise<KeyUsageEntry> {
+ const def = key.definition;
+ const entry: KeyUsageEntry = {
+ keyId: def.id,
+ provider: def.provider,
+ status: key.status,
+ windows: [],
+ ...(key.lastError ? { lastError: key.lastError } : {}),
+ ...(typeof key.exhaustedAt === "number" ? { exhaustedAt: key.exhaustedAt } : {}),
+ };
+
+ if (def.provider === "anthropic") {
+ const account = matchAnthropicAccount(accounts, def.id, def.credentials_file);
+ if (!account) {
+ entry.unavailableReason = "no Claude account credentials available for this key";
+ return entry;
+ }
+ let result: ClaudeUsageResult | null = null;
+ try {
+ result = await fetchAnthropic(account);
+ } catch {
+ result = null;
+ }
+ if (!result) {
+ entry.unavailableReason = "no live usage data and no cached usage available";
+ return entry;
+ }
+ entry.dataSource = result.source;
+ if (typeof result.cachedAt === "number") entry.cachedAt = result.cachedAt;
+ entry.windows = anthropicWindows(result.report);
+ if (entry.windows.length === 0) {
+ entry.unavailableReason = "usage endpoint returned no window data";
+ }
+ return entry;
+ }
+
+ if (def.provider === "opencode-go") {
+ let report: OpencodeUsageReport | null = null;
+ try {
+ report = await fetchOpencode(def.id);
+ } catch {
+ report = null;
+ }
+ if (!report) {
+ entry.unavailableReason =
+ "live usage unavailable (requires OPENCODE_COOKIE and a workspace id, or the source returned no data; OpenCode keeps no local cache)";
+ return entry;
+ }
+ entry.dataSource = "live";
+ entry.windows = opencodeWindows(report);
+ if (entry.windows.length === 0) {
+ entry.unavailableReason = "usage source returned no window data";
+ }
+ return entry;
+ }
+
+ entry.unsupported = true;
+ return entry;
+}
+
+export function createKeyUsageTool(callbacks: KeyUsageCallbacks): ToolDefinition {
+ const fetchAnthropic = callbacks.fetchAnthropicUsage ?? getAccountUsageWithSource;
+ const fetchOpencode = callbacks.fetchOpencodeUsage ?? defaultFetchOpencodeUsage;
+
+ return {
+ name: "key_usage",
+ description: [
+ "Report current usage levels for configured API keys so you can pick a key with",
+ "headroom, warn before hitting a rate limit, or diagnose an exhausted-key failure.",
+ "",
+ "For each key it returns: provider, active/exhausted status (with the last error when",
+ "exhausted), remaining rate-limit headroom per window (5-hour, weekly, and monthly where",
+ "the provider exposes it), each window's reset timestamp, and whether the figures are",
+ "live or served from cache (with the cache's last-fetched time).",
+ "",
+ "Pass a key_id to inspect one key; omit it to report all keys. Usage reporting is",
+ "supported for anthropic and opencode-go keys.",
+ ].join("\n"),
+ parameters: z.object({
+ key_id: z
+ .string()
+ .optional()
+ .describe(
+ 'The id of a single key to report (as configured in dispatch.toml, e.g. "claude-max"). Omit to report all configured keys.',
+ ),
+ }),
+ execute: async (args: Record<string, unknown>): Promise<string> => {
+ const requestedKeyId = (args.key_id as string | undefined)?.trim() || undefined;
+
+ const allKeys = callbacks.listKeys();
+ if (allKeys.length === 0) {
+ return "No API keys are configured.";
+ }
+
+ let keys = allKeys;
+ if (requestedKeyId) {
+ keys = allKeys.filter((k) => k.definition.id === requestedKeyId);
+ if (keys.length === 0) {
+ const available = allKeys.map((k) => k.definition.id).join(", ");
+ return `Error: no key found with id "${requestedKeyId}". Available keys: ${available}.`;
+ }
+ }
+
+ const accounts = callbacks.listClaudeAccounts();
+ const entries: KeyUsageEntry[] = [];
+ for (const key of keys) {
+ entries.push(await buildEntry(key, accounts, fetchAnthropic, fetchOpencode));
+ }
+
+ return formatKeyUsage(entries, Date.now());
+ },
+ };
+}
diff --git a/packages/core/src/tools/summon.ts b/packages/core/src/tools/summon.ts
index b941152..2a076e6 100644
--- a/packages/core/src/tools/summon.ts
+++ b/packages/core/src/tools/summon.ts
@@ -287,6 +287,7 @@ export function createSummonTool(
"write_file",
"run_shell",
"search_code",
+ "key_usage",
"todo",
"summon",
"retrieve",
diff --git a/packages/core/tests/tools/key-usage.test.ts b/packages/core/tests/tools/key-usage.test.ts
new file mode 100644
index 0000000..643e30e
--- /dev/null
+++ b/packages/core/tests/tools/key-usage.test.ts
@@ -0,0 +1,317 @@
+import { describe, expect, it, vi } from "vitest";
+
+// The tool imports `getAccountUsageWithSource` from `claude.ts`, which
+// transitively imports `db/index.js` (top-level `import { Database } from
+// "bun:sqlite"`) — unresolvable under vitest's Node runtime. These tests inject
+// stub fetchers and never hit the real fetchers/DB, so stubbing the db module
+// is enough to let the import chain resolve.
+vi.mock("../../src/db/index.js", () => ({
+ getDatabase: vi.fn(() => {
+ throw new Error("db not available in this test");
+ }),
+}));
+
+import type { ClaudeAccount, ClaudeUsageResult } from "../../src/credentials/claude.js";
+import type { OpencodeUsageReport } from "../../src/credentials/opencode.js";
+import {
+ createKeyUsageTool,
+ formatKeyUsage,
+ type KeyUsageCallbacks,
+} from "../../src/tools/key-usage.js";
+import type { KeyDefinition, KeyState } from "../../src/types/index.js";
+
+// ─── Builders ─────────────────────────────────────────────────
+
+function keyState(
+ def: Partial<KeyDefinition> & { id: string; provider: string },
+ overrides: Partial<Omit<KeyState, "definition">> = {},
+): KeyState {
+ return {
+ definition: { base_url: "https://example.test", ...def },
+ status: "active",
+ ...overrides,
+ };
+}
+
+function account(id: string, source = `/creds/${id}.json`): ClaudeAccount {
+ return {
+ id,
+ label: id,
+ source,
+ credentials: { accessToken: "tok", refreshToken: "ref", expiresAt: Date.now() + 3_600_000 },
+ };
+}
+
+/** Build the tool with explicit stub fetchers — no network, no DB. */
+function buildTool(opts: {
+ keys: KeyState[];
+ accounts?: ClaudeAccount[];
+ anthropic?: (a: ClaudeAccount) => Promise<ClaudeUsageResult | null>;
+ opencode?: (keyId: string) => Promise<OpencodeUsageReport | null>;
+}) {
+ const callbacks: KeyUsageCallbacks = {
+ listKeys: () => opts.keys,
+ listClaudeAccounts: () => opts.accounts ?? [],
+ fetchAnthropicUsage: opts.anthropic ?? (async () => null),
+ fetchOpencodeUsage: opts.opencode ?? (async () => null),
+ };
+ return createKeyUsageTool(callbacks);
+}
+
+const HOUR = 3_600_000;
+
+describe("key_usage tool", () => {
+ it("reports all keys when no key_id is given", async () => {
+ const reset5h = Date.now() + 2 * HOUR;
+ const tool = buildTool({
+ keys: [
+ keyState({ id: "claude-max", provider: "anthropic", credentials_file: "/creds/max.json" }),
+ keyState({ id: "opencode-1", provider: "opencode-go" }),
+ ],
+ accounts: [account("claude-max", "/creds/max.json")],
+ anthropic: async () => ({
+ source: "live",
+ report: {
+ fiveHour: { utilization: 0.25, resetsAt: reset5h },
+ sevenDay: { utilization: 0.6 },
+ },
+ }),
+ opencode: async () => ({
+ fiveHour: { utilization: 0.1 },
+ weekly: { utilization: 0.4 },
+ monthly: { utilization: 0.7 },
+ }),
+ });
+
+ const out = await tool.execute({});
+
+ // Both keys present with providers.
+ expect(out).toContain("[claude-max] provider: anthropic");
+ expect(out).toContain("[opencode-1] provider: opencode-go");
+ // Remaining = (1 - utilization) * 100.
+ expect(out).toContain("5-hour: 75% remaining");
+ expect(out).toContain("week: 40% remaining");
+ expect(out).toContain("5-hour: 90% remaining");
+ expect(out).toContain("week: 60% remaining");
+ expect(out).toContain("month: 30% remaining");
+ expect(out).toContain("data: live (fetched just now)");
+ });
+
+ it("filters to a single key when key_id is given and does not fetch others", async () => {
+ const opencodeFetch = vi.fn(async () => ({ fiveHour: { utilization: 0.5 } }));
+ const tool = buildTool({
+ keys: [
+ keyState({ id: "claude-max", provider: "anthropic" }),
+ keyState({ id: "opencode-1", provider: "opencode-go" }),
+ ],
+ accounts: [account("claude-max")],
+ anthropic: async () => ({
+ source: "live",
+ report: { fiveHour: { utilization: 0.2 } },
+ }),
+ opencode: opencodeFetch,
+ });
+
+ const out = await tool.execute({ key_id: "claude-max" });
+
+ expect(out).toContain("[claude-max] provider: anthropic");
+ expect(out).not.toContain("opencode-1");
+ expect(opencodeFetch).not.toHaveBeenCalled();
+ });
+
+ it("returns a helpful error for an unknown key_id", async () => {
+ const tool = buildTool({
+ keys: [
+ keyState({ id: "claude-max", provider: "anthropic" }),
+ keyState({ id: "opencode-1", provider: "opencode-go" }),
+ ],
+ });
+
+ const out = await tool.execute({ key_id: "nope" });
+
+ expect(out).toContain('no key found with id "nope"');
+ expect(out).toContain("claude-max");
+ expect(out).toContain("opencode-1");
+ });
+
+ it("reports cached data with the source's last-fetched timestamp", async () => {
+ const cachedAt = Date.UTC(2025, 0, 2, 3, 4, 5);
+ const tool = buildTool({
+ keys: [keyState({ id: "claude-max", provider: "anthropic" })],
+ accounts: [account("claude-max")],
+ anthropic: async () => ({
+ source: "cache",
+ cachedAt,
+ report: { fiveHour: { utilization: 0.5 } },
+ }),
+ });
+
+ const out = await tool.execute({});
+
+ expect(out).toContain("data: cached — last fetched from source 2025-01-02T03:04:05.000Z");
+ expect(out).toContain("5-hour: 50% remaining");
+ });
+
+ it("omits the month window for anthropic (no monthly bucket)", async () => {
+ const tool = buildTool({
+ keys: [keyState({ id: "claude-max", provider: "anthropic" })],
+ accounts: [account("claude-max")],
+ anthropic: async () => ({
+ source: "live",
+ report: { fiveHour: { utilization: 0.1 }, sevenDay: { utilization: 0.2 } },
+ }),
+ });
+
+ const out = await tool.execute({});
+
+ expect(out).toContain("5-hour:");
+ expect(out).toContain("week:");
+ expect(out).not.toContain("month:");
+ });
+
+ it("includes the month window for opencode-go", async () => {
+ const tool = buildTool({
+ keys: [keyState({ id: "opencode-1", provider: "opencode-go" })],
+ opencode: async () => ({
+ fiveHour: { utilization: 0.1 },
+ weekly: { utilization: 0.2 },
+ monthly: { utilization: 0.3 },
+ }),
+ });
+
+ const out = await tool.execute({});
+
+ expect(out).toContain("month: 70% remaining");
+ });
+
+ it("surfaces exhausted status with the last error", async () => {
+ const exhaustedAt = Date.now() - HOUR;
+ const tool = buildTool({
+ keys: [
+ keyState(
+ { id: "opencode-1", provider: "opencode-go" },
+ { status: "exhausted", lastError: "429 rate limit exceeded", exhaustedAt },
+ ),
+ ],
+ opencode: async () => null,
+ });
+
+ const out = await tool.execute({});
+
+ expect(out).toContain("status: EXHAUSTED");
+ expect(out).toContain("last error: 429 rate limit exceeded");
+ });
+
+ it("flags providers without usage support", async () => {
+ const tool = buildTool({
+ keys: [keyState({ id: "gem", provider: "google" })],
+ });
+
+ const out = await tool.execute({});
+
+ expect(out).toContain("[gem] provider: google");
+ expect(out).toContain("not supported");
+ });
+
+ it("reports unavailable when a supported provider returns no usage", async () => {
+ const tool = buildTool({
+ keys: [keyState({ id: "claude-max", provider: "anthropic" })],
+ accounts: [account("claude-max")],
+ anthropic: async () => null,
+ });
+
+ const out = await tool.execute({});
+
+ expect(out).toContain("usage: unavailable");
+ expect(out).toContain("no cached usage");
+ });
+
+ it("reports unavailable for anthropic keys with no account credentials", async () => {
+ const tool = buildTool({
+ keys: [keyState({ id: "claude-max", provider: "anthropic" })],
+ accounts: [],
+ });
+
+ const out = await tool.execute({});
+
+ expect(out).toContain("no Claude account credentials available");
+ });
+
+ it("treats a fetcher that throws as unavailable (does not crash)", async () => {
+ const tool = buildTool({
+ keys: [keyState({ id: "opencode-1", provider: "opencode-go" })],
+ opencode: async () => {
+ throw new Error("network down");
+ },
+ });
+
+ const out = await tool.execute({});
+
+ expect(out).toContain("usage: unavailable");
+ });
+
+ it("reports when no keys are configured at all", async () => {
+ const tool = buildTool({ keys: [] });
+ const out = await tool.execute({});
+ expect(out).toBe("No API keys are configured.");
+ });
+
+ it("clamps out-of-range utilization to 0–100%", async () => {
+ const tool = buildTool({
+ keys: [keyState({ id: "opencode-1", provider: "opencode-go" })],
+ opencode: async () => ({
+ fiveHour: { utilization: 1.2 }, // over 100% used → 0% remaining
+ weekly: { utilization: -0.5 }, // negative → 100% remaining
+ }),
+ });
+
+ const out = await tool.execute({});
+
+ expect(out).toContain("5-hour: 0% remaining");
+ expect(out).toContain("week: 100% remaining");
+ });
+});
+
+describe("formatKeyUsage (pure)", () => {
+ const now = Date.UTC(2025, 5, 1, 12, 0, 0);
+
+ it("formats reset timestamps with ISO + relative time", () => {
+ const out = formatKeyUsage(
+ [
+ {
+ keyId: "claude-max",
+ provider: "anthropic",
+ status: "active",
+ dataSource: "live",
+ windows: [{ label: "5-hour", remainingPercent: 80, resetsAt: now + 90 * 60_000 }],
+ },
+ ],
+ now,
+ );
+
+ expect(out).toContain("5-hour: 80% remaining, resets 2025-06-01T13:30:00.000Z (in 1h 30m)");
+ });
+
+ it("renders a past reset/exhaustion time as 'ago'", () => {
+ const out = formatKeyUsage(
+ [
+ {
+ keyId: "opencode-1",
+ provider: "opencode-go",
+ status: "exhausted",
+ exhaustedAt: now - 2 * HOUR,
+ lastError: "boom",
+ windows: [],
+ },
+ ],
+ now,
+ );
+
+ expect(out).toContain("status: EXHAUSTED (since 2025-06-01T10:00:00.000Z, 2h ago)");
+ expect(out).toContain("last error: boom");
+ });
+
+ it("returns a friendly message when no entries match", () => {
+ expect(formatKeyUsage([], now)).toBe("No API keys matched.");
+ });
+});
diff --git a/packages/frontend/src/lib/components/TabBar.svelte b/packages/frontend/src/lib/components/TabBar.svelte
index 354260c..7371f7b 100644
--- a/packages/frontend/src/lib/components/TabBar.svelte
+++ b/packages/frontend/src/lib/components/TabBar.svelte
@@ -1,5 +1,6 @@
<script lang="ts">
import { tick } from "svelte";
+import type { Tab } from "../tabs.svelte.js";
import { tabStore } from "../tabs.svelte.js";
function statusColor(status: string): string {
@@ -8,6 +9,21 @@ function statusColor(status: string): string {
return "bg-success";
}
+/**
+ * A tab "needs attention" — and should ping to grab the user's eye — when the
+ * agent has stopped and is likely waiting on the user:
+ * (a) the turn ended (idle) but the task list still has incomplete tasks
+ * (pending / in_progress) — the agent probably expects a response; or
+ * (b) the turn stopped due to an error of any kind.
+ */
+function needsAttention(tab: Tab): boolean {
+ if (tab.agentStatus === "error") return true;
+ if (tab.agentStatus === "idle") {
+ return tab.tasks.some((t) => t.status === "pending" || t.status === "in_progress");
+ }
+ return false;
+}
+
const userTabs = $derived(tabStore.tabs.filter((t) => t.parentTabId === null));
const subagentTabs = $derived(
tabStore.tabs.filter((t) => t.parentTabId !== null && t.parentTabId === activeUserTabId),
@@ -123,7 +139,14 @@ function handleRenameKeydown(e: KeyboardEvent): void {
tabindex="0"
>
<span class="flex items-center gap-1.5">
- <span class="w-1.5 h-1.5 rounded-full shrink-0 {statusColor(tab.agentStatus)}"></span>
+ {#if needsAttention(tab)}
+ <span class="relative inline-grid shrink-0 *:[grid-area:1/1]">
+ <span class="w-1.5 h-1.5 rounded-full animate-ping {statusColor(tab.agentStatus)}"></span>
+ <span class="w-1.5 h-1.5 rounded-full {statusColor(tab.agentStatus)}"></span>
+ </span>
+ {:else}
+ <span class="w-1.5 h-1.5 rounded-full shrink-0 {statusColor(tab.agentStatus)}"></span>
+ {/if}
<span class="font-mono text-[10px] px-1 py-0.5 rounded bg-base-300 text-base-content/60 shrink-0" title="Tab ID — agents address this tab by this handle">{tabStore.shortHandleFor(tab.id)}</span>
{#if editingTabId === tab.id}
<input
@@ -183,7 +206,14 @@ function handleRenameKeydown(e: KeyboardEvent): void {
tabindex="0"
>
<span class="flex items-center gap-1">
- <span class="w-1 h-1 rounded-full shrink-0 {statusColor(tab.agentStatus)}"></span>
+ {#if needsAttention(tab)}
+ <span class="relative inline-grid shrink-0 *:[grid-area:1/1]">
+ <span class="w-1 h-1 rounded-full animate-ping {statusColor(tab.agentStatus)}"></span>
+ <span class="w-1 h-1 rounded-full {statusColor(tab.agentStatus)}"></span>
+ </span>
+ {:else}
+ <span class="w-1 h-1 rounded-full shrink-0 {statusColor(tab.agentStatus)}"></span>
+ {/if}
<span class="font-mono text-[10px] px-1 rounded bg-base-300 text-base-content/60 shrink-0" title="Tab ID — agents address this tab by this handle">{tabStore.shortHandleFor(tab.id)}</span>
<span class="max-w-28 truncate text-xs">{tab.title}</span>
</span>
diff --git a/packages/frontend/src/lib/components/ToolPermissions.svelte b/packages/frontend/src/lib/components/ToolPermissions.svelte
index 6b09a07..4298724 100644
--- a/packages/frontend/src/lib/components/ToolPermissions.svelte
+++ b/packages/frontend/src/lib/components/ToolPermissions.svelte
@@ -53,6 +53,12 @@ const toolPermissions: ToolPermission[] = [
description: "Allow the AI to search the codebase with the cs ranked code-search engine",
},
{
+ id: "key_usage",
+ label: "Key usage",
+ description:
+ "Allow the AI to read current API-key usage levels, rate-limit headroom, and reset times",
+ },
+ {
id: "lsp",
label: "LSP queries",
description:
diff --git a/packages/frontend/src/lib/settings.svelte.ts b/packages/frontend/src/lib/settings.svelte.ts
index 0da4e45..1b93804 100644
--- a/packages/frontend/src/lib/settings.svelte.ts
+++ b/packages/frontend/src/lib/settings.svelte.ts
@@ -15,6 +15,7 @@ let toolPerms = $state<Record<string, boolean>>({
web_search: false,
youtube_transcribe: false,
search_code: false,
+ key_usage: false,
lsp: false,
});
let savedToolPerms = $state<Record<string, boolean>>({
@@ -29,6 +30,7 @@ let savedToolPerms = $state<Record<string, boolean>>({
web_search: false,
youtube_transcribe: false,
search_code: false,
+ key_usage: false,
lsp: false,
});
let skillChecks = $state<Record<string, boolean>>({});