Help us shape the future of Go! Take the 2026 Go Developer Survey

tmux

package
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 3, 2026 License: MIT Imports: 16 Imported by: 0

README

tmux controller: design

This document explains why the controller is built the way it is: the design decisions, the alternatives they rejected, and the tmux pitfalls they avoid. How to use the package is described by its godoc (doc.go).

Every pitfall described here was met on a real tmux, and most are guarded by a test: when changing something below, run the tests against a real tmux, they are the executable part of this document.

Server

rat owns a foreground server

tmux normally daemonizes its server: the process starting it exits, and nobody can wait for the server. rat starts it with -D instead, which keeps it in the foreground as a child of rat. rat can then wait for it, notice right away when it dies, and reap it.

A single goroutine calls Wait() on the server (it can only be called once); everything else learns the server exited through a channel closed by that goroutine. This is how an early exit is reported during startup, how StopServer waits for the exit, and how StartServer notices a previous server died on its own and can start a new one.

WaitServer exposes that exit to the caller, with its reason, so that it can restart the server (the controller never does it by itself). Each run of the server is a value of its own (serverProcess): a waiter keeps it, and still reads why it exited once the controller has moved on to another server. An exit asked by StopServer is told apart from a server exiting on its own: a crash, the OOM killer, or tmux kill-server typed in a terminal, which reaches rat's server since TMUX is kept (see Terminal environment).

A foreground server also survives its last session: tmux turns exit-empty off for it (server.c). Otherwise an agent closing its last window would stop every terminal of the tenant.

The consequence is that stopping rat stops its terminals, and their running commands: what an admin stopping the service counts on (see AGENTS.md). Rejected: adopting a server already running on the socket at startup, which would let terminals survive a rat restart, but loses that guarantee, and the ability to wait for the server (it is not rat's child anymore).

A rat killed without a chance to stop its server (SIGKILL, a crash) leaves it running: the kernel does not stop a child with its parent, the server is reparented to init, holding the socket of the tenant, its terminals and their commands (seen with a test run killed: its servers were still there hours later). As a service, the control group ends it with the rest (see AGENTS.md). Started by hand, the next rat on the tenant is refused with the PID of that server (ErrServerSocketInUse), for the admin to stop it: nothing is left silently. Rejected: Pdeathsig, the signal the kernel sends a child when its parent dies, which fires when the thread that started it exits, not the process. Go ends a thread when a goroutine locked to it (runtime.LockOSThread) returns: rat locks none today, but a later change starting the server from such a goroutine would stop the terminals under a running rat, with nothing in the code showing the dependency. Linux only as well, for a gap the service already closes.

One socket per tenant

The server listens on -L rat-<tenant> (rat for the default tenant). tmux puts sockets in a per user directory (/tmp/tmux-<uid>/, mode 0700, refused if owned by someone else), so tenants of different users never clash, and other users can not reach them. Tenant names are validated: / or .. would move the socket out of that directory, and the length is bounded by the unix socket path limit (104 bytes on macOS, 108 on Linux).

Readiness: our server, answering

The server socket appears a few milliseconds after the process starts. StartServer probes the server (display-message -p '#{pid}') every 50 ms, for up to a second, and only accepts an answer carrying the PID of the process it started. Another server may be answering on the socket: a leftover of a previous run, or another rat on the same tenant.

  • If a server already runs on the socket, tmux -D does not start a second one: it connects to it as a client, fails (open terminal failed: not a terminal) and exits. The watcher reports that exit within milliseconds. The message tells nothing of the cause, so rather than parsing it, StartServer asks the socket: a server answering there, ours being gone, is another one, reported as ErrServerSocketInUse with its PID. This is what keeps a tenant to a single rat on a machine (for a user). The tmux stderr is still kept (bounded) and included in the error, for the other startup failures: tmux only writes there when failing at startup.
  • A stale socket (its server died without cleaning it) is simply taken over by the new server.

Rejected: checking the socket is free before starting. It could be taken right after the check, so the check after starting would be needed anyway.

Stopping, gracefully then not

StopServer asks the server to exit (kill-server), then escalates:

  1. no exit after 5 seconds: the server context is canceled, which sends SIGTERM (a custom Cancel: the default would be SIGKILL, giving tmux no chance to clean up);
  2. no exit 2 seconds later: WaitDelay makes Go send SIGKILL.

The 5 seconds include kill-server itself: a stuck server does not answer it, and the caller may give no deadline (ratd does not, when it stops). kill-server failing does not mean the server is stuck either: it may be exiting already, on a signal of its own, as when systemd stops a service by signaling all its processes at once. The server is then waited for all the same, and its exit reported as its own (ErrServerNotRunning) rather than as terminated by StopServer.

WaitDelay does not bound how long Wait() blocks on a running process: its timer only starts once the context is canceled (or the process exited with its pipes still open). This is why the first step needs its own timer. The error tells what was needed (ErrServerTerminated, ErrServerKilled), read from the exit status. Even SIGKILL can not guarantee the server is gone when StopServer returns: a process in uninterruptible sleep only dies when it leaves it.

Commands and the server lock

Commands on sessions and windows hold the server lock shared (they run concurrently), starting and stopping hold it exclusively. Without it, a command could run while the server is being stopped: new-session would then start a new server by itself, daemonized, which rat would not watch nor stop. Only a server crashing while a command runs can still lead to that.

A stuck server

A command on a server that does not answer (stopped, deadlocked, dying) must still return once its context ends, for the caller to tell its own caller. Killing the tmux client is not enough: a client hands its stdin, stdout and stderr to the server (file descriptors passed over the socket), and a server not reading its socket leaves them in the socket queue, where the kernel keeps them open. Waiting for the output of the killed client then waits for the server: with a server stopped for 3 seconds and a client killed after half a second, its output reached its end after 4 seconds (tmux 3.7c). Every command therefore has a WaitDelay: once its context has ended and the client is killed, waiting gives up on the pipes after a second, and closes them.

A server shutting down during a command

A server shutting down cleanly (SIGTERM, kill-server) tells its clients to exit normally: a client whose command was still waiting exits with status 0, no output and no error, although its command never ran (tmux 3.3a and 3.7c). The controller can not tell it from a success by the exit status. It can by the output, for the commands that always print something: list-windows lists a window at least (a session has one), a capture prints the state of the pane. No output then means the server is gone, reported as ErrServerNotRunning. Silent commands (a paste, keys, a new window) have nothing to tell: one sent while the server shuts down is reported as done, and the caller learns of the shutdown with its next command.

A server killed, or dying on its own, makes its clients fail instead ("server exited unexpectedly"). A failed command is explained by asking tmux what exists (see Targets and names): when that fails as well, the server is given half a second to be seen exiting, and the failure is reported as ErrServerNotRunning rather than with the message. Not once the context of the command has ended: a server stuck rather than dying would make every timeout longer.

Rejected: ending each silent invocation with a command printing a marker, whose absence would tell the invocation did not complete. Should the shutdown lose the output of an invocation that did complete, it would be reported as failed, and retried: a command pasted twice runs twice, worse than a paste reported done in terminals about to vanish.

Environment

The goal: the same terminals on every machine, whatever the user configuration and whatever the environment rat runs in (a systemd service often has no locale, no terminal, / as working directory).

No tmux configuration

Every tmux invocation passes -f /dev/null, so neither the user configuration (~/.tmux.conf, ~/.config/tmux/tmux.conf) nor the system one (/etc/tmux.conf) is loaded: they could change the prefix, the window indexes, add hooks or plugins. The flag only matters for a command starting a server, but that is not only StartServer: new-session starts a server when none is running, so every invocation carries it.

Options

Without a configuration file, rat sets what it relies on once the server is ready, everything else being tmux defaults:

  • default-shell: bash (see below).
  • history-limit 10000 (tmux default 2000): the scrollback a capture can include.
  • default-size 200x24 (tmux default 80x24). Wide, because programs lay out their output for the terminal width and some truncate lines to it (ps, docker, tables): joining wrapped lines at capture can not recover what they cut. Short, because the height is what every capture returns, and agents capture repeatedly while waiting for a command. Not shorter than 24 though, the classic height full-screen programs are designed for: below it some refuse to run or cut their menus, top shows few processes, and pagers kick in more often.
  • window-size manual, with the size of terminals: with the default (latest), a human attaching to inspect the terminals resizes them to their own terminal, and they keep that size once the human detaches. The policy alone is not enough: a window created while a client is attached, to any session, is created at the size of that client (default-size only applies when none is), and manual keeps the size the window has. A client attached read-only (attach -r, flagged ignore-size) counts all the same when it is the only one, and new-session -x -y only sets the default-size of the session, not the size of its window (measured with tmux 3.4, a read-only client in a terminal of 120x40: windows of 120x39). Both are set on each window, in the invocation creating it, by resize-window -x 200 -y 24, which sets the policy to manual as well. Not globally: tmux 3.3 to 3.6 crash (segfault, killing every terminal) when a session is created while the global value is manual. Fixed in tmux 3.7 (commit 7d41761e, GitHub issue 4849), without a changelog entry. The global value stays at the default, and a test guards it.
  • allow-rename off: already the default, but window names are how agents find their terminals: programs must not rename them (escape sequences).
Window keys, for a human watching read-only

A human watches the terminals read-only (attach -r, as the root README recommends): what they type must not reach the agents' shells. tmux then refuses every key but those bound to switch-client or detach-client, the others answering "Client is read-only", the keys moving between windows included: the human could only watch the window they attached to.

rat binds those keys to switch-client instead (windowKeys): C-b n, p, l and 0 to 9 to switch-client -t :+, :-, :! and :=0 to :=9. Switching to a window of the current session selects it, as next-window, previous-window, last-window and select-window do for a client that is not read-only, wrapping around the same way: such a client sees no difference. Moving changes nothing for agents: rat never reads the current window of a session, targeting windows by name (see Targets and names) and creating them without selecting them (-d). The current window belongs to the session, not to the client: two humans watching the same session move together.

Still refused read-only, having no switch-client equivalent: the window chooser (C-b w), and copy mode to scroll back (C-b [) before tmux 3.7 ("Allow copy mode to work for readonly clients" in its changelog; refused by 3.3a and 3.5a, measured). tmux 3.7 also tightened the read-only checks of switch-client, so that a client only detaches itself: switching windows still works. Measured with tmux 3.3a and 3.7c, which a test guards with a read-only client attached in a terminal.

Rejected: dropping -r, which lets a key typed while watching reach a shell; attaching to a given window (attach -r -t '=session:=window'), detaching to watch another, which needs no code but makes watching several windows a chore.

Terminals run bash, at home

Terminals run bash, checked at StartServer rather than failing on each new window: it is always available, and a single shell keeps terminals the same on every machine. tmux would otherwise run the login shell of the account: it changes from one machine to another, a dedicated user has none (nologin, as the root README sets it up), and each shell would need a prompt hook of its own (see Prompts). bash starts as a login shell: it reads ~/.bash_profile or ~/.profile, not ~/.bashrc unless the profile sources it.

bash 4.4 or later is required, and StartServer refuses an older one: pasted text relies on bracketed paste (see Input), which bash has since 4.4. macOS ships bash 3.2 (the last version under GPLv2) as /bin/bash: a recent one must be installed (Homebrew) and come first in PATH. The version is read from bash --version rather than from a script printing BASH_VERSINFO, which would run the file named by BASH_ENV.

Sessions and windows start in the home directory of the user running rat, as a terminal does: with a dedicated user for agents, it is their own. tmux would otherwise start them in the working directory of the client creating them, rat's (/ for a systemd service). This holds for windows too: a new window does not inherit the directory of its session, it needs -c as well.

Terminal environment

Terminals inherit rat's environment, plus variables enforced at StartServer:

  • LC_ALL=C.UTF-8. Without a UTF-8 locale, bash reads non ASCII input (é, ✓) as meta keys and mangles it: echo 'ok ✓ café' becomes echo ' cafOk'. The C locale gives English messages and stable formats (decimal point, dates), the easiest for models to read, and LC_ALL overrides any other locale variable. Rejected: fixing the locale only when rat's is not UTF-8 (with LC_CTYPE), which kept terminals different from one machine to another.

  • PAGER, GIT_PAGER, MANPAGER, SYSTEMD_PAGER set to cat: tools would otherwise open less when their output does not fit the screen, and agents would have to notice it and quit it. The output goes to the scrollback instead. The tool specific ones are needed as they take precedence over PAGER and users often set them (GIT_PAGER also overrides core.pager).

  • PROMPT_COMMAND, which records each prompt (see Prompts) and runs bind "set enable-bracketed-paste on": pasted text relies on bracketed paste (see Input), which bash 4.4 and 5.0 do not enable by default, and an inputrc can disable. bash runs PROMPT_COMMAND before each prompt, after its startup files, so the setting holds whatever they say. It is set apart from the other variables, as it names the tmux running the server. Rejected: an inputrc of rat's (INPUTRC), a file to maintain; typing the bind command in each new terminal, which shows on the screen and in the history, and holds only until something changes the setting.

    Limit: a startup file assigning PROMPT_COMMAND (rather than adding to it, as most do) replaces it: the inputrc or bash default applies, and no prompt is recorded. A command a startup file adds in front of rat's may change the exit status rat's gets, and loses the statuses of a pipeline even when it keeps the status (direnv does: see Pipelines). rat does not refuse to run then (single line inputs still work), but CheckTerminals detects them, for the caller to warn: it runs bash once as terminals start it (login, interactive, their environment, TERM and TMUX included, as startup files often run tmux when TMUX is empty, and PROMPT_COMMAND as StartServer set it, not read back: see show-environment escapes $), and types commands into it:

    1. unset HISTFILE: an interactive bash saves the commands it read in the history of the user when it exits.
    2. (exit 6) | (exit 7): a pipeline whose status, 7, the commands of a PROMPT_COMMAND are unlikely to leave, unlike 0 or 1 (history -a fails with 1 once HISTFILE is unset).
    3. A line answering with what bracketed paste is at the prompt, as readline reports it (bind -v), the status and the pipeline rat's command got (__rat_status and __rat_pipeline, which it leaves in the shell; empty if it did not run), and what PROMPT_COMMAND became, for the logs.

    Typed on its input rather than given with -c, the commands make bash show its prompt before each, running PROMPT_COMMAND as in a terminal: what is checked is what the prompt ends up with, whoever turned it on. Rejected: reading what PROMPT_COMMAND became, the check until then, which took starship for an override (measured with starship 1.26): it replaces PROMPT_COMMAND with a function of its own, which runs the previous value (and changes the status before). Measured with bash 4.4, 5.0 and 5.3: PROMPT_COMMAND runs, and bind -v answers, without a terminal. TMUX_PANE is left out of its environment: rat's command would record the prompts of the check in the pane it names, on rat's server, which TMUX names (a rat started in a terminal of another tmux inherits a TMUX_PANE, naming a pane of rat's server as well, measured). It runs bash directly: tmux 3.3 does not return the output of run-shell to the client asking for it. It is a snapshot: a startup file changed afterwards goes unnoticed until the next check, but for the windows created since, which tell it (see Hooked windows).

TMUX is kept, on purpose. tmux sets it in every terminal (it can not be removed with set-environment, only by the command starting the terminal), and a tmux command typed in a terminal uses it to reach rat's server. Removing it would point such commands to the user's default server instead: an agent typing tmux kill-server would kill the user's own sessions, and a plain tmux would open a tmux nested in its terminal, which tmux refuses as long as TMUX is set.

Targets and names

Exact targets, never IDs

Windows are always targeted as =session:=window. Without =, tmux also accepts a prefix or a pattern: sess would reach the session session, and win the window window, silently acting on another terminal than the one named.

tmux IDs ($session, @window, %pane) are never used: they are global to the server, so an ID would reach a window of another session. Keeping them would also be a cache, stale as soon as a window closes behind rat's back (an agent typing exit, a human closing it). Names are asked to tmux every time instead.

Names

Session and window names follow the tenant rule, held by package tmux/names: letters, digits, _ and -, up to 32 characters. In targets, : and . separate the session, window and pane parts, and a leading = asks for an exact match: any of them in a name would change what it targets.

A window name is not made of digits only either: tmux reads the window part of a target as an index first (07 as index 7), and = only turns off prefix and pattern matching. =s:=1 reaches the window at index 1 when there is one, whatever its name, and the window named 1 only otherwise. Measured with tmux 3.4, windows main, a, b and 1 at indexes 0 to 3: =s:=1 reached a, text sent to 1 ran in a, and closing 1 closed a, while the window description, looked up by name, told of 1. Session targets have no index, and a tenant is no target: the rule is the window's alone (names.CheckWindow), certificates naming tenants and clients as before. Rejected: resolving the name to its window ID first (@1), which rat never uses (see Exact targets, never IDs), and which would take a second invocation, the window changing in between.

Window names are unique within a session, as they are how agents find their terminals. tmux accepts duplicates, and an exact target then fails as if the window did not exist, so rat checks the name is free before creating a window (under a lock, the check and the creation are atomic within rat). This only protects against rat itself: a tmux new-window typed in a terminal could still create a duplicate.

display-message never fails

display-message does not fail on a missing target: for a missing session it prints empty fields, and for a missing window it silently describes the current window of the session instead. Windows are therefore described from list-windows, looking the name up. Two display-message are left: the readiness probe, which has no target, and the state read with a capture, in an invocation that fails with the capture on a missing target (see Capture).

Missing targets: ask tmux, do not parse its messages

tmux reports a missing target with messages depending on its state: can't find session: x, but no current target once no session is left. Rather than parsing them, a failed command is explained by asking tmux which sessions and windows exist, to return ErrSessionNotFound or ErrWindowNotFound. It costs extra commands on the error path only.

Reading tmux output

UTF-8, whatever rat's locale

When the environment of a tmux client has no UTF-8 locale (a service often has none), tmux replaces non ASCII characters by _ in what the client prints: a path /tmp/café is read as /tmp/caf_, and captures lose every accent and symbol. Every invocation passes -u to always output UTF-8.

This is distinct from the locale of the terminals (see Environment): -u fixes what tmux prints, LC_ALL=C.UTF-8 fixes what bash reads. Both are needed for a text to survive from input to capture.

Printable separators only

tmux also replaces control characters by _ in its formatted output (-F, -p): a tab separating fields comes back as _, and the fields can not be told apart anymore. Rejected separators: tabs and other control characters (replaced), and non ASCII characters (replaced too without -u, and not worth depending on).

The window description is therefore separated by spaces, which must not be ambiguous:

  • neither the name (validated) nor the numbers (activity, full screen, scrollback) can contain a space;
  • the last prompt (see Prompts) and the foreground command are quoted by tmux, with the q: format modifier escaping spaces and special characters with a backslash, and unquoted by rat;
  • the working directory comes last, so it needs no quoting: everything after the command is the path.
show-environment escapes $

tmux 3.4 escapes $ followed by a name or { in what show-environment prints (\$b, \${c}; $?, $( and $$ aside), as if its output were to be read back as a tmux command, where tmux expands such variables. -s escapes for a shell on top of that (\\$b), which a shell then expands. Terminals get the value as it was set: only what is printed changes (measured with tmux 3.4, no changelog entry). rat never reads its variables back: CheckTerminals runs bash with the PROMPT_COMMAND that StartServer set, a copy read back being garbled, and TestServerOptions reads the environment of a terminal.

Input

Agents act on a terminal, they do not run commands: they paste text and press keys, and the input may answer a prompt ("press y to confirm") as well as start a command. rat does not know when a command finishes in general: it knows which process is in the foreground, and when the bash of the terminal shows its prompt again (see Prompts).

One input, one tmux invocation

An input is a text with its optional Enter, or a series of keys, and each is a single tmux invocation. tmux runs an invocation whole, so no other input can interleave with it, and rat needs no lock on windows. This comes from the tmux source (3.7c):

  • the client sends all its arguments, ; separators included, in a single message (client.c);
  • the server turns that message into commands appended together to the queue of that client (server-client.c, cmdq_get_command in cmd-queue.c);
  • the server is single threaded, and drains a client queue in one go, stopping only on a command that waits (server_loop in server.c, cmdq_next in cmd-queue.c);
  • copy-mode -q, send-keys and paste-buffer never wait. load-buffer - does, while reading its stdin, but before anything is typed: other clients may run then, not in the middle of the text.

Two agents typing in the same window are like two persons sharing a keyboard: each input is one hand on it, and the inputs come one after the other, never mixed. A text and keys are separate inputs on purpose: which keys to press usually depends on what the text caused on the screen.

Text is pasted, not typed with send-keys

The obvious way, send-keys -l -- text, fails agents twice:

  • Size. The client sends its arguments in a single message of 16 KiB at most (MAX_IMSGSIZE), and fails with command too long beyond: a text of about 16,300 characters. Agents paste files, scripts and heredocs, as a human would, and must be able to.
  • Parsing. Arguments go through tmux parsing: words such as Enter would be sent as keys (needing -l), a leading - read as a flag (needing --), and a trailing ; read as the end of the command and dropped (see below).

Text is therefore loaded into a tmux buffer from stdin, then pasted, in the same invocation:

load-buffer -b rat-input-N - ; copy-mode -q -t =session:=window ; paste-buffer -t =session:=window -b rat-input-N -r -d -p ; send-keys -t =session:=window Enter
  • stdin is not subject to the message limit (115 KB went through unchanged in a manual test, 64 KiB in TestSendLargeText), and never goes through argument parsing.
  • paste-buffer writes the buffer to the terminal as is. -r keeps new lines, which tmux would otherwise replace by carriage returns. -p marks the paste, see below.
  • Buffers are global to the server: each input uses its own name (a counter), so concurrent inputs do not paste each other's text. -d deletes the buffer once pasted; a failed paste (missing window) leaves it, holding the text of an agent, so rat deletes it.
  • load-buffer creates no buffer from an empty input, and the paste would then fail: an empty text only presses Enter, if asked.
  • copy-mode -q leaves any tmux mode, right before the paste (see Input leaves tmux modes).

Nothing is added nor removed, and Enter is only pressed when asked, as a separate key. A text holding control characters is refused instead (see Control characters are refused).

A paste is all or nothing

A command ended by its context kills its tmux client. Killed while load-buffer still reads the text, the invocation fails whole: nothing is pasted, and no buffer is left (measured with a client killed after 1 MB of a stream, tmux 3.3a and 3.7c; the same stream not killed pastes all of it). Killed once the text is read, the server holds every command of the invocation, and paste-buffer writes the buffer at once. A caller canceling an input (a timeout, an agent abandoning its call) therefore never pastes part of a text. TestSendTextAllOrNothing guards it.

This holds because the text is held in memory, whole: a text streamed from a source ending early would be pasted as far as it went, load-buffer taking the end of its input for the end of the text.

Pasted as a human pastes

A terminal program receives bytes, and can not tell a paste from typing: pasting rm -rf x followed by a new line into a shell would run it. Programs can ask the terminal to mark pastes (bracketed paste): the terminal, here tmux, then wraps each paste between ESC[200~ and ESC[201~. -p adds these markers when the program asks for them, and only then (cmd-paste-buffer.c).

bash asks for them (readline's enable-bracketed-paste) while it waits at its prompt: it inserts a pasted text into its command line, new lines included, and runs nothing until Enter, which then runs every line. The rule for agents is the one of a human paste: the text is pasted, Enter runs it. Without -p, each new line would run the line before it, and a text ending with a new line would run by itself.

As for a human, what the text does depends on the program when it arrives, which rat does not know:

  • A program that does not ask (cat, a read prompt, most scripts) reads the text as typed, a new line as Enter.
  • bash only asks while waiting at its prompt: readline turns the mode on before displaying the prompt, and off before running a command. A text pasted while bash starts (a window just created) or runs a command waits in the terminal, marked for nobody, and bash reads it later as typed.
  • bash before 5.1 does not ask by default (the setting exists since 4.4, off until 5.1), and an inputrc can turn it off: rat enforces it with PROMPT_COMMAND (see Terminal environment). bash 3.2, shipped by macOS, can not ask: rat refuses it.

Verified with bash 3.2 to 5.3 (tmux 3.5a and 3.7c), for the default setting, an inputrc turning it off, and with and without -p. TestSendTextPasted guards the behavior at a bash prompt.

Rejected:

  • send-keys -l for short texts and the buffer for long ones: two paths and two sets of pitfalls, for no gain.
  • Splitting long texts over several invocations: each part becomes an input of its own, which others could interleave with, bringing back a lock on windows.
  • Limiting the size of texts: an agent must be able to do what a human does, and a big paste is valid.
Control characters are refused

A text holding a control character (below space, or DEL), tab, new line and carriage return aside, is refused (CheckText, ErrControlCharacter), and nothing is sent: what the program would get is not what the agent sent, and depends on the tmux version.

  • Until tmux 3.6, paste-buffer writes the buffer as is between the markers, and bash ends the paste at the first ESC[201~ it reads, wherever it comes from. The rest of the text is read as typed, each new line running the line before it, and the real end marker leaves a ~ on the command line. A text the agent did not write (a file, a web page, the output of a command) runs without Enter. Reproduced with tmux 3.4 and bash 5.2; TestSendTextControlCharacter guards it.
  • From tmux 3.7, paste-buffer passes the buffer through vis(3) (VIS_SAFE) unless -S is given, against this very sequence: control characters other than tab, new line, carriage return, backspace and bell are written as visible text (^[ for ESC). The hole is closed by altering the text, silently. Measured with tmux 3.7c (the bytes a program reads, with and without -S, which brings the early end back), as cmd-paste-buffer.c and its CHANGES tell.

Refusing every control character vis rewrites, and backspace and bell, which no text holds, leaves it nothing to do: the paste is the same from tmux 3.3a on, without knowing the version. An agent meaning a key presses it (SendKeys), a file holding control characters is written without the terminal (ratd's write_file). Bytes are checked rather than characters: no byte of a multi-byte UTF-8 character is below 0x80. C1 controls, encoded in UTF-8 (U+009B…), are not refused: they end no paste, bash reading bytes.

Rejected:

  • Removing them, or encoding them as tmux 3.7 does: the agent would not know its text changed, and it meant something by them, a key or content, which only it can send the right way.
  • Refusing ESC[201~ alone: the hole closed, other control characters would still reach the program as is until 3.6 and altered from 3.7, terminals differing with the tmux of the machine.
  • Passing -S from tmux 3.7, to paste control characters as is everywhere: older versions refuse the flag, and two paths, depending on the version, with TestMinimumTmux testing only one.
Trailing semicolons

tmux reads an argument ending with ; as the end of its command, and drops that ;, even when the argument is passed without a shell: echo a; would be typed as echo a, and the ; key would not be pressed at all. A ; preceded by a backslash is kept instead (tmux removes the backslash). Text escapes this by not being an argument, but key names are (; is a key), so they go through tmuxArg, which escapes a trailing ; that way. A ; elsewhere in an argument is not affected.

Keys by name

Keys are tmux key names (C-c, Escape, Up, F5…), validated before being sent: tmux types an unknown name as text instead of failing, so Ctrl-C would be typed rather than interrupt the command. Names are case sensitive (stricter than tmux), and only cover what agents need: printable ASCII characters and the usual named keys, with the C-, M- and S- modifiers.

Input leaves tmux modes

A human peeking at the terminals (attached to rat's tmux server) can leave a window in a tmux mode: copy mode to scroll back (C-b [), clock mode (C-b t), the choose-tree menus (C-b s, C-b w)… A mode is displayed over the program, and stays on once the human detaches. Input then does not reach the program as sent (tmux 3.3a and 3.7c):

  • Text is pasted unmarked. paste-buffer still writes to the program, but checks whether it asked for bracketed paste on the screen of the mode rather than on its own (wp->screen in cmd-paste-buffer.c): at a bash prompt, a multi-line text runs line by line.
  • Keys go to the mode. Copy mode binds them to its own commands: q leaves it, and a key prompting for more (f, t, g) fails the whole invocation (no current client). The other modes only take keys from an attached client (window_pane_key in window.c): the keys of a command are dropped, and the command reports success.

Input therefore leaves them first, with copy-mode -q, which leaves every mode of the window (window_pane_reset_mode_all), and does nothing on a window in none. It runs in the invocation of the input, right before it: after load-buffer, which waits, so that no mode can be entered in between. A human scrolling back sees the view return to the program as the agent types: the terminals are the agents', humans peek.

Reads leave modes alone: a capture reads the screen of the program, not the view of the mode.

Rejected: reporting the mode (in the window description, then in list_windows and read_window), or refusing input in a mode, for the caller to leave it: the caller is an agent in the end, who would get tmux plumbing to handle, and could not leave every mode anyway, its keys being dropped.

Prompts

tmux knows which process is in the foreground of a terminal, not when a command finished nor how: bash in the foreground also means a builtin or a loop running, a bash script, bash -c or a subshell running, or bash not having started the command just sent yet. bash knows: it shows its prompt again once the command finished, and runs PROMPT_COMMAND right before. rat's PROMPT_COMMAND (see Terminal environment) records each prompt on the pane option @rat_prompt: the exit status of the command, the time, and the statuses of a pipeline (see Pipelines). The window description reads it (Window.Prompt).

tmux names the process leading the foreground after its first argument, the interpreter of a script. Measured on Linux with tmux 3.4: a script run by #!/usr/bin/env bash or #!/bin/bash, bash x.sh, bash -c, and a subshell or a group in a pipeline running several commands show as bash, a Python script as python3. bash may run the last command of a subshell in its place, which then shows as that command. TestForegroundBash guards the ones showing as bash.

The hook
  • $? is read first: anything run before it changes it. A startup file putting a command of its own in front of rat's can change it too: history -a does, every status then reading 0, as does starship, which runs rat's command from a function of its own; direnv and bash-preexec keep it (measured with starship 1.26, direnv and bash-preexec on Debian 12). CheckTerminals tells whether the status reaches rat's command (see Terminal environment).
  • The first prompt of a bash records no status (-): it follows the startup files, not a command, and their last status (1 on macOS with an empty ~/.bash_profile) is not the agent's. So does the first prompt of a bash started in the terminal: the variable telling a first prompt is not exported.
  • Not while a line waits on the terminal: a text sent with Enter while a command runs, or while bash starts, waits in the terminal (see Pasted as a human pastes), and bash runs it right after its next prompt, which does not end the text. Recorded, that prompt would tell the text finished as soon as the command before it did, with its status: a script queued behind a command, which shows as bash in the foreground, would look finished for its whole run. read -t 0 tells, without reading anything, whether a line waits: the hook records nothing then, and the prompt after the text, after its last line, is recorded instead. Only a whole line: until readline takes the terminal, after PROMPT_COMMAND, the terminal is in canonical mode, where a text without Enter is not readable yet. Such a text does not run either: it waits on the command line, and the prompt is recorded.
  • Marked first, recorded if still marked: the record runs in the background (see below), and lands a millisecond or more after the check of a line waiting. An input reaching the terminal in between is not seen waiting, and clears the option before the record lands: recorded, the prompt would read as following it. Measured from the machine itself (tmux 3.4, bash 5.2), with an input sent right after clear, 1.5 ms after it: every time; sent 1 ms later, never. So the hook first marks the prompt as being recorded, with a mark of its own (recording, the PID of the bash and a count), and waits for tmux to hold it. It then checks for a line waiting, and records the prompt only if the mark is still there (if-shell -F, evaluated by tmux): an input from then on clears the mark (see Inputs clear it), dropping the record. A mark of its own rather than a fixed one: a record landing after the next prompt is marked would replace that mark, telling the status of the command before. A mark reads as no prompt. With it, the same input never got the prompt (25 tries each, sent 0, 1 and 3 ms after clear). It rests on tmux writing an input it handled before the mark to the terminal by the time it answers the mark: bash still has to get that answer before checking.
  • tmux is reached by the client of the terminal, the tmux running the server, by its absolute path: StartServer finds it in rat's PATH, as the server was found (/usr/bin/tmux on Debian, /opt/homebrew/bin/tmux with Homebrew). Rejected: tmux looked up at each prompt in the PATH of the terminal, which the agent and its startup files change: tmux missing there, or another one found first, would fail silently, the terminal recording no prompt anymore. The client uses TMUX and TMUX_PANE, which tmux sets in every terminal, and is not run without them: an agent unsetting TMUX would reach another server, and a bash run elsewhere (the startup check) records nothing.
  • The record in the background, from a subshell: in the foreground, the tmux client would show as the command of the terminal while it runs (tmux instead of bash, a few milliseconds per prompt). A subshell rather than a job: bash would announce a job, and its end. The mark is waited for, from a command substitution, which runs in the process group of bash: bash still shows as the command (only bash was seen polling the window while it showed 100 prompts).
  • Nothing on the screen: every output is silenced, and the time comes from printf '%(%s)T', without starting a process, or date for a bash older than 4.2 started in the terminal (typing bash on macOS runs the 3.2 of the system, first in the PATH of a login shell). The cost is two tmux clients per prompt, about 1 ms each on Linux, 4 ms on macOS: bash waits for the first one, the mark, before showing its prompt (1.6 ms measured on WSL 2).

Measured with tmux 3.3a, 3.5a and 3.7c, and bash 4.4 to 5.3: a command records its status, C-c at the prompt or on a command 130, an empty Enter keeps the previous status, a bash started in the terminal records its prompts, and its exit status once it exits. A multi-line text pasted then run gets a single prompt with bash 5.1 and later, and one per line with 4.4 and 5.0, which show a prompt between them. Lines waiting on the terminal, measured with tmux 3.4 and bash 5.2: of the prompts bash shows for the lines typed while a command runs, only the one after the last line is recorded, and for a text sent while bash starts, the one after the text rather than its first prompt; a text typed without Enter lets the prompt be recorded.

Pipelines

A pipeline exits with the status of its last command: command 2>&1 | tee log, which ratd recommends for long outputs, exits with the status of tee, 0 almost always, whatever command did. So the hook also reads the status of each command (PIPESTATUS), in the assignment reading $?: __rat_pipe=("${PIPESTATUS[@]}" $?), before any command changes them, and records them after the time when the pipeline holds several commands (0 1790000000 1 0). The caller tells what the status hides.

  • Kept only when its last status is $?: a pipeline negated (! false | true) exits with 1, its last command with 0, and C-c at the prompt changes $? (130) but not always PIPESTATUS. The status is still recorded, without the pipeline.
  • Joined by a loop, not by ${a[*]}, which joins with the first character of IFS, which startup files and agents may change. No negative index (${a[-1]}) either: bash 3.2, typed in a terminal on macOS, would print an error at each prompt.
  • Lost to a command run before rat's: any command run in PROMPT_COMMAND before rat's replaces PIPESTATUS with its own, even one keeping $? (direnv's). CheckTerminals tells.
  • Lost in a bash that runs no command before its first prompt (no startup file running one, bash --norc typed in a terminal): bash saves PIPESTATUS before running PROMPT_COMMAND and restores it afterwards, and when no command created it yet, it saves nothing and restores an empty array, which it never fills again (save_pipestatus_array, restore_pipestatus_array and set_pipestatus_array in variables.c, bash 5.2). PIPESTATUS then stays empty in that shell, for the agent as well: a PROMPT_COMMAND alone does it, rat's or any other. Measured with bash 3.2 to 5.3; unsetting it from PROMPT_COMMAND does not repair it, as the restore empties the variable the commands after the unset create again. A login shell reads /etc/profile, which runs commands on every system met: CheckTerminals, running bash as terminals start it, tells when it does not.

Measured with bash 3.2, 4.4, 5.0 and 5.3 (tmux 3.7c), and 5.2 (tmux 3.4): false | true records 1 0, yes | head -1 141 0, C-c on sleep 5 | true 130 0, a pipeline negated and C-c at the prompt no pipeline, the same with IFS=,.

Inputs clear it

Each input clears the option, in its own invocation, right before the text or the keys: a prompt recorded afterwards follows the input. The option then tells, by itself, whether bash showed its prompt since the last input, the command sent having finished, with no state in rat, not even a marker of the input. Without it, the prompt of the previous command would read as the end of the one just sent. While it is cleared: a command running, a text left on the command line (no Enter), keys typed at the prompt, bash starting. Clearing the option also clears the mark of a prompt being recorded, which drops that record (see The hook). Clearing an option that is not set succeeds (measured with tmux 3.3a to 3.7c): it can not fail the input.

Limits, where the prompt recorded is not the one of the command sent:

  • bash 4.4 and 5.0 show a prompt between the lines of a multi-line text run at once: the prompt after its first line is recorded while the next ones run.
  • Input typed by a human attached to the terminal does not go through rat: it clears nothing.
Inputs record their time

Each input also records its time on the pane option @rat_input, in the same invocation, in seconds: tmux formats have no clock, so the time is rat's, which runs on the machine of tmux. The window description reads it (Window.Input): how long the command sent last has run, while it still runs. Whether it finished is not read from it, the prompt tells by itself: it gives the caller a time from the clock of the machine, where an agent adding up its waits miscounts them (see wait_window in the README of ratd).

Hooked windows

CheckTerminals is a snapshot (see Terminal environment): a startup file assigning PROMPT_COMMAND afterwards (an agent running an installer editing .bashrc) goes unnoticed, and the windows created from then on record no prompt, nor get bracketed paste. So the hook also sets the pane option @rat_hooked at each prompt, in the tmux command setting the mark, without a client of its own, and nothing clears it. The window description reads it (Window.Hooked): without it, bash is still starting, or its startup files replace PROMPT_COMMAND. The two can not be told apart, the first prompt of a bash coming once its startup files ran, however long they take: the caller tells both. The windows created before the change keep their bash, which read the startup files before it, and stay hooked.

Limit: once set, it stays. A bash started in the terminal with other startup files, or PROMPT_COMMAND replaced by a command typed in the terminal, goes unnoticed.

Rejected: running CheckTerminals again at each window creation, which adds a bash running its startup files (up to the timeout of the check) to each, and misses the windows tmux creates by itself (the first of a session).

Reading it

The options are read with the window description: the prompt and the input quoted (#{q:@rat_prompt}, #{q:@rat_input}), the hook marker as 0 or 1 (#{?#{@rat_hooked},1,0}). The mark of a prompt being recorded reads as no prompt, and so does a pipeline that does not end with the status. A command typed in a terminal can write them, TMUX being kept: a value rat did not write reads as no prompt or no input, never as an error failing the description.

Rejected:

  • The pane title, set by an escape sequence without starting a process: any program sets it (ssh sessions, vim), and would be taken for a prompt.
  • Waiting on a tmux channel (wait-for) signaled by the hook, rather than reading an option: the channel would be named after the pane ID, which rat never uses, and an option keeps the status for whoever reads it later.

Capture

A capture returns the screen as displayed, preceded on demand by rows of scrollback, with what is needed to interpret it. How the screen is captured depends on what the terminal shows.

The normal screen

capture-pane -p -J, with -S -n for n rows of scrollback above the screen.

  • -J joins the rows tmux wrapped at the terminal width, so a long line comes back whole. It also keeps trailing spaces, which rat removes, with the empty lines at the end of the screen: an idle terminal returns its prompt, not a screen of blank lines.
  • The snapshot tells how many scrollback rows it includes: the ones requested, fewer if the scrollback is shorter. They are rows: a line wrapped over several rows is joined, so the content may show fewer lines than that.
  • A request beyond the scrollback starts at its first row, but tmux silently captures no scrollback at all for a number out of its range (a C int, cmd-capture-pane.c): rat bounds the request by the history limit it sets, beyond which no scrollback is kept.
  • The cursor is not told: its position is in screen rows, which joined rows and removed empty lines make point at the wrong line, and it sits at the end of the prompt anyway.
  • The size of the screen alone is told, for the caller to bound what it sends (a screen over a budget can then be refused rather than cut). The content can not tell it once it includes scrollback: a line wrapped from the scrollback onto the screen is joined into one line (a line of 100000 characters then comes whole, the screen included). The screen is therefore captured a second time, alone and unjoined, before the content: tmux prints exactly one line per row, empty rows included (3.3a and 3.7c), so the output splits at the height of the pane. It costs a screen more of output, about 5 KB. Rejected: capturing the screen alone only when needed, in a second invocation, which would measure another frame than the content.
A full-screen program

Under a full-screen program (less, vim, top), the terminal shows its alternate screen.

  • The scrollback above belongs to the terminal before the program started: included, it would be mistaken for the program output. It is never included then, and the snapshot says a full-screen program is running. What such a program displays disappears when it quits.
  • The screen is captured as displayed, without -J: a full-screen program lays out its own screen, and may let the terminal wrap a row it fills (less does): joined, that row would shift every row below it. Line N of the content is row N of the screen. Empty lines at the end are still removed, and tmux removes trailing spaces itself without -J.
  • The cursor is told, counted from 1 (tmux counts from 0): it tells where the input of the program goes (a field, a position in a file). It may be below the last line of the content, empty lines at the end being removed. Columns count terminal cells, a wide character taking two. A program may hide its cursor (htop does, less and vim do not), which tmux tells (cursor_flag): no position is told then, as a human sees no cursor, and the one tmux keeps is a leftover.

The snapshot carries these properties rather than failing (asking for scrollback under a full-screen program is not an error): an agent reads in the result itself what it got and why, without a failed round trip, which small models handle worst.

State and screen, in one invocation

The state of the terminal (alternate screen, scrollback size, cursor) must describe the very screen captured: read apart, a program starting or quitting in between would get its screen captured the wrong way, and a cursor could come from another frame than the content, for a program redrawing. Both are read in a single invocation, which tmux runs whole (see Input):

display-message -p -t T '#{alternate_on} #{history_size} #{cursor_flag} #{cursor_x} #{cursor_y} #{pane_height}' ;
if-shell -F -t T '#{alternate_on}' 'capture-pane -p -t T' 'capture-pane -p -t T ; capture-pane -p -J -S -n -t T'
  • if-shell -F evaluates its condition in tmux, without running a shell, and queues the commands chosen right after itself (cmd-if-shell.c): it never waits, and the queue is drained in one go.
  • The program output can not change the screen in between: tmux reads it in its event loop (window_pane_read_callback in window.c), and drains client queues between two iterations of that loop (proc_loop in proc.c, server_loop in server.c).
  • The commands of if-shell are strings parsed by tmux: only the target, made of validated names, and a number go in them.
  • For a missing window, display-message describes another one (see Targets and names), but the capture fails, and so does the invocation: its output is discarded.

Rejected: describing the window, then capturing it, in two invocations: simpler, but the properties and the capture were a few milliseconds apart.

Documentation

Overview

Package tmux controls a dedicated tmux server giving agents persistent terminals.

It is the low level layer of rat: explicit, with no automatic behavior, but opinionated for agent usage. It exposes what agents need rather than all of tmux, and enforces its invariants (exact targets, validated names, a server free of any user configuration…). The MCP server composes these primitives into the few tools agents see. The design decisions, and the tmux pitfalls they avoid, are described in the README of this package.

Server

A Controller owns one tmux server, on a socket named after its tenant (rat-<tenant>, or rat for the default tenant). Controller.StartServer starts it in the foreground, as a child of rat, watches it and returns once it is ready. Controller.StopServer asks it to exit, then terminates it if needed. Stopping the server stops every terminal it runs. Controller.WaitServer tells when and why the server exited on its own, for the caller to restart it: the controller never does it by itself.

Sessions and windows

A session groups the windows of an agent (or of an MCP client). A tmux session can not be empty: Controller.NewSession creates it with a first window named FirstWindow, and closing its last window closes the session.

A window is a terminal running bash, starting in the home directory of the user running rat. Windows are identified by their name, unique within their session: it is how agents find them back. Tenant, session and window names are plain names: package tmux/names holds the rule, for callers to check a name without tmux. Controller.ListWindows and Controller.Window describe them as tmux currently sees them: foreground command, working directory, last activity, full-screen program, size of the scrollback, the last prompt of their bash, with the exit status of the command before it (Prompt), which bash records in tmux itself, the time of the last input, which inputs record there too, and whether their bash ran the hook recording prompts (Window.Hooked), which startup files changed since Controller.CheckTerminals can prevent. Nothing is cached: tmux is the single source of truth.

Input and capture

Controller.SendText pastes text as is, as a human pastes, then presses Enter only if asked: agents may be answering a prompt rather than running a command. A text holding a control character but tab and new lines is refused, which CheckText tells without tmux: until tmux 3.6, one (ESC[201~) ends the paste early, and from 3.7, tmux alters them. Pasting relies on bash bracketed paste, and the prompts windows record on the prompt hook of bash, both of which startup files can defeat: Controller.CheckTerminals tells. Controller.SendKeys presses keys by their tmux names (C-c, Escape, Up…), which CheckKey validates without tmux. Both first leave any tmux mode (copy mode…) a human peeking at the terminals left the window in, where input would not reach the program as sent. Controller.Capture returns a Snapshot of what a window displays, with optional scrollback above it, and what is needed to interpret and bound it: whether a full-screen program runs, captured as displayed then, where its cursor is, and the size of the screen alone.

Errors

Failures are reported with sentinel errors (ErrSessionNotFound, ErrWindowExists, ErrServerNotRunning…) for the caller to decide what to do: the controller never works around them. For instance, the MCP server meeting ErrSessionNotFound creates the session and carries on, treating ErrSessionExists as a concurrent creation rather than an error.

Index

Constants

View Source
const (
	ScreenColumns = 200
	ScreenRows    = 24
)

ScreenColumns and ScreenRows are the fixed size of the terminals (tmux default: 80x24). Wide, because programs lay out their output for the terminal width: some truncate lines to it (ps, docker, tables), and joining wrapped lines at capture can not recover what they cut. Short, because the height is what a full screen capture returns by default: more lines are available on demand from the scrollback. But not shorter than 24, the classic terminal height full-screen programs are designed for: below it some refuse to run or cut their menus (dialog, whiptail), top shows few processes, and pagers (less via git, journalctl…) kick in more often.

View Source
const FirstWindow = "main"

FirstWindow is the name of the window a session is created with.

View Source
const NoStatus = -1

NoStatus is the Status of a Prompt following no command.

Variables

View Source
var (
	// ErrInvalidKey is returned by SendKeys for a name that is not a known tmux key name.
	ErrInvalidKey = errors.New("invalid key name")
	// ErrControlCharacter is returned by SendText for a text holding a control character other
	// than tab, new line and carriage return.
	ErrControlCharacter = errors.New("control character")
)
View Source
var (
	// ErrServerAlreadyStarted is returned by StartServer while its server is running.
	ErrServerAlreadyStarted = errors.New("server already started")
	// ErrServerSocketInUse is returned by StartServer when another tmux server answers on the
	// socket of the tenant: another rat serving the same tenant, or a server left behind.
	ErrServerSocketInUse = errors.New("socket already served by another tmux server")
	// ErrServerNotRunning is returned by commands and StopServer when the server has not been
	// started, has been stopped, or has exited on its own. StartServer can start a new one.
	ErrServerNotRunning = errors.New("server not running")
	// ErrServerTerminated is returned by StopServer when the server had to be sent SIGTERM.
	ErrServerTerminated = errors.New("server did not stop gracefully and has been terminated (SIGTERM)")
	// ErrServerKilled is returned by StopServer when the server had to be sent SIGKILL.
	ErrServerKilled = errors.New("server did not stop gracefully and has been killed (SIGKILL)")
	// ErrUnsupportedBash is returned by StartServer when the bash found in PATH is older than
	// bashMinVersion, before any server is started.
	ErrUnsupportedBash = errors.New("unsupported bash version")
)
View Source
var (
	// ErrSessionNotFound is returned by commands on a session that does not exist.
	ErrSessionNotFound = errors.New("session not found")
	// ErrSessionExists is returned by NewSession when the session already exists.
	ErrSessionExists = errors.New("session already exists")
)
View Source
var (
	// ErrWindowNotFound is returned by commands on a window that does not exist in its session.
	ErrWindowNotFound = errors.New("window not found")
	// ErrWindowExists is returned by NewWindow when the session already has a window of that name.
	ErrWindowExists = errors.New("window already exists")
)

Functions

func CheckKey

func CheckKey(key string) error

CheckKey returns an error wrapping ErrInvalidKey if key is not a key name SendKeys accepts: a printable ASCII character or a named key (Enter, Escape, Up, F5…), with optional modifiers (C-, M-, S-). It is the rule SendKeys applies, for callers to find which key is refused.

func CheckText

func CheckText(text string) error

CheckText returns an error wrapping ErrControlCharacter if text holds a control character SendText refuses: an ASCII one below space, or DEL, but tab, new line and carriage return. It names the first one, in hexadecimal and caret notation (0x1b (^[) for ESC), and its line. It is the rule SendText applies, for callers to find which character is refused.

Refused rather than pasted, or removed: the terminal would not get what the caller sent, and the caller would not know. Until tmux 3.6, ESC[201~ ends the bracketed paste early, and the lines after it run without Enter; from 3.7, tmux rewrites control characters into visible text. Keys are pressed with SendKeys. See "Control characters are refused" in the README.

Types

type Controller

type Controller struct {
	// contains filtered or unexported fields
}

Controller owns the tmux server of a tenant, and runs the commands on its sessions and windows. Create it with New, which validates the tenant. It is safe for concurrent use.

It holds the server process, never the state of its terminals: sessions and windows are asked to tmux on every call. It runs one server at a time, and a server that exited on its own can be replaced by calling StartServer again.

func New

func New(tenant string) (c *Controller, err error)

New returns a controller for tenant, which can be empty (default socket) or a plain name (see package names): the error then wraps names.ErrInvalid. It does not start the server: see StartServer.

func (*Controller) Capture

func (c *Controller) Capture(ctx context.Context, session, window string, extraLines int) (Snapshot, error)

Capture returns the content of window in session, as currently displayed, preceded by up to extraLines rows of scrollback. The error wraps ErrSessionNotFound or ErrWindowNotFound if they do not exist, and ErrServerNotRunning if the server shut down during the command.

func (*Controller) CheckTerminals

func (c *Controller) CheckTerminals(ctx context.Context) (TerminalsCheck, error)

CheckTerminals checks what the bash of terminals ends up with at its prompt, once its startup files ran: bracketed paste, which pasted text relies on (see SendText), and the prompt hook, which records prompts and the status of commands (see Window.Prompt). rat sets both through PROMPT_COMMAND, which a startup file assigning it (rather than adding to it) replaces: bracketed paste then depends on bash defaults (on from 5.1) and the inputrc, and no prompt is recorded. A command a startup file adds in front of rat's may change the status rat's gets, or lose the statuses of a pipeline. What it finds are warnings for the caller to relay, terminals keep working: the error tells the check could not run. It runs bash once as terminals start it (login, interactive, their environment), typing commands into it: bash shows its prompt and runs PROMPT_COMMAND as in a terminal, so that a startup file running rat's command from a function of its own (as starship does) is not taken for an override. Startup files may block, so ctx should bound it. It fails with ErrServerNotRunning if the server is not started.

func (*Controller) KillSession

func (c *Controller) KillSession(ctx context.Context, session string) error

KillSession destroys session, terminating the processes running in its windows. The error wraps ErrSessionNotFound if the session does not exist.

func (*Controller) KillWindow

func (c *Controller) KillWindow(ctx context.Context, session, window string) error

KillWindow destroys window in session, terminating the processes running in it. Destroying the last window of a session destroys the session too. The error wraps ErrSessionNotFound or ErrWindowNotFound if they do not exist.

func (*Controller) ListSessions

func (c *Controller) ListSessions(ctx context.Context) ([]string, error)

ListSessions returns the names of the existing sessions, sorted by name.

func (*Controller) ListWindows

func (c *Controller) ListWindows(ctx context.Context, session string) ([]Window, error)

ListWindows returns the windows of session, in tmux index order. It is not the creation order: a new window takes the first free index, such as one left by a closed window. The error wraps ErrSessionNotFound if the session does not exist, and ErrServerNotRunning if the server shut down during the command.

func (*Controller) NewSession

func (c *Controller) NewSession(ctx context.Context, session string) error

NewSession creates session, with a first window named FirstWindow: a tmux session can not exist without a window. Agents name the windows they create themselves, and can use this one as is. Its terminals start in the home directory of the user running rat. The error wraps ErrSessionExists if the session already exists.

func (*Controller) NewWindow

func (c *Controller) NewWindow(ctx context.Context, session, window string) error

NewWindow creates window in session, a terminal running bash in the home directory of the user running rat, and leaves the window a human may be looking at selected. The error wraps ErrSessionNotFound if the session does not exist, or ErrWindowExists if a window already has that name in the session: names are how windows are found back.

func (*Controller) SendKeys

func (c *Controller) SendKeys(ctx context.Context, session, window string, keys ...string) error

SendKeys presses keys in window of session, in order. Keys are tmux key names, such as "C-c", "Escape", "Up" or "F5"; the error wraps ErrInvalidKey for an unknown one, and nothing is sent. A tmux mode a human left the window in (copy mode, to scroll back) is left first: in a mode, tmux would take the keys for itself, or drop them. Pressing keys clears the prompt recorded until then (see Window.Prompt), and records their time (see Window.Input). The error wraps ErrSessionNotFound or ErrWindowNotFound if they do not exist.

func (*Controller) SendText

func (c *Controller) SendText(ctx context.Context, session, window, text string, enter bool) error

SendText pastes text in window of session, as a human pastes, then presses Enter if enter is true. The text is pasted as is, never interpreted as key names and with nothing added or removed. It holds no control character but tab, new line and carriage return: the error wraps ErrControlCharacter otherwise, and nothing is sent (see CheckText). When the text arrives, a program asking for pastes to be marked (bracketed paste: bash waiting at its prompt, vim…) receives it as a paste: bash inserts it into its command line, new lines included, and runs nothing until Enter. Otherwise, the text is read as typed, a new line as Enter: by programs that do not ask, but also by bash reading it later, when it was pasted while bash was starting or running a command. It has no size limit, and reaches the terminal whole: no other input can interleave with it. A tmux mode a human left the window in (copy mode, to scroll back) is left first: in a mode, tmux would paste the text unmarked. Sending something clears the prompt recorded until then (see Window.Prompt), and records its time (see Window.Input). The error wraps ErrSessionNotFound or ErrWindowNotFound if they do not exist.

A paste is all or nothing: a command ended by its context while the text streams to tmux pastes none of it, and leaves no buffer behind.

func (*Controller) StartServer

func (c *Controller) StartServer(ctx context.Context) (err error)

StartServer starts the tmux server and returns once it is ready and configured. ctx bounds the life of the server: once canceled, the server is terminated (SIGTERM, then SIGKILL), as a safeguard for a caller leaving it running. A caller stopping the server in order (StopServer) must pass a context that outlives that stop, such as one a signal does not cancel. The server does not load any tmux configuration and its terminals run bash, which must be installed (the returned error then wraps exec.ErrNotFound) in version bashMinVersion or later (the error then wraps ErrUnsupportedBash). It refuses a socket another server answers on, leaving that server untouched: the error then wraps ErrServerSocketInUse, with its PID.

func (*Controller) StopServer

func (c *Controller) StopServer(ctx context.Context) (err error)

StopServer stops the current server if running, asking it to exit with kill-server, and terminating it if it has not exited within serverStopGracePeriod, stuck or not answering. A nil error means the server exited gracefully. Otherwise:

  • ErrServerNotRunning: there was no server to stop, or it exited on its own, before or while being stopped: a service manager stopping a service signals all its processes at once, the server included
  • ErrServerTerminated: the server did not exit gracefully and has been sent SIGTERM, which it exited on
  • ErrServerKilled: the server ignored SIGTERM for serverKillDelay and has been sent SIGKILL
  • any other error: the server exited with a non-zero code, or waiting for it failed

SIGKILL is issued as a last resort, but the server being gone once this returns is not guaranteed: a process in uninterruptible sleep (D state) for example only dies when it leaves it.

func (*Controller) WaitServer

func (c *Controller) WaitServer(ctx context.Context) error

WaitServer waits for the current server to exit, and tells why. It returns nil when StopServer asked it to exit (StopServer reports how that went), and an error wrapping ErrServerNotRunning with the reason when it exited on its own: crash, killed, or kill-server typed in a terminal. It returns ErrServerNotRunning right away if no server is started, and ctx.Err() if ctx ends first. It does not restart the server: the caller decides, and StartServer replaces a server that exited on its own.

func (*Controller) Window

func (c *Controller) Window(ctx context.Context, session, window string) (Window, error)

Window returns the description of window in session. The error wraps ErrSessionNotFound or ErrWindowNotFound if they do not exist.

type Position

type Position struct {
	Row, Column int
}

Position is a position on the screen, counted from 1: row 1 is the first line of a full-screen capture. Columns count terminal cells, a wide character (CJK, emoji) taking two. The zero Position is no position.

type Prompt

type Prompt struct {
	// Time is when bash displayed it, to the second. The zero Time is no prompt.
	Time time.Time
	// Status is the exit status of the command bash ran before it, NoStatus for the first prompt of
	// a bash, which follows its startup files rather than a command. For a pipeline, it is the
	// status of its last command, as bash tells it ($?): command | tee log exits with the status
	// of tee, whatever command did.
	Status int
	// Pipeline is the exit status of each command of the pipeline bash ran before the prompt, left
	// to right, the last one being Status. Nil for a single command, or when the hook does not get
	// them (see TerminalsCheck.Pipelines).
	Pipeline []int
}

Prompt is a prompt the bash of a terminal displayed, as it recorded it.

type Snapshot

type Snapshot struct {
	// Content is the screen as displayed, preceded by the extra lines of scrollback. Trailing
	// spaces and empty lines at the end are removed. On the normal screen, lines wrapped by the
	// terminal are joined. Under a full-screen program, rows are kept as displayed: line N of the
	// content is row N of the screen.
	Content string
	// ExtraLines is the number of scrollback rows included above the screen: the ones requested,
	// fewer if the scrollback is shorter, none for a full-screen program. They are terminal rows:
	// a line wrapped over several rows is joined, so the content may show fewer lines.
	ExtraLines int
	// FullScreen is true when a full-screen program (less, vim, top…) draws on the alternate
	// screen: the scrollback above belongs to the terminal before it started, so it is not
	// included, and what the program displays disappears when it quits.
	FullScreen bool
	// ScreenBytes is the size of the screen alone, in bytes: its rows as displayed, trailing spaces
	// and empty rows at the end removed. It tells whether the screen fits a budget when Content
	// includes scrollback, where the screen can not be told apart: a line wrapped from the
	// scrollback onto the screen is joined. On the normal screen, it may exceed what the screen
	// takes in Content by the new lines of the rows joined, one per row at most.
	ScreenBytes int
	// Cursor is where the cursor of a full-screen program is, telling where its input goes (a
	// field, a position in a file). It may be below the last line of the content, empty lines at
	// the end being removed. It is the zero Position on the normal screen, where joined rows and
	// removed empty lines would make it point at the wrong line (it sits at the end of the prompt
	// anyway), and when the program hides its cursor, as a human then sees none.
	Cursor Position
}

Snapshot is the content of a window at the time of a capture, with what is needed to interpret it.

type TerminalsCheck

type TerminalsCheck struct {
	// BracketedPaste tells pasted text waits on the command line until Enter (see SendText).
	BracketedPaste bool
	// Prompts tells the prompt hook runs: windows record their prompts (see Window.Prompt).
	Prompts bool
	// Statuses tells the prompt hook gets the exit status of commands, which a command a startup
	// file adds in front of rat's, in PROMPT_COMMAND, may change.
	Statuses bool
	// Pipelines tells the prompt hook gets the status of each command of a pipeline (see
	// Prompt.Pipeline), which a command a startup file adds in front of rat's loses, even when it
	// keeps the status (direnv does).
	Pipelines bool
	// PromptCommand is what PROMPT_COMMAND became, quoted for bash, for the caller to tell.
	PromptCommand string
}

TerminalsCheck is what the bash of terminals ends up with at its prompt, once its startup files ran: what rat relies on, which startup files can defeat (see CheckTerminals).

type Window

type Window struct {
	Name string
	// Command is the process in the foreground of the terminal: the shell (bash) when it waits
	// for input, the running program otherwise. bash also shows for what bash runs: builtins and
	// loops, but also bash scripts, bash -c, and subshells, which tmux names after their
	// interpreter. It does not see background jobs, nor what runs within ssh or a nested shell
	// (which show as ssh and bash). It tells nothing about the exit status of the last command:
	// Prompt does.
	Command string
	// Path is the working directory of the foreground process.
	Path string
	// Activity is the last time the terminal output changed.
	Activity time.Time
	// FullScreen is true when a full-screen program (less, vim, top…) draws on the alternate
	// screen of the terminal: what it displays disappears when it quits.
	FullScreen bool
	// Scrollback is the number of terminal rows kept above the screen, which a capture can
	// include (bounded by the history limit).
	Scrollback int
	// Prompt is the prompt the bash of the terminal displayed since the last input (SendText,
	// SendKeys), with the exit status of the command before it: the command sent finished. It is
	// the zero Prompt while bash displayed none since: a command running, a text left on the command
	// line, keys typed at the prompt, bash starting. Only the bash of the terminal, and the ones
	// started in it, record their prompts (see StartServer): not a program displaying a prompt of
	// its own (ssh, an interpreter). Nor a prompt displayed while a line waits on the terminal: text
	// sent with Enter while a command runs, or while bash starts, runs right after it, and the prompt
	// recorded is the one after the text. With bash 4.4 and 5.0, the lines of a text run at once
	// each get a prompt, recorded while the next ones run.
	Prompt Prompt
	// Input is when the last input reached the terminal (SendText, SendKeys), to the second: how
	// long the command sent last has run, while it still does. The zero Time when nothing was sent
	// since the window was created.
	Input time.Time
	// Hooked tells the bash of the terminal ran rat's prompt hook once at least (see StartServer).
	// Until it does, no prompt is recorded, nor bracketed paste enforced: bash is still starting,
	// or its startup files replace PROMPT_COMMAND, which CheckTerminals only checks when called.
	// It stays true once set: a bash started afterwards in the terminal, or PROMPT_COMMAND
	// replaced there, goes unnoticed.
	Hooked bool
}

Window describes a terminal, as tmux sees it when asked.

Directories

Path Synopsis
Package names holds the naming rule of rat: tenants, sessions and windows are plain names, made of letters, digits, '_' and '-', up to 32 characters.
Package names holds the naming rule of rat: tenants, sessions and windows are plain names, made of letters, digits, '_' and '-', up to 32 characters.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL