use std::io;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use clap::Subcommand;
use super::crypto;
use super::delegation::{self, DelegationContext};
use super::listener::RelayListener;
use super::mesh::PeerRegistry;
use super::peer::PeerConnection;
use super::{
PENDING_PEER_ID, clear_pending_psk, forget_peer, gen_msg_id, is_valid_peer_id,
list_known_peers, load_or_create_identity, load_peer_meta, load_peer_psk, load_pending_psk,
save_peer_psk, save_pending_psk,
};
#[derive(Subcommand)]
pub enum RelayCommand {
Serve {
#[arg(long, default_value_t = 9847)]
port: u16,
},
Pair,
Accept {
code: String,
peer_id: String,
},
Connect {
addr: String,
},
Peers,
Disconnect {
peer_id: String,
},
Forget {
peer_id: String,
},
Identity,
Delegate {
peer: String,
prompt: String,
#[arg(long)]
cwd: Option<String>,
#[arg(long)]
git_ref: Option<String>,
},
Status,
Interrupt {
task_id: String,
interrupt_type: String,
reason: Vec<String>,
},
Invite {
#[arg(long)]
qr: bool,
#[arg(long)]
words: bool,
},
Join {
input: Vec<String>,
},
Discover,
}
pub fn dispatch_command(command: &RelayCommand, json_mode: bool) -> io::Result<()> {
match command {
RelayCommand::Serve { port } => cmd_serve(*port),
RelayCommand::Pair => cmd_pair(json_mode),
RelayCommand::Accept { code, peer_id } => cmd_accept(code, peer_id),
RelayCommand::Connect { addr } => cmd_connect(addr),
RelayCommand::Peers => cmd_peers(json_mode),
RelayCommand::Disconnect { peer_id } => cmd_disconnect(peer_id),
RelayCommand::Forget { peer_id } => cmd_forget(peer_id),
RelayCommand::Identity => cmd_identity(json_mode),
RelayCommand::Delegate {
peer,
prompt,
cwd,
git_ref,
} => cmd_delegate(peer, prompt, cwd.as_deref(), git_ref.clone(), json_mode),
RelayCommand::Status => cmd_task_status(json_mode),
RelayCommand::Interrupt {
task_id,
interrupt_type,
reason,
} => cmd_interrupt(task_id, interrupt_type, reason),
RelayCommand::Invite { qr, words } => cmd_invite(*qr, *words, json_mode),
RelayCommand::Join { input } => cmd_join(input),
RelayCommand::Discover => cmd_discover(json_mode),
}
}
fn cmd_serve(port: u16) -> io::Result<()> {
let mut port = port;
let cfg = crate::config::Config::load();
let relay_cfg = cfg.relay.unwrap_or_default();
#[cfg(feature = "hive")]
let hive_cfg = cfg.hive.unwrap_or_default();
let identity = load_or_create_identity();
if port == 9847 {
port = relay_cfg.listen_port;
}
let listen_addr = format!("{}:{port}", relay_cfg.listen_addr);
let addr: SocketAddr = listen_addr
.parse()
.map_err(|e| io::Error::other(format!("invalid addr '{listen_addr}': {e}")))?;
let registry = Arc::new(Mutex::new(PeerRegistry::new(
relay_cfg.heartbeat_interval_secs,
)));
let listener = RelayListener::start(
addr,
Arc::clone(®istry),
identity.clone(),
relay_cfg.max_peers,
)?;
println!("Relay listening on {} as {}", listener.addr, identity);
println!("Press Ctrl+C to stop.");
let mut worker = super::worker::RemoteWorker::new(identity.as_str());
#[cfg(feature = "hive")]
let (mut hive_store, mut gossip, broadcast_rx) = {
let hive_enabled = hive_cfg.enabled;
let store = hive_enabled.then(crate::hive::store::HiveStore::load);
let gossip_engine = hive_enabled.then(|| {
let mut engine = crate::hive::gossip::GossipEngine::new(
identity.as_str(),
hive_cfg.max_propagation,
hive_cfg.knowledge_ttl_days,
);
engine.set_sharing_filter(crate::hive::SharingFilter::from_config(&hive_cfg));
engine
});
let rx = if hive_enabled {
let (tx, rx) = std::sync::mpsc::channel::<u32>();
crate::hive::set_broadcast_channel(tx);
Some(rx)
} else {
None
};
(store, gossip_engine, rx)
};
let running = Arc::new(std::sync::atomic::AtomicBool::new(true));
let r = Arc::clone(&running);
let _ = ctrlc::set_handler(move || {
r.store(false, std::sync::atomic::Ordering::Relaxed);
});
while running.load(std::sync::atomic::Ordering::Relaxed) {
std::thread::sleep(std::time::Duration::from_secs(1));
if let Ok(mut reg) = registry.lock() {
let messages = reg.drain_messages();
for (from_peer, msg) in messages {
match msg.msg_type {
super::MessageType::Heartbeat => {
reg.handle_heartbeat(&from_peer);
}
super::MessageType::DelegateTask => {
match super::delegation::parse_delegate_message(&msg) {
Ok((task_id, prompt, cwd, context)) => {
println!(
"[{}] DelegateTask '{}' from {}",
crate::logger::timestamp_now(),
task_id,
from_peer
);
match worker.accept_task(
&task_id,
&prompt,
cwd.as_deref(),
context,
from_peer.as_str(),
) {
Ok(status_msg) => {
let _ = reg.send_to(from_peer.as_str(), &status_msg);
}
Err(e) => {
eprintln!(" Failed to accept task: {e}");
}
}
}
Err(e) => eprintln!(" Bad DelegateTask message: {e}"),
}
}
super::MessageType::TaskInterrupt => {
match super::delegation::parse_interrupt_message(&msg) {
Ok((task_id, itype, reason)) => {
println!(
"[{}] TaskInterrupt '{}' ({}) from {}",
crate::logger::timestamp_now(),
task_id,
itype,
from_peer
);
if let Some(resp) =
worker.handle_interrupt(&task_id, &itype, &reason)
{
let _ = reg.send_to(from_peer.as_str(), &resp);
}
}
Err(e) => eprintln!(" Bad TaskInterrupt message: {e}"),
}
}
super::MessageType::TaskStatus | super::MessageType::TaskHandoff => {
println!(
"[{}] {:?} from {}",
crate::logger::timestamp_now(),
msg.msg_type,
from_peer
);
}
#[cfg(feature = "hive")]
super::MessageType::KnowledgeSync => {
if let (Some(gossip), Some(hive_store)) =
(gossip.as_mut(), hive_store.as_mut())
{
let (stats, accepted) = gossip.handle_sync(hive_store, &msg);
println!(
"[{}] KnowledgeSync from {}: {} accepted, {} rejected",
crate::logger::timestamp_now(),
from_peer,
stats.accepted,
stats.rejected
);
if !accepted.is_empty() {
let connected = reg.connected_peers();
let prop_msgs = gossip.propagate(&accepted, &from_peer, &connected);
for (target, prop_msg) in prop_msgs {
let _ = reg.send_to(target.as_str(), &prop_msg);
}
}
}
}
#[cfg(feature = "hive")]
super::MessageType::KnowledgeRequest => {
if let (Some(gossip), Some(hive_store)) =
(gossip.as_ref(), hive_store.as_ref())
{
let snapshots = gossip.handle_request(hive_store, &msg);
for snap in snapshots {
let _ = reg.send_to(from_peer.as_str(), &snap);
}
}
}
#[cfg(feature = "hive")]
super::MessageType::KnowledgeSnapshot => {
if let (Some(gossip), Some(hive_store)) =
(gossip.as_mut(), hive_store.as_mut())
{
let stats = gossip.handle_snapshot(hive_store, &msg);
println!(
"[{}] KnowledgeSnapshot from {}: {} accepted",
crate::logger::timestamp_now(),
from_peer,
stats.accepted
);
}
}
_ => {
println!(
"[{}] {:?} from {}",
crate::logger::timestamp_now(),
msg.msg_type,
from_peer
);
}
}
}
let worker_msgs = worker.tick();
for (target_peer, msg) in worker_msgs {
let _ = reg.send_to(&target_peer, &msg);
}
#[cfg(feature = "hive")]
if let (Some(broadcast_rx), Some(gossip), Some(hive_store)) =
(broadcast_rx.as_ref(), gossip.as_mut(), hive_store.as_ref())
{
while broadcast_rx.try_recv().is_ok() {
let connected = reg.connected_peers();
let sync_msgs = gossip.generate_sync_messages(hive_store, &connected);
for (target, sync_msg) in sync_msgs {
let _ = reg.send_to(target.as_str(), &sync_msg);
}
}
}
let events = reg.tick(identity.as_str());
for event in events {
match event {
super::mesh::MeshEvent::PeerDisconnected(id) => {
println!("Peer {} disconnected", id);
}
super::mesh::MeshEvent::ReconnectScheduled(id, delay) => {
println!("Reconnect to {} in {:?}", id, delay);
}
super::mesh::MeshEvent::ReconnectNeeded(id, addr) => {
println!("Reconnecting to {} ...", id);
match reconnect_peer(&mut reg, &id, addr, &identity) {
Ok(()) => println!("Reconnected to {}", id),
Err(e) => println!("Reconnect to {} failed: {}", id, e),
}
}
}
}
}
}
listener.stop();
println!("\nRelay stopped.");
Ok(())
}
fn cmd_pair(json_mode: bool) -> io::Result<()> {
let identity = load_or_create_identity();
let psk = crypto::generate_psk();
let code = crypto::format_psk(&psk);
if json_mode {
let json = serde_json::json!({
"identity": identity.as_str(),
"pair_code": code,
});
println!("{}", serde_json::to_string_pretty(&json).unwrap());
} else {
println!("Your identity: {}", identity);
println!();
println!("PAIR CODE: {}", code);
println!();
println!("Share this code with the peer you want to connect.");
println!(
"They should run: claudectl relay accept {} {}",
code, identity
);
}
let canonical_psk = crypto::parse_psk(&code).expect("just-generated code must parse");
save_pending_psk(&canonical_psk).map_err(io::Error::other)?;
Ok(())
}
fn cmd_accept(code: &str, peer_id: &str) -> io::Result<()> {
if !is_valid_peer_id(peer_id) || peer_id == PENDING_PEER_ID {
return Err(io::Error::other(format!("invalid peer id: {peer_id}")));
}
let psk =
crypto::parse_psk(code).map_err(|e| io::Error::other(format!("invalid code: {e}")))?;
save_peer_psk(peer_id, &psk).map_err(io::Error::other)?;
clear_pending_psk();
println!("Paired with peer: {}", peer_id);
println!("PSK stored. You can now connect with:");
println!(" claudectl relay connect <host>:<port>");
Ok(())
}
fn run_connect_loop(registry: &Arc<Mutex<PeerRegistry>>, identity: &str) {
let running = Arc::new(std::sync::atomic::AtomicBool::new(true));
let r = Arc::clone(&running);
let _ = ctrlc::set_handler(move || {
r.store(false, std::sync::atomic::Ordering::Relaxed);
});
let identity = super::PeerId(identity.to_string());
while running.load(std::sync::atomic::Ordering::Relaxed) {
std::thread::sleep(std::time::Duration::from_secs(1));
if let Ok(mut reg) = registry.lock() {
let messages = reg.drain_messages();
for (peer_id, msg) in &messages {
match msg.msg_type {
super::MessageType::Heartbeat => {
reg.handle_heartbeat(peer_id);
}
_ => {
println!(
"[{}] {:?} from {}",
crate::logger::timestamp_now(),
msg.msg_type,
peer_id
);
}
}
}
let events = reg.tick(identity.as_str());
for event in events {
match event {
super::mesh::MeshEvent::PeerDisconnected(id) => {
println!("Peer {} disconnected", id);
}
super::mesh::MeshEvent::ReconnectScheduled(id, delay) => {
println!("Reconnect to {} in {:?}", id, delay);
}
super::mesh::MeshEvent::ReconnectNeeded(id, addr) => {
println!("Reconnecting to {} ...", id);
match reconnect_peer(&mut reg, &id, addr, &identity) {
Ok(()) => println!("Reconnected to {}", id),
Err(e) => println!("Reconnect to {} failed: {}", id, e),
}
}
}
}
}
}
println!("\nDisconnected.");
}
fn try_connect(
addr: SocketAddr,
psk: &[u8; 32],
identity: &super::PeerId,
) -> Result<(String, Arc<Mutex<PeerRegistry>>), String> {
let registry = Arc::new(Mutex::new(PeerRegistry::new(30)));
let tx = {
let reg = registry.lock().unwrap();
reg.message_tx()
};
let conn = PeerConnection::connect(addr, psk, identity, tx)?;
let remote_id = conn.peer_id.0.clone();
if let Ok(mut reg) = registry.lock() {
reg.add_peer(conn);
}
Ok((remote_id, registry))
}
fn reconnect_peer(
reg: &mut PeerRegistry,
peer_id: &super::PeerId,
addr: Option<SocketAddr>,
identity: &super::PeerId,
) -> Result<(), String> {
let addr = addr.ok_or("missing reconnect address")?;
let psk = load_peer_psk(peer_id.as_str()).ok_or("missing peer PSK")?;
let tx = reg.message_tx();
let conn = PeerConnection::connect(addr, &psk, identity, tx)?;
if conn.peer_id != *peer_id {
return Err(format!(
"remote identity mismatch: expected {}, got {}",
peer_id, conn.peer_id
));
}
reg.add_peer(conn);
Ok(())
}
fn cmd_connect(addr_str: &str) -> io::Result<()> {
let addr: SocketAddr = addr_str
.parse()
.map_err(|e| io::Error::other(format!("invalid address '{addr_str}': {e}")))?;
let identity = load_or_create_identity();
for peer_id in &list_known_peers() {
if let Some(psk) = load_peer_psk(peer_id) {
if let Ok((remote_id, registry)) = try_connect(addr, &psk, &identity) {
if remote_id == *peer_id && is_valid_peer_id(&remote_id) {
println!("Connected to {} ({})", remote_id, addr);
let _ = super::save_peer_meta(&remote_id, &addr.to_string());
run_connect_loop(®istry, identity.as_str());
return Ok(());
}
}
}
}
if let Some(psk) = load_pending_psk() {
if let Ok((remote_id, registry)) = try_connect(addr, &psk, &identity) {
if is_valid_peer_id(&remote_id) && remote_id != PENDING_PEER_ID {
println!("Connected to {} ({})", remote_id, addr);
let _ = save_peer_psk(&remote_id, &psk);
let _ = super::save_peer_meta(&remote_id, &addr.to_string());
clear_pending_psk();
run_connect_loop(®istry, identity.as_str());
return Ok(());
}
}
}
eprintln!("Could not connect to {}", addr_str);
eprintln!("Make sure you have paired with this peer first:");
eprintln!(" 1. Remote runs: claudectl relay pair");
eprintln!(" 2. You run: claudectl relay accept <code> <peer-id>");
Err(io::Error::other("connection failed"))
}
fn cmd_peers(json_mode: bool) -> io::Result<()> {
let identity = load_or_create_identity();
let known = list_known_peers();
if json_mode {
let peers: Vec<serde_json::Value> = known
.iter()
.map(|id| {
let meta = load_peer_meta(id).unwrap_or(serde_json::json!({}));
serde_json::json!({
"peer_id": id,
"addr": meta.get("addr").and_then(|v| v.as_str()).unwrap_or("unknown"),
"last_seen": meta.get("last_seen").and_then(|v| v.as_u64()).unwrap_or(0),
"has_psk": load_peer_psk(id).is_some(),
})
})
.collect();
let output = serde_json::json!({
"identity": identity.as_str(),
"peers": peers,
});
println!("{}", serde_json::to_string_pretty(&output).unwrap());
} else {
println!("Identity: {}", identity);
println!();
if known.is_empty() {
println!("No paired peers. Run 'claudectl relay pair' to get started.");
} else {
println!("{:<20} {:<24} PAIRED", "PEER", "ADDRESS");
println!("{}", "─".repeat(56));
for id in &known {
let meta = load_peer_meta(id).unwrap_or(serde_json::json!({}));
let addr = meta
.get("addr")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let has_psk = if load_peer_psk(id).is_some() {
"yes"
} else {
"no"
};
println!("{:<20} {:<24} {}", id, addr, has_psk);
}
}
}
Ok(())
}
fn cmd_disconnect(peer_id: &str) -> io::Result<()> {
println!("Note: to disconnect a live connection, stop the relay serve/connect process.");
println!(
"To remove the pairing entirely, use: claudectl relay forget {}",
peer_id
);
Ok(())
}
fn cmd_forget(peer_id: &str) -> io::Result<()> {
if load_peer_psk(peer_id).is_none() {
eprintln!("Unknown peer: {}", peer_id);
return Err(io::Error::other("unknown peer"));
}
forget_peer(peer_id);
println!("Forgot peer: {}", peer_id);
Ok(())
}
fn cmd_identity(json_mode: bool) -> io::Result<()> {
let identity = load_or_create_identity();
if json_mode {
println!("{}", serde_json::json!({ "identity": identity.as_str() }));
} else {
println!("{}", identity);
}
Ok(())
}
fn cmd_delegate(
peer_id: &str,
prompt: &str,
cwd: Option<&str>,
git_ref: Option<String>,
json_mode: bool,
) -> io::Result<()> {
let identity = load_or_create_identity();
let task_id = gen_msg_id().replace("msg_", "task_");
let context = DelegationContext {
git_ref,
..Default::default()
};
let msg =
delegation::build_delegate_message(&task_id, prompt, cwd, &context, identity.as_str())
.map_err(|e| io::Error::other(format!("build message: {e}")))?;
if json_mode {
let output = serde_json::json!({
"task_id": task_id,
"peer": peer_id,
"prompt": prompt,
"cwd": cwd,
"status": "delegated",
"message": msg,
});
println!("{}", serde_json::to_string_pretty(&output).unwrap());
} else {
println!("Task {} delegated to peer {}", task_id, peer_id);
println!(" Prompt: {}", prompt);
if let Some(c) = cwd {
println!(" CWD: {}", c);
}
println!();
println!("Note: In standalone CLI mode, the message is built but not sent.");
println!("Use `claudectl relay serve` or TUI mode for live delegation.");
}
Ok(())
}
fn cmd_task_status(json_mode: bool) -> io::Result<()> {
let identity = load_or_create_identity();
if json_mode {
let output = serde_json::json!({
"identity": identity.as_str(),
"active_delegated_tasks": 0,
"note": "Live task status requires relay serve or TUI mode",
});
println!("{}", serde_json::to_string_pretty(&output).unwrap());
} else {
println!("Relay identity: {}", identity);
println!();
println!("No active delegated tasks.");
println!("Live task status requires `claudectl relay serve` or TUI mode.");
}
Ok(())
}
fn cmd_interrupt(task_id: &str, interrupt_type: &str, reason: &[String]) -> io::Result<()> {
let reason_str = reason.join(" ");
let identity = load_or_create_identity();
let msg = delegation::build_interrupt_message(
task_id,
interrupt_type,
&reason_str,
identity.as_str(),
);
println!("Interrupt built for task {}", task_id);
println!(" Type: {}", interrupt_type);
if !reason_str.is_empty() {
println!(" Reason: {}", reason_str);
}
println!(" Message ID: {}", msg.id);
println!();
println!("Note: In standalone CLI mode, the message is built but not sent.");
println!("Use `claudectl relay serve` or TUI mode for live interrupts.");
Ok(())
}
fn cmd_invite(show_qr: bool, show_words: bool, json_mode: bool) -> io::Result<()> {
let identity = load_or_create_identity();
let cfg = crate::config::Config::load();
let relay_cfg = cfg.relay.unwrap_or_default();
let ip = detect_local_ip().unwrap_or_else(|| "127.0.0.1".to_string());
let port = relay_cfg.listen_port;
let addr: std::net::SocketAddr = format!("{ip}:{port}")
.parse()
.map_err(|e| io::Error::other(format!("invalid addr: {e}")))?;
let raw_psk = crypto::generate_psk();
let code = crypto::format_psk(&raw_psk);
let canonical_psk = crypto::parse_psk(&code).expect("just-generated code must parse");
let invite_link = super::invite::build_invite_link(identity.as_str(), &addr, &canonical_psk);
let relay_code = super::invite::encode_relay_code(&addr, &canonical_psk);
let word_phrase = super::invite::encode_words(&addr, &canonical_psk);
if json_mode {
let output = serde_json::json!({
"identity": identity.as_str(),
"invite_link": invite_link,
"relay_code": relay_code,
"word_phrase": word_phrase,
"addr": addr.to_string(),
});
println!("{}", serde_json::to_string_pretty(&output).unwrap());
return Ok(());
}
println!("Your identity: {}", identity);
println!();
println!(" RELAY CODE: {}", relay_code);
println!();
if show_words {
println!(" WORD PHRASE: {}", word_phrase);
println!();
}
println!(" INVITE LINK: {}", invite_link);
println!();
println!("Share any of the above with your peer. They run:");
println!();
println!(" claudectl relay join {}", relay_code);
if show_words {
println!(" claudectl relay join {}", word_phrase);
}
println!(" claudectl relay join {}", invite_link);
println!();
if show_qr {
println!("QR Code (scan to join):");
println!();
println!("{}", super::invite::render_qr(&invite_link));
}
let pending_path = super::peers_dir().join("_pending.key");
let _ = std::fs::create_dir_all(super::peers_dir());
let _ = std::fs::write(&pending_path, crypto::hex_encode(&canonical_psk));
Ok(())
}
fn cmd_join(input: &[String]) -> io::Result<()> {
if input.is_empty() {
eprintln!("Usage: claudectl relay join <relay-code | invite-link | word-phrase>");
return Err(io::Error::other("missing argument"));
}
let input = input.join(" ");
let identity = load_or_create_identity();
let (addr, psk, remote_identity) = if input.starts_with("cctl://") {
let (id, addr, psk) = super::invite::parse_invite_link(&input)
.map_err(|e| io::Error::other(format!("invalid invite link: {e}")))?;
(addr, psk, Some(id))
} else if input.contains('-')
&& input
.split('-')
.all(|w| w.len() <= 5 && w.chars().all(|c| c.is_ascii_alphabetic()))
{
let (addr, psk) = super::invite::decode_words(&input)
.map_err(|e| io::Error::other(format!("invalid word phrase: {e}")))?;
(addr, psk, None)
} else {
let (addr, psk) = super::invite::decode_relay_code(&input)
.map_err(|e| io::Error::other(format!("invalid relay code: {e}")))?;
(addr, psk, None)
};
println!("Connecting to {}...", addr);
let (remote_id, registry) = try_connect(addr, &psk, &identity)
.map_err(|e| io::Error::other(format!("connection failed: {e}")))?;
if let Some(ref expected) = remote_identity {
if remote_id != *expected {
println!(
"Warning: expected peer '{}' but connected to '{}'",
expected, remote_id
);
}
}
println!("Paired with {} ({})", remote_id, addr);
let _ = save_peer_psk(&remote_id, &psk);
let _ = super::save_peer_meta(&remote_id, &addr.to_string());
run_connect_loop(®istry, identity.as_str());
Ok(())
}
fn cmd_discover(json_mode: bool) -> io::Result<()> {
let identity = load_or_create_identity();
println!("Scanning LAN for claudectl instances (3 seconds)...");
println!();
let peers = super::lan::scan_lan(std::time::Duration::from_secs(3), identity.as_str());
if json_mode {
let json_peers: Vec<serde_json::Value> = peers
.iter()
.map(|p| {
serde_json::json!({
"identity": p.identity,
"addr": p.relay_addr().to_string(),
"version": p.version,
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&json_peers).unwrap());
return Ok(());
}
if peers.is_empty() {
println!("No claudectl instances found on the local network.");
println!();
println!("Make sure peers are running: claudectl relay serve");
println!("Or use invite codes: claudectl relay invite");
} else {
println!("Found {} instance(s):", peers.len());
println!();
println!(" {:<20} {:<24} VERSION", "IDENTITY", "ADDRESS");
println!(" {}", "─".repeat(56));
for peer in &peers {
let paired = if load_peer_psk(&peer.identity).is_some() {
" (paired)"
} else {
""
};
println!(
" {:<20} {:<24} {}{}",
peer.identity,
peer.relay_addr().to_string(),
peer.version,
paired,
);
}
println!();
println!("To pair, run: claudectl relay invite on the remote machine,");
println!("then: claudectl relay join <code> here.");
}
Ok(())
}
fn detect_local_ip() -> Option<String> {
let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
socket.connect("8.8.8.8:80").ok()?;
let local_addr = socket.local_addr().ok()?;
Some(local_addr.ip().to_string())
}