workstreams-cli 0.6.4__tar.gz → 0.6.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.
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/PKG-INFO +2 -2
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/README.md +1 -1
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/pyproject.toml +1 -1
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/__init__.py +1 -1
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/cli.py +3 -1
- workstreams_cli-0.6.5/src/workstreams/multiplexer/wmux.py +539 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams_cli.egg-info/PKG-INFO +2 -2
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams_cli.egg-info/SOURCES.txt +1 -0
- workstreams_cli-0.6.5/tests/test_cli_dispatch.py +118 -0
- workstreams_cli-0.6.5/tests/test_wmux.py +686 -0
- workstreams_cli-0.6.4/src/workstreams/multiplexer/wmux.py +0 -370
- workstreams_cli-0.6.4/tests/test_wmux.py +0 -395
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/setup.cfg +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/confidence.py +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/config.py +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/dashboard.py +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/event_log.py +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/manager.py +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/models.py +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/multiplexer/__init__.py +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/multiplexer/base.py +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/multiplexer/lmux.py +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/multiplexer/lmux_client.py +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/multiplexer/tmux.py +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/multiplexer/tmux_compatible.py +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/multiplexer/zellij.py +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/notifier.py +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/py.typed +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/subagent_client.py +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams_cli.egg-info/dependency_links.txt +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams_cli.egg-info/entry_points.txt +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams_cli.egg-info/requires.txt +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams_cli.egg-info/top_level.txt +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/tests/test_confidence.py +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/tests/test_config.py +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/tests/test_event_log.py +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/tests/test_lmux.py +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/tests/test_lmux_client.py +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/tests/test_models.py +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/tests/test_multiplexer_default.py +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/tests/test_notifier.py +0 -0
- {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/tests/test_tmux.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: workstreams-cli
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.5
|
|
4
4
|
Summary: Visually dispatch coding-agent work to subagents in real terminal windows and monitor it in one dashboard - for any coding agent (Claude Code, Codex, OpenCode, Qwen Code, Hermes, Cline, and more).
|
|
5
5
|
Author: Dream-Pixels-Forge
|
|
6
6
|
License: MIT
|
|
@@ -114,7 +114,7 @@ pip install -e ".[yaml,dev]" # dev extras add pytest
|
|
|
114
114
|
Verify:
|
|
115
115
|
|
|
116
116
|
```bash
|
|
117
|
-
workstreams --version # -> workstreams 0.6.
|
|
117
|
+
workstreams --version # -> workstreams 0.6.5
|
|
118
118
|
```
|
|
119
119
|
|
|
120
120
|
> **Note:** every command also accepts `--json` to emit machine-readable output (where supported), which coding agents can parse. All read-side commands work without a multiplexer installed; only `start`/`dispatch`/`work`/`attach` need one.
|
|
@@ -84,7 +84,7 @@ pip install -e ".[yaml,dev]" # dev extras add pytest
|
|
|
84
84
|
Verify:
|
|
85
85
|
|
|
86
86
|
```bash
|
|
87
|
-
workstreams --version # -> workstreams 0.6.
|
|
87
|
+
workstreams --version # -> workstreams 0.6.5
|
|
88
88
|
```
|
|
89
89
|
|
|
90
90
|
> **Note:** every command also accepts `--json` to emit machine-readable output (where supported), which coding agents can parse. All read-side commands work without a multiplexer installed; only `start`/`dispatch`/`work`/`attach` need one.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "workstreams-cli"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.5"
|
|
8
8
|
description = "Visually dispatch coding-agent work to subagents in real terminal windows and monitor it in one dashboard - for any coding agent (Claude Code, Codex, OpenCode, Qwen Code, Hermes, Cline, and more)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -335,7 +335,9 @@ def _cmd_workstream(args) -> int:
|
|
|
335
335
|
def _cmd_dispatch(args) -> int:
|
|
336
336
|
manager = _load_manager(args)
|
|
337
337
|
# Build the pane command
|
|
338
|
-
prompt = args.prompt or
|
|
338
|
+
prompt = args.prompt or (
|
|
339
|
+
f"Work on issue #{args.issue} as {args.subagent}" if args.issue else f"as {args.subagent}"
|
|
340
|
+
)
|
|
339
341
|
if args.agent:
|
|
340
342
|
pane_cmd = f"{args.agent} {prompt}"
|
|
341
343
|
if args.issue:
|
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
"""Native wmux multiplexer.
|
|
2
|
+
|
|
3
|
+
wmux is an Electron GUI terminal multiplexer with NO usable CLI (every
|
|
4
|
+
``wmux <verb>`` launches the GUI and blocks), so it must be driven over its
|
|
5
|
+
daemon sockets. Protocol verified live against wmux 3.66.0:
|
|
6
|
+
|
|
7
|
+
Control pipe
|
|
8
|
+
``$WMUX_SOCKET_PATH`` > ``~/.wmux{suffix}/daemon.sock`` >
|
|
9
|
+
``~/.wmux{suffix}.sock`` > ``~/.wmux-daemon{suffix}.sock``.
|
|
10
|
+
Newline-delimited JSON: request ``{"id", "method", "params", "token"}``
|
|
11
|
+
-> one line ``{"id", "ok", "result" | "error"}``. The token travels per
|
|
12
|
+
frame (``$WMUX_AUTH_TOKEN`` > ``~/.wmux{suffix}/daemon-auth-token`` >
|
|
13
|
+
``~/.wmux-auth-token``); a wrong token yields
|
|
14
|
+
``{"ok": false, "error": "unauthorized"}`` plus a closed socket. There is
|
|
15
|
+
NO handshake and no approval prompt — third-party tokens may call the
|
|
16
|
+
session lifecycle methods directly. EVERY call sets a timeout, and
|
|
17
|
+
connection drops are retried (the daemon rate-limits ~20 conns/s).
|
|
18
|
+
|
|
19
|
+
Workstream mapping
|
|
20
|
+
tmux windows <-> wmux sessions ``ws-<project>-<n>`` created with
|
|
21
|
+
``daemon.createSession`` (a persistent shell, like tmux ``exec bash -i``).
|
|
22
|
+
Commands are typed into the session's pty the tmux send-keys way, so the
|
|
23
|
+
session outlives them. Input uses the per-session pipe
|
|
24
|
+
``~/.wmux{suffix}/session-<id>.sock``: connect, send ``<token>\\n``, read
|
|
25
|
+
until the ``\\0WMUX_FLUSH_DONE:<token>\\0`` marker, then write the command
|
|
26
|
+
(bytes after the auth line go STRAIGHT to the pty — append ``\\n`` to
|
|
27
|
+
submit). The pipe accepts ONE client: if the pane is open in the GUI the
|
|
28
|
+
dispatch fails with a clear error. Capture reads ``daemon.readSessionText``
|
|
29
|
+
(no pipe needed). The workstream->session map is persisted as JSON, the
|
|
30
|
+
same pattern as lmux.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import re
|
|
38
|
+
import shlex
|
|
39
|
+
import shutil
|
|
40
|
+
import socket
|
|
41
|
+
import sys
|
|
42
|
+
import time
|
|
43
|
+
import uuid
|
|
44
|
+
from typing import Any, Dict, List, Optional
|
|
45
|
+
|
|
46
|
+
from .base import MultiplexerBase
|
|
47
|
+
|
|
48
|
+
#: Daemon contract for session ids (spawnSession + assertExternalSessionId).
|
|
49
|
+
#: The ``auto-`` prefix is reserved for scheduled runs — our ``ws-`` is safe.
|
|
50
|
+
SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
|
|
51
|
+
|
|
52
|
+
_FLUSH_TIMEOUT = 5.0
|
|
53
|
+
_PIPE_CONNECT_ATTEMPTS = 3
|
|
54
|
+
_PIPE_RETRY_SLEEP = 0.35
|
|
55
|
+
_ATTACH_POLL_SECONDS = 1.5
|
|
56
|
+
_CONTROL_CONNECT_ATTEMPTS = 3
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _wmux_home() -> str:
|
|
60
|
+
suffix = os.environ.get("WMUX_DATA_SUFFIX", "")
|
|
61
|
+
return os.path.join(os.path.expanduser("~"), f".wmux{suffix}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def default_socket_path() -> str:
|
|
65
|
+
"""Return the wmux control-pipe path for the current user.
|
|
66
|
+
|
|
67
|
+
Env override wins; otherwise prefer the live daemon socket, falling back
|
|
68
|
+
to the legacy pipe names. When no candidate exists, return the primary
|
|
69
|
+
path so error messages point somewhere sensible.
|
|
70
|
+
"""
|
|
71
|
+
env = os.environ.get("WMUX_SOCKET_PATH")
|
|
72
|
+
if env:
|
|
73
|
+
return env
|
|
74
|
+
home = os.path.expanduser("~")
|
|
75
|
+
suffix = os.environ.get("WMUX_DATA_SUFFIX", "")
|
|
76
|
+
candidates = [
|
|
77
|
+
os.path.join(home, f".wmux{suffix}", "daemon.sock"),
|
|
78
|
+
os.path.join(home, f".wmux{suffix}.sock"),
|
|
79
|
+
os.path.join(home, f".wmux-daemon{suffix}.sock"),
|
|
80
|
+
]
|
|
81
|
+
for path in candidates:
|
|
82
|
+
if os.path.exists(path):
|
|
83
|
+
return path
|
|
84
|
+
return candidates[0]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def session_socket_path(session_id: str) -> str:
|
|
88
|
+
"""Return the per-session input pipe path (getSessionSocketPath)."""
|
|
89
|
+
return os.path.join(_wmux_home(), f"session-{session_id}.sock")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _token_candidates() -> List[str]:
|
|
93
|
+
home = os.path.expanduser("~")
|
|
94
|
+
suffix = os.environ.get("WMUX_DATA_SUFFIX", "")
|
|
95
|
+
return [
|
|
96
|
+
os.path.join(home, f".wmux{suffix}", "daemon-auth-token"),
|
|
97
|
+
os.path.join(home, ".wmux-auth-token"),
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _read_token() -> Optional[str]:
|
|
102
|
+
env = os.environ.get("WMUX_AUTH_TOKEN")
|
|
103
|
+
if env:
|
|
104
|
+
return env
|
|
105
|
+
for path in _token_candidates():
|
|
106
|
+
try:
|
|
107
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
108
|
+
token = f.read().strip()
|
|
109
|
+
if token:
|
|
110
|
+
return token
|
|
111
|
+
except OSError:
|
|
112
|
+
continue
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class _PipeBusy(RuntimeError):
|
|
117
|
+
"""Session pipe dropped us before the flush marker (occupied / rate-limited)."""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class WmuxClient:
|
|
121
|
+
"""Minimal newline-delimited JSON-RPC client for the wmux daemon."""
|
|
122
|
+
|
|
123
|
+
name = "wmux"
|
|
124
|
+
|
|
125
|
+
def __init__(self, socket_path: Optional[str] = None, timeout: float = 10.0):
|
|
126
|
+
self.socket_path = socket_path or default_socket_path()
|
|
127
|
+
self.timeout = timeout
|
|
128
|
+
self.token = _read_token()
|
|
129
|
+
|
|
130
|
+
def call(self, method: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
131
|
+
"""Invoke ``method`` and return its ``result``. Raises on failure."""
|
|
132
|
+
frame = {
|
|
133
|
+
"id": str(uuid.uuid4()),
|
|
134
|
+
"method": method,
|
|
135
|
+
"params": params if params is not None else {},
|
|
136
|
+
"token": self.token,
|
|
137
|
+
}
|
|
138
|
+
reply = self._exchange(frame)
|
|
139
|
+
if reply.get("ok"):
|
|
140
|
+
return reply.get("result")
|
|
141
|
+
error = str(reply.get("error") or "")
|
|
142
|
+
if "unauthorized" in error.lower():
|
|
143
|
+
if self.token is None:
|
|
144
|
+
checked = ", ".join(["$WMUX_AUTH_TOKEN", *_token_candidates()])
|
|
145
|
+
raise RuntimeError(
|
|
146
|
+
f"wmux: no auth token found (checked {checked}) — "
|
|
147
|
+
"is the wmux daemon running?"
|
|
148
|
+
)
|
|
149
|
+
raise RuntimeError(
|
|
150
|
+
"wmux: unauthorized — the daemon rejected the auth token "
|
|
151
|
+
"(stale token file? restart wmux)"
|
|
152
|
+
)
|
|
153
|
+
raise RuntimeError(f"wmux: {method} failed: {error or reply}")
|
|
154
|
+
|
|
155
|
+
# ------------------------------------------------------------------ #
|
|
156
|
+
# Wire protocol
|
|
157
|
+
# ------------------------------------------------------------------ #
|
|
158
|
+
def _exchange(self, frame: Dict[str, Any]) -> Dict[str, Any]:
|
|
159
|
+
"""One socket round-trip. ALWAYS sets a timeout — no hangs.
|
|
160
|
+
|
|
161
|
+
Connection drops are retried: the daemon rate-limits incoming
|
|
162
|
+
connections and may accept-then-drop them.
|
|
163
|
+
"""
|
|
164
|
+
payload = (json.dumps(frame) + "\n").encode("utf-8")
|
|
165
|
+
data = b""
|
|
166
|
+
for attempt in range(_CONTROL_CONNECT_ATTEMPTS):
|
|
167
|
+
data = b""
|
|
168
|
+
try:
|
|
169
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
170
|
+
try:
|
|
171
|
+
sock.settimeout(self.timeout)
|
|
172
|
+
sock.connect(self.socket_path)
|
|
173
|
+
sock.sendall(payload)
|
|
174
|
+
while not data.endswith(b"\n"):
|
|
175
|
+
chunk = sock.recv(65536)
|
|
176
|
+
if not chunk:
|
|
177
|
+
break
|
|
178
|
+
data += chunk
|
|
179
|
+
finally:
|
|
180
|
+
sock.close()
|
|
181
|
+
except (FileNotFoundError, ConnectionRefusedError) as exc:
|
|
182
|
+
raise RuntimeError(
|
|
183
|
+
f"wmux is not running: cannot connect to {self.socket_path} "
|
|
184
|
+
f"({exc}). Start wmux first (e.g. launch the wmux app / "
|
|
185
|
+
"`wmux daemon`) and retry."
|
|
186
|
+
) from exc
|
|
187
|
+
except socket.timeout as exc:
|
|
188
|
+
raise RuntimeError(
|
|
189
|
+
f"wmux: no reply from the daemon at {self.socket_path} "
|
|
190
|
+
f"after {self.timeout}s"
|
|
191
|
+
) from exc
|
|
192
|
+
except (ConnectionResetError, BrokenPipeError) as exc:
|
|
193
|
+
if attempt == _CONTROL_CONNECT_ATTEMPTS - 1:
|
|
194
|
+
raise RuntimeError(
|
|
195
|
+
f"wmux: the daemon kept dropping the connection at "
|
|
196
|
+
f"{self.socket_path} ({exc})"
|
|
197
|
+
) from exc
|
|
198
|
+
time.sleep(0.2)
|
|
199
|
+
continue
|
|
200
|
+
except OSError as exc:
|
|
201
|
+
raise RuntimeError(
|
|
202
|
+
f"wmux socket I/O error on {self.socket_path}: {exc}"
|
|
203
|
+
) from exc
|
|
204
|
+
if not data:
|
|
205
|
+
# Accepted, then closed without a reply (rate limit / restart).
|
|
206
|
+
if attempt == _CONTROL_CONNECT_ATTEMPTS - 1:
|
|
207
|
+
raise RuntimeError(
|
|
208
|
+
f"wmux: the daemon closed the connection without a "
|
|
209
|
+
f"reply at {self.socket_path} (rate limited?)"
|
|
210
|
+
)
|
|
211
|
+
time.sleep(0.2)
|
|
212
|
+
continue
|
|
213
|
+
break
|
|
214
|
+
|
|
215
|
+
text = data.decode("utf-8", "replace").strip()
|
|
216
|
+
if not text:
|
|
217
|
+
raise RuntimeError(f"wmux: empty reply for {frame.get('method')}")
|
|
218
|
+
try:
|
|
219
|
+
reply = json.loads(text)
|
|
220
|
+
except json.JSONDecodeError as exc:
|
|
221
|
+
raise RuntimeError(
|
|
222
|
+
f"wmux: non-JSON reply for {frame.get('method')}: {text[:200]}"
|
|
223
|
+
) from exc
|
|
224
|
+
if not isinstance(reply, dict):
|
|
225
|
+
raise RuntimeError(f"wmux: unexpected reply shape: {reply!r}")
|
|
226
|
+
return reply
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class WmuxMultiplexer(MultiplexerBase):
|
|
230
|
+
"""Native wmux workstream management (session RPC + session pipes)."""
|
|
231
|
+
|
|
232
|
+
name = "wmux"
|
|
233
|
+
|
|
234
|
+
def __init__(self, config: Any):
|
|
235
|
+
super().__init__(config)
|
|
236
|
+
self.session = f"workstreams-{config.project}"
|
|
237
|
+
self._client: Optional[WmuxClient] = None
|
|
238
|
+
self._pane_map: Dict[int, Dict[str, str]] = {} # ws_id -> {sessionId}
|
|
239
|
+
|
|
240
|
+
# ------------------------------------------------------------------ #
|
|
241
|
+
# Client
|
|
242
|
+
# ------------------------------------------------------------------ #
|
|
243
|
+
@property
|
|
244
|
+
def _cli(self) -> WmuxClient:
|
|
245
|
+
if self._client is None:
|
|
246
|
+
self._client = WmuxClient()
|
|
247
|
+
return self._client
|
|
248
|
+
|
|
249
|
+
def _session_id(self, workstream_id: int) -> str:
|
|
250
|
+
"""Stable, daemon-valid session id: ``ws-<project>-<n>`` (<= 64 chars)."""
|
|
251
|
+
project = re.sub(r"[^a-zA-Z0-9_-]", "-", str(self.config.project))
|
|
252
|
+
suffix = f"-{int(workstream_id)}"
|
|
253
|
+
budget = 64 - len("ws-") - len(suffix)
|
|
254
|
+
if budget < 0:
|
|
255
|
+
raise RuntimeError("wmux: project name too long for a session id")
|
|
256
|
+
sid = f"ws-{project[:budget]}{suffix}"
|
|
257
|
+
if not SESSION_ID_RE.match(sid):
|
|
258
|
+
raise RuntimeError(
|
|
259
|
+
f"wmux: cannot derive a valid session id from project "
|
|
260
|
+
f"{self.config.project!r} (got {sid!r})"
|
|
261
|
+
)
|
|
262
|
+
return sid
|
|
263
|
+
|
|
264
|
+
# ------------------------------------------------------------------ #
|
|
265
|
+
# Base interface
|
|
266
|
+
# ------------------------------------------------------------------ #
|
|
267
|
+
def session_name(self) -> str:
|
|
268
|
+
return self.session
|
|
269
|
+
|
|
270
|
+
def pane_target(self, workstream_id: int) -> str:
|
|
271
|
+
"""wmux targets sessions by id; return the recorded sessionId (or "")."""
|
|
272
|
+
self._restore_pane_map()
|
|
273
|
+
entry = self._pane_map.get(workstream_id)
|
|
274
|
+
return str(entry["sessionId"]) if entry and entry.get("sessionId") else ""
|
|
275
|
+
|
|
276
|
+
def binary_name(self) -> str:
|
|
277
|
+
return "wmux"
|
|
278
|
+
|
|
279
|
+
def is_available(self) -> bool:
|
|
280
|
+
return shutil.which("wmux") is not None
|
|
281
|
+
|
|
282
|
+
def start(
|
|
283
|
+
self, workstreams: List[Dict[str, Any]], command: Optional[str] = None
|
|
284
|
+
) -> None:
|
|
285
|
+
client = self._cli
|
|
286
|
+
listed = client.call("daemon.listSessions") or []
|
|
287
|
+
existing = {
|
|
288
|
+
str(s.get("id")) for s in listed if isinstance(s, dict) and s.get("id")
|
|
289
|
+
}
|
|
290
|
+
base_dir = self.config.base_path
|
|
291
|
+
|
|
292
|
+
for i, ws in enumerate(workstreams):
|
|
293
|
+
ws_id = int(ws.get("id", i + 1))
|
|
294
|
+
sid = self._session_id(ws_id)
|
|
295
|
+
|
|
296
|
+
if sid not in existing:
|
|
297
|
+
params: Dict[str, Any] = {
|
|
298
|
+
"id": sid,
|
|
299
|
+
"cwd": base_dir,
|
|
300
|
+
"cols": 80,
|
|
301
|
+
"rows": 24,
|
|
302
|
+
}
|
|
303
|
+
shell = os.environ.get("SHELL")
|
|
304
|
+
if shell:
|
|
305
|
+
params["cmd"] = shell
|
|
306
|
+
try:
|
|
307
|
+
client.call("daemon.createSession", params)
|
|
308
|
+
except RuntimeError as exc:
|
|
309
|
+
# A concurrent start may have won the race; reuse in that case.
|
|
310
|
+
if "already exists" not in str(exc):
|
|
311
|
+
raise
|
|
312
|
+
self._pane_map[ws_id] = {"sessionId": sid}
|
|
313
|
+
|
|
314
|
+
line = self._compose_line(ws, base_dir, command)
|
|
315
|
+
if line:
|
|
316
|
+
self._send_to_session(sid, line)
|
|
317
|
+
|
|
318
|
+
self._persist_pane_map()
|
|
319
|
+
print(
|
|
320
|
+
f"Started {len(workstreams)} workstream(s) in wmux session '{self.session}'"
|
|
321
|
+
)
|
|
322
|
+
print(" Observe: wmux GUI — sessions are named ws-<project>-<n>.")
|
|
323
|
+
|
|
324
|
+
def attach(self, session: Optional[str] = None) -> None:
|
|
325
|
+
# wmux is a GUI: there is no blocking CLI attach to run here.
|
|
326
|
+
target = session or self.session
|
|
327
|
+
print(f"wmux session '{target}': open the wmux window to observe it.")
|
|
328
|
+
|
|
329
|
+
def send_command(self, workstream_id: int, command: str) -> bool:
|
|
330
|
+
self._restore_pane_map()
|
|
331
|
+
entry = self._pane_map.get(workstream_id)
|
|
332
|
+
if entry is None or not entry.get("sessionId"):
|
|
333
|
+
print(
|
|
334
|
+
f"[workstreams] wmux: no session recorded for workstream "
|
|
335
|
+
f"{workstream_id}; run `workstreams start` first.",
|
|
336
|
+
file=sys.stderr,
|
|
337
|
+
)
|
|
338
|
+
return False
|
|
339
|
+
try:
|
|
340
|
+
self._send_to_session(entry["sessionId"], command)
|
|
341
|
+
return True
|
|
342
|
+
except RuntimeError as exc:
|
|
343
|
+
print(f"[workstreams] wmux: {exc}", file=sys.stderr)
|
|
344
|
+
return False
|
|
345
|
+
|
|
346
|
+
def capture(self, workstream_id: int, lines: int = 20) -> List[str]:
|
|
347
|
+
self._restore_pane_map()
|
|
348
|
+
entry = self._pane_map.get(workstream_id)
|
|
349
|
+
if entry is None or not entry.get("sessionId"):
|
|
350
|
+
return []
|
|
351
|
+
reply = self._cli.call("daemon.readSessionText", {"id": entry["sessionId"]})
|
|
352
|
+
if not isinstance(reply, dict) or reply.get("mode") != "rows":
|
|
353
|
+
return []
|
|
354
|
+
rows = reply.get("rows") or []
|
|
355
|
+
texts = [str(r.get("text", "")).rstrip() for r in rows if isinstance(r, dict)]
|
|
356
|
+
return [t for t in texts if t][-lines:]
|
|
357
|
+
|
|
358
|
+
def list_windows(self) -> List[Dict[str, Any]]:
|
|
359
|
+
self._restore_pane_map()
|
|
360
|
+
if not self._pane_map:
|
|
361
|
+
return []
|
|
362
|
+
mapped = {
|
|
363
|
+
str(e["sessionId"]) for e in self._pane_map.values() if e.get("sessionId")
|
|
364
|
+
}
|
|
365
|
+
listed = self._cli.call("daemon.listSessions") or []
|
|
366
|
+
return [
|
|
367
|
+
{"id": str(s.get("id")), "label": str(s.get("id"))}
|
|
368
|
+
for s in listed
|
|
369
|
+
if isinstance(s, dict) and str(s.get("id")) in mapped
|
|
370
|
+
]
|
|
371
|
+
|
|
372
|
+
def kill(self) -> None:
|
|
373
|
+
self._restore_pane_map()
|
|
374
|
+
for entry in list(self._pane_map.values()):
|
|
375
|
+
sid = entry.get("sessionId")
|
|
376
|
+
if not sid:
|
|
377
|
+
continue
|
|
378
|
+
try:
|
|
379
|
+
self._cli.call("daemon.destroySession", {"id": sid})
|
|
380
|
+
except RuntimeError:
|
|
381
|
+
pass # best-effort teardown
|
|
382
|
+
|
|
383
|
+
# ------------------------------------------------------------------ #
|
|
384
|
+
# Session pipe (input path)
|
|
385
|
+
# ------------------------------------------------------------------ #
|
|
386
|
+
def _send_to_session(self, session_id: str, text: str) -> None:
|
|
387
|
+
"""Type ``text`` (plus Enter) into the session's pty via its pipe."""
|
|
388
|
+
sock = self._pipe_connect(session_id)
|
|
389
|
+
try:
|
|
390
|
+
payload = text if text.endswith("\n") else text + "\n"
|
|
391
|
+
sock.sendall(payload.encode("utf-8"))
|
|
392
|
+
# Brief drain: swallow the pty echo / reflush tail, then go.
|
|
393
|
+
sock.settimeout(0.3)
|
|
394
|
+
try:
|
|
395
|
+
while sock.recv(65536):
|
|
396
|
+
pass
|
|
397
|
+
except (socket.timeout, OSError):
|
|
398
|
+
pass
|
|
399
|
+
finally:
|
|
400
|
+
sock.close()
|
|
401
|
+
|
|
402
|
+
def _pipe_connect(self, session_id: str) -> socket.socket:
|
|
403
|
+
"""Connect + authenticate against ``session-<id>.sock``.
|
|
404
|
+
|
|
405
|
+
Creates the pipe (``daemon.attachSession``) when missing and retries
|
|
406
|
+
transient drops (per-pipe rate limit, stale socket file).
|
|
407
|
+
"""
|
|
408
|
+
path = session_socket_path(session_id)
|
|
409
|
+
token = (self._cli.token or "").encode("utf-8")
|
|
410
|
+
last_error: Optional[BaseException] = None
|
|
411
|
+
for attempt in range(_PIPE_CONNECT_ATTEMPTS):
|
|
412
|
+
if not os.path.exists(path):
|
|
413
|
+
self._cli.call("daemon.attachSession", {"id": session_id})
|
|
414
|
+
deadline = time.monotonic() + _ATTACH_POLL_SECONDS
|
|
415
|
+
while not os.path.exists(path) and time.monotonic() < deadline:
|
|
416
|
+
time.sleep(0.05)
|
|
417
|
+
if not os.path.exists(path):
|
|
418
|
+
raise RuntimeError(
|
|
419
|
+
f"wmux: session '{session_id}' has no live pipe "
|
|
420
|
+
"(destroyed or never created?) — run `workstreams start`."
|
|
421
|
+
)
|
|
422
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
423
|
+
try:
|
|
424
|
+
sock.settimeout(_FLUSH_TIMEOUT)
|
|
425
|
+
sock.connect(path)
|
|
426
|
+
sock.sendall(token + b"\n")
|
|
427
|
+
self._read_auth_flush(sock, session_id, token)
|
|
428
|
+
return sock
|
|
429
|
+
except _PipeBusy as exc:
|
|
430
|
+
sock.close()
|
|
431
|
+
last_error = exc
|
|
432
|
+
if attempt < _PIPE_CONNECT_ATTEMPTS - 1:
|
|
433
|
+
time.sleep(_PIPE_RETRY_SLEEP)
|
|
434
|
+
continue
|
|
435
|
+
break
|
|
436
|
+
except (FileNotFoundError, ConnectionRefusedError) as exc:
|
|
437
|
+
# Stale socket file: SessionPipe.start() unlinks it itself on
|
|
438
|
+
# re-attach, so drop it and force a fresh attachSession.
|
|
439
|
+
sock.close()
|
|
440
|
+
last_error = exc
|
|
441
|
+
try:
|
|
442
|
+
if os.path.exists(path):
|
|
443
|
+
os.unlink(path)
|
|
444
|
+
except OSError:
|
|
445
|
+
pass
|
|
446
|
+
continue
|
|
447
|
+
except RuntimeError:
|
|
448
|
+
sock.close()
|
|
449
|
+
raise
|
|
450
|
+
except OSError as exc:
|
|
451
|
+
sock.close()
|
|
452
|
+
raise RuntimeError(
|
|
453
|
+
f"wmux: session pipe I/O error on {path}: {exc}"
|
|
454
|
+
) from exc
|
|
455
|
+
raise RuntimeError(
|
|
456
|
+
f"wmux: cannot use the pipe for session '{session_id}': {last_error} "
|
|
457
|
+
"— the pane may be open in the wmux GUI (the pipe takes one client); "
|
|
458
|
+
"close/detach it and retry."
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
def _read_auth_flush(
|
|
462
|
+
self, sock: socket.socket, session_id: str, token: bytes
|
|
463
|
+
) -> None:
|
|
464
|
+
"""Validate auth and consume output until the flush marker.
|
|
465
|
+
|
|
466
|
+
Returns once the marker is seen or the server goes quiet — input
|
|
467
|
+
writes are safe either way (bytes go straight to the pty). Raises
|
|
468
|
+
``_PipeBusy`` when the server drops us before authenticating.
|
|
469
|
+
"""
|
|
470
|
+
marker = b"\0WMUX_FLUSH_DONE:" + token + b"\0"
|
|
471
|
+
tail = b""
|
|
472
|
+
deadline = time.monotonic() + _FLUSH_TIMEOUT
|
|
473
|
+
while time.monotonic() < deadline:
|
|
474
|
+
try:
|
|
475
|
+
chunk = sock.recv(65536)
|
|
476
|
+
except socket.timeout:
|
|
477
|
+
return # quiet server; input path is independent of the flush
|
|
478
|
+
if not chunk:
|
|
479
|
+
if b"AUTH_FAILED" in tail:
|
|
480
|
+
raise RuntimeError(
|
|
481
|
+
"wmux: session pipe rejected the auth token "
|
|
482
|
+
"(stale token file? restart wmux)"
|
|
483
|
+
)
|
|
484
|
+
raise _PipeBusy(
|
|
485
|
+
f"the pipe for session '{session_id}' dropped the "
|
|
486
|
+
"connection before the flush marker"
|
|
487
|
+
)
|
|
488
|
+
tail = (tail + chunk)[-4096:]
|
|
489
|
+
if b"AUTH_FAILED" in tail:
|
|
490
|
+
raise RuntimeError(
|
|
491
|
+
"wmux: session pipe rejected the auth token "
|
|
492
|
+
"(stale token file? restart wmux)"
|
|
493
|
+
)
|
|
494
|
+
if marker in tail:
|
|
495
|
+
return
|
|
496
|
+
|
|
497
|
+
# ------------------------------------------------------------------ #
|
|
498
|
+
# Command composition (mirrors the tmux multiplexer's send-keys form)
|
|
499
|
+
# ------------------------------------------------------------------ #
|
|
500
|
+
@staticmethod
|
|
501
|
+
def _compose_line(ws: Dict[str, Any], base_dir: str, command: Optional[str]) -> str:
|
|
502
|
+
segments: List[str] = []
|
|
503
|
+
ws_path = ws.get("path") or ""
|
|
504
|
+
if ws_path:
|
|
505
|
+
segments.append(f"cd {shlex.quote(os.path.join(base_dir, ws_path))}")
|
|
506
|
+
env = ws.get("env") or {}
|
|
507
|
+
body_parts = [f"{k}={shlex.quote(str(v))}" for k, v in sorted(env.items())]
|
|
508
|
+
ws_cmd = command or ws.get("command") or ""
|
|
509
|
+
if ws_cmd:
|
|
510
|
+
body_parts.append(ws_cmd)
|
|
511
|
+
body = " ".join(body_parts)
|
|
512
|
+
if segments and body:
|
|
513
|
+
return f"{segments[0]} && {body}"
|
|
514
|
+
return segments[0] if segments else body
|
|
515
|
+
|
|
516
|
+
# ------------------------------------------------------------------ #
|
|
517
|
+
# Session map persistence (same pattern as lmux)
|
|
518
|
+
# ------------------------------------------------------------------ #
|
|
519
|
+
def _map_path(self) -> str:
|
|
520
|
+
data_dir = os.environ.get("WORKSTREAMS_DATA_DIR", ".workstreams")
|
|
521
|
+
return os.path.join(data_dir, f"{self.session}.panes.json")
|
|
522
|
+
|
|
523
|
+
def _persist_pane_map(self) -> None:
|
|
524
|
+
try:
|
|
525
|
+
os.makedirs(os.path.dirname(self._map_path()), exist_ok=True)
|
|
526
|
+
with open(self._map_path(), "w", encoding="utf-8") as f:
|
|
527
|
+
json.dump({"panes": self._pane_map}, f)
|
|
528
|
+
except OSError:
|
|
529
|
+
pass
|
|
530
|
+
|
|
531
|
+
def _restore_pane_map(self) -> None:
|
|
532
|
+
if self._pane_map:
|
|
533
|
+
return
|
|
534
|
+
try:
|
|
535
|
+
with open(self._map_path(), "r", encoding="utf-8") as f:
|
|
536
|
+
data = json.load(f)
|
|
537
|
+
self._pane_map = {int(k): dict(v) for k, v in data.get("panes", {}).items()}
|
|
538
|
+
except (OSError, ValueError, json.JSONDecodeError, TypeError):
|
|
539
|
+
self._pane_map = {}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: workstreams-cli
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.5
|
|
4
4
|
Summary: Visually dispatch coding-agent work to subagents in real terminal windows and monitor it in one dashboard - for any coding agent (Claude Code, Codex, OpenCode, Qwen Code, Hermes, Cline, and more).
|
|
5
5
|
Author: Dream-Pixels-Forge
|
|
6
6
|
License: MIT
|
|
@@ -114,7 +114,7 @@ pip install -e ".[yaml,dev]" # dev extras add pytest
|
|
|
114
114
|
Verify:
|
|
115
115
|
|
|
116
116
|
```bash
|
|
117
|
-
workstreams --version # -> workstreams 0.6.
|
|
117
|
+
workstreams --version # -> workstreams 0.6.5
|
|
118
118
|
```
|
|
119
119
|
|
|
120
120
|
> **Note:** every command also accepts `--json` to emit machine-readable output (where supported), which coding agents can parse. All read-side commands work without a multiplexer installed; only `start`/`dispatch`/`work`/`attach` need one.
|
|
@@ -25,6 +25,7 @@ src/workstreams_cli.egg-info/dependency_links.txt
|
|
|
25
25
|
src/workstreams_cli.egg-info/entry_points.txt
|
|
26
26
|
src/workstreams_cli.egg-info/requires.txt
|
|
27
27
|
src/workstreams_cli.egg-info/top_level.txt
|
|
28
|
+
tests/test_cli_dispatch.py
|
|
28
29
|
tests/test_confidence.py
|
|
29
30
|
tests/test_config.py
|
|
30
31
|
tests/test_event_log.py
|