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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions src/assistant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,25 @@ pub enum AssistantStatus {
Archived,
}

/// One worktree entry tracked by a Reviewer assistant.
/// One worktree entry tracked by a worktree-backed assistant.
///
/// `agman_created` records whether agman set up the worktree (and the local
/// branch) itself — drives archive cleanup. Worktrees that already existed
/// when the reviewer was created are left intact on archive.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewerWorktree {
pub struct AssistantWorktree {
pub repo: String,
pub branch: String,
pub path: PathBuf,
pub agman_created: bool,
}

/// Discriminator for the two assistant kinds. Each kind carries its own
#[derive(Default, Serialize, Deserialize, Clone, Copy, Debug)]
pub struct TesterCapabilities {
pub browser: bool,
}

/// Discriminator for the assistant kinds. Each kind carries its own
/// kind-specific metadata; everything else (harness stamping, inbox, tmux,
/// telegram switcher, send-message routing, poll-target enumeration) is
/// kind-agnostic and lives on the surrounding [`AssistantMeta`].
Expand All @@ -46,7 +51,13 @@ pub enum AssistantKind {
},
Reviewer {
#[serde(default)]
worktrees: Vec<ReviewerWorktree>,
worktrees: Vec<AssistantWorktree>,
},
Tester {
#[serde(default)]
worktrees: Vec<AssistantWorktree>,
#[serde(default)]
capabilities: TesterCapabilities,
},
}

Expand Down Expand Up @@ -172,6 +183,11 @@ impl Assistant {
matches!(self.meta.kind, AssistantKind::Reviewer { .. })
}

/// True if this assistant is a Tester.
pub fn is_tester(&self) -> bool {
matches!(self.meta.kind, AssistantKind::Tester { .. })
}

/// Write meta.json to disk. Stamps `updated_at` before writing.
pub fn save_meta(&mut self) -> Result<()> {
self.meta.updated_at = Utc::now();
Expand Down
47 changes: 39 additions & 8 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ EXAMPLES:
agman send-message chief-of-staff @./message.md")]
SendMessage {
/// Target: "chief-of-staff", "telegram", "researcher:<project>--<name>",
/// "reviewer:<project>--<name>", or a project name (for the PM)
/// "reviewer:<project>--<name>", "tester:<project>--<name>", or a project name (for the PM)
target: String,
/// Message text (can also be provided via stdin or --file)
#[arg(allow_hyphen_values = true)]
Expand Down Expand Up @@ -208,17 +208,20 @@ EXAMPLES:
file: Option<std::path::PathBuf>,
},

/// Create an assistant (researcher or reviewer). Defaults to Chief of
/// Create an assistant (researcher, reviewer, or tester). Defaults to Chief of
/// Staff-level when --project is omitted.
#[command(after_help = "\
EXAMPLES:
agman create-assistant --kind researcher --name api-investigator --description \"Investigate the API latency\"
agman create-assistant --kind reviewer --name pr-1247 --project reviews \\
--branch galoy:fix-deposit-flow \\
--branch lana-dashboard:fix-deposit-flow \\
--description \"Review the cross-repo deposit fix\"")]
--description \"Review the cross-repo deposit fix\"
agman create-assistant --kind tester --name browser-pass --project reviews \\
--branch galoy:fix-deposit-flow --browser \\
--description \"Exercise the deposit flow in browser\"")]
CreateAssistant {
/// Assistant kind: researcher or reviewer
/// Assistant kind: researcher, reviewer, or tester
#[arg(long, value_enum)]
kind: AssistantKindArg,
/// Assistant name (alphanumeric + hyphens)
Expand All @@ -230,7 +233,7 @@ EXAMPLES:
/// Description/initial question
#[arg(long, short, allow_hyphen_values = true)]
description: Option<String>,
// --- Researcher-only flags (rejected for reviewer) ---
// --- Researcher-only flags (rejected for reviewer/tester) ---
/// Repository name (researcher only — for working directory context)
#[arg(long)]
repo: Option<String>,
Expand All @@ -240,11 +243,14 @@ EXAMPLES:
/// Task ID to inherit working directory from (researcher only)
#[arg(long)]
task: Option<String>,
// --- Reviewer-only flag (repeatable; rejected for researcher) ---
/// `<repo>:<branch>` pair to scope the reviewer to (reviewer only;
/// repeat to include multiple). Required for reviewers.
// --- Worktree-backed flags (repeatable; rejected for researcher) ---
/// `<repo>:<branch>` pair to scope the reviewer/tester to; repeat
/// to include multiple. Required for reviewers and testers.
#[arg(long = "branch", value_name = "REPO:BRANCH")]
branch_pair: Vec<String>,
/// Request browser automation tools (tester only)
#[arg(long, default_value_t = false)]
browser: bool,
},

/// List assistants
Expand Down Expand Up @@ -319,6 +325,30 @@ EXAMPLES:
description: Option<String>,
},

/// Create a tester (alias for `create-assistant --kind tester`).
#[command(after_help = "\
EXAMPLES:
agman create-tester --name browser-pass --project reviews \\
--branch galoy:fix-deposit-flow --browser \\
--description \"Exercise the deposit flow\"")]
CreateTester {
/// Tester name (alphanumeric + hyphens)
#[arg(long, short)]
name: String,
/// Project name (defaults to "chief-of-staff")
#[arg(long)]
project: Option<String>,
/// `<repo>:<branch>` pair (repeatable, required at least once)
#[arg(long = "branch", value_name = "REPO:BRANCH", required = true)]
branch_pair: Vec<String>,
/// Request browser automation tools
#[arg(long, default_value_t = false)]
browser: bool,
/// Description
#[arg(long, short, allow_hyphen_values = true)]
description: Option<String>,
},

/// List researchers (alias for `list-assistants --kind researcher`).
ListResearchers {
/// Filter by project name
Expand Down Expand Up @@ -357,6 +387,7 @@ EXAMPLES:
pub enum AssistantKindArg {
Researcher,
Reviewer,
Tester,
}

#[derive(Debug, Clone, Copy, ValueEnum)]
Expand Down
7 changes: 6 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ impl Config {

// --- Assistant paths ---
//
// Both Researchers and Reviewers share the on-disk layout under
// Assistants share the on-disk layout under
// `~/.agman/assistants/<project>--<name>/`. The kind discriminator lives
// inside `meta.json`. Tmux session names diverge by kind so a researcher
// and reviewer with the same name+project don't collide on resume.
Expand Down Expand Up @@ -363,6 +363,11 @@ impl Config {
format!("agman-reviewer-{project}--{name}")
}

/// Tmux session name for a tester.
pub fn tester_tmux_session(project: &str, name: &str) -> String {
format!("agman-tester-{project}--{name}")
}

// --- Project template paths ---

/// Directory where project templates are stored: ~/.agman/project-templates/
Expand Down
3 changes: 3 additions & 0 deletions src/harness/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ impl Harness for ClaudeHarness {
cmd.push_str(&format!(" --resume '{}'", escaped_uuid));
}
}
if ctx.capabilities.browser {
cmd.push_str(" --chrome");
}
cmd
}

Expand Down
93 changes: 92 additions & 1 deletion src/harness/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ use anyhow::Result;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};

use super::{Harness, HarnessKind, LaunchContext, RegisterContext, SessionKey};
use super::{
AssistantCapabilities, Harness, HarnessKind, LaunchContext, RegisterContext, SessionKey,
};

pub struct CodexHarness;

Expand Down Expand Up @@ -43,6 +45,9 @@ impl Harness for CodexHarness {
if ctx.no_alt_screen {
cmd.push_str(" --no-alt-screen");
}
if ctx.capabilities.browser {
cmd.push_str(" -c 'mcp_servers.playwright.enabled=true'");
}
cmd.push_str(&format!(" -C '{}'", cwd_str));
cmd.push_str(&format!(" resume '{}'", escaped_name));
return cmd;
Expand All @@ -69,6 +74,9 @@ impl Harness for CodexHarness {
cmd.push_str(" --no-alt-screen");
}
cmd.push_str(&format!(" -c '{}'", dev_arg_escaped));
if ctx.capabilities.browser {
cmd.push_str(" -c 'mcp_servers.playwright.enabled=true'");
}
cmd
}

Expand All @@ -79,6 +87,14 @@ impl Harness for CodexHarness {
ensure_workspace_trusted_in(&trust_file, cwd)
}

fn ensure_capabilities_configured(&self, caps: &AssistantCapabilities) -> Result<()> {
if caps.browser {
let config_toml = super::harness_home(HarnessKind::Codex).join("config.toml");
ensure_browser_mcp_in(&config_toml)?;
}
Ok(())
}

/// Paste-inject `/rename <name>` post-launch and verify the entry shows
/// up in `~/.codex/session_index.jsonl`. Self-verifying with retry: codex
/// step 2+ relaunches faster than first launch (file watchers warm, no
Expand Down Expand Up @@ -380,6 +396,81 @@ pub fn ensure_workspace_trusted_in(trust_file: &Path, cwd: &Path) -> Result<()>
write_atomically(trust_file, toml::to_string(&doc)?.as_bytes())
}

/// Ensure the Playwright MCP server is defined in codex config but disabled
/// by default. Tester launches opt in per process with a `-c` override.
pub fn ensure_browser_mcp_in(config_toml_path: &Path) -> Result<()> {
use anyhow::Context;

let mut doc: toml::Value = if config_toml_path.exists() {
let text = std::fs::read_to_string(config_toml_path)
.with_context(|| format!("read codex config file at {}", config_toml_path.display()))?;
if text.trim().is_empty() {
toml::Value::Table(toml::value::Table::new())
} else {
toml::from_str(&text).with_context(|| {
format!(
"parse codex config file at {} as TOML",
config_toml_path.display()
)
})?
}
} else {
toml::Value::Table(toml::value::Table::new())
};

let root = doc.as_table_mut().ok_or_else(|| {
anyhow::anyhow!(
"codex config file at {} is not a TOML table",
config_toml_path.display()
)
})?;

let mcp_servers = root
.entry("mcp_servers".to_string())
.or_insert_with(|| toml::Value::Table(toml::value::Table::new()))
.as_table_mut()
.ok_or_else(|| {
anyhow::anyhow!(
"codex config file at {} has non-table `mcp_servers`",
config_toml_path.display()
)
})?;

if mcp_servers.contains_key("playwright") {
return Ok(());
}

let mut playwright = toml::value::Table::new();
playwright.insert(
"command".to_string(),
toml::Value::String("npx".to_string()),
);
playwright.insert(
"args".to_string(),
toml::Value::Array(vec![toml::Value::String(
"@playwright/mcp@latest".to_string(),
)]),
);
playwright.insert(
"env_vars".to_string(),
toml::Value::Array(
[
"DISPLAY",
"WAYLAND_DISPLAY",
"XAUTHORITY",
"XDG_RUNTIME_DIR",
]
.into_iter()
.map(|v| toml::Value::String(v.to_string()))
.collect(),
),
);
playwright.insert("enabled".to_string(), toml::Value::Boolean(false));
mcp_servers.insert("playwright".to_string(), toml::Value::Table(playwright));

write_atomically(config_toml_path, toml::to_string(&doc)?.as_bytes())
}

/// Write `bytes` to `dest` atomically: write to `<dest>.tmp`, fsync, rename.
/// Creates `dest`'s parent directory if missing.
fn write_atomically(dest: &Path, bytes: &[u8]) -> Result<()> {
Expand Down
22 changes: 22 additions & 0 deletions src/harness/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ pub fn ensure_workspace_trusted_for_test(
}
}

/// Test-only entrypoint for codex browser MCP configuration. Production uses
/// `CodexHarness::ensure_capabilities_configured`, which resolves the config
/// path from `harness_home(Codex)`.
#[doc(hidden)]
pub fn ensure_browser_mcp_for_test(config_toml_path: &Path) -> Result<()> {
codex::ensure_browser_mcp_in(config_toml_path)
}

/// Identifies which harness to use. Persisted in config + per-agent stamps.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
Expand Down Expand Up @@ -170,6 +178,12 @@ pub enum SessionKey<'a> {
Resume(&'a str),
}

/// Optional assistant capabilities requested at launch time.
#[derive(Default, Clone, Copy, Debug)]
pub struct AssistantCapabilities {
pub browser: bool,
}

/// Static input for `Harness::build_session_command`. Names follow the
/// harness's resume / session-listing convention so the user can reattach
/// manually from a shell (`claude --resume <id>`, `codex resume <name>`, or
Expand Down Expand Up @@ -197,6 +211,8 @@ pub struct LaunchContext<'a> {
/// pane content (needed by the inbox snippet-verification loop). Claude
/// ignores.
pub no_alt_screen: bool,
/// Optional assistant capabilities. Non-assistant launches pass default.
pub capabilities: AssistantCapabilities,
/// Whether this launch pins a fresh session, resumes a prior one, or
/// neither. See `SessionKey` for the per-variant behaviour.
pub session_key: SessionKey<'a>,
Expand Down Expand Up @@ -240,6 +256,12 @@ pub trait Harness: Send + Sync {
/// usable state and `/rename` paste-injects run as shell commands).
fn ensure_workspace_trusted(&self, cwd: &Path) -> Result<()>;

/// Ensure any requested assistant capabilities are configured before
/// launch. Harnesses that do not need setup use the default no-op.
fn ensure_capabilities_configured(&self, _caps: &AssistantCapabilities) -> Result<()> {
Ok(())
}

/// Post-launch step. Called once `is_session_ready_in` reports the
/// foreground process is no longer a shell.
/// - Claude: no-op.
Expand Down
Loading
Loading