|
| 1 | +# Control Mode |
| 2 | + |
| 3 | +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. |
| 4 | + |
| 5 | +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. |
| 6 | + |
| 7 | +## Quick Start |
| 8 | + |
| 9 | +```powershell |
| 10 | +# 1. Create a detached session |
| 11 | +psmux new-session -d -s work -x 120 -y 30 |
| 12 | +
|
| 13 | +# 2. Attach in control mode (no-echo) |
| 14 | +psmux -CC |
| 15 | +``` |
| 16 | + |
| 17 | +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. |
| 18 | + |
| 19 | +``` |
| 20 | +list-windows |
| 21 | +%begin 1700000000 1 1 |
| 22 | +0: pwsh* (1 panes) [120x30] |
| 23 | +%end 1700000000 1 1 |
| 24 | +``` |
| 25 | + |
| 26 | +To exit, close stdin (Ctrl+D / EOF) or send `kill-server`. |
| 27 | + |
| 28 | +## Flags |
| 29 | + |
| 30 | +| Flag | Mode | Behavior | |
| 31 | +|------|------|----------| |
| 32 | +| `-C` | Echo | Commands you send are echoed back to stdout before the response. Useful for debugging and interactive testing. | |
| 33 | +| `-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 \`). | |
| 34 | + |
| 35 | +## Session Targeting |
| 36 | + |
| 37 | +By default, control mode connects to the session stored in `PSMUX_SESSION_NAME`. You can set it before launching: |
| 38 | + |
| 39 | +```powershell |
| 40 | +$env:PSMUX_SESSION_NAME = "my-session" |
| 41 | +psmux -CC |
| 42 | +``` |
| 43 | + |
| 44 | +## Wire Protocol |
| 45 | + |
| 46 | +### Command/Response Framing |
| 47 | + |
| 48 | +Every command you send gets a response wrapped in `%begin` / `%end` (or `%error`) markers: |
| 49 | + |
| 50 | +``` |
| 51 | +<your command> |
| 52 | +%begin <timestamp> <command_number> <flags> |
| 53 | +<response lines> |
| 54 | +%end <timestamp> <command_number> <flags> |
| 55 | +``` |
| 56 | + |
| 57 | +| Field | Description | |
| 58 | +|-------|-------------| |
| 59 | +| `timestamp` | Unix epoch seconds when the command was processed | |
| 60 | +| `command_number` | Sequential counter (1, 2, 3, ...) for each command in the session | |
| 61 | +| `flags` | Reserved, always `1` | |
| 62 | + |
| 63 | +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`: |
| 64 | + |
| 65 | +``` |
| 66 | +nonexistent-command |
| 67 | +%begin 1700000000 1 1 |
| 68 | +unknown command: nonexistent-command |
| 69 | +%error 1700000000 1 1 |
| 70 | +``` |
| 71 | + |
| 72 | +Command response blocks never interleave with each other. Notifications (described below) arrive between command blocks, never inside them. |
| 73 | + |
| 74 | +### Notifications |
| 75 | + |
| 76 | +Notifications are asynchronous lines that psmux sends whenever something happens in the session. They always start with `%` and arrive between command response blocks. |
| 77 | + |
| 78 | +#### Window Notifications |
| 79 | + |
| 80 | +| Notification | Meaning | |
| 81 | +|---|---| |
| 82 | +| `%window-add @<WID>` | A new window was created | |
| 83 | +| `%window-close @<WID>` | A window was destroyed | |
| 84 | +| `%window-renamed @<WID> <name>` | A window was renamed | |
| 85 | +| `%window-pane-changed @<WID> %<PID>` | The active pane in a window changed | |
| 86 | +| `%layout-change @<WID> <layout> <visible_layout> <flags>` | A window's pane layout changed (split, resize, etc.) | |
| 87 | + |
| 88 | +#### Session Notifications |
| 89 | + |
| 90 | +| Notification | Meaning | |
| 91 | +|---|---| |
| 92 | +| `%session-changed $<SID> <name>` | The attached session changed | |
| 93 | +| `%session-renamed <name>` | The current session was renamed | |
| 94 | +| `%session-window-changed $<SID> @<WID>` | The active window in a session changed | |
| 95 | +| `%sessions-changed` | A session was created or destroyed | |
| 96 | + |
| 97 | +#### Pane Output |
| 98 | + |
| 99 | +| Notification | Meaning | |
| 100 | +|---|---| |
| 101 | +| `%output %<PID> <escaped_data>` | A pane produced output | |
| 102 | +| `%pane-mode-changed %<PID>` | A pane entered or exited a special mode (e.g. copy mode) | |
| 103 | + |
| 104 | +#### Flow Control |
| 105 | + |
| 106 | +| Notification | Meaning | |
| 107 | +|---|---| |
| 108 | +| `%pause %<PID>` | Output for this pane has been paused (client is too far behind) | |
| 109 | +| `%continue %<PID>` | Output for this pane has resumed | |
| 110 | + |
| 111 | +#### Client and Buffer |
| 112 | + |
| 113 | +| Notification | Meaning | |
| 114 | +|---|---| |
| 115 | +| `%client-detached <client>` | A client disconnected from the session | |
| 116 | +| `%client-session-changed <client> $<SID> <name>` | Another client changed its attached session | |
| 117 | +| `%paste-buffer-changed <name>` | A paste buffer was modified | |
| 118 | +| `%paste-buffer-deleted <name>` | A paste buffer was deleted | |
| 119 | +| `%message <text>` | A status message was generated (e.g. from `display-message`) | |
| 120 | + |
| 121 | +#### Exit |
| 122 | + |
| 123 | +| Notification | Meaning | |
| 124 | +|---|---| |
| 125 | +| `%exit` | The control client is disconnecting. In `-CC` mode, followed by `ESC \` (ST sequence). | |
| 126 | +| `%exit <reason>` | Disconnecting with a reason (e.g. `too far behind`). | |
| 127 | + |
| 128 | +### ID Formats |
| 129 | + |
| 130 | +All IDs are stable, monotonically increasing integers that never get reused during a server's lifetime: |
| 131 | + |
| 132 | +| Prefix | Entity | Example | |
| 133 | +|--------|--------|---------| |
| 134 | +| `$` | Session | `$0` | |
| 135 | +| `@` | Window | `@0`, `@1`, `@2` | |
| 136 | +| `%` | Pane | `%0`, `%1`, `%2` | |
| 137 | + |
| 138 | +### Output Escaping |
| 139 | + |
| 140 | +Data in `%output` notifications uses octal escaping for non-printable bytes: |
| 141 | + |
| 142 | +| Byte | Encoding | |
| 143 | +|------|----------| |
| 144 | +| Printable ASCII (0x20 to 0x7E) | Passed through as-is | |
| 145 | +| Tab (0x09) | Passed through as-is | |
| 146 | +| Backslash (0x5C) | `\134` | |
| 147 | +| Carriage return (0x0D) | `\015` | |
| 148 | +| Line feed (0x0A) | `\012` | |
| 149 | +| Any other byte | `\NNN` (3-digit octal) | |
| 150 | + |
| 151 | +Example: `hello\r\n` becomes `%output %0 hello\015\012`. |
| 152 | + |
| 153 | +## Supported Commands |
| 154 | + |
| 155 | +All standard psmux/tmux commands work in control mode. Here are the most useful ones for plugin development: |
| 156 | + |
| 157 | +### Session and Window Management |
| 158 | + |
| 159 | +``` |
| 160 | +new-window # Create a new window |
| 161 | +new-window -n editor # Create a named window |
| 162 | +split-window -v # Split vertically |
| 163 | +split-window -h # Split horizontally |
| 164 | +kill-pane # Kill the active pane |
| 165 | +kill-window # Kill the active window |
| 166 | +select-window -t 1 # Switch to window 1 |
| 167 | +select-pane -t %3 # Switch to pane %3 |
| 168 | +rename-window new-name # Rename the active window |
| 169 | +rename-session new-name # Rename the session |
| 170 | +``` |
| 171 | + |
| 172 | +### Querying State |
| 173 | + |
| 174 | +``` |
| 175 | +list-windows # List all windows |
| 176 | +list-windows -F '#{window_id}' # Custom format |
| 177 | +list-panes # List panes in active window |
| 178 | +list-panes -a # List all panes across all windows |
| 179 | +list-sessions # List sessions |
| 180 | +list-clients # List connected clients |
| 181 | +display-message -p '#{pane_id}' # Print a format variable |
| 182 | +has-session -t my-session # Check if session exists (exit code) |
| 183 | +``` |
| 184 | + |
| 185 | +### Interacting with Panes |
| 186 | + |
| 187 | +``` |
| 188 | +send-keys -t %0 "echo hello" Enter # Send keystrokes to a pane |
| 189 | +send-keys -t %0 -l "literal text" # Send text literally (no key parsing) |
| 190 | +capture-pane -t %0 -p # Capture the visible content of a pane |
| 191 | +``` |
| 192 | + |
| 193 | +### Configuration and Hooks |
| 194 | + |
| 195 | +``` |
| 196 | +set-option -g status-style "bg=blue" # Set an option |
| 197 | +show-options -g # Show all global options |
| 198 | +set-hook -g after-new-window "display-message hi" # Set a hook |
| 199 | +bind-key M-x display-message "pressed!" # Bind a key |
| 200 | +``` |
| 201 | + |
| 202 | +### Server |
| 203 | + |
| 204 | +``` |
| 205 | +list-commands # List all available commands |
| 206 | +server-info # Server information |
| 207 | +kill-server # Shut down the server |
| 208 | +``` |
| 209 | + |
| 210 | +## Building a Plugin |
| 211 | + |
| 212 | +### Minimal Python Example |
| 213 | + |
| 214 | +```python |
| 215 | +import subprocess |
| 216 | +import threading |
| 217 | + |
| 218 | +proc = subprocess.Popen( |
| 219 | + ["psmux", "-CC"], |
| 220 | + stdin=subprocess.PIPE, |
| 221 | + stdout=subprocess.PIPE, |
| 222 | + stderr=subprocess.PIPE, |
| 223 | + text=True, |
| 224 | + env={**__import__("os").environ, "PSMUX_SESSION_NAME": "work"}, |
| 225 | +) |
| 226 | + |
| 227 | +def read_notifications(): |
| 228 | + for line in proc.stdout: |
| 229 | + line = line.rstrip("\n") |
| 230 | + if line.startswith("%output"): |
| 231 | + parts = line.split(" ", 2) |
| 232 | + pane_id = parts[1] |
| 233 | + data = parts[2] if len(parts) > 2 else "" |
| 234 | + print(f"[{pane_id}] {data}") |
| 235 | + elif line.startswith("%window-add"): |
| 236 | + print(f"Window created: {line}") |
| 237 | + elif line.startswith("%begin"): |
| 238 | + pass # Start of command response |
| 239 | + elif line.startswith("%end"): |
| 240 | + pass # End of command response |
| 241 | + elif line.startswith("%error"): |
| 242 | + print(f"Command error: {line}") |
| 243 | + |
| 244 | +reader = threading.Thread(target=read_notifications, daemon=True) |
| 245 | +reader.start() |
| 246 | + |
| 247 | +# Send a command |
| 248 | +proc.stdin.write("list-windows\n") |
| 249 | +proc.stdin.flush() |
| 250 | + |
| 251 | +# Create a new window |
| 252 | +proc.stdin.write("new-window -n build\n") |
| 253 | +proc.stdin.flush() |
| 254 | + |
| 255 | +# Run a command in it |
| 256 | +proc.stdin.write('send-keys "cargo build" Enter\n') |
| 257 | +proc.stdin.flush() |
| 258 | + |
| 259 | +import time |
| 260 | +time.sleep(5) |
| 261 | +proc.stdin.close() |
| 262 | +proc.wait() |
| 263 | +``` |
| 264 | + |
| 265 | +### Minimal PowerShell Example |
| 266 | + |
| 267 | +```powershell |
| 268 | +$env:PSMUX_SESSION_NAME = "work" |
| 269 | +$psi = [System.Diagnostics.ProcessStartInfo]::new() |
| 270 | +$psi.FileName = (Get-Command psmux).Source |
| 271 | +$psi.Arguments = "-CC" |
| 272 | +$psi.RedirectStandardInput = $true |
| 273 | +$psi.RedirectStandardOutput = $true |
| 274 | +$psi.UseShellExecute = $false |
| 275 | +
|
| 276 | +$proc = [System.Diagnostics.Process]::Start($psi) |
| 277 | +
|
| 278 | +# Send a command |
| 279 | +$proc.StandardInput.WriteLine("list-windows") |
| 280 | +$proc.StandardInput.Flush() |
| 281 | +Start-Sleep -Seconds 1 |
| 282 | +
|
| 283 | +# Read the response |
| 284 | +while ($proc.StandardOutput.Peek() -ge 0) { |
| 285 | + $line = $proc.StandardOutput.ReadLine() |
| 286 | + Write-Host $line |
| 287 | +} |
| 288 | +
|
| 289 | +$proc.StandardInput.Close() |
| 290 | +$proc.WaitForExit(5000) |
| 291 | +``` |
| 292 | + |
| 293 | +### Minimal Node.js Example |
| 294 | + |
| 295 | +```javascript |
| 296 | +const { spawn } = require("child_process"); |
| 297 | + |
| 298 | +const proc = spawn("psmux", ["-CC"], { |
| 299 | + env: { ...process.env, PSMUX_SESSION_NAME: "work" }, |
| 300 | + stdio: ["pipe", "pipe", "pipe"], |
| 301 | +}); |
| 302 | + |
| 303 | +proc.stdout.on("data", (chunk) => { |
| 304 | + for (const line of chunk.toString().split("\n")) { |
| 305 | + if (line.startsWith("%output")) { |
| 306 | + const [, paneId, ...rest] = line.split(" "); |
| 307 | + console.log(`[${paneId}] ${rest.join(" ")}`); |
| 308 | + } else if (line.startsWith("%begin")) { |
| 309 | + // Command response starting |
| 310 | + } else if (line.startsWith("%end")) { |
| 311 | + // Command response complete |
| 312 | + } |
| 313 | + } |
| 314 | +}); |
| 315 | + |
| 316 | +proc.stdin.write("list-windows\n"); |
| 317 | +proc.stdin.write("new-window -n monitor\n"); |
| 318 | +proc.stdin.write('send-keys "top" Enter\n'); |
| 319 | + |
| 320 | +setTimeout(() => { |
| 321 | + proc.stdin.end(); |
| 322 | +}, 5000); |
| 323 | +``` |
| 324 | + |
| 325 | +## Parsing Tips |
| 326 | + |
| 327 | +1. **Read line by line.** Every notification and framing marker is a single line terminated by `\n`. |
| 328 | + |
| 329 | +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. |
| 330 | + |
| 331 | +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. |
| 332 | + |
| 333 | +4. **Buffer line parsing for `%output`.** Split on the first two spaces: `%output`, pane ID, then the rest is escaped output data. |
| 334 | + |
| 335 | +5. **Decode octal escapes.** Replace `\NNN` sequences in output data with the corresponding byte value. `\134` is a literal backslash. |
| 336 | + |
| 337 | +6. **Handle connection loss gracefully.** If the session dies or the server shuts down, stdout will close (EOF). Your reader loop should exit cleanly. |
| 338 | + |
| 339 | +## Differences from tmux |
| 340 | + |
| 341 | +psmux control mode is wire-compatible with tmux's protocol. A few features that exist in tmux but are not yet implemented in psmux: |
| 342 | + |
| 343 | +| Feature | Status | Notes | |
| 344 | +|---------|--------|-------| |
| 345 | +| `refresh-client -f` flags | Planned | Per-client flags like `no-output`, `pause-after=N` | |
| 346 | +| `refresh-client -A` pane actions | Planned | Per-pane on/off/continue/pause | |
| 347 | +| `refresh-client -B` subscriptions | Planned | Filtered format variable monitoring | |
| 348 | +| `refresh-client -C WxH` | Planned | Client-side size override | |
| 349 | +| `%extended-output` | Planned | Output with age info for flow control | |
| 350 | +| `%subscription-changed` | Planned | Subscription value change events | |
| 351 | +| Unlinked window notifications | N/A | psmux uses one session per server | |
| 352 | + |
| 353 | +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. |
| 354 | + |
| 355 | +## Format Variables |
| 356 | + |
| 357 | +Use `display-message -p` to query any format variable: |
| 358 | + |
| 359 | +``` |
| 360 | +display-message -p '#{session_name}: #{window_index} #{pane_id}' |
| 361 | +``` |
| 362 | + |
| 363 | +Common variables for control mode plugins: |
| 364 | + |
| 365 | +| Variable | Example | Description | |
| 366 | +|----------|---------|-------------| |
| 367 | +| `#{session_name}` | `work` | Session name | |
| 368 | +| `#{session_id}` | `$0` | Session stable ID | |
| 369 | +| `#{window_id}` | `@0` | Window stable ID | |
| 370 | +| `#{window_index}` | `0` | Window index | |
| 371 | +| `#{window_name}` | `pwsh` | Window name | |
| 372 | +| `#{pane_id}` | `%0` | Pane stable ID | |
| 373 | +| `#{pane_index}` | `0` | Pane index within window | |
| 374 | +| `#{pane_pid}` | `12345` | Pane child process PID | |
| 375 | +| `#{pane_current_command}` | `pwsh` | Pane running command | |
| 376 | +| `#{pane_width}` | `120` | Pane width in columns | |
| 377 | +| `#{pane_height}` | `30` | Pane height in rows | |
| 378 | +| `#{cursor_x}` | `5` | Cursor column | |
| 379 | +| `#{cursor_y}` | `10` | Cursor row | |
0 commit comments