workstreams-cli 0.6.1__tar.gz → 0.6.2__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 (33) hide show
  1. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/PKG-INFO +30 -4
  2. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/README.md +29 -3
  3. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/pyproject.toml +1 -1
  4. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/__init__.py +1 -1
  5. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/cli.py +13 -5
  6. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/config.py +9 -1
  7. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/models.py +1 -1
  8. workstreams_cli-0.6.2/src/workstreams/multiplexer/__init__.py +173 -0
  9. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams_cli.egg-info/PKG-INFO +30 -4
  10. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams_cli.egg-info/SOURCES.txt +1 -0
  11. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/tests/test_config.py +5 -1
  12. workstreams_cli-0.6.2/tests/test_multiplexer_default.py +130 -0
  13. workstreams_cli-0.6.1/src/workstreams/multiplexer/__init__.py +0 -32
  14. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/setup.cfg +0 -0
  15. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/confidence.py +0 -0
  16. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/dashboard.py +0 -0
  17. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/event_log.py +0 -0
  18. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/manager.py +0 -0
  19. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/multiplexer/base.py +0 -0
  20. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/multiplexer/tmux.py +0 -0
  21. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/multiplexer/tmux_compatible.py +0 -0
  22. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/multiplexer/zellij.py +0 -0
  23. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/notifier.py +0 -0
  24. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/py.typed +0 -0
  25. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/subagent_client.py +0 -0
  26. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams_cli.egg-info/dependency_links.txt +0 -0
  27. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams_cli.egg-info/entry_points.txt +0 -0
  28. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams_cli.egg-info/requires.txt +0 -0
  29. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams_cli.egg-info/top_level.txt +0 -0
  30. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/tests/test_confidence.py +0 -0
  31. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/tests/test_event_log.py +0 -0
  32. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/tests/test_models.py +0 -0
  33. {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/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.1
3
+ Version: 0.6.2
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.1
117
+ workstreams --version # -> workstreams 0.6.2
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
- | `tmux` (default) | `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. |
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
- The tmux/zellij session is auto-named `workstreams-<project>`.
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.1
87
+ workstreams --version # -> workstreams 0.6.2
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
- | `tmux` (default) | `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. |
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
- The tmux/zellij session is auto-named `workstreams-<project>`.
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.1"
7
+ version = "0.6.2"
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.1"
45
+ __version__ = "0.6.2"
46
46
 
47
47
  __all__ = [
48
48
  # models
@@ -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=data.get("multiplexer") or os.environ.get("WORKSTREAMS_MULTIPLEXER") or "tmux",
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 = "tmux"
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,173 @@
1
+ """Terminal multiplexer implementations + platform-aware default detection."""
2
+
3
+ import shutil
4
+ import sys
5
+ from typing import Dict
6
+
7
+ from .base import MultiplexerBase
8
+ from .tmux import TmuxMultiplexer
9
+ from .zellij import ZellijMultiplexer
10
+ from .tmux_compatible import TmuxCompatibleMultiplexer
11
+
12
+ __all__ = [
13
+ "MultiplexerBase",
14
+ "TmuxMultiplexer",
15
+ "ZellijMultiplexer",
16
+ "get_multiplexer",
17
+ "resolve_default_multiplexer",
18
+ ]
19
+
20
+ # Name -> binary that must be on PATH. The TmuxCompatibleMultiplexer wraps
21
+ # any binary whose CLI is compatible with tmux (send-keys, new-window, ...).
22
+ _TMUX_COMPATIBLE = {
23
+ "nami": "nami",
24
+ "lmux": "lmux",
25
+ "wmux": "wmux",
26
+ "herdr": "herdr",
27
+ }
28
+
29
+ # Ordered preference list per platform. We pick the FIRST that is installed.
30
+ _PLATFORM_PREFERENCE = {
31
+ "win": ["wmux", "lmux", "zellij", "tmux"],
32
+ "mac": ["tmux", "zellij", "nami", "lmux", "wmux"],
33
+ "linux": ["lmux", "zellij", "tmux"],
34
+ }
35
+
36
+
37
+ def _installed(name: str) -> bool:
38
+ """Return True if the CLI binary for `name` is on PATH.
39
+
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.
45
+ """
46
+ if name in ("tmux", "zellij"):
47
+ binary = name
48
+ elif name in _TMUX_COMPATIBLE:
49
+ binary = _TMUX_COMPATIBLE[name]
50
+ else:
51
+ return False
52
+ if shutil.which(binary) is None:
53
+ return False
54
+ if name in _TMUX_COMPATIBLE:
55
+ return _supports_tmux_dialect(binary)
56
+ return True
57
+
58
+
59
+ _tmux_dialect_cache: Dict[str, bool] = {}
60
+
61
+
62
+ def _supports_tmux_dialect(binary: str) -> bool:
63
+ """Heuristic: does `binary` accept tmux-style subcommands?
64
+
65
+ Probing method:
66
+ 1. Run `<binary> help` — look for canonical tmux verbs
67
+ (new-session / has-session / send-keys / capture-pane).
68
+ 2. If that's inconclusive (tmux has no `help` subcommand and dumps
69
+ to stderr instead), run `<binary> new-session -P` and treat
70
+ "command not found" / "unknown command" in stderr as a negative.
71
+
72
+ For `lmux` the help text exposes `workspace.create` / `surface.send-key`
73
+ instead of the tmux verbs, so this probe correctly returns False and
74
+ auto-detect skips it — only an explicit user-configured `lmux` will
75
+ attempt the wrapper.
76
+ """
77
+ import subprocess as _sp
78
+ key = binary
79
+ if key in _tmux_dialect_cache:
80
+ return _tmux_dialect_cache[key]
81
+ result = False
82
+ try:
83
+ proc = _sp.run([binary, "help"], capture_output=True, text=True, timeout=5)
84
+ help_text = (proc.stdout or "") + (proc.stderr or "")
85
+ # Require BOTH a session verb and a key verb. `lmux` happens to
86
+ # mention "capture-pane" in one subcommand's description but is
87
+ # missing session.send-keys / new-session entirely, so a single
88
+ # hit is not enough to call it tmux-compatible.
89
+ has_session = any(m in help_text for m in ("new-session", "has-session"))
90
+ has_keys = any(m in help_text for m in ("send-keys", "send-text"))
91
+ result = has_session and has_keys
92
+ except (_sp.TimeoutExpired, FileNotFoundError, OSError):
93
+ result = False
94
+ if not result:
95
+ # Secondary probe: tmux has no `help`; `new-session -P` on a bad
96
+ # socket prints an error but the command exists. Only count as
97
+ # "no dialect" when we see an explicit unknown-command message.
98
+ try:
99
+ proc = _sp.run([binary, "new-session", "-P"],
100
+ capture_output=True, text=True, timeout=5)
101
+ err = (proc.stderr or "").lower()
102
+ if proc.returncode != 0 and ("unknown command" in err or "invalid command" in err):
103
+ result = False
104
+ except (_sp.TimeoutExpired, FileNotFoundError, OSError):
105
+ pass
106
+ _tmux_dialect_cache[key] = result
107
+ return result
108
+
109
+
110
+ def resolve_default_multiplexer(interactive: bool = False, quiet: bool = False) -> str:
111
+ """Pick the best available multiplexer for the current platform.
112
+
113
+ Returns a string name (tmux, zellij, lmux, wmux, ...). When
114
+ `interactive` and no preference is installed, asks the user. When
115
+ `quiet`, never prompts — falls back to the first available, or "tmux".
116
+ """
117
+ platform = sys.platform
118
+ if platform.startswith("win"):
119
+ key = "win"
120
+ elif platform == "darwin":
121
+ key = "mac"
122
+ else:
123
+ key = "linux"
124
+
125
+ preference = _PLATFORM_PREFERENCE[key]
126
+ available = [m for m in preference if _installed(m)]
127
+
128
+ primary = preference[0]
129
+ if _installed(primary):
130
+ return primary
131
+ if available:
132
+ return available[0]
133
+
134
+ # Nothing on the preference list is installed.
135
+ if interactive and sys.stdin.isatty():
136
+ options = [m for m in preference if _installed(m)] or _PLATFORM_PREFERENCE[key]
137
+ print("No preferred multiplexer found on this system. Available options:")
138
+ for i, opt in enumerate(options, start=1):
139
+ print(f" {i}. {opt}")
140
+ print("Which one? [1]: ", end="", flush=True)
141
+ try:
142
+ choice = int(input().strip() or "1")
143
+ except (ValueError, EOFError):
144
+ choice = 1
145
+ return options[choice - 1] if 1 <= choice <= len(options) else options[0]
146
+
147
+ # Quiet / non-interactive: fall back to tmux (the most universal one).
148
+ if _installed("tmux"):
149
+ return "tmux"
150
+ return "tmux" # the caller will surface the "install tmux" hint
151
+
152
+
153
+ def get_multiplexer(name: str, config) -> MultiplexerBase:
154
+ """Get a multiplexer instance by name."""
155
+ if name == "default" or not name:
156
+ # Resolve the platform-aware default; the user can override this in
157
+ # .workstreams.yaml by setting `multiplexer: tmux` (or any other).
158
+ name = resolve_default_multiplexer(interactive=False)
159
+ multiplexers = {
160
+ "tmux": TmuxMultiplexer,
161
+ "zellij": ZellijMultiplexer,
162
+ }
163
+ if name in _TMUX_COMPATIBLE:
164
+ binary = _TMUX_COMPATIBLE[name]
165
+ return TmuxCompatibleMultiplexer(config, binary)
166
+ cls = multiplexers.get(name)
167
+ if not cls:
168
+ raise ValueError(
169
+ f"Unknown multiplexer: {name}. "
170
+ f"Supported: {', '.join(multiplexers)} plus tmux-compatible: {', '.join(_TMUX_COMPATIBLE)} "
171
+ f"or 'default' (auto-detect)."
172
+ )
173
+ return cls(config)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workstreams-cli
3
- Version: 0.6.1
3
+ Version: 0.6.2
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.1
117
+ workstreams --version # -> workstreams 0.6.2
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
- | `tmux` (default) | `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. |
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
- The tmux/zellij session is auto-named `workstreams-<project>`.
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`).
@@ -26,4 +26,5 @@ tests/test_confidence.py
26
26
  tests/test_config.py
27
27
  tests/test_event_log.py
28
28
  tests/test_models.py
29
+ tests/test_multiplexer_default.py
29
30
  tests/test_notifier.py
@@ -64,7 +64,11 @@ def test_load_nonexistent_returns_default_project(tmp_path):
64
64
  loaded = load_config(project_dir, "fallback-proj")
65
65
  assert loaded is not None
66
66
  assert loaded.project == "fallback-proj"
67
- assert loaded.multiplexer == "tmux"
67
+ # A bare load now auto-resolves the platform default multiplexer (e.g.
68
+ # lmux on Linux), so assert it's a *concrete* name, not the "default"
69
+ # sentinel.
70
+ assert loaded.multiplexer != "default"
71
+ assert loaded.multiplexer in {"tmux", "zellij", "lmux", "wmux", "nami", "herdr"}
68
72
 
69
73
 
70
74
  def test_load_json_when_no_yaml_present(tmp_path):
@@ -0,0 +1,130 @@
1
+ """Tests for platform-aware default multiplexer resolution."""
2
+
3
+ import sys
4
+ sys.path.insert(0, "src")
5
+
6
+ from unittest import mock
7
+ from pathlib import Path
8
+
9
+ from workstreams.multiplexer import (
10
+ resolve_default_multiplexer,
11
+ get_multiplexer,
12
+ _PLATFORM_PREFERENCE,
13
+ _installed,
14
+ )
15
+ from workstreams.models import WorkstreamsConfig
16
+
17
+
18
+ def _cfg():
19
+ return WorkstreamsConfig(
20
+ project="p", multiplexer="default", layout="even-horizontal",
21
+ base_branch="main", workstreams=[], shared_deps=[], base_path=".",
22
+ agent="auto",
23
+ )
24
+
25
+
26
+ def test_default_resolves_to_a_concrete_name():
27
+ # Whatever this machine has, "default" must never come back as "default".
28
+ name = resolve_default_multiplexer(interactive=False)
29
+ assert name != "default"
30
+ assert name in {"tmux", "zellij", "lmux", "wmux", "nami", "herdr"}
31
+
32
+
33
+ def test_primary_wins_when_installed(monkeypatch):
34
+ # Simulate: only the platform primary is installed.
35
+ monkeypatch.setattr("workstreams.multiplexer._installed",
36
+ lambda n: n == _PLATFORM_PREFERENCE["linux"][0])
37
+ got = resolve_default_multiplexer(interactive=False)
38
+ assert got == _PLATFORM_PREFERENCE["linux"][0]
39
+
40
+
41
+ def test_falls_back_to_first_available_when_primary_missing(monkeypatch):
42
+ # Primary not installed, but the second preference is.
43
+ primary = _PLATFORM_PREFERENCE["linux"][0]
44
+ second = _PLATFORM_PREFERENCE["linux"][1]
45
+ monkeypatch.setattr("workstreams.multiplexer._installed",
46
+ lambda n: n == second)
47
+ got = resolve_default_multiplexer(interactive=False)
48
+ assert got == second
49
+ assert got != primary
50
+
51
+
52
+ def test_nothing_installed_returns_tmux(monkeypatch):
53
+ monkeypatch.setattr("workstreams.multiplexer._installed", lambda n: False)
54
+ got = resolve_default_multiplexer(interactive=False)
55
+ assert got == "tmux"
56
+
57
+
58
+ def test_get_multiplexer_default_returns_concrete_class():
59
+ mux = get_multiplexer("default", _cfg())
60
+ # Should be one of the concrete multiplexer classes, not None / not default.
61
+ from workstreams.multiplexer import MultiplexerBase
62
+ assert isinstance(mux, MultiplexerBase)
63
+ assert mux.name != "default"
64
+
65
+
66
+ def test_get_multiplexer_empty_string_resolves():
67
+ mux = get_multiplexer("", _cfg())
68
+ from workstreams.multiplexer import MultiplexerBase
69
+ assert isinstance(mux, MultiplexerBase)
70
+ assert mux.name != "default"
71
+
72
+
73
+ def test_explicit_name_still_works(monkeypatch):
74
+ # "tmux" must be honored even if the resolver would pick otherwise.
75
+ monkeypatch.setattr("workstreams.multiplexer._installed", lambda n: False)
76
+ mux = get_multiplexer("tmux", _cfg())
77
+ assert mux.name == "tmux"
78
+
79
+
80
+ def test_unknown_name_raises():
81
+ try:
82
+ get_multiplexer("definitely-not-a-mux", _cfg())
83
+ raised = False
84
+ except ValueError:
85
+ raised = True
86
+ assert raised, "unknown multiplexer name should raise ValueError"
87
+
88
+
89
+ def test_platform_preference_lists_are_sane():
90
+ # Each platform's primary must be a known multiplexer name.
91
+ for key, pref in _PLATFORM_PREFERENCE.items():
92
+ assert len(pref) >= 3
93
+ for m in pref:
94
+ assert m in {"tmux", "zellij", "lmux", "wmux", "nami", "herdr"}
95
+
96
+ def test_lmux_not_tmux_compatible(monkeypatch):
97
+ """Regression: lmux's CLI is workspace/surface-based, not tmux-
98
+ compatible. Even when installed, _installed('lmux') must return
99
+ False so auto-detect skips it and falls to zellij/tmux."""
100
+ import workstreams.multiplexer as m
101
+ m._tmux_dialect_cache.clear()
102
+ import shutil
103
+ monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/lmux" if name == "lmux" else None)
104
+ monkeypatch.setattr("workstreams.multiplexer._supports_tmux_dialect", lambda b: False)
105
+ assert m._installed("lmux") is False
106
+
107
+
108
+ def test_zellij_dialect_recognised(monkeypatch):
109
+ """zellij IS in the platform list and we trust its CLI shape via
110
+ the explicit class; the dialect probe is only for the wrapper
111
+ family."""
112
+ import workstreams.multiplexer as m
113
+ m._tmux_dialect_cache.clear()
114
+ import shutil
115
+ monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/zellij")
116
+ # zellij goes through the non-wrapper path, so _installed is True
117
+ # purely based on `shutil.which`.
118
+ assert m._installed("zellij") is True
119
+
120
+
121
+ def test_auto_detect_falls_past_incompatible_lmux(monkeypatch):
122
+ """On Linux, if only 'lmux' and 'zellij' are installed and lmux is
123
+ not tmux-compatible, auto-detect must return zellij."""
124
+ import workstreams.multiplexer as m
125
+ import shutil
126
+ m._tmux_dialect_cache.clear()
127
+ monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/lmux" if name in ("lmux","zellij") else None)
128
+ monkeypatch.setattr("workstreams.multiplexer._supports_tmux_dialect", lambda b: b == "zellij")
129
+ got = m.resolve_default_multiplexer(interactive=False)
130
+ assert got == "zellij"
@@ -1,32 +0,0 @@
1
- """Terminal multiplexer implementations."""
2
- """Terminal multiplexer implementations."""
3
-
4
- from .base import MultiplexerBase
5
- from .tmux import TmuxMultiplexer
6
- from .zellij import ZellijMultiplexer
7
- from .tmux_compatible import TmuxCompatibleMultiplexer
8
-
9
- __all__ = ["MultiplexerBase", "TmuxMultiplexer", "ZellijMultiplexer", "get_multiplexer"]
10
-
11
- _TMUX_COMPATIBLE = {
12
- "nami": "nami",
13
- "lmux": "lmux",
14
- "wmux": "wmux",
15
- "herdr": "herdr",
16
- }
17
-
18
- def get_multiplexer(name: str, config) -> MultiplexerBase:
19
- """Get a multiplexer instance by name."""
20
- multiplexers = {
21
- "tmux": TmuxMultiplexer,
22
- "zellij": ZellijMultiplexer,
23
- }
24
- if name in _TMUX_COMPATIBLE:
25
- binary = _TMUX_COMPATIBLE[name]
26
- return TmuxCompatibleMultiplexer(config, binary)
27
- cls = multiplexers.get(name)
28
- if not cls:
29
- raise ValueError(
30
- f"Unknown multiplexer: {name}. Supported: {', '.join(multiplexers)} plus tmux-compatible: {', '.join(_TMUX_COMPATIBLE)}"
31
- )
32
- return cls(config)