taskmux 0.2.3__tar.gz → 0.2.5__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.
- {taskmux-0.2.3 → taskmux-0.2.5}/PKG-INFO +107 -9
- {taskmux-0.2.3 → taskmux-0.2.5}/README.md +106 -8
- {taskmux-0.2.3 → taskmux-0.2.5}/pyproject.toml +1 -1
- {taskmux-0.2.3 → taskmux-0.2.5}/taskmux/cli.py +9 -3
- {taskmux-0.2.3 → taskmux-0.2.5}/taskmux/config.py +29 -2
- {taskmux-0.2.3 → taskmux-0.2.5}/taskmux/daemon.py +1 -1
- taskmux-0.2.5/taskmux/models.py +84 -0
- {taskmux-0.2.3 → taskmux-0.2.5}/taskmux/tmux_manager.py +260 -44
- taskmux-0.2.3/taskmux/models.py +0 -48
- {taskmux-0.2.3 → taskmux-0.2.5}/.gitignore +0 -0
- {taskmux-0.2.3 → taskmux-0.2.5}/LICENSE +0 -0
- {taskmux-0.2.3 → taskmux-0.2.5}/taskmux/__init__.py +0 -0
- {taskmux-0.2.3 → taskmux-0.2.5}/taskmux/agent.py +0 -0
- {taskmux-0.2.3 → taskmux-0.2.5}/taskmux/hooks.py +0 -0
- {taskmux-0.2.3 → taskmux-0.2.5}/taskmux/init.py +0 -0
- {taskmux-0.2.3 → taskmux-0.2.5}/taskmux/main.py +0 -0
- {taskmux-0.2.3 → taskmux-0.2.5}/taskmux/templates/claude.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: taskmux
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.5
|
|
4
4
|
Summary: Modern tmux-based task manager for LLM development tools
|
|
5
5
|
Project-URL: Homepage, https://github.com/nc9/taskmux
|
|
6
6
|
Project-URL: Repository, https://github.com/nc9/taskmux
|
|
@@ -104,6 +104,67 @@ command = "docker compose up postgres"
|
|
|
104
104
|
auto_start = false
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
+
## Full Example
|
|
108
|
+
|
|
109
|
+
A full-stack app with a database, API server, and frontend — using health checks to ensure each service is ready before starting its dependents:
|
|
110
|
+
|
|
111
|
+
```toml
|
|
112
|
+
name = "fullstack-app"
|
|
113
|
+
|
|
114
|
+
[tasks.db]
|
|
115
|
+
command = "docker compose up postgres redis"
|
|
116
|
+
health_check = "pg_isready -h localhost -p 5432"
|
|
117
|
+
health_interval = 3
|
|
118
|
+
|
|
119
|
+
[tasks.migrate]
|
|
120
|
+
command = "python manage.py migrate && echo 'done' && sleep infinity"
|
|
121
|
+
cwd = "apps/api"
|
|
122
|
+
depends_on = ["db"]
|
|
123
|
+
health_check = "test -f .migrate-complete"
|
|
124
|
+
|
|
125
|
+
[tasks.api]
|
|
126
|
+
command = "python manage.py runserver 0.0.0.0:8000"
|
|
127
|
+
cwd = "apps/api"
|
|
128
|
+
depends_on = ["migrate"]
|
|
129
|
+
health_check = "curl -sf http://localhost:8000/health"
|
|
130
|
+
|
|
131
|
+
[tasks.worker]
|
|
132
|
+
command = "celery -A myapp worker -l info"
|
|
133
|
+
cwd = "apps/api"
|
|
134
|
+
depends_on = ["db"]
|
|
135
|
+
|
|
136
|
+
[tasks.web]
|
|
137
|
+
command = "bun dev"
|
|
138
|
+
cwd = "apps/web"
|
|
139
|
+
depends_on = ["api"]
|
|
140
|
+
health_check = "curl -sf http://localhost:3000"
|
|
141
|
+
|
|
142
|
+
[tasks.storybook]
|
|
143
|
+
command = "bun storybook"
|
|
144
|
+
cwd = "apps/web"
|
|
145
|
+
auto_start = false
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
What happens on `taskmux start`:
|
|
149
|
+
|
|
150
|
+
1. **db** starts first (no dependencies)
|
|
151
|
+
2. **migrate** and **worker** wait for db's health check (`pg_isready`) to pass
|
|
152
|
+
3. **api** waits for migrate's health check
|
|
153
|
+
4. **web** waits for api's health check (`curl localhost:8000/health`)
|
|
154
|
+
5. **storybook** is skipped (`auto_start = false`) — start it manually with `taskmux start storybook`
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
taskmux start # Starts everything in dependency order
|
|
158
|
+
taskmux logs # Interleaved logs from all tasks
|
|
159
|
+
taskmux logs -g "ERROR" # Grep all tasks for errors
|
|
160
|
+
taskmux logs api # Logs from just the API
|
|
161
|
+
taskmux logs -f api # Follow API logs live
|
|
162
|
+
taskmux health # Health check table
|
|
163
|
+
taskmux inspect api # JSON state for a single task
|
|
164
|
+
taskmux restart worker # Restart just the worker
|
|
165
|
+
taskmux start storybook # Start a manual task
|
|
166
|
+
```
|
|
167
|
+
|
|
107
168
|
## Commands
|
|
108
169
|
|
|
109
170
|
```bash
|
|
@@ -124,10 +185,13 @@ taskmux remove <task> # Remove task from config
|
|
|
124
185
|
taskmux inspect <task> # JSON task state (pid, command, health)
|
|
125
186
|
|
|
126
187
|
# Logs
|
|
127
|
-
taskmux logs
|
|
128
|
-
taskmux logs
|
|
129
|
-
taskmux logs -
|
|
130
|
-
taskmux logs <task>
|
|
188
|
+
taskmux logs # Interleaved logs from all tasks
|
|
189
|
+
taskmux logs <task> # Show recent logs for a task
|
|
190
|
+
taskmux logs -f # Attach to session (switch windows with tmux keybinds)
|
|
191
|
+
taskmux logs -f <task> # Follow a task's logs live
|
|
192
|
+
taskmux logs -n 200 <task> # Last N lines
|
|
193
|
+
taskmux logs -g "error" # Search all tasks
|
|
194
|
+
taskmux logs <task> -g "error" # Search one task
|
|
131
195
|
taskmux logs <task> -g "error" -C 5 # Grep with context lines
|
|
132
196
|
|
|
133
197
|
# Init
|
|
@@ -161,12 +225,20 @@ after_stop = "echo done"
|
|
|
161
225
|
|
|
162
226
|
[tasks.server]
|
|
163
227
|
command = "python manage.py runserver"
|
|
228
|
+
cwd = "apps/api"
|
|
229
|
+
health_check = "curl -sf http://localhost:8000/health"
|
|
230
|
+
depends_on = ["db"]
|
|
164
231
|
|
|
165
232
|
[tasks.server.hooks]
|
|
166
233
|
before_start = "python manage.py migrate"
|
|
167
234
|
|
|
235
|
+
[tasks.db]
|
|
236
|
+
command = "docker compose up postgres"
|
|
237
|
+
health_check = "pg_isready -h localhost"
|
|
238
|
+
|
|
168
239
|
[tasks.worker]
|
|
169
240
|
command = "celery worker -A myapp"
|
|
241
|
+
depends_on = ["db"]
|
|
170
242
|
|
|
171
243
|
[tasks.tailwind]
|
|
172
244
|
command = "npx tailwindcss -w"
|
|
@@ -185,8 +257,31 @@ auto_start = false
|
|
|
185
257
|
| `hooks.after_stop` | — | Run after stopping tasks |
|
|
186
258
|
| `tasks.<name>.command` | — | Shell command to run |
|
|
187
259
|
| `tasks.<name>.auto_start` | `true` | Start with `taskmux start` |
|
|
260
|
+
| `tasks.<name>.cwd` | — | Working directory for the task |
|
|
261
|
+
| `tasks.<name>.health_check` | — | Shell command to check health (exit 0 = healthy) |
|
|
262
|
+
| `tasks.<name>.health_interval` | `10` | Seconds between health checks |
|
|
263
|
+
| `tasks.<name>.health_timeout` | `5` | Seconds before health check times out |
|
|
264
|
+
| `tasks.<name>.health_retries` | `3` | Consecutive failures before "unhealthy" |
|
|
265
|
+
| `tasks.<name>.depends_on` | `[]` | Task names that must be healthy before this task starts |
|
|
188
266
|
| `tasks.<name>.hooks.*` | — | Per-task lifecycle hooks (same fields as global) |
|
|
189
267
|
|
|
268
|
+
### Dependency Ordering
|
|
269
|
+
|
|
270
|
+
Tasks with `depends_on` are started in topological order. Before starting a task, taskmux waits for each dependency's health check to pass (up to `health_retries * health_interval` seconds). If a dependency never becomes healthy, the dependent task is skipped with a warning.
|
|
271
|
+
|
|
272
|
+
Circular dependencies and references to nonexistent tasks are rejected at config load time.
|
|
273
|
+
|
|
274
|
+
When starting a single task with `taskmux start <task>`, dependencies are not auto-started — you get a warning if they aren't running.
|
|
275
|
+
|
|
276
|
+
### Health Checks
|
|
277
|
+
|
|
278
|
+
If `health_check` is set, taskmux runs it as a shell command. Exit code 0 means healthy. If not set, taskmux falls back to checking if the tmux pane has a running process (not just a shell prompt).
|
|
279
|
+
|
|
280
|
+
Health checks are used by:
|
|
281
|
+
- `taskmux health` — shows a table of all task health
|
|
282
|
+
- `taskmux start` — waits for dependencies to be healthy before starting dependents
|
|
283
|
+
- `taskmux daemon` — continuously monitors and auto-restarts unhealthy tasks
|
|
284
|
+
|
|
190
285
|
### Hook Cascade
|
|
191
286
|
|
|
192
287
|
Hooks fire in this order:
|
|
@@ -212,14 +307,17 @@ Use `--defaults` to skip prompts (CI/automation).
|
|
|
212
307
|
|
|
213
308
|
```json
|
|
214
309
|
{
|
|
215
|
-
"name": "
|
|
216
|
-
"command": "
|
|
310
|
+
"name": "api",
|
|
311
|
+
"command": "python manage.py runserver 0.0.0.0:8000",
|
|
217
312
|
"auto_start": true,
|
|
313
|
+
"cwd": "apps/api",
|
|
314
|
+
"health_check": "curl -sf http://localhost:8000/health",
|
|
315
|
+
"depends_on": ["db"],
|
|
218
316
|
"running": true,
|
|
219
317
|
"healthy": true,
|
|
220
318
|
"pid": "12345",
|
|
221
|
-
"pane_current_command": "
|
|
222
|
-
"pane_current_path": "/home/user/project",
|
|
319
|
+
"pane_current_command": "python",
|
|
320
|
+
"pane_current_path": "/home/user/project/apps/api",
|
|
223
321
|
"window_id": "@1",
|
|
224
322
|
"pane_id": "%1"
|
|
225
323
|
}
|
|
@@ -69,6 +69,67 @@ command = "docker compose up postgres"
|
|
|
69
69
|
auto_start = false
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
+
## Full Example
|
|
73
|
+
|
|
74
|
+
A full-stack app with a database, API server, and frontend — using health checks to ensure each service is ready before starting its dependents:
|
|
75
|
+
|
|
76
|
+
```toml
|
|
77
|
+
name = "fullstack-app"
|
|
78
|
+
|
|
79
|
+
[tasks.db]
|
|
80
|
+
command = "docker compose up postgres redis"
|
|
81
|
+
health_check = "pg_isready -h localhost -p 5432"
|
|
82
|
+
health_interval = 3
|
|
83
|
+
|
|
84
|
+
[tasks.migrate]
|
|
85
|
+
command = "python manage.py migrate && echo 'done' && sleep infinity"
|
|
86
|
+
cwd = "apps/api"
|
|
87
|
+
depends_on = ["db"]
|
|
88
|
+
health_check = "test -f .migrate-complete"
|
|
89
|
+
|
|
90
|
+
[tasks.api]
|
|
91
|
+
command = "python manage.py runserver 0.0.0.0:8000"
|
|
92
|
+
cwd = "apps/api"
|
|
93
|
+
depends_on = ["migrate"]
|
|
94
|
+
health_check = "curl -sf http://localhost:8000/health"
|
|
95
|
+
|
|
96
|
+
[tasks.worker]
|
|
97
|
+
command = "celery -A myapp worker -l info"
|
|
98
|
+
cwd = "apps/api"
|
|
99
|
+
depends_on = ["db"]
|
|
100
|
+
|
|
101
|
+
[tasks.web]
|
|
102
|
+
command = "bun dev"
|
|
103
|
+
cwd = "apps/web"
|
|
104
|
+
depends_on = ["api"]
|
|
105
|
+
health_check = "curl -sf http://localhost:3000"
|
|
106
|
+
|
|
107
|
+
[tasks.storybook]
|
|
108
|
+
command = "bun storybook"
|
|
109
|
+
cwd = "apps/web"
|
|
110
|
+
auto_start = false
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
What happens on `taskmux start`:
|
|
114
|
+
|
|
115
|
+
1. **db** starts first (no dependencies)
|
|
116
|
+
2. **migrate** and **worker** wait for db's health check (`pg_isready`) to pass
|
|
117
|
+
3. **api** waits for migrate's health check
|
|
118
|
+
4. **web** waits for api's health check (`curl localhost:8000/health`)
|
|
119
|
+
5. **storybook** is skipped (`auto_start = false`) — start it manually with `taskmux start storybook`
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
taskmux start # Starts everything in dependency order
|
|
123
|
+
taskmux logs # Interleaved logs from all tasks
|
|
124
|
+
taskmux logs -g "ERROR" # Grep all tasks for errors
|
|
125
|
+
taskmux logs api # Logs from just the API
|
|
126
|
+
taskmux logs -f api # Follow API logs live
|
|
127
|
+
taskmux health # Health check table
|
|
128
|
+
taskmux inspect api # JSON state for a single task
|
|
129
|
+
taskmux restart worker # Restart just the worker
|
|
130
|
+
taskmux start storybook # Start a manual task
|
|
131
|
+
```
|
|
132
|
+
|
|
72
133
|
## Commands
|
|
73
134
|
|
|
74
135
|
```bash
|
|
@@ -89,10 +150,13 @@ taskmux remove <task> # Remove task from config
|
|
|
89
150
|
taskmux inspect <task> # JSON task state (pid, command, health)
|
|
90
151
|
|
|
91
152
|
# Logs
|
|
92
|
-
taskmux logs
|
|
93
|
-
taskmux logs
|
|
94
|
-
taskmux logs -
|
|
95
|
-
taskmux logs <task>
|
|
153
|
+
taskmux logs # Interleaved logs from all tasks
|
|
154
|
+
taskmux logs <task> # Show recent logs for a task
|
|
155
|
+
taskmux logs -f # Attach to session (switch windows with tmux keybinds)
|
|
156
|
+
taskmux logs -f <task> # Follow a task's logs live
|
|
157
|
+
taskmux logs -n 200 <task> # Last N lines
|
|
158
|
+
taskmux logs -g "error" # Search all tasks
|
|
159
|
+
taskmux logs <task> -g "error" # Search one task
|
|
96
160
|
taskmux logs <task> -g "error" -C 5 # Grep with context lines
|
|
97
161
|
|
|
98
162
|
# Init
|
|
@@ -126,12 +190,20 @@ after_stop = "echo done"
|
|
|
126
190
|
|
|
127
191
|
[tasks.server]
|
|
128
192
|
command = "python manage.py runserver"
|
|
193
|
+
cwd = "apps/api"
|
|
194
|
+
health_check = "curl -sf http://localhost:8000/health"
|
|
195
|
+
depends_on = ["db"]
|
|
129
196
|
|
|
130
197
|
[tasks.server.hooks]
|
|
131
198
|
before_start = "python manage.py migrate"
|
|
132
199
|
|
|
200
|
+
[tasks.db]
|
|
201
|
+
command = "docker compose up postgres"
|
|
202
|
+
health_check = "pg_isready -h localhost"
|
|
203
|
+
|
|
133
204
|
[tasks.worker]
|
|
134
205
|
command = "celery worker -A myapp"
|
|
206
|
+
depends_on = ["db"]
|
|
135
207
|
|
|
136
208
|
[tasks.tailwind]
|
|
137
209
|
command = "npx tailwindcss -w"
|
|
@@ -150,8 +222,31 @@ auto_start = false
|
|
|
150
222
|
| `hooks.after_stop` | — | Run after stopping tasks |
|
|
151
223
|
| `tasks.<name>.command` | — | Shell command to run |
|
|
152
224
|
| `tasks.<name>.auto_start` | `true` | Start with `taskmux start` |
|
|
225
|
+
| `tasks.<name>.cwd` | — | Working directory for the task |
|
|
226
|
+
| `tasks.<name>.health_check` | — | Shell command to check health (exit 0 = healthy) |
|
|
227
|
+
| `tasks.<name>.health_interval` | `10` | Seconds between health checks |
|
|
228
|
+
| `tasks.<name>.health_timeout` | `5` | Seconds before health check times out |
|
|
229
|
+
| `tasks.<name>.health_retries` | `3` | Consecutive failures before "unhealthy" |
|
|
230
|
+
| `tasks.<name>.depends_on` | `[]` | Task names that must be healthy before this task starts |
|
|
153
231
|
| `tasks.<name>.hooks.*` | — | Per-task lifecycle hooks (same fields as global) |
|
|
154
232
|
|
|
233
|
+
### Dependency Ordering
|
|
234
|
+
|
|
235
|
+
Tasks with `depends_on` are started in topological order. Before starting a task, taskmux waits for each dependency's health check to pass (up to `health_retries * health_interval` seconds). If a dependency never becomes healthy, the dependent task is skipped with a warning.
|
|
236
|
+
|
|
237
|
+
Circular dependencies and references to nonexistent tasks are rejected at config load time.
|
|
238
|
+
|
|
239
|
+
When starting a single task with `taskmux start <task>`, dependencies are not auto-started — you get a warning if they aren't running.
|
|
240
|
+
|
|
241
|
+
### Health Checks
|
|
242
|
+
|
|
243
|
+
If `health_check` is set, taskmux runs it as a shell command. Exit code 0 means healthy. If not set, taskmux falls back to checking if the tmux pane has a running process (not just a shell prompt).
|
|
244
|
+
|
|
245
|
+
Health checks are used by:
|
|
246
|
+
- `taskmux health` — shows a table of all task health
|
|
247
|
+
- `taskmux start` — waits for dependencies to be healthy before starting dependents
|
|
248
|
+
- `taskmux daemon` — continuously monitors and auto-restarts unhealthy tasks
|
|
249
|
+
|
|
155
250
|
### Hook Cascade
|
|
156
251
|
|
|
157
252
|
Hooks fire in this order:
|
|
@@ -177,14 +272,17 @@ Use `--defaults` to skip prompts (CI/automation).
|
|
|
177
272
|
|
|
178
273
|
```json
|
|
179
274
|
{
|
|
180
|
-
"name": "
|
|
181
|
-
"command": "
|
|
275
|
+
"name": "api",
|
|
276
|
+
"command": "python manage.py runserver 0.0.0.0:8000",
|
|
182
277
|
"auto_start": true,
|
|
278
|
+
"cwd": "apps/api",
|
|
279
|
+
"health_check": "curl -sf http://localhost:8000/health",
|
|
280
|
+
"depends_on": ["db"],
|
|
183
281
|
"running": true,
|
|
184
282
|
"healthy": true,
|
|
185
283
|
"pid": "12345",
|
|
186
|
-
"pane_current_command": "
|
|
187
|
-
"pane_current_path": "/home/user/project",
|
|
284
|
+
"pane_current_command": "python",
|
|
285
|
+
"pane_current_path": "/home/user/project/apps/api",
|
|
188
286
|
"window_id": "@1",
|
|
189
287
|
"pane_id": "%1"
|
|
190
288
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import json
|
|
5
|
+
from typing import List, Optional # noqa: UP035
|
|
5
6
|
|
|
6
7
|
import typer
|
|
7
8
|
from rich.console import Console
|
|
@@ -106,13 +107,13 @@ def kill(
|
|
|
106
107
|
|
|
107
108
|
@app.command()
|
|
108
109
|
def logs(
|
|
109
|
-
task: str = typer.Argument(
|
|
110
|
+
task: str | None = typer.Argument(None, help="Task name (omit for all)"),
|
|
110
111
|
follow: bool = typer.Option(False, "-f", "--follow", help="Follow logs"),
|
|
111
112
|
lines: int = typer.Option(100, "-n", "--lines", help="Number of lines"),
|
|
112
113
|
grep: str | None = typer.Option(None, "-g", "--grep", help="Filter logs by pattern"),
|
|
113
114
|
context: int = typer.Option(3, "-C", "--context", help="Context lines around grep matches"),
|
|
114
115
|
):
|
|
115
|
-
"""Show logs for a task."""
|
|
116
|
+
"""Show logs for a task, or all tasks if none specified."""
|
|
116
117
|
cli = TaskmuxCLI()
|
|
117
118
|
cli.tmux.show_logs(task, follow, lines, grep=grep, context=context)
|
|
118
119
|
|
|
@@ -131,9 +132,14 @@ def inspect(
|
|
|
131
132
|
def add(
|
|
132
133
|
task: str = typer.Argument(..., help="Task name"),
|
|
133
134
|
command: str = typer.Argument(..., help="Command to run"),
|
|
135
|
+
cwd: str | None = typer.Option(None, "--cwd", help="Working directory"),
|
|
136
|
+
health_check: str | None = typer.Option(None, "--health-check", help="Health check command"),
|
|
137
|
+
depends_on: Optional[List[str]] = typer.Option( # noqa: UP006, UP045, B008
|
|
138
|
+
None, "--depends-on", help="Dependency task names"
|
|
139
|
+
),
|
|
134
140
|
):
|
|
135
141
|
"""Add a new task."""
|
|
136
|
-
addTask(None, task, command)
|
|
142
|
+
addTask(None, task, command, cwd=cwd, health_check=health_check, depends_on=depends_on)
|
|
137
143
|
console.print(f"Added task '{task}': {command}")
|
|
138
144
|
|
|
139
145
|
|
|
@@ -104,6 +104,18 @@ def writeConfig(path: Path | None, config: TaskmuxConfig) -> Path:
|
|
|
104
104
|
inner.add("command", task_cfg.command)
|
|
105
105
|
if not task_cfg.auto_start:
|
|
106
106
|
inner.add("auto_start", False)
|
|
107
|
+
if task_cfg.cwd is not None:
|
|
108
|
+
inner.add("cwd", task_cfg.cwd)
|
|
109
|
+
if task_cfg.health_check is not None:
|
|
110
|
+
inner.add("health_check", task_cfg.health_check)
|
|
111
|
+
if task_cfg.health_interval != 10:
|
|
112
|
+
inner.add("health_interval", task_cfg.health_interval)
|
|
113
|
+
if task_cfg.health_timeout != 5:
|
|
114
|
+
inner.add("health_timeout", task_cfg.health_timeout)
|
|
115
|
+
if task_cfg.health_retries != 3:
|
|
116
|
+
inner.add("health_retries", task_cfg.health_retries)
|
|
117
|
+
if task_cfg.depends_on:
|
|
118
|
+
inner.add("depends_on", task_cfg.depends_on)
|
|
107
119
|
# Task-level hooks
|
|
108
120
|
task_hooks_tbl = _writeHooksTable(task_cfg.hooks)
|
|
109
121
|
if task_hooks_tbl:
|
|
@@ -115,11 +127,26 @@ def writeConfig(path: Path | None, config: TaskmuxConfig) -> Path:
|
|
|
115
127
|
return p
|
|
116
128
|
|
|
117
129
|
|
|
118
|
-
def addTask(
|
|
130
|
+
def addTask(
|
|
131
|
+
path: Path | None,
|
|
132
|
+
name: str,
|
|
133
|
+
command: str,
|
|
134
|
+
*,
|
|
135
|
+
cwd: str | None = None,
|
|
136
|
+
health_check: str | None = None,
|
|
137
|
+
depends_on: list[str] | None = None,
|
|
138
|
+
) -> TaskmuxConfig:
|
|
119
139
|
"""Add a task to config and persist."""
|
|
120
140
|
cfg = loadConfig(path)
|
|
121
141
|
new_tasks = dict(cfg.tasks)
|
|
122
|
-
|
|
142
|
+
kwargs: dict = {"command": command}
|
|
143
|
+
if cwd is not None:
|
|
144
|
+
kwargs["cwd"] = cwd
|
|
145
|
+
if health_check is not None:
|
|
146
|
+
kwargs["health_check"] = health_check
|
|
147
|
+
if depends_on:
|
|
148
|
+
kwargs["depends_on"] = depends_on
|
|
149
|
+
new_tasks[name] = TaskConfig(**kwargs)
|
|
123
150
|
cfg = TaskmuxConfig(name=cfg.name, auto_start=cfg.auto_start, hooks=cfg.hooks, tasks=new_tasks)
|
|
124
151
|
writeConfig(path, cfg)
|
|
125
152
|
return cfg
|
|
@@ -191,7 +191,7 @@ class TaskmuxDaemon:
|
|
|
191
191
|
if task_name and self.cli.tmux.session_exists():
|
|
192
192
|
try:
|
|
193
193
|
sess = self.cli.tmux._get_session()
|
|
194
|
-
window = sess.windows.get(window_name=task_name)
|
|
194
|
+
window = sess.windows.get(window_name=task_name, default=None)
|
|
195
195
|
if window and window.active_pane:
|
|
196
196
|
output = window.active_pane.cmd(
|
|
197
197
|
"capture-pane", "-p", "-S", f"-{lines}"
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Pydantic models for Taskmux configuration."""
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, model_validator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _StrictConfig(BaseModel):
|
|
9
|
+
"""Base config: frozen, warns on unknown keys."""
|
|
10
|
+
|
|
11
|
+
model_config = ConfigDict(frozen=True)
|
|
12
|
+
|
|
13
|
+
@model_validator(mode="before")
|
|
14
|
+
@classmethod
|
|
15
|
+
def _warn_unknown_keys(cls, values: dict) -> dict:
|
|
16
|
+
if not isinstance(values, dict):
|
|
17
|
+
return values
|
|
18
|
+
known = set(cls.model_fields.keys())
|
|
19
|
+
unknown = set(values.keys()) - known
|
|
20
|
+
for key in sorted(unknown):
|
|
21
|
+
warnings.warn(f"Unknown config key: {key!r}", UserWarning, stacklevel=2)
|
|
22
|
+
return values
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HookConfig(_StrictConfig):
|
|
26
|
+
"""Lifecycle hooks for tasks or global config."""
|
|
27
|
+
|
|
28
|
+
before_start: str | None = None
|
|
29
|
+
after_start: str | None = None
|
|
30
|
+
before_stop: str | None = None
|
|
31
|
+
after_stop: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TaskConfig(_StrictConfig):
|
|
35
|
+
"""Single task definition."""
|
|
36
|
+
|
|
37
|
+
command: str
|
|
38
|
+
auto_start: bool = True
|
|
39
|
+
cwd: str | None = None
|
|
40
|
+
health_check: str | None = None
|
|
41
|
+
health_interval: int = 10
|
|
42
|
+
health_timeout: int = 5
|
|
43
|
+
health_retries: int = 3
|
|
44
|
+
depends_on: list[str] = []
|
|
45
|
+
hooks: HookConfig = HookConfig()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TaskmuxConfig(_StrictConfig):
|
|
49
|
+
"""Top-level taskmux.toml schema."""
|
|
50
|
+
|
|
51
|
+
name: str = "taskmux"
|
|
52
|
+
auto_start: bool = True
|
|
53
|
+
hooks: HookConfig = HookConfig()
|
|
54
|
+
tasks: dict[str, TaskConfig] = {}
|
|
55
|
+
|
|
56
|
+
@model_validator(mode="after")
|
|
57
|
+
def _validate_depends_on(self) -> "TaskmuxConfig":
|
|
58
|
+
"""Reject unknown depends_on references and cycles."""
|
|
59
|
+
task_names = set(self.tasks.keys())
|
|
60
|
+
for name, cfg in self.tasks.items():
|
|
61
|
+
for dep in cfg.depends_on:
|
|
62
|
+
if dep not in task_names:
|
|
63
|
+
raise ValueError(f"Task '{name}' depends on unknown task '{dep}'")
|
|
64
|
+
if dep == name:
|
|
65
|
+
raise ValueError(f"Task '{name}' depends on itself")
|
|
66
|
+
|
|
67
|
+
# Cycle detection via DFS
|
|
68
|
+
WHITE, GRAY, BLACK = 0, 1, 2
|
|
69
|
+
color: dict[str, int] = {n: WHITE for n in task_names}
|
|
70
|
+
|
|
71
|
+
def dfs(node: str) -> None:
|
|
72
|
+
color[node] = GRAY
|
|
73
|
+
for dep in self.tasks[node].depends_on:
|
|
74
|
+
if color[dep] == GRAY:
|
|
75
|
+
raise ValueError(f"Dependency cycle detected involving '{dep}'")
|
|
76
|
+
if color[dep] == WHITE:
|
|
77
|
+
dfs(dep)
|
|
78
|
+
color[node] = BLACK
|
|
79
|
+
|
|
80
|
+
for n in task_names:
|
|
81
|
+
if color[n] == WHITE:
|
|
82
|
+
dfs(n)
|
|
83
|
+
|
|
84
|
+
return self
|
|
@@ -2,14 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import subprocess
|
|
5
6
|
import time
|
|
7
|
+
from collections import deque
|
|
6
8
|
from datetime import datetime
|
|
7
9
|
|
|
8
10
|
import libtmux
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.markup import escape
|
|
9
13
|
|
|
10
14
|
from .hooks import runHook
|
|
11
15
|
from .models import TaskmuxConfig
|
|
12
16
|
|
|
17
|
+
TASK_COLORS = ["cyan", "green", "yellow", "magenta", "blue", "red"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _find_new_lines(current: list[str], prev_tail: list[str]) -> list[str]:
|
|
21
|
+
"""Return lines in current that are new since prev_tail."""
|
|
22
|
+
if not prev_tail:
|
|
23
|
+
return current
|
|
24
|
+
target = prev_tail[-1]
|
|
25
|
+
for i in range(len(current) - 1, -1, -1):
|
|
26
|
+
if current[i] == target:
|
|
27
|
+
ctx = min(len(prev_tail), i + 1)
|
|
28
|
+
if current[i - ctx + 1 : i + 1] == prev_tail[-ctx:]:
|
|
29
|
+
return current[i + 1 :]
|
|
30
|
+
return current # no match, prev scrolled away — return all
|
|
31
|
+
|
|
13
32
|
|
|
14
33
|
class TmuxManager:
|
|
15
34
|
"""Manages tmux sessions and tasks using libtmux API."""
|
|
@@ -47,6 +66,39 @@ class TmuxManager:
|
|
|
47
66
|
except Exception:
|
|
48
67
|
return []
|
|
49
68
|
|
|
69
|
+
def _is_pane_alive(self, task_name: str) -> bool:
|
|
70
|
+
"""Check if task's pane has a running process (not just a shell)."""
|
|
71
|
+
if not self.session_exists():
|
|
72
|
+
return False
|
|
73
|
+
try:
|
|
74
|
+
window = self._get_session().windows.get(window_name=task_name, default=None)
|
|
75
|
+
if window and window.active_pane:
|
|
76
|
+
cmd = getattr(window.active_pane, "pane_current_command", "")
|
|
77
|
+
return cmd != "" and cmd != "bash"
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
def is_task_healthy(self, task_name: str) -> bool:
|
|
83
|
+
"""Check task health. Uses health_check command if configured, falls back to pane-alive."""
|
|
84
|
+
task_cfg = self.config.tasks.get(task_name)
|
|
85
|
+
if not task_cfg:
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
if not task_cfg.health_check:
|
|
89
|
+
return self._is_pane_alive(task_name)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
result = subprocess.run(
|
|
93
|
+
task_cfg.health_check,
|
|
94
|
+
shell=True,
|
|
95
|
+
capture_output=True,
|
|
96
|
+
timeout=task_cfg.health_timeout,
|
|
97
|
+
)
|
|
98
|
+
return result.returncode == 0
|
|
99
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
100
|
+
return False
|
|
101
|
+
|
|
50
102
|
def get_task_status(self, task_name: str) -> dict[str, str | bool]:
|
|
51
103
|
"""Get detailed status for a task"""
|
|
52
104
|
task_cfg = self.config.tasks.get(task_name)
|
|
@@ -64,27 +116,65 @@ class TmuxManager:
|
|
|
64
116
|
windows = self.list_windows()
|
|
65
117
|
status["running"] = task_name in windows
|
|
66
118
|
|
|
67
|
-
if
|
|
68
|
-
|
|
69
|
-
window = self._get_session().windows.get(window_name=task_name)
|
|
70
|
-
if window and window.active_pane:
|
|
71
|
-
current_command = getattr(window.active_pane, "pane_current_command", "")
|
|
72
|
-
status["healthy"] = current_command != "" and current_command != "bash"
|
|
73
|
-
except Exception:
|
|
74
|
-
pass
|
|
119
|
+
if status["running"]:
|
|
120
|
+
status["healthy"] = self.is_task_healthy(task_name)
|
|
75
121
|
|
|
76
122
|
return status
|
|
77
123
|
|
|
78
124
|
def _send_command_to_window(
|
|
79
|
-
self, sess: libtmux.Session, task_name: str, command: str
|
|
125
|
+
self, sess: libtmux.Session, task_name: str, command: str, cwd: str | None = None
|
|
80
126
|
) -> libtmux.Window:
|
|
81
127
|
"""Create a new window and send a command to it."""
|
|
82
|
-
|
|
128
|
+
kwargs: dict = {"attach": False, "window_name": task_name}
|
|
129
|
+
if cwd:
|
|
130
|
+
kwargs["start_directory"] = cwd
|
|
131
|
+
window = sess.new_window(**kwargs)
|
|
83
132
|
pane = window.active_pane
|
|
84
133
|
if pane:
|
|
85
134
|
pane.send_keys(command, enter=True)
|
|
86
135
|
return window
|
|
87
136
|
|
|
137
|
+
def _toposort_tasks(self, task_names: list[str]) -> list[str]:
|
|
138
|
+
"""Topological sort tasks by depends_on (Kahn's algorithm). Raises on cycles."""
|
|
139
|
+
# Build adjacency + in-degree for the subset
|
|
140
|
+
in_degree: dict[str, int] = {n: 0 for n in task_names}
|
|
141
|
+
dependents: dict[str, list[str]] = {n: [] for n in task_names}
|
|
142
|
+
name_set = set(task_names)
|
|
143
|
+
|
|
144
|
+
for name in task_names:
|
|
145
|
+
for dep in self.config.tasks[name].depends_on:
|
|
146
|
+
if dep in name_set:
|
|
147
|
+
in_degree[name] += 1
|
|
148
|
+
dependents[dep].append(name)
|
|
149
|
+
|
|
150
|
+
queue: deque[str] = deque(n for n in task_names if in_degree[n] == 0)
|
|
151
|
+
result: list[str] = []
|
|
152
|
+
|
|
153
|
+
while queue:
|
|
154
|
+
node = queue.popleft()
|
|
155
|
+
result.append(node)
|
|
156
|
+
for dep in dependents[node]:
|
|
157
|
+
in_degree[dep] -= 1
|
|
158
|
+
if in_degree[dep] == 0:
|
|
159
|
+
queue.append(dep)
|
|
160
|
+
|
|
161
|
+
if len(result) != len(task_names):
|
|
162
|
+
raise ValueError("Dependency cycle detected in tasks")
|
|
163
|
+
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
def _wait_for_healthy(self, task_name: str, timeout: float) -> bool:
|
|
167
|
+
"""Poll is_task_healthy until True or timeout."""
|
|
168
|
+
task_cfg = self.config.tasks[task_name]
|
|
169
|
+
interval = task_cfg.health_interval
|
|
170
|
+
elapsed = 0.0
|
|
171
|
+
while elapsed < timeout:
|
|
172
|
+
if self.is_task_healthy(task_name):
|
|
173
|
+
return True
|
|
174
|
+
time.sleep(interval)
|
|
175
|
+
elapsed += interval
|
|
176
|
+
return self.is_task_healthy(task_name)
|
|
177
|
+
|
|
88
178
|
def start_task(self, task_name: str) -> None:
|
|
89
179
|
"""Start a single task (create window + send command)."""
|
|
90
180
|
if task_name not in self.config.tasks:
|
|
@@ -99,11 +189,16 @@ class TmuxManager:
|
|
|
99
189
|
task_cfg = self.config.tasks[task_name]
|
|
100
190
|
|
|
101
191
|
# Check if already running
|
|
102
|
-
existing = sess.windows.get(window_name=task_name)
|
|
192
|
+
existing = sess.windows.get(window_name=task_name, default=None)
|
|
103
193
|
if existing:
|
|
104
194
|
print(f"Task '{task_name}' already running")
|
|
105
195
|
return
|
|
106
196
|
|
|
197
|
+
# Warn if deps aren't running
|
|
198
|
+
for dep in task_cfg.depends_on:
|
|
199
|
+
if dep not in self.list_windows():
|
|
200
|
+
print(f"Warning: dependency '{dep}' is not running")
|
|
201
|
+
|
|
107
202
|
# Hooks: global before_start, then task before_start
|
|
108
203
|
if not runHook(self.config.hooks.before_start, task_name):
|
|
109
204
|
return
|
|
@@ -118,11 +213,13 @@ class TmuxManager:
|
|
|
118
213
|
default.rename_window(task_name)
|
|
119
214
|
pane = default.active_pane
|
|
120
215
|
if pane:
|
|
216
|
+
if task_cfg.cwd:
|
|
217
|
+
pane.send_keys(f"cd {task_cfg.cwd}", enter=True)
|
|
121
218
|
pane.send_keys(task_cfg.command, enter=True)
|
|
122
219
|
else:
|
|
123
|
-
self._send_command_to_window(sess, task_name, task_cfg.command)
|
|
220
|
+
self._send_command_to_window(sess, task_name, task_cfg.command, task_cfg.cwd)
|
|
124
221
|
else:
|
|
125
|
-
self._send_command_to_window(sess, task_name, task_cfg.command)
|
|
222
|
+
self._send_command_to_window(sess, task_name, task_cfg.command, task_cfg.cwd)
|
|
126
223
|
|
|
127
224
|
runHook(task_cfg.hooks.after_start, task_name)
|
|
128
225
|
runHook(self.config.hooks.after_start, task_name)
|
|
@@ -139,7 +236,7 @@ class TmuxManager:
|
|
|
139
236
|
return
|
|
140
237
|
|
|
141
238
|
sess = self._get_session()
|
|
142
|
-
window = sess.windows.get(window_name=task_name)
|
|
239
|
+
window = sess.windows.get(window_name=task_name, default=None)
|
|
143
240
|
if not window:
|
|
144
241
|
print(f"Task '{task_name}' not running")
|
|
145
242
|
return
|
|
@@ -160,7 +257,7 @@ class TmuxManager:
|
|
|
160
257
|
print(f"Stopped task '{task_name}'")
|
|
161
258
|
|
|
162
259
|
def start_all(self) -> None:
|
|
163
|
-
"""Start all auto_start tasks
|
|
260
|
+
"""Start all auto_start tasks in dependency order."""
|
|
164
261
|
if self.session_exists():
|
|
165
262
|
print(f"Session '{self.config.name}' already exists")
|
|
166
263
|
return
|
|
@@ -171,11 +268,14 @@ class TmuxManager:
|
|
|
171
268
|
print(f"Created session '{self.config.name}' (auto_start disabled, no tasks launched)")
|
|
172
269
|
return
|
|
173
270
|
|
|
174
|
-
auto_tasks =
|
|
271
|
+
auto_tasks = {name: cfg for name, cfg in self.config.tasks.items() if cfg.auto_start}
|
|
175
272
|
if not auto_tasks:
|
|
176
273
|
print("No auto-start tasks defined in config")
|
|
177
274
|
return
|
|
178
275
|
|
|
276
|
+
# Topological sort for dependency ordering
|
|
277
|
+
sorted_names = self._toposort_tasks(list(auto_tasks.keys()))
|
|
278
|
+
|
|
179
279
|
# Global before_start
|
|
180
280
|
if not runHook(self.config.hooks.before_start):
|
|
181
281
|
return
|
|
@@ -183,20 +283,38 @@ class TmuxManager:
|
|
|
183
283
|
self.session = self.server.new_session(session_name=self.config.name, attach=False)
|
|
184
284
|
sess = self._get_session()
|
|
185
285
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
286
|
+
first = True
|
|
287
|
+
for task_name in sorted_names:
|
|
288
|
+
task_cfg = auto_tasks[task_name]
|
|
289
|
+
|
|
290
|
+
# Wait for dependencies to become healthy before starting
|
|
291
|
+
skip = False
|
|
292
|
+
for dep in task_cfg.depends_on:
|
|
293
|
+
if dep in auto_tasks:
|
|
294
|
+
dep_cfg = auto_tasks[dep]
|
|
295
|
+
timeout = dep_cfg.health_retries * dep_cfg.health_interval
|
|
296
|
+
if not self._wait_for_healthy(dep, timeout):
|
|
297
|
+
print(f"Warning: dependency '{dep}' not healthy, skipping '{task_name}'")
|
|
298
|
+
skip = True
|
|
299
|
+
break
|
|
300
|
+
if skip:
|
|
301
|
+
continue
|
|
196
302
|
|
|
197
|
-
for task_name, task_cfg in auto_tasks[1:]:
|
|
198
303
|
runHook(task_cfg.hooks.before_start, task_name)
|
|
199
|
-
|
|
304
|
+
|
|
305
|
+
if first and sess.windows:
|
|
306
|
+
# First task reuses default window
|
|
307
|
+
default_window = sess.windows[0]
|
|
308
|
+
default_window.rename_window(task_name)
|
|
309
|
+
pane = default_window.active_pane
|
|
310
|
+
if pane:
|
|
311
|
+
if task_cfg.cwd:
|
|
312
|
+
pane.send_keys(f"cd {task_cfg.cwd}", enter=True)
|
|
313
|
+
pane.send_keys(task_cfg.command, enter=True)
|
|
314
|
+
first = False
|
|
315
|
+
else:
|
|
316
|
+
self._send_command_to_window(sess, task_name, task_cfg.command, task_cfg.cwd)
|
|
317
|
+
|
|
200
318
|
runHook(task_cfg.hooks.after_start, task_name)
|
|
201
319
|
|
|
202
320
|
# Global after_start
|
|
@@ -215,7 +333,7 @@ class TmuxManager:
|
|
|
215
333
|
# Stop each task with hooks
|
|
216
334
|
sess = self._get_session()
|
|
217
335
|
for task_name, task_cfg in self.config.tasks.items():
|
|
218
|
-
window = sess.windows.get(window_name=task_name)
|
|
336
|
+
window = sess.windows.get(window_name=task_name, default=None)
|
|
219
337
|
if window:
|
|
220
338
|
runHook(task_cfg.hooks.before_stop, task_name)
|
|
221
339
|
pane = window.active_pane
|
|
@@ -253,7 +371,7 @@ class TmuxManager:
|
|
|
253
371
|
task_cfg = self.config.tasks[task_name]
|
|
254
372
|
command = task_cfg.command
|
|
255
373
|
|
|
256
|
-
window = sess.windows.get(window_name=task_name)
|
|
374
|
+
window = sess.windows.get(window_name=task_name, default=None)
|
|
257
375
|
if window:
|
|
258
376
|
runHook(task_cfg.hooks.before_stop, task_name)
|
|
259
377
|
pane = window.active_pane
|
|
@@ -265,11 +383,13 @@ class TmuxManager:
|
|
|
265
383
|
runHook(task_cfg.hooks.before_start, task_name)
|
|
266
384
|
pane = window.active_pane
|
|
267
385
|
if pane:
|
|
386
|
+
if task_cfg.cwd:
|
|
387
|
+
pane.send_keys(f"cd {task_cfg.cwd}", enter=True)
|
|
268
388
|
pane.send_keys(command, enter=True)
|
|
269
389
|
runHook(task_cfg.hooks.after_start, task_name)
|
|
270
390
|
else:
|
|
271
391
|
runHook(task_cfg.hooks.before_start, task_name)
|
|
272
|
-
self._send_command_to_window(sess, task_name, command)
|
|
392
|
+
self._send_command_to_window(sess, task_name, command, task_cfg.cwd)
|
|
273
393
|
runHook(task_cfg.hooks.after_start, task_name)
|
|
274
394
|
|
|
275
395
|
print(f"Restarted task '{task_name}'")
|
|
@@ -280,7 +400,7 @@ class TmuxManager:
|
|
|
280
400
|
print(f"Session '{self.config.name}' doesn't exist")
|
|
281
401
|
return
|
|
282
402
|
|
|
283
|
-
window = self._get_session().windows.get(window_name=task_name)
|
|
403
|
+
window = self._get_session().windows.get(window_name=task_name, default=None)
|
|
284
404
|
if window:
|
|
285
405
|
window.kill()
|
|
286
406
|
print(f"Killed task '{task_name}'")
|
|
@@ -297,6 +417,9 @@ class TmuxManager:
|
|
|
297
417
|
"name": task_name,
|
|
298
418
|
"command": task_cfg.command,
|
|
299
419
|
"auto_start": task_cfg.auto_start,
|
|
420
|
+
"cwd": task_cfg.cwd,
|
|
421
|
+
"health_check": task_cfg.health_check,
|
|
422
|
+
"depends_on": task_cfg.depends_on,
|
|
300
423
|
"running": False,
|
|
301
424
|
"healthy": False,
|
|
302
425
|
"pid": None,
|
|
@@ -310,7 +433,7 @@ class TmuxManager:
|
|
|
310
433
|
return info
|
|
311
434
|
|
|
312
435
|
sess = self._get_session()
|
|
313
|
-
window = sess.windows.get(window_name=task_name)
|
|
436
|
+
window = sess.windows.get(window_name=task_name, default=None)
|
|
314
437
|
if not window:
|
|
315
438
|
return info
|
|
316
439
|
|
|
@@ -324,37 +447,88 @@ class TmuxManager:
|
|
|
324
447
|
info["pane_current_command"] = getattr(pane, "pane_current_command", None)
|
|
325
448
|
info["pane_current_path"] = getattr(pane, "pane_current_path", None)
|
|
326
449
|
|
|
327
|
-
|
|
328
|
-
info["healthy"] = current_cmd != "" and current_cmd != "bash"
|
|
329
|
-
|
|
450
|
+
info["healthy"] = self.is_task_healthy(task_name)
|
|
330
451
|
return info
|
|
331
452
|
|
|
453
|
+
def _tail_panes(
|
|
454
|
+
self,
|
|
455
|
+
panes: list[tuple[str, libtmux.Pane, str]],
|
|
456
|
+
lines: int = 100,
|
|
457
|
+
grep: str | None = None,
|
|
458
|
+
) -> None:
|
|
459
|
+
"""Poll capture-pane and print new lines with colored task prefixes."""
|
|
460
|
+
console = Console()
|
|
461
|
+
state: dict[str, list[str]] = {}
|
|
462
|
+
|
|
463
|
+
try:
|
|
464
|
+
while True:
|
|
465
|
+
for task_name, pane, color in panes:
|
|
466
|
+
output = pane.cmd("capture-pane", "-p", "-S", f"-{lines}").stdout
|
|
467
|
+
while output and not output[-1].strip():
|
|
468
|
+
output.pop()
|
|
469
|
+
|
|
470
|
+
prev = state.get(task_name, [])
|
|
471
|
+
new = _find_new_lines(output, prev)
|
|
472
|
+
|
|
473
|
+
if grep:
|
|
474
|
+
new = [ln for ln in new if grep.lower() in ln.lower()]
|
|
475
|
+
|
|
476
|
+
for line in new:
|
|
477
|
+
prefix = escape(f"[{task_name}]")
|
|
478
|
+
console.print(f"[{color}]{prefix}[/{color}] {escape(line)}")
|
|
479
|
+
|
|
480
|
+
if output:
|
|
481
|
+
state[task_name] = output[-50:]
|
|
482
|
+
|
|
483
|
+
time.sleep(0.5)
|
|
484
|
+
except KeyboardInterrupt:
|
|
485
|
+
console.print("\n[dim]Stopped following logs[/dim]")
|
|
486
|
+
|
|
487
|
+
def _collect_panes(self, task_names: list[str]) -> list[tuple[str, libtmux.Pane, str]]:
|
|
488
|
+
"""Collect (name, pane, color) tuples for running tasks."""
|
|
489
|
+
sess = self._get_session()
|
|
490
|
+
result: list[tuple[str, libtmux.Pane, str]] = []
|
|
491
|
+
for i, name in enumerate(task_names):
|
|
492
|
+
window = sess.windows.get(window_name=name, default=None)
|
|
493
|
+
if not window:
|
|
494
|
+
continue
|
|
495
|
+
pane = window.active_pane
|
|
496
|
+
if pane:
|
|
497
|
+
color = TASK_COLORS[i % len(TASK_COLORS)]
|
|
498
|
+
result.append((name, pane, color))
|
|
499
|
+
return result
|
|
500
|
+
|
|
332
501
|
def show_logs(
|
|
333
502
|
self,
|
|
334
|
-
task_name: str,
|
|
503
|
+
task_name: str | None,
|
|
335
504
|
follow: bool = False,
|
|
336
505
|
lines: int = 100,
|
|
337
506
|
grep: str | None = None,
|
|
338
507
|
context: int = 3,
|
|
339
508
|
) -> None:
|
|
340
|
-
"""Show logs for a task
|
|
509
|
+
"""Show logs for a task or all tasks."""
|
|
341
510
|
if not self.session_exists():
|
|
342
511
|
print(f"Session '{self.config.name}' doesn't exist")
|
|
343
512
|
return
|
|
344
513
|
|
|
514
|
+
if task_name is None:
|
|
515
|
+
self.show_all_logs(follow=follow, lines=lines, grep=grep, context=context)
|
|
516
|
+
return
|
|
517
|
+
|
|
345
518
|
if task_name not in self.config.tasks:
|
|
346
519
|
print(f"Task '{task_name}' not found in config")
|
|
347
520
|
return
|
|
348
521
|
|
|
349
522
|
sess = self._get_session()
|
|
350
|
-
window = sess.windows.get(window_name=task_name)
|
|
523
|
+
window = sess.windows.get(window_name=task_name, default=None)
|
|
351
524
|
if not window:
|
|
352
525
|
print(f"Task '{task_name}' not found")
|
|
353
526
|
return
|
|
354
527
|
|
|
355
528
|
if follow:
|
|
356
|
-
|
|
357
|
-
|
|
529
|
+
panes = self._collect_panes([task_name])
|
|
530
|
+
if panes:
|
|
531
|
+
self._tail_panes(panes, lines=lines, grep=grep)
|
|
358
532
|
else:
|
|
359
533
|
pane = window.active_pane
|
|
360
534
|
if pane:
|
|
@@ -365,6 +539,43 @@ class TmuxManager:
|
|
|
365
539
|
for line in output:
|
|
366
540
|
print(line)
|
|
367
541
|
|
|
542
|
+
def show_all_logs(
|
|
543
|
+
self,
|
|
544
|
+
follow: bool = False,
|
|
545
|
+
lines: int = 100,
|
|
546
|
+
grep: str | None = None,
|
|
547
|
+
context: int = 3,
|
|
548
|
+
) -> None:
|
|
549
|
+
"""Show logs from all running tasks."""
|
|
550
|
+
sess = self._get_session()
|
|
551
|
+
console = Console()
|
|
552
|
+
task_names = list(self.config.tasks.keys())
|
|
553
|
+
|
|
554
|
+
if follow:
|
|
555
|
+
panes = self._collect_panes(task_names)
|
|
556
|
+
if panes:
|
|
557
|
+
self._tail_panes(panes, lines=lines, grep=grep)
|
|
558
|
+
return
|
|
559
|
+
|
|
560
|
+
for i, task_name in enumerate(task_names):
|
|
561
|
+
window = sess.windows.get(window_name=task_name, default=None)
|
|
562
|
+
if not window:
|
|
563
|
+
continue
|
|
564
|
+
pane = window.active_pane
|
|
565
|
+
if not pane:
|
|
566
|
+
continue
|
|
567
|
+
color = TASK_COLORS[i % len(TASK_COLORS)]
|
|
568
|
+
output = pane.cmd("capture-pane", "-p", "-S", f"-{lines}").stdout
|
|
569
|
+
if grep:
|
|
570
|
+
matching = [line for line in output if grep.lower() in line.lower()]
|
|
571
|
+
for line in matching:
|
|
572
|
+
prefix = escape(f"[{task_name}]")
|
|
573
|
+
console.print(f"[{color}]{prefix}[/{color}] {escape(line)}")
|
|
574
|
+
else:
|
|
575
|
+
for line in output:
|
|
576
|
+
prefix = escape(f"[{task_name}]")
|
|
577
|
+
console.print(f"[{color}]{prefix}[/{color}] {escape(line)}")
|
|
578
|
+
|
|
368
579
|
def list_tasks(self) -> None:
|
|
369
580
|
"""List all tasks and their status"""
|
|
370
581
|
print(f"Session: {self.config.name}")
|
|
@@ -381,7 +592,12 @@ class TmuxManager:
|
|
|
381
592
|
"Healthy" if status["healthy"] else "Running" if status["running"] else "Stopped"
|
|
382
593
|
)
|
|
383
594
|
auto = "" if task_cfg.auto_start else " [manual]"
|
|
384
|
-
|
|
595
|
+
extras = ""
|
|
596
|
+
if task_cfg.cwd:
|
|
597
|
+
extras += f" cwd={task_cfg.cwd}"
|
|
598
|
+
if task_cfg.depends_on:
|
|
599
|
+
extras += f" deps=[{','.join(task_cfg.depends_on)}]"
|
|
600
|
+
print(f"{health_icon} {status_text:8} {task_name:15} {task_cfg.command}{auto}{extras}")
|
|
385
601
|
|
|
386
602
|
def show_status(self) -> None:
|
|
387
603
|
"""Show overall session status"""
|
|
@@ -394,9 +610,9 @@ class TmuxManager:
|
|
|
394
610
|
self.list_tasks()
|
|
395
611
|
|
|
396
612
|
def check_task_health(self, task_name: str) -> bool:
|
|
397
|
-
"""Check if a task is healthy
|
|
613
|
+
"""Check if a task is healthy"""
|
|
614
|
+
is_healthy = self.is_task_healthy(task_name)
|
|
398
615
|
status = self.get_task_status(task_name)
|
|
399
|
-
is_healthy = bool(status["running"] and status["healthy"])
|
|
400
616
|
|
|
401
617
|
self.task_health[task_name] = {
|
|
402
618
|
"healthy": is_healthy,
|
taskmux-0.2.3/taskmux/models.py
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
"""Pydantic models for Taskmux configuration."""
|
|
2
|
-
|
|
3
|
-
import warnings
|
|
4
|
-
|
|
5
|
-
from pydantic import BaseModel, ConfigDict, model_validator
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class _StrictConfig(BaseModel):
|
|
9
|
-
"""Base config: frozen, warns on unknown keys."""
|
|
10
|
-
|
|
11
|
-
model_config = ConfigDict(frozen=True)
|
|
12
|
-
|
|
13
|
-
@model_validator(mode="before")
|
|
14
|
-
@classmethod
|
|
15
|
-
def _warn_unknown_keys(cls, values: dict) -> dict:
|
|
16
|
-
if not isinstance(values, dict):
|
|
17
|
-
return values
|
|
18
|
-
known = set(cls.model_fields.keys())
|
|
19
|
-
unknown = set(values.keys()) - known
|
|
20
|
-
for key in sorted(unknown):
|
|
21
|
-
warnings.warn(f"Unknown config key: {key!r}", UserWarning, stacklevel=2)
|
|
22
|
-
return values
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class HookConfig(_StrictConfig):
|
|
26
|
-
"""Lifecycle hooks for tasks or global config."""
|
|
27
|
-
|
|
28
|
-
before_start: str | None = None
|
|
29
|
-
after_start: str | None = None
|
|
30
|
-
before_stop: str | None = None
|
|
31
|
-
after_stop: str | None = None
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
class TaskConfig(_StrictConfig):
|
|
35
|
-
"""Single task definition."""
|
|
36
|
-
|
|
37
|
-
command: str
|
|
38
|
-
auto_start: bool = True
|
|
39
|
-
hooks: HookConfig = HookConfig()
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
class TaskmuxConfig(_StrictConfig):
|
|
43
|
-
"""Top-level taskmux.toml schema."""
|
|
44
|
-
|
|
45
|
-
name: str = "taskmux"
|
|
46
|
-
auto_start: bool = True
|
|
47
|
-
hooks: HookConfig = HookConfig()
|
|
48
|
-
tasks: dict[str, TaskConfig] = {}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|