Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Or add to `~/.config/opencode/opencode.json` (OpenCode):
|------|-------------|
| `team_create` | Create a new agent team. One team per server session. |
| `team_delete` | Delete a team and all its data. Fails if teammates are still active. |
| `spawn_teammate` | Spawn a teammate in a tmux pane (Claude or OpenCode backend). |
| `spawn_teammate` | Spawn a teammate in tmux (pane by default, window when `USE_TMUX_WINDOWS` is set). |
| `send_message` | Send direct messages, broadcasts, shutdown/plan approval responses. |
| `read_inbox` | Read messages from an agent's inbox. |
| `poll_inbox` | Long-poll an inbox for new messages (up to 30s). |
Expand All @@ -73,12 +73,12 @@ Or add to `~/.config/opencode/opencode.json` (OpenCode):
| `task_update` | Update task status, owner, dependencies, or metadata. |
| `task_list` | List all tasks for a team. |
| `task_get` | Get full details of a specific task. |
| `force_kill_teammate` | Forcibly kill a teammate's tmux pane and clean up. |
| `force_kill_teammate` | Forcibly kill a teammate's tmux pane/window and clean up. |
| `process_shutdown_approved` | Remove a teammate after graceful shutdown approval. |

## How it works

- **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.
- **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.
- **Messaging**: JSON-based inboxes under `~/.claude/teams/<team>/inboxes/`. File locking (`fcntl`) prevents corruption from concurrent reads/writes.
- **Tasks**: JSON task files under `~/.claude/tasks/<team>/`. Tasks have status tracking, ownership, and dependency management (`blocks`/`blockedBy`).
- **Concurrency safety**: Atomic writes via `tempfile` + `os.replace` for config. `fcntl` file locks for inbox operations.
Expand Down
12 changes: 7 additions & 5 deletions src/claude_teams/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,14 @@
discover_opencode_models,
kill_tmux_pane,
spawn_teammate,
use_tmux_windows,
)

logger = logging.getLogger(__name__)


_SPAWN_TOOL_BASE_DESCRIPTION = (
"Spawn a new teammate in a tmux pane. The teammate receives its initial "
"Spawn a new teammate in a tmux {target}. The teammate receives its initial "
"prompt via inbox and begins working autonomously. Names must be unique "
"within the team."
)
Expand All @@ -44,7 +45,8 @@ def _build_spawn_description(
opencode_server_url: str | None = None,
opencode_agents: list[dict] | None = None,
) -> str:
parts = [_SPAWN_TOOL_BASE_DESCRIPTION]
tmux_target = "window" if use_tmux_windows() else "pane"
parts = [_SPAWN_TOOL_BASE_DESCRIPTION.format(target=tmux_target)]
backends = []
if claude_binary:
backends.append("'claude' (default, models: sonnet, opus, haiku)")
Expand Down Expand Up @@ -162,7 +164,7 @@ def spawn_teammate_tool(
plan_mode_required: bool = False,
backend_type: Literal["claude", "opencode"] = "claude",
) -> dict:
"""Spawn a new teammate in a tmux pane. Description is dynamically updated
"""Spawn a new teammate in tmux. Description is dynamically updated
at startup with available backends and models."""
ls = _get_lifespan(ctx)
opencode_agent = None
Expand Down Expand Up @@ -558,9 +560,9 @@ def read_config(team_name: str) -> dict:

@mcp.tool
def force_kill_teammate(team_name: str, agent_name: str, ctx: Context) -> dict:
"""Forcibly kill a teammate's tmux pane. Use when graceful shutdown via
"""Forcibly kill a teammate's tmux target. Use when graceful shutdown via
send_message(type='shutdown_request') is not possible or not responding.
Kills the tmux pane, removes member from config, and resets their tasks."""
Kills the tmux pane/window, removes member from config, and resets their tasks."""
oc_url = _get_lifespan(ctx).get("opencode_server_url")
config = teams.read_config(team_name)
member = None
Expand Down
27 changes: 26 additions & 1 deletion src/claude_teams/spawner.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import os
import shlex
import shutil
import subprocess
Expand Down Expand Up @@ -33,6 +34,27 @@ def discover_harness_binary(name: str) -> str | None:
return shutil.which(name)


def use_tmux_windows() -> bool:
"""Return True when teammate processes should be spawned in tmux windows."""
return os.environ.get("USE_TMUX_WINDOWS") is not None


def build_tmux_spawn_args(command: str, name: str) -> list[str]:
"""Build the tmux command used to spawn a teammate process."""
if use_tmux_windows():
return [
"tmux",
"new-window",
"-dP",
"-F",
"#{window_id}",
"-n",
f"@claude-team | {name}",
command,
]
return ["tmux", "split-window", "-dP", "-F", "#{pane_id}", command]


def discover_opencode_models(opencode_binary: str) -> list[str]:
"""Run ``opencode models --refresh`` and return available model names."""
try:
Expand Down Expand Up @@ -201,7 +223,7 @@ def spawn_teammate(
cmd = build_spawn_command(member, claude_binary, lead_session_id)

result = subprocess.run(
["tmux", "split-window", "-dP", "-F", "#{pane_id}", cmd],
build_tmux_spawn_args(cmd, name),
capture_output=True,
text=True,
check=True,
Expand Down Expand Up @@ -236,4 +258,7 @@ def spawn_teammate(


def kill_tmux_pane(pane_id: str) -> None:
if pane_id.startswith("@"):
subprocess.run(["tmux", "kill-window", "-t", pane_id], check=False)
return
subprocess.run(["tmux", "kill-pane", "-t", pane_id], check=False)
10 changes: 10 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,16 @@ async def opencode_only_client(tmp_path: Path, monkeypatch):


class TestBuildSpawnDescription:
def test_should_reference_tmux_pane_by_default(self, monkeypatch) -> None:
monkeypatch.delenv("USE_TMUX_WINDOWS", raising=False)
desc = _build_spawn_description("/bin/claude", None, [])
assert "tmux pane" in desc

def test_should_reference_tmux_window_when_enabled(self, monkeypatch) -> None:
monkeypatch.setenv("USE_TMUX_WINDOWS", "1")
desc = _build_spawn_description("/bin/claude", None, [])
assert "tmux window" in desc

def test_both_backends_available(self) -> None:
desc = _build_spawn_description(
"/bin/claude",
Expand Down
32 changes: 32 additions & 0 deletions tests/test_spawner.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,29 @@ def test_updates_pane_id(self, mock_subprocess: MagicMock, team_dir: Path) -> No
found = [m for m in config.members if m.name == "researcher"]
assert found[0].tmux_pane_id == "%42"

@patch("claude_teams.spawner.subprocess")
def test_should_use_new_window_when_enabled(
self,
mock_subprocess: MagicMock,
team_dir: Path,
monkeypatch,
) -> None:
monkeypatch.setenv("USE_TMUX_WINDOWS", "0")
mock_subprocess.run.return_value.stdout = "@42\n"
member = spawn_teammate(
TEAM,
"window-worker",
"Do research",
"/usr/local/bin/claude",
SESSION_ID,
base_dir=team_dir,
)
assert member.tmux_pane_id == "@42"
call_args = mock_subprocess.run.call_args[0][0]
assert call_args[:5] == ["tmux", "new-window", "-dP", "-F", "#{window_id}"]
assert "-n" in call_args
assert call_args[call_args.index("-n") + 1] == "@claude-team | window-worker"

@patch("claude_teams.spawner.subprocess.run")
def test_should_rollback_member_when_tmux_spawn_fails(
self, mock_run: MagicMock, team_dir: Path
Expand Down Expand Up @@ -197,6 +220,15 @@ def test_calls_subprocess(self, mock_subprocess: MagicMock) -> None:
["tmux", "kill-pane", "-t", "%99"], check=False
)

@patch("claude_teams.spawner.subprocess")
def test_calls_kill_window_for_window_target(
self, mock_subprocess: MagicMock
) -> None:
kill_tmux_pane("@99")
mock_subprocess.run.assert_called_once_with(
["tmux", "kill-window", "-t", "@99"], check=False
)


class TestBuildOpencodeAttachCommand:
def test_should_contain_attach_with_session_and_dir(self) -> None:
Expand Down