twmux 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
twmux/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
twmux/bin/__init__.py ADDED
File without changes
twmux/bin/cli.py ADDED
@@ -0,0 +1,263 @@
1
+ """twmux CLI entry point."""
2
+
3
+ import json as json_lib
4
+ from typing import Annotated
5
+
6
+ import typer
7
+ from rich import print as rprint
8
+
9
+ from twmux import __version__
10
+
11
+ app = typer.Typer(
12
+ help="Race-condition-safe tmux wrapper for coding agents",
13
+ no_args_is_help=True,
14
+ )
15
+
16
+ # Global options
17
+ json_output: bool = False
18
+ socket_name: str | None = None
19
+
20
+
21
+ @app.callback()
22
+ def main(
23
+ json: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
24
+ verbose: Annotated[bool, typer.Option("-v", "--verbose", help="Verbose output")] = False,
25
+ socket: Annotated[str | None, typer.Option("-L", "--socket", help="Socket name")] = None,
26
+ ) -> None:
27
+ """Configure global options."""
28
+ global json_output, socket_name
29
+ json_output = json
30
+ socket_name = socket
31
+
32
+
33
+ def get_pane(target: str):
34
+ """Resolve target to libtmux Pane object."""
35
+ from libtmux import Server
36
+
37
+ server = Server(socket_name=socket_name)
38
+
39
+ # Handle pane ID directly (e.g., %5)
40
+ if target.startswith("%"):
41
+ for session in server.sessions:
42
+ for window in session.windows:
43
+ for pane in window.panes:
44
+ if pane.pane_id == target:
45
+ return pane
46
+ raise typer.BadParameter(f"Pane not found: {target}")
47
+
48
+ # Handle session:window.pane format
49
+ parts = target.split(":")
50
+ session_name = parts[0] if parts[0] else None
51
+
52
+ # Find session
53
+ if session_name:
54
+ sessions = [s for s in server.sessions if s.session_name == session_name]
55
+ if not sessions:
56
+ raise typer.BadParameter(f"Session not found: {session_name}")
57
+ session = sessions[0]
58
+ else:
59
+ session = server.sessions[0] if server.sessions else None
60
+ if not session:
61
+ raise typer.BadParameter("No tmux sessions found")
62
+
63
+ # Parse window.pane
64
+ if len(parts) > 1 and parts[1]:
65
+ window_pane = parts[1].split(".")
66
+ window_idx = int(window_pane[0]) if window_pane[0] else 0
67
+ pane_idx = int(window_pane[1]) if len(window_pane) > 1 else 0
68
+
69
+ window = session.windows[window_idx]
70
+ return window.panes[pane_idx]
71
+
72
+ return session.active_window.active_pane
73
+
74
+
75
+ def output_result(data: dict) -> None:
76
+ """Output result in appropriate format."""
77
+ if json_output:
78
+ print(json_lib.dumps(data))
79
+ else:
80
+ for key, value in data.items():
81
+ rprint(f"{key}: {value}")
82
+
83
+
84
+ @app.command()
85
+ def version() -> None:
86
+ """Show version."""
87
+ if json_output:
88
+ print(json_lib.dumps({"version": __version__}))
89
+ else:
90
+ print(f"twmux {__version__}")
91
+
92
+
93
+ @app.command()
94
+ def send(
95
+ text: Annotated[str, typer.Argument(help="Text to send")],
96
+ target: Annotated[str, typer.Option("-t", "--target", help="Target pane")] = "",
97
+ no_enter: Annotated[bool, typer.Option("--no-enter", help="Don't send Enter")] = False,
98
+ delay: Annotated[float, typer.Option("--delay", help="Enter delay (seconds)")] = 0.05,
99
+ ) -> None:
100
+ """Send text to pane (race-condition safe)."""
101
+ from twmux.lib.safe_input import send_safe
102
+
103
+ pane = get_pane(target)
104
+ result = send_safe(pane, text, enter=not no_enter, enter_delay=delay)
105
+
106
+ output_result({"success": result.success, "attempts": result.attempts})
107
+
108
+ if not result.success:
109
+ raise typer.Exit(1)
110
+
111
+
112
+ @app.command(name="exec")
113
+ def exec_cmd(
114
+ command: Annotated[str, typer.Argument(help="Command to execute")],
115
+ target: Annotated[str, typer.Option("-t", "--target", help="Target pane")] = "",
116
+ timeout: Annotated[float, typer.Option("--timeout", help="Timeout (seconds)")] = 30.0,
117
+ ) -> None:
118
+ """Execute command and capture output + exit code."""
119
+ from twmux.lib.execution import execute
120
+
121
+ pane = get_pane(target)
122
+ result = execute(pane, command, timeout=timeout)
123
+
124
+ output_result(
125
+ {
126
+ "output": result.output,
127
+ "exit_code": result.exit_code,
128
+ "timed_out": result.timed_out,
129
+ }
130
+ )
131
+
132
+
133
+ @app.command()
134
+ def capture(
135
+ target: Annotated[str, typer.Option("-t", "--target", help="Target pane")] = "",
136
+ lines: Annotated[int | None, typer.Option("-n", "--lines", help="Number of lines")] = None,
137
+ ) -> None:
138
+ """Capture pane content."""
139
+ pane = get_pane(target)
140
+
141
+ if lines:
142
+ content = pane.capture_pane(start=-lines)
143
+ else:
144
+ content = pane.capture_pane()
145
+
146
+ if json_output:
147
+ print(json_lib.dumps({"content": content}))
148
+ else:
149
+ print("\n".join(content))
150
+
151
+
152
+ @app.command(name="wait-idle")
153
+ def wait_idle(
154
+ target: Annotated[str, typer.Option("-t", "--target", help="Target pane")] = "",
155
+ timeout: Annotated[float, typer.Option("--timeout", help="Timeout (seconds)")] = 30.0,
156
+ interval: Annotated[float, typer.Option("--interval", help="Poll interval")] = 0.2,
157
+ ) -> None:
158
+ """Wait for pane output to stabilize."""
159
+ from twmux.lib.safe_input import wait_for_idle
160
+
161
+ pane = get_pane(target)
162
+ result = wait_for_idle(pane, poll_interval=interval, timeout=timeout)
163
+
164
+ output_result({"idle": result.idle, "elapsed": round(result.elapsed, 3)})
165
+
166
+ if not result.idle:
167
+ raise typer.Exit(1)
168
+
169
+
170
+ @app.command()
171
+ def interrupt(
172
+ target: Annotated[str, typer.Option("-t", "--target", help="Target pane")] = "",
173
+ ) -> None:
174
+ """Send Ctrl+C to pane."""
175
+ pane = get_pane(target)
176
+ pane.send_keys("C-c", enter=False)
177
+
178
+ output_result({"interrupted": True})
179
+
180
+
181
+ @app.command()
182
+ def launch(
183
+ target: Annotated[str, typer.Option("-t", "--target", help="Target pane")] = "",
184
+ command: Annotated[str | None, typer.Option("-c", "--command", help="Command to run")] = None,
185
+ vertical: Annotated[bool, typer.Option("-v", "--vertical", help="Split vertically")] = False,
186
+ ) -> None:
187
+ """Create new pane (split current window)."""
188
+ from libtmux.constants import PaneDirection
189
+
190
+ pane = get_pane(target)
191
+
192
+ direction = PaneDirection.Right if vertical else PaneDirection.Below
193
+ new_pane = pane.split(direction=direction)
194
+
195
+ if command:
196
+ new_pane.send_keys(command, enter=True)
197
+
198
+ output_result({"pane_id": new_pane.pane_id})
199
+
200
+
201
+ @app.command()
202
+ def kill(
203
+ target: Annotated[str, typer.Option("-t", "--target", help="Target pane")] = "",
204
+ ) -> None:
205
+ """Kill pane."""
206
+ pane = get_pane(target)
207
+ pane.kill()
208
+
209
+ output_result({"killed": True})
210
+
211
+
212
+ @app.command()
213
+ def escape(
214
+ target: Annotated[str, typer.Option("-t", "--target", help="Target pane")] = "",
215
+ ) -> None:
216
+ """Send Escape key to pane."""
217
+ pane = get_pane(target)
218
+ pane.send_keys("Escape", enter=False)
219
+
220
+ output_result({"escaped": True})
221
+
222
+
223
+ @app.command()
224
+ def status() -> None:
225
+ """Show tmux state."""
226
+ from libtmux import Server
227
+
228
+ server = Server(socket_name=socket_name)
229
+
230
+ sessions_data = []
231
+ for session in server.sessions:
232
+ windows_data = []
233
+ for window in session.windows:
234
+ panes_data = [{"pane_id": p.pane_id, "pane_index": p.pane_index} for p in window.panes]
235
+ windows_data.append(
236
+ {
237
+ "window_id": window.window_id,
238
+ "window_index": window.window_index,
239
+ "window_name": window.window_name,
240
+ "panes": panes_data,
241
+ }
242
+ )
243
+ sessions_data.append(
244
+ {
245
+ "session_id": session.session_id,
246
+ "session_name": session.session_name,
247
+ "windows": windows_data,
248
+ }
249
+ )
250
+
251
+ if json_output:
252
+ print(json_lib.dumps({"sessions": sessions_data}, indent=2))
253
+ else:
254
+ for s in sessions_data:
255
+ rprint(f"[bold]{s['session_name']}[/bold] ({s['session_id']})")
256
+ for w in s["windows"]:
257
+ rprint(f" {w['window_index']}: {w['window_name']} ({w['window_id']})")
258
+ for p in w["panes"]:
259
+ rprint(f" .{p['pane_index']}: {p['pane_id']}")
260
+
261
+
262
+ if __name__ == "__main__":
263
+ app()
twmux/lib/__init__.py ADDED
File without changes
twmux/lib/execution.py ADDED
@@ -0,0 +1,121 @@
1
+ """Marker-based command execution with exit code capture."""
2
+
3
+ import os
4
+ import re
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from libtmux import Pane
11
+
12
+ # Progressive capture expansion levels
13
+ EXPANSION_LEVELS: list[int | None] = [100, 500, 2000, None]
14
+
15
+
16
+ @dataclass
17
+ class ExecResult:
18
+ """Result of command execution."""
19
+
20
+ output: str
21
+ exit_code: int # -1 = timeout/markers not found
22
+ timed_out: bool
23
+
24
+
25
+ def generate_markers() -> tuple[str, str]:
26
+ """Generate unique start and end markers.
27
+
28
+ Uses PID + nanosecond timestamp for uniqueness.
29
+ """
30
+ unique_id = f"{os.getpid()}_{time.time_ns()}"
31
+ start_marker = f"__TWMUX_START_{unique_id}__"
32
+ end_marker = f"__TWMUX_END_{unique_id}__"
33
+ return start_marker, end_marker
34
+
35
+
36
+ def wrap_command(cmd: str, start_marker: str, end_marker: str) -> str:
37
+ """Wrap command with markers to capture output and exit code."""
38
+ return f"echo {start_marker}; {{ {cmd}; }} 2>&1; echo {end_marker}:$?"
39
+
40
+
41
+ def parse_output(captured: str, start_marker: str, end_marker: str) -> ExecResult:
42
+ """Parse captured output to extract command output and exit code.
43
+
44
+ Returns:
45
+ ExecResult with parsed output and exit code, or exit_code=-1 if markers not found
46
+ """
47
+ if start_marker not in captured:
48
+ return ExecResult(output=captured, exit_code=-1, timed_out=False)
49
+
50
+ # Find end marker with exit code
51
+ end_pattern = re.escape(end_marker) + r":(\d+)"
52
+ end_match = re.search(end_pattern, captured)
53
+
54
+ if not end_match:
55
+ return ExecResult(output=captured, exit_code=-1, timed_out=False)
56
+
57
+ exit_code = int(end_match.group(1))
58
+
59
+ # Extract output between markers (use rfind to get LAST occurrence,
60
+ # since the marker also appears in the echoed command line)
61
+ start_idx = captured.rfind(start_marker) + len(start_marker)
62
+ end_idx = end_match.start()
63
+
64
+ output = captured[start_idx:end_idx].strip()
65
+
66
+ return ExecResult(output=output, exit_code=exit_code, timed_out=False)
67
+
68
+
69
+ def execute(
70
+ pane: "Pane",
71
+ cmd: str,
72
+ timeout: float = 30.0,
73
+ poll_interval: float = 0.2,
74
+ ) -> ExecResult:
75
+ """Execute command and capture output with exit code.
76
+
77
+ Uses unique markers to reliably capture command output and exit code.
78
+ Polls with progressive expansion to handle long outputs efficiently.
79
+
80
+ Args:
81
+ pane: libtmux Pane object
82
+ cmd: Command to execute
83
+ timeout: Maximum seconds to wait for completion
84
+ poll_interval: Seconds between polls
85
+
86
+ Returns:
87
+ ExecResult with output, exit code, and timeout status
88
+ """
89
+ start_marker, end_marker = generate_markers()
90
+ wrapped = wrap_command(cmd, start_marker, end_marker)
91
+
92
+ # Send wrapped command
93
+ pane.send_keys(wrapped, enter=True)
94
+
95
+ start_time = time.monotonic()
96
+
97
+ while True:
98
+ elapsed = time.monotonic() - start_time
99
+ if elapsed >= timeout:
100
+ return ExecResult(output="", exit_code=-1, timed_out=True)
101
+
102
+ # Try progressive expansion levels
103
+ for lines in EXPANSION_LEVELS:
104
+ if lines is None:
105
+ captured = "\n".join(pane.capture_pane())
106
+ else:
107
+ captured = "\n".join(pane.capture_pane(start=-lines))
108
+
109
+ # Check if end marker present
110
+ end_pattern = re.escape(end_marker) + r":(\d+)"
111
+ if re.search(end_pattern, captured):
112
+ # Verify start marker also present
113
+ if start_marker in captured:
114
+ return parse_output(captured, start_marker, end_marker)
115
+ # End found but not start - need more lines
116
+ continue
117
+ else:
118
+ # End not found - wait and poll again
119
+ break
120
+
121
+ time.sleep(poll_interval)
@@ -0,0 +1,120 @@
1
+ """Race-condition-safe tmux input operations."""
2
+
3
+ import hashlib
4
+ import time
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from libtmux import Pane
10
+
11
+
12
+ @dataclass
13
+ class SendResult:
14
+ """Result of a safe send operation."""
15
+
16
+ success: bool
17
+ attempts: int
18
+
19
+
20
+ @dataclass
21
+ class WaitResult:
22
+ """Result of wait_for_idle operation."""
23
+
24
+ idle: bool
25
+ elapsed: float
26
+
27
+
28
+ def _hash_content(content: list[str]) -> str:
29
+ """Hash pane content for comparison."""
30
+ text = "\n".join(content)
31
+ return hashlib.md5(text.encode()).hexdigest()
32
+
33
+
34
+ def wait_for_idle(
35
+ pane: "Pane",
36
+ poll_interval: float = 0.2,
37
+ stable_count: int = 3,
38
+ timeout: float = 30.0,
39
+ ) -> WaitResult:
40
+ """Wait until pane output stabilizes.
41
+
42
+ Args:
43
+ pane: libtmux Pane object
44
+ poll_interval: Seconds between content checks
45
+ stable_count: Number of consecutive identical hashes required
46
+ timeout: Maximum seconds to wait
47
+
48
+ Returns:
49
+ WaitResult with idle=True if stabilized, False if timeout
50
+ """
51
+ start = time.monotonic()
52
+ last_hash = None
53
+ consecutive = 0
54
+
55
+ while True:
56
+ elapsed = time.monotonic() - start
57
+ if elapsed >= timeout:
58
+ return WaitResult(idle=False, elapsed=elapsed)
59
+
60
+ content = pane.capture_pane()
61
+ current_hash = _hash_content(content)
62
+
63
+ if current_hash == last_hash:
64
+ consecutive += 1
65
+ if consecutive >= stable_count:
66
+ return WaitResult(idle=True, elapsed=elapsed)
67
+ else:
68
+ consecutive = 1
69
+ last_hash = current_hash
70
+
71
+ time.sleep(poll_interval)
72
+
73
+
74
+ def send_safe(
75
+ pane: "Pane",
76
+ text: str,
77
+ enter: bool = True,
78
+ enter_delay: float = 0.05,
79
+ max_retries: int = 3,
80
+ retry_delay: float = 0.1,
81
+ ) -> SendResult:
82
+ """Send text to pane with optional Enter verification.
83
+
84
+ Args:
85
+ pane: libtmux Pane object
86
+ text: Text to send
87
+ enter: Whether to send Enter after text
88
+ enter_delay: Delay between text and Enter (race condition mitigation)
89
+ max_retries: Maximum Enter retry attempts
90
+ retry_delay: Delay between retries
91
+
92
+ Returns:
93
+ SendResult indicating success and attempt count
94
+ """
95
+ # Send the text without Enter first
96
+ pane.send_keys(text, enter=False)
97
+
98
+ if not enter:
99
+ return SendResult(success=True, attempts=1)
100
+
101
+ # Delay before Enter to avoid race condition
102
+ time.sleep(enter_delay)
103
+
104
+ # Capture content before Enter for verification
105
+ content_before = pane.capture_pane()
106
+
107
+ for attempt in range(1, max_retries + 1):
108
+ pane.enter()
109
+ time.sleep(enter_delay)
110
+
111
+ content_after = pane.capture_pane()
112
+
113
+ # Verify Enter was received: content should change
114
+ if content_after != content_before:
115
+ return SendResult(success=True, attempts=attempt)
116
+
117
+ # Retry with increasing delay
118
+ time.sleep(retry_delay * attempt)
119
+
120
+ return SendResult(success=False, attempts=max_retries)
@@ -0,0 +1,254 @@
1
+ Metadata-Version: 2.4
2
+ Name: twmux
3
+ Version: 0.1.0
4
+ Summary: Race-condition-safe tmux wrapper for coding agents
5
+ Author-email: sysid <sysid@gmx.de>
6
+ Requires-Python: >=3.13
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: libtmux>=0.46
9
+ Requires-Dist: typer>=0.15
10
+ Requires-Dist: rich>=13.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: build>=1.0; extra == "dev"
13
+ Requires-Dist: pytest>=8.0; extra == "dev"
14
+ Requires-Dist: pytest-cov>=6.0; extra == "dev"
15
+ Requires-Dist: ruff>=0.9; extra == "dev"
16
+ Requires-Dist: twine>=6.0; extra == "dev"
17
+
18
+ <p align="center">
19
+ <img src="docs/logo_264x264.png" alt="twmux logo" width="200">
20
+ </p>
21
+
22
+ Race-condition-safe tmux wrapper for coding agents.
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ uv pip install -e .
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ ```bash
33
+ twmux [OPTIONS] COMMAND [ARGS]
34
+ ```
35
+
36
+ ### Global Options
37
+
38
+ | Option | Description |
39
+ |--------|-------------|
40
+ | `--json` | Output as JSON (for programmatic use) |
41
+ | `-L, --socket NAME` | Connect to specific tmux socket |
42
+ | `-v, --verbose` | Verbose output |
43
+
44
+ ## Commands
45
+
46
+ ### send - Send text safely
47
+
48
+ Send text to a pane with race-condition-safe Enter handling.
49
+
50
+ ```bash
51
+ twmux send -t %5 "echo hello"
52
+ twmux send -t main:0.1 "make test" --delay 0.1
53
+ twmux send -t %5 "partial text" --no-enter
54
+ ```
55
+
56
+ ### exec - Execute and capture
57
+
58
+ Execute a command and capture output with exit code.
59
+
60
+ ```bash
61
+ twmux exec -t %5 "ls -la"
62
+ twmux --json exec -t main:0 "make test" --timeout 60
63
+ ```
64
+
65
+ Returns:
66
+ - `output`: Command stdout/stderr
67
+ - `exit_code`: Command exit code (-1 if timeout)
68
+ - `timed_out`: Whether command timed out
69
+
70
+ ### capture - Capture pane content
71
+
72
+ ```bash
73
+ twmux capture -t %5
74
+ twmux capture -t %5 -n 50 # Last 50 lines
75
+ twmux --json capture -t main:0
76
+ ```
77
+
78
+ ### wait-idle - Wait for output stabilization
79
+
80
+ Wait until pane output stops changing.
81
+
82
+ ```bash
83
+ twmux wait-idle -t %5
84
+ twmux wait-idle -t %5 --timeout 10 --interval 0.1
85
+ ```
86
+
87
+ ### interrupt - Send Ctrl+C
88
+
89
+ ```bash
90
+ twmux interrupt -t %5
91
+ ```
92
+
93
+ ### escape - Send Escape key
94
+
95
+ ```bash
96
+ twmux escape -t %5
97
+ ```
98
+
99
+ ### launch - Create new pane
100
+
101
+ Split current pane to create a new one.
102
+
103
+ ```bash
104
+ twmux launch -t %5 # Split below
105
+ twmux launch -t %5 -v # Split right (vertical)
106
+ twmux launch -t %5 -c "python3" # Split and run command
107
+ ```
108
+
109
+ ### kill - Kill pane
110
+
111
+ ```bash
112
+ twmux kill -t %5
113
+ ```
114
+
115
+ ### status - Show tmux state
116
+
117
+ ```bash
118
+ twmux status
119
+ twmux --json status
120
+ ```
121
+
122
+ ## Target Addressing
123
+
124
+ The `-t` option accepts tmux target syntax to identify panes.
125
+
126
+ ### Pane ID (Recommended)
127
+
128
+ Direct pane reference using tmux pane ID:
129
+
130
+ ```bash
131
+ twmux send -t %5 "echo hello" # Pane ID %5
132
+ twmux exec -t %12 "ls" # Pane ID %12
133
+ ```
134
+
135
+ Get pane IDs with `twmux status` or `tmux list-panes -a`.
136
+
137
+ ### Session:Window.Pane Format
138
+
139
+ Hierarchical addressing:
140
+
141
+ ```bash
142
+ # Full path: session:window.pane
143
+ twmux send -t main:0.1 "echo hello" # Session "main", window 0, pane 1
144
+ twmux send -t dev:2.0 "make test" # Session "dev", window 2, pane 0
145
+
146
+ # Partial paths
147
+ twmux send -t main:0 "echo hello" # Session "main", window 0, active pane
148
+ twmux send -t main: "echo hello" # Session "main", active window/pane
149
+ twmux send -t :0.1 "echo hello" # First session, window 0, pane 1
150
+ ```
151
+
152
+ ### Target Resolution
153
+
154
+ | Target | Meaning |
155
+ |--------|---------|
156
+ | `%5` | Pane with ID %5 (absolute) |
157
+ | `main:0.1` | Session "main", window 0, pane 1 |
158
+ | `main:0` | Session "main", window 0, active pane |
159
+ | `main:` | Session "main", active window and pane |
160
+ | `:0.1` | First session, window 0, pane 1 |
161
+ | `:0` | First session, window 0, active pane |
162
+ | (empty) | First session, active window and pane |
163
+
164
+ ### Examples
165
+
166
+ ```bash
167
+ # Start a REPL in a new pane and interact with it
168
+ twmux launch -t %5 -c "python3"
169
+ # Returns: {"pane_id": "%12"}
170
+
171
+ # Send commands to the new pane
172
+ twmux send -t %12 "print('hello')"
173
+ twmux wait-idle -t %12
174
+
175
+ # Capture output
176
+ twmux capture -t %12 -n 10
177
+
178
+ # Execute and get result
179
+ twmux --json exec -t %12 "print(1+1)"
180
+ # Returns: {"output": "2", "exit_code": 0, "timed_out": false}
181
+
182
+ # Clean up
183
+ twmux kill -t %12
184
+ ```
185
+
186
+ ## JSON Output
187
+
188
+ All commands support `--json` for programmatic use:
189
+
190
+ ```bash
191
+ $ twmux --json exec -t %5 "echo hello"
192
+ {"output": "hello", "exit_code": 0, "timed_out": false}
193
+
194
+ $ twmux --json send -t %5 "test"
195
+ {"success": true, "attempts": 1}
196
+
197
+ $ twmux --json status
198
+ {
199
+ "sessions": [
200
+ {
201
+ "session_id": "$0",
202
+ "session_name": "main",
203
+ "windows": [...]
204
+ }
205
+ ]
206
+ }
207
+ ```
208
+
209
+ ## How It Works
210
+
211
+ ### Race-Condition-Safe Send
212
+
213
+ The `send` command:
214
+ 1. Sends text without Enter
215
+ 2. Waits (configurable delay)
216
+ 3. Captures pane content
217
+ 4. Sends Enter
218
+ 5. Verifies content changed
219
+ 6. Retries if needed
220
+
221
+ ### Marker-Based Execution
222
+
223
+ The `exec` command:
224
+ 1. Generates unique markers
225
+ 2. Wraps command: `echo START; { cmd; } 2>&1; echo END:$?`
226
+ 3. Polls pane with progressive expansion (100 → 500 → 2000 → all lines)
227
+ 4. Parses output between markers
228
+ 5. Extracts exit code
229
+
230
+ ### Output Stabilization
231
+
232
+ The `wait-idle` command:
233
+ 1. Hashes pane content (MD5)
234
+ 2. Polls at configurable interval
235
+ 3. Returns when N consecutive hashes match
236
+ 4. Times out if content keeps changing
237
+
238
+ ## Development
239
+
240
+ ```bash
241
+ make install # Install dependencies
242
+ make test # Run tests
243
+ make lint # Check code style
244
+ make format # Auto-format code
245
+ make check # Run lint + test
246
+ ```
247
+
248
+ ## License
249
+
250
+ MIT
251
+
252
+
253
+ ### Prior Art, Inspiration
254
+ - [GitHub - pchalasani/claude-code-tools](https://github.com/pchalasani/claude-code-tools)
@@ -0,0 +1,11 @@
1
+ twmux/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ twmux/bin/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ twmux/bin/cli.py,sha256=hXiFpNhKV6N3yQnylRrc8_xOzFJBr7Sn6y65-H2sTQo,7914
4
+ twmux/lib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ twmux/lib/execution.py,sha256=VLVOWaVnKIAUAxpPwGTpnpIrA-ZmH-jytogdx8VkX84,3801
6
+ twmux/lib/safe_input.py,sha256=y9nrhb_AIecMTZA_WpDsowktGujM5PXI0ABYkMkeLOQ,3040
7
+ twmux-0.1.0.dist-info/METADATA,sha256=6xeAQYwzETiYGw3o2kX7XYCAAh9sSiTiWID2T3vaanY,5428
8
+ twmux-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
9
+ twmux-0.1.0.dist-info/entry_points.txt,sha256=ReZKwESJxhZyrSQNXPV4U1jvOb0g14E_81KVPXzlKno,44
10
+ twmux-0.1.0.dist-info/top_level.txt,sha256=qpZCQer5e2-eXJ_ztdXMMLdyKZBXiItxGcRXu-dlaPs,6
11
+ twmux-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ twmux = twmux.bin.cli:app
@@ -0,0 +1 @@
1
+ twmux