twmux 0.1.0__tar.gz
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-0.1.0/PKG-INFO +254 -0
- twmux-0.1.0/README.md +237 -0
- twmux-0.1.0/pyproject.toml +41 -0
- twmux-0.1.0/setup.cfg +4 -0
- twmux-0.1.0/src/twmux/__init__.py +1 -0
- twmux-0.1.0/src/twmux/bin/__init__.py +0 -0
- twmux-0.1.0/src/twmux/bin/cli.py +263 -0
- twmux-0.1.0/src/twmux/lib/__init__.py +0 -0
- twmux-0.1.0/src/twmux/lib/execution.py +121 -0
- twmux-0.1.0/src/twmux/lib/safe_input.py +120 -0
- twmux-0.1.0/src/twmux.egg-info/PKG-INFO +254 -0
- twmux-0.1.0/src/twmux.egg-info/SOURCES.txt +14 -0
- twmux-0.1.0/src/twmux.egg-info/dependency_links.txt +1 -0
- twmux-0.1.0/src/twmux.egg-info/entry_points.txt +2 -0
- twmux-0.1.0/src/twmux.egg-info/requires.txt +10 -0
- twmux-0.1.0/src/twmux.egg-info/top_level.txt +1 -0
twmux-0.1.0/PKG-INFO
ADDED
|
@@ -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)
|
twmux-0.1.0/README.md
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="docs/logo_264x264.png" alt="twmux logo" width="200">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
Race-condition-safe tmux wrapper for coding agents.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uv pip install -e .
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
twmux [OPTIONS] COMMAND [ARGS]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Global Options
|
|
20
|
+
|
|
21
|
+
| Option | Description |
|
|
22
|
+
|--------|-------------|
|
|
23
|
+
| `--json` | Output as JSON (for programmatic use) |
|
|
24
|
+
| `-L, --socket NAME` | Connect to specific tmux socket |
|
|
25
|
+
| `-v, --verbose` | Verbose output |
|
|
26
|
+
|
|
27
|
+
## Commands
|
|
28
|
+
|
|
29
|
+
### send - Send text safely
|
|
30
|
+
|
|
31
|
+
Send text to a pane with race-condition-safe Enter handling.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
twmux send -t %5 "echo hello"
|
|
35
|
+
twmux send -t main:0.1 "make test" --delay 0.1
|
|
36
|
+
twmux send -t %5 "partial text" --no-enter
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### exec - Execute and capture
|
|
40
|
+
|
|
41
|
+
Execute a command and capture output with exit code.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
twmux exec -t %5 "ls -la"
|
|
45
|
+
twmux --json exec -t main:0 "make test" --timeout 60
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
- `output`: Command stdout/stderr
|
|
50
|
+
- `exit_code`: Command exit code (-1 if timeout)
|
|
51
|
+
- `timed_out`: Whether command timed out
|
|
52
|
+
|
|
53
|
+
### capture - Capture pane content
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
twmux capture -t %5
|
|
57
|
+
twmux capture -t %5 -n 50 # Last 50 lines
|
|
58
|
+
twmux --json capture -t main:0
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### wait-idle - Wait for output stabilization
|
|
62
|
+
|
|
63
|
+
Wait until pane output stops changing.
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
twmux wait-idle -t %5
|
|
67
|
+
twmux wait-idle -t %5 --timeout 10 --interval 0.1
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### interrupt - Send Ctrl+C
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
twmux interrupt -t %5
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### escape - Send Escape key
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
twmux escape -t %5
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### launch - Create new pane
|
|
83
|
+
|
|
84
|
+
Split current pane to create a new one.
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
twmux launch -t %5 # Split below
|
|
88
|
+
twmux launch -t %5 -v # Split right (vertical)
|
|
89
|
+
twmux launch -t %5 -c "python3" # Split and run command
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### kill - Kill pane
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
twmux kill -t %5
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### status - Show tmux state
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
twmux status
|
|
102
|
+
twmux --json status
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Target Addressing
|
|
106
|
+
|
|
107
|
+
The `-t` option accepts tmux target syntax to identify panes.
|
|
108
|
+
|
|
109
|
+
### Pane ID (Recommended)
|
|
110
|
+
|
|
111
|
+
Direct pane reference using tmux pane ID:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
twmux send -t %5 "echo hello" # Pane ID %5
|
|
115
|
+
twmux exec -t %12 "ls" # Pane ID %12
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Get pane IDs with `twmux status` or `tmux list-panes -a`.
|
|
119
|
+
|
|
120
|
+
### Session:Window.Pane Format
|
|
121
|
+
|
|
122
|
+
Hierarchical addressing:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
# Full path: session:window.pane
|
|
126
|
+
twmux send -t main:0.1 "echo hello" # Session "main", window 0, pane 1
|
|
127
|
+
twmux send -t dev:2.0 "make test" # Session "dev", window 2, pane 0
|
|
128
|
+
|
|
129
|
+
# Partial paths
|
|
130
|
+
twmux send -t main:0 "echo hello" # Session "main", window 0, active pane
|
|
131
|
+
twmux send -t main: "echo hello" # Session "main", active window/pane
|
|
132
|
+
twmux send -t :0.1 "echo hello" # First session, window 0, pane 1
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Target Resolution
|
|
136
|
+
|
|
137
|
+
| Target | Meaning |
|
|
138
|
+
|--------|---------|
|
|
139
|
+
| `%5` | Pane with ID %5 (absolute) |
|
|
140
|
+
| `main:0.1` | Session "main", window 0, pane 1 |
|
|
141
|
+
| `main:0` | Session "main", window 0, active pane |
|
|
142
|
+
| `main:` | Session "main", active window and pane |
|
|
143
|
+
| `:0.1` | First session, window 0, pane 1 |
|
|
144
|
+
| `:0` | First session, window 0, active pane |
|
|
145
|
+
| (empty) | First session, active window and pane |
|
|
146
|
+
|
|
147
|
+
### Examples
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
# Start a REPL in a new pane and interact with it
|
|
151
|
+
twmux launch -t %5 -c "python3"
|
|
152
|
+
# Returns: {"pane_id": "%12"}
|
|
153
|
+
|
|
154
|
+
# Send commands to the new pane
|
|
155
|
+
twmux send -t %12 "print('hello')"
|
|
156
|
+
twmux wait-idle -t %12
|
|
157
|
+
|
|
158
|
+
# Capture output
|
|
159
|
+
twmux capture -t %12 -n 10
|
|
160
|
+
|
|
161
|
+
# Execute and get result
|
|
162
|
+
twmux --json exec -t %12 "print(1+1)"
|
|
163
|
+
# Returns: {"output": "2", "exit_code": 0, "timed_out": false}
|
|
164
|
+
|
|
165
|
+
# Clean up
|
|
166
|
+
twmux kill -t %12
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## JSON Output
|
|
170
|
+
|
|
171
|
+
All commands support `--json` for programmatic use:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
$ twmux --json exec -t %5 "echo hello"
|
|
175
|
+
{"output": "hello", "exit_code": 0, "timed_out": false}
|
|
176
|
+
|
|
177
|
+
$ twmux --json send -t %5 "test"
|
|
178
|
+
{"success": true, "attempts": 1}
|
|
179
|
+
|
|
180
|
+
$ twmux --json status
|
|
181
|
+
{
|
|
182
|
+
"sessions": [
|
|
183
|
+
{
|
|
184
|
+
"session_id": "$0",
|
|
185
|
+
"session_name": "main",
|
|
186
|
+
"windows": [...]
|
|
187
|
+
}
|
|
188
|
+
]
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## How It Works
|
|
193
|
+
|
|
194
|
+
### Race-Condition-Safe Send
|
|
195
|
+
|
|
196
|
+
The `send` command:
|
|
197
|
+
1. Sends text without Enter
|
|
198
|
+
2. Waits (configurable delay)
|
|
199
|
+
3. Captures pane content
|
|
200
|
+
4. Sends Enter
|
|
201
|
+
5. Verifies content changed
|
|
202
|
+
6. Retries if needed
|
|
203
|
+
|
|
204
|
+
### Marker-Based Execution
|
|
205
|
+
|
|
206
|
+
The `exec` command:
|
|
207
|
+
1. Generates unique markers
|
|
208
|
+
2. Wraps command: `echo START; { cmd; } 2>&1; echo END:$?`
|
|
209
|
+
3. Polls pane with progressive expansion (100 → 500 → 2000 → all lines)
|
|
210
|
+
4. Parses output between markers
|
|
211
|
+
5. Extracts exit code
|
|
212
|
+
|
|
213
|
+
### Output Stabilization
|
|
214
|
+
|
|
215
|
+
The `wait-idle` command:
|
|
216
|
+
1. Hashes pane content (MD5)
|
|
217
|
+
2. Polls at configurable interval
|
|
218
|
+
3. Returns when N consecutive hashes match
|
|
219
|
+
4. Times out if content keeps changing
|
|
220
|
+
|
|
221
|
+
## Development
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
make install # Install dependencies
|
|
225
|
+
make test # Run tests
|
|
226
|
+
make lint # Check code style
|
|
227
|
+
make format # Auto-format code
|
|
228
|
+
make check # Run lint + test
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## License
|
|
232
|
+
|
|
233
|
+
MIT
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
### Prior Art, Inspiration
|
|
237
|
+
- [GitHub - pchalasani/claude-code-tools](https://github.com/pchalasani/claude-code-tools)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "twmux"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Race-condition-safe tmux wrapper for coding agents"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "sysid", email = "sysid@gmx.de" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"libtmux>=0.46",
|
|
12
|
+
"typer>=0.15",
|
|
13
|
+
"rich>=13.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
twmux = "twmux.bin.cli:app"
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = [
|
|
21
|
+
"build>=1.0",
|
|
22
|
+
"pytest>=8.0",
|
|
23
|
+
"pytest-cov>=6.0",
|
|
24
|
+
"ruff>=0.9",
|
|
25
|
+
"twine>=6.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["setuptools>=61"]
|
|
30
|
+
build-backend = "setuptools.build_meta"
|
|
31
|
+
|
|
32
|
+
[tool.ruff]
|
|
33
|
+
line-length = 100
|
|
34
|
+
target-version = "py313"
|
|
35
|
+
|
|
36
|
+
[tool.ruff.lint]
|
|
37
|
+
select = ["E", "F", "I", "UP"]
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
41
|
+
addopts = "-v --tb=short"
|
twmux-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
File without changes
|
|
@@ -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()
|
|
File without changes
|
|
@@ -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,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/twmux/__init__.py
|
|
4
|
+
src/twmux.egg-info/PKG-INFO
|
|
5
|
+
src/twmux.egg-info/SOURCES.txt
|
|
6
|
+
src/twmux.egg-info/dependency_links.txt
|
|
7
|
+
src/twmux.egg-info/entry_points.txt
|
|
8
|
+
src/twmux.egg-info/requires.txt
|
|
9
|
+
src/twmux.egg-info/top_level.txt
|
|
10
|
+
src/twmux/bin/__init__.py
|
|
11
|
+
src/twmux/bin/cli.py
|
|
12
|
+
src/twmux/lib/__init__.py
|
|
13
|
+
src/twmux/lib/execution.py
|
|
14
|
+
src/twmux/lib/safe_input.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
twmux
|