Skip to main content

agent_float_term/
tmux.rs

1//! tmux owns the terminals; this module owns only explicitly marked bindings and sessions.
2
3use crate::config::{self, checked_path, private_dir, Config, Paths};
4use crate::inspect::{Invocation, Liveness};
5use crate::install::shell_quote;
6use anyhow::{bail, ensure, Context, Result};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::env;
10use std::ffi::OsStr;
11use std::fs::{self, File, OpenOptions};
12use std::io::{IsTerminal, Read, Write};
13use std::os::fd::AsRawFd;
14use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt};
15use std::path::{Path, PathBuf};
16use std::process::{Command, Output, Stdio};
17use std::thread;
18use std::time::{Duration, Instant};
19
20mod lifecycle;
21mod routing;
22
23pub use lifecycle::watch;
24
25const GENERATION: &str = "@aft_generation";
26const OWNER: &str = "@aft_owner";
27const FLOAT_GENERATION: &str = "@aft_float_generation";
28const COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
29const TERMINAL_STYLE: &str = "none,fg=terminal,bg=terminal";
30
31#[derive(Clone)]
32struct Tmux {
33    binary: PathBuf,
34    socket: PathBuf,
35}
36
37fn which(name: &str) -> Result<PathBuf> {
38    env::split_paths(&env::var_os("PATH").unwrap_or_default())
39        .map(|dir| dir.join(name))
40        .find(|path| fs::metadata(path).is_ok_and(|m| m.is_file() && m.mode() & 0o111 != 0))
41        .context(format!("{name} is not on PATH"))?
42        .canonicalize()
43        .with_context(|| format!("resolve {name}"))
44}
45
46fn text(path: &Path) -> Result<&str> {
47    checked_path(path)?;
48    path.to_str().context("path must be UTF-8")
49}
50
51fn quiet() -> bool {
52    env::var_os("AFT_QUIET").as_deref() == Some(OsStr::new("1"))
53}
54
55fn tmux_binary() -> Result<PathBuf> {
56    if let Some(path) = env::var_os("AFT_TMUX_BINARY").filter(|value| !value.is_empty()) {
57        let path = PathBuf::from(path);
58        checked_path(&path)?;
59        ensure!(
60            fs::metadata(&path).is_ok_and(|m| m.is_file() && m.mode() & 0o111 != 0),
61            "AFT_TMUX_BINARY is unavailable; use the client matching your running tmux server"
62        );
63        return Ok(path);
64    }
65    which("tmux")
66}
67
68fn token() -> Result<String> {
69    let mut bytes = [0; 16];
70    File::open("/dev/urandom")?.read_exact(&mut bytes)?;
71    let mut token = String::with_capacity(32);
72    for byte in bytes {
73        use std::fmt::Write;
74        write!(token, "{byte:02x}")?;
75    }
76    Ok(token)
77}
78
79fn valid_token(value: &str) -> bool {
80    value.len() == 32 && value.bytes().all(|b| b.is_ascii_hexdigit())
81}
82
83fn valid_pane(value: &str) -> bool {
84    value
85        .strip_prefix('%')
86        .is_some_and(|v| !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit()))
87}
88
89/// tmux performs format expansion even on shell-quoted command strings.
90fn literal_format(value: &str) -> String {
91    value.replace('#', "##")
92}
93
94fn tmux_quote(value: &str) -> String {
95    format!(
96        "\"{}\"",
97        value
98            .replace('\\', "\\\\")
99            .replace('"', "\\\"")
100            .replace('$', "\\$")
101    )
102}
103
104fn capture(command: &mut Command, input: Option<&[u8]>) -> Result<Output> {
105    command.stdin(if input.is_some() {
106        Stdio::piped()
107    } else {
108        Stdio::null()
109    });
110    let mut child = command
111        .stdout(Stdio::piped())
112        .stderr(Stdio::piped())
113        .spawn()
114        .context("start tmux command")?;
115    let result = (|| {
116        let mut stdout = child.stdout.take().context("missing stdout pipe")?;
117        let mut stderr = child.stderr.take().context("missing stderr pipe")?;
118        let mut stdin = child.stdin.take();
119        for fd in [
120            Some(stdout.as_raw_fd()),
121            Some(stderr.as_raw_fd()),
122            stdin.as_ref().map(AsRawFd::as_raw_fd),
123        ]
124        .into_iter()
125        .flatten()
126        {
127            // SAFETY: these descriptors are owned by the live pipe handles above.
128            let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
129            ensure!(flags >= 0, "cannot inspect command pipe flags");
130            // SAFETY: F_SETFL changes only the flags on the valid owned descriptor.
131            let result = unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) };
132            ensure!(result >= 0, "cannot configure nonblocking command pipes");
133        }
134        let started = Instant::now();
135        let mut output = Vec::new();
136        let mut errors = Vec::new();
137        let mut out_done = false;
138        let mut err_done = false;
139        let mut remaining = input.unwrap_or_default();
140        loop {
141            ensure!(
142                started.elapsed() < COMMAND_TIMEOUT,
143                "tmux command timed out"
144            );
145            if remaining.is_empty() {
146                stdin.take();
147            }
148            if let Some(pipe) = &mut stdin {
149                match pipe.write(remaining) {
150                    Ok(0) => bail!("tmux stdin closed"),
151                    Ok(n) => remaining = &remaining[n..],
152                    Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => (),
153                    Err(error) => return Err(error).context("write tmux input"),
154                }
155            }
156            for (pipe, bytes, done) in [
157                (&mut stdout as &mut dyn Read, &mut output, &mut out_done),
158                (&mut stderr as &mut dyn Read, &mut errors, &mut err_done),
159            ] {
160                if *done {
161                    continue;
162                }
163                let mut buffer = [0; 8192];
164                match pipe.read(&mut buffer) {
165                    Ok(0) => *done = true,
166                    Ok(n) => {
167                        bytes.extend_from_slice(&buffer[..n]);
168                        ensure!(bytes.len() <= 1024 * 1024, "tmux output exceeds limit");
169                    }
170                    Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => (),
171                    Err(error) => return Err(error).context("read tmux output"),
172                }
173            }
174            if let Some(status) = child.try_wait()? {
175                if out_done && err_done {
176                    return Ok(Output {
177                        status,
178                        stdout: output,
179                        stderr: errors,
180                    });
181                }
182            }
183            thread::sleep(Duration::from_millis(2));
184        }
185    })();
186    if result.is_err() {
187        let _ = child.kill();
188        let _ = child.wait();
189    }
190    result
191}
192
193fn checked_output(output: Output) -> Result<String> {
194    ensure!(
195        output.status.success(),
196        "tmux: {}",
197        String::from_utf8_lossy(&output.stderr).trim()
198    );
199    Ok(String::from_utf8(output.stdout)?
200        .trim_end_matches('\n')
201        .to_owned())
202}
203
204impl Tmux {
205    fn new(socket: PathBuf) -> Result<Self> {
206        checked_path(&socket)?;
207        let metadata = fs::symlink_metadata(&socket).context("tmux socket is unavailable")?;
208        ensure!(
209            metadata.file_type().is_socket() && metadata.uid() == config::uid(),
210            "tmux socket must be owned by this user and must not be a symlink"
211        );
212        Ok(Self {
213            binary: tmux_binary()?,
214            socket,
215        })
216    }
217
218    fn resolve(socket: Option<PathBuf>) -> Result<Self> {
219        let socket = match socket {
220            Some(path) => path,
221            None => match env::var("TMUX").ok().filter(|s| !s.is_empty()) {
222                Some(value) => {
223                    let mut fields = value.rsplitn(3, ',');
224                    fields.next();
225                    fields.next();
226                    PathBuf::from(
227                        fields
228                            .next()
229                            .context("invalid TMUX environment; use --socket")?,
230                    )
231                }
232                None => dedicated_socket()?,
233            },
234        };
235        Self::new(socket)
236            .context("select tmux server (run inside tmux or specify --socket)")?
237            .matching_client()
238    }
239
240    fn client_version(&self) -> Result<String> {
241        let value = checked_output(capture(Command::new(&self.binary).arg("-V"), None)?)?;
242        Ok(value
243            .strip_prefix("tmux ")
244            .context("unexpected tmux client version")?
245            .to_owned())
246    }
247
248    fn matching_client(mut self) -> Result<Self> {
249        let server = self.output(&["display-message", "-p", "#{version}"])?;
250        let client = self.client_version()?;
251        if client == server {
252            return Ok(self);
253        }
254        // Preserve an explicitly approved per-server client across login-shell PATH
255        // changes. Never download a client or restart a server to resolve a mismatch.
256        if let Some(record) = read_record(&record_path(&self.socket)?)? {
257            if record.socket == self.socket {
258                if let Some(binary) = record.binary {
259                    let mut candidate = self.clone();
260                    candidate.binary = binary;
261                    if candidate
262                        .client_version()
263                        .is_ok_and(|version| version == server)
264                    {
265                        self.binary = candidate.binary;
266                        return Ok(self);
267                    }
268                }
269            }
270        }
271        bail!("tmux client {client} does not match running server {server}; terminal FD passing can fail. Set AFT_TMUX_BINARY to a matching {server} executable and run bind. Existing sessions were preserved")
272    }
273
274    fn command(&self) -> Command {
275        let mut command = Command::new(&self.binary);
276        command.arg("-S").arg(&self.socket).env_remove("TMUX");
277        command
278    }
279
280    fn output(&self, args: &[&str]) -> Result<String> {
281        checked_output(capture(self.command().args(args), None)?)
282    }
283
284    fn source(&self, command: &str) -> Result<()> {
285        self.source_result(command).map(|_| ())
286    }
287
288    fn source_result(&self, command: &str) -> Result<String> {
289        checked_output(capture(
290            self.command().args(["source-file", "-"]),
291            Some(command.as_bytes()),
292        )?)
293    }
294
295    fn global(&self, option: &str) -> Result<String> {
296        self.output(&["show-options", "-gqv", option])
297    }
298
299    fn generation(&self) -> Result<String> {
300        let generation = self.global(GENERATION)?;
301        ensure!(
302            valid_token(&generation),
303            "server has no valid agent-float-term ownership marker; run bind"
304        );
305        Ok(generation)
306    }
307
308    fn compatible(&self) -> Result<String> {
309        let version = self.output(&["display-message", "-p", "#{version}"])?;
310        ensure!(
311            supported_version(&version),
312            "running tmux {version} is unsupported; need 3.4 or newer"
313        );
314        let client = self.client_version()?;
315        ensure!(client == version,
316            "tmux client {client} does not match running server {version}; use a matching client via AFT_TMUX_BINARY (do not restart live sessions)");
317        ensure!(
318            self.global("exit-unattached")? != "on",
319            "exit-unattached is on; disable it explicitly or use the dedicated server"
320        );
321        ensure!(
322            self.global("destroy-unattached")? != "on",
323            "destroy-unattached is on; disable it explicitly or use the dedicated server"
324        );
325        Ok(version)
326    }
327
328    fn key_binding(&self, key: &str) -> Result<Option<String>> {
329        // tmux 3.7c sends a one-item listing to a status message, not stdout.
330        // Listing all tables avoids that when root has only one binding.
331        let all = self.output(&["list-keys"])?;
332        Ok(all
333            .lines()
334            .filter(|line| {
335                line.split_whitespace()
336                    .collect::<Vec<_>>()
337                    .windows(2)
338                    .any(|w| w == ["-T", "root"])
339            })
340            .filter_map(binding_line)
341            .find(|(found, _)| found == key)
342            .map(|(_, line)| line))
343    }
344
345    fn pane(&self, id: &str) -> Result<Pane> {
346        ensure!(valid_pane(id), "invalid pane ID");
347        let value = self.output(&[
348            "display-message",
349            "-p",
350            "-t",
351            id,
352            "#{pane_id}|#{pane_pid}|#{pane_tty}|#{pane_dead}|#{pane_in_mode}|#{version}",
353            ";",
354            "show-options",
355            "-gqv",
356            GENERATION,
357            ";",
358            "show-options",
359            "-gqv",
360            "exit-unattached",
361            ";",
362            "show-options",
363            "-gqv",
364            "destroy-unattached",
365            ";",
366            "show-options",
367            "-gqv",
368            "default-shell",
369            ";",
370            "display-message",
371            "-p",
372            "-t",
373            id,
374            "#{pane_current_path}",
375        ])?;
376        let mut lines = value.lines();
377        let fields: Vec<_> = lines
378            .next()
379            .context("missing pane metadata")?
380            .split('|')
381            .collect();
382        ensure!(
383            fields.len() == 6 && fields[0] == id && fields[3] == "0",
384            "pane disappeared or is dead"
385        );
386        Ok(Pane {
387            id: id.into(),
388            pid: fields[1].parse()?,
389            tty: fields[2].into(),
390            in_mode: fields[4] != "0",
391            server_version: fields[5].into(),
392            generation: lines.next().context("missing server generation")?.into(),
393            exit_unattached: lines.next().context("missing detach policy")? != "off",
394            destroy_unattached: lines.next().context("missing session policy")? != "off",
395            default_shell: lines.next().context("missing default shell")?.into(),
396            cwd: lines.next().context("missing pane directory")?.into(),
397        })
398    }
399
400    fn client(&self, pid: u32) -> Result<Client> {
401        let all = self.output(&[
402            "list-clients",
403            "-F",
404            "#{client_pid}|#{client_name}|#{pane_id}|#{session_id}",
405        ])?;
406        for line in all.lines() {
407            let fields: Vec<_> = line.split('|').collect();
408            if fields.len() == 4 && fields[0].parse::<u32>() == Ok(pid) {
409                return Ok(Client {
410                    pid,
411                    name: fields[1].into(),
412                    pane: fields[2].into(),
413                    session: fields[3].into(),
414                });
415            }
416        }
417        bail!("invoking client disappeared")
418    }
419
420    fn message(&self, client: &Client, message: &str) {
421        let _ = self.output(&["display-message", "-c", &client.name, message]);
422    }
423
424    fn forward(&self, pane: &Pane, client: &Client, key: &str) -> Result<()> {
425        if env::var_os("AFT_RESTORE_INSTANCE").is_some() {
426            return Ok(());
427        }
428        if self.client_by_name_is_on(client, &pane.id)? {
429            self.output(&["send-keys", "-t", &pane.id, key])?;
430        }
431        Ok(())
432    }
433
434    fn client_by_name_is_on(&self, client: &Client, pane: &str) -> Result<bool> {
435        let current = self.client(client.pid)?;
436        Ok(
437            current.name == client.name
438                && current.session == client.session
439                && current.pane == pane,
440        )
441    }
442
443    fn popup_policy(&self, generation: &str) -> Result<()> {
444        let metadata = self.output(&[
445            "show-options",
446            "-gqv",
447            GENERATION,
448            ";",
449            "show-options",
450            "-gqv",
451            "exit-unattached",
452            ";",
453            "show-options",
454            "-gqv",
455            "destroy-unattached",
456        ])?;
457        ensure!(
458            metadata.lines().collect::<Vec<_>>() == [generation, "off", "off"],
459            "global generation or detach policies changed; popup was not opened"
460        );
461        Ok(())
462    }
463
464    fn popup_client_unchanged(&self, client: &Client, pane: &Pane) -> Result<bool> {
465        let metadata = self.output(&[
466            "show-options",
467            "-gqv",
468            GENERATION,
469            ";",
470            "show-options",
471            "-gqv",
472            "exit-unattached",
473            ";",
474            "show-options",
475            "-gqv",
476            "destroy-unattached",
477            ";",
478            "list-clients",
479            "-F",
480            "#{client_pid}|#{client_name}|#{session_id}|#{pane_id}|#{pane_in_mode}",
481        ])?;
482        let mut lines = metadata.lines();
483        ensure!(lines.next() == Some(&pane.generation) && lines.next() == Some("off") && lines.next() == Some("off"),
484            "global generation or detach policies changed during shell startup; popup was not opened");
485        let expected = format!(
486            "{}|{}|{}|{}|0",
487            client.pid, client.name, client.session, pane.id
488        );
489        Ok(lines.any(|line| line == expected))
490    }
491}
492
493struct Pane {
494    id: String,
495    pid: u32,
496    tty: PathBuf,
497    in_mode: bool,
498    server_version: String,
499    generation: String,
500    exit_unattached: bool,
501    destroy_unattached: bool,
502    default_shell: PathBuf,
503    cwd: PathBuf,
504}
505struct Client {
506    pid: u32,
507    name: String,
508    pane: String,
509    session: String,
510}
511
512// list-keys aligns columns according to the other keys in its table. Normalize
513// only the command prefix, leaving the serialized command body byte-for-byte.
514fn binding_line(line: &str) -> Option<(String, String)> {
515    let words: Vec<_> = line.split_whitespace().collect();
516    let table = words.iter().position(|word| *word == "-T")?;
517    let key = *words.get(table + 2)?;
518    let mut rest = line;
519    for _ in 0..table + 3 {
520        rest = rest.trim_start();
521        rest = &rest[rest.find(char::is_whitespace).unwrap_or(rest.len())..];
522    }
523    Some((
524        key.into(),
525        format!(
526            "{} root {} {}",
527            words[..=table].join(" "),
528            key,
529            rest.trim_start()
530        ),
531    ))
532}
533
534fn supported_version(value: &str) -> bool {
535    let Some((major, rest)) = value.split_once('.') else {
536        return false;
537    };
538    let Ok(major) = major.parse::<u32>() else {
539        return false;
540    };
541    let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
542    let Ok(minor) = digits.parse::<u32>() else {
543        return false;
544    };
545    major > 3 || (major == 3 && minor >= 4)
546}
547
548fn runtime_dir() -> Result<PathBuf> {
549    let directory = Paths::discover()?.state.join("runtime");
550    private_dir(&directory)?;
551    Ok(directory)
552}
553
554fn record_path(socket: &Path) -> Result<PathBuf> {
555    Ok(Paths::discover()?.state.join("runtime").join(format!(
556        "{:x}.json",
557        Sha256::digest(socket.as_os_str().as_encoded_bytes())
558    )))
559}
560
561fn lock(path: &Path) -> Result<File> {
562    lock_for(path, Duration::from_secs(2))?.context("another agent-float-term operation is busy")
563}
564
565fn lock_for(path: &Path, timeout: Duration) -> Result<Option<File>> {
566    let file = OpenOptions::new()
567        .read(true)
568        .write(true)
569        .create(true)
570        .truncate(false)
571        .mode(0o600)
572        .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK)
573        .open(path)?;
574    let metadata = file.metadata()?;
575    ensure!(
576        metadata.is_file() && metadata.uid() == config::uid() && metadata.mode() & 0o077 == 0,
577        "unsafe runtime lock file"
578    );
579    let start = Instant::now();
580    loop {
581        // SAFETY: file owns this descriptor for the entire lifetime of the lock.
582        if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
583            return Ok(Some(file));
584        }
585        let error = std::io::Error::last_os_error();
586        ensure!(
587            error.kind() == std::io::ErrorKind::WouldBlock,
588            "lock failed: {error}"
589        );
590        if start.elapsed() >= timeout {
591            return Ok(None);
592        }
593        thread::sleep(Duration::from_millis(10));
594    }
595}
596
597#[derive(Serialize, Deserialize)]
598#[serde(deny_unknown_fields)]
599struct Binding {
600    socket: PathBuf,
601    binary: Option<PathBuf>,
602    binary_stamp: Option<BinaryStamp>,
603    server_version: Option<String>,
604    generation: String,
605    key: String,
606    installed: String,
607    prior_installed: Option<String>,
608    previous: Option<String>,
609}
610
611#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
612struct BinaryStamp {
613    device: u64,
614    inode: u64,
615    length: u64,
616    modified: i64,
617    modified_ns: i64,
618    mode: u32,
619}
620
621fn binary_stamp(path: &Path) -> Result<BinaryStamp> {
622    let metadata = fs::metadata(path)?;
623    ensure!(
624        metadata.is_file() && metadata.mode() & 0o111 != 0,
625        "tmux client is not executable"
626    );
627    Ok(BinaryStamp {
628        device: metadata.dev(),
629        inode: metadata.ino(),
630        length: metadata.len(),
631        modified: metadata.mtime(),
632        modified_ns: metadata.mtime_nsec(),
633        mode: metadata.mode(),
634    })
635}
636
637fn validate_popup_client(tmux: &Tmux, pane: &Pane) -> Result<()> {
638    ensure!(
639        supported_version(&pane.server_version),
640        "running tmux is older than 3.4"
641    );
642    ensure!(
643        valid_token(&pane.generation),
644        "server ownership marker is unavailable; run bind"
645    );
646    ensure!(
647        !pane.exit_unattached && !pane.destroy_unattached,
648        "exit-unattached/destroy-unattached must be off; existing sessions were preserved"
649    );
650    if let Some(binding) = read_record(&record_path(&tmux.socket)?)? {
651        ensure!(
652            binding.generation == pane.generation,
653            "server generation changed; run bind before opening a popup"
654        );
655        if binding.socket == tmux.socket
656            && binding.generation == pane.generation
657            && binding.binary.as_deref() == Some(tmux.binary.as_path())
658            && binding.server_version.as_deref() == Some(pane.server_version.as_str())
659            && binding.binary_stamp.as_ref() == Some(&binary_stamp(&tmux.binary)?)
660        {
661            return Ok(());
662        }
663    }
664    // Old records and replaced clients require a fresh check. A verified, unchanged
665    // executable need not be spawned for `-V` on every keypress.
666    let client = tmux.client_version()?;
667    ensure!(client == pane.server_version,
668        "tmux client {client} does not match running server {}; run bind with a matching AFT_TMUX_BINARY", pane.server_version);
669    Ok(())
670}
671
672fn read_record(path: &Path) -> Result<Option<Binding>> {
673    let file = match OpenOptions::new()
674        .read(true)
675        .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
676        .open(path)
677    {
678        Ok(file) => file,
679        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
680        Err(error) => return Err(error).context("open binding record"),
681    };
682    let metadata = file.metadata()?;
683    ensure!(
684        metadata.is_file() && metadata.uid() == config::uid() && metadata.mode() & 0o077 == 0,
685        "unsafe binding record"
686    );
687    let mut bytes = Vec::new();
688    file.take(128 * 1024).read_to_end(&mut bytes)?;
689    let record: Binding = serde_json::from_slice(&bytes)?;
690    if let Some(binary) = &record.binary {
691        checked_path(binary)?;
692    }
693    ensure!(
694        valid_token(&record.generation),
695        "invalid binding generation"
696    );
697    Config {
698        key: record.key.clone(),
699        ..Config::default()
700    }
701    .validate()?;
702    Ok(Some(record))
703}
704
705fn write_record(path: &Path, record: &Binding) -> Result<()> {
706    atomic_private_write(path, &serde_json::to_vec_pretty(record)?)
707}
708
709fn atomic_private_write(path: &Path, bytes: &[u8]) -> Result<()> {
710    let temporary = path.with_extension(format!("{}.tmp", token()?));
711    let mut file = OpenOptions::new()
712        .write(true)
713        .create_new(true)
714        .mode(0o600)
715        .open(&temporary)?;
716    file.write_all(bytes)?;
717    file.sync_all()?;
718    fs::rename(&temporary, path)?;
719    File::open(path.parent().context("record parent")?)?.sync_all()?;
720    Ok(())
721}
722
723fn helper_path() -> Result<PathBuf> {
724    let paths = Paths::discover()?;
725    if let Some(external) = crate::install::external_helper_path(&paths)? {
726        return Ok(external);
727    }
728    let executable = env::current_exe()?.canonicalize()?;
729    let installed = paths.bin.join("agent-float-term");
730    if installed
731        .canonicalize()
732        .is_ok_and(|path| path == executable)
733    {
734        Ok(installed)
735    } else {
736        Ok(executable)
737    }
738}
739
740fn dispatch_command(tmux: &Tmux, key: &str) -> Result<String> {
741    let helper = shell_quote(text(&helper_path()?)?);
742    let tmux_binary = shell_quote(text(&tmux.binary)?);
743    let socket = shell_quote(text(&tmux.socket)?);
744    // Explicit product roots make the binding independent of a stale server environment.
745    let paths = Paths::discover()?;
746    let home = env::var("HOME").context("HOME must be UTF-8")?;
747    let environment = format!(
748        "AFT_TMUX_BINARY={} HOME={} XDG_CONFIG_HOME={} XDG_DATA_HOME={} XDG_STATE_HOME={}",
749        shell_quote(text(&tmux.binary)?),
750        shell_quote(&home),
751        shell_quote(text(paths.config.parent().context("config root")?)?),
752        shell_quote(text(paths.data.parent().context("data root")?)?),
753        shell_quote(text(paths.state.parent().context("state root")?)?)
754    );
755    // Expand only the two tmux-generated numeric identifiers, never user-controlled paths.
756    let prefix = literal_format(&format!(
757        "if [ -x {helper} ]; then env {environment} {helper} dispatch --socket {socket}"
758    ));
759    let suffix = literal_format(&format!(
760        " --key {}; else {tmux_binary} -S {socket} send-keys -t",
761        shell_quote(key)
762    ));
763    Ok(format!("{prefix} --pane '#{{pane_id}}' --client-pid '#{{client_pid}}'{suffix} '#{{pane_id}}' {}; fi", shell_quote(key)))
764}
765
766fn restore(tmux: &Tmux, binding: &Binding) -> Result<bool> {
767    if tmux.global(GENERATION)? != binding.generation {
768        return Ok(false);
769    }
770    if !binding_matches(binding, tmux.key_binding(&binding.key)?.as_deref()) {
771        return Ok(false);
772    }
773    match &binding.previous {
774        Some(previous) => tmux.source(&format!("{previous}\n"))?,
775        None => {
776            tmux.output(&["unbind-key", "-T", "root", &binding.key])?;
777        }
778    }
779    Ok(true)
780}
781
782fn binding_matches(binding: &Binding, live: Option<&str>) -> bool {
783    live.is_some_and(|line| {
784        line == binding.installed || binding.prior_installed.as_deref() == Some(line)
785    })
786}
787
788pub fn bind(socket: Option<PathBuf>, replace_key: bool) -> Result<()> {
789    let mut config = config::load()?;
790    let tmux = Tmux::resolve(socket)?;
791    let verified_stamp = binary_stamp(&tmux.binary)?;
792    let server_version = tmux.compatible()?;
793    ensure!(
794        binary_stamp(&tmux.binary)? == verified_stamp,
795        "tmux executable changed while checking its version; retry bind"
796    );
797    crate::install::register_homebrew()?;
798    runtime_dir()?;
799    let record_path = record_path(&tmux.socket)?;
800    let _guard = lock(&record_path.with_extension("lock"))?;
801    let mut generation = tmux.global(GENERATION)?;
802    let existing = read_record(&record_path)?;
803    let existing = existing.filter(|b| b.socket == tmux.socket && b.generation == generation);
804    let initialize_generation = generation.is_empty();
805    if initialize_generation {
806        generation = token()?;
807    }
808    let command = dispatch_command(&tmux, &config.key)?;
809    let float =
810        format!("#{{&&:#{{==:#{{@aft_float_generation}},{generation}}},#{{!=:#{{@aft_owner}},}}}}");
811    // Ask tmux to canonicalize aliases (e.g. C-i -> Tab) and serialize our intended
812    // binding in an owned temporary table. Never adopt a post-write user binding.
813    let table = format!("aft-probe-{}", token()?);
814    let sentinel = if config.key == "F12" { "F11" } else { "F12" };
815    tmux.output(&[
816        "bind-key",
817        "-T",
818        &table,
819        sentinel,
820        "display-message",
821        "AFT_PROBE_SENTINEL",
822    ])?;
823    tmux.output(&[
824        "bind-key",
825        "-T",
826        &table,
827        &config.key,
828        "if-shell",
829        "-F",
830        &float,
831        "detach-client",
832        &format!("run-shell -b {}", tmux_quote(&command)),
833    ])?;
834    let serialized = tmux.output(&["list-keys", "-T", &table]);
835    tmux.output(&["unbind-key", "-a", "-T", &table])?;
836    let serialized = serialized?;
837    let (canonical_key, installed) = serialized
838        .lines()
839        .filter_map(binding_line)
840        .find(|(key, _)| key != sentinel)
841        .context("cannot serialize binding")?;
842    config.key = canonical_key;
843    Config {
844        key: config.key.clone(),
845        ..Config::default()
846    }
847    .validate()
848    .context("tmux's canonical key is unsupported; choose another key")?;
849    let current = tmux.key_binding(&config.key)?;
850    let ours = existing
851        .as_ref()
852        .is_some_and(|b| b.key == config.key && binding_matches(b, current.as_deref()));
853    ensure!(
854        current.is_none() || ours || replace_key,
855        "{} is already bound; choose another key or explicitly use --replace-key",
856        config.key
857    );
858    if initialize_generation {
859        tmux.output(&["set-option", "-g", GENERATION, &generation])?;
860    }
861    ensure!(valid_token(&generation), "invalid server ownership marker");
862    if let Some(old) = &existing {
863        if old.key != config.key {
864            restore(&tmux, old)?;
865        }
866    }
867    let previous = if ours {
868        existing.and_then(|b| b.previous)
869    } else {
870        current.clone()
871    };
872    let mut record = Binding {
873        socket: tmux.socket.clone(),
874        binary: Some(tmux.binary.clone()),
875        binary_stamp: Some(verified_stamp),
876        server_version: Some(server_version),
877        generation,
878        key: config.key.clone(),
879        installed,
880        prior_installed: if ours { current.clone() } else { None },
881        previous,
882    };
883    // Journal the restoration value before changing the live key. An interrupted
884    // apply can be recovered without guessing which previous binding was ours.
885    write_record(&record_path, &record).context("record binding ownership")?;
886    ensure!(
887        tmux.key_binding(&config.key)? == current,
888        "key changed during installation; left untouched"
889    );
890    tmux.source(&format!("{}\n", record.installed))?;
891    ensure!(
892        tmux.key_binding(&config.key)?.as_deref() == Some(&record.installed),
893        "key changed while applying integration; user binding was not adopted"
894    );
895    record.prior_installed = None;
896    write_record(&record_path, &record)?;
897    if !quiet() {
898        println!(
899            "Bound {} on {} (AI invocations only; inside floats, hide).",
900            config.key,
901            tmux.socket.display()
902        );
903    }
904    Ok(())
905}
906
907pub fn unbind_all() -> Result<()> {
908    let directory = Paths::discover()?.state.join("runtime");
909    if !directory.exists() {
910        return Ok(());
911    }
912    private_dir(&directory)?;
913    for entry in fs::read_dir(&directory)? {
914        let path = entry?.path();
915        if path.extension() != Some(OsStr::new("json")) {
916            continue;
917        }
918        let _guard = lock(&path.with_extension("lock"))?;
919        let Some(record) = read_record(&path)? else {
920            continue;
921        };
922        let server = Tmux::new(record.socket.clone()).map(|mut tmux| {
923            if let Some(binary) = &record.binary {
924                tmux.binary = binary.clone();
925            }
926            tmux
927        });
928        match server {
929            Ok(tmux) => {
930                routing::cleanup_server(&tmux, &record.generation)?;
931                match restore(&tmux, &record) {
932                    Ok(true) => println!("Restored {} on {}", record.key, record.socket.display()),
933                    Ok(false) => println!(
934                        "Preserved changed or restarted server binding on {}",
935                        record.socket.display()
936                    ),
937                    Err(error) => {
938                        eprintln!("Preserved unavailable binding record: {error}");
939                        continue;
940                    }
941                }
942            }
943            Err(_) => {
944                eprintln!(
945                    "Server unavailable: {}; missing-executable forwarding remains in place",
946                    record.socket.display()
947                );
948                continue;
949            }
950        }
951        fs::remove_file(path)?;
952    }
953    Ok(())
954}
955
956fn dedicated_socket() -> Result<PathBuf> {
957    let base = env::var_os("XDG_RUNTIME_DIR")
958        .filter(|v| !v.is_empty())
959        .map(PathBuf::from)
960        .unwrap_or_else(env::temp_dir);
961    checked_path(&base)?;
962    Ok(base
963        .canonicalize()
964        .context("runtime base directory must exist")?
965        .join(format!("agent-float-term-{}", config::uid()))
966        .join("tmux.sock"))
967}
968
969fn socket_directory(path: &Path) -> Result<()> {
970    // Socket directories may live under root's sticky /tmp. Do not relax the
971    // general installer checks for user-owned configuration/data directories.
972    if !path.exists() {
973        let parent = fs::metadata(path.parent().context("socket parent")?)?;
974        let trusted = parent.uid() == config::uid() && parent.mode() & 0o022 == 0;
975        let sticky =
976            (parent.uid() == 0 || parent.uid() == config::uid()) && parent.mode() & 0o1000 != 0;
977        ensure!(
978            parent.is_dir() && (trusted || sticky),
979            "unsafe socket parent directory"
980        );
981        use std::os::unix::fs::DirBuilderExt;
982        match fs::DirBuilder::new().mode(0o700).create(path) {
983            Ok(()) => (),
984            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => (),
985            Err(error) => return Err(error).context("create socket directory"),
986        }
987    }
988    private_dir(path)
989}
990
991fn dedicated_marker(path: &Path) -> Result<String> {
992    let file = OpenOptions::new()
993        .read(true)
994        .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
995        .open(path)?;
996    let metadata = file.metadata()?;
997    ensure!(
998        metadata.is_file() && metadata.uid() == config::uid() && metadata.mode() & 0o077 == 0,
999        "unsafe dedicated-server marker"
1000    );
1001    let mut marker = String::new();
1002    file.take(64).read_to_string(&mut marker)?;
1003    ensure!(valid_token(&marker), "invalid dedicated-server marker");
1004    Ok(marker)
1005}
1006
1007pub fn start() -> Result<()> {
1008    ensure!(
1009        std::io::stdin().is_terminal() && std::io::stdout().is_terminal(),
1010        "start requires an interactive terminal"
1011    );
1012    if env::var_os("TMUX").is_some_and(|value| !value.is_empty()) {
1013        bind(None, false)?;
1014        if !quiet() {
1015            println!("Already inside tmux; no nested outer session was created.");
1016        }
1017        return Ok(());
1018    }
1019    let config = config::load()?;
1020    let socket = dedicated_socket()?;
1021    #[cfg(target_os = "macos")]
1022    ensure!(
1023        socket.as_os_str().len() < 104,
1024        "socket path is too long; select a shorter XDG_RUNTIME_DIR"
1025    );
1026    socket_directory(socket.parent().context("socket directory")?)?;
1027    let guard = lock(&socket.with_extension("lock"))?;
1028    let mut binary = tmux_binary()?;
1029    let version = checked_output(capture(Command::new(&binary).arg("-V"), None)?)?;
1030    ensure!(
1031        supported_version(version.trim_start_matches("tmux ")),
1032        "need tmux 3.4 or newer"
1033    );
1034    let session = format!("aft-work-{}", &token()?[..12]);
1035    let shell = config
1036        .shell
1037        .clone()
1038        .or_else(|| env::var_os("SHELL").map(PathBuf::from))
1039        .unwrap_or_else(|| "/bin/sh".into());
1040    checked_path(&shell)?;
1041    let marker_path = socket.with_extension("owner");
1042    let mut existed = socket.exists();
1043    if existed {
1044        let metadata = fs::symlink_metadata(&socket)?;
1045        ensure!(
1046            metadata.file_type().is_socket() && metadata.uid() == config::uid(),
1047            "unsafe dedicated socket"
1048        );
1049        if std::os::unix::net::UnixStream::connect(&socket)
1050            .err()
1051            .is_some_and(|error| error.kind() == std::io::ErrorKind::ConnectionRefused)
1052        {
1053            dedicated_marker(&marker_path)
1054                .context("dead socket has no ownership evidence; inspect it manually")?;
1055            let current = fs::symlink_metadata(&socket)?;
1056            ensure!(
1057                metadata.ino() == current.ino() && metadata.dev() == current.dev(),
1058                "socket changed during recovery"
1059            );
1060            fs::remove_file(&socket)?;
1061            existed = false;
1062        }
1063    }
1064    if existed {
1065        let tmux = Tmux::new(socket.clone())?.matching_client()?;
1066        ensure!(
1067            tmux.global("@aft_dedicated")? == "1",
1068            "socket is not an owned dedicated server"
1069        );
1070        ensure!(
1071            tmux.generation()? == dedicated_marker(&marker_path)?,
1072            "dedicated-server generation changed"
1073        );
1074        tmux.compatible()?;
1075        binary = tmux.binary;
1076    }
1077    crate::install::register_homebrew()?;
1078    let mut command = Command::new(&binary);
1079    let cwd = env::current_dir()?;
1080    command
1081        .args(["-S"])
1082        .arg(&socket)
1083        .args(["-f", "/dev/null", "new-session", "-d", "-s", &session, "-c"])
1084        .arg(literal_format(text(&cwd)?))
1085        .args(["-e", "AFT_DISABLE=1"])
1086        .args(["-e", "AFT_QUIET="])
1087        .args(["-e", &format!("AFT_TMUX_BINARY={}", text(&binary)?)])
1088        .arg(&shell)
1089        .arg("-l");
1090    checked_output(capture(&mut command, None)?)?;
1091    let mut tmux = Tmux::new(socket.clone())?;
1092    tmux.binary = binary;
1093    if !existed {
1094        let generation = token()?;
1095        tmux.output(&["set-option", "-g", GENERATION, &generation])?;
1096        tmux.output(&["set-option", "-g", "@aft_dedicated", "1"])?;
1097        tmux.output(&["set-option", "-g", "status", "off"])?;
1098        atomic_private_write(&marker_path, generation.as_bytes())?;
1099    }
1100    drop(guard);
1101    if let Err(error) = bind(Some(socket), false) {
1102        eprintln!("Integration failed; your shell is retained as {session}: {error}");
1103    }
1104    let status = tmux
1105        .command()
1106        .args(["attach-session", "-E", "-t", &format!("={session}")])
1107        .status()?;
1108    ensure!(
1109        status.success(),
1110        "tmux attachment failed; session {session} was preserved"
1111    );
1112    Ok(())
1113}
1114
1115fn alive(pid: &str) -> bool {
1116    let Ok(pid) = pid.parse::<i32>() else {
1117        return false;
1118    };
1119    if pid <= 0 {
1120        return false;
1121    }
1122    // SAFETY: signal 0 only queries process existence/permission; it sends no signal.
1123    let result = unsafe { libc::kill(pid, 0) };
1124    result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
1125}
1126
1127pub fn dispatch(socket: PathBuf, pane: String, client_pid: u32, key: String) -> Result<()> {
1128    Config {
1129        key: key.clone(),
1130        ..Config::default()
1131    }
1132    .validate()?;
1133    let tmux = Tmux::new(socket)?;
1134    let pane = tmux.pane(&pane)?;
1135    let client = tmux.client(client_pid)?;
1136    if client.pane != pane.id {
1137        return Ok(());
1138    }
1139    let mut config = match config::load() {
1140        Ok(config) => config,
1141        Err(_) => return tmux.forward(&pane, &client, &key),
1142    };
1143    // Use tmux's installed spelling (for example Tab for C-i). Shortcut edits
1144    // take effect on bind; dimensions still reload on every opening.
1145    if let Some(binding) = read_record(&record_path(&tmux.socket)?)?
1146        .filter(|binding| binding.socket == tmux.socket && binding.generation == pane.generation)
1147    {
1148        config.key = binding.key;
1149    }
1150    if pane.in_mode {
1151        return tmux.forward(&pane, &client, &key);
1152    }
1153    let invocation = crate::inspect::eligible(pane.pid, &pane.tty, &config.harness_paths)
1154        .ok()
1155        .filter(|decision| decision.eligible)
1156        .and_then(|decision| decision.invocation);
1157    let Some(invocation) = invocation else {
1158        return tmux.forward(&pane, &client, &key);
1159    };
1160    match popup(&tmux, &pane, &client, &config, invocation) {
1161        Ok(()) => Ok(()),
1162        Err(error) => {
1163            let detail: String = format!("pane {}: {error:#}", pane.id)
1164                .chars()
1165                .take(1024)
1166                .map(|c| if c.is_control() { ' ' } else { c })
1167                .collect();
1168            let _ = tmux.output(&["set-option", "-g", "@aft_last_error", &detail]);
1169            // Error context contains our operations, never inspected argv or terminal contents.
1170            tmux.message(
1171                &client,
1172                "agent-float-term: popup failed; run doctor (shells are preserved)",
1173            );
1174            eprintln!("agent-float-term: {error:#}");
1175            Ok(())
1176        }
1177    }
1178}
1179
1180fn popup(
1181    tmux: &Tmux,
1182    pane: &Pane,
1183    client: &Client,
1184    config: &Config,
1185    invocation: Invocation,
1186) -> Result<()> {
1187    validate_popup_client(tmux, pane)?;
1188    let generation = &pane.generation;
1189    let lock_path = lifecycle::parent_lock(generation, &pane.id)?;
1190    let guard = lock(&lock_path)?;
1191    let listing = tmux.output(&[
1192        "list-sessions",
1193        "-F",
1194        "#{session_id}|#{@aft_owner}|#{@aft_float_generation}|#{session_attached}|#{@aft_worker}|#{window_id}|#{pane_id}|#{window_linked}|#{@aft_invocation}|#{@aft_instance}",
1195    ])?;
1196    let owned: Vec<_> = listing
1197        .lines()
1198        .filter_map(|line| {
1199            let fields: Vec<_> = line.split('|').collect();
1200            (fields.len() == 10 && fields[1] == pane.id && fields[2] == generation)
1201                .then_some(fields)
1202        })
1203        .collect();
1204    if let Some(instance) = env::var_os("AFT_RESTORE_INSTANCE") {
1205        let Some(instance) = instance.to_str().filter(|value| valid_token(value)) else {
1206            return Ok(());
1207        };
1208        let Some(fields) = owned.iter().find(|fields| fields[9] == instance) else {
1209            return Ok(());
1210        };
1211        if !lifecycle::restore_allowed(tmux, fields[0], instance, invocation, client)? {
1212            return Ok(());
1213        }
1214    }
1215    // Old identity-less sessions are retained, never adopted or automatically killed.
1216    for fields in &owned {
1217        if let Ok(old) = serde_json::from_str::<Invocation>(fields[8]) {
1218            if old != invocation && old.liveness() == Liveness::Exited && valid_token(fields[9]) {
1219                lifecycle::kill_owned(tmux, fields[0], generation, &pane.id, fields[9], old)?;
1220            }
1221        }
1222    }
1223    let matching: Vec<_> = owned
1224        .iter()
1225        .filter(|fields| {
1226            serde_json::from_str::<Invocation>(fields[8]).ok() == Some(invocation)
1227                && valid_token(fields[9])
1228        })
1229        .collect();
1230    ensure!(
1231        matching.len() <= 1,
1232        "multiple sessions claim this pane; inspect sessions before continuing"
1233    );
1234    let (target, window, float_pane, instance) = if let Some(fields) = matching.first() {
1235        if fields[3] != "0" || alive(fields[4]) {
1236            tmux.message(
1237                client,
1238                "Harness Floating Terminal is already open or opening in another client",
1239            );
1240            return Ok(());
1241        }
1242        ensure!(
1243            fields[7] == "0" && lifecycle::exclusive_windows(tmux, fields[0])?,
1244            "owned floating window is linked to another session; left untouched"
1245        );
1246        (
1247            fields[0].to_owned(),
1248            fields[5].to_owned(),
1249            fields[6].to_owned(),
1250            fields[9].to_owned(),
1251        )
1252    } else {
1253        let cwd = text(&pane.cwd)?;
1254        ensure!(
1255            Path::new(&cwd).is_dir(),
1256            "parent pane directory is unavailable"
1257        );
1258        let shell = match &config.shell {
1259            Some(shell) => shell.clone(),
1260            None => pane.default_shell.clone(),
1261        };
1262        checked_path(&shell)?;
1263        tmux.popup_policy(generation)?;
1264        let instance = token()?;
1265        let session = format!("aft-{}-{}-{instance}", &generation[..12], &pane.id[1..]);
1266        let target = tmux.output(&[
1267            "new-session",
1268            "-d",
1269            "-P",
1270            "-F",
1271            "#{session_id}",
1272            "-s",
1273            &session,
1274            "-c",
1275            &literal_format(cwd),
1276            "-e",
1277            "AFT_DISABLE=1",
1278            "-e",
1279            &format!("AFT_TMUX_BINARY={}", text(&tmux.binary)?),
1280            text(&shell)?,
1281            "-i",
1282        ])?;
1283        let metadata = tmux.output(&[
1284            "set-option",
1285            "-t",
1286            &target,
1287            OWNER,
1288            &pane.id,
1289            ";",
1290            "set-option",
1291            "-t",
1292            &target,
1293            FLOAT_GENERATION,
1294            generation,
1295            ";",
1296            "set-option",
1297            "-t",
1298            &target,
1299            "@aft_instance",
1300            &instance,
1301            ";",
1302            "set-option",
1303            "-t",
1304            &target,
1305            "@aft_invocation",
1306            &serde_json::to_string(&invocation)?,
1307            ";",
1308            "set-option",
1309            "-t",
1310            &target,
1311            "destroy-unattached",
1312            "off",
1313            ";",
1314            "set-option",
1315            "-t",
1316            &target,
1317            "status",
1318            "off",
1319            ";",
1320            "display-message",
1321            "-p",
1322            "-t",
1323            &target,
1324            "#{window_id}|#{pane_id}|#{window_linked}",
1325        ])?;
1326        let fields: Vec<_> = metadata.split('|').collect();
1327        ensure!(
1328            fields.len() == 3 && fields[2] == "0",
1329            "new floating window is linked elsewhere or unavailable"
1330        );
1331        (target, fields[0].to_owned(), fields[1].to_owned(), instance)
1332    };
1333    lifecycle::start_watcher(tmux, &target, &instance)?;
1334    if !tmux.popup_client_unchanged(client, pane)? {
1335        return Ok(());
1336    }
1337    // Revalidate after session creation, which can run a shell's startup files.
1338    if !crate::inspect::eligible(pane.pid, &pane.tty, &config.harness_paths)
1339        .is_ok_and(|d| d.eligible && d.invocation == Some(invocation))
1340    {
1341        return Ok(());
1342    }
1343    if env::var_os("AFT_RESTORE_INSTANCE").is_some()
1344        && !lifecycle::restore_allowed(tmux, &target, &instance, invocation, client)?
1345    {
1346        return Ok(());
1347    }
1348    let worker = std::process::id().to_string();
1349    // Keep the safety checks at the mutation boundary, not just in an earlier
1350    // snapshot. All style changes are local to this exclusively owned window/pane.
1351    let condition = [
1352        lifecycle::ownership_condition(generation, &pane.id, &instance, invocation)?,
1353        "#{==:#{session_grouped},0}".into(),
1354        "#{==:#{m:*1*,#{W:#{window_linked}}},0}".into(),
1355        "#{==:#{session_attached},0}".into(),
1356        "#{==:#{window_linked},0}".into(),
1357        "#{==:#{exit-unattached},0}".into(),
1358        format!("#{{==:#{{window_id}},{window}}}"),
1359        format!("#{{==:#{{pane_id}},{float_pane}}}"),
1360    ]
1361    .into_iter()
1362    .reduce(|left, right| format!("#{{&&:{left},{right}}}"))
1363    .context("missing claim condition")?;
1364    let body = format!(
1365        "set-option -t {target} detach-on-destroy on ; \
1366         set-option -w -t {window} remain-on-exit off ; \
1367         set-option -w -t {window} window-style {style} ; \
1368         set-option -w -t {window} window-active-style {style} ; \
1369         set-option -p -t {float_pane} window-style {style} ; \
1370         set-option -p -t {float_pane} window-active-style {style} ; \
1371         set-option -t {target} @aft_worker {worker} ; \
1372         set-option -t {target} @aft_viewer '' ; \
1373         set-option -t {target} @aft_visible 1 ; \
1374         set-option -t {target} @aft_routing 0 ; \
1375         set-option -t {target} @aft_origin_client {origin_pid} ; \
1376         set-option -t {target} @aft_origin_name {origin_name} ; \
1377         set-option -t {target} @aft_origin_session {origin_session} ; \
1378         set-option -g @aft_last_error '' ; display-message -p AFT_READY",
1379        target = tmux_quote(&target),
1380        window = tmux_quote(&window),
1381        float_pane = tmux_quote(&float_pane),
1382        style = tmux_quote(TERMINAL_STYLE),
1383        origin_pid = client.pid,
1384        origin_name = tmux_quote(&client.name),
1385        origin_session = tmux_quote(&client.session),
1386    );
1387    let reply = tmux.source_result(&format!(
1388        "if-shell -F -t {} {} {} {}\n",
1389        tmux_quote(&target),
1390        tmux_quote(&condition),
1391        tmux_quote(&body),
1392        tmux_quote("display-message -p AFT_CHANGED")
1393    ))?;
1394    ensure!(
1395        reply == "AFT_READY",
1396        "floating session changed, is linked, or is already attached; left untouched"
1397    );
1398    let result = (|| -> Result<std::process::ExitStatus> {
1399        let command = lifecycle::viewer_command(tmux, &target, &instance, &worker)?;
1400        routing::prepare(tmux, pane, client, &target, config)?;
1401        // Query the running server, not the client version. Stock tmux retains
1402        // its popup behavior; only our versioned extension accepts this flag.
1403        let status_mouse =
1404            tmux.output(&["display-message", "-p", "#{aft_popup_status_mouse}"])? == "1";
1405        drop(guard);
1406        // This client waits only while the popup is visible. Detaching the inner client
1407        // exits its command, which closes -E without terminating the retained shell.
1408        let mut popup = tmux.command();
1409        popup.arg("display-popup");
1410        if status_mouse {
1411            popup.arg("-M");
1412        }
1413        Ok(popup
1414            .args([
1415                "-E",
1416                "-s",
1417                "fg=terminal,bg=terminal",
1418                "-S",
1419                "fg=terminal,bg=terminal",
1420                "-c",
1421                &client.name,
1422                "-t",
1423                &pane.id,
1424                "-w",
1425                &format!("{}%", config.width),
1426                "-h",
1427                &format!("{}%", config.height),
1428                "-x",
1429                "C",
1430                "-y",
1431                "C",
1432                &command,
1433            ])
1434            .stdin(Stdio::null())
1435            .stdout(Stdio::null())
1436            .status()?)
1437    })();
1438    // A single in-server conditional clears only this worker's claim. There is no
1439    // read/clear round trip and a late cleanup cannot erase a newer viewer claim.
1440    let _guard = lock(&lock_path).ok();
1441    let closed_by_route = tmux.output(&[
1442        "if-shell",
1443        "-F",
1444        "-t",
1445        &target,
1446        &format!("#{{&&:#{{==:#{{@aft_instance}},{instance}}},#{{==:#{{@aft_worker}},{worker}}}}}"),
1447        &format!(
1448            "display-message -p -t {target} '#{{@aft_routing}}' ; set-option -t {target} @aft_worker '' ; set-option -t {target} @aft_viewer ''",
1449            target = tmux_quote(&target)
1450        ),
1451    ]).is_ok_and(|reason| reason == "1");
1452    routing::finished(tmux, &target, &worker);
1453    ensure!(
1454        result?.success() || closed_by_route || invocation.liveness() == Liveness::Exited,
1455        "tmux popup command failed"
1456    );
1457    Ok(())
1458}
1459
1460#[derive(Debug)]
1461struct Float {
1462    name: String,
1463    id: String,
1464    owner: String,
1465    attached: u32,
1466    orphan: bool,
1467}
1468
1469fn owned_sessions(tmux: &Tmux) -> Result<Vec<Float>> {
1470    let generation = tmux.generation()?;
1471    let all = tmux.output(&[
1472        "list-sessions",
1473        "-F",
1474        "#{session_id}|#{@aft_owner}|#{@aft_float_generation}|#{session_attached}",
1475    ])?;
1476    let mut result = Vec::new();
1477    for line in all.lines() {
1478        let fields: Vec<_> = line.split('|').collect();
1479        if fields.len() != 4 || fields[2] != generation || !valid_pane(fields[1]) {
1480            continue;
1481        }
1482        // Names are only presentation; metadata and the exact server establish ownership.
1483        result.push(Float {
1484            name: tmux.output(&["display-message", "-p", "-t", fields[0], "#{session_name}"])?,
1485            id: fields[0].into(),
1486            owner: fields[1].into(),
1487            attached: fields[3].parse()?,
1488            orphan: tmux.pane(fields[1]).is_err(),
1489        });
1490    }
1491    Ok(result)
1492}
1493
1494pub fn sessions(socket: Option<PathBuf>) -> Result<()> {
1495    let tmux = Tmux::resolve(socket)?;
1496    let floats = owned_sessions(&tmux)?;
1497    if floats.is_empty() {
1498        println!("No owned floating shells on {}", tmux.socket.display());
1499    }
1500    for float in floats {
1501        println!(
1502            "{}\towner={}\tviewers={}\t{}",
1503            float.name,
1504            float.owner,
1505            float.attached,
1506            if float.orphan { "orphan" } else { "retained" }
1507        );
1508    }
1509    Ok(())
1510}
1511
1512pub fn cleanup(socket: Option<PathBuf>, session: Option<String>, yes: bool) -> Result<()> {
1513    let tmux = Tmux::resolve(socket)?;
1514    let generation = tmux.generation()?;
1515    let floats = owned_sessions(&tmux)?;
1516    if let Some(name) = &session {
1517        ensure!(
1518            floats.iter().any(|f| &f.name == name && f.orphan),
1519            "no owned orphan session matches {name}"
1520        );
1521    }
1522    for float in floats
1523        .into_iter()
1524        .filter(|f| f.orphan && session.as_ref().is_none_or(|name| name == &f.name))
1525    {
1526        println!(
1527            "{} {} (all jobs in this shell will terminate)",
1528            if yes { "Remove" } else { "Would remove" },
1529            float.name
1530        );
1531        if !yes {
1532            continue;
1533        }
1534        let lock_path = lifecycle::parent_lock(&generation, &float.owner)?;
1535        let _guard = lock(&lock_path)?;
1536        let still_owned = owned_sessions(&tmux)?
1537            .into_iter()
1538            .any(|f| f.id == float.id && f.orphan && f.attached == 0);
1539        ensure!(
1540            still_owned
1541                && tmux.generation()? == generation
1542                && lifecycle::exclusive_windows(&tmux, &float.id)?,
1543            "session changed, is attached, grouped, or linked; left untouched"
1544        );
1545        let condition = [
1546            format!("#{{==:#{{@aft_float_generation}},{generation}}}"),
1547            format!("#{{==:#{{@aft_owner}},{}}}", float.owner),
1548            "#{==:#{session_attached},0}".into(),
1549            "#{==:#{session_grouped},0}".into(),
1550            "#{==:#{m:*1*,#{W:#{window_linked}}},0}".into(),
1551        ]
1552        .into_iter()
1553        .reduce(|left, right| format!("#{{&&:{left},{right}}}"))
1554        .unwrap();
1555        tmux.output(&[
1556            "if-shell",
1557            "-F",
1558            "-t",
1559            &float.id,
1560            &condition,
1561            &format!("kill-session -t {}", tmux_quote(&float.id)),
1562        ])?;
1563    }
1564    if !yes {
1565        println!("Preview only; repeat with --yes to terminate owned orphan shells.");
1566    }
1567    Ok(())
1568}
1569
1570pub fn doctor(socket: Option<PathBuf>) -> Result<()> {
1571    println!(
1572        "agent-float-term {} / {} {}",
1573        env!("CARGO_PKG_VERSION"),
1574        env::consts::OS,
1575        env::consts::ARCH
1576    );
1577    let config = config::load()?;
1578    println!(
1579        "Configuration: valid; key={}, size={}x{}%, explicit mappings={}",
1580        config.key,
1581        config.width,
1582        config.height,
1583        config.harness_paths.len()
1584    );
1585    for harness in ["claude", "codex", "opencode"] {
1586        println!(
1587            "{harness}: {}",
1588            if which(harness).is_ok() {
1589                "on PATH (not an activation guarantee)"
1590            } else {
1591                "not on PATH"
1592            }
1593        );
1594    }
1595    let tmux = Tmux::resolve(socket)?;
1596    tmux.compatible()?;
1597    println!("tmux server: {} (compatible)", tmux.socket.display());
1598    println!(
1599        "tmux client: {} ({})",
1600        tmux.binary.display(),
1601        tmux.client_version()?
1602    );
1603    println!(
1604        "Status-bar mouse: {}",
1605        if tmux.output(&["display-message", "-p", "#{aft_popup_status_mouse}"])? == "1" {
1606            "supported; an exposed status-bar left click dismisses the float and uses the main binding"
1607        } else {
1608            "unavailable on this server; hide the float first (requires the opt-in tmux status-mouse patch)"
1609        }
1610    );
1611    let last_error = tmux.global("@aft_last_error")?;
1612    if !last_error.is_empty() {
1613        println!("Last popup failure: {last_error}");
1614    }
1615    let record = read_record(&record_path(&tmux.socket)?)?;
1616    let installed = match record {
1617        Some(record) => {
1618            tmux.global(GENERATION)? == record.generation
1619                && binding_matches(&record, tmux.key_binding(&record.key)?.as_deref())
1620        }
1621        None => false,
1622    };
1623    println!(
1624        "Owned key binding: {}",
1625        if installed {
1626            "intact"
1627        } else {
1628            "absent or changed; run bind"
1629        }
1630    );
1631    if let Ok(pane) = env::var("TMUX_PANE") {
1632        if let Ok(pane) = tmux.pane(&pane) {
1633            let decision = crate::inspect::eligible(pane.pid, &pane.tty, &config.harness_paths);
1634            println!(
1635                "Current pane: {}",
1636                match decision {
1637                    Ok(decision) => decision.reason,
1638                    Err(_) => "inspection unavailable or ambiguous; key passes through",
1639                }
1640            );
1641        }
1642    }
1643    println!("Diagnostics contain no process arguments, environments, or terminal contents.");
1644    Ok(())
1645}
1646
1647#[cfg(test)]
1648mod tests {
1649    use super::*;
1650
1651    #[test]
1652    fn version_floor_and_identifiers() {
1653        for value in ["3.4", "3.7c", "4.0"] {
1654            assert!(supported_version(value));
1655        }
1656        for value in ["3.2a", "3.3", "3.3a", "3.3z", "2.9", "unknown", "next-3.4"] {
1657            assert!(!supported_version(value));
1658        }
1659        assert!(valid_pane("%123"));
1660        for value in ["%", "%1;kill-server", "main:1", "%1\n"] {
1661            assert!(!valid_pane(value));
1662        }
1663        assert!(valid_token(&token().unwrap()));
1664    }
1665
1666    #[test]
1667    fn format_values_are_literal() {
1668        assert_eq!(
1669            literal_format("/a/#{pane_id}/#(touch bad)"),
1670            "/a/##{pane_id}/##(touch bad)"
1671        );
1672        assert_eq!(tmux_quote("a\"b\\c$d"), "\"a\\\"b\\\\c\\$d\"");
1673    }
1674
1675    #[test]
1676    fn interrupted_rebind_retains_both_owned_states() {
1677        let directory = tempfile::tempdir().unwrap();
1678        let path = directory.path().join("binding.json");
1679        let mut binding = Binding {
1680            socket: "/tmp/owned.sock".into(),
1681            binary: Some("/usr/bin/tmux".into()),
1682            binary_stamp: None,
1683            server_version: None,
1684            generation: token().unwrap(),
1685            key: "M-C-@".into(),
1686            installed: "new helper".into(),
1687            prior_installed: Some("old helper".into()),
1688            previous: Some("original user binding".into()),
1689        };
1690        write_record(&path, &binding).unwrap();
1691        let pending = read_record(&path).unwrap().unwrap();
1692        assert!(binding_matches(&pending, Some("old helper")));
1693        assert!(binding_matches(&pending, Some("new helper")));
1694        assert!(!binding_matches(&pending, Some("later user binding")));
1695        assert_eq!(pending.previous.as_deref(), Some("original user binding"));
1696        binding.prior_installed = None;
1697        write_record(&path, &binding).unwrap();
1698        assert!(!binding_matches(
1699            &read_record(&path).unwrap().unwrap(),
1700            Some("old helper")
1701        ));
1702    }
1703
1704    #[test]
1705    fn socket_directory_allows_sticky_temp_without_relaxing_installer() {
1706        use std::os::unix::fs::{symlink, PermissionsExt};
1707        let directory = tempfile::tempdir().unwrap();
1708        fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o1777)).unwrap();
1709        let socket_dir = directory.path().join("socket-dir");
1710        socket_directory(&socket_dir).unwrap();
1711        assert_eq!(fs::metadata(&socket_dir).unwrap().mode() & 0o777, 0o700);
1712        assert!(private_dir(&directory.path().join("installer-dir")).is_err());
1713        symlink(&socket_dir, directory.path().join("symlink")).unwrap();
1714        assert!(socket_directory(&directory.path().join("symlink")).is_err());
1715        fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o777)).unwrap();
1716        assert!(socket_directory(&directory.path().join("unsafe")).is_err());
1717    }
1718}