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
8 changes: 8 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Format code
fmt:
cargo fmt

# Run all lints (format check + clippy)
lint:
cargo fmt --check
cargo clippy -- -D warnings
2 changes: 2 additions & 0 deletions rustfmt.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Use rustfmt defaults. This file exists so contributors and CI know
# the project is formatted with `cargo fmt`.
20 changes: 12 additions & 8 deletions src/acp/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,16 @@ impl AcpConnection {
// Auto-reply session/request_permission
if msg.method.as_deref() == Some("session/request_permission") {
if let Some(id) = msg.id {
let title = msg.params.as_ref()
let title = msg
.params
.as_ref()
.and_then(|p| p.get("toolCall"))
.and_then(|t| t.get("title"))
.and_then(|t| t.as_str())
.unwrap_or("?");
info!(title, "auto-allow permission");
let reply = JsonRpcResponse::new(id, json!({"optionId": "allow_always"}));
let reply =
JsonRpcResponse::new(id, json!({"optionId": "allow_always"}));
if let Ok(data) = serde_json::to_string(&reply) {
let mut w = stdin_clone.lock().await;
let _ = w.write_all(format!("{data}\n").as_bytes()).await;
Expand Down Expand Up @@ -214,7 +217,9 @@ impl AcpConnection {
)
.await?;

let agent_name = resp.result.as_ref()
let agent_name = resp
.result
.as_ref()
.and_then(|r| r.get("agentInfo"))
.and_then(|a| a.get("name"))
.and_then(|n| n.as_str())
Expand All @@ -225,13 +230,12 @@ impl AcpConnection {

pub async fn session_new(&mut self, cwd: &str) -> Result<String> {
let resp = self
.send_request(
"session/new",
Some(json!({"cwd": cwd, "mcpServers": []})),
)
.send_request("session/new", Some(json!({"cwd": cwd, "mcpServers": []})))
.await?;

let session_id = resp.result.as_ref()
let session_id = resp
.result
.as_ref()
.and_then(|r| r.get("sessionId"))
.and_then(|s| s.as_str())
.ok_or_else(|| anyhow!("no sessionId in session/new response"))?
Expand Down
5 changes: 4 additions & 1 deletion src/acp/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,10 @@ impl SessionPool {
/// Get mutable access to a connection. Caller must have called get_or_create first.
pub async fn with_connection<F, R>(&self, thread_id: &str, f: F) -> Result<R>
where
F: FnOnce(&mut AcpConnection) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<R>> + Send + '_>>,
F: FnOnce(
&mut AcpConnection,
)
-> std::pin::Pin<Box<dyn std::future::Future<Output = Result<R>> + Send + '_>>,
{
let mut conns = self.connections.write().await;
let conn = conns
Expand Down
35 changes: 27 additions & 8 deletions src/acp/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ pub struct JsonRpcRequest {

impl JsonRpcRequest {
pub fn new(id: u64, method: impl Into<String>, params: Option<Value>) -> Self {
Self { jsonrpc: "2.0", id, method: method.into(), params }
Self {
jsonrpc: "2.0",
id,
method: method.into(),
params,
}
}
}

Expand All @@ -27,7 +32,11 @@ pub struct JsonRpcResponse {

impl JsonRpcResponse {
pub fn new(id: u64, result: Value) -> Self {
Self { jsonrpc: "2.0", id, result }
Self {
jsonrpc: "2.0",
id,
result,
}
}
}

Expand Down Expand Up @@ -75,16 +84,26 @@ pub fn classify_notification(msg: &JsonRpcMessage) -> Option<AcpEvent> {
let text = update.get("content")?.get("text")?.as_str()?;
Some(AcpEvent::Text(text.to_string()))
}
"agent_thought_chunk" => {
Some(AcpEvent::Thinking)
}
"agent_thought_chunk" => Some(AcpEvent::Thinking),
"tool_call" => {
let title = update.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
let title = update
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Some(AcpEvent::ToolStart { title })
}
"tool_call_update" => {
let title = update.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
let status = update.get("status").and_then(|v| v.as_str()).unwrap_or("").to_string();
let title = update
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let status = update
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if status == "completed" || status == "failed" {
Some(AcpEvent::ToolDone { title, status })
} else {
Expand Down
88 changes: 65 additions & 23 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,28 +85,63 @@ pub struct ReactionTiming {

// --- defaults ---

fn default_working_dir() -> String { "/tmp".into() }
fn default_max_sessions() -> usize { 10 }
fn default_ttl_hours() -> u64 { 24 }
fn default_true() -> bool { true }

fn emoji_queued() -> String { "👀".into() }
fn emoji_thinking() -> String { "🤔".into() }
fn emoji_tool() -> String { "🔥".into() }
fn emoji_coding() -> String { "👨‍💻".into() }
fn emoji_web() -> String { "⚡".into() }
fn emoji_done() -> String { "🆗".into() }
fn emoji_error() -> String { "😱".into() }

fn default_debounce_ms() -> u64 { 700 }
fn default_stall_soft_ms() -> u64 { 10_000 }
fn default_stall_hard_ms() -> u64 { 30_000 }
fn default_done_hold_ms() -> u64 { 1_500 }
fn default_error_hold_ms() -> u64 { 2_500 }
fn default_working_dir() -> String {
"/tmp".into()
}
fn default_max_sessions() -> usize {
10
}
fn default_ttl_hours() -> u64 {
24
}
fn default_true() -> bool {
true
}

fn emoji_queued() -> String {
"👀".into()
}
fn emoji_thinking() -> String {
"🤔".into()
}
fn emoji_tool() -> String {
"🔥".into()
}
fn emoji_coding() -> String {
"👨‍💻".into()
}
fn emoji_web() -> String {
"⚡".into()
}
fn emoji_done() -> String {
"🆗".into()
}
fn emoji_error() -> String {
"😱".into()
}

fn default_debounce_ms() -> u64 {
700
}
fn default_stall_soft_ms() -> u64 {
10_000
}
fn default_stall_hard_ms() -> u64 {
30_000
}
fn default_done_hold_ms() -> u64 {
1_500
}
fn default_error_hold_ms() -> u64 {
2_500
}

impl Default for PoolConfig {
fn default() -> Self {
Self { max_sessions: default_max_sessions(), session_ttl_hours: default_ttl_hours() }
Self {
max_sessions: default_max_sessions(),
session_ttl_hours: default_ttl_hours(),
}
}
}

Expand All @@ -124,17 +159,24 @@ impl Default for ReactionsConfig {
impl Default for ReactionEmojis {
fn default() -> Self {
Self {
queued: emoji_queued(), thinking: emoji_thinking(), tool: emoji_tool(),
coding: emoji_coding(), web: emoji_web(), done: emoji_done(), error: emoji_error(),
queued: emoji_queued(),
thinking: emoji_thinking(),
tool: emoji_tool(),
coding: emoji_coding(),
web: emoji_web(),
done: emoji_done(),
error: emoji_error(),
}
}
}

impl Default for ReactionTiming {
fn default() -> Self {
Self {
debounce_ms: default_debounce_ms(), stall_soft_ms: default_stall_soft_ms(),
stall_hard_ms: default_stall_hard_ms(), done_hold_ms: default_done_hold_ms(),
debounce_ms: default_debounce_ms(),
stall_soft_ms: default_stall_soft_ms(),
stall_hard_ms: default_stall_hard_ms(),
done_hold_ms: default_done_hold_ms(),
error_hold_ms: default_error_hold_ms(),
}
}
Expand Down
35 changes: 29 additions & 6 deletions src/discord.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ impl EventHandler for Handler {

let is_mentioned = msg.mentions_user_id(bot_id)
|| msg.content.contains(&format!("<@{}>", bot_id))
|| msg.mention_roles.iter().any(|r| msg.content.contains(&format!("<@&{}>", r)));
|| msg
.mention_roles
.iter()
.any(|r| msg.content.contains(&format!("<@&{}>", r)));

let in_thread = if !in_allowed_channel {
match msg.channel_id.to_channel(&ctx.http).await {
Expand Down Expand Up @@ -74,7 +77,9 @@ impl EventHandler for Handler {
}

// Inject structured sender context so the downstream CLI can identify who sent the message
let display_name = msg.member.as_ref()
let display_name = msg
.member
.as_ref()
.and_then(|m| m.nick.as_ref())
.unwrap_or(&msg.author.name);
let sender_ctx = serde_json::json!({
Expand Down Expand Up @@ -118,7 +123,13 @@ impl EventHandler for Handler {

let thread_key = thread_id.to_string();
if let Err(e) = self.pool.get_or_create(&thread_key).await {
let _ = edit(&ctx, thread_channel, thinking_msg.id, "⚠️ Failed to start agent.").await;
let _ = edit(
&ctx,
thread_channel,
thinking_msg.id,
"⚠️ Failed to start agent.",
)
.await;
error!("pool error: {e}");
return;
}
Expand Down Expand Up @@ -175,8 +186,18 @@ impl EventHandler for Handler {
}
}

async fn edit(ctx: &Context, ch: ChannelId, msg_id: MessageId, content: &str) -> serenity::Result<Message> {
ch.edit_message(&ctx.http, msg_id, serenity::builder::EditMessage::new().content(content)).await
async fn edit(
ctx: &Context,
ch: ChannelId,
msg_id: MessageId,
content: &str,
) -> serenity::Result<Message> {
ch.edit_message(
&ctx.http,
msg_id,
serenity::builder::EditMessage::new().content(content),
)
.await
}

async fn stream_prompt(
Expand Down Expand Up @@ -280,7 +301,9 @@ async fn stream_prompt(
AcpEvent::ToolDone { title, status, .. } => {
reactions.set_thinking().await;
let icon = if status == "completed" { "✅" } else { "❌" };
if let Some(line) = tool_lines.iter_mut().rev().find(|l| l.contains(&title)) {
if let Some(line) =
tool_lines.iter_mut().rev().find(|l| l.contains(&title))
{
*line = format!("{icon} `{title}`");
}
let _ = buf_tx.send(compose_display(&tool_lines, &text_buf));
Expand Down
5 changes: 2 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,8 @@ async fn main() -> anyhow::Result<()> {
reactions_config: cfg.reactions,
};

let intents = GatewayIntents::GUILD_MESSAGES
| GatewayIntents::MESSAGE_CONTENT
| GatewayIntents::GUILDS;
let intents =
GatewayIntents::GUILD_MESSAGES | GatewayIntents::MESSAGE_CONTENT | GatewayIntents::GUILDS;

let mut client = Client::builder(&cfg.discord.bot_token, intents)
.event_handler(handler)
Expand Down
Loading