Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Loading