tsugite-tmux 0.14.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.
@@ -0,0 +1,183 @@
1
+ # ---> Python
2
+ # Byte-compiled / optimized / DLL files
3
+ __pycache__/
4
+ *.py[cod]
5
+ *$py.class
6
+
7
+ # C extensions
8
+ *.so
9
+
10
+ # Distribution / packaging
11
+ .Python
12
+ build/
13
+ develop-eggs/
14
+ dist/
15
+ downloads/
16
+ eggs/
17
+ .eggs/
18
+ lib/
19
+ lib64/
20
+ parts/
21
+ sdist/
22
+ var/
23
+ wheels/
24
+ share/python-wheels/
25
+ *.egg-info/
26
+ .installed.cfg
27
+ *.egg
28
+ MANIFEST
29
+
30
+ # PyInstaller
31
+ # Usually these files are written by a python script from a template
32
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
33
+ *.manifest
34
+ *.spec
35
+
36
+ # Installer logs
37
+ pip-log.txt
38
+ pip-delete-this-directory.txt
39
+
40
+ # Unit test / coverage reports
41
+ htmlcov/
42
+ .tox/
43
+ .nox/
44
+ .coverage
45
+ .coverage.*
46
+ .cache
47
+ nosetests.xml
48
+ coverage.xml
49
+ *.cover
50
+ *.py,cover
51
+ .hypothesis/
52
+ .pytest_cache/
53
+ cover/
54
+
55
+ # Translations
56
+ *.mo
57
+ *.pot
58
+
59
+ # Django stuff:
60
+ *.log
61
+ local_settings.py
62
+ db.sqlite3
63
+ db.sqlite3-journal
64
+
65
+ # Flask stuff:
66
+ instance/
67
+ .webassets-cache
68
+
69
+ # Scrapy stuff:
70
+ .scrapy
71
+
72
+ # Sphinx documentation
73
+ docs/_build/
74
+
75
+ # PyBuilder
76
+ .pybuilder/
77
+ target/
78
+
79
+ # Jupyter Notebook
80
+ .ipynb_checkpoints
81
+
82
+ # IPython
83
+ profile_default/
84
+ ipython_config.py
85
+
86
+ # pyenv
87
+ # For a library or package, you might want to ignore these files since the code is
88
+ # intended to run in multiple environments; otherwise, check them in:
89
+ # .python-version
90
+
91
+ # pipenv
92
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
93
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
94
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
95
+ # install all needed dependencies.
96
+ #Pipfile.lock
97
+
98
+ # poetry
99
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
100
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
101
+ # commonly ignored for libraries.
102
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
103
+ #poetry.lock
104
+
105
+ # pdm
106
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
107
+ #pdm.lock
108
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
109
+ # in version control.
110
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
111
+ .pdm.toml
112
+ .pdm-python
113
+ .pdm-build/
114
+
115
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
116
+ __pypackages__/
117
+
118
+ # Celery stuff
119
+ celerybeat-schedule
120
+ celerybeat.pid
121
+
122
+ # SageMath parsed files
123
+ *.sage.py
124
+
125
+ # Environments
126
+ .env
127
+ .venv
128
+ env/
129
+ venv/
130
+ ENV/
131
+ env.bak/
132
+ venv.bak/
133
+
134
+ # Spyder project settings
135
+ .spyderproject
136
+ .spyproject
137
+
138
+ # Rope project settings
139
+ .ropeproject
140
+
141
+ # mkdocs documentation
142
+ /site
143
+
144
+ # mypy
145
+ .mypy_cache/
146
+ .dmypy.json
147
+ dmypy.json
148
+
149
+ # Pyre type checker
150
+ .pyre/
151
+
152
+ # pytype static type analyzer
153
+ .pytype/
154
+
155
+ # Cython debug symbols
156
+ cython_debug/
157
+
158
+ # PyCharm
159
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
160
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
161
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
162
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
163
+ #.idea/
164
+
165
+ .env
166
+ .env
167
+ benchmark_results/
168
+ test_output/
169
+ .claude/settings.local.json
170
+ std*.txt
171
+ secrets/*
172
+
173
+
174
+ # TODO: temp - I need to clean up the docs
175
+ docs-old/
176
+ examples/*
177
+ !examples/tsugite-example-plugin/
178
+ agents/
179
+ .claude/
180
+ .tsugite/
181
+ benchmarks/
182
+ docker-compose.test.yml
183
+ #### TODO ^^^
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: tsugite-tmux
3
+ Version: 0.14.0
4
+ Summary: Tsugite plugin: tmux session management tools with automatic output logging
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: tsugite-cli==0.14.0
@@ -0,0 +1,16 @@
1
+ [project]
2
+ name = "tsugite-tmux"
3
+ version = "0.14.0"
4
+ description = "Tsugite plugin: tmux session management tools with automatic output logging"
5
+ requires-python = ">=3.11"
6
+ dependencies = ["tsugite-cli==0.14.0"]
7
+
8
+ [project.entry-points."tsugite.plugins"]
9
+ tmux = "tsugite_tmux"
10
+
11
+ [build-system]
12
+ requires = ["hatchling"]
13
+ build-backend = "hatchling.build"
14
+
15
+ [tool.uv.sources]
16
+ tsugite-cli = { workspace = true }
@@ -0,0 +1,376 @@
1
+ """Tests for tmux session management tools."""
2
+
3
+ import json
4
+ import subprocess
5
+ from unittest.mock import MagicMock, call, patch
6
+
7
+ import pytest
8
+
9
+ from tsugite_tmux import (
10
+ _list_managed_sessions,
11
+ _strip_ansi,
12
+ _validate_name,
13
+ get_tmux_sessions,
14
+ tmux_create,
15
+ tmux_kill,
16
+ tmux_list,
17
+ tmux_read,
18
+ tmux_send,
19
+ )
20
+
21
+
22
+ @pytest.fixture
23
+ def mock_metadata(tmp_path, monkeypatch):
24
+ """Redirect metadata and log paths to tmp_path."""
25
+ meta_dir = tmp_path / "tmux"
26
+ log_dir = tmp_path / "tmux-logs"
27
+ meta_dir.mkdir()
28
+ log_dir.mkdir()
29
+
30
+ monkeypatch.setattr("tsugite_tmux._get_metadata_path", lambda: meta_dir / "sessions.json")
31
+ monkeypatch.setattr("tsugite_tmux._get_log_dir", lambda: log_dir)
32
+ return tmp_path
33
+
34
+
35
+ def _make_run_result(returncode=0, stdout="", stderr=""):
36
+ result = MagicMock(spec=subprocess.CompletedProcess)
37
+ result.returncode = returncode
38
+ result.stdout = stdout
39
+ result.stderr = stderr
40
+ return result
41
+
42
+
43
+ class TestStripAnsi:
44
+ def test_strips_sgr_sequences(self):
45
+ assert _strip_ansi("\x1b[31mred\x1b[0m") == "red"
46
+
47
+ def test_strips_bold_and_color(self):
48
+ assert _strip_ansi("\x1b[1;32mgreen bold\x1b[0m") == "green bold"
49
+
50
+ def test_strips_osc_sequences(self):
51
+ assert _strip_ansi("\x1b]0;title\x07text") == "text"
52
+
53
+ def test_strips_charset_designator(self):
54
+ assert _strip_ansi("\x1b(Btext") == "text"
55
+
56
+ def test_passthrough_clean_text(self):
57
+ assert _strip_ansi("hello world") == "hello world"
58
+
59
+ def test_mixed_ansi(self):
60
+ text = "\x1b[1m\x1b[32mOK\x1b[0m: \x1b[34mtest\x1b[0m passed"
61
+ assert _strip_ansi(text) == "OK: test passed"
62
+
63
+
64
+ class TestValidateName:
65
+ def test_valid_names(self):
66
+ for name in ["test", "my-session", "project_1", "A-b_C-3"]:
67
+ _validate_name(name)
68
+
69
+ def test_invalid_names(self):
70
+ for name in ["has space", "semi;colon", "pipe|char", "slash/path", ""]:
71
+ with pytest.raises(ValueError, match="Invalid session name"):
72
+ _validate_name(name)
73
+
74
+
75
+ class TestTmuxCreate:
76
+ @patch("tsugite_tmux._session_exists", return_value=False)
77
+ @patch("tsugite_tmux.subprocess.run")
78
+ def test_create_session(self, mock_run, mock_exists, mock_metadata):
79
+ mock_run.return_value = _make_run_result()
80
+
81
+ result = tmux_create("test")
82
+
83
+ assert result["name"] == "test"
84
+ assert result["tmux_session"] == "tsu-test"
85
+ assert result["status"] == "created"
86
+ assert "log_file" in result
87
+
88
+ calls = mock_run.call_args_list
89
+ assert calls[0] == call(
90
+ ["tmux", "new-session", "-d", "-s", "tsu-test", "-x", "200", "-y", "50"],
91
+ capture_output=True,
92
+ text=True,
93
+ )
94
+ assert calls[1].args[0][:4] == ["tmux", "pipe-pane", "-t", "tsu-test"]
95
+
96
+ @patch("tsugite_tmux._session_exists", return_value=False)
97
+ @patch("tsugite_tmux.subprocess.run")
98
+ def test_create_with_command(self, mock_run, mock_exists, mock_metadata):
99
+ mock_run.return_value = _make_run_result()
100
+
101
+ tmux_create("test", command="htop")
102
+
103
+ new_session_call = mock_run.call_args_list[0]
104
+ assert "htop" in new_session_call.args[0]
105
+
106
+ @patch("tsugite_tmux._session_exists", return_value=True)
107
+ def test_create_already_exists(self, mock_exists):
108
+ with pytest.raises(RuntimeError, match="already exists"):
109
+ tmux_create("test")
110
+
111
+ def test_create_invalid_name(self):
112
+ with pytest.raises(ValueError, match="Invalid session name"):
113
+ tmux_create("bad name")
114
+
115
+ @patch("tsugite_tmux._session_exists", return_value=False)
116
+ @patch("tsugite_tmux.subprocess.run")
117
+ def test_create_saves_metadata(self, mock_run, mock_exists, mock_metadata):
118
+ mock_run.return_value = _make_run_result()
119
+
120
+ tmux_create("myproject", command="python3")
121
+
122
+ meta_path = mock_metadata / "tmux" / "sessions.json"
123
+ assert meta_path.exists()
124
+ data = json.loads(meta_path.read_text())
125
+ assert "myproject" in data
126
+ assert data["myproject"]["command"] == "python3"
127
+ assert data["myproject"]["prefixed_name"] == "tsu-myproject"
128
+
129
+ @patch("tsugite_tmux._session_exists", return_value=False)
130
+ @patch("tsugite_tmux.subprocess.run")
131
+ def test_create_cleans_up_on_pipe_failure(self, mock_run, mock_exists, mock_metadata):
132
+ mock_run.side_effect = [
133
+ _make_run_result(), # new-session succeeds
134
+ _make_run_result(returncode=1, stderr="pipe error"), # pipe-pane fails
135
+ _make_run_result(), # kill-session cleanup
136
+ ]
137
+
138
+ with pytest.raises(RuntimeError, match="Failed to set up logging"):
139
+ tmux_create("test")
140
+
141
+ @patch("tsugite_tmux._session_exists", return_value=False)
142
+ @patch("tsugite_tmux.subprocess.run")
143
+ def test_create_pipe_pane_uses_shlex_quote(self, mock_run, mock_exists, mock_metadata):
144
+ mock_run.return_value = _make_run_result()
145
+
146
+ tmux_create("test")
147
+
148
+ pipe_call = mock_run.call_args_list[1]
149
+ pipe_arg = pipe_call.args[0][5] # The -o argument value
150
+ assert pipe_arg.startswith("cat >> ")
151
+
152
+
153
+ class TestTmuxRead:
154
+ @patch("tsugite_tmux._session_exists", return_value=True)
155
+ @patch("tsugite_tmux.subprocess.run")
156
+ def test_read_pane(self, mock_run, mock_exists):
157
+ mock_run.return_value = _make_run_result(stdout="\x1b[32mhello\x1b[0m world\n")
158
+
159
+ result = tmux_read("test", lines=10)
160
+
161
+ assert result == "hello world\n"
162
+ mock_run.assert_called_once_with(
163
+ ["tmux", "capture-pane", "-t", "tsu-test", "-p", "-S", "-10"],
164
+ capture_output=True,
165
+ text=True,
166
+ )
167
+
168
+ def test_read_log(self, mock_metadata):
169
+ log_dir = mock_metadata / "tmux-logs"
170
+ log_file = log_dir / "test.log"
171
+ log_file.write_text("line1\nline2\nline3\n\x1b[31mline4\x1b[0m\n")
172
+
173
+ result = tmux_read("test", lines=2, source="log")
174
+
175
+ assert result == "line3\nline4\n"
176
+
177
+ @patch("tsugite_tmux._session_exists", return_value=False)
178
+ def test_read_nonexistent_pane(self, mock_exists):
179
+ with pytest.raises(RuntimeError, match="not found"):
180
+ tmux_read("nonexistent")
181
+
182
+ def test_read_nonexistent_log(self, mock_metadata):
183
+ with pytest.raises(RuntimeError, match="No log file"):
184
+ tmux_read("nonexistent", source="log")
185
+
186
+ def test_read_invalid_source(self):
187
+ with pytest.raises(ValueError, match="Invalid source"):
188
+ tmux_read("test", source="invalid")
189
+
190
+ @patch("tsugite_tmux._session_exists", return_value=True)
191
+ @patch("tsugite_tmux.subprocess.run")
192
+ def test_read_clamps_lines(self, mock_run, mock_exists):
193
+ mock_run.return_value = _make_run_result(stdout="text\n")
194
+
195
+ tmux_read("test", lines=99999)
196
+
197
+ args = mock_run.call_args.args[0]
198
+ assert "-5000" in args
199
+
200
+
201
+ class TestTmuxSend:
202
+ @patch("tsugite_tmux._session_exists", return_value=True)
203
+ @patch("tsugite_tmux.subprocess.run")
204
+ def test_send_with_enter(self, mock_run, mock_exists):
205
+ mock_run.return_value = _make_run_result()
206
+
207
+ result = tmux_send("test", "ls -la")
208
+
209
+ mock_run.assert_called_once_with(
210
+ ["tmux", "send-keys", "-t", "tsu-test", "ls -la", "Enter"],
211
+ capture_output=True,
212
+ text=True,
213
+ )
214
+ assert "command" in result
215
+
216
+ @patch("tsugite_tmux._session_exists", return_value=True)
217
+ @patch("tsugite_tmux.subprocess.run")
218
+ def test_send_without_enter(self, mock_run, mock_exists):
219
+ mock_run.return_value = _make_run_result()
220
+
221
+ result = tmux_send("test", "q", enter=False)
222
+
223
+ mock_run.assert_called_once_with(
224
+ ["tmux", "send-keys", "-t", "tsu-test", "q"],
225
+ capture_output=True,
226
+ text=True,
227
+ )
228
+ assert "keys" in result
229
+
230
+ @patch("tsugite_tmux._session_exists", return_value=False)
231
+ def test_send_nonexistent(self, mock_exists):
232
+ with pytest.raises(RuntimeError, match="not found"):
233
+ tmux_send("nonexistent", "hello")
234
+
235
+
236
+ class TestListManagedSessions:
237
+ """Tests for the shared _list_managed_sessions helper used by tmux_list and get_tmux_sessions."""
238
+
239
+ @patch("tsugite_tmux.subprocess.run")
240
+ def test_filters_by_prefix(self, mock_run, mock_metadata):
241
+ mock_run.return_value = _make_run_result(
242
+ stdout="tsu-project1\tbash\nuser-session\tvim\ntsu-project2\tpython3\n"
243
+ )
244
+
245
+ meta_path = mock_metadata / "tmux" / "sessions.json"
246
+ meta_path.write_text(
247
+ json.dumps(
248
+ {
249
+ "project1": {"command": "htop", "created_at": "2026-01-01T00:00:00", "log_file": "/tmp/p1.log"},
250
+ "project2": {"command": None, "created_at": "2026-01-02T00:00:00", "log_file": "/tmp/p2.log"},
251
+ }
252
+ )
253
+ )
254
+
255
+ result = _list_managed_sessions()
256
+
257
+ assert len(result) == 2
258
+ names = [s["name"] for s in result]
259
+ assert "project1" in names
260
+ assert "project2" in names
261
+
262
+ @patch("tsugite_tmux.subprocess.run")
263
+ def test_idle_status_for_shell(self, mock_run, mock_metadata):
264
+ mock_run.return_value = _make_run_result(stdout="tsu-test\tbash\n")
265
+
266
+ result = _list_managed_sessions()
267
+
268
+ assert result[0]["status"] == "idle"
269
+
270
+ @patch("tsugite_tmux.subprocess.run")
271
+ def test_active_status_for_process(self, mock_run, mock_metadata):
272
+ mock_run.return_value = _make_run_result(stdout="tsu-test\tpython3\n")
273
+
274
+ result = _list_managed_sessions()
275
+
276
+ assert result[0]["status"] == "active: python3"
277
+
278
+ @patch("tsugite_tmux.subprocess.run")
279
+ def test_no_server(self, mock_run):
280
+ mock_run.return_value = _make_run_result(returncode=1, stderr="no server running")
281
+
282
+ assert _list_managed_sessions() == []
283
+
284
+ @patch("tsugite_tmux.subprocess.run")
285
+ def test_no_managed_sessions(self, mock_run, mock_metadata):
286
+ mock_run.return_value = _make_run_result(stdout="user-session\tbash\n")
287
+
288
+ assert _list_managed_sessions() == []
289
+
290
+
291
+ class TestTmuxList:
292
+ @patch("tsugite_tmux.subprocess.run")
293
+ def test_list_delegates_to_shared_helper(self, mock_run, mock_metadata):
294
+ mock_run.return_value = _make_run_result(stdout="tsu-project1\tbash\ntsu-project2\thtop\n")
295
+
296
+ result = tmux_list()
297
+
298
+ assert len(result) == 2
299
+ assert result[0]["status"] == "idle"
300
+ assert result[1]["status"] == "active: htop"
301
+
302
+ @patch("tsugite_tmux.subprocess.run")
303
+ def test_list_no_server(self, mock_run):
304
+ mock_run.return_value = _make_run_result(returncode=1)
305
+
306
+ assert tmux_list() == []
307
+
308
+
309
+ class TestTmuxKill:
310
+ @patch("tsugite_tmux._session_exists", return_value=True)
311
+ @patch("tsugite_tmux.subprocess.run")
312
+ def test_kill_session(self, mock_run, mock_exists, mock_metadata):
313
+ mock_run.return_value = _make_run_result()
314
+
315
+ meta_path = mock_metadata / "tmux" / "sessions.json"
316
+ meta_path.write_text(json.dumps({"test": {"command": "htop"}}))
317
+
318
+ result = tmux_kill("test")
319
+
320
+ mock_run.assert_called_once_with(
321
+ ["tmux", "kill-session", "-t", "tsu-test"],
322
+ capture_output=True,
323
+ text=True,
324
+ )
325
+ assert "terminated" in result
326
+
327
+ data = json.loads(meta_path.read_text())
328
+ assert "test" not in data
329
+
330
+ @patch("tsugite_tmux._session_exists", return_value=False)
331
+ def test_kill_nonexistent(self, mock_exists):
332
+ with pytest.raises(RuntimeError, match="not found"):
333
+ tmux_kill("nonexistent")
334
+
335
+
336
+ class TestGetTmuxSessions:
337
+ @patch("tsugite_tmux.shutil.which", return_value=None)
338
+ def test_no_tmux_installed(self, mock_which):
339
+ assert get_tmux_sessions() == []
340
+
341
+ @patch("tsugite_tmux.subprocess.run")
342
+ @patch("tsugite_tmux.shutil.which", return_value="/usr/bin/tmux")
343
+ def test_filters_prefix(self, mock_which, mock_run, mock_metadata):
344
+ mock_run.return_value = _make_run_result(stdout="tsu-myproject\tbash\nother-session\tvim\n")
345
+
346
+ result = get_tmux_sessions()
347
+
348
+ assert len(result) == 1
349
+ assert result[0]["name"] == "myproject"
350
+ assert "created_at" not in result[0]
351
+ assert "log_file" not in result[0]
352
+
353
+ @patch("tsugite_tmux.subprocess.run")
354
+ @patch("tsugite_tmux.shutil.which", return_value="/usr/bin/tmux")
355
+ def test_idle_status(self, mock_which, mock_run, mock_metadata):
356
+ mock_run.return_value = _make_run_result(stdout="tsu-test\tzsh\n")
357
+
358
+ result = get_tmux_sessions()
359
+
360
+ assert result[0]["status"] == "idle"
361
+
362
+ @patch("tsugite_tmux.subprocess.run")
363
+ @patch("tsugite_tmux.shutil.which", return_value="/usr/bin/tmux")
364
+ def test_active_status(self, mock_which, mock_run, mock_metadata):
365
+ mock_run.return_value = _make_run_result(stdout="tsu-test\thtop\n")
366
+
367
+ result = get_tmux_sessions()
368
+
369
+ assert result[0]["status"] == "active: htop"
370
+
371
+ @patch("tsugite_tmux.subprocess.run")
372
+ @patch("tsugite_tmux.shutil.which", return_value="/usr/bin/tmux")
373
+ def test_no_server_running(self, mock_which, mock_run):
374
+ mock_run.return_value = _make_run_result(returncode=1)
375
+
376
+ assert get_tmux_sessions() == []
@@ -0,0 +1,292 @@
1
+ """Tsugite plugin: tmux session management tools with automatic output logging."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import shlex
7
+ import shutil
8
+ import subprocess
9
+ from collections import deque
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from tsugite.cli.helpers import get_workspace_dir
15
+ from tsugite.config import get_xdg_data_path
16
+ from tsugite.tools import tool
17
+
18
+ SESSION_PREFIX = "tsu-"
19
+ ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?\x07|\x1b\(B")
20
+ SHELLS = {"bash", "zsh", "sh", "fish", "dash", "ksh", "csh", "tcsh"}
21
+ VALID_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
22
+
23
+
24
+ def _prefixed(name: str) -> str:
25
+ return f"{SESSION_PREFIX}{name}"
26
+
27
+
28
+ def _strip_ansi(text: str) -> str:
29
+ return ANSI_RE.sub("", text)
30
+
31
+
32
+ def _validate_name(name: str) -> None:
33
+ if not VALID_NAME_RE.match(name):
34
+ raise ValueError(f"Invalid session name '{name}': only alphanumeric, hyphens, and underscores allowed")
35
+
36
+
37
+ def _session_exists(prefixed_name: str) -> bool:
38
+ result = subprocess.run(
39
+ ["tmux", "has-session", "-t", prefixed_name],
40
+ capture_output=True,
41
+ )
42
+ return result.returncode == 0
43
+
44
+
45
+ def _get_metadata_path() -> Path:
46
+ return get_xdg_data_path("tmux") / "sessions.json"
47
+
48
+
49
+ def _load_metadata() -> dict:
50
+ path = _get_metadata_path()
51
+ if not path.exists():
52
+ return {}
53
+ try:
54
+ return json.loads(path.read_text())
55
+ except (json.JSONDecodeError, OSError):
56
+ return {}
57
+
58
+
59
+ def _save_metadata(data: dict) -> None:
60
+ path = _get_metadata_path()
61
+ path.parent.mkdir(parents=True, exist_ok=True)
62
+ tmp = path.with_suffix(".tmp")
63
+ tmp.write_text(json.dumps(data, indent=2))
64
+ os.replace(str(tmp), str(path))
65
+
66
+
67
+ def _get_log_dir() -> Path:
68
+ return get_xdg_data_path("tmux-logs")
69
+
70
+
71
+ def _list_managed_sessions() -> list:
72
+ """Fetch all managed sessions with status in a single batched subprocess call."""
73
+ result = subprocess.run(
74
+ ["tmux", "list-panes", "-a", "-F", "#{session_name}\t#{pane_current_command}"],
75
+ capture_output=True,
76
+ text=True,
77
+ )
78
+ if result.returncode != 0:
79
+ return []
80
+
81
+ pane_cmds = {}
82
+ for line in result.stdout.strip().split("\n"):
83
+ if not line.strip():
84
+ continue
85
+ parts = line.split("\t", 1)
86
+ session_name = parts[0]
87
+ if session_name.startswith(SESSION_PREFIX) and session_name not in pane_cmds:
88
+ pane_cmds[session_name] = parts[1] if len(parts) > 1 else ""
89
+
90
+ if not pane_cmds:
91
+ return []
92
+
93
+ metadata = _load_metadata()
94
+ sessions = []
95
+ for prefixed, cmd in pane_cmds.items():
96
+ name = prefixed[len(SESSION_PREFIX) :]
97
+ status = "idle" if (not cmd or cmd.lower() in SHELLS) else f"active: {cmd}"
98
+ meta = metadata.get(name, {})
99
+ sessions.append(
100
+ {
101
+ "name": name,
102
+ "status": status,
103
+ "created_at": meta.get("created_at", "unknown"),
104
+ "command": meta.get("command"),
105
+ "log_file": meta.get("log_file"),
106
+ }
107
+ )
108
+
109
+ return sessions
110
+
111
+
112
+ @tool(category="tmux")
113
+ def tmux_create(name: str, command: Optional[str] = None) -> dict:
114
+ """Create a named tmux session with automatic output logging.
115
+
116
+ Args:
117
+ name: Session name (alphanumeric, hyphens, underscores)
118
+ command: Initial command to run in the session (e.g., "htop", "python3")
119
+
120
+ Returns:
121
+ Dict with session name, tmux session name, log file path, and status
122
+ """
123
+ _validate_name(name)
124
+ prefixed = _prefixed(name)
125
+
126
+ if _session_exists(prefixed):
127
+ raise RuntimeError(
128
+ f"Session '{name}' already exists. Kill it first with tmux_kill('{name}') or use a different name."
129
+ )
130
+
131
+ log_dir = _get_log_dir()
132
+ log_dir.mkdir(parents=True, exist_ok=True)
133
+ log_file = log_dir / f"{name}.log"
134
+
135
+ workspace = get_workspace_dir()
136
+ cmd = ["tmux", "new-session", "-d", "-s", prefixed, "-x", "200", "-y", "50"]
137
+ if workspace is not None:
138
+ cmd.extend(["-c", str(workspace)])
139
+ if command:
140
+ cmd.append(command)
141
+
142
+ result = subprocess.run(cmd, capture_output=True, text=True)
143
+ if result.returncode != 0:
144
+ raise RuntimeError(f"Failed to create tmux session: {result.stderr.strip()}")
145
+
146
+ pipe_result = subprocess.run(
147
+ ["tmux", "pipe-pane", "-t", prefixed, "-o", f"cat >> {shlex.quote(str(log_file))}"],
148
+ capture_output=True,
149
+ text=True,
150
+ )
151
+ if pipe_result.returncode != 0:
152
+ subprocess.run(["tmux", "kill-session", "-t", prefixed], capture_output=True)
153
+ raise RuntimeError(f"Failed to set up logging: {pipe_result.stderr.strip()}")
154
+
155
+ metadata = _load_metadata()
156
+ metadata[name] = {
157
+ "prefixed_name": prefixed,
158
+ "log_file": str(log_file),
159
+ "created_at": datetime.now(timezone.utc).isoformat(),
160
+ "command": command,
161
+ }
162
+ _save_metadata(metadata)
163
+
164
+ return {
165
+ "name": name,
166
+ "tmux_session": prefixed,
167
+ "log_file": str(log_file),
168
+ "status": "created",
169
+ }
170
+
171
+
172
+ @tool(category="tmux")
173
+ def tmux_read(name: str, lines: int = 50, source: str = "pane") -> str:
174
+ """Read output from a tmux session.
175
+
176
+ Args:
177
+ name: Session name (as given to tmux_create)
178
+ lines: Number of lines to capture (default: 50, max: 5000)
179
+ source: "pane" for current visible content, "log" for full pipe-pane log history
180
+
181
+ Returns:
182
+ Session output with ANSI escape codes stripped
183
+ """
184
+ _validate_name(name)
185
+ prefixed = _prefixed(name)
186
+ lines = max(1, min(lines, 5000))
187
+
188
+ if source == "pane":
189
+ if not _session_exists(prefixed):
190
+ raise RuntimeError(f"Session '{name}' not found. Use tmux_list() to see active sessions.")
191
+
192
+ result = subprocess.run(
193
+ ["tmux", "capture-pane", "-t", prefixed, "-p", "-S", f"-{lines}"],
194
+ capture_output=True,
195
+ text=True,
196
+ )
197
+ if result.returncode != 0:
198
+ raise RuntimeError(f"Failed to capture pane: {result.stderr.strip()}")
199
+ return _strip_ansi(result.stdout)
200
+
201
+ elif source == "log":
202
+ log_file = _get_log_dir() / f"{name}.log"
203
+ if not log_file.exists():
204
+ raise RuntimeError(f"No log file found for session '{name}'.")
205
+ with open(log_file) as f:
206
+ tail = deque(f, maxlen=lines)
207
+ return _strip_ansi("".join(tail))
208
+
209
+ else:
210
+ raise ValueError(f"Invalid source '{source}': must be 'pane' or 'log'")
211
+
212
+
213
+ @tool(category="tmux")
214
+ def tmux_send(name: str, keys: str, enter: bool = True) -> str:
215
+ """Send keystrokes to a tmux session.
216
+
217
+ Args:
218
+ name: Session name (as given to tmux_create)
219
+ keys: Keys to send (text command or tmux key names like "C-c", "Enter", "q")
220
+ enter: Whether to send Enter after the keys (default: True). Set False for
221
+ single-key interactive inputs like "q" to quit, "y" to confirm, etc.
222
+
223
+ Returns:
224
+ Confirmation message
225
+ """
226
+ _validate_name(name)
227
+ prefixed = _prefixed(name)
228
+
229
+ if not _session_exists(prefixed):
230
+ raise RuntimeError(f"Session '{name}' not found. Use tmux_list() to see active sessions.")
231
+
232
+ cmd = ["tmux", "send-keys", "-t", prefixed, keys]
233
+ if enter:
234
+ cmd.append("Enter")
235
+
236
+ result = subprocess.run(cmd, capture_output=True, text=True)
237
+ if result.returncode != 0:
238
+ raise RuntimeError(f"Failed to send keys: {result.stderr.strip()}")
239
+
240
+ return f"Sent {'keys' if not enter else 'command'} to session '{name}'"
241
+
242
+
243
+ @tool(category="tmux")
244
+ def tmux_list() -> list:
245
+ """List all tsugite-managed tmux sessions with their current status.
246
+
247
+ Returns:
248
+ List of dicts with name, status, created_at, command, and log_file for each session
249
+ """
250
+ return _list_managed_sessions()
251
+
252
+
253
+ @tool(category="tmux")
254
+ def tmux_kill(name: str) -> str:
255
+ """Terminate a tmux session and clean up its metadata.
256
+
257
+ Args:
258
+ name: Session name (as given to tmux_create)
259
+
260
+ Returns:
261
+ Confirmation message
262
+ """
263
+ _validate_name(name)
264
+ prefixed = _prefixed(name)
265
+
266
+ if not _session_exists(prefixed):
267
+ raise RuntimeError(f"Session '{name}' not found. Use tmux_list() to see active sessions.")
268
+
269
+ result = subprocess.run(
270
+ ["tmux", "kill-session", "-t", prefixed],
271
+ capture_output=True,
272
+ text=True,
273
+ )
274
+ if result.returncode != 0:
275
+ raise RuntimeError(f"Failed to kill session: {result.stderr.strip()}")
276
+
277
+ metadata = _load_metadata()
278
+ metadata.pop(name, None)
279
+ _save_metadata(metadata)
280
+
281
+ return f"Session '{name}' terminated. Log file preserved at {_get_log_dir() / f'{name}.log'}"
282
+
283
+
284
+ def get_tmux_sessions() -> list:
285
+ """Active tsugite-managed tmux sessions, for use as a Jinja2 template global.
286
+
287
+ Returns a list of dicts with name, status, and command for each session.
288
+ Returns empty list if tmux is not installed or no managed sessions exist.
289
+ """
290
+ if not shutil.which("tmux"):
291
+ return []
292
+ return [{"name": s["name"], "status": s["status"], "command": s["command"]} for s in _list_managed_sessions()]