Files
hack-house/hh/src/app.rs
T
leetcrypt 37d5916b2e
CI / rust client (hh) (macos-latest) (push) Waiting to run
CI / rust client (hh) (ubuntu-latest) (push) Waiting to run
CI / rust coverage (push) Waiting to run
CI / python server (3.10) (push) Waiting to run
CI / python server (3.11) (push) Waiting to run
CI / python server (3.12) (push) Waiting to run
CI / headless e2e smoke (push) Waiting to run
CI / dependency audit (push) Waiting to run
CI / secret scanning (push) Waiting to run
feat(layout): fullscreen + interactive pane resizing
Add a window-management layer so the chat, roster and sandbox-terminal
panes can be resized live and fullscreened, with named presets for
recall.

- New layout.rs: single source of truth for the body split (pty_pct,
  roster_width) and a Zoom state (Normal/Term/Chat), persisted to
  layouts/<slug>.toml like themes. Both ui::draw and app::sbx_dims read
  from it, so resizing just mutates state and clears announced_dims —
  the per-tick loop re-syncs and broadcasts the new PTY grid.
- F4 cycles terminal/chat fullscreen (F-keys aren't forwarded to the
  shell, so nothing is stolen from in-shell apps).
- Interactive editing: click a pane (or F5 to cycle terminal -> chat ->
  roster) to select it; arrows then resize it live, Esc/Enter finishes.
  The selected pane gets a bold accent border and an ✎ title marker.
  ui::pane_at hit-tests against the same body_areas rects draw() paints.
- /layout slimmed to presets only (save/load/list/rm/reset); the old
  numeric pty/chat/roster and full/chatfull/normal verbs are replaced by
  the interactive flow.
- Help menu updated: LAYOUT cluster + KEYS line document F4, click/F5,
  arrows and Esc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 12:53:44 -07:00

2963 lines
135 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! TUI application state, network event model, and the async run loop.
use crate::ft;
use crate::layout::Layout;
use crate::net::{self, Session};
use crate::sbx;
use crate::theme::Theme;
use crate::ui;
use anyhow::Result;
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use crossterm::event::{
DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEventKind,
KeyModifiers, MouseButton, MouseEventKind,
};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use futures_util::{SinkExt, StreamExt};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use serde_json::json;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
use tokio_tungstenite::tungstenite::Message as WsMsg;
const SBX_NAME: &str = "hack-house";
#[derive(Clone)]
pub struct ChatLine {
pub ts: String,
pub username: String,
pub text: String,
pub system: bool,
}
/// A power a user currently holds in the room. Badges stack: a user can be the
/// `Host` *and* a `Sudoer` *and* a `Driver` at once. `Member` is the floor —
/// returned only when none of the others apply. The order of the variants is
/// the order badges are painted (host first).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Role {
Host,
Sudoer,
Driver,
Member,
}
#[derive(Clone)]
pub struct User {
pub user_id: String,
pub username: String,
}
/// An in-progress incoming transfer we accepted. Chunks stream straight to a
/// disk-backed `Sink` (created lazily on the first chunk) so a multi-GB payload
/// never sits in RAM.
struct Transfer {
meta: ft::Offer,
sink: Option<ft::Sink>,
accepted: bool,
}
/// An outgoing transfer awaiting / serving an accept. The payload is streamed
/// from `src` on disk, not held in memory.
struct ActiveSend {
id: String,
src: std::path::PathBuf,
/// `src` is a temp tar we built for a directory — delete it after sending.
temp: bool,
sending: bool,
}
/// Decoded events arriving from the websocket reader task.
pub enum Net {
Init {
lines: Vec<ChatLine>,
users: Vec<User>,
},
Message(ChatLine),
Roster {
users: Vec<User>,
capacity: usize,
},
Joined(String),
Left(String),
SbxStatus {
backend: String,
ready: bool,
rows: u16,
cols: u16,
},
SbxResize {
rows: u16,
cols: u16,
},
SbxData(Vec<u8>),
SbxInput {
from: String,
bytes: Vec<u8>,
},
Perm {
owner: String,
drivers: Vec<String>,
sudoers: Vec<String>,
},
Ft(ft::Ft),
/// An AI agent is generating a reply (`on`) or has finished (`!on`).
AiTyping {
name: String,
on: bool,
},
/// Incremental reply text from a streaming AI agent. `text` is the reply so
/// far (cumulative); `done` clears the live preview (the final, persisted
/// chat message arrives separately as a normal `Message`).
AiStream {
name: String,
text: String,
done: bool,
},
/// A local system notice produced off-thread (e.g. async Ollama probe).
Sys(String),
Err(String),
/// An outgoing `/send` finished hashing off-thread and is staged for
/// streaming — the run loop records it in `active_send` so a peer's `/accept`
/// can start the chunk stream. The offer frame is emitted by the same task.
SendReady {
id: String,
src: std::path::PathBuf,
temp: bool,
name: String,
size: u64,
to: Option<String>,
},
/// Relayed to the room when a member opens a shared VirtualBox VM locally, so
/// the *other* party knows the appliance is live and can open their own copy.
/// `by` is the server-authenticated launcher; the receiver skips its own echo.
VmOpened {
by: String,
vm: String,
},
Closed,
}
pub struct SbxView {
pub parser: vt100::Parser,
pub backend: String,
}
/// The arrow-navigable VirtualBox VM picker, opened by a bare `/sbx launch vbox`
/// (or `/sbx gui`). Holds the locally-registered VM names and the highlighted
/// row; Enter/Tab fills `/sbx launch vbox gui <vm>` into the input, Esc dismisses.
#[derive(Clone)]
pub struct VboxPicker {
pub vms: Vec<String>,
pub selected: usize,
}
/// A resizable region of the window, for interactive layout editing. Selected by
/// clicking it (or cycling with F5); once selected, arrow keys resize it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pane {
Chat,
Roster,
Terminal,
}
pub struct App {
pub me: String,
pub lines: Vec<ChatLine>,
pub users: Vec<User>,
pub capacity: usize,
pub input: String,
pub connected: bool,
pub sandbox: Option<SbxView>,
pub driving: bool,
pub owner: Option<String>,
pub drivers: std::collections::HashSet<String>,
/// Members whose VM unix account has sudo (superuser). Always includes owner.
pub sudoers: std::collections::HashSet<String>,
pub pending_offer: Option<ft::Offer>,
/// Open VM picker (arrow-navigable list of local VirtualBox VMs), or None.
pub vbox_picker: Option<VboxPicker>,
transfers: HashMap<String, Transfer>,
/// Chat scrollback: lines scrolled up from the live bottom (0 = following).
pub chat_scroll: usize,
/// Sandbox terminal scrollback: rows scrolled up from the bottom.
pub sbx_scroll: usize,
/// Whether the help overlay is showing.
pub show_help: bool,
/// Vertical scroll offset (rows) into the help overlay when it doesn't fit.
pub help_scroll: u16,
/// Index of the currently highlighted help cluster (up/down moves it).
pub help_selected: usize,
/// Per-cluster expand/collapse state (left/right/Enter toggles); sized to the
/// cluster count when the overlay opens.
pub help_expanded: Vec<bool>,
/// A reconnect handshake is in flight (Ctrl-R after a disconnect).
pub reconnecting: bool,
/// Transient error shown as a popup over the clergy (cleared on next keypress).
pub error: Option<String>,
/// The room password this client authenticated with (shown by `/pw`).
pub password: String,
/// AI agents currently generating a reply — drives the "thinking" spinner.
pub ai_typing: std::collections::HashSet<String>,
/// Live, in-progress reply text per streaming agent, shown as a transient
/// preview bubble until the final message lands. Keyed by agent name.
pub ai_stream: std::collections::HashMap<String, String>,
/// Monotonic tick counter used to animate the AI spinner.
pub spin: usize,
/// When set, agents we summon are auto-granted sandbox drive on each launch
/// (`/ai start <name> allow`). Re-applied in the broker Ready handler, since
/// launching a sandbox resets the ACL back to just the owner.
pub agent_sbx_allow: bool,
/// Display name the summoned agent joined under — derived from its model or
/// profile at `/ai start` (e.g. "qwen2.5:3b", model name + parameter size).
/// Tracked so the broker Ready handler can re-grant its drive and `/ai stop`
/// can revoke the right ACL entry.
pub agent_name: Option<String>,
/// Window layout: chat/terminal split, roster width, fullscreen zoom. Read
/// by `ui::draw` and `sbx_dims`; mutated by F4/F5 + interactive editing.
pub layout: Layout,
/// Interactive layout editing: the pane currently selected for resizing
/// (click it or cycle with F5), or None when not editing. When set, arrow
/// keys resize this pane instead of scrolling.
pub focused_pane: Option<Pane>,
}
impl App {
fn new(me: String) -> Self {
Self {
me,
lines: Vec::new(),
users: Vec::new(),
capacity: 0,
input: String::new(),
connected: false,
sandbox: None,
driving: false,
owner: None,
drivers: std::collections::HashSet::new(),
sudoers: std::collections::HashSet::new(),
pending_offer: None,
vbox_picker: None,
transfers: HashMap::new(),
chat_scroll: 0,
sbx_scroll: 0,
show_help: false,
help_scroll: 0,
help_selected: 0,
help_expanded: Vec::new(),
reconnecting: false,
error: None,
password: String::new(),
ai_typing: std::collections::HashSet::new(),
ai_stream: std::collections::HashMap::new(),
spin: 0,
agent_sbx_allow: false,
agent_name: None,
layout: Layout::default(),
focused_pane: None,
}
}
/// Cycle the interactive-edit selection: Terminal → Chat → Roster → off.
/// Bound to F5; clicking a pane jumps straight to it instead. The Terminal
/// step is skipped when no sandbox is up (nothing to resize there).
fn cycle_focus(&mut self) {
let has_term = self.sandbox.is_some();
self.focused_pane = match self.focused_pane {
None if has_term => Some(Pane::Terminal),
None => Some(Pane::Chat),
Some(Pane::Terminal) => Some(Pane::Chat),
Some(Pane::Chat) => Some(Pane::Roster),
Some(Pane::Roster) => None,
};
}
/// Append a chat line. Holds the viewport steady if scrolled up, and caps
/// the in-memory backlog.
fn push_line(&mut self, l: ChatLine) {
self.lines.push(l);
if self.chat_scroll > 0 {
self.chat_scroll += 1;
}
const CAP: usize = 4000;
if self.lines.len() > CAP {
let drop = self.lines.len() - CAP;
self.lines.drain(0..drop);
self.chat_scroll = self.chat_scroll.min(self.lines.len().saturating_sub(1));
}
}
pub fn is_owner(&self) -> bool {
self.owner.as_deref() == Some(self.me.as_str())
}
pub fn can_drive(&self) -> bool {
self.drivers.contains(&self.me)
}
/// The room host — whoever opened the house. The relay returns the roster in
/// join order (its session store is an insertion-ordered map), and every
/// client is served the *same* ordered roster, so "first occupant" is a
/// stable, consistent host across all peers without any extra protocol.
pub fn host(&self) -> Option<&str> {
self.users.first().map(|u| u.username.as_str())
}
/// Every role `name` currently holds, additive and in paint order. Reads the
/// exact same `sudoers`/`drivers` sets the broker uses to gate shell input,
/// so a rendered badge can never claim a power the room won't actually
/// enforce. Always returns at least `Member`.
pub fn roles_of(&self, name: &str) -> Vec<Role> {
let mut roles = Vec::new();
if self.host() == Some(name) {
roles.push(Role::Host);
}
if self.sudoers.contains(name) {
roles.push(Role::Sudoer);
}
if self.drivers.contains(name) {
roles.push(Role::Driver);
}
if roles.is_empty() {
roles.push(Role::Member);
}
roles
}
fn sys(&mut self, text: impl Into<String>) {
self.push_line(ChatLine {
ts: String::new(),
username: String::new(),
text: text.into(),
system: true,
});
}
/// Open the help overlay fresh: first cluster highlighted, scrolled to top,
/// all clusters collapsed (the empty expand vec renders as all-collapsed and
/// is resized to the cluster count on the first navigation key).
fn open_help(&mut self) {
self.show_help = true;
self.help_scroll = 0;
self.help_selected = 0;
self.help_expanded.clear();
}
/// Surface an error: kept in chat scrollback for history AND shown as a
/// popup over the clergy so it can't bleed onto / be overwritten at the
/// input box. Dismissed by the next keypress.
fn err(&mut self, text: impl Into<String>) {
let t = text.into();
self.sys(format!("✖ {t}"));
self.error = Some(t);
}
fn apply(&mut self, n: Net) {
match n {
Net::Init { lines, users } => {
self.lines = lines;
self.users = users;
self.connected = true;
self.chat_scroll = 0;
self.sys(format!("joined as {} ⛧", self.me));
self.sys("/sbx launch <docker|multipass|vbox> · /drive (F2 releases) · /ai start · /ai <question> · /send <user> <file> · /sendroom <file> · /pw show password · PgUp/PgDn scroll chat · ctrl-q quit");
}
Net::Message(l) => self.push_line(l),
Net::Roster { users, capacity } => {
self.users = users;
self.capacity = capacity;
}
Net::Joined(name) => self.sys(format!("{name} entered the house")),
Net::Left(uid) => {
if let Some(p) = self.users.iter().position(|u| u.user_id == uid) {
let name = self.users.remove(p).username;
self.ai_typing.remove(&name); // a departed agent isn't thinking
self.ai_stream.remove(&name); // …nor streaming a reply
self.sys(format!("{name} left"));
}
}
Net::AiTyping { name, on } => {
if on {
self.ai_typing.insert(name);
} else {
self.ai_typing.remove(&name);
}
}
Net::AiStream { name, text, done } => {
if done {
self.ai_stream.remove(&name);
} else {
// Streaming has started → drop the bare "thinking" spinner;
// the live preview now signals the agent is working.
self.ai_typing.remove(&name);
self.ai_stream.insert(name, text);
}
}
Net::SbxStatus {
backend,
ready,
rows,
cols,
} => {
if ready {
// Converge on the shared shell. If we already track this
// sandbox, just match its size — recreating the parser would
// wipe scrollback and re-announce on every re-broadcast. The
// owner re-sends `status:ready` whenever a member (re)joins, so
// this MUST be idempotent for everyone already in the room; only
// a genuinely new sandbox (none yet) is created and announced.
match &mut self.sandbox {
Some(v) => {
v.parser.set_size(rows.max(1), cols.max(1));
v.backend = backend.clone();
}
None => {
self.sandbox = Some(SbxView {
parser: vt100::Parser::new(rows.max(1), cols.max(1), 2000),
backend: backend.clone(),
});
self.sys(format!("⛧ sandbox summoned ({backend}) — F2 to drive"));
}
}
} else {
self.sandbox = None;
self.driving = false;
self.sbx_scroll = 0;
self.owner = None;
self.drivers.clear();
self.sudoers.clear();
self.sys("⛧ sandbox dismissed");
}
}
Net::SbxResize { rows, cols } => {
if let Some(v) = &mut self.sandbox {
v.parser.set_size(rows.max(1), cols.max(1));
}
}
Net::SbxData(bytes) => {
if let Some(v) = &mut self.sandbox {
v.parser.process(&bytes);
}
}
Net::SbxInput { .. } => {} // broker enforces + writes in the run loop
Net::Perm {
owner,
drivers,
sudoers,
} => {
let new: std::collections::HashSet<String> = drivers.into_iter().collect();
let sudo: std::collections::HashSet<String> = sudoers.into_iter().collect();
if !owner.is_empty() && self.owner.as_deref() != Some(owner.as_str()) {
self.sys(format!("⛧ {owner} is the superuser (sandbox owner)"));
}
if new.contains(&self.me)
&& !self.drivers.contains(&self.me)
&& self.owner.is_some()
{
self.sys("⛧ you were granted drive (F2 to take the shell)");
} else if !new.contains(&self.me) && self.drivers.contains(&self.me) {
self.driving = false;
self.sys("⛧ your drive permission was revoked");
}
if sudo.contains(&self.me)
&& !self.sudoers.contains(&self.me)
&& self.owner.is_some()
{
self.sys("⛧ you were granted sudo (superuser) in the VM");
}
self.owner = Some(owner).filter(|o| !o.is_empty());
self.drivers = new;
self.sudoers = sudo;
}
Net::Ft(_) => {} // handled in the run loop (needs out channel + disk)
Net::SendReady { .. } => {} // handled in the run loop (stages active_send)
Net::Sys(t) => self.sys(t),
Net::Err(t) => self.err(t),
Net::VmOpened { by, vm } => {
// Skip our own echo — we already saw the local "launched" line.
if by != self.me {
self.sys(format!(
"⛧ {by} opened {vm} locally — `/sbx gui {vm}` to open your own copy"
));
}
}
Net::Closed => {
self.connected = false;
self.sys("connection closed — press Ctrl-R to reconnect");
}
}
}
}
/// Human label for a resizable pane (used in layout-editing hints).
fn pane_label(p: Pane) -> &'static str {
match p {
Pane::Chat => "chat",
Pane::Roster => "roster",
Pane::Terminal => "terminal",
}
}
/// Join saved-preset names for display, or "none" when there are none.
fn once_or_none(names: Vec<String>) -> String {
if names.is_empty() {
"none".to_string()
} else {
names.join(" · ")
}
}
/// PTY grid (rows, cols) for the sandbox terminal. `pty_pct` is the terminal's
/// share of the body height (see `layout::Layout`); it must match the split
/// `ui::draw` paints, so they stay in lock-step via `app.layout`. Width is the
/// full body width — the terminal pane always spans the frame, only its height
/// changes with the split.
fn sbx_dims(term_w: u16, term_h: u16, pty_pct: u16) -> (u16, u16) {
let body_h = term_h.saturating_sub(4);
let sbx_h = (body_h as u32 * pty_pct as u32 / 100) as u16;
(
sbx_h.saturating_sub(2).max(1),
term_w.saturating_sub(2).max(1),
)
}
/// One page of sandbox scrollback = the visible grid height (defaults to 10 if
/// no sandbox is up). The run loop clamps `sbx_scroll` to the grid height anyway.
fn sbx_page(app: &App) -> usize {
app.sandbox
.as_ref()
.map(|v| v.parser.screen().size().0 as usize)
.unwrap_or(10)
.max(1)
}
fn key_to_pty(code: KeyCode, mods: KeyModifiers) -> Option<Vec<u8>> {
match code {
KeyCode::Char(c) => {
if mods.contains(KeyModifiers::CONTROL) {
let u = (c.to_ascii_uppercase() as u8).wrapping_sub(64);
Some(vec![u & 0x1f])
} else {
Some(c.to_string().into_bytes())
}
}
KeyCode::Enter => Some(vec![b'\r']),
KeyCode::Backspace => Some(vec![0x7f]),
KeyCode::Tab => Some(vec![b'\t']),
// Esc must reach the shell (vim/less/etc. depend on it). Drive is released
// with F2, not Esc, so this never collides with leaving the shell.
KeyCode::Esc => Some(vec![0x1b]),
KeyCode::Up => Some(b"\x1b[A".to_vec()),
KeyCode::Down => Some(b"\x1b[B".to_vec()),
KeyCode::Right => Some(b"\x1b[C".to_vec()),
KeyCode::Left => Some(b"\x1b[D".to_vec()),
_ => None,
}
}
/// Queue an encrypted JSON frame for transmission (drained by the run loop).
fn send_frame(out: &UnboundedSender<WsMsg>, room: &fernet::Fernet, value: serde_json::Value) {
let _ = out.send(WsMsg::Text(room.encrypt(value.to_string().as_bytes())));
}
/// Read `path` and broadcast a file/dir offer. `to = Some(user)` targets one
/// member (only they're prompted); `to = None` offers to the whole room. The
/// payload is staged in `active_send` and streamed once an /accept arrives.
fn offer_payload(
app: &mut App,
send_seq: &mut u64,
out_tx: &UnboundedSender<WsMsg>,
room: &Arc<fernet::Fernet>,
app_tx: &UnboundedSender<Net>,
path: &str,
to: Option<&str>,
) {
*send_seq += 1;
let id = format!("{}-{}", app.me, send_seq);
let path = path.to_string();
let to = to.map(str::to_string);
let out = out_tx.clone();
let room = room.clone();
let atx = app_tx.clone();
// Hashing (and tarring a directory) walks every byte — do it off the UI
// thread so a multi-GB payload doesn't freeze the TUI. The task stages the
// send via `Net::SendReady` *then* emits the offer, so a peer's `/accept`
// can never race ahead of `active_send`.
app.sys(format!("hashing {path}…"));
tokio::task::spawn_blocking(move || match ft::prepare_send(&path) {
Ok(s) => {
let _ = atx.send(Net::SendReady {
id: id.clone(),
src: s.src,
temp: s.temp,
name: s.name.clone(),
size: s.size,
to: to.clone(),
});
let mut frame = json!({
"_ft":"offer","id": id,"name": s.name,"size": s.size,"sha256": s.sha256,"dir": s.dir
});
if let Some(t) = &to {
frame["to"] = json!(t);
}
let _ = out.send(WsMsg::Text(room.encrypt(frame.to_string().as_bytes())));
}
Err(e) => {
let _ = atx.send(Net::Err(format!("send failed: {e}")));
}
});
}
fn broadcast_acl(out: &UnboundedSender<WsMsg>, room: &fernet::Fernet, app: &App) {
let drivers: Vec<&String> = app.drivers.iter().collect();
let sudoers: Vec<&String> = app.sudoers.iter().collect();
send_frame(
out,
room,
json!({
"_perm":"acl","owner": app.owner, "drivers": drivers, "sudoers": sudoers
}),
);
}
/// Stream a payload from disk to the clergy as `_ft` chunks (background, paced).
/// Reads `src` in `CHUNK` blocks so only one chunk is ever resident — a multi-GB
/// appliance streams with flat memory. Deletes `src` afterward if it's a temp tar.
fn spawn_send(
id: String,
src: std::path::PathBuf,
temp: bool,
out: UnboundedSender<WsMsg>,
room: Arc<fernet::Fernet>,
) {
tokio::task::spawn_blocking(move || {
use std::io::Read;
let mut f = match std::fs::File::open(&src) {
Ok(f) => f,
Err(_) => return,
};
let mut buf = vec![0u8; ft::CHUNK];
let mut seq = 0usize;
loop {
let n = match f.read(&mut buf) {
Ok(0) => break,
Ok(n) => n,
Err(_) => break,
};
let frame =
json!({"_ft":"chunk","id": id,"seq": seq,"data": STANDARD.encode(&buf[..n])});
if out
.send(WsMsg::Text(room.encrypt(frame.to_string().as_bytes())))
.is_err()
{
break;
}
seq += 1;
std::thread::sleep(Duration::from_millis(2));
}
let _ = out.send(WsMsg::Text(
room.encrypt(json!({"_ft":"done","id": id}).to_string().as_bytes()),
));
if temp {
let _ = std::fs::remove_file(&src);
}
});
}
fn handle_ft(
f: ft::Ft,
app: &mut App,
active: &mut Option<ActiveSend>,
out: &UnboundedSender<WsMsg>,
room: &Arc<fernet::Fernet>,
downloads: &std::path::Path,
) -> Option<std::path::PathBuf> {
// Set to the saved path when a transfer completes & verifies, so the caller
// can auto-bridge it into a running sandbox.
let mut saved = None;
match f {
ft::Ft::Offer(o) => {
if o.from == app.me {
return None; // our own offer echo
}
// Direct send addressed to a specific member: everyone receives the
// broadcast, but only the named recipient is prompted.
if let Some(to) = &o.to {
if to != &app.me {
return None;
}
}
app.sys(format!(
"⛧ {} offers {} ({}{}){} — /accept or /reject",
o.from,
o.name,
ft::human(o.size as usize),
if o.dir { ", directory" } else { "" },
if o.to.is_some() { " directly to you" } else { "" },
));
app.transfers.insert(
o.id.clone(),
Transfer {
meta: o.clone(),
sink: None,
accepted: false,
},
);
app.pending_offer = Some(o);
}
ft::Ft::Accept(id) => {
if let Some(a) = active.as_mut() {
if a.id == id && !a.sending {
a.sending = true;
spawn_send(id, a.src.clone(), a.temp, out.clone(), room.clone());
app.sys("transfer accepted — sending…");
}
}
}
ft::Ft::Reject(id) => {
if active.as_ref().map(|a| a.id == id).unwrap_or(false) {
app.sys("transfer rejected");
if let Some(a) = active.take() {
if a.temp {
let _ = std::fs::remove_file(&a.src); // discard the temp tar
}
}
}
}
ft::Ft::Chunk { id, data } => {
// Stream straight to the disk-backed sink (created lazily on the
// first chunk). Enforce the declared size (clamped to STREAM_MAX) on
// receipt — a sender can lie about `size` or just keep streaming, so
// never let an accepted transfer grow past the cap.
let mut overflow = false;
let mut io_err: Option<String> = None;
if let Some(t) = app.transfers.get_mut(&id) {
if t.accepted {
if t.sink.is_none() {
match ft::Sink::create(downloads, &id) {
Ok(s) => t.sink = Some(s),
Err(e) => io_err = Some(e.to_string()),
}
}
if let Some(s) = t.sink.as_mut() {
let cap = (t.meta.size).min(ft::STREAM_MAX);
if s.written() + data.len() as u64 > cap {
overflow = true;
} else if let Err(e) = s.write(&data) {
io_err = Some(e.to_string());
}
}
}
}
if overflow {
if let Some(t) = app.transfers.remove(&id) {
if let Some(s) = t.sink {
s.abort();
}
app.err(format!(
"{} — transfer exceeds declared size (max {}), aborted",
t.meta.name,
ft::human((t.meta.size).min(ft::STREAM_MAX) as usize)
));
}
} else if let Some(e) = io_err {
if let Some(t) = app.transfers.remove(&id) {
if let Some(s) = t.sink {
s.abort();
}
app.err(format!("{} — write failed: {e}", t.meta.name));
}
}
}
ft::Ft::Done(id) => {
if let Some(t) = app.transfers.remove(&id) {
if t.accepted {
match t.sink {
Some(s) => match s.finish() {
Ok((tmp, sha)) => {
if sha != t.meta.sha256 {
let _ = std::fs::remove_file(&tmp);
app.err(format!(
"{} — SHA-256 mismatch, discarded",
t.meta.name
));
} else {
match ft::commit(downloads, &t.meta, &tmp) {
Ok(p) => {
app.sys(format!(
"⛧ saved {} ({}) — verified ✓",
p.display(),
ft::human(t.meta.size as usize)
));
saved = Some(p);
}
Err(e) => app.err(format!("save failed: {e}")),
}
}
}
Err(e) => {
app.err(format!("{} — finalize failed: {e}", t.meta.name))
}
},
None => app.err(format!("{} — no data received", t.meta.name)),
}
}
}
if app
.pending_offer
.as_ref()
.map(|o| o.id == id)
.unwrap_or(false)
{
app.pending_offer = None;
}
}
}
saved
}
/// Put the terminal back the way we found it: leave raw mode, leave the
/// alternate screen, stop mouse capture, show the cursor. Best-effort — every
/// step is independent so one failing (e.g. already-restored) can't strand the
/// rest. Safe to call more than once.
fn restore_terminal() {
let _ = disable_raw_mode();
let _ = execute!(std::io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
let _ = execute!(std::io::stdout(), crossterm::cursor::Show);
}
/// RAII: restores the terminal on drop. As long as a guard is alive, *any* exit
/// from `run` — normal return, `?` error, or a panic unwinding through the
/// frame — leaves the user's terminal (or tmux pane) usable instead of stuck in
/// raw/alt-screen mode.
struct TermGuard;
impl Drop for TermGuard {
fn drop(&mut self) {
restore_terminal();
}
}
pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme) -> Result<()> {
let (tx, mut rx) = unbounded_channel::<Net>();
let app_tx = tx.clone();
let write = net::open(&session, tx.clone()).await?;
// Carries the result of a background reconnect handshake back to the loop.
let (recon_tx, mut recon_rx) = unbounded_channel::<std::result::Result<Session, String>>();
// All outgoing frames funnel through here so background tasks (file chunks,
// PTY relay) can transmit without owning the socket.
let (out_tx, out_rx) = unbounded_channel::<WsMsg>();
// The websocket writer runs on its own task so a slow / backpressured socket
// (notably while relaying a sandbox PTY stream to a remote peer) can never
// stall the UI loop's keyboard + chat handling. On reconnect the loop hands
// the writer a fresh sink through `sink_tx`.
let (sink_tx, sink_rx) = unbounded_channel::<net::WsSink>();
tokio::spawn(writer_task(write, out_rx, sink_rx));
let (pty_tx, mut pty_rx): (UnboundedSender<Vec<u8>>, UnboundedReceiver<Vec<u8>>) =
unbounded_channel();
let (broker_tx, mut broker_rx) = unbounded_channel::<BrokerMsg>();
let mut broker: Option<sbx::Sandbox> = None;
let mut broker_meta: Option<(sbx::Backend, String)> = None;
let mut launching = false;
let mut announced_dims: Option<(u16, u16)> = None;
let mut active_send: Option<ActiveSend> = None;
let mut send_seq: u64 = 0;
// The local AI agent subprocess this client spawned via `/ai start`, if any.
let mut agent: Option<std::process::Child> = None;
let downloads = PathBuf::from("./downloads");
enable_raw_mode()?;
let mut stdout = std::io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let mut term = Terminal::new(CrosstermBackend::new(stdout))?;
// From here on the terminal is in raw/alt-screen mode. The guard restores it
// on every exit path (return, `?`, panic); the panic hook additionally prints
// the panic *after* restoring, so a crash leaves a readable message in the
// pane instead of garbled raw-mode output.
let _term_guard = TermGuard;
let default_panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore_terminal();
default_panic(info);
}));
// Graceful shutdown on signals: a `kill`, a closed tmux pane (SIGHUP), or a
// Ctrl-C delivered while NOT in raw mode all break the loop cleanly so the
// guard above can restore the terminal — no more being "booted" with a
// broken pane. (In raw mode Ctrl-C arrives as a key event, handled below.)
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())?;
let mut app = App::new(session.username.clone());
app.password = params.password.clone();
// Seed the roster width from the active vestment so a `--theme` with a wider
// roster is honoured; from here on the live layout owns it (so /theme swaps
// don't stomp a width the user has set with /layout).
app.layout.roster_width = theme.roster_width;
let mut events = EventStream::new();
let mut tick = tokio::time::interval(Duration::from_millis(50));
let result = loop {
// Apply the sandbox scrollback offset (0 = follow live).
//
// vt100 0.15.2 panics ("subtract with overflow" in grid::visible_rows)
// if the scrollback offset ever exceeds the visible grid height: it does
// `rows_len - offset` on usize without clamping. Cap our offset to the
// grid height so a fast scroll can never cross that line and crash us.
if let Some(v) = &mut app.sandbox {
let rows = v.parser.screen().size().0 as usize;
app.sbx_scroll = app.sbx_scroll.min(rows);
v.parser.set_scrollback(app.sbx_scroll);
}
if let Err(e) = term.draw(|f| ui::draw(f, &app, &theme)) {
break Err(e.into());
}
if broker.is_some() {
if let Ok(sz) = term.size() {
let dims = sbx_dims(sz.width, sz.height, app.layout.effective_pty_pct());
if announced_dims != Some(dims) {
announced_dims = Some(dims);
if let Some(sb) = &broker {
let _ = sb.resize(dims.0, dims.1);
}
send_frame(
&out_tx,
&session.room,
json!({"_sbx":"resize","rows":dims.0,"cols":dims.1}),
);
}
}
}
tokio::select! {
biased; // keyboard first, so Ctrl-C / Esc are never starved by output floods
maybe = events.next() => {
match maybe {
Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => {
app.error = None; // any keypress dismisses the error popup
// Ctrl-Q always quits. Ctrl-C quits too — *unless* we're
// driving the sandbox, where it must reach the PTY as an
// interrupt (handled in the `app.driving` branch below).
if k.modifiers.contains(KeyModifiers::CONTROL)
&& (matches!(k.code, KeyCode::Char('q'))
|| (matches!(k.code, KeyCode::Char('c')) && !app.driving))
{
break Ok(());
}
if k.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(k.code, KeyCode::Char('x'))
&& !app.driving
{
// Panic kill switch (sandbox owner): revoke every
// non-owner driver, interrupt whatever is running in
// the PTY, and re-broadcast the locked-down ACL. Cuts a
// runaway agent (or human) off mid-command. Gated on
// `!app.driving` so that while YOU hold the shell, Ctrl-X
// reaches the PTY instead (nano's quit; key_to_pty sends
// 0x18) — release with F2 first to arm the kill switch.
if let Some(sb) = &mut broker {
let owner = app.me.clone();
app.drivers.retain(|u| *u == owner);
app.sudoers.retain(|u| *u == owner);
app.agent_sbx_allow = false;
let _ = sb.write_input(&[0x03]); // Ctrl-C into the shell
broadcast_acl(&out_tx, &session.room, &app);
app.sys("⛧ kill switch — revoked all drive + interrupted the shell");
} else {
app.sys("kill switch is for the sandbox owner (you don't hold the PTY)");
}
} else if k.modifiers.contains(KeyModifiers::CONTROL)
&& k.modifiers.contains(KeyModifiers::ALT)
&& matches!(k.code, KeyCode::Char('p'))
{
// Conjure a brand-new procedural vestment (not a bundled
// preset): fresh palette + sigil, rolled from the clock.
theme = Theme::random();
app.sys(format!(
"{} conjured vestment '{}'",
theme.sigil, theme.name
));
} else if k.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(k.code, KeyCode::Char('r'))
&& !app.connected
{
// Reconnect: re-run the SRP handshake off-thread so the UI
// stays responsive, then re-attach the websocket on success.
if !app.reconnecting {
app.reconnecting = true;
app.sys("⛧ reconnecting…");
let p = params.clone();
let rtx = recon_tx.clone();
tokio::task::spawn_blocking(move || {
let r = net::authenticate(
&p.ip, p.port, &p.user, &p.password, p.no_tls, p.insecure,
)
.map_err(|e| e.to_string());
let _ = rtx.send(r);
});
}
} else if app.vbox_picker.is_some() {
// Modal VM picker (from a bare `/sbx launch vbox`): arrow
// keys move the highlight, Enter/Tab boots the selected VM
// on this machine (host-frictionless path; a non-host who
// needs install/import is steered to re-issue with `yes`),
// Esc dismisses. Intercepts keys so the list stays put.
match k.code {
KeyCode::Up => {
if let Some(p) = &mut app.vbox_picker {
p.selected = p.selected.saturating_sub(1);
}
}
KeyCode::Down => {
if let Some(p) = &mut app.vbox_picker {
p.selected =
(p.selected + 1).min(p.vms.len().saturating_sub(1));
}
}
KeyCode::Enter | KeyCode::Tab => {
let vm = app
.vbox_picker
.take()
.and_then(|p| p.vms.into_iter().nth(p.selected));
if let Some(vm) = vm {
launch_vbox_gui(
&mut app, vm, false, &app_tx, &out_tx, &session.room,
);
}
}
KeyCode::Esc => {
app.vbox_picker = None;
app.sys("⛧ picker dismissed");
}
_ => {} // ignore other keys so the picker stays put
}
} else if app.show_help {
// tmux-style nav: up/down highlight a cluster, left/right
// (or Enter) collapse/expand it, PgUp/PgDn scroll the
// overflow, only Esc closes so stray keys can't dismiss a
// menu you're still reading.
let count = ui::help_cluster_count(&theme);
let max = term
.size()
.map(|s| ui::help_max_scroll(s.width, s.height, &app, &theme))
.unwrap_or(0);
// keep the expand state sized to the clusters
if app.help_expanded.len() != count {
app.help_expanded.resize(count, false);
}
match k.code {
KeyCode::Up => {
app.help_selected = app.help_selected.saturating_sub(1);
}
KeyCode::Down => {
app.help_selected =
(app.help_selected + 1).min(count.saturating_sub(1));
}
KeyCode::Left => {
if let Some(e) = app.help_expanded.get_mut(app.help_selected) {
*e = false; // collapse the highlighted cluster
}
}
KeyCode::Right => {
if let Some(e) = app.help_expanded.get_mut(app.help_selected) {
*e = true; // reveal the highlighted cluster
}
}
KeyCode::Enter | KeyCode::Char(' ') => {
if let Some(e) = app.help_expanded.get_mut(app.help_selected) {
*e = !*e; // toggle
}
}
KeyCode::PageUp => app.help_scroll = app.help_scroll.saturating_sub(10),
KeyCode::PageDown => app.help_scroll = (app.help_scroll + 10).min(max),
KeyCode::Home => app.help_scroll = 0,
KeyCode::End => app.help_scroll = max,
KeyCode::Esc | KeyCode::F(1) => {
// Esc dismisses; F1 toggles the overlay back shut
app.show_help = false;
app.help_scroll = 0;
}
_ => {} // ignore other keys so the menu stays put
}
// clamp scroll in case collapsing shrank the content
let max = term
.size()
.map(|s| ui::help_max_scroll(s.width, s.height, &app, &theme))
.unwrap_or(0);
app.help_scroll = app.help_scroll.min(max);
} else if k.code == KeyCode::F(1) {
app.open_help(); // F1 from any mode
} else if k.code == KeyCode::F(2) {
if app.sandbox.is_none() {
} else if app.can_drive() {
app.driving = !app.driving;
} else {
app.sys("you don't have drive permission — the owner can /grant you");
}
} else if k.code == KeyCode::F(4) {
// Fullscreen the terminal (cycle terminal → chat → split).
// Caught before the drive branch so it works mid-session;
// F-keys aren't forwarded to the shell anyway (key_to_pty).
if app.sandbox.is_some() {
app.layout.cycle_zoom();
announced_dims = None; // re-sync PTY to the new height
app.sys(format!("⛧ {}", app.layout.describe()));
} else {
app.sys("no sandbox to fullscreen — /sbx launch first");
}
} else if k.code == KeyCode::F(5) {
// Enter/cycle interactive layout editing: pick a pane to
// resize (Terminal → Chat → Roster → off). Clicking a pane
// selects it directly; arrows then resize it.
app.cycle_focus();
match app.focused_pane {
Some(p) => app.sys(format!(
"⛧ editing {} — arrows resize · Esc done",
pane_label(p)
)),
None => app.sys("⛧ layout editing off"),
}
} else if app.focused_pane.is_some() && !app.driving {
// Interactive layout editing: a pane is selected (via click
// or F5). Arrows resize it live; Esc/Enter finishes. Sits
// before the driving branch but is gated on !driving so the
// shell still gets its keys while you hold the PTY.
let pane = app.focused_pane.unwrap();
match (pane, k.code) {
(_, KeyCode::Esc) | (_, KeyCode::Enter) => {
app.focused_pane = None;
app.sys(format!("⛧ {}", app.layout.describe()));
}
// Terminal taller / chat shorter (and the mirror image
// when the chat pane is the one selected).
(Pane::Terminal, KeyCode::Up) | (Pane::Chat, KeyCode::Down) => {
app.layout.grow_pty(3);
announced_dims = None;
app.sys(format!("⛧ {}", app.layout.describe()));
}
(Pane::Terminal, KeyCode::Down) | (Pane::Chat, KeyCode::Up) => {
app.layout.shrink_pty(3);
announced_dims = None;
app.sys(format!("⛧ {}", app.layout.describe()));
}
(Pane::Roster, KeyCode::Left) => {
app.layout.roster_width =
app.layout.roster_width.saturating_sub(2);
app.sys(format!("⛧ {}", app.layout.describe()));
}
(Pane::Roster, KeyCode::Right) => {
app.layout.roster_width =
(app.layout.roster_width + 2).min(Layout::MAX_ROSTER);
app.sys(format!("⛧ {}", app.layout.describe()));
}
_ => {} // ignore other keys while editing
}
} else if app.driving {
// Esc is NOT a release key here — vim & friends need it,
// so it's forwarded to the PTY like any other key (see
// key_to_pty). Press F2 (handled above) to release the
// shell back to chat.
if k.code == KeyCode::PageUp {
// Scroll the shared shell's scrollback without releasing the
// drive: PgUp/PgDn aren't forwarded to the PTY anyway.
app.sbx_scroll = (app.sbx_scroll + sbx_page(&app)).min(2000);
} else if k.code == KeyCode::PageDown {
app.sbx_scroll = app.sbx_scroll.saturating_sub(sbx_page(&app));
} else if let Some(bytes) = key_to_pty(k.code, k.modifiers) {
if let Some(sb) = &mut broker {
// I own the sandbox: write straight to the PTY — instant,
// and Ctrl-C can't be queued behind outgoing output.
let _ = sb.write_input(&bytes);
} else {
send_frame(&out_tx, &session.room, json!({"_sbx":"input","b64": STANDARD.encode(&bytes)}));
}
}
} else {
match k.code {
KeyCode::Enter => {
let line = app.input.trim().to_string();
app.input.clear();
app.chat_scroll = 0; // jump back to live on send
handle_command(&line, &mut app, &mut theme, &mut send_seq,
&mut broker, &mut broker_meta, &mut launching, &mut announced_dims,
&out_tx, &pty_tx, &broker_tx, &app_tx, &session, &term,
&mut agent, &params);
}
KeyCode::Backspace => { app.input.pop(); }
// Scroll: ↑/↓ scroll the sandbox terminal if one is up,
// otherwise the chat. PgUp/PgDn always scroll chat.
KeyCode::Up => {
if app.sandbox.is_some() {
app.sbx_scroll = (app.sbx_scroll + 1).min(2000);
} else {
app.chat_scroll = (app.chat_scroll + 1).min(app.lines.len().saturating_sub(1));
}
}
KeyCode::Down => {
if app.sandbox.is_some() {
app.sbx_scroll = app.sbx_scroll.saturating_sub(1);
} else {
app.chat_scroll = app.chat_scroll.saturating_sub(1);
}
}
KeyCode::PageUp => {
app.chat_scroll = (app.chat_scroll + 10).min(app.lines.len().saturating_sub(1));
}
KeyCode::PageDown => {
app.chat_scroll = app.chat_scroll.saturating_sub(10);
}
KeyCode::Home => {
app.chat_scroll = app.lines.len().saturating_sub(1);
}
KeyCode::End => {
app.chat_scroll = 0;
app.sbx_scroll = 0;
}
KeyCode::Char(c) => app.input.push(c),
_ => {}
}
}
}
Some(Ok(Event::Mouse(m))) => {
// Mouse wheel scrolls the sandbox terminal if one is up
// (incl. while driving), otherwise the chat — mirrors ↑/↓.
match m.kind {
MouseEventKind::ScrollUp => {
if app.sandbox.is_some() {
app.sbx_scroll = (app.sbx_scroll + 3).min(2000);
} else {
app.chat_scroll = (app.chat_scroll + 3).min(app.lines.len().saturating_sub(1));
}
}
MouseEventKind::ScrollDown => {
if app.sandbox.is_some() {
app.sbx_scroll = app.sbx_scroll.saturating_sub(3);
} else {
app.chat_scroll = app.chat_scroll.saturating_sub(3);
}
}
// Left-click a pane to select it for interactive resize
// (then arrows adjust it; Esc finishes). Skipped while
// driving the shell or with an overlay up, so clicks there
// don't hijack the terminal/help/picker.
MouseEventKind::Down(MouseButton::Left)
if !app.driving && !app.show_help && app.vbox_picker.is_none() =>
{
if let Ok(sz) = term.size() {
if let Some(p) =
ui::pane_at(sz.width, sz.height, &app, m.column, m.row)
{
app.focused_pane = Some(p);
app.sys(format!(
"⛧ editing {} — arrows resize · Esc done",
pane_label(p)
));
} else {
app.focused_pane = None;
}
}
}
_ => {}
}
}
Some(Err(e)) => break Err(e.into()),
_ => {}
}
}
net = rx.recv() => {
// Drain a burst of incoming frames per turn. The reader funnels both
// chat and high-volume `_sbx:data` terminal output through this one
// channel, and the loop redraws once per turn — so handling a single
// frame per redraw lets a busy sandbox stream bury chat arbitrarily far
// back in the queue. Pulling up to a cap of ready frames now keeps chat
// latency bounded no matter how hard the shared shell is scrolling.
let Some(first) = net else { break Ok(()) };
let mut burst = vec![first];
drain_ready(&mut rx, &mut burst, 256);
for ev in burst {
match ev {
Net::SbxInput { from, bytes } => {
if let Some(sb) = &mut broker {
if app.drivers.contains(&from) {
let _ = sb.write_input(&bytes);
}
}
}
Net::Ft(f) => {
// On a received, SHA-verified file, auto-bridge it into
// a sandbox we host so the whole clergy can use it from
// the shared shell. Runs off the UI thread (docker/mp
// exec + tar stream) and reports back via the app channel.
// A local backend already shares the host fs — nothing to do.
if let Some(path) =
handle_ft(f, &mut app, &mut active_send, &out_tx, &session.room, &downloads)
{
// A received VirtualBox appliance auto-imports so the VM
// registers locally and the recipient can immediately
// `/sbx launch vbox gui <vm>`. Off-thread (VBoxManage import
// is slow); reports back via the app channel.
let ext = path
.extension()
.and_then(|s| s.to_str())
.map(|s| s.to_ascii_lowercase());
if matches!(ext.as_deref(), Some("ova") | Some("ovf")) {
let tx = app_tx.clone();
let ova = path.clone();
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || {
sbx::import_appliance(&ova)
})
.await;
let _ = match res {
Ok(Ok(vm)) => tx.send(Net::Sys(format!(
"⛧ imported VM {vm} — `/sbx launch vbox gui {vm}` to boot it"
))),
Ok(Err(e)) => tx.send(Net::Sys(format!(
"(received .ova not auto-imported: {e})"
))),
Err(e) => tx.send(Net::Err(format!("import task: {e}"))),
};
});
}
if let Some((be, name)) = &broker_meta {
if !matches!(be, sbx::Backend::Local) {
let (be, name) = (*be, name.clone());
let run_user = sbx::run_user_for(
be,
app.owner.as_deref().unwrap_or(app.me.as_str()),
);
let tx = app_tx.clone();
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || {
sbx::push(be, &name, &run_user, &path)
})
.await;
let _ = match res {
Ok(Ok(dest)) => tx.send(Net::Sys(format!(
"⛧ bridged into sandbox → {dest}"
))),
Ok(Err(e)) => tx.send(Net::Sys(format!(
"(received file not bridged into sandbox: {e})"
))),
Err(e) => tx.send(Net::Err(format!("bridge task: {e}"))),
};
});
}
}
}
}
// The broker renders its sandbox locally from the PTY, so it
// ignores its own echoed status/data; everyone else uses them.
Net::SbxData(b) => {
if broker.is_none() {
if let Some(v) = &mut app.sandbox { v.parser.process(&b); }
}
}
Net::SbxStatus { .. } if broker.is_some() => {}
ev @ Net::Joined(_) => {
// Someone (re)entered and missed everything broadcast
// before they connected. If we host the sandbox, replay
// it so it reappears for them: `status:ready` makes the
// pane show up, the screen snapshot (`_sbx:data`) paints
// its current contents instead of a blank pane, and the
// ACL re-broadcast re-syncs grant state (also what makes
// `/ai start <m> allow` reach a freshly-joined agent).
// The status/data handlers are idempotent, so members
// already in the room aren't disturbed by the replay.
if broker.is_some() {
if let (Some(v), Some((be, _))) = (&app.sandbox, &broker_meta) {
let (rows, cols) = v.parser.screen().size();
send_frame(&out_tx, &session.room, json!({
"_sbx":"status","state":"ready",
"backend": be.label(),"rows": rows,"cols": cols
}));
let snap = v.parser.screen().contents_formatted();
send_frame(&out_tx, &session.room, json!({
"_sbx":"data","b64": STANDARD.encode(&snap)
}));
}
broadcast_acl(&out_tx, &session.room, &app);
}
app.apply(ev);
}
Net::SendReady { id, src, temp, name, size, to } => {
// The off-thread hash finished and emitted the offer; record
// the staged send so a peer's /accept can start streaming.
active_send = Some(ActiveSend { id, src, temp, sending: false });
match to {
Some(t) => app.sys(format!(
"offered {} ({}) to {} — waiting for an /accept",
name,
ft::human(size as usize),
t
)),
None => app.sys(format!(
"offered {} ({}) to the room — waiting for an /accept",
name,
ft::human(size as usize)
)),
}
}
other => app.apply(other),
}
}
}
msg = broker_rx.recv() => {
match msg {
Some(BrokerMsg::Ready { sb, backend, name, rows, cols }) => {
broker = Some(sb);
broker_meta = Some((backend, name));
announced_dims = Some((rows, cols));
launching = false;
// Local sandbox view — broker renders straight from the PTY.
app.sandbox = Some(SbxView {
parser: vt100::Parser::new(rows.max(1), cols.max(1), 2000),
backend: backend.label().to_string(),
});
app.sys(format!("⛧ sandbox summoned ({}) — /drive to take the shell", backend.label()));
app.owner = Some(app.me.clone());
app.drivers.clear();
app.drivers.insert(app.me.clone());
app.sudoers.clear();
app.sudoers.insert(app.me.clone()); // owner = superuser
if app.agent_sbx_allow {
// Re-apply a `/ai start … allow` grant the launch reset.
if let Some(n) = &app.agent_name {
app.drivers.insert(n.clone());
}
}
send_frame(&out_tx, &session.room, json!({
"_sbx":"status","state":"ready","backend": backend.label(), "rows": rows, "cols": cols
}));
broadcast_acl(&out_tx, &session.room, &app);
}
Some(BrokerMsg::Failed) => { launching = false; }
None => {}
}
}
recon = recon_rx.recv() => {
if let Some(result) = recon {
app.reconnecting = false;
match result {
Ok(s) => {
session = s;
match net::open(&session, tx.clone()).await {
Ok(w) => {
let _ = sink_tx.send(w);
app.sys("⛧ websocket re-attached — syncing…");
// If we host the sandbox, re-announce it so the
// rest of the house re-syncs the shared shell.
if let Some((be, _)) = &broker_meta {
if let Some(v) = &app.sandbox {
let (rows, cols) = v.parser.screen().size();
send_frame(&out_tx, &session.room, json!({
"_sbx":"status","state":"ready",
"backend": be.label(),"rows": rows,"cols": cols
}));
broadcast_acl(&out_tx, &session.room, &app);
}
}
}
Err(e) => app.err(format!("reconnect failed: {e}")),
}
}
Err(e) => app.sys(format!("reconnect failed: {e}")),
}
}
}
pty = pty_rx.recv() => {
if let Some(mut bytes) = pty {
// Coalesce a burst (e.g. `tree`) into one frame: fewer round-trips,
// no flood. Render locally now so the owner sees output instantly.
while let Ok(more) = pty_rx.try_recv() {
bytes.extend_from_slice(&more);
if bytes.len() > 256 * 1024 { break; }
}
if let Some(v) = &mut app.sandbox { v.parser.process(&bytes); }
send_frame(&out_tx, &session.room, json!({"_sbx":"data","b64": STANDARD.encode(&bytes)}));
}
}
_ = sigterm.recv() => { break Ok(()); }
_ = sighup.recv() => { break Ok(()); }
_ = tick.tick() => { app.spin = app.spin.wrapping_add(1); }
}
};
if let Some(mut sb) = broker.take() {
sb.stop();
if let Some((be, name)) = broker_meta.take() {
sbx::teardown(be, &name);
}
}
if let Some(mut child) = agent.take() {
let _ = child.kill();
let _ = child.wait();
}
disable_raw_mode()?;
execute!(
term.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
term.show_cursor()?;
result
}
enum BrokerMsg {
Ready {
sb: sbx::Sandbox,
backend: sbx::Backend,
name: String,
rows: u16,
cols: u16,
},
Failed,
}
/// Owns the websocket write half and drains all outgoing frames off the UI
/// loop. Because `sink.send().await` can block on a backpressured socket (e.g.
/// while the server relays a sandbox PTY stream to a slow remote peer), keeping
/// it here means that stall never starves keyboard input or chat rendering. A
/// reconnect delivers a fresh sink via `sink_rx`; the reader task independently
/// surfaces `Net::Closed`, so a dead sink here just drops frames until then.
async fn writer_task(
mut sink: net::WsSink,
mut out_rx: UnboundedReceiver<WsMsg>,
mut sink_rx: UnboundedReceiver<net::WsSink>,
) {
loop {
tokio::select! {
biased;
// Swap in a reconnect's fresh sink before draining more frames.
new_sink = sink_rx.recv() => {
if let Some(s) = new_sink {
sink = s;
}
}
msg = out_rx.recv() => {
let Some(first) = msg else { return }; // app exiting
// Coalesce a burst (file chunks / PTY relay) into one batch.
let mut batch = vec![first];
while let Ok(m) = out_rx.try_recv() {
batch.push(m);
if batch.len() >= 64 { break; }
}
for m in batch {
if sink.send(m).await.is_err() {
break; // sink dead — wait for a reconnect to replace it
}
}
}
}
}
}
/// Pull up to `cap` *already-ready* items out of `rx` (without awaiting) in FIFO
/// order, appending to `buf`. The UI loop uses this to drain a burst of incoming
/// frames per turn so a high-volume `_sbx:data` stream can't bury chat behind a
/// one-frame-per-redraw cap.
fn drain_ready<T>(rx: &mut UnboundedReceiver<T>, buf: &mut Vec<T>, cap: usize) {
while buf.len() < cap {
match rx.try_recv() {
Ok(m) => buf.push(m),
Err(_) => break, // empty or disconnected — nothing more to take right now
}
}
}
#[allow(clippy::too_many_arguments)]
fn handle_command(
line: &str,
app: &mut App,
theme: &mut Theme,
send_seq: &mut u64,
broker: &mut Option<sbx::Sandbox>,
broker_meta: &mut Option<(sbx::Backend, String)>,
launching: &mut bool,
announced_dims: &mut Option<(u16, u16)>,
out_tx: &UnboundedSender<WsMsg>,
pty_tx: &UnboundedSender<Vec<u8>>,
broker_tx: &UnboundedSender<BrokerMsg>,
app_tx: &UnboundedSender<Net>,
session: &Session,
term: &Terminal<CrosstermBackend<std::io::Stdout>>,
agent: &mut Option<std::process::Child>,
params: &net::ConnParams,
) {
let room = &session.room;
if line == "/help" || line == "/?" {
app.open_help();
} else if line == "/clear" || line == "/cls" {
// Local-only: wipe this client's chat scrollback. Doesn't touch the
// room — other peers keep their own history.
app.lines.clear();
app.chat_scroll = 0;
app.sys("⛧ chat cleared");
} else if line == "/pw" || line == "/password" {
// Show the room password locally (never broadcast). Handy when the
// server's password was autogenerated and you need to read it off / share
// it out-of-band to invite someone into the room.
if app.password.is_empty() {
app.sys("⛧ no room password (joined without one)");
} else {
app.sys(format!("⛧ room password: {}", app.password));
}
} else if let Some(rest) = line.strip_prefix("/theme") {
// Live vestment switch: `/theme <name>`, or bare `/theme` to list options.
let name = rest.trim();
if name.is_empty() {
app.sys(format!(
"vestments: {} · random · save [name] — /theme <name>",
Theme::available().join(" · ")
));
} else if name == "random" {
// Same as Ctrl+Alt+P — roll a fresh procedural vestment.
*theme = Theme::random();
app.sys(format!(
"{} conjured vestment '{}'",
theme.sigil, theme.name
));
} else if name == "save" || name.starts_with("save ") {
// Persist the vestment you're currently wearing (e.g. a `random`
// roll you like) to themes/<slug>.toml so it sticks around. Bare
// `/theme save` reuses the theme's own generated name.
let want = name[4..].trim();
let want = if want.is_empty() {
theme.name.clone()
} else {
want.to_string()
};
match theme.save(&want) {
Ok(slug) => app.sys(format!(
"{} saved vestment '{slug}' — re-don it anytime with /theme {slug}",
theme.sigil
)),
Err(e) => app.err(format!("couldn't save vestment: {e}")),
}
} else {
match Theme::by_name(name) {
Ok(t) => {
*theme = t;
app.sys(format!("donned the {name} vestments"));
}
Err(_) => app.err(format!(
"no theme '{name}' — try: {}",
Theme::available().join(" · ")
)),
}
}
} else if let Some(rest) = line.strip_prefix("/layout") {
// Named layout presets only — live resizing is interactive now (F4
// fullscreen, F5 or click a pane then arrows). `/layout` keeps just the
// memorable verbs for saving/recalling arrangements, plus `reset`.
// Loading a preset clears announced_dims so the run loop re-syncs (and
// broadcasts) the live PTY size on the next tick.
let rest = rest.trim();
let (cmd, arg) = rest
.split_once(char::is_whitespace)
.map(|(c, a)| (c, a.trim()))
.unwrap_or((rest, ""));
let mut resized = false;
match cmd {
"" => {
app.sys(format!("⛧ layout: {}", app.layout.describe()));
app.sys(" resize live: F4 fullscreen · F5 or click a pane, then arrows · Esc done");
app.sys(format!(
" presets: {} — /layout save <name> · load <name> · rm <name>",
once_or_none(Layout::available())
));
}
"reset" | "default" => {
let roster = app.layout.roster_width;
app.layout = Layout::default();
app.layout.roster_width = roster; // keep your roster choice on reset
resized = true;
app.sys(format!("⛧ layout reset — {}", app.layout.describe()));
}
"save" if !arg.is_empty() => match app.layout.save(arg) {
Ok(slug) => app.sys(format!(
"⛧ saved layout '{slug}' — re-apply anytime with /layout load {slug}"
)),
Err(e) => app.err(format!("couldn't save layout: {e}")),
},
"load" | "apply" if !arg.is_empty() => match Layout::by_name(arg) {
Ok(l) => {
app.layout = l;
resized = true;
app.sys(format!("⛧ loaded layout '{arg}' — {}", app.layout.describe()));
}
Err(_) => app.err(format!(
"no saved layout '{arg}' — saved: {}",
once_or_none(Layout::available())
)),
},
"list" | "presets" | "ls" => {
app.sys(format!("⛧ saved layouts: {}", once_or_none(Layout::available())));
}
"rm" | "delete" | "del" if !arg.is_empty() => match Layout::remove(arg) {
Ok(()) => app.sys(format!("⛧ deleted layout '{arg}'")),
Err(e) => app.err(format!("{e}")),
},
// Bare `/layout <name>` → load a saved preset if it exists.
name => match Layout::by_name(name) {
Ok(l) => {
app.layout = l;
resized = true;
app.sys(format!("⛧ loaded layout '{name}' — {}", app.layout.describe()));
}
Err(_) => app.sys(
"usage: /layout [save <name>|load <name>|list|rm <name>|reset] — resize live with F4/F5 or click",
),
},
}
if resized {
*announced_dims = None; // force the run loop to re-sync the PTY size
}
} else if line == "/drive" {
// Mobile-friendly alternative to F2 (no function key needed).
if app.sandbox.is_none() {
app.sys("no sandbox running — /sbx launch first");
} else if app.can_drive() {
app.driving = true;
app.sys("⛧ drive mode ON — type into the shell (Esc reaches vim etc.) · press F2 to release");
} else {
app.sys("you don't have drive permission — the owner can /grant you");
}
} else if let Some(rest) = line.strip_prefix("/sendroom ") {
// Offer a file/dir to the whole room — anyone may /accept.
offer_payload(app, send_seq, out_tx, room, app_tx, rest.trim(), None);
} else if let Some(rest) = line.strip_prefix("/send ") {
// Direct send to one member: `/send <user> <path>`. Everyone receives the
// broadcast offer, but only <user> is prompted to /accept.
let rest = rest.trim();
match rest.split_once(char::is_whitespace) {
Some((who, path)) => {
let (who, path) = (who.trim(), path.trim());
if who == app.me {
app.sys("can't /send to yourself — use /sendroom <path> for everyone");
} else if path.is_empty() {
app.sys("usage: /send <user> <path> · /sendroom <path> for everyone");
} else if !app.users.iter().any(|u| u.username == who) {
let roster = app
.users
.iter()
.map(|u| u.username.as_str())
.filter(|u| *u != app.me)
.collect::<Vec<_>>()
.join(" · ");
app.err(format!("no member '{who}' in the room — try: {roster}"));
} else {
offer_payload(app, send_seq, out_tx, room, app_tx, path, Some(who));
}
}
None => app.sys("usage: /send <user> <path> · /sendroom <path> for everyone"),
}
} else if line == "/accept" {
if let Some(o) = app.pending_offer.take() {
send_frame(out_tx, room, json!({"_ft":"accept","id": o.id}));
if let Some(t) = app.transfers.get_mut(&o.id) {
t.accepted = true;
}
app.sys(format!("accepting {}…", o.name));
} else {
app.sys("no pending offer");
}
} else if line == "/reject" {
if let Some(o) = app.pending_offer.take() {
send_frame(out_tx, room, json!({"_ft":"reject","id": o.id}));
app.transfers.remove(&o.id);
app.sys("rejected the offer");
} else {
app.sys("no pending offer");
}
} else if let Some(rest) = line.strip_prefix("/sbx") {
let mut p = rest.split_whitespace();
match p.next() {
Some("launch") => {
// `--start` (alias `--start-daemon` / `-y`) opts in to booting a
// stopped Docker daemon; everything else is positional. The first
// positional selects the backend: docker | multipass | vbox |
// local. Each runs on the *invoker's* own machine.
let args: Vec<&str> = p.collect();
let start_daemon = args
.iter()
.any(|a| matches!(*a, "--start" | "--start-daemon" | "-y"));
// Consent token: if the selected backend's binary is missing,
// `install` opts in to installing it (detect-then-install). It's
// a positional keyword (not the image), so filter it out below.
let want_install = args.iter().any(|a| matches!(*a, "install" | "--install"));
let mut pos = args
.iter()
.copied()
.filter(|a| !a.starts_with('-') && !matches!(*a, "install"));
let first = pos.next();
if matches!(first, Some("virtualbox") | Some("vbox")) {
// VirtualBox runs locally in its own GUI — host & guest each
// open their own copy, nothing is relayed. It's independent of
// the shared-PTY sandbox, so it bypasses the "already running"
// guard. Grammar: `/sbx launch vbox [gui] <vm> [yes]`; a bare
// `/sbx launch vbox` opens the arrow-navigable VM picker.
let mut rest = pos.peekable();
if rest.peek() == Some(&"new") {
// `/sbx launch vbox new [name]` — build a brand-new VM from
// a cloud image (cloud-init toolchain), not an existing one.
rest.next();
let name = rest.next().unwrap_or("hh-vbox").to_string();
launch_vbox_new(app, name, app.me.clone(), app_tx);
} else {
if rest.peek() == Some(&"gui") {
rest.next(); // optional `gui` keyword (sugar)
}
match rest.next() {
Some(vm) => {
let confirmed = rest
.any(|t| matches!(t, "yes" | "y" | "go" | "confirm" | "ok"));
launch_vbox_gui(app, vm.to_string(), confirmed, app_tx, out_tx, room);
}
None => open_vbox_picker(app),
}
}
} else if app.sandbox.is_some() || broker.is_some() || *launching {
app.sys("a sandbox is already running");
} else {
let backend = first
.and_then(sbx::Backend::parse)
.unwrap_or(sbx::Backend::Local);
let image = pos
.next()
.map(str::to_string)
.unwrap_or_else(|| backend.default_image().to_string());
// Is this backend's binary present? Docker/Multipass can be
// installed on consent; Local needs nothing and vbox is
// handled in its own branch above.
let installed = match backend {
sbx::Backend::Docker => sbx::docker_installed(),
sbx::Backend::Multipass => sbx::multipass_installed(),
_ => true,
};
let token = first.unwrap_or("docker");
if !installed && !want_install {
app.err(format!(
"{} is not installed — retry with `/sbx launch {token} install` to install it (needs sudo)",
backend.label()
));
} else if installed
&& backend == sbx::Backend::Docker
&& !start_daemon
&& !sbx::docker_daemon_up()
{
app.err("docker daemon is not running — retry with `/sbx launch docker --start` to boot it (sudo), or run ./scripts/ensure-docker.sh in a terminal first");
} else {
// install_first ⇒ binary missing + consent given: install
// it off-thread before provisioning (a fresh Docker
// install also leaves its daemon up).
let install_first = !installed;
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
let (rows, cols) = sbx_dims(sz.0, sz.1, app.layout.effective_pty_pct());
*launching = true;
let members: Vec<String> =
app.users.iter().map(|u| u.username.clone()).collect();
if install_first {
app.sys(format!(
"installing {} (needs sudo)… then summoning the sandbox",
backend.label()
));
} else {
app.sys(format!(
"summoning {} sandbox… (provisioning unix users; multipass boot ~30s)",
backend.label()
));
}
spawn_launch(
backend,
image,
app.me.clone(),
members,
rows,
cols,
start_daemon,
install_first,
pty_tx.clone(),
broker_tx.clone(),
app_tx.clone(),
);
}
}
}
Some("stop") => {
if let Some(mut sb) = broker.take() {
sb.stop();
if let Some((be, name)) = broker_meta.take() {
tokio::task::spawn_blocking(move || sbx::teardown(be, &name));
}
*announced_dims = None;
send_frame(out_tx, room, json!({"_sbx":"status","state":"stopped"}));
} else {
app.sys("you are not hosting a sandbox");
}
}
Some("save") => {
// `--local` (alias `-l`) also writes a portable, standalone copy
// under hh-snapshots/ (a docker `.tar`) the saver fully owns; the
// default keeps just the in-backend snapshot. The label is the
// first non-flag arg.
let args: Vec<&str> = p.collect();
let local = args.iter().any(|a| matches!(*a, "--local" | "-l"));
let label = args
.iter()
.copied()
.find(|a| !a.starts_with('-'))
.unwrap_or("snap")
.to_string();
if !is_snap_label(&label) {
app.sys("snapshot label must be alphanumerics, '.', '_' or '-'");
} else if let Some((be, name)) = broker_meta.clone() {
app.sys(format!(
"saving sandbox state as '{label}'{}…",
if local { " (+ local copy)" } else { "" }
));
// Docker commits a *live* container, so its save is non-
// disruptive. Multipass can only snapshot a powered-off
// instance, so saving it necessarily stops the shared shell —
// tear the live session down (same as `/sbx stop`, but WITHOUT
// purging: the instance must survive so the snapshot does too).
if be == sbx::Backend::Multipass {
if let Some(mut sb) = broker.take() {
sb.stop();
}
broker_meta.take();
*announced_dims = None;
send_frame(out_tx, room, json!({"_sbx":"status","state":"stopped"}));
app.sys("multipass must power off to snapshot — stopping the shared shell, then saving (reload it with `/sbx load`)");
}
let (tx, lbl) = (app_tx.clone(), label.clone());
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || sbx::save_state(be, &name, &label, local)).await;
let _ = match res {
Ok(Ok(desc)) => tx.send(Net::Sys(format!(
"⛧ saved sandbox → {desc} · reload with `/sbx load {lbl}`"))),
Ok(Err(e)) => tx.send(Net::Err(format!("save failed: {e}"))),
Err(e) => tx.send(Net::Err(format!("save task: {e}"))),
};
});
} else {
app.sys("only the sandbox host can /sbx save (launch one first)");
}
}
Some("load") => match p.next() {
None => app.sys("usage: /sbx load <label> (a docker or multipass snapshot saved via /sbx save)"),
Some(label) if !is_snap_label(label) => {
app.sys("snapshot label must be alphanumerics, '.', '_' or '-'");
}
Some(label) => {
if app.sandbox.is_some() || broker.is_some() || *launching {
app.sys("stop the current sandbox first (`/sbx stop`) before loading a snapshot");
} else {
// A bare label is backend-ambiguous, so probe which backend
// actually holds the snapshot, then restore through it:
// docker reruns a committed image; multipass restores the
// (stopped, preserved) instance and re-attaches its shell.
let label = label.to_string();
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
let (rows, cols) = sbx_dims(sz.0, sz.1, app.layout.effective_pty_pct());
*launching = true;
let members: Vec<String> =
app.users.iter().map(|u| u.username.clone()).collect();
let owner = app.me.clone();
app.sys(format!("loading snapshot '{label}'…"));
let (pty, btx, atx) =
(pty_tx.clone(), broker_tx.clone(), app_tx.clone());
tokio::spawn(async move {
let (n, lbl) = (SBX_NAME.to_string(), label.clone());
let kind = tokio::task::spawn_blocking(move || {
sbx::locate_snapshot(&n, &lbl)
})
.await
.unwrap_or(sbx::SnapKind::None);
match kind {
sbx::SnapKind::Docker => {
if !sbx::docker_daemon_up() {
let _ = atx.send(Net::Err("docker daemon is not running — `/sbx launch docker --start` once to boot it, then retry".into()));
let _ = btx.send(BrokerMsg::Failed);
return;
}
let image = format!("{}:{}", sbx::SNAP_REPO, label);
let _ = atx.send(Net::Sys(format!("loading docker sandbox from {image}…")));
spawn_launch(
sbx::Backend::Docker, image, owner, members, rows,
cols, false, false, pty, btx, atx,
);
}
sbx::SnapKind::Multipass => {
let lbl = label.clone();
let res = tokio::task::spawn_blocking(move || {
sbx::mp_restore(SBX_NAME, &lbl)
})
.await;
match res {
Ok(Ok(desc)) => {
let _ = atx.send(Net::Sys(format!("⛧ {desc} · booting…")));
spawn_launch(
sbx::Backend::Multipass,
sbx::Backend::Multipass.default_image().to_string(),
owner, members, rows, cols, false, false, pty, btx, atx,
);
}
Ok(Err(e)) => {
let _ = atx.send(Net::Err(format!("load failed: {e}")));
let _ = btx.send(BrokerMsg::Failed);
}
Err(e) => {
let _ = atx.send(Net::Err(format!("load task: {e}")));
let _ = btx.send(BrokerMsg::Failed);
}
}
}
sbx::SnapKind::None => {
let _ = atx.send(Net::Err(format!("no saved snapshot '{label}' — list with `/sbx snaps`")));
let _ = btx.send(BrokerMsg::Failed);
}
}
});
}
}
},
Some("snaps") | Some("snapshots") => {
let be = broker_meta
.as_ref()
.map(|(b, _)| *b)
.unwrap_or(sbx::Backend::Docker);
let (tx, name) = (app_tx.clone(), SBX_NAME.to_string());
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || sbx::list_snapshots(be, &name)).await;
let _ = match res {
Ok(Ok(v)) if !v.is_empty() => {
tx.send(Net::Sys(format!("saved snapshots: {}", v.join(", "))))
}
Ok(Ok(_)) => tx.send(Net::Sys(
"no saved snapshots yet — `/sbx save [label]` to make one".into())),
Ok(Err(e)) => tx.send(Net::Err(format!("snaps: {e}"))),
Err(e) => tx.send(Net::Err(format!("snaps task: {e}"))),
};
});
}
Some("vms") => {
let tx = app_tx.clone();
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(|| {
let ver = sbx::vbox_version().ok_or_else(|| {
"VirtualBox isn't installed — install it with `/sbx gui <vm> --install`, or run ./scripts/ensure-vbox.sh".to_string()
})?;
let vms = sbx::list_vms().map_err(|e| e.to_string())?;
Ok::<_, String>((ver, vms))
})
.await;
let _ = match res {
Ok(Ok((ver, v))) if !v.is_empty() => tx.send(Net::Sys(format!(
"VirtualBox {ver} detected · VMs: {}",
v.join(", ")
))),
Ok(Ok((ver, _))) => tx.send(Net::Sys(format!(
"VirtualBox {ver} detected · no VMs registered"
))),
Ok(Err(e)) => tx.send(Net::Err(e)),
Err(e) => tx.send(Net::Err(format!("vms task: {e}"))),
};
});
}
Some("vmsave") => {
// Snapshot a local VirtualBox VM. `--local` (alias `-l`) also
// exports a portable `.ova` appliance under hh-snapshots/.
let args: Vec<&str> = p.collect();
let local = args.iter().any(|a| matches!(*a, "--local" | "-l"));
let mut pos = args.iter().copied().filter(|a| !a.starts_with('-'));
match pos.next() {
None => app.sys(
"usage: /sbx vmsave <vm> [label] [--local] (snapshot a VirtualBox VM; list VMs with /sbx vms)",
),
Some(vm) => {
let label = pos.next().unwrap_or("snap").to_string();
if !is_snap_label(&label) {
app.sys("snapshot label must be alphanumerics, '.', '_' or '-'");
} else {
app.sys(format!(
"snapshotting VM '{vm}' as '{label}'{}…",
if local { " (+ local .ova)" } else { "" }
));
let (tx, vm) = (app_tx.clone(), vm.to_string());
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || {
sbx::vm_save_state(&vm, &label, local)
})
.await;
let _ = match res {
Ok(Ok(desc)) => tx.send(Net::Sys(format!("⛧ saved VM → {desc}"))),
Ok(Err(e)) => tx.send(Net::Err(format!("vmsave failed: {e}"))),
Err(e) => tx.send(Net::Err(format!("vmsave task: {e}"))),
};
});
}
}
}
}
Some("vmsnaps") => {
match p.next() {
None => app.sys("usage: /sbx vmsnaps <vm> (list a VirtualBox VM's snapshots)"),
Some(vm) => {
let (tx, vm) = (app_tx.clone(), vm.to_string());
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || sbx::vm_snapshots(&vm)).await;
let _ = match res {
Ok(Ok(v)) if !v.is_empty() => {
tx.send(Net::Sys(format!("VM snapshots: {}", v.join(", "))))
}
Ok(Ok(_)) => tx.send(Net::Sys(
"no VM snapshots yet — `/sbx vmsave <vm> [label]` to make one".into())),
Ok(Err(e)) => tx.send(Net::Err(format!("vmsnaps: {e}"))),
Err(e) => tx.send(Net::Err(format!("vmsnaps task: {e}"))),
};
});
}
}
}
Some("vmload") => {
// Restore a VirtualBox VM to a snapshot (inverse of /sbx vmsave)
// then boot its GUI locally. `<label>` picks a named snapshot;
// omit it to restore the VM's current snapshot.
let mut vpos = p.filter(|a| !a.starts_with('-'));
match vpos.next() {
None => app.sys(
"usage: /sbx vmload <vm> [label] (restore a VirtualBox snapshot + boot; list with /sbx vmsnaps <vm>)",
),
Some(vm) => {
let label = vpos.next().map(str::to_string);
if label.as_deref().is_some_and(|l| !is_snap_label(l)) {
app.sys("snapshot label must be alphanumerics, '.', '_' or '-'");
} else {
app.sys(format!(
"restoring VM '{vm}'{} then booting…",
label
.as_deref()
.map(|l| format!(" to '{l}'"))
.unwrap_or_default()
));
let (tx, vm) = (app_tx.clone(), vm.to_string());
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || {
let desc = sbx::vm_restore(&vm, label.as_deref())?;
let boot = sbx::gui_launch(&vm)?;
Ok::<_, anyhow::Error>(format!("{desc} · {boot}"))
})
.await;
let _ = match res {
Ok(Ok(desc)) => tx.send(Net::Sys(format!("⛧ {desc}"))),
Ok(Err(e)) => tx.send(Net::Err(format!("vmload failed: {e}"))),
Err(e) => tx.send(Net::Err(format!("vmload task: {e}"))),
};
});
}
}
}
}
Some("gui") => {
// Convenience alias for `/sbx launch vbox gui <vm> [yes]` — opens a
// local VirtualBox VM's GUI on your own machine. Bare `/sbx gui`
// opens the VM picker, same as `/sbx launch vbox`.
let mut gpos = p.filter(|a| !a.starts_with('-'));
match gpos.next() {
Some(vm) => {
let confirmed =
gpos.any(|t| matches!(t, "yes" | "y" | "go" | "confirm" | "ok"));
launch_vbox_gui(app, vm.to_string(), confirmed, app_tx, out_tx, room);
}
None => open_vbox_picker(app),
}
}
_ => app.sys(
"usage: /sbx launch vbox [gui] <vm> [yes] (host opens directly; non-host appends yes) · /sbx launch vbox new [name] (fresh VM) · /sbx gui <vm> alias · launch <docker|multipass> [image] (or local) · vms · stop · save [label] [--local] · load <label> · snaps · vmsave <vm> [label] [--local] · vmload <vm> [label] · vmsnaps <vm>",
),
}
} else if let Some(rest) = line.strip_prefix("/unsudo") {
let target = rest.trim();
if !app.is_owner() {
app.sys("only the owner can /unsudo");
} else if target.is_empty() {
app.sys("usage: /unsudo <user>");
} else if let Some((be, name)) = broker_meta.clone() {
app.sudoers.remove(target);
let (t, n) = (target.to_string(), name);
tokio::task::spawn_blocking(move || sbx::set_sudo(be, &n, &t, false));
broadcast_acl(out_tx, room, app);
app.sys(format!("revoked sudo from {target} in the VM"));
} else {
app.sys("no sandbox running");
}
} else if let Some(rest) = line.strip_prefix("/sudo") {
let target = rest.trim();
if !app.is_owner() {
app.sys("only the owner can delegate sudo");
} else if target.is_empty() {
app.sys("usage: /sudo <user> (delegate VM superuser) | /unsudo <user>");
} else if let Some((be, name)) = broker_meta.clone() {
app.sudoers.insert(target.to_string());
let (t, n) = (target.to_string(), name);
tokio::task::spawn_blocking(move || sbx::set_sudo(be, &n, &t, true));
broadcast_acl(out_tx, room, app);
app.sys(format!("delegated VM superuser (sudo) to {target}"));
} else {
app.sys("no sandbox running");
}
} else if let Some(rest) = line.strip_prefix("/grant") {
let target = rest.trim();
if !app.is_owner() {
app.sys("only the sandbox owner can /grant");
} else if target.is_empty() {
app.sys("usage: /grant <user>");
} else {
app.drivers.insert(target.to_string());
broadcast_acl(out_tx, room, app);
app.sys(format!("granted drive to {target}"));
}
} else if let Some(rest) = line.strip_prefix("/revoke") {
let target = rest.trim();
if !app.is_owner() {
app.sys("only the sandbox owner can /revoke");
} else if target == app.me {
app.sys("the owner cannot revoke themselves");
} else if target.is_empty() {
app.sys("usage: /revoke <user>");
} else {
app.drivers.remove(target);
broadcast_acl(out_tx, room, app);
app.sys(format!("revoked drive from {target}"));
}
} else if line == "/ai stop" {
// Reap a child that already exited (e.g. failed auth) so the message is honest.
if agent
.as_mut()
.is_some_and(|c| matches!(c.try_wait(), Ok(Some(_))))
{
*agent = None;
}
if let Some(mut child) = agent.take() {
let _ = child.kill();
let _ = child.wait();
app.sys("⛧ dismissed the AI agent");
// Drop any sandbox drive the agent held so a dead handle can't act.
app.agent_sbx_allow = false;
let revoked = app
.agent_name
.take()
.is_some_and(|n| app.drivers.remove(&n) | app.sudoers.remove(&n));
if revoked && app.sandbox.is_some() {
broadcast_acl(out_tx, room, app);
}
} else {
app.sys("no AI agent was started from this client");
}
} else if let Some(rest) = line
.strip_prefix("/ai start")
.filter(|r| r.is_empty() || r.starts_with(' '))
{
// Drop a handle to an agent that has already exited so we can restart.
if agent
.as_mut()
.is_some_and(|c| matches!(c.try_wait(), Ok(Some(_))))
{
*agent = None;
}
if agent.is_some() {
app.sys("an AI agent is already running from this client — /ai stop first");
} else {
// A trailing `allow` flag auto-grants the agent sandbox drive on launch.
let raw = rest.trim();
let (raw, grant_sbx) = match raw.strip_suffix("allow") {
Some(head) if head.is_empty() || head.ends_with(' ') => (head.trim(), true),
_ => (raw, false),
};
// A bare name (no ':' tag, no '/' path) is a models.toml profile;
// anything else is treated as a literal Ollama model tag.
let (profile, model): (Option<&str>, &str) = if raw.is_empty() {
(None, "qwen2.5:3b")
} else if raw.contains(':') || raw.contains('/') {
(None, raw)
} else {
(Some(raw), raw)
};
// Name the agent after what it runs, for clarity in the roster: a
// profile keeps its label; a direct Ollama model uses its tag
// (e.g. "qwen2.5:3b" — model name + parameter size).
let name = profile.unwrap_or(model);
match spawn_agent(params, &app.password, name, profile, model) {
Ok(child) => {
*agent = Some(child);
app.agent_name = Some(name.to_string());
app.agent_sbx_allow = grant_sbx;
let desc = match profile {
Some(p) => format!("profile {p}"),
None => format!("ollama/{model}"),
};
app.sys(format!(
"⛧ summoning {name} ({desc})… it will announce when online"
));
if grant_sbx {
// Grant now if a sandbox is already running; otherwise the
// Ready handler applies it when one launches.
if app.sandbox.is_some() && app.owner.as_deref() == Some(app.me.as_str()) {
app.drivers.insert(name.to_string());
broadcast_acl(out_tx, room, app);
}
app.sys(format!(
"⛧ {name} will get sandbox drive — Ctrl-X kills all drive in a pinch"
));
}
}
Err(e) => app.err(format!("/ai start failed: {e}")),
}
}
} else if line == "/ai list" || line == "/ai models" {
// Reap an agent that already exited so we don't forward into a dead pipe.
if agent
.as_mut()
.is_some_and(|c| matches!(c.try_wait(), Ok(Some(_))))
{
*agent = None;
}
if agent.is_some() {
// A live agent answers these itself (canned, zero model-call).
let _ = out_tx.send(WsMsg::Text(room.encrypt(line.as_bytes())));
} else if line == "/ai list" {
app.sys("no AI agent running from this client — /ai start to summon one");
} else {
// No agent: still useful to show what could be started locally.
app.sys("querying local ollama…");
let tx = app_tx.clone();
tokio::task::spawn_blocking(move || {
let msg = match local_ollama_models() {
Ok(ms) if !ms.is_empty() => format!(
"local ollama models (start one with `/ai start <name>`): {}",
ms.join(", ")
),
Ok(_) => "ollama is reachable but has no models pulled — \
`ollama pull qwen2.5:3b` or run ./scripts/bootstrap-ai.sh"
.to_string(),
Err(_) => "ollama not reachable at localhost:11434 — run \
./scripts/bootstrap-ai.sh, or `/ai start <profile>` for a cloud model"
.to_string(),
};
let _ = tx.send(Net::Sys(msg));
});
}
} else if !line.is_empty() && app.connected {
let _ = out_tx.send(WsMsg::Text(room.encrypt(line.as_bytes())));
}
}
/// Probe the local Ollama daemon for installed model tags. Used to answer
/// `/ai models` before any agent is summoned (the agentless path); a running
/// agent answers in-room instead. Honors `$OLLAMA_HOST`.
fn local_ollama_models() -> Result<Vec<String>, String> {
let host = std::env::var("OLLAMA_HOST")
.ok()
.filter(|h| !h.is_empty())
.unwrap_or_else(|| "http://localhost:11434".to_string());
let host = host.trim_end_matches('/');
let url = format!("{host}/api/tags");
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_millis(1500))
.build()
.map_err(|e| e.to_string())?;
let body: serde_json::Value = client
.get(&url)
.send()
.map_err(|e| e.to_string())?
.json()
.map_err(|e| e.to_string())?;
let models = body
.get("models")
.and_then(|m| m.as_array())
.map(|arr| {
arr.iter()
.filter_map(|m| m.get("name").and_then(|n| n.as_str()))
.map(str::to_string)
.collect()
})
.unwrap_or_default();
Ok(models)
}
/// A safe snapshot label — compatible with both a Docker image tag and a
/// multipass snapshot name (alphanumerics plus `.`, `_`, `-`).
fn is_snap_label(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 64
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
}
/// Find a local VirtualBox appliance (`.ova`/`.ovf`) for `vm` so a non-host can
/// import ("pull") it. Looks in `hh-snapshots/` (where `/sbx vmsave --local`
/// writes exports) and `./downloads/` (where a /sent appliance lands). Matches
/// on the file stem equalling, prefixing (`<vm>-…`), or containing the VM name.
fn find_local_appliance(vm: &str) -> Option<std::path::PathBuf> {
let dirs = [
std::path::PathBuf::from(sbx::SNAP_DIR),
std::path::PathBuf::from("./downloads"),
];
for dir in dirs {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for e in entries.flatten() {
let path = e.path();
let ext_ok = path
.extension()
.and_then(|s| s.to_str())
.map(|s| s.eq_ignore_ascii_case("ova") || s.eq_ignore_ascii_case("ovf"))
.unwrap_or(false);
let name_match = path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s == vm || s.starts_with(&format!("{vm}-")) || s.contains(vm))
.unwrap_or(false);
if ext_ok && name_match {
return Some(path);
}
}
}
None
}
/// Open the arrow-navigable VM picker (populated synchronously — a single
/// `VBoxManage list vms`, same blocking style as the other vbox probes here).
/// Empty/uninstalled cases steer the user instead of opening an empty list.
fn open_vbox_picker(app: &mut App) {
if !sbx::vbox_installed() {
app.sys("VirtualBox isn't installed — `/sbx launch vbox gui <vm> yes` installs it, or run ./scripts/ensure-vbox.sh");
return;
}
match sbx::list_vms() {
Ok(vms) if !vms.is_empty() => {
app.vbox_picker = Some(VboxPicker { vms, selected: 0 });
app.sys("⛧ pick a VM — ↑/↓ move · Enter/Tab choose · Esc dismiss");
}
Ok(_) => app.sys(
"no VirtualBox VMs registered — a host can /send you a .ova, then `/sbx launch vbox gui <vm> yes` to import it",
),
Err(e) => app.err(format!("listing VMs: {e}")),
}
}
/// Launch (or pull-then-launch) a local VirtualBox VM's GUI on the caller's OWN
/// machine — nothing is relayed; every member opens their own copy. Shared by
/// `/sbx launch vbox gui <vm>` and the `/sbx gui <vm>` alias.
///
/// Frictionless for a "host" who already has VirtualBox AND the VM imported with
/// no other VM holding VT-x: it just boots. A non-host missing VirtualBox or the
/// VM image — or who'd have to stop another VM first — must re-issue with a
/// trailing `yes`, which then installs VirtualBox, imports the shared appliance,
/// and/or frees VT-x before booting.
/// Build + boot a brand-new VirtualBox VM from a cloud image (the only path that
/// creates a VM from scratch — `launch_vbox_gui` opens existing ones). The heavy
/// lifting (image download, cloud-init seed, VBox create/boot) is in
/// scripts/vbox-new.sh; we run it off-thread and report through `app_tx` so the
/// first-run ~600MB download never stalls the TUI.
fn launch_vbox_new(app: &mut App, name: String, user: String, app_tx: &UnboundedSender<Net>) {
if !is_snap_label(&name) {
app.sys("VM name must be alphanumerics, '.', '_' or '-'");
return;
}
if !sbx::vbox_installed() {
app.sys("VirtualBox isn't installed — `/sbx gui <vm> --install` or run ./scripts/ensure-vbox.sh first");
return;
}
if sbx::vm_registered(&name) {
app.sys(format!(
"a VM named {name} already exists — pick another name, or boot it with `/sbx gui {name}`"
));
return;
}
app.sys(format!(
"building fresh VirtualBox VM {name} (first run downloads a ~600MB Ubuntu cloud image; cloud-init installs the toolchain on boot)…"
));
let tx = app_tx.clone();
tokio::spawn(async move {
let (n, u) = (name.clone(), user);
let res = tokio::task::spawn_blocking(move || sbx::vbox_new(&n, &u)).await;
let _ = match res {
Ok(Ok(desc)) => tx.send(Net::Sys(format!("⛧ {desc}"))),
Ok(Err(e)) => tx.send(Net::Err(format!("vbox new failed: {e}"))),
Err(e) => tx.send(Net::Err(format!("vbox new task: {e}"))),
};
});
}
fn launch_vbox_gui(
app: &mut App,
vm: String,
confirmed: bool,
app_tx: &UnboundedSender<Net>,
out_tx: &UnboundedSender<WsMsg>,
room: &Arc<fernet::Fernet>,
) {
let installed = sbx::vbox_installed();
if installed && sbx::vm_running(&vm) {
app.sys(format!(
"{vm} is already running — look for its window on your desktop"
));
return;
}
let have_vm = installed && sbx::vm_registered(&vm);
let conflicts = sbx::vtx_holders();
let needs_install = !installed;
let needs_import = !have_vm;
// Host path: VirtualBox + this VM already present and nothing to stop → boot
// immediately, no confirmation.
if installed && have_vm && conflicts.is_empty() {
spawn_vm_execute(vm, conflicts, false, false, app, app_tx, out_tx, room);
return;
}
// Otherwise a side effect is required (install / import / stop another VM).
// The non-host opts in with a trailing `yes`.
if !confirmed {
let mut steps: Vec<String> = Vec::new();
if needs_install {
steps.push("install VirtualBox (needs sudo)".to_string());
}
if needs_import {
match find_local_appliance(&vm) {
Some(p) => steps.push(format!("import {}", p.display())),
None => {
app.sys(format!(
"no local appliance for {vm} yet — have the host export it (`/sbx vmsave {vm} --local`) and /send you the .ova, then `/sbx launch vbox gui {vm} yes`"
));
return;
}
}
}
if !conflicts.is_empty() {
steps.push(format!("stop {} other VM(s) holding VT-x", conflicts.len()));
}
app.sys(format!(
"opening {vm} on YOUR machine will {}. → `/sbx launch vbox gui {vm} yes` to proceed",
steps.join(", ")
));
return;
}
// Confirmed. Refuse VT-x holders we can't cleanly restart rather than kill them.
if let Some(bad) = conflicts.iter().find(|h| !h.stoppable) {
app.err(format!(
"can't free VT-x automatically — {} is running and I won't kill it. Stop it yourself, then retry `/sbx launch vbox gui {vm} yes`.",
bad.label
));
return;
}
spawn_vm_execute(vm, conflicts, needs_install, needs_import, app, app_tx, out_tx, room);
}
/// Final step of the gauntlet: off-thread, stop any VT-x holders (reversibly),
/// install VirtualBox if needed, then boot the VM's GUI. Reports through the
/// app channel so the blocking work never stalls the TUI. On success it also
/// broadcasts a `_sbx:vm` frame so the *other* party sees the shared appliance
/// go live and can `/sbx gui <vm>` their own local copy — anyone in the room may
/// do so (the per-client consent gauntlet *is* the client's permission; this is
/// deliberately not owner-gated, unlike `/sbx save`).
#[allow(clippy::too_many_arguments)] // launch context: vm + flags + 3 channels
fn spawn_vm_execute(
vm: String,
conflicts: Vec<sbx::VtxHolder>,
needs_install: bool,
needs_import: bool,
app: &mut App,
app_tx: &UnboundedSender<Net>,
out_tx: &UnboundedSender<WsMsg>,
room: &Arc<fernet::Fernet>,
) {
// Resolve the appliance path now (on the app thread) so the blocking task
// doesn't have to re-scan; None is fine unless an import actually proves
// necessary inside the task.
let appliance = if needs_import {
find_local_appliance(&vm)
} else {
None
};
if conflicts.is_empty() {
app.sys(format!("launching {vm}’…"));
} else {
app.sys(format!(
"freeing VT-x ({} VM(s)) then launching {vm}’…",
conflicts.len()
));
}
let tx = app_tx.clone();
let out = out_tx.clone();
let room = room.clone();
let announce_vm = vm.clone();
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || {
for h in &conflicts {
sbx::stop_vtx_holder(h).map_err(|e| format!("freeing VT-x: {e}"))?;
}
if needs_install && !sbx::vbox_installed() {
sbx::ensure_vbox_install().map_err(|e| format!("install failed: {e}"))?;
}
// Pull the shared appliance in if the VM still isn't registered.
if needs_import && !sbx::vm_registered(&vm) {
let ova = appliance.ok_or_else(|| {
format!("no local .ova appliance found for {vm} to import")
})?;
sbx::import_appliance(&ova).map_err(|e| format!("import failed: {e}"))?;
}
sbx::gui_launch(&vm).map_err(|e| e.to_string())
})
.await;
let _ = match res {
Ok(Ok(desc)) => {
// Tell the room the shared VM is live (others can open their own).
let frame = json!({"_sbx": "vm", "vm": announce_vm});
let _ = out.send(WsMsg::Text(room.encrypt(frame.to_string().as_bytes())));
tx.send(Net::Sys(format!("⛧ {desc}")))
}
Ok(Err(e)) => tx.send(Net::Err(e)),
Err(e) => tx.send(Net::Err(format!("gui task: {e}"))),
};
});
}
#[allow(clippy::too_many_arguments)]
fn spawn_launch(
backend: sbx::Backend,
image: String,
owner: String,
members: Vec<String>,
rows: u16,
cols: u16,
start_daemon: bool,
install_first: bool,
pty_tx: UnboundedSender<Vec<u8>>,
broker_tx: UnboundedSender<BrokerMsg>,
app_tx: UnboundedSender<Net>,
) {
tokio::spawn(async move {
// Optional install step: the backend's binary was missing and the user
// opted in. Runs before provisioning; failure aborts the launch (and
// clears the *launching guard via BrokerMsg::Failed).
if install_first {
let res = tokio::task::spawn_blocking(move || match backend {
sbx::Backend::Docker => sbx::ensure_docker_install(),
sbx::Backend::Multipass => sbx::ensure_multipass_install(),
_ => Ok(()),
})
.await;
if let Err(e) = res.unwrap_or_else(|e| Err(anyhow::anyhow!("join: {e}"))) {
let _ = app_tx.send(Net::Err(format!("install failed: {e}")));
let _ = broker_tx.send(BrokerMsg::Failed);
return;
}
}
let name = SBX_NAME.to_string();
let prep = {
let (n, img) = (name.clone(), image.clone());
tokio::task::spawn_blocking(move || sbx::prepare(backend, &n, &img, start_daemon)).await
};
if let Err(e) = prep.unwrap_or_else(|e| Err(anyhow::anyhow!("join: {e}"))) {
let _ = app_tx.send(Net::Err(format!("sandbox prepare failed: {e}")));
let _ = broker_tx.send(BrokerMsg::Failed);
return;
}
// Provision real unix accounts (owner = sudoer) → the shell's run-user.
let run_user = {
let (n, o, ms) = (name.clone(), owner.clone(), members.clone());
tokio::task::spawn_blocking(move || sbx::provision(backend, &n, &o, &ms))
.await
.unwrap_or_default()
};
let (std_tx, std_rx) = std::sync::mpsc::channel::<Vec<u8>>();
match sbx::Sandbox::launch(backend, &name, &run_user, rows, cols, std_tx) {
Ok(sb) => {
std::thread::spawn(move || {
while let Ok(b) = std_rx.recv() {
if pty_tx.send(b).is_err() {
break;
}
}
});
let _ = broker_tx.send(BrokerMsg::Ready {
sb,
backend,
name,
rows,
cols,
});
}
Err(e) => {
let _ = app_tx.send(Net::Err(format!("sandbox launch failed: {e}")));
let _ = broker_tx.send(BrokerMsg::Failed);
}
}
});
}
/// Locate the repo root (the dir containing `cmd_chat/agent/`) by walking up from
/// the executable's path and the current directory — so `/ai start` finds the
/// Python agent whether the client runs from the checkout or its `target/` dir.
fn find_repo_root() -> Option<std::path::PathBuf> {
let mut starts: Vec<std::path::PathBuf> = Vec::new();
if let Ok(exe) = std::env::current_exe() {
starts.push(exe);
}
if let Ok(cwd) = std::env::current_dir() {
starts.push(cwd);
}
for start in starts {
let mut dir: Option<&std::path::Path> = Some(start.as_path());
while let Some(d) = dir {
if d.join("cmd_chat").join("agent").is_dir() {
return Some(d.to_path_buf());
}
dir = d.parent();
}
}
None
}
/// Spawn the Python AI agent as a child process that joins this room as a normal
/// encrypted client (same SRP + room password). Returns the child handle so
/// `/ai stop` (and client quit) can kill it. The agent's stdout/stderr go to a
/// log file in the temp dir so its prints never corrupt the TUI.
fn spawn_agent(
params: &net::ConnParams,
password: &str,
name: &str,
profile: Option<&str>,
model: &str,
) -> std::result::Result<std::process::Child, String> {
use std::process::{Command, Stdio};
let root = find_repo_root().ok_or_else(|| {
"can't locate the repo (cmd_chat/) — run the client from the checkout".to_string()
})?;
// Prefer the project venv's interpreter; fall back to HH_AI_PYTHON or python3.
let venv_py = root.join(".venv/bin/python");
let program = if venv_py.is_file() {
venv_py
} else {
std::path::PathBuf::from(std::env::var("HH_AI_PYTHON").unwrap_or_else(|_| "python3".into()))
};
let log_path = std::env::temp_dir().join(format!("hh-agent-{name}.log"));
let log = std::fs::File::create(&log_path)
.map_err(|e| format!("agent log {}: {e}", log_path.display()))?;
let log_err = log.try_clone().map_err(|e| e.to_string())?;
let mut cmd = Command::new(&program);
cmd.current_dir(&root)
.arg("-m")
.arg("cmd_chat.agent")
.arg(&params.ip)
.arg(params.port.to_string())
.arg("--name")
.arg(name);
// A profile carries its own provider/model/endpoint from models.toml;
// otherwise summon a local Ollama model by tag.
match profile {
Some(p) => {
cmd.arg("--profile").arg(p);
}
None => {
cmd.arg("--provider")
.arg("ollama")
.arg("--model")
.arg(model);
}
}
cmd.stdin(Stdio::null())
.stdout(Stdio::from(log))
.stderr(Stdio::from(log_err));
if !password.is_empty() {
cmd.arg("--password").arg(password);
}
if params.no_tls {
cmd.arg("--no-tls");
}
if params.insecure {
cmd.arg("--insecure");
}
cmd.spawn()
.map_err(|e| format!("could not start agent ({}): {e}", program.display()))
}
#[cfg(test)]
mod tests {
use super::{drain_ready, App, Net, Role, User, VboxPicker};
use tokio::sync::mpsc::unbounded_channel;
/// The picker highlights the first VM and wraps a clamped selection; this is
/// the pure state the key handler drives with ↑/↓.
#[test]
fn vbox_picker_selection_is_clamped() {
let mut app = App::new("alice".into());
app.vbox_picker = Some(VboxPicker {
vms: vec!["WinLab".into(), "KaliBox".into()],
selected: 0,
});
let p = app.vbox_picker.as_mut().unwrap();
// moving up from the top stays at 0 (saturating)
p.selected = p.selected.saturating_sub(1);
assert_eq!(p.selected, 0);
// moving down is capped at the last index
p.selected = (p.selected + 1).min(p.vms.len() - 1);
p.selected = (p.selected + 1).min(p.vms.len() - 1);
assert_eq!(p.selected, 1, "selection must not run past the last VM");
}
/// A `_sbx:vm` broadcast from ANOTHER member surfaces a "open your own copy"
/// notice to the room.
#[test]
fn vm_opened_from_peer_notifies_the_room() {
let mut app = App::new("alice".into());
app.apply(Net::VmOpened {
by: "bob".into(),
vm: "WinLab".into(),
});
let last = &app.lines.last().unwrap().text;
assert!(last.contains("bob") && last.contains("WinLab"));
}
/// ...but our OWN echo is skipped — we already printed the local "launched"
/// line, so we must not double-report it.
#[test]
fn vm_opened_self_echo_is_skipped() {
let mut app = App::new("alice".into());
let before = app.lines.len();
app.apply(Net::VmOpened {
by: "alice".into(),
vm: "WinLab".into(),
});
assert_eq!(app.lines.len(), before, "self-echo must not add a line");
}
/// `drain_ready` pulls a bounded burst in FIFO order and stops at the cap.
#[tokio::test]
async fn drain_ready_is_fifo_and_capped() {
let (tx, mut rx) = unbounded_channel::<u32>();
for i in 0..1000 {
tx.send(i).unwrap();
}
// Mimic the loop: one awaited frame, then drain the ready burst.
let first = rx.recv().await.unwrap();
let mut buf = vec![first];
drain_ready(&mut rx, &mut buf, 256);
assert_eq!(buf.len(), 256, "burst must be capped");
assert_eq!(buf, (0..256).collect::<Vec<_>>(), "burst must stay FIFO");
}
/// An empty channel leaves the buffer untouched (no spurious items, no hang).
#[tokio::test]
async fn drain_ready_on_empty_is_a_noop() {
let (tx, mut rx) = unbounded_channel::<u32>();
tx.send(7).unwrap();
let first = rx.recv().await.unwrap();
let mut buf = vec![first];
drain_ready(&mut rx, &mut buf, 256);
assert_eq!(buf, vec![7]);
}
/// Regression for the "starting a sandbox stalls chat" bug: chat and a flood of
/// `_sbx:data` frames share one channel. Handling one frame per redraw would let
/// chat fall ~800 turns behind; batch draining must surface it within
/// ceil(801 / 256) = 4 turns no matter how hard the shell is scrolling.
#[tokio::test]
async fn chat_surfaces_promptly_under_sbx_flood() {
let (tx, mut rx) = unbounded_channel::<&'static str>();
for _ in 0..800 {
tx.send("sbx").unwrap();
}
tx.send("CHAT").unwrap();
for _ in 0..800 {
tx.send("sbx").unwrap();
}
let mut turns = 0usize;
let mut saw_chat = false;
while let Ok(first) = rx.try_recv() {
let mut burst = vec![first];
drain_ready(&mut rx, &mut burst, 256);
turns += 1;
if burst.contains(&"CHAT") {
saw_chat = true;
break;
}
}
assert!(saw_chat, "chat frame must be observed");
assert!(
turns <= 4,
"chat took {turns} turns to surface (expected <= 4)"
);
}
fn user(name: &str) -> User {
User {
user_id: format!("id-{name}"),
username: name.into(),
}
}
/// The host is the first occupant of the roster, and an empty roster has no
/// host — so a freshly-built app (no users yet) confers the title on nobody.
#[test]
fn host_is_first_roster_member() {
let mut app = App::new("alice".into());
assert_eq!(app.host(), None, "empty roster has no host");
app.users = vec![user("alice"), user("bob")];
assert_eq!(app.host(), Some("alice"));
// Host follows roster order, not who `me` is.
app.users = vec![user("bob"), user("alice")];
assert_eq!(app.host(), Some("bob"));
}
/// The host wears the Host badge with zero sandbox activity — Option A: the
/// title shows the moment they're in the room, not only after a launch.
#[test]
fn host_badge_shows_without_a_sandbox() {
let mut app = App::new("alice".into());
app.users = vec![user("alice"), user("bob")];
assert_eq!(app.roles_of("alice"), vec![Role::Host]);
assert_eq!(app.roles_of("bob"), vec![Role::Member]);
}
/// Badges stack: a host who summoned a sandbox (sudoer) and can drive holds
/// all three at once, in paint order Host → Sudoer → Driver.
#[test]
fn roles_stack_additively() {
let mut app = App::new("alice".into());
app.users = vec![user("alice"), user("bob")];
app.sudoers.insert("alice".into());
app.drivers.insert("alice".into());
assert_eq!(
app.roles_of("alice"),
vec![Role::Host, Role::Sudoer, Role::Driver]
);
}
/// A non-host can hold powers independently of the host title: bob granted
/// drive shows Driver only — never Host, and not a bare Member.
#[test]
fn non_host_powers_are_independent_of_the_title() {
let mut app = App::new("alice".into());
app.users = vec![user("alice"), user("bob")];
app.drivers.insert("bob".into());
assert_eq!(app.roles_of("bob"), vec![Role::Driver]);
assert_eq!(app.roles_of("alice"), vec![Role::Host]);
}
}