From 94d1834cc3e7081a0d00e4e3431322b3d09197e6 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 30 Jun 2026 06:43:50 +0000 Subject: [PATCH 1/9] feat(cli-mode): generic multi-agent CLI launch + codex support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalize the tmux-backed CLI pane beyond claude so any agent can run its interactive TUI in the main workspace, reusing the same per-agent settings as headless mode (without touching the managed/UIX executors). - Add CliLaunchSpec (program/base_args/resume/prompt/continue forms) + an interactive_cli_spec() trait method; agents opt in, None falls back to claude. - Implement it for ClaudeCode (refactor of the old hardcoded launch — no behavior change) and Codex (-m model, -c model_reasoning_effort, -s sandbox, -a approval, --no-alt-screen, 'codex resume '). - Make cli_bootstrap/PtyCommand::TmuxCli agent-agnostic; resolve the agent from Session.executor in terminal.rs; honor the selected executor in start_workspace_cli instead of hardcoding claude. - Pre-seed codex local-env friction (per-folder trust + the blocking 'Update available' startup modal) so an unattended launch reaches the TUI. Codex CLI mode verified live in a tmux pane (YOLO autonomy + model + worktree). --- Cargo.lock | 1 + crates/executors/src/executors/claude.rs | 68 +- crates/executors/src/executors/cli.rs | 106 +++ crates/executors/src/executors/codex.rs | 129 ++++ crates/executors/src/executors/mod.rs | 15 + crates/local-deployment/Cargo.toml | 1 + crates/local-deployment/src/pty.rs | 648 +++++++++++++----- crates/server/src/routes/terminal.rs | 57 +- crates/services/src/services/container.rs | 4 +- ...0-cli-agents-and-loop-automation-design.md | 150 ++++ 10 files changed, 998 insertions(+), 181 deletions(-) create mode 100644 crates/executors/src/executors/cli.rs create mode 100644 docs/superpowers/specs/2026-06-30-cli-agents-and-loop-automation-design.md diff --git a/Cargo.lock b/Cargo.lock index 265876e3fc5..f4564e944b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4990,6 +4990,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-util", + "toml 0.8.2", "tracing", "trusted-key-auth", "utils", diff --git a/crates/executors/src/executors/claude.rs b/crates/executors/src/executors/claude.rs index 807196cd9bf..2fb0bd12d4c 100644 --- a/crates/executors/src/executors/claude.rs +++ b/crates/executors/src/executors/claude.rs @@ -39,7 +39,10 @@ use crate::{ env::ExecutionEnv, executors::{ AppendPrompt, AvailabilityInfo, BaseCodingAgent, ExecutorError, SpawnedChild, - StandardCodingAgentExecutor, codex::client::LogWriter, utils::reorder_slash_commands, + StandardCodingAgentExecutor, + cli::{CliContinue, CliLaunchSpec, CliPromptArg, CliResume}, + codex::client::LogWriter, + utils::reorder_slash_commands, }, logs::{ ActionType, AnsweredQuestion, AskUserQuestionItem, AskUserQuestionOption, FileChange, @@ -457,6 +460,50 @@ mod cli_launch_tests { v(&["--model", "claude-opus-4-8", "--effort", "max"]) ); } + + use std::path::Path; + + use super::ClaudeCode; + use crate::executors::{ + StandardCodingAgentExecutor, + cli::{CliContinue, CliPromptArg, CliResume}, + }; + + fn claude_from(json: serde_json::Value) -> ClaudeCode { + serde_json::from_value(json).unwrap() + } + + #[test] + fn interactive_spec_carries_model_effort_and_skips_permissions() { + let claude = claude_from(serde_json::json!({ "model": "sonnet", "effort": "high" })); + let spec = claude.interactive_cli_spec(Path::new("/tmp")).unwrap(); + assert_eq!(spec.program, "claude"); + assert!( + spec.base_args + .windows(2) + .any(|w| w == ["--model", "sonnet"]) + ); + assert!(spec.base_args.windows(2).any(|w| w == ["--effort", "high"])); + assert!( + spec.base_args + .iter() + .any(|a| a == "--dangerously-skip-permissions") + ); + assert_eq!(spec.resume, CliResume::Flag("--resume".to_string())); + assert_eq!(spec.prompt_arg, CliPromptArg::Positional); + assert_eq!( + spec.continue_fallback, + CliContinue::Flag("--continue".to_string()) + ); + } + + #[test] + fn interactive_spec_defaults_to_opus_max() { + let claude = claude_from(serde_json::json!({})); + let spec = claude.interactive_cli_spec(Path::new("/tmp")).unwrap(); + assert!(spec.base_args.windows(2).any(|w| w == ["--model", "opus"])); + assert!(spec.base_args.windows(2).any(|w| w == ["--effort", "max"])); + } } fn default_discovered_options() -> crate::executor_discovery::ExecutorDiscoveredOptions { @@ -541,6 +588,25 @@ impl StandardCodingAgentExecutor for ClaudeCode { self.approvals_service = Some(approvals); } + fn interactive_cli_spec(&self, _cwd: &Path) -> Option { + // `--model`/`--effort` mirror headless mode; unset falls back to Opus at + // max effort, and effort is omitted for models without effort support. + let mut base_args = interactive_cli_args( + self.model.as_deref(), + self.effort.as_ref().map(AsRef::as_ref), + ); + // CLI mode skips per-tool approval for the app-created (trusted) + // worktree, matching the historical hardcoded launch. The separate + // one-time folder-trust dialog is pre-accepted in the deployment layer. + base_args.push("--dangerously-skip-permissions".to_string()); + Some( + CliLaunchSpec::new("claude", base_args) + .with_resume(CliResume::Flag("--resume".to_string())) + .with_prompt_arg(CliPromptArg::Positional) + .with_continue(CliContinue::Flag("--continue".to_string())), + ) + } + async fn spawn( &self, current_dir: &Path, diff --git a/crates/executors/src/executors/cli.rs b/crates/executors/src/executors/cli.rs new file mode 100644 index 00000000000..8a3bc9443d1 --- /dev/null +++ b/crates/executors/src/executors/cli.rs @@ -0,0 +1,106 @@ +//! Interactive-CLI launch specs. +//! +//! "CLI mode" runs a coding agent's own interactive TUI inside a persistent +//! tmux pane (the main workspace surface) instead of the headless executor. +//! Each agent describes how to launch its TUI — binary, flags, how to resume a +//! prior session, how to pass an initial prompt — via +//! [`StandardCodingAgentExecutor::interactive_cli_spec`], and the tmux +//! bootstrap (`local-deployment::pty`) turns that into the shell command it +//! runs. Keeping the recipe agent-owned (built from the agent's already +//! overridden config) is what lets every agent reuse the same managed-mode +//! settings (model / reasoning effort / sandbox / approval) in CLI mode. +//! +//! [`StandardCodingAgentExecutor::interactive_cli_spec`]: super::StandardCodingAgentExecutor::interactive_cli_spec + +/// How an agent's interactive CLI resumes a prior session given its id. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CliResume { + /// ` ` — the id is appended as the value of + /// a top-level flag (e.g. claude `--resume `). The `base_args` + /// (model/effort/…) still apply. + Flag(String), + /// ` ` — resume is a subcommand that restores the + /// session's own settings, so `base_args` are NOT replayed (e.g. codex + /// `resume `). + Subcommand(String), + /// Resuming by id isn't supported; a fresh session is started instead. + Unsupported, +} + +/// How an agent's interactive CLI accepts the initial prompt at launch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CliPromptArg { + /// Trailing positional argument: ` ''`. + Positional, + /// First message passed as the value of a flag: ` + /// ''` (e.g. copilot `-i ''`). + Flag(String), + /// No command-line way to seed the first prompt; it must be delivered after + /// launch (e.g. via tmux send-keys). The bootstrap starts a bare TUI. + Unsupported, +} + +/// How to launch the agent's TUI when there's nothing explicit to resume and no +/// initial prompt — i.e. reconnecting to a workspace whose tmux session died. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CliContinue { + /// ` || ` — continue the most recent conversation in this + /// cwd, falling back to a fresh TUI (e.g. claude `--continue`). + Flag(String), + /// ` --last || ` — resume the most recent + /// session via a subcommand, falling back to fresh (e.g. codex `resume + /// --last`). + ResumeLast { subcommand: String }, + /// Always start a fresh TUI. + Fresh, +} + +/// A fully-resolved recipe for launching one agent's interactive CLI in a tmux +/// pane. Built by [`StandardCodingAgentExecutor::interactive_cli_spec`] from the +/// agent's already-overridden config; consumed by the tmux bootstrap. +/// +/// [`StandardCodingAgentExecutor::interactive_cli_spec`]: super::StandardCodingAgentExecutor::interactive_cli_spec +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CliLaunchSpec { + /// Binary the bootstrap gates on (`command -v `) and exec's. Must + /// be a bare command name (validated by the bootstrap before use). + pub program: String, + /// Flags applied to every launch form except the resume-by-subcommand form + /// (model / reasoning effort / sandbox / approval / autonomy / cwd …). + /// Returned as discrete argv entries; the bootstrap shell-quotes each. + pub base_args: Vec, + /// How to resume a prior session by id (handover from the chat UI). + pub resume: CliResume, + /// How the workspace's initial prompt is delivered (CLI-first creation). + pub prompt_arg: CliPromptArg, + /// The no-id / no-prompt fallback (a workspace whose tmux session died). + pub continue_fallback: CliContinue, +} + +impl CliLaunchSpec { + /// Convenience constructor for the common positional-prompt agent shape. + pub fn new(program: impl Into, base_args: Vec) -> Self { + Self { + program: program.into(), + base_args, + resume: CliResume::Unsupported, + prompt_arg: CliPromptArg::Positional, + continue_fallback: CliContinue::Fresh, + } + } + + pub fn with_resume(mut self, resume: CliResume) -> Self { + self.resume = resume; + self + } + + pub fn with_prompt_arg(mut self, prompt_arg: CliPromptArg) -> Self { + self.prompt_arg = prompt_arg; + self + } + + pub fn with_continue(mut self, continue_fallback: CliContinue) -> Self { + self.continue_fallback = continue_fallback; + self + } +} diff --git a/crates/executors/src/executors/codex.rs b/crates/executors/src/executors/codex.rs index db8c5ea7cf5..14405fe8259 100644 --- a/crates/executors/src/executors/codex.rs +++ b/crates/executors/src/executors/codex.rs @@ -75,6 +75,7 @@ use crate::{ executors::{ AppendPrompt, AvailabilityInfo, BaseCodingAgent, ExecutorError, ExecutorExitResult, SlashCommandDescription, SpawnedChild, StandardCodingAgentExecutor, + cli::{CliContinue, CliLaunchSpec, CliPromptArg, CliResume}, }, logs::utils::patch, model_selector::{ModelInfo, ModelSelectorConfig, PermissionPolicy, ReasoningOption}, @@ -224,6 +225,63 @@ impl StandardCodingAgentExecutor for Codex { self.approvals = Some(approvals); } + fn interactive_cli_spec(&self, _cwd: &Path) -> Option { + let mut args: Vec = Vec::new(); + + // Model: strip the `-fast` suffix (the fast service tier isn't + // expressible as an interactive flag; the base model still applies). + let (model, _fast) = resolve_model(self.model.as_deref()); + if let Some(model) = model { + args.push("-m".to_string()); + args.push(model.to_string()); + } + + // Reasoning effort via a config override; codex parses the value as + // TOML and falls back to a literal string, so a bare word is fine. + if let Some(effort) = &self.model_reasoning_effort { + args.push("-c".to_string()); + args.push(format!("model_reasoning_effort={}", effort.as_ref())); + } + + // Sandbox: default to the managed default (danger-full-access) so an + // unattended loop isn't blocked by a restrictive sandbox. `-s` values + // match the kebab serde reprs except we map Auto onto the default. + let sandbox = match self.sandbox { + Some(SandboxMode::ReadOnly) => "read-only", + Some(SandboxMode::WorkspaceWrite) => "workspace-write", + Some(SandboxMode::DangerFullAccess) | Some(SandboxMode::Auto) | None => { + "danger-full-access" + } + }; + args.push("-s".to_string()); + args.push(sandbox.to_string()); + + // Approval: default to `never` (autonomous) for the loop; Plan / + // Supervised still flow in through `apply_overrides`. Note the CLI value + // for `UnlessTrusted` is `untrusted`, not the kebab serde repr. + let approval = match self.ask_for_approval { + Some(AskForApproval::UnlessTrusted) => "untrusted", + Some(AskForApproval::OnFailure) => "on-failure", + Some(AskForApproval::OnRequest) => "on-request", + Some(AskForApproval::Never) | None => "never", + }; + args.push("-a".to_string()); + args.push(approval.to_string()); + + // Inline mode keeps the TUI in the normal scrollback buffer so the tmux + // pane (and the loop-automation capture-pane poll) stays readable. + args.push("--no-alt-screen".to_string()); + + Some( + CliLaunchSpec::new("codex", args) + .with_resume(CliResume::Subcommand("resume".to_string())) + .with_prompt_arg(CliPromptArg::Positional) + .with_continue(CliContinue::ResumeLast { + subcommand: "resume".to_string(), + }), + ) + } + async fn spawn( &self, current_dir: &Path, @@ -780,4 +838,75 @@ mod tests { ); assert_eq!(resolve_model(None), (None, false)); } + + use std::path::Path; + + use super::Codex; + use crate::executors::{ + StandardCodingAgentExecutor, + cli::{CliContinue, CliPromptArg, CliResume}, + }; + + fn codex_from(json: serde_json::Value) -> Codex { + serde_json::from_value(json).unwrap() + } + + fn has_pair(args: &[String], a: &str, b: &str) -> bool { + args.windows(2).any(|w| w[0] == a && w[1] == b) + } + + #[test] + fn interactive_spec_maps_settings_to_flags() { + let codex = codex_from(serde_json::json!({ + "model": "gpt-5.5", + "model_reasoning_effort": "high", + "sandbox": "danger-full-access", + "ask_for_approval": "never", + })); + let spec = codex.interactive_cli_spec(Path::new("/tmp")).unwrap(); + assert_eq!(spec.program, "codex"); + assert!(has_pair(&spec.base_args, "-m", "gpt-5.5")); + assert!( + spec.base_args + .iter() + .any(|a| a == "model_reasoning_effort=high") + ); + assert!(has_pair(&spec.base_args, "-s", "danger-full-access")); + assert!(has_pair(&spec.base_args, "-a", "never")); + assert!(spec.base_args.iter().any(|a| a == "--no-alt-screen")); + assert_eq!(spec.resume, CliResume::Subcommand("resume".to_string())); + assert_eq!(spec.prompt_arg, CliPromptArg::Positional); + assert!(matches!( + spec.continue_fallback, + CliContinue::ResumeLast { .. } + )); + } + + #[test] + fn interactive_spec_defaults_to_autonomous_full_access() { + let spec = codex_from(serde_json::json!({})) + .interactive_cli_spec(Path::new("/tmp")) + .unwrap(); + // No model selected -> no -m; sandbox/approval default to the loop-safe + // autonomous pair. + assert!(!spec.base_args.iter().any(|a| a == "-m")); + assert!(has_pair(&spec.base_args, "-s", "danger-full-access")); + assert!(has_pair(&spec.base_args, "-a", "never")); + } + + #[test] + fn interactive_spec_strips_fast_suffix() { + let spec = codex_from(serde_json::json!({ "model": "gpt-5.5-fast" })) + .interactive_cli_spec(Path::new("/tmp")) + .unwrap(); + assert!(has_pair(&spec.base_args, "-m", "gpt-5.5")); + } + + #[test] + fn interactive_spec_maps_unless_trusted_to_untrusted_cli_value() { + let spec = codex_from(serde_json::json!({ "ask_for_approval": "unless-trusted" })) + .interactive_cli_spec(Path::new("/tmp")) + .unwrap(); + assert!(has_pair(&spec.base_args, "-a", "untrusted")); + } } diff --git a/crates/executors/src/executors/mod.rs b/crates/executors/src/executors/mod.rs index 1126d091799..56ccb2af277 100644 --- a/crates/executors/src/executors/mod.rs +++ b/crates/executors/src/executors/mod.rs @@ -33,6 +33,7 @@ use crate::{ pub mod acp; pub mod amp; pub mod claude; +pub mod cli; pub mod codex; pub mod copilot; pub mod cursor; @@ -224,6 +225,20 @@ pub trait StandardCodingAgentExecutor { fn use_approvals(&mut self, _approvals: Arc) {} + /// Describe how to launch this agent's interactive CLI (TUI) in a tmux pane + /// for "CLI mode". Built from this agent's already-overridden config so the + /// CLI launch honors the same model / reasoning effort / sandbox / approval + /// selection as headless mode. Returns `None` for agents without interactive + /// CLI support (the default), in which case CLI mode falls back to claude. + /// + /// Pre-launch local-environment prep some agents need (pre-accepting a + /// per-folder trust dialog, seeding onboarding/theme/auth so the TUI doesn't + /// block) is handled in the deployment layer keyed off the spec's `program`, + /// keeping this a pure description of the launch command. + fn interactive_cli_spec(&self, _cwd: &Path) -> Option { + None + } + async fn spawn( &self, current_dir: &Path, diff --git a/crates/local-deployment/Cargo.toml b/crates/local-deployment/Cargo.toml index 867a32fd842..d0a4a1981ec 100644 --- a/crates/local-deployment/Cargo.toml +++ b/crates/local-deployment/Cargo.toml @@ -39,6 +39,7 @@ tokio = { workspace = true } globwalk = "0.9" portable-pty = "0.8" dirs = "5.0" +toml = "0.8" [build-dependencies] dotenv = "0.15" diff --git a/crates/local-deployment/src/pty.rs b/crates/local-deployment/src/pty.rs index 3f72b8e7d2e..847bf15c32a 100644 --- a/crates/local-deployment/src/pty.rs +++ b/crates/local-deployment/src/pty.rs @@ -9,6 +9,7 @@ use std::{ thread, }; +use executors::executors::cli::{CliContinue, CliLaunchSpec, CliPromptArg, CliResume}; use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem}; use thiserror::Error; use tokio::sync::mpsc; @@ -31,83 +32,125 @@ pub enum PtyCommand { /// mode joins the *exact* conversation the chat UI is showing, and /// follow-ups from either side share one transcript. resume_session_id: Option, - /// The workspace's initial prompt (CLI-first creation): handed to - /// interactive claude as its argument so the run happens visibly in + /// The workspace's initial prompt (CLI-first creation): handed to the + /// interactive agent as its argument so the run happens visibly in /// the terminal instead of a headless executor. Ignored when /// `resume_session_id` is set. initial_prompt: Option, - /// Profile-derived flags for the interactive `claude` launch - /// (currently `--model`/`--effort`), pre-resolved from the session's - /// selected ExecutorConfig. Empty falls back to claude's own defaults; - /// the resolver defaults a fresh CLI start to Opus at max effort. - agent_args: Vec, + /// How to launch the selected agent's interactive CLI — binary, flags + /// (model/effort/sandbox/approval pre-resolved from the session's + /// ExecutorConfig), and the resume/prompt/continue forms. Claude is the + /// default; codex and the other agents fill in their own spec. + spec: CliLaunchSpec, }, } /// Build the initial window command for a new CLI tmux session. Runs the -/// interactive `claude` TUI when installed, then drops to a shell instead of -/// ending the session (so a crashed/exited claude leaves a usable pane). -/// Ignored by `-A` attaches (only runs when the session is first created). +/// selected agent's interactive TUI when installed (`spec.program`), then drops +/// to a shell instead of ending the session (so a crashed/exited agent leaves a +/// usable pane). Ignored by `-A` attaches (only runs on first creation). /// -/// - `--resume ` (when `resume_session_id` is a valid UUID) joins claude's +/// The launch form is chosen from the agent-supplied [`CliLaunchSpec`]: +/// - resume by id (`resume_session_id`, a validated UUID) joins the agent's /// exact session — the same transcript the headless executor created/uses — -/// so the chat UI and CLI hand off the conversation in both directions. -/// - With nothing to resume, claude starts a FRESH TUI. Never `--continue`: -/// on a brand-new workspace it printed "No conversation found to continue" -/// and dumped the pane into a bare shell (the resume target is resolved -/// workspace-wide server-side, so a missing id really means there is no -/// conversation yet). -/// - `--dangerously-skip-permissions` skips per-tool approval prompts for this -/// trusted worktree. claude's one-time folder-trust dialog is separate and -/// has no flag/setting to suppress; we pre-accept it for the worktree in -/// `ensure_claude_folder_trusted` (the workspace is app-created, so trusted) -/// before this bootstrap runs. -/// - `agent_args` carries profile-derived flags (`--model`/`--effort`) resolved -/// from the session's selected ExecutorConfig, so CLI mode honors the same -/// model + reasoning effort as the headless executor. +/// so the chat UI and CLI hand off the conversation. The shape is per-agent: +/// a flag (`claude --resume `) or a subcommand (`codex resume `). +/// - with an `initial_prompt` (CLI-first creation) the workspace prompt is +/// delivered as a positional arg or a flag value, per the spec. +/// - otherwise the agent's continue-fallback runs (claude `--continue`, codex +/// `resume --last`, or a fresh TUI), so a workspace whose tmux session died +/// rejoins its conversation where possible. /// -/// TODO(profile-integration): model/effort now flow through `agent_args`, but a -/// full convergence on the executor profile system (`ExecutorProfileId`, -/// alternate agent CLIs) is still future work — keep new options flowing as -/// pre-resolved `agent_args` rather than bolting fields onto `PtyCommand`. +/// Agent flags (model / effort / sandbox / approval / autonomy) are pre-resolved +/// into `spec.base_args` from the session's selected `ExecutorConfig`, so CLI +/// mode honors the same selection as the headless executor. Per-folder trust / +/// onboarding dialogs the agent can't suppress are pre-accepted separately in +/// [`maybe_seed_cli_trust`] before this bootstrap runs. fn cli_bootstrap( + spec: &CliLaunchSpec, resume_session_id: Option<&str>, initial_prompt: Option<&str>, - agent_args: &[String], ) -> String { - // Profile-derived flags (model/effort) applied to EVERY launch form below, - // shell-quoted so a model id can never break out of the command. - let flags: String = agent_args + // The program is a bare binary name from our own code; quote it anyway so + // it can never be anything but a single command word. + let prog = shell_single_quote(&spec.program); + + // Agent flags (model/effort/sandbox/approval/autonomy) applied to every + // launch form except resume-by-subcommand, shell-quoted so a value can + // never break out of the command. + let flags: String = spec + .base_args .iter() .map(|arg| format!(" {}", shell_single_quote(arg))) .collect(); - let base = format!("claude{flags} --dangerously-skip-permissions"); + let base = format!("{prog}{flags}"); + + // Nothing explicit to run (a CLI-first workspace whose tmux session died): + // continue the most recent conversation in this cwd if the agent can, + // otherwise a fresh TUI. + let continue_launch = || match &spec.continue_fallback { + CliContinue::Flag(flag) => format!("{base} {flag} || {base}"), + CliContinue::ResumeLast { subcommand } => { + format!("{prog} {subcommand} --last || {prog}") + } + CliContinue::Fresh => base.clone(), + }; - // Only a strict UUID may be interpolated into the shell string. claude + // Only a strict UUID may be interpolated into the shell string. Agent // session ids are UUIDs, so this both validates intent and forecloses // shell injection via the id. let launch = if let Some(id) = resume_session_id.filter(|id| is_uuid(id)) { - format!("{base} --resume {id}") + match &spec.resume { + // ` --resume ` — flags still apply (claude). + CliResume::Flag(flag) => format!("{base} {flag} {id}"), + // ` resume ` — a resume subcommand restores the + // session's own settings, so the base flags are NOT replayed (codex). + CliResume::Subcommand(sub) => format!("{prog} {sub} {id}"), + CliResume::Unsupported => continue_launch(), + } } else if let Some(prompt) = initial_prompt.map(str::trim).filter(|p| !p.is_empty()) { - // CLI-first creation: the workspace prompt becomes claude's argument - // (single-quote escaped — the prompt is arbitrary user text). A - // leading space neutralizes prompts that start with '-' so they can - // never parse as flags. - let guarded = if prompt.starts_with('-') { - format!(" {prompt}") - } else { - prompt.to_string() - }; - format!("{base} {}", shell_single_quote(&guarded)) + // CLI-first creation: the workspace prompt is delivered to the agent. + match &spec.prompt_arg { + // Trailing positional arg (single-quote escaped — arbitrary user + // text). A leading space neutralizes prompts starting with '-' so + // they can never parse as flags. + CliPromptArg::Positional => { + let guarded = if prompt.starts_with('-') { + format!(" {prompt}") + } else { + prompt.to_string() + }; + format!("{base} {}", shell_single_quote(&guarded)) + } + // Prompt as a flag value (e.g. copilot `-i ''`); a leading + // '-' is harmless after the flag. + CliPromptArg::Flag(flag) => format!("{base} {flag} {}", shell_single_quote(prompt)), + // No CLI way to seed the prompt — start the TUI and rely on a + // post-launch keystroke delivery (loop automation / send-keys). + CliPromptArg::Unsupported => continue_launch(), + } } else { - // Nothing explicit to run: resume the most recent conversation in - // this cwd if one exists (a CLI-first workspace whose tmux session - // died), otherwise fall through to a fresh TUI — `--continue` exits - // non-zero when there is no conversation to continue. - format!("{base} --continue || {base}") + continue_launch() }; - format!(r#"command -v claude >/dev/null 2>&1 && {launch}; exec "${{SHELL:-/bin/sh}}""#) + format!(r#"command -v {prog} >/dev/null 2>&1 && {launch}; exec "${{SHELL:-/bin/sh}}""#) +} + +/// Pre-accept per-folder trust / first-run dialogs the selected agent's CLI +/// would otherwise block on, for this app-created (trusted) worktree. Keyed by +/// the launch program so each agent's local-environment friction is handled in +/// one place. Best-effort: any failure just means the dialog shows as before. +fn maybe_seed_cli_trust(program: &str, dir: &Path) { + match program { + "claude" => ensure_claude_folder_trusted(dir), + "codex" => { + ensure_codex_folder_trusted(dir); + ensure_codex_update_nag_dismissed(); + } + // copilot / cursor / gemini / qwen onboarding seeding is added + // alongside each agent's CLI support. + _ => {} + } } /// POSIX single-quote escaping: the only character that needs handling @@ -248,6 +291,208 @@ fn apply_trust_to_config(root: &mut serde_json::Value, project_keys: &[String]) changed } +/// `$CODEX_HOME/config.toml`, else `~/.codex/config.toml` — where codex records +/// per-project trust. +fn codex_config_path() -> Option { + match std::env::var("CODEX_HOME") { + Ok(home) if !home.trim().is_empty() => Some(PathBuf::from(home).join("config.toml")), + _ => dirs::home_dir().map(|home| home.join(".codex").join("config.toml")), + } +} + +/// Pre-accept codex's per-directory trust prompt for this app-created worktree +/// by marking it `trusted` in `~/.codex/config.toml`, so the interactive launch +/// never blocks on "Do you want to allow Codex to work in this folder?". +/// Per-process memoized; best-effort (a failure just means the prompt shows). +fn ensure_codex_folder_trusted(dir: &Path) { + static SEEDED: OnceLock>> = OnceLock::new(); + let seeded = SEEDED.get_or_init(|| Mutex::new(HashSet::new())); + + if seeded + .lock() + .unwrap_or_else(|p| p.into_inner()) + .contains(dir) + { + return; + } + + match seed_codex_trust(dir) { + Ok(()) => { + seeded + .lock() + .unwrap_or_else(|p| p.into_inner()) + .insert(dir.to_path_buf()); + } + Err(e) => { + tracing::debug!( + "Could not pre-trust {} in codex config.toml (trust prompt may show): {e}", + dir.display() + ); + } + } +} + +/// Append `[projects.""] trust_level = "trusted"` blocks for `dir` (and its +/// canonical form) to codex's `config.toml`, but only when absent. Non-destructive +/// by design: it never rewrites the user's existing settings — it appends — and +/// bails without writing if either the existing file or the result wouldn't parse +/// as TOML, so a malformed merge can never corrupt the user's codex config. +fn seed_codex_trust(dir: &Path) -> std::io::Result<()> { + static WRITE_LOCK: OnceLock> = OnceLock::new(); + let _guard = WRITE_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|p| p.into_inner()); + + let Some(config_path) = codex_config_path() else { + return Ok(()); + }; + + let existing = match std::fs::read_to_string(&config_path) { + Ok(contents) => contents, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(e) => return Err(e), + }; + + // Never touch a config we can't parse — preserve the user's settings. + if !existing.trim().is_empty() && toml::from_str::(&existing).is_err() { + return Ok(()); + } + + let additions = codex_trust_additions(&existing, dir); + if additions.is_empty() { + return Ok(()); + } + + let mut updated = existing; + if !updated.is_empty() && !updated.ends_with('\n') { + updated.push('\n'); + } + updated.push_str(&additions); + + // Final guard: only write if the merged document is valid TOML, so a stray + // duplicate table (e.g. codex stored the path in a different quoting) can + // never corrupt the config — worst case the trust prompt shows once. + if toml::from_str::(&updated).is_err() { + return Ok(()); + } + + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp_path = config_path.with_extension("toml.vk-trust-tmp"); + std::fs::write(&tmp_path, updated.as_bytes())?; + std::fs::rename(&tmp_path, &config_path)?; + Ok(()) +} + +/// Pure helper: the `[projects.""]` blocks to append for `dir` (and its +/// canonical form) that aren't already present in `existing`. Split out so the +/// "only add when absent" logic is unit-testable without touching real files. +fn codex_trust_additions(existing: &str, dir: &Path) -> String { + let mut keys = vec![dir.to_string_lossy().into_owned()]; + if let Ok(canonical) = dir.canonicalize() { + let canonical = canonical.to_string_lossy().into_owned(); + if !keys.contains(&canonical) { + keys.push(canonical); + } + } + + let mut additions = String::new(); + for key in keys { + // codex writes the table as `[projects.""]`; only append when that + // exact header is absent so we never create a duplicate table. + let header = format!("[projects.\"{key}\"]"); + if !existing.contains(&header) { + additions.push_str(&format!( + "\n[projects.\"{key}\"]\ntrust_level = \"trusted\"\n" + )); + } + } + additions +} + +/// Far-future sentinel written to codex's `version.json` `last_checked_at`, so +/// codex's throttled update check treats the cached result as fresh and never +/// re-checks (a re-check would re-surface the blocking "Update available" modal +/// with a newer version). +const CODEX_UPDATE_CHECK_FROZEN_AT: &str = "2099-01-01T00:00:00Z"; + +/// `$CODEX_HOME/version.json`, else `~/.codex/version.json` — codex's cached +/// update-check state. +fn codex_version_json_path() -> Option { + match std::env::var("CODEX_HOME") { + Ok(home) if !home.trim().is_empty() => Some(PathBuf::from(home).join("version.json")), + _ => dirs::home_dir().map(|home| home.join(".codex").join("version.json")), + } +} + +/// Defuse codex's blocking "Update available — 1. Update now / 2. Skip / 3. Skip +/// until next version · Press enter to continue" startup modal, which otherwise +/// stalls an unattended launch whenever the user's codex is behind latest +/// (verified live). We replicate codex's own "skip until next version" state in +/// `version.json`: mark the cached latest as dismissed and freeze the check +/// timestamp so codex won't re-check and re-prompt. Codex still shows a passive +/// one-line banner, but the TUI proceeds straight to the prompt. Per-process +/// memoized; best-effort (a failure just means the modal may show). +fn ensure_codex_update_nag_dismissed() { + static DONE: OnceLock<()> = OnceLock::new(); + DONE.get_or_init(|| { + if let Err(e) = seed_codex_update_dismissal() { + tracing::debug!("Could not pre-dismiss codex update modal: {e}"); + } + }); +} + +fn seed_codex_update_dismissal() -> std::io::Result<()> { + let Some(path) = codex_version_json_path() else { + return Ok(()); + }; + let contents = match std::fs::read_to_string(&path) { + Ok(c) => c, + // Nothing cached yet (codex never ran) — nothing to pre-dismiss. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e), + }; + let Ok(mut value) = serde_json::from_str::(&contents) else { + return Ok(()); + }; + let Some(obj) = value.as_object_mut() else { + return Ok(()); + }; + // No known latest -> there's no modal to pre-dismiss. + let Some(latest) = obj + .get("latest_version") + .and_then(|v| v.as_str()) + .map(str::to_owned) + else { + return Ok(()); + }; + + let already_dismissed = obj.get("dismissed_version").and_then(|v| v.as_str()) == Some(&latest); + let already_frozen = + obj.get("last_checked_at").and_then(|v| v.as_str()) == Some(CODEX_UPDATE_CHECK_FROZEN_AT); + if already_dismissed && already_frozen { + return Ok(()); + } + + obj.insert( + "dismissed_version".to_string(), + serde_json::Value::String(latest), + ); + obj.insert( + "last_checked_at".to_string(), + serde_json::Value::String(CODEX_UPDATE_CHECK_FROZEN_AT.to_string()), + ); + + let serialized = serde_json::to_vec(&value) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + let tmp_path = path.with_extension("json.vk-upd-tmp"); + std::fs::write(&tmp_path, &serialized)?; + std::fs::rename(&tmp_path, &path)?; + Ok(()) +} + /// Configuration for the embedded tmux server, written next to the app's /// other runtime assets and passed via `-f` so a fresh server never loads the /// user's personal `~/.tmux.conf` (prefix remaps, status styling — and most @@ -585,21 +830,25 @@ impl PtyService { // CLI mode rides tmux when present; otherwise (and for the // default side terminal) spawn the user's shell directly. - let (tmux_session, tmux_resume_id, tmux_initial_prompt, tmux_agent_args) = - match &command { - PtyCommand::TmuxCli { - session_name, - resume_session_id, - initial_prompt, - agent_args, - } if tmux_available() => ( - Some(session_name.clone()), - resume_session_id.clone(), - initial_prompt.clone(), - agent_args.clone(), - ), - _ => (None, None, None, Vec::new()), - }; + let (tmux_session, tmux_resume_id, tmux_initial_prompt, tmux_spec): ( + Option, + Option, + Option, + Option, + ) = match &command { + PtyCommand::TmuxCli { + session_name, + resume_session_id, + initial_prompt, + spec, + } if tmux_available() => ( + Some(session_name.clone()), + resume_session_id.clone(), + initial_prompt.clone(), + Some(spec.clone()), + ), + _ => (None, None, None, None), + }; // Never silently break the persistence promise: if CLI mode was // requested but tmux is absent, say so in the pane itself. @@ -615,84 +864,86 @@ impl PtyService { } } - let (mut cmd, shell_name) = if let Some(session_name) = &tmux_session { - // Bring an already-running server in line with our config - // (options are server-wide; `-f` below only affects a fresh - // server start). - ensure_cli_tmux_server_options(); - - // Pre-accept claude's per-directory folder-trust dialog for - // this app-created worktree so the launch never blocks on it. - ensure_claude_folder_trusted(&working_dir); - - let mut cmd = CommandBuilder::new("tmux"); - // Our own config instead of the user's ~/.tmux.conf — the - // embedded terminal needs deterministic mouse/clipboard - // behavior (see CLI_TMUX_CONF); the user's personal tmux on - // the default socket is unaffected. - if let Some(conf) = cli_tmux_conf_path() { - cmd.arg("-f"); - cmd.arg(conf); - } - // Dedicated socket isolates our sessions from the user's tmux. - cmd.arg("-L"); - cmd.arg(CLI_TMUX_SOCKET); - cmd.arg("new-session"); - // -A: attach if the session exists, else create. - // - // We deliberately do NOT pass -D (detach other clients): a new - // attach would detach the prior client, whose tmux process then - // exits → its PTY hits EOF → the WebSocket closes → the frontend - // reconnects → the new attach detaches it again, a self- - // sustaining reconnect loop that also resets the session to the - // attaching client's 80x24 default on every cycle. Without -D, - // reconnects simply attach; the prior client is cleaned up by - // close_session killing its PTY child. Two simultaneous browser - // windows would mirror (tmux sizes to the smaller) — a rare, - // benign trade vs. the loop. - cmd.arg("-A"); - cmd.arg("-s"); - cmd.arg(session_name); - cmd.arg("-c"); - cmd.arg(&working_dir); - cmd.arg(cli_bootstrap( - tmux_resume_id.as_deref(), - tmux_initial_prompt.as_deref(), - &tmux_agent_args, - )); - cmd.cwd(&working_dir); - // No shell-specific prompt configuration for the tmux client. - (cmd, String::new()) - } else { - let mut cmd = CommandBuilder::new(&shell); - cmd.cwd(&working_dir); - - // Configure shell-specific options - let shell_name = shell - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .to_string(); - - if shell_name == "powershell.exe" || shell_name == "pwsh.exe" { - // PowerShell: use -NoLogo for cleaner startup - cmd.arg("-NoLogo"); - } else if shell_name == "cmd.exe" { - // cmd.exe: no special args needed + let (mut cmd, shell_name) = + if let (Some(session_name), Some(spec)) = (&tmux_session, &tmux_spec) { + // Bring an already-running server in line with our config + // (options are server-wide; `-f` below only affects a fresh + // server start). + ensure_cli_tmux_server_options(); + + // Pre-accept the agent's per-directory folder-trust / first-run + // dialog for this app-created worktree so the launch never + // blocks on it. + maybe_seed_cli_trust(&spec.program, &working_dir); + + let mut cmd = CommandBuilder::new("tmux"); + // Our own config instead of the user's ~/.tmux.conf — the + // embedded terminal needs deterministic mouse/clipboard + // behavior (see CLI_TMUX_CONF); the user's personal tmux on + // the default socket is unaffected. + if let Some(conf) = cli_tmux_conf_path() { + cmd.arg("-f"); + cmd.arg(conf); + } + // Dedicated socket isolates our sessions from the user's tmux. + cmd.arg("-L"); + cmd.arg(CLI_TMUX_SOCKET); + cmd.arg("new-session"); + // -A: attach if the session exists, else create. + // + // We deliberately do NOT pass -D (detach other clients): a new + // attach would detach the prior client, whose tmux process then + // exits → its PTY hits EOF → the WebSocket closes → the frontend + // reconnects → the new attach detaches it again, a self- + // sustaining reconnect loop that also resets the session to the + // attaching client's 80x24 default on every cycle. Without -D, + // reconnects simply attach; the prior client is cleaned up by + // close_session killing its PTY child. Two simultaneous browser + // windows would mirror (tmux sizes to the smaller) — a rare, + // benign trade vs. the loop. + cmd.arg("-A"); + cmd.arg("-s"); + cmd.arg(session_name); + cmd.arg("-c"); + cmd.arg(&working_dir); + cmd.arg(cli_bootstrap( + spec, + tmux_resume_id.as_deref(), + tmux_initial_prompt.as_deref(), + )); + cmd.cwd(&working_dir); + // No shell-specific prompt configuration for the tmux client. + (cmd, String::new()) } else { - // Unix shells - cmd.env("VIBE_KANBAN_TERMINAL", "1"); - - if shell_name == "bash" { - cmd.env("PROMPT_COMMAND", r#"PS1='$ '; unset PROMPT_COMMAND"#); - } else if shell_name == "zsh" { - // PROMPT is set after spawning + let mut cmd = CommandBuilder::new(&shell); + cmd.cwd(&working_dir); + + // Configure shell-specific options + let shell_name = shell + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + + if shell_name == "powershell.exe" || shell_name == "pwsh.exe" { + // PowerShell: use -NoLogo for cleaner startup + cmd.arg("-NoLogo"); + } else if shell_name == "cmd.exe" { + // cmd.exe: no special args needed } else { - cmd.env("PS1", "$ "); + // Unix shells + cmd.env("VIBE_KANBAN_TERMINAL", "1"); + + if shell_name == "bash" { + cmd.env("PROMPT_COMMAND", r#"PS1='$ '; unset PROMPT_COMMAND"#); + } else if shell_name == "zsh" { + // PROMPT is set after spawning + } else { + cmd.env("PS1", "$ "); + } } - } - (cmd, shell_name) - }; + (cmd, shell_name) + }; cmd.env("TERM", "xterm-256color"); cmd.env("COLORTERM", "truecolor"); @@ -887,82 +1138,114 @@ mod tests { assert!(name.starts_with("vk_")); } + /// A claude-shaped spec (flag resume, positional prompt, `--continue` + /// fallback) — mirrors `ClaudeCode::interactive_cli_spec`. + fn claude_spec(base_args: &[&str]) -> CliLaunchSpec { + CliLaunchSpec::new("claude", base_args.iter().map(|s| s.to_string()).collect()) + .with_resume(CliResume::Flag("--resume".to_string())) + .with_prompt_arg(CliPromptArg::Positional) + .with_continue(CliContinue::Flag("--continue".to_string())) + } + + /// A codex-shaped spec (subcommand resume, positional prompt, `resume + /// --last` fallback) — mirrors `Codex::interactive_cli_spec`. + fn codex_spec(base_args: &[&str]) -> CliLaunchSpec { + CliLaunchSpec::new("codex", base_args.iter().map(|s| s.to_string()).collect()) + .with_resume(CliResume::Subcommand("resume".to_string())) + .with_prompt_arg(CliPromptArg::Positional) + .with_continue(CliContinue::ResumeLast { + subcommand: "resume".to_string(), + }) + } + #[test] - fn cli_bootstrap_runs_claude_then_drops_to_shell() { - let b = cli_bootstrap(None, None, &[]); - assert!(b.contains("command -v claude")); + fn cli_bootstrap_runs_program_then_drops_to_shell() { + let b = cli_bootstrap(&claude_spec(&[]), None, None); + assert!(b.contains("command -v 'claude'")); assert!( b.ends_with(r#"exec "${SHELL:-/bin/sh}""#), - "bootstrap must keep the pane alive after claude exits" + "bootstrap must keep the pane alive after the agent exits" ); } #[test] fn cli_bootstrap_resume_takes_precedence_and_rejects_non_uuids() { - // A valid claude session UUID -> --resume , even if a prompt is - // also present (an existing conversation always wins). + // A valid session UUID -> --resume , even if a prompt is also + // present (an existing conversation always wins). let id = "28b98f08-5f5f-4b1e-8c4e-41ae87c0c706"; - let b = cli_bootstrap(Some(id), Some("do things"), &[]); + let b = cli_bootstrap(&claude_spec(&[]), Some(id), Some("do things")); assert!(b.contains(&format!("--resume {id}"))); assert!(!b.contains("do things")); // Non-UUID (injection attempt) is rejected and never interpolated. let evil = "x; rm -rf ~"; - let b = cli_bootstrap(Some(evil), None, &[]); + let b = cli_bootstrap(&claude_spec(&[]), Some(evil), None); assert!(!b.contains("rm -rf")); assert!(!b.contains("--resume")); } + #[test] + fn cli_bootstrap_codex_resume_is_a_subcommand_without_base_flags() { + // codex resumes via a subcommand that restores the session's own + // settings, so the model/sandbox/approval flags are NOT replayed. + let id = "28b98f08-5f5f-4b1e-8c4e-41ae87c0c706"; + let spec = codex_spec(&["-m", "gpt-5.5", "-s", "danger-full-access"]); + let b = cli_bootstrap(&spec, Some(id), None); + assert!(b.contains(&format!("'codex' resume {id}"))); + assert!( + !b.contains("-m"), + "base flags must not ride the resume: {b}" + ); + // Continue fallback uses `resume --last`, falling back to a fresh TUI. + let cont = cli_bootstrap(&spec, None, None); + assert!(cont.contains("'codex' resume --last || 'codex'")); + } + #[test] fn cli_bootstrap_passes_initial_prompt_injection_safe() { - let b = cli_bootstrap(None, Some("Fix the login bug"), &[]); - assert!(b.contains("claude --dangerously-skip-permissions 'Fix the login bug'")); + let spec = claude_spec(&["--dangerously-skip-permissions"]); + let b = cli_bootstrap(&spec, None, Some("Fix the login bug")); + assert!(b.contains("'--dangerously-skip-permissions' 'Fix the login bug'")); // Quotes and shell metacharacters stay inert inside the quoting. let evil = "'; rm -rf ~; echo '"; - let b = cli_bootstrap(None, Some(evil), &[]); + let b = cli_bootstrap(&spec, None, Some(evil)); // The single quotes in the prompt are escaped as '\'' — the raw // sequence `'; rm` can therefore never terminate the quoting. assert!(b.contains(r"'"), "quotes must be escaped: {b}"); assert!(!b.contains("&& rm"), "injection must not escape: {b}"); - // A prompt starting with '-' is space-guarded so claude can't parse + // A prompt starting with '-' is space-guarded so the agent can't parse // it as a flag. - let dashy = cli_bootstrap(None, Some("-rf is a flag-looking prompt"), &[]); + let dashy = cli_bootstrap(&spec, None, Some("-rf is a flag-looking prompt")); assert!(dashy.contains("' -rf is a flag-looking prompt'")); // Blank prompts fall through to the no-prompt path. - let blank = cli_bootstrap(None, Some(" "), &[]); - assert!(blank.contains("--continue || claude")); + let blank = cli_bootstrap(&spec, None, Some(" ")); + assert!(blank.contains("--continue || 'claude'")); } #[test] fn cli_bootstrap_falls_back_to_continue_then_fresh() { - // With nothing explicit to run: resume the cwd's latest conversation + // With nothing explicit to run: continue the cwd's latest conversation // when one exists (CLI-first workspace after tmux death), else a // fresh TUI — never a stranded "No conversation found" pane. - let b = cli_bootstrap(None, None, &[]); + let b = cli_bootstrap( + &claude_spec(&["--dangerously-skip-permissions"]), + None, + None, + ); assert!(b.contains( - "claude --dangerously-skip-permissions --continue || claude --dangerously-skip-permissions" + "'claude' '--dangerously-skip-permissions' --continue || 'claude' '--dangerously-skip-permissions'" )); } - #[test] - fn cli_bootstrap_applies_model_and_effort_flags() { - let args = ["--model", "opus", "--effort", "max"].map(String::from); - let b = cli_bootstrap(None, None, &args); - assert!( - b.contains("claude '--model' 'opus' '--effort' 'max' --dangerously-skip-permissions") - ); - } - #[test] fn cli_bootstrap_shell_quotes_agent_args_on_every_form() { // Glob/metacharacters in a model id stay inert (single-quoted)... - let args = ["--model".to_string(), "opus[1m]".to_string()]; - let b = cli_bootstrap(None, None, &args); + let b = cli_bootstrap(&claude_spec(&["--model", "opus[1m]"]), None, None); assert!(b.contains("'--model' 'opus[1m]'")); // ...and the flags ride the continue/fresh fallback too. - assert!(b.contains("--dangerously-skip-permissions --continue")); + assert!(b.contains("'opus[1m]' --continue")); } #[test] @@ -987,6 +1270,27 @@ mod tests { assert!(!apply_trust_to_config(&mut root, &["/new/dir".to_string()])); } + #[test] + fn codex_trust_additions_appends_only_when_absent() { + let dir = std::path::Path::new("/var/tmp/wt/abc-fresh-xyz"); + // Empty config -> a trusted block for the dir is produced (the path + // appears, marked trusted). Canonicalize fails for a non-existent path, + // so only the given key is emitted. + let add = codex_trust_additions("", dir); + assert!(add.contains(r#"[projects."/var/tmp/wt/abc-fresh-xyz"]"#)); + assert!(add.contains(r#"trust_level = "trusted""#)); + // The merged result must be valid TOML. + assert!(toml::from_str::(&add).is_ok()); + + // Already-present header -> no duplicate table is appended (which would + // make codex's config invalid TOML). + let existing = format!( + "approval_policy = \"never\"\n\n[projects.\"{}\"]\ntrust_level = \"trusted\"\n", + dir.display() + ); + assert!(codex_trust_additions(&existing, dir).is_empty()); + } + #[test] fn shell_single_quote_escapes_quotes() { assert_eq!(shell_single_quote("plain"), "'plain'"); diff --git a/crates/server/src/routes/terminal.rs b/crates/server/src/routes/terminal.rs index 57cf6c5877e..750eea83eff 100644 --- a/crates/server/src/routes/terminal.rs +++ b/crates/server/src/routes/terminal.rs @@ -1,4 +1,7 @@ -use std::path::PathBuf; +use std::{ + path::{Path, PathBuf}, + str::FromStr, +}; use axum::{ Router, @@ -12,7 +15,11 @@ use db::models::{ workspace::Workspace, workspace_repo::WorkspaceRepo, }; use deployment::Deployment; -use executors::{actions::ExecutorActionType, executors::claude}; +use executors::{ + actions::ExecutorActionType, + executors::{BaseCodingAgent, StandardCodingAgentExecutor, cli::CliLaunchSpec}, + profile::{ExecutorConfig, ExecutorConfigs}, +}; use local_deployment::pty::{ PtyCommand, cli_tmux_available, cli_tmux_session_exists, cli_tmux_session_name, }; @@ -119,6 +126,43 @@ async fn resolve_cli_model_effort( (None, None) } +/// Resolve how to launch the workspace's selected agent in CLI mode. The agent +/// type comes from the session's `executor` (defaulting to claude); the picked +/// model/effort are folded in as overrides so the interactive launch honors the +/// same selection as headless mode. Agents without their own interactive CLI +/// support fall back to a default claude launch so CLI mode always works. +fn resolve_cli_launch_spec( + session: Option<&Session>, + model_id: Option, + reasoning_id: Option, + dir: &Path, +) -> CliLaunchSpec { + let executor = session + .and_then(|s| s.executor.as_deref()) + .and_then(|e| BaseCodingAgent::from_str(e).ok()) + .unwrap_or(BaseCodingAgent::ClaudeCode); + + let profiles = ExecutorConfigs::get_cached(); + + let mut config = ExecutorConfig::new(executor); + config.model_id = model_id; + config.reasoning_id = reasoning_id; + let mut agent = profiles.get_coding_agent_or_default(&config.profile_id()); + if config.has_overrides() { + agent.apply_overrides(&config); + } + + agent.interactive_cli_spec(dir).unwrap_or_else(|| { + // The selected agent has no interactive CLI support (yet) — fall back to + // a default claude launch so the CLI pane is never left without an agent. + let claude = ExecutorConfig::new(BaseCodingAgent::ClaudeCode); + profiles + .get_coding_agent_or_default(&claude.profile_id()) + .interactive_cli_spec(dir) + .expect("claude always provides an interactive CLI spec") + }) +} + async fn terminal_ws( ws: SignedWsUpgrade, State(deployment): State, @@ -259,11 +303,10 @@ async fn terminal_ws( prompt_session_to_clear = session.as_ref().map(|s| s.id); } - // Honor the workspace's selected model/effort at launch (defaults - // to Opus at max effort when nothing was selected). + // Honor the workspace's selected agent + model/effort at launch + // (defaults to claude at Opus/max when nothing was selected). let (model_id, reasoning_id) = resolve_cli_model_effort(pool, session.as_ref()).await; - let agent_args = - claude::interactive_cli_args(model_id.as_deref(), reasoning_id.as_deref()); + let spec = resolve_cli_launch_spec(session.as_ref(), model_id, reasoning_id, &dir); ( dir, @@ -271,7 +314,7 @@ async fn terminal_ws( session_name: cli_tmux_session_name(query.workspace_id), resume_session_id, initial_prompt, - agent_args, + spec, }, ) } diff --git a/crates/services/src/services/container.rs b/crates/services/src/services/container.rs index 01a55eb9b9d..757510d5a9b 100644 --- a/crates/services/src/services/container.rs +++ b/crates/services/src/services/container.rs @@ -1091,10 +1091,12 @@ pub trait ContainerService { .await? .ok_or(SqlxError::RowNotFound)?; + // Persist the workspace's selected agent so the CLI terminal launches + // that agent's interactive TUI (not always claude). let session = Session::create( &self.db().pool, &CreateSession { - executor: Some(executors::executors::BaseCodingAgent::ClaudeCode.to_string()), + executor: Some(executor_config.executor.to_string()), name: None, }, Uuid::new_v4(), diff --git a/docs/superpowers/specs/2026-06-30-cli-agents-and-loop-automation-design.md b/docs/superpowers/specs/2026-06-30-cli-agents-and-loop-automation-design.md new file mode 100644 index 00000000000..4f51c7bd1d8 --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-cli-agents-and-loop-automation-design.md @@ -0,0 +1,150 @@ +# CLI-mode multi-agent support + agentic-loop automation + +Date: 2026-06-30 · Branch: `mr/1ac7-agent-retry-schedule-cli` + +Two additive features for BetterCoding (the managed/UIX executors are **untouched**): + +1. **Loop automation** — keep agentic loops running when a *chat stops for a non-completion + reason* (usage limit / transient rate limit): detect the limit banner, schedule a + wake-up at the reset time (or retry every N min), and re-prompt the agent. +2. **Multi-agent CLI mode** — the persistent tmux-backed CLI pane currently only runs + `claude`. Generalize it so every supported agent (codex first, then gemini, opencode, + cursor, droid, amp, qwen, copilot) can run interactively in the pane, reusing the same + per-agent settings the managed UIX already exposes. + +## Approved decisions + +- Automation is **CLI-first** (tmux panes); headless/managed is a documented extension point. +- Detection is **auto**, but the loop is **opt-in per workspace, default OFF** (global default OFF). +- **All 8** agents via one generic abstraction; codex built + tested live now, the rest staged + with code + tests + an exact install/e2e plan awaiting approval before any install. +- **Autonomous by default** in CLI (worktrees are app-created/isolated): claude + `--dangerously-skip-permissions`, codex `--sandbox danger-full-access --ask-for-approval never`, + etc. Supervised/Plan still map through so it can be dialed back per workspace. +- Policy lives at the **workspace** level (1:1 with the tmux session) + a global default; + "per project" = enabling it across a project's workspaces (no `workspace→project` FK exists). + +## Key code facts (from exploration) + +- CLI launch is Claude-hardwired in 3 spots: `cli_bootstrap` literal `claude …` + (`crates/local-deployment/src/pty.rs:84`), `start_workspace_cli` hardcodes + `BaseCodingAgent::ClaudeCode` (`crates/services/src/services/container.rs:1097`), and + `terminal.rs` only calls `claude::interactive_cli_args` (`crates/server/src/routes/terminal.rs:265`). +- `PtyCommand::TmuxCli.agent_args: Vec` is already a generic seam (`pty.rs:27-44`); + there's a `TODO(profile-integration)` at `pty.rs:69`. +- The create flow already produces an `ExecutorConfig` for ANY agent + (`CreateChatBoxContainer.tsx`); it just isn't honored in the CLI pane. +- `StandardCodingAgentExecutor` trait + `#[enum_dispatch(CodingAgent)]` + (`crates/executors/src/executors/mod.rs:220`). Each `CodingAgent` variant wraps that agent's + config struct; `apply_overrides` already maps the unified `ExecutorConfig` + (`model_id`/`reasoning_id`/`permission_policy`) onto agent fields. +- Codex managed mode runs `codex app-server` (JSON-RPC) — the TUI is greenfield. Real codex 0.140 + surface (captured live): `codex [PROMPT]`, `codex resume [PROMPT] --last`, + `-m/--model`, `-s/--sandbox `, + `-a/--ask-for-approval `, `-C/--cd `, + `-c model_reasoning_effort="high"`, `--no-alt-screen` (inline mode → clean tmux scrollback). +- No scheduler exists; background loops are `tokio::spawn`+`interval`. Templates: reaper + (`crates/local-deployment/src/container.rs:409`, 30 min), PR monitor (`select!{interval, Notify}`, + `crates/services/src/services/pr_monitor.rs:86`), cli_activity (2 s, `crates/local-deployment/src/cli_activity.rs`). + Spawned from `LocalDeployment::new()` (`crates/local-deployment/src/lib.rs:265`). +- CLI pane output is **ephemeral** (PTY→WS, never persisted): detection needs a new + `tmux capture-pane -p` poll; live re-prompt needs a new `tmux send-keys` helper. Neither exists. +- Headless extension hooks: Claude parses `ClaudeJson::RateLimitEvent` but **drops** it + (`crates/executors/src/executors/claude.rs:2152`); `finalize_task` + (`crates/services/src/services/container.rs:238`) is the completion hook; retries reuse + `CodingAgentFollowUpRequest`. Codex has `account/rateLimits/read` (`codex/client.rs:293`). +- Session carries the agent + CLI selections: `Session.executor`, `pending_cli_prompt`, + `cli_model_id`, `cli_reasoning_id` (`crates/db/src/models/session.rs`). +- Notifications exist: `NotificationService.notify(title, msg, workspace_id)` + (`crates/services/src/services/notification.rs`). +- Types → TS: `crates/server/src/bin/generate_types.rs` → `shared/types.ts`; per-agent RJSF + schemas via `virtual:executor-schemas`. Frontend API client: `packages/web-core/src/shared/lib/api.ts`. + +## Part 2 — Multi-agent CLI mode + +### Abstraction (executors crate) +New types + trait methods (default = no CLI support, so agents opt in): +```rust +pub struct CliLaunchSpec { + pub program: String, // binary gated on `command -v` and exec'd + pub base_args: Vec, // model/effort/sandbox/approval/cwd/autonomy flags + pub resume: CliResume, // Flag("--resume") | Subcommand("resume") | Unsupported + pub prompt_arg: CliPromptArg, // Positional | PositionalAfterResume | Unsupported + pub extra_env: Vec<(String,String)>, // e.g. skip-onboarding env +} +enum CliResume { Flag(String), Subcommand(String), Unsupported } +enum CliPromptArg { Positional, Unsupported } + +// on StandardCodingAgentExecutor: +fn interactive_cli_spec(&self, cwd: &Path) -> Option { None } +fn pre_cli_launch(&self, _cwd: &Path) {} // trust-seeding etc. +``` +- `ClaudeCode`: refactor existing `interactive_cli_args` + `ensure_claude_folder_trusted` behind + these (no behavior change; keep `--model/--effort`, `--dangerously-skip-permissions`, + `--resume `, prompt positional). +- `Codex`: build from `Codex` config — `-m ` (strip `-fast`), `-c model_reasoning_effort=`, + `-s ` (default danger-full-access), `-a ` (Auto→never), `-C `, + `--no-alt-screen`; resume = `Subcommand("resume")` (`codex resume [prompt]`). + +### Plumbing +- `pty.rs`: `cli_bootstrap` takes a resolved spec (program + flags + resume/prompt form) instead of + the `claude` literal; keep shell-quoting, `command -v ` gate, `exec $SHELL` fallback. + `PtyCommand::TmuxCli` carries `program` + `resume_kind` + `prompt_kind` (+ existing `agent_args`). +- `services/container.rs:start_workspace_cli`: use the workspace's selected executor, not `ClaudeCode`. +- `server/routes/terminal.rs`: resolve `ExecutorConfig` from `Session.executor` + CLI model/effort, + instantiate the `CodingAgent`, `apply_overrides`, call `interactive_cli_spec`; fall back to claude + default if the agent has no spec. +- Frontend: CLI pane header shows the running agent (label from `Session.executor`). + +### Tests +- Unit: `interactive_cli_spec` argv for representative configs per agent (mirror claude's + `cli_launch_tests`). +- Codex live e2e (installed 0.140): create CLI workspace w/ codex, verify tmux launch, model/effort/ + sandbox honored, resume, no approval stall. +- Other 7: unit tests now; e2e checklist gated on install approval. + +## Part 1 — Loop automation + +### Data model (new migration) +- `loop_automation`: `workspace_id` PK, `enabled` (def false), `retry_interval_secs` (def 600), + `continuation_prompt` (def "Continue."), `max_attempts` (def e.g. 50), `attempts_used`, timestamps. +- `scheduled_wakeup`: `id`, `workspace_id`, `fire_at`, `kind` (RateLimitRetry|UsageLimitWake|Manual), + `prompt`, `recurring_cron` NULL, `fired_at` NULL, `attempt`, `created_at`. +- Global defaults in `Config`. + +### Loop supervisor (new periodic task, local-deployment) +PR-monitor shape (`select!{ interval.tick(), notify.notified() }`), spawned from `LocalDeployment::new()`. +Each tick: +1. **Detect** — for enabled workspaces, `tmux capture-pane -p -t vk_` (new helper) and scan the + tail: transient rate-limit banner → schedule `RateLimitRetry` at now+interval (dedupe); + usage-limit banner → parse reset time → schedule `UsageLimitWake` at that instant (next-window / + next-day fallback). Reuse cli_activity Running/Idle so a busy agent is never interrupted. +2. **Fire** — for due wakeups with an idle agent: `tmux send-keys -t vk_ -l ''` then + `Enter` (new helper). If the session died, re-park via `set_pending_cli_prompt`. Mark `fired_at`, + bump attempts, respect cap, fire a notification. + +Banner patterns (case-insensitive), centralized + unit-tested: +- rate-limit: `temporarily limiting requests`, `· Rate limited`, `Rate limited`. +- usage-limit: Claude `usage limit reached` / `resets at