1use 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
89fn 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 let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
129 ensure!(flags >= 0, "cannot inspect command pipe flags");
130 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 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 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
512fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let status_mouse =
1404 tmux.output(&["display-message", "-p", "#{aft_popup_status_mouse}"])? == "1";
1405 drop(guard);
1406 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 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 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}