Skip to main content

zeph_core/agent/
slash_commands.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Static registry of all slash commands available in the agent loop.
5//!
6//! Used by `/help` to enumerate and display commands grouped by category.
7
8/// Broad grouping for displaying commands in `/help` output.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum SlashCategory {
11    Session,
12    Model,
13    Info,
14    Memory,
15    Tools,
16    Debug,
17    Planning,
18    Advanced,
19}
20
21impl SlashCategory {
22    #[must_use]
23    pub fn as_str(self) -> &'static str {
24        match self {
25            Self::Session => "Session",
26            Self::Model => "Model",
27            Self::Info => "Info",
28            Self::Memory => "Memory",
29            Self::Tools => "Tools",
30            Self::Debug => "Debug",
31            Self::Planning => "Planning",
32            Self::Advanced => "Advanced",
33        }
34    }
35}
36
37/// Metadata for a single slash command displayed by `/help`.
38pub struct SlashCommandInfo {
39    pub name: &'static str,
40    /// Argument hint shown after the command name, e.g. `[path]` or `<name>`.
41    pub args: &'static str,
42    pub description: &'static str,
43    pub category: SlashCategory,
44    /// When `Some`, this entry was compiled in only for that feature.
45    /// Shown in the help output as `[requires: <feature>]`.
46    pub feature_gate: Option<&'static str>,
47}
48
49/// All slash commands recognised by the agent loop, in display order.
50///
51/// Feature-gated entries are wrapped in `#[cfg(feature = "...")]` so that
52/// only commands compiled into the binary appear in `/help`.
53pub const COMMANDS: &[SlashCommandInfo] = &[
54    // --- Info ---
55    SlashCommandInfo {
56        name: "/help",
57        args: "",
58        description: "Show this help message",
59        category: SlashCategory::Info,
60        feature_gate: None,
61    },
62    SlashCommandInfo {
63        name: "/status",
64        args: "",
65        description: "Show current session status (provider, model, tokens, uptime)",
66        category: SlashCategory::Info,
67        feature_gate: None,
68    },
69    SlashCommandInfo {
70        name: "/skills",
71        args: "",
72        description: "List loaded skills (grouped by category when available)",
73        category: SlashCategory::Info,
74        feature_gate: None,
75    },
76    SlashCommandInfo {
77        name: "/skills confusability",
78        args: "",
79        description: "Show skill pairs with high embedding similarity (potential disambiguation failures)",
80        category: SlashCategory::Info,
81        feature_gate: None,
82    },
83    SlashCommandInfo {
84        name: "/guardrail",
85        args: "",
86        description: "Show guardrail status (provider, model, action, timeout, stats)",
87        category: SlashCategory::Info,
88        feature_gate: Some("guardrail"),
89    },
90    SlashCommandInfo {
91        name: "/log",
92        args: "",
93        description: "Toggle verbose log output",
94        category: SlashCategory::Info,
95        feature_gate: None,
96    },
97    // --- Session ---
98    SlashCommandInfo {
99        name: "/exit",
100        args: "",
101        description: "Exit the agent (also: /quit)",
102        category: SlashCategory::Session,
103        feature_gate: None,
104    },
105    SlashCommandInfo {
106        name: "/new",
107        args: "[--no-digest] [--keep-plan]",
108        description: "Start a new conversation (reset context, preserve memory and MCP)",
109        category: SlashCategory::Session,
110        feature_gate: None,
111    },
112    SlashCommandInfo {
113        name: "/clear",
114        args: "",
115        description: "Clear conversation history",
116        category: SlashCategory::Session,
117        feature_gate: None,
118    },
119    SlashCommandInfo {
120        name: "/reset",
121        args: "",
122        description: "Reset conversation history (alias for /clear, replies with confirmation)",
123        category: SlashCategory::Session,
124        feature_gate: None,
125    },
126    SlashCommandInfo {
127        name: "/clear-queue",
128        args: "",
129        description: "Discard queued messages",
130        category: SlashCategory::Session,
131        feature_gate: None,
132    },
133    SlashCommandInfo {
134        name: "/compact",
135        args: "",
136        description: "Compact the context window",
137        category: SlashCategory::Session,
138        feature_gate: None,
139    },
140    // --- Model ---
141    SlashCommandInfo {
142        name: "/model",
143        args: "[id|refresh]",
144        description: "Show or switch the active model",
145        category: SlashCategory::Model,
146        feature_gate: None,
147    },
148    SlashCommandInfo {
149        name: "/provider",
150        args: "[name|status]",
151        description: "List configured providers or switch to one by name",
152        category: SlashCategory::Model,
153        feature_gate: None,
154    },
155    // --- Memory ---
156    SlashCommandInfo {
157        name: "/feedback",
158        args: "<skill> <message>",
159        description: "Submit feedback for a skill",
160        category: SlashCategory::Memory,
161        feature_gate: None,
162    },
163    SlashCommandInfo {
164        name: "/graph",
165        args: "[subcommand]",
166        description: "Query or manage the knowledge graph",
167        category: SlashCategory::Memory,
168        feature_gate: None,
169    },
170    SlashCommandInfo {
171        name: "/memory",
172        args: "[tiers|promote <id>...]",
173        description: "Show memory tier stats or manually promote messages to semantic tier",
174        category: SlashCategory::Memory,
175        feature_gate: None,
176    },
177    SlashCommandInfo {
178        name: "/guidelines",
179        args: "",
180        description: "Show current compression guidelines",
181        category: SlashCategory::Memory,
182        feature_gate: Some("compression-guidelines"),
183    },
184    // --- Tools ---
185    SlashCommandInfo {
186        name: "/skill",
187        args: "<name>",
188        description: "Load and display a skill body",
189        category: SlashCategory::Tools,
190        feature_gate: None,
191    },
192    SlashCommandInfo {
193        name: "/skill create",
194        args: "<description>",
195        description: "Generate a SKILL.md from natural language via LLM",
196        category: SlashCategory::Tools,
197        feature_gate: None,
198    },
199    SlashCommandInfo {
200        name: "/mcp",
201        args: "[add|list|tools|remove]",
202        description: "Manage MCP servers",
203        category: SlashCategory::Tools,
204        feature_gate: None,
205    },
206    SlashCommandInfo {
207        name: "/image",
208        args: "<path>",
209        description: "Attach an image to the next message",
210        category: SlashCategory::Tools,
211        feature_gate: None,
212    },
213    SlashCommandInfo {
214        name: "/agent",
215        args: "[subcommand]",
216        description: "Manage sub-agents",
217        category: SlashCategory::Tools,
218        feature_gate: None,
219    },
220    // --- Planning ---
221    SlashCommandInfo {
222        name: "/plan",
223        args: "[goal|confirm|cancel|status|list|resume|retry]",
224        description: "Create or manage execution plans",
225        category: SlashCategory::Planning,
226        feature_gate: None,
227    },
228    // --- Debug ---
229    SlashCommandInfo {
230        name: "/debug-dump",
231        args: "[path]",
232        description: "Enable or toggle debug dump output",
233        category: SlashCategory::Debug,
234        feature_gate: None,
235    },
236    SlashCommandInfo {
237        name: "/dump-format",
238        args: "<json|raw|trace>",
239        description: "Switch debug dump format at runtime",
240        category: SlashCategory::Debug,
241        feature_gate: None,
242    },
243    // --- Advanced (feature-gated) ---
244    #[cfg(feature = "scheduler")]
245    SlashCommandInfo {
246        name: "/scheduler",
247        args: "[list]",
248        description: "List scheduled tasks",
249        category: SlashCategory::Tools,
250        feature_gate: Some("scheduler"),
251    },
252    SlashCommandInfo {
253        name: "/experiment",
254        args: "[subcommand]",
255        description: "Experimental features",
256        category: SlashCategory::Advanced,
257        feature_gate: Some("experiments"),
258    },
259    SlashCommandInfo {
260        name: "/lsp",
261        args: "",
262        description: "Show LSP context status",
263        category: SlashCategory::Advanced,
264        feature_gate: Some("lsp-context"),
265    },
266    SlashCommandInfo {
267        name: "/policy",
268        args: "[status|check <tool> [args_json]]",
269        description: "Inspect policy status or dry-run evaluation",
270        category: SlashCategory::Tools,
271        feature_gate: Some("policy-enforcer"),
272    },
273    SlashCommandInfo {
274        name: "/focus",
275        args: "",
276        description: "Show Focus Agent status (active session, knowledge block size)",
277        category: SlashCategory::Advanced,
278        feature_gate: Some("context-compression"),
279    },
280    SlashCommandInfo {
281        name: "/sidequest",
282        args: "",
283        description: "Show SideQuest eviction stats (passes run, tokens freed)",
284        category: SlashCategory::Advanced,
285        feature_gate: Some("context-compression"),
286    },
287];
288
289use zeph_llm::provider::LlmProvider;
290
291use super::Agent;
292use super::error;
293use super::message_queue::{MAX_IMAGE_BYTES, detect_image_mime};
294
295impl<C: crate::channel::Channel> Agent<C> {
296    /// Handle built-in slash commands that short-circuit the main `run` loop.
297    ///
298    /// Returns `Some(true)` to break the loop (exit), `Some(false)` to continue to the next
299    /// iteration, or `None` if the command was not recognized (caller should call
300    /// `process_user_message`).
301    #[allow(clippy::too_many_lines)]
302    pub(super) async fn handle_builtin_command(
303        &mut self,
304        trimmed: &str,
305    ) -> Result<Option<bool>, error::AgentError> {
306        if trimmed == "/clear-queue" {
307            let n = self.clear_queue();
308            self.notify_queue_count().await;
309            self.channel
310                .send(&format!("Cleared {n} queued messages."))
311                .await?;
312            let _ = self.channel.flush_chunks().await;
313            return Ok(Some(false));
314        }
315
316        if trimmed == "/compact" {
317            if self.msg.messages.len() > self.context_manager.compaction_preserve_tail + 1 {
318                match self.compact_context().await {
319                    Ok(
320                        super::context::CompactionOutcome::Compacted
321                        | super::context::CompactionOutcome::NoChange,
322                    ) => {
323                        let _ = self.channel.send("Context compacted successfully.").await;
324                    }
325                    Ok(super::context::CompactionOutcome::ProbeRejected) => {
326                        let _ = self
327                            .channel
328                            .send(
329                                "Compaction rejected: summary quality below threshold. \
330                                 Original context preserved.",
331                            )
332                            .await;
333                    }
334                    Err(e) => {
335                        let _ = self.channel.send(&format!("Compaction failed: {e}")).await;
336                    }
337                }
338            } else {
339                let _ = self.channel.send("Nothing to compact.").await;
340            }
341            let _ = self.channel.flush_chunks().await;
342            return Ok(Some(false));
343        }
344
345        if trimmed == "/new" || trimmed.starts_with("/new ") {
346            let args = trimmed.strip_prefix("/new").unwrap_or("").trim();
347            let keep_plan = args.split_whitespace().any(|a| a == "--keep-plan");
348            let no_digest = args.split_whitespace().any(|a| a == "--no-digest");
349            match self.reset_conversation(keep_plan, no_digest).await {
350                Ok((old_id, new_id)) => {
351                    let old = old_id.map_or_else(|| "none".to_string(), |id| id.0.to_string());
352                    let new = new_id.map_or_else(|| "none".to_string(), |id| id.0.to_string());
353                    let keep_note = if keep_plan { " (plan preserved)" } else { "" };
354                    self.channel
355                        .send(&format!(
356                            "New conversation started. Previous: {old} → Current: {new}{keep_note}"
357                        ))
358                        .await?;
359                }
360                Err(e) => {
361                    self.channel
362                        .send(&format!("Failed to start new conversation: {e}"))
363                        .await?;
364                }
365            }
366            let _ = self.channel.flush_chunks().await;
367            return Ok(Some(false));
368        }
369
370        if trimmed == "/clear" {
371            self.clear_history();
372            self.tool_orchestrator.clear_cache();
373            if let Ok(mut urls) = self.security.user_provided_urls.write() {
374                urls.clear();
375            }
376            let _ = self.channel.flush_chunks().await;
377            return Ok(Some(false));
378        }
379
380        if trimmed == "/reset" {
381            self.clear_history();
382            self.tool_orchestrator.clear_cache();
383            if let Ok(mut urls) = self.security.user_provided_urls.write() {
384                urls.clear();
385            }
386            self.channel.send("Conversation history reset.").await?;
387            let _ = self.channel.flush_chunks().await;
388            return Ok(Some(false));
389        }
390
391        if trimmed == "/cache-stats" {
392            let stats = self.tool_orchestrator.cache_stats();
393            self.channel.send(&stats).await?;
394            let _ = self.channel.flush_chunks().await;
395            return Ok(Some(false));
396        }
397
398        if trimmed == "/model" || trimmed.starts_with("/model ") {
399            self.handle_model_command(trimmed).await;
400            let _ = self.channel.flush_chunks().await;
401            return Ok(Some(false));
402        }
403
404        if trimmed == "/provider" || trimmed.starts_with("/provider ") {
405            self.handle_provider_command(trimmed).await;
406            let _ = self.channel.flush_chunks().await;
407            return Ok(Some(false));
408        }
409
410        if trimmed == "/debug-dump" || trimmed.starts_with("/debug-dump ") {
411            self.handle_debug_dump_command(trimmed).await;
412            let _ = self.channel.flush_chunks().await;
413            return Ok(Some(false));
414        }
415
416        if trimmed.starts_with("/dump-format") {
417            self.handle_dump_format_command(trimmed).await;
418            let _ = self.channel.flush_chunks().await;
419            return Ok(Some(false));
420        }
421
422        if trimmed == "/exit" || trimmed == "/quit" {
423            if self.channel.supports_exit() {
424                return Ok(Some(true));
425            }
426            let _ = self
427                .channel
428                .send("/exit is not supported in this channel.")
429                .await;
430            return Ok(Some(false));
431        }
432
433        Ok(None)
434    }
435
436    /// Dispatch slash commands. Returns `Some(Ok(()))` when handled,
437    /// `Some(Err(_))` on I/O error, `None` to fall through to LLM processing.
438    #[allow(clippy::too_many_lines)]
439    pub(super) async fn dispatch_slash_command(
440        &mut self,
441        trimmed: &str,
442    ) -> Option<Result<(), error::AgentError>> {
443        macro_rules! handled {
444            ($expr:expr) => {{
445                if let Err(e) = $expr {
446                    return Some(Err(e));
447                }
448                let _ = self.channel.flush_chunks().await;
449                return Some(Ok(()));
450            }};
451        }
452
453        let slash_urls = zeph_sanitizer::exfiltration::extract_flagged_urls(trimmed);
454        if !slash_urls.is_empty()
455            && let Ok(mut set) = self.security.user_provided_urls.write()
456        {
457            set.extend(slash_urls);
458        }
459
460        if trimmed == "/help" {
461            handled!(self.handle_help_command().await);
462        }
463
464        if trimmed == "/status" {
465            handled!(self.handle_status_command().await);
466        }
467        if trimmed == "/guardrail" {
468            handled!(self.handle_guardrail_command().await);
469        }
470
471        if trimmed == "/skills" || trimmed.starts_with("/skills ") {
472            let subcommand = trimmed.strip_prefix("/skills").unwrap_or("").trim();
473            handled!(self.handle_skills_family(subcommand).await);
474        }
475
476        if trimmed == "/skill" || trimmed.starts_with("/skill ") {
477            let rest = trimmed
478                .strip_prefix("/skill")
479                .unwrap_or("")
480                .trim()
481                .to_owned();
482            handled!(self.handle_skill_command(&rest).await);
483        }
484
485        if trimmed == "/feedback" || trimmed.starts_with("/feedback ") {
486            let rest = trimmed
487                .strip_prefix("/feedback")
488                .unwrap_or("")
489                .trim()
490                .to_owned();
491            handled!(self.handle_feedback(&rest).await);
492        }
493
494        if trimmed == "/mcp" || trimmed.starts_with("/mcp ") {
495            let args = trimmed.strip_prefix("/mcp").unwrap_or("").trim().to_owned();
496            handled!(self.handle_mcp_command(&args).await);
497        }
498
499        if trimmed == "/image" || trimmed.starts_with("/image ") {
500            let path = trimmed
501                .strip_prefix("/image")
502                .unwrap_or("")
503                .trim()
504                .to_owned();
505            if path.is_empty() {
506                handled!(
507                    self.channel
508                        .send("Usage: /image <path>")
509                        .await
510                        .map_err(Into::into)
511                );
512            }
513            handled!(self.handle_image_command(&path).await);
514        }
515
516        if trimmed == "/plan" || trimmed.starts_with("/plan ") {
517            return Some(self.dispatch_plan_command(trimmed).await);
518        }
519
520        if trimmed == "/graph" || trimmed.starts_with("/graph ") {
521            handled!(self.handle_graph_command(trimmed).await);
522        }
523
524        if trimmed == "/memory" || trimmed.starts_with("/memory ") {
525            handled!(self.handle_memory_command(trimmed).await);
526        }
527        if trimmed == "/guidelines" {
528            handled!(self.handle_guidelines_command().await);
529        }
530
531        #[cfg(feature = "scheduler")]
532        if trimmed == "/scheduler" || trimmed.starts_with("/scheduler ") {
533            handled!(self.handle_scheduler_command(trimmed).await);
534        }
535        if trimmed == "/experiment" || trimmed.starts_with("/experiment ") {
536            handled!(self.handle_experiment_command(trimmed).await);
537        }
538        if trimmed == "/lsp" {
539            handled!(self.handle_lsp_status_command().await);
540        }
541        if trimmed == "/policy" || trimmed.starts_with("/policy ") {
542            let args = trimmed
543                .strip_prefix("/policy")
544                .unwrap_or("")
545                .trim()
546                .to_owned();
547            handled!(self.handle_policy_command(&args).await);
548        }
549
550        if trimmed == "/log" {
551            handled!(self.handle_log_command().await);
552        }
553
554        if trimmed.starts_with("/agent") || trimmed.starts_with('@') {
555            return self.dispatch_agent_command(trimmed).await;
556        }
557        if trimmed == "/focus" {
558            handled!(self.handle_focus_status_command().await);
559        }
560        if trimmed == "/sidequest" {
561            handled!(self.handle_sidequest_status_command().await);
562        }
563
564        None
565    }
566
567    pub(super) async fn dispatch_agent_command(
568        &mut self,
569        trimmed: &str,
570    ) -> Option<Result<(), error::AgentError>> {
571        let known: Vec<String> = self
572            .orchestration
573            .subagent_manager
574            .as_ref()
575            .map(|m| m.definitions().iter().map(|d| d.name.clone()).collect())
576            .unwrap_or_default();
577        match crate::subagent::AgentCommand::parse(trimmed, &known) {
578            Ok(cmd) => {
579                if let Some(msg) = self.handle_agent_command(cmd).await
580                    && let Err(e) = self.channel.send(&msg).await
581                {
582                    return Some(Err(e.into()));
583                }
584                let _ = self.channel.flush_chunks().await;
585                Some(Ok(()))
586            }
587            Err(e) if trimmed.starts_with('@') => {
588                tracing::debug!("@mention not matched as agent: {e}");
589                None
590            }
591            Err(e) => {
592                if let Err(send_err) = self.channel.send(&e.to_string()).await {
593                    return Some(Err(send_err.into()));
594                }
595                let _ = self.channel.flush_chunks().await;
596                Some(Ok(()))
597            }
598        }
599    }
600
601    pub(super) async fn handle_image_command(
602        &mut self,
603        path: &str,
604    ) -> Result<(), error::AgentError> {
605        use std::path::Component;
606        use zeph_llm::provider::{ImageData, MessagePart};
607
608        let has_parent_dir = std::path::Path::new(path)
609            .components()
610            .any(|c| c == Component::ParentDir);
611        if has_parent_dir {
612            self.channel
613                .send("Invalid image path: path traversal not allowed")
614                .await?;
615            let _ = self.channel.flush_chunks().await;
616            return Ok(());
617        }
618
619        let data = match std::fs::read(path) {
620            Ok(d) => d,
621            Err(e) => {
622                self.channel
623                    .send(&format!("Cannot read image {path}: {e}"))
624                    .await?;
625                let _ = self.channel.flush_chunks().await;
626                return Ok(());
627            }
628        };
629        if data.len() > MAX_IMAGE_BYTES {
630            self.channel
631                .send(&format!(
632                    "Image {path} exceeds size limit ({} MB), skipping",
633                    MAX_IMAGE_BYTES / 1024 / 1024
634                ))
635                .await?;
636            let _ = self.channel.flush_chunks().await;
637            return Ok(());
638        }
639        let mime_type = detect_image_mime(Some(path)).to_string();
640        self.msg
641            .pending_image_parts
642            .push(MessagePart::Image(Box::new(ImageData { data, mime_type })));
643        self.channel
644            .send(&format!("Image loaded: {path}. Send your message."))
645            .await?;
646        let _ = self.channel.flush_chunks().await;
647        Ok(())
648    }
649
650    pub(super) async fn handle_help_command(&mut self) -> Result<(), error::AgentError> {
651        use std::fmt::Write;
652
653        let mut out = String::from("Slash commands:\n\n");
654
655        let categories = [
656            SlashCategory::Info,
657            SlashCategory::Session,
658            SlashCategory::Model,
659            SlashCategory::Memory,
660            SlashCategory::Tools,
661            SlashCategory::Planning,
662            SlashCategory::Debug,
663            SlashCategory::Advanced,
664        ];
665
666        for cat in &categories {
667            let entries: Vec<_> = COMMANDS.iter().filter(|c| &c.category == cat).collect();
668            if entries.is_empty() {
669                continue;
670            }
671            let _ = writeln!(out, "{}:", cat.as_str());
672            for cmd in entries {
673                if cmd.args.is_empty() {
674                    let _ = write!(out, "  {}", cmd.name);
675                } else {
676                    let _ = write!(out, "  {} {}", cmd.name, cmd.args);
677                }
678                let _ = write!(out, "  — {}", cmd.description);
679                if let Some(feat) = cmd.feature_gate {
680                    let _ = write!(out, " [requires: {feat}]");
681                }
682                let _ = writeln!(out);
683            }
684            let _ = writeln!(out);
685        }
686
687        self.channel.send(out.trim_end()).await?;
688        Ok(())
689    }
690
691    #[allow(clippy::too_many_lines)]
692    pub(super) async fn handle_status_command(&mut self) -> Result<(), error::AgentError> {
693        use std::fmt::Write;
694        use zeph_llm::provider::Role;
695
696        let uptime = self.lifecycle.start_time.elapsed().as_secs();
697        let msg_count = self
698            .msg
699            .messages
700            .iter()
701            .filter(|m| m.role == Role::User)
702            .count();
703
704        let (
705            api_calls,
706            prompt_tokens,
707            completion_tokens,
708            cost_cents,
709            mcp_servers,
710            orch_plans,
711            orch_tasks,
712            orch_completed,
713            orch_failed,
714            orch_skipped,
715        ) = if let Some(ref tx) = self.metrics.metrics_tx {
716            let m = tx.borrow();
717            (
718                m.api_calls,
719                m.prompt_tokens,
720                m.completion_tokens,
721                m.cost_spent_cents,
722                m.mcp_server_count,
723                m.orchestration.plans_total,
724                m.orchestration.tasks_total,
725                m.orchestration.tasks_completed,
726                m.orchestration.tasks_failed,
727                m.orchestration.tasks_skipped,
728            )
729        } else {
730            (0, 0, 0, 0.0, 0, 0, 0, 0, 0, 0)
731        };
732
733        let skill_count = self
734            .skill_state
735            .registry
736            .read()
737            .map(|r| r.all_meta().len())
738            .unwrap_or(0);
739
740        let mut out = String::from("Session status:\n\n");
741        let _ = writeln!(out, "Provider:  {}", self.provider.name());
742        let _ = writeln!(out, "Model:     {}", self.runtime.model_name);
743        let _ = writeln!(out, "Uptime:    {uptime}s");
744        let _ = writeln!(out, "Turns:     {msg_count}");
745        let _ = writeln!(out, "API calls: {api_calls}");
746        let _ = writeln!(
747            out,
748            "Tokens:    {prompt_tokens} prompt / {completion_tokens} completion"
749        );
750        let _ = writeln!(out, "Skills:    {skill_count}");
751        let _ = writeln!(out, "MCP:       {mcp_servers} server(s)");
752        if let Some(ref tf) = self.tool_schema_filter {
753            let _ = writeln!(
754                out,
755                "Filter:    enabled (top_k={}, always_on={}, {} embeddings)",
756                tf.top_k(),
757                tf.always_on_count(),
758                tf.embedding_count(),
759            );
760        }
761        if let Some(ref adv) = self.runtime.adversarial_policy_info {
762            let provider_display = if adv.provider.is_empty() {
763                "default"
764            } else {
765                adv.provider.as_str()
766            };
767            let _ = writeln!(
768                out,
769                "Adv gate:  enabled (provider={}, policies={}, fail_open={})",
770                provider_display, adv.policy_count, adv.fail_open
771            );
772        }
773        if cost_cents > 0.0 {
774            let _ = writeln!(out, "Cost:      ${:.4}", cost_cents / 100.0);
775        }
776        if orch_plans > 0 {
777            let _ = writeln!(out);
778            let _ = writeln!(out, "Orchestration:");
779            let _ = writeln!(out, "  Plans:     {orch_plans}");
780            let _ = writeln!(out, "  Tasks:     {orch_completed}/{orch_tasks} completed");
781            if orch_failed > 0 {
782                let _ = writeln!(out, "  Failed:    {orch_failed}");
783            }
784            if orch_skipped > 0 {
785                let _ = writeln!(out, "  Skipped:   {orch_skipped}");
786            }
787        }
788
789        {
790            use crate::config::PruningStrategy;
791            if matches!(
792                self.context_manager.compression.pruning_strategy,
793                PruningStrategy::Subgoal | PruningStrategy::SubgoalMig
794            ) {
795                let _ = writeln!(out);
796                let _ = writeln!(
797                    out,
798                    "Pruning:   {}",
799                    match self.context_manager.compression.pruning_strategy {
800                        PruningStrategy::SubgoalMig => "subgoal_mig",
801                        _ => "subgoal",
802                    }
803                );
804                let subgoal_count = self.compression.subgoal_registry.subgoals.len();
805                let _ = writeln!(out, "Subgoals:  {subgoal_count} tracked");
806                if let Some(active) = self.compression.subgoal_registry.active_subgoal() {
807                    let _ = writeln!(out, "Active:    \"{}\"", active.description);
808                } else {
809                    let _ = writeln!(out, "Active:    (none yet)");
810                }
811            }
812        }
813
814        let gc = &self.memory_state.graph_config;
815        if gc.enabled {
816            let _ = writeln!(out);
817            if gc.spreading_activation.enabled {
818                let _ = writeln!(
819                    out,
820                    "Graph recall: spreading activation (lambda={:.2}, hops={})",
821                    gc.spreading_activation.decay_lambda, gc.spreading_activation.max_hops,
822                );
823            } else {
824                let _ = writeln!(out, "Graph recall: BFS (hops={})", gc.max_hops,);
825            }
826        }
827
828        self.channel.send(out.trim_end()).await?;
829        Ok(())
830    }
831
832    pub(super) async fn handle_guardrail_command(&mut self) -> Result<(), error::AgentError> {
833        use std::fmt::Write;
834
835        let mut out = String::new();
836        if let Some(ref guardrail) = self.security.guardrail {
837            let stats = guardrail.stats();
838            let _ = writeln!(out, "Guardrail: enabled");
839            let _ = writeln!(out, "Action:    {:?}", guardrail.action());
840            let _ = writeln!(out, "Fail strategy: {:?}", guardrail.fail_strategy());
841            let _ = writeln!(out, "Timeout:   {}ms", guardrail.timeout_ms());
842            let _ = writeln!(
843                out,
844                "Tool scan: {}",
845                if guardrail.scan_tool_output() {
846                    "enabled"
847                } else {
848                    "disabled"
849                }
850            );
851            let _ = writeln!(out, "\nStats:");
852            let _ = writeln!(out, "  Total checks:  {}", stats.total_checks);
853            let _ = writeln!(out, "  Flagged:       {}", stats.flagged_count);
854            let _ = writeln!(out, "  Errors:        {}", stats.error_count);
855            let _ = writeln!(out, "  Avg latency:   {}ms", stats.avg_latency_ms());
856        } else {
857            out.push_str("Guardrail: disabled\n");
858            out.push_str(
859                "Enable with: --guardrail flag or [security.guardrail] enabled = true in config",
860            );
861        }
862
863        self.channel.send(out.trim_end()).await?;
864        Ok(())
865    }
866
867    pub(super) async fn handle_skills_family(
868        &mut self,
869        subcommand: &str,
870    ) -> Result<(), error::AgentError> {
871        match subcommand {
872            "" => self.handle_skills_command().await,
873            "confusability" => self.handle_skills_confusability_command().await,
874            other => {
875                self.channel
876                    .send(&format!(
877                        "Unknown /skills subcommand: '{other}'. Available: confusability"
878                    ))
879                    .await?;
880                Ok(())
881            }
882        }
883    }
884
885    pub(super) async fn handle_skills_command(&mut self) -> Result<(), error::AgentError> {
886        use std::collections::BTreeMap;
887        use std::fmt::Write;
888
889        let all_meta: Vec<zeph_skills::loader::SkillMeta> = self
890            .skill_state
891            .registry
892            .read()
893            .expect("registry read lock")
894            .all_meta()
895            .into_iter()
896            .cloned()
897            .collect();
898
899        let mut trust_map: std::collections::HashMap<String, String> =
900            std::collections::HashMap::new();
901        for meta in &all_meta {
902            if let Some(memory) = &self.memory_state.memory {
903                let info = memory
904                    .sqlite()
905                    .load_skill_trust(&meta.name)
906                    .await
907                    .ok()
908                    .flatten()
909                    .map_or_else(String::new, |r| format!(" [{}]", r.trust_level));
910                trust_map.insert(meta.name.clone(), info);
911            }
912        }
913
914        let mut output = String::from("Available skills:\n\n");
915
916        let has_categories = all_meta.iter().any(|m| m.category.is_some());
917        if has_categories {
918            let mut by_category: BTreeMap<&str, Vec<&zeph_skills::loader::SkillMeta>> =
919                BTreeMap::new();
920            for meta in &all_meta {
921                let cat = meta.category.as_deref().unwrap_or("other");
922                by_category.entry(cat).or_default().push(meta);
923            }
924            for (cat, skills) in &by_category {
925                let _ = writeln!(output, "[{cat}]");
926                for meta in skills {
927                    let trust_info = trust_map.get(&meta.name).map_or("", String::as_str);
928                    let _ = writeln!(output, "- {} — {}{trust_info}", meta.name, meta.description);
929                }
930                output.push('\n');
931            }
932        } else {
933            for meta in &all_meta {
934                let trust_info = trust_map.get(&meta.name).map_or("", String::as_str);
935                let _ = writeln!(output, "- {} — {}{trust_info}", meta.name, meta.description);
936            }
937        }
938
939        if let Some(memory) = &self.memory_state.memory {
940            match memory.sqlite().load_skill_usage().await {
941                Ok(usage) if !usage.is_empty() => {
942                    output.push_str("\nUsage statistics:\n\n");
943                    for row in &usage {
944                        let _ = writeln!(
945                            output,
946                            "- {}: {} invocations (last: {})",
947                            row.skill_name, row.invocation_count, row.last_used_at,
948                        );
949                    }
950                }
951                Ok(_) => {}
952                Err(e) => tracing::warn!("failed to load skill usage: {e:#}"),
953            }
954        }
955
956        self.channel.send(&output).await?;
957        Ok(())
958    }
959
960    pub(super) async fn handle_skills_confusability_command(
961        &mut self,
962    ) -> Result<(), error::AgentError> {
963        let threshold = self.skill_state.confusability_threshold;
964        if threshold <= 0.0 {
965            self.channel
966                .send(
967                    "Confusability monitoring is disabled. \
968                     Set [skills] confusability_threshold in config (e.g. 0.85) to enable.",
969                )
970                .await?;
971            return Ok(());
972        }
973
974        let Some(matcher) = &self.skill_state.matcher else {
975            self.channel
976                .send("Skill matcher not available (no embedding provider configured).")
977                .await?;
978            return Ok(());
979        };
980
981        let all_meta: Vec<zeph_skills::loader::SkillMeta> = self
982            .skill_state
983            .registry
984            .read()
985            .expect("registry read lock")
986            .all_meta()
987            .into_iter()
988            .cloned()
989            .collect();
990        let refs: Vec<&zeph_skills::loader::SkillMeta> = all_meta.iter().collect();
991
992        let report = matcher.confusability_report(&refs, threshold).await;
993        self.channel.send(&report.to_string()).await?;
994        Ok(())
995    }
996}