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(),
history: Vec::new(),
};
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());
}
crate::util::sane_line_discipline();
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;
let mut cmd = std::process::Command::new(std::env::current_exe()?);
cmd.args(["server", "start"])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::from(log.try_clone()?))
.stderr(std::process::Stdio::from(log));
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
cmd.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 missed_deliveries<'a>(
history: &'a [serde_json::Value],
seen: Option<&str>,
) -> Vec<&'a serde_json::Value> {
let is_worker =
|m: &serde_json::Value| m.get("role").and_then(|v| v.as_str()) == Some("worker");
let Some(seen) = seen.and_then(parse_ts) else {
let last_human = history
.iter()
.rposition(|m| m.get("role").and_then(|v| v.as_str()) == Some("host-op"));
return history
.iter()
.skip(last_human.map(|i| i + 1).unwrap_or(0))
.filter(|m| is_worker(m))
.collect();
};
history
.iter()
.filter(|m| {
is_worker(m)
&& m.get("ts")
.and_then(|v| v.as_str())
.and_then(parse_ts)
.is_some_and(|ts| ts > seen)
})
.collect()
}
fn parse_ts(value: &str) -> Option<time::OffsetDateTime> {
time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339).ok()
}
fn seen_marker(channel_id: &str) -> Option<String> {
std::fs::read_to_string(crate::paths::talk_seen_file(channel_id))
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn mark_seen(channel_id: &str) {
let path = crate::paths::talk_seen_file(channel_id);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, crate::util::now_rfc3339());
}
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)
}
}
struct TermiosGuard(Option<libc::termios>);
impl Drop for TermiosGuard {
fn drop(&mut self) {
if let Some(t) = self.0.take() {
unsafe {
libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &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 logical = crate::channel::links::logical_of(&channel_id);
let context = crate::worker::memory::RunContext {
provider: "term".into(),
channel_id: Some(logical.clone()),
surface_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);
if logical != channel_id {
let members = crate::channel::links::surfaces_of(&logical).join(", ");
eprintln!(
"talking to {worker} — channel {logical} (trusted; linked surfaces: {members}); quiet sessions wind down and wake on your next message; /help for commands, Ctrl-D leaves"
);
} else {
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 missed = missed_deliveries(&history, seen_marker(&channel_id).as_deref());
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("")
);
}
}
mark_seen(&channel_id);
let termios_guard = TermiosGuard(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;
}
let mut delivered = false;
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;
delivered = true;
}
}
}
}
if delivered {
mark_seen(&channel_id);
}
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;
}
let history = crate::channel::discord::recent_messages(
&channel_id,
crate::channel::discord::HISTORY_SNAPSHOT_MAX,
);
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(),
history,
});
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;
drop(termios_guard);
eprintln!("\nleft the conversation — resume: roster talk {worker}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn msg(role: &str, ts: &str, content: &str) -> serde_json::Value {
json!({ "role": role, "ts": ts, "content": content })
}
#[test]
fn replay_shows_only_deliveries_newer_than_the_cursor() {
let history = vec![
msg("host-op", "2026-07-17T04:00:00Z", "do the thing"),
msg("worker", "2026-07-17T04:10:00.5Z", "report A"),
msg("worker", "2026-07-17T04:12:00Z", "report B"),
];
let missed = missed_deliveries(&history, Some("2026-07-17T04:11:00.25Z"));
assert_eq!(missed.len(), 1);
assert_eq!(missed[0]["content"], "report B");
assert!(missed_deliveries(&history, Some("2026-07-17T04:13:00Z")).is_empty());
let missed = missed_deliveries(&history, None);
assert_eq!(missed.len(), 2);
}
}