use std::io::IsTerminal;
use std::path::Path;
use std::process::Command as StdCommand;
use std::time::SystemTime;
use clap::Parser;
use dialoguer::Confirm;
use git_paw::agents;
use git_paw::broker;
use git_paw::broker::messages::BrokerMessage;
#[cfg(test)]
use git_paw::broker::publish::build_status_message;
use git_paw::broker::publish::publish_to_broker_http;
use git_paw::cli::{Cli, Command, SpecsFormat};
use git_paw::config::{self, PawConfig, SupervisorConfig};
use git_paw::detect;
use git_paw::error::PawError;
use git_paw::git;
use git_paw::interactive;
use git_paw::session::{self, Session, SessionMode, SessionStatus, WorktreeEntry};
use git_paw::tmux;
fn main() {
let args = Cli::parse();
let command = args.command.unwrap_or(Command::Start {
cli: None,
branches: None,
from_all_specs: false,
specs: None,
specs_format: None,
dry_run: false,
preset: None,
supervisor: false,
no_supervisor: false,
force: false,
no_rebase: false,
});
if let Err(err) = run(command) {
err.exit();
}
}
fn run(command: Command) -> Result<(), PawError> {
match command {
Command::Start {
cli: cli_flag,
branches: branches_flag,
from_all_specs,
specs,
specs_format,
dry_run,
preset,
supervisor,
no_supervisor,
force,
no_rebase,
} => {
let supervisor_enabled =
resolve_supervisor_mode_from_cwd(no_supervisor, supervisor, dry_run)?;
let spec_mode = SpecMode::from_flags(from_all_specs, specs.as_deref());
let specs_format_str = specs_format.map(SpecsFormat::as_str);
match resolve_dispatch_target(&spec_mode, supervisor_enabled) {
DispatchTarget::Supervisor { spec_mode } => {
let cwd = std::env::current_dir().map_err(|e| {
PawError::SessionError(format!("cannot read current directory: {e}"))
})?;
let repo_root = git::validate_repo(&cwd)?;
let config = config::load_config(&repo_root, None)?;
cmd_supervisor(
&repo_root,
&config,
cli_flag.as_deref(),
branches_flag.as_deref(),
&spec_mode,
specs_format_str,
dry_run,
no_rebase,
)
}
DispatchTarget::StartWithSpecs(mode) => cmd_start_with_specs(
cli_flag.as_deref(),
&mode,
specs_format_str,
dry_run,
force,
no_rebase,
),
DispatchTarget::Start => cmd_start(
cli_flag,
branches_flag,
dry_run,
preset.as_deref(),
no_supervisor,
no_rebase,
),
}
}
Command::Add {
branch,
cli,
from_spec,
} => cmd_add(branch.as_deref(), cli.as_deref(), from_spec.as_deref()),
Command::Remove {
branch,
keep_worktree,
force,
} => cmd_remove(&branch, keep_worktree, force),
Command::Pause => cmd_pause(),
Command::Stop { force } => cmd_stop(force),
Command::Purge { force, stale } => cmd_purge(force, stale),
Command::Status { json } => cmd_status(json),
Command::ListClis => cmd_list_clis(),
Command::AddCli {
name,
command,
display_name,
} => cmd_add_cli(&name, &command, display_name.as_deref()),
Command::RemoveCli { name } => cmd_remove_cli(&name),
Command::Dashboard => cmd_dashboard(),
Command::Init => git_paw::init::run_init(),
Command::Replay {
branch,
list,
color,
session,
} => cmd_replay(branch, list, color, session.as_deref()),
Command::Approvals {
session,
limit,
json,
} => cmd_approvals(session.as_deref(), limit, json),
Command::Mcp { repo, log_file } => {
git_paw::mcp::cmd_mcp(repo.as_deref(), log_file.as_deref())
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SpecMode {
None,
All,
Picker,
Narrow(Vec<String>),
}
impl SpecMode {
pub fn from_flags(from_all_specs: bool, specs: Option<&[String]>) -> Self {
if from_all_specs {
return Self::All;
}
match specs {
None => Self::None,
Some([]) => Self::Picker,
Some(values) => Self::Narrow(values.to_vec()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum DispatchTarget {
Supervisor { spec_mode: SpecMode },
StartWithSpecs(SpecMode),
Start,
}
fn resolve_dispatch_target(spec_mode: &SpecMode, supervisor_enabled: bool) -> DispatchTarget {
match (supervisor_enabled, spec_mode) {
(true, mode) => DispatchTarget::Supervisor {
spec_mode: mode.clone(),
},
(false, SpecMode::All | SpecMode::Picker | SpecMode::Narrow(_)) => {
DispatchTarget::StartWithSpecs(spec_mode.clone())
}
(false, SpecMode::None) => DispatchTarget::Start,
}
}
fn apply_spec_mode(
mode: &SpecMode,
discovered: Vec<git_paw::specs::SpecEntry>,
prompter: &dyn interactive::Prompter,
) -> Result<Vec<git_paw::specs::SpecEntry>, PawError> {
match mode {
SpecMode::None | SpecMode::All => Ok(discovered),
SpecMode::Picker => {
if !is_interactive_stdin() {
return Err(PawError::SpecError(
"--specs without values requires an interactive terminal\n \
Use `--specs NAME[,NAME...]` to narrow explicitly, or\n \
`--from-all-specs` to launch every discovered spec."
.to_string(),
));
}
prompter.select_specs(&discovered)
}
SpecMode::Narrow(names) => git_paw::specs::resolve::resolve_specs(&discovered, names),
}
}
fn is_interactive_stdin() -> bool {
std::io::stdin().is_terminal()
}
fn attach_or_print_hint(session_name: &str) -> Result<(), PawError> {
if is_interactive_stdin() {
tmux::attach(session_name)
} else {
println!("Session '{session_name}' started in detached mode.");
println!("Attach with: tmux attach -t {session_name}");
Ok(())
}
}
pub(crate) fn build_task_prompt(spec_entry: Option<&git_paw::specs::SpecEntry>) -> String {
use git_paw::specs::SpecBackendKind;
match spec_entry {
Some(s) => match s.backend {
SpecBackendKind::OpenSpec => format!("/opsx:apply {id}", id = s.id),
SpecBackendKind::Markdown | SpecBackendKind::SpecKit => format!(
"Begin your assigned task. The full spec is in AGENTS.md in this worktree. \
Additional artifacts (proposal, design, specs, tasks) live under \
openspec/changes/{id}/ — read them all before starting.",
id = s.id,
),
},
None => "Begin your assigned task as described in AGENTS.md.".to_string(),
}
}
fn resolve_supervisor_mode_from_cwd(
no_supervisor_flag: bool,
supervisor_flag: bool,
dry_run: bool,
) -> Result<bool, PawError> {
if no_supervisor_flag {
return Ok(false);
}
if supervisor_flag {
return Ok(true);
}
let Ok(cwd) = std::env::current_dir() else {
return Ok(false);
};
let Ok(repo_root) = git::validate_repo(&cwd) else {
return Ok(false);
};
let config = config::load_config(&repo_root, None).unwrap_or_default();
resolve_supervisor_mode(
no_supervisor_flag,
supervisor_flag,
dry_run,
&config,
&mut TerminalSupervisorPrompt,
)
}
trait SupervisorPrompt {
fn ask(&mut self) -> Result<bool, PawError>;
}
struct TerminalSupervisorPrompt;
impl SupervisorPrompt for TerminalSupervisorPrompt {
fn ask(&mut self) -> Result<bool, PawError> {
if !std::io::stdin().is_terminal() {
return Ok(false);
}
Confirm::new()
.with_prompt("Start in supervisor mode?")
.default(false)
.interact()
.map_err(|e| PawError::SessionError(format!("supervisor prompt failed: {e}")))
}
}
fn resolve_supervisor_mode(
no_supervisor_flag: bool,
supervisor_flag: bool,
dry_run: bool,
config: &PawConfig,
prompt: &mut dyn SupervisorPrompt,
) -> Result<bool, PawError> {
if no_supervisor_flag {
return Ok(false);
}
if supervisor_flag {
return Ok(true);
}
if let Some(cfg) = &config.supervisor {
return Ok(cfg.enabled);
}
if dry_run {
return Ok(false);
}
prompt.ask()
}
fn config_to_custom_defs(config: &PawConfig) -> Vec<detect::CustomCliDef> {
config
.clis
.iter()
.map(|(name, cli)| detect::CustomCliDef {
name: name.clone(),
command: cli.command.clone(),
display_name: cli.display_name.clone(),
})
.collect()
}
fn to_interactive_cli(cli: &detect::CliInfo) -> interactive::CliInfo {
interactive::CliInfo {
display_name: cli.display_name.clone(),
binary_name: cli.binary_name.clone(),
}
}
#[allow(clippy::too_many_lines)]
fn cmd_start(
cli_flag: Option<String>,
branches_flag: Option<Vec<String>>,
dry_run: bool,
preset: Option<&str>,
no_supervisor: bool,
no_rebase: bool,
) -> Result<(), PawError> {
let cwd = std::env::current_dir()
.map_err(|e| PawError::SessionError(format!("cannot read current directory: {e}")))?;
let repo_root = git::validate_repo(&cwd)?;
let existing_session = session::find_session_for_repo(&repo_root)?;
if !dry_run
&& let Some(existing) = &existing_session
&& !invalidate_if_stale(&repo_root, existing)?
{
let effective =
existing.effective_status(|name| tmux::is_session_alive(name).unwrap_or(false));
match effective {
SessionStatus::Paused => {
println!(
"Restarting paused session '{}' (broker + reattach)...",
existing.session_name
);
return restart_from_pause(&repo_root, existing);
}
SessionStatus::Active => {
println!("Reattaching to session '{}'...", existing.session_name);
return attach_or_print_hint(&existing.session_name);
}
SessionStatus::Stopped => {
println!("Recovering session '{}'...", existing.session_name);
return recover_session(&repo_root, existing);
}
}
}
tmux::ensure_tmux_installed()?;
let config = config::load_config(&repo_root, None)?;
if !no_supervisor && config.supervisor.as_ref().is_some_and(|s| s.enabled) {
return cmd_supervisor(
&repo_root,
&config,
cli_flag.as_deref(),
branches_flag.as_deref(),
&SpecMode::None,
None,
dry_run,
no_rebase,
);
}
let custom_defs = config_to_custom_defs(&config);
let (resolved_cli, resolved_branches) = if let Some(preset_name) = preset {
let p = config
.get_preset(preset_name)
.ok_or_else(|| PawError::ConfigError(format!("preset '{preset_name}' not found")))?;
(Some(p.cli.clone()), Some(p.branches.clone()))
} else {
(cli_flag, branches_flag)
};
let detected = detect::detect_clis(&custom_defs);
if detected.is_empty() {
return Err(PawError::NoCLIsFound);
}
let all_branches = git::list_branches(&repo_root)?;
let interactive_clis: Vec<interactive::CliInfo> =
detected.iter().map(to_interactive_cli).collect();
let prompter = interactive::TerminalPrompter;
let selection = interactive::run_selection(
&prompter,
&all_branches,
&interactive_clis,
resolved_cli.as_deref(),
resolved_branches.as_deref(),
)?;
let project = git::project_name(&repo_root);
let mouse = config.mouse.unwrap_or(true);
let session_name = tmux::resolve_session_name(&project)?;
if dry_run {
if let Some(ref existing) = existing_session {
eprintln!(
"warning: session '{}' already exists — purge it before starting a new one\n",
existing.session_name
);
}
println!("Dry run — session plan:\n");
println!(" Session: {session_name}");
println!(" Mouse: {}", if mouse { "on" } else { "off" });
println!();
for (branch, cli) in &selection.mappings {
let wt_dir = git::worktree_dir_name(&project, branch);
println!(" {branch} \u{2192} {cli} (../{wt_dir})");
}
return Ok(());
}
git::prune_worktrees(&repo_root)?;
let broker_config = config.broker.clone();
let mut builder = tmux::TmuxSessionBuilder::new(&project)
.session_name(session_name)
.mouse_mode(mouse)
.border_affordances(config.border_affordances_enabled());
if broker_config.enabled {
let repo_str = repo_root.to_string_lossy().to_string();
builder = builder.add_pane(tmux::PaneSpec {
branch: "dashboard".to_string(),
worktree: repo_str,
cli_command: format!(
"{} __dashboard",
std::env::current_exe()
.unwrap_or_else(|_| std::path::PathBuf::from("git-paw"))
.display()
),
});
builder = builder.set_environment("GIT_PAW_BROKER_URL", &broker_config.url());
}
let mut worktree_entries = Vec::new();
let skill_content = if broker_config.enabled {
let template = git_paw::skills::resolve("coordination")?;
Some(template)
} else {
None
};
for (branch, cli) in &selection.mappings {
let wt = git::create_worktree(&repo_root, branch, !no_rebase)?;
let wt_str = wt.path.to_string_lossy().to_string();
let rendered_skill = skill_content.as_ref().map(|tmpl| {
git_paw::skills::render(
tmpl,
branch,
&broker_config.url(),
&project,
&git_paw::skills::GateCommands::default(),
&[],
)
});
let assignment = git_paw::agents::WorktreeAssignment {
branch: branch.clone(),
cli: cli.clone(),
spec_content: None,
owned_files: None,
skill_content: rendered_skill,
inter_agent_rules: None,
};
git_paw::agents::setup_worktree_agents_md(&repo_root, &wt.path, &assignment)?;
if broker_config.enabled {
let agent_id = git_paw::broker::messages::slugify_branch(branch);
let strict_guard = config
.supervisor
.as_ref()
.is_none_or(SupervisorConfig::strict_branch_guard);
git_paw::agents::install_git_hooks(
&wt.path,
&broker_config.url(),
&agent_id,
branch,
strict_guard,
)?;
}
builder = builder.add_pane(tmux::PaneSpec {
branch: branch.clone(),
worktree: wt_str,
cli_command: cli.clone(),
});
worktree_entries.push(WorktreeEntry {
branch: branch.clone(),
worktree_path: wt.path,
cli: cli.clone(),
branch_created: wt.branch_created,
pending_boot_prompt: None,
});
}
let tmux_session = builder.build()?;
tmux_session.execute()?;
if broker_config.enabled {
for (idx, (branch, _)) in selection.mappings.iter().enumerate() {
let pane_idx = if broker_config.enabled { idx + 1 } else { idx };
let boot_block = git_paw::skills::build_boot_block(branch, &broker_config.url());
let args =
git_paw::tmux::build_boot_inject_args(&tmux_session.name, pane_idx, &boot_block);
let _ = std::process::Command::new("tmux").args(&args).status();
}
}
let mut state = Session {
session_name: tmux_session.name.clone(),
repo_path: repo_root,
project_name: project,
created_at: SystemTime::now(),
status: SessionStatus::Active,
worktrees: worktree_entries,
broker_port: None,
broker_bind: None,
broker_log_path: None,
mode: SessionMode::Bare,
dashboard_pane: None,
};
if broker_config.enabled {
state.broker_port = Some(broker_config.port);
state.broker_bind = Some(broker_config.bind.clone());
state.broker_log_path = Some(session::session_state_dir()?.join("broker.log"));
state.dashboard_pane = Some(0);
}
session::save_session(&state)?;
let pane_offset = usize::from(broker_config.enabled);
write_repo_discovery_file(
&state.repo_path,
&tmux_session.name,
&state.worktrees,
pane_offset,
);
attach_or_print_hint(&tmux_session.name)
}
#[cfg_attr(not(test), expect(dead_code))]
fn publish_supervisor_question(question: &str, broker_url: &str) -> Result<(), PawError> {
let msg = BrokerMessage::Question {
agent_id: "supervisor".to_string(),
payload: git_paw::broker::messages::QuestionPayload {
question: question.to_string(),
},
};
publish_to_broker_http(broker_url, &msg)
}
fn spawn_auto_approve_thread(
session_name: String,
broker_url: String,
config: Option<config::AutoApproveConfig>,
pane_map: std::collections::HashMap<String, usize>,
worktree_map: std::collections::HashMap<String, std::path::PathBuf>,
recorder: git_paw::supervisor::manual_approvals::ManualDecisionRecorder,
) -> Option<(
std::sync::Arc<std::sync::atomic::AtomicBool>,
std::thread::JoinHandle<()>,
)> {
let cfg = config?.resolved();
if !cfg.enabled {
return None;
}
let period = std::time::Duration::from_secs(
cfg.stall_threshold_seconds
.max(git_paw::config::AutoApproveConfig::MIN_STALL_THRESHOLD_SECONDS),
);
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let stop_clone = stop.clone();
let handle = std::thread::spawn(move || {
use git_paw::supervisor::approve::TmuxKeyDispatcher;
use git_paw::supervisor::poll::{
PollContext, TmuxPaneInspector, fetch_status_over_http, tick_from_status,
};
struct BrokerForwarder {
broker_url: String,
recorder: git_paw::supervisor::manual_approvals::ManualDecisionRecorder,
}
impl git_paw::supervisor::poll::QuestionForwarder for BrokerForwarder {
fn forward_question(
&mut self,
agent_id: &str,
kind: git_paw::supervisor::permission_prompt::PermissionType,
captured: &str,
) {
let question = format!(
"{agent_id} is stalled on a permission prompt classified as {kind:?}; \
please review the pane and decide manually."
);
let msg = git_paw::broker::messages::BrokerMessage::Question {
agent_id: "supervisor".to_string(),
payload: git_paw::broker::messages::QuestionPayload { question },
};
if let Err(e) =
git_paw::broker::publish::publish_to_broker_http(&self.broker_url, &msg)
{
eprintln!("auto-approve: failed to forward question to dashboard: {e}");
}
if let Some(learning) = self.recorder.record_forwarded(agent_id, captured)
&& let Err(e) = git_paw::broker::publish::publish_to_broker_http(
&self.broker_url,
&learning,
)
{
eprintln!("auto-approve: failed to publish permission_pattern learning: {e}");
}
}
}
let inspector = TmuxPaneInspector;
let resolver = move |id: &str| pane_map.get(id).copied();
let worktree_resolver = move |id: &str| worktree_map.get(id).cloned();
let mut dispatcher = TmuxKeyDispatcher;
let mut forwarder = BrokerForwarder {
broker_url: broker_url.clone(),
recorder,
};
while !stop_clone.load(std::sync::atomic::Ordering::Relaxed) {
std::thread::sleep(period);
if stop_clone.load(std::sync::atomic::Ordering::Relaxed) {
break;
}
let rows = match fetch_status_over_http(&broker_url) {
Ok(rows) => rows,
Err(e) => {
eprintln!("auto-approve: broker /status fetch failed: {e}");
continue;
}
};
let mut ctx = PollContext {
state: None,
session: &session_name,
config: &cfg,
resolver: &resolver,
inspector: &inspector,
dispatcher: &mut dispatcher,
forwarder: &mut forwarder,
worktree_resolver: &worktree_resolver,
broker_url: Some(&broker_url),
};
let _ = tick_from_status(&rows, &mut ctx);
}
});
Some((stop, handle))
}
struct AttachContext<'a> {
repo_root: &'a Path,
project: &'a str,
broker_config: &'a git_paw::config::BrokerConfig,
agent_cli: &'a str,
agent_flags: &'a str,
coordination_template: Option<&'a git_paw::skills::SkillTemplate>,
gate_commands: &'a git_paw::skills::GateCommands<'a>,
session_backends: &'a [git_paw::specs::SpecBackendKind],
inter_agent_rules: Option<&'a str>,
strict_guard: bool,
no_rebase: bool,
}
struct AttachedAgent {
pane: tmux::PaneSpec,
prompt: String,
entry: WorktreeEntry,
}
fn attach_agent(
ctx: &AttachContext,
branch: &str,
spec_entry: Option<&git_paw::specs::SpecEntry>,
) -> Result<AttachedAgent, PawError> {
let wt = git::create_worktree(ctx.repo_root, branch, !ctx.no_rebase)?;
let wt_str = wt.path.to_string_lossy().to_string();
let rendered_skill = ctx.coordination_template.map(|tmpl| {
git_paw::skills::render(
tmpl,
branch,
&ctx.broker_config.url(),
ctx.project,
ctx.gate_commands,
ctx.session_backends,
)
});
let spec_content = spec_entry.map(|s| s.prompt.clone());
let owned_files = spec_entry.and_then(|s| s.owned_files.clone());
let assignment = git_paw::agents::WorktreeAssignment {
branch: branch.to_string(),
cli: ctx.agent_cli.to_string(),
spec_content,
owned_files,
skill_content: rendered_skill,
inter_agent_rules: ctx.inter_agent_rules.map(str::to_string),
};
git_paw::agents::setup_worktree_agents_md(ctx.repo_root, &wt.path, &assignment)?;
if ctx.broker_config.enabled {
let agent_id = git_paw::broker::messages::slugify_branch(branch);
git_paw::agents::install_git_hooks(
&wt.path,
&ctx.broker_config.url(),
&agent_id,
branch,
ctx.strict_guard,
)?;
}
let cli_command = if ctx.agent_flags.is_empty() {
ctx.agent_cli.to_string()
} else {
format!("{} {}", ctx.agent_cli, ctx.agent_flags)
};
let boot_block = git_paw::skills::build_boot_block(branch, &ctx.broker_config.url());
let task_prompt = build_task_prompt(spec_entry);
Ok(AttachedAgent {
pane: tmux::PaneSpec {
branch: branch.to_string(),
worktree: wt_str,
cli_command,
},
prompt: format!("{boot_block}\n\n{task_prompt}"),
entry: WorktreeEntry {
branch: branch.to_string(),
worktree_path: wt.path,
cli: ctx.agent_cli.to_string(),
branch_created: wt.branch_created,
pending_boot_prompt: None,
},
})
}
#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
fn cmd_supervisor(
repo_root: &Path,
config: &PawConfig,
cli_flag: Option<&str>,
branches_flag: Option<&[String]>,
spec_mode: &SpecMode,
specs_format_override: Option<&str>,
dry_run: bool,
no_rebase: bool,
) -> Result<(), PawError> {
let default_supervisor_cfg = SupervisorConfig::default();
let supervisor_cfg = config
.supervisor
.as_ref()
.unwrap_or(&default_supervisor_cfg);
let supervisor_cli = supervisor_cfg
.cli
.clone()
.or_else(|| config.default_cli.clone())
.ok_or_else(|| {
PawError::ConfigError(
"supervisor mode requires either [supervisor].cli or default_cli to be set"
.to_string(),
)
})?;
let agent_cli = cli_flag
.map(ToString::to_string)
.or_else(|| config.default_cli.clone())
.unwrap_or_else(|| supervisor_cli.clone());
let mut spec_by_branch: std::collections::HashMap<String, git_paw::specs::SpecEntry> =
std::collections::HashMap::new();
let branches: Vec<String> = if let Some(bs) = branches_flag {
bs.to_vec()
} else if matches!(spec_mode, SpecMode::None) {
let custom_defs = config_to_custom_defs(config);
let detected = detect::detect_clis(&custom_defs);
if detected.is_empty() {
return Err(PawError::NoCLIsFound);
}
let all_branches = git::list_branches(repo_root)?;
let interactive_clis: Vec<interactive::CliInfo> =
detected.iter().map(to_interactive_cli).collect();
let prompter = interactive::TerminalPrompter;
let selection = interactive::run_selection(
&prompter,
&all_branches,
&interactive_clis,
cli_flag,
None,
)?;
selection.mappings.into_iter().map(|(b, _)| b).collect()
} else {
let discovered =
git_paw::specs::scan_specs_with_override(config, repo_root, specs_format_override)?;
if discovered.is_empty() {
return Err(PawError::ConfigError(
"supervisor mode found no branches: pass --branches or define specs".to_string(),
));
}
let specs = apply_spec_mode(spec_mode, discovered, &interactive::TerminalPrompter)?;
if specs.is_empty() {
return Err(PawError::ConfigError(
"supervisor mode found no branches: pass --branches or define specs".to_string(),
));
}
let mut out = Vec::with_capacity(specs.len());
for spec in specs {
out.push(spec.branch.clone());
spec_by_branch.insert(spec.branch.clone(), spec);
}
out
};
let project = git::project_name(repo_root);
let session_name = tmux::resolve_session_name(&project)?;
let mouse = config.mouse.unwrap_or(true);
let broker_config = config.broker.clone();
let approval = &supervisor_cfg.agent_approval;
let agent_flags = config::approval_flags(&agent_cli, approval);
let supervisor_flags = config::approval_flags(&supervisor_cli, approval);
if dry_run {
println!("Dry run — supervisor session plan:\n");
println!(" Session: {session_name}");
println!(" Supervisor: {supervisor_cli}");
println!(" Agent CLI: {agent_cli}");
println!(" Approval: {approval:?}");
println!(" Mouse: {}", if mouse { "on" } else { "off" });
if broker_config.enabled {
println!(" Broker URL: {}", broker_config.url());
}
println!();
for branch in &branches {
let wt_dir = git::worktree_dir_name(&project, branch);
let cmd = if agent_flags.is_empty() {
agent_cli.clone()
} else {
format!("{agent_cli} {agent_flags}")
};
println!(" {branch} \u{2192} {cmd} (../{wt_dir})");
}
return Ok(());
}
let layout = git_paw::supervisor::layout::supervisor_layout(branches.len())?;
git::prune_worktrees(repo_root)?;
if broker_config.enabled {
let claude_settings = repo_root.join(".claude").join("settings.json");
if let Err(e) = git_paw::supervisor::curl_allowlist::setup_curl_allowlist(
&broker_config.url(),
&claude_settings,
) {
eprintln!("warning: failed to setup curl allowlist: {e}");
}
for cli in session_cli_settings_paths(config, &supervisor_cli, &agent_cli) {
if let Err(e) = git_paw::supervisor::curl_allowlist::setup_curl_allowlist(
&broker_config.url(),
&cli,
) {
eprintln!(
"warning: failed to setup curl allowlist at {}: {e}",
cli.display()
);
}
}
}
if supervisor_cfg.common_dev_allowlist.enabled {
for (path, err) in git_paw::supervisor::dev_allowlist::seed_supervisor_session(
&supervisor_cfg.common_dev_allowlist.extra,
repo_root,
&configured_settings_paths(config),
) {
eprintln!(
"warning: failed to seed dev allowlist into {}: {err}",
path.display(),
);
}
}
let session_backends: Vec<git_paw::specs::SpecBackendKind> = {
let mut seen: Vec<git_paw::specs::SpecBackendKind> = Vec::new();
for entry in spec_by_branch.values() {
if !seen.contains(&entry.backend) {
seen.push(entry.backend);
}
}
seen
};
let supervisor_skill_template = git_paw::skills::resolve("supervisor")?;
let supervisor_md = git_paw::skills::render(
&supervisor_skill_template,
"supervisor",
&broker_config.url(),
&project,
&supervisor_cfg.gate_commands(),
&session_backends,
);
let supervisor_assignment = git_paw::agents::WorktreeAssignment {
branch: "supervisor".to_string(),
cli: supervisor_cli.clone(),
spec_content: None,
owned_files: None,
skill_content: Some(supervisor_md),
inter_agent_rules: None,
};
git_paw::agents::setup_worktree_agents_md(repo_root, repo_root, &supervisor_assignment)?;
let coordination_template = if broker_config.enabled {
Some(git_paw::skills::resolve("coordination")?)
} else {
None
};
let branch_refs: Vec<&str> = branches.iter().map(String::as_str).collect();
let inter_agent_rules = git_paw::agents::build_inter_agent_rules(&branch_refs);
let repo_str = repo_root.to_string_lossy().to_string();
let dashboard_command = format!(
"{} __dashboard",
std::env::current_exe()
.unwrap_or_else(|_| std::path::PathBuf::from("git-paw"))
.display()
);
let supervisor_cli_command = if supervisor_flags.is_empty() {
supervisor_cli.clone()
} else {
format!("{supervisor_cli} {supervisor_flags}")
};
let supervisor_pane = tmux::PaneSpec {
branch: "supervisor".to_string(),
worktree: repo_str.clone(),
cli_command: supervisor_cli_command,
};
let dashboard_pane = tmux::PaneSpec {
branch: "dashboard".to_string(),
worktree: repo_str,
cli_command: dashboard_command,
};
let mut agent_panes: Vec<tmux::PaneSpec> = Vec::with_capacity(branches.len());
let mut agent_prompts: Vec<String> = Vec::with_capacity(branches.len());
let mut worktree_entries: Vec<WorktreeEntry> = Vec::with_capacity(branches.len());
let strict_guard = config
.supervisor
.as_ref()
.is_none_or(SupervisorConfig::strict_branch_guard);
let gate_commands = supervisor_cfg.gate_commands();
let attach_ctx = AttachContext {
repo_root,
project: &project,
broker_config: &broker_config,
agent_cli: &agent_cli,
agent_flags,
coordination_template: coordination_template.as_ref(),
gate_commands: &gate_commands,
session_backends: &session_backends,
inter_agent_rules: Some(inter_agent_rules.as_str()),
strict_guard,
no_rebase,
};
for branch in &branches {
let attached = attach_agent(&attach_ctx, branch, spec_by_branch.get(branch))?;
agent_panes.push(attached.pane);
agent_prompts.push(attached.prompt);
worktree_entries.push(attached.entry);
}
let env_vars: Vec<(String, String)> = if broker_config.enabled {
vec![("GIT_PAW_BROKER_URL".to_string(), broker_config.url())]
} else {
Vec::new()
};
let tmux_session = tmux::build_supervisor_session(
&project,
Some(session_name.clone()),
&supervisor_pane,
&dashboard_pane,
&agent_panes,
layout,
mouse,
config.border_affordances_enabled(),
&env_vars,
)?;
tmux_session.execute()?;
let mut state = Session {
session_name: tmux_session.name.clone(),
repo_path: repo_root.to_path_buf(),
project_name: project.clone(),
created_at: SystemTime::now(),
status: SessionStatus::Active,
worktrees: worktree_entries,
broker_port: None,
broker_bind: None,
broker_log_path: None,
mode: SessionMode::Supervisor,
dashboard_pane: None,
};
if broker_config.enabled {
state.broker_port = Some(broker_config.port);
state.broker_bind = Some(broker_config.bind.clone());
state.broker_log_path = Some(session::session_state_dir()?.join("broker.log"));
state.dashboard_pane = Some(1);
}
session::save_session(&state)?;
write_repo_discovery_file(
repo_root,
&tmux_session.name,
&state.worktrees,
git_paw::supervisor::layout::SUPERVISOR_PANE_OFFSET,
);
std::thread::sleep(std::time::Duration::from_secs(2));
let supervisor_boot_block =
git_paw::skills::build_boot_block("supervisor", &broker_config.url());
let supervisor_framing =
"Begin observing the spec implementation session. Your skill (AGENTS.md) describes \
your role — read it, then start the autonomous loop. The user can type questions or \
directives directly into your pane; handle them per the 'When the user types in your \
pane' section of your skill."
.to_string();
let supervisor_prompt = format!("{supervisor_boot_block}\n\n{supervisor_framing}");
let supervisor_delay = resolve_submit_delay_ms(&supervisor_cli, config);
submit_prompt_to_pane(&tmux_session.name, 0, &supervisor_prompt, supervisor_delay);
let agent_delay = resolve_submit_delay_ms(&agent_cli, config);
for (idx, prompt) in agent_prompts.iter().enumerate() {
let pane_idx = git_paw::supervisor::layout::SUPERVISOR_PANE_OFFSET + idx;
submit_prompt_to_pane(&tmux_session.name, pane_idx, prompt, agent_delay);
}
if let Some(notice) = learnings_disclosure_notice(config.supervisor.as_ref()) {
println!("{notice}");
}
println!(
"Supervisor session '{}' launched with {} coding agent(s).",
tmux_session.name,
branches.len()
);
println!("Attach with: tmux attach -t {}", tmux_session.name);
Ok(())
}
const GIT_PAW_ISSUES_URL: &str = "https://github.com/bearicorn/git-paw/issues";
#[must_use]
fn learnings_disclosure_notice(supervisor: Option<&SupervisorConfig>) -> Option<String> {
supervisor.filter(|s| s.enabled && s.learnings)?;
Some(format!(
"Learnings mode is on. Friction signals are written locally to \
.git-paw/session-learnings.md — no telemetry, nothing is sent anywhere.\n\
If a recurring rough edge is worth fixing in git-paw, you can optionally \
share that file by opening an issue at {GIT_PAW_ISSUES_URL} — review it \
first and strip or anonymise any repo-specific details (branch names, \
file paths, spec IDs); your own LLM can help with that."
))
}
#[must_use]
fn resolve_submit_delay_ms(cli: &str, config: &git_paw::config::PawConfig) -> u64 {
let base = cli.split_whitespace().next().unwrap_or(cli);
config
.clis
.get(base)
.and_then(|c| c.submit_delay_ms)
.unwrap_or(git_paw::DEFAULT_SUBMIT_DELAY_MS)
}
fn session_cli_settings_paths(
config: &git_paw::config::PawConfig,
supervisor_cli: &str,
agent_cli: &str,
) -> Vec<std::path::PathBuf> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for cli in [supervisor_cli, agent_cli] {
let base = cli.split_whitespace().next().unwrap_or(cli);
if let Some(raw) = config
.clis
.get(base)
.and_then(|c| c.settings_path.as_deref())
{
let expanded = expand_tilde(raw);
let parent_exists = expanded.parent().is_some_and(std::path::Path::is_dir);
if parent_exists && seen.insert(expanded.clone()) {
out.push(expanded);
}
}
}
out
}
fn configured_settings_paths(config: &git_paw::config::PawConfig) -> Vec<std::path::PathBuf> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for custom in config.clis.values() {
if let Some(raw) = custom.settings_path.as_deref() {
let expanded = expand_tilde(raw);
let parent_exists = expanded.parent().is_some_and(std::path::Path::is_dir);
if parent_exists && seen.insert(expanded.clone()) {
out.push(expanded);
}
}
}
out
}
fn expand_tilde(path: &str) -> std::path::PathBuf {
match git_paw::dirs::home_dir() {
Some(home) if path == "~" => home,
Some(home) => match path.strip_prefix("~/") {
Some(rest) => home.join(rest),
None => std::path::PathBuf::from(path),
},
None => std::path::PathBuf::from(path),
}
}
fn submit_prompt_to_pane(session_name: &str, pane_idx: usize, prompt: &str, delay_ms: u64) {
let target = format!("{session_name}:0.{pane_idx}");
let _ = std::process::Command::new("tmux")
.args(["send-keys", "-t", &target, "-l", prompt])
.status();
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
let _ = std::process::Command::new("tmux")
.args(["send-keys", "-t", &target, "Enter"])
.status();
}
fn write_repo_discovery_file(
repo_root: &Path,
session_name: &str,
worktrees: &[WorktreeEntry],
pane_offset: usize,
) {
let agents = worktrees
.iter()
.enumerate()
.map(|(idx, wt)| session::RepoAgentEntry {
branch_id: git_paw::broker::messages::slugify_branch(&wt.branch),
worktree_path: wt.worktree_path.clone(),
cli: wt.cli.clone(),
pane_index: pane_offset + idx,
})
.collect();
let file = session::RepoSessionFile {
session_name: session_name.to_string(),
agents,
};
if let Err(e) = session::write_repo_session_file(repo_root, &file) {
eprintln!("warning: failed to write per-repo session discovery file: {e}");
}
}
fn cmd_start_with_specs(
cli_flag: Option<&str>,
spec_mode: &SpecMode,
specs_format_override: Option<&str>,
dry_run: bool,
force: bool,
no_rebase: bool,
) -> Result<(), PawError> {
let cwd = std::env::current_dir()
.map_err(|e| PawError::SessionError(format!("cannot read current directory: {e}")))?;
let repo_root = git::validate_repo(&cwd)?;
let existing_session = session::find_session_for_repo(&repo_root)?;
if !dry_run
&& let Some(existing) = &existing_session
&& !invalidate_if_stale(&repo_root, existing)?
{
let effective =
existing.effective_status(|name| tmux::is_session_alive(name).unwrap_or(false));
match effective {
SessionStatus::Paused => {
println!(
"Restarting paused session '{}' (broker + reattach)...",
existing.session_name
);
return restart_from_pause(&repo_root, existing);
}
SessionStatus::Active => {
println!("Reattaching to session '{}'...", existing.session_name);
return attach_or_print_hint(&existing.session_name);
}
SessionStatus::Stopped => {
println!("Recovering session '{}'...", existing.session_name);
return recover_session(&repo_root, existing);
}
}
}
tmux::ensure_tmux_installed()?;
let config = config::load_config(&repo_root, None)?;
let discovered =
git_paw::specs::scan_specs_with_override(&config, &repo_root, specs_format_override)?;
if discovered.is_empty() {
println!("No pending specs found.");
return Ok(());
}
let specs = apply_spec_mode(spec_mode, discovered, &interactive::TerminalPrompter)?;
if specs.is_empty() {
println!("No pending specs found.");
return Ok(());
}
let uncommitted_specs = git::check_uncommitted_specs(&repo_root, &specs)?;
if !uncommitted_specs.is_empty() && !force {
eprintln!(
"warning: Uncommitted spec changes detected in: {}\n Commit your changes or use --force to proceed",
uncommitted_specs.join(", ")
);
} else if !uncommitted_specs.is_empty() && force {
eprintln!("Proceeding with --force flag (uncommitted spec changes ignored)");
}
let custom_defs = config_to_custom_defs(&config);
let detected = detect::detect_clis(&custom_defs);
if detected.is_empty() {
return Err(PawError::NoCLIsFound);
}
let interactive_clis: Vec<interactive::CliInfo> =
detected.iter().map(to_interactive_cli).collect();
let prompter = interactive::TerminalPrompter;
let mappings = interactive::resolve_cli_for_specs(
&specs,
cli_flag,
&config,
&interactive_clis,
&prompter,
)?;
let spec_by_branch: std::collections::HashMap<&str, &git_paw::specs::SpecEntry> =
specs.iter().map(|s| (s.branch.as_str(), s)).collect();
let project = git::project_name(&repo_root);
let mouse = config.mouse.unwrap_or(true);
let session_name = tmux::resolve_session_name(&project)?;
if dry_run {
if let Some(ref existing) = existing_session {
eprintln!(
"warning: session '{}' already exists — purge it before starting a new one\n",
existing.session_name
);
}
println!("Dry run — session plan (from specs):\n");
println!(" Session: {session_name}");
println!(" Mouse: {}", if mouse { "on" } else { "off" });
println!();
for (branch, cli) in &mappings {
let wt_dir = git::worktree_dir_name(&project, branch);
println!(" {branch} \u{2192} {cli} (../{wt_dir})");
}
return Ok(());
}
launch_spec_session(
&repo_root,
&config,
&mappings,
&spec_by_branch,
&project,
mouse,
no_rebase,
)
}
#[allow(clippy::too_many_lines)]
fn launch_spec_session(
repo_root: &std::path::Path,
config: &PawConfig,
mappings: &[(String, String)],
spec_by_branch: &std::collections::HashMap<&str, &git_paw::specs::SpecEntry>,
project: &str,
mouse: bool,
no_rebase: bool,
) -> Result<(), PawError> {
let session_name = tmux::resolve_session_name(project)?;
git::prune_worktrees(repo_root)?;
let broker_config = config.broker.clone();
let mut builder = tmux::TmuxSessionBuilder::new(project)
.session_name(session_name)
.mouse_mode(mouse)
.border_affordances(config.border_affordances_enabled());
if broker_config.enabled {
let repo_str = repo_root.to_string_lossy().to_string();
builder = builder.add_pane(tmux::PaneSpec {
branch: "dashboard".to_string(),
worktree: repo_str,
cli_command: format!(
"{} __dashboard",
std::env::current_exe()
.unwrap_or_else(|_| std::path::PathBuf::from("git-paw"))
.display()
),
});
builder = builder.set_environment("GIT_PAW_BROKER_URL", &broker_config.url());
}
let skill_template = if broker_config.enabled {
Some(git_paw::skills::resolve("coordination")?)
} else {
None
};
let mut worktree_entries = Vec::new();
let session_backends: Vec<git_paw::specs::SpecBackendKind> = {
let mut seen: Vec<git_paw::specs::SpecBackendKind> = Vec::new();
for entry in spec_by_branch.values() {
if !seen.contains(&entry.backend) {
seen.push(entry.backend);
}
}
seen
};
for (branch, cli) in mappings {
let wt = git::create_worktree(repo_root, branch, !no_rebase)?;
let wt_str = wt.path.to_string_lossy().to_string();
let rendered_skill = skill_template.as_ref().map(|tmpl| {
git_paw::skills::render(
tmpl,
branch,
&broker_config.url(),
project,
&config
.supervisor
.as_ref()
.map(|s| s.gate_commands())
.unwrap_or_default(),
&session_backends,
)
});
let spec_content = spec_by_branch
.get(branch.as_str())
.map(|s| s.prompt.clone());
let owned_files = spec_by_branch
.get(branch.as_str())
.and_then(|s| s.owned_files.clone());
let assignment = git_paw::agents::WorktreeAssignment {
branch: branch.clone(),
cli: cli.clone(),
spec_content,
owned_files,
skill_content: rendered_skill,
inter_agent_rules: None,
};
git_paw::agents::setup_worktree_agents_md(repo_root, &wt.path, &assignment)?;
if broker_config.enabled {
let agent_id = git_paw::broker::messages::slugify_branch(branch);
let strict_guard = config
.supervisor
.as_ref()
.is_none_or(SupervisorConfig::strict_branch_guard);
git_paw::agents::install_git_hooks(
&wt.path,
&broker_config.url(),
&agent_id,
branch,
strict_guard,
)?;
}
builder = builder.add_pane(tmux::PaneSpec {
branch: branch.clone(),
worktree: wt_str,
cli_command: cli.clone(),
});
worktree_entries.push(WorktreeEntry {
branch: branch.clone(),
worktree_path: wt.path,
cli: cli.clone(),
branch_created: wt.branch_created,
pending_boot_prompt: None,
});
}
let mut tmux_session = builder.build()?;
if config.logging.as_ref().is_some_and(|l| l.enabled) {
let pane_offset = usize::from(broker_config.enabled);
git_paw::logging::ensure_log_dir(repo_root, &tmux_session.name)?;
for (i, (branch, _)) in mappings.iter().enumerate() {
let log_path = git_paw::logging::log_file_path(repo_root, &tmux_session.name, branch);
let pane_target = format!("{}:{}.{}", tmux_session.name, 0, i + pane_offset);
tmux_session.pipe_pane(&pane_target, &log_path);
}
}
tmux_session.execute()?;
if broker_config.enabled {
let pane_offset = usize::from(broker_config.enabled);
for (idx, (branch, _)) in mappings.iter().enumerate() {
let pane_idx = idx + pane_offset;
let boot_block = git_paw::skills::build_boot_block(branch, &broker_config.url());
let args =
git_paw::tmux::build_boot_inject_args(&tmux_session.name, pane_idx, &boot_block);
let _ = std::process::Command::new("tmux").args(&args).status();
}
}
let mut state = Session {
session_name: tmux_session.name.clone(),
repo_path: repo_root.to_path_buf(),
project_name: project.to_string(),
created_at: SystemTime::now(),
status: SessionStatus::Active,
worktrees: worktree_entries,
broker_port: None,
broker_bind: None,
broker_log_path: None,
mode: SessionMode::Bare,
dashboard_pane: None,
};
if broker_config.enabled {
state.broker_port = Some(broker_config.port);
state.broker_bind = Some(broker_config.bind.clone());
state.broker_log_path = Some(session::session_state_dir()?.join("broker.log"));
state.dashboard_pane = Some(0);
}
session::save_session(&state)?;
attach_or_print_hint(&tmux_session.name)
}
fn restart_from_pause(repo_root: &Path, existing: &Session) -> Result<(), PawError> {
tmux::ensure_tmux_installed()?;
let dashboard_index = existing.dashboard_pane.unwrap_or(0);
if existing.broker_port.is_some() {
let dashboard_command = format!(
"{} __dashboard",
std::env::current_exe()
.unwrap_or_else(|_| std::path::PathBuf::from("git-paw"))
.display()
);
let repo_str = repo_root.to_string_lossy().to_string();
let split_status = StdCommand::new("tmux")
.args([
"split-window",
"-h",
"-b",
"-t",
&format!("{}:0.{dashboard_index}", existing.session_name),
"-c",
&repo_str,
])
.status()
.map_err(|e| PawError::TmuxError(format!("failed to spawn dashboard pane: {e}")))?;
if !split_status.success() {
return Err(PawError::TmuxError(
"failed to recreate dashboard pane".to_string(),
));
}
let target = format!("{}:0.{dashboard_index}", existing.session_name);
let _ = StdCommand::new("tmux")
.args(["select-pane", "-t", &target, "-T", "dashboard"])
.status();
let send_status = StdCommand::new("tmux")
.args(["send-keys", "-t", &target, &dashboard_command, "Enter"])
.status()
.map_err(|e| PawError::TmuxError(format!("failed to send dashboard command: {e}")))?;
if !send_status.success() {
return Err(PawError::TmuxError(
"failed to send dashboard command".to_string(),
));
}
}
let mut updated = existing.clone();
updated.status = SessionStatus::Active;
let has_pending = updated
.worktrees
.iter()
.any(|w| w.pending_boot_prompt.is_some());
if has_pending {
let session_name = updated.session_name.clone();
let offset = agent_pane_offset(&updated);
let config = config::load_config(repo_root, None)?;
for (idx, wt) in updated.worktrees.iter_mut().enumerate() {
if let Some(pending) = wt.pending_boot_prompt.take() {
let delay = resolve_submit_delay_ms(&wt.cli, &config);
submit_prompt_to_pane(&session_name, offset + idx, &pending, delay);
}
}
}
session::save_session(&updated)?;
attach_or_print_hint(&existing.session_name)
}
fn recover_session(repo_root: &Path, existing: &Session) -> Result<(), PawError> {
tmux::ensure_tmux_installed()?;
let config = config::load_config(repo_root, None)?;
let mouse = config.mouse.unwrap_or(true);
let supervisor_enabled_in_config = config.supervisor.as_ref().is_some_and(|s| s.enabled);
let mode = if existing.mode == SessionMode::Supervisor {
SessionMode::Supervisor
} else if supervisor_enabled_in_config {
eprintln!(
"warning: session '{}' was created with a v0.4 layout but [supervisor] is enabled \
in current config — rebuilding with v0.5 supervisor-as-pane layout.",
existing.session_name
);
SessionMode::Supervisor
} else {
SessionMode::Bare
};
let broker_url = existing
.broker_port
.zip(existing.broker_bind.as_ref())
.map(|(port, bind)| format!("http://{bind}:{port}"));
if let Some(url) = &broker_url {
let claude_settings = repo_root.join(".claude").join("settings.json");
if let Err(e) =
git_paw::supervisor::curl_allowlist::setup_curl_allowlist(url, &claude_settings)
{
eprintln!("warning: failed to setup curl allowlist: {e}");
}
}
if mode == SessionMode::Supervisor
&& let Some(supervisor_cfg) = config.supervisor.as_ref()
&& supervisor_cfg.common_dev_allowlist.enabled
{
for (path, err) in git_paw::supervisor::dev_allowlist::seed_supervisor_session(
&supervisor_cfg.common_dev_allowlist.extra,
repo_root,
&configured_settings_paths(&config),
) {
eprintln!(
"warning: failed to seed dev allowlist into {}: {err}",
path.display(),
);
}
}
if tmux::is_session_alive(&existing.session_name).unwrap_or(false)
&& let Err(e) = tmux::kill_session(&existing.session_name)
{
eprintln!(
"warning: could not tear down stale tmux session '{}' before recovery: {e}",
existing.session_name
);
}
let tmux_session = match mode {
SessionMode::Supervisor => {
recover_supervisor_session(repo_root, existing, &config, broker_url.as_deref(), mouse)?
}
SessionMode::Bare => recover_bare_session(
repo_root,
existing,
broker_url.as_deref(),
mouse,
config.border_affordances_enabled(),
)?,
};
tmux_session.execute()?;
let mut updated = existing.clone();
updated.status = SessionStatus::Active;
updated.mode = mode;
session::save_session(&updated)?;
attach_or_print_hint(&tmux_session.name)
}
fn recover_bare_session(
repo_root: &Path,
existing: &Session,
broker_url: Option<&str>,
mouse: bool,
border_affordances: bool,
) -> Result<tmux::TmuxSession, PawError> {
let mut builder = tmux::TmuxSessionBuilder::new(&existing.project_name)
.session_name(existing.session_name.clone())
.mouse_mode(mouse)
.border_affordances(border_affordances);
if let Some(url) = broker_url {
let repo_str = repo_root.to_string_lossy().to_string();
builder = builder.add_pane(tmux::PaneSpec {
branch: "dashboard".to_string(),
worktree: repo_str,
cli_command: format!(
"{} __dashboard",
std::env::current_exe()
.unwrap_or_else(|_| std::path::PathBuf::from("git-paw"))
.display()
),
});
builder = builder.set_environment("GIT_PAW_BROKER_URL", url);
}
for entry in &existing.worktrees {
builder = builder.add_pane(tmux::PaneSpec {
branch: entry.branch.clone(),
worktree: entry.worktree_path.to_string_lossy().to_string(),
cli_command: entry.cli.clone(),
});
}
builder.build()
}
fn recover_supervisor_session(
repo_root: &Path,
existing: &Session,
config: &PawConfig,
broker_url: Option<&str>,
mouse: bool,
) -> Result<tmux::TmuxSession, PawError> {
let default_supervisor_cfg = SupervisorConfig::default();
let supervisor_cfg = config
.supervisor
.as_ref()
.unwrap_or(&default_supervisor_cfg);
let supervisor_cli = supervisor_cfg
.cli
.clone()
.or_else(|| config.default_cli.clone())
.ok_or_else(|| {
PawError::ConfigError(
"supervisor recovery requires either [supervisor].cli or default_cli to be set"
.to_string(),
)
})?;
let supervisor_flags = config::approval_flags(&supervisor_cli, &supervisor_cfg.agent_approval);
let supervisor_cli_command = if supervisor_flags.is_empty() {
supervisor_cli
} else {
format!("{supervisor_cli} {supervisor_flags}")
};
let layout = git_paw::supervisor::layout::supervisor_layout(existing.worktrees.len())?;
let repo_str = repo_root.to_string_lossy().to_string();
let supervisor_pane = tmux::PaneSpec {
branch: "supervisor".to_string(),
worktree: repo_str.clone(),
cli_command: supervisor_cli_command,
};
let dashboard_pane = tmux::PaneSpec {
branch: "dashboard".to_string(),
worktree: repo_str,
cli_command: format!(
"{} __dashboard",
std::env::current_exe()
.unwrap_or_else(|_| std::path::PathBuf::from("git-paw"))
.display()
),
};
let agent_panes: Vec<tmux::PaneSpec> = existing
.worktrees
.iter()
.map(|entry| tmux::PaneSpec {
branch: entry.branch.clone(),
worktree: entry.worktree_path.to_string_lossy().to_string(),
cli_command: entry.cli.clone(),
})
.collect();
let env_vars: Vec<(String, String)> = broker_url
.map(|url| vec![("GIT_PAW_BROKER_URL".to_string(), url.to_string())])
.unwrap_or_default();
tmux::build_supervisor_session(
&existing.project_name,
Some(existing.session_name.clone()),
&supervisor_pane,
&dashboard_pane,
&agent_panes,
layout,
mouse,
config.border_affordances_enabled(),
&env_vars,
)
}
fn agent_pane_offset(session: &Session) -> usize {
match session.mode {
SessionMode::Supervisor => git_paw::supervisor::layout::SUPERVISOR_PANE_OFFSET,
SessionMode::Bare => usize::from(session.broker_port.is_some()),
}
}
fn bare_mode_unsupported(session_name: &str, verb: &str) -> PawError {
PawError::SessionError(format!(
"`git paw {verb}` supports supervisor-mode sessions (the default). Session \
'{session_name}' was started in bare (no-supervisor) mode, whose tiled grid is \
not re-tiled incrementally in v0.6.0. Stop and re-start with the full branch set, \
or run the session in supervisor mode to use add/remove."
))
}
#[allow(clippy::too_many_lines)]
fn cmd_add(
branch_arg: Option<&str>,
cli_flag: Option<&str>,
from_spec: Option<&str>,
) -> Result<(), PawError> {
let cwd = std::env::current_dir()
.map_err(|e| PawError::SessionError(format!("cannot read current directory: {e}")))?;
let repo_root = git::validate_repo(&cwd)?;
let Some(existing) = session::find_session_for_repo(&repo_root)? else {
return Err(PawError::SessionError(
"no active session for this repository. Start one with `git paw start`.".to_string(),
));
};
let effective = existing.effective_status(|n| tmux::is_session_alive(n).unwrap_or(false));
let paused = match effective {
SessionStatus::Active => false,
SessionStatus::Paused => true,
SessionStatus::Stopped => {
return Err(PawError::SessionError(format!(
"session '{}' is stopped — recover it with `git paw start` before adding agents.",
existing.session_name
)));
}
};
if existing.mode == SessionMode::Bare {
return Err(bare_mode_unsupported(&existing.session_name, "add"));
}
tmux::ensure_tmux_installed()?;
let config = config::load_config(&repo_root, None)?;
let broker_config = config.broker.clone();
let project = git::project_name(&repo_root);
let (branch, resolved_cli, spec_entry): (
String,
Option<String>,
Option<git_paw::specs::SpecEntry>,
) = if let Some(spec_name) = from_spec {
let discovered = git_paw::specs::scan_specs(&config, &repo_root)?;
let mut resolved =
git_paw::specs::resolve::resolve_specs(&discovered, &[spec_name.to_string()])?;
let spec = resolved.drain(..).next().ok_or_else(|| {
PawError::SpecError(format!("spec '{spec_name}' resolved to no entries"))
})?;
let cli = cli_flag.map(str::to_string).or_else(|| spec.cli.clone());
(spec.branch.clone(), cli, Some(spec))
} else {
let branch = branch_arg
.expect("clap requires a branch when --from-spec is absent")
.to_string();
(branch, cli_flag.map(str::to_string), None)
};
if existing.worktrees.iter().any(|w| w.branch == branch) {
return Err(PawError::SessionError(format!(
"branch '{branch}' is already an agent in session '{}'.",
existing.session_name
)));
}
let session_default_cli = existing.worktrees.first().map(|w| w.cli.clone());
let agent_cli = resolved_cli
.or(session_default_cli)
.or_else(|| config.default_cli.clone())
.ok_or_else(|| {
PawError::ConfigError(
"no CLI specified and the session has no default to fall back to; pass --cli <id>."
.to_string(),
)
})?;
let custom_defs = config_to_custom_defs(&config);
let detected = detect::detect_clis(&custom_defs);
let agent_cli_base = agent_cli.split_whitespace().next().unwrap_or(&agent_cli);
let cli_in_session = existing
.worktrees
.iter()
.any(|w| w.cli.split_whitespace().next() == Some(agent_cli_base));
if !cli_in_session && !detected.iter().any(|c| c.binary_name == agent_cli_base) {
let ids: Vec<&str> = detected.iter().map(|c| c.binary_name.as_str()).collect();
return Err(PawError::ConfigError(format!(
"unknown CLI '{agent_cli_base}'. Detected CLIs: {}.",
if ids.is_empty() {
"(none)".to_string()
} else {
ids.join(", ")
}
)));
}
let prev_agent_count = existing.worktrees.len();
let layout = git_paw::supervisor::layout::layout_for(prev_agent_count + 1)?;
let _lock = git_paw::lock::SessionLock::acquire(&repo_root)?;
let default_sup = SupervisorConfig::default();
let supervisor_cfg = config.supervisor.as_ref().unwrap_or(&default_sup);
let approval = &supervisor_cfg.agent_approval;
let agent_flags = config::approval_flags(&agent_cli, approval);
let strict_guard = config
.supervisor
.as_ref()
.is_none_or(SupervisorConfig::strict_branch_guard);
let gate_commands = supervisor_cfg.gate_commands();
let coordination_template = if broker_config.enabled {
Some(git_paw::skills::resolve("coordination")?)
} else {
None
};
let session_backends: Vec<git_paw::specs::SpecBackendKind> = spec_entry
.as_ref()
.map(|s| vec![s.backend])
.unwrap_or_default();
let mut all_branches: Vec<&str> = existing
.worktrees
.iter()
.map(|w| w.branch.as_str())
.collect();
all_branches.push(branch.as_str());
let inter_agent_rules = git_paw::agents::build_inter_agent_rules(&all_branches);
let attach_ctx = AttachContext {
repo_root: &repo_root,
project: &project,
broker_config: &broker_config,
agent_cli: &agent_cli,
agent_flags,
coordination_template: coordination_template.as_ref(),
gate_commands: &gate_commands,
session_backends: &session_backends,
inter_agent_rules: Some(inter_agent_rules.as_str()),
strict_guard,
no_rebase: false,
};
let AttachedAgent {
pane,
prompt,
mut entry,
} = attach_agent(&attach_ctx, &branch, spec_entry.as_ref())?;
let new_worktree_path = entry.worktree_path.clone();
let offset = agent_pane_offset(&existing);
let new_pane_idx = offset + prev_agent_count;
tmux::build_add_agent_commands(
&existing.session_name,
&pane,
prev_agent_count,
layout,
config.border_affordances_enabled(),
)
.execute()?;
if paused {
entry.pending_boot_prompt = Some(prompt.clone());
}
let mut updated = existing.clone();
updated.worktrees.push(entry);
session::save_session(&updated)?;
write_repo_discovery_file(
&repo_root,
&updated.session_name,
&updated.worktrees,
offset,
);
if broker_config.enabled {
let agent_id = broker::messages::slugify_branch(&branch);
if let Err(e) = broker::publish::register_watch_target_http(
&broker_config.url(),
&agent_id,
&new_worktree_path,
&agent_cli,
) {
eprintln!("warning: could not register '{branch}' with the broker watcher: {e}");
}
}
if paused {
println!(
"Added '{branch}' to paused session '{}' (pane {new_pane_idx}); it will start on \
`git paw resume`.",
updated.session_name
);
} else {
std::thread::sleep(std::time::Duration::from_secs(2));
let delay = resolve_submit_delay_ms(&agent_cli, &config);
submit_prompt_to_pane(&updated.session_name, new_pane_idx, &prompt, delay);
println!(
"Added '{branch}' to session '{}' (pane {new_pane_idx}).",
updated.session_name
);
}
Ok(())
}
#[allow(clippy::too_many_lines)]
fn cmd_remove(branch: &str, keep_worktree: bool, force: bool) -> Result<(), PawError> {
let cwd = std::env::current_dir()
.map_err(|e| PawError::SessionError(format!("cannot read current directory: {e}")))?;
let repo_root = git::validate_repo(&cwd)?;
if branch == "supervisor" {
return Err(PawError::SessionError(
"refusing to remove the supervisor. To end the whole session, run `git paw stop` \
(or `git paw purge` to also remove worktrees)."
.to_string(),
));
}
let Some(existing) = session::find_session_for_repo(&repo_root)? else {
return Err(PawError::SessionError(
"no active session for this repository.".to_string(),
));
};
if existing.mode == SessionMode::Bare {
return Err(bare_mode_unsupported(&existing.session_name, "remove"));
}
let Some(pos) = existing.worktrees.iter().position(|w| w.branch == branch) else {
let live: Vec<&str> = existing
.worktrees
.iter()
.map(|w| w.branch.as_str())
.collect();
return Err(PawError::SessionError(format!(
"branch '{branch}' is not an agent in session '{}'. Live agents: {}.",
existing.session_name,
if live.is_empty() {
"(none)".to_string()
} else {
live.join(", ")
}
)));
};
let target = existing.worktrees[pos].clone();
if !force && !keep_worktree {
let dirty = git::uncommitted_files(&target.worktree_path).unwrap_or_default();
if !dirty.is_empty() {
let list = dirty
.iter()
.map(|f| format!(" {f}"))
.collect::<Vec<_>>()
.join("\n");
return Err(PawError::SessionError(format!(
"worktree for '{branch}' has uncommitted changes:\n{list}\n\n\
Commit them first, or pass --force to remove anyway (the changes will be lost), \
or --keep-worktree to detach the pane and leave the worktree on disk."
)));
}
}
tmux::ensure_tmux_installed()?;
let _lock = git_paw::lock::SessionLock::acquire(&repo_root)?;
let offset = agent_pane_offset(&existing);
let pane_idx = offset + pos;
let session_alive = tmux::is_session_alive(&existing.session_name).unwrap_or(false);
if session_alive {
let idx = u32::try_from(pane_idx)
.map_err(|_| PawError::TmuxError(format!("pane index {pane_idx} out of range")))?;
tmux::kill_pane(&existing.session_name, idx)?;
}
let remaining = existing.worktrees.len() - 1;
if session_alive && remaining > 0 {
let layout = git_paw::supervisor::layout::layout_for(remaining)?;
tmux::build_remove_retile_commands(&existing.session_name, remaining, layout).execute()?;
}
if keep_worktree {
println!(
"Keeping worktree on disk: {}",
target.worktree_path.display()
);
} else {
detach_worktree(&repo_root, &target, &mut std::io::stderr());
}
let mut updated = existing.clone();
updated.worktrees.remove(pos);
session::save_session(&updated)?;
write_repo_discovery_file(
&repo_root,
&updated.session_name,
&updated.worktrees,
offset,
);
println!(
"Removed '{branch}' from session '{}'.",
updated.session_name
);
Ok(())
}
#[allow(clippy::too_many_lines)]
fn cmd_dashboard() -> Result<(), PawError> {
if std::env::var("TMUX").is_err() {
return Err(PawError::DashboardError(
"this is an internal command that should only be run by git-paw inside tmux"
.to_string(),
));
}
let cwd = std::env::current_dir()
.map_err(|e| PawError::SessionError(format!("cannot read current directory: {e}")))?;
let repo_root = git::validate_repo(&cwd)?;
let config = config::load_config(&repo_root, None)?;
let broker_config = config.broker.clone();
let broker_log_cfg = config
.dashboard
.as_ref()
.map(|d| d.broker_log.clone())
.unwrap_or_default();
let conflict_cfg = config
.supervisor
.as_ref()
.filter(|s| s.enabled)
.map(|s| s.conflict.clone());
let learnings_interval_seconds = config
.supervisor
.as_ref()
.map_or(60, |s| s.learnings_config.flush_interval_seconds);
let verify_on_commit_nudge = config
.supervisor
.as_ref()
.is_none_or(SupervisorConfig::verify_on_commit_nudge_enabled);
let supervisor_cli = config
.supervisor
.as_ref()
.and_then(|s| s.cli.clone())
.or_else(|| config.default_cli.clone())
.unwrap_or_default();
let log_path = session::session_state_dir()?.join("broker.log");
let mut broker_state = broker::BrokerState::new(Some(log_path))
.with_verify_on_commit_nudge(verify_on_commit_nudge)
.with_seeded_cli("supervisor", &supervisor_cli);
let saved_session = session::find_session_for_repo(&repo_root)?;
let watch_targets = saved_session
.as_ref()
.map(|sess| {
sess.worktrees
.iter()
.map(|wt| broker::WatchTarget {
agent_id: broker::messages::slugify_branch(&wt.branch),
cli: wt.cli.clone(),
worktree_path: wt.worktree_path.clone(),
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
{
let engine_is_openspec =
git_paw::specs::resolved_spec_type(&config, &repo_root).as_deref() == Some("openspec");
let mut roster: Vec<(String, std::path::PathBuf)> = watch_targets
.iter()
.map(|t| (t.agent_id.clone(), t.worktree_path.clone()))
.collect();
roster.push((
git_paw::opsx::SUPERVISOR_AGENT_ID.to_string(),
repo_root.clone(),
));
broker_state = broker_state.with_role_gating(git_paw::opsx::RoleGatingContext {
mode: config.role_gating_mode(),
engine_is_openspec,
roster,
});
}
if let Some(sup) = config
.supervisor
.as_ref()
.filter(|s| s.enabled && s.learnings)
{
let learnings_path = repo_root.join(".git-paw").join("session-learnings.md");
let mut aggregator = broker::learnings::LearningsAggregator::new(learnings_path);
aggregator.set_broker_publish(
sup.learnings_config
.broker_publish
.resolve(broker_config.enabled),
);
for target in &watch_targets {
aggregator.register_agent(&target.agent_id);
}
broker_state.attach_learnings(std::sync::Arc::new(std::sync::Mutex::new(aggregator)));
}
let handle = broker::start_broker_with(
&broker_config,
broker_state,
watch_targets,
conflict_cfg,
learnings_interval_seconds,
)?;
let state = std::sync::Arc::clone(&handle.state);
let shutdown = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
#[cfg(unix)]
{
use std::sync::atomic::AtomicPtr;
static SHUTDOWN_PTR: AtomicPtr<std::sync::atomic::AtomicBool> =
AtomicPtr::new(std::ptr::null_mut());
const SIGHUP: std::ffi::c_int = 1;
unsafe extern "C" {
fn signal(signum: std::ffi::c_int, handler: extern "C" fn(std::ffi::c_int)) -> usize;
}
extern "C" fn sighup_handler(_: std::ffi::c_int) {
let ptr = SHUTDOWN_PTR.load(std::sync::atomic::Ordering::Acquire);
if !ptr.is_null() {
unsafe {
(*ptr).store(true, std::sync::atomic::Ordering::Relaxed);
}
}
}
SHUTDOWN_PTR.store(
std::sync::Arc::as_ptr(&shutdown).cast_mut(),
std::sync::atomic::Ordering::Release,
);
unsafe {
signal(SIGHUP, sighup_handler);
}
}
let auto_approve_handle = saved_session
.as_ref()
.filter(|sess| sess.mode == SessionMode::Supervisor && broker_config.enabled)
.and_then(|sess| {
let auto_approve_cfg = config
.supervisor
.as_ref()
.and_then(|s| s.auto_approve.clone())?;
let pane_map: std::collections::HashMap<String, usize> = sess
.worktrees
.iter()
.enumerate()
.map(|(idx, wt)| {
(
broker::messages::slugify_branch(&wt.branch),
idx + git_paw::supervisor::layout::SUPERVISOR_PANE_OFFSET,
)
})
.collect();
let worktree_map: std::collections::HashMap<String, std::path::PathBuf> = sess
.worktrees
.iter()
.map(|wt| {
(
broker::messages::slugify_branch(&wt.branch),
wt.worktree_path.clone(),
)
})
.collect();
let supervisor = config.supervisor.as_ref();
let manual_enabled =
supervisor.is_none_or(SupervisorConfig::manual_approvals_log_enabled);
let learnings_enabled = supervisor.is_some_and(|s| s.learnings);
let cli = supervisor.and_then(|s| s.cli.clone());
let recorder = git_paw::supervisor::manual_approvals::ManualDecisionRecorder::new(
git_paw::supervisor::manual_approvals::log_path(
&sess.repo_path,
&sess.session_name,
),
manual_enabled,
learnings_enabled,
sess.project_name.clone(),
cli,
);
spawn_auto_approve_thread(
sess.session_name.clone(),
broker_config.url(),
Some(auto_approve_cfg),
pane_map,
worktree_map,
recorder,
)
});
let dashboard_result = git_paw::dashboard::run_dashboard_with_panes(
&state,
handle,
&shutdown,
&std::collections::HashMap::new(),
None,
broker_log_cfg.max_messages,
broker_log_cfg.default_visible,
);
if let Some((stop, join)) = auto_approve_handle {
stop.store(true, std::sync::atomic::Ordering::Relaxed);
let _ = join.join();
}
dashboard_result
}
fn cmd_pause() -> Result<(), PawError> {
let cwd = std::env::current_dir()
.map_err(|e| PawError::SessionError(format!("cannot read current directory: {e}")))?;
let repo_root = git::validate_repo(&cwd)?;
let Some(existing) = session::find_session_for_repo(&repo_root)? else {
println!("No active session for this repo.");
return Ok(());
};
if existing.status == SessionStatus::Paused {
println!("Session '{}' is already paused.", existing.session_name);
return Ok(());
}
let effective = existing.effective_status(|name| tmux::is_session_alive(name).unwrap_or(false));
if effective == SessionStatus::Stopped {
println!(
"Session '{}' is already stopped; pause has no effect.",
existing.session_name
);
return Ok(());
}
tmux::detach_client(&existing.session_name)?;
if existing.broker_port.is_some() {
let pane_idx = existing.dashboard_pane.unwrap_or(0);
tmux::kill_pane(&existing.session_name, pane_idx)?;
}
let cli_pane_count = existing.worktrees.len();
let session_name = existing.session_name.clone();
let mut updated = existing;
updated.status = SessionStatus::Paused;
session::save_session(&updated)?;
println!(
"Session '{session_name}' paused. {cli_pane_count} CLI pane(s) still running. \
Run 'git paw start' to resume."
);
Ok(())
}
fn cmd_stop(_force: bool) -> Result<(), PawError> {
let cwd = std::env::current_dir()
.map_err(|e| PawError::SessionError(format!("cannot read current directory: {e}")))?;
let repo_root = git::validate_repo(&cwd)?;
let Some(existing) = session::find_session_for_repo(&repo_root)? else {
println!("No active session for this repo.");
return Ok(());
};
if tmux::is_session_alive(&existing.session_name)? {
tmux::kill_session(&existing.session_name)?;
}
if let Err(e) = agents::remove_session_boot_block(&repo_root) {
eprintln!("warning: failed to clean session boot block from AGENTS.md: {e}");
}
let mut updated = existing;
updated.status = SessionStatus::Stopped;
session::save_session(&updated)?;
println!("Session stopped. Worktrees and state preserved.");
println!("Run `git paw start` to recover.");
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
enum PurgeOutcome {
Purged,
Cancelled,
}
fn cmd_purge(force: bool, stale: bool) -> Result<(), PawError> {
if stale {
return cmd_purge_stale();
}
let cwd = std::env::current_dir()
.map_err(|e| PawError::SessionError(format!("cannot read current directory: {e}")))?;
let repo_root = git::validate_repo(&cwd)?;
let Some(existing) = session::find_session_for_repo(&repo_root)? else {
println!("No session to purge for this repo.");
return Ok(());
};
let sessions_dir = session::session_state_dir()?;
let mut confirm = |prompt: &str| -> Result<bool, PawError> {
Confirm::new()
.with_prompt(prompt)
.default(false)
.interact()
.map_err(|_| PawError::UserCancelled)
};
let mut kill_tmux = |name: &str| -> Result<(), PawError> {
if tmux::is_session_alive(name)? {
tmux::kill_session(name)?;
}
Ok(())
};
let outcome = purge_with_prompt(
&repo_root,
&sessions_dir,
&existing,
force,
&mut confirm,
&mut kill_tmux,
&mut std::io::stderr(),
)?;
match outcome {
PurgeOutcome::Purged => println!("Purged session '{}'.", existing.session_name),
PurgeOutcome::Cancelled => println!("Purge cancelled."),
}
Ok(())
}
fn invalidate_if_stale(repo_root: &Path, existing: &Session) -> Result<bool, PawError> {
let liveness = tmux::session_liveness(&existing.session_name);
if session::DisplayStatus::from_receipt(&existing.status, liveness)
!= session::DisplayStatus::Stale
{
return Ok(false);
}
let when = existing
.created_at_iso8601()
.map(|t| format!(", last seen {t}"))
.unwrap_or_default();
eprintln!(
"notice: removed stale session receipt\n ({}{}, tmux session no longer exists)",
existing.session_name, when
);
let sessions_dir = session::session_state_dir()?;
let mut confirm = |_: &str| -> Result<bool, PawError> { Ok(true) };
let mut kill_tmux = |name: &str| -> Result<(), PawError> {
if tmux::is_session_alive(name)? {
tmux::kill_session(name)?;
}
Ok(())
};
purge_with_prompt(
repo_root,
&sessions_dir,
existing,
true,
&mut confirm,
&mut kill_tmux,
&mut std::io::stderr(),
)?;
Ok(true)
}
fn cmd_purge_stale() -> Result<(), PawError> {
let sessions_dir = session::session_state_dir()?;
let all = session::load_all_sessions_in(&sessions_dir)?;
let stale: Vec<session::Session> = all
.into_iter()
.filter(|s| {
let liveness = tmux::session_liveness(&s.session_name);
session::DisplayStatus::from_receipt(&s.status, liveness)
== session::DisplayStatus::Stale
})
.collect();
if stale.is_empty() {
println!("No stale sessions to purge.");
return Ok(());
}
let mut confirm = |_: &str| -> Result<bool, PawError> { Ok(true) };
let mut kill_tmux = |name: &str| -> Result<(), PawError> {
if tmux::is_session_alive(name)? {
tmux::kill_session(name)?;
}
Ok(())
};
for session_entry in &stale {
let outcome = purge_with_prompt(
&session_entry.repo_path,
&sessions_dir,
session_entry,
true,
&mut confirm,
&mut kill_tmux,
&mut std::io::stderr(),
)?;
if outcome == PurgeOutcome::Purged {
println!("Purged stale session '{}'.", session_entry.session_name);
}
}
Ok(())
}
fn detach_worktree(repo_root: &Path, entry: &WorktreeEntry, stderr: &mut dyn std::io::Write) {
let _ = writeln!(
stderr,
"Removing worktree {}...",
entry.worktree_path.display()
);
let _ = stderr.flush();
let started = std::time::Instant::now();
let result = git::remove_worktree(repo_root, &entry.worktree_path);
let elapsed_secs = started.elapsed().as_secs_f64();
match result {
Ok(()) => {
let _ = writeln!(stderr, " ...done ({elapsed_secs:.1}s)");
}
Err(e) => {
let _ = writeln!(
stderr,
"warning: failed to remove worktree '{}' after {:.1}s: {e}",
entry.worktree_path.display(),
elapsed_secs
);
}
}
let _ = stderr.flush();
if entry.branch_created
&& let Err(e) = git::delete_branch(repo_root, &entry.branch)
{
let _ = writeln!(
stderr,
"warning: failed to delete branch '{}': {e}",
entry.branch
);
}
}
fn purge_with_prompt(
repo_root: &Path,
sessions_dir: &Path,
session: &Session,
force: bool,
confirm: &mut dyn FnMut(&str) -> Result<bool, PawError>,
kill_tmux: &mut dyn FnMut(&str) -> Result<(), PawError>,
stderr: &mut dyn std::io::Write,
) -> Result<PurgeOutcome, PawError> {
let default_branch = resolve_default_branch(repo_root);
let unmerged = collect_unmerged_branches(repo_root, session, &default_branch);
if !unmerged.is_empty() {
let _ = writeln!(
stderr,
"Warning: {} branch(es) have unmerged commits:",
unmerged.len()
);
for (branch, count) in &unmerged {
let _ = writeln!(
stderr,
" {branch}: {count} commit(s) not in {default_branch}"
);
}
let _ = writeln!(
stderr,
"Purging is irreversible — those commits will be lost."
);
let _ = stderr.flush();
}
if !force {
let prompt_text = if unmerged.is_empty() {
"This will remove the tmux session, all worktrees, and session state. Continue?"
} else {
"Purge is irreversible. Continue?"
};
if !confirm(prompt_text)? {
return Ok(PurgeOutcome::Cancelled);
}
}
kill_tmux(&session.session_name)?;
for entry in &session.worktrees {
detach_worktree(repo_root, entry, stderr);
}
if let Some(ref log_path) = session.broker_log_path {
let _ = std::fs::remove_file(log_path);
}
if let Err(e) = agents::remove_session_boot_block(repo_root) {
let _ = writeln!(
stderr,
"warning: failed to clean session boot block from AGENTS.md: {e}"
);
}
session::delete_session_in(&session.session_name, sessions_dir)?;
if let Err(e) = session::remove_repo_session_file(repo_root, &session.session_name) {
let _ = writeln!(
stderr,
"warning: failed to remove per-repo session discovery file: {e}"
);
}
Ok(PurgeOutcome::Purged)
}
fn resolve_default_branch(repo_root: &Path) -> String {
let output = StdCommand::new("git")
.args(["symbolic-ref", "refs/remotes/origin/HEAD"])
.current_dir(repo_root)
.output();
if let Ok(out) = output
&& out.status.success()
{
let s = String::from_utf8_lossy(&out.stdout);
if let Some(name) = s.trim().strip_prefix("refs/remotes/origin/") {
return name.to_string();
}
}
"main".to_string()
}
fn collect_unmerged_branches(
repo_root: &Path,
session: &Session,
default_branch: &str,
) -> Vec<(String, usize)> {
let mut out = Vec::new();
for entry in &session.worktrees {
if entry.branch == default_branch {
continue;
}
let result = StdCommand::new("git")
.args(["log", &entry.branch, "--not", default_branch, "--oneline"])
.current_dir(repo_root)
.output();
let Ok(output) = result else { continue };
if !output.status.success() {
continue;
}
let count = String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|l| !l.trim().is_empty())
.count();
if count > 0 {
out.push((entry.branch.clone(), count));
}
}
out
}
fn cmd_status(json: bool) -> Result<(), PawError> {
let cwd = std::env::current_dir()
.map_err(|e| PawError::SessionError(format!("cannot read current directory: {e}")))?;
let repo_root = git::validate_repo(&cwd)?;
let Some(existing) = session::find_session_for_repo(&repo_root)? else {
if json {
println!("{}", serde_json::json!({ "session": null }));
} else {
println!("No session for this repo.");
}
return Ok(());
};
let liveness = tmux::session_liveness(&existing.session_name);
let display = session::DisplayStatus::from_receipt(&existing.status, liveness);
let alive = matches!(liveness, tmux::SessionLiveness::Alive);
if json {
let worktrees: Vec<_> = existing
.worktrees
.iter()
.map(|e| {
serde_json::json!({
"branch": e.branch,
"cli": e.cli,
"worktree_path": e.worktree_path,
})
})
.collect();
let obj = serde_json::json!({
"session": existing.session_name,
"status": display.as_str(),
"tmux_running": alive,
"worktrees": worktrees,
});
println!("{obj}");
return Ok(());
}
println!("Session: {}", existing.session_name);
println!("Status: {} {display}", display.icon());
match display {
session::DisplayStatus::Paused => {
println!(" \u{21b3} run 'git paw start' to resume");
}
session::DisplayStatus::Stale => {
println!(
" \u{21b3} tmux session no longer exists — run 'git paw start' to \
self-heal, or 'git paw purge --stale' to clear it"
);
}
_ => {}
}
println!("Tmux: {}", if alive { "running" } else { "not running" });
println!();
if let (Some(bind), Some(port)) = (&existing.broker_bind, existing.broker_port) {
let url = format!("http://{bind}:{port}");
match broker::probe_broker(&url) {
broker::ProbeResult::LiveBroker => println!("Broker: {url} (running)"),
_ if display == session::DisplayStatus::Paused => {
println!("Broker: {url} (paused \u{2014} run 'git paw start' to resume)");
}
_ => println!("Broker: {url} (not responding)"),
}
println!();
}
if existing.worktrees.is_empty() {
println!(" (no worktrees)");
} else {
for entry in &existing.worktrees {
println!(
" {} \u{2192} {} ({})",
entry.branch,
entry.cli,
entry.worktree_path.display()
);
}
}
Ok(())
}
fn cmd_list_clis() -> Result<(), PawError> {
let cwd = std::env::current_dir()
.map_err(|e| PawError::SessionError(format!("cannot read current directory: {e}")))?;
let repo_root = git::validate_repo(&cwd)?;
let config = config::load_config(&repo_root, None)?;
let custom_defs = config_to_custom_defs(&config);
let clis = detect::detect_clis(&custom_defs);
if clis.is_empty() {
println!("No AI CLIs found.");
println!("Install one or use `git paw add-cli` to register a custom CLI.");
return Ok(());
}
println!("{:<15} {:<10} PATH", "NAME", "SOURCE");
for cli in &clis {
println!(
"{:<15} {:<10} {}",
cli.binary_name,
cli.source,
cli.path.display()
);
}
Ok(())
}
fn cmd_add_cli(name: &str, command: &str, display_name: Option<&str>) -> Result<(), PawError> {
config::add_custom_cli(name, command, display_name)?;
println!("Added custom CLI '{name}'.");
Ok(())
}
fn cmd_replay(
branch: Option<String>,
list: bool,
color: bool,
session: Option<&str>,
) -> Result<(), PawError> {
let cwd = std::env::current_dir()
.map_err(|e| PawError::SessionError(format!("cannot read current directory: {e}")))?;
let repo_root = git::validate_repo(&cwd)?;
if list {
return git_paw::replay::display_list(&repo_root);
}
let branch = branch.expect("branch is required unless --list is passed");
let session_name = git_paw::replay::resolve_session(&repo_root, session)?;
let log_path = git_paw::replay::find_log(&repo_root, &session_name, &branch)?;
if color {
git_paw::replay::replay_colored(&log_path)
} else {
git_paw::replay::replay_stripped(&log_path)
}
}
fn resolve_approvals_session(
repo_root: &Path,
session_flag: Option<&str>,
) -> Result<String, PawError> {
if let Some(name) = session_flag {
return Ok(name.to_string());
}
match session::find_session_for_repo(repo_root)? {
Some(s) => Ok(s.session_name),
None => Err(PawError::SessionError(
"no active session for this repo; pass --session <NAME> to target one".to_string(),
)),
}
}
fn cmd_approvals(
session_flag: Option<&str>,
limit: Option<usize>,
json: bool,
) -> Result<(), PawError> {
use git_paw::supervisor::manual_approvals::{self, AggregatedApproval, Suggestion};
let cwd = std::env::current_dir()
.map_err(|e| PawError::SessionError(format!("cannot read current directory: {e}")))?;
let repo_root = git::validate_repo(&cwd)?;
let session_name = resolve_approvals_session(&repo_root, session_flag)?;
let project_name = git::project_name(&repo_root);
let log_path = manual_approvals::log_path(&repo_root, &session_name);
let mut rows = manual_approvals::aggregate(&log_path)
.map_err(|e| PawError::SessionError(format!("failed to read manual-approvals log: {e}")))?;
if let Some(n) = limit {
rows.truncate(n);
}
let classified: Vec<(AggregatedApproval, Suggestion)> = rows
.into_iter()
.map(|r| {
let s = manual_approvals::suggest_target(&r.pattern, &project_name, "", None);
(r, s)
})
.collect();
if json {
let approvals: Vec<serde_json::Value> = classified
.iter()
.map(|(r, s)| {
serde_json::json!({
"pattern": r.pattern,
"count": r.count,
"suggested_target": s.json_value(),
"first_seen": r.first_seen,
"last_seen": r.last_seen,
})
})
.collect();
let out = serde_json::json!({
"session": session_name,
"approvals": approvals,
});
println!(
"{}",
serde_json::to_string_pretty(&out)
.map_err(|e| PawError::SessionError(format!("failed to serialize JSON: {e}")))?
);
return Ok(());
}
if classified.is_empty() {
println!("no manual approvals recorded for session '{session_name}'");
return Ok(());
}
let pattern_w = classified
.iter()
.map(|(r, _)| r.pattern.len())
.max()
.unwrap_or(0)
.max("PATTERN".len());
let count_w = classified
.iter()
.map(|(r, _)| r.count.to_string().len())
.max()
.unwrap_or(0)
.max("COUNT".len());
println!("{:<pattern_w$} {:>count_w$} SUGGEST", "PATTERN", "COUNT");
for (r, s) in &classified {
println!(
"{:<pattern_w$} {:>count_w$} {}",
r.pattern,
r.count,
s.label()
);
}
Ok(())
}
fn cmd_remove_cli(name: &str) -> Result<(), PawError> {
let cwd = std::env::current_dir()
.map_err(|e| PawError::SessionError(format!("cannot read current directory: {e}")))?;
if let Ok(repo_root) = git::validate_repo(&cwd) {
let config = config::load_config(&repo_root, None)?;
if !config.clis.contains_key(name) {
let detected = detect::detect_known_clis();
if detected.iter().any(|c| c.binary_name == name) {
return Err(PawError::CliNotFound(format!(
"CLI '{name}' is auto-detected, not a custom CLI. Cannot remove."
)));
}
}
}
config::remove_custom_cli(name)?;
println!("Removed custom CLI '{name}'.");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use git_paw::config::SupervisorConfig;
use serial_test::serial;
use std::path::PathBuf;
use std::time::UNIX_EPOCH;
use tempfile::TempDir;
#[test]
fn dispatch_from_all_specs_with_supervisor_routes_to_supervisor_with_all() {
let target = resolve_dispatch_target(&SpecMode::All, true);
assert_eq!(
target,
DispatchTarget::Supervisor {
spec_mode: SpecMode::All
}
);
}
#[test]
fn dispatch_from_all_specs_without_supervisor_routes_to_start_with_specs() {
let target = resolve_dispatch_target(&SpecMode::All, false);
assert_eq!(target, DispatchTarget::StartWithSpecs(SpecMode::All));
}
#[test]
fn dispatch_supervisor_without_specs_routes_to_supervisor_with_none() {
let target = resolve_dispatch_target(&SpecMode::None, true);
assert_eq!(
target,
DispatchTarget::Supervisor {
spec_mode: SpecMode::None
}
);
}
#[test]
fn dispatch_neither_flag_routes_to_start() {
let target = resolve_dispatch_target(&SpecMode::None, false);
assert_eq!(target, DispatchTarget::Start);
}
#[test]
fn dispatch_picker_routes_to_start_with_specs_picker() {
let target = resolve_dispatch_target(&SpecMode::Picker, false);
assert_eq!(target, DispatchTarget::StartWithSpecs(SpecMode::Picker));
}
#[test]
fn dispatch_narrow_routes_to_start_with_specs_narrow() {
let names = vec!["add-auth".to_string()];
let target = resolve_dispatch_target(&SpecMode::Narrow(names.clone()), false);
assert_eq!(
target,
DispatchTarget::StartWithSpecs(SpecMode::Narrow(names))
);
}
#[test]
fn dispatch_narrow_with_supervisor_routes_to_supervisor_with_named_subset() {
let names = vec!["a".to_string(), "b".to_string()];
let target = resolve_dispatch_target(&SpecMode::Narrow(names.clone()), true);
assert_eq!(
target,
DispatchTarget::Supervisor {
spec_mode: SpecMode::Narrow(names)
}
);
}
#[test]
fn dispatch_picker_with_supervisor_routes_to_supervisor_with_picker() {
let target = resolve_dispatch_target(&SpecMode::Picker, true);
assert_eq!(
target,
DispatchTarget::Supervisor {
spec_mode: SpecMode::Picker
}
);
}
#[test]
fn spec_mode_from_flags_translates_each_combination() {
assert_eq!(SpecMode::from_flags(true, None), SpecMode::All);
assert_eq!(SpecMode::from_flags(false, None), SpecMode::None);
let empty: Vec<String> = Vec::new();
assert_eq!(SpecMode::from_flags(false, Some(&empty)), SpecMode::Picker);
let names = vec!["add-auth".to_string(), "fix-session".to_string()];
assert_eq!(
SpecMode::from_flags(false, Some(&names)),
SpecMode::Narrow(names)
);
}
fn make_spec_entry(id: &str, prompt_body: &str) -> git_paw::specs::SpecEntry {
git_paw::specs::SpecEntry {
id: id.to_string(),
backend: git_paw::specs::SpecBackendKind::Markdown,
branch: format!("feat/{id}"),
cli: None,
prompt: prompt_body.to_string(),
owned_files: None,
}
}
#[test]
fn task_prompt_with_spec_points_at_agents_md_and_includes_id() {
let entry = make_spec_entry("my-change", "## 1. First section\n\nbody body body");
let prompt = build_task_prompt(Some(&entry));
assert!(
prompt.contains("AGENTS.md"),
"spec-derived task prompt should point at AGENTS.md, got: {prompt}"
);
assert!(
prompt.contains("openspec/changes/my-change"),
"spec-derived task prompt should include the spec id directory, got: {prompt}"
);
}
#[test]
fn task_prompt_without_spec_uses_default_agents_md_fallback() {
let prompt = build_task_prompt(None);
assert_eq!(
prompt, "Begin your assigned task as described in AGENTS.md.",
"no-spec task prompt should be the existing default fallback verbatim"
);
}
#[test]
fn task_prompt_openspec_backend_invokes_opsx_apply_slash_command() {
let entry = git_paw::specs::SpecEntry {
id: "my-change".to_string(),
backend: git_paw::specs::SpecBackendKind::OpenSpec,
branch: "feat/my-change".to_string(),
cli: None,
prompt: String::new(),
owned_files: None,
};
let prompt = build_task_prompt(Some(&entry));
assert_eq!(
prompt, "/opsx:apply my-change",
"OpenSpec-backed task prompt should be exactly the bare slash command, got: {prompt}"
);
assert!(
!prompt.contains("AGENTS.md"),
"OpenSpec branch must suppress the AGENTS.md pointer prose, got: {prompt}"
);
assert!(
!prompt.contains("openspec/changes/"),
"OpenSpec branch must suppress the openspec/changes/ path prose, got: {prompt}"
);
}
#[test]
fn task_prompt_markdown_backend_uses_generic_agents_md_pointer() {
let entry = git_paw::specs::SpecEntry {
id: "my-feature".to_string(),
backend: git_paw::specs::SpecBackendKind::Markdown,
branch: "feat/my-feature".to_string(),
cli: None,
prompt: String::new(),
owned_files: None,
};
let prompt = build_task_prompt(Some(&entry));
assert!(
prompt.contains("AGENTS.md"),
"Markdown-backed task prompt should point at AGENTS.md, got: {prompt}"
);
assert!(
prompt.contains("openspec/changes/my-feature"),
"Markdown-backed task prompt should include the spec id directory, got: {prompt}"
);
assert!(
!prompt.contains("/opsx:apply"),
"Markdown branch must NOT invoke the OpenSpec slash command, got: {prompt}"
);
}
#[test]
fn task_prompt_without_spec_unchanged_after_backend_introduction() {
let prompt = build_task_prompt(None);
assert_eq!(
prompt, "Begin your assigned task as described in AGENTS.md.",
"no-spec task prompt should be the existing default fallback verbatim"
);
}
#[test]
fn task_prompt_does_not_include_spec_body_first_line() {
let entry = make_spec_entry(
"prompt-submit-fix",
"## 1. Code fix in cmd_supervisor\n\n- [ ] 1.1 Add a constant.\n",
);
let prompt = build_task_prompt(Some(&entry));
assert!(
!prompt.contains("## 1. Code fix in cmd_supervisor"),
"task prompt MUST NOT include the spec body's first heading in raw form, got: {prompt}"
);
assert!(
!prompt.contains("- [ ] 1.1"),
"task prompt MUST NOT include spec body content, got: {prompt}"
);
}
#[test]
fn build_task_prompt_spec_entry_contains_agents_md_and_spec_id() {
let entry = make_spec_entry("governance-config", "## 1. Struct definitions\n\nBody.");
let prompt = build_task_prompt(Some(&entry));
assert!(
prompt.contains("AGENTS.md"),
"spec-derived prompt should point at AGENTS.md, got: {prompt}"
);
assert!(
prompt.contains("openspec/changes/governance-config"),
"spec-derived prompt should embed the spec id directory, got: {prompt}"
);
assert!(
!prompt.contains("## 1. Struct definitions"),
"spec body's first heading MUST NOT leak into the prompt, got: {prompt}"
);
}
#[test]
fn supervisor_pane_prompt_starts_with_boot_block() {
let broker_url = "http://127.0.0.1:9119";
let boot_block = git_paw::skills::build_boot_block("supervisor", broker_url);
let supervisor_framing = "Begin observing the spec implementation session. Your skill (AGENTS.md) describes \
your role — read it, then start the autonomous loop. The user can type questions or \
directives directly into your pane; handle them per the 'When the user types in your \
pane' section of your skill.";
let supervisor_prompt = format!("{boot_block}\n\n{supervisor_framing}");
assert!(
supervisor_prompt.starts_with(&boot_block),
"supervisor pane prompt should begin with the boot block; got:\n{supervisor_prompt}"
);
assert!(
boot_block.contains("\"agent_id\":\"supervisor\""),
"boot block must carry the supervisor agent_id substitution; got:\n{boot_block}"
);
let framing_idx = supervisor_prompt
.find("Begin observing")
.expect("`Begin observing` framing must follow the boot block");
assert!(
framing_idx > boot_block.len(),
"framing must follow the boot block, not precede it; framing_idx={framing_idx}"
);
}
#[test]
fn supervisor_launch_records_boot_delay_constant() {
let src = include_str!("main.rs");
let cmd_start = src
.find("fn cmd_supervisor(")
.expect("cmd_supervisor signature present");
let body_start = src[cmd_start..]
.find('{')
.map(|o| cmd_start + o)
.expect("opening brace");
let mut depth: i32 = 0;
let mut end = body_start;
for (i, ch) in src[body_start..].char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = body_start + i + 1;
break;
}
}
_ => {}
}
}
let body = &src[body_start..end];
let sleep_idx = body
.find("std::thread::sleep")
.expect("cmd_supervisor must call std::thread::sleep before injecting prompts");
let snippet = &body[sleep_idx..(sleep_idx + 200).min(body.len())];
let ms = if let Some(open) = snippet.find("Duration::from_secs(") {
let after = &snippet[open + "Duration::from_secs(".len()..];
let close = after.find(')').expect("matching ) for from_secs");
let n: u64 = after[..close]
.trim()
.parse()
.expect("from_secs argument should parse as u64");
n * 1000
} else if let Some(open) = snippet.find("Duration::from_millis(") {
let after = &snippet[open + "Duration::from_millis(".len()..];
let close = after.find(')').expect("matching ) for from_millis");
after[..close]
.trim()
.parse::<u64>()
.expect("from_millis argument should parse as u64")
} else {
panic!(
"cmd_supervisor's pre-send-keys sleep should use Duration::from_secs / from_millis; got:\n{snippet}"
);
};
assert!(
(1500..=3000).contains(&ms),
"pre-send-keys boot delay must be in [1500ms, 3000ms]; got {ms}ms"
);
}
#[test]
fn build_task_prompt_is_deterministic_and_io_free() {
let entry = make_spec_entry("governance-config", "## body\n\nmore body");
let a = build_task_prompt(Some(&entry));
let b = build_task_prompt(Some(&entry));
assert_eq!(a.as_bytes(), b.as_bytes(), "must be deterministic");
let src = include_str!("main.rs");
let needle = "pub(crate) fn build_task_prompt";
let start = src
.find(needle)
.unwrap_or_else(|| panic!("build_task_prompt signature not found in main.rs"));
let body_start = src[start..].find('{').map_or_else(
|| panic!("opening brace not found after signature"),
|o| start + o,
);
let mut depth: i32 = 0;
let mut end = body_start;
for (i, ch) in src[body_start..].char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = body_start + i + 1;
break;
}
}
_ => {}
}
}
assert!(end > body_start, "did not find closing brace");
let body = &src[body_start..end];
for needle in [
"std::fs::",
"File::open",
"File::create",
"Command::new",
"tokio::fs::",
] {
assert!(
!body.contains(needle),
"build_task_prompt body must not contain `{needle}`; body:\n{body}"
);
}
}
struct StubPrompt {
answer: bool,
called: bool,
}
impl SupervisorPrompt for StubPrompt {
fn ask(&mut self) -> Result<bool, PawError> {
self.called = true;
Ok(self.answer)
}
}
fn cfg_with_supervisor(enabled: bool) -> PawConfig {
PawConfig {
supervisor: Some(SupervisorConfig {
enabled,
..Default::default()
}),
..Default::default()
}
}
#[test]
fn resolve_flag_wins_over_disabled_config() {
let cfg = cfg_with_supervisor(false);
let mut prompt = StubPrompt {
answer: false,
called: false,
};
let result = resolve_supervisor_mode(false, true, false, &cfg, &mut prompt).unwrap();
assert!(result);
assert!(!prompt.called);
}
#[test]
fn resolve_config_enabled_skips_prompt() {
let cfg = cfg_with_supervisor(true);
let mut prompt = StubPrompt {
answer: false,
called: false,
};
let result = resolve_supervisor_mode(false, false, false, &cfg, &mut prompt).unwrap();
assert!(result);
assert!(!prompt.called);
}
#[test]
fn resolve_config_disabled_skips_prompt() {
let cfg = cfg_with_supervisor(false);
let mut prompt = StubPrompt {
answer: true,
called: false,
};
let result = resolve_supervisor_mode(false, false, false, &cfg, &mut prompt).unwrap();
assert!(!result);
assert!(!prompt.called);
}
#[test]
fn resolve_dry_run_no_section_skips_prompt() {
let cfg = PawConfig::default();
let mut prompt = StubPrompt {
answer: true,
called: false,
};
let result = resolve_supervisor_mode(false, false, true, &cfg, &mut prompt).unwrap();
assert!(!result);
assert!(!prompt.called);
}
#[test]
fn resolve_no_section_prompts_and_returns_answer() {
let cfg = PawConfig::default();
let mut prompt = StubPrompt {
answer: true,
called: false,
};
let result = resolve_supervisor_mode(false, false, false, &cfg, &mut prompt).unwrap();
assert!(result);
assert!(prompt.called);
}
#[test]
fn resolve_dry_run_plus_flag_still_enables() {
let cfg = PawConfig::default();
let mut prompt = StubPrompt {
answer: false,
called: false,
};
let result = resolve_supervisor_mode(false, true, true, &cfg, &mut prompt).unwrap();
assert!(result);
}
#[test]
fn resolve_no_supervisor_overrides_config_enabled() {
let cfg = cfg_with_supervisor(true);
let mut prompt = StubPrompt {
answer: true,
called: false,
};
let result = resolve_supervisor_mode(true, false, false, &cfg, &mut prompt).unwrap();
assert!(
!result,
"--no-supervisor must override config enabled = true"
);
assert!(!prompt.called);
}
#[test]
fn resolve_no_supervisor_with_config_disabled_is_idempotent() {
let cfg = cfg_with_supervisor(false);
let mut prompt = StubPrompt {
answer: true,
called: false,
};
let result = resolve_supervisor_mode(true, false, false, &cfg, &mut prompt).unwrap();
assert!(!result);
assert!(!prompt.called);
}
#[test]
fn resolve_no_supervisor_with_no_section_skips_prompt() {
let cfg = PawConfig::default();
let mut prompt = StubPrompt {
answer: true,
called: false,
};
let result = resolve_supervisor_mode(true, false, false, &cfg, &mut prompt).unwrap();
assert!(!result);
assert!(
!prompt.called,
"--no-supervisor must short-circuit before the prompt"
);
}
#[test]
fn resolve_no_supervisor_with_dry_run_disables() {
let cfg = cfg_with_supervisor(true);
let mut prompt = StubPrompt {
answer: true,
called: false,
};
let result = resolve_supervisor_mode(true, false, true, &cfg, &mut prompt).unwrap();
assert!(!result);
assert!(!prompt.called);
}
struct PurgeSandbox {
_sandbox: TempDir,
repo: PathBuf,
sessions_dir: PathBuf,
session: Session,
}
fn git(dir: &Path, args: &[&str]) {
let out = StdCommand::new("git")
.current_dir(dir)
.args(args)
.output()
.expect("git spawn");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn setup_purge_sandbox(with_unmerged_commit: bool) -> PurgeSandbox {
let sandbox = TempDir::new().expect("tempdir");
let repo = sandbox.path().join("repo");
std::fs::create_dir(&repo).expect("mkdir repo");
let sessions_dir = sandbox.path().join("sessions");
git(&repo, &["init", "-q", "-b", "main"]);
git(&repo, &["config", "user.email", "test@test.com"]);
git(&repo, &["config", "user.name", "Test"]);
std::fs::write(repo.join("README.md"), "# test").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", "initial"]);
let wt = git::create_worktree(&repo, "feature/test", false).expect("create worktree");
if with_unmerged_commit {
std::fs::write(wt.path.join("new.txt"), "hello").unwrap();
git(&wt.path, &["add", "."]);
git(&wt.path, &["commit", "-q", "-m", "feature work"]);
}
let session = Session {
session_name: "paw-repo".to_string(),
repo_path: repo.clone(),
project_name: "repo".to_string(),
created_at: UNIX_EPOCH,
status: SessionStatus::Stopped,
worktrees: vec![WorktreeEntry {
branch: "feature/test".to_string(),
worktree_path: wt.path.clone(),
cli: "claude".to_string(),
branch_created: wt.branch_created,
pending_boot_prompt: None,
}],
broker_port: None,
broker_bind: None,
broker_log_path: None,
mode: SessionMode::Bare,
dashboard_pane: None,
};
session::save_session_in(&session, &sessions_dir).expect("save session");
PurgeSandbox {
_sandbox: sandbox,
repo,
sessions_dir,
session,
}
}
#[test]
#[serial]
fn purge_no_unmerged_commits_runs_without_warning() {
let sb = setup_purge_sandbox(false);
let worktree_path = sb.session.worktrees[0].worktree_path.clone();
let mut confirm_calls = 0;
let mut confirm = |_: &str| -> Result<bool, PawError> {
confirm_calls += 1;
Ok(true)
};
let mut stderr = Vec::<u8>::new();
let outcome = purge_with_prompt(
&sb.repo,
&sb.sessions_dir,
&sb.session,
false,
&mut confirm,
&mut |_: &str| Ok(()),
&mut stderr,
)
.unwrap();
assert_eq!(outcome, PurgeOutcome::Purged);
assert_eq!(confirm_calls, 1);
let err_text = String::from_utf8(stderr).unwrap();
assert!(
!err_text.contains("unmerged"),
"stderr should not mention unmerged: {err_text:?}"
);
assert!(!worktree_path.exists(), "worktree should be removed");
assert!(
!sb.sessions_dir.join("paw-repo.json").exists(),
"session file should be removed"
);
}
#[test]
#[serial]
fn purge_with_unmerged_commits_emits_warning_to_stderr() {
let sb = setup_purge_sandbox(true);
let mut confirm = |_: &str| -> Result<bool, PawError> { Ok(true) };
let mut stderr = Vec::<u8>::new();
let outcome = purge_with_prompt(
&sb.repo,
&sb.sessions_dir,
&sb.session,
false,
&mut confirm,
&mut |_: &str| Ok(()),
&mut stderr,
)
.unwrap();
assert_eq!(outcome, PurgeOutcome::Purged);
let err_text = String::from_utf8(stderr).unwrap();
assert!(
err_text.contains("Warning:") && err_text.contains("unmerged commits"),
"stderr should contain unmerged warning: {err_text:?}"
);
assert!(
err_text.contains("feature/test"),
"stderr should name the branch: {err_text:?}"
);
assert!(
err_text.contains("irreversible"),
"stderr should warn about data loss: {err_text:?}"
);
}
#[test]
#[serial]
fn purge_force_skips_confirm_but_still_warns() {
let sb = setup_purge_sandbox(true);
let mut confirm_calls = 0;
let mut confirm = |_: &str| -> Result<bool, PawError> {
confirm_calls += 1;
Ok(false)
};
let mut stderr = Vec::<u8>::new();
let outcome = purge_with_prompt(
&sb.repo,
&sb.sessions_dir,
&sb.session,
true, &mut confirm,
&mut |_: &str| Ok(()),
&mut stderr,
)
.unwrap();
assert_eq!(outcome, PurgeOutcome::Purged);
assert_eq!(confirm_calls, 0, "force should not invoke confirm");
let err_text = String::from_utf8(stderr).unwrap();
assert!(
err_text.contains("Warning:") && err_text.contains("unmerged commits"),
"force mode should still warn: {err_text:?}"
);
}
#[test]
#[serial]
fn purge_cancelled_leaves_worktree_in_place() {
let sb = setup_purge_sandbox(true);
let worktree_path = sb.session.worktrees[0].worktree_path.clone();
let mut confirm = |_: &str| -> Result<bool, PawError> { Ok(false) };
let mut tmux_calls = 0;
let mut kill_tmux = |_: &str| -> Result<(), PawError> {
tmux_calls += 1;
Ok(())
};
let mut stderr = Vec::<u8>::new();
let outcome = purge_with_prompt(
&sb.repo,
&sb.sessions_dir,
&sb.session,
false,
&mut confirm,
&mut kill_tmux,
&mut stderr,
)
.unwrap();
assert_eq!(outcome, PurgeOutcome::Cancelled);
assert_eq!(tmux_calls, 0, "tmux must not be killed on cancel");
assert!(worktree_path.exists(), "worktree must remain on cancel");
assert!(
sb.sessions_dir.join("paw-repo.json").exists(),
"session file must remain on cancel"
);
}
struct OrderedWrite {
events: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
}
impl std::io::Write for OrderedWrite {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.events
.borrow_mut()
.push(format!("write({})", buf.len()));
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
self.events.borrow_mut().push("flush".to_string());
Ok(())
}
}
#[test]
#[serial]
fn purge_emits_per_worktree_progress_messages() {
let sb = setup_purge_sandbox(false);
let worktree_path_display = sb.session.worktrees[0].worktree_path.display().to_string();
let mut confirm = |_: &str| -> Result<bool, PawError> { Ok(true) };
let mut stderr = Vec::<u8>::new();
let outcome = purge_with_prompt(
&sb.repo,
&sb.sessions_dir,
&sb.session,
true, &mut confirm,
&mut |_: &str| Ok(()),
&mut stderr,
)
.unwrap();
assert_eq!(outcome, PurgeOutcome::Purged);
let err_text = String::from_utf8(stderr).unwrap();
assert!(
err_text.contains(&format!("Removing worktree {worktree_path_display}")),
"stderr should announce each worktree removal by path; got:\n{err_text}"
);
assert!(
err_text.contains("...done ("),
"stderr should emit a `...done (Xs)` marker per worktree once removal completes; got:\n{err_text}"
);
}
#[test]
#[serial]
fn purge_with_unmerged_commits_flushes_stderr_before_confirm() {
let sb = setup_purge_sandbox(true);
let events: std::rc::Rc<std::cell::RefCell<Vec<String>>> =
std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let events_for_confirm = std::rc::Rc::clone(&events);
let mut confirm = |_: &str| -> Result<bool, PawError> {
events_for_confirm.borrow_mut().push("confirm".to_string());
Ok(true)
};
let mut stderr = OrderedWrite {
events: std::rc::Rc::clone(&events),
};
let outcome = purge_with_prompt(
&sb.repo,
&sb.sessions_dir,
&sb.session,
false,
&mut confirm,
&mut |_: &str| Ok(()),
&mut stderr,
)
.unwrap();
assert_eq!(outcome, PurgeOutcome::Purged);
let recorded = events.borrow().clone();
let confirm_idx = recorded
.iter()
.position(|s| s == "confirm")
.expect("confirm must have been called");
let flush_before_confirm_idx = recorded[..confirm_idx]
.iter()
.rposition(|s| s == "flush")
.expect("stderr.flush() must have been called before the prompt fired");
let has_write_before_flush = recorded[..flush_before_confirm_idx]
.iter()
.any(|s| s.starts_with("write("));
assert!(
has_write_before_flush,
"at least one stderr write must precede the pre-confirm flush; events: {recorded:?}"
);
}
#[test]
fn disclosure_notice_prints_when_learnings_enabled() {
let cfg = SupervisorConfig {
enabled: true,
learnings: true,
..SupervisorConfig::default()
};
let notice =
learnings_disclosure_notice(Some(&cfg)).expect("notice must print when opted in");
assert!(
notice.contains(".git-paw/session-learnings.md"),
"notice must name the local path; got: {notice}"
);
assert!(
notice.contains("no telemetry") && notice.contains("nothing is sent anywhere"),
"notice must state the no-telemetry stance; got: {notice}"
);
assert!(
notice.contains(GIT_PAW_ISSUES_URL),
"notice must invite sharing via the canonical issues URL; got: {notice}"
);
assert!(
notice.contains("anonymise"),
"notice must carry the review/anonymise caveat; got: {notice}"
);
}
#[test]
fn no_disclosure_notice_when_learnings_disabled() {
let cfg = SupervisorConfig {
enabled: true,
learnings: false,
..SupervisorConfig::default()
};
assert!(
learnings_disclosure_notice(Some(&cfg)).is_none(),
"no notice when learnings is disabled"
);
}
#[test]
fn no_disclosure_notice_when_supervisor_section_absent() {
assert!(
learnings_disclosure_notice(None).is_none(),
"no notice when the [supervisor] section is absent"
);
}
#[test]
fn no_disclosure_notice_when_supervisor_disabled() {
let cfg = SupervisorConfig {
enabled: false,
learnings: true,
..SupervisorConfig::default()
};
assert!(
learnings_disclosure_notice(Some(&cfg)).is_none(),
"no notice when supervisor mode itself is disabled"
);
}
#[test]
fn learnings_doc_carries_privacy_and_sharing_section() {
let doc = include_str!("../docs/src/user-guide/learnings.md");
assert!(
doc.contains("## Privacy & Sharing"),
"learnings doc must carry a Privacy & Sharing section"
);
assert!(
doc.contains("no telemetry"),
"section must state the no-telemetry / local / opt-in stance"
);
assert!(
doc.contains(GIT_PAW_ISSUES_URL),
"section must link to the GitHub issue tracker for optional sharing"
);
assert!(
doc.contains("anonymise"),
"section must carry the review-and-anonymise caveat"
);
}
}
#[cfg(test)]
mod supervisor_self_register_tests {
use std::sync::Arc;
use git_paw::broker;
use git_paw::broker::delivery;
use git_paw::broker::messages::BrokerMessage;
use super::build_status_message;
#[test]
fn supervisor_boot_status_message_has_spec_fields() {
let msg = build_status_message(
"supervisor",
"working",
Some("Supervisor booting".to_string()),
None,
);
match msg {
BrokerMessage::Status { agent_id, payload } => {
assert_eq!(agent_id, "supervisor");
assert_eq!(payload.status, "working");
assert_eq!(payload.message.as_deref(), Some("Supervisor booting"));
assert!(payload.modified_files.is_empty());
}
_ => panic!("expected BrokerMessage::Status"),
}
}
#[test]
fn publish_message_with_supervisor_boot_registers_supervisor() {
let state = Arc::new(broker::BrokerState::new(None));
let msg = build_status_message(
"supervisor",
"working",
Some("Supervisor booting".to_string()),
None,
);
delivery::publish_message(&state, &msg);
let inner = state.read();
let record = inner
.agents
.get("supervisor")
.expect("supervisor agent record exists");
assert_eq!(record.status, "working");
}
#[test]
fn supervisor_appears_in_agent_status_snapshot_after_boot() {
let state = Arc::new(broker::BrokerState::new(None));
let msg = build_status_message(
"supervisor",
"working",
Some("Supervisor booting".to_string()),
None,
);
delivery::publish_message(&state, &msg);
let snapshot = delivery::agent_status_snapshot(&state);
let entry = snapshot
.iter()
.find(|e| e.agent_id == "supervisor")
.expect("supervisor row appears in snapshot");
assert_eq!(entry.status, "working");
}
}
#[cfg(test)]
mod supervisor_question_tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicU16, Ordering};
use git_paw::broker::messages::BrokerMessage;
use git_paw::broker::{self, BrokerState, delivery};
use git_paw::config::BrokerConfig;
use super::publish_supervisor_question;
static PORT_COUNTER: AtomicU16 = AtomicU16::new(0);
fn spawn_broker() -> (broker::BrokerHandle, String) {
#[allow(clippy::cast_possible_truncation)]
let base = 30_000 + (std::process::id() as u16 % 5000);
let offset = PORT_COUNTER.fetch_add(1, Ordering::SeqCst);
let mut port = base + offset;
let mut attempts = 0;
loop {
let cfg = BrokerConfig {
enabled: true,
port,
bind: "127.0.0.1".to_string(),
..Default::default()
};
match broker::start_broker(&cfg, BrokerState::new(None), Vec::new()) {
Ok(handle) => {
let url = cfg.url();
return (handle, url);
}
Err(_) if attempts < 10 => {
port += 100;
attempts += 1;
}
Err(e) => panic!("failed to start test broker: {e}"),
}
}
}
fn curl_available() -> bool {
which::which("curl").is_ok()
}
#[test]
fn publish_supervisor_question_routes_to_supervisor_inbox() {
if !curl_available() {
eprintln!("skipping: curl not available on PATH");
return;
}
let (handle, url) = spawn_broker();
let state: Arc<BrokerState> = Arc::clone(&handle.state);
publish_supervisor_question("Continue with this approach?", &url)
.expect("publish should succeed against a live broker");
let mut found: Option<BrokerMessage> = None;
for _ in 0..40 {
let (msgs, _) = delivery::poll_messages(&state, "supervisor", 0);
if let Some(msg) = msgs.into_iter().next() {
found = Some(msg);
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
let msg = found.expect("supervisor inbox should receive the question");
match msg {
BrokerMessage::Question { agent_id, payload } => {
assert_eq!(agent_id, "supervisor");
assert_eq!(payload.question, "Continue with this approach?");
}
other => panic!("expected BrokerMessage::Question, got {other:?}"),
}
}
#[test]
fn publish_supervisor_question_preserves_quotes_in_question_text() {
if !curl_available() {
eprintln!("skipping: curl not available on PATH");
return;
}
let (handle, url) = spawn_broker();
let state: Arc<BrokerState> = Arc::clone(&handle.state);
let question = r#"Should I use "bcrypt" or argon2?"#;
publish_supervisor_question(question, &url)
.expect("publish should succeed with embedded quotes");
let mut found: Option<BrokerMessage> = None;
for _ in 0..40 {
let (msgs, _) = delivery::poll_messages(&state, "supervisor", 0);
if let Some(msg) = msgs.into_iter().next() {
found = Some(msg);
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
let msg = found.expect("supervisor inbox should receive the question");
match msg {
BrokerMessage::Question { agent_id, payload } => {
assert_eq!(agent_id, "supervisor");
assert!(
payload.question.contains("bcrypt"),
"stored question must include the literal word 'bcrypt'; got: {:?}",
payload.question
);
assert!(
payload.question.contains("argon2"),
"stored question must include the literal word 'argon2'; got: {:?}",
payload.question
);
}
other => panic!("expected BrokerMessage::Question, got {other:?}"),
}
}
#[test]
fn publish_supervisor_question_returns_error_when_broker_unreachable() {
if !curl_available() {
eprintln!("skipping: curl not available on PATH");
return;
}
let result = publish_supervisor_question("anything", "http://127.0.0.1:1");
assert!(
result.is_err(),
"publishing to an unreachable broker must error"
);
}
}
#[cfg(test)]
mod submit_delay_tests {
use std::collections::HashMap;
use git_paw::config::{CustomCli, PawConfig};
use super::resolve_submit_delay_ms;
fn config_with(cli: &str, submit_delay_ms: Option<u64>) -> PawConfig {
let mut clis = HashMap::new();
clis.insert(
cli.to_string(),
CustomCli {
command: cli.to_string(),
display_name: None,
submit_delay_ms,
settings_path: None,
},
);
PawConfig {
clis,
..PawConfig::default()
}
}
#[test]
fn unknown_or_unconfigured_cli_uses_agnostic_default() {
let cfg = PawConfig::default();
assert_eq!(
resolve_submit_delay_ms("any-cli", &cfg),
git_paw::DEFAULT_SUBMIT_DELAY_MS,
);
}
#[test]
fn custom_cli_submit_delay_override_is_honoured() {
let cfg = config_with("mycli", Some(2500));
assert_eq!(resolve_submit_delay_ms("mycli", &cfg), 2500);
}
#[test]
fn custom_cli_without_override_falls_back_to_default() {
let cfg = config_with("mycli", None);
assert_eq!(
resolve_submit_delay_ms("mycli", &cfg),
git_paw::DEFAULT_SUBMIT_DELAY_MS,
);
}
#[test]
fn lookup_keys_on_the_binary_not_the_flags() {
let cfg = config_with("mycli", Some(2500));
assert_eq!(
resolve_submit_delay_ms("mycli --dangerously-skip-permissions", &cfg),
2500,
);
}
#[test]
fn no_cli_name_is_hardcoded_in_the_resolver() {
let cfg = PawConfig::default();
for cli in ["claude", "claude-oss", "gemini", "codex", "whatever"] {
assert_eq!(
resolve_submit_delay_ms(cli, &cfg),
git_paw::DEFAULT_SUBMIT_DELAY_MS,
"{cli} must use the agnostic default, not a hardcoded value"
);
}
}
fn config_with_settings_path(cli: &str, settings_path: Option<String>) -> PawConfig {
let mut clis = HashMap::new();
clis.insert(
cli.to_string(),
CustomCli {
command: cli.to_string(),
display_name: None,
submit_delay_ms: None,
settings_path,
},
);
PawConfig {
clis,
..PawConfig::default()
}
}
#[test]
fn configured_settings_paths_returns_targets_with_existing_parents() {
let dir = tempfile::TempDir::new().unwrap();
let target = dir.path().join("settings.json");
let cfg = config_with_settings_path("mycli", Some(target.to_string_lossy().into_owned()));
let paths = super::configured_settings_paths(&cfg);
assert_eq!(
paths,
vec![target],
"configured path with existing parent is returned"
);
}
#[test]
fn configured_settings_paths_skips_targets_with_absent_parent() {
let dir = tempfile::TempDir::new().unwrap();
let target = dir.path().join("missing-subdir").join("settings.json");
let cfg = config_with_settings_path("mycli", Some(target.to_string_lossy().into_owned()));
assert!(
super::configured_settings_paths(&cfg).is_empty(),
"a configured path whose parent is absent must be skipped",
);
}
#[test]
fn configured_settings_paths_empty_when_no_clis() {
assert!(super::configured_settings_paths(&PawConfig::default()).is_empty());
}
}