From 601195855d881cadc39a50d03b679ee86ad9dd55 Mon Sep 17 00:00:00 2001 From: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:09:13 +0800 Subject: [PATCH 1/2] feat: add GitHub Copilot CLI as new provider Add `gh copilot` support as a new CCB provider with prefix `hask`, following the existing Droid provider pattern. Uses pane-log based communication since Copilot has no structured JSONL session logs. New files: - lib/haskd_session.py - session management - lib/haskd_protocol.py - CCB protocol helpers - lib/copilot_comm.py - pane-log reader and communicator - lib/askd/adapters/copilot.py - unified daemon adapter - bin/hask, bin/hpend, bin/hping - CLI scripts Modified: providers.py, askd, ask, install.sh, adapters/__init__.py, provider_stub.py Closes #118 --- bin/ask | 8 +- bin/askd | 6 +- bin/hask | 217 +++++++++++++++ bin/hpend | 110 ++++++++ bin/hping | 41 +++ install.sh | 3 + lib/askd/adapters/__init__.py | 1 + lib/askd/adapters/copilot.py | 241 ++++++++++++++++ lib/copilot_comm.py | 500 ++++++++++++++++++++++++++++++++++ lib/haskd_protocol.py | 96 +++++++ lib/haskd_session.py | 165 +++++++++++ lib/providers.py | 23 ++ test/stubs/provider_stub.py | 12 +- 13 files changed, 1418 insertions(+), 5 deletions(-) create mode 100755 bin/hask create mode 100755 bin/hpend create mode 100755 bin/hping create mode 100644 lib/askd/adapters/copilot.py create mode 100644 lib/copilot_comm.py create mode 100644 lib/haskd_protocol.py create mode 100644 lib/haskd_session.py diff --git a/bin/ask b/bin/ask index db805b0a..f56e8ef4 100755 --- a/bin/ask +++ b/bin/ask @@ -6,7 +6,7 @@ Usage: ask [options] Providers: - gemini, codex, opencode, droid, claude + gemini, codex, opencode, droid, claude, copilot Modes: Default (async): Background task with hook callback @@ -56,6 +56,7 @@ PROVIDER_DAEMONS = { "opencode": "oask", "droid": "dask", "claude": "lask", + "copilot": "hask", } CALLER_SESSION_FILES = { @@ -64,6 +65,7 @@ CALLER_SESSION_FILES = { "gemini": ".gemini-session", "opencode": ".opencode-session", "droid": ".droid-session", + "copilot": ".copilot-session", } CALLER_PANE_ENV_HINTS = { @@ -71,6 +73,7 @@ CALLER_PANE_ENV_HINTS = { "gemini": ("GEMINI_TMUX_SESSION", "GEMINI_WEZTERM_PANE"), "opencode": ("OPENCODE_TMUX_SESSION", "OPENCODE_WEZTERM_PANE"), "droid": ("DROID_TMUX_SESSION", "DROID_WEZTERM_PANE"), + "copilot": ("COPILOT_TMUX_SESSION", "COPILOT_WEZTERM_PANE"), } CALLER_ENV_HINTS = { @@ -78,6 +81,7 @@ CALLER_ENV_HINTS = { "gemini": ("GEMINI_SESSION_ID", "GEMINI_RUNTIME_DIR"), "opencode": ("OPENCODE_SESSION_ID", "OPENCODE_RUNTIME_DIR"), "droid": ("DROID_SESSION_ID", "DROID_RUNTIME_DIR"), + "copilot": ("COPILOT_SESSION_ID", "COPILOT_RUNTIME_DIR"), } VALID_CALLERS = set(CALLER_SESSION_FILES.keys()) | {"email", "manual"} @@ -471,7 +475,7 @@ def _usage() -> None: print("Usage: ask [options] ", file=sys.stderr) print("", file=sys.stderr) print("Providers:", file=sys.stderr) - print(" gemini, codex, opencode, droid, claude", file=sys.stderr) + print(" gemini, codex, opencode, droid, claude, copilot", file=sys.stderr) print("", file=sys.stderr) print("Options:", file=sys.stderr) print(" -h, --help Show this help message", file=sys.stderr) diff --git a/bin/askd b/bin/askd index f3dd0f73..a05f5f5d 100755 --- a/bin/askd +++ b/bin/askd @@ -2,7 +2,7 @@ """ askd - Unified Ask Daemon for all AI providers. -Single daemon process handling codex, gemini, opencode, droid, and claude. +Single daemon process handling codex, gemini, opencode, droid, claude, and copilot. """ from __future__ import annotations @@ -26,6 +26,7 @@ from askd.adapters.gemini import GeminiAdapter from askd.adapters.opencode import OpenCodeAdapter from askd.adapters.droid import DroidAdapter from askd.adapters.claude import ClaudeAdapter +from askd.adapters.copilot import CopilotAdapter def _parse_listen(value: str) -> tuple[str, int]: @@ -38,7 +39,7 @@ def _parse_listen(value: str) -> tuple[str, int]: return host or "127.0.0.1", int(port_s or "0") -ALL_PROVIDERS = ["codex", "gemini", "opencode", "droid", "claude"] +ALL_PROVIDERS = ["codex", "gemini", "opencode", "droid", "claude", "copilot"] ADAPTER_CLASSES = { "codex": CodexAdapter, @@ -46,6 +47,7 @@ ADAPTER_CLASSES = { "opencode": OpenCodeAdapter, "droid": DroidAdapter, "claude": ClaudeAdapter, + "copilot": CopilotAdapter, } diff --git a/bin/hask b/bin/hask new file mode 100755 index 00000000..388e0c5b --- /dev/null +++ b/bin/hask @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +""" +hask - Send message to Copilot and wait for reply (sync). + +Designed to be used with Claude Code's run_in_background=true. +If --output is provided, reply is written atomically to that file and stdout stays empty. +""" + +from __future__ import annotations + +import os +import sys +import time +from pathlib import Path + +script_dir = Path(__file__).resolve().parent +lib_dir = script_dir.parent / "lib" +sys.path.insert(0, str(lib_dir)) +from compat import read_stdin_text, setup_windows_encoding + +setup_windows_encoding() + +from cli_output import EXIT_ERROR, EXIT_NO_REPLY, EXIT_OK, atomic_write_text +from env_utils import env_bool +from askd_client import ( + state_file_from_env, + find_project_session_file, + resolve_work_dir_with_registry, + try_daemon_request, + maybe_start_daemon, + wait_for_daemon_ready, + check_background_mode, +) +from providers import HASK_CLIENT_SPEC + + +ASYNC_GUARDRAIL = """[CCB_ASYNC_SUBMITTED provider=copilot] +IMPORTANT: Task submitted to Copilot. You MUST: +1. Tell user "Copilot processing..." +2. END YOUR TURN IMMEDIATELY +3. Do NOT wait, poll, check status, or use any more tools +""" + + +def _daemon_startup_wait_s(timeout: float) -> float: + raw = (os.environ.get("CCB_HASKD_STARTUP_WAIT_S") or "").strip() + if raw: + try: + v = float(raw) + except Exception: + v = 0.0 + if v > 0: + return min(max(0.2, v), max(0.2, float(timeout))) + return min(8.0, max(1.0, float(timeout))) + + +def _daemon_retry_wait_s(timeout: float) -> float: + raw = (os.environ.get("CCB_HASKD_RETRY_WAIT_S") or "").strip() + if raw: + try: + v = float(raw) + except Exception: + v = 0.0 + if v > 0: + return min(1.0, max(0.05, v)) + return min(0.3, max(0.05, float(timeout) / 50.0)) + + +def _daemon_request_with_retries( + work_dir: Path, + message: str, + timeout: float, + quiet: bool, + output_path: Path | None, +) -> tuple[str, int] | None: + state_file = state_file_from_env(HASK_CLIENT_SPEC.state_file_env) + + result = try_daemon_request(HASK_CLIENT_SPEC, work_dir, message, timeout, quiet, state_file, output_path=output_path) + if result is not None: + return result + + if not env_bool(HASK_CLIENT_SPEC.enabled_env, True): + return None + if not find_project_session_file(work_dir, HASK_CLIENT_SPEC.session_filename): + return None + + if state_file and state_file.exists(): + try: + if not wait_for_daemon_ready(HASK_CLIENT_SPEC, 0.2, state_file): + try: + state_file.unlink() + except Exception: + pass + except Exception: + pass + + started = maybe_start_daemon(HASK_CLIENT_SPEC, work_dir) + if started: + wait_for_daemon_ready(HASK_CLIENT_SPEC, _daemon_startup_wait_s(timeout), state_file) + + wait_s = _daemon_retry_wait_s(timeout) + deadline = time.time() + min(3.0, max(0.2, float(timeout))) + while time.time() < deadline: + result = try_daemon_request(HASK_CLIENT_SPEC, work_dir, message, timeout, quiet, state_file, output_path=output_path) + if result is not None: + return result + time.sleep(wait_s) + + return None + + +def _usage() -> None: + print("Usage: hask [--sync] [--session-file FILE] [--timeout SECONDS] [--output FILE] ", file=sys.stderr) + + +def main(argv: list[str]) -> int: + if len(argv) <= 1 and sys.stdin.isatty(): + _usage() + return EXIT_ERROR + + output_path: Path | None = None + timeout: float | None = None + quiet = False + sync_mode = False + session_file: str | None = None + + parts: list[str] = [] + it = iter(argv[1:]) + for token in it: + if token in ("-h", "--help"): + _usage() + return EXIT_OK + if token in ("-q", "--quiet"): + quiet = True + continue + if token == "--sync": + sync_mode = True + continue + if token == "--session-file": + try: + session_file = next(it) + except StopIteration: + print("[ERROR] --session-file requires a file path", file=sys.stderr) + return EXIT_ERROR + continue + if token in ("-o", "--output"): + try: + output_path = Path(next(it)).expanduser() + except StopIteration: + print("[ERROR] --output requires a file path", file=sys.stderr) + return EXIT_ERROR + continue + if token in ("-t", "--timeout"): + try: + timeout = float(next(it)) + except StopIteration: + print("[ERROR] --timeout requires a number", file=sys.stderr) + return EXIT_ERROR + except ValueError: + print("[ERROR] --timeout must be a number", file=sys.stderr) + return EXIT_ERROR + continue + parts.append(token) + + message = " ".join(parts).strip() + if not message and not sys.stdin.isatty(): + message = read_stdin_text().strip() + if not message: + print("[ERROR] Message cannot be empty", file=sys.stderr) + return EXIT_ERROR + + if timeout is None: + try: + timeout = float(os.environ.get("CCB_SYNC_TIMEOUT", "3600.0")) + except Exception: + timeout = 3600.0 + + try: + work_dir, _ = resolve_work_dir_with_registry( + HASK_CLIENT_SPEC, + provider="copilot", + cli_session_file=session_file, + env_session_file=os.environ.get("CCB_SESSION_FILE"), + ) + + daemon_result = _daemon_request_with_retries(work_dir, message, timeout, quiet, output_path) + if daemon_result is not None: + reply, exit_code = daemon_result + if not sync_mode: + print(ASYNC_GUARDRAIL, file=sys.stderr, flush=True) + if output_path: + atomic_write_text(output_path, reply + "\n") + return exit_code + sys.stdout.write(reply) + if not reply.endswith("\n"): + sys.stdout.write("\n") + return exit_code + + if not env_bool(HASK_CLIENT_SPEC.enabled_env, True): + print(f"[ERROR] {HASK_CLIENT_SPEC.enabled_env}=0: hask daemon mode disabled.", file=sys.stderr) + return EXIT_ERROR + if not find_project_session_file(work_dir, HASK_CLIENT_SPEC.session_filename): + print("[ERROR] No active Copilot session found for this directory.", file=sys.stderr) + print("Run `ccb copilot` (or add copilot to ccb.config) in this project first.", file=sys.stderr) + return EXIT_ERROR + print("[ERROR] hask daemon required but not available.", file=sys.stderr) + print("Start it with `askd` (or enable autostart via CCB_HASKD_AUTOSTART=1).", file=sys.stderr) + return EXIT_ERROR + except KeyboardInterrupt: + return 130 + except Exception as exc: + print(f"[ERROR] {exc}", file=sys.stderr) + return EXIT_ERROR + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/bin/hpend b/bin/hpend new file mode 100755 index 00000000..bbc578cb --- /dev/null +++ b/bin/hpend @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +""" +hpend - View latest Copilot reply +""" + +import os +import sys +from pathlib import Path +import argparse + +script_dir = Path(__file__).resolve().parent +lib_dir = script_dir.parent / "lib" +sys.path.insert(0, str(lib_dir)) +from compat import setup_windows_encoding + +setup_windows_encoding() + +from cli_output import EXIT_ERROR, EXIT_NO_REPLY, EXIT_OK +from copilot_comm import CopilotLogReader +from ccb_protocol import strip_trailing_markers +from askd_client import resolve_work_dir_with_registry +from providers import HASK_CLIENT_SPEC + + +def _debug_enabled() -> bool: + return (os.environ.get("CCB_DEBUG") in ("1", "true", "yes")) or (os.environ.get("HPEND_DEBUG") in ("1", "true", "yes")) + + +def _debug(message: str) -> None: + if not _debug_enabled(): + return + print(f"[DEBUG] {message}", file=sys.stderr) + + +def _resolve_pane_log(work_dir: Path) -> Path | None: + """Try to find the pane log path from the session file.""" + from session_utils import find_project_session_file + import json + + session_file = find_project_session_file(work_dir, ".copilot-session") + if not session_file: + return None + try: + with session_file.open("r", encoding="utf-8-sig") as f: + data = json.load(f) + # Try explicit pane_log_path first + raw = data.get("pane_log_path") + if raw: + return Path(str(raw)).expanduser() + # Fall back to runtime_dir / pane.log + runtime = data.get("runtime_dir") + if runtime: + return Path(str(runtime)) / "pane.log" + except Exception as exc: + _debug(f"Failed to read .copilot-session ({session_file}): {exc}") + return None + + +def main(argv: list[str]) -> int: + try: + parser = argparse.ArgumentParser(prog="hpend", add_help=True) + parser.add_argument("n", nargs="?", type=int, default=1, help="Show the latest N conversations") + parser.add_argument("--raw", action="store_true", help="Do not strip protocol/harness marker lines") + parser.add_argument("--session-file", dest="session_file", default=None, help="Path to .copilot-session (or .ccb/.copilot-session)") + args = parser.parse_args(argv[1:]) + + n = max(1, int(args.n or 1)) + raw = bool(args.raw) + + work_dir, _explicit_session_file = resolve_work_dir_with_registry( + HASK_CLIENT_SPEC, + provider="copilot", + cli_session_file=args.session_file, + env_session_file=os.environ.get("CCB_SESSION_FILE"), + ) + + pane_log = _resolve_pane_log(work_dir) + reader = CopilotLogReader(work_dir=work_dir, pane_log_path=pane_log) + + if n > 1: + conversations = reader.latest_conversations(n) + if not conversations: + print("No reply available", file=sys.stderr) + return EXIT_NO_REPLY + for i, (question, reply) in enumerate(conversations): + if question: + print(f"Q: {question}") + cleaned = reply if raw else strip_trailing_markers(reply or "") + print(f"A: {cleaned}") + if i < len(conversations) - 1: + print("---") + return EXIT_OK + + message = reader.latest_message() + if not message: + print("No reply available", file=sys.stderr) + return EXIT_NO_REPLY + print(message if raw else strip_trailing_markers(message)) + return EXIT_OK + except Exception as exc: + if _debug_enabled(): + import traceback + + traceback.print_exc() + print(f"[ERROR] execution failed: {exc}", file=sys.stderr) + return EXIT_ERROR + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/bin/hping b/bin/hping new file mode 100755 index 00000000..8258fbac --- /dev/null +++ b/bin/hping @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +""" +hping - Test Copilot connectivity +""" + +import sys +import os +import argparse +from pathlib import Path + +script_dir = Path(__file__).resolve().parent +lib_dir = script_dir.parent / "lib" +sys.path.insert(0, str(lib_dir)) +from compat import setup_windows_encoding + +setup_windows_encoding() + +try: + from copilot_comm import CopilotCommunicator + + def main(): + try: + parser = argparse.ArgumentParser(prog="hping", add_help=True) + parser.add_argument("--session-file", dest="session_file", default=None, help="Path to .copilot-session (or .ccb/.copilot-session)") + args = parser.parse_args() + if args.session_file: + os.environ["CCB_SESSION_FILE"] = str(args.session_file) + comm = CopilotCommunicator() + healthy, message = comm.ping(display=False) + print(message) + return 0 if healthy else 1 + except Exception as e: + print(f"[ERROR] Copilot connectivity test failed: {e}") + return 1 + + if __name__ == "__main__": + sys.exit(main()) + +except ImportError as e: + print(f"[ERROR] Module import failed: {e}") + sys.exit(1) diff --git a/install.sh b/install.sh index 56bc0cf6..2f071131 100755 --- a/install.sh +++ b/install.sh @@ -115,6 +115,9 @@ SCRIPTS_TO_LINK=( bin/dask bin/dpend bin/dping + bin/hask + bin/hpend + bin/hping bin/ask bin/ccb-ping bin/pend diff --git a/lib/askd/adapters/__init__.py b/lib/askd/adapters/__init__.py index 8eee3fba..f13dd7e9 100644 --- a/lib/askd/adapters/__init__.py +++ b/lib/askd/adapters/__init__.py @@ -13,4 +13,5 @@ "OpenCodeAdapter", "DroidAdapter", "ClaudeAdapter", + "CopilotAdapter", ] diff --git a/lib/askd/adapters/copilot.py b/lib/askd/adapters/copilot.py new file mode 100644 index 00000000..2692b794 --- /dev/null +++ b/lib/askd/adapters/copilot.py @@ -0,0 +1,241 @@ +""" +Copilot provider adapter for the unified ask daemon. + +Wraps existing haskd_* modules to provide a consistent interface. +""" +from __future__ import annotations + +import os +import time +from pathlib import Path +from typing import Any, Optional + +from askd.adapters.base import BaseProviderAdapter, ProviderRequest, ProviderResult, QueuedTask +from askd_runtime import log_path, write_log +from ccb_protocol import REQ_ID_PREFIX +from completion_hook import ( + COMPLETION_STATUS_CANCELLED, + COMPLETION_STATUS_COMPLETED, + COMPLETION_STATUS_FAILED, + COMPLETION_STATUS_INCOMPLETE, + default_reply_for_status, + notify_completion, +) +from haskd_protocol import extract_reply_for_req, is_done_text, wrap_copilot_prompt +from haskd_session import compute_session_key, load_project_session +from copilot_comm import CopilotLogReader +from providers import HASKD_SPEC +from terminal import get_backend_for_session + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _write_log(line: str) -> None: + write_log(log_path(HASKD_SPEC.log_file_name), line) + + +def _tail_state_for_log(log_path_val: Optional[Path], *, tail_bytes: int) -> dict: + if not log_path_val or not log_path_val.exists(): + return {"pane_log_path": log_path_val, "offset": 0} + try: + size = log_path_val.stat().st_size + except OSError: + size = 0 + offset = max(0, size - max(0, int(tail_bytes))) + return {"pane_log_path": log_path_val, "offset": offset} + + +class CopilotAdapter(BaseProviderAdapter): + """Adapter for Copilot provider.""" + + @property + def key(self) -> str: + return "copilot" + + @property + def spec(self): + return HASKD_SPEC + + @property + def session_filename(self) -> str: + return ".copilot-session" + + def load_session(self, work_dir: Path) -> Optional[Any]: + return load_project_session(work_dir) + + def compute_session_key(self, session: Any) -> str: + return compute_session_key(session) if session else "copilot:unknown" + + def handle_task(self, task: QueuedTask) -> ProviderResult: + started_ms = _now_ms() + req = task.request + work_dir = Path(req.work_dir) + _write_log(f"[INFO] start provider=copilot req_id={task.req_id} work_dir={req.work_dir}") + + session = load_project_session(work_dir) + session_key = self.compute_session_key(session) + + if not session: + return ProviderResult( + exit_code=1, + reply="No active Copilot session found for work_dir.", + req_id=task.req_id, + session_key=session_key, + done_seen=False, + status=COMPLETION_STATUS_FAILED, + ) + + ok, pane_or_err = session.ensure_pane() + if not ok: + return ProviderResult( + exit_code=1, + reply=f"Session pane not available: {pane_or_err}", + req_id=task.req_id, + session_key=session_key, + done_seen=False, + status=COMPLETION_STATUS_FAILED, + ) + pane_id = pane_or_err + + backend = get_backend_for_session(session.data) + if not backend: + return ProviderResult( + exit_code=1, + reply="Terminal backend not available", + req_id=task.req_id, + session_key=session_key, + done_seen=False, + status=COMPLETION_STATUS_FAILED, + ) + + # Copilot uses pane-log based communication (no JSONL session logs) + pane_log_path: Optional[Path] = None + raw_log = session.data.get("pane_log_path") + if raw_log: + pane_log_path = Path(str(raw_log)).expanduser() + elif session.runtime_dir: + pane_log_path = session.runtime_dir / "pane.log" + + log_reader = CopilotLogReader(work_dir=Path(session.work_dir), pane_log_path=pane_log_path) + state = log_reader.capture_state() + + prompt = wrap_copilot_prompt(req.message, task.req_id) + backend.send_text(pane_id, prompt) + + deadline = None if float(req.timeout_s) < 0.0 else (time.time() + float(req.timeout_s)) + chunks: list[str] = [] + anchor_seen = False + fallback_scan = False + anchor_ms: Optional[int] = None + done_seen = False + done_ms: Optional[int] = None + + anchor_grace_deadline = min(deadline, time.time() + 1.5) if deadline else (time.time() + 1.5) + anchor_collect_grace = min(deadline, time.time() + 2.0) if deadline else (time.time() + 2.0) + rebounded = False + tail_bytes = int(os.environ.get("CCB_HASKD_REBIND_TAIL_BYTES", str(2 * 1024 * 1024))) + pane_check_interval = float(os.environ.get("CCB_HASKD_PANE_CHECK_INTERVAL", "2.0")) + last_pane_check = time.time() + + while True: + # Check for cancellation + if task.cancel_event and task.cancel_event.is_set(): + _write_log(f"[INFO] Task cancelled during wait loop: req_id={task.req_id}") + break + + if deadline is not None: + remaining = deadline - time.time() + if remaining <= 0: + break + wait_step = min(remaining, 0.5) + else: + wait_step = 0.5 + + if time.time() - last_pane_check >= pane_check_interval: + try: + alive = bool(backend.is_alive(pane_id)) + except Exception: + alive = False + if not alive: + _write_log(f"[ERROR] Pane {pane_id} died during request req_id={task.req_id}") + return ProviderResult( + exit_code=1, + reply="Copilot pane died during request", + req_id=task.req_id, + session_key=session_key, + done_seen=False, + anchor_seen=anchor_seen, + fallback_scan=fallback_scan, + anchor_ms=anchor_ms, + status=COMPLETION_STATUS_FAILED, + ) + last_pane_check = time.time() + + events, state = log_reader.wait_for_events(state, wait_step) + if not events: + if (not rebounded) and (not anchor_seen) and time.time() >= anchor_grace_deadline: + log_reader = CopilotLogReader(work_dir=Path(session.work_dir), pane_log_path=pane_log_path) + state = _tail_state_for_log(pane_log_path, tail_bytes=tail_bytes) + fallback_scan = True + rebounded = True + continue + + for role, text in events: + if role == "user": + if f"{REQ_ID_PREFIX} {task.req_id}" in text: + anchor_seen = True + if anchor_ms is None: + anchor_ms = _now_ms() - started_ms + continue + if role != "assistant": + continue + if (not anchor_seen) and time.time() < anchor_collect_grace: + continue + chunks.append(text) + combined = "\n".join(chunks) + if is_done_text(combined, task.req_id): + done_seen = True + done_ms = _now_ms() - started_ms + break + + if done_seen: + break + + combined = "\n".join(chunks) + final_reply = extract_reply_for_req(combined, task.req_id) + status = COMPLETION_STATUS_COMPLETED if done_seen else COMPLETION_STATUS_INCOMPLETE + if task.cancelled: + status = COMPLETION_STATUS_CANCELLED + reply_for_hook = final_reply + if not reply_for_hook.strip(): + reply_for_hook = default_reply_for_status(status, done_seen=done_seen) + notify_completion( + provider="copilot", + output_file=req.output_path, + reply=reply_for_hook, + req_id=task.req_id, + done_seen=done_seen, + status=status, + caller=req.caller, + email_req_id=req.email_req_id, + email_msg_id=req.email_msg_id, + email_from=req.email_from, + work_dir=req.work_dir, + ) + + result = ProviderResult( + exit_code=0 if done_seen else 2, + reply=final_reply, + req_id=task.req_id, + session_key=session_key, + done_seen=done_seen, + done_ms=done_ms, + anchor_seen=anchor_seen, + anchor_ms=anchor_ms, + fallback_scan=fallback_scan, + status=status, + ) + _write_log(f"[INFO] done provider=copilot req_id={task.req_id} exit={result.exit_code}") + return result diff --git a/lib/copilot_comm.py b/lib/copilot_comm.py new file mode 100644 index 00000000..9b8cd1d4 --- /dev/null +++ b/lib/copilot_comm.py @@ -0,0 +1,500 @@ +""" +Copilot communication module. + +Reads replies from tmux pane-log files (raw terminal text) and sends prompts +by injecting text into the Copilot pane via the configured backend. + +Unlike Droid, Copilot does not produce structured JSONL session logs. Instead, +we read raw terminal output captured via `tmux pipe-pane`, strip ANSI escape +sequences, and look for CCB protocol markers in the plain text. +""" +from __future__ import annotations + +import json +import os +import re +import time +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from ccb_config import apply_backend_env +from pane_registry import upsert_registry +from project_id import compute_ccb_project_id +from session_utils import find_project_session_file, safe_write_session +from terminal import get_backend_for_session, get_pane_id_from_session + +apply_backend_env() + +# --------------------------------------------------------------------------- +# ANSI escape stripping +# --------------------------------------------------------------------------- + +_ANSI_ESCAPE_RE = re.compile( + r""" + \x1b # ESC + (?: # followed by one of … + \[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e] # CSI sequence + | \].*?(?:\x07|\x1b\\) # OSC sequence (terminated by BEL or ST) + | [\x40-\x5f] # Fe sequence (2-byte) + ) + """, + re.VERBOSE, +) + + +def _strip_ansi(text: str) -> str: + """Remove ANSI/VT escape sequences from raw terminal output.""" + return _ANSI_ESCAPE_RE.sub("", text) + + +# --------------------------------------------------------------------------- +# CCB marker patterns +# --------------------------------------------------------------------------- + +_CCB_REQ_ID_RE = re.compile(r"^\s*CCB_REQ_ID:\s*(\S+)\s*$", re.MULTILINE) +_CCB_DONE_RE = re.compile( + r"^\s*CCB_DONE:\s*(?:[0-9a-f]{32}|\d{8}-\d{6}-\d{3}-\d+-\d+)\s*$", + re.IGNORECASE | re.MULTILINE, +) + + +# --------------------------------------------------------------------------- +# CopilotLogReader — reads from tmux pipe-pane log files +# --------------------------------------------------------------------------- + + +class CopilotLogReader: + """Reads Copilot replies from tmux pane-log files (raw terminal text).""" + + def __init__(self, work_dir: Optional[Path] = None, pane_log_path: Optional[Path] = None): + self.work_dir = work_dir or Path.cwd() + self._pane_log_path: Optional[Path] = pane_log_path + try: + poll = float(os.environ.get("COPILOT_POLL_INTERVAL", "0.05")) + except Exception: + poll = 0.05 + self._poll_interval = min(0.5, max(0.02, poll)) + + def set_pane_log_path(self, path: Optional[Path]) -> None: + """Override the pane log path (e.g. from session file).""" + if path: + try: + candidate = path if isinstance(path, Path) else Path(str(path)).expanduser() + except Exception: + return + self._pane_log_path = candidate + + def _resolve_log_path(self) -> Optional[Path]: + """Return the pane log path, or None if unavailable.""" + if self._pane_log_path and self._pane_log_path.exists(): + return self._pane_log_path + return None + + # ---- public interface identical to DroidLogReader ---- + + def capture_state(self) -> Dict[str, Any]: + log_path = self._resolve_log_path() + offset = 0 + if log_path and log_path.exists(): + try: + offset = log_path.stat().st_size + except OSError: + offset = 0 + return {"pane_log_path": log_path, "offset": offset} + + def wait_for_message(self, state: Dict[str, Any], timeout: float) -> Tuple[Optional[str], Dict[str, Any]]: + return self._read_since(state, timeout=timeout, block=True) + + def try_get_message(self, state: Dict[str, Any]) -> Tuple[Optional[str], Dict[str, Any]]: + return self._read_since(state, timeout=0.0, block=False) + + def wait_for_events(self, state: Dict[str, Any], timeout: float) -> Tuple[List[Tuple[str, str]], Dict[str, Any]]: + return self._read_since_events(state, timeout=timeout, block=True) + + def try_get_events(self, state: Dict[str, Any]) -> Tuple[List[Tuple[str, str]], Dict[str, Any]]: + return self._read_since_events(state, timeout=0.0, block=False) + + def latest_message(self) -> Optional[str]: + """Scan the full pane log and return the last assistant content block.""" + log_path = self._resolve_log_path() + if not log_path or not log_path.exists(): + return None + try: + raw = log_path.read_text(encoding="utf-8", errors="replace") + except OSError: + return None + clean = _strip_ansi(raw) + blocks = self._extract_assistant_blocks(clean) + return blocks[-1] if blocks else None + + def latest_conversations(self, n: int = 1) -> List[Tuple[str, str]]: + """Return up to *n* recent (user_prompt, assistant_reply) pairs from the pane log.""" + log_path = self._resolve_log_path() + if not log_path or not log_path.exists(): + return [] + try: + raw = log_path.read_text(encoding="utf-8", errors="replace") + except OSError: + return [] + clean = _strip_ansi(raw) + pairs = self._extract_conversation_pairs(clean) + return pairs[-max(1, int(n)):] + + # ---- internal helpers ---- + + def _read_since(self, state: Dict[str, Any], timeout: float, block: bool) -> Tuple[Optional[str], Dict[str, Any]]: + deadline = time.time() + max(0.0, float(timeout)) if block else time.time() + current_state = dict(state or {}) + + while True: + log_path = self._resolve_log_path() + if log_path is None or not log_path.exists(): + if not block or time.time() >= deadline: + return None, current_state + time.sleep(self._poll_interval) + continue + + # If log path changed, reset offset + if current_state.get("pane_log_path") != log_path: + current_state["pane_log_path"] = log_path + current_state["offset"] = 0 + + message, current_state = self._read_new_content(log_path, current_state) + if message: + return message, current_state + + if not block or time.time() >= deadline: + return None, current_state + time.sleep(self._poll_interval) + + def _read_new_content(self, log_path: Path, state: Dict[str, Any]) -> Tuple[Optional[str], Dict[str, Any]]: + """Read new bytes from the pane log, strip ANSI, extract assistant replies.""" + offset = int(state.get("offset") or 0) + try: + size = log_path.stat().st_size + except OSError: + return None, state + + if size < offset: + # Log was truncated / rotated — reset + offset = 0 + + if size == offset: + return None, state + + try: + with log_path.open("rb") as handle: + handle.seek(offset) + data = handle.read() + except OSError: + return None, state + + new_offset = offset + len(data) + text = data.decode("utf-8", errors="replace") + clean = _strip_ansi(text) + + # Look for assistant content blocks in the new chunk + blocks = self._extract_assistant_blocks(clean) + latest = blocks[-1] if blocks else None + + new_state = {"pane_log_path": log_path, "offset": new_offset} + return latest, new_state + + def _read_since_events(self, state: Dict[str, Any], timeout: float, block: bool) -> Tuple[List[Tuple[str, str]], Dict[str, Any]]: + deadline = time.time() + max(0.0, float(timeout)) if block else time.time() + current_state = dict(state or {}) + + while True: + log_path = self._resolve_log_path() + if log_path is None or not log_path.exists(): + if not block or time.time() >= deadline: + return [], current_state + time.sleep(self._poll_interval) + continue + + if current_state.get("pane_log_path") != log_path: + current_state["pane_log_path"] = log_path + current_state["offset"] = 0 + + events, current_state = self._read_new_events(log_path, current_state) + if events: + return events, current_state + + if not block or time.time() >= deadline: + return [], current_state + time.sleep(self._poll_interval) + + def _read_new_events(self, log_path: Path, state: Dict[str, Any]) -> Tuple[List[Tuple[str, str]], Dict[str, Any]]: + offset = int(state.get("offset") or 0) + try: + size = log_path.stat().st_size + except OSError: + return [], state + + if size < offset: + offset = 0 + if size == offset: + return [], state + + try: + with log_path.open("rb") as handle: + handle.seek(offset) + data = handle.read() + except OSError: + return [], state + + new_offset = offset + len(data) + text = data.decode("utf-8", errors="replace") + clean = _strip_ansi(text) + + events: List[Tuple[str, str]] = [] + pairs = self._extract_conversation_pairs(clean) + for user_msg, assistant_msg in pairs: + if user_msg: + events.append(("user", user_msg)) + if assistant_msg: + events.append(("assistant", assistant_msg)) + + new_state = {"pane_log_path": log_path, "offset": new_offset} + return events, new_state + + @staticmethod + def _extract_assistant_blocks(text: str) -> List[str]: + """ + Extract assistant reply blocks from cleaned terminal text. + + A reply block is text between a CCB_REQ_ID marker and the corresponding + CCB_DONE marker. If no markers are found, fall back to returning non-empty + text chunks that look like assistant output. + """ + blocks: List[str] = [] + req_positions = [(m.end(), m.group(1)) for m in _CCB_REQ_ID_RE.finditer(text)] + done_positions = [m.start() for m in _CCB_DONE_RE.finditer(text)] + + if not req_positions and not done_positions: + # No CCB markers — treat the whole text as potential output + stripped = text.strip() + if stripped: + blocks.append(stripped) + return blocks + + for req_end, _req_id in req_positions: + # Find the next CCB_DONE after this REQ_ID + next_done = None + for dp in done_positions: + if dp > req_end: + next_done = dp + break + if next_done is not None: + segment = text[req_end:next_done].strip() + if segment: + blocks.append(segment) + else: + # No done marker yet — partial reply, take what we have + segment = text[req_end:].strip() + if segment: + blocks.append(segment) + + return blocks + + @staticmethod + def _extract_conversation_pairs(text: str) -> List[Tuple[str, str]]: + """ + Extract (user_prompt, assistant_reply) pairs from terminal text. + + User prompts are the text injected before CCB_REQ_ID markers. + Assistant replies are the text between CCB_REQ_ID and CCB_DONE. + """ + pairs: List[Tuple[str, str]] = [] + req_matches = list(_CCB_REQ_ID_RE.finditer(text)) + done_positions = [m.start() for m in _CCB_DONE_RE.finditer(text)] + + prev_end = 0 + for req_match in req_matches: + # User prompt is text before this REQ_ID line (from previous boundary) + user_text = text[prev_end:req_match.start()].strip() + req_end = req_match.end() + + # Find next CCB_DONE + next_done = None + for dp in done_positions: + if dp > req_end: + next_done = dp + break + + if next_done is not None: + assistant_text = text[req_end:next_done].strip() + prev_end = next_done + else: + assistant_text = text[req_end:].strip() + prev_end = len(text) + + pairs.append((user_text, assistant_text)) + + return pairs + + +# --------------------------------------------------------------------------- +# CopilotCommunicator — loads session, checks pane health +# --------------------------------------------------------------------------- + + +class CopilotCommunicator: + """Communicate with Copilot via terminal and read replies from pane logs.""" + + def __init__(self, lazy_init: bool = False): + self.session_info = self._load_session_info() + if not self.session_info: + raise RuntimeError( + "No active Copilot session found. " + "Run 'ccb copilot' (or add copilot to ccb.config) first" + ) + + self.session_id = str(self.session_info.get("session_id") or "").strip() + self.terminal = self.session_info.get("terminal", "tmux") + self.pane_id = get_pane_id_from_session(self.session_info) or "" + self.pane_title_marker = self.session_info.get("pane_title_marker") or "" + self.backend = get_backend_for_session(self.session_info) + self.timeout = int( + os.environ.get("COPILOT_SYNC_TIMEOUT", os.environ.get("CCB_SYNC_TIMEOUT", "3600")) + ) + self.marker_prefix = "hask" + self.project_session_file = self.session_info.get("_session_file") + + self._log_reader: Optional[CopilotLogReader] = None + self._log_reader_primed = False + + if self.terminal == "wezterm" and self.backend and self.pane_title_marker: + resolver = getattr(self.backend, "find_pane_by_title_marker", None) + if callable(resolver): + resolved = resolver(self.pane_title_marker) + if resolved: + self.pane_id = resolved + + self._publish_registry() + + if not lazy_init: + self._ensure_log_reader() + healthy, msg = self._check_session_health() + if not healthy: + raise RuntimeError( + f"Session unhealthy: {msg}\n" + "Hint: run ccb copilot (or add copilot to ccb.config) to start a new session" + ) + + @property + def log_reader(self) -> CopilotLogReader: + if self._log_reader is None: + self._ensure_log_reader() + return self._log_reader # type: ignore[return-value] + + def _ensure_log_reader(self) -> None: + if self._log_reader is not None: + return + work_dir_hint = self.session_info.get("work_dir") + log_work_dir = Path(work_dir_hint) if isinstance(work_dir_hint, str) and work_dir_hint else None + + # Derive pane log path from session info + pane_log_path: Optional[Path] = None + raw_log_path = self.session_info.get("pane_log_path") + if raw_log_path: + pane_log_path = Path(str(raw_log_path)).expanduser() + elif self.session_info.get("runtime_dir"): + # Convention: runtime_dir / pane.log + pane_log_path = Path(str(self.session_info["runtime_dir"])) / "pane.log" + + self._log_reader = CopilotLogReader(work_dir=log_work_dir, pane_log_path=pane_log_path) + self._log_reader_primed = True + + def _find_session_file(self) -> Optional[Path]: + env_session = (os.environ.get("CCB_SESSION_FILE") or "").strip() + if env_session: + try: + session_path = Path(os.path.expanduser(env_session)) + if session_path.name == ".copilot-session" and session_path.is_file(): + return session_path + except Exception: + pass + return find_project_session_file(Path.cwd(), ".copilot-session") + + def _load_session_info(self) -> Optional[dict]: + project_session = self._find_session_file() + if not project_session: + return None + try: + with project_session.open("r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict) or data.get("active", False) is False: + return None + data["_session_file"] = str(project_session) + return data + except Exception: + return None + + def _publish_registry(self) -> None: + try: + wd = self.session_info.get("work_dir") + ccb_pid = compute_ccb_project_id(Path(wd)) if isinstance(wd, str) and wd else "" + upsert_registry( + { + "ccb_session_id": self.session_id, + "ccb_project_id": ccb_pid or None, + "work_dir": wd, + "terminal": self.terminal, + "providers": { + "copilot": { + "pane_id": self.pane_id or None, + "pane_title_marker": self.session_info.get("pane_title_marker"), + "session_file": self.project_session_file, + } + }, + } + ) + except Exception: + pass + + def _check_session_health(self) -> Tuple[bool, str]: + return self._check_session_health_impl(probe_terminal=True) + + def _check_session_health_impl(self, probe_terminal: bool) -> Tuple[bool, str]: + try: + if not self.pane_id: + return False, "Session pane id not found" + if probe_terminal and self.backend: + pane_alive = self.backend.is_alive(self.pane_id) + if self.terminal == "wezterm" and self.pane_title_marker and not pane_alive: + resolver = getattr(self.backend, "find_pane_by_title_marker", None) + if callable(resolver): + resolved = resolver(self.pane_title_marker) + if resolved: + self.pane_id = resolved + pane_alive = self.backend.is_alive(self.pane_id) + if not pane_alive: + if self.terminal == "wezterm": + err = getattr(self.backend, "last_list_error", None) + if err: + return False, f"WezTerm CLI error: {err}" + return False, f"{self.terminal} session {self.pane_id} not found" + return True, "Session OK" + except Exception as exc: + return False, f"Check failed: {exc}" + + def ping(self, display: bool = True) -> Tuple[bool, str]: + healthy, status = self._check_session_health() + msg = ( + f"Copilot connection OK ({status})" if healthy + else f"Copilot connection error: {status}" + ) + if display: + print(msg) + return healthy, msg + + def get_status(self) -> Dict[str, Any]: + healthy, status = self._check_session_health() + return { + "session_id": self.session_id, + "terminal": self.terminal, + "pane_id": self.pane_id, + "healthy": healthy, + "status": status, + } diff --git a/lib/haskd_protocol.py b/lib/haskd_protocol.py new file mode 100644 index 00000000..993a30c7 --- /dev/null +++ b/lib/haskd_protocol.py @@ -0,0 +1,96 @@ +""" +Copilot protocol helpers. + +Wraps prompts with CCB markers and extracts replies — simplified version +without skills injection. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass + +from ccb_protocol import ( + DONE_PREFIX, + REQ_ID_PREFIX, + is_done_text, + make_req_id, + strip_done_text, +) + +ANY_DONE_LINE_RE = re.compile(r"^\s*CCB_DONE:\s*(?:[0-9a-f]{32}|\d{8}-\d{6}-\d{3}-\d+-\d+)\s*$", re.IGNORECASE) + + +def wrap_copilot_prompt(message: str, req_id: str) -> str: + """Wrap a prompt with CCB protocol markers for Copilot.""" + message = (message or "").rstrip() + return ( + f"{REQ_ID_PREFIX} {req_id}\n\n" + f"{message}\n\n" + "IMPORTANT:\n" + "- Reply with an execution summary, in English. Do not stay silent.\n" + "- End your reply with this exact final line (verbatim, on its own line):\n" + f"{DONE_PREFIX} {req_id}\n" + ) + + +def extract_reply_for_req(text: str, req_id: str) -> str: + """ + Extract the reply segment for req_id from a Copilot message. + + Copilot may emit multiple replies in a single assistant message, each ending with its own + `CCB_DONE: ` line. In that case, we want only the segment between the previous done line + (any req_id) and the done line for our req_id. + """ + lines = [ln.rstrip("\n") for ln in (text or "").splitlines()] + if not lines: + return "" + + target_re = re.compile(rf"^\s*CCB_DONE:\s*{re.escape(req_id)}\s*$", re.IGNORECASE) + done_idxs = [i for i, ln in enumerate(lines) if ANY_DONE_LINE_RE.match(ln or "")] + target_idxs = [i for i in done_idxs if target_re.match(lines[i] or "")] + + if not target_idxs: + # No CCB_DONE for our req_id found + # If there are other CCB_DONE markers, this is likely old content - return empty + if done_idxs: + return "" # Prevent returning old content + return strip_done_text(text, req_id) + + target_i = target_idxs[-1] + prev_done_i = -1 + for i in reversed(done_idxs): + if i < target_i: + prev_done_i = i + break + + segment = lines[prev_done_i + 1 : target_i] + while segment and segment[0].strip() == "": + segment = segment[1:] + while segment and segment[-1].strip() == "": + segment = segment[:-1] + return "\n".join(segment).rstrip() + + +@dataclass(frozen=True) +class HaskdRequest: + client_id: str + work_dir: str + timeout_s: float + quiet: bool + message: str + output_path: str | None = None + req_id: str | None = None + caller: str = "claude" + + +@dataclass(frozen=True) +class HaskdResult: + exit_code: int + reply: str + req_id: str + session_key: str + done_seen: bool + done_ms: int | None = None + anchor_seen: bool = False + fallback_scan: bool = False + anchor_ms: int | None = None diff --git a/lib/haskd_session.py b/lib/haskd_session.py new file mode 100644 index 00000000..bddb60af --- /dev/null +++ b/lib/haskd_session.py @@ -0,0 +1,165 @@ +""" +Copilot project session management. + +Simplified session binding for GitHub Copilot CLI — no JSONL session binding, +pane-log based communication only. +""" +from __future__ import annotations + +import json +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Optional, Tuple + +from ccb_config import apply_backend_env +from project_id import compute_ccb_project_id +from session_utils import find_project_session_file as _find_project_session_file, safe_write_session +from terminal import get_backend_for_session + +apply_backend_env() + + +def find_project_session_file(work_dir: Path) -> Optional[Path]: + return _find_project_session_file(work_dir, ".copilot-session") + + +def _read_json(path: Path) -> dict: + try: + raw = path.read_text(encoding="utf-8-sig") + obj = json.loads(raw) + return obj if isinstance(obj, dict) else {} + except Exception: + return {} + + +def _now_str() -> str: + return time.strftime("%Y-%m-%d %H:%M:%S") + + +@dataclass +class CopilotProjectSession: + session_file: Path + data: dict + + @property + def terminal(self) -> str: + return (self.data.get("terminal") or "tmux").strip() or "tmux" + + @property + def pane_id(self) -> str: + v = self.data.get("pane_id") + if not v and self.terminal == "tmux": + v = self.data.get("tmux_session") + return str(v or "").strip() + + @property + def pane_title_marker(self) -> str: + return str(self.data.get("pane_title_marker") or "").strip() + + @property + def work_dir(self) -> str: + return str(self.data.get("work_dir") or self.session_file.parent) + + @property + def runtime_dir(self) -> Path: + return Path(self.data.get("runtime_dir") or self.session_file.parent) + + @property + def start_cmd(self) -> str: + return str(self.data.get("start_cmd") or "").strip() + + def backend(self): + return get_backend_for_session(self.data) + + def _attach_pane_log(self, backend: object, pane_id: str) -> None: + ensure = getattr(backend, "ensure_pane_log", None) + if callable(ensure): + try: + ensure(str(pane_id)) + except Exception: + pass + + def ensure_pane(self) -> Tuple[bool, str]: + backend = self.backend() + if not backend: + return False, "Terminal backend not available" + + pane_id = self.pane_id + if pane_id and backend.is_alive(pane_id): + self._attach_pane_log(backend, pane_id) + return True, pane_id + + marker = self.pane_title_marker + resolver = getattr(backend, "find_pane_by_title_marker", None) + if marker and callable(resolver): + resolved = resolver(marker) + if resolved and backend.is_alive(str(resolved)): + self.data["pane_id"] = str(resolved) + self.data["updated_at"] = _now_str() + self._write_back() + self._attach_pane_log(backend, str(resolved)) + return True, str(resolved) + + if self.terminal == "tmux": + start_cmd = self.start_cmd + respawn = getattr(backend, "respawn_pane", None) + if start_cmd and callable(respawn): + last_err: str | None = None + target = pane_id + if marker and callable(resolver): + try: + target = resolver(marker) or target + except Exception: + pass + if target and str(target).startswith("%"): + try: + saver = getattr(backend, "save_crash_log", None) + if callable(saver): + try: + runtime = self.runtime_dir + runtime.mkdir(parents=True, exist_ok=True) + crash_log = runtime / f"pane-crash-{int(time.time())}.log" + saver(str(target), str(crash_log), lines=1000) + except Exception: + pass + respawn(str(target), cmd=start_cmd, cwd=self.work_dir, remain_on_exit=True) + if backend.is_alive(str(target)): + self.data["pane_id"] = str(target) + self.data["updated_at"] = _now_str() + self._write_back() + self._attach_pane_log(backend, str(target)) + return True, str(target) + last_err = "respawn did not revive pane" + except Exception as exc: + last_err = f"{exc}" + if last_err: + return False, f"Pane not alive and respawn failed: {last_err}" + + return False, f"Pane not alive: {pane_id}" + + def _write_back(self) -> None: + payload = json.dumps(self.data, ensure_ascii=False, indent=2) + "\n" + safe_write_session(self.session_file, payload) + + +def load_project_session(work_dir: Path) -> Optional[CopilotProjectSession]: + session_file = find_project_session_file(work_dir) + if not session_file: + return None + data = _read_json(session_file) + if not data: + return None + if data.get("active") is False: + return None + return CopilotProjectSession(session_file=session_file, data=data) + + +def compute_session_key(session: CopilotProjectSession) -> str: + pid = str(session.data.get("ccb_project_id") or "").strip() + if not pid: + try: + pid = compute_ccb_project_id(Path(session.work_dir)) + except Exception: + pid = "" + return f"copilot:{pid}" if pid else "copilot:unknown" diff --git a/lib/providers.py b/lib/providers.py index 5fec189f..fc188458 100644 --- a/lib/providers.py +++ b/lib/providers.py @@ -133,3 +133,26 @@ class ProviderClientSpec: daemon_bin_name="askd", daemon_module="askd.daemon", ) + + +# Copilot (GitHub Copilot CLI) +HASKD_SPEC = ProviderDaemonSpec( + daemon_key="haskd", + protocol_prefix="hask", + state_file_name="haskd.json", + log_file_name="haskd.log", + idle_timeout_env="CCB_HASKD_IDLE_TIMEOUT_S", + lock_name="haskd", +) + + +HASK_CLIENT_SPEC = ProviderClientSpec( + protocol_prefix="hask", + enabled_env="CCB_HASKD", + autostart_env_primary="CCB_HASKD_AUTOSTART", + autostart_env_legacy="CCB_AUTO_HASKD", + state_file_env="CCB_HASKD_STATE_FILE", + session_filename=".copilot-session", + daemon_bin_name="askd", + daemon_module="askd.daemon", +) diff --git a/test/stubs/provider_stub.py b/test/stubs/provider_stub.py index 428965a2..1026c455 100755 --- a/test/stubs/provider_stub.py +++ b/test/stubs/provider_stub.py @@ -254,7 +254,7 @@ def main(argv: list[str]) -> int: args, _unknown = parser.parse_known_args(argv[1:]) provider = (args.provider or Path(argv[0]).name).strip().lower() - if provider not in ("codex", "gemini", "claude", "opencode", "droid"): + if provider not in ("codex", "gemini", "claude", "opencode", "droid", "copilot"): print(f"[stub] unknown provider: {provider}", file=sys.stderr) return 2 @@ -268,6 +268,8 @@ def main(argv: list[str]) -> int: opencode_state: dict | None = None droid_session_path: Path | None = None droid_session_id = "" + copilot_session_path: Path | None = None + copilot_session_id = "" if provider == "gemini": gemini_session_path = _gemini_session_path() @@ -291,6 +293,10 @@ def main(argv: list[str]) -> int: droid_session_path = _droid_session_path() droid_session_id = (os.environ.get("DROID_SESSION_ID") or "").strip() or f"stub-{uuid.uuid4().hex}" _ensure_droid_session_start(droid_session_path, droid_session_id, os.getcwd()) + elif provider == "copilot": + copilot_session_path = _droid_session_path() # Reuse droid-style JSONL for stub + copilot_session_id = (os.environ.get("COPILOT_SESSION_ID") or "").strip() or f"stub-{uuid.uuid4().hex}" + _ensure_droid_session_start(copilot_session_path, copilot_session_id, os.getcwd()) def _handle_request(req_id: str, prompt: str) -> None: if provider == "codex": @@ -317,6 +323,10 @@ def _handle_request(req_id: str, prompt: str) -> None: assert droid_session_path is not None _handle_droid(req_id, prompt, delay_s, droid_session_path, droid_session_id) return + if provider == "copilot": + assert copilot_session_path is not None + _handle_droid(req_id, prompt, delay_s, copilot_session_path, copilot_session_id) + return def _signal_handler(_signum, _frame): raise SystemExit(0) From eebf079328b0207759f473c45de16eb509465438 Mon Sep 17 00:00:00 2001 From: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:28:17 +0800 Subject: [PATCH 2/2] fix: address code review findings for copilot provider - Remove unused import (safe_write_session) and attribute (marker_prefix) - Add emoji prefixes to error/success messages for consistency with Droid - Fix type: ignore by using assert for type narrowing - Isolate copilot stub session path to avoid data pollution with Droid --- lib/copilot_comm.py | 14 +++++++------- test/stubs/provider_stub.py | 8 +++++++- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/lib/copilot_comm.py b/lib/copilot_comm.py index 9b8cd1d4..d7cd7b5e 100644 --- a/lib/copilot_comm.py +++ b/lib/copilot_comm.py @@ -20,7 +20,7 @@ from ccb_config import apply_backend_env from pane_registry import upsert_registry from project_id import compute_ccb_project_id -from session_utils import find_project_session_file, safe_write_session +from session_utils import find_project_session_file from terminal import get_backend_for_session, get_pane_id_from_session apply_backend_env() @@ -346,7 +346,7 @@ def __init__(self, lazy_init: bool = False): self.session_info = self._load_session_info() if not self.session_info: raise RuntimeError( - "No active Copilot session found. " + "❌ No active Copilot session found. " "Run 'ccb copilot' (or add copilot to ccb.config) first" ) @@ -358,7 +358,6 @@ def __init__(self, lazy_init: bool = False): self.timeout = int( os.environ.get("COPILOT_SYNC_TIMEOUT", os.environ.get("CCB_SYNC_TIMEOUT", "3600")) ) - self.marker_prefix = "hask" self.project_session_file = self.session_info.get("_session_file") self._log_reader: Optional[CopilotLogReader] = None @@ -378,7 +377,7 @@ def __init__(self, lazy_init: bool = False): healthy, msg = self._check_session_health() if not healthy: raise RuntimeError( - f"Session unhealthy: {msg}\n" + f"❌ Session unhealthy: {msg}\n" "Hint: run ccb copilot (or add copilot to ccb.config) to start a new session" ) @@ -386,7 +385,8 @@ def __init__(self, lazy_init: bool = False): def log_reader(self) -> CopilotLogReader: if self._log_reader is None: self._ensure_log_reader() - return self._log_reader # type: ignore[return-value] + assert self._log_reader is not None + return self._log_reader def _ensure_log_reader(self) -> None: if self._log_reader is not None: @@ -482,8 +482,8 @@ def _check_session_health_impl(self, probe_terminal: bool) -> Tuple[bool, str]: def ping(self, display: bool = True) -> Tuple[bool, str]: healthy, status = self._check_session_health() msg = ( - f"Copilot connection OK ({status})" if healthy - else f"Copilot connection error: {status}" + f"✅ Copilot connection OK ({status})" if healthy + else f"❌ Copilot connection error: {status}" ) if display: print(msg) diff --git a/test/stubs/provider_stub.py b/test/stubs/provider_stub.py index 1026c455..0fe5efea 100755 --- a/test/stubs/provider_stub.py +++ b/test/stubs/provider_stub.py @@ -294,8 +294,14 @@ def main(argv: list[str]) -> int: droid_session_id = (os.environ.get("DROID_SESSION_ID") or "").strip() or f"stub-{uuid.uuid4().hex}" _ensure_droid_session_start(droid_session_path, droid_session_id, os.getcwd()) elif provider == "copilot": - copilot_session_path = _droid_session_path() # Reuse droid-style JSONL for stub copilot_session_id = (os.environ.get("COPILOT_SESSION_ID") or "").strip() or f"stub-{uuid.uuid4().hex}" + explicit = (os.environ.get("COPILOT_SESSION_PATH") or "").strip() + if explicit: + copilot_session_path = Path(explicit).expanduser() + else: + root = _droid_sessions_root() + slug = _droid_slug(Path.cwd()) + copilot_session_path = root / slug / f"copilot-{copilot_session_id}.jsonl" _ensure_droid_session_start(copilot_session_path, copilot_session_id, os.getcwd()) def _handle_request(req_id: str, prompt: str) -> None: