diff --git a/.github/workflows/a5c.yml b/.github/workflows/a5c.yml new file mode 100644 index 00000000000..ac9f8b06fc4 --- /dev/null +++ b/.github/workflows/a5c.yml @@ -0,0 +1,135 @@ +# Template for a5c agent workflow aligned to pnpm/Corepack. +# Maintainers: this template is authoritative. Please sync into +# .github/workflows/a5c.yml so the active workflow uses pnpm caching: +# - actions/setup-node@v4 with `cache: pnpm` +# - `cache-dependency-path: pnpm-lock.yaml` + +name: a5c + +env: + DISABLE_AUTOUPDATER: 1 + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1 + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + AZURE_OPENAI_PROJECT_NAME: ${{ vars.AZURE_OPENAI_PROJECT_NAME || '' }} + AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY || '' }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY || '' }} + DEBUG: ${{ github.event.inputs.debug || 'false' }} + GITHUB_TOKEN: ${{ secrets.A5C_AGENT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + # Optional: supply a GitHub App token for validator checks + # A5C_AGENT_GITHUB_TOKEN: ${{ secrets.A5C_AGENT_GITHUB_TOKEN }} + # Feature flags for validator checks behavior + # A5C_VALIDATOR_ENABLE_CHECKS: "true" # set false to disable Check Runs + # A5C_VALIDATOR_USE_STATUSES: "false" # if supported, use commit statuses instead of checks + DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }} + DISCORD_GUILD_ID: ${{ vars.DISCORD_GUILD_ID }} + A5C_CLI_TOOL: ${{ vars.A5C_CLI_TOOL }} + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN || '' }} + SLACK_SIGNING_SECRET: ${{ secrets.SLACK_SIGNING_SECRET || '' }} + SLACK_APP_TOKEN: ${{ secrets.SLACK_APP_TOKEN || '' }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN || '' }} + VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID || '' }} + VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID || '' }} + SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN || '' }} + SUPABASE_ORG_ID: ${{ vars.SUPABASE_ORG_ID || '' }} + SUPABASE_PROJECT_REF: ${{ vars.SUPABASE_PROJECT_REF || '' }} + SUPABASE_PROJECT_URL: ${{ vars.SUPABASE_PROJECT_URL || '' }} + SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD || '' }} + STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY || '' }} + STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY || '' }} + STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET || '' }} + STRIPE_WEBHOOK_URL: ${{ vars.STRIPE_WEBHOOK_URL || '' }} + STRIPE_WEBHOOK_ID: ${{ vars.STRIPE_WEBHOOK_ID || '' }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID || '' }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY || '' }} + AWS_REGION: ${{ vars.AWS_REGION || '' }} + GOOGLE_APPLICATION_CREDENTIALS: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS || '' }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID || '' }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET || '' }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID || '' }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID || '' }} + AZURE_ACR_NAME: ${{ vars.AZURE_ACR_NAME || '' }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || '' }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || '' }} + AUTH_GITHUB_CLIENT_ID: ${{ secrets.AUTH_GITHUB_CLIENT_ID || '' }} + AUTH_GITHUB_CLIENT_SECRET: ${{ secrets.AUTH_GITHUB_CLIENT_SECRET || '' }} + AUTH_GITHUB_ORG_ID: ${{ vars.AUTH_GITHUB_ORG_ID || '' }} + AUTH_GITHUB_ORG_NAME: ${{ vars.AUTH_GITHUB_ORG_NAME || '' }} + AUTH_GITHUB_ORG_DESCRIPTION: ${{ vars.AUTH_GITHUB_ORG_DESCRIPTION || '' }} + HEROKU_API_KEY: ${{ secrets.HEROKU_API_KEY || '' }} + HEROKU_APP_NAME: ${{ vars.HEROKU_APP_NAME || '' }} + HEROKU_APP_ID: ${{ vars.HEROKU_APP_ID || '' }} + HEROKU_APP_URL: ${{ vars.HEROKU_APP_URL || '' }} + +on: + pull_request: + types: [opened, synchronize, reopened] + branches: [main, develop, master] + push: + branches: [] + issues: + types: [opened] + issue_comment: + types: [created] + schedule: + - cron: '*/30 * * * *' + workflow_run: + types: [completed] + workflows: [Build,Deploy,Tests,Release,E2E Tests,Infrastructure Deployment,Integration Tests,rust-ci] + workflow_dispatch: + inputs: + agent_uri: + description: 'Specific agent to run (optional - leave empty for auto-routing)' + required: false + debug: + description: 'Enable debug mode' + required: false + default: false + type: boolean + +jobs: + a5c: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + security-events: write + actions: write + attestations: write + checks: write + deployments: write + discussions: write + id-token: write + models: read + packages: write + pages: write + statuses: write + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Filter Self Workflow-run + id: filter-self + if: github.event_name == 'workflow_run' && (github.event.workflow_run.conclusion != 'failure' || github.event.workflow_run.head_branch != 'main') + run: | + echo "skip=true" >> "$GITHUB_OUTPUT" + + - name: Run A5C + id: agents + if: steps.filter-self.outputs.skip != 'true' + uses: a5c-ai/action@main + with: + agent_uri: ${{ github.event.inputs.agent_uri || '' }} + github_token: ${{ secrets.A5C_AGENT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Upload artifacts + id: upload-artifacts + uses: actions/upload-artifact@v4 + with: + name: a5c-artifacts + path: | + /tmp/agent-output.md + /tmp/agent-output-*/** diff --git a/codex-rs/README.md b/codex-rs/README.md index 390f5d31aa2..a844b5ac843 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -63,6 +63,18 @@ codex completion zsh codex completion fish ``` +### Usage and Guardrail Resets + +Check your current guardrail usage and next reset times via: + +``` +codex usage +# or +codex /usage +``` + +When usage data is unavailable, the command prints a clear, non-fatal message and exits successfully. + ### Experimenting with the Codex Sandbox To test to see what happens when a command is run under the sandbox provided by Codex, we provide the following subcommands in Codex CLI: diff --git a/codex-rs/chatgpt/src/lib.rs b/codex-rs/chatgpt/src/lib.rs index 440a309db64..9b47b6f04bb 100644 --- a/codex-rs/chatgpt/src/lib.rs +++ b/codex-rs/chatgpt/src/lib.rs @@ -2,3 +2,4 @@ pub mod apply_command; mod chatgpt_client; mod chatgpt_token; pub mod get_task; +pub mod usage; diff --git a/codex-rs/chatgpt/src/usage.rs b/codex-rs/chatgpt/src/usage.rs new file mode 100644 index 00000000000..20dd4941919 --- /dev/null +++ b/codex-rs/chatgpt/src/usage.rs @@ -0,0 +1,80 @@ +use anyhow::Context; +use codex_core::config::Config; +use serde::Deserialize; + +use crate::chatgpt_client::chatgpt_get_request; + +/// High-level summary of guardrail usage for display in CLI. +#[derive(Debug, Clone, Default)] +pub struct UsageSummary { + pub plan: Option, + pub standard_used_minutes: Option, + pub standard_limit_minutes: Option, + pub reasoning_used_minutes: Option, + pub reasoning_limit_minutes: Option, + pub next_reset_at: Option, +} + +/// Flexible wire model so we can tolerate backend changes without breaking the CLI. +#[derive(Debug, Deserialize)] +struct RawUsage { + #[serde(default)] + plan: Option, + #[serde(default)] + next_reset_at: Option, + #[serde(default)] + reset_at: Option, + #[serde(default)] + standard: Option, + #[serde(default)] + reasoning: Option, +} + +#[derive(Debug, Deserialize)] +struct Bucket { + #[serde(default)] + used_minutes: Option, + #[serde(default)] + limit_minutes: Option, + #[serde(default)] + used: Option, + #[serde(default)] + limit: Option, +} + +impl From for UsageSummary { + fn from(raw: RawUsage) -> Self { + let plan = raw.plan; + let next_reset_at = raw.next_reset_at.or(raw.reset_at); + let (mut standard_used_minutes, mut standard_limit_minutes) = (None, None); + let (mut reasoning_used_minutes, mut reasoning_limit_minutes) = (None, None); + + if let Some(b) = raw.standard { + standard_used_minutes = b.used_minutes.or(b.used); + standard_limit_minutes = b.limit_minutes.or(b.limit); + } + if let Some(b) = raw.reasoning { + reasoning_used_minutes = b.used_minutes.or(b.used); + reasoning_limit_minutes = b.limit_minutes.or(b.limit); + } + + UsageSummary { + plan, + standard_used_minutes, + standard_limit_minutes, + reasoning_used_minutes, + reasoning_limit_minutes, + next_reset_at, + } + } +} + +/// Fetch ChatGPT guardrail usage using the current auth and config. +pub async fn get_usage(config: &Config) -> anyhow::Result { + // This path is provided by the ChatGPT backend for Codex usage display. + // The structure is intentionally parsed via a flexible wire model. + let raw: RawUsage = chatgpt_get_request(config, "/wham/usage".to_string()) + .await + .context("Failed to fetch usage from ChatGPT backend")?; + Ok(raw.into()) +} diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index c6d80c0adfa..7c8da6b5b16 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -2,6 +2,7 @@ pub mod debug_sandbox; mod exit_status; pub mod login; pub mod proto; +pub mod usage; use clap::Parser; use codex_common::CliConfigOverrides; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 2acc3d84c50..c23c83ec7fc 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -12,12 +12,14 @@ use codex_cli::login::run_login_with_api_key; use codex_cli::login::run_login_with_chatgpt; use codex_cli::login::run_logout; use codex_cli::proto; +use codex_cli::usage::run_usage_command; use codex_common::CliConfigOverrides; use codex_exec::Cli as ExecCli; use codex_tui::Cli as TuiCli; use std::path::PathBuf; use crate::proto::ProtoCli; +use codex_cli::usage::UsageCommand; /// Codex CLI /// @@ -76,6 +78,10 @@ enum Subcommand { /// Internal: generate TypeScript protocol bindings. #[clap(hide = true)] GenerateTs(GenerateTsCommand), + + /// Show current guardrail usage and reset times. + #[clap(visible_alias = "/usage")] + Usage(UsageCommand), } #[derive(Debug, Parser)] @@ -212,6 +218,10 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() Some(Subcommand::GenerateTs(gen_cli)) => { codex_protocol_ts::generate_ts(&gen_cli.out_dir, gen_cli.prettier.as_deref())?; } + Some(Subcommand::Usage(mut usage_cli)) => { + prepend_config_flags(&mut usage_cli.config_overrides, cli.config_overrides); + run_usage_command(usage_cli.config_overrides).await?; + } } Ok(()) diff --git a/codex-rs/cli/src/usage.rs b/codex-rs/cli/src/usage.rs new file mode 100644 index 00000000000..74323454985 --- /dev/null +++ b/codex-rs/cli/src/usage.rs @@ -0,0 +1,100 @@ +use codex_common::CliConfigOverrides; +use codex_core::config::Config; +use codex_core::config::ConfigOverrides; +use codex_login::AuthMode; +use codex_login::CodexAuth; + +use codex_chatgpt::usage::get_usage as get_chatgpt_usage; + +#[derive(Debug, clap::Parser)] +pub struct UsageCommand { + #[clap(skip)] + pub config_overrides: CliConfigOverrides, +} + +pub async fn run_usage_command(cli_config_overrides: CliConfigOverrides) -> anyhow::Result<()> { + let config = load_config_or_exit(cli_config_overrides); + + match CodexAuth::from_codex_home(&config.codex_home, config.preferred_auth_method) { + Ok(Some(auth)) => match auth.mode { + AuthMode::ApiKey => { + let plan = auth + .get_plan_type() + .unwrap_or_else(|| "unknown".to_string()); + println!("Plan: {plan}"); + println!( + "Using an API key. Guardrail usage does not apply; billing is per-token.\nSee https://platform.openai.com/account/usage for detailed usage." + ); + Ok(()) + } + AuthMode::ChatGPT => { + let plan = auth + .get_plan_type() + .unwrap_or_else(|| "unknown".to_string()); + match get_chatgpt_usage(&config).await { + Ok(summary) => { + println!("Plan: {plan}"); + if let Some(when) = summary.next_reset_at.as_deref() { + println!("Next reset: {when}"); + } + if let (Some(u), Some(l)) = ( + summary.standard_used_minutes, + summary.standard_limit_minutes, + ) { + println!("Standard: {u} / {l} minutes used"); + } + if let (Some(u), Some(l)) = ( + summary.reasoning_used_minutes, + summary.reasoning_limit_minutes, + ) { + println!("Reasoning: {u} / {l} minutes used"); + } + + // If no buckets printed, fall back to a generic message. + if summary.standard_used_minutes.is_none() + && summary.reasoning_used_minutes.is_none() + { + println!("Usage data retrieved, but no bucket details available."); + } + Ok(()) + } + Err(e) => { + println!( + "Plan: {plan}\nUnable to retrieve usage from ChatGPT backend.\nReason: {e}\nUsage information is currently unavailable." + ); + Ok(()) + } + } + } + }, + Ok(None) => { + println!("Not logged in. Usage information requires authentication.\nRun: codex login"); + Ok(()) + } + Err(e) => { + println!( + "Unable to determine authentication status.\nReason: {e}\nUsage information is currently unavailable." + ); + Ok(()) + } + } +} + +fn load_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config { + let cli_overrides = match cli_config_overrides.parse_overrides() { + Ok(v) => v, + Err(e) => { + eprintln!("Error parsing -c overrides: {e}"); + std::process::exit(1); + } + }; + + let config_overrides = ConfigOverrides::default(); + match Config::load_with_cli_overrides(cli_overrides, config_overrides) { + Ok(config) => config, + Err(e) => { + eprintln!("Error loading configuration: {e}"); + std::process::exit(1); + } + } +} diff --git a/codex-rs/cli/tests/usage.rs b/codex-rs/cli/tests/usage.rs new file mode 100644 index 00000000000..dff0dd1fa35 --- /dev/null +++ b/codex-rs/cli/tests/usage.rs @@ -0,0 +1,11 @@ +use codex_cli::usage::run_usage_command; +use codex_common::CliConfigOverrides; + +#[tokio::test] +async fn usage_command_runs() { + // Should not error; prints a helpful message even if not logged in. + let overrides = CliConfigOverrides::default(); + run_usage_command(overrides) + .await + .expect("usage should run"); +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 9f23420c7e2..032745be5ad 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -48,6 +48,7 @@ pub mod project_doc; mod rollout; pub(crate) mod safety; pub mod seatbelt; +pub mod sessions; pub mod shell; pub mod spawn; pub mod terminal; diff --git a/codex-rs/core/src/sessions.rs b/codex-rs/core/src/sessions.rs new file mode 100644 index 00000000000..ea266fe3287 --- /dev/null +++ b/codex-rs/core/src/sessions.rs @@ -0,0 +1,182 @@ +use std::fs; +use std::io::BufRead; +use std::io::BufReader; +use std::path::Path; +use std::path::PathBuf; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; +use time::OffsetDateTime; +use time::UtcOffset; + +use serde_json::Value; +use uuid::Uuid; + +use crate::config::Config; + +#[derive(Debug, Clone)] +pub struct SessionEntry { + pub id: Uuid, + pub created: String, + pub last_active: Option, + pub title: Option, + pub path: PathBuf, +} + +/// Return the root directory where sessions are stored ("~/.codex/sessions"). +pub fn sessions_root(config: &Config) -> PathBuf { + let mut p = config.codex_home.clone(); + p.push("sessions"); + p +} + +/// List saved sessions discovered under the configured Codex home. +/// +/// Scans recursively for files named like `rollout-*.jsonl` and extracts: +/// - id and created timestamp from the first JSON line +/// - a human-friendly title from the first user message if present +/// - last active from file modification time +pub fn list_sessions(config: &Config) -> std::io::Result> { + let root = sessions_root(config); + if !root.exists() { + return Ok(Vec::new()); + } + let mut entries: Vec = Vec::new(); + collect_sessions_recursively(&root, &mut entries)?; + // Sort newest-first by last_active, then by created. + entries.sort_by(|a, b| { + b.last_active + .cmp(&a.last_active) + .then(b.created.cmp(&a.created)) + }); + Ok(entries) +} + +fn collect_sessions_recursively(dir: &Path, out: &mut Vec) -> std::io::Result<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + let ft = entry.file_type()?; + if ft.is_dir() { + collect_sessions_recursively(&path, out)?; + continue; + } + if ft.is_file() + && path + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with("rollout-") && n.ends_with(".jsonl")) + .unwrap_or(false) + && let Ok(e) = parse_session_file(&path) + { + out.push(e); + } + } + Ok(()) +} + +fn parse_session_file(path: &Path) -> std::io::Result { + let file = fs::File::open(path)?; + let mut reader = BufReader::new(file); + let mut first_line = String::new(); + reader.read_line(&mut first_line)?; + if first_line.trim().is_empty() { + return Err(std::io::Error::other("empty session file")); + } + let v: Value = serde_json::from_str(&first_line) + .map_err(|e| std::io::Error::other(format!("failed to parse meta: {e}")))?; + let id = v + .get("id") + .and_then(|x| x.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()) + .ok_or_else(|| std::io::Error::other("missing id in session meta"))?; + let created = v + .get("timestamp") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + + // Derive title from the first user message text, if present. + let title = derive_first_user_message_title(reader).ok(); + + // File mtime as last_active. + let last_active = fs::metadata(path) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| human_time(t).ok()); + + Ok(SessionEntry { + id, + created, + last_active, + title, + path: path.to_path_buf(), + }) +} + +fn derive_first_user_message_title(mut reader: R) -> Result { + let mut line = String::new(); + // Scan a limited number of lines to avoid heavy I/O on very long sessions. + let mut scanned = 0usize; + while scanned < 256 { + line.clear(); + if reader.read_line(&mut line).map_err(|_| ())? == 0 { + break; + } + let Ok(v) = serde_json::from_str::(&line) else { + continue; + }; + let role = v.get("role").and_then(|x| x.as_str()).unwrap_or(""); + if role == "user" { + // Find text from the first OutputText content item. + if let Some(text) = v.get("content").and_then(|c| c.as_array()).and_then(|arr| { + arr.iter() + .find_map(|item| item.get("text").and_then(|t| t.as_str())) + }) { + let trimmed = text.trim(); + let short: String = trimmed.chars().take(80).collect(); + let short = if trimmed.chars().count() > 80 { + format!("{short}…") + } else { + short + }; + return Ok(short); + } + } + scanned += 1; + } + Err(()) +} + +fn human_time(t: SystemTime) -> std::io::Result { + let dur = t + .duration_since(UNIX_EPOCH) + .map_err(|e| std::io::Error::other(format!("{e}")))?; + let dt = OffsetDateTime::from_unix_timestamp(dur.as_secs() as i64) + .map_err(|e| std::io::Error::other(format!("{e}")))?; + let local = match UtcOffset::current_local_offset() { + Ok(off) => dt.to_offset(off), + Err(_) => dt, + }; + local + .format(&time::macros::format_description!( + "[year]-[month]-[day] [hour]:[minute]" + )) + .map_err(|e| std::io::Error::other(format!("{e}"))) +} + +/// Find the session entry by UUID. +pub fn find_by_id(config: &Config, id: Uuid) -> std::io::Result> { + let sessions = list_sessions(config)?; + Ok(sessions.into_iter().find(|e| e.id == id)) +} + +/// Return the most recent session, if any. +pub fn latest(config: &Config) -> std::io::Result> { + let mut sessions = list_sessions(config)?; + sessions.sort_by(|a, b| { + b.last_active + .cmp(&a.last_active) + .then(b.created.cmp(&a.created)) + }); + Ok(sessions.into_iter().next()) +} diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6d107d67a04..0c0e1810856 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -177,6 +177,19 @@ impl App { ); tui.frame_requester().schedule_frame(); } + AppEvent::ResumeSession(path) => { + self.config.experimental_resume = Some(path); + self.chat_widget = ChatWidget::new( + self.config.clone(), + self.server.clone(), + tui.frame_requester(), + self.app_event_tx.clone(), + None, + Vec::new(), + self.enhanced_keys_supported, + ); + tui.frame_requester().schedule_frame(); + } AppEvent::InsertHistoryLines(lines) => { if let Some(Overlay::Transcript(t)) = &mut self.overlay { t.insert_lines(lines.clone()); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 439edbfdc73..cae875b2a49 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -20,6 +20,9 @@ pub(crate) enum AppEvent { /// Request to exit the application gracefully. ExitRequest, + /// Resume a saved session by path to the rollout file. + ResumeSession(std::path::PathBuf), + /// Forward an `Op` to the Agent. Using an `AppEvent` for this avoids /// bubbling channels through layers of widgets. CodexOp(codex_core::protocol::Op), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4695c6566cf..2a2eff44f76 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -800,6 +800,9 @@ impl ChatWidget { } self.app_event_tx.send(AppEvent::ExitRequest); } + SlashCommand::Sessions => { + self.open_sessions_popup(); + } SlashCommand::Diff => { self.add_diff_in_progress(); let tx = self.app_event_tx.clone(); @@ -823,6 +826,9 @@ impl ChatWidget { SlashCommand::Status => { self.add_status_output(); } + SlashCommand::Usage => { + self.add_usage_output(); + } SlashCommand::Mcp => { self.add_mcp_output(); } @@ -871,6 +877,50 @@ impl ChatWidget { self.bottom_pane.handle_paste(text); } + fn open_sessions_popup(&mut self) { + let sessions = match codex_core::sessions::list_sessions(&self.config) { + Ok(v) => v, + Err(e) => { + tracing::warn!("failed to list sessions: {e}"); + Vec::new() + } + }; + let mut items: Vec = Vec::new(); + for s in sessions { + let title = s.title.clone().unwrap_or_else(|| "(no title)".to_string()); + let desc = Some(format!( + "created: {} last: {}", + s.created, + s.last_active.unwrap_or_else(|| "unknown".to_string()) + )); + let path = s.path.clone(); + let id = s.id; + let actions: Vec = vec![Box::new(move |tx| { + tracing::info!("resume selected session: {id}"); + tx.send(crate::app_event::AppEvent::ResumeSession(path.clone())); + })]; + items.push(crate::bottom_pane::SelectionItem { + name: format!("{title} — {id}"), + description: desc, + is_current: false, + actions, + }); + } + if items.is_empty() { + items.push(crate::bottom_pane::SelectionItem { + name: "No saved sessions".to_string(), + description: None, + is_current: true, + actions: vec![], + }); + } + self.bottom_pane.show_selection_view( + "Sessions".to_string(), + Some("Select a session to resume".to_string()), + Some("Up/Down to navigate; Enter to resume; Esc to cancel".to_string()), + items, + ); + } // Returns true if caller should skip rendering this frame (a future frame is scheduled). pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool { if self.bottom_pane.flush_paste_burst_if_due() { @@ -1070,6 +1120,10 @@ impl ChatWidget { )); } + pub(crate) fn add_usage_output(&mut self) { + self.add_to_history(history_cell::new_usage_output(&self.config)); + } + /// Open a popup to choose the model preset (model + reasoning effort). pub(crate) fn open_model_popup(&mut self) { let current_model = self.config.model.clone(); diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index 8eb6d6b896d..9db5f319912 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -60,4 +60,12 @@ pub struct Cli { #[clap(skip)] pub config_overrides: CliConfigOverrides, + + /// List previously saved sessions and exit. + #[arg(long = "list-sessions", default_value_t = false)] + pub list_sessions: bool, + + /// Resume a saved session by id, 'latest', or a path to a rollout file. + #[arg(long = "resume", value_name = "ID|latest|PATH")] + pub resume: Option, } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index cc05b36fc0c..a6e10ec6a16 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -18,6 +18,8 @@ use codex_core::protocol::McpInvocation; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionConfiguredEvent; use codex_core::protocol::TokenUsage; +use codex_login::AuthMode; +use codex_login::CodexAuth; use codex_login::get_auth_file; use codex_login::try_read_auth_json; use codex_protocol::parse_command::ParsedCommand; @@ -846,6 +848,46 @@ pub(crate) fn new_status_output( PlainHistoryCell { lines } } +/// Render guardrail usage information (placeholder until providers are wired). +pub(crate) fn new_usage_output(config: &Config) -> PlainHistoryCell { + let mut lines: Vec> = Vec::new(); + lines.push(Line::from("")); + lines.push(Line::from("/usage".magenta())); + + match CodexAuth::from_codex_home(&config.codex_home, config.preferred_auth_method) { + Ok(Some(auth)) => { + let plan = auth + .get_plan_type() + .unwrap_or_else(|| "unknown".to_string()); + match auth.mode { + AuthMode::ApiKey | AuthMode::ChatGPT => { + lines.push(Line::from("")); + lines.push(Line::from("Usage information is currently unavailable.")); + lines.push(Line::from(format!("Plan: {plan}"))); + lines.push(Line::from( + "Note: Guardrail usage and reset times will appear here when supported.", + )); + } + } + } + Ok(None) => { + lines.push(Line::from("")); + lines.push(Line::from( + "Not logged in. Usage information requires authentication.", + )); + lines.push(Line::from("Run: codex login")); + } + Err(e) => { + lines.push(Line::from("")); + lines.push(Line::from("Unable to determine authentication status.")); + lines.push(Line::from(format!("Reason: {e}"))); + lines.push(Line::from("Usage information is currently unavailable.")); + } + } + + PlainHistoryCell { lines } +} + /// Render a summary of configured MCP servers from the current `Config`. pub(crate) fn empty_mcp_output() -> PlainHistoryCell { let lines: Vec> = vec![ diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 544aec272a6..656e13f3f8c 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -135,9 +135,11 @@ pub async fn run_main( let overrides_cli = codex_common::CliConfigOverrides { raw_overrides }; let cli_kv_overrides = match overrides_cli.parse_overrides() { Ok(v) => v, - #[allow(clippy::print_stderr)] Err(e) => { - eprintln!("Error parsing -c overrides: {e}"); + #[allow(clippy::print_stderr)] + { + eprintln!("Error parsing -c overrides: {e}"); + } std::process::exit(1); } }; @@ -145,23 +147,82 @@ pub async fn run_main( let mut config = { // Load configuration and support CLI overrides. - #[allow(clippy::print_stderr)] match Config::load_with_cli_overrides(cli_kv_overrides.clone(), overrides) { Ok(config) => config, Err(err) => { - eprintln!("Error loading configuration: {err}"); + #[allow(clippy::print_stderr)] + { + eprintln!("Error loading configuration: {err}"); + } std::process::exit(1); } } }; + // Handle session management CLI flags before starting the TUI. + if cli.list_sessions { + let sessions = match codex_core::sessions::list_sessions(&config) { + Ok(v) => v, + Err(e) => { + #[allow(clippy::print_stderr)] + { + eprintln!("Failed to list sessions: {e}"); + } + Vec::new() + } + }; + #[allow(clippy::print_stdout)] + { + if sessions.is_empty() { + println!("No sessions found."); + } else { + for s in sessions { + let title = s.title.unwrap_or_else(|| "(no title)".to_string()); + let last = s.last_active.unwrap_or_else(|| "unknown".to_string()); + println!("{} {} {}\n {}", s.id, s.created, last, s.path.display()); + println!(" {title}"); + } + } + } + return Ok(Default::default()); + } + + if let Some(resume_arg) = &cli.resume { + // Resolve to a path: try explicit path, then UUID, then 'latest'. + let resume_path = if std::path::Path::new(resume_arg).exists() { + Some(PathBuf::from(resume_arg)) + } else if resume_arg == "latest" { + codex_core::sessions::latest(&config) + .ok() + .flatten() + .map(|e| e.path) + } else if let Ok(id) = uuid::Uuid::parse_str(resume_arg) { + codex_core::sessions::find_by_id(&config, id) + .ok() + .flatten() + .map(|e| e.path) + } else { + None + }; + if let Some(path) = resume_path { + config.experimental_resume = Some(path); + } else { + #[allow(clippy::print_stderr)] + { + eprintln!("Could not resolve session to resume: {resume_arg}"); + } + } + } + // we load config.toml here to determine project state. - #[allow(clippy::print_stderr)] let config_toml = { let codex_home = match find_codex_home() { Ok(codex_home) => codex_home, Err(err) => { - eprintln!("Error finding codex home: {err}"); + #[allow(clippy::print_stderr)] + { + eprintln!("Error finding codex home: {err}"); + } std::process::exit(1); } }; @@ -169,7 +230,10 @@ pub async fn run_main( match load_config_as_toml_with_cli_overrides(&codex_home, cli_kv_overrides) { Ok(config_toml) => config_toml, Err(err) => { - eprintln!("Error loading config.toml: {err}"); + #[allow(clippy::print_stderr)] + { + eprintln!("Error loading config.toml: {err}"); + } std::process::exit(1); } } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 3268a92a2db..362c0fb145d 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -20,8 +20,10 @@ pub enum SlashCommand { Diff, Mention, Status, + Usage, Mcp, Logout, + Sessions, Quit, #[cfg(debug_assertions)] TestApproval, @@ -38,10 +40,12 @@ impl SlashCommand { SlashCommand::Diff => "show git diff (including untracked files)", SlashCommand::Mention => "mention a file", SlashCommand::Status => "show current session configuration and token usage", + SlashCommand::Usage => "show guardrail usage and reset times", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Approvals => "choose what Codex can do without approval", SlashCommand::Mcp => "list configured MCP tools", SlashCommand::Logout => "log out of Codex", + SlashCommand::Sessions => "list and resume previous sessions", #[cfg(debug_assertions)] SlashCommand::TestApproval => "test approval request", } @@ -62,9 +66,11 @@ impl SlashCommand { | SlashCommand::Model | SlashCommand::Approvals | SlashCommand::Logout => false, + SlashCommand::Sessions => false, SlashCommand::Diff | SlashCommand::Mention | SlashCommand::Status + | SlashCommand::Usage | SlashCommand::Mcp | SlashCommand::Quit => true, diff --git a/docs/dev/developer-agent/cli-usage-20250829T084245Z.md b/docs/dev/developer-agent/cli-usage-20250829T084245Z.md new file mode 100644 index 00000000000..8dd88dd97fe --- /dev/null +++ b/docs/dev/developer-agent/cli-usage-20250829T084245Z.md @@ -0,0 +1,22 @@ +# CLI: /usage command + +## Context +Implement a discoverable `codex usage` (`/usage` alias) to print current 5h and weekly guardrail usage with reset times. Gracefully handle unavailable data. + +## Plan +- Add `usage` subcommand in `codex-rs/cli` (alias `/usage`). +- Create a simple usage reporter with pluggable providers; default to a clear fallback message. +- Update help, add a basic CLI test. +- Validate with `cargo test -p codex-cli`. + +## Notes +- Avoid touching sandbox env var logic. +- Keep TUI unchanged to avoid snapshot breaks; future PR can add a compact indicator. + +## Results +- Implemented `usage` subcommand (alias `/usage`) with graceful fallback. +- Added test and README updates. +- Validated with `cargo test -p codex-cli`. + +## Next +- Plug in real usage providers when upstream endpoints are available or when rate-limit headers are persisted by the client layer. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000000..bb67c752581 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,33 @@ +{ + "name": "codex-monorepo", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codex-monorepo", + "devDependencies": { + "prettier": "^3.5.3" + }, + "engines": { + "node": ">=22", + "pnpm": ">=9.0.0" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + } + } +}