use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub const PROTOCOL_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SocketRequest {
pub version: u32,
pub request_id: String,
pub command: String,
pub payload: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SocketResponse {
pub version: u32,
pub request_id: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub payload: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<SocketError>,
}
impl SocketResponse {
pub fn is_ok(&self) -> bool {
self.status == "ok"
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SocketError {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentStateInfo {
pub state: String,
pub last_transition: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentSummary {
pub agent: String,
pub state: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LaunchConfig {
pub agent: String,
pub team: String,
pub command: String,
pub prompt: Option<String>,
pub timeout_secs: u32,
pub env_vars: std::collections::HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LaunchResult {
pub agent: String,
pub pane_id: String,
pub state: String,
pub warning: Option<String>,
}
pub fn launch_agent(config: &LaunchConfig) -> anyhow::Result<Option<LaunchResult>> {
let payload = match serde_json::to_value(config) {
Ok(v) => v,
Err(e) => anyhow::bail!("Failed to serialize LaunchConfig: {e}"),
};
let request = SocketRequest {
version: PROTOCOL_VERSION,
request_id: new_request_id(),
command: "launch".to_string(),
payload,
};
let response = match query_daemon(&request)? {
Some(r) => r,
None => return Ok(None),
};
if !response.is_ok() {
let msg = response
.error
.map(|e| format!("{}: {}", e.code, e.message))
.unwrap_or_else(|| "unknown daemon error".to_string());
anyhow::bail!("Daemon returned error for launch command: {msg}");
}
let payload = match response.payload {
Some(p) => p,
None => return Ok(None),
};
match serde_json::from_value::<LaunchResult>(payload) {
Ok(result) => Ok(Some(result)),
Err(e) => anyhow::bail!("Failed to parse LaunchResult from daemon response: {e}"),
}
}
pub fn daemon_socket_path() -> anyhow::Result<PathBuf> {
let home = crate::home::get_home_dir()?;
Ok(home.join(".claude/daemon/atm-daemon.sock"))
}
pub fn daemon_pid_path() -> anyhow::Result<PathBuf> {
let home = crate::home::get_home_dir()?;
Ok(home.join(".claude/daemon/atm-daemon.pid"))
}
#[allow(unused_variables)]
pub fn daemon_is_running() -> bool {
#[cfg(unix)]
{
let pid_path = match daemon_pid_path() {
Ok(p) => p,
Err(_) => return false,
};
if let Ok(content) = std::fs::read_to_string(&pid_path) {
if let Ok(pid) = content.trim().parse::<i32>() {
return pid_alive(pid);
}
}
false
}
#[cfg(not(unix))]
{
false
}
}
#[allow(unused_variables)]
pub fn query_daemon(request: &SocketRequest) -> anyhow::Result<Option<SocketResponse>> {
#[cfg(unix)]
{
query_daemon_unix(request)
}
#[cfg(not(unix))]
{
Ok(None)
}
}
pub fn query_agent_state(agent: &str, team: &str) -> anyhow::Result<Option<AgentStateInfo>> {
let request = SocketRequest {
version: PROTOCOL_VERSION,
request_id: new_request_id(),
command: "agent-state".to_string(),
payload: serde_json::json!({ "agent": agent, "team": team }),
};
let response = match query_daemon(&request)? {
Some(r) => r,
None => return Ok(None),
};
if !response.is_ok() {
return Ok(None);
}
let payload = match response.payload {
Some(p) => p,
None => return Ok(None),
};
match serde_json::from_value::<AgentStateInfo>(payload) {
Ok(info) => Ok(Some(info)),
Err(_) => Ok(None),
}
}
pub fn subscribe_to_agent(
subscriber: &str,
agent: &str,
team: &str,
events: &[String],
) -> anyhow::Result<Option<SocketResponse>> {
let request = SocketRequest {
version: PROTOCOL_VERSION,
request_id: new_request_id(),
command: "subscribe".to_string(),
payload: serde_json::json!({
"subscriber": subscriber,
"agent": agent,
"team": team,
"events": events,
}),
};
query_daemon(&request)
}
pub fn unsubscribe_from_agent(
subscriber: &str,
agent: &str,
team: &str,
) -> anyhow::Result<Option<SocketResponse>> {
let request = SocketRequest {
version: PROTOCOL_VERSION,
request_id: new_request_id(),
command: "unsubscribe".to_string(),
payload: serde_json::json!({
"subscriber": subscriber,
"agent": agent,
"team": team,
}),
};
query_daemon(&request)
}
pub fn query_list_agents() -> anyhow::Result<Option<Vec<AgentSummary>>> {
let request = SocketRequest {
version: PROTOCOL_VERSION,
request_id: new_request_id(),
command: "list-agents".to_string(),
payload: serde_json::Value::Object(Default::default()),
};
let response = match query_daemon(&request)? {
Some(r) => r,
None => return Ok(None),
};
if !response.is_ok() {
return Ok(None);
}
let payload = match response.payload {
Some(p) => p,
None => return Ok(None),
};
match serde_json::from_value::<Vec<AgentSummary>>(payload) {
Ok(agents) => Ok(Some(agents)),
Err(_) => Ok(None),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentPaneInfo {
pub pane_id: String,
pub log_path: String,
}
pub fn query_agent_pane(agent: &str) -> anyhow::Result<Option<AgentPaneInfo>> {
let request = SocketRequest {
version: PROTOCOL_VERSION,
request_id: new_request_id(),
command: "agent-pane".to_string(),
payload: serde_json::json!({ "agent": agent }),
};
let response = match query_daemon(&request)? {
Some(r) => r,
None => return Ok(None),
};
if !response.is_ok() {
return Ok(None);
}
let payload = match response.payload {
Some(p) => p,
None => return Ok(None),
};
match serde_json::from_value::<AgentPaneInfo>(payload) {
Ok(info) => Ok(Some(info)),
Err(_) => Ok(None),
}
}
fn new_request_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
let id = std::process::id();
format!("req-{id}-{nanos}")
}
#[cfg(unix)]
fn query_daemon_unix(request: &SocketRequest) -> anyhow::Result<Option<SocketResponse>> {
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::time::Duration;
let socket_path = daemon_socket_path()?;
let stream = match UnixStream::connect(&socket_path) {
Ok(s) => s,
Err(_) => return Ok(None),
};
let timeout = Duration::from_millis(500);
stream.set_read_timeout(Some(timeout)).ok();
stream.set_write_timeout(Some(timeout)).ok();
let request_line = serde_json::to_string(request)?;
{
let mut writer = std::io::BufWriter::new(&stream);
writer.write_all(request_line.as_bytes())?;
writer.write_all(b"\n")?;
writer.flush()?;
}
let mut reader = BufReader::new(&stream);
let mut response_line = String::new();
match reader.read_line(&mut response_line) {
Ok(0) | Err(_) => return Ok(None), Ok(_) => {}
}
let response: SocketResponse = match serde_json::from_str(response_line.trim()) {
Ok(r) => r,
Err(_) => return Ok(None),
};
Ok(Some(response))
}
#[cfg(unix)]
fn pid_alive(pid: i32) -> bool {
unsafe extern "C" {
fn kill(pid: i32, sig: i32) -> i32;
}
let result = unsafe { kill(pid, 0) };
result == 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_socket_request_serialization() {
let req = SocketRequest {
version: 1,
request_id: "req-123".to_string(),
command: "agent-state".to_string(),
payload: serde_json::json!({ "agent": "arch-ctm", "team": "atm-dev" }),
};
let json = serde_json::to_string(&req).unwrap();
let decoded: SocketRequest = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.version, 1);
assert_eq!(decoded.request_id, "req-123");
assert_eq!(decoded.command, "agent-state");
}
#[test]
fn test_socket_response_ok_deserialization() {
let json = r#"{"version":1,"request_id":"req-123","status":"ok","payload":{"state":"idle","last_transition":"2026-02-16T22:30:00Z"}}"#;
let resp: SocketResponse = serde_json::from_str(json).unwrap();
assert!(resp.is_ok());
assert_eq!(resp.request_id, "req-123");
assert!(resp.payload.is_some());
assert!(resp.error.is_none());
}
#[test]
fn test_socket_response_error_deserialization() {
let json = r#"{"version":1,"request_id":"req-456","status":"error","error":{"code":"AGENT_NOT_FOUND","message":"Agent 'unknown' is not tracked"}}"#;
let resp: SocketResponse = serde_json::from_str(json).unwrap();
assert!(!resp.is_ok());
let err = resp.error.unwrap();
assert_eq!(err.code, "AGENT_NOT_FOUND");
}
#[test]
fn test_agent_state_info_deserialization() {
let json = r#"{"state":"idle","last_transition":"2026-02-16T22:30:00Z"}"#;
let info: AgentStateInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.state, "idle");
assert_eq!(info.last_transition.as_deref(), Some("2026-02-16T22:30:00Z"));
}
#[test]
fn test_agent_state_info_missing_transition() {
let json = r#"{"state":"launching"}"#;
let info: AgentStateInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.state, "launching");
assert!(info.last_transition.is_none());
}
#[test]
fn test_query_daemon_no_socket_returns_none() {
let req = SocketRequest {
version: PROTOCOL_VERSION,
request_id: "req-test".to_string(),
command: "agent-state".to_string(),
payload: serde_json::json!({}),
};
let result = query_daemon(&req);
assert!(result.is_ok());
}
#[test]
fn test_new_request_id_is_unique() {
let id1 = new_request_id();
std::thread::sleep(std::time::Duration::from_nanos(1000));
let id2 = new_request_id();
assert!(!id1.is_empty());
assert!(!id2.is_empty());
}
#[test]
fn test_daemon_socket_path_contains_expected_suffix() {
let path = daemon_socket_path().unwrap();
assert!(path.to_string_lossy().ends_with("atm-daemon.sock"));
assert!(path.to_string_lossy().contains(".claude/daemon"));
}
#[test]
fn test_daemon_pid_path_contains_expected_suffix() {
let path = daemon_pid_path().unwrap();
assert!(path.to_string_lossy().ends_with("atm-daemon.pid"));
assert!(path.to_string_lossy().contains(".claude/daemon"));
}
#[test]
fn test_query_agent_state_no_daemon_returns_none() {
let result = query_agent_state("arch-ctm", "atm-dev");
assert!(result.is_ok());
}
#[test]
fn test_agent_pane_info_deserialization() {
let json = r#"{"pane_id":"%42","log_path":"/home/user/.claude/logs/arch-ctm.log"}"#;
let info: AgentPaneInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.pane_id, "%42");
assert_eq!(info.log_path, "/home/user/.claude/logs/arch-ctm.log");
}
#[test]
fn test_query_agent_pane_no_daemon_returns_none() {
let result = query_agent_pane("arch-ctm");
assert!(result.is_ok());
}
#[test]
fn test_launch_config_serialization() {
let mut env_vars = std::collections::HashMap::new();
env_vars.insert("EXTRA_VAR".to_string(), "value".to_string());
let config = LaunchConfig {
agent: "arch-ctm".to_string(),
team: "atm-dev".to_string(),
command: "codex --yolo".to_string(),
prompt: Some("Review the bridge module".to_string()),
timeout_secs: 30,
env_vars,
};
let json = serde_json::to_string(&config).unwrap();
let decoded: LaunchConfig = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.agent, "arch-ctm");
assert_eq!(decoded.team, "atm-dev");
assert_eq!(decoded.command, "codex --yolo");
assert_eq!(decoded.prompt.as_deref(), Some("Review the bridge module"));
assert_eq!(decoded.timeout_secs, 30);
assert_eq!(decoded.env_vars.get("EXTRA_VAR").map(String::as_str), Some("value"));
}
#[test]
fn test_launch_config_no_prompt_serialization() {
let config = LaunchConfig {
agent: "worker-1".to_string(),
team: "my-team".to_string(),
command: "codex --yolo".to_string(),
prompt: None,
timeout_secs: 60,
env_vars: std::collections::HashMap::new(),
};
let json = serde_json::to_string(&config).unwrap();
let decoded: LaunchConfig = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.agent, "worker-1");
assert!(decoded.prompt.is_none());
assert!(decoded.env_vars.is_empty());
}
#[test]
fn test_launch_result_serialization() {
let result = LaunchResult {
agent: "arch-ctm".to_string(),
pane_id: "%42".to_string(),
state: "launching".to_string(),
warning: None,
};
let json = serde_json::to_string(&result).unwrap();
let decoded: LaunchResult = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.agent, "arch-ctm");
assert_eq!(decoded.pane_id, "%42");
assert_eq!(decoded.state, "launching");
assert!(decoded.warning.is_none());
}
#[test]
fn test_launch_result_with_warning_serialization() {
let result = LaunchResult {
agent: "arch-ctm".to_string(),
pane_id: "%7".to_string(),
state: "launching".to_string(),
warning: Some("Readiness timeout reached".to_string()),
};
let json = serde_json::to_string(&result).unwrap();
let decoded: LaunchResult = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.warning.as_deref(), Some("Readiness timeout reached"));
}
#[test]
fn test_launch_agent_no_daemon_returns_none() {
let config = LaunchConfig {
agent: "test-agent".to_string(),
team: "test-team".to_string(),
command: "codex --yolo".to_string(),
prompt: None,
timeout_secs: 5,
env_vars: std::collections::HashMap::new(),
};
let result = launch_agent(&config);
assert!(result.is_ok());
}
#[test]
fn test_agent_summary_serialization() {
let summary = AgentSummary {
agent: "arch-ctm".to_string(),
state: "idle".to_string(),
};
let json = serde_json::to_string(&summary).unwrap();
let decoded: AgentSummary = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.agent, "arch-ctm");
assert_eq!(decoded.state, "idle");
}
#[cfg(unix)]
#[test]
fn test_pid_alive_current_process() {
let pid = std::process::id() as i32;
assert!(pid_alive(pid));
}
#[cfg(unix)]
#[test]
fn test_pid_alive_nonexistent_pid() {
assert!(!pid_alive(i32::MAX));
}
}