diff --git a/bin/ask b/bin/ask index fc67b5f..7e88909 100755 --- a/bin/ask +++ b/bin/ask @@ -22,9 +22,11 @@ Examples: from __future__ import annotations import os +import shutil import subprocess import sys import tempfile +import time from datetime import datetime from pathlib import Path @@ -112,6 +114,12 @@ def _send_via_unified_daemon( from askd_runtime import state_file_path import askd_rpc + ready_timeout = min(timeout, 2.0) if timeout and timeout > 0 else 2.0 + if not _ensure_unified_daemon_ready(timeout_s=ready_timeout): + print("[ERROR] Unified askd daemon not running", file=sys.stderr) + print("Start it with `askd` (or enable autostart via CCB_ASKD_AUTOSTART=1).", file=sys.stderr) + return EXIT_ERROR + # Use CCB_RUN_DIR (set by CCB startup) to locate the state file. # This already contains the correct project-specific path. state_file = state_file_path("askd.json") @@ -190,6 +198,96 @@ def _env_bool(name: str, default: bool = False) -> bool: return val not in ("0", "false", "no", "off") +def _is_pid_alive(pid: int) -> bool: + if pid <= 0: + return False + try: + os.kill(pid, 0) + return True + except OSError: + return False + except Exception: + return True + + +def _askd_start_argv() -> list[str] | None: + local = script_dir / "askd" + candidates: list[str] = [] + if local.exists(): + candidates.append(str(local)) + found = shutil.which("askd") + if found: + candidates.append(found) + if not candidates: + return None + + entry = candidates[0] + lower = entry.lower() + if lower.endswith((".cmd", ".bat", ".exe")): + return [entry] + return [sys.executable, entry] + + +def _ensure_unified_daemon_ready(timeout_s: float = 2.0) -> bool: + if not _use_unified_daemon(): + return True + + from askd_runtime import state_file_path + import askd_rpc + + state_file = state_file_path("askd.json") + try: + if askd_rpc.ping_daemon("ask", 0.2, state_file): + return True + except Exception: + pass + + if not _env_bool("CCB_ASKD_AUTOSTART", True): + return False + + argv = _askd_start_argv() + if not argv: + return False + + env = os.environ.copy() + parent_raw = (env.get("CCB_PARENT_PID") or "").strip() + if parent_raw: + try: + parent_pid = int(parent_raw) + except Exception: + parent_pid = 0 + if parent_pid <= 0 or not _is_pid_alive(parent_pid): + env.pop("CCB_PARENT_PID", None) + env.pop("CCB_MANAGED", None) + + kwargs = { + "stdin": subprocess.DEVNULL, + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + "close_fds": True, + "env": env, + } + if os.name == "nt": + kwargs["creationflags"] = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + else: + kwargs["start_new_session"] = True + + try: + subprocess.Popen(argv, **kwargs) + except Exception: + return False + + deadline = time.time() + max(0.2, float(timeout_s)) + while time.time() < deadline: + try: + if askd_rpc.ping_daemon("ask", 0.2, state_file): + return True + except Exception: + pass + time.sleep(0.1) + return False + + def _default_foreground() -> bool: # Allow explicit override if _env_bool("CCB_ASK_BACKGROUND", False): @@ -337,6 +435,13 @@ def main(argv: list[str]) -> int: return EXIT_ERROR # Default async mode: background task via nohup, using unified askd daemon + if _use_unified_daemon(): + ready_timeout = min(timeout, 2.0) if timeout and timeout > 0 else 2.0 + if not _ensure_unified_daemon_ready(timeout_s=ready_timeout): + print("[ERROR] Unified askd daemon not running", file=sys.stderr) + print("Start it with `askd` (or enable autostart via CCB_ASKD_AUTOSTART=1).", file=sys.stderr) + return EXIT_ERROR + task_id = make_task_id() log_dir = Path(tempfile.gettempdir()) / "ccb-tasks" log_dir.mkdir(parents=True, exist_ok=True) diff --git a/bin/ccb-mounted b/bin/ccb-mounted index 6175fea..5156cce 100755 --- a/bin/ccb-mounted +++ b/bin/ccb-mounted @@ -1,16 +1,43 @@ #!/usr/bin/env bash # ccb-mounted - Check which CCB providers are mounted -# Usage: ccb-mounted [--json|--simple] +# Usage: ccb-mounted [--json|--simple] [--autostart] [path] set -euo pipefail PROVIDERS="codex:cask gemini:gask opencode:oask claude:lask droid:dask" -CWD="${1:-$(pwd)}" -FORMAT="${2:---json}" +CWD=$(pwd) +FORMAT="--json" +AUTOSTART=false # Get script directory SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# Argument parsing +while [[ $# -gt 0 ]]; do + case "$1" in + --simple) + FORMAT="--simple" + shift + ;; + --json) + FORMAT="--json" + shift + ;; + --autostart) + AUTOSTART=true + shift + ;; + -*) + echo "Unknown option: $1" >&2 + exit 1 + ;; + *) + CWD="$1" + shift + ;; + esac +done + # Normalize CWD for Windows (convert /e/... to E:/...) if [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]]; then # Convert msys path /e/foo to E:/foo @@ -28,7 +55,11 @@ if [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]] || [[ -n "${WINDIR:- # Check session file exists first if [[ -f "$CWD/.ccb/.${provider}-session" ]] || [[ -f "$CWD/.ccb_config/.${provider}-session" ]] || [[ -f "$CWD/.${provider}-session" ]]; then # Try the ping command to check if daemon is responsive - if "$SCRIPT_DIR/ccb-ping" "$provider" >/dev/null 2>&1; then + PING_ARGS=("$provider") + if [[ "$AUTOSTART" == true ]]; then + PING_ARGS+=("--autostart") + fi + if (cd "$CWD" && "$SCRIPT_DIR/ccb-ping" "${PING_ARGS[@]}") >/dev/null 2>&1; then MOUNTED="$MOUNTED $provider" fi fi @@ -45,7 +76,19 @@ else # Check session file exists if [[ -f "$CWD/.ccb/.${provider}-session" ]] || [[ -f "$CWD/.ccb_config/.${provider}-session" ]] || [[ -f "$CWD/.${provider}-session" ]]; then # Check daemon is online + IS_ONLINE=false if echo "$ONLINE" | grep -q "${daemon}d"; then + IS_ONLINE=true + fi + + if [[ "$IS_ONLINE" == false ]] && [[ "$AUTOSTART" == true ]]; then + # Try to autostart if requested and not online + if (cd "$CWD" && "$SCRIPT_DIR/ccb-ping" "$provider" --autostart) >/dev/null 2>&1; then + IS_ONLINE=true + fi + fi + + if [[ "$IS_ONLINE" == true ]]; then MOUNTED="$MOUNTED $provider" fi fi diff --git a/bin/ccb-ping b/bin/ccb-ping index b9a90e8..535bf0a 100755 --- a/bin/ccb-ping +++ b/bin/ccb-ping @@ -3,7 +3,7 @@ ccb-ping - Test connectivity with AI providers. Usage: - ccb-ping [--session-file FILE] + ccb-ping [--session-file FILE] [--autostart] Providers: gemini, codex, opencode, droid, claude @@ -31,14 +31,30 @@ PROVIDER_COMMS = { "claude": ("claude_comm", "ClaudeCommunicator"), } - def _usage(): - print("Usage: ccb-ping [--session-file FILE]", file=sys.stderr) + print("Usage: ccb-ping [--session-file FILE] [--autostart]", file=sys.stderr) print("", file=sys.stderr) print("Providers:", file=sys.stderr) print(" gemini, codex, opencode, droid, claude", file=sys.stderr) +def _resolve_work_dir(session_file: str | None) -> Path: + if not session_file: + return Path.cwd() + raw = str(session_file).strip() + if not raw: + return Path.cwd() + try: + p = Path(os.path.expanduser(raw)).resolve() + except Exception: + p = Path(os.path.expanduser(raw)).absolute() + # Session files normally live in /.ccb/.-session + parent_name = p.parent.name + if parent_name in (".ccb", ".ccb_config"): + return p.parent.parent + return p.parent + + def main(): if len(sys.argv) < 2: _usage() @@ -58,16 +74,47 @@ def main(): # Parse remaining arguments parser = argparse.ArgumentParser(prog=f"ccb-ping {provider}", add_help=False) parser.add_argument("--session-file", dest="session_file", default=None) + parser.add_argument("--autostart", action="store_true", help="Start daemon if needed") args, _ = parser.parse_known_args(sys.argv[2:]) if args.session_file: os.environ["CCB_SESSION_FILE"] = str(args.session_file) + work_dir = _resolve_work_dir(args.session_file) # Import and instantiate the communicator module_name, class_name = PROVIDER_COMMS[provider] try: module = __import__(module_name) comm_class = getattr(module, class_name) + + # Pre-emptive autostart check if requested + if args.autostart: + try: + from askd_client import maybe_start_daemon, wait_for_daemon_ready + from providers import ( + CASK_CLIENT_SPEC, + DASK_CLIENT_SPEC, + GASK_CLIENT_SPEC, + LASK_CLIENT_SPEC, + OASK_CLIENT_SPEC, + ) + + specs = { + "codex": CASK_CLIENT_SPEC, + "gemini": GASK_CLIENT_SPEC, + "opencode": OASK_CLIENT_SPEC, + "claude": LASK_CLIENT_SPEC, + "droid": DASK_CLIENT_SPEC, + } + + spec = specs.get(provider) + if spec: + maybe_start_daemon(spec, work_dir) + # Small wait for it to bind + wait_for_daemon_ready(spec, 0.5) + except Exception as e: + print(f"[WARN] Autostart pre-check failed: {e}", file=sys.stderr) + comm = comm_class() healthy, message = comm.ping(display=False) print(message) diff --git a/lib/daskd_protocol.py b/lib/daskd_protocol.py index 9dfba71..57acefd 100644 --- a/lib/daskd_protocol.py +++ b/lib/daskd_protocol.py @@ -13,7 +13,7 @@ strip_done_text, ) -ANY_DONE_LINE_RE = re.compile(r"^\s*CCB_DONE:\s*\d{8}-\d{6}-\d{3}-\d+\s*$", re.IGNORECASE) +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) _SKILL_CACHE: str | None = None diff --git a/lib/gaskd_protocol.py b/lib/gaskd_protocol.py index c0a2bcf..fcf0522 100644 --- a/lib/gaskd_protocol.py +++ b/lib/gaskd_protocol.py @@ -11,8 +11,8 @@ strip_done_text, ) -# Match both old (32-char hex) and new (YYYYMMDD-HHMMSS-mmm-PID) req_id formats -ANY_DONE_LINE_RE = re.compile(r"^\s*CCB_DONE:\s*(?:[0-9a-f]{32}|\d{8}-\d{6}-\d{3}-\d+)\s*$", re.IGNORECASE) +# Match both old (32-char hex) and new (YYYYMMDD-HHMMSS-mmm-PID-counter) req_id formats +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_gemini_prompt(message: str, req_id: str) -> str: diff --git a/lib/gemini_comm.py b/lib/gemini_comm.py index 51baa19..bb00bc4 100755 --- a/lib/gemini_comm.py +++ b/lib/gemini_comm.py @@ -8,6 +8,7 @@ import hashlib import json import os +import re import sys import time import threading @@ -33,38 +34,101 @@ _GEMINI_HASH_CACHE_TS = 0.0 +def _slugify_project_hash(name: str) -> str: + """Return Gemini-compatible slug for a project directory name.""" + text = (name or "").strip().lower() + text = re.sub(r"[^a-z0-9]+", "-", text) + return text.strip("-") + + def _compute_project_hashes(work_dir: Optional[Path] = None) -> tuple[str, str]: - """Return ``(basename_hash, sha256_hash)`` for *work_dir*. + """Return ``(slug_hash, sha256_hash)`` for *work_dir*. - Gemini CLI >= 0.29.0 uses the directory basename; older versions used - a SHA-256 hash of the absolute path. We compute both so the caller - can try each one. + Gemini CLI >= 0.29.0 uses a slugified basename for project directories; + older versions used a SHA-256 hash of the absolute path. We compute both + so the caller can try each one. """ path = work_dir or Path.cwd() try: abs_path = path.expanduser().absolute() except Exception: abs_path = path - basename_hash = abs_path.name + basename_hash = _slugify_project_hash(abs_path.name) sha256_hash = hashlib.sha256(str(abs_path).encode()).hexdigest() return basename_hash, sha256_hash +def _project_hash_candidates(work_dir: Optional[Path] = None, *, root: Optional[Path] = None) -> list[str]: + """Return ordered project-hash candidates for this work directory. + + Supports Gemini's historical SHA-256 layout and modern slug-based layouts, + including collision-suffixed directories like ``name-1``. + """ + path = work_dir or Path.cwd() + try: + abs_path = path.expanduser().absolute() + except Exception: + abs_path = path + + raw_base = (abs_path.name or "").strip() + slug_base, sha256_hash = _compute_project_hashes(abs_path) + suffix_re = re.compile(rf"^{re.escape(slug_base)}-\d+$") if slug_base else None + + candidates: list[str] = [] + seen: set[str] = set() + + def _add(value: str) -> None: + token = (value or "").strip() + if not token or token in seen: + return + seen.add(token) + candidates.append(token) + + root_path = Path(root).expanduser() if root else None + discovered: list[tuple[float, str]] = [] + if root_path and root_path.is_dir() and slug_base: + try: + for child in root_path.iterdir(): + if not child.is_dir(): + continue + chats = child / "chats" + if not chats.is_dir(): + continue + name = child.name + if name == slug_base or name == raw_base or (suffix_re and suffix_re.match(name)): + try: + latest_mtime = max( + (p.stat().st_mtime for p in chats.glob("session-*.json") if p.is_file()), + default=chats.stat().st_mtime, + ) + except OSError: + latest_mtime = 0.0 + discovered.append((latest_mtime, name)) + except OSError: + pass + + for _mtime, name in sorted(discovered, key=lambda item: item[0], reverse=True): + _add(name) + _add(slug_base) + _add(raw_base) + _add(sha256_hash) + return candidates + + def _get_project_hash(work_dir: Optional[Path] = None) -> str: """Return the Gemini session directory name for *work_dir*. - Prefers the new basename format (Gemini CLI >= 0.29.0) when its - ``chats/`` directory exists, falls back to SHA-256 (older versions), - and defaults to basename for forward compatibility. + Prefers discovered slug-based directories (including collision suffixes), + falls back to SHA-256 (older versions), and defaults to slug basename for + forward compatibility. """ path = work_dir or Path.cwd() - basename_hash, sha256_hash = _compute_project_hashes(path) root = Path(os.environ.get("GEMINI_ROOT") or (Path.home() / ".gemini" / "tmp")).expanduser() - if (root / basename_hash / "chats").is_dir(): - return basename_hash - if (root / sha256_hash / "chats").is_dir(): - return sha256_hash - return basename_hash + candidates = _project_hash_candidates(path, root=root) + for project_hash in candidates: + if (root / project_hash / "chats").is_dir(): + return project_hash + return candidates[0] if candidates else "" def _iter_registry_work_dirs() -> list[Path]: @@ -100,9 +164,10 @@ def _work_dirs_for_hash(project_hash: str) -> list[Path]: _GEMINI_HASH_CACHE = {} for wd in _iter_registry_work_dirs(): try: - # Register both hash formats so the watchdog can match either - bn, sha = _compute_project_hashes(wd) - for h in (bn, sha): + # Register all known hash candidates so the watchdog can match + # slug, slug-suffixed, and legacy SHA-256 layouts. + hashes = _project_hash_candidates(wd, root=GEMINI_ROOT) + for h in hashes: _GEMINI_HASH_CACHE.setdefault(h, []).append(wd) except Exception: continue @@ -195,11 +260,13 @@ def __init__(self, root: Path = GEMINI_ROOT, work_dir: Optional[Path] = None): forced_hash = os.environ.get("GEMINI_PROJECT_HASH", "").strip() if forced_hash: self._project_hash = forced_hash + self._all_known_hashes = {forced_hash} else: self._project_hash = _get_project_hash(self.work_dir) - bn, sha = _compute_project_hashes(self.work_dir) - # Store all known hashes so they survive hash adoption - self._all_known_hashes = {bn, sha} + # Store all known hashes so they survive hash adoption and Gemini + # hash-format changes. + self._all_known_hashes = set(_project_hash_candidates(self.work_dir, root=self.root)) + self._all_known_hashes.add(self._project_hash) self._preferred_session: Optional[Path] = None try: poll = float(os.environ.get("GEMINI_POLL_INTERVAL", "0.05")) diff --git a/test/test_ask_cli.py b/test/test_ask_cli.py new file mode 100644 index 0000000..44bbdab --- /dev/null +++ b/test/test_ask_cli.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def _run_ask(args: list[str], *, cwd: Path, env: dict[str, str]) -> subprocess.CompletedProcess[str]: + exe = sys.executable + script_path = _repo_root() / "bin" / "ask" + return subprocess.run( + [exe, str(script_path), *args], + cwd=str(cwd), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + +def test_async_mode_fails_fast_when_unified_daemon_unavailable(tmp_path: Path) -> None: + env = dict(os.environ) + env["CCB_CALLER"] = "claude" + env["CCB_UNIFIED_ASKD"] = "1" + env["CCB_ASKD_AUTOSTART"] = "0" + env["CCB_RUN_DIR"] = str(tmp_path / "run") + + proc = _run_ask(["gemini", "hello"], cwd=tmp_path, env=env) + + assert proc.returncode == 1 + assert "Unified askd daemon not running" in proc.stderr + assert "[CCB_ASYNC_SUBMITTED" not in proc.stdout diff --git a/test/test_autostart_on_check.py b/test/test_autostart_on_check.py new file mode 100644 index 0000000..0c92cc7 --- /dev/null +++ b/test/test_autostart_on_check.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + + +SCRIPT_DIR = Path(__file__).resolve().parent +BIN_DIR = SCRIPT_DIR.parent / "bin" +ASKD_BIN = BIN_DIR / "askd" +CCB_PING_BIN = BIN_DIR / "ccb-ping" +CCB_MOUNTED_BIN = BIN_DIR / "ccb-mounted" + + +def _write_gemini_session(project_dir: Path) -> Path: + cfg_dir = project_dir / ".ccb" + cfg_dir.mkdir(parents=True, exist_ok=True) + session_file = cfg_dir / ".gemini-session" + payload = { + "active": True, + "work_dir": str(project_dir), + "runtime_dir": str(project_dir), + "session_id": "test-session", + "pane_id": "%1", + "terminal": "tmux", + } + session_file.write_text(json.dumps(payload, ensure_ascii=True) + "\n", encoding="utf-8") + return session_file + + +def _shutdown_askd(run_dir: Path) -> None: + env = dict(os.environ) + env["CCB_RUN_DIR"] = str(run_dir) + subprocess.run([str(ASKD_BIN), "--shutdown"], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def test_ccb_ping_autostart_uses_session_file_work_dir(tmp_path: Path) -> None: + project_dir = tmp_path / "project" + other_dir = tmp_path / "other" + run_dir = tmp_path / "run" + project_dir.mkdir() + other_dir.mkdir() + run_dir.mkdir() + session_file = _write_gemini_session(project_dir) + + env = dict(os.environ) + env["CCB_GASKD"] = "1" + env["CCB_GASKD_AUTOSTART"] = "1" + env["CCB_RUN_DIR"] = str(run_dir) + + try: + subprocess.run( + [str(CCB_PING_BIN), "gemini", "--session-file", str(session_file), "--autostart"], + cwd=str(other_dir), + env=env, + capture_output=True, + text=True, + ) + assert (run_dir / "askd.json").exists() + finally: + _shutdown_askd(run_dir) + + +def test_ccb_mounted_autostart_uses_target_path(tmp_path: Path) -> None: + project_dir = tmp_path / "project" + other_dir = tmp_path / "other" + run_dir = tmp_path / "run" + project_dir.mkdir() + other_dir.mkdir() + run_dir.mkdir() + _write_gemini_session(project_dir) + + env = dict(os.environ) + env["CCB_GASKD"] = "1" + env["CCB_GASKD_AUTOSTART"] = "1" + env["CCB_RUN_DIR"] = str(run_dir) + + try: + result = subprocess.run( + ["bash", str(CCB_MOUNTED_BIN), "--autostart", str(project_dir)], + cwd=str(other_dir), + env=env, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0 + parsed = json.loads(result.stdout) + assert parsed.get("cwd") == str(project_dir) + assert isinstance(parsed.get("mounted"), list) + assert (run_dir / "askd.json").exists() + finally: + _shutdown_askd(run_dir) diff --git a/test/test_gemini_comm.py b/test/test_gemini_comm.py new file mode 100644 index 0000000..21c1183 --- /dev/null +++ b/test/test_gemini_comm.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from ccb_protocol import is_done_text +from gemini_comm import GeminiLogReader + + +def _write_session(path: Path, *, messages: list[dict], session_id: str = "sid-1") -> None: + path.parent.mkdir(parents=True, exist_ok=True) + payload = {"sessionId": session_id, "messages": messages} + path.write_text(json.dumps(payload, ensure_ascii=True, indent=2) + "\n", encoding="utf-8") + + +def test_capture_state_finds_slugified_suffix_project_hash(tmp_path: Path) -> None: + work_dir = tmp_path / "claude_code_bridge" + work_dir.mkdir() + root = tmp_path / "gemini-root" + session_path = root / "claude-code-bridge-1" / "chats" / "session-a.json" + _write_session( + session_path, + messages=[ + {"type": "user", "content": "hello"}, + {"type": "gemini", "id": "g1", "content": "world"}, + ], + ) + + reader = GeminiLogReader(root=root, work_dir=work_dir) + state = reader.capture_state() + + assert state.get("session_path") == session_path + assert int(state.get("msg_count") or 0) == 2 + + +def test_wait_for_message_reads_reply_from_slugified_suffix_project_hash(tmp_path: Path) -> None: + req_id = "20260222-161452-539-76463-1" + work_dir = tmp_path / "claude_code_bridge" + work_dir.mkdir() + root = tmp_path / "gemini-root" + session_path = root / "claude-code-bridge-1" / "chats" / "session-b.json" + + messages = [{"type": "user", "content": f"CCB_REQ_ID: {req_id}\nquestion"}] + _write_session(session_path, messages=messages) + + reader = GeminiLogReader(root=root, work_dir=work_dir) + state = reader.capture_state() + + messages.append({"type": "gemini", "id": "g2", "content": f"ok\nCCB_DONE: {req_id}"}) + _write_session(session_path, messages=messages) + + reply, new_state = reader.wait_for_message(state, timeout=0.5) + + assert reply is not None + assert is_done_text(reply, req_id) + assert new_state.get("session_path") == session_path