use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use axum::{
Json, Router,
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, patch, post},
};
use serde_json::json;
use tracing::{error, info};
use uuid::Uuid;
use crate::AppState;
use crate::acp::AcpClient;
use crate::acp::config_prefs;
use crate::agent::state::AgentSnapshot;
use crate::api::agents::load_agent;
use crate::models::session::{
AdoptSession, CreateSession, ExternalSessionResponse, RuntimeKind, Session, UpdateSession,
};
pub fn routes() -> Router<AppState> {
Router::new()
.route("/projects/{pid}/sessions", get(list_sessions).post(create_session))
.route("/sessions/{id}", patch(update_session).delete(delete_session))
.route("/sessions/{id}/cwd", get(get_session_cwd))
.route("/sessions/{id}/release", post(release_session))
.route("/sessions/{id}/messages", get(list_messages))
.route("/sessions/{id}/messages/sync", post(sync_messages))
.route("/sessions/external", get(list_external_sessions))
.route("/sessions/adopt", post(adopt_session))
}
async fn list_sessions(
State(state): State<AppState>,
Path(pid): Path<String>,
) -> impl IntoResponse {
let mut sessions: Vec<Session> =
sqlx::query_as("SELECT * FROM sessions WHERE project_id = ? ORDER BY created_at DESC")
.bind(&pid)
.fetch_all(&state.db)
.await
.unwrap();
let agent_map: HashMap<String, AgentSnapshot> = state
.engines
.list_sessions()
.await
.unwrap_or_default()
.into_iter()
.filter_map(|info| {
let kind =
crate::agent::state::AgentKind::from_str(info.agent_kind.as_deref().unwrap_or(""))?;
let state = crate::agent::state::AgentState::from_str(
info.agent_state.as_deref().unwrap_or(""),
)?;
let reason = info
.attention_reason
.as_deref()
.and_then(crate::agent::state::AttentionReason::from_str);
Some((
info.name,
AgentSnapshot {
agent_kind: kind,
agent_state: state,
attention_reason: reason,
agent_event: info.agent_event,
agent_nonce: info.agent_nonce,
},
))
})
.collect();
let alive_acp: std::collections::HashSet<String> =
state.acp_supervisor.snapshot().await.into_iter().map(|(id, _)| id).collect();
let screen_map = state.engines.watcher().snapshot().await;
for session in &mut sessions {
if session.runtime_kind == RuntimeKind::Acp {
session.acp_process_alive = alive_acp.contains(&session.id);
continue;
}
if let Some(ref engine_name) = session.tmux_session_name {
session.is_active = state.engines.is_active(session.runtime_kind, engine_name).await;
if let Some(snapshot) = agent_map.get(engine_name) {
session.agent_kind = Some(snapshot.agent_kind.as_str().to_string());
session.agent_state = Some(snapshot.agent_state.as_str().to_string());
session.attention_reason =
snapshot.attention_reason.map(|r| r.as_str().to_string());
session.agent_event = snapshot.agent_event.clone();
session.agent_nonce = snapshot.agent_nonce.clone();
}
if let Some(screen) = screen_map.get(engine_name) {
session.agent_kind = Some(screen.kind.as_str().to_string());
session.agent_state = Some(screen.state.as_str().to_string());
session.agent_detected = Some(screen.kind.as_str().to_string());
}
}
}
Json(json!(sessions))
}
async fn create_session(
State(state): State<AppState>,
Path(pid): Path<String>,
Json(req): Json<CreateSession>,
) -> impl IntoResponse {
let runtime_kind = req.runtime_kind.unwrap_or_default();
if runtime_kind == RuntimeKind::Acp {
let agent_id = match &req.agent_id {
Some(id) if !id.is_empty() => id.clone(),
_ => {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "agent_id is required for ACP sessions" })),
);
}
};
let agent = match load_agent(&state.db, &agent_id).await {
Some(a) => a,
None => {
return (StatusCode::NOT_FOUND, Json(json!({ "error": "agent not found" })));
}
};
let workspace_path = resolve_workspace_path(&req.workspace_path, &pid, &state).await;
let cwd = std::path::PathBuf::from(&workspace_path);
let acp_client = match AcpClient::spawn_and_connect(agent, cwd, &state.api_keys).await {
Ok(c) => Arc::new(c),
Err(e) => {
error!("ACP spawn failed: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("failed to spawn agent: {}", e) })),
);
}
};
let acp_session_id = acp_client.session_id().0.to_string();
let id = Uuid::new_v4().to_string();
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO sessions (id, project_id, workspace_path, name, tmux_session_name, hook_enabled, hook_status, created_at, runtime_kind, acp_session_id, agent_id) VALUES (?, ?, ?, ?, NULL, 0, NULL, ?, 'acp', ?, ?)",
)
.bind(&id)
.bind(&pid)
.bind(&workspace_path)
.bind(&req.name)
.bind(&now)
.bind(&acp_session_id)
.bind(&agent_id)
.execute(&state.db)
.await
.unwrap();
acp_client.attach_persistence(state.db.clone(), id.clone());
acp_client.attach_config_prefs(state.db.clone(), id.clone(), agent_id.clone());
acp_client.restore_config_prefs().await;
state.acp_supervisor.insert(id.clone(), acp_client).await;
info!(
"created ACP session: {} (agent: {}, acp_session_id: {})",
id, agent_id, acp_session_id
);
let session = Session {
id,
project_id: pid,
workspace_path,
name: req.name,
tmux_session_name: None,
hook_enabled: false,
hook_status: None,
created_at: now,
runtime_kind: RuntimeKind::Acp,
acp_session_id: Some(acp_session_id),
agent_id: Some(agent_id),
last_cwd: None,
is_active: true,
agent_kind: None,
agent_state: None,
attention_reason: None,
agent_event: None,
agent_nonce: None,
agent_detected: None,
acp_process_alive: false,
};
return (StatusCode::CREATED, Json(json!(session)));
}
if runtime_kind == RuntimeKind::Pty {
let workspace_path = resolve_workspace_path(&req.workspace_path, &pid, &state).await;
let id = Uuid::new_v4().to_string();
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO sessions (id, project_id, workspace_path, name, tmux_session_name, hook_enabled, hook_status, created_at, runtime_kind, acp_session_id) VALUES (?, ?, ?, ?, ?, 0, NULL, ?, 'pty', NULL)",
)
.bind(&id)
.bind(&pid)
.bind(&workspace_path)
.bind(&req.name)
.bind(&id)
.bind(&now)
.execute(&state.db)
.await
.unwrap();
info!("created pty session: {} (cwd: {})", id, workspace_path);
let engine_key = id.clone();
let session = Session {
id,
project_id: pid,
workspace_path,
name: req.name,
tmux_session_name: Some(engine_key),
hook_enabled: false,
hook_status: None,
created_at: now,
runtime_kind: RuntimeKind::Pty,
acp_session_id: None,
agent_id: None,
last_cwd: None,
is_active: false,
agent_kind: None,
agent_state: None,
attention_reason: None,
agent_event: None,
agent_nonce: None,
agent_detected: None,
acp_process_alive: false,
};
return (StatusCode::CREATED, Json(json!(session)));
}
let workspace_path = resolve_workspace_path(&req.workspace_path, &pid, &state).await;
let id = Uuid::new_v4().to_string();
let engine_name = format!("lt_{}", &id[..8]);
let now = chrono::Utc::now().to_rfc3339();
let hook_enabled = match state
.engines
.create_session(RuntimeKind::Tmux, &engine_name, &workspace_path, req.command.as_deref())
.await
{
Ok(injected) => {
info!("created multiplexer session: {} (cwd: {})", engine_name, workspace_path);
injected && req.command.is_some()
}
Err(e) => {
error!("failed to create multiplexer session: {}", e);
false
}
};
sqlx::query(
"INSERT INTO sessions (id, project_id, workspace_path, name, tmux_session_name, hook_enabled, hook_status, created_at, runtime_kind, acp_session_id) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, 'tmux', NULL)",
)
.bind(&id)
.bind(&pid)
.bind(&workspace_path)
.bind(&req.name)
.bind(&engine_name)
.bind(hook_enabled as i32)
.bind(&now)
.execute(&state.db)
.await
.unwrap();
if let Err(e) = state.engines.track_session(RuntimeKind::Tmux, &engine_name).await {
error!("failed to ensure activity tracking for new session {}: {}", engine_name, e);
}
let session = Session {
id,
project_id: pid,
workspace_path,
name: req.name,
tmux_session_name: Some(engine_name.clone()),
hook_enabled,
hook_status: None,
created_at: now,
runtime_kind: RuntimeKind::Tmux,
acp_session_id: None,
agent_id: None,
last_cwd: None,
is_active: false,
agent_kind: None,
agent_state: None,
attention_reason: None,
agent_event: None,
agent_nonce: None,
agent_detected: None,
acp_process_alive: false,
};
(StatusCode::CREATED, Json(json!(session)))
}
async fn update_session(
State(state): State<AppState>,
Path(id): Path<String>,
Json(req): Json<UpdateSession>,
) -> impl IntoResponse {
let result = sqlx::query("UPDATE sessions SET name = COALESCE(?, name) WHERE id = ?")
.bind(req.name)
.bind(&id)
.execute(&state.db)
.await
.unwrap();
if result.rows_affected() == 0 {
return (StatusCode::NOT_FOUND, Json(json!({ "error": "not found" })));
}
let session: Session = sqlx::query_as("SELECT * FROM sessions WHERE id = ?")
.bind(&id)
.fetch_one(&state.db)
.await
.unwrap();
(StatusCode::OK, Json(json!(session)))
}
pub async fn cleanup_session_runtime(
state: &AppState,
session_id: &str,
engine_name: Option<&str>,
runtime_kind: &str,
) {
match runtime_kind {
"acp" => {
if let Some(client) = state.acp_supervisor.dispose(session_id).await {
client.shutdown().await;
}
}
"pty" => {
if let Some(name) = engine_name
&& let Err(e) = state.engines.kill_session(RuntimeKind::Pty, name).await
{
error!("failed to kill pty session {}: {}", name, e);
}
}
_ => {
if let Some(name) = engine_name {
state.engines.untrack_session(RuntimeKind::Tmux, name).await;
if let Err(e) = state.engines.kill_session(RuntimeKind::Tmux, name).await {
error!("failed to kill multiplexer session {}: {}", name, e);
}
}
}
}
}
async fn delete_session(
State(state): State<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
let row: Option<(Option<String>, String)> =
sqlx::query_as("SELECT tmux_session_name, runtime_kind FROM sessions WHERE id = ?")
.bind(&id)
.fetch_optional(&state.db)
.await
.ok()
.flatten();
let result = sqlx::query("DELETE FROM sessions WHERE id = ?")
.bind(&id)
.execute(&state.db)
.await
.unwrap();
if result.rows_affected() == 0 {
return (StatusCode::NOT_FOUND, Json(json!({ "error": "not found" })));
}
if let Some((engine_name, runtime_kind)) = row {
cleanup_session_runtime(&state, &id, engine_name.as_deref(), &runtime_kind).await;
}
let _ = config_prefs::clear_session_configs(&state.db, &id).await;
(StatusCode::OK, Json(json!({ "ok": true })))
}
async fn release_session(
State(state): State<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
let runtime_kind: Option<String> =
sqlx::query_scalar("SELECT runtime_kind FROM sessions WHERE id = ?")
.bind(&id)
.fetch_optional(&state.db)
.await
.ok()
.flatten();
match runtime_kind.as_deref() {
Some("acp") => {
if let Some(client) = state.acp_supervisor.dispose(&id).await {
client.shutdown().await;
}
(StatusCode::OK, Json(json!({ "ok": true })))
}
Some(_) => {
(StatusCode::BAD_REQUEST, Json(json!({ "error": "only acp sessions can be released" })))
}
None => (StatusCode::NOT_FOUND, Json(json!({ "error": "not found" }))),
}
}
async fn get_session_cwd(
State(state): State<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
let row: Option<(String, String, Option<String>)> = sqlx::query_as(
"SELECT runtime_kind, tmux_session_name, workspace_path FROM sessions WHERE id = ?",
)
.bind(&id)
.fetch_optional(&state.db)
.await
.ok()
.flatten();
let Some((runtime_kind, engine_name, workspace_path_opt)) = row else {
return (StatusCode::NOT_FOUND, Json(json!({ "error": "session not found" })));
};
let workspace_path = workspace_path_opt
.unwrap_or_else(|| std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()));
if runtime_kind != "tmux" || engine_name.is_empty() {
return (StatusCode::OK, Json(json!({ "cwd": workspace_path })));
}
let cwd = match state.engines.current_cwd(RuntimeKind::Tmux, &engine_name).await {
Ok(cwd) => cwd,
Err(e) => {
error!("pane_cwd failed for {}: {}", engine_name, e);
workspace_path
}
};
(StatusCode::OK, Json(json!({ "cwd": crate::fs::display_path_str(&cwd) })))
}
const MESSAGES_PAGE_DEFAULT_LIMIT: usize = 100;
const MESSAGES_PAGE_MAX_LIMIT: usize = 500;
const MESSAGES_PAGE_MAX_BYTES: usize = 2 * 1024 * 1024;
#[derive(serde::Deserialize)]
struct ListMessagesQuery {
before: Option<String>,
limit: Option<usize>,
}
async fn list_messages(
State(state): State<AppState>,
Path(id): Path<String>,
Query(q): Query<ListMessagesQuery>,
) -> impl IntoResponse {
let before = match q.before.as_deref() {
Some(raw) => match crate::acp::chat_persistence::MessageCursor::parse(raw) {
Some(c) => Some(c),
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "malformed before cursor" })),
);
}
},
None => None,
};
let limit = q.limit.unwrap_or(MESSAGES_PAGE_DEFAULT_LIMIT).min(MESSAGES_PAGE_MAX_LIMIT);
match crate::acp::chat_persistence::list_messages_page(
&state.db,
&id,
before.as_ref(),
limit,
MESSAGES_PAGE_MAX_BYTES,
)
.await
{
Ok(page) => {
let messages: Vec<serde_json::Value> = page
.rows
.into_iter()
.map(|(role, text, created_at, msg_id, blocks, status, last_seq)| {
json!({
"id": msg_id,
"role": role,
"text": text,
"createdAt": created_at,
"blocks": blocks,
"status": status,
"lastSeq": last_seq,
})
})
.collect();
let next_cursor = page.next_cursor.map(|c| c.encode());
(
StatusCode::OK,
Json(json!({
"messages": messages,
"hasMore": next_cursor.is_some(),
"nextCursor": next_cursor,
})),
)
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() }))),
}
}
async fn sync_messages(
State(state): State<AppState>,
Path(id): Path<String>,
Json(body): Json<SyncMessagesRequest>,
) -> impl IntoResponse {
let rows: Vec<crate::acp::chat_persistence::SyncMessageInput> = body
.messages
.into_iter()
.filter(|m| m.role == "user" || m.role == "assistant")
.map(|m| crate::acp::chat_persistence::SyncMessageInput {
id: m.id,
role: m.role,
text: m.text,
blocks: m.blocks,
})
.collect();
match crate::acp::chat_persistence::sync_messages(&state.db, &id, &rows).await {
Ok(()) => (StatusCode::OK, Json(json!({ "ok": true }))),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() }))),
}
}
#[derive(serde::Deserialize)]
struct SyncMessagesRequest {
messages: Vec<SyncMessage>,
}
#[derive(serde::Deserialize)]
struct SyncMessage {
#[serde(default)]
id: Option<String>,
role: String,
text: String,
#[serde(default)]
blocks: Option<String>,
}
async fn resolve_workspace_path(req_path: &str, project_id: &str, state: &AppState) -> String {
let raw = if req_path.is_empty() {
let project_path: Option<(String,)> =
sqlx::query_as("SELECT path FROM projects WHERE id = ?")
.bind(project_id)
.fetch_optional(&state.db)
.await
.ok()
.flatten();
project_path
.map(|(p,)| p)
.unwrap_or_else(|| std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()))
} else {
req_path.to_string()
};
let expanded = if raw == "~" || raw.starts_with("~/") {
let home = std::env::var("HOME").unwrap_or_else(|_| "/".into());
raw.replacen('~', &home, 1)
} else {
raw
};
if std::path::Path::new(&expanded).exists() {
expanded
} else {
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string())
}
}
async fn list_external_sessions(State(state): State<AppState>) -> impl IntoResponse {
let mux_sessions = match state.engines.list_sessions().await {
Ok(s) => s,
Err(e) => {
error!("list_external_sessions: multiplexer error: {}", e);
return (StatusCode::OK, Json(json!({ "sessions": [] })));
}
};
let recorded: Vec<(String,)> = sqlx::query_as(
"SELECT tmux_session_name FROM sessions WHERE tmux_session_name IS NOT NULL",
)
.fetch_all(&state.db)
.await
.unwrap_or_default();
let recorded_names: HashSet<String> = recorded.into_iter().map(|(n,)| n).collect();
let external: Vec<_> =
mux_sessions.into_iter().filter(|s| !recorded_names.contains(&s.name)).collect();
let screen_map = state.engines.watcher().snapshot().await;
let mut result = Vec::with_capacity(external.len());
for s in external {
let screen = screen_map.get(&s.name);
result.push(ExternalSessionResponse {
agent_kind: screen.map(|sc| sc.kind.as_str().to_string()).or(s.agent_kind),
agent_state: screen.map(|sc| sc.state.as_str().to_string()).or(s.agent_state),
name: s.name,
attached: s.attached,
windows: s.windows,
created: s.created,
cwd: s.cwd.map(|c| crate::fs::display_path_str(&c)),
attention_reason: s.attention_reason,
agent_event: s.agent_event,
agent_nonce: s.agent_nonce,
});
}
(StatusCode::OK, Json(json!({ "sessions": result })))
}
async fn adopt_session(
State(state): State<AppState>,
Json(req): Json<AdoptSession>,
) -> impl IntoResponse {
if !state.engines.session_exists(RuntimeKind::Tmux, &req.external_name).await {
return (StatusCode::NOT_FOUND, Json(json!({ "error": "multiplexer session not found" })));
}
let project_exists: bool = sqlx::query_scalar("SELECT COUNT(*) > 0 FROM projects WHERE id = ?")
.bind(&req.project_id)
.fetch_one(&state.db)
.await
.unwrap_or(false);
if !project_exists {
return (StatusCode::NOT_FOUND, Json(json!({ "error": "project not found" })));
}
let already_adopted: bool =
sqlx::query_scalar("SELECT COUNT(*) > 0 FROM sessions WHERE tmux_session_name = ?")
.bind(&req.external_name)
.fetch_one(&state.db)
.await
.unwrap_or(false);
if already_adopted {
return (StatusCode::CONFLICT, Json(json!({ "error": "session already adopted" })));
}
let engine_name = req.external_name.clone();
let workspace_path = state
.engines
.current_cwd(RuntimeKind::Tmux, &engine_name)
.await
.map(|c| crate::fs::display_path_str(&c))
.unwrap_or_else(|_| std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()));
let id = Uuid::new_v4().to_string();
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO sessions (id, project_id, workspace_path, name, tmux_session_name, hook_enabled, hook_status, created_at, runtime_kind, acp_session_id) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, 'tmux', NULL)",
)
.bind(&id)
.bind(&req.project_id)
.bind(&workspace_path)
.bind(&engine_name)
.bind(&engine_name)
.bind(false as i32)
.bind(&now)
.execute(&state.db)
.await
.unwrap();
if let Err(e) = state.engines.track_session(RuntimeKind::Tmux, &engine_name).await {
error!("failed to ensure activity tracking for adopted session {}: {}", engine_name, e);
}
let session = Session {
id,
project_id: req.project_id,
workspace_path,
name: Some(engine_name.clone()),
tmux_session_name: Some(engine_name),
hook_enabled: false,
hook_status: None,
created_at: now,
runtime_kind: RuntimeKind::Tmux,
acp_session_id: None,
agent_id: None,
last_cwd: None,
is_active: false,
agent_kind: None,
agent_state: None,
attention_reason: None,
agent_event: None,
agent_nonce: None,
agent_detected: None,
acp_process_alive: false,
};
(StatusCode::CREATED, Json(json!(session)))
}