Skip to content

Commit 155e2b0

Browse files
committed
feat: add startup config for copilot, codebuddy, and qwen providers
The daemon adapters, protocol modules, CLI scripts (hask/bask/qask), and ask dispatcher were already merged but the ccb launcher was missing: - _ALLOWED_PROVIDERS whitelist - Provider validation (CCB_CALLER) - Unified askd daemon startup loop - Daemon spec mapping - Tmux/WezTerm pane creation dispatch - Warmup/ping routing - Start command generation - Session file writing - Claude env overrides for inter-provider communication - Help text listing - Legacy session migration Adds generic _start_generic_tmux(), _build_generic_start_cmd(), and _write_generic_session() methods to avoid duplicating the established pattern for each new pane-log provider. All 268 tests pass. Closes SeemSeam#59 (follow-up) Ref SeemSeam#122
1 parent 5077c6c commit 155e2b0

2 files changed

Lines changed: 152 additions & 16 deletions

File tree

ccb

Lines changed: 151 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ from session_utils import (
4040
)
4141
from pane_registry import upsert_registry, load_registry_by_project_id
4242
from project_id import compute_ccb_project_id
43-
from providers import CASK_CLIENT_SPEC, GASK_CLIENT_SPEC, OASK_CLIENT_SPEC, LASK_CLIENT_SPEC, DASK_CLIENT_SPEC
43+
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
4444
from process_lock import ProviderLock
4545
from askd_rpc import shutdown_daemon, read_state
4646
from askd_runtime import state_file_path
@@ -608,7 +608,7 @@ class AILauncher:
608608
"""Managed env + explicit caller marker for the pane/provider process."""
609609
env = self._managed_env_overrides()
610610
prov = (provider or "").strip().lower()
611-
if prov in {"claude", "codex", "gemini", "opencode", "droid", "email", "manual"}:
611+
if prov in {"claude", "codex", "gemini", "opencode", "droid", "copilot", "codebuddy", "qwen", "email", "manual"}:
612612
env["CCB_CALLER"] = prov
613613
return env
614614

@@ -631,7 +631,7 @@ class AILauncher:
631631
if not cfg.is_dir():
632632
return
633633

634-
for name in (".codex-session", ".gemini-session", ".opencode-session", ".claude-session", ".droid-session"):
634+
for name in (".codex-session", ".gemini-session", ".opencode-session", ".claude-session", ".droid-session", ".copilot-session", ".codebuddy-session", ".qwen-session"):
635635
legacy = self.project_root / name
636636
if not legacy.exists():
637637
continue
@@ -760,7 +760,7 @@ class AILauncher:
760760
def _maybe_start_unified_askd(self, *, quiet: bool = False) -> None:
761761
"""Start unified askd daemon (provider-agnostic)."""
762762
# Try to start for any enabled provider that uses askd (including claude)
763-
for provider in ["codex", "gemini", "opencode", "droid", "claude"]:
763+
for provider in ["codex", "gemini", "opencode", "droid", "claude", "copilot", "codebuddy", "qwen"]:
764764
if provider in [p.lower() for p in self.providers]:
765765
# Try to start and check if successful
766766
self._maybe_start_provider_daemon(provider, quiet=quiet)
@@ -799,6 +799,9 @@ class AILauncher:
799799
"opencode": OASK_CLIENT_SPEC,
800800
"claude": LASK_CLIENT_SPEC,
801801
"droid": DASK_CLIENT_SPEC,
802+
"copilot": HASK_CLIENT_SPEC,
803+
"codebuddy": BASK_CLIENT_SPEC,
804+
"qwen": QASK_CLIENT_SPEC,
802805
}
803806
spec = specs.get(provider)
804807
if not spec:
@@ -1227,6 +1230,8 @@ class AILauncher:
12271230
return self._start_opencode_tmux(parent_pane=parent_pane, direction=direction)
12281231
elif provider == "droid":
12291232
return self._start_droid_tmux(parent_pane=parent_pane, direction=direction)
1233+
elif provider in ("copilot", "codebuddy", "qwen"):
1234+
return self._start_generic_tmux(provider, parent_pane=parent_pane, direction=direction)
12301235
else:
12311236
print(f"❌ {t('unknown_provider', provider=provider)}")
12321237
return None
@@ -1280,6 +1285,8 @@ class AILauncher:
12801285
self._write_opencode_session(runtime, None, pane_id=pane_id, pane_title_marker=pane_title_marker, start_cmd=start_cmd)
12811286
elif provider == "droid":
12821287
self._write_droid_session(runtime, None, pane_id=pane_id, pane_title_marker=pane_title_marker, start_cmd=start_cmd)
1288+
elif provider in ("copilot", "codebuddy", "qwen"):
1289+
self._write_generic_session(provider, runtime, None, pane_id=pane_id, pane_title_marker=pane_title_marker, start_cmd=start_cmd)
12831290
else:
12841291
print(f"❌ {t('unknown_provider', provider=provider)}")
12851292
return None
@@ -1851,16 +1858,19 @@ class AILauncher:
18511858
def _warmup_provider(self, provider: str, timeout: float = 8.0) -> bool:
18521859
if provider == "gemini":
18531860
return True
1854-
if provider == "codex":
1855-
ping_script = self.script_dir / "bin" / "cping"
1856-
elif provider == "gemini":
1857-
ping_script = self.script_dir / "bin" / "gping"
1858-
elif provider == "opencode":
1859-
ping_script = self.script_dir / "bin" / "oping"
1860-
elif provider == "droid":
1861-
ping_script = self.script_dir / "bin" / "dping"
1862-
else:
1861+
ping_map = {
1862+
"codex": "cping",
1863+
"gemini": "gping",
1864+
"opencode": "oping",
1865+
"droid": "dping",
1866+
"copilot": "hping",
1867+
"codebuddy": "bping",
1868+
"qwen": "qping",
1869+
}
1870+
ping_name = ping_map.get(provider)
1871+
if not ping_name:
18631872
return False
1873+
ping_script = self.script_dir / "bin" / ping_name
18641874

18651875
if not ping_script.exists():
18661876
return False
@@ -1904,6 +1914,12 @@ class AILauncher:
19041914
return self._build_opencode_start_cmd()
19051915
elif provider == "droid":
19061916
return self._build_droid_start_cmd()
1917+
elif provider == "copilot":
1918+
return self._build_generic_start_cmd(provider, "gh copilot", "COPILOT_START_CMD")
1919+
elif provider == "codebuddy":
1920+
return self._build_generic_start_cmd(provider, "codebuddy", "CODEBUDDY_START_CMD")
1921+
elif provider == "qwen":
1922+
return self._build_generic_start_cmd(provider, "qwen", "QWEN_START_CMD")
19071923
return ""
19081924

19091925
def _opencode_resume_allowed(self) -> bool:
@@ -2347,6 +2363,113 @@ class AILauncher:
23472363
print(f"✅ {t('started_backend', provider='Droid', terminal='tmux pane', pane_id=pane_id)}")
23482364
return pane_id
23492365

2366+
def _start_generic_tmux(
2367+
self,
2368+
provider: str,
2369+
*,
2370+
parent_pane: str | None = None,
2371+
direction: str | None = None,
2372+
) -> str | None:
2373+
runtime = self.runtime_dir / provider
2374+
runtime.mkdir(parents=True, exist_ok=True)
2375+
2376+
env_overrides = self._provider_env_overrides(provider)
2377+
start_cmd = self._build_env_prefix(env_overrides) + _build_export_path_cmd(self.script_dir / "bin") + self._get_start_cmd(provider)
2378+
pane_title_marker = f"CCB-{provider.capitalize()}"
2379+
2380+
backend = TmuxBackend()
2381+
2382+
use_direction = (direction or ("right" if not self.tmux_panes else "bottom")).strip() or "right"
2383+
use_parent = parent_pane
2384+
if not use_parent:
2385+
try:
2386+
use_parent = backend.get_current_pane_id()
2387+
except Exception:
2388+
use_parent = None
2389+
if not use_parent and use_direction == "bottom":
2390+
try:
2391+
use_parent = next(reversed(self.tmux_panes.values()))
2392+
except StopIteration:
2393+
use_parent = None
2394+
2395+
try:
2396+
if use_parent and str(use_parent).startswith("%") and not backend.pane_exists(str(use_parent)):
2397+
use_parent = backend.get_current_pane_id()
2398+
except Exception:
2399+
use_parent = None
2400+
2401+
pane_id = backend.create_pane("", str(Path.cwd()), direction=use_direction, percent=50, parent_pane=use_parent)
2402+
backend.respawn_pane(pane_id, cmd=start_cmd, cwd=str(Path.cwd()), remain_on_exit=True)
2403+
backend.set_pane_title(pane_id, pane_title_marker)
2404+
backend.set_pane_user_option(pane_id, "@ccb_agent", provider.capitalize())
2405+
2406+
self.tmux_panes[provider] = pane_id
2407+
2408+
self._write_generic_session(
2409+
provider,
2410+
runtime,
2411+
None,
2412+
pane_id=pane_id,
2413+
pane_title_marker=pane_title_marker,
2414+
start_cmd=start_cmd,
2415+
)
2416+
2417+
print(f"✅ {t('started_backend', provider=provider.capitalize(), terminal='tmux pane', pane_id=pane_id)}")
2418+
return pane_id
2419+
2420+
def _build_generic_start_cmd(self, provider: str, default_cmd: str, env_var: str) -> str:
2421+
cmd = (os.environ.get(env_var) or default_cmd).strip() or default_cmd
2422+
return cmd
2423+
2424+
def _write_generic_session(self, provider, runtime, tmux_session, pane_id=None, pane_title_marker=None, start_cmd=None):
2425+
session_file = self._project_session_file(f".{provider}-session")
2426+
2427+
writable, reason, fix = check_session_writable(session_file)
2428+
if not writable:
2429+
print(f"❌ Cannot write {session_file.name}: {reason}", file=sys.stderr)
2430+
print(f"💡 Fix: {fix}", file=sys.stderr)
2431+
return False
2432+
2433+
data = {
2434+
"session_id": self.session_id,
2435+
"ccb_session_id": self.session_id,
2436+
"ccb_project_id": compute_ccb_project_id(self.project_root),
2437+
"runtime_dir": str(runtime),
2438+
"terminal": self.terminal_type,
2439+
"tmux_session": tmux_session,
2440+
"pane_id": pane_id,
2441+
"pane_title_marker": pane_title_marker,
2442+
"work_dir": str(self.project_root),
2443+
"work_dir_norm": _normalize_path_for_match(str(self.project_root)),
2444+
"start_dir": str(self.invocation_dir),
2445+
"active": True,
2446+
"started_at": time.strftime("%Y-%m-%d %H:%M:%S"),
2447+
"start_cmd": str(start_cmd) if start_cmd else None,
2448+
}
2449+
2450+
ok, err = safe_write_session(session_file, json.dumps(data, ensure_ascii=False, indent=2))
2451+
if not ok:
2452+
print(err, file=sys.stderr)
2453+
return False
2454+
try:
2455+
upsert_registry({
2456+
"ccb_session_id": self.session_id,
2457+
"ccb_project_id": compute_ccb_project_id(self.project_root),
2458+
"work_dir": str(self.project_root),
2459+
"terminal": self.terminal_type,
2460+
"providers": {
2461+
provider: {
2462+
"pane_id": pane_id,
2463+
"pane_title_marker": pane_title_marker,
2464+
"session_file": str(session_file),
2465+
}
2466+
},
2467+
})
2468+
except Exception:
2469+
pass
2470+
self._maybe_start_provider_daemon(provider)
2471+
return True
2472+
23502473
def _start_cmd_pane(
23512474
self,
23522475
*,
@@ -3068,6 +3191,19 @@ class AILauncher:
30683191
else:
30693192
env["DROID_TMUX_SESSION"] = pane_id
30703193

3194+
for extra in ("copilot", "codebuddy", "qwen"):
3195+
if extra in self.providers:
3196+
runtime = self.runtime_dir / extra
3197+
prefix = extra.upper()
3198+
env[f"{prefix}_SESSION_ID"] = self.session_id
3199+
env[f"{prefix}_RUNTIME_DIR"] = str(runtime)
3200+
env[f"{prefix}_TERMINAL"] = self.terminal_type or ""
3201+
pane_id = self._provider_pane_id(extra)
3202+
if self.terminal_type == "wezterm":
3203+
env[f"{prefix}_WEZTERM_PANE"] = pane_id
3204+
else:
3205+
env[f"{prefix}_TMUX_SESSION"] = pane_id
3206+
30713207
return env
30723208

30733209
def _build_claude_env(self) -> dict:
@@ -4847,7 +4983,7 @@ def main():
48474983
subparsers = parser.add_subparsers(dest="command", help="Subcommands")
48484984

48494985
kill_parser = subparsers.add_parser("kill", help="Terminate session or clean up zombies")
4850-
kill_parser.add_argument("providers", nargs="*", default=[], help="Backends to terminate (codex/gemini/opencode/claude/droid)")
4986+
kill_parser.add_argument("providers", nargs="*", default=[], help="Backends to terminate (codex/gemini/opencode/claude/droid/copilot/codebuddy/qwen)")
48514987
kill_parser.add_argument("-f", "--force", action="store_true", help="Clean up all zombie tmux sessions globally")
48524988
kill_parser.add_argument("-y", "--yes", action="store_true", help="Skip confirmation prompt (with -f)")
48534989

@@ -4885,7 +5021,7 @@ def main():
48855021
start_parser.add_argument(
48865022
"providers",
48875023
nargs="*",
4888-
help="Backends to start (space or comma separated): codex, gemini, opencode, claude, droid (add cmd for a shell pane)",
5024+
help="Backends to start (space or comma separated): codex, gemini, opencode, claude, droid, copilot, codebuddy, qwen (add cmd for a shell pane)",
48895025
)
48905026
start_parser.add_argument("-r", "--resume", "--restore", action="store_true", help="Resume context")
48915027
start_parser.add_argument("-a", "--auto", action="store_true", help="Full auto permission mode")

lib/ccb_start_config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ class StartConfig:
1919
path: Optional[Path] = None
2020

2121

22-
_ALLOWED_PROVIDERS = {"codex", "gemini", "opencode", "claude", "droid"}
22+
_ALLOWED_PROVIDERS = {"codex", "gemini", "opencode", "claude", "droid", "copilot", "codebuddy", "qwen"}
2323

2424

2525
def _parse_tokens(raw: str) -> list[str]:

0 commit comments

Comments
 (0)