zeph-commands 0.22.4

Slash command registry, handler trait, and channel sink abstraction for Zeph
Documentation

zeph-commands

Crates.io docs.rs License: MIT OR Apache-2.0 MSRV

Slash command registry, handler trait, and channel sink abstraction for Zeph.

This crate provides the non-generic infrastructure for slash command dispatch. It has no dependency on zeph-core — the agent implements the provided traits and wires them in at startup.

Modules

  • sink — [ChannelSink] minimal async I/O trait; replaces the C: Channel generic in handlers
  • context — [CommandContext] non-generic dispatch context with trait-object fields
  • traits — 15 per-domain subsystem-access sub-traits (MemoryAccess, GraphAccess, ModelAccess, SkillAccess, PolicyAccess, SchedulerAccess, LspAccess, SessionControlAccess, McpAccess, OrchestrationAccess, SubagentAccess, IntegrationAccess, TrackingAccess, WorktreeAccess, MiscAccess); AgentAccess is an empty marker supertrait over all 15 (blanket-impl'd) so dyn AgentAccess call sites are unchanged
  • handlers — concrete handler implementations (session, debug, skill, mcp, plan, …)
  • commands — static COMMANDS metadata table used by /help
  • transcript — [TranscriptFormatter] / [TranscriptEntry] / [TranscriptRole], the single source of truth for rendering bounded conversation history into role-prefixed, tool-collapsed text. Both the flat-text channels (CLI, Telegram, Discord, Slack) and the TUI backfill path reuse it, so /history renders identically everywhere

Design

CommandRegistry<Ctx> and CommandHandler<Ctx> are non-generic over the channel type. Handlers receive a &mut CommandContext whose fields are trait objects, so a change in zeph-core's agent loop does not recompile this crate.

Dispatch algorithm

CommandRegistry::dispatch performs a linear scan over registered handlers and picks the longest word-boundary match, enabling subcommand resolution without ambiguity:

/plan confirm   →  handler "/plan confirm"   wins over "/plan"
/plan           →  handler "/plan"           (no "/plan confirm" match)

Borrow splitting

When CommandRegistry is stored as an Agent<C> field, the dispatch site uses std::mem::take to move the registry out temporarily, constructs a CommandContext, dispatches, and restores the registry. This avoids borrow-checker conflicts with the channel field.

NullSink and NullAgent are zero-cost sentinels for dispatch blocks that do not need channel I/O or agent-access commands respectively.

Authorization is fail-closed by default

CommandHandler::requires_auth() defaults to true: a handler that does not override it requires a trusted (local) caller, and CommandRegistry::dispatch rejects it with a CommandError when the dispatch site passes trusted = false (e.g. a remote channel such as Telegram/Discord/Slack). Read-only or self-gated commands that are safe to expose on remote channels must explicitly override requires_auth() to return false.

[!NOTE] This default was flipped from permissive (false) to fail-closed (true) after repeated incidents where a new handler silently stayed reachable from untrusted channels until an audit caught it. New handlers — including the PingHandler example below — now require a trusted session unless they explicitly opt out.

Usage

Register and dispatch commands

use zeph_commands::{CommandRegistry, CommandContext, NullSink, NullAgent};

// Build the registry once at agent startup.
let mut registry: CommandRegistry<CommandContext> = CommandRegistry::new();
// registry.register(MyHandler);

// At dispatch time, construct the context and call dispatch.
let mut sink = NullSink;
let mut agent = NullAgent;
let mut ctx = CommandContext::new(&mut sink, &mut agent);

// registry.dispatch(&mut ctx, "/help").await;

Implement a custom handler

use std::future::Future;
use std::pin::Pin;
use zeph_commands::{CommandHandler, CommandOutput, CommandError, SlashCategory};

struct PingHandler;

impl<Ctx: Send> CommandHandler<Ctx> for PingHandler {
    fn name(&self) -> &'static str { "/ping" }
    fn description(&self) -> &'static str { "Reply with pong" }
    fn category(&self) -> SlashCategory { SlashCategory::Session }

    fn handle<'a>(
        &'a self,
        _ctx: &'a mut Ctx,
        _args: &'a str,
    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
        Box::pin(async { Ok(CommandOutput::Message("pong".into())) })
    }
}

Slash categories

Commands are grouped into categories for /help output:

Category Commands
Session /clear, /reset, /exit, /new, …
Configuration /model, /provider, /guardrail, …
Memory /memory, /graph, /compact, /guidelines, /store, …
Skills /skill, /skills, /feedback, …
Planning /plan, /focus, /sidequest, …
Debugging /debug-dump, /log, /lsp, /status, …
Integration /mcp, /image, /agent, /search, …
Advanced /experiment, /policy, /scheduler, …

Features

Feature Description Default
cocoon Enables the /cocoon handler (Cocoon sidecar status and model listing) No
profiling Extra tracing instrumentation spans for dispatch latency profiling No

Installation

cargo add zeph-commands

Documentation

Full documentation: https://bug-ops.github.io/zeph/

License

Licensed under either of MIT or Apache License, Version 2.0 at your option.