workstreams-cli 0.6.2__tar.gz → 0.6.4__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 (39) hide show
  1. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/PKG-INFO +2 -2
  2. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/README.md +1 -1
  3. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/pyproject.toml +1 -1
  4. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/__init__.py +1 -1
  5. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/multiplexer/__init__.py +62 -12
  6. workstreams_cli-0.6.4/src/workstreams/multiplexer/lmux.py +258 -0
  7. workstreams_cli-0.6.4/src/workstreams/multiplexer/lmux_client.py +123 -0
  8. workstreams_cli-0.6.4/src/workstreams/multiplexer/wmux.py +370 -0
  9. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams_cli.egg-info/PKG-INFO +2 -2
  10. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams_cli.egg-info/SOURCES.txt +8 -1
  11. workstreams_cli-0.6.4/tests/test_lmux.py +144 -0
  12. workstreams_cli-0.6.4/tests/test_lmux_client.py +102 -0
  13. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/tests/test_multiplexer_default.py +41 -11
  14. workstreams_cli-0.6.4/tests/test_tmux.py +109 -0
  15. workstreams_cli-0.6.4/tests/test_wmux.py +395 -0
  16. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/setup.cfg +0 -0
  17. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/cli.py +0 -0
  18. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/confidence.py +0 -0
  19. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/config.py +0 -0
  20. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/dashboard.py +0 -0
  21. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/event_log.py +0 -0
  22. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/manager.py +0 -0
  23. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/models.py +0 -0
  24. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/multiplexer/base.py +0 -0
  25. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/multiplexer/tmux.py +0 -0
  26. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/multiplexer/tmux_compatible.py +0 -0
  27. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/multiplexer/zellij.py +0 -0
  28. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/notifier.py +0 -0
  29. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/py.typed +0 -0
  30. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/subagent_client.py +0 -0
  31. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams_cli.egg-info/dependency_links.txt +0 -0
  32. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams_cli.egg-info/entry_points.txt +0 -0
  33. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams_cli.egg-info/requires.txt +0 -0
  34. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams_cli.egg-info/top_level.txt +0 -0
  35. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/tests/test_confidence.py +0 -0
  36. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/tests/test_config.py +0 -0
  37. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/tests/test_event_log.py +0 -0
  38. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/tests/test_models.py +0 -0
  39. {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/tests/test_notifier.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workstreams-cli
3
- Version: 0.6.2
3
+ Version: 0.6.4
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.2
117
+ workstreams --version # -> workstreams 0.6.4
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.2
87
+ workstreams --version # -> workstreams 0.6.4
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.2"
7
+ version = "0.6.4"
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.2"
45
+ __version__ = "0.6.4"
46
46
 
47
47
  __all__ = [
48
48
  # models
@@ -1,6 +1,7 @@
1
1
  """Terminal multiplexer implementations + platform-aware default detection."""
2
2
 
3
3
  import shutil
4
+ import subprocess
4
5
  import sys
5
6
  from typing import Dict
6
7
 
@@ -8,25 +9,34 @@ from .base import MultiplexerBase
8
9
  from .tmux import TmuxMultiplexer
9
10
  from .zellij import ZellijMultiplexer
10
11
  from .tmux_compatible import TmuxCompatibleMultiplexer
12
+ from .lmux import LmuxMultiplexer
13
+ from .wmux import WmuxMultiplexer
11
14
 
12
15
  __all__ = [
13
16
  "MultiplexerBase",
14
17
  "TmuxMultiplexer",
15
18
  "ZellijMultiplexer",
19
+ "LmuxMultiplexer",
20
+ "WmuxMultiplexer",
16
21
  "get_multiplexer",
17
22
  "resolve_default_multiplexer",
18
23
  ]
19
24
 
20
25
  # Name -> binary that must be on PATH. The TmuxCompatibleMultiplexer wraps
21
26
  # any binary whose CLI is compatible with tmux (send-keys, new-window, ...).
27
+ # NOTE: "lmux" and "wmux" are NOT tmux-compatible; they have native dialects
28
+ # (LmuxMultiplexer JSON verbs / WmuxMultiplexer JSON-RPC) and are handled by
29
+ # their own classes. The tmux-compat shim HANGS FOREVER on wmux (every wmux
30
+ # invocation launches the Electron GUI and blocks), so wmux must never be
31
+ # routed through it.
22
32
  _TMUX_COMPATIBLE = {
23
33
  "nami": "nami",
24
- "lmux": "lmux",
25
- "wmux": "wmux",
26
34
  "herdr": "herdr",
27
35
  }
28
36
 
29
37
  # Ordered preference list per platform. We pick the FIRST that is installed.
38
+ # lmux is the top Linux choice because it is purpose-built for AI coding
39
+ # agents and workstreams now drives it natively.
30
40
  _PLATFORM_PREFERENCE = {
31
41
  "win": ["wmux", "lmux", "zellij", "tmux"],
32
42
  "mac": ["tmux", "zellij", "nami", "lmux", "wmux"],
@@ -37,12 +47,25 @@ _PLATFORM_PREFERENCE = {
37
47
  def _installed(name: str) -> bool:
38
48
  """Return True if the CLI binary for `name` is on PATH.
39
49
 
40
- For tmux-compatible wrappers (lmux, wmux, nami, herdr) we additionally
41
- require that the binary actually exposes a tmux-style command, because
42
- 'installed' alone is not sufficient — e.g. `lmux` has a completely
43
- different CLI (`workspace.create`, `surface.send-key`) and is not
44
- tmux-compatible despite the name.
50
+ For the tmux-compatible wrappers (nami, wmux, herdr) we additionally
51
+ require that the binary actually exposes a tmux-style command.
52
+
53
+ `lmux` and `wmux` are special-cased: each speaks its OWN native dialect
54
+ and is driven by its dedicated class (LmuxMultiplexer / WmuxMultiplexer)
55
+ — never the tmux wrapper. wmux additionally requires a live RPC socket
56
+ for auto-detect, because probing it with a subprocess would launch the
57
+ Electron GUI and block.
45
58
  """
59
+ if name == "lmux":
60
+ if shutil.which("lmux") is None:
61
+ return False
62
+ return _supports_lmux_dialect("lmux")
63
+ if name == "wmux":
64
+ if shutil.which("wmux") is None:
65
+ return False
66
+ from .wmux import default_socket_path
67
+ import os
68
+ return os.path.exists(default_socket_path())
46
69
  if name in ("tmux", "zellij"):
47
70
  binary = name
48
71
  elif name in _TMUX_COMPATIBLE:
@@ -57,6 +80,30 @@ def _installed(name: str) -> bool:
57
80
 
58
81
 
59
82
  _tmux_dialect_cache: Dict[str, bool] = {}
83
+ _lmux_dialect_cache: Dict[str, bool] = {}
84
+
85
+
86
+ def _supports_lmux_dialect(binary: str) -> bool:
87
+ """Heuristic: does `binary` expose lmux's native verb-dialect?
88
+
89
+ Probes ``<binary> help`` for the canonical native verbs:
90
+ ``workspace.create`` AND ``surface.send_text``. A binary that has these
91
+ is driven natively by :class:`LmuxMultiplexer` (JSON socket protocol).
92
+ """
93
+ key = binary
94
+ if key in _lmux_dialect_cache:
95
+ return _lmux_dialect_cache[key]
96
+ result = False
97
+ try:
98
+ proc = subprocess.run([binary, "help"], capture_output=True, text=True, timeout=5)
99
+ help_text = (proc.stdout or "") + (proc.stderr or "")
100
+ has_workspace = "workspace.create" in help_text
101
+ has_send_text = "surface.send_text" in help_text or "surface.send-text" in help_text
102
+ result = has_workspace and has_send_text
103
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
104
+ result = False
105
+ _lmux_dialect_cache[key] = result
106
+ return result
60
107
 
61
108
 
62
109
  def _supports_tmux_dialect(binary: str) -> bool:
@@ -159,15 +206,18 @@ def get_multiplexer(name: str, config) -> MultiplexerBase:
159
206
  multiplexers = {
160
207
  "tmux": TmuxMultiplexer,
161
208
  "zellij": ZellijMultiplexer,
209
+ "lmux": LmuxMultiplexer,
210
+ "wmux": WmuxMultiplexer,
162
211
  }
163
- if name in _TMUX_COMPATIBLE:
164
- binary = _TMUX_COMPATIBLE[name]
165
- return TmuxCompatibleMultiplexer(config, binary)
166
212
  cls = multiplexers.get(name)
167
213
  if not cls:
214
+ # Fallback: a tmux-compatible wrapper binary (nami/herdr).
215
+ if name in _TMUX_COMPATIBLE:
216
+ binary = _TMUX_COMPATIBLE[name]
217
+ return TmuxCompatibleMultiplexer(config, binary)
168
218
  raise ValueError(
169
219
  f"Unknown multiplexer: {name}. "
170
- f"Supported: {', '.join(multiplexers)} plus tmux-compatible: {', '.join(_TMUX_COMPATIBLE)} "
171
- f"or 'default' (auto-detect)."
220
+ f"Supported: {', '.join(multiplexers)} plus tmux-compatible: "
221
+ f"{', '.join(_TMUX_COMPATIBLE)} or 'default' (auto-detect)."
172
222
  )
173
223
  return cls(config)
@@ -0,0 +1,258 @@
1
+ """Native lmux multiplexer.
2
+
3
+ lmux is a Linux terminal multiplexer built specifically for AI coding
4
+ agents. It is NOT tmux-compatible: its CLI speaks its own verb-dialect
5
+ (``workspace.create``, ``surface.send_text``, ``read-screen`` ...), backed
6
+ by a JSON-over-Unix-socket protocol. This class drives that protocol
7
+ directly via :class:`LmuxClient` — no tmux-dialect faking.
8
+
9
+ Model mapping
10
+ -------------
11
+ - workspace ≈ session (one per workstreams project)
12
+ - surface ≈ tab/window (one per workstream)
13
+ - pane ≈ split (lmux splits within a surface)
14
+
15
+ The workstream->surface ID map is persisted to the shared JSONL event log
16
+ so that re-invocations of ``dispatch``/``capture`` target the same surface
17
+ deterministically.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import sys
25
+ from typing import Any, Dict, List, Optional
26
+
27
+ from .base import MultiplexerBase
28
+ from .lmux_client import LmuxClient, default_socket_path
29
+
30
+
31
+ class LmuxMultiplexer(MultiplexerBase):
32
+ """Native lmux workstream management (JSON socket protocol)."""
33
+
34
+ name = "lmux"
35
+
36
+ def __init__(self, config: Any):
37
+ super().__init__(config)
38
+ self.session = f"workstreams-{config.project}"
39
+ self._client: Optional[LmuxClient] = None
40
+ self._workspace_id: Optional[int] = None
41
+ self._surface_map: Dict[int, int] = {} # workstream_id -> surface_id
42
+
43
+ # ------------------------------------------------------------------ #
44
+ # Client
45
+ # ------------------------------------------------------------------ #
46
+ @property
47
+ def _cli(self) -> LmuxClient:
48
+ if self._client is None:
49
+ self._client = LmuxClient.ensure_daemon()
50
+ return self._client
51
+
52
+ # ------------------------------------------------------------------ #
53
+ # Base interface
54
+ # ------------------------------------------------------------------ #
55
+ def session_name(self) -> str:
56
+ return self.session
57
+
58
+ def pane_target(self, workstream_id: int) -> str:
59
+ """lmux targets surfaces by numeric id, not `session:win.pane` strings.
60
+
61
+ Return the recorded surface id as a string, or ``""`` if unknown.
62
+ """
63
+ self._restore_surface_map()
64
+ sid = self._surface_map.get(workstream_id)
65
+ return str(sid) if sid is not None else ""
66
+
67
+ def binary_name(self) -> str:
68
+ return "lmux"
69
+
70
+ def is_available(self) -> bool:
71
+ return LmuxClient.binary_available()
72
+
73
+ def start(self, workstreams: List[Dict[str, Any]], command: Optional[str] = None) -> None:
74
+ client = self._cli
75
+ # Idempotent: reuse an existing workspace with the same name.
76
+ existing = client.cmd("workspace.list", {})
77
+ workspace_id = self._find_workspace(existing)
78
+ if workspace_id is None:
79
+ reply = client.cmd("workspace.create", {"title": self.session})
80
+ workspace_id = self._as_int(reply, "id")
81
+ self._workspace_id = workspace_id
82
+
83
+ for i, ws in enumerate(workstreams):
84
+ ws_id = int(ws.get("id", i + 1))
85
+ ws_name = ws.get("name", f"ws{ws_id}")
86
+ reply = client.cmd(
87
+ "surface.create", {"workspace": workspace_id, "title": ws_name}
88
+ )
89
+ surface_id = self._as_int(reply, "id")
90
+ self._surface_map[ws_id] = surface_id
91
+
92
+ # Run the workstream's command (or a global default) in this surface.
93
+ ws_cmd = command or ws.get("command") or ""
94
+ if ws_cmd:
95
+ path = ws.get("path", "")
96
+ if path:
97
+ ws_cmd = f"cd {path} && {ws_cmd}"
98
+ client.cmd(
99
+ "surface.send_text",
100
+ {"surface": surface_id, "workspace": workspace_id, "text": ws_cmd},
101
+ )
102
+
103
+ self._persist_surface_map()
104
+ print(f"Started {len(workstreams)} workstream(s) in lmux workspace '{self.session}'")
105
+ print(f" Observe: lmux tree · Attach a TUI: lmux (workspace '{self.session}')")
106
+
107
+ def attach(self, session: Optional[str] = None) -> None:
108
+ # lmux is spawn-and-observe oriented; interactive attach is best-effort.
109
+ target = session or self.session
110
+ print(f"Attach to lmux workspace '{target}': run `lmux` and select it, or `lmux tree`.")
111
+ print(" (lmux has no blocking attach; surfaces are spawned for agents to observe.)")
112
+
113
+ def send_command(self, workstream_id: int, command: str) -> bool:
114
+ """Send `command` to the recorded surface for `workstream_id`."""
115
+ self._restore_surface_map()
116
+ surface_id = self._surface_map.get(workstream_id)
117
+ if surface_id is None:
118
+ print(
119
+ f"[workstreams] lmux: no surface recorded for workstream "
120
+ f"{workstream_id}; run `workstreams start` first.",
121
+ file=sys.stderr,
122
+ )
123
+ return False
124
+ client = self._cli
125
+ reply = client.cmd(
126
+ "surface.send_text",
127
+ {"surface": surface_id, "workspace": self._workspace_id, "text": command},
128
+ )
129
+ return self._ok(reply)
130
+
131
+ def capture(self, workstream_id: int, lines: int = 20) -> List[str]:
132
+ """Read the recorded surface's terminal screen via ``read-screen``."""
133
+ self._restore_surface_map()
134
+ surface_id = self._surface_map.get(workstream_id)
135
+ if surface_id is None:
136
+ return []
137
+ return self._capture_surface(surface_id, lines=lines)
138
+
139
+ def _capture_surface(self, surface_id: int, lines: int = 20) -> List[str]:
140
+ client = self._cli
141
+ reply = client.cmd(
142
+ "read-screen", {"surface": surface_id, "workspace": self._workspace_id}
143
+ )
144
+ result = self._result(reply)
145
+ text = result.get("text", "") if isinstance(result, dict) else str(result)
146
+ raw_lines = [ln.rstrip() for ln in text.splitlines()]
147
+ return raw_lines[-lines:]
148
+
149
+ def list_windows(self) -> List[Dict[str, Any]]:
150
+ client = self._cli
151
+ reply = client.cmd("surface.list", {})
152
+ result = self._result(reply)
153
+ text = result.get("text", "") if isinstance(result, dict) else str(result)
154
+ out: List[Dict[str, Any]] = []
155
+ for ln in text.splitlines():
156
+ ln = ln.strip()
157
+ if not ln:
158
+ continue
159
+ out.append({"raw": ln})
160
+ return out
161
+
162
+ def kill(self) -> None:
163
+ if self._workspace_id is not None:
164
+ # workspace.close extracts the id as a string from the JSON args.
165
+ self._cli.cmd("workspace.close", {"id": str(self._workspace_id)})
166
+
167
+ # ------------------------------------------------------------------ #
168
+ # Helpers
169
+ # ------------------------------------------------------------------ #
170
+ @staticmethod
171
+ def _result(reply: Any) -> Any:
172
+ """Unwrap the lmux daemon envelope.
173
+
174
+ Every JSON reply is ``{"ok": bool, "result": {...}}`` (or
175
+ ``{"ok": false, "error": ...}``). Some text-only verbs return a
176
+ bare ``{"text": ...}`` payload with no envelope. Return the inner
177
+ result, or the payload itself when there is no envelope.
178
+ """
179
+ if isinstance(reply, dict):
180
+ if "result" in reply:
181
+ return reply["result"]
182
+ return reply
183
+ return reply
184
+
185
+ def _find_workspace(self, list_reply: Dict[str, Any]) -> Optional[int]:
186
+ """Find an existing workspace with our session name (idempotent start)."""
187
+ result = self._result(list_reply)
188
+ if not isinstance(result, dict):
189
+ return None
190
+ # workspace.list replies carry the list under "workspaces".
191
+ workspaces = result.get("workspaces")
192
+ if isinstance(workspaces, list):
193
+ for w in workspaces:
194
+ if isinstance(w, dict) and w.get("title") == self.session:
195
+ wid = w.get("id")
196
+ if isinstance(wid, int):
197
+ return wid
198
+ if isinstance(wid, str) and wid.isdigit():
199
+ return int(wid)
200
+ return None
201
+ # Fall back to text form: parse "title (id=N)" lines.
202
+ import re
203
+ text = result.get("text", "") if "text" in result else json.dumps(result)
204
+ m = re.search(rf"{re.escape(self.session)}[^0-9]*\(?id=(\d+)\)?", text)
205
+ return int(m.group(1)) if m else None
206
+
207
+ @classmethod
208
+ def _as_int(cls, reply: Any, key: str = "id") -> int:
209
+ result = cls._result(reply)
210
+ if isinstance(result, dict):
211
+ v = result.get(key)
212
+ if isinstance(v, int):
213
+ return v
214
+ if isinstance(v, str) and v.isdigit():
215
+ return int(v)
216
+ raise RuntimeError(f"lmux: expected int id in reply: {reply!r}")
217
+
218
+ @staticmethod
219
+ def _ok(reply: Any) -> bool:
220
+ """Check if an lmux reply indicates success.
221
+
222
+ Returns True only if the reply explicitly indicates success
223
+ (has "ok" key with truthy value). Returns False for any
224
+ unknown or error status, including non-dict replies.
225
+ """
226
+ if not isinstance(reply, dict):
227
+ # Non-dict reply - unknown format, assume failure
228
+ return False
229
+ if "ok" in reply:
230
+ return bool(reply["ok"])
231
+ # Dict reply without "ok" key - cannot confirm success
232
+ return False
233
+
234
+ def _map_path(self) -> str:
235
+ data_dir = os.environ.get("WORKSTREAMS_DATA_DIR", ".workstreams")
236
+ return os.path.join(data_dir, f"{self.session}.surfaces.json")
237
+
238
+ def _persist_surface_map(self) -> None:
239
+ try:
240
+ os.makedirs(os.path.dirname(self._map_path()), exist_ok=True)
241
+ with open(self._map_path(), "w", encoding="utf-8") as f:
242
+ json.dump(
243
+ {"workspace": self._workspace_id, "surfaces": self._surface_map},
244
+ f,
245
+ )
246
+ except OSError:
247
+ pass
248
+
249
+ def _restore_surface_map(self) -> None:
250
+ if self._surface_map:
251
+ return
252
+ try:
253
+ with open(self._map_path(), "r", encoding="utf-8") as f:
254
+ data = json.load(f)
255
+ self._workspace_id = data.get("workspace") or self._workspace_id
256
+ self._surface_map = {int(k): int(v) for k, v in data.get("surfaces", {}).items()}
257
+ except (OSError, ValueError, json.JSONDecodeError):
258
+ self._surface_map = {}
@@ -0,0 +1,123 @@
1
+ """Raw JSON-over-Unix-socket client for the lmux daemon.
2
+
3
+ lmux (Linux terminal multiplexer built for AI coding agents) is NOT
4
+ tmux-compatible: its CLI speaks its own verb-dialect
5
+ (``workspace.create``, ``surface.send_text``, ``read-screen`` ...). Under the
6
+ hood every one-shot subcommand is a JSON request over a Unix socket. This
7
+ client reproduces that wire protocol so workstreams can drive lmux natively.
8
+
9
+ Wire format (verified from ``lmux help`` + strace of the real binary):
10
+
11
+ connect -> /run/user/<uid>/lmux.sock (env LMUX_SOCKET / XDG_RUNTIME_DIR)
12
+ write -> {"cmd": "<name>", "args": {...}}\\n
13
+ read -> <json reply>\\n
14
+ close
15
+
16
+ The daemon lazy-warms a PTY on the first command, so the first call after a
17
+ fresh daemon start can take a few seconds. We use a generous default timeout.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import socket
25
+ import shutil
26
+ from typing import Any, Dict, Optional
27
+
28
+
29
+ def default_socket_path() -> str:
30
+ """Return the lmux daemon socket path for the current user."""
31
+ env = os.environ.get("LMUX_SOCKET")
32
+ if env:
33
+ return env
34
+ runtime = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
35
+ return os.path.join(runtime, "lmux.sock")
36
+
37
+
38
+ class LmuxClient:
39
+ """Minimal lmux daemon client."""
40
+
41
+ name = "lmux"
42
+
43
+ def __init__(self, socket_path: Optional[str] = None, timeout: float = 10.0):
44
+ self.socket_path = socket_path or default_socket_path()
45
+ self.timeout = timeout
46
+
47
+ # ------------------------------------------------------------------ #
48
+ # Protocol
49
+ # ------------------------------------------------------------------ #
50
+ def cmd(self, name: str, args: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
51
+ """Send a single JSON command and return the parsed JSON reply.
52
+
53
+ Raises ``RuntimeError`` if the socket is missing or the reply can't
54
+ be parsed.
55
+ """
56
+ args = args or {}
57
+ payload = (json.dumps({"cmd": name, "args": args}) + "\n").encode("utf-8")
58
+ if not os.path.exists(self.socket_path):
59
+ raise RuntimeError(
60
+ f"lmux daemon socket not found at {self.socket_path}. "
61
+ "Start it with: lmux daemon"
62
+ )
63
+ try:
64
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
65
+ sock.settimeout(self.timeout)
66
+ sock.connect(self.socket_path)
67
+ sock.sendall(payload)
68
+ data = b""
69
+ while not data.endswith(b"\n"):
70
+ chunk = sock.recv(4096)
71
+ if not chunk:
72
+ break
73
+ data += chunk
74
+ sock.close()
75
+ except OSError as exc:
76
+ raise RuntimeError(f"lmux socket I/O error: {exc}") from exc
77
+
78
+ text = data.decode("utf-8", "replace").strip()
79
+ if not text:
80
+ # Some lmux verbs (tree, *.list) return human-readable text, not
81
+ # JSON. Return it as a text payload so callers can still use it.
82
+ return {"text": text}
83
+ try:
84
+ return json.loads(text)
85
+ except json.JSONDecodeError:
86
+ return {"text": text}
87
+
88
+ # ------------------------------------------------------------------ #
89
+ # Convenience
90
+ # ------------------------------------------------------------------ #
91
+ def ping(self) -> Dict[str, Any]:
92
+ return self.cmd("ping")
93
+
94
+ @staticmethod
95
+ def binary_available() -> bool:
96
+ """True if the lmux binary is on PATH."""
97
+ return shutil.which("lmux") is not None
98
+
99
+ @classmethod
100
+ def ensure_daemon(cls, socket_path: Optional[str] = None) -> "LmuxClient":
101
+ """Return a client, starting the daemon if the socket is missing.
102
+
103
+ No-op start (idempotent) when a live socket already exists. Guarded
104
+ by ``WORKSTREAMS_LMUX_NO_DAEMON`` to skip the auto-start.
105
+ """
106
+ client = cls(socket_path)
107
+ if os.path.exists(client.socket_path):
108
+ return client
109
+ if os.environ.get("WORKSTREAMS_LMUX_NO_DAEMON"):
110
+ return client # caller will get a clean RuntimeError on .cmd()
111
+ if not cls.binary_available():
112
+ raise RuntimeError("lmux binary not found on PATH")
113
+ import subprocess
114
+ # Detached daemon; the socket appears within a couple seconds.
115
+ proc = subprocess.Popen(
116
+ ["lmux", "daemon"],
117
+ stdin=subprocess.DEVNULL,
118
+ stdout=subprocess.DEVNULL,
119
+ stderr=subprocess.DEVNULL,
120
+ start_new_session=True,
121
+ )
122
+ proc.wait(timeout=15) # lmux daemon backgrounds itself; wait is short.
123
+ return client