workstreams-cli 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- workstreams/__init__.py +71 -0
- workstreams/cli.py +499 -0
- workstreams/config.py +227 -0
- workstreams/dashboard.py +341 -0
- workstreams/event_log.py +240 -0
- workstreams/manager.py +573 -0
- workstreams/models.py +121 -0
- workstreams/multiplexer/__init__.py +32 -0
- workstreams/multiplexer/base.py +44 -0
- workstreams/multiplexer/tmux.py +202 -0
- workstreams/multiplexer/tmux_compatible.py +22 -0
- workstreams/multiplexer/zellij.py +109 -0
- workstreams/notifier.py +147 -0
- workstreams/py.typed +0 -0
- workstreams/subagent_client.py +125 -0
- workstreams_cli-0.5.0.dist-info/METADATA +622 -0
- workstreams_cli-0.5.0.dist-info/RECORD +20 -0
- workstreams_cli-0.5.0.dist-info/WHEEL +5 -0
- workstreams_cli-0.5.0.dist-info/entry_points.txt +2 -0
- workstreams_cli-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Terminal multiplexer implementations."""
|
|
2
|
+
"""Terminal multiplexer implementations."""
|
|
3
|
+
|
|
4
|
+
from .base import MultiplexerBase
|
|
5
|
+
from .tmux import TmuxMultiplexer
|
|
6
|
+
from .zellij import ZellijMultiplexer
|
|
7
|
+
from .tmux_compatible import TmuxCompatibleMultiplexer
|
|
8
|
+
|
|
9
|
+
__all__ = ["MultiplexerBase", "TmuxMultiplexer", "ZellijMultiplexer", "get_multiplexer"]
|
|
10
|
+
|
|
11
|
+
_TMUX_COMPATIBLE = {
|
|
12
|
+
"nami": "nami",
|
|
13
|
+
"lmux": "lmux",
|
|
14
|
+
"wmux": "wmux",
|
|
15
|
+
"herdr": "herdr",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
def get_multiplexer(name: str, config) -> MultiplexerBase:
|
|
19
|
+
"""Get a multiplexer instance by name."""
|
|
20
|
+
multiplexers = {
|
|
21
|
+
"tmux": TmuxMultiplexer,
|
|
22
|
+
"zellij": ZellijMultiplexer,
|
|
23
|
+
}
|
|
24
|
+
if name in _TMUX_COMPATIBLE:
|
|
25
|
+
binary = _TMUX_COMPATIBLE[name]
|
|
26
|
+
return TmuxCompatibleMultiplexer(config, binary)
|
|
27
|
+
cls = multiplexers.get(name)
|
|
28
|
+
if not cls:
|
|
29
|
+
raise ValueError(
|
|
30
|
+
f"Unknown multiplexer: {name}. Supported: {', '.join(multiplexers)} plus tmux-compatible: {', '.join(_TMUX_COMPATIBLE)}"
|
|
31
|
+
)
|
|
32
|
+
return cls(config)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Base multiplexer interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MultiplexerBase(ABC):
|
|
10
|
+
"""Base class for terminal multiplexers."""
|
|
11
|
+
|
|
12
|
+
#: Multiplexer name (used in logs / status)
|
|
13
|
+
name = "base"
|
|
14
|
+
|
|
15
|
+
def __init__(self, config: Any):
|
|
16
|
+
self.config = config
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def start(self, workstreams: List[Dict[str, Any]], command: Optional[str] = None) -> None:
|
|
20
|
+
"""Start workstreams in multiplexer."""
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def attach(self, session: str) -> None:
|
|
24
|
+
"""Attach to existing session (blocks until user exits)."""
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def send_command(self, workstream_id: int, command: str) -> bool:
|
|
28
|
+
"""Send a command to the workstream's pane/tab."""
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def session_name(self) -> str:
|
|
32
|
+
"""Return the session name used by start()."""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def pane_target(self, workstream_id: int) -> str:
|
|
36
|
+
"""Return the pane target for a workstream (used for send_command)."""
|
|
37
|
+
|
|
38
|
+
def is_available(self) -> bool:
|
|
39
|
+
"""Check if the multiplexer binary exists on PATH."""
|
|
40
|
+
import shutil
|
|
41
|
+
return shutil.which(self.binary_name()) is not None
|
|
42
|
+
|
|
43
|
+
def binary_name(self) -> str:
|
|
44
|
+
return self.name
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Tmux multiplexer implementation.
|
|
2
|
+
|
|
3
|
+
Addressing model (verified against tmux 3.4):
|
|
4
|
+
- Each workstream gets its own tmux WINDOW named after the workstream
|
|
5
|
+
(`new-window -n <name>`). Windows can be targeted by NAME:
|
|
6
|
+
`<session>:<window-name>`.
|
|
7
|
+
- Each window hosts a persistent `bash -i` so the detached session never
|
|
8
|
+
dies when its startup command exits.
|
|
9
|
+
- In the "tiled" layout all workstreams live in one window as panes and
|
|
10
|
+
are targeted by index: `<session>:1.<pane-index>`.
|
|
11
|
+
|
|
12
|
+
NOTE: `select-pane -T` only sets a *display title* in tmux; it does NOT
|
|
13
|
+
make a pane addressable by that title. Window names (via `new-window -n`
|
|
14
|
+
/ `rename-window`) are the reliable target mechanism.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import subprocess
|
|
20
|
+
from typing import Any, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
from .base import MultiplexerBase
|
|
23
|
+
|
|
24
|
+
# Start a persistent interactive shell in every window/pane so detached
|
|
25
|
+
# sessions stay alive after their first command finishes.
|
|
26
|
+
_PERSIST_SHELL = "exec bash -i"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TmuxMultiplexer(MultiplexerBase):
|
|
30
|
+
"""Tmux-based workstream management."""
|
|
31
|
+
|
|
32
|
+
name = "tmux"
|
|
33
|
+
|
|
34
|
+
def __init__(self, config: Any):
|
|
35
|
+
super().__init__(config)
|
|
36
|
+
self.session = f"workstreams-{config.project}"
|
|
37
|
+
|
|
38
|
+
# -- helpers -----------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
def _tmux(self, *args: str, check: bool = False, capture: bool = True) -> subprocess.CompletedProcess:
|
|
41
|
+
cmd = ["tmux"] + list(args)
|
|
42
|
+
if capture:
|
|
43
|
+
return subprocess.run(cmd, check=check, capture_output=True, text=True)
|
|
44
|
+
return subprocess.run(cmd, check=check, text=True)
|
|
45
|
+
|
|
46
|
+
def _run_quiet(self, *args: str) -> None:
|
|
47
|
+
"""Run a tmux command, swallowing non-zero exits (best-effort layout)."""
|
|
48
|
+
self._tmux(*args)
|
|
49
|
+
|
|
50
|
+
def _ensure_session(self, base_dir: str) -> None:
|
|
51
|
+
"""Create the session if it does not exist yet."""
|
|
52
|
+
result = self._tmux("has-session", "-t", self.session)
|
|
53
|
+
if result.returncode != 0:
|
|
54
|
+
create = self._tmux(
|
|
55
|
+
"new-session", "-d", "-s", self.session, "-c", base_dir,
|
|
56
|
+
_PERSIST_SHELL,
|
|
57
|
+
)
|
|
58
|
+
if create.returncode != 0:
|
|
59
|
+
raise RuntimeError(
|
|
60
|
+
f"failed to create tmux session '{self.session}': "
|
|
61
|
+
f"{(create.stderr or '').strip() or create.stdout.strip()}"
|
|
62
|
+
)
|
|
63
|
+
# Warm the server / confirm session is up.
|
|
64
|
+
self._tmux("list-windows", "-t", self.session)
|
|
65
|
+
|
|
66
|
+
def _session_alive(self) -> bool:
|
|
67
|
+
return self._tmux("has-session", "-t", self.session).returncode == 0
|
|
68
|
+
|
|
69
|
+
def _window_target(self, window_name: str) -> str:
|
|
70
|
+
"""Target a window by its name."""
|
|
71
|
+
return f"{self.session}:{window_name}"
|
|
72
|
+
|
|
73
|
+
def session_name(self) -> str:
|
|
74
|
+
return self.session
|
|
75
|
+
|
|
76
|
+
def pane_target(self, workstream_id: int) -> str:
|
|
77
|
+
"""Return the target for a workstream.
|
|
78
|
+
|
|
79
|
+
Tab/window layout: target by window NAME -> `<session>:<ws-name>`.
|
|
80
|
+
Tiled layout: target by pane index -> `<session>:0.<pane-index>`.
|
|
81
|
+
"""
|
|
82
|
+
ws = self.config.workstream(workstream_id)
|
|
83
|
+
name = ws.name if ws else f"ws{workstream_id}"
|
|
84
|
+
if self.config.layout in ("tiled", "grid"):
|
|
85
|
+
idx = workstream_id - 1
|
|
86
|
+
return f"{self.session}:0.{idx}"
|
|
87
|
+
# Window-per-workstream: each workstream is a window named after it.
|
|
88
|
+
return self._window_target(name)
|
|
89
|
+
|
|
90
|
+
# -- required interface --------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def start(self, workstreams: List[Dict[str, Any]], command: Optional[str] = None) -> None:
|
|
93
|
+
base_dir = self.config.base_path
|
|
94
|
+
self._ensure_session(base_dir)
|
|
95
|
+
|
|
96
|
+
layout = self.config.layout
|
|
97
|
+
if layout in ("tiled", "grid"):
|
|
98
|
+
self._layout_single_window_panes(workstreams, base_dir)
|
|
99
|
+
else:
|
|
100
|
+
self._layout_tab_windows(workstreams, base_dir)
|
|
101
|
+
|
|
102
|
+
# Send each command to its window/pane.
|
|
103
|
+
for i, ws in enumerate(workstreams):
|
|
104
|
+
target = self._pane_target_for_index(i, layout)
|
|
105
|
+
ws_command = command or ws.get("command") or ""
|
|
106
|
+
if ws_command:
|
|
107
|
+
ws_path = ws.get("path", "")
|
|
108
|
+
pane_env = ws.get("env") or {}
|
|
109
|
+
env_prefix = " ".join(f"{k}={v}" for k, v in pane_env.items())
|
|
110
|
+
cd_part = f"cd {base_dir}/{ws_path} && " if ws_path else ""
|
|
111
|
+
full = f"{cd_part}{env_prefix} {ws_command}".strip()
|
|
112
|
+
self._run_quiet("send-keys", "-t", target, full, "Enter")
|
|
113
|
+
|
|
114
|
+
print(f"Started {len(workstreams)} workstream(s) in tmux session '{self.session}'")
|
|
115
|
+
print(f" Attach: tmux attach -t {self.session}")
|
|
116
|
+
print(f" Or: workstreams attach --multiplexer tmux")
|
|
117
|
+
|
|
118
|
+
# -- layouts --------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def _layout_tab_windows(self, workstreams: List[Dict[str, Any]], base_dir: str) -> None:
|
|
121
|
+
"""One window per workstream - cleanest for coding agents."""
|
|
122
|
+
first = True
|
|
123
|
+
for i, ws in enumerate(workstreams):
|
|
124
|
+
ws_name = ws.get("name", f"ws{ws.get('id', i + 1)}")
|
|
125
|
+
ws_path = f"{base_dir}/{ws.get('path', '')}"
|
|
126
|
+
if first:
|
|
127
|
+
# Reuse the initial window: rename it to the first workstream.
|
|
128
|
+
# Window indices start at 0. The session was created with a
|
|
129
|
+
# single initial window at index 0, so target ":0".
|
|
130
|
+
self._run_quiet("rename-window", "-t", f"{self.session}:0", ws_name)
|
|
131
|
+
self._run_quiet("send-keys", "-t", f"{self.session}:0", f"cd {ws_path}", "Enter")
|
|
132
|
+
first = False
|
|
133
|
+
else:
|
|
134
|
+
self._run_quiet(
|
|
135
|
+
"new-window", "-t", self.session,
|
|
136
|
+
"-n", ws_name, "-c", ws_path, _PERSIST_SHELL,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def _layout_single_window_panes(self, workstreams: List[Dict[str, Any]], base_dir: str) -> None:
|
|
140
|
+
"""All workstreams in one window as tiled panes."""
|
|
141
|
+
for i, ws in enumerate(workstreams):
|
|
142
|
+
ws_name = ws.get("name", f"ws{ws.get('id', i + 1)}")
|
|
143
|
+
ws_path = f"{base_dir}/{ws.get('path', '')}"
|
|
144
|
+
if i == 0:
|
|
145
|
+
# First pane already exists; just cd it.
|
|
146
|
+
self._run_quiet("send-keys", "-t", f"{self.session}:0.0", f"cd {ws_path}", "Enter")
|
|
147
|
+
_ = ws_name
|
|
148
|
+
else:
|
|
149
|
+
split = "-h" if i % 2 == 1 else "-v"
|
|
150
|
+
self._run_quiet(
|
|
151
|
+
"split-window", "-t", f"{self.session}:0", split,
|
|
152
|
+
"-c", ws_path, _PERSIST_SHELL,
|
|
153
|
+
)
|
|
154
|
+
self._run_quiet("select-layout", "-t", f"{self.session}:0", "tiled")
|
|
155
|
+
|
|
156
|
+
def _pane_target_for_index(self, i: int, layout: str) -> str:
|
|
157
|
+
"""Target the i-th workstream given the layout."""
|
|
158
|
+
if layout in ("tiled", "grid"):
|
|
159
|
+
return f"{self.session}:0.{i}"
|
|
160
|
+
# Window-per-workstream: window NAMES equal the workstream name.
|
|
161
|
+
# Resolve the name from the config so we always hit the right window.
|
|
162
|
+
ws = self.config.workstreams[i] if i < len(self.config.workstreams) else None
|
|
163
|
+
name = ws.name if ws else f"ws{i + 1}"
|
|
164
|
+
return self._window_target(name)
|
|
165
|
+
|
|
166
|
+
# -- lifecycle -------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
def attach(self, session: Optional[str] = None) -> None:
|
|
169
|
+
target = session or self.session
|
|
170
|
+
result = self._tmux("attach", "-t", target, capture=False, check=False)
|
|
171
|
+
# attach blocks until the user detaches.
|
|
172
|
+
_ = result
|
|
173
|
+
|
|
174
|
+
def send_command(self, workstream_id: int, command: str) -> bool:
|
|
175
|
+
target = self.pane_target(workstream_id)
|
|
176
|
+
if not self._session_alive():
|
|
177
|
+
print(
|
|
178
|
+
f"[workstreams] tmux session '{self.session}' is not running. "
|
|
179
|
+
"Run `workstreams start` first.",
|
|
180
|
+
flush=True,
|
|
181
|
+
)
|
|
182
|
+
return False
|
|
183
|
+
result = self._tmux("send-keys", "-t", target, command, "Enter")
|
|
184
|
+
return result.returncode == 0
|
|
185
|
+
|
|
186
|
+
def capture(self, workstream_id: int, lines: int = 20) -> str:
|
|
187
|
+
"""Return the last `lines` of a workstream pane's output."""
|
|
188
|
+
target = self.pane_target(workstream_id)
|
|
189
|
+
result = self._tmux("capture-pane", "-t", target, "-p", "-S", f"-{lines}")
|
|
190
|
+
return result.stdout.strip()
|
|
191
|
+
|
|
192
|
+
def is_running(self) -> bool:
|
|
193
|
+
return self._session_alive()
|
|
194
|
+
|
|
195
|
+
def list_windows(self) -> List[str]:
|
|
196
|
+
result = self._tmux("list-windows", "-t", self.session)
|
|
197
|
+
if result.returncode != 0:
|
|
198
|
+
return []
|
|
199
|
+
return [w for w in result.stdout.strip().split("\n") if w]
|
|
200
|
+
|
|
201
|
+
def kill(self) -> None:
|
|
202
|
+
self._run_quiet("kill-session", "-t", self.session)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Tmux-compatible multiplexer wrappers (nami, lmux, wmux, herdr)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import Any
|
|
5
|
+
from .tmux import TmuxMultiplexer
|
|
6
|
+
|
|
7
|
+
class TmuxCompatibleMultiplexer(TmuxMultiplexer):
|
|
8
|
+
"""Wrapper for tmux-compatible multiplexers that use the same CLI semantics."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, config: Any, binary: str):
|
|
11
|
+
super().__init__(config)
|
|
12
|
+
self._binary = binary
|
|
13
|
+
|
|
14
|
+
def binary_name(self) -> str:
|
|
15
|
+
return self._binary
|
|
16
|
+
|
|
17
|
+
def _tmux(self, *args: str, check: bool = False, capture: bool = True):
|
|
18
|
+
import subprocess
|
|
19
|
+
cmd = [self._binary] + list(args)
|
|
20
|
+
if capture:
|
|
21
|
+
return subprocess.run(cmd, check=check, capture_output=True, text=True)
|
|
22
|
+
return subprocess.run(cmd, check=check, text=True)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Zellij multiplexer implementation.
|
|
2
|
+
|
|
3
|
+
Zellij uses tabs; each workstream gets its own tab. Zellij's scripting
|
|
4
|
+
surface is less mature than tmux's, so we use `zellij action` and
|
|
5
|
+
`zellij run` where available.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from .base import MultiplexerBase
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ZellijMultiplexer(MultiplexerBase):
|
|
18
|
+
"""Zellij-based workstream management."""
|
|
19
|
+
|
|
20
|
+
name = "zellij"
|
|
21
|
+
|
|
22
|
+
def __init__(self, config: Any):
|
|
23
|
+
super().__init__(config)
|
|
24
|
+
self.session = f"workstreams-{config.project}"
|
|
25
|
+
|
|
26
|
+
def _zj(self, *args: str, check: bool = False, capture: bool = True) -> subprocess.CompletedProcess:
|
|
27
|
+
cmd = ["zellij"] + list(args)
|
|
28
|
+
if capture:
|
|
29
|
+
return subprocess.run(cmd, check=check, capture_output=True, text=True)
|
|
30
|
+
return subprocess.run(cmd, check=check, text=True)
|
|
31
|
+
|
|
32
|
+
def session_name(self) -> str:
|
|
33
|
+
return self.session
|
|
34
|
+
|
|
35
|
+
def pane_target(self, workstream_id: int) -> str:
|
|
36
|
+
ws = self.config.workstream(workstream_id)
|
|
37
|
+
return ws.name if ws else f"ws{workstream_id}"
|
|
38
|
+
|
|
39
|
+
def start(self, workstreams: List[Dict[str, Any]], command: Optional[str] = None) -> None:
|
|
40
|
+
base_dir = self.config.base_path
|
|
41
|
+
|
|
42
|
+
# Create (or attach to) the session in the background
|
|
43
|
+
result = self._zj(
|
|
44
|
+
"attach", self.session, "--create",
|
|
45
|
+
"--", "sleep", "999999",
|
|
46
|
+
check=False,
|
|
47
|
+
)
|
|
48
|
+
_ = result
|
|
49
|
+
|
|
50
|
+
for i, ws in enumerate(workstreams):
|
|
51
|
+
ws_name = ws.get("name", f"ws{ws.get('id', i + 1)}")
|
|
52
|
+
# Create a tab per workstream
|
|
53
|
+
self._zj("action", "new-tab", "--name", ws_name, check=False)
|
|
54
|
+
ws_path = f"{base_dir}/{ws.get('path', '')}"
|
|
55
|
+
ws_command = command or ws.get("command") or ""
|
|
56
|
+
if ws_command:
|
|
57
|
+
pane_env = ws.get("env") or {}
|
|
58
|
+
env_prefix = " ".join(f"{k}={v}" for k, v in pane_env.items())
|
|
59
|
+
full = f"cd {ws_path} && {env_prefix} {ws_command}".strip()
|
|
60
|
+
self._zj("run", "--", "bash", "-lc", full, check=False)
|
|
61
|
+
|
|
62
|
+
print(f"Started {len(workstreams)} workstream(s) in zellij session '{self.session}'")
|
|
63
|
+
print(f" Attach: zellij attach {self.session}")
|
|
64
|
+
|
|
65
|
+
def attach(self, session: Optional[str] = None) -> None:
|
|
66
|
+
self._zj("attach", session or self.session, check=False, capture=False)
|
|
67
|
+
|
|
68
|
+
def send_command(self, workstream_id: int, command: str) -> bool:
|
|
69
|
+
"""Zellij has no simple `send-keys` equivalent; use `zellij run`.
|
|
70
|
+
|
|
71
|
+
This runs the command in the *current* tab of the session. For
|
|
72
|
+
per-tab targeting you'd need the Zellij RPC API; for now we log
|
|
73
|
+
a notice and run in the foreground tab.
|
|
74
|
+
"""
|
|
75
|
+
import sys
|
|
76
|
+
print(
|
|
77
|
+
f"[workstreams] zellij: send_command targets current tab only; "
|
|
78
|
+
f"run: {command}",
|
|
79
|
+
file=sys.stderr,
|
|
80
|
+
)
|
|
81
|
+
self._zj("run", "--", "bash", "-lc", command, check=False)
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
def capture(self, lines: int = 20) -> List[str]:
|
|
85
|
+
"""Read the last N lines from the workstream's log file.
|
|
86
|
+
|
|
87
|
+
Zellij has no built-in pane-capture CLI like `tmux capture-pane`,
|
|
88
|
+
so we read from the per-workstream log file instead. This is the
|
|
89
|
+
most reliable cross-platform source and works even when the
|
|
90
|
+
zellij session is not attached.
|
|
91
|
+
"""
|
|
92
|
+
import os
|
|
93
|
+
log_file = os.environ.get("WORKSTREAMS_ZELLIJ_LOG")
|
|
94
|
+
if not log_file:
|
|
95
|
+
return []
|
|
96
|
+
try:
|
|
97
|
+
with open(log_file, "r", encoding="utf-8", errors="replace") as f:
|
|
98
|
+
all_lines = f.readlines()
|
|
99
|
+
return [ln.rstrip("\n") for ln in all_lines[-lines:]]
|
|
100
|
+
except OSError:
|
|
101
|
+
return []
|
|
102
|
+
|
|
103
|
+
def is_available(self) -> bool:
|
|
104
|
+
import shutil
|
|
105
|
+
return shutil.which("zellij") is not None
|
|
106
|
+
|
|
107
|
+
def is_running(self) -> bool:
|
|
108
|
+
result = self._zj("list-sessions", check=False)
|
|
109
|
+
return self.session in (result.stdout or "")
|
workstreams/notifier.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Cross-terminal notifications (desktop + file-based for other workstreams)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import datetime, UTC
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from .event_log import default_log_dir
|
|
15
|
+
|
|
16
|
+
NOTIFY_SENDERS = [
|
|
17
|
+
"notify-send",
|
|
18
|
+
"terminal-notify",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Notifier:
|
|
23
|
+
"""Send notifications to other terminals / desktops."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, project: str, log_dir: Optional[Path] = None):
|
|
26
|
+
self.project = project
|
|
27
|
+
self.notify_dir = Path(log_dir) if log_dir else default_log_dir(project)
|
|
28
|
+
self.notify_dir.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
self.notify_file = self.notify_dir / "notifications.jsonl"
|
|
30
|
+
|
|
31
|
+
def send(self, title: str, message: str, urgency: str = "normal") -> bool:
|
|
32
|
+
"""Send notification via desktop notification and file."""
|
|
33
|
+
sent = False
|
|
34
|
+
system = platform.system()
|
|
35
|
+
|
|
36
|
+
if system == "Darwin":
|
|
37
|
+
script = f'display notification {json.dumps(message)} with title {json.dumps(title)}'
|
|
38
|
+
try:
|
|
39
|
+
result = subprocess.run(
|
|
40
|
+
["osascript", "-e", script],
|
|
41
|
+
capture_output=True,
|
|
42
|
+
text=True,
|
|
43
|
+
timeout=5,
|
|
44
|
+
)
|
|
45
|
+
sent = result.returncode == 0
|
|
46
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
47
|
+
pass
|
|
48
|
+
elif system == "Windows":
|
|
49
|
+
# Windows: use PowerShell for desktop notification
|
|
50
|
+
ps_script = (
|
|
51
|
+
f"[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null; "
|
|
52
|
+
f"$n = New-Object System.Windows.Forms.NotifyIcon; "
|
|
53
|
+
f"$n.Icon = [System.Drawing.SystemIcons]::Information; "
|
|
54
|
+
f"$n.Visible = $true; "
|
|
55
|
+
f"$n.ShowBalloonTip(5000, {json.dumps(title)}, {json.dumps(message)}, 'Info')"
|
|
56
|
+
)
|
|
57
|
+
try:
|
|
58
|
+
subprocess.run(
|
|
59
|
+
["powershell", "-Command", ps_script],
|
|
60
|
+
capture_output=True,
|
|
61
|
+
text=True,
|
|
62
|
+
timeout=5,
|
|
63
|
+
)
|
|
64
|
+
sent = True
|
|
65
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
66
|
+
pass
|
|
67
|
+
else:
|
|
68
|
+
# Linux: notify-send. Use --transient + --expire-time so the
|
|
69
|
+
# notification auto-dismisses instead of staying open. Critical
|
|
70
|
+
# urgency notifications are modal/blocking by spec — downgrade
|
|
71
|
+
# them and rely on expire-time so they don't pin forever.
|
|
72
|
+
effective_urgency = "normal" if urgency == "critical" else urgency
|
|
73
|
+
expire_ms = 15000 if urgency == "critical" else 8000
|
|
74
|
+
cmd = [
|
|
75
|
+
"notify-send",
|
|
76
|
+
"--transient",
|
|
77
|
+
"--expire-time", str(expire_ms),
|
|
78
|
+
"-u", effective_urgency,
|
|
79
|
+
title,
|
|
80
|
+
message,
|
|
81
|
+
]
|
|
82
|
+
try:
|
|
83
|
+
result = subprocess.run(
|
|
84
|
+
cmd,
|
|
85
|
+
capture_output=True,
|
|
86
|
+
text=True,
|
|
87
|
+
timeout=5,
|
|
88
|
+
)
|
|
89
|
+
sent = result.returncode == 0
|
|
90
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
91
|
+
# Fallback: terminal-notify writes to stdout of the terminal
|
|
92
|
+
try:
|
|
93
|
+
result = subprocess.run(
|
|
94
|
+
["terminal-notify", title, message],
|
|
95
|
+
capture_output=True,
|
|
96
|
+
text=True,
|
|
97
|
+
timeout=5,
|
|
98
|
+
)
|
|
99
|
+
sent = result.returncode == 0
|
|
100
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
# Always write to notification file for other instances to pick up
|
|
104
|
+
notif = {
|
|
105
|
+
"title": title,
|
|
106
|
+
"message": message,
|
|
107
|
+
"urgency": urgency,
|
|
108
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
109
|
+
}
|
|
110
|
+
try:
|
|
111
|
+
with open(self.notify_file, "a", encoding="utf-8") as f:
|
|
112
|
+
f.write(json.dumps(notif) + "\n")
|
|
113
|
+
sent = True
|
|
114
|
+
except OSError:
|
|
115
|
+
pass
|
|
116
|
+
return sent
|
|
117
|
+
|
|
118
|
+
def get_notifications(self, since: Optional[datetime] = None) -> List[Dict[str, Any]]:
|
|
119
|
+
"""Get pending notifications."""
|
|
120
|
+
if not self.notify_file.exists():
|
|
121
|
+
return []
|
|
122
|
+
notifs: List[Dict[str, Any]] = []
|
|
123
|
+
with open(self.notify_file, encoding="utf-8") as f:
|
|
124
|
+
for line in f:
|
|
125
|
+
line = line.strip()
|
|
126
|
+
if not line:
|
|
127
|
+
continue
|
|
128
|
+
try:
|
|
129
|
+
notif = json.loads(line)
|
|
130
|
+
if since is not None:
|
|
131
|
+
ts_raw = notif.get("timestamp", "")
|
|
132
|
+
try:
|
|
133
|
+
ts = datetime.fromisoformat(ts_raw)
|
|
134
|
+
if ts.tzinfo is None:
|
|
135
|
+
ts = ts.replace(tzinfo=UTC)
|
|
136
|
+
if ts < since:
|
|
137
|
+
continue
|
|
138
|
+
except ValueError:
|
|
139
|
+
pass
|
|
140
|
+
notifs.append(notif)
|
|
141
|
+
except json.JSONDecodeError:
|
|
142
|
+
continue
|
|
143
|
+
return notifs
|
|
144
|
+
|
|
145
|
+
def clear_notifications(self) -> None:
|
|
146
|
+
if self.notify_file.exists():
|
|
147
|
+
self.notify_file.unlink()
|
workstreams/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Subagent client helpers - report events from any process.
|
|
2
|
+
|
|
3
|
+
Import these from your subagent (a Claude Code session, a Codex session,
|
|
4
|
+
a script, a cron job...) so it can report progress to the main terminal:
|
|
5
|
+
|
|
6
|
+
from workstreams import subagent_progress
|
|
7
|
+
subagent_progress("myproject", 1, "claude-code", 42, "Fixed auth", {"files": 3})
|
|
8
|
+
|
|
9
|
+
Or use the one-liner CLI that needs no Python import at all:
|
|
10
|
+
|
|
11
|
+
workstreams event started --project myproject --workstream 1 \
|
|
12
|
+
--subagent claude-code --issue 42 --message "Fix auth"
|
|
13
|
+
|
|
14
|
+
All events land in ~/.workstreams/<project>/events.jsonl and show up in
|
|
15
|
+
`workstreams monitor`, `workstreams events`, and any other terminal.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any, Dict, Optional
|
|
21
|
+
|
|
22
|
+
from .models import SubagentEvent
|
|
23
|
+
from .event_log import get_event_log
|
|
24
|
+
|
|
25
|
+
# Subagent identifiers are free-form (claude-code, codex, opencode, qwen-code,
|
|
26
|
+
# mimocode, hermes, kilo-code, cline, ...). We do NOT restrict them: the whole
|
|
27
|
+
# point of workstreams is agent-agnostic dispatch.
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def subagent_report(
|
|
31
|
+
project: str,
|
|
32
|
+
workstream_id: int,
|
|
33
|
+
subagent: str,
|
|
34
|
+
issue: int,
|
|
35
|
+
event_type: str,
|
|
36
|
+
message: str,
|
|
37
|
+
data: Optional[Dict[str, Any]] = None,
|
|
38
|
+
) -> bool:
|
|
39
|
+
"""Report an arbitrary event (cross-process)."""
|
|
40
|
+
event_log = get_event_log(project)
|
|
41
|
+
event = SubagentEvent(
|
|
42
|
+
workstream_id=workstream_id,
|
|
43
|
+
subagent=subagent,
|
|
44
|
+
issue=issue,
|
|
45
|
+
event_type=event_type,
|
|
46
|
+
message=message,
|
|
47
|
+
data=data or {},
|
|
48
|
+
)
|
|
49
|
+
return event_log.append(event)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def subagent_started(
|
|
53
|
+
project: str,
|
|
54
|
+
workstream_id: int,
|
|
55
|
+
subagent: str,
|
|
56
|
+
issue: int,
|
|
57
|
+
prompt: str = "",
|
|
58
|
+
data: Optional[Dict[str, Any]] = None,
|
|
59
|
+
) -> bool:
|
|
60
|
+
"""Report subagent started."""
|
|
61
|
+
return subagent_report(
|
|
62
|
+
project, workstream_id, subagent, issue, "started",
|
|
63
|
+
f"{subagent} started on issue #{issue}" + (f": {prompt}" if prompt else ""),
|
|
64
|
+
{"prompt": prompt, **(data or {})},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def subagent_progress(
|
|
69
|
+
project: str,
|
|
70
|
+
workstream_id: int,
|
|
71
|
+
subagent: str,
|
|
72
|
+
issue: int,
|
|
73
|
+
message: str,
|
|
74
|
+
data: Optional[Dict[str, Any]] = None,
|
|
75
|
+
) -> bool:
|
|
76
|
+
"""Report subagent progress."""
|
|
77
|
+
return subagent_report(project, workstream_id, subagent, issue, "progress", message, data)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def subagent_completed(
|
|
81
|
+
project: str,
|
|
82
|
+
workstream_id: int,
|
|
83
|
+
subagent: str,
|
|
84
|
+
issue: int,
|
|
85
|
+
message: str,
|
|
86
|
+
data: Optional[Dict[str, Any]] = None,
|
|
87
|
+
) -> bool:
|
|
88
|
+
"""Report subagent completed."""
|
|
89
|
+
return subagent_report(project, workstream_id, subagent, issue, "completed", message, data)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def subagent_failed(
|
|
93
|
+
project: str,
|
|
94
|
+
workstream_id: int,
|
|
95
|
+
subagent: str,
|
|
96
|
+
issue: int,
|
|
97
|
+
message: str,
|
|
98
|
+
data: Optional[Dict[str, Any]] = None,
|
|
99
|
+
) -> bool:
|
|
100
|
+
"""Report subagent failed."""
|
|
101
|
+
return subagent_report(project, workstream_id, subagent, issue, "failed", message, data)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def subagent_error(
|
|
105
|
+
project: str,
|
|
106
|
+
workstream_id: int,
|
|
107
|
+
subagent: str,
|
|
108
|
+
issue: int,
|
|
109
|
+
message: str,
|
|
110
|
+
data: Optional[Dict[str, Any]] = None,
|
|
111
|
+
) -> bool:
|
|
112
|
+
"""Report a subagent error."""
|
|
113
|
+
return subagent_report(project, workstream_id, subagent, issue, "error", message, data)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def subagent_done(
|
|
117
|
+
project: str,
|
|
118
|
+
workstream_id: int,
|
|
119
|
+
subagent: str,
|
|
120
|
+
issue: int,
|
|
121
|
+
message: str,
|
|
122
|
+
data: Optional[Dict[str, Any]] = None,
|
|
123
|
+
) -> bool:
|
|
124
|
+
"""Report a subagent finished (used by workstreams work --wait)."""
|
|
125
|
+
return subagent_report(project, workstream_id, subagent, issue, "done", message, data)
|