From 80f26c7383e4e901b3add0fb91285f37754e1018 Mon Sep 17 00:00:00 2001 From: Carl Louie Ratcliffe Date: Thu, 2 Apr 2026 13:32:00 +0800 Subject: [PATCH 1/7] feat: add claude-opus and claude-sonnet as separate providers Enable running multiple Claude instances with different models in the same CCB session via `ccb codex claude-opus claude-sonnet`. Each variant gets its own pane, session file, daemon spec, and /ask routing. New providers: claude-opus (--model opus), claude-sonnet (--model sonnet) New binaries: loask, lsask, lopend, lspend New specs: LOASK_CLIENT_SPEC (prefix: loask), LSASK_CLIENT_SPEC (prefix: lsask) Co-Authored-By: Claude Sonnet 4.6 (1M context) Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/ask | 14 +- bin/ccb-completion-hook | 2 + bin/ccb-ping | 8 +- bin/loask | 188 ++++++++++++++++++++++++ bin/lopend | 253 +++++++++++++++++++++++++++++++++ bin/lsask | 188 ++++++++++++++++++++++++ bin/lspend | 253 +++++++++++++++++++++++++++++++++ bin/pend | 4 +- ccb | 163 ++++++++++++++------- lib/ccb_start_config.py | 2 +- lib/claude_session_resolver.py | 17 +-- lib/providers.py | 44 ++++++ 12 files changed, 1068 insertions(+), 68 deletions(-) create mode 100755 bin/loask create mode 100755 bin/lopend create mode 100755 bin/lsask create mode 100755 bin/lspend diff --git a/bin/ask b/bin/ask index 1557ce58..dd6913af 100755 --- a/bin/ask +++ b/bin/ask @@ -6,7 +6,7 @@ Usage: ask [options] Providers: - gemini, codex, opencode, droid, claude, copilot, codebuddy, qwen + gemini, codex, opencode, droid, claude, claude-opus, claude-sonnet, copilot, codebuddy, qwen Modes: Default (async): Background task with hook callback @@ -32,6 +32,8 @@ from pathlib import Path PROVIDER_DISPLAY = { "opencode": "OpenCode", + "claude-opus": "Claude-Opus", + "claude-sonnet": "Claude-Sonnet", } @@ -57,6 +59,8 @@ PROVIDER_DAEMONS = { "opencode": "oask", "droid": "dask", "claude": "lask", + "claude-opus": "loask", + "claude-sonnet": "lsask", "copilot": "hask", "codebuddy": "bask", "qwen": "qask", @@ -64,6 +68,8 @@ PROVIDER_DAEMONS = { CALLER_SESSION_FILES = { "claude": ".claude-session", + "claude-opus": ".claude-opus-session", + "claude-sonnet": ".claude-sonnet-session", "codex": ".codex-session", "gemini": ".gemini-session", "opencode": ".opencode-session", @@ -77,6 +83,8 @@ CALLER_PANE_ENV_HINTS = { "codex": ("CODEX_TMUX_SESSION", "CODEX_WEZTERM_PANE"), "gemini": ("GEMINI_TMUX_SESSION", "GEMINI_WEZTERM_PANE"), "opencode": ("OPENCODE_TMUX_SESSION", "OPENCODE_WEZTERM_PANE"), + "claude-opus": ("CLAUDE_OPUS_TMUX_SESSION", "CLAUDE_OPUS_WEZTERM_PANE"), + "claude-sonnet": ("CLAUDE_SONNET_TMUX_SESSION", "CLAUDE_SONNET_WEZTERM_PANE"), "droid": ("DROID_TMUX_SESSION", "DROID_WEZTERM_PANE"), "copilot": ("COPILOT_TMUX_SESSION", "COPILOT_WEZTERM_PANE"), "codebuddy": ("CODEBUDDY_TMUX_SESSION", "CODEBUDDY_WEZTERM_PANE"), @@ -87,6 +95,8 @@ CALLER_ENV_HINTS = { "codex": ("CODEX_SESSION_ID", "CODEX_RUNTIME_DIR"), "gemini": ("GEMINI_SESSION_ID", "GEMINI_RUNTIME_DIR"), "opencode": ("OPENCODE_SESSION_ID", "OPENCODE_RUNTIME_DIR"), + "claude-opus": ("CLAUDE_OPUS_SESSION_ID", "CLAUDE_OPUS_RUNTIME_DIR"), + "claude-sonnet": ("CLAUDE_SONNET_SESSION_ID", "CLAUDE_SONNET_RUNTIME_DIR"), "droid": ("DROID_SESSION_ID", "DROID_RUNTIME_DIR"), "copilot": ("COPILOT_SESSION_ID", "COPILOT_RUNTIME_DIR"), "codebuddy": ("CODEBUDDY_SESSION_ID", "CODEBUDDY_RUNTIME_DIR"), @@ -499,7 +509,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, copilot, codebuddy, qwen", file=sys.stderr) + print(" gemini, codex, opencode, droid, claude, claude-opus, claude-sonnet, copilot, codebuddy, qwen", 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/ccb-completion-hook b/bin/ccb-completion-hook index a5195f67..fd2284e1 100755 --- a/bin/ccb-completion-hook +++ b/bin/ccb-completion-hook @@ -559,6 +559,8 @@ def main() -> int: # Fallback: find caller's pane_id from session file session_files = { "claude": ".claude-session", + "claude-opus": ".claude-opus-session", + "claude-sonnet": ".claude-sonnet-session", "codex": ".codex-session", "gemini": ".gemini-session", "opencode": ".opencode-session", diff --git a/bin/ccb-ping b/bin/ccb-ping index 535bf0ae..84b255cd 100755 --- a/bin/ccb-ping +++ b/bin/ccb-ping @@ -29,13 +29,15 @@ PROVIDER_COMMS = { "opencode": ("opencode_comm", "OpenCodeCommunicator"), "droid": ("droid_comm", "DroidCommunicator"), "claude": ("claude_comm", "ClaudeCommunicator"), + "claude-opus": ("claude_comm", "ClaudeCommunicator"), + "claude-sonnet": ("claude_comm", "ClaudeCommunicator"), } def _usage(): 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) + print(" gemini, codex, opencode, droid, claude, claude-opus, claude-sonnet", file=sys.stderr) def _resolve_work_dir(session_file: str | None) -> Path: @@ -96,6 +98,8 @@ def main(): DASK_CLIENT_SPEC, GASK_CLIENT_SPEC, LASK_CLIENT_SPEC, + LOASK_CLIENT_SPEC, + LSASK_CLIENT_SPEC, OASK_CLIENT_SPEC, ) @@ -104,6 +108,8 @@ def main(): "gemini": GASK_CLIENT_SPEC, "opencode": OASK_CLIENT_SPEC, "claude": LASK_CLIENT_SPEC, + "claude-opus": LOASK_CLIENT_SPEC, + "claude-sonnet": LSASK_CLIENT_SPEC, "droid": DASK_CLIENT_SPEC, } diff --git a/bin/loask b/bin/loask new file mode 100755 index 00000000..25efbf80 --- /dev/null +++ b/bin/loask @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +loask - Send message to Claude-Opus and wait for reply (sync). + +Designed to be used with Codex/OpenCode in background mode. +If --output is provided, the reply is written atomically to that file and stdout stays empty. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Optional, Tuple + +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, 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, +) +from providers import LOASK_CLIENT_SPEC as LASK_CLIENT_SPEC +from claude_session_resolver import resolve_claude_session +from terminal import get_backend_for_session, get_pane_id_from_session +from laskd_protocol import wrap_claude_prompt, make_req_id + + +ASYNC_GUARDRAIL = """[CCB_ASYNC_SUBMITTED provider=claude-opus] +IMPORTANT: Task submitted to Claude-Opus. You MUST: +1. Tell user "Claude-Opus processing..." +2. END YOUR TURN IMMEDIATELY +3. Do NOT wait, poll, check status, or use any more tools +""" + + +def _usage() -> None: + print("Usage: loask [--async] [--sync] [--no-wrap] [--session-file FILE] [--timeout SECONDS] [--output FILE] ", file=sys.stderr) + + +def _parse_args(argv: list[str]) -> Tuple[Optional[Path], float, str, bool, Optional[str], bool, bool, bool]: + output: Optional[Path] = None + timeout: Optional[float] = None + quiet = False + async_mode = False + sync_mode = False + no_wrap = False + session_file: Optional[str] = None + parts: list[str] = [] + + it = iter(argv[1:]) + for token in it: + if token in ("-h", "--help"): + _usage() + raise SystemExit(0) + if token in ("-q", "--quiet"): + quiet = True + continue + if token == "--async": + async_mode = True + continue + if token == "--sync": + sync_mode = True + continue + if token == "--no-wrap": + no_wrap = True + continue + if token == "--session-file": + try: + session_file = next(it) + except StopIteration: + raise ValueError("--session-file requires a file path") + continue + if token in ("-o", "--output"): + try: + output = Path(next(it)).expanduser() + except StopIteration: + raise ValueError("--output requires a file path") + continue + if token in ("-t", "--timeout"): + try: + timeout = float(next(it)) + except StopIteration: + raise ValueError("--timeout requires a number") + except ValueError as exc: + raise ValueError(f"Invalid --timeout: {exc}") + continue + parts.append(token) + + message = " ".join(parts).strip() + if timeout is None: + try: + timeout = float(os.environ.get("CCB_SYNC_TIMEOUT", "-1")) + except Exception: + timeout = -1.0 + if async_mode: + timeout = 0.0 + return output, timeout, message, quiet, session_file, async_mode, sync_mode, no_wrap + + +def main(argv: list[str]) -> int: + try: + output_path, timeout, message, quiet, session_file, async_mode, sync_mode, no_wrap = _parse_args(argv) + if not message and not sys.stdin.isatty(): + message = read_stdin_text().strip() + if not message: + _usage() + return EXIT_ERROR + if async_mode: + sync_mode = False + + # Set no_wrap flag via environment variable + if no_wrap: + os.environ["CCB_NO_WRAP"] = "1" + + work_dir, _ = resolve_work_dir_with_registry( + LASK_CLIENT_SPEC, + provider="claude-opus", + cli_session_file=session_file, + env_session_file=os.environ.get("CCB_SESSION_FILE"), + ) + + state_file = state_file_from_env(LASK_CLIENT_SPEC.state_file_env) + daemon_result = try_daemon_request(LASK_CLIENT_SPEC, work_dir, message, timeout, quiet, state_file, output_path=output_path) + if daemon_result is None and maybe_start_daemon(LASK_CLIENT_SPEC, work_dir): + wait_for_daemon_ready(LASK_CLIENT_SPEC, timeout_s=min(2.0, max(0.2, float(timeout))), state_file=state_file) + daemon_result = try_daemon_request(LASK_CLIENT_SPEC, work_dir, message, timeout, quiet, state_file, output_path=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 + + # Fallback: send directly to Claude pane (works for both sync and async) + resolution = resolve_claude_session(work_dir, provider="claude-opus") + if resolution and resolution.data: + data = dict(resolution.data) + if data.get("claude_pane_id") and not data.get("pane_id"): + data["pane_id"] = data.get("claude_pane_id") + backend = get_backend_for_session(data) if data else None + pane_id = get_pane_id_from_session(data) if data else "" + if backend and pane_id: + # Apply context injection in fallback path (same as daemon path) + if not no_wrap: + req_id = os.environ.get("CCB_REQ_ID") or make_req_id() + message = wrap_claude_prompt(message, req_id) + backend.send_text(pane_id, message) + if not sync_mode: + print(ASYNC_GUARDRAIL, file=sys.stderr, flush=True) + return 0 + + if not env_bool(LASK_CLIENT_SPEC.enabled_env, True): + print(f"[ERROR] {LASK_CLIENT_SPEC.enabled_env}=0: loask daemon mode disabled.", file=sys.stderr) + return EXIT_ERROR + if not find_project_session_file(work_dir, LASK_CLIENT_SPEC.session_filename): + print("[ERROR] No active Claude session found for this directory.", file=sys.stderr) + print("Run `ccb claude` (or add claude to ccb.config) in this project first.", file=sys.stderr) + return EXIT_ERROR + print("[ERROR] loask daemon required but not available.", file=sys.stderr) + print("Start it with loaskd (or enable autostart via CCB_LOASKD_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__": + raise SystemExit(main(sys.argv)) diff --git a/bin/lopend b/bin/lopend new file mode 100755 index 00000000..a7634fc2 --- /dev/null +++ b/bin/lopend @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +""" +lopend - View latest Claude-Opus reply +""" + +import json +import os +import argparse +import sys +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() + +from cli_output import EXIT_ERROR, EXIT_NO_REPLY, EXIT_OK +from claude_comm import ClaudeLogReader +from ccb_protocol import strip_trailing_markers +from pane_registry import load_registry_by_session_id, load_registry_by_claude_pane, load_registry_by_project_id, upsert_registry +from session_utils import find_project_session_file +from askd_client import resolve_work_dir_with_registry +from providers import LOASK_CLIENT_SPEC as LASK_CLIENT_SPEC +from project_id import compute_ccb_project_id + + +def _debug_enabled() -> bool: + return (os.environ.get("CCB_DEBUG") in ("1", "true", "yes")) or (os.environ.get("LOPEND_DEBUG") in ("1", "true", "yes")) + + +def _debug(message: str) -> None: + if not _debug_enabled(): + return + print(f"[DEBUG] {message}", file=sys.stderr) + + +def _load_session_log_path(work_dir: Path, explicit_session_file: Path | None) -> tuple[Path | None, str | None]: + session_file = explicit_session_file or find_project_session_file(work_dir, ".claude-opus-session") + if not session_file: + return None, None + try: + with session_file.open("r", encoding="utf-8-sig", errors="replace") as f: + data = json.load(f) + path_str = data.get("claude_session_path") + session_id = data.get("claude_session_id") or data.get("session_id") + if path_str: + return Path(path_str).expanduser(), session_id + except Exception as exc: + _debug(f"Failed to read .claude-session ({session_file}): {exc}") + return None, None + + +def _record_project_id(record: dict) -> str: + pid = str(record.get("ccb_project_id") or "").strip() + if pid: + return pid + wd = record.get("work_dir") + if isinstance(wd, str) and wd.strip(): + try: + return compute_ccb_project_id(Path(wd.strip())) + except Exception: + return "" + return "" + + +def _load_registry_log_path(current_pid: str | None) -> tuple[Path | None, dict | None]: + session_id = (os.environ.get("CCB_SESSION_ID") or "").strip() + if session_id: + record = load_registry_by_session_id(session_id) + if record and current_pid: + record_pid = _record_project_id(record) + if record_pid != current_pid: + record = None + if record: + providers = record.get("providers") if isinstance(record.get("providers"), dict) else {} + claude = providers.get("claude-opus") if isinstance(providers, dict) else None + path_str = (claude or {}).get("claude_session_path") if isinstance(claude, dict) else record.get("claude_session_path") + if path_str: + path = Path(path_str).expanduser() + _debug(f"Using registry by CCB_SESSION_ID: {path}") + return path, record + + pane_id = (os.environ.get("WEZTERM_PANE") or os.environ.get("TMUX_PANE") or "").strip() + if pane_id: + record = load_registry_by_claude_pane(pane_id) + if record and current_pid: + record_pid = _record_project_id(record) + if record_pid != current_pid: + record = None + if record: + providers = record.get("providers") if isinstance(record.get("providers"), dict) else {} + claude = providers.get("claude-opus") if isinstance(providers, dict) else None + path_str = (claude or {}).get("claude_session_path") if isinstance(claude, dict) else record.get("claude_session_path") + if path_str: + path = Path(path_str).expanduser() + _debug(f"Using registry by pane id: {path}") + return path, record + return None, None + + +def _path_mtime_ns(path: Path | None) -> int: + if not path: + return -1 + try: + return int(path.stat().st_mtime_ns) + except Exception: + return -1 + + +def _pick_log_path(registry_log_path: Path | None, session_log_path: Path | None) -> tuple[Path | None, str]: + reg = registry_log_path if registry_log_path and registry_log_path.exists() else None + ses = session_log_path if session_log_path and session_log_path.exists() else None + + if reg and ses: + try: + if reg.resolve() == ses.resolve(): + _debug(f"Registry/session path identical: {reg}") + return reg, "same" + except Exception: + pass + + reg_mtime = _path_mtime_ns(reg) + ses_mtime = _path_mtime_ns(ses) + if ses_mtime >= reg_mtime: + _debug( + "Registry/session conflict detected; prefer .claude-session path " + f"(session newer/equal). registry={reg} session={ses}" + ) + return ses, "session" + _debug( + "Registry/session conflict detected; prefer registry path " + f"(registry newer). registry={reg} session={ses}" + ) + return reg, "registry" + + if ses: + _debug(f"Using claude_session_path from .claude-session: {ses}") + return ses, "session" + if reg: + _debug(f"Using claude_session_path from registry: {reg}") + return reg, "registry" + return None, "none" + + +def _sync_registry_claude_path(record: dict | None, path: Path | None) -> None: + if not record or not path: + return + providers = record.get("providers") + if not isinstance(providers, dict): + return + claude = providers.get("claude-opus") + if not isinstance(claude, dict): + return + new_path = str(path) + old_path = str(claude.get("claude_session_path") or "").strip() + if old_path == new_path: + return + try: + claude["claude_session_path"] = new_path + record["providers"] = providers + upsert_registry(record) + _debug(f"Registry claude_session_path refreshed: {new_path}") + except Exception as exc: + _debug(f"Failed to refresh registry claude_session_path: {exc}") + + +def _parse_n(argv: list[str]) -> int: + if len(argv) <= 1: + return 1 + try: + n = int(argv[1]) + except ValueError: + return 1 + return max(1, n) + + +def main(argv: list[str]) -> int: + try: + parser = argparse.ArgumentParser(prog="lopend", 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 .claude-session (or .ccb/.claude-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( + LASK_CLIENT_SPEC, + provider="claude-opus", + cli_session_file=args.session_file, + env_session_file=os.environ.get("CCB_SESSION_FILE"), + ) + + current_pid = "" + try: + current_pid = compute_ccb_project_id(work_dir) + except Exception: + current_pid = "" + + registry_log_path, registry_record = _load_registry_log_path(current_pid) + if not registry_log_path: + if current_pid: + rec = load_registry_by_project_id(current_pid, "claude-opus") + if rec: + providers = rec.get("providers") if isinstance(rec.get("providers"), dict) else {} + claude = providers.get("claude-opus") if isinstance(providers, dict) else None + path_str = (claude or {}).get("claude_session_path") if isinstance(claude, dict) else None + if isinstance(path_str, str) and path_str.strip(): + registry_log_path = Path(path_str.strip()).expanduser() + registry_record = rec + _debug(f"Using registry by ccb_project_id: {registry_log_path}") + + session_log_path, _session_id = _load_session_log_path(work_dir, explicit_session_file) + log_path, _path_source = _pick_log_path(registry_log_path, session_log_path) + if _path_source in ("session", "same"): + _sync_registry_claude_path(registry_record, log_path) + + reader = ClaudeLogReader(work_dir=work_dir) + if log_path: + reader.set_preferred_session(log_path) + + 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/lsask b/bin/lsask new file mode 100755 index 00000000..2121d559 --- /dev/null +++ b/bin/lsask @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +lsask - Send message to Claude-Sonnet and wait for reply (sync). + +Designed to be used with Codex/OpenCode in background mode. +If --output is provided, the reply is written atomically to that file and stdout stays empty. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Optional, Tuple + +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, 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, +) +from providers import LSASK_CLIENT_SPEC as LASK_CLIENT_SPEC +from claude_session_resolver import resolve_claude_session +from terminal import get_backend_for_session, get_pane_id_from_session +from laskd_protocol import wrap_claude_prompt, make_req_id + + +ASYNC_GUARDRAIL = """[CCB_ASYNC_SUBMITTED provider=claude-sonnet] +IMPORTANT: Task submitted to Claude-Sonnet. You MUST: +1. Tell user "Claude-Sonnet processing..." +2. END YOUR TURN IMMEDIATELY +3. Do NOT wait, poll, check status, or use any more tools +""" + + +def _usage() -> None: + print("Usage: lsask [--async] [--sync] [--no-wrap] [--session-file FILE] [--timeout SECONDS] [--output FILE] ", file=sys.stderr) + + +def _parse_args(argv: list[str]) -> Tuple[Optional[Path], float, str, bool, Optional[str], bool, bool, bool]: + output: Optional[Path] = None + timeout: Optional[float] = None + quiet = False + async_mode = False + sync_mode = False + no_wrap = False + session_file: Optional[str] = None + parts: list[str] = [] + + it = iter(argv[1:]) + for token in it: + if token in ("-h", "--help"): + _usage() + raise SystemExit(0) + if token in ("-q", "--quiet"): + quiet = True + continue + if token == "--async": + async_mode = True + continue + if token == "--sync": + sync_mode = True + continue + if token == "--no-wrap": + no_wrap = True + continue + if token == "--session-file": + try: + session_file = next(it) + except StopIteration: + raise ValueError("--session-file requires a file path") + continue + if token in ("-o", "--output"): + try: + output = Path(next(it)).expanduser() + except StopIteration: + raise ValueError("--output requires a file path") + continue + if token in ("-t", "--timeout"): + try: + timeout = float(next(it)) + except StopIteration: + raise ValueError("--timeout requires a number") + except ValueError as exc: + raise ValueError(f"Invalid --timeout: {exc}") + continue + parts.append(token) + + message = " ".join(parts).strip() + if timeout is None: + try: + timeout = float(os.environ.get("CCB_SYNC_TIMEOUT", "-1")) + except Exception: + timeout = -1.0 + if async_mode: + timeout = 0.0 + return output, timeout, message, quiet, session_file, async_mode, sync_mode, no_wrap + + +def main(argv: list[str]) -> int: + try: + output_path, timeout, message, quiet, session_file, async_mode, sync_mode, no_wrap = _parse_args(argv) + if not message and not sys.stdin.isatty(): + message = read_stdin_text().strip() + if not message: + _usage() + return EXIT_ERROR + if async_mode: + sync_mode = False + + # Set no_wrap flag via environment variable + if no_wrap: + os.environ["CCB_NO_WRAP"] = "1" + + work_dir, _ = resolve_work_dir_with_registry( + LASK_CLIENT_SPEC, + provider="claude-sonnet", + cli_session_file=session_file, + env_session_file=os.environ.get("CCB_SESSION_FILE"), + ) + + state_file = state_file_from_env(LASK_CLIENT_SPEC.state_file_env) + daemon_result = try_daemon_request(LASK_CLIENT_SPEC, work_dir, message, timeout, quiet, state_file, output_path=output_path) + if daemon_result is None and maybe_start_daemon(LASK_CLIENT_SPEC, work_dir): + wait_for_daemon_ready(LASK_CLIENT_SPEC, timeout_s=min(2.0, max(0.2, float(timeout))), state_file=state_file) + daemon_result = try_daemon_request(LASK_CLIENT_SPEC, work_dir, message, timeout, quiet, state_file, output_path=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 + + # Fallback: send directly to Claude pane (works for both sync and async) + resolution = resolve_claude_session(work_dir, provider="claude-sonnet") + if resolution and resolution.data: + data = dict(resolution.data) + if data.get("claude_pane_id") and not data.get("pane_id"): + data["pane_id"] = data.get("claude_pane_id") + backend = get_backend_for_session(data) if data else None + pane_id = get_pane_id_from_session(data) if data else "" + if backend and pane_id: + # Apply context injection in fallback path (same as daemon path) + if not no_wrap: + req_id = os.environ.get("CCB_REQ_ID") or make_req_id() + message = wrap_claude_prompt(message, req_id) + backend.send_text(pane_id, message) + if not sync_mode: + print(ASYNC_GUARDRAIL, file=sys.stderr, flush=True) + return 0 + + if not env_bool(LASK_CLIENT_SPEC.enabled_env, True): + print(f"[ERROR] {LASK_CLIENT_SPEC.enabled_env}=0: lsask daemon mode disabled.", file=sys.stderr) + return EXIT_ERROR + if not find_project_session_file(work_dir, LASK_CLIENT_SPEC.session_filename): + print("[ERROR] No active Claude session found for this directory.", file=sys.stderr) + print("Run `ccb claude` (or add claude to ccb.config) in this project first.", file=sys.stderr) + return EXIT_ERROR + print("[ERROR] lsask daemon required but not available.", file=sys.stderr) + print("Start it with lsaskd (or enable autostart via CCB_LSASKD_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__": + raise SystemExit(main(sys.argv)) diff --git a/bin/lspend b/bin/lspend new file mode 100755 index 00000000..2fe833cb --- /dev/null +++ b/bin/lspend @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +""" +lspend - View latest Claude-Sonnet reply +""" + +import json +import os +import argparse +import sys +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() + +from cli_output import EXIT_ERROR, EXIT_NO_REPLY, EXIT_OK +from claude_comm import ClaudeLogReader +from ccb_protocol import strip_trailing_markers +from pane_registry import load_registry_by_session_id, load_registry_by_claude_pane, load_registry_by_project_id, upsert_registry +from session_utils import find_project_session_file +from askd_client import resolve_work_dir_with_registry +from providers import LSASK_CLIENT_SPEC as LASK_CLIENT_SPEC +from project_id import compute_ccb_project_id + + +def _debug_enabled() -> bool: + return (os.environ.get("CCB_DEBUG") in ("1", "true", "yes")) or (os.environ.get("LSPEND_DEBUG") in ("1", "true", "yes")) + + +def _debug(message: str) -> None: + if not _debug_enabled(): + return + print(f"[DEBUG] {message}", file=sys.stderr) + + +def _load_session_log_path(work_dir: Path, explicit_session_file: Path | None) -> tuple[Path | None, str | None]: + session_file = explicit_session_file or find_project_session_file(work_dir, ".claude-sonnet-session") + if not session_file: + return None, None + try: + with session_file.open("r", encoding="utf-8-sig", errors="replace") as f: + data = json.load(f) + path_str = data.get("claude_session_path") + session_id = data.get("claude_session_id") or data.get("session_id") + if path_str: + return Path(path_str).expanduser(), session_id + except Exception as exc: + _debug(f"Failed to read .claude-session ({session_file}): {exc}") + return None, None + + +def _record_project_id(record: dict) -> str: + pid = str(record.get("ccb_project_id") or "").strip() + if pid: + return pid + wd = record.get("work_dir") + if isinstance(wd, str) and wd.strip(): + try: + return compute_ccb_project_id(Path(wd.strip())) + except Exception: + return "" + return "" + + +def _load_registry_log_path(current_pid: str | None) -> tuple[Path | None, dict | None]: + session_id = (os.environ.get("CCB_SESSION_ID") or "").strip() + if session_id: + record = load_registry_by_session_id(session_id) + if record and current_pid: + record_pid = _record_project_id(record) + if record_pid != current_pid: + record = None + if record: + providers = record.get("providers") if isinstance(record.get("providers"), dict) else {} + claude = providers.get("claude-sonnet") if isinstance(providers, dict) else None + path_str = (claude or {}).get("claude_session_path") if isinstance(claude, dict) else record.get("claude_session_path") + if path_str: + path = Path(path_str).expanduser() + _debug(f"Using registry by CCB_SESSION_ID: {path}") + return path, record + + pane_id = (os.environ.get("WEZTERM_PANE") or os.environ.get("TMUX_PANE") or "").strip() + if pane_id: + record = load_registry_by_claude_pane(pane_id) + if record and current_pid: + record_pid = _record_project_id(record) + if record_pid != current_pid: + record = None + if record: + providers = record.get("providers") if isinstance(record.get("providers"), dict) else {} + claude = providers.get("claude-sonnet") if isinstance(providers, dict) else None + path_str = (claude or {}).get("claude_session_path") if isinstance(claude, dict) else record.get("claude_session_path") + if path_str: + path = Path(path_str).expanduser() + _debug(f"Using registry by pane id: {path}") + return path, record + return None, None + + +def _path_mtime_ns(path: Path | None) -> int: + if not path: + return -1 + try: + return int(path.stat().st_mtime_ns) + except Exception: + return -1 + + +def _pick_log_path(registry_log_path: Path | None, session_log_path: Path | None) -> tuple[Path | None, str]: + reg = registry_log_path if registry_log_path and registry_log_path.exists() else None + ses = session_log_path if session_log_path and session_log_path.exists() else None + + if reg and ses: + try: + if reg.resolve() == ses.resolve(): + _debug(f"Registry/session path identical: {reg}") + return reg, "same" + except Exception: + pass + + reg_mtime = _path_mtime_ns(reg) + ses_mtime = _path_mtime_ns(ses) + if ses_mtime >= reg_mtime: + _debug( + "Registry/session conflict detected; prefer .claude-session path " + f"(session newer/equal). registry={reg} session={ses}" + ) + return ses, "session" + _debug( + "Registry/session conflict detected; prefer registry path " + f"(registry newer). registry={reg} session={ses}" + ) + return reg, "registry" + + if ses: + _debug(f"Using claude_session_path from .claude-session: {ses}") + return ses, "session" + if reg: + _debug(f"Using claude_session_path from registry: {reg}") + return reg, "registry" + return None, "none" + + +def _sync_registry_claude_path(record: dict | None, path: Path | None) -> None: + if not record or not path: + return + providers = record.get("providers") + if not isinstance(providers, dict): + return + claude = providers.get("claude-sonnet") + if not isinstance(claude, dict): + return + new_path = str(path) + old_path = str(claude.get("claude_session_path") or "").strip() + if old_path == new_path: + return + try: + claude["claude_session_path"] = new_path + record["providers"] = providers + upsert_registry(record) + _debug(f"Registry claude_session_path refreshed: {new_path}") + except Exception as exc: + _debug(f"Failed to refresh registry claude_session_path: {exc}") + + +def _parse_n(argv: list[str]) -> int: + if len(argv) <= 1: + return 1 + try: + n = int(argv[1]) + except ValueError: + return 1 + return max(1, n) + + +def main(argv: list[str]) -> int: + try: + parser = argparse.ArgumentParser(prog="lspend", 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 .claude-session (or .ccb/.claude-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( + LASK_CLIENT_SPEC, + provider="claude-sonnet", + cli_session_file=args.session_file, + env_session_file=os.environ.get("CCB_SESSION_FILE"), + ) + + current_pid = "" + try: + current_pid = compute_ccb_project_id(work_dir) + except Exception: + current_pid = "" + + registry_log_path, registry_record = _load_registry_log_path(current_pid) + if not registry_log_path: + if current_pid: + rec = load_registry_by_project_id(current_pid, "claude-sonnet") + if rec: + providers = rec.get("providers") if isinstance(rec.get("providers"), dict) else {} + claude = providers.get("claude-sonnet") if isinstance(providers, dict) else None + path_str = (claude or {}).get("claude_session_path") if isinstance(claude, dict) else None + if isinstance(path_str, str) and path_str.strip(): + registry_log_path = Path(path_str.strip()).expanduser() + registry_record = rec + _debug(f"Using registry by ccb_project_id: {registry_log_path}") + + session_log_path, _session_id = _load_session_log_path(work_dir, explicit_session_file) + log_path, _path_source = _pick_log_path(registry_log_path, session_log_path) + if _path_source in ("session", "same"): + _sync_registry_claude_path(registry_record, log_path) + + reader = ClaudeLogReader(work_dir=work_dir) + if log_path: + reader.set_preferred_session(log_path) + + 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/pend b/bin/pend index e4b81968..ff5515fb 100755 --- a/bin/pend +++ b/bin/pend @@ -27,6 +27,8 @@ PROVIDER_PENDS = { "opencode": "opend", "droid": "dpend", "claude": "lpend", + "claude-opus": "lopend", + "claude-sonnet": "lspend", } @@ -34,7 +36,7 @@ def _usage(): print("Usage: pend [N] [--session-file FILE]", 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, claude-opus, claude-sonnet", file=sys.stderr) print("", file=sys.stderr) print("Arguments:", file=sys.stderr) print(" N Show the latest N conversations (default: 1)", file=sys.stderr) diff --git a/ccb b/ccb index eddd1fbb..ef74834d 100755 --- a/ccb +++ b/ccb @@ -40,7 +40,7 @@ from session_utils import ( ) from pane_registry import upsert_registry, load_registry_by_project_id from project_id import compute_ccb_project_id -from providers import CASK_CLIENT_SPEC, GASK_CLIENT_SPEC, OASK_CLIENT_SPEC, LASK_CLIENT_SPEC, DASK_CLIENT_SPEC +from providers import CASK_CLIENT_SPEC, GASK_CLIENT_SPEC, OASK_CLIENT_SPEC, LASK_CLIENT_SPEC, LOASK_CLIENT_SPEC, LSASK_CLIENT_SPEC, DASK_CLIENT_SPEC from process_lock import ProviderLock from askd_rpc import shutdown_daemon, read_state from askd_runtime import state_file_path @@ -612,7 +612,7 @@ class AILauncher: """Managed env + explicit caller marker for the pane/provider process.""" env = self._managed_env_overrides() prov = (provider or "").strip().lower() - if prov in {"claude", "codex", "gemini", "opencode", "droid", "email", "manual"}: + if prov in {"claude", "claude-opus", "claude-sonnet", "codex", "gemini", "opencode", "droid", "email", "manual"}: env["CCB_CALLER"] = prov # Merge per-provider launch_env from config. extra = self.launch_env.get(prov) @@ -641,7 +641,7 @@ class AILauncher: if not cfg.is_dir(): return - for name in (".codex-session", ".gemini-session", ".opencode-session", ".claude-session", ".droid-session"): + for name in (".codex-session", ".gemini-session", ".opencode-session", ".claude-session", ".claude-opus-session", ".claude-sonnet-session", ".droid-session"): legacy = self.project_root / name if not legacy.exists(): continue @@ -770,7 +770,7 @@ class AILauncher: def _maybe_start_unified_askd(self, *, quiet: bool = False) -> None: """Start unified askd daemon (provider-agnostic).""" # Try to start for any enabled provider that uses askd (including claude) - for provider in ["codex", "gemini", "opencode", "droid", "claude"]: + for provider in ["codex", "gemini", "opencode", "droid", "claude", "claude-opus", "claude-sonnet"]: if provider in [p.lower() for p in self.providers]: # Try to start and check if successful self._maybe_start_provider_daemon(provider, quiet=quiet) @@ -808,6 +808,8 @@ class AILauncher: "gemini": GASK_CLIENT_SPEC, "opencode": OASK_CLIENT_SPEC, "claude": LASK_CLIENT_SPEC, + "claude-opus": LOASK_CLIENT_SPEC, + "claude-sonnet": LSASK_CLIENT_SPEC, "droid": DASK_CLIENT_SPEC, } spec = specs.get(provider) @@ -1361,11 +1363,16 @@ class AILauncher: print(f"āš ļø codex_session_clear_failed: {exc}", file=sys.stderr) return data if isinstance(data, dict) else {} - def _claude_session_file(self) -> Path: - return self._project_session_file(".claude-session") + def _claude_session_file(self, provider: str = "claude") -> Path: + _SESSION_FILE_MAP = { + "claude": ".claude-session", + "claude-opus": ".claude-opus-session", + "claude-sonnet": ".claude-sonnet-session", + } + return self._project_session_file(_SESSION_FILE_MAP.get(provider, ".claude-session")) - def _backfill_claude_session_work_dir_fields(self) -> None: - path = self._claude_session_file() + def _backfill_claude_session_work_dir_fields(self, provider: str = "claude") -> None: + path = self._claude_session_file(provider) if not path.exists(): return data = self._read_json_file(path) @@ -1385,8 +1392,8 @@ class AILauncher: payload = json.dumps(data, ensure_ascii=False, indent=2) safe_write_session(path, payload) - def _read_local_claude_session_id(self) -> str | None: - data = self._read_json_file(self._claude_session_file()) + def _read_local_claude_session_id(self, provider: str = "claude") -> str | None: + data = self._read_json_file(self._claude_session_file(provider)) sid = data.get("claude_session_id") if not sid: legacy = data.get("session_id") @@ -1416,8 +1423,9 @@ class AILauncher: pane_id: str | None = None, pane_title_marker: str | None = None, terminal: str | None = None, + provider: str = "claude", ) -> None: - path = self._claude_session_file() + path = self._claude_session_file(provider) writable, reason, fix = check_session_writable(path) if not writable: print(f"āŒ Cannot write {path.name}: {reason}", file=sys.stderr) @@ -1460,7 +1468,7 @@ class AILauncher: "work_dir": str(self.project_root), "terminal": terminal or self.terminal_type, "providers": { - "claude": { + provider: { "pane_id": pane_id, "pane_title_marker": pane_title_marker, "session_file": str(path), @@ -1472,7 +1480,7 @@ class AILauncher: ) except Exception: pass - self._maybe_start_provider_daemon("claude") + self._maybe_start_provider_daemon(provider) def _get_latest_codex_session_id(self) -> tuple[str | None, bool]: """ @@ -2648,8 +2656,8 @@ class AILauncher: return self._run_shell_command(start_cmd, cwd=str(Path.cwd())) def _start_provider_in_current_pane(self, provider: str) -> int: - if provider == "claude": - return self._start_claude() + if provider in ("claude", "claude-opus", "claude-sonnet"): + return self._start_claude(provider) if provider == "codex": return self._start_codex_current_pane() if provider == "gemini": @@ -2723,7 +2731,7 @@ class AILauncher: self._maybe_start_provider_daemon("codex") return True - def _write_cend_registry(self, claude_pane_id: str, codex_pane_id: str | None) -> bool: + def _write_cend_registry(self, claude_pane_id: str, codex_pane_id: str | None, claude_provider: str = "claude") -> bool: if not claude_pane_id: return False record = { @@ -2734,7 +2742,7 @@ class AILauncher: "work_dir": str(Path.cwd()), "terminal": self.terminal_type, "providers": { - "claude": {"pane_id": claude_pane_id}, + claude_provider: {"pane_id": claude_pane_id}, "codex": {"pane_id": codex_pane_id}, }, } @@ -2744,12 +2752,16 @@ class AILauncher: return ok def _sync_cend_registry(self) -> None: - if "codex" not in self.providers or "claude" not in self.providers: + if "codex" not in self.providers: + return + # Find the first Claude variant in the providers list. + claude_provider = next((p for p in self.providers if p in ("claude", "claude-opus", "claude-sonnet")), None) + if not claude_provider: return codex_pane_id = self._provider_pane_id("codex") - claude_pane_id = self._provider_pane_id("claude") + claude_pane_id = self._provider_pane_id(claude_provider) if codex_pane_id and claude_pane_id: - self._write_cend_registry(claude_pane_id, codex_pane_id) + self._write_cend_registry(claude_pane_id, codex_pane_id, claude_provider) def _write_gemini_session(self, runtime, tmux_session, pane_id=None, pane_title_marker=None, start_cmd=None): session_file = self._project_session_file(".gemini-session") @@ -3035,9 +3047,9 @@ class AILauncher: "āŒ Claude CLI not found. Install: npm install -g @anthropic-ai/claude-code" ) - def _claude_env_overrides(self) -> dict: + def _claude_env_overrides(self, provider: str = "claude") -> dict: env: dict[str, str] = {} - env.update(self._provider_env_overrides("claude")) + env.update(self._provider_env_overrides(provider)) if "codex" in self.providers: runtime = self.runtime_dir / "codex" env["CODEX_SESSION_ID"] = self.session_id @@ -3084,16 +3096,43 @@ class AILauncher: else: env["DROID_TMUX_SESSION"] = pane_id + if "claude-opus" in self.providers: + runtime = self.runtime_dir / "claude-opus" + env["CLAUDE_OPUS_SESSION_ID"] = self.session_id + env["CLAUDE_OPUS_RUNTIME_DIR"] = str(runtime) + env["CLAUDE_OPUS_TERMINAL"] = self.terminal_type or "" + pane_id = self._provider_pane_id("claude-opus") + if self.terminal_type == "wezterm": + env["CLAUDE_OPUS_WEZTERM_PANE"] = pane_id + else: + env["CLAUDE_OPUS_TMUX_SESSION"] = pane_id + + if "claude-sonnet" in self.providers: + runtime = self.runtime_dir / "claude-sonnet" + env["CLAUDE_SONNET_SESSION_ID"] = self.session_id + env["CLAUDE_SONNET_RUNTIME_DIR"] = str(runtime) + env["CLAUDE_SONNET_TERMINAL"] = self.terminal_type or "" + pane_id = self._provider_pane_id("claude-sonnet") + if self.terminal_type == "wezterm": + env["CLAUDE_SONNET_WEZTERM_PANE"] = pane_id + else: + env["CLAUDE_SONNET_TMUX_SESSION"] = pane_id + return env - def _build_claude_env(self) -> dict: + def _build_claude_env(self, provider: str = "claude") -> dict: env = self._with_bin_path_env() - env.update(self._claude_env_overrides()) + env.update(self._claude_env_overrides(provider)) return env - def _claude_start_plan(self) -> tuple[list[str], str, bool]: + def _claude_start_plan(self, provider: str = "claude") -> tuple[list[str], str, bool]: claude_cmd = self._find_claude_cmd() cmd = [claude_cmd] + # Append --model flag for claude-opus / claude-sonnet variants. + _CLAUDE_MODEL_MAP = {"claude-opus": "opus", "claude-sonnet": "sonnet"} + model = _CLAUDE_MODEL_MAP.get(provider) + if model: + cmd.extend(["--model", model]) if self.auto: cmd.append("--dangerously-skip-permissions") has_history = False @@ -3103,7 +3142,7 @@ class AILauncher: if has_history: cmd.append("--continue") # Append per-provider launch_args from config. - extra = self.launch_args.get("claude") + extra = self.launch_args.get(provider) or self.launch_args.get("claude") if isinstance(extra, str) and extra.strip(): cmd.extend(shlex.split(extra.strip())) run_cwd = str(self.project_root) if self.resume else str(Path.cwd()) @@ -3111,12 +3150,14 @@ class AILauncher: run_cwd = str(resume_dir) return cmd, run_cwd, has_history - def _start_claude(self) -> int: - print(f"šŸš€ {t('starting_claude')}") - env = self._build_claude_env() + def _start_claude(self, provider: str = "claude") -> int: + _LABEL_MAP = {"claude": "Claude", "claude-opus": "Claude-Opus", "claude-sonnet": "Claude-Sonnet"} + label = _LABEL_MAP.get(provider, "Claude") + print(f"šŸš€ Starting {label}...") + env = self._build_claude_env(provider) try: - cmd, run_cwd, has_history = self._claude_start_plan() + cmd, run_cwd, has_history = self._claude_start_plan(provider) except FileNotFoundError as e: print(str(e)) return 1 @@ -3152,12 +3193,14 @@ class AILauncher: print(f"\nāš ļø {t('user_interrupted')}") return 130 - def _start_claude_pane(self, *, parent_pane: str | None, direction: str | None) -> str | None: - print(f"šŸš€ {t('starting_claude')}") - env_overrides = self._claude_env_overrides() + def _start_claude_pane(self, *, parent_pane: str | None, direction: str | None, provider: str = "claude") -> str | None: + _LABEL_MAP = {"claude": "Claude", "claude-opus": "Claude-Opus", "claude-sonnet": "Claude-Sonnet"} + label = _LABEL_MAP.get(provider, "Claude") + print(f"šŸš€ Starting {label}...") + env_overrides = self._claude_env_overrides(provider) try: - cmd_parts, run_cwd, has_history = self._claude_start_plan() + cmd_parts, run_cwd, has_history = self._claude_start_plan(provider) except FileNotFoundError as e: print(str(e)) return None @@ -3168,9 +3211,10 @@ class AILauncher: else: print(f"ā„¹ļø {t('no_claude_session')}") + pane_title = f"CCB-{label}-{self.project_id[:8]}" start_cmd = " ".join(cmd_parts) full_cmd = ( - _build_pane_title_cmd(f"CCB-Claude-{self.project_id[:8]}") + _build_pane_title_cmd(pane_title) + self._build_env_prefix(env_overrides) + _build_export_path_cmd(self.script_dir / "bin") + start_cmd @@ -3182,27 +3226,28 @@ class AILauncher: if self.terminal_type == "wezterm": backend = WeztermBackend() pane_id = backend.create_pane(full_cmd, run_cwd, direction=use_direction, percent=50, parent_pane=use_parent) - self.wezterm_panes["claude"] = pane_id + self.wezterm_panes[provider] = pane_id else: backend = TmuxBackend() pane_id = backend.create_pane("", run_cwd, direction=use_direction, percent=50, parent_pane=use_parent) backend.respawn_pane(pane_id, cmd=full_cmd, cwd=run_cwd, remain_on_exit=True) - backend.set_pane_title(pane_id, f"CCB-Claude-{self.project_id[:8]}") - backend.set_pane_user_option(pane_id, "@ccb_agent", "Claude") - self.tmux_panes["claude"] = pane_id + backend.set_pane_title(pane_id, pane_title) + backend.set_pane_user_option(pane_id, "@ccb_agent", label) + self.tmux_panes[provider] = pane_id try: self._write_local_claude_session( - session_id=self._read_local_claude_session_id(), + session_id=self._read_local_claude_session_id(provider), active=True, pane_id=str(pane_id or ""), - pane_title_marker=f"CCB-Claude-{self.project_id[:8]}", + pane_title_marker=pane_title, terminal=self.terminal_type, + provider=provider, ) except Exception: pass - print(f"āœ… {t('started_backend', provider='Claude', terminal=f'{self.terminal_type} pane', pane_id=pane_id)}") + print(f"āœ… {t('started_backend', provider=label, terminal=f'{self.terminal_type} pane', pane_id=pane_id)}") return pane_id def cleanup( @@ -3265,6 +3310,8 @@ class AILauncher: self._project_session_file(".gemini-session"), self._project_session_file(".opencode-session"), self._project_session_file(".claude-session"), + self._project_session_file(".claude-opus-session"), + self._project_session_file(".claude-sonnet-session"), self._project_session_file(".droid-session"), ]: if session_file.exists(): @@ -3361,7 +3408,8 @@ class AILauncher: if not self._require_project_config_dir(): return 2 - self._backfill_claude_session_work_dir_fields() + for _bp in [p for p in self.providers if p in ("claude", "claude-opus", "claude-sonnet")]: + self._backfill_claude_session_work_dir_fields(_bp) if not self.providers: print("āŒ No providers configured. Define providers in ccb.config or pass them on the command line.", file=sys.stderr) @@ -3442,15 +3490,18 @@ class AILauncher: except Exception: pass - # Mark current Claude pane/session as active when Claude is the anchor. - if self.anchor_provider == "claude": + # Mark current Claude pane/session as active when Claude variant is the anchor. + if self.anchor_provider in ("claude", "claude-opus", "claude-sonnet"): + _anchor_label_map = {"claude": "Claude", "claude-opus": "Claude-Opus", "claude-sonnet": "Claude-Sonnet"} + _anchor_label = _anchor_label_map.get(self.anchor_provider, "Claude") try: self._write_local_claude_session( - session_id=self._read_local_claude_session_id(), + session_id=self._read_local_claude_session_id(self.anchor_provider), active=True, pane_id=str(self.anchor_pane_id or ""), - pane_title_marker=f"CCB-Claude-{self.project_id[:8]}", + pane_title_marker=f"CCB-{_anchor_label}-{self.project_id[:8]}", terminal=self.terminal_type, + provider=self.anchor_provider, ) except Exception: pass @@ -3458,8 +3509,8 @@ class AILauncher: def _start_item(item: str, *, parent: str | None, direction: str | None) -> str | None: if item == "cmd": return self._start_cmd_pane(parent_pane=parent, direction=direction, cmd_settings=cmd_settings) - if item == "claude": - return self._start_claude_pane(parent_pane=parent, direction=direction) + if item in ("claude", "claude-opus", "claude-sonnet"): + return self._start_claude_pane(parent_pane=parent, direction=direction, provider=item) pane_id = self._start_provider(item, parent_pane=parent, direction=direction) if pane_id: self._warmup_provider(item) @@ -3895,7 +3946,7 @@ def cmd_kill(args): return _kill_global_zombies(yes=yes) # Project-level cleanup (original behavior) - providers = _parse_providers(args.providers or ["codex", "gemini", "opencode", "claude", "droid"], allow_unknown=True) + providers = _parse_providers(args.providers or ["codex", "gemini", "opencode", "claude", "claude-opus", "claude-sonnet", "droid"], allow_unknown=True) if not providers: return 2 @@ -3905,6 +3956,8 @@ def cmd_kill(args): "gemini": GASK_CLIENT_SPEC, "opencode": OASK_CLIENT_SPEC, "claude": LASK_CLIENT_SPEC, + "claude-opus": LOASK_CLIENT_SPEC, + "claude-sonnet": LSASK_CLIENT_SPEC, "droid": DASK_CLIENT_SPEC, } @@ -3987,7 +4040,7 @@ def _parse_providers(values: list[str], *, allow_unknown: bool = False) -> list[ Returns a de-duplicated list preserving order. """ - allowed = {"codex", "gemini", "opencode", "claude", "droid"} + allowed = {"codex", "gemini", "opencode", "claude", "claude-opus", "claude-sonnet", "droid"} raw_parts = _split_provider_tokens(values) if not raw_parts: @@ -4007,8 +4060,8 @@ def _parse_providers(values: list[str], *, allow_unknown: bool = False) -> list[ if unknown and not allow_unknown: print(f"āŒ invalid provider(s): {', '.join(unknown)}", file=sys.stderr) - print("šŸ’” use: ccb codex gemini opencode claude droid (spaces) or ccb codex,gemini,opencode,claude,droid (commas)", file=sys.stderr) - print("šŸ’” allowed: codex, gemini, opencode, claude, droid", file=sys.stderr) + print("šŸ’” use: ccb codex gemini opencode claude claude-opus claude-sonnet droid", file=sys.stderr) + print("šŸ’” allowed: codex, gemini, opencode, claude, claude-opus, claude-sonnet, droid", file=sys.stderr) return [] return parsed @@ -4019,7 +4072,7 @@ def _parse_providers_with_cmd(values: list[str]) -> tuple[list[str], bool]: Parse providers from argv and treat "cmd" as a separate flag. Returns (providers, cmd_enabled). """ - allowed = {"codex", "gemini", "opencode", "claude", "droid"} + allowed = {"codex", "gemini", "opencode", "claude", "claude-opus", "claude-sonnet", "droid"} raw_parts = _split_provider_tokens(values) if not raw_parts: return [], False @@ -4914,7 +4967,7 @@ def main(): start_parser.add_argument( "providers", nargs="*", - help="Backends to start (space or comma separated): codex, gemini, opencode, claude, droid (add cmd for a shell pane)", + help="Backends to start (space or comma separated): codex, gemini, opencode, claude, claude-opus, claude-sonnet, droid (add cmd for a shell pane)", ) start_parser.add_argument("-r", "--resume", "--restore", action="store_true", help="Resume context") start_parser.add_argument("-a", "--auto", action="store_true", help="Full auto permission mode") diff --git a/lib/ccb_start_config.py b/lib/ccb_start_config.py index 58da17ae..f8ba3495 100644 --- a/lib/ccb_start_config.py +++ b/lib/ccb_start_config.py @@ -19,7 +19,7 @@ class StartConfig: path: Optional[Path] = None -_ALLOWED_PROVIDERS = {"codex", "gemini", "opencode", "claude", "droid"} +_ALLOWED_PROVIDERS = {"codex", "gemini", "opencode", "claude", "claude-opus", "claude-sonnet", "droid"} def _parse_tokens(raw: str) -> list[str]: diff --git a/lib/claude_session_resolver.py b/lib/claude_session_resolver.py index 3ae0a10c..bfcaf73c 100644 --- a/lib/claude_session_resolver.py +++ b/lib/claude_session_resolver.py @@ -240,7 +240,7 @@ def _load_registry_by_project_id_unfiltered(ccb_project_id: str, work_dir: Path) return best -def resolve_claude_session(work_dir: Path) -> Optional[ClaudeSessionResolution]: +def resolve_claude_session(work_dir: Path, provider: str = "claude") -> Optional[ClaudeSessionResolution]: best_fallback: Optional[ClaudeSessionResolution] = None try: current_pid = compute_ccb_project_id(work_dir) @@ -289,7 +289,8 @@ def consider(candidate: Optional[ClaudeSessionResolution]) -> Optional[ClaudeSes if not record_pid or (current_pid and record_pid != current_pid): continue data = _data_from_registry(record, work_dir) - session_file = _session_file_from_record(record) or find_project_session_file(work_dir, ".claude-session") + _sfn = {"claude": ".claude-session", "claude-opus": ".claude-opus-session", "claude-sonnet": ".claude-sonnet-session"}.get(provider, ".claude-session") + session_file = _session_file_from_record(record) or find_project_session_file(work_dir, _sfn) candidate = _select_resolution(data, session_file, record, f"registry:{key}") resolved = consider(candidate) if resolved: @@ -302,10 +303,10 @@ def consider(candidate: Optional[ClaudeSessionResolution]) -> Optional[ClaudeSes except Exception: pid = "" if pid: - record = load_registry_by_project_id(pid, "claude") + record = load_registry_by_project_id(pid, provider) if isinstance(record, dict): data = _data_from_registry(record, work_dir) - session_file = _session_file_from_record(record) or find_project_session_file(work_dir, ".claude-session") + session_file = _session_file_from_record(record) or find_project_session_file(work_dir, _sfn) candidate = _select_resolution(data, session_file, record, "registry:project") resolved = consider(candidate) if resolved: @@ -315,14 +316,14 @@ def consider(candidate: Optional[ClaudeSessionResolution]) -> Optional[ClaudeSes unfiltered = _load_registry_by_project_id_unfiltered(pid, work_dir) if isinstance(unfiltered, dict): data = _data_from_registry(unfiltered, work_dir) - session_file = _session_file_from_record(unfiltered) or find_project_session_file(work_dir, ".claude-session") + session_file = _session_file_from_record(unfiltered) or find_project_session_file(work_dir, _sfn) candidate = _select_resolution(data, session_file, unfiltered, "registry:project_unfiltered") resolved = consider(candidate) if resolved: return resolved - # 3) .claude-session file - session_file = find_project_session_file(work_dir, ".claude-session") + # 3) Session file + session_file = find_project_session_file(work_dir, _sfn) if session_file: data = _read_json(session_file) if data: @@ -344,7 +345,7 @@ def consider(candidate: Optional[ClaudeSessionResolution]) -> Optional[ClaudeSes record = None if record: data = _data_from_registry(record, work_dir) - session_file = _session_file_from_record(record) or find_project_session_file(work_dir, ".claude-session") + session_file = _session_file_from_record(record) or find_project_session_file(work_dir, _sfn) candidate = _select_resolution(data, session_file, record, "registry:pane") resolved = consider(candidate) if resolved: diff --git a/lib/providers.py b/lib/providers.py index bd4b3547..87cfac4b 100644 --- a/lib/providers.py +++ b/lib/providers.py @@ -65,6 +65,26 @@ class ProviderClientSpec: ) +LOASKD_SPEC = ProviderDaemonSpec( + daemon_key="loaskd", + protocol_prefix="loask", + state_file_name="loaskd.json", + log_file_name="loaskd.log", + idle_timeout_env="CCB_LOASKD_IDLE_TIMEOUT_S", + lock_name="loaskd", +) + + +LSASKD_SPEC = ProviderDaemonSpec( + daemon_key="lsaskd", + protocol_prefix="lsask", + state_file_name="lsaskd.json", + log_file_name="lsaskd.log", + idle_timeout_env="CCB_LSASKD_IDLE_TIMEOUT_S", + lock_name="lsaskd", +) + + DASKD_SPEC = ProviderDaemonSpec( daemon_key="daskd", protocol_prefix="dask", @@ -123,6 +143,30 @@ class ProviderClientSpec: ) +LOASK_CLIENT_SPEC = ProviderClientSpec( + protocol_prefix="loask", + enabled_env="CCB_LOASKD", + autostart_env_primary="CCB_LOASKD_AUTOSTART", + autostart_env_legacy="CCB_AUTO_LOASKD", + state_file_env="CCB_LOASKD_STATE_FILE", + session_filename=".claude-opus-session", + daemon_bin_name="askd", + daemon_module="askd.daemon", +) + + +LSASK_CLIENT_SPEC = ProviderClientSpec( + protocol_prefix="lsask", + enabled_env="CCB_LSASKD", + autostart_env_primary="CCB_LSASKD_AUTOSTART", + autostart_env_legacy="CCB_AUTO_LSASKD", + state_file_env="CCB_LSASKD_STATE_FILE", + session_filename=".claude-sonnet-session", + daemon_bin_name="askd", + daemon_module="askd.daemon", +) + + DASK_CLIENT_SPEC = ProviderClientSpec( protocol_prefix="dask", enabled_env="CCB_DASKD", From 192aa8b85d608c6614b436a68c807b14be857426 Mon Sep 17 00:00:00 2001 From: Carl Louie Ratcliffe Date: Thu, 2 Apr 2026 15:59:08 +0800 Subject: [PATCH 2/7] fix: session resolver and daemon routing for multi-model claude Fix 6 issues found during cross-model audit (Opus + Sonnet): claude_session_resolver.py: - Move _sfn assignment before SESSION_ENV_KEYS loop (was NameError on fallback paths when no session env vars set) - Add CLAUDE_OPUS_SESSION_ID and CLAUDE_SONNET_SESSION_ID to SESSION_ENV_KEYS (enables fast registry lookup for claude-only setups) - Make _data_from_registry and _session_file_from_record accept provider param (was hardcoded to providers.get("claude"), missing variant data) - Make _candidate_default_session_file accept session_filename param (was hardcoded to .claude-session for all variants) askd_client.py: - Fix daemon path for all providers: send "ask.request" type with "provider" field when targeting unified askd daemon. Previously all clients sent "{prefix}.request" which the unified daemon rejected. - Use instance mechanism for claude variants (claude:opus, claude:sonnet) so ClaudeAdapter can resolve variant-specific session files. - Accept both "ask.response" and spec-specific response type to preserve standalone daemon compatibility (e.g., bin/laskd). - Default caller field from provider name when CCB_CALLER not set. bin/lopend, bin/lspend: - Fix debug messages to reference correct session filenames. Co-Authored-By: Claude Opus 4.6 (1M context) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- bin/lopend | 2 +- bin/lspend | 2 +- lib/askd_client.py | 32 ++++++++++++++++++++++++++++++-- lib/claude_session_resolver.py | 34 ++++++++++++++++++---------------- 4 files changed, 50 insertions(+), 20 deletions(-) diff --git a/bin/lopend b/bin/lopend index a7634fc2..b9190403 100755 --- a/bin/lopend +++ b/bin/lopend @@ -47,7 +47,7 @@ def _load_session_log_path(work_dir: Path, explicit_session_file: Path | None) - if path_str: return Path(path_str).expanduser(), session_id except Exception as exc: - _debug(f"Failed to read .claude-session ({session_file}): {exc}") + _debug(f"Failed to read .claude-opus-session ({session_file}): {exc}") return None, None diff --git a/bin/lspend b/bin/lspend index 2fe833cb..bafca3c4 100755 --- a/bin/lspend +++ b/bin/lspend @@ -47,7 +47,7 @@ def _load_session_log_path(work_dir: Path, explicit_session_file: Path | None) - if path_str: return Path(path_str).expanduser(), session_id except Exception as exc: - _debug(f"Failed to read .claude-session ({session_file}): {exc}") + _debug(f"Failed to read .claude-sonnet-session ({session_file}): {exc}") return None, None diff --git a/lib/askd_client.py b/lib/askd_client.py index 81e3672b..f60c4a86 100644 --- a/lib/askd_client.py +++ b/lib/askd_client.py @@ -22,6 +22,22 @@ from pane_registry import load_registry_by_project_id +# Maps protocol_prefix → unified daemon provider key. +# Entries with ":" use the instance mechanism (e.g., "claude:opus" → base="claude", instance="opus"). +_UNIFIED_PROVIDER_MAP = { + "cask": "codex", + "gask": "gemini", + "oask": "opencode", + "dask": "droid", + "lask": "claude", + "loask": "claude:opus", + "lsask": "claude:sonnet", + "hask": "copilot", + "bask": "codebuddy", + "qask": "qwen", +} + + def resolve_work_dir( spec: ProviderClientSpec, *, @@ -219,8 +235,16 @@ def try_daemon_request( return None try: + # Use unified askd protocol when targeting the unified daemon. + # Maps protocol_prefix to the provider key the daemon expects. + unified_provider = _UNIFIED_PROVIDER_MAP.get(spec.protocol_prefix) + if spec.daemon_module == "askd.daemon" and unified_provider: + msg_type = "ask.request" + else: + msg_type = f"{spec.protocol_prefix}.request" + payload = { - "type": f"{spec.protocol_prefix}.request", + "type": msg_type, "v": 1, "id": f"{spec.protocol_prefix}-{os.getpid()}-{int(time.time() * 1000)}", "token": token, @@ -229,6 +253,8 @@ def try_daemon_request( "quiet": bool(quiet), "message": message, } + if unified_provider: + payload["provider"] = unified_provider if output_path: payload["output_path"] = str(output_path) req_id = os.environ.get("CCB_REQ_ID", "").strip() @@ -238,6 +264,8 @@ def try_daemon_request( if no_wrap in ("1", "true", "yes"): payload["no_wrap"] = True caller = os.environ.get("CCB_CALLER", "").strip() + if not caller and unified_provider: + caller = unified_provider.split(":")[0] if caller: payload["caller"] = caller connect_timeout = min(1.0, max(0.1, float(timeout))) @@ -258,7 +286,7 @@ def try_daemon_request( return None line = buf.split(b"\n", 1)[0].decode("utf-8", errors="replace") resp = json.loads(line) - if resp.get("type") != f"{spec.protocol_prefix}.response": + if resp.get("type") not in ("ask.response", f"{spec.protocol_prefix}.response"): return None reply = str(resp.get("reply") or "") exit_code = int(resp.get("exit_code", 1)) diff --git a/lib/claude_session_resolver.py b/lib/claude_session_resolver.py index bfcaf73c..063aa88b 100644 --- a/lib/claude_session_resolver.py +++ b/lib/claude_session_resolver.py @@ -17,6 +17,8 @@ "CODEX_SESSION_ID", "GEMINI_SESSION_ID", "OPENCODE_SESSION_ID", + "CLAUDE_OPUS_SESSION_ID", + "CLAUDE_SONNET_SESSION_ID", ) @@ -56,9 +58,9 @@ def _pane_from_data(data: dict) -> str: return "" -def _session_file_from_record(record: dict) -> Optional[Path]: +def _session_file_from_record(record: dict, provider: str = "claude") -> Optional[Path]: providers = record.get("providers") if isinstance(record.get("providers"), dict) else {} - claude = providers.get("claude") if isinstance(providers, dict) else None + claude = providers.get(provider) if isinstance(providers, dict) else None path_str = None if isinstance(claude, dict): path_str = claude.get("session_file") @@ -72,7 +74,7 @@ def _session_file_from_record(record: dict) -> Optional[Path]: return None -def _data_from_registry(record: dict, fallback_work_dir: Path) -> dict: +def _data_from_registry(record: dict, fallback_work_dir: Path, provider: str = "claude") -> dict: data: dict = {} if not isinstance(record, dict): return data @@ -82,7 +84,7 @@ def _data_from_registry(record: dict, fallback_work_dir: Path) -> dict: data["terminal"] = record.get("terminal") providers = record.get("providers") if isinstance(record.get("providers"), dict) else {} - claude = providers.get("claude") if isinstance(providers, dict) else None + claude = providers.get(provider) if isinstance(providers, dict) else None if isinstance(claude, dict): pane_id = claude.get("pane_id") if pane_id: @@ -114,12 +116,12 @@ def _select_resolution(data: dict, session_file: Optional[Path], record: Optiona ) -def _candidate_default_session_file(work_dir: Path) -> Optional[Path]: +def _candidate_default_session_file(work_dir: Path, session_filename: str = ".claude-session") -> Optional[Path]: try: cfg = resolve_project_config_dir(work_dir) except Exception: return None - return cfg / ".claude-session" + return cfg / session_filename def _registry_run_dir() -> Path: @@ -247,6 +249,7 @@ def resolve_claude_session(work_dir: Path, provider: str = "claude") -> Optional except Exception: current_pid = "" strict_project = resolve_project_config_dir(work_dir).is_dir() + _sfn = {"claude": ".claude-session", "claude-opus": ".claude-opus-session", "claude-sonnet": ".claude-sonnet-session"}.get(provider, ".claude-session") allow_cross = os.environ.get("CCB_ALLOW_CROSS_PROJECT_SESSION") in ("1", "true", "yes") if not strict_project and not allow_cross: return None @@ -288,9 +291,8 @@ def consider(candidate: Optional[ClaudeSessionResolution]) -> Optional[ClaudeSes record_pid = _record_project_id(record) if not record_pid or (current_pid and record_pid != current_pid): continue - data = _data_from_registry(record, work_dir) - _sfn = {"claude": ".claude-session", "claude-opus": ".claude-opus-session", "claude-sonnet": ".claude-sonnet-session"}.get(provider, ".claude-session") - session_file = _session_file_from_record(record) or find_project_session_file(work_dir, _sfn) + data = _data_from_registry(record, work_dir, provider) + session_file = _session_file_from_record(record, provider) or find_project_session_file(work_dir, _sfn) candidate = _select_resolution(data, session_file, record, f"registry:{key}") resolved = consider(candidate) if resolved: @@ -305,8 +307,8 @@ def consider(candidate: Optional[ClaudeSessionResolution]) -> Optional[ClaudeSes if pid: record = load_registry_by_project_id(pid, provider) if isinstance(record, dict): - data = _data_from_registry(record, work_dir) - session_file = _session_file_from_record(record) or find_project_session_file(work_dir, _sfn) + data = _data_from_registry(record, work_dir, provider) + session_file = _session_file_from_record(record, provider) or find_project_session_file(work_dir, _sfn) candidate = _select_resolution(data, session_file, record, "registry:project") resolved = consider(candidate) if resolved: @@ -315,8 +317,8 @@ def consider(candidate: Optional[ClaudeSessionResolution]) -> Optional[ClaudeSes # Fallback: accept latest registry record even if pane liveness can't be verified. unfiltered = _load_registry_by_project_id_unfiltered(pid, work_dir) if isinstance(unfiltered, dict): - data = _data_from_registry(unfiltered, work_dir) - session_file = _session_file_from_record(unfiltered) or find_project_session_file(work_dir, _sfn) + data = _data_from_registry(unfiltered, work_dir, provider) + session_file = _session_file_from_record(unfiltered, provider) or find_project_session_file(work_dir, _sfn) candidate = _select_resolution(data, session_file, unfiltered, "registry:project_unfiltered") resolved = consider(candidate) if resolved: @@ -344,8 +346,8 @@ def consider(candidate: Optional[ClaudeSessionResolution]) -> Optional[ClaudeSes if not record_pid or (current_pid and record_pid != current_pid): record = None if record: - data = _data_from_registry(record, work_dir) - session_file = _session_file_from_record(record) or find_project_session_file(work_dir, _sfn) + data = _data_from_registry(record, work_dir, provider) + session_file = _session_file_from_record(record, provider) or find_project_session_file(work_dir, _sfn) candidate = _select_resolution(data, session_file, record, "registry:pane") resolved = consider(candidate) if resolved: @@ -353,7 +355,7 @@ def consider(candidate: Optional[ClaudeSessionResolution]) -> Optional[ClaudeSes if best_fallback: if not best_fallback.session_file: - best_fallback.session_file = _candidate_default_session_file(work_dir) + best_fallback.session_file = _candidate_default_session_file(work_dir, _sfn) return best_fallback return None From 8ae07e033e374871ef4e81b576f392bb1c5fd4be Mon Sep 17 00:00:00 2001 From: Carl Louie Ratcliffe Date: Thu, 2 Apr 2026 16:03:47 +0800 Subject: [PATCH 3/7] fix: guard unified daemon protocol against standalone daemon regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address two issues found during Sonnet audit of the daemon fix: 1. Only use "ask.request" protocol when state came from the default unified daemon state file (askd.json). When state comes from a custom state file (CCB_LASKD_STATE_FILE) or CCB_RUN_DIR fallback, keep the legacy protocol to avoid sending wrong type to standalone daemons like bin/laskd. Tracked via using_unified_state flag. 2. Change caller fallback from target provider base ("claude") to "manual" — the caller field identifies who asked, not who was asked. Co-Authored-By: Claude Opus 4.6 (1M context) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- lib/askd_client.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/askd_client.py b/lib/askd_client.py index f60c4a86..a1c63c19 100644 --- a/lib/askd_client.py +++ b/lib/askd_client.py @@ -213,6 +213,10 @@ def try_daemon_request( read_state = getattr(daemon_module, "read_state") st = read_state(state_file=state_file) + # Track whether state came from the default unified daemon (askd.json). + # Only use unified protocol when we know we're talking to the unified daemon, + # not a standalone daemon found via custom state file or CCB_RUN_DIR fallback. + using_unified_state = state_file is None and st is not None # If state not found and CCB_RUN_DIR is set, try project-specific state file # This fixes background mode where env vars may not be inherited @@ -238,7 +242,7 @@ def try_daemon_request( # Use unified askd protocol when targeting the unified daemon. # Maps protocol_prefix to the provider key the daemon expects. unified_provider = _UNIFIED_PROVIDER_MAP.get(spec.protocol_prefix) - if spec.daemon_module == "askd.daemon" and unified_provider: + if using_unified_state and spec.daemon_module == "askd.daemon" and unified_provider: msg_type = "ask.request" else: msg_type = f"{spec.protocol_prefix}.request" @@ -265,7 +269,7 @@ def try_daemon_request( payload["no_wrap"] = True caller = os.environ.get("CCB_CALLER", "").strip() if not caller and unified_provider: - caller = unified_provider.split(":")[0] + caller = "manual" if caller: payload["caller"] = caller connect_timeout = min(1.0, max(0.1, float(timeout))) From 06109f69708654d1b2fb2e3c65433915c4a3c13f Mon Sep 17 00:00:00 2001 From: Carl Louie Ratcliffe Date: Fri, 3 Apr 2026 17:13:43 +0800 Subject: [PATCH 4/7] feat: formalize Gemini integration as task-conditioned inspiration role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconcile config drift between canonical templates and generated files: - Role table: designer→claude-opus, inspiration→gemini (task-conditioned), reviewer→claude-sonnet+codex (dual), executor→claude-opus - Review policy: dual-reviewer 10/10 all dimensions, no round limit, shared context, provider-failure exception - Inspiration: challenge-first for architecture/planning, creative brainstorming for UI/UX/naming/ideation, visible fallback on failure - Skill docs: all-plan updated across claude/codex/droid provider packs Plan reviewed and scored 10/10 by all 3 providers (opus, sonnet, codex) across 9 revision rounds. Code review scored 10/10 by codex (repo-verified) and 8/10 by sonnet (AGENTS.md absent from diff due to .gitignore; generated artifact verified correct in repo by codex). Co-Authored-By: Claude Opus 4.6 (1M context) --- .clinerules | 12 +-- claude_skills/all-plan/README.md | 10 +- claude_skills/all-plan/SKILL.md | 8 +- claude_skills/all-plan/references/flow.md | 118 +++++++++++++--------- codex_skills/all-plan/README.md | 10 +- codex_skills/all-plan/SKILL.md | 8 +- codex_skills/all-plan/references/flow.md | 118 +++++++++++++--------- config/agents-md-ccb.md | 48 ++++----- config/claude-md-ccb.md | 50 ++++++--- config/clinerules-ccb.md | 12 +-- droid_skills/all-plan/README.md | 10 +- droid_skills/all-plan/SKILL.md | 8 +- droid_skills/all-plan/references/flow.md | 118 +++++++++++++--------- 13 files changed, 317 insertions(+), 213 deletions(-) diff --git a/.clinerules b/.clinerules index 5a3df7df..a1c4d089 100644 --- a/.clinerules +++ b/.clinerules @@ -4,12 +4,12 @@ Abstract roles map to concrete AI providers. Skills reference roles, not providers directly. | Role | Provider | Description | -|------|----------|-------------| -| `designer` | `claude` | Primary planner and architect — owns plans and designs | -| `inspiration` | `gemini` | Creative brainstorming — provides ideas as reference only (unreliable, never blindly follow) | -| `reviewer` | `codex` | Scored quality gate — evaluates plans/code using Rubrics | -| `executor` | `claude` | Code implementation — writes and modifies code | +|---|---|---| +| `designer` | `claude-opus` | Primary planner and architect — owns plans and designs | +| `inspiration` | `gemini` | Task-conditioned second perspective — architectural challenge (default) or creative brainstorming (for UI/UX/naming/ideation tasks) | +| `reviewer` | `claude-sonnet`, `codex` | Both review and evaluate — all dimensions must score 10 | +| `executor` | `claude-opus` | Code implementation — writes and modifies code | To change a role assignment, edit the Provider column above. -When a skill references a role (e.g. `reviewer`), resolve it to the provider listed here. +When a skill references a role (e.g. `reviewer`), resolve it to BOTH providers listed (send to each via `/ask`). diff --git a/claude_skills/all-plan/README.md b/claude_skills/all-plan/README.md index 3da7a9dd..987fc301 100644 --- a/claude_skills/all-plan/README.md +++ b/claude_skills/all-plan/README.md @@ -18,9 +18,9 @@ Example: **5-Phase Design Process:** 1. **Requirement Clarification** - 5-Dimension readiness model, structured Q&A -2. **Inspiration Brainstorming** - Creative ideas from `inspiration` (reference only) +2. **Inspiration Consultation** - Task-conditioned input from `inspiration`: architectural challenge (default) or creative brainstorming (UI/UX/naming/ideation) 3. **Design** - `designer` creates the full plan, integrating adopted ideas -4. **Scored Review** - `reviewer` scores using Rubric A (must pass >= 7.0) +4. **Dual Scored Review** - Both reviewers (`claude-sonnet` + `codex`) score using Rubric A — all dimensions must reach 10 5. **Final Output** - Actionable plan saved to `plans/` directory ## Roles Used @@ -28,8 +28,8 @@ Example: | Role | Responsibility | |------|---------------| | `designer` | Primary planner, owns the plan | -| `inspiration` | Creative consultant (unreliable, user decides) | -| `reviewer` | Quality gate (Rubric A, per-dimension scoring) | +| `inspiration` | Task-conditioned second perspective (architectural challenge or creative brainstorming) | +| `reviewer` | Dual quality gate — both `claude-sonnet` and `codex` score (all dimensions must reach 10) | Roles resolve to providers via CLAUDE.md `CCB_ROLES` table. @@ -37,7 +37,7 @@ Roles resolve to providers via CLAUDE.md `CCB_ROLES` table. - **Structured Clarification**: 5-Dimension readiness scoring (100 pts) - **Inspiration Filter**: Adopt / Adapt / Discard with user approval -- **Scored Quality Gate**: Dimension-level scoring, auto-correction (max 3 rounds) +- **Dual Scored Quality Gate**: Both reviewers must score 10 on all dimensions — iterate until pass, no round limit - **Optional Web Research**: Triggered when requirements depend on external info ## When to Use diff --git a/claude_skills/all-plan/SKILL.md b/claude_skills/all-plan/SKILL.md index cda0000c..d65fa297 100644 --- a/claude_skills/all-plan/SKILL.md +++ b/claude_skills/all-plan/SKILL.md @@ -2,7 +2,7 @@ name: all-plan description: Collaborative planning using abstract roles (designer + inspiration + reviewer). metadata: - short-description: designer plans + inspiration brainstorms + reviewer scores + short-description: designer plans + inspiration challenges/brainstorms + dual reviewer scores (10/10) --- # All Plan (Claude Version) @@ -11,9 +11,9 @@ Collaborative planning using abstract roles defined in CLAUDE.md Role Assignment Highlights: - 5-Dimension requirement clarification (retained) -- `inspiration` brainstorming for creative/aesthetic ideas +- `inspiration` provides task-conditioned input: architectural challenge (default) or creative brainstorming (UI/UX/naming/ideation) - `designer` creates the full plan independently -- `reviewer` scores the plan using Rubric A (must pass >= 7.0) -- Auto-correction loop (max 3 rounds) +- `reviewer` (both `claude-sonnet` and `codex`) scores the plan — all dimensions must reach 10 +- Iterate until 10/10 — no round limit For full instructions, see `references/flow.md` diff --git a/claude_skills/all-plan/references/flow.md b/claude_skills/all-plan/references/flow.md index 3eb4f4bd..47c83d18 100644 --- a/claude_skills/all-plan/references/flow.md +++ b/claude_skills/all-plan/references/flow.md @@ -6,8 +6,8 @@ Planning skill using abstract roles defined in CLAUDE.md Role Assignment table. **Roles used by this skill** (resolve to providers via CLAUDE.md `CCB_ROLES`): - `designer` — Primary planner, owns the plan from start to finish -- `inspiration` — Creative brainstorming consultant (unreliable, use with judgment) -- `reviewer` — Scored quality gate, evaluates the plan using Rubric A (must pass >= 7.0) +- `inspiration` — Task-conditioned second perspective: architectural challenge (default) or creative brainstorming (for UI/UX/naming/ideation tasks). Advisory — the `designer` synthesizes input and makes the final call. If unreachable, surface visibly ("Gemini unavailable — proceeding without inspiration input") and proceed. +- `reviewer` — Dual scored quality gate (`claude-sonnet` + `codex`). Both score using Rubric A — all dimensions must reach 10. Iterate until pass, no round limit. --- @@ -211,13 +211,32 @@ Save as `design_brief`. --- -### Phase 2: Inspiration Brainstorming +### Phase 2: Inspiration Consultation -Send the design brief to `inspiration` for creative input. The `inspiration` provider excels at divergent thinking, aesthetic ideas, and unconventional approaches — but is often unreliable, so treat all output as **reference only**. +Send the design brief to `inspiration` for task-conditioned input. The prompt varies by task type. **2.1 Request Inspiration** -Send to `inspiration` (via `/ask`): +Send to `inspiration` (via `/ask gemini`). Choose the prompt based on task type: + +**For architecture/planning tasks (default):** + +``` +You are an architectural reviewer and second perspective. Based on this design brief, challenge the assumptions and propose alternatives — not a full implementation plan. + +DESIGN BRIEF: +[design_brief] + +Provide: +1) 2-3 assumptions in this brief that should be stress-tested +2) 1-2 alternative architectural approaches worth considering +3) Risks or failure modes the brief may have missed +4) Trade-offs the designer should explicitly decide on + +Be direct and critical. Your role is to strengthen the plan by challenging it. +``` + +**For UI/UX, naming, copy, or ideation tasks:** ``` You are a creative brainstorming partner. Based on this design brief, provide INSPIRATION and CREATIVE IDEAS — not a full implementation plan. @@ -235,6 +254,8 @@ Provide: Be bold and creative. Practical feasibility is secondary — inspiration is the goal. ``` +**If `inspiration` is unreachable:** Surface this visibly ("Gemini unavailable — proceeding without inspiration input") and skip to Phase 3. + Save response as `inspiration_response`. **2.2 The `designer` Filters Inspiration Ideas** @@ -314,11 +335,11 @@ Save as `plan_draft_v1`. ### Phase 4: Scored Review -Submit the plan to `reviewer` for scored review using Rubric A (defined in CLAUDE.md). +Submit the plan to BOTH reviewers for scored review using Rubric A (defined in AGENTS.md). **4.1 Submit Plan for Review** -Send to `reviewer` (via `/ask`): +Send to BOTH reviewers (via `/ask claude-sonnet` AND `/ask codex`): ``` [PLAN REVIEW REQUEST] @@ -370,31 +391,30 @@ Return your response as JSON with this exact structure: **4.2 Parse and Judge** -After receiving the `reviewer`'s JSON response: +After receiving BOTH reviewers' JSON responses: ``` iteration = 1 CHECK: - - If overall >= 7.0 AND no single dimension score <= 3 → PASS + - If BOTH reviewers score 10 on ALL dimensions → PASS - Otherwise → FAIL ``` -**4.3 Auto-Correction Loop (on FAIL)** +**4.3 Iteration Loop (on FAIL)** ``` -WHILE result == FAIL AND iteration <= 3: - 1. Read each dimension's weaknesses and fix suggestions - 2. Read critical_issues list - 3. Revise plan_draft to address ALL issues +WHILE result == FAIL: + 1. Read ALL feedback from BOTH reviewers (scores, weaknesses, fix suggestions) + 2. Read critical_issues lists from both + 3. Revise plan_draft to address ALL issues from BOTH reviewers 4. Save as plan_draft_v{iteration+1} - 5. Re-submit to `reviewer` via /ask (same template) + 5. Re-submit to BOTH reviewers via /ask (include: the revised plan, ALL prior feedback from BOTH reviewers, and what was changed in response) 6. iteration += 1 7. Re-check PASS/FAIL -IF iteration > 3 AND still FAIL: - Present all review rounds to user - Ask: "Review did not pass after 3 rounds. How would you like to proceed?" +No round limit — iterate until 10/10 is reached. +Exception: if one reviewer is unreachable after reasonable retry, proceed with the available reviewer's scores and flag the gap to the user. ``` **4.4 Display Score Summary (on PASS)** @@ -402,15 +422,23 @@ IF iteration > 3 AND still FAIL: ``` REVIEW: PASSED (Round [N]) ================================= -| Dimension | Score | Weight | Weighted | -|-----------------------|-------|--------|----------| -| Clarity | X/10 | 20% | X.XX | -| Completeness | X/10 | 25% | X.XX | -| Feasibility | X/10 | 25% | X.XX | -| Risk Assessment | X/10 | 15% | X.XX | -| Requirement Alignment | X/10 | 15% | X.XX | -|-----------------------|-------|--------|----------| -| OVERALL | | | X.XX/10 | +Reviewer: claude-sonnet +| Dimension | Score | +|-----------------------|-------| +| Clarity | 10/10 | +| Completeness | 10/10 | +| Feasibility | 10/10 | +| Risk Assessment | 10/10 | +| Requirement Alignment | 10/10 | + +Reviewer: codex +| Dimension | Score | +|-----------------------|-------| +| Clarity | 10/10 | +| Completeness | 10/10 | +| Feasibility | 10/10 | +| Risk Assessment | 10/10 | +| Requirement Alignment | 10/10 | Key Strengths: - [from `reviewer` response] @@ -442,7 +470,7 @@ Use this template: **Readiness Score**: [X]/100 -**Review Score**: [X.XX]/10 (passed round [N]) +**Review**: Passed round [N] (all dimensions 10/10 from both reviewers) **Generated**: [Date] ``` @@ -543,16 +571,15 @@ Finish the plan document with credits and appendix: ## Review Summary -| Dimension | Score | -|-----------|-------| -| Clarity | X/10 | -| Completeness | X/10 | -| Feasibility | X/10 | -| Risk Assessment | X/10 | -| Requirement Alignment | X/10 | -| **Overall** | **X.XX/10** | +| Dimension | claude-sonnet | codex | +|-----------|-------|-------| +| Clarity | X/10 | X/10 | +| Completeness | X/10 | X/10 | +| Feasibility | X/10 | X/10 | +| Risk Assessment | X/10 | X/10 | +| Requirement Alignment | X/10 | X/10 | -Review rounds: [N] +Review rounds: [N] | Pass: all dimensions 10/10 from both reviewers --- @@ -583,7 +610,7 @@ Summary: - Steps: [N] implementation steps - Risks: [N] identified with mitigations - Readiness: [X]/100 -- Review Score: [X.XX]/10 (round [N]) +- Review: Passed round [N] (all dimensions 10/10 from both reviewers) - Inspiration Ideas: [N] adopted, [N] adapted, [N] discarded Next: Review the plan and proceed with implementation when ready. @@ -596,11 +623,11 @@ Next: Review the plan and proceed with implementation when ready. 1. **`designer` Owns the Design**: The `designer` is the sole planner; `inspiration` and `reviewer` are consultants 2. **Structured Clarification**: Use option-based questions to systematically capture requirements 3. **Readiness Scoring**: Quantify requirement completeness before proceeding -4. **`inspiration` for Ideas Only**: Leverage creativity but never blindly follow it +4. **`inspiration` is Advisory**: Task-conditioned input (architectural challenge or creative brainstorming) — the `designer` synthesizes and decides 5. **User Controls Inspiration**: User decides which ideas to adopt/discard -6. **`reviewer` as Quality Gate**: Plan must pass Rubric A (>= 7.0) before proceeding -7. **Dimension-Level Feedback**: The `reviewer` scores each dimension individually with actionable fixes -8. **Auto-Correction with Limits**: Max 3 review rounds; escalate to user if still failing +6. **Dual `reviewer` Quality Gate**: Plan must pass Rubric A (all dimensions = 10 from BOTH reviewers) before proceeding +7. **Dimension-Level Feedback**: Both reviewers score each dimension individually with actionable fixes +8. **Iterate Until Pass**: No round limit — shared context ensures reviewers see each other's feedback 9. **Concrete Deliverables**: Output actionable plan document, not just discussion notes 10. **Research When Needed**: Use WebSearch for external knowledge when applicable @@ -610,7 +637,8 @@ Next: Review the plan and proceed with implementation when ready. - This skill is designed for complex features or architectural decisions - For simple tasks, use direct implementation instead -- Resolve `inspiration` and `reviewer` to providers via CLAUDE.md Role Assignment, then use `/ask ` -- If `inspiration` provider is not available, skip Phase 2 and proceed directly to Phase 3 -- If `reviewer` provider is not available, skip Phase 4 and present the plan directly to user +- Resolve `inspiration` to provider via CLAUDE.md Role Assignment (`/ask gemini`); resolve `reviewer` to BOTH providers (`/ask claude-sonnet` AND `/ask codex`) +- If `inspiration` provider is unreachable, surface visibly ("Gemini unavailable — proceeding without inspiration input") and skip to Phase 3 +- If one reviewer is unreachable, proceed with the available reviewer and flag the gap to user +- If both reviewers are unavailable, present the plan directly to user - Plans are saved to `plans/` directory with descriptive filenames diff --git a/codex_skills/all-plan/README.md b/codex_skills/all-plan/README.md index 3da7a9dd..987fc301 100644 --- a/codex_skills/all-plan/README.md +++ b/codex_skills/all-plan/README.md @@ -18,9 +18,9 @@ Example: **5-Phase Design Process:** 1. **Requirement Clarification** - 5-Dimension readiness model, structured Q&A -2. **Inspiration Brainstorming** - Creative ideas from `inspiration` (reference only) +2. **Inspiration Consultation** - Task-conditioned input from `inspiration`: architectural challenge (default) or creative brainstorming (UI/UX/naming/ideation) 3. **Design** - `designer` creates the full plan, integrating adopted ideas -4. **Scored Review** - `reviewer` scores using Rubric A (must pass >= 7.0) +4. **Dual Scored Review** - Both reviewers (`claude-sonnet` + `codex`) score using Rubric A — all dimensions must reach 10 5. **Final Output** - Actionable plan saved to `plans/` directory ## Roles Used @@ -28,8 +28,8 @@ Example: | Role | Responsibility | |------|---------------| | `designer` | Primary planner, owns the plan | -| `inspiration` | Creative consultant (unreliable, user decides) | -| `reviewer` | Quality gate (Rubric A, per-dimension scoring) | +| `inspiration` | Task-conditioned second perspective (architectural challenge or creative brainstorming) | +| `reviewer` | Dual quality gate — both `claude-sonnet` and `codex` score (all dimensions must reach 10) | Roles resolve to providers via CLAUDE.md `CCB_ROLES` table. @@ -37,7 +37,7 @@ Roles resolve to providers via CLAUDE.md `CCB_ROLES` table. - **Structured Clarification**: 5-Dimension readiness scoring (100 pts) - **Inspiration Filter**: Adopt / Adapt / Discard with user approval -- **Scored Quality Gate**: Dimension-level scoring, auto-correction (max 3 rounds) +- **Dual Scored Quality Gate**: Both reviewers must score 10 on all dimensions — iterate until pass, no round limit - **Optional Web Research**: Triggered when requirements depend on external info ## When to Use diff --git a/codex_skills/all-plan/SKILL.md b/codex_skills/all-plan/SKILL.md index a8556193..c9c9a6b7 100644 --- a/codex_skills/all-plan/SKILL.md +++ b/codex_skills/all-plan/SKILL.md @@ -2,7 +2,7 @@ name: all-plan description: Collaborative planning using abstract roles (designer + inspiration + reviewer). metadata: - short-description: designer plans + inspiration brainstorms + reviewer scores + short-description: designer plans + inspiration challenges/brainstorms + dual reviewer scores (10/10) --- # All Plan (Codex Version) @@ -11,9 +11,9 @@ Collaborative planning using abstract roles defined in CLAUDE.md Role Assignment Highlights: - 5-Dimension requirement clarification (retained) -- `inspiration` brainstorming for creative/aesthetic ideas +- `inspiration` provides task-conditioned input: architectural challenge (default) or creative brainstorming (UI/UX/naming/ideation) - `designer` creates the full plan independently -- `reviewer` scores the plan using Rubric A (must pass >= 7.0) -- Auto-correction loop (max 3 rounds) +- `reviewer` (both `claude-sonnet` and `codex`) scores the plan — all dimensions must reach 10 +- Iterate until 10/10 — no round limit For full instructions, see `references/flow.md` diff --git a/codex_skills/all-plan/references/flow.md b/codex_skills/all-plan/references/flow.md index 87975a7e..b464b07c 100644 --- a/codex_skills/all-plan/references/flow.md +++ b/codex_skills/all-plan/references/flow.md @@ -6,8 +6,8 @@ Planning skill using abstract roles defined in CLAUDE.md Role Assignment table. **Roles used by this skill** (resolve to providers via CLAUDE.md `CCB_ROLES`): - `designer` — Primary planner, owns the plan from start to finish -- `inspiration` — Creative brainstorming consultant (unreliable, use with judgment) -- `reviewer` — Scored quality gate, evaluates the plan using Rubric A (must pass >= 7.0) +- `inspiration` — Task-conditioned second perspective: architectural challenge (default) or creative brainstorming (for UI/UX/naming/ideation tasks). Advisory — the `designer` synthesizes input and makes the final call. If unreachable, surface visibly ("Gemini unavailable — proceeding without inspiration input") and proceed. +- `reviewer` — Dual scored quality gate (`claude-sonnet` + `codex`). Both score using Rubric A — all dimensions must reach 10. Iterate until pass, no round limit. --- @@ -211,13 +211,32 @@ Save as `design_brief`. --- -### Phase 2: Inspiration Brainstorming +### Phase 2: Inspiration Consultation -Send the design brief to `inspiration` for creative input. The `inspiration` provider excels at divergent thinking, aesthetic ideas, and unconventional approaches — but is often unreliable, so treat all output as **reference only**. +Send the design brief to `inspiration` for task-conditioned input. The prompt varies by task type. **2.1 Request Inspiration** -Send to `inspiration` (via `/ask`): +Send to `inspiration` (via `/ask gemini`). Choose the prompt based on task type: + +**For architecture/planning tasks (default):** + +``` +You are an architectural reviewer and second perspective. Based on this design brief, challenge the assumptions and propose alternatives — not a full implementation plan. + +DESIGN BRIEF: +[design_brief] + +Provide: +1) 2-3 assumptions in this brief that should be stress-tested +2) 1-2 alternative architectural approaches worth considering +3) Risks or failure modes the brief may have missed +4) Trade-offs the designer should explicitly decide on + +Be direct and critical. Your role is to strengthen the plan by challenging it. +``` + +**For UI/UX, naming, copy, or ideation tasks:** ``` You are a creative brainstorming partner. Based on this design brief, provide INSPIRATION and CREATIVE IDEAS — not a full implementation plan. @@ -235,6 +254,8 @@ Provide: Be bold and creative. Practical feasibility is secondary — inspiration is the goal. ``` +**If `inspiration` is unreachable:** Surface this visibly ("Gemini unavailable — proceeding without inspiration input") and skip to Phase 3. + Save response as `inspiration_response`. **2.2 The `designer` Filters Inspiration Ideas** @@ -314,11 +335,11 @@ Save as `plan_draft_v1`. ### Phase 4: Scored Review -Submit the plan to `reviewer` for scored review using Rubric A (defined in CLAUDE.md). +Submit the plan to BOTH reviewers for scored review using Rubric A (defined in AGENTS.md). **4.1 Submit Plan for Review** -Send to `reviewer` (via `/ask`): +Send to BOTH reviewers (via `/ask claude-sonnet` AND `/ask codex`): ``` [PLAN REVIEW REQUEST] @@ -370,31 +391,30 @@ Return your response as JSON with this exact structure: **4.2 Parse and Judge** -After receiving the `reviewer`'s JSON response: +After receiving BOTH reviewers' JSON responses: ``` iteration = 1 CHECK: - - If overall >= 7.0 AND no single dimension score <= 3 → PASS + - If BOTH reviewers score 10 on ALL dimensions → PASS - Otherwise → FAIL ``` -**4.3 Auto-Correction Loop (on FAIL)** +**4.3 Iteration Loop (on FAIL)** ``` -WHILE result == FAIL AND iteration <= 3: - 1. Read each dimension's weaknesses and fix suggestions - 2. Read critical_issues list - 3. Revise plan_draft to address ALL issues +WHILE result == FAIL: + 1. Read ALL feedback from BOTH reviewers (scores, weaknesses, fix suggestions) + 2. Read critical_issues lists from both + 3. Revise plan_draft to address ALL issues from BOTH reviewers 4. Save as plan_draft_v{iteration+1} - 5. Re-submit to `reviewer` via /ask (same template) + 5. Re-submit to BOTH reviewers via /ask (include: the revised plan, ALL prior feedback from BOTH reviewers, and what was changed in response) 6. iteration += 1 7. Re-check PASS/FAIL -IF iteration > 3 AND still FAIL: - Present all review rounds to user - Ask: "Review did not pass after 3 rounds. How would you like to proceed?" +No round limit — iterate until 10/10 is reached. +Exception: if one reviewer is unreachable after reasonable retry, proceed with the available reviewer's scores and flag the gap to the user. ``` **4.4 Display Score Summary (on PASS)** @@ -402,15 +422,23 @@ IF iteration > 3 AND still FAIL: ``` REVIEW: PASSED (Round [N]) ================================= -| Dimension | Score | Weight | Weighted | -|-----------------------|-------|--------|----------| -| Clarity | X/10 | 20% | X.XX | -| Completeness | X/10 | 25% | X.XX | -| Feasibility | X/10 | 25% | X.XX | -| Risk Assessment | X/10 | 15% | X.XX | -| Requirement Alignment | X/10 | 15% | X.XX | -|-----------------------|-------|--------|----------| -| OVERALL | | | X.XX/10 | +Reviewer: claude-sonnet +| Dimension | Score | +|-----------------------|-------| +| Clarity | 10/10 | +| Completeness | 10/10 | +| Feasibility | 10/10 | +| Risk Assessment | 10/10 | +| Requirement Alignment | 10/10 | + +Reviewer: codex +| Dimension | Score | +|-----------------------|-------| +| Clarity | 10/10 | +| Completeness | 10/10 | +| Feasibility | 10/10 | +| Risk Assessment | 10/10 | +| Requirement Alignment | 10/10 | Key Strengths: - [from `reviewer` response] @@ -442,7 +470,7 @@ Use this template: **Readiness Score**: [X]/100 -**Review Score**: [X.XX]/10 (passed round [N]) +**Review**: Passed round [N] (all dimensions 10/10 from both reviewers) **Generated**: [Date] ``` @@ -543,16 +571,15 @@ Finish the plan document with credits and appendix: ## Review Summary -| Dimension | Score | -|-----------|-------| -| Clarity | X/10 | -| Completeness | X/10 | -| Feasibility | X/10 | -| Risk Assessment | X/10 | -| Requirement Alignment | X/10 | -| **Overall** | **X.XX/10** | +| Dimension | claude-sonnet | codex | +|-----------|-------|-------| +| Clarity | X/10 | X/10 | +| Completeness | X/10 | X/10 | +| Feasibility | X/10 | X/10 | +| Risk Assessment | X/10 | X/10 | +| Requirement Alignment | X/10 | X/10 | -Review rounds: [N] +Review rounds: [N] | Pass: all dimensions 10/10 from both reviewers --- @@ -583,7 +610,7 @@ Summary: - Steps: [N] implementation steps - Risks: [N] identified with mitigations - Readiness: [X]/100 -- Review Score: [X.XX]/10 (round [N]) +- Review: Passed round [N] (all dimensions 10/10 from both reviewers) - Inspiration Ideas: [N] adopted, [N] adapted, [N] discarded Next: Review the plan and proceed with implementation when ready. @@ -596,11 +623,11 @@ Next: Review the plan and proceed with implementation when ready. 1. **`designer` Owns the Design**: The `designer` is the sole planner; `inspiration` and `reviewer` are consultants 2. **Structured Clarification**: Use option-based questions to systematically capture requirements 3. **Readiness Scoring**: Quantify requirement completeness before proceeding -4. **`inspiration` for Ideas Only**: Leverage creativity but never blindly follow it +4. **`inspiration` is Advisory**: Task-conditioned input (architectural challenge or creative brainstorming) — the `designer` synthesizes and decides 5. **User Controls Inspiration**: User decides which ideas to adopt/discard -6. **`reviewer` as Quality Gate**: Plan must pass Rubric A (>= 7.0) before proceeding -7. **Dimension-Level Feedback**: The `reviewer` scores each dimension individually with actionable fixes -8. **Auto-Correction with Limits**: Max 3 review rounds; escalate to user if still failing +6. **Dual `reviewer` Quality Gate**: Plan must pass Rubric A (all dimensions = 10 from BOTH reviewers) before proceeding +7. **Dimension-Level Feedback**: Both reviewers score each dimension individually with actionable fixes +8. **Iterate Until Pass**: No round limit — shared context ensures reviewers see each other's feedback 9. **Concrete Deliverables**: Output actionable plan document, not just discussion notes 10. **Research When Needed**: Use WebSearch for external knowledge when applicable @@ -610,7 +637,8 @@ Next: Review the plan and proceed with implementation when ready. - This skill is designed for complex features or architectural decisions - For simple tasks, use direct implementation instead -- Resolve `inspiration` and `reviewer` to providers via CLAUDE.md Role Assignment, then use `/ask ` -- If `inspiration` provider is not available, skip Phase 2 and proceed directly to Phase 3 -- If `reviewer` provider is not available, skip Phase 4 and present the plan directly to user +- Resolve `inspiration` to provider via CLAUDE.md Role Assignment (`/ask gemini`); resolve `reviewer` to BOTH providers (`/ask claude-sonnet` AND `/ask codex`) +- If `inspiration` provider is unreachable, surface visibly ("Gemini unavailable — proceeding without inspiration input") and skip to Phase 3 +- If one reviewer is unreachable, proceed with the available reviewer and flag the gap to user +- If both reviewers are unavailable, present the plan directly to user - Plans are saved to `plans/` directory with descriptive filenames diff --git a/config/agents-md-ccb.md b/config/agents-md-ccb.md index 8af9efba..8dbb6dfd 100644 --- a/config/agents-md-ccb.md +++ b/config/agents-md-ccb.md @@ -4,45 +4,45 @@ Abstract roles map to concrete AI providers. Skills reference roles, not providers directly. | Role | Provider | Description | -|------|----------|-------------| -| `designer` | `claude` | Primary planner and architect — owns plans and designs | -| `inspiration` | `gemini` | Creative brainstorming — provides ideas as reference only (unreliable, never blindly follow) | -| `reviewer` | `codex` | Scored quality gate — evaluates plans/code using Rubrics | -| `executor` | `claude` | Code implementation — writes and modifies code | +|---|---|---| +| `designer` | `claude-opus` | Primary planner and architect — owns plans and designs | +| `inspiration` | `gemini` | Task-conditioned second perspective — architectural challenge (default) or creative brainstorming (for UI/UX/naming/ideation tasks) | +| `reviewer` | `claude-sonnet`, `codex` | Both review and evaluate — all dimensions must score 10 | +| `executor` | `claude-opus` | Code implementation — writes and modifies code | To change a role assignment, edit the Provider column above. -When a skill references a role (e.g. `reviewer`), resolve it to the provider listed here. +When a skill references a role (e.g. `reviewer`), resolve it to BOTH providers listed (send to each via `/ask`). ## Review Rubrics & Templates -When you (Codex) receive a review request from the `designer`, use these rubrics to score. +When you receive a review request from the `designer`, use these rubrics to score each dimension individually. ### Rubric A: Plan Review (5 dimensions, each 1-10) -| # | Dimension | Weight | What to evaluate | -|---|-----------------------|--------|-------------------------------------------------------------------| -| 1 | Clarity | 20% | Unambiguous steps; another developer can follow without questions | -| 2 | Completeness | 25% | All requirements, edge cases, and deliverables covered | -| 3 | Feasibility | 25% | Steps achievable with current codebase and dependencies | -| 4 | Risk Assessment | 15% | Risks identified with concrete mitigations | -| 5 | Requirement Alignment | 15% | Every step traces to a stated requirement; no scope creep | +| # | Dimension | What to evaluate | +|---|---|---| +| 1 | Clarity | Unambiguous steps; another developer can follow without questions | +| 2 | Completeness | All requirements, edge cases, and deliverables covered | +| 3 | Feasibility | Steps achievable with current codebase and dependencies | +| 4 | Risk Assessment | Risks identified with concrete mitigations | +| 5 | Requirement Alignment | Every step traces to a stated requirement; no scope creep | -**Overall Plan Score** = ClarityƗ0.20 + CompletenessƗ0.25 + FeasibilityƗ0.25 + RiskƗ0.15 + AlignmentƗ0.15 +**Pass**: all 5 dimensions = 10. No weighted average — every dimension must independently reach 10. ### Rubric B: Code Review (6 dimensions, each 1-10) -| # | Dimension | Weight | What to evaluate | -|---|------------------|--------|-----------------------------------------------------------------| -| 1 | Correctness | 25% | Code does what the plan specified; no logic bugs | -| 2 | Security | 15% | No injection, no hardcoded secrets, proper input validation | -| 3 | Maintainability | 20% | Clean code, good naming, follows project conventions | -| 4 | Performance | 10% | No unnecessary O(n²), no blocking calls, efficient resource use | -| 5 | Test Coverage | 15% | New/changed paths covered by tests; tests pass | -| 6 | Plan Adherence | 15% | Implementation matches the approved plan | +| # | Dimension | What to evaluate | +|---|---|---| +| 1 | Correctness | Code does what the plan specified; no logic bugs | +| 2 | Security | No injection, no hardcoded secrets, proper input validation | +| 3 | Maintainability | Clean code, good naming, follows project conventions | +| 4 | Performance | No unnecessary O(n²), no blocking calls, efficient resource use | +| 5 | Test Coverage | New/changed paths covered by tests; tests pass | +| 6 | Plan Adherence | Implementation matches the approved plan | -**Overall Code Score** = CorrectnessƗ0.25 + SecurityƗ0.15 + MaintainabilityƗ0.20 + PerformanceƗ0.10 + TestCoverageƗ0.15 + PlanAdherenceƗ0.15 +**Pass**: all 6 dimensions = 10. No weighted average — every dimension must independently reach 10. ### Response Format diff --git a/config/claude-md-ccb.md b/config/claude-md-ccb.md index 236b6429..cea48412 100644 --- a/config/claude-md-ccb.md +++ b/config/claude-md-ccb.md @@ -22,36 +22,56 @@ This rule applies unconditionally. Violating it causes duplicate requests and wa Abstract roles map to concrete AI providers. Skills reference roles, not providers directly. | Role | Provider | Description | -|------|----------|-------------| -| `designer` | `claude` | Primary planner and architect — owns plans and designs | -| `inspiration` | `gemini` | Creative brainstorming — provides ideas as reference only (unreliable, never blindly follow) | -| `reviewer` | `codex` | Scored quality gate — evaluates plans/code using Rubrics | -| `executor` | `claude` | Code implementation — writes and modifies code | +|---|---|---| +| `designer` | `claude-opus` | Primary planner and architect — owns plans and designs | +| `inspiration` | `gemini` | Task-conditioned second perspective — architectural challenge (default) or creative brainstorming (for UI/UX/naming/ideation tasks) | +| `reviewer` | `claude-sonnet`, `codex` | Both review and evaluate — all dimensions must score 10 | +| `executor` | `claude-opus` | Code implementation — writes and modifies code | To change a role assignment, edit the Provider column above. -When a skill references a role (e.g. `reviewer`), resolve it to the provider listed here (e.g. `/ask codex`). +When a skill references a role (e.g. `reviewer`), resolve it to BOTH providers listed (send to each via `/ask`). ## Peer Review Framework -The `designer` MUST send to `reviewer` (via `/ask`) at two checkpoints: -1. **Plan Review** — after finalizing a plan, BEFORE writing code. Tag: `[PLAN REVIEW REQUEST]`. -2. **Code Review** — after completing code changes, BEFORE reporting done. Tag: `[CODE REVIEW REQUEST]`. +The workflow has two checkpoints, each with a distinct pass action: + +1. **Plan Review** — the `designer` finalizes a plan, sends to BOTH reviewers. Tag: `[PLAN REVIEW REQUEST]`. + - **On pass**: display scores, then the `executor` implements the plan immediately. +2. **Code Review** — after the `executor` completes implementation, the `designer` sends changes to BOTH reviewers. Tag: `[CODE REVIEW REQUEST]`. + - **On pass**: display scores, then report completion to the user. Include the full plan or `git diff` between `--- PLAN START/END ---` or `--- CHANGES START/END ---` delimiters. -The `reviewer` scores using Rubrics defined in `AGENTS.md` and returns JSON. +Send to both via `ask claude-sonnet` and `ask codex`. Both score using rubrics defined in `AGENTS.md` and return JSON. + +**Pass criteria**: BOTH reviewers score 10 on ALL dimensions. This is intentionally strict — iteration is the mechanism, not a flaw. +**On fail**: fix all issues from both responses, re-submit to both. Repeat until 10/10 is reached — no round limit. + +### Shared Context Rule + +On every review submission (including the first), include in the message to EACH reviewer: +1. The plan or diff being reviewed +2. ALL prior feedback from BOTH reviewers (scores, weaknesses, and fix suggestions from every previous round) +3. What was changed in response to that feedback -**Pass criteria**: overall >= 7.0 AND no single dimension <= 3. -**On fail**: fix issues from response, re-submit (max 3 rounds). After 3 failures, present results to user. -**On pass**: display final scores as a summary table. +This ensures every reviewer has full visibility into the other's critiques. No reviewer should operate in isolation. + +**Exception — provider failure**: if one reviewer is unreachable after reasonable retry, proceed with the available reviewer's scores and flag the gap to the user. ## Inspiration Consultation -For creative tasks (UI/UX design, copywriting, naming, brainstorming), the `designer` SHOULD consult `inspiration` (via `/ask`) for reference ideas. -The `inspiration` provider is often unreliable — never blindly follow. Exercise independent judgment and present suggestions to the user for decision. +The `designer` SHOULD consult `inspiration` (via `/ask gemini`) based on task type: + +- **Architecture/planning tasks** (default): request assumption stress-testing, alternative designs, and architectural challenge +- **UI/UX, naming, copy, ideation tasks**: request creative brainstorming and option generation +- **Ambiguous task type**: default to challenge-first + +The `inspiration` provider's input is advisory — the `designer` synthesizes it and makes the final call. Present suggestions to the user for decision. + +If the `inspiration` provider is unreachable, surface this visibly ("Gemini unavailable — proceeding without inspiration input") and proceed. Do not fail silently. diff --git a/config/clinerules-ccb.md b/config/clinerules-ccb.md index 5a3df7df..a1c4d089 100644 --- a/config/clinerules-ccb.md +++ b/config/clinerules-ccb.md @@ -4,12 +4,12 @@ Abstract roles map to concrete AI providers. Skills reference roles, not providers directly. | Role | Provider | Description | -|------|----------|-------------| -| `designer` | `claude` | Primary planner and architect — owns plans and designs | -| `inspiration` | `gemini` | Creative brainstorming — provides ideas as reference only (unreliable, never blindly follow) | -| `reviewer` | `codex` | Scored quality gate — evaluates plans/code using Rubrics | -| `executor` | `claude` | Code implementation — writes and modifies code | +|---|---|---| +| `designer` | `claude-opus` | Primary planner and architect — owns plans and designs | +| `inspiration` | `gemini` | Task-conditioned second perspective — architectural challenge (default) or creative brainstorming (for UI/UX/naming/ideation tasks) | +| `reviewer` | `claude-sonnet`, `codex` | Both review and evaluate — all dimensions must score 10 | +| `executor` | `claude-opus` | Code implementation — writes and modifies code | To change a role assignment, edit the Provider column above. -When a skill references a role (e.g. `reviewer`), resolve it to the provider listed here. +When a skill references a role (e.g. `reviewer`), resolve it to BOTH providers listed (send to each via `/ask`). diff --git a/droid_skills/all-plan/README.md b/droid_skills/all-plan/README.md index 3da7a9dd..987fc301 100644 --- a/droid_skills/all-plan/README.md +++ b/droid_skills/all-plan/README.md @@ -18,9 +18,9 @@ Example: **5-Phase Design Process:** 1. **Requirement Clarification** - 5-Dimension readiness model, structured Q&A -2. **Inspiration Brainstorming** - Creative ideas from `inspiration` (reference only) +2. **Inspiration Consultation** - Task-conditioned input from `inspiration`: architectural challenge (default) or creative brainstorming (UI/UX/naming/ideation) 3. **Design** - `designer` creates the full plan, integrating adopted ideas -4. **Scored Review** - `reviewer` scores using Rubric A (must pass >= 7.0) +4. **Dual Scored Review** - Both reviewers (`claude-sonnet` + `codex`) score using Rubric A — all dimensions must reach 10 5. **Final Output** - Actionable plan saved to `plans/` directory ## Roles Used @@ -28,8 +28,8 @@ Example: | Role | Responsibility | |------|---------------| | `designer` | Primary planner, owns the plan | -| `inspiration` | Creative consultant (unreliable, user decides) | -| `reviewer` | Quality gate (Rubric A, per-dimension scoring) | +| `inspiration` | Task-conditioned second perspective (architectural challenge or creative brainstorming) | +| `reviewer` | Dual quality gate — both `claude-sonnet` and `codex` score (all dimensions must reach 10) | Roles resolve to providers via CLAUDE.md `CCB_ROLES` table. @@ -37,7 +37,7 @@ Roles resolve to providers via CLAUDE.md `CCB_ROLES` table. - **Structured Clarification**: 5-Dimension readiness scoring (100 pts) - **Inspiration Filter**: Adopt / Adapt / Discard with user approval -- **Scored Quality Gate**: Dimension-level scoring, auto-correction (max 3 rounds) +- **Dual Scored Quality Gate**: Both reviewers must score 10 on all dimensions — iterate until pass, no round limit - **Optional Web Research**: Triggered when requirements depend on external info ## When to Use diff --git a/droid_skills/all-plan/SKILL.md b/droid_skills/all-plan/SKILL.md index 71a0f8f5..0a905e81 100644 --- a/droid_skills/all-plan/SKILL.md +++ b/droid_skills/all-plan/SKILL.md @@ -2,7 +2,7 @@ name: all-plan description: Collaborative planning using abstract roles (designer + inspiration + reviewer). metadata: - short-description: designer plans + inspiration brainstorms + reviewer scores + short-description: designer plans + inspiration challenges/brainstorms + dual reviewer scores (10/10) --- # All Plan (Droid Version) @@ -11,9 +11,9 @@ Collaborative planning using abstract roles defined in CLAUDE.md Role Assignment Highlights: - 5-Dimension requirement clarification (retained) -- `inspiration` brainstorming for creative/aesthetic ideas +- `inspiration` provides task-conditioned input: architectural challenge (default) or creative brainstorming (UI/UX/naming/ideation) - `designer` creates the full plan independently -- `reviewer` scores the plan using Rubric A (must pass >= 7.0) -- Auto-correction loop (max 3 rounds) +- `reviewer` (both `claude-sonnet` and `codex`) scores the plan — all dimensions must reach 10 +- Iterate until 10/10 — no round limit For full instructions, see `references/flow.md` diff --git a/droid_skills/all-plan/references/flow.md b/droid_skills/all-plan/references/flow.md index 3ec84d9b..5f1641a8 100644 --- a/droid_skills/all-plan/references/flow.md +++ b/droid_skills/all-plan/references/flow.md @@ -6,8 +6,8 @@ Planning skill using abstract roles defined in CLAUDE.md Role Assignment table. **Roles used by this skill** (resolve to providers via CLAUDE.md `CCB_ROLES`): - `designer` — Primary planner, owns the plan from start to finish -- `inspiration` — Creative brainstorming consultant (unreliable, use with judgment) -- `reviewer` — Scored quality gate, evaluates the plan using Rubric A (must pass >= 7.0) +- `inspiration` — Task-conditioned second perspective: architectural challenge (default) or creative brainstorming (for UI/UX/naming/ideation tasks). Advisory — the `designer` synthesizes input and makes the final call. If unreachable, surface visibly ("Gemini unavailable — proceeding without inspiration input") and proceed. +- `reviewer` — Dual scored quality gate (`claude-sonnet` + `codex`). Both score using Rubric A — all dimensions must reach 10. Iterate until pass, no round limit. --- @@ -211,13 +211,32 @@ Save as `design_brief`. --- -### Phase 2: Inspiration Brainstorming +### Phase 2: Inspiration Consultation -Send the design brief to `inspiration` for creative input. The `inspiration` provider excels at divergent thinking, aesthetic ideas, and unconventional approaches — but is often unreliable, so treat all output as **reference only**. +Send the design brief to `inspiration` for task-conditioned input. The prompt varies by task type. **2.1 Request Inspiration** -Send to `inspiration` (via `/ask`): +Send to `inspiration` (via `/ask gemini`). Choose the prompt based on task type: + +**For architecture/planning tasks (default):** + +``` +You are an architectural reviewer and second perspective. Based on this design brief, challenge the assumptions and propose alternatives — not a full implementation plan. + +DESIGN BRIEF: +[design_brief] + +Provide: +1) 2-3 assumptions in this brief that should be stress-tested +2) 1-2 alternative architectural approaches worth considering +3) Risks or failure modes the brief may have missed +4) Trade-offs the designer should explicitly decide on + +Be direct and critical. Your role is to strengthen the plan by challenging it. +``` + +**For UI/UX, naming, copy, or ideation tasks:** ``` You are a creative brainstorming partner. Based on this design brief, provide INSPIRATION and CREATIVE IDEAS — not a full implementation plan. @@ -235,6 +254,8 @@ Provide: Be bold and creative. Practical feasibility is secondary — inspiration is the goal. ``` +**If `inspiration` is unreachable:** Surface this visibly ("Gemini unavailable — proceeding without inspiration input") and skip to Phase 3. + Save response as `inspiration_response`. **2.2 The `designer` Filters Inspiration Ideas** @@ -314,11 +335,11 @@ Save as `plan_draft_v1`. ### Phase 4: Scored Review -Submit the plan to `reviewer` for scored review using Rubric A (defined in CLAUDE.md). +Submit the plan to BOTH reviewers for scored review using Rubric A (defined in AGENTS.md). **4.1 Submit Plan for Review** -Send to `reviewer` (via `/ask`): +Send to BOTH reviewers (via `/ask claude-sonnet` AND `/ask codex`): ``` [PLAN REVIEW REQUEST] @@ -370,31 +391,30 @@ Return your response as JSON with this exact structure: **4.2 Parse and Judge** -After receiving the `reviewer`'s JSON response: +After receiving BOTH reviewers' JSON responses: ``` iteration = 1 CHECK: - - If overall >= 7.0 AND no single dimension score <= 3 → PASS + - If BOTH reviewers score 10 on ALL dimensions → PASS - Otherwise → FAIL ``` -**4.3 Auto-Correction Loop (on FAIL)** +**4.3 Iteration Loop (on FAIL)** ``` -WHILE result == FAIL AND iteration <= 3: - 1. Read each dimension's weaknesses and fix suggestions - 2. Read critical_issues list - 3. Revise plan_draft to address ALL issues +WHILE result == FAIL: + 1. Read ALL feedback from BOTH reviewers (scores, weaknesses, fix suggestions) + 2. Read critical_issues lists from both + 3. Revise plan_draft to address ALL issues from BOTH reviewers 4. Save as plan_draft_v{iteration+1} - 5. Re-submit to `reviewer` via /ask (same template) + 5. Re-submit to BOTH reviewers via /ask (include: the revised plan, ALL prior feedback from BOTH reviewers, and what was changed in response) 6. iteration += 1 7. Re-check PASS/FAIL -IF iteration > 3 AND still FAIL: - Present all review rounds to user - Ask: "Review did not pass after 3 rounds. How would you like to proceed?" +No round limit — iterate until 10/10 is reached. +Exception: if one reviewer is unreachable after reasonable retry, proceed with the available reviewer's scores and flag the gap to the user. ``` **4.4 Display Score Summary (on PASS)** @@ -402,15 +422,23 @@ IF iteration > 3 AND still FAIL: ``` REVIEW: PASSED (Round [N]) ================================= -| Dimension | Score | Weight | Weighted | -|-----------------------|-------|--------|----------| -| Clarity | X/10 | 20% | X.XX | -| Completeness | X/10 | 25% | X.XX | -| Feasibility | X/10 | 25% | X.XX | -| Risk Assessment | X/10 | 15% | X.XX | -| Requirement Alignment | X/10 | 15% | X.XX | -|-----------------------|-------|--------|----------| -| OVERALL | | | X.XX/10 | +Reviewer: claude-sonnet +| Dimension | Score | +|-----------------------|-------| +| Clarity | 10/10 | +| Completeness | 10/10 | +| Feasibility | 10/10 | +| Risk Assessment | 10/10 | +| Requirement Alignment | 10/10 | + +Reviewer: codex +| Dimension | Score | +|-----------------------|-------| +| Clarity | 10/10 | +| Completeness | 10/10 | +| Feasibility | 10/10 | +| Risk Assessment | 10/10 | +| Requirement Alignment | 10/10 | Key Strengths: - [from `reviewer` response] @@ -442,7 +470,7 @@ Use this template: **Readiness Score**: [X]/100 -**Review Score**: [X.XX]/10 (passed round [N]) +**Review**: Passed round [N] (all dimensions 10/10 from both reviewers) **Generated**: [Date] ``` @@ -543,16 +571,15 @@ Finish the plan document with credits and appendix: ## Review Summary -| Dimension | Score | -|-----------|-------| -| Clarity | X/10 | -| Completeness | X/10 | -| Feasibility | X/10 | -| Risk Assessment | X/10 | -| Requirement Alignment | X/10 | -| **Overall** | **X.XX/10** | +| Dimension | claude-sonnet | codex | +|-----------|-------|-------| +| Clarity | X/10 | X/10 | +| Completeness | X/10 | X/10 | +| Feasibility | X/10 | X/10 | +| Risk Assessment | X/10 | X/10 | +| Requirement Alignment | X/10 | X/10 | -Review rounds: [N] +Review rounds: [N] | Pass: all dimensions 10/10 from both reviewers --- @@ -583,7 +610,7 @@ Summary: - Steps: [N] implementation steps - Risks: [N] identified with mitigations - Readiness: [X]/100 -- Review Score: [X.XX]/10 (round [N]) +- Review: Passed round [N] (all dimensions 10/10 from both reviewers) - Inspiration Ideas: [N] adopted, [N] adapted, [N] discarded Next: Review the plan and proceed with implementation when ready. @@ -596,11 +623,11 @@ Next: Review the plan and proceed with implementation when ready. 1. **`designer` Owns the Design**: The `designer` is the sole planner; `inspiration` and `reviewer` are consultants 2. **Structured Clarification**: Use option-based questions to systematically capture requirements 3. **Readiness Scoring**: Quantify requirement completeness before proceeding -4. **`inspiration` for Ideas Only**: Leverage creativity but never blindly follow it +4. **`inspiration` is Advisory**: Task-conditioned input (architectural challenge or creative brainstorming) — the `designer` synthesizes and decides 5. **User Controls Inspiration**: User decides which ideas to adopt/discard -6. **`reviewer` as Quality Gate**: Plan must pass Rubric A (>= 7.0) before proceeding -7. **Dimension-Level Feedback**: The `reviewer` scores each dimension individually with actionable fixes -8. **Auto-Correction with Limits**: Max 3 review rounds; escalate to user if still failing +6. **Dual `reviewer` Quality Gate**: Plan must pass Rubric A (all dimensions = 10 from BOTH reviewers) before proceeding +7. **Dimension-Level Feedback**: Both reviewers score each dimension individually with actionable fixes +8. **Iterate Until Pass**: No round limit — shared context ensures reviewers see each other's feedback 9. **Concrete Deliverables**: Output actionable plan document, not just discussion notes 10. **Research When Needed**: Use WebSearch for external knowledge when applicable @@ -610,7 +637,8 @@ Next: Review the plan and proceed with implementation when ready. - This skill is designed for complex features or architectural decisions - For simple tasks, use direct implementation instead -- Resolve `inspiration` and `reviewer` to providers via CLAUDE.md Role Assignment, then use `/ask ` -- If `inspiration` provider is not available, skip Phase 2 and proceed directly to Phase 3 -- If `reviewer` provider is not available, skip Phase 4 and present the plan directly to user +- Resolve `inspiration` to provider via CLAUDE.md Role Assignment (`/ask gemini`); resolve `reviewer` to BOTH providers (`/ask claude-sonnet` AND `/ask codex`) +- If `inspiration` provider is unreachable, surface visibly ("Gemini unavailable — proceeding without inspiration input") and skip to Phase 3 +- If one reviewer is unreachable, proceed with the available reviewer and flag the gap to user +- If both reviewers are unavailable, present the plan directly to user - Plans are saved to `plans/` directory with descriptive filenames From 241a1251b944dfca7a06f1d2fbd23324d0852bef Mon Sep 17 00:00:00 2001 From: Carl Louie Ratcliffe Date: Fri, 3 Apr 2026 19:10:40 +0800 Subject: [PATCH 5/7] feat: add named session support and fix completion hook routing Add --session / CCB_SESSION_NAME support so multiple CCB instances can run in the same directory with instance-specific session files. Fix silent NameError in completion hook that prevented auto-delivery of provider results. Tighten glob patterns in session file lookups and cmd_kill to avoid cross-provider matches (e.g. 'claude' no longer matches 'claude-opus-session'). Known limitations (deferred): - LaskdSessionRegistry monitor uses str(work_dir) as cache key; with multiple named Claude instances, lpend may show stale data but message routing is unaffected. - Named sessions for claude-opus/claude-sonnet require unified daemon mode (default). Legacy loask/lsask wrappers do not propagate session names. - Windows: provider keys containing ':' in log filenames (pre-existing). Co-Authored-By: Claude Opus 4.6 --- bin/ask | 14 +++++- bin/askd | 6 ++- bin/ccb-completion-hook | 5 +- bin/cpend | 5 +- bin/dpend | 10 ++-- bin/gpend | 10 ++-- bin/lopend | 5 +- bin/lpend | 5 +- bin/lspend | 5 +- bin/opend | 5 +- ccb | 65 ++++++++++++++++++++++---- config/ccb-status.sh | 14 ++++-- lib/askd/adapters/claude.py | 84 +++++++++++++++++++++++++++++++--- lib/askd/adapters/codebuddy.py | 1 + lib/askd/adapters/codex.py | 1 + lib/askd/adapters/copilot.py | 1 + lib/askd/adapters/droid.py | 1 + lib/askd/adapters/gemini.py | 1 + lib/askd/adapters/opencode.py | 1 + lib/askd/adapters/qwen.py | 1 + lib/claude_session_resolver.py | 6 ++- lib/completion_hook.py | 5 ++ lib/laskd_registry.py | 57 +++++++++++++++++++++-- lib/memory/transfer.py | 6 +++ 24 files changed, 267 insertions(+), 47 deletions(-) diff --git a/bin/ask b/bin/ask index dd6913af..ce96d40d 100755 --- a/bin/ask +++ b/bin/ask @@ -48,7 +48,7 @@ from compat import read_stdin_text, setup_windows_encoding setup_windows_encoding() from cli_output import EXIT_ERROR, EXIT_OK -from providers import parse_qualified_provider +from providers import parse_qualified_provider, make_qualified_key from session_utils import find_project_session_file @@ -534,13 +534,19 @@ def main(argv: list[str]) -> int: base_provider, instance = parse_qualified_provider(raw_provider) + # When running inside a named CCB session, qualify the provider so the daemon + # routes to the session-specific provider pane and session file. + session_name = os.environ.get("CCB_SESSION_NAME", "").strip() + if not instance and session_name: + instance = session_name + if base_provider not in PROVIDER_DAEMONS: print(f"[ERROR] Unknown provider: {base_provider}", file=sys.stderr) print(f"[ERROR] Available: {', '.join(PROVIDER_DAEMONS.keys())}", file=sys.stderr) return EXIT_ERROR daemon_cmd = PROVIDER_DAEMONS[base_provider] - provider = raw_provider # keep full qualified key for daemon routing + provider = make_qualified_key(base_provider, instance) # Parse remaining arguments timeout: float = 3600.0 @@ -701,6 +707,8 @@ def main(argv: list[str]) -> int: win_pane_env_lines += f'$env:CCB_CALLER_PANE_ID = "{win_caller_pane_id}"\n' if win_caller_terminal: win_pane_env_lines += f'$env:CCB_CALLER_TERMINAL = "{win_caller_terminal}"\n' + if session_name: + win_pane_env_lines += f'$env:CCB_SESSION_NAME = "{session_name}"\n' script_content = f'''$ErrorActionPreference = "SilentlyContinue" $OutputEncoding = [System.Text.Encoding]::UTF8 @@ -755,6 +763,8 @@ exit $rc pane_env_lines += f'export CCB_CALLER_PANE_ID="{bg_pane_id}"\n' if bg_terminal: pane_env_lines += f'export CCB_CALLER_TERMINAL="{bg_terminal}"\n' + if session_name: + pane_env_lines += f'export CCB_SESSION_NAME="{session_name}"\n' quoted_status = shlex.quote(str(status_file)) quoted_ask_cmd = shlex.quote(ask_cmd) diff --git a/bin/askd b/bin/askd index 99119a65..bdd526bc 100755 --- a/bin/askd +++ b/bin/askd @@ -25,7 +25,7 @@ from askd.adapters.codex import CodexAdapter 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.claude import ClaudeAdapter, ClaudeOpusAdapter, ClaudeSonnetAdapter from askd.adapters.copilot import CopilotAdapter from askd.adapters.codebuddy import CodebuddyAdapter from askd.adapters.qwen import QwenAdapter @@ -41,7 +41,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", "copilot", "codebuddy", "qwen"] +ALL_PROVIDERS = ["codex", "gemini", "opencode", "droid", "claude", "claude-opus", "claude-sonnet", "copilot", "codebuddy", "qwen"] ADAPTER_CLASSES = { "codex": CodexAdapter, @@ -49,6 +49,8 @@ ADAPTER_CLASSES = { "opencode": OpenCodeAdapter, "droid": DroidAdapter, "claude": ClaudeAdapter, + "claude-opus": ClaudeOpusAdapter, + "claude-sonnet": ClaudeSonnetAdapter, "copilot": CopilotAdapter, "codebuddy": CodebuddyAdapter, "qwen": QwenAdapter, diff --git a/bin/ccb-completion-hook b/bin/ccb-completion-hook index fd2284e1..20b237cf 100755 --- a/bin/ccb-completion-hook +++ b/bin/ccb-completion-hook @@ -41,6 +41,7 @@ from completion_hook import ( default_reply_for_status, normalize_completion_status, ) +from providers import session_filename_for_instance from session_utils import find_project_session_file setup_windows_encoding() @@ -566,7 +567,9 @@ def main() -> int: "opencode": ".opencode-session", "droid": ".droid-session", } - session_filename = session_files.get(caller, ".claude-session") + base_session_filename = session_files.get(caller, ".claude-session") + ccb_session_name = os.environ.get("CCB_SESSION_NAME", "").strip() + session_filename = session_filename_for_instance(base_session_filename, ccb_session_name or None) work_dir = os.environ.get("CCB_WORK_DIR", "") search_paths: list[Path] = [] diff --git a/bin/cpend b/bin/cpend index 0a828a25..46f3eb7a 100755 --- a/bin/cpend +++ b/bin/cpend @@ -24,7 +24,7 @@ try: from pane_registry import load_registry_by_session_id, load_registry_by_claude_pane, load_registry_by_project_id from session_utils import find_project_session_file from askd_client import resolve_work_dir_with_registry - from providers import CASK_CLIENT_SPEC + from providers import CASK_CLIENT_SPEC, session_filename_for_instance from project_id import compute_ccb_project_id except ImportError as exc: print(f"Import failed: {exc}") @@ -42,7 +42,8 @@ def _debug(message: str) -> None: def _load_session_log_path(work_dir: Path, explicit_session_file: Path | None) -> tuple[Path | None, str | None]: """Load codex_session_path from .codex-session (or .ccb/.codex-session) if exists""" - session_file = explicit_session_file or find_project_session_file(work_dir, ".codex-session") + _sfn = session_filename_for_instance(".codex-session", os.environ.get("CCB_SESSION_NAME", "").strip() or None) + session_file = explicit_session_file or find_project_session_file(work_dir, _sfn) if not session_file: return None, None try: diff --git a/bin/dpend b/bin/dpend index b87498ff..e7a2f488 100755 --- a/bin/dpend +++ b/bin/dpend @@ -20,7 +20,7 @@ from cli_output import EXIT_ERROR, EXIT_NO_REPLY, EXIT_OK from droid_comm import DroidLogReader, read_droid_session_start from ccb_protocol import strip_trailing_markers from askd_client import resolve_work_dir_with_registry -from providers import DASK_CLIENT_SPEC +from providers import DASK_CLIENT_SPEC, session_filename_for_instance from session_utils import find_project_session_file, safe_write_session from project_id import compute_ccb_project_id from pane_registry import load_registry_by_project_id @@ -36,8 +36,12 @@ def _debug(message: str) -> None: print(f"[DEBUG] {message}", file=sys.stderr) +def _session_filename() -> str: + return session_filename_for_instance(".droid-session", os.environ.get("CCB_SESSION_NAME", "").strip() or None) + + def _load_session_path(work_dir: Path) -> Path | None: - session_file = find_project_session_file(work_dir, ".droid-session") + session_file = find_project_session_file(work_dir, _session_filename()) if not session_file: return None try: @@ -52,7 +56,7 @@ def _load_session_path(work_dir: Path) -> Path | None: def _update_session_file(work_dir: Path, actual_session: Path) -> None: - session_file = find_project_session_file(work_dir, ".droid-session") + session_file = find_project_session_file(work_dir, _session_filename()) if not session_file: return try: diff --git a/bin/gpend b/bin/gpend index da4c4a42..f91a334c 100755 --- a/bin/gpend +++ b/bin/gpend @@ -21,7 +21,7 @@ try: from cli_output import EXIT_ERROR, EXIT_NO_REPLY, EXIT_OK from gemini_comm import GeminiLogReader from askd_client import resolve_work_dir_with_registry - from providers import GASK_CLIENT_SPEC + from providers import GASK_CLIENT_SPEC, session_filename_for_instance from session_utils import find_project_session_file from project_id import compute_ccb_project_id from pane_registry import load_registry_by_project_id @@ -40,9 +40,13 @@ def _debug(message: str) -> None: print(f"[DEBUG] {message}", file=sys.stderr) +def _session_filename() -> str: + return session_filename_for_instance(".gemini-session", os.environ.get("CCB_SESSION_NAME", "").strip() or None) + + def _load_session_path(work_dir: Path) -> Path | None: """Load gemini_session_path from .gemini-session (or .ccb/.gemini-session) if exists""" - session_file = find_project_session_file(work_dir, ".gemini-session") + session_file = find_project_session_file(work_dir, _session_filename()) if not session_file: return None try: @@ -58,7 +62,7 @@ def _load_session_path(work_dir: Path) -> Path | None: def _update_session_file(work_dir: Path, actual_session: Path) -> None: """Update .gemini-session with actual session path if different""" - session_file = find_project_session_file(work_dir, ".gemini-session") + session_file = find_project_session_file(work_dir, _session_filename()) if not session_file: return try: diff --git a/bin/lopend b/bin/lopend index b9190403..d87bc367 100755 --- a/bin/lopend +++ b/bin/lopend @@ -21,7 +21,7 @@ from ccb_protocol import strip_trailing_markers from pane_registry import load_registry_by_session_id, load_registry_by_claude_pane, load_registry_by_project_id, upsert_registry from session_utils import find_project_session_file from askd_client import resolve_work_dir_with_registry -from providers import LOASK_CLIENT_SPEC as LASK_CLIENT_SPEC +from providers import LOASK_CLIENT_SPEC as LASK_CLIENT_SPEC, session_filename_for_instance from project_id import compute_ccb_project_id @@ -36,7 +36,8 @@ def _debug(message: str) -> None: def _load_session_log_path(work_dir: Path, explicit_session_file: Path | None) -> tuple[Path | None, str | None]: - session_file = explicit_session_file or find_project_session_file(work_dir, ".claude-opus-session") + _sfn = session_filename_for_instance(".claude-opus-session", os.environ.get("CCB_SESSION_NAME", "").strip() or None) + session_file = explicit_session_file or find_project_session_file(work_dir, _sfn) if not session_file: return None, None try: diff --git a/bin/lpend b/bin/lpend index a95a8d3c..7e2c3659 100755 --- a/bin/lpend +++ b/bin/lpend @@ -21,7 +21,7 @@ from ccb_protocol import strip_trailing_markers from pane_registry import load_registry_by_session_id, load_registry_by_claude_pane, load_registry_by_project_id, upsert_registry from session_utils import find_project_session_file from askd_client import resolve_work_dir_with_registry -from providers import LASK_CLIENT_SPEC +from providers import LASK_CLIENT_SPEC, session_filename_for_instance from project_id import compute_ccb_project_id @@ -36,7 +36,8 @@ def _debug(message: str) -> None: def _load_session_log_path(work_dir: Path, explicit_session_file: Path | None) -> tuple[Path | None, str | None]: - session_file = explicit_session_file or find_project_session_file(work_dir, ".claude-session") + _sfn = session_filename_for_instance(".claude-session", os.environ.get("CCB_SESSION_NAME", "").strip() or None) + session_file = explicit_session_file or find_project_session_file(work_dir, _sfn) if not session_file: return None, None try: diff --git a/bin/lspend b/bin/lspend index bafca3c4..37979993 100755 --- a/bin/lspend +++ b/bin/lspend @@ -21,7 +21,7 @@ from ccb_protocol import strip_trailing_markers from pane_registry import load_registry_by_session_id, load_registry_by_claude_pane, load_registry_by_project_id, upsert_registry from session_utils import find_project_session_file from askd_client import resolve_work_dir_with_registry -from providers import LSASK_CLIENT_SPEC as LASK_CLIENT_SPEC +from providers import LSASK_CLIENT_SPEC as LASK_CLIENT_SPEC, session_filename_for_instance from project_id import compute_ccb_project_id @@ -36,7 +36,8 @@ def _debug(message: str) -> None: def _load_session_log_path(work_dir: Path, explicit_session_file: Path | None) -> tuple[Path | None, str | None]: - session_file = explicit_session_file or find_project_session_file(work_dir, ".claude-sonnet-session") + _sfn = session_filename_for_instance(".claude-sonnet-session", os.environ.get("CCB_SESSION_NAME", "").strip() or None) + session_file = explicit_session_file or find_project_session_file(work_dir, _sfn) if not session_file: return None, None try: diff --git a/bin/opend b/bin/opend index 4bd63d88..1835def2 100755 --- a/bin/opend +++ b/bin/opend @@ -25,9 +25,12 @@ def _load_session_id_filter(work_dir: Path, explicit_session_file: Path | None) if session_file is None: try: from session_utils import find_project_session_file + from providers import session_filename_for_instance except Exception: find_project_session_file = None - session_file = find_project_session_file(work_dir, ".opencode-session") if find_project_session_file else (work_dir / ".opencode-session") + session_filename_for_instance = None + _sfn = session_filename_for_instance(".opencode-session", os.environ.get("CCB_SESSION_NAME", "").strip() or None) if session_filename_for_instance else ".opencode-session" + session_file = find_project_session_file(work_dir, _sfn) if find_project_session_file else (work_dir / _sfn) if session_file and session_file.exists(): try: diff --git a/ccb b/ccb index ef74834d..b6801388 100755 --- a/ccb +++ b/ccb @@ -40,7 +40,7 @@ from session_utils import ( ) from pane_registry import upsert_registry, load_registry_by_project_id from project_id import compute_ccb_project_id -from providers import CASK_CLIENT_SPEC, GASK_CLIENT_SPEC, OASK_CLIENT_SPEC, LASK_CLIENT_SPEC, LOASK_CLIENT_SPEC, LSASK_CLIENT_SPEC, DASK_CLIENT_SPEC +from providers import CASK_CLIENT_SPEC, GASK_CLIENT_SPEC, OASK_CLIENT_SPEC, LASK_CLIENT_SPEC, LOASK_CLIENT_SPEC, LSASK_CLIENT_SPEC, DASK_CLIENT_SPEC, session_filename_for_instance from process_lock import ProviderLock from askd_rpc import shutdown_daemon, read_state from askd_runtime import state_file_path @@ -557,10 +557,12 @@ class AILauncher: cmd_config: dict | None = None, launch_args: dict | None = None, launch_env: dict | None = None, + session_name: str = "", ): self.providers = providers or ["codex"] self.resume = resume self.auto = auto + self.session_name = (session_name or "").strip() self.cmd_config = self._normalize_cmd_config(cmd_config) self.launch_args = launch_args if isinstance(launch_args, dict) else {} self.launch_env = launch_env if isinstance(launch_env, dict) else {} @@ -572,7 +574,8 @@ class AILauncher: self.project_root = self.invocation_dir.resolve() except Exception: self.project_root = self.invocation_dir.absolute() - self.session_id = f"ai-{int(time.time())}-{os.getpid()}" + session_suffix = f"-{self.session_name}" if self.session_name else "" + self.session_id = f"ai-{int(time.time())}-{os.getpid()}{session_suffix}" self.ccb_pid = os.getpid() self.project_id = compute_ccb_project_id(self.project_root) project_hash = (self.project_id or "")[:16] or "unknown" @@ -606,6 +609,8 @@ class AILauncher: } if os.environ.get("CCB_RUN_DIR"): env["CCB_RUN_DIR"] = os.environ["CCB_RUN_DIR"] + if self.session_name: + env["CCB_SESSION_NAME"] = self.session_name return env def _provider_env_overrides(self, provider: str) -> dict: @@ -627,7 +632,7 @@ class AILauncher: def _project_session_file(self, filename: str) -> Path: cfg = self._project_config_dir() - return cfg / filename + return cfg / session_filename_for_instance(filename, self.session_name or None) def _migrate_legacy_project_files(self) -> None: """ @@ -3212,7 +3217,7 @@ class AILauncher: print(f"ā„¹ļø {t('no_claude_session')}") pane_title = f"CCB-{label}-{self.project_id[:8]}" - start_cmd = " ".join(cmd_parts) + start_cmd = " ".join(shlex.quote(p) for p in cmd_parts) full_cmd = ( _build_pane_title_cmd(pane_title) + self._build_env_prefix(env_overrides) @@ -3710,9 +3715,11 @@ def cmd_start(args): pane_id = str(entry.get("pane_id") or "").strip() return record, pane_id - # Enforce single ccb instance per directory. + # Enforce single ccb instance per directory (or per directory+session if --session is given). + session_name = (getattr(args, "session", "") or "").strip() lock_cwd = str(Path.cwd().resolve()) - ccb_lock = ProviderLock("ccb", timeout=0.1, cwd=lock_cwd) + lock_key = f"ccb-{session_name}" if session_name else "ccb" + ccb_lock = ProviderLock(lock_key, timeout=0.1, cwd=lock_cwd) if not ccb_lock.try_acquire(): pid = "" try: @@ -3793,6 +3800,7 @@ def cmd_start(args): cmd_config=cmd_config, launch_args=launch_args, launch_env=launch_env, + session_name=session_name, ) return launcher.run_up() @@ -3961,10 +3969,45 @@ def cmd_kill(args): "droid": DASK_CLIENT_SPEC, } + _variant_providers = {"claude-opus", "claude-sonnet"} + + def _safe_mtime(path: Path) -> float: + try: + return path.stat().st_mtime + except OSError: + return 0.0 + + def _collect_session_files(cfg: Path, prov: str) -> list[Path]: + """Collect default + named-instance session files for a provider.""" + if not cfg.is_dir(): + return [] + results: list[Path] = [] + default = cfg / f".{prov}-session" + if default.exists(): + results.append(default) + for p in cfg.glob(f".{prov}-*-session"): + # When killing base 'claude', skip variant-provider files + # (e.g. .claude-opus-bar-session belongs to claude-opus). + if prov not in _variant_providers and any(p.name == f".{vp}-session" or p.name.startswith(f".{vp}-") for vp in _variant_providers): + continue + if p not in results: + results.append(p) + results.sort(key=_safe_mtime, reverse=True) + return results + + cfg_dir = resolve_project_config_dir(Path.cwd()) + for provider in providers: # 1. Kill UI sessions (tmux/wezterm) - session_file = find_project_session_file(Path.cwd(), f".{provider}-session") - if session_file and session_file.exists(): + all_session_files = _collect_session_files(cfg_dir, provider) + if not all_session_files: + sf = find_project_session_file(Path.cwd(), f".{provider}-session") + if sf: + all_session_files = [sf] + killed_any = False + for session_file in all_session_files: + if not session_file.exists(): + continue try: data = json.loads(session_file.read_text(encoding="utf-8-sig")) terminal = data.get("terminal", "tmux") @@ -3988,10 +4031,11 @@ def cmd_kill(args): data["active"] = False data["ended_at"] = time.strftime("%Y-%m-%d %H:%M:%S") safe_write_session(session_file, json.dumps(data, ensure_ascii=False, indent=2)) - print(f"āœ… {provider.capitalize()} session terminated") + print(f"āœ… {provider.capitalize()} session terminated ({session_file.name})") + killed_any = True except Exception as e: print(f"āŒ {provider}: {e}") - else: + if not killed_any: print(f"ā„¹ļø {provider}: No active session file found") # 2. Kill background daemon (graceful shutdown, then force kill) @@ -4971,6 +5015,7 @@ def main(): ) start_parser.add_argument("-r", "--resume", "--restore", action="store_true", help="Resume context") start_parser.add_argument("-a", "--auto", action="store_true", help="Full auto permission mode") + start_parser.add_argument("-s", "--session", default="", help="Named session (allows multiple CCB instances in the same directory)") args = start_parser.parse_args(argv) return cmd_start(args) diff --git a/config/ccb-status.sh b/config/ccb-status.sh index 2bed1262..1bfe05bf 100755 --- a/config/ccb-status.sh +++ b/config/ccb-status.sh @@ -32,13 +32,17 @@ check_daemon() { check_session() { local name="$1" local session_file + local suffix="" + if [[ -n "$CCB_SESSION_NAME" ]]; then + suffix="-${CCB_SESSION_NAME}" + fi case "$name" in - claude) session_file="$PWD/.ccb/.claude-session" ;; - codex) session_file="$PWD/.ccb/.codex-session" ;; - gemini) session_file="$PWD/.ccb/.gemini-session" ;; - opencode) session_file="$PWD/.ccb/.opencode-session" ;; - droid) session_file="$PWD/.ccb/.droid-session" ;; + claude) session_file="$PWD/.ccb/.claude${suffix}-session" ;; + codex) session_file="$PWD/.ccb/.codex${suffix}-session" ;; + gemini) session_file="$PWD/.ccb/.gemini${suffix}-session" ;; + opencode) session_file="$PWD/.ccb/.opencode${suffix}-session" ;; + droid) session_file="$PWD/.ccb/.droid${suffix}-session" ;; esac # Backwards compatibility: legacy config dir or root-level session file. diff --git a/lib/askd/adapters/claude.py b/lib/askd/adapters/claude.py index 72aeb0c6..df9308a2 100644 --- a/lib/askd/adapters/claude.py +++ b/lib/askd/adapters/claude.py @@ -23,10 +23,11 @@ default_reply_for_status, notify_completion, ) +from claude_session_resolver import resolve_claude_session from laskd_registry import get_session_registry from laskd_protocol import extract_reply_for_req, is_done_text, wrap_claude_prompt -from laskd_session import compute_session_key, load_project_session -from providers import LASKD_SPEC +from laskd_session import ClaudeProjectSession, _ensure_work_dir_fields, compute_session_key, load_project_session +from providers import LASKD_SPEC, LOASKD_SPEC, LSASKD_SPEC from session_file_watcher import HAS_WATCHDOG from terminal import get_backend_for_session @@ -490,6 +491,10 @@ def on_stop(self) -> None: def load_session(self, work_dir: Path, instance: Optional[str] = None) -> Optional[Any]: return load_project_session(work_dir, instance) + def _load_session(self, work_dir: Path, instance: Optional[str] = None) -> Optional[Any]: + """Load session using provider-appropriate resolution. Subclasses override for variant providers.""" + return load_project_session(work_dir, instance) + def compute_session_key(self, session: Any, instance: Optional[str] = None) -> str: return compute_session_key(session, instance) if session else "claude:unknown" @@ -497,10 +502,10 @@ 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=claude req_id={task.req_id} work_dir={req.work_dir}") + _write_log(f"[INFO] start provider={self.key} req_id={task.req_id} work_dir={req.work_dir}") instance = task.request.instance - session = load_project_session(work_dir, instance) + session = self._load_session(work_dir, instance) session_key = self.compute_session_key(session, instance) if not session: @@ -561,7 +566,7 @@ def handle_task(self, task: QueuedTask) -> ProviderResult: return result def _finalize_result(self, result: ProviderResult, req: ProviderRequest, task: QueuedTask) -> None: - _write_log(f"[INFO] done provider=claude req_id={result.req_id} exit={result.exit_code}") + _write_log(f"[INFO] done provider={self.key} req_id={result.req_id} exit={result.exit_code}") reply_for_hook = result.reply status = result.status or (COMPLETION_STATUS_COMPLETED if result.done_seen else COMPLETION_STATUS_INCOMPLETE) @@ -576,7 +581,7 @@ def _finalize_result(self, result: ProviderResult, req: ProviderRequest, task: Q f"done_seen={result.done_seen} email_req_id={req.email_req_id}" ) notify_completion( - provider="claude", + provider=self.key, output_file=req.output_path, reply=reply_for_hook, req_id=result.req_id, @@ -589,6 +594,7 @@ def _finalize_result(self, result: ProviderResult, req: ProviderRequest, task: Q work_dir=req.work_dir, caller_pane_id=req.caller_pane_id, caller_terminal=req.caller_terminal, + session_name=req.instance or "", ) def _postprocess_reply(self, req: ProviderRequest, reply: str) -> str: @@ -713,3 +719,69 @@ def _wait_for_response( ), ) return result + + +class ClaudeOpusAdapter(ClaudeAdapter): + """Adapter for Claude-Opus provider.""" + + @property + def key(self) -> str: + return "claude-opus" + + @property + def spec(self): + return LOASKD_SPEC + + @property + def session_filename(self) -> str: + return ".claude-opus-session" + + def load_session(self, work_dir: Path, instance: Optional[str] = None) -> Optional[Any]: + return self._load_session(work_dir, instance) + + def _load_session(self, work_dir: Path, instance: Optional[str] = None) -> Optional[Any]: + resolution = resolve_claude_session(work_dir, provider="claude-opus", instance=instance) + if not resolution or not resolution.data: + return None + data = dict(resolution.data) + session_file = resolution.session_file + if not session_file: + return None + _ensure_work_dir_fields(data, session_file=session_file, fallback_work_dir=work_dir) + return ClaudeProjectSession(session_file=session_file, data=data) + + def compute_session_key(self, session: Any, instance: Optional[str] = None) -> str: + return compute_session_key(session, instance) if session else "claude-opus:unknown" + + +class ClaudeSonnetAdapter(ClaudeAdapter): + """Adapter for Claude-Sonnet provider.""" + + @property + def key(self) -> str: + return "claude-sonnet" + + @property + def spec(self): + return LSASKD_SPEC + + @property + def session_filename(self) -> str: + return ".claude-sonnet-session" + + def load_session(self, work_dir: Path, instance: Optional[str] = None) -> Optional[Any]: + return self._load_session(work_dir, instance) + + def _load_session(self, work_dir: Path, instance: Optional[str] = None) -> Optional[Any]: + resolution = resolve_claude_session(work_dir, provider="claude-sonnet", instance=instance) + if not resolution or not resolution.data: + return None + data = dict(resolution.data) + session_file = resolution.session_file + if not session_file: + return None + _ensure_work_dir_fields(data, session_file=session_file, fallback_work_dir=work_dir) + return ClaudeProjectSession(session_file=session_file, data=data) + + def compute_session_key(self, session: Any, instance: Optional[str] = None) -> str: + return compute_session_key(session, instance) if session else "claude-sonnet:unknown" diff --git a/lib/askd/adapters/codebuddy.py b/lib/askd/adapters/codebuddy.py index d9fb9419..9f07b382 100644 --- a/lib/askd/adapters/codebuddy.py +++ b/lib/askd/adapters/codebuddy.py @@ -226,6 +226,7 @@ def handle_task(self, task: QueuedTask) -> ProviderResult: work_dir=req.work_dir, caller_pane_id=req.caller_pane_id, caller_terminal=req.caller_terminal, + session_name=req.instance or "", ) result = ProviderResult( diff --git a/lib/askd/adapters/codex.py b/lib/askd/adapters/codex.py index 18043436..feec55d9 100644 --- a/lib/askd/adapters/codex.py +++ b/lib/askd/adapters/codex.py @@ -331,6 +331,7 @@ def handle_task(self, task: QueuedTask) -> ProviderResult: work_dir=req.work_dir, caller_pane_id=req.caller_pane_id, caller_terminal=req.caller_terminal, + session_name=req.instance or "", ) return result diff --git a/lib/askd/adapters/copilot.py b/lib/askd/adapters/copilot.py index be692972..15714bdb 100644 --- a/lib/askd/adapters/copilot.py +++ b/lib/askd/adapters/copilot.py @@ -226,6 +226,7 @@ def handle_task(self, task: QueuedTask) -> ProviderResult: work_dir=req.work_dir, caller_pane_id=req.caller_pane_id, caller_terminal=req.caller_terminal, + session_name=req.instance or "", ) result = ProviderResult( diff --git a/lib/askd/adapters/droid.py b/lib/askd/adapters/droid.py index 25070233..ad91a251 100644 --- a/lib/askd/adapters/droid.py +++ b/lib/askd/adapters/droid.py @@ -226,6 +226,7 @@ def handle_task(self, task: QueuedTask) -> ProviderResult: work_dir=req.work_dir, caller_pane_id=req.caller_pane_id, caller_terminal=req.caller_terminal, + session_name=req.instance or "", ) result = ProviderResult( diff --git a/lib/askd/adapters/gemini.py b/lib/askd/adapters/gemini.py index 58ba7bd6..6fb78ee3 100644 --- a/lib/askd/adapters/gemini.py +++ b/lib/askd/adapters/gemini.py @@ -286,6 +286,7 @@ def handle_task(self, task: QueuedTask) -> ProviderResult: work_dir=req.work_dir, caller_pane_id=req.caller_pane_id, caller_terminal=req.caller_terminal, + session_name=req.instance or "", ) result = ProviderResult( diff --git a/lib/askd/adapters/opencode.py b/lib/askd/adapters/opencode.py index 3f23476f..9cd88a37 100644 --- a/lib/askd/adapters/opencode.py +++ b/lib/askd/adapters/opencode.py @@ -240,6 +240,7 @@ def _handle_task_locked(self, task: QueuedTask, session: Any, session_key: str, work_dir=req.work_dir, caller_pane_id=req.caller_pane_id, caller_terminal=req.caller_terminal, + session_name=req.instance or "", ) result = ProviderResult( diff --git a/lib/askd/adapters/qwen.py b/lib/askd/adapters/qwen.py index 63f9383d..265fc815 100644 --- a/lib/askd/adapters/qwen.py +++ b/lib/askd/adapters/qwen.py @@ -226,6 +226,7 @@ def handle_task(self, task: QueuedTask) -> ProviderResult: work_dir=req.work_dir, caller_pane_id=req.caller_pane_id, caller_terminal=req.caller_terminal, + session_name=req.instance or "", ) result = ProviderResult( diff --git a/lib/claude_session_resolver.py b/lib/claude_session_resolver.py index 063aa88b..88d5a7f8 100644 --- a/lib/claude_session_resolver.py +++ b/lib/claude_session_resolver.py @@ -9,6 +9,7 @@ from pane_registry import load_registry_by_claude_pane, load_registry_by_project_id, load_registry_by_session_id from project_id import compute_ccb_project_id +from providers import session_filename_for_instance from session_utils import find_project_session_file, resolve_project_config_dir @@ -242,14 +243,15 @@ def _load_registry_by_project_id_unfiltered(ccb_project_id: str, work_dir: Path) return best -def resolve_claude_session(work_dir: Path, provider: str = "claude") -> Optional[ClaudeSessionResolution]: +def resolve_claude_session(work_dir: Path, provider: str = "claude", instance: Optional[str] = None) -> Optional[ClaudeSessionResolution]: best_fallback: Optional[ClaudeSessionResolution] = None try: current_pid = compute_ccb_project_id(work_dir) except Exception: current_pid = "" strict_project = resolve_project_config_dir(work_dir).is_dir() - _sfn = {"claude": ".claude-session", "claude-opus": ".claude-opus-session", "claude-sonnet": ".claude-sonnet-session"}.get(provider, ".claude-session") + _base_sfn = {"claude": ".claude-session", "claude-opus": ".claude-opus-session", "claude-sonnet": ".claude-sonnet-session"}.get(provider, ".claude-session") + _sfn = session_filename_for_instance(_base_sfn, instance) allow_cross = os.environ.get("CCB_ALLOW_CROSS_PROJECT_SESSION") in ("1", "true", "yes") if not strict_project and not allow_cross: return None diff --git a/lib/completion_hook.py b/lib/completion_hook.py index 964693ae..8a7350c0 100644 --- a/lib/completion_hook.py +++ b/lib/completion_hook.py @@ -91,6 +91,7 @@ def _run_hook_async( status: str = COMPLETION_STATUS_COMPLETED, caller_pane_id: str = "", caller_terminal: str = "", + session_name: str = "", ) -> None: """Run the completion hook in a background thread.""" if not env_bool("CCB_COMPLETION_HOOK_ENABLED", True): @@ -149,6 +150,8 @@ def _run(): env["CCB_CALLER_PANE_ID"] = caller_pane_id if caller_terminal: env["CCB_CALLER_TERMINAL"] = caller_terminal + if session_name: + env["CCB_SESSION_NAME"] = session_name # Pass reply via stdin to avoid command line length limits # Use longer timeout for SMTP retries (3 retries * 8s max backoff + send time) @@ -182,6 +185,7 @@ def notify_completion( status: str | None = None, caller_pane_id: str = "", caller_terminal: str = "", + session_name: str = "", ) -> None: """ Notify the caller that a CCB delegation task has completed. @@ -214,4 +218,5 @@ def notify_completion( normalized_status, caller_pane_id, caller_terminal, + session_name, ) diff --git a/lib/laskd_registry.py b/lib/laskd_registry.py index 1a6b3364..64d4d76a 100644 --- a/lib/laskd_registry.py +++ b/lib/laskd_registry.py @@ -853,8 +853,57 @@ def _log_has_user_messages(self, log_path: Path, *, scan_lines: int = 80) -> boo return False return False - def _find_claude_session_file(self, work_dir: Path) -> Optional[Path]: - return find_project_session_file(work_dir, ".claude-session") or (resolve_project_config_dir(work_dir) / ".claude-session") + @staticmethod + def _safe_mtime(p: Path) -> float: + try: + return p.stat().st_mtime + except OSError: + return 0.0 + + def _find_claude_session_file(self, work_dir: Path, session_id: str = "") -> Optional[Path]: + """Find the session file matching session_id, checking instance variants. + + Only considers base-claude session files (.claude-session and named + instances like .claude-foo-session). Does NOT match variant-provider + files (.claude-opus-session, .claude-sonnet-session). + """ + cfg = resolve_project_config_dir(work_dir) + if not cfg.is_dir(): + return find_project_session_file(work_dir, ".claude-session") or (cfg / ".claude-session") + + # Collect the default file plus any named-instance files. + # Named instances use session_filename_for_instance which produces + # .claude--session, so we glob .claude-*-session and then + # exclude variant-provider files whose names start with a known + # variant prefix (e.g. .claude-opus*, .claude-sonnet*). + _variant_prefixes = (".claude-opus-", ".claude-opus-session", ".claude-sonnet-", ".claude-sonnet-session") + candidates = [cfg / ".claude-session"] if (cfg / ".claude-session").exists() else [] + for p in cfg.glob(".claude-*-session"): + if any(p.name == vp or p.name.startswith(vp) for vp in _variant_prefixes): + continue + if p not in candidates: + candidates.append(p) + candidates.sort(key=self._safe_mtime, reverse=True) + + if not candidates: + return find_project_session_file(work_dir, ".claude-session") or (cfg / ".claude-session") + if session_id: + for c in candidates: + try: + data = json.loads(c.read_text(encoding="utf-8", errors="replace")) + if isinstance(data, dict) and str(data.get("claude_session_id") or "").strip() == session_id: + return c + except Exception: + pass + # Fallback: most recently modified active file, or the default + for c in candidates: + try: + data = json.loads(c.read_text(encoding="utf-8", errors="replace")) + if isinstance(data, dict) and data.get("active") is not False: + return c + except Exception: + pass + return candidates[0] if candidates else (cfg / ".claude-session") def _update_session_file_direct(self, session_file: Path, log_path: Path, session_id: str) -> None: if not session_file.exists(): @@ -902,7 +951,7 @@ def _on_new_log_file_global(self, path: Path) -> None: if not session_id: return work_dir = Path(cwd) - session_file = self._find_claude_session_file(work_dir) + session_file = self._find_claude_session_file(work_dir, session_id=session_id) if session_file: self._update_session_file_direct(session_file, path, session_id) @@ -1009,7 +1058,7 @@ def _on_sessions_index(self, project_key: str, index_path: Path) -> None: if not session_path or not session_path.exists(): continue session_id = session_path.stem - session_file = self._find_claude_session_file(work_dir) + session_file = self._find_claude_session_file(work_dir, session_id=session_id) if session_file: self._update_session_file_direct(session_file, session_path, session_id) session = entry.session or load_project_session(work_dir) diff --git a/lib/memory/transfer.py b/lib/memory/transfer.py index 063d4ab7..1d2ce0c2 100644 --- a/lib/memory/transfer.py +++ b/lib/memory/transfer.py @@ -13,6 +13,9 @@ from pathlib import Path from typing import Optional +import os + +from providers import session_filename_for_instance from session_utils import find_project_session_file, legacy_project_config_dir, project_config_dir, resolve_project_config_dir from .types import ConversationEntry, TransferContext, SessionNotFoundError, SessionStats from .session_parser import ClaudeSessionParser @@ -54,6 +57,7 @@ def _load_session_data(self, provider: str) -> tuple[Optional[Path], dict]: filename = self.SOURCE_SESSION_FILES.get(provider) if not filename: return None, {} + filename = session_filename_for_instance(filename, os.environ.get("CCB_SESSION_NAME", "").strip() or None) session_file = find_project_session_file(self.work_dir, filename) if not session_file or not session_file.exists(): return None, {} @@ -68,10 +72,12 @@ def _load_session_data(self, provider: str) -> tuple[Optional[Path], dict]: def _auto_source_candidates(self) -> list[str]: candidates: list[tuple[float, str]] = [] + _inst = os.environ.get("CCB_SESSION_NAME", "").strip() or None for provider in self.DEFAULT_SOURCE_ORDER: filename = self.SOURCE_SESSION_FILES.get(provider) if not filename: continue + filename = session_filename_for_instance(filename, _inst) session_file = find_project_session_file(self.work_dir, filename) if not session_file or not session_file.exists(): continue From e27ad2a47e8bd986ed832f33337fc749c6a12273 Mon Sep 17 00:00:00 2001 From: Carl Louie Ratcliffe Date: Fri, 3 Apr 2026 19:41:12 +0800 Subject: [PATCH 6/7] fix: instance-aware registry, daemon routing, and Windows filename safety Make LaskdSessionRegistry fully instance-aware: cache key uses work_dir::instance format, all reload/monitor/watcher callbacks extract instance from the key and pass it through to load_project_session and session_filename_for_instance. Fix _UNIFIED_PROVIDER_MAP to map loask->claude-opus and lsask->claude-sonnet (was claude:opus/claude:sonnet which consumed the instance slot). Update try_daemon_request and maybe_start_daemon to use instance-qualified session filenames and provider keys. Update loask and lsask fallback paths to pass CCB_SESSION_NAME to resolve_claude_session. Relax --session-file validation to accept any instance-qualified filename matching the provider's pattern, independent of CCB_SESSION_NAME. Sanitize provider key (colon->hyphen) in all temp filenames in bin/ask for Windows compatibility. Co-Authored-By: Claude Opus 4.6 --- bin/ask | 12 +++--- bin/loask | 3 +- bin/lsask | 3 +- lib/askd_client.py | 45 +++++++++++++++------ lib/laskd_registry.py | 91 ++++++++++++++++++++++++++++--------------- 5 files changed, 103 insertions(+), 51 deletions(-) diff --git a/bin/ask b/bin/ask index ce96d40d..aabbd87b 100755 --- a/bin/ask +++ b/bin/ask @@ -650,8 +650,10 @@ def main(argv: list[str]) -> int: task_id = make_task_id() log_dir = Path(tempfile.gettempdir()) / "ccb-tasks" log_dir.mkdir(parents=True, exist_ok=True) - log_file = log_dir / f"ask-{provider}-{task_id}.log" - status_file = log_dir / f"ask-{provider}-{task_id}.status" + # Sanitize provider for filenames — colon is invalid on Windows. + _safe_provider = provider.replace(":", "-") + log_file = log_dir / f"ask-{_safe_provider}-{task_id}.log" + status_file = log_dir / f"ask-{_safe_provider}-{task_id}.status" try: log_file.touch(exist_ok=True) except Exception: @@ -681,11 +683,11 @@ def main(argv: list[str]) -> int: CREATE_NEW_PROCESS_GROUP = 0x00000200 # Write message to temp file to avoid escaping issues - msg_file = log_dir / f"ask-{provider}-{task_id}.msg" + msg_file = log_dir / f"ask-{_safe_provider}-{task_id}.msg" msg_file.write_text(message, encoding="utf-8") # Write PowerShell script - call ask --foreground to use unified daemon - script_file = log_dir / f"ask-{provider}-{task_id}.ps1" + script_file = log_dir / f"ask-{_safe_provider}-{task_id}.ps1" status_file_win = str(status_file).replace('"', '`"') log_file_win = str(log_file).replace('"', '`"') @@ -791,7 +793,7 @@ fi exit "$rc" ''' # Write script to temp file for detached execution - script_file = log_dir / f"ask-{provider}-{task_id}.sh" + script_file = log_dir / f"ask-{_safe_provider}-{task_id}.sh" script_file.write_text(bg_script, encoding="utf-8") script_file.chmod(0o755) diff --git a/bin/loask b/bin/loask index 25efbf80..278b83e7 100755 --- a/bin/loask +++ b/bin/loask @@ -150,7 +150,8 @@ def main(argv: list[str]) -> int: return exit_code # Fallback: send directly to Claude pane (works for both sync and async) - resolution = resolve_claude_session(work_dir, provider="claude-opus") + _instance = os.environ.get("CCB_SESSION_NAME", "").strip() or None + resolution = resolve_claude_session(work_dir, provider="claude-opus", instance=_instance) if resolution and resolution.data: data = dict(resolution.data) if data.get("claude_pane_id") and not data.get("pane_id"): diff --git a/bin/lsask b/bin/lsask index 2121d559..9a886474 100755 --- a/bin/lsask +++ b/bin/lsask @@ -150,7 +150,8 @@ def main(argv: list[str]) -> int: return exit_code # Fallback: send directly to Claude pane (works for both sync and async) - resolution = resolve_claude_session(work_dir, provider="claude-sonnet") + _instance = os.environ.get("CCB_SESSION_NAME", "").strip() or None + resolution = resolve_claude_session(work_dir, provider="claude-sonnet", instance=_instance) if resolution and resolution.data: data = dict(resolution.data) if data.get("claude_pane_id") and not data.get("pane_id"): diff --git a/lib/askd_client.py b/lib/askd_client.py index a1c63c19..8f2b86f6 100644 --- a/lib/askd_client.py +++ b/lib/askd_client.py @@ -11,7 +11,7 @@ from typing import Optional, Tuple from env_utils import env_bool -from providers import ProviderClientSpec +from providers import ProviderClientSpec, session_filename_for_instance from session_utils import ( CCB_PROJECT_CONFIG_DIRNAME, CCB_PROJECT_CONFIG_LEGACY_DIRNAME, @@ -23,15 +23,15 @@ # Maps protocol_prefix → unified daemon provider key. -# Entries with ":" use the instance mechanism (e.g., "claude:opus" → base="claude", instance="opus"). +# Variant providers (claude-opus, claude-sonnet) map to their own adapter keys. _UNIFIED_PROVIDER_MAP = { "cask": "codex", "gask": "gemini", "oask": "opencode", "dask": "droid", "lask": "claude", - "loask": "claude:opus", - "lsask": "claude:sonnet", + "loask": "claude-opus", + "lsask": "claude-sonnet", "hask": "copilot", "bask": "codebuddy", "qask": "qwen", @@ -72,9 +72,18 @@ def resolve_work_dir( except Exception: session_path = Path(expanded).absolute() - if session_path.name != spec.session_filename: + # Accept default and any instance-qualified session filenames. + # e.g. for spec ".claude-sonnet-session", accept ".claude-sonnet-session" + # and ".claude-sonnet--session" for any instance. + _base = spec.session_filename + _name = session_path.name + _valid = (_name == _base) + if not _valid and _base.endswith("-session"): + _prefix = _base[:-len("-session")] # e.g. ".claude-sonnet" + _valid = _name.startswith(f"{_prefix}-") and _name.endswith("-session") + if not _valid: raise ValueError( - f"Invalid session file for {spec.protocol_prefix}: expected filename {spec.session_filename}, got {session_path.name}" + f"Invalid session file for {spec.protocol_prefix}: expected {_base} or {_base[:-len('-session')]}--session, got {_name}" ) if not session_path.exists(): raise ValueError(f"Session file not found: {session_path}") @@ -119,10 +128,12 @@ def resolve_work_dir_with_registry( # Try to get work_dir from unified askd daemon state from askd_runtime import get_daemon_work_dir + _reg_inst = os.environ.get("CCB_SESSION_NAME", "").strip() or None + _reg_sfn = session_filename_for_instance(spec.session_filename, _reg_inst) daemon_work_dir = get_daemon_work_dir("askd.json") if daemon_work_dir and daemon_work_dir.exists(): try: - found = find_project_session_file(daemon_work_dir, spec.session_filename) + found = find_project_session_file(daemon_work_dir, _reg_sfn) if found: return daemon_work_dir, found except Exception: @@ -147,7 +158,7 @@ def resolve_work_dir_with_registry( wd = rec.get("work_dir") if isinstance(wd, str) and wd.strip(): try: - found = find_project_session_file(Path(wd.strip()), spec.session_filename) + found = find_project_session_file(Path(wd.strip()), _reg_sfn) except Exception: found = None if found: @@ -157,7 +168,7 @@ def resolve_work_dir_with_registry( cfg_dir = resolve_project_config_dir(Path(wd.strip())) except Exception: cfg_dir = Path(wd.strip()) / CCB_PROJECT_CONFIG_DIRNAME - session_file = str(cfg_dir / spec.session_filename) + session_file = str(cfg_dir / _reg_sfn) if session_file: try: return resolve_work_dir( @@ -205,7 +216,10 @@ def try_daemon_request( if not env_bool(spec.enabled_env, True): return None - if not find_project_session_file(work_dir, spec.session_filename): + # Check for session file, accounting for named sessions. + _inst = os.environ.get("CCB_SESSION_NAME", "").strip() or None + _sfn = session_filename_for_instance(spec.session_filename, _inst) + if not find_project_session_file(work_dir, _sfn): return None from importlib import import_module @@ -258,7 +272,11 @@ def try_daemon_request( "message": message, } if unified_provider: - payload["provider"] = unified_provider + # Qualify provider with named session instance if active. + if _inst: + payload["provider"] = f"{unified_provider}:{_inst}" + else: + payload["provider"] = unified_provider if output_path: payload["output_path"] = str(output_path) req_id = os.environ.get("CCB_REQ_ID", "").strip() @@ -304,7 +322,10 @@ def maybe_start_daemon(spec: ProviderClientSpec, work_dir: Path) -> bool: return False if not autostart_enabled(spec.autostart_env_primary, spec.autostart_env_legacy, True): return False - if not find_project_session_file(work_dir, spec.session_filename): + # Check for session file, accounting for named sessions. + _inst = os.environ.get("CCB_SESSION_NAME", "").strip() or None + _sfn = session_filename_for_instance(spec.session_filename, _inst) + if not find_project_session_file(work_dir, _sfn): return False candidates: list[str] = [] diff --git a/lib/laskd_registry.py b/lib/laskd_registry.py index 64d4d76a..60288809 100644 --- a/lib/laskd_registry.py +++ b/lib/laskd_registry.py @@ -18,6 +18,7 @@ from laskd_session import ClaudeProjectSession, load_project_session, _maybe_auto_extract_old_session from project_id import compute_ccb_project_id, normalize_work_dir +from providers import session_filename_for_instance from session_file_watcher import HAS_WATCHDOG, SessionFileWatcher from session_utils import ( CCB_PROJECT_CONFIG_DIRNAME, @@ -491,37 +492,46 @@ def stop_monitor(self) -> None: self._stop_root_watcher() self._stop_all_watchers() - def get_session(self, work_dir: Path) -> Optional[ClaudeProjectSession]: - key = str(work_dir) + @staticmethod + def _cache_key(work_dir: Path, instance: str = "") -> str: + """Cache key that differentiates named sessions in the same directory.""" + base = str(work_dir) + if instance: + return f"{base}::{instance}" + return base + + def get_session(self, work_dir: Path, instance: str = "") -> Optional[ClaudeProjectSession]: + key = self._cache_key(work_dir, instance) + _sfn = session_filename_for_instance(".claude-session", instance or None) with self._lock: entry = self._sessions.get(key) if entry: session_file = ( entry.session_file - or find_project_session_file(work_dir, ".claude-session") - or (resolve_project_config_dir(work_dir) / ".claude-session") + or find_project_session_file(work_dir, _sfn) + or (resolve_project_config_dir(work_dir) / _sfn) ) if session_file.exists(): try: current_mtime = session_file.stat().st_mtime if (not entry.session_file) or (session_file != entry.session_file) or (current_mtime != entry.file_mtime): _write_log(f"[INFO] Session file changed, reloading: {work_dir}") - entry = self._load_and_cache(work_dir) + entry = self._load_and_cache(work_dir, instance=instance) except Exception: pass if entry and entry.valid: return entry.session else: - entry = self._load_and_cache(work_dir) + entry = self._load_and_cache(work_dir, instance=instance) if entry: return entry.session return None - def register_session(self, work_dir: Path, session: ClaudeProjectSession) -> None: + def register_session(self, work_dir: Path, session: ClaudeProjectSession, instance: str = "") -> None: """Register an active session for monitoring.""" - key = str(work_dir) + key = self._cache_key(work_dir, instance) session_file = session.session_file mtime = 0.0 if session_file and session_file.exists(): @@ -544,12 +554,13 @@ def register_session(self, work_dir: Path, session: ClaudeProjectSession) -> Non self._sessions[key] = entry self._ensure_watchers_for_work_dir(work_dir, key) - def _load_and_cache(self, work_dir: Path) -> Optional[_SessionEntry]: - session = load_project_session(work_dir) + def _load_and_cache(self, work_dir: Path, instance: str = "") -> Optional[_SessionEntry]: + session = load_project_session(work_dir, instance or None) + _sfn = session_filename_for_instance(".claude-session", instance or None) session_file = ( session.session_file if session - else (find_project_session_file(work_dir, ".claude-session") or (resolve_project_config_dir(work_dir) / ".claude-session")) + else (find_project_session_file(work_dir, _sfn) or (resolve_project_config_dir(work_dir) / _sfn)) ) mtime = 0.0 if session_file.exists(): @@ -576,19 +587,19 @@ def _load_and_cache(self, work_dir: Path) -> Optional[_SessionEntry]: next_bind_refresh=0.0, bind_backoff_s=0.0, ) - self._sessions[str(work_dir)] = entry + self._sessions[self._cache_key(work_dir, instance)] = entry return entry if entry.valid else None - def invalidate(self, work_dir: Path) -> None: - key = str(work_dir) + def invalidate(self, work_dir: Path, instance: str = "") -> None: + key = self._cache_key(work_dir, instance) with self._lock: if key in self._sessions: self._sessions[key].valid = False _write_log(f"[INFO] Session invalidated: {work_dir}") self._release_watchers_for_work_dir(work_dir, key) - def remove(self, work_dir: Path) -> None: - key = str(work_dir) + def remove(self, work_dir: Path, instance: str = "") -> None: + key = self._cache_key(work_dir, instance) with self._lock: if key in self._sessions: del self._sessions[key] @@ -622,11 +633,14 @@ def _check_all_sessions(self) -> None: removed_work_dirs.append(entry.work_dir) for key in keys_to_remove: del self._sessions[key] - for work_dir in removed_work_dirs: - self._release_watchers_for_work_dir(work_dir, str(work_dir)) + for key, work_dir in zip(keys_to_remove, removed_work_dirs): + self._release_watchers_for_work_dir(work_dir, key) def _check_one(self, key: str, work_dir: Path, *, now: float, refresh_interval_s: float, scan_limit: int) -> None: - session_file = find_project_session_file(work_dir, ".claude-session") or (resolve_project_config_dir(work_dir) / ".claude-session") + # Extract instance from cache key (format: "work_dir::instance" or just "work_dir") + _instance = key.split("::", 1)[1] if "::" in key else "" + _sfn = session_filename_for_instance(".claude-session", _instance or None) + session_file = find_project_session_file(work_dir, _sfn) or (resolve_project_config_dir(work_dir) / _sfn) try: exists = session_file.exists() except Exception: @@ -655,7 +669,7 @@ def _check_one(self, key: str, work_dir: Path, *, now: float, refresh_interval_s return file_changed = bool((entry.session_file != session_file) or (entry.file_mtime != current_mtime)) if file_changed or (entry.session is None): - session = load_project_session(work_dir) + session = load_project_session(work_dir, _instance or None) entry.session = session entry.session_file = session_file entry.file_mtime = current_mtime @@ -955,15 +969,25 @@ def _on_new_log_file_global(self, path: Path) -> None: if session_file: self._update_session_file_direct(session_file, path, session_id) - key = str(work_dir) - with self._lock: - entry = self._sessions.get(key) - session = entry.session if entry else None - if session: - try: - session.update_claude_binding(session_path=path, session_id=session_id) - except Exception: - pass + # Determine instance from the matched session file name so we look up + # the correct cache entry (default or named). + _inst = "" + if session_file: + _name = session_file.name # e.g. ".claude-foo-session" + if _name.startswith(".claude-") and _name.endswith("-session") and _name != ".claude-session": + _inst = _name[len(".claude-"):-len("-session")] + # Try instance-specific key first, then fall back to default key. + for _try_inst in ([_inst, ""] if _inst else [""]): + key = self._cache_key(work_dir, _try_inst) + with self._lock: + entry = self._sessions.get(key) + session = entry.session if entry else None + if session: + try: + session.update_claude_binding(session_path=path, session_id=session_id) + except Exception: + pass + break def _on_new_log_file(self, project_key: str, path: Path) -> None: if path.name == "sessions-index.json": @@ -1003,7 +1027,8 @@ def _on_new_log_file(self, project_key: str, path: Path) -> None: for key, entry in entries: if not entry or not entry.valid: continue - session = entry.session or load_project_session(entry.work_dir) + _inst = key.split("::", 1)[1] if "::" in key else None + session = entry.session or load_project_session(entry.work_dir, _inst) if not session: continue current_path = Path(session.claude_session_path).expanduser() if session.claude_session_path else None @@ -1028,7 +1053,8 @@ def _on_new_log_file(self, project_key: str, path: Path) -> None: continue if cwd and not _path_within(cwd, str(entry.work_dir)): continue - session = entry.session or load_project_session(entry.work_dir) + _inst = key.split("::", 1)[1] if "::" in key else None + session = entry.session or load_project_session(entry.work_dir, _inst) if not session: continue try: @@ -1061,7 +1087,8 @@ def _on_sessions_index(self, project_key: str, index_path: Path) -> None: session_file = self._find_claude_session_file(work_dir, session_id=session_id) if session_file: self._update_session_file_direct(session_file, session_path, session_id) - session = entry.session or load_project_session(work_dir) + _inst = key.split("::", 1)[1] if "::" in key else None + session = entry.session or load_project_session(work_dir, _inst) if not session: continue try: From e7100b7201f299edd2614d9157d6069802018dac Mon Sep 17 00:00:00 2001 From: Carl Louie Ratcliffe Date: Fri, 3 Apr 2026 21:05:58 +0800 Subject: [PATCH 7/7] fix: session name validation, namespace safety, and watcher routing Validate --session names: only [a-zA-Z0-9][a-zA-Z0-9_-]* allowed, normalized to lowercase. Reject reserved names (opus, sonnet, opus-*, sonnet-*) that would collide with claude-opus/claude-sonnet provider session files. Fix _find_claude_session_file to search ALL claude session files (including opus/sonnet) when matching by session_id, preventing the log watcher from corrupting base claude sessions with variant session data. Fallback path remains restricted to base-claude files only. Scope --session-file validation to accept default filename plus the active CCB_SESSION_NAME-qualified filename (set-based, not broad pattern match). Restore _reg_sfn in resolve_work_dir_with_registry for correct named-session discovery via daemon state and registry. Co-Authored-By: Claude Opus 4.6 --- ccb | 15 ++++++++++++++- lib/askd_client.py | 21 +++++++++------------ lib/laskd_registry.py | 42 +++++++++++++++++++++--------------------- 3 files changed, 44 insertions(+), 34 deletions(-) diff --git a/ccb b/ccb index b6801388..d57bc4b6 100755 --- a/ccb +++ b/ccb @@ -3715,8 +3715,21 @@ def cmd_start(args): pane_id = str(entry.get("pane_id") or "").strip() return record, pane_id - # Enforce single ccb instance per directory (or per directory+session if --session is given). + # Validate and normalize session name. session_name = (getattr(args, "session", "") or "").strip() + if session_name: + import re + if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]*$', session_name): + print(f"[ERROR] Invalid session name: {session_name!r}", file=sys.stderr) + print("[ERROR] Session names must contain only letters, digits, hyphens, and underscores", file=sys.stderr) + return 1 + _reserved = {"opus", "sonnet"} + if session_name.lower() in _reserved or any(session_name.lower().startswith(f"{r}-") for r in _reserved): + print(f"[ERROR] Session name {session_name!r} is reserved (conflicts with claude-opus/claude-sonnet providers)", file=sys.stderr) + return 1 + session_name = session_name.lower() + + # Enforce single ccb instance per directory (or per directory+session if --session is given). lock_cwd = str(Path.cwd().resolve()) lock_key = f"ccb-{session_name}" if session_name else "ccb" ccb_lock = ProviderLock(lock_key, timeout=0.1, cwd=lock_cwd) diff --git a/lib/askd_client.py b/lib/askd_client.py index 8f2b86f6..ac7f8d0f 100644 --- a/lib/askd_client.py +++ b/lib/askd_client.py @@ -72,18 +72,14 @@ def resolve_work_dir( except Exception: session_path = Path(expanded).absolute() - # Accept default and any instance-qualified session filenames. - # e.g. for spec ".claude-sonnet-session", accept ".claude-sonnet-session" - # and ".claude-sonnet--session" for any instance. - _base = spec.session_filename - _name = session_path.name - _valid = (_name == _base) - if not _valid and _base.endswith("-session"): - _prefix = _base[:-len("-session")] # e.g. ".claude-sonnet" - _valid = _name.startswith(f"{_prefix}-") and _name.endswith("-session") - if not _valid: + # Accept default and CCB_SESSION_NAME-qualified filenames. + _inst = os.environ.get("CCB_SESSION_NAME", "").strip() or None + _accepted = {spec.session_filename} + if _inst: + _accepted.add(session_filename_for_instance(spec.session_filename, _inst)) + if session_path.name not in _accepted: raise ValueError( - f"Invalid session file for {spec.protocol_prefix}: expected {_base} or {_base[:-len('-session')]}--session, got {_name}" + f"Invalid session file for {spec.protocol_prefix}: expected filename {spec.session_filename}, got {session_path.name}" ) if not session_path.exists(): raise ValueError(f"Session file not found: {session_path}") @@ -126,7 +122,8 @@ def resolve_work_dir_with_registry( default_cwd=default_cwd, ) - # Try to get work_dir from unified askd daemon state + # Try to get work_dir from unified askd daemon state. + # Use instance-qualified filename when CCB_SESSION_NAME is active. from askd_runtime import get_daemon_work_dir _reg_inst = os.environ.get("CCB_SESSION_NAME", "").strip() or None _reg_sfn = session_filename_for_instance(spec.session_filename, _reg_inst) diff --git a/lib/laskd_registry.py b/lib/laskd_registry.py index 60288809..e98c1c36 100644 --- a/lib/laskd_registry.py +++ b/lib/laskd_registry.py @@ -875,49 +875,49 @@ def _safe_mtime(p: Path) -> float: return 0.0 def _find_claude_session_file(self, work_dir: Path, session_id: str = "") -> Optional[Path]: - """Find the session file matching session_id, checking instance variants. + """Find the session file matching session_id, checking all claude variants. - Only considers base-claude session files (.claude-session and named - instances like .claude-foo-session). Does NOT match variant-provider - files (.claude-opus-session, .claude-sonnet-session). + When searching by session_id, ALL claude session files are considered + (base, opus, sonnet, named instances) so that the watcher can route + log updates to the correct provider's session file. + + When falling back (no session_id match), only base-claude files are + considered to avoid cross-provider contamination. """ cfg = resolve_project_config_dir(work_dir) if not cfg.is_dir(): return find_project_session_file(work_dir, ".claude-session") or (cfg / ".claude-session") - # Collect the default file plus any named-instance files. - # Named instances use session_filename_for_instance which produces - # .claude--session, so we glob .claude-*-session and then - # exclude variant-provider files whose names start with a known - # variant prefix (e.g. .claude-opus*, .claude-sonnet*). _variant_prefixes = (".claude-opus-", ".claude-opus-session", ".claude-sonnet-", ".claude-sonnet-session") - candidates = [cfg / ".claude-session"] if (cfg / ".claude-session").exists() else [] + + # Collect ALL claude session files for session_id matching. + all_candidates = [cfg / ".claude-session"] if (cfg / ".claude-session").exists() else [] for p in cfg.glob(".claude-*-session"): - if any(p.name == vp or p.name.startswith(vp) for vp in _variant_prefixes): - continue - if p not in candidates: - candidates.append(p) - candidates.sort(key=self._safe_mtime, reverse=True) + if p not in all_candidates: + all_candidates.append(p) + all_candidates.sort(key=self._safe_mtime, reverse=True) - if not candidates: - return find_project_session_file(work_dir, ".claude-session") or (cfg / ".claude-session") if session_id: - for c in candidates: + for c in all_candidates: try: data = json.loads(c.read_text(encoding="utf-8", errors="replace")) if isinstance(data, dict) and str(data.get("claude_session_id") or "").strip() == session_id: return c except Exception: pass - # Fallback: most recently modified active file, or the default - for c in candidates: + + # Fallback: restrict to base-claude files only (exclude variant providers). + base_candidates = [c for c in all_candidates if not any(c.name == vp or c.name.startswith(vp) for vp in _variant_prefixes)] + if not base_candidates: + return find_project_session_file(work_dir, ".claude-session") or (cfg / ".claude-session") + for c in base_candidates: try: data = json.loads(c.read_text(encoding="utf-8", errors="replace")) if isinstance(data, dict) and data.get("active") is not False: return c except Exception: pass - return candidates[0] if candidates else (cfg / ".claude-session") + return base_candidates[0] if base_candidates else (cfg / ".claude-session") def _update_session_file_direct(self, session_file: Path, log_path: Path, session_id: str) -> None: if not session_file.exists():