repo_description: IronClaw is OpenClaw inspired implementation in Rust focused on privacy and security
code: //! System commands and job handlers for the agent.
  //!
  //! Extracted from `agent_loop.rs` to isolate the /help, /model, /status,
  //! and other command processing from the core agent loop.
  
  use std::sync::Arc;
  
  use tokio::sync::Mutex;
  use uuid::Uuid;
  
  use crate::agent::session::Session;
  use crate::agent::submission::SubmissionResult;
  use crate::agent::{Agent, MessageIntent};
  use crate::channels::{IncomingMessage, StatusUpdate};
  use crate::context::JobState;
  use crate::error::Error;
  use crate::llm::{ChatMessage, Reasoning};
  use crate::ownership::Owned;
  
  /// Format a count with a suffix, using K/M abbreviations for large numbers.
  fn format_count(n: u64, suffix: &str) -> String {
      if n >= 1_000_000 {
          format!("{:.1}M {}", n as f64 / 1_000_000.0, suffix)
      } else if n >= 1_000 {
          format!("{:.1}K {}", n as f64 / 1_000.0, suffix)
      } else {
          format!("{} {}", n, suffix)
      }
  }
  
  fn format_vertical_list(title: &str, items: &[String]) -> String {
      if items.is_empty() {
          return format!("{}:\n  (none)", title);
      }
  
      let mut out = format!("{}:\n", title);
      for item in items {
          out.push_str(&format!("  {}\n", item));
      }
      out.trim_end().to_string()
  }
  
  impl Agent {
      /// Handle job-related intents without turn tracking.
      pub(super) async fn handle_job_or_command(
          &self,
          intent: MessageIntent,
          message: &IncomingMessage,
          tenant: &crate::tenant::TenantCtx,
      ) -> Result<SubmissionResult, Error> {
          // Send thinking status for non-trivial operations
          if let MessageIntent::CreateJob { .. } = &intent {
              let _ = self
                  .channels
                  .send_status(
                      &message.channel,
                      StatusUpdate::Thinking("Processing...".into()),
                      &message.metadata,
                  )
                  .await;
          }
  
          let response = match intent {
              MessageIntent::CreateJob {
                  title,
                  description,
                  category,
              } => {
                  self.handle_create_job(tenant, title, description, category)
                      .await?
              }
              MessageIntent::CheckJobStatus { job_id } => {
                  self.handle_check_status(tenant, job_id).await?
              }
              MessageIntent::CancelJob { job_id } => self.handle_cancel_job(tenant, &job_id).await?,
              MessageIntent::ListJobs { filter } => self.handle_list_jobs(tenant, filter).await?,
              MessageIntent::HelpJob { job_id } => self.handle_help_job(tenant, &job_id).await?,
              MessageIntent::Command { command, args } => {
                  match self
                      .handle_command(&command, &args, &message.channel, tenant)
                      .await?
                  {
                      Some(s) => s,
                      None => return Ok(SubmissionResult::Ok { message: None }), // Shutdown signal
                  }
              }
              _ => "Unknown intent".to_string(),
          };
          Ok(SubmissionResult::response(response))
      }
  
      async fn handle_create_job(
          &self,
          tenant: &crate::tenant::TenantCtx,
          title: String,
          description: String,
          category: Option<String>,
      ) -> Result<String, Error> {
          let job_id = self
              .scheduler
              .dispatch_job(tenant.user_id(), &title, &description, None)
              .await?;
  
          // Set the dedicated category field (not stored in metadata)
          if let Some(cat) = category
              && let Err(e) = self
                  .context_manager
                  .update_context(job_id, |ctx| {
                      ctx.category = Some(cat);
                  })
                  .await
          {
              tracing::warn!(job_id = %job_id, "Failed to set job category: {}", e);
          }
  
          Ok(format!(
              "Created job: {}\nID: {}\n\nThe job has been scheduled and is now running.",
              title, job_id
          ))
      }
  
      async fn handle_check_status(
          &self,
          tenant: &crate::tenant::TenantCtx,
          job_id: Option<String>,
      ) -> Result<String, Error> {
          match job_id {
              Some(id) => {
                  let uuid = Uuid::parse_str(&id)
                      .map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?;
  
                  // Try DB first for persistent state, fall back to ContextManager.
                  // TenantScope.get_job() auto-filters by ownership — no manual check needed.
                  if let Some(store) = tenant.store()
                      && let Ok(Some(ctx)) = store.get_job(uuid).await
                  {
                      return Ok(format!(
                          "Job: {}\nStatus: {:?}\nCreated: {}\nStarted: {}\nActual cost: {}",
                          ctx.title,
                          ctx.state,
                          ctx.created_at.format("%Y-%m-%d %H:%M:%S"),
                          ctx.started_at
                              .map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string())
                              .unwrap_or_else(|| "Not started".to_string()),
                          ctx.actual_cost
                      ));
                  }
  
                  let ctx = self.context_manager.get_context(uuid).await?;
                  if !ctx.is_owned_by(tenant.user_id()) {
                      return Err(crate::error::JobError::NotFound { id: uuid }.into());
                  }
  
                  Ok(format!(
                      "Job: {}\nStatus: {:?}\nCreated: {}\nStarted: {}\nActual cost: {}",
                      ctx.title,
                      ctx.state,
                      ctx.created_at.format("%Y-%m-%d %H:%M:%S"),
                      ctx.started_at
                          .map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string())
                          .unwrap_or_else(|| "Not started".to_string()),
                      ctx.actual_cost
                  ))
              }
              None => {
                  // Show summary from DB for consistency with Jobs tab.
                  // TenantScope methods auto-scope to user — no user_id parameter needed.
                  if let Some(store) = tenant.store() {
                      let mut total = 0;
                      let mut in_progress = 0;
                      let mut completed = 0;
                      let mut failed = 0;
                      let mut stuck = 0;
  
                      if let Ok(s) = store.agent_job_summary().await {
                          total += s.total;
                          in_progress += s.in_progress;
                          completed += s.completed;
                          failed += s.failed;
                          stuck += s.stuck;
                      }
                      if let Ok(s) = store.sandbox_job_summary().await {
                          total += s.total;
                          in_progress += s.running;
                          completed += s.completed;
                          failed += s.failed + s.interrupted;
                      }
  
                      return Ok(format!(
                          "Jobs summary: Total: {} In Progress: {} Completed: {} Failed: {} Stuck: {}",
                          total, in_progress, completed, failed, stuck
                      ));
                  }
  
                  // Fallback to ContextManager if no DB.
                  let summary = self.context_manager.summary_for(tenant.user_id()).await;
                  Ok(format!(
                      "Jobs summary: Total: {} In Progress: {} Completed: {} Failed: {} Stuck: {}",
                      summary.total,
                      summary.in_progress,
                      summary.completed,
                      summary.failed,
                      summary.stuck
                  ))
              }
          }
      }
  
      async fn handle_cancel_job(
          &self,
          tenant: &crate::tenant::TenantCtx,
          job_id: &str,
      ) -> Result<String, Error> {
          let uuid = Uuid::parse_str(job_id)
              .map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?;
  
          let ctx = self.context_manager.get_context(uuid).await?;
          if !ctx.is_owned_by(tenant.user_id()) {
              return Err(crate::error::JobError::NotFound { id: uuid }.into());
          }
  
          self.scheduler.stop(uuid).await?;
  
          // Also update DB so the Jobs tab reflects cancellation immediately.
          // Use TenantScope — ownership already verified above.
          if let Some(store) = tenant.store()
              && let Err(e) = store
                  .update_job_status(uuid, JobState::Cancelled, Some("Cancelled by user"))
                  .await
          {
              tracing::warn!(job_id = %uuid, "Failed to persist cancellation to DB: {}", e);
          }
  
          Ok(format!("Job {} has been cancelled.", job_id))
      }
  
      async fn handle_list_jobs(
          &self,
          tenant: &crate::tenant::TenantCtx,
          _filter: Option<String>,
      ) -> Result<String, Error> {
          // List from DB for consistency with Jobs tab.
          // TenantScope methods auto-scope to user.
          if let Some(store) = tenant.store() {
              let agent_jobs = match store.list_agent_jobs().await {
                  Ok(jobs) => jobs,
                  Err(e) => {
                      tracing::warn!("Failed to list agent jobs: {}", e);
                      Vec::new()
                  }
              };
              let sandbox_jobs = match store.list_sandbox_jobs().await {
                  Ok(jobs) => jobs,
                  Err(e) => {
                      tracing::warn!("Failed to list sandbox jobs: {}", e);
                      Vec::new()
                  }
              };
  
              if agent_jobs.is_empty() && sandbox_jobs.is_empty() {
                  return Ok("No jobs found.".to_string());
              }
  
              let mut output = String::from("Jobs:\n");
              for j in &agent_jobs {
                  output.push_str(&format!("  {} - {} ({})\n", j.id, j.title, j.status));
              }
              for j in &sandbox_jobs {
                  output.push_str(&format!("  {} - {} ({})\n", j.id, j.task, j.status));
              }
              return Ok(output);
          }
  
          // Fallback to ContextManager if no DB.
          let jobs = self.context_manager.all_jobs_for(tenant.user_id()).await;
          if jobs.is_empty() {
              return Ok("No jobs found.".to_string());
          }
  
          let mut output = String::from("Jobs:\n");
          for job_id in jobs {
              if let Ok(ctx) = self.context_manager.get_context(job_id).await {
                  output.push_str(&format!("  {} - {} ({:?})\n", job_id, ctx.title, ctx.state));
              }
          }
          Ok(output)
      }
  
      async fn handle_help_job(
          &self,
          tenant: &crate::tenant::TenantCtx,
          job_id: &str,
      ) -> Result<String, Error> {
          let uuid = Uuid::parse_str(job_id)
              .map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?;
  
          let ctx = self.context_manager.get_context(uuid).await?;
          if !ctx.is_owned_by(tenant.user_id()) {
              return Err(crate::error::JobError::NotFound { id: uuid }.into());
          }
  
          if ctx.state == crate::context::JobState::Stuck {
              // Attempt recovery
              self.context_manager
                  .update_context(uuid, |ctx| ctx.attempt_recovery())
                  .await?
                  .map_err(|s| crate::error::JobError::ContextError {
                      id: uuid,
                      reason: s,
                  })?;
  
              // Reschedule
              self.scheduler.schedule(uuid).await?;
  
              Ok(format!(
                  "Job {} was stuck. Attempting recovery (attempt #{}).",
                  job_id,
                  ctx.repair_attempts + 1
              ))
          } else {
              Ok(format!(
                  "Job {} is not stuck (current state: {:?}). No help needed.",
                  job_id, ctx.state
              ))
          }
      }
  
      /// Show job status inline — either all jobs (no id) or a specific job.
      pub(super) async fn process_job_status(
          &self,
          tenant: &crate::tenant::TenantCtx,
          job_id: Option<&str>,
      ) -> Result<SubmissionResult, Error> {
          match self
              .handle_check_status(tenant, job_id.map(|s| s.to_string()))
              .await
          {
              Ok(text) => Ok(SubmissionResult::response(text)),
              Err(e) => Ok(SubmissionResult::error(format!("Job status error: {}", e))),
          }
      }
  
      /// Cancel a job by ID.
      pub(super) async fn process_job_cancel(
          &self,
          tenant: &crate::tenant::TenantCtx,
          job_id: &str,
      ) -> Result<SubmissionResult, Error> {
          match self.handle_cancel_job(tenant, job_id).await {
              Ok(text) => Ok(SubmissionResult::response(text)),
              Err(e) => Ok(SubmissionResult::error(format!("Cancel error: {}", e))),
          }
      }
  
      /// Trigger a manual heartbeat check.
      pub(super) async fn process_heartbeat(&self) -> Result<SubmissionResult, Error> {
          let Some(workspace) = self.workspace() else {
              return Ok(SubmissionResult::error(
                  "Heartbeat requires a workspace (database must be connected).",
              ));
          };
  
          let runner = crate::agent::HeartbeatRunner::new(
              crate::agent::HeartbeatConfig::default(),
              crate::workspace::hygiene::HygieneConfig::default(),
              workspace.clone(),
              self.llm().clone(),
          );
  
          match runner.check_heartbeat().await {
              crate::agent::HeartbeatResult::Ok => Ok(SubmissionResult::ok_with_message(
                  "Heartbeat: all clear, nothing needs attention.",
              )),
              crate::agent::HeartbeatResult::NeedsAttention(msg) => Ok(SubmissionResult::response(
                  format!("Heartbeat findings:\n\n{}", msg),
              )),
              crate::agent::HeartbeatResult::Skipped => Ok(SubmissionResult::ok_with_message(
                  "Heartbeat skipped: no HEARTBEAT.md checklist found in workspace.",
              )),
              crate::agent::HeartbeatResult::Failed(err) => Ok(SubmissionResult::error(format!(
                  "Heartbeat failed: {}",
                  err
              ))),
          }
      }
  
      /// Summarize the current thread's conversation.
      pub(super) async fn process_summarize(
          &self,
          session: Arc<Mutex<Session>>,
          thread_id: Uuid,
      ) -> Result<SubmissionResult, Error> {
          let messages = {
              let sess = session.lock().await;
              let thread = sess
                  .threads
                  .get(&thread_id)
                  .ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
              thread.messages()
          };
  
          if messages.is_empty() {
              return Ok(SubmissionResult::ok_with_message(
                  "Nothing to summarize (empty thread).",
              ));
          }
  
          // Build a summary prompt with the conversation
          let mut context = Vec::new();
          context.push(ChatMessage::system(
              "Summarize the conversation so far in 3-5 concise bullet points. \
               Focus on decisions made, actions taken, and key outcomes. \
               Be brief and factual.",
          ));
          // Include the conversation messages (truncate to last 20 to avoid context overflow)
          let start = if messages.len() > 20 {
              messages.len() - 20
          } else {
              0
          };
          context.extend_from_slice(&messages[start..]);
          context.push(ChatMessage::user("Summarize this conversation."));
  
          let request = crate::llm::CompletionRequest::new(context)
              .with_max_tokens(512)
              .with_temperature(0.3);
  
          let reasoning =
              Reasoning::new(self.llm().clone()).with_model_name(self.llm().active_model_name());
          match reasoning.complete(request).await {
              Ok((text, _usage)) => Ok(SubmissionResult::response(format!(
                  "Thread Summary:\n\n{}",
                  text.trim()
              ))),
              Err(e) => Ok(SubmissionResult::error(format!("Summarize failed: {}", e))),
          }
      }
  
      /// Suggest next steps based on the current thread.
      pub(super) async fn process_suggest(
          &self,
          session: Arc<Mutex<Session>>,
          thread_id: Uuid,
      ) -> Result<SubmissionResult, Error> {
          let messages = {
              let sess = session.lock().await;
              let thread = sess
                  .threads
                  .get(&thread_id)
                  .ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
              thread.messages()
          };
  
          if messages.is_empty() {
              return Ok(SubmissionResult::ok_with_message(
                  "Nothing to suggest from (empty thread).",
              ));
          }
  
          let mut context = Vec::new();
          context.push(ChatMessage::system(
              "Based on the conversation so far, suggest 2-4 concrete next steps the user could take. \
               Be actionable and specific. Format as a numbered list.",
          ));
          let start = if messages.len() > 20 {
              messages.len() - 20
          } else {
              0
          };
          context.extend_from_slice(&messages[start..]);
          context.push(ChatMessage::user("What should I do next?"));
  
          let request = crate::llm::CompletionRequest::new(context)
              .with_max_tokens(512)
              .with_temperature(0.5);
  
          let reasoning =
              Reasoning::new(self.llm().clone()).with_model_name(self.llm().active_model_name());
          match reasoning.complete(request).await {
              Ok((text, _usage)) => Ok(SubmissionResult::response(format!(
                  "Suggested Next Steps:\n\n{}",
                  text.trim()
              ))),
              Err(e) => Ok(SubmissionResult::error(format!("Suggest failed: {}", e))),
          }
      }
  
      /// Handle `/expected <description>` — capture expected behavior and fire into
      /// the self-improvement pipeline.
      ///
      /// Collects recent conversation turns (user input, tool calls, responses) and
      /// packages them with the user's description of what should have happened.
      /// This fires a `user_feedback:expected_behavior` system event that the
      /// expected-behavior learning mission picks up.
      pub(super) async fn process_expected(
          &self,
          session: Arc<Mutex<Session>>,
          thread_id: Uuid,
          description: &str,
          user_id: &str,
      ) -> Result<SubmissionResult, Error> {
          // Extract recent turns from the session (last 5 turns for context)
          let recent_context = {
              let sess = session.lock().await;
              let thread = sess
                  .threads
                  .get(&thread_id)
                  .ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
  
              let turns: Vec<serde_json::Value> = thread
                  .turns
                  .iter()
                  .rev()
                  .take(5)
                  .collect::<Vec<_>>()
                  .into_iter()
                  .rev()
                  .map(|turn| {
                      let tool_calls: Vec<serde_json::Value> = turn
                          .tool_calls
                          .iter()
                          .map(|tc| {
                              serde_json::json!({
                                  "tool": tc.name,
                                  "error": tc.error,
                              })
                          })
                          .collect();
                      serde_json::json!({
                          "user_input": turn.user_input,
                          "response": turn.response,
                          "tool_calls": tool_calls,
                          "state": format!("{:?}", turn.state),
                          "error": turn.error,
                      })
                  })
                  .collect();
              turns
          };
  
          if recent_context.is_empty() {
              return Ok(SubmissionResult::ok_with_message(
                  "No conversation history to attach feedback to.",
              ));
          }
  
          let payload = serde_json::json!({
              "expected_behavior": description,
              "thread_id": thread_id.to_string(),
              "recent_turns": recent_context,
          });
  
          // Fire into v2 mission manager (learning missions)
          let mut fired: usize = 0;
          if let Some(mgr) = self.mission_manager().await {
              match mgr
                  .fire_on_system_event(
                      "user_feedback",
                      "expected_behavior",
                      user_id,
                      Some(payload.clone()),
                  )
                  .await
              {
                  Ok(ids) => fired += ids.len(),
                  Err(e) => {
                      tracing::debug!("failed to fire expected-behavior mission: {e}");
                  }
              }
          }
  
          // Also fire through v1 routine engine (if routines listen for this)
          if let Some(engine) = self.routine_engine().await {
              fired += engine
                  .emit_system_event(
                      "user_feedback",
                      "expected_behavior",
                      &payload,
                      Some(user_id),
                  )
                  .await;
          }
  
          if fired > 0 {
              Ok(SubmissionResult::ok_with_message(format!(
                  "Feedback captured. Fired {fired} self-improvement thread(s) to investigate."
              )))
          } else {
              Ok(SubmissionResult::ok_with_message(
                  "Feedback noted but no self-improvement missions are configured to handle it. \
                   The engine will use this context in future learning cycles.",
              ))
          }
      }
  
      /// Handle `/reasoning [N|all]` — show reasoning history for the active thread.
      pub(super) async fn handle_reasoning_command(
          &self,
          args: &[String],
          session: &Arc<Mutex<Session>>,
          thread_id: Uuid,
      ) -> SubmissionResult {
          // Clone the turn data we need, then drop the session lock.
          let turns_snapshot: Vec<(
              usize,
              Option<String>,
              Vec<crate::agent::session::TurnToolCall>,
          )>;
          {
              let sess = session.lock().await;
              let thread = match sess.threads.get(&thread_id) {
                  Some(t) => t,
                  None => return SubmissionResult::error("No active thread."),
              };
  
              if thread.turns.is_empty() {
                  return SubmissionResult::ok_with_message("No turns yet.");
              }
  
              // Parse argument: default=last turn, "all"=all turns, N=specific turn (1-based).
              let selected: Vec<&crate::agent::session::Turn> = match args.first().map(|s| s.as_str())
              {
                  Some("all") => thread.turns.iter().collect(),
                  Some(n) => match n.parse::<usize>() {
                      Ok(0) => return SubmissionResult::error("Turn numbers start at 1."),
                      Ok(num) if num > thread.turns.len() => {
                          return SubmissionResult::error(format!(
                              "Turn {} does not exist (max: {}).",
                              num,
                              thread.turns.len()
                          ));
                      }
                      Ok(num) => vec![&thread.turns[num - 1]],
                      Err(_) => return SubmissionResult::error("Usage: /reasoning [N|all]"),
                  },
                  None => {
                      // Default: last turn that has tool calls
                      match thread.turns.iter().rev().find(|t| !t.tool_calls.is_empty()) {
                          Some(t) => vec![t],
                          None => {
                              return SubmissionResult::ok_with_message("No turns with tool calls.");
                          }
                      }
                  }
              };
  
              turns_snapshot = selected
                  .into_iter()
                  .map(|t| (t.turn_number, t.narrative.clone(), t.tool_calls.clone()))
                  .collect();
          }
          // Session lock is now dropped — format output without holding it.
  
          let mut output = String::new();
          for (turn_number, narrative, tool_calls) in &turns_snapshot {
              output.push_str(&format!("--- Turn {} ---\n", turn_number + 1));
              if let Some(narrative) = narrative {
                  output.push_str(&format!("Reasoning: {}\n", narrative));
              }
              if tool_calls.is_empty() {
                  output.push_str("  (no tool calls)\n");
              } else {
                  for tc in tool_calls {
                      let status = if tc.error.is_some() {
                          "error"
                      } else if tc.result.is_some() {
                          "ok"
                      } else {
                          "pending"
                      };
                      output.push_str(&format!("  {} [{}]", tc.name, status));
                      if let Some(ref rationale) = tc.rationale {
                          output.push_str(&format!(" — {}", rationale));
                      }
                      output.push('\n');
                  }
              }
              output.push('\n');
          }
  
          SubmissionResult::response(output.trim_end())
      }
  
      /// Handle system commands that bypass thread-state checks entirely.
      pub(super) async fn handle_system_command(
          &self,
          command: &str,
          args: &[String],
          channel: &str,
          tenant: &crate::tenant::TenantCtx,
      ) -> Result<SubmissionResult, Error> {
          match command {
              "help" => Ok(SubmissionResult::response(concat!(
                  "System:\n",
                  "  /help             Show this help\n",
                  "  /model [name]     Show or switch the active model\n",
                  "  /version          Show version info\n",
                  "  /tools            List available tools\n",
                  "  /debug            Toggle debug mode\n",
                  "  /reasoning [N|all] Show agent reasoning for turns\n",
                  "  /ping             Connectivity check\n",
                  "\n",
                  "Jobs:\n",
                  "  /job <desc>       Create a new job\n",
                  "  /status [id]      Check job status\n",
                  "  /cancel <id>      Cancel a job\n",
                  "  /list             List all jobs\n",
                  "\n",
                  "Plans:\n",
                  "  /plan <desc>      Create an execution plan\n",
                  "  /plan approve [ref] Approve and start execution\n",
                  "  /plan status [ref]  Check plan progress\n",
                  "  /plan revise [ref]  Revise with feedback\n",
                  "  /plan list        List all plans\n",
                  "\n",
                  "Session:\n",
                  "  /undo             Undo last turn\n",
                  "  /redo             Redo undone turn\n",
                  "  /compact          Compress context window\n",
                  "  /clear            Clear current thread\n",
                  "  /interrupt        Stop current operation\n",
                  "  /new              New conversation thread\n",
                  "  /thread <id>      Switch to thread\n",
                  "  /resume           Resume a previous conversation\n",
                  "\n",
                  "Skills:\n",
                  "  /skills             List installed skills\n",
                  "  /skills search <q>  Search ClawHub registry\n",
                  "\n",
                  "Agent:\n",
                  "  /heartbeat        Run heartbeat check\n",
                  "  /summarize        Summarize current thread\n",
                  "  /suggest          Suggest next steps\n",
                  "  /restart          Gracefully restart the process\n",
                  "\n",
                  "  /quit             Exit",
              ))),
  
              "ping" => Ok(SubmissionResult::response("pong!")),
  
              "restart" => {
                  tracing::info!("[commands::restart] Restart command received");
                  // Channel authorization check: restart is only available via web interface
                  if channel != "gateway" {
                      tracing::warn!(
                          "[commands::restart] Restart rejected: not from gateway channel (from: {})",
                          channel
                      );
                      return Ok(SubmissionResult::error(
                          "Restart is only available through the web interface with explicit user confirmation. \
                           Use the Restart button in the UI."
                              .to_string(),
                      ));
                  }
                  // Environment check: restart is only available in Docker containers
                  let in_docker = std::env::var("IRONCLAW_IN_DOCKER")
                      .map(|v| v.to_lowercase() == "true")
                      .unwrap_or(false);
  
                  tracing::debug!("[commands::restart] IRONCLAW_IN_DOCKER={}", in_docker);
  
                  if !in_docker {
                      tracing::warn!(
                          "[commands::restart] Restart rejected: not in Docker environment"
                      );
                      return Ok(SubmissionResult::error(
                          "Restart is not available in this environment. \
                           The IRONCLAW_IN_DOCKER environment variable must be set to 'true' for Docker deployments."
                              .to_string(),
                      ));
                  }
  
                  // Execute restart tool directly (don't dispatch as a job for LLM planning)
                  // This ensures the tool runs immediately without LLM involvement
                  use crate::tools::Tool;
                  let tool = crate::tools::builtin::RestartTool;
                  let params = serde_json::json!({});
  
                  // Create a minimal JobContext for the tool
                  let dummy_ctx =
                      crate::context::JobContext::with_user("system", "Restart", "Graceful restart");
  
                  match tool.execute(params, &dummy_ctx).await {
                      Ok(output) => {
                          tracing::info!("[commands::restart] RestartTool executed successfully");
                          // Extract text from the ToolOutput result
                          let response = match output.result {
                              serde_json::Value::String(s) => s,
                              _ => output.result.to_string(),
                          };
                          Ok(SubmissionResult::response(response))
                      }
                      Err(e) => {
                          tracing::error!(
                              "[commands::restart] RestartTool execution failed: {:?}",
                              e
                          );
                          Ok(SubmissionResult::error(format!("Restart failed: {}", e)))
                      }
                  }
              }
  
              "version" => Ok(SubmissionResult::response(format!(
                  "{} v{}",
                  env!("CARGO_PKG_NAME"),
                  env!("CARGO_PKG_VERSION")
              ))),
  
              "tools" => {
                  let tools = self.tools().list().await;
                  Ok(SubmissionResult::response(format_vertical_list(
                      "Available tools",
                      &tools,
                  )))
              }
  
              "debug" => {
                  // Debug toggle is handled client-side in the REPL.
                  // For non-REPL channels, just acknowledge.
                  Ok(SubmissionResult::ok_with_message(
                      "Debug toggle is handled by your client.",
                  ))
              }
  
              "skills" => {
                  if args.first().map(|s| s.as_str()) == Some("search") {
                      let query = args[1..].join(" ");
                      if query.is_empty() {
                          return Ok(SubmissionResult::error("Usage: /skills search <query>"));
                      }
                      self.handle_skills_search(&query).await
                  } else if args.is_empty() {
                      self.handle_skills_list().await
                  } else {
                      Ok(SubmissionResult::error(
                          "Usage: /skills or /skills search <query>",
                      ))
                  }
              }
  
              "model" => {
                  let current = self.llm().active_model_name();
  
                  if args.is_empty() {
                      // Show current model and list available models
                      let mut out = format!("Active model: {}\n", current);
                      match self.llm().list_models().await {
                          Ok(models) if !models.is_empty() => {
                              out.push_str("\nAvailable models:\n");
                              for m in &models {
                                  let marker = if *m == current { " (active)" } else { "" };
                                  out.push_str(&format!("  {}{}\n", m, marker));
                              }
                              out.push_str("\nUse /model <name> to switch.");
                          }
                          Ok(_) => {
                              out.push_str(
                                  "\nCould not fetch model list. Use /model <name> to switch.",
                              );
                          }
                          Err(e) => {
                              out.push_str(&format!(
                                  "\nCould not fetch models: {}. Use /model <name> to switch.",
                                  e
                              ));
                          }
                      }
                      Ok(SubmissionResult::response(out))
                  } else {
                      let requested = &args[0];
  
                      // Validate the model exists
                      match self.llm().list_models().await {
                          Ok(models) if !models.is_empty() => {
                              if !models.iter().any(|m| m == requested) {
                                  return Ok(SubmissionResult::error(format!(
                                      "Unknown model: {}. Available models:\n  {}",
                                      requested,
                                      models.join("\n  ")
                                  )));
                              }
                          }
                          Ok(_) => {
                              // Empty model list, can't validate but try anyway
                          }
                          Err(e) => {
                              tracing::warn!("Could not fetch model list for validation: {}", e);
                          }
                      }
  
                      if self.config.multi_tenant {
                          // Multi-tenant: only persist to per-user DB settings.
                          // Do NOT call set_model() on the shared provider — that
                          // would change the default for all users. The per-request
                          // model_override in the dispatcher reads from the same
                          // "selected_model" setting and applies it per-user.
                          self.persist_selected_model(tenant, requested).await;
                          Ok(SubmissionResult::response(format!(
                              "Model preference set to: {} (per-user)",
                              requested
                          )))
                      } else {
                          match self.llm().set_model(requested) {
                              Ok(()) => {
                                  // Persist the model choice so it survives restarts.
                                  self.persist_selected_model(tenant, requested).await;
                                  Ok(SubmissionResult::response(format!(
                                      "Switched model to: {}",
                                      requested
                                  )))
                              }
                              Err(e) => Ok(SubmissionResult::error(format!(
                                  "Failed to switch model: {}",
                                  e
                              ))),
                          }
                      }
                  }
              }
  
              _ => Ok(SubmissionResult::error(format!(
                  "Unknown command: {}. Try /help",
                  command
              ))),
          }
      }
  
      /// List installed skills.
      async fn handle_skills_list(&self) -> Result<SubmissionResult, Error> {
          let Some(registry) = self.skill_registry() else {
              return Ok(SubmissionResult::error("Skills system not enabled."));
          };
  
          let guard = match registry.read() {
              Ok(g) => g,
              Err(e) => {
                  return Ok(SubmissionResult::error(format!(
                      "Skill registry lock error: {}",
                      e
                  )));
              }
          };
  
          let skills = guard.skills();
          if skills.is_empty() {
              return Ok(SubmissionResult::response(
                  "No skills installed.\n\nUse /skills search <query> to find skills on ClawHub.",
              ));
          }
  
          let mut out = String::from("Installed skills:\n\n");
          for s in skills {
              let desc = if s.manifest.description.chars().count() > 60 {
                  let truncated: String = s.manifest.description.chars().take(57).collect();
                  format!("{}...", truncated)
              } else {
                  s.manifest.description.clone()
              };
              out.push_str(&format!(
                  "  {:<24} v{:<10} [{}]  {}\n",
                  s.manifest.name, s.manifest.version, s.trust, desc,
              ));
          }
          out.push_str("\nUse /skills search <query> to find more on ClawHub.");
  
          Ok(SubmissionResult::response(out))
      }
  
      /// Search ClawHub for skills.
      async fn handle_skills_search(&self, query: &str) -> Result<SubmissionResult, Error> {
          let catalog = match self.skill_catalog() {
              Some(c) => c,
              None => {
                  return Ok(SubmissionResult::error("Skill catalog not available."));
              }
          };
  
          let outcome = catalog.search(query).await;
  
          // Enrich top results with detail data (stars, downloads, owner)
          let mut entries = outcome.results;
          catalog.enrich_search_results(&mut entries, 5).await;
  
          let mut out = format!("ClawHub results for \"{}\":\n\n", query);
  
          if entries.is_empty() {
              if let Some(ref err) = outcome.error {
                  out.push_str(&format!("  (registry error: {})\n", err));
              } else {
                  out.push_str("  No results found.\n");
              }
          } else {
              for entry in &entries {
                  let owner_str = entry
                      .owner
                      .as_deref()
                      .map(|o| format!("  by {}", o))
                      .unwrap_or_default();
  
                  let stats_parts: Vec<String> = [
                      entry.stars.map(|s| format!("{} stars", s)),
                      entry.downloads.map(|d| format_count(d, "downloads")),
                  ]
                  .into_iter()
                  .flatten()
                  .collect();
                  let stats_str = if stats_parts.is_empty() {
                      String::new()
                  } else {
                      format!("  {}", stats_parts.join("  "))
                  };
  
                  out.push_str(&format!(
                      "  {:<24} v{:<10}{}{}\n",
                      entry.name, entry.version, owner_str, stats_str,
                  ));
                  if !entry.description.is_empty() {
                      out.push_str(&format!("    {}\n\n", entry.description));
                  }
              }
          }
  
          // Show matching installed skills
          if let Some(registry) = self.skill_registry()
              && let Ok(guard) = registry.read()
          {
              let query_lower = query.to_lowercase();
              let matches: Vec<_> = guard
                  .skills()
                  .iter()
                  .filter(|s| {
                      s.manifest.name.to_lowercase().contains(&query_lower)
                          || s.manifest.description.to_lowercase().contains(&query_lower)
                  })
                  .collect();
  
              if !matches.is_empty() {
                  out.push_str(&format!("Installed skills matching \"{}\":\n", query));
                  for s in &matches {
                      out.push_str(&format!(
                          "  {:<24} v{:<10} [{}]\n",
                          s.manifest.name, s.manifest.version, s.trust,
                      ));
                  }
              }
          }
  
          Ok(SubmissionResult::response(out))
      }
  
      /// Handle legacy command routing from the Router (job commands that go through
      /// process_user_input -> router -> handle_job_or_command -> here).
      pub(super) async fn handle_command(
          &self,
          command: &str,
          args: &[String],
          channel: &str,
          tenant: &crate::tenant::TenantCtx,
      ) -> Result<Option<String>, Error> {
          // System commands are now handled directly via Submission::SystemCommand,
          // but the router may still send us unknown /commands.
          match self
              .handle_system_command(command, args, channel, tenant)
              .await?
          {
              SubmissionResult::Response { content } => Ok(Some(content)),
              SubmissionResult::Ok { message } => Ok(message),
              SubmissionResult::Error { message } => Ok(Some(format!("Error: {}", message))),
              _ => Ok(None),
          }
      }
  
      /// Persist the selected model to the settings store (DB and/or TOML config).
      ///
      /// Best-effort: logs warnings on failure but does not propagate errors,
      /// since the in-memory model switch already succeeded.
      ///
      /// The DB setting is the primary persistence layer. For LLM settings the
      /// resolution priority is `DB > env > TOML > default`, so writing to DB
      /// is sufficient for the change to survive restarts. The `.env` and TOML
      /// files are only updated as a courtesy when they already contain a model
      /// var, to avoid user confusion.
      ///
      /// In multi-tenant mode, only the per-user DB setting is written — global
      /// .env and TOML files are shared across users and must not be mutated.
      async fn persist_selected_model(&self, tenant: &crate::tenant::TenantCtx, model: &str) {
          // 1. Persist to DB if available (per-user scoped via TenantScope).
          if let Some(store) = tenant.store() {
              let value = serde_json::Value::String(model.to_string());
              if let Err(e) = store.set_setting("selected_model", &value).await {
                  tracing::warn!("Failed to persist model to DB: {}", e);
              } else {
                  tracing::debug!(
                      user_id = tenant.user_id(),
                      "Persisted selected_model to DB: {}",
                      model
                  );
              }
          } else {
              tracing::warn!("No database store available — model choice will not persist to DB");
          }
  
          // 2. In multi-tenant mode, skip .env/TOML writes — these are global
          // files shared by all users. The per-user DB setting is sufficient.
          if self.config.multi_tenant {
              return;
          }
  
          // 3. Best-effort update of .env and TOML if they already contain a
          //    model var. DB is authoritative (DB > env > TOML), but keeping
          //    these in sync avoids confusion when users inspect the files.
          let model_owned = model.to_string();
          let backend = self.deps.llm_backend.clone();
          if let Err(e) = tokio::task::spawn_blocking(move || {
              // 3a. Update the backend-specific model env var in ~/.ironclaw/.env
              //     only if the var already exists (don't inject new vars).
              let registry = crate::llm::ProviderRegistry::load();
              let model_env = registry.model_env_var(&backend);
              let env_var_prefix = format!("{}=", model_env);
  
              let env_path = crate::bootstrap::ironclaw_env_path();
              let env_has_var = std::fs::read_to_string(&env_path)
                  .ok()
                  .is_some_and(|content| {
                      content.lines().any(|line| {
                          let trimmed = line.trim_start();
                          !trimmed.starts_with('#') && trimmed.starts_with(&env_var_prefix)
                      })
                  });
              if env_has_var {
                  if let Err(e) = crate::bootstrap::upsert_bootstrap_var(model_env, &model_owned) {
                      tracing::warn!("Failed to update {} in .env: {}", model_env, e);
                  } else {
                      tracing::debug!("Updated {} in .env to {}", model_env, model_owned);
                  }
              }
  
              // 3b. Update TOML config file if it already exists.
              //     Don't create a new one — DB persistence is sufficient.
              let toml_path = crate::settings::Settings::default_toml_path();
              match crate::settings::Settings::load_toml(&toml_path) {
                  Ok(Some(mut settings)) => {
                      settings.selected_model = Some(model_owned);
                      if let Err(e) = settings.save_toml(&toml_path) {
                          tracing::warn!("Failed to persist model to config.toml: {}", e);
                      }
                  }
                  Ok(None) => {
                      // No config file on disk; DB persistence is sufficient.
                  }
                  Err(e) => {
                      tracing::warn!("Failed to load config.toml for model persistence: {}", e);
                  }
              }
          })
          .await
          {
              tracing::warn!("Model persistence task failed: {}", e);
          }
      }
  }
  
  #[cfg(test)]
  mod tests {
      use super::format_vertical_list;
  
      #[test]
      fn format_vertical_list_renders_one_item_per_line() {
          let formatted = format_vertical_list(
              "Available tools",
              &[
                  "time".to_string(),
                  "shell".to_string(),
                  "github".to_string(),
              ],
          );
  
          assert_eq!(formatted, "Available tools:\n  time\n  shell\n  github");
      }
  }
  