use std::net::SocketAddr;
use std::path::Path;
use axum::body::Bytes;
use axum::extract::{ConnectInfo, Query, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::Json;
use serde::Deserialize;
use serde_json::json;
use crate::scheduler;
use crate::state::SharedState;
use crate::tmux;
use crate::transport;
const MAX_DESCRIPTION_LEN: usize = 200;
const NPUB_DISPLAY_LEN: usize = 16;
const CONNECT_TIMEOUT_SECS: u64 = 10;
const MAX_TASK_RUNS_RETURNED: usize = 50;
pub(crate) fn normalize_optional_string(input: Option<String>) -> Option<String> {
input
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
pub(crate) fn extract_project_description(project_dir: &str) -> Option<String> {
let dir = Path::new(project_dir);
if let Ok(contents) = std::fs::read_to_string(dir.join("Cargo.toml")) {
for line in contents.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("description") {
let rest = rest.trim_start();
if let Some(rest) = rest.strip_prefix('=') {
let val = rest.trim().trim_matches('"');
if !val.is_empty() {
return Some(val.to_string());
}
}
}
}
}
if let Ok(contents) = std::fs::read_to_string(dir.join("package.json")) {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&contents) {
if let Some(desc) = json["description"].as_str() {
if !desc.is_empty() {
return Some(desc.to_string());
}
}
}
}
if let Ok(contents) = std::fs::read_to_string(dir.join("README.md")) {
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let truncated = if trimmed.len() > MAX_DESCRIPTION_LEN {
format!("{}...", &trimmed[..MAX_DESCRIPTION_LEN])
} else {
trimmed.to_string()
};
return Some(truncated);
}
}
None
}
pub async fn get_session(
State(state): State<SharedState>,
axum::extract::Path(name): axum::extract::Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
let proto = state.protocol.read().await;
match proto.sessions.get(&name) {
Some(s) => {
let stale = s.metadata.is_stale();
(
StatusCode::OK,
Json(json!({
"id": s.id,
"pane": s.pane,
"origin": s.origin.label(),
"vim_mode": s.metadata.vim_mode,
"project_dir": s.metadata.project_dir,
"role": s.metadata.role,
"bulletin": s.metadata.bulletin,
"networked": s.metadata.networked,
"worktree": s.metadata.worktree,
"model": s.metadata.model,
"effort": s.metadata.effort,
"last_metadata_update": s.metadata.last_metadata_update,
"stale": stale,
"backend_session_id": s.metadata.backend_session_id,
"backend": s.metadata.backend,
"reminder": s.metadata.reminder,
"prompt": s.metadata.prompt,
"iteration": s.metadata.iteration,
"iteration_log": s.metadata.iteration_log,
"last_iteration_at": s.metadata.last_iteration_at,
"worktree_present": s.metadata.worktree_present,
})),
)
}
None => (
StatusCode::NOT_FOUND,
Json(json!({"error": format!("session '{}' not found", name)})),
),
}
}
pub async fn status(State(state): State<SharedState>) -> Json<serde_json::Value> {
let proto = state.protocol.read().await;
let nodes = state.nodes.read().await;
let transports = state.transports().await;
let sessions_list: Vec<_> = proto
.sessions
.values()
.map(|s| {
let stale = s.metadata.is_stale();
json!({
"id": s.id,
"pane": s.pane,
"origin": s.origin.label(),
"vim_mode": s.metadata.vim_mode,
"project_dir": s.metadata.project_dir,
"role": s.metadata.role,
"bulletin": s.metadata.bulletin,
"networked": s.metadata.networked,
"worktree": s.metadata.worktree,
"model": s.metadata.model,
"effort": s.metadata.effort,
"last_metadata_update": s.metadata.last_metadata_update,
"stale": stale,
"backend_session_id": s.metadata.backend_session_id,
"backend": s.metadata.backend,
"reminder": s.metadata.reminder,
"prompt": s.metadata.prompt,
"iteration": s.metadata.iteration,
"iteration_log": s.metadata.iteration_log,
"last_iteration_at": s.metadata.last_iteration_at,
"worktree_present": s.metadata.worktree_present,
})
})
.collect();
drop(proto);
let nodes_list: Vec<_> = nodes
.values()
.map(|p| {
json!({
"name": p.name,
"daemon_id": p.daemon_id,
})
})
.collect();
let transports_list: Vec<_> = transports
.values()
.map(|t| {
json!({
"name": t.transport_name(),
"ready": t.is_ready(),
"endpoint_id": t.endpoint_id(),
})
})
.collect();
let first_transport = transports.values().next();
let compat_transport = first_transport.map(|t| t.transport_name());
let compat_endpoint_id = first_transport.and_then(|t| t.endpoint_id());
let assistant_panes: Vec<_> = state
.cached_assistant_panes()
.await
.into_iter()
.map(|p| json!({ "pane_id": p.pane_id, "session": p.session_name }))
.collect();
Json(json!({
"version": env!("CARGO_PKG_VERSION"),
"daemon": state.config.name,
"daemon_id": state.config.npub,
"port": state.config.port,
"transports": transports_list,
"transport": compat_transport,
"endpoint_id": compat_endpoint_id,
"sessions": sessions_list,
"nodes": nodes_list,
"assistant_panes": assistant_panes,
}))
}
#[derive(Debug, Deserialize, Default)]
pub struct TicketQuery {
#[serde(default, deserialize_with = "deserialize_string_or_seq")]
relay: Vec<String>,
}
fn deserialize_string_or_seq<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
struct StringOrSeq;
impl<'de> de::Visitor<'de> for StringOrSeq {
type Value = Vec<String>;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a string or sequence of strings")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Vec<String>, E> {
Ok(vec![v.to_string()])
}
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<String>, A::Error> {
let mut v = Vec::new();
while let Some(s) = seq.next_element()? {
v.push(s);
}
Ok(v)
}
}
deserializer.deserialize_any(StringOrSeq)
}
pub async fn ticket(
State(state): State<SharedState>,
Query(query): Query<TicketQuery>,
) -> (StatusCode, Json<serde_json::Value>) {
let t = if !query.relay.is_empty() {
match crate::nostr_transport::ensure_active(&state, query.relay).await {
Ok(t) => t,
Err(e) => {
let msg = format!("failed to start nostr transport: {e}");
tracing::error!("{msg}");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": msg })),
);
}
}
} else {
let Some(t) = state.transport_by_name("nostr").await else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": "nostr transport is not active" })),
);
};
t
};
match t.ticket_string().await {
Some(ticket) => (
StatusCode::OK,
Json(json!({
"ticket": ticket,
"endpoint_id": t.endpoint_id(),
"transport": "nostr",
})),
),
None => (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": "nostr transport not ready" })),
),
}
}
#[derive(Debug, Deserialize, Default)]
pub struct RegenerateQuery {
confirm: Option<bool>,
}
pub async fn regenerate_ticket(
State(state): State<SharedState>,
Query(query): Query<RegenerateQuery>,
) -> (StatusCode, Json<serde_json::Value>) {
let Some(t) = state.transport_by_name("nostr").await else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": "nostr transport is not active" })),
);
};
if query.confirm != Some(true) {
return (
StatusCode::OK,
Json(json!({
"warning": "This will destroy your nostr identity (nsec). All nodes must re-connect. Add ?confirm=true to proceed.",
"transport": "nostr",
})),
);
}
match t
.regenerate(&state.config.config_dir, &state.config.data_dir)
.await
{
Ok(ticket) => (
StatusCode::OK,
Json(json!({ "ticket": ticket, "transport": "nostr" })),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
),
}
}
#[derive(Debug, Deserialize)]
pub struct ConnectBody {
ticket: String,
name: Option<String>,
}
pub async fn connect(
State(state): State<SharedState>,
Json(body): Json<ConnectBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let nprofile_part = body
.ticket
.split_once('#')
.map_or(body.ticket.as_str(), |(left, _)| left);
if !nprofile_part.starts_with("nprofile1") {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "ticket must be an nprofile1 string" })),
);
}
tracing::info!(
"connect request received (ticket len={})",
body.ticket.len()
);
let peer_npub = extract_npub(&body.ticket);
if let Some(ref npub) = peer_npub {
let node_name = body
.name
.as_deref()
.unwrap_or(&npub[..NPUB_DISPLAY_LEN.min(npub.len())]);
if let Err(existing) = state.try_add_node(npub, node_name) {
let msg = format!("already connected to this daemon as '{existing}'");
tracing::info!("connect rejected: {msg}");
return (StatusCode::CONFLICT, Json(json!({ "error": msg })));
}
}
let t = if let Some(t) = state.transport_by_name("nostr").await {
t
} else {
let relays = extract_nprofile_relays(&body.ticket);
match crate::nostr_transport::ensure_active(&state, relays).await {
Ok(t) => t,
Err(e) => {
let msg = format!("failed to start nostr transport: {e}");
tracing::error!("{msg}");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": msg })),
);
}
}
};
let connect_fut = t.connect(&body.ticket, state.clone(), true);
match tokio::time::timeout(
std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
connect_fut,
)
.await
{
Err(_) => {
tracing::warn!("connect timed out after 10s waiting for peer");
return (
StatusCode::GATEWAY_TIMEOUT,
Json(json!({ "error": "connect timed out waiting for peer" })),
);
}
Ok(Err(e)) => {
tracing::error!("connect failed: {e}");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("connect failed: {e}") })),
);
}
Ok(Ok(())) => {}
}
if let Err(e) = crate::persistence::add_connection(
&state.config.data_dir,
&body.ticket,
body.name.as_deref(),
peer_npub.as_deref(),
) {
tracing::warn!("failed to persist connection: {e}");
}
tracing::info!("node connected successfully via nostr");
(
StatusCode::OK,
Json(json!({ "status": "connected", "transport": "nostr" })),
)
}
fn strip_ticket_secret(ticket: &str) -> &str {
ticket.split_once('#').map_or(ticket, |(left, _)| left)
}
fn extract_nprofile_relays(ticket: &str) -> Vec<String> {
use nostr_sdk::prelude::*;
Nip19Profile::from_bech32(strip_ticket_secret(ticket))
.map(|p| p.relays.into_iter().map(|r| r.to_string()).collect())
.unwrap_or_default()
}
pub fn extract_npub(ticket: &str) -> Option<String> {
use nostr_sdk::prelude::*;
Nip19Profile::from_bech32(strip_ticket_secret(ticket))
.ok()
.and_then(|p| p.public_key.to_bech32().ok())
}
#[derive(Debug, Deserialize)]
pub struct RegisterBody {
id: String,
pane: Option<String>,
#[serde(default)]
vim_mode: bool,
project_dir: Option<String>,
role: Option<String>,
bulletin: Option<String>,
#[serde(default)]
networked: Option<bool>,
#[serde(alias = "claude_session_id")]
backend_session_id: Option<String>,
#[serde(default)]
backend: Option<String>,
#[serde(default)]
reminder: Option<String>,
}
fn parse_tcp_inode_for_local(tcp_table: &str, needle: &str) -> Option<u64> {
for line in tcp_table.lines().skip(1) {
let cols: Vec<&str> = line.split_whitespace().collect();
if cols.len() >= 10 && cols[1] == needle {
return cols[9].parse().ok();
}
}
None
}
fn needle_for_loopback_peer(peer: SocketAddr) -> Option<String> {
let std::net::IpAddr::V4(v4) = peer.ip() else {
return None;
};
let o = v4.octets();
Some(format!(
"{:02X}{:02X}{:02X}{:02X}:{:04X}",
o[3],
o[2],
o[1],
o[0],
peer.port()
))
}
#[cfg(target_os = "linux")]
fn resolve_loopback_peer(peer: SocketAddr) -> Option<String> {
let needle = needle_for_loopback_peer(peer)?;
let tcp_table = std::fs::read_to_string("/proc/net/tcp").ok()?;
let inode = parse_tcp_inode_for_local(&tcp_table, &needle)?;
let socket_target = format!("socket:[{inode}]");
for entry in std::fs::read_dir("/proc").ok()?.flatten() {
let name = entry.file_name();
let pid = match name.to_str() {
Some(s) if s.chars().all(|c| c.is_ascii_digit()) => s,
_ => continue,
};
let fd_dir = entry.path().join("fd");
let Ok(fds) = std::fs::read_dir(&fd_dir) else {
continue;
};
for fd in fds.flatten() {
if let Ok(link) = std::fs::read_link(fd.path())
&& link.to_str() == Some(socket_target.as_str())
{
let cmdline = std::fs::read_to_string(entry.path().join("cmdline"))
.unwrap_or_default()
.replace('\0', " ")
.trim_end()
.to_string();
return Some(format!("pid={pid} cmd={cmdline:?}"));
}
}
}
None
}
#[cfg(not(target_os = "linux"))]
fn resolve_loopback_peer(_peer: SocketAddr) -> Option<String> {
None
}
pub async fn register(
State(state): State<SharedState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
body_bytes: Bytes,
) -> (StatusCode, Json<serde_json::Value>) {
let user_agent = headers
.get(header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.unwrap_or("-");
let raw_body = String::from_utf8_lossy(&body_bytes);
let caller = resolve_loopback_peer(peer).unwrap_or_else(|| "pid=unknown".to_string());
tracing::info!(
target: "ouija::api::register",
peer = %peer,
user_agent = %user_agent,
caller = %caller,
"/api/register: raw_body={}",
raw_body,
);
let body: RegisterBody = match serde_json::from_slice(&body_bytes) {
Ok(b) => b,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": format!("invalid JSON: {e}") })),
);
}
};
if body.id.contains('/') {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "session ID must not contain '/'" })),
);
}
let project_description = body
.project_dir
.as_deref()
.and_then(extract_project_description);
let metadata = crate::state::SessionMetadata {
vim_mode: body.vim_mode,
project_dir: body.project_dir,
role: body.role,
bulletin: body.bulletin,
networked: body.networked.unwrap_or(true),
backend_session_id: body.backend_session_id,
backend: body.backend,
project_description,
reminder: body.reminder,
..Default::default()
};
if let Some(ref p) = body.pane {
let names = state.backends.all_process_names();
let refs: Vec<&str> = names.iter().map(|s| s.as_str()).collect();
if !crate::tmux::pane_alive(p, &refs) {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": format!("pane {p} does not exist") })),
);
}
}
let backend = match metadata.backend {
Some(ref b) => Some(b.clone()),
None => match body.pane {
Some(ref p) => state.detect_backend_in_pane(p).await,
None => None,
},
};
let proto_meta = crate::daemon_protocol::SessionMeta {
project_dir: metadata.project_dir.clone(),
role: metadata.role.clone(),
bulletin: metadata.bulletin.clone(),
networked: metadata.networked,
worktree: metadata.worktree,
vim_mode: metadata.vim_mode,
backend,
backend_session_id: metadata.backend_session_id.clone(),
reminder: metadata.reminder.clone(),
..Default::default()
};
let effects = state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: body.id.clone(),
pane: body.pane.clone(),
metadata: proto_meta,
})
.await;
let (session_id, _replaced) = match effects.iter().find_map(|e| match e {
crate::daemon_protocol::Effect::RegisterOk {
session_id,
replaced,
} => Some((session_id.clone(), replaced.clone())),
_ => None,
}) {
Some(ok) => ok,
None => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "unexpected register result" })),
);
}
};
(
StatusCode::OK,
Json(json!({
"registered": session_id,
"pane": body.pane,
})),
)
}
#[derive(Debug, Deserialize)]
pub struct SendBody {
from: String,
to: String,
message: String,
#[serde(default)]
expects_reply: bool,
#[serde(default)]
responds_to: Option<u64>,
#[serde(default)]
done: bool,
}
pub async fn send_msg(
State(state): State<SharedState>,
Json(body): Json<SendBody>,
) -> (StatusCode, Json<serde_json::Value>) {
if body.from == body.to {
let suffix = format!("/{}", body.to);
let prefix = format!("{}/", body.to);
let proto = state.protocol.read().await;
let suggestions: Vec<&str> = proto
.sessions
.keys()
.filter(|k| k.ends_with(&suffix) || k.starts_with(&prefix))
.map(|k| k.as_str())
.collect();
let hint = if suggestions.is_empty() {
"If you meant a remote session, use the full node-prefixed name (e.g. 'node/session'). GET /api/status to see all available targets.".to_string()
} else {
format!(
"Did you mean one of these remote sessions? {} — GET /api/status to check.",
suggestions.join(", ")
)
};
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": format!("cannot send a message to yourself. {hint}") })),
);
}
let effects = state
.apply_and_execute(crate::daemon_protocol::Event::Send {
from: body.from,
to: body.to,
message: body.message,
expects_reply: body.expects_reply,
responds_to: body.responds_to,
done: body.done,
})
.await;
if let Some((method, msg_id)) = effects.iter().find_map(|e| match e {
crate::daemon_protocol::Effect::SendDelivered { method, msg_id, .. } => {
Some((method.clone(), *msg_id))
}
_ => None,
}) {
(
StatusCode::OK,
Json(json!({
"status": "delivered",
"method": method,
"msg_id": msg_id,
})),
)
} else if let Some((reason, renamed_to)) = effects.iter().find_map(|e| match e {
crate::daemon_protocol::Effect::SendFailed {
reason, renamed_to, ..
} => Some((reason.clone(), renamed_to.clone())),
_ => None,
}) {
let mut body = json!({ "error": reason });
if let Some(new_id) = renamed_to {
body["renamed_to"] = json!(new_id);
}
(StatusCode::NOT_FOUND, Json(body))
} else {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "unexpected send result" })),
)
}
}
#[derive(Debug, Deserialize)]
pub struct RenameBody {
old_id: String,
new_id: String,
}
pub async fn rename(
State(state): State<SharedState>,
Json(body): Json<RenameBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let effects = state
.apply_and_execute(crate::daemon_protocol::Event::Rename {
old_id: body.old_id.clone(),
new_id: body.new_id.clone(),
})
.await;
if effects
.iter()
.any(|e| matches!(e, crate::daemon_protocol::Effect::RenameOk { .. }))
{
(
StatusCode::OK,
Json(json!({ "renamed": body.old_id, "to": body.new_id })),
)
} else {
let reason = effects
.iter()
.find_map(|e| match e {
crate::daemon_protocol::Effect::RenameFailed { reason } => Some(reason.clone()),
_ => None,
})
.unwrap_or_else(|| format!("session '{}' not found", body.old_id));
(StatusCode::NOT_FOUND, Json(json!({ "error": reason })))
}
}
#[derive(Debug, Deserialize)]
pub struct RemoveBody {
id: String,
#[serde(default)]
keep_worktree: Option<bool>,
}
pub async fn remove(
State(state): State<SharedState>,
Json(body): Json<RemoveBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let effects = state
.apply_and_execute(crate::daemon_protocol::Event::Remove {
id: body.id.clone(),
keep_worktree: body.keep_worktree.unwrap_or(false),
})
.await;
if effects
.iter()
.any(|e| matches!(e, crate::daemon_protocol::Effect::RemoveOk { .. }))
{
(StatusCode::OK, Json(json!({ "removed": body.id })))
} else {
let reason = effects
.iter()
.find_map(|e| match e {
crate::daemon_protocol::Effect::RemoveFailed { reason, .. } => Some(reason.clone()),
_ => None,
})
.unwrap_or_else(|| format!("session '{}' not found", body.id));
(StatusCode::NOT_FOUND, Json(json!({ "error": reason })))
}
}
#[derive(Debug, Deserialize)]
pub struct SessionUpdateBody {
id: String,
networked: Option<bool>,
role: Option<String>,
project_dir: Option<String>,
bulletin: Option<String>,
}
pub async fn update_session(
State(state): State<SharedState>,
Json(body): Json<SessionUpdateBody>,
) -> (StatusCode, Json<serde_json::Value>) {
{
let proto = state.protocol.read().await;
let Some(session) = proto.sessions.get(&body.id) else {
return (
StatusCode::NOT_FOUND,
Json(json!({ "error": format!("session '{}' not found", body.id) })),
);
};
if matches!(session.origin, crate::daemon_protocol::Origin::Remote(_)) {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "cannot update remote session" })),
);
}
}
state
.apply_and_execute(crate::daemon_protocol::Event::UpdateMetadata {
id: body.id.clone(),
role: body.role,
bulletin: body.bulletin,
project_dir: body.project_dir,
networked: body.networked,
})
.await;
let proto = state.protocol.read().await;
let response = if let Some(s) = proto.sessions.get(&body.id) {
json!({
"updated": s.id,
"networked": s.metadata.networked,
"role": s.metadata.role,
"bulletin": s.metadata.bulletin,
"project_dir": s.metadata.project_dir,
})
} else {
json!({ "updated": body.id })
};
(StatusCode::OK, Json(response))
}
#[derive(Debug, Deserialize)]
pub struct InjectBody {
pane: String,
message: String,
#[serde(default)]
vim_mode: bool,
}
pub async fn inject(
State(state): State<SharedState>,
Json(body): Json<InjectBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let session_id = {
let proto = state.protocol.read().await;
match proto
.sessions
.values()
.find(|s| s.pane.as_deref() == Some(&body.pane))
.map(|s| s.id.clone())
{
Some(id) => id,
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "no session registered for this pane"})),
);
}
}
};
match tmux::locked_inject(
&state,
&session_id,
&body.pane,
&body.message,
body.vim_mode,
)
.await
{
Ok(()) => (StatusCode::OK, Json(json!({ "status": "injected" }))),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
),
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CompactBody {
#[serde(default)]
pub continuation: Option<String>,
}
pub async fn compact(
State(state): State<SharedState>,
axum::extract::Path(session_id): axum::extract::Path<String>,
Json(body): Json<CompactBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let (status, value) = compact_inner(&state, session_id, body).await;
(status, Json(value))
}
fn compact_success_body(continuation_delivered: bool, error: Option<String>) -> serde_json::Value {
let mut body = json!({
"status": "ok",
"compacted": true,
"continuation_delivered": continuation_delivered,
});
if let Some(err) = error {
body["error"] = json!(err);
}
body
}
async fn compact_inner(
state: &std::sync::Arc<crate::state::AppState>,
session_id: String,
body: CompactBody,
) -> (StatusCode, serde_json::Value) {
let continuation = body.continuation.and_then(|s| {
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
});
let lookup = {
let proto = state.protocol.read().await;
match proto.sessions.get(&session_id) {
Some(s) => SessionLookup {
pane: s.pane.clone(),
backend_session_id: s.metadata.backend_session_id.clone(),
project_dir: s.metadata.project_dir.clone(),
backend_name: s.metadata.backend.clone(),
model: s.metadata.model.clone(),
effort: s.metadata.effort.clone(),
},
None => {
return (
StatusCode::NOT_FOUND,
json!({"error": format!("session '{}' not found", session_id)}),
);
}
}
};
let backend = match lookup.backend_name.as_deref() {
Some(name) => state
.backends
.get(name)
.unwrap_or_else(|| state.backends.default()),
None => state.backends.default(),
};
match backend.delivery_mode() {
crate::backend::DeliveryMode::TuiInjection => {
let Some(pane) = lookup.pane else {
return (
StatusCode::BAD_REQUEST,
json!({"error": "session has no pane (remote sessions cannot be compacted)"}),
);
};
let Some(compact_cmd) = backend.compact_command().map(str::to_string) else {
return (
StatusCode::BAD_REQUEST,
json!({"error": format!("backend '{}' does not support compact", backend.name())}),
);
};
let parked = if let Some(ref text) = continuation {
let acquired = state
.try_set_pending_compact_continuation(&session_id, text.clone())
.await;
if !acquired {
return (
StatusCode::CONFLICT,
json!({"error": "another compact continuation is already pending for this session"}),
);
}
true
} else {
false
};
if let Err(e) =
tmux::locked_inject(state, &session_id, &pane, &compact_cmd, false).await
{
if parked {
if state
.drain_agent_compact_continuation(&session_id)
.await
.is_none()
{
tracing::warn!(
session = %session_id,
"rollback drain returned None after successful try-set; slot may be orphaned",
);
}
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
json!({"error": e.to_string()}),
);
}
(StatusCode::OK, compact_success_body(false, None))
}
crate::backend::DeliveryMode::HttpApi { .. } => {
let Some(backend_session_id) = lookup.backend_session_id else {
return (
StatusCode::BAD_REQUEST,
json!({
"error": format!(
"session has no backend_session_id (backend '{}' not attached)",
backend.name()
)
}),
);
};
let Some((provider_id, model_id)) =
resolve_opencode_compact_model(state, lookup.model.as_deref()).await
else {
return (
StatusCode::BAD_REQUEST,
json!({
"error": "cannot resolve provider/model for summarize: session has no parseable `model` \
(expected \"providerID/modelID\") and /config/providers is unreachable \
or empty. Configure the session with `ouija spawn-session --model <p/m>` \
or ensure opencode serve has at least one configured provider."
}),
);
};
let port = state.opencode_serve_port();
let summarize_url =
format!("http://127.0.0.1:{port}/session/{backend_session_id}/summarize");
let summarize_body = json!({
"providerID": provider_id,
"modelID": model_id,
});
let mut summarize_req = state
.http_client
.post(&summarize_url)
.json(&summarize_body)
.timeout(std::time::Duration::from_secs(300));
if let Some(dir) = lookup.project_dir.as_deref() {
summarize_req = summarize_req.header("x-opencode-directory", dir);
}
match summarize_req.send().await {
Ok(r) if r.status().is_success() => {}
Ok(r) => {
let status = r.status();
let text = r.text().await.unwrap_or_default();
return (
StatusCode::BAD_GATEWAY,
json!({"error": format!("opencode /summarize returned {status}: {text}")}),
);
}
Err(e) => {
return (
StatusCode::BAD_GATEWAY,
json!({"error": format!("opencode /summarize request failed: {e}")}),
);
}
}
let continuation_delivered = if let Some(continuation) = continuation {
match tmux::deliver_via_http(
state,
&backend_session_id,
lookup.project_dir.as_deref(),
&continuation,
lookup.model.as_deref(),
lookup.effort.as_deref(),
)
.await
{
Ok(()) => true,
Err(e) => {
tracing::warn!(
session = %session_id,
"continuation delivery failed after successful summarize: {e}"
);
return (
StatusCode::OK,
compact_success_body(
false,
Some(format!("opencode continuation delivery failed: {e}")),
),
);
}
}
} else {
false
};
(
StatusCode::OK,
compact_success_body(continuation_delivered, None),
)
}
}
}
struct SessionLookup {
pane: Option<String>,
backend_session_id: Option<String>,
project_dir: Option<String>,
backend_name: Option<String>,
model: Option<String>,
effort: Option<String>,
}
pub async fn nodes(State(state): State<SharedState>) -> Json<serde_json::Value> {
let connected = state.nodes.read().await;
let self_entry = json!({
"name": state.config.name,
"npub": state.config.npub,
"status": "self",
"transport": null,
"since": null,
});
let mut entries: Vec<serde_json::Value> = vec![self_entry];
for p in connected.values() {
entries.push(json!({
"name": p.name,
"npub": p.daemon_id,
"status": "connected",
"transport": null,
"since": p.connected_at.format("%H:%M:%S").to_string(),
}));
}
let connected_names: std::collections::HashSet<&str> =
connected.values().map(|p| p.name.as_str()).collect();
if let Ok(conns) = crate::persistence::load_connections(&state.config.data_dir) {
for conn in &conns {
if let Some(name) = &conn.node_name
&& connected_names.contains(name.as_str())
{
continue;
}
entries.push(json!({
"name": conn.node_name,
"npub": conn.daemon_npub,
"status": "saved",
"transport": "nostr",
"since": conn.connected_at.format("%Y-%m-%d").to_string(),
}));
}
}
Json(json!({ "nodes": entries }))
}
#[derive(Debug, Deserialize)]
pub struct DisconnectNodeBody {
daemon_id: String,
}
pub async fn disconnect_node(
State(state): State<SharedState>,
Json(body): Json<DisconnectNodeBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let removed = state.disconnect_node(&body.daemon_id).await;
(
StatusCode::OK,
Json(json!({
"disconnected": body.daemon_id,
"sessions_removed": removed,
})),
)
}
pub async fn get_settings(State(state): State<SharedState>) -> Json<serde_json::Value> {
let settings = state.settings.read().await;
Json(json!({
"auto_register": settings.auto_register,
}))
}
#[derive(Debug, Deserialize)]
pub struct SettingsUpdateBody {
auto_register: Option<bool>,
projects_dir: Option<String>,
idle_timeout_secs: Option<u64>,
reaper_interval_secs: Option<u64>,
max_local_sessions: Option<u64>,
}
pub async fn update_settings(
State(state): State<SharedState>,
Json(body): Json<SettingsUpdateBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let mut settings = state.settings.write().await;
if let Some(v) = body.auto_register {
settings.auto_register = v;
}
let projects_dir_changed = body.projects_dir.is_some();
if let Some(v) = body.projects_dir {
settings.projects_dir = Some(v);
}
if let Some(v) = body.idle_timeout_secs {
settings.idle_timeout_secs = v;
}
if let Some(v) = body.reaper_interval_secs {
settings.reaper_interval_secs = v;
}
if let Some(v) = body.max_local_sessions {
settings.max_local_sessions = v;
}
if let Err(e) = crate::persistence::save_settings(&state.config.config_dir, &settings) {
tracing::warn!("failed to save settings: {e}");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("failed to save: {e}") })),
);
}
drop(settings);
if projects_dir_changed {
let s = state.clone();
tokio::spawn(async move {
crate::project_index::refresh_index(&s).await;
});
}
let settings = state.settings.read().await;
(
StatusCode::OK,
Json(json!({
"status": "saved",
"settings": {
"auto_register": settings.auto_register,
"projects_dir": settings.projects_dir,
}
})),
)
}
pub async fn bulk_update_sessions(
State(state): State<SharedState>,
Json(body): Json<BulkSessionUpdateBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let mut count = 0;
{
let mut proto = state.protocol.write().await;
for session in proto.sessions.values_mut() {
if matches!(session.origin, crate::daemon_protocol::Origin::Local) {
if let Some(v) = body.networked {
if session.metadata.networked != v {
session.metadata.networked = v;
count += 1;
}
}
}
}
}
if count > 0 {
transport::broadcast_local_sessions(&state).await;
}
(StatusCode::OK, Json(json!({ "updated": count })))
}
#[derive(Debug, Deserialize)]
pub struct BulkSessionUpdateBody {
networked: Option<bool>,
}
pub async fn get_relays(State(state): State<SharedState>) -> Json<serde_json::Value> {
let relays = crate::nostr_transport::load_relays(&state.config.data_dir);
Json(json!({ "relays": relays }))
}
#[derive(Debug, Deserialize)]
pub struct RelaysUpdateBody {
relays: Vec<String>,
}
pub async fn update_relays(
State(state): State<SharedState>,
Json(body): Json<RelaysUpdateBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let relays: Vec<String> = body
.relays
.into_iter()
.map(|r| r.trim().to_string())
.filter(|r| !r.is_empty())
.collect();
if let Err(e) = crate::nostr_transport::save_relays(&state.config.data_dir, &relays) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("failed to save: {e}") })),
);
}
(
StatusCode::OK,
Json(json!({ "status": "saved", "relays": relays })),
)
}
pub async fn list_tasks(State(state): State<SharedState>) -> Json<serde_json::Value> {
let tasks = state.scheduled_tasks.read().await;
let mut list: Vec<&scheduler::ScheduledTask> = tasks.values().collect();
list.sort_by_key(|t| &t.created_at);
let entries: Vec<serde_json::Value> = list
.iter()
.map(|t| {
json!({
"id": t.id,
"name": t.name,
"cron": t.cron,
"target_session": t.target_session,
"enabled": t.enabled,
"next_run": t.next_run,
"last_run": t.last_run,
"last_status": t.last_status,
"run_count": t.run_count,
"project_dir": t.project_dir,
"once": t.once,
"backend_session_id": t.backend_session_id,
"on_fire": t.on_fire,
})
})
.collect();
Json(json!({ "tasks": entries }))
}
#[derive(Debug, Deserialize)]
pub struct CreateTaskBody {
name: String,
cron: String,
target_session: Option<String>,
prompt: Option<String>,
reminder: Option<String>,
project_dir: Option<String>,
#[serde(default)]
once: Option<bool>,
#[serde(alias = "claude_session_id")]
backend_session_id: Option<String>,
#[serde(default)]
on_fire: Option<crate::scheduler::OnFire>,
}
pub async fn create_task(
State(state): State<SharedState>,
Json(body): Json<CreateTaskBody>,
) -> (StatusCode, Json<serde_json::Value>) {
if let Err(e) = scheduler::validate_cron(&body.cron) {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": format!("invalid cron: {e}") })),
);
}
let mut task = scheduler::new_task(
body.name,
body.cron,
body.target_session,
body.prompt,
body.reminder,
body.once.unwrap_or(false),
body.backend_session_id,
body.on_fire.unwrap_or_default(),
);
task.project_dir = body.project_dir;
let id = task.id.clone();
state.add_task(task).await;
(StatusCode::OK, Json(json!({ "created": id })))
}
#[derive(Debug, Deserialize)]
pub struct TaskIdBody {
id: String,
}
pub async fn delete_task(
State(state): State<SharedState>,
Json(body): Json<TaskIdBody>,
) -> (StatusCode, Json<serde_json::Value>) {
match state.remove_task(&body.id).await {
Some(_) => (StatusCode::OK, Json(json!({ "deleted": body.id }))),
None => (
StatusCode::NOT_FOUND,
Json(json!({ "error": format!("task '{}' not found", body.id) })),
),
}
}
pub async fn enable_task(
State(state): State<SharedState>,
Json(body): Json<TaskIdBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let tasks = state.scheduled_tasks.read().await;
if !tasks.contains_key(&body.id) {
return (
StatusCode::NOT_FOUND,
Json(json!({ "error": format!("task '{}' not found", body.id) })),
);
}
drop(tasks);
state
.update_task(&body.id, |t| {
t.enabled = true;
t.next_run = scheduler::compute_next_run(&t.cron);
})
.await;
(StatusCode::OK, Json(json!({ "enabled": body.id })))
}
pub async fn disable_task(
State(state): State<SharedState>,
Json(body): Json<TaskIdBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let tasks = state.scheduled_tasks.read().await;
if !tasks.contains_key(&body.id) {
return (
StatusCode::NOT_FOUND,
Json(json!({ "error": format!("task '{}' not found", body.id) })),
);
}
drop(tasks);
state
.update_task(&body.id, |t| {
t.enabled = false;
t.next_run = None;
})
.await;
(StatusCode::OK, Json(json!({ "disabled": body.id })))
}
pub async fn trigger_task(
State(state): State<SharedState>,
Json(body): Json<TaskIdBody>,
) -> (StatusCode, Json<serde_json::Value>) {
{
let tasks = state.scheduled_tasks.read().await;
if !tasks.contains_key(&body.id) {
return (
StatusCode::NOT_FOUND,
Json(json!({ "error": format!("task '{}' not found", body.id) })),
);
}
}
scheduler::execute_task(&state, &body.id).await;
(StatusCode::OK, Json(json!({ "triggered": body.id })))
}
#[derive(Debug, Deserialize, Default)]
pub struct TaskRunsQuery {
task: Option<String>,
}
pub async fn list_task_runs(
State(state): State<SharedState>,
Query(query): Query<TaskRunsQuery>,
) -> Json<serde_json::Value> {
let runs = state.task_runs.read().await;
let entries: Vec<serde_json::Value> = runs
.iter()
.rev()
.filter(|r| query.task.as_ref().is_none_or(|id| r.task_id == *id))
.take(MAX_TASK_RUNS_RETURNED)
.map(|r| {
json!({
"task_id": r.task_id,
"task_name": r.task_name,
"timestamp": r.timestamp,
"status": r.status,
"error": r.error,
"session_name": r.session_name,
"revived_pane": r.revived_pane,
})
})
.collect();
Json(json!({ "runs": entries }))
}
#[derive(Debug, Deserialize)]
pub struct AddHumanBody {
pub npub: String,
pub name: String,
pub default_session: Option<String>,
}
pub async fn add_human(
State(state): State<SharedState>,
Json(body): Json<AddHumanBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let name = body.name.trim().to_string();
if name.is_empty() || name.contains('/') {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "invalid name" })),
);
}
{
let proto = state.protocol.read().await;
if proto
.sessions
.get(&name)
.is_some_and(|s| !matches!(s.origin, crate::daemon_protocol::Origin::Human(_)))
{
return (
StatusCode::CONFLICT,
Json(json!({ "error": "name conflicts with existing session" })),
);
}
}
let mut settings = state.settings.write().await;
if settings.human_sessions.iter().any(|h| h.name == name) {
return (
StatusCode::CONFLICT,
Json(json!({ "error": "human session already exists" })),
);
}
let human = crate::persistence::HumanSession {
npub: body.npub.clone(),
name: name.clone(),
default_session: body.default_session,
welcomed: false,
};
settings.human_sessions.push(human);
if let Err(e) = crate::persistence::save_settings(&state.config.config_dir, &settings) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("failed to save: {e}") })),
);
}
drop(settings);
{
let mut proto = state.protocol.write().await;
proto.sessions.entry(name.clone()).or_insert_with(|| {
crate::daemon_protocol::SessionEntry {
id: name.clone(),
pane: None,
origin: crate::daemon_protocol::Origin::Human(body.npub.clone()),
metadata: crate::daemon_protocol::SessionMeta {
role: Some("human".to_string()),
networked: false,
..Default::default()
},
..Default::default()
}
});
}
(
StatusCode::OK,
Json(json!({ "status": "added", "name": name })),
)
}
#[derive(Debug, Deserialize)]
pub struct RemoveHumanBody {
pub name: String,
}
pub async fn remove_human(
State(state): State<SharedState>,
Json(body): Json<RemoveHumanBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let mut settings = state.settings.write().await;
let before = settings.human_sessions.len();
settings.human_sessions.retain(|h| h.name != body.name);
if settings.human_sessions.len() == before {
return (StatusCode::NOT_FOUND, Json(json!({ "error": "not found" })));
}
if let Err(e) = crate::persistence::save_settings(&state.config.config_dir, &settings) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("failed to save: {e}") })),
);
}
drop(settings);
{
let mut proto = state.protocol.write().await;
if proto
.sessions
.get(&body.name)
.is_some_and(|s| matches!(s.origin, crate::daemon_protocol::Origin::Human(_)))
{
proto.sessions.remove(&body.name);
}
}
(StatusCode::OK, Json(json!({ "status": "removed" })))
}
pub async fn list_humans(State(state): State<SharedState>) -> Json<serde_json::Value> {
let settings = state.settings.read().await;
let humans: Vec<serde_json::Value> = settings
.human_sessions
.iter()
.map(|h| {
json!({
"name": h.name,
"npub": h.npub,
"default_session": h.default_session,
})
})
.collect();
Json(json!({ "humans": humans }))
}
#[derive(Debug, Deserialize)]
pub struct SessionNameBody {
name: String,
#[serde(default)]
fresh: Option<bool>,
#[serde(default)]
worktree: Option<bool>,
#[serde(default)]
project_dir: Option<String>,
#[serde(default)]
prompt: Option<String>,
#[serde(default)]
from: Option<String>,
#[serde(default)]
backend: Option<String>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
effort: Option<String>,
#[serde(default)]
reminder: Option<String>,
#[serde(default)]
branch: Option<String>,
#[serde(default)]
base_branch: Option<String>,
#[serde(default)]
keep_worktree: Option<bool>,
#[serde(default)]
force_reset: Option<bool>,
}
fn restart_drops_destructive_intent(body: &SessionNameBody) -> Option<String> {
let mut dropped: Vec<&str> = Vec::new();
if body.force_reset == Some(true) {
dropped.push("force_reset=true");
}
if body.base_branch.is_some() {
dropped.push("base_branch");
}
if dropped.is_empty() {
return None;
}
Some(format!(
"session '{}' is already registered, routing to restart_session \
which cannot act on {}; destructive intent silently dropped. \
File a ticket for a sync reset endpoint if this is load-bearing.",
body.name,
dropped.join(", ")
))
}
pub async fn kill_session(
State(state): State<SharedState>,
Json(body): Json<SessionNameBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let result = if body.keep_worktree.unwrap_or(false) {
crate::nostr_transport::kill_session_keep_worktree(&state, &body.name).await
} else {
crate::nostr_transport::kill_session(&state, &body.name).await
};
(StatusCode::OK, Json(json!({ "result": result })))
}
pub async fn prune_stale_sessions(
State(state): State<SharedState>,
Json(body): Json<PruneStaleBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let stale_sessions: Vec<(String, String)> = {
let proto = state.protocol.read().await;
proto
.sessions
.values()
.filter(|s| {
matches!(s.origin, crate::daemon_protocol::Origin::Local)
&& s.metadata.worktree_present == Some(false)
})
.filter_map(|s| s.metadata.project_dir.as_ref().map(|d| (s.id.clone(), d.clone())))
.collect()
};
if !body.confirm {
return (
StatusCode::OK,
Json(json!({ "dry_run": true, "would_prune": stale_sessions.iter().map(|(id, _)| id).cloned().collect::<Vec<_>>() })),
);
}
let mut pruned = Vec::new();
let mut errors = Vec::new();
let mut already_gone = Vec::new();
let input_ids: Vec<String> = stale_sessions.iter().map(|(id, _)| id.clone()).collect();
let effects = state
.apply_and_execute(crate::daemon_protocol::Event::PruneStale {
sessions: stale_sessions,
})
.await;
let pruned_set: std::collections::HashSet<String> = effects
.iter()
.filter_map(|e| match e {
crate::daemon_protocol::Effect::RemoveOk { id } => Some(id.clone()),
_ => None,
})
.collect();
let already_gone_set: std::collections::HashSet<String> = effects
.iter()
.filter_map(|e| match e {
crate::daemon_protocol::Effect::RemoveFailed { id, kind, .. }
if *kind == crate::daemon_protocol::RemoveFailureKind::NotFound =>
{
Some(id.clone())
}
_ => None,
})
.collect();
for id in input_ids {
if pruned_set.contains(&id) {
pruned.push(id);
} else if already_gone_set.contains(&id) {
tracing::debug!("session {} vanished between snapshot and prune", id);
already_gone.push(id);
} else {
tracing::warn!("failed to prune session {} (no longer stale or guard tripped)", id);
errors.push(id);
}
}
let response = if errors.is_empty() && already_gone.is_empty() {
json!({ "dry_run": false, "pruned": pruned })
} else {
let mut obj = serde_json::Map::new();
obj.insert("dry_run".into(), serde_json::Value::Bool(false));
obj.insert("pruned".into(), serde_json::Value::Array(pruned.into_iter().map(serde_json::Value::String).collect()));
if !errors.is_empty() {
obj.insert("errors".into(), serde_json::Value::Array(errors.into_iter().map(serde_json::Value::String).collect()));
}
if !already_gone.is_empty() {
obj.insert("already_gone".into(), serde_json::Value::Array(already_gone.into_iter().map(serde_json::Value::String).collect()));
}
serde_json::Value::Object(obj)
};
(StatusCode::OK, Json(response))
}
#[derive(serde::Deserialize)]
pub struct PruneStaleBody {
#[serde(default)]
confirm: bool,
}
pub async fn start_session(
State(state): State<SharedState>,
Json(body): Json<SessionNameBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let mut body = body;
body.model = normalize_optional_string(body.model);
body.effort = normalize_optional_string(body.effort);
let name = body.name.clone();
let state2 = state.clone();
tokio::spawn(async move {
let exists = state2
.protocol
.read()
.await
.sessions
.contains_key(&body.name);
if exists {
tracing::info!(
"session '{}' exists, restarting with fresh context",
body.name
);
if let Some(msg) = restart_drops_destructive_intent(&body) {
tracing::warn!("{msg}");
}
let (_result, _msg_id) = crate::nostr_transport::restart_session(
&state2,
&body.name,
true, body.prompt.as_deref(),
body.from.as_deref(),
None, body.backend.as_deref(),
body.model.as_deref(),
body.effort.as_deref(),
body.reminder.as_deref(),
)
.await;
tracing::info!("async session restart complete: {}", body.name);
return;
}
let (result, _prompt_msg_id) = crate::nostr_transport::start_session(
&state2,
&body.name,
body.worktree,
body.project_dir.as_deref(),
body.prompt.as_deref(),
body.from.as_deref(),
None, body.backend.as_deref(),
body.model.as_deref(),
body.effort.as_deref(),
body.reminder.as_deref(),
body.branch.as_deref(),
body.base_branch.as_deref(),
body.force_reset.unwrap_or(false),
)
.await;
tracing::info!(
"async session start complete: {}, result: {result}",
body.name
);
});
(
StatusCode::ACCEPTED,
Json(json!({ "session": name, "status": "starting" })),
)
}
pub async fn restart_session(
State(state): State<SharedState>,
Json(body): Json<SessionNameBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let mut body = body;
body.model = normalize_optional_string(body.model);
body.effort = normalize_optional_string(body.effort);
if let Some(msg) = restart_drops_destructive_intent(&body) {
tracing::warn!("{msg}");
}
let fresh = body.fresh.unwrap_or(false);
let (result, _prompt_msg_id) = crate::nostr_transport::restart_session(
&state,
&body.name,
fresh,
body.prompt.as_deref(),
body.from.as_deref(),
None, body.backend.as_deref(),
body.model.as_deref(),
body.effort.as_deref(),
body.reminder.as_deref(),
)
.await;
(StatusCode::OK, Json(json!({ "result": result })))
}
pub async fn get_block_interactive(
State(_state): State<SharedState>,
axum::extract::Path(_pane): axum::extract::Path<String>,
) -> Json<serde_json::Value> {
Json(json!({ "block_interactive": false }))
}
pub async fn clear_block_interactive(
State(_state): State<SharedState>,
axum::extract::Path(_pane): axum::extract::Path<String>,
) -> StatusCode {
StatusCode::OK
}
fn resolve_pane_to_session(proto: &crate::daemon_protocol::DaemonState, raw: &str) -> Option<String> {
let suffix = raw.strip_prefix('%').unwrap_or(raw);
let pane_id = format!("%{suffix}");
proto
.sessions
.values()
.find(|s| s.pane.as_deref() == Some(&pane_id))
.map(|s| s.id.clone())
}
pub async fn get_pending_replies(
State(state): State<SharedState>,
axum::extract::Path(pane): axum::extract::Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
let (status, value) = get_pending_replies_inner(&state, pane).await;
(status, Json(value))
}
async fn get_pending_replies_inner(
state: &SharedState,
pane: String,
) -> (StatusCode, serde_json::Value) {
let session_id = {
let proto = state.protocol.read().await;
resolve_pane_to_session(&proto, &pane)
};
let Some(id) = session_id else {
return (
StatusCode::NOT_FOUND,
json!({ "error": format!("pane '{pane}' is not registered") }),
);
};
let replies = state.query_agent_pending_replies(&id).await;
let list: Vec<_> = replies
.iter()
.map(|r| json!({ "msg_id": r.msg_id, "from": r.from, "message": r.message, "received_at": r.received_at }))
.collect();
(
StatusCode::OK,
json!({ "pending_replies": list, "count": list.len() }),
)
}
pub async fn delete_pending_reply(
State(state): State<SharedState>,
axum::extract::Path((pane, from)): axum::extract::Path<(String, String)>,
) -> (StatusCode, Json<serde_json::Value>) {
let (status, value) = delete_pending_reply_inner(&state, pane, from).await;
(status, Json(value))
}
async fn delete_pending_reply_inner(
state: &SharedState,
pane: String,
from: String,
) -> (StatusCode, serde_json::Value) {
let session_id = {
let proto = state.protocol.read().await;
resolve_pane_to_session(&proto, &pane)
};
let Some(id) = session_id else {
return (
StatusCode::NOT_FOUND,
json!({ "error": format!("pane '{pane}' is not registered") }),
);
};
let cleared = {
let mut proto = state.protocol.write().await;
proto.clear_pending_reply_from(&id, &from)
};
(StatusCode::OK, json!({ "cleared": cleared }))
}
pub async fn session_stopped(
State(state): State<SharedState>,
axum::extract::Path(pane): axum::extract::Path<String>,
) -> StatusCode {
let session_id = {
let proto = state.protocol.read().await;
resolve_pane_to_session(&proto, &pane)
};
if let Some(id) = session_id {
state
.notify_agent(&id, crate::session_agent::SessionMsg::Stopped)
.await;
}
StatusCode::OK
}
pub async fn session_active(
State(state): State<SharedState>,
axum::extract::Path(pane): axum::extract::Path<String>,
) -> StatusCode {
let session_id = {
let proto = state.protocol.read().await;
resolve_pane_to_session(&proto, &pane)
};
if let Some(id) = session_id {
state
.notify_agent(&id, crate::session_agent::SessionMsg::Active)
.await;
}
StatusCode::OK
}
fn deliver_pending_prompt(state: &SharedState, session_name: &str) -> bool {
let pending = state.pending_prompts.lock().unwrap().remove(session_name);
let Some((pane_id, prompt)) = pending else {
return false;
};
let state = state.clone();
let sid = session_name.to_string();
tokio::spawn(async move {
if let Err(e) = crate::tmux::locked_inject(&state, &sid, &pane_id, &prompt, false).await {
tracing::warn!("readiness prompt delivery failed for {sid}: {e}");
} else {
tracing::info!("delivered queued prompt to {sid} via readiness signal");
}
});
true
}
pub async fn session_ready(
State(state): State<SharedState>,
axum::extract::Path(session_id): axum::extract::Path<String>,
) -> Json<serde_json::Value> {
let delivered = deliver_pending_prompt(&state, &session_id);
Json(json!({"delivered": delivered}))
}
pub async fn backend_session_ready(
State(state): State<SharedState>,
axum::extract::Path(backend_sid): axum::extract::Path<String>,
body_bytes: Bytes,
) -> Json<serde_json::Value> {
let hints = if body_bytes.is_empty() {
BackendSessionReadyHints::default()
} else {
match serde_json::from_slice::<BackendSessionReadyHints>(&body_bytes) {
Ok(h) => h,
Err(e) => {
tracing::debug!(
target: "ouija::api::backend_session_ready",
"failed to parse readiness hints ({e}); falling back to scan path"
);
BackendSessionReadyHints::default()
}
}
};
Json(backend_session_ready_inner_with_hints(&state, backend_sid, hints).await)
}
#[derive(Debug, Default, Deserialize)]
struct BackendSessionReadyHints {
#[serde(default)]
pane: Option<String>,
#[serde(default)]
cwd: Option<String>,
}
#[cfg(test)]
async fn backend_session_ready_inner(
state: &std::sync::Arc<crate::state::AppState>,
backend_sid: String,
) -> serde_json::Value {
backend_session_ready_inner_with_hints(state, backend_sid, BackendSessionReadyHints::default())
.await
}
async fn backend_session_ready_inner_with_hints(
state: &std::sync::Arc<crate::state::AppState>,
backend_sid: String,
hints: BackendSessionReadyHints,
) -> serde_json::Value {
let session_name = {
let proto = state.protocol.read().await;
proto
.sessions
.values()
.find(|s| s.metadata.backend_session_id.as_deref() == Some(&backend_sid))
.map(|s| s.id.clone())
};
let name = if let Some(n) = session_name {
n
} else {
let adopted = adopt_backend_session_id(state, &backend_sid).await;
if let Some(n) = adopted {
n
} else {
let auto_register = state.settings.read().await.auto_register;
if !auto_register {
tracing::debug!(
"auto_register disabled; declining to auto-provision for backend_session_id {backend_sid}"
);
return json!({"delivered": false, "error": "no session with this backend_session_id"});
}
if let (Some(pane), Some(cwd)) = (hints.pane.as_deref(), hints.cwd.as_deref()) {
if let Some(n) =
auto_provision_with_explicit_pane(state, &backend_sid, pane, cwd).await
{
n
} else {
return json!({"delivered": false, "error": "no session with this backend_session_id"});
}
} else {
let Some(dir) = lookup_opencode_session_dir(state, &backend_sid).await else {
return json!({"delivered": false, "error": "no session with this backend_session_id"});
};
let Some(n) = auto_provision_from_backend_session(state, &backend_sid, &dir).await
else {
return json!({"delivered": false, "error": "no session with this backend_session_id"});
};
n
}
}
};
let delivered = deliver_pending_prompt(state, &name);
json!({"delivered": delivered, "session": name})
}
async fn auto_provision_from_backend_session(
state: &std::sync::Arc<crate::state::AppState>,
backend_sid: &str,
dir: &str,
) -> Option<String> {
{
let proto = state.protocol.read().await;
if let Some(existing) = proto
.sessions
.values()
.find(|s| s.metadata.backend_session_id.as_deref() == Some(backend_sid))
{
return Some(existing.id.clone());
}
}
let panes = state.list_assistant_panes().await;
let registered_panes: std::collections::HashSet<String> = {
let proto = state.protocol.read().await;
proto
.sessions
.values()
.filter(|s| matches!(s.origin, crate::daemon_protocol::Origin::Local))
.filter_map(|s| s.pane.clone())
.collect()
};
let candidates: Vec<String> = panes
.into_iter()
.filter(|p| {
!registered_panes.contains(&p.pane_id)
&& p.pane_current_path
.as_deref()
.map(|path| crate::state::resolve_project_root(path) == dir)
.unwrap_or(false)
})
.map(|p| p.pane_id)
.collect();
let pane_id = match candidates.len() {
1 => candidates.into_iter().next().unwrap(),
0 => {
tracing::warn!(
"auto-provision declined: no tmux pane running opencode in dir {dir} for backend_session_id {backend_sid}"
);
return None;
}
n => {
tracing::warn!(
"auto-provision declined: {n} opencode panes in dir {dir}; cannot map backend_session_id {backend_sid} unambiguously"
);
return None;
}
};
register_auto_provisioned_session(state, backend_sid, &pane_id, dir).await
}
async fn auto_provision_with_explicit_pane(
state: &std::sync::Arc<crate::state::AppState>,
backend_sid: &str,
pane: &str,
cwd: &str,
) -> Option<String> {
if cwd.is_empty() || cwd == "/" || !cwd.starts_with('/') {
tracing::warn!(
"auto-provision declined: invalid hint cwd {cwd:?} (must be absolute, non-empty, non-root) for backend_session_id {backend_sid}"
);
return None;
}
let dir = crate::state::resolve_project_root(cwd);
{
let proto = state.protocol.read().await;
if let Some(existing) = proto
.sessions
.values()
.find(|s| s.metadata.backend_session_id.as_deref() == Some(backend_sid))
{
return Some(existing.id.clone());
}
}
let panes = state.list_assistant_panes().await;
if !panes.iter().any(|p| p.pane_id == pane) {
tracing::warn!(
"auto-provision declined: hint pane {pane} is not among current assistant panes (backend_session_id {backend_sid})"
);
return None;
}
{
let proto = state.protocol.read().await;
let already_bound = proto.sessions.values().any(|s| {
matches!(s.origin, crate::daemon_protocol::Origin::Local)
&& s.pane.as_deref() == Some(pane)
});
if already_bound {
tracing::warn!(
"auto-provision declined: hint pane {pane} is already bound to another local session (backend_session_id {backend_sid})"
);
return None;
}
}
register_auto_provisioned_session(state, backend_sid, pane, dir).await
}
async fn register_auto_provisioned_session(
state: &std::sync::Arc<crate::state::AppState>,
backend_sid: &str,
pane_id: &str,
dir: &str,
) -> Option<String> {
let basename = std::path::Path::new(dir)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unnamed");
let base_id = crate::state::sanitize_session_id(basename);
if base_id.is_empty() {
tracing::warn!(
"auto-provision declined: could not derive a session id from dir {dir} (basename='{basename}')"
);
return None;
}
let id = {
let proto = state.protocol.read().await;
if let Some(existing) = proto
.sessions
.values()
.find(|s| s.metadata.backend_session_id.as_deref() == Some(backend_sid))
{
return Some(existing.id.clone());
}
let id_to_pane: std::collections::HashMap<String, Option<String>> = proto
.sessions
.iter()
.map(|(id, s)| (id.clone(), s.pane.clone()))
.collect();
crate::state::resolve_unique_session_id(&id_to_pane, &base_id, Some(pane_id))
};
tracing::info!(
"auto-provisioned session '{id}' for pane {pane_id} / backend_session_id {backend_sid} (dir: {dir})"
);
let metadata = crate::daemon_protocol::SessionMeta {
project_dir: Some(dir.to_string()),
role: Some(format!("working on {basename}")),
backend: Some("opencode".into()),
backend_session_id: Some(backend_sid.to_string()),
..Default::default()
};
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: id.clone(),
pane: Some(pane_id.to_string()),
metadata,
})
.await;
Some(id)
}
fn disambiguate_adoption_candidates(
backend_sid: &str,
dir: &str,
candidates: Vec<String>,
) -> Option<String> {
match candidates.len() {
0 => None,
1 => candidates.into_iter().next(),
n => {
tracing::warn!(
"refusing to adopt backend_session_id {backend_sid}: {n} ambiguous candidates in dir {dir}: {candidates:?}"
);
None
}
}
}
async fn lookup_opencode_session_dir(
state: &std::sync::Arc<crate::state::AppState>,
backend_sid: &str,
) -> Option<String> {
let port = state.opencode_serve_port();
let url = format!("http://127.0.0.1:{port}/session/{backend_sid}");
let resp = state
.http_client
.get(&url)
.timeout(std::time::Duration::from_secs(3))
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let body: serde_json::Value = resp.json().await.ok()?;
body["directory"].as_str().map(str::to_string)
}
fn parse_opencode_model(model: &str) -> Option<(String, String)> {
let trimmed = model.trim();
let (provider, model_id) = trimmed.split_once('/')?;
let provider = provider.trim();
let model_id = model_id.trim();
if provider.is_empty() || model_id.is_empty() {
return None;
}
Some((provider.to_string(), model_id.to_string()))
}
async fn resolve_opencode_compact_model(
state: &std::sync::Arc<crate::state::AppState>,
session_model: Option<&str>,
) -> Option<(String, String)> {
if let Some(m) = session_model
&& let Some(pair) = parse_opencode_model(m)
{
return Some(pair);
}
let port = state.opencode_serve_port();
let url = format!("http://127.0.0.1:{port}/config/providers");
let resp = state
.http_client
.get(&url)
.timeout(std::time::Duration::from_secs(5))
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let body: serde_json::Value = resp.json().await.ok()?;
let default_map = body.get("default")?.as_object()?;
let (provider, model) = default_map.iter().next()?;
let model_id = model.as_str()?;
Some((provider.clone(), model_id.to_string()))
}
async fn adopt_backend_session_id(
state: &std::sync::Arc<crate::state::AppState>,
backend_sid: &str,
) -> Option<String> {
let dir = lookup_opencode_session_dir(state, backend_sid).await?;
let candidates: Vec<String> = {
let proto = state.protocol.read().await;
proto
.sessions
.values()
.filter(|s| {
matches!(s.origin, crate::daemon_protocol::Origin::Local)
&& s.metadata.project_dir.as_deref() == Some(dir.as_str())
&& s.metadata.backend_session_id.is_none()
})
.map(|s| s.id.clone())
.collect()
};
let session_id = disambiguate_adoption_candidates(backend_sid, &dir, candidates)?;
tracing::info!(
"adopting backend_session_id {backend_sid} for session {session_id} (dir: {dir})"
);
state
.apply_and_execute(crate::daemon_protocol::Event::AdoptBackend {
id: session_id.clone(),
backend: "opencode".into(),
backend_session_id: backend_sid.to_string(),
})
.await;
Some(session_id)
}
pub async fn list_projects(
State(state): State<SharedState>,
) -> axum::Json<Vec<crate::project_index::ProjectInfo>> {
let index = state.project_index.read().await;
let mut projects: Vec<_> = index.values().cloned().collect();
projects.sort_by(|a, b| a.name.cmp(&b.name));
axum::Json(projects)
}
#[derive(Deserialize)]
pub struct ClearReminderBody {
pub from: String,
pub clearing_id: u64,
}
pub async fn clear_reminder(
State(state): State<SharedState>,
Json(body): Json<ClearReminderBody>,
) -> (StatusCode, Json<serde_json::Value>) {
state
.notify_agent(
&body.from,
crate::session_agent::SessionMsg::ClearReminder {
clearing_id: body.clearing_id,
},
)
.await;
(
StatusCode::OK,
Json(json!({
"cleared": body.clearing_id,
"session": body.from,
"hint": "Reminder paused. It will resume after new activity (incoming message, hook fire, etc.)."
})),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_optional_string_passthrough() {
assert_eq!(
normalize_optional_string(Some("sonnet".into())),
Some("sonnet".into())
);
assert_eq!(normalize_optional_string(None), None);
}
#[test]
fn normalize_optional_string_trims_and_drops_empty() {
assert_eq!(normalize_optional_string(Some("".into())), None);
assert_eq!(normalize_optional_string(Some(" ".into())), None);
assert_eq!(normalize_optional_string(Some("\t\n ".into())), None);
assert_eq!(
normalize_optional_string(Some(" opus ".into())),
Some("opus".into())
);
}
#[test]
fn disambiguate_single_candidate_adopts() {
let got = disambiguate_adoption_candidates("ses_x", "/repo", vec!["only".into()]);
assert_eq!(got.as_deref(), Some("only"));
}
#[test]
fn disambiguate_zero_candidates_fails() {
let got = disambiguate_adoption_candidates("ses_x", "/repo", vec![]);
assert!(got.is_none());
}
#[test]
fn disambiguate_multiple_candidates_fails_closed() {
let got = disambiguate_adoption_candidates(
"ses_x",
"/repo",
vec!["hub".into(), "hub-skill-probe".into()],
);
assert!(got.is_none());
}
#[test]
fn needle_for_127_0_0_1_encodes_little_endian_hex() {
let peer: SocketAddr = "127.0.0.1:45084".parse().unwrap();
assert_eq!(
needle_for_loopback_peer(peer).as_deref(),
Some("0100007F:B01C")
);
}
#[test]
fn parse_tcp_inode_finds_matching_local() {
let table = "\
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n\
0: 0100007F:B01C 0100007F:1EC8 01 00000000:00000000 00:00000000 00000000 1000 0 1234567 1 0000000000000000 20 4 20 10 -1\n\
1: 0100007F:1EC8 0100007F:B01C 01 00000000:00000000 00:00000000 00000000 1000 0 7654321 1 0000000000000000 20 4 20 10 -1\n";
assert_eq!(
parse_tcp_inode_for_local(table, "0100007F:B01C"),
Some(1234567)
);
assert_eq!(
parse_tcp_inode_for_local(table, "0100007F:1EC8"),
Some(7654321)
);
assert_eq!(parse_tcp_inode_for_local(table, "0100007F:FFFF"), None);
}
#[test]
fn parse_tcp_inode_skips_header_and_short_lines() {
let table = "sl local_address rem_address st\nshort line\n";
assert!(parse_tcp_inode_for_local(table, "0100007F:0001").is_none());
}
#[test]
fn extract_from_cargo_toml() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"foo\"\ndescription = \"A test crate\"\n",
)
.unwrap();
let desc = extract_project_description(dir.path().to_str().unwrap());
assert_eq!(desc.as_deref(), Some("A test crate"));
}
#[test]
fn extract_from_package_json() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("package.json"),
r#"{"name":"foo","description":"A JS project"}"#,
)
.unwrap();
let desc = extract_project_description(dir.path().to_str().unwrap());
assert_eq!(desc.as_deref(), Some("A JS project"));
}
#[test]
fn extract_from_readme() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("README.md"),
"# My Project\n\nThis is a great project.\n",
)
.unwrap();
let desc = extract_project_description(dir.path().to_str().unwrap());
assert_eq!(desc.as_deref(), Some("This is a great project."));
}
#[test]
fn extract_cargo_toml_preferred_over_readme() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\ndescription = \"From cargo\"\n",
)
.unwrap();
std::fs::write(dir.path().join("README.md"), "# Title\n\nFrom readme\n").unwrap();
let desc = extract_project_description(dir.path().to_str().unwrap());
assert_eq!(desc.as_deref(), Some("From cargo"));
}
#[test]
fn extract_missing_files_returns_none() {
let dir = tempfile::tempdir().unwrap();
assert!(extract_project_description(dir.path().to_str().unwrap()).is_none());
}
#[tokio::test]
async fn compact_session_not_found_returns_404() {
let state = crate::state::AppState::new_for_test();
let (status, body) = compact_inner(
&state,
"ghost".into(),
CompactBody {
continuation: Some("go".into()),
},
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body["error"].as_str().unwrap().contains("not found"));
}
#[tokio::test]
async fn compact_cc_without_pane_returns_400() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "cc-no-pane".into(),
pane: None,
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("claude-code".into()),
..Default::default()
},
})
.await;
let (status, body) = compact_inner(
&state,
"cc-no-pane".into(),
CompactBody {
continuation: Some("go".into()),
},
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body["error"].as_str().unwrap().contains("pane"));
}
#[tokio::test]
async fn compact_oc_without_backend_session_id_returns_400() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-no-sid".into(),
pane: None,
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: None,
..Default::default()
},
})
.await;
let (status, body) = compact_inner(
&state,
"oc-no-sid".into(),
CompactBody {
continuation: Some("go".into()),
},
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
body["error"]
.as_str()
.unwrap()
.contains("backend_session_id"),
"expected error to mention backend_session_id, got: {}",
body["error"]
);
}
#[tokio::test]
async fn compact_oc_summarize_failure_returns_502() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-fail".into(),
pane: None,
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_probe".into()),
model: Some("anthropic/claude-sonnet-4-6".into()),
..Default::default()
},
})
.await;
let (status, body) = compact_inner(
&state,
"oc-fail".into(),
CompactBody {
continuation: Some("keep going".into()),
},
)
.await;
assert_eq!(status, StatusCode::BAD_GATEWAY);
let err = body["error"].as_str().unwrap_or_default();
assert!(
err.contains("summarize"),
"expected error to mention /summarize, got: {err}"
);
}
#[tokio::test]
async fn compact_oc_bare_compact_is_no_longer_rejected_at_api_boundary() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-no-cont".into(),
pane: None,
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_probe".into()),
model: Some("anthropic/claude-sonnet-4-6".into()),
..Default::default()
},
})
.await;
let (status, body) = compact_inner(
&state,
"oc-no-cont".into(),
CompactBody { continuation: None },
)
.await;
assert_eq!(
status,
StatusCode::BAD_GATEWAY,
"bare /compact must now progress to the summarize call (which 502s in the test env), \
not get rejected at the API boundary"
);
assert!(
body["error"]
.as_str()
.unwrap_or_default()
.contains("summarize"),
"expected error to mention /summarize, got: {}",
body["error"]
);
}
#[tokio::test]
async fn compact_oc_empty_continuation_normalizes_to_bare_compact() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-empty-cont".into(),
pane: None,
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_probe".into()),
model: Some("anthropic/claude-sonnet-4-6".into()),
..Default::default()
},
})
.await;
let (status, body) = compact_inner(
&state,
"oc-empty-cont".into(),
CompactBody {
continuation: Some(" ".into()),
},
)
.await;
assert_eq!(status, StatusCode::BAD_GATEWAY);
assert!(
body["error"]
.as_str()
.unwrap_or_default()
.contains("summarize"),
"expected error to mention /summarize, got: {}",
body["error"]
);
}
#[tokio::test]
async fn compact_oc_without_model_and_serve_unreachable_returns_400() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-no-model".into(),
pane: None,
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_probe".into()),
model: None,
..Default::default()
},
})
.await;
let (status, body) = compact_inner(
&state,
"oc-no-model".into(),
CompactBody {
continuation: Some("keep going".into()),
},
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
let err = body["error"].as_str().unwrap_or_default();
assert!(
err.contains("provider") || err.contains("model"),
"expected error to mention provider/model resolution, got: {err}"
);
}
#[test]
fn compact_body_rejects_unknown_fields() {
let bad = serde_json::json!({"continuatino": "oops"});
let err = serde_json::from_value::<CompactBody>(bad).unwrap_err();
assert!(
err.to_string().contains("unknown field"),
"expected unknown-field error, got: {err}"
);
}
#[test]
fn compact_success_body_matches_docstring_envelope() {
let body = compact_success_body(false, None);
let obj = body.as_object().expect("success body is a JSON object");
assert_eq!(
obj.len(),
3,
"success body must have exactly 3 keys; got {:?}",
obj.keys().collect::<Vec<_>>()
);
assert_eq!(body["status"], "ok");
assert_eq!(body["compacted"], true);
assert_eq!(body["continuation_delivered"], false);
}
#[test]
fn compact_success_body_propagates_continuation_delivered_flag() {
assert_eq!(
compact_success_body(true, None)["continuation_delivered"],
true
);
assert_eq!(
compact_success_body(false, None)["continuation_delivered"],
false
);
}
#[test]
fn compact_success_body_with_error_preserves_envelope() {
let body = compact_success_body(false, Some("boom".into()));
let obj = body.as_object().expect("success body is a JSON object");
assert_eq!(obj.len(), 4);
assert_eq!(body["status"], "ok");
assert_eq!(body["compacted"], true);
assert_eq!(body["continuation_delivered"], false);
assert_eq!(body["error"], "boom");
}
#[tokio::test]
async fn compact_cc_inject_failure_drains_parked_continuation() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "cc-inject-fail".into(),
pane: Some("%999999999".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("claude-code".into()),
..Default::default()
},
})
.await;
let (status, _body) = compact_inner(
&state,
"cc-inject-fail".into(),
CompactBody {
continuation: Some("rollback me".into()),
},
)
.await;
assert_eq!(
status,
StatusCode::INTERNAL_SERVER_ERROR,
"expected inject failure to surface as 500"
);
let pending = state
.drain_agent_compact_continuation("cc-inject-fail")
.await;
assert_eq!(
pending, None,
"rollback must drain the parked continuation on inject failure — slot was not released"
);
}
#[tokio::test]
async fn compact_cc_with_pending_continuation_returns_409() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "cc-busy".into(),
pane: Some("%1".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("claude-code".into()),
..Default::default()
},
})
.await;
let acquired = state
.try_set_pending_compact_continuation("cc-busy", "first".into())
.await;
assert!(acquired, "slot should be empty for a fresh session");
let (status, body) = compact_inner(
&state,
"cc-busy".into(),
CompactBody {
continuation: Some("second".into()),
},
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
let err = body["error"].as_str().unwrap();
assert!(
err.contains("pending") || err.contains("in progress"),
"expected error to mention pending/in-progress, got: {err}"
);
let pending = state.drain_agent_compact_continuation("cc-busy").await;
assert_eq!(
pending.as_deref(),
Some("first"),
"first caller's continuation must not be overwritten by the rejected second attempt"
);
}
#[tokio::test]
async fn lookup_opencode_session_dir_returns_none_when_serve_unreachable() {
let state = crate::state::AppState::new_for_test();
let dir = lookup_opencode_session_dir(&state, "ses_does_not_exist").await;
assert!(
dir.is_none(),
"unreachable opencode serve must produce None, got Some({dir:?})"
);
}
#[test]
fn parse_opencode_model_two_segments() {
assert_eq!(
parse_opencode_model("anthropic/claude-sonnet-4-6"),
Some(("anthropic".into(), "claude-sonnet-4-6".into()))
);
}
#[test]
fn parse_opencode_model_splits_on_first_slash_only() {
assert_eq!(
parse_opencode_model("openrouter/openai/gpt-5.4"),
Some(("openrouter".into(), "openai/gpt-5.4".into()))
);
}
#[test]
fn parse_opencode_model_trims_whitespace_per_segment() {
assert_eq!(
parse_opencode_model(" openrouter / gpt-5.4 "),
Some(("openrouter".into(), "gpt-5.4".into()))
);
}
#[test]
fn parse_opencode_model_rejects_no_slash() {
assert_eq!(parse_opencode_model("sonnet"), None);
assert_eq!(parse_opencode_model(""), None);
assert_eq!(parse_opencode_model(" "), None);
}
#[test]
fn parse_opencode_model_rejects_empty_segment() {
assert_eq!(parse_opencode_model("/"), None);
assert_eq!(parse_opencode_model("anthropic/"), None);
assert_eq!(parse_opencode_model("/sonnet"), None);
assert_eq!(parse_opencode_model(" / "), None);
assert_eq!(parse_opencode_model("anthropic/ "), None);
}
#[tokio::test]
async fn resolve_opencode_compact_model_uses_session_model_when_parseable() {
let state = crate::state::AppState::new_for_test();
let result =
resolve_opencode_compact_model(&state, Some("anthropic/claude-sonnet-4-6")).await;
assert_eq!(
result,
Some(("anthropic".into(), "claude-sonnet-4-6".into()))
);
}
#[tokio::test]
async fn resolve_opencode_compact_model_returns_none_when_no_model_and_serve_unreachable() {
let state = crate::state::AppState::new_for_test();
let result = resolve_opencode_compact_model(&state, None).await;
assert_eq!(result, None);
}
#[tokio::test]
async fn resolve_opencode_compact_model_falls_through_on_unparseable_session_model() {
let state = crate::state::AppState::new_for_test();
let result = resolve_opencode_compact_model(&state, Some("sonnet")).await;
assert_eq!(result, None, "bare 'sonnet' must not be accepted as a pair");
}
fn pane_in(dir: &str, pane_id: &str) -> crate::tmux::TmuxPane {
crate::tmux::TmuxPane {
pane_id: pane_id.into(),
session_name: "test".into(),
pane_current_path: Some(dir.into()),
}
}
#[tokio::test]
async fn auto_provision_creates_session_for_single_matching_pane() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/freshproject", "%17")];
let result =
auto_provision_from_backend_session(&state, "ses_brand_new", "/tmp/freshproject").await;
let session_id = result.expect("auto-provision must succeed for exactly one matching pane");
assert_eq!(session_id, "freshproject");
let proto = state.protocol.read().await;
let session = proto
.sessions
.get(&session_id)
.expect("session must exist in protocol state");
assert_eq!(session.pane.as_deref(), Some("%17"), "pane must be bound");
assert_eq!(
session.metadata.backend.as_deref(),
Some("opencode"),
"backend must be opencode"
);
assert_eq!(
session.metadata.backend_session_id.as_deref(),
Some("ses_brand_new"),
"backend_session_id must be bound atomically with the Register"
);
assert_eq!(
session.metadata.project_dir.as_deref(),
Some("/tmp/freshproject"),
"project_dir must be set"
);
drop(proto);
let resolved = state.find_session_by_pane("%17").await;
assert_eq!(
resolved.as_deref(),
Some("freshproject"),
"find_session_by_pane must resolve the auto-provisioned pane"
);
}
#[tokio::test]
async fn auto_provision_declines_when_no_pane_matches_dir() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/someother", "%11")];
let result =
auto_provision_from_backend_session(&state, "ses_unmatched", "/tmp/freshproject").await;
assert!(
result.is_none(),
"auto-provision must decline with zero matching panes, got Some({result:?})"
);
let proto = state.protocol.read().await;
assert!(
proto.sessions.is_empty(),
"no session must be created on zero-match decline, got: {:?}",
proto.sessions.keys().collect::<Vec<_>>()
);
}
#[tokio::test]
async fn auto_provision_declines_on_ambiguous_multiple_panes() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![
pane_in("/tmp/freshproject", "%17"),
pane_in("/tmp/freshproject", "%23"),
];
let result =
auto_provision_from_backend_session(&state, "ses_ambiguous", "/tmp/freshproject").await;
assert!(
result.is_none(),
"auto-provision must decline on >=2 matching panes, got Some({result:?})"
);
let proto = state.protocol.read().await;
assert!(
proto.sessions.is_empty(),
"no session must be created on ambiguity decline"
);
}
#[tokio::test]
async fn auto_provision_skips_panes_already_registered() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/freshproject", "%17")];
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "preexisting".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
project_dir: Some("/tmp/freshproject".into()),
..Default::default()
},
})
.await;
let result =
auto_provision_from_backend_session(&state, "ses_intruder", "/tmp/freshproject").await;
assert!(
result.is_none(),
"auto-provision must skip already-registered panes, got Some({result:?})"
);
let proto = state.protocol.read().await;
assert_eq!(
proto.sessions.len(),
1,
"no additional session must be created, got: {:?}",
proto.sessions.keys().collect::<Vec<_>>()
);
let preexisting = proto.sessions.get("preexisting").unwrap();
assert!(
preexisting.metadata.backend_session_id.is_none(),
"pre-existing session must NOT be clobbered with the intruder's backend_session_id"
);
}
#[tokio::test]
async fn auto_provision_short_circuits_when_concurrent_call_won_the_race() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/freshproject", "%17")];
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "already-bound".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
project_dir: Some("/tmp/freshproject".into()),
backend: Some("opencode".into()),
backend_session_id: Some("ses_raced".into()),
..Default::default()
},
})
.await;
let result =
auto_provision_from_backend_session(&state, "ses_raced", "/tmp/freshproject").await;
assert_eq!(
result.as_deref(),
Some("already-bound"),
"recheck must surface the concurrent winner's id, not invent a new one"
);
let proto = state.protocol.read().await;
assert_eq!(
proto.sessions.len(),
1,
"no extra session must be created on the lost race"
);
}
#[tokio::test]
async fn backend_session_ready_respects_auto_register_disabled() {
let state = crate::state::AppState::new_for_test();
state.settings.write().await.auto_register = false;
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/freshproject", "%17")];
let response = backend_session_ready_inner(&state, "ses_gated".into()).await;
assert_eq!(response["delivered"], false);
assert!(
response["error"]
.as_str()
.unwrap_or("")
.contains("no session with this backend_session_id"),
"expected strict error, got: {response}"
);
assert!(response.get("session").is_none());
let proto = state.protocol.read().await;
assert!(
proto.sessions.is_empty(),
"no session must be created when auto_register is disabled"
);
}
#[tokio::test]
async fn backend_session_ready_returns_strict_error_when_serve_unreachable() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/freshproject", "%17")];
let response = backend_session_ready_inner(&state, "ses_no_serve".into()).await;
assert_eq!(response["delivered"], false);
assert!(
response["error"]
.as_str()
.unwrap_or("")
.contains("no session with this backend_session_id"),
"expected strict error, got: {response}"
);
let proto = state.protocol.read().await;
assert!(
proto.sessions.is_empty(),
"no session must be created when opencode serve is unreachable"
);
}
#[tokio::test]
async fn backend_session_ready_direct_lookup_hits_when_session_already_bound() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "prebound".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
project_dir: Some("/tmp/freshproject".into()),
backend: Some("opencode".into()),
backend_session_id: Some("ses_known".into()),
..Default::default()
},
})
.await;
let response = backend_session_ready_inner(&state, "ses_known".into()).await;
assert_eq!(
response["session"].as_str(),
Some("prebound"),
"direct lookup must surface the session id, got: {response}"
);
}
#[tokio::test]
async fn backend_session_ready_uses_explicit_pane_and_cwd_hints() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/different-dir", "%31")];
let hints = BackendSessionReadyHints {
pane: Some("%31".into()),
cwd: Some("/tmp/explicit-project".into()),
};
let response =
backend_session_ready_inner_with_hints(&state, "ses_explicit".into(), hints).await;
assert_eq!(
response["session"].as_str(),
Some("explicit-project"),
"hint path must auto-provision with id derived from cwd basename, got: {response}"
);
let proto = state.protocol.read().await;
let session = proto
.sessions
.get("explicit-project")
.expect("session exists");
assert_eq!(session.pane.as_deref(), Some("%31"));
assert_eq!(
session.metadata.backend_session_id.as_deref(),
Some("ses_explicit"),
);
assert_eq!(
session.metadata.project_dir.as_deref(),
Some("/tmp/explicit-project"),
);
}
#[tokio::test]
async fn backend_session_ready_explicit_hints_respect_auto_register_disabled() {
let state = crate::state::AppState::new_for_test();
state.settings.write().await.auto_register = false;
let hints = BackendSessionReadyHints {
pane: Some("%31".into()),
cwd: Some("/tmp/explicit-project".into()),
};
let response =
backend_session_ready_inner_with_hints(&state, "ses_gated_with_hints".into(), hints)
.await;
assert_eq!(response["delivered"], false);
assert!(
response["error"]
.as_str()
.unwrap_or("")
.contains("no session with this backend_session_id"),
"expected strict error, got: {response}"
);
let proto = state.protocol.read().await;
assert!(
proto.sessions.is_empty(),
"hints must not bypass the auto_register opt-out"
);
}
#[tokio::test]
async fn backend_session_ready_partial_hints_fall_back_to_scan_path() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/half-hinted", "%31")];
let hints = BackendSessionReadyHints {
pane: Some("%31".into()),
cwd: None,
};
let response =
backend_session_ready_inner_with_hints(&state, "ses_half".into(), hints).await;
assert_eq!(response["delivered"], false);
assert!(response.get("session").is_none());
let proto = state.protocol.read().await;
assert!(proto.sessions.is_empty());
}
#[tokio::test]
async fn backend_session_ready_explicit_hints_direct_lookup_still_wins() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "prebound".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
project_dir: Some("/tmp/some-real-project".into()),
backend: Some("opencode".into()),
backend_session_id: Some("ses_known".into()),
..Default::default()
},
})
.await;
let hints = BackendSessionReadyHints {
pane: Some("%99".into()),
cwd: Some("/tmp/unrelated".into()),
};
let response =
backend_session_ready_inner_with_hints(&state, "ses_known".into(), hints).await;
assert_eq!(
response["session"].as_str(),
Some("prebound"),
"direct lookup must win even when hints point elsewhere, got: {response}"
);
let proto = state.protocol.read().await;
assert_eq!(
proto.sessions.len(),
1,
"no session must be created from the hints when direct lookup hits"
);
}
#[tokio::test]
async fn hint_path_rejects_pane_not_in_assistant_panes() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/unrelated", "%11")];
let result = auto_provision_with_explicit_pane(
&state,
"ses_ghost",
"%99", "/tmp/freshproject",
)
.await;
assert!(
result.is_none(),
"hint path must reject a pane that is not in list_assistant_panes, got Some({result:?})"
);
let proto = state.protocol.read().await;
assert!(
proto.sessions.is_empty(),
"no session must be created for an unverified pane"
);
}
#[tokio::test]
async fn hint_path_rejects_empty_cwd() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/ignored", "%17")];
let result = auto_provision_with_explicit_pane(
&state,
"ses_bad_cwd",
"%17",
"", )
.await;
assert!(
result.is_none(),
"empty cwd must be rejected, got Some({result:?})"
);
assert!(state.protocol.read().await.sessions.is_empty());
}
#[tokio::test]
async fn hint_path_rejects_bare_root_cwd() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/ignored", "%17")];
let result = auto_provision_with_explicit_pane(&state, "ses_root_cwd", "%17", "/").await;
assert!(
result.is_none(),
"bare `/` cwd must be rejected, got Some({result:?})"
);
assert!(state.protocol.read().await.sessions.is_empty());
}
#[tokio::test]
async fn hint_path_rejects_relative_cwd() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/ignored", "%17")];
let result =
auto_provision_with_explicit_pane(&state, "ses_rel_cwd", "%17", "relative/path").await;
assert!(
result.is_none(),
"relative cwd must be rejected, got Some({result:?})"
);
assert!(state.protocol.read().await.sessions.is_empty());
}
#[test]
fn hints_tolerate_unknown_fields_for_forward_compat() {
let body =
br#"{"pane":"%17","cwd":"/tmp/foo","tty_path":"/dev/pts/3","plugin_version":"2.0"}"#;
let hints: BackendSessionReadyHints =
serde_json::from_slice(body).expect("unknown fields must not fail parsing");
assert_eq!(hints.pane.as_deref(), Some("%17"));
assert_eq!(hints.cwd.as_deref(), Some("/tmp/foo"));
}
#[tokio::test]
async fn hint_path_rejects_pane_already_registered_to_another_session() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/freshproject", "%17")];
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "prebound".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
project_dir: Some("/tmp/freshproject".into()),
..Default::default()
},
})
.await;
let result = auto_provision_with_explicit_pane(
&state,
"ses_hijacker",
"%17", "/tmp/freshproject",
)
.await;
assert!(
result.is_none(),
"hint path must refuse to hijack an already-registered pane, got Some({result:?})"
);
let proto = state.protocol.read().await;
assert_eq!(
proto.sessions.len(),
1,
"the victim session must survive intact"
);
let survivor = proto.sessions.get("prebound").unwrap();
assert!(
survivor.metadata.backend_session_id.is_none(),
"victim's metadata must not be rewritten with the hijacker's backend_session_id"
);
}
#[test]
fn session_name_body_parses_force_reset_when_present() {
let body: SessionNameBody = serde_json::from_str(
r#"{"name":"s","worktree":true,"base_branch":"main","force_reset":true}"#,
)
.expect("body parses");
assert_eq!(
body.force_reset,
Some(true),
"force_reset=true must deserialize to Some(true)"
);
}
#[test]
fn session_name_body_force_reset_defaults_to_none() {
let body: SessionNameBody = serde_json::from_str(r#"{"name":"s"}"#).expect("body parses");
assert_eq!(
body.force_reset, None,
"omitted force_reset must deserialize to None (safe default)"
);
}
#[test]
fn restart_drops_destructive_intent_fires_for_force_reset_true() {
let body: SessionNameBody =
serde_json::from_str(r#"{"name":"s","force_reset":true}"#).unwrap();
let warn = restart_drops_destructive_intent(&body);
assert!(
warn.is_some(),
"force_reset=true on the restart path must produce a warn message"
);
let msg = warn.unwrap();
assert!(
msg.contains("force_reset"),
"warn message must mention force_reset, got: {msg}"
);
}
#[test]
fn restart_drops_destructive_intent_fires_for_base_branch() {
let body: SessionNameBody =
serde_json::from_str(r#"{"name":"s","base_branch":"main"}"#).unwrap();
let warn = restart_drops_destructive_intent(&body);
assert!(
warn.is_some(),
"base_branch on the restart path must produce a warn message — \
restart_session cannot act on it"
);
assert!(
warn.unwrap().contains("base_branch"),
"warn message must mention base_branch"
);
}
#[test]
fn restart_drops_destructive_intent_silent_when_no_opt_in() {
let body: SessionNameBody = serde_json::from_str(r#"{"name":"s"}"#).unwrap();
assert!(
restart_drops_destructive_intent(&body).is_none(),
"no opt-in supplied, no warn"
);
}
#[test]
fn restart_drops_destructive_intent_silent_when_force_reset_false() {
let body: SessionNameBody =
serde_json::from_str(r#"{"name":"s","force_reset":false}"#).unwrap();
assert!(
restart_drops_destructive_intent(&body).is_none(),
"force_reset=false is not an opt-in; no warn"
);
}
#[tokio::test]
async fn resolve_pane_to_session_accepts_bare_suffix() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sess-a".into(),
pane: Some("%74".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
let proto = state.protocol.read().await;
assert_eq!(
resolve_pane_to_session(&proto, "74").as_deref(),
Some("sess-a"),
"bare numeric suffix must resolve"
);
}
#[tokio::test]
async fn resolve_pane_to_session_accepts_percent_prefix() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sess-a".into(),
pane: Some("%74".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
let proto = state.protocol.read().await;
assert_eq!(
resolve_pane_to_session(&proto, "%74").as_deref(),
Some("sess-a"),
"%-prefixed form must also resolve (future %25-encoded callers)"
);
}
#[tokio::test]
async fn resolve_pane_to_session_none_for_unknown_pane() {
let state = crate::state::AppState::new_for_test();
let proto = state.protocol.read().await;
assert!(resolve_pane_to_session(&proto, "999").is_none());
assert!(resolve_pane_to_session(&proto, "%999").is_none());
}
#[tokio::test]
async fn get_pending_replies_returns_404_for_unknown_pane() {
let state = crate::state::AppState::new_for_test();
let (status, _) = get_pending_replies_inner(&state, "999".into()).await;
assert_eq!(
status,
StatusCode::NOT_FOUND,
"unknown pane must 404, never 200 + empty"
);
}
#[tokio::test]
async fn get_pending_replies_returns_200_for_known_pane() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sess-a".into(),
pane: Some("%74".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
let (status, body) = get_pending_replies_inner(&state, "74".into()).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["count"].as_u64(), Some(0));
}
#[tokio::test]
async fn delete_pending_reply_returns_404_for_unknown_pane() {
let state = crate::state::AppState::new_for_test();
let (status, body) =
delete_pending_reply_inner(&state, "999".into(), "sender".into()).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(
body["error"].as_str().is_some(),
"404 response must include a JSON error field, got: {body}"
);
}
#[tokio::test]
async fn delete_pending_reply_returns_cleared_count_when_slot_existed() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sender-a".into(),
pane: Some("%74".into()),
metadata: crate::daemon_protocol::SessionMeta {
networked: true,
..Default::default()
},
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "receiver-b".into(),
pane: Some("%99".into()),
metadata: crate::daemon_protocol::SessionMeta {
networked: true,
..Default::default()
},
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Send {
from: "sender-a".into(),
to: "receiver-b".into(),
message: "do a thing".into(),
expects_reply: true,
responds_to: None,
done: false,
})
.await;
let (status, body) =
delete_pending_reply_inner(&state, "99".into(), "sender-a".into()).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body["cleared"].as_u64(),
Some(1),
"one slot existed → cleared must be 1, got body: {body}"
);
}
#[tokio::test]
async fn delete_pending_reply_reports_cleared_zero_when_nothing_to_clear() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "receiver-b".into(),
pane: Some("%99".into()),
metadata: crate::daemon_protocol::SessionMeta {
networked: true,
..Default::default()
},
})
.await;
let (status, body) =
delete_pending_reply_inner(&state, "99".into(), "ghost-sender".into()).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body["cleared"].as_u64(),
Some(0),
"no matching slot → cleared must be 0, got body: {body}"
);
}
#[test]
fn clear_pending_reply_from_returns_removed_count() {
use crate::daemon_protocol::{DaemonState, Event, SessionMeta};
let mut state = DaemonState::new_for_model("d".into(), "h".into());
state.apply(Event::Register {
id: "sender".into(),
pane: Some("%1".into()),
metadata: SessionMeta {
networked: true,
..Default::default()
},
});
state.apply(Event::Register {
id: "target".into(),
pane: Some("%2".into()),
metadata: SessionMeta {
networked: true,
..Default::default()
},
});
state.apply(Event::Send {
from: "sender".into(),
to: "target".into(),
message: "x".into(),
expects_reply: true,
responds_to: None,
done: false,
});
assert_eq!(state.clear_pending_reply_from("target", "sender"), 1);
assert_eq!(state.clear_pending_reply_from("target", "sender"), 0);
assert_eq!(state.clear_pending_reply_from("ghost", "sender"), 0);
}
#[tokio::test]
async fn delete_pending_reply_end_to_end_through_axum_router() {
use axum::Router;
use axum::routing::delete;
use tokio::net::TcpListener;
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sender-a".into(),
pane: Some("%74".into()),
metadata: crate::daemon_protocol::SessionMeta {
networked: true,
..Default::default()
},
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "receiver-b".into(),
pane: Some("%99".into()),
metadata: crate::daemon_protocol::SessionMeta {
networked: true,
..Default::default()
},
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Send {
from: "sender-a".into(),
to: "receiver-b".into(),
message: "do a thing".into(),
expects_reply: true,
responds_to: None,
done: false,
})
.await;
let app = Router::new()
.route(
"/api/pane/{pane}/pending-replies/{from}",
delete(delete_pending_reply),
)
.with_state(state.clone());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let client = reqwest::Client::new();
let url = format!("http://{addr}/api/pane/99/pending-replies/sender-a");
let resp = client.delete(&url).send().await.unwrap();
assert_eq!(
resp.status().as_u16(),
200,
"DELETE on real pane must return 200, not silent 404"
);
{
let proto = state.protocol.read().await;
let still_there = proto
.pending_replies
.get("receiver-b")
.map(|v| v.iter().any(|e| e.from == "sender-a"))
.unwrap_or(false);
assert!(
!still_there,
"pending-reply slot must be cleared after the DELETE"
);
}
let url2 = format!("http://{addr}/api/pane/%2599/pending-replies/sender-a");
let resp2 = client.delete(&url2).send().await.unwrap();
assert_eq!(
resp2.status().as_u16(),
200,
"%25-encoded `%` form must also route to the right pane"
);
let url3 = format!("http://{addr}/api/pane/%74/pending-replies/sender-a");
let resp3 = client.delete(&url3).send().await.unwrap();
assert_eq!(
resp3.status().as_u16(),
404,
"raw `%74` URL (the pre-fix CLI's bug) must 404, not silently match"
);
server.abort();
}
#[tokio::test]
async fn delete_pending_reply_handles_slash_containing_sender_id() {
use axum::Router;
use axum::routing::delete;
use tokio::net::TcpListener;
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "feat/646-test".into(),
pane: Some("%74".into()),
metadata: crate::daemon_protocol::SessionMeta {
networked: true,
..Default::default()
},
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "receiver-b".into(),
pane: Some("%99".into()),
metadata: crate::daemon_protocol::SessionMeta {
networked: true,
..Default::default()
},
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Send {
from: "feat/646-test".into(),
to: "receiver-b".into(),
message: "do a thing".into(),
expects_reply: true,
responds_to: None,
done: false,
})
.await;
let app = Router::new()
.route(
"/api/pane/{pane}/pending-replies/{from}",
delete(delete_pending_reply),
)
.with_state(state.clone());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let client = reqwest::Client::new();
let buggy_url =
format!("http://{addr}/api/pane/99/pending-replies/feat/646-test");
let buggy_resp = client.delete(&buggy_url).send().await.unwrap();
assert_eq!(
buggy_resp.status().as_u16(),
404,
"raw `/` in sender_id must break route matching (not silently \
succeed); the CLI fix is to percent-encode it"
);
{
let proto = state.protocol.read().await;
let entries = proto
.pending_replies
.get("receiver-b")
.expect("slot should still exist after a 404");
assert!(entries.iter().any(|e| e.from == "feat/646-test"));
}
let encoded_url =
format!("http://{addr}/api/pane/99/pending-replies/feat%2F646-test");
let resp = client.delete(&encoded_url).send().await.unwrap();
assert_eq!(
resp.status().as_u16(),
200,
"percent-encoded sender_id must route to the handler and clear the slot"
);
let proto = state.protocol.read().await;
let still_there = proto
.pending_replies
.get("receiver-b")
.map(|v| v.iter().any(|e| e.from == "feat/646-test"))
.unwrap_or(false);
assert!(
!still_there,
"slot from sender `feat/646-test` must be cleared"
);
server.abort();
}
#[tokio::test]
async fn delete_pending_reply_clears_stuck_slot_after_sender_renamed() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sender-a".into(),
pane: Some("%74".into()),
metadata: crate::daemon_protocol::SessionMeta {
networked: true,
..Default::default()
},
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "receiver-b".into(),
pane: Some("%99".into()),
metadata: crate::daemon_protocol::SessionMeta {
networked: true,
..Default::default()
},
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Send {
from: "sender-a".into(),
to: "receiver-b".into(),
message: "do a thing".into(),
expects_reply: true,
responds_to: None,
done: false,
})
.await;
{
let proto = state.protocol.read().await;
let entries = proto
.pending_replies
.get("receiver-b")
.expect("receiver should have a pending-reply bucket");
assert!(
entries.iter().any(|e| e.from == "sender-a"),
"sender-a slot should exist before clear"
);
}
state
.apply_and_execute(crate::daemon_protocol::Event::Rename {
old_id: "sender-a".into(),
new_id: "sender-renamed".into(),
})
.await;
{
let proto = state.protocol.read().await;
let entries = proto
.pending_replies
.get("receiver-b")
.expect("receiver bucket must survive rename of sender");
assert!(
entries.iter().any(|e| e.from == "sender-a"),
"sender-a slot must still be there after rename — this is \
the bug shape we're proving we can clear"
);
}
let (status, body) =
delete_pending_reply_inner(&state, "99".into(), "sender-a".into()).await;
assert_eq!(
status,
StatusCode::OK,
"clear-reply on real pane + real pending slot must return 200"
);
assert_eq!(
body["cleared"].as_u64(),
Some(1),
"cleared must report the removed slot count so the CLI is not lied to"
);
let proto = state.protocol.read().await;
let still_there = proto
.pending_replies
.get("receiver-b")
.map(|v| v.iter().any(|e| e.from == "sender-a"))
.unwrap_or(false);
assert!(
!still_there,
"sender-a slot must be cleared after DELETE /api/pane/99/pending-replies/sender-a"
);
}
#[tokio::test]
async fn prune_stale_sessions_dry_run_lists_stale() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "stale-s1".into(),
pane: Some("%1".into()),
metadata: crate::daemon_protocol::SessionMeta {
project_dir: Some("/tmp/nonexistent".into()),
worktree_present: Some(false),
..Default::default()
},
})
.await;
let (status, body) = prune_stale_sessions(
State(state.clone()),
Json(PruneStaleBody { confirm: false }),
)
.await;
assert_eq!(status, StatusCode::OK);
let value = body.0;
assert_eq!(value["dry_run"], true);
assert_eq!(value["would_prune"], serde_json::json!(["stale-s1"]));
let proto = state.protocol.read().await;
assert!(proto.sessions.contains_key("stale-s1"));
}
#[tokio::test]
async fn prune_stale_sessions_confirm_removes_stale() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "stale-s1".into(),
pane: Some("%1".into()),
metadata: crate::daemon_protocol::SessionMeta {
project_dir: Some("/tmp/nonexistent".into()),
worktree_present: Some(false),
..Default::default()
},
})
.await;
let (status, body) = prune_stale_sessions(
State(state.clone()),
Json(PruneStaleBody { confirm: true }),
)
.await;
assert_eq!(status, StatusCode::OK);
let value = body.0;
assert_eq!(value["dry_run"], false);
assert_eq!(value["pruned"], serde_json::json!(["stale-s1"]));
let proto = state.protocol.read().await;
assert!(!proto.sessions.contains_key("stale-s1"));
}
}