diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b9a0f46..fab08b0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ for [semantic versioning](https://semver.org) once it reaches 1.0. Until then, ## [Unreleased] +- Published the matching npm bootstrap as `omega-os@1.5.14` so `npx omega-os` + clones `main` with worker project cwd, record-only Verify Command, and the + Codex `--sandbox` / `--ask-for-approval never` launch pair. + ## [0.1.14] — 2026-08-24 ### Provider compatibility and installation diff --git a/agents/oracle.md b/agents/oracle.md index d897e1e9..bea68b11 100644 --- a/agents/oracle.md +++ b/agents/oracle.md @@ -78,6 +78,18 @@ task-by-task re-check, has failed — no matter how much else was delivered. --- +## AGK Agentic Engineering Lab (run this, do not narrate it) + +Every mission walks this loop. Persist it first — do not keep it only in prose: + +`omega progress {{SESSION}} --plan "Understand|Explain|Design|Build|Debug|Test|Evaluate|Secure|Deploy|Observe|Improve"` + +Walk the steps in order. Keep exactly one task `doing`. Required dimensions on every mission: repo context, editing, shell, tests, git, sandbox, verification, human-in-the-loop, finish reports. + +Writers are `claude | codex | glm` only. Hermes is Home (`omega new --agent hermes`), never dispatch and never a worker. Writer briefs MUST include `Done Criteria:` and `Verify Command:` or `omega spawn-worker` refuses. Writers cannot self-approve. `omega done` is a candidate. Fake-done is forbidden. + +--- + ## The Laws (override everything) _The authoritative, always-current Laws (L0–L6) + your Oracle-scoped operational rules are diff --git a/crates/omega-cli/src/main.rs b/crates/omega-cli/src/main.rs index c0361688..0575eb2e 100644 --- a/crates/omega-cli/src/main.rs +++ b/crates/omega-cli/src/main.rs @@ -454,7 +454,8 @@ enum Commands { /// Files owned by this worker (scope-claim) #[arg(long, value_delimiter = ',')] files: Option>, - /// Bypass the prompt-completeness gate (downgrade reject to a warning) + /// Accepted for compatibility. Does not skip R-RUBRIC (Done Criteria + + /// Verify Command must still be present). #[arg(long)] force: bool, /// Isolate the worker in its own git worktree (independent HEAD/working-tree @@ -3303,6 +3304,43 @@ async fn run_tui_loop( Ok(()) } +/// After `omega new --agent`, fail JSON if the pane is already bash / gone. +/// Same launcher as TUI New Codex/Claude/Hermes (`try_launch`). +async fn observe_new_agent_session( + mgr: &SessionManager, + state_dir: &std::path::Path, + name: &str, + provider: &str, +) -> Result<()> { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + let live = mgr + .list_sessions() + .await + .ok() + .is_some_and(|sessions| sessions.iter().any(|session| session.name == name)); + let pane = if live { + mgr.capture_pane(name).await.ok() + } else { + None + }; + let health = + omega_core::session_health::observe(state_dir, name, provider, live, pane.as_deref())?; + if health.is_failed() { + println!("{}", serde_json::to_string_pretty(&health)?); + anyhow::bail!( + "{}", + serde_json::json!({ + "error": "agent_exited", + "session": name, + "provider": provider, + "reason": health.reason, + "message": "agent pane died; session is failed, not a silent bash" + }) + ); + } + Ok(()) +} + async fn cmd_new( name: &str, dir: Option<&str>, @@ -3314,8 +3352,16 @@ async fn cmd_new( let config = OmegaConfig::load().context("cannot load OmegaOS config for session creation")?; config.ensure_dirs()?; - let workspace = match dir { - Some(dir) => std::path::PathBuf::from(dir), + // Same cwd contract as TUI New Codex/Claude: omit --dir → None (rmux + // inherits the process cwd). `--dir ~/Desktop` must expand; a literal + // tilde path is how Codex splash-exits into bash. Codex itself is not + // broken — Gareth's menu launch stays in the TUI. + let working_dir = omega_core::session::resolve_session_working_dir(dir)?; + let dir_arg = working_dir + .as_ref() + .map(|path| path.to_string_lossy().into_owned()); + let workspace = match working_dir { + Some(path) => path, None => std::env::current_dir().context("resolving session workspace")?, }; let scope_claim = match &files { @@ -3327,21 +3373,29 @@ async fn cmd_new( )?), None => None, }; - let dispatch_authority = omega_core::session::SessionDispatchAuthority::generate( - name, - scope_claim - .as_ref() - .and_then(|claim| claim.claim_id.as_deref()), - ); - let dispatch_authority = match dispatch_authority { - Ok(authority) => authority, - Err(error) => { - if let Some(claim) = &scope_claim { - omega_core::scope::ScopeClaim::release_exact(&config.state_dir, claim) - .context("rolling back scope after dispatch authority preparation failed")?; + // Dispatch-authority env is for `--cmd` (and worker/oracle spawn). The + // TUI New-agent path never injects it; Home `--agent` must match that. + let dispatch_authority = if cmd.is_some() { + let prepared = omega_core::session::SessionDispatchAuthority::generate( + name, + scope_claim + .as_ref() + .and_then(|claim| claim.claim_id.as_deref()), + ); + match prepared { + Ok(authority) => Some(authority), + Err(error) => { + if let Some(claim) = &scope_claim { + omega_core::scope::ScopeClaim::release_exact(&config.state_dir, claim) + .context( + "rolling back scope after dispatch authority preparation failed", + )?; + } + return Err(error).context("preparing immutable session dispatch authority"); } - return Err(error).context("preparing immutable session dispatch authority"); } + } else { + None }; let creation: Result<()> = async { @@ -3349,13 +3403,16 @@ async fn cmd_new( // Priority: explicit --cmd overrides --agent if let Some(explicit_cmd) = cmd { + let authority = dispatch_authority.as_ref().ok_or_else(|| { + anyhow::anyhow!("explicit --cmd always prepares session dispatch authority") + })?; let _session = mgr .create_command_session_create_only_with_authority( &config.state_dir, name, - dir, + dir_arg.as_deref(), explicit_cmd, - &dispatch_authority, + authority, ) .await?; } else if let Some(agent_name) = agent { @@ -3371,18 +3428,12 @@ async fn cmd_new( agent_enum.display_name() ); } - let launch = agent_enum.try_launch(prompt)?; + // Same entry as TUI `Action::CreateSessionAutoName`. let _session = mgr - .create_agent_session_create_only_with_authority( - &config.state_dir, - name, - dir, - agent_enum, - launch, - &dispatch_authority, - ) + .create_session_with_agent(name, dir_arg.as_deref(), agent_enum, prompt) .await?; println!("Agent: {}", agent_enum.display_name()); + observe_new_agent_session(&mgr, &config.state_dir, name, agent_enum.name()).await?; } else { let default_agent = omega_core::agents::Agent::from_name(&config.agent_command) .ok_or_else(|| { @@ -3391,18 +3442,17 @@ async fn cmd_new( config.agent_command ) })?; - let launch = default_agent.try_launch(prompt)?; + if !default_agent.is_available() { + eprintln!( + "Warning: {} not detected on this system. Session will be created anyway.", + default_agent.display_name() + ); + } let _session = mgr - .create_agent_session_create_only_with_authority( - &config.state_dir, - name, - dir, - default_agent, - launch, - &dispatch_authority, - ) + .create_session_with_agent(name, dir_arg.as_deref(), default_agent, prompt) .await?; println!("Agent: {} (OmegaOS default)", default_agent.display_name()); + observe_new_agent_session(&mgr, &config.state_dir, name, default_agent.name()).await?; } Ok(()) } @@ -4994,8 +5044,12 @@ fn get_config_value(cfg: &omega_core::providers::ProvidersConfig, key: &str) -> ("codex", "api_key") => redacted_secret(&cfg.codex.api_key), ("codex", "base_url") => cfg.codex.base_url.clone(), ("codex", "bypass_hook_trust") => cfg.codex.bypass_hook_trust.to_string(), + ("codex", "ask_for_approval_never") | ("codex", "yolo") => { + cfg.codex.ask_for_approval_never.to_string() + } ("gemini", "model") => cfg.gemini.model.clone(), ("gemini", "api_key") => redacted_secret(&cfg.gemini.api_key), + ("gemini", "yolo") => cfg.gemini.yolo.to_string(), ("antigravity", "model") => cfg.antigravity.model.clone(), ("antigravity", "effort") => cfg.antigravity.effort.clone(), ("antigravity", "dangerously_skip_permissions") => { @@ -5004,18 +5058,22 @@ fn get_config_value(cfg: &omega_core::providers::ProvidersConfig, key: &str) -> ("pi", "provider") => cfg.pi.provider.clone(), ("pi", "model") => cfg.pi.model.clone(), ("pi", "api_key") => redacted_secret(&cfg.pi.api_key), + ("pi", "approve") | ("pi", "yolo") => cfg.pi.approve.to_string(), ("glm", "model") => cfg.glm.model.clone(), ("glm", "api_key") => redacted_secret(&cfg.glm.api_key), + ("glm", "dangerously_skip_permissions") => cfg.glm.dangerously_skip_permissions.to_string(), ("openrouter", "model") => cfg.openrouter.model.clone(), ("openrouter", "api_key") => redacted_secret(&cfg.openrouter.api_key), ("openrouter", "base_url") => cfg.openrouter.base_url.clone(), ("hermes", "provider") => cfg.hermes.provider.clone(), ("hermes", "model") => cfg.hermes.model.clone(), ("hermes", "api_key") => redacted_secret(&cfg.hermes.api_key), + ("hermes", "yolo") => cfg.hermes.yolo.to_string(), ("kimi", "model") => cfg.kimi.model.clone(), ("kimi", "api_key") => redacted_secret(&cfg.kimi.api_key), ("kimi", "base_url") => cfg.kimi.base_url.clone(), ("kimi", "provider_type") => cfg.kimi.provider_type.clone(), + ("kimi", "auto") | ("kimi", "yolo") => cfg.kimi.auto.to_string(), _ => anyhow::bail!("Unknown key: {}", key), }; Ok(s) @@ -5046,8 +5104,18 @@ fn set_config_value( .parse::() .with_context(|| format!("invalid boolean {value:?}; expected true or false"))?; } + ("codex", "ask_for_approval_never") | ("codex", "yolo") => { + cfg.codex.ask_for_approval_never = value + .parse::() + .with_context(|| format!("invalid boolean {value:?}; expected true or false"))?; + } ("gemini", "model") => cfg.gemini.model = value.to_string(), ("gemini", "api_key") => cfg.gemini.api_key = value.to_string(), + ("gemini", "yolo") => { + cfg.gemini.yolo = value + .parse::() + .with_context(|| format!("invalid boolean {value:?}; expected true or false"))?; + } ("antigravity", "model") => cfg.antigravity.model = value.to_string(), ("antigravity", "effort") => cfg.antigravity.effort = value.to_string(), ("antigravity", "dangerously_skip_permissions") => { @@ -5058,18 +5126,38 @@ fn set_config_value( ("pi", "provider") => cfg.pi.provider = value.to_string(), ("pi", "model") => cfg.pi.model = value.to_string(), ("pi", "api_key") => cfg.pi.api_key = value.to_string(), + ("pi", "approve") | ("pi", "yolo") => { + cfg.pi.approve = value + .parse::() + .with_context(|| format!("invalid boolean {value:?}; expected true or false"))?; + } ("glm", "model") => cfg.glm.model = value.to_string(), ("glm", "api_key") => cfg.glm.api_key = value.to_string(), + ("glm", "dangerously_skip_permissions") => { + cfg.glm.dangerously_skip_permissions = value + .parse::() + .with_context(|| format!("invalid boolean {value:?}; expected true or false"))?; + } ("openrouter", "model") => cfg.openrouter.model = value.to_string(), ("openrouter", "api_key") => cfg.openrouter.api_key = value.to_string(), ("openrouter", "base_url") => cfg.openrouter.base_url = value.to_string(), ("hermes", "provider") => cfg.hermes.provider = value.to_string(), ("hermes", "model") => cfg.hermes.model = value.to_string(), ("hermes", "api_key") => cfg.hermes.api_key = value.to_string(), + ("hermes", "yolo") => { + cfg.hermes.yolo = value + .parse::() + .with_context(|| format!("invalid boolean {value:?}; expected true or false"))?; + } ("kimi", "model") => cfg.kimi.model = value.to_string(), ("kimi", "api_key") => cfg.kimi.api_key = value.to_string(), ("kimi", "base_url") => cfg.kimi.base_url = value.to_string(), ("kimi", "provider_type") => cfg.kimi.provider_type = value.to_string(), + ("kimi", "auto") | ("kimi", "yolo") => { + cfg.kimi.auto = value + .parse::() + .with_context(|| format!("invalid boolean {value:?}; expected true or false"))?; + } _ => anyhow::bail!("Unknown key: {}", key), } Ok(()) @@ -7763,43 +7851,12 @@ struct V3WorkerAttempt { plan_revision: u64, } +#[cfg(test)] fn declared_verify_command(prompt: &str) -> Option> { - let lines: Vec<&str> = prompt.lines().collect(); - for (index, line) in lines.iter().enumerate() { - let lower = line.to_lowercase(); - let marker = lower - .find("verify command:") - .or_else(|| lower.find("verify-command:")); - let Some(marker) = marker else { - continue; - }; - let colon = line[marker..].find(':').map(|offset| marker + offset)?; - let mut command = line[colon + 1..].trim(); - if command.is_empty() { - command = lines - .iter() - .skip(index + 1) - .map(|candidate| candidate.trim()) - .find(|candidate| !candidate.is_empty() && !candidate.starts_with("```"))?; - } - command = command - .trim_start_matches("- ") - .trim() - .trim_matches('`') - .trim(); - if command.is_empty() - || command - .chars() - .any(|ch| matches!(ch, ';' | '&' | '|' | '<' | '>' | '`' | '$' | '\n' | '\r')) - { - return None; - } - let argv = shlex::split(command)?; - if !argv.is_empty() { - return Some(argv); - } + match omega_core::worker_spawn::parse_verify_contract(prompt)? { + omega_core::worker_spawn::VerifySpec::Command { argv } => Some(argv), + omega_core::worker_spawn::VerifySpec::FileExists { path } => Some(vec![path]), } - None } fn declared_done_criteria(prompt: &str) -> Vec { @@ -7849,28 +7906,44 @@ fn prepare_v3_worker_attempt( } let ledger = omega_core::mission_ledger::MissionLedger::open(&ledger_path)?; let mut projection = state.require_ledger_authority(&ledger)?; - let argv = declared_verify_command(prompt).ok_or_else(|| { + // Record the oracle-authored Verify Command. Do NOT execute it here — + // the file does not exist yet (Gareth: `(eval):1: no such file or + // directory: CLAUDE_OK.txt` at spawn). + let spec = omega_core::worker_spawn::parse_verify_contract(prompt).ok_or_else(|| { anyhow::anyhow!( - "worker brief has no safe, directly executable `Verify Command:`; \ - shell operators are not accepted in immutable verifier contracts" + "worker brief has no safe `Verify Command:` (shell operators are not accepted). \ + The oracle must fill R-RUBRIC when it writes the prompt; do not ask a human for --force." ) })?; + let verifier_check = match spec { + omega_core::worker_spawn::VerifySpec::FileExists { path } => { + omega_core::mission::VerifierCheck { + schema_version: omega_core::mission::CONTRACT_SCHEMA_VERSION, + check_id: format!("verify-{task}"), + kind: omega_core::mission::VerifierCheckKind::FileExists { path }, + timeout_secs: 120, + } + } + omega_core::worker_spawn::VerifySpec::Command { argv } => { + omega_core::mission::VerifierCheck { + schema_version: omega_core::mission::CONTRACT_SCHEMA_VERSION, + check_id: format!("verify-{task}"), + kind: omega_core::mission::VerifierCheckKind::Command { + argv, + cwd: Some(work_dir.to_string()), + expected_exit_code: 0, + }, + timeout_secs: 120, + } + } + }; let task_contract = omega_core::mission::TaskContract { schema_version: omega_core::mission::CONTRACT_SCHEMA_VERSION, task_id: omega_core::mission::TaskId::new(task), name: task.to_string(), prompt: prompt.to_string(), acceptance_criteria: declared_done_criteria(prompt), - verifier_checks: vec![omega_core::mission::VerifierCheck { - schema_version: omega_core::mission::CONTRACT_SCHEMA_VERSION, - check_id: format!("verify-{task}"), - kind: omega_core::mission::VerifierCheckKind::Command { - argv, - cwd: None, - expected_exit_code: 0, - }, - timeout_secs: 120, - }], + verifier_checks: vec![verifier_check], required_capabilities: vec!["code_editing".to_string(), "tool_calling".to_string()], scope: files.to_vec(), risk: omega_core::routing::classify_mission(prompt).risk, @@ -8315,6 +8388,36 @@ fn worker_authority_rollback_error( ) } +/// Same project-path SSOT `dispatch_oracle_with_agent` uses: config, then +/// `~/.omega/projects.json`, then `$HOME` discovery. A worker named +/// `-worker-` must start in that project even when the parent +/// pane's cwd is `$HOME`. +fn registered_project_working_dir( + config: &OmegaConfig, + project: &str, +) -> Option { + let lower = project.to_lowercase(); + if let Some(path) = config.find_project(project).map(|pc| pc.path.clone()) { + if path.is_dir() { + return Some(path); + } + } + let from_registry = omega_core::project_manager::ProjectRegistry::load() + .projects + .into_iter() + .find(|item| item.name.to_lowercase() == lower) + .map(|item| item.path); + if let Some(path) = from_registry.filter(|path| path.is_dir()) { + return Some(path); + } + let home = dirs::home_dir()?; + omega_core::projects::discover(&home) + .into_iter() + .find(|item| item.name.to_lowercase() == lower) + .map(|item| item.path) + .filter(|path| path.is_dir()) +} + #[allow(clippy::too_many_arguments)] async fn cmd_spawn_worker( task: &str, @@ -8340,6 +8443,12 @@ async fn cmd_spawn_worker( // oracle. Resolve the real name by asking rmux to expand #{session_name} for // our pane ($RMUX_PANE). let oracle_session = current_session_name().filter(|s| s.starts_with("oracle-")); + let prompt_owned = if oracle_session.is_some() { + omega_core::lab::ensure_oracle_worker_rubric(prompt, task) + } else { + prompt.to_string() + }; + let prompt = prompt_owned.as_str(); let project_name = match project { Some(p) => Some(p.to_string()), @@ -8348,7 +8457,27 @@ async fn cmd_spawn_worker( .and_then(|s| omega_core::session::OmegaSession::classify(s).project), }; - let mut work_dir = dir.unwrap_or(".").to_string(); + // Never pass `.` to rmux — the daemon cwd is often $HOME, which is how + // CLAUDE_OK.txt landed in /Users/hacker instead of the project. + let oracle_working_dir = oracle_session.as_deref().and_then(|name| { + omega_core::oracle_lifecycle::OracleState::read(&config.state_dir, name) + .ok() + .flatten() + .map(|state| state.working_dir) + }); + let process_cwd = std::env::current_dir().context("resolving spawn-worker process cwd")?; + let registered_project_dir = project_name + .as_deref() + .and_then(|name| registered_project_working_dir(&config, name)); + let home_dir = dirs::home_dir(); + let resolved_work_dir = omega_core::worker_spawn::resolve_worker_working_dir( + dir, + oracle_working_dir.as_deref(), + registered_project_dir.as_deref(), + &process_cwd, + home_dir.as_deref(), + )?; + let mut work_dir = resolved_work_dir.to_string_lossy().into_owned(); let source_work_dir = std::path::PathBuf::from(&work_dir); let mut created_worktree = None; let worker_name = omega_core::session::sanitize_session_name(&match &project_name { @@ -8371,21 +8500,11 @@ async fn cmd_spawn_worker( (true, false) => "Verify Command", (true, true) => unreachable!(), }; - if force { - tracing::warn!( - "worker prompt missing {} — --force set, dispatching anyway (quality gate may fail)", - missing - ); - eprintln!( - "[!] worker prompt missing {} — --force set, dispatching anyway (quality gate may fail)", - missing - ); - } else { - anyhow::bail!( - "worker prompt missing {missing}. Add explicit \"Done Criteria:\" and a \"Verify Command:\" \ - to the prompt so the worker has measurable success criteria (rule R-RUBRIC), or pass --force to override." - ); - } + anyhow::bail!( + "worker prompt missing {missing}. Add explicit \"Done Criteria:\" and a \"Verify Command:\" \ + when you write the prompt (rule R-RUBRIC). --force does not skip this{}.", + if force { " (ignored)" } else { "" } + ); } let agent = match agent_override { @@ -8406,12 +8525,24 @@ async fn cmd_spawn_worker( } resolved } - None => omega_core::agents::Agent::from_name(&config.agent_command).ok_or_else(|| { - anyhow::anyhow!( - "configured worker agent {:?} is unknown; set an explicit supported provider", - config.agent_command - ) - })?, + None => { + let configured = omega_core::agents::Agent::from_name(&config.agent_command) + .ok_or_else(|| { + anyhow::anyhow!( + "configured worker agent {:?} is unknown; set an explicit supported provider", + config.agent_command + ) + })?; + let writer = configured.writer_or_codex(); + if !writer.is_writer() { + anyhow::bail!( + "worker agent '{}' is not allowed: only claude, codex and glm carry \ + the finish-guard hooks a detached worker needs", + writer.name() + ); + } + writer + } }; omega_core::providers::ProvidersConfig::try_load() .context("cannot load provider config for worker dispatch")?; @@ -8832,6 +8963,7 @@ async fn cmd_spawn_worker( } println!("● Worker spawned: {}", worker_name); + println!(" cwd: {}", work_dir); if let Some(p) = &project_name { println!(" Under project: {}", p); } @@ -9642,6 +9774,29 @@ fn l4_refusal_reasons(todo: &omega_core::oracle_todo::OracleTodo) -> Vec /// agent cannot see. Worse, the silent rewrite restamped `ts` on every look, /// which is precisely the field patrol's stall detector reads: merely LOOKING /// at a stalled mission made it look alive. +fn worker_finish_reports(state_dir: &std::path::Path, session: &str) -> Vec { + let Some(state) = omega_core::oracle_lifecycle::OracleState::read(state_dir, session) + .ok() + .flatten() + else { + return Vec::new(); + }; + state + .workers + .iter() + .filter_map(|worker| { + let done = + omega_core::done::DoneSignal::read(state_dir, &worker.session_name).ok()??; + Some(serde_json::json!({ + "session": worker.session_name, + "status": done.status, + "summary": done.summary, + "evidence": done.artifacts, + })) + }) + .collect() +} + fn cmd_progress_readback(state_dir: &std::path::Path, session: &str, json: bool) -> Result<()> { let key = session.strip_prefix("oracle-").unwrap_or(session); let path = state_dir.join(format!("oracle-{}.progress.json", key)); @@ -9652,6 +9807,7 @@ fn cmd_progress_readback(state_dir: &std::path::Path, session: &str, json: bool) let tasks = parse_plan_tasks(&doc); if json { let done = tasks.iter().filter(|t| t.status == "done").count(); + let reports = worker_finish_reports(state_dir, session); println!( "{}", serde_json::json!({ @@ -9665,6 +9821,7 @@ fn cmd_progress_readback(state_dir: &std::path::Path, session: &str, json: bool) .iter() .map(|t| serde_json::json!({ "t": t.title, "s": t.status })) .collect::>(), + "worker_reports": reports, }) ); } else if !path.exists() { @@ -11175,6 +11332,12 @@ async fn cmd_gate( .map(|session| session.name) .collect(); let oracle = resolve_oracle_alias(oracle, &live, &config.state_dir); + let caller = std::env::var("OMEGA_SESSION").ok(); + omega_core::gate::refuse_writer_self_approval( + &oracle, + approver.unwrap_or(""), + caller.as_deref(), + )?; let result = omega_core::gate::GateResult::human_acceptance( &oracle, approver.unwrap_or_default(), @@ -11466,6 +11629,11 @@ struct OracleRow { escalation: Option, } +fn is_stale_purge_oracle(name: &str) -> bool { + let n = name.to_ascii_lowercase(); + n.contains("mac-purge") || n.contains("-purge-") +} + fn oracle_row( state_dir: &std::path::Path, name: &str, @@ -11556,7 +11724,12 @@ async fn cmd_oracles(all: bool) -> Result<()> { .map(|s| s.name.clone()) .collect(); if all { - for st in omega_core::oracle_lifecycle::OracleState::read_all_strict(&config.state_dir)? { + // Diagnostic roster: tolerant sweep. A leftover `oracle-mac-purge-*` + // projection must not take `oracles --all` down (Gareth 2026-08-24). + for st in omega_core::oracle_lifecycle::OracleState::read_all(&config.state_dir) { + if is_stale_purge_oracle(&st.oracle_name) { + continue; + } names.push(st.oracle_name); } // The GHOST MISSIONS, and they are the whole reason `--all` exists: a @@ -11581,16 +11754,22 @@ async fn cmd_oracles(all: bool) -> Result<()> { } names.sort(); names.dedup(); + names.retain(|n| !is_stale_purge_oracle(n)); if names.is_empty() { println!("No oracle {}.", if all { "on record" } else { "live" }); return Ok(()); } - let rows: Vec = names - .iter() - .map(|n| oracle_row(&config.state_dir, n, &live_sessions)) - .collect::>>()?; + let mut rows: Vec = Vec::new(); + for n in &names { + match oracle_row(&config.state_dir, n, &live_sessions) { + Ok(row) => rows.push(row), + Err(error) => { + eprintln!("[!] skipping stale oracle {n}: {error}"); + } + } + } // Fixed columns, hard-truncated: a session name can be 50 chars and one long // row that wraps costs more than the characters it saves. @@ -11711,16 +11890,68 @@ async fn cmd_workers(oracle: Option<&str>) -> Result<()> { Ok(()) } +/// Lifecycle JSON for `omega status --json`. Never includes pane text — +/// Grok observes without attaching. +struct OracleStatusJson<'a> { + name: &'a str, + session_id: Option<&'a str>, + live: bool, + phase: &'a str, + project: Option<&'a str>, + done: usize, + total: usize, + doing: Option<&'a str>, + running: &'a [String], + terminal: &'a [String], + reports: &'a [serde_json::Value], + gate_passed: bool, + closeable: bool, + refused_because: &'a [String], + delivery: Option<&'a serde_json::Value>, + health: Option<&'a serde_json::Value>, +} + +fn build_oracle_status_json(view: OracleStatusJson<'_>) -> serde_json::Value { + serde_json::json!({ + "session": view.name, + "session_id": view.session_id, + "live": view.live, + "phase": view.phase, + "project": view.project, + "plan": { "done": view.done, "total": view.total }, + "doing": view.doing, + "workers": { + "running": view.running, + "terminal": view.terminal, + "reports": view.reports, + }, + "gate_passed": view.gate_passed, + "closeable": view.closeable, + "refused_because": view.refused_because, + "delivery": view.delivery, + "health": view.health, + }) +} + +fn build_session_status_json( + name: &str, + live: bool, + provider: Option<&str>, + health: Option<&serde_json::Value>, +) -> serde_json::Value { + serde_json::json!({ + "session": name, + "live": live, + "provider": provider, + "health": health, + }) +} + /// `omega status [--json]`. /// -/// A NON-oracle session keeps the original behaviour byte for byte (the last -/// 30 lines of its pane), because that is what every prompt template and the -/// `/omega-status` command tell an agent to read. -/// -/// An ORACLE gets the lifecycle block FIRST, then the same pane tail. The old -/// output answered "what is it printing right now" but never "is this mission -/// closeable", so the operator had to reconstruct the close-gate by hand from -/// three state files, and usually reconstructed it wrong. +/// `--json` is lifecycle only (no pane dump) so an external orchestrator can +/// observe without attaching. A NON-oracle human print still dumps the last +/// 30 pane lines. An ORACLE human print is the lifecycle block then the pane. async fn cmd_status(name: &str, json: bool) -> Result<()> { // Resolve the mission-key spelling BEFORE classifying: `dentistrygpt-3` // classifies as a plain session, so it took the pane-capture branch and died @@ -11740,6 +11971,26 @@ async fn cmd_status(name: &str, json: bool) -> Result<()> { let is_oracle = omega_core::session::OmegaSession::classify(name).role == omega_core::session::SessionRole::Oracle; if !is_oracle { + if json { + let session_live = live.iter().any(|s| s.name == name); + let provider = omega_core::session::read_session_provider(name); + if !session_live { + let _ = omega_core::session_health::observe( + &config.state_dir, + name, + provider.as_deref().unwrap_or("unknown"), + false, + None, + ); + } + let health = omega_core::session_health::read(&config.state_dir, name)? + .map(|h| serde_json::to_value(h).unwrap_or(serde_json::Value::Null)); + println!( + "{}", + build_session_status_json(name, session_live, provider.as_deref(), health.as_ref(),) + ); + return Ok(()); + } let content = mgr.capture_pane(name).await?; let lines: Vec<&str> = content.lines().collect(); let start = lines.len().saturating_sub(30); @@ -11819,19 +12070,57 @@ async fn cmd_status(name: &str, json: bool) -> Result<()> { .unwrap_or_else(|| "(no lifecycle state)".to_string()); if json { + if !session_live { + let provider = omega_core::session::read_session_provider(name) + .unwrap_or_else(|| "unknown".to_string()); + let _ = omega_core::session_health::observe( + &config.state_dir, + name, + &provider, + false, + None, + ); + } + let mut reports = Vec::new(); + for worker_name in workers.running.iter().chain(workers.terminal.iter()) { + if let Ok(Some(done)) = + omega_core::done::DoneSignal::read(&config.state_dir, worker_name) + { + reports.push(serde_json::json!({ + "session": worker_name, + "status": done.status, + "summary": done.summary, + "evidence": done.artifacts, + })); + } + } + let delivery = omega_core::dispatch::read_last_delivery(&config.state_dir, name) + .ok() + .flatten() + .and_then(|d| serde_json::to_value(d).ok()); + let health = omega_core::session_health::read(&config.state_dir, name) + .ok() + .flatten() + .and_then(|h| serde_json::to_value(h).ok()); println!( "{}", - serde_json::json!({ - "session": name, - "live": session_live, - "phase": phase, - "project": state.as_ref().map(|s| s.project.clone()), - "plan": { "done": done, "total": total }, - "doing": doing.map(|t| t.title.clone()), - "workers": { "running": workers.running, "terminal": workers.terminal }, - "gate_passed": gate_passed, - "closeable": !verdict.refused, - "refused_because": verdict.reasons, + build_oracle_status_json(OracleStatusJson { + name, + session_id: state.as_ref().and_then(|s| s.session_id.as_deref()), + live: session_live, + phase: &phase, + project: state.as_ref().map(|s| s.project.as_str()), + done, + total, + doing: doing.map(|t| t.title.as_str()), + running: &workers.running, + terminal: &workers.terminal, + reports: &reports, + gate_passed, + closeable: !verdict.refused, + refused_because: &verdict.reasons, + delivery: delivery.as_ref(), + health: health.as_ref(), }) ); return Ok(()); @@ -16710,6 +16999,39 @@ fn resolve_omega_src() -> Option { /// whose target is INSIDE ~/.omega — user-managed links are never touched. /// `symlink_metadata` succeeding while `exists()` (which follows the link) /// fails is the dangling test. +fn prune_unlisted_omega_skill_links( + dir: &std::path::Path, + omega_dir: &std::path::Path, + keep: &[&str], +) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + let skills_root = omega_dir.join("skills"); + for entry in entries.flatten() { + let path = entry.path(); + let Ok(meta) = std::fs::symlink_metadata(&path) else { + continue; + }; + if !meta.file_type().is_symlink() { + continue; + } + let Ok(target) = std::fs::read_link(&path) else { + continue; + }; + if !target.starts_with(&skills_root) && !target.starts_with(omega_dir) { + continue; + } + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if keep.contains(&name) { + continue; + } + if std::fs::remove_file(&path).is_ok() { + println!(" [-] pruned extra Codex skill link: {}", path.display()); + } + } +} + fn prune_dangling_omega_links(dir: &std::path::Path, omega_dir: &std::path::Path) { let Ok(entries) = std::fs::read_dir(dir) else { return; @@ -17006,33 +17328,38 @@ fn cmd_sync() -> Result<()> { } } - // Codex activates reusable skills from the provider-neutral - // ~/.agents/skills directory. Link every skill the canonical registry can - // parse, including categorized/nested entries. Mentioning a slash command - // in AGENTS.md alone does not make a skill discoverable by Codex. + // Codex SessionStart injects every skill under ~/.agents/skills. + // Dumping the full catalog (90+) exceeds the skills context budget + // (live: 49 skills dropped). Link only the Lab loop skill Omega oracles + // actually run; prune leftover omega-owned links that are not allowlisted. + const CODEX_SESSIONSTART_SKILLS: &[&str] = &["agentic-engineering-lab"]; let codex_skills = home.join(".agents").join("skills"); std::fs::create_dir_all(&codex_skills)?; prune_dangling_omega_links(&codex_skills, &omega_dir); + prune_unlisted_omega_skill_links(&codex_skills, &omega_dir, CODEX_SESSIONSTART_SKILLS); let skills_dir = omega_dir.join("skills"); if skills_dir.exists() { use omega_core::skill_registry::{OwnedSkillRoot, SkillCatalogV1, SkillRegistry}; let catalog = SkillCatalogV1::compile(&[OwnedSkillRoot::new("installed", &skills_dir)])?; let registry = SkillRegistry::from_catalog(&catalog, &skills_dir); + let mut linked = 0usize; for skill in registry.list() { + if !CODEX_SESSIONSTART_SKILLS.contains(&skill.name.as_str()) { + continue; + } let Some(skill_dir) = skill.path.parent() else { continue; }; let link = codex_skills.join(&skill.name); - if link.exists() { - continue; + if !link.exists() { + #[cfg(unix)] + std::os::unix::fs::symlink(skill_dir, &link)?; + println!(" [+] Codex SessionStart skill: ${}", skill.name); } - #[cfg(unix)] - std::os::unix::fs::symlink(skill_dir, &link)?; - println!(" [+] Codex skill: ${}", skill.name); + linked += 1; } println!( - "[+] Codex skills synced: {} canonical entries → {}", - registry.count(), + "[+] Codex SessionStart skills synced: {linked} allowlisted entries → {}", codex_skills.display() ); } @@ -17157,6 +17484,82 @@ mod phase1_tests { assert!(!cfg.claude.dangerously_skip_permissions); set_config_value(&mut cfg, "claude.dangerously_skip_permissions", "true").unwrap(); assert!(cfg.claude.dangerously_skip_permissions); + set_config_value(&mut cfg, "glm.dangerously_skip_permissions", "true").unwrap(); + assert!(cfg.glm.dangerously_skip_permissions); + assert_eq!( + get_config_value(&cfg, "glm.dangerously_skip_permissions").unwrap(), + "true" + ); + } + + #[test] + fn oracles_all_ignores_stale_mac_purge_names() { + assert!(is_stale_purge_oracle("oracle-mac-purge-20260822")); + assert!(is_stale_purge_oracle("oracle-mac-purge-20260822-2")); + assert!(!is_stale_purge_oracle("oracle-omega-orch-audit")); + assert!(!is_stale_purge_oracle("oracle-OmegaOS")); + } + + #[test] + fn status_json_payload_never_includes_pane_dump() { + let reports = vec![serde_json::json!({ + "session": "OmegaOS-worker-a", + "status": "done_clean", + "summary": "wrote the file", + "evidence": [] + })]; + let delivery = serde_json::json!({ + "tag": "followup", + "at": "2026-08-24T00:00:00Z", + "preview": "also check capture" + }); + let health = serde_json::json!({ + "session": "oracle-OmegaOS", + "provider": "codex", + "status": "running" + }); + let json = build_oracle_status_json(OracleStatusJson { + name: "oracle-OmegaOS", + session_id: Some("sess-1"), + live: true, + phase: "ANALYSE", + project: Some("OmegaOS"), + done: 0, + total: 11, + doing: Some("Understand"), + running: &[], + terminal: &["OmegaOS-worker-a".to_string()], + reports: &reports, + gate_passed: false, + closeable: false, + refused_because: &["plan 0/11 — pas 100% (L4)".to_string()], + delivery: Some(&delivery), + health: Some(&health), + }); + let rendered = serde_json::to_string(&json).unwrap(); + assert!(json.get("pane").is_none(), "{rendered}"); + assert!(!rendered.contains("─── pane")); + assert!(!rendered.contains("bash-5.3")); + assert_eq!(json["session"], "oracle-OmegaOS"); + assert_eq!(json["session_id"], "sess-1"); + assert_eq!(json["delivery"]["tag"], "followup"); + assert_eq!(json["health"]["status"], "running"); + assert_eq!(json["plan"]["total"], 11); + assert_eq!(json["workers"]["reports"][0]["status"], "done_clean"); + let home = build_session_status_json( + "t-codex", + false, + Some("codex"), + Some(&serde_json::json!({ + "session": "t-codex", + "provider": "codex", + "status": "failed", + "reason": "agent_exited: pane fell through to bash" + })), + ); + let home_rendered = serde_json::to_string(&home).unwrap(); + assert!(home.get("pane").is_none(), "{home_rendered}"); + assert_eq!(home["health"]["status"], "failed"); } #[test] @@ -19191,6 +19594,81 @@ mod phase1_tests { "Done Criteria: green\nVerify Command: cargo test && curl example.test" ) .is_none()); + assert_eq!( + declared_verify_command("Done Criteria: file exists\nVerify Command: CLAUDE_OK.txt"), + Some(vec!["CLAUDE_OK.txt".to_string()]), + "a bare artifact is recorded, not eval'd" + ); + } + + #[test] + fn spawn_records_bare_verify_file_without_evaling_it() { + let state_dir = std::env::temp_dir().join(format!( + "omega-v3-no-eval-verify-{}-{}", + std::process::id(), + chrono::Utc::now().timestamp_micros() + )); + std::fs::create_dir_all(&state_dir).unwrap(); + let config = OmegaConfig { + state_dir: state_dir.clone(), + ..OmegaConfig::default() + }; + let mission = + omega_core::mission::Mission::new("OmegaOS", "write marker", state_dir.clone()); + let ledger = omega_core::mission_ledger::MissionLedger::open( + state_dir.join("mission-engine-v3.sqlite3"), + ) + .unwrap(); + let created = ledger + .create_mission(&mission, "test-create", "test") + .unwrap(); + let mut classified = omega_core::mission_ledger::AppendEvent::new( + mission.id.clone(), + 1, + "test-classified", + "test", + "mission_classified", + ); + classified.next_mission_state = Some(omega_core::mission::MissionState::Classified); + ledger.append(classified).unwrap(); + let oracle_name = "oracle-OmegaOS-test"; + omega_core::oracle_lifecycle::OracleState::from_ledger(oracle_name, &mission, &created) + .unwrap() + .write(&state_dir) + .unwrap(); + + let marker = state_dir.join("CLAUDE_OK.txt"); + assert!(!marker.exists(), "precondition: file must not exist yet"); + prepare_v3_worker_attempt( + &config, + Some(oracle_name), + "OmegaOS-worker-claude-ok", + "claude-ok", + "Write CLAUDE_OK.txt\nDone Criteria: file exists\nVerify Command: CLAUDE_OK.txt", + state_dir.to_str().unwrap(), + &["CLAUDE_OK.txt".to_string()], + omega_core::agents::Agent::Claude, + ) + .unwrap(); + assert!( + !marker.exists(), + "Verify Command must not be eval'd at spawn before the worker writes the file" + ); + let plan = ledger.active_plan(&mission.id).unwrap().unwrap(); + let task = plan + .tasks + .iter() + .find(|task| task.task_id.as_str() == "claude-ok") + .unwrap(); + assert!( + matches!( + task.verifier_checks[0].kind, + omega_core::mission::VerifierCheckKind::FileExists { ref path } if path == "CLAUDE_OK.txt" + ), + "bare artifact verify is FileExists, not a shell command: {:?}", + task.verifier_checks[0].kind + ); + let _ = std::fs::remove_dir_all(&state_dir); } #[test] diff --git a/crates/omega-core/src/agents.rs b/crates/omega-core/src/agents.rs index 706428f3..1171dd9c 100644 --- a/crates/omega-core/src/agents.rs +++ b/crates/omega-core/src/agents.rs @@ -206,6 +206,23 @@ impl Agent { } } + /// Writers that may own a detached worker or an oracle mission. + /// Hermes is Home (`omega new --agent hermes`) and is never a writer. + pub fn is_writer(self) -> bool { + matches!(self, Agent::Claude | Agent::Codex | Agent::Glm) + } + + /// Map a configured Home/shell provider onto a writer. Used when the + /// global `agent_command` is Hermes but a worker/oracle still needs a + /// coding agent. + pub fn writer_or_codex(self) -> Self { + if self.is_writer() { + self + } else { + Agent::Codex + } + } + pub fn from_name(s: &str) -> Option { match s.to_lowercase().as_str() { "claude" => Some(Agent::Claude), @@ -510,7 +527,7 @@ impl Agent { providers.claude.dangerously_skip_permissions, )?; let mut args = format!( - "{}{}CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 claude{}", + "{}{}exec CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 claude{}", env_prefix, trust_prefix, permission_args ); if let Some(ref sys_file) = opts.system_prompt_file { @@ -614,11 +631,8 @@ impl Agent { (None, None) => None, }; match final_prompt { - Some(p) => format!( - "bash -c {}", - shell_quote(&format!("{} {}; exec bash", args, shell_quote(&p))) - ), - None => format!("bash -c {}", shell_quote(&format!("{}; exec bash", args))), + Some(p) => pane_bash(&format!("{} {}", args, shell_quote(&p))), + None => pane_bash(&args), } } Agent::Codex => { @@ -645,15 +659,19 @@ impl Agent { // dark rmux/omega TUI it blends in. Quoted so the ';' is one env // value, not a shell separator. let trust_prefix = "omega trust-dir \"$PWD\" >/dev/null 2>&1; "; - // Codex >=0.147 makes --approve-for-me a complete permission - // preset: it sets workspace-write + on-request itself and - // explicitly CONFLICTS with a separate --sandbox flag. Omega - // installs known hooks. Detached panes bypass the otherwise - // blocking review by default; operators with additional - // untrusted hooks can disable that explicit provider setting. + // Codex >=0.147: `--approve-for-me` CONFLICTS with `--sandbox` + // (CLI *or* ~/.codex/config.toml sandbox_mode). Live 0.149.1 + // dies with "`--sandbox` cannot be used with `--approve-for-me`" + // and the pane used to fall through to bash. The valid + // unattended pair is workspace-write + never-ask. + let approval = if providers.codex.ask_for_approval_never { + "--sandbox workspace-write --ask-for-approval never" + } else { + "--sandbox workspace-write --ask-for-approval on-request" + }; let mut args = format!( - "{}{}COLORFGBG='15;0' codex --strict-config --approve-for-me", - env_prefix, trust_prefix + "{}{}exec COLORFGBG='15;0' codex --strict-config {}", + env_prefix, trust_prefix, approval ); if providers.codex.bypass_hook_trust { args.push_str(" --dangerously-bypass-hook-trust"); @@ -676,11 +694,8 @@ impl Agent { args.push_str(" resume --last"); } match initial_prompt { - Some(p) => format!( - "bash -c {}", - shell_quote(&format!("{} -- {}; exec bash", args, shell_quote(p))) - ), - None => format!("bash -c {}", shell_quote(&format!("{}; exec bash", args))), + Some(p) => pane_bash(&format!("{} -- {}", args, shell_quote(p))), + None => pane_bash(&args), } } Agent::Gemini => { @@ -692,28 +707,24 @@ impl Agent { } else { "" }; + let yolo_arg = if providers.gemini.yolo { " --yolo" } else { "" }; match initial_prompt { - Some(p) => format!( - "bash -c {}", - shell_quote(&format!( - "{}gemini{}{} --prompt-interactive {}; exec bash", - env_prefix, - model_arg, - resume_arg, - shell_quote(p) - )) - ), - None => format!( - "bash -c {}", - shell_quote(&format!( - "{}gemini{}{}; exec bash", - env_prefix, model_arg, resume_arg - )) - ), + Some(p) => pane_bash(&format!( + "{}exec gemini{}{}{} --prompt-interactive {}", + env_prefix, + model_arg, + yolo_arg, + resume_arg, + shell_quote(p) + )), + None => pane_bash(&format!( + "{}exec gemini{}{}{}", + env_prefix, model_arg, yolo_arg, resume_arg + )), } } Agent::Antigravity => { - let mut args = format!("{}agy", env_prefix); + let mut args = format!("{}exec agy", env_prefix); if providers.antigravity.dangerously_skip_permissions { args.push_str(" --dangerously-skip-permissions"); } @@ -727,17 +738,12 @@ impl Agent { args.push_str(" --continue"); } match initial_prompt { - Some(prompt) => format!( - "bash -c {}", - shell_quote(&format!( - "{} --prompt-interactive {}; exec bash", - args, - shell_quote(prompt) - )) - ), - None => { - format!("bash -c {}", shell_quote(&format!("{}; exec bash", args))) - } + Some(prompt) => pane_bash(&format!( + "{} --prompt-interactive {}", + args, + shell_quote(prompt) + )), + None => pane_bash(&args), } } Agent::Pi => { @@ -764,24 +770,26 @@ impl Agent { } else { "" }; + // Official Pi CLI has no tool-yolo. `--approve` only skips + // project-trust; document that, do not invent a bypass. + let approve_arg = if providers.pi.approve { + " --approve" + } else { + "" + }; match initial_prompt { - Some(p) => format!( - "bash -c {}", - shell_quote(&format!( - "{}pi {}{} -- {}; exec bash", - env_prefix, - pi_args, - resume_arg, - shell_quote(p) - )) - ), - None => format!( - "bash -c {}", - shell_quote(&format!( - "{}pi {}{}; exec bash", - env_prefix, pi_args, resume_arg - )) - ), + Some(p) => pane_bash(&format!( + "{}exec pi {}{}{} -- {}", + env_prefix, + pi_args, + approve_arg, + resume_arg, + shell_quote(p) + )), + None => pane_bash(&format!( + "{}exec pi {}{}{}", + env_prefix, pi_args, approve_arg, resume_arg + )), } } Agent::OpenRouter => { @@ -801,19 +809,13 @@ impl Agent { resume_arg ); match initial_prompt { - Some(prompt) => format!( - "bash -c {}", - shell_quote(&format!( - "{}pi {} -- {}; exec bash", - env_prefix, - args, - shell_quote(prompt) - )) - ), - None => format!( - "bash -c {}", - shell_quote(&format!("{}pi {}; exec bash", env_prefix, args)) - ), + Some(prompt) => pane_bash(&format!( + "{}exec pi {} -- {}", + env_prefix, + args, + shell_quote(prompt) + )), + None => pane_bash(&format!("{}exec pi {}", env_prefix, args)), } } Agent::Hermes => { @@ -844,25 +846,30 @@ impl Agent { } else { "" }; + // Home TUI. `--yolo` / HERMES_YOLO_MODE keep tool calls from + // blocking a detached pane. Never `-q`: that is a one-shot + // query that exits and used to drop the pane to bash. + let yolo_arg = if providers.hermes.yolo { " --yolo" } else { "" }; + let yolo_env = if providers.hermes.yolo { + "HERMES_YOLO_MODE=1 " + } else { + "" + }; match initial_prompt { - Some(p) => format!( - "bash -c {}", - shell_quote(&format!( - "{}hermes chat{}{}{} -q {}; exec bash", - env_prefix, - provider_arg, - hermes_args, - resume_arg, - shell_quote(p) - )) - ), - None => format!( - "bash -c {}", - shell_quote(&format!( - "{}hermes chat{}{}{}; exec bash", - env_prefix, provider_arg, hermes_args, resume_arg - )) - ), + Some(p) => pane_bash(&format!( + "{}exec {}hermes chat{}{}{}{} {}", + env_prefix, + yolo_env, + provider_arg, + hermes_args, + yolo_arg, + resume_arg, + shell_quote(p) + )), + None => pane_bash(&format!( + "{}exec {}hermes chat{}{}{}{}", + env_prefix, yolo_env, provider_arg, hermes_args, yolo_arg, resume_arg + )), } } Agent::Glm => { @@ -891,25 +898,19 @@ impl Agent { "" }; match initial_prompt { - Some(p) => format!( - "bash -c {}", - shell_quote(&format!( - "{} {}CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 claude{}{}{} {}; exec bash", - env_prefix, - trust_prefix, - perms, - model_arg, - resume_arg, - shell_quote(p) - )) - ), - None => format!( - "bash -c {}", - shell_quote(&format!( - "{} {}CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 claude{}{}{}; exec bash", - env_prefix, trust_prefix, perms, model_arg, resume_arg - )) - ), + Some(p) => pane_bash(&format!( + "{} {}exec CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 claude{}{}{} {}", + env_prefix, + trust_prefix, + perms, + model_arg, + resume_arg, + shell_quote(p) + )), + None => pane_bash(&format!( + "{} {}exec CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 claude{}{}{}", + env_prefix, trust_prefix, perms, model_arg, resume_arg + )), } } Agent::Kimi => { @@ -930,24 +931,20 @@ impl Agent { } else { "" }; + let auto_arg = if providers.kimi.auto { " --auto" } else { "" }; match initial_prompt { - Some(p) => format!( - "bash -c {}", - shell_quote(&format!( - "{}kimi{}{} --prompt {}; exec bash", - env_prefix, - model_arg, - resume_arg, - shell_quote(p) - )) - ), - None => format!( - "bash -c {}", - shell_quote(&format!( - "{}kimi --auto{}{}; exec bash", - env_prefix, model_arg, resume_arg - )) - ), + Some(p) => pane_bash(&format!( + "{}exec kimi{}{}{} --prompt {}", + env_prefix, + auto_arg, + model_arg, + resume_arg, + shell_quote(p) + )), + None => pane_bash(&format!( + "{}exec kimi{}{}{}", + env_prefix, auto_arg, model_arg, resume_arg + )), } } Agent::Shell => match initial_prompt { @@ -1098,6 +1095,17 @@ fn shell_quote(s: &str) -> String { format!("'{}'", s.replace('\'', "'\\''")) } +/// The pane process *is* the agent. Callers must put `exec` immediately +/// before the agent binary so a crash cannot fall through to bash. +/// Agent exit = session death. Never append `; exec bash` after the agent. +fn pane_bash(inner: &str) -> String { + assert!( + !inner.contains("; exec bash"), + "agent launch must not fall through to bash: {inner}" + ); + format!("bash -c {}", shell_quote(inner)) +} + #[cfg(test)] mod tests { use super::*; @@ -1196,13 +1204,19 @@ mod tests { let cmd = launch(Agent::Codex, None, LaunchOptions::default()); // Color is preserved (no NO_COLOR); a dark-terminal hint keeps Codex's // band readable (light-on-dark) instead of black-on-black; inline render. + // Never pair --sandbox with --approve-for-me (Codex 0.149 dies). assert!( !cmd.contains("NO_COLOR") && cmd.contains("COLORFGBG=") && cmd.contains("15;0") - && cmd.contains("codex --strict-config --approve-for-me") - && cmd.contains("--approve-for-me") - && !cmd.contains("--sandbox") + && cmd.contains("codex --strict-config") + && cmd.contains("--sandbox") + && cmd.contains("workspace-write") + && cmd.contains("--ask-for-approval") + && cmd.contains("never") + && !cmd.contains("--approve-for-me") + && !cmd.contains("; exec bash") + && cmd.contains("exec COLORFGBG=") && cmd.contains("--add-dir") && cmd.contains("--dangerously-bypass-hook-trust") && cmd.contains("--no-alt-screen"), @@ -1210,6 +1224,68 @@ mod tests { ); } + #[test] + fn home_launch_stays_alive_for_codex_claude_hermes() { + for agent in [Agent::Codex, Agent::Claude, Agent::Hermes] { + let cmd = launch(agent, None, LaunchOptions::default()); + assert!( + !cmd.contains("; exec bash"), + "{} Home launch must not fall through to bash: {cmd}", + agent.name() + ); + assert!( + cmd.contains("exec "), + "{} Home pane must exec the agent (same as TUI New {}): {cmd}", + agent.name(), + agent.display_name() + ); + assert!( + cmd.contains("bash -c "), + "{} must share the pane_bash wrapper TUI uses: {cmd}", + agent.name() + ); + } + let tui = launch(Agent::Codex, None, LaunchOptions::default()); + let cli = Agent::Codex.try_launch(None).unwrap().command().to_string(); + assert_eq!( + tui, cli, + "omega new --agent codex must use the same command as TUI New Codex" + ); + } + + #[test] + fn agent_pane_is_the_agent_not_a_bash_fallback() { + for agent in [ + Agent::Claude, + Agent::Codex, + Agent::Gemini, + Agent::Antigravity, + Agent::Pi, + Agent::OpenRouter, + Agent::Hermes, + Agent::Glm, + Agent::Kimi, + ] { + let cmd = launch( + agent, + Some("inspect the repository"), + LaunchOptions::default(), + ); + assert!( + !cmd.contains("; exec bash"), + "{} must not fall through to bash: {cmd}", + agent.name() + ); + assert!( + cmd.contains("exec "), + "{} pane must exec the agent: {cmd}", + agent.name() + ); + } + let shell = launch(Agent::Shell, None, LaunchOptions::default()); + assert_eq!(shell, "bash"); + } + #[test] fn claude_interactive_defaults_to_auto_and_omits_print_only_limits() { let opts = LaunchOptions { @@ -1283,18 +1359,21 @@ mod tests { assert!(!cmd.contains("key with ' quote; $(touch nope)"), "{cmd}"); assert!(!cmd.contains("export KIMI_API_KEY="), "{cmd}"); assert!(cmd.contains("--prompt"), "{cmd}"); - assert!(!cmd.contains("kimi --auto"), "{cmd}"); + assert!(cmd.contains("--auto"), "{cmd}"); } #[test] fn kimi_interactive_session_uses_auto_policy() { let cmd = launch(Agent::Kimi, None, LaunchOptions::default()); - assert!(cmd.contains("kimi --auto"), "{cmd}"); + assert!( + cmd.contains("kimi --auto") || cmd.contains("kimi --auto") || cmd.contains("--auto"), + "{cmd}" + ); assert!(!cmd.contains("--prompt"), "{cmd}"); } #[test] - fn hermes_prompt_uses_chat_query_subcommand() { + fn hermes_home_stays_a_tui_and_never_uses_query_lane() { let providers = ProvidersConfig { hermes: crate::providers::HermesConfig { provider: "openrouter".to_string(), @@ -1311,7 +1390,10 @@ mod tests { .unwrap(); assert!(cmd.contains("hermes chat --provider"), "{cmd}"); assert!(cmd.contains("openrouter"), "{cmd}"); - assert!(cmd.contains(" -q "), "{cmd}"); + assert!(cmd.contains("--yolo"), "{cmd}"); + assert!(cmd.contains("HERMES_YOLO_MODE=1"), "{cmd}"); + assert!(!cmd.contains(" -q "), "{cmd}"); + assert!(!cmd.contains("; exec bash"), "{cmd}"); } #[test] @@ -1322,6 +1404,8 @@ mod tests { LaunchOptions::default(), ); assert!(cmd.contains("--prompt-interactive"), "{cmd}"); + assert!(cmd.contains("--yolo"), "{cmd}"); + assert!(!cmd.contains("; exec bash"), "{cmd}"); } #[test] diff --git a/crates/omega-core/src/dispatch.rs b/crates/omega-core/src/dispatch.rs index 89d857f4..9f4f20a6 100644 --- a/crates/omega-core/src/dispatch.rs +++ b/crates/omega-core/src/dispatch.rs @@ -382,7 +382,8 @@ pub const FOLLOWUP_ROUTING_ENV: &str = "OMEGA_FOLLOWUP_ROUTING"; /// It shipped the other way round first, and deliberately: the routing /// DECISION ([`route_dispatch`]) was sound, but the DELIVERY half accepted /// three classes of pane that are not the agent's composer — a live bash shell -/// left behind by `bash -c ' …; exec bash'` after the agent dies, a +/// (legacy launches used to `exec bash` after the agent; a dead agent can +/// still leave a shell if someone typed `bash` in the pane), a /// modal whose hint wrapped or which carried no hint at all, and a composer /// holding the operator's unsent draft — each reproduced in runtime. The /// default flips now that the probe demands positive evidence instead @@ -548,11 +549,10 @@ pub fn route_now( /// ANYWHERE in the pane plus the absence of one known question modal, and a /// forensic audit reproduced three panes that pass that test and must not: /// -/// 1. A LIVE BASH SHELL. Every oracle runs as `bash -c ' …; exec bash'` -/// (agents.rs:452), so an agent that dies — crash, `--max-turns`, budget, -/// auth, a reused session id — leaves the session UP, its last frame on -/// screen with the composer in it, and a shell prompt underneath. The -/// mission body would have executed there as command lines. +/// 1. A LIVE BASH SHELL. Agent panes now `exec` the agent (agent exit = +/// session death). A leftover bash pane still exists when the operator +/// typed `bash`/`codex` by hand, or on a pre-fix session. The mission +/// body must not execute there as command lines. /// 2. A MODAL THE BLACKLIST DOES NOT KNOW: the same question modal with its /// hint hard-wrapped onto two lines (a narrow pane in a split layout), or a /// numbered permission dialog, which draws no hint at all. The Enter that @@ -641,6 +641,73 @@ fn gen_session_uuid() -> String { ) } +fn resolve_dispatch_agent( + agent_override: Option<&str>, + configured: &str, +) -> Result { + crate::external_orchestrator::resolve_mission_writer(agent_override, configured) +} + +fn seed_lab_plan(state_dir: &Path, oracle_name: &str) -> Result<()> { + let mut todo = crate::oracle_todo::OracleTodo::load(state_dir, oracle_name)?; + todo.set_plan(crate::lab::LAB_LOOP_STEPS.iter().copied()); + let _ = todo.upsert( + "Understand", + crate::oracle_todo::TodoStatus::Doing, + Some("seeded by omega dispatch — AGK Agentic Engineering Lab loop"), + ); + todo.save(state_dir, oracle_name)?; + Ok(()) +} + +/// Last `omega dispatch` delivery for an oracle — the Cursor-sidebar `reply` +/// record Grok reads from `status --json` without attaching. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct LastDelivery { + pub tag: String, + pub at: String, + pub preview: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub confirmed: Option, +} + +pub fn last_delivery_path(state_dir: &Path, oracle_name: &str) -> Result { + crate::scope::validate_session_identity(oracle_name)?; + let key = oracle_name.strip_prefix("oracle-").unwrap_or(oracle_name); + crate::scope::validate_session_identity(key)?; + Ok(state_dir.join(format!("oracle-{key}.delivery.json"))) +} + +pub fn persist_last_delivery( + state_dir: &Path, + oracle_name: &str, + tag: &str, + preview: &str, + confirmed: Option, +) -> Result<()> { + let path = last_delivery_path(state_dir, oracle_name)?; + let preview: String = preview.chars().take(240).collect(); + let record = LastDelivery { + tag: tag.to_string(), + at: chrono::Utc::now().to_rfc3339(), + preview, + confirmed, + }; + let bytes = serde_json::to_vec_pretty(&record).context("serializing last delivery")?; + crate::config::atomic_write_private(&path, &bytes) + .with_context(|| format!("writing last delivery {}", path.display())) +} + +pub fn read_last_delivery(state_dir: &Path, oracle_name: &str) -> Result> { + let path = last_delivery_path(state_dir, oracle_name)?; + let Some(bytes) = crate::config::read_private_optional(&path)? else { + return Ok(None); + }; + Ok(Some(serde_json::from_slice(&bytes).with_context(|| { + format!("parsing last delivery {}", path.display()) + })?)) +} + /// Mint a FRESH `--session-id` for an oracle dispatch and persist it. /// /// CRITICAL: `claude --session-id ` CREATES a session with that exact id and @@ -814,6 +881,59 @@ impl Dispatcher { .map(|outcome| outcome.oracle_name) } + /// After spawn: if the agent dies to bash or the pane vanishes, fail JSON. + /// Empty splash frames stay `running` — that is not death. + async fn observe_spawned_oracle(&self, oracle: &str, provider: &str) -> Result<()> { + let mut last_health = + crate::session_health::record_launch(&self.config.state_dir, oracle, provider) + .unwrap_or_else(|_| crate::session_health::SessionHealth::launch(oracle, provider)); + for probe in 0..3 { + tokio::time::sleep(Duration::from_secs(2)).await; + let live = self + .session_mgr + .list_sessions() + .await + .ok() + .is_some_and(|sessions| sessions.iter().any(|session| session.name == oracle)); + let pane = if live { + self.session_mgr.capture_pane(oracle).await.ok() + } else { + None + }; + last_health = crate::session_health::observe( + &self.config.state_dir, + oracle, + provider, + live, + pane.as_deref(), + )?; + if last_health.is_failed() { + anyhow::bail!( + "{}", + serde_json::json!({ + "error": "agent_exited", + "oracle": oracle, + "provider": provider, + "delivery": "spawned_failed", + "reason": last_health.reason, + "probe": probe, + "message": "oracle agent died after launch; session is failed, not a silent bash. Retry or pass --new." + }) + ); + } + if live + && pane.as_deref().is_some_and(|text| { + !text.trim().is_empty() + && !crate::session_health::pane_fell_to_silent_bash(text) + }) + { + return Ok(()); + } + } + let _ = last_health; + Ok(()) + } + /// Probe the pane until it presents a typeable composer, bounded by /// `FOLLOWUP_PANE_ATTEMPTS * FOLLOWUP_PANE_INTERVAL`. Returns whether it is /// safe to type. A capture error is treated as not-ready: we never type into @@ -1205,7 +1325,6 @@ impl Dispatcher { let followup_allowed = followup_routing_enabled(); let route = route_now(&state_dir, project, &live, force_new || !followup_allowed); - let mut pane_not_ready = false; let preferred: Option = match route { DispatchRoute::Followup { oracle } => { let outcome = self.deliver_followup(&oracle, project, mission).await; @@ -1216,37 +1335,38 @@ impl Dispatcher { // mission already delivered, and it created a sibling oracle // carrying the same mission_text. if let Some(delivery) = followup_disposition(outcome) { + let _ = persist_last_delivery( + &state_dir, + &oracle, + delivery.tag(), + mission, + Some(matches!(delivery, DispatchDelivery::Followup)), + ); return Ok(DispatchOutcome { oracle_name: oracle, delivery, }); } - // NOTHING WAS TYPED: the composer never became typeable, the - // target stopped qualifying, or its pane could not be re-read - // at the last look. The mission exists nowhere, so fall through - // to a normal spawn — a followup that lands in a shell, or is - // lost, is worse than the sibling oracle we are trying to avoid. - pane_not_ready = true; - // …but do NOT throw away the idle-recycling candidate on the - // way out. Asking the SAME router with `force_new` skips only - // the followup branch and returns the `preferred` name the - // pre-existing idle-reuse rules would have picked, which is - // what the commit message claimed was left unchanged and what - // hardcoding `None` here quietly broke. The session list is - // re-read because the probe may have slept for eight seconds. - let live_now: Vec = self - .session_mgr - .list_sessions() - .await - .unwrap_or_default() - .iter() - .map(|s| s.name.clone()) - .collect(); - match route_now(&state_dir, project, &live_now, true) { - DispatchRoute::Spawn { preferred } => preferred, - // Unreachable: `force_new` skips the followup branch. - DispatchRoute::Followup { .. } => None, - } + // NOTHING WAS TYPED. Do NOT spawn oracle-*-2. Twin oracles + // are how follow-up on a not-ready composer duplicated the + // mission (DISPATCH_DELIVERY=spawned_pane_not_ready). Wait + // or fail JSON — same as Cursor Cloud Agent `reply`. + let _ = persist_last_delivery( + &state_dir, + &oracle, + "followup_blocked", + mission, + Some(false), + ); + anyhow::bail!( + "{}", + serde_json::json!({ + "error": "followup_pane_not_ready", + "oracle": oracle, + "delivery": "followup_blocked", + "message": "live oracle composer is not typeable; refusing to spawn a sibling. Retry when the pane is ready, or pass --new." + }) + ); } DispatchRoute::Spawn { preferred } => preferred, }; @@ -1314,9 +1434,17 @@ impl Dispatcher { // The legacy OracleState is now a projection carrying the same stable // mission identity. It remains for existing readers during migration, // but is never allowed to invent an empty mission for this path. - let oracle_state = + let mut oracle_state = OracleState::from_ledger(&oracle_name, &mission_record, &classified_outcome)?; + // Stamp session_id on the FIRST write. A later resolve+rewrite can + // fail CAS and leave ANALYSE with session_id null — that is an + // Omega persist hole, not "Codex is down". + let session_id = gen_session_uuid(); + oracle_state.session_id = Some(session_id.clone()); oracle_state.write(&self.config.state_dir)?; + seed_lab_plan(&self.config.state_dir, &oracle_name).with_context(|| { + format!("seeding Lab plan for {oracle_name} — ANALYSE with 0/0 is not a dispatch") + })?; let ship = OraclePromptGenerator::should_ship(mission); let god_mode = OraclePromptGenerator::is_god_mode(mission); @@ -1386,20 +1514,8 @@ impl Dispatcher { // The per-mission override wins over the configured default. Resolve // the typed provider before compiling rules so provider-only doctrine // cannot leak or disappear through a neutral prompt. - let agent = match agent_override { - Some(name) => crate::agents::Agent::from_name(name).ok_or_else(|| { - anyhow::anyhow!( - "unknown agent '{}' — expected one of: claude, codex, gemini, pi, hermes, glm, kimi, shell", - name - ) - })?, - None => crate::agents::Agent::from_name(&self.config.agent_command).ok_or_else(|| { - anyhow::anyhow!( - "configured agent `{}` is unknown; refusing to dispatch on an implicit provider", - self.config.agent_command - ) - })?, - }; + let agent = resolve_dispatch_agent(agent_override, &self.config.agent_command)?; + crate::external_orchestrator::headless_writer_launch(agent, Some(&prompt))?; // THE FUNNEL — every dispatched agent (any LLM backend) MUST receive // its role-scoped Laws + operational rules via this single call. @@ -1424,6 +1540,7 @@ impl Dispatcher { prompt.push_str("\n\n"); prompt.push_str(&compiled.markdown); } + prompt.push_str(&crate::lab::oracle_lab_block()); // Claude-only smart spawn (2026-w20 features): /goal + --effort + // budget caps. Gemini/GLM/Pi/Hermes fall back to the bare launcher @@ -1452,6 +1569,14 @@ impl Dispatcher { &[crate::providers::ProviderCapability::LongContext], ) .map_err(|error| anyhow::anyhow!("provider capability negotiation failed: {error}"))?; + // First write already stamped session_id. Reminting here used to + // rewrite under CAS and leave ANALYSE with session_id=null when that + // rewrite lost. Resurrect still calls resolve_session_id (fresh + // conversation). This is an Omega persist hole, not "Codex is down". + let session_id = match oracle_state.session_id.clone() { + Some(id) => id, + None => resolve_session_id(&self.config.state_dir, &oracle_name, project, &work_path), + }; if matches!(agent, crate::agents::Agent::Claude) { let mut opts = crate::agents::LaunchOptions::default(); // Ultracode posture: the oracle is the strategic brain — it reasons @@ -1536,12 +1661,7 @@ impl Dispatcher { ), } opts.exclude_dynamic_prompt_sections = true; - opts.session_id = Some(resolve_session_id( - &self.config.state_dir, - &oracle_name, - project, - &work_path, - )); + opts.session_id = Some(session_id.clone()); opts.debug_file = Some( self.config .state_dir @@ -1637,13 +1757,29 @@ impl Dispatcher { } } } + let _ = persist_last_delivery( + &self.config.state_dir, + &oracle_name, + DispatchDelivery::Spawned.tag(), + mission, + None, + ); + if let Err(error) = self + .observe_spawned_oracle(&oracle_name, agent.name()) + .await + { + let _ = persist_last_delivery( + &self.config.state_dir, + &oracle_name, + "spawned_failed", + mission, + Some(false), + ); + return Err(error); + } Ok(DispatchOutcome { oracle_name, - delivery: if pane_not_ready { - DispatchDelivery::SpawnedPaneNotReady - } else { - DispatchDelivery::Spawned - }, + delivery: DispatchDelivery::Spawned, }) } @@ -1797,6 +1933,12 @@ impl Dispatcher { .create_agent_session_with_opts(oracle_name, &work_dir, agent, Some(&prompt), opts) .await?; } else { + let _ = resolve_session_id( + &self.config.state_dir, + oracle_name, + &state.project, + &state.working_dir, + ); self.session_mgr .create_agent_session(oracle_name, &work_dir, agent.name(), Some(&prompt)) .await?; @@ -2956,14 +3098,27 @@ mod followup_routing_tests { } } - /// THE OTHER HALF, and the reason this is not simply "never spawn": when - /// NOTHING was typed, the mission exists nowhere and the spawn is the only - /// thing that delivers it. That path stays exactly as it was. + /// When NOTHING was typed, disposition is None — and the caller now + /// returns a JSON error instead of spawning oracle-*-2. #[test] - fn a_followup_that_was_never_sent_still_falls_back_to_a_spawn() { + fn a_followup_that_was_never_sent_does_not_authorize_a_spawn() { assert_eq!(followup_disposition(FollowupOutcome::NotSent), None); } + #[test] + fn hermes_is_home_and_cannot_be_dispatched() { + let err = resolve_dispatch_agent(Some("hermes"), "codex").unwrap_err(); + let text = err.to_string(); + assert!(text.contains("hermes_is_home"), "{text}"); + assert!(text.contains("omega new --agent hermes"), "{text}"); + } + + #[test] + fn configured_hermes_defaults_dispatch_to_codex() { + let agent = resolve_dispatch_agent(None, "hermes").unwrap(); + assert_eq!(agent, crate::agents::Agent::Codex); + } + /// The CLI must not open a session journal under a LIVE oracle's name — it /// either appends a second session header into the journal of the mission /// still running or hides it behind a near-empty newer file (omega-cli @@ -3256,4 +3411,69 @@ mod ledger_followup_tests { ); assert!(append_followup_event(tmp.path(), "oracle-terminal", "too late", true).is_err()); } + + #[test] + fn last_delivery_is_visible_for_status_json_without_a_pane() { + let tmp = tempfile::TempDir::new().unwrap(); + persist_last_delivery( + tmp.path(), + "oracle-OmegaOS", + "followup", + "also verify the Telegram path", + Some(true), + ) + .unwrap(); + let got = read_last_delivery(tmp.path(), "oracle-OmegaOS") + .unwrap() + .expect("delivery"); + assert_eq!(got.tag, "followup"); + assert_eq!(got.confirmed, Some(true)); + assert!(got.preview.contains("Telegram")); + let json = serde_json::to_value(&got).unwrap(); + assert!(json.get("pane").is_none()); + } + + #[test] + fn seed_lab_plan_writes_eleven_steps_or_fails() { + let tmp = tempfile::TempDir::new().unwrap(); + let ledger = MissionLedger::open(mission_ledger_path(tmp.path())).unwrap(); + let mission = Mission::new("OmegaOS", "tiny mission", PathBuf::from("/tmp/OmegaOS")); + ledger + .create_mission( + &mission, + &format!("test:{}:created", mission.id.as_str()), + "test", + ) + .unwrap(); + let mut classified = AppendEvent::new( + mission.id.clone(), + 1, + format!("test:{}:classified", mission.id.as_str()), + "test", + "mission_classified", + ); + classified.next_mission_state = Some(MissionState::Classified); + let classified = ledger.append(classified).unwrap(); + let mut state = OracleState::from_ledger("oracle-OmegaOS", &mission, &classified).unwrap(); + assert!( + state.session_id.is_none(), + "from_ledger must not invent a conversation id" + ); + let session_id = gen_session_uuid(); + state.session_id = Some(session_id.clone()); + state.write(tmp.path()).unwrap(); + let loaded = OracleState::read(tmp.path(), "oracle-OmegaOS") + .unwrap() + .unwrap(); + assert_eq!( + loaded.session_id.as_deref(), + Some(session_id.as_str()), + "first persist must carry session_id so ANALYSE is not session_id=null" + ); + seed_lab_plan(tmp.path(), "oracle-OmegaOS").unwrap(); + let todo = crate::oracle_todo::OracleTodo::load(tmp.path(), "oracle-OmegaOS").unwrap(); + assert_eq!(todo.tasks.len(), 11); + assert_eq!(todo.tasks[0].title, "Understand"); + assert_eq!(todo.tasks[0].status, crate::oracle_todo::TodoStatus::Doing); + } } diff --git a/crates/omega-core/src/doctor.rs b/crates/omega-core/src/doctor.rs index 21d3a53e..b69dff6c 100644 --- a/crates/omega-core/src/doctor.rs +++ b/crates/omega-core/src/doctor.rs @@ -377,7 +377,8 @@ fn minimum_agent_version(agent: crate::agents::Agent) -> Option let raw = match agent { // Opus 5 support starts here. Agent::Claude | Agent::Glm => "2.1.219", - // --approve-for-me is stable from 0.147 onward. + // --sandbox workspace-write + --ask-for-approval never (never pair + // sandbox with --approve-for-me; 0.149 dies). Agent::Codex => "0.147.0", // First stable Gemini 3.1 model support. Agent::Gemini => "0.31.0", diff --git a/crates/omega-core/src/external_orchestrator.rs b/crates/omega-core/src/external_orchestrator.rs new file mode 100644 index 00000000..e914ac85 --- /dev/null +++ b/crates/omega-core/src/external_orchestrator.rs @@ -0,0 +1,139 @@ +//! Headless contract for an **external** orchestrator (Grok Bot). +//! +//! Grok Bot is an external orchestrator. Atlas/Telegram is optional. One +//! oracle per project. Review is outside Omega. +//! +//! Loop Grok actually runs: +//! 1. Observe: `oracles`, `workers`, `status --json`, `progress` (read-back). +//! 2. `omega dispatch ""` (default Codex). Never `--agent hermes`. +//! 3. The oracle plans/verifies and calls `omega spawn-worker` (claude|codex|glm). +//! 4. Reap finish reports. Writer `omega done` is a candidate, not a verdict. +//! 5. Kill / close the mission. Gareth alone may `omega gate --accept`. +//! +//! `omega send` / `omega attach` into a provider setup wizard is forbidden. +//! Dispatch and orchestrate must refuse a launch that *is* a login/wizard +//! rather than starting one and hoping Grok types through it. + +use crate::agents::Agent; +use anyhow::Result; + +pub fn hermes_is_home_error() -> serde_json::Value { + serde_json::json!({ + "error": "hermes_is_home", + "message": "Hermes is Home (`omega new --agent hermes`). Do not dispatch --agent hermes. Use claude, codex, or glm." + }) +} + +pub fn wizard_refused_error() -> serde_json::Value { + serde_json::json!({ + "error": "wizard_refused", + "message": "dispatch/orchestrate must not launch a provider setup wizard. Log in from a Home pane (`omega new --agent …`) or `omega doctor`. Grok must not type into wizards." + }) +} + +/// Explicit `--agent hermes` is refused. A configured Home Hermes falls +/// through to Codex so `omega dispatch` / `omega orchestrate` stay writers. +pub fn resolve_mission_writer(explicit: Option<&str>, configured: &str) -> Result { + if let Some(name) = explicit { + if name.eq_ignore_ascii_case("hermes") { + anyhow::bail!("{}", hermes_is_home_error()); + } + let agent = Agent::from_name(name).ok_or_else(|| { + anyhow::anyhow!( + "unknown agent '{}' — expected one of: claude, codex, gemini, pi, glm, kimi, shell", + name + ) + })?; + refuse_hermes_dispatch(agent)?; + Ok(agent) + } else { + let agent = Agent::from_name(configured).ok_or_else(|| { + anyhow::anyhow!( + "configured agent `{configured}` is unknown; refusing to dispatch on an implicit provider" + ) + })?; + if matches!(agent, Agent::Hermes) { + return Ok(Agent::Codex); + } + Ok(agent) + } +} + +pub fn refuse_hermes_dispatch(agent: Agent) -> Result<()> { + if matches!(agent, Agent::Hermes) { + anyhow::bail!("{}", hermes_is_home_error()); + } + Ok(()) +} + +/// True when the pane command is a login / device-auth / `/login` wizard +/// rather than the agent TUI. Dispatch must never start these. +pub fn command_starts_provider_wizard(command: &str) -> bool { + let c = command.to_ascii_lowercase(); + c.contains(" login") + || c.contains("\tlogin") + || c.contains("auth login") + || c.contains("device-auth") + || c.contains("/login") + || c.contains(" --login") +} + +pub fn refuse_wizard_launch(command: &str) -> Result<()> { + if command_starts_provider_wizard(command) { + anyhow::bail!("{}", wizard_refused_error()); + } + Ok(()) +} + +/// Writer launch used by dispatch/orchestrate: Hermes refused, no login argv. +pub fn headless_writer_launch(agent: Agent, prompt: Option<&str>) -> Result { + refuse_hermes_dispatch(agent)?; + let launch = agent.try_launch(prompt)?; + refuse_wizard_launch(launch.command())?; + Ok(launch.command().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hermes_dispatch_is_refused() { + let err = resolve_mission_writer(Some("hermes"), "codex").unwrap_err(); + let text = err.to_string(); + assert!(text.contains("hermes_is_home"), "{text}"); + assert!(text.contains("omega new --agent hermes"), "{text}"); + assert!(refuse_hermes_dispatch(Agent::Hermes).is_err()); + assert!(headless_writer_launch(Agent::Hermes, None).is_err()); + } + + #[test] + fn configured_hermes_defaults_to_codex_writer() { + assert_eq!( + resolve_mission_writer(None, "hermes").unwrap(), + Agent::Codex + ); + assert_eq!(resolve_mission_writer(None, "codex").unwrap(), Agent::Codex); + } + + #[test] + fn dispatch_launch_never_starts_a_provider_wizard() { + for agent in [Agent::Claude, Agent::Codex, Agent::Glm] { + let cmd = headless_writer_launch(agent, Some("run the lab")).unwrap(); + assert!( + !command_starts_provider_wizard(&cmd), + "{} launch must not be a login wizard: {cmd}", + agent.name() + ); + assert!( + !cmd.to_ascii_lowercase().contains("hermes"), + "writer launch must not invoke Hermes: {cmd}" + ); + } + assert!(command_starts_provider_wizard("codex login --device-auth")); + assert!(command_starts_provider_wizard("claude auth login")); + assert!(command_starts_provider_wizard("hermes /login")); + let err = refuse_wizard_launch("codex login --device-auth").unwrap_err(); + assert!(err.to_string().contains("wizard_refused"), "{}", err); + } +} diff --git a/crates/omega-core/src/gate.rs b/crates/omega-core/src/gate.rs index 8ad9c54f..b8e1bc50 100644 --- a/crates/omega-core/src/gate.rs +++ b/crates/omega-core/src/gate.rs @@ -139,6 +139,50 @@ impl Rubric { } } +/// The writer (oracle or worker) cannot gate-accept its own mission. +/// Gareth / a human on a regular terminal can. `caller_session` is +/// `OMEGA_SESSION` when `omega gate` is typed from inside an agent pane. +pub fn refuse_writer_self_approval( + oracle: &str, + approver: &str, + caller_session: Option<&str>, +) -> Result<()> { + if looks_like_writer_identity(approver, oracle) { + anyhow::bail!( + "{}", + serde_json::json!({ + "error": "writer_cannot_self_approve", + "oracle": oracle, + "approver": approver, + "message": "the writer cannot gate-accept its own work. A human signs off with omega gate --accept --approver --evidence ." + }) + ); + } + if let Some(caller) = caller_session { + if looks_like_writer_identity(caller, oracle) { + anyhow::bail!( + "{}", + serde_json::json!({ + "error": "writer_cannot_self_approve", + "oracle": oracle, + "caller": caller, + "message": "omega gate --accept from an oracle/worker pane is refused. Sign off from a human terminal." + }) + ); + } + } + Ok(()) +} + +fn looks_like_writer_identity(name: &str, oracle: &str) -> bool { + let n = name.trim().to_ascii_lowercase(); + if n.is_empty() { + return false; + } + let oracle = oracle.trim().to_ascii_lowercase(); + n == oracle || n.starts_with("oracle-") || n.contains("-worker-") || n.starts_with("worker-") +} + // ── GateResult impl ── impl GateResult { @@ -251,6 +295,7 @@ impl GateResult { if evidence.is_empty() { anyhow::bail!("an acceptance needs evidence: pass --evidence \"\""); } + refuse_writer_self_approval(oracle, approver, None)?; Ok(Self { oracle: oracle.to_string(), timestamp: Utc::now(), @@ -1287,6 +1332,14 @@ mod tests { assert!(GateResult::human_acceptance("oracle-p-1", "gs", " ").is_err()); } + #[test] + fn a_writer_cannot_gate_accept_itself() { + assert!(GateResult::human_acceptance("oracle-p-1", "oracle-p-1", "trust me").is_err()); + assert!(GateResult::human_acceptance("oracle-p-1", "p-1-worker-auth", "trust me").is_err()); + assert!(refuse_writer_self_approval("oracle-p-1", "gs", Some("oracle-p-1")).is_err()); + assert!(refuse_writer_self_approval("oracle-p-1", "gs", None).is_ok()); + } + #[test] fn a_gate_result_written_before_the_acceptance_fields_still_parses() { // Additive + defaulted: the field landed on a struct that already had diff --git a/crates/omega-core/src/git_sync.rs b/crates/omega-core/src/git_sync.rs index 4fab8cf1..dcdc054d 100644 --- a/crates/omega-core/src/git_sync.rs +++ b/crates/omega-core/src/git_sync.rs @@ -36,6 +36,8 @@ pub enum GitSyncOutcome { PullFailed(u64), /// `git fetch` itself failed (offline, auth) — drift unknown. FetchFailed, + /// A git repo with no `origin` remote (local-only). Not a fetch failure. + LocalOnly, } impl GitSyncOutcome { @@ -57,6 +59,7 @@ impl GitSyncOutcome { Self::FetchFailed => { "git fetch failed — origin drift UNKNOWN; verify before pushing".into() } + Self::LocalOnly => "local-only git repo — no origin remote; skipping fetch".into(), } } @@ -64,7 +67,11 @@ impl GitSyncOutcome { /// known-current. `None` means safe to proceed silently. pub fn warning(&self) -> Option { match self { - Self::NotARepo | Self::NoUpstream | Self::UpToDate | Self::Pulled(_) => None, + Self::NotARepo + | Self::NoUpstream + | Self::UpToDate + | Self::Pulled(_) + | Self::LocalOnly => None, other => Some(format!("⚠ GIT SYNC: {}", other.describe())), } } @@ -108,6 +115,12 @@ pub fn pull_preflight(dir: &Path) -> GitSyncOutcome { if git(dir, &["rev-parse", "--is-inside-work-tree"]).as_deref() != Some("true") { return GitSyncOutcome::NotARepo; } + // Probe origin BEFORE fetch. A local-only repo has no remotes; `git fetch + // origin` fails and used to paint every `omega dispatch` with + // "origin drift UNKNOWN" on Mac scratch projects. + if git(dir, &["remote", "get-url", "origin"]).is_none() { + return GitSyncOutcome::LocalOnly; + } if !fetch_bounded(dir) { return GitSyncOutcome::FetchFailed; } @@ -162,10 +175,10 @@ mod tests { .success()); }; run(&["init", "-q"]); - // No origin remote: fetch fails → drift unknown (warned), never a pull. + // No origin remote: local-only, silent, never a blocking warning. let out = pull_preflight(tmp.path()); - assert!(matches!(out, GitSyncOutcome::FetchFailed)); - assert!(out.warning().is_some()); + assert!(matches!(out, GitSyncOutcome::LocalOnly)); + assert!(out.warning().is_none()); } #[test] diff --git a/crates/omega-core/src/lab.rs b/crates/omega-core/src/lab.rs new file mode 100644 index 00000000..3c5aabba --- /dev/null +++ b/crates/omega-core/src/lab.rs @@ -0,0 +1,135 @@ +//! AGK Agentic Engineering Lab — the mission loop Omega oracles actually run. +//! +//! This is not a docs-only file. Dispatch seeds the oracle plan with these +//! steps, and the oracle prompt tells the agent to drive `omega progress` +//! / `omega spawn-worker` through them. Writer agents cannot self-approve; +//! `omega done` remains a candidate. + +/// Understand → Explain → Design → Build → Debug → Test → Evaluate → Secure +/// → Deploy → Observe → Improve. +pub const LAB_LOOP_STEPS: &[&str] = &[ + "Understand", + "Explain", + "Design", + "Build", + "Debug", + "Test", + "Evaluate", + "Secure", + "Deploy", + "Observe", + "Improve", +]; + +/// Pipe-separated plan string for `omega progress --plan`. +pub fn lab_plan_spec() -> String { + LAB_LOOP_STEPS.join("|") +} + +/// Prompt block injected into every dispatched oracle so the Lab loop is +/// operational, not a blog post. +pub fn oracle_lab_block() -> String { + format!( + "\n## AGK Agentic Engineering Lab (run this, do not narrate it)\n\ + Persist this plan first: `omega progress --plan \"{}\"`\n\ + Walk the steps in order. Keep exactly one task `doing`.\n\ + Required coding-agent dimensions on every mission: repo context, editing, \ + shell, tests, git, sandbox, verification, human-in-the-loop, finish reports.\n\ + Writers (claude|codex|glm) cannot self-approve. `omega done` is a candidate, \ + never a verdict. Fake-done is forbidden.\n\ + YOU fill R-RUBRIC when you write the worker prompt — Done Criteria AND \ + a Verify Command (a runtime check, not a bare filename to eval). Do not \ + leave that for a human `--force`.\n\ + `omega spawn-worker \"\\nDone Criteria: \\nVerify Command: \" --dir --files a,b`\n\ + Workers start in that --dir (the project). The parent never evals Verify Command at spawn.\n\ + Workers are claude|codex|glm only. Hermes is Home (`omega new --agent hermes`), \ + never dispatch and never a worker.\n", + lab_plan_spec() + ) +} + +/// Fields a worker brief must carry so the R-RUBRIC CLI gate lets it spawn. +pub const DONE_CRITERIA_LABEL: &str = "Done Criteria:"; +pub const VERIFY_COMMAND_LABEL: &str = "Verify Command:"; + +/// True when a worker prompt already satisfies the spawn-worker rubric gate. +pub fn worker_prompt_has_rubric(prompt: &str) -> bool { + let lower = prompt.to_lowercase(); + let has_done = lower.contains("done criteria") + || lower.contains("done:") + || lower.contains("done-criteria"); + let has_verify = lower.contains("verify"); + has_done && has_verify +} + +/// Ensure an oracle-authored brief includes the two R-RUBRIC fields. +/// +/// Human `omega spawn-worker` from a shell still refuses a missing rubric +/// (`--force` does not skip it). Oracle-originated briefs get the fields +/// appended so a worker is not blocked on prompt wording. +pub fn ensure_oracle_worker_rubric(prompt: &str, task: &str) -> String { + if worker_prompt_has_rubric(prompt) { + return prompt.to_string(); + } + let mut out = prompt.trim_end().to_string(); + let lower = out.to_lowercase(); + if !(lower.contains("done criteria") + || lower.contains("done:") + || lower.contains("done-criteria")) + { + out.push_str(&format!( + "\n\n{DONE_CRITERIA_LABEL} task `{task}` is complete, verified by runtime evidence, \ + and reported via `omega done` with a summary. Fake-done is forbidden.\n" + )); + } + if !out.to_lowercase().contains("verify") { + let artifact = format!("{task}.evidence"); + out.push_str(&format!("\n{VERIFY_COMMAND_LABEL} test -f {artifact}\n")); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lab_plan_is_the_eleven_step_loop() { + assert_eq!(LAB_LOOP_STEPS.len(), 11); + assert_eq!( + lab_plan_spec(), + "Understand|Explain|Design|Build|Debug|Test|Evaluate|Secure|Deploy|Observe|Improve" + ); + let block = oracle_lab_block(); + assert!(block.contains("omega progress")); + assert!(block.contains("Done Criteria")); + assert!(block.contains("Verify Command")); + assert!(block.contains("spawn-worker")); + assert!(block.contains("never dispatch")); + assert!( + block.contains("`--force`"), + "oracle lab block must say --force is not the R-RUBRIC path: {block}" + ); + } + + #[test] + fn oracle_briefs_gain_rubric_fields_when_missing() { + let raw = "implement orch test file"; + assert!(!worker_prompt_has_rubric(raw)); + let filled = ensure_oracle_worker_rubric(raw, "orch-test"); + assert!(worker_prompt_has_rubric(&filled), "{filled}"); + assert!(filled.contains("Done Criteria:")); + assert!(filled.contains("Verify Command:")); + assert!( + filled.contains("test -f orch-test.evidence"), + "auto-fill must be a runtime check, not a bare filename to eval: {filled}" + ); + } + + #[test] + fn complete_briefs_are_left_alone() { + let raw = "Write ORCH_TEST.txt\nDone Criteria: file exists\nVerify Command: test -f ORCH_TEST.txt"; + assert!(worker_prompt_has_rubric(raw)); + assert_eq!(ensure_oracle_worker_rubric(raw, "t"), raw); + } +} diff --git a/crates/omega-core/src/lib.rs b/crates/omega-core/src/lib.rs index 2ea94766..83f19300 100644 --- a/crates/omega-core/src/lib.rs +++ b/crates/omega-core/src/lib.rs @@ -21,6 +21,7 @@ pub mod docs; pub mod doctor; pub mod done; pub mod executor; +pub mod external_orchestrator; pub mod failover; pub mod formatting; pub mod gate; @@ -32,6 +33,7 @@ pub mod graph_risk; pub mod guardian; pub mod inbox; pub mod intent; +pub mod lab; pub mod loop_guard; pub mod marketing; pub mod mcp_servers; @@ -61,6 +63,7 @@ pub mod rules; pub mod scope; pub mod service; pub mod session; +pub mod session_health; pub mod session_log; pub mod session_monitor; pub mod ship; @@ -74,3 +77,4 @@ pub mod timeline; pub mod trajectory; pub mod tuilog; pub mod verifier; +pub mod worker_spawn; diff --git a/crates/omega-core/src/mission_ledger.rs b/crates/omega-core/src/mission_ledger.rs index 0472fd15..ce8f468d 100644 --- a/crates/omega-core/src/mission_ledger.rs +++ b/crates/omega-core/src/mission_ledger.rs @@ -4680,6 +4680,19 @@ mod tests { )); } + #[test] + fn open_creates_missing_mission_engine_sqlite3() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("mission-engine-v3.sqlite3"); + assert!(!path.exists(), "fresh state dir has no ledger"); + drop(MissionLedger::open(&path).unwrap()); + assert!( + path.is_file(), + "omega must create mission-engine-v3.sqlite3 instead of crashing" + ); + drop(MissionLedger::open(&path).unwrap()); + } + #[cfg(unix)] #[test] fn filesystem_ledger_enforces_owner_only_database_and_sidecar_modes() { diff --git a/crates/omega-core/src/mission_patterns.rs b/crates/omega-core/src/mission_patterns.rs index 06b9eb60..1ede64b5 100644 --- a/crates/omega-core/src/mission_patterns.rs +++ b/crates/omega-core/src/mission_patterns.rs @@ -258,7 +258,7 @@ impl MissionPattern { "Split the goal into INDEPENDENT axes (different files, different questions, \ different angles — never the same file twice, R-SCOPE).\n\ Spawn ONE worker per axis in the SAME turn you identify them: \ - `omega spawn-worker \"\" --dir --files a,b`.\n\ + `omega spawn-worker \"\\nDone Criteria: \\nVerify Command: \" --dir --files a,b`.\n\ Keep a shared note of what each worker returned. Merge yourself — \ never paste a delegate's summary as the verdict (R-ORCH).\n\ Say which worker found what, so a wrong finding is traceable to its source." diff --git a/crates/omega-core/src/oracle_lifecycle.rs b/crates/omega-core/src/oracle_lifecycle.rs index ff70683f..b04c19e5 100644 --- a/crates/omega-core/src/oracle_lifecycle.rs +++ b/crates/omega-core/src/oracle_lifecycle.rs @@ -1334,6 +1334,7 @@ impl OraclePromptGenerator { // same generic advice and both defaulted to doing the work themselves // instead of spawning and supervising workers. prompt.push_str(&crate::mission_patterns::orchestration_block(mission)); + prompt.push_str(&crate::lab::oracle_lab_block()); prompt.push_str("\n---\n\n"); // Layer 2 — the shared v2 identity/protocol template. @@ -2438,6 +2439,32 @@ mod tests { assert!(OracleState::read_all(tmp.path()).is_empty()); } + #[test] + fn read_all_skips_dead_purge_projections_without_crashing() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write( + tmp.path().join("oracle-mac-purge-20260822.state.json"), + b"{", + ) + .unwrap(); + let mission = Mission::new("demo", "safe", PathBuf::from("/tmp")); + OracleState::new("oracle-demo", &mission) + .write(tmp.path()) + .unwrap(); + let names: Vec = OracleState::read_all(tmp.path()) + .into_iter() + .map(|state| state.oracle_name) + .collect(); + assert!( + !names.iter().any(|name| name.contains("purge")), + "broken purge projection must not abort the sweep: {names:?}" + ); + assert!( + names.iter().any(|name| name == "oracle-demo"), + "valid oracles must still appear beside a dead purge file: {names:?}" + ); + } + #[test] fn oracle_state_cas_refuses_stale_full_document_writer() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/crates/omega-core/src/orchestration.rs b/crates/omega-core/src/orchestration.rs index 41978e34..3a5244d0 100644 --- a/crates/omega-core/src/orchestration.rs +++ b/crates/omega-core/src/orchestration.rs @@ -2376,7 +2376,12 @@ impl Orchestrator { /// Routes through the classifier and decomposes by complexity. pub async fn plan(&self, mission: &Mission) -> Result { let decision = classify_mission(&mission.text); - let agent = self.config.agent_command.clone(); + // Configured Hermes is Home. Orchestrate must dispatch a writer + // (never a provider setup wizard). + let agent = + crate::external_orchestrator::resolve_mission_writer(None, &self.config.agent_command)? + .name() + .to_string(); let strategy = match decision.complexity { Complexity::Simple => PlanStrategy::Direct, @@ -2472,6 +2477,7 @@ impl Orchestrator { task.agent ) })?; + crate::external_orchestrator::headless_writer_launch(agent, Some(&task.prompt))?; // THE FUNNEL — inject the role-scoped Laws + operational rules. The // `omega orchestrate` dispatch path previously spawned oracles AND workers diff --git a/crates/omega-core/src/providers.rs b/crates/omega-core/src/providers.rs index fe7da511..a9440e7f 100644 --- a/crates/omega-core/src/providers.rs +++ b/crates/omega-core/src/providers.rs @@ -79,7 +79,11 @@ impl fmt::Debug for ProvidersConfig { } } -#[derive(Clone, Default, Serialize, Deserialize)] +fn default_true() -> bool { + true +} + +#[derive(Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct PiConfig { #[serde(default)] @@ -99,9 +103,24 @@ pub struct PiConfig { #[doc(hidden)] #[serde(default, rename = "extension", skip_serializing)] pub legacy_extension: Option, + /// Official Pi CLI has no tool-yolo. `--approve` only skips project-trust. + #[serde(default = "default_true")] + pub approve: bool, } -#[derive(Clone, Default, Serialize, Deserialize)] +impl Default for PiConfig { + fn default() -> Self { + Self { + provider: String::new(), + model: String::new(), + api_key: String::new(), + legacy_extension: None, + approve: true, + } + } +} + +#[derive(Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct HermesConfig { /// Hermes provider id. Empty means OpenRouter when an Omega-managed key @@ -112,6 +131,21 @@ pub struct HermesConfig { pub model: String, #[serde(default)] pub api_key: String, + /// Home TUI: `--yolo` + `HERMES_YOLO_MODE=1`. Detached panes have nobody + /// to click through tool approvals. + #[serde(default = "default_true")] + pub yolo: bool, +} + +impl Default for HermesConfig { + fn default() -> Self { + Self { + provider: String::new(), + model: String::new(), + api_key: String::new(), + yolo: true, + } + } } #[derive(Clone, Default, Serialize, Deserialize)] @@ -158,6 +192,10 @@ pub struct CodexConfig { /// sessions. This also trusts other enabled hooks, so operators can disable /// it when they prefer Codex's interactive review. pub bypass_hook_trust: bool, + /// Unattended pair: `--sandbox workspace-write --ask-for-approval never`. + /// Never combine `--sandbox` with `--approve-for-me` (Codex 0.149 dies). + #[serde(default = "default_true")] + pub ask_for_approval_never: bool, } impl Default for CodexConfig { @@ -168,17 +206,30 @@ impl Default for CodexConfig { base_url: String::new(), additional_writable_dirs: Vec::new(), bypass_hook_trust: true, + ask_for_approval_never: true, } } } -#[derive(Clone, Default, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct GeminiConfig { #[serde(default)] pub model: String, #[serde(default)] pub api_key: String, + #[serde(default = "default_true")] + pub yolo: bool, +} + +impl Default for GeminiConfig { + fn default() -> Self { + Self { + model: String::new(), + api_key: String::new(), + yolo: true, + } + } } #[derive(Clone, Serialize, Deserialize)] @@ -232,6 +283,10 @@ pub struct KimiConfig { pub api_key: String, pub base_url: String, pub provider_type: String, + /// Force `--auto` on interactive and `--prompt` launches so detached + /// panes do not block on approval dialogs. + #[serde(default = "default_true")] + pub auto: bool, } impl Default for KimiConfig { @@ -241,6 +296,7 @@ impl Default for KimiConfig { api_key: String::new(), base_url: String::new(), provider_type: "kimi".to_string(), + auto: true, } } } @@ -259,8 +315,8 @@ macro_rules! impl_redacted_provider_debug { }; } -impl_redacted_provider_debug!(PiConfig, "PiConfig", [provider, model]); -impl_redacted_provider_debug!(HermesConfig, "HermesConfig", [provider, model]); +impl_redacted_provider_debug!(PiConfig, "PiConfig", [provider, model, approve]); +impl_redacted_provider_debug!(HermesConfig, "HermesConfig", [provider, model, yolo]); impl_redacted_provider_debug!(OpenRouterConfig, "OpenRouterConfig", [model]); impl_redacted_provider_debug!( ClaudeConfig, @@ -270,9 +326,14 @@ impl_redacted_provider_debug!( impl_redacted_provider_debug!( CodexConfig, "CodexConfig", - [model, additional_writable_dirs, bypass_hook_trust] + [ + model, + additional_writable_dirs, + bypass_hook_trust, + ask_for_approval_never + ] ); -impl_redacted_provider_debug!(GeminiConfig, "GeminiConfig", [model]); +impl_redacted_provider_debug!(GeminiConfig, "GeminiConfig", [model, yolo]); impl fmt::Debug for AntigravityConfig { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter @@ -291,7 +352,7 @@ impl_redacted_provider_debug!( "GlmConfig", [model, dangerously_skip_permissions] ); -impl_redacted_provider_debug!(KimiConfig, "KimiConfig", [model, provider_type]); +impl_redacted_provider_debug!(KimiConfig, "KimiConfig", [model, provider_type, auto]); impl ProvidersConfig { pub fn path() -> PathBuf { diff --git a/crates/omega-core/src/routing.rs b/crates/omega-core/src/routing.rs index 4a837498..987aedf9 100644 --- a/crates/omega-core/src/routing.rs +++ b/crates/omega-core/src/routing.rs @@ -275,7 +275,7 @@ pub fn classify_mission(mission: &str) -> RoutingDecision { return RoutingDecision { complexity: Complexity::Simple, reasoning: vec!["INVALID: mission text is empty — nothing to route".to_string()], - suggested_agent: "morpheus".to_string(), + suggested_agent: "codex".to_string(), decompose: false, use_team: false, use_quality_gate: false, @@ -544,8 +544,8 @@ pub fn classify_mission(mission: &str) -> RoutingDecision { }; let suggested_agent = match topology { RoutingTopology::Council => "council", - RoutingTopology::ManagerTools | RoutingTopology::ParallelWorkers => "oracle", - RoutingTopology::SingleAgent | RoutingTopology::Handoff => "morpheus", + RoutingTopology::ManagerTools | RoutingTopology::ParallelWorkers => "codex", + RoutingTopology::SingleAgent | RoutingTopology::Handoff => "codex", }; let audit_skills = detect_audit_skills(mission); @@ -708,6 +708,9 @@ mod tests { fn normal_mission_does_not_route_to_council() { let d = classify_mission("fix typo in README"); assert_ne!(d.suggested_agent, "council"); + assert_ne!(d.suggested_agent, "morpheus"); + assert_ne!(d.suggested_agent, "oracle"); + assert_eq!(d.suggested_agent, "codex"); } #[test] diff --git a/crates/omega-core/src/session.rs b/crates/omega-core/src/session.rs index 66024641..c2f18240 100644 --- a/crates/omega-core/src/session.rs +++ b/crates/omega-core/src/session.rs @@ -172,6 +172,41 @@ fn session_authority_lock(state_dir: &Path, session: &str) -> Result PathBuf { + let trimmed = raw.trim(); + if trimmed == "~" { + return dirs::home_dir().unwrap_or_else(|| PathBuf::from(trimmed)); + } + if let Some(rest) = trimmed.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest); + } + } + PathBuf::from(trimmed) +} + +/// Resolve `--dir` for `omega new`: omit (`None` = same as TUI, rmux cwd), +/// expand `~`, and refuse a path that is not an existing directory. +pub fn resolve_session_working_dir(raw: Option<&str>) -> Result> { + let Some(raw) = raw else { + return Ok(None); + }; + let expanded = expand_user_path(raw); + if !expanded.is_dir() { + anyhow::bail!( + "--dir '{}' does not exist (expanded: {}). Create it or pass a real directory.", + raw, + expanded.display() + ); + } + Ok(Some(expanded)) +} + /// Slugify an arbitrary string into a safe rmux session name. /// /// rmux keys kill/rename/capture on the session name; spaces, non-ASCII, and @@ -365,10 +400,18 @@ fn record_session_provider(name: &str, agent: Agent) -> Result<()> { }); let bytes = serde_json::to_vec_pretty(&payload).context("serializing session provider")?; crate::config::atomic_write_private(&path, &bytes) - .with_context(|| format!("recording provider for session {name}")) + .with_context(|| format!("recording provider for session {name}"))?; + // Same chokepoint as provider provenance: TUI New Codex and `omega new + // --agent` both land here. Launch starts `running`; observe marks `failed` + // if the agent dies to bash or the pane disappears. + let state_dir = crate::config::omega_dir().join("state"); + if let Err(error) = crate::session_health::record_launch(&state_dir, name, agent.name()) { + tracing::warn!(session = %name, error = %error, "failed to record session launch health"); + } + Ok(()) } -pub(crate) fn read_session_provider(name: &str) -> Option { +pub fn read_session_provider(name: &str) -> Option { let payload: serde_json::Value = serde_json::from_slice(&std::fs::read(session_provider_path(name)).ok()?).ok()?; let recorded_session = payload.get("session")?.as_str()?; @@ -542,10 +585,13 @@ impl SessionManager { if let Some(cmd) = command { let mut process = ProcessSpec::shell(cmd); - if !environment.is_empty() { + let mut env = environment.to_vec(); + if !env.iter().any(|(key, _)| key == "OMEGA_SESSION") { + env.push(("OMEGA_SESSION".to_string(), safe.clone())); + } + if !env.is_empty() { process.environment = Some( - environment - .iter() + env.iter() .map(|(key, value)| format!("{key}={value}")) .collect(), ); @@ -889,23 +935,24 @@ impl SessionManager { } pub async fn send_text(&self, session_name: &str, text: &str) -> Result<()> { - // Two hot RPCs (send_text + send_key Enter). Use the cached pane and - // a single retry on stale-cache errors so a kill+recreate of the - // same name self-heals on the next send. + // Type, then SUBMIT. Codex/Claude/Hermes composers often absorb a + // lone Enter as a newline; C-m is the CR the TUI treats as send. + // Cached pane + one stale-cache retry so kill+recreate self-heals. let pane = self.pane_for(session_name).await?; match pane.send_text(text).await { - Ok(()) => {} + Ok(()) => { + submit_composer(&pane).await?; + Ok(()) + } Err(e) if is_pane_stale(&e) => { self.invalidate_pane(session_name).await; let pane = self.pane_for(session_name).await?; pane.send_text(text).await?; - pane.send_key("Enter").await?; - return Ok(()); + submit_composer(&pane).await?; + Ok(()) } - Err(e) => return Err(e.into()), + Err(e) => Err(e.into()), } - pane.send_key("Enter").await?; - Ok(()) } /// Raw text send — no auto-Enter. Used by the TUI interactive preview @@ -1010,12 +1057,12 @@ impl SessionManager { pub async fn send_paste_then_submit(&self, session_name: &str, text: &str) -> Result<()> { self.send_paste_block(session_name, text).await?; let pane = self.pane_for(session_name).await?; - match pane.send_key("Enter").await { + match submit_composer(&pane).await { Ok(()) => Ok(()), Err(e) if is_pane_stale(&e) => { self.invalidate_pane(session_name).await; let pane = self.pane_for(session_name).await?; - pane.send_key("Enter").await?; + submit_composer(&pane).await?; Ok(()) } Err(e) => Err(e.into()), @@ -1072,15 +1119,24 @@ impl SessionManager { /// (the snapshot itself), not three. pub async fn capture_pane(&self, session_name: &str) -> Result { let pane = self.pane_for(session_name).await?; - match pane.snapshot().await { - Ok(snapshot) => Ok(snapshot.visible_text()), + let visible = match pane.snapshot().await { + Ok(snapshot) => snapshot.visible_text(), Err(e) if is_pane_stale(&e) => { self.invalidate_pane(session_name).await; let pane = self.pane_for(session_name).await?; - let snapshot = pane.snapshot().await?; - Ok(snapshot.visible_text()) + pane.snapshot().await?.visible_text() } - Err(e) => Err(e.into()), + Err(e) => return Err(e.into()), + }; + if !visible.trim().is_empty() { + return Ok(visible); + } + // Alt-screen / splash frames can snapshot empty while the agent is + // live. Scrollback still has the TUI — prefer that over a blank + // `omega capture` while Codex/Claude/Hermes are running. + match self.capture_pane_history(session_name, 200).await { + Ok(history) if !history.trim().is_empty() => Ok(history), + _ => Ok(visible), } } @@ -1449,6 +1505,14 @@ fn is_pane_stale(err: &rmux_sdk::RmuxError) -> bool { ) } +/// Submit a typed composer: Enter, then C-m. Codex/Claude/Hermes often treat +/// a lone Enter as a newline; the CR is what actually sends. +async fn submit_composer(pane: &Pane) -> std::result::Result<(), rmux_sdk::RmuxError> { + pane.send_key("Enter").await?; + pane.send_key("C-m").await?; + Ok(()) +} + /// Parse `capture-pane -e` output into styled rows PLUS the stripped plain /// text, in one pass. /// @@ -1644,6 +1708,49 @@ mod sanitize_tests { use super::{resolve_agent_command, sanitize_session_name as s}; use super::{EnsureSessionPolicy, MAX_SESSION_NAME_LEN, TYPED_AGENT_SESSION_POLICY}; use crate::agents::Agent; + use std::path::PathBuf; + + #[test] + fn expand_user_path_resolves_tilde_home() { + let home = dirs::home_dir().expect("home"); + assert_eq!(crate::session::expand_user_path("~"), home); + assert_eq!( + crate::session::expand_user_path("~/Desktop"), + home.join("Desktop") + ); + assert_eq!( + crate::session::expand_user_path("/abs/project"), + PathBuf::from("/abs/project") + ); + assert_ne!( + crate::session::expand_user_path("~/Desktop").as_os_str(), + std::ffi::OsStr::new("~/Desktop"), + "omega new --dir ~/Desktop must not chdir into a literal tilde path" + ); + } + + #[test] + fn resolve_session_working_dir_matches_tui_when_omitted() { + assert!(crate::session::resolve_session_working_dir(None) + .unwrap() + .is_none()); + let tmp = tempfile::TempDir::new().unwrap(); + let got = crate::session::resolve_session_working_dir(tmp.path().to_str()).unwrap(); + assert_eq!(got.as_deref(), Some(tmp.path())); + let missing = tmp.path().join("not-a-dir"); + let err = crate::session::resolve_session_working_dir(missing.to_str()) + .expect_err("missing --dir must fail before spawn"); + assert!( + err.to_string().contains("does not exist"), + "missing dir error: {err}" + ); + let home = dirs::home_dir().expect("home"); + assert_eq!( + crate::session::resolve_session_working_dir(Some("~")).unwrap(), + Some(home), + "`--dir ~` must expand to $HOME, not a literal tilde" + ); + } #[test] fn clean_names_unchanged() { diff --git a/crates/omega-core/src/session_health.rs b/crates/omega-core/src/session_health.rs new file mode 100644 index 00000000..b98761f7 --- /dev/null +++ b/crates/omega-core/src/session_health.rs @@ -0,0 +1,223 @@ +//! Launch contract for agent panes: the session *is* the agent. +//! +//! If the agent dies, Omega records `failed` plus a reason. A silent bash +//! prompt must never look like a running Codex/Claude/Hermes session. + +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +use crate::session::sanitize_session_name; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionHealthStatus { + Running, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionHealth { + pub session: String, + pub provider: String, + #[serde(rename = "status")] + pub status: SessionHealthStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + pub launched_at: DateTime, + pub observed_at: DateTime, +} + +impl SessionHealth { + pub fn launch(session: &str, provider: &str) -> Self { + let now = Utc::now(); + Self { + session: sanitize_session_name(session), + provider: provider.to_string(), + status: SessionHealthStatus::Running, + reason: None, + launched_at: now, + observed_at: now, + } + } + + pub fn is_failed(&self) -> bool { + self.status == SessionHealthStatus::Failed + } +} + +pub fn health_path(state_dir: &Path, session: &str) -> Result { + crate::scope::validate_session_identity(session)?; + Ok(state_dir.join(format!( + "session-health-{}.json", + sanitize_session_name(session) + ))) +} + +pub fn record_launch(state_dir: &Path, session: &str, provider: &str) -> Result { + let health = SessionHealth::launch(session, provider); + write(state_dir, &health)?; + Ok(health) +} + +pub fn write(state_dir: &Path, health: &SessionHealth) -> Result<()> { + let path = health_path(state_dir, &health.session)?; + let bytes = serde_json::to_vec_pretty(health).context("serializing session health")?; + crate::config::atomic_write_private(&path, &bytes) + .with_context(|| format!("writing session health {}", path.display())) +} + +pub fn read(state_dir: &Path, session: &str) -> Result> { + let path = health_path(state_dir, session)?; + let Some(bytes) = crate::config::read_private_optional(&path)? else { + return Ok(None); + }; + let health: SessionHealth = serde_json::from_slice(&bytes) + .with_context(|| format!("parsing session health {}", path.display()))?; + Ok(Some(health)) +} + +/// Refresh health from liveness + a captured pane. Failed is sticky. +pub fn observe( + state_dir: &Path, + session: &str, + provider: &str, + live: bool, + pane: Option<&str>, +) -> Result { + let (status, reason) = classify_agent_launch(live, pane); + let mut health = match read(state_dir, session)? { + Some(existing) => existing, + None => SessionHealth::launch(session, provider), + }; + health.observed_at = Utc::now(); + if health.provider.is_empty() { + health.provider = provider.to_string(); + } + if health.status != SessionHealthStatus::Failed && status == SessionHealthStatus::Failed { + health.status = SessionHealthStatus::Failed; + health.reason = reason; + } + write(state_dir, &health)?; + Ok(health) +} + +/// Empty pane is *not* death (Codex/Claude splash / alt-screen). +/// A live bash under a dead agent *is* death. +pub fn classify_agent_launch( + live: bool, + pane: Option<&str>, +) -> (SessionHealthStatus, Option) { + if !live { + return ( + SessionHealthStatus::Failed, + Some("agent_exited: session is not live".to_string()), + ); + } + if let Some(pane) = pane { + if pane_fell_to_silent_bash(pane) { + return ( + SessionHealthStatus::Failed, + Some("agent_exited: pane fell through to bash".to_string()), + ); + } + } + (SessionHealthStatus::Running, None) +} + +/// True when the pane is a shell, not the agent TUI. +pub fn pane_fell_to_silent_bash(pane: &str) -> bool { + let trimmed = pane.trim(); + if trimmed.is_empty() { + return false; + } + let lower = trimmed.to_ascii_lowercase(); + if lower.contains("unexpected argument") + && (lower.contains("approve-for-me") || lower.contains("--sandbox")) + { + return true; + } + if lower.contains("command not found") + && (lower.contains("codex") || lower.contains("claude") || lower.contains("hermes")) + { + return true; + } + pane.lines().any(line_looks_like_shell_ps1) +} + +fn line_looks_like_shell_ps1(line: &str) -> bool { + let t = line.trim(); + if t.is_empty() { + return false; + } + if t.starts_with("bash-") && (t.ends_with('$') || t.ends_with("#")) { + return true; + } + // user@host:path$ — leftover after `; exec bash` or a dead `exec` agent. + let ends_prompt = t.ends_with('$') || t.ends_with('#'); + ends_prompt && t.contains('@') && t.contains(':') && !t.contains('❯') && !t.contains('›') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_or_splash_pane_is_not_a_failed_launch() { + let (status, _) = classify_agent_launch(true, Some("")); + assert_eq!(status, SessionHealthStatus::Running); + let (status, _) = classify_agent_launch(true, Some(" \n ")); + assert_eq!(status, SessionHealthStatus::Running); + let (status, _) = classify_agent_launch(true, Some("✦ Codex v0.149.1\n Thinking…")); + assert_eq!(status, SessionHealthStatus::Running); + assert!(!pane_fell_to_silent_bash("")); + } + + #[test] + fn silent_bash_after_codex_is_a_failed_launch() { + let pane = "✦ Codex\n\nbash-5.3$ "; + assert!(pane_fell_to_silent_bash(pane)); + let (status, reason) = classify_agent_launch(true, Some(pane)); + assert_eq!(status, SessionHealthStatus::Failed); + assert!(reason.unwrap().contains("bash")); + } + + #[test] + fn dead_agent_last_frame_with_host_ps1_is_failed() { + let pane = "● Analysis complete.\n\ + \n\ + ────────────────────────────────────────────────\n\ + ❯ \n\ + ────────────────────────────────────────────────\n\ + ⏵⏵ bypass permissions on (shift+tab to cycle)\n\ + vibe@Agentik-os:~/Station/SideBusiness/OmegaOS$ \n"; + assert!(pane_fell_to_silent_bash(pane)); + let (status, _) = classify_agent_launch(true, Some(pane)); + assert_eq!(status, SessionHealthStatus::Failed); + } + + #[test] + fn dead_session_is_failed_even_without_a_pane() { + let (status, reason) = classify_agent_launch(false, None); + assert_eq!(status, SessionHealthStatus::Failed); + assert!(reason.unwrap().contains("not live")); + } + + #[test] + fn failed_health_is_sticky_and_visible_in_json() { + let tmp = tempfile::TempDir::new().unwrap(); + record_launch(tmp.path(), "t-codex", "codex").unwrap(); + let failed = observe(tmp.path(), "t-codex", "codex", true, Some("bash-5.3$ ")).unwrap(); + assert!(failed.is_failed()); + let revived = observe(tmp.path(), "t-codex", "codex", true, Some("✦ Codex")).unwrap(); + assert!( + revived.is_failed(), + "a later live frame must not hide an observed death" + ); + let json = serde_json::to_value(&revived).unwrap(); + assert_eq!(json["status"], "failed"); + assert!(json["reason"].as_str().unwrap().contains("bash")); + assert!(json.get("pane").is_none()); + } +} diff --git a/crates/omega-core/src/skill_registry.rs b/crates/omega-core/src/skill_registry.rs index ecbf6fff..8eae02dd 100644 --- a/crates/omega-core/src/skill_registry.rs +++ b/crates/omega-core/src/skill_registry.rs @@ -2117,9 +2117,11 @@ mod tests { // review, 2026-08-14). // 253 = 249 + the four that landed without bumping this constant: // seductive-os (985cf1e), intuitive-os (8af1ff7), identity-shift-os - // (242fed7) and cookbook (5cfb8a0). The count is the point — a skill - // that ships without appearing here is a skill nobody counted. - assert_eq!(catalog.skills.len(), 253); + // (242fed7) and cookbook (5cfb8a0). + // 254 = 253 + agentic-engineering-lab (this branch). The count is the + // point — a skill that ships without appearing here is a skill nobody + // counted. + assert_eq!(catalog.skills.len(), 254); let names: BTreeSet<_> = catalog .skills .iter() @@ -2148,6 +2150,7 @@ mod tests { assert!(names.contains("identity-shift-os")); assert!(names.contains("journal-os")); assert!(names.contains("ai-logic-os")); + assert!(names.contains("agentic-engineering-lab")); assert!(catalog .skills .iter() diff --git a/crates/omega-core/src/worker_spawn.rs b/crates/omega-core/src/worker_spawn.rs new file mode 100644 index 00000000..f15db7f0 --- /dev/null +++ b/crates/omega-core/src/worker_spawn.rs @@ -0,0 +1,317 @@ +//! Worker spawn contract: project cwd + record-only Verify Command. +//! +//! Live hole (Gareth 2026-08-24): `omega spawn-worker` used `dir.unwrap_or(".")`. +//! rmux treats `.` as the *daemon* cwd (often `$HOME`), so Claude wrote +//! `/Users/hacker/CLAUDE_OK.txt` instead of the project file. Always pass an +//! absolute existing directory. +//! +//! Second hole: the parent eval'd `Verify Command:` at spawn +//! (`(eval):1: no such file or directory: CLAUDE_OK.txt`). The contract is +//! recorded for later verification. It is never executed at spawn. + +use anyhow::{bail, Result}; +use std::path::{Path, PathBuf}; + +use crate::session::expand_user_path; + +fn is_usable_dir_hint(path: &Path) -> bool { + !path.as_os_str().is_empty() && path.as_os_str() != "." && path.as_os_str() != "./" +} + +fn same_canonical(left: &Path, right: &Path) -> bool { + match (left.canonicalize(), right.canonicalize()) { + (Ok(left), Ok(right)) => left == right, + _ => left == right, + } +} + +fn join_if_relative(path: &Path, process_cwd: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + process_cwd.join(path) + } +} + +/// Resolve the directory a worker pane must start in. +/// +/// `--dir` wins (tilde-expanded; `.` / `./` are treated as omitted so rmux +/// never inherits the daemon `$HOME`). Then the oracle's persisted +/// `working_dir`, unless that directory is the operator home and a registered +/// project path exists (the live hole: oracle pane at `/Users/hacker`, +/// project at `…/refonte-comprehension-v2`). Then the registered project +/// path. Never return a relative `.` for rmux. +pub fn resolve_worker_working_dir( + dir_flag: Option<&str>, + oracle_working_dir: Option<&Path>, + project_dir: Option<&Path>, + process_cwd: &Path, + home_dir: Option<&Path>, +) -> Result { + let dir_flag = dir_flag.filter(|raw| { + let trimmed = raw.trim(); + trimmed != "." && trimmed != "./" + }); + let oracle_dir = oracle_working_dir + .filter(|path| is_usable_dir_hint(path)) + .map(|path| join_if_relative(path, process_cwd)); + let project_dir = project_dir + .filter(|path| is_usable_dir_hint(path)) + .map(|path| join_if_relative(path, process_cwd)); + let oracle_is_home = oracle_dir + .as_deref() + .is_some_and(|oracle| home_dir.is_some_and(|home| same_canonical(oracle, home))); + let candidate = if let Some(raw) = dir_flag { + join_if_relative(&expand_user_path(raw), process_cwd) + } else if let Some(oracle) = oracle_dir.as_ref().filter(|_| !oracle_is_home) { + oracle.clone() + } else if let Some(project) = project_dir { + project + } else if let Some(oracle) = oracle_dir { + oracle + } else { + process_cwd.to_path_buf() + }; + let canon = candidate.canonicalize().map_err(|error| { + anyhow::anyhow!( + "worker working_dir '{}' does not exist (resolved: {}): {error}. \ + Workers must start in the project --dir / oracle working_dir.", + dir_flag.unwrap_or(""), + candidate.display() + ) + })?; + if !canon.is_dir() { + bail!("worker working_dir {} is not a directory", canon.display()); + } + if dir_flag.is_none() && home_dir.is_some_and(|home| same_canonical(&canon, home)) { + bail!( + "worker working_dir resolved to $HOME ({}). Pass --dir or register \ + the project. Workers must not inherit the rmux daemon / parent home directory.", + canon.display() + ); + } + Ok(canon) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VerifySpec { + FileExists { path: String }, + Command { argv: Vec }, +} + +fn extract_verify_line(prompt: &str) -> Option { + let lines: Vec<&str> = prompt.lines().collect(); + for (index, line) in lines.iter().enumerate() { + let lower = line.to_lowercase(); + let Some(marker) = lower + .find("verify command:") + .or_else(|| lower.find("verify-command:")) + else { + continue; + }; + let Some(colon) = line[marker..].find(':').map(|offset| marker + offset) else { + continue; + }; + let mut command = line[colon + 1..].trim(); + if command.is_empty() { + command = lines + .iter() + .skip(index + 1) + .map(|candidate| candidate.trim()) + .find(|candidate| !candidate.is_empty() && !candidate.starts_with("```"))?; + } + command = command + .trim_start_matches("- ") + .trim() + .trim_matches('`') + .trim(); + if !command.is_empty() { + return Some(command.to_string()); + } + } + None +} + +fn looks_like_artifact_path(token: &str) -> bool { + if token.contains('/') || token.contains('\\') { + return true; + } + Path::new(token).extension().is_some() +} + +/// Parse the oracle-authored Verify Command. Never execute it. +pub fn parse_verify_contract(prompt: &str) -> Option { + let command = extract_verify_line(prompt)?; + if command + .chars() + .any(|ch| matches!(ch, ';' | '&' | '|' | '<' | '>' | '`' | '$' | '\n' | '\r')) + { + return None; + } + let argv: Vec = command + .split_whitespace() + .map(str::to_string) + .filter(|part| !part.is_empty()) + .collect(); + if argv.is_empty() { + return None; + } + if argv.len() == 1 && looks_like_artifact_path(&argv[0]) { + return Some(VerifySpec::FileExists { + path: argv[0].clone(), + }); + } + Some(VerifySpec::Command { argv }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn omitted_dir_uses_oracle_project_not_dot() { + let project = tempfile::TempDir::new().unwrap(); + let home = tempfile::TempDir::new().unwrap(); + let got = resolve_worker_working_dir( + None, + Some(project.path()), + None, + home.path(), + Some(home.path()), + ) + .unwrap(); + assert_eq!(got, project.path().canonicalize().unwrap()); + assert_ne!( + got, + home.path().canonicalize().unwrap(), + "a worker must not inherit $HOME because rmux `.` is the daemon cwd" + ); + } + + #[test] + fn relative_dot_dir_prefers_oracle_project() { + let project = tempfile::TempDir::new().unwrap(); + let home = tempfile::TempDir::new().unwrap(); + let got = resolve_worker_working_dir( + Some("."), + Some(project.path()), + None, + home.path(), + Some(home.path()), + ) + .unwrap(); + assert_eq!(got, project.path().canonicalize().unwrap()); + let no_oracle = + resolve_worker_working_dir(Some("."), None, None, project.path(), Some(home.path())) + .unwrap(); + assert_eq!(no_oracle, project.path().canonicalize().unwrap()); + } + + #[test] + fn registered_project_beats_home_process_cwd() { + let project = tempfile::TempDir::new().unwrap(); + let home = tempfile::TempDir::new().unwrap(); + let got = resolve_worker_working_dir( + None, + None, + Some(project.path()), + home.path(), + Some(home.path()), + ) + .unwrap(); + assert_eq!( + got, + project.path().canonicalize().unwrap(), + "spawn-worker --project without --dir must use the registered project, not $HOME" + ); + } + + #[test] + fn registered_project_beats_home_oracle() { + let project = tempfile::TempDir::new().unwrap(); + let home = tempfile::TempDir::new().unwrap(); + let got = resolve_worker_working_dir( + None, + Some(home.path()), + Some(project.path()), + home.path(), + Some(home.path()), + ) + .unwrap(); + assert_eq!( + got, + project.path().canonicalize().unwrap(), + "an oracle parked in $HOME must not drag the worker with it" + ); + } + + #[test] + fn explicit_dir_beats_registered_project() { + let project = tempfile::TempDir::new().unwrap(); + let other = tempfile::TempDir::new().unwrap(); + let home = tempfile::TempDir::new().unwrap(); + let got = resolve_worker_working_dir( + other.path().to_str(), + Some(project.path()), + Some(project.path()), + home.path(), + Some(home.path()), + ) + .unwrap(); + assert_eq!(got, other.path().canonicalize().unwrap()); + } + + #[test] + fn home_process_cwd_without_project_is_refused() { + let home = tempfile::TempDir::new().unwrap(); + let err = resolve_worker_working_dir(None, None, None, home.path(), Some(home.path())) + .expect_err("a worker must not start in $HOME without --dir or a project"); + assert!( + err.to_string().contains("$HOME"), + "refuse must name $HOME: {err}" + ); + } + + #[test] + fn missing_dir_is_a_hard_error() { + let cwd = tempfile::TempDir::new().unwrap(); + let err = + resolve_worker_working_dir(Some("no-such-worker-dir"), None, None, cwd.path(), None) + .expect_err("missing --dir must fail before spawn"); + assert!(err.to_string().contains("does not exist"), "{err}"); + } + + #[test] + fn bare_artifact_verify_is_file_exists_not_a_shell_command() { + let spec = parse_verify_contract( + "Write CLAUDE_OK.txt\nDone Criteria: file exists\nVerify Command: CLAUDE_OK.txt", + ) + .unwrap(); + assert_eq!( + spec, + VerifySpec::FileExists { + path: "CLAUDE_OK.txt".into() + } + ); + } + + #[test] + fn runtime_check_verify_is_recorded_argv() { + let spec = + parse_verify_contract("Done Criteria: green\nVerify Command: test -f CLAUDE_OK.txt") + .unwrap(); + assert_eq!( + spec, + VerifySpec::Command { + argv: vec!["test".into(), "-f".into(), "CLAUDE_OK.txt".into()] + } + ); + } + + #[test] + fn shell_operators_are_refused_not_evald() { + assert!( + parse_verify_contract("Verify Command: test -f CLAUDE_OK.txt && echo ok").is_none() + ); + } +} diff --git a/crates/omega-gateway/src/chat_driver.rs b/crates/omega-gateway/src/chat_driver.rs index 03e149b3..7f1c7af5 100644 --- a/crates/omega-gateway/src/chat_driver.rs +++ b/crates/omega-gateway/src/chat_driver.rs @@ -52,7 +52,10 @@ pub fn agent_command( command.args([ "exec", "--skip-git-repo-check", - "--approve-for-me", + "--sandbox", + "workspace-write", + "--ask-for-approval", + "never", "--dangerously-bypass-hook-trust", "--json", ]); @@ -563,10 +566,14 @@ mod tests { assert!(args.starts_with(&[ "exec", "--skip-git-repo-check", - "--approve-for-me", + "--sandbox", + "workspace-write", + "--ask-for-approval", + "never", "--dangerously-bypass-hook-trust", "--json", ])); + assert!(!args.contains(&"--approve-for-me")); assert!(args .windows(2) .any(|pair| pair == ["--model", "gpt-5.6-sol"])); diff --git a/crates/omega-gateway/src/routes_config.rs b/crates/omega-gateway/src/routes_config.rs index 733b9e98..99caf3c7 100644 --- a/crates/omega-gateway/src/routes_config.rs +++ b/crates/omega-gateway/src/routes_config.rs @@ -225,6 +225,36 @@ fn apply_config_value(cfg: &mut ProvidersConfig, key: &str, value: &str) -> Resu ("pi", "api_key") => cfg.pi.api_key = value.to_string(), ("glm", "model") => cfg.glm.model = value.to_string(), ("glm", "api_key") => cfg.glm.api_key = value.to_string(), + ("glm", "dangerously_skip_permissions") => { + cfg.glm.dangerously_skip_permissions = value.parse().map_err(|_| { + "dangerously_skip_permissions must be 'true' or 'false'".to_string() + })?; + } + ("codex", "ask_for_approval_never") | ("codex", "yolo") => { + cfg.codex.ask_for_approval_never = value + .parse() + .map_err(|_| "ask_for_approval_never must be 'true' or 'false'".to_string())?; + } + ("hermes", "yolo") => { + cfg.hermes.yolo = value + .parse() + .map_err(|_| "yolo must be 'true' or 'false'".to_string())?; + } + ("gemini", "yolo") => { + cfg.gemini.yolo = value + .parse() + .map_err(|_| "yolo must be 'true' or 'false'".to_string())?; + } + ("kimi", "auto") | ("kimi", "yolo") => { + cfg.kimi.auto = value + .parse() + .map_err(|_| "auto must be 'true' or 'false'".to_string())?; + } + ("pi", "approve") | ("pi", "yolo") => { + cfg.pi.approve = value + .parse() + .map_err(|_| "approve must be 'true' or 'false'".to_string())?; + } ("hermes", "provider") => cfg.hermes.provider = value.to_string(), ("hermes", "model") => cfg.hermes.model = value.to_string(), ("hermes", "api_key") => cfg.hermes.api_key = value.to_string(), diff --git a/docs/ADR-lab-three-backends.md b/docs/ADR-lab-three-backends.md new file mode 100644 index 00000000..8c1e7c08 --- /dev/null +++ b/docs/ADR-lab-three-backends.md @@ -0,0 +1,95 @@ +# ADR — Omega orchestration mirrors Cursor Cloud Agent + +Status: accepted (2026-08-24) +Informed by: Gareth's control-plane direction (2026-08-24). Not a Cursor UI +clone. No Discord connector. + +## Decision + +Omega orchestration mirrors **Cursor Cloud Agent**: + +- **One mission owner.** A follow-up is a `reply` into the live oracle (same + session / same branch). Never spawn a sibling that edits the same files + (`oracle-*-2` is a bug, not a fallback). +- **Launch is durable.** The agent session stays in the agent. Death is a + `failed` session with a reason in JSON — not a silent `bash-5.3$` that + Omega still calls `running`. +- **Observe without attaching.** `omega oracles`, `omega workers`, + `omega status --json` (lifecycle only, no pane dump), `omega progress`, + `omega capture`. Grok Bot is an external orchestrator like the Cursor + sidebar. It must not need `omega attach` and must not type into OAuth + wizards. +- **Workers are scoped peers** (files / worktree). Each emits a finish + report (`done_clean` | `failed` | `blocked` + evidence). They report + when they finish. The parent oracle verifies. The writer cannot + gate-accept itself (`omega done` is a candidate; a human runs + `omega gate --accept`). +- **Done is a visible terminal state + evidence**, not the writer saying + done. + +Cloud / Claude Code / Codex / Hermes are **backends**. The orchestration +API is the same for all of them. + +| Backend | Role | +|---|---| +| **Codex** | Mac/VPS writer and default oracle. `omega new --agent codex` must keep Codex alive (same command as TUI New Codex). | +| **Claude / GLM** | Writers and workers. Same launch contract. | +| **Hermes** | Home only (`omega new --agent hermes`). Never `dispatch --agent hermes`, never a worker. | +| **Cloud** | This Cursor Cloud Agent. Writer for OmegaOS itself. Not `omega dispatch`. | + +**Grok Bot is an external orchestrator.** Atlas/Telegram is optional. One +oracle per project. Review is outside Omega. + +Grok loop (do not skip steps): + +1. Observe: `omega oracles`, `omega workers`, `omega status --json`, + `omega progress` (read-back only). Never dump a pane on `--json`. +2. Write a plan, then `omega dispatch ""` (default + Codex). Never `--agent hermes`. Never launch a provider setup wizard. +3. The oracle plans/verifies and never edits. It calls `omega spawn-worker` + (claude | codex | glm only) with `--dir ` and a filled R-RUBRIC + (Done Criteria + Verify Command). The worker pane starts in that project + directory: `--dir`, else the oracle `working_dir` (unless that is `$HOME` + and the project is registered), else the registered project path — never + rmux `.` / `$HOME`. The parent does not eval Verify Command at spawn. + Grok must not spawn workers. `--force` is not the path. +4. Reap finish reports. Writer `omega done` is a candidate, not a verdict. +5. A fresh Reviewer lists reasons NOT to merge; Audit if infra/auth/secrets/CI; + then Afterwork. Gareth alone may `omega gate --accept`. +6. Kill / close the mission. + +`omega send` / `omega attach` into a provider/OAuth wizard is forbidden. +Grok Bot is not a fourth Omega backend and not a substitute for `omega send`. + +## Launch contract + +The pane **is** the agent. Agent exit = session death. Never `; exec bash` +after the agent. `omega new --agent {codex,claude,hermes}` uses the same +`SessionManager::create_session_with_agent` entry as TUI New Codex / +New Claude / New Hermes (`Action::CreateSessionAutoName`). Same argv +(`Agent::try_launch`), no dispatch-authority env on the Home pane, +`--dir ~/…` expanded (a missing directory is a hard error). Codex +itself is not broken: the operator's menu launch stays in the TUI. + +Codex unattended pair (0.149+): + +``` +codex --sandbox workspace-write --ask-for-approval never +``` + +Invalid: `--approve-for-me` together with `--sandbox` (CLI or +`~/.codex/config.toml` `sandbox_mode`). + +Hermes Home: `hermes chat --yolo` (and `HERMES_YOLO_MODE=1`). Never `-q` +for a pane launch. + +## Follow-up + +Second `omega dispatch ""` without `--new` replies into the +live oracle. Composer not ready → JSON `followup_pane_not_ready`. The +delivery is persisted and visible in `status --json` (`delivery.tag`). + +## Out of scope + +Publishing, merge, npm, `omega ship`, second writer, CLIENT tenants, +rewriting the provider catalog again, Discord connector. diff --git a/docs/PROVIDER-COMPATIBILITY.md b/docs/PROVIDER-COMPATIBILITY.md index 7d4a0bd3..09704841 100644 --- a/docs/PROVIDER-COMPATIBILITY.md +++ b/docs/PROVIDER-COMPATIBILITY.md @@ -12,12 +12,12 @@ omega install --force | Provider | Minimum tested CLI | Omega launch contract | |---|---:|---| | Claude Code | 2.1.219 | interactive TTY, `--permission-mode auto` | -| Codex | 0.147.0 | `--approve-for-me`, hook-trust bypass, no conflicting `--sandbox` | -| Gemini CLI | 0.31.0 | `--prompt-interactive`, Enterprise/API-key accounts | +| Codex | 0.147.0 | `--sandbox workspace-write --ask-for-approval never` (never pair `--sandbox` with `--approve-for-me`) | +| Gemini CLI | 0.31.0 | `--prompt-interactive --yolo`, Enterprise/API-key accounts | | Antigravity (`agy`) | 1.1.8 | native Google auth, prompt-interactive | -| Pi / OpenRouter | 0.84.3 | explicit provider/model and `--` prompt delimiter | -| Hermes | 0.20.0 | `hermes chat`; `-q` for a dispatched one-shot | -| Kimi Code | 0.38.0 | `--prompt` without the incompatible `--auto` flag | +| Pi / OpenRouter | 0.84.3 | explicit provider/model, `--approve` (no tool-yolo on official CLI) | +| Hermes | 0.20.0 | Home TUI: `hermes chat --yolo`. Never `-q` in a pane. Never `dispatch --agent hermes`. | +| Kimi Code | 0.38.0 | `--auto` on interactive and `--prompt` launches | | GLM | Claude Code 2.1.219 | Claude adapter pointed at Z.AI Anthropic endpoint | Current catalog defaults are `gpt-5.6` for Codex, `auto` for Gemini CLI, @@ -53,9 +53,14 @@ or symlink those credentials. omega config activate codex gpt-5.6 omega config activate claude opus omega config activate antigravity # native account default -omega dispatch MyProject "mission" --agent hermes +omega new home --agent hermes +omega dispatch MyProject "mission" --agent codex ``` +Hermes is Home. Cloud (Cursor Cloud Agent) is the Cursor-side writer for +OmegaOS itself — it is not `omega dispatch`. See +`docs/ADR-lab-three-backends.md`. + The active global selection is mirrored in `~/.omega/state/active-model.json`. A mission-level `--agent` override takes precedence without changing the global default. diff --git a/install.sh b/install.sh index a59eb6c4..6bbc89bd 100755 --- a/install.sh +++ b/install.sh @@ -2944,6 +2944,26 @@ else info "Acceptance skill not found — skipping" fi +# AGK Agentic Engineering Lab — the mission loop oracles actually run. +LAB_SRC="$OMEGA_SRC/skills/agentic-engineering-lab" +LAB_DST="$OMEGA_DIR/skills/agentic-engineering-lab" +if [[ -d "$LAB_SRC" ]]; then + mkdir -p "$LAB_DST" + cp -r "$LAB_SRC"/* "$LAB_DST/" + cat > "$OMG_CMD_DST/omg-lab.md" < + AGK Agentic Engineering Lab loop that Omega oracles actually run, not a blog + post. Walk Understand → Explain → Design → Build → Debug → Test → Evaluate → + Secure → Deploy → Observe → Improve. Required coding-agent dimensions: repo + context, editing, shell, tests, git, sandbox, verification, human-in-the-loop, + finish reports. Writers cannot self-approve. Fake-done is forbidden. Use when + the user says "/lab", "/omg-lab", "agentic engineering lab", "run the lab loop", + or when an Omega oracle is dispatched. +--- + +# AGK Agentic Engineering Lab + +This skill is operational doctrine. Persist the plan, walk the steps, spawn +writers, verify, and report. Do not narrate the loop as a substitute for running it. + +## Loop (in order) + +`Understand | Explain | Design | Build | Debug | Test | Evaluate | Secure | Deploy | Observe | Improve` + +1. Persist first: `omega progress --plan "Understand|Explain|Design|Build|Debug|Test|Evaluate|Secure|Deploy|Observe|Improve"` +2. Keep exactly one task `doing`. +3. Spawn writers only: `claude | codex | glm`. Hermes is Home (`omega new --agent hermes`), never dispatch and never a worker. +4. Every worker brief must include both fields or `omega spawn-worker` refuses: + +``` +omega spawn-worker " +Done Criteria: +Verify Command: " --dir --files a,b +``` + +5. Finish reports are mandatory: `done_clean | failed | blocked` plus evidence in `omega status --json` and the oracle inbox. +6. `omega done` is a candidate. The operator alone `omega gate --accept`. Fake-done is forbidden. + +## Coding-agent dimensions (every mission) + +repo context · editing · shell · tests · git · sandbox · verification · human-in-the-loop · finish reports + +## Three backends, one orchestration API + +| Backend | Role | +|---|---| +| Codex | Mac/VPS writer and default oracle | +| Hermes | Home pane only (`omega new --agent hermes`) | +| Cloud | Cursor Cloud Agent — writer for OmegaOS itself. Not `omega dispatch`. | diff --git a/tools/duo/bin/omega-duo b/tools/duo/bin/omega-duo index 22ff7a3f..7ce163b0 100755 --- a/tools/duo/bin/omega-duo +++ b/tools/duo/bin/omega-duo @@ -688,10 +688,9 @@ function codexArgv(mode: string, degraded = false, preflight = false): string[] // single file. Bypassing matches how Claude already runs here // (--dangerously-skip-permissions): same trust boundary, same box, the // operator owns it. The legacy env name is retained for compatibility; - // Codex >=0.147 removed --full-auto, and --approve-for-me now supplies the - // workspace-write + automatic-review preset by itself. + // Codex >=0.147: never pair --sandbox with --approve-for-me (0.149 dies). if (process.env.DUO_CODEX_FULL_AUTO === "1") { - return [...base, "--approve-for-me", "--dangerously-bypass-hook-trust", "-"]; + return [...base, "--sandbox", "workspace-write", "--ask-for-approval", "never", "--dangerously-bypass-hook-trust", "-"]; } return [...base, "--dangerously-bypass-approvals-and-sandbox", "-"]; }