Skip to content

Commit c676400

Browse files
authored
feat(tmux): spawn teammates in windows when USE_TMUX_WINDOWS is set (#5)
* feat(tmux): spawn teammates in windows when USE_TMUX_WINDOWS is set * feat(tmux): prefix teammate window names with @claude-team
1 parent b3ca4e4 commit c676400

5 files changed

Lines changed: 78 additions & 9 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ Or add to `~/.config/opencode/opencode.json` (OpenCode):
6464
|------|-------------|
6565
| `team_create` | Create a new agent team. One team per server session. |
6666
| `team_delete` | Delete a team and all its data. Fails if teammates are still active. |
67-
| `spawn_teammate` | Spawn a teammate in a tmux pane (Claude or OpenCode backend). |
67+
| `spawn_teammate` | Spawn a teammate in tmux (pane by default, window when `USE_TMUX_WINDOWS` is set). |
6868
| `send_message` | Send direct messages, broadcasts, shutdown/plan approval responses. |
6969
| `read_inbox` | Read messages from an agent's inbox. |
7070
| `poll_inbox` | Long-poll an inbox for new messages (up to 30s). |
@@ -73,12 +73,12 @@ Or add to `~/.config/opencode/opencode.json` (OpenCode):
7373
| `task_update` | Update task status, owner, dependencies, or metadata. |
7474
| `task_list` | List all tasks for a team. |
7575
| `task_get` | Get full details of a specific task. |
76-
| `force_kill_teammate` | Forcibly kill a teammate's tmux pane and clean up. |
76+
| `force_kill_teammate` | Forcibly kill a teammate's tmux pane/window and clean up. |
7777
| `process_shutdown_approved` | Remove a teammate after graceful shutdown approval. |
7878

7979
## How it works
8080

81-
- **Spawning**: Teammates launch in tmux panes via `tmux split-window`. Backend can be Claude (`claude`) or OpenCode (`opencode`). Each gets a unique agent ID (`name@team`) and color.
81+
- **Spawning**: Teammates launch in tmux via `tmux split-window` (default) or `tmux new-window` when `USE_TMUX_WINDOWS` is set. Backend can be Claude (`claude`) or OpenCode (`opencode`). Each gets a unique agent ID (`name@team`) and color.
8282
- **Messaging**: JSON-based inboxes under `~/.claude/teams/<team>/inboxes/`. File locking (`fcntl`) prevents corruption from concurrent reads/writes.
8383
- **Tasks**: JSON task files under `~/.claude/tasks/<team>/`. Tasks have status tracking, ownership, and dependency management (`blocks`/`blockedBy`).
8484
- **Concurrency safety**: Atomic writes via `tempfile` + `os.replace` for config. `fcntl` file locks for inbox operations.

src/claude_teams/server.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,14 @@
2525
discover_opencode_models,
2626
kill_tmux_pane,
2727
spawn_teammate,
28+
use_tmux_windows,
2829
)
2930

3031
logger = logging.getLogger(__name__)
3132

3233

3334
_SPAWN_TOOL_BASE_DESCRIPTION = (
34-
"Spawn a new teammate in a tmux pane. The teammate receives its initial "
35+
"Spawn a new teammate in a tmux {target}. The teammate receives its initial "
3536
"prompt via inbox and begins working autonomously. Names must be unique "
3637
"within the team."
3738
)
@@ -44,7 +45,8 @@ def _build_spawn_description(
4445
opencode_server_url: str | None = None,
4546
opencode_agents: list[dict] | None = None,
4647
) -> str:
47-
parts = [_SPAWN_TOOL_BASE_DESCRIPTION]
48+
tmux_target = "window" if use_tmux_windows() else "pane"
49+
parts = [_SPAWN_TOOL_BASE_DESCRIPTION.format(target=tmux_target)]
4850
backends = []
4951
if claude_binary:
5052
backends.append("'claude' (default, models: sonnet, opus, haiku)")
@@ -162,7 +164,7 @@ def spawn_teammate_tool(
162164
plan_mode_required: bool = False,
163165
backend_type: Literal["claude", "opencode"] = "claude",
164166
) -> dict:
165-
"""Spawn a new teammate in a tmux pane. Description is dynamically updated
167+
"""Spawn a new teammate in tmux. Description is dynamically updated
166168
at startup with available backends and models."""
167169
ls = _get_lifespan(ctx)
168170
opencode_agent = None
@@ -558,9 +560,9 @@ def read_config(team_name: str) -> dict:
558560

559561
@mcp.tool
560562
def force_kill_teammate(team_name: str, agent_name: str, ctx: Context) -> dict:
561-
"""Forcibly kill a teammate's tmux pane. Use when graceful shutdown via
563+
"""Forcibly kill a teammate's tmux target. Use when graceful shutdown via
562564
send_message(type='shutdown_request') is not possible or not responding.
563-
Kills the tmux pane, removes member from config, and resets their tasks."""
565+
Kills the tmux pane/window, removes member from config, and resets their tasks."""
564566
oc_url = _get_lifespan(ctx).get("opencode_server_url")
565567
config = teams.read_config(team_name)
566568
member = None

src/claude_teams/spawner.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import os
34
import shlex
45
import shutil
56
import subprocess
@@ -33,6 +34,27 @@ def discover_harness_binary(name: str) -> str | None:
3334
return shutil.which(name)
3435

3536

37+
def use_tmux_windows() -> bool:
38+
"""Return True when teammate processes should be spawned in tmux windows."""
39+
return os.environ.get("USE_TMUX_WINDOWS") is not None
40+
41+
42+
def build_tmux_spawn_args(command: str, name: str) -> list[str]:
43+
"""Build the tmux command used to spawn a teammate process."""
44+
if use_tmux_windows():
45+
return [
46+
"tmux",
47+
"new-window",
48+
"-dP",
49+
"-F",
50+
"#{window_id}",
51+
"-n",
52+
f"@claude-team | {name}",
53+
command,
54+
]
55+
return ["tmux", "split-window", "-dP", "-F", "#{pane_id}", command]
56+
57+
3658
def discover_opencode_models(opencode_binary: str) -> list[str]:
3759
"""Run ``opencode models --refresh`` and return available model names."""
3860
try:
@@ -201,7 +223,7 @@ def spawn_teammate(
201223
cmd = build_spawn_command(member, claude_binary, lead_session_id)
202224

203225
result = subprocess.run(
204-
["tmux", "split-window", "-dP", "-F", "#{pane_id}", cmd],
226+
build_tmux_spawn_args(cmd, name),
205227
capture_output=True,
206228
text=True,
207229
check=True,
@@ -236,4 +258,7 @@ def spawn_teammate(
236258

237259

238260
def kill_tmux_pane(pane_id: str) -> None:
261+
if pane_id.startswith("@"):
262+
subprocess.run(["tmux", "kill-window", "-t", pane_id], check=False)
263+
return
239264
subprocess.run(["tmux", "kill-pane", "-t", pane_id], check=False)

tests/test_server.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -832,6 +832,16 @@ async def opencode_only_client(tmp_path: Path, monkeypatch):
832832

833833

834834
class TestBuildSpawnDescription:
835+
def test_should_reference_tmux_pane_by_default(self, monkeypatch) -> None:
836+
monkeypatch.delenv("USE_TMUX_WINDOWS", raising=False)
837+
desc = _build_spawn_description("/bin/claude", None, [])
838+
assert "tmux pane" in desc
839+
840+
def test_should_reference_tmux_window_when_enabled(self, monkeypatch) -> None:
841+
monkeypatch.setenv("USE_TMUX_WINDOWS", "1")
842+
desc = _build_spawn_description("/bin/claude", None, [])
843+
assert "tmux window" in desc
844+
835845
def test_both_backends_available(self) -> None:
836846
desc = _build_spawn_description(
837847
"/bin/claude",

tests/test_spawner.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,29 @@ def test_updates_pane_id(self, mock_subprocess: MagicMock, team_dir: Path) -> No
167167
found = [m for m in config.members if m.name == "researcher"]
168168
assert found[0].tmux_pane_id == "%42"
169169

170+
@patch("claude_teams.spawner.subprocess")
171+
def test_should_use_new_window_when_enabled(
172+
self,
173+
mock_subprocess: MagicMock,
174+
team_dir: Path,
175+
monkeypatch,
176+
) -> None:
177+
monkeypatch.setenv("USE_TMUX_WINDOWS", "0")
178+
mock_subprocess.run.return_value.stdout = "@42\n"
179+
member = spawn_teammate(
180+
TEAM,
181+
"window-worker",
182+
"Do research",
183+
"/usr/local/bin/claude",
184+
SESSION_ID,
185+
base_dir=team_dir,
186+
)
187+
assert member.tmux_pane_id == "@42"
188+
call_args = mock_subprocess.run.call_args[0][0]
189+
assert call_args[:5] == ["tmux", "new-window", "-dP", "-F", "#{window_id}"]
190+
assert "-n" in call_args
191+
assert call_args[call_args.index("-n") + 1] == "@claude-team | window-worker"
192+
170193
@patch("claude_teams.spawner.subprocess.run")
171194
def test_should_rollback_member_when_tmux_spawn_fails(
172195
self, mock_run: MagicMock, team_dir: Path
@@ -197,6 +220,15 @@ def test_calls_subprocess(self, mock_subprocess: MagicMock) -> None:
197220
["tmux", "kill-pane", "-t", "%99"], check=False
198221
)
199222

223+
@patch("claude_teams.spawner.subprocess")
224+
def test_calls_kill_window_for_window_target(
225+
self, mock_subprocess: MagicMock
226+
) -> None:
227+
kill_tmux_pane("@99")
228+
mock_subprocess.run.assert_called_once_with(
229+
["tmux", "kill-window", "-t", "@99"], check=False
230+
)
231+
200232

201233
class TestBuildOpencodeAttachCommand:
202234
def test_should_contain_attach_with_session_and_dir(self) -> None:

0 commit comments

Comments
 (0)