feat(maestro): daemon spawns tmux sessions with 256-color TERM (NAP-252)
ci / rust (push) Failing after 3m7s

`maestrod` running from a stripped systemd env leaves the spawned shell
without `TERM`/`COLORTERM`, and the pane process inherits tmux's
`default-terminal` (which defaults to `screen`, 8 colors). Browser
attaches via xterm.js render ANSI sequences as plain text.

Fix:
- `Tmux::new_session` now passes `-e TERM=xterm-256color -e
  COLORTERM=truecolor` to `tmux new-session`.
- New `Tmux::set_option_global` chains `start-server \; set-option -g`
  so it works before any session exists; the daemon calls it on startup
  to set `default-terminal=tmux-256color`.

Operators wanting 24-bit color should add
`set-option -ga terminal-overrides ",xterm-256color:RGB"` to their
own tmux config — left out of the daemon since that's operator territory.

Tests:
- Unit: `new_session_args` returns the expected argv including the
  `-e TERM=…` / `COLORTERM=…` pair.
- Integration: spawn a session under an isolated tmux server, set
  `default-terminal`, run `printenv` + `tput colors`, assert the pane
  sees `TERM=tmux-256color`, `COLORTERM=truecolor`, and 256 colors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 06:50:56 +00:00
co-authored by Claude Opus 4.7
parent 3bd793f394
commit 080f961238
3 changed files with 114 additions and 5 deletions
+6
View File
@@ -121,6 +121,12 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
let sidecar = SidecarStore::new(config.sidecar_dir());
let worktree_mgr = WorktreeManager::new(config.worktree_root.clone());
let tmux = Tmux::new();
// The pane process's TERM comes from tmux's `default-terminal`, which on
// most distros is `screen` (8 colors). Promote it so attach clients see
// 256-color output. NAP-252.
if let Err(e) = tmux.set_option_global("default-terminal", "tmux-256color") {
tracing::warn!(error = ?e, "failed to set tmux default-terminal — sessions may lack color");
}
let manager = SessionManager::new_with_replay(
config.daemon_id.clone(),
tmux,
+65 -5
View File
@@ -77,17 +77,31 @@ impl Tmux {
c
}
/// `tmux new-session -d -s maestro-<id> -c <cwd> <command>`.
/// `tmux new-session -d -e TERM=xterm-256color -e COLORTERM=truecolor -s maestro-<id> -c <cwd> <command>`.
///
/// `command` is passed as a single shell-line argument to tmux, which
/// invokes it via `$SHELL -c`.
/// invokes it via `$SHELL -c`. The `-e TERM=…`/`COLORTERM=…` flags ensure
/// the spawned shell sees sensible color env vars even when `maestrod`
/// runs from a stripped systemd unit. Pair with [`Self::set_option_global`]
/// of `default-terminal` for true 256-color output in the pane.
pub fn new_session(&self, id: Uuid, cwd: &Path, command: &str) -> Result<(), TmuxError> {
let name = session_name(id);
let out = self
.cmd()
.args(["new-session", "-d", "-s", &name, "-c"])
.arg(cwd)
.arg(command)
.args(new_session_args(&name, cwd, command))
.output()?;
check(out)
}
/// `tmux start-server \; set-option -g <name> <value>`. Used on daemon
/// startup to set `default-terminal` to `tmux-256color` so the pane's
/// `TERM` is a 256-color terminfo entry (the distro default `screen` caps
/// at 8). The chained `start-server` makes the call work before any
/// session exists.
pub fn set_option_global(&self, name: &str, value: &str) -> Result<(), TmuxError> {
let out = self
.cmd()
.args(["start-server", ";", "set-option", "-g", name, value])
.output()?;
check(out)
}
@@ -205,6 +219,25 @@ pub fn session_name(id: Uuid) -> String {
format!("{SESSION_PREFIX}{id}")
}
/// Args passed to `tmux` for `new_session`, excluding the binary itself and
/// any socket/tmpdir flags injected by [`Tmux::cmd`]. Split out so the argv
/// can be asserted without invoking tmux.
fn new_session_args(name: &str, cwd: &Path, command: &str) -> Vec<OsString> {
vec![
OsString::from("new-session"),
OsString::from("-d"),
OsString::from("-e"),
OsString::from("TERM=xterm-256color"),
OsString::from("-e"),
OsString::from("COLORTERM=truecolor"),
OsString::from("-s"),
OsString::from(name),
OsString::from("-c"),
cwd.as_os_str().to_os_string(),
OsString::from(command),
]
}
fn check(out: Output) -> Result<(), TmuxError> {
if out.status.success() {
Ok(())
@@ -215,3 +248,30 @@ fn check(out: Output) -> Result<(), TmuxError> {
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_session_args_pass_256color_env() {
let args = new_session_args("maestro-abc", Path::new("/tmp/proj"), "sleep 30");
let strs: Vec<&str> = args.iter().map(|s| s.to_str().unwrap()).collect();
assert_eq!(
strs,
vec![
"new-session",
"-d",
"-e",
"TERM=xterm-256color",
"-e",
"COLORTERM=truecolor",
"-s",
"maestro-abc",
"-c",
"/tmp/proj",
"sleep 30",
]
);
}
}
+43
View File
@@ -115,6 +115,49 @@ fn resize_window_succeeds() {
fx.tmux.kill_session(id).expect("kill_session");
}
#[test]
fn new_session_sets_256color_env_and_default_terminal() {
let fx = TmuxFixture::new();
let cwd = TempDir::new().unwrap();
let sink = TempDir::new().unwrap();
let sink_path = sink.path().join("env.log");
let id = Uuid::new_v4();
fx.tmux
.set_option_global("default-terminal", "tmux-256color")
.expect("set default-terminal");
fx.tmux
.new_session(id, cwd.path(), "bash --noprofile --norc")
.expect("new_session");
wait_for(Duration::from_secs(3), || {
fx.tmux.list_sessions().unwrap().contains(&id)
});
let sink_command = format!("cat >> {}", shell_quote(&sink_path.display().to_string()));
fx.tmux.pipe_pane(id, &sink_command).expect("pipe_pane");
fx.tmux
.send_keys(
id,
b"printf 'TERM=%s\\nCOLORTERM=%s\\nCOLORS=%s\\n' \"$TERM\" \"$COLORTERM\" \"$(tput colors)\"\n",
)
.expect("send_keys");
wait_for(Duration::from_secs(5), || {
std::fs::read_to_string(&sink_path)
.map(|s| {
s.contains("TERM=tmux-256color")
&& s.contains("COLORTERM=truecolor")
&& s.contains("COLORS=256")
})
.unwrap_or(false)
});
fx.tmux.kill_session(id).expect("kill_session");
}
#[test]
fn list_sessions_returns_empty_when_no_server() {
let fx = TmuxFixture::new();