diff --git a/PARITY.md b/PARITY.md index 3daa8d45..86621f94 100644 --- a/PARITY.md +++ b/PARITY.md @@ -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//state.json`, `jobs//tmp/`, `daemon/roster.json`, and `daemon.log`. `claw agents`, `claw agents --json [--all] [--cwd ]`, `claw --bg`, `claw --bg --exec`, `claw attach/logs/stop/kill/respawn/rm `, 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. diff --git a/USAGE.md b/USAGE.md index a8ff1711..d431987f 100644 --- a/USAGE.md +++ b/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 ./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//state.json`, per-session temp files under `jobs//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 +./target/debug/claw logs +./target/debug/claw stop +./target/debug/claw respawn +./target/debug/claw rm +./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. diff --git a/rust/PARITY.md b/rust/PARITY.md index f5fb705f..5f04b5c7 100644 --- a/rust/PARITY.md +++ b/rust/PARITY.md @@ -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//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** | diff --git a/rust/README.md b/rust/README.md index afd6245c..c8409a18 100644 --- a/rust/README.md +++ b/rust/README.md @@ -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 |install |uninstall |help]` -- `/agents [list|show |create |help]` +- `/agents [--all] [--cwd ] [list|show |create |help]` - `/mcp [list|show |help]` - `/doctor` - `/plugin [list|install |enable |disable |uninstall |update ]` diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 7dcdca56..06e811f2 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -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 |create |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, } +#[derive(Debug, Clone)] +struct AgentViewCollection { + config_dir: PathBuf, + store_dir: PathBuf, + views: Vec, +} + +#[derive(Debug, Clone)] +struct AgentRunView { + agent_id: String, + name: String, + description: String, + subagent_type: Option, + model: Option, + status: String, + state: String, + output_file: Option, + manifest_file: String, + started_at: Option, + completed_at: Option, + pid: Option, + waiting_for: Option, + session_id: Option, + cwd: String, + kind: String, + error: Option, +} + +#[derive(Debug, Clone, Default)] +struct AgentViewOptions { + include_all: bool, + cwd: Option, +} + #[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 { + let options = parse_agent_view_options(args)?; + let collection = load_agent_views(&options)?; + Ok(Value::Array( + collection + .views + .iter() + .map(agent_view_json) + .collect::>(), + )) +} + 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 { Ok(CreatedAgent { name, path }) } +fn parse_agent_view_options(args: Option<&str>) -> std::io::Result { + let mut options = AgentViewOptions::default(); + let mut parts = args + .unwrap_or_default() + .split_whitespace() + .collect::>() + .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 ]" + ), + )); + } + } + } + Ok(options) +} + +fn load_agent_views(options: &AgentViewOptions) -> std::io::Result { + 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::>(); + Ok(AgentViewCollection { + config_dir: supervisor.config_dir().to_path_buf(), + store_dir: supervisor.jobs_dir(), + views, + }) +} + fn default_skill_install_root() -> std::io::Result { 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("") + )); + 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::>(), + }) +} + +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> { fn render_agents_usage(unexpected: Option<&str>) -> String { let mut lines = vec![ "Agents".to_string(), - " Usage /agents [list|show |create |help]".to_string(), - " Direct CLI claw agents [list|show |create |help]".to_string(), + " Usage /agents [--all] [--cwd ] [list|show |create |help]" + .to_string(), + " Direct CLI claw agents [--json] [--all] [--cwd ] [list|show |create |help]" + .to_string(), + " Background claw --bg \"\"; claw --bg --exec ''".to_string(), + " Lifecycle claw attach|logs|stop|kill|respawn|rm ; claw daemon status".to_string(), " Format TOML files (.toml); create scaffolds .claw/agents/.toml" .to_string(), " Sources .claw/agents, ~/.claw/agents, $CLAW_CONFIG_HOME/agents".to_string(), + " Session state $CLAUDE_CONFIG_DIR/jobs//state.json or ~/.claude/jobs//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 |create |help]", - "direct_cli": "claw agents [list|show |create |help]", + "slash_command": "/agents [--all] [--cwd ] [list|show |create |help]", + "direct_cli": "claw agents [--json] [--all] [--cwd ] [list|show |create |help]", "format": "toml", "create": "claw agents create ", + "background": "claw --bg \"\"", "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 |create |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 |create |help]" + "claw agents [--json] [--all] [--cwd ] [list|show |create |help]" ); // `show ` 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 |create |help]") - ); - assert!(agents_help - .contains("Direct CLI claw agents [list|show |create |help]")); + assert!(agents_help.contains( + "Usage /agents [--all] [--cwd ] [list|show |create |help]" + )); + assert!(agents_help.contains( + "Direct CLI claw agents [--json] [--all] [--cwd ] [list|show |create |help]" + )); assert!(agents_help.contains( "Format TOML files (.toml); create scaffolds .claw/agents/.toml" )); diff --git a/rust/crates/runtime/src/agent_supervisor.rs b/rust/crates/runtime/src/agent_supervisor.rs new file mode 100644 index 00000000..02eca08a --- /dev/null +++ b/rust/crates/runtime/src/agent_supervisor.rs @@ -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 for AgentSupervisorError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +impl From 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, + pub include_all: bool, +} + +#[derive(Debug, Clone)] +pub struct AgentJobCreate { + pub cwd: PathBuf, + pub kind: AgentJobKind, + pub prompt: Option, + pub command: Option, + pub name: Option, + pub model: Option, + pub agent: Option, + pub permission_mode: Option, + pub reasoning_effort: Option, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub waiting_for: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub command: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub permission_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub output_tail: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stopped_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub completed_at: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentRoster { + pub version: u32, + pub updated_at: String, + pub sessions: Vec, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub started_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[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 { + Ok(Self { + config_dir: default_claude_config_dir()?, + }) + } + + #[must_use] + pub fn from_config_dir(config_dir: impl Into) -> 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 { + 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, 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 { + 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 { + 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, + ) -> Result { + self.update_job(id, |record| { + record.session_id = Some(session_id.into()); + }) + } + + pub fn append_log(&self, id: &str, text: &str) -> Result { + 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 { + 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 { + 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 { + 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 { + 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, + ) -> Result { + 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 { + 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 { + 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 { + 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::>(); + 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); + } +} diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 674d8925..a39fddbe 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -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, diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index faf94fb9..9db7d098 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -26,12 +26,20 @@ use std::io::{self, IsTerminal, Read, Write}; use std::net::TcpListener; use std::ops::{Deref, DerefMut}; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; use std::sync::{Arc, Mutex, OnceLock}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant, UNIX_EPOCH}; +use crossterm::cursor::{Hide, MoveTo, Show}; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; +use crossterm::execute; +use crossterm::style::Print; +use crossterm::terminal::{ + self, disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen, + LeaveAlternateScreen, +}; use log::debug; use api::{ @@ -43,12 +51,12 @@ use api::{ }; use commands::{ - classify_skills_slash_command, handle_agents_slash_command, handle_agents_slash_command_json, - handle_mcp_slash_command, handle_mcp_slash_command_json, handle_plugins_slash_command, - handle_skills_slash_command, handle_skills_slash_command_json, render_slash_command_help, - render_slash_command_help_filtered, resolve_skill_invocation, resume_supported_slash_commands, - slash_command_specs, validate_slash_command_input, PluginsCommandResult, SkillSlashDispatch, - SlashCommand, + classify_skills_slash_command, handle_agent_view_json_array, handle_agents_slash_command, + handle_agents_slash_command_json, handle_mcp_slash_command, handle_mcp_slash_command_json, + handle_plugins_slash_command, handle_skills_slash_command, handle_skills_slash_command_json, + render_slash_command_help, render_slash_command_help_filtered, resolve_skill_invocation, + resume_supported_slash_commands, slash_command_specs, validate_slash_command_input, + PluginsCommandResult, SkillSlashDispatch, SlashCommand, }; use init::initialize_repo; use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry}; @@ -304,6 +312,11 @@ const CLI_OPTION_SUGGESTIONS: &[&str] = &[ "--resume", "--acp", "-acp", + "--bg", + "--background", + "--exec", + "--name", + "--agent", "--print", "--compact", "--base-commit", @@ -1012,7 +1025,57 @@ fn run() -> Result<(), Box> { CliAction::Agents { args, output_format, - } => LiveCli::print_agents(args.as_deref(), output_format)?, + model, + permission_mode, + reasoning_effort, + agent, + } => LiveCli::print_agents(AgentViewPrintRequest { + args: args.as_deref(), + output_format, + model: &model, + permission_mode, + reasoning_effort: reasoning_effort.as_deref(), + agent: agent.as_deref(), + })?, + CliAction::Background { + prompt, + exec, + name, + agent, + model, + output_format, + permission_mode, + reasoning_effort, + } => run_background_agent(BackgroundAgentRequest { + prompt: prompt.as_deref(), + exec: exec.as_deref(), + name: name.as_deref(), + agent: agent.as_deref(), + model: &model, + output_format, + permission_mode, + reasoning_effort: reasoning_effort.as_deref(), + })?, + CliAction::AgentAttach { id, output_format } => run_agent_attach(&id, output_format)?, + CliAction::AgentLogs { id, output_format } => run_agent_logs(&id, output_format)?, + CliAction::AgentStop { + id, + signal, + output_format, + } => run_agent_stop(&id, signal, output_format)?, + CliAction::AgentRespawn { + id, + all, + output_format, + } => run_agent_respawn(id.as_deref(), all, output_format)?, + CliAction::AgentRemove { id, output_format } => run_agent_remove(&id, output_format)?, + CliAction::DaemonStatus { output_format } => run_agent_daemon_status(output_format)?, + CliAction::DaemonStop { + any, + keep_workers, + output_format, + } => run_agent_daemon_stop(any, keep_workers, output_format)?, + CliAction::AgentWorker { id } => run_agent_worker(&id)?, CliAction::Mcp { args, output_format, @@ -1171,6 +1234,53 @@ enum CliAction { Agents { args: Option, output_format: CliOutputFormat, + model: String, + permission_mode: PermissionMode, + reasoning_effort: Option, + agent: Option, + }, + Background { + prompt: Option, + exec: Option, + name: Option, + agent: Option, + model: String, + output_format: CliOutputFormat, + permission_mode: PermissionMode, + reasoning_effort: Option, + }, + AgentAttach { + id: String, + output_format: CliOutputFormat, + }, + AgentLogs { + id: String, + output_format: CliOutputFormat, + }, + AgentStop { + id: String, + signal: AgentStopSignal, + output_format: CliOutputFormat, + }, + AgentRespawn { + id: Option, + all: bool, + output_format: CliOutputFormat, + }, + AgentRemove { + id: String, + output_format: CliOutputFormat, + }, + DaemonStatus { + output_format: CliOutputFormat, + }, + DaemonStop { + any: bool, + keep_workers: bool, + output_format: CliOutputFormat, + }, + AgentWorker { + id: String, }, Mcp { args: Option, @@ -1315,6 +1425,12 @@ enum CliOutputFormat { Json, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AgentStopSignal { + Term, + Kill, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum OutputFormatSource { Default, @@ -1496,6 +1612,11 @@ fn parse_args(args: &[String]) -> Result { let mut base_commit: Option = None; let mut reasoning_effort: Option = None; let mut allow_broad_cwd = false; + let mut background = false; + let mut background_exec: Option = None; + let mut background_name: Option = None; + let mut background_agent: Option = None; + let mut print_requested = false; // #755: -p prompt text captured as single token; remaining args continue // flag parsing. None until `-p ` is seen. @@ -1596,6 +1717,56 @@ fn parse_args(args: &[String]) -> Result { permission_mode_override = Some(PermissionMode::DangerFullAccess); index += 1; } + "--bg" | "--background" => { + background = true; + index += 1; + } + "--exec" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing_flag_value: missing value for --exec.\nUsage: claw --bg --exec ''".to_string())?; + if value.trim().is_empty() { + return Err( + "missing_flag_value: --exec requires a non-empty command.\nUsage: claw --bg --exec ''" + .to_string(), + ); + } + background_exec = Some(value.clone()); + index += 2; + } + flag if flag.starts_with("--exec=") => { + let value = flag["--exec=".len()..].trim(); + if value.is_empty() { + return Err( + "missing_flag_value: --exec requires a non-empty command.\nUsage: claw --bg --exec ''" + .to_string(), + ); + } + background_exec = Some(value.to_string()); + index += 1; + } + "--name" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing_flag_value: missing value for --name.\nUsage: claw --bg --name ''".to_string())?; + background_name = Some(value.clone()); + index += 2; + } + flag if flag.starts_with("--name=") => { + background_name = Some(flag["--name=".len()..].to_string()); + index += 1; + } + "--agent" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing_flag_value: missing value for --agent.\nUsage: claw --bg --agent ''".to_string())?; + background_agent = Some(value.clone()); + index += 2; + } + flag if flag.starts_with("--agent=") => { + background_agent = Some(flag["--agent=".len()..].to_string()); + index += 1; + } "--compact" => { compact = true; index += 1; @@ -1695,6 +1866,7 @@ fn parse_args(args: &[String]) -> Result { } "--print" => { // Claw Code compat: --print makes output non-interactive + print_requested = true; output_format = CliOutputFormat::Text; index += 1; } @@ -1808,6 +1980,57 @@ fn parse_args(args: &[String]) -> Result { let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?; + if background_exec.is_some() && !background { + return Err( + "invalid_flag_value: --exec is only supported with --bg/--background.\nUsage: claw --bg --exec ''" + .to_string(), + ); + } + + if background { + if print_requested || short_p_prompt.is_some() { + return Err( + "invalid_flag_value: --bg cannot be combined with -p/--print.\nUsage: claw --bg '' or claw --bg --exec ''" + .to_string(), + ); + } + if compact { + return Err( + "invalid_flag_value: --compact is only supported with foreground prompt mode.\nUsage: claw --bg '' or claw --compact ''" + .to_string(), + ); + } + let positional_prompt = rest.join(" "); + if background_exec.is_some() && !positional_prompt.trim().is_empty() { + return Err( + "unexpected_extra_args: --bg --exec does not accept a separate prompt.\nUsage: claw --bg --exec ''" + .to_string(), + ); + } + let prompt = if background_exec.is_none() { + let prompt = positional_prompt.trim().to_string(); + if prompt.is_empty() { + return Err( + "missing_prompt: --bg requires a prompt unless --exec is used.\nUsage: claw --bg '' or claw --bg --exec ''" + .to_string(), + ); + } + Some(prompt) + } else { + None + }; + return Ok(CliAction::Background { + prompt, + exec: background_exec, + name: background_name, + agent: background_agent, + model: resolve_model_alias_with_config(&model), + output_format, + permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode), + reasoning_effort, + }); + } + // #755: -p consumed exactly one token; dispatch now that all flags are parsed if let Some(prompt) = short_p_prompt { return Ok(CliAction::Prompt { @@ -1947,11 +2170,23 @@ fn parse_args(args: &[String]) -> Result { } match rest[0].as_str() { + "__agent-worker" => parse_agent_worker_args(&rest[1..]), "dump-manifests" => parse_dump_manifests_args(&rest[1..], output_format), "bootstrap-plan" => Ok(CliAction::BootstrapPlan { output_format }), + "attach" => parse_agent_id_action("attach", &rest[1..], output_format), + "logs" => parse_agent_id_action("logs", &rest[1..], output_format), + "stop" => parse_agent_stop_args(&rest[1..], AgentStopSignal::Term, output_format), + "kill" => parse_agent_stop_args(&rest[1..], AgentStopSignal::Kill, output_format), + "respawn" => parse_agent_respawn_args(&rest[1..], output_format), + "rm" | "remove" => parse_agent_id_action("rm", &rest[1..], output_format), + "daemon" => parse_agent_daemon_args(&rest[1..], output_format), "agents" => Ok(CliAction::Agents { args: join_optional_args(&rest[1..]), output_format, + model: resolve_model_alias_with_config(&model), + permission_mode: permission_mode(), + reasoning_effort: reasoning_effort.clone(), + agent: background_agent.clone(), }), "mcp" => Ok(CliAction::Mcp { args: join_optional_args(&rest[1..]), @@ -2488,6 +2723,14 @@ fn bare_slash_command_guidance(command_name: &str) -> Option { command_name, "dump-manifests" | "bootstrap-plan" + | "attach" + | "logs" + | "stop" + | "kill" + | "respawn" + | "rm" + | "remove" + | "daemon" | "agents" | "mcp" | "plugin" @@ -2550,6 +2793,131 @@ fn parse_acp_args(args: &[String], output_format: CliOutputFormat) -> Result Result { + match args { + [id] if !id.trim().is_empty() => Ok(CliAction::AgentWorker { id: id.clone() }), + [] => Err("missing_argument: __agent-worker requires a session id".to_string()), + _ => { + Err("unexpected_extra_args: __agent-worker accepts exactly one session id".to_string()) + } + } +} + +fn parse_agent_id_action( + action: &str, + args: &[String], + output_format: CliOutputFormat, +) -> Result { + match args { + [id] if !id.trim().is_empty() => match action { + "attach" => Ok(CliAction::AgentAttach { + id: id.clone(), + output_format, + }), + "logs" => Ok(CliAction::AgentLogs { + id: id.clone(), + output_format, + }), + "rm" => Ok(CliAction::AgentRemove { + id: id.clone(), + output_format, + }), + _ => unreachable!("unsupported agent id action"), + }, + [] => Err(format!( + "missing_argument: {action} requires a background session id.\nUsage: claw {action} " + )), + _ => Err(format!( + "unexpected_extra_args: claw {action} accepts exactly one session id.\nUsage: claw {action} " + )), + } +} + +fn parse_agent_stop_args( + args: &[String], + signal: AgentStopSignal, + output_format: CliOutputFormat, +) -> Result { + match args { + [id] if !id.trim().is_empty() => Ok(CliAction::AgentStop { + id: id.clone(), + signal, + output_format, + }), + [] => Err("missing_argument: stop requires a background session id.\nUsage: claw stop or claw kill ".to_string()), + _ => Err("unexpected_extra_args: stop/kill accepts exactly one session id.\nUsage: claw stop or claw kill ".to_string()), + } +} + +fn parse_agent_respawn_args( + args: &[String], + output_format: CliOutputFormat, +) -> Result { + match args { + [flag] if flag == "--all" => Ok(CliAction::AgentRespawn { + id: None, + all: true, + output_format, + }), + [id] if !id.trim().is_empty() => Ok(CliAction::AgentRespawn { + id: Some(id.clone()), + all: false, + output_format, + }), + [] => Err( + "missing_argument: respawn requires a session id or --all.\nUsage: claw respawn or claw respawn --all" + .to_string(), + ), + _ => Err( + "unexpected_extra_args: respawn accepts a session id or --all.\nUsage: claw respawn or claw respawn --all" + .to_string(), + ), + } +} + +fn parse_agent_daemon_args( + args: &[String], + output_format: CliOutputFormat, +) -> Result { + match args { + [action] if action == "status" => Ok(CliAction::DaemonStatus { output_format }), + [action, flags @ ..] if action == "stop" => { + let mut any = false; + let mut keep_workers = false; + for flag in flags { + match flag.as_str() { + "--any" => any = true, + "--keep-workers" => keep_workers = true, + other => { + return Err(format!( + "unknown_option: unsupported daemon stop option `{other}`.\nUsage: claw daemon stop --any [--keep-workers]" + )); + } + } + } + if !any { + return Err( + "missing_argument: daemon stop requires --any.\nUsage: claw daemon stop --any [--keep-workers]" + .to_string(), + ); + } + Ok(CliAction::DaemonStop { + any, + keep_workers, + output_format, + }) + } + [] => Err( + "missing_argument: daemon requires status or stop.\nUsage: claw daemon status | claw daemon stop --any [--keep-workers]" + .to_string(), + ), + _ => Err( + "unknown subcommand: daemon supports status or stop.\nUsage: claw daemon status | claw daemon stop --any [--keep-workers]" + .to_string(), + ), + } +} + fn try_resolve_bare_skill_prompt(cwd: &Path, trimmed: &str) -> Option { let bare_first_token = trimmed.split_whitespace().next().unwrap_or_default(); let looks_like_skill_name = !bare_first_token.is_empty() @@ -2634,6 +3002,10 @@ fn parse_direct_slash_cli_action( Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents { args, output_format, + model, + permission_mode: permission_mode.mode, + reasoning_effort, + agent: None, }), Ok(Some(SlashCommand::Mcp { action, target })) => Ok(CliAction::Mcp { args: match (action, target) { @@ -4830,7 +5202,7 @@ fn build_rust_resolver_manifest(workspace_dir: &Path) -> Result>(); - let agent_report = handle_agents_slash_command_json(None, workspace_dir)?; + let agent_report = handle_agents_slash_command_json(Some("list"), workspace_dir)?; let skill_report = handle_skills_slash_command_json(None, workspace_dir)?; let agents = agent_report .get("agents") @@ -7088,11 +7460,14 @@ fn run_repl( let resolved_model = resolve_repl_model(model)?; let mut cli = LiveCli::new(resolved_model, true, allowed_tools, permission_mode)?; cli.set_reasoning_effort(reasoning_effort); - let mut editor = - input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default()); println!("{}", cli.startup_banner()); println!("{}", format_connected_line(&cli.model)); + run_live_cli_loop(&mut cli) +} +fn run_live_cli_loop(cli: &mut LiveCli) -> Result<(), Box> { + let mut editor = + input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default()); loop { editor.set_completions(cli.repl_completion_candidates().unwrap_or_default()); match editor.read_line()? { @@ -7284,6 +7659,513 @@ impl Drop for BuiltRuntime { } } +#[derive(Debug, Clone, Copy)] +struct AgentViewPrintRequest<'a> { + args: Option<&'a str>, + output_format: CliOutputFormat, + model: &'a str, + permission_mode: PermissionMode, + reasoning_effort: Option<&'a str>, + agent: Option<&'a str>, +} + +#[derive(Debug, Clone, Default)] +struct AgentViewTuiOptions { + include_all: bool, + cwd: Option, +} + +#[derive(Debug)] +struct AgentViewTuiState { + options: AgentViewTuiOptions, + selected: usize, + peek_open: bool, + input: String, + status: Option, + confirm_remove_id: Option, +} + +#[derive(Debug)] +enum AgentViewTuiExit { + Quit, + Attach(String), +} + +struct AgentViewTerminalGuard { + active: bool, +} + +impl AgentViewTerminalGuard { + fn enter() -> Result> { + enable_raw_mode()?; + execute!(io::stdout(), EnterAlternateScreen, Hide)?; + Ok(Self { active: true }) + } +} + +impl Drop for AgentViewTerminalGuard { + fn drop(&mut self) { + if self.active { + let _ = execute!(io::stdout(), Show, LeaveAlternateScreen); + let _ = disable_raw_mode(); + } + } +} + +fn should_open_agent_view_tui(args: Option<&str>, output_format: CliOutputFormat) -> bool { + if output_format != CliOutputFormat::Text + || !io::stdin().is_terminal() + || !io::stdout().is_terminal() + { + return false; + } + let Some(args) = args.map(str::trim).filter(|value| !value.is_empty()) else { + return true; + }; + if args.split_whitespace().any(|part| part == "--json") { + return false; + } + let first = args.split_whitespace().next().unwrap_or_default(); + first.starts_with("--") && !matches!(first, "--help" | "-h") +} + +fn parse_agent_view_tui_options(args: Option<&str>) -> Result { + let mut options = AgentViewTuiOptions::default(); + let mut parts = args + .unwrap_or_default() + .split_whitespace() + .collect::>() + .into_iter(); + while let Some(part) = parts.next() { + match part { + "--all" => options.include_all = true, + "--cwd" => { + let Some(value) = parts.next() else { + return Err("missing_flag_value: agents --cwd requires a path".to_string()); + }; + options.cwd = Some(PathBuf::from(value)); + } + value if value.starts_with("--cwd=") => { + options.cwd = Some(PathBuf::from(&value["--cwd=".len()..])); + } + "--json" => {} + other => { + return Err(format!( + "unknown agents option: {other}\nUsage: claw agents [--json] [--all] [--cwd ]" + )); + } + } + } + Ok(options) +} + +fn run_agent_view_tui( + request: &AgentViewPrintRequest<'_>, +) -> Result<(), Box> { + let options = parse_agent_view_tui_options(request.args) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + let supervisor = runtime::AgentSupervisor::from_default_config()?; + let mut state = AgentViewTuiState { + options, + selected: 0, + peek_open: false, + input: String::new(), + status: None, + confirm_remove_id: None, + }; + let attach = { + let _terminal = AgentViewTerminalGuard::enter()?; + run_agent_view_tui_loop(&supervisor, request, &mut state)? + }; + match attach { + AgentViewTuiExit::Quit => Ok(()), + AgentViewTuiExit::Attach(id) => run_agent_attach(&id, CliOutputFormat::Text), + } +} + +fn run_agent_view_tui_loop( + supervisor: &runtime::AgentSupervisor, + request: &AgentViewPrintRequest<'_>, + state: &mut AgentViewTuiState, +) -> Result> { + let mut stdout = io::stdout(); + loop { + let jobs = load_agent_view_tui_jobs(supervisor, &state.options)?; + if state.selected >= jobs.len() { + state.selected = jobs.len().saturating_sub(1); + } + render_agent_view_tui(&mut stdout, state, &jobs)?; + if !event::poll(Duration::from_millis(500))? { + continue; + } + let Event::Key(key) = event::read()? else { + continue; + }; + if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('c')) { + return Ok(AgentViewTuiExit::Quit); + } + if let Some(exit) = handle_agent_view_key(supervisor, request, state, &jobs, key)? { + return Ok(exit); + } + } +} + +fn load_agent_view_tui_jobs( + supervisor: &runtime::AgentSupervisor, + options: &AgentViewTuiOptions, +) -> Result, Box> { + let mut jobs = supervisor.list_jobs(&runtime::AgentListFilter { + cwd: options.cwd.clone(), + include_all: options.include_all, + })?; + jobs.sort_by(|left, right| { + agent_view_group_rank(left) + .cmp(&agent_view_group_rank(right)) + .then(right.updated_at.cmp(&left.updated_at)) + .then(left.id.cmp(&right.id)) + }); + Ok(jobs) +} + +fn handle_agent_view_key( + supervisor: &runtime::AgentSupervisor, + request: &AgentViewPrintRequest<'_>, + state: &mut AgentViewTuiState, + jobs: &[runtime::AgentJobRecord], + key: KeyEvent, +) -> Result, Box> { + match key.code { + KeyCode::Esc | KeyCode::Char('q') if state.input.is_empty() => { + return Ok(Some(AgentViewTuiExit::Quit)); + } + KeyCode::Up if state.input.is_empty() => { + state.selected = state.selected.saturating_sub(1); + state.confirm_remove_id = None; + } + KeyCode::Down if state.input.is_empty() => { + if !jobs.is_empty() { + state.selected = (state.selected + 1).min(jobs.len() - 1); + } + state.confirm_remove_id = None; + } + KeyCode::Char(' ') if state.input.is_empty() => { + state.peek_open = !state.peek_open; + state.confirm_remove_id = None; + } + KeyCode::Left if state.input.is_empty() => { + state.peek_open = false; + state.confirm_remove_id = None; + } + KeyCode::Right | KeyCode::Enter if state.input.trim().is_empty() => { + if let Some(job) = jobs.get(state.selected) { + return Ok(Some(AgentViewTuiExit::Attach(job.id.clone()))); + } + } + KeyCode::Enter => { + let prompt = state.input.trim().to_string(); + if !prompt.is_empty() { + let job = start_background_agent_job(BackgroundAgentRequest { + prompt: Some(&prompt), + exec: None, + name: None, + agent: request.agent, + model: request.model, + output_format: request.output_format, + permission_mode: request.permission_mode, + reasoning_effort: request.reasoning_effort, + })?; + state.status = Some(format!("started background session {}", job.id)); + state.input.clear(); + state.selected = 0; + state.peek_open = false; + state.confirm_remove_id = None; + } + } + KeyCode::Backspace => { + state.input.pop(); + state.confirm_remove_id = None; + } + KeyCode::Char('s') if state.input.is_empty() => { + if let Some(job) = jobs.get(state.selected) { + stop_agent_view_job(supervisor, job, AgentStopSignal::Term, state)?; + } + } + KeyCode::Char('k') if state.input.is_empty() => { + if let Some(job) = jobs.get(state.selected) { + stop_agent_view_job(supervisor, job, AgentStopSignal::Kill, state)?; + } + } + KeyCode::Char('r') if state.input.is_empty() => { + if let Some(job) = jobs.get(state.selected) { + let queued = supervisor.respawn_job(&job.id)?; + let child = spawn_agent_worker(&queued.id, Path::new(&queued.cwd))?; + let job = supervisor.set_process(&queued.id, child.id())?; + state.status = Some(format!( + "respawned {} with pid {}", + job.id, + job.pid + .map_or_else(|| "".to_string(), |pid| pid.to_string()) + )); + state.peek_open = false; + state.confirm_remove_id = None; + } + } + KeyCode::Char('d') if state.input.is_empty() => { + if let Some(job) = jobs.get(state.selected) { + if state.confirm_remove_id.as_deref() == Some(job.id.as_str()) { + supervisor.remove_job(&job.id)?; + state.status = Some(format!("removed background session {}", job.id)); + state.confirm_remove_id = None; + state.peek_open = false; + } else { + state.confirm_remove_id = Some(job.id.clone()); + state.status = Some(format!("press d again to remove {}", job.id)); + } + } + } + KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + state.input.push(ch); + state.confirm_remove_id = None; + } + _ => {} + } + Ok(None) +} + +fn stop_agent_view_job( + supervisor: &runtime::AgentSupervisor, + job: &runtime::AgentJobRecord, + signal: AgentStopSignal, + state: &mut AgentViewTuiState, +) -> Result<(), Box> { + if let Some(pid) = job.pid { + let _ = signal_process(pid, signal); + } + let stopped = supervisor.stop_job(&job.id)?; + state.status = Some(format!( + "stopped {} with {}", + stopped.id, + match signal { + AgentStopSignal::Term => "TERM", + AgentStopSignal::Kill => "KILL", + } + )); + state.peek_open = false; + state.confirm_remove_id = None; + Ok(()) +} + +fn render_agent_view_tui( + stdout: &mut io::Stdout, + state: &AgentViewTuiState, + jobs: &[runtime::AgentJobRecord], +) -> Result<(), Box> { + let (cols, rows) = terminal::size().unwrap_or((100, 30)); + let screen = render_agent_view_screen(state, jobs, cols as usize, rows as usize); + execute!(stdout, MoveTo(0, 0), Clear(ClearType::All), Print(screen))?; + stdout.flush()?; + Ok(()) +} + +fn render_agent_view_screen( + state: &AgentViewTuiState, + jobs: &[runtime::AgentJobRecord], + width: usize, + height: usize, +) -> String { + let width = width.max(40); + let height = height.max(12); + let mut lines = Vec::new(); + let summary = summarize_agent_jobs(jobs); + lines.push(truncate_chars( + &format!( + "Claude Code Agent View count={} working={} needs_input={} completed={}", + jobs.len(), + summary.working, + summary.blocked, + summary.completed + ), + width, + )); + lines.push(truncate_chars( + &format!( + "Scope: {}", + state.options.cwd.as_ref().map_or_else( + || "all projects".to_string(), + |path| path.display().to_string() + ) + ), + width, + )); + if let Some(status) = &state.status { + lines.push(truncate_chars(&format!("Status: {status}"), width)); + } + lines.push(String::new()); + + if jobs.is_empty() { + lines + .push("No background sessions. Type a prompt and press Enter to dispatch.".to_string()); + } else { + let mut current_group = ""; + for (index, job) in jobs.iter().enumerate() { + let group = agent_view_group_label(job); + if group != current_group { + if !current_group.is_empty() { + lines.push(String::new()); + } + lines.push(group.to_string()); + current_group = group; + } + lines.push(render_agent_view_row(job, index == state.selected, width)); + } + } + + if state.peek_open { + lines.push(String::new()); + lines.push(truncate_chars(&"-".repeat(width), width)); + if let Some(job) = jobs.get(state.selected) { + lines.extend(render_agent_view_peek(job, width)); + } else { + lines.push("Peek: no session selected".to_string()); + } + } + + while lines.len() + 3 < height { + lines.push(String::new()); + } + lines.truncate(height.saturating_sub(2)); + lines.push(truncate_chars(&format!("Dispatch: {}", state.input), width)); + lines.push(truncate_chars( + "Enter dispatch/attach Space peek Up/Down select s stop k kill r respawn d rm q quit", + width, + )); + lines.join("\n") +} + +#[derive(Default)] +struct AgentViewSummaryCounts { + working: usize, + blocked: usize, + completed: usize, +} + +fn summarize_agent_jobs(jobs: &[runtime::AgentJobRecord]) -> AgentViewSummaryCounts { + let mut summary = AgentViewSummaryCounts::default(); + for job in jobs { + match job.state { + runtime::AgentJobState::Working => summary.working += 1, + runtime::AgentJobState::Blocked => summary.blocked += 1, + runtime::AgentJobState::Done + | runtime::AgentJobState::Failed + | runtime::AgentJobState::Stopped => summary.completed += 1, + } + } + summary +} + +fn render_agent_view_row(job: &runtime::AgentJobRecord, selected: bool, width: usize) -> String { + let selector = if selected { ">" } else { " " }; + let pid = job + .pid + .map_or_else(|| ".".to_string(), |pid| format!("*{pid}")); + let name = job + .name + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or(&job.id); + let detail = job + .waiting_for + .as_deref() + .or(job.output_tail.as_deref()) + .or(job.prompt.as_deref()) + .or(job.command.as_deref()) + .unwrap_or_default() + .replace('\n', " "); + truncate_chars( + &format!( + "{selector} {pid:<9} {name:<22} {:<8} {}", + agent_state_label(&job.state), + detail + ), + width, + ) +} + +fn render_agent_view_peek(job: &runtime::AgentJobRecord, width: usize) -> Vec { + let mut lines = vec![ + truncate_chars(&format!("Peek {}", job.id), width), + truncate_chars( + &format!(" Name {}", job.name.as_deref().unwrap_or(&job.id)), + width, + ), + truncate_chars( + &format!(" State {}", agent_state_label(&job.state)), + width, + ), + truncate_chars(&format!(" Cwd {}", job.cwd), width), + ]; + if let Some(session_id) = &job.session_id { + lines.push(truncate_chars(&format!(" Session {session_id}"), width)); + } + if let Some(prompt) = &job.prompt { + lines.push(truncate_chars(&format!(" Prompt {prompt}"), width)); + } + if let Some(command) = &job.command { + lines.push(truncate_chars(&format!(" Command {command}"), width)); + } + if let Some(output) = &job.output_tail { + lines.push(" Output".to_string()); + lines.extend( + output + .lines() + .rev() + .take(8) + .collect::>() + .into_iter() + .rev() + .map(|line| truncate_chars(&format!(" {line}"), width)), + ); + } else { + lines.push(" Output ".to_string()); + } + lines +} + +fn agent_view_group_rank(job: &runtime::AgentJobRecord) -> u8 { + match job.state { + runtime::AgentJobState::Blocked => 0, + runtime::AgentJobState::Working => 1, + runtime::AgentJobState::Done => 2, + runtime::AgentJobState::Failed => 3, + runtime::AgentJobState::Stopped => 4, + } +} + +fn agent_view_group_label(job: &runtime::AgentJobRecord) -> &'static str { + match job.state { + runtime::AgentJobState::Blocked => "Needs input", + runtime::AgentJobState::Working => "Working", + runtime::AgentJobState::Done => "Completed", + runtime::AgentJobState::Failed | runtime::AgentJobState::Stopped => "Stopped or failed", + } +} + +fn truncate_chars(value: &str, width: usize) -> String { + if width == 0 { + return String::new(); + } + let count = value.chars().count(); + if count <= width { + return value.to_string(); + } + if width <= 1 { + return value.chars().take(width).collect(); + } + let mut output = value.chars().take(width - 1).collect::(); + output.push('~'); + output +} + #[derive(Debug, Deserialize)] struct ToolSearchRequest { query: String, @@ -7678,6 +8560,7 @@ impl LiveCli { let system_prompt = build_system_prompt(&model)?; let session_state = new_cli_session()?; let session = create_managed_session_handle(&session_state.session_id)?; + link_current_agent_job_session(&session); let runtime = build_runtime( session_state.with_persistence_path(session.path.clone()), &session.id, @@ -8206,7 +9089,14 @@ impl LiveCli { self.handle_plugins_command(action.as_deref(), target.as_deref())? } SlashCommand::Agents { args } => { - if let Err(error) = Self::print_agents(args.as_deref(), CliOutputFormat::Text) { + if let Err(error) = Self::print_agents(AgentViewPrintRequest { + args: args.as_deref(), + output_format: CliOutputFormat::Text, + model: &self.model, + permission_mode: self.permission_mode, + reasoning_effort: None, + agent: None, + }) { eprintln!("{error}"); } false @@ -8578,15 +9468,23 @@ impl LiveCli { Ok(()) } - fn print_agents( - args: Option<&str>, - output_format: CliOutputFormat, - ) -> Result<(), Box> { + fn print_agents(request: AgentViewPrintRequest<'_>) -> Result<(), Box> { + if agent_view_requests_json_array(request.args) { + let value = handle_agent_view_json_array(request.args)?; + println!("{}", serde_json::to_string_pretty(&value)?); + return Ok(()); + } + if should_open_agent_view_tui(request.args, request.output_format) { + run_agent_view_tui(&request)?; + return Ok(()); + } let cwd = env::current_dir()?; - match output_format { - CliOutputFormat::Text => println!("{}", handle_agents_slash_command(args, &cwd)?), + match request.output_format { + CliOutputFormat::Text => { + println!("{}", handle_agents_slash_command(request.args, &cwd)?) + } CliOutputFormat::Json => { - let value = handle_agents_slash_command_json(args, &cwd)?; + let value = handle_agents_slash_command_json(request.args, &cwd)?; // #789: parity with print_mcp/#788 print_skills — exit 1 when envelope // reports an error so automation can rely on exit code instead of // parsing the JSON status field. @@ -9172,6 +10070,19 @@ fn create_managed_session_handle( }) } +fn link_current_agent_job_session(session: &SessionHandle) { + let Ok(job_id) = env::var("CLAW_AGENT_JOB_ID") else { + return; + }; + if job_id.trim().is_empty() { + return; + } + let Ok(supervisor) = runtime::AgentSupervisor::from_default_config() else { + return; + }; + let _ = supervisor.set_session_id(&job_id, &session.id); +} + fn resolve_session_reference(reference: &str) -> Result> { let handle = current_session_store()? .resolve_reference(reference) @@ -9470,6 +10381,584 @@ fn render_session_list(active_session_id: &str) -> Result { + prompt: Option<&'a str>, + exec: Option<&'a str>, + name: Option<&'a str>, + agent: Option<&'a str>, + model: &'a str, + output_format: CliOutputFormat, + permission_mode: PermissionMode, + reasoning_effort: Option<&'a str>, +} + +fn run_background_agent( + request: BackgroundAgentRequest<'_>, +) -> Result<(), Box> { + let output_format = request.output_format; + let job = start_background_agent_job(request)?; + print_agent_background_started(&job, output_format)?; + Ok(()) +} + +fn start_background_agent_job( + request: BackgroundAgentRequest<'_>, +) -> Result> { + let supervisor = runtime::AgentSupervisor::from_default_config()?; + let cwd = env::current_dir()?; + let job = supervisor.create_job(runtime::AgentJobCreate { + cwd: cwd.clone(), + kind: if request.exec.is_some() { + runtime::AgentJobKind::Exec + } else { + runtime::AgentJobKind::Claude + }, + prompt: request.prompt.map(ToOwned::to_owned), + command: request.exec.map(ToOwned::to_owned), + name: request.name.map(ToOwned::to_owned), + model: (!request.model.trim().is_empty()).then(|| request.model.to_string()), + agent: request.agent.map(ToOwned::to_owned), + permission_mode: Some(request.permission_mode.as_str().to_string()), + reasoning_effort: request.reasoning_effort.map(ToOwned::to_owned), + })?; + let child = spawn_agent_worker(&job.id, Path::new(&job.cwd))?; + let job = supervisor.set_process(&job.id, child.id())?; + Ok(job) +} + +fn spawn_agent_worker( + id: &str, + cwd: &Path, +) -> Result> { + let executable = env::current_exe()?; + let mut command = Command::new(executable); + command + .arg("__agent-worker") + .arg(id) + .current_dir(cwd) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + Ok(command.spawn()?) +} + +fn print_agent_background_started( + job: &runtime::AgentJobRecord, + output_format: CliOutputFormat, +) -> Result<(), Box> { + match output_format { + CliOutputFormat::Text => { + println!( + "Started background session {id}\n State {state}\n Working dir {cwd}\n View claw agents\n Attach claw attach {id}\n Logs claw logs {id}\n Stop claw stop {id}", + id = job.id, + state = agent_state_label(&job.state), + cwd = job.cwd, + ); + } + CliOutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "agents", + "action": "background", + "status": "ok", + "session": agent_job_json(job), + "commands": agent_management_commands(&job.id), + }))? + ); + } + } + Ok(()) +} + +fn run_agent_attach( + id: &str, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let supervisor = runtime::AgentSupervisor::from_default_config()?; + let job = supervisor.load_job(id)?; + if output_format == CliOutputFormat::Text + && io::stdin().is_terminal() + && io::stdout().is_terminal() + && job.session_id.is_some() + { + run_attached_agent_session(&job)?; + return Ok(()); + } + let logs = supervisor.read_logs(id)?; + match output_format { + CliOutputFormat::Text => { + println!( + "Attach {id}\n State {state}\n Working dir {cwd}\n Session {session}\n\n{logs}", + id = job.id, + state = agent_state_label(&job.state), + cwd = job.cwd, + session = job.session_id.as_deref().unwrap_or(""), + ); + } + CliOutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "agents", + "action": "attach", + "status": "ok", + "session": agent_job_json(&job), + "logs": logs, + "interactive": false, + }))? + ); + } + } + Ok(()) +} + +struct CurrentDirGuard { + previous: PathBuf, +} + +impl CurrentDirGuard { + fn enter(path: &Path) -> io::Result { + let previous = env::current_dir()?; + env::set_current_dir(path)?; + Ok(Self { previous }) + } +} + +impl Drop for CurrentDirGuard { + fn drop(&mut self) { + let _ = env::set_current_dir(&self.previous); + } +} + +fn run_attached_agent_session( + job: &runtime::AgentJobRecord, +) -> Result<(), Box> { + let session_id = job.session_id.as_deref().ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!("background session {} has no resumable session id", job.id), + ) + })?; + let _cwd_guard = CurrentDirGuard::enter(Path::new(&job.cwd))?; + let (handle, session) = load_session_reference(session_id)?; + let model = job + .model + .clone() + .or_else(|| session.model.clone()) + .unwrap_or_else(|| DEFAULT_MODEL.to_string()); + let model = resolve_repl_model(model)?; + let permission_mode = job + .permission_mode + .as_deref() + .map(parse_permission_mode_arg) + .transpose() + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))? + .unwrap_or_else(default_permission_mode); + let system_prompt = build_system_prompt(&model)?; + let runtime = build_runtime( + session.with_persistence_path(handle.path.clone()), + &handle.id, + model.clone(), + system_prompt.clone(), + true, + true, + None, + permission_mode, + None, + )?; + let mut cli = LiveCli { + model, + allowed_tools: None, + permission_mode, + system_prompt, + runtime, + session: SessionHandle { + id: handle.id, + path: handle.path, + }, + prompt_history: Vec::new(), + }; + println!( + "Attached background session {}\n State {}\n Working dir {}\n Detach /exit", + job.id, + agent_state_label(&job.state), + job.cwd + ); + println!("{}", format_connected_line(&cli.model)); + run_live_cli_loop(&mut cli) +} + +fn run_agent_logs( + id: &str, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let supervisor = runtime::AgentSupervisor::from_default_config()?; + let job = supervisor.load_job(id)?; + let logs = supervisor.read_logs(id)?; + match output_format { + CliOutputFormat::Text => print!("{logs}"), + CliOutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "agents", + "action": "logs", + "status": "ok", + "session": agent_job_json(&job), + "logs": logs, + }))? + ); + } + } + Ok(()) +} + +fn run_agent_stop( + id: &str, + signal: AgentStopSignal, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let supervisor = runtime::AgentSupervisor::from_default_config()?; + let before = supervisor.load_job(id)?; + if let Some(pid) = before.pid { + signal_process(pid, signal)?; + } + let job = supervisor.stop_job(id)?; + match output_format { + CliOutputFormat::Text => { + println!( + "Stopped background session {id}\n Signal {signal}\n Previous pid {pid}", + id = job.id, + signal = match signal { + AgentStopSignal::Term => "TERM", + AgentStopSignal::Kill => "KILL", + }, + pid = before + .pid + .map(|pid| pid.to_string()) + .unwrap_or_else(|| "".to_string()), + ); + } + CliOutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "agents", + "action": "stop", + "status": "ok", + "signal": match signal { + AgentStopSignal::Term => "term", + AgentStopSignal::Kill => "kill", + }, + "session": agent_job_json(&job), + }))? + ); + } + } + Ok(()) +} + +fn run_agent_respawn( + id: Option<&str>, + all: bool, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let supervisor = runtime::AgentSupervisor::from_default_config()?; + let jobs = if all { + supervisor + .list_jobs(&runtime::AgentListFilter { + cwd: None, + include_all: true, + })? + .into_iter() + .filter(|job| { + !matches!( + job.state, + runtime::AgentJobState::Working | runtime::AgentJobState::Blocked + ) || job.pid.is_none() + }) + .collect::>() + } else { + vec![supervisor.load_job(id.expect("id required when all=false"))?] + }; + + let mut respawned = Vec::new(); + for job in jobs { + if matches!( + job.state, + runtime::AgentJobState::Working | runtime::AgentJobState::Blocked + ) && job.pid.is_some() + { + continue; + } + let queued = supervisor.respawn_job(&job.id)?; + let child = spawn_agent_worker(&queued.id, Path::new(&queued.cwd))?; + respawned.push(supervisor.set_process(&queued.id, child.id())?); + } + + match output_format { + CliOutputFormat::Text => { + println!("Respawned {} background session(s)", respawned.len()); + for job in &respawned { + println!( + " {} state={} pid={}", + job.id, + agent_state_label(&job.state), + job.pid + .map_or_else(|| "".to_string(), |pid| pid.to_string()) + ); + } + } + CliOutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "agents", + "action": "respawn", + "status": "ok", + "count": respawned.len(), + "sessions": respawned.iter().map(agent_job_json).collect::>(), + }))? + ); + } + } + Ok(()) +} + +fn run_agent_remove( + id: &str, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let supervisor = runtime::AgentSupervisor::from_default_config()?; + supervisor.remove_job(id)?; + match output_format { + CliOutputFormat::Text => println!("Removed background session {id}"), + CliOutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "agents", + "action": "rm", + "status": "ok", + "removed": id, + }))? + ); + } + } + Ok(()) +} + +fn run_agent_daemon_status( + output_format: CliOutputFormat, +) -> Result<(), Box> { + let supervisor = runtime::AgentSupervisor::from_default_config()?; + let status = supervisor.daemon_status(VERSION)?; + match output_format { + CliOutputFormat::Text => { + println!( + "Agent supervisor\n Reachable {}\n Version {}\n Config {}\n Roster {}\n Log {}\n Live sessions {}\n Workers {}", + status.reachable, + status.version, + status.config_dir, + status.roster_path, + status.log_path, + status.live_sessions, + status.worker_count, + ); + } + CliOutputFormat::Json => { + let mut value = serde_json::to_value(&status)?; + if let Some(object) = value.as_object_mut() { + object.insert("kind".to_string(), json!("agents")); + object.insert("action".to_string(), json!("daemon_status")); + object.insert("status".to_string(), json!("ok")); + } + println!("{}", serde_json::to_string_pretty(&value)?); + } + } + Ok(()) +} + +fn run_agent_daemon_stop( + any: bool, + keep_workers: bool, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let supervisor = runtime::AgentSupervisor::from_default_config()?; + let mut stopped = Vec::new(); + if any && !keep_workers { + for job in supervisor.list_jobs(&runtime::AgentListFilter { + cwd: None, + include_all: false, + })? { + if let Some(pid) = job.pid { + let _ = signal_process(pid, AgentStopSignal::Term); + } + stopped.push(supervisor.stop_job(&job.id)?); + } + } + match output_format { + CliOutputFormat::Text => { + println!( + "Stopped agent supervisor\n Keep workers {}\n Sessions stopped {}", + keep_workers, + stopped.len() + ); + } + CliOutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "agents", + "action": "daemon_stop", + "status": "ok", + "keep_workers": keep_workers, + "stopped_count": stopped.len(), + "sessions": stopped.iter().map(agent_job_json).collect::>(), + }))? + ); + } + } + Ok(()) +} + +fn run_agent_worker(id: &str) -> Result<(), Box> { + let supervisor = runtime::AgentSupervisor::from_default_config()?; + let job = supervisor.load_job(id)?; + let output = match job.kind { + runtime::AgentJobKind::Exec => run_agent_exec_worker(&job)?, + runtime::AgentJobKind::Claude | runtime::AgentJobKind::Interactive => { + run_agent_prompt_worker(&job)? + } + }; + supervisor.append_log(id, &output.text)?; + supervisor.finish_job(id, output.exit_code, Some(&output.text))?; + Ok(()) +} + +struct AgentWorkerOutput { + exit_code: i32, + text: String, +} + +fn run_agent_exec_worker( + job: &runtime::AgentJobRecord, +) -> Result> { + let command = job.command.as_deref().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "agent exec job missing command", + ) + })?; + let output = Command::new("sh") + .arg("-c") + .arg(command) + .current_dir(&job.cwd) + .output()?; + Ok(command_output_to_worker_output(output)) +} + +fn run_agent_prompt_worker( + job: &runtime::AgentJobRecord, +) -> Result> { + let prompt = job.prompt.as_deref().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "agent prompt job missing prompt", + ) + })?; + let executable = env::current_exe()?; + let mut command = Command::new(executable); + if let Some(model) = &job.model { + command.arg("--model").arg(model); + } + if let Some(permission_mode) = &job.permission_mode { + command.arg("--permission-mode").arg(permission_mode); + } + if let Some(reasoning_effort) = &job.reasoning_effort { + command.arg("--reasoning-effort").arg(reasoning_effort); + } + command + .arg("prompt") + .arg(prompt) + .current_dir(&job.cwd) + .env("CLAW_AGENT_JOB_ID", &job.id); + let output = command.output()?; + Ok(command_output_to_worker_output(output)) +} + +fn command_output_to_worker_output(output: std::process::Output) -> AgentWorkerOutput { + let mut text = String::new(); + text.push_str(&String::from_utf8_lossy(&output.stdout)); + text.push_str(&String::from_utf8_lossy(&output.stderr)); + AgentWorkerOutput { + exit_code: output.status.code().unwrap_or(1), + text, + } +} + +fn signal_process(pid: u32, signal: AgentStopSignal) -> Result<(), Box> { + let signal_arg = match signal { + AgentStopSignal::Term => "-TERM", + AgentStopSignal::Kill => "-KILL", + }; + let status = Command::new("kill") + .arg(signal_arg) + .arg(pid.to_string()) + .status()?; + if status.success() { + Ok(()) + } else { + Err(format!("failed to signal process {pid} with {signal_arg}").into()) + } +} + +fn agent_management_commands(id: &str) -> Value { + json!({ + "view": "claw agents", + "attach": format!("claw attach {id}"), + "logs": format!("claw logs {id}"), + "stop": format!("claw stop {id}"), + "kill": format!("claw kill {id}"), + "respawn": format!("claw respawn {id}"), + "remove": format!("claw rm {id}"), + }) +} + +fn agent_job_json(job: &runtime::AgentJobRecord) -> Value { + serde_json::to_value(job).unwrap_or_else(|_| json!({ "id": job.id })) +} + +fn agent_state_label(state: &runtime::AgentJobState) -> &'static str { + match state { + runtime::AgentJobState::Working => "working", + runtime::AgentJobState::Blocked => "blocked", + runtime::AgentJobState::Done => "done", + runtime::AgentJobState::Failed => "failed", + runtime::AgentJobState::Stopped => "stopped", + } +} + +fn agent_view_requests_json_array(args: Option<&str>) -> bool { + let Some(args) = args else { + return false; + }; + let mut saw_json = false; + for token in args.split_whitespace() { + if matches!( + token, + "list" | "show" | "info" | "describe" | "create" | "help" + ) { + return false; + } + if token == "--json" { + saw_json = true; + } + } + saw_json +} + /// #449: credentials-free session list that works without API keys. /// `claw session list --output-format json` should work in CI/offline. fn run_session_list(output_format: CliOutputFormat) -> Result<(), Box> { @@ -14257,7 +15746,11 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { )?; writeln!(out, " claw dump-manifests [--manifests-dir PATH]")?; writeln!(out, " claw bootstrap-plan")?; - writeln!(out, " claw agents")?; + writeln!(out, " claw agents [--json] [--all] [--cwd PATH]")?; + writeln!(out, " claw --bg \"prompt\"")?; + writeln!(out, " claw --bg --exec \"command\"")?; + writeln!(out, " claw attach|logs|stop|kill|respawn|rm ")?; + writeln!(out, " claw daemon status")?; writeln!(out, " claw mcp")?; writeln!(out, " claw skills")?; writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?; @@ -14296,6 +15789,18 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { out, " --compact Strip tool call details; print only the final assistant text (text mode only; useful for piping)" )?; + writeln!( + out, + " --bg, --background Start a Claude Code background session" + )?; + writeln!( + out, + " --exec COMMAND With --bg, run a background shell command" + )?; + writeln!( + out, + " --name NAME, --agent NAME Name a background session or request a configured agent" + )?; writeln!( out, " --permission-mode MODE Set read-only, workspace-write, or danger-full-access" @@ -14357,7 +15862,9 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { out, " claw --resume {LATEST_SESSION_REFERENCE} /status /diff /export notes.txt" )?; - writeln!(out, " claw agents")?; + writeln!(out, " claw agents --json --all")?; + writeln!(out, " claw --bg \"fix the parser regression\"")?; + writeln!(out, " claw logs ")?; writeln!(out, " claw mcp show my-server")?; writeln!(out, " claw /skills")?; writeln!(out, " claw doctor")?; @@ -14419,22 +15926,23 @@ mod tests { format_pr_report, format_resume_report, format_status_report, format_tool_call_start, format_tool_result, format_ultraplan_report, format_unknown_slash_command, format_unknown_slash_command_message, format_user_visible_api_error, - merge_prompt_with_stdin, normalize_permission_mode, parse_args, parse_export_args, - parse_git_status_branch, parse_git_status_metadata_for, parse_git_workspace_summary, - parse_history_count, permission_policy, print_help_to, push_output_block, - render_config_report, render_diff_report, render_diff_report_for, render_help_topic, - render_help_topic_json, render_memory_report, render_prompt_history_report, - render_repl_help, render_resume_usage, render_session_list, render_session_markdown, - resolve_model_alias, resolve_model_alias_with_config, resolve_repl_model, - resolve_session_reference, response_to_events, resume_supported_slash_commands, - run_resume_command, short_tool_id, slash_command_completion_candidates_with_sessions, - split_error_hint, status_context, status_json_value, summarize_tool_payload_for_markdown, - try_resolve_bare_skill_prompt, validate_no_args, write_mcp_server_fixture, CliAction, - CliOutputFormat, CliToolExecutor, GitOperation, GitWorkspaceSummary, - InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic, - PermissionModeProvenance, PromptHistoryEntry, SessionLifecycleKind, - SessionLifecycleSummary, SlashCommand, StatusUsage, TmuxPaneSnapshot, DEFAULT_MODEL, - LATEST_SESSION_REFERENCE, STUB_COMMANDS, + link_current_agent_job_session, merge_prompt_with_stdin, normalize_permission_mode, + parse_args, parse_export_args, parse_git_status_branch, parse_git_status_metadata_for, + parse_git_workspace_summary, parse_history_count, permission_policy, print_help_to, + push_output_block, render_agent_view_screen, render_config_report, render_diff_report, + render_diff_report_for, render_help_topic, render_help_topic_json, render_memory_report, + render_prompt_history_report, render_repl_help, render_resume_usage, render_session_list, + render_session_markdown, resolve_model_alias, resolve_model_alias_with_config, + resolve_repl_model, resolve_session_reference, response_to_events, + resume_supported_slash_commands, run_resume_command, short_tool_id, + slash_command_completion_candidates_with_sessions, split_error_hint, status_context, + status_json_value, summarize_tool_payload_for_markdown, try_resolve_bare_skill_prompt, + validate_no_args, write_mcp_server_fixture, AgentStopSignal, AgentViewTuiOptions, + AgentViewTuiState, CliAction, CliOutputFormat, CliToolExecutor, GitOperation, + GitWorkspaceSummary, InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, + LocalHelpTopic, PermissionModeProvenance, PromptHistoryEntry, SessionHandle, + SessionLifecycleKind, SessionLifecycleSummary, SlashCommand, StatusUsage, TmuxPaneSnapshot, + DEFAULT_MODEL, LATEST_SESSION_REFERENCE, STUB_COMMANDS, }; use api::{ApiError, MessageResponse, OutputContentBlock, Usage}; use plugins::{ @@ -15467,7 +16975,11 @@ mod tests { parse_args(&["agents".to_string()]).expect("agents should parse"), CliAction::Agents { args: None, - output_format: CliOutputFormat::Text + output_format: CliOutputFormat::Text, + model: DEFAULT_MODEL.to_string(), + permission_mode: crate::default_permission_mode(), + reasoning_effort: None, + agent: None, } ); assert_eq!( @@ -15509,6 +17021,26 @@ mod tests { CliAction::Agents { args: Some("--help".to_string()), output_format: CliOutputFormat::Text, + model: DEFAULT_MODEL.to_string(), + permission_mode: crate::default_permission_mode(), + reasoning_effort: None, + agent: None, + } + ); + assert_eq!( + parse_args(&[ + "agents".to_string(), + "--json".to_string(), + "--all".to_string() + ]) + .expect("agents --json --all should parse"), + CliAction::Agents { + args: Some("--json --all".to_string()), + output_format: CliOutputFormat::Text, + model: DEFAULT_MODEL.to_string(), + permission_mode: crate::default_permission_mode(), + reasoning_effort: None, + agent: None, } ); // #145: `plugins` must parse as CliAction::Plugins (not fall through @@ -15731,6 +17263,225 @@ mod tests { } } + #[test] + fn parses_agent_view_background_and_lifecycle_commands() { + let _guard = env_lock(); + std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"); + + match parse_args(&[ + "--bg".to_string(), + "--name".to_string(), + "parser-fix".to_string(), + "--agent".to_string(), + "Explore".to_string(), + "--reasoning-effort".to_string(), + "medium".to_string(), + "fix parser".to_string(), + ]) + .expect("--bg prompt should parse") + { + CliAction::Background { + prompt, + exec, + name, + agent, + reasoning_effort, + .. + } => { + assert_eq!(prompt.as_deref(), Some("fix parser")); + assert_eq!(exec, None); + assert_eq!(name.as_deref(), Some("parser-fix")); + assert_eq!(agent.as_deref(), Some("Explore")); + assert_eq!(reasoning_effort.as_deref(), Some("medium")); + } + other => panic!("expected background prompt action, got {other:?}"), + } + + match parse_args(&[ + "--background".to_string(), + "--exec".to_string(), + "cargo test -p runtime".to_string(), + ]) + .expect("--bg --exec should parse") + { + CliAction::Background { prompt, exec, .. } => { + assert_eq!(prompt, None); + assert_eq!(exec.as_deref(), Some("cargo test -p runtime")); + } + other => panic!("expected background exec action, got {other:?}"), + } + + assert!( + parse_args(&["--bg".to_string(), "-p".to_string(), "hi".to_string()]) + .expect_err("--bg -p should reject") + .contains("--bg cannot be combined") + ); + assert!( + parse_args(&["--bg".to_string(), "--print".to_string(), "hi".to_string()]) + .expect_err("--bg --print should reject") + .contains("--bg cannot be combined") + ); + + assert_eq!( + parse_args(&["attach".to_string(), "abc123".to_string()]).expect("attach"), + CliAction::AgentAttach { + id: "abc123".to_string(), + output_format: CliOutputFormat::Text, + } + ); + assert_eq!( + parse_args(&["logs".to_string(), "abc123".to_string()]).expect("logs"), + CliAction::AgentLogs { + id: "abc123".to_string(), + output_format: CliOutputFormat::Text, + } + ); + assert_eq!( + parse_args(&["stop".to_string(), "abc123".to_string()]).expect("stop"), + CliAction::AgentStop { + id: "abc123".to_string(), + signal: AgentStopSignal::Term, + output_format: CliOutputFormat::Text, + } + ); + assert_eq!( + parse_args(&["kill".to_string(), "abc123".to_string()]).expect("kill"), + CliAction::AgentStop { + id: "abc123".to_string(), + signal: AgentStopSignal::Kill, + output_format: CliOutputFormat::Text, + } + ); + assert_eq!( + parse_args(&["respawn".to_string(), "--all".to_string()]).expect("respawn"), + CliAction::AgentRespawn { + id: None, + all: true, + output_format: CliOutputFormat::Text, + } + ); + assert_eq!( + parse_args(&["rm".to_string(), "abc123".to_string()]).expect("rm"), + CliAction::AgentRemove { + id: "abc123".to_string(), + output_format: CliOutputFormat::Text, + } + ); + assert_eq!( + parse_args(&["daemon".to_string(), "status".to_string()]).expect("daemon status"), + CliAction::DaemonStatus { + output_format: CliOutputFormat::Text, + } + ); + assert_eq!( + parse_args(&[ + "daemon".to_string(), + "stop".to_string(), + "--any".to_string(), + "--keep-workers".to_string(), + ]) + .expect("daemon stop"), + CliAction::DaemonStop { + any: true, + keep_workers: true, + output_format: CliOutputFormat::Text, + } + ); + } + + #[test] + fn agent_view_tui_screen_groups_sessions_and_shows_peek() { + let state = AgentViewTuiState { + options: AgentViewTuiOptions { + include_all: true, + cwd: Some(PathBuf::from("/tmp/project")), + }, + selected: 0, + peek_open: true, + input: "write tests".to_string(), + status: Some("started background session abc123".to_string()), + confirm_remove_id: None, + }; + let job = runtime::AgentJobRecord { + id: "abc123".to_string(), + kind: runtime::AgentJobKind::Claude, + cwd: "/tmp/project".to_string(), + started_at: "100".to_string(), + updated_at: "120".to_string(), + state: runtime::AgentJobState::Working, + pid: Some(42), + status: Some("running".to_string()), + waiting_for: None, + session_id: Some("session-abc".to_string()), + name: Some("parser fix".to_string()), + prompt: Some("fix parser".to_string()), + command: None, + model: Some(DEFAULT_MODEL.to_string()), + agent: Some("Explore".to_string()), + permission_mode: Some("manual".to_string()), + reasoning_effort: Some("medium".to_string()), + output_tail: Some("latest output".to_string()), + exit_code: None, + stopped_at: None, + completed_at: None, + extra: std::collections::BTreeMap::new(), + }; + + let screen = render_agent_view_screen(&state, &[job], 100, 24); + + assert!(screen.contains("Claude Code Agent View")); + assert!(screen.contains("Working")); + assert!(screen.contains("> *42")); + assert!(screen.contains("Peek abc123")); + assert!(screen.contains("Session session-abc")); + assert!(screen.contains("Dispatch: write tests")); + } + + #[test] + fn agent_worker_session_id_links_back_to_supervisor_job() { + let _guard = env_lock(); + let root = temp_dir(); + let config = root.join("claude-config"); + let workspace = root.join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace should exist"); + let supervisor = runtime::AgentSupervisor::from_config_dir(&config); + let job = supervisor + .create_job(runtime::AgentJobCreate { + cwd: workspace, + kind: runtime::AgentJobKind::Claude, + prompt: Some("fix parser".to_string()), + command: None, + name: Some("parser".to_string()), + model: Some(DEFAULT_MODEL.to_string()), + agent: None, + permission_mode: Some("manual".to_string()), + reasoning_effort: None, + }) + .expect("job should create"); + let previous_config = std::env::var("CLAUDE_CONFIG_DIR").ok(); + let previous_job = std::env::var("CLAW_AGENT_JOB_ID").ok(); + std::env::set_var("CLAUDE_CONFIG_DIR", &config); + std::env::set_var("CLAW_AGENT_JOB_ID", &job.id); + + link_current_agent_job_session(&SessionHandle { + id: "session-linked".to_string(), + path: root.join("session-linked.jsonl"), + }); + + let linked = supervisor.load_job(&job.id).expect("job should load"); + assert_eq!(linked.session_id.as_deref(), Some("session-linked")); + + match previous_config { + Some(value) => std::env::set_var("CLAUDE_CONFIG_DIR", value), + None => std::env::remove_var("CLAUDE_CONFIG_DIR"), + } + match previous_job { + Some(value) => std::env::set_var("CLAW_AGENT_JOB_ID", value), + None => std::env::remove_var("CLAW_AGENT_JOB_ID"), + } + let _ = std::fs::remove_dir_all(root); + } + #[test] fn dump_manifests_subcommand_accepts_explicit_manifest_dir() { assert_eq!( @@ -16500,7 +18251,9 @@ mod tests { ); // #774: agents now uses \n-delimited format — update test string to match real emission assert_eq!( - classify_error_kind("unknown agents subcommand: bogus.\nSupported: list, show, help"), + classify_error_kind( + "unknown agents subcommand: bogus.\nSupported: list, show, create, views, help" + ), "unknown_agents_subcommand" ); assert_eq!( @@ -16953,7 +18706,11 @@ mod tests { parse_args(&["/agents".to_string()]).expect("/agents should parse"), CliAction::Agents { args: None, - output_format: CliOutputFormat::Text + output_format: CliOutputFormat::Text, + model: DEFAULT_MODEL.to_string(), + permission_mode: crate::default_permission_mode(), + reasoning_effort: None, + agent: None, } ); assert_eq!( diff --git a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs index 7ffef21c..17f1f3ce 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -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", ], &[ (