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.
Files changed (42) hide show
  1. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/PKG-INFO +2 -2
  2. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/README.md +1 -1
  3. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/pyproject.toml +1 -1
  4. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/__init__.py +1 -1
  5. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/cli.py +3 -1
  6. workstreams_cli-0.6.5/src/workstreams/multiplexer/wmux.py +539 -0
  7. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams_cli.egg-info/PKG-INFO +2 -2
  8. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams_cli.egg-info/SOURCES.txt +1 -0
  9. workstreams_cli-0.6.5/tests/test_cli_dispatch.py +118 -0
  10. workstreams_cli-0.6.5/tests/test_wmux.py +686 -0
  11. workstreams_cli-0.6.4/src/workstreams/multiplexer/wmux.py +0 -370
  12. workstreams_cli-0.6.4/tests/test_wmux.py +0 -395
  13. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/setup.cfg +0 -0
  14. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/confidence.py +0 -0
  15. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/config.py +0 -0
  16. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/dashboard.py +0 -0
  17. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/event_log.py +0 -0
  18. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/manager.py +0 -0
  19. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/models.py +0 -0
  20. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/multiplexer/__init__.py +0 -0
  21. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/multiplexer/base.py +0 -0
  22. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/multiplexer/lmux.py +0 -0
  23. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/multiplexer/lmux_client.py +0 -0
  24. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/multiplexer/tmux.py +0 -0
  25. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/multiplexer/tmux_compatible.py +0 -0
  26. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/multiplexer/zellij.py +0 -0
  27. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/notifier.py +0 -0
  28. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/py.typed +0 -0
  29. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams/subagent_client.py +0 -0
  30. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams_cli.egg-info/dependency_links.txt +0 -0
  31. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams_cli.egg-info/entry_points.txt +0 -0
  32. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams_cli.egg-info/requires.txt +0 -0
  33. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/src/workstreams_cli.egg-info/top_level.txt +0 -0
  34. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/tests/test_confidence.py +0 -0
  35. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/tests/test_config.py +0 -0
  36. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/tests/test_event_log.py +0 -0
  37. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/tests/test_lmux.py +0 -0
  38. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/tests/test_lmux_client.py +0 -0
  39. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/tests/test_models.py +0 -0
  40. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/tests/test_multiplexer_default.py +0 -0
  41. {workstreams_cli-0.6.4 → workstreams_cli-0.6.5}/tests/test_notifier.py +0 -0
  42. {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.4
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.4
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.4
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.4"
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"
@@ -42,7 +42,7 @@ from .confidence import (
42
42
  extract_score,
43
43
  )
44
44
 
45
- __version__ = "0.6.4"
45
+ __version__ = "0.6.5"
46
46
 
47
47
  __all__ = [
48
48
  # models
@@ -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 f"Work on issue #{args.issue} as {args.subagent}" if args.issue else f"as {args.subagent}"
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.4
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.4
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