Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ uuid = { version = "1", features = ["v4"] }
regex = "1"
anyhow = "1"
rand = "0.8"
base64 = "0.22"
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ In short:

1. Go to https://discord.com/developers/applications and create an application
2. Bot tab → enable **Message Content Intent**
3. OAuth2 → URL Generator → scope: `bot` → permissions: Send Messages, Send Messages in Threads, Create Public Threads, Read Message History, Add Reactions, Manage Messages
3. OAuth2 → URL Generator → scopes: **`bot` + `applications.commands`** → permissions: Send Messages, Send Messages in Threads, Create Public Threads, Read Message History, Add Reactions, Manage Messages
4. Invite the bot to your server using the generated URL

> **Note:** `applications.commands` scope is required for the `/model` slash command. If you previously invited the bot with only `bot` scope, re-invite it using the new URL — the bot account stays the same, no data is lost.

### 2. Configure

```bash
Expand Down Expand Up @@ -82,6 +84,17 @@ In your Discord channel:

The bot creates a thread. After that, just type in the thread — no @mention needed.

### Slash Commands

| Command | Description |
|---------|-------------|
| `/model` | Show the current model and list all available models |
| `/model <model>` | Switch to a specific model. Supports aliases (`auto`, `opus`, `sonnet`, `haiku`) and full model ids — Discord's autocomplete shows the full list as you type. |

The model picker uses Discord's native autocomplete: type `/m` and Discord will suggest `/model`; tab into the field and use ↑↓ to pick from the live list of available models reported by your agent backend.

> Slash commands require the `applications.commands` OAuth scope (see [Create a Discord Bot](#1-create-a-discord-bot) above). If `/model` does not appear in your Discord client, re-invite the bot with the correct scope.

## Pluggable Agent Backends

Supports Kiro CLI, Claude Code, Codex, Gemini, and any ACP-compatible CLI.
Expand Down
90 changes: 90 additions & 0 deletions src/acp/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ fn expand_env(val: &str) -> String {
}
use tokio::time::Instant;

#[derive(Debug, Clone)]
pub struct ModelInfo {
pub model_id: String,
pub name: String,
pub description: String,
}

pub struct AcpConnection {
_proc: Child,
stdin: Arc<Mutex<ChildStdin>>,
Expand All @@ -29,6 +36,8 @@ pub struct AcpConnection {
pub acp_session_id: Option<String>,
pub last_active: Instant,
pub session_reset: bool,
pub current_model: String,
pub available_models: Vec<ModelInfo>,
_reader_handle: JoinHandle<()>,
}

Expand Down Expand Up @@ -163,6 +172,8 @@ impl AcpConnection {
acp_session_id: None,
last_active: Instant::now(),
session_reset: false,
current_model: "auto".to_string(),
available_models: Vec::new(),
_reader_handle: reader_handle,
})
}
Expand Down Expand Up @@ -239,9 +250,88 @@ impl AcpConnection {

info!(session_id = %session_id, "session created");
self.acp_session_id = Some(session_id.clone());

if let Some(models) = resp.result.as_ref().and_then(|r| r.get("models")) {
if let Some(current) = models.get("currentModelId").and_then(|v| v.as_str()) {
self.current_model = current.to_string();
}
if let Some(arr) = models.get("availableModels").and_then(|v| v.as_array()) {
self.available_models = arr
.iter()
.filter_map(|m| {
let model_id = m.get("modelId")?.as_str()?.to_string();
let name = m
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(&model_id)
.to_string();
let description = m
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if description.contains("[Deprecated]")
|| description.contains("[Internal]")
{
return None;
}
Some(ModelInfo {
model_id,
name,
description,
})
})
.collect();
info!(
count = self.available_models.len(),
current = %self.current_model,
"parsed available models"
);
}
}

Ok(session_id)
}

pub async fn session_set_model(&mut self, model_id: &str) -> Result<()> {
let session_id = self
.acp_session_id
.as_ref()
.ok_or_else(|| anyhow!("no active session"))?
.clone();
self.send_request(
"session/set_model",
Some(json!({
"sessionId": session_id,
"modelId": model_id,
})),
)
.await?;
self.current_model = model_id.to_string();
Ok(())
}

pub fn resolve_model_alias(&self, input: &str) -> Option<String> {
let lower = input.to_lowercase();
if self.available_models.iter().any(|m| m.model_id == lower) {
return Some(lower);
}
let candidate = match lower.as_str() {
"auto" => "auto",
"opus" => "claude-opus-4.6",
"sonnet" => "claude-sonnet-4.6",
"haiku" => "claude-haiku-4.5",
other => other,
};
if candidate == "auto"
|| self.available_models.iter().any(|m| m.model_id == candidate)
{
Some(candidate.to_string())
} else {
None
}
}

/// Send a prompt and return a receiver for streaming notifications.
/// The final message on the channel will have id set (the prompt response).
pub async fn session_prompt(
Expand Down
23 changes: 22 additions & 1 deletion src/acp/pool.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::acp::connection::AcpConnection;
use crate::acp::connection::{AcpConnection, ModelInfo};
use crate::config::AgentConfig;
use anyhow::{anyhow, Result};
use std::collections::HashMap;
Expand All @@ -10,6 +10,11 @@ pub struct SessionPool {
connections: RwLock<HashMap<String, AcpConnection>>,
config: AgentConfig,
max_sessions: usize,
/// Snapshot of available models from the most recent session creation.
/// Populated on first session_new() so slash command autocomplete can serve
/// suggestions without spawning a fresh agent (which takes ~10s).
cached_models: RwLock<Vec<ModelInfo>>,
cached_current_model: RwLock<String>,
}

impl SessionPool {
Expand All @@ -18,9 +23,19 @@ impl SessionPool {
connections: RwLock::new(HashMap::new()),
config,
max_sessions,
cached_models: RwLock::new(Vec::new()),
cached_current_model: RwLock::new("auto".to_string()),
}
}

pub async fn cached_models(&self) -> Vec<ModelInfo> {
self.cached_models.read().await.clone()
}

pub async fn cached_current_model(&self) -> String {
self.cached_current_model.read().await.clone()
}

pub async fn get_or_create(&self, thread_id: &str) -> Result<()> {
// Check if alive connection exists
{
Expand Down Expand Up @@ -59,6 +74,12 @@ impl SessionPool {
conn.initialize().await?;
conn.session_new(&self.config.working_dir).await?;

// Refresh model cache snapshot for slash command autocomplete.
if !conn.available_models.is_empty() {
*self.cached_models.write().await = conn.available_models.clone();
}
*self.cached_current_model.write().await = conn.current_model.clone();

let is_rebuild = conns.contains_key(thread_id);
if is_rebuild {
conn.session_reset = true;
Expand Down
Loading
Loading