feat: implement Claude Code agent view supervisor
This commit is contained in:
parent
1a73dc2c4e
commit
0e2214aeff
|
|
@ -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.
|
||||
|
|
|
|||
22
USAGE.md
22
USAGE.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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** |
|
||||
|
|
|
|||
|
|
@ -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>]`
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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",
|
||||
],
|
||||
&[
|
||||
(
|
||||
|
|
|
|||
Loading…
Reference in New Issue