Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Cargo.lock

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

36 changes: 36 additions & 0 deletions docs/kiro.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,39 @@ kubectl rollout restart deployment/openab-kiro
|------|----------|
| `~/.kiro/` | Settings, skills, sessions |
| `~/.local/share/kiro-cli/` | OAuth tokens (`data.sqlite3` → `auth_kv` table), conversation history |

## Slash Commands

| Command | Purpose | Status |
|---------|---------|--------|
| `/models` | Switch AI model | ✅ Implemented |
| `/agents` | Switch agent mode | ✅ Implemented |
| `/cancel` | Cancel current generation | ✅ Implemented |

### `/models` — Switch AI Model

Kiro CLI returns available models via ACP `configOptions` (category: `"model"`) on session creation. User types `/models` in a thread → select menu appears → pick a model → OpenAB sends `session/set_config_option` (falls back to `/model <value>` prompt if not supported).

### `/agents` — Switch Agent Mode

Same mechanism as `/models` but for the `agent` category. Kiro CLI exposes modes like `kiro_default` and `kiro_planner` via `configOptions`.

### `/cancel` — Cancel Current Operation

Sends a `session/cancel` JSON-RPC notification to abort in-flight LLM requests and tool calls. Works immediately — no need to wait for the current response to finish.

**Note:** All slash commands only work in threads where a conversation is already active. If no session exists, they will prompt the user to start one first.

See [docs/slash-commands.md](slash-commands.md) for full details.

## Built-in Kiro CLI Commands

All built-in kiro-cli slash commands can be passed directly after an @mention:

```
@MyBot /compact
@MyBot /clear
@MyBot /model claude-sonnet-4
```

These are forwarded as-is to the kiro-cli ACP session as a prompt. Any command that kiro-cli supports in its interactive mode works here.
71 changes: 69 additions & 2 deletions src/acp/connection.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::acp::protocol::{JsonRpcMessage, JsonRpcRequest, JsonRpcResponse};
use crate::acp::protocol::{ConfigOption, JsonRpcMessage, JsonRpcRequest, JsonRpcResponse, parse_config_options};
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
use std::collections::HashMap;
Expand Down Expand Up @@ -115,6 +115,7 @@ pub struct AcpConnection {
notify_tx: Arc<Mutex<Option<mpsc::UnboundedSender<JsonRpcMessage>>>>,
pub acp_session_id: Option<String>,
pub supports_load_session: bool,
pub config_options: Vec<ConfigOption>,
pub last_active: Instant,
pub session_reset: bool,
_reader_handle: JoinHandle<()>,
Expand Down Expand Up @@ -268,6 +269,7 @@ impl AcpConnection {
notify_tx,
acp_session_id: None,
supports_load_session: false,
config_options: Vec::new(),
last_active: Instant::now(),
session_reset: false,
_reader_handle: reader_handle,
Expand All @@ -278,7 +280,7 @@ impl AcpConnection {
self.next_id.fetch_add(1, Ordering::Relaxed)
}

async fn send_raw(&self, data: &str) -> Result<()> {
pub(crate) async fn send_raw(&self, data: &str) -> Result<()> {
debug!(data = data.trim(), "acp_send");
let mut w = self.stdin.lock().await;
w.write_all(data.as_bytes()).await?;
Expand Down Expand Up @@ -352,9 +354,66 @@ impl AcpConnection {

info!(session_id = %session_id, "session created");
self.acp_session_id = Some(session_id.clone());
if let Some(result) = resp.result.as_ref() {
self.config_options = parse_config_options(result);
if !self.config_options.is_empty() {
info!(count = self.config_options.len(), "parsed configOptions");
}
}
Ok(session_id)
}

/// Set a config option (e.g. model, mode) via ACP session/set_config_option.
/// Returns the updated list of all config options.
pub async fn set_config_option(&mut self, config_id: &str, value: &str) -> Result<Vec<ConfigOption>> {
let session_id = self
.acp_session_id
.as_ref()
.ok_or_else(|| anyhow!("no session"))?
.clone();

let resp = self
.send_request(
"session/set_config_option",
Some(json!({
"sessionId": session_id,
"configId": config_id,
"value": value,
})),
)
.await;

match resp {
Ok(r) => {
if let Some(result) = r.result.as_ref() {
self.config_options = parse_config_options(result);
}
info!(config_id, value, "config option set");
}
Err(_) => {
// Fall back: send as a slash command (e.g. "/model claude-sonnet-4")
let cmd = format!("/{config_id} {value}");
info!(cmd, "set_config_option not supported, falling back to prompt");
let _resp = self
.send_request(
"session/prompt",
Some(json!({
"sessionId": session_id,
"prompt": [{"type": "text", "text": cmd}],
})),
)
.await?;
for opt in &mut self.config_options {
if opt.id == config_id {
opt.current_value = value.to_string();
}
}
}
}

Ok(self.config_options.clone())
}

/// Send a prompt with content blocks (text and/or images) and return a receiver
/// for streaming notifications. The final message on the channel will have id set
/// (the prompt response).
Expand Down Expand Up @@ -403,6 +462,11 @@ impl AcpConnection {
self.last_active = Instant::now();
}

/// Return a clone of the stdin handle for lock-free cancel.
pub fn cancel_handle(&self) -> Arc<Mutex<ChildStdin>> {
Arc::clone(&self.stdin)
}

pub fn alive(&self) -> bool {
!self._reader_handle.is_finished()
}
Expand All @@ -422,6 +486,9 @@ impl AcpConnection {
}
info!(session_id, "session loaded");
self.acp_session_id = Some(session_id.to_string());
if let Some(result) = resp.result.as_ref() {
self.config_options = parse_config_options(result);
}
Ok(())
}

Expand Down
63 changes: 63 additions & 0 deletions src/acp/pool.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::acp::connection::AcpConnection;
use crate::acp::protocol::ConfigOption;
use crate::config::AgentConfig;
use anyhow::{anyhow, Result};
use std::collections::HashMap;
Expand All @@ -12,6 +13,9 @@ use tracing::{info, warn};
struct PoolState {
/// Active connections: thread_key → AcpConnection handle.
active: HashMap<String, Arc<Mutex<AcpConnection>>>,
/// Lock-free cancel handles: thread_key → (stdin, session_id).
/// Stored separately so cancel can work without locking the connection.
cancel_handles: HashMap<String, (Arc<tokio::sync::Mutex<tokio::process::ChildStdin>>, String)>,
/// Suspended sessions: thread_key → ACP sessionId.
/// Saved on eviction so sessions can be resumed via `session/load`.
suspended: HashMap<String, String>,
Expand Down Expand Up @@ -62,6 +66,7 @@ impl SessionPool {
Self {
state: RwLock::new(PoolState {
active: HashMap::new(),
cancel_handles: HashMap::new(),
suspended: HashMap::new(),
creating: HashMap::new(),
}),
Expand Down Expand Up @@ -163,6 +168,8 @@ impl SessionPool {
}
}

let cancel_handle = new_conn.cancel_handle();
let cancel_session_id = new_conn.acp_session_id.clone().unwrap_or_default();
let new_conn = Arc::new(Mutex::new(new_conn));

let mut state = self.state.write().await;
Expand Down Expand Up @@ -206,6 +213,9 @@ impl SessionPool {

state.suspended.remove(thread_id);
state.active.insert(thread_id.to_string(), new_conn);
if !cancel_session_id.is_empty() {
state.cancel_handles.insert(thread_id.to_string(), (cancel_handle, cancel_session_id));
}
Ok(())
}

Expand All @@ -229,6 +239,59 @@ impl SessionPool {
f(&mut conn).await
}

/// Get cached configOptions for a session (e.g. available models).
pub async fn get_config_options(&self, thread_id: &str) -> Vec<ConfigOption> {
let state = self.state.read().await;
let conn = match state.active.get(thread_id) {
Some(c) => c.clone(),
None => return Vec::new(),
};
drop(state);
let conn = conn.lock().await;
conn.config_options.clone()
}

/// Set a config option (e.g. model) via ACP and return updated options.
pub async fn set_config_option(
&self,
thread_id: &str,
config_id: &str,
value: &str,
) -> Result<Vec<ConfigOption>> {
let conn = {
let state = self.state.read().await;
state
.active
.get(thread_id)
.cloned()
.ok_or_else(|| anyhow!("no connection for thread {thread_id}"))?
};
let mut conn = conn.lock().await;
conn.set_config_option(config_id, value).await
}

/// Cancel the current in-flight operation for a session.
/// Uses pre-stored cancel handles to avoid locking the connection (which is held during streaming).
pub async fn cancel_session(&self, thread_id: &str) -> Result<()> {
let (stdin, session_id) = {
let state = self.state.read().await;
state.cancel_handles.get(thread_id).cloned()
.ok_or_else(|| anyhow!("no session for thread {thread_id}"))?
};
let data = serde_json::to_string(&serde_json::json!({
"jsonrpc": "2.0",
"method": "session/cancel",
"params": {"sessionId": session_id}
}))?;
tracing::info!(session_id, "sending session/cancel");
use tokio::io::AsyncWriteExt;
let mut w = stdin.lock().await;
w.write_all(data.as_bytes()).await?;
w.write_all(b"\n").await?;
w.flush().await?;
Ok(())
}

pub async fn cleanup_idle(&self, ttl_secs: u64) {
let cutoff = Instant::now() - std::time::Duration::from_secs(ttl_secs);

Expand Down
106 changes: 106 additions & 0 deletions src/acp/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,107 @@ impl std::fmt::Display for JsonRpcError {
}
}

// --- ACP configOptions (session-level configuration) ---

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigOptionValue {
pub value: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfigOption {
pub id: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(rename = "type")]
pub option_type: String,
pub current_value: String,
pub options: Vec<ConfigOptionValue>,
}

/// Extract configOptions from a JSON-RPC result value.
/// Supports standard `configOptions` and kiro-cli's `models`/`modes` fallback.
pub fn parse_config_options(result: &Value) -> Vec<ConfigOption> {
if let Some(opts) = result
.get("configOptions")
.and_then(|v| serde_json::from_value::<Vec<ConfigOption>>(v.clone()).ok())
{
if !opts.is_empty() {
return opts;
}
}

// Kiro-cli fallback: parse models/modes format
let mut options = Vec::new();

if let Some(models) = result.get("models") {
let current = models.get("currentModelId").and_then(|v| v.as_str()).unwrap_or("");
if let Some(available) = models.get("availableModels").and_then(|v| v.as_array()) {
let values: Vec<ConfigOptionValue> = available
.iter()
.filter_map(|m| {
let id = m.get("modelId").or_else(|| m.get("id")).and_then(|v| v.as_str())?;
let name = m.get("name").and_then(|v| v.as_str()).unwrap_or(id);
Some(ConfigOptionValue {
value: id.to_string(),
name: name.to_string(),
description: m.get("description").and_then(|v| v.as_str()).map(String::from),
})
})
.collect();
if !values.is_empty() {
options.push(ConfigOption {
id: "model".to_string(),
name: "Model".to_string(),
description: Some("AI model selection".to_string()),
category: Some("model".to_string()),
option_type: "enum".to_string(),
current_value: current.to_string(),
options: values,
});
}
}
}

if let Some(modes) = result.get("modes") {
let current = modes.get("currentModeId").and_then(|v| v.as_str()).unwrap_or("");
if let Some(available) = modes.get("availableModes").and_then(|v| v.as_array()) {
let values: Vec<ConfigOptionValue> = available
.iter()
.filter_map(|m| {
let id = m.get("id").and_then(|v| v.as_str())?;
let name = m.get("name").and_then(|v| v.as_str()).unwrap_or(id);
Some(ConfigOptionValue {
value: id.to_string(),
name: name.to_string(),
description: m.get("description").and_then(|v| v.as_str()).map(String::from),
})
})
.collect();
if !values.is_empty() {
options.push(ConfigOption {
id: "agent".to_string(),
name: "Agent".to_string(),
description: Some("Agent mode selection".to_string()),
category: Some("agent".to_string()),
option_type: "enum".to_string(),
current_value: current.to_string(),
options: values,
});
}
}
}

options
}

// --- ACP notification classification ---

#[derive(Debug)]
Expand All @@ -62,6 +163,7 @@ pub enum AcpEvent {
Thinking,
ToolStart { id: String, title: String },
ToolDone { id: String, title: String, status: String },
ConfigUpdate { options: Vec<ConfigOption> },
Status,
}

Expand Down Expand Up @@ -105,6 +207,10 @@ pub fn classify_notification(msg: &JsonRpcMessage) -> Option<AcpEvent> {
}
}
"plan" => Some(AcpEvent::Status),
"config_option_update" => {
let options = parse_config_options(update);
Some(AcpEvent::ConfigUpdate { options })
}
_ => None,
}
}
Loading
Loading