use std::io::{self, Write};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use agent_coordinator_acp::{AcpHarness, RunOptions};
use agent_coordinator_core::{
ApprovalHandler, ApprovalMode, ApprovalRequest, Budget, Decision, Event, EventSink,
FileRunStore, HarnessSpec, McpServerSpec, RunSpec, now_ms,
};
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let Some(command) = args.first().map(String::as_str) else {
usage();
std::process::exit(2);
};
match command {
"run" => run(&args[1..]),
"presets" => presets(&args[1..]),
"-h" | "--help" | "help" => usage(),
"-V" | "--version" => println!("ac {}", env!("CARGO_PKG_VERSION")),
other => {
eprintln!("未知命令:{other}");
usage();
std::process::exit(2);
}
}
}
fn usage() {
println!(
"\
ac {} —— 把一个有界任务派给用户选的 harness
用法:
ac run --workspace <DIR> --task <TEXT> --harness <COMMAND> [选项]
ac presets [--json] 看真机矩阵(--json 给机器读)
run 选项:
--harness <CMD> ACP agent 可执行文件(如 opencode / gemini / npx)
--arg <A> 传给 harness 的参数,可重复(如 --arg acp)
--workspace <DIR> 唯一工作区(绝对路径)
--task <TEXT> 有界目标,一句话能验收那种
--mcp <CMD> 注入给 harness 的 MCP server 命令(工具面)
--mcp-arg <A> MCP server 参数,可重复
--mcp-name <NAME> 工具面名字(默认 notes)
--approval <MODE> auto | ask | deny(默认 auto)
--minutes <N> 时间预算(默认 30)
--model <M> 指定模型(harness 自己决定是否支持)
--resume <ID|last> 续已有会话;last = 最近一条带 session_id 的记录
(要求 harness 声明 session/load;不支持时报错,不新开)
--timeout-secs <N> 覆盖超时(调试/测试用)
--store <DIR> 运行记录目录(默认 <workspace>/.ac-runs)
--json 事件与结果都按 JSON 行输出
例:
ac run --workspace . --task \"把这段剪报整理成三条要点\" \\
--harness opencode --arg acp \\
--mcp node --mcp-arg tools.js --mcp-arg mcp",
env!("CARGO_PKG_VERSION")
);
}
const MATRIX: &str = include_str!("../data/harness-matrix.json");
#[derive(serde::Deserialize)]
struct Matrix {
updated_at: String,
#[serde(default)]
default_for_experiments: Vec<String>,
harnesses: Vec<MatrixEntry>,
}
#[derive(serde::Deserialize)]
struct MatrixEntry {
id: String,
command: String,
status: String,
#[serde(default)]
version: Option<String>,
#[serde(default)]
checked_at: Option<String>,
#[serde(default)]
notes: Option<String>,
#[serde(default)]
blocker: Option<String>,
}
fn presets(args: &[String]) {
if args.iter().any(|arg| arg == "--json") {
print!("{MATRIX}");
return;
}
let matrix: Matrix = match serde_json::from_str(MATRIX) {
Ok(matrix) => matrix,
Err(error) => {
eprintln!("harness-matrix.json 解析失败:{error}");
std::process::exit(1);
}
};
println!(
"{:<14} {:<50} {:<8} {:<20} 备注",
"id", "command", "真机", "核于 · 版本"
);
for entry in &matrix.harnesses {
let tier = match entry.status.as_str() {
"verified" => "跑通",
"handshake" => "握手通",
_ => "未核",
};
let stamp = format!(
"{} · {}",
entry.checked_at.as_deref().unwrap_or("—"),
entry.version.as_deref().unwrap_or("—")
);
let note = entry
.blocker
.clone()
.or_else(|| entry.notes.clone())
.unwrap_or_default();
println!(
"{:<14} {:<50} {tier:<8} {stamp:<20} {note}",
entry.id, entry.command
);
}
let defaults = if matrix.default_for_experiments.is_empty() {
"—".to_string()
} else {
matrix.default_for_experiments.join(" / ")
};
println!(
"\n矩阵最后更新:{}。实验默认用:{defaults}(其余先别拿来做基线)。\
\n没跑过的写『未核』,不许写成『应该可以』;机器可读:ac presets --json。",
matrix.updated_at
);
}
fn value_at(args: &[String], index: &mut usize, flag: &str) -> String {
*index += 1;
match args.get(*index) {
Some(value) => value.clone(),
None => {
eprintln!("{flag} 需要值");
std::process::exit(2);
}
}
}
fn run(args: &[String]) {
let mut workspace: Option<PathBuf> = None;
let mut task: Option<String> = None;
let mut harness_command: Option<String> = None;
let mut harness_args: Vec<String> = Vec::new();
let mut harness_id = "custom".to_string();
let mut mcp_command: Option<String> = None;
let mut mcp_args: Vec<String> = Vec::new();
let mut mcp_name = "notes".to_string();
let mut approval = ApprovalMode::Auto;
let mut minutes: u64 = 30;
let mut model: Option<String> = None;
let mut resume: Option<String> = None;
let mut timeout_secs: Option<u64> = None;
let mut store_dir: Option<PathBuf> = None;
let mut json = false;
let mut index = 0;
while index < args.len() {
let flag = args[index].as_str();
match flag {
"--workspace" => workspace = Some(PathBuf::from(value_at(args, &mut index, flag))),
"--task" | "-t" => task = Some(value_at(args, &mut index, flag)),
"--harness" => {
let value = value_at(args, &mut index, flag);
harness_id = PathBuf::from(&value)
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("custom")
.to_string();
harness_command = Some(value);
}
"--harness-id" => harness_id = value_at(args, &mut index, flag),
"--arg" => harness_args.push(value_at(args, &mut index, flag)),
"--mcp" => mcp_command = Some(value_at(args, &mut index, flag)),
"--mcp-arg" => mcp_args.push(value_at(args, &mut index, flag)),
"--mcp-name" => mcp_name = value_at(args, &mut index, flag),
"--approval" => {
approval = match value_at(args, &mut index, flag).as_str() {
"auto" => ApprovalMode::Auto,
"ask" => ApprovalMode::Ask,
"deny" => ApprovalMode::Deny,
other => {
eprintln!("--approval 只认 auto|ask|deny,收到 {other}");
std::process::exit(2);
}
}
}
"--minutes" => minutes = value_at(args, &mut index, flag).parse().unwrap_or(30),
"--model" => model = Some(value_at(args, &mut index, flag)),
"--resume" => resume = Some(value_at(args, &mut index, flag)),
"--timeout-secs" => {
timeout_secs = value_at(args, &mut index, flag).parse().ok();
}
"--store" => store_dir = Some(PathBuf::from(value_at(args, &mut index, flag))),
"--json" => json = true,
other => {
eprintln!("未知参数:{other}");
std::process::exit(2);
}
}
index += 1;
}
let (Some(workspace), Some(task), Some(command)) = (workspace, task, harness_command) else {
eprintln!("缺少必需参数:--workspace / --task / --harness");
usage();
std::process::exit(2);
};
let mut harness = HarnessSpec::new(harness_id, command).with_args(harness_args);
if let Some(model) = &model {
harness = harness.with_env("AC_MODEL", model);
}
let tools = mcp_command
.map(|command| vec![McpServerSpec::new(mcp_name, command).with_args(mcp_args)])
.unwrap_or_default();
let store_root = store_dir.unwrap_or_else(|| workspace.join(".ac-runs"));
let store = match FileRunStore::new(&store_root) {
Ok(store) => Arc::new(store),
Err(error) => {
eprintln!("运行记录目录建不出来({}):{error}", store_root.display());
std::process::exit(1);
}
};
let mut spec = RunSpec::new(task, &workspace, harness)
.with_tools(tools)
.with_approval(approval)
.with_budget(Budget {
minutes: Some(minutes),
tokens: None,
turns: None,
});
spec.model = model;
if let Some(session) = resume {
let session = if session == "last" {
match store.last_session_id() {
Ok(Some(id)) => id,
Ok(None) => {
eprintln!(
"--resume last 找不到可续的会话:{} 里没有 session_id",
store_root.join("index.jsonl").display()
);
std::process::exit(2);
}
Err(error) => {
eprintln!("读取运行记录失败({}):{error}", store_root.display());
std::process::exit(1);
}
}
} else {
session
};
spec = spec.with_resume(session);
}
let mut options = RunOptions::default()
.with_sink(Box::new(CliSink { json }))
.with_store(store)
.with_run_id(agent_coordinator_core::new_run_id(now_ms()));
if approval == ApprovalMode::Ask {
options = options.with_approvals(Box::new(CliApproval));
}
let mut runner = AcpHarness::new(spec.harness.clone());
if let Some(secs) = timeout_secs {
runner = runner.with_timeout(Duration::from_secs(secs));
}
let outcome = runner.run(&spec, options);
if json {
println!(
"{}",
serde_json::to_string_pretty(&outcome).unwrap_or_default()
);
} else if outcome.ok {
println!(
"\n完成:{}({} 条事件,{} tokens)",
outcome.run_id,
outcome.events,
outcome.usage.tokens.unwrap_or(0)
);
if let Some(session) = &outcome.session_id {
println!("会话:{session}(下次用 --resume 续上,harness 需支持 session/load)");
}
} else {
println!(
"\n失败:{} —— {}",
outcome
.error
.as_ref()
.map(|error| error.code.as_str())
.unwrap_or("unknown"),
outcome
.error
.as_ref()
.map(|error| error.message.as_str())
.unwrap_or("")
);
}
if !outcome.ok {
std::process::exit(1);
}
}
struct CliSink {
json: bool,
}
impl EventSink for CliSink {
fn emit(&mut self, event: Event) {
if self.json {
if let Ok(line) = serde_json::to_string(&event) {
println!("{line}");
}
return;
}
match event {
Event::Message { text, .. } => {
print!("{text}");
let _ = io::stdout().flush();
}
Event::Tool {
status,
name,
detail,
..
} => println!(
"\n· {} {:?}{}",
name.as_deref().unwrap_or("tool"),
status,
detail.map(|d| format!(" — {d}")).unwrap_or_default()
),
Event::Approval { tool, risk, .. } => println!("\n! 审批:{tool}({risk:?})"),
Event::Plan { steps, .. } => println!("\n# 计划:{}", steps.join(" → ")),
Event::Usage { tokens, .. } => println!("\n~ tokens:{}", tokens.unwrap_or(0)),
Event::Log { text, .. } => println!("\n[log] {text}"),
Event::Artifact { path, .. } => println!("\n+ 产物:{path}"),
Event::RunStarted { harness, .. } => println!("▶ {harness} 开始"),
Event::RunCompleted { ok, reason, .. } => println!(
"\n■ 结束:{}{}",
if ok { "ok" } else { "failed" },
reason.map(|r| format!("({r})")).unwrap_or_default()
),
}
}
}
struct CliApproval;
impl ApprovalHandler for CliApproval {
fn decide(&mut self, request: &ApprovalRequest) -> Decision {
eprintln!(
"\n审批请求:{}({:?}){}",
request.tool,
request.risk,
request
.detail
.as_deref()
.map(|detail| format!("\n {detail}"))
.unwrap_or_default()
);
eprint!("允许?[y] 一次 / [a] 本次会话 / [n] 拒绝(默认拒绝):");
let _ = io::stderr().flush();
let mut line = String::new();
if io::stdin().read_line(&mut line).is_err() {
return Decision::Deny;
}
match line.trim() {
"y" | "Y" => Decision::Allow,
"a" | "A" => Decision::AllowAlways,
_ => Decision::Deny,
}
}
}
#[cfg(test)]
mod tests {
use super::{MATRIX, Matrix};
#[test]
fn matrix_is_well_formed() {
let matrix: Matrix = serde_json::from_str(MATRIX).expect("harness-matrix.json 解析失败");
assert!(!matrix.harnesses.is_empty(), "矩阵不能是空的");
assert!(
!matrix.updated_at.trim().is_empty(),
"矩阵必须有 updated_at"
);
for entry in &matrix.harnesses {
assert!(!entry.id.trim().is_empty(), "id 不能为空");
assert!(!entry.command.trim().is_empty(), "{} 缺启动命令", entry.id);
assert!(
["verified", "handshake", "unknown"].contains(&entry.status.as_str()),
"{} 的 status 不认识:{}",
entry.id,
entry.status
);
if entry.status != "unknown" {
assert!(
entry.checked_at.is_some(),
"{} 有真机结论就必须写 checked_at",
entry.id
);
}
if entry.status == "handshake" {
assert!(
entry.blocker.is_some(),
"{} 是握手通,必须写清被什么挡住",
entry.id
);
}
}
assert!(
!matrix.default_for_experiments.is_empty(),
"必须写明实验默认用哪几条"
);
for id in &matrix.default_for_experiments {
let entry = matrix
.harnesses
.iter()
.find(|entry| &entry.id == id)
.unwrap_or_else(|| panic!("默认实验项 {id} 不在矩阵里"));
assert_eq!(
entry.status, "verified",
"默认实验项 {id} 不是跑通状态,先跑通再写进来"
);
}
}
}