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 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 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 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 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 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 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 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 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 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 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 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 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 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}