diff --git a/README.md b/README.md index 1f19734..03a7e40 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,16 @@ # loopctl -A Rust CLI + TUI for orchestrating long-running [Claude Code][claude-code] +A Rust CLI + TUI for orchestrating long-running coding-agent "loops" — persistent agent sessions whose state lives in a markdown repo. +Each loop can run [Claude Code][claude-code], [Codex][codex], or +[opencode][opencode] (per-loop, selectable via the `agent` field). Native to KDE Plasma: each loop runs as a Konsole tab on a chosen virtual desktop, with a central daemon owning schedules, health checks, and restart-resilient state. [claude-code]: https://www.anthropic.com/claude-code +[codex]: https://github.com/openai/codex +[opencode]: https://opencode.ai ## Status @@ -130,12 +134,20 @@ claude_loops_dir = "~/claude-loops" terminal = "konsole" central_group = "8" default_dispatch = "tick_script" -claude_args = ["--dangerously-skip-permissions"] # workbench-wide default +default_agent = "claude" # agent a loop uses if it sets none +agent_args = ["--dangerously-skip-permissions"] # default-agent args (alias: claude_args) paste_submit_delay_ms = 700 # bump if Enter doesn't auto-fire [terminal.konsole] profile = "ClaudeLoop" # optional Konsole profile name +# Per-agent overrides. Built-in presets exist for `claude`, `codex`, and +# `opencode` (binary + hands-off flag), so these tables are only needed to +# change a binary/args or to define a brand-new custom agent. +[agents.codex] +binary = "codex" +args = ["--dangerously-bypass-approvals-and-sandbox"] + [[loops]] name = "alpha-loop" dir = "alpha-loop" @@ -145,10 +157,19 @@ schedule = "every 10m" dispatch = "tick_script" # or "resume_prompt", or "none" tick_script = "skill/scripts/tick.py" tick_args = ["--cache", "$HOME/.claude/changecase-tracker-cache/cases.json"] -# Optional per-loop override of claude_args. `[]` = stock claude with prompts. -# claude_args = [] +# agent = "codex" # claude | codex | opencode | custom; omit = default_agent +# Optional per-loop override of agent_args. `[]` = stock agent with prompts. +# agent_args = [] ``` +**Agents.** Each loop launches a coding-agent CLI in its pane — `claude` +by default, or `codex` / `opencode` (or any custom agent from `[agents.*]`) +via the per-loop `agent` field. Built-in presets supply each agent's binary +and hands-off flag; `[agents.]` overrides them or defines new agents. +The older `claude_binary` / `claude_args` keys still parse (they're aliases +for `agent_binary` / `agent_args`) and are rewritten to the new names on +first daemon load. + State (rebuildable, kept out of the workbench repo): `~/.local/state/agent-loop-workbench/state.json` diff --git a/src/config/mod.rs b/src/config/mod.rs index afe3fd3..cac13f5 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -20,8 +20,8 @@ pub mod schema; pub mod user; pub use schema::{ - Dispatch, KonsoleSettings, LoopConfig, Schedule, TerminalSettings, WorkbenchConfig, - WorkbenchSettings, + builtin_agent_preset, AgentOverride, Dispatch, KonsoleSettings, LoopConfig, ResolvedAgent, + Schedule, TerminalSettings, WorkbenchConfig, WorkbenchSettings, BUILTIN_AGENTS, }; pub use user::{load_user_config, save_user_config, user_config_path, UserConfig}; diff --git a/src/config/schema.rs b/src/config/schema.rs index 14b44fe..6fc8da3 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -41,6 +41,7 @@ //! tick_script = "skill/scripts/tick.py" # required iff dispatch = "tick_script" //! ``` +use std::collections::BTreeMap; use std::str::FromStr; use anyhow::{anyhow, bail, Result}; @@ -57,10 +58,55 @@ pub struct WorkbenchConfig { pub workbench: WorkbenchSettings, #[serde(default)] pub terminal: TerminalSettings, + /// Optional per-agent overrides, keyed by agent name (`[agents.codex]`). + /// Merged over the built-in presets (`claude`, `codex`, `opencode`); + /// unknown names define brand-new agents. A loop selects one via its + /// `agent` field (or inherits `workbench.default_agent`). + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub agents: BTreeMap, #[serde(default)] pub loops: Vec, } +/// Per-agent override from an `[agents.]` table. Either field may be +/// omitted, in which case the built-in preset (if any) supplies it. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AgentOverride { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub binary: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub args: Option>, +} + +/// A fully-resolved agent launch spec: the binary to exec in the loop's +/// pane plus the argv that follows it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedAgent { + pub binary: String, + pub args: Vec, +} + +/// Built-in agent presets: `(binary, hands-off args)`. These are the +/// zero-config defaults; `[agents.]` and the per-loop / workbench +/// arg fields layer on top. Returns `None` for names with no preset (a +/// custom agent defined purely via `[agents.]`). +pub fn builtin_agent_preset(name: &str) -> Option<(&'static str, Vec)> { + match name { + // Long-running loops run hands-off; each agent's "skip all + // confirmation prompts" flag is baked in as the default. + "claude" => Some(("claude", vec!["--dangerously-skip-permissions".to_string()])), + "codex" => Some(( + "codex", + vec!["--dangerously-bypass-approvals-and-sandbox".to_string()], + )), + "opencode" => Some(("opencode", vec![])), + _ => None, + } +} + +/// Names of the built-in agent presets, for error messages. +pub const BUILTIN_AGENTS: &[&str] = &["claude", "codex", "opencode"]; + fn is_zero_u32(n: &u32) -> bool { *n == 0 } @@ -73,16 +119,33 @@ pub struct WorkbenchSettings { /// stubs and are rejected at validation time. pub terminal: String, pub central_group: String, - #[serde(default = "default_claude_binary")] - pub claude_binary: String, + /// Which agent a loop runs when it doesn't name one itself. Must + /// resolve to a built-in preset (`claude`, `codex`, `opencode`) or an + /// `[agents.]` entry. Defaults to `claude`. + #[serde(default = "default_agent_name")] + pub default_agent: String, + /// Binary for the *default* agent. Back-compat alias: `claude_binary`. + /// `None` ⇒ take it from the default agent's `[agents.*]` entry or + /// built-in preset. Non-default agents are configured via `[agents.*]`. + #[serde( + default, + alias = "claude_binary", + skip_serializing_if = "Option::is_none" + )] + pub agent_binary: Option, #[serde(default = "default_dispatch_kind")] pub default_dispatch: Dispatch, - /// Default args appended to every `claude` invocation. Loops with - /// `claude_args = []` (an empty array) explicitly opt out and get - /// stock claude with normal permission prompting. Loops that omit - /// the field entirely inherit this list. - #[serde(default = "default_claude_args")] - pub claude_args: Vec, + /// Default args for the *default* agent. Back-compat alias: + /// `claude_args`. A loop with `agent_args = []` (an empty array) + /// explicitly opts out and runs the stock agent with normal + /// permission prompting; a loop that omits it inherits this list (or, + /// when `None` here, the agent's `[agents.*]` / preset args). + #[serde( + default, + alias = "claude_args", + skip_serializing_if = "Option::is_none" + )] + pub agent_args: Option>, /// Milliseconds to wait between pasting the resume prompt body and /// sending the CR (Enter) that submits it. Default 700ms — long /// enough for claude to finish startup and enable raw mode, but @@ -97,18 +160,12 @@ fn default_paste_submit_delay_ms() -> u64 { 700 } -fn default_claude_binary() -> String { +fn default_agent_name() -> String { "claude".to_string() } fn default_dispatch_kind() -> Dispatch { Dispatch::ResumePrompt } -fn default_claude_args() -> Vec { - // Long-running loops are trusted to run hands-off — see README. - // Override at workbench level by setting `claude_args = [...]` or - // disable per-loop by setting `claude_args = []`. - vec!["--dangerously-skip-permissions".to_string()] -} #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct TerminalSettings { @@ -148,17 +205,28 @@ pub struct LoopConfig { /// Defaults to no args. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tick_args: Vec, - /// Per-loop claude args. `None` ⇒ inherit `WorkbenchSettings.claude_args`. - /// `Some(vec![])` ⇒ explicit opt-out: run stock `claude` with the - /// user's normal permission prompts (the typical state for a brand - /// new loop that hasn't earned hands-off trust yet). + /// Which agent runs in this loop's pane. `None` ⇒ inherit + /// `WorkbenchSettings.default_agent`. Must resolve to a built-in preset + /// or an `[agents.]` entry. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option, + /// Per-loop agent args. Back-compat alias: `claude_args`. `None` ⇒ + /// inherit (the agent's `[agents.*]` args, else the workbench-wide + /// `agent_args`, else the preset). `Some(vec![])` ⇒ explicit opt-out: + /// run the stock agent with the user's normal permission prompts (the + /// typical state for a brand new loop that hasn't earned hands-off + /// trust yet). /// /// `skip_serializing_if = is_none` is critical: without it, every /// toml round-trip (move-group, close-loop, etc.) emits - /// `claude_args = []` for `None`, which silently overrides the + /// `agent_args = []` for `None`, which silently overrides the /// workbench-wide default the next time the config is read. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub claude_args: Option>, + #[serde( + default, + alias = "claude_args", + skip_serializing_if = "Option::is_none" + )] + pub agent_args: Option>, /// `false` ⇒ this loop is closed: skip it during `up`, scheduled /// ticks, and health checks; close any live pane. Persists across /// daemon restarts. Defaults to `true` (active) when the field is @@ -182,17 +250,6 @@ impl LoopConfig { Schedule::from_str(&self.schedule) } - /// Effective claude args for this loop. If the loop has its own - /// `claude_args` set (even an empty list), it wins. Otherwise the - /// workbench-wide default applies. - pub fn effective_claude_args<'a>( - &'a self, - workbench: &'a WorkbenchSettings, - ) -> &'a [String] { - self.claude_args - .as_deref() - .unwrap_or(&workbench.claude_args) - } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -371,6 +428,29 @@ impl WorkbenchConfig { self.workbench.terminal ); } + // Agent names (default + any `[agents.*]` keys + per-loop) must be + // safe identifiers, and `default_agent` must actually resolve to a + // known agent so a typo fails loudly instead of trying to exec a + // nonexistent binary named after the typo. + if !is_safe_identifier(&self.workbench.default_agent) { + bail!( + "workbench.default_agent = `{}` must match [A-Za-z0-9._-]+", + self.workbench.default_agent + ); + } + for key in self.agents.keys() { + if !is_safe_identifier(key) { + bail!("agent name `{key}` must match [A-Za-z0-9._-]+"); + } + } + if !self.is_known_agent(&self.workbench.default_agent) { + bail!( + "workbench.default_agent = `{}` is unknown (built-ins: {}; or define it under [agents.{}])", + self.workbench.default_agent, + BUILTIN_AGENTS.join(", "), + self.workbench.default_agent, + ); + } // Loop names must be unique — they're the lookup key in // state.json, RPC params, and CLI subcommands. A duplicate // means `loopctl tick ` (etc.) becomes ambiguous. @@ -429,6 +509,26 @@ impl WorkbenchConfig { ); } } + // Per-loop agent must be a safe identifier and resolve to a + // known agent (built-in preset or an `[agents.*]` entry). + if let Some(agent) = lc.agent.as_deref() { + if !is_safe_identifier(agent) { + bail!( + "loop `{}`: agent = `{}` must match [A-Za-z0-9._-]+", + lc.name, + agent + ); + } + if !self.is_known_agent(agent) { + bail!( + "loop `{}`: agent = `{}` is unknown (built-ins: {}; or define it under [agents.{}])", + lc.name, + agent, + BUILTIN_AGENTS.join(", "), + agent, + ); + } + } // Eager schedule parse. lc.parsed_schedule() .map_err(|e| anyhow!("loop `{}`: invalid schedule: {e}", lc.name))?; @@ -451,6 +551,63 @@ impl WorkbenchConfig { pub fn loop_by_name(&self, name: &str) -> Option<&LoopConfig> { self.loops.iter().find(|l| l.name == name) } + + /// The agent name a loop runs under (its own `agent`, else the + /// workbench default). + pub fn agent_name_for<'a>(&'a self, lc: &'a LoopConfig) -> &'a str { + lc.agent.as_deref().unwrap_or(&self.workbench.default_agent) + } + + /// Returns true iff `name` is a known agent — either a built-in preset + /// or a user-defined `[agents.]` entry. + pub fn is_known_agent(&self, name: &str) -> bool { + builtin_agent_preset(name).is_some() || self.agents.contains_key(name) + } + + /// Resolve the binary + args to launch for a loop. Precedence, highest + /// first: + /// binary: `[agents.].binary` → (default agent only) + /// `workbench.agent_binary` → built-in preset → the name + /// itself. + /// args: per-loop `agent_args` → `[agents.].args` → + /// (default agent only) `workbench.agent_args` → preset → []. + /// The "default agent only" rungs are the back-compat path: an old + /// config's `claude_binary`/`claude_args` (now `agent_binary`/ + /// `agent_args`) configure whatever `default_agent` names. + pub fn resolve_agent(&self, lc: &LoopConfig) -> ResolvedAgent { + let name = self.agent_name_for(lc); + let is_default = name == self.workbench.default_agent; + let preset = builtin_agent_preset(name); + let user = self.agents.get(name); + + let binary = user + .and_then(|a| a.binary.clone()) + .or_else(|| { + if is_default { + self.workbench.agent_binary.clone() + } else { + None + } + }) + .or_else(|| preset.as_ref().map(|(b, _)| b.to_string())) + .unwrap_or_else(|| name.to_string()); + + let args = lc + .agent_args + .clone() + .or_else(|| user.and_then(|a| a.args.clone())) + .or_else(|| { + if is_default { + self.workbench.agent_args.clone() + } else { + None + } + }) + .or_else(|| preset.map(|(_, a)| a)) + .unwrap_or_default(); + + ResolvedAgent { binary, args } + } } #[cfg(test)] @@ -487,8 +644,14 @@ tick_script = "skill/scripts/tick.py" let cfg = parse(MINIMAL).expect("parse"); assert_eq!(cfg.schema_version, 1); assert_eq!(cfg.workbench.terminal, "konsole"); - assert_eq!(cfg.workbench.claude_binary, "claude"); // default + assert_eq!(cfg.workbench.default_agent, "claude"); // default + assert_eq!(cfg.workbench.agent_binary, None); // resolves via preset assert_eq!(cfg.workbench.default_dispatch, Dispatch::ResumePrompt); // default + // With no explicit binary/args, the default agent resolves to the + // built-in claude preset. + let resolved = cfg.resolve_agent(&cfg.loops[0]); + assert_eq!(resolved.binary, "claude"); + assert_eq!(resolved.args, vec!["--dangerously-skip-permissions".to_string()]); assert_eq!(cfg.loops.len(), 1); let lc = &cfg.loops[0]; assert_eq!(lc.effective_tab_title(), "alpha-loop"); @@ -634,35 +797,95 @@ tick_script = "skill/scripts/tick.py" } #[test] - fn round_trip_omits_none_claude_args() { - // Loop with claude_args left absent must NOT serialize as - // `claude_args = []` — that empty array would silently override + fn round_trip_omits_none_agent_args() { + // Loop with agent_args left absent must NOT serialize as + // `agent_args = []` — that empty array would silently override // the workbench-wide default the next time the file is loaded. let cfg = parse(MINIMAL).unwrap(); - assert!(cfg.loops[0].claude_args.is_none()); + assert!(cfg.loops[0].agent_args.is_none()); let body = toml::to_string_pretty(&cfg).unwrap(); - // The workbench-wide `claude_args` IS allowed; only the per-loop - // one should be missing. let after_loops = body .split_once("[[loops]]") .map(|(_, rest)| rest) .unwrap_or(&body); assert!( - !after_loops.contains("claude_args"), - "round-trip leaked per-loop claude_args:\n{body}" + !after_loops.contains("agent_args"), + "round-trip leaked per-loop agent_args:\n{body}" ); } #[test] - fn round_trip_preserves_explicit_empty_claude_args() { + fn round_trip_preserves_explicit_empty_agent_args() { let body = MINIMAL.replace( "tick_script = \"skill/scripts/tick.py\"", - "tick_script = \"skill/scripts/tick.py\"\nclaude_args = []", + "tick_script = \"skill/scripts/tick.py\"\nagent_args = []", ); let cfg = parse(&body).unwrap(); - assert_eq!(cfg.loops[0].claude_args.as_deref(), Some(&[][..])); + assert_eq!(cfg.loops[0].agent_args.as_deref(), Some(&[][..])); let out = toml::to_string_pretty(&cfg).unwrap(); - assert!(out.contains("claude_args = []")); + assert!(out.contains("agent_args = []")); + } + + #[test] + fn legacy_claude_args_alias_still_parses() { + // Old configs used `claude_args`; the serde alias keeps them + // working and they now populate `agent_args`. + let body = MINIMAL.replace( + "tick_script = \"skill/scripts/tick.py\"", + "tick_script = \"skill/scripts/tick.py\"\nclaude_args = [\"--foo\"]", + ); + let cfg = parse(&body).unwrap(); + assert_eq!(cfg.loops[0].agent_args.as_deref(), Some(&["--foo".to_string()][..])); + } + + #[test] + fn legacy_workbench_claude_fields_alias_and_drive_default_agent() { + let body = MINIMAL.replace( + "central_group = \"1\"", + "central_group = \"1\"\nclaude_binary = \"/usr/bin/claude\"\nclaude_args = [\"--foo\"]", + ); + let cfg = parse(&body).unwrap(); + assert_eq!(cfg.workbench.agent_binary.as_deref(), Some("/usr/bin/claude")); + let resolved = cfg.resolve_agent(&cfg.loops[0]); + assert_eq!(resolved.binary, "/usr/bin/claude"); + assert_eq!(resolved.args, vec!["--foo".to_string()]); + } + + #[test] + fn rejects_unknown_default_agent() { + let body = MINIMAL.replace( + "central_group = \"1\"", + "central_group = \"1\"\ndefault_agent = \"bogus\"", + ); + let err = parse(&body).unwrap_err().to_string(); + assert!(err.contains("default_agent"), "got: {err}"); + assert!(err.contains("unknown"), "got: {err}"); + } + + #[test] + fn rejects_unknown_loop_agent() { + let body = MINIMAL.replace( + "dispatch = \"tick_script\"", + "dispatch = \"tick_script\"\nagent = \"bogus\"", + ); + let err = parse(&body).unwrap_err().to_string(); + assert!(err.contains("bogus"), "got: {err}"); + assert!(err.contains("unknown"), "got: {err}"); + } + + #[test] + fn accepts_custom_agent_defined_in_agents_table() { + let body = format!( + "{}\n[agents.mycli]\nbinary = \"mycli\"\nargs = [\"--go\"]\n", + MINIMAL.replace( + "dispatch = \"tick_script\"", + "dispatch = \"tick_script\"\nagent = \"mycli\"", + ) + ); + let cfg = parse(&body).expect("custom agent should validate"); + let resolved = cfg.resolve_agent(&cfg.loops[0]); + assert_eq!(resolved.binary, "mycli"); + assert_eq!(resolved.args, vec!["--go".to_string()]); } #[test] diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 11f5258..3792f5d 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -59,7 +59,7 @@ impl DaemonContext { /// Latest migration version. Bumped by changes that need to mutate /// existing user workbench.toml files atomically at daemon startup. -const LATEST_MIGRATION_V: u32 = 2; +const LATEST_MIGRATION_V: u32 = 3; /// Apply one-shot migrations to the in-memory config and persist them /// back to `config_path`. Migrations are gated by @@ -78,8 +78,8 @@ fn apply_migrations(config: &mut WorkbenchConfig, config_path: &Path) -> Result< if config.loopctl_migration_v < 1 { let mut stripped = 0usize; for lc in config.loops.iter_mut() { - if matches!(&lc.claude_args, Some(v) if v.is_empty()) { - lc.claude_args = None; + if matches!(&lc.agent_args, Some(v) if v.is_empty()) { + lc.agent_args = None; stripped += 1; } } @@ -106,6 +106,18 @@ fn apply_migrations(config: &mut WorkbenchConfig, config_path: &Path) -> Result< config.loopctl_migration_v = 2; } + // v3: `claude_binary`/`claude_args` are now `agent_binary`/`agent_args` + // (workbench + per-loop), part of multi-agent support (claude / codex / + // opencode). Like v2, the serde aliases mean the in-memory struct is + // already correct regardless of the on-disk key; the unconditional + // rewrite below emits the new names. Nothing to mutate here. + if config.loopctl_migration_v < 3 { + tracing::info!( + "migration v3: `claude_binary`/`claude_args` renamed to `agent_binary`/`agent_args`; older configs are accepted via serde alias and rewritten on persist" + ); + config.loopctl_migration_v = 3; + } + // Persist the migrated config so subsequent runs skip the migration. let body = toml::to_string_pretty(config)?; let tmp = config_path.with_extension("toml.tmp"); @@ -435,12 +447,13 @@ claude_args = ["--allowed-tools", "Read"] std::fs::write(&p, body).unwrap(); let mut cfg = parse(body); apply_migrations(&mut cfg, &p).unwrap(); - // Migration v1 was about claude_args; v2 just bumps the version - // and triggers the slug→name rewrite on persist. Latest is 2. - assert_eq!(cfg.loopctl_migration_v, 2); - assert!(cfg.loops[0].claude_args.is_none(), "alpha [] should be cleared"); + // Migration v1 stripped empty per-loop arg overrides; later + // migrations just bump the version and rewrite on persist. Latest + // is now 3 (claude_* → agent_* rename). + assert_eq!(cfg.loopctl_migration_v, 3); + assert!(cfg.loops[0].agent_args.is_none(), "alpha [] should be cleared"); assert_eq!( - cfg.loops[1].claude_args.as_deref(), + cfg.loops[1].agent_args.as_deref(), Some(&["--allowed-tools".to_string(), "Read".to_string()][..]), "beta with real args must NOT be touched" ); @@ -452,7 +465,7 @@ claude_args = ["--allowed-tools", "Read"] let p = dir.path().join("workbench.toml"); let body = r#" schema_version = 1 -loopctl_migration_v = 2 +loopctl_migration_v = 3 [workbench] claude_loops_dir = "~/x" @@ -466,14 +479,53 @@ readme_anchor = "alpha" group_key = "7" schedule = "none" dispatch = "resume_prompt" -claude_args = [] +agent_args = [] "#; std::fs::write(&p, body).unwrap(); let mut cfg = parse(body); apply_migrations(&mut cfg, &p).unwrap(); // Already at the latest version: hand-set [] is left alone. - assert_eq!(cfg.loops[0].claude_args.as_deref(), Some(&[][..])); - assert_eq!(cfg.loopctl_migration_v, 2); + assert_eq!(cfg.loops[0].agent_args.as_deref(), Some(&[][..])); + assert_eq!(cfg.loopctl_migration_v, 3); + } + + #[test] + fn migration_v3_rewrites_legacy_claude_fields_to_agent() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("workbench.toml"); + // Pre-v3 config using the old claude_binary/claude_args keys. + let body = r#" +schema_version = 1 +loopctl_migration_v = 2 + +[workbench] +claude_loops_dir = "~/x" +terminal = "konsole" +central_group = "1" +claude_binary = "claude" +claude_args = ["--dangerously-skip-permissions"] + +[[loops]] +name = "alpha" +dir = "alpha" +readme_anchor = "alpha" +group_key = "7" +schedule = "none" +dispatch = "resume_prompt" +claude_args = ["--allowed-tools", "Read"] +"#; + std::fs::write(&p, body).unwrap(); + let mut cfg = parse(body); + apply_migrations(&mut cfg, &p).unwrap(); + assert_eq!(cfg.loopctl_migration_v, 3); + // On-disk file must now use the new key names and none of the old. + let on_disk = std::fs::read_to_string(&p).unwrap(); + assert!(on_disk.contains("agent_binary"), "expected agent_binary:\n{on_disk}"); + assert!(on_disk.contains("agent_args"), "expected agent_args:\n{on_disk}"); + assert!( + !on_disk.contains("claude_binary") && !on_disk.contains("claude_args"), + "legacy claude_* keys must be gone:\n{on_disk}" + ); } #[test] @@ -501,7 +553,7 @@ dispatch = "resume_prompt" std::fs::write(&p, body).unwrap(); let mut cfg = parse(body); apply_migrations(&mut cfg, &p).unwrap(); - assert_eq!(cfg.loopctl_migration_v, 2); + assert_eq!(cfg.loopctl_migration_v, 3); assert_eq!(cfg.loops[0].name, "alpha"); // The persisted file now uses `name = ...` not `slug = ...`. let on_disk = std::fs::read_to_string(&p).unwrap(); diff --git a/src/main.rs b/src/main.rs index a1fa78a..a7629c8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,7 +33,7 @@ use agent_loop_workbench::{ }; #[derive(Parser, Debug)] -#[command(name = "loopctl", version, about = "Manage Claude agent loops")] +#[command(name = "loopctl", version, about = "Manage AI agent loops (claude, codex, opencode)")] struct Cli { /// Path to workbench.toml. Defaults to ~/claude-loops/workbench.toml. #[arg(long, global = true)] @@ -116,6 +116,11 @@ enum Command { /// dispatch kind: resume_prompt, tick_script, or none. #[arg(long, default_value = "resume_prompt")] dispatch: String, + /// Agent to run in the loop's pane: claude, codex, opencode, or a + /// custom name defined under `[agents.*]`. Omit to inherit + /// workbench.default_agent. + #[arg(long)] + agent: Option, /// Required iff dispatch=tick_script. Path relative to dir. #[arg(long)] tick_script: Option, @@ -238,6 +243,7 @@ async fn main() -> Result<()> { message, prompt_file, dispatch, + agent, tick_script, no_up, } => { @@ -251,6 +257,7 @@ async fn main() -> Result<()> { message.as_deref(), prompt_file.as_deref(), dispatch, + agent.as_deref(), tick_script.as_deref(), !*no_up, ) @@ -788,6 +795,7 @@ async fn cmd_add_loop( message: Option<&str>, prompt_file: Option<&std::path::Path>, dispatch: &str, + agent: Option<&str>, tick_script: Option<&str>, auto_up: bool, ) -> Result<()> { @@ -803,10 +811,17 @@ async fn cmd_add_loop( } }; let cfg_path = resolve_config_path(cli)?; - let loops_dir = cfg_path - .parent() - .ok_or_else(|| anyhow::anyhow!("config path has no parent: {cfg_path:?}"))? - .to_path_buf(); + // The loop's workdir + README live under `claude_loops_dir`, which is + // where the daemon resolves them at `up` time. Do NOT use + // `cfg_path.parent()`: the canonical config lives at + // `~/.config/loopctl/workbench.toml`, which is usually NOT the loops + // repo — using the parent would create the dir/README in the wrong + // place and `up` would then fail with "workdir does not exist". + let cfg = config::load(&cfg_path) + .with_context(|| format!("load workbench.toml at {}", cfg_path.display()))?; + let loops_dir = std::path::PathBuf::from(agent_loop_workbench::util::expand_path( + &cfg.workbench.claude_loops_dir, + )); let dir_value = dir.unwrap_or(name).to_string(); // Reject `..` and absolute paths up front. The same check runs in // LoopConfig::validate, but only AFTER the file is written; we @@ -869,6 +884,19 @@ async fn cmd_add_loop( config::Schedule::from_str(schedule) .with_context(|| format!("invalid --schedule {schedule:?}"))?; + // Validate --agent up-front against built-ins + any [agents.*] already + // in the config, so we don't write a half-loop that the post-write + // re-parse then rejects. + if let Some(a) = agent { + if !cfg.is_known_agent(a) { + anyhow::bail!( + "unknown agent `{a}` (built-ins: {}; or add an [agents.{a}] table to {})", + config::BUILTIN_AGENTS.join(", "), + cfg_path.display(), + ); + } + } + let anchor = agent_loop_workbench::readme::slugify_heading(&heading_value); add_to_readme(&loops_dir.join("README.md"), &heading_value, &anchor, &prompt)?; add_to_workbench_toml( @@ -879,6 +907,7 @@ async fn cmd_add_loop( group, schedule, &dispatch_value, + agent, tick_script, )?; // Re-validate the freshly-written config; bail loudly if we broke it. @@ -985,6 +1014,7 @@ fn add_to_workbench_toml( group: &str, schedule: &str, dispatch: &str, + agent: Option<&str>, tick_script: Option<&str>, ) -> Result<()> { let mut existing = std::fs::read_to_string(path) @@ -998,16 +1028,19 @@ fn add_to_workbench_toml( existing.push_str(&format!( "[[loops]]\nname = \"{name}\"\ndir = \"{dir}\"\nreadme_anchor = \"{anchor}\"\ngroup_key = \"{group}\"\nschedule = \"{schedule}\"\ndispatch = \"{dispatch}\"\n", )); + if let Some(a) = agent { + existing.push_str(&format!("agent = \"{a}\"\n")); + } if let Some(t) = tick_script { existing.push_str(&format!("tick_script = \"{t}\"\n")); } - // New loops get an explicit empty claude_args so they DON'T inherit - // the workbench-wide --dangerously-skip-permissions default. The - // operator can flip this to inherit (delete the line) once the loop - // has earned hands-off trust. - existing.push_str("# New loop: stock claude with normal permission prompts.\n"); - existing.push_str("# Delete this line to inherit workbench.claude_args (e.g. --dangerously-skip-permissions).\n"); - existing.push_str("claude_args = []\n"); + // New loops get an explicit empty agent_args so they DON'T inherit + // the workbench-wide --dangerously-skip-permissions (or per-agent + // hands-off) default. The operator can flip this to inherit (delete + // the line) once the loop has earned hands-off trust. + existing.push_str("# New loop: stock agent with normal permission prompts.\n"); + existing.push_str("# Delete this line to inherit the agent's hands-off args (e.g. --dangerously-skip-permissions).\n"); + existing.push_str("agent_args = []\n"); std::fs::write(path, existing).with_context(|| format!("write workbench.toml at {path:?}"))?; config::snapshot_in_git(path, &format!("loopctl: add-loop {name}")); Ok(()) diff --git a/src/orchestrator.rs b/src/orchestrator.rs index a30237d..6c74ced 100644 --- a/src/orchestrator.rs +++ b/src/orchestrator.rs @@ -237,7 +237,7 @@ async fn ensure_pane_for_loop( lc.name )); } - let initial = Some(claude_invocation(config, lc)); + let initial = Some(agent_invocation(config, lc)); let spec = PaneSpec { group_key: lc.group_key.clone(), title: lc.effective_tab_title().to_string(), @@ -248,10 +248,13 @@ async fn ensure_pane_for_loop( } /// Build the shell line that the konsole tab will run on launch: -/// ` ` with each arg POSIX-quoted. -fn claude_invocation(config: &WorkbenchConfig, lc: &LoopConfig) -> String { - let mut s = posix_quote(&config.workbench.claude_binary); - for a in lc.effective_claude_args(&config.workbench) { +/// ` ` with each arg POSIX-quoted. The agent +/// (claude / codex / opencode / custom) and its args are resolved from the +/// loop's `agent`, the `[agents.*]` overrides, and the workbench defaults. +fn agent_invocation(config: &WorkbenchConfig, lc: &LoopConfig) -> String { + let resolved = config.resolve_agent(lc); + let mut s = posix_quote(&resolved.binary); + for a in &resolved.args { s.push(' '); s.push_str(&posix_quote(a)); } @@ -922,7 +925,7 @@ dispatch = "resume_prompt" } #[test] - fn claude_invocation_uses_workbench_default_when_loop_unset() { + fn agent_invocation_uses_workbench_default_when_loop_unset() { let mut cfg = config::parse( r#" schema_version = 1 @@ -942,23 +945,88 @@ dispatch = "resume_prompt" "#, ) .unwrap(); - // workbench-wide default applies when LoopConfig.claude_args is None. + // The default agent (claude) preset applies when the loop and the + // workbench set no explicit args. let alpha = &cfg.loops[0]; assert_eq!( - super::claude_invocation(&cfg, alpha), + super::agent_invocation(&cfg, alpha), "claude --dangerously-skip-permissions" ); - // Explicit override at workbench level. - cfg.workbench.claude_args = vec!["--allowed-tools".into(), "Read,Write".into()]; + // Explicit override at workbench level (formerly claude_args). + cfg.workbench.agent_args = Some(vec!["--allowed-tools".into(), "Read,Write".into()]); assert_eq!( - super::claude_invocation(&cfg, alpha), + super::agent_invocation(&cfg, alpha), "claude --allowed-tools Read,Write" ); } #[test] - fn claude_invocation_loop_override_wins_and_empty_means_no_args() { + fn agent_invocation_selects_codex_preset_by_loop_agent() { + let cfg = config::parse( + r#" +schema_version = 1 + +[workbench] +claude_loops_dir = "/tmp" +terminal = "konsole" +central_group = "1" + +[[loops]] +name = "demo-codex" +dir = "demo-codex" +readme_anchor = "demo-codex" +group_key = "2" +schedule = "none" +dispatch = "resume_prompt" +agent = "codex" +"#, + ) + .unwrap(); + // No [agents.*] override, no per-loop args: the built-in codex + // preset (binary + hands-off flag) drives the invocation. + let lc = cfg.loop_by_name("demo-codex").unwrap(); + assert_eq!( + super::agent_invocation(&cfg, lc), + "codex --dangerously-bypass-approvals-and-sandbox" + ); + } + + #[test] + fn agent_invocation_honors_agents_table_override() { + let cfg = config::parse( + r#" +schema_version = 1 + +[workbench] +claude_loops_dir = "/tmp" +terminal = "konsole" +central_group = "1" + +[agents.codex] +binary = "/opt/codex/bin/codex" +args = ["exec", "--full-auto"] + +[[loops]] +name = "demo-codex" +dir = "demo-codex" +readme_anchor = "demo-codex" +group_key = "2" +schedule = "none" +dispatch = "resume_prompt" +agent = "codex" +"#, + ) + .unwrap(); + let lc = cfg.loop_by_name("demo-codex").unwrap(); + assert_eq!( + super::agent_invocation(&cfg, lc), + "/opt/codex/bin/codex exec --full-auto" + ); + } + + #[test] + fn agent_invocation_loop_override_wins_and_empty_means_no_args() { let cfg = config::parse( r#" schema_version = 1 @@ -990,9 +1058,9 @@ claude_args = ["--allowed-tools", "Read"] .unwrap(); let alpha = cfg.loop_by_name("alpha").unwrap(); let beta = cfg.loop_by_name("beta").unwrap(); - assert_eq!(super::claude_invocation(&cfg, alpha), "claude"); + assert_eq!(super::agent_invocation(&cfg, alpha), "claude"); assert_eq!( - super::claude_invocation(&cfg, beta), + super::agent_invocation(&cfg, beta), "claude --allowed-tools Read" ); } diff --git a/src/repo_loader.rs b/src/repo_loader.rs index 94da109..d3f12d5 100644 --- a/src/repo_loader.rs +++ b/src/repo_loader.rs @@ -380,8 +380,10 @@ fn write_workbench_toml( s.push_str(&format!("terminal = \"{terminal_name}\"\n")); s.push_str(&format!("central_group = \"{central}\"\n")); s.push_str("default_dispatch = \"resume_prompt\"\n"); - s.push_str("# Default claude args applied to every loop. New loops opt out (see add-loop).\n"); - s.push_str("claude_args = [\"--dangerously-skip-permissions\"]\n\n"); + s.push_str("# Agent every loop runs unless it sets its own `agent` (claude | codex | opencode).\n"); + s.push_str("default_agent = \"claude\"\n"); + s.push_str("# Default args for the default agent. New loops opt out (see add-loop).\n"); + s.push_str("agent_args = [\"--dangerously-skip-permissions\"]\n\n"); for l in loops { let group = plan