Skip to main content

ninox_core/
tmux.rs

1use anyhow::{Context, Result};
2use tokio::process::Command;
3
4/// Name of the private tmux server socket all ninox sessions live on.
5/// Isolates ninox from the user's own tmux server and ~/.tmux.conf — and,
6/// via `is_test_binary`, isolates the test suite's own tmux server from the
7/// real running app's. Without this, `cargo test` (or manually clearing a
8/// stale "duplicate session" test collision with `tmux -L ninox
9/// kill-server`) kills the user's actual live orchestrator/worker panes,
10/// since tests and the production app previously shared this exact socket.
11/// Not a `const` because it depends on that runtime check.
12pub(crate) fn socket() -> &'static str {
13    if is_test_binary() { "ninox-test" } else { "ninox" }
14}
15
16/// Cargo places every test/bench/example binary's compiled output under
17/// `target/<profile>/deps/` (e.g. `target/debug/deps/ninox_core-<hash>`),
18/// while the real `cargo build`/`cargo run` binary lives directly at
19/// `target/<profile>/<name>` with no `deps` path component. Checking for
20/// that segment is a reliable, zero-config way to tell "am I a test binary"
21/// apart from "am I the real app" — no env var, no per-test setup required,
22/// so it can't race under parallel test execution the way a shared env var
23/// would (see `lifecycle::usage::ENV_TEST_GUARD` for a case where that
24/// exact hazard already had to be worked around once in this codebase).
25fn is_test_binary() -> bool {
26    std::env::current_exe()
27        .ok()
28        .is_some_and(|p| p.components().any(|c| c.as_os_str() == "deps"))
29}
30
31/// Parse a `tmux -V` version string (e.g. "tmux 3.4" or "tmux 3.5a") into
32/// (major, minor). Unparseable input degrades to (0, 0) so version checks
33/// fail closed rather than panicking.
34fn parse_tmux_version(raw: &str) -> (u32, u32) {
35    let ver = raw.trim().strip_prefix("tmux ").unwrap_or(raw.trim());
36    let mut parts = ver.split(|c: char| !c.is_ascii_digit());
37    let major: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
38    let minor: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
39    (major, minor)
40}
41
42/// The installed tmux version, detected synchronously (a single fast `tmux
43/// -V` call). Used to build a config that only uses directives the running
44/// tmux actually supports, and by tests to gate assertions that only hold on
45/// newer tmux. Returns (0, 0) if tmux is missing or unparseable.
46pub fn detected_version_sync() -> (u32, u32) {
47    std::process::Command::new("tmux")
48        .arg("-V")
49        .output()
50        .ok()
51        .filter(|o| o.status.success())
52        .map(|o| parse_tmux_version(&String::from_utf8_lossy(&o.stdout)))
53        .unwrap_or((0, 0))
54}
55
56/// `extended-keys-format` (needed to disambiguate keys like Shift+Enter as
57/// CSI-u) was added in tmux 3.5; older tmux rejects the option outright.
58fn supports_extended_keys_format((major, minor): (u32, u32)) -> bool {
59    (major, minor) >= (3, 5)
60}
61
62/// Build the ninox-managed server config (spec §"Dedicated tmux server"),
63/// tailored to what `version` actually supports so we never ask an older
64/// tmux to parse a directive it doesn't understand.
65fn server_config_for_version(version: (u32, u32)) -> String {
66    let mut cfg = String::from("# Managed by ninox — rewritten on every app start. Do not edit.\n");
67    cfg.push_str("set -g  default-terminal \"tmux-256color\"\n");
68    cfg.push_str("set -as terminal-features \"xterm*:RGB:usstyle:extkeys:hyperlinks\"\n");
69    cfg.push_str("set -s  extended-keys always\n");
70    if supports_extended_keys_format(version) {
71        cfg.push_str("set -s  extended-keys-format csi-u\n");
72    }
73    cfg.push_str("set -g  history-limit 100000\n");
74    cfg.push_str("set -g  status off\n");
75    cfg.push_str("set -s  escape-time 0\n");
76    cfg.push_str("set -g  window-size latest\n");
77    cfg.push_str("set -g  allow-passthrough on\n");
78    cfg.push_str("set -g  focus-events on\n");
79    // Keep the server alive with zero sessions/clients so the one-time
80    // bootstrap in `ensure_server_ready` (a bare `start-server`, no session)
81    // doesn't get reaped before later commands reach it.
82    cfg.push_str("set -g  exit-empty off\n");
83    cfg
84}
85
86fn config_path() -> std::path::PathBuf {
87    let file = if is_test_binary() { "tmux-test.conf" } else { "tmux.conf" };
88    dirs::config_dir()
89        .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
90        .join("ninox")
91        .join(file)
92}
93
94/// Write the ninox tmux server config. Called once at startup so config
95/// drift between app versions cannot accumulate. The content is tailored to
96/// the installed tmux version (see `server_config_for_version`).
97pub fn write_server_config() -> Result<std::path::PathBuf> {
98    let path = config_path();
99    if let Some(dir) = path.parent() {
100        std::fs::create_dir_all(dir)?;
101    }
102    std::fs::write(&path, server_config_for_version(detected_version_sync()))?;
103    Ok(path)
104}
105
106/// argv prefix routing a tmux invocation to the private ninox server.
107/// Does NOT include `-f`: config application is handled once, deterministically,
108/// by `ensure_server_ready` (see its doc comment for why `-f` on every
109/// invocation is not safe to rely on).
110fn socket_args() -> Vec<String> {
111    vec!["-L".into(), socket().into()]
112}
113
114/// Fail fast if tmux is missing or older than 3.2 (extended-keys support).
115pub async fn require_version() -> Result<()> {
116    let out = Command::new("tmux").arg("-V").output().await
117        .context("tmux not found — install tmux (brew install tmux / apt install tmux)")?;
118    let v = String::from_utf8_lossy(&out.stdout);
119    let ver = v.trim().strip_prefix("tmux ").unwrap_or(v.trim());
120    let version = parse_tmux_version(&v);
121    anyhow::ensure!(
122        version >= (3, 2),
123        "ninox requires tmux >= 3.2 for extended keyboard support; found {ver}"
124    );
125    if !supports_extended_keys_format(version) {
126        tracing::warn!(
127            "tmux {ver} detected — extended-keys-format csi-u requires tmux >= 3.5; \
128             Shift+Enter and other disambiguated keys may not reach apps correctly \
129             on this version"
130        );
131    }
132    Ok(())
133}
134
135/// Ensure the ninox config file exists, the private tmux server is running,
136/// and that server has our config applied — exactly once per process, no
137/// matter how many concurrent callers race to be first.
138///
139/// Why this exists: `tmux -f <path>` silently falls back to built-in
140/// defaults — no error, nothing on stderr — if `<path>` doesn't exist yet
141/// (verified directly: `tmux -f /does/not/exist new-session -d ...` exits 0
142/// with default options). `write_server_config` is normally called once by
143/// `main` before any session is created, but nothing else guarantees that
144/// ordering — a caller that creates a session before the config file has
145/// ever been written (e.g. this crate's test suite, or a future call site)
146/// gets a server silently running with tmux defaults (status bar visible,
147/// 2000-line history, no window-size follow) for that server's entire
148/// lifetime, since config is only read once, at server start. Reproduced
149/// deterministically on a from-scratch Linux `$HOME` (no prior ninox run to
150/// have left the file behind) — exactly the shape of a fresh CI runner or a
151/// fresh install, which is why this only ever showed up on Ubuntu CI.
152/// Funnelling every ninox-socket command through this guard first means the
153/// file is written, and the server started against it, exactly once, before
154/// anything else can race ahead and start the server unconfigured.
155async fn ensure_server_ready() {
156    static READY: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new();
157    READY.get_or_init(|| async {
158        if let Err(e) = write_server_config() {
159            tracing::warn!("failed to write tmux config: {e}");
160        }
161        let conf = config_path().display().to_string();
162        // `start-server` needs no session; `exit-empty off` (in our config)
163        // keeps the freshly-started server alive with none. If a server
164        // from an older ninox run is already up, `-f` here is a no-op, so
165        // `source-file` re-applies our (possibly newer) config explicitly —
166        // this is also what makes "rewritten on every app start" true for a
167        // long-lived server, not just for the file on disk.
168        let _ = run_raw(&["-L", socket(), "-f", &conf, "start-server"]).await;
169        let _ = run_raw(&["-L", socket(), "source-file", &conf]).await;
170    }).await;
171}
172
173fn is_missing_session(e: &anyhow::Error) -> bool {
174    let msg = e.to_string();
175    msg.contains("can't find session")
176        || msg.contains("session not found")
177        || msg.contains("no server running")
178        || msg.contains("no sessions")
179        // tmux's message for a session-targeted command (has-session,
180        // kill-session, list-panes, ...) when the server is up but holds
181        // zero sessions total — a state that's now reachable because
182        // `ensure_server_ready` keeps the ninox server alive empty
183        // (`exit-empty off`) rather than only ever existing once a real
184        // session has been created on it.
185        || msg.contains("no current target")
186}
187
188/// Metadata about a running tmux session from `list-sessions`.
189#[derive(Debug, Clone)]
190pub struct TmuxSession {
191    pub id:         String,
192    pub created_ms: i64,
193    pub pid:        Option<u32>,
194    pub tty:        Option<String>,
195}
196
197/// Run a tmux subcommand against the ninox server and return trimmed stdout.
198async fn run(args: &[&str]) -> Result<String> {
199    ensure_server_ready().await;
200    let prefix = socket_args();
201    let mut full: Vec<&str> = prefix.iter().map(String::as_str).collect();
202    full.extend_from_slice(args);
203    run_raw(&full).await
204}
205
206/// Run tmux with NO socket routing (the user's default server) — only for
207/// legacy sessions created by pre-private-socket builds.
208async fn run_default(args: &[&str]) -> Result<String> {
209    run_raw(args).await
210}
211
212/// The old `run` body, renamed: spawn tmux with exactly these args.
213async fn run_raw(args: &[&str]) -> Result<String> {
214    let out = Command::new("tmux")
215        .args(args)
216        .kill_on_drop(true)
217        .output()
218        .await
219        .context("tmux not found — install tmux (brew install tmux / apt install tmux)")?;
220    if !out.status.success() {
221        let stderr = String::from_utf8_lossy(&out.stderr);
222        anyhow::bail!("tmux {:?} failed: {}", args, stderr.trim());
223    }
224    Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
225}
226
227/// Run a command that targets an existing session. Tries the ninox server
228/// first, then falls back to the default server for legacy sessions.
229async fn run_session_scoped(args: &[&str]) -> Result<String> {
230    match run(args).await {
231        Err(e) if is_missing_session(&e) => run_default(args).await,
232        other => other,
233    }
234}
235
236/// Run tmux; swallow errors and return empty string on failure.
237/// Logs warnings for debugging; does not propagate errors.
238async fn run_best_effort(args: &[&str]) -> String {
239    match run(args).await {
240        Ok(result) => result,
241        Err(e) => {
242            tracing::warn!("tmux {:?} failed (ignored): {}", args, e);
243            String::new()
244        }
245    }
246}
247
248/// `run_best_effort`, but against the default (non-ninox) socket — used to
249/// surface legacy sessions from pre-private-socket builds.
250async fn run_best_effort_default(args: &[&str]) -> String {
251    match run_default(args).await {
252        Ok(result) => result,
253        Err(e) => {
254            tracing::warn!("tmux (default socket) {:?} failed (ignored): {}", args, e);
255            String::new()
256        }
257    }
258}
259
260/// Shell-quote a string to prevent injection in tmux commands.
261/// Wraps the string in single quotes and escapes interior single quotes.
262fn shell_quote(s: &str) -> String {
263    format!("'{}'", s.replace('\'', "'\\''"))
264}
265
266/// Create a detached tmux session.  Kills a stale session with the same name
267/// if one exists, then hides the status bar so the terminal widget is clean.
268pub async fn create_session(
269    id:        &str,
270    workspace: &str,
271    cmd:       &str,
272    env:       &[(&str, &str)],
273) -> Result<()> {
274    // Build -e KEY=VALUE pairs
275    let mut env_pairs: Vec<String> = Vec::new();
276    for (k, v) in env {
277        anyhow::ensure!(!k.contains('='), "env key must not contain '=': {k}");
278        env_pairs.push(format!("{k}={v}"));
279    }
280    let mut extra: Vec<&str> = Vec::new();
281    for pair in &env_pairs {
282        // Values are passed as separate argv tokens via execve — no shell quoting needed.
283        extra.push("-e");
284        extra.push(pair.as_str());
285    }
286
287    // Wrap the command in a login shell so the full user PATH is available.
288    // tmux sessions do not inherit shell rc files, so tools installed via
289    // nvm / cargo / homebrew etc. would not be found otherwise.
290    let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
291    let shell_cmd = format!("{shell} -l -c {}", shell_quote(cmd));
292
293    // Fix the terminal dimensions to match the canvas.  The canvas is roughly
294    // (window_width - 220px sidebar) / 7.8px_per_col ≈ 135 cols on a 1280-wide
295    // window.  Use 140 as a safe default; too-wide values push Claude Code's
296    // centered content off-screen.
297    let mut base = vec!["new-session", "-d", "-s", id, "-x", "140", "-y", "50", "-c", workspace];
298    base.extend_from_slice(&extra);
299    base.push(&shell_cmd);
300
301    // A duplicate name means a LIVE tmux session already exists under this
302    // id. Killing it to make room (the old behavior) silently destroys a
303    // running agent whenever the store and tmux disagree about what exists —
304    // surface the conflict to the caller instead; the spawn UI shows it.
305    run(&base).await.map(|_| ())
306}
307
308/// Kill a tmux session.  Succeeds even if the session doesn't exist.
309/// `run_session_scoped` falls back to the default socket when the session
310/// isn't found on the ninox server, so legacy sessions are killed there too.
311pub async fn kill_session(id: &str) -> Result<()> {
312    match run_session_scoped(&["kill-session", "-t", id]).await {
313        Ok(_) => Ok(()),
314        Err(e) => {
315            if is_missing_session(&e) {
316                Ok(())
317            } else {
318                Err(e)
319            }
320        }
321    }
322}
323
324/// Returns `true` if a tmux session with this name is currently running.
325pub async fn has_session(id: &str) -> bool {
326    run_session_scoped(&["has-session", "-t", id]).await.is_ok()
327}
328
329/// List every live tmux session.  Sessions on the ninox server are listed
330/// first; legacy sessions on the default server are appended for any id not
331/// already seen (ninox socket wins on conflicts).
332pub async fn list_sessions() -> Result<Vec<TmuxSession>> {
333    // A literal tab column separator is mangled by tmux's -F formatter on
334    // older tmux (verified: tmux 3.4 rewrites an embedded tab byte in a -F
335    // template to `_` in the output, so every field collapses into one;
336    // tmux 3.6 passes it through untouched). `|` survives on both and can't
337    // appear in any of these fields (ninox controls session-name shape;
338    // the rest are numeric or a `/dev/...` path).
339    const SEP: &str = "|";
340    let fmt = format!("#{{session_name}}{SEP}#{{session_created}}{SEP}#{{pane_pid}}{SEP}#{{pane_tty}}");
341    let ninox_raw = run_best_effort(&["list-sessions", "-F", &fmt]).await;
342    let default_raw = run_best_effort_default(&["list-sessions", "-F", &fmt]).await;
343
344    let mut seen = std::collections::HashSet::new();
345    let mut sessions = Vec::new();
346    for raw in [ninox_raw, default_raw] {
347        for line in raw.lines().filter(|l| !l.is_empty()) {
348            let mut cols = line.splitn(4, SEP);
349            let Some(id) = cols.next().map(str::to_string) else { continue };
350            if !seen.insert(id.clone()) {
351                continue;
352            }
353            let sec = cols.next().and_then(|s| s.parse::<i64>().ok()).unwrap_or(0);
354            let pid = cols.next().and_then(|s| s.parse::<u32>().ok());
355            let tty = cols.next().map(str::to_string).filter(|s| !s.is_empty());
356            sessions.push(TmuxSession { id, created_ms: sec * 1000, pid, tty });
357        }
358    }
359    Ok(sessions)
360}
361
362/// Return the tty device path (e.g. `/dev/ttys003`) for the session's active pane.
363pub async fn get_pane_tty(id: &str) -> Result<Option<String>> {
364    let out = run_session_scoped(&["list-panes", "-t", id, "-F", "#{pane_tty}"]).await?;
365    Ok(out
366        .lines()
367        .next()
368        .map(|s| s.trim().to_string())
369        .filter(|s| !s.is_empty()))
370}
371
372/// Start piping pane output to `dest_path` (regular file, not FIFO).
373/// Does NOT use `-o` so it force-restarts any existing pipe — required for reconnect.
374pub async fn pipe_pane(id: &str, dest_path: &str) -> Result<()> {
375    run_session_scoped(&["pipe-pane", "-t", id, &format!("cat > {}", shell_quote(dest_path))]).await?;
376    Ok(())
377}
378
379/// Full argv for `tmux attach` for this session (element 0 is "tmux"),
380/// resolving whether it lives on the ninox or the legacy default server.
381pub async fn attach_args(session_id: &str) -> Vec<String> {
382    let mut argv = vec!["tmux".to_string()];
383    if run(&["has-session", "-t", session_id]).await.is_ok() {
384        argv.extend(socket_args());
385    } else {
386        tracing::warn!(
387            "session {session_id} predates the ninox socket — attaching on the \
388             legacy default tmux server without the managed config (extended \
389             keys / resize guarantees are degraded until it terminates naturally)"
390        );
391    }
392    argv.extend(["attach-session", "-t", session_id].map(String::from));
393    argv
394}
395
396/// Number of scrolled-off lines tmux holds for this pane.
397pub async fn history_size(session_id: &str) -> i64 {
398    run_session_scoped(&["display-message", "-p", "-t", session_id, "#{history_size}"])
399        .await
400        .ok()
401        .and_then(|s| s.trim().parse().ok())
402        .unwrap_or(0)
403}
404
405/// Capture styled pane content for the line range [start, end], where
406/// negative indices address history (-1 = newest history line). No -J:
407/// lines stay wrapped at pane width so they re-parse at the same columns.
408pub async fn capture_history(session_id: &str, start: i64, end: i64) -> Vec<u8> {
409    run_session_scoped(&[
410        "capture-pane", "-p", "-e", "-t", session_id,
411        "-S", &start.to_string(), "-E", &end.to_string(),
412    ])
413    .await
414    .map(|s| s.into_bytes())
415    .unwrap_or_default()
416}
417
418/// Send text to a tmux session as if typed at the keyboard.
419/// The text is followed by Enter so the agent receives and acts on it.
420/// Uses `tmux send-keys -l` (literal mode) to avoid tmux interpreting
421/// special characters like `{`, `}`, arrows.
422pub async fn send_keys(session_id: &str, text: &str) -> Result<()> {
423    // Send the message text in literal mode
424    run_session_scoped(&["send-keys", "-t", session_id, "-l", text]).await?;
425    // Send Enter to submit
426    run_session_scoped(&["send-keys", "-t", session_id, "Enter"]).await?;
427    Ok(())
428}
429
430/// Write `bytes` to the session's master PTY via tmux's paste-buffer
431/// mechanism — the supported way to inject raw input (as opposed to
432/// `send-keys`, which tmux may reinterpret). `tmp_path` is a scratch file
433/// used to stage the buffer contents and is removed afterwards regardless
434/// of outcome.
435pub async fn paste_buffer(session_id: &str, buf_name: &str, tmp_path: &str, bytes: &[u8]) -> Result<()> {
436    std::fs::write(tmp_path, bytes)?;
437    let result = run_session_scoped(&[
438        "load-buffer", "-b", buf_name, tmp_path, ";",
439        "paste-buffer", "-b", buf_name, "-t", session_id, "-d",
440    ]).await;
441    let _ = std::fs::remove_file(tmp_path);
442    result.map(|_| ())
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use tokio::time::{sleep, Duration};
449
450    fn tmux_available() -> bool {
451        std::process::Command::new("tmux")
452            .args(["-V"])
453            .output()
454            .map(|o| o.status.success())
455            .unwrap_or(false)
456    }
457
458    fn unique_id() -> String {
459        // Millis alone collide when parallel test threads start within the
460        // same tick, producing duplicate tmux session names; a per-process
461        // counter guarantees uniqueness regardless of clock resolution (see
462        // the same fix in client.rs's unique_id).
463        static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
464        let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
465        format!(
466            "test-{}-{n}",
467            std::time::SystemTime::now()
468                .duration_since(std::time::UNIX_EPOCH)
469                .unwrap()
470                .as_millis()
471        )
472    }
473
474    /// The whole point of `is_test_binary`: every test in this suite must
475    /// resolve to the isolated socket, never the real app's `"ninox"` — this
476    /// is what stops `cargo test` (run by a developer with the real app open)
477    /// from ever touching, or a stale-session cleanup from ever killing, a
478    /// live session the user is actually using.
479    #[test]
480    fn tests_resolve_to_the_isolated_socket_not_the_real_apps() {
481        assert!(is_test_binary(), "the test binary itself must be detected as a test binary");
482        assert_eq!(socket(), "ninox-test");
483        assert_ne!(socket(), "ninox");
484    }
485
486    #[tokio::test]
487    async fn create_and_has_and_kill() {
488        if !tmux_available() { return; }
489        let id = unique_id();
490        create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
491        assert!(has_session(&id).await);
492        kill_session(&id).await.unwrap();
493        assert!(!has_session(&id).await);
494    }
495
496    #[tokio::test]
497    async fn list_includes_created() {
498        if !tmux_available() { return; }
499        let id = unique_id();
500        create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
501        let sessions = list_sessions().await.unwrap();
502        assert!(sessions.iter().any(|s| s.id == id));
503        kill_session(&id).await.unwrap();
504    }
505
506    #[tokio::test]
507    async fn get_pane_tty_returns_dev_path() {
508        if !tmux_available() { return; }
509        let id = unique_id();
510        create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
511        let tty = get_pane_tty(&id).await.unwrap();
512        assert!(tty.map(|t| t.starts_with("/dev/")).unwrap_or(false));
513        kill_session(&id).await.unwrap();
514    }
515
516    #[tokio::test]
517    async fn send_keys_builds_correct_command() {
518        // This test validates our argument construction without actually calling tmux.
519        // We test the shell_quote helper used by send_keys.
520        let quoted = shell_quote("hello world");
521        assert_eq!(quoted, "'hello world'");
522        let with_apostrophe = shell_quote("don't");
523        assert_eq!(with_apostrophe, "'don'\\''t'");
524    }
525
526    #[test]
527    fn server_config_is_written_and_contains_required_settings() {
528        let path = write_server_config().unwrap();
529        let body = std::fs::read_to_string(&path).unwrap();
530        for required in [
531            "default-terminal \"tmux-256color\"",
532            "extended-keys always",
533            "history-limit 100000",
534            "status off",
535            "window-size latest",
536            "allow-passthrough on",
537            "exit-empty off",
538        ] {
539            assert!(body.contains(required), "config missing {required:?}\n{body}");
540        }
541        // extended-keys-format requires tmux >= 3.5 (older tmux rejects the
542        // option outright); the written config must match what's installed.
543        if supports_extended_keys_format(detected_version_sync()) {
544            assert!(body.contains("extended-keys-format csi-u"),
545                    "config missing extended-keys-format on tmux >= 3.5\n{body}");
546        } else {
547            assert!(!body.contains("extended-keys-format"),
548                    "config must omit extended-keys-format on tmux < 3.5 (rejected as invalid)\n{body}");
549        }
550    }
551
552    #[tokio::test]
553    async fn require_version_passes_on_installed_tmux() {
554        if !tmux_available() { return; }
555        require_version().await.unwrap();
556    }
557
558    #[tokio::test]
559    async fn sessions_are_created_on_the_ninox_socket() {
560        if !tmux_available() { return; }
561        let id = unique_id();
562        create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
563        // Visible via -L ninox …
564        assert!(has_session(&id).await);
565        // … and NOT on the default socket.
566        let default_out = std::process::Command::new("tmux")
567            .args(["has-session", "-t", &id])
568            .output()
569            .unwrap();
570        assert!(!default_out.status.success(), "session leaked onto the default socket");
571        kill_session(&id).await.unwrap();
572    }
573
574    #[tokio::test]
575    async fn legacy_default_socket_sessions_are_still_reachable() {
576        if !tmux_available() { return; }
577        let id = unique_id();
578        // Simulate a session created by an older build: default socket, no -L.
579        let st = std::process::Command::new("tmux")
580            .args(["new-session", "-d", "-s", &id, "-x", "80", "-y", "24", "sleep 30"])
581            .status()
582            .unwrap();
583        assert!(st.success());
584        assert!(has_session(&id).await, "has_session must fall back to the default socket");
585        let argv = attach_args(&id).await;
586        assert!(!argv.contains(&"-L".to_string()), "legacy session must attach without -L: {argv:?}");
587        kill_session(&id).await.unwrap(); // must kill on the default socket too
588        assert!(!has_session(&id).await);
589    }
590
591    #[tokio::test]
592    async fn capture_history_returns_scrolled_off_lines() {
593        if !tmux_available() { return; }
594        let id = unique_id();
595        // 50-row pane; print 80 numbered lines so the earliest ones scroll into history.
596        create_session(&id, "/tmp", "bash -c 'for i in $(seq 1 80); do echo line-$i; done; sleep 30'", &[]).await.unwrap();
597        sleep(Duration::from_millis(500)).await;
598        let hist = history_size(&id).await;
599        assert!(hist > 0, "expected history to accumulate, got {hist}");
600        let bytes = capture_history(&id, -hist, -1).await;
601        let text = String::from_utf8_lossy(&bytes);
602        assert!(text.contains("line-1"), "oldest line missing from history capture: {text}");
603        kill_session(&id).await.unwrap();
604    }
605}