Control mode lets external programs drive psmux programmatically over a structured text protocol. Instead of rendering a TUI, psmux sends machine-readable notifications and accepts commands over stdin/stdout, making it the foundation for building plugins, IDE integrations, custom dashboards, session monitors, and any tooling that needs to interact with terminal sessions.
This is the same protocol that tmux uses for its control mode (tmux -C / tmux -CC), so existing knowledge and many client libraries transfer directly to psmux.
# 1. Create a detached session
psmux new-session -d -s work -x 120 -y 30
# 2. Attach in control mode (no-echo)
psmux -CCpsmux connects to the running session and enters a command/response loop. You type commands on stdin, and psmux responds on stdout with structured output.
list-windows
%begin 1700000000 1 1
0: pwsh* (1 panes) [120x30]
%end 1700000000 1 1
To exit, close stdin (Ctrl+D / EOF) or send kill-server.
| Flag | Mode | Behavior |
|---|---|---|
-C |
Echo | Commands you send are echoed back to stdout before the response. Useful for debugging and interactive testing. |
-CC |
No-echo | Commands are not echoed. This is the mode you want for programmatic use. In this mode, %exit is followed by an ST sequence (ESC \). |
By default, control mode connects to the session stored in PSMUX_SESSION_NAME. You can set it before launching:
$env:PSMUX_SESSION_NAME = "my-session"
psmux -CCEvery command you send gets a response wrapped in %begin / %end (or %error) markers:
<your command>
%begin <timestamp> <command_number> <flags>
<response lines>
%end <timestamp> <command_number> <flags>
| Field | Description |
|---|---|
timestamp |
Unix epoch seconds when the command was processed |
command_number |
Sequential counter (1, 2, 3, ...) for each command in the session |
flags |
Reserved, always 1 |
The %begin and %end lines always share the same timestamp, command number, and flags. If a command fails, the closing frame is %error instead of %end:
nonexistent-command
%begin 1700000000 1 1
unknown command: nonexistent-command
%error 1700000000 1 1
Command response blocks never interleave with each other. Notifications (described below) arrive between command blocks, never inside them.
Notifications are asynchronous lines that psmux sends whenever something happens in the session. They always start with % and arrive between command response blocks.
| Notification | Meaning |
|---|---|
%window-add @<WID> |
A new window was created |
%window-close @<WID> |
A window was destroyed |
%window-renamed @<WID> <name> |
A window was renamed |
%window-pane-changed @<WID> %<PID> |
The active pane in a window changed |
%layout-change @<WID> <layout> <visible_layout> <flags> |
A window's pane layout changed (split, resize, etc.) |
| Notification | Meaning |
|---|---|
%session-changed $<SID> <name> |
The attached session changed |
%session-renamed <name> |
The current session was renamed |
%session-window-changed $<SID> @<WID> |
The active window in a session changed |
%sessions-changed |
A session was created or destroyed |
| Notification | Meaning |
|---|---|
%output %<PID> <escaped_data> |
A pane produced output |
%pane-mode-changed %<PID> |
A pane entered or exited a special mode (e.g. copy mode) |
| Notification | Meaning |
|---|---|
%pause %<PID> |
Output for this pane has been paused (client is too far behind) |
%continue %<PID> |
Output for this pane has resumed |
| Notification | Meaning |
|---|---|
%client-detached <client> |
A client disconnected from the session |
%client-session-changed <client> $<SID> <name> |
Another client changed its attached session |
%paste-buffer-changed <name> |
A paste buffer was modified |
%paste-buffer-deleted <name> |
A paste buffer was deleted |
%message <text> |
A status message was generated (e.g. from display-message) |
| Notification | Meaning |
|---|---|
%exit |
The control client is disconnecting. In -CC mode, followed by ESC \ (ST sequence). |
%exit <reason> |
Disconnecting with a reason (e.g. too far behind). |
All IDs are stable, monotonically increasing integers that never get reused during a server's lifetime:
| Prefix | Entity | Example |
|---|---|---|
$ |
Session | $0 |
@ |
Window | @0, @1, @2 |
% |
Pane | %0, %1, %2 |
Data in %output notifications uses octal escaping for non-printable bytes:
| Byte | Encoding |
|---|---|
| Printable ASCII (0x20 to 0x7E) | Passed through as-is |
| Tab (0x09) | Passed through as-is |
| Backslash (0x5C) | \\ (doubled) |
| Carriage return (0x0D) | \015 |
| Line feed (0x0A) | \012 |
| Any other byte | \NNN (3-digit octal) |
Example: hello\r\n becomes %output %0 hello\015\012.
Control mode has its own command dispatcher, so it accepts a deliberate subset of the full psmux command set rather than everything the CLI accepts. Anything outside that subset comes back as %error unknown command: <name>. Send list-commands to see the catalog. Everything shown below is dispatched in control mode.
new-window # Create a new window
new-window -n editor # Create a named window
split-window -v # Split vertically
split-window -h # Split horizontally
kill-pane # Kill the active pane
kill-window # Kill the active window
select-window -t 1 # Switch to window 1
select-pane -t %3 # Switch to pane %3
rename-window new-name # Rename the active window
rename-session new-name # Rename the session
list-windows # List all windows
list-windows -F '#{window_id}' # Custom format
list-panes # List panes in active window
list-panes -a # List all panes across all windows
list-sessions # List sessions
list-clients # List connected clients
display-message -p '#{pane_id}' # Print a format variable
has-session -t my-session # Check if session exists (exit code)
send-keys -t %0 "echo hello" Enter # Send keystrokes to a pane
send-keys -t %0 -l "literal text" # Send text literally (no key parsing)
capture-pane -t %0 -p # Capture the visible content of a pane
set-option -g status-style "bg=blue" # Set an option
show-options -g # Show all global options
set-hook -g after-new-window "display-message hi" # Set a hook
bind-key M-x display-message "pressed!" # Bind a key
list-commands # List all available commands
server-info # Server information
kill-server # Shut down the server
These commands exist in psmux but not in tmux. The "In control mode" column matters: only the first three are wired into the control mode dispatcher. The rest are server commands, reachable from a key binding, a config line or a raw socket connection, but a control mode client gets %error unknown command for them.
| Command | In control mode | Description |
|---|---|---|
dump-state (alias dump) |
Yes | Returns the entire session state as a JSON blob (windows, panes, options, sizes, screen content). Invaluable for building rich UIs. |
zoom-pane |
Yes | Toggle zoom on the active pane |
run-command <cmd> (alias runcmd) |
Yes | Run any server command by name and return its output, with a 15 second timeout |
dump-layout |
No | Returns the pane layout tree structure |
list-tree |
No | Returns a hierarchical session/window/pane tree view |
send-text <text> |
No | Send raw text directly to the active pane (no key name parsing) |
send-paste <text> |
No | Send text as a bracketed paste sequence. Also available at the CLI as psmux send-paste. |
claim-session |
No | Internal warm pool claim used during session startup |
set-pane-title <title> |
No | Set the title of the current pane |
toggle-sync |
No | Toggle synchronized input across all panes in a window |
run-command is the escape hatch, but it only reaches commands that the config file layer also implements, so run-command toggle-sync works while run-command send-text hello does not. dump-layout, list-tree, send-text, set-pane-title and claim-session are reachable only over a raw socket connection or from a key binding, and send-paste additionally from the CLI.
import subprocess
import threading
proc = subprocess.Popen(
["psmux", "-CC"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env={**__import__("os").environ, "PSMUX_SESSION_NAME": "work"},
)
def read_notifications():
for line in proc.stdout:
line = line.rstrip("\n")
if line.startswith("%output"):
parts = line.split(" ", 2)
pane_id = parts[1]
data = parts[2] if len(parts) > 2 else ""
print(f"[{pane_id}] {data}")
elif line.startswith("%window-add"):
print(f"Window created: {line}")
elif line.startswith("%begin"):
pass # Start of command response
elif line.startswith("%end"):
pass # End of command response
elif line.startswith("%error"):
print(f"Command error: {line}")
reader = threading.Thread(target=read_notifications, daemon=True)
reader.start()
# Send a command
proc.stdin.write("list-windows\n")
proc.stdin.flush()
# Create a new window
proc.stdin.write("new-window -n build\n")
proc.stdin.flush()
# Run a command in it
proc.stdin.write('send-keys "cargo build" Enter\n')
proc.stdin.flush()
import time
time.sleep(5)
proc.stdin.close()
proc.wait()$env:PSMUX_SESSION_NAME = "work"
$psi = [System.Diagnostics.ProcessStartInfo]::new()
$psi.FileName = (Get-Command psmux).Source
$psi.Arguments = "-CC"
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.UseShellExecute = $false
$proc = [System.Diagnostics.Process]::Start($psi)
# Send a command
$proc.StandardInput.WriteLine("list-windows")
$proc.StandardInput.Flush()
Start-Sleep -Seconds 1
# Read the response
while ($proc.StandardOutput.Peek() -ge 0) {
$line = $proc.StandardOutput.ReadLine()
Write-Host $line
}
$proc.StandardInput.Close()
$proc.WaitForExit(5000)const { spawn } = require("child_process");
const proc = spawn("psmux", ["-CC"], {
env: { ...process.env, PSMUX_SESSION_NAME: "work" },
stdio: ["pipe", "pipe", "pipe"],
});
proc.stdout.on("data", (chunk) => {
for (const line of chunk.toString().split("\n")) {
if (line.startsWith("%output")) {
const [, paneId, ...rest] = line.split(" ");
console.log(`[${paneId}] ${rest.join(" ")}`);
} else if (line.startsWith("%begin")) {
// Command response starting
} else if (line.startsWith("%end")) {
// Command response complete
}
}
});
proc.stdin.write("list-windows\n");
proc.stdin.write("new-window -n monitor\n");
proc.stdin.write('send-keys "top" Enter\n');
setTimeout(() => {
proc.stdin.end();
}, 5000);-
Read line by line. Every notification and framing marker is a single line terminated by
\n. -
Track command state. When you send a command, set a flag. Lines between
%beginand%end/%errorare the command's output. Everything outside those blocks is asynchronous notifications. -
Match begin/end pairs by command number. The second field in
%beginand%endlines is the command counter. Use it to correlate responses with requests. -
Buffer line parsing for
%output. Split on the first two spaces:%output, pane ID, then the rest is escaped output data. -
Decode octal escapes. Replace
\NNNsequences in output data with the corresponding byte value.\134is a literal backslash. -
Handle connection loss gracefully. If the session dies or the server shuts down, stdout will close (EOF). Your reader loop should exit cleanly.
psmux control mode is wire-compatible with tmux's protocol. The flow control and subscription layer is implemented:
| Feature | Status | Notes |
|---|---|---|
refresh-client -f flags |
Implemented | pause-after=N and no-pause are parsed and honored. Other tmux client flags are ignored. |
refresh-client -A pane actions |
Implemented | -A '%N:continue' resumes a paused pane. pause is accepted but only continue takes effect. |
refresh-client -B subscriptions |
Implemented | -B 'name:target:format' adds a subscription, -B 'name:' removes it. Values are re-checked at most once per second. |
refresh-client -C w,h |
Implemented | Sets the control client viewport size. Note the argument is comma separated (-C 120,30) on the control mode path, which is what iTerm2 sends. |
%extended-output |
Implemented | Emitted instead of %output once a client has set pause-after=N, carrying the output age in milliseconds. |
%subscription-changed |
Implemented | Emitted when a subscribed format string changes value. |
| Unlinked window notifications | Not implemented | psmux never emits %unlinked-window-add, %unlinked-window-close or %unlinked-window-renamed. Each psmux server process owns exactly one session, so no window is ever outside the attached session's window list. |
The core protocol (framing, notifications, escaping, IDs, command dispatch) is fully compatible. Plugins targeting the basic tmux control mode protocol will work identically on psmux.
psmux runs a separate server process for each session, unlike tmux where one server holds many sessions. A control mode client therefore sees exactly one session, and %session-changed fires only when that client is pointed at a different session.
Multiple sessions still coexist on the machine, each with its own server, and psmux stitches them together:
list-sessionsenumerates every session on the machine (or every session in the-Lnamespace), not just the one this client is attached to.%sessions-changedfires when a session is created or destroyed anywhere, which is why it carries no session ID.switch-client -t <other-session>and cross sessionjoin-pane -s <other-session>:...reach across server processes.
So "one session per server" is a statement about process topology, not a limit of one session per machine.
If you are porting a Unix tmux plugin to psmux, be aware of these ConPTY behaviors:
- SMCUP/RMCUP consumed internally. ConPTY processes alternate screen buffer switches before the output reaches psmux. The
alternate_onflag is always false. psmux uses a heuristic (last row content analysis) to detect fullscreen TUI applications. - Output normalization. ConPTY may normalize line endings and process certain cursor movement sequences internally.
%outputdata may look slightly different from what a Unix tmux session would produce for the same shell command. capture-panealways reflects the primary screen buffer. There is no reliable way to detect whether a pane is showing the alternate screen.- Ctrl+C propagation.
GenerateConsoleCtrlEventsends to ALL processes sharing the console, not just the foreground process. When testing TUI apps viasend-keys, prefer using the app's quit key (e.g.q) rather thanC-c. - TUI exit timing. After a TUI application exits and sends RMCUP, ConPTY needs time to generate the restore sequences. If you
capture-paneimmediately after a TUI exits, you may still see TUI content. Allow 4 to 6 seconds for the screen to settle.
Use -L to run multiple independent psmux servers on the same machine:
psmux -L dev new-session -d -s myapp -x 120 -y 30
$env:PSMUX_SESSION_NAME = "dev__myapp"
psmux -CCThe PSMUX_SESSION_NAME value follows the format <namespace>__<session> when using -L. The double underscore is the separator.
Use display-message -p to query any format variable:
display-message -p '#{session_name}: #{window_index} #{pane_id}'
Common variables for control mode plugins:
| Variable | Example | Description |
|---|---|---|
#{session_name} |
work |
Session name |
#{session_id} |
$0 |
Session stable ID |
#{window_id} |
@0 |
Window stable ID |
#{window_index} |
0 |
Window index |
#{window_name} |
pwsh |
Window name |
#{pane_id} |
%0 |
Pane stable ID |
#{pane_index} |
0 |
Pane index within window |
#{pane_pid} |
12345 |
Pane child process PID |
#{pane_current_command} |
pwsh |
Pane running command |
#{pane_width} |
120 |
Pane width in columns |
#{pane_height} |
30 |
Pane height in rows |
#{cursor_x} |
5 |
Cursor column |
#{cursor_y} |
10 |
Cursor row |