Show and answer approvals in bxctl chat

Implemented-By: Grok 4.6
This commit is contained in:
2026-09-22 20:25:40 -07:00
parent 0a9df81fe4
commit f1f17a171f
5 changed files with 906 additions and 66 deletions
+146 -10
View File
@@ -1,14 +1,17 @@
//! `bxctl chat`: a turn client over `loop.sock` and a printer for the events it receives.
use std::io::Write;
use proto::{
Envelope, ErrorCode, Message, PROTOCOL_VERSION, SessionId, Turn, TurnDone, TurnEvent,
WireError, read_frame, write_frame,
};
use std::io::{BufRead, Write};
use std::os::unix::net::UnixStream;
use std::path::Path;
use proto::{
DenyReason, Envelope, ErrorCode, Message, PROTOCOL_VERSION, SessionId, Timestamp, Turn,
TurnDone, TurnEvent, WireError, read_frame, write_frame,
};
use crate::admin;
use crate::escape::escape_model_text;
#[derive(Debug)]
pub enum ChatError {
Connect(std::io::Error),
@@ -142,7 +145,7 @@ impl Printer {
out.write_all(b"\x1b[2m")?;
self.dimmed_open = true;
}
out.write_all(text.as_bytes())?;
out.write_all(escape_model_text(text).as_bytes())?;
}
}
TurnEvent::Content { text } => {
@@ -151,11 +154,12 @@ impl Printer {
self.dimmed_open = false;
}
if self.stream_content {
out.write_all(text.as_bytes())?;
out.write_all(escape_model_text(text).as_bytes())?;
}
}
TurnEvent::ToolCallStarted { name } => {
self.close_dimmed(out)?;
let name = escape_model_text(name);
writeln!(out, "[tool {name}]")?;
}
TurnEvent::ToolResult {
@@ -164,6 +168,7 @@ impl Printer {
truncated,
} => {
self.close_dimmed(out)?;
let name = escape_model_text(name);
if *truncated {
writeln!(out, "[{name}: {class:?}, truncated]")?;
} else {
@@ -195,8 +200,32 @@ impl Printer {
writeln!(out, "[cache loss: {got} of {expected}]")?;
}
TurnEvent::Queued { .. } | TurnEvent::Progress { .. } => {}
// Printed from task 20 on; until then these events are not sent.
TurnEvent::ApprovalPending { .. } | TurnEvent::ToolDenied { .. } => {}
TurnEvent::ApprovalPending { .. } => {
self.close_dimmed(out)?;
}
TurnEvent::ToolDenied { name, reason } => {
self.close_dimmed(out)?;
let shown = escape_model_text(name);
writeln!(out, "[denied {shown}: {}]", admin::reason_name(*reason))?;
match reason {
DenyReason::GrantsInvalid => {
writeln!(out, "see docs/runbook.md#grants-invalid")?;
}
DenyReason::AuditUnavailable => {
writeln!(out, "see docs/runbook.md#audit-unavailable")?;
}
DenyReason::StateUnreadable => {
writeln!(out, "see docs/runbook.md#broker-state-damaged")?;
}
DenyReason::NoGrant
| DenyReason::GrantExpired
| DenyReason::TaintTooHigh
| DenyReason::DeniedByGrant
| DenyReason::ApprovalRefused
| DenyReason::ApprovalExpired
| DenyReason::InvalidArguments => {}
}
}
}
out.flush()
}
@@ -213,3 +242,110 @@ impl Printer {
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OnPending {
EventOnly,
Show,
Ask,
}
pub struct Approvals<'a> {
pub admin_socket: &'a Path,
pub on_pending: OnPending,
}
pub struct TurnIo<'a> {
pub printer: &'a mut Printer,
pub input: &'a mut dyn BufRead,
pub out: &'a mut dyn Write,
}
pub fn handle_pending(
admin_socket: &Path,
approval: u64,
ask: bool,
now: Timestamp,
input: &mut dyn BufRead,
out: &mut dyn Write,
) -> std::io::Result<()> {
let items = match admin::list(admin_socket) {
Ok(items) => items,
Err(e) => {
writeln!(out, "approval {approval}: cannot ask brokerd: {e}")?;
return Ok(());
}
};
let Some(item) = items.iter().find(|item| item.approval == approval) else {
writeln!(out, "approval {approval} is no longer pending")?;
return Ok(());
};
out.write_all(b"\x1b[0m")?;
admin::write_block(out, item, now)?;
if !ask {
return Ok(());
}
write!(out, "type {approval} to approve, anything else refuses: ")?;
out.flush()?;
let mut line = String::new();
input.read_line(&mut line)?;
if line.ends_with('\n') {
line.pop();
}
if line.ends_with('\r') {
line.pop();
}
let result = if line == approval.to_string() {
admin::cmd_approve(admin_socket, approval, out)
} else {
admin::cmd_refuse(admin_socket, approval, None, out)
};
match result {
Ok(_) => Ok(()),
Err(e) => {
writeln!(out, "approval {approval}: {e}")?;
Ok(())
}
}
}
/// The outer error is a failed write to `io.out`; the inner one is the turn's.
pub fn stream_turn(
socket: &Path,
session: &SessionId,
text: &str,
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| {
if err.is_some() {
return;
}
if let Err(e) = io.printer.event(io.out, event) {
err = Some(e);
return;
}
if let TurnEvent::ApprovalPending { approval, .. } = event
&& approvals.on_pending != OnPending::EventOnly
{
let ask = approvals.on_pending == OnPending::Ask;
if let Err(e) = handle_pending(
approvals.admin_socket,
*approval,
ask,
Timestamp::now(),
io.input,
io.out,
) {
err = Some(e);
}
}
});
if let Some(e) = err {
return Err(e);
}
io.printer.end_reasoning(io.out)?;
Ok(outcome)
}