From f82b86f826f92bdd5dfc0dc596aaea28358d3906 Mon Sep 17 00:00:00 2001 From: PengMacStudio Date: Sun, 22 Feb 2026 16:49:21 +0800 Subject: [PATCH 1/2] fix: Gemini slug-based project hash and ccb-ping autostart support - Support Gemini CLI slug-based project directories with collision suffixes (e.g. claude-code-bridge-1) - Update ANY_DONE_LINE_RE in daskd/gaskd to match new req_id format (YYYYMMDD-HHMMSS-mmm-PID-counter) - Add --autostart flag to ccb-ping and ccb-mounted - Add tests for slug hash discovery and autostart functionality Co-Authored-By: Claude Opus 4.6 --- bin/ask | 105 +++++++++++++++++++++++++++++++ bin/ccb-mounted | 49 ++++++++++++++- bin/ccb-ping | 28 +++++++++ lib/daskd_protocol.py | 2 +- lib/gaskd_protocol.py | 4 +- lib/gemini_comm.py | 107 ++++++++++++++++++++++++++------ test/test_ask_cli.py | 37 +++++++++++ test/test_autostart_on_check.py | 69 ++++++++++++++++++++ test/test_gemini_comm.py | 56 +++++++++++++++++ 9 files changed, 431 insertions(+), 26 deletions(-) create mode 100644 test/test_ask_cli.py create mode 100644 test/test_autostart_on_check.py create mode 100644 test/test_gemini_comm.py 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..7686516 100755 --- a/bin/ccb-mounted +++ b/bin/ccb-mounted @@ -5,12 +5,39 @@ 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 "$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 "$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..5ee82c9 100755 --- a/bin/ccb-ping +++ b/bin/ccb-ping @@ -58,6 +58,7 @@ 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: @@ -68,6 +69,33 @@ def main(): 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, GASK_CLIENT_SPEC, OASK_CLIENT_SPEC, LASK_CLIENT_SPEC, DASK_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: + # Only try to start if it doesn't seem to be running or responsive + # Actually, maybe_start_daemon already checks for session file. + # We can use the logic from bin/ask: try first, then maybe start. + work_dir = Path.cwd() + 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"[DEBUG] Autostart 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..db7e37c --- /dev/null +++ b/test/test_autostart_on_check.py @@ -0,0 +1,69 @@ +import os +import subprocess +import sys +import time +import json +import pytest +from pathlib import Path + +# Add lib to sys.path +script_dir = Path(__file__).resolve().parent +lib_dir = script_dir.parent / "lib" +sys.path.insert(0, str(lib_dir)) + +def test_ccb_ping_autostart(tmp_path): + """Test that ccb-ping --autostart starts the daemon if session file exists.""" + project_dir = tmp_path / "project" + project_dir.mkdir() + + # Create a mock session file + session_file = project_dir / ".gemini-session" + # Provide enough fields to avoid GeminiCommunicator init errors + session_file.write_text(json.dumps({ + "active": True, + "work_dir": str(project_dir), + "session_id": "test-session-123", + "runtime_dir": str(tmp_path / "run"), + "pane_id": "1", + "terminal": "tmux" + })) + (tmp_path / "run").mkdir() + + # Run ccb-ping with --autostart + bin_dir = script_dir.parent / "bin" + ccb_ping = bin_dir / "ccb-ping" + + env = os.environ.copy() + env["CCB_GASKD_AUTOSTART"] = "1" + env["CCB_GASKD"] = "1" + # Use a mock CCB_RUN_DIR so we don't interfere with real sessions + env["CCB_RUN_DIR"] = str(tmp_path / "run") + + result = subprocess.run( + [sys.executable, str(ccb_ping), "gemini", "--autostart"], + cwd=str(project_dir), + capture_output=True, + text=True, + env=env + ) + + # Check that it didn't fail with "unrecognized argument" + assert "unrecognized arguments: --autostart" not in result.stderr + +def test_ccb_mounted_autostart(tmp_path): + """Test that ccb-mounted --autostart is recognized.""" + project_dir = tmp_path / "project" + project_dir.mkdir() + + bin_dir = script_dir.parent / "bin" + ccb_mounted = bin_dir / "ccb-mounted" + + result = subprocess.run( + ["bash", str(ccb_mounted), "--autostart", str(project_dir)], + capture_output=True, + text=True + ) + + # Should be valid JSON + data = json.loads(result.stdout) + assert "mounted" in data 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 From c38fcc523e91d32875ebfba0cf9a07109640e7ec Mon Sep 17 00:00:00 2001 From: PengMacStudio Date: Sun, 22 Feb 2026 16:55:38 +0800 Subject: [PATCH 2/2] fix: route autostart checks to target project work dir --- bin/ccb-mounted | 6 +- bin/ccb-ping | 41 ++++++--- test/test_autostart_on_check.py | 143 +++++++++++++++++++------------- 3 files changed, 117 insertions(+), 73 deletions(-) diff --git a/bin/ccb-mounted b/bin/ccb-mounted index 7686516..5156cce 100755 --- a/bin/ccb-mounted +++ b/bin/ccb-mounted @@ -1,6 +1,6 @@ #!/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 @@ -59,7 +59,7 @@ if [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]] || [[ -n "${WINDIR:- if [[ "$AUTOSTART" == true ]]; then PING_ARGS+=("--autostart") fi - if "$SCRIPT_DIR/ccb-ping" "${PING_ARGS[@]}" >/dev/null 2>&1; then + if (cd "$CWD" && "$SCRIPT_DIR/ccb-ping" "${PING_ARGS[@]}") >/dev/null 2>&1; then MOUNTED="$MOUNTED $provider" fi fi @@ -83,7 +83,7 @@ else if [[ "$IS_ONLINE" == false ]] && [[ "$AUTOSTART" == true ]]; then # Try to autostart if requested and not online - if "$SCRIPT_DIR/ccb-ping" "$provider" --autostart >/dev/null 2>&1; then + if (cd "$CWD" && "$SCRIPT_DIR/ccb-ping" "$provider" --autostart) >/dev/null 2>&1; then IS_ONLINE=true fi fi diff --git a/bin/ccb-ping b/bin/ccb-ping index 5ee82c9..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() @@ -63,6 +79,7 @@ def main(): 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] @@ -74,8 +91,14 @@ def main(): if args.autostart: try: from askd_client import maybe_start_daemon, wait_for_daemon_ready - from providers import CASK_CLIENT_SPEC, GASK_CLIENT_SPEC, OASK_CLIENT_SPEC, LASK_CLIENT_SPEC, DASK_CLIENT_SPEC - + 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, @@ -83,18 +106,14 @@ def main(): "claude": LASK_CLIENT_SPEC, "droid": DASK_CLIENT_SPEC, } - + spec = specs.get(provider) if spec: - # Only try to start if it doesn't seem to be running or responsive - # Actually, maybe_start_daemon already checks for session file. - # We can use the logic from bin/ask: try first, then maybe start. - work_dir = Path.cwd() 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"[DEBUG] Autostart failed: {e}", file=sys.stderr) + print(f"[WARN] Autostart pre-check failed: {e}", file=sys.stderr) comm = comm_class() healthy, message = comm.ping(display=False) diff --git a/test/test_autostart_on_check.py b/test/test_autostart_on_check.py index db7e37c..0c92cc7 100644 --- a/test/test_autostart_on_check.py +++ b/test/test_autostart_on_check.py @@ -1,69 +1,94 @@ +from __future__ import annotations + +import json import os import subprocess -import sys -import time -import json -import pytest from pathlib import Path -# Add lib to sys.path -script_dir = Path(__file__).resolve().parent -lib_dir = script_dir.parent / "lib" -sys.path.insert(0, str(lib_dir)) -def test_ccb_ping_autostart(tmp_path): - """Test that ccb-ping --autostart starts the daemon if session file exists.""" +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() - - # Create a mock session file - session_file = project_dir / ".gemini-session" - # Provide enough fields to avoid GeminiCommunicator init errors - session_file.write_text(json.dumps({ - "active": True, - "work_dir": str(project_dir), - "session_id": "test-session-123", - "runtime_dir": str(tmp_path / "run"), - "pane_id": "1", - "terminal": "tmux" - })) - (tmp_path / "run").mkdir() - - # Run ccb-ping with --autostart - bin_dir = script_dir.parent / "bin" - ccb_ping = bin_dir / "ccb-ping" - - env = os.environ.copy() - env["CCB_GASKD_AUTOSTART"] = "1" + other_dir.mkdir() + run_dir.mkdir() + session_file = _write_gemini_session(project_dir) + + env = dict(os.environ) env["CCB_GASKD"] = "1" - # Use a mock CCB_RUN_DIR so we don't interfere with real sessions - env["CCB_RUN_DIR"] = str(tmp_path / "run") - - result = subprocess.run( - [sys.executable, str(ccb_ping), "gemini", "--autostart"], - cwd=str(project_dir), - capture_output=True, - text=True, - env=env - ) - - # Check that it didn't fail with "unrecognized argument" - assert "unrecognized arguments: --autostart" not in result.stderr - -def test_ccb_mounted_autostart(tmp_path): - """Test that ccb-mounted --autostart is recognized.""" + 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() - - bin_dir = script_dir.parent / "bin" - ccb_mounted = bin_dir / "ccb-mounted" - - result = subprocess.run( - ["bash", str(ccb_mounted), "--autostart", str(project_dir)], - capture_output=True, - text=True - ) - - # Should be valid JSON - data = json.loads(result.stdout) - assert "mounted" in data + 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)