use std::path::PathBuf;
use std::time::Duration;
use crate::mail_route::{Caller, LiveSessions, Unresolved};
use crate::mailbox::{local_machine_name, mail_root, Envelope, MailAddress, MailKind, ReplyVia};
use crate::HarnessHomes;
pub const EXIT_REFUSED: i32 = 2;
pub const EXIT_UNKNOWN: i32 = 3;
pub const EXIT_STORED: i32 = 4;
pub const EXIT_FAILED: i32 = 5;
pub struct Outcome {
pub code: i32,
pub text: String,
}
impl Outcome {
pub fn new(code: i32, text: impl Into<String>) -> Self {
Self {
code,
text: text.into(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct SendOptions {
pub in_reply_to: Option<String>,
pub notify_when_idle: bool,
pub queue: bool,
pub idempotency_key: Option<String>,
}
pub async fn send(
homes: &HarnessHomes,
caller: &Caller,
to: &str,
body: &str,
options: SendOptions,
) -> std::io::Result<Outcome> {
let SendOptions {
in_reply_to,
notify_when_idle,
queue,
idempotency_key,
} = options;
if let Some(machine) = remote_machine(to) {
let request = serde_json::json!({
"op": "send",
"from": caller.address.to_string(),
"from_name": caller.name,
"to": to,
"body": body,
"in_reply_to": in_reply_to,
"notify_when_idle": notify_when_idle,
"queue": queue,
"id": idempotency_key,
});
return Ok(remote_send(&machine, &request));
}
deliver(
homes,
caller,
to,
body,
in_reply_to,
notify_when_idle,
queue,
idempotency_key,
)
.await
}
fn remote_machine(to: &str) -> Option<String> {
let local = local_machine_name();
let machine = match MailAddress::parse(to) {
Ok(address) => address.machine,
Err(_) => to.rsplit_once('@')?.1.to_string(),
};
(machine != local).then_some(machine)
}
fn remote_send(machine: &str, request: &serde_json::Value) -> Outcome {
match crate::mailbox::teams_mail(machine, request) {
Ok(answer) => Outcome::new(
answer["code"]
.as_i64()
.map_or(EXIT_FAILED, |code| code as i32),
answer["text"]
.as_str()
.or_else(|| answer["detail"].as_str())
.unwrap_or("the other machine answered nothing readable")
.to_string(),
),
Err(detail) if detail.contains("no mail grant") => Outcome::new(
EXIT_REFUSED,
format!(
"Not sent: machine {machine} does not accept mail from you (no mail grant). Only \
an operator can grant it; tell your user. Don't retry."
),
),
Err(detail) => Outcome::new(
EXIT_FAILED,
format!("Not sent to machine {machine}: {detail}"),
),
}
}
#[allow(clippy::too_many_arguments)]
async fn deliver(
homes: &HarnessHomes,
caller: &Caller,
to: &str,
body: &str,
in_reply_to: Option<String>,
notify_when_idle: bool,
queue: bool,
idempotency_key: Option<String>,
) -> std::io::Result<Outcome> {
use crate::mail_route::{deliver as route, door_for, Delivered, Refused};
let (address, name) = match MailAddress::parse(to) {
Ok(address) if address.harness == "operator" => {
let name = format!("{}@{}", address.session_id, address.machine);
(address, name)
}
_ => match LiveSessions::read(homes).resolve(to) {
Ok(session) => (session.address.clone(), session.name.clone()),
Err(Unresolved::Stale(message) | Unresolved::Unknown(message)) => {
return Ok(Outcome::new(EXIT_UNKNOWN, message));
}
},
};
if address == caller.address {
return Ok(Outcome::new(
EXIT_REFUSED,
"Not sent: that address is your own session.",
));
}
let door = match door_for(homes, &address) {
Ok(door) => door,
Err(_) => {
return Ok(Outcome::new(
EXIT_UNKNOWN,
format!(
"{name} is no longer running. Nothing was sent. Run supercode message list for \
the live sessions."
),
))
}
};
let message_id = match &idempotency_key {
Some(key) => format!(
"m-{}",
&blake3::hash(format!("{}\0{key}", caller.address).as_bytes()).to_hex()[..24]
),
None => crate::mailbox::new_message_id()?,
};
let sent_marker = sent_marker(&caller.address, &message_id);
if let Ok(previous) = std::fs::read_to_string(&sent_marker) {
return Ok(Outcome::new(
0,
format!(
"Already sent with --id {}: {previous} Nothing was sent again.",
idempotency_key.as_deref().unwrap_or_default()
),
));
}
if recent_sends(&caller.address, &address) >= LOOP_LIMIT {
return Ok(Outcome::new(
EXIT_REFUSED,
format!(
"Not sent: this would be message {} from you to {name} in {} minutes. If you two \
are trading acknowledgements or status, stop; tell your user if the exchange must \
go on.",
LOOP_LIMIT + 1,
LOOP_WINDOW.as_secs() / 60
),
));
}
let mut envelope = Envelope::new(
caller.address.clone(),
caller.name.clone(),
MailKind::Peer,
ReplyVia::Command,
body,
)?;
envelope.id = message_id.clone();
envelope.in_reply_to = in_reply_to;
let tier = door.name();
let idle_note = if notify_when_idle {
" Subscribed: one idle notice reaches you when its next turn ends."
} else {
""
};
let (code, what) = match route(&envelope, &address, &door, !queue, notify_when_idle).await {
Err(detail) => {
return Ok(Outcome::new(
EXIT_FAILED,
format!("Not sent to {name}: {detail}"),
))
}
Ok(Err(Refused::CannotQueueNative)) => {
return Ok(Outcome::new(
EXIT_REFUSED,
format!(
"Not sent: {name} is idle, and a Claude session always starts a turn when a \
message arrives, so --queue cannot hold it. Send without --queue to wake it."
),
))
}
Ok(Err(Refused::TooLong(bytes))) => {
return Ok(Outcome::new(
EXIT_REFUSED,
format!(
"Not sent: the message is {bytes} bytes; the limit is {}. Write it to a file \
and send its path instead.",
crate::mail_route::MAX_RELAYED_BYTES
),
))
}
Ok(Ok(Delivered::Steered)) => (0, "steered into its running turn"),
Ok(Ok(Delivered::Started)) => (0, "it was idle, so the message started a turn"),
Ok(Ok(Delivered::Native { busy: true })) => (0, "it will read it at its next tool call"),
Ok(Ok(Delivered::Native { busy: false })) => {
(0, "it was idle, so the message starts its next turn")
}
Ok(Ok(Delivered::Hooked)) => (
0,
"its hook points it at the message at its next tool call, or when its turn ends; an \
idle session sees it on its next turn",
),
Ok(Ok(Delivered::Queued)) => (
0,
"it is idle and --queue leaves it so; the message waits in its mailbox",
),
Ok(Ok(Delivered::Operator)) => (0, "filed in its mailbox"),
Ok(Ok(Delivered::Stored)) => (
EXIT_STORED,
"Stored (not failed): it has no delivery door, so it sees this only if it runs \
supercode message inbox (a Codex session gets one with: supercode message setup \
codex). Don't resend, and don't wait for a reply",
),
};
record_send(&caller.address, &address, &message_id);
let text = if code == 0 {
format!(
"sent to {name} ({}, {tier}): {what}. Message id {message_id}.{idle_note} Don't poll; \
carry on.",
address.harness
)
} else {
format!(
"{name} ({}): {what}. Message id {message_id}.{idle_note}",
address.harness
)
};
if code == 0 && idempotency_key.is_some() {
if let Some(parent) = sent_marker.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(&sent_marker, &text).ok();
}
Ok(Outcome::new(code, text))
}
const LOOP_LIMIT: usize = 8;
const LOOP_WINDOW: Duration = Duration::from_secs(10 * 60);
fn send_log(sender: &MailAddress) -> PathBuf {
let hash = blake3::hash(sender.to_string().as_bytes()).to_hex();
mail_root().join("sent").join(&hash[..24]).join("log.jsonl")
}
fn recent_sends(sender: &MailAddress, to: &MailAddress) -> usize {
let now = now_ms();
let window = LOOP_WINDOW.as_millis() as u64;
std::fs::read_to_string(send_log(sender))
.unwrap_or_default()
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.filter(|entry| entry["to"].as_str() == Some(to.to_string().as_str()))
.filter(|entry| {
entry["at_ms"]
.as_u64()
.is_some_and(|at| now.saturating_sub(at) < window)
})
.count()
}
fn record_send(sender: &MailAddress, to: &MailAddress, message_id: &str) {
let log = send_log(sender);
if let Some(parent) = log.parent() {
std::fs::create_dir_all(parent).ok();
}
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log)
{
use std::io::Write as _;
let entry = serde_json::json!({"to": to.to_string(), "at_ms": now_ms(), "id": message_id});
writeln!(file, "{entry}").ok();
}
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_millis() as u64)
.unwrap_or_default()
}
fn sent_marker(sender: &MailAddress, message_id: &str) -> PathBuf {
let hash = blake3::hash(sender.to_string().as_bytes()).to_hex();
mail_root().join("sent").join(&hash[..24]).join(message_id)
}