use crate::run::boxed;
use crate::util::BErr;
use rustyline::ExternalPrinter as _;
pub async fn chat(worker: &str, idle: u64) -> Result<(), BErr> {
crate::worker::require_worker(worker)?;
let (tx, rx) = tokio::sync::mpsc::channel::<boxed::SessionMessage>(32);
let reader = tokio::spawn(async move {
use tokio::io::AsyncBufReadExt;
let mut lines = tokio::io::BufReader::new(tokio::io::stdin()).lines();
while let Ok(Some(l)) = lines.next_line().await {
if l.trim().is_empty() {
continue;
}
let msg = boxed::SessionMessage {
text: l,
author_label: "stdin".into(),
context: crate::worker::memory::RunContext::default(),
};
if tx.send(msg).await.is_err() {
break;
}
}
});
let run_id = boxed::new_run_id();
boxed::run_session(
worker,
&run_id,
crate::worker::context::RunSurface::DirectBox,
crate::worker::memory::RunContext::default(),
rx,
idle,
None,
)
.await?;
reader.abort();
println!(
"session ended — transcript: {}",
crate::paths::run_dir(&run_id)
.join("stdout.jsonl")
.display()
);
Ok(())
}
async fn ensure_server(interactive: bool) -> Result<(), BErr> {
if crate::cli::server::gateway_up().await {
return Ok(());
}
if !interactive {
return Err("the server isn't running — start it: roster server start".into());
}
eprint!("the server isn't running — start it now? [y/N] ");
use std::io::Write;
let _ = std::io::stderr().flush();
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
if !matches!(answer.trim(), "y" | "Y" | "yes") {
return Err("talk needs the server — start it: roster server start".into());
}
let log_path = crate::paths::state_root().join("server.log");
std::fs::create_dir_all(crate::paths::state_root())?;
let log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)?;
use std::os::unix::process::CommandExt;
std::process::Command::new(std::env::current_exe()?)
.args(["server", "start"])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::from(log.try_clone()?))
.stderr(std::process::Stdio::from(log))
.process_group(0)
.spawn()?;
eprintln!("starting server (log: {}) …", log_path.display());
for _ in 0..40 {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
if crate::cli::server::gateway_up().await {
return Ok(());
}
}
Err(format!("the server did not come up — check {}", log_path.display()).into())
}
struct SlashHelper {
worker: String,
}
impl rustyline::completion::Completer for SlashHelper {
type Candidate = rustyline::completion::Pair;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &rustyline::Context<'_>,
) -> rustyline::Result<(usize, Vec<Self::Candidate>)> {
let (from, candidates) = crate::channel::slash::complete(line, pos, &self.worker);
let space = if candidates.len() == 1 { " " } else { "" };
Ok((
from,
candidates
.into_iter()
.map(|c| rustyline::completion::Pair {
replacement: format!("{c}{space}"),
display: c,
})
.collect(),
))
}
}
impl rustyline::hint::Hinter for SlashHelper {
type Hint = String;
}
impl rustyline::highlight::Highlighter for SlashHelper {}
impl rustyline::validate::Validator for SlashHelper {}
impl rustyline::Helper for SlashHelper {}
fn stdin_termios() -> Option<libc::termios> {
unsafe {
let mut t: libc::termios = std::mem::zeroed();
(libc::tcgetattr(libc::STDIN_FILENO, &mut t) == 0).then_some(t)
}
}
fn spawn_sink<F: FnMut(String) + Send + 'static>(
mut rx: tokio::sync::mpsc::Receiver<String>,
mut write: F,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
while let Some(text) = rx.recv().await {
write(text);
}
})
}
fn spawn_session(
worker: &str,
idle: u64,
context: crate::worker::memory::RunContext,
reply_tx: tokio::sync::mpsc::Sender<String>,
info_tx: tokio::sync::mpsc::Sender<String>,
) -> (
tokio::sync::mpsc::Sender<boxed::SessionMessage>,
tokio::task::JoinHandle<()>,
) {
let (tx, rx) = tokio::sync::mpsc::channel::<boxed::SessionMessage>(32);
let run_id = boxed::new_run_id();
let worker = worker.to_string();
let handle = tokio::spawn(async move {
let note = match boxed::run_session(
&worker,
&run_id,
crate::worker::context::RunSurface::TermSession,
context,
rx,
idle,
Some(reply_tx),
)
.await
{
Ok(()) => format!("(session {run_id} wound down — the conversation stays open)"),
Err(e) => format!("(session {run_id} failed: {e})"),
};
let _ = info_tx.send(note).await;
});
(tx, handle)
}
pub async fn talk(worker: &str, idle: u64) -> Result<(), BErr> {
crate::worker::require_worker(worker)?;
let interactive = unsafe { libc::isatty(libc::STDIN_FILENO) } == 1;
ensure_server(interactive).await?;
let user = std::env::var("USER").unwrap_or_else(|_| "operator".into());
let channel_id = format!("term-{user}-{worker}");
if let Err(e) = crate::channel::discord::set_channel_trust(&channel_id, true) {
eprintln!("talk: could not mark {channel_id} trusted: {e}");
}
let context = crate::worker::memory::RunContext {
provider: "term".into(),
channel_id: Some(channel_id.clone()),
user_id: Some(user.clone()),
message_id: None,
thread_ts: None,
role: "host-op".into(),
is_dm: true,
inbound: false,
};
let (reply_tx, reply_rx) = tokio::sync::mpsc::channel::<String>(32);
eprintln!(
"talking to {worker} — terminal channel {channel_id} (trusted); quiet sessions wind down and wake on your next message; /help for commands, Ctrl-D leaves"
);
let history = crate::channel::discord::recent_messages(&channel_id, 200);
let last_human = history
.iter()
.rposition(|m| m.get("role").and_then(|v| v.as_str()) == Some("host-op"));
let missed: Vec<&serde_json::Value> = history
.iter()
.skip(last_human.map(|i| i + 1).unwrap_or(0))
.filter(|m| m.get("role").and_then(|v| v.as_str()) == Some("worker"))
.collect();
if !missed.is_empty() {
println!("while you were away:");
for m in missed {
println!(
"{worker}> {}\n",
m.get("content").and_then(|v| v.as_str()).unwrap_or("")
);
}
}
let saved_termios = stdin_termios();
let (line_tx, mut line_rx) = tokio::sync::mpsc::channel::<String>(32);
let (info_tx, info_rx) = tokio::sync::mpsc::channel::<String>(32);
let (reply_sink, info_sink) = if interactive {
let mut rl =
rustyline::Editor::<SlashHelper, rustyline::history::DefaultHistory>::with_config(
rustyline::Config::builder()
.completion_type(rustyline::CompletionType::List)
.build(),
)?;
rl.set_helper(Some(SlashHelper {
worker: worker.to_string(),
}));
let _ = rl.load_history(&crate::paths::talk_history_file());
let mut reply_printer = rl.create_external_printer()?;
let mut info_printer = rl.create_external_printer()?;
std::thread::spawn(move || {
while let Ok(line) = rl.readline("you> ") {
if line.trim().is_empty() {
continue;
}
let _ = rl.add_history_entry(line.as_str());
if line_tx.blocking_send(line).is_err() {
break;
}
}
let _ = rl.save_history(&crate::paths::talk_history_file());
});
let worker_name = worker.to_string();
(
spawn_sink(reply_rx, move |text| {
let _ = reply_printer.print(format!("{worker_name}> {text}\n"));
}),
spawn_sink(info_rx, move |text| {
let _ = info_printer.print(format!("{text}\n"));
}),
)
} else {
tokio::spawn(async move {
use tokio::io::AsyncBufReadExt;
let mut lines = tokio::io::BufReader::new(tokio::io::stdin()).lines();
while let Ok(Some(l)) = lines.next_line().await {
if l.trim().is_empty() {
continue;
}
if line_tx.send(l).await.is_err() {
break;
}
}
});
let worker_name = worker.to_string();
(
spawn_sink(reply_rx, move |text| println!("{worker_name}> {text}")),
spawn_sink(info_rx, |text| println!("{text}")),
)
};
let delivery_watch = {
let channel_id = channel_id.clone();
let reply_tx = reply_tx.clone();
tokio::spawn(async move {
let path = crate::paths::channel_dir(&channel_id).join("messages.jsonl");
let mut seen = std::fs::read_to_string(&path)
.map(|s| s.lines().count())
.unwrap_or(0);
loop {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let lines: Vec<&str> = text.lines().collect();
if lines.len() < seen {
seen = lines.len(); continue;
}
for l in &lines[seen..] {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(l) {
if v.get("role").and_then(|r| r.as_str()) == Some("worker") {
if let Some(content) = v.get("content").and_then(|c| c.as_str()) {
let _ = reply_tx.send(content.to_string()).await;
}
}
}
}
seen = lines.len();
}
})
};
let mut live: Option<(
tokio::sync::mpsc::Sender<boxed::SessionMessage>,
tokio::task::JoinHandle<()>,
)> = None;
while let Some(l) = line_rx.recv().await {
if l.trim_start().starts_with('/') {
let reply = match crate::channel::slash::parse(l.trim()) {
Ok(call) => {
crate::channel::slash::run(
worker,
&call,
&channel_id,
&context,
"host-op",
&format!("term:{user}"),
)
.await
}
Err(e) => e,
};
let _ = info_tx.send(reply).await;
continue;
}
crate::channel::discord::persist_message(
&channel_id,
&serde_json::json!({
"ts": crate::util::now_rfc3339(),
"author_id": user, "author": user, "role": "host-op",
"content": l, "attachments": [],
}),
);
let mut msg = Some(boxed::SessionMessage {
text: l,
author_label: user.clone(),
context: context.clone(),
});
if let Some((tx, _)) = live.as_ref() {
if let Err(back) = tx.send(msg.take().unwrap()).await {
msg = Some(back.0);
live = None;
}
}
if let Some(m) = msg {
let _ = info_tx.send(format!("(waking {worker}…)")).await;
let (tx, handle) = spawn_session(
worker,
idle,
context.clone(),
reply_tx.clone(),
info_tx.clone(),
);
if tx.send(m).await.is_err() {
let _ = info_tx
.send("(message not delivered — the session failed to start; try again)".into())
.await;
}
live = Some((tx, handle));
}
}
if let Some((tx, handle)) = live.take() {
drop(tx);
let _ = handle.await;
}
delivery_watch.abort();
drop(reply_tx);
drop(info_tx);
let _ = reply_sink.await;
let _ = info_sink.await;
if let Some(t) = saved_termios {
unsafe {
libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &t);
}
}
eprintln!("\nleft the conversation — resume: roster talk {worker}");
Ok(())
}