use std::collections::BTreeMap;
use std::env;
use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::Duration;
use falsegreen_agent::agent::{Agent, AgentLimits, RunOutcome};
use falsegreen_agent::auth::{AgentAuth, AuthenticationState, CORE_CLIENT_COMMIT};
use falsegreen_agent::context::{ContextBudget, ContextBuilder};
use falsegreen_agent::event::{EventKind, EventStore};
use falsegreen_agent::falsegreen::EmbeddedFalseGreenVerifier;
use falsegreen_agent::genui::{ActionCatalog, HostCapabilities, default_negotiated_capabilities};
use falsegreen_agent::genui_composition::CompositionLimits;
use falsegreen_agent::genui_native::{NativeStateProjection, NativeSurfaceFamily, NativeVerdict};
use falsegreen_agent::hardware::RuntimeBackend;
use falsegreen_agent::inference::{ModelCapabilities, OpenAiCompatibleProvider};
use falsegreen_agent::managed::{ManagedRuntime, ManagedRuntimeConfig};
use falsegreen_agent::mcp::McpClientSet;
use falsegreen_agent::model::NEOHORSE_V1_DOWNLOAD;
use falsegreen_agent::profile::{NEOHORSE_V1, NEOHORSE_V1_PROFILE_NAME};
use falsegreen_agent::runtime::{LLAMA_CPP_COMMIT, LLAMA_CPP_RELEASE};
use falsegreen_agent::session::{Session, SessionState};
use falsegreen_agent::tools::{NativeTools, ToolLimits};
use falsegreen_agent::ux::{
Command, Invocation, TaskCommand, WorkspaceSettings, parse, process_environment, resolve_paths,
};
use falsegreen_agent::workspace::Workspace;
use serde::Serialize;
use serde_json::json;
fn main() -> ExitCode {
let json_requested = env::args().any(|argument| argument == "--json");
match run_cli() {
Ok(code) => code,
Err(error) => {
if json_requested {
eprintln!(
"{}",
serde_json::to_string(&json!({"ok": false, "error": error.to_string()}))
.expect("error JSON is serializable")
);
} else {
eprintln!("falsegreen-agent: {error}");
}
ExitCode::from(2)
}
}
}
fn run_cli() -> Result<ExitCode, Box<dyn std::error::Error>> {
let invocation = parse(env::args().skip(1))?;
match invocation.command.clone() {
Command::Run => run_agent(&invocation, None),
Command::Resume { session_id } => run_agent(&invocation, session_id),
Command::ReplaceSession {
predecessor_session_id,
} => replace_session(&invocation, &predecessor_session_id),
Command::Inspect { session_id } => inspect_session(&invocation, session_id),
Command::Doctor => doctor(&invocation),
Command::Login => login(&invocation),
Command::Logout => logout(&invocation),
Command::Status => authentication_status(&invocation),
Command::Task(command) => configure_task(&invocation, command),
Command::Help => {
println!("{}", usage());
Ok(ExitCode::SUCCESS)
}
Command::Version => {
println!("falsegreen-agent {}", env!("CARGO_PKG_VERSION"));
Ok(ExitCode::SUCCESS)
}
}
}
fn login(invocation: &Invocation) -> Result<ExitCode, Box<dyn std::error::Error>> {
let enrollment_key = if let Some(token) = invocation.options.get("token") {
token.clone()
} else {
eprint!("Enter your FalseGreen enrollment key: ");
io::stderr().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
input.trim().to_owned()
};
let auth = AgentAuth::discover()?;
let outcome = auth.login(&enrollment_key)?;
print_value(
invocation.json(),
&json!({
"ok": true,
"session_id": outcome.session_id,
"credentials_shared_with_standalone": outcome.credentials_shared_with_standalone,
"core_client_commit": CORE_CLIENT_COMMIT
}),
&format!(
"Logged in. Session {} is shared with the standalone FalseGreen client.",
outcome.session_id
),
)?;
Ok(ExitCode::SUCCESS)
}
fn logout(invocation: &Invocation) -> Result<ExitCode, Box<dyn std::error::Error>> {
let auth = AgentAuth::discover()?;
let outcome = auth.logout()?;
print_value(
invocation.json(),
&json!({"ok": true, "outcome": outcome}),
match outcome {
falsegreen_agent::auth::LogoutOutcome::Revoked => {
"Logged out and revoked the stored FalseGreen session."
}
falsegreen_agent::auth::LogoutOutcome::NoStoredSession => {
"No stored FalseGreen session was present."
}
},
)?;
Ok(ExitCode::SUCCESS)
}
fn authentication_status(invocation: &Invocation) -> Result<ExitCode, Box<dyn std::error::Error>> {
let status = AgentAuth::discover()?.status()?;
let ok = status.authentication == AuthenticationState::Authenticated;
print_value(
invocation.json(),
&json!({
"ok": ok,
"authentication": status.authentication,
"session_id": status.session_id,
"api_endpoint": status.api_endpoint,
"detail": status.detail,
"core_client_commit": CORE_CLIENT_COMMIT
}),
&format!(
"FalseGreen authentication: {:?}. {}\nAPI endpoint: {}",
status.authentication, status.detail, status.api_endpoint
),
)?;
Ok(if ok {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
})
}
fn run_agent(
invocation: &Invocation,
forced_session: Option<String>,
) -> Result<ExitCode, Box<dyn std::error::Error>> {
let current_dir = env::current_dir()?;
let environment = process_environment();
let paths = resolve_paths(&invocation.options, ¤t_dir, &environment)?;
paths.prepare()?;
let workspace = Workspace::open(&paths.workspace)?;
let mut settings = WorkspaceSettings::load(&paths)?;
let effective_options = effective_options(&invocation.options, &environment);
let mut store = EventStore::open(&paths.database)?;
let requested_session = forced_session.or_else(|| invocation.options.get("session").cloned());
if requested_session.is_some() && invocation.options.contains_key("goal") {
return Err("a resumed session already has a frozen goal; do not pass --goal".into());
}
let explicit_resume = matches!(invocation.command, Command::Resume { .. });
let implicit_resume = requested_session.is_none()
&& !invocation.switched("new-session")
&& !invocation.options.contains_key("goal")
&& matches!(invocation.command, Command::Run);
let session_id = if requested_session.is_none() && (implicit_resume || explicit_resume) {
latest_paused_session(&store)?
} else {
requested_session
};
if explicit_resume && session_id.is_none() {
return Err("there is no safely paused session to resume in this workspace".into());
}
let resumed = session_id.is_some();
let (existing_session, pending_goal, task_id) = if let Some(session_id) = session_id {
let session = Session::reconstruct(&store, &session_id)?;
session.validate_replacement_resume(&store, &workspace)?;
let persisted_task = session_falsegreen_task(&store, &session_id)?;
let selected_task = selected_task(&invocation.options, &settings, &environment);
if let (Some(persisted), Some(selected)) = (&persisted_task, &selected_task)
&& persisted != selected
{
return Err(format!(
"session {session_id} is bound to FalseGreen task {persisted}, not {selected}"
)
.into());
}
let task_id = persisted_task.or(selected_task).ok_or_else(|| {
format!(
"session {session_id} has no recorded FalseGreen task; select one with `falsegreen-agent task select ID`"
)
})?;
(Some(session), None, task_id)
} else {
let requested_goal = invocation
.options
.get("goal")
.or_else(|| environment.get("FALSEGREEN_AGENT_GOAL"))
.filter(|goal| !goal.trim().is_empty())
.cloned();
let goal = match requested_goal {
Some(goal) => Some(goal),
None => prompt_value(invocation, "Coding goal: ")?,
}
.ok_or("a new run needs a goal: `falsegreen-agent \"your task\"` or --goal TEXT")?;
let selected = selected_task(&invocation.options, &settings, &environment);
let task_id = match selected {
Some(task) => Some(task),
None => prompt_value(invocation, "FalseGreen task ID: ")?,
}
.ok_or(
"no FalseGreen task is selected; run `falsegreen-agent task select TASK_ID` or pass --task TASK_ID",
)?;
(None, Some(goal), task_id)
};
if (!resumed || invocation.options.contains_key("fg-task"))
&& settings.selected_falsegreen_task.as_deref() != Some(&task_id)
{
settings.selected_falsegreen_task = Some(task_id.clone());
settings.save(&paths)?;
}
let limits = AgentLimits {
max_model_turns: number_option(&effective_options, "max-model-turns", 40)?,
max_tool_calls: number_option(&effective_options, "max-tool-calls", 80)?,
max_repair_cycles: number_option(&effective_options, "max-repair-cycles", 2)?,
max_wall_time: duration_option(&effective_options, "max-wall-seconds", 1_800)?,
};
let inference_timeout = duration_option(&effective_options, "inference-timeout-seconds", 180)?;
let verification_timeout =
duration_option(&effective_options, "verification-timeout-seconds", 900)?;
let temperature = effective_options
.get("temperature")
.map_or(Ok(0.0), |value| value.parse())
.map_err(|_| "--temperature must be a number")?;
if !(0.0..=2.0).contains(&temperature) {
return Err("--temperature must be between 0 and 2".into());
}
validate_task_id(&task_id)?;
if AgentAuth::discover()?.local_status()?.session_id.is_none() {
return Err(
"FalseGreen Agent is not authenticated; run `falsegreen-agent login` first".into(),
);
}
let falsegreen = EmbeddedFalseGreenVerifier::from_stored_session(
&task_id,
verification_timeout,
)
.map_err(|error| {
let detail = error.to_string();
if detail.to_ascii_lowercase().contains("not authenticated") {
"FalseGreen Agent is not authenticated; run `falsegreen-agent login` first".to_owned()
} else {
format!("embedded FalseGreen client could not open the stored session: {detail}")
}
})?;
let mcp_config = mcp_config_path(
invocation,
&settings,
&environment,
&paths.workspace,
¤t_dir,
)?;
if let Some(config) = &mcp_config {
McpClientSet::inspect_config_path(config)?;
}
let external_endpoint = effective_options.get("endpoint").cloned();
let mut managed_runtime = None;
let (provider, model_capabilities, runtime_mode) = if let Some(endpoint) = external_endpoint {
let capabilities = external_model_configuration(&effective_options)?;
let api_key = effective_options
.get("api-key-env")
.map(env::var)
.transpose()?;
let provider = OpenAiCompatibleProvider::new(
&endpoint,
&capabilities.identifier,
api_key,
inference_timeout,
)
.with_model_capabilities(capabilities.clone());
(provider, capabilities, "external_override")
} else {
reject_managed_model_overrides(&effective_options)?;
let mut config = if let Some(cache_dir) = effective_options.get("cache-dir") {
ManagedRuntimeConfig::new(absolute_path(cache_dir, ¤t_dir))
} else {
ManagedRuntimeConfig::discover()?
};
config.preferred_backend = effective_options
.get("runtime-backend")
.map(|value| parse_runtime_backend(value))
.transpose()?;
if config.cache_root.starts_with(&paths.workspace) {
return Err("managed cache directory must remain outside the coding workspace".into());
}
config.startup_timeout =
duration_option(&effective_options, "runtime-startup-timeout-seconds", 600)?;
eprintln!(
"Preparing the pinned managed runtime and NeoHorse model in {}...",
config.cache_root.display()
);
let mut runtime = ManagedRuntime::new(config);
let prepared = runtime.prepare()?;
let capabilities = prepared.capabilities.clone();
let provider = prepared.provider(inference_timeout);
managed_runtime = Some(runtime);
(provider, capabilities, "managed")
};
let default_context_tokens = model_capabilities
.context_window_tokens
.map_or(16_384, |tokens| tokens as usize);
let context_budget = ContextBudget {
max_context_tokens: usize_option(
&effective_options,
"max-context-tokens",
default_context_tokens,
)?,
output_reserve_tokens: usize_option(&effective_options, "output-reserve-tokens", 2_048)?,
recent_event_limit: usize_option(&effective_options, "recent-event-limit", 24)?,
};
if context_budget.output_reserve_tokens >= context_budget.max_context_tokens {
return Err("--output-reserve-tokens must be smaller than --max-context-tokens".into());
}
if let Some(model_limit) = model_capabilities.context_window_tokens
&& context_budget.max_context_tokens > model_limit as usize
{
return Err(format!(
"--max-context-tokens exceeds the selected model profile limit of {model_limit}"
)
.into());
}
let mut tools = NativeTools::new(workspace, ToolLimits::default());
if let Some(config_path) = &mcp_config {
tools = tools.with_mcp(McpClientSet::from_config_path(config_path)?);
}
let tool_schemas = tools.tool_schemas();
let provider = provider.with_tool_schemas(tool_schemas.clone());
if invocation.options.contains_key("mcp-config") {
settings.mcp_config = mcp_config.clone();
settings.save(&paths)?;
}
let session = match existing_session {
Some(session) => session,
None => Session::create(
&mut store,
pending_goal.as_deref().expect("new sessions have a goal"),
)?,
};
if !resumed {
store.append(
&session.id,
EventKind::Checkpoint,
&json!({
"checkpoint_kind": "acceptance_authority",
"falsegreen_task_id": task_id,
"harness_version": env!("CARGO_PKG_VERSION"),
"core_client_commit": CORE_CLIENT_COMMIT,
"ux": {
"workspace_defaulted_to_current_directory": !invocation.options.contains_key("workspace"),
"runtime": runtime_mode,
"falsegreen": "embedded",
"mcp": mcp_config
}
}),
)?;
}
store.append(
&session.id,
EventKind::Checkpoint,
&json!({
"checkpoint_kind": "mcp_discovery",
"discovery": tools.mcp_discovery_metadata(),
"native_only": tools.mcp_discovery_metadata().is_none()
}),
)?;
let pause_after = optional_number(&effective_options, "pause-after-model-turns")?;
let genui_enabled = invocation.switched("genui");
let run_and_render = {
let mut agent = Agent::new(
&mut store,
session,
provider,
falsegreen,
tools,
ContextBuilder::new(context_budget).with_tool_schemas(tool_schemas),
limits,
)
.with_temperature(temperature)
.with_pause_after_model_turns(pause_after);
let outcome = agent.run()?;
let rendered = if genui_enabled {
let width = env::var("COLUMNS")
.ok()
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(100)
.max(1);
let host = HostCapabilities {
terminal_width: width,
..HostCapabilities::default()
};
let capabilities = default_negotiated_capabilities(width);
let mut catalog = ActionCatalog::default();
Some(
agent
.compose_genui_for_outcome(
&outcome,
&host,
&capabilities,
&mut catalog,
CompositionLimits::default(),
)?
.rendered()
.to_owned(),
)
} else {
None
};
Ok::<_, falsegreen_agent::agent::AgentError>((outcome, rendered))
};
let diagnostics = managed_runtime
.as_ref()
.map(ManagedRuntime::diagnostics)
.unwrap_or_default();
if let Some(runtime) = managed_runtime.as_mut()
&& let Err(error) = runtime.shutdown()
{
eprintln!("warning: managed runtime cleanup failed: {error}");
}
let (outcome, genui_rendered) = run_and_render.map_err(|error| {
if diagnostics.is_empty() {
error.to_string()
} else {
format!("{error}\nmanaged runtime diagnostics:\n{diagnostics}")
}
})?;
print_outcome(
&outcome,
&store,
invocation.json(),
invocation.switched("genui"),
genui_rendered.as_deref(),
)?;
Ok(exit_for_state(outcome.state))
}
fn configure_task(
invocation: &Invocation,
command: TaskCommand,
) -> Result<ExitCode, Box<dyn std::error::Error>> {
let current_dir = env::current_dir()?;
let environment = process_environment();
let paths = resolve_paths(&invocation.options, ¤t_dir, &environment)?;
paths.prepare()?;
Workspace::open(&paths.workspace)?;
let mut settings = WorkspaceSettings::load(&paths)?;
match command {
TaskCommand::Select(task_id) => {
settings.selected_falsegreen_task = Some(task_id.clone());
settings.save(&paths)?;
print_value(
invocation.json(),
&json!({"ok": true, "selected_task": task_id, "workspace": paths.workspace}),
&format!("Selected FalseGreen task {task_id} for this workspace."),
)?;
}
TaskCommand::Show => {
let task = settings.selected_falsegreen_task.as_deref();
print_value(
invocation.json(),
&json!({"ok": task.is_some(), "selected_task": task, "workspace": paths.workspace}),
task.map_or("No FalseGreen task is selected.", |task_id| task_id),
)?;
if task.is_none() {
return Ok(ExitCode::from(1));
}
}
TaskCommand::Clear => {
settings.selected_falsegreen_task = None;
settings.save(&paths)?;
print_value(
invocation.json(),
&json!({"ok": true, "selected_task": null, "workspace": paths.workspace}),
"Cleared the selected FalseGreen task for this workspace.",
)?;
}
}
Ok(ExitCode::SUCCESS)
}
fn inspect_session(
invocation: &Invocation,
requested_session: Option<String>,
) -> Result<ExitCode, Box<dyn std::error::Error>> {
let current_dir = env::current_dir()?;
let environment = process_environment();
let paths = resolve_paths(&invocation.options, ¤t_dir, &environment)?;
paths.prepare()?;
let store = EventStore::open(&paths.database)?;
let session_id = requested_session
.or_else(|| store.session_ids_by_recency().ok()?.into_iter().next())
.ok_or("there are no sessions for this workspace")?;
let session = Session::reconstruct(&store, &session_id)?;
let events = store.events(&session_id)?;
let predecessor = store.replacement_for_session(&session_id)?;
let replacement = store.replacement_for_predecessor(&session_id)?;
let value = json!({
"session": {
"id": session.id,
"goal": session.goal,
"state": session.state,
"model_turns": session.model_turns,
"tool_calls": session.tool_calls,
"repair_cycles": session.repair_cycles,
"falsegreen_task_id": session_falsegreen_task(&store, &session_id)?
},
"replacement": {
"predecessor": predecessor,
"successor": replacement
},
"events": events
});
if invocation.json() {
println!("{}", serde_json::to_string_pretty(&value)?);
} else {
println!(
"Session {} — {:?}\nGoal: {}\nModel turns: {}; tool calls: {}; repairs: {}",
session.id,
session.state,
session.goal,
session.model_turns,
session.tool_calls,
session.repair_cycles
);
}
Ok(ExitCode::SUCCESS)
}
fn replace_session(
invocation: &Invocation,
predecessor_session_id: &str,
) -> Result<ExitCode, Box<dyn std::error::Error>> {
let current_dir = env::current_dir()?;
let environment = process_environment();
let paths = resolve_paths(&invocation.options, ¤t_dir, &environment)?;
paths.prepare()?;
let workspace = Workspace::open(&paths.workspace)?;
let mut store = EventStore::open(&paths.database)?;
let expected_candidate = invocation
.options
.get("candidate-sha256")
.ok_or("replace-session requires --candidate-sha256 SHA256")?;
let created = Session::create_replacement(
&mut store,
&workspace,
predecessor_session_id,
expected_candidate,
)?;
print_value(
invocation.json(),
&json!({
"ok": true,
"session_id": created.session.id,
"state": created.session.state,
"predecessor_session_id": created.replacement.predecessor_session_id,
"candidate_sha256": created.replacement.candidate_sha256,
"falsegreen_task_id": created.replacement.falsegreen_task_id,
"relationship": "single_direct_replacement_no_chains",
"model_turns": created.session.model_turns,
"tool_calls": created.session.tool_calls
}),
&format!(
"Created replacement session {} for terminal predecessor {} with candidate {}.",
created.session.id,
created.replacement.predecessor_session_id,
created.replacement.candidate_sha256
),
)?;
Ok(ExitCode::SUCCESS)
}
#[derive(Debug, Serialize)]
struct DoctorReport {
ok: bool,
workspace: PathBuf,
state_directory: PathBuf,
cache_directory: Option<PathBuf>,
embedded_core_commit: &'static str,
checks: Vec<DoctorCheck>,
}
#[derive(Debug, Serialize)]
struct DoctorCheck {
name: &'static str,
ok: bool,
detail: String,
}
fn doctor(invocation: &Invocation) -> Result<ExitCode, Box<dyn std::error::Error>> {
let current_dir = env::current_dir()?;
let environment = process_environment();
let paths = resolve_paths(&invocation.options, ¤t_dir, &environment)?;
let mut checks = Vec::new();
checks.push(check(
"workspace",
Workspace::open(&paths.workspace).is_ok(),
paths.workspace.display().to_string(),
));
let state_ready = paths.prepare().is_ok();
checks.push(check(
"durable_state",
state_ready,
format!("outside workspace at {}", paths.workspace_state.display()),
));
let settings = WorkspaceSettings::load(&paths)?;
checks.push(check(
"falsegreen_task",
settings.selected_falsegreen_task.is_some()
|| invocation.options.contains_key("fg-task")
|| environment.contains_key("FALSEGREEN_TASK_ID")
|| environment.contains_key("FGREEN_TASK_ID"),
selected_task(&invocation.options, &settings, &environment).map_or_else(
|| "not selected".to_owned(),
|task| format!("selected: {task}"),
),
));
let effective_options = effective_options(&invocation.options, &environment);
let mut cache_directory = None;
if let Some(endpoint) = effective_options.get("endpoint") {
let model = external_model_configuration(&effective_options);
checks.push(check(
"platform",
true,
format!("{}-{}", env::consts::OS, env::consts::ARCH),
));
checks.push(check(
"managed_runtime",
true,
format!("disabled by explicit external endpoint {endpoint}"),
));
checks.push(check(
"model",
model.is_ok(),
model.map_or_else(
|error| error.to_string(),
|model| format!("{}; external stack is unqualified", model.identifier),
),
));
} else {
let mut config = if let Some(cache_dir) = effective_options.get("cache-dir") {
ManagedRuntimeConfig::new(absolute_path(cache_dir, ¤t_dir))
} else {
ManagedRuntimeConfig::discover()?
};
config.preferred_backend = effective_options
.get("runtime-backend")
.map(|value| parse_runtime_backend(value))
.transpose()?;
cache_directory = Some(config.cache_root.clone());
if config.cache_root.starts_with(&paths.workspace) {
checks.push(check(
"managed_cache",
false,
"cache directory must remain outside the coding workspace".to_owned(),
));
} else {
let inspection = config.inspect(invocation.switched("verify-cache"));
match inspection {
Ok(inspection) => {
checks.push(check(
"platform",
true,
format!(
"{}; CPUs={}; memory_bytes={:?}; GPUs={:?}",
inspection.hardware.platform,
inspection.hardware.logical_cpus,
inspection.hardware.total_memory_bytes,
inspection.hardware.gpus
),
));
let runtime_valid =
!inspection.runtime.install_present || inspection.runtime.install_verified;
let archive_valid = inspection.runtime.archive_digest_verified.unwrap_or(true);
checks.push(check(
"managed_runtime",
runtime_valid && archive_valid,
format!(
"llama.cpp {} commit {}; backend={}; artifact={} sha256={}; archive_present={}; archive_digest_verified={:?}; install_verified={}; qualified_stack=false",
LLAMA_CPP_RELEASE,
LLAMA_CPP_COMMIT,
inspection.backend.cache_key(),
inspection.runtime.artifact_file_name,
inspection.runtime.artifact_sha256,
inspection.runtime.archive_present,
inspection.runtime.archive_digest_verified,
inspection.runtime.install_verified
),
));
let model_valid = (!inspection.model.present
&& inspection.available_disk_bytes >= NEOHORSE_V1_DOWNLOAD.size_bytes)
|| (inspection.model.size_matches
&& inspection.model.digest_verified != Some(false));
checks.push(check(
"model",
model_valid,
format!(
"{} revision {}; path={}; expected_sha256={}; present={}; size_matches={}; digest_verified={:?}; available_disk_bytes={}",
NEOHORSE_V1_DOWNLOAD.repository,
NEOHORSE_V1_DOWNLOAD.revision,
inspection.model.path.display(),
NEOHORSE_V1_DOWNLOAD.sha256,
inspection.model.present,
inspection.model.size_matches,
inspection.model.digest_verified,
inspection.available_disk_bytes
),
));
}
Err(error) => {
checks.push(check("platform", false, error.to_string()));
checks.push(check("managed_runtime", false, error.to_string()));
checks.push(check("model", false, error.to_string()));
}
}
}
}
let auth = AgentAuth::discover()?;
let auth_status = auth.local_status()?;
checks.push(check(
"falsegreen_auth",
auth_status.session_id.is_some(),
format!(
"embedded Core {}; state={:?}; session={}; endpoint={}; {}",
CORE_CLIENT_COMMIT,
auth_status.authentication,
auth_status.session_id.as_deref().unwrap_or("none"),
auth_status.api_endpoint,
auth_status.detail
),
));
let mcp = mcp_config_path(
invocation,
&settings,
&environment,
&paths.workspace,
¤t_dir,
)?;
let mcp_check = match mcp {
None if invocation.switched("no-mcp") => check(
"mcp",
true,
"disabled explicitly; native tools only".to_owned(),
),
None => check(
"mcp",
true,
"no configuration; native tools only".to_owned(),
),
Some(path) if invocation.switched("mcp-smoke") => {
match McpClientSet::from_config_path(&path) {
Ok(clients) => {
let discovery = clients.discovery_metadata();
let servers = discovery["servers"].as_array();
let server_count = servers.map_or(0, Vec::len);
let tool_count = servers.map_or(0, |items| {
items
.iter()
.filter_map(|server| server["tools"].as_array())
.map(Vec::len)
.sum::<usize>()
});
check(
"mcp",
true,
format!(
"rmcp 3.3.0 live initialize/capability negotiation/tools/list/cleanup succeeded; servers={server_count}; tools={tool_count}"
),
)
}
Err(error) => check("mcp", false, error.to_string()),
}
}
Some(path) => match McpClientSet::inspect_config_path(&path) {
Ok(summary) => check(
"mcp",
true,
format!("configuration={}; summary={summary}", path.display()),
),
Err(error) => check("mcp", false, error.to_string()),
},
};
checks.push(mcp_check);
let ok = checks.iter().all(|item| item.ok);
let report = DoctorReport {
ok,
workspace: paths.workspace,
state_directory: paths.workspace_state,
cache_directory,
embedded_core_commit: CORE_CLIENT_COMMIT,
checks,
};
if invocation.json() {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!("FalseGreen Agent doctor\n");
for item in &report.checks {
println!(
"{} {:<20} {}",
if item.ok { "OK" } else { "NEEDS ATTENTION" },
item.name,
item.detail
);
}
}
Ok(if ok {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
})
}
const fn check(name: &'static str, ok: bool, detail: String) -> DoctorCheck {
DoctorCheck { name, ok, detail }
}
fn effective_options(
options: &BTreeMap<String, String>,
environment: &BTreeMap<String, String>,
) -> BTreeMap<String, String> {
let mut effective = options.clone();
for (option, variable) in [
("endpoint", "FALSEGREEN_AGENT_ENDPOINT"),
("model-artifact", "FALSEGREEN_AGENT_MODEL_ARTIFACT"),
("cache-dir", "FALSEGREEN_AGENT_CACHE_DIR"),
("runtime-backend", "FALSEGREEN_AGENT_RUNTIME_BACKEND"),
] {
if !effective.contains_key(option)
&& let Some(value) = environment.get(variable)
{
effective.insert(option.to_owned(), value.clone());
}
}
effective
}
fn external_model_configuration(
options: &BTreeMap<String, String>,
) -> Result<ModelCapabilities, Box<dyn std::error::Error>> {
if options.contains_key("model") {
return Err(
"--model is ambiguous in V1; use the pinned NeoHorse artifact or --unqualified-model MODEL"
.into(),
);
}
if let Some(model) = options.get("unqualified-model") {
return Ok(ModelCapabilities {
identifier: model.clone(),
repository: options.get("model-repository").cloned(),
artifact: options.get("model-artifact").cloned(),
artifact_sha256: options.get("artifact-sha256").cloned(),
quantization: options.get("quantization").cloned(),
chat_template: options.get("chat-template").cloned(),
context_window_tokens: options
.get("model-context-tokens")
.map(|value| value.parse())
.transpose()
.map_err(|_| "--model-context-tokens must be an unsigned integer")?,
native_tools: true,
qualification: None,
});
}
let profile = options
.get("profile")
.map_or(NEOHORSE_V1_PROFILE_NAME, String::as_str);
if profile != NEOHORSE_V1_PROFILE_NAME {
return Err(format!(
"unknown model profile {profile:?}; use {NEOHORSE_V1_PROFILE_NAME:?} or --unqualified-model"
)
.into());
}
for forbidden in [
"model-repository",
"artifact-sha256",
"quantization",
"chat-template",
"model-context-tokens",
] {
if options.contains_key(forbidden) {
return Err(format!(
"--{forbidden} cannot override the pinned NeoHorse profile; use --unqualified-model"
)
.into());
}
}
let artifact = options.get("model-artifact").ok_or(
"an external --endpoint requires --model-artifact for pinned NeoHorse, or --unqualified-model MODEL",
)?;
let mut capabilities = NEOHORSE_V1.capabilities(artifact)?;
capabilities.qualification = None;
Ok(capabilities)
}
fn reject_managed_model_overrides(
options: &BTreeMap<String, String>,
) -> Result<(), Box<dyn std::error::Error>> {
for name in [
"model-artifact",
"model",
"unqualified-model",
"model-repository",
"artifact-sha256",
"quantization",
"chat-template",
"model-context-tokens",
"api-key-env",
] {
if options.contains_key(name) {
return Err(format!(
"--{name} is an external-provider override and requires --endpoint"
)
.into());
}
}
if options
.get("profile")
.is_some_and(|profile| profile != NEOHORSE_V1_PROFILE_NAME)
{
return Err(format!(
"the managed runtime supports only profile {NEOHORSE_V1_PROFILE_NAME:?}"
)
.into());
}
Ok(())
}
fn parse_runtime_backend(value: &str) -> Result<RuntimeBackend, String> {
match value {
"cpu" => Ok(RuntimeBackend::Cpu),
"rocm" => Ok(RuntimeBackend::Rocm),
"vulkan" => Ok(RuntimeBackend::Vulkan),
"metal" => Ok(RuntimeBackend::Metal),
_ => Err("--runtime-backend must be cpu, rocm, vulkan, or metal".to_owned()),
}
}
fn validate_task_id(value: &str) -> Result<(), String> {
if value.is_empty()
|| value.len() > 512
|| value
.bytes()
.any(|byte| !(byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')))
{
Err("FalseGreen task ID has an invalid format".to_owned())
} else {
Ok(())
}
}
fn selected_task(
options: &BTreeMap<String, String>,
settings: &WorkspaceSettings,
environment: &BTreeMap<String, String>,
) -> Option<String> {
options
.get("fg-task")
.or_else(|| environment.get("FALSEGREEN_TASK_ID"))
.or_else(|| environment.get("FGREEN_TASK_ID"))
.cloned()
.or_else(|| settings.selected_falsegreen_task.clone())
}
fn mcp_config_path(
invocation: &Invocation,
settings: &WorkspaceSettings,
environment: &BTreeMap<String, String>,
workspace: &Path,
current_dir: &Path,
) -> Result<Option<PathBuf>, String> {
if invocation.switched("no-mcp") && invocation.options.contains_key("mcp-config") {
return Err("--no-mcp cannot be combined with --mcp-config".to_owned());
}
if invocation.switched("no-mcp") {
return Ok(None);
}
let selected = invocation
.options
.get("mcp-config")
.map(PathBuf::from)
.or_else(|| {
environment
.get("FALSEGREEN_AGENT_MCP_CONFIG")
.map(PathBuf::from)
})
.or_else(|| settings.mcp_config.clone())
.or_else(|| {
let local = workspace.join(".falsegreen-agent/mcp.json");
local.is_file().then_some(local)
});
Ok(selected.map(|path| {
if path.is_absolute() {
path
} else {
current_dir.join(path)
}
}))
}
fn prompt_value(invocation: &Invocation, prompt: &str) -> Result<Option<String>, io::Error> {
if invocation.json() || !io::stdin().is_terminal() {
return Ok(None);
}
eprint!("{prompt}");
io::stderr().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let value = input.trim();
Ok((!value.is_empty()).then(|| value.to_owned()))
}
fn session_falsegreen_task(
store: &EventStore,
session_id: &str,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
Ok(store.events(session_id)?.into_iter().find_map(|event| {
(event.kind == EventKind::Checkpoint
&& event.payload["checkpoint_kind"] == "acceptance_authority")
.then(|| {
event.payload["falsegreen_task_id"]
.as_str()
.map(str::to_owned)
})
.flatten()
}))
}
fn latest_paused_session(store: &EventStore) -> Result<Option<String>, Box<dyn std::error::Error>> {
for session_id in store.session_ids_by_recency()? {
if Session::reconstruct(store, &session_id)?.state == SessionState::Paused {
return Ok(Some(session_id));
}
}
Ok(None)
}
fn absolute_path(path: &str, current_dir: &Path) -> PathBuf {
let path = PathBuf::from(path);
if path.is_absolute() {
path
} else {
current_dir.join(path)
}
}
fn print_outcome(
outcome: &RunOutcome,
store: &EventStore,
json_output: bool,
genui_enabled: bool,
genui_rendered: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let native_decision = evaluate_native_authority(store, &outcome.session_id)?;
if json_output {
let mut value = serde_json::to_value(outcome)?;
let object = value
.as_object_mut()
.ok_or("run outcome JSON is not an object")?;
object.remove("falsegreen_result");
match &native_decision {
NativeAuthorityDecision::Valid(projection) => {
object.insert(
"falsegreen_state".to_owned(),
json!(native_verdict_wire(projection.verification.verdict)),
);
object.insert(
"falsegreen_authority".to_owned(),
json!({
"verdict": native_verdict_wire(projection.verification.verdict),
"source": "validated_native_projection",
"result_digest": projection.verification.result_digest.value(),
}),
);
if let Some(result) = &outcome.falsegreen_result {
object.insert(
"falsegreen_result".to_owned(),
serde_json::to_value(result)?,
);
}
}
NativeAuthorityDecision::Unavailable(reason) => {
object.insert("falsegreen_state".to_owned(), json!("unavailable"));
object.insert("falsegreen_diagnostic".to_owned(), json!(reason));
object.insert("authority_scope".to_owned(), json!("agent_session_only"));
}
NativeAuthorityDecision::NoNative => {
object.insert("authority_scope".to_owned(), json!("agent_session_only"));
}
}
println!("{}", serde_json::to_string_pretty(&value)?);
return Ok(());
}
println!("Session: {}", outcome.session_id);
println!("State: {:?}", outcome.state);
println!(
"Model turns: {}; tool calls: {}; repair cycles: {}",
outcome.model_turns, outcome.tool_calls, outcome.repair_cycles
);
if genui_enabled {
let width = std::env::var("COLUMNS")
.ok()
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(100)
.max(1);
match native_decision {
NativeAuthorityDecision::Valid(projection) => {
println!("\nG4 FalseGreen-native surfaces (read-only projection)");
for family in [
NativeSurfaceFamily::Task,
NativeSurfaceFamily::FrozenScope,
NativeSurfaceFamily::Job,
NativeSurfaceFamily::Run,
NativeSurfaceFamily::VerificationResult,
NativeSurfaceFamily::Evidence,
NativeSurfaceFamily::Report,
NativeSurfaceFamily::FailureRecovery,
NativeSurfaceFamily::ReplacementSession,
] {
let rendered = projection.render(store, family, width)?;
println!("\n[{}]\n{}", family.as_str(), rendered);
}
println!(
"\nFalseGreen native verdict: {} (validated authority).",
projection.verification.verdict.label()
);
}
NativeAuthorityDecision::Unavailable(reason) => {
println!(
"\nG4 FalseGreen-native surfaces (read-only projection)\n\
FalseGreen state unavailable\nAuthority data inconsistent\nRefresh required\nDiagnostic: {reason}"
);
}
NativeAuthorityDecision::NoNative => {
println!("{}", agent_session_state_message(outcome.state));
}
}
} else {
match native_decision {
NativeAuthorityDecision::NoNative => {
println!("{}", agent_session_state_message(outcome.state));
}
NativeAuthorityDecision::Unavailable(reason) => {
println!("FalseGreen state unavailable; {reason}");
println!("{}", agent_session_state_message(outcome.state));
}
NativeAuthorityDecision::Valid(_) => {
println!("{}", agent_session_state_message(outcome.state));
}
}
}
match outcome.state {
SessionState::Completed => println!(
"Canonical FalseGreen accepted the candidate and granted completion authority."
),
SessionState::AcceptedAwaitingAuthority => println!(
"Canonical verification accepted the candidate, but completion authority is not ready."
),
SessionState::Paused => {
println!("Paused at a durable turn boundary. Resume with `falsegreen-agent resume`.")
}
_ => {}
}
if let Some(rendered) = genui_rendered {
println!("\nGenUI surface (G5 composition)\n{rendered}");
}
Ok(())
}
enum NativeAuthorityDecision {
NoNative,
Valid(Box<NativeStateProjection>),
Unavailable(String),
}
fn evaluate_native_authority(
store: &EventStore,
session_id: &str,
) -> Result<NativeAuthorityDecision, Box<dyn std::error::Error>> {
if !native_authority_applicable(store, session_id)? {
return Ok(NativeAuthorityDecision::NoNative);
}
let projection = match NativeStateProjection::from_store(store, session_id) {
Ok(projection) => projection,
Err(error) => return Ok(NativeAuthorityDecision::Unavailable(error.to_string())),
};
let events = store.events(session_id)?;
let has_report = events.iter().any(|event| {
event.kind == EventKind::Checkpoint
&& event.payload["checkpoint_kind"] == "report_authority"
});
let has_evidence = events.iter().any(|event| {
event.kind == EventKind::FalsegreenResult && event.payload.get("evidence").is_some()
});
if projection.verification.verdict == NativeVerdict::Unknown
|| !projection.verification.source_bound
|| (has_report && projection.report.id.value().is_none())
|| (has_evidence && !projection.evidence.complete)
{
return Ok(NativeAuthorityDecision::Unavailable(
projection
.verification
.binding_error
.clone()
.unwrap_or_else(|| "native authority is incomplete or inconsistent".to_owned()),
));
}
Ok(NativeAuthorityDecision::Valid(Box::new(projection)))
}
fn native_verdict_wire(verdict: NativeVerdict) -> &'static str {
match verdict {
NativeVerdict::Accepted => "accepted",
NativeVerdict::Failed => "failed",
NativeVerdict::InsufficientEvidence => "insufficient_evidence",
NativeVerdict::Incomplete => "incomplete",
NativeVerdict::Invalid => "invalid",
NativeVerdict::Unknown => "unknown",
}
}
fn agent_session_state_message(state: SessionState) -> &'static str {
match state {
SessionState::Initializing => "Agent session initializing.",
SessionState::Working => "Agent session working.",
SessionState::WaitingForTool => "Agent session waiting for a tool.",
SessionState::CandidateReady => "Agent session candidate ready.",
SessionState::Verifying => "Agent session verifying.",
SessionState::Repairing => "Agent session repairing.",
SessionState::Paused => "Agent session paused at a durable turn boundary.",
SessionState::AcceptedAwaitingAuthority => {
"Agent session accepted-awaiting-authority; FalseGreen completion authority is separate."
}
SessionState::Completed => "Agent session completed.",
SessionState::Failed => "Agent session failed.",
SessionState::BudgetExhausted => "Agent session budget exhausted.",
}
}
fn native_authority_applicable(
store: &EventStore,
session_id: &str,
) -> Result<bool, Box<dyn std::error::Error>> {
let events = store.events(session_id)?;
Ok(events.iter().any(|event| {
event.kind == EventKind::FalsegreenResult
|| (event.kind == EventKind::Checkpoint
&& matches!(
event.payload["checkpoint_kind"].as_str(),
Some(
"acceptance_authority"
| "task_authority"
| "falsegreen_task_authority"
| "frozen_scope_authority"
| "job_authority"
| "run_authority"
| "recovery_authority"
| "report_authority"
)
))
}))
}
fn print_value(
json_output: bool,
value: &serde_json::Value,
human: &str,
) -> Result<(), serde_json::Error> {
if json_output {
println!("{}", serde_json::to_string_pretty(value)?);
} else {
println!("{human}");
}
Ok(())
}
fn exit_for_state(state: SessionState) -> ExitCode {
match state {
SessionState::Completed => ExitCode::SUCCESS,
SessionState::Paused => ExitCode::from(75),
SessionState::AcceptedAwaitingAuthority => ExitCode::from(3),
_ => ExitCode::from(1),
}
}
fn number_option(
options: &BTreeMap<String, String>,
key: &str,
default: u32,
) -> Result<u32, String> {
options.get(key).map_or(Ok(default), |value| {
value
.parse()
.map_err(|_| format!("--{key} must be an unsigned integer"))
})
}
fn optional_number(options: &BTreeMap<String, String>, key: &str) -> Result<Option<u32>, String> {
options
.get(key)
.map(|value| {
value
.parse()
.map_err(|_| format!("--{key} must be an unsigned integer"))
})
.transpose()
}
fn usize_option(
options: &BTreeMap<String, String>,
key: &str,
default: usize,
) -> Result<usize, String> {
options.get(key).map_or(Ok(default), |value| {
value
.parse()
.map_err(|_| format!("--{key} must be an unsigned integer"))
})
}
fn duration_option(
options: &BTreeMap<String, String>,
key: &str,
default_seconds: u64,
) -> Result<Duration, String> {
let seconds = options.get(key).map_or(Ok(default_seconds), |value| {
value
.parse()
.map_err(|_| format!("--{key} must be an unsigned integer"))
})?;
Ok(Duration::from_secs(seconds))
}
fn usage() -> &'static str {
"FalseGreen Agent — bounded local coding with canonical completion authority
Usage:
falsegreen-agent [GOAL] [OPTIONS]
falsegreen-agent run [GOAL] [OPTIONS]
falsegreen-agent resume [SESSION] [OPTIONS]
falsegreen-agent replace-session PREDECESSOR --candidate-sha256 SHA256 [OPTIONS]
falsegreen-agent inspect [SESSION] [--json]
falsegreen-agent doctor [--json] [--verify-cache] [--mcp-smoke]
falsegreen-agent login [--token KEY] [--json]
falsegreen-agent logout [--json]
falsegreen-agent status [--json]
falsegreen-agent task select TASK_ID
falsegreen-agent task show | clear
Normal path:
Run from the Git repository to be changed. The current directory is the workspace,
NeoHorse is the pinned managed default, FalseGreen is embedded, configured MCP
servers are discovered, and durable state is kept outside the repository. A paused
session resumes automatically when no new goal is supplied.
Common options:
--goal TEXT Goal instead of positional text
--task TASK_ID Select/bind the canonical FalseGreen task
--workspace PATH Advanced workspace override (default: current directory)
--state-dir PATH Advanced durable-state override
--json Machine-readable output and errors
--genui Add the trusted terminal surface to human output
--new-session Do not auto-resume a paused session
--candidate-sha256 SHA256 Exact candidate required for replacement creation
--mcp-config PATH MCP server configuration override
--no-mcp Disable configured MCP servers explicitly
--cache-dir PATH Managed runtime/model cache override
--runtime-backend BACKEND cpu, rocm, vulkan, or metal
Advanced provider overrides:
--endpoint URL --model-artifact PATH
--unqualified-model MODEL Explicitly select an unqualified external model
Exit 0 requires canonical Accepted plus completion authority. Accepted without
authority exits 3; a safe resumable pause exits 75. Model output, tests, and
candidate_ready can never mint acceptance."
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::{
agent_session_state_message, external_model_configuration, latest_paused_session,
session_falsegreen_task,
};
use falsegreen_agent::event::{EventKind, EventStore};
use falsegreen_agent::session::{Session, SessionState};
#[test]
fn unqualified_model_is_explicitly_marked() {
let capabilities = external_model_configuration(&BTreeMap::from([(
"unqualified-model".to_owned(),
"test-model".to_owned(),
)]))
.expect("configuration");
assert_eq!(capabilities.identifier, "test-model");
assert!(capabilities.qualification.is_none());
}
#[test]
fn selected_task_is_recovered_from_durable_session_history() {
let mut store = EventStore::open_memory().expect("store");
let session = Session::create(&mut store, "fix it").expect("session");
store
.append(
&session.id,
EventKind::Checkpoint,
&serde_json::json!({
"checkpoint_kind": "acceptance_authority",
"falsegreen_task_id": "task_abc"
}),
)
.expect("checkpoint");
assert_eq!(
session_falsegreen_task(&store, &session.id)
.expect("task")
.as_deref(),
Some("task_abc")
);
}
#[test]
fn automatic_resume_selects_only_a_paused_session() {
let mut store = EventStore::open_memory().expect("store");
let _active = Session::create(&mut store, "active").expect("active");
let mut paused = Session::create(&mut store, "paused").expect("paused");
paused
.transition(&mut store, SessionState::Paused, "test")
.expect("pause");
assert_eq!(
latest_paused_session(&store).expect("latest").as_deref(),
Some(paused.id.as_str())
);
}
#[test]
fn no_native_wording_reflects_every_agent_session_state() {
for (state, expected) in [
(SessionState::Initializing, "initializing"),
(SessionState::Working, "working"),
(SessionState::WaitingForTool, "waiting for a tool"),
(SessionState::CandidateReady, "candidate ready"),
(SessionState::Verifying, "verifying"),
(SessionState::Repairing, "repairing"),
(SessionState::Paused, "paused"),
(
SessionState::AcceptedAwaitingAuthority,
"accepted-awaiting-authority",
),
(SessionState::Completed, "completed"),
(SessionState::Failed, "failed"),
(SessionState::BudgetExhausted, "budget exhausted"),
] {
let message = agent_session_state_message(state);
assert!(message.contains(expected), "{state:?}: {message}");
if state != SessionState::Completed {
assert!(
!message.contains("session completed"),
"{state:?}: {message}"
);
}
}
}
}