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    runtime_dir()?;
798    let record_path = record_path(&tmux.socket)?;
799    let _guard = lock(&record_path.with_extension("lock"))?;
800    let mut generation = tmux.global(GENERATION)?;
801    let existing = read_record(&record_path)?;
802    let existing = existing.filter(|b| b.socket == tmux.socket && b.generation == generation);
803    let initialize_generation = generation.is_empty();
804    if initialize_generation {
805        generation = token()?;
806    }
807    let command = dispatch_command(&tmux, &config.key)?;
808    let float =
809        format!("#{{&&:#{{==:#{{@aft_float_generation}},{generation}}},#{{!=:#{{@aft_owner}},}}}}");
810    // Ask tmux to canonicalize aliases (e.g. C-i -> Tab) and serialize our intended
811    // binding in an owned temporary table. Never adopt a post-write user binding.
812    let table = format!("aft-probe-{}", token()?);
813    let sentinel = if config.key == "F12" { "F11" } else { "F12" };
814    tmux.output(&[
815        "bind-key",
816        "-T",
817        &table,
818        sentinel,
819        "display-message",
820        "AFT_PROBE_SENTINEL",
821    ])?;
822    tmux.output(&[
823        "bind-key",
824        "-T",
825        &table,
826        &config.key,
827        "if-shell",
828        "-F",
829        &float,
830        "detach-client",
831        &format!("run-shell -b {}", tmux_quote(&command)),
832    ])?;
833    let serialized = tmux.output(&["list-keys", "-T", &table]);
834    tmux.output(&["unbind-key", "-a", "-T", &table])?;
835    let serialized = serialized?;
836    let (canonical_key, installed) = serialized
837        .lines()
838        .filter_map(binding_line)
839        .find(|(key, _)| key != sentinel)
840        .context("cannot serialize binding")?;
841    config.key = canonical_key;
842    Config {
843        key: config.key.clone(),
844        ..Config::default()
845    }
846    .validate()
847    .context("tmux's canonical key is unsupported; choose another key")?;
848    let current = tmux.key_binding(&config.key)?;
849    let ours = existing
850        .as_ref()
851        .is_some_and(|b| b.key == config.key && binding_matches(b, current.as_deref()));
852    ensure!(
853        current.is_none() || ours || replace_key,
854        "{} is already bound; choose another key or explicitly use --replace-key",
855        config.key
856    );
857    if initialize_generation {
858        tmux.output(&["set-option", "-g", GENERATION, &generation])?;
859    }
860    ensure!(valid_token(&generation), "invalid server ownership marker");
861    if let Some(old) = &existing {
862        if old.key != config.key {
863            restore(&tmux, old)?;
864        }
865    }
866    let previous = if ours {
867        existing.and_then(|b| b.previous)
868    } else {
869        current.clone()
870    };
871    let mut record = Binding {
872        socket: tmux.socket.clone(),
873        binary: Some(tmux.binary.clone()),
874        binary_stamp: Some(verified_stamp),
875        server_version: Some(server_version),
876        generation,
877        key: config.key.clone(),
878        installed,
879        prior_installed: if ours { current.clone() } else { None },
880        previous,
881    };
882    // Journal the restoration value before changing the live key. An interrupted
883    // apply can be recovered without guessing which previous binding was ours.
884    write_record(&record_path, &record).context("record binding ownership")?;
885    ensure!(
886        tmux.key_binding(&config.key)? == current,
887        "key changed during installation; left untouched"
888    );
889    tmux.source(&format!("{}\n", record.installed))?;
890    ensure!(
891        tmux.key_binding(&config.key)?.as_deref() == Some(&record.installed),
892        "key changed while applying integration; user binding was not adopted"
893    );
894    record.prior_installed = None;
895    write_record(&record_path, &record)?;
896    if !quiet() {
897        println!(
898            "Bound {} on {} (AI invocations only; inside floats, hide).",
899            config.key,
900            tmux.socket.display()
901        );
902    }
903    Ok(())
904}
905
906pub fn unbind_all() -> Result<()> {
907    let directory = Paths::discover()?.state.join("runtime");
908    if !directory.exists() {
909        return Ok(());
910    }
911    private_dir(&directory)?;
912    for entry in fs::read_dir(&directory)? {
913        let path = entry?.path();
914        if path.extension() != Some(OsStr::new("json")) {
915            continue;
916        }
917        let _guard = lock(&path.with_extension("lock"))?;
918        let Some(record) = read_record(&path)? else {
919            continue;
920        };
921        let server = Tmux::new(record.socket.clone()).map(|mut tmux| {
922            if let Some(binary) = &record.binary {
923                tmux.binary = binary.clone();
924            }
925            tmux
926        });
927        match server {
928            Ok(tmux) => {
929                routing::cleanup_server(&tmux, &record.generation)?;
930                match restore(&tmux, &record) {
931                    Ok(true) => println!("Restored {} on {}", record.key, record.socket.display()),
932                    Ok(false) => println!(
933                        "Preserved changed or restarted server binding on {}",
934                        record.socket.display()
935                    ),
936                    Err(error) => {
937                        eprintln!("Preserved unavailable binding record: {error}");
938                        continue;
939                    }
940                }
941            }
942            Err(_) => {
943                eprintln!(
944                    "Server unavailable: {}; missing-executable forwarding remains in place",
945                    record.socket.display()
946                );
947                continue;
948            }
949        }
950        fs::remove_file(path)?;
951    }
952    Ok(())
953}
954
955fn dedicated_socket() -> Result<PathBuf> {
956    let base = env::var_os("XDG_RUNTIME_DIR")
957        .filter(|v| !v.is_empty())
958        .map(PathBuf::from)
959        .unwrap_or_else(env::temp_dir);
960    checked_path(&base)?;
961    Ok(base
962        .canonicalize()
963        .context("runtime base directory must exist")?
964        .join(format!("agent-float-term-{}", config::uid()))
965        .join("tmux.sock"))
966}
967
968fn socket_directory(path: &Path) -> Result<()> {
969    // Socket directories may live under root's sticky /tmp. Do not relax the
970    // general installer checks for user-owned configuration/data directories.
971    if !path.exists() {
972        let parent = fs::metadata(path.parent().context("socket parent")?)?;
973        let trusted = parent.uid() == config::uid() && parent.mode() & 0o022 == 0;
974        let sticky =
975            (parent.uid() == 0 || parent.uid() == config::uid()) && parent.mode() & 0o1000 != 0;
976        ensure!(
977            parent.is_dir() && (trusted || sticky),
978            "unsafe socket parent directory"
979        );
980        use std::os::unix::fs::DirBuilderExt;
981        match fs::DirBuilder::new().mode(0o700).create(path) {
982            Ok(()) => (),
983            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => (),
984            Err(error) => return Err(error).context("create socket directory"),
985        }
986    }
987    private_dir(path)
988}
989
990fn dedicated_marker(path: &Path) -> Result<String> {
991    let file = OpenOptions::new()
992        .read(true)
993        .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
994        .open(path)?;
995    let metadata = file.metadata()?;
996    ensure!(
997        metadata.is_file() && metadata.uid() == config::uid() && metadata.mode() & 0o077 == 0,
998        "unsafe dedicated-server marker"
999    );
1000    let mut marker = String::new();
1001    file.take(64).read_to_string(&mut marker)?;
1002    ensure!(valid_token(&marker), "invalid dedicated-server marker");
1003    Ok(marker)
1004}
1005
1006pub fn start() -> Result<()> {
1007    ensure!(
1008        std::io::stdin().is_terminal() && std::io::stdout().is_terminal(),
1009        "start requires an interactive terminal"
1010    );
1011    if env::var_os("TMUX").is_some_and(|value| !value.is_empty()) {
1012        bind(None, false)?;
1013        if !quiet() {
1014            println!("Already inside tmux; no nested outer session was created.");
1015        }
1016        return Ok(());
1017    }
1018    let config = config::load()?;
1019    let socket = dedicated_socket()?;
1020    #[cfg(target_os = "macos")]
1021    ensure!(
1022        socket.as_os_str().len() < 104,
1023        "socket path is too long; select a shorter XDG_RUNTIME_DIR"
1024    );
1025    socket_directory(socket.parent().context("socket directory")?)?;
1026    let guard = lock(&socket.with_extension("lock"))?;
1027    let mut binary = tmux_binary()?;
1028    let version = checked_output(capture(Command::new(&binary).arg("-V"), None)?)?;
1029    ensure!(
1030        supported_version(version.trim_start_matches("tmux ")),
1031        "need tmux 3.4 or newer"
1032    );
1033    let session = format!("aft-work-{}", &token()?[..12]);
1034    let shell = config
1035        .shell
1036        .clone()
1037        .or_else(|| env::var_os("SHELL").map(PathBuf::from))
1038        .unwrap_or_else(|| "/bin/sh".into());
1039    checked_path(&shell)?;
1040    let marker_path = socket.with_extension("owner");
1041    let mut existed = socket.exists();
1042    if existed {
1043        let metadata = fs::symlink_metadata(&socket)?;
1044        ensure!(
1045            metadata.file_type().is_socket() && metadata.uid() == config::uid(),
1046            "unsafe dedicated socket"
1047        );
1048        if std::os::unix::net::UnixStream::connect(&socket)
1049            .err()
1050            .is_some_and(|error| error.kind() == std::io::ErrorKind::ConnectionRefused)
1051        {
1052            dedicated_marker(&marker_path)
1053                .context("dead socket has no ownership evidence; inspect it manually")?;
1054            let current = fs::symlink_metadata(&socket)?;
1055            ensure!(
1056                metadata.ino() == current.ino() && metadata.dev() == current.dev(),
1057                "socket changed during recovery"
1058            );
1059            fs::remove_file(&socket)?;
1060            existed = false;
1061        }
1062    }
1063    if existed {
1064        let tmux = Tmux::new(socket.clone())?.matching_client()?;
1065        ensure!(
1066            tmux.global("@aft_dedicated")? == "1",
1067            "socket is not an owned dedicated server"
1068        );
1069        ensure!(
1070            tmux.generation()? == dedicated_marker(&marker_path)?,
1071            "dedicated-server generation changed"
1072        );
1073        tmux.compatible()?;
1074        binary = tmux.binary;
1075    }
1076    let mut command = Command::new(&binary);
1077    let cwd = env::current_dir()?;
1078    command
1079        .args(["-S"])
1080        .arg(&socket)
1081        .args(["-f", "/dev/null", "new-session", "-d", "-s", &session, "-c"])
1082        .arg(literal_format(text(&cwd)?))
1083        .args(["-e", "AFT_DISABLE=1"])
1084        .args(["-e", "AFT_QUIET="])
1085        .args(["-e", &format!("AFT_TMUX_BINARY={}", text(&binary)?)])
1086        .arg(&shell)
1087        .arg("-l");
1088    checked_output(capture(&mut command, None)?)?;
1089    let mut tmux = Tmux::new(socket.clone())?;
1090    tmux.binary = binary;
1091    if !existed {
1092        let generation = token()?;
1093        tmux.output(&["set-option", "-g", GENERATION, &generation])?;
1094        tmux.output(&["set-option", "-g", "@aft_dedicated", "1"])?;
1095        tmux.output(&["set-option", "-g", "status", "off"])?;
1096        atomic_private_write(&marker_path, generation.as_bytes())?;
1097    }
1098    drop(guard);
1099    if let Err(error) = bind(Some(socket), false) {
1100        eprintln!("Integration failed; your shell is retained as {session}: {error}");
1101    }
1102    let status = tmux
1103        .command()
1104        .args(["attach-session", "-E", "-t", &format!("={session}")])
1105        .status()?;
1106    ensure!(
1107        status.success(),
1108        "tmux attachment failed; session {session} was preserved"
1109    );
1110    Ok(())
1111}
1112
1113fn alive(pid: &str) -> bool {
1114    let Ok(pid) = pid.parse::<i32>() else {
1115        return false;
1116    };
1117    if pid <= 0 {
1118        return false;
1119    }
1120    // SAFETY: signal 0 only queries process existence/permission; it sends no signal.
1121    let result = unsafe { libc::kill(pid, 0) };
1122    result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
1123}
1124
1125pub fn dispatch(socket: PathBuf, pane: String, client_pid: u32, key: String) -> Result<()> {
1126    Config {
1127        key: key.clone(),
1128        ..Config::default()
1129    }
1130    .validate()?;
1131    let tmux = Tmux::new(socket)?;
1132    let pane = tmux.pane(&pane)?;
1133    let client = tmux.client(client_pid)?;
1134    if client.pane != pane.id {
1135        return Ok(());
1136    }
1137    let mut config = match config::load() {
1138        Ok(config) => config,
1139        Err(_) => return tmux.forward(&pane, &client, &key),
1140    };
1141    // Use tmux's installed spelling (for example Tab for C-i). Shortcut edits
1142    // take effect on bind; dimensions still reload on every opening.
1143    if let Some(binding) = read_record(&record_path(&tmux.socket)?)?
1144        .filter(|binding| binding.socket == tmux.socket && binding.generation == pane.generation)
1145    {
1146        config.key = binding.key;
1147    }
1148    if pane.in_mode {
1149        return tmux.forward(&pane, &client, &key);
1150    }
1151    let invocation = crate::inspect::eligible(pane.pid, &pane.tty, &config.harness_paths)
1152        .ok()
1153        .filter(|decision| decision.eligible)
1154        .and_then(|decision| decision.invocation);
1155    let Some(invocation) = invocation else {
1156        return tmux.forward(&pane, &client, &key);
1157    };
1158    match popup(&tmux, &pane, &client, &config, invocation) {
1159        Ok(()) => Ok(()),
1160        Err(error) => {
1161            let detail: String = format!("pane {}: {error:#}", pane.id)
1162                .chars()
1163                .take(1024)
1164                .map(|c| if c.is_control() { ' ' } else { c })
1165                .collect();
1166            let _ = tmux.output(&["set-option", "-g", "@aft_last_error", &detail]);
1167            // Error context contains our operations, never inspected argv or terminal contents.
1168            tmux.message(
1169                &client,
1170                "agent-float-term: popup failed; run doctor (shells are preserved)",
1171            );
1172            eprintln!("agent-float-term: {error:#}");
1173            Ok(())
1174        }
1175    }
1176}
1177
1178fn popup(
1179    tmux: &Tmux,
1180    pane: &Pane,
1181    client: &Client,
1182    config: &Config,
1183    invocation: Invocation,
1184) -> Result<()> {
1185    validate_popup_client(tmux, pane)?;
1186    let generation = &pane.generation;
1187    let lock_path = lifecycle::parent_lock(generation, &pane.id)?;
1188    let guard = lock(&lock_path)?;
1189    let listing = tmux.output(&[
1190        "list-sessions",
1191        "-F",
1192        "#{session_id}|#{@aft_owner}|#{@aft_float_generation}|#{session_attached}|#{@aft_worker}|#{window_id}|#{pane_id}|#{window_linked}|#{@aft_invocation}|#{@aft_instance}",
1193    ])?;
1194    let owned: Vec<_> = listing
1195        .lines()
1196        .filter_map(|line| {
1197            let fields: Vec<_> = line.split('|').collect();
1198            (fields.len() == 10 && fields[1] == pane.id && fields[2] == generation)
1199                .then_some(fields)
1200        })
1201        .collect();
1202    if let Some(instance) = env::var_os("AFT_RESTORE_INSTANCE") {
1203        let Some(instance) = instance.to_str().filter(|value| valid_token(value)) else {
1204            return Ok(());
1205        };
1206        let Some(fields) = owned.iter().find(|fields| fields[9] == instance) else {
1207            return Ok(());
1208        };
1209        if !lifecycle::restore_allowed(tmux, fields[0], instance, invocation, client)? {
1210            return Ok(());
1211        }
1212    }
1213    // Old identity-less sessions are retained, never adopted or automatically killed.
1214    for fields in &owned {
1215        if let Ok(old) = serde_json::from_str::<Invocation>(fields[8]) {
1216            if old != invocation && old.liveness() == Liveness::Exited && valid_token(fields[9]) {
1217                lifecycle::kill_owned(tmux, fields[0], generation, &pane.id, fields[9], old)?;
1218            }
1219        }
1220    }
1221    let matching: Vec<_> = owned
1222        .iter()
1223        .filter(|fields| {
1224            serde_json::from_str::<Invocation>(fields[8]).ok() == Some(invocation)
1225                && valid_token(fields[9])
1226        })
1227        .collect();
1228    ensure!(
1229        matching.len() <= 1,
1230        "multiple sessions claim this pane; inspect sessions before continuing"
1231    );
1232    let (target, window, float_pane, instance) = if let Some(fields) = matching.first() {
1233        if fields[3] != "0" || alive(fields[4]) {
1234            tmux.message(
1235                client,
1236                "Harness Floating Terminal is already open or opening in another client",
1237            );
1238            return Ok(());
1239        }
1240        ensure!(
1241            fields[7] == "0" && lifecycle::exclusive_windows(tmux, fields[0])?,
1242            "owned floating window is linked to another session; left untouched"
1243        );
1244        (
1245            fields[0].to_owned(),
1246            fields[5].to_owned(),
1247            fields[6].to_owned(),
1248            fields[9].to_owned(),
1249        )
1250    } else {
1251        let cwd = text(&pane.cwd)?;
1252        ensure!(
1253            Path::new(&cwd).is_dir(),
1254            "parent pane directory is unavailable"
1255        );
1256        let shell = match &config.shell {
1257            Some(shell) => shell.clone(),
1258            None => pane.default_shell.clone(),
1259        };
1260        checked_path(&shell)?;
1261        tmux.popup_policy(generation)?;
1262        let instance = token()?;
1263        let session = format!("aft-{}-{}-{instance}", &generation[..12], &pane.id[1..]);
1264        let target = tmux.output(&[
1265            "new-session",
1266            "-d",
1267            "-P",
1268            "-F",
1269            "#{session_id}",
1270            "-s",
1271            &session,
1272            "-c",
1273            &literal_format(cwd),
1274            "-e",
1275            "AFT_DISABLE=1",
1276            "-e",
1277            &format!("AFT_TMUX_BINARY={}", text(&tmux.binary)?),
1278            text(&shell)?,
1279            "-i",
1280        ])?;
1281        let metadata = tmux.output(&[
1282            "set-option",
1283            "-t",
1284            &target,
1285            OWNER,
1286            &pane.id,
1287            ";",
1288            "set-option",
1289            "-t",
1290            &target,
1291            FLOAT_GENERATION,
1292            generation,
1293            ";",
1294            "set-option",
1295            "-t",
1296            &target,
1297            "@aft_instance",
1298            &instance,
1299            ";",
1300            "set-option",
1301            "-t",
1302            &target,
1303            "@aft_invocation",
1304            &serde_json::to_string(&invocation)?,
1305            ";",
1306            "set-option",
1307            "-t",
1308            &target,
1309            "destroy-unattached",
1310            "off",
1311            ";",
1312            "set-option",
1313            "-t",
1314            &target,
1315            "status",
1316            "off",
1317            ";",
1318            "display-message",
1319            "-p",
1320            "-t",
1321            &target,
1322            "#{window_id}|#{pane_id}|#{window_linked}",
1323        ])?;
1324        let fields: Vec<_> = metadata.split('|').collect();
1325        ensure!(
1326            fields.len() == 3 && fields[2] == "0",
1327            "new floating window is linked elsewhere or unavailable"
1328        );
1329        (target, fields[0].to_owned(), fields[1].to_owned(), instance)
1330    };
1331    lifecycle::start_watcher(tmux, &target, &instance)?;
1332    if !tmux.popup_client_unchanged(client, pane)? {
1333        return Ok(());
1334    }
1335    // Revalidate after session creation, which can run a shell's startup files.
1336    if !crate::inspect::eligible(pane.pid, &pane.tty, &config.harness_paths)
1337        .is_ok_and(|d| d.eligible && d.invocation == Some(invocation))
1338    {
1339        return Ok(());
1340    }
1341    if env::var_os("AFT_RESTORE_INSTANCE").is_some()
1342        && !lifecycle::restore_allowed(tmux, &target, &instance, invocation, client)?
1343    {
1344        return Ok(());
1345    }
1346    let worker = std::process::id().to_string();
1347    // Keep the safety checks at the mutation boundary, not just in an earlier
1348    // snapshot. All style changes are local to this exclusively owned window/pane.
1349    let condition = [
1350        lifecycle::ownership_condition(generation, &pane.id, &instance, invocation)?,
1351        "#{==:#{session_grouped},0}".into(),
1352        "#{==:#{m:*1*,#{W:#{window_linked}}},0}".into(),
1353        "#{==:#{session_attached},0}".into(),
1354        "#{==:#{window_linked},0}".into(),
1355        "#{==:#{exit-unattached},0}".into(),
1356        format!("#{{==:#{{window_id}},{window}}}"),
1357        format!("#{{==:#{{pane_id}},{float_pane}}}"),
1358    ]
1359    .into_iter()
1360    .reduce(|left, right| format!("#{{&&:{left},{right}}}"))
1361    .context("missing claim condition")?;
1362    let body = format!(
1363        "set-option -t {target} detach-on-destroy on ; \
1364         set-option -w -t {window} remain-on-exit off ; \
1365         set-option -w -t {window} window-style {style} ; \
1366         set-option -w -t {window} window-active-style {style} ; \
1367         set-option -p -t {float_pane} window-style {style} ; \
1368         set-option -p -t {float_pane} window-active-style {style} ; \
1369         set-option -t {target} @aft_worker {worker} ; \
1370         set-option -t {target} @aft_viewer '' ; \
1371         set-option -t {target} @aft_visible 1 ; \
1372         set-option -t {target} @aft_routing 0 ; \
1373         set-option -t {target} @aft_origin_client {origin_pid} ; \
1374         set-option -t {target} @aft_origin_name {origin_name} ; \
1375         set-option -t {target} @aft_origin_session {origin_session} ; \
1376         set-option -g @aft_last_error '' ; display-message -p AFT_READY",
1377        target = tmux_quote(&target),
1378        window = tmux_quote(&window),
1379        float_pane = tmux_quote(&float_pane),
1380        style = tmux_quote(TERMINAL_STYLE),
1381        origin_pid = client.pid,
1382        origin_name = tmux_quote(&client.name),
1383        origin_session = tmux_quote(&client.session),
1384    );
1385    let reply = tmux.source_result(&format!(
1386        "if-shell -F -t {} {} {} {}\n",
1387        tmux_quote(&target),
1388        tmux_quote(&condition),
1389        tmux_quote(&body),
1390        tmux_quote("display-message -p AFT_CHANGED")
1391    ))?;
1392    ensure!(
1393        reply == "AFT_READY",
1394        "floating session changed, is linked, or is already attached; left untouched"
1395    );
1396    let result = (|| -> Result<std::process::ExitStatus> {
1397        let command = lifecycle::viewer_command(tmux, &target, &instance, &worker)?;
1398        routing::prepare(tmux, pane, client, &target, config)?;
1399        drop(guard);
1400        // This client waits only while the popup is visible. Detaching the inner client
1401        // exits its command, which closes -E without terminating the retained shell.
1402        Ok(tmux
1403            .command()
1404            .args([
1405                "display-popup",
1406                "-E",
1407                "-s",
1408                "fg=terminal,bg=terminal",
1409                "-S",
1410                "fg=terminal,bg=terminal",
1411                "-c",
1412                &client.name,
1413                "-t",
1414                &pane.id,
1415                "-w",
1416                &format!("{}%", config.width),
1417                "-h",
1418                &format!("{}%", config.height),
1419                "-x",
1420                "C",
1421                "-y",
1422                "C",
1423                &command,
1424            ])
1425            .stdin(Stdio::null())
1426            .stdout(Stdio::null())
1427            .status()?)
1428    })();
1429    // A single in-server conditional clears only this worker's claim. There is no
1430    // read/clear round trip and a late cleanup cannot erase a newer viewer claim.
1431    let _guard = lock(&lock_path).ok();
1432    let closed_by_route = tmux.output(&[
1433        "if-shell",
1434        "-F",
1435        "-t",
1436        &target,
1437        &format!("#{{&&:#{{==:#{{@aft_instance}},{instance}}},#{{==:#{{@aft_worker}},{worker}}}}}"),
1438        &format!(
1439            "display-message -p -t {target} '#{{@aft_routing}}' ; set-option -t {target} @aft_worker '' ; set-option -t {target} @aft_viewer ''",
1440            target = tmux_quote(&target)
1441        ),
1442    ]).is_ok_and(|reason| reason == "1");
1443    routing::finished(tmux, &target, &worker);
1444    ensure!(
1445        result?.success() || closed_by_route || invocation.liveness() == Liveness::Exited,
1446        "tmux popup command failed"
1447    );
1448    Ok(())
1449}
1450
1451#[derive(Debug)]
1452struct Float {
1453    name: String,
1454    id: String,
1455    owner: String,
1456    attached: u32,
1457    orphan: bool,
1458}
1459
1460fn owned_sessions(tmux: &Tmux) -> Result<Vec<Float>> {
1461    let generation = tmux.generation()?;
1462    let all = tmux.output(&[
1463        "list-sessions",
1464        "-F",
1465        "#{session_id}|#{@aft_owner}|#{@aft_float_generation}|#{session_attached}",
1466    ])?;
1467    let mut result = Vec::new();
1468    for line in all.lines() {
1469        let fields: Vec<_> = line.split('|').collect();
1470        if fields.len() != 4 || fields[2] != generation || !valid_pane(fields[1]) {
1471            continue;
1472        }
1473        // Names are only presentation; metadata and the exact server establish ownership.
1474        result.push(Float {
1475            name: tmux.output(&["display-message", "-p", "-t", fields[0], "#{session_name}"])?,
1476            id: fields[0].into(),
1477            owner: fields[1].into(),
1478            attached: fields[3].parse()?,
1479            orphan: tmux.pane(fields[1]).is_err(),
1480        });
1481    }
1482    Ok(result)
1483}
1484
1485pub fn sessions(socket: Option<PathBuf>) -> Result<()> {
1486    let tmux = Tmux::resolve(socket)?;
1487    let floats = owned_sessions(&tmux)?;
1488    if floats.is_empty() {
1489        println!("No owned floating shells on {}", tmux.socket.display());
1490    }
1491    for float in floats {
1492        println!(
1493            "{}\towner={}\tviewers={}\t{}",
1494            float.name,
1495            float.owner,
1496            float.attached,
1497            if float.orphan { "orphan" } else { "retained" }
1498        );
1499    }
1500    Ok(())
1501}
1502
1503pub fn cleanup(socket: Option<PathBuf>, session: Option<String>, yes: bool) -> Result<()> {
1504    let tmux = Tmux::resolve(socket)?;
1505    let generation = tmux.generation()?;
1506    let floats = owned_sessions(&tmux)?;
1507    if let Some(name) = &session {
1508        ensure!(
1509            floats.iter().any(|f| &f.name == name && f.orphan),
1510            "no owned orphan session matches {name}"
1511        );
1512    }
1513    for float in floats
1514        .into_iter()
1515        .filter(|f| f.orphan && session.as_ref().is_none_or(|name| name == &f.name))
1516    {
1517        println!(
1518            "{} {} (all jobs in this shell will terminate)",
1519            if yes { "Remove" } else { "Would remove" },
1520            float.name
1521        );
1522        if !yes {
1523            continue;
1524        }
1525        let lock_path = lifecycle::parent_lock(&generation, &float.owner)?;
1526        let _guard = lock(&lock_path)?;
1527        let still_owned = owned_sessions(&tmux)?
1528            .into_iter()
1529            .any(|f| f.id == float.id && f.orphan && f.attached == 0);
1530        ensure!(
1531            still_owned
1532                && tmux.generation()? == generation
1533                && lifecycle::exclusive_windows(&tmux, &float.id)?,
1534            "session changed, is attached, grouped, or linked; left untouched"
1535        );
1536        let condition = [
1537            format!("#{{==:#{{@aft_float_generation}},{generation}}}"),
1538            format!("#{{==:#{{@aft_owner}},{}}}", float.owner),
1539            "#{==:#{session_attached},0}".into(),
1540            "#{==:#{session_grouped},0}".into(),
1541            "#{==:#{m:*1*,#{W:#{window_linked}}},0}".into(),
1542        ]
1543        .into_iter()
1544        .reduce(|left, right| format!("#{{&&:{left},{right}}}"))
1545        .unwrap();
1546        tmux.output(&[
1547            "if-shell",
1548            "-F",
1549            "-t",
1550            &float.id,
1551            &condition,
1552            &format!("kill-session -t {}", tmux_quote(&float.id)),
1553        ])?;
1554    }
1555    if !yes {
1556        println!("Preview only; repeat with --yes to terminate owned orphan shells.");
1557    }
1558    Ok(())
1559}
1560
1561pub fn doctor(socket: Option<PathBuf>) -> Result<()> {
1562    println!(
1563        "agent-float-term {} / {} {}",
1564        env!("CARGO_PKG_VERSION"),
1565        env::consts::OS,
1566        env::consts::ARCH
1567    );
1568    let config = config::load()?;
1569    println!(
1570        "Configuration: valid; key={}, size={}x{}%, explicit mappings={}",
1571        config.key,
1572        config.width,
1573        config.height,
1574        config.harness_paths.len()
1575    );
1576    for harness in ["claude", "codex", "opencode"] {
1577        println!(
1578            "{harness}: {}",
1579            if which(harness).is_ok() {
1580                "on PATH (not an activation guarantee)"
1581            } else {
1582                "not on PATH"
1583            }
1584        );
1585    }
1586    let tmux = Tmux::resolve(socket)?;
1587    tmux.compatible()?;
1588    println!("tmux server: {} (compatible)", tmux.socket.display());
1589    println!(
1590        "tmux client: {} ({})",
1591        tmux.binary.display(),
1592        tmux.client_version()?
1593    );
1594    let last_error = tmux.global("@aft_last_error")?;
1595    if !last_error.is_empty() {
1596        println!("Last popup failure: {last_error}");
1597    }
1598    let record = read_record(&record_path(&tmux.socket)?)?;
1599    let installed = match record {
1600        Some(record) => {
1601            tmux.global(GENERATION)? == record.generation
1602                && binding_matches(&record, tmux.key_binding(&record.key)?.as_deref())
1603        }
1604        None => false,
1605    };
1606    println!(
1607        "Owned key binding: {}",
1608        if installed {
1609            "intact"
1610        } else {
1611            "absent or changed; run bind"
1612        }
1613    );
1614    if let Ok(pane) = env::var("TMUX_PANE") {
1615        if let Ok(pane) = tmux.pane(&pane) {
1616            let decision = crate::inspect::eligible(pane.pid, &pane.tty, &config.harness_paths);
1617            println!(
1618                "Current pane: {}",
1619                match decision {
1620                    Ok(decision) => decision.reason,
1621                    Err(_) => "inspection unavailable or ambiguous; key passes through",
1622                }
1623            );
1624        }
1625    }
1626    println!("Diagnostics contain no process arguments, environments, or terminal contents.");
1627    Ok(())
1628}
1629
1630#[cfg(test)]
1631mod tests {
1632    use super::*;
1633
1634    #[test]
1635    fn version_floor_and_identifiers() {
1636        for value in ["3.4", "3.7c", "4.0"] {
1637            assert!(supported_version(value));
1638        }
1639        for value in ["3.2a", "3.3", "3.3a", "3.3z", "2.9", "unknown", "next-3.4"] {
1640            assert!(!supported_version(value));
1641        }
1642        assert!(valid_pane("%123"));
1643        for value in ["%", "%1;kill-server", "main:1", "%1\n"] {
1644            assert!(!valid_pane(value));
1645        }
1646        assert!(valid_token(&token().unwrap()));
1647    }
1648
1649    #[test]
1650    fn format_values_are_literal() {
1651        assert_eq!(
1652            literal_format("/a/#{pane_id}/#(touch bad)"),
1653            "/a/##{pane_id}/##(touch bad)"
1654        );
1655        assert_eq!(tmux_quote("a\"b\\c$d"), "\"a\\\"b\\\\c\\$d\"");
1656    }
1657
1658    #[test]
1659    fn interrupted_rebind_retains_both_owned_states() {
1660        let directory = tempfile::tempdir().unwrap();
1661        let path = directory.path().join("binding.json");
1662        let mut binding = Binding {
1663            socket: "/tmp/owned.sock".into(),
1664            binary: Some("/usr/bin/tmux".into()),
1665            binary_stamp: None,
1666            server_version: None,
1667            generation: token().unwrap(),
1668            key: "M-C-@".into(),
1669            installed: "new helper".into(),
1670            prior_installed: Some("old helper".into()),
1671            previous: Some("original user binding".into()),
1672        };
1673        write_record(&path, &binding).unwrap();
1674        let pending = read_record(&path).unwrap().unwrap();
1675        assert!(binding_matches(&pending, Some("old helper")));
1676        assert!(binding_matches(&pending, Some("new helper")));
1677        assert!(!binding_matches(&pending, Some("later user binding")));
1678        assert_eq!(pending.previous.as_deref(), Some("original user binding"));
1679        binding.prior_installed = None;
1680        write_record(&path, &binding).unwrap();
1681        assert!(!binding_matches(
1682            &read_record(&path).unwrap().unwrap(),
1683            Some("old helper")
1684        ));
1685    }
1686
1687    #[test]
1688    fn socket_directory_allows_sticky_temp_without_relaxing_installer() {
1689        use std::os::unix::fs::{symlink, PermissionsExt};
1690        let directory = tempfile::tempdir().unwrap();
1691        fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o1777)).unwrap();
1692        let socket_dir = directory.path().join("socket-dir");
1693        socket_directory(&socket_dir).unwrap();
1694        assert_eq!(fs::metadata(&socket_dir).unwrap().mode() & 0o777, 0o700);
1695        assert!(private_dir(&directory.path().join("installer-dir")).is_err());
1696        symlink(&socket_dir, directory.path().join("symlink")).unwrap();
1697        assert!(socket_directory(&directory.path().join("symlink")).is_err());
1698        fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o777)).unwrap();
1699        assert!(socket_directory(&directory.path().join("unsafe")).is_err());
1700    }
1701}