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//! Slash command helpers for `Agent<C>`.
5//!
6//! The `COMMANDS` constant has moved to `zeph-commands::commands`. This module hosts the
7//! [`zeph_commands::SessionControlAccess`] implementation for `Agent<C>` (`/recap`, `/compact`,
8//! `/new`, `/status`, `/guardrail`, `/focus`, `/sidequest`, `/image`, `/undo`, `/redo`, `/conv`)
9//! plus its private helpers (`/conv resume`/`fork`/`list`/`show`, status-string formatting) and
10//! the session/agent command registry builders used by `Agent::run`.
11
12use std::future::Future;
13use std::pin::Pin;
14
15use tracing::Instrument as _;
16use zeph_commands::{CommandError, SessionControlAccess};
17use zeph_llm::provider::LlmProvider;
18
19use super::Agent;
20use super::error;
21use crate::channel::Channel;
22
23/// Returns a formatted overlay summary string for slash/TUI display.
24///
25/// Resolves the active plugin overlay against a scratch `Config::default()`.
26/// Source and skipped plugin lists are accurate; merged config values (e.g.,
27/// `allowed_commands`) are not shown because they depend on the live config base.
28pub(crate) fn format_overlay_section(plugins_dir: &std::path::Path) -> String {
29    let mut cfg = zeph_config::Config::default();
30    match zeph_plugins::apply_plugin_config_overlays(&mut cfg, plugins_dir) {
31        Err(e) => format!("overlay resolution failed: {e}"),
32        Ok(overlay) => {
33            if overlay.source_plugins.is_empty() && overlay.skipped_plugins.is_empty() {
34                return "No plugin overlay active.".to_owned();
35            }
36            let mut out = String::from("Active plugin overlay:\n");
37            if overlay.source_plugins.is_empty() {
38                out.push_str("  Source plugins:  (none)\n");
39            } else {
40                out.push_str("  Source plugins:  ");
41                out.push_str(&overlay.source_plugins.join(", "));
42                out.push('\n');
43            }
44            if overlay.skipped_plugins.is_empty() {
45                out.push_str("  Skipped plugins: (none)\n");
46            } else {
47                out.push_str("  Skipped plugins:\n");
48                for reason in &overlay.skipped_plugins {
49                    out.push_str("    - ");
50                    out.push_str(reason);
51                    out.push('\n');
52                }
53            }
54            out.push_str(
55                "  Note: overlay values shown against default config — run with --config for live intersection.",
56            );
57            out
58        }
59    }
60}
61
62impl<C: crate::channel::Channel> Agent<C> {
63    /// Handle built-in slash commands that short-circuit the main `run` loop.
64    ///
65    /// Returns `Some(true)` to break the loop (exit), `Some(false)` to continue to the next
66    /// iteration, or `None` if the command was not recognized (caller should call
67    /// `process_user_message`).
68    ///
69    /// Most commands are now handled through the session-registry or agent-registry. This
70    /// method is kept for commands that could not be migrated due to non-Sync type constraints.
71    #[allow(clippy::unused_self)]
72    pub(super) fn handle_builtin_command(&self, _trimmed: &str) -> Option<bool> {
73        None
74    }
75
76    /// Dispatch slash commands that cannot be handled by the registry.
77    ///
78    /// Currently handles only `@mention` dispatch. All `/` slash commands are now
79    /// dispatched through the session or agent command registry in `Agent::run`.
80    ///
81    /// Returns `Some(Ok(()))` when handled, `Some(Err(_))` on I/O error, `None` to
82    /// fall through to LLM processing.
83    pub(super) async fn dispatch_slash_command(
84        &mut self,
85        trimmed: &str,
86    ) -> Option<Result<(), error::AgentError>> {
87        // @mention dispatch: not a `/` command, so not in the registry.
88        if trimmed.starts_with('@') {
89            return self.dispatch_agent_command(trimmed).await;
90        }
91
92        // `/subagent spawn <cmd>` — ACP external process spawn (#3302).
93        if trimmed.eq_ignore_ascii_case("/subagent")
94            || trimmed.to_ascii_lowercase().starts_with("/subagent ")
95        {
96            let args = trimmed.get("/subagent".len()..).unwrap_or("").trim();
97            return Some(self.handle_subagent_slash(args).await);
98        }
99
100        None
101    }
102
103    /// Handle `/subagent [spawn <cmd>]` and return a user-visible result.
104    ///
105    /// Routes `/subagent spawn <cmd>` through the ACP spawn callback when available.
106    /// Returns a usage hint when no sub-command or command string is given, and a
107    /// "not available" message when the ACP spawn callback has not been injected.
108    ///
109    /// This path launches an external ACP subagent process (`zeph_acp::run_session` via
110    /// `spawn_fn`, wired in `src/runner.rs`) and never touches `SubAgentManager` or
111    /// `SpawnContext` — the `delegation_mode` gate inside `SubAgentManager::spawn` does not
112    /// see it at all. Spec 042 FR-003 requires `disabled` mode to reject *every* spawn path,
113    /// so the effective-mode check below is an explicit, separate gate at this choke point
114    /// (issue #5857). Uses `DelegationMode::permits_explicit()` — the same allow-list predicate
115    /// `SubAgentManager::resume` uses — rather than a hand-written `== Disabled` deny-list, so
116    /// the two enforcement points cannot drift apart and neither fails open on a future
117    /// `#[non_exhaustive]` variant. `/subagent spawn` is itself an explicit user action, so it
118    /// stays permitted under `explicit_request_only` and `proactive`, blocked only when
119    /// `permits_explicit()` is `false` (currently just `disabled`).
120    async fn handle_subagent_slash(&mut self, args: &str) -> Result<(), error::AgentError> {
121        let msg: String = if args.is_empty() {
122            "Usage: /subagent <subcommand>\n\nSubcommands:\n  spawn <command>  Spawn an ACP sub-agent process".to_owned()
123        } else {
124            let (subcmd, rest) = args.split_once(' ').unwrap_or((args, ""));
125            match subcmd {
126                "spawn" => {
127                    let cmd = rest.trim();
128                    let effective_mode = self.effective_delegation_mode();
129                    if cmd.is_empty() {
130                        "Usage: /subagent spawn <command>\n\nExample: /subagent spawn zeph --acp"
131                            .to_owned()
132                    } else if !effective_mode.permits_explicit() {
133                        tracing::warn!(
134                            mode = ?effective_mode,
135                            "/subagent spawn rejected: delegation disabled by configuration"
136                        );
137                        "Sub-agent delegation is disabled by configuration \
138                         ([agents].delegation_mode = \"disabled\" or [agents].enabled = false)."
139                            .to_owned()
140                    } else if let Some(spawn_fn) = self.runtime.config.acp_subagent_spawn_fn.clone()
141                    {
142                        let cmd = cmd.to_owned();
143                        match spawn_fn(cmd).await {
144                            Ok(output) => output,
145                            Err(e) => format!("Sub-agent error: {e}"),
146                        }
147                    } else {
148                        "ACP sub-agent spawning is not available in this mode.\n\
149                         Use `zeph acp run-agent --command <CMD> --prompt <TEXT>` for one-shot sessions."
150                            .to_owned()
151                    }
152                }
153                other => format!("Unknown /subagent subcommand: '{other}'. Available: spawn"),
154            }
155        };
156
157        let _ = self.channel.send(&msg).await;
158        let _ = self.channel.flush_chunks().await;
159        Ok(())
160    }
161
162    pub(super) async fn dispatch_agent_command(
163        &mut self,
164        trimmed: &str,
165    ) -> Option<Result<(), error::AgentError>> {
166        let known: Vec<String> = self
167            .services
168            .orchestration
169            .subagent_manager
170            .as_ref()
171            .map(|m| m.definitions().iter().map(|d| d.name.clone()).collect())
172            .unwrap_or_default();
173        match zeph_subagent::AgentCommand::parse(trimmed, &known) {
174            Ok(cmd) => {
175                if let Some(msg) = self.handle_agent_command(cmd).await
176                    && let Err(e) = self.channel.send(&msg).await
177                {
178                    return Some(Err(e.into()));
179                }
180                let _ = self.channel.flush_chunks().await;
181                Some(Ok(()))
182            }
183            Err(e) if trimmed.starts_with('@') => {
184                tracing::debug!("@mention not matched as agent: {e}");
185                None
186            }
187            Err(e) => {
188                if let Err(send_err) = self.channel.send(&e.to_string()).await {
189                    return Some(Err(send_err.into()));
190                }
191                let _ = self.channel.flush_chunks().await;
192                Some(Ok(()))
193            }
194        }
195    }
196
197    /// Return formatted session status string for use via [`SessionControlAccess::session_status`].
198    pub(super) fn handle_status_as_string(&mut self) -> String {
199        use std::fmt::Write;
200        use zeph_llm::provider::Role;
201
202        let uptime = self.runtime.lifecycle.start_time.elapsed().as_secs();
203        let msg_count = self
204            .msg
205            .messages
206            .iter()
207            .filter(|m| m.role == Role::User)
208            .count();
209
210        let metrics = collect_status_metrics(self.runtime.metrics.metrics_tx.as_ref());
211        let skill_count = self.services.skill.registry.read().all_meta().len();
212
213        let mut out = String::from("Session status:\n\n");
214        let _ = writeln!(out, "Provider:  {}", self.provider.name());
215        let _ = writeln!(out, "Model:     {}", self.runtime.config.model_name);
216        let _ = writeln!(out, "Uptime:    {uptime}s");
217        let _ = writeln!(out, "Turns:     {msg_count}");
218        let _ = writeln!(out, "API calls: {}", metrics.api_calls);
219        if metrics.reasoning_tokens > 0 {
220            let _ = writeln!(
221                out,
222                "Tokens:    {} prompt / {} completion ({} reasoning, subset of completion)",
223                metrics.prompt_tokens, metrics.completion_tokens, metrics.reasoning_tokens
224            );
225        } else {
226            let _ = writeln!(
227                out,
228                "Tokens:    {} prompt / {} completion",
229                metrics.prompt_tokens, metrics.completion_tokens
230            );
231        }
232        let _ = writeln!(out, "Skills:    {skill_count}");
233        let _ = writeln!(out, "MCP:       {} server(s)", metrics.mcp_servers);
234        if let Some(ref tf) = self.services.tool_state.tool_schema_filter {
235            let _ = writeln!(
236                out,
237                "Filter:    enabled (top_k={}, always_on={}, {} embeddings)",
238                tf.top_k(),
239                tf.always_on_count(),
240                tf.embedding_count(),
241            );
242        }
243        if let Some(ref adv) = self.runtime.config.adversarial_policy_info {
244            let provider_display = if adv.provider.is_empty() {
245                "default"
246            } else {
247                adv.provider.as_str()
248            };
249            let _ = writeln!(
250                out,
251                "Adv gate:  enabled (provider={}, policies={}, fail_open={}, timeout_ms={})",
252                provider_display, adv.policy_count, adv.fail_open, adv.timeout_ms
253            );
254        }
255        append_cost_section(&mut out, metrics.cost_cents, &metrics.provider_breakdown);
256        append_orchestration_section(
257            &mut out,
258            metrics.orch_plans,
259            metrics.orch_tasks,
260            metrics.orch_completed,
261            metrics.orch_failed,
262            metrics.orch_skipped,
263        );
264        append_ensemble_section(
265            &mut out,
266            metrics.ensemble_degraded,
267            metrics.ensemble_agreement_ratio,
268            &metrics.ensemble_member_stats,
269        );
270        append_pruning_section(
271            &mut out,
272            self.context_manager.compression.pruning_strategy,
273            self.services.compression.subgoal_registry.subgoals.len(),
274            self.services.compression.subgoal_registry.active_subgoal(),
275        );
276        append_graph_recall_section(&mut out, &self.services.memory.extraction.graph_config);
277
278        out.trim_end().to_owned()
279    }
280
281    /// Return formatted guardrail status string for use via [`SessionControlAccess::guardrail_status`].
282    pub(super) fn format_guardrail_status(&self) -> String {
283        use std::fmt::Write;
284
285        let mut out = String::new();
286        if let Some(ref guardrail) = self.services.security.guardrail {
287            let stats = guardrail.stats();
288            let _ = writeln!(out, "Guardrail: enabled");
289            let _ = writeln!(out, "Action:    {:?}", guardrail.action());
290            let _ = writeln!(out, "Fail strategy: {:?}", guardrail.fail_strategy());
291            let _ = writeln!(out, "Timeout:   {}ms", guardrail.timeout_ms());
292            let _ = writeln!(
293                out,
294                "Tool scan: {}",
295                if guardrail.scan_tool_output() {
296                    "enabled"
297                } else {
298                    "disabled"
299                }
300            );
301            let _ = writeln!(out, "\nStats:");
302            let _ = writeln!(out, "  Total checks:  {}", stats.total_checks);
303            let _ = writeln!(out, "  Flagged:       {}", stats.flagged_count);
304            let _ = writeln!(out, "  Errors:        {}", stats.error_count);
305            let _ = writeln!(out, "  Avg latency:   {}ms", stats.avg_latency_ms());
306        } else {
307            out.push_str("Guardrail: disabled\n");
308            out.push_str(
309                "Enable with: --guardrail flag or [security.guardrail] enabled = true in config",
310            );
311        }
312        out.trim_end().to_owned()
313    }
314
315    /// Return formatted Focus Agent status string for use via [`SessionControlAccess::focus_status`].
316    pub(super) fn format_focus_status(&self) -> String {
317        use std::fmt::Write;
318        let mut out = String::from("Focus Agent status\n\n");
319        let _ = writeln!(
320            out,
321            "Enabled:          {}",
322            self.services.focus.config.enabled
323        );
324        let _ = writeln!(out, "Active session:   {}", self.services.focus.is_active());
325        if let Some(ref scope) = self.services.focus.active_scope {
326            let _ = writeln!(out, "Active scope:     {scope}");
327        }
328        let _ = writeln!(
329            out,
330            "Knowledge blocks: {}",
331            self.services.focus.knowledge_blocks.len()
332        );
333        let _ = writeln!(
334            out,
335            "Turns since focus: {}",
336            self.services.focus.turns_since_focus
337        );
338        out.trim_end().to_owned()
339    }
340
341    /// Return formatted `SideQuest` eviction status string for use via
342    /// [`SessionControlAccess::sidequest_status`].
343    pub(super) fn format_sidequest_status(&self) -> String {
344        use std::fmt::Write;
345        let mut out = String::from("SideQuest status\n\n");
346        let _ = writeln!(
347            out,
348            "Enabled:        {}",
349            self.services.sidequest.config.enabled
350        );
351        let _ = writeln!(
352            out,
353            "Interval turns: {}",
354            self.services.sidequest.config.interval_turns
355        );
356        let _ = writeln!(
357            out,
358            "Turn counter:   {}",
359            self.services.sidequest.turn_counter
360        );
361        let _ = writeln!(
362            out,
363            "Passes run:     {}",
364            self.services.sidequest.passes_run
365        );
366        let _ = writeln!(
367            out,
368            "Total evicted:  {} tool outputs",
369            self.services.sidequest.total_evicted
370        );
371        out.trim_end().to_owned()
372    }
373
374    /// Load an image and return a status string for use via [`SessionControlAccess::load_image`].
375    #[cfg_attr(not(test), allow(dead_code))]
376    pub(super) fn handle_image_as_string(&mut self, path: &str) -> String {
377        use zeph_common::path_guard::{PathRejection, classify_relative_path};
378        use zeph_llm::provider::{ImageData, MessagePart};
379
380        match classify_relative_path(path) {
381            PathRejection::Allowed => {}
382            PathRejection::Absolute => {
383                return "Invalid image path: absolute paths are not supported, use a path \
384                    relative to the working directory"
385                    .to_owned();
386            }
387            PathRejection::Traversal => {
388                return "Invalid image path: path traversal ('..') is not allowed".to_owned();
389            }
390        }
391
392        let data = match std::fs::read(path) {
393            Ok(d) => d,
394            Err(e) => return format!("Cannot read image {path}: {e}"),
395        };
396        if data.len() > super::message_queue::MAX_IMAGE_BYTES {
397            return format!(
398                "Image {path} exceeds size limit ({} MB), skipping",
399                super::message_queue::MAX_IMAGE_BYTES / 1024 / 1024
400            );
401        }
402        let mime_type = super::message_queue::detect_image_mime(Some(path)).to_string();
403        self.msg
404            .pending_image_parts
405            .push(MessagePart::Image(Box::new(ImageData { data, mime_type })));
406        format!("Image loaded: {path}. Send your message.")
407    }
408
409    /// Return the `/skills [subcommand]` output as a `String` without sending via channel.
410    ///
411    /// Execute a `/plugins` command given pre-cloned state, suitable for use inside
412    /// `tokio::task::spawn_blocking` without borrowing `&self`.
413    #[allow(clippy::needless_pass_by_value)]
414    pub(super) fn run_plugin_command(
415        args: &str,
416        managed_dir: Option<std::path::PathBuf>,
417        mcp_allowed: Vec<String>,
418        base_shell_allowed: Vec<String>,
419        ephemeral_plugin_names: Vec<String>,
420        reputation_cfg: &zeph_config::plugins::ReputationConfig,
421    ) -> String {
422        // Use the canonical default so CLI and TUI always reference the same directory.
423        let plugins_dir = zeph_plugins::PluginManager::default_plugins_dir();
424
425        let (subcmd, rest) = args.trim().split_once(' ').unwrap_or((args.trim(), ""));
426
427        // Overlay subcommand does not need PluginManager; resolve early to avoid moving plugins_dir.
428        if subcmd == "overlay" || (matches!(subcmd, "" | "list") && rest.trim() == "--overlay") {
429            return format_overlay_section(&plugins_dir);
430        }
431
432        // Fall back to the canonical default managed skills dir so the conflict check is
433        // never silently disabled by an empty path (M5 fix).
434        let managed_dir = managed_dir
435            .unwrap_or_else(|| zeph_config::defaults::default_vault_dir().join("skills"));
436        let mgr = zeph_plugins::PluginManager::new(
437            plugins_dir,
438            managed_dir,
439            mcp_allowed,
440            base_shell_allowed,
441        )
442        .with_reputation_config(reputation_cfg, false);
443
444        match subcmd {
445            "" | "list" => match mgr.list_installed() {
446                Ok(plugins) if plugins.is_empty() && ephemeral_plugin_names.is_empty() => {
447                    "No plugins installed.".to_owned()
448                }
449                Ok(plugins) => {
450                    let mut lines: Vec<String> = plugins
451                        .iter()
452                        .map(|p| format!("{} v{} — {}", p.name, p.version, p.description))
453                        .collect();
454                    for name in &ephemeral_plugin_names {
455                        lines.push(format!("{name} [ephemeral]"));
456                    }
457                    lines.join("\n")
458                }
459                Err(e) => format!("plugin list failed: {e}"),
460            },
461            "add" => {
462                use std::fmt::Write as _;
463                if rest.is_empty() {
464                    return "Usage: /plugins add <source>".to_owned();
465                }
466                match mgr.add(rest.trim()) {
467                    Ok(r) => {
468                        let mut out = format!("Installed plugin \"{}\"", r.name);
469                        if !r.installed_skills.is_empty() {
470                            let _ = write!(out, "\n  Skills: {}", r.installed_skills.join(", "));
471                        }
472                        if !r.mcp_server_ids.is_empty() {
473                            let _ = write!(
474                                out,
475                                "\n  MCP servers (restart required): {}",
476                                r.mcp_server_ids.join(", ")
477                            );
478                        }
479                        for w in &r.warnings {
480                            let _ = write!(out, "\n  warning: {w}");
481                        }
482                        out
483                    }
484                    Err(e) => format!("plugin add failed: {e}"),
485                }
486            }
487            "remove" => {
488                use std::fmt::Write as _;
489                if rest.is_empty() {
490                    return "Usage: /plugins remove <name>".to_owned();
491                }
492                match mgr.remove(rest.trim()) {
493                    Ok(r) => {
494                        let mut out = format!("Removed plugin \"{}\"", rest.trim());
495                        if !r.removed_skills.is_empty() {
496                            let _ =
497                                write!(out, "\n  Removed skills: {}", r.removed_skills.join(", "));
498                        }
499                        out
500                    }
501                    Err(e) => format!("plugin remove failed: {e}"),
502                }
503            }
504            other => {
505                format!(
506                    "Unknown /plugins subcommand: '{other}'. Available: list, list --overlay, overlay, add, remove"
507                )
508            }
509        }
510    }
511
512    #[tracing::instrument(skip_all, name = "core.agent.handle_skills")]
513    pub(super) async fn handle_skills_as_string(
514        &mut self,
515        subcommand: &str,
516    ) -> Result<String, error::AgentError> {
517        match subcommand {
518            "" => self.handle_skills_command_as_string().await,
519            "confusability" => self.handle_skills_confusability_as_string().await,
520            "injection" => self.handle_skills_injection_as_string(),
521            "trust" => self.handle_skills_trust_as_string(),
522            other => Ok(format!(
523                "Unknown /skills subcommand: '{other}'. Available: confusability, injection, trust"
524            )),
525        }
526    }
527
528    #[tracing::instrument(skip_all, name = "core.agent.handle_skills_command")]
529    async fn handle_skills_command_as_string(&mut self) -> Result<String, error::AgentError> {
530        use std::collections::BTreeMap;
531        use std::fmt::Write;
532
533        let (all_meta, load_errors): (
534            Vec<zeph_skills::loader::SkillMeta>,
535            Vec<(std::path::PathBuf, String)>,
536        ) = {
537            let reg = self.services.skill.registry.read();
538            (
539                reg.all_meta().into_iter().cloned().collect(),
540                reg.load_errors().to_vec(),
541            )
542        };
543
544        // Clone Arc before .await to avoid holding &self across suspension points.
545        let memory = self.services.memory.persistence.memory.clone();
546        let mut trust_map: std::collections::HashMap<String, String> =
547            std::collections::HashMap::new();
548        for meta in &all_meta {
549            if let Some(ref memory) = memory {
550                let info = memory
551                    .sqlite()
552                    .load_skill_trust(&meta.name)
553                    .await
554                    .ok()
555                    .flatten()
556                    .map_or_else(String::new, |r| format!(" [{}]", r.trust_level));
557                trust_map.insert(meta.name.clone(), info);
558            }
559        }
560
561        let mut output = String::from("Available skills:\n\n");
562
563        let has_categories = all_meta.iter().any(|m| m.category.is_some());
564        if has_categories {
565            let mut by_category: BTreeMap<&str, Vec<&zeph_skills::loader::SkillMeta>> =
566                BTreeMap::new();
567            for meta in &all_meta {
568                let cat = meta.category.as_deref().unwrap_or("other");
569                by_category.entry(cat).or_default().push(meta);
570            }
571            for (cat, skills) in &by_category {
572                let _ = writeln!(output, "[{cat}]");
573                for meta in skills {
574                    let trust_info = trust_map.get(&meta.name).map_or("", String::as_str);
575                    let _ = writeln!(output, "- {} — {}{trust_info}", meta.name, meta.description);
576                }
577                output.push('\n');
578            }
579        } else {
580            for meta in &all_meta {
581                let trust_info = trust_map.get(&meta.name).map_or("", String::as_str);
582                let _ = writeln!(output, "- {} — {}{trust_info}", meta.name, meta.description);
583            }
584        }
585
586        if let Some(ref memory) = memory {
587            match memory.sqlite().load_skill_usage().await {
588                Ok(usage) if !usage.is_empty() => {
589                    output.push_str("\nUsage statistics:\n\n");
590                    for row in &usage {
591                        let _ = writeln!(
592                            output,
593                            "- {}: {} invocations (last: {})",
594                            row.skill_name, row.invocation_count, row.last_used_at,
595                        );
596                    }
597                }
598                Ok(_) => {}
599                Err(e) => tracing::warn!("failed to load skill usage: {e:#}"),
600            }
601        }
602
603        if !load_errors.is_empty() {
604            output.push_str("\nFailed to load:\n");
605            for (path, reason) in &load_errors {
606                let _ = writeln!(output, "- {}: {reason}", path.display());
607            }
608        }
609
610        Ok(output)
611    }
612
613    /// Start a user-driven loop that injects `prompt` every `interval_secs` seconds.
614    pub(crate) fn start_user_loop(&mut self, prompt: String, interval_secs: u64) {
615        use std::time::Duration;
616        use tokio::time::{Instant, MissedTickBehavior};
617
618        let period = Duration::from_secs(interval_secs);
619        // interval_at(now + period, period) ensures the first tick fires after one full period,
620        // not immediately. tokio::time::interval() would fire at t=0 which is never desired.
621        let mut interval = tokio::time::interval_at(Instant::now() + period, period);
622        interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
623
624        let cancel_tx = tokio_util::sync::CancellationToken::new();
625        self.runtime.lifecycle.user_loop = Some(crate::agent::state::LoopState {
626            prompt,
627            iteration: 0,
628            interval,
629            cancel_tx,
630        });
631    }
632
633    /// Stop the active user loop and return a user-visible message.
634    pub(crate) fn stop_user_loop(&mut self) -> String {
635        if let Some(ls) = self.runtime.lifecycle.user_loop.take() {
636            let iters = ls.iteration;
637            ls.cancel_tx.cancel();
638            format!("Loop stopped after {iters} iteration(s).")
639        } else {
640            "No active loop.".to_owned()
641        }
642    }
643
644    #[tracing::instrument(skip_all, name = "core.agent.handle_skills_confusability")]
645    async fn handle_skills_confusability_as_string(&mut self) -> Result<String, error::AgentError> {
646        let threshold = self.services.skill.confusability_threshold;
647        if threshold <= 0.0 {
648            return Ok("Confusability monitoring is disabled. \
649                 Set [skills] confusability_threshold in config (e.g. 0.85) to enable."
650                .to_owned());
651        }
652
653        let Some(matcher) = &self.services.skill.matcher else {
654            return Ok(
655                "Skill matcher not available (no embedding provider configured).".to_owned(),
656            );
657        };
658
659        let all_meta: Vec<zeph_skills::loader::SkillMeta> = self
660            .services
661            .skill
662            .registry
663            .read()
664            .all_meta()
665            .into_iter()
666            .cloned()
667            .collect();
668        let refs: Vec<&zeph_skills::loader::SkillMeta> = all_meta.iter().collect();
669
670        let report = matcher.confusability_report(&refs, threshold).await;
671        Ok(report.to_string())
672    }
673
674    /// Report the current `GoSkills` grouping / injection-score config, as applied to this
675    /// `Agent` instance. Exists so tests outside `zeph-core` can observe that
676    /// `group_structured`, `support_similarity_threshold`, and `min_injection_score` reached
677    /// the constructed `Agent` at cold start, mirroring `handle_skills_confusability_as_string`.
678    #[tracing::instrument(skip_all, name = "core.agent.handle_skills_injection")]
679    fn handle_skills_injection_as_string(&self) -> Result<String, error::AgentError> {
680        Ok(format!(
681            "Skill injection config: group_structured={}, support_similarity_threshold={:.2}, min_injection_score={:.2}",
682            self.services.skill.group_structured,
683            self.services.skill.support_similarity_threshold,
684            self.services.skill.min_injection_score,
685        ))
686    }
687
688    /// Report the current skill-trust levels and `SkillOrchestra` RL routing state, as applied
689    /// to this `Agent` instance. Exists so tests outside `zeph-core` can observe that
690    /// `with_trust_config` and `with_rl_routing`/`with_rl_head` reached the constructed `Agent`
691    /// at cold start (#5920/#5921), mirroring `handle_skills_injection_as_string` (#5867) for
692    /// the same wire-X-into-ACP/serve/daemon defect class.
693    #[tracing::instrument(skip_all, name = "core.agent.handle_skills_trust")]
694    fn handle_skills_trust_as_string(&self) -> Result<String, error::AgentError> {
695        let trust = &self.services.skill.trust_config;
696        let rl_enabled = self
697            .services
698            .learning_engine
699            .rl_routing
700            .as_ref()
701            .is_some_and(|r| r.enabled);
702        Ok(format!(
703            "Skill trust config: default_level={:?}, local_level={:?}, bundled_level={:?}, \
704             hash_mismatch_level={:?} | RL routing: enabled={}, rl_head_loaded={}",
705            trust.default_level,
706            trust.local_level,
707            trust.bundled_level,
708            trust.hash_mismatch_level,
709            rl_enabled,
710            self.services.skill.rl_head.is_some(),
711        ))
712    }
713}
714
715/// Builds the phase-1 (session/debug) command registry used by [`Agent::run`]: handlers
716/// that only need `ChannelSink`/`DebugAccess`/`MessageAccess`/`SessionAccess`, not
717/// `&mut Agent<C>` itself.
718///
719/// Extracted into its own function (rather than inlined in `run`) so the exact set of
720/// registered handlers can be inspected by tests without duplicating the `.register()`
721/// call list — see `commands_rs_drift_tests` for the regression guard this enables
722/// against `zeph_commands::COMMANDS` silently drifting from the real registrations (#5987).
723pub(crate) fn build_session_debug_registry<'ctx>()
724-> zeph_commands::CommandRegistry<zeph_commands::CommandContext<'ctx>> {
725    use zeph_commands::CommandRegistry;
726    use zeph_commands::handlers::debug::{DebugDumpCommand, DumpFormatCommand, LogCommand};
727    use zeph_commands::handlers::help::HelpCommand;
728    use zeph_commands::handlers::session::{
729        ClearCommand, ClearQueueCommand, ExitCommand, HistoryCommand, QuitCommand, ResetCommand,
730    };
731
732    let mut reg = CommandRegistry::new();
733    reg.register(ExitCommand);
734    reg.register(QuitCommand);
735    reg.register(ClearCommand);
736    reg.register(ResetCommand);
737    reg.register(ClearQueueCommand);
738    reg.register(HistoryCommand);
739    reg.register(LogCommand);
740    reg.register(DebugDumpCommand);
741    reg.register(DumpFormatCommand);
742    reg.register(HelpCommand);
743    #[cfg(test)]
744    reg.register(super::test_stubs::TestErrorCommand);
745    reg
746}
747
748/// Builds the phase-2 (agent-command) registry used by [`Agent::run`]: handlers that
749/// need `&mut Agent<C>` directly.
750///
751/// See [`build_session_debug_registry`] for why this is extracted.
752pub(crate) fn build_agent_command_registry<'ctx>()
753-> zeph_commands::CommandRegistry<zeph_commands::CommandContext<'ctx>> {
754    use zeph_commands::CommandRegistry;
755    use zeph_commands::handlers::{
756        acp::AcpCommand,
757        agent_cmd::AgentCommand,
758        agents_fleet::AgentsFleetCommand,
759        caveman::CavemanCommand,
760        cd::CdCommand,
761        checkpoint::{RedoCommand, UndoCommand},
762        compaction::{CompactCommand, NewConversationCommand, RecapCommand},
763        conv::ConvCommand,
764        experiment::ExperimentCommand,
765        goal::GoalCommand,
766        loop_cmd::LoopCommand,
767        lsp::LspCommand,
768        mcp::McpCommand,
769        memory::{
770            GraphCommand, GuidelinesCommand, KnowledgeSlashCommand, MemoryCommand,
771            StoreSlashCommand,
772        },
773        misc::{CacheStatsCommand, ImageCommand, NotifyTestCommand},
774        model::{ModelCommand, ProviderCommand},
775        plan::PlanCommand,
776        plugins::PluginsCommand,
777        policy::PolicyCommand,
778        reasoning_effort::ReasoningEffortCommand,
779        scheduler::SchedulerCommand,
780        search::SearchCommand,
781        skill::{FeedbackCommand, SkillCommand, SkillsCommand},
782        status::{FocusCommand, GuardrailCommand, SideQuestCommand, StatusCommand},
783        think_tokens::ThinkTokensCommand,
784        trajectory::{ScopeCommand, TrajectoryCommand},
785        worktree::WorktreeCommand,
786    };
787
788    let mut agent_reg = CommandRegistry::new();
789    agent_reg.register(CavemanCommand);
790    agent_reg.register(CdCommand);
791    agent_reg.register(MemoryCommand);
792    agent_reg.register(StoreSlashCommand);
793    agent_reg.register(GraphCommand);
794    agent_reg.register(KnowledgeSlashCommand);
795    agent_reg.register(GuidelinesCommand);
796    agent_reg.register(ModelCommand);
797    agent_reg.register(ProviderCommand);
798    agent_reg.register(ThinkTokensCommand);
799    agent_reg.register(ReasoningEffortCommand);
800    // Phase 6 migrations: /skill, /skills, /feedback use clone-before-await pattern.
801    agent_reg.register(SkillCommand);
802    agent_reg.register(SkillsCommand);
803    agent_reg.register(FeedbackCommand);
804    agent_reg.register(McpCommand);
805    agent_reg.register(PolicyCommand);
806    agent_reg.register(SchedulerCommand);
807    agent_reg.register(SearchCommand);
808    agent_reg.register(LspCommand);
809    // Phase 4 migrations (Send-safe commands):
810    agent_reg.register(CacheStatsCommand);
811    agent_reg.register(ImageCommand);
812    agent_reg.register(NotifyTestCommand);
813    agent_reg.register(StatusCommand);
814    agent_reg.register(GuardrailCommand);
815    agent_reg.register(FocusCommand);
816    agent_reg.register(SideQuestCommand);
817    agent_reg.register(AgentCommand);
818    agent_reg.register(AgentsFleetCommand);
819    // Phase 5 migrations (Send-compatible):
820    agent_reg.register(CompactCommand);
821    agent_reg.register(NewConversationCommand);
822    agent_reg.register(RecapCommand);
823    agent_reg.register(ExperimentCommand);
824    agent_reg.register(PlanCommand);
825    agent_reg.register(LoopCommand);
826    agent_reg.register(PluginsCommand);
827    agent_reg.register(AcpCommand);
828    #[cfg(feature = "cocoon")]
829    agent_reg.register(zeph_commands::handlers::cocoon::CocoonCommand);
830    agent_reg.register(TrajectoryCommand);
831    agent_reg.register(ScopeCommand);
832    agent_reg.register(GoalCommand);
833    agent_reg.register(UndoCommand);
834    agent_reg.register(RedoCommand);
835    agent_reg.register(ConvCommand);
836    agent_reg.register(WorktreeCommand);
837    agent_reg
838}
839
840struct StatusMetrics {
841    api_calls: u64,
842    prompt_tokens: u64,
843    completion_tokens: u64,
844    reasoning_tokens: u64,
845    cost_cents: f64,
846    mcp_servers: usize,
847    orch_plans: u64,
848    orch_tasks: u64,
849    orch_completed: u64,
850    orch_failed: u64,
851    orch_skipped: u64,
852    ensemble_degraded: u64,
853    ensemble_agreement_ratio: Option<f64>,
854    ensemble_member_stats: Vec<(String, f64, u64)>,
855    provider_breakdown: Vec<(String, crate::cost::ProviderUsage)>,
856}
857
858fn collect_status_metrics(
859    metrics_tx: Option<&tokio::sync::watch::Sender<crate::metrics::MetricsSnapshot>>,
860) -> StatusMetrics {
861    if let Some(tx) = metrics_tx {
862        let m = tx.borrow();
863        StatusMetrics {
864            api_calls: m.api_calls,
865            prompt_tokens: m.prompt_tokens,
866            completion_tokens: m.completion_tokens,
867            reasoning_tokens: m.reasoning_tokens,
868            cost_cents: m.cost_spent_cents,
869            mcp_servers: m.mcp_server_count,
870            orch_plans: m.orchestration.plans_total,
871            orch_tasks: m.orchestration.tasks_total,
872            orch_completed: m.orchestration.tasks_completed,
873            orch_failed: m.orchestration.tasks_failed,
874            orch_skipped: m.orchestration.tasks_skipped,
875            ensemble_degraded: m.orchestration.ensemble_degraded_total,
876            ensemble_agreement_ratio: m.orchestration.ensemble_last_agreement_ratio,
877            ensemble_member_stats: m.orchestration.ensemble_member_stats.clone(),
878            provider_breakdown: m.provider_cost_breakdown.clone(),
879        }
880    } else {
881        StatusMetrics {
882            api_calls: 0,
883            prompt_tokens: 0,
884            completion_tokens: 0,
885            reasoning_tokens: 0,
886            cost_cents: 0.0,
887            mcp_servers: 0,
888            orch_plans: 0,
889            orch_tasks: 0,
890            orch_completed: 0,
891            orch_failed: 0,
892            orch_skipped: 0,
893            ensemble_degraded: 0,
894            ensemble_agreement_ratio: None,
895            ensemble_member_stats: vec![],
896            provider_breakdown: vec![],
897        }
898    }
899}
900
901fn append_cost_section(
902    out: &mut String,
903    cost_cents: f64,
904    provider_breakdown: &[(String, crate::cost::ProviderUsage)],
905) {
906    use std::fmt::Write;
907    if cost_cents > 0.0 {
908        let _ = writeln!(out, "Cost:      ${:.4}", cost_cents / 100.0);
909        if !provider_breakdown.is_empty() {
910            let _ = writeln!(
911                out,
912                "  {:<16} {:>8} {:>8} {:>8}",
913                "Provider", "Requests", "Tokens", "Cost"
914            );
915            for (name, usage) in provider_breakdown {
916                let total_tokens = usage.input_tokens + usage.output_tokens;
917                let _ = writeln!(
918                    out,
919                    "  {:<16} {:>8} {:>8} {:>8}",
920                    name,
921                    usage.request_count,
922                    total_tokens,
923                    format!("${:.4}", usage.cost_cents / 100.0),
924                );
925            }
926        }
927    }
928}
929
930fn append_orchestration_section(
931    out: &mut String,
932    orch_plans: u64,
933    orch_tasks: u64,
934    orch_completed: u64,
935    orch_failed: u64,
936    orch_skipped: u64,
937) {
938    use std::fmt::Write;
939    if orch_plans > 0 {
940        let _ = writeln!(out);
941        let _ = writeln!(out, "Orchestration:");
942        let _ = writeln!(out, "  Plans:     {orch_plans}");
943        let _ = writeln!(out, "  Tasks:     {orch_completed}/{orch_tasks} completed");
944        if orch_failed > 0 {
945            let _ = writeln!(out, "  Failed:    {orch_failed}");
946        }
947        if orch_skipped > 0 {
948            let _ = writeln!(out, "  Skipped:   {orch_skipped}");
949        }
950    }
951}
952
953/// Append ensemble-verified plan verification stats (spec `073-orch-ensemble-merge`) to the
954/// `/status` output. Silent (no section printed) when the ensemble has never run — the
955/// member-stats list is empty and no member has ever cast a ballot.
956fn append_ensemble_section(
957    out: &mut String,
958    ensemble_degraded: u64,
959    ensemble_agreement_ratio: Option<f64>,
960    ensemble_member_stats: &[(String, f64, u64)],
961) {
962    use std::fmt::Write;
963    if ensemble_member_stats.is_empty() && ensemble_degraded == 0 {
964        return;
965    }
966    let _ = writeln!(out);
967    let _ = writeln!(out, "Ensemble verify:");
968    if let Some(ratio) = ensemble_agreement_ratio {
969        let _ = writeln!(out, "  Last agreement: {:.0}%", ratio * 100.0);
970    }
971    if ensemble_degraded > 0 {
972        let _ = writeln!(out, "  Degraded:  {ensemble_degraded} (quorum fallback)");
973    }
974    for (member, score, observations) in ensemble_member_stats {
975        let _ = writeln!(out, "  {member:<16} ema={score:.2} (n={observations})");
976    }
977}
978
979fn append_pruning_section(
980    out: &mut String,
981    pruning_strategy: crate::config::PruningStrategy,
982    subgoal_count: usize,
983    active_subgoal: Option<&zeph_agent_context::compaction::Subgoal>,
984) {
985    use crate::config::PruningStrategy;
986    use std::fmt::Write;
987    if matches!(
988        pruning_strategy,
989        PruningStrategy::Subgoal | PruningStrategy::SubgoalMig
990    ) {
991        let _ = writeln!(out);
992        let _ = writeln!(
993            out,
994            "Pruning:   {}",
995            match pruning_strategy {
996                PruningStrategy::SubgoalMig => "subgoal_mig",
997                _ => "subgoal",
998            }
999        );
1000        let _ = writeln!(out, "Subgoals:  {subgoal_count} tracked");
1001        if let Some(active) = active_subgoal {
1002            let _ = writeln!(out, "Active:    \"{}\"", active.description);
1003        } else {
1004            let _ = writeln!(out, "Active:    (none yet)");
1005        }
1006    }
1007}
1008
1009fn append_graph_recall_section(out: &mut String, gc: &zeph_config::memory::GraphConfig) {
1010    use std::fmt::Write;
1011    if gc.enabled {
1012        let _ = writeln!(out);
1013        if gc.spreading_activation.enabled {
1014            let _ = writeln!(
1015                out,
1016                "Graph recall: spreading activation (lambda={:.2}, hops={})",
1017                gc.spreading_activation.decay_lambda, gc.spreading_activation.max_hops,
1018            );
1019        } else {
1020            let _ = writeln!(out, "Graph recall: BFS (hops={})", gc.max_hops);
1021        }
1022    }
1023}
1024
1025impl<C: Channel> Agent<C> {
1026    /// `/conv resume <id>` (spec-068, #5343, D-9): mid-session live swap onto an existing
1027    /// durable session. Resolves `conversation_id` via the `SessionId`<->`ConversationId`
1028    /// bijection (spec §5.2) — reuses the session's existing linked conversation if one exists,
1029    /// otherwise mints one and links it (a session created via the HTTP API's `POST /sessions`,
1030    /// or a legacy session, may not have one yet).
1031    async fn handle_conv_resume(&mut self, id: &str) -> Result<String, CommandError> {
1032        if id.is_empty() {
1033            return Ok("Usage: /conv resume <id>".to_owned());
1034        }
1035        // #5487 fix 3: `load_and_resume_conversation` now opens the target session's event log
1036        // exclusively (INV-D2). Resuming into the session already live in this agent would try
1037        // to acquire a second exclusive lock on the same directory this agent's own
1038        // `SessionSink` already holds open, deadlocking on `AlreadyLocked` — short-circuit with a
1039        // clear message instead of attempting a self-conflicting reopen.
1040        if let Some(sink) = &self.services.session.session_sink
1041            && sink.session_id().as_str() == id
1042        {
1043            return Ok(format!("Already in session '{id}'."));
1044        }
1045        let Some(memory) = self.services.memory.persistence.memory.clone() else {
1046            return Ok(
1047                "Conversation-session persistence requires memory to be enabled ([memory] enabled = true)."
1048                    .to_owned(),
1049            );
1050        };
1051        let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1052        let Some(metadata) = store
1053            .get(id)
1054            .await
1055            .map_err(|e| CommandError::new(e.to_string()))?
1056        else {
1057            return Ok(format!("Session '{id}' not found."));
1058        };
1059
1060        let conversation_id = if let Some(cid) = metadata.conversation_id {
1061            zeph_memory::ConversationId(cid)
1062        } else {
1063            let cid = memory
1064                .sqlite()
1065                .create_conversation()
1066                .await
1067                .map_err(|e| CommandError::new(e.to_string()))?;
1068            store
1069                .link_conversation(id, cid.0)
1070                .await
1071                .map_err(|e| CommandError::new(e.to_string()))?;
1072            cid
1073        };
1074
1075        let session_id = zeph_common::SessionId::new(id);
1076        self.load_and_resume_conversation(&session_id, conversation_id)
1077            .await
1078            .map_err(|e| CommandError::new(e.to_string()))?;
1079
1080        Ok(format!(
1081            "Resumed session {id} ({} event(s) replayed).",
1082            metadata.event_count
1083        ))
1084    }
1085
1086    /// `/conv fork <id>` (spec-068, #5343, D-9): eager-copies `id`'s durable log into a fresh
1087    /// child session via `ForkEngine::fork` (P2), then immediately live-swaps onto the child —
1088    /// same effect as `POST /sessions/:id/fork` (spec §9.4) but for the current CLI/TUI session
1089    /// instead of spawning a new `SessionActor`.
1090    async fn handle_conv_fork(&mut self, id: &str) -> Result<String, CommandError> {
1091        if id.is_empty() {
1092            return Ok("Usage: /conv fork <id>".to_owned());
1093        }
1094        let Some(memory) = self.services.memory.persistence.memory.clone() else {
1095            return Ok(
1096                "Conversation-session persistence requires memory to be enabled ([memory] enabled = true)."
1097                    .to_owned(),
1098            );
1099        };
1100        let Some(session_persistence_config) =
1101            self.services.session.session_persistence_config.clone()
1102        else {
1103            return Ok(
1104                "Conversation-session persistence is not enabled ([session] enabled = true)."
1105                    .to_owned(),
1106            );
1107        };
1108        let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1109        let data_dir = std::path::PathBuf::from(&session_persistence_config.data_dir);
1110        let new_id = zeph_common::SessionId::generate();
1111
1112        let fork_result =
1113            zeph_session::ForkEngine::fork(&data_dir, id, new_id.as_str(), None, &store, None)
1114                .await
1115                .map_err(|e| CommandError::new(e.to_string()))?;
1116
1117        let conversation_id = memory
1118            .sqlite()
1119            .create_conversation()
1120            .await
1121            .map_err(|e| CommandError::new(e.to_string()))?;
1122
1123        self.load_and_resume_conversation(&new_id, conversation_id)
1124            .await
1125            .map_err(|e| CommandError::new(e.to_string()))?;
1126
1127        Ok(format!(
1128            "Forked session {id} -> {} ({} event(s) copied); now the active conversation.",
1129            fork_result.new_session_id, fork_result.events_copied
1130        ))
1131    }
1132}
1133
1134/// Formats `/conv list` — mirrors `sessions list`'s CLI table layout
1135/// (`src/commands/sessions.rs`) and `zeph serve-sessions`'s `GET /sessions`.
1136async fn handle_conv_list(store: &zeph_session::SessionStore) -> Result<String, CommandError> {
1137    use std::fmt::Write as _;
1138
1139    let sessions = store
1140        .list(&zeph_session::SessionFilter::default())
1141        .await
1142        .map_err(|e| CommandError::new(format!("failed to list sessions: {e}")))?;
1143
1144    if sessions.is_empty() {
1145        return Ok("No conversation-sessions found.".to_owned());
1146    }
1147
1148    let mut out = format!(
1149        "{:<38} {:<30} {:<9} {:>6} {:<24}\n",
1150        "ID", "TITLE", "STATUS", "EVENTS", "UPDATED"
1151    );
1152    out.push_str(&"-".repeat(110));
1153    out.push('\n');
1154    for s in &sessions {
1155        let title = s.title.as_deref().unwrap_or("(untitled)");
1156        let _ = writeln!(
1157            out,
1158            "{:<38} {:<30} {:<9} {:>6} {:<24}",
1159            s.session_id,
1160            crate::text::truncate_to_chars(title, 30),
1161            s.status.as_str(),
1162            s.event_count,
1163            s.updated_at
1164        );
1165    }
1166    Ok(out.trim_end().to_owned())
1167}
1168
1169/// Formats `/conv show <id>` — one session's metadata, mirroring `zeph serve-sessions`'s
1170/// `GET /sessions/:id` (metadata only; use `zeph sessions show --events <id>` on the CLI for a
1171/// full event-log dump).
1172async fn handle_conv_show(
1173    store: &zeph_session::SessionStore,
1174    id: &str,
1175) -> Result<String, CommandError> {
1176    if id.is_empty() {
1177        return Ok("Usage: /conv show <id>".to_owned());
1178    }
1179    let metadata = store
1180        .get(id)
1181        .await
1182        .map_err(|e| CommandError::new(format!("failed to read session metadata: {e}")))?;
1183    let Some(m) = metadata else {
1184        return Ok(format!("Session '{id}' not found."));
1185    };
1186    Ok(format!(
1187        "Session {}\n  title: {}\n  status: {}\n  events: {} (last_seq={})\n  forked_from: {}\n  created: {}\n  updated: {}",
1188        m.session_id,
1189        m.title.as_deref().unwrap_or("(untitled)"),
1190        m.status.as_str(),
1191        m.event_count,
1192        m.last_seq,
1193        m.forked_from.as_deref().unwrap_or("-"),
1194        m.created_at,
1195        m.updated_at
1196    ))
1197}
1198
1199impl<C: Channel + Send + 'static> SessionControlAccess for Agent<C> {
1200    // ----- /recap -----
1201
1202    fn session_recap<'a>(
1203        &'a mut self,
1204    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1205        Box::pin(
1206            async move {
1207                match self.build_recap().await {
1208                    Ok(text) => Ok(text),
1209                    Err(e) => {
1210                        // /recap is an explicit user command — surface a fixed message so that
1211                        // LlmError internals (URLs with embedded credentials, response excerpts)
1212                        // are never forwarded to the user channel. Full detail goes to the log.
1213                        tracing::warn!("session recap command: {}", e.0);
1214                        Ok("Recap unavailable — see logs for details".to_string())
1215                    }
1216                }
1217            }
1218            .instrument(tracing::info_span!("core.agent_access.session_recap")),
1219        )
1220    }
1221
1222    // ----- /compact -----
1223
1224    fn compact_context<'a>(
1225        &'a mut self,
1226    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1227        Box::pin(
1228            self.compact_context_command()
1229                .instrument(tracing::info_span!("core.agent_access.compact_context")),
1230        )
1231    }
1232
1233    // ----- /new -----
1234
1235    fn reset_conversation<'a>(
1236        &'a mut self,
1237        keep_plan: bool,
1238        no_digest: bool,
1239    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1240        Box::pin(async move {
1241            match self.reset_conversation(keep_plan, no_digest).await {
1242                Ok((old_id, new_id)) => {
1243                    let old = old_id.map_or_else(|| "none".to_string(), |id| id.0.to_string());
1244                    let new = new_id.map_or_else(|| "none".to_string(), |id| id.0.to_string());
1245                    let keep_note = if keep_plan { " (plan preserved)" } else { "" };
1246                    Ok(format!(
1247                        "New conversation started. Previous: {old} → Current: {new}{keep_note}"
1248                    ))
1249                }
1250                Err(e) => Ok(format!("Failed to start new conversation: {e}")),
1251            }
1252        })
1253    }
1254
1255    // ----- /cache-stats -----
1256
1257    fn cache_stats(&self) -> String {
1258        self.tool_orchestrator.cache_stats()
1259    }
1260
1261    // ----- /status -----
1262
1263    fn session_status<'a>(
1264        &'a mut self,
1265    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1266        Box::pin(async move { Ok(self.handle_status_as_string()) })
1267    }
1268
1269    // ----- /guardrail -----
1270
1271    fn guardrail_status(&self) -> String {
1272        self.format_guardrail_status()
1273    }
1274
1275    // ----- /focus -----
1276
1277    fn focus_status(&self) -> String {
1278        self.format_focus_status()
1279    }
1280
1281    // ----- /sidequest -----
1282
1283    fn sidequest_status(&self) -> String {
1284        self.format_sidequest_status()
1285    }
1286
1287    // ----- /image -----
1288
1289    fn load_image<'a>(
1290        &'a mut self,
1291        path: &'a str,
1292    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1293        use zeph_common::path_guard::{PathRejection, classify_relative_path};
1294        use zeph_llm::provider::{ImageData, MessagePart};
1295
1296        match classify_relative_path(path) {
1297            PathRejection::Allowed => {}
1298            PathRejection::Absolute => {
1299                return Box::pin(async move {
1300                    Ok(
1301                        "Invalid image path: absolute paths are not supported, use a path \
1302                        relative to the working directory"
1303                            .to_owned(),
1304                    )
1305                });
1306            }
1307            PathRejection::Traversal => {
1308                return Box::pin(async move {
1309                    Ok("Invalid image path: path traversal ('..') is not allowed".to_owned())
1310                });
1311            }
1312        }
1313
1314        let path_owned = path.to_owned();
1315        Box::pin(async move {
1316            let path_for_task = path_owned.clone();
1317            let read_result = tokio::task::spawn_blocking(move || std::fs::read(&path_for_task))
1318                .await
1319                .map_err(|e| CommandError::new(format!("spawn_blocking join error: {e}")))?;
1320            let data = match read_result {
1321                Ok(d) => d,
1322                Err(e) => return Ok(format!("Cannot read image {path_owned}: {e}")),
1323            };
1324            if data.len() > crate::agent::message_queue::MAX_IMAGE_BYTES {
1325                return Ok(format!(
1326                    "Image {path_owned} exceeds size limit ({} MB), skipping",
1327                    crate::agent::message_queue::MAX_IMAGE_BYTES / 1024 / 1024
1328                ));
1329            }
1330            let mime_type =
1331                crate::agent::message_queue::detect_image_mime(Some(&path_owned)).to_string();
1332            self.msg
1333                .pending_image_parts
1334                .push(MessagePart::Image(Box::new(ImageData { data, mime_type })));
1335            Ok(format!("Image loaded: {path_owned}. Send your message."))
1336        })
1337    }
1338
1339    // ----- /undo, /redo -----
1340
1341    fn handle_undo<'a>(
1342        &'a mut self,
1343        args: &'a str,
1344    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1345        let executor = std::sync::Arc::clone(&self.tool_executor);
1346        let args_owned = args.trim().to_owned();
1347        Box::pin(async move {
1348            if args_owned == "list" {
1349                let result = executor.checkpoint_list_erased();
1350                if !result.supported {
1351                    return Ok(
1352                        "Checkpoints are not enabled. Set `[tools.shell] checkpoints_enabled = true` in config.".to_owned()
1353                    );
1354                }
1355                if result.entries.is_empty() {
1356                    return Ok("Undo stack is empty.".to_owned());
1357                }
1358                let mut lines = vec![format!("Undo stack ({} entries):", result.entries.len())];
1359                for e in &result.entries {
1360                    lines.push(format!(
1361                        "  [{}] {} ({} file(s))",
1362                        e.index, e.command, e.file_count
1363                    ));
1364                }
1365                if result.redo_depth > 0 {
1366                    lines.push(format!("Redo depth: {}", result.redo_depth));
1367                }
1368                return Ok(lines.join("\n"));
1369            }
1370
1371            let n: usize = if args_owned.is_empty() {
1372                1
1373            } else {
1374                match args_owned.parse::<usize>() {
1375                    Ok(v) if v > 0 => v,
1376                    _ => {
1377                        return Err(CommandError::new(format!(
1378                            "Invalid argument: expected a positive integer or 'list', got '{args_owned}'"
1379                        )));
1380                    }
1381                }
1382            };
1383
1384            let result = tokio::task::spawn_blocking(move || executor.checkpoint_undo_erased(n))
1385                .await
1386                .map_err(|e| CommandError::new(format!("undo task panicked: {e}")))?;
1387            if !result.supported {
1388                return Ok(
1389                    "Checkpoints are not enabled. Set `[tools.shell] checkpoints_enabled = true` in config.".to_owned()
1390                );
1391            }
1392            Ok(result.message)
1393        })
1394    }
1395
1396    fn handle_redo<'a>(
1397        &'a mut self,
1398        args: &'a str,
1399    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1400        let _ = args;
1401        let executor = std::sync::Arc::clone(&self.tool_executor);
1402        Box::pin(async move {
1403            let result = tokio::task::spawn_blocking(move || executor.checkpoint_redo_erased())
1404                .await
1405                .map_err(|e| CommandError::new(format!("redo task panicked: {e}")))?;
1406            if !result.supported {
1407                return Ok(
1408                    "Checkpoints are not enabled. Set `[tools.shell] checkpoints_enabled = true` in config.".to_owned()
1409                );
1410            }
1411            Ok(result.message)
1412        })
1413    }
1414
1415    // ----- /conv -----
1416
1417    fn handle_conv<'a>(
1418        &'a mut self,
1419        args: &'a str,
1420    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1421        let args_owned = args.trim().to_owned();
1422        Box::pin(async move {
1423            // `resume`/`fork` need `&mut self` (mid-session live conversation swap, D-9) —
1424            // handled first so `self` isn't already borrowed by the `list`/`show` path below.
1425            if let Some(id) = args_owned.strip_prefix("resume ") {
1426                return self.handle_conv_resume(id.trim()).await;
1427            }
1428            if let Some(id) = args_owned.strip_prefix("fork ") {
1429                return self.handle_conv_fork(id.trim()).await;
1430            }
1431
1432            let Some(memory) = self.services.memory.persistence.memory.clone() else {
1433                return Ok(
1434                    "Conversation-session persistence requires memory to be enabled ([memory] enabled = true)."
1435                        .to_owned(),
1436                );
1437            };
1438            let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1439
1440            if let Some(id) = args_owned.strip_prefix("show ") {
1441                return handle_conv_show(&store, id.trim()).await;
1442            }
1443            if args_owned.is_empty() || args_owned == "list" {
1444                return handle_conv_list(&store).await;
1445            }
1446            Ok(format!(
1447                "Unknown /conv subcommand '{args_owned}'. Usage: /conv [list | show <id> | resume <id> | fork <id>]"
1448            ))
1449        })
1450    }
1451}
1452
1453#[cfg(test)]
1454mod tests {
1455    use super::super::agent_tests::{
1456        MockChannel, MockToolExecutor, create_test_registry, mock_provider,
1457    };
1458    use super::*;
1459    use zeph_memory::semantic::SemanticMemory;
1460
1461    async fn memory_without_qdrant() -> SemanticMemory {
1462        SemanticMemory::new(
1463            ":memory:",
1464            "http://127.0.0.1:1",
1465            None,
1466            zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
1467            "test-model",
1468        )
1469        .await
1470        .unwrap()
1471    }
1472
1473    /// #5904 SIGNIFICANT-1: `dispatch_slash_command` is the only slash-command dispatch path
1474    /// with no `trusted`/`requires_auth` check at all — it runs `/subagent spawn <cmd>`
1475    /// (external ACP process spawn) unconditionally regardless of channel trust. HTTP entry
1476    /// points (serve-sessions, gateway) rely on `zeph_commands::is_recognized_command`
1477    /// excluding every command dispatched here, so they never forward such a command raw
1478    /// expecting the registry's trust gate to catch it. If this function ever starts handling
1479    /// another command besides `/subagent` (`@mention` is not `/`-prefixed and is exempt),
1480    /// `zeph_commands::UNGATED_DISPATCH_COMMANDS` must be updated to exclude it too — this test
1481    /// pins the current, single exception so that omission is caught here, at the source of the
1482    /// trust-blind path, not only in `zeph-commands`.
1483    #[test]
1484    fn subagent_is_excluded_from_is_recognized_command() {
1485        assert!(!zeph_commands::is_recognized_command("/subagent"));
1486        assert!(!zeph_commands::is_recognized_command(
1487            "/subagent spawn zeph --acp"
1488        ));
1489    }
1490
1491    #[test]
1492    fn format_overlay_section_empty_dir() {
1493        let tmp = tempfile::tempdir().unwrap();
1494        let out = format_overlay_section(tmp.path());
1495        assert_eq!(out, "No plugin overlay active.");
1496    }
1497
1498    #[test]
1499    fn format_overlay_section_with_source_plugin() {
1500        let tmp = tempfile::tempdir().unwrap();
1501        let plugin_dir = tmp.path().join("myplugin");
1502        std::fs::create_dir_all(&plugin_dir).unwrap();
1503        let manifest = r#"
1504[plugin]
1505name = "myplugin"
1506version = "0.1.0"
1507description = "test"
1508
1509[config.tools.shell]
1510blocked_commands = ["curl"]
1511"#;
1512        std::fs::write(plugin_dir.join(".plugin.toml"), manifest).unwrap();
1513        let out = format_overlay_section(tmp.path());
1514        assert!(out.contains("Active plugin overlay:"));
1515        assert!(out.contains("myplugin"));
1516        assert!(out.contains("Source plugins:"));
1517        assert!(out.contains("Note:"));
1518    }
1519
1520    #[test]
1521    fn run_plugin_command_overlay_subcommand() {
1522        let tmp = tempfile::tempdir().unwrap();
1523        // Override default plugins dir is not possible in run_plugin_command since it uses
1524        // the canonical dir. Test that the function returns the expected prefix on an empty dir.
1525        // We test format_overlay_section directly for correctness; this test guards routing.
1526        let out = format_overlay_section(tmp.path());
1527        assert_eq!(out, "No plugin overlay active.");
1528    }
1529
1530    #[test]
1531    fn format_overlay_section_skipped_plugin_shows_reason() {
1532        let tmp = tempfile::tempdir().unwrap();
1533        // Write a plugin dir with an invalid manifest to trigger skipped_plugins.
1534        let plugin_dir = tmp.path().join("badplugin");
1535        std::fs::create_dir_all(&plugin_dir).unwrap();
1536        std::fs::write(
1537            plugin_dir.join(".plugin.toml"),
1538            b"not valid toml at all {{{{",
1539        )
1540        .unwrap();
1541        let out = format_overlay_section(tmp.path());
1542        // Either skipped with reason or empty overlay — either way must not panic.
1543        assert!(out.contains("No plugin overlay active.") || out.contains("badplugin"));
1544    }
1545
1546    // #5487 fix 3: `handle_conv_resume` had zero prior test coverage. Resuming into the
1547    // session already live in this agent must short-circuit before attempting to re-acquire
1548    // the exclusive lock this agent's own SessionSink already holds (a guaranteed
1549    // `AlreadyLocked` self-deadlock, since flock conflicts are per open-file-description, not
1550    // per-process).
1551    #[tokio::test]
1552    async fn handle_conv_resume_same_session_short_circuits() {
1553        let memory = memory_without_qdrant().await;
1554        let cid = memory.sqlite().create_conversation().await.unwrap();
1555        let dir = tempfile::tempdir().unwrap();
1556        let data_dir = dir.path().to_path_buf();
1557        let session_id = zeph_common::SessionId::new("s1");
1558        let session_path = zeph_session::session_dir(&data_dir, session_id.as_str());
1559        let log = zeph_session::SessionEventLog::open_exclusive(&session_path)
1560            .await
1561            .unwrap();
1562        let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1563        let sink = zeph_agent_persistence::SessionSink::new(
1564            std::sync::Arc::new(log),
1565            store,
1566            session_id.clone(),
1567        );
1568        let session_config = zeph_config::SessionConfig {
1569            enabled: true,
1570            data_dir: data_dir.to_string_lossy().into_owned(),
1571            ..Default::default()
1572        };
1573
1574        let mut agent = Agent::new(
1575            mock_provider(vec![]),
1576            MockChannel::new(vec![]),
1577            create_test_registry(),
1578            None,
1579            5,
1580            MockToolExecutor::no_tools(),
1581        )
1582        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1583        .with_session_sink(Some(std::sync::Arc::new(sink)))
1584        .with_session_persistence_config(Some(session_config));
1585
1586        let result = agent.handle_conv("resume s1").await.unwrap();
1587        assert_eq!(
1588            result, "Already in session 's1'.",
1589            "resuming into the currently-active session must short-circuit, not attempt \
1590             hydration/lock acquisition"
1591        );
1592    }
1593
1594    // Regression check for the guard above: resuming into a genuinely different session (not
1595    // the one already live in this agent) must still hydrate normally.
1596    #[tokio::test]
1597    async fn handle_conv_resume_different_session_still_hydrates() {
1598        let memory = memory_without_qdrant().await;
1599        let cid = memory.sqlite().create_conversation().await.unwrap();
1600        let dir = tempfile::tempdir().unwrap();
1601        let data_dir = dir.path().to_path_buf();
1602
1603        // Agent is currently "in" session s1, whose own lock is held by its SessionSink.
1604        let active_session_id = zeph_common::SessionId::new("s1");
1605        let active_session_path = zeph_session::session_dir(&data_dir, active_session_id.as_str());
1606        let active_log = zeph_session::SessionEventLog::open_exclusive(&active_session_path)
1607            .await
1608            .unwrap();
1609        let active_store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1610        let active_sink = zeph_agent_persistence::SessionSink::new(
1611            std::sync::Arc::new(active_log),
1612            active_store,
1613            active_session_id,
1614        );
1615
1616        // Target session s2 exists in the store (unlocked directory) — this is what should be
1617        // resumed into.
1618        let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1619        store.create("s2").await.unwrap();
1620
1621        let session_config = zeph_config::SessionConfig {
1622            enabled: true,
1623            data_dir: data_dir.to_string_lossy().into_owned(),
1624            ..Default::default()
1625        };
1626
1627        let mut agent = Agent::new(
1628            mock_provider(vec![]),
1629            MockChannel::new(vec![]),
1630            create_test_registry(),
1631            None,
1632            5,
1633            MockToolExecutor::no_tools(),
1634        )
1635        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1636        .with_session_sink(Some(std::sync::Arc::new(active_sink)))
1637        .with_session_persistence_config(Some(session_config));
1638
1639        let result = agent.handle_conv("resume s2").await.unwrap();
1640        assert!(
1641            result.starts_with("Resumed session s2"),
1642            "resuming into a different, unlocked session must still hydrate normally, got: {result}"
1643        );
1644    }
1645
1646    // #5764: `/conv fork` had zero test coverage — only `/conv resume` was tested above.
1647    // Forks session "s1" into a fresh child and confirms the agent live-swaps onto it.
1648    #[tokio::test]
1649    async fn handle_conv_fork_creates_child_session_and_switches_to_it() {
1650        let memory = memory_without_qdrant().await;
1651        let cid = memory.sqlite().create_conversation().await.unwrap();
1652        let dir = tempfile::tempdir().unwrap();
1653        let data_dir = dir.path().to_path_buf();
1654
1655        let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1656        store.create("s1").await.unwrap();
1657        let src_dir = zeph_session::session_dir(&data_dir, "s1");
1658        let log = zeph_session::SessionEventLog::open(&src_dir).await.unwrap();
1659        log.append(
1660            None,
1661            None,
1662            zeph_session::SessionEvent::SessionStarted {
1663                session_id: "s1".to_owned(),
1664                cwd: "/repo".to_owned(),
1665                provider_name: "claude".to_owned(),
1666                model: "opus".to_owned(),
1667                forked_from: None,
1668            },
1669        )
1670        .await
1671        .unwrap();
1672        store
1673            .update_seq("s1", log.last_seq().unwrap(), 1)
1674            .await
1675            .unwrap();
1676        drop(log);
1677
1678        let session_config = zeph_config::SessionConfig {
1679            enabled: true,
1680            data_dir: data_dir.to_string_lossy().into_owned(),
1681            ..Default::default()
1682        };
1683
1684        let mut agent = Agent::new(
1685            mock_provider(vec![]),
1686            MockChannel::new(vec![]),
1687            create_test_registry(),
1688            None,
1689            5,
1690            MockToolExecutor::no_tools(),
1691        )
1692        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1693        .with_session_persistence_config(Some(session_config));
1694
1695        let result = agent.handle_conv("fork s1").await.unwrap();
1696        assert!(
1697            result.starts_with("Forked session s1 ->"),
1698            "expected fork confirmation message, got: {result}"
1699        );
1700        assert!(
1701            result.contains("event(s) copied"),
1702            "expected copied-event count in confirmation, got: {result}"
1703        );
1704    }
1705
1706    // Regression test for AC-23 (spec-068 §13.5/§13.9): `/conv fork` is a live in-session swap
1707    // reached via `load_and_resume_conversation`, entirely bypassing the process-startup banner
1708    // computed in `src/runner.rs`. Forks a session with real (non-`SessionStarted`-only) prior
1709    // history and asserts the resume banner is sent through the channel for this path too.
1710    #[tokio::test]
1711    async fn handle_conv_fork_sends_resume_banner_for_non_empty_history() {
1712        let memory = memory_without_qdrant().await;
1713        let cid = memory.sqlite().create_conversation().await.unwrap();
1714        let dir = tempfile::tempdir().unwrap();
1715        let data_dir = dir.path().to_path_buf();
1716
1717        let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1718        store.create("s1").await.unwrap();
1719        let src_dir = zeph_session::session_dir(&data_dir, "s1");
1720        let log = zeph_session::SessionEventLog::open(&src_dir).await.unwrap();
1721        log.append(
1722            None,
1723            None,
1724            zeph_session::SessionEvent::SessionStarted {
1725                session_id: "s1".to_owned(),
1726                cwd: "/repo".to_owned(),
1727                provider_name: "claude".to_owned(),
1728                model: "opus".to_owned(),
1729                forked_from: None,
1730            },
1731        )
1732        .await
1733        .unwrap();
1734        log.append(
1735            None,
1736            None,
1737            zeph_session::SessionEvent::UserMessage {
1738                text: "hello".to_owned(),
1739                image_refs: vec![],
1740            },
1741        )
1742        .await
1743        .unwrap();
1744        store
1745            .update_seq("s1", log.last_seq().unwrap(), 2)
1746            .await
1747            .unwrap();
1748        drop(log);
1749
1750        let session_config = zeph_config::SessionConfig {
1751            enabled: true,
1752            data_dir: data_dir.to_string_lossy().into_owned(),
1753            ..Default::default()
1754        };
1755
1756        let mut agent = Agent::new(
1757            mock_provider(vec![]),
1758            MockChannel::new(vec![]),
1759            create_test_registry(),
1760            None,
1761            5,
1762            MockToolExecutor::no_tools(),
1763        )
1764        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1765        .with_session_persistence_config(Some(session_config));
1766
1767        let result = agent.handle_conv("fork s1").await.unwrap();
1768        assert!(
1769            result.starts_with("Forked session s1 ->"),
1770            "expected fork confirmation message, got: {result}"
1771        );
1772
1773        let sent = agent.channel.sent_messages();
1774        assert!(
1775            sent.iter().any(|m| m.contains("Resuming session")),
1776            "forking a session with non-empty prior history must send the resume banner \
1777             through the channel, got sent messages: {sent:?}"
1778        );
1779    }
1780
1781    // `SessionControlAccess::load_image` had zero direct coverage — only
1782    // `Agent::handle_image_as_string` (slash_commands.rs) and `cli.rs`'s inline check
1783    // were tested. These exercise the real `Agent<C>` impl via the trait.
1784
1785    #[tokio::test]
1786    async fn load_image_rejects_absolute_path() {
1787        let mut agent = Agent::new(
1788            mock_provider(vec![]),
1789            MockChannel::new(vec![]),
1790            create_test_registry(),
1791            None,
1792            5,
1793            MockToolExecutor::no_tools(),
1794        );
1795
1796        let result = SessionControlAccess::load_image(&mut agent, "/etc/passwd")
1797            .await
1798            .unwrap();
1799        assert!(result.contains("absolute paths are not supported"));
1800    }
1801
1802    #[tokio::test]
1803    async fn load_image_rejects_parent_dir_traversal() {
1804        let mut agent = Agent::new(
1805            mock_provider(vec![]),
1806            MockChannel::new(vec![]),
1807            create_test_registry(),
1808            None,
1809            5,
1810            MockToolExecutor::no_tools(),
1811        );
1812
1813        let result = SessionControlAccess::load_image(&mut agent, "../../etc/passwd")
1814            .await
1815            .unwrap();
1816        assert!(result.contains("path traversal") && result.contains("not allowed"));
1817    }
1818}