From 6806477888940d3eac890d73dd19e7497aaaa632 Mon Sep 17 00:00:00 2001 From: lingyuzeng Date: Sun, 8 Mar 2026 17:05:11 +0800 Subject: [PATCH 1/8] Add configurable HTTP host and host allowlist --- src/maniple_mcp/server.py | 103 +++++++++++++++++++++++++++++++-- tests/test_server_http_cli.py | 106 ++++++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 tests/test_server_http_cli.py diff --git a/src/maniple_mcp/server.py b/src/maniple_mcp/server.py index 76a0ca9..17a949d 100644 --- a/src/maniple_mcp/server.py +++ b/src/maniple_mcp/server.py @@ -15,6 +15,7 @@ from mcp.server.fastmcp import Context, FastMCP from mcp.server.session import ServerSession +from mcp.server.transport_security import TransportSecuritySettings from maniple.events import get_latest_snapshot, prune_event_backups, read_events_since from maniple.poller import WorkerPoller @@ -28,6 +29,9 @@ logger = logging.getLogger("maniple") EVENT_BACKUP_CAP_MB = 200 +LOCAL_HTTP_HOSTS = ("127.0.0.1", "localhost", "::1") +LOCAL_ALLOWED_HOSTS = ["127.0.0.1:*", "localhost:*", "[::1]:*"] +LOCAL_ALLOWED_ORIGINS = ["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"] # ============================================================================= @@ -338,6 +342,7 @@ def create_mcp_server( host: str = "127.0.0.1", port: int = 8766, enable_poller: bool = False, + transport_security: TransportSecuritySettings | None = None, ) -> FastMCP: """Create and configure the FastMCP server instance.""" server = FastMCP( @@ -345,6 +350,7 @@ def create_mcp_server( lifespan=functools.partial(app_lifespan, enable_poller=enable_poller), host=host, port=port, + transport_security=transport_security, ) # Register all tools from the tools package register_all_tools(server, ensure_connection) @@ -474,19 +480,79 @@ async def resource_session_screen( # ============================================================================= -def run_server(transport: str = "stdio", port: int = 8766): +def _dedupe_preserving_order(values: list[str]) -> list[str]: + """Return values with duplicates removed while keeping the first occurrence.""" + return list(dict.fromkeys(values)) + + +def build_transport_security_settings( + *, + host: str, + allowed_hosts: list[str] | None = None, + allowed_origins: list[str] | None = None, + disable_dns_rebinding_protection: bool = False, +) -> TransportSecuritySettings | None: + """ + Build explicit transport security settings for HTTP mode. + + Returns None when no explicit overrides are requested so FastMCP can keep + its built-in localhost defaults unchanged. + """ + if not allowed_hosts and not allowed_origins and not disable_dns_rebinding_protection: + return None + + merged_hosts = list(allowed_hosts or []) + merged_origins = list(allowed_origins or []) + + if not disable_dns_rebinding_protection and host in LOCAL_HTTP_HOSTS: + merged_hosts = [*LOCAL_ALLOWED_HOSTS, *merged_hosts] + merged_origins = [*LOCAL_ALLOWED_ORIGINS, *merged_origins] + + return TransportSecuritySettings( + enable_dns_rebinding_protection=not disable_dns_rebinding_protection, + allowed_hosts=_dedupe_preserving_order(merged_hosts), + allowed_origins=_dedupe_preserving_order(merged_origins), + ) + + +def run_server( + transport: str = "stdio", + *, + host: str = "127.0.0.1", + port: int = 8766, + allowed_hosts: list[str] | None = None, + allowed_origins: list[str] | None = None, + disable_dns_rebinding_protection: bool = False, +): """ Run the MCP server. Args: transport: Transport mode - "stdio" or "streamable-http" + host: Bind address for HTTP transport port: Port for HTTP transport (default 8766) """ log_path = configure_logging() if transport == "streamable-http": - logger.info("Starting Maniple MCP Server (HTTP on port %s). Logs: %s", port, log_path) + logger.info( + "Starting Maniple MCP Server (HTTP on %s:%s). Logs: %s", + host, + port, + log_path, + ) + transport_security = build_transport_security_settings( + host=host, + allowed_hosts=allowed_hosts, + allowed_origins=allowed_origins, + disable_dns_rebinding_protection=disable_dns_rebinding_protection, + ) # Create server with configured port for HTTP mode - server = create_mcp_server(host="127.0.0.1", port=port, enable_poller=True) + server = create_mcp_server( + host=host, + port=port, + enable_poller=True, + transport_security=transport_security, + ) server.run(transport="streamable-http") else: logger.info("Starting Maniple MCP Server (stdio). Logs: %s", log_path) @@ -505,12 +571,34 @@ def main(): action="store_true", help="Run in HTTP mode (streamable-http) instead of stdio", ) + parser.add_argument( + "--host", + default="127.0.0.1", + help="Bind address for HTTP mode (default: 127.0.0.1)", + ) parser.add_argument( "--port", type=int, default=8766, help="Port for HTTP mode (default: 8766)", ) + parser.add_argument( + "--allow-host", + action="append", + default=None, + help="Allow this HTTP Host header when DNS rebinding protection is enabled. Repeatable.", + ) + parser.add_argument( + "--allow-origin", + action="append", + default=None, + help="Allow this HTTP Origin header when DNS rebinding protection is enabled. Repeatable.", + ) + parser.add_argument( + "--disable-dns-rebinding-protection", + action="store_true", + help="Disable HTTP DNS rebinding protection for streamable-http mode.", + ) # Config subcommands for reading/writing ~/.maniple/config.json. subparsers = parser.add_subparsers(dest="command") @@ -627,7 +715,14 @@ def main(): # Default behavior: run the MCP server. if args.http: - run_server(transport="streamable-http", port=args.port) + run_server( + transport="streamable-http", + host=args.host, + port=args.port, + allowed_hosts=args.allow_host, + allowed_origins=args.allow_origin, + disable_dns_rebinding_protection=args.disable_dns_rebinding_protection, + ) else: run_server(transport="stdio") diff --git a/tests/test_server_http_cli.py b/tests/test_server_http_cli.py new file mode 100644 index 0000000..001da96 --- /dev/null +++ b/tests/test_server_http_cli.py @@ -0,0 +1,106 @@ +"""Tests for HTTP CLI options and transport security configuration.""" + +import sys + +import maniple_mcp.server as server_module + + +def test_build_transport_security_settings_defaults_to_fastmcp_behavior(): + """No explicit overrides should preserve FastMCP's built-in defaults.""" + settings = server_module.build_transport_security_settings(host="127.0.0.1") + assert settings is None + + +def test_build_transport_security_settings_merges_localhost_defaults(): + """Explicit allow-lists on localhost should keep local defaults available.""" + settings = server_module.build_transport_security_settings( + host="127.0.0.1", + allowed_hosts=["100.64.0.45:8766"], + allowed_origins=["https://manager.example.com"], + ) + + assert settings is not None + assert settings.enable_dns_rebinding_protection is True + assert "127.0.0.1:*" in settings.allowed_hosts + assert "localhost:*" in settings.allowed_hosts + assert "100.64.0.45:8766" in settings.allowed_hosts + assert "http://127.0.0.1:*" in settings.allowed_origins + assert "https://manager.example.com" in settings.allowed_origins + + +def test_build_transport_security_settings_can_disable_rebinding_protection(): + """The explicit disable flag should override localhost auto-protection.""" + settings = server_module.build_transport_security_settings( + host="127.0.0.1", + disable_dns_rebinding_protection=True, + ) + + assert settings is not None + assert settings.enable_dns_rebinding_protection is False + assert settings.allowed_hosts == [] + assert settings.allowed_origins == [] + + +def test_run_server_http_uses_custom_host_and_security(monkeypatch): + """HTTP mode should forward host and transport security to FastMCP.""" + captured: dict[str, object] = {} + + class DummyServer: + def run(self, *, transport: str) -> None: + captured["transport"] = transport + + def fake_create_mcp_server(**kwargs): + captured.update(kwargs) + return DummyServer() + + monkeypatch.setattr(server_module, "create_mcp_server", fake_create_mcp_server) + monkeypatch.setattr(server_module, "configure_logging", lambda: "/tmp/maniple.log") + + server_module.run_server( + transport="streamable-http", + host="100.64.0.45", + port=8766, + allowed_hosts=["100.64.0.45:8766"], + ) + + assert captured["host"] == "100.64.0.45" + assert captured["port"] == 8766 + assert captured["enable_poller"] is True + assert captured["transport"] == "streamable-http" + settings = captured["transport_security"] + assert settings is not None + assert settings.allowed_hosts == ["100.64.0.45:8766"] + + +def test_main_parses_http_host_security_flags(monkeypatch): + """CLI parsing should pass host and allow-lists through to run_server.""" + captured: dict[str, object] = {} + monkeypatch.setattr( + sys, + "argv", + [ + "maniple", + "--http", + "--host", + "100.64.0.45", + "--port", + "8766", + "--allow-host", + "100.64.0.45:8766", + "--allow-origin", + "https://manager.example.com", + "--disable-dns-rebinding-protection", + ], + ) + monkeypatch.setattr(server_module, "run_server", lambda **kwargs: captured.update(kwargs)) + + server_module.main() + + assert captured == { + "transport": "streamable-http", + "host": "100.64.0.45", + "port": 8766, + "allowed_hosts": ["100.64.0.45:8766"], + "allowed_origins": ["https://manager.example.com"], + "disable_dns_rebinding_protection": True, + } From cccb816e5369a288d9341d76a2ccbaa1c5e0961a Mon Sep 17 00:00:00 2001 From: lingyuzeng Date: Sun, 8 Mar 2026 17:19:26 +0800 Subject: [PATCH 2/8] Add configurable worker providers and claude-switch wrapper docs --- README.md | 134 ++++++++++++++ scripts/claude-maniple-switch | 43 +++++ src/maniple_mcp/cli_backends/base.py | 13 +- src/maniple_mcp/cli_backends/claude.py | 33 +++- src/maniple_mcp/cli_backends/codex.py | 3 +- src/maniple_mcp/config.py | 58 +++++- src/maniple_mcp/iterm_utils.py | 7 +- src/maniple_mcp/terminal_backends/iterm.py | 2 + src/maniple_mcp/terminal_backends/tmux.py | 7 +- src/maniple_mcp/tools/spawn_workers.py | 69 +++++++ tests/test_claude_maniple_switch.py | 59 ++++++ tests/test_cli_backends.py | 26 +++ tests/test_config.py | 24 +++ tests/test_spawn_workers_defaults.py | 203 ++++++++++++++++++++- 14 files changed, 665 insertions(+), 16 deletions(-) create mode 100644 scripts/claude-maniple-switch create mode 100644 tests/test_claude_maniple_switch.py diff --git a/README.md b/README.md index 748883a..00b53f8 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,36 @@ Workers can run either Claude Code or OpenAI Codex. Set `agent_type: "codex"` in - **HTTP Mode**: Run as a persistent service with streamable-http transport - **Config File**: Centralized configuration at `~/.maniple/config.json` +### HTTP Mode + +Use `--host` to change the bind address for streamable HTTP mode: + +```bash +uv run python -m maniple_mcp --http --host 0.0.0.0 --port 8766 +``` + +If you want DNS rebinding protection enabled for non-localhost clients, add +repeatable `--allow-host` entries and optional `--allow-origin` entries: + +```bash +uv run python -m maniple_mcp \ + --http \ + --host 100.64.0.45 \ + --port 8766 \ + --allow-host 100.64.0.45:8766 \ + --allow-host my-tailnet-host.ts.net:8766 +``` + +For temporary experiments on a trusted network, you can disable the protection: + +```bash +uv run python -m maniple_mcp \ + --http \ + --host 100.64.0.45 \ + --port 8766 \ + --disable-dns-rebinding-protection +``` + ## Requirements - Python 3.11+ @@ -213,8 +243,112 @@ maniple config set # Set and persist a value | `MANIPLE_TERMINAL_BACKEND` | (auto-detect) | Force terminal backend: `tmux` or `iterm`. Highest precedence. | | `MANIPLE_PROJECT_DIR` | (none) | Enables `"project_path": "auto"` in worker configs. | | `MANIPLE_COMMAND` | `claude` | Override the CLI command for Claude Code workers. | +| `MANIPLE_CLAUDE_SUPPORTS_SETTINGS` | auto | Force custom Claude wrappers to receive `--settings` so stop hooks still work. | | `MANIPLE_CODEX_COMMAND` | `codex` | Override the CLI command for Codex workers. | +### Provider Presets + +For multiple Claude-compatible providers, keep the files separated like this: + +- Main config: `~/.maniple/config.json` +- Provider credentials: `~/.maniple/.env` +- Wrapper executable: `~/bin/claude-maniple-switch` + +The wrapper can source an existing `claude-switch` installation, but it reads +provider credentials from `~/.maniple/.env` so users do not need to depend on +`~/.claude-switch/.env` or the repository checkout for secrets. + +Define named presets in `~/.maniple/config.json` and reference them per worker: + +```json +{ + "providers": { + "minimax": { + "command": "/home/you/bin/claude-maniple-switch", + "env": { + "CLAUDE_SWITCH_PROVIDER": "minimax" + } + }, + "kimi": { + "command": "/home/you/bin/claude-maniple-switch", + "env": { + "CLAUDE_SWITCH_PROVIDER": "kimi" + } + }, + "local": { + "command": "/home/you/bin/claude-maniple-switch", + "env": { + "CLAUDE_SWITCH_PROVIDER": "local" + } + } + } +} +``` + +Example `~/.maniple/.env`: + +```bash +export MINIMAX_API_KEY="replace-me" +export MINIMAX_BASE_URL="https://api.minimaxi.com/anthropic" +export MINIMAX_MODEL="MiniMax-M2.1" + +export KIMI_API_KEY="replace-me" +export KIMI_BASE_URL="https://api.moonshot.cn/anthropic" +export KIMI_MODEL="kimi-k2-thinking-turbo" + +export LOCAL_API_KEY="replace-me" +export LOCAL_BASE_URL="http://127.0.0.1:4000" +export LOCAL_MODEL="claude-3-5-sonnet" +``` + +Example wrapper at `~/bin/claude-maniple-switch`: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +CLAUDE_SWITCH_DIR="${CLAUDE_SWITCH_DIR:-$HOME/.local/bin/claude-switch}" +CLAUDE_SWITCH_SCRIPT="${CLAUDE_SWITCH_SCRIPT:-$CLAUDE_SWITCH_DIR/claude-providers-v3.sh}" +MANIPLE_PROVIDER_ENV_FILE="${MANIPLE_PROVIDER_ENV_FILE:-$HOME/.maniple/.env}" +provider="${CLAUDE_SWITCH_PROVIDER:-official}" + +source "$CLAUDE_SWITCH_SCRIPT" >/dev/null 2>&1 + +if [[ -f "$MANIPLE_PROVIDER_ENV_FILE" ]]; then + set -a + source "$MANIPLE_PROVIDER_ENV_FILE" >/dev/null 2>&1 + set +a +fi + +case "$provider" in + minimax) claude-minimax "$@" ;; + kimi) claude-kimi "$@" ;; + local) claude-local "$@" ;; + official) claude-official "$@" ;; + *) echo "Unknown CLAUDE_SWITCH_PROVIDER: $provider" >&2; exit 1 ;; +esac +``` + +Then use either a named `provider` preset or direct `command` / `env` overrides: + +```json +{ + "workers": [ + { + "project_path": "/repo", + "provider": "kimi" + }, + { + "project_path": "/repo", + "command": "/home/you/bin/claude-maniple-switch", + "env": { + "CLAUDE_SWITCH_PROVIDER": "local" + } + } + ] +} +``` + ## MCP Tools ### Worker Management diff --git a/scripts/claude-maniple-switch b/scripts/claude-maniple-switch new file mode 100644 index 0000000..fa430ce --- /dev/null +++ b/scripts/claude-maniple-switch @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +CLAUDE_SWITCH_DIR="${CLAUDE_SWITCH_DIR:-$HOME/.local/bin/claude-switch}" +CLAUDE_SWITCH_SCRIPT="${CLAUDE_SWITCH_SCRIPT:-$CLAUDE_SWITCH_DIR/claude-providers-v3.sh}" +MANIPLE_PROVIDER_ENV_FILE="${MANIPLE_PROVIDER_ENV_FILE:-$HOME/.maniple/.env}" +provider="${CLAUDE_SWITCH_PROVIDER:-official}" + +if [[ ! -f "$CLAUDE_SWITCH_SCRIPT" ]]; then + echo "claude-maniple-switch: missing claude-switch script: $CLAUDE_SWITCH_SCRIPT" >&2 + exit 1 +fi + +# shellcheck disable=SC1090 +source "$CLAUDE_SWITCH_SCRIPT" >/dev/null 2>&1 + +# Load maniple-specific provider credentials after claude-switch so these +# values take precedence without requiring changes to the user's shell setup. +if [[ -f "$MANIPLE_PROVIDER_ENV_FILE" ]]; then + set -a + # shellcheck disable=SC1090 + source "$MANIPLE_PROVIDER_ENV_FILE" >/dev/null 2>&1 + set +a +fi + +case "$provider" in + official) claude-official "$@" ;; + local) claude-local "$@" ;; + local2) claude-local2 "$@" ;; + local3) claude-local3 "$@" ;; + kimi) claude-kimi "$@" ;; + ckimi) claude-ckimi "$@" ;; + glm) claude-glm "$@" ;; + minimax) claude-minimax "$@" ;; + aliyun) claude-aliyun "$@" ;; + kat) claude-kat "$@" ;; + greverse) claude-greverse "$@" ;; + gcs) claude-gcs "$@" ;; + *) + echo "claude-maniple-switch: unknown CLAUDE_SWITCH_PROVIDER: $provider" >&2 + exit 1 + ;; +esac diff --git a/src/maniple_mcp/cli_backends/base.py b/src/maniple_mcp/cli_backends/base.py index 9389be3..c122f50 100644 --- a/src/maniple_mcp/cli_backends/base.py +++ b/src/maniple_mcp/cli_backends/base.py @@ -47,6 +47,7 @@ def build_args( dangerously_skip_permissions: bool = False, settings_file: str | None = None, plugin_dir: str | list[str] | None = None, + command_override: str | None = None, ) -> list[str]: """ Build the argument list for the CLI command. @@ -89,7 +90,7 @@ def idle_detection_method(self) -> Literal["stop_hook", "jsonl_stream", "none"]: ... @abstractmethod - def supports_settings_file(self) -> bool: + def supports_settings_file(self, command_override: str | None = None) -> bool: """ Return whether this CLI supports --settings flag for hook injection. @@ -104,6 +105,7 @@ def build_full_command( settings_file: str | None = None, plugin_dir: str | list[str] | None = None, env_vars: dict[str, str] | None = None, + command_override: str | None = None, ) -> str: """ Build the complete command string including env vars. @@ -120,11 +122,16 @@ def build_full_command( Returns: Complete command string ready for shell execution """ - cmd = self.command() + cmd = command_override or self.command() args = self.build_args( dangerously_skip_permissions=dangerously_skip_permissions, - settings_file=settings_file if self.supports_settings_file() else None, + settings_file=( + settings_file + if self.supports_settings_file(command_override=command_override) + else None + ), plugin_dir=plugin_dir, + command_override=command_override, ) if args: diff --git a/src/maniple_mcp/cli_backends/claude.py b/src/maniple_mcp/cli_backends/claude.py index a3d63fe..11c070b 100644 --- a/src/maniple_mcp/cli_backends/claude.py +++ b/src/maniple_mcp/cli_backends/claude.py @@ -6,6 +6,7 @@ """ from typing import Literal +from pathlib import Path from .base import AgentCLI from ..utils.env_vars import get_env_with_fallback @@ -16,6 +17,8 @@ # Environment variables for command override (takes highest precedence). _ENV_VAR = "MANIPLE_COMMAND" _ENV_VAR_FALLBACK = "CLAUDE_TEAM_COMMAND" +_SETTINGS_ENV_VAR = "MANIPLE_CLAUDE_SUPPORTS_SETTINGS" +_SETTINGS_ENV_VAR_FALLBACK = "CLAUDE_TEAM_CLAUDE_SUPPORTS_SETTINGS" def get_claude_command() -> str: @@ -51,6 +54,24 @@ def get_claude_command() -> str: return _DEFAULT_COMMAND +def _command_supports_settings(command: str) -> bool: + """ + Decide whether a Claude command wrapper should receive --settings. + + Default behavior stays conservative for custom commands, but wrappers can opt in + explicitly via env when they simply inject provider env vars and then exec Claude. + """ + if command == _DEFAULT_COMMAND: + return True + + env_val = get_env_with_fallback(_SETTINGS_ENV_VAR, _SETTINGS_ENV_VAR_FALLBACK) + if env_val: + return env_val.strip().lower() in {"1", "true", "yes", "on"} + + command_name = Path(command.split()[0]).name + return command_name.startswith("claude-") + + class ClaudeCLI(AgentCLI): """ Claude Code CLI implementation. @@ -84,6 +105,7 @@ def build_args( dangerously_skip_permissions: bool = False, settings_file: str | None = None, plugin_dir: str | list[str] | None = None, + command_override: str | None = None, ) -> list[str]: """ Build Claude CLI arguments. @@ -108,10 +130,9 @@ def build_args( args.append("--plugin-dir") args.append(dir_path) - # Only add --settings for the default 'claude' command. - # Custom commands like 'happy' have their own session tracking mechanisms. - # See HAPPY_INTEGRATION_RESEARCH.md for full analysis. - if settings_file and self._is_default_command(): + # Only add --settings when the command is known to pass through to Claude. + # Wrapper commands can opt in explicitly via MANIPLE_CLAUDE_SUPPORTS_SETTINGS. + if settings_file and self.supports_settings_file(command_override=command_override): args.append("--settings") args.append(settings_file) @@ -142,14 +163,14 @@ def idle_detection_method(self) -> Literal["stop_hook", "jsonl_stream", "none"]: """ return "stop_hook" - def supports_settings_file(self) -> bool: + def supports_settings_file(self, command_override: str | None = None) -> bool: """ Claude supports --settings for hook injection. Only returns True for the default 'claude' command. Custom wrappers may have their own settings mechanisms. """ - return self._is_default_command() + return _command_supports_settings(command_override or get_claude_command()) def _is_default_command(self) -> bool: """Check if using the default 'claude' command (not a custom wrapper).""" diff --git a/src/maniple_mcp/cli_backends/codex.py b/src/maniple_mcp/cli_backends/codex.py index 8df3221..c03dab4 100644 --- a/src/maniple_mcp/cli_backends/codex.py +++ b/src/maniple_mcp/cli_backends/codex.py @@ -88,6 +88,7 @@ def build_args( dangerously_skip_permissions: bool = False, settings_file: str | None = None, plugin_dir: str | list[str] | None = None, + command_override: str | None = None, ) -> list[str]: """ Build Codex CLI arguments for interactive mode. @@ -140,7 +141,7 @@ def idle_detection_method(self) -> Literal["stop_hook", "jsonl_stream", "none"]: """ return "jsonl_stream" - def supports_settings_file(self) -> bool: + def supports_settings_file(self, command_override: str | None = None) -> bool: """ Codex doesn't support --settings for hook injection. diff --git a/src/maniple_mcp/config.py b/src/maniple_mcp/config.py index df43b57..909a1a4 100644 --- a/src/maniple_mcp/config.py +++ b/src/maniple_mcp/config.py @@ -71,6 +71,14 @@ class IssueTrackerConfig: override: IssueTrackerName | None = None +@dataclass +class ProviderConfig: + """Named command/env preset for worker launches.""" + + command: str | None = None + env: dict[str, str] = field(default_factory=dict) + + @dataclass class ClaudeTeamConfig: """Top-level configuration container for claude-team.""" @@ -81,6 +89,7 @@ class ClaudeTeamConfig: terminal: TerminalConfig = field(default_factory=TerminalConfig) events: EventsConfig = field(default_factory=EventsConfig) issue_tracker: IssueTrackerConfig = field(default_factory=IssueTrackerConfig) + providers: dict[str, ProviderConfig] = field(default_factory=dict) def default_config() -> ClaudeTeamConfig: @@ -159,7 +168,15 @@ def _parse_config(data: dict) -> ClaudeTeamConfig: # Validate expected top-level keys before parsing sections. _validate_keys( data, - {"version", "commands", "defaults", "terminal", "events", "issue_tracker"}, + { + "version", + "commands", + "defaults", + "terminal", + "events", + "issue_tracker", + "providers", + }, "config", ) version = _read_version(data.get("version")) @@ -168,6 +185,7 @@ def _parse_config(data: dict) -> ClaudeTeamConfig: terminal = _parse_terminal(data.get("terminal")) events = _parse_events(data.get("events")) issue_tracker = _parse_issue_tracker(data.get("issue_tracker")) + providers = _parse_providers(data.get("providers")) return ClaudeTeamConfig( version=version, commands=commands, @@ -175,6 +193,7 @@ def _parse_config(data: dict) -> ClaudeTeamConfig: terminal=terminal, events=events, issue_tracker=issue_tracker, + providers=providers, ) @@ -291,6 +310,25 @@ def _parse_issue_tracker(value: object) -> IssueTrackerConfig: ) +def _parse_providers(value: object) -> dict[str, ProviderConfig]: + # Parse named worker launch provider presets. + data = _ensure_dict(value, "providers") + providers: dict[str, ProviderConfig] = {} + for provider_name, provider_value in data.items(): + if not isinstance(provider_name, str) or not provider_name.strip(): + raise ConfigError("providers keys must be non-empty strings") + provider_data = _ensure_dict(provider_value, f"providers.{provider_name}") + _validate_keys(provider_data, {"command", "env"}, f"providers.{provider_name}") + providers[provider_name] = ProviderConfig( + command=_optional_str( + provider_data.get("command"), + f"providers.{provider_name}.command", + ), + env=_optional_str_map(provider_data.get("env"), f"providers.{provider_name}.env"), + ) + return providers + + def _ensure_dict(value: object, path: str) -> dict: # Ensure sections are JSON objects, defaulting to empty dicts. if value is None: @@ -356,6 +394,23 @@ def _optional_literal( return value +def _optional_str_map(value: object, path: str) -> dict[str, str]: + # Validate optional string-to-string maps. + if value is None: + return {} + if not isinstance(value, dict): + raise ConfigError(f"{path} must be a JSON object") + + result: dict[str, str] = {} + for key, item in value.items(): + if not isinstance(key, str) or not key.strip(): + raise ConfigError(f"{path} keys must be non-empty strings") + if not isinstance(item, str): + raise ConfigError(f"{path}.{key} must be a string") + result[key] = item + return result + + __all__ = [ "AgentType", "ClaudeTeamConfig", @@ -365,6 +420,7 @@ def _optional_literal( "EventsConfig", "IssueTrackerConfig", "LayoutMode", + "ProviderConfig", "TerminalBackend", "TerminalConfig", "IssueTrackerName", diff --git a/src/maniple_mcp/iterm_utils.py b/src/maniple_mcp/iterm_utils.py index 3a0f1c7..a50b131 100644 --- a/src/maniple_mcp/iterm_utils.py +++ b/src/maniple_mcp/iterm_utils.py @@ -638,6 +638,7 @@ async def start_agent_in_session( stop_hook_marker_id: Optional[str] = None, output_capture_path: Optional[str] = None, plugin_dir: Optional[str | list[str]] = None, + command_override: Optional[str] = None, ) -> None: """ Start an agent CLI in an existing iTerm2 session. @@ -673,7 +674,7 @@ async def start_agent_in_session( # Build settings file for Stop hook injection if supported settings_file = None - if stop_hook_marker_id and cli.supports_settings_file(): + if stop_hook_marker_id and cli.supports_settings_file(command_override=command_override): settings_file = build_stop_hook_settings_file(stop_hook_marker_id) # Build the full command using the AgentCLI abstraction @@ -682,6 +683,7 @@ async def start_agent_in_session( settings_file=settings_file, plugin_dir=plugin_dir, env_vars=env, + command_override=command_override, ) # Add output capture via tee if requested @@ -698,12 +700,13 @@ async def start_agent_in_session( await send_prompt(session, cmd) # Wait for agent to actually start (detect ready patterns, not blind sleep) + effective_command = command_override or cli.command() if not await wait_for_agent_ready( session, cli, timeout_seconds=agent_ready_timeout ): raise RuntimeError( f"{cli.engine_id} failed to start in {project_path} within " - f"{agent_ready_timeout}s. Check that '{cli.command()}' command is " + f"{agent_ready_timeout}s. Check that '{effective_command}' command is " "available and authentication is configured." ) diff --git a/src/maniple_mcp/terminal_backends/iterm.py b/src/maniple_mcp/terminal_backends/iterm.py index b3ccafd..746fc32 100644 --- a/src/maniple_mcp/terminal_backends/iterm.py +++ b/src/maniple_mcp/terminal_backends/iterm.py @@ -197,6 +197,7 @@ async def start_agent_in_session( stop_hook_marker_id: Optional[str] = None, output_capture_path: Optional[str] = None, plugin_dir: Optional[str] = None, + command_override: Optional[str] = None, ) -> None: """Start a CLI agent in an existing terminal session.""" await iterm_utils.start_agent_in_session( @@ -210,6 +211,7 @@ async def start_agent_in_session( stop_hook_marker_id=stop_hook_marker_id, output_capture_path=output_capture_path, plugin_dir=plugin_dir, + command_override=command_override, ) async def find_available_window( diff --git a/src/maniple_mcp/terminal_backends/tmux.py b/src/maniple_mcp/terminal_backends/tmux.py index 92f2bd8..3617929 100644 --- a/src/maniple_mcp/terminal_backends/tmux.py +++ b/src/maniple_mcp/terminal_backends/tmux.py @@ -468,6 +468,7 @@ async def start_agent_in_session( stop_hook_marker_id: str | None = None, output_capture_path: str | None = None, plugin_dir: str | None = None, + command_override: str | None = None, ) -> None: """Start a CLI agent in an existing tmux pane.""" # Ensure the shell is responsive before we send the launch command. @@ -482,7 +483,7 @@ async def start_agent_in_session( # Optionally inject a Stop hook using a settings file (Claude only). settings_file = None - if stop_hook_marker_id and cli.supports_settings_file(): + if stop_hook_marker_id and cli.supports_settings_file(command_override=command_override): from ..iterm_utils import build_stop_hook_settings_file settings_file = build_stop_hook_settings_file(stop_hook_marker_id) @@ -493,6 +494,7 @@ async def start_agent_in_session( settings_file=settings_file, plugin_dir=plugin_dir, env_vars=env, + command_override=command_override, ) # Capture stdout/stderr if requested (useful for JSONL idle detection). @@ -509,10 +511,11 @@ async def start_agent_in_session( cli, timeout_seconds=agent_ready_timeout, ) + effective_command = command_override or cli.command() if not agent_ready: raise RuntimeError( f"{cli.engine_id} failed to start in {project_path} within " - f"{agent_ready_timeout}s. Check that '{cli.command()}' is " + f"{agent_ready_timeout}s. Check that '{effective_command}' is " "available and authentication is configured." ) diff --git a/src/maniple_mcp/tools/spawn_workers.py b/src/maniple_mcp/tools/spawn_workers.py index 3b875e5..cd1a3d3 100644 --- a/src/maniple_mcp/tools/spawn_workers.py +++ b/src/maniple_mcp/tools/spawn_workers.py @@ -51,6 +51,9 @@ class WorkerConfig(TypedDict, total=False): use_worktree: bool # Optional: Create isolated worktree (default True) worktree: WorktreeConfig # Optional: Worktree settings (branch/base) plugin_dir: str | list[str] # Optional: Path(s) to plugin directory for --plugin-dir + provider: str # Optional: Named provider preset from config.providers + command: str # Optional: Per-worker command override + env: dict[str, str] # Optional: Per-worker environment variable overrides def register_tools(mcp: FastMCP, ensure_connection) -> None: @@ -154,6 +157,12 @@ async def spawn_workers( skip_permissions: Whether to start Claude with --dangerously-skip-permissions. Default False. Without this, workers can only read local files and will struggle with most commands (writes, shell, etc.). + provider: Optional named launch preset from config.providers. Cannot be + combined with command/env on the same worker. + command: Optional per-worker command override. Use this for wrapper + scripts like /path/to/maniple-claude-local. + env: Optional per-worker environment variables merged into the worker + launch environment. Values must be strings. **Worker Assignment (how workers know what to do):** @@ -233,6 +242,7 @@ async def spawn_workers( logger.warning("Invalid config file; using defaults: %s", exc) config = default_config() defaults = config.defaults + providers = config.providers # Resolve layout from config if not explicitly provided if layout is None: @@ -387,6 +397,59 @@ async def spawn_workers( agent_type = defaults.agent_type agent_types.append(agent_type) + resolved_commands: list[str | None] = [] + resolved_env_overrides: list[dict[str, str]] = [] + for i, w in enumerate(workers): + provider_name = w.get("provider") + command_override = w.get("command") + env_override = w.get("env") + + if provider_name is not None and (command_override is not None or env_override is not None): + return error_response( + f"Worker {i} cannot combine 'provider' with 'command' or 'env'", + hint="Use either a named provider preset or direct command/env overrides.", + ) + + if provider_name is not None: + if not isinstance(provider_name, str) or not provider_name.strip(): + return error_response(f"Worker {i} has invalid 'provider'") + provider = providers.get(provider_name) + if provider is None: + return error_response( + f"Unknown provider for worker {i}: {provider_name}", + hint="Add it under config.providers or use command/env directly.", + ) + resolved_commands.append(provider.command) + resolved_env_overrides.append(dict(provider.env)) + continue + + if command_override is not None: + if not isinstance(command_override, str) or not command_override.strip(): + return error_response(f"Worker {i} has invalid 'command'") + resolved_commands.append(command_override) + else: + resolved_commands.append(None) + + if env_override is None: + resolved_env_overrides.append({}) + elif not isinstance(env_override, dict): + return error_response(f"Worker {i} has invalid 'env'") + else: + normalized_env: dict[str, str] = {} + for key, value in env_override.items(): + if not isinstance(key, str) or not key.strip(): + return error_response( + f"Worker {i} has invalid env key", + hint="Environment variable names must be non-empty strings.", + ) + if not isinstance(value, str): + return error_response( + f"Worker {i} has invalid env value for {key}", + hint="Environment variable values must be strings.", + ) + normalized_env[key] = value + resolved_env_overrides.append(normalized_env) + # Build profile customizations for each worker (iTerm-only) profile_customizations: list[object | None] = [None] * worker_count if isinstance(backend, ItermBackend): @@ -671,6 +734,10 @@ async def start_agent_for_worker(index: int) -> None: else: env = None + extra_env = resolved_env_overrides[index] + if extra_env: + env = (env or {}) | extra_env + # Codex can prompt interactively to install updates, which blocks # unattended remote worker launches. Mark worker sessions as CI to # suppress interactive upgrade prompts. @@ -684,6 +751,7 @@ async def start_agent_for_worker(index: int) -> None: if skip_permissions is None: skip_permissions = defaults.skip_permissions plugin_dir = worker_config.get("plugin_dir") + command_override = resolved_commands[index] await backend.start_agent_in_session( handle=session, cli=cli, @@ -692,6 +760,7 @@ async def start_agent_for_worker(index: int) -> None: env=env, stop_hook_marker_id=stop_hook_marker_id, plugin_dir=plugin_dir, + command_override=command_override, ) await asyncio.gather(*[start_agent_for_worker(i) for i in range(worker_count)]) diff --git a/tests/test_claude_maniple_switch.py b/tests/test_claude_maniple_switch.py new file mode 100644 index 0000000..d471731 --- /dev/null +++ b/tests/test_claude_maniple_switch.py @@ -0,0 +1,59 @@ +"""Tests for the claude-maniple-switch wrapper script.""" + +from __future__ import annotations + +import os +import stat +import subprocess +from pathlib import Path + + +def _write_executable(path: Path, contents: str) -> None: + path.write_text(contents) + path.chmod(path.stat().st_mode | stat.S_IXUSR) + + +def test_wrapper_loads_maniple_env_and_dispatches_provider(tmp_path: Path) -> None: + """The wrapper should dispatch to the requested provider with ~/.maniple env overrides.""" + fake_switch = tmp_path / "fake-claude-switch.sh" + _write_executable( + fake_switch, + """#!/usr/bin/env bash +claude-minimax() { + echo "provider=minimax" + echo "base_url=${ANTHROPIC_BASE_URL:-}" + echo "auth=${ANTHROPIC_AUTH_TOKEN:-}" + echo "arg1=${1:-}" +} +claude-kimi() { echo "provider=kimi"; } +claude-local() { echo "provider=local"; } +claude-official() { echo "provider=official"; } +""", + ) + + provider_env = tmp_path / ".env" + provider_env.write_text( + "export ANTHROPIC_BASE_URL=https://minimax.example.test/anthropic\n" + "export ANTHROPIC_AUTH_TOKEN=test-token\n" + ) + + wrapper = Path(__file__).resolve().parent.parent / "scripts" / "claude-maniple-switch" + env = os.environ.copy() + env.update({ + "CLAUDE_SWITCH_SCRIPT": str(fake_switch), + "CLAUDE_SWITCH_PROVIDER": "minimax", + "MANIPLE_PROVIDER_ENV_FILE": str(provider_env), + }) + + result = subprocess.run( + ["bash", str(wrapper), "--help"], + capture_output=True, + text=True, + check=True, + env=env, + ) + + assert "provider=minimax" in result.stdout + assert "base_url=https://minimax.example.test/anthropic" in result.stdout + assert "auth=test-token" in result.stdout + assert "arg1=--help" in result.stdout diff --git a/tests/test_cli_backends.py b/tests/test_cli_backends.py index e315ba9..5fa2e84 100644 --- a/tests/test_cli_backends.py +++ b/tests/test_cli_backends.py @@ -160,6 +160,24 @@ def test_supports_settings_file_custom_command(self): cli = ClaudeCLI() assert cli.supports_settings_file() is False + def test_supports_settings_file_claude_wrapper_command(self): + """Wrapper commands named like claude-* should keep --settings support.""" + with patch.dict(os.environ, {"MANIPLE_COMMAND": "/usr/local/bin/claude-local"}): + cli = ClaudeCLI() + assert cli.supports_settings_file() is True + + def test_supports_settings_file_env_opt_in_for_wrapper(self): + """Custom wrappers can opt in explicitly to preserve stop-hook injection.""" + with patch.dict( + os.environ, + { + "MANIPLE_COMMAND": "/opt/bin/provider-wrapper", + "MANIPLE_CLAUDE_SUPPORTS_SETTINGS": "true", + }, + ): + cli = ClaudeCLI() + assert cli.supports_settings_file() is True + def test_build_full_command_simple(self): """build_full_command should combine command and args.""" with patch.dict(os.environ, {}, clear=True): @@ -180,6 +198,14 @@ def test_build_full_command_with_env_vars(self): assert "BAZ=qux" in cmd assert cmd.endswith("claude") + def test_build_args_settings_file_supported_for_wrapper(self): + """Wrapper commands that pass through to Claude should still get --settings.""" + with patch.dict(os.environ, {"MANIPLE_COMMAND": "/usr/local/bin/claude-local"}): + cli = ClaudeCLI() + args = cli.build_args(settings_file="/path/to/settings.json") + assert "--settings" in args + assert "/path/to/settings.json" in args + class TestCodexCLI: """Tests for Codex CLI backend.""" diff --git a/tests/test_config.py b/tests/test_config.py index 24dcba3..c1130e6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -12,6 +12,7 @@ DefaultsConfig, EventsConfig, IssueTrackerConfig, + ProviderConfig, TerminalConfig, default_config, load_config, @@ -63,6 +64,11 @@ def test_default_issue_tracker(self): config = default_config() assert config.issue_tracker.override is None + def test_default_providers(self): + """Default provider presets should be empty.""" + config = default_config() + assert config.providers == {} + class TestSaveConfig: """Tests for save_config function.""" @@ -104,6 +110,12 @@ def test_saves_all_fields(self, tmp_path: Path): terminal=TerminalConfig(backend="tmux"), events=EventsConfig(max_size_mb=5, recent_hours=48, stale_threshold_minutes=15), issue_tracker=IssueTrackerConfig(override="beads"), + providers={ + "local": ProviderConfig( + command="/usr/local/bin/claude-local", + env={"CLAUDE_PROVIDER": "local"}, + ) + }, ) save_config(config, config_path) data = json.loads(config_path.read_text()) @@ -119,6 +131,8 @@ def test_saves_all_fields(self, tmp_path: Path): assert data["events"]["recent_hours"] == 48 assert data["events"]["stale_threshold_minutes"] == 15 assert data["issue_tracker"]["override"] == "beads" + assert data["providers"]["local"]["command"] == "/usr/local/bin/claude-local" + assert data["providers"]["local"]["env"] == {"CLAUDE_PROVIDER": "local"} def test_json_is_formatted(self, tmp_path: Path): """Saved JSON is indented for readability.""" @@ -160,6 +174,12 @@ def test_loads_existing_config(self, tmp_path: Path): "terminal": {"backend": "iterm"}, "events": {"max_size_mb": 10}, "issue_tracker": {"override": "pebbles"}, + "providers": { + "local": { + "command": "/usr/local/bin/claude-local", + "env": {"CLAUDE_PROVIDER": "local"}, + } + }, })) config = load_config(config_path) assert config.commands.claude == "/my/claude" @@ -167,6 +187,8 @@ def test_loads_existing_config(self, tmp_path: Path): assert config.terminal.backend == "iterm" assert config.events.max_size_mb == 10 assert config.issue_tracker.override == "pebbles" + assert config.providers["local"].command == "/usr/local/bin/claude-local" + assert config.providers["local"].env == {"CLAUDE_PROVIDER": "local"} def test_partial_config_uses_defaults(self, tmp_path: Path): """Missing sections use default values.""" @@ -210,6 +232,7 @@ def test_roundtrip_preserves_values(self, tmp_path: Path): terminal=TerminalConfig(backend="tmux"), events=EventsConfig(max_size_mb=2, recent_hours=12, stale_threshold_minutes=30), issue_tracker=IssueTrackerConfig(override="beads"), + providers={"local": ProviderConfig(env={"CLAUDE_PROVIDER": "local"})}, ) save_config(original, config_path) loaded = load_config(config_path) @@ -225,6 +248,7 @@ def test_roundtrip_preserves_values(self, tmp_path: Path): assert loaded.events.recent_hours == original.events.recent_hours assert loaded.events.stale_threshold_minutes == original.events.stale_threshold_minutes assert loaded.issue_tracker.override == original.issue_tracker.override + assert loaded.providers == original.providers class TestJsonValidationErrors: diff --git a/tests/test_spawn_workers_defaults.py b/tests/test_spawn_workers_defaults.py index b7bce9b..5472aec 100644 --- a/tests/test_spawn_workers_defaults.py +++ b/tests/test_spawn_workers_defaults.py @@ -6,7 +6,7 @@ from mcp.server.fastmcp import FastMCP import maniple_mcp.session_state as session_state -from maniple_mcp.config import ConfigError, DefaultsConfig, default_config +from maniple_mcp.config import ConfigError, DefaultsConfig, ProviderConfig, default_config from maniple_mcp.registry import SessionRegistry from maniple_mcp.terminal_backends.base import TerminalSession from maniple_mcp.tools import spawn_workers as spawn_workers_module @@ -64,6 +64,7 @@ async def start_agent_in_session( "dangerously_skip_permissions": dangerously_skip_permissions, "env": env, "stop_hook_marker_id": stop_hook_marker_id, + **kwargs, }) async def send_prompt_for_agent( @@ -131,7 +132,15 @@ def fake_generate_worker_prompt(*args, **kwargs): async def fake_await_marker_in_jsonl(*args, **kwargs): return None + async def fake_await_codex_marker_in_jsonl(*args, **kwargs): + return None + monkeypatch.setattr(session_state, "await_marker_in_jsonl", fake_await_marker_in_jsonl) + monkeypatch.setattr( + session_state, + "await_codex_marker_in_jsonl", + fake_await_codex_marker_in_jsonl, + ) monkeypatch.setattr(session_state, "generate_marker_message", lambda *args, **kwargs: "MARKER") backend = FakeBackend() @@ -208,7 +217,15 @@ def fake_generate_worker_prompt(*args, **kwargs): async def fake_await_marker_in_jsonl(*args, **kwargs): return None + async def fake_await_codex_marker_in_jsonl(*args, **kwargs): + return None + monkeypatch.setattr(session_state, "await_marker_in_jsonl", fake_await_marker_in_jsonl) + monkeypatch.setattr( + session_state, + "await_codex_marker_in_jsonl", + fake_await_codex_marker_in_jsonl, + ) monkeypatch.setattr(session_state, "generate_marker_message", lambda *args, **kwargs: "MARKER") backend = FakeBackend() @@ -270,7 +287,15 @@ async def test_spawn_workers_merges_codex_ci_with_worktree_tracker_env(tmp_path, async def fake_await_marker_in_jsonl(*args, **kwargs): return None + async def fake_await_codex_marker_in_jsonl(*args, **kwargs): + return None + monkeypatch.setattr(session_state, "await_marker_in_jsonl", fake_await_marker_in_jsonl) + monkeypatch.setattr( + session_state, + "await_codex_marker_in_jsonl", + fake_await_codex_marker_in_jsonl, + ) monkeypatch.setattr(session_state, "generate_marker_message", lambda *args, **kwargs: "MARKER") backend = FakeBackend() @@ -356,3 +381,179 @@ async def ensure_connection(app_context): session = result["sessions"]["Worker1"] assert session["coordinator_badge"] == "Preferred badge" assert backend.create_calls[0]["coordinator_badge"] == "Preferred badge" + + +@pytest.mark.asyncio +async def test_spawn_workers_uses_named_provider_preset(tmp_path, monkeypatch): + """Named providers should supply per-worker command and env overrides.""" + config = default_config() + config.defaults = DefaultsConfig( + agent_type="claude", + skip_permissions=False, + use_worktree=False, + layout="new", + ) + config.providers = { + "local": ProviderConfig( + command="/usr/local/bin/claude-local", + env={"CLAUDE_PROVIDER": "local", "ANTHROPIC_BASE_URL": "http://provider"}, + ) + } + monkeypatch.setattr(spawn_workers_module, "load_config", lambda: config) + monkeypatch.setattr(spawn_workers_module, "get_cli_backend", lambda *_: "cli:claude") + monkeypatch.setattr(spawn_workers_module, "get_worktree_tracker_dir", lambda *_: None) + monkeypatch.setattr( + spawn_workers_module, + "generate_worker_prompt", + lambda *args, **kwargs: "PROMPT", + ) + monkeypatch.setattr( + spawn_workers_module, + "get_coordinator_guidance", + lambda *args, **kwargs: {"summary": "ok"}, + ) + + async def fake_await_marker_in_jsonl(*args, **kwargs): + return None + + monkeypatch.setattr(session_state, "await_marker_in_jsonl", fake_await_marker_in_jsonl) + monkeypatch.setattr(session_state, "generate_marker_message", lambda *args, **kwargs: "MARKER") + + backend = FakeBackend() + registry = SessionRegistry() + app_ctx = SimpleNamespace(registry=registry, backend=backend) + + async def ensure_connection(app_context): + return app_context.backend + + mcp = FastMCP("test") + spawn_workers_module.register_tools(mcp, ensure_connection) + tool = mcp._tool_manager.get_tool("spawn_workers") + assert tool is not None + + repo_path = tmp_path / "repo" + repo_path.mkdir() + + ctx = SimpleNamespace(request_context=SimpleNamespace(lifespan_context=app_ctx)) + await tool.run({ + "workers": [{ + "project_path": str(repo_path), + "name": "Worker1", + "provider": "local", + }], + }, context=ctx) + + assert backend.started[0]["env"] == { + "CLAUDE_PROVIDER": "local", + "ANTHROPIC_BASE_URL": "http://provider", + } + assert backend.started[0]["command_override"] == "/usr/local/bin/claude-local" + + +@pytest.mark.asyncio +async def test_spawn_workers_uses_direct_command_and_env_override(tmp_path, monkeypatch): + """Workers should support direct command/env overrides without providers.""" + config = default_config() + config.defaults = DefaultsConfig( + agent_type="claude", + skip_permissions=False, + use_worktree=False, + layout="new", + ) + monkeypatch.setattr(spawn_workers_module, "load_config", lambda: config) + monkeypatch.setattr(spawn_workers_module, "get_cli_backend", lambda *_: "cli:claude") + monkeypatch.setattr( + spawn_workers_module, + "get_worktree_tracker_dir", + lambda *_: ("MANIPLE_WORKTREE_TRACKER_DIR", "/tmp/tracker"), + ) + monkeypatch.setattr( + spawn_workers_module, + "generate_worker_prompt", + lambda *args, **kwargs: "PROMPT", + ) + monkeypatch.setattr( + spawn_workers_module, + "get_coordinator_guidance", + lambda *args, **kwargs: {"summary": "ok"}, + ) + + async def fake_await_marker_in_jsonl(*args, **kwargs): + return None + + monkeypatch.setattr(session_state, "await_marker_in_jsonl", fake_await_marker_in_jsonl) + monkeypatch.setattr(session_state, "generate_marker_message", lambda *args, **kwargs: "MARKER") + + backend = FakeBackend() + registry = SessionRegistry() + app_ctx = SimpleNamespace(registry=registry, backend=backend) + + async def ensure_connection(app_context): + return app_context.backend + + mcp = FastMCP("test") + spawn_workers_module.register_tools(mcp, ensure_connection) + tool = mcp._tool_manager.get_tool("spawn_workers") + assert tool is not None + + repo_path = tmp_path / "repo" + repo_path.mkdir() + + ctx = SimpleNamespace(request_context=SimpleNamespace(lifespan_context=app_ctx)) + await tool.run({ + "workers": [{ + "project_path": str(repo_path), + "name": "Worker1", + "command": "/usr/local/bin/claude-ckimi", + "env": {"CLAUDE_PROVIDER": "ckimi"}, + }], + }, context=ctx) + + assert backend.started[0]["env"] == { + "MANIPLE_WORKTREE_TRACKER_DIR": "/tmp/tracker", + "CLAUDE_PROVIDER": "ckimi", + } + assert backend.started[0]["command_override"] == "/usr/local/bin/claude-ckimi" + + +@pytest.mark.asyncio +async def test_spawn_workers_rejects_provider_and_command_mix(tmp_path, monkeypatch): + """provider cannot be combined with direct command/env overrides.""" + config = default_config() + config.defaults = DefaultsConfig( + agent_type="claude", + skip_permissions=False, + use_worktree=False, + layout="new", + ) + config.providers = { + "local": ProviderConfig(command="/bin/claude-local") + } + monkeypatch.setattr(spawn_workers_module, "load_config", lambda: config) + + backend = FakeBackend() + registry = SessionRegistry() + app_ctx = SimpleNamespace(registry=registry, backend=backend) + + async def ensure_connection(app_context): + return app_context.backend + + mcp = FastMCP("test") + spawn_workers_module.register_tools(mcp, ensure_connection) + tool = mcp._tool_manager.get_tool("spawn_workers") + assert tool is not None + + repo_path = tmp_path / "repo" + repo_path.mkdir() + + ctx = SimpleNamespace(request_context=SimpleNamespace(lifespan_context=app_ctx)) + result = await tool.run({ + "workers": [{ + "project_path": str(repo_path), + "provider": "local", + "command": "/bin/other", + }], + }, context=ctx) + + assert "error" in result + assert "cannot combine 'provider' with 'command' or 'env'" in result["error"] From 6875579e626d721491024650e08082340b227508 Mon Sep 17 00:00:00 2001 From: lingyuzeng Date: Sun, 8 Mar 2026 18:48:04 +0800 Subject: [PATCH 3/8] Document service setup and harden claude-switch wrapper --- README.md | 97 +++++++++++++++++++++++++++++++++++ scripts/claude-maniple-switch | 12 ++++- 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 00b53f8..dbdba75 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,16 @@ Workers can run either Claude Code or OpenAI Codex. Set `agent_type: "codex"` in ### HTTP Mode +Minimal service command: + +```bash +uv run python -m maniple_mcp \ + --http \ + --host 100.64.0.45 \ + --port 8766 \ + --allow-host 100.64.0.45:8766 +``` + Use `--host` to change the bind address for streamable HTTP mode: ```bash @@ -98,7 +108,10 @@ uv run python -m maniple_mcp \ - `uv` package manager - **tmux backend**: tmux installed (macOS or Linux) - **iTerm2 backend**: macOS with iTerm2 and Python API enabled (Preferences > General > Magic > Enable Python API) +- **Claude workers** (optional): Claude Code CLI installed - **Codex workers** (optional): OpenAI Codex CLI installed +- **Linux service mode** (optional): `systemd --user` available +- **Provider wrapper mode** (optional): a local `claude-switch`-style wrapper installation if you want multiple Claude-compatible providers ## Installation @@ -178,6 +191,55 @@ For project-scoped `.mcp.json` files, use `MANIPLE_PROJECT_DIR` so workers inher After adding the configuration, restart Claude Code for it to take effect. +## Running as a Service + +For a persistent Linux deployment, run `maniple` under `systemd --user` and keep +logs under `~/.maniple/logs/`. + +Example unit file at `~/.config/systemd/user/maniple.service`: + +```ini +[Unit] +Description=Maniple MCP Server +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/path/to/maniple +Environment=MANIPLE_TERMINAL_BACKEND=tmux +ExecStart=/path/to/uv run --project /path/to/maniple maniple --http --host 100.64.0.45 --port 8766 --allow-host 100.64.0.45:8766 +Restart=always +RestartSec=3 +StandardOutput=append:%h/.maniple/logs/maniple.out.log +StandardError=append:%h/.maniple/logs/maniple.err.log + +[Install] +WantedBy=default.target +``` + +Enable and start it: + +```bash +mkdir -p ~/.maniple/logs ~/.config/systemd/user +systemctl --user daemon-reload +systemctl --user enable --now maniple +systemctl --user status maniple +``` + +To keep the service running after reboot without an active graphical login: + +```bash +loginctl enable-linger "$USER" +``` + +Useful log commands: + +```bash +journalctl --user -u maniple -n 100 --no-pager +tail -f ~/.maniple/logs/maniple.out.log ~/.maniple/logs/maniple.err.log +``` + ## Config File `maniple` reads configuration from `~/.maniple/config.json`. Manage it with the CLI: @@ -258,6 +320,13 @@ The wrapper can source an existing `claude-switch` installation, but it reads provider credentials from `~/.maniple/.env` so users do not need to depend on `~/.claude-switch/.env` or the repository checkout for secrets. +Minimal setup: + +1. Put provider credentials in `~/.maniple/.env` +2. Install `~/bin/claude-maniple-switch` +3. Add named presets under `providers` in `~/.maniple/config.json` +4. Call `spawn_workers` with `provider: "minimax"` or `provider: "kimi"` or `provider: "local"` + Define named presets in `~/.maniple/config.json` and reference them per worker: ```json @@ -349,6 +418,34 @@ Then use either a named `provider` preset or direct `command` / `env` overrides: } ``` +Minimal `spawn_workers` example: + +```json +{ + "workers": [ + { + "project_path": "/repo", + "provider": "kimi", + "skip_permissions": true + } + ], + "layout": "new" +} +``` + +### First-Run Notes for Claude Workers + +When testing autonomous Claude workers, the MCP service itself may be healthy but +Claude Code can still pause on local interactive confirmations. Common examples: + +- trusting a newly opened project folder +- approving local `.mcp.json` servers discovered in the target repo +- confirming the `--dangerously-skip-permissions` / bypass mode warning + +If this happens, the worker will appear to time out during startup even though +the tmux pane is alive. Check the pane directly and clear the confirmation once. +After that, service-mode orchestration is much smoother. + ## MCP Tools ### Worker Management diff --git a/scripts/claude-maniple-switch b/scripts/claude-maniple-switch index fa430ce..21a8ff9 100644 --- a/scripts/claude-maniple-switch +++ b/scripts/claude-maniple-switch @@ -11,8 +11,18 @@ if [[ ! -f "$CLAUDE_SWITCH_SCRIPT" ]]; then exit 1 fi +# Import function definitions from claude-switch, but skip its top-level side +# effects (dependency checks, tips, and automatic env loading). # shellcheck disable=SC1090 -source "$CLAUDE_SWITCH_SCRIPT" >/dev/null 2>&1 +source <( + sed \ + -e '/^_claude_check_dependencies$/d' \ + -e '/^_claude_init_config$/d' \ + -e '/^_claude_ccmanager_tips$/d' \ + -e '/^_claude_check_ralph_setup$/d' \ + -e '/^_claude_load_env$/d' \ + "$CLAUDE_SWITCH_SCRIPT" +) >/dev/null 2>&1 # Load maniple-specific provider credentials after claude-switch so these # values take precedence without requiring changes to the user's shell setup. From 68db6435c8879340c4163b10d9b73bc9c367b794 Mon Sep 17 00:00:00 2001 From: lingyuzeng Date: Sun, 8 Mar 2026 20:06:22 +0800 Subject: [PATCH 4/8] Add provider discovery and default provider support --- README.md | 275 +++++++++++++++++++++++- scripts/claude-maniple-switch | 21 ++ src/maniple_mcp/config.py | 4 +- src/maniple_mcp/config_cli.py | 1 + src/maniple_mcp/tools/__init__.py | 2 + src/maniple_mcp/tools/close_workers.py | 48 ++++- src/maniple_mcp/tools/list_providers.py | 56 +++++ src/maniple_mcp/tools/spawn_workers.py | 2 + src/maniple_mcp/utils/errors.py | 7 +- src/maniple_mcp/worktree.py | 58 +++++ tests/test_claude_maniple_switch.py | 48 +++++ tests/test_close_workers.py | 187 ++++++++++++++++ tests/test_config.py | 27 +++ tests/test_config_cli.py | 6 + tests/test_list_providers.py | 73 +++++++ tests/test_spawn_workers_defaults.py | 64 ++++++ uv.lock | 2 +- 17 files changed, 872 insertions(+), 9 deletions(-) create mode 100644 src/maniple_mcp/tools/list_providers.py create mode 100644 tests/test_close_workers.py create mode 100644 tests/test_list_providers.py diff --git a/README.md b/README.md index dbdba75..18f0237 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,101 @@ After adding the configuration, restart Claude Code for it to take effect. For a persistent Linux deployment, run `maniple` under `systemd --user` and keep logs under `~/.maniple/logs/`. +### Debian/Ubuntu From-Source Setup + +This is the recommended path when you want a user-level `systemd` service that +runs directly from your local checkout and automatically picks up source +changes after a service restart. + +1. Install system packages: + +```bash +sudo apt update +sudo apt install -y git tmux curl +``` + +2. Install `uv` if needed: + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +3. Clone and sync the project: + +```bash +git clone https://github.com/Martian-Engineering/maniple.git +cd maniple +uv sync --group dev +``` + +4. Verify the CLI works from the checkout: + +```bash +uv run maniple --help +``` + +5. Create the user service directories: + +```bash +mkdir -p ~/.maniple/logs ~/.config/systemd/user +``` + +6. Create `~/.config/systemd/user/maniple.service`: + +```ini +[Unit] +Description=Maniple MCP Server +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/home/you/path/to/maniple +Environment=MANIPLE_TERMINAL_BACKEND=tmux +ExecStart=/home/you/.local/bin/uv run --project /home/you/path/to/maniple maniple --http --host 100.64.0.45 --port 8766 --allow-host 100.64.0.45:8766 +Restart=always +RestartSec=3 +StandardOutput=append:%h/.maniple/logs/maniple.out.log +StandardError=append:%h/.maniple/logs/maniple.err.log + +[Install] +WantedBy=default.target +``` + +7. Load and start the user service: + +```bash +systemctl --user daemon-reload +systemctl --user enable --now maniple +systemctl --user status maniple +``` + +8. Enable lingering so the service survives reboot without an active desktop login: + +```bash +loginctl enable-linger "$USER" +``` + +9. After editing Python source code in the checkout, restart the service: + +```bash +systemctl --user restart maniple +``` + +10. After editing the unit file itself, reload systemd and restart: + +```bash +systemctl --user daemon-reload +systemctl --user restart maniple +``` + +11. Tail logs while debugging: + +```bash +journalctl --user -u maniple -n 100 --no-pager +tail -f ~/.maniple/logs/maniple.out.log ~/.maniple/logs/maniple.err.log +``` + Example unit file at `~/.config/systemd/user/maniple.service`: ```ini @@ -268,6 +363,7 @@ maniple config set # Set and persist a value }, "defaults": { "agent_type": "claude", + "provider": null, "skip_permissions": false, "use_worktree": true, "layout": "auto" @@ -290,6 +386,7 @@ maniple config set # Set and persist a value | `commands.claude` | string | Override Claude CLI command (e.g. `"happy"`) | | `commands.codex` | string | Override Codex CLI command (e.g. `"happy codex"`) | | `defaults.agent_type` | `"claude"` or `"codex"` | Default agent type for new workers | +| `defaults.provider` | string or null | Default named provider preset for `spawn_workers` when a worker omits `provider` | | `defaults.skip_permissions` | bool | Default `--dangerously-skip-permissions` flag | | `defaults.use_worktree` | bool | Create git worktrees by default | | `defaults.layout` | `"auto"` or `"new"` | Default layout mode for spawn_workers | @@ -370,6 +467,98 @@ export LOCAL_BASE_URL="http://127.0.0.1:4000" export LOCAL_MODEL="claude-3-5-sonnet" ``` +Recommended convention for `~/.maniple/.env`: + +```bash +export MANIPLE_PROVIDER_MINIMAX_API_KEY="replace-me" +export MANIPLE_PROVIDER_MINIMAX_BASE_URL="https://api.minimaxi.com/anthropic" +export MANIPLE_PROVIDER_MINIMAX_MODEL="MiniMax-M2.1" + +export MANIPLE_PROVIDER_KIMI_API_KEY="replace-me" +export MANIPLE_PROVIDER_KIMI_BASE_URL="https://api.moonshot.cn/anthropic" +export MANIPLE_PROVIDER_KIMI_MODEL="kimi-k2-thinking-turbo" + +export MANIPLE_PROVIDER_LOCAL_API_KEY="replace-me" +export MANIPLE_PROVIDER_LOCAL_BASE_URL="http://127.0.0.1:4000" +export MANIPLE_PROVIDER_LOCAL_MODEL="claude-3-5-sonnet" +``` + +The wrapper maps these namespaced variables automatically based on the selected +provider name. The convention is: + +```text +provider name in config: local +CLAUDE_SWITCH_PROVIDER: local +~/.maniple/.env prefix: MANIPLE_PROVIDER_LOCAL_ +provider env aliases loaded: LOCAL_API_KEY / LOCAL_BASE_URL / LOCAL_MODEL +``` + +Examples: +- `provider: "kimi"` -> reads `MANIPLE_PROVIDER_KIMI_*` +- `provider: "minimax"` -> reads `MANIPLE_PROVIDER_MINIMAX_*` +- `provider: "local2"` -> reads `MANIPLE_PROVIDER_LOCAL2_*` + +This keeps provider selection in `~/.maniple/config.json` and provider secrets +in `~/.maniple/.env`, with a predictable naming rule between them. + +### Adding or Removing Providers + +To add a provider: + +1. Ensure your wrapper supports it in the `case "$provider" in ... esac` block. +2. Add a preset under `providers` in `~/.maniple/config.json`. +3. Add matching `MANIPLE_PROVIDER__*` values to `~/.maniple/.env`. +4. Restart the MCP service if it is running under `systemd --user`. + +Example: add `local2` + +`~/.maniple/config.json` + +```json +{ + "providers": { + "local2": { + "command": "/home/you/bin/claude-maniple-switch", + "env": { + "CLAUDE_SWITCH_PROVIDER": "local2" + } + } + } +} +``` + +`~/.maniple/.env` + +```bash +export MANIPLE_PROVIDER_LOCAL2_API_KEY="replace-me" +export MANIPLE_PROVIDER_LOCAL2_BASE_URL="http://127.0.0.1:4001" +export MANIPLE_PROVIDER_LOCAL2_MODEL="claude-3-5-sonnet" +``` + +To remove a provider: + +1. Delete its preset from `~/.maniple/config.json` +2. Optionally delete its `MANIPLE_PROVIDER__*` lines from `~/.maniple/.env` +3. Restart the MCP service if needed + +Service reload after provider changes: + +```bash +systemctl --user restart maniple +``` + +Set a default provider for workers that do not specify one explicitly: + +```bash +maniple config set defaults.provider local +``` + +Clear the default provider: + +```bash +maniple config set defaults.provider null +``` + Example wrapper at `~/bin/claude-maniple-switch`: ```bash @@ -433,6 +622,53 @@ Minimal `spawn_workers` example: } ``` +### MCP Client Usage + +Recommended client flow: + +1. Call `list_providers` to discover available named provider presets. +2. Call `spawn_workers` with `provider: ""`. +3. Put the actual task in the worker `prompt`. + +Structured example: + +```json +{ + "workers": [ + { + "project_path": "/repo", + "provider": "kimi", + "prompt": "Inspect the training pipeline and fix the failing evaluation step.", + "skip_permissions": true + } + ], + "layout": "new" +} +``` + +Minimal natural-language prompt examples for MCP-capable clients: + +```text +先调用 list_providers,告诉我当前可用的 provider 名字。 +``` + +```text +调用 spawn_workers,在 /repo 启动 1 个 Claude worker,provider 用 kimi,skip_permissions=true,prompt 用“检查训练脚本报错并修复”。 +``` + +```text +调用 spawn_workers,在 /repo 启动 1 个 Claude worker,provider 用 local2,layout 用 new,prompt 用“检查 API 服务启动失败的原因并修复”。 +``` + +Prompt-writing guidance for reliable tool invocation: +- Name the MCP tool explicitly: `list_providers` or `spawn_workers` +- Specify the provider name exactly as returned by `list_providers` +- Put the worker's assignment into the `prompt` field +- Include `project_path` explicitly +- Use `skip_permissions: true` when the worker needs to edit files or run commands + +`spawn_work` is not a valid tool name; use `spawn_workers`. + ### First-Run Notes for Claude Workers When testing autonomous Claude workers, the MCP service itself may be healthy but @@ -455,9 +691,10 @@ After that, service-mode orchestration is much smoother. | `spawn_workers` | Create workers with multi-pane layouts. Supports Claude Code and Codex agents. | | `list_workers` | List all managed workers with status. Filter by status or project. | | `examine_worker` | Get detailed worker status including conversation stats and last response preview. | -| `close_workers` | Gracefully terminate one or more workers. Worktree branches are preserved. | +| `close_workers` | Gracefully terminate one or more workers. Worktree branches are preserved by default, or deleted with `delete_branch: true`. | | `discover_workers` | Find existing Claude Code/Codex sessions running in tmux or iTerm2. | | `adopt_worker` | Import a discovered session into the managed registry. | +| `list_providers` | List configured Claude-compatible provider presets from `~/.maniple/config.json`. | ### Communication @@ -538,6 +775,32 @@ Returns: success, session_ids, results, [idle_session_ids, all_idle, timed_out] ``` +#### close_workers + +``` +Arguments: + session_ids: list[str] - Worker IDs to close (accepts any identifier format) + force: bool - Force close even if a worker is busy + delete_branch: bool - Also delete the worker branch after removing its + worktree (default: false) + +Returns: + session_ids, results, success_count, failure_count +``` + +Worktree cleanup behavior: +- By default, `close_workers` removes the worker worktree directory and keeps the branch. +- Set `delete_branch: true` to remove both the worktree and its branch in one call. + +Example: + +```json +{ + "session_ids": ["Hypatia"], + "delete_branch": true +} +``` + #### wait_idle_workers ``` @@ -551,6 +814,16 @@ Returns: session_ids, idle_session_ids, all_idle, waiting_on, mode, waited_seconds, timed_out ``` +#### list_providers + +``` +Returns: + config_path, count, provider_names, providers, usage_tip +``` + +Use this when an MCP client wants to discover which named provider presets are +available before calling `spawn_workers` with `provider: ""`. + #### poll_worker_changes ``` diff --git a/scripts/claude-maniple-switch b/scripts/claude-maniple-switch index 21a8ff9..0d7b592 100644 --- a/scripts/claude-maniple-switch +++ b/scripts/claude-maniple-switch @@ -33,6 +33,27 @@ if [[ -f "$MANIPLE_PROVIDER_ENV_FILE" ]]; then set +a fi +_provider_env_prefix() { + printf '%s' "$1" | tr '[:lower:]-' '[:upper:]_' +} + +_apply_provider_env_convention() { + local provider_prefix shell_prefix suffix source_var target_var value + + provider_prefix="$(_provider_env_prefix "$provider")" + + for suffix in API_KEY AUTH_TOKEN BASE_URL MODEL; do + source_var="MANIPLE_PROVIDER_${provider_prefix}_${suffix}" + target_var="${provider_prefix}_${suffix}" + value="${!source_var:-}" + if [[ -n "$value" && -z "${!target_var:-}" ]]; then + export "${target_var}=${value}" + fi + done +} + +_apply_provider_env_convention + case "$provider" in official) claude-official "$@" ;; local) claude-local "$@" ;; diff --git a/src/maniple_mcp/config.py b/src/maniple_mcp/config.py index 909a1a4..397b258 100644 --- a/src/maniple_mcp/config.py +++ b/src/maniple_mcp/config.py @@ -43,6 +43,7 @@ class DefaultsConfig: """Default values applied when spawn_workers fields are omitted.""" agent_type: AgentType = "claude" + provider: str | None = None skip_permissions: bool = False use_worktree: bool = True layout: LayoutMode = "auto" @@ -225,7 +226,7 @@ def _parse_defaults(value: object) -> DefaultsConfig: data = _ensure_dict(value, "defaults") _validate_keys( data, - {"agent_type", "skip_permissions", "use_worktree", "layout"}, + {"agent_type", "provider", "skip_permissions", "use_worktree", "layout"}, "defaults", ) return DefaultsConfig( @@ -235,6 +236,7 @@ def _parse_defaults(value: object) -> DefaultsConfig: "defaults.agent_type", DefaultsConfig.agent_type, ), + provider=_optional_str(data.get("provider"), "defaults.provider"), skip_permissions=_optional_bool( data.get("skip_permissions"), "defaults.skip_permissions", diff --git a/src/maniple_mcp/config_cli.py b/src/maniple_mcp/config_cli.py index 1b971fe..d118749 100644 --- a/src/maniple_mcp/config_cli.py +++ b/src/maniple_mcp/config_cli.py @@ -274,6 +274,7 @@ def _set_nested_value(data: dict, key: str, value: object) -> None: field, _ALLOWED_AGENT_TYPES, ), + "defaults.provider": _parse_optional_string, "defaults.skip_permissions": _parse_bool, "defaults.use_worktree": _parse_bool, "defaults.layout": lambda value, field: _parse_literal( diff --git a/src/maniple_mcp/tools/__init__.py b/src/maniple_mcp/tools/__init__.py index f398182..977f9a2 100644 --- a/src/maniple_mcp/tools/__init__.py +++ b/src/maniple_mcp/tools/__init__.py @@ -14,6 +14,7 @@ from . import discover_workers from . import examine_worker from . import list_workers +from . import list_providers from . import list_worktrees from . import message_workers from . import poll_worker_changes @@ -38,6 +39,7 @@ def register_all_tools(mcp: FastMCP, ensure_connection) -> None: check_idle_workers.register_tools(mcp) close_workers.register_tools(mcp) examine_worker.register_tools(mcp) + list_providers.register_tools(mcp) list_workers.register_tools(mcp) list_worktrees.register_tools(mcp) message_workers.register_tools(mcp) diff --git a/src/maniple_mcp/tools/close_workers.py b/src/maniple_mcp/tools/close_workers.py index 0331a9d..b0ba7e5 100644 --- a/src/maniple_mcp/tools/close_workers.py +++ b/src/maniple_mcp/tools/close_workers.py @@ -16,7 +16,12 @@ from ..iterm_utils import CODEX_PRE_ENTER_DELAY from ..registry import SessionRegistry, SessionStatus -from ..worktree import WorktreeError, remove_worktree +from ..worktree import ( + WorktreeError, + delete_worktree_branch, + get_worktree_branch, + remove_worktree, +) from ..utils import error_response, HINTS logger = logging.getLogger("maniple") @@ -48,6 +53,7 @@ async def _close_single_worker( session_id: str, registry: "SessionRegistry", force: bool = False, + delete_branch: bool = False, ) -> dict: """ Close a single worker session. @@ -63,7 +69,7 @@ async def _close_single_worker( force: If True, force close even if session is busy Returns: - Dict with success status and worktree_cleaned flag + Dict with success status and cleanup flags """ # Check if busy if session.status == SessionStatus.BUSY and not force: @@ -72,6 +78,7 @@ async def _close_single_worker( "error": "Session is busy", "hint": HINTS["session_busy"], "worktree_cleaned": False, + "branch_deleted": False, } try: @@ -91,10 +98,17 @@ async def _close_single_worker( # TODO(rabsef-bicrym): Programmatically time these actions await asyncio.sleep(1.0) - # Clean up worktree if exists (keeps branch alive for cherry-picking) + # Capture the branch before removing the worktree so optional branch + # cleanup can still happen after git unregisters the worktree. worktree_cleaned = False + branch_deleted = False if session.worktree_path and session.main_repo_path: + worktree_branch = None try: + worktree_branch = get_worktree_branch( + repo_path=session.main_repo_path, + worktree_path=session.worktree_path, + ) remove_worktree( repo_path=session.main_repo_path, worktree_path=session.worktree_path, @@ -104,6 +118,16 @@ async def _close_single_worker( # Log but don't fail the close logger.warning(f"Failed to clean up worktree for {session_id}: {e}") + if delete_branch and worktree_cleaned and worktree_branch: + try: + delete_worktree_branch( + repo_path=session.main_repo_path, + branch_name=worktree_branch, + ) + branch_deleted = True + except WorktreeError as e: + logger.warning(f"Failed to delete branch for {session_id}: {e}") + # Close the terminal pane/window await backend.close_session(session.terminal_session, force=force) @@ -113,6 +137,7 @@ async def _close_single_worker( return { "success": True, "worktree_cleaned": worktree_cleaned, + "branch_deleted": branch_deleted, } except Exception as e: @@ -123,6 +148,7 @@ async def _close_single_worker( "success": True, "warning": f"Session removed but cleanup may be incomplete: {e}", "worktree_cleaned": False, + "branch_deleted": False, } @@ -134,6 +160,7 @@ async def close_workers( ctx: Context[ServerSession, "AppContext"], session_ids: list[str], force: bool | None = False, + delete_branch: bool | None = False, ) -> dict: """ Close one or more managed Claude Code sessions. @@ -144,7 +171,8 @@ async def close_workers( ⚠️ **NOTE: WORKTREE CLEANUP** Workers with worktrees commit to ephemeral branches. When closed: - The worktree directory is removed - - The branch is KEPT for cherry-picking/merging + - The branch is kept by default for cherry-picking/merging + - Set delete_branch=True to also delete the worker branch immediately **AFTER closing workers with worktrees:** 1. Review commits on the worker's branch @@ -155,6 +183,8 @@ async def close_workers( session_ids: List of session IDs to close (1 or more required). Accepts internal IDs, terminal IDs, or worker names. force: If True, force close even if sessions are busy + delete_branch: If True, also delete the worker branch after the + worktree is removed Returns: Dict with: @@ -165,6 +195,7 @@ async def close_workers( """ # Handle None values from MCP clients that send explicit null for omitted params force = force if force is not None else False + delete_branch = delete_branch if delete_branch is not None else False app_ctx = ctx.request_context.lifespan_context registry = app_ctx.registry @@ -198,7 +229,14 @@ async def close_workers( # Close all sessions in parallel async def close_one(sid: str, session) -> tuple[str, dict]: - result = await _close_single_worker(backend, session, sid, registry, force) + result = await _close_single_worker( + backend, + session, + sid, + registry, + force, + delete_branch, + ) return (sid, result) tasks = [close_one(sid, session) for sid, session in sessions_to_close] diff --git a/src/maniple_mcp/tools/list_providers.py b/src/maniple_mcp/tools/list_providers.py new file mode 100644 index 0000000..dcb141b --- /dev/null +++ b/src/maniple_mcp/tools/list_providers.py @@ -0,0 +1,56 @@ +""" +Provider listing tool. + +Provides list_providers for discovering configured worker launch providers. +""" + +from mcp.server.fastmcp import FastMCP + +from ..config import ConfigError, load_config, resolve_config_path +from ..utils import error_response + + +def register_tools(mcp: FastMCP) -> None: + """Register list_providers tool on the MCP server.""" + + @mcp.tool() + async def list_providers() -> dict: + """ + List configured worker launch providers from ~/.maniple/config.json. + + Useful for clients that want to discover which Claude-compatible + provider presets are available before calling spawn_workers. + + Returns: + Dict with provider names, command/env metadata, and config path + """ + config_path = resolve_config_path() + + try: + config = load_config() + except ConfigError as exc: + return error_response( + f"Invalid config: {exc}", + hint="Fix ~/.maniple/config.json and try again.", + config_path=str(config_path), + ) + + providers = [] + for name in sorted(config.providers): + provider = config.providers[name] + providers.append({ + "name": name, + "command": provider.command, + "env": dict(sorted(provider.env.items())), + }) + + return { + "config_path": str(config_path), + "count": len(providers), + "provider_names": [item["name"] for item in providers], + "providers": providers, + "usage_tip": ( + "Pass provider='' in spawn_workers to launch a worker " + "with one of these presets." + ), + } diff --git a/src/maniple_mcp/tools/spawn_workers.py b/src/maniple_mcp/tools/spawn_workers.py index cd1a3d3..19294a4 100644 --- a/src/maniple_mcp/tools/spawn_workers.py +++ b/src/maniple_mcp/tools/spawn_workers.py @@ -401,6 +401,8 @@ async def spawn_workers( resolved_env_overrides: list[dict[str, str]] = [] for i, w in enumerate(workers): provider_name = w.get("provider") + if provider_name is None: + provider_name = defaults.provider command_override = w.get("command") env_override = w.get("env") diff --git a/src/maniple_mcp/utils/errors.py b/src/maniple_mcp/utils/errors.py index f4534be..09c9a1d 100644 --- a/src/maniple_mcp/utils/errors.py +++ b/src/maniple_mcp/utils/errors.py @@ -4,7 +4,12 @@ Provides standardized error formatting and common hints for recovery. """ -from ..registry import SessionRegistry, ManagedSession +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..registry import ManagedSession, SessionRegistry def error_response( diff --git a/src/maniple_mcp/worktree.py b/src/maniple_mcp/worktree.py index 8eb5b45..def84d5 100644 --- a/src/maniple_mcp/worktree.py +++ b/src/maniple_mcp/worktree.py @@ -457,6 +457,64 @@ def remove_worktree( return True +def get_worktree_branch(repo_path: Path, worktree_path: Path) -> Optional[str]: + """ + Return the branch currently associated with a registered worktree path. + + Args: + repo_path: Path to the main repository + worktree_path: Full path to the worktree + + Returns: + Branch name if the worktree is registered and not detached, otherwise None + """ + repo_path = Path(repo_path).resolve() + worktree_path = Path(worktree_path).resolve() + + for worktree in list_git_worktrees(repo_path): + if Path(worktree["path"]).resolve() == worktree_path: + return worktree.get("branch") + + return None + + +def delete_worktree_branch( + repo_path: Path, + branch_name: str, + force: bool = True, +) -> bool: + """ + Delete a worktree branch after its worktree has been removed. + + Args: + repo_path: Path to the main repository + branch_name: Branch to delete + force: If True, use `git branch -D`; otherwise use `-d` + + Returns: + True if the branch was deleted or already absent + + Raises: + WorktreeError: If the branch deletion fails + """ + repo_path = Path(repo_path).resolve() + + branch_flag = "-D" if force else "-d" + result = subprocess.run( + ["git", "-C", str(repo_path), "branch", branch_flag, branch_name], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + stderr = result.stderr.strip() + if "not found" in stderr or "branch '" in stderr and "not found" in stderr: + return True + raise WorktreeError(f"Failed to delete branch: {stderr}") + + return True + + def list_git_worktrees(repo_path: Path) -> list[dict]: """ List all worktrees registered with git for a repository. diff --git a/tests/test_claude_maniple_switch.py b/tests/test_claude_maniple_switch.py index d471731..486dd9f 100644 --- a/tests/test_claude_maniple_switch.py +++ b/tests/test_claude_maniple_switch.py @@ -57,3 +57,51 @@ def test_wrapper_loads_maniple_env_and_dispatches_provider(tmp_path: Path) -> No assert "base_url=https://minimax.example.test/anthropic" in result.stdout assert "auth=test-token" in result.stdout assert "arg1=--help" in result.stdout + + +def test_wrapper_applies_namespaced_provider_env_convention(tmp_path: Path) -> None: + """Namespaced MANIPLE_PROVIDER_* vars should map to provider-specific aliases.""" + fake_switch = tmp_path / "fake-claude-switch.sh" + _write_executable( + fake_switch, + """#!/usr/bin/env bash +claude-local() { + echo "provider=local" + echo "local_base_url=${LOCAL_BASE_URL:-}" + echo "local_api_key=${LOCAL_API_KEY:-}" + echo "local_model=${LOCAL_MODEL:-}" +} +claude-official() { echo "provider=official"; } +""", + ) + + provider_env = tmp_path / ".env" + provider_env.write_text( + "export MANIPLE_PROVIDER_LOCAL_BASE_URL=http://127.0.0.1:4000\n" + "export MANIPLE_PROVIDER_LOCAL_API_KEY=local-token\n" + "export MANIPLE_PROVIDER_LOCAL_MODEL=claude-local-model\n" + ) + + wrapper = Path(__file__).resolve().parent.parent / "scripts" / "claude-maniple-switch" + env = os.environ.copy() + env.pop("LOCAL_BASE_URL", None) + env.pop("LOCAL_API_KEY", None) + env.pop("LOCAL_MODEL", None) + env.update({ + "CLAUDE_SWITCH_SCRIPT": str(fake_switch), + "CLAUDE_SWITCH_PROVIDER": "local", + "MANIPLE_PROVIDER_ENV_FILE": str(provider_env), + }) + + result = subprocess.run( + ["bash", str(wrapper)], + capture_output=True, + text=True, + check=True, + env=env, + ) + + assert "provider=local" in result.stdout + assert "local_base_url=http://127.0.0.1:4000" in result.stdout + assert "local_api_key=local-token" in result.stdout + assert "local_model=claude-local-model" in result.stdout diff --git a/tests/test_close_workers.py b/tests/test_close_workers.py new file mode 100644 index 0000000..586b9e0 --- /dev/null +++ b/tests/test_close_workers.py @@ -0,0 +1,187 @@ +"""Tests for the close_workers tool.""" + +import importlib +from pathlib import Path +from types import SimpleNamespace + +import pytest +from mcp.server.fastmcp import FastMCP + +close_workers_module = importlib.import_module("maniple_mcp.tools.close_workers") + + +class FakeBackend: + """Minimal backend for close_workers tests.""" + + def __init__(self) -> None: + self.sent_keys: list[tuple[str, str]] = [] + self.sent_texts: list[tuple[str, str]] = [] + self.closed: list[tuple[str, bool]] = [] + + async def send_key(self, session, key: str) -> None: + self.sent_keys.append((session.native_id, key)) + + async def send_text(self, session, text: str) -> None: + self.sent_texts.append((session.native_id, text)) + + async def close_session(self, session, force: bool = False) -> None: + self.closed.append((session.native_id, force)) + + +async def _no_sleep(_: float) -> None: + """Skip timing delays in tests.""" + + +class FakeRegistry: + """Minimal registry for close_workers tests.""" + + def __init__(self, session) -> None: + self.session = session + self.removed: list[str] = [] + + def resolve(self, session_id: str): + if self.session and session_id == self.session.session_id: + return self.session + return None + + def remove(self, session_id: str): + self.removed.append(session_id) + if self.session and session_id == self.session.session_id: + session = self.session + self.session = None + return session + return None + + +def _make_session() -> SimpleNamespace: + return SimpleNamespace( + session_id="worker-1", + status=close_workers_module.SessionStatus.READY, + agent_type="claude", + terminal_session=SimpleNamespace(native_id="%1"), + main_repo_path=Path("/repo"), + worktree_path=Path("/repo/.worktrees/worker-branch"), + ) + + +def _build_tool(backend: FakeBackend, registry: FakeRegistry): + app_ctx = SimpleNamespace(registry=registry, terminal_backend=backend) + mcp = FastMCP("test") + close_workers_module.register_tools(mcp) + tool = mcp._tool_manager.get_tool("close_workers") + assert tool is not None + ctx = SimpleNamespace(request_context=SimpleNamespace(lifespan_context=app_ctx)) + return tool, ctx + + +@pytest.mark.asyncio +async def test_close_workers_keeps_branch_by_default(monkeypatch): + """close_workers should remove the worktree but keep the branch by default.""" + monkeypatch.setattr(close_workers_module.asyncio, "sleep", _no_sleep) + + removed: list[tuple[Path, Path]] = [] + deleted_branches: list[tuple[Path, str]] = [] + + monkeypatch.setattr( + close_workers_module, + "get_worktree_branch", + lambda repo_path, worktree_path: "worker-branch", + ) + monkeypatch.setattr( + close_workers_module, + "remove_worktree", + lambda repo_path, worktree_path: removed.append((repo_path, worktree_path)) or True, + ) + monkeypatch.setattr( + close_workers_module, + "delete_worktree_branch", + lambda repo_path, branch_name: deleted_branches.append((repo_path, branch_name)) or True, + ) + + backend = FakeBackend() + registry = FakeRegistry(_make_session()) + + tool, ctx = _build_tool(backend, registry) + result = await tool.run({"session_ids": ["worker-1"]}, context=ctx) + + assert result["success_count"] == 1 + assert result["results"]["worker-1"]["worktree_cleaned"] is True + assert result["results"]["worker-1"]["branch_deleted"] is False + assert removed == [(Path("/repo"), Path("/repo/.worktrees/worker-branch"))] + assert deleted_branches == [] + + +@pytest.mark.asyncio +async def test_close_workers_can_delete_branch(monkeypatch): + """close_workers should optionally delete the branch after removing the worktree.""" + monkeypatch.setattr(close_workers_module.asyncio, "sleep", _no_sleep) + + call_order: list[str] = [] + + monkeypatch.setattr( + close_workers_module, + "get_worktree_branch", + lambda repo_path, worktree_path: "worker-branch", + ) + monkeypatch.setattr( + close_workers_module, + "remove_worktree", + lambda repo_path, worktree_path: call_order.append("remove_worktree") or True, + ) + monkeypatch.setattr( + close_workers_module, + "delete_worktree_branch", + lambda repo_path, branch_name: call_order.append(f"delete_branch:{branch_name}") or True, + ) + + backend = FakeBackend() + registry = FakeRegistry(_make_session()) + + tool, ctx = _build_tool(backend, registry) + result = await tool.run( + {"session_ids": ["worker-1"], "delete_branch": True}, + context=ctx, + ) + + assert result["success_count"] == 1 + assert result["results"]["worker-1"]["worktree_cleaned"] is True + assert result["results"]["worker-1"]["branch_deleted"] is True + assert call_order == ["remove_worktree", "delete_branch:worker-branch"] + + +@pytest.mark.asyncio +async def test_close_workers_skips_branch_delete_when_worktree_cleanup_fails(monkeypatch): + """Branch deletion should not run if worktree removal fails.""" + monkeypatch.setattr(close_workers_module.asyncio, "sleep", _no_sleep) + + deleted_branches: list[str] = [] + + monkeypatch.setattr( + close_workers_module, + "get_worktree_branch", + lambda repo_path, worktree_path: "worker-branch", + ) + + def fail_remove_worktree(repo_path, worktree_path): + raise close_workers_module.WorktreeError("boom") + + monkeypatch.setattr(close_workers_module, "remove_worktree", fail_remove_worktree) + monkeypatch.setattr( + close_workers_module, + "delete_worktree_branch", + lambda repo_path, branch_name: deleted_branches.append(branch_name) or True, + ) + + backend = FakeBackend() + registry = FakeRegistry(_make_session()) + + tool, ctx = _build_tool(backend, registry) + result = await tool.run( + {"session_ids": ["worker-1"], "delete_branch": True}, + context=ctx, + ) + + assert result["success_count"] == 1 + assert result["results"]["worker-1"]["worktree_cleaned"] is False + assert result["results"]["worker-1"]["branch_deleted"] is False + assert deleted_branches == [] diff --git a/tests/test_config.py b/tests/test_config.py index c1130e6..446a7f0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -43,6 +43,7 @@ def test_default_defaults(self): """Default spawn_workers defaults.""" config = default_config() assert config.defaults.agent_type == "claude" + assert config.defaults.provider is None assert config.defaults.skip_permissions is False assert config.defaults.use_worktree is True assert config.defaults.layout == "auto" @@ -103,6 +104,7 @@ def test_saves_all_fields(self, tmp_path: Path): commands=CommandsConfig(claude="/custom/claude", codex="/custom/codex"), defaults=DefaultsConfig( agent_type="codex", + provider="local", skip_permissions=True, use_worktree=False, layout="new", @@ -123,6 +125,7 @@ def test_saves_all_fields(self, tmp_path: Path): assert data["commands"]["claude"] == "/custom/claude" assert data["commands"]["codex"] == "/custom/codex" assert data["defaults"]["agent_type"] == "codex" + assert data["defaults"]["provider"] == "local" assert data["defaults"]["skip_permissions"] is True assert data["defaults"]["use_worktree"] is False assert data["defaults"]["layout"] == "new" @@ -184,6 +187,7 @@ def test_loads_existing_config(self, tmp_path: Path): config = load_config(config_path) assert config.commands.claude == "/my/claude" assert config.defaults.agent_type == "codex" + assert config.defaults.provider is None assert config.terminal.backend == "iterm" assert config.events.max_size_mb == 10 assert config.issue_tracker.override == "pebbles" @@ -198,6 +202,7 @@ def test_partial_config_uses_defaults(self, tmp_path: Path): # All other fields should have defaults assert config.commands.claude is None assert config.defaults.agent_type == "claude" + assert config.defaults.provider is None assert config.terminal.backend is None assert config.events.max_size_mb == 1 assert config.issue_tracker.override is None @@ -225,6 +230,7 @@ def test_roundtrip_preserves_values(self, tmp_path: Path): commands=CommandsConfig(claude="/bin/claude", codex="/bin/codex"), defaults=DefaultsConfig( agent_type="codex", + provider="local", skip_permissions=True, use_worktree=False, layout="new", @@ -240,6 +246,7 @@ def test_roundtrip_preserves_values(self, tmp_path: Path): assert loaded.commands.claude == original.commands.claude assert loaded.commands.codex == original.commands.codex assert loaded.defaults.agent_type == original.defaults.agent_type + assert loaded.defaults.provider == original.defaults.provider assert loaded.defaults.skip_permissions == original.defaults.skip_permissions assert loaded.defaults.use_worktree == original.defaults.use_worktree assert loaded.defaults.layout == original.defaults.layout @@ -468,6 +475,26 @@ def test_defaults_skip_permissions_not_bool(self, tmp_path: Path): with pytest.raises(ConfigError, match="defaults.skip_permissions must be a boolean"): load_config(config_path) + def test_defaults_provider_not_string(self, tmp_path: Path): + """Non-string provider raises ConfigError.""" + config_path = tmp_path / "config.json" + config_path.write_text(json.dumps({ + "version": 1, + "defaults": {"provider": True}, + })) + with pytest.raises(ConfigError, match="defaults.provider must be a string"): + load_config(config_path) + + def test_defaults_provider_empty_string_raises(self, tmp_path: Path): + """Empty default provider raises ConfigError.""" + config_path = tmp_path / "config.json" + config_path.write_text(json.dumps({ + "version": 1, + "defaults": {"provider": ""}, + })) + with pytest.raises(ConfigError, match="defaults.provider cannot be empty"): + load_config(config_path) + def test_defaults_use_worktree_not_bool(self, tmp_path: Path): """Non-boolean use_worktree raises ConfigError.""" config_path = tmp_path / "config.json" diff --git a/tests/test_config_cli.py b/tests/test_config_cli.py index c134b00..1f9b383 100644 --- a/tests/test_config_cli.py +++ b/tests/test_config_cli.py @@ -109,6 +109,12 @@ def test_set_stale_threshold_minutes(self, config_path: Path): config = load_config() assert config.events.stale_threshold_minutes == 30 + def test_set_default_provider(self, config_path: Path): + """set_config_value persists defaults.provider.""" + set_config_value("defaults.provider", "local") + config = load_config() + assert config.defaults.provider == "local" + class TestStaleThresholdEnvOverride: """Tests for stale_threshold_minutes env override.""" diff --git a/tests/test_list_providers.py b/tests/test_list_providers.py new file mode 100644 index 0000000..062ad94 --- /dev/null +++ b/tests/test_list_providers.py @@ -0,0 +1,73 @@ +"""Tests for the list_providers tool.""" + +from types import SimpleNamespace + +from mcp.server.fastmcp import FastMCP + +from maniple_mcp.config import ConfigError, ProviderConfig, default_config +from maniple_mcp.tools import list_providers as list_providers_module + + +async def _run_tool(payload: dict | None = None): + mcp = FastMCP("test") + list_providers_module.register_tools(mcp) + tool = mcp._tool_manager.get_tool("list_providers") + assert tool is not None + return await tool.run(payload or {}, context=SimpleNamespace()) + + +async def test_list_providers_returns_sorted_provider_metadata(monkeypatch): + """Configured providers should be returned in stable sorted order.""" + config = default_config() + config.providers = { + "kimi": ProviderConfig( + command="/home/test/bin/claude-maniple-switch", + env={"CLAUDE_SWITCH_PROVIDER": "kimi"}, + ), + "aliyun": ProviderConfig( + command="/home/test/bin/claude-maniple-switch", + env={"CLAUDE_SWITCH_PROVIDER": "aliyun"}, + ), + } + monkeypatch.setattr(list_providers_module, "load_config", lambda: config) + monkeypatch.setattr( + list_providers_module, + "resolve_config_path", + lambda: "/home/test/.maniple/config.json", + ) + + result = await _run_tool() + + assert result["count"] == 2 + assert result["provider_names"] == ["aliyun", "kimi"] + assert result["providers"] == [ + { + "name": "aliyun", + "command": "/home/test/bin/claude-maniple-switch", + "env": {"CLAUDE_SWITCH_PROVIDER": "aliyun"}, + }, + { + "name": "kimi", + "command": "/home/test/bin/claude-maniple-switch", + "env": {"CLAUDE_SWITCH_PROVIDER": "kimi"}, + }, + ] + + +async def test_list_providers_returns_error_for_invalid_config(monkeypatch): + """Config validation failures should surface as tool errors.""" + monkeypatch.setattr( + list_providers_module, + "load_config", + lambda: (_ for _ in ()).throw(ConfigError("bad config")), + ) + monkeypatch.setattr( + list_providers_module, + "resolve_config_path", + lambda: "/home/test/.maniple/config.json", + ) + + result = await _run_tool() + + assert result["error"] == "Invalid config: bad config" + assert result["config_path"] == "/home/test/.maniple/config.json" diff --git a/tests/test_spawn_workers_defaults.py b/tests/test_spawn_workers_defaults.py index 5472aec..14cd4a7 100644 --- a/tests/test_spawn_workers_defaults.py +++ b/tests/test_spawn_workers_defaults.py @@ -450,6 +450,70 @@ async def ensure_connection(app_context): assert backend.started[0]["command_override"] == "/usr/local/bin/claude-local" +@pytest.mark.asyncio +async def test_spawn_workers_uses_default_provider_when_not_specified(tmp_path, monkeypatch): + """defaults.provider should apply when a worker omits provider/command/env.""" + config = default_config() + config.defaults = DefaultsConfig( + agent_type="claude", + skip_permissions=False, + use_worktree=False, + layout="new", + provider="local", + ) + config.providers = { + "local": ProviderConfig( + command="/usr/local/bin/claude-local", + env={"CLAUDE_PROVIDER": "local"}, + ) + } + monkeypatch.setattr(spawn_workers_module, "load_config", lambda: config) + monkeypatch.setattr(spawn_workers_module, "get_cli_backend", lambda *_: "cli:claude") + monkeypatch.setattr(spawn_workers_module, "get_worktree_tracker_dir", lambda *_: None) + monkeypatch.setattr( + spawn_workers_module, + "generate_worker_prompt", + lambda *args, **kwargs: "PROMPT", + ) + monkeypatch.setattr( + spawn_workers_module, + "get_coordinator_guidance", + lambda *args, **kwargs: {"summary": "ok"}, + ) + + async def fake_await_marker_in_jsonl(*args, **kwargs): + return None + + monkeypatch.setattr(session_state, "await_marker_in_jsonl", fake_await_marker_in_jsonl) + monkeypatch.setattr(session_state, "generate_marker_message", lambda *args, **kwargs: "MARKER") + + backend = FakeBackend() + registry = SessionRegistry() + app_ctx = SimpleNamespace(registry=registry, backend=backend) + + async def ensure_connection(app_context): + return app_context.backend + + mcp = FastMCP("test") + spawn_workers_module.register_tools(mcp, ensure_connection) + tool = mcp._tool_manager.get_tool("spawn_workers") + assert tool is not None + + repo_path = tmp_path / "repo" + repo_path.mkdir() + + ctx = SimpleNamespace(request_context=SimpleNamespace(lifespan_context=app_ctx)) + await tool.run({ + "workers": [{ + "project_path": str(repo_path), + "name": "Worker1", + }], + }, context=ctx) + + assert backend.started[0]["env"] == {"CLAUDE_PROVIDER": "local"} + assert backend.started[0]["command_override"] == "/usr/local/bin/claude-local" + + @pytest.mark.asyncio async def test_spawn_workers_uses_direct_command_and_env_override(tmp_path, monkeypatch): """Workers should support direct command/env overrides without providers.""" diff --git a/uv.lock b/uv.lock index 297ee1f..af94b72 100644 --- a/uv.lock +++ b/uv.lock @@ -301,7 +301,7 @@ wheels = [ [[package]] name = "maniple-mcp" -version = "0.12.0" +version = "0.13.0" source = { editable = "." } dependencies = [ { name = "iterm2" }, From 1167c87ce0accd4a6ac079edd0f7b98955b3f824 Mon Sep 17 00:00:00 2001 From: lingyuzeng Date: Sun, 8 Mar 2026 20:12:35 +0800 Subject: [PATCH 5/8] Add setup scripts for systemd and provider presets --- Makefile | 10 +- README.md | 26 ++++ scripts/install-systemd-user.sh | 62 ++++++++++ scripts/setup-provider-presets.sh | 195 ++++++++++++++++++++++++++++++ 4 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 scripts/install-systemd-user.sh create mode 100644 scripts/setup-provider-presets.sh diff --git a/Makefile b/Makefile index 7568581..e6fa6e9 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,11 @@ -.PHONY: install-commands install-commands-force test sync help +.PHONY: install-commands install-commands-force install-systemd-user setup-provider-presets test sync help help: @echo "Available targets:" @echo " install-commands Install slash commands to ~/.claude/commands/" @echo " install-commands-force Install slash commands (overwrite existing)" + @echo " install-systemd-user Install/update the user-level systemd service" + @echo " setup-provider-presets Install wrapper and create provider config templates" @echo " test Run pytest" @echo " sync Sync dependencies with uv" @@ -13,6 +15,12 @@ install-commands: install-commands-force: uv run scripts/install-commands.py --force +install-systemd-user: + bash scripts/install-systemd-user.sh + +setup-provider-presets: + bash scripts/setup-provider-presets.sh + test: uv run --group dev pytest diff --git a/README.md b/README.md index 18f0237..4cbce63 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,20 @@ uv sync --group dev uv run maniple --help ``` +Quick-start helpers from this repo: + +```bash +# Install/update ~/bin/claude-maniple-switch and create starter files: +bash scripts/setup-provider-presets.sh + +# Install/update ~/.config/systemd/user/maniple.service for this checkout: +bash scripts/install-systemd-user.sh +``` + +These scripts create the files for you and keep existing user config files in +place. After they run, you still need to edit `~/.maniple/config.json` and +`~/.maniple/.env` with your actual provider choices and credentials. + 5. Create the user service directories: ```bash @@ -424,6 +438,18 @@ Minimal setup: 3. Add named presets under `providers` in `~/.maniple/config.json` 4. Call `spawn_workers` with `provider: "minimax"` or `provider: "kimi"` or `provider: "local"` +Bootstrap these files automatically: + +```bash +bash scripts/setup-provider-presets.sh +``` + +That script will: +- install `~/bin/claude-maniple-switch` +- create `~/.maniple/config.json` if it does not exist +- create `~/.maniple/.env` if it does not exist +- leave existing files untouched and print the next manual steps + Define named presets in `~/.maniple/config.json` and reference them per worker: ```json diff --git a/scripts/install-systemd-user.sh b/scripts/install-systemd-user.sh new file mode 100644 index 0000000..bd25321 --- /dev/null +++ b/scripts/install-systemd-user.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Install a user-level systemd service for running Maniple HTTP mode from this +# source checkout. + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +repo_root="$(cd "${script_dir}/.." && pwd -P)" + +uv_bin="${MANIPLE_UV_BIN:-$(command -v uv)}" +if [[ -z "${uv_bin}" ]]; then + echo "uv not found in PATH. Install uv first." >&2 + exit 1 +fi + +host="${MANIPLE_HTTP_HOST:-127.0.0.1}" +port="${MANIPLE_HTTP_PORT:-8766}" +backend="${MANIPLE_TERMINAL_BACKEND:-tmux}" +allow_host="${MANIPLE_ALLOW_HOST:-${host}:${port}}" + +config_dir="${HOME}/.config/systemd/user" +service_path="${config_dir}/maniple.service" +logs_dir="${HOME}/.maniple/logs" + +mkdir -p "${config_dir}" "${logs_dir}" + +cat > "${service_path}" < "${config_path}" < "${env_path}" <<'EOF' +# Fill in only the providers you actually use. +# +# Naming convention: +# provider name "local2" -> MANIPLE_PROVIDER_LOCAL2_* +# provider name "kimi" -> MANIPLE_PROVIDER_KIMI_* + +export MANIPLE_PROVIDER_LOCAL_API_KEY="replace-me" +export MANIPLE_PROVIDER_LOCAL_BASE_URL="https://example.local" +export MANIPLE_PROVIDER_LOCAL_MODEL="replace-me" + +export MANIPLE_PROVIDER_LOCAL2_API_KEY="replace-me" +export MANIPLE_PROVIDER_LOCAL2_BASE_URL="https://example.local2" +export MANIPLE_PROVIDER_LOCAL2_MODEL="replace-me" + +export MANIPLE_PROVIDER_LOCAL3_API_KEY="replace-me" +export MANIPLE_PROVIDER_LOCAL3_BASE_URL="https://example.local3" +export MANIPLE_PROVIDER_LOCAL3_MODEL="replace-me" + +export MANIPLE_PROVIDER_KIMI_API_KEY="replace-me" +export MANIPLE_PROVIDER_KIMI_BASE_URL="https://api.moonshot.cn/anthropic" +export MANIPLE_PROVIDER_KIMI_MODEL="replace-me" + +export MANIPLE_PROVIDER_CKIMI_API_KEY="replace-me" +export MANIPLE_PROVIDER_CKIMI_BASE_URL="https://example.ckimi" +export MANIPLE_PROVIDER_CKIMI_MODEL="replace-me" + +export MANIPLE_PROVIDER_MINIMAX_API_KEY="replace-me" +export MANIPLE_PROVIDER_MINIMAX_BASE_URL="https://api.minimaxi.com/anthropic" +export MANIPLE_PROVIDER_MINIMAX_MODEL="replace-me" + +export MANIPLE_PROVIDER_ALIYUN_API_KEY="replace-me" +export MANIPLE_PROVIDER_ALIYUN_BASE_URL="https://example.aliyun" +export MANIPLE_PROVIDER_ALIYUN_MODEL="replace-me" + +export MANIPLE_PROVIDER_GLM_API_KEY="replace-me" +export MANIPLE_PROVIDER_GLM_BASE_URL="https://example.glm" +export MANIPLE_PROVIDER_GLM_MODEL="replace-me" + +export MANIPLE_PROVIDER_KAT_API_KEY="replace-me" +export MANIPLE_PROVIDER_KAT_BASE_URL="https://example.kat" +export MANIPLE_PROVIDER_KAT_MODEL="replace-me" + +export MANIPLE_PROVIDER_GREVERSE_API_KEY="replace-me" +export MANIPLE_PROVIDER_GREVERSE_BASE_URL="https://example.greverse" +export MANIPLE_PROVIDER_GREVERSE_MODEL="replace-me" + +export MANIPLE_PROVIDER_GCS_API_KEY="replace-me" +export MANIPLE_PROVIDER_GCS_BASE_URL="https://example.gcs" +export MANIPLE_PROVIDER_GCS_MODEL="replace-me" +EOF + chmod 0600 "${env_path}" + echo "Created ${env_path}" +else + echo "Keeping existing ${env_path}" +fi + +echo +echo "Installed wrapper:" +echo " ${wrapper_dst}" +echo +echo "Next steps:" +echo " 1. Edit ${config_path} and keep only the providers you want." +echo " 2. Edit ${env_path} and replace the placeholder values." +echo " 3. Optional: set a default provider:" +echo " uv run maniple config set defaults.provider local" +echo " 4. If running under systemd:" +echo " systemctl --user restart maniple" From 365f9d48933186ba0e0b9a275a136fb69f69b679 Mon Sep 17 00:00:00 2001 From: lingyuzeng Date: Sun, 8 Mar 2026 20:14:07 +0800 Subject: [PATCH 6/8] Highlight Linux quick start in README --- README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/README.md b/README.md index 4cbce63..94b46f2 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,35 @@ uv run python -m maniple_mcp \ ## Installation +### Linux Quick Start + +For a Debian/Ubuntu-style source checkout with `systemd --user` and provider +presets, the fastest path is: + +```bash +git clone https://github.com/Martian-Engineering/maniple.git +cd maniple +uv sync --group dev + +# Install/update ~/bin/claude-maniple-switch and create starter files: +bash scripts/setup-provider-presets.sh + +# Install/update ~/.config/systemd/user/maniple.service for this checkout: +bash scripts/install-systemd-user.sh +``` + +Then edit: +- `~/.maniple/config.json` +- `~/.maniple/.env` + +After changing provider settings: + +```bash +systemctl --user restart maniple +``` + +For the full Debian/Linux walkthrough, see [Running as a Service](#running-as-a-service). + ### As Claude Code Plugin (recommended) ```bash From 6f5b53fbcd23b80b0d049a6c6220336df10aaa19 Mon Sep 17 00:00:00 2001 From: lingyuzeng Date: Sun, 8 Mar 2026 22:13:13 +0800 Subject: [PATCH 7/8] Improve Claude worker startup handling --- README.md | 65 ++++++++++- src/maniple_mcp/cli_backends/claude.py | 7 +- src/maniple_mcp/config.py | 8 +- src/maniple_mcp/iterm_utils.py | 38 ++++++- src/maniple_mcp/launch_blockers.py | 71 ++++++++++++ src/maniple_mcp/terminal_backends/iterm.py | 2 + src/maniple_mcp/terminal_backends/tmux.py | 21 ++++ src/maniple_mcp/tools/spawn_workers.py | 15 ++- src/maniple_mcp/utils/errors.py | 4 + tests/test_cli_backends.py | 6 +- tests/test_config.py | 14 ++- tests/test_iterm_utils.py | 90 +++++++++++++-- tests/test_spawn_workers_defaults.py | 124 +++++++++++++++++++++ tests/test_tmux_backend.py | 76 +++++++++++++ 14 files changed, 516 insertions(+), 25 deletions(-) create mode 100644 src/maniple_mcp/launch_blockers.py diff --git a/README.md b/README.md index 94b46f2..019f19b 100644 --- a/README.md +++ b/README.md @@ -412,7 +412,8 @@ maniple config set # Set and persist a value "layout": "auto" }, "terminal": { - "backend": null + "backend": null, + "auto_accept_startup_prompts": false }, "events": { "max_size_mb": 1, @@ -430,10 +431,11 @@ maniple config set # Set and persist a value | `commands.codex` | string | Override Codex CLI command (e.g. `"happy codex"`) | | `defaults.agent_type` | `"claude"` or `"codex"` | Default agent type for new workers | | `defaults.provider` | string or null | Default named provider preset for `spawn_workers` when a worker omits `provider` | -| `defaults.skip_permissions` | bool | Default `--dangerously-skip-permissions` flag | +| `defaults.skip_permissions` | bool | Default Claude permission mode (`--permission-mode bypassPermissions`) | | `defaults.use_worktree` | bool | Create git worktrees by default | | `defaults.layout` | `"auto"` or `"new"` | Default layout mode for spawn_workers | | `terminal.backend` | `"tmux"` or `"iterm"` | Terminal backend override (null = auto-detect) | +| `terminal.auto_accept_startup_prompts` | bool | Auto-confirm known Claude startup prompts during worker launch | | `events.max_size_mb` | int | Max event log file size before rotation | | `events.recent_hours` | int | Hours of events to retain | | `issue_tracker.override` | `"beads"` or `"pebbles"` | Force a specific issue tracker | @@ -731,12 +733,46 @@ Claude Code can still pause on local interactive confirmations. Common examples: - trusting a newly opened project folder - approving local `.mcp.json` servers discovered in the target repo -- confirming the `--dangerously-skip-permissions` / bypass mode warning +- confirming Claude permission-mode / bypass mode warnings when the CLI requires it If this happens, the worker will appear to time out during startup even though the tmux pane is alive. Check the pane directly and clear the confirmation once. After that, service-mode orchestration is much smoother. +If you want unattended worker startup in a trusted local environment, set +`terminal.auto_accept_startup_prompts` to `true`. This only auto-confirms +known startup blockers during launch, currently: + +- the project `.mcp.json` trust prompt +- the Claude bypass-permissions startup confirmation + +This is intentionally opt-in because it accepts security prompts on your behalf. + +For the same project, MCP trust is usually a one-time confirmation. After you +accept the `.mcp.json` server for that project, future workers in the same +project normally stop hitting that prompt. It can reappear if you reset Claude +state, change the project's MCP config, or open a different project. + +The bypass-permissions warning is also a Claude-side interactive confirmation. +After you accept it once, repeated prompts are less likely in the same local +setup, but you should not assume that it is permanently suppressed across CLI +upgrades, config resets, or entirely new environments. + +For tmux-backed workers, you can jump straight to the blocked worker pane and +clear the prompt manually: + +```bash +# Open the smoke-test worker that was blocked on the .mcp.json trust prompt +tmux attach-session -t maniple-maniple-smoke \; select-window -t 2 + +# Open the MolGen worker that was blocked on the bypass-permissions prompt +tmux attach-session -t maniple-MolGen \; select-window -t 35 +``` + +Once attached: +- For the `.mcp.json` prompt, choose `Use this and all future MCP servers in this project` if you trust that repo's MCP config. +- For the bypass-permissions prompt, move to `Yes, I accept` and press Enter if you intentionally launched that worker with `skip_permissions: true`. + ## MCP Tools ### Worker Management @@ -795,7 +831,7 @@ WorkerConfig fields: badge: str - Task description (shown in badge, used in branch names) issue_id: str - Issue tracker ID (for badge, branch naming, and workflow instructions) prompt: str - Additional instructions (combined with standard worker prompt) - skip_permissions: bool - Start with --dangerously-skip-permissions + skip_permissions: bool - Start Claude with `--permission-mode bypassPermissions` use_worktree: bool - Create isolated git worktree (default: true) worktree: WorktreeConfig - Optional worktree settings: branch: Explicit branch name (auto-generated if omitted) @@ -1071,6 +1107,27 @@ make install-commands ### General +**Worker startup timed out, but the pane is still alive** +- Claude may be blocked on a first-run confirmation rather than actually failing to start +- Common blockers are: + - the project `.mcp.json` trust prompt +- the Claude bypass-permissions confirmation +- For tmux sessions, attach directly to the blocked pane: + +```bash +tmux attach-session -t \; select-window -t +``` + +- Example sessions from local debugging: + +```bash +tmux attach-session -t maniple-maniple-smoke \; select-window -t 2 +tmux attach-session -t maniple-MolGen \; select-window -t 35 +``` + +- After you clear the confirmation once, retry `spawn_workers` +- For the same project, `.mcp.json` trust is usually a one-time confirmation unless Claude state or project MCP config changes + **"Session not found"** - The worker may have been closed externally - Use `list_workers` to see active workers diff --git a/src/maniple_mcp/cli_backends/claude.py b/src/maniple_mcp/cli_backends/claude.py index 11c070b..12d309e 100644 --- a/src/maniple_mcp/cli_backends/claude.py +++ b/src/maniple_mcp/cli_backends/claude.py @@ -77,7 +77,7 @@ class ClaudeCLI(AgentCLI): Claude Code CLI implementation. Supports: - - --dangerously-skip-permissions flag + - --permission-mode bypassPermissions - --settings flag for Stop hook injection - Ready detection via TUI patterns (robot banner, '>' prompt, 'tokens' status) - Idle detection via Stop hook markers in JSONL @@ -111,7 +111,7 @@ def build_args( Build Claude CLI arguments. Args: - dangerously_skip_permissions: Add --dangerously-skip-permissions + dangerously_skip_permissions: Add --permission-mode bypassPermissions settings_file: Path to settings JSON for Stop hook injection plugin_dir: Path(s) to plugin directory for --plugin-dir (single string or list) @@ -121,7 +121,8 @@ def build_args( args: list[str] = [] if dangerously_skip_permissions: - args.append("--dangerously-skip-permissions") + args.append("--permission-mode") + args.append("bypassPermissions") if plugin_dir: # Support both single string and list of strings diff --git a/src/maniple_mcp/config.py b/src/maniple_mcp/config.py index 397b258..d8da25f 100644 --- a/src/maniple_mcp/config.py +++ b/src/maniple_mcp/config.py @@ -54,6 +54,7 @@ class TerminalConfig: """Terminal backend configuration.""" backend: TerminalBackend | None = None # None = auto-detect + auto_accept_startup_prompts: bool = False @dataclass @@ -259,7 +260,7 @@ def _parse_defaults(value: object) -> DefaultsConfig: def _parse_terminal(value: object) -> TerminalConfig: # Parse terminal backend configuration. data = _ensure_dict(value, "terminal") - _validate_keys(data, {"backend"}, "terminal") + _validate_keys(data, {"backend", "auto_accept_startup_prompts"}, "terminal") return TerminalConfig( backend=_optional_literal( data.get("backend"), @@ -267,6 +268,11 @@ def _parse_terminal(value: object) -> TerminalConfig: "terminal.backend", None, ), + auto_accept_startup_prompts=_optional_bool( + data.get("auto_accept_startup_prompts"), + "terminal.auto_accept_startup_prompts", + TerminalConfig.auto_accept_startup_prompts, + ), ) diff --git a/src/maniple_mcp/iterm_utils.py b/src/maniple_mcp/iterm_utils.py index a50b131..1622fff 100644 --- a/src/maniple_mcp/iterm_utils.py +++ b/src/maniple_mcp/iterm_utils.py @@ -20,6 +20,7 @@ from .cli_backends import AgentCLI from .subprocess_cache import cached_system_profiler +from .launch_blockers import AgentLaunchBlocked, detect_launch_blocker logger = logging.getLogger("maniple.iterm_utils") @@ -481,6 +482,9 @@ async def wait_for_claude_ready( try: content = await read_screen_text(session) lines = content.split('\n') + blocker = detect_launch_blocker(content, "claude") + if blocker is not None: + raise AgentLaunchBlocked(blocker) # Check if content is stable (same as last read) if content == last_content: @@ -502,6 +506,8 @@ async def wait_for_claude_ready( logger.debug("Claude ready: found status bar with 'tokens'") return True + except AgentLaunchBlocked: + raise except Exception as e: # Screen read failed, retry logger.debug(f"Screen read failed during Claude ready check: {e}") @@ -518,6 +524,7 @@ async def wait_for_agent_ready( timeout_seconds: float = 15.0, poll_interval: float = 0.2, stable_count: int = 2, + auto_accept_startup_prompts: bool = False, ) -> bool: """ Wait for an agent CLI to be ready to accept input. @@ -542,11 +549,32 @@ async def wait_for_agent_ready( start_time = time.monotonic() last_content = None stable_reads = 0 + auto_accepted_blockers: set[str] = set() while (time.monotonic() - start_time) < timeout_seconds: try: content = await read_screen_text(session) lines = content.split('\n') + blocker = detect_launch_blocker(content, cli.engine_id) + if blocker is not None: + if ( + auto_accept_startup_prompts + and blocker.auto_accept_choice + and blocker.slug not in auto_accepted_blockers + ): + logger.info( + "Auto-accepting Claude startup blocker in iTerm: %s", + blocker.slug, + ) + await send_text(session, blocker.auto_accept_choice) + await asyncio.sleep(0.2) + await send_key(session, "enter") + auto_accepted_blockers.add(blocker.slug) + last_content = None + stable_reads = 0 + await asyncio.sleep(0.5) + continue + raise AgentLaunchBlocked(blocker) # Check if content is stable (same as last read) if content == last_content: @@ -566,6 +594,8 @@ async def wait_for_agent_ready( ) return True + except AgentLaunchBlocked: + raise except Exception as e: # Screen read failed, retry logger.debug(f"Screen read failed during agent ready check: {e}") @@ -639,6 +669,7 @@ async def start_agent_in_session( output_capture_path: Optional[str] = None, plugin_dir: Optional[str | list[str]] = None, command_override: Optional[str] = None, + auto_accept_startup_prompts: bool = False, ) -> None: """ Start an agent CLI in an existing iTerm2 session. @@ -702,7 +733,10 @@ async def start_agent_in_session( # Wait for agent to actually start (detect ready patterns, not blind sleep) effective_command = command_override or cli.command() if not await wait_for_agent_ready( - session, cli, timeout_seconds=agent_ready_timeout + session, + cli, + timeout_seconds=agent_ready_timeout, + auto_accept_startup_prompts=auto_accept_startup_prompts, ): raise RuntimeError( f"{cli.engine_id} failed to start in {project_path} within " @@ -883,7 +917,7 @@ async def create_multi_claude_layout( the expected pane names for the layout (e.g., for 'quad': 'top_left', 'top_right', 'bottom_left', 'bottom_right') layout: Layout type (single, vertical, horizontal, quad, triple_vertical) - skip_permissions: If True, start Claude with --dangerously-skip-permissions + skip_permissions: If True, start Claude with --permission-mode bypassPermissions project_envs: Optional dict mapping pane names to env var dicts. Each pane can have its own environment variables set before starting Claude. profile: Optional profile name to use for all panes diff --git a/src/maniple_mcp/launch_blockers.py b/src/maniple_mcp/launch_blockers.py new file mode 100644 index 0000000..a3e3f9a --- /dev/null +++ b/src/maniple_mcp/launch_blockers.py @@ -0,0 +1,71 @@ +""" +Detect interactive launch blockers shown before an agent reaches its ready UI. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class LaunchBlocker: + """Structured description of a launch blocker visible on screen.""" + + slug: str + summary: str + hint: str + auto_accept_choice: str | None = None + + +class AgentLaunchBlocked(RuntimeError): + """Raised when an agent launch is blocked on an interactive confirmation UI.""" + + def __init__(self, blocker: LaunchBlocker): + self.blocker = blocker + self.hint = blocker.hint + super().__init__(blocker.summary) + + +_CLAUDE_BLOCKERS: tuple[tuple[tuple[str, ...], LaunchBlocker], ...] = ( + ( + ("New MCP server found in .mcp.json", "Use this and all future MCP servers"), + LaunchBlocker( + slug="mcp_trust_confirmation", + summary=( + "Claude launch is blocked on the project MCP trust prompt. " + "Approve the MCP server in the worker terminal, then retry." + ), + hint=( + "Open the worker pane and accept the .mcp.json server prompt " + "once for this project, or remove/disable that MCP entry before spawning." + ), + auto_accept_choice="1", + ), + ), + ( + ("WARNING: Claude Code running in Bypass Permissions mode", "Yes, I accept"), + LaunchBlocker( + slug="bypass_permissions_confirmation", + summary=( + "Claude launch is blocked on the Bypass Permissions confirmation. " + "Accept it in the worker terminal, or disable skip_permissions for that worker." + ), + hint=( + "Open the worker pane and confirm Bypass Permissions once, or set " + "skip_permissions=False if you do not want that startup prompt." + ), + auto_accept_choice="2", + ), + ), +) + + +def detect_launch_blocker(content: str, engine_id: str) -> LaunchBlocker | None: + """Return a known blocker if the current screen content matches one.""" + if engine_id != "claude": + return None + + for needles, blocker in _CLAUDE_BLOCKERS: + if all(needle in content for needle in needles): + return blocker + return None diff --git a/src/maniple_mcp/terminal_backends/iterm.py b/src/maniple_mcp/terminal_backends/iterm.py index 746fc32..4e3140d 100644 --- a/src/maniple_mcp/terminal_backends/iterm.py +++ b/src/maniple_mcp/terminal_backends/iterm.py @@ -198,6 +198,7 @@ async def start_agent_in_session( output_capture_path: Optional[str] = None, plugin_dir: Optional[str] = None, command_override: Optional[str] = None, + auto_accept_startup_prompts: bool = False, ) -> None: """Start a CLI agent in an existing terminal session.""" await iterm_utils.start_agent_in_session( @@ -212,6 +213,7 @@ async def start_agent_in_session( output_capture_path=output_capture_path, plugin_dir=plugin_dir, command_override=command_override, + auto_accept_startup_prompts=auto_accept_startup_prompts, ) async def find_available_window( diff --git a/src/maniple_mcp/terminal_backends/tmux.py b/src/maniple_mcp/terminal_backends/tmux.py index 3617929..d5505e7 100644 --- a/src/maniple_mcp/terminal_backends/tmux.py +++ b/src/maniple_mcp/terminal_backends/tmux.py @@ -14,6 +14,7 @@ from typing import Any, TYPE_CHECKING from .base import TerminalBackend, TerminalSession +from ..launch_blockers import AgentLaunchBlocked, detect_launch_blocker if TYPE_CHECKING: from ..cli_backends import AgentCLI @@ -469,6 +470,7 @@ async def start_agent_in_session( output_capture_path: str | None = None, plugin_dir: str | None = None, command_override: str | None = None, + auto_accept_startup_prompts: bool = False, ) -> None: """Start a CLI agent in an existing tmux pane.""" # Ensure the shell is responsive before we send the launch command. @@ -510,6 +512,7 @@ async def start_agent_in_session( handle, cli, timeout_seconds=agent_ready_timeout, + auto_accept_startup_prompts=auto_accept_startup_prompts, ) effective_command = command_override or cli.command() if not agent_ready: @@ -602,6 +605,7 @@ async def _wait_for_agent_ready( timeout_seconds: float = 15.0, poll_interval: float = 0.2, stable_count: int = 2, + auto_accept_startup_prompts: bool = False, ) -> bool: """Wait for an agent CLI to show its ready patterns.""" import time @@ -610,10 +614,27 @@ async def _wait_for_agent_ready( start_time = time.monotonic() last_content = None stable_reads = 0 + auto_accepted_blockers: set[str] = set() while (time.monotonic() - start_time) < timeout_seconds: # Read the pane content and only check once output stabilizes. content = await self.read_screen_text(session) + blocker = detect_launch_blocker(content, cli.engine_id) + if blocker is not None: + if ( + auto_accept_startup_prompts + and blocker.auto_accept_choice + and blocker.slug not in auto_accepted_blockers + ): + await self.send_text(session, blocker.auto_accept_choice) + await asyncio.sleep(0.2) + await self.send_key(session, "enter") + auto_accepted_blockers.add(blocker.slug) + last_content = None + stable_reads = 0 + await asyncio.sleep(0.5) + continue + raise AgentLaunchBlocked(blocker) if content == last_content: stable_reads += 1 else: diff --git a/src/maniple_mcp/tools/spawn_workers.py b/src/maniple_mcp/tools/spawn_workers.py index 19294a4..a36343a 100644 --- a/src/maniple_mcp/tools/spawn_workers.py +++ b/src/maniple_mcp/tools/spawn_workers.py @@ -20,6 +20,7 @@ from ..config import ConfigError, default_config, load_config from ..colors import generate_tab_color from ..formatting import format_badge_text, format_session_title +from ..launch_blockers import AgentLaunchBlocked from ..names import pick_names_for_count from ..profile import apply_appearance_colors from ..registry import SessionStatus @@ -154,7 +155,7 @@ async def spawn_workers( commit with issue reference). Used for badge first line and branch naming. prompt: Optional additional instructions. Combined with standard worker prompt, not a replacement. Use for extra context beyond what the issue describes. - skip_permissions: Whether to start Claude with --dangerously-skip-permissions. + skip_permissions: Whether to start Claude with --permission-mode bypassPermissions. Default False. Without this, workers can only read local files and will struggle with most commands (writes, shell, etc.). provider: Optional named launch preset from config.providers. Cannot be @@ -763,6 +764,7 @@ async def start_agent_for_worker(index: int) -> None: stop_hook_marker_id=stop_hook_marker_id, plugin_dir=plugin_dir, command_override=command_override, + auto_accept_startup_prompts=config.terminal.auto_accept_startup_prompts, ) await asyncio.gather(*[start_agent_for_worker(i) for i in range(worker_count)]) @@ -936,9 +938,18 @@ async def start_agent_for_worker(index: int) -> None: except ValueError as e: logger.error(f"Validation error in spawn_workers: {e}") return error_response(str(e)) + except AgentLaunchBlocked as e: + logger.error(f"Worker launch blocked: {e}") + return error_response( + str(e), + hint=getattr(e, "hint", HINTS["launch_blocked"]), + ) except Exception as e: logger.error(f"Failed to spawn workers: {e}") + hint = getattr(e, "hint", None) + if hint is None and backend.backend_id == "iterm": + hint = HINTS["iterm_connection"] return error_response( str(e), - hint=HINTS["iterm_connection"], + hint=hint, ) diff --git a/src/maniple_mcp/utils/errors.py b/src/maniple_mcp/utils/errors.py index 09c9a1d..c67166d 100644 --- a/src/maniple_mcp/utils/errors.py +++ b/src/maniple_mcp/utils/errors.py @@ -71,6 +71,10 @@ def error_response( "The session is currently processing. Wait for it to finish, or use " "force=True to close it anyway (may lose work)" ), + "launch_blocked": ( + "Open the worker terminal and complete any Claude startup confirmation " + "prompt, then retry spawning the worker" + ), } diff --git a/tests/test_cli_backends.py b/tests/test_cli_backends.py index 5fa2e84..7427749 100644 --- a/tests/test_cli_backends.py +++ b/tests/test_cli_backends.py @@ -107,10 +107,10 @@ def test_build_args_empty_default(self): assert args == [] def test_build_args_skip_permissions(self): - """Should add --dangerously-skip-permissions flag.""" + """Should add permission-mode bypassPermissions.""" cli = ClaudeCLI() args = cli.build_args(dangerously_skip_permissions=True) - assert "--dangerously-skip-permissions" in args + assert args[:2] == ["--permission-mode", "bypassPermissions"] def test_build_args_settings_file_default_command(self): """Should add --settings flag for default claude command.""" @@ -185,7 +185,7 @@ def test_build_full_command_simple(self): os.environ.pop("CLAUDE_TEAM_COMMAND", None) cli = ClaudeCLI() cmd = cli.build_full_command(dangerously_skip_permissions=True) - assert cmd == "claude --dangerously-skip-permissions" + assert cmd == "claude --permission-mode bypassPermissions" def test_build_full_command_with_env_vars(self): """build_full_command should prepend env vars.""" diff --git a/tests/test_config.py b/tests/test_config.py index 446a7f0..ac23b37 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -52,6 +52,7 @@ def test_default_terminal(self): """Default terminal backend is None (auto-detect).""" config = default_config() assert config.terminal.backend is None + assert config.terminal.auto_accept_startup_prompts is False def test_default_events(self): """Default events config values.""" @@ -109,7 +110,10 @@ def test_saves_all_fields(self, tmp_path: Path): use_worktree=False, layout="new", ), - terminal=TerminalConfig(backend="tmux"), + terminal=TerminalConfig( + backend="tmux", + auto_accept_startup_prompts=True, + ), events=EventsConfig(max_size_mb=5, recent_hours=48, stale_threshold_minutes=15), issue_tracker=IssueTrackerConfig(override="beads"), providers={ @@ -130,6 +134,7 @@ def test_saves_all_fields(self, tmp_path: Path): assert data["defaults"]["use_worktree"] is False assert data["defaults"]["layout"] == "new" assert data["terminal"]["backend"] == "tmux" + assert data["terminal"]["auto_accept_startup_prompts"] is True assert data["events"]["max_size_mb"] == 5 assert data["events"]["recent_hours"] == 48 assert data["events"]["stale_threshold_minutes"] == 15 @@ -174,7 +179,10 @@ def test_loads_existing_config(self, tmp_path: Path): "version": 1, "commands": {"claude": "/my/claude"}, "defaults": {"agent_type": "codex"}, - "terminal": {"backend": "iterm"}, + "terminal": { + "backend": "iterm", + "auto_accept_startup_prompts": True, + }, "events": {"max_size_mb": 10}, "issue_tracker": {"override": "pebbles"}, "providers": { @@ -189,6 +197,7 @@ def test_loads_existing_config(self, tmp_path: Path): assert config.defaults.agent_type == "codex" assert config.defaults.provider is None assert config.terminal.backend == "iterm" + assert config.terminal.auto_accept_startup_prompts is True assert config.events.max_size_mb == 10 assert config.issue_tracker.override == "pebbles" assert config.providers["local"].command == "/usr/local/bin/claude-local" @@ -204,6 +213,7 @@ def test_partial_config_uses_defaults(self, tmp_path: Path): assert config.defaults.agent_type == "claude" assert config.defaults.provider is None assert config.terminal.backend is None + assert config.terminal.auto_accept_startup_prompts is False assert config.events.max_size_mb == 1 assert config.issue_tracker.override is None diff --git a/tests/test_iterm_utils.py b/tests/test_iterm_utils.py index 3e9c1fe..3bb5b9f 100644 --- a/tests/test_iterm_utils.py +++ b/tests/test_iterm_utils.py @@ -5,7 +5,81 @@ import pytest -from maniple_mcp.iterm_utils import build_stop_hook_settings_file +from maniple_mcp.iterm_utils import build_stop_hook_settings_file, wait_for_agent_ready +from maniple_mcp.launch_blockers import AgentLaunchBlocked + + +class _FakeClaudeCLI: + engine_id = "claude" + + def ready_patterns(self): + return [">", "tokens"] + + +@pytest.mark.asyncio +async def test_wait_for_agent_ready_raises_on_mcp_trust_prompt(monkeypatch): + async def fake_read_screen_text(_session): + return ( + "New MCP server found in .mcp.json\n" + "1. Use this and all future MCP servers in this project\n" + ) + + monkeypatch.setattr("maniple_mcp.iterm_utils.read_screen_text", fake_read_screen_text) + + with pytest.raises(AgentLaunchBlocked) as excinfo: + await wait_for_agent_ready(object(), _FakeClaudeCLI(), timeout_seconds=1.0) + + assert "MCP trust prompt" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_wait_for_agent_ready_raises_on_bypass_permissions_prompt(monkeypatch): + async def fake_read_screen_text(_session): + return ( + "WARNING: Claude Code running in Bypass Permissions mode\n" + "2. Yes, I accept\n" + ) + + monkeypatch.setattr("maniple_mcp.iterm_utils.read_screen_text", fake_read_screen_text) + + with pytest.raises(AgentLaunchBlocked) as excinfo: + await wait_for_agent_ready(object(), _FakeClaudeCLI(), timeout_seconds=1.0) + + assert "Bypass Permissions confirmation" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_wait_for_agent_ready_auto_accepts_bypass_permissions(monkeypatch): + responses = iter([ + "WARNING: Claude Code running in Bypass Permissions mode\n2. Yes, I accept\n", + "> ", + "> ", + "> ", + ]) + sent = [] + + async def fake_read_screen_text(_session): + return next(responses) + + async def fake_send_text(_session, text): + sent.append(("text", text)) + + async def fake_send_key(_session, key): + sent.append(("key", key)) + + monkeypatch.setattr("maniple_mcp.iterm_utils.read_screen_text", fake_read_screen_text) + monkeypatch.setattr("maniple_mcp.iterm_utils.send_text", fake_send_text) + monkeypatch.setattr("maniple_mcp.iterm_utils.send_key", fake_send_key) + + ready = await wait_for_agent_ready( + object(), + _FakeClaudeCLI(), + timeout_seconds=2.0, + auto_accept_startup_prompts=True, + ) + + assert ready is True + assert sent == [("text", "2"), ("key", "enter")] class TestClaudeCommandBuilding: @@ -75,8 +149,8 @@ def test_custom_command_with_path_skips_settings(self): assert "--settings" not in claude_cmd - def test_dangerously_skip_permissions_still_added(self): - """--dangerously-skip-permissions should be added regardless of command.""" + def test_bypass_permissions_mode_still_added(self): + """Bypass permission mode should be added regardless of command.""" # Test with default claude with patch.dict(os.environ, {}, clear=False): os.environ.pop("MANIPLE_COMMAND", None) @@ -84,9 +158,9 @@ def test_dangerously_skip_permissions_still_added(self): dangerously_skip_permissions = True if dangerously_skip_permissions: - claude_cmd += " --dangerously-skip-permissions" + claude_cmd += " --permission-mode bypassPermissions" - assert "--dangerously-skip-permissions" in claude_cmd + assert "--permission-mode bypassPermissions" in claude_cmd # Test with custom command with patch.dict(os.environ, {"MANIPLE_COMMAND": "happy"}): @@ -94,10 +168,10 @@ def test_dangerously_skip_permissions_still_added(self): dangerously_skip_permissions = True if dangerously_skip_permissions: - claude_cmd += " --dangerously-skip-permissions" + claude_cmd += " --permission-mode bypassPermissions" - assert "--dangerously-skip-permissions" in claude_cmd - assert claude_cmd == "happy --dangerously-skip-permissions" + assert "--permission-mode bypassPermissions" in claude_cmd + assert claude_cmd == "happy --permission-mode bypassPermissions" class TestBuildStopHookSettingsFile: diff --git a/tests/test_spawn_workers_defaults.py b/tests/test_spawn_workers_defaults.py index 14cd4a7..aa4280f 100644 --- a/tests/test_spawn_workers_defaults.py +++ b/tests/test_spawn_workers_defaults.py @@ -7,6 +7,7 @@ import maniple_mcp.session_state as session_state from maniple_mcp.config import ConfigError, DefaultsConfig, ProviderConfig, default_config +from maniple_mcp.launch_blockers import AgentLaunchBlocked, LaunchBlocker from maniple_mcp.registry import SessionRegistry from maniple_mcp.terminal_backends.base import TerminalSession from maniple_mcp.tools import spawn_workers as spawn_workers_module @@ -22,6 +23,7 @@ def __init__(self) -> None: self.prompts = [] self.sessions = [] self.create_calls = [] + self.raise_on_start = None async def create_session( self, @@ -57,6 +59,8 @@ async def start_agent_in_session( stop_hook_marker_id: str | None = None, **kwargs, ) -> None: + if self.raise_on_start is not None: + raise self.raise_on_start self.started.append({ "handle": handle, "cli": cli, @@ -82,6 +86,47 @@ async def send_prompt_for_agent( }) +async def _run_spawn_workers_tool(tmp_path, monkeypatch, backend, workers): + config = default_config() + config.defaults = DefaultsConfig(use_worktree=False, layout="new") + monkeypatch.setattr(spawn_workers_module, "load_config", lambda: config) + monkeypatch.setattr(spawn_workers_module, "get_cli_backend", lambda agent_type: f"cli:{agent_type}") + monkeypatch.setattr(spawn_workers_module, "get_worktree_tracker_dir", lambda *_: None) + monkeypatch.setattr( + spawn_workers_module, + "generate_worker_prompt", + lambda *args, **kwargs: "PROMPT", + ) + monkeypatch.setattr( + spawn_workers_module, + "get_coordinator_guidance", + lambda *args, **kwargs: {"summary": "ok"}, + ) + async def fake_await_marker_in_jsonl(*args, **kwargs): + return None + + async def fake_await_codex_marker_in_jsonl(*args, **kwargs): + return None + + monkeypatch.setattr(session_state, "await_marker_in_jsonl", fake_await_marker_in_jsonl) + monkeypatch.setattr(session_state, "await_codex_marker_in_jsonl", fake_await_codex_marker_in_jsonl) + monkeypatch.setattr(session_state, "generate_marker_message", lambda *args, **kwargs: "MARKER") + + registry = SessionRegistry() + app_ctx = SimpleNamespace(registry=registry, backend=backend) + + async def ensure_connection(app_context): + return app_context.backend + + mcp = FastMCP("test") + spawn_workers_module.register_tools(mcp, ensure_connection) + tool = mcp._tool_manager.get_tool("spawn_workers") + assert tool is not None + + ctx = SimpleNamespace(request_context=SimpleNamespace(lifespan_context=app_ctx)) + return await tool.run({"workers": workers}, context=ctx) + + @pytest.mark.asyncio async def test_spawn_workers_uses_config_defaults(tmp_path, monkeypatch): """spawn_workers should apply config defaults when fields are omitted.""" @@ -171,6 +216,85 @@ async def ensure_connection(app_context): assert prompt_calls == [False] +@pytest.mark.asyncio +async def test_spawn_workers_returns_launch_blocked_hint(tmp_path, monkeypatch): + """spawn_workers should surface launch blocker hints instead of iTerm advice.""" + repo_path = tmp_path / "repo" + repo_path.mkdir() + + backend = FakeBackend() + backend.raise_on_start = AgentLaunchBlocked( + LaunchBlocker( + slug="mcp_trust_confirmation", + summary="Claude launch is blocked on the project MCP trust prompt.", + hint="Open the worker pane and accept the .mcp.json server prompt once.", + ) + ) + + result = await _run_spawn_workers_tool( + tmp_path, + monkeypatch, + backend, + [{"project_path": str(repo_path), "name": "Worker1"}], + ) + + assert result["error"] == "Claude launch is blocked on the project MCP trust prompt." + assert ".mcp.json server prompt" in result["hint"] + + +@pytest.mark.asyncio +async def test_spawn_workers_passes_auto_accept_startup_prompts(tmp_path, monkeypatch): + """spawn_workers should pass terminal auto-accept config to the backend.""" + config = default_config() + config.defaults = DefaultsConfig(use_worktree=False, layout="new") + config.terminal.auto_accept_startup_prompts = True + monkeypatch.setattr(spawn_workers_module, "load_config", lambda: config) + monkeypatch.setattr(spawn_workers_module, "get_cli_backend", lambda agent_type: f"cli:{agent_type}") + monkeypatch.setattr(spawn_workers_module, "get_worktree_tracker_dir", lambda *_: None) + monkeypatch.setattr( + spawn_workers_module, + "generate_worker_prompt", + lambda *args, **kwargs: "PROMPT", + ) + monkeypatch.setattr( + spawn_workers_module, + "get_coordinator_guidance", + lambda *args, **kwargs: {"summary": "ok"}, + ) + + async def fake_await_marker_in_jsonl(*args, **kwargs): + return None + + async def fake_await_codex_marker_in_jsonl(*args, **kwargs): + return None + + monkeypatch.setattr(session_state, "await_marker_in_jsonl", fake_await_marker_in_jsonl) + monkeypatch.setattr(session_state, "await_codex_marker_in_jsonl", fake_await_codex_marker_in_jsonl) + monkeypatch.setattr(session_state, "generate_marker_message", lambda *args, **kwargs: "MARKER") + + backend = FakeBackend() + registry = SessionRegistry() + app_ctx = SimpleNamespace(registry=registry, backend=backend) + + async def ensure_connection(app_context): + return app_context.backend + + mcp = FastMCP("test") + spawn_workers_module.register_tools(mcp, ensure_connection) + tool = mcp._tool_manager.get_tool("spawn_workers") + assert tool is not None + + repo_path = tmp_path / "repo" + repo_path.mkdir() + + ctx = SimpleNamespace(request_context=SimpleNamespace(lifespan_context=app_ctx)) + await tool.run({ + "workers": [{"project_path": str(repo_path), "name": "Worker1"}], + }, context=ctx) + + assert backend.started[0]["auto_accept_startup_prompts"] is True + + @pytest.mark.asyncio async def test_spawn_workers_invalid_config_falls_back(tmp_path, monkeypatch): """spawn_workers should fall back to defaults if config is invalid.""" diff --git a/tests/test_tmux_backend.py b/tests/test_tmux_backend.py index 21c5bff..83a8ace 100644 --- a/tests/test_tmux_backend.py +++ b/tests/test_tmux_backend.py @@ -4,6 +4,8 @@ import pytest +from maniple_mcp.cli_backends import claude_cli +from maniple_mcp.launch_blockers import AgentLaunchBlocked from maniple_mcp.terminal_backends.base import TerminalSession from maniple_mcp.terminal_backends.tmux import TmuxBackend, tmux_session_name_for_project @@ -257,3 +259,77 @@ def test_tmux_session_name_fallback_for_none(): """Test that None project path produces fallback session name.""" session = tmux_session_name_for_project(None) assert session == "maniple-project" + + +@pytest.mark.asyncio +async def test_wait_for_agent_ready_raises_on_mcp_trust_prompt(monkeypatch): + backend = TmuxBackend() + session = TerminalSession("tmux", "%1", "%1") + + async def fake_read_screen_text(_session): + return ( + "New MCP server found in .mcp.json\n" + "1. Use this and all future MCP servers in this project\n" + ) + + monkeypatch.setattr(backend, "read_screen_text", fake_read_screen_text) + + with pytest.raises(AgentLaunchBlocked) as excinfo: + await backend._wait_for_agent_ready(session, claude_cli, timeout_seconds=1.0) + + assert "MCP trust prompt" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_wait_for_agent_ready_raises_on_bypass_permissions_prompt(monkeypatch): + backend = TmuxBackend() + session = TerminalSession("tmux", "%1", "%1") + + async def fake_read_screen_text(_session): + return ( + "WARNING: Claude Code running in Bypass Permissions mode\n" + "2. Yes, I accept\n" + ) + + monkeypatch.setattr(backend, "read_screen_text", fake_read_screen_text) + + with pytest.raises(AgentLaunchBlocked) as excinfo: + await backend._wait_for_agent_ready(session, claude_cli, timeout_seconds=1.0) + + assert "Bypass Permissions confirmation" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_wait_for_agent_ready_auto_accepts_bypass_permissions(monkeypatch): + backend = TmuxBackend() + session = TerminalSession("tmux", "%1", "%1") + responses = iter([ + "WARNING: Claude Code running in Bypass Permissions mode\n2. Yes, I accept\n", + "> ", + "> ", + "> ", + ]) + sent = [] + + async def fake_read_screen_text(_session): + return next(responses) + + async def fake_send_text(_session, text): + sent.append(("text", text)) + + async def fake_send_key(_session, key): + sent.append(("key", key)) + + monkeypatch.setattr(backend, "read_screen_text", fake_read_screen_text) + monkeypatch.setattr(backend, "send_text", fake_send_text) + monkeypatch.setattr(backend, "send_key", fake_send_key) + + ready = await backend._wait_for_agent_ready( + session, + claude_cli, + timeout_seconds=2.0, + auto_accept_startup_prompts=True, + ) + + assert ready is True + assert sent == [("text", "2"), ("key", "enter")] From 233097e57dcd0abca525b2b7cc05e8e2aa258021 Mon Sep 17 00:00:00 2001 From: lingyuzeng Date: Sun, 8 Mar 2026 22:35:48 +0800 Subject: [PATCH 8/8] Fix Codex default provider inheritance --- src/maniple_mcp/tools/spawn_workers.py | 2 +- tests/test_spawn_workers_defaults.py | 68 ++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/maniple_mcp/tools/spawn_workers.py b/src/maniple_mcp/tools/spawn_workers.py index a36343a..e656444 100644 --- a/src/maniple_mcp/tools/spawn_workers.py +++ b/src/maniple_mcp/tools/spawn_workers.py @@ -402,7 +402,7 @@ async def spawn_workers( resolved_env_overrides: list[dict[str, str]] = [] for i, w in enumerate(workers): provider_name = w.get("provider") - if provider_name is None: + if provider_name is None and agent_types[i] != "codex": provider_name = defaults.provider command_override = w.get("command") env_override = w.get("env") diff --git a/tests/test_spawn_workers_defaults.py b/tests/test_spawn_workers_defaults.py index aa4280f..c3c6690 100644 --- a/tests/test_spawn_workers_defaults.py +++ b/tests/test_spawn_workers_defaults.py @@ -638,6 +638,74 @@ async def ensure_connection(app_context): assert backend.started[0]["command_override"] == "/usr/local/bin/claude-local" +@pytest.mark.asyncio +async def test_spawn_workers_codex_ignores_default_provider(tmp_path, monkeypatch): + """Codex workers should not inherit defaults.provider Claude wrappers.""" + config = default_config() + config.defaults = DefaultsConfig( + agent_type="codex", + skip_permissions=False, + use_worktree=False, + layout="new", + provider="local", + ) + config.providers = { + "local": ProviderConfig( + command="/usr/local/bin/claude-local", + env={"CLAUDE_PROVIDER": "local"}, + ) + } + monkeypatch.setattr(spawn_workers_module, "load_config", lambda: config) + monkeypatch.setattr(spawn_workers_module, "get_cli_backend", lambda *_: "cli:codex") + monkeypatch.setattr(spawn_workers_module, "get_worktree_tracker_dir", lambda *_: None) + monkeypatch.setattr( + spawn_workers_module, + "generate_worker_prompt", + lambda *args, **kwargs: "PROMPT", + ) + monkeypatch.setattr( + spawn_workers_module, + "get_coordinator_guidance", + lambda *args, **kwargs: {"summary": "ok"}, + ) + + async def fake_await_codex_marker_in_jsonl(*args, **kwargs): + return None + + monkeypatch.setattr( + session_state, + "await_codex_marker_in_jsonl", + fake_await_codex_marker_in_jsonl, + ) + monkeypatch.setattr(session_state, "generate_marker_message", lambda *args, **kwargs: "MARKER") + + backend = FakeBackend() + registry = SessionRegistry() + app_ctx = SimpleNamespace(registry=registry, backend=backend) + + async def ensure_connection(app_context): + return app_context.backend + + mcp = FastMCP("test") + spawn_workers_module.register_tools(mcp, ensure_connection) + tool = mcp._tool_manager.get_tool("spawn_workers") + assert tool is not None + + repo_path = tmp_path / "repo" + repo_path.mkdir() + + ctx = SimpleNamespace(request_context=SimpleNamespace(lifespan_context=app_ctx)) + await tool.run({ + "workers": [{ + "project_path": str(repo_path), + "name": "Worker1", + }], + }, context=ctx) + + assert backend.started[0]["env"] == {"CI": "1"} + assert backend.started[0]["command_override"] is None + + @pytest.mark.asyncio async def test_spawn_workers_uses_direct_command_and_env_override(tmp_path, monkeypatch): """Workers should support direct command/env overrides without providers."""