# M2b task 09: `bxctl chat` **Branch:** `m2b` (run `git switch m2b`; `git status --short` must be empty, otherwise stop) **Commit subject:** `Add bxctl chat` ## Goal The owner's chat client: a line in, a turn over `loop.sock`, the stream shown as it arrives. It is also the scripting tool the device checks use (`--say`, `--json`). ## Files - Copy: `crates/bxctl/tests/chat.rs` - Create: `crates/bxctl/src/chat.rs` - Modify: `crates/bxctl/Cargo.toml` (add `serde_json.workspace = true`), `crates/bxctl/src/lib.rs` (add `pub mod chat;`), `crates/bxctl/src/main.rs`, `docs/implementer-log.md` ## Interfaces ```rust // crates/bxctl/src/chat.rs pub enum ChatError { Connect(std::io::Error), Frame(FrameError), Refused(WireError), Protocol(String) } // Debug; Display; Error // Refused displays as ": ", where the code name is lowercase words: "session full", "turn limit", "no such session", … pub fn new_session_id() -> SessionId; // "chat--" /// Sends one turn and reads the reply. `on_event` sees every event frame as it arrives. pub fn run_turn(socket: &Path, session: &SessionId, content: &str, resume: bool, on_event: &mut dyn FnMut(&TurnEvent)) -> Result; pub struct Printer { pub show_thinking: bool, pub json: bool, pub stream_content: bool, /* private: whether a dimmed block is open */ } impl Printer { pub fn new(show_thinking: bool, json: bool) -> Printer; // stream_content: true pub fn event(&mut self, out: &mut impl Write, event: &TurnEvent) -> std::io::Result<()>; pub fn end_reasoning(&mut self, out: &mut impl Write) -> std::io::Result<()>; // closes an open dimmed block } ``` **`run_turn`:** connect; send `Turn { session, content, resume }` as an envelope with `id: 1`, `final: true`; then read frames. A frame whose `id` is not 1 is `Protocol`. `TurnEvent` with `final: false` goes to `on_event`; `TurnDone` with `final: true` is the result; `Error` with `final: true` is `Refused`; anything else is `Protocol`. **`Printer::event`**, per event, when `json` is false: | Event | Output | |---|---| | `Reasoning { text }` | if `show_thinking`: open a dimmed block with `\x1b[2m` on the first, then the text as it is | | `Content { text }` | close the dimmed block if open (`\x1b[0m` and a newline); then the text as it is, only if `stream_content` | | `ToolCallStarted { name }` | close the block if open; `[tool ]` and a newline | | `ToolResult { name, class, truncated }` | `[: ]` or `[: , truncated]`, newline | | `Waiting { slot_busy }` | `[waiting: slot busy]` or `[waiting: slot idle]` | | `Retrying { attempt, after_ms, error }` | `[retrying: attempt in ms: ]` | | `ThinkingCapped { tokens }` | `[thinking capped at tokens]` | | `CacheLoss { expected, got }` | `[cache loss: of ]` | | `Queued`, `Progress` | nothing | Flush after every event. When `json` is true: every event is one line of JSON (`serde_json` of the `TurnEvent`), nothing is skipped, no escape codes. **`main`:** `bxctl chat [--socket ] [--session ] [--no-thinking] [--say ] [--json]`. The socket defaults to `$BOXMAKER_HOME/run/loop/loop.sock`, or `/var/lib/boxmaker/…` when the variable is unset. An invalid `--session`, an unknown option, or a first argument other than `chat` prints a usage line and exits 2. - With `--session`, the first turn is sent with `resume: true`; if `loopd` answers `no_such_session`, it is sent again with `resume: false`. Without `--session`, a new id is made and the first turn creates it. Every later turn resumes. - `--say `: one turn. Events go to **stderr**, with `stream_content` off; the answer goes to stdout with a newline; exit 0. On error: `bxctl: ` on stderr, nothing on stdout, exit 1. - Otherwise: print `session `, then repeat: print `> `, read a line; EOF or `/quit` ends with exit 0; a blank line is skipped; anything else is a turn, with events and content on stdout, and a newline after the answer if it did not end with one. A failed turn prints `bxctl: ` and the loop goes on. - `--json` with `--say` also prints the `TurnDone` as one JSON line after the events. ## Steps - [ ] **1. Copy.** `git switch m2b`, then `mkdir -p crates/bxctl/tests && cp docs/plans/M2b/files/crates/bxctl/tests/chat.rs crates/bxctl/tests/` - [ ] **2. See the test fail.** `cargo test -p bxctl --test chat`. Expected: it does not compile. - [ ] **3. Write `chat.rs`, `main.rs`, and the two one-line changes.** Run `cargo fmt --all`. - [ ] **4. See the tests pass.** `cargo test -p bxctl`. Expected: `11 passed`. - [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`. - [ ] **6. Log and commit.** `git add crates/bxctl docs/implementer-log.md && git commit` ## Done when - `cargo test -p bxctl` reports 11 passed; `make gate` prints `gate: ok`. - `bxctl` depends on `proto` and `serde_json` only. ## Stop and report if - A test in `chat.rs` needs the exact wording of an output line that this task does not give.