diff --git a/Cargo.lock b/Cargo.lock index 265876e3fc..3b0374f0a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4963,6 +4963,7 @@ dependencies = [ "api-types", "async-trait", "base64 0.22.1", + "chrono", "client-info", "command-group", "db", @@ -4986,10 +4987,12 @@ dependencies = [ "serde", "serde_json", "services", + "sqlx", "tempfile", "thiserror 2.0.18", "tokio", "tokio-util", + "toml 0.8.2", "tracing", "trusted-key-auth", "utils", @@ -8927,6 +8930,7 @@ dependencies = [ "utils", "uuid", "walkdir", + "which", "workspace-manager", "worktree-manager", "ws-bridge", diff --git a/crates/db/.sqlx/query-272b192b9a625c3f2c03eabe943ede90098e26ce27f2aec69ea968c0a7219c94.json b/crates/db/.sqlx/query-272b192b9a625c3f2c03eabe943ede90098e26ce27f2aec69ea968c0a7219c94.json new file mode 100644 index 0000000000..31b9346e9a --- /dev/null +++ b/crates/db/.sqlx/query-272b192b9a625c3f2c03eabe943ede90098e26ce27f2aec69ea968c0a7219c94.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE scheduled_wakeups SET fired_at = datetime('now', 'subsec') WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "272b192b9a625c3f2c03eabe943ede90098e26ce27f2aec69ea968c0a7219c94" +} diff --git a/crates/db/.sqlx/query-2c10f6acdca114945bfafefcf9e552c597c4974cbbb6f50e6e2b34370d12ac79.json b/crates/db/.sqlx/query-2c10f6acdca114945bfafefcf9e552c597c4974cbbb6f50e6e2b34370d12ac79.json new file mode 100644 index 0000000000..21efc7bc0e --- /dev/null +++ b/crates/db/.sqlx/query-2c10f6acdca114945bfafefcf9e552c597c4974cbbb6f50e6e2b34370d12ac79.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT\n id as \"id!: Uuid\",\n workspace_id as \"workspace_id!: Uuid\",\n fire_at as \"fire_at!: DateTime\",\n kind,\n prompt,\n attempt,\n fired_at as \"fired_at?: DateTime\",\n created_at as \"created_at!: DateTime\"\n FROM scheduled_wakeups\n WHERE workspace_id = $1 AND ($2 OR fired_at IS NULL)\n ORDER BY fired_at IS NOT NULL, fire_at ASC", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "workspace_id!: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "fire_at!: DateTime", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "kind", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "prompt", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "attempt", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "fired_at?: DateTime", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + true, + false, + false, + false, + true, + false, + true, + false + ] + }, + "hash": "2c10f6acdca114945bfafefcf9e552c597c4974cbbb6f50e6e2b34370d12ac79" +} diff --git a/crates/db/.sqlx/query-33db5a7db44015572057f6238adb945460ecbacc3c5a74ddd6eb9e9d129173a2.json b/crates/db/.sqlx/query-33db5a7db44015572057f6238adb945460ecbacc3c5a74ddd6eb9e9d129173a2.json new file mode 100644 index 0000000000..3e907ba859 --- /dev/null +++ b/crates/db/.sqlx/query-33db5a7db44015572057f6238adb945460ecbacc3c5a74ddd6eb9e9d129173a2.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT COUNT(*) as \"count!: i64\"\n FROM scheduled_wakeups\n WHERE workspace_id = $1 AND kind = $2 AND fired_at IS NULL", + "describe": { + "columns": [ + { + "name": "count!: i64", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false + ] + }, + "hash": "33db5a7db44015572057f6238adb945460ecbacc3c5a74ddd6eb9e9d129173a2" +} diff --git a/crates/db/.sqlx/query-37d9c8137897138901b3c0db49a67cf9f91730e525d9b575e7a6f3d18b9f647f.json b/crates/db/.sqlx/query-37d9c8137897138901b3c0db49a67cf9f91730e525d9b575e7a6f3d18b9f647f.json new file mode 100644 index 0000000000..5cd5f944e6 --- /dev/null +++ b/crates/db/.sqlx/query-37d9c8137897138901b3c0db49a67cf9f91730e525d9b575e7a6f3d18b9f647f.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT\n workspace_id as \"workspace_id!: Uuid\",\n enabled,\n retry_interval_secs,\n continuation_prompt,\n max_attempts,\n attempts_used,\n updated_at as \"updated_at!: DateTime\"\n FROM loop_automations\n WHERE enabled = 1", + "describe": { + "columns": [ + { + "name": "workspace_id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "enabled", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "retry_interval_secs", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "continuation_prompt", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "max_attempts", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "attempts_used", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 6, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + true, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "37d9c8137897138901b3c0db49a67cf9f91730e525d9b575e7a6f3d18b9f647f" +} diff --git a/crates/db/.sqlx/query-44722b3a8773a17de6c8a10c10eb6c0b0d9efe1da92ca058c91072fa1a5a7673.json b/crates/db/.sqlx/query-44722b3a8773a17de6c8a10c10eb6c0b0d9efe1da92ca058c91072fa1a5a7673.json new file mode 100644 index 0000000000..e019b9d804 --- /dev/null +++ b/crates/db/.sqlx/query-44722b3a8773a17de6c8a10c10eb6c0b0d9efe1da92ca058c91072fa1a5a7673.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO loop_automations\n (workspace_id, enabled, retry_interval_secs, continuation_prompt,\n max_attempts, attempts_used, updated_at)\n VALUES ($1, $2, $3, $4, $5, 0, datetime('now', 'subsec'))\n ON CONFLICT(workspace_id) DO UPDATE SET\n enabled = excluded.enabled,\n retry_interval_secs = excluded.retry_interval_secs,\n continuation_prompt = excluded.continuation_prompt,\n max_attempts = excluded.max_attempts,\n attempts_used = 0,\n updated_at = excluded.updated_at", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "44722b3a8773a17de6c8a10c10eb6c0b0d9efe1da92ca058c91072fa1a5a7673" +} diff --git a/crates/db/.sqlx/query-6147e567c16faf57f30a303c4e966516167768ab8026ceac3aba362d333899e6.json b/crates/db/.sqlx/query-6147e567c16faf57f30a303c4e966516167768ab8026ceac3aba362d333899e6.json new file mode 100644 index 0000000000..f461d669c9 --- /dev/null +++ b/crates/db/.sqlx/query-6147e567c16faf57f30a303c4e966516167768ab8026ceac3aba362d333899e6.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO scheduled_wakeups\n (id, workspace_id, fire_at, kind, prompt, attempt, created_at)\n VALUES ($1, $2, $3, $4, $5, $6, datetime('now', 'subsec'))", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "6147e567c16faf57f30a303c4e966516167768ab8026ceac3aba362d333899e6" +} diff --git a/crates/db/.sqlx/query-65e254f59d551226f323786d692279af4a158bb584e41720472f852e910a16c5.json b/crates/db/.sqlx/query-65e254f59d551226f323786d692279af4a158bb584e41720472f852e910a16c5.json new file mode 100644 index 0000000000..e18b4c01f4 --- /dev/null +++ b/crates/db/.sqlx/query-65e254f59d551226f323786d692279af4a158bb584e41720472f852e910a16c5.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "UPDATE loop_automations\n SET attempts_used = attempts_used + 1,\n updated_at = datetime('now', 'subsec')\n WHERE workspace_id = $1\n RETURNING attempts_used", + "describe": { + "columns": [ + { + "name": "attempts_used", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "65e254f59d551226f323786d692279af4a158bb584e41720472f852e910a16c5" +} diff --git a/crates/db/.sqlx/query-694d7ed49783af28a5a0ee78d83010796022f4ebf2f31f20af2e0bcfebe348ab.json b/crates/db/.sqlx/query-694d7ed49783af28a5a0ee78d83010796022f4ebf2f31f20af2e0bcfebe348ab.json new file mode 100644 index 0000000000..a8df4855ed --- /dev/null +++ b/crates/db/.sqlx/query-694d7ed49783af28a5a0ee78d83010796022f4ebf2f31f20af2e0bcfebe348ab.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT\n id as \"id!: Uuid\",\n workspace_id as \"workspace_id!: Uuid\",\n fire_at as \"fire_at!: DateTime\",\n kind,\n prompt,\n attempt,\n fired_at as \"fired_at?: DateTime\",\n created_at as \"created_at!: DateTime\"\n FROM scheduled_wakeups WHERE id = $1", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "workspace_id!: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "fire_at!: DateTime", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "kind", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "prompt", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "attempt", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "fired_at?: DateTime", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + true, + false, + true, + false + ] + }, + "hash": "694d7ed49783af28a5a0ee78d83010796022f4ebf2f31f20af2e0bcfebe348ab" +} diff --git a/crates/db/.sqlx/query-8ae6657138bcaa4bf5c5d5791f7b19e1c0cfd6d469aab98cbc12e3e33fcefa94.json b/crates/db/.sqlx/query-8ae6657138bcaa4bf5c5d5791f7b19e1c0cfd6d469aab98cbc12e3e33fcefa94.json new file mode 100644 index 0000000000..459bf015d5 --- /dev/null +++ b/crates/db/.sqlx/query-8ae6657138bcaa4bf5c5d5791f7b19e1c0cfd6d469aab98cbc12e3e33fcefa94.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM scheduled_wakeups WHERE workspace_id = $1 AND fired_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "8ae6657138bcaa4bf5c5d5791f7b19e1c0cfd6d469aab98cbc12e3e33fcefa94" +} diff --git a/crates/db/.sqlx/query-b154ed62876b122f957f26be98c03f59902f55a3531e239b6af1e1daaafb5ad2.json b/crates/db/.sqlx/query-b154ed62876b122f957f26be98c03f59902f55a3531e239b6af1e1daaafb5ad2.json new file mode 100644 index 0000000000..2d2777be9a --- /dev/null +++ b/crates/db/.sqlx/query-b154ed62876b122f957f26be98c03f59902f55a3531e239b6af1e1daaafb5ad2.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM scheduled_wakeups WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "b154ed62876b122f957f26be98c03f59902f55a3531e239b6af1e1daaafb5ad2" +} diff --git a/crates/db/.sqlx/query-b192f2e1662ab7532cce700c19ff0c6695020d07404ad41eda3d74f0213d8eee.json b/crates/db/.sqlx/query-b192f2e1662ab7532cce700c19ff0c6695020d07404ad41eda3d74f0213d8eee.json new file mode 100644 index 0000000000..44dfcc7148 --- /dev/null +++ b/crates/db/.sqlx/query-b192f2e1662ab7532cce700c19ff0c6695020d07404ad41eda3d74f0213d8eee.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT\n workspace_id as \"workspace_id!: Uuid\",\n enabled,\n retry_interval_secs,\n continuation_prompt,\n max_attempts,\n attempts_used,\n updated_at as \"updated_at!: DateTime\"\n FROM loop_automations\n WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "name": "workspace_id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "enabled", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "retry_interval_secs", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "continuation_prompt", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "max_attempts", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "attempts_used", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 6, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "b192f2e1662ab7532cce700c19ff0c6695020d07404ad41eda3d74f0213d8eee" +} diff --git a/crates/db/.sqlx/query-da1eab51ab229efe3dba6e5b4545fb77ca0df88088718266bca38e1c0a952bec.json b/crates/db/.sqlx/query-da1eab51ab229efe3dba6e5b4545fb77ca0df88088718266bca38e1c0a952bec.json new file mode 100644 index 0000000000..4d06569289 --- /dev/null +++ b/crates/db/.sqlx/query-da1eab51ab229efe3dba6e5b4545fb77ca0df88088718266bca38e1c0a952bec.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT\n id as \"id!: Uuid\",\n workspace_id as \"workspace_id!: Uuid\",\n fire_at as \"fire_at!: DateTime\",\n kind,\n prompt,\n attempt,\n fired_at as \"fired_at?: DateTime\",\n created_at as \"created_at!: DateTime\"\n FROM scheduled_wakeups\n WHERE fired_at IS NULL AND datetime(fire_at) <= datetime('now')\n ORDER BY fire_at ASC", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "workspace_id!: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "fire_at!: DateTime", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "kind", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "prompt", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "attempt", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "fired_at?: DateTime", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + true, + false, + false, + false, + true, + false, + true, + false + ] + }, + "hash": "da1eab51ab229efe3dba6e5b4545fb77ca0df88088718266bca38e1c0a952bec" +} diff --git a/crates/db/migrations/20260630120000_add_loop_automation.sql b/crates/db/migrations/20260630120000_add_loop_automation.sql new file mode 100644 index 0000000000..9bea8ee582 --- /dev/null +++ b/crates/db/migrations/20260630120000_add_loop_automation.sql @@ -0,0 +1,49 @@ +-- Agentic-loop automation: keep a workspace's CLI agent going when a chat stops +-- for a NON-completion reason (usage limit / transient rate limit) by detecting +-- the limit banner in the tmux pane, scheduling a wake-up at the reset time (or +-- retrying every N minutes), and re-prompting the agent. Opt-in per workspace, +-- default OFF. + +-- Per-workspace policy. A row exists only once the user has touched the toggle; +-- absence means "disabled" (the default). +CREATE TABLE IF NOT EXISTS loop_automations ( + workspace_id BLOB PRIMARY KEY, + -- Master switch. The supervisor only inspects enabled workspaces. + enabled INTEGER NOT NULL DEFAULT 0, + -- How often to retry a transient rate-limit (seconds). 600 = every 10 min. + retry_interval_secs INTEGER NOT NULL DEFAULT 600, + -- The message sent into the pane to nudge the agent to continue. + continuation_prompt TEXT NOT NULL DEFAULT 'Continue.', + -- Safety cap on automatic re-prompts before the loop gives up (0 = no cap). + max_attempts INTEGER NOT NULL DEFAULT 50, + -- Automatic re-prompts delivered so far (reset when re-enabled). + attempts_used INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE +); + +-- Pending / delivered wake-ups. A scheduled re-prompt for a workspace at a +-- specific time. `manual` rows are user-created ("ping at 05:00 UTC"); the +-- others are created by the supervisor on detecting a limit banner. +CREATE TABLE IF NOT EXISTS scheduled_wakeups ( + id BLOB PRIMARY KEY, + workspace_id BLOB NOT NULL, + -- When to fire (SQLite datetime string, UTC). + fire_at TEXT NOT NULL, + -- 'rate_limit_retry' | 'usage_limit_wake' | 'manual'. + kind TEXT NOT NULL, + -- Message to deliver; NULL falls back to the policy's continuation_prompt. + prompt TEXT, + -- Which automatic attempt this is (for the cap / display). + attempt INTEGER NOT NULL DEFAULT 1, + -- Set when the wake-up has been delivered (or skipped); NULL = pending. + fired_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_scheduled_wakeups_due + ON scheduled_wakeups (fired_at, fire_at); +CREATE INDEX IF NOT EXISTS idx_scheduled_wakeups_workspace + ON scheduled_wakeups (workspace_id); diff --git a/crates/db/src/models/loop_automation.rs b/crates/db/src/models/loop_automation.rs new file mode 100644 index 0000000000..8a813dd0f6 --- /dev/null +++ b/crates/db/src/models/loop_automation.rs @@ -0,0 +1,378 @@ +//! Agentic-loop automation persistence. +//! +//! Two entities keep a workspace's CLI agent going when a chat stops for a +//! non-completion reason: +//! - [`LoopAutomation`] — the per-workspace, opt-in policy (default OFF). +//! - [`ScheduledWakeup`] — a pending/delivered re-prompt at a specific time, +//! created by the supervisor on detecting a limit banner, or by the user +//! ("ping at 05:00 UTC"). + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::SqlitePool; +use ts_rs::TS; +use uuid::Uuid; + +/// Why a wake-up was scheduled. Stored as a snake_case string. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export)] +pub enum WakeupKind { + /// Transient "Server is temporarily limiting requests · Rate limited" — a + /// short backoff retry (every `retry_interval_secs`). + RateLimitRetry, + /// A usage-window limit ("usage limit reached") — wake at the reset time. + UsageLimitWake, + /// User-scheduled ("ping at 05:00 UTC", next-day). + Manual, +} + +impl WakeupKind { + pub fn as_str(&self) -> &'static str { + match self { + WakeupKind::RateLimitRetry => "rate_limit_retry", + WakeupKind::UsageLimitWake => "usage_limit_wake", + WakeupKind::Manual => "manual", + } + } + + pub fn parse(s: &str) -> Self { + match s { + "rate_limit_retry" => WakeupKind::RateLimitRetry, + "usage_limit_wake" => WakeupKind::UsageLimitWake, + _ => WakeupKind::Manual, + } + } +} + +/// Per-workspace loop-automation policy. A row exists only once the user has +/// touched the toggle; absence means disabled (the default). +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct LoopAutomation { + pub workspace_id: Uuid, + pub enabled: bool, + pub retry_interval_secs: i64, + pub continuation_prompt: String, + pub max_attempts: i64, + pub attempts_used: i64, + #[ts(type = "string")] + pub updated_at: DateTime, +} + +impl LoopAutomation { + /// The effective policy for a workspace, or `None` when never configured + /// (treated as disabled). + pub async fn get(pool: &SqlitePool, workspace_id: Uuid) -> Result, sqlx::Error> { + let row = sqlx::query!( + r#"SELECT + workspace_id as "workspace_id!: Uuid", + enabled, + retry_interval_secs, + continuation_prompt, + max_attempts, + attempts_used, + updated_at as "updated_at!: DateTime" + FROM loop_automations + WHERE workspace_id = $1"#, + workspace_id + ) + .fetch_optional(pool) + .await?; + + Ok(row.map(|r| Self { + workspace_id: r.workspace_id, + enabled: r.enabled != 0, + retry_interval_secs: r.retry_interval_secs, + continuation_prompt: r.continuation_prompt, + max_attempts: r.max_attempts, + attempts_used: r.attempts_used, + updated_at: r.updated_at, + })) + } + + /// Create or replace a workspace's policy. Editing the settings re-arms the + /// loop (attempts_used reset to 0). + pub async fn upsert( + pool: &SqlitePool, + workspace_id: Uuid, + enabled: bool, + retry_interval_secs: i64, + continuation_prompt: &str, + max_attempts: i64, + ) -> Result { + let enabled_i = i64::from(enabled); + sqlx::query!( + r#"INSERT INTO loop_automations + (workspace_id, enabled, retry_interval_secs, continuation_prompt, + max_attempts, attempts_used, updated_at) + VALUES ($1, $2, $3, $4, $5, 0, datetime('now', 'subsec')) + ON CONFLICT(workspace_id) DO UPDATE SET + enabled = excluded.enabled, + retry_interval_secs = excluded.retry_interval_secs, + continuation_prompt = excluded.continuation_prompt, + max_attempts = excluded.max_attempts, + attempts_used = 0, + updated_at = excluded.updated_at"#, + workspace_id, + enabled_i, + retry_interval_secs, + continuation_prompt, + max_attempts + ) + .execute(pool) + .await?; + + Ok(Self::get(pool, workspace_id).await?.expect("just upserted")) + } + + /// All workspaces with the loop enabled (the supervisor's detection set). + pub async fn list_enabled(pool: &SqlitePool) -> Result, sqlx::Error> { + let rows = sqlx::query!( + r#"SELECT + workspace_id as "workspace_id!: Uuid", + enabled, + retry_interval_secs, + continuation_prompt, + max_attempts, + attempts_used, + updated_at as "updated_at!: DateTime" + FROM loop_automations + WHERE enabled = 1"# + ) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map(|r| Self { + workspace_id: r.workspace_id, + enabled: r.enabled != 0, + retry_interval_secs: r.retry_interval_secs, + continuation_prompt: r.continuation_prompt, + max_attempts: r.max_attempts, + attempts_used: r.attempts_used, + updated_at: r.updated_at, + }) + .collect()) + } + + /// Whether the attempt cap has been reached (0 = uncapped). + pub fn cap_reached(&self) -> bool { + self.max_attempts > 0 && self.attempts_used >= self.max_attempts + } + + /// Record one delivered automatic re-prompt; returns the new count. + pub async fn increment_attempts( + pool: &SqlitePool, + workspace_id: Uuid, + ) -> Result { + let row = sqlx::query!( + r#"UPDATE loop_automations + SET attempts_used = attempts_used + 1, + updated_at = datetime('now', 'subsec') + WHERE workspace_id = $1 + RETURNING attempts_used"#, + workspace_id + ) + .fetch_one(pool) + .await?; + Ok(row.attempts_used) + } +} + +/// A pending or delivered re-prompt for a workspace's CLI agent. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ScheduledWakeup { + pub id: Uuid, + pub workspace_id: Uuid, + #[ts(type = "string")] + pub fire_at: DateTime, + pub kind: WakeupKind, + pub prompt: Option, + pub attempt: i64, + #[ts(type = "string | null")] + pub fired_at: Option>, + #[ts(type = "string")] + pub created_at: DateTime, +} + +impl ScheduledWakeup { + pub async fn create( + pool: &SqlitePool, + workspace_id: Uuid, + fire_at: DateTime, + kind: WakeupKind, + prompt: Option<&str>, + attempt: i64, + ) -> Result { + let id = Uuid::new_v4(); + let kind_s = kind.as_str(); + sqlx::query!( + r#"INSERT INTO scheduled_wakeups + (id, workspace_id, fire_at, kind, prompt, attempt, created_at) + VALUES ($1, $2, $3, $4, $5, $6, datetime('now', 'subsec'))"#, + id, + workspace_id, + fire_at, + kind_s, + prompt, + attempt + ) + .execute(pool) + .await?; + + Ok(Self::find_by_id(pool, id).await?.expect("just inserted")) + } + + pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result, sqlx::Error> { + let row = sqlx::query!( + r#"SELECT + id as "id!: Uuid", + workspace_id as "workspace_id!: Uuid", + fire_at as "fire_at!: DateTime", + kind, + prompt, + attempt, + fired_at as "fired_at?: DateTime", + created_at as "created_at!: DateTime" + FROM scheduled_wakeups WHERE id = $1"#, + id + ) + .fetch_optional(pool) + .await?; + Ok(row.map(|r| Self { + id: r.id, + workspace_id: r.workspace_id, + fire_at: r.fire_at, + kind: WakeupKind::parse(&r.kind), + prompt: r.prompt, + attempt: r.attempt, + fired_at: r.fired_at, + created_at: r.created_at, + })) + } + + /// Pending wake-ups whose fire time has passed (the supervisor's deliver set). + pub async fn list_due(pool: &SqlitePool) -> Result, sqlx::Error> { + let rows = sqlx::query!( + r#"SELECT + id as "id!: Uuid", + workspace_id as "workspace_id!: Uuid", + fire_at as "fire_at!: DateTime", + kind, + prompt, + attempt, + fired_at as "fired_at?: DateTime", + created_at as "created_at!: DateTime" + FROM scheduled_wakeups + WHERE fired_at IS NULL AND datetime(fire_at) <= datetime('now') + ORDER BY fire_at ASC"# + ) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|r| Self { + id: r.id, + workspace_id: r.workspace_id, + fire_at: r.fire_at, + kind: WakeupKind::parse(&r.kind), + prompt: r.prompt, + attempt: r.attempt, + fired_at: r.fired_at, + created_at: r.created_at, + }) + .collect()) + } + + /// Whether a pending wake-up of this kind already exists (scheduling dedupe). + pub async fn has_pending( + pool: &SqlitePool, + workspace_id: Uuid, + kind: WakeupKind, + ) -> Result { + let kind_s = kind.as_str(); + let row = sqlx::query!( + r#"SELECT COUNT(*) as "count!: i64" + FROM scheduled_wakeups + WHERE workspace_id = $1 AND kind = $2 AND fired_at IS NULL"#, + workspace_id, + kind_s + ) + .fetch_one(pool) + .await?; + Ok(row.count > 0) + } + + /// Wake-ups for a workspace (for the UI / status). Pending first. + pub async fn list_for_workspace( + pool: &SqlitePool, + workspace_id: Uuid, + include_fired: bool, + ) -> Result, sqlx::Error> { + let rows = sqlx::query!( + r#"SELECT + id as "id!: Uuid", + workspace_id as "workspace_id!: Uuid", + fire_at as "fire_at!: DateTime", + kind, + prompt, + attempt, + fired_at as "fired_at?: DateTime", + created_at as "created_at!: DateTime" + FROM scheduled_wakeups + WHERE workspace_id = $1 AND ($2 OR fired_at IS NULL) + ORDER BY fired_at IS NOT NULL, fire_at ASC"#, + workspace_id, + include_fired + ) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|r| Self { + id: r.id, + workspace_id: r.workspace_id, + fire_at: r.fire_at, + kind: WakeupKind::parse(&r.kind), + prompt: r.prompt, + attempt: r.attempt, + fired_at: r.fired_at, + created_at: r.created_at, + }) + .collect()) + } + + pub async fn mark_fired(pool: &SqlitePool, id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query!( + r#"UPDATE scheduled_wakeups SET fired_at = datetime('now', 'subsec') WHERE id = $1"#, + id + ) + .execute(pool) + .await?; + Ok(()) + } + + pub async fn delete(pool: &SqlitePool, id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query!(r#"DELETE FROM scheduled_wakeups WHERE id = $1"#, id) + .execute(pool) + .await?; + Ok(()) + } + + /// Drop all pending wake-ups for a workspace (e.g. when the loop is disabled). + pub async fn delete_pending_for_workspace( + pool: &SqlitePool, + workspace_id: Uuid, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + r#"DELETE FROM scheduled_wakeups WHERE workspace_id = $1 AND fired_at IS NULL"#, + workspace_id + ) + .execute(pool) + .await?; + Ok(()) + } +} diff --git a/crates/db/src/models/mod.rs b/crates/db/src/models/mod.rs index 9f351f850c..677c05c61c 100644 --- a/crates/db/src/models/mod.rs +++ b/crates/db/src/models/mod.rs @@ -3,6 +3,7 @@ pub mod execution_process; pub mod execution_process_logs; pub mod execution_process_repo_state; pub mod file; +pub mod loop_automation; pub mod merge; pub mod project; pub mod pull_request; diff --git a/crates/executors/src/executors/amp.rs b/crates/executors/src/executors/amp.rs index 51705218dd..e0f4e4a526 100644 --- a/crates/executors/src/executors/amp.rs +++ b/crates/executors/src/executors/amp.rs @@ -13,6 +13,7 @@ use crate::{ executors::{ AppendPrompt, BaseCodingAgent, ExecutorError, SpawnedChild, StandardCodingAgentExecutor, claude::{ClaudeLogProcessor, HistoryStrategy}, + cli::{CliContinue, CliLaunchSpec, CliPromptArg, CliResume}, }, logs::{stderr_processor::normalize_stderr_logs, utils::EntryIndexProvider}, profile::ExecutorConfig, @@ -45,6 +46,24 @@ impl Amp { #[async_trait] impl StandardCodingAgentExecutor for Amp { + fn interactive_cli_spec(&self, _cwd: &Path) -> Option { + // Amp selects the model via a mode, not a raw id, and its autonomy is + // settings-driven (`amp.dangerouslyAllowAll`), so there are no model / + // autonomy launch flags. `--no-ide` keeps it from attaching to an IDE in + // a headless pane. The first message is piped on stdin (Amp stays + // interactive while stdout is a TTY); a thread is resumed via the + // `threads continue` subcommand. + let args = vec!["--no-ide".to_string()]; + Some( + CliLaunchSpec::new("amp", args) + .with_prompt_arg(CliPromptArg::StdinPipe) + .with_resume(CliResume::Subcommand("threads continue".to_string())) + .with_continue(CliContinue::ResumeLast { + subcommand: "threads continue".to_string(), + }), + ) + } + async fn spawn( &self, current_dir: &Path, diff --git a/crates/executors/src/executors/claude.rs b/crates/executors/src/executors/claude.rs index 807196cd9b..2fb0bd12d4 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 0000000000..576164c91d --- /dev/null +++ b/crates/executors/src/executors/cli.rs @@ -0,0 +1,110 @@ +//! 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. gemini/copilot `-i ''`). + Flag(String), + /// First message piped on stdin: `printf '%s\n' '' | + /// ` (e.g. amp, which stays interactive as long as stdout is a + /// TTY — which the tmux pane provides). + StdinPipe, + /// 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 db8c5ea7cf..14405fe825 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/copilot.rs b/crates/executors/src/executors/copilot.rs index 1f33f7c2ed..ea66855b62 100644 --- a/crates/executors/src/executors/copilot.rs +++ b/crates/executors/src/executors/copilot.rs @@ -16,6 +16,7 @@ use crate::{ executors::{ AppendPrompt, AvailabilityInfo, BaseCodingAgent, ExecutorError, SpawnedChild, StandardCodingAgentExecutor, + cli::{CliContinue, CliLaunchSpec, CliPromptArg, CliResume}, }, logs::utils::patch, model_selector::{ModelInfo, ModelSelectorConfig, PermissionPolicy}, @@ -104,6 +105,42 @@ impl StandardCodingAgentExecutor for Copilot { } } + fn interactive_cli_spec(&self, _cwd: &Path) -> Option { + let mut args = Vec::new(); + if let Some(model) = self.model.as_deref().filter(|m| !m.is_empty()) { + args.push("--model".to_string()); + args.push(model.to_string()); + } + // `--allow-all-tools` runs without per-tool approval (autonomous); + // Supervised disables it. Honor any explicit allow/deny/add-dir too. + if self.allow_all_tools != Some(false) { + args.push("--allow-all-tools".to_string()); + } + if let Some(tool) = self.allow_tool.as_deref().filter(|t| !t.is_empty()) { + args.push("--allow-tool".to_string()); + args.push(tool.to_string()); + } + if let Some(tool) = self.deny_tool.as_deref().filter(|t| !t.is_empty()) { + args.push("--deny-tool".to_string()); + args.push(tool.to_string()); + } + if let Some(dirs) = &self.add_dir { + for dir in dirs.iter().filter(|d| !d.is_empty()) { + args.push("--add-dir".to_string()); + args.push(dir.to_string()); + } + } + // (Copilot's startup banner is off by default — there's no `--no-banner`; + // `--banner` would opt back in.) + // `-i` starts interactive and submits the first prompt. + Some( + CliLaunchSpec::new("copilot", args) + .with_prompt_arg(CliPromptArg::Flag("-i".to_string())) + .with_resume(CliResume::Flag("--resume".to_string())) + .with_continue(CliContinue::Flag("--continue".to_string())), + ) + } + async fn spawn( &self, current_dir: &Path, diff --git a/crates/executors/src/executors/cursor.rs b/crates/executors/src/executors/cursor.rs index fd3eefde86..843ca5fd3b 100644 --- a/crates/executors/src/executors/cursor.rs +++ b/crates/executors/src/executors/cursor.rs @@ -22,6 +22,7 @@ use crate::{ executors::{ AppendPrompt, AvailabilityInfo, BaseCodingAgent, ExecutorError, SpawnedChild, StandardCodingAgentExecutor, + cli::{CliContinue, CliLaunchSpec, CliPromptArg, CliResume}, }, logs::{ ActionType, FileChange, NormalizedEntry, NormalizedEntryError, NormalizedEntryType, @@ -183,6 +184,27 @@ impl StandardCodingAgentExecutor for CursorAgent { } } + fn interactive_cli_spec(&self, _cwd: &Path) -> Option { + let mut args = Vec::new(); + if let Some(model) = self.model.as_deref().filter(|m| !m.is_empty()) { + // Cursor encodes reasoning in the model id (e.g. `sonnet-4.5-thinking`); + // reuse the same resolver the headless path uses. + let resolved = resolve_cursor_model_name(model, self.reasoning.as_deref()); + args.push("--model".to_string()); + args.push(resolved.to_string()); + } + // `--force` auto-runs commands (autonomous); Supervised disables it. + if self.force != Some(false) { + args.push("--force".to_string()); + } + Some( + CliLaunchSpec::new("cursor-agent", args) + .with_prompt_arg(CliPromptArg::Positional) + .with_resume(CliResume::Flag("--resume".to_string())) + .with_continue(CliContinue::Flag("--continue".to_string())), + ) + } + async fn spawn( &self, current_dir: &Path, diff --git a/crates/executors/src/executors/droid.rs b/crates/executors/src/executors/droid.rs index cdb4757f0d..f83cba3dbd 100644 --- a/crates/executors/src/executors/droid.rs +++ b/crates/executors/src/executors/droid.rs @@ -15,6 +15,7 @@ use crate::{ executors::{ AppendPrompt, AvailabilityInfo, BaseCodingAgent, ExecutorError, SpawnedChild, StandardCodingAgentExecutor, + cli::{CliContinue, CliLaunchSpec, CliPromptArg, CliResume}, }, logs::utils::{EntryIndexProvider, patch}, model_selector::{ModelInfo, ModelSelectorConfig}, @@ -155,6 +156,38 @@ impl StandardCodingAgentExecutor for Droid { } } + fn interactive_cli_spec(&self, _cwd: &Path) -> Option { + // droid's interactive TUI (verified on v0.161) only exposes the autonomy + // level — model and reasoning effort are NOT launch flags there (they're + // `droid exec`-only / set in-TUI via `/model`), so the spec carries only + // `--auto`. The positional prompt seeds the first message. + let mut args = Vec::new(); + match self.autonomy { + Autonomy::Low => { + args.push("--auto".to_string()); + args.push("low".to_string()); + } + Autonomy::Medium => { + args.push("--auto".to_string()); + args.push("medium".to_string()); + } + // `high` is the most autonomous interactive level; the headless-only + // SkipPermissionsUnsafe maps onto it for the loop. + Autonomy::High | Autonomy::SkipPermissionsUnsafe => { + args.push("--auto".to_string()); + args.push("high".to_string()); + } + Autonomy::Normal => {} + } + // `--resume` with no id continues the most recent session. + Some( + CliLaunchSpec::new("droid", args) + .with_prompt_arg(CliPromptArg::Positional) + .with_resume(CliResume::Flag("--resume".to_string())) + .with_continue(CliContinue::Flag("--resume".to_string())), + ) + } + async fn spawn( &self, current_dir: &Path, diff --git a/crates/executors/src/executors/gemini.rs b/crates/executors/src/executors/gemini.rs index 8d6cb38b32..36b079af5c 100644 --- a/crates/executors/src/executors/gemini.rs +++ b/crates/executors/src/executors/gemini.rs @@ -16,6 +16,7 @@ use crate::{ executors::{ AppendPrompt, AvailabilityInfo, BaseCodingAgent, ExecutorError, SpawnedChild, StandardCodingAgentExecutor, + cli::{CliLaunchSpec, CliPromptArg, CliResume}, }, logs::utils::patch, model_selector::{ModelInfo, ModelSelectorConfig, PermissionPolicy}, @@ -81,6 +82,28 @@ impl StandardCodingAgentExecutor for Gemini { self.approvals = Some(approvals); } + fn interactive_cli_spec(&self, _cwd: &Path) -> Option { + let mut args = Vec::new(); + if let Some(model) = self.model.as_deref().filter(|m| !m.is_empty()) { + args.push("--model".to_string()); + args.push(model.to_string()); + } + // Autonomous by default for the app-created worktree; Supervised + // (yolo == Some(false)) leaves the default approval mode in place. + if self.yolo != Some(false) { + args.push("--approval-mode".to_string()); + args.push("yolo".to_string()); + } + // `-i` starts interactive AND submits the first prompt; `--resume ` + // rejoins a session. Gemini has no `--continue`, so the fallback is a + // fresh TUI. + Some( + CliLaunchSpec::new("gemini", args) + .with_prompt_arg(CliPromptArg::Flag("-i".to_string())) + .with_resume(CliResume::Flag("--resume".to_string())), + ) + } + async fn spawn( &self, current_dir: &Path, diff --git a/crates/executors/src/executors/mod.rs b/crates/executors/src/executors/mod.rs index 1126d09179..2ea827f0e9 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, @@ -421,3 +436,69 @@ mod tests { assert_eq!(result.unwrap(), BaseCodingAgent::CursorAgent); } } + +#[cfg(test)] +mod cli_spec_tests { + use std::path::Path; + + use super::{BaseCodingAgent, StandardCodingAgentExecutor}; + use crate::profile::{ExecutorConfigs, ExecutorProfileId}; + + fn default_spec_args(agent: BaseCodingAgent) -> Option<(String, Vec)> { + ExecutorConfigs::from_defaults() + .get_coding_agent_or_default(&ExecutorProfileId::new(agent)) + .interactive_cli_spec(Path::new("/tmp")) + .map(|s| (s.program, s.base_args)) + } + + /// Every supported agent advertises an interactive CLI launch via its + /// default profile, with the expected binary name. + #[test] + fn every_agent_has_a_cli_spec_with_the_right_binary() { + use BaseCodingAgent::*; + for (agent, program) in [ + (ClaudeCode, "claude"), + (Codex, "codex"), + (Gemini, "gemini"), + (QwenCode, "qwen"), + (Opencode, "opencode"), + (CursorAgent, "cursor-agent"), + (Droid, "droid"), + (Amp, "amp"), + (Copilot, "copilot"), + ] { + let got = default_spec_args(agent); + assert_eq!( + got.as_ref().map(|(p, _)| p.as_str()), + Some(program), + "{agent:?} should launch `{program}`" + ); + } + } + + /// CLI mode runs each agent autonomously by default (worktrees are + /// app-created), so an unattended loop isn't stalled by approval prompts. + #[test] + fn agents_default_to_autonomous_flags() { + use BaseCodingAgent::*; + let has = |agent: BaseCodingAgent, needle: &str| { + default_spec_args(agent) + .map(|(_, args)| args.iter().any(|a| a == needle)) + .unwrap_or(false) + }; + assert!( + has(Gemini, "yolo"), + "gemini should default to yolo approval" + ); + assert!( + has(QwenCode, "yolo"), + "qwen should default to yolo approval" + ); + assert!(has(Copilot, "--allow-all-tools")); + assert!(has(CursorAgent, "--force")); + assert!( + has(Droid, "--skip-permissions-unsafe") || has(Droid, "--auto"), + "droid should default to an autonomous autonomy level" + ); + } +} diff --git a/crates/executors/src/executors/opencode.rs b/crates/executors/src/executors/opencode.rs index 9d33b58243..22c8f04f2f 100644 --- a/crates/executors/src/executors/opencode.rs +++ b/crates/executors/src/executors/opencode.rs @@ -19,7 +19,9 @@ use crate::{ executors::{ AppendPrompt, AvailabilityInfo, BaseCodingAgent, ExecutorError, ExecutorExitResult, SlashCommandDescription, SpawnedChild, StandardCodingAgentExecutor, - opencode::types::OpencodeExecutorEvent, utils::reorder_slash_commands, + cli::{CliContinue, CliLaunchSpec, CliPromptArg, CliResume}, + opencode::types::OpencodeExecutorEvent, + utils::reorder_slash_commands, }, logs::utils::patch, model_selector::{AgentInfo, ModelInfo, ModelProvider, PermissionPolicy, ReasoningOption}, @@ -411,6 +413,24 @@ impl StandardCodingAgentExecutor for Opencode { self.approvals = Some(approvals); } + fn interactive_cli_spec(&self, _cwd: &Path) -> Option { + let mut args = Vec::new(); + if let Some(model) = self.model.as_deref().filter(|m| !m.is_empty()) { + args.push("-m".to_string()); + args.push(model.to_string()); + } + // opencode's stable TUI has no autonomy flag — auto-approve is driven by + // the `permission` block in opencode.json (see the install plan), so + // there's nothing to emit here. `--prompt` seeds the first message; `-s` + // resumes a session id; `-c` continues the last session. + Some( + CliLaunchSpec::new("opencode", args) + .with_prompt_arg(CliPromptArg::Flag("--prompt".to_string())) + .with_resume(CliResume::Flag("--session".to_string())) + .with_continue(CliContinue::Flag("--continue".to_string())), + ) + } + async fn spawn( &self, current_dir: &Path, diff --git a/crates/executors/src/executors/qwen.rs b/crates/executors/src/executors/qwen.rs index 141f30ff5d..b75ff549be 100644 --- a/crates/executors/src/executors/qwen.rs +++ b/crates/executors/src/executors/qwen.rs @@ -14,7 +14,9 @@ use crate::{ executor_discovery::ExecutorDiscoveredOptions, executors::{ AppendPrompt, AvailabilityInfo, BaseCodingAgent, ExecutorError, SpawnedChild, - StandardCodingAgentExecutor, gemini::AcpAgentHarness, + StandardCodingAgentExecutor, + cli::{CliContinue, CliLaunchSpec, CliPromptArg, CliResume}, + gemini::AcpAgentHarness, }, logs::utils::patch, model_selector::{ModelSelectorConfig, PermissionPolicy}, @@ -78,6 +80,26 @@ impl StandardCodingAgentExecutor for QwenCode { self.approvals = Some(approvals); } + fn interactive_cli_spec(&self, _cwd: &Path) -> Option { + let mut args = Vec::new(); + if let Some(model) = self.model.as_deref().filter(|m| !m.is_empty()) { + args.push("--model".to_string()); + args.push(model.to_string()); + } + // Autonomous by default; Supervised (yolo == Some(false)) leaves the + // default approval mode. + if self.yolo != Some(false) { + args.push("--approval-mode".to_string()); + args.push("yolo".to_string()); + } + Some( + CliLaunchSpec::new("qwen", args) + .with_prompt_arg(CliPromptArg::Flag("-i".to_string())) + .with_resume(CliResume::Flag("--resume".to_string())) + .with_continue(CliContinue::Flag("--continue".to_string())), + ) + } + async fn spawn( &self, current_dir: &Path, diff --git a/crates/local-deployment/Cargo.toml b/crates/local-deployment/Cargo.toml index 867a32fd84..ffeb017eb9 100644 --- a/crates/local-deployment/Cargo.toml +++ b/crates/local-deployment/Cargo.toml @@ -39,6 +39,9 @@ tokio = { workspace = true } globwalk = "0.9" portable-pty = "0.8" dirs = "5.0" +toml = "0.8" +chrono = { version = "0.4", features = ["serde"] } +sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "chrono", "uuid"] } [build-dependencies] dotenv = "0.15" diff --git a/crates/local-deployment/src/lib.rs b/crates/local-deployment/src/lib.rs index 4498c2f179..faa09c41a3 100644 --- a/crates/local-deployment/src/lib.rs +++ b/crates/local-deployment/src/lib.rs @@ -47,6 +47,7 @@ pub mod cli_activity; mod command; pub mod container; mod copy; +pub mod loop_supervisor; pub mod pty; #[derive(Clone)] @@ -270,6 +271,10 @@ impl Deployment for LocalDeployment { // (Running / Needs Attention buckets in the sidebar). cli_activity::CliActivityMonitor::spawn(db.clone()); + // Keep opted-in CLI loops going across usage/rate limits: detect the + // limit banner, schedule a wake-up, and re-prompt the agent. + loop_supervisor::LoopSupervisor::spawn(db.clone()); + let deployment = Self { config, user_id, diff --git a/crates/local-deployment/src/loop_supervisor.rs b/crates/local-deployment/src/loop_supervisor.rs new file mode 100644 index 0000000000..338295f498 --- /dev/null +++ b/crates/local-deployment/src/loop_supervisor.rs @@ -0,0 +1,394 @@ +//! Agentic-loop supervisor — keeps a workspace's CLI agent going when a chat +//! stops for a NON-completion reason. +//! +//! CLI pane output is ephemeral (streamed to the browser, never persisted), so a +//! background poller is the only way to notice that an agent stalled on a usage +//! or rate limit. Each tick this: +//! +//! 1. **Delivers** any due [`ScheduledWakeup`] by typing the continuation prompt +//! into the live tmux pane (or re-parking it if the session died). +//! 2. **Detects** limit banners in every enabled workspace's pane and schedules +//! a wake-up: a short retry for a transient rate limit, or a wake at the +//! parsed reset time for a usage-window limit. +//! +//! Opt-in per workspace (default OFF). Kill-switch: `DISABLE_LOOP_AUTOMATION`. +//! Idle-only re-prompting falls out of the design: a limit-kind wake-up is only +//! delivered while the limit banner is still present, so a manually-resumed +//! agent is never interrupted. + +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use db::{ + DBService, + models::{ + loop_automation::{LoopAutomation, ScheduledWakeup, WakeupKind}, + session::Session, + }, +}; + +use crate::pty::{capture_cli_pane, cli_tmux_available, cli_tmux_session_exists, send_cli_keys}; + +/// How often to poll panes for limit banners and check for due wake-ups. +const POLL_INTERVAL: Duration = Duration::from_secs(20); + +/// Fallback wake delay for a usage-window limit whose reset time couldn't be +/// parsed from the banner — re-detection at wake time reschedules if still +/// limited, so this only bounds how often we re-check during a long window. +const USAGE_BACKOFF_SECS: i64 = 1800; // 30 minutes + +/// Env kill-switch (mirrors `DISABLE_CLI_SESSION_REAP`). +const DISABLE_ENV: &str = "DISABLE_LOOP_AUTOMATION"; + +/// Default continuation prompt when neither the wake-up nor the policy set one. +const DEFAULT_CONTINUATION: &str = "Continue."; + +/// What a CLI pane's tail tells us about why the agent stopped. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LimitSignal { + /// Transient provider throttling ("temporarily limiting requests · Rate + /// limited") — retry after a short backoff. + RateLimited, + /// A usage-window limit ("reached your usage limit") — wake at the reset + /// time when we can parse one. + UsageLimited { reset_at: Option> }, +} + +/// Classify a CLI pane's visible content. Rate-limit is checked first because +/// the provider's rate-limit banner explicitly says "(not your usage limit)", +/// which would otherwise trip the usage-limit matcher. +pub fn detect_limit(pane: &str) -> Option { + detect_limit_at(pane, Utc::now()) +} + +fn detect_limit_at(pane: &str, now: DateTime) -> Option { + let lower = pane.to_lowercase(); + + // Transient rate limit — the user's exact phrasing: "Server is temporarily + // limiting requests (not your usage limit) · Rate limited". + if lower.contains("temporarily limiting requests") || lower.contains("rate limited") { + return Some(LimitSignal::RateLimited); + } + + // Usage-window limit (5-hour / weekly). Checked after rate-limit so the + // "(not your usage limit)" disclaimer never lands here. + if lower.contains("usage limit") + || lower.contains("limit reached") + || lower.contains("limit will reset") + || lower.contains("5-hour limit") + || lower.contains("weekly limit") + || lower.contains("out of usage") + { + return Some(LimitSignal::UsageLimited { + reset_at: parse_reset_at(pane, now), + }); + } + + None +} + +/// Best-effort parse of a reset time ("resets at 3:45pm", "limit will reset at +/// 23:00") into the next occurrence of that clock time at-or-after `now`. The +/// banner's timezone isn't reliably stated, so the clock time is interpreted as +/// UTC; an imperfect guess self-corrects because re-detection at wake time +/// reschedules while the limit persists. Returns None when no time is found. +fn parse_reset_at(text: &str, now: DateTime) -> Option> { + let lower = text.to_lowercase(); + let anchor = lower.find("reset")?; + let (hour, minute) = parse_clock_after(&lower[anchor..])?; + + let today = now.date_naive().and_hms_opt(hour, minute, 0)?.and_utc(); + let fire = if today <= now { + today + chrono::Duration::days(1) + } else { + today + }; + // Wake a touch after the stated reset so the window has actually rolled. + Some(fire + chrono::Duration::seconds(60)) +} + +/// Scan for the first `H`, `H:MM`, optionally `am`/`pm` token in `s` and return +/// `(hour24, minute)`. Returns None if nothing time-like is found or it's out of +/// range. +fn parse_clock_after(s: &str) -> Option<(u32, u32)> { + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() && !bytes[i].is_ascii_digit() { + i += 1; + } + if i >= bytes.len() { + return None; + } + + // Hour (1-2 digits). + let h_start = i; + while i < bytes.len() && bytes[i].is_ascii_digit() && i - h_start < 2 { + i += 1; + } + let mut hour: u32 = s[h_start..i].parse().ok()?; + + // Optional ":MM". + let mut minute: u32 = 0; + if i < bytes.len() && bytes[i] == b':' { + let m_start = i + 1; + let mut j = m_start; + while j < bytes.len() && bytes[j].is_ascii_digit() && j - m_start < 2 { + j += 1; + } + if j > m_start { + minute = s[m_start..j].parse().ok()?; + i = j; + } + } + + // Optional am/pm (allowing a space and "a.m."-style dots). + let rest: String = s[i..] + .chars() + .take(6) + .filter(|c| !c.is_whitespace() && *c != '.') + .collect(); + if rest.starts_with("pm") { + if hour < 12 { + hour += 12; + } + } else if rest.starts_with("am") && hour == 12 { + hour = 0; + } + + if hour > 23 || minute > 59 { + return None; + } + Some((hour, minute)) +} + +pub struct LoopSupervisor; + +impl LoopSupervisor { + pub fn spawn(db: DBService) { + tokio::spawn(async move { + if std::env::var(DISABLE_ENV).is_ok() { + tracing::info!("{DISABLE_ENV} set; loop automation supervisor disabled"); + return; + } + if !cli_tmux_available() { + tracing::debug!("tmux unavailable; loop automation supervisor not started"); + return; + } + + let mut interval = tokio::time::interval(POLL_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + interval.tick().await; + if let Err(e) = tick(&db).await { + tracing::warn!("loop automation tick failed: {e}"); + } + } + }); + } +} + +async fn tick(db: &DBService) -> Result<(), sqlx::Error> { + let now = Utc::now(); + deliver_due_wakeups(db, now).await?; + detect_and_schedule(db, now).await?; + Ok(()) +} + +/// Deliver (or skip) every wake-up whose fire time has passed. +async fn deliver_due_wakeups(db: &DBService, _now: DateTime) -> Result<(), sqlx::Error> { + let pool = &db.pool; + for wakeup in ScheduledWakeup::list_due(pool).await? { + let wid = wakeup.workspace_id; + let policy = LoopAutomation::get(pool, wid).await.unwrap_or(None); + let is_limit = !matches!(wakeup.kind, WakeupKind::Manual); + + // A limit-kind wake-up only fires while the loop is still enabled and + // under its attempt cap (the user may have disabled it since). + if is_limit + && !policy + .as_ref() + .map(|p| p.enabled && !p.cap_reached()) + .unwrap_or(false) + { + ScheduledWakeup::mark_fired(pool, wakeup.id).await?; + continue; + } + + let prompt = wakeup + .prompt + .clone() + .or_else(|| policy.as_ref().map(|p| p.continuation_prompt.clone())) + .unwrap_or_else(|| DEFAULT_CONTINUATION.to_string()); + + // The tmux session may have been reaped before a long usage-window wake + // came due — re-park the prompt so the next attach delivers it. + if !cli_tmux_session_exists(wid).await { + if let Ok(Some(session)) = Session::find_latest_by_workspace_id(pool, wid).await { + let _ = Session::set_pending_cli_prompt(pool, session.id, &prompt).await; + tracing::info!("loop: workspace {wid} session gone; re-parked wake-up prompt"); + } + ScheduledWakeup::mark_fired(pool, wakeup.id).await?; + continue; + } + + // For a limit-kind wake, only re-prompt if the limit banner is STILL + // showing — a manually-resumed agent must not be interrupted. Manual + // wake-ups are the user's explicit intent and always deliver. + if is_limit { + let still_limited = capture_cli_pane(wid) + .await + .map(|pane| detect_limit(&pane).is_some()) + .unwrap_or(false); + if !still_limited { + ScheduledWakeup::mark_fired(pool, wakeup.id).await?; + continue; + } + } + + if send_cli_keys(wid, &prompt).await { + if is_limit { + let _ = LoopAutomation::increment_attempts(pool, wid).await; + } + tracing::info!( + "loop: delivered {} wake-up to workspace {wid}", + wakeup.kind.as_str() + ); + } else { + tracing::warn!("loop: failed to deliver wake-up to workspace {wid}"); + } + ScheduledWakeup::mark_fired(pool, wakeup.id).await?; + } + Ok(()) +} + +/// Scan each enabled workspace's pane and schedule a wake-up on a fresh limit. +async fn detect_and_schedule(db: &DBService, now: DateTime) -> Result<(), sqlx::Error> { + let pool = &db.pool; + for policy in LoopAutomation::list_enabled(pool).await? { + if policy.cap_reached() { + continue; + } + let wid = policy.workspace_id; + if !cli_tmux_session_exists(wid).await { + continue; + } + let Some(pane) = capture_cli_pane(wid).await else { + continue; + }; + + match detect_limit_at(&pane, now) { + Some(LimitSignal::RateLimited) => { + if !ScheduledWakeup::has_pending(pool, wid, WakeupKind::RateLimitRetry).await? { + let fire_at = + now + chrono::Duration::seconds(policy.retry_interval_secs.max(1)); + ScheduledWakeup::create( + pool, + wid, + fire_at, + WakeupKind::RateLimitRetry, + None, + policy.attempts_used + 1, + ) + .await?; + tracing::info!("loop: rate limit on workspace {wid}; retry at {fire_at}"); + } + } + Some(LimitSignal::UsageLimited { reset_at }) => { + if !ScheduledWakeup::has_pending(pool, wid, WakeupKind::UsageLimitWake).await? { + let fire_at = reset_at + .unwrap_or_else(|| now + chrono::Duration::seconds(USAGE_BACKOFF_SECS)); + ScheduledWakeup::create( + pool, + wid, + fire_at, + WakeupKind::UsageLimitWake, + None, + policy.attempts_used + 1, + ) + .await?; + tracing::info!("loop: usage limit on workspace {wid}; wake at {fire_at}"); + } + } + None => {} + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use chrono::TimeZone; + + use super::*; + + fn at(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> DateTime { + Utc.with_ymd_and_hms(y, mo, d, h, mi, 0).unwrap() + } + + #[test] + fn rate_limit_banner_is_classified_first() { + let pane = "API Error: Server is temporarily limiting requests (not your usage limit) · Rate limited"; + assert_eq!(detect_limit(pane), Some(LimitSignal::RateLimited)); + } + + #[test] + fn rate_limited_phrase_alone_matches() { + assert_eq!( + detect_limit("· Rate limited, retrying"), + Some(LimitSignal::RateLimited) + ); + } + + #[test] + fn usage_limit_banner_is_classified() { + let now = at(2026, 6, 30, 9, 0); + let got = detect_limit_at("You've reached your usage limit.", now); + assert!(matches!(got, Some(LimitSignal::UsageLimited { .. }))); + } + + #[test] + fn normal_output_is_not_a_limit() { + assert_eq!(detect_limit("Running tests... 12 passed"), None); + assert_eq!(detect_limit(""), None); + } + + #[test] + fn parses_12h_reset_time_to_next_occurrence() { + // now 09:00 UTC, "resets at 3:45pm" -> today 15:45 (+60s buffer). + let now = at(2026, 6, 30, 9, 0); + let got = parse_reset_at("Your limit will reset at 3:45pm", now).unwrap(); + assert_eq!(got, at(2026, 6, 30, 15, 45) + chrono::Duration::seconds(60)); + } + + #[test] + fn parses_24h_reset_time() { + let now = at(2026, 6, 30, 9, 0); + let got = parse_reset_at("limit will reset at 23:00", now).unwrap(); + assert_eq!(got, at(2026, 6, 30, 23, 0) + chrono::Duration::seconds(60)); + } + + #[test] + fn reset_time_already_past_rolls_to_tomorrow() { + // now 16:00, "resets 3pm" -> 15:00 already past -> tomorrow 15:00. + let now = at(2026, 6, 30, 16, 0); + let got = parse_reset_at("resets 3pm", now).unwrap(); + assert_eq!(got, at(2026, 7, 1, 15, 0) + chrono::Duration::seconds(60)); + } + + #[test] + fn usage_limit_without_a_time_has_no_reset() { + let now = at(2026, 6, 30, 9, 0); + match detect_limit_at("You are out of usage for now.", now) { + Some(LimitSignal::UsageLimited { reset_at }) => assert!(reset_at.is_none()), + other => panic!("expected usage limit, got {other:?}"), + } + } + + #[test] + fn parse_clock_handles_noon_and_midnight() { + assert_eq!(parse_clock_after("at 12am"), Some((0, 0))); + assert_eq!(parse_clock_after("at 12pm"), Some((12, 0))); + assert_eq!(parse_clock_after("at 12:30pm"), Some((12, 30))); + } +} diff --git a/crates/local-deployment/src/pty.rs b/crates/local-deployment/src/pty.rs index 3f72b8e7d2..4647f84850 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; @@ -16,6 +17,10 @@ use utils::shell::get_interactive_shell; use uuid::Uuid; /// What to run on the PTY. +// `TmuxCli` carries the resolved launch spec and is the common case (CLI mode is +// the default pane); the size gap vs. the unit `Shell` variant is expected and +// boxing the hot path would only add an allocation. +#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone)] pub enum PtyCommand { /// The user's interactive shell (default side-terminal behavior). @@ -31,83 +36,162 @@ 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. gemini/copilot `-i ''`); a + // leading '-' is harmless after the flag. + CliPromptArg::Flag(flag) => format!("{base} {flag} {}", shell_single_quote(prompt)), + // Prompt piped on stdin (e.g. amp); the TUI stays interactive + // because the tmux pane keeps stdout a TTY. + CliPromptArg::StdinPipe => { + format!("printf '%s\\n' {} | {base}", 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}}""#) + // When the agent's binary isn't installed/on PATH, don't silently drop into + // a bare shell (the user picked an agent and would see no reason why) — print + // a clear, actionable notice first, then keep the pane usable as a shell so + // they can install it right there or switch agents. + let missing = match cli_install_hint(&spec.program) { + Some(hint) => format!( + "printf '\\n [!] %s is not installed or not on PATH.\\n Install: %s\\n then reopen this terminal, or pick another agent.\\n\\n' {prog} {}", + shell_single_quote(hint) + ), + None => format!("printf '\\n [!] %s is not installed or not on PATH.\\n\\n' {prog}"), + }; + + format!( + r#"if command -v {prog} >/dev/null 2>&1; then {launch}; else {missing}; fi; exec "${{SHELL:-/bin/sh}}""# + ) +} + +/// How to install each interactive-CLI agent, shown in the pane when its binary +/// isn't on PATH. Kept here (next to the bootstrap that prints it) rather than on +/// the spec so the message stays a deployment concern. +fn cli_install_hint(program: &str) -> Option<&'static str> { + Some(match program { + "claude" => "npm i -g @anthropic-ai/claude-code", + "codex" => "npm i -g @openai/codex", + "gemini" => "npm i -g @google/gemini-cli", + "qwen" => "npm i -g @qwen-code/qwen-code", + "opencode" => "npm i -g opencode-ai", + "copilot" => "npm i -g @github/copilot", + "amp" => "npm i -g @sourcegraph/amp", + "cursor-agent" => "curl https://cursor.com/install -fsS | bash", + "droid" => "curl -fsSL https://app.factory.ai/cli | sh", + _ => return None, + }) +} + +/// 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 +332,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 @@ -417,6 +703,83 @@ pub async fn kill_cli_tmux_session(workspace_id: Uuid) { } } +/// Capture the visible content of a workspace's CLI tmux pane (best-effort). +/// Returns `None` when tmux is down or the session doesn't exist. The loop +/// supervisor uses this to spot usage/rate-limit banners and to tell whether +/// the agent is idle — CLI pane output is otherwise ephemeral (streamed to the +/// browser, never persisted), so this is the only server-side view of it. +pub async fn capture_cli_pane(workspace_id: Uuid) -> Option { + if !tmux_available() { + return None; + } + // Pane-targeting commands (capture-pane / send-keys) reject the `=exact` + // session-target syntax; they take a pane target. The full 32-hex session + // name can't be a prefix of any other `vk_*` session, so the bare name + // resolves unambiguously to this session's (sole) pane. + let session_name = cli_tmux_session_name(workspace_id); + let output = tokio::process::Command::new("tmux") + .args([ + "-L", + CLI_TMUX_SOCKET, + "capture-pane", + "-p", + "-t", + &session_name, + ]) + .stderr(std::process::Stdio::null()) + .output() + .await + .ok()?; + if !output.status.success() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +/// Type `text` into a workspace's CLI tmux pane and submit it (Enter), as if the +/// user typed it. This is the only way to re-prompt a LIVE, detached agent: the +/// parked `pending_cli_prompt` path only fires when a fresh tmux session is +/// created, so an already-running pane needs keystroke injection. `-l` sends the +/// text literally so it can never be interpreted as tmux key names; Enter is a +/// separate call so it submits rather than being typed verbatim. Best-effort. +pub async fn send_cli_keys(workspace_id: Uuid, text: &str) -> bool { + if !tmux_available() { + return false; + } + // Bare name (not `=exact`): send-keys takes a pane target, for which the + // `=` session-target syntax is rejected. Unambiguous given full-hex names. + let target = cli_tmux_session_name(workspace_id); + + let typed = tokio::process::Command::new("tmux") + .args([ + "-L", + CLI_TMUX_SOCKET, + "send-keys", + "-t", + &target, + "-l", + text, + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await + .map(|s| s.success()) + .unwrap_or(false); + if !typed { + return false; + } + + tokio::process::Command::new("tmux") + .args(["-L", CLI_TMUX_SOCKET, "send-keys", "-t", &target, "Enter"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await + .map(|s| s.success()) + .unwrap_or(false) +} + /// Seconds since the Unix epoch (best-effort; 0 if the system clock is before /// the epoch). Used to turn tmux's `session_activity` epoch into an idle age. fn now_unix_secs() -> i64 { @@ -585,21 +948,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 +982,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 +1256,129 @@ 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_warns_with_install_hint_when_agent_missing() { + // A not-installed agent must explain itself instead of silently dropping + // to a bare shell. + let b = cli_bootstrap(&claude_spec(&[]), None, None); + assert!(b.contains("if command -v 'claude'")); + assert!(b.contains("is not installed or not on PATH")); + assert!( + b.contains("npm i -g @anthropic-ai/claude-code"), + "missing-agent notice should carry the install hint: {b}" ); + // The pane is still left usable as a shell. + assert!(b.ends_with(r#"exec "${SHELL:-/bin/sh}""#)); } #[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 +1403,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/Cargo.toml b/crates/server/Cargo.toml index 679b7ebd4f..a5a2185a87 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -13,6 +13,7 @@ deployment = { path = "../deployment" } executors = { path = "../executors" } local-deployment = { path = "../local-deployment" } utils = { path = "../utils" } +which = "8.0.0" git = { path = "../git" } git-host = { path = "../git-host" } db = { path = "../db" } diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index c2f83fd685..1a71d9d3b1 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -101,8 +101,15 @@ fn generate_types_content() -> String { server::routes::config::CheckEditorAvailabilityQuery::decl(), server::routes::config::CheckEditorAvailabilityResponse::decl(), server::routes::config::CheckAgentAvailabilityQuery::decl(), + server::routes::config::CliAgentAvailability::decl(), server::routes::config::AgentPresetOptionsQuery::decl(), server::routes::oauth::CurrentUserResponse::decl(), + db::models::loop_automation::WakeupKind::decl(), + db::models::loop_automation::LoopAutomation::decl(), + db::models::loop_automation::ScheduledWakeup::decl(), + server::routes::loop_automation::LoopAutomationStatus::decl(), + server::routes::loop_automation::UpsertLoopAutomationRequest::decl(), + server::routes::loop_automation::CreateWakeupRequest::decl(), relay_types::StartSpake2EnrollmentRequest::decl(), relay_types::FinishSpake2EnrollmentRequest::decl(), relay_types::StartSpake2EnrollmentResponse::decl(), diff --git a/crates/server/src/routes/config.rs b/crates/server/src/routes/config.rs index f22f7895c6..b2c9faa552 100644 --- a/crates/server/src/routes/config.rs +++ b/crates/server/src/routes/config.rs @@ -52,6 +52,7 @@ pub fn router() -> Router { get(check_editor_availability), ) .route("/agents/check-availability", get(check_agent_availability)) + .route("/agents/cli-availability", get(cli_agent_availability)) .route("/agents/preset-options", get(get_agent_preset_options)) .route( "/agents/discovered-options/ws", @@ -560,6 +561,39 @@ async fn check_agent_availability( ResponseJson(ApiResponse::success(info)) } +/// Which agents' interactive CLI binary is actually on PATH. CLI mode launches +/// the agent's own binary (`command -v `), whereas managed mode runs via +/// npx — so this is a CLI-specific signal the picker uses to flag agents that +/// won't start in CLI mode (rather than the config-file-based `AvailabilityInfo`). +#[derive(Debug, Serialize, TS)] +#[ts(export)] +pub struct CliAgentAvailability { + pub installed: Vec, +} + +async fn cli_agent_availability( + State(_deployment): State, +) -> ResponseJson> { + let profiles = ExecutorConfigs::get_cached(); + let cwd = std::env::temp_dir(); + + let mut installed: Vec = profiles + .executors + .keys() + .copied() + .filter(|executor| { + profiles + .get_coding_agent(&ExecutorProfileId::new(*executor)) + .and_then(|agent| agent.interactive_cli_spec(&cwd)) + .map(|spec| which::which(&spec.program).is_ok()) + .unwrap_or(false) + }) + .collect(); + installed.sort_by_key(|e| e.to_string()); + + ResponseJson(ApiResponse::success(CliAgentAvailability { installed })) +} + #[derive(Debug, Deserialize, TS)] pub struct AgentPresetOptionsQuery { pub executor: BaseCodingAgent, diff --git a/crates/server/src/routes/loop_automation.rs b/crates/server/src/routes/loop_automation.rs new file mode 100644 index 0000000000..0593b68b04 --- /dev/null +++ b/crates/server/src/routes/loop_automation.rs @@ -0,0 +1,160 @@ +//! REST API for agentic-loop automation (part 1): the per-workspace "keep +//! going" policy plus manual wake-ups ("ping at 05:00 UTC"). The supervisor +//! (`local_deployment::loop_supervisor`) consumes what these write. + +use axum::{ + Json, Router, + extract::{Path, State}, + response::Json as ResponseJson, + routing::{delete, get, post}, +}; +use chrono::{DateTime, Utc}; +use db::models::loop_automation::{LoopAutomation, ScheduledWakeup, WakeupKind}; +use deployment::Deployment; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utils::response::ApiResponse; +use uuid::Uuid; + +use crate::{DeploymentImpl, error::ApiError}; + +/// Combined policy + pending wake-ups for a workspace (one round-trip for the UI). +#[derive(Debug, Serialize, TS)] +#[ts(export)] +pub struct LoopAutomationStatus { + /// `None` when the workspace has never been configured (treated as disabled). + pub policy: Option, + pub pending_wakeups: Vec, +} + +#[derive(Debug, Deserialize, TS)] +#[ts(export)] +pub struct UpsertLoopAutomationRequest { + pub enabled: bool, + #[serde(default)] + pub retry_interval_secs: Option, + #[serde(default)] + pub continuation_prompt: Option, + #[serde(default)] + pub max_attempts: Option, +} + +#[derive(Debug, Deserialize, TS)] +#[ts(export)] +pub struct CreateWakeupRequest { + /// When to fire (UTC). The supervisor delivers the prompt into the CLI pane. + #[ts(type = "string")] + pub fire_at: DateTime, + /// Message to deliver; falls back to the policy's continuation prompt. + #[serde(default)] + pub prompt: Option, +} + +/// Lower bound on the retry interval so a misconfiguration can't hammer the pane. +const MIN_RETRY_INTERVAL_SECS: i64 = 60; +const DEFAULT_RETRY_INTERVAL_SECS: i64 = 600; +const DEFAULT_MAX_ATTEMPTS: i64 = 50; +const DEFAULT_CONTINUATION: &str = "Continue."; + +async fn status_for( + deployment: &DeploymentImpl, + workspace_id: Uuid, +) -> Result { + let pool = &deployment.db().pool; + let policy = LoopAutomation::get(pool, workspace_id).await?; + let pending_wakeups = ScheduledWakeup::list_for_workspace(pool, workspace_id, false).await?; + Ok(LoopAutomationStatus { + policy, + pending_wakeups, + }) +} + +pub async fn get_status( + State(deployment): State, + Path(workspace_id): Path, +) -> Result>, ApiError> { + Ok(ResponseJson(ApiResponse::success( + status_for(&deployment, workspace_id).await?, + ))) +} + +pub async fn upsert_policy( + State(deployment): State, + Path(workspace_id): Path, + Json(payload): Json, +) -> Result>, ApiError> { + let pool = &deployment.db().pool; + + let retry_interval_secs = payload + .retry_interval_secs + .unwrap_or(DEFAULT_RETRY_INTERVAL_SECS) + .max(MIN_RETRY_INTERVAL_SECS); + let continuation_prompt = payload + .continuation_prompt + .filter(|p| !p.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_CONTINUATION.to_string()); + let max_attempts = payload.max_attempts.unwrap_or(DEFAULT_MAX_ATTEMPTS).max(0); + + LoopAutomation::upsert( + pool, + workspace_id, + payload.enabled, + retry_interval_secs, + &continuation_prompt, + max_attempts, + ) + .await?; + + // Disabling the loop clears any pending auto-retries so a stale wake-up + // doesn't poke the agent after the user turned it off. + if !payload.enabled { + ScheduledWakeup::delete_pending_for_workspace(pool, workspace_id).await?; + } + + Ok(ResponseJson(ApiResponse::success( + status_for(&deployment, workspace_id).await?, + ))) +} + +pub async fn create_wakeup( + State(deployment): State, + Path(workspace_id): Path, + Json(payload): Json, +) -> Result>, ApiError> { + let pool = &deployment.db().pool; + let prompt = payload.prompt.filter(|p| !p.trim().is_empty()); + let wakeup = ScheduledWakeup::create( + pool, + workspace_id, + payload.fire_at, + WakeupKind::Manual, + prompt.as_deref(), + 1, + ) + .await?; + Ok(ResponseJson(ApiResponse::success(wakeup))) +} + +pub async fn delete_wakeup( + State(deployment): State, + Path(wakeup_id): Path, +) -> Result>, ApiError> { + ScheduledWakeup::delete(&deployment.db().pool, wakeup_id).await?; + Ok(ResponseJson(ApiResponse::success(()))) +} + +pub fn router() -> Router { + Router::new() + .route( + "/workspaces/{workspace_id}/loop-automation", + get(get_status).put(upsert_policy), + ) + .route( + "/workspaces/{workspace_id}/loop-automation/wakeups", + post(create_wakeup), + ) + .route( + "/loop-automation/wakeups/{wakeup_id}", + delete(delete_wakeup), + ) +} diff --git a/crates/server/src/routes/mod.rs b/crates/server/src/routes/mod.rs index decec5ead2..a2bcaf2e3f 100644 --- a/crates/server/src/routes/mod.rs +++ b/crates/server/src/routes/mod.rs @@ -17,6 +17,7 @@ pub mod execution_processes; pub mod frontend; pub mod health; pub mod host_relay; +pub mod loop_automation; pub mod oauth; pub mod organizations; pub mod preview; @@ -53,6 +54,7 @@ pub fn router(deployment: DeploymentImpl) -> IntoMakeService { .merge(releases::router()) .merge(sessions::router(&deployment)) .merge(terminal::router()) + .merge(loop_automation::router()) .route("/ssh-session", get(ssh_session::ssh_session_ws)) .nest("/remote", remote::router()) .merge(webrtc::router()) diff --git a/crates/server/src/routes/terminal.rs b/crates/server/src/routes/terminal.rs index 57cf6c5877..750eea83ef 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 01a55eb9b9..757510d5a9 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 0000000000..4f51c7bd1d --- /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