diff --git a/README.md b/README.md index ca1e24ef..4c7255f7 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/src/features/account/UsageDialog.tsx b/app/src/features/account/UsageDialog.tsx index cbaa96a1..8ab6e5c4 100644 --- a/app/src/features/account/UsageDialog.tsx +++ b/app/src/features/account/UsageDialog.tsx @@ -3,34 +3,63 @@ 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 (

{u.intro}

- {usage === null || usage.windows.length === 0 ? ( + {shown.length === 0 ? (

{u.empty}

) : ( - <> - {limited && } -
    - {usage.windows.map((window) => ( - - ))} -
-

{u.updated(fromNow(usage.observedAt))}

- + shown.map((entry) => { + const limited = + entry.usage.status !== "allowed" && entry.usage.status !== "allowed_warning"; + return ( +
+ {named && ( +

+ {names[entry.agent]} + {entry.plan !== null && ( + + {u.plan(entry.plan)} + + )} +

+ )} + {limited && ( + + )} +
    + {entry.usage.windows.map((window) => ( + + ))} +
+

{u.updated(fromNow(entry.usage.observedAt))}

+
+ ); + }) )}
@@ -38,6 +67,8 @@ export function UsageDialog({ onClose }: { onClose(): void }) { ); } +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); diff --git a/app/src/features/account/account.test.tsx b/app/src/features/account/account.test.tsx index a6e33daa..b01c5e36 100644 --- a/app/src/features/account/account.test.tsx +++ b/app/src/features/account/account.test.tsx @@ -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(); diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts index 305de3a7..fcbdfacc 100644 --- a/app/src/i18n/en/account.ts +++ b/app/src/i18n/en/account.ts @@ -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, 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", diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts index 131441ec..1a712437 100644 --- a/app/src/i18n/es/account.ts +++ b/app/src/i18n/es/account.ts @@ -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", diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts index 99e64ad4..6eec1a69 100644 --- a/app/src/i18n/pt-BR/account.ts +++ b/app/src/i18n/pt-BR/account.ts @@ -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", diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts index 9e7e18ff..36978edf 100644 --- a/app/src/lib/fake.ts +++ b/app/src/lib/fake.ts @@ -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; diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index a43d5322..04724dde 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -418,7 +418,12 @@ enabledAgents: Array, * Whether each enabled agent other than Claude Code is installed * (spec 30). Claude Code has its own fields above. */ -agentChecks: Array, }; +agentChecks: Array, +/** + * The account usage of the agents other than Claude Code, as each last + * reported it (spec 30); `usage` above is Claude's. + */ +agentUsage: Array, }; export type CrewsCreateParams = { name: string, /** @@ -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, diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index 41900cf8..cac8af2c 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -161,6 +161,7 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); out.decl::(); out.decl::(); out.decl::(); diff --git a/crates/botloft-core/src/protocol/methods.rs b/crates/botloft-core/src/protocol/methods.rs index a3a8368c..86ac0386 100644 --- a/crates/botloft-core/src/protocol/methods.rs +++ b/crates/botloft-core/src/protocol/methods.rs @@ -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, + /// 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, +} + +/// 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, + pub usage: AccountUsage, } /// What looking for an agent's program found. diff --git a/crates/botloftd/src/agent/codex_decoder.rs b/crates/botloftd/src/agent/codex_decoder.rs index 3e8a5bda..4d2cc267 100644 --- a/crates/botloftd/src/agent/codex_decoder.rs +++ b/crates/botloftd/src/agent/codex_decoder.rs @@ -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 => {} } } @@ -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, ¶ms["turn"]), _ => {} diff --git a/crates/botloftd/src/agent/codex_usage.rs b/crates/botloftd/src/agent/codex_usage.rs new file mode 100644 index 00000000..7460d3df --- /dev/null +++ b/crates/botloftd/src/agent/codex_usage.rs @@ -0,0 +1,130 @@ +//! The Codex account's usage (spec 30.3.2): what `account/rateLimits/read` +//! answers, as the app's usage dialog shows it for Claude Code. + +use botloft_core::protocol::{AccountUsage, AgentKind, AgentUsage, UsageWindow}; +use serde_json::Value; + +/// From this share used on, the usage is a warning. +const WARN_FROM: i64 = 80; + +/// The `rateLimits` of a `account/rateLimits/read` answer. `None` when no +/// window is there (an account with no limit to show). +pub(super) fn parse(limits: &Value, now_ms: i64) -> Option { + let windows: Vec = ["primary", "secondary"] + .into_iter() + .filter_map(|key| window(&limits[key])) + .collect(); + if windows.is_empty() { + return None; + } + let reached = !limits["rateLimitReachedType"].is_null(); + let most = ["primary", "secondary"] + .into_iter() + .filter_map(|key| limits[key]["usedPercent"].as_i64()) + .max() + .unwrap_or(0); + let status = if reached || most >= 100 { + "rejected" + } else if most >= WARN_FROM { + "allowed_warning" + } else { + "allowed" + }; + // The window that is full is the one that decides when it is back. + let resets_at = windows + .iter() + .filter(|window| window.utilization >= 1.0) + .filter_map(|window| window.resets_at) + .max() + .or_else(|| windows.iter().filter_map(|window| window.resets_at).min()); + Some(AgentUsage { + agent: AgentKind::Codex, + plan: limits["planType"].as_str().map(str::to_owned), + usage: AccountUsage { + status: status.to_owned(), + resets_at, + windows, + observed_at: now_ms, + }, + }) +} + +fn window(value: &Value) -> Option { + let used = value["usedPercent"].as_i64()?; + let minutes = value["windowDurationMins"].as_i64(); + Some(UsageWindow { + name: window_name(minutes), + utilization: (used as f64 / 100.0).clamp(0.0, 1.0), + // Seconds in Codex, milliseconds here. + resets_at: value["resetsAt"].as_i64().map(|seconds| seconds * 1000), + }) +} + +/// The names the app knows for the windows of Claude Code, so the dialog +/// reads the same; any other length is named by its minutes. +fn window_name(minutes: Option) -> String { + match minutes { + Some(300) => "five_hour".to_owned(), + Some(10_080) => "seven_day".to_owned(), + Some(43_200) => "thirty_day".to_owned(), + Some(minutes) => format!("{minutes}_minutes"), + None => "window".to_owned(), + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn a_full_free_plan_is_a_limit_with_the_day_it_is_back() { + // What the real Codex 0.148 answered on 2026-10-11. + let limits = json!({ + "limitId": "codex", + "primary": { "usedPercent": 100, "windowDurationMins": 43200, "resetsAt": 1794165867 }, + "secondary": null, + "planType": "free", + "rateLimitReachedType": "rate_limit_reached", + }); + let usage = parse(&limits, 5).expect("usage"); + assert_eq!(usage.agent, AgentKind::Codex); + assert_eq!(usage.plan.as_deref(), Some("free")); + assert_eq!(usage.usage.status, "rejected"); + assert_eq!(usage.usage.resets_at, Some(1_794_165_867_000)); + assert_eq!(usage.usage.observed_at, 5); + assert_eq!(usage.usage.windows.len(), 1); + assert_eq!(usage.usage.windows[0].name, "thirty_day"); + assert!((usage.usage.windows[0].utilization - 1.0).abs() < f64::EPSILON); + } + + #[test] + fn two_windows_keep_their_names_and_warn_from_eighty() { + let limits = json!({ + "primary": { "usedPercent": 34, "windowDurationMins": 300, "resetsAt": 100 }, + "secondary": { "usedPercent": 82, "windowDurationMins": 10080, "resetsAt": 200 }, + "planType": "plus", + }); + let usage = parse(&limits, 0).expect("usage").usage; + assert_eq!(usage.status, "allowed_warning"); + let names: Vec<_> = usage.windows.iter().map(|w| w.name.as_str()).collect(); + assert_eq!(names, ["five_hour", "seven_day"]); + // Nothing is full: the nearest reset is the one to say. + assert_eq!(usage.resets_at, Some(100_000)); + } + + #[test] + fn an_account_without_a_window_shows_nothing() { + assert!(parse(&json!({ "primary": null, "secondary": null }), 0).is_none()); + assert!(parse(&json!({}), 0).is_none()); + } + + #[test] + fn an_odd_window_is_named_by_its_minutes() { + let limits = json!({ "primary": { "usedPercent": 1, "windowDurationMins": 90 } }); + let usage = parse(&limits, 0).expect("usage").usage; + assert_eq!(usage.windows[0].name, "90_minutes"); + assert_eq!(usage.windows[0].resets_at, None); + } +} diff --git a/crates/botloftd/src/agent/codex_wire.rs b/crates/botloftd/src/agent/codex_wire.rs index 8140d5c6..5e2721a9 100644 --- a/crates/botloftd/src/agent/codex_wire.rs +++ b/crates/botloftd/src/agent/codex_wire.rs @@ -24,6 +24,7 @@ enum Pending { ThreadResume, Turn, Compact, + RateLimits, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -226,6 +227,13 @@ impl Shared { } None => self.request(&mut state, "thread/start", params, Pending::ThreadStart), } + // After the thread, so the thread's request keeps its place. + self.request( + &mut state, + "account/rateLimits/read", + json!({}), + Pending::RateLimits, + ); Answer::None } Pending::ThreadResume if failed => { @@ -256,6 +264,8 @@ impl Shared { Answer::None } } + Pending::RateLimits if !failed => Answer::Usage(event["result"]["rateLimits"].clone()), + Pending::RateLimits => Answer::None, Pending::Compact if failed => { state.turn_active = false; self.pump(&mut state); @@ -275,6 +285,17 @@ impl Shared { } } + /// Asks for the account's rate limits again; the answer is `Answer::Usage`. + pub(super) fn read_rate_limits(&self) { + let mut state = self.lock(); + self.request( + &mut state, + "account/rateLimits/read", + json!({}), + Pending::RateLimits, + ); + } + /// The turn ended: the next queued message may go. pub(super) fn turn_done(&self) { let mut state = self.lock(); @@ -309,6 +330,8 @@ pub(super) enum Answer { Failed(Option), /// Nobody is signed in to Codex. SignedOut, + /// The account's rate limits, as `account/rateLimits/read` gave them. + Usage(Value), } /// What the supervisor writes to: Botloft's turn lines in, JSON-RPC out. @@ -394,7 +417,13 @@ mod tests { shared.answered(&json!({ "id": 1, "result": {} })); assert_eq!( methods(), - ["initialize", "initialized", "account/read", "thread/start"] + [ + "initialize", + "initialized", + "account/read", + "thread/start", + "account/rateLimits/read" + ] ); let start = written.lock().expect("lock")[3].clone(); assert_eq!(start["params"]["approvalPolicy"], "on-request"); diff --git a/crates/botloftd/src/agent/mod.rs b/crates/botloftd/src/agent/mod.rs index 891a39bf..390e2d8b 100644 --- a/crates/botloftd/src/agent/mod.rs +++ b/crates/botloftd/src/agent/mod.rs @@ -7,6 +7,7 @@ mod agy; mod claude; mod codex; mod codex_decoder; +mod codex_usage; mod codex_wire; use std::ffi::OsString; diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs index e14f7125..5abb0765 100644 --- a/crates/botloftd/src/service/mod.rs +++ b/crates/botloftd/src/service/mod.rs @@ -124,6 +124,7 @@ pub fn status(daemon: &Daemon) -> ApiResult { .filter(|kind| daemon.supervisor.agent_enabled(*kind)) .collect(), agent_checks: agent_checks(daemon), + agent_usage: daemon.agent_usage(), }) } diff --git a/crates/botloftd/src/state.rs b/crates/botloftd/src/state.rs index b983113e..1eb766bd 100644 --- a/crates/botloftd/src/state.rs +++ b/crates/botloftd/src/state.rs @@ -6,8 +6,8 @@ use std::sync::{Arc, Mutex, MutexGuard, TryLockError}; use std::time::Instant; use botloft_core::protocol::{ - AccountUsage, AutoBackupStatus, Bot, BotContextChanged, BotDeleted, BotDesktop, BotMcp, - BotRules, BotStateChanged, BrowserAction, BrowserState, ChatDelta, ChatItemChanged, + AccountUsage, AgentUsage, AutoBackupStatus, Bot, BotContextChanged, BotDeleted, BotDesktop, + BotMcp, BotRules, BotStateChanged, BrowserAction, BrowserState, ChatDelta, ChatItemChanged, CloudProgress, CloudSignedIn, Crew, CrewDeleted, Delivery, DesktopAwayUse, DesktopState, FolderRecycled, McpOverview, Message, MobilePairRequest, MobileStatus, Question, ReactionChanged, Routine, RoutineRun, ScreenDraft, Task, @@ -149,6 +149,8 @@ pub struct Daemon { owner_token: TokenHash, events: broadcast::Sender, usage: Mutex>, + /// The usage the other agents last reported, one per agent. + agent_usage: Mutex>, started: Instant, } @@ -198,6 +200,7 @@ impl Daemon { owner_token: options.owner_token, events, usage: Mutex::new(None), + agent_usage: Mutex::new(Vec::new()), started: Instant::now(), }) } @@ -257,6 +260,23 @@ impl Daemon { .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(usage); } + /// The account usage the agents other than Claude Code last reported. + pub fn agent_usage(&self) -> Vec { + self.agent_usage + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + + pub(crate) fn set_agent_usage(&self, usage: AgentUsage) { + let mut all = self + .agent_usage + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + all.retain(|have| have.agent != usage.agent); + all.push(usage); + } + pub fn uptime_ms(&self) -> i64 { i64::try_from(self.started.elapsed().as_millis()).unwrap_or(i64::MAX) } diff --git a/crates/botloftd/tests/codex_bot.rs b/crates/botloftd/tests/codex_bot.rs index 04ef4835..ee788dd2 100644 --- a/crates/botloftd/tests/codex_bot.rs +++ b/crates/botloftd/tests/codex_bot.rs @@ -746,3 +746,60 @@ async fn the_owners_connected_tools_are_in_the_thread_with_their_secrets() { // The crew's own server is still there, with the bot's token. assert!(servers["botloft"]["url"].is_string()); } + +#[tokio::test] +async fn the_codex_account_usage_reaches_the_status_and_follows_the_updates() { + let codex = codex_bot().await; + let process = codex.runtime.process(1).await; + handshake(&process, "th_usage").await; + // What Codex 0.148 answered for a full free plan. + let read = sent(&process, "account/rateLimits/read").await; + process + .emit(json!({ "id": read["id"], "result": { "rateLimits": { + "primary": { "usedPercent": 100, "windowDurationMins": 43200, "resetsAt": 1794165867 }, + "secondary": null, + "planType": "free", + "rateLimitReachedType": "rate_limit_reached", + } } })) + .await; + let usage = wait_for_usage(&codex).await; + assert_eq!(usage.plan.as_deref(), Some("free")); + assert_eq!(usage.usage.status, "rejected"); + assert_eq!(usage.usage.windows[0].name, "thirty_day"); + assert_eq!(usage.usage.resets_at, Some(1_794_165_867_000)); + + // An update is sparse, so the whole snapshot is asked for again. + let before = process + .input_lines() + .iter() + .filter(|line| line["method"] == "account/rateLimits/read") + .count(); + process + .emit(notify( + "account/rateLimits/updated", + json!({ "rateLimits": { "primary": { "usedPercent": 10, "resetsAt": 1 } } }), + )) + .await; + for _ in 0..600 { + let now = process + .input_lines() + .iter() + .filter(|line| line["method"] == "account/rateLimits/read") + .count(); + if now > before { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("the limits were not read again"); +} + +async fn wait_for_usage(codex: &Codex) -> botloft_core::protocol::AgentUsage { + for _ in 0..600 { + if let Some(usage) = codex.daemon.agent_usage().into_iter().next() { + return usage; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("no usage reported"); +} diff --git a/docs/spec.md b/docs/spec.md index 63db8b7f..e47baa89 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -2727,4 +2727,5 @@ Um agente só roda bots quando o Botloft impõe o que a seção 7.5 impõe no Cl | `codex`: retomar conversa, erros de login e de limite, compactação | Feitos e vistos com o `codex` real (A3b a A3d): retomada pelo `thread/resume`, `unauthorized` vira erro de login, `usageLimitExceeded` vira limite (o plano gratuito chegou a ele em 2026-10-10 e o bot ficou em limite, como deve), compactação por `thread/compact/start`. `turn/steer` **não visto** | feito | | `codex`: o que o dono usa pelo Botloft | Visto em 2026-10-10 com o `codex-cli` 0.148.0-alpha.9 e o plano gratuito: o chefe sugere um agente com `suggest_bot` (cartão, aprovação e agente criado em Codex); o navegador abre uma página depois da aprovação do site; `desktop_windows` sem permissão dada não age; mensagens e tarefas entre dois agentes Codex chegam e são concluídas; recusar uma edição não deixa arquivo, permitir deixa, o cartão traz "sempre permitir" (a regra vale para aquele arquivo e aquela ferramenta, como no Claude Code); o modo `plan` não escreve. O modo `accept_edits` e o comando só de leitura **não foram vistos**: o limite do plano acabou antes | feito, com esses dois a ver | | `codex`: leitura de arquivos | Segue **aberta**: o sandbox `read-only` deixa ler qualquer arquivo sem perguntar (`C:\Windows\win.ini` foi lido por comando). Um perfil de permissão (`default_permissions` e `permissions..filesystem` com `deny`) só vale com `windows.sandbox = "elevated"`, que precisa da configuração de administrador do Codex; mesmo onde ela existe, pastas novas dentro do perfil do usuário ficam ilegíveis para o usuário do sandbox (a pasta de trabalho falhou com `CreateProcessWithLogonW failed: 267`). Por isso o aviso do diálogo continua ("ainda pode ler qualquer arquivo") | não resolvido | +| `codex`: uso do plano | `account/rateLimits/read` (sem custo de cota) devolveu, com o `codex-cli` 0.148.0-alpha.9 e o plano gratuito, `rateLimits.primary { usedPercent: 100, windowDurationMins: 43200, resetsAt: 1794165867 }` (segundos), `planType: "free"` e `rateLimitReachedType: "rate_limit_reached"`. O daemon lê isso depois do `initialize` e de novo a cada `account/rateLimits/updated` (que é esparso), e a janela de uso do app mostra o Codex com o nome e o plano, ao lado do Claude Code (`SystemStatus.agentUsage`). Janelas de 300 e 10080 minutos têm os nomes das do Claude Code; 43200 é `thirty_day` | feito | | `codex`: cerca por nome parecido | A cerca de pastas casava por texto: uma crew `Suite` fechava a pasta `Suite2\Ana` e a edição era negada sem cartão. Agora o nome da pasta tem que terminar ali (`Suite2` e `Suite.old` são outras pastas) | feito |