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
use std::io::{self, Stdout};
use std::sync::Arc;
use crossterm::event::{
DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
EnableFocusChange, EnableMouseCapture, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, supports_keyboard_enhancement, EnterAlternateScreen,
LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use bosun::app::App;
use bosun::config::Config;
use bosun::error::{BosunError, Result};
use bosun::store::Store;
use bosun::tmux::{
attach::emergency_unbind, status_bar::emergency_uninstall as emergency_status_bar,
TokioTmuxClient,
};
#[tokio::main]
async fn main() -> Result<()> {
if std::env::args().any(|a| a == "--version" || a == "-V") {
println!("bosun {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
// `bosun update [--check]` runs synchronously, prints to stderr,
// and exits before any TUI/tmux machinery starts. Failures
// surface as a non-zero exit code with the anyhow chain printed.
let mut args = std::env::args().skip(1);
if let Some(first) = args.next() {
if first == "update" {
let check_only = args.any(|a| a == "--check");
return match bosun::commands::update::run(check_only) {
Ok(()) => Ok(()),
Err(e) => {
eprintln!("bosun update: {:#}", e);
std::process::exit(1);
}
};
}
if first == "release-notes" {
return match bosun::commands::release_notes::run() {
Ok(()) => Ok(()),
Err(e) => {
eprintln!("bosun release-notes: {:#}", e);
std::process::exit(1);
}
};
}
if first == "editor" {
// `bosun editor` (no arg) prints current; `bosun editor
// <cmd>` sets it. We don't accept multi-word commands
// through argv splitting — if a user wants `code --new-window`
// they can edit config.toml directly. The simple form is
// the documented path.
let arg = args.next();
return match bosun::commands::editor::run(arg) {
Ok(()) => Ok(()),
Err(e) => {
eprintln!("bosun editor: {:#}", e);
std::process::exit(1);
}
};
}
if first == "help" || first == "--help" || first == "-h" {
print_help();
return Ok(());
}
}
init_tracing();
let config = Config::from_env();
let socket = config.tmux_socket.clone();
let client: Arc<TokioTmuxClient> = match &socket {
Some(s) => Arc::new(TokioTmuxClient::with_socket(s.clone())),
None => Arc::new(TokioTmuxClient::new()),
};
// Open the SQLite store for recents + future metadata. Failure
// here is non-fatal — we fall back to an in-memory store so bosun
// still runs, just without persistence across launches.
let store = Arc::new(match Store::open_default() {
Ok(s) => s,
Err(e) => {
tracing::warn!("store open failed, using in-memory: {}", e);
Store::in_memory().expect("in-memory store cannot fail")
}
});
// Panic hook: restore terminal + clean up C-q binding + restore
// the user's tmux status bar before we die.
let socket_for_hook = socket.clone();
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = disable_raw_mode();
// Pop unconditionally — harmless if we never pushed (the
// terminal ignores a pop on an empty stack), and it keeps the
// kitty keyboard protocol from leaking past a panic.
let _ = execute!(
io::stdout(),
PopKeyboardEnhancementFlags,
DisableFocusChange,
DisableBracketedPaste,
DisableMouseCapture,
LeaveAlternateScreen
);
emergency_unbind(socket_for_hook.as_deref());
emergency_status_bar(socket_for_hook.as_deref());
default_hook(info);
}));
let (mut terminal, kbd_enhanced) = setup_terminal()?;
// Probe the outer terminal for its default fg/bg/cursor colors so
// the embedded session PTY can answer the OSC 10/11/12 queries that
// apps like Codex / Neovim use to pick a light vs dark palette
// (issue #2). Must run here — after raw mode is on but *before*
// `App::new` spawns the input actor that owns stdin. Only worth the
// ~120ms window when the embed is actually in play; if the terminal
// doesn't answer, the embed falls back to the active theme colors.
let term_colors = if config.embed_enabled {
bosun::terminal_query::probe(std::time::Duration::from_millis(120))
} else {
bosun::terminal_query::TermColors::default()
};
let mut app = App::new(client, socket, config, store);
app.kbd_enhanced = kbd_enhanced;
app.term_colors = term_colors;
let run_result = app.run(&mut terminal).await;
restore_terminal(&mut terminal, kbd_enhanced)?;
run_result
}
/// Set up the terminal and return it alongside whether the kitty
/// keyboard progressive-enhancement flags were successfully pushed.
/// The bool is threaded back so teardown pops exactly what we pushed.
fn setup_terminal() -> Result<(Terminal<CrosstermBackend<Stdout>>, bool)> {
enable_raw_mode().map_err(BosunError::Io)?;
let mut stdout = io::stdout();
// Mouse capture is needed so the draggable divider between the
// session list and preview pane can see clicks and drags. We
// tear it down around `tmux attach` so tmux owns the mouse
// during an attach (see `App::perform_attach`).
// Bracketed paste lets crossterm hand us pasted text as one
// `Event::Paste(String)` rather than character-by-character
// `Event::Key` events. The outer terminal also encodes
// drag-drop file paths and image markers using the same
// protocol, so this is the path for "I dropped an image onto
// bosun" → forward to the focused embed PTY.
execute!(
stdout,
EnterAlternateScreen,
EnableMouseCapture,
EnableBracketedPaste,
// FocusGained/FocusLost events let us recover from things
// like iTerm's Cmd+R "reset" that clears the screen and
// exits alt screen out from under ratatui — the next focus
// gain triggers a full repaint. See `App::recover_display`.
EnableFocusChange,
)
.map_err(BosunError::Io)?;
// Request the kitty keyboard protocol's "disambiguate escape
// codes" enhancement. This is what makes the outer terminal
// report modifiers unambiguously — without it, terminals fall
// back to legacy encoding and hand us *bare* keys for chords
// like Option+Delete (should be Alt+Backspace) and Shift+Up/Down
// (should be modified arrows), which breaks word-delete in the
// embed and the in-focus session-cycle chords. Gated on
// `supports_keyboard_enhancement` so it's a no-op on terminals
// that don't speak the protocol (Apple Terminal.app), where we
// keep the prior behavior. DISAMBIGUATE alone (no
// REPORT_EVENT_TYPES) means no key-release events, so the focus
// nav chords don't double-fire.
let kbd_enhanced = matches!(supports_keyboard_enhancement(), Ok(true))
&& execute!(
stdout,
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES),
)
.is_ok();
let backend = CrosstermBackend::new(stdout);
Terminal::new(backend)
.map(|t| (t, kbd_enhanced))
.map_err(BosunError::Io)
}
fn restore_terminal(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
kbd_enhanced: bool,
) -> Result<()> {
if kbd_enhanced {
let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
}
disable_raw_mode().map_err(BosunError::Io)?;
execute!(
terminal.backend_mut(),
DisableFocusChange,
DisableBracketedPaste,
DisableMouseCapture,
LeaveAlternateScreen,
)
.map_err(BosunError::Io)?;
terminal.show_cursor().map_err(BosunError::Io)?;
Ok(())
}
fn print_help() {
println!(
"bosun {version} — tmux-native orchestrator for AI agent sessions
USAGE:
bosun Launch the TUI (default)
bosun update Check for and install the latest release
bosun update --check Check for an update without installing
bosun release-notes Page the bundled CHANGELOG.md
bosun editor [<cmd>] Print or set the editor launched by `e` in the TUI
(e.g. `bosun editor zed`, `bosun editor code`)
bosun --version Print version and exit
bosun --help Print this message
ENVIRONMENT:
BOSUN_LOG Tracing filter (e.g. `info`, `bosun=debug`). Off by default.
See https://github.com/yetidevworks/bosun for full docs.",
version = env!("CARGO_PKG_VERSION")
);
}
fn init_tracing() {
use tracing_subscriber::{fmt, EnvFilter};
// Send logs to stderr. During TUI mode this may scramble the alt-screen,
// so default filter is OFF — enable with BOSUN_LOG=info.
let filter = EnvFilter::try_from_env("BOSUN_LOG").unwrap_or_else(|_| EnvFilter::new("off"));
let _ = fmt()
.with_env_filter(filter)
.with_writer(io::stderr)
.try_init();
}