Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/content/docs/reference/command-line-options.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ Global flags can be used with interactive mode and subcommands.
| --- | --- | --- |
| `--model` | string | Override the model used for this process. |
| `--log-level` | `trace`, `debug`, `info`, `warn`, `error` | Override process logging level. |
| `--dangerously-skip-permissions`, `--yolo` | none | Start the interactive TUI with full-access permissions, skipping approval prompts. |
| `--help` | none | Print command help. |
| `--version` | none | Print Devo version. |

Example:

```bash
devo --model deepseek-v4-pro --log-level debug
devo --yolo
```

## Commands
Expand Down
70 changes: 65 additions & 5 deletions crates/cli/src/agent_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ use devo_util_paths::find_devo_home;
/// when a provider config already exists. `exit_after_onboarding` exits after a
/// successful onboarding save instead of continuing into the interactive TUI.
/// `log_level` is forwarded to the background server process.
/// `dangerously_skip_permissions` starts the session with full-access permissions.
pub(crate) async fn run_agent(
force_onboarding: bool,
exit_after_onboarding: bool,
log_level: Option<&str>,
initial_session_id: Option<SessionId>,
dangerously_skip_permissions: bool,
) -> Result<devo_tui::AppExit> {
let cwd = std::env::current_dir()?;
let config_home = find_devo_home().context("could not determine devo home directory")?;
Expand All @@ -45,11 +47,8 @@ pub(crate) async fn run_agent(
.collect();
let app_config = FileSystemAppConfigLoader::new(config_home.clone()).load(Some(&cwd))?;
let project_key = project_config_key(&cwd);
let permission_preset = app_config
.projects
.get(&project_key)
.and_then(|config| config.permission_preset)
.unwrap_or(PermissionPreset::Default);
let permission_preset =
initial_permission_preset(&app_config, &project_key, dangerously_skip_permissions);
let (onboarding_mode, resolved) = resolve_initial_provider_settings(
force_onboarding,
&app_config,
Expand Down Expand Up @@ -116,6 +115,24 @@ pub(crate) async fn run_agent(
Ok(exit)
}

fn initial_permission_preset(
app_config: &AppConfig,
project_key: &str,
dangerously_skip_permissions: bool,
) -> PermissionPreset {
let mut permission_preset = app_config
.projects
.get(project_key)
.and_then(|config| config.permission_preset)
.unwrap_or(PermissionPreset::Default);

if dangerously_skip_permissions {
permission_preset = PermissionPreset::FullAccess;
}

permission_preset
}

/// Resolves the initial provider settings and whether onboarding should be shown.
///
/// `force_onboarding` requests onboarding regardless of stored configuration.
Expand Down Expand Up @@ -243,6 +260,7 @@ mod tests {

use pretty_assertions::assert_eq;

use super::initial_permission_preset;
use super::resolve_initial_provider_settings;
use super::saved_model_entries;
use devo_core::AppConfig;
Expand All @@ -251,10 +269,12 @@ mod tests {
use devo_core::Model;
use devo_core::ModelBindingConfig;
use devo_core::PresetModelCatalog;
use devo_core::ProjectConfig;
use devo_core::ProviderConfigSection;
use devo_core::ProviderDefaultsConfig;
use devo_core::ProviderVendorConfig;
use devo_core::ResolvedProviderSettings;
use devo_protocol::PermissionPreset;
use devo_protocol::ProviderWireApi;
use devo_tui::SavedModelEntry;

Expand Down Expand Up @@ -283,6 +303,46 @@ mod tests {
}
}

#[test]
fn initial_permission_preset_uses_project_config_when_flag_is_false() {
let mut app_config = AppConfig::default();
app_config.projects.insert(
"project-key".to_string(),
ProjectConfig {
permission_preset: Some(PermissionPreset::ReadOnly),
},
);

assert_eq!(
initial_permission_preset(
&app_config,
"project-key",
/*dangerously_skip_permissions*/ false,
),
PermissionPreset::ReadOnly,
);
}

#[test]
fn initial_permission_preset_overrides_to_full_access_when_flag_is_true() {
let mut app_config = AppConfig::default();
app_config.projects.insert(
"project-key".to_string(),
ProjectConfig {
permission_preset: Some(PermissionPreset::ReadOnly),
},
);

assert_eq!(
initial_permission_preset(
&app_config,
"project-key",
/*dangerously_skip_permissions*/ true,
),
PermissionPreset::FullAccess,
);
}

#[test]
fn resolve_initial_provider_settings_uses_catalog_fallback_during_onboarding() {
let actual = resolve_initial_provider_settings(
Expand Down
44 changes: 44 additions & 0 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ struct Cli {
.try_map(|level| level.parse::<LevelFilter>())
)]
log_level: Option<LevelFilter>,

/// Start with full-access permissions, skipping approval prompts.
#[arg(
long = "dangerously-skip-permissions",
visible_alias = "yolo",
global = true
)]
dangerously_skip_permissions: bool,
}

fn main() -> Result<()> {
Expand Down Expand Up @@ -186,6 +194,7 @@ async fn run_cli() -> Result<()> {
/*exit_after_onboarding*/ true,
log_level.as_deref(),
None,
cli.dangerously_skip_permissions,
)
.await?;
for line in onboarding_exit_messages(&exit, /*color_enabled*/ true) {
Expand All @@ -211,6 +220,7 @@ async fn run_cli() -> Result<()> {
/*exit_after_onboarding*/ false,
log_level.as_deref(),
Some(*session_id),
cli.dangerously_skip_permissions,
)
.await?;
for line in exit_messages(&exit, /*color_enabled*/ true) {
Expand Down Expand Up @@ -240,6 +250,7 @@ async fn run_cli() -> Result<()> {
/*exit_after_onboarding*/ false,
log_level.as_deref(),
None,
cli.dangerously_skip_permissions,
)
.await?;
let exit_lines = exit_messages(&exit, /*color_enabled*/ true);
Expand Down Expand Up @@ -420,6 +431,33 @@ mod tests {
}
}

#[test]
fn cli_parses_dangerously_skip_permissions_flag() {
let cli = Cli::try_parse_from(["devo", "--dangerously-skip-permissions"])
.expect("parse dangerously-skip-permissions");

assert!(cli.command.is_none());
assert!(cli.dangerously_skip_permissions);
}

#[test]
fn cli_parses_yolo_alias_for_dangerously_skip_permissions() {
let cli = Cli::try_parse_from(["devo", "--yolo"]).expect("parse yolo");

assert!(cli.command.is_none());
assert!(cli.dangerously_skip_permissions);
}

#[test]
fn cli_parses_yolo_alias_on_resume_subcommand() {
let session_id = SessionId::new();
let cli = Cli::try_parse_from(["devo", "resume", &session_id.to_string(), "--yolo"])
.expect("parse resume with yolo");

assert!(matches!(cli.command, Some(Command::Resume { .. })));
assert!(cli.dangerously_skip_permissions);
}

#[test]
fn cli_rejects_unsupported_log_levels() {
let err = Cli::try_parse_from(["devo", "--log-level", "off"]).expect_err("reject off");
Expand All @@ -440,6 +478,7 @@ mod tests {
command: None,
model: None,
log_level: Some(level),
dangerously_skip_permissions: false,
};

assert_eq!(
Expand All @@ -462,11 +501,13 @@ mod tests {
command: None,
model: None,
log_level: None,
dangerously_skip_permissions: false,
},
Cli {
command: Some(Command::Onboard),
model: None,
log_level: None,
dangerously_skip_permissions: false,
},
Cli {
command: Some(Command::Prompt {
Expand All @@ -475,6 +516,7 @@ mod tests {
}),
model: None,
log_level: None,
dangerously_skip_permissions: false,
},
] {
assert_eq!(
Expand All @@ -493,6 +535,7 @@ mod tests {
command: Some(Command::Doctor),
model: None,
log_level: None,
dangerously_skip_permissions: false,
};
let server = Cli {
command: Some(Command::Server {
Expand All @@ -502,6 +545,7 @@ mod tests {
}),
model: None,
log_level: None,
dangerously_skip_permissions: false,
};

assert_eq!(
Expand Down
14 changes: 7 additions & 7 deletions crates/core/src/history/compaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
//! Two compaction modes (`CompactionKind`) choose different preserve strategies:
//!
//! * **Auto** — token-budget threshold in the query loop (`query.rs`).
//! Preserves a tail window of roughly [`COMPACT_USER_MESSAGE_MAX_TOKENS`]
//! estimated tokens via [`split_by_user_message_budget`], regardless of user
//! Preserves a tail window of roughly `COMPACT_USER_MESSAGE_MAX_TOKENS`
//! estimated tokens via `split_by_user_message_budget`, regardless of user
//! message boundaries. Example: `[user1, asst1, user2, asst2, user3]` may
//! become `[summary, asst2, user3]` when `asst2` and `user3` fit the tail
//! budget but `user2` does not.
//! * **Proactive** — `/compact` or provider `context_too_long` retry.
//! Preserves from the latest user message onward via
//! [`preserve_suffix_from_latest_user_message`]. Example: the same history
//! `preserve_suffix_from_latest_user_message`. Example: the same history
//! becomes `[summary, user3]` only.
//!
//! The compaction flow:
Expand Down Expand Up @@ -129,12 +129,12 @@ pub enum CompactionKind {
/// Automatic compaction when context pressure is high.
///
/// Skips when [`should_compact`] says the session is already within budget.
/// Preserve strategy: [`split_by_user_message_budget`] over the tail
/// [`COMPACT_USER_MESSAGE_MAX_TOKENS`] window (items, not user turns).
/// Preserve strategy: `split_by_user_message_budget` over the tail
/// `COMPACT_USER_MESSAGE_MAX_TOKENS` window (items, not user turns).
Auto,
/// Forced compaction that always runs.
///
/// Preserve strategy: [`preserve_suffix_from_latest_user_message`] — from
/// Preserve strategy: `preserve_suffix_from_latest_user_message` — from
/// the last `Role::User` item through the end of history.
Proactive,
}
Expand Down Expand Up @@ -434,7 +434,7 @@ mod tests {

#[test]
fn compact_builds_merged_messages() {
let items = vec![
let items = [
ResponseItem::Message(Message::user("hello")),
// Split assistant turn — text + two tool calls
ResponseItem::Message(Message::assistant_text("ok")),
Expand Down
9 changes: 9 additions & 0 deletions crates/server/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,12 @@ The server runtime uses **one session actor per session**. Durable session state
- **Runtime concurrency changes need integration coverage** in `crates/server/tests/`: interrupt mid-stream, queued follow-ups, goal lifecycle interrupts, persistence/resume, research.
- **Prefer waiting on observable protocol outcomes** (notifications, terminal status) over sleeping or polling internal maps.
- Follow existing test conventions: `pretty_assertions::assert_eq`, compare whole objects where possible, platform-aware paths when touching filesystem behavior.

### Session persistence layers

- **Rollout JSONL** under `~/.devo/sessions/` is the canonical conversation history.
- **SQLite** (`devo.db` `sessions` table) stores a lightweight index (`rollout_path`, `parent_session_id`, title, cwd, timestamps) used by `session/list` and resume decisions.
- **In-memory session actors** are loaded on demand via `get_or_load_parent_session`; root sessions are LRU-evicted (capacity 16) when unpinned.
- **`session/list`** returns durable user-visible sessions only (non-ephemeral, no `agent_path`; includes forks with `parent_session_id`); subagent rows are indexed but hidden from list.
- **`session/resume`** loads parent sessions lazily from rollout files. Subagent session ids cannot be resumed directly; missing rollout files fail with an explicit restore error.
- **Startup** runs `index_rollout_metadata` in the background instead of replaying every rollout into memory.
6 changes: 3 additions & 3 deletions crates/server/src/bootstrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,9 +252,9 @@ pub async fn run_server_process(
serde_json::Map::from_iter([("trigger".to_string(), serde_json::json!("init"))]),
)
.await;
tracing::info!("starting persisted session restore");
runtime.load_persisted_sessions().await?;
tracing::info!("persisted session restore completed");
if runtime.backfill_session_index_if_required()? {
tracing::info!("rollout metadata index backfill completed");
}

let shutdown_signal = tokio_util::sync::CancellationToken::new();
let internal_proxy_control = InternalProxyControl::new(shutdown_signal.clone());
Expand Down
Loading
Loading