use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use hotl_engine::{AskReply, EngineEvent, Outcome, SessionHandle};
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
use tokio::sync::{mpsc, oneshot, Mutex};
pub const UPDATE_SCHEMA_VERSION: u32 = 1;
pub const PROTOCOL_VERSION: &str = "0.1";
type Writer = Arc<Mutex<Box<dyn AsyncWrite + Send + Unpin>>>;
type Pending = Arc<std::sync::Mutex<HashMap<u64, oneshot::Sender<AskReply>>>>;
type PendingQuestions =
Arc<std::sync::Mutex<HashMap<u64, oneshot::Sender<hotl_types::QuestionAnswer>>>>;
fn ask_reply_from_result(result: Option<&Value>) -> AskReply {
let Some(r) = result else {
return AskReply::Deny { message: None };
};
if let Some(content) = r.get("respond").and_then(Value::as_str) {
return AskReply::Respond {
content: content.to_string(),
};
}
if let Some(input) = r.get("input") {
return AskReply::AllowEdited {
input: input.clone(),
};
}
if r.get("allow").and_then(Value::as_bool) == Some(true) {
return AskReply::Allow;
}
AskReply::Deny {
message: r.get("message").and_then(Value::as_str).map(String::from),
}
}
fn question_answer_from_result(result: Option<&Value>) -> hotl_types::QuestionAnswer {
let Some(r) = result else {
return hotl_types::QuestionAnswer::NoHuman;
};
if let Some(text) = r.get("freeText").and_then(Value::as_str) {
return hotl_types::QuestionAnswer::FreeText(text.to_string());
}
if let Some(arr) = r.get("selected").and_then(Value::as_array) {
let labels: Vec<String> = arr
.iter()
.filter_map(Value::as_str)
.map(String::from)
.collect();
if !labels.is_empty() {
return hotl_types::QuestionAnswer::Selected(labels);
}
}
hotl_types::QuestionAnswer::NoHuman
}
type PendingPrompt = Arc<std::sync::Mutex<VecDeque<Value>>>;
pub enum SessionSpec {
New {
name: Option<String>,
},
Load {
session_id: String,
name: Option<String>,
},
}
pub struct SessionOpen {
pub handle: SessionHandle,
pub name: Option<String>,
pub mode: String,
}
pub type SessionFactory = Box<dyn FnMut(SessionSpec) -> Result<SessionOpen, String> + Send>;
#[derive(Debug, Clone)]
pub struct SkillInfo {
pub name: String,
pub description: String,
}
#[derive(Debug, Clone)]
pub struct ServerInfo {
pub skills: Vec<SkillInfo>,
pub default_mode: String,
pub context_window: u64,
pub model: String,
}
pub async fn serve(
read: impl AsyncRead + Send + Unpin + 'static,
write: impl AsyncWrite + Send + Unpin + 'static,
mut factory: SessionFactory,
info: ServerInfo,
) {
let writer: Writer = Arc::new(Mutex::new(Box::new(write)));
let pending: Pending = Arc::new(std::sync::Mutex::new(HashMap::new()));
let pending_questions: PendingQuestions = Arc::new(std::sync::Mutex::new(HashMap::new()));
let pending_prompt: PendingPrompt = Arc::new(std::sync::Mutex::new(VecDeque::new()));
let mut next_id: u64 = 1;
let mut session: Option<SessionState> = None;
let mut lines = BufReader::new(read).lines();
while let Ok(Some(line)) = lines.next_line().await {
let Ok(msg) = serde_json::from_str::<Value>(&line) else {
continue; };
if msg.get("method").is_none() {
if let Some(id) = msg.get("id").and_then(Value::as_u64) {
if let Some(reply) = pending_questions
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&id)
{
let _ = reply.send(question_answer_from_result(msg.get("result")));
} else if let Some(reply) = pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&id)
{
let _ = reply.send(ask_reply_from_result(msg.get("result")));
}
}
continue;
}
handle_request(
&msg,
&writer,
&mut factory,
&mut session,
&pending,
&pending_questions,
&pending_prompt,
&mut next_id,
&info,
)
.await;
}
}
struct SessionState {
id: String,
handle: SessionHandle,
drain: tokio::task::JoinHandle<()>,
}
#[allow(clippy::too_many_arguments)]
async fn handle_request(
msg: &Value,
writer: &Writer,
factory: &mut SessionFactory,
session: &mut Option<SessionState>,
pending: &Pending,
pending_questions: &PendingQuestions,
pending_prompt: &PendingPrompt,
next_id: &mut u64,
info: &ServerInfo,
) {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
match msg.get("method").and_then(Value::as_str).unwrap_or("") {
"initialize" => {
let skills: Vec<Value> = info
.skills
.iter()
.map(|s| json!({"name": s.name, "description": s.description}))
.collect();
reply_ok(
writer,
id,
json!({
"protocolVersion": PROTOCOL_VERSION,
"schemaVersion": UPDATE_SCHEMA_VERSION,
"skills": skills,
"defaultMode": info.default_mode,
"contextWindow": info.context_window,
}),
)
.await;
}
method @ ("session/new" | "session/load") => {
let name = match msg.pointer("/params/name") {
None | Some(Value::Null) => None,
Some(v) => match v.as_str().and_then(hotl_types::normalize_session_name) {
Some(n) => Some(n),
None => {
return reply_err(
writer,
id,
"params.name must be 1–64 chars after trimming",
)
.await
}
},
};
let spec = if method == "session/load" {
match msg.pointer("/params/sessionId").and_then(Value::as_str) {
Some(sid) => SessionSpec::Load {
session_id: sid.to_string(),
name,
},
None => {
return reply_err(writer, id, "session/load requires params.sessionId")
.await
}
}
} else {
SessionSpec::New { name }
};
match factory(spec) {
Ok(open) => {
let mode = open.mode;
if let Some(old) = session.take() {
old.handle.interrupt();
old.drain.abort();
pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clear();
pending_questions
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clear();
pending_prompt
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clear();
}
let state = start_session(
open.handle,
writer.clone(),
pending.clone(),
pending_questions.clone(),
pending_prompt.clone(),
next_id,
info.model.clone(),
);
if method == "session/load" {
state.handle.continue_turn().await;
}
let sid = state.id.clone();
*session = Some(state);
reply_ok(
writer,
id,
json!({"sessionId": sid, "name": open.name, "mode": mode}),
)
.await;
}
Err(e) => reply_err(writer, id, &e).await,
}
}
"session/prompt" => {
let Some(state) = session.as_ref() else {
return reply_err(writer, id, "no session — call session/new first").await;
};
let Some(text) = msg.pointer("/params/text").and_then(Value::as_str) else {
return reply_err(writer, id, "session/prompt requires params.text").await;
};
pending_prompt
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push_back(id);
state.handle.prompt(text.to_string()).await;
}
"session/rename" => {
let Some(state) = session.as_ref() else {
return reply_err(writer, id, "no session — call session/new first").await;
};
let Some(name) = msg
.pointer("/params/name")
.and_then(Value::as_str)
.and_then(hotl_types::normalize_session_name)
else {
return reply_err(
writer,
id,
"session/rename requires params.name (1–64 chars after trimming)",
)
.await;
};
state.handle.rename(name).await;
reply_ok(writer, id, json!({"ok": true})).await;
}
"session/set_mode" => {
let Some(state) = session.as_ref() else {
return reply_err(writer, id, "no session — call session/new first").await;
};
let Some(mode) = msg
.pointer("/params/mode")
.and_then(Value::as_str)
.and_then(hotl_tools::rules::PermissionMode::from_str)
else {
return reply_err(
writer,
id,
"session/set_mode requires params.mode (ask | auto | plan | dontask)",
)
.await;
};
let effective = hotl_tools::rules::enforced_mode(mode);
state.handle.set_mode(mode).await;
let session_id = state.id.clone();
reply_ok(writer, id, json!({"ok": true, "mode": effective.as_str()})).await;
notify(
writer,
&session_id,
json!({"type": "mode_changed", "mode": effective.as_str()}),
)
.await;
}
"session/steer" => {
let Some(state) = session.as_ref() else {
return reply_err(writer, id, "no session — call session/new first").await;
};
let Some(text) = msg.pointer("/params/text").and_then(Value::as_str) else {
return reply_err(writer, id, "session/steer requires params.text").await;
};
state.handle.steer(text.to_string()).await;
reply_ok(writer, id, json!({"queued": true})).await;
}
"session/cancel" => {
if let Some(state) = session.as_ref() {
state.handle.interrupt();
}
reply_ok(writer, id, json!({"cancelled": true})).await;
}
other => reply_err(writer, id, &format!("unknown method `{other}`")).await,
}
}
#[allow(clippy::too_many_arguments)]
fn start_session(
mut handle: SessionHandle,
writer: Writer,
pending: Pending,
pending_questions: PendingQuestions,
pending_prompt: PendingPrompt,
next_id: &mut u64,
model: String,
) -> SessionState {
let id = format!("acp-{}", *next_id);
let req_id_seed = *next_id * 1_000_000;
*next_id += 1;
let events = std::mem::replace(&mut handle.events, mpsc::channel(1).1);
let sid = id.clone();
let drain = tokio::spawn(drain_events(
events,
writer,
pending,
pending_questions,
pending_prompt,
sid,
req_id_seed,
model,
));
SessionState { id, handle, drain }
}
#[allow(clippy::too_many_arguments)]
async fn drain_events(
mut events: mpsc::Receiver<EngineEvent>,
writer: Writer,
pending: Pending,
pending_questions: PendingQuestions,
pending_prompt: PendingPrompt,
session_id: String,
mut req_id: u64,
model: String,
) {
while let Some(event) = events.recv().await {
match event {
EngineEvent::Ask {
summary,
protected_why,
reply,
} => {
req_id += 1;
pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(req_id, reply);
send(&writer, &json!({
"jsonrpc": "2.0", "id": req_id, "method": "session/request_permission",
"params": {"sessionId": session_id, "summary": summary, "protectedWhy": protected_why},
}))
.await;
}
EngineEvent::Question {
question, reply, ..
} => {
req_id += 1;
pending_questions
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(req_id, reply);
send(
&writer,
&json!({
"jsonrpc": "2.0", "id": req_id, "method": "session/request_question",
"params": {
"sessionId": session_id,
"header": question.header,
"prompt": question.prompt,
"options": question.options,
"multi": question.multi,
},
}),
)
.await;
}
EngineEvent::TurnDone { outcome, usage } => {
pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.retain(|_, tx| !tx.is_closed());
pending_questions
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.retain(|_, tx| !tx.is_closed());
notify(
&writer,
&session_id,
json!({"type": "turn_done", "outcome": outcome_tag(&outcome)}),
)
.await;
let prompt_id = pending_prompt
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.pop_front();
if let Some(id) = prompt_id {
reply_ok(
&writer,
id,
json!({
"schemaVersion": UPDATE_SCHEMA_VERSION,
"outcome": outcome_tag(&outcome),
"usage": crate::wire::usage_frame(&model, &usage),
}),
)
.await;
}
}
other => {
if let Some(update) = update_payload(&other) {
notify(&writer, &session_id, update).await;
}
}
}
}
}
pub(crate) fn update_payload(event: &EngineEvent) -> Option<Value> {
crate::wire::update_frame(event)
}
pub(crate) fn outcome_tag(outcome: &Outcome) -> Value {
crate::wire::outcome_frame(outcome)
}
async fn notify(writer: &Writer, session_id: &str, update: Value) {
send(writer, &json!({
"jsonrpc": "2.0", "method": "session/update",
"params": {"schemaVersion": UPDATE_SCHEMA_VERSION, "sessionId": session_id, "update": update},
}))
.await;
}
async fn reply_ok(writer: &Writer, id: Value, result: Value) {
send(
writer,
&json!({"jsonrpc": "2.0", "id": id, "result": result}),
)
.await;
}
async fn reply_err(writer: &Writer, id: Value, message: &str) {
send(
writer,
&json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32600, "message": message}}),
)
.await;
}
async fn send(writer: &Writer, msg: &Value) {
let mut line = msg.to_string();
line.push('\n');
let mut w = writer.lock().await;
let _ = w.write_all(line.as_bytes()).await;
let _ = w.flush().await;
}