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.
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/PKG-INFO +2 -2
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/README.md +1 -1
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/pyproject.toml +1 -1
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/__init__.py +1 -1
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/multiplexer/__init__.py +62 -12
- workstreams_cli-0.6.4/src/workstreams/multiplexer/lmux.py +258 -0
- workstreams_cli-0.6.4/src/workstreams/multiplexer/lmux_client.py +123 -0
- workstreams_cli-0.6.4/src/workstreams/multiplexer/wmux.py +370 -0
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams_cli.egg-info/PKG-INFO +2 -2
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams_cli.egg-info/SOURCES.txt +8 -1
- workstreams_cli-0.6.4/tests/test_lmux.py +144 -0
- workstreams_cli-0.6.4/tests/test_lmux_client.py +102 -0
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/tests/test_multiplexer_default.py +41 -11
- workstreams_cli-0.6.4/tests/test_tmux.py +109 -0
- workstreams_cli-0.6.4/tests/test_wmux.py +395 -0
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/setup.cfg +0 -0
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/cli.py +0 -0
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/confidence.py +0 -0
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/config.py +0 -0
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/dashboard.py +0 -0
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/event_log.py +0 -0
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/manager.py +0 -0
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/models.py +0 -0
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/multiplexer/base.py +0 -0
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/multiplexer/tmux.py +0 -0
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/multiplexer/tmux_compatible.py +0 -0
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/multiplexer/zellij.py +0 -0
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/notifier.py +0 -0
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/py.typed +0 -0
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams/subagent_client.py +0 -0
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams_cli.egg-info/dependency_links.txt +0 -0
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams_cli.egg-info/entry_points.txt +0 -0
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams_cli.egg-info/requires.txt +0 -0
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/src/workstreams_cli.egg-info/top_level.txt +0 -0
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/tests/test_confidence.py +0 -0
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/tests/test_config.py +0 -0
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/tests/test_event_log.py +0 -0
- {workstreams_cli-0.6.2 → workstreams_cli-0.6.4}/tests/test_models.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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"
|
|
@@ -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 (
|
|
41
|
-
require that the binary actually exposes a tmux-style command
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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:
|
|
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
|