# Control Mode
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.
## Quick Start
```powershell
# 1. Create a detached session
psmux new-session -d -s work -x 120 -y 30
# 2. Attach in control mode (no-echo)
psmux -CC
```
psmux 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`.
## Flags
| `-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 \`). |
## Session Targeting
By default, control mode connects to the session stored in `PSMUX_SESSION_NAME`. You can set it before launching:
```powershell
$env:PSMUX_SESSION_NAME = "my-session"
psmux -CC
```
## Wire Protocol
### Command/Response Framing
Every 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>
```
| `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
Notifications are asynchronous lines that psmux sends whenever something happens in the session. They always start with `%` and arrive between command response blocks.
#### Window Notifications
| `%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.) |
#### Session Notifications
| `%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 |
#### Pane Output
| `%output %<PID> <escaped_data>` | A pane produced output |
| `%pane-mode-changed %<PID>` | A pane entered or exited a special mode (e.g. copy mode) |
#### Flow Control
| `%pause %<PID>` | Output for this pane has been paused (client is too far behind) |
| `%continue %<PID>` | Output for this pane has resumed |
#### Client and Buffer
| `%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`) |
#### Exit
| `%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`). |
### ID Formats
All IDs are stable, monotonically increasing integers that never get reused during a server's lifetime:
| `$` | Session | `$0` |
| `@` | Window | `@0`, `@1`, `@2` |
| `%` | Pane | `%0`, `%1`, `%2` |
### Output Escaping
Data in `%output` notifications uses octal escaping for non-printable bytes:
| 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`.
## Supported Commands
All standard psmux/tmux commands work in control mode. Here are the most useful ones for plugin development:
### Session and Window Management
```
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
```
### Querying State
```
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)
```
### Interacting with Panes
```
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
```
### Configuration and Hooks
```
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
```
### Server
```
list-commands # List all available commands
server-info # Server information
kill-server # Shut down the server
```
### psmux Extension Commands
These commands are available in psmux but do not exist in tmux:
| `dump-state` | Returns the entire session state as a JSON blob (windows, panes, options, sizes, screen content). Invaluable for building rich UIs. |
| `dump-layout` | Returns the pane layout tree structure |
| `list-tree` | Returns a hierarchical session/window/pane tree view |
| `send-text <text>` | Send raw text directly to the active pane (no key name parsing) |
| `send-paste <text>` | Send text as a bracketed paste sequence |
| `claim-session` | Claim a warm (pre spawned) session for faster startup |
| `set-pane-title <title>` | Set the title of the current pane |
| `toggle-sync` | Toggle synchronized input across all panes in a window |
## Building a Plugin
### Minimal Python Example
```python
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()
```
### Minimal PowerShell Example
```powershell
$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)
```
### Minimal Node.js Example
```javascript
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);
```
## Parsing Tips
1. **Read line by line.** Every notification and framing marker is a single line terminated by `\n`.
2. **Track command state.** When you send a command, set a flag. Lines between `%begin` and `%end`/`%error` are the command's output. Everything outside those blocks is asynchronous notifications.
3. **Match begin/end pairs by command number.** The second field in `%begin` and `%end` lines is the command counter. Use it to correlate responses with requests.
4. **Buffer line parsing for `%output`.** Split on the first two spaces: `%output`, pane ID, then the rest is escaped output data.
5. **Decode octal escapes.** Replace `\NNN` sequences in output data with the corresponding byte value. `\134` is a literal backslash.
6. **Handle connection loss gracefully.** If the session dies or the server shuts down, stdout will close (EOF). Your reader loop should exit cleanly.
## Differences from tmux
psmux control mode is wire-compatible with tmux's protocol. A few features that exist in tmux but are not yet implemented in psmux:
| `refresh-client -f` flags | Planned | Per-client flags like `no-output`, `pause-after=N` |
| `refresh-client -A` pane actions | Planned | Per-pane on/off/continue/pause |
| `refresh-client -B` subscriptions | Planned | Filtered format variable monitoring |
| `refresh-client -C WxH` | Planned | Client-side size override |
| `%extended-output` | Planned | Output with age info for flow control |
| `%subscription-changed` | Planned | Subscription value change events |
| Unlinked window notifications | N/A | psmux uses one session per server |
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.
### Windows ConPTY Considerations
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_on` flag 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. `%output` data may look slightly different from what a Unix tmux session would produce for the same shell command.
- **`capture-pane` always reflects the primary screen buffer.** There is no reliable way to detect whether a pane is showing the alternate screen.
- **Ctrl+C propagation.** `GenerateConsoleCtrlEvent` sends to ALL processes sharing the console, not just the foreground process. When testing TUI apps via `send-keys`, prefer using the app's quit key (e.g. `q`) rather than `C-c`.
- **TUI exit timing.** After a TUI application exits and sends RMCUP, ConPTY needs time to generate the restore sequences. If you `capture-pane` immediately after a TUI exits, you may still see TUI content. Allow 4 to 6 seconds for the screen to settle.
### Namespace Isolation
Use `-L` to run multiple independent psmux servers on the same machine:
```powershell
psmux -L dev new-session -d -s myapp -x 120 -y 30
$env:PSMUX_SESSION_NAME = "dev__myapp"
psmux -CC
```
The `PSMUX_SESSION_NAME` value follows the format `<namespace>__<session>` when using `-L`. The double underscore is the separator.
## Format Variables
Use `display-message -p` to query any format variable:
```
display-message -p '#{session_name}: #{window_index} #{pane_id}'
```
Common variables for control mode plugins:
| `#{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 |