feat: implement Claude Code agent view supervisor

This commit is contained in:
抱月 2026-07-06 00:59:56 +08:00
parent 1a73dc2c4e
commit 0e2214aeff
9 changed files with 2996 additions and 62 deletions

View File

@ -150,16 +150,18 @@ Canonical scenario map: `rust/mock_parity_scenarios.json`
- `mvp_tool_specs()` in `rust/crates/tools/src/lib.rs` exposes the core tool set plus Claude Code v2.1.201 compatibility contracts.
- Core execution is present for `bash`, `read_file`, `write_file`, `edit_file`, `glob_search`, and `grep_search`.
- Existing product tools in `mvp_tool_specs()` include `WebFetch`, `WebSearch`, `TodoWrite`, `Skill`, `Agent`, `ToolSearch`, `NotebookEdit`, `Sleep`, `SendUserMessage`, `Config`, `EnterPlanMode`, `ExitPlanMode`, `StructuredOutput`, `REPL`, and `PowerShell`.
- Agent view now uses the Claude Code v2.1.201 background-session layout: `CLAUDE_CONFIG_DIR` or `~/.claude`, `jobs/<id>/state.json`, `jobs/<id>/tmp/`, `daemon/roster.json`, and `daemon.log`. `claw agents`, `claw agents --json [--all] [--cwd <path>]`, `claw --bg`, `claw --bg --exec`, `claw attach/logs/stop/kill/respawn/rm <id>`, and `claw daemon status/stop --any` are wired through `runtime::AgentSupervisor`; TTY `claw agents` opens a full-screen crossterm view with grouped rows, peek, dispatch, and lifecycle shortcuts.
- The 9-lane push replaced pure fixed-payload stubs for `Task*`, `Team*`, `Cron*`, `LSP`, and MCP tools with registry-backed handlers on `main`; the v2.1.201 migration extends `TaskCreate`/`TaskUpdate` to the structured task-list payload shape.
- The v2.1.201 host-facing contracts include `Workflow`, `Monitor`, `ScheduleWakeup`, `PushNotification`, `ReportFindings`, `ReadMcpResourceDir`, `Artifact`, `Projects`, `ClaudeDesign`, and `ShowOnboardingRolePicker`.
- `Brief` is handled as an execution alias in `execute_tool()`, but it is not a separately exposed tool spec in `mvp_tool_specs()`.
### Still limited or intentionally shallow
- `AskUserQuestion` still returns a pending response payload rather than real interactive UI wiring.
- `AskUserQuestion` supports local stdin/stdout question flow, but it is not wired to an async host UI prompt surface.
- `McpAuth` reports structured login/logout contract state and host-flow requirements, but it does not open a browser or complete OAuth by itself.
- `Workflow` and `Monitor` expose compatibility payloads, but local workflow JavaScript execution and live monitor scheduling are not implemented in this runtime.
- `RemoteTrigger` remains a stub response.
- `RemoteTrigger` performs direct HTTP requests, but it has not been validated against every upstream host-trigger edge case.
- Agent view has a local supervisor, official state files, shell lifecycle commands, raw `agents --json` arrays, background worker spawning, a basic full-screen TUI, and terminal attach into resumable prompt sessions once workers record their managed session id. Remaining gaps are peek-panel replies, row pin/rename/reorder/collapse shortcuts, generated row summaries/PR status, `/bg` or left-arrow handoff from an already-running foreground session, and a real supervisor socket/pre-warmed worker pool.
- `TestingPermission` remains test-only.
- Task, team, cron, MCP, and LSP are no longer just fixed-payload stubs in `execute_tool()`, but several remain registry-backed approximations rather than full external-runtime integrations.
- Bash deep validation remains branch-only until `36dac6c` is merged.

View File

@ -513,6 +513,10 @@ cd rust
./target/debug/claw status
./target/debug/claw sandbox
./target/debug/claw agents
./target/debug/claw agents --json --all
./target/debug/claw --bg "fix the parser regression"
./target/debug/claw --bg --exec "cargo test -p runtime"
./target/debug/claw logs <agent-id>
./target/debug/claw agents create my-agent
./target/debug/claw mcp
./target/debug/claw skills
@ -550,6 +554,24 @@ avoid pasting API keys into chat.
./target/debug/claw agents list
```
Agent view follows the Claude Code background-session layout. `CLAUDE_CONFIG_DIR` overrides the default `~/.claude`; sessions are stored under `jobs/<id>/state.json`, per-session temp files under `jobs/<id>/tmp/`, and the supervisor roster under `daemon/roster.json`.
```bash
./target/debug/claw agents
./target/debug/claw agents --json --all
./target/debug/claw agents --cwd "$PWD"
./target/debug/claw --bg "fix the parser regression"
./target/debug/claw --bg --exec "cargo test -p runtime"
./target/debug/claw attach <agent-id>
./target/debug/claw logs <agent-id>
./target/debug/claw stop <agent-id>
./target/debug/claw respawn <agent-id>
./target/debug/claw rm <agent-id>
./target/debug/claw daemon status
```
When stdout/stdin are attached to a terminal, `claw agents` opens a full-screen view. Type a prompt and press Enter to dispatch a new background session, use Up/Down to select rows, Space to peek at captured output, Enter or Right to attach, `s`/`k` to stop or kill, `r` to respawn, and `d` twice to remove a row. In JSON mode or non-TTY automation it keeps the structured list behavior.
## Session management
REPL turns are persisted under `.claw/sessions/` in the current workspace.

View File

@ -58,7 +58,7 @@ Hashes below come from `git log --oneline`. Merge line counts come from `git sho
| **WebSearch** | `tools` | search query execution — **moderate parity** |
| **TodoWrite** | `tools` | todo/note persistence — **moderate parity** |
| **Skill** | `tools` | skill discovery/install — **moderate parity** |
| **Agent** | `tools` | agent delegation — **moderate parity** |
| **Agent** | `tools` + `runtime::agent_supervisor` + `commands` | agent delegation plus Claude Code agent-view state under `~/.claude/jobs/<id>/state.json`, `daemon/roster.json`, `claw agents --json`, `--bg`, shell lifecycle commands, basic full-screen TUI, and attach into resumable prompt sessions **moderate parity**; peek replies, row organization shortcuts, `/bg` foreground handoff, and real supervisor socket/worker-pool semantics remain partial |
| **TaskCreate** | `runtime::task_registry` + `tools` | in-memory task creation wired into tool dispatch — **good parity** |
| **TaskGet** | `runtime::task_registry` + `tools` | task lookup + metadata payload — **good parity** |
| **TaskList** | `runtime::task_registry` + `tools` | registry-backed task listing — **good parity** |

View File

@ -181,7 +181,7 @@ The REPL now exposes a much broader surface than the original minimal shell:
Notable claw-first surfaces now available directly in slash form:
- `/skills [list|show <name>|install <path>|uninstall <name>|help]`
- `/agents [list|show <name>|create <name>|help]`
- `/agents [--all] [--cwd <path>] [list|show <name>|create <name>|help]`
- `/mcp [list|show <server>|help]`
- `/doctor`
- `/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]`

View File

@ -240,7 +240,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
SlashCommandSpec {
name: "agents",
aliases: &[],
summary: "List, show, or create configured agents",
summary: "Open agent view or manage configured agents",
argument_hint: Some("[list|show <name>|create <name>|help]"),
resume_supported: true,
},
@ -1833,7 +1833,8 @@ fn parse_list_or_help_args(
|| value.starts_with("show ")
|| value.starts_with("info ")
|| value.starts_with("describe ")
|| value.starts_with("create ") =>
|| value.starts_with("create ")
|| value.starts_with("--") =>
{
Ok(args)
}
@ -2228,6 +2229,40 @@ pub(crate) struct AgentCollection {
pub(crate) invalid_agents: Vec<InvalidAgentConfig>,
}
#[derive(Debug, Clone)]
struct AgentViewCollection {
config_dir: PathBuf,
store_dir: PathBuf,
views: Vec<AgentRunView>,
}
#[derive(Debug, Clone)]
struct AgentRunView {
agent_id: String,
name: String,
description: String,
subagent_type: Option<String>,
model: Option<String>,
status: String,
state: String,
output_file: Option<String>,
manifest_file: String,
started_at: Option<String>,
completed_at: Option<String>,
pid: Option<u32>,
waiting_for: Option<String>,
session_id: Option<String>,
cwd: String,
kind: String,
error: Option<String>,
}
#[derive(Debug, Clone, Default)]
struct AgentViewOptions {
include_all: bool,
cwd: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SkillSummary {
name: String,
@ -2482,7 +2517,17 @@ pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
}
match normalize_optional_args(args) {
None | Some("list") => {
None => {
let options = parse_agent_view_options(None)?;
let collection = load_agent_views(&options)?;
Ok(render_agent_views_report(&collection, None))
}
Some(view_args) if view_args.starts_with("--") => {
let options = parse_agent_view_options(Some(view_args))?;
let collection = load_agent_views(&options)?;
Ok(render_agent_views_report(&collection, None))
}
Some("list") => {
let roots = discover_definition_roots(cwd, "agents");
let agents = load_agents_from_roots(&roots)?;
Ok(render_agents_report(&agents))
@ -2583,7 +2628,17 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
}
match normalize_optional_args(args) {
None | Some("list") => {
None => {
let options = parse_agent_view_options(None)?;
let collection = load_agent_views(&options)?;
Ok(render_agent_views_report_json(cwd, &collection, None))
}
Some(view_args) if view_args.starts_with("--") => {
let options = parse_agent_view_options(Some(view_args))?;
let collection = load_agent_views(&options)?;
Ok(render_agent_views_report_json(cwd, &collection, None))
}
Some("list") => {
let roots = discover_definition_roots(cwd, "agents");
let collection = load_agents_from_roots_with_invalids(&roots)?;
Ok(render_agents_report_json(cwd, &collection))
@ -2712,6 +2767,18 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
}
}
pub fn handle_agent_view_json_array(args: Option<&str>) -> std::io::Result<Value> {
let options = parse_agent_view_options(args)?;
let collection = load_agent_views(&options)?;
Ok(Value::Array(
collection
.views
.iter()
.map(agent_view_json)
.collect::<Vec<_>>(),
))
}
pub fn handle_mcp_slash_command(
args: Option<&str>,
cwd: &Path,
@ -3936,6 +4003,99 @@ fn create_agent(name: &str, cwd: &Path) -> std::io::Result<CreatedAgent> {
Ok(CreatedAgent { name, path })
}
fn parse_agent_view_options(args: Option<&str>) -> std::io::Result<AgentViewOptions> {
let mut options = AgentViewOptions::default();
let mut parts = args
.unwrap_or_default()
.split_whitespace()
.collect::<Vec<_>>()
.into_iter();
while let Some(part) = parts.next() {
match part {
"--json" => {}
"--all" => options.include_all = true,
"--cwd" => {
let Some(value) = parts.next() else {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"missing_flag_value: agents --cwd requires a path",
));
};
options.cwd = Some(PathBuf::from(value));
}
value if value.starts_with("--cwd=") => {
options.cwd = Some(PathBuf::from(&value["--cwd=".len()..]));
}
other => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"unknown agents option: {other}\nUsage: claw agents [--json] [--all] [--cwd <path>]"
),
));
}
}
}
Ok(options)
}
fn load_agent_views(options: &AgentViewOptions) -> std::io::Result<AgentViewCollection> {
let supervisor = runtime::AgentSupervisor::from_default_config()
.map_err(|error| std::io::Error::other(error.to_string()))?;
let filter = runtime::AgentListFilter {
cwd: options.cwd.clone(),
include_all: options.include_all,
};
let jobs = supervisor
.list_jobs(&filter)
.map_err(|error| std::io::Error::other(error.to_string()))?;
let views = jobs
.into_iter()
.map(|job| AgentRunView {
agent_id: job.id.clone(),
name: job.name.clone().unwrap_or_else(|| job.id.clone()),
description: job
.prompt
.clone()
.or(job.command.clone())
.unwrap_or_default(),
subagent_type: job.agent.clone(),
model: job.model.clone(),
status: job.status.unwrap_or_else(|| "unknown".to_string()),
state: serde_json::to_value(&job.state)
.ok()
.and_then(|value| value.as_str().map(ToString::to_string))
.unwrap_or_else(|| "unknown".to_string()),
output_file: Some(
supervisor
.job_dir(&job.id)
.join("output.log")
.display()
.to_string(),
),
manifest_file: supervisor.state_path(&job.id).display().to_string(),
started_at: Some(job.started_at),
completed_at: job.completed_at,
pid: job.pid,
waiting_for: job.waiting_for,
session_id: job.session_id,
cwd: job.cwd,
kind: serde_json::to_value(&job.kind)
.ok()
.and_then(|value| value.as_str().map(ToString::to_string))
.unwrap_or_else(|| "unknown".to_string()),
error: job
.output_tail
.filter(|_| matches!(job.state, runtime::AgentJobState::Failed)),
})
.collect::<Vec<_>>();
Ok(AgentViewCollection {
config_dir: supervisor.config_dir().to_path_buf(),
store_dir: supervisor.jobs_dir(),
views,
})
}
fn default_skill_install_root() -> std::io::Result<PathBuf> {
if let Ok(claw_config_home) = env::var("CLAW_CONFIG_HOME") {
return Ok(PathBuf::from(claw_config_home).join("skills"));
@ -4510,6 +4670,170 @@ fn render_agents_report_json_with_action(
})
}
fn render_agent_views_report(collection: &AgentViewCollection, target: Option<&str>) -> String {
let views = filtered_agent_views(collection, target);
let summary = agent_view_summary(&views);
let mut lines = vec![
"Agent view".to_string(),
format!(" Config {}", collection.config_dir.display()),
format!(" Jobs {}", collection.store_dir.display()),
format!(" Count {}", views.len()),
format!(
" Summary working={} blocked={} done={} failed={} stopped={}",
summary.working, summary.blocked, summary.done, summary.failed, summary.stopped
),
];
if views.is_empty() {
lines.push(" Message No background sessions found.".to_string());
return lines.join("\n");
}
lines.push(String::new());
for view in views {
lines.push(format!(
" {} {} state={} status={}",
view.agent_id, view.name, view.state, view.status
));
lines.push(format!(" cwd {}", view.cwd));
lines.push(format!(" kind {}", view.kind));
lines.push(format!(
" started {}",
view.started_at.as_deref().unwrap_or("<unknown>")
));
if !view.description.is_empty() {
lines.push(format!(" prompt {}", view.description));
}
if let Some(model) = &view.model {
lines.push(format!(" model {model}"));
}
if let Some(agent) = &view.subagent_type {
lines.push(format!(" agent {agent}"));
}
if let Some(pid) = view.pid {
lines.push(format!(" pid {pid}"));
}
if let Some(waiting_for) = &view.waiting_for {
lines.push(format!(" waiting_for {waiting_for}"));
}
if let Some(output_file) = &view.output_file {
lines.push(format!(" output {output_file}"));
}
lines.push(format!(" state {}", view.manifest_file));
if let Some(error) = &view.error {
lines.push(format!(" error {error}"));
}
}
lines.join("\n")
}
fn render_agent_views_report_json(
cwd: &Path,
collection: &AgentViewCollection,
target: Option<&str>,
) -> Value {
let views = filtered_agent_views(collection, target);
let summary = agent_view_summary(&views);
json!({
"kind": "agents",
"action": "view",
"status": "ok",
"working_directory": cwd.display().to_string(),
"config_dir": collection.config_dir.display().to_string(),
"store": collection.store_dir.display().to_string(),
"requested": target,
"count": views.len(),
"summary": {
"total": views.len(),
"working": summary.working,
"blocked": summary.blocked,
"done": summary.done,
"failed": summary.failed,
"stopped": summary.stopped,
"unknown": summary.unknown,
},
"sessions": views.iter().map(|view| agent_view_json(view)).collect::<Vec<_>>(),
})
}
fn filtered_agent_views<'a>(
collection: &'a AgentViewCollection,
target: Option<&str>,
) -> Vec<&'a AgentRunView> {
collection
.views
.iter()
.filter(|view| target.is_none_or(|target| view.agent_id == target || view.name == target))
.collect()
}
#[derive(Debug, Clone, Copy, Default)]
struct AgentViewSummary {
working: usize,
blocked: usize,
done: usize,
failed: usize,
stopped: usize,
unknown: usize,
}
fn agent_view_summary(views: &[&AgentRunView]) -> AgentViewSummary {
let mut summary = AgentViewSummary::default();
for view in views {
match view.state.as_str() {
"working" => summary.working += 1,
"blocked" => summary.blocked += 1,
"done" => summary.done += 1,
"failed" => summary.failed += 1,
"stopped" => summary.stopped += 1,
_ => summary.unknown += 1,
}
}
summary
}
fn agent_view_json(view: &AgentRunView) -> Value {
let mut value = serde_json::Map::new();
value.insert("cwd".to_string(), json!(&view.cwd));
value.insert("kind".to_string(), json!(&view.kind));
value.insert("startedAt".to_string(), json!(&view.started_at));
value.insert("id".to_string(), json!(&view.agent_id));
value.insert("state".to_string(), json!(&view.state));
if let Some(pid) = view.pid {
value.insert("pid".to_string(), json!(pid));
}
if !view.status.is_empty() {
value.insert("status".to_string(), json!(&view.status));
}
if let Some(waiting_for) = &view.waiting_for {
value.insert("waitingFor".to_string(), json!(waiting_for));
}
if let Some(session_id) = &view.session_id {
value.insert("sessionId".to_string(), json!(session_id));
}
value.insert("name".to_string(), json!(&view.name));
if !view.description.is_empty() {
value.insert("prompt".to_string(), json!(&view.description));
}
if let Some(model) = &view.model {
value.insert("model".to_string(), json!(model));
}
if let Some(agent) = &view.subagent_type {
value.insert("agent".to_string(), json!(agent));
}
if let Some(completed_at) = &view.completed_at {
value.insert("completedAt".to_string(), json!(completed_at));
}
if let Some(output_file) = &view.output_file {
value.insert("outputFile".to_string(), json!(output_file));
}
value.insert("stateFile".to_string(), json!(&view.manifest_file));
if let Some(error) = &view.error {
value.insert("error".to_string(), json!(error));
}
Value::Object(value)
}
fn render_agents_missing_argument_json(action: &str, argument: &str) -> Value {
json!({
"kind": "agents",
@ -4992,11 +5316,16 @@ fn help_path_from_args(args: &str) -> Option<Vec<&str>> {
fn render_agents_usage(unexpected: Option<&str>) -> String {
let mut lines = vec![
"Agents".to_string(),
" Usage /agents [list|show <name>|create <name>|help]".to_string(),
" Direct CLI claw agents [list|show <name>|create <name>|help]".to_string(),
" Usage /agents [--all] [--cwd <path>] [list|show <name>|create <name>|help]"
.to_string(),
" Direct CLI claw agents [--json] [--all] [--cwd <path>] [list|show <name>|create <name>|help]"
.to_string(),
" Background claw --bg \"<prompt>\"; claw --bg --exec '<command>'".to_string(),
" Lifecycle claw attach|logs|stop|kill|respawn|rm <id>; claw daemon status".to_string(),
" Format TOML files (.toml); create scaffolds .claw/agents/<name>.toml"
.to_string(),
" Sources .claw/agents, ~/.claw/agents, $CLAW_CONFIG_HOME/agents".to_string(),
" Session state $CLAUDE_CONFIG_DIR/jobs/<id>/state.json or ~/.claude/jobs/<id>/state.json".to_string(),
];
if let Some(args) = unexpected {
lines.push(format!(" Unexpected {args}"));
@ -5011,10 +5340,11 @@ fn render_agents_usage_json(unexpected: Option<&str>) -> Value {
"ok": unexpected.is_none(),
"status": if unexpected.is_some() { "error" } else { "ok" },
"usage": {
"slash_command": "/agents [list|show <name>|create <name>|help]",
"direct_cli": "claw agents [list|show <name>|create <name>|help]",
"slash_command": "/agents [--all] [--cwd <path>] [list|show <name>|create <name>|help]",
"direct_cli": "claw agents [--json] [--all] [--cwd <path>] [list|show <name>|create <name>|help]",
"format": "toml",
"create": "claw agents create <name>",
"background": "claw --bg \"<prompt>\"",
"sources": [".claw/agents", "~/.claw/agents", "~/.codex/agents", "$CLAW_CONFIG_HOME/agents"],
},
"unexpected": unexpected,
@ -5955,6 +6285,14 @@ mod tests {
));
assert!(agents_error
.contains(" Usage /agents [list|show <name>|create <name>|help]"));
assert!(SlashCommand::parse("/agents views").is_err());
assert_eq!(
SlashCommand::parse("/agents --all").expect("agents options should parse"),
Some(SlashCommand::Agents {
args: Some("--all".to_string())
})
);
}
#[test]
@ -6535,7 +6873,7 @@ mod tests {
assert_eq!(help["status"], "ok");
assert_eq!(
help["usage"]["direct_cli"],
"claw agents [list|show <name>|create <name>|help]"
"claw agents [--json] [--all] [--cwd <path>] [list|show <name>|create <name>|help]"
);
// `show <name>` is now valid. Known agent returns ok with matching entry.
@ -6568,6 +6906,82 @@ mod tests {
let _ = fs::remove_dir_all(claude_config);
}
#[test]
fn renders_background_agent_view_from_supervisor_state() {
let _guard = env_guard();
let workspace = temp_dir("agent-view-workspace");
let config = temp_dir("agent-view-config");
fs::create_dir_all(&workspace).expect("workspace");
let canonical_workspace = fs::canonicalize(&workspace).expect("canonical workspace");
let original_claude_config_dir = std::env::var_os("CLAUDE_CONFIG_DIR");
std::env::set_var("CLAUDE_CONFIG_DIR", &config);
let supervisor = runtime::AgentSupervisor::from_config_dir(&config);
let old = supervisor
.create_job(runtime::AgentJobCreate {
cwd: workspace.clone(),
kind: runtime::AgentJobKind::Exec,
prompt: None,
command: Some("echo older".to_string()),
name: Some("shell-old".to_string()),
model: None,
agent: None,
permission_mode: None,
reasoning_effort: None,
})
.expect("old job");
supervisor
.finish_job(&old.id, 0, Some("older\n"))
.expect("finish old job");
let new = supervisor
.create_job(runtime::AgentJobCreate {
cwd: workspace.clone(),
kind: runtime::AgentJobKind::Claude,
prompt: Some("fix parser".to_string()),
command: None,
name: Some("explore-new".to_string()),
model: Some("anthropic/claude-sonnet-5".to_string()),
agent: Some("Explore".to_string()),
permission_mode: Some("manual".to_string()),
reasoning_effort: Some("medium".to_string()),
})
.expect("new job");
supervisor.set_process(&new.id, 4242).expect("pid");
let report = handle_agents_slash_command_json(Some("--all"), &workspace)
.expect("agent view should render");
assert_eq!(report["kind"], "agents");
assert_eq!(report["action"], "view");
assert_eq!(report["status"], "ok");
assert_eq!(report["count"], 2);
assert_eq!(report["summary"]["working"], 1);
assert_eq!(report["summary"]["done"], 1);
let sessions = report["sessions"].as_array().expect("sessions");
let new_session = sessions
.iter()
.find(|session| session["id"] == new.id)
.expect("new session");
assert_eq!(new_session["state"], "working");
assert_eq!(
new_session["cwd"],
canonical_workspace.display().to_string()
);
assert_eq!(new_session["kind"], "claude");
let raw = super::handle_agent_view_json_array(Some("--json")).expect("raw array");
let raw = raw.as_array().expect("array");
assert_eq!(raw.len(), 1);
assert_eq!(raw[0]["id"], new.id);
let text = super::handle_agents_slash_command(Some("--all"), &workspace)
.expect("agent view text should render");
assert!(text.contains("Agent view"));
assert!(text.contains("explore-new"));
assert!(text.contains("state=working"));
restore_env_var("CLAUDE_CONFIG_DIR", original_claude_config_dir);
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(config);
}
#[test]
fn lists_skills_from_project_and_user_roots() {
let workspace = temp_dir("skills-workspace");
@ -6712,11 +7126,12 @@ mod tests {
let agents_help =
super::handle_agents_slash_command(Some("help"), &cwd).expect("agents help");
assert!(
agents_help.contains("Usage /agents [list|show <name>|create <name>|help]")
);
assert!(agents_help
.contains("Direct CLI claw agents [list|show <name>|create <name>|help]"));
assert!(agents_help.contains(
"Usage /agents [--all] [--cwd <path>] [list|show <name>|create <name>|help]"
));
assert!(agents_help.contains(
"Direct CLI claw agents [--json] [--all] [--cwd <path>] [list|show <name>|create <name>|help]"
));
assert!(agents_help.contains(
"Format TOML files (.toml); create scaffolds .claw/agents/<name>.toml"
));

View File

@ -0,0 +1,635 @@
use std::collections::BTreeMap;
use std::env;
use std::fmt::{Display, Formatter};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use serde_json::Value;
const ROSTER_VERSION: u32 = 1;
#[derive(Debug)]
pub enum AgentSupervisorError {
ConfigDirUnavailable,
Io(std::io::Error),
Json(serde_json::Error),
JobNotFound(String),
}
impl Display for AgentSupervisorError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::ConfigDirUnavailable => {
write!(
f,
"agent_supervisor_config_unavailable: set CLAUDE_CONFIG_DIR or HOME"
)
}
Self::Io(error) => write!(f, "{error}"),
Self::Json(error) => write!(f, "{error}"),
Self::JobNotFound(id) => write!(f, "agent_job_not_found: {id}"),
}
}
}
impl std::error::Error for AgentSupervisorError {}
impl From<std::io::Error> for AgentSupervisorError {
fn from(error: std::io::Error) -> Self {
Self::Io(error)
}
}
impl From<serde_json::Error> for AgentSupervisorError {
fn from(error: serde_json::Error) -> Self {
Self::Json(error)
}
}
#[derive(Debug, Clone)]
pub struct AgentSupervisor {
config_dir: PathBuf,
}
#[derive(Debug, Clone, Default)]
pub struct AgentListFilter {
pub cwd: Option<PathBuf>,
pub include_all: bool,
}
#[derive(Debug, Clone)]
pub struct AgentJobCreate {
pub cwd: PathBuf,
pub kind: AgentJobKind,
pub prompt: Option<String>,
pub command: Option<String>,
pub name: Option<String>,
pub model: Option<String>,
pub agent: Option<String>,
pub permission_mode: Option<String>,
pub reasoning_effort: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum AgentJobKind {
Claude,
Exec,
Interactive,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum AgentJobState {
Working,
Blocked,
Done,
Failed,
Stopped,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentJobRecord {
pub id: String,
pub kind: AgentJobKind,
pub cwd: String,
pub started_at: String,
pub updated_at: String,
pub state: AgentJobState,
#[serde(skip_serializing_if = "Option::is_none")]
pub pid: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub waiting_for: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub permission_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_tail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stopped_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub completed_at: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentRoster {
pub version: u32,
pub updated_at: String,
pub sessions: Vec<AgentRosterEntry>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentRosterEntry {
pub id: String,
pub state: AgentJobState,
pub cwd: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub pid: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub started_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentDaemonStatus {
pub reachable: bool,
pub version: String,
pub config_dir: String,
pub socket_dir: String,
pub roster_path: String,
pub log_path: String,
pub live_sessions: usize,
pub worker_count: usize,
}
impl AgentSupervisor {
pub fn from_default_config() -> Result<Self, AgentSupervisorError> {
Ok(Self {
config_dir: default_claude_config_dir()?,
})
}
#[must_use]
pub fn from_config_dir(config_dir: impl Into<PathBuf>) -> Self {
Self {
config_dir: config_dir.into(),
}
}
#[must_use]
pub fn config_dir(&self) -> &Path {
&self.config_dir
}
#[must_use]
pub fn jobs_dir(&self) -> PathBuf {
self.config_dir.join("jobs")
}
#[must_use]
pub fn daemon_dir(&self) -> PathBuf {
self.config_dir.join("daemon")
}
#[must_use]
pub fn roster_path(&self) -> PathBuf {
self.daemon_dir().join("roster.json")
}
#[must_use]
pub fn log_path(&self) -> PathBuf {
self.config_dir.join("daemon.log")
}
#[must_use]
pub fn job_dir(&self, id: &str) -> PathBuf {
self.jobs_dir().join(id)
}
#[must_use]
pub fn state_path(&self, id: &str) -> PathBuf {
self.job_dir(id).join("state.json")
}
pub fn create_job(
&self,
request: AgentJobCreate,
) -> Result<AgentJobRecord, AgentSupervisorError> {
fs::create_dir_all(self.jobs_dir())?;
fs::create_dir_all(self.daemon_dir())?;
let mut id = short_job_id();
while self.state_path(&id).exists() {
id = short_job_id();
}
let job_dir = self.job_dir(&id);
fs::create_dir_all(job_dir.join("tmp"))?;
let now = timestamp();
let cwd = canonical_string(&request.cwd);
let record = AgentJobRecord {
id,
kind: request.kind,
cwd,
started_at: now.clone(),
updated_at: now,
state: AgentJobState::Working,
pid: None,
status: Some("queued".to_string()),
waiting_for: None,
session_id: None,
name: request.name,
prompt: request.prompt,
command: request.command,
model: request.model,
agent: request.agent,
permission_mode: request.permission_mode,
reasoning_effort: request.reasoning_effort,
output_tail: None,
exit_code: None,
stopped_at: None,
completed_at: None,
extra: BTreeMap::new(),
};
self.save_job(&record)?;
self.rewrite_roster()?;
Ok(record)
}
pub fn list_jobs(
&self,
filter: &AgentListFilter,
) -> Result<Vec<AgentJobRecord>, AgentSupervisorError> {
let entries = match fs::read_dir(self.jobs_dir()) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(error.into()),
};
let cwd_filter = filter.cwd.as_ref().map(|cwd| canonical_string(cwd));
let mut records = Vec::new();
for entry in entries {
let entry = entry?;
if !entry.path().is_dir() {
continue;
}
let id = entry.file_name().to_string_lossy().to_string();
let Ok(record) = self.load_job(&id) else {
continue;
};
if let Some(cwd_filter) = &cwd_filter {
if !record.cwd.starts_with(cwd_filter) {
continue;
}
}
if !filter.include_all
&& !matches!(
record.state,
AgentJobState::Working | AgentJobState::Blocked
)
&& record.pid.is_none()
{
continue;
}
records.push(record);
}
records.sort_by(|left, right| {
right
.updated_at
.cmp(&left.updated_at)
.then(left.id.cmp(&right.id))
});
Ok(records)
}
pub fn load_job(&self, id: &str) -> Result<AgentJobRecord, AgentSupervisorError> {
let path = self.state_path(id);
if !path.is_file() {
return Err(AgentSupervisorError::JobNotFound(id.to_string()));
}
let contents = fs::read_to_string(path)?;
Ok(serde_json::from_str(&contents)?)
}
pub fn save_job(&self, record: &AgentJobRecord) -> Result<(), AgentSupervisorError> {
let path = self.state_path(&record.id);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, serde_json::to_string_pretty(record)?)?;
Ok(())
}
pub fn set_process(&self, id: &str, pid: u32) -> Result<AgentJobRecord, AgentSupervisorError> {
self.update_job(id, |record| {
record.pid = Some(pid);
record.status = Some("running".to_string());
record.state = AgentJobState::Working;
})
}
pub fn set_session_id(
&self,
id: &str,
session_id: impl Into<String>,
) -> Result<AgentJobRecord, AgentSupervisorError> {
self.update_job(id, |record| {
record.session_id = Some(session_id.into());
})
}
pub fn append_log(&self, id: &str, text: &str) -> Result<AgentJobRecord, AgentSupervisorError> {
let log_path = self.job_dir(id).join("output.log");
if let Some(parent) = log_path.parent() {
fs::create_dir_all(parent)?;
}
let mut existing = fs::read_to_string(&log_path).unwrap_or_default();
existing.push_str(text);
fs::write(&log_path, &existing)?;
self.update_job(id, |record| {
record.output_tail = Some(tail(&existing, 8192));
})
}
pub fn finish_job(
&self,
id: &str,
exit_code: i32,
output: Option<&str>,
) -> Result<AgentJobRecord, AgentSupervisorError> {
self.update_job(id, |record| {
record.pid = None;
record.exit_code = Some(exit_code);
record.completed_at = Some(timestamp());
record.status = Some("exited".to_string());
record.state = if exit_code == 0 {
AgentJobState::Done
} else {
AgentJobState::Failed
};
if let Some(output) = output {
record.output_tail = Some(tail(output, 8192));
}
})
}
pub fn stop_job(&self, id: &str) -> Result<AgentJobRecord, AgentSupervisorError> {
self.update_job(id, |record| {
record.pid = None;
record.state = AgentJobState::Stopped;
record.status = Some("stopped".to_string());
record.stopped_at = Some(timestamp());
})
}
pub fn respawn_job(&self, id: &str) -> Result<AgentJobRecord, AgentSupervisorError> {
self.update_job(id, |record| {
record.pid = None;
record.state = AgentJobState::Working;
record.status = Some("queued".to_string());
record.stopped_at = None;
record.completed_at = None;
record.exit_code = None;
})
}
pub fn remove_job(&self, id: &str) -> Result<(), AgentSupervisorError> {
let path = self.job_dir(id);
if !path.exists() {
return Err(AgentSupervisorError::JobNotFound(id.to_string()));
}
fs::remove_dir_all(path)?;
self.rewrite_roster()?;
Ok(())
}
pub fn read_logs(&self, id: &str) -> Result<String, AgentSupervisorError> {
let state = self.load_job(id)?;
let log_path = self.job_dir(id).join("output.log");
Ok(fs::read_to_string(log_path).unwrap_or_else(|_| {
state
.output_tail
.unwrap_or_else(|| "No output captured for this session yet.".to_string())
}))
}
pub fn daemon_status(
&self,
version: impl Into<String>,
) -> Result<AgentDaemonStatus, AgentSupervisorError> {
let filter = AgentListFilter {
cwd: None,
include_all: false,
};
let live_sessions = self.list_jobs(&filter)?.len();
Ok(AgentDaemonStatus {
reachable: self.roster_path().is_file() || live_sessions > 0,
version: version.into(),
config_dir: self.config_dir.display().to_string(),
socket_dir: self.daemon_dir().display().to_string(),
roster_path: self.roster_path().display().to_string(),
log_path: self.log_path().display().to_string(),
live_sessions,
worker_count: 0,
})
}
pub fn rewrite_roster(&self) -> Result<AgentRoster, AgentSupervisorError> {
fs::create_dir_all(self.daemon_dir())?;
let jobs = self.list_jobs(&AgentListFilter {
cwd: None,
include_all: true,
})?;
let now = timestamp();
let roster = AgentRoster {
version: ROSTER_VERSION,
updated_at: now,
sessions: jobs
.iter()
.map(|job| AgentRosterEntry {
id: job.id.clone(),
state: job.state.clone(),
cwd: job.cwd.clone(),
pid: job.pid,
name: job.name.clone(),
started_at: Some(job.started_at.clone()),
updated_at: Some(job.updated_at.clone()),
extra: BTreeMap::new(),
})
.collect(),
extra: BTreeMap::new(),
};
fs::write(self.roster_path(), serde_json::to_string_pretty(&roster)?)?;
Ok(roster)
}
fn update_job(
&self,
id: &str,
update: impl FnOnce(&mut AgentJobRecord),
) -> Result<AgentJobRecord, AgentSupervisorError> {
let mut record = self.load_job(id)?;
update(&mut record);
record.updated_at = timestamp();
self.save_job(&record)?;
self.rewrite_roster()?;
Ok(record)
}
}
pub fn default_claude_config_dir() -> Result<PathBuf, AgentSupervisorError> {
if let Ok(path) = env::var("CLAUDE_CONFIG_DIR") {
return Ok(PathBuf::from(path));
}
if let Some(home) = env::var_os("HOME") {
return Ok(PathBuf::from(home).join(".claude"));
}
Err(AgentSupervisorError::ConfigDirUnavailable)
}
fn canonical_string(path: &Path) -> String {
fs::canonicalize(path)
.unwrap_or_else(|_| path.to_path_buf())
.display()
.to_string()
}
fn timestamp() -> String {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.to_string()
}
fn short_job_id() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let pid = std::process::id() as u128;
format!("{:08x}", (nanos ^ pid) & 0xffff_ffff)
}
fn tail(value: &str, max_chars: usize) -> String {
let chars = value.chars().collect::<Vec<_>>();
if chars.len() <= max_chars {
return value.to_string();
}
chars[chars.len().saturating_sub(max_chars)..]
.iter()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
env::temp_dir().join(format!("agent-supervisor-{label}-{nanos}"))
}
#[test]
fn creates_job_state_and_roster_under_config_dir() {
let config = temp_dir("create");
let workspace = temp_dir("workspace");
fs::create_dir_all(&workspace).expect("workspace");
let supervisor = AgentSupervisor::from_config_dir(&config);
let job = supervisor
.create_job(AgentJobCreate {
cwd: workspace.clone(),
kind: AgentJobKind::Claude,
prompt: Some("fix the parser".to_string()),
command: None,
name: Some("parser-fix".to_string()),
model: Some("sonnet".to_string()),
agent: None,
permission_mode: Some("manual".to_string()),
reasoning_effort: Some("medium".to_string()),
})
.expect("job");
assert!(supervisor.state_path(&job.id).is_file());
assert!(supervisor.roster_path().is_file());
let listed = supervisor
.list_jobs(&AgentListFilter {
cwd: Some(workspace),
include_all: false,
})
.expect("list");
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].name.as_deref(), Some("parser-fix"));
let _ = fs::remove_dir_all(config);
}
#[test]
fn lifecycle_updates_state_and_default_listing_hides_finished_jobs() {
let config = temp_dir("lifecycle");
let workspace = temp_dir("workspace");
fs::create_dir_all(&workspace).expect("workspace");
let supervisor = AgentSupervisor::from_config_dir(&config);
let job = supervisor
.create_job(AgentJobCreate {
cwd: workspace,
kind: AgentJobKind::Exec,
prompt: None,
command: Some("echo hi".to_string()),
name: None,
model: None,
agent: None,
permission_mode: None,
reasoning_effort: None,
})
.expect("job");
supervisor.set_process(&job.id, 1234).expect("pid");
supervisor.append_log(&job.id, "hello\n").expect("log");
let done = supervisor
.finish_job(&job.id, 0, Some("hello\n"))
.expect("done");
assert_eq!(done.state, AgentJobState::Done);
assert_eq!(supervisor.read_logs(&job.id).expect("logs"), "hello\n");
assert!(supervisor
.list_jobs(&AgentListFilter::default())
.expect("default")
.is_empty());
assert_eq!(
supervisor
.list_jobs(&AgentListFilter {
cwd: None,
include_all: true,
})
.expect("all")
.len(),
1
);
supervisor.respawn_job(&job.id).expect("respawn");
assert_eq!(
supervisor.load_job(&job.id).expect("load").state,
AgentJobState::Working
);
supervisor.remove_job(&job.id).expect("remove");
assert!(matches!(
supervisor.load_job(&job.id),
Err(AgentSupervisorError::JobNotFound(_))
));
let _ = fs::remove_dir_all(config);
}
}

View File

@ -4,6 +4,7 @@
//! MCP plumbing, tool-facing file operations, and the core conversation loop
//! that drives interactive and one-shot turns.
pub mod agent_supervisor;
mod approval_tokens;
mod bash;
pub mod bash_validation;
@ -53,6 +54,11 @@ mod trust_resolver;
mod usage;
pub mod worker_boot;
pub use agent_supervisor::{
default_claude_config_dir, AgentDaemonStatus, AgentJobCreate, AgentJobKind, AgentJobRecord,
AgentJobState, AgentListFilter, AgentRoster, AgentRosterEntry, AgentSupervisor,
AgentSupervisorError,
};
pub use approval_tokens::{
ApprovalDelegationHop, ApprovalScope, ApprovalTokenAudit, ApprovalTokenError,
ApprovalTokenGrant, ApprovalTokenLedger, ApprovalTokenStatus,

File diff suppressed because it is too large Load Diff

View File

@ -891,7 +891,7 @@ fn inventory_commands_emit_structured_json_when_requested() {
let agents = assert_json_command_with_env(
&root,
&["--output-format", "json", "agents"],
&["--output-format", "json", "agents", "list"],
&[
("HOME", isolated_home.to_str().expect("utf8 home")),
(
@ -1156,7 +1156,7 @@ fn agents_command_emits_structured_agent_entries_when_requested() {
let parsed = assert_json_command_with_env(
&workspace,
&["--output-format", "json", "agents"],
&["--output-format", "json", "agents", "list"],
&[
("HOME", home.to_str().expect("utf8 home")),
(
@ -1186,6 +1186,99 @@ fn agents_command_emits_structured_agent_entries_when_requested() {
assert_eq!(parsed["agents"][2]["shadowed_by"]["id"], "project_claw");
}
#[test]
fn agent_view_background_exec_writes_official_state_and_json_array() {
let root = unique_temp_dir("agent-view-bg-exec");
let config = root.join("claude-config");
fs::create_dir_all(&root).expect("root should exist");
fs::create_dir_all(&config).expect("config should exist");
let canonical_root = fs::canonicalize(&root).expect("canonical root");
let envs = [("CLAUDE_CONFIG_DIR", config.to_str().expect("utf8 config"))];
let created = run_claw(
&root,
&[
"--output-format",
"json",
"--bg",
"--name",
"smoke-exec",
"--exec",
"printf agent-hi",
],
&envs,
);
assert!(
created.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&created.stdout),
String::from_utf8_lossy(&created.stderr)
);
let created_json = parse_json_stdout(&created, "agent background create");
assert_eq!(created_json["kind"], "agents");
assert_eq!(created_json["action"], "background");
let id = created_json["session"]["id"]
.as_str()
.expect("session id")
.to_string();
let state_path = config.join("jobs").join(&id).join("state.json");
let tmp_dir = config.join("jobs").join(&id).join("tmp");
let roster_path = config.join("daemon").join("roster.json");
assert!(tmp_dir.is_dir(), "official tmp dir should exist");
assert!(roster_path.is_file(), "official roster should exist");
let mut state = Value::Null;
for _ in 0..100 {
if state_path.is_file() {
let contents = fs::read_to_string(&state_path).expect("state should read");
state = serde_json::from_str(&contents).expect("state json");
if state["state"] == "done" {
break;
}
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert_eq!(state["id"], id);
assert_eq!(state["kind"], "exec");
assert_eq!(state["cwd"], canonical_root.display().to_string());
assert_eq!(state["name"], "smoke-exec");
assert_eq!(state["state"], "done");
assert_eq!(state["exitCode"], 0);
let raw_agents = run_claw(&root, &["agents", "--json", "--all"], &envs);
assert!(raw_agents.status.success());
let raw = parse_json_stdout(&raw_agents, "agents --json --all");
let sessions = raw
.as_array()
.expect("official agents --json returns array");
let session = sessions
.iter()
.find(|session| session["id"] == id)
.expect("background session listed");
assert_eq!(session["cwd"], canonical_root.display().to_string());
assert_eq!(session["kind"], "exec");
assert_eq!(session["state"], "done");
assert!(session["startedAt"].is_string());
let logs =
assert_json_command_with_env(&root, &["--output-format", "json", "logs", &id], &envs);
assert_eq!(logs["kind"], "agents");
assert_eq!(logs["action"], "logs");
assert!(logs["logs"]
.as_str()
.expect("logs text")
.contains("agent-hi"));
let daemon = assert_json_command_with_env(
&root,
&["--output-format", "json", "daemon", "status"],
&envs,
);
assert_eq!(daemon["configDir"], config.display().to_string());
assert_eq!(daemon["rosterPath"], roster_path.display().to_string());
}
#[test]
fn agents_and_skills_inventory_share_source_schema_702() {
let root = unique_temp_dir("inventory-source-schema-702");
@ -1220,8 +1313,11 @@ fn agents_and_skills_inventory_share_source_schema_702() {
isolated_codex.to_str().expect("utf8 codex home"),
),
];
let agents =
assert_json_command_with_env(&workspace, &["--output-format", "json", "agents"], &envs);
let agents = assert_json_command_with_env(
&workspace,
&["--output-format", "json", "agents", "list"],
&envs,
);
let skills =
assert_json_command_with_env(&workspace, &["--output-format", "json", "skills"], &envs);
@ -1660,6 +1756,7 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() {
"--resume",
session_path.to_str().expect("utf8 session path"),
"/agents",
"list",
],
&[
(