bosun-tmux 2.0.5

Tmux-native orchestrator for AI agent sessions
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
//! Embedded terminal preview for the focused session.
//!
//! Owns a `tmux attach -r -t <session>` PTY, a vt100 parser fed by a
//! background reader thread, and the screen state the preview render
//! path samples each frame. Replaces the v0.4 `capture-pane` snapshot
//! preview for the focused session (only). Section / empty-state /
//! non-focused previews still go through the snapshot path —
//! `Config::embed_enabled` gates the embed entirely, falling back to
//! the snapshot path if the user disables it.
//!
//! ## Threading
//!
//! The PTY's reader is a blocking `std::io::Read`. We pump it on a
//! dedicated `std::thread` and forward every chunk through the same
//! `mpsc::UnboundedSender<AppMsg>` the input + tmux actors use. Each
//! chunk becomes an `AppMsg::EmbedBytes { session, bytes }` so the
//! single-writer app task processes it on its normal main loop. The
//! `session` field on the message lets the app discard bytes from a
//! stale embed when the user has already switched focus.
//!
//! ## Cleanup
//!
//! `Drop` flips the stop flag and `kill()`s the tmux child. The
//! child's death closes the master fd, the reader hits EOF on the
//! next `read`, and the thread exits. We also `drop(pair.slave)` at
//! spawn time — the child still holds an fd to it; this just removes
//! our local handle so we're not the last referent.

use std::io::{Read, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;

use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::Widget;
use tokio::sync::mpsc;
use tui_term::widget::PseudoTerminal;

use crate::events::AppMsg;

/// PTY read buffer size. 8 KiB is large enough that a flood (e.g.
/// `cargo build` warnings, a `yes` flood) doesn't death-spiral into
/// 1-byte reads, and small enough that a typical agent response
/// arrives in one or two chunks.
const READ_BUF_SIZE: usize = 8192;

/// Attach mode for the embedded `tmux attach` PTY.
///
/// `Preview` uses `-f read-only` (read-only, *not* `ignore-size`).
/// Bosun cannot send keys to the session, but the client *does*
/// participate in tmux's window-size negotiation. With the default
/// `window-size latest`, this means the session tracks whichever
/// client is most recently active — when bosun's preview is the
/// current activity, the session resizes to bosun's preview area
/// and content fits without clipping.
///
/// `Focused` uses plain `attach` (read-write, also part of
/// negotiation). The user's keys flow to the session through
/// bosun, and when bosun is active the session is sized to the
/// preview area.
///
/// We previously used `-r` (which is `-f read-only,ignore-size`)
/// and `-f ignore-size` to protect *other* clients from being
/// resized by bosun, plus a `tmux resize-window` to force the
/// session to bosun's preview width. That had two compounding
/// problems: (1) `resize-window` sets `window-size=manual` as a
/// side effect, which disables future auto-resize, so a user's
/// full-screen `tmux attach` (after detaching from bosun) saw
/// content clipped to bosun's narrower size. (2) `ignore-size`
/// alone wouldn't have caused the session to track preview width
/// in the first place. Dropping both fixes both issues.
///
/// Trade-off acknowledged: a parallel `tmux attach` to the same
/// session in another terminal will see size changes as bosun
/// toggles activity. In practice bosun is the sole viewer of
/// sessions it manages, so this rarely matters; users who run
/// parallel attaches can disable the embed via `BOSUN_EMBED=0`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttachMode {
    Preview,
    Focused,
}

impl AttachMode {
    fn tmux_attach_args(self) -> &'static [&'static str] {
        match self {
            AttachMode::Preview => &["attach", "-f", "read-only"],
            AttachMode::Focused => &["attach"],
        }
    }
}

/// Minimum PTY grid size. tmux refuses to size a session under
/// (rows=2, cols=10) on some configurations, and vt100's screen
/// would render a useless sliver anyway. We clamp at (4, 20).
const MIN_ROWS: u16 = 4;
const MIN_COLS: u16 = 20;

pub struct EmbedTerminal {
    /// Internal tmux session name (matches `SessionView.name()`).
    /// The reader thread tags every byte chunk with this so the app
    /// can recognize and discard stale messages from a previous
    /// embed instance after a focus switch.
    session: String,
    parser: vt100::Parser,
    master: Box<dyn MasterPty + Send>,
    /// Boxed `dyn Write` over the same PTY master as `master`.
    /// portable_pty exposes input as `take_writer()`; we cache the
    /// handle here so `write` doesn't need a fresh allocation per
    /// keystroke. Always Some after construction; the Option is
    /// only there to satisfy the borrow checker around `take_writer`.
    writer: Option<Box<dyn Write + Send>>,
    child: Box<dyn Child + Send + Sync>,
    /// Belt-and-braces signal for the reader thread. The reliable
    /// stop is the child's death (master fd closes → reader sees
    /// EOF), but the flag lets the loop exit at the next read
    /// boundary even if the child is briefly slow to die.
    stop: Arc<AtomicBool>,
    rows: u16,
    cols: u16,
    /// Current attach mode. Toggled by App when entering / leaving
    /// focus mode — which actually means dropping this embed and
    /// spawning a new one in the opposite mode (the PTY's attach
    /// args differ between modes and aren't runtime-switchable).
    mode: AttachMode,
    /// Default fg/bg/cursor colors to answer inner OSC 10/11/12 color
    /// queries with — the outer terminal's, so Codex/Neovim et al.
    /// detect the real light/dark background through bosun's tmux
    /// layer instead of timing out and assuming dark (issue #2).
    default_colors: crate::terminal_query::DefaultColors,
    /// Incremental scanner that spots those queries in the inner byte
    /// stream, carrying an incomplete trailing sequence between reads.
    color_query_scanner: crate::terminal_query::QueryScanner,
}

impl EmbedTerminal {
    /// Spawn a new embedded terminal attached to `session` on
    /// `socket` (None = tmux default socket). Sized to (rows, cols),
    /// clamped to (MIN_ROWS, MIN_COLS). Forwards every PTY byte
    /// chunk to `evt_tx` as `AppMsg::EmbedBytes { session, bytes }`.
    ///
    /// `initial_snapshot` (typically the bytes from
    /// `tmux capture-pane -p -e -J`) is fed into the vt100 parser
    /// before the reader thread starts. The parser's screen begins
    /// at the session's current state, so the first frame the user
    /// sees after spawn is a coherent snapshot rather than an empty
    /// grid being filled in by tmux's initial `attach -r` repaint.
    /// Passing `None` is harmless — the parser just starts blank
    /// and tmux's relay paints it over the next few hundred ms.
    // Spawn genuinely needs all of these; grouping them into a config
    // struct would just move the noise to the call sites.
    #[allow(clippy::too_many_arguments)]
    pub fn spawn(
        socket: Option<&str>,
        session: &str,
        rows: u16,
        cols: u16,
        mode: AttachMode,
        initial_snapshot: Option<&[u8]>,
        default_colors: crate::terminal_query::DefaultColors,
        evt_tx: mpsc::UnboundedSender<AppMsg>,
    ) -> std::io::Result<Self> {
        let rows = rows.max(MIN_ROWS);
        let cols = cols.max(MIN_COLS);

        let pty = native_pty_system();
        let pair = pty
            .openpty(PtySize {
                rows,
                cols,
                pixel_width: 0,
                pixel_height: 0,
            })
            .map_err(io_err("openpty"))?;

        let mut cmd = CommandBuilder::new("tmux");
        if let Some(sock) = socket {
            cmd.arg("-L");
            cmd.arg(sock);
        }
        for a in mode.tmux_attach_args() {
            cmd.arg(a);
        }
        cmd.arg("-t");
        cmd.arg(session);
        // Hint to whatever shell tmux relays. tmux's own protocol
        // negotiates the real terminal type with its child apps, so
        // this is only the outer shell hint.
        cmd.env("TERM", "xterm-256color");

        // Reset the session's `window-size` option to `latest`
        // before we attach. A previous bosun run may have shipped
        // with the `force_resize_window` code (later reverted),
        // which called `tmux resize-window -x cols -y rows` — and
        // that command implicitly sets `window-size=manual` on the
        // session as a side effect. Manual-mode sessions ignore
        // client size negotiation entirely, so even after the
        // resize-window code was removed, sessions created under
        // the old code stayed pinned to their last size. A
        // user's full-screen `tmux attach` would then find the
        // session still at preview width and refuse to grow.
        //
        // Setting back to `latest` (tmux's default) restores
        // automatic negotiation: whichever client was most-
        // recently active drives the window size. Safe to fire
        // even on sessions that were never pinned — it's idempotent
        // with the default. Best-effort; errors are logged.
        reset_window_size(socket, session);

        let child = pair
            .slave
            .spawn_command(cmd)
            .map_err(io_err("spawn tmux"))?;
        // Drop our slave handle. The child still owns one; dropping
        // ours means we won't accidentally keep the slave fd alive
        // past the child's death.
        drop(pair.slave);

        let mut reader = pair
            .master
            .try_clone_reader()
            .map_err(io_err("clone reader"))?;
        // Cache a writer handle so per-keystroke `write` doesn't
        // re-acquire it. `take_writer` is portable_pty's owned-handle
        // API; some platforms return a non-cloneable writer, so we
        // take it once at spawn time.
        let writer = pair.master.take_writer().map_err(io_err("take writer"))?;
        let stop = Arc::new(AtomicBool::new(false));
        let stop_reader = stop.clone();
        let session_owned = session.to_string();
        let evt_tx_reader = evt_tx;
        thread::Builder::new()
            .name(format!("bosun-embed-{}", session))
            .spawn(move || {
                let mut buf = [0u8; READ_BUF_SIZE];
                loop {
                    if stop_reader.load(Ordering::Relaxed) {
                        break;
                    }
                    match reader.read(&mut buf) {
                        Ok(0) => break,
                        Ok(n) => {
                            let chunk = buf[..n].to_vec();
                            if evt_tx_reader
                                .send(AppMsg::EmbedBytes {
                                    session: session_owned.clone(),
                                    bytes: chunk,
                                })
                                .is_err()
                            {
                                // Receiver dropped — app is shutting
                                // down. Nothing useful left to do.
                                break;
                            }
                        }
                        Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
                        Err(_) => break,
                    }
                }
            })
            .map_err(io_err("spawn reader"))?;

        let mut parser = vt100::Parser::new(rows, cols, 0);
        if let Some(snap) = initial_snapshot {
            // Feed the capture-pane snapshot synchronously, before
            // the first frame is rendered. The parser's screen now
            // matches what the user would see if they attached
            // directly — so the immediate draw shows a coherent
            // view instead of an empty grid being filled in.
            parser.process(snap);
        }

        Ok(Self {
            session: session.to_string(),
            parser,
            master: pair.master,
            writer: Some(writer),
            child,
            stop,
            rows,
            cols,
            mode,
            default_colors,
            color_query_scanner: crate::terminal_query::QueryScanner::default(),
        })
    }

    /// Write key bytes into the PTY master. Only meaningful in
    /// `AttachMode::Focused` — `Preview` mode runs tmux's `-r`
    /// (read-only) attach, which silently drops key input. (Terminal
    /// *responses* like OSC color replies are read by tmux itself even
    /// on a read-only client, which is why `feed` uses `write_raw`
    /// directly rather than going through this.)
    /// Returns the underlying io error on write failure (rare; the
    /// most likely cause is the child having exited).
    pub fn write(&mut self, bytes: &[u8]) -> std::io::Result<()> {
        self.write_raw(bytes)
    }

    pub fn mode(&self) -> AttachMode {
        self.mode
    }

    /// True iff the inner app has enabled some flavor of mouse
    /// tracking (DECSET 1000 / 1002 / 1003). The vt100 parser
    /// tracks this from the byte stream; callers use it to gate
    /// mouse forwarding so apps that *didn't* opt into mouse
    /// don't get SGR-1006 sequences pumped into their stdin (which
    /// they'd interpret as literal escape bytes — visible garbage
    /// at best, broken input at worst).
    pub fn wants_mouse(&self) -> bool {
        !matches!(
            self.parser.screen().mouse_protocol_mode(),
            vt100::MouseProtocolMode::None
        )
    }

    /// True iff the inner app has enabled DECCKM (cursor-key
    /// application mode, DECSET 1). When on, arrow keys are
    /// expected as SS3 sequences (`\eOA/B/C/D`) instead of the
    /// default CSI form (`\e[A/B/C/D`). vim in command mode,
    /// readline, and some TUI apps care about this distinction;
    /// most modern terminals/apps handle both forms gracefully.
    pub fn application_cursor(&self) -> bool {
        self.parser.screen().application_cursor()
    }

    pub fn session(&self) -> &str {
        &self.session
    }

    /// Feed a chunk of PTY bytes into the vt100 parser. Cheap —
    /// vt100 is a single-pass state machine.
    ///
    /// Also scans the chunk for inner-app OSC 10/11/12 color *queries*
    /// (which tmux relays down to us as its client terminal) and
    /// answers each with the outer terminal's real fg/bg/cursor. vt100
    /// ignores the queries, so the inner app would otherwise time out
    /// and assume a dark background (issue #2). The query bytes still
    /// flow into the parser too — harmless, vt100 drops them.
    pub fn feed(&mut self, bytes: &[u8]) {
        for (kind, term) in self.color_query_scanner.scan(bytes) {
            let reply = self.default_colors.response(kind, term);
            if let Err(e) = self.write_raw(&reply) {
                tracing::warn!("embed color-query reply failed: {}", e);
            }
        }
        self.parser.process(bytes);
    }

    /// Write bytes straight to the PTY master regardless of attach
    /// mode. `write` is gated on `Focused`, but OSC color replies must
    /// go back even in `Preview` mode — tmux relays the query to us as
    /// its client and expects the answer on the same channel.
    fn write_raw(&mut self, bytes: &[u8]) -> std::io::Result<()> {
        if let Some(w) = self.writer.as_mut() {
            w.write_all(bytes)?;
            w.flush()?;
        }
        Ok(())
    }

    /// Resize both the parser grid and the PTY's window size. Cheap
    /// no-op when the dimensions are unchanged. The child sees a
    /// SIGWINCH and (for well-behaved TUI apps like Claude Code,
    /// vim, etc.) repaints. tmux relays the resize down to the
    /// session-attached pane.
    pub fn resize(&mut self, rows: u16, cols: u16) {
        let rows = rows.max(MIN_ROWS);
        let cols = cols.max(MIN_COLS);
        if rows == self.rows && cols == self.cols {
            return;
        }
        self.rows = rows;
        self.cols = cols;
        self.parser.screen_mut().set_size(rows, cols);
        let _ = self.master.resize(PtySize {
            rows,
            cols,
            pixel_width: 0,
            pixel_height: 0,
        });
        // The session's window will track our new size through
        // tmux's normal negotiation (window-size=latest by default
        // + our client participates because we don't set
        // ignore-size). No explicit resize-window required.
    }

    /// Render the current vt100 screen into `area` of `buf`. Uses
    /// `tui_term::widget::PseudoTerminal`, which walks the screen
    /// grid and emits ratatui `Cell`s with SGR attributes translated.
    pub fn render(&self, buf: &mut Buffer, area: Rect) {
        let widget = PseudoTerminal::new(self.parser.screen());
        widget.render(area, buf);
    }
}

impl Drop for EmbedTerminal {
    fn drop(&mut self) {
        self.stop.store(true, Ordering::Relaxed);
        // Killing the child closes the slave end; the master's reader
        // then hits EOF and the reader thread exits naturally. We
        // intentionally do NOT join the thread here — if the child
        // wedges, joining would block the app's shutdown path.
        let _ = self.child.kill();
    }
}

/// Map a portable-pty / spawn error into a generic `std::io::Error`
/// so callers can propagate without taking a dep on portable_pty's
/// concrete error type. Accepts anything `Display` so it works
/// against both `anyhow::Error` (what portable_pty returns) and
/// `std::io::Error` (what `thread::spawn` returns).
fn io_err<E: std::fmt::Display>(what: &'static str) -> impl FnOnce(E) -> std::io::Error {
    move |e| std::io::Error::other(format!("{what}: {e}"))
}

/// `tmux set-option -t <session> window-size latest` — restores
/// the default automatic-negotiation policy on a session that may
/// have been left pinned to `manual` by a previous bosun run.
/// Best-effort; logs on failure but never panics.
fn reset_window_size(socket: Option<&str>, session: &str) {
    let mut cmd = std::process::Command::new("tmux");
    if let Some(s) = socket {
        cmd.arg("-L").arg(s);
    }
    cmd.args(["set-option", "-t", session, "window-size", "latest"]);
    if let Err(e) = cmd.status() {
        tracing::debug!("tmux set window-size latest on {}: {}", session, e);
    }
}