Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,8 @@ printf %s "$TOKEN" | ade secrets set TOKEN --stdin
ade secrets set TOKEN --value-file token.txt
ade secrets delete STRIPE_API_KEY
ade usage snapshot --text
ade usage refresh --text
ade --role cto usage refresh --text # live Claude/Codex quota only
ade --role cto usage refresh --history --text # local provider history + costs
ade usage budget get --text
ade usage budget set --from-file budget.json
ade usage budget check --provider claude --scope global
Expand Down
17 changes: 17 additions & 0 deletions apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,9 @@ function createRuntime() {
daily: [],
})),
getUsageSnapshot: vi.fn(() => ({ available: true, entries: [] })),
noteQuotaDemand: vi.fn(() => ({ available: true, entries: [] })),
forceRefresh: vi.fn(async () => ({ available: true, entries: [] })),
refreshHistory: vi.fn(async () => ({ available: true, entries: [] })),
poll: vi.fn(async () => ({ available: true, entries: [] })),
start: vi.fn(() => {}),
stop: vi.fn(() => {}),
Expand Down Expand Up @@ -2599,6 +2601,12 @@ describe("adeRpcServer", () => {
expect(usageActions.structuredContent.actions).toContainEqual(
expect.objectContaining({ domain: "usage", action: "getAdeUsageStats", name: "usage.getAdeUsageStats" }),
);
expect(usageActions.structuredContent.actions).toContainEqual(
expect.objectContaining({ domain: "usage", action: "noteQuotaDemand", name: "usage.noteQuotaDemand" }),
);
expect(usageActions.structuredContent.actions).not.toContainEqual(
expect.objectContaining({ domain: "usage", action: "refreshHistory" }),
);

const allDomains = await callTool(handler, "list_ade_actions", { domain: "all" });
expect(allDomains?.isError).toBeUndefined();
Expand Down Expand Up @@ -2727,6 +2735,15 @@ describe("adeRpcServer", () => {
expect(fixture.runtime.usageTrackingService.getAdeUsageStats).toHaveBeenCalledWith({ preset: "7d" });
expect(usageStats.structuredContent.result).toMatchObject({ preset: "7d", daily: [] });

const quotaDemand = await callTool(handler, "run_ade_action", {
domain: "usage",
action: "noteQuotaDemand",
args: {},
});
expect(quotaDemand?.isError).toBeUndefined();
expect(fixture.runtime.usageTrackingService.noteQuotaDemand).toHaveBeenCalledWith(undefined);
expect(quotaDemand.structuredContent.result).toEqual({ available: true, entries: [] });

});

it("records normalized ADE Code actions without recording terminal keystrokes", async () => {
Expand Down
7 changes: 6 additions & 1 deletion apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1300,7 +1300,12 @@ export async function createAdeRuntime(args: {
});
const detachPushSources = publishPushEvents
? pushPublisherService.attachSources(projectId, {
agentChatService: agentChatService ?? null,
// The lightweight no-agent headless chat stub intentionally exposes
// only its request/response surface. Do not treat it as an event
// source unless it implements the full subscription contract.
agentChatService: typeof agentChatService?.subscribeToEvents === "function"
? agentChatService
: null,
ptyService,
subscribePrNotifications: (cb) => {
pushPrNotificationSubscribers.add(cb);
Expand Down
9 changes: 9 additions & 0 deletions apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7382,6 +7382,15 @@ describe("ADE CLI", () => {
expect(polled.kind).toBe("execute");
if (polled.kind !== "execute") return;
expect(polled.steps[0]?.params).toEqual(plan.steps[0]?.params);

const history = buildCliPlan(["usage", "refresh", "--history"]);
expect(history.kind).toBe("execute");
if (history.kind !== "execute") return;
expect(history.label).toBe("usage history refresh");
expect(history.steps[0]?.params).toEqual({
name: "run_ade_action",
arguments: { domain: "usage", action: "refreshHistory", args: {} },
});
});

it("usage budget get routes to the budget.getConfig action", () => {
Expand Down
19 changes: 9 additions & 10 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1961,19 +1961,17 @@ const HELP_BY_COMMAND: Record<string, string> = {
usage: `${ADE_BANNER}
Usage and provider quotas

Reads live provider quota usage (Claude five-hour + weekly, Codex five-hour +
weekly, Cursor monthly via the team Admin API), pacing, costs, and budget
guardrails. The desktop app surfaces this same data in the top-bar Usage popup.
Reads authoritative Claude and Codex quota windows, pacing, cached local
history, and budget guardrails. Live quota refresh is intentionally separate
from local provider-ledger scans.

$ ade usage snapshot --text Cached snapshot (windows, pacing, costs, errors)
$ ade usage refresh --text Force a fresh poll (invalidates cost cache)
$ ade usage snapshot --text Cached quota, source/stale state, and history
$ ade --role cto usage refresh --text Refresh live provider quota only
$ ade --role cto usage refresh --history --text Scan local provider history and costs
$ ade usage budget get --text Read budget guardrail config
$ ade usage budget set --from-file budget.json Save budget guardrail config
$ ade usage budget check --provider claude --scope global
$ ade usage budget cumulative --scope global Cumulative spend for the current week

Cursor uses the Admin API (https://api.cursor.com/teams/spend) — set
CURSOR_ADMIN_API_KEY (or CURSOR_API_KEY) so the poll can authenticate.
`,
secrets: `${ADE_BANNER}
ADE project secrets
Expand Down Expand Up @@ -9764,10 +9762,11 @@ function buildUsagePlan(args: string[]): CliPlan {
};
}
if (sub === "refresh" || sub === "poll") {
const history = args.includes("--history");
return {
kind: "execute",
label: "usage refresh",
steps: [actionStep("result", "usage", "forceRefresh", {})],
label: history ? "usage history refresh" : "usage refresh",
steps: [actionStep("result", "usage", history ? "refreshHistory" : "forceRefresh", {})],
};
}
if (sub === "budget") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,10 @@ function makePairingConnectInfo(
describe("createSyncRemoteCommandService", () => {
it("serves the cross-client usage snapshot to paired mobile and web clients", async () => {
const getAdeUsageStats = vi.fn().mockResolvedValue({ generatedAt: "2026-07-09T12:00:00.000Z", daily: [] });
const { service } = createService({ usageTrackingService: { getAdeUsageStats } });
const quotaSnapshot = { windows: [], lastPolledAt: "2026-07-09T12:00:00.000Z", errors: [] };
const getUsageSnapshot = vi.fn(() => quotaSnapshot);
const forceRefresh = vi.fn(async () => ({ ...quotaSnapshot, lastPolledAt: "2026-07-09T12:01:00.000Z" }));
const { service } = createService({ usageTrackingService: { getAdeUsageStats, getUsageSnapshot, forceRefresh } });

expect(service.getDescriptor("usage.getAdeStats")).toEqual({
action: "usage.getAdeStats",
Expand All @@ -127,6 +130,29 @@ describe("createSyncRemoteCommandService", () => {
await expect(service.execute(makePayload("usage.getAdeStats", { preset: "decade" }))).rejects.toThrow(
"usage.getAdeStats preset must be today, 7d, 30d, year, or all.",
);
expect(service.getDescriptor("usage.getQuotaSnapshot")).toEqual({
action: "usage.getQuotaSnapshot",
scope: "runtime",
policy: { viewerAllowed: true },
});
expect(service.getDescriptor("usage.refreshQuota")).toEqual({
action: "usage.refreshQuota",
scope: "runtime",
policy: { viewerAllowed: true },
});
await expect(service.execute(makePayload("usage.getQuotaSnapshot"))).resolves.toEqual(quotaSnapshot);
await expect(service.execute(makePayload("usage.refreshQuota"))).resolves.toMatchObject({
lastPolledAt: "2026-07-09T12:01:00.000Z",
});
expect(getUsageSnapshot).toHaveBeenCalledTimes(1);
expect(forceRefresh).toHaveBeenCalledWith({ allowInteractiveAuth: false });

getAdeUsageStats.mockClear();
await service.execute(makePayload("usage.getAdeStats", { preset: "7d", scope: "project" }));
expect(getAdeUsageStats).toHaveBeenCalledWith({ preset: "7d", scope: "project" });
await expect(service.execute(makePayload("usage.getAdeStats", { scope: "galaxy" }))).rejects.toThrow(
"usage.getAdeStats scope must be machine or project.",
);
});

it("advertises and executes machine personal chats as runtime commands", async () => {
Expand Down
17 changes: 16 additions & 1 deletion apps/ade-cli/src/services/sync/syncRemoteCommandService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ import type {
UpdatePrTitleArgs,
WriteTextAtomicArgs,
} from "../../../../desktop/src/shared/types";
import { isAdeUsageRangePreset } from "../../../../desktop/src/shared/types";
import { isAdeUsageRangePreset, isAdeUsageScope } from "../../../../desktop/src/shared/types";
import type { OrchestrationRunCreateRequest } from "../../../../desktop/src/shared/types/orchestration";
import { PERSONAL_CHAT_ACTIONS, isPersonalChatActionQueueable } from "../../../../desktop/src/shared/types/personalChats";
import {
Expand Down Expand Up @@ -4527,11 +4527,26 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg
if (preset && !isAdeUsageRangePreset(preset)) {
throw new Error("usage.getAdeStats preset must be today, 7d, 30d, year, or all.");
}
const scope = asTrimmedString(payload.scope);
if (scope && !isAdeUsageScope(scope)) {
throw new Error("usage.getAdeStats scope must be machine or project.");
}
return await args.usageTrackingService.getAdeUsageStats({
...(isAdeUsageRangePreset(preset) ? { preset } : {}),
...(isAdeUsageScope(scope) ? { scope } : {}),
});
});

register("usage.getQuotaSnapshot", { viewerAllowed: true }, async () => {
Comment thread
arul28 marked this conversation as resolved.
if (!args.usageTrackingService) throw new Error("Usage quota is not available in this runtime.");
return args.usageTrackingService.getUsageSnapshot();
}, "runtime");

register("usage.refreshQuota", { viewerAllowed: true }, async () => {
if (!args.usageTrackingService) throw new Error("Usage quota is not available in this runtime.");
return await args.usageTrackingService.forceRefresh({ allowInteractiveAuth: false });
}, "runtime");

registerLaneRemoteCommands({ args, register });
registerWorkRemoteCommands({ args, register });
registerProcessRemoteCommands({ args, register });
Expand Down
16 changes: 15 additions & 1 deletion apps/ade-cli/src/services/sync/syncService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,10 @@ describe("createSyncService", () => {
preset: "year",
daily: [],
}));
const getUsageSnapshot = vi.fn(() => ({ windows: [], lastPolledAt: "2026-07-09T12:00:00.000Z", errors: [] }));
const forceRefresh = vi.fn(async () => ({ windows: [], lastPolledAt: "2026-07-09T12:01:00.000Z", errors: [] }));
const service = createService(db, projectRoot, {
usageTrackingService: { getAdeUsageStats } as any,
usageTrackingService: { getAdeUsageStats, getUsageSnapshot, forceRefresh } as any,
});

try {
Expand All @@ -103,6 +105,18 @@ describe("createSyncService", () => {
args: { preset: "year" },
})).resolves.toMatchObject({ preset: "year", daily: [] });
expect(getAdeUsageStats).toHaveBeenCalledWith({ preset: "year" });
await expect(service.executeRemoteCommand({
commandId: "cmd-usage-quota",
action: "usage.getQuotaSnapshot",
args: {},
})).resolves.toMatchObject({ lastPolledAt: "2026-07-09T12:00:00.000Z" });
await expect(service.executeRemoteCommand({
commandId: "cmd-refresh-quota",
action: "usage.refreshQuota",
args: {},
})).resolves.toMatchObject({ lastPolledAt: "2026-07-09T12:01:00.000Z" });
expect(getUsageSnapshot).toHaveBeenCalledTimes(1);
expect(forceRefresh).toHaveBeenCalledWith({ allowInteractiveAuth: false });
} finally {
await service.dispose();
db.close();
Expand Down
31 changes: 31 additions & 0 deletions apps/ade-cli/src/tuiClient/__tests__/RightPane.usage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,20 @@ describe("RightPane usage", () => {
const { lastFrame } = renderPane({
kind: "usage",
title: "Usage",
providerStatuses: [{
id: "claude",
label: "Claude",
state: "ok",
source: "oauth",
updatedAt: new Date().toISOString(),
}],
quotaWindows: [{ id: "rate-limit", label: "Rate limit", percent: 42, resetAt }],
session: { input: 12_000, output: 3_400, cost: 0.42 },
});
const text = lastFrame() ?? "";
expect(text).toContain("USAGE");
expect(text).toContain("Claude");
expect(text).toContain("OAUTH · just now");
expect(text).toContain("Rate limit");
expect(text).toContain("42%");
// Live reset countdown marker.
Expand All @@ -35,6 +44,28 @@ describe("RightPane usage", () => {
expect(text).toContain("$0.42");
});

it("marks carried-forward provider data as stale without hiding quota windows", () => {
const { lastFrame } = renderPane({
kind: "usage",
title: "Usage",
providerStatuses: [{
id: "codex",
label: "Codex",
state: "stale",
source: "http",
updatedAt: new Date(Date.now() - 2 * 60_000).toISOString(),
message: "Rate limited; retrying soon",
}],
quotaWindows: [{ id: "codex:weekly", label: "Codex weekly", percent: 61, resetAt: null }],
session: null,
});
const text = lastFrame() ?? "";
expect(text).toContain("HTTP · 2m ago");
expect(text).toContain("STALE · Rate limited; retrying soon");
expect(text).toContain("Codex weekly");
expect(text).toContain("61%");
});

it("degrades to the session block when quota windows are unavailable", () => {
const { lastFrame } = renderPane({
kind: "usage",
Expand Down
6 changes: 5 additions & 1 deletion apps/ade-cli/src/tuiClient/__tests__/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,9 +222,13 @@ describe("commands", () => {

it("keeps provider-agnostic skills visible outside Claude chats", () => {
const rows = paletteCommands("/", [], { provider: "codex" });
for (const name of ["/agents", "/init", "/usage", "/insights", "/fast"]) {
for (const name of ["/agents", "/init", "/insights", "/fast"]) {
expect(rows).not.toContainEqual(expect.objectContaining({ name }));
}
expect(rows).toContainEqual(expect.objectContaining({
name: "/usage",
description: "Show Claude and Codex limits plus session usage",
}));
expect(rows).toContainEqual(expect.objectContaining({
name: "/skills",
description: "List agent skills from project, user, and ADE bundled roots",
Expand Down
68 changes: 41 additions & 27 deletions apps/ade-cli/src/tuiClient/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import type { LaneDeleteRisk, LaneLinearIssue, LaneSummary } from "../../../desk
import type { FeedbackPreparedDraft, FeedbackSubmission } from "../../../desktop/src/shared/types/feedback";
import type { ProjectSecretsListResult, ProjectSecretValueResult } from "../../../desktop/src/shared/types/projectSecrets";
import type { SearchQueryResult, SearchResultItem } from "../../../desktop/src/shared/types/search";
import type { ChatTerminalPreviewResult, ChatTerminalSession } from "../../../desktop/src/shared/types";
import type { ChatTerminalPreviewResult, ChatTerminalSession, UsageSnapshot } from "../../../desktop/src/shared/types";
import {
DEFAULT_CODEX_REASONING_EFFORT,
approveToolUse,
Expand Down Expand Up @@ -9094,38 +9094,52 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
return;
}
if (name === "/usage") {
if (!sessionId) {
addNotice("Usage needs an active chat.", "error");
return;
}
if (activeCommandProvider !== "claude") {
addNotice("Usage is available for Claude chats.", "error");
return;
}
// Session tokens/cost come straight off the local event stream — always
// available even when the daemon snapshot carries no quota window. Mirror
// the token-summary effect's fallback-context resolution.
const usageFallbackContext = activeSession?.modelId
? getModelById(activeSession.modelId)?.contextWindow ?? null
: null;
const stats = latestTokenStats(events, usageFallbackContext);
const sessionBlock = {
input: stats.inputTokens,
output: stats.outputTokens,
cost: stats.costUsd,
};
// Quota windows are reuse-only: the daemon exposes at most a single
// rate-limit window (parsed into stats.rateLimit). Render it when present,
// otherwise degrade to the session block.
const quotaWindows = stats.rateLimit?.usedPercentage != null
? [{
id: "rate-limit",
label: "Rate limit",
percent: stats.rateLimit.usedPercentage,
resetAt: stats.rateLimit.resetsAt,
}]
: undefined;
setRightPane({ kind: "usage", title: "Usage", quotaWindows, session: sessionBlock });
const stats = sessionId ? latestTokenStats(events, usageFallbackContext) : null;
const sessionBlock = stats
? { input: stats.inputTokens, output: stats.outputTokens, cost: stats.costUsd }
: null;
setRightPane({ kind: "usage", title: "Usage", loading: true, session: sessionBlock });
try {
const snapshot = await conn.action<UsageSnapshot>("usage", "getUsageSnapshot", {});
const providerStatuses = (["claude", "codex", "cursor"] as const).flatMap((provider) => {
const status = snapshot.providerStatus?.[provider];
if (!status) return [];
return [{
id: provider,
label: providerLabel(provider),
state: status.state,
source: status.source,
updatedAt: status.updatedAt ?? status.lastSuccessAt,
message: status.message,
}];
});
const quotaWindows = snapshot.windows.map((window, index) => {
const provider = providerLabel(window.provider);
const label = window.windowType === "five_hour"
? "5-hour"
: window.windowType.replaceAll("_", " ");
return {
id: `${window.provider}:${window.windowType}:${index}`,
label: `${provider} ${label}`,
percent: window.percentUsed,
resetAt: Math.floor(Date.parse(window.resetsAt) / 1000),
};
});
setRightPane({ kind: "usage", title: "Usage", providerStatuses, quotaWindows, session: sessionBlock });
} catch (error) {
setRightPane({
kind: "usage",
title: "Usage",
session: sessionBlock,
error: error instanceof Error ? error.message : String(error),
});
}
return;
}
if (name === "/agents") {
Expand Down
2 changes: 1 addition & 1 deletion apps/ade-cli/src/tuiClient/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [
{ name: "/secrets", description: "List project secret names and copy masked values", placement: "right", category: "Nav" },
{ name: "/compact", description: "Compact the active chat context", placement: "chat", argumentHint: "[instructions]", providers: ["claude", "codex"], category: "Model" },
{ name: "/init", description: "Generate AGENTS.md and Claude pointer files", placement: "right", providers: ["claude"], category: "Nav" },
{ name: "/usage", description: "Show Claude usage through the active SDK session", placement: "chat", providers: ["claude"], category: "Model" },
{ name: "/usage", description: "Show Claude and Codex limits plus session usage", placement: "chat", category: "Model" },
{ name: "/insights", description: "Generate Claude session insights through the active SDK session", placement: "chat", providers: ["claude"], category: "Model" },
{ name: "/fast", description: "Toggle Claude fast mode through the active SDK session", placement: "chat", argumentHint: "[on|off]", providers: ["claude"], category: "Model" },
{ name: "/goal", description: "Set, clear, or inspect the active chat goal", placement: "chat", argumentHint: "[<text>|clear|status active|paused|complete]", providers: ["claude", "codex"], category: "Model" },
Expand Down
Loading