diff --git a/.github/screenshots/ccb_help_screenshot.png b/.github/screenshots/ccb_help_screenshot.png new file mode 100644 index 00000000..580d21b7 Binary files /dev/null and b/.github/screenshots/ccb_help_screenshot.png differ diff --git a/.github/screenshots/ccb_test_final.png b/.github/screenshots/ccb_test_final.png new file mode 100644 index 00000000..4ad1f27e Binary files /dev/null and b/.github/screenshots/ccb_test_final.png differ diff --git a/.github/screenshots/ccb_test_screenshot.png b/.github/screenshots/ccb_test_screenshot.png new file mode 100644 index 00000000..87684003 Binary files /dev/null and b/.github/screenshots/ccb_test_screenshot.png differ diff --git a/bin/ccb-mounted b/bin/ccb-mounted index 5156cce3..e6fa3a1c 100755 --- a/bin/ccb-mounted +++ b/bin/ccb-mounted @@ -4,7 +4,7 @@ set -euo pipefail -PROVIDERS="codex:cask gemini:gask opencode:oask claude:lask droid:dask" +PROVIDERS="codex:cask gemini:gask opencode:oask claude:lask droid:dask copilot:hask codebuddy:bask qwen:qask" CWD=$(pwd) FORMAT="--json" AUTOSTART=false diff --git a/ccb b/ccb index 90d028c2..609c1b02 100755 --- a/ccb +++ b/ccb @@ -40,7 +40,7 @@ from session_utils import ( ) from pane_registry import upsert_registry, load_registry_by_project_id from project_id import compute_ccb_project_id -from providers import CASK_CLIENT_SPEC, GASK_CLIENT_SPEC, OASK_CLIENT_SPEC, LASK_CLIENT_SPEC, DASK_CLIENT_SPEC +from providers import CASK_CLIENT_SPEC, GASK_CLIENT_SPEC, OASK_CLIENT_SPEC, LASK_CLIENT_SPEC, DASK_CLIENT_SPEC, HASK_CLIENT_SPEC, BASK_CLIENT_SPEC, QASK_CLIENT_SPEC from process_lock import ProviderLock from askd_rpc import shutdown_daemon, read_state from askd_runtime import state_file_path @@ -608,7 +608,7 @@ class AILauncher: """Managed env + explicit caller marker for the pane/provider process.""" env = self._managed_env_overrides() prov = (provider or "").strip().lower() - if prov in {"claude", "codex", "gemini", "opencode", "droid", "email", "manual"}: + if prov in {"claude", "codex", "gemini", "opencode", "droid", "copilot", "codebuddy", "qwen", "email", "manual"}: env["CCB_CALLER"] = prov return env @@ -631,7 +631,7 @@ class AILauncher: if not cfg.is_dir(): return - for name in (".codex-session", ".gemini-session", ".opencode-session", ".claude-session", ".droid-session"): + for name in (".codex-session", ".gemini-session", ".opencode-session", ".claude-session", ".droid-session", ".copilot-session", ".codebuddy-session", ".qwen-session"): legacy = self.project_root / name if not legacy.exists(): continue @@ -760,7 +760,7 @@ class AILauncher: def _maybe_start_unified_askd(self, *, quiet: bool = False) -> None: """Start unified askd daemon (provider-agnostic).""" # Try to start for any enabled provider that uses askd (including claude) - for provider in ["codex", "gemini", "opencode", "droid", "claude"]: + for provider in ["codex", "gemini", "opencode", "droid", "claude", "copilot", "codebuddy", "qwen"]: if provider in [p.lower() for p in self.providers]: # Try to start and check if successful self._maybe_start_provider_daemon(provider, quiet=quiet) @@ -799,6 +799,9 @@ class AILauncher: "opencode": OASK_CLIENT_SPEC, "claude": LASK_CLIENT_SPEC, "droid": DASK_CLIENT_SPEC, + "copilot": HASK_CLIENT_SPEC, + "codebuddy": BASK_CLIENT_SPEC, + "qwen": QASK_CLIENT_SPEC, } spec = specs.get(provider) if not spec: @@ -1227,6 +1230,8 @@ class AILauncher: return self._start_opencode_tmux(parent_pane=parent_pane, direction=direction) elif provider == "droid": return self._start_droid_tmux(parent_pane=parent_pane, direction=direction) + elif provider in ("copilot", "codebuddy", "qwen"): + return self._start_generic_tmux(provider, parent_pane=parent_pane, direction=direction) else: print(f"❌ {t('unknown_provider', provider=provider)}") return None @@ -1280,6 +1285,8 @@ class AILauncher: self._write_opencode_session(runtime, None, pane_id=pane_id, pane_title_marker=pane_title_marker, start_cmd=start_cmd) elif provider == "droid": self._write_droid_session(runtime, None, pane_id=pane_id, pane_title_marker=pane_title_marker, start_cmd=start_cmd) + elif provider in ("copilot", "codebuddy", "qwen"): + self._write_generic_session(provider, runtime, None, pane_id=pane_id, pane_title_marker=pane_title_marker, start_cmd=start_cmd) else: print(f"❌ {t('unknown_provider', provider=provider)}") return None @@ -1851,16 +1858,19 @@ class AILauncher: def _warmup_provider(self, provider: str, timeout: float = 8.0) -> bool: if provider == "gemini": return True - if provider == "codex": - ping_script = self.script_dir / "bin" / "cping" - elif provider == "gemini": - ping_script = self.script_dir / "bin" / "gping" - elif provider == "opencode": - ping_script = self.script_dir / "bin" / "oping" - elif provider == "droid": - ping_script = self.script_dir / "bin" / "dping" - else: + ping_map = { + "codex": "cping", + "gemini": "gping", + "opencode": "oping", + "droid": "dping", + "copilot": "hping", + "codebuddy": "bping", + "qwen": "qping", + } + ping_name = ping_map.get(provider) + if not ping_name: return False + ping_script = self.script_dir / "bin" / ping_name if not ping_script.exists(): return False @@ -1904,6 +1914,12 @@ class AILauncher: return self._build_opencode_start_cmd() elif provider == "droid": return self._build_droid_start_cmd() + elif provider == "copilot": + return self._build_generic_start_cmd(provider, "gh copilot", "COPILOT_START_CMD") + elif provider == "codebuddy": + return self._build_generic_start_cmd(provider, "codebuddy", "CODEBUDDY_START_CMD") + elif provider == "qwen": + return self._build_generic_start_cmd(provider, "qwen", "QWEN_START_CMD") return "" def _opencode_resume_allowed(self) -> bool: @@ -2347,6 +2363,113 @@ class AILauncher: print(f"✅ {t('started_backend', provider='Droid', terminal='tmux pane', pane_id=pane_id)}") return pane_id + def _start_generic_tmux( + self, + provider: str, + *, + parent_pane: str | None = None, + direction: str | None = None, + ) -> str | None: + runtime = self.runtime_dir / provider + runtime.mkdir(parents=True, exist_ok=True) + + env_overrides = self._provider_env_overrides(provider) + start_cmd = self._build_env_prefix(env_overrides) + _build_export_path_cmd(self.script_dir / "bin") + self._get_start_cmd(provider) + pane_title_marker = f"CCB-{provider.capitalize()}" + + backend = TmuxBackend() + + use_direction = (direction or ("right" if not self.tmux_panes else "bottom")).strip() or "right" + use_parent = parent_pane + if not use_parent: + try: + use_parent = backend.get_current_pane_id() + except Exception: + use_parent = None + if not use_parent and use_direction == "bottom": + try: + use_parent = next(reversed(self.tmux_panes.values())) + except StopIteration: + use_parent = None + + try: + if use_parent and str(use_parent).startswith("%") and not backend.pane_exists(str(use_parent)): + use_parent = backend.get_current_pane_id() + except Exception: + use_parent = None + + pane_id = backend.create_pane("", str(Path.cwd()), direction=use_direction, percent=50, parent_pane=use_parent) + backend.respawn_pane(pane_id, cmd=start_cmd, cwd=str(Path.cwd()), remain_on_exit=True) + backend.set_pane_title(pane_id, pane_title_marker) + backend.set_pane_user_option(pane_id, "@ccb_agent", provider.capitalize()) + + self.tmux_panes[provider] = pane_id + + self._write_generic_session( + provider, + runtime, + None, + pane_id=pane_id, + pane_title_marker=pane_title_marker, + start_cmd=start_cmd, + ) + + print(f"✅ {t('started_backend', provider=provider.capitalize(), terminal='tmux pane', pane_id=pane_id)}") + return pane_id + + def _build_generic_start_cmd(self, provider: str, default_cmd: str, env_var: str) -> str: + cmd = (os.environ.get(env_var) or default_cmd).strip() or default_cmd + return cmd + + def _write_generic_session(self, provider, runtime, tmux_session, pane_id=None, pane_title_marker=None, start_cmd=None): + session_file = self._project_session_file(f".{provider}-session") + + writable, reason, fix = check_session_writable(session_file) + if not writable: + print(f"❌ Cannot write {session_file.name}: {reason}", file=sys.stderr) + print(f"💡 Fix: {fix}", file=sys.stderr) + return False + + data = { + "session_id": self.session_id, + "ccb_session_id": self.session_id, + "ccb_project_id": compute_ccb_project_id(self.project_root), + "runtime_dir": str(runtime), + "terminal": self.terminal_type, + "tmux_session": tmux_session, + "pane_id": pane_id, + "pane_title_marker": pane_title_marker, + "work_dir": str(self.project_root), + "work_dir_norm": _normalize_path_for_match(str(self.project_root)), + "start_dir": str(self.invocation_dir), + "active": True, + "started_at": time.strftime("%Y-%m-%d %H:%M:%S"), + "start_cmd": str(start_cmd) if start_cmd else None, + } + + ok, err = safe_write_session(session_file, json.dumps(data, ensure_ascii=False, indent=2)) + if not ok: + print(err, file=sys.stderr) + return False + try: + upsert_registry({ + "ccb_session_id": self.session_id, + "ccb_project_id": compute_ccb_project_id(self.project_root), + "work_dir": str(self.project_root), + "terminal": self.terminal_type, + "providers": { + provider: { + "pane_id": pane_id, + "pane_title_marker": pane_title_marker, + "session_file": str(session_file), + } + }, + }) + except Exception: + pass + self._maybe_start_provider_daemon(provider) + return True + def _start_cmd_pane( self, *, @@ -3068,6 +3191,19 @@ class AILauncher: else: env["DROID_TMUX_SESSION"] = pane_id + for extra in ("copilot", "codebuddy", "qwen"): + if extra in self.providers: + runtime = self.runtime_dir / extra + prefix = extra.upper() + env[f"{prefix}_SESSION_ID"] = self.session_id + env[f"{prefix}_RUNTIME_DIR"] = str(runtime) + env[f"{prefix}_TERMINAL"] = self.terminal_type or "" + pane_id = self._provider_pane_id(extra) + if self.terminal_type == "wezterm": + env[f"{prefix}_WEZTERM_PANE"] = pane_id + else: + env[f"{prefix}_TMUX_SESSION"] = pane_id + return env def _build_claude_env(self) -> dict: @@ -3958,7 +4094,7 @@ def _parse_providers(values: list[str], *, allow_unknown: bool = False) -> list[ Returns a de-duplicated list preserving order. """ - allowed = {"codex", "gemini", "opencode", "claude", "droid"} + allowed = {"codex", "gemini", "opencode", "claude", "droid", "copilot", "codebuddy", "qwen"} raw_parts = _split_provider_tokens(values) if not raw_parts: @@ -3978,8 +4114,8 @@ def _parse_providers(values: list[str], *, allow_unknown: bool = False) -> list[ if unknown and not allow_unknown: print(f"❌ invalid provider(s): {', '.join(unknown)}", file=sys.stderr) - print("💡 use: ccb codex gemini opencode claude droid (spaces) or ccb codex,gemini,opencode,claude,droid (commas)", file=sys.stderr) - print("💡 allowed: codex, gemini, opencode, claude, droid", file=sys.stderr) + print("💡 use: ccb codex gemini opencode claude droid copilot codebuddy qwen (spaces) or ccb codex,gemini,opencode,claude,droid,copilot,codebuddy,qwen (commas)", file=sys.stderr) + print("💡 allowed: codex, gemini, opencode, claude, droid, copilot, codebuddy, qwen", file=sys.stderr) return [] return parsed @@ -3990,7 +4126,7 @@ def _parse_providers_with_cmd(values: list[str]) -> tuple[list[str], bool]: Parse providers from argv and treat "cmd" as a separate flag. Returns (providers, cmd_enabled). """ - allowed = {"codex", "gemini", "opencode", "claude", "droid"} + allowed = {"codex", "gemini", "opencode", "claude", "droid", "copilot", "codebuddy", "qwen"} raw_parts = _split_provider_tokens(values) if not raw_parts: return [], False @@ -4014,8 +4150,8 @@ def _parse_providers_with_cmd(values: list[str]) -> tuple[list[str], bool]: if unknown: print(f"❌ invalid provider(s): {', '.join(unknown)}", file=sys.stderr) - print("💡 use: ccb codex gemini opencode claude droid cmd (spaces) or ccb codex,gemini,opencode,claude,droid,cmd (commas)", file=sys.stderr) - print("💡 allowed: codex, gemini, opencode, claude, droid, cmd", file=sys.stderr) + print("💡 use: ccb codex gemini opencode claude droid copilot codebuddy qwen cmd (spaces) or ccb codex,gemini,opencode,claude,droid,copilot,codebuddy,qwen,cmd (commas)", file=sys.stderr) + print("💡 allowed: codex, gemini, opencode, claude, droid, copilot, codebuddy, qwen, cmd", file=sys.stderr) return [], cmd_enabled return parsed, cmd_enabled @@ -4847,7 +4983,7 @@ def main(): subparsers = parser.add_subparsers(dest="command", help="Subcommands") kill_parser = subparsers.add_parser("kill", help="Terminate session or clean up zombies") - kill_parser.add_argument("providers", nargs="*", default=[], help="Backends to terminate (codex/gemini/opencode/claude/droid)") + kill_parser.add_argument("providers", nargs="*", default=[], help="Backends to terminate (codex/gemini/opencode/claude/droid/copilot/codebuddy/qwen)") kill_parser.add_argument("-f", "--force", action="store_true", help="Clean up all zombie tmux sessions globally") kill_parser.add_argument("-y", "--yes", action="store_true", help="Skip confirmation prompt (with -f)") @@ -4885,7 +5021,7 @@ def main(): start_parser.add_argument( "providers", nargs="*", - help="Backends to start (space or comma separated): codex, gemini, opencode, claude, droid (add cmd for a shell pane)", + help="Backends to start (space or comma separated): codex, gemini, opencode, claude, droid, copilot, codebuddy, qwen (add cmd for a shell pane)", ) start_parser.add_argument("-r", "--resume", "--restore", action="store_true", help="Resume context") start_parser.add_argument("-a", "--auto", action="store_true", help="Full auto permission mode") diff --git a/lib/ccb_start_config.py b/lib/ccb_start_config.py index 58da17ae..efd5e77d 100644 --- a/lib/ccb_start_config.py +++ b/lib/ccb_start_config.py @@ -10,7 +10,7 @@ CONFIG_FILENAME = "ccb.config" -DEFAULT_PROVIDERS = ["codex", "gemini", "opencode", "claude"] +DEFAULT_PROVIDERS = ["codex", "gemini", "opencode", "claude", "droid", "copilot", "codebuddy", "qwen"] @dataclass @@ -19,7 +19,7 @@ class StartConfig: path: Optional[Path] = None -_ALLOWED_PROVIDERS = {"codex", "gemini", "opencode", "claude", "droid"} +_ALLOWED_PROVIDERS = {"codex", "gemini", "opencode", "claude", "droid", "copilot", "codebuddy", "qwen"} def _parse_tokens(raw: str) -> list[str]: diff --git a/lib/mail/config.py b/lib/mail/config.py index 380feba5..b4a71371 100644 --- a/lib/mail/config.py +++ b/lib/mail/config.py @@ -32,7 +32,7 @@ CURRENT_CONFIG_VERSION = 3 # Supported AI providers -SUPPORTED_PROVIDERS = ["claude", "codex", "gemini", "opencode", "droid"] +SUPPORTED_PROVIDERS = ["claude", "codex", "gemini", "opencode", "droid", "copilot", "codebuddy", "qwen"] # Notification modes NotifyMode = Literal["on_completion", "realtime", "periodic", "on_request"] diff --git a/lib/mail_tui/wizard.py b/lib/mail_tui/wizard.py index 7d20f81d..a0511c58 100644 --- a/lib/mail_tui/wizard.py +++ b/lib/mail_tui/wizard.py @@ -104,9 +104,12 @@ def run_simple_wizard() -> bool: print(" 3. Gemini") print(" 4. OpenCode") print(" 5. Droid") + print(" 6. Copilot") + print(" 7. CodeBuddy") + print(" 8. Qwen") - default_choice = input("\nEnter choice [1-5]: ").strip() - default_map = {"1": "claude", "2": "codex", "3": "gemini", "4": "opencode", "5": "droid"} + default_choice = input("\nEnter choice [1-8]: ").strip() + default_map = {"1": "claude", "2": "codex", "3": "gemini", "4": "opencode", "5": "droid", "6": "copilot", "7": "codebuddy", "8": "qwen"} default_provider = default_map.get(default_choice, "claude") # Step 6: Allowed senders (whitelist) diff --git a/lib/memory/transfer.py b/lib/memory/transfer.py index 063d4ab7..69886b30 100644 --- a/lib/memory/transfer.py +++ b/lib/memory/transfer.py @@ -23,16 +23,19 @@ class ContextTransfer: """Orchestrate context transfer between providers.""" - SUPPORTED_PROVIDERS = ("codex", "gemini", "opencode", "droid") - SUPPORTED_SOURCES = ("auto", "claude", "codex", "gemini", "opencode", "droid") + SUPPORTED_PROVIDERS = ("codex", "gemini", "opencode", "droid", "copilot", "codebuddy", "qwen") + SUPPORTED_SOURCES = ("auto", "claude", "codex", "gemini", "opencode", "droid", "copilot", "codebuddy", "qwen") SOURCE_SESSION_FILES = { "claude": ".claude-session", "codex": ".codex-session", "gemini": ".gemini-session", "opencode": ".opencode-session", "droid": ".droid-session", + "copilot": ".copilot-session", + "codebuddy": ".codebuddy-session", + "qwen": ".qwen-session", } - DEFAULT_SOURCE_ORDER = ("claude", "codex", "gemini", "opencode", "droid") + DEFAULT_SOURCE_ORDER = ("claude", "codex", "gemini", "opencode", "droid", "copilot", "codebuddy", "qwen") DEFAULT_FALLBACK_PAIRS = 50 def __init__( diff --git a/lib/pane_registry.py b/lib/pane_registry.py index 3ace8b8d..6b4659e2 100644 --- a/lib/pane_registry.py +++ b/lib/pane_registry.py @@ -132,7 +132,7 @@ def _get_providers_map(data: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: # Legacy flat format: derive providers on demand (no persistence here). out = {} - for p in ("codex", "gemini", "opencode", "claude"): + for p in ("codex", "gemini", "opencode", "claude", "droid", "copilot", "codebuddy", "qwen"): entry = _provider_entry_from_legacy(data, p) if entry: out[p] = entry @@ -327,7 +327,7 @@ def upsert_registry(record: Dict[str, Any]) -> bool: providers[p][k] = v # Migrate legacy flat fields into providers. - for p in ("codex", "gemini", "opencode", "claude"): + for p in ("codex", "gemini", "opencode", "claude", "droid", "copilot", "codebuddy", "qwen"): legacy_entry = _provider_entry_from_legacy(record, p) if legacy_entry: providers.setdefault(p, {}) diff --git a/lib/web/routes/providers.py b/lib/web/routes/providers.py index b6acc39a..95afab62 100644 --- a/lib/web/routes/providers.py +++ b/lib/web/routes/providers.py @@ -28,7 +28,7 @@ class PingResult(BaseModel): error: Optional[str] = None -KNOWN_PROVIDERS = ["claude", "codex", "gemini", "opencode", "droid"] +KNOWN_PROVIDERS = ["claude", "codex", "gemini", "opencode", "droid", "copilot", "codebuddy", "qwen"] def check_provider_available(provider: str) -> ProviderStatus: diff --git a/mcp/ccb-delegation/server.py b/mcp/ccb-delegation/server.py index e14ed45e..230dbac4 100644 --- a/mcp/ccb-delegation/server.py +++ b/mcp/ccb-delegation/server.py @@ -33,6 +33,10 @@ "gemini": {"ask": "gask", "pend": "gpend", "ping": "gping"}, "claude": {"ask": "lask", "pend": "lpend", "ping": "lping"}, "opencode": {"ask": "oask", "pend": "opend", "ping": "oping"}, + "droid": {"ask": "dask", "pend": "dpend", "ping": "dping"}, + "copilot": {"ask": "hask", "pend": "hpend", "ping": "hping"}, + "codebuddy": {"ask": "bask", "pend": "bpend", "ping": "bping"}, + "qwen": {"ask": "qask", "pend": "qpend", "ping": "qping"}, } ALIAS_TOOLS = [ @@ -40,14 +44,26 @@ ("gask", "gemini", "ask"), ("lask", "claude", "ask"), ("oask", "opencode", "ask"), + ("dask", "droid", "ask"), + ("hask", "copilot", "ask"), + ("bask", "codebuddy", "ask"), + ("qask", "qwen", "ask"), ("cpend", "codex", "pend"), ("gpend", "gemini", "pend"), ("lpend", "claude", "pend"), ("opend", "opencode", "pend"), + ("dpend", "droid", "pend"), + ("hpend", "copilot", "pend"), + ("bpend", "codebuddy", "pend"), + ("qpend", "qwen", "pend"), ("cping", "codex", "ping"), ("gping", "gemini", "ping"), ("lping", "claude", "ping"), ("oping", "opencode", "ping"), + ("dping", "droid", "ping"), + ("hping", "copilot", "ping"), + ("bping", "codebuddy", "ping"), + ("qping", "qwen", "ping"), ] ALIAS_MAP = {name: (provider, kind) for name, provider, kind in ALIAS_TOOLS} @@ -105,7 +121,7 @@ def _ping_schema() -> dict[str, Any]: TOOL_DEFS = [] -for provider in ("codex", "gemini", "claude", "opencode"): +for provider in ("codex", "gemini", "claude", "opencode", "droid", "copilot", "codebuddy", "qwen"): TOOL_DEFS.append( { "name": f"ccb_ask_{provider}",