use crate::engine::fast_path::{try_fast_path, FailureContext, FastPathResult};
use crate::engine::{
check_cli_available, durable_log_dir, launch_agent, load_config_or_default, parse_agent,
prepare_launch_prompt, write_prompt_log, AgentCapabilities, AgentConfig, Config,
ContextBreakdown, ContextSourceOverrides, LaunchPromptInput, LaunchTarget, ProcessConfig,
PromptComponents, SkillSyncOptions, StreamFormat, Surface,
};
use crate::lf::commands::util::{find_repo_root, launch_session};
use crate::lf::output::{format_context_header, format_reproducible_command, Colors};
use crate::lf::Cli;
use anyhow::{anyhow, Result};
use std::io::IsTerminal;
use std::path::PathBuf;
use std::time::Instant;
use tracing::{debug, info, instrument, trace, warn};
#[instrument(skip(cli), fields(step = ?step, has_message = message.is_some()))]
pub fn run(step: Option<&str>, message: Option<&str>, cli: &Cli) -> Result<()> {
let mut built = build_prompt(step, message, cli)?;
if let Some(ref cmd) = built.fast_path {
info!(cmd = cmd, "trying fast-path");
match try_fast_path(cmd, &built.repo_root) {
Ok(FastPathResult::Success) => {
info!("fast-path succeeded, skipping agent");
return Ok(());
}
Ok(FastPathResult::Failed {
exit_code,
stdout,
stderr,
}) => {
info!(exit_code, "fast-path failed, falling back to agent");
let ctx = FailureContext {
cmd,
exit_code,
stdout: &stdout,
stderr: &stderr,
};
built.agent_config.task_prompt = format!("{ctx}{}", built.agent_config.task_prompt);
}
Err(err) => {
info!(error = %err, "fast-path execution error, falling back to agent");
}
}
}
print_context_header(&built, cli);
launch_prompt(&built, cli)
}
struct PromptBuild {
repo_root: PathBuf,
config: Config,
agent_config: AgentConfig,
process: ProcessConfig,
capabilities: AgentCapabilities,
components: PromptComponents,
breakdown: ContextBreakdown,
prompt: String,
harness: String,
model: Option<String>,
step_name: Option<String>,
log_name: String,
fast_path: Option<String>,
}
fn build_prompt(step: Option<&str>, message: Option<&str>, cli: &Cli) -> Result<PromptBuild> {
let start = Instant::now();
let repo_root = find_repo_root()?;
debug!(elapsed_ms = start.elapsed().as_millis(), "found repo root");
let config_start = Instant::now();
let config = load_config_or_default(Some(&repo_root));
debug!(
elapsed_ms = config_start.elapsed().as_millis(),
"loaded config"
);
trace!(
agent = config.agent.as_deref().unwrap_or("claude:opus"),
?config.yolo,
"loaded config"
);
let discover_start = Instant::now();
let discovered_step = if let Some(step_name) = step {
Some(crate::lf::discovery::discover_step(&repo_root, step_name)?)
} else {
None
};
debug!(
elapsed_ms = discover_start.elapsed().as_millis(),
"discovered step"
);
if let Some(ref s) = discovered_step {
debug!(s.name, s.interactive, "discovered step");
}
let is_interactive = is_interactive_run(cli, &config, discovered_step.as_ref(), step, message);
info!("preparing launch prompt");
let prepare_start = Instant::now();
let surface = if cli.ide {
Surface::Ide
} else if is_interactive {
Surface::Cli
} else {
Surface::Headless
};
let prepared = prepare_launch_prompt(
&config,
LaunchPromptInput {
repo_root: repo_root.clone(),
step: step.map(|value| value.to_string()),
resolved_step: discovered_step.clone(),
surface,
directions: cli.direction.clone(),
docs: cli.docs.clone(),
wave: cli.wave.clone(),
message: message.map(|value| value.to_string()),
no_loopflow: cli.no_loopflow,
agent: cli.model.clone(),
cwd: Some(repo_root.clone()),
max_turns: None,
yolo_mode: cli.yolo || config.yolo,
include_config_directions: !cli.no_direction,
source_overrides: ContextSourceOverrides {
diff_files: cli.diff_files_setting(),
diff: cli.diff_setting(),
clipboard: if cli.clipboard { Some(true) } else { None },
},
summary: None,
client_context: Default::default(),
related_repos: Vec::new(),
},
)?;
debug!(
elapsed_ms = prepare_start.elapsed().as_millis(),
"prepared launch prompt"
);
let agent = prepared
.config
.agent
.clone()
.expect("prepare_launch_prompt always sets agent");
let (harness, model) = parse_agent(&agent);
let step_name = discovered_step
.as_ref()
.map(|step| step.name.clone())
.or_else(|| step.map(|value| value.to_string()));
let log_name = step_name
.as_deref()
.unwrap_or(if message.is_some() { "inline" } else { "chat" })
.to_string();
let process = ProcessConfig {
auto: !is_interactive,
stream: !is_interactive,
..Default::default()
};
let capabilities = AgentCapabilities {
chrome: cli.chrome_setting().unwrap_or(config.chrome),
};
let fast_path = discovered_step.as_ref().and_then(|s| s.fast_path.clone());
let mut agent_config = prepared.config;
let mut prompt = prepared.prompt;
if let Some(step_name) = step_name.as_deref() {
if should_launch_via_skill(step_name) {
let sync_start = Instant::now();
crate::engine::sync_skills(&repo_root, &SkillSyncOptions::default())?;
debug!(
elapsed_ms = sync_start.elapsed().as_millis(),
"synced vendor skills"
);
prompt = skill_launch_seed(
&harness,
surface,
step_name,
message,
prepared.components.operate,
&crate::engine::prompt::format_wave_context_sections(&prepared.components),
);
agent_config.system_prompt.clear();
agent_config.task_prompt = prompt.clone();
} else {
warn!(
step = step_name,
"external skill step uses assembled prompt fallback"
);
}
}
Ok(PromptBuild {
repo_root,
config,
agent_config,
process,
capabilities,
components: prepared.components,
breakdown: prepared.breakdown,
prompt,
harness,
model,
step_name,
log_name,
fast_path,
})
}
fn is_interactive_run(
cli: &Cli,
config: &Config,
discovered_step: Option<&crate::engine::Step>,
step: Option<&str>,
message: Option<&str>,
) -> bool {
cli.tui
|| cli.ide
|| cli.interactive
|| (!cli.batch
&& (discovered_step
.and_then(|step| step.interactive)
.unwrap_or(false)
|| step
.map(|step_name| config.interactive.contains(&step_name.to_string()))
.unwrap_or(false)
|| (step.is_none() && message.is_none())))
}
fn should_launch_via_skill(step_name: &str) -> bool {
!step_name.starts_with("npx/") && !step_name.starts_with("rams/")
}
fn skill_launch_seed(
harness: &str,
surface: Surface,
step_name: &str,
message: Option<&str>,
loopflow: bool,
wave_sections: &[String],
) -> String {
let sigil = if harness == "codex" { '$' } else { '/' };
let system_components = PromptComponents {
surface,
operate: loopflow,
..Default::default()
};
let system_sections = crate::engine::prompt::format_system_sections(&system_components);
let mut seed = format!("{sigil}{step_name}\n\n{}", system_sections.join("\n\n"));
for section in wave_sections {
seed.push_str("\n\n");
seed.push_str(section);
}
if let Some(message) = message.filter(|value| !value.trim().is_empty()) {
seed.push_str("\n\n<lf:message>\n");
seed.push_str(message);
seed.push_str("\n</lf:message>");
}
seed
}
fn print_context_header(built: &PromptBuild, cli: &Cli) {
let colors = Colors::new();
let header = format_context_header(&built.breakdown);
let direction_names: Vec<String> = built
.components
.directions
.iter()
.map(|d| d.name.clone())
.collect();
let cli_model = if cli.model.is_some() {
built.agent_config.agent.as_deref()
} else {
None
};
let command = format_reproducible_command(
built.step_name.as_deref(),
&direction_names,
built.components.wave.as_deref(),
&cli.docs,
cli.clipboard,
cli.no_loopflow,
cli_model,
);
eprintln!(
"{dim}{header}\n\n {command}{reset}",
dim = colors.dim,
header = header,
command = command,
reset = colors.reset,
);
}
fn launch_prompt(built: &PromptBuild, cli: &Cli) -> Result<()> {
let forced_target = if cli.ide {
Some(LaunchTarget::Ide)
} else if cli.tui {
Some(LaunchTarget::Tui)
} else {
None
};
if forced_target.is_some() || !built.process.auto {
info!("launching interactive vendor session");
launch_session(
forced_target.unwrap_or(built.config.session.launch),
&built.harness,
built.model.as_deref(),
&built.repo_root,
&built.prompt,
)?;
return Ok(());
}
let cli_check_start = Instant::now();
if !check_cli_available(&built.harness) {
return Err(anyhow!(
"'{}' CLI not found. Run `lf op doctor` to check dependencies.",
built.harness
));
}
debug!(
elapsed_ms = cli_check_start.elapsed().as_millis(),
"checked cli availability"
);
let write_prompt_start = Instant::now();
write_prompt_log(&built.repo_root, &built.prompt, &built.log_name, None)?;
debug!(
elapsed_ms = write_prompt_start.elapsed().as_millis(),
"wrote prompt log"
);
let context_file_start = Instant::now();
let context_file = if built.agent_config.system_prompt.trim().is_empty() {
None
} else {
Some(write_prompt_log(
&built.repo_root,
&built.agent_config.system_prompt,
&format!("{}.context", built.log_name),
None,
)?)
};
debug!(
elapsed_ms = context_file_start.elapsed().as_millis(),
"wrote context log"
);
let use_color = std::env::var("NO_COLOR").is_err() && std::io::stderr().is_terminal();
let mut process = built.process.clone();
process.context_file = context_file;
process.stream_format = StreamFormat::Human(use_color);
let directive_file = std::env::var("LOOPFLOW_DIRECTIVE_FILE").ok();
let mut agent_config = built.agent_config.clone();
let relay_path = directive_file.as_ref().and_then(|_| {
tempfile::NamedTempFile::new()
.ok()
.map(|f| f.into_temp_path().to_path_buf())
});
if let Some(ref path) = relay_path {
agent_config.directive_relay = Some(path.clone());
}
debug!(launch = ?agent_config, ?process, ?built.capabilities, "launching agent");
info!(harness = built.harness, "launching agent");
let launch_start = Instant::now();
let result = launch_agent(&agent_config, &process, &built.capabilities);
if let (Some(relay), Some(ref target)) = (relay_path, directive_file) {
relay_directives(&relay, target);
}
let result = result?;
debug!(
elapsed_ms = launch_start.elapsed().as_millis(),
"agent finished"
);
debug!(exit_code = result.exit_code, "agent completed");
if result.exit_code == 0 {
Ok(())
} else {
let log_hint = durable_log_dir(&built.repo_root)
.map(|p| p.display().to_string())
.unwrap_or_else(|| "~/.lf/logs/".to_string());
Err(anyhow!(
"agent exited with code {}. Check {} for details.",
result.exit_code,
log_hint
))
}
}
fn relay_directives(relay: &std::path::Path, target: &str) {
let content = match std::fs::read_to_string(relay) {
Ok(c) => c,
Err(_) => return,
};
let _ = std::fs::remove_file(relay);
let safe_lines: Vec<&str> = content
.lines()
.filter(|line| line.starts_with("cd "))
.collect();
if safe_lines.is_empty() {
return;
}
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(target)
{
use std::io::Write;
for line in safe_lines {
let _ = writeln!(file, "{}", line);
}
}
}
pub fn split_step_args(args: &[String]) -> Result<(String, Vec<String>)> {
let first = args.first().ok_or_else(|| anyhow!("no step specified"))?;
let mut step = first.clone();
let step_args = args.iter().skip(1).cloned().collect::<Vec<_>>();
if let Some(stripped) = step.strip_suffix(':') {
step = stripped.to_string();
}
if step.is_empty() {
return Err(anyhow!("no step specified"));
}
Ok((step, step_args))
}
#[cfg(test)]
mod tests {
use super::{is_interactive_run, should_launch_via_skill, skill_launch_seed, split_step_args};
use crate::engine::{Config, Step, Surface};
use crate::lf::Cli;
use clap::Parser;
#[test]
fn forced_session_handoff_counts_as_interactive() {
let cli = Cli::parse_from(["lf", "--ide", "gate"]);
let config = Config::default();
assert!(is_interactive_run(&cli, &config, None, Some("gate"), None));
}
#[test]
fn interactive_step_counts_as_interactive_without_force_flag() {
let cli = Cli::parse_from(["lf", "design"]);
let config = Config::default();
let mut step = Step::named("design");
step.interactive = Some(true);
assert!(is_interactive_run(
&cli,
&config,
Some(&step),
Some("design"),
None
));
}
#[test]
fn skill_launch_seed_starts_with_slash_step_and_message() {
let seed = skill_launch_seed(
"claude",
Surface::Cli,
"implement",
Some("build auth"),
false,
&[],
);
assert!(seed.starts_with("/implement\n\n"));
assert!(!seed.contains("<lf:orientation>"));
assert!(seed.contains("<lf:message>\nbuild auth\n</lf:message>"));
}
#[test]
fn skill_launch_seed_uses_dollar_sigil_for_codex() {
let seed = skill_launch_seed("codex", Surface::Cli, "gate", None, false, &[]);
assert!(seed.starts_with("$gate\n\n"));
}
#[test]
fn skill_launch_seed_interactive_surfaces_have_no_preamble() {
for surface in [Surface::Cli, Surface::Ide, Surface::ConcertoMac] {
let seed = skill_launch_seed("claude", surface, "gate", None, false, &[]);
assert!(seed.starts_with("/gate\n\n"));
assert!(!seed.contains("Run mode"), "surface {surface:?}");
}
}
#[test]
fn skill_launch_seed_omits_message_when_absent() {
let seed = skill_launch_seed("claude", Surface::Cli, "gate", None, false, &[]);
assert!(!seed.contains("<lf:message>"));
assert!(!seed.contains("<lf:orientation>"));
}
#[test]
fn skill_launch_seed_headless_includes_preamble() {
let seed = skill_launch_seed("claude", Surface::Headless, "implement", None, false, &[]);
assert!(seed.contains("Run mode is headless"));
}
#[test]
fn skill_launch_seed_omits_loopflow_when_disabled() {
let seed = skill_launch_seed("claude", Surface::Headless, "implement", None, false, &[]);
assert!(!seed.contains("<lf:loopflow>"));
assert!(!seed.contains("lf op commit"));
}
#[test]
fn skill_launch_seed_includes_loopflow_when_enabled() {
let seed = skill_launch_seed("claude", Surface::Headless, "implement", None, true, &[]);
assert!(seed.contains("<lf:loopflow>"));
assert!(seed.contains("lf op commit"));
assert!(seed.contains("</lf:loopflow>"));
assert_eq!(
seed.matches("<lf:loopflow>").count(),
1,
"the skill seed carries the loopflow operating document once"
);
assert!(seed.contains("lf chat --parent"));
assert!(seed.contains("lf memory add"));
}
#[test]
fn skill_launch_seed_carries_ambient_wave_sections_before_the_message() {
let sections = vec![
"<lf:wave-memory>\n- prefer small PRs\n</lf:wave-memory>".to_string(),
"<lf:wave-chat-recent>\nuser: status?\n</lf:wave-chat-recent>".to_string(),
];
let seed = skill_launch_seed(
"claude",
Surface::Headless,
"implement",
Some("build auth"),
false,
§ions,
);
let memory_pos = seed.find("<lf:wave-memory>").unwrap();
let chat_pos = seed.find("<lf:wave-chat-recent>").unwrap();
let message_pos = seed.find("<lf:message>").unwrap();
assert!(memory_pos < chat_pos);
assert!(chat_pos < message_pos);
}
#[test]
fn external_skill_steps_keep_assembled_prompt_fallback() {
assert!(!should_launch_via_skill("npx/vercel-labs/deep-research"));
assert!(!should_launch_via_skill("rams/rams"));
assert!(should_launch_via_skill("implement"));
}
#[test]
fn split_step_args_handles_trailing_colon() {
let args = vec![
"implement:".to_string(),
"add".to_string(),
"logs".to_string(),
];
let (step, rest) = split_step_args(&args).expect("split args");
assert_eq!(step, "implement");
assert_eq!(rest, vec!["add".to_string(), "logs".to_string()]);
}
#[test]
fn split_step_args_preserves_namespaced_step() {
let args = vec!["npx/explain-code".to_string()];
let (step, rest) = split_step_args(&args).expect("split args");
assert_eq!(step, "npx/explain-code");
assert!(rest.is_empty());
}
#[test]
fn split_step_args_preserves_namespaced_step_with_args() {
let args = vec!["gstack/office-hours".to_string(), "auth flow".to_string()];
let (step, rest) = split_step_args(&args).expect("split args");
assert_eq!(step, "gstack/office-hours");
assert_eq!(rest, vec!["auth flow".to_string()]);
}
}