workstreams-cli 0.6.1__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.1 → workstreams_cli-0.6.4}/PKG-INFO +30 -4
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/README.md +29 -3
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/pyproject.toml +1 -1
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/src/workstreams/__init__.py +1 -1
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/src/workstreams/cli.py +13 -5
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/src/workstreams/config.py +9 -1
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/src/workstreams/models.py +1 -1
- workstreams_cli-0.6.4/src/workstreams/multiplexer/__init__.py +223 -0
- 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.1 → workstreams_cli-0.6.4}/src/workstreams_cli.egg-info/PKG-INFO +30 -4
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/src/workstreams_cli.egg-info/SOURCES.txt +9 -1
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/tests/test_config.py +5 -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.4/tests/test_multiplexer_default.py +160 -0
- 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.1/src/workstreams/multiplexer/__init__.py +0 -32
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/setup.cfg +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/src/workstreams/confidence.py +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/src/workstreams/dashboard.py +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/src/workstreams/event_log.py +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/src/workstreams/manager.py +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/src/workstreams/multiplexer/base.py +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/src/workstreams/multiplexer/tmux.py +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/src/workstreams/multiplexer/tmux_compatible.py +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/src/workstreams/multiplexer/zellij.py +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/src/workstreams/notifier.py +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/src/workstreams/py.typed +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/src/workstreams/subagent_client.py +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/src/workstreams_cli.egg-info/dependency_links.txt +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/src/workstreams_cli.egg-info/entry_points.txt +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/src/workstreams_cli.egg-info/requires.txt +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/src/workstreams_cli.egg-info/top_level.txt +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/tests/test_confidence.py +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/tests/test_event_log.py +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.4}/tests/test_models.py +0 -0
- {workstreams_cli-0.6.1 → 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.
|
|
@@ -139,10 +139,36 @@ The terminal tool that hosts the visible windows. `workstreams` currently suppor
|
|
|
139
139
|
|
|
140
140
|
| Multiplexer | Layouts | Notes |
|
|
141
141
|
|-------------|---------|-------|
|
|
142
|
-
| `
|
|
142
|
+
| `default` (auto-detect) | per backend | **Picks the best multiplexer installed for your OS**: `tmux` on macOS, `lmux` on Linux, `wmux` on Windows — falling back to `zellij` when the platform primary is missing. This is now the out-of-the-box behaviour. |
|
|
143
|
+
| `tmux` | `even-horizontal`, `even-vertical`, `main-horizontal`, `tiled` | One **window per workstream** (cleanest for agents), or all in one tiled window. Detached sessions survive your logout. |
|
|
143
144
|
| `zellij` | tabs | One **tab per workstream**. Simpler scripting surface; `dispatch` targets the current tab only. |
|
|
145
|
+
| `nami` / `lmux` / `wmux` / `herdr` | tmux-compatible | tmux-compatible CLIs wrapped by `TmuxCompatibleMultiplexer`. |
|
|
144
146
|
|
|
145
|
-
|
|
147
|
+
**Auto-detect** — you normally don't pick one. `load_config` resolves the
|
|
148
|
+
platform default at startup:
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
macOS → tmux first, then zellij, nami, lmux, wmux
|
|
152
|
+
Linux → lmux first, then zellij, then tmux
|
|
153
|
+
Windows → wmux first, then lmux, zellij, tmux
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The first *installed* binary in that list wins — **and** for the
|
|
157
|
+
tmux-compatible wrapper family (`nami` / `lmux` / `wmux` / `herdr`)
|
|
158
|
+
workstreams validates that the binary actually exposes tmux-style verbs
|
|
159
|
+
(`new-session` / `send-keys` / …) before committing. For example `lmux`
|
|
160
|
+
v1 has a completely different CLI shape (`workspace.create`,
|
|
161
|
+
`surface.send-key`) so the validator rejects it and auto-detect falls
|
|
162
|
+
through to `zellij` or `tmux`. If you *know* your `lmux` build exposes
|
|
163
|
+
the tmux dialect, pin it explicitly with `multiplexer: lmux` — the
|
|
164
|
+
validator is only consulted on the `default` auto-pick path. If you want a specific one:
|
|
165
|
+
|
|
166
|
+
- Set it in your `.workstreams.yaml` / `.json`: `multiplexer: zellij`
|
|
167
|
+
- Or pass `--multiplexer zellij` on `init` / `start` / `dispatch` / `work`
|
|
168
|
+
- Or run `workstreams init --choose` to interactively pick, which persists the
|
|
169
|
+
choice into your config file so later commands use it.
|
|
170
|
+
|
|
171
|
+
The tmux/zellij/lmux/wmux session is auto-named `workstreams-<project>`.
|
|
146
172
|
|
|
147
173
|
### 3. Subagent
|
|
148
174
|
The coding agent doing the work inside a workstream. This is deliberately **free-form**: `claude-code`, `codex`, `opencode`, `qwen-code`, `mimocode`, `hermes`, `kilo-code`, `cline`, or any label you want. There is no vendor lock-in — a subagent is just an identifier used in the event log. The agent that actually runs is whatever command you send into the pane (see `dispatch`/`work`).
|
|
@@ -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.
|
|
@@ -109,10 +109,36 @@ The terminal tool that hosts the visible windows. `workstreams` currently suppor
|
|
|
109
109
|
|
|
110
110
|
| Multiplexer | Layouts | Notes |
|
|
111
111
|
|-------------|---------|-------|
|
|
112
|
-
| `
|
|
112
|
+
| `default` (auto-detect) | per backend | **Picks the best multiplexer installed for your OS**: `tmux` on macOS, `lmux` on Linux, `wmux` on Windows — falling back to `zellij` when the platform primary is missing. This is now the out-of-the-box behaviour. |
|
|
113
|
+
| `tmux` | `even-horizontal`, `even-vertical`, `main-horizontal`, `tiled` | One **window per workstream** (cleanest for agents), or all in one tiled window. Detached sessions survive your logout. |
|
|
113
114
|
| `zellij` | tabs | One **tab per workstream**. Simpler scripting surface; `dispatch` targets the current tab only. |
|
|
115
|
+
| `nami` / `lmux` / `wmux` / `herdr` | tmux-compatible | tmux-compatible CLIs wrapped by `TmuxCompatibleMultiplexer`. |
|
|
114
116
|
|
|
115
|
-
|
|
117
|
+
**Auto-detect** — you normally don't pick one. `load_config` resolves the
|
|
118
|
+
platform default at startup:
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
macOS → tmux first, then zellij, nami, lmux, wmux
|
|
122
|
+
Linux → lmux first, then zellij, then tmux
|
|
123
|
+
Windows → wmux first, then lmux, zellij, tmux
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The first *installed* binary in that list wins — **and** for the
|
|
127
|
+
tmux-compatible wrapper family (`nami` / `lmux` / `wmux` / `herdr`)
|
|
128
|
+
workstreams validates that the binary actually exposes tmux-style verbs
|
|
129
|
+
(`new-session` / `send-keys` / …) before committing. For example `lmux`
|
|
130
|
+
v1 has a completely different CLI shape (`workspace.create`,
|
|
131
|
+
`surface.send-key`) so the validator rejects it and auto-detect falls
|
|
132
|
+
through to `zellij` or `tmux`. If you *know* your `lmux` build exposes
|
|
133
|
+
the tmux dialect, pin it explicitly with `multiplexer: lmux` — the
|
|
134
|
+
validator is only consulted on the `default` auto-pick path. If you want a specific one:
|
|
135
|
+
|
|
136
|
+
- Set it in your `.workstreams.yaml` / `.json`: `multiplexer: zellij`
|
|
137
|
+
- Or pass `--multiplexer zellij` on `init` / `start` / `dispatch` / `work`
|
|
138
|
+
- Or run `workstreams init --choose` to interactively pick, which persists the
|
|
139
|
+
choice into your config file so later commands use it.
|
|
140
|
+
|
|
141
|
+
The tmux/zellij/lmux/wmux session is auto-named `workstreams-<project>`.
|
|
116
142
|
|
|
117
143
|
### 3. Subagent
|
|
118
144
|
The coding agent doing the work inside a workstream. This is deliberately **free-form**: `claude-code`, `codex`, `opencode`, `qwen-code`, `mimocode`, `hermes`, `kilo-code`, `cline`, or any label you want. There is no vendor lock-in — a subagent is just an identifier used in the event log. The agent that actually runs is whatever command you send into the pane (see `dispatch`/`work`).
|
|
@@ -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"
|
|
@@ -98,7 +98,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
98
98
|
# init
|
|
99
99
|
init = common_parent("init")
|
|
100
100
|
init.add_argument("--workstreams", type=int, default=4, help="Number of workstreams to create (default: 4)")
|
|
101
|
-
init.add_argument("--multiplexer", choices=["tmux", "zellij", "nami", "lmux", "wmux", "herdr"], default=None, help="Multiplexer type")
|
|
101
|
+
init.add_argument("--multiplexer", choices=["default", "tmux", "zellij", "nami", "lmux", "wmux", "herdr"], default=None, help="Multiplexer type ('default' = auto-detect: tmux/mac, lmux/linux, wmux/win)")
|
|
102
|
+
init.add_argument("--choose", action="store_true", help="Interactively pick the multiplexer (writes choice to .workstreams.yaml)")
|
|
102
103
|
init.add_argument("--layout", choices=["even-horizontal", "even-vertical", "main-horizontal", "tiled"], default=None)
|
|
103
104
|
init.add_argument("--base-branch", default=None, help="Base branch (default: main)")
|
|
104
105
|
init.add_argument("--mode", choices=["worktree", "branch"], default=None, help="worktree (isolated) or branch (shared dir)")
|
|
@@ -110,12 +111,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
110
111
|
start = common_parent("start")
|
|
111
112
|
start.add_argument("--workstream", type=int, help="Start a single workstream by ID")
|
|
112
113
|
start.add_argument("--cmd", help="Override the command sent to each pane")
|
|
113
|
-
start.add_argument("--multiplexer", choices=["tmux", "zellij", "nami", "lmux", "wmux", "herdr"], default=None)
|
|
114
|
+
start.add_argument("--multiplexer", choices=["default", "tmux", "zellij", "nami", "lmux", "wmux", "herdr"], default=None)
|
|
114
115
|
start.add_argument("--layout", choices=["even-horizontal", "even-vertical", "main-horizontal", "tiled"], default=None)
|
|
115
116
|
|
|
116
117
|
# attach
|
|
117
118
|
attach = common_parent("attach")
|
|
118
|
-
attach.add_argument("--multiplexer", choices=["tmux", "zellij", "nami", "lmux", "wmux", "herdr"], default=None)
|
|
119
|
+
attach.add_argument("--multiplexer", choices=["default", "tmux", "zellij", "nami", "lmux", "wmux", "herdr"], default=None)
|
|
119
120
|
attach.add_argument("--session", help="Override session name")
|
|
120
121
|
|
|
121
122
|
# status
|
|
@@ -162,7 +163,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
162
163
|
dispatch.add_argument("--prompt", help="Prompt / task description sent to the pane")
|
|
163
164
|
dispatch.add_argument("--agent", help="Agent binary to invoke (e.g. 'claude', 'codex', 'opencode run'). If omitted, sends --prompt verbatim to the pane.")
|
|
164
165
|
dispatch.add_argument("--wait", action="store_true", help="Block until subagent reports done/failed")
|
|
165
|
-
dispatch.add_argument("--multiplexer", choices=["tmux", "zellij", "nami", "lmux", "wmux", "herdr"], default=None)
|
|
166
|
+
dispatch.add_argument("--multiplexer", choices=["default", "tmux", "zellij", "nami", "lmux", "wmux", "herdr"], default=None)
|
|
166
167
|
|
|
167
168
|
# work (run agent command directly)
|
|
168
169
|
work = common_parent("work")
|
|
@@ -172,7 +173,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
172
173
|
work.add_argument("--subagent", default="agent", help="Identifier used in event log")
|
|
173
174
|
work.add_argument("--issue", type=int, default=0)
|
|
174
175
|
work.add_argument("--wait", action="store_true", help="Block until terminal event")
|
|
175
|
-
work.add_argument("--multiplexer", choices=["tmux", "zellij", "nami", "lmux", "wmux", "herdr"], default=None)
|
|
176
|
+
work.add_argument("--multiplexer", choices=["default", "tmux", "zellij", "nami", "lmux", "wmux", "herdr"], default=None)
|
|
176
177
|
|
|
177
178
|
# run
|
|
178
179
|
run = common_parent("run")
|
|
@@ -266,6 +267,13 @@ def _cmd_init(args) -> int:
|
|
|
266
267
|
manager.config.mode = args.mode
|
|
267
268
|
if args.agent:
|
|
268
269
|
manager.config.agent = args.agent
|
|
270
|
+
# Interactive picker: ask the user which multiplexer to persist,
|
|
271
|
+
# then write it to the config file.
|
|
272
|
+
if getattr(args, "choose", False):
|
|
273
|
+
from .multiplexer import resolve_default_multiplexer
|
|
274
|
+
chosen = resolve_default_multiplexer(interactive=True)
|
|
275
|
+
manager.config.multiplexer = chosen
|
|
276
|
+
print(f"Multiplexer: {chosen} (persisted to config)")
|
|
269
277
|
if args.no_worktrees:
|
|
270
278
|
# Skip git worktree creation; just write config
|
|
271
279
|
from .models import WorkstreamConfig
|
|
@@ -80,9 +80,17 @@ def load_config(
|
|
|
80
80
|
if not project:
|
|
81
81
|
project = base_path.name or "myproject"
|
|
82
82
|
|
|
83
|
+
# Resolve the multiplexer: explicit config/env value wins; the literal
|
|
84
|
+
# string "default" (or absence) auto-detects the best installed
|
|
85
|
+
# multiplexer for this platform (tmux/mac, lmux/linux, wmux/win).
|
|
86
|
+
multiplexer = data.get("multiplexer") or os.environ.get("WORKSTREAMS_MULTIPLEXER") or "default"
|
|
87
|
+
if multiplexer == "default":
|
|
88
|
+
from .multiplexer import resolve_default_multiplexer
|
|
89
|
+
multiplexer = resolve_default_multiplexer(interactive=False)
|
|
90
|
+
|
|
83
91
|
return WorkstreamsConfig(
|
|
84
92
|
project=project,
|
|
85
|
-
multiplexer=
|
|
93
|
+
multiplexer=multiplexer,
|
|
86
94
|
layout=data.get("layout") or os.environ.get("WORKSTREAMS_LAYOUT") or "even-horizontal",
|
|
87
95
|
base_branch=data.get("base_branch") or os.environ.get("WORKSTREAMS_BASE_BRANCH") or "main",
|
|
88
96
|
mode=data.get("mode") or os.environ.get("WORKSTREAMS_WORKTREE_MODE") or "worktree",
|
|
@@ -39,7 +39,7 @@ class WorkstreamsConfig:
|
|
|
39
39
|
"""Project-level configuration."""
|
|
40
40
|
|
|
41
41
|
project: str
|
|
42
|
-
multiplexer: str = "
|
|
42
|
+
multiplexer: str = "default" # "default" = auto-detect platform best
|
|
43
43
|
layout: str = "even-horizontal"
|
|
44
44
|
base_branch: str = "main"
|
|
45
45
|
mode: str = "worktree" # worktree|branch
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Terminal multiplexer implementations + platform-aware default detection."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Dict
|
|
7
|
+
|
|
8
|
+
from .base import MultiplexerBase
|
|
9
|
+
from .tmux import TmuxMultiplexer
|
|
10
|
+
from .zellij import ZellijMultiplexer
|
|
11
|
+
from .tmux_compatible import TmuxCompatibleMultiplexer
|
|
12
|
+
from .lmux import LmuxMultiplexer
|
|
13
|
+
from .wmux import WmuxMultiplexer
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"MultiplexerBase",
|
|
17
|
+
"TmuxMultiplexer",
|
|
18
|
+
"ZellijMultiplexer",
|
|
19
|
+
"LmuxMultiplexer",
|
|
20
|
+
"WmuxMultiplexer",
|
|
21
|
+
"get_multiplexer",
|
|
22
|
+
"resolve_default_multiplexer",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# Name -> binary that must be on PATH. The TmuxCompatibleMultiplexer wraps
|
|
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.
|
|
32
|
+
_TMUX_COMPATIBLE = {
|
|
33
|
+
"nami": "nami",
|
|
34
|
+
"herdr": "herdr",
|
|
35
|
+
}
|
|
36
|
+
|
|
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.
|
|
40
|
+
_PLATFORM_PREFERENCE = {
|
|
41
|
+
"win": ["wmux", "lmux", "zellij", "tmux"],
|
|
42
|
+
"mac": ["tmux", "zellij", "nami", "lmux", "wmux"],
|
|
43
|
+
"linux": ["lmux", "zellij", "tmux"],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _installed(name: str) -> bool:
|
|
48
|
+
"""Return True if the CLI binary for `name` is on PATH.
|
|
49
|
+
|
|
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.
|
|
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())
|
|
69
|
+
if name in ("tmux", "zellij"):
|
|
70
|
+
binary = name
|
|
71
|
+
elif name in _TMUX_COMPATIBLE:
|
|
72
|
+
binary = _TMUX_COMPATIBLE[name]
|
|
73
|
+
else:
|
|
74
|
+
return False
|
|
75
|
+
if shutil.which(binary) is None:
|
|
76
|
+
return False
|
|
77
|
+
if name in _TMUX_COMPATIBLE:
|
|
78
|
+
return _supports_tmux_dialect(binary)
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
|
|
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
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _supports_tmux_dialect(binary: str) -> bool:
|
|
110
|
+
"""Heuristic: does `binary` accept tmux-style subcommands?
|
|
111
|
+
|
|
112
|
+
Probing method:
|
|
113
|
+
1. Run `<binary> help` — look for canonical tmux verbs
|
|
114
|
+
(new-session / has-session / send-keys / capture-pane).
|
|
115
|
+
2. If that's inconclusive (tmux has no `help` subcommand and dumps
|
|
116
|
+
to stderr instead), run `<binary> new-session -P` and treat
|
|
117
|
+
"command not found" / "unknown command" in stderr as a negative.
|
|
118
|
+
|
|
119
|
+
For `lmux` the help text exposes `workspace.create` / `surface.send-key`
|
|
120
|
+
instead of the tmux verbs, so this probe correctly returns False and
|
|
121
|
+
auto-detect skips it — only an explicit user-configured `lmux` will
|
|
122
|
+
attempt the wrapper.
|
|
123
|
+
"""
|
|
124
|
+
import subprocess as _sp
|
|
125
|
+
key = binary
|
|
126
|
+
if key in _tmux_dialect_cache:
|
|
127
|
+
return _tmux_dialect_cache[key]
|
|
128
|
+
result = False
|
|
129
|
+
try:
|
|
130
|
+
proc = _sp.run([binary, "help"], capture_output=True, text=True, timeout=5)
|
|
131
|
+
help_text = (proc.stdout or "") + (proc.stderr or "")
|
|
132
|
+
# Require BOTH a session verb and a key verb. `lmux` happens to
|
|
133
|
+
# mention "capture-pane" in one subcommand's description but is
|
|
134
|
+
# missing session.send-keys / new-session entirely, so a single
|
|
135
|
+
# hit is not enough to call it tmux-compatible.
|
|
136
|
+
has_session = any(m in help_text for m in ("new-session", "has-session"))
|
|
137
|
+
has_keys = any(m in help_text for m in ("send-keys", "send-text"))
|
|
138
|
+
result = has_session and has_keys
|
|
139
|
+
except (_sp.TimeoutExpired, FileNotFoundError, OSError):
|
|
140
|
+
result = False
|
|
141
|
+
if not result:
|
|
142
|
+
# Secondary probe: tmux has no `help`; `new-session -P` on a bad
|
|
143
|
+
# socket prints an error but the command exists. Only count as
|
|
144
|
+
# "no dialect" when we see an explicit unknown-command message.
|
|
145
|
+
try:
|
|
146
|
+
proc = _sp.run([binary, "new-session", "-P"],
|
|
147
|
+
capture_output=True, text=True, timeout=5)
|
|
148
|
+
err = (proc.stderr or "").lower()
|
|
149
|
+
if proc.returncode != 0 and ("unknown command" in err or "invalid command" in err):
|
|
150
|
+
result = False
|
|
151
|
+
except (_sp.TimeoutExpired, FileNotFoundError, OSError):
|
|
152
|
+
pass
|
|
153
|
+
_tmux_dialect_cache[key] = result
|
|
154
|
+
return result
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def resolve_default_multiplexer(interactive: bool = False, quiet: bool = False) -> str:
|
|
158
|
+
"""Pick the best available multiplexer for the current platform.
|
|
159
|
+
|
|
160
|
+
Returns a string name (tmux, zellij, lmux, wmux, ...). When
|
|
161
|
+
`interactive` and no preference is installed, asks the user. When
|
|
162
|
+
`quiet`, never prompts — falls back to the first available, or "tmux".
|
|
163
|
+
"""
|
|
164
|
+
platform = sys.platform
|
|
165
|
+
if platform.startswith("win"):
|
|
166
|
+
key = "win"
|
|
167
|
+
elif platform == "darwin":
|
|
168
|
+
key = "mac"
|
|
169
|
+
else:
|
|
170
|
+
key = "linux"
|
|
171
|
+
|
|
172
|
+
preference = _PLATFORM_PREFERENCE[key]
|
|
173
|
+
available = [m for m in preference if _installed(m)]
|
|
174
|
+
|
|
175
|
+
primary = preference[0]
|
|
176
|
+
if _installed(primary):
|
|
177
|
+
return primary
|
|
178
|
+
if available:
|
|
179
|
+
return available[0]
|
|
180
|
+
|
|
181
|
+
# Nothing on the preference list is installed.
|
|
182
|
+
if interactive and sys.stdin.isatty():
|
|
183
|
+
options = [m for m in preference if _installed(m)] or _PLATFORM_PREFERENCE[key]
|
|
184
|
+
print("No preferred multiplexer found on this system. Available options:")
|
|
185
|
+
for i, opt in enumerate(options, start=1):
|
|
186
|
+
print(f" {i}. {opt}")
|
|
187
|
+
print("Which one? [1]: ", end="", flush=True)
|
|
188
|
+
try:
|
|
189
|
+
choice = int(input().strip() or "1")
|
|
190
|
+
except (ValueError, EOFError):
|
|
191
|
+
choice = 1
|
|
192
|
+
return options[choice - 1] if 1 <= choice <= len(options) else options[0]
|
|
193
|
+
|
|
194
|
+
# Quiet / non-interactive: fall back to tmux (the most universal one).
|
|
195
|
+
if _installed("tmux"):
|
|
196
|
+
return "tmux"
|
|
197
|
+
return "tmux" # the caller will surface the "install tmux" hint
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def get_multiplexer(name: str, config) -> MultiplexerBase:
|
|
201
|
+
"""Get a multiplexer instance by name."""
|
|
202
|
+
if name == "default" or not name:
|
|
203
|
+
# Resolve the platform-aware default; the user can override this in
|
|
204
|
+
# .workstreams.yaml by setting `multiplexer: tmux` (or any other).
|
|
205
|
+
name = resolve_default_multiplexer(interactive=False)
|
|
206
|
+
multiplexers = {
|
|
207
|
+
"tmux": TmuxMultiplexer,
|
|
208
|
+
"zellij": ZellijMultiplexer,
|
|
209
|
+
"lmux": LmuxMultiplexer,
|
|
210
|
+
"wmux": WmuxMultiplexer,
|
|
211
|
+
}
|
|
212
|
+
cls = multiplexers.get(name)
|
|
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)
|
|
218
|
+
raise ValueError(
|
|
219
|
+
f"Unknown multiplexer: {name}. "
|
|
220
|
+
f"Supported: {', '.join(multiplexers)} plus tmux-compatible: "
|
|
221
|
+
f"{', '.join(_TMUX_COMPATIBLE)} or 'default' (auto-detect)."
|
|
222
|
+
)
|
|
223
|
+
return cls(config)
|