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    // tmux does NOT fail on a nonexistent `-c` dir (verified on 3.6a: exit 0,
275    // pane silently starts in $HOME). For harnesses with cwd-scoped state —
276    // claude-code keys conversations to the directory it starts in — that
277    // fallback breaks `--resume` with "No conversation found with session
278    // ID". Fail loudly here instead.
279    anyhow::ensure!(
280        std::path::Path::new(workspace).is_dir(),
281        "workspace directory does not exist: {workspace}"
282    );
283    // Build -e KEY=VALUE pairs
284    let mut env_pairs: Vec<String> = Vec::new();
285    for (k, v) in env {
286        anyhow::ensure!(!k.contains('='), "env key must not contain '=': {k}");
287        env_pairs.push(format!("{k}={v}"));
288    }
289    let mut extra: Vec<&str> = Vec::new();
290    for pair in &env_pairs {
291        // Values are passed as separate argv tokens via execve — no shell quoting needed.
292        extra.push("-e");
293        extra.push(pair.as_str());
294    }
295
296    // Wrap the command in a login shell so the full user PATH is available.
297    // tmux sessions do not inherit shell rc files, so tools installed via
298    // nvm / cargo / homebrew etc. would not be found otherwise.
299    let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
300    let shell_cmd = format!("{shell} -l -c {}", shell_quote(cmd));
301
302    // Fix the terminal dimensions to match the canvas.  The canvas is roughly
303    // (window_width - 220px sidebar) / 7.8px_per_col ≈ 135 cols on a 1280-wide
304    // window.  Use 140 as a safe default; too-wide values push Claude Code's
305    // centered content off-screen.
306    let mut base = vec!["new-session", "-d", "-s", id, "-x", "140", "-y", "50", "-c", workspace];
307    base.extend_from_slice(&extra);
308    base.push(&shell_cmd);
309
310    // A duplicate name means a LIVE tmux session already exists under this
311    // id. Killing it to make room (the old behavior) silently destroys a
312    // running agent whenever the store and tmux disagree about what exists —
313    // surface the conflict to the caller instead; the spawn UI shows it.
314    run(&base).await.map(|_| ())
315}
316
317/// Kill a tmux session.  Succeeds even if the session doesn't exist.
318/// `run_session_scoped` falls back to the default socket when the session
319/// isn't found on the ninox server, so legacy sessions are killed there too.
320pub async fn kill_session(id: &str) -> Result<()> {
321    match run_session_scoped(&["kill-session", "-t", id]).await {
322        Ok(_) => Ok(()),
323        Err(e) => {
324            if is_missing_session(&e) {
325                Ok(())
326            } else {
327                Err(e)
328            }
329        }
330    }
331}
332
333/// Returns `true` if a tmux session with this name is currently running.
334pub async fn has_session(id: &str) -> bool {
335    run_session_scoped(&["has-session", "-t", id]).await.is_ok()
336}
337
338/// List every live tmux session.  Sessions on the ninox server are listed
339/// first; legacy sessions on the default server are appended for any id not
340/// already seen (ninox socket wins on conflicts).
341pub async fn list_sessions() -> Result<Vec<TmuxSession>> {
342    // A literal tab column separator is mangled by tmux's -F formatter on
343    // older tmux (verified: tmux 3.4 rewrites an embedded tab byte in a -F
344    // template to `_` in the output, so every field collapses into one;
345    // tmux 3.6 passes it through untouched). `|` survives on both and can't
346    // appear in any of these fields (ninox controls session-name shape;
347    // the rest are numeric or a `/dev/...` path).
348    const SEP: &str = "|";
349    let fmt = format!("#{{session_name}}{SEP}#{{session_created}}{SEP}#{{pane_pid}}{SEP}#{{pane_tty}}");
350    let ninox_raw = run_best_effort(&["list-sessions", "-F", &fmt]).await;
351    let default_raw = run_best_effort_default(&["list-sessions", "-F", &fmt]).await;
352
353    let mut seen = std::collections::HashSet::new();
354    let mut sessions = Vec::new();
355    for raw in [ninox_raw, default_raw] {
356        for line in raw.lines().filter(|l| !l.is_empty()) {
357            let mut cols = line.splitn(4, SEP);
358            let Some(id) = cols.next().map(str::to_string) else { continue };
359            if !seen.insert(id.clone()) {
360                continue;
361            }
362            let sec = cols.next().and_then(|s| s.parse::<i64>().ok()).unwrap_or(0);
363            let pid = cols.next().and_then(|s| s.parse::<u32>().ok());
364            let tty = cols.next().map(str::to_string).filter(|s| !s.is_empty());
365            sessions.push(TmuxSession { id, created_ms: sec * 1000, pid, tty });
366        }
367    }
368    Ok(sessions)
369}
370
371/// Return the tty device path (e.g. `/dev/ttys003`) for the session's active pane.
372pub async fn get_pane_tty(id: &str) -> Result<Option<String>> {
373    let out = run_session_scoped(&["list-panes", "-t", id, "-F", "#{pane_tty}"]).await?;
374    Ok(out
375        .lines()
376        .next()
377        .map(|s| s.trim().to_string())
378        .filter(|s| !s.is_empty()))
379}
380
381/// Start piping pane output to `dest_path` (regular file, not FIFO).
382/// Does NOT use `-o` so it force-restarts any existing pipe — required for reconnect.
383pub async fn pipe_pane(id: &str, dest_path: &str) -> Result<()> {
384    run_session_scoped(&["pipe-pane", "-t", id, &format!("cat > {}", shell_quote(dest_path))]).await?;
385    Ok(())
386}
387
388/// Full argv for `tmux attach` for this session (element 0 is "tmux"),
389/// resolving whether it lives on the ninox or the legacy default server.
390pub async fn attach_args(session_id: &str) -> Vec<String> {
391    let mut argv = vec!["tmux".to_string()];
392    if run(&["has-session", "-t", session_id]).await.is_ok() {
393        argv.extend(socket_args());
394    } else {
395        tracing::warn!(
396            "session {session_id} predates the ninox socket — attaching on the \
397             legacy default tmux server without the managed config (extended \
398             keys / resize guarantees are degraded until it terminates naturally)"
399        );
400    }
401    argv.extend(["attach-session", "-t", session_id].map(String::from));
402    argv
403}
404
405/// Number of scrolled-off lines tmux holds for this pane.
406pub async fn history_size(session_id: &str) -> i64 {
407    run_session_scoped(&["display-message", "-p", "-t", session_id, "#{history_size}"])
408        .await
409        .ok()
410        .and_then(|s| s.trim().parse().ok())
411        .unwrap_or(0)
412}
413
414/// Capture styled pane content for the line range [start, end], where
415/// negative indices address history (-1 = newest history line). No -J:
416/// lines stay wrapped at pane width so they re-parse at the same columns.
417pub async fn capture_history(session_id: &str, start: i64, end: i64) -> Vec<u8> {
418    run_session_scoped(&[
419        "capture-pane", "-p", "-e", "-t", session_id,
420        "-S", &start.to_string(), "-E", &end.to_string(),
421    ])
422    .await
423    .map(|s| s.into_bytes())
424    .unwrap_or_default()
425}
426
427/// A burst of injected characters makes Claude Code's TUI enter paste
428/// handling; an Enter arriving before that settles is swallowed and the
429/// message sits unsubmitted in the input box. Wait this long before Enter.
430const SEND_SUBMIT_DELAY_MS: u64 = 300;
431/// After Enter, re-check delivery this many times, this far apart,
432/// re-sending Enter whenever the message is still visible at the prompt.
433const SEND_VERIFY_ATTEMPTS: u32 = 3;
434const SEND_VERIFY_DELAY_MS: u64 = 500;
435
436/// Send text to a tmux session as if typed at the keyboard.
437/// The text is followed by Enter so the agent receives and acts on it.
438/// Uses `tmux send-keys -l` (literal mode) to avoid tmux interpreting
439/// special characters like `{`, `}`, arrows.
440///
441/// Delivery is verified: if the message is still sitting unsubmitted at the
442/// pane's `❯` input prompt (the stuck-`[Pasted text #N]` failure mode),
443/// Enter is re-sent up to `SEND_VERIFY_ATTEMPTS` times, and exhausting the
444/// retries is an error so callers know the target never saw the message.
445/// Panes that give no signal (no `❯` prompt, unrelated prompt content) are
446/// treated as delivered — a retry Enter must never fire at a human's
447/// half-typed input.
448pub async fn send_keys(session_id: &str, text: &str) -> Result<()> {
449    // Send the message text in literal mode
450    run_session_scoped(&["send-keys", "-t", session_id, "-l", text]).await?;
451    // Let the TUI finish paste processing before submitting.
452    tokio::time::sleep(std::time::Duration::from_millis(SEND_SUBMIT_DELAY_MS)).await;
453    run_session_scoped(&["send-keys", "-t", session_id, "Enter"]).await?;
454
455    // Every Enter — the initial one and each retry — gets its own
456    // verification pass, so a submission by the final retry is still
457    // reported as success (a false "stuck" error invites a re-send,
458    // which double-delivers).
459    for attempt in 0..=SEND_VERIFY_ATTEMPTS {
460        tokio::time::sleep(std::time::Duration::from_millis(SEND_VERIFY_DELAY_MS)).await;
461        if !message_stuck_at_prompt(&capture_visible_plain(session_id).await, text) {
462            return Ok(());
463        }
464        if attempt < SEND_VERIFY_ATTEMPTS {
465            run_session_scoped(&["send-keys", "-t", session_id, "Enter"]).await?;
466        }
467    }
468    anyhow::bail!(
469        "message to {session_id} is still unsubmitted at its input prompt \
470         after {SEND_VERIFY_ATTEMPTS} Enter retries"
471    )
472}
473
474/// Plain-text capture of the pane's visible contents — no `-e`, so the
475/// output carries no escape sequences and can be string-matched.
476async fn capture_visible_plain(session_id: &str) -> String {
477    run_session_scoped(&["capture-pane", "-p", "-t", session_id])
478        .await
479        .unwrap_or_default()
480}
481
482/// The trimmed content after the pane's last `❯` input-prompt line, with the
483/// input box's right border/padding stripped (`  msg   │` → `msg`). `None`
484/// when no prompt line is visible at all (plain shell, alt-screen app) —
485/// distinct from `Some("")`, an empty-but-present input box. Shared by
486/// [`message_stuck_at_prompt`] and [`wake_idle_session`].
487fn prompt_line_content(pane: &str) -> Option<&str> {
488    const PROMPT: char = '❯';
489    let line = pane.lines().rev().find(|l| l.contains(PROMPT))?;
490    let after = &line[line.rfind(PROMPT).unwrap() + PROMPT.len_utf8()..];
491    // Strip the input box's right border and padding: `  msg   │`.
492    Some(after.trim().trim_end_matches('│').trim())
493}
494
495/// Whether a message we just injected is sitting unsubmitted in the target
496/// pane's input box. Looks at the last line holding the `❯` input prompt:
497/// stuck means it shows a `[Pasted text #N]` attachment marker or the
498/// message text itself (prefix-matched both ways, since the box truncates
499/// long lines). Anything else after the prompt — empty box, a placeholder
500/// hint, a human's half-typed message — is NOT ours to submit, so this
501/// stays false and no retry Enter is ever sent at it.
502fn message_stuck_at_prompt(pane: &str, text: &str) -> bool {
503    let Some(content) = prompt_line_content(pane) else {
504        return false;
505    };
506    if content.is_empty() {
507        return false;
508    }
509    if content.starts_with("[Pasted text") {
510        return true;
511    }
512    let first_line = text.lines().next().unwrap_or("").trim();
513    if first_line.is_empty() {
514        return false;
515    }
516    if content.starts_with(first_line) {
517        return true;
518    }
519    // Truncated stuck message: the box cuts long lines at the pane edge,
520    // so the visible content is a leading fragment of the message. Require
521    // it to be long enough to be distinctive — every reaction starts with
522    // `[Ninox]`, and a human who has typed `[` (or `[Ninox] C`) when this
523    // check runs must not have their unfinished input Enter'd for them. A
524    // genuinely truncated line is pane-width, far above this floor.
525    const MIN_TRUNCATED_MATCH_CHARS: usize = 10;
526    content.chars().count() >= MIN_TRUNCATED_MATCH_CHARS && first_line.starts_with(content)
527}
528
529/// Marker text for [`wake_idle_session`]'s idle-wake nudge — never the
530/// actual message, which (when the opt-in file-based inbox is enabled) is
531/// delivered entirely through the Stop/UserPromptSubmit hooks' JSON output,
532/// not the keystroke. Kept short and distinctive so it can never collide
533/// with real content a human might be mid-typing.
534const IDLE_WAKE_NUDGE: &str = ".";
535
536/// Whether the pane's prompt currently holds ONLY the idle-wake nudge
537/// marker — nothing added, nothing removed. Used both right before sending
538/// Enter and (implicitly, by its absence) to detect that Enter already
539/// submitted cleanly.
540fn nudge_still_alone_at_prompt(pane: &str) -> bool {
541    prompt_line_content(pane) == Some(IDLE_WAKE_NUDGE)
542}
543
544/// Wake an idle session so its Stop/UserPromptSubmit hooks run and drain
545/// the file-based inbox (see `ninox_core::inbox`) sooner than the next
546/// natural turn boundary. Only meaningful when that opt-in inbox is
547/// enabled — the message itself never travels through this keystroke. For
548/// an idle session this nudge IS the only delivery trigger there is (no
549/// Stop/UserPromptSubmit fires on its own until something wakes it) — see
550/// `crate::messaging::deliver_message`'s doc comment for the resulting
551/// guarantee (best-effort, not "will always eventually drain").
552///
553/// Skips sending anything unless the pane's input prompt is visibly present
554/// AND empty: a human mid-typing, or no visible prompt at all (alt-screen,
555/// still rendering), must never be clobbered by a nudge. The one exception
556/// is the prompt holding EXACTLY our own nudge marker and nothing else —
557/// see the stale-nudge paragraph below.
558///
559/// Deliberately does NOT reuse [`send_keys`] wholesale: that function sends
560/// Enter unconditionally `SEND_SUBMIT_DELAY_MS` after the literal text, with
561/// no re-check of what else may have landed at the prompt in that window —
562/// fine for a real message the caller explicitly wants delivered, but not
563/// for a nudge whose entire point is to never touch a human's typing. After
564/// typing the nudge, this re-verifies the prompt still holds ONLY the nudge
565/// — nothing else — immediately before pressing Enter; if a human started
566/// typing in that window, Enter is withheld entirely (their in-progress
567/// input is left alone, nudge character included, rather than risking it
568/// being submitted on our behalf).
569///
570/// Withholding Enter that way creates its own hazard if left unhandled: the
571/// pane is then left showing just our nudge character, un-submitted. A
572/// later call would see that as "prompt has content" and skip entirely —
573/// forever, since nothing else will ever clear a marker only we would type.
574/// That would permanently wedge delivery to an idle session behind a single
575/// lost race. Closed by treating "prompt holds exactly our nudge marker" as
576/// recognizably OUR OWN leftover from a previous attempt rather than a
577/// human's input: submit it immediately (verified Enter) instead of typing
578/// a fresh one. Losing today's race is still not a delivery failure — the
579/// message is already durably written to the inbox file — so no retry loop
580/// is needed for the fresh-nudge path either.
581pub async fn wake_idle_session(session_id: &str) -> Result<()> {
582    let pane = capture_visible_plain(session_id).await;
583    match prompt_line_content(&pane) {
584        Some("") => {} // proceed to type a fresh nudge below
585        Some(IDLE_WAKE_NUDGE) => {
586            // Our own nudge, left over un-submitted from a previous call —
587            // submit it now rather than treating the box as permanently
588            // occupied.
589            return run_session_scoped(&["send-keys", "-t", session_id, "Enter"]).await.map(|_| ());
590        }
591        _ => return Ok(()), // genuinely occupied by something else
592    }
593
594    run_session_scoped(&["send-keys", "-t", session_id, "-l", IDLE_WAKE_NUDGE]).await?;
595    tokio::time::sleep(std::time::Duration::from_millis(SEND_SUBMIT_DELAY_MS)).await;
596
597    if !nudge_still_alone_at_prompt(&capture_visible_plain(session_id).await) {
598        return Ok(());
599    }
600    run_session_scoped(&["send-keys", "-t", session_id, "Enter"]).await.map(|_| ())
601}
602
603/// Write `bytes` to the session's master PTY via tmux's paste-buffer
604/// mechanism — the supported way to inject raw input (as opposed to
605/// `send-keys`, which tmux may reinterpret). `tmp_path` is a scratch file
606/// used to stage the buffer contents and is removed afterwards regardless
607/// of outcome.
608pub async fn paste_buffer(session_id: &str, buf_name: &str, tmp_path: &str, bytes: &[u8]) -> Result<()> {
609    std::fs::write(tmp_path, bytes)?;
610    let result = run_session_scoped(&[
611        "load-buffer", "-b", buf_name, tmp_path, ";",
612        "paste-buffer", "-b", buf_name, "-t", session_id, "-d",
613    ]).await;
614    let _ = std::fs::remove_file(tmp_path);
615    result.map(|_| ())
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use tokio::time::{sleep, Duration};
622
623    fn tmux_available() -> bool {
624        std::process::Command::new("tmux")
625            .args(["-V"])
626            .output()
627            .map(|o| o.status.success())
628            .unwrap_or(false)
629    }
630
631    fn unique_id() -> String {
632        // Millis alone collide when parallel test threads start within the
633        // same tick, producing duplicate tmux session names; a per-process
634        // counter guarantees uniqueness regardless of clock resolution (see
635        // the same fix in client.rs's unique_id).
636        static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
637        let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
638        format!(
639            "test-{}-{n}",
640            std::time::SystemTime::now()
641                .duration_since(std::time::UNIX_EPOCH)
642                .unwrap()
643                .as_millis()
644        )
645    }
646
647    /// The whole point of `is_test_binary`: every test in this suite must
648    /// resolve to the isolated socket, never the real app's `"ninox"` — this
649    /// is what stops `cargo test` (run by a developer with the real app open)
650    /// from ever touching, or a stale-session cleanup from ever killing, a
651    /// live session the user is actually using.
652    #[test]
653    fn tests_resolve_to_the_isolated_socket_not_the_real_apps() {
654        assert!(is_test_binary(), "the test binary itself must be detected as a test binary");
655        assert_eq!(socket(), "ninox-test");
656        assert_ne!(socket(), "ninox");
657    }
658
659    #[tokio::test]
660    async fn create_session_fails_when_workspace_dir_is_missing() {
661        if !tmux_available() { return; }
662        let id = unique_id();
663        // tmux 3.6a exits 0 for `new-session -c <missing-dir>` and silently
664        // starts the pane in $HOME instead — which made `claude --resume`
665        // fail with "No conversation found with session ID" (conversations
666        // are cwd-scoped). create_session must fail loudly instead.
667        let result = create_session(&id, "/definitely/not/a/real/dir", "sleep 30", &[]).await;
668        assert!(result.is_err(), "missing workspace must be an error, not a silent $HOME fallback");
669        assert!(!has_session(&id).await, "no session may be left behind on failure");
670    }
671
672    #[tokio::test]
673    async fn create_and_has_and_kill() {
674        if !tmux_available() { return; }
675        let id = unique_id();
676        create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
677        assert!(has_session(&id).await);
678        kill_session(&id).await.unwrap();
679        assert!(!has_session(&id).await);
680    }
681
682    #[tokio::test]
683    async fn list_includes_created() {
684        if !tmux_available() { return; }
685        let id = unique_id();
686        create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
687        let sessions = list_sessions().await.unwrap();
688        assert!(sessions.iter().any(|s| s.id == id));
689        kill_session(&id).await.unwrap();
690    }
691
692    #[tokio::test]
693    async fn get_pane_tty_returns_dev_path() {
694        if !tmux_available() { return; }
695        let id = unique_id();
696        create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
697        let tty = get_pane_tty(&id).await.unwrap();
698        assert!(tty.map(|t| t.starts_with("/dev/")).unwrap_or(false));
699        kill_session(&id).await.unwrap();
700    }
701
702    // ── message_stuck_at_prompt ─────────────────────────────────────────
703    //
704    // Fixtures mirror real Claude Code pane captures: transcript above, a
705    // bordered input box with a `❯` prompt at the bottom.
706
707    const REACTION: &str = "[Ninox] CI is failing on your PR (1/3 checks). Please fix the following:\n  - build\n\nRun the failing checks locally, fix the issues, and push your changes.";
708
709    #[test]
710    fn stuck_when_pasted_text_marker_sits_at_prompt() {
711        let pane = "\
712● Working on the auth refactor now.
713
714╭──────────────────────────────────────────────╮
715│ ❯ [Pasted text #1 +4 lines]                  │
716╰──────────────────────────────────────────────╯
717  ? for shortcuts";
718        assert!(message_stuck_at_prompt(pane, REACTION));
719    }
720
721    #[test]
722    fn stuck_when_message_text_sits_at_prompt() {
723        let pane = "\
724╭──────────────────────────────────────────────────────╮
725│ ❯ [Ninox] Worker `auth-fix`'s PR #12 merged.         │
726╰──────────────────────────────────────────────────────╯";
727        assert!(message_stuck_at_prompt(pane, "[Ninox] Worker `auth-fix`'s PR #12 merged."));
728    }
729
730    /// The input box truncates long lines at the pane edge — a visible
731    /// prefix of the message still counts as stuck.
732    #[test]
733    fn stuck_when_prompt_shows_truncated_prefix_of_message() {
734        let pane = "│ ❯ [Ninox] CI is failing on your PR (1/3 che│";
735        assert!(message_stuck_at_prompt(pane, REACTION));
736    }
737
738    /// A submitted message echoes into the transcript ABOVE an empty input
739    /// box — only the last prompt line may be consulted, or every
740    /// successful send would look stuck.
741    #[test]
742    fn not_stuck_when_prompt_empty_and_message_echoed_in_transcript() {
743        let pane = "\
744❯ [Ninox] Worker `auth-fix`'s PR #12 merged.
745
746● Noted — spawning the follow-up worker.
747
748╭──────────────────────────────────────────────╮
749│ ❯                                            │
750╰──────────────────────────────────────────────╯";
751        assert!(!message_stuck_at_prompt(pane, "[Ninox] Worker `auth-fix`'s PR #12 merged."));
752    }
753
754    /// A human's half-typed message (or a placeholder hint) at the prompt
755    /// must never be treated as ours — a retry Enter here would submit
756    /// their unfinished message.
757    #[test]
758    fn not_stuck_when_prompt_holds_unrelated_text() {
759        let pane = "│ ❯ can you also update the readme while     │";
760        assert!(!message_stuck_at_prompt(pane, REACTION));
761    }
762
763    /// Panes without the Claude input prompt (plain shells, alt-screen
764    /// apps) give no signal — treat as delivered, never retry blindly.
765    #[test]
766    fn not_stuck_when_pane_has_no_prompt_marker() {
767        let pane = "$ echo hi\nhi\n$";
768        assert!(!message_stuck_at_prompt(pane, "echo hi"));
769    }
770
771    /// The reverse prefix match (truncated stuck message) must require
772    /// enough characters to be distinctive. Every reaction starts with
773    /// `[Ninox]`, so a human who happens to have typed `[` — or even
774    /// `[Ninox] C` — when the verify pass runs must not have their
775    /// unfinished input submitted by a retry Enter.
776    #[test]
777    fn not_stuck_when_prompt_holds_only_a_short_prefix_of_message() {
778        for typed in ["[", "[Ninox] C"] {
779            let pane = format!("│ ❯ {typed}                                   │");
780            assert!(
781                !message_stuck_at_prompt(&pane, REACTION),
782                "short prompt content {typed:?} must not count as our stuck message",
783            );
784        }
785    }
786
787    // ── prompt_line_content / wake_idle_session ─────────────────────────
788
789    #[test]
790    fn prompt_line_content_distinguishes_missing_from_empty_prompt() {
791        assert_eq!(prompt_line_content("$ echo hi\nhi\n$"), None);
792        assert_eq!(prompt_line_content("│ ❯                                            │"), Some(""));
793        assert_eq!(
794            prompt_line_content("│ ❯ hello there                                │"),
795            Some("hello there"),
796        );
797    }
798
799    #[test]
800    fn nudge_still_alone_at_prompt_true_when_only_the_nudge_is_present() {
801        let pane = "│ ❯ .                                          │";
802        assert!(nudge_still_alone_at_prompt(pane));
803    }
804
805    /// A human who started typing during the pre-Enter delay must fail
806    /// this check — this is the exact signal `wake_idle_session` uses to
807    /// withhold Enter rather than submit their partial input.
808    #[test]
809    fn nudge_still_alone_at_prompt_false_once_more_text_is_appended() {
810        let pane = "│ ❯ .oops I started typing                    │";
811        assert!(!nudge_still_alone_at_prompt(pane));
812    }
813
814    #[test]
815    fn nudge_still_alone_at_prompt_false_once_the_prompt_is_empty_again() {
816        // Either it already submitted cleanly, or something else cleared
817        // the box — either way it's no longer "just the nudge".
818        let pane = "│ ❯                                            │";
819        assert!(!nudge_still_alone_at_prompt(pane));
820    }
821
822    #[test]
823    fn nudge_still_alone_at_prompt_false_when_no_prompt_is_visible() {
824        assert!(!nudge_still_alone_at_prompt("$ echo hi\nhi\n$"));
825    }
826
827    /// A human mid-typing (or any other non-empty prompt content) must
828    /// never be clobbered by an idle-wake nudge.
829    #[tokio::test]
830    async fn wake_idle_session_skips_when_prompt_has_content() {
831        if !tmux_available() { return; }
832        let id = unique_id();
833        create_session(&id, "/tmp", "cat", &[]).await.unwrap();
834        sleep(Duration::from_millis(300)).await;
835        // Simulate a non-empty prompt directly (bypassing send_keys's own
836        // verify loop — this test only exercises wake_idle_session's
837        // pre-check).
838        run_session_scoped(&["send-keys", "-t", &id, "-l", "❯ half-typed message"]).await.unwrap();
839        run_session_scoped(&["send-keys", "-t", &id, "Enter"]).await.unwrap();
840        wait_for_pane_contains(&id, "half-typed message").await;
841
842        wake_idle_session(&id).await.unwrap();
843        sleep(Duration::from_millis(300)).await;
844
845        let pane = capture_visible_plain(&id).await;
846        assert!(
847            !pane.lines().any(|l| l.trim() == IDLE_WAKE_NUDGE),
848            "must never nudge over a non-empty prompt: {pane}"
849        );
850        kill_session(&id).await.unwrap();
851    }
852
853    /// A visibly empty prompt is exactly when the nudge must fire.
854    #[tokio::test]
855    async fn wake_idle_session_sends_nudge_when_prompt_is_empty() {
856        if !tmux_available() { return; }
857        let id = unique_id();
858        create_session(&id, "/tmp", "cat", &[]).await.unwrap();
859        sleep(Duration::from_millis(300)).await;
860        // Simulate an empty Claude Code input prompt: literal text with NO
861        // Enter, so the cursor stays on this exact line — matching how a
862        // real redrawing TUI box keeps typed characters on the same line
863        // as ❯ rather than advancing to a fresh line the way Enter would.
864        run_session_scoped(&["send-keys", "-t", &id, "-l", "❯ "]).await.unwrap();
865        wait_for_pane_contains(&id, "❯").await;
866
867        wake_idle_session(&id).await.unwrap();
868
869        // Enter must have been pressed: cat processes the completed line
870        // and echoes it back once it receives the newline.
871        wait_for_pane_contains(&id, "❯ .").await;
872        kill_session(&id).await.unwrap();
873    }
874
875    /// Regression test for the stall this closes: if a previous
876    /// `wake_idle_session` call typed the nudge but had Enter withheld
877    /// (e.g. it lost the pre-Enter recapture race), the box is left
878    /// showing just the nudge marker, un-submitted. A naive re-application
879    /// of the "prompt must be empty" check would see that as permanently
880    /// occupied and skip forever — wedging every future delivery attempt
881    /// to this idle session behind one lost race. Recognizing "prompt
882    /// holds exactly our own marker" as ours to submit closes that.
883    #[tokio::test]
884    async fn wake_idle_session_submits_its_own_stale_nudge_instead_of_stalling_forever() {
885        if !tmux_available() { return; }
886        let id = unique_id();
887        create_session(&id, "/tmp", "cat", &[]).await.unwrap();
888        sleep(Duration::from_millis(300)).await;
889        let marker_line = format!("❯ {IDLE_WAKE_NUDGE}");
890        // Simulate a previous attempt that typed the nudge but never
891        // pressed Enter — no Enter here either, so the cursor stays put.
892        run_session_scoped(&["send-keys", "-t", &id, "-l", &marker_line]).await.unwrap();
893        wait_for_pane_contains(&id, &marker_line).await;
894
895        wake_idle_session(&id).await.unwrap();
896        sleep(Duration::from_millis(300)).await;
897
898        // Submission must have happened immediately (no fresh nudge typed,
899        // no delay): cat echoes the completed line, so the marker line
900        // now appears twice (once from the still-visible original typing,
901        // once from cat's echo of what it read on Enter).
902        let pane = capture_visible_plain(&id).await;
903        let occurrences = pane.lines().filter(|l| l.trim() == marker_line).count();
904        assert_eq!(
905            occurrences, 2,
906            "a stale nudge left over from a previous attempt must be submitted, \
907             not left stuck (which would wedge every future delivery): {pane}"
908        );
909        kill_session(&id).await.unwrap();
910    }
911
912    /// The core race this hardens against: a human starts typing during the
913    /// nudge's own pre-Enter delay window. Injects extra keystrokes into
914    /// the same line ~100ms after the nudge lands (well inside
915    /// `SEND_SUBMIT_DELAY_MS` = 300ms) and asserts Enter was withheld: the
916    /// accumulated line must still be sitting unsubmitted, not echoed back
917    /// by cat as a completed transcript line (which would happen twice —
918    /// once from the live tty echo of typing, once from cat's own echo of
919    /// what it read — if Enter had wrongly been sent).
920    #[tokio::test]
921    async fn wake_idle_session_withholds_enter_if_typing_starts_during_the_delay() {
922        if !tmux_available() { return; }
923        let id = unique_id();
924        create_session(&id, "/tmp", "cat", &[]).await.unwrap();
925        sleep(Duration::from_millis(300)).await;
926        run_session_scoped(&["send-keys", "-t", &id, "-l", "❯ "]).await.unwrap();
927        wait_for_pane_contains(&id, "❯").await;
928
929        let id2 = id.clone();
930        let racer = tokio::spawn(async move {
931            sleep(Duration::from_millis(100)).await;
932            run_session_scoped(&["send-keys", "-t", &id2, "-l", "oops"]).await.unwrap();
933        });
934        wake_idle_session(&id).await.unwrap();
935        racer.await.unwrap();
936        sleep(Duration::from_millis(500)).await; // let any wrongly-sent Enter's effects settle
937
938        let pane = capture_visible_plain(&id).await;
939        let occurrences = pane.lines().filter(|l| l.contains("oops")).count();
940        assert_eq!(
941            occurrences, 1,
942            "the human's typing must never be submitted on our behalf: {pane}"
943        );
944        kill_session(&id).await.unwrap();
945    }
946
947    /// Happy path against a real pane: text lands and the call succeeds —
948    /// the verification pass must not false-positive on a ❯-less pane.
949    #[tokio::test]
950    async fn send_keys_delivers_to_a_plain_pane() {
951        if !tmux_available() { return; }
952        let id = unique_id();
953        create_session(&id, "/tmp", "cat", &[]).await.unwrap();
954        sleep(Duration::from_millis(300)).await;
955        send_keys(&id, "hello from ninox").await.unwrap();
956        let pane = capture_visible_plain(&id).await;
957        assert!(pane.contains("hello from ninox"), "pane: {pane}");
958        kill_session(&id).await.unwrap();
959    }
960
961    /// Poll until the session's pane shows `needle` — a fixed sleep races
962    /// the login shell `create_session` wraps commands in (a slow profile
963    /// can delay the command past any fixed delay; cf. the first-start
964    /// config races fixed in PR #12).
965    async fn wait_for_pane_contains(id: &str, needle: &str) {
966        for _ in 0..50 {
967            if capture_visible_plain(id).await.contains(needle) {
968                return;
969            }
970            sleep(Duration::from_millis(100)).await;
971        }
972        panic!("pane {id} never showed {needle:?}");
973    }
974
975    /// A message that stays visible at a `❯` prompt after Enter (the
976    /// stuck-paste failure mode) must surface as an error after the
977    /// retries are exhausted, not silently report success.
978    #[tokio::test]
979    async fn send_keys_errors_when_message_stays_stuck_at_a_prompt() {
980        if !tmux_available() { return; }
981        let id = unique_id();
982        // `cat > /dev/null` never redraws: the tty echo leaves our text
983        // sitting after the printed ❯ forever, exactly like a stuck paste.
984        create_session(&id, "/tmp", "printf '\\xe2\\x9d\\xaf '; exec cat > /dev/null", &[]).await.unwrap();
985        wait_for_pane_contains(&id, "❯").await;
986        let err = send_keys(&id, "stuck message").await.unwrap_err();
987        assert!(err.to_string().contains("unsubmitted"), "err: {err}");
988        kill_session(&id).await.unwrap();
989    }
990
991    /// The final retry Enter must itself be verified: when it is the one
992    /// that submits the message, send_keys reports success — not "still
993    /// unsubmitted" to a caller who might then re-send and double-deliver.
994    #[tokio::test]
995    async fn send_keys_succeeds_when_the_last_retry_enter_submits() {
996        if !tmux_available() { return; }
997        let id = unique_id();
998        // Consumes the message line plus every retry Enter, keeping the
999        // text visibly stuck at the ❯ the whole time, then clears the
1000        // pane — i.e. the message is only submitted by the LAST retry.
1001        create_session(
1002            &id, "/tmp",
1003            "printf '\\xe2\\x9d\\xaf '; read a; read b; read c; read d; printf '\\x1b[2J\\x1b[H'; exec sleep 30",
1004            &[],
1005        ).await.unwrap();
1006        wait_for_pane_contains(&id, "❯").await;
1007        send_keys(&id, "late message").await.unwrap();
1008        kill_session(&id).await.unwrap();
1009    }
1010
1011    #[tokio::test]
1012    async fn send_keys_builds_correct_command() {
1013        // This test validates our argument construction without actually calling tmux.
1014        // We test the shell_quote helper used by send_keys.
1015        let quoted = shell_quote("hello world");
1016        assert_eq!(quoted, "'hello world'");
1017        let with_apostrophe = shell_quote("don't");
1018        assert_eq!(with_apostrophe, "'don'\\''t'");
1019    }
1020
1021    #[test]
1022    fn server_config_is_written_and_contains_required_settings() {
1023        let path = write_server_config().unwrap();
1024        let body = std::fs::read_to_string(&path).unwrap();
1025        for required in [
1026            "default-terminal \"tmux-256color\"",
1027            "extended-keys always",
1028            "history-limit 100000",
1029            "status off",
1030            "window-size latest",
1031            "allow-passthrough on",
1032            "exit-empty off",
1033        ] {
1034            assert!(body.contains(required), "config missing {required:?}\n{body}");
1035        }
1036        // extended-keys-format requires tmux >= 3.5 (older tmux rejects the
1037        // option outright); the written config must match what's installed.
1038        if supports_extended_keys_format(detected_version_sync()) {
1039            assert!(body.contains("extended-keys-format csi-u"),
1040                    "config missing extended-keys-format on tmux >= 3.5\n{body}");
1041        } else {
1042            assert!(!body.contains("extended-keys-format"),
1043                    "config must omit extended-keys-format on tmux < 3.5 (rejected as invalid)\n{body}");
1044        }
1045    }
1046
1047    #[tokio::test]
1048    async fn require_version_passes_on_installed_tmux() {
1049        if !tmux_available() { return; }
1050        require_version().await.unwrap();
1051    }
1052
1053    #[tokio::test]
1054    async fn sessions_are_created_on_the_ninox_socket() {
1055        if !tmux_available() { return; }
1056        let id = unique_id();
1057        create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
1058        // Visible via -L ninox …
1059        assert!(has_session(&id).await);
1060        // … and NOT on the default socket.
1061        let default_out = std::process::Command::new("tmux")
1062            .args(["has-session", "-t", &id])
1063            .output()
1064            .unwrap();
1065        assert!(!default_out.status.success(), "session leaked onto the default socket");
1066        kill_session(&id).await.unwrap();
1067    }
1068
1069    #[tokio::test]
1070    async fn legacy_default_socket_sessions_are_still_reachable() {
1071        if !tmux_available() { return; }
1072        let id = unique_id();
1073        // Simulate a session created by an older build: default socket, no -L.
1074        let st = std::process::Command::new("tmux")
1075            .args(["new-session", "-d", "-s", &id, "-x", "80", "-y", "24", "sleep 30"])
1076            .status()
1077            .unwrap();
1078        assert!(st.success());
1079        assert!(has_session(&id).await, "has_session must fall back to the default socket");
1080        let argv = attach_args(&id).await;
1081        assert!(!argv.contains(&"-L".to_string()), "legacy session must attach without -L: {argv:?}");
1082        kill_session(&id).await.unwrap(); // must kill on the default socket too
1083        assert!(!has_session(&id).await);
1084    }
1085
1086    #[tokio::test]
1087    async fn capture_history_returns_scrolled_off_lines() {
1088        if !tmux_available() { return; }
1089        let id = unique_id();
1090        // 50-row pane; print 80 numbered lines so the earliest ones scroll into history.
1091        create_session(&id, "/tmp", "bash -c 'for i in $(seq 1 80); do echo line-$i; done; sleep 30'", &[]).await.unwrap();
1092        sleep(Duration::from_millis(500)).await;
1093        let hist = history_size(&id).await;
1094        assert!(hist > 0, "expected history to accumulate, got {hist}");
1095        let bytes = capture_history(&id, -hist, -1).await;
1096        let text = String::from_utf8_lossy(&bytes);
1097        assert!(text.contains("line-1"), "oldest line missing from history capture: {text}");
1098        kill_session(&id).await.unwrap();
1099    }
1100}