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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taskmux
3
- Version: 0.2.3
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 <task> # Show recent logs
128
- taskmux logs -f <task> # Follow logs live
129
- taskmux logs -n 100 <task> # Last N lines
130
- taskmux logs <task> -g "error" # Search logs with grep
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": "server",
216
- "command": "npm run dev",
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": "node",
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 <task> # Show recent logs
93
- taskmux logs -f <task> # Follow logs live
94
- taskmux logs -n 100 <task> # Last N lines
95
- taskmux logs <task> -g "error" # Search logs with grep
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": "server",
181
- "command": "npm run dev",
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": "node",
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
  }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "taskmux"
3
- version = "0.2.3"
3
+ version = "0.2.5"
4
4
  description = "Modern tmux-based task manager for LLM development tools"
5
5
  readme = "README.md"
6
6
  license-file = "LICENSE"
@@ -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(..., help="Task name"),
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(path: Path | None, name: str, command: str) -> TaskmuxConfig:
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
- new_tasks[name] = TaskConfig(command=command)
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 self.session and status["running"]:
68
- try:
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
- window = sess.new_window(attach=False, window_name=task_name)
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 (or create session if global auto_start=False)."""
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 = [(name, cfg) for name, cfg in self.config.tasks.items() if cfg.auto_start]
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
- # First task reuses default window
187
- first_name, first_cfg = auto_tasks[0]
188
- runHook(first_cfg.hooks.before_start, first_name)
189
- if sess.windows:
190
- default_window = sess.windows[0]
191
- default_window.rename_window(first_name)
192
- pane = default_window.active_pane
193
- if pane:
194
- pane.send_keys(first_cfg.command, enter=True)
195
- runHook(first_cfg.hooks.after_start, first_name)
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
- self._send_command_to_window(sess, task_name, task_cfg.command)
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
- current_cmd = info["pane_current_command"] or ""
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, optionally filtering with grep."""
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
- window.select_window()
357
- sess.attach()
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
- print(f"{health_icon} {status_text:8} {task_name:15} {task_cfg.command}{auto}")
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 (process still running)"""
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,
@@ -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