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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ Claude Code is the full experience. Antigravity and Codex are off until you turn
Antigravity and Codex" in Settings (or list them in Botloft's `config.toml`, `experimental_agents =
["agy"]`, `["codex"]` or both); then a new agent can be made on them, and Settings has "New agents run
on". A Codex agent asks you before it changes a file or runs a
command, like Claude Code, and shows the exact size of its conversation; it can still read any file.
command, like Claude Code, and shows the exact size of its conversation and the use of its plan; it can still read any file.
It has its own model list and effort picker, and understands images. See [spec section 30](docs/spec.md) for what was
checked.

Expand Down
61 changes: 46 additions & 15 deletions app/src/features/account/UsageDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,41 +3,72 @@

import { useT } from "../../i18n";
import { fromNow } from "../../lib/format";
import type { UsageWindow } from "../../lib/protocol.gen";
import type { AgentUsage, UsageWindow } from "../../lib/protocol.gen";
import { useApp } from "../../store/context";
import { Callout } from "../../ui/Callout";
import { Dialog } from "../../ui/Dialog";
import { TokensByBot } from "./TokensByBot";

export function UsageDialog({ onClose }: { onClose(): void }) {
const u = useT().account.usage;
const usage = useApp((state) => state.system?.usage ?? null);
const limited =
usage !== null && usage.status !== "allowed" && usage.status !== "allowed_warning";
const t = useT();
const u = t.account.usage;
const claude = useApp((state) => state.system?.usage ?? null);
const others = useApp((state) => state.system?.agentUsage ?? NO_USAGE);
const names = t.bots.dialog.agent.names;
const named = others.length > 0;
const shown = [
...(claude === null ? [] : [{ agent: "claude" as const, plan: null, usage: claude }]),
...others,
].filter((entry) => entry.usage.windows.length > 0);

return (
<Dialog title={u.title} onClose={onClose}>
<div className="flex flex-col gap-4 text-sm">
<p className="text-ink-soft leading-relaxed">{u.intro}</p>
{usage === null || usage.windows.length === 0 ? (
{shown.length === 0 ? (
<p className="text-muted">{u.empty}</p>
) : (
<>
{limited && <Callout tone="warn" title={u.limited} />}
<ul className="flex flex-col gap-4">
{usage.windows.map((window) => (
<Window key={window.name} window={window} />
))}
</ul>
<p className="text-muted text-xs">{u.updated(fromNow(usage.observedAt))}</p>
</>
shown.map((entry) => {
const limited =
entry.usage.status !== "allowed" && entry.usage.status !== "allowed_warning";
return (
<section key={entry.agent} className="flex flex-col gap-4">
{named && (
<h3 className="font-medium">
{names[entry.agent]}
{entry.plan !== null && (
<span className="ml-2 font-normal text-muted text-xs">
{u.plan(entry.plan)}
</span>
)}
</h3>
)}
{limited && (
<Callout
tone="warn"
title={
entry.agent === "claude" ? u.limited : u.limitedAgent(names[entry.agent])
}
/>
)}
<ul className="flex flex-col gap-4">
{entry.usage.windows.map((window) => (
<Window key={window.name} window={window} />
))}
</ul>
<p className="text-muted text-xs">{u.updated(fromNow(entry.usage.observedAt))}</p>
</section>
);
})
)}
<TokensByBot />
</div>
</Dialog>
);
}

const NO_USAGE: AgentUsage[] = [];

function Window({ window }: { window: UsageWindow }) {
const u = useT().account.usage;
const percent = Math.round(Math.min(1, Math.max(0, window.utilization)) * 100);
Expand Down
38 changes: 38 additions & 0 deletions app/src/features/account/account.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,44 @@ describe("account area", () => {
expect(dialog.textContent).toContain("92% used");
});

test("usage of another agent comes under its own name, with its plan and its limit", async () => {
const fake = new FakeBotloft();
const now = Date.now();
fake.system = {
...fake.system,
usage: {
status: "allowed",
resetsAt: null,
observedAt: now,
windows: [{ name: "five_hour", utilization: 0.2, resetsAt: null }],
},
agentUsage: [
{
agent: "codex",
plan: "free",
usage: {
status: "rejected",
resetsAt: now + 3 * 86_400_000,
observedAt: now,
windows: [{ name: "thirty_day", utilization: 1, resetsAt: now + 3 * 86_400_000 }],
},
},
],
};
renderApp(fake);
await openAccount();
fireEvent.click(screen.getByRole("menuitem", { name: "Usage" }));
const dialog = screen.getByRole("dialog", { name: "Usage" });
expect(within(dialog).getByRole("heading", { name: /^Claude Code/ })).toBeDefined();
expect(within(dialog).getByRole("heading", { name: /^Codex/ })).toBeDefined();
expect(dialog.textContent).toContain("Plan: free");
expect(within(dialog).getByRole("progressbar", { name: "30-day window" })).toBeDefined();
expect(dialog.textContent).toContain("100% used");
expect(dialog.textContent).toContain("Agents on Codex wait until it resets.");
// Claude Code is not limited: its own warning does not show.
expect(dialog.textContent).not.toContain("Your agents wait until it resets.");
});

test("usage waits for an agent's first reply", async () => {
renderApp();
await openAccount();
Expand Down
3 changes: 3 additions & 0 deletions app/src/i18n/en/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@ export const account = {
seven_day: "This week",
seven_day_opus: "This week, Opus",
seven_day_sonnet: "This week, Sonnet",
thirty_day: "30-day window",
} as Record<string, string>,
used: (percent: number) => `${percent}% used`,
resets: (relative: string) => `Resets ${relative}`,
limited: "Limit reached. Your agents wait until it resets.",
limitedAgent: (agent: string) => `Limit reached. Agents on ${agent} wait until it resets.`,
plan: (plan: string) => `Plan: ${plan}`,
updated: (relative: string) => `Updated ${relative}`,
tokens: {
title: "Use by agent",
Expand Down
4 changes: 4 additions & 0 deletions app/src/i18n/es/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,14 @@ export const account: Messages["account"] = {
seven_day: "Esta semana",
seven_day_opus: "Esta semana, Opus",
seven_day_sonnet: "Esta semana, Sonnet",
thirty_day: "Ventana de 30 días",
},
used: (percent: number) => `${percent}% usado`,
resets: (relative: string) => `Se renueva ${relative}`,
limited: "Límite alcanzado. Tus agentes esperan hasta que se renueve.",
limitedAgent: (agent: string) =>
`Límite alcanzado. Los agentes en ${agent} esperan hasta que se renueve.`,
plan: (plan: string) => `Plan: ${plan}`,
updated: (relative: string) => `Actualizado ${relative}`,
tokens: {
title: "Uso por agente",
Expand Down
4 changes: 4 additions & 0 deletions app/src/i18n/pt-BR/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,14 @@ export const account: Messages["account"] = {
seven_day: "Esta semana",
seven_day_opus: "Esta semana, Opus",
seven_day_sonnet: "Esta semana, Sonnet",
thirty_day: "Janela de 30 dias",
},
used: (percent: number) => `${percent}% usado`,
resets: (relative: string) => `Renova ${relative}`,
limited: "Limite atingido. Seus agentes esperam até ele renovar.",
limitedAgent: (agent: string) =>
`Limite atingido. Os agentes no ${agent} esperam até ele renovar.`,
plan: (plan: string) => `Plano: ${plan}`,
updated: (relative: string) => `Atualizado ${relative}`,
tokens: {
title: "Uso por agente",
Expand Down
1 change: 1 addition & 0 deletions app/src/lib/fake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export class FakeBotloft implements BotloftApi {
usage: null,
enabledAgents: ["claude"],
agentChecks: [],
agentUsage: [],
};
/** How many times the app asked for a new Claude Code check. */
refreshes = 0;
Expand Down
16 changes: 15 additions & 1 deletion app/src/lib/protocol.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,12 @@ enabledAgents: Array<AgentKind>,
* Whether each enabled agent other than Claude Code is installed
* (spec 30). Claude Code has its own fields above.
*/
agentChecks: Array<AgentCheck>, };
agentChecks: Array<AgentCheck>,
/**
* The account usage of the agents other than Claude Code, as each last
* reported it (spec 30); `usage` above is Claude's.
*/
agentUsage: Array<AgentUsage>, };

export type CrewsCreateParams = { name: string,
/**
Expand Down Expand Up @@ -529,6 +534,15 @@ export type AgentCheck = { agent: AgentKind,
*/
version: string | null, };

/**
* The usage of an agent's account, with the plan it is on.
*/
export type AgentUsage = { agent: AgentKind,
/**
* The plan the agent names (`free`, `plus`...); `null` when it does not.
*/
plan: string | null, usage: AccountUsage, };

export type BotIdParams = { botId: BotId, };

export type BotsRestartParams = { botId: BotId,
Expand Down
1 change: 1 addition & 0 deletions crates/botloft-core/src/protocol/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ fn export_bindings() {
out.decl::<AgentsModelsParams>();
out.decl::<AgentModel>();
out.decl::<AgentCheck>();
out.decl::<AgentUsage>();
out.decl::<BotIdParams>();
out.decl::<BotsRestartParams>();
out.decl::<BotStateChanged>();
Expand Down
14 changes: 14 additions & 0 deletions crates/botloft-core/src/protocol/methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,20 @@ pub struct SystemStatus {
/// Whether each enabled agent other than Claude Code is installed
/// (spec 30). Claude Code has its own fields above.
pub agent_checks: Vec<AgentCheck>,
/// The account usage of the agents other than Claude Code, as each last
/// reported it (spec 30); `usage` above is Claude's.
pub agent_usage: Vec<AgentUsage>,
}

/// The usage of an agent's account, with the plan it is on.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(test, derive(ts_rs::TS))]
pub struct AgentUsage {
pub agent: AgentKind,
/// The plan the agent names (`free`, `plus`...); `null` when it does not.
pub plan: Option<String>,
pub usage: AccountUsage,
}

/// What looking for an agent's program found.
Expand Down
7 changes: 7 additions & 0 deletions crates/botloftd/src/agent/codex_decoder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ impl CodexDecoder {
tracing::warn!(bot = %bot, "codex could not start a thread: {message:?}");
}
Answer::SignedOut => self.signed_out(daemon, bot, generation),
Answer::Usage(limits) => {
if let Some(usage) = super::codex_usage::parse(&limits, daemon.clock.now_ms()) {
daemon.set_agent_usage(usage);
}
}
Answer::Lost | Answer::None => {}
}
}
Expand Down Expand Up @@ -231,6 +236,8 @@ impl CodexDecoder {
.as_i64()
.map(|seconds| seconds * 1000)
.or(self.limit_resets_at);
// The update is sparse: the whole snapshot is read again.
self.shared.read_rate_limits();
}
"turn/completed" => self.turn_completed(daemon, bot, generation, &params["turn"]),
_ => {}
Expand Down
Loading
Loading