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.
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/PKG-INFO +30 -4
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/README.md +29 -3
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/pyproject.toml +1 -1
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/__init__.py +1 -1
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/cli.py +13 -5
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/config.py +9 -1
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/models.py +1 -1
- workstreams_cli-0.6.2/src/workstreams/multiplexer/__init__.py +173 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams_cli.egg-info/PKG-INFO +30 -4
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams_cli.egg-info/SOURCES.txt +1 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/tests/test_config.py +5 -1
- workstreams_cli-0.6.2/tests/test_multiplexer_default.py +130 -0
- workstreams_cli-0.6.1/src/workstreams/multiplexer/__init__.py +0 -32
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/setup.cfg +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/confidence.py +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/dashboard.py +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/event_log.py +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/manager.py +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/multiplexer/base.py +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/multiplexer/tmux.py +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/multiplexer/tmux_compatible.py +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/multiplexer/zellij.py +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/notifier.py +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/py.typed +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/subagent_client.py +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams_cli.egg-info/dependency_links.txt +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams_cli.egg-info/entry_points.txt +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams_cli.egg-info/requires.txt +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams_cli.egg-info/top_level.txt +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/tests/test_confidence.py +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/tests/test_event_log.py +0 -0
- {workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/tests/test_models.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
| `
|
|
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.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
|
-
| `
|
|
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.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"
|
|
@@ -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,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.
|
|
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.
|
|
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
|
-
| `
|
|
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`).
|
|
@@ -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
|
-
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams/multiplexer/tmux_compatible.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{workstreams_cli-0.6.1 → workstreams_cli-0.6.2}/src/workstreams_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|