//! `bsk request-help` — pause and ask the human to complete an in-page //! step (captcha / login / confirmation). Blocks until the user acts in //! the browser overlay or the call times out. use std::path::PathBuf; use std::time::Duration; use anyhow::Context; use bsk_protocol::Method; use bsk_protocol::tools::{ HelpCompletionCriteria, HelpOutcome, HelpTarget, RequestHelpParams, RequestHelpResult, }; use clap::Args; use crate::cli::ensure_daemon::ensure_daemon; use crate::cli::error::{CliError, Format}; use crate::cli::navigate::parse_timeout_ms; /// Environment variable that disables the blocking `request-help` /// human-in-loop tool, for unattended / background-server deployments /// where a blocking help overlay must never appear. pub(crate) const REQUEST_HELP_ENV: &str = "BSK_REQUEST_HELP"; /// Note attached to the synthetic result returned when request-help is /// disabled, so the agent knows why no overlay was shown. pub(crate) const REQUEST_HELP_DISABLED_NOTE: &str = "request-help disabled by BSK_REQUEST_HELP=off (unattended mode)"; /// Whether `request-help` is disabled via [`REQUEST_HELP_ENV`]. Only the /// value `off` (case-insensitive, surrounding whitespace ignored) /// disables it; unset or any other value keeps the default enabled /// behaviour. pub(crate) fn request_help_disabled() -> bool { is_off_value(std::env::var(REQUEST_HELP_ENV).ok().as_deref()) } /// Pure value check behind [`request_help_disabled`], kept separate so it /// can be unit-tested without touching process env (parallel tests race /// on `std::env::set_var`, which is also `unsafe` in edition 2024). fn is_off_value(value: Option<&str>) -> bool { value.is_some_and(|v| v.trim().eq_ignore_ascii_case("off")) } #[derive(Debug, Clone, Args)] pub struct RequestHelpArgs { #[arg(long)] pub session: String, /// Message shown to the user explaining what they need to do. #[arg(long)] pub prompt: String, /// Optional custom title for the overlay panel. #[arg(long)] pub title: Option, /// Target tab. Defaults to the Agent Window's active tab. #[arg(long = "tab-id")] pub tab_id: Option, /// Component to highlight. Repeatable. Accepts a snapshot ref /// (`@e3`) or a CSS selector (`#login`). A value starting with `@` /// or matching `eN` is treated as a ref; everything else is a /// selector. #[arg(long = "target")] pub target: Vec, /// Hard timeout (default 5m). Accepts `5m`, `300s`, `300000ms`. #[arg(long, default_value = "5m", value_parser = parse_timeout_ms)] pub timeout: u32, /// JSON success detector, e.g. '{"any":[{"url_contains":"/dashboard"}],"stable_for_ms":1000}'. #[arg(long = "completion-criteria")] pub completion_criteria: Option, } /// Classify a `--target` value as a ref or a selector. Refs are `@eN` /// or bare `eN` (digits after a leading `e`); everything else is a CSS /// selector. pub fn parse_target(raw: &str) -> HelpTarget { let trimmed = raw.trim(); let candidate = trimmed.strip_prefix('@').unwrap_or(trimmed); let is_ref = candidate.starts_with('e') && candidate.len() > 1 && candidate[1..].chars().all(|c| c.is_ascii_digit()); if is_ref { HelpTarget { ref_: Some(trimmed.to_string()), selector: None, } } else { HelpTarget { ref_: None, selector: Some(trimmed.to_string()), } } } pub fn dispatch(args: RequestHelpArgs, format: Format) -> Result<(), CliError> { // Disabled (`BSK_REQUEST_HELP=off`): return a synthetic `disabled` // result immediately — no daemon startup, no overlay, no waiting. if request_help_disabled() { let result = RequestHelpResult { outcome: HelpOutcome::Disabled, completed_by: None, note: Some(REQUEST_HELP_DISABLED_NOTE.into()), tab_id: args.tab_id.unwrap_or(0), resolved_targets: None, }; return render(&result, format); } let info = ensure_daemon().context("ensure daemon is running")?; let targets: Vec = args.target.iter().map(|t| parse_target(t)).collect(); let completion_criteria = args .completion_criteria .as_deref() .map(serde_json::from_str::) .transpose() .context("parse --completion-criteria JSON")?; let params = RequestHelpParams { session_id: args.session, tab_id: args.tab_id, prompt: args.prompt, title: args.title, targets: if targets.is_empty() { None } else { Some(targets) }, completion_criteria, timeout_ms: Some(args.timeout), }; let reply = call(info.sock_path, params, args.timeout)?; render(&reply, format) } fn call( sock: PathBuf, params: RequestHelpParams, timeout_ms: u32, ) -> Result { crate::cli::business_rpc::call::( sock, "request-help", Method::ToolRequestHelp, Some(params), ipc_timeout(timeout_ms), ) } fn render(reply: &RequestHelpResult, format: Format) -> Result<(), CliError> { match format { Format::Json => { let json = serde_json::to_string_pretty(reply) .map_err(|e| CliError::Local(anyhow::anyhow!(e)))?; println!("{json}"); } Format::Human => { print!("tab={} outcome={}", reply.tab_id, reply.outcome.as_str()); if let Some(note) = &reply.note { print!(" note={note:?}"); } println!(); } } Ok(()) } /// IPC budget: requested timeout + 15s slack so the client does not /// tear the connection down before the daemon's (long) wait resolves. fn ipc_timeout(timeout_ms: u32) -> Duration { Duration::from_millis(u64::from(timeout_ms)) .checked_add(Duration::from_secs(15)) .unwrap_or(Duration::from_secs(u64::from(timeout_ms / 1_000) + 15)) } #[cfg(test)] mod tests { use super::*; #[test] fn classifies_refs() { assert_eq!(parse_target("@e3").ref_.as_deref(), Some("@e3")); assert_eq!(parse_target("e12").ref_.as_deref(), Some("e12")); assert!(parse_target("@e3").selector.is_none()); } #[test] fn classifies_selectors() { assert_eq!(parse_target("#login").selector.as_deref(), Some("#login")); assert_eq!(parse_target(".btn").selector.as_deref(), Some(".btn")); // `email` starts with `e` but is not `e` → selector. assert_eq!(parse_target("email").selector.as_deref(), Some("email")); assert!(parse_target("#login").ref_.is_none()); } #[test] fn off_value_detection() { assert!(is_off_value(Some("off"))); assert!(is_off_value(Some("OFF"))); assert!(is_off_value(Some("Off"))); assert!(is_off_value(Some(" off "))); assert!(is_off_value(Some("\toff\n"))); assert!(!is_off_value(None)); assert!(!is_off_value(Some(""))); assert!(!is_off_value(Some("on"))); assert!(!is_off_value(Some("0"))); assert!(!is_off_value(Some("offx"))); assert!(!is_off_value(Some("of f"))); } }