Files
boxmaker/docs/plans/M5c1/04-bxctl-speaker.md
T
kyleandClaude Opus 5.5 5454d7ea1b M5c-1 plan: task 04, bxctl chat --speaker NAME, opt-in, no default
The speaker goes on every turn of the chat: interactive, --say and --say's retry. Without the
flag it is None. A name that is empty or holds whitespace or a control character (Unicode), or a
second --speaker, is a usage error: the usage text on stderr and exit status 2. The usage text
gains the flag and one line explaining it. Given: chat_speaker.rs (new) and new versions of
chat.rs, chat_approvals.rs and cli.rs. Task 01 now copies chat.rs-task01, the older chat.rs.

Checks: every before block found once at ref 03; replayed from the task text on a fresh ref-03
worktree with its own build directory, byte-identical to the reference, gate ok with 1158 tests
(1148 + 10); /tmp unchanged around the reference's and the replay's gate runs; 20 of 20
mutations caught; the new tests passed 40 of 40 runs under CPU load; given files pass fmt and
the 500-line check; no chat-template tag in the plan or the given files.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 01:04:27 -07:00

17 KiB

M5c-1 task 04: bxctl chat --speaker NAME

Branch: m5c1 (run git switch m5c1; git status --short must be empty, otherwise stop) Commit subject: bxctl: chat --speaker NAME, the speaker on every turn of the chat Log row: the Task cell is M5c1/04-bxctl-speaker.

Goal

Task 01 gave Turn a field speaker: Option<String>, task 02 made loopd log it, and task 03 made gatewayd set it. memoryd will send a turn to Honcho only when it has a speaker (M5c spec, sections 4 and 11). bxctl chat, the owner's console, still sends speaker: None on every turn. This task gives it a flag, --speaker NAME.

The owner's rule: the speaker is opt-in, with no default. Console turns are mostly tests, and Honcho cannot forget a turn once it has it. So:

The command line Turn.speaker on the chat's turns
bxctl chat --speaker kyle (interactive) Some("kyle") on every turn, not only the first
bxctl chat --speaker kyle --say hi Some("kyle"), also on the turn sent again when a named session did not exist
bxctl chat --speaker first.last Some("first.last"): the name as typed, a . is not changed
bxctl chat or bxctl chat --say hi, no flag None, as today

No default, no environment variable, no configuration: only the flag.

The name's rule, exactly. bxctl refuses only what cannot be a name at all. The value is refused when it is empty, or when any character in it is whitespace (char::is_whitespace, which includes the tab, the line feed, the no-break space U+00A0 and the other Unicode spaces) or a control character (char::is_control: U+0000 to U+001F, U+007F and U+0080 to U+009F). Anything else is kept exactly as typed: letters outside ASCII, ., a leading -. memoryd checks the name against Honcho's own pattern in a later task. A second --speaker is refused too, even with the same name: one of two names would be the wrong person.

A refused value is a usage error, like every other bad argument of bxctl: parse returns UsageError, and main (unchanged) prints the usage text on stderr and exits with status 2, before it connects to anything. So nothing is sent.

The usage text gains the flag at the end of the chat line and one line under "Talk to loopd." that says what it does. Every explanation line of the usage text is indented by exactly six spaces, every command line by exactly two; a given test checks every line.

Every place that changes

This is the complete list.

Place What happens there
crates/bxctl/src/cli.rs, USAGE the flag in the chat line, one line explaining it (edit C1)
crates/bxctl/src/cli.rs, ChatOptions and ChatOptions::default new field speaker, None by default (edits C2, C3)
crates/bxctl/src/cli.rs, parse_chat, and a new parse_speaker the flag, its check, a second one refused (edits C4, C5)
crates/bxctl/src/chat.rs, run_turn a new parameter speaker: Option<&str>, after resume, put on the Turn (edits R1, R2)
crates/bxctl/src/chat.rs, stream_turn the same parameter, passed to run_turn (edit R3)
crates/bxctl/src/main.rs, run_turn_twice and run_interactive pass opts.speaker.as_deref() at all three calls of stream_turn (edits M1 to M3)
crates/bxctl/src/main.rs, main no change: a UsageError already prints the usage text and exits 2
tests that build a ChatOptions or call run_turn or stream_turn new versions are given

Files

  • Copy, from docs/plans/M5c1/files/ (the same path under it):
    • new: crates/bxctl/tests/chat_speaker.rs
    • new versions of (they replace the files in the tree): crates/bxctl/tests/chat.rs, chat_approvals.rs and cli.rs. Do not copy chat.rs-task01: it is the older version.
  • Modify: crates/bxctl/src/cli.rs, crates/bxctl/src/chat.rs, crates/bxctl/src/main.rs, docs/implementer-log.md.
  • Do not touch: any other source file (main's handling of a usage error in particular), any file of another crate, any Cargo.toml, crates/bxctl/tests/support/.

Facts you need

  • How parse_chat reads a flag with a value. Its loop matches args[i]. An arm like "--say" => opts.say = Some(value_at(args, i)?) takes the value, and the i += 2 after the match steps over the flag and its value. value_at(args, i) is args[i + 1], or UsageError when there is none; it takes the next argument even when it looks like a flag, so --speaker --json is the name --json (a given test says so). The new arm is a block, because it first refuses a second --speaker, and it falls through to the same i += 2: no continue.
  • Why the parameter is Option<&str>. ChatOptions owns the name as Option<String>; opts.speaker.as_deref() lends it as Option<&str>, and run_turn makes the Turn's own copy with speaker.map(str::to_string), as loopd does since task 02.
  • The three calls in main.rs. run_turn_twice (the --say path) calls stream_turn twice: once, and again with resume false when loopd answers that the named session does not exist. run_interactive calls it once per line. All three pass the speaker.
  • Seven arguments. stream_turn goes from six parameters to seven. Clippy's too_many_arguments fires only above seven, so there is no warning and no #[allow].
  • The usage line is long. The chat line of USAGE is already over 100 characters, inside a string; cargo fmt does not touch a string's contents, and the line-length check of the gate counts lines per file, not their width. Copy C1 exactly: its spaces are checked.
  • The given tests' processes and paths. chat_speaker.rs starts bxctl with a 20-second deadline, and every socket and directory it makes is removed by a guard (support/temp.rs).
  • The calls the edits use:
    Call Signature, and what it gives
    value_at fn value_at(args: &[String], i: usize) -> Result<String, UsageError> (exists)
    parse_speaker fn parse_speaker(value: &str) -> Result<String, UsageError> (new, C5)
    str::is_empty value.is_empty() -> bool
    str::chars, Iterator::any value.chars().any(|c| …) -> bool: true when one character matches
    char::is_whitespace, char::is_control c.is_whitespace() -> bool, c.is_control() -> bool (Unicode, not only ASCII)
    Option::as_deref opts.speaker.as_deref(): Option<String> to Option<&str>
    Option::map speaker.map(str::to_string): Option<&str> to Option<String>
    run_turn pub fn run_turn(socket: &Path, session: &SessionId, content: &str, resume: bool, speaker: Option<&str>, on_event: &mut dyn FnMut(&TurnEvent)) -> Result<TurnDone, ChatError> (R1)
    stream_turn pub fn stream_turn(socket: &Path, session: &SessionId, text: &str, resume: bool, speaker: Option<&str>, approvals: &Approvals<'_>, io: &mut TurnIo<'_>) -> std::io::Result<Result<TurnDone, ChatError>> (R3)

The edits, exactly

Nothing else in these files changes. Each quoted "before" block occurs exactly once in its file.

crates/bxctl/src/cli.rs

C1. In USAGE, the lines

  bxctl chat [--socket PATH] [--admin-socket PATH] [--session ID] [--say TEXT] [--no-thinking] [--json]
      Talk to loopd.

become

  bxctl chat [--socket PATH] [--admin-socket PATH] [--session ID] [--say TEXT] [--no-thinking] [--json] [--speaker NAME]
      Talk to loopd.
      With --speaker NAME (no spaces), the turns are NAME's and are synced to Honcho as that person.

The chat line starts with two spaces; each of the two lines under it with six.

C2. In struct ChatOptions, the lines

    pub say: Option<String>,
    pub json: bool,
}

become

    pub say: Option<String>,
    pub json: bool,
    /// Who is typing, from `--speaker`, sent on every turn (M5c spec, section 11). There is no
    /// default: without the flag it is `None`, and a turn without a speaker is not synced.
    pub speaker: Option<String>,
}

C3. In ChatOptions::default, the lines

            say: None,
            json: false,
        }

become

            say: None,
            json: false,
            speaker: None,
        }

C4. In parse_chat, the lines

            "--say" => opts.say = Some(value_at(args, i)?),
            "--no-thinking" => {

become

            "--say" => opts.say = Some(value_at(args, i)?),
            // Given twice, it is refused rather than one of them chosen: the turns would be synced
            // as the wrong person.
            "--speaker" => {
                if opts.speaker.is_some() {
                    return Err(UsageError);
                }
                opts.speaker = Some(parse_speaker(&value_at(args, i)?)?);
            }
            "--no-thinking" => {

C5. The line

// A refuse reason must be present; its content is unvalidated on the client.

becomes

// A speaker must be a name: not empty, and no whitespace or control character anywhere. The rest,
// Honcho's pattern, is `memoryd`'s to check (M5c spec, section 3); a `.` is kept as it is.
fn parse_speaker(value: &str) -> Result<String, UsageError> {
    if value.is_empty() || value.chars().any(|c| c.is_whitespace() || c.is_control()) {
        return Err(UsageError);
    }
    Ok(value.to_string())
}

// A refuse reason must be present; its content is unvalidated on the client.

crates/bxctl/src/chat.rs

R1. The lines

/// 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<TurnDone, ChatError> {

become

/// Sends one turn and reads the reply. `on_event` sees every event frame as it arrives. `speaker`
/// is `--speaker`'s value, sent as it is; `None` sends none.
pub fn run_turn(
    socket: &Path,
    session: &SessionId,
    content: &str,
    resume: bool,
    speaker: Option<&str>,
    on_event: &mut dyn FnMut(&TurnEvent),
) -> Result<TurnDone, ChatError> {

R2. In run_turn, the lines

        // Set in M5c-1 task 04, from `--speaker`. Until then no turn has a speaker.
        speaker: None,

become

        speaker: speaker.map(str::to_string),

R3. In stream_turn, the lines

    resume: bool,
    approvals: &Approvals<'_>,
    io: &mut TurnIo<'_>,
) -> std::io::Result<Result<TurnDone, ChatError>> {
    let mut err = None;
    let outcome = run_turn(socket, session, text, resume, &mut |event| {

become

    resume: bool,
    speaker: Option<&str>,
    approvals: &Approvals<'_>,
    io: &mut TurnIo<'_>,
) -> std::io::Result<Result<TurnDone, ChatError>> {
    let mut err = None;
    let outcome = run_turn(socket, session, text, resume, speaker, &mut |event| {

crates/bxctl/src/main.rs

M1. In run_turn_twice, the line

    match stream_turn(&opts.socket, session, text, resume, &approvals, &mut io) {

becomes

    match stream_turn(
        &opts.socket,
        session,
        text,
        resume,
        opts.speaker.as_deref(),
        &approvals,
        &mut io,
    ) {

M2. In run_turn_twice, the line

            match stream_turn(&opts.socket, session, text, false, &approvals, &mut io) {

becomes

            match stream_turn(
                &opts.socket,
                session,
                text,
                false,
                opts.speaker.as_deref(),
                &approvals,
                &mut io,
            ) {

(M1 and M2 are how cargo fmt lays the calls out once they have seven arguments.)

M3. In run_interactive, the lines

            text,
            resume,
            &approvals,
            &mut io,
        ) {

become

            text,
            resume,
            opts.speaker.as_deref(),
            &approvals,
            &mut io,
        ) {

Steps

  • 1. Copy the given files. From the repository root, four commands:

    cp docs/plans/M5c1/files/crates/bxctl/tests/chat_speaker.rs crates/bxctl/tests/
    cp docs/plans/M5c1/files/crates/bxctl/tests/chat.rs crates/bxctl/tests/
    cp docs/plans/M5c1/files/crates/bxctl/tests/chat_approvals.rs crates/bxctl/tests/
    cp docs/plans/M5c1/files/crates/bxctl/tests/cli.rs crates/bxctl/tests/
    

    Then git status --short lists 3 modified files and 1 new one, all under crates/bxctl/tests/.

  • 2. See them fail. cargo build --tests --keep-going -p bxctl: four test targets do not compile: chat, chat_approvals, chat_speaker and cli. Every error is about a name this task adds or changes: E0061 (this function takes 5 arguments but 6 arguments were supplied, for run_turn; this function takes 6 arguments but 7 arguments were supplied, for stream_turn), E0282 (type annotations needed, once, in chat.rs, on the closure passed to run_turn), E0560 (struct `ChatOptions` has no field named `speaker` ) or E0609 (no field `speaker` on type `ChatOptions` ).

  • 3. cli.rs. Make edits C1 to C5. cargo check -p bxctl: no error. Then cargo test -p bxctl --test cli: 15 passed.

  • 4. chat.rs. Make edits R1, R2 and R3. cargo check -p bxctl now fails in main.rs only, with three errors, one at each call of stream_turn: E0061 (this function takes 7 arguments but 6 arguments were supplied). The notes under them also point into chat.rs, at stream_turn's definition; that is where the call is compared, not a second error.

  • 5. main.rs. Make edits M1, M2 and M3. cargo check -p bxctl: no error and no warning.

  • 6. See them pass. cargo test -p bxctl --test chat_speaker: 7 passed. Then cargo test -p bxctl: every suite passes. The new tests:

    Test What it checks
    chat_speaker_is_the_value_after_the_flag (cli.rs) C4, C5: the name as typed (first.last, a name outside ASCII, --json)
    a_bad_speaker_is_a_usage_error (cli.rs) C4, C5: empty, whitespace or a control character anywhere, no value, a second --speaker
    usage_explains_the_speaker_flag (cli.rs) C1: the chat lines exactly, and the indentation of every line
    run_turn_puts_the_speaker_on_the_turn R1, R2: Some and None on the Turn
    stream_turn_passes_the_speaker_on R3
    say_sends_the_speaker_as_it_was_typed M1 end to end: first.last, and the text unchanged
    without_the_flag_no_turn_has_a_speaker C3: --say and interactive, None on every turn
    the_retried_turn_keeps_the_speaker M2: both turns of a --say on a session that does not exist
    every_interactive_turn_has_the_speaker M3: four turns, one of them failed, all with the speaker
    a_bad_speaker_is_a_usage_error_and_sends_nothing the binary: status 2, the usage text on stderr, nothing on stdout, no turn sent

    chat_defaults_come_from_home and chat_takes_every_flag_in_any_order in cli.rs also check speaker: None by default, and the flag among all the others.

  • 7. Check. Three commands:

    • grep -rn 'speaker: None' crates/bxctl/src prints one line, the default in ChatOptions::default (C3): the placeholder in chat.rs is gone.
    • git diff --stat -- crates/bxctl/src lists exactly chat.rs, cli.rs and main.rs.
    • git diff --stat -- crates/bxcore crates/loopd crates/memoryd crates/brokerd crates/gatewayd prints nothing.
  • 8. Run the gate. cargo fmt --all, then make gate, with a shell timeout of 600000 ms. Expected last line: gate: ok, with 1158 tests: 1148 before this task, plus 10 new ones (three in cli.rs, seven in chat_speaker.rs; the new versions of chat.rs and chat_approvals.rs keep their counts).

  • 9. Log and commit. Add your row to docs/implementer-log.md; its Task cell is M5c1/04-bxctl-speaker. git status --short lists the three sources, the three replaced test files and your log as modified, and chat_speaker.rs as new. Stage with git add crates/bxctl docs/implementer-log.md Cargo.lock, then commit as AGENTS.md says. Cargo.lock does not change in this task; the git add is harmless.

Done when

  • make gate prints gate: ok with 1158 tests.
  • git diff HEAD~1 --stat lists only the three source files of "Modify", the four copied files and docs/implementer-log.md.

Stop and report if

  • A "before" block of any edit is not in its file exactly as shown, or is there more than once.
  • Step 2 shows a different result: other test targets, or an error of another kind.
  • Step 3's cargo check fails, or step 4's fails anywhere other than the three calls of stream_turn in main.rs, or with other errors.
  • After step 3 or 6, a test fails. Do not change a test: stop, and give the failing tests' names and their messages.
  • The fix seems to need a change anywhere else: in main, in another crate, a default speaker, a speaker from the environment or a configuration, a change to the name (such as . to _), or a new kind of error with its own message. It does not; stop and say why you think so.
  • A test in inferproxy, toolkit or memoryd fails in the gate: check once whether it fails without your change, then stop and report both results.
  • The gate passes with a count other than 1158.
  • A comment in an edit does not fit the code under it. Quote it and stop.