From eace1888b6fc4d55566458e31992fd524ab0d1c0 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sat, 7 Mar 2026 22:04:33 -0600 Subject: [PATCH 1/4] feat: Add `pu bench` and `pu play` commands for agent suspend/resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suspend/resume protocol and engine support existed but had no CLI exposure. This adds two commands using PurePoint's basketball metaphor: - `pu bench ` / `pu bench --all` — suspend agents - `pu play ` — resume a benched agent Also enhances `pu status` to show "benched" (yellow) for suspended agents instead of the generic "waiting" status. Co-Authored-By: Claude Opus 4.6 --- crates/pu-cli/src/commands/bench.rs | 55 ++++++++++++++++++++++++++ crates/pu-cli/src/commands/mod.rs | 1 + crates/pu-cli/src/main.rs | 27 +++++++++++++ crates/pu-cli/src/output.rs | 61 ++++++++++++++++++++++++++--- 4 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 crates/pu-cli/src/commands/bench.rs diff --git a/crates/pu-cli/src/commands/bench.rs b/crates/pu-cli/src/commands/bench.rs new file mode 100644 index 0000000..e586236 --- /dev/null +++ b/crates/pu-cli/src/commands/bench.rs @@ -0,0 +1,55 @@ +use crate::client; +use crate::daemon_ctrl; +use crate::error::CliError; +use crate::output; +use pu_core::protocol::{Request, SuspendTarget}; +use std::path::Path; + +pub async fn run_bench( + socket: &Path, + agent: Option, + all: bool, + json: bool, +) -> Result<(), CliError> { + let target = if all { + SuspendTarget::All + } else if let Some(id) = agent { + SuspendTarget::Agent(id) + } else { + return Err(CliError::Other( + "bench target required — use or --all".into(), + )); + }; + + daemon_ctrl::ensure_daemon(socket).await?; + + let project_root = crate::commands::cwd_string()?; + let resp = client::send_request( + socket, + &Request::Suspend { + project_root, + target, + }, + ) + .await?; + let resp = output::check_response(resp, json)?; + output::print_response(&resp, json); + Ok(()) +} + +pub async fn run_play(socket: &Path, agent_id: &str, json: bool) -> Result<(), CliError> { + daemon_ctrl::ensure_daemon(socket).await?; + + let project_root = crate::commands::cwd_string()?; + let resp = client::send_request( + socket, + &Request::Resume { + project_root, + agent_id: agent_id.to_string(), + }, + ) + .await?; + let resp = output::check_response(resp, json)?; + output::print_response(&resp, json); + Ok(()) +} diff --git a/crates/pu-cli/src/commands/mod.rs b/crates/pu-cli/src/commands/mod.rs index cb25e00..488301c 100644 --- a/crates/pu-cli/src/commands/mod.rs +++ b/crates/pu-cli/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod agent_def; pub mod attach; +pub mod bench; pub mod clean; pub mod diff; pub mod grid; diff --git a/crates/pu-cli/src/main.rs b/crates/pu-cli/src/main.rs index ee8111d..771ba6c 100644 --- a/crates/pu-cli/src/main.rs +++ b/crates/pu-cli/src/main.rs @@ -66,6 +66,25 @@ enum Commands { #[arg(long)] json: bool, }, + /// Bench (suspend) agents — pull them off the court + Bench { + /// Agent ID to bench + agent_id: Option, + /// Bench all active agents + #[arg(long)] + all: bool, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Put a benched agent back in play (resume) + Play { + /// Agent ID to resume + agent_id: String, + /// Output as JSON + #[arg(long)] + json: bool, + }, /// Kill agents Kill { /// Kill specific agent @@ -500,6 +519,14 @@ async fn main() { ) .await } + Commands::Bench { + agent_id, + all, + json, + } => commands::bench::run_bench(&socket, agent_id, all, json).await, + Commands::Play { agent_id, json } => { + commands::bench::run_play(&socket, &agent_id, json).await + } Commands::Status { agent, json } => commands::status::run(&socket, agent, json).await, Commands::Kill { agent, diff --git a/crates/pu-cli/src/output.rs b/crates/pu-cli/src/output.rs index da59fa8..9129ee9 100644 --- a/crates/pu-cli/src/output.rs +++ b/crates/pu-cli/src/output.rs @@ -24,6 +24,17 @@ pub fn check_response(resp: Response, json: bool) -> Result } fn status_colored(status: AgentStatus, exit_code: Option) -> String { + status_colored_with_suspended(status, exit_code, false) +} + +fn status_colored_with_suspended( + status: AgentStatus, + exit_code: Option, + suspended: bool, +) -> String { + if suspended && status.is_alive() { + return "benched".yellow().to_string(); + } match status { AgentStatus::Streaming => "streaming".green().to_string(), AgentStatus::Waiting => "waiting".cyan().to_string(), @@ -95,7 +106,7 @@ pub fn print_response(response: &Response, json_mode: bool) { "{:<14} {:<16} {}", a.id.dimmed(), a.name, - status_colored(a.status, a.exit_code) + status_colored_with_suspended(a.status, a.exit_code, a.suspended) ); } } @@ -119,7 +130,7 @@ pub fn print_response(response: &Response, json_mode: bool) { " {:<14} {:<16} {}", a.id.dimmed(), a.name, - status_colored(a.status, a.exit_code), + status_colored_with_suspended(a.status, a.exit_code, a.suspended), ); } } @@ -130,7 +141,7 @@ pub fn print_response(response: &Response, json_mode: bool) { "{} {} {}", a.id.dimmed(), a.name.bold(), - status_colored(a.status, a.exit_code) + status_colored_with_suspended(a.status, a.exit_code, a.suspended) ); if let Some(pid) = a.pid { println!(" PID: {pid}"); @@ -149,11 +160,18 @@ pub fn print_response(response: &Response, json_mode: bool) { println!("Killed {} agent(s)", killed.len()); } Response::SuspendResult { suspended } => { - println!("Suspended {} agent(s)", suspended.len()); + if suspended.is_empty() { + println!("No agents to bench"); + } else { + println!("Benched {} agent(s)", suspended.len()); + for id in suspended { + println!(" {}", id.dimmed()); + } + } } Response::ResumeResult { agent_id, status } => { println!( - "Resumed agent {} ({})", + "Back in play: {} ({})", agent_id.bold(), status_colored(*status, None) ); @@ -701,6 +719,12 @@ mod tests { print_response(&resp, false); } + #[test] + fn given_empty_suspend_result_should_not_panic() { + let resp = Response::SuspendResult { suspended: vec![] }; + print_response(&resp, false); + } + #[test] fn given_resume_result_should_not_panic() { let resp = Response::ResumeResult { @@ -843,6 +867,33 @@ mod tests { assert!(s.contains("waiting")); } + // --- status_colored_with_suspended (bench) --- + + #[test] + fn given_suspended_streaming_should_show_benched() { + let s = status_colored_with_suspended(AgentStatus::Streaming, None, true); + assert!(s.contains("benched")); + } + + #[test] + fn given_suspended_waiting_should_show_benched() { + let s = status_colored_with_suspended(AgentStatus::Waiting, None, true); + assert!(s.contains("benched")); + } + + #[test] + fn given_suspended_broken_should_not_show_benched() { + let s = status_colored_with_suspended(AgentStatus::Broken, Some(0), true); + assert!(!s.contains("benched")); + assert!(s.contains("done")); + } + + #[test] + fn given_not_suspended_should_show_normal_status() { + let s = status_colored_with_suspended(AgentStatus::Streaming, None, false); + assert!(s.contains("streaming")); + } + // --- diff output --- #[test] From 7b8acacca2754519e750140b806a1b2e7a07779b Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sun, 8 Mar 2026 09:58:53 -0500 Subject: [PATCH 2/4] fix: Add self-protection to pu bench (mirrors kill.rs pattern) Refuse to bench own agent when PU_AGENT_ID matches the target. For bulk --all, warn if self was included in the suspend results since the protocol lacks an exclude mechanism for suspend. Co-Authored-By: Claude Opus 4.6 --- crates/pu-cli/src/commands/bench.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/pu-cli/src/commands/bench.rs b/crates/pu-cli/src/commands/bench.rs index e586236..10db7ef 100644 --- a/crates/pu-cli/src/commands/bench.rs +++ b/crates/pu-cli/src/commands/bench.rs @@ -11,9 +11,17 @@ pub async fn run_bench( all: bool, json: bool, ) -> Result<(), CliError> { + let self_agent_id = std::env::var("PU_AGENT_ID").ok(); + let target = if all { SuspendTarget::All } else if let Some(id) = agent { + // Self-protection: refuse to bench own agent + if let Some(ref self_id) = self_agent_id { + if &id == self_id { + return Err(CliError::Other("cannot bench self".into())); + } + } SuspendTarget::Agent(id) } else { return Err(CliError::Other( @@ -33,6 +41,18 @@ pub async fn run_bench( ) .await?; let resp = output::check_response(resp, json)?; + + // Warn if self was included in bulk bench results + if let Some(ref self_id) = self_agent_id { + if let pu_core::protocol::Response::SuspendResult { ref suspended } = resp { + if suspended.contains(self_id) { + eprintln!( + "warning: agent {self_id} benched itself — use `pu play {self_id}` to resume", + ); + } + } + } + output::print_response(&resp, json); Ok(()) } From 953fec0f2a4912ad31ac1475e47129355a58a7ab Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sun, 8 Mar 2026 13:59:16 -0500 Subject: [PATCH 3/4] fix: Reject ambiguous `pu bench --all` with conflicts_with Clap now errors if both an agent ID and --all are provided, instead of silently ignoring the agent ID. Co-Authored-By: Claude Opus 4.6 --- crates/pu-cli/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/pu-cli/src/main.rs b/crates/pu-cli/src/main.rs index 771ba6c..04f513f 100644 --- a/crates/pu-cli/src/main.rs +++ b/crates/pu-cli/src/main.rs @@ -69,6 +69,7 @@ enum Commands { /// Bench (suspend) agents — pull them off the court Bench { /// Agent ID to bench + #[arg(conflicts_with = "all")] agent_id: Option, /// Bench all active agents #[arg(long)] From c7a1b9f506459260858f4929a79b7eaca4c379e1 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sun, 8 Mar 2026 14:20:40 -0500 Subject: [PATCH 4/4] docs: Add docstrings and long_about for bench command Address CodeRabbit docstring coverage warning and nitpick about documenting --all self-inclusion behavior in the CLI help text. Co-Authored-By: Claude Opus 4.6 --- crates/pu-cli/src/commands/bench.rs | 5 +++++ crates/pu-cli/src/main.rs | 3 +++ crates/pu-cli/src/output.rs | 2 ++ 3 files changed, 10 insertions(+) diff --git a/crates/pu-cli/src/commands/bench.rs b/crates/pu-cli/src/commands/bench.rs index 10db7ef..24055f8 100644 --- a/crates/pu-cli/src/commands/bench.rs +++ b/crates/pu-cli/src/commands/bench.rs @@ -5,6 +5,10 @@ use crate::output; use pu_core::protocol::{Request, SuspendTarget}; use std::path::Path; +/// Suspend one or all agents ("bench" them). +/// +/// When `--all` is used, the invoking agent may be included in the suspend +/// results. A warning is emitted if this happens; use `pu play ` to resume. pub async fn run_bench( socket: &Path, agent: Option, @@ -57,6 +61,7 @@ pub async fn run_bench( Ok(()) } +/// Resume a previously benched agent, putting it back in play. pub async fn run_play(socket: &Path, agent_id: &str, json: bool) -> Result<(), CliError> { daemon_ctrl::ensure_daemon(socket).await?; diff --git a/crates/pu-cli/src/main.rs b/crates/pu-cli/src/main.rs index 04f513f..94f4b27 100644 --- a/crates/pu-cli/src/main.rs +++ b/crates/pu-cli/src/main.rs @@ -67,6 +67,9 @@ enum Commands { json: bool, }, /// Bench (suspend) agents — pull them off the court + #[command(long_about = "Bench (suspend) agents — pull them off the court.\n\n\ + When using --all, the invoking agent may also be suspended.\n\ + Use `pu play ` to resume a benched agent.")] Bench { /// Agent ID to bench #[arg(conflicts_with = "all")] diff --git a/crates/pu-cli/src/output.rs b/crates/pu-cli/src/output.rs index 9129ee9..2eae2cc 100644 --- a/crates/pu-cli/src/output.rs +++ b/crates/pu-cli/src/output.rs @@ -23,10 +23,12 @@ pub fn check_response(resp: Response, json: bool) -> Result } } +/// Return a colored status string for display (delegates with `suspended = false`). fn status_colored(status: AgentStatus, exit_code: Option) -> String { status_colored_with_suspended(status, exit_code, false) } +/// Return a colored status string, showing "benched" (yellow) for suspended alive agents. fn status_colored_with_suspended( status: AgentStatus, exit_code: Option,