Add bxctl chat

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-18 20:24:26 -07:00
parent eb8fc5f920
commit 06cda69509
7 changed files with 908 additions and 3 deletions
+273 -3
View File
@@ -1,4 +1,274 @@
fn main() {
eprintln!("bxctl: not implemented until M2");
std::process::exit(2);
//! `bxctl`: the owner's chat client for `loopd`.
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use bxctl::chat::{ChatError, Printer, new_session_id, run_turn};
use proto::{ErrorCode, SessionId, TurnDone, TurnEvent};
const USAGE: &str =
"usage: bxctl chat [--socket <path>] [--session <id>] [--no-thinking] [--say <text>] [--json]";
struct Options {
socket: PathBuf,
session: Option<SessionId>,
show_thinking: bool,
say: Option<String>,
json: bool,
}
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.first().map(String::as_str) != Some("chat") {
eprintln!("{USAGE}");
return ExitCode::from(2);
}
match parse_chat(&args[1..]) {
Ok(opts) => run(&opts),
Err(()) => {
eprintln!("{USAGE}");
ExitCode::from(2)
}
}
}
fn parse_chat(args: &[String]) -> Result<Options, ()> {
let mut opts = Options {
socket: default_socket(),
session: None,
show_thinking: true,
say: None,
json: false,
};
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--socket" => {
i += 1;
match args.get(i) {
Some(p) => opts.socket = PathBuf::from(p),
None => return Err(()),
}
}
"--session" => {
i += 1;
match args.get(i) {
Some(s) => match SessionId::new(s) {
Ok(id) => opts.session = Some(id),
Err(_) => return Err(()),
},
None => return Err(()),
}
}
"--say" => {
i += 1;
match args.get(i) {
Some(t) => opts.say = Some(t.to_string()),
None => return Err(()),
}
}
"--no-thinking" => opts.show_thinking = false,
"--json" => opts.json = true,
_ => return Err(()),
}
i += 1;
}
Ok(opts)
}
// The base directory: $BOXMAKER_HOME if set, otherwise the system location the brief fixes.
fn default_socket() -> PathBuf {
let base = std::env::var_os("BOXMAKER_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker"));
base.join("run").join("loop").join("loop.sock")
}
// A writer that records the first io error it hits, so the on_event closure (which cannot return a
// Result) does not lose a write failure. The error is checked after run_turn returns.
struct Sink<'a> {
out: &'a mut (dyn Write + 'static),
err: Option<std::io::Error>,
}
impl Sink<'_> {
fn event(&mut self, printer: &mut Printer, event: &TurnEvent) {
if self.err.is_none() {
self.err = printer.event(self.out, event).err();
}
}
}
// Runs one turn, streaming events through `printer` to `out`. Returns the turn outcome, or the
// first io error that occurred while writing.
fn stream_turn(
socket: &Path,
session: &SessionId,
text: &str,
resume: bool,
sink: &mut Sink,
printer: &mut Printer,
) -> Result<Result<TurnDone, ChatError>, std::io::Error> {
let outcome = run_turn(socket, session, text, resume, &mut |event| {
sink.event(printer, event)
});
// Flush any dimmed block left open before reporting the turn's result.
if sink.err.is_none() {
printer.end_reasoning(sink.out)?;
}
match sink.err.take() {
Some(e) => Err(e),
None => Ok(outcome),
}
}
fn run(opts: &Options) -> ExitCode {
match &opts.say {
Some(text) => run_say(opts, text),
None => run_interactive(opts),
}
}
fn run_say(opts: &Options, text: &str) -> ExitCode {
let session = match &opts.session {
Some(s) => s.clone(),
None => new_session_id(),
};
let mut printer = Printer::new(opts.show_thinking, opts.json);
let code = run_turn_twice(opts, &session, text, opts.session.is_some(), &mut printer);
match code {
Ok(()) => ExitCode::SUCCESS,
Err(RunError::Io(e)) => {
eprintln!("bxctl: {e}");
ExitCode::from(1)
}
Err(RunError::Chat(e)) => {
eprintln!("bxctl: {e}");
ExitCode::from(1)
}
}
}
// Runs a --say turn, retrying once when the named session does not exist.
fn run_turn_twice(
opts: &Options,
session: &SessionId,
text: &str,
resume: bool,
printer: &mut Printer,
) -> Result<(), RunError> {
let stderr = std::io::stderr();
let mut handle = stderr.lock();
let mut sink = Sink {
out: &mut handle,
err: None,
};
match stream_turn(&opts.socket, session, text, resume, &mut sink, printer) {
Err(e) => Err(RunError::Io(e)),
Ok(Ok(done)) => {
write_answer(opts, &done).map_err(RunError::Io)?;
Ok(())
}
Ok(Err(ChatError::Refused(w))) if w.code == ErrorCode::NoSuchSession => {
// The named session does not exist: retry once, creating it (resume=false).
let mut sink = Sink {
out: &mut handle,
err: None,
};
match stream_turn(&opts.socket, session, text, false, &mut sink, printer) {
Err(e) => Err(RunError::Io(e)),
Ok(Ok(done)) => {
write_answer(opts, &done).map_err(RunError::Io)?;
Ok(())
}
Ok(Err(e)) => Err(RunError::Chat(e)),
}
}
Ok(Err(e)) => Err(RunError::Chat(e)),
}
}
fn run_interactive(opts: &Options) -> ExitCode {
let mut printer = Printer::new(opts.show_thinking, opts.json);
let mut session = None;
let stdin = std::io::stdin();
let mut reader = BufReader::new(stdin.lock());
loop {
eprint!("> ");
let _ = std::io::stderr().flush();
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => break,
Ok(_) => {}
Err(e) => {
eprintln!("bxctl: {e}");
return ExitCode::from(1);
}
}
let text = line.trim();
if text == "/quit" {
break;
}
if text.is_empty() {
continue;
}
let just_created = session.is_none();
let this_session = session.get_or_insert_with(new_session_id);
// The first turn creates the session (resume=false); later turns resume it.
let resume = !just_created;
if just_created {
println!("session {}", this_session.as_str());
}
let stderr = std::io::stderr();
let mut handle = stderr.lock();
let mut sink = Sink {
out: &mut handle,
err: None,
};
match stream_turn(
&opts.socket,
this_session,
text,
resume,
&mut sink,
&mut printer,
) {
Err(e) => {
eprintln!("bxctl: {e}");
return ExitCode::from(1);
}
Ok(Ok(done)) => {
if let Err(e) = write_answer(opts, &done) {
eprintln!("bxctl: {e}");
return ExitCode::from(1);
}
}
Ok(Err(e)) => {
eprintln!("bxctl: {e}");
return ExitCode::from(1);
}
}
}
ExitCode::SUCCESS
}
// The plain answer goes to stdout; in --json mode the TurnDone goes to stderr after the events.
fn write_answer(opts: &Options, done: &TurnDone) -> std::io::Result<()> {
if opts.json {
let line = serde_json::to_string(done).map_err(std::io::Error::other)?;
let mut err = std::io::stderr().lock();
err.write_all(line.as_bytes())?;
err.write_all(b"\n")?;
}
let stdout = std::io::stdout();
let mut out = stdout.lock();
out.write_all(done.content.as_bytes())?;
out.write_all(b"\n")?;
out.flush()
}
enum RunError {
Io(std::io::Error),
Chat(ChatError),
}