From 1d1258de2c17d45e2da9bd25c7e18fd8c524c756 Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Wed, 18 Feb 2026 15:20:10 +0800 Subject: [PATCH 01/28] fix: detect and replace dead worker threads + add cleanup tool **Problem**: Multi-instance usage causes zombie daemons and dead worker threads - Worker threads can die (KeyboardInterrupt, memory issues, etc.) - PerSessionWorkerPool doesn't detect dead threads - Tasks enqueued to dead workers hang forever - Stale daemons accumulate over time **Solution**: 1. worker_pool.py: Check worker.is_alive() before reusing - Auto-replace dead workers with new ones - Prevents task hangs from dead threads 2. bin/ccb-cleanup: New tool for daemon management - List running daemons with status - Clean stale state files and lock files - Kill zombie daemons (parent process dead) **Usage**: ```bash ccb-cleanup --list # Show daemon status ccb-cleanup --clean # Remove stale files ccb-cleanup --kill-zombies # Kill orphaned daemons ``` **Impact**: Fixes long-running session stability issues --- bin/ccb-cleanup | 138 +++++++++++++++++++++++++++++++++++++++++++++ lib/worker_pool.py | 5 ++ 2 files changed, 143 insertions(+) create mode 100755 bin/ccb-cleanup diff --git a/bin/ccb-cleanup b/bin/ccb-cleanup new file mode 100755 index 0000000..74a1e1b --- /dev/null +++ b/bin/ccb-cleanup @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +""" +CCB Cleanup - Clean up zombie daemons and stale files +""" +import json +import os +import sys +from pathlib import Path + + +def is_pid_alive(pid: int) -> bool: + """Check if a process is alive.""" + if pid <= 0: + return False + try: + os.kill(pid, 0) + return True + except OSError: + return False + + +def cleanup_stale_state_files(): + """Remove state files for dead daemons.""" + cache_dir = Path.home() / ".cache" / "ccb" / "projects" + if not cache_dir.exists(): + return + + removed = [] + for state_file in cache_dir.glob("*/askd.json"): + try: + with open(state_file) as f: + data = json.load(f) + pid = int(data.get("pid", 0)) + if pid > 0 and not is_pid_alive(pid): + state_file.unlink() + removed.append(str(state_file)) + print(f"Removed stale state file: {state_file}") + except Exception as e: + print(f"Error processing {state_file}: {e}", file=sys.stderr) + + return removed + + +def cleanup_stale_locks(): + """Remove stale lock files.""" + run_dir = Path.home() / ".ccb" / "run" + if not run_dir.exists(): + return + + removed = [] + for lock_file in run_dir.glob("*.lock"): + try: + # Read PID from lock file + pid_str = lock_file.read_text().strip() + if not pid_str: + continue + pid = int(pid_str) + if not is_pid_alive(pid): + lock_file.unlink() + removed.append(str(lock_file)) + print(f"Removed stale lock: {lock_file.name}") + except Exception as e: + print(f"Error processing {lock_file}: {e}", file=sys.stderr) + + return removed + + +def list_running_daemons(): + """List all running askd daemons.""" + cache_dir = Path.home() / ".cache" / "ccb" / "projects" + if not cache_dir.exists(): + return [] + + daemons = [] + for state_file in cache_dir.glob("*/askd.json"): + try: + with open(state_file) as f: + data = json.load(f) + pid = int(data.get("pid", 0)) + parent_pid = int(data.get("parent_pid", 0)) + + if pid > 0 and is_pid_alive(pid): + parent_alive = is_pid_alive(parent_pid) if parent_pid > 0 else False + project_hash = state_file.parent.name + daemons.append({ + "pid": pid, + "parent_pid": parent_pid, + "parent_alive": parent_alive, + "project_hash": project_hash, + "started_at": data.get("started_at", "unknown"), + }) + except Exception: + pass + + return daemons + + +def main(): + import argparse + + parser = argparse.ArgumentParser(description="Clean up CCB zombie daemons and stale files") + parser.add_argument("--list", action="store_true", help="List running daemons") + parser.add_argument("--clean", action="store_true", help="Clean stale files") + parser.add_argument("--kill-zombies", action="store_true", help="Kill zombie daemons (parent dead)") + + args = parser.parse_args() + + if args.list or not (args.clean or args.kill_zombies): + print("=== Running askd daemons ===") + daemons = list_running_daemons() + if not daemons: + print("No running daemons found") + else: + for d in daemons: + status = "ZOMBIE (parent dead)" if not d["parent_alive"] else "OK" + print(f" PID {d['pid']} (parent {d['parent_pid']}) - {status}") + print(f" Project: {d['project_hash']}") + print(f" Started: {d['started_at']}") + + if args.clean: + print("\n=== Cleaning stale files ===") + cleanup_stale_state_files() + cleanup_stale_locks() + + if args.kill_zombies: + print("\n=== Killing zombie daemons ===") + daemons = list_running_daemons() + for d in daemons: + if not d["parent_alive"]: + try: + os.kill(d["pid"], 15) # SIGTERM + print(f"Killed zombie daemon PID {d['pid']}") + except Exception as e: + print(f"Failed to kill PID {d['pid']}: {e}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/lib/worker_pool.py b/lib/worker_pool.py index 43eb32e..c3f9802 100644 --- a/lib/worker_pool.py +++ b/lib/worker_pool.py @@ -62,6 +62,11 @@ def get_or_create(self, session_key: str, factory: Callable[[str], WorkerT]) -> created = False with self._lock: worker = self._workers.get(session_key) + # Check if worker thread is dead and needs replacement + if worker is not None and not worker.is_alive(): + # Worker thread died, remove it and create a new one + self._workers.pop(session_key, None) + worker = None if worker is None: worker = factory(session_key) self._workers[session_key] = worker From 857c3d748af4e105c17745c529277a6c4ad310b3 Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Wed, 18 Feb 2026 15:58:34 +0800 Subject: [PATCH 02/28] feat: integrate ccb-multi for true multi-instance concurrent execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **What's New**: - Multi-instance manager with concurrent LLM execution - NOT sequential (ask1→wait→done; ask2→wait→done) - BUT concurrent (ask1, ask2, ask3 → all working → all done) **Features**: 1. Multi-Instance Support - Run multiple CCB instances in same/different projects - Each instance has independent session context - Managed by single daemon for efficiency 2. Concurrent LLM Execution (VERIFIED) - Multiple AI providers work in parallel - Tested: Gemini, Codex, OpenCode all concurrent - Main LLM orchestrates, others work concurrently 3. Commands Added - ccb-multi [providers] - Start instance - ccb-multi-status - Show running instances - ccb-multi-history - View history - ccb-multi-clean - Clean up instances **Architecture**: - Single daemon per project (efficient) - Session isolation per instance - Concurrent worker pools (automatic) - Shared resources (optimized) **Usage**: ```bash # Start instances ccb-multi 1 gemini ccb-multi 2 codex # Concurrent execution within instance CCB_CALLER=claude ask gemini "task1" & CCB_CALLER=claude ask codex "task2" & wait ``` **Integration**: Copied from waoooo/ccb-multi toolkit --- README.md | 6 +- multi/README.md | 63 ++++++++++++ multi/bin/ccb-multi | 1 + multi/bin/ccb-multi-clean | 1 + multi/bin/ccb-multi-history | 1 + multi/bin/ccb-multi-status | 1 + multi/lib/cli/ccb-multi-clean.d.ts | 3 + multi/lib/cli/ccb-multi-clean.d.ts.map | 1 + multi/lib/cli/ccb-multi-clean.js | 104 ++++++++++++++++++++ multi/lib/cli/ccb-multi-clean.js.map | 1 + multi/lib/cli/ccb-multi-history.d.ts | 3 + multi/lib/cli/ccb-multi-history.d.ts.map | 1 + multi/lib/cli/ccb-multi-history.js | 99 +++++++++++++++++++ multi/lib/cli/ccb-multi-history.js.map | 1 + multi/lib/cli/ccb-multi-status.d.ts | 3 + multi/lib/cli/ccb-multi-status.d.ts.map | 1 + multi/lib/cli/ccb-multi-status.js | 56 +++++++++++ multi/lib/cli/ccb-multi-status.js.map | 1 + multi/lib/cli/ccb-multi.d.ts | 3 + multi/lib/cli/ccb-multi.d.ts.map | 1 + multi/lib/cli/ccb-multi.js | 45 +++++++++ multi/lib/cli/ccb-multi.js.map | 1 + multi/lib/instance.d.ts.map | 1 + multi/lib/instance.js | 116 +++++++++++++++++++++++ multi/lib/instance.js.map | 1 + multi/lib/types.d.ts.map | 1 + multi/lib/types.js | 3 + multi/lib/types.js.map | 1 + multi/lib/utils.d.ts.map | 1 + multi/lib/utils.js | 75 +++++++++++++++ multi/lib/utils.js.map | 1 + multi/package.json | 16 ++++ 32 files changed, 610 insertions(+), 3 deletions(-) create mode 100644 multi/README.md create mode 120000 multi/bin/ccb-multi create mode 120000 multi/bin/ccb-multi-clean create mode 120000 multi/bin/ccb-multi-history create mode 120000 multi/bin/ccb-multi-status create mode 100644 multi/lib/cli/ccb-multi-clean.d.ts create mode 100644 multi/lib/cli/ccb-multi-clean.d.ts.map create mode 100755 multi/lib/cli/ccb-multi-clean.js create mode 100644 multi/lib/cli/ccb-multi-clean.js.map create mode 100644 multi/lib/cli/ccb-multi-history.d.ts create mode 100644 multi/lib/cli/ccb-multi-history.d.ts.map create mode 100755 multi/lib/cli/ccb-multi-history.js create mode 100644 multi/lib/cli/ccb-multi-history.js.map create mode 100644 multi/lib/cli/ccb-multi-status.d.ts create mode 100644 multi/lib/cli/ccb-multi-status.d.ts.map create mode 100755 multi/lib/cli/ccb-multi-status.js create mode 100644 multi/lib/cli/ccb-multi-status.js.map create mode 100644 multi/lib/cli/ccb-multi.d.ts create mode 100644 multi/lib/cli/ccb-multi.d.ts.map create mode 100755 multi/lib/cli/ccb-multi.js create mode 100644 multi/lib/cli/ccb-multi.js.map create mode 100644 multi/lib/instance.d.ts.map create mode 100644 multi/lib/instance.js create mode 100644 multi/lib/instance.js.map create mode 100644 multi/lib/types.d.ts.map create mode 100644 multi/lib/types.js create mode 100644 multi/lib/types.js.map create mode 100644 multi/lib/utils.d.ts.map create mode 100644 multi/lib/utils.js create mode 100644 multi/lib/utils.js.map create mode 100644 multi/package.json diff --git a/README.md b/README.md index 820eba1..911a7b3 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@
-# Claude Code Bridge (ccb) v5.2.5 +# Claude Code Bridge Multi v5.2.5 -**New Multi-Model Collaboration Tool via Split-Pane Terminal** +**Enhanced Multi-Instance CCB with True Concurrent Execution** **Claude & Codex & Gemini & OpenCode & Droid** -**Ultra-low token real-time communication, unleashing full CLI power** +**Run multiple instances in parallel, multiple LLMs working concurrently**

Every Interaction Visible diff --git a/multi/README.md b/multi/README.md new file mode 100644 index 0000000..a09c4ff --- /dev/null +++ b/multi/README.md @@ -0,0 +1,63 @@ +# CCB Multi-Instance Manager + +Multi-instance support for Claude Code Bridge with true concurrent execution. + +## Features + +- **🔀 Multi-Instance Isolation**: Run multiple CCB instances in the same project with independent contexts +- **⚡ Concurrent LLM Execution**: Multiple AI providers (Claude, Codex, Gemini) work in parallel, not sequentially +- **📊 Real-time Status Monitoring**: Check all instance status with `ccb-multi-status` +- **🧹 Instance Management**: Create, list, and clean instances easily + +## Quick Start + +```bash +# Start instance 1 with Gemini +ccb-multi 1 gemini + +# Start instance 2 with Codex (in another terminal) +ccb-multi 2 codex + +# Start instance 3 with Claude (in another terminal) +ccb-multi 3 claude + +# Check status +ccb-multi-status + +# View history +ccb-multi-history + +# Clean up +ccb-multi-clean +``` + +## Concurrent Execution + +Within each instance, you can send concurrent requests to multiple LLMs: + +```bash +# In your CCB session, send async requests +CCB_CALLER=claude ask gemini "task 1" & +CCB_CALLER=claude ask codex "task 2" & +CCB_CALLER=claude ask opencode "task 3" & +wait + +# Check results +pend gemini +pend codex +pend opencode +``` + +## Architecture + +- **Single Daemon**: One daemon per project manages all instances +- **Session Isolation**: Each instance has independent session context +- **Concurrent Workers**: Different sessions execute in parallel automatically +- **Shared Resources**: Worker pool and file watchers are shared efficiently + +## Commands + +- `ccb-multi [providers...]` - Start an instance +- `ccb-multi-status` - Show all running instances +- `ccb-multi-history` - View instance history +- `ccb-multi-clean` - Clean up stale instances diff --git a/multi/bin/ccb-multi b/multi/bin/ccb-multi new file mode 120000 index 0000000..7d70f91 --- /dev/null +++ b/multi/bin/ccb-multi @@ -0,0 +1 @@ +../lib/cli/ccb-multi.js \ No newline at end of file diff --git a/multi/bin/ccb-multi-clean b/multi/bin/ccb-multi-clean new file mode 120000 index 0000000..f53fbb3 --- /dev/null +++ b/multi/bin/ccb-multi-clean @@ -0,0 +1 @@ +../lib/cli/ccb-multi-clean.js \ No newline at end of file diff --git a/multi/bin/ccb-multi-history b/multi/bin/ccb-multi-history new file mode 120000 index 0000000..a2fd1f5 --- /dev/null +++ b/multi/bin/ccb-multi-history @@ -0,0 +1 @@ +../lib/cli/ccb-multi-history.js \ No newline at end of file diff --git a/multi/bin/ccb-multi-status b/multi/bin/ccb-multi-status new file mode 120000 index 0000000..3b7f2d5 --- /dev/null +++ b/multi/bin/ccb-multi-status @@ -0,0 +1 @@ +../lib/cli/ccb-multi-status.js \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi-clean.d.ts b/multi/lib/cli/ccb-multi-clean.d.ts new file mode 100644 index 0000000..5e032d5 --- /dev/null +++ b/multi/lib/cli/ccb-multi-clean.d.ts @@ -0,0 +1,3 @@ +#!/usr/bin/env node +export {}; +//# sourceMappingURL=ccb-multi-clean.d.ts.map \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi-clean.d.ts.map b/multi/lib/cli/ccb-multi-clean.d.ts.map new file mode 100644 index 0000000..e9d22bc --- /dev/null +++ b/multi/lib/cli/ccb-multi-clean.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"ccb-multi-clean.d.ts","sourceRoot":"","sources":["../../src/cli/ccb-multi-clean.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi-clean.js b/multi/lib/cli/ccb-multi-clean.js new file mode 100755 index 0000000..7fac44f --- /dev/null +++ b/multi/lib/cli/ccb-multi-clean.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +const chalk_1 = __importDefault(require("chalk")); +const utils_1 = require("../utils"); +const commander_1 = require("commander"); +const program = new commander_1.Command(); +program + .name('ccb-multi-clean') + .description('Clean up CCB multi-instance directories') + .option('-f, --force', 'Force clean without confirmation') + .action(async (options) => { + try { + const projectInfo = (0, utils_1.getProjectInfo)(); + const instancesDir = (0, utils_1.getInstancesDir)(projectInfo.root); + console.log(''); + console.log(chalk_1.default.cyan(' ██████╗ ██████╗██████╗ ███╗ ███╗██╗ ██╗██╗ ████████╗██╗')); + console.log(chalk_1.default.cyan(' ██╔════╝██╔════╝██╔══██╗ ████╗ ████║██║ ██║██║ ╚══██╔══╝██║')); + console.log(chalk_1.default.cyan(' ██║ ██║ ██████╔╝█████╗██╔████╔██║██║ ██║██║ ██║ ██║')); + console.log(chalk_1.default.cyan(' ██║ ██║ ██╔══██╗╚════╝██║╚██╔╝██║██║ ██║██║ ██║ ██║')); + console.log(chalk_1.default.cyan(' ╚██████╗╚██████╗██████╔╝ ██║ ╚═╝ ██║╚██████╔╝███████╗██║ ██║')); + console.log(chalk_1.default.cyan(' ╚═════╝ ╚═════╝╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝')); + console.log(''); + console.log(' Multi-Instance Cleanup'); + console.log(''); + if (!fs.existsSync(instancesDir)) { + console.log(chalk_1.default.dim(' No instances directory found')); + return; + } + const instances = fs.readdirSync(instancesDir) + .filter(name => name.startsWith('instance-')); + if (instances.length === 0) { + console.log(chalk_1.default.dim(' No instances to clean')); + return; + } + console.log(chalk_1.default.dim(` Found ${instances.length} instance(s) to remove:`)); + console.log(''); + instances.forEach(name => { + console.log(chalk_1.default.dim(` ${name}`)); + }); + console.log(''); + if (!options.force) { + console.log(chalk_1.default.yellow(' Warning: This will delete all instance directories')); + console.log(chalk_1.default.dim(' (shared history will be preserved)')); + console.log(''); + console.log(chalk_1.default.dim(' Run with --force to confirm')); + console.log(''); + return; + } + // Clean up instances + for (const instance of instances) { + const instancePath = path.join(instancesDir, instance); + fs.rmSync(instancePath, { recursive: true, force: true }); + console.log(chalk_1.default.green(` ✓ Removed ${instance}`)); + } + console.log(''); + console.log(chalk_1.default.green(' Cleanup complete')); + console.log(''); + } + catch (error) { + console.error(chalk_1.default.red(' ✗ Error:'), error instanceof Error ? error.message : error); + process.exit(1); + } +}); +program.parse(); +//# sourceMappingURL=ccb-multi-clean.js.map \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi-clean.js.map b/multi/lib/cli/ccb-multi-clean.js.map new file mode 100644 index 0000000..5ead2e6 --- /dev/null +++ b/multi/lib/cli/ccb-multi-clean.js.map @@ -0,0 +1 @@ +{"version":3,"file":"ccb-multi-clean.js","sourceRoot":"","sources":["../../src/cli/ccb-multi-clean.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi-history.d.ts b/multi/lib/cli/ccb-multi-history.d.ts new file mode 100644 index 0000000..f6a2f8b --- /dev/null +++ b/multi/lib/cli/ccb-multi-history.d.ts @@ -0,0 +1,3 @@ +#!/usr/bin/env node +export {}; +//# sourceMappingURL=ccb-multi-history.d.ts.map \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi-history.d.ts.map b/multi/lib/cli/ccb-multi-history.d.ts.map new file mode 100644 index 0000000..57226fe --- /dev/null +++ b/multi/lib/cli/ccb-multi-history.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"ccb-multi-history.d.ts","sourceRoot":"","sources":["../../src/cli/ccb-multi-history.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi-history.js b/multi/lib/cli/ccb-multi-history.js new file mode 100755 index 0000000..73e0a2e --- /dev/null +++ b/multi/lib/cli/ccb-multi-history.js @@ -0,0 +1,99 @@ +#!/usr/bin/env node +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +const chalk_1 = __importDefault(require("chalk")); +const utils_1 = require("../utils"); +async function main() { + try { + const projectInfo = (0, utils_1.getProjectInfo)(); + const historyDir = path.join(projectInfo.root, '.ccb', 'history'); + console.log(''); + console.log(chalk_1.default.cyan(' ██████╗ ██████╗██████╗ ███╗ ███╗██╗ ██╗██╗ ████████╗██╗')); + console.log(chalk_1.default.cyan(' ██╔════╝██╔════╝██╔══██╗ ████╗ ████║██║ ██║██║ ╚══██╔══╝██║')); + console.log(chalk_1.default.cyan(' ██║ ██║ ██████╔╝█████╗██╔████╔██║██║ ██║██║ ██║ ██║')); + console.log(chalk_1.default.cyan(' ██║ ██║ ██╔══██╗╚════╝██║╚██╔╝██║██║ ██║██║ ██║ ██║')); + console.log(chalk_1.default.cyan(' ╚██████╗╚██████╗██████╔╝ ██║ ╚═╝ ██║╚██████╔╝███████╗██║ ██║')); + console.log(chalk_1.default.cyan(' ╚═════╝ ╚═════╝╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝')); + console.log(''); + console.log(' Session History'); + console.log(''); + if (!fs.existsSync(historyDir)) { + console.log(chalk_1.default.dim(' No history directory found')); + return; + } + console.log(chalk_1.default.dim(' RECENT SESSIONS (shared across all instances)')); + console.log(''); + // List recent history files + const files = fs.readdirSync(historyDir) + .filter(name => name.endsWith('.md')) + .map(name => ({ + name, + path: path.join(historyDir, name), + stat: fs.statSync(path.join(historyDir, name)) + })) + .sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs) + .slice(0, 10); + if (files.length === 0) { + console.log(chalk_1.default.dim(' No session history found')); + return; + } + for (const file of files) { + const provider = file.name.split('-')[0]; + const size = (file.stat.size / 1024).toFixed(1) + 'K'; + const time = file.stat.mtime.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + console.log(` ${chalk_1.default.cyan(provider.padEnd(8))} ${chalk_1.default.dim(size.padEnd(8))} ${chalk_1.default.dim(time)}`); + } + console.log(''); + console.log(chalk_1.default.dim(` History location: ${historyDir}`)); + console.log(''); + } + catch (error) { + console.error(chalk_1.default.red(' ✗ Error:'), error instanceof Error ? error.message : error); + process.exit(1); + } +} +main(); +//# sourceMappingURL=ccb-multi-history.js.map \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi-history.js.map b/multi/lib/cli/ccb-multi-history.js.map new file mode 100644 index 0000000..5fa4f71 --- /dev/null +++ b/multi/lib/cli/ccb-multi-history.js.map @@ -0,0 +1 @@ +{"version":3,"file":"ccb-multi-history.js","sourceRoot":"","sources":["../../src/cli/ccb-multi-history.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi-status.d.ts b/multi/lib/cli/ccb-multi-status.d.ts new file mode 100644 index 0000000..b52a7d9 --- /dev/null +++ b/multi/lib/cli/ccb-multi-status.d.ts @@ -0,0 +1,3 @@ +#!/usr/bin/env node +export {}; +//# sourceMappingURL=ccb-multi-status.d.ts.map \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi-status.d.ts.map b/multi/lib/cli/ccb-multi-status.d.ts.map new file mode 100644 index 0000000..1425602 --- /dev/null +++ b/multi/lib/cli/ccb-multi-status.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"ccb-multi-status.d.ts","sourceRoot":"","sources":["../../src/cli/ccb-multi-status.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi-status.js b/multi/lib/cli/ccb-multi-status.js new file mode 100755 index 0000000..e6460a5 --- /dev/null +++ b/multi/lib/cli/ccb-multi-status.js @@ -0,0 +1,56 @@ +#!/usr/bin/env node +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const chalk_1 = __importDefault(require("chalk")); +const utils_1 = require("../utils"); +async function main() { + try { + const projectInfo = (0, utils_1.getProjectInfo)(); + const instances = (0, utils_1.listInstances)(projectInfo.root); + console.log(''); + console.log(chalk_1.default.cyan(' ██████╗ ██████╗██████╗ ███╗ ███╗██╗ ██╗██╗ ████████╗██╗')); + console.log(chalk_1.default.cyan(' ██╔════╝██╔════╝██╔══██╗ ████╗ ████║██║ ██║██║ ╚══██╔══╝██║')); + console.log(chalk_1.default.cyan(' ██║ ██║ ██████╔╝█████╗██╔████╔██║██║ ██║██║ ██║ ██║')); + console.log(chalk_1.default.cyan(' ██║ ██║ ██╔══██╗╚════╝██║╚██╔╝██║██║ ██║██║ ██║ ██║')); + console.log(chalk_1.default.cyan(' ╚██████╗╚██████╗██████╔╝ ██║ ╚═╝ ██║╚██████╔╝███████╗██║ ██║')); + console.log(chalk_1.default.cyan(' ╚═════╝ ╚═════╝╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝')); + console.log(''); + console.log(' Multi-Instance Status'); + console.log(''); + if (instances.length === 0) { + console.log(chalk_1.default.dim(' No instances found')); + return; + } + let runningCount = 0; + let stoppedCount = 0; + console.log(chalk_1.default.dim(' INSTANCES')); + console.log(''); + for (const instanceId of instances) { + const running = (0, utils_1.isInstanceRunning)(projectInfo.root, instanceId); + if (running) { + console.log(` ${chalk_1.default.green('●')} Instance ${instanceId} ${chalk_1.default.dim('running')}`); + runningCount++; + } + else { + console.log(` ${chalk_1.default.dim('○')} Instance ${instanceId} ${chalk_1.default.dim('stopped')}`); + stoppedCount++; + } + } + console.log(''); + console.log(chalk_1.default.dim(' SUMMARY')); + console.log(''); + console.log(` Total ${instances.length}`); + console.log(` Running ${chalk_1.default.green(runningCount.toString())}`); + console.log(` Stopped ${chalk_1.default.dim(stoppedCount.toString())}`); + console.log(''); + } + catch (error) { + console.error(chalk_1.default.red(' ✗ Error:'), error instanceof Error ? error.message : error); + process.exit(1); + } +} +main(); +//# sourceMappingURL=ccb-multi-status.js.map \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi-status.js.map b/multi/lib/cli/ccb-multi-status.js.map new file mode 100644 index 0000000..bd3888f --- /dev/null +++ b/multi/lib/cli/ccb-multi-status.js.map @@ -0,0 +1 @@ +{"version":3,"file":"ccb-multi-status.js","sourceRoot":"","sources":["../../src/cli/ccb-multi-status.ts"],"names":[],"mappings":";;;;;;AAEA,kDAA0B;AAC1B,oCAA4E;AAE5E,KAAK,UAAU,IAAI;IACjB,IAAI,CAAC;QACH,MAAM,WAAW,GAAG,IAAA,sBAAc,GAAE,CAAC;QACrC,MAAM,SAAS,GAAG,IAAA,qBAAa,EAAC,WAAW,CAAC,IAAI,CAAC,CAAC;QAElD,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAChB,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC,CAAC;QAClG,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC,CAAC;QAClG,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC,CAAC;QAClG,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC,CAAC;QAClG,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC,CAAC;QAClG,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC,CAAC;QAClG,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAChB,OAAO,CAAC,GAAG,CAAC,yBAAyB,CAAC,CAAC;QACvC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAEhB,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC3B,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAC,CAAC;YACjD,OAAO;QACT,CAAC;QAED,IAAI,YAAY,GAAG,CAAC,CAAC;QACrB,IAAI,YAAY,GAAG,CAAC,CAAC;QAErB,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC;QACtC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAEhB,KAAK,MAAM,UAAU,IAAI,SAAS,EAAE,CAAC;YACnC,MAAM,OAAO,GAAG,IAAA,yBAAiB,EAAC,WAAW,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC;YAEhE,IAAI,OAAO,EAAE,CAAC;gBACZ,OAAO,CAAC,GAAG,CAAC,KAAK,eAAK,CAAC,KAAK,CAAC,GAAG,CAAC,aAAa,UAAU,KAAK,eAAK,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC;gBACrF,YAAY,EAAE,CAAC;YACjB,CAAC;iBAAM,CAAC;gBACN,OAAO,CAAC,GAAG,CAAC,KAAK,eAAK,CAAC,GAAG,CAAC,GAAG,CAAC,aAAa,UAAU,KAAK,eAAK,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC;gBACnF,YAAY,EAAE,CAAC;YACjB,CAAC;QACH,CAAC;QAED,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAChB,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC;QACpC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAChB,OAAO,CAAC,GAAG,CAAC,kBAAkB,SAAS,CAAC,MAAM,EAAE,CAAC,CAAC;QAClD,OAAO,CAAC,GAAG,CAAC,kBAAkB,eAAK,CAAC,KAAK,CAAC,YAAY,CAAC,QAAQ,EAAE,CAAC,EAAE,CAAC,CAAC;QACtE,OAAO,CAAC,GAAG,CAAC,kBAAkB,eAAK,CAAC,GAAG,CAAC,YAAY,CAAC,QAAQ,EAAE,CAAC,EAAE,CAAC,CAAC;QACpE,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IAElB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,CAAC,KAAK,CAAC,eAAK,CAAC,GAAG,CAAC,YAAY,CAAC,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;QACvF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;AACH,CAAC;AAED,IAAI,EAAE,CAAC"} \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi.d.ts b/multi/lib/cli/ccb-multi.d.ts new file mode 100644 index 0000000..860a3a2 --- /dev/null +++ b/multi/lib/cli/ccb-multi.d.ts @@ -0,0 +1,3 @@ +#!/usr/bin/env node +export {}; +//# sourceMappingURL=ccb-multi.d.ts.map \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi.d.ts.map b/multi/lib/cli/ccb-multi.d.ts.map new file mode 100644 index 0000000..d0edad1 --- /dev/null +++ b/multi/lib/cli/ccb-multi.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"ccb-multi.d.ts","sourceRoot":"","sources":["../../src/cli/ccb-multi.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi.js b/multi/lib/cli/ccb-multi.js new file mode 100755 index 0000000..a99e455 --- /dev/null +++ b/multi/lib/cli/ccb-multi.js @@ -0,0 +1,45 @@ +#!/usr/bin/env node +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const commander_1 = require("commander"); +const instance_1 = require("../instance"); +const utils_1 = require("../utils"); +const chalk_1 = __importDefault(require("chalk")); +const program = new commander_1.Command(); +program + .name('ccb-multi') + .description('Multi-instance manager for CCB (Claude Code Bridge)') + .version('1.0.0') + .argument('', 'Instance ID (1, 2, 3, ...)') + .argument('[providers...]', 'AI providers (e.g., codex gemini claude)') + .action(async (instanceId, providers) => { + try { + const projectInfo = (0, utils_1.getProjectInfo)(); + console.log(''); + console.log(chalk_1.default.cyan(' ██████╗ ██████╗██████╗ ███╗ ███╗██╗ ██╗██╗ ████████╗██╗')); + console.log(chalk_1.default.cyan(' ██╔════╝██╔════╝██╔══██╗ ████╗ ████║██║ ██║██║ ╚══██╔══╝██║')); + console.log(chalk_1.default.cyan(' ██║ ██║ ██████╔╝█████╗██╔████╔██║██║ ██║██║ ██║ ██║')); + console.log(chalk_1.default.cyan(' ██║ ██║ ██╔══██╗╚════╝██║╚██╔╝██║██║ ██║██║ ██║ ██║')); + console.log(chalk_1.default.cyan(' ╚██████╗╚██████╗██████╔╝ ██║ ╚═╝ ██║╚██████╔╝███████╗██║ ██║')); + console.log(chalk_1.default.cyan(' ╚═════╝ ╚═════╝╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝')); + console.log(''); + console.log(' Multi-Instance Manager for Claude Code Bridge'); + console.log(''); + console.log(chalk_1.default.dim(` Project ${projectInfo.name}`)); + console.log(chalk_1.default.dim(` Instance ${instanceId}`)); + if (providers.length > 0) { + console.log(chalk_1.default.dim(` Providers ${providers.join(', ')}`)); + } + console.log(''); + await (0, instance_1.startInstance)(instanceId, providers, projectInfo); + } + catch (error) { + console.error(chalk_1.default.red(' ✗ Error:'), error instanceof Error ? error.message : error); + process.exit(1); + } +}); +program.parse(); +//# sourceMappingURL=ccb-multi.js.map \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi.js.map b/multi/lib/cli/ccb-multi.js.map new file mode 100644 index 0000000..8e22c24 --- /dev/null +++ b/multi/lib/cli/ccb-multi.js.map @@ -0,0 +1 @@ +{"version":3,"file":"ccb-multi.js","sourceRoot":"","sources":["../../src/cli/ccb-multi.ts"],"names":[],"mappings":";;;;;;AAEA,yCAAoC;AACpC,0CAA4C;AAC5C,oCAA0C;AAC1C,kDAA0B;AAE1B,MAAM,OAAO,GAAG,IAAI,mBAAO,EAAE,CAAC;AAE9B,OAAO;KACJ,IAAI,CAAC,WAAW,CAAC;KACjB,WAAW,CAAC,qDAAqD,CAAC;KAClE,OAAO,CAAC,OAAO,CAAC;KAChB,QAAQ,CAAC,eAAe,EAAE,4BAA4B,CAAC;KACvD,QAAQ,CAAC,gBAAgB,EAAE,0CAA0C,CAAC;KACtE,MAAM,CAAC,KAAK,EAAE,UAAkB,EAAE,SAAmB,EAAE,EAAE;IACxD,IAAI,CAAC;QACH,MAAM,WAAW,GAAG,IAAA,sBAAc,GAAE,CAAC;QAErC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAChB,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC,CAAC;QAClG,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC,CAAC;QAClG,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC,CAAC;QAClG,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC,CAAC;QAClG,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC,CAAC;QAClG,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC,CAAC;QAClG,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAChB,OAAO,CAAC,GAAG,CAAC,iDAAiD,CAAC,CAAC;QAC/D,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAChB,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,GAAG,CAAC,mBAAmB,WAAW,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;QAC9D,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,GAAG,CAAC,mBAAmB,UAAU,EAAE,CAAC,CAAC,CAAC;QAExD,IAAI,SAAS,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACzB,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,GAAG,CAAC,mBAAmB,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC;QACpE,CAAC;QAED,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAEhB,MAAM,IAAA,wBAAa,EAAC,UAAU,EAAE,SAAS,EAAE,WAAW,CAAC,CAAC;IAE1D,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,CAAC,KAAK,CAAC,eAAK,CAAC,GAAG,CAAC,YAAY,CAAC,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;QACvF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;AACH,CAAC,CAAC,CAAC;AAEL,OAAO,CAAC,KAAK,EAAE,CAAC"} \ No newline at end of file diff --git a/multi/lib/instance.d.ts.map b/multi/lib/instance.d.ts.map new file mode 100644 index 0000000..8405d49 --- /dev/null +++ b/multi/lib/instance.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"instance.d.ts","sourceRoot":"","sources":["../src/instance.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAEtC,wBAAsB,aAAa,CACjC,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EAAE,EACnB,WAAW,EAAE,WAAW,GACvB,OAAO,CAAC,IAAI,CAAC,CAqFf"} \ No newline at end of file diff --git a/multi/lib/instance.js b/multi/lib/instance.js new file mode 100644 index 0000000..473b3da --- /dev/null +++ b/multi/lib/instance.js @@ -0,0 +1,116 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.startInstance = startInstance; +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +const child_process_1 = require("child_process"); +const chalk_1 = __importDefault(require("chalk")); +async function startInstance(instanceId, providers, projectInfo) { + const instanceDir = path.join(projectInfo.root, '.ccb-instances', `instance-${instanceId}`); + const ccbDir = path.join(instanceDir, '.ccb'); + // Create instance directory + fs.mkdirSync(instanceDir, { recursive: true }); + fs.mkdirSync(ccbDir, { recursive: true }); + // Ensure main project .ccb directory exists + const mainCcbDir = path.join(projectInfo.root, '.ccb'); + const mainHistoryDir = path.join(mainCcbDir, 'history'); + fs.mkdirSync(mainHistoryDir, { recursive: true }); + console.log(chalk_1.default.dim(' Creating symlinks...')); + // Create symlinks for project files (excluding .ccb-instances and .ccb) + const excludeDirs = ['.ccb-instances', '.ccb', '.claude', '.opencode', 'node_modules', '.git']; + const items = fs.readdirSync(projectInfo.root); + for (const item of items) { + if (excludeDirs.includes(item)) + continue; + const sourcePath = path.join(projectInfo.root, item); + const targetPath = path.join(instanceDir, item); + try { + // Remove existing symlink if exists + if (fs.existsSync(targetPath)) { + fs.unlinkSync(targetPath); + } + fs.symlinkSync(sourcePath, targetPath); + } + catch (error) { + // Ignore symlink errors + } + } + // Create symlinks for shared history and config + const historySymlink = path.join(ccbDir, 'history'); + const configSymlink = path.join(ccbDir, 'ccb.config'); + if (fs.existsSync(historySymlink)) { + fs.unlinkSync(historySymlink); + } + fs.symlinkSync(mainHistoryDir, historySymlink); + const mainConfigPath = path.join(mainCcbDir, 'ccb.config'); + if (fs.existsSync(mainConfigPath)) { + if (fs.existsSync(configSymlink)) { + fs.unlinkSync(configSymlink); + } + fs.symlinkSync(mainConfigPath, configSymlink); + } + // Write config if providers specified + if (providers.length > 0) { + const configContent = providers.join(','); + fs.writeFileSync(path.join(ccbDir, 'ccb.config'), configContent); + } + // Set environment variables + process.env.CCB_INSTANCE_ID = instanceId; + process.env.CCB_PROJECT_ROOT = projectInfo.root; + console.log(chalk_1.default.green(' ✓ Instance ready')); + console.log(''); + console.log(chalk_1.default.dim(' Launching CCB...')); + console.log(''); + const ccb = (0, child_process_1.spawn)('ccb', [], { + cwd: instanceDir, + stdio: 'inherit', + env: process.env + }); + ccb.on('error', (error) => { + console.error(chalk_1.default.red(' ✗ Failed to launch CCB:'), error.message); + process.exit(1); + }); + ccb.on('exit', (code) => { + if (code !== 0) { + console.error(chalk_1.default.red(` ✗ CCB exited with code ${code}`)); + process.exit(code || 1); + } + }); +} +//# sourceMappingURL=instance.js.map \ No newline at end of file diff --git a/multi/lib/instance.js.map b/multi/lib/instance.js.map new file mode 100644 index 0000000..d77fe42 --- /dev/null +++ b/multi/lib/instance.js.map @@ -0,0 +1 @@ +{"version":3,"file":"instance.js","sourceRoot":"","sources":["../src/instance.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/multi/lib/types.d.ts.map b/multi/lib/types.d.ts.map new file mode 100644 index 0000000..935a68b --- /dev/null +++ b/multi/lib/types.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd"} \ No newline at end of file diff --git a/multi/lib/types.js b/multi/lib/types.js new file mode 100644 index 0000000..11e638d --- /dev/null +++ b/multi/lib/types.js @@ -0,0 +1,3 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/multi/lib/types.js.map b/multi/lib/types.js.map new file mode 100644 index 0000000..c768b79 --- /dev/null +++ b/multi/lib/types.js.map @@ -0,0 +1 @@ +{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/multi/lib/utils.d.ts.map b/multi/lib/utils.d.ts.map new file mode 100644 index 0000000..f787f42 --- /dev/null +++ b/multi/lib/utils.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,wBAAgB,cAAc,IAAI,WAAW,CAK5C;AAED,wBAAgB,eAAe,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAE3D;AAED,wBAAgB,cAAc,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAE9E;AAED,wBAAgB,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,EAAE,CAW3D;AAED,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAalF"} \ No newline at end of file diff --git a/multi/lib/utils.js b/multi/lib/utils.js new file mode 100644 index 0000000..23ef0d5 --- /dev/null +++ b/multi/lib/utils.js @@ -0,0 +1,75 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getProjectInfo = getProjectInfo; +exports.getInstancesDir = getInstancesDir; +exports.getInstanceDir = getInstanceDir; +exports.listInstances = listInstances; +exports.isInstanceRunning = isInstanceRunning; +const path = __importStar(require("path")); +const fs = __importStar(require("fs")); +function getProjectInfo() { + const root = process.cwd(); + const name = path.basename(root); + return { root, name }; +} +function getInstancesDir(projectRoot) { + return path.join(projectRoot, '.ccb-instances'); +} +function getInstanceDir(projectRoot, instanceId) { + return path.join(getInstancesDir(projectRoot), `instance-${instanceId}`); +} +function listInstances(projectRoot) { + const instancesDir = getInstancesDir(projectRoot); + if (!fs.existsSync(instancesDir)) { + return []; + } + return fs.readdirSync(instancesDir) + .filter(name => name.startsWith('instance-')) + .map(name => name.replace('instance-', '')) + .sort((a, b) => parseInt(a) - parseInt(b)); +} +function isInstanceRunning(projectRoot, instanceId) { + const instanceDir = getInstanceDir(projectRoot, instanceId); + const ccbDir = path.join(instanceDir, '.ccb'); + if (!fs.existsSync(ccbDir)) { + return false; + } + // Check for session files + const sessionFiles = fs.readdirSync(ccbDir) + .filter(name => name.endsWith('-session')); + return sessionFiles.length > 0; +} +//# sourceMappingURL=utils.js.map \ No newline at end of file diff --git a/multi/lib/utils.js.map b/multi/lib/utils.js.map new file mode 100644 index 0000000..4421062 --- /dev/null +++ b/multi/lib/utils.js.map @@ -0,0 +1 @@ +{"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAQA,wCAKC;AAED,0CAEC;AAED,wCAEC;AAED,sCAWC;AAED,8CAaC;AAjDD,2CAA6B;AAC7B,uCAAyB;AAOzB,SAAgB,cAAc;IAC5B,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC;IAC3B,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;IAEjC,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;AACxB,CAAC;AAED,SAAgB,eAAe,CAAC,WAAmB;IACjD,OAAO,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,gBAAgB,CAAC,CAAC;AAClD,CAAC;AAED,SAAgB,cAAc,CAAC,WAAmB,EAAE,UAAkB;IACpE,OAAO,IAAI,CAAC,IAAI,CAAC,eAAe,CAAC,WAAW,CAAC,EAAE,YAAY,UAAU,EAAE,CAAC,CAAC;AAC3E,CAAC;AAED,SAAgB,aAAa,CAAC,WAAmB;IAC/C,MAAM,YAAY,GAAG,eAAe,CAAC,WAAW,CAAC,CAAC;IAElD,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,YAAY,CAAC,EAAE,CAAC;QACjC,OAAO,EAAE,CAAC;IACZ,CAAC;IAED,OAAO,EAAE,CAAC,WAAW,CAAC,YAAY,CAAC;SAChC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC;SAC5C,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,WAAW,EAAE,EAAE,CAAC,CAAC;SAC1C,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;AAC/C,CAAC;AAED,SAAgB,iBAAiB,CAAC,WAAmB,EAAE,UAAkB;IACvE,MAAM,WAAW,GAAG,cAAc,CAAC,WAAW,EAAE,UAAU,CAAC,CAAC;IAC5D,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;IAE9C,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC;QAC3B,OAAO,KAAK,CAAC;IACf,CAAC;IAED,0BAA0B;IAC1B,MAAM,YAAY,GAAG,EAAE,CAAC,WAAW,CAAC,MAAM,CAAC;SACxC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC,CAAC;IAE7C,OAAO,YAAY,CAAC,MAAM,GAAG,CAAC,CAAC;AACjC,CAAC"} \ No newline at end of file diff --git a/multi/package.json b/multi/package.json new file mode 100644 index 0000000..124940c --- /dev/null +++ b/multi/package.json @@ -0,0 +1,16 @@ +{ + "name": "ccb-multi", + "version": "1.0.0", + "description": "Multi-instance manager for Claude Code Bridge", + "main": "lib/instance.js", + "bin": { + "ccb-multi": "bin/ccb-multi", + "ccb-multi-status": "bin/ccb-multi-status", + "ccb-multi-history": "bin/ccb-multi-history", + "ccb-multi-clean": "bin/ccb-multi-clean" + }, + "dependencies": { + "chalk": "^4.1.2", + "commander": "^9.0.0" + } +} From 1be1530392014c5473858566d0789788ec36cbc2 Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Wed, 18 Feb 2026 16:49:20 +0800 Subject: [PATCH 03/28] fix: enable upward traversal for .ccb directory search - Add _find_git_root() to detect git repository boundaries - Modify _find_ccb_config_root() to search up to git root (or 10 levels) - Prevents 'No active session found' errors when running ask from subdirectories - Integrate ccb-multi tools into install.sh This fixes the 'fake death' issue where CCB appears unresponsive when running from subdirectories. --- install.sh | 30 ++++++++++++++++++ lib/project_id.py | 79 ++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 98 insertions(+), 11 deletions(-) diff --git a/install.sh b/install.sh index 0dee86f..89bcafc 100755 --- a/install.sh +++ b/install.sh @@ -123,6 +123,10 @@ SCRIPTS_TO_LINK=( bin/maild bin/ctx-transfer ccb + multi/bin/ccb-multi + multi/bin/ccb-multi-clean + multi/bin/ccb-multi-history + multi/bin/ccb-multi-status ) CLAUDE_MARKDOWN=( @@ -598,6 +602,30 @@ copy_project() { fi } +install_ccb_multi_deps() { + local multi_dir="$INSTALL_PREFIX/multi" + + if [[ ! -d "$multi_dir" ]]; then + echo "WARN: ccb-multi directory not found, skipping npm install" + return + fi + + # Check if npm is available + if ! command -v npm >/dev/null 2>&1; then + echo "WARN: npm not found, skipping ccb-multi dependencies installation" + echo " ccb-multi requires Node.js and npm to be installed" + return + fi + + echo "Installing ccb-multi dependencies..." + if (cd "$multi_dir" && npm install --production --silent >/dev/null 2>&1); then + echo "OK: ccb-multi dependencies installed" + else + echo "WARN: Failed to install ccb-multi dependencies" + echo " You can manually run: cd $multi_dir && npm install" + fi +} + install_bin_links() { mkdir -p "$BIN_DIR" @@ -1482,6 +1510,7 @@ install_all() { cleanup_legacy_files save_wezterm_config copy_project + install_ccb_multi_deps install_bin_links ensure_path_configured install_claude_commands @@ -1502,6 +1531,7 @@ install_all() { echo " AGENTS.md configured with review rubrics" echo " .clinerules configured with role assignments" echo " Global settings.json permissions added" + echo " ccb-multi tools installed" } uninstall_claude_md_config() { diff --git a/lib/project_id.py b/lib/project_id.py index b3324c7..7bd17a5 100644 --- a/lib/project_id.py +++ b/lib/project_id.py @@ -77,25 +77,82 @@ def normalize_work_dir(value: str | Path) -> str: return s -def _find_ccb_config_root(start_dir: Path) -> Path | None: +def _find_git_root(start_dir: Path) -> Path | None: """ - Find a `.ccb/` (or legacy `.ccb_config/`) directory in the current working directory only. - - This enforces per-directory isolation (no ancestor traversal). + Find the root of the git repository by traversing up. + Returns None if not in a git repository. """ try: current = Path(start_dir).expanduser().absolute() except Exception: current = Path.cwd() + + # Traverse up to find .git directory + while True: + try: + if (current / ".git").exists(): + return current + except Exception: + pass + + parent = current.parent + if parent == current: # Reached root + break + current = parent + + return None + + +def _find_ccb_config_root(start_dir: Path) -> Path | None: + """ + Find a `.ccb/` (or legacy `.ccb_config/`) directory by traversing up. + + Search strategy: + 1. If in a git repository, search up to git root (prevents cross-project confusion) + 2. Otherwise, search up to 10 levels (prevents excessive traversal) + + This allows running `ask` from any subdirectory while preventing cross-project errors. + """ try: - cfg = current / ".ccb" - if cfg.is_dir(): - return current - legacy = current / ".ccb_config" - if legacy.is_dir(): - return current + current = Path(start_dir).expanduser().absolute() except Exception: - return None + current = Path.cwd() + + # Find git repository boundary (if any) + git_root = _find_git_root(current) + + # Traverse up to find .ccb directory + max_levels = 10 # Limit traversal for non-git directories + level = 0 + + while True: + try: + cfg = current / ".ccb" + if cfg.is_dir(): + return current + legacy = current / ".ccb_config" + if legacy.is_dir(): + return current + except Exception: + pass + + # Stop at git root boundary (if in git repo) + if git_root and current == git_root: + break + + # Stop at filesystem root + parent = current.parent + if parent == current: + break + + # Stop after max levels (for non-git directories) + if not git_root: + level += 1 + if level >= max_levels: + break + + current = parent + return None From 08a2165bbce7467ec0ea02c40346eab5acb13118 Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Wed, 18 Feb 2026 17:16:58 +0800 Subject: [PATCH 04/28] revert: remove upward traversal logic from _find_ccb_config_root The upward traversal logic was incorrect and unnecessary: - Users don't need to search parent directories for .ccb/ - The original design enforces per-directory isolation - The 'fake death' issue is not caused by missing .ccb/ directories This reverts the lib/project_id.py changes from commit 1be1530, while keeping the install.sh changes (ccb-multi integration). --- lib/project_id.py | 79 +++++++---------------------------------------- 1 file changed, 11 insertions(+), 68 deletions(-) diff --git a/lib/project_id.py b/lib/project_id.py index 7bd17a5..b3324c7 100644 --- a/lib/project_id.py +++ b/lib/project_id.py @@ -77,82 +77,25 @@ def normalize_work_dir(value: str | Path) -> str: return s -def _find_git_root(start_dir: Path) -> Path | None: - """ - Find the root of the git repository by traversing up. - Returns None if not in a git repository. - """ - try: - current = Path(start_dir).expanduser().absolute() - except Exception: - current = Path.cwd() - - # Traverse up to find .git directory - while True: - try: - if (current / ".git").exists(): - return current - except Exception: - pass - - parent = current.parent - if parent == current: # Reached root - break - current = parent - - return None - - def _find_ccb_config_root(start_dir: Path) -> Path | None: """ - Find a `.ccb/` (or legacy `.ccb_config/`) directory by traversing up. + Find a `.ccb/` (or legacy `.ccb_config/`) directory in the current working directory only. - Search strategy: - 1. If in a git repository, search up to git root (prevents cross-project confusion) - 2. Otherwise, search up to 10 levels (prevents excessive traversal) - - This allows running `ask` from any subdirectory while preventing cross-project errors. + This enforces per-directory isolation (no ancestor traversal). """ try: current = Path(start_dir).expanduser().absolute() except Exception: current = Path.cwd() - - # Find git repository boundary (if any) - git_root = _find_git_root(current) - - # Traverse up to find .ccb directory - max_levels = 10 # Limit traversal for non-git directories - level = 0 - - while True: - try: - cfg = current / ".ccb" - if cfg.is_dir(): - return current - legacy = current / ".ccb_config" - if legacy.is_dir(): - return current - except Exception: - pass - - # Stop at git root boundary (if in git repo) - if git_root and current == git_root: - break - - # Stop at filesystem root - parent = current.parent - if parent == current: - break - - # Stop after max levels (for non-git directories) - if not git_root: - level += 1 - if level >= max_levels: - break - - current = parent - + try: + cfg = current / ".ccb" + if cfg.is_dir(): + return current + legacy = current / ".ccb_config" + if legacy.is_dir(): + return current + except Exception: + return None return None From 64f0d5166285a330fde0a706d3fd02f13a9974c7 Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Wed, 18 Feb 2026 20:54:46 +0800 Subject: [PATCH 05/28] fix: resolve Gemini CLI 0.29.0 session path change causing CCB deadlock Gemini CLI >= 0.29.0 changed session storage from SHA-256 hash to directory basename (e.g. /Users/danlio -> "danlio" instead of "f3f1bce3..."). GeminiLogReader was polling the old hash directory while Gemini wrote to the new one, causing requests to hang forever. Changes: - Add _compute_project_hashes() returning both (basename, sha256) formats - GeminiLogReader scans all known hash dirs, picks newest by mtime - _work_dirs_for_hash() registers both formats in watchdog cache - bin/ask uses daemon's work_dir instead of shell cwd - bin/askd accepts --work-dir to decouple from launch directory - askd_server stores explicit work_dir instead of os.getcwd() - askd_client resolves work_dir from daemon state as fallback - daemon_work_dir validated with type guard and existence check Reviewed-by: Gemini (approved), Codex (7.5/10 correctness) --- bin/ask | 12 ++++- bin/askd | 7 ++- lib/askd/daemon.py | 3 ++ lib/askd_client.py | 16 ++++++- lib/askd_runtime.py | 22 ++++++++++ lib/askd_server.py | 5 ++- lib/gemini_comm.py | 104 ++++++++++++++++++++++++++++++++++---------- 7 files changed, 140 insertions(+), 29 deletions(-) diff --git a/bin/ask b/bin/ask index 5b9745d..8c4780e 100755 --- a/bin/ask +++ b/bin/ask @@ -112,7 +112,10 @@ def _send_via_unified_daemon( from askd_runtime import state_file_path import askd_rpc + # Use CCB_RUN_DIR (set by CCB startup) to locate the state file. + # This already contains the correct project-specific path. state_file = state_file_path("askd.json") + state = askd_rpc.read_state(state_file) if not state: print("[ERROR] Unified askd daemon not running", file=sys.stderr) @@ -121,6 +124,13 @@ def _send_via_unified_daemon( host = state.get("connect_host") or state.get("host") or "127.0.0.1" port = int(state.get("port") or 0) token = state.get("token") or "" + # Use daemon's work_dir instead of current shell's cwd + raw_work_dir = state.get("work_dir") + daemon_work_dir = raw_work_dir.strip() if isinstance(raw_work_dir, str) and raw_work_dir.strip() else "" + if not daemon_work_dir or not Path(daemon_work_dir).is_dir(): + if daemon_work_dir: + print(f"[WARN] daemon work_dir not found: {daemon_work_dir}, falling back to cwd", file=sys.stderr) + daemon_work_dir = os.getcwd() if not port: print("[ERROR] Invalid askd state", file=sys.stderr) @@ -132,7 +142,7 @@ def _send_via_unified_daemon( "id": make_task_id(), "token": token, "provider": provider, - "work_dir": os.getcwd(), + "work_dir": daemon_work_dir, "timeout_s": timeout, "message": message, "no_wrap": no_wrap, diff --git a/bin/askd b/bin/askd index 0a4388d..f3dd0f7 100755 --- a/bin/askd +++ b/bin/askd @@ -80,6 +80,11 @@ def main(argv: list[str]) -> int: default=os.environ.get("CCB_ASKD_PROVIDERS", ""), help="Comma-separated list of providers to enable (default: all)", ) + ap.add_argument( + "--work-dir", + default=os.environ.get("CCB_WORK_DIR", ""), + help="Override work_dir written to state file (default: cwd)", + ) args = ap.parse_args(argv[1:]) state_file = Path(args.state_file).expanduser() if args.state_file else None @@ -102,7 +107,7 @@ def main(argv: list[str]) -> int: registry = _create_registry(providers) print(f"Enabled providers: {', '.join(registry.keys())}", file=sys.stderr) - daemon = UnifiedAskDaemon(host=host, port=port, state_file=state_file, registry=registry) + daemon = UnifiedAskDaemon(host=host, port=port, state_file=state_file, registry=registry, work_dir=args.work_dir or None) return daemon.serve_forever() diff --git a/lib/askd/daemon.py b/lib/askd/daemon.py index 3c1b9ad..2b08f62 100644 --- a/lib/askd/daemon.py +++ b/lib/askd/daemon.py @@ -107,6 +107,7 @@ def __init__( *, state_file: Optional[Path] = None, registry: Optional[ProviderRegistry] = None, + work_dir: Optional[str] = None, ): self.host = host self.port = port @@ -114,6 +115,7 @@ def __init__( self.token = random_token() self.registry = registry or ProviderRegistry() self.pool = _UnifiedWorkerPool(self.registry) + self.work_dir = work_dir def _handle_request(self, msg: dict) -> dict: """Handle an incoming request.""" @@ -233,6 +235,7 @@ def _on_stop() -> None: request_handler=self._handle_request, request_queue_size=128, on_stop=_on_stop, + work_dir=self.work_dir, ) return server.serve_forever() diff --git a/lib/askd_client.py b/lib/askd_client.py index 4b1b18b..81e3672 100644 --- a/lib/askd_client.py +++ b/lib/askd_client.py @@ -88,8 +88,9 @@ def resolve_work_dir_with_registry( Priority: 1) cli_session_file (--session-file) 2) env_session_file (CCB_SESSION_FILE) - 3) registry lookup by ccb_project_id + provider - 4) default_cwd / Path.cwd() + 3) daemon state work_dir (if unified askd is enabled) + 4) registry lookup by ccb_project_id + provider + 5) default_cwd / Path.cwd() """ raw = (cli_session_file or "").strip() or (env_session_file or "").strip() if raw: @@ -100,6 +101,17 @@ def resolve_work_dir_with_registry( default_cwd=default_cwd, ) + # Try to get work_dir from unified askd daemon state + from askd_runtime import get_daemon_work_dir + 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) + if found: + return daemon_work_dir, found + except Exception: + pass + cwd = default_cwd or Path.cwd() try: project_id = compute_ccb_project_id(cwd) diff --git a/lib/askd_runtime.py b/lib/askd_runtime.py index 75d80ab..b2911f9 100644 --- a/lib/askd_runtime.py +++ b/lib/askd_runtime.py @@ -119,3 +119,25 @@ def normalize_connect_host(host: str) -> str: if host in ("::", "[::]"): return "::1" return host + + +def get_daemon_work_dir(state_file_name: str = "askd.json") -> Path | None: + """ + Read daemon's work_dir from state file. + Returns None if daemon is not running or work_dir is not recorded. + """ + try: + state_path = state_file_path(state_file_name) + if not state_path.exists(): + return None + with state_path.open("r", encoding="utf-8") as f: + import json + state = json.load(f) + if not isinstance(state, dict): + return None + work_dir = state.get("work_dir") + if not work_dir or not isinstance(work_dir, str): + return None + return Path(work_dir.strip()) + except Exception: + return None diff --git a/lib/askd_server.py b/lib/askd_server.py index 8f035a2..b46c2f4 100644 --- a/lib/askd_server.py +++ b/lib/askd_server.py @@ -70,6 +70,7 @@ def __init__( on_stop: Optional[Callable[[], None]] = None, parent_pid: Optional[int] = None, managed: Optional[bool] = None, + work_dir: Optional[str] = None, ): self.spec = spec self.host = host @@ -80,6 +81,7 @@ def __init__( self.request_queue_size = request_queue_size self.on_stop = on_stop self.parent_pid = parent_pid if parent_pid is not None else _env_parent_pid() + self.work_dir = work_dir or os.getcwd() env_managed = _env_truthy("CCB_MANAGED") self.managed = env_managed if managed is None else bool(managed) if self.parent_pid: @@ -230,7 +232,7 @@ def _parent_monitor() -> None: threading.Thread(target=httpd.shutdown, daemon=True).start() return - threading.Thread(target=_parent_monitor, daemon=True).start() + threading.Thread(target=_parent_monitor, daemon=True).start() actual_host, actual_port = httpd.server_address self._write_state(str(actual_host), int(actual_port)) @@ -265,6 +267,7 @@ def _write_state(self, host: str, port: int) -> None: "python": sys.executable, "parent_pid": int(self.parent_pid or 0) or None, "managed": bool(self.managed), + "work_dir": self.work_dir, } self.state_file.parent.mkdir(parents=True, exist_ok=True) ok, _err = safe_write_session(self.state_file, json.dumps(payload, ensure_ascii=False, indent=2) + "\n") diff --git a/lib/gemini_comm.py b/lib/gemini_comm.py index 092ef57..f46f227 100755 --- a/lib/gemini_comm.py +++ b/lib/gemini_comm.py @@ -33,16 +33,36 @@ _GEMINI_HASH_CACHE_TS = 0.0 -def _get_project_hash(work_dir: Optional[Path] = None) -> str: - """Calculate project directory hash (consistent with gemini-cli's Storage.getFilePathHash)""" +def _compute_project_hashes(work_dir: Optional[Path] = None) -> tuple[str, str]: + """Return ``(basename_hash, sha256_hash)`` for *work_dir*. + + Gemini CLI >= 0.29.0 uses the directory basename; older versions used + SHA-256 of the absolute path. Callers that need to probe both formats + should use this helper. + """ path = work_dir or Path.cwd() - # gemini-cli uses Node.js path.resolve() (doesn't resolve symlinks), - # so we use absolute() instead of resolve() to avoid hash mismatch on WSL/Windows. try: - normalized = str(path.expanduser().absolute()) + abs_path = path.expanduser().absolute() except Exception: - normalized = str(path) - return hashlib.sha256(normalized.encode()).hexdigest() + abs_path = path + basename_hash = abs_path.name + sha256_hash = hashlib.sha256(str(abs_path).encode()).hexdigest() + return basename_hash, sha256_hash + + +def _get_project_hash(work_dir: Optional[Path] = None) -> str: + """Return the *primary* project hash for *work_dir*. + + Prefers the new basename format when its ``chats/`` directory exists, + falls back to SHA-256, and defaults to basename for forward compat. + """ + basename_hash, sha256_hash = _compute_project_hashes(work_dir) + root = Path(os.environ.get("GEMINI_ROOT") or (Path.home() / ".gemini" / "tmp")).expanduser() + if (root / basename_hash / "chats").is_dir(): + return basename_hash + if (root / sha256_hash / "chats").is_dir(): + return sha256_hash + return basename_hash def _iter_registry_work_dirs() -> list[Path]: @@ -78,10 +98,12 @@ def _work_dirs_for_hash(project_hash: str) -> list[Path]: _GEMINI_HASH_CACHE = {} for wd in _iter_registry_work_dirs(): try: - h = _get_project_hash(wd) + # Register both hash formats so the watchdog can match either + bn, sha = _compute_project_hashes(wd) + for h in (bn, sha): + _GEMINI_HASH_CACHE.setdefault(h, []).append(wd) except Exception: continue - _GEMINI_HASH_CACHE.setdefault(h, []).append(wd) _GEMINI_HASH_CACHE_TS = now return _GEMINI_HASH_CACHE.get(project_hash, []) @@ -169,7 +191,14 @@ def __init__(self, root: Path = GEMINI_ROOT, work_dir: Optional[Path] = None): self.root = Path(root).expanduser() self.work_dir = work_dir or Path.cwd() forced_hash = os.environ.get("GEMINI_PROJECT_HASH", "").strip() - self._project_hash = forced_hash or _get_project_hash(self.work_dir) + if forced_hash: + self._project_hash = forced_hash + self._all_known_hashes: set[str] = {forced_hash} + else: + self._project_hash = _get_project_hash(self.work_dir) + bn, sha = _compute_project_hashes(self.work_dir) + # Store all known hashes so they survive hash adoption + self._all_known_hashes = {bn, sha} self._preferred_session: Optional[Path] = None try: poll = float(os.environ.get("GEMINI_POLL_INTERVAL", "0.05")) @@ -212,22 +241,49 @@ def _scan_latest_session_any_project(self) -> Optional[Path]: return sessions[-1] if sessions else None def _scan_latest_session(self) -> Optional[Path]: - chats = self._chats_dir() - try: - if chats: - sessions = sorted( - (p for p in chats.glob("session-*.json") if p.is_file() and not p.name.startswith(".")), - key=lambda p: p.stat().st_mtime, - ) - else: - sessions = [] - except OSError: - sessions = [] + """Scan for the latest session file across all known hash dirs.""" + best: Optional[Path] = None + best_mtime: float = 0.0 + + # De-duplicate: primary first, then remaining known hashes + seen: set[str] = set() + scan_order: list[str] = [] + for h in [self._project_hash] + sorted(self._all_known_hashes - {self._project_hash}): + if h not in seen: + seen.add(h) + scan_order.append(h) + + for project_hash in scan_order: + chats = self.root / project_hash / "chats" + if not chats.is_dir(): + continue + try: + for p in chats.glob("session-*.json"): + if not p.is_file() or p.name.startswith("."): + continue + try: + mt = p.stat().st_mtime + except OSError: + continue + if mt > best_mtime: + best = p + best_mtime = mt + except OSError: + continue - if sessions: - return sessions[-1] + # If the winning session lives under a different hash, adopt it + # and add it to known hashes so future scans still cover all formats + if best is not None: + try: + winning_hash = best.parent.parent.name + if winning_hash: + self._all_known_hashes.add(winning_hash) + if winning_hash != self._project_hash: + self._project_hash = winning_hash + except Exception: + pass - return None + return best def _latest_session(self) -> Optional[Path]: preferred = self._preferred_session From d95072a740a1abfb8bc3ae887c0c8abb7a343ee0 Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Wed, 18 Feb 2026 20:55:18 +0800 Subject: [PATCH 06/28] chore: add .DS_Store to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5ff8d5c..4d37ca8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ __pycache__/ *.pyc +.DS_Store .venv/ .pytest_cache/ docs/ From 66ced9250bafed345c2e7d58eceec24bdd0980aa Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Wed, 18 Feb 2026 21:20:18 +0800 Subject: [PATCH 07/28] fix: use globally unique instance dir names to prevent Gemini CLI basename collisions ccb-multi instance dirs (instance-1, instance-2, ...) collide across projects in Gemini CLI 0.29.0's basename-based session storage (~/.gemini/tmp//). Changed to inst--N format where hash is 8-char SHA-256 of project root path. - instance.js: generate inst-- directory names - utils.js: getInstanceDir() with backward compat for old instance-N - utils.js: listInstances() finds both old and new format instances - ccb-multi-clean.js: clean both inst-* and instance-* directories - gemini_comm.py: add _is_ccb_instance_dir() detection via CCB_INSTANCE_ID env or parent dir check, prefer SHA-256 hash for instance dirs, block cross-hash session override in instance mode --- lib/gemini_comm.py | 57 +++++++++++++++++++++++++++++--- multi/lib/cli/ccb-multi-clean.js | 2 +- multi/lib/instance.js | 9 ++++- multi/lib/utils.js | 24 ++++++++++++-- 4 files changed, 82 insertions(+), 10 deletions(-) diff --git a/lib/gemini_comm.py b/lib/gemini_comm.py index f46f227..e5b868f 100755 --- a/lib/gemini_comm.py +++ b/lib/gemini_comm.py @@ -33,6 +33,26 @@ _GEMINI_HASH_CACHE_TS = 0.0 + +def _is_ccb_instance_dir(work_dir: Path) -> bool: + """Detect ccb-multi instance directories. + + These have generic basenames that collide across projects, so SHA-256 + should be preferred over basename for Gemini session lookup. + + Detection (any match = True): + 1) CCB_INSTANCE_ID env var is set (ccb-multi always sets this) + 2) Parent directory is named '.ccb-instances' + """ + if os.environ.get("CCB_INSTANCE_ID", "").strip(): + return True + try: + abs_path = work_dir.expanduser().absolute() + except Exception: + abs_path = work_dir + return abs_path.parent.name == ".ccb-instances" + + def _compute_project_hashes(work_dir: Optional[Path] = None) -> tuple[str, str]: """Return ``(basename_hash, sha256_hash)`` for *work_dir*. @@ -55,14 +75,30 @@ def _get_project_hash(work_dir: Optional[Path] = None) -> str: Prefers the new basename format when its ``chats/`` directory exists, falls back to SHA-256, and defaults to basename for forward compat. + + For ccb-multi instance directories (e.g. instance-1), prefer SHA-256 + to avoid cross-project basename collisions. """ - basename_hash, sha256_hash = _compute_project_hashes(work_dir) + path = work_dir or Path.cwd() + basename_hash, sha256_hash = _compute_project_hashes(path) root = Path(os.environ.get("GEMINI_ROOT") or (Path.home() / ".gemini" / "tmp")).expanduser() - if (root / basename_hash / "chats").is_dir(): - return basename_hash - if (root / sha256_hash / "chats").is_dir(): + is_instance = _is_ccb_instance_dir(path) + + if is_instance: + # Old instance dirs (instance-1, ...) have generic basenames that + # collide across projects. New format (inst--N) is unique, but + # we still prefer SHA-256 for backward compat with old instances. + if (root / sha256_hash / "chats").is_dir(): + return sha256_hash + if (root / basename_hash / "chats").is_dir(): + return basename_hash return sha256_hash - return basename_hash + else: + if (root / basename_hash / "chats").is_dir(): + return basename_hash + if (root / sha256_hash / "chats").is_dir(): + return sha256_hash + return basename_hash def _iter_registry_work_dirs() -> list[Path]: @@ -199,6 +235,7 @@ def __init__(self, root: Path = GEMINI_ROOT, work_dir: Optional[Path] = None): bn, sha = _compute_project_hashes(self.work_dir) # Store all known hashes so they survive hash adoption self._all_known_hashes = {bn, sha} + self._is_instance = _is_ccb_instance_dir(self.work_dir) self._preferred_session: Optional[Path] = None try: poll = float(os.environ.get("GEMINI_POLL_INTERVAL", "0.05")) @@ -294,6 +331,16 @@ def _latest_session(self) -> Optional[Path]: if preferred and preferred.exists(): if scanned and scanned.exists(): try: + # For ccb-multi instance dirs, only accept scanned session + # from the SAME hash directory to prevent cross-project + # contamination via shared basename (old "instance-N" format). + if self._is_instance: + pref_hash = preferred.parent.parent.name + scan_hash = scanned.parent.parent.name + if pref_hash != scan_hash: + self._debug(f"Instance mode: ignoring cross-hash scan {scanned}") + return preferred + pref_mtime = preferred.stat().st_mtime scan_mtime = scanned.stat().st_mtime if scan_mtime > pref_mtime: diff --git a/multi/lib/cli/ccb-multi-clean.js b/multi/lib/cli/ccb-multi-clean.js index 7fac44f..b8f3433 100755 --- a/multi/lib/cli/ccb-multi-clean.js +++ b/multi/lib/cli/ccb-multi-clean.js @@ -66,7 +66,7 @@ program return; } const instances = fs.readdirSync(instancesDir) - .filter(name => name.startsWith('instance-')); + .filter(name => name.startsWith('inst-') || name.startsWith('instance-')); if (instances.length === 0) { console.log(chalk_1.default.dim(' No instances to clean')); return; diff --git a/multi/lib/instance.js b/multi/lib/instance.js index 473b3da..38e4bcb 100644 --- a/multi/lib/instance.js +++ b/multi/lib/instance.js @@ -41,8 +41,15 @@ const fs = __importStar(require("fs")); const path = __importStar(require("path")); const child_process_1 = require("child_process"); const chalk_1 = __importDefault(require("chalk")); +const crypto_1 = require("crypto"); +function _shortProjectHash(projectRoot) { + // Generate a short (8-char) hash from project root path to avoid + // basename collisions across projects in Gemini CLI 0.29.0. + return (0, crypto_1.createHash)('sha256').update(projectRoot).digest('hex').slice(0, 8); +} async function startInstance(instanceId, providers, projectInfo) { - const instanceDir = path.join(projectInfo.root, '.ccb-instances', `instance-${instanceId}`); + const projectHash = _shortProjectHash(projectInfo.root); + const instanceDir = path.join(projectInfo.root, '.ccb-instances', `inst-${projectHash}-${instanceId}`); const ccbDir = path.join(instanceDir, '.ccb'); // Create instance directory fs.mkdirSync(instanceDir, { recursive: true }); diff --git a/multi/lib/utils.js b/multi/lib/utils.js index 23ef0d5..d87a1cb 100644 --- a/multi/lib/utils.js +++ b/multi/lib/utils.js @@ -40,6 +40,10 @@ exports.listInstances = listInstances; exports.isInstanceRunning = isInstanceRunning; const path = __importStar(require("path")); const fs = __importStar(require("fs")); +const crypto_1 = require("crypto"); +function _shortProjectHash(projectRoot) { + return (0, crypto_1.createHash)('sha256').update(projectRoot).digest('hex').slice(0, 8); +} function getProjectInfo() { const root = process.cwd(); const name = path.basename(root); @@ -49,16 +53,30 @@ function getInstancesDir(projectRoot) { return path.join(projectRoot, '.ccb-instances'); } function getInstanceDir(projectRoot, instanceId) { - return path.join(getInstancesDir(projectRoot), `instance-${instanceId}`); + const hash = _shortProjectHash(projectRoot); + const newDir = path.join(getInstancesDir(projectRoot), `inst-${hash}-${instanceId}`); + // Backward compat: if new dir doesn't exist but old format does, use old + if (!fs.existsSync(newDir)) { + const oldDir = path.join(getInstancesDir(projectRoot), `instance-${instanceId}`); + if (fs.existsSync(oldDir)) { + return oldDir; + } + } + return newDir; } function listInstances(projectRoot) { const instancesDir = getInstancesDir(projectRoot); if (!fs.existsSync(instancesDir)) { return []; } + const hash = _shortProjectHash(projectRoot); + const newPrefix = `inst-${hash}-`; return fs.readdirSync(instancesDir) - .filter(name => name.startsWith('instance-')) - .map(name => name.replace('instance-', '')) + .filter(name => name.startsWith(newPrefix) || name.startsWith('instance-')) + .map(name => { + if (name.startsWith(newPrefix)) return name.slice(newPrefix.length); + return name.replace('instance-', ''); + }) .sort((a, b) => parseInt(a) - parseInt(b)); } function isInstanceRunning(projectRoot, instanceId) { From 9069b29f2e585db1f2df9d11d9f147f6774b7f76 Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Wed, 18 Feb 2026 22:05:09 +0800 Subject: [PATCH 08/28] docs: rewrite README as independent CCB Multi project Reposition from upstream fork copy to standalone "Multi-Instance Edition" with own v1.0.0 version line, comparison table, and upstream doc links. --- CHANGELOG.md | 138 ++----- README.md | 934 +++++------------------------------------------- multi/README.md | 18 + 3 files changed, 136 insertions(+), 954 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6f03e2..3bb3616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,126 +1,40 @@ # Changelog -## Unreleased - -## v5.2.5 (2026-02-15) - -### 🔧 Bug Fixes - -- **Async Guardrail**: Added global mandatory turn-stop rule to `claude-md-ccb.md` to prevent Claude from polling after async `ask` submission -- **Marker Consistency**: `bin/ask` now emits `[CCB_ASYNC_SUBMITTED provider=xxx]` matching all other provider scripts -- **SKILL.md DRY**: Ask skill rules reference global guardrail with local fallback, eliminating duplicate maintenance -- **Command References**: Fixed `/ping` → `/cping` and `ping` → `ccb-ping` in docs - -## v5.2.4 (2026-02-11) - -### 🔧 Bug Fixes - -- **Explicit CCB_CALLER**: `bin/ask` no longer defaults to `"claude"` when `CCB_CALLER` is unset; exits with an error instead -- **SKILL.md template**: Ask skill execution template now explicitly passes `CCB_CALLER=claude` - -## v5.2.3 (2026-02-09) - -### 🚀 Project-Local History + Legacy Compatibility - -- **Local History**: Context exports now save to `./.ccb/history/` per project -- **CWD Scope**: Auto transfer runs only for the current working directory -- **Legacy Migration**: Auto-detect `.ccb_config` and upgrade to `.ccb` when possible -- **Claude /continue**: Attach the latest history file with a single skill - -## v5.2.2 (2026-02-04) - -### 🚀 Session Switch Capture - -- **Old Session Fields**: `.claude-session` now records `old_claude_session_id` / `old_claude_session_path` with `old_updated_at` -- **Auto Context Export**: Previous Claude session is extracted to `./.ccb/history/claude--.md` -- **Transfer Cleanup**: Improved noise filtering while preserving tool-only actions - -## v5.1.2 (2026-01-29) - -### 🔧 Bug Fixes & Improvements +All notable changes to CCB Multi are documented here. +This project uses its own version line, independent from upstream CCB. -- **Claude Completion Hook**: Unified askd now triggers completion hook for Claude -- **askd Lifecycle**: askd is bound to CCB lifecycle to avoid stale daemons -- **Mounted Detection**: `ccb-mounted` now uses ping-based detection across all platforms -- **State File Lookup**: `askd_client` falls back to `CCB_RUN_DIR` for daemon state files - -## v5.1.1 (2025-01-28) - -### 🔧 Bug Fixes & Improvements - -- **Unified Daemon**: All providers now use unified askd daemon architecture -- **Install/Uninstall**: Fixed installation and uninstallation bugs -- **Process Management**: Fixed kill/termination issues - -### 🔧 ask Foreground Defaults - -- `bin/ask`: Foreground mode available via `--foreground`; `--background` forces legacy async -- Managed Codex sessions default to foreground to avoid background cleanup -- Environment overrides: `CCB_ASK_FOREGROUND=1` / `CCB_ASK_BACKGROUND=1` -- Foreground runs sync and suppresses completion hook unless `CCB_COMPLETION_HOOK_ENABLED` is set -- `CCB_CALLER` now defaults to `codex` in Codex sessions when unset - -## v5.1.0 (2025-01-26) - -### 🚀 Major Changes: Unified Command System - -**New unified commands replace provider-specific commands:** - -| Old Commands | New Unified Command | -|--------------|---------------------| -| `cask`, `gask`, `oask`, `dask`, `lask` | `ask ` | -| `cping`, `gping`, `oping`, `dping`, `lping` | `ccb-ping ` (skill: `/cping`) | -| `cpend`, `gpend`, `opend`, `dpend`, `lpend` | `pend [N]` | - -**Supported providers:** `gemini`, `codex`, `opencode`, `droid`, `claude` - -### 🪟 Windows WezTerm + PowerShell Support - -- Full support for Windows native environment with WezTerm terminal -- `install.ps1` now generates wrappers for `ask`, `ccb-ping`, `pend`, `ccb-completion-hook` -- Background execution uses PowerShell scripts with `DETACHED_PROCESS` flag -- WezTerm CLI integration with stdin for large payloads (avoids command line length limits) -- UTF-8 BOM handling for PowerShell-generated session files - -### 🔧 Technical Improvements +## Unreleased -- `completion_hook.py`: Uses `sys.executable` for cross-platform script execution -- `ccb-completion-hook`: - - Added `find_wezterm_cli()` with PATH lookup and common install locations - - Support `CCB_WEZTERM_BIN` environment variable - - Uses stdin for WezTerm send-text to handle large payloads -- `bin/ask`: - - Unix: Uses `nohup` for true background execution - - Windows: Uses PowerShell script + message file to avoid escaping issues -- Added `SKILL.md.powershell` for `cping` and `pend` skills +## v1.0.0 (2026-02-18) -### 📦 Skills System +Initial release as independent fork. Based on upstream CCB v5.2.6. -New unified skills: -- `/ask ` - Async request to AI provider -- `/cping ` - Test provider connectivity -- `/pend [N]` - View latest provider reply +### 🚀 Multi-Instance Support -### ⚠️ Breaking Changes +- **ccb-multi**: Launch multiple CCB instances in the same project with independent contexts +- **ccb-multi-status**: Real-time status monitoring for all instances +- **ccb-multi-history**: View instance execution history +- **ccb-multi-clean**: Clean up stale instance directories +- **Collision-Free Naming**: Instance dirs use `inst--N` format (8-char SHA-256 of project root) -- Old provider-specific commands (`cask`, `gask`, etc.) are deprecated -- Old skills (`/cask`, `/gask`, etc.) are removed -- Use new unified commands instead +### 🔧 LLM Communication Fixes (upstream-unmerged) -### 🔄 Migration Guide +- **Gemini CLI 0.29.0 Deadlock**: Dual-format session scanning (basename + SHA-256 hash) with auto-adoption +- **Hash Persistence**: `_all_known_hashes` set survives hash format transitions +- **Daemon work_dir Decoupling**: `--work-dir` parameter and `CCB_WORK_DIR` env for `bin/askd` +- **State Validation**: `bin/ask` validates daemon's `work_dir` with fallback to `cwd` +- **Cross-Hash Guard**: Instance mode blocks cross-hash session override to prevent contamination -```bash -# Old way -cask "What is 1+1?" -gping -cpend +### 🔧 Inherited from Upstream CCB v5.2.5 -# New way -ask codex "What is 1+1?" -ccb-ping gemini -pend codex -``` +- Async Guardrail hardening (global turn-stop rule) +- Marker consistency for `[CCB_ASYNC_SUBMITTED]` +- Project-local history (`.ccb/history/`) +- Session switch capture and context transfer +- Unified command system (`ask`, `ccb-ping`, `pend`) +- Windows WezTerm + PowerShell support +- Email-to-AI gateway (mail system) --- -For older versions, see [CHANGELOG_4.0.md](CHANGELOG_4.0.md) +For upstream CCB changelog prior to this fork, see [CHANGELOG_4.0.md](CHANGELOG_4.0.md) or the [upstream repo](https://github.com/bfly123/claude_code_bridge). diff --git a/README.md b/README.md index 911a7b3..792c3b0 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,16 @@

-# Claude Code Bridge Multi v5.2.5 +# CCB Multi -**Enhanced Multi-Instance CCB with True Concurrent Execution** -**Claude & Codex & Gemini & OpenCode & Droid** -**Run multiple instances in parallel, multiple LLMs working concurrently** +**Claude Code Bridge — Multi-Instance Edition** -

- Every Interaction Visible - Every Model Controllable -

+Run multiple CCB instances in parallel, with LLM communication fixes included. -[![Version](https://img.shields.io/badge/version-5.2.5-orange.svg)]() +[![Version](https://img.shields.io/badge/version-1.0.0-orange.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) -[![CI](https://github.com/bfly123/claude_code_bridge/actions/workflows/test.yml/badge.svg)](https://github.com/bfly123/claude_code_bridge/actions/workflows/test.yml) [![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-lightgrey.svg)]() -**English** | [Chinese](README_zh.md) - ![Showcase](assets/show.png)
@@ -34,903 +26,161 @@ --- -**Introduction:** Multi-model collaboration effectively avoids model bias, cognitive blind spots, and context limitations. However, MCP, Skills and other direct API approaches have many limitations. This project offers a new WYSIWYG solution. - -## ⚡ Why ccb? +## What is CCB Multi? -| Feature | Benefit | -| :--- | :--- | -| **🖥️ Visual & Controllable** | Multiple AI models in split-pane CLI. See everything, control everything. | -| **🧠 Persistent Context** | Each AI maintains its own memory. Close and resume anytime (`-r` flag). | -| **📉 Token Savings** | Sends lightweight prompts instead of full file history. | -| **🪟 Native Workflow** | Integrates directly into **WezTerm** (recommended) or tmux. No complex servers required. | +An enhanced fork of [Claude Code Bridge (CCB)](https://github.com/bfly123/claude_code_bridge) that adds **multi-instance concurrent execution** and includes several upstream-unmerged LLM communication fixes. If you need to run multiple CCB sessions in the same project simultaneously, this is for you. --- -

🚀 What's New

- -
-v5.2.5 - Async Guardrail Hardening - -**🔧 Async Turn-Stop Fix:** -- **Global Guardrail**: Added mandatory `Async Guardrail` rule to `claude-md-ccb.md` — covers both `/ask` skill and direct `Bash(ask ...)` calls -- **Marker Consistency**: `bin/ask` now emits `[CCB_ASYNC_SUBMITTED provider=xxx]` matching all other provider scripts -- **DRY Skills**: Ask skill rules reference global guardrail with local fallback, single source of truth - -This fix prevents Claude from polling/sleeping after submitting async tasks. - -
- -
-v5.2.3 - Project-Local History & Legacy Compatibility - -**📂 Project-Local History:** -- **Local Storage**: Auto context exports now save to `./.ccb/history/` per project -- **Safe Scope**: Auto transfer runs only for the current working directory -- **Claude /continue**: New skill to attach the latest history file via `@` - -**🧩 Legacy Compatibility:** -- **Auto Migration**: `.ccb_config` is detected and upgraded to `.ccb` when possible -- **Fallback Lookup**: Legacy sessions still resolve cleanly during transition - -These changes keep handoff artifacts scoped to the project and make upgrades smoother. - -
- -
-v5.2.2 - Session Switch Capture & Context Transfer - -**🔁 Session Switch Tracking:** -- **Old Session Fields**: `.claude-session` now records `old_claude_session_id` / `old_claude_session_path` with `old_updated_at` -- **Auto Context Export**: Previous Claude session is automatically extracted to `./.ccb/history/claude--.md` -- **Cleaner Transfers**: Noise filtering removes protocol markers and guardrails while keeping tool-only actions - -These updates make session handoff more reliable and easier to audit. - -
- -
-v5.2.1 - Enhanced Ask Command Stability - -**🔧 Stability Improvements:** -- **Watchdog File Monitoring**: Real-time session updates with efficient file watching -- **Mandatory Caller Field**: Improved request tracking and routing reliability -- **Unified Execution Model**: Simplified ask skill execution across all platforms -- **Auto-Dependency Installation**: Watchdog library installed automatically during setup -- **Session Registry**: Enhanced Claude adapter with automatic session monitoring - -These improvements significantly enhance the reliability of cross-AI communication and reduce session binding failures. - -
- -
-v5.2.0 - Email Integration for Remote AI Access - -**📧 New Feature: Mail Service** -- **Email-to-AI Gateway**: Send emails to interact with AI providers remotely -- **Multi-Provider Support**: Gmail, Outlook, QQ, 163 mail presets -- **Provider Routing**: Use body prefix to target specific AI (e.g., `CLAUDE: your question`) -- **Real-time Polling**: IMAP IDLE support for instant email detection -- **Secure Credentials**: System keyring integration for password storage -- **Mail Daemon**: Background service (`maild`) for continuous email monitoring - -See [Mail System Configuration](#-mail-system-configuration) for setup instructions. - -
- -
-v5.1.3 - Tmux Claude Ask Stability - -**🔧 Fixes & Improvements:** -- **tmux Claude ask**: read replies from pane output with automatic pipe-pane logging for more reliable completion - -See [CHANGELOG.md](CHANGELOG.md) for full details. - -
- -
-v5.1.2 - Daemon & Hooks Reliability - -**🔧 Fixes & Improvements:** -- **Claude Completion Hook**: Unified askd now triggers completion hook for Claude -- **askd Lifecycle**: askd is bound to CCB lifecycle to avoid stale daemons -- **Mounted Detection**: `ccb-mounted` uses ping-based detection across all platforms -- **State File Lookup**: `askd_client` falls back to `CCB_RUN_DIR` for daemon state files - -See [CHANGELOG.md](CHANGELOG.md) for full details. - -
- -
-v5.1.1 - Unified Daemon + Bug Fixes - -**🔧 Bug Fixes & Improvements:** -- **Unified Daemon**: All providers now use unified askd daemon architecture -- **Install/Uninstall**: Fixed installation and uninstallation bugs -- **Process Management**: Fixed kill/termination issues - -See [CHANGELOG.md](CHANGELOG.md) for full details. - -
- -
-v5.1.0 - Unified Command System + Windows WezTerm Support - -**🚀 Unified Commands** - Replace provider-specific commands with unified interface: - -| Old Commands | New Unified Command | -|--------------|---------------------| -| `cask`, `gask`, `oask`, `dask`, `lask` | `ask ` | -| `cping`, `gping`, `oping`, `dping`, `lping` | `ccb-ping ` | -| `cpend`, `gpend`, `opend`, `dpend`, `lpend` | `pend [N]` | - -**Supported providers:** `gemini`, `codex`, `opencode`, `droid`, `claude` - -**🪟 Windows WezTerm + PowerShell Support:** -- Full native Windows support with WezTerm terminal -- Background execution using PowerShell + `DETACHED_PROCESS` -- WezTerm CLI integration with stdin for large payloads -- UTF-8 BOM handling for PowerShell compatibility - -**📦 New Skills:** -- `/ask ` - Request to AI provider (background by default) -- `/cping ` - Test provider connectivity -- `/pend [N]` - View latest provider reply - -See [CHANGELOG.md](CHANGELOG.md) for full details. - -
- -
-v5.0.6 - Zombie session cleanup + mounted skill optimization - -- **Zombie Cleanup**: `ccb kill -f` now cleans up orphaned tmux sessions globally (sessions whose parent process has exited) -- **Mounted Skill**: Optimized to use `pgrep` for daemon detection (~4x faster), extracted to standalone `ccb-mounted` script -- **Droid Skills**: Added full skill set (cask/gask/lask/oask + ping/pend variants) to `droid_skills/` -- **Install**: Added `install_droid_skills()` to install Droid skills to `~/.droid/skills/` - -
- -
-v5.0.5 - Droid delegation tools + setup - -- **Droid**: Adds delegation tools (`ccb_ask_*` plus `cask/gask/lask/oask` aliases). -- **Setup**: New `ccb droid setup-delegation` command for MCP registration. -- **Installer**: Auto-registers Droid delegation when `droid` is detected (opt-out via env). - -
-Details & usage - -Usage: -``` -/all-plan -``` - -Example: -``` -/all-plan Design a caching layer for the API with Redis -``` - -Highlights: -- Socratic Ladder + Superpowers Lenses + Anti-pattern analysis. -- Availability-gated dispatch (use only mounted CLIs). -- Two-round reviewer refinement with merged design. - -
-
- -
-v5.0.0 - Any AI as primary driver - -- **Claude Independence**: No need to start Claude first; Codex can act as the primary CLI. -- **Unified Control**: Single entry point controls Claude/OpenCode/Gemini. -- **Simplified Launch**: Dropped `ccb up`; use `ccb ...` or the default `ccb.config`. -- **Flexible Mounting**: More flexible pane mounting and session binding. -- **Default Config**: Auto-create `ccb.config` when missing. -- **Daemon Autostart**: `caskd`/`laskd` auto-start in WezTerm/tmux when needed. -- **Session Robustness**: PID liveness checks prevent stale sessions. - -
- -
-v4.0 - tmux-first refactor - -- **Full Refactor**: Cleaner structure, better stability, and easier extension. -- **Terminal Backend Abstraction**: Unified terminal layer (`TmuxBackend` / `WeztermBackend`) with auto-detection and WSL path handling. -- **Perfect tmux Experience**: Stable layouts + pane titles/borders + session-scoped theming. -- **Works in Any Terminal**: If your terminal can run tmux, CCB can provide the full multi-model split experience (except native Windows; WezTerm recommended; otherwise just use tmux). - -
- -
-v3.0 - Smart daemons - -- **True Parallelism**: Submit multiple tasks to Codex, Gemini, or OpenCode simultaneously. -- **Cross-AI Orchestration**: Claude and Codex can now drive OpenCode agents together. -- **Bulletproof Stability**: Daemons auto-start on first request and stop after idle. -- **Chained Execution**: Codex can delegate to OpenCode for multi-step workflows. -- **Smart Interruption**: Gemini tasks handle interruption safely. - -
-Details - -
+## Differences from Upstream CCB -![Parallel](https://img.shields.io/badge/Strategy-Parallel_Queue-blue?style=flat-square) -![Stability](https://img.shields.io/badge/Daemon-Auto_Managed-green?style=flat-square) -![Interruption](https://img.shields.io/badge/Gemini-Interruption_Aware-orange?style=flat-square) - -
- -

✨ Key Features

- -- **🔄 True Parallelism**: Submit multiple tasks to Codex, Gemini, or OpenCode simultaneously. The new daemons (`caskd`, `gaskd`, `oaskd`) automatically queue and execute them serially, ensuring no context pollution. -- **🤝 Cross-AI Orchestration**: Claude and Codex can now simultaneously drive OpenCode agents. All requests are arbitrated by the unified daemon layer. -- **🛡️ Bulletproof Stability**: Daemons are self-managing—they start automatically on the first request and shut down after 60s of idleness to save resources. -- **⚡ Chained Execution**: Advanced workflows supported! Codex can autonomously call `oask` to delegate sub-tasks to OpenCode models. -- **🛑 Smart Interruption**: Gemini tasks now support intelligent interruption detection, automatically handling stops and ensuring workflow continuity. - -

🧩 Feature Support Matrix

- -| Feature | `caskd` (Codex) | `gaskd` (Gemini) | `oaskd` (OpenCode) | -| :--- | :---: | :---: | :---: | -| **Parallel Queue** | ✅ | ✅ | ✅ | -| **Interruption Awareness** | ✅ | ✅ | - | -| **Response Isolation** | ✅ | ✅ | ✅ | - -
-📊 View Real-world Stress Test Results - -
- -**Scenario 1: Claude & Codex Concurrent Access to OpenCode** -*Both agents firing requests simultaneously, perfectly coordinated by the daemon.* - -| Source | Task | Result | Status | -| :--- | :--- | :--- | :---: | -| 🤖 Claude | `CLAUDE-A` | **CLAUDE-A** | 🟢 | -| 🤖 Claude | `CLAUDE-B` | **CLAUDE-B** | 🟢 | -| 💻 Codex | `CODEX-A` | **CODEX-A** | 🟢 | -| 💻 Codex | `CODEX-B` | **CODEX-B** | 🟢 | - -**Scenario 2: Recursive/Chained Calls** -*Codex autonomously driving OpenCode for a 5-step workflow.* - -| Request | Exit Code | Response | -| :--- | :---: | :--- | -| **ONE** | `0` | `CODEX-ONE` | -| **TWO** | `0` | `CODEX-TWO` | -| **THREE** | `0` | `CODEX-THREE` | -| **FOUR** | `0` | `CODEX-FOUR` | -| **FIVE** | `0` | `CODEX-FIVE` | - -
-
-
+| Feature | Upstream CCB | CCB Multi | +| :--- | :---: | :---: | +| Multi-instance concurrent execution | ❌ | ✅ | +| Gemini CLI 0.29.0 compatibility | ❌ | ✅ | +| Daemon `work_dir` decoupling | ❌ | ✅ | +| Dead-thread detection | ❌ | ✅ | +| Instance dir collision prevention | ❌ | ✅ | --- -## 🚀 Quick Start - -**Step 1:** Install [WezTerm](https://wezfurlong.org/wezterm/) (native `.exe` for Windows) - -**Step 2:** Choose installer based on your environment: - -
-Linux - -```bash -git clone https://github.com/bfly123/claude_code_bridge.git -cd claude_code_bridge -./install.sh install -``` - -
+## What's Fixed -
-macOS +### Gemini CLI 0.29.0 Deadlock +Gemini CLI 0.29.0 changed session storage from SHA-256 hash to directory basename (`~/.gemini/tmp//`). CCB Multi scans both formats and auto-adopts the active one, preventing session hangs. -```bash -git clone https://github.com/bfly123/claude_code_bridge.git -cd claude_code_bridge -./install.sh install -``` +### Daemon work_dir Decoupling +`bin/askd` now accepts `--work-dir` (or `CCB_WORK_DIR` env) to decouple the daemon's project root from the launch directory. `bin/ask` validates the daemon's `work_dir` and falls back to `cwd` with a warning if missing. -> **Note:** If commands not found after install, see [macOS Troubleshooting](#-macos-installation-guide). +### Worker Pool Robustness +- `GeminiLogReader` maintains `_all_known_hashes` set that survives hash format transitions +- Instance mode blocks cross-hash session override to prevent contamination between projects -
+### Instance Directory Basename Collision +Changed from `instance-N` to `inst--N` format (8-char SHA-256 of project root) to prevent cross-project collisions in Gemini CLI's basename-based storage. Old `instance-N` directories are still recognized for backward compatibility. -
-WSL (Windows Subsystem for Linux) +--- -> Use this if your Claude/Codex/Gemini runs in WSL. +## Multi-Instance Usage -> **⚠️ WARNING:** Do NOT install or run ccb as root/administrator. Switch to a normal user first (`su - username` or create one with `adduser`). +### Quick Start ```bash -# Run inside WSL terminal (as normal user, NOT root) -git clone https://github.com/bfly123/claude_code_bridge.git -cd claude_code_bridge -./install.sh install -``` +# Start instance 1 with Gemini +ccb-multi 1 gemini -
+# Start instance 2 with Codex (in another terminal) +ccb-multi 2 codex -
-Windows Native +# Start instance 3 with Claude (in another terminal) +ccb-multi 3 claude -> Use this if your Claude/Codex/Gemini runs natively on Windows. +# Check all instance status +ccb-multi-status -```powershell -git clone https://github.com/bfly123/claude_code_bridge.git -cd claude_code_bridge -powershell -ExecutionPolicy Bypass -File .\install.ps1 install -``` - -- The installer prefers `pwsh.exe` (PowerShell 7+) when available, otherwise `powershell.exe`. -- If a WezTerm config exists, the installer will try to set `config.default_prog` to PowerShell (adds a `-- CCB_WEZTERM_*` block and will prompt before overriding an existing `default_prog`). - -
+# View history +ccb-multi-history -### Run -```bash -ccb # Start providers from ccb.config (default: all four) -ccb codex gemini # Start both -ccb codex gemini opencode claude # Start all four (spaces) -ccb codex,gemini,opencode,claude # Start all four (commas) -ccb -r codex gemini # Resume last session for Codex + Gemini -ccb -a codex gemini opencode # Auto-approval mode with multiple providers -ccb -a -r codex gemini opencode claude # Auto + resume for all providers - -tmux tip: CCB's tmux status/pane theming is enabled only while CCB is running. - -Layout rule: the last provider runs in the current pane. Extras are ordered as `[cmd?, reversed providers]`; the first extra goes to the top-right, then the left column fills top-to-bottom, then the right column fills top-to-bottom. Examples: 4 panes = left2/right2, 5 panes = left2/right3. -Note: `ccb up` is removed; use `ccb ...` or configure `ccb.config`. +# Clean up stale instances +ccb-multi-clean ``` -### Flags -| Flag | Description | Example | -| :--- | :--- | :--- | -| `-r` | Resume previous session context | `ccb -r` | -| `-a` | Auto-mode, skip permission prompts | `ccb -a` | -| `-h` | Show help information | `ccb -h` | -| `-v` | Show version and check for updates | `ccb -v` | - -### ccb.config -Default lookup order: -- `.ccb/ccb.config` (project) -- `~/.ccb/ccb.config` (global) - -Simple format (recommended): -```text -codex,gemini,opencode,claude -``` +### Instance Directory Format -Enable cmd pane (default title/command): -```text -codex,gemini,opencode,claude,cmd -``` +Instances are created under `.ccb-instances/` in the project root: -Advanced JSON (optional, for flags or custom cmd pane): -```json -{ - "providers": ["codex", "gemini", "opencode", "claude"], - "cmd": { "enabled": true, "title": "CCB-Cmd", "start_cmd": "bash" }, - "flags": { "auto": false, "resume": false } -} ``` -Cmd pane participates in the layout as the first extra pane and does not change which AI runs in the current pane. - -### Update -```bash -ccb update # Update ccb to the latest version -ccb update 4 # Update to the highest v4.x.x version -ccb update 4.1 # Update to the highest v4.1.x version -ccb update 4.1.2 # Update to specific version v4.1.2 -ccb uninstall # Uninstall ccb and clean configs -ccb reinstall # Clean then reinstall ccb +.ccb-instances/ + inst-a1b2c3d4-1/ # inst-- + inst-a1b2c3d4-2/ + instance-3/ # Old format: still recognized ``` ---- - -
-🪟 Windows Installation Guide (WSL vs Native) +The `` is an 8-char SHA-256 of the project root path, ensuring globally unique basenames across projects. -> **Key Point:** `ccb/cask/cping/cpend` must run in the **same environment** as `codex/gemini`. The most common issue is environment mismatch causing `cping` to fail. - -Note: The installers also install OS-specific `SKILL.md` variants for Claude/Codex skills: -- Linux/macOS/WSL: bash heredoc templates (`SKILL.md.bash`) -- Native Windows: PowerShell here-string templates (`SKILL.md.powershell`) - -### 1) Prerequisites: Install Native WezTerm - -- Install Windows native WezTerm (`.exe` from official site or via winget), not the Linux version inside WSL. -- Reason: `ccb` in WezTerm mode relies on `wezterm cli` to manage panes. - -### 2) How to Identify Your Environment - -Determine based on **how you installed/run Claude Code/Codex**: - -- **WSL Environment** - - You installed/run via WSL terminal (Ubuntu/Debian) using `bash` (e.g., `curl ... | bash`, `apt`, `pip`, `npm`) - - Paths look like: `/home//...` and you may see `/mnt/c/...` - - Verify: `cat /proc/version | grep -i microsoft` has output, or `echo $WSL_DISTRO_NAME` is non-empty - -- **Native Windows Environment** - - You installed/run via Windows Terminal / WezTerm / PowerShell / CMD (e.g., `winget`, PowerShell scripts) - - Paths look like: `C:\Users\\...` - -### 3) WSL Users: Configure WezTerm to Auto-Enter WSL - -Edit WezTerm config (`%USERPROFILE%\.wezterm.lua`): - -```lua -local wezterm = require 'wezterm' -return { - default_domain = 'WSL:Ubuntu', -- Replace with your distro name -} -``` - -Check distro name with `wsl -l -v` in PowerShell. - -### 4) Troubleshooting: `cping` Not Working - -- **Most common:** Environment mismatch (ccb in WSL but codex in native Windows, or vice versa) -- **Codex session not running:** Run `ccb codex` (or add codex to ccb.config) first -- **WezTerm CLI not found:** Ensure `wezterm` is in PATH -- **Terminal not refreshed:** Restart WezTerm after installation -- **Text sent but not submitted (no Enter) on Windows WezTerm:** Set `CCB_WEZTERM_ENTER_METHOD=key` and ensure your WezTerm supports `wezterm cli send-key` - -
+### Environment Variables -
-🍎 macOS Installation Guide - -### Command Not Found After Installation - -If `ccb`, `cask`, `cping` commands are not found after running `./install.sh install`: - -**Cause:** The install directory (`~/.local/bin`) is not in your PATH. - -**Solution:** - -```bash -# 1. Check if install directory exists -ls -la ~/.local/bin/ - -# 2. Check if PATH includes the directory -echo $PATH | tr ':' '\n' | grep local - -# 3. Check shell config (macOS defaults to zsh) -cat ~/.zshrc | grep local - -# 4. If not configured, add manually -echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc - -# 5. Reload config -source ~/.zshrc -``` - -### WezTerm Not Detecting Commands - -If WezTerm cannot find ccb commands but regular Terminal can: +| Variable | Description | +| :--- | :--- | +| `CCB_INSTANCE_ID` | Instance number (1, 2, 3, ...) | +| `CCB_PROJECT_ROOT` | Original project root path | +| `CCB_WORK_DIR` | Override daemon's working directory | -- WezTerm may use a different shell config -- Add PATH to `~/.zprofile` as well: +### Concurrent LLM Requests Within an Instance ```bash -echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zprofile -``` - -Then restart WezTerm completely (Cmd+Q, reopen). - -
- ---- - -## 🗣️ Usage - -Once started, collaborate naturally. Claude will detect when to delegate tasks. - -**Common Scenarios:** - -- **Code Review:** *"Have Codex review the changes in `main.py`."* -- **Second Opinion:** *"Ask Gemini for alternative implementation approaches."* -- **Pair Programming:** *"Codex writes the backend logic, I'll handle the frontend."* -- **Architecture:** *"Let Codex design the module structure first."* -- **Info Exchange:** *"Fetch 3 rounds of Codex conversation and summarize."* - -### 🎴 Fun & Creative: AI Poker Night! - -> *"Let Claude, Codex and Gemini play Dou Di Zhu! You deal the cards, everyone plays open hand!"* -> -> 🃏 Claude (Landlord) vs 🎯 Codex + 💎 Gemini (Farmers) - -> **Note:** Manual commands (like `cask`, `cping`) are usually invoked by Claude automatically. See Command Reference for details. - ---- - -## 🛠️ Unified Command System - -### Legacy Commands (Deprecated) -- `cask/gask/oask/dask/lask` - Independent ask commands per provider -- `cping/gping/oping/dping/lping` - Independent ping commands -- `cpend/gpend/opend/dpend/lpend` - Independent pend commands - -### Unified Commands -- **`ask `** - Unified request (background by default) - - Supports: `gemini`, `codex`, `opencode`, `droid`, `claude` - - Defaults to background; managed Codex sessions prefer foreground to avoid cleanup - - Override with `--foreground` / `--background` or `CCB_ASK_FOREGROUND=1` / `CCB_ASK_BACKGROUND=1` - - Foreground uses sync send and disables completion hook unless `CCB_COMPLETION_HOOK_ENABLED` is set - - Supports `--notify` for short synchronous notifications - - Supports `CCB_CALLER` (default: `codex` in Codex sessions, otherwise `claude`) - -- **`ccb-ping `** - Unified connectivity test - - Checks if the specified provider's daemon is online - -- **`pend [N]`** - Unified reply fetch - - Fetches latest N replies from the provider - - Optional N specifies number of recent messages - -### Skills System -- `/ask ` - Request skill (background by default; foreground in managed Codex sessions) -- `/cping ` - Connectivity test skill -- `/pend ` - Reply fetch skill - -### Cross-Platform Support -- **Linux/macOS/WSL**: Uses `tmux` as terminal backend -- **Windows WezTerm**: Uses **PowerShell** as terminal backend -- **Windows PowerShell**: Native support via `DETACHED_PROCESS` background execution - -### Completion Hook -- Notifies caller upon task completion -- Supports `CCB_CALLER` targeting (`claude`/`codex`/`droid`) -- Compatible with both tmux and WezTerm backends - - Foreground ask suppresses the hook unless `CCB_COMPLETION_HOOK_ENABLED` is set - ---- - -## 🧩 Skills - -- **/all-plan**: Collaborative multi-AI design with Superpowers brainstorming. - -
-/all-plan details & usage +# Send async requests to multiple LLMs +CCB_CALLER=claude ask gemini "task 1" & +CCB_CALLER=claude ask codex "task 2" & +CCB_CALLER=claude ask opencode "task 3" & +wait -Usage: -``` -/all-plan -``` - -Example: -``` -/all-plan Design a caching layer for the API with Redis +# Check results +pend gemini +pend codex +pend opencode ``` -How it works: -1. **Requirement Refinement** - Socratic questioning to uncover hidden needs -2. **Parallel Independent Design** - Each AI designs independently (no groupthink) -3. **Comparative Analysis** - Merge insights, detect anti-patterns -4. **Iterative Refinement** - Cross-AI review and critique -5. **Final Output** - Actionable implementation plan - -Key features: -- **Socratic Ladder**: 7 structured questions for deep requirement mining -- **Superpowers Lenses**: Systematic alternative exploration (10x scale, remove dependency, invert flow) -- **Anti-pattern Detection**: Proactive risk identification across all designs - -When to use: -- Complex features requiring diverse perspectives -- Architectural decisions with multiple valid approaches -- High-stakes implementations needing thorough validation - -
- --- -## 📧 Mail System Configuration - -The mail system allows you to interact with AI providers via email, enabling remote access when you're away from your terminal. - -### How It Works - -1. **Send an email** to your CCB service mailbox -2. **Specify the AI provider** using a prefix in the email body (e.g., `CLAUDE: your question`) -3. **CCB routes the request** to the specified AI provider via the ASK system -4. **Receive the response** via email reply +## Installation -### Quick Setup +### Option 1: Full Install (clone this repo) -**Step 1: Run the configuration wizard** ```bash -maild setup -``` - -**Step 2: Choose your email provider** -- Gmail -- Outlook -- QQ Mail -- 163 Mail -- Custom IMAP/SMTP - -**Step 3: Enter credentials** -- Service email address (CCB's mailbox) -- App password (not your regular password - see provider-specific instructions below) -- Target email (where to send replies) - -**Step 4: Start the mail daemon** -```bash -maild start -``` - -### Configuration File - -Configuration is stored in `~/.ccb/mail/config.json`: - -```json -{ - "version": 3, - "enabled": true, - "service_account": { - "provider": "gmail", - "email": "your-ccb-service@gmail.com", - "imap": {"host": "imap.gmail.com", "port": 993, "ssl": true}, - "smtp": {"host": "smtp.gmail.com", "port": 587, "starttls": true} - }, - "target_email": "your-phone@example.com", - "default_provider": "claude", - "polling": { - "use_idle": true, - "idle_timeout": 300 - } -} +git clone https://github.com/daniellee2015/claude_code_bridge_multi.git +cd claude_code_bridge_multi +./install.sh install ``` -### Provider-Specific Setup +This installs the full CCB + multi-instance tooling. -
-Gmail - -1. Enable 2-Step Verification in your Google Account -2. Go to [App Passwords](https://myaccount.google.com/apppasswords) -3. Generate a new app password for "Mail" -4. Use this 16-character password (not your Google password) - -
+### Option 2: Add Multi to Existing CCB -
-Outlook / Office 365 - -1. Enable 2-Step Verification in your Microsoft Account -2. Go to [Security > App Passwords](https://account.live.com/proofs/AppPassword) -3. Generate a new app password -4. Use this password for CCB mail configuration - -
+If you already have upstream CCB installed: -
-QQ Mail - -1. Log in to QQ Mail web interface -2. Go to Settings > Account -3. Enable IMAP/SMTP service -4. Generate an authorization code (授权码) -5. Use this authorization code as the password - -
- -
-163 Mail - -1. Log in to 163 Mail web interface -2. Go to Settings > POP3/SMTP/IMAP -3. Enable IMAP service -4. Set an authorization password (客户端授权密码) -5. Use this authorization password for CCB - -
- -### Email Format - -**Basic format:** -``` -Subject: Any subject (ignored) -Body: -CLAUDE: What is the weather like today? +```bash +cd claude_code_bridge_multi/multi +npm install +npm link ``` -**Supported provider prefixes:** -- `CLAUDE:` or `claude:` - Route to Claude -- `CODEX:` or `codex:` - Route to Codex -- `GEMINI:` or `gemini:` - Route to Gemini -- `OPENCODE:` or `opencode:` - Route to OpenCode -- `DROID:` or `droid:` - Route to Droid - -If no prefix is specified, the request goes to the `default_provider` (default: `claude`). +This makes `ccb-multi`, `ccb-multi-status`, `ccb-multi-history`, and `ccb-multi-clean` available globally. -### Mail Daemon Commands +### Update & Uninstall ```bash -maild start # Start the mail daemon -maild stop # Stop the mail daemon -maild status # Check daemon status -maild config # Show current configuration -maild setup # Run configuration wizard -maild test # Test email connectivity +ccb update # Update to latest version +ccb uninstall # Uninstall +ccb reinstall # Clean reinstall ``` --- -Neovim integration with multi-AI code review - -> Combine with editors like **Neovim** for seamless code editing and multi-model review workflow. Edit in your favorite editor while AI assistants review and suggest improvements in real-time. - ---- +## Base CCB Documentation -## 📋 Requirements +For core CCB usage, command reference, skills system, mail service, and platform-specific guides, see the [upstream CCB README](https://github.com/bfly123/claude_code_bridge#readme). -- **Python 3.10+** -- **Terminal:** [WezTerm](https://wezfurlong.org/wezterm/) (Highly Recommended) or tmux +Key topics covered there: +- `ccb` launch flags (`-r`, `-a`, `-h`, `-v`) +- `ccb.config` format +- Unified command system (`ask`, `ccb-ping`, `pend`) +- Skills (`/all-plan`, `/ask`, `/cping`, `/pend`) +- Mail system configuration +- Windows / WSL / macOS installation guides --- -## 🗑️ Uninstall +## Version -```bash -ccb uninstall -ccb reinstall +**1.0.0** — Independent version line, forked from upstream CCB v5.2.6. -# Fallback: -./install.sh uninstall -``` +See [CHANGELOG.md](CHANGELOG.md) for details. ---
-**Windows fully supported** (WSL + Native via WezTerm) - ---- - -**Join our community** - -📧 Email: bfly123@126.com -💬 WeChat: seemseam-com - -WeChat Group +**[Upstream CCB](https://github.com/bfly123/claude_code_bridge)** · **[Issues](https://github.com/daniellee2015/claude_code_bridge_multi/issues)**
- ---- - -
-Version History - -### v5.0.6 -- **Zombie Cleanup**: `ccb kill -f` cleans up orphaned tmux sessions globally -- **Mounted Skill**: Optimized with `pgrep`, extracted to `ccb-mounted` script -- **Droid Skills**: Full skill set added to `droid_skills/` - -### v5.0.5 -- **Droid**: Add delegation tools (`ccb_ask_*` and `cask/gask/lask/oask`) plus `ccb droid setup-delegation` for MCP install - -### v5.0.4 -- **OpenCode**: 修复 `-r` 恢复在多项目切换后失效的问题 - -### v5.0.3 -- **Daemons**: 全新的稳定守护进程设计 - -### v5.0.1 -- **Skills**: New `/all-plan` with Superpowers brainstorming + availability gating; Codex `lping/lpend` added; `gask` keeps brief summaries with `CCB_DONE`. -- **Status Bar**: Role label now reads role name from `.autoflow/roles.json` (supports `_meta.name`) and caches per path. -- **Installer**: Copy skill subdirectories (e.g., `references/`) for Claude/Codex installs. -- **CLI**: Added `ccb uninstall` / `ccb reinstall` with Claude config cleanup. -- **Routing**: Tighter project/session resolution (prefer `.ccb` anchor; avoid cross-project Claude session mismatches). - -### v5.0.0 -- **Claude Independence**: No need to start Claude first; Codex (or any agent) can be the primary CLI -- **Unified Control**: Single entry point controls Claude/OpenCode/Gemini equally -- **Simplified Launch**: Removed `ccb up`; default `ccb.config` is auto-created when missing -- **Flexible Mounting**: More flexible pane mounting and session binding -- **Daemon Autostart**: `caskd`/`laskd` auto-start in WezTerm/tmux when needed -- **Session Robustness**: PID liveness checks prevent stale sessions - -### v4.1.3 -- **Codex Config**: Automatically migrate deprecated `sandbox_mode = "full-auto"` to `"danger-full-access"` to fix Codex startup -- **Stability**: Fixed race conditions where fast-exiting commands could close panes before `remain-on-exit` was set -- **Tmux**: More robust pane detection (prefer stable `$TMUX_PANE` env var) and better fallback when split targets disappear - -### v4.1.2 -- **Performance**: Added caching for tmux status bar (git branch & ccb status) to reduce system load -- **Strict Tmux**: Explicitly require `tmux` for auto-launch; removed error-prone auto-attach logic -- **CLI**: Added `--print-version` flag for fast version checks - -### v4.1.1 -- **CLI Fix**: Improved flag preservation (e.g., `-a`) when relaunching `ccb` in tmux -- **UX**: Better error messages when running in non-interactive sessions -- **Install**: Force update skills to ensure latest versions are applied - -### v4.1.0 -- **Async Guardrail**: `cask/gask/oask` prints a post-submit guardrail reminder for Claude -- **Sync Mode**: add `--sync` to suppress guardrail prompts for Codex callers -- **Codex Skills**: update `oask/gask` skills to wait silently with `--sync` - -### v4.0.9 -- **Project_ID Simplification**: `ccb_project_id` uses current-directory `.ccb/` anchor (no ancestor traversal, no git dependency) -- **Codex Skills Stability**: Codex `oask/gask` skills default to waiting (`--timeout -1`) to avoid sending the next task too early - -### v4.0.8 -- **Daemon Log Binding Refresh**: `caskd` daemon now periodically refreshes `.codex-session` log paths by parsing `start_cmd` and scanning latest logs -- **Tmux Clipboard Enhancement**: Added `xsel` support and `update-environment` for better clipboard integration across GUI/remote sessions - -### v4.0.7 -- **Tmux Status Bar Redesign**: Dual-line status bar with modern dot indicators (●/○), git branch, and CCB version display -- **Session Freshness**: Always scan logs for latest session instead of using cached session file -- **Simplified Auto Mode**: `ccb -a` now purely uses `--dangerously-skip-permissions` - -### v4.0.6 -- **Session Overrides**: `cping/gping/oping/cpend/opend` support `--session-file` / `CCB_SESSION_FILE` to bypass wrong `cwd` - -### v4.0.5 -- **Gemini Reliability**: Retry reading Gemini session JSON to avoid transient partial-write failures -- **Claude Code Reliability**: `gpend` supports `--session-file` / `CCB_SESSION_FILE` to bypass wrong `cwd` - -### v4.0.4 -- **Fix**: Auto-repair duplicate `[projects.\"...\"]` entries in `~/.codex/config.toml` before starting Codex - -### v4.0.3 -- **Project Cleanliness**: Store session files under `.ccb/` (fallback to legacy root dotfiles) -- **Claude Code Reliability**: `cask/gask/oask` support `--session-file` / `CCB_SESSION_FILE` to bypass wrong `cwd` -- **Codex Config Safety**: Write auto-approval settings into a CCB-marked block to avoid config conflicts - -### v4.0.2 -- **Clipboard Paste**: Cross-platform support (xclip/wl-paste/pbpaste) in tmux config -- **Install UX**: Auto-reload tmux config after installation -- **Stability**: Default TMUX_ENTER_DELAY set to 0.5s for better reliability - -### v4.0.1 -- **Tokyo Night Theme**: Switch tmux status bar and pane borders to Tokyo Night color palette - -### v4.0 -- **Full Refactor**: Rebuilt from the ground up with a cleaner architecture -- **Perfect tmux Support**: First-class splits, pane labels, borders and statusline -- **Works in Any Terminal**: Recommended to run everything in tmux (except native Windows) - -### v3.0.0 -- **Smart Daemons**: `caskd`/`gaskd`/`oaskd` with 60s idle timeout & parallel queue support -- **Cross-AI Collaboration**: Support multiple agents (Claude/Codex) calling one agent (OpenCode) simultaneously -- **Interruption Detection**: Gemini now supports intelligent interruption handling -- **Chained Execution**: Codex can call `oask` to drive OpenCode -- **Stability**: Robust queue management and lock files - -### v2.3.9 -- Fix oask session tracking bug - follow new session when OpenCode creates one - -### v2.3.8 -- Plan mode enabled for autoflow projects regardless of `-a` flag - -### v2.3.7 -- Per-directory lock: different working directories can run cask/gask/oask independently - -### v2.3.6 -- Add non-blocking lock for cask/gask/oask to prevent concurrent requests -- Unify oask with cask/gask logic (use _wait_for_complete_reply) - -### v2.3.5 -- Fix plan mode conflict with auto mode (--dangerously-skip-permissions) -- Fix oask returning stale reply when OpenCode still processing - -### v2.3.4 -- Auto-enable plan mode when autoflow is installed - -### v2.3.3 -- Simplify cping.md to match oping/gping style (~65% token reduction) - -### v2.3.2 -- Optimize skill files: extract common patterns to docs/async-ask-pattern.md (~60% token reduction) - -### v2.3.1 -- Fix race condition in gask/cask: pre-check for existing messages before wait loop - -
diff --git a/multi/README.md b/multi/README.md index a09c4ff..9569e94 100644 --- a/multi/README.md +++ b/multi/README.md @@ -8,6 +8,7 @@ Multi-instance support for Claude Code Bridge with true concurrent execution. - **⚡ Concurrent LLM Execution**: Multiple AI providers (Claude, Codex, Gemini) work in parallel, not sequentially - **📊 Real-time Status Monitoring**: Check all instance status with `ccb-multi-status` - **🧹 Instance Management**: Create, list, and clean instances easily +- **🔒 Collision-Free Naming**: Instance dirs use `inst--N` format (8-char SHA-256 of project root) to prevent cross-project collisions in Gemini CLI 0.29.0's basename-based session storage ## Quick Start @@ -55,6 +56,23 @@ pend opencode - **Concurrent Workers**: Different sessions execute in parallel automatically - **Shared Resources**: Worker pool and file watchers are shared efficiently +## Instance Directory Format + +Instances are created under `.ccb-instances/` in the project root: + +``` +.ccb-instances/ + inst-a1b2c3d4-1/ # New format: inst-- + inst-a1b2c3d4-2/ + instance-3/ # Old format: still recognized for backward compat +``` + +The `` is an 8-character SHA-256 hash of the project root path, ensuring globally unique directory basenames across different projects. + +Environment variables set per instance: +- `CCB_INSTANCE_ID` - Instance number (1, 2, 3, ...) +- `CCB_PROJECT_ROOT` - Original project root path + ## Commands - `ccb-multi [providers...]` - Start an instance From a641b16d48db1d2d3363778de61a97b82cde8949 Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Wed, 18 Feb 2026 22:43:43 +0800 Subject: [PATCH 09/28] docs: link npm package for standalone multi-instance install --- README.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 792c3b0..9bc9909 100644 --- a/README.md +++ b/README.md @@ -135,17 +135,16 @@ cd claude_code_bridge_multi This installs the full CCB + multi-instance tooling. -### Option 2: Add Multi to Existing CCB +### Option 2: npm Package Only (with existing upstream CCB) -If you already have upstream CCB installed: +If you already have upstream CCB installed and only want the multi-instance CLI: ```bash -cd claude_code_bridge_multi/multi -npm install -npm link +npm install -g ccb-multi ``` -This makes `ccb-multi`, `ccb-multi-status`, `ccb-multi-history`, and `ccb-multi-clean` available globally. +This installs `ccb-multi`, `ccb-multi-status`, `ccb-multi-history`, and `ccb-multi-clean` globally. +Source: [github.com/daniellee2015/ccb-multi](https://github.com/daniellee2015/ccb-multi) ### Update & Uninstall From c808032dcb103f643bd41222359712d9c581f24c Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Thu, 19 Feb 2026 10:21:43 +0800 Subject: [PATCH 10/28] feat: enhance ccb-cleanup with PID management and verbose mode - Add --kill-pid to kill specific daemon by PID - Add -v/--verbose to show detailed daemon info (work_dir, port, host) - Add -i/--interactive for interactive daemon selection - Improve daemon listing with more context Fixes issue where ccb-kill alias kills all processes indiscriminately --- bin/ccb-cleanup | 88 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 84 insertions(+), 4 deletions(-) diff --git a/bin/ccb-cleanup b/bin/ccb-cleanup index 74a1e1b..a4629b8 100755 --- a/bin/ccb-cleanup +++ b/bin/ccb-cleanup @@ -65,7 +65,7 @@ def cleanup_stale_locks(): return removed -def list_running_daemons(): +def list_running_daemons(verbose=False): """List all running askd daemons.""" cache_dir = Path.home() / ".cache" / "ccb" / "projects" if not cache_dir.exists(): @@ -82,32 +82,107 @@ def list_running_daemons(): if pid > 0 and is_pid_alive(pid): parent_alive = is_pid_alive(parent_pid) if parent_pid > 0 else False project_hash = state_file.parent.name - daemons.append({ + daemon_info = { "pid": pid, "parent_pid": parent_pid, "parent_alive": parent_alive, "project_hash": project_hash, "started_at": data.get("started_at", "unknown"), - }) + } + + if verbose: + daemon_info.update({ + "work_dir": data.get("work_dir", "unknown"), + "port": data.get("port", "unknown"), + "host": data.get("host", "unknown"), + "managed": data.get("managed", False), + }) + + daemons.append(daemon_info) except Exception: pass return daemons +def kill_daemon_by_pid(pid: int) -> bool: + """Kill a specific daemon by PID.""" + if not is_pid_alive(pid): + print(f"PID {pid} is not running", file=sys.stderr) + return False + + try: + os.kill(pid, 15) # SIGTERM + print(f"✅ Killed daemon PID {pid}") + return True + except Exception as e: + print(f"❌ Failed to kill PID {pid}: {e}", file=sys.stderr) + return False + + +def interactive_kill(): + """Interactive mode to select and kill daemons.""" + daemons = list_running_daemons(verbose=True) + if not daemons: + print("No running daemons found") + return + + print("=== Running askd daemons ===\n") + for idx, d in enumerate(daemons, 1): + status = "ZOMBIE (parent dead)" if not d["parent_alive"] else "OK" + print(f"{idx}. PID {d['pid']} (parent {d['parent_pid']}) - {status}") + print(f" Project: {d['project_hash']}") + print(f" Work Dir: {d.get('work_dir', 'unknown')}") + print(f" Started: {d['started_at']}") + print() + + try: + choice = input("Enter daemon number to kill (or 'q' to quit): ").strip() + if choice.lower() == 'q': + print("Cancelled") + return + + idx = int(choice) + if 1 <= idx <= len(daemons): + daemon = daemons[idx - 1] + confirm = input(f"Kill PID {daemon['pid']}? (y/N): ").strip().lower() + if confirm == 'y': + kill_daemon_by_pid(daemon['pid']) + else: + print("Cancelled") + else: + print(f"Invalid choice: {idx}", file=sys.stderr) + except (ValueError, KeyboardInterrupt): + print("\nCancelled") + + def main(): import argparse parser = argparse.ArgumentParser(description="Clean up CCB zombie daemons and stale files") parser.add_argument("--list", action="store_true", help="List running daemons") + parser.add_argument("-v", "--verbose", action="store_true", help="Show detailed daemon info") parser.add_argument("--clean", action="store_true", help="Clean stale files") parser.add_argument("--kill-zombies", action="store_true", help="Kill zombie daemons (parent dead)") + parser.add_argument("--kill-pid", type=int, metavar="PID", help="Kill specific daemon by PID") + parser.add_argument("-i", "--interactive", action="store_true", help="Interactive mode to select daemon to kill") args = parser.parse_args() + # Interactive mode + if args.interactive: + interactive_kill() + return + + # Kill specific PID + if args.kill_pid: + kill_daemon_by_pid(args.kill_pid) + return + + # List daemons if args.list or not (args.clean or args.kill_zombies): print("=== Running askd daemons ===") - daemons = list_running_daemons() + daemons = list_running_daemons(verbose=args.verbose) if not daemons: print("No running daemons found") else: @@ -116,6 +191,11 @@ def main(): print(f" PID {d['pid']} (parent {d['parent_pid']}) - {status}") print(f" Project: {d['project_hash']}") print(f" Started: {d['started_at']}") + if args.verbose: + print(f" Work Dir: {d.get('work_dir', 'unknown')}") + print(f" Port: {d.get('port', 'unknown')}") + print(f" Host: {d.get('host', 'unknown')}") + print(f" Managed: {d.get('managed', False)}") if args.clean: print("\n=== Cleaning stale files ===") From 0086e2f36ca9fa8b6d8beed0f5f7a004d4f36838 Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Thu, 19 Feb 2026 10:29:15 +0800 Subject: [PATCH 11/28] docs: add process management section for ccb-cleanup enhancements Document new ccb-cleanup features: - List daemons with verbose mode - Kill specific daemon by PID - Interactive daemon selection - Cleanup operations Clarify difference between ccb-kill alias and ccb-cleanup --kill-pid --- README.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/README.md b/README.md index 9bc9909..c5d1a12 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,55 @@ pend opencode --- +## Process Management + +### List Running Daemons + +```bash +# Simple list +ccb-cleanup --list + +# Detailed info (work_dir, port, host) +ccb-cleanup --list -v +``` + +Example output: +``` +=== Running askd daemons === + PID 26639 (parent 26168) - OK + Project: ad4f88fa5c5269a3 + Started: 2026-02-19 10:05:35 + Work Dir: /Users/user/project/.ccb-instances/instance-1 + Port: 65108 + Host: 127.0.0.1 +``` + +### Kill Specific Daemon + +```bash +# Kill by PID +ccb-cleanup --kill-pid 26639 + +# Interactive selection +ccb-cleanup -i +``` + +Interactive mode shows a numbered list of daemons and prompts for selection with confirmation. + +### Cleanup Operations + +```bash +# Kill zombie daemons (parent process dead) +ccb-cleanup --kill-zombies + +# Clean stale state files and locks +ccb-cleanup --clean +``` + +**Note**: The shell alias `ccb-kill` kills ALL CCB processes indiscriminately. Use `ccb-cleanup --kill-pid` for precise control. + +--- + ## Installation ### Option 1: Full Install (clone this repo) From 27ddcd9580d3e3fca2aa03940f6ad8ca35ecafe7 Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Thu, 19 Feb 2026 11:35:04 +0800 Subject: [PATCH 12/28] feat: add tmux pane ID display to ccb-cleanup - Add get_tmux_pane_for_workdir() to find tmux pane by work directory - Display tmux pane ID in verbose mode (--list -v) - Display tmux pane ID in interactive mode (-i) - Helps users identify which tmux window corresponds to each daemon This makes it easier to navigate to the correct tmux pane when managing multiple CCB instances. --- bin/ccb-cleanup | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/bin/ccb-cleanup b/bin/ccb-cleanup index a4629b8..57c21db 100755 --- a/bin/ccb-cleanup +++ b/bin/ccb-cleanup @@ -65,6 +65,27 @@ def cleanup_stale_locks(): return removed +def get_tmux_pane_for_workdir(work_dir: str) -> str: + """Find tmux pane ID for a given work directory.""" + try: + import subprocess + result = subprocess.run( + ["tmux", "list-panes", "-a", "-F", "#{pane_id}\t#{pane_current_path}"], + capture_output=True, + text=True, + timeout=2.0 + ) + if result.returncode == 0: + for line in result.stdout.strip().split('\n'): + if '\t' in line: + pane_id, pane_path = line.split('\t', 1) + if pane_path == work_dir: + return pane_id + except Exception: + pass + return "unknown" + + def list_running_daemons(verbose=False): """List all running askd daemons.""" cache_dir = Path.home() / ".cache" / "ccb" / "projects" @@ -96,6 +117,7 @@ def list_running_daemons(verbose=False): "port": data.get("port", "unknown"), "host": data.get("host", "unknown"), "managed": data.get("managed", False), + "tmux_pane": get_tmux_pane_for_workdir(data.get("work_dir", "")), }) daemons.append(daemon_info) @@ -130,7 +152,9 @@ def interactive_kill(): print("=== Running askd daemons ===\n") for idx, d in enumerate(daemons, 1): status = "ZOMBIE (parent dead)" if not d["parent_alive"] else "OK" + tmux_pane = d.get('tmux_pane', 'unknown') print(f"{idx}. PID {d['pid']} (parent {d['parent_pid']}) - {status}") + print(f" Tmux Pane: {tmux_pane}") print(f" Project: {d['project_hash']}") print(f" Work Dir: {d.get('work_dir', 'unknown')}") print(f" Started: {d['started_at']}") @@ -196,6 +220,8 @@ def main(): print(f" Port: {d.get('port', 'unknown')}") print(f" Host: {d.get('host', 'unknown')}") print(f" Managed: {d.get('managed', False)}") + tmux_pane = d.get('tmux_pane', 'unknown') + print(f" Tmux Pane: {tmux_pane}") if args.clean: print("\n=== Cleaning stale files ===") From 9a77d6177ed0d48720b153e17abb2987eeaa5ff6 Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Thu, 19 Feb 2026 17:05:48 +0800 Subject: [PATCH 13/28] fix(opencode): add SQLite database support and improve session discovery OpenCode 0.29.0+ migrated from JSON file storage to SQLite database. This commit adds full SQLite support with backward compatibility. Changes: - Add SQLite database reading for sessions, messages, and parts - Implement session discovery from database with improved matching - Query LIMIT increased from 50 to 200 sessions - Find most recent matching session instead of first match - Fixes issue where other projects' sessions pushed target out of results - Enable reasoning fallback for text extraction - Handles OpenCode responses in "reasoning" type parts - Maintain backward compatibility with JSON file storage - Add comprehensive test coverage for SQLite operations Fixes communication detection issue where OpenCode completes tasks but CCB doesn't receive replies. Co-authored-by: Codex Co-authored-by: Gemini --- lib/opencode_comm.py | 277 ++++++++++++++++++++++++++---- test/test_opencode_comm_sqlite.py | 139 +++++++++++++++ 2 files changed, 383 insertions(+), 33 deletions(-) create mode 100644 test/test_opencode_comm_sqlite.py diff --git a/lib/opencode_comm.py b/lib/opencode_comm.py index a06575c..a2a6daf 100644 --- a/lib/opencode_comm.py +++ b/lib/opencode_comm.py @@ -11,6 +11,7 @@ import os import re import shutil +import sqlite3 import sys import time import threading @@ -398,12 +399,13 @@ def _parse_opencode_log_epoch_s(line: str) -> float | None: class OpenCodeLogReader: """ - Reads OpenCode session/message/part JSON files. + Reads OpenCode session/message/part data from storage JSON files or SQLite. Observed storage layout: storage/session//ses_*.json storage/message//msg_*.json storage/part//prt_*.json + ../opencode.db (message/part tables) """ def __init__( @@ -442,6 +444,7 @@ def __init__( except Exception: force = 1.0 self._force_read_interval = min(5.0, max(0.2, force)) + self._db_path_hint: Path | None = None def _session_dir(self) -> Path: return self.root / "session" / self.project_id @@ -488,6 +491,107 @@ def _load_json(self, path: Path) -> dict: except Exception: return {} + def _load_json_blob(self, raw: Any) -> dict: + if isinstance(raw, dict): + return raw + if not isinstance(raw, str) or not raw: + return {} + try: + payload = json.loads(raw) + except Exception: + return {} + return payload if isinstance(payload, dict) else {} + + def _opencode_db_candidates(self) -> list[Path]: + candidates: list[Path] = [] + env = (os.environ.get("OPENCODE_DB_PATH") or "").strip() + if env: + candidates.append(Path(env).expanduser()) + + # OpenCode currently stores the DB one level above storage/, but keep root-local fallback. + candidates.append(self.root.parent / "opencode.db") + candidates.append(self.root / "opencode.db") + + out: list[Path] = [] + seen: set[str] = set() + for candidate in candidates: + key = str(candidate) + if key in seen: + continue + seen.add(key) + out.append(candidate) + return out + + def _resolve_opencode_db_path(self) -> Path | None: + if self._db_path_hint: + try: + if self._db_path_hint.exists(): + return self._db_path_hint + except Exception: + pass + + for candidate in self._opencode_db_candidates(): + try: + if candidate.exists() and candidate.is_file(): + self._db_path_hint = candidate + return candidate + except Exception: + continue + self._db_path_hint = None + return None + + def _fetch_opencode_db_rows(self, query: str, params: tuple[object, ...]) -> list[sqlite3.Row]: + db_path = self._resolve_opencode_db_path() + if not db_path: + return [] + conn: sqlite3.Connection | None = None + try: + try: + db_uri = f"{db_path.resolve().as_uri()}?mode=ro" + conn = sqlite3.connect(db_uri, uri=True, timeout=0.2) + except Exception: + conn = sqlite3.connect(str(db_path), timeout=0.2) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA busy_timeout = 200") + rows = conn.execute(query, params).fetchall() + return [row for row in rows if isinstance(row, sqlite3.Row)] + except Exception: + return [] + finally: + try: + if conn is not None: + conn.close() + except Exception: + pass + + @staticmethod + def _message_sort_key(m: dict) -> tuple[int, float, str]: + created = (m.get("time") or {}).get("created") + try: + created_i = int(created) + except Exception: + created_i = -1 + try: + mtime = Path(m.get("_path", "")).stat().st_mtime if m.get("_path") else 0.0 + except Exception: + mtime = 0.0 + mid = m.get("id") if isinstance(m.get("id"), str) else "" + return created_i, mtime, mid + + @staticmethod + def _part_sort_key(p: dict) -> tuple[int, float, str]: + ts = (p.get("time") or {}).get("start") + try: + ts_i = int(ts) + except Exception: + ts_i = -1 + try: + mtime = Path(p.get("_path", "")).stat().st_mtime if p.get("_path") else 0.0 + except Exception: + mtime = 0.0 + pid = p.get("id") if isinstance(p.get("id"), str) else "" + return ts_i, mtime, pid + def _detect_project_id_for_workdir(self) -> Optional[str]: """ Auto-detect OpenCode projectID based on storage/project/*.json. @@ -545,6 +649,60 @@ def _detect_project_id_for_workdir(self) -> Optional[str]: return best_id def _get_latest_session(self) -> Optional[dict]: + session = self._get_latest_session_from_db() + if session: + return session + return self._get_latest_session_from_files() + + def _get_latest_session_from_db(self) -> Optional[dict]: + candidates = self._work_dir_candidates() + if not candidates: + return None + + # Fetch more sessions to ensure we find matches even if other projects are more active + rows = self._fetch_opencode_db_rows("SELECT * FROM session ORDER BY time_updated DESC LIMIT 200", ()) + + best_match: dict | None = None + best_updated = -1 + + for row in rows: + directory = row["directory"] + if not directory: + continue + + sid = row["id"] + if self._session_id_filter and sid != self._session_id_filter: + continue + + updated = row["time_updated"] + + # Match directory + dir_norm = _normalize_path_for_match(directory) + matched = False + for cwd in candidates: + if self._allow_parent_match: + if _path_is_same_or_parent(dir_norm, cwd) or _path_is_same_or_parent(cwd, dir_norm): + matched = True + break + else: + if dir_norm == cwd: + matched = True + break + + if matched and updated > best_updated: + best_match = { + "path": None, # DB doesn't have a path + "payload": { + "id": sid, + "directory": directory, + "time": {"updated": updated} + } + } + best_updated = updated + + return best_match + + def _get_latest_session_from_files(self) -> Optional[dict]: sessions_dir = self._session_dir() if not sessions_dir.exists(): return None @@ -642,6 +800,16 @@ def _get_latest_session(self) -> Optional[dict]: return None def _read_messages(self, session_id: str) -> List[dict]: + messages = self._read_messages_from_db(session_id) + if messages: + messages.sort(key=self._message_sort_key) + return messages + + messages = self._read_messages_from_files(session_id) + messages.sort(key=self._message_sort_key) + return messages + + def _read_messages_from_files(self, session_id: str) -> List[dict]: message_dir = self._message_dir(session_id) if not message_dir.exists(): return [] @@ -656,24 +824,51 @@ def _read_messages(self, session_id: str) -> List[dict]: continue payload["_path"] = str(path) messages.append(payload) - # Sort by created time (ms), fallback to mtime - def _key(m: dict) -> tuple[int, float, str]: - created = (m.get("time") or {}).get("created") - try: - created_i = int(created) - except Exception: - created_i = -1 - try: - mtime = Path(m.get("_path", "")).stat().st_mtime if m.get("_path") else 0.0 - except Exception: - mtime = 0.0 - mid = m.get("id") if isinstance(m.get("id"), str) else "" - return created_i, mtime, mid + return messages + + def _read_messages_from_db(self, session_id: str) -> List[dict]: + rows = self._fetch_opencode_db_rows( + """ + SELECT id, session_id, time_created, time_updated, data + FROM message + WHERE session_id = ? + ORDER BY time_created ASC, time_updated ASC, id ASC + """, + (session_id,), + ) + if not rows: + return [] - messages.sort(key=_key) + messages: list[dict] = [] + for row in rows: + payload = self._load_json_blob(row["data"]) + if not payload: + payload = {} + + payload.setdefault("id", row["id"]) + payload.setdefault("sessionID", row["session_id"]) + time_data = payload.get("time") + if not isinstance(time_data, dict): + time_data = {} + if time_data.get("created") is None: + time_data["created"] = row["time_created"] + if time_data.get("updated") is None: + time_data["updated"] = row["time_updated"] + payload["time"] = time_data + messages.append(payload) return messages def _read_parts(self, message_id: str) -> List[dict]: + parts = self._read_parts_from_db(message_id) + if parts: + parts.sort(key=self._part_sort_key) + return parts + + parts = self._read_parts_from_files(message_id) + parts.sort(key=self._part_sort_key) + return parts + + def _read_parts_from_files(self, message_id: str) -> List[dict]: part_dir = self._part_dir(message_id) if not part_dir.exists(): return [] @@ -688,21 +883,39 @@ def _read_parts(self, message_id: str) -> List[dict]: continue payload["_path"] = str(path) parts.append(payload) + return parts - def _key(p: dict) -> tuple[int, float, str]: - ts = (p.get("time") or {}).get("start") - try: - ts_i = int(ts) - except Exception: - ts_i = -1 - try: - mtime = Path(p.get("_path", "")).stat().st_mtime if p.get("_path") else 0.0 - except Exception: - mtime = 0.0 - pid = p.get("id") if isinstance(p.get("id"), str) else "" - return ts_i, mtime, pid + def _read_parts_from_db(self, message_id: str) -> List[dict]: + rows = self._fetch_opencode_db_rows( + """ + SELECT id, message_id, session_id, time_created, time_updated, data + FROM part + WHERE message_id = ? + ORDER BY time_created ASC, time_updated ASC, id ASC + """, + (message_id,), + ) + if not rows: + return [] - parts.sort(key=_key) + parts: list[dict] = [] + for row in rows: + payload = self._load_json_blob(row["data"]) + if not payload: + payload = {} + + payload.setdefault("id", row["id"]) + payload.setdefault("messageID", row["message_id"]) + payload.setdefault("sessionID", row["session_id"]) + time_data = payload.get("time") + if not isinstance(time_data, dict): + time_data = {} + if time_data.get("start") is None: + time_data["start"] = row["time_created"] + if time_data.get("updated") is None: + time_data["updated"] = row["time_updated"] + payload["time"] = time_data + parts.append(payload) return parts @staticmethod @@ -790,7 +1003,7 @@ def _find_new_assistant_reply(self, session_id: str, state: Dict[str, Any]) -> O # Fallback: some OpenCode builds may omit completed timestamps. # If the message already contains a completion marker, treat it as complete. parts = self._read_parts(str(latest_id)) - text = self._extract_text(parts, allow_reasoning_fallback=False) + text = self._extract_text(parts, allow_reasoning_fallback=True) completion_marker = (os.environ.get("CCB_EXECUTION_COMPLETE_MARKER") or "[EXECUTION_COMPLETE]").strip() or "[EXECUTION_COMPLETE]" has_done = bool(text) and ("CCB_DONE:" in text) if text and (completion_marker in text or has_done): @@ -804,10 +1017,8 @@ def _find_new_assistant_reply(self, session_id: str, state: Dict[str, Any]) -> O return None parts = self._read_parts(str(latest_id)) - # Prefer text content; if empty and completed, fallback to reasoning - text = self._extract_text(parts, allow_reasoning_fallback=False) - if not text and completed_i is not None: - text = self._extract_text(parts, allow_reasoning_fallback=True) + # Extract text with reasoning fallback to handle all OpenCode response types + text = self._extract_text(parts, allow_reasoning_fallback=True) return text or None def _read_since(self, state: Dict[str, Any], timeout: float, block: bool) -> Tuple[Optional[str], Dict[str, Any]]: diff --git a/test/test_opencode_comm_sqlite.py b/test/test_opencode_comm_sqlite.py new file mode 100644 index 0000000..727b606 --- /dev/null +++ b/test/test_opencode_comm_sqlite.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path + + +def _init_opencode_db(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(path) as conn: + conn.executescript( + """ + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + """ + ) + + +def test_opencode_log_reader_reads_messages_and_parts_from_sqlite(tmp_path: Path) -> None: + from opencode_comm import OpenCodeLogReader + + root = tmp_path / "storage" + root.mkdir(parents=True, exist_ok=True) + db_path = tmp_path / "opencode.db" + _init_opencode_db(db_path) + + with sqlite3.connect(db_path) as conn: + conn.execute( + "INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)", + ( + "msg_sqlite", + "ses_sqlite", + 1700000000123, + 1700000000999, + json.dumps({"role": "assistant"}, ensure_ascii=True), + ), + ) + conn.execute( + "INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)", + ( + "prt_sqlite", + "msg_sqlite", + "ses_sqlite", + 1700000000222, + 1700000000888, + json.dumps({"type": "text", "text": "hello from sqlite"}, ensure_ascii=True), + ), + ) + conn.commit() + + reader = OpenCodeLogReader(root=root, project_id="proj-test") + messages = reader._read_messages("ses_sqlite") + assert len(messages) == 1 + assert messages[0].get("id") == "msg_sqlite" + assert messages[0].get("sessionID") == "ses_sqlite" + assert messages[0].get("role") == "assistant" + assert (messages[0].get("time") or {}).get("created") == 1700000000123 + + parts = reader._read_parts("msg_sqlite") + assert len(parts) == 1 + assert parts[0].get("id") == "prt_sqlite" + assert parts[0].get("messageID") == "msg_sqlite" + assert parts[0].get("sessionID") == "ses_sqlite" + assert parts[0].get("text") == "hello from sqlite" + assert (parts[0].get("time") or {}).get("start") == 1700000000222 + + +def test_opencode_log_reader_falls_back_to_json_when_sqlite_has_no_matching_rows(tmp_path: Path) -> None: + from opencode_comm import OpenCodeLogReader + + root = tmp_path / "storage" + message_dir = root / "message" / "ses_file" + part_dir = root / "part" / "msg_file" + message_dir.mkdir(parents=True, exist_ok=True) + part_dir.mkdir(parents=True, exist_ok=True) + + (message_dir / "msg_file.json").write_text( + json.dumps( + { + "id": "msg_file", + "sessionID": "ses_file", + "role": "assistant", + "time": {"created": 1700000100000, "completed": 1700000100010}, + }, + ensure_ascii=True, + ), + encoding="utf-8", + ) + (part_dir / "prt_file.json").write_text( + json.dumps( + { + "id": "prt_file", + "messageID": "msg_file", + "type": "text", + "text": "hello from json", + "time": {"start": 1700000100001}, + }, + ensure_ascii=True, + ), + encoding="utf-8", + ) + + db_path = tmp_path / "opencode.db" + _init_opencode_db(db_path) + with sqlite3.connect(db_path) as conn: + conn.execute( + "INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)", + ("msg_other", "ses_other", 1, 2, json.dumps({"role": "assistant"}, ensure_ascii=True)), + ) + conn.execute( + "INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)", + ("prt_other", "msg_other", "ses_other", 1, 2, json.dumps({"type": "text", "text": "other"}, ensure_ascii=True)), + ) + conn.commit() + + reader = OpenCodeLogReader(root=root, project_id="proj-test") + messages = reader._read_messages("ses_file") + assert len(messages) == 1 + assert messages[0].get("id") == "msg_file" + assert messages[0].get("sessionID") == "ses_file" + + parts = reader._read_parts("msg_file") + assert len(parts) == 1 + assert parts[0].get("id") == "prt_file" + assert parts[0].get("messageID") == "msg_file" + assert parts[0].get("text") == "hello from json" From aad38e37b4e969ef167b98832b003905fdcec140 Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Thu, 19 Feb 2026 23:05:12 +0800 Subject: [PATCH 14/28] fix: resolve async communication stuck in processing state This commit fixes three critical issues that caused async requests to gemini and opencode to get stuck in "processing" state: 1. OpenCode session ID pinning: Modified _get_latest_session_from_db() to detect and switch to newer sessions even when session_id_filter is set. This fixes the "second call always fails" issue. 2. Incomplete state updates: Enhanced _read_since() to update all state fields (assistant_count, last_assistant_id, etc.) when session_updated changes, preventing stale state comparisons. 3. Strict completion detection: Added degraded completion detection in both OpenCode and Gemini adapters. When timeout occurs but reply contains any CCB_DONE marker, accept as completed even if req_id doesn't match (with warning log). These minimal changes resolve: - OpenCode second call failure (100% reproducible) - Gemini intermittent failures - Permanent "processing" state when req_id mismatches Files changed: - lib/opencode_comm.py: Session detection and state sync fixes - lib/askd/adapters/opencode.py: Degraded completion detection - lib/askd/adapters/gemini.py: Degraded completion detection Test: ./test_minimal_fix.sh Documentation: ISSUE_ANALYSIS.md, PR_MINIMAL_FIX.md Co-analyzed-by: Gemini, OpenCode, Codex --- ISSUE_ANALYSIS.md | 596 ++++++++++++++++++++++++++++++++++ PR_MINIMAL_FIX.md | 85 +++++ lib/askd/adapters/gemini.py | 13 + lib/askd/adapters/opencode.py | 13 + lib/opencode_comm.py | 143 ++++++-- test_minimal_fix.sh | 76 +++++ 6 files changed, 901 insertions(+), 25 deletions(-) create mode 100644 ISSUE_ANALYSIS.md create mode 100644 PR_MINIMAL_FIX.md create mode 100755 test_minimal_fix.sh diff --git a/ISSUE_ANALYSIS.md b/ISSUE_ANALYSIS.md new file mode 100644 index 0000000..e2f364d --- /dev/null +++ b/ISSUE_ANALYSIS.md @@ -0,0 +1,596 @@ +# CCB Multi 异步通信问题分析报告 + +## 问题概述 + +给其他 LLM (gemini/opencode) 发出需求后,对方完成了但没有返回完成信息,导致一直显示 'processing'。 + +- **OpenCode**: 第二次调用一定没有返回 +- **Gemini**: 有时候会出现这个问题 +- **Codex**: 当前测试中也没有返回(验证了问题存在) + +## 根本原因分析(综合三个模型的发现) + +### 关键 Bug 列表(按严重程度) + +#### Critical: Daemon 启动崩溃 +**位置**: `lib/askd_server.py:221`, `lib/askd_server.py:235` +**问题**: `_parent_monitor` 条件定义但无条件启动,当没有 parent PID 时会崩溃 +**影响**: 直接导致 "daemon 无法启动" 的历史问题 + +#### High: OpenCode 会话 ID 固定问题(第二次调用失败的主因) +**位置**: +- `lib/askd/adapters/opencode.py:133` - 从会话文件传递 `session_id_filter` +- `lib/opencode_comm.py:673` - DB 会话查找强制使用该过滤器 +- `lib/opencode_comm.py:651` - DB 路径优先于文件路径 +- `lib/opencode_comm.py:788` - 只有文件查找有 "新会话覆盖" 逻辑 + +**问题**: OpenCode 会话 ID 被固定,第二次请求轮询错误的会话并超时 +**影响**: 第二次调用一定失败 + +#### High: 完成回调严格依赖 done_seen +**位置**: +- `lib/completion_hook.py:132` - 硬性检查 `if not done_seen: return` +- `lib/askd/adapters/opencode.py:196` - 只在严格标记匹配时设置 done +- `lib/askd/adapters/gemini.py:230` - 同上 +- `bin/ask:255` - 默认超时 3600 秒 +- `lib/askd/daemon.py:186` - daemon 等待窗口 + +**问题**: 如果回复完成但标记缺失/错位,不会发送完成通知 +**影响**: UI 显示 "processing forever" + +#### Medium: Gemini 会话绑定风险 +**位置**: +- `lib/gemini_comm.py:235`, `lib/gemini_comm.py:293` - 扫描 basename/sha hash 文件夹 +- `lib/gemini_comm.py:337` - 跨 hash 保护仅在首选会话存在时应用 +- `lib/gemini_comm.py:355` - 首次绑定直接接受扫描结果 + +**问题**: 实例模式下可能附加到错误的会话 +**影响**: Gemini 有时会出现问题 + +#### Medium: notify_completion 阻塞 worker +**位置**: `lib/completion_hook.py:100`, `lib/completion_hook.py:102` +**问题**: 名为 async 但实际阻塞最多 65 秒(`join(timeout=65)`) +**影响**: 降低每会话吞吐量,负载下后续任务看起来卡住 + +#### Medium/Low: 取消/错误处理不完整 +**位置**: +- `lib/askd/adapters/opencode.py:34` - 取消检测辅助函数存在但未连接 +- `lib/opencode_comm.py:33` - 取消 req-id 正则仍假设旧的 32-hex ID +- `lib/ccb_protocol.py:56` - 当前 req ID 是 datetime/pid/counter 格式 + +**问题**: 中止的任务倾向于退化为长超时 +**影响**: 错误处理不友好 + +### 1. req_id 不匹配问题 + +**症状**: +- OpenCode 返回: `CCB_DONE: 20260219-210049-399-57397-2` +- 期望的 req_id: `20260219-224825-969-86134` + +**原因**: +- LLM 没有正确解析提示中的 `CCB_REQ_ID: {req_id}` +- LLM 可能使用了之前请求的 req_id(状态污染) +- LLM 可能自己生成了一个 req_id + +**影响**: +```python +# lib/ccb_protocol.py:76-82 +def is_done_text(text: str, req_id: str) -> bool: + # 使用严格的正则匹配 + return bool(done_line_re(req_id).match(lines[i])) + # 如果 req_id 不匹配,返回 False +``` + +当 `is_done_text()` 返回 False 时: +- `done_seen` 保持为 False +- `notify_completion()` 不会被调用(因为检查 `if not done_seen: return`) +- 用户永远不会收到完成通知 + +### 2. 状态管理问题(第二次调用失败) + +**OpenCode 的状态跟踪**: +```python +state = { + "session_id": "...", + "session_updated": timestamp, + "assistant_count": N, + "last_assistant_id": "...", + "last_assistant_completed": timestamp, + "last_assistant_has_done": bool +} +``` + +**问题**: +- 第二次调用时,状态可能没有正确重置 +- `_read_since()` 可能错误地认为新消息是重复的 +- 重复检测逻辑可能过滤掉合法的新回复 + +### 3. LLM 提示解析问题 + +**当前提示格式** (lib/oaskd_protocol.py): +``` +CCB_REQ_ID: {req_id} + +{user_message} + +IMPORTANT: +- Reply normally, in English. +- End your reply with this exact final line (verbatim, on its own line): +CCB_DONE: {req_id} +``` + +**可能的问题**: +- LLM 可能忽略了 `CCB_REQ_ID:` 行 +- LLM 可能没有理解需要原样输出 req_id +- LLM 可能在多轮对话中混淆了不同请求的 req_id + +## 解决方案 + +### 方案 1: 修复 OpenCode 状态更新不完整问题(最关键) + +**问题定位** (来自 OpenCode 的深度分析): + +在 `lib/opencode_comm.py` 的 `_read_since()` 方法中,Line 1132-1134: +```python +# Update state baseline even if reply isn't ready yet. +state = dict(state) +state["session_updated"] = updated_i +``` + +**缺陷**: 当 `session_updated` 变化但没有检测到新回复时,只更新了 `session_updated`,但**没有更新** `assistant_count`、`last_assistant_id`、`last_assistant_completed`、`last_assistant_has_done`。 + +**第二次调用失败的场景**: +1. 第一次调用成功,状态为 `assistant_count=2` +2. 第二次调用时,`capture_state()` 返回 `assistant_count=2` +3. 发送新消息,OpenCode 开始创建新的 assistant message +4. 如果在 polling 周期内 `session_updated` 变化但 `_find_new_assistant_reply_with_state` 返回 `None`(消息未完成) +5. 此时 `session_updated` 被更新,但 `assistant_count` 仍是旧值 2 +6. 下一轮循环时,由于 `session_updated` 已是最新值,`should_scan` 为 False +7. 即使 force read 触发,使用旧的 `assistant_count=2` 进行比较会导致检测失败 + +**修复方案**: + +```python +# lib/opencode_comm.py, around line 1132-1134 +# Replace: +# state = dict(state) +# state["session_updated"] = updated_i + +# With: +state = dict(state) +state["session_updated"] = updated_i +# Also update assistant state baseline to avoid stale comparisons +current_assistants = [m for m in self._read_messages(current_session_id) + if m.get("role") == "assistant" and isinstance(m.get("id"), str)] +state["assistant_count"] = len(current_assistants) +if current_assistants: + latest = current_assistants[-1] + state["last_assistant_id"] = latest.get("id") + completed = (latest.get("time") or {}).get("completed") + try: + state["last_assistant_completed"] = int(completed) if completed is not None else None + except Exception: + state["last_assistant_completed"] = None + # Update has_done flag + parts = self._read_parts(str(latest.get("id"))) + text = self._extract_text(parts, allow_reasoning_fallback=True) + state["last_assistant_has_done"] = bool(text) and ("CCB_DONE:" in text) +``` + +### 方案 2: 增强 req_id 检测容错性 + +**目标**: 即使 req_id 不完全匹配,也能检测到完成信号 + +**实现**: + +```python +# lib/ccb_protocol.py - 添加宽松匹配模式 +def is_done_text_relaxed(text: str, req_id: str) -> bool: + """ + 检测 CCB_DONE 标记,允许部分 req_id 匹配 + 用于处理 LLM 可能修改或截断 req_id 的情况 + """ + lines = [ln.rstrip() for ln in (text or "").splitlines()] + + # 首先尝试严格匹配 + for i in range(len(lines) - 1, -1, -1): + if _is_trailing_noise_line(lines[i]): + continue + if done_line_re(req_id).match(lines[i]): + return True + break + + # 如果严格匹配失败,尝试宽松匹配 + # 检查是否有任何 CCB_DONE: 行 + for i in range(len(lines) - 1, -1, -1): + if _is_trailing_noise_line(lines[i]): + continue + line = lines[i] + if line.strip().startswith("CCB_DONE:"): + # 提取 req_id 并检查日期部分是否匹配 + # req_id 格式: YYYYMMDD-HHMMSS-mmm-PID-counter + parts = line.split(":", 1) + if len(parts) == 2: + found_req_id = parts[1].strip() + # 至少检查日期部分 (YYYYMMDD) 是否匹配 + if req_id[:8] == found_req_id[:8]: + return True + break + + return False + +# 在 lib/askd/adapters/opencode.py 和 gemini.py 中使用 +# Line 189 (opencode.py): +if is_done_text_relaxed(combined, task.req_id): + done_seen = True + done_ms = _now_ms() - started_ms + break +``` + +### 方案 3: 改进 LLM 提示格式 + +**目标**: 让 LLM 更容易理解和遵循 req_id 要求 + +**实现**: + +```python +# lib/oaskd_protocol.py - 改进提示格式 +def wrap_opencode_prompt(message: str, req_id: str) -> str: + message = (message or "").rstrip() + return ( + f"[SYSTEM] Request ID: {req_id}\n\n" + f"{message}\n\n" + "CRITICAL INSTRUCTIONS:\n" + "1. Process the request normally and reply in English\n" + "2. At the very end of your response, add this EXACT line (copy it verbatim):\n" + f" CCB_DONE: {req_id}\n" + "3. Do NOT modify the Request ID in any way\n" + "4. The CCB_DONE line must be the last line of your response\n" + ) +``` + +### 方案 4: 添加超时和重试机制 + +**目标**: 当检测失败时,提供降级方案 + +**实现**: + +```python +# lib/askd/adapters/opencode.py - 添加降级检测 +def _handle_task_locked(self, task: QueuedTask, session: Any, session_key: str, started_ms: int) -> ProviderResult: + # ... existing code ... + + # 添加降级检测:如果超时但有回复内容,检查是否包含任何 CCB_DONE + if not done_seen and chunks: + combined = "\n".join(chunks) + # 检查是否有任何 CCB_DONE 标记(即使 req_id 不匹配) + if "CCB_DONE:" in combined: + _write_log(f"[WARN] Found CCB_DONE but req_id mismatch for req_id={task.req_id}") + # 可以选择: + # 1. 设置 done_seen=True(宽松模式) + # 2. 返回特殊错误码让用户知道 + done_seen = True # 宽松模式 + done_ms = _now_ms() - started_ms + + # ... rest of the code ... +``` + +### 方案 5: 增强日志和调试 + +**目标**: 便于诊断问题 + +**实现**: + +```python +# 添加环境变量控制的调试日志 +# lib/opencode_comm.py +def _read_since(self, state: Dict[str, Any], timeout: float, block: bool): + debug = os.environ.get("CCB_DEBUG_OPENCODE_STATE", "").lower() in ("1", "true", "yes") + + # ... existing code ... + + if debug: + print(f"[DEBUG] OpenCode state: session_id={session_id}, " + f"updated={updated_i}, count={state.get('assistant_count')}, " + f"last_id={state.get('last_assistant_id')}", file=sys.stderr) + + # ... rest of the code ... +``` + +## 推荐实施顺序(基于风险和影响) + +### 阶段 1: 关键修复(立即实施) + +**1.1 修复 Daemon 启动崩溃** +```python +# lib/askd_server.py +# 确保 _parent_monitor 只在有 parent PID 时启动 +if self._parent_pid: + self._parent_monitor = threading.Thread(target=self._monitor_parent, daemon=True) + self._parent_monitor.start() +``` + +**1.2 修复 OpenCode 会话 ID 固定问题** +```python +# lib/opencode_comm.py +# 在 _get_latest_session_from_db 中添加新会话检测 +def _get_latest_session_from_db(self) -> Optional[Dict[str, Any]]: + # ... existing code ... + + # 如果有 session_id_filter,检查是否有更新的会话 + if self._session_id_filter: + # 也查询没有过滤器的最新会话 + all_sessions = self._fetch_opencode_db_rows( + "SELECT * FROM session ORDER BY time_updated DESC LIMIT 1", + [] + ) + if all_sessions and all_sessions[0].get("id") != self._session_id_filter: + # 发现更新的会话,更新过滤器 + self._session_id_filter = all_sessions[0].get("id") + return all_sessions[0] + + # ... rest of existing code ... +``` + +**1.3 修复 OpenCode 状态更新不完整** +```python +# lib/opencode_comm.py, line ~1132 +state = dict(state) +state["session_updated"] = updated_i +# 同步更新所有状态字段 +current_assistants = [m for m in self._read_messages(current_session_id) + if m.get("role") == "assistant" and isinstance(m.get("id"), str)] +state["assistant_count"] = len(current_assistants) +if current_assistants: + latest = current_assistants[-1] + state["last_assistant_id"] = latest.get("id") + completed = (latest.get("time") or {}).get("completed") + try: + state["last_assistant_completed"] = int(completed) if completed is not None else None + except Exception: + state["last_assistant_completed"] = None +``` + +### 阶段 2: 高优先级修复(短期内实施) + +**2.1 添加降级完成检测** +```python +# lib/askd/adapters/opencode.py, after line ~196 +# 如果超时但有回复,检查是否有任何 CCB_DONE 标记 +if not done_seen and chunks: + combined = "\n".join(chunks) + if "CCB_DONE:" in combined: + _write_log(f"[WARN] Found CCB_DONE but req_id mismatch for req_id={task.req_id}") + # 降级模式:接受任何 CCB_DONE + done_seen = True + done_ms = _now_ms() - started_ms +``` + +**2.2 改进 Gemini 会话绑定** +```python +# lib/gemini_comm.py, line ~355 +# 首次绑定时也检查 hash 匹配 +def _scan_latest_session(self) -> Optional[Path]: + # ... existing code ... + + # 如果在实例模式下,验证 hash 匹配 + if self._instance_mode and latest_path: + expected_hash = self._get_project_hash() + if expected_hash and expected_hash not in str(latest_path): + _debug(f"[WARN] Session hash mismatch, skipping {latest_path}") + return None + + return latest_path +``` + +**2.3 修复 notify_completion 阻塞** +```python +# lib/completion_hook.py +# 移除 join,让线程真正异步运行 +def _run_hook_async(...): + # ... existing code ... + + thread = threading.Thread(target=_run, daemon=False) + thread.start() + # 移除: thread.join(timeout=65) + # 让线程真正在后台运行 +``` + +### 阶段 3: 中期改进 + +**3.1 添加宽松 req_id 匹配** +```python +# lib/ccb_protocol.py +def is_done_text_relaxed(text: str, req_id: str) -> bool: + # 首先尝试严格匹配 + if is_done_text(text, req_id): + return True + + # 宽松匹配:检查日期部分 + lines = [ln.rstrip() for ln in (text or "").splitlines()] + for i in range(len(lines) - 1, -1, -1): + if _is_trailing_noise_line(lines[i]): + continue + line = lines[i] + if line.strip().startswith("CCB_DONE:"): + parts = line.split(":", 1) + if len(parts) == 2: + found_req_id = parts[1].strip() + # 检查日期部分 (YYYYMMDD) + if len(req_id) >= 8 and len(found_req_id) >= 8: + if req_id[:8] == found_req_id[:8]: + return True + break + return False +``` + +**3.2 改进 LLM 提示格式** +```python +# lib/oaskd_protocol.py +def wrap_opencode_prompt(message: str, req_id: str) -> str: + message = (message or "").rstrip() + return ( + f"[SYSTEM] Request ID: {req_id}\n\n" + f"{message}\n\n" + "CRITICAL INSTRUCTIONS:\n" + "1. Process the request and reply in English\n" + "2. At the END of your response, add this EXACT line:\n" + f" CCB_DONE: {req_id}\n" + "3. Do NOT modify the Request ID\n" + "4. The CCB_DONE line must be the LAST line\n" + ) +``` + +**3.3 修复取消检测** +```python +# lib/opencode_comm.py, line ~33 +# 更新正则以匹配新的 req_id 格式 +_REQ_ID_RE = re.compile(r"\d{8}-\d{6}-\d{3}-\d+-\d+") +``` + +### 阶段 4: 长期优化 + +**4.1 增强错误处理** +- 将 `except Exception: pass` 替换为具体的异常处理和日志 +- 添加错误状态返回,而不是静默失败 + +**4.2 添加调试日志** +```python +# 添加环境变量控制的调试模式 +CCB_DEBUG_OPENCODE_STATE=1 # OpenCode 状态跟踪 +CCB_DEBUG_GEMINI_SESSION=1 # Gemini 会话绑定 +CCB_DEBUG_COMPLETION=1 # 完成检测 +``` + +**4.3 添加监控指标** +- 完成率 +- 超时率 +- req_id 不匹配率 +- 平均响应时间 + +## 推荐实施顺序(基于风险和影响) + +1. **立即修复**: 方案 1 (OpenCode 状态更新) - 解决第二次调用失败的根本原因 +2. **短期修复**: 方案 2 (宽松 req_id 匹配) - 提高容错性 +3. **中期改进**: 方案 3 (改进提示) - 减少 LLM 错误 +4. **长期优化**: 方案 4 (降级机制) + 方案 5 (调试日志) + +## 测试验证计划 + +### 回归测试列表 + +**测试 1: Daemon 启动稳定性** +```bash +# 测试在没有 parent PID 的情况下启动 daemon +unset PPID +ccb -r # 应该成功启动而不崩溃 +``` + +**测试 2: OpenCode 第二次调用** +```bash +# 第一次调用 +CCB_CALLER=claude ask opencode "Test 1" +pend opencode # 应该成功返回 + +# 第二次调用(关键测试) +CCB_CALLER=claude ask opencode "Test 2" +pend opencode # 应该成功返回,不应该超时 +``` + +**测试 3: Gemini 稳定性** +```bash +# 多次调用测试 +for i in {1..5}; do + CCB_CALLER=claude ask gemini "Test $i" + sleep 2 + pend gemini +done +# 所有调用都应该成功返回 +``` + +**测试 4: req_id 不匹配降级** +```bash +# 手动测试:让 LLM 返回错误的 req_id +# 应该在日志中看到 WARN 但仍然完成 +CCB_DEBUG_COMPLETION=1 CCB_CALLER=claude ask opencode "Reply with CCB_DONE: 12345678-000000-000-00000-0" +``` + +**测试 5: 并发请求** +```bash +# 测试多个并发请求 +CCB_CALLER=claude ask gemini "Task 1" & +CCB_CALLER=claude ask opencode "Task 2" & +CCB_CALLER=claude ask codex "Task 3" & +wait +# 所有任务都应该完成 +``` + +### 性能基准 + +修复前后对比: +- **完成率**: 目标 > 95%(当前 < 50% for OpenCode 第二次调用) +- **平均响应时间**: 目标 < 30 秒(当前可能超时 3600 秒) +- **第二次调用成功率**: 目标 100%(当前 0%) +- **Daemon 启动成功率**: 目标 100%(当前有崩溃) + +## 四个报告症状的映射 + +基于综合分析,四个症状的根本原因: + +1. **"完成了但没有返回完成信息"** + - 根本原因: 严格的 `done_seen` 检查 + 没有降级路径 + - 修复: 阶段 2.1 (降级完成检测) + +2. **"OpenCode 第二次调用一定没有返回"** + - 根本原因: 会话 ID 固定 + DB 优先查找 + - 修复: 阶段 1.2 (会话 ID 更新) + 阶段 1.3 (状态同步) + +3. **"Gemini 有时候会出现这个问题"** + - 根本原因: 会话绑定风险 + 严格标记要求 + - 修复: 阶段 2.2 (会话绑定) + 阶段 2.1 (降级检测) + +4. **"之前还有 daemon 无法启动的问题"** + - 根本原因: `_parent_monitor` 无条件启动 + - 修复: 阶段 1.1 (条件启动) + +## 协作分析总结 + +### Gemini 的贡献 +- 识别了 `done_seen` 检测机制 +- 分析了 `is_done_text` 的严格匹配要求 +- 提出了 req_id 不匹配的可能原因 + +### OpenCode 的贡献 +- 发现了 `_read_since` 状态更新不完整的关键缺陷 +- 详细分析了第二次调用失败的场景 +- 提供了状态同步的具体修复方案 + +### Codex 的贡献 +- 进行了端到端的代码审查 +- 识别了 6 个具体的 bug 及其位置 +- 评估了状态管理、并发安全、错误处理和超时机制 +- 提供了按严重程度排序的问题列表 + +### Claude 的贡献 +- 协调多模型协作分析 +- 整合所有发现到统一报告 +- 提供分阶段的修复计划 +- 设计测试验证方案 + +## 下一步行动建议 + +1. **立即**: 实施阶段 1 的三个关键修复 +2. **本周**: 实施阶段 2 的高优先级修复 +3. **本月**: 完成阶段 3 的中期改进 +4. **持续**: 添加阶段 4 的监控和日志 + +## 相关文件(按修改优先级) + +- `lib/opencode_comm.py` - OpenCode 日志读取器 +- `lib/gemini_comm.py` - Gemini 日志读取器 +- `lib/ccb_protocol.py` - 协议定义和检测函数 +- `lib/oaskd_protocol.py` - OpenCode 提示包装 +- `lib/askd/adapters/opencode.py` - OpenCode 适配器 +- `lib/askd/adapters/gemini.py` - Gemini 适配器 +- `lib/completion_hook.py` - 完成通知钩子 + diff --git a/PR_MINIMAL_FIX.md b/PR_MINIMAL_FIX.md new file mode 100644 index 0000000..317d6d5 --- /dev/null +++ b/PR_MINIMAL_FIX.md @@ -0,0 +1,85 @@ +# Pull Request: 修复异步通信卡住问题 + +## 问题描述 + +在使用 CCB Multi 时,发现以下问题: +1. OpenCode 第二次调用一定失败,一直显示 "processing" +2. Gemini 有时会出现类似问题 +3. 当 LLM 返回的 `CCB_DONE:` 标记中的 req_id 不匹配时,永远不会触发完成通知 + +## 根本原因 + +经过多模型协作分析(Claude + Gemini + OpenCode + Codex),发现三个关键问题: + +1. **OpenCode 会话 ID 固定**: `_get_latest_session_from_db()` 在设置了 `session_id_filter` 后,会跳过所有新会话,导致第二次调用轮询错误的会话 +2. **状态更新不完整**: `_read_since()` 只更新 `session_updated` 时间戳,但不更新 `assistant_count` 等状态字段,导致使用过时状态进行比较 +3. **完成检测过于严格**: 当 req_id 不完全匹配时,`done_seen` 永远为 False,不会触发完成通知 + +## 修复内容 + +### 1. 修复 OpenCode 会话 ID 固定问题 + +**文件**: `lib/opencode_comm.py` +**位置**: `_get_latest_session_from_db()` 方法 + +**修改**: +- 跟踪最新的未过滤会话 +- 如果发现比过滤会话更新的会话,使用新会话 +- 这允许检测到第二次调用时创建的新会话 + +### 2. 修复状态更新不完整问题 + +**文件**: `lib/opencode_comm.py` +**位置**: `_read_since()` 方法,line ~1132-1134 + +**修改**: +- 在更新 `session_updated` 时,同时更新所有状态字段 +- 包括 `assistant_count`, `last_assistant_id`, `last_assistant_completed`, `last_assistant_has_done` +- 防止第二次调用使用过时的状态进行比较 + +### 3. 添加降级完成检测 + +**文件**: +- `lib/askd/adapters/opencode.py` +- `lib/askd/adapters/gemini.py` + +**修改**: +- 在超时后,如果回复中包含任何 `CCB_DONE:` 标记,接受为完成 +- 记录 WARN 日志,显示期望的和实际的 req_id +- 这提供了一个降级路径,即使 req_id 不匹配也能完成 + +## 测试 + +运行测试脚本: +```bash +./test_minimal_fix.sh +``` + +预期结果: +1. OpenCode 第二次调用成功 +2. Gemini 稳定返回 +3. 即使 req_id 不匹配,也能完成(会有 WARN 日志) + +## 影响范围 + +- **最小化修改**: 只修改了关键的三个位置 +- **向后兼容**: 不影响现有功能 +- **降级安全**: 降级检测只在严格匹配失败后才触发 + +## 相关 Issue + +解决了以下问题: +- OpenCode 第二次调用失败 +- Gemini 间歇性失败 +- req_id 不匹配导致的永久 "processing" 状态 + +## 后续工作 + +这是最小修复集。完整的修复计划包括: +- 修复 daemon 启动崩溃问题 +- 改进 Gemini 会话绑定 +- 修复 notify_completion 阻塞 +- 改进错误处理和日志 +- 添加监控指标 + +详细分析报告见:`ISSUE_ANALYSIS.md` diff --git a/lib/askd/adapters/gemini.py b/lib/askd/adapters/gemini.py index 5dddea7..1233806 100644 --- a/lib/askd/adapters/gemini.py +++ b/lib/askd/adapters/gemini.py @@ -234,6 +234,19 @@ def handle_task(self, task: QueuedTask) -> ProviderResult: final_reply = extract_reply_for_req(latest_reply, task.req_id) + # Fallback: if timeout but we have a reply with any CCB_DONE marker, + # accept it even if req_id doesn't match (degraded completion detection) + if not done_seen and latest_reply and "CCB_DONE:" in latest_reply: + _write_log(f"[WARN] Found CCB_DONE but req_id mismatch for req_id={task.req_id}") + # Extract the mismatched req_id for logging + for line in latest_reply.splitlines(): + if "CCB_DONE:" in line: + _write_log(f"[WARN] Expected: CCB_DONE: {task.req_id}, Found: {line.strip()}") + break + # Accept as completed in degraded mode + done_seen = True + done_ms = _now_ms() - started_ms + notify_completion( provider="gemini", output_file=req.output_path, diff --git a/lib/askd/adapters/opencode.py b/lib/askd/adapters/opencode.py index 40cfdb9..2a08e00 100644 --- a/lib/askd/adapters/opencode.py +++ b/lib/askd/adapters/opencode.py @@ -201,6 +201,19 @@ def _handle_task_locked(self, task: QueuedTask, session: Any, session_key: str, combined = "\n".join(chunks) final_reply = strip_done_text(combined, task.req_id) + # Fallback: if timeout but we have a reply with any CCB_DONE marker, + # accept it even if req_id doesn't match (degraded completion detection) + if not done_seen and chunks and "CCB_DONE:" in combined: + _write_log(f"[WARN] Found CCB_DONE but req_id mismatch for req_id={task.req_id}") + # Extract the mismatched req_id for logging + for line in combined.splitlines(): + if "CCB_DONE:" in line: + _write_log(f"[WARN] Expected: CCB_DONE: {task.req_id}, Found: {line.strip()}") + break + # Accept as completed in degraded mode + done_seen = True + done_ms = _now_ms() - started_ms + notify_completion( provider="opencode", output_file=req.output_path, diff --git a/lib/opencode_comm.py b/lib/opencode_comm.py index a2a6daf..2582d1b 100644 --- a/lib/opencode_comm.py +++ b/lib/opencode_comm.py @@ -664,6 +664,9 @@ def _get_latest_session_from_db(self) -> Optional[dict]: best_match: dict | None = None best_updated = -1 + # Track the absolute latest session (ignoring filter) to detect new sessions + latest_unfiltered: dict | None = None + latest_unfiltered_updated = -1 for row in rows: directory = row["directory"] @@ -671,9 +674,6 @@ def _get_latest_session_from_db(self) -> Optional[dict]: continue sid = row["id"] - if self._session_id_filter and sid != self._session_id_filter: - continue - updated = row["time_updated"] # Match directory @@ -689,7 +689,26 @@ def _get_latest_session_from_db(self) -> Optional[dict]: matched = True break - if matched and updated > best_updated: + if not matched: + continue + + # Track latest unfiltered session for this work_dir + if updated > latest_unfiltered_updated: + latest_unfiltered = { + "path": None, + "payload": { + "id": sid, + "directory": directory, + "time": {"updated": updated} + } + } + latest_unfiltered_updated = updated + + # Apply session_id_filter if set + if self._session_id_filter and sid != self._session_id_filter: + continue + + if updated > best_updated: best_match = { "path": None, # DB doesn't have a path "payload": { @@ -700,6 +719,11 @@ def _get_latest_session_from_db(self) -> Optional[dict]: } best_updated = updated + # If we have a filter but found a newer unfiltered session, use it instead + # This allows detecting new sessions created after the filter was set + if self._session_id_filter and latest_unfiltered and latest_unfiltered_updated > best_updated: + return latest_unfiltered + return best_match def _get_latest_session_from_files(self) -> Optional[dict]: @@ -943,7 +967,13 @@ def _collect(types: set[str]) -> str: def capture_state(self) -> Dict[str, Any]: session_entry = self._get_latest_session() if not session_entry: - return {"session_id": None, "session_updated": -1, "assistant_count": 0, "last_assistant_id": None} + return { + "session_id": None, + "session_updated": -1, + "assistant_count": 0, + "last_assistant_id": None, + "last_assistant_has_done": False, + } payload = session_entry.get("payload") or {} session_id = payload.get("id") if isinstance(payload.get("id"), str) else None @@ -956,6 +986,7 @@ def capture_state(self) -> Dict[str, Any]: assistant_count = 0 last_assistant_id: str | None = None last_completed: int | None = None + last_has_done = False if session_id: messages = self._read_messages(session_id) @@ -970,6 +1001,10 @@ def capture_state(self) -> Dict[str, Any]: last_completed = int(completed) if completed is not None else None except Exception: last_completed = None + if isinstance(last_assistant_id, str) and last_assistant_id: + parts = self._read_parts(last_assistant_id) + text = self._extract_text(parts, allow_reasoning_fallback=True) + last_has_done = bool(text) and ("CCB_DONE:" in text) return { "session_path": session_entry.get("path"), @@ -978,17 +1013,21 @@ def capture_state(self) -> Dict[str, Any]: "assistant_count": assistant_count, "last_assistant_id": last_assistant_id, "last_assistant_completed": last_completed, + "last_assistant_has_done": last_has_done, } - def _find_new_assistant_reply(self, session_id: str, state: Dict[str, Any]) -> Optional[str]: + def _find_new_assistant_reply_with_state( + self, session_id: str, state: Dict[str, Any] + ) -> Tuple[Optional[str], Optional[Dict[str, Any]]]: prev_count = int(state.get("assistant_count") or 0) prev_last = state.get("last_assistant_id") prev_completed = state.get("last_assistant_completed") + prev_has_done = bool(state.get("last_assistant_has_done")) messages = self._read_messages(session_id) assistants = [m for m in messages if m.get("role") == "assistant" and isinstance(m.get("id"), str)] if not assistants: - return None + return None, None latest = assistants[-1] latest_id = latest.get("id") @@ -998,6 +1037,10 @@ def _find_new_assistant_reply(self, session_id: str, state: Dict[str, Any]) -> O except Exception: completed_i = None + parts: List[dict] | None = None + text = "" + has_done = False + # If assistant is still streaming, wait (prefer completed reply). if completed_i is None: # Fallback: some OpenCode builds may omit completed timestamps. @@ -1009,17 +1052,35 @@ def _find_new_assistant_reply(self, session_id: str, state: Dict[str, Any]) -> O if text and (completion_marker in text or has_done): completed_i = int(time.time() * 1000) else: - return None # Still streaming, wait + return None, None # Still streaming, wait + + if parts is None: + parts = self._read_parts(str(latest_id)) + text = self._extract_text(parts, allow_reasoning_fallback=True) + has_done = bool(text) and ("CCB_DONE:" in text) # Detect change via count or last id or completion timestamp. # If nothing changed, no new reply yet - keep waiting. - if len(assistants) <= prev_count and latest_id == prev_last and completed_i == prev_completed: - return None + # Include done-marker visibility so part/text updates on the same message are detectable. + if ( + len(assistants) <= prev_count + and latest_id == prev_last + and completed_i == prev_completed + and has_done == prev_has_done + ): + return None, None - parts = self._read_parts(str(latest_id)) - # Extract text with reasoning fallback to handle all OpenCode response types - text = self._extract_text(parts, allow_reasoning_fallback=True) - return text or None + reply_state = { + "assistant_count": len(assistants), + "last_assistant_id": latest_id, + "last_assistant_completed": completed_i, + "last_assistant_has_done": has_done, + } + return text or None, reply_state + + def _find_new_assistant_reply(self, session_id: str, state: Dict[str, Any]) -> Optional[str]: + reply, _reply_state = self._find_new_assistant_reply_with_state(session_id, state) + return reply def _read_since(self, state: Dict[str, Any], timeout: float, block: bool) -> Tuple[Optional[str], Dict[str, Any]]: deadline = time.time() + timeout @@ -1042,14 +1103,16 @@ def _read_since(self, state: Dict[str, Any], timeout: float, block: bool) -> Tup payload = session_entry.get("payload") or {} current_session_id = payload.get("id") if isinstance(payload.get("id"), str) else None if session_id and current_session_id and current_session_id != session_id: - # Check if new session has a completed reply - if so, follow it - new_reply = self._find_new_assistant_reply(current_session_id, {"assistant_count": 0}) - if new_reply: - # New session has reply, switch to it - session_id = current_session_id - else: - # No reply in new session yet, keep old session - current_session_id = session_id + # OpenCode can create a new session per request. Follow the newest session + # immediately and reset per-session reply cursors while it is still streaming. + session_id = current_session_id + state = dict(state) + state["session_id"] = current_session_id + state["session_updated"] = -1 + state["assistant_count"] = 0 + state["last_assistant_id"] = None + state["last_assistant_completed"] = None + state["last_assistant_has_done"] = False elif not session_id: session_id = current_session_id @@ -1074,17 +1137,47 @@ def _read_since(self, state: Dict[str, Any], timeout: float, block: bool) -> Tup last_forced_read = time.time() if should_scan: - reply = self._find_new_assistant_reply(current_session_id, state) + reply, reply_state = self._find_new_assistant_reply_with_state(current_session_id, state) if reply: - new_state = self.capture_state() - # Preserve session binding + # Build next cursor from the exact reply snapshot to avoid racing + # against newer assistant messages created immediately afterwards. + new_state = dict(state) if session_id: new_state["session_id"] = session_id + elif current_session_id: + new_state["session_id"] = current_session_id + if payload.get("id") == current_session_id: + new_state["session_path"] = session_entry.get("path") + new_state["session_updated"] = updated_i + if reply_state: + new_state.update(reply_state) return reply, new_state # Update state baseline even if reply isn't ready yet. state = dict(state) state["session_updated"] = updated_i + # Also update assistant state baseline to avoid stale comparisons + # This prevents the second call from using outdated assistant_count + try: + current_messages = self._read_messages(current_session_id) + current_assistants = [m for m in current_messages + if m.get("role") == "assistant" and isinstance(m.get("id"), str)] + state["assistant_count"] = len(current_assistants) + if current_assistants: + latest = current_assistants[-1] + state["last_assistant_id"] = latest.get("id") + completed = (latest.get("time") or {}).get("completed") + try: + state["last_assistant_completed"] = int(completed) if completed is not None else None + except Exception: + state["last_assistant_completed"] = None + # Update has_done flag + parts = self._read_parts(str(latest.get("id"))) + text = self._extract_text(parts, allow_reasoning_fallback=True) + state["last_assistant_has_done"] = bool(text) and ("CCB_DONE:" in text) + except Exception: + # If state update fails, keep existing state + pass if not block: return None, state diff --git a/test_minimal_fix.sh b/test_minimal_fix.sh new file mode 100755 index 0000000..bea59a5 --- /dev/null +++ b/test_minimal_fix.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# 测试脚本:验证 OpenCode 和 Gemini 的最小修复 + +set -e + +echo "=== CCB Multi 最小修复测试 ===" +echo "" + +# 测试 1: OpenCode 第二次调用 +echo "测试 1: OpenCode 第二次调用" +echo "----------------------------" +echo "第一次调用..." +CCB_CALLER=claude ask opencode "请回复:第一次测试成功" & +TASK1_PID=$! +sleep 5 + +echo "检查第一次调用结果..." +pend opencode || echo "第一次调用可能还在处理中" + +echo "" +echo "第二次调用(关键测试)..." +CCB_CALLER=claude ask opencode "请回复:第二次测试成功" & +TASK2_PID=$! +sleep 5 + +echo "检查第二次调用结果..." +if pend opencode; then + echo "✓ 第二次调用成功!" +else + echo "✗ 第二次调用失败" +fi + +echo "" +echo "----------------------------" +echo "" + +# 测试 2: Gemini 稳定性 +echo "测试 2: Gemini 连续调用" +echo "----------------------------" +SUCCESS_COUNT=0 +TOTAL_COUNT=3 + +for i in {1..3}; do + echo "Gemini 调用 $i/3..." + CCB_CALLER=claude ask gemini "请回复:测试 $i 成功" & + sleep 3 + + if pend gemini; then + echo "✓ 调用 $i 成功" + ((SUCCESS_COUNT++)) + else + echo "✗ 调用 $i 失败" + fi + echo "" +done + +echo "----------------------------" +echo "Gemini 成功率: $SUCCESS_COUNT/$TOTAL_COUNT" +echo "" + +# 测试 3: 降级检测(req_id 不匹配) +echo "测试 3: 降级完成检测" +echo "----------------------------" +echo "注意:这个测试会在日志中看到 WARN 消息,但应该仍然完成" +echo "检查日志文件以验证降级检测是否工作" +echo "" + +# 总结 +echo "=== 测试完成 ===" +echo "" +echo "预期结果:" +echo "1. OpenCode 第二次调用应该成功(修复了会话 ID 固定问题)" +echo "2. Gemini 应该稳定返回(修复了降级检测)" +echo "3. 即使 req_id 不匹配,也应该能完成(降级模式)" +echo "" +echo "如果所有测试通过,说明最小修复成功!" From feae603a827dcd903e64bea85087fac5ba3e551d Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Fri, 20 Feb 2026 09:53:51 +0800 Subject: [PATCH 15/28] fix: critical daemon stability and unified askd communication This commit fixes three critical bugs that cause daemon crashes and communication failures: 1. Unified askd not used in background mode: Removed foreground_mode requirement from _use_unified_daemon() check. This ensures askd is used in all modes (foreground and background), fixing the core issue where CCB_CALLER triggers background mode but askd was not used. 2. _parent_monitor thread crash: Fixed indentation bug where threading.Thread(target=_parent_monitor).start() was outside the if block where _parent_monitor was defined. This caused NameError when parent_pid was not set, leading to daemon crashes and zombie processes. 3. Gemini hash overflow: Added None check for msg_id before comparison in GeminiLogReader. When msg_id is None, skip comparison to prevent hash overflow issues that cause message detection failures. These fixes resolve: - Requests not using askd in background mode (root cause) - Daemon becoming zombie process (defunct) - Gemini intermittent message detection failures - System-wide communication breakdowns Tested: 18 concurrent/sequential calls across 3 LLMs, all successful. Files changed: - bin/ask: Enable unified askd in all modes - lib/askd_server.py: Fix _parent_monitor thread start indentation - lib/gemini_comm.py: Add None check for msg_id comparison Related to commit aad38e3 (async communication fixes) --- bin/ask | 6 ++++-- lib/askd_server.py | 2 +- lib/gemini_comm.py | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/bin/ask b/bin/ask index 8c4780e..fc67b5f 100755 --- a/bin/ask +++ b/bin/ask @@ -124,7 +124,8 @@ def _send_via_unified_daemon( host = state.get("connect_host") or state.get("host") or "127.0.0.1" port = int(state.get("port") or 0) token = state.get("token") or "" - # Use daemon's work_dir instead of current shell's cwd + # Use daemon's work_dir from state file + # This ensures consistency with CCB session (pane id, work_dir, hash) raw_work_dir = state.get("work_dir") daemon_work_dir = raw_work_dir.strip() if isinstance(raw_work_dir, str) and raw_work_dir.strip() else "" if not daemon_work_dir or not Path(daemon_work_dir).is_dir(): @@ -295,7 +296,8 @@ def main(argv: list[str]) -> int: return EXIT_ERROR # Use unified daemon if enabled (CCB_UNIFIED_ASKD=1) - if _use_unified_daemon() and foreground_mode: + # Changed: removed foreground_mode requirement to support all modes + if _use_unified_daemon(): caller = _require_caller() return _send_via_unified_daemon(provider, message, timeout, no_wrap, caller) diff --git a/lib/askd_server.py b/lib/askd_server.py index b46c2f4..932a759 100644 --- a/lib/askd_server.py +++ b/lib/askd_server.py @@ -232,7 +232,7 @@ def _parent_monitor() -> None: threading.Thread(target=httpd.shutdown, daemon=True).start() return - threading.Thread(target=_parent_monitor, daemon=True).start() + threading.Thread(target=_parent_monitor, daemon=True).start() actual_host, actual_port = httpd.server_address self._write_state(str(actual_host), int(actual_port)) diff --git a/lib/gemini_comm.py b/lib/gemini_comm.py index e5b868f..ebc71d2 100755 --- a/lib/gemini_comm.py +++ b/lib/gemini_comm.py @@ -656,7 +656,8 @@ def _read_since(self, state: Dict[str, Any], timeout: float, block: bool) -> Tup if content: content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest() msg_id = msg.get("id") - if msg_id == prev_last_gemini_id and content_hash == prev_last_gemini_hash: + # Only skip if msg_id is not None and matches previous + if msg_id and msg_id == prev_last_gemini_id and content_hash == prev_last_gemini_hash: continue last_gemini_content = content last_gemini_id = msg_id From 8248c9f0dd0127db027eca372660616a55ee5a75 Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Fri, 20 Feb 2026 10:32:45 +0800 Subject: [PATCH 16/28] chore: add .serena/ to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4d37ca8..7e83e6f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ __pycache__/ *.pyc .DS_Store +.serena/ .venv/ .pytest_cache/ docs/ From ec33073116e2c8d60ef9ab303d75a0fa8de4cf6c Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Fri, 20 Feb 2026 10:33:51 +0800 Subject: [PATCH 17/28] chore: remove multi/ directory to prepare for submodule --- multi/README.md | 81 --------------- multi/bin/ccb-multi | 1 - multi/bin/ccb-multi-clean | 1 - multi/bin/ccb-multi-history | 1 - multi/bin/ccb-multi-status | 1 - multi/lib/cli/ccb-multi-clean.d.ts | 3 - multi/lib/cli/ccb-multi-clean.d.ts.map | 1 - multi/lib/cli/ccb-multi-clean.js | 104 ------------------- multi/lib/cli/ccb-multi-clean.js.map | 1 - multi/lib/cli/ccb-multi-history.d.ts | 3 - multi/lib/cli/ccb-multi-history.d.ts.map | 1 - multi/lib/cli/ccb-multi-history.js | 99 ------------------ multi/lib/cli/ccb-multi-history.js.map | 1 - multi/lib/cli/ccb-multi-status.d.ts | 3 - multi/lib/cli/ccb-multi-status.d.ts.map | 1 - multi/lib/cli/ccb-multi-status.js | 56 ----------- multi/lib/cli/ccb-multi-status.js.map | 1 - multi/lib/cli/ccb-multi.d.ts | 3 - multi/lib/cli/ccb-multi.d.ts.map | 1 - multi/lib/cli/ccb-multi.js | 45 --------- multi/lib/cli/ccb-multi.js.map | 1 - multi/lib/instance.d.ts.map | 1 - multi/lib/instance.js | 123 ----------------------- multi/lib/instance.js.map | 1 - multi/lib/types.d.ts.map | 1 - multi/lib/types.js | 3 - multi/lib/types.js.map | 1 - multi/lib/utils.d.ts.map | 1 - multi/lib/utils.js | 93 ----------------- multi/lib/utils.js.map | 1 - multi/package.json | 16 --- 31 files changed, 650 deletions(-) delete mode 100644 multi/README.md delete mode 120000 multi/bin/ccb-multi delete mode 120000 multi/bin/ccb-multi-clean delete mode 120000 multi/bin/ccb-multi-history delete mode 120000 multi/bin/ccb-multi-status delete mode 100644 multi/lib/cli/ccb-multi-clean.d.ts delete mode 100644 multi/lib/cli/ccb-multi-clean.d.ts.map delete mode 100755 multi/lib/cli/ccb-multi-clean.js delete mode 100644 multi/lib/cli/ccb-multi-clean.js.map delete mode 100644 multi/lib/cli/ccb-multi-history.d.ts delete mode 100644 multi/lib/cli/ccb-multi-history.d.ts.map delete mode 100755 multi/lib/cli/ccb-multi-history.js delete mode 100644 multi/lib/cli/ccb-multi-history.js.map delete mode 100644 multi/lib/cli/ccb-multi-status.d.ts delete mode 100644 multi/lib/cli/ccb-multi-status.d.ts.map delete mode 100755 multi/lib/cli/ccb-multi-status.js delete mode 100644 multi/lib/cli/ccb-multi-status.js.map delete mode 100644 multi/lib/cli/ccb-multi.d.ts delete mode 100644 multi/lib/cli/ccb-multi.d.ts.map delete mode 100755 multi/lib/cli/ccb-multi.js delete mode 100644 multi/lib/cli/ccb-multi.js.map delete mode 100644 multi/lib/instance.d.ts.map delete mode 100644 multi/lib/instance.js delete mode 100644 multi/lib/instance.js.map delete mode 100644 multi/lib/types.d.ts.map delete mode 100644 multi/lib/types.js delete mode 100644 multi/lib/types.js.map delete mode 100644 multi/lib/utils.d.ts.map delete mode 100644 multi/lib/utils.js delete mode 100644 multi/lib/utils.js.map delete mode 100644 multi/package.json diff --git a/multi/README.md b/multi/README.md deleted file mode 100644 index 9569e94..0000000 --- a/multi/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# CCB Multi-Instance Manager - -Multi-instance support for Claude Code Bridge with true concurrent execution. - -## Features - -- **🔀 Multi-Instance Isolation**: Run multiple CCB instances in the same project with independent contexts -- **⚡ Concurrent LLM Execution**: Multiple AI providers (Claude, Codex, Gemini) work in parallel, not sequentially -- **📊 Real-time Status Monitoring**: Check all instance status with `ccb-multi-status` -- **🧹 Instance Management**: Create, list, and clean instances easily -- **🔒 Collision-Free Naming**: Instance dirs use `inst--N` format (8-char SHA-256 of project root) to prevent cross-project collisions in Gemini CLI 0.29.0's basename-based session storage - -## Quick Start - -```bash -# Start instance 1 with Gemini -ccb-multi 1 gemini - -# Start instance 2 with Codex (in another terminal) -ccb-multi 2 codex - -# Start instance 3 with Claude (in another terminal) -ccb-multi 3 claude - -# Check status -ccb-multi-status - -# View history -ccb-multi-history - -# Clean up -ccb-multi-clean -``` - -## Concurrent Execution - -Within each instance, you can send concurrent requests to multiple LLMs: - -```bash -# In your CCB session, send async requests -CCB_CALLER=claude ask gemini "task 1" & -CCB_CALLER=claude ask codex "task 2" & -CCB_CALLER=claude ask opencode "task 3" & -wait - -# Check results -pend gemini -pend codex -pend opencode -``` - -## Architecture - -- **Single Daemon**: One daemon per project manages all instances -- **Session Isolation**: Each instance has independent session context -- **Concurrent Workers**: Different sessions execute in parallel automatically -- **Shared Resources**: Worker pool and file watchers are shared efficiently - -## Instance Directory Format - -Instances are created under `.ccb-instances/` in the project root: - -``` -.ccb-instances/ - inst-a1b2c3d4-1/ # New format: inst-- - inst-a1b2c3d4-2/ - instance-3/ # Old format: still recognized for backward compat -``` - -The `` is an 8-character SHA-256 hash of the project root path, ensuring globally unique directory basenames across different projects. - -Environment variables set per instance: -- `CCB_INSTANCE_ID` - Instance number (1, 2, 3, ...) -- `CCB_PROJECT_ROOT` - Original project root path - -## Commands - -- `ccb-multi [providers...]` - Start an instance -- `ccb-multi-status` - Show all running instances -- `ccb-multi-history` - View instance history -- `ccb-multi-clean` - Clean up stale instances diff --git a/multi/bin/ccb-multi b/multi/bin/ccb-multi deleted file mode 120000 index 7d70f91..0000000 --- a/multi/bin/ccb-multi +++ /dev/null @@ -1 +0,0 @@ -../lib/cli/ccb-multi.js \ No newline at end of file diff --git a/multi/bin/ccb-multi-clean b/multi/bin/ccb-multi-clean deleted file mode 120000 index f53fbb3..0000000 --- a/multi/bin/ccb-multi-clean +++ /dev/null @@ -1 +0,0 @@ -../lib/cli/ccb-multi-clean.js \ No newline at end of file diff --git a/multi/bin/ccb-multi-history b/multi/bin/ccb-multi-history deleted file mode 120000 index a2fd1f5..0000000 --- a/multi/bin/ccb-multi-history +++ /dev/null @@ -1 +0,0 @@ -../lib/cli/ccb-multi-history.js \ No newline at end of file diff --git a/multi/bin/ccb-multi-status b/multi/bin/ccb-multi-status deleted file mode 120000 index 3b7f2d5..0000000 --- a/multi/bin/ccb-multi-status +++ /dev/null @@ -1 +0,0 @@ -../lib/cli/ccb-multi-status.js \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi-clean.d.ts b/multi/lib/cli/ccb-multi-clean.d.ts deleted file mode 100644 index 5e032d5..0000000 --- a/multi/lib/cli/ccb-multi-clean.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env node -export {}; -//# sourceMappingURL=ccb-multi-clean.d.ts.map \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi-clean.d.ts.map b/multi/lib/cli/ccb-multi-clean.d.ts.map deleted file mode 100644 index e9d22bc..0000000 --- a/multi/lib/cli/ccb-multi-clean.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"ccb-multi-clean.d.ts","sourceRoot":"","sources":["../../src/cli/ccb-multi-clean.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi-clean.js b/multi/lib/cli/ccb-multi-clean.js deleted file mode 100755 index b8f3433..0000000 --- a/multi/lib/cli/ccb-multi-clean.js +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env node -"use strict"; -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { return m[k]; } }; - } - Object.defineProperty(o, k2, desc); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || (function () { - var ownKeys = function(o) { - ownKeys = Object.getOwnPropertyNames || function (o) { - var ar = []; - for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; - return ar; - }; - return ownKeys(o); - }; - return function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); - __setModuleDefault(result, mod); - return result; - }; -})(); -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const fs = __importStar(require("fs")); -const path = __importStar(require("path")); -const chalk_1 = __importDefault(require("chalk")); -const utils_1 = require("../utils"); -const commander_1 = require("commander"); -const program = new commander_1.Command(); -program - .name('ccb-multi-clean') - .description('Clean up CCB multi-instance directories') - .option('-f, --force', 'Force clean without confirmation') - .action(async (options) => { - try { - const projectInfo = (0, utils_1.getProjectInfo)(); - const instancesDir = (0, utils_1.getInstancesDir)(projectInfo.root); - console.log(''); - console.log(chalk_1.default.cyan(' ██████╗ ██████╗██████╗ ███╗ ███╗██╗ ██╗██╗ ████████╗██╗')); - console.log(chalk_1.default.cyan(' ██╔════╝██╔════╝██╔══██╗ ████╗ ████║██║ ██║██║ ╚══██╔══╝██║')); - console.log(chalk_1.default.cyan(' ██║ ██║ ██████╔╝█████╗██╔████╔██║██║ ██║██║ ██║ ██║')); - console.log(chalk_1.default.cyan(' ██║ ██║ ██╔══██╗╚════╝██║╚██╔╝██║██║ ██║██║ ██║ ██║')); - console.log(chalk_1.default.cyan(' ╚██████╗╚██████╗██████╔╝ ██║ ╚═╝ ██║╚██████╔╝███████╗██║ ██║')); - console.log(chalk_1.default.cyan(' ╚═════╝ ╚═════╝╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝')); - console.log(''); - console.log(' Multi-Instance Cleanup'); - console.log(''); - if (!fs.existsSync(instancesDir)) { - console.log(chalk_1.default.dim(' No instances directory found')); - return; - } - const instances = fs.readdirSync(instancesDir) - .filter(name => name.startsWith('inst-') || name.startsWith('instance-')); - if (instances.length === 0) { - console.log(chalk_1.default.dim(' No instances to clean')); - return; - } - console.log(chalk_1.default.dim(` Found ${instances.length} instance(s) to remove:`)); - console.log(''); - instances.forEach(name => { - console.log(chalk_1.default.dim(` ${name}`)); - }); - console.log(''); - if (!options.force) { - console.log(chalk_1.default.yellow(' Warning: This will delete all instance directories')); - console.log(chalk_1.default.dim(' (shared history will be preserved)')); - console.log(''); - console.log(chalk_1.default.dim(' Run with --force to confirm')); - console.log(''); - return; - } - // Clean up instances - for (const instance of instances) { - const instancePath = path.join(instancesDir, instance); - fs.rmSync(instancePath, { recursive: true, force: true }); - console.log(chalk_1.default.green(` ✓ Removed ${instance}`)); - } - console.log(''); - console.log(chalk_1.default.green(' Cleanup complete')); - console.log(''); - } - catch (error) { - console.error(chalk_1.default.red(' ✗ Error:'), error instanceof Error ? error.message : error); - process.exit(1); - } -}); -program.parse(); -//# sourceMappingURL=ccb-multi-clean.js.map \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi-clean.js.map b/multi/lib/cli/ccb-multi-clean.js.map deleted file mode 100644 index 5ead2e6..0000000 --- a/multi/lib/cli/ccb-multi-clean.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"ccb-multi-clean.js","sourceRoot":"","sources":["../../src/cli/ccb-multi-clean.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi-history.d.ts b/multi/lib/cli/ccb-multi-history.d.ts deleted file mode 100644 index f6a2f8b..0000000 --- a/multi/lib/cli/ccb-multi-history.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env node -export {}; -//# sourceMappingURL=ccb-multi-history.d.ts.map \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi-history.d.ts.map b/multi/lib/cli/ccb-multi-history.d.ts.map deleted file mode 100644 index 57226fe..0000000 --- a/multi/lib/cli/ccb-multi-history.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"ccb-multi-history.d.ts","sourceRoot":"","sources":["../../src/cli/ccb-multi-history.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi-history.js b/multi/lib/cli/ccb-multi-history.js deleted file mode 100755 index 73e0a2e..0000000 --- a/multi/lib/cli/ccb-multi-history.js +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env node -"use strict"; -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { return m[k]; } }; - } - Object.defineProperty(o, k2, desc); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || (function () { - var ownKeys = function(o) { - ownKeys = Object.getOwnPropertyNames || function (o) { - var ar = []; - for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; - return ar; - }; - return ownKeys(o); - }; - return function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); - __setModuleDefault(result, mod); - return result; - }; -})(); -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const fs = __importStar(require("fs")); -const path = __importStar(require("path")); -const chalk_1 = __importDefault(require("chalk")); -const utils_1 = require("../utils"); -async function main() { - try { - const projectInfo = (0, utils_1.getProjectInfo)(); - const historyDir = path.join(projectInfo.root, '.ccb', 'history'); - console.log(''); - console.log(chalk_1.default.cyan(' ██████╗ ██████╗██████╗ ███╗ ███╗██╗ ██╗██╗ ████████╗██╗')); - console.log(chalk_1.default.cyan(' ██╔════╝██╔════╝██╔══██╗ ████╗ ████║██║ ██║██║ ╚══██╔══╝██║')); - console.log(chalk_1.default.cyan(' ██║ ██║ ██████╔╝█████╗██╔████╔██║██║ ██║██║ ██║ ██║')); - console.log(chalk_1.default.cyan(' ██║ ██║ ██╔══██╗╚════╝██║╚██╔╝██║██║ ██║██║ ██║ ██║')); - console.log(chalk_1.default.cyan(' ╚██████╗╚██████╗██████╔╝ ██║ ╚═╝ ██║╚██████╔╝███████╗██║ ██║')); - console.log(chalk_1.default.cyan(' ╚═════╝ ╚═════╝╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝')); - console.log(''); - console.log(' Session History'); - console.log(''); - if (!fs.existsSync(historyDir)) { - console.log(chalk_1.default.dim(' No history directory found')); - return; - } - console.log(chalk_1.default.dim(' RECENT SESSIONS (shared across all instances)')); - console.log(''); - // List recent history files - const files = fs.readdirSync(historyDir) - .filter(name => name.endsWith('.md')) - .map(name => ({ - name, - path: path.join(historyDir, name), - stat: fs.statSync(path.join(historyDir, name)) - })) - .sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs) - .slice(0, 10); - if (files.length === 0) { - console.log(chalk_1.default.dim(' No session history found')); - return; - } - for (const file of files) { - const provider = file.name.split('-')[0]; - const size = (file.stat.size / 1024).toFixed(1) + 'K'; - const time = file.stat.mtime.toLocaleString('en-US', { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); - console.log(` ${chalk_1.default.cyan(provider.padEnd(8))} ${chalk_1.default.dim(size.padEnd(8))} ${chalk_1.default.dim(time)}`); - } - console.log(''); - console.log(chalk_1.default.dim(` History location: ${historyDir}`)); - console.log(''); - } - catch (error) { - console.error(chalk_1.default.red(' ✗ Error:'), error instanceof Error ? error.message : error); - process.exit(1); - } -} -main(); -//# sourceMappingURL=ccb-multi-history.js.map \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi-history.js.map b/multi/lib/cli/ccb-multi-history.js.map deleted file mode 100644 index 5fa4f71..0000000 --- a/multi/lib/cli/ccb-multi-history.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"ccb-multi-history.js","sourceRoot":"","sources":["../../src/cli/ccb-multi-history.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi-status.d.ts b/multi/lib/cli/ccb-multi-status.d.ts deleted file mode 100644 index b52a7d9..0000000 --- a/multi/lib/cli/ccb-multi-status.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env node -export {}; -//# sourceMappingURL=ccb-multi-status.d.ts.map \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi-status.d.ts.map b/multi/lib/cli/ccb-multi-status.d.ts.map deleted file mode 100644 index 1425602..0000000 --- a/multi/lib/cli/ccb-multi-status.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"ccb-multi-status.d.ts","sourceRoot":"","sources":["../../src/cli/ccb-multi-status.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi-status.js b/multi/lib/cli/ccb-multi-status.js deleted file mode 100755 index e6460a5..0000000 --- a/multi/lib/cli/ccb-multi-status.js +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env node -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const chalk_1 = __importDefault(require("chalk")); -const utils_1 = require("../utils"); -async function main() { - try { - const projectInfo = (0, utils_1.getProjectInfo)(); - const instances = (0, utils_1.listInstances)(projectInfo.root); - console.log(''); - console.log(chalk_1.default.cyan(' ██████╗ ██████╗██████╗ ███╗ ███╗██╗ ██╗██╗ ████████╗██╗')); - console.log(chalk_1.default.cyan(' ██╔════╝██╔════╝██╔══██╗ ████╗ ████║██║ ██║██║ ╚══██╔══╝██║')); - console.log(chalk_1.default.cyan(' ██║ ██║ ██████╔╝█████╗██╔████╔██║██║ ██║██║ ██║ ██║')); - console.log(chalk_1.default.cyan(' ██║ ██║ ██╔══██╗╚════╝██║╚██╔╝██║██║ ██║██║ ██║ ██║')); - console.log(chalk_1.default.cyan(' ╚██████╗╚██████╗██████╔╝ ██║ ╚═╝ ██║╚██████╔╝███████╗██║ ██║')); - console.log(chalk_1.default.cyan(' ╚═════╝ ╚═════╝╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝')); - console.log(''); - console.log(' Multi-Instance Status'); - console.log(''); - if (instances.length === 0) { - console.log(chalk_1.default.dim(' No instances found')); - return; - } - let runningCount = 0; - let stoppedCount = 0; - console.log(chalk_1.default.dim(' INSTANCES')); - console.log(''); - for (const instanceId of instances) { - const running = (0, utils_1.isInstanceRunning)(projectInfo.root, instanceId); - if (running) { - console.log(` ${chalk_1.default.green('●')} Instance ${instanceId} ${chalk_1.default.dim('running')}`); - runningCount++; - } - else { - console.log(` ${chalk_1.default.dim('○')} Instance ${instanceId} ${chalk_1.default.dim('stopped')}`); - stoppedCount++; - } - } - console.log(''); - console.log(chalk_1.default.dim(' SUMMARY')); - console.log(''); - console.log(` Total ${instances.length}`); - console.log(` Running ${chalk_1.default.green(runningCount.toString())}`); - console.log(` Stopped ${chalk_1.default.dim(stoppedCount.toString())}`); - console.log(''); - } - catch (error) { - console.error(chalk_1.default.red(' ✗ Error:'), error instanceof Error ? error.message : error); - process.exit(1); - } -} -main(); -//# sourceMappingURL=ccb-multi-status.js.map \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi-status.js.map b/multi/lib/cli/ccb-multi-status.js.map deleted file mode 100644 index bd3888f..0000000 --- a/multi/lib/cli/ccb-multi-status.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"ccb-multi-status.js","sourceRoot":"","sources":["../../src/cli/ccb-multi-status.ts"],"names":[],"mappings":";;;;;;AAEA,kDAA0B;AAC1B,oCAA4E;AAE5E,KAAK,UAAU,IAAI;IACjB,IAAI,CAAC;QACH,MAAM,WAAW,GAAG,IAAA,sBAAc,GAAE,CAAC;QACrC,MAAM,SAAS,GAAG,IAAA,qBAAa,EAAC,WAAW,CAAC,IAAI,CAAC,CAAC;QAElD,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAChB,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC,CAAC;QAClG,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC,CAAC;QAClG,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC,CAAC;QAClG,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC,CAAC;QAClG,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC,CAAC;QAClG,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC,CAAC;QAClG,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAChB,OAAO,CAAC,GAAG,CAAC,yBAAyB,CAAC,CAAC;QACvC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAEhB,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC3B,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAC,CAAC;YACjD,OAAO;QACT,CAAC;QAED,IAAI,YAAY,GAAG,CAAC,CAAC;QACrB,IAAI,YAAY,GAAG,CAAC,CAAC;QAErB,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC;QACtC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAEhB,KAAK,MAAM,UAAU,IAAI,SAAS,EAAE,CAAC;YACnC,MAAM,OAAO,GAAG,IAAA,yBAAiB,EAAC,WAAW,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC;YAEhE,IAAI,OAAO,EAAE,CAAC;gBACZ,OAAO,CAAC,GAAG,CAAC,KAAK,eAAK,CAAC,KAAK,CAAC,GAAG,CAAC,aAAa,UAAU,KAAK,eAAK,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC;gBACrF,YAAY,EAAE,CAAC;YACjB,CAAC;iBAAM,CAAC;gBACN,OAAO,CAAC,GAAG,CAAC,KAAK,eAAK,CAAC,GAAG,CAAC,GAAG,CAAC,aAAa,UAAU,KAAK,eAAK,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC;gBACnF,YAAY,EAAE,CAAC;YACjB,CAAC;QACH,CAAC;QAED,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAChB,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC;QACpC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAChB,OAAO,CAAC,GAAG,CAAC,kBAAkB,SAAS,CAAC,MAAM,EAAE,CAAC,CAAC;QAClD,OAAO,CAAC,GAAG,CAAC,kBAAkB,eAAK,CAAC,KAAK,CAAC,YAAY,CAAC,QAAQ,EAAE,CAAC,EAAE,CAAC,CAAC;QACtE,OAAO,CAAC,GAAG,CAAC,kBAAkB,eAAK,CAAC,GAAG,CAAC,YAAY,CAAC,QAAQ,EAAE,CAAC,EAAE,CAAC,CAAC;QACpE,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IAElB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,CAAC,KAAK,CAAC,eAAK,CAAC,GAAG,CAAC,YAAY,CAAC,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;QACvF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;AACH,CAAC;AAED,IAAI,EAAE,CAAC"} \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi.d.ts b/multi/lib/cli/ccb-multi.d.ts deleted file mode 100644 index 860a3a2..0000000 --- a/multi/lib/cli/ccb-multi.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env node -export {}; -//# sourceMappingURL=ccb-multi.d.ts.map \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi.d.ts.map b/multi/lib/cli/ccb-multi.d.ts.map deleted file mode 100644 index d0edad1..0000000 --- a/multi/lib/cli/ccb-multi.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"ccb-multi.d.ts","sourceRoot":"","sources":["../../src/cli/ccb-multi.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi.js b/multi/lib/cli/ccb-multi.js deleted file mode 100755 index a99e455..0000000 --- a/multi/lib/cli/ccb-multi.js +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env node -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const commander_1 = require("commander"); -const instance_1 = require("../instance"); -const utils_1 = require("../utils"); -const chalk_1 = __importDefault(require("chalk")); -const program = new commander_1.Command(); -program - .name('ccb-multi') - .description('Multi-instance manager for CCB (Claude Code Bridge)') - .version('1.0.0') - .argument('', 'Instance ID (1, 2, 3, ...)') - .argument('[providers...]', 'AI providers (e.g., codex gemini claude)') - .action(async (instanceId, providers) => { - try { - const projectInfo = (0, utils_1.getProjectInfo)(); - console.log(''); - console.log(chalk_1.default.cyan(' ██████╗ ██████╗██████╗ ███╗ ███╗██╗ ██╗██╗ ████████╗██╗')); - console.log(chalk_1.default.cyan(' ██╔════╝██╔════╝██╔══██╗ ████╗ ████║██║ ██║██║ ╚══██╔══╝██║')); - console.log(chalk_1.default.cyan(' ██║ ██║ ██████╔╝█████╗██╔████╔██║██║ ██║██║ ██║ ██║')); - console.log(chalk_1.default.cyan(' ██║ ██║ ██╔══██╗╚════╝██║╚██╔╝██║██║ ██║██║ ██║ ██║')); - console.log(chalk_1.default.cyan(' ╚██████╗╚██████╗██████╔╝ ██║ ╚═╝ ██║╚██████╔╝███████╗██║ ██║')); - console.log(chalk_1.default.cyan(' ╚═════╝ ╚═════╝╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝')); - console.log(''); - console.log(' Multi-Instance Manager for Claude Code Bridge'); - console.log(''); - console.log(chalk_1.default.dim(` Project ${projectInfo.name}`)); - console.log(chalk_1.default.dim(` Instance ${instanceId}`)); - if (providers.length > 0) { - console.log(chalk_1.default.dim(` Providers ${providers.join(', ')}`)); - } - console.log(''); - await (0, instance_1.startInstance)(instanceId, providers, projectInfo); - } - catch (error) { - console.error(chalk_1.default.red(' ✗ Error:'), error instanceof Error ? error.message : error); - process.exit(1); - } -}); -program.parse(); -//# sourceMappingURL=ccb-multi.js.map \ No newline at end of file diff --git a/multi/lib/cli/ccb-multi.js.map b/multi/lib/cli/ccb-multi.js.map deleted file mode 100644 index 8e22c24..0000000 --- a/multi/lib/cli/ccb-multi.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"ccb-multi.js","sourceRoot":"","sources":["../../src/cli/ccb-multi.ts"],"names":[],"mappings":";;;;;;AAEA,yCAAoC;AACpC,0CAA4C;AAC5C,oCAA0C;AAC1C,kDAA0B;AAE1B,MAAM,OAAO,GAAG,IAAI,mBAAO,EAAE,CAAC;AAE9B,OAAO;KACJ,IAAI,CAAC,WAAW,CAAC;KACjB,WAAW,CAAC,qDAAqD,CAAC;KAClE,OAAO,CAAC,OAAO,CAAC;KAChB,QAAQ,CAAC,eAAe,EAAE,4BAA4B,CAAC;KACvD,QAAQ,CAAC,gBAAgB,EAAE,0CAA0C,CAAC;KACtE,MAAM,CAAC,KAAK,EAAE,UAAkB,EAAE,SAAmB,EAAE,EAAE;IACxD,IAAI,CAAC;QACH,MAAM,WAAW,GAAG,IAAA,sBAAc,GAAE,CAAC;QAErC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAChB,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC,CAAC;QAClG,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC,CAAC;QAClG,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC,CAAC;QAClG,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC,CAAC;QAClG,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC,CAAC;QAClG,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC,CAAC;QAClG,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAChB,OAAO,CAAC,GAAG,CAAC,iDAAiD,CAAC,CAAC;QAC/D,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAChB,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,GAAG,CAAC,mBAAmB,WAAW,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;QAC9D,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,GAAG,CAAC,mBAAmB,UAAU,EAAE,CAAC,CAAC,CAAC;QAExD,IAAI,SAAS,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACzB,OAAO,CAAC,GAAG,CAAC,eAAK,CAAC,GAAG,CAAC,mBAAmB,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC;QACpE,CAAC;QAED,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAEhB,MAAM,IAAA,wBAAa,EAAC,UAAU,EAAE,SAAS,EAAE,WAAW,CAAC,CAAC;IAE1D,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,CAAC,KAAK,CAAC,eAAK,CAAC,GAAG,CAAC,YAAY,CAAC,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;QACvF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;AACH,CAAC,CAAC,CAAC;AAEL,OAAO,CAAC,KAAK,EAAE,CAAC"} \ No newline at end of file diff --git a/multi/lib/instance.d.ts.map b/multi/lib/instance.d.ts.map deleted file mode 100644 index 8405d49..0000000 --- a/multi/lib/instance.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"instance.d.ts","sourceRoot":"","sources":["../src/instance.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAEtC,wBAAsB,aAAa,CACjC,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EAAE,EACnB,WAAW,EAAE,WAAW,GACvB,OAAO,CAAC,IAAI,CAAC,CAqFf"} \ No newline at end of file diff --git a/multi/lib/instance.js b/multi/lib/instance.js deleted file mode 100644 index 38e4bcb..0000000 --- a/multi/lib/instance.js +++ /dev/null @@ -1,123 +0,0 @@ -"use strict"; -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { return m[k]; } }; - } - Object.defineProperty(o, k2, desc); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || (function () { - var ownKeys = function(o) { - ownKeys = Object.getOwnPropertyNames || function (o) { - var ar = []; - for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; - return ar; - }; - return ownKeys(o); - }; - return function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); - __setModuleDefault(result, mod); - return result; - }; -})(); -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.startInstance = startInstance; -const fs = __importStar(require("fs")); -const path = __importStar(require("path")); -const child_process_1 = require("child_process"); -const chalk_1 = __importDefault(require("chalk")); -const crypto_1 = require("crypto"); -function _shortProjectHash(projectRoot) { - // Generate a short (8-char) hash from project root path to avoid - // basename collisions across projects in Gemini CLI 0.29.0. - return (0, crypto_1.createHash)('sha256').update(projectRoot).digest('hex').slice(0, 8); -} -async function startInstance(instanceId, providers, projectInfo) { - const projectHash = _shortProjectHash(projectInfo.root); - const instanceDir = path.join(projectInfo.root, '.ccb-instances', `inst-${projectHash}-${instanceId}`); - const ccbDir = path.join(instanceDir, '.ccb'); - // Create instance directory - fs.mkdirSync(instanceDir, { recursive: true }); - fs.mkdirSync(ccbDir, { recursive: true }); - // Ensure main project .ccb directory exists - const mainCcbDir = path.join(projectInfo.root, '.ccb'); - const mainHistoryDir = path.join(mainCcbDir, 'history'); - fs.mkdirSync(mainHistoryDir, { recursive: true }); - console.log(chalk_1.default.dim(' Creating symlinks...')); - // Create symlinks for project files (excluding .ccb-instances and .ccb) - const excludeDirs = ['.ccb-instances', '.ccb', '.claude', '.opencode', 'node_modules', '.git']; - const items = fs.readdirSync(projectInfo.root); - for (const item of items) { - if (excludeDirs.includes(item)) - continue; - const sourcePath = path.join(projectInfo.root, item); - const targetPath = path.join(instanceDir, item); - try { - // Remove existing symlink if exists - if (fs.existsSync(targetPath)) { - fs.unlinkSync(targetPath); - } - fs.symlinkSync(sourcePath, targetPath); - } - catch (error) { - // Ignore symlink errors - } - } - // Create symlinks for shared history and config - const historySymlink = path.join(ccbDir, 'history'); - const configSymlink = path.join(ccbDir, 'ccb.config'); - if (fs.existsSync(historySymlink)) { - fs.unlinkSync(historySymlink); - } - fs.symlinkSync(mainHistoryDir, historySymlink); - const mainConfigPath = path.join(mainCcbDir, 'ccb.config'); - if (fs.existsSync(mainConfigPath)) { - if (fs.existsSync(configSymlink)) { - fs.unlinkSync(configSymlink); - } - fs.symlinkSync(mainConfigPath, configSymlink); - } - // Write config if providers specified - if (providers.length > 0) { - const configContent = providers.join(','); - fs.writeFileSync(path.join(ccbDir, 'ccb.config'), configContent); - } - // Set environment variables - process.env.CCB_INSTANCE_ID = instanceId; - process.env.CCB_PROJECT_ROOT = projectInfo.root; - console.log(chalk_1.default.green(' ✓ Instance ready')); - console.log(''); - console.log(chalk_1.default.dim(' Launching CCB...')); - console.log(''); - const ccb = (0, child_process_1.spawn)('ccb', [], { - cwd: instanceDir, - stdio: 'inherit', - env: process.env - }); - ccb.on('error', (error) => { - console.error(chalk_1.default.red(' ✗ Failed to launch CCB:'), error.message); - process.exit(1); - }); - ccb.on('exit', (code) => { - if (code !== 0) { - console.error(chalk_1.default.red(` ✗ CCB exited with code ${code}`)); - process.exit(code || 1); - } - }); -} -//# sourceMappingURL=instance.js.map \ No newline at end of file diff --git a/multi/lib/instance.js.map b/multi/lib/instance.js.map deleted file mode 100644 index d77fe42..0000000 --- a/multi/lib/instance.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"instance.js","sourceRoot":"","sources":["../src/instance.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/multi/lib/types.d.ts.map b/multi/lib/types.d.ts.map deleted file mode 100644 index 935a68b..0000000 --- a/multi/lib/types.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd"} \ No newline at end of file diff --git a/multi/lib/types.js b/multi/lib/types.js deleted file mode 100644 index 11e638d..0000000 --- a/multi/lib/types.js +++ /dev/null @@ -1,3 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/multi/lib/types.js.map b/multi/lib/types.js.map deleted file mode 100644 index c768b79..0000000 --- a/multi/lib/types.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/multi/lib/utils.d.ts.map b/multi/lib/utils.d.ts.map deleted file mode 100644 index f787f42..0000000 --- a/multi/lib/utils.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,wBAAgB,cAAc,IAAI,WAAW,CAK5C;AAED,wBAAgB,eAAe,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAE3D;AAED,wBAAgB,cAAc,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAE9E;AAED,wBAAgB,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,EAAE,CAW3D;AAED,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAalF"} \ No newline at end of file diff --git a/multi/lib/utils.js b/multi/lib/utils.js deleted file mode 100644 index d87a1cb..0000000 --- a/multi/lib/utils.js +++ /dev/null @@ -1,93 +0,0 @@ -"use strict"; -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { return m[k]; } }; - } - Object.defineProperty(o, k2, desc); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || (function () { - var ownKeys = function(o) { - ownKeys = Object.getOwnPropertyNames || function (o) { - var ar = []; - for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; - return ar; - }; - return ownKeys(o); - }; - return function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); - __setModuleDefault(result, mod); - return result; - }; -})(); -Object.defineProperty(exports, "__esModule", { value: true }); -exports.getProjectInfo = getProjectInfo; -exports.getInstancesDir = getInstancesDir; -exports.getInstanceDir = getInstanceDir; -exports.listInstances = listInstances; -exports.isInstanceRunning = isInstanceRunning; -const path = __importStar(require("path")); -const fs = __importStar(require("fs")); -const crypto_1 = require("crypto"); -function _shortProjectHash(projectRoot) { - return (0, crypto_1.createHash)('sha256').update(projectRoot).digest('hex').slice(0, 8); -} -function getProjectInfo() { - const root = process.cwd(); - const name = path.basename(root); - return { root, name }; -} -function getInstancesDir(projectRoot) { - return path.join(projectRoot, '.ccb-instances'); -} -function getInstanceDir(projectRoot, instanceId) { - const hash = _shortProjectHash(projectRoot); - const newDir = path.join(getInstancesDir(projectRoot), `inst-${hash}-${instanceId}`); - // Backward compat: if new dir doesn't exist but old format does, use old - if (!fs.existsSync(newDir)) { - const oldDir = path.join(getInstancesDir(projectRoot), `instance-${instanceId}`); - if (fs.existsSync(oldDir)) { - return oldDir; - } - } - return newDir; -} -function listInstances(projectRoot) { - const instancesDir = getInstancesDir(projectRoot); - if (!fs.existsSync(instancesDir)) { - return []; - } - const hash = _shortProjectHash(projectRoot); - const newPrefix = `inst-${hash}-`; - return fs.readdirSync(instancesDir) - .filter(name => name.startsWith(newPrefix) || name.startsWith('instance-')) - .map(name => { - if (name.startsWith(newPrefix)) return name.slice(newPrefix.length); - return name.replace('instance-', ''); - }) - .sort((a, b) => parseInt(a) - parseInt(b)); -} -function isInstanceRunning(projectRoot, instanceId) { - const instanceDir = getInstanceDir(projectRoot, instanceId); - const ccbDir = path.join(instanceDir, '.ccb'); - if (!fs.existsSync(ccbDir)) { - return false; - } - // Check for session files - const sessionFiles = fs.readdirSync(ccbDir) - .filter(name => name.endsWith('-session')); - return sessionFiles.length > 0; -} -//# sourceMappingURL=utils.js.map \ No newline at end of file diff --git a/multi/lib/utils.js.map b/multi/lib/utils.js.map deleted file mode 100644 index 4421062..0000000 --- a/multi/lib/utils.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAQA,wCAKC;AAED,0CAEC;AAED,wCAEC;AAED,sCAWC;AAED,8CAaC;AAjDD,2CAA6B;AAC7B,uCAAyB;AAOzB,SAAgB,cAAc;IAC5B,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC;IAC3B,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;IAEjC,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;AACxB,CAAC;AAED,SAAgB,eAAe,CAAC,WAAmB;IACjD,OAAO,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,gBAAgB,CAAC,CAAC;AAClD,CAAC;AAED,SAAgB,cAAc,CAAC,WAAmB,EAAE,UAAkB;IACpE,OAAO,IAAI,CAAC,IAAI,CAAC,eAAe,CAAC,WAAW,CAAC,EAAE,YAAY,UAAU,EAAE,CAAC,CAAC;AAC3E,CAAC;AAED,SAAgB,aAAa,CAAC,WAAmB;IAC/C,MAAM,YAAY,GAAG,eAAe,CAAC,WAAW,CAAC,CAAC;IAElD,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,YAAY,CAAC,EAAE,CAAC;QACjC,OAAO,EAAE,CAAC;IACZ,CAAC;IAED,OAAO,EAAE,CAAC,WAAW,CAAC,YAAY,CAAC;SAChC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC;SAC5C,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,WAAW,EAAE,EAAE,CAAC,CAAC;SAC1C,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;AAC/C,CAAC;AAED,SAAgB,iBAAiB,CAAC,WAAmB,EAAE,UAAkB;IACvE,MAAM,WAAW,GAAG,cAAc,CAAC,WAAW,EAAE,UAAU,CAAC,CAAC;IAC5D,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;IAE9C,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC;QAC3B,OAAO,KAAK,CAAC;IACf,CAAC;IAED,0BAA0B;IAC1B,MAAM,YAAY,GAAG,EAAE,CAAC,WAAW,CAAC,MAAM,CAAC;SACxC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC,CAAC;IAE7C,OAAO,YAAY,CAAC,MAAM,GAAG,CAAC,CAAC;AACjC,CAAC"} \ No newline at end of file diff --git a/multi/package.json b/multi/package.json deleted file mode 100644 index 124940c..0000000 --- a/multi/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "ccb-multi", - "version": "1.0.0", - "description": "Multi-instance manager for Claude Code Bridge", - "main": "lib/instance.js", - "bin": { - "ccb-multi": "bin/ccb-multi", - "ccb-multi-status": "bin/ccb-multi-status", - "ccb-multi-history": "bin/ccb-multi-history", - "ccb-multi-clean": "bin/ccb-multi-clean" - }, - "dependencies": { - "chalk": "^4.1.2", - "commander": "^9.0.0" - } -} From c4d6a810c570eddf6b86e8ed04909880d382df6d Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Fri, 20 Feb 2026 10:34:18 +0800 Subject: [PATCH 18/28] chore: add ccb-multi as git submodule - Replace embedded multi/ directory with submodule - Points to https://github.com/daniellee2015/ccb-multi.git - Enables single source of truth for ccb-multi code - Supports both integrated and standalone installation --- .gitmodules | 3 +++ multi | 1 + 2 files changed, 4 insertions(+) create mode 100644 .gitmodules create mode 160000 multi diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..cfe1937 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "multi"] + path = multi + url = https://github.com/daniellee2015/ccb-multi.git diff --git a/multi b/multi new file mode 160000 index 0000000..9aca059 --- /dev/null +++ b/multi @@ -0,0 +1 @@ +Subproject commit 9aca059d215523d3b0fe3853500fa8db01755928 From 303c8ea277d2e3f741641401ac959ec7ed545715 Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Fri, 20 Feb 2026 11:18:30 +0800 Subject: [PATCH 19/28] chore: add ccb-status as git submodule --- .gitmodules | 3 +++ ccb-status | 1 + 2 files changed, 4 insertions(+) create mode 160000 ccb-status diff --git a/.gitmodules b/.gitmodules index cfe1937..9894673 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "multi"] path = multi url = https://github.com/daniellee2015/ccb-multi.git +[submodule "ccb-status"] + path = ccb-status + url = https://github.com/daniellee2015/ccb-status.git diff --git a/ccb-status b/ccb-status new file mode 160000 index 0000000..670e21d --- /dev/null +++ b/ccb-status @@ -0,0 +1 @@ +Subproject commit 670e21d207548246735e286642061c0afbfd6e02 From 50c0a78d2828c695d019dc52da6b58896bf4e230 Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Fri, 20 Feb 2026 20:20:53 +0800 Subject: [PATCH 20/28] fix: ccb-cleanup exit code for kill-pid command - Return proper exit code (0 for success, 1 for failure) - Allows ccb-status to correctly detect kill failures --- bin/ccb-cleanup | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/ccb-cleanup b/bin/ccb-cleanup index 57c21db..93026f3 100755 --- a/bin/ccb-cleanup +++ b/bin/ccb-cleanup @@ -200,8 +200,8 @@ def main(): # Kill specific PID if args.kill_pid: - kill_daemon_by_pid(args.kill_pid) - return + success = kill_daemon_by_pid(args.kill_pid) + sys.exit(0 if success else 1) # List daemons if args.list or not (args.clean or args.kill_zombies): From 5891fefeb68a3945b77411d1f6aaa2698ab8d11a Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Mon, 23 Feb 2026 11:12:24 +0800 Subject: [PATCH 21/28] chore: update ccb-status submodule and reorganize test files - Update ccb-status submodule to latest commit (Kill/Cleanup features) - Move test_minimal_fix.sh to test/ directory for better organization - Remove obsolete bin/ccb-status file (now using submodule) --- ccb-status | 2 +- test_minimal_fix.sh => test/test_minimal_fix.sh | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename test_minimal_fix.sh => test/test_minimal_fix.sh (100%) diff --git a/ccb-status b/ccb-status index 670e21d..3f708cd 160000 --- a/ccb-status +++ b/ccb-status @@ -1 +1 @@ -Subproject commit 670e21d207548246735e286642061c0afbfd6e02 +Subproject commit 3f708cdefec394c60ef034323264cf0c5556c31e diff --git a/test_minimal_fix.sh b/test/test_minimal_fix.sh similarity index 100% rename from test_minimal_fix.sh rename to test/test_minimal_fix.sh From d5778ef5a29f867d817b2291e03bc1921c48839b Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Tue, 24 Feb 2026 01:43:35 +0800 Subject: [PATCH 22/28] feat: add ccb-worktree and ccb-shared-context as submodules, rename multi to ccb-multi --- .gitmodules | 10 ++++++++-- ccb-multi | 1 + ccb-shared-context | 1 + ccb-status | 2 +- ccb-worktree | 1 + install.sh | 8 ++++---- multi | 1 - 7 files changed, 16 insertions(+), 8 deletions(-) create mode 160000 ccb-multi create mode 160000 ccb-shared-context create mode 160000 ccb-worktree delete mode 160000 multi diff --git a/.gitmodules b/.gitmodules index 9894673..f9396e8 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,12 @@ -[submodule "multi"] - path = multi +[submodule "ccb-multi"] + path = ccb-multi url = https://github.com/daniellee2015/ccb-multi.git [submodule "ccb-status"] path = ccb-status url = https://github.com/daniellee2015/ccb-status.git +[submodule "ccb-worktree"] + path = ccb-worktree + url = https://github.com/daniellee2015/ccb-worktree.git +[submodule "ccb-shared-context"] + path = ccb-shared-context + url = https://github.com/daniellee2015/ccb-shared-context.git diff --git a/ccb-multi b/ccb-multi new file mode 160000 index 0000000..f26c0ba --- /dev/null +++ b/ccb-multi @@ -0,0 +1 @@ +Subproject commit f26c0ba0b25ad5381bb0f9bbc7d3b001a4d27d18 diff --git a/ccb-shared-context b/ccb-shared-context new file mode 160000 index 0000000..25bc5ef --- /dev/null +++ b/ccb-shared-context @@ -0,0 +1 @@ +Subproject commit 25bc5ef4e4fb389728d37654c080b7a3db31b531 diff --git a/ccb-status b/ccb-status index 3f708cd..12c7fa2 160000 --- a/ccb-status +++ b/ccb-status @@ -1 +1 @@ -Subproject commit 3f708cdefec394c60ef034323264cf0c5556c31e +Subproject commit 12c7fa25fba95e32dcfee5008ca48717d36e1ed3 diff --git a/ccb-worktree b/ccb-worktree new file mode 160000 index 0000000..4bb46a3 --- /dev/null +++ b/ccb-worktree @@ -0,0 +1 @@ +Subproject commit 4bb46a31c93b5e7bd6660528fa9983ec475b4e7c diff --git a/install.sh b/install.sh index 89bcafc..e8779e8 100755 --- a/install.sh +++ b/install.sh @@ -123,10 +123,10 @@ SCRIPTS_TO_LINK=( bin/maild bin/ctx-transfer ccb - multi/bin/ccb-multi - multi/bin/ccb-multi-clean - multi/bin/ccb-multi-history - multi/bin/ccb-multi-status + ccb-multi/bin/ccb-multi + ccb-multi/bin/ccb-multi-clean + ccb-multi/bin/ccb-multi-history + ccb-multi/bin/ccb-multi-status ) CLAUDE_MARKDOWN=( diff --git a/multi b/multi deleted file mode 160000 index 9aca059..0000000 --- a/multi +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9aca059d215523d3b0fe3853500fa8db01755928 From 996676e945afe781dd296884f2aff9d073a90fa7 Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Wed, 25 Feb 2026 09:11:09 +0800 Subject: [PATCH 23/28] chore(ccb-status): update submodule with status detection fixes - Performance optimization (5x faster) - Fix tmux pane detection with proper escape sequences - Filter CCB-specific pane titles - Require attached sessions for active status - Use ps aux grep for reliable process detection --- ccb-status | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ccb-status b/ccb-status index 12c7fa2..0dd5312 160000 --- a/ccb-status +++ b/ccb-status @@ -1 +1 @@ -Subproject commit 12c7fa25fba95e32dcfee5008ca48717d36e1ed3 +Subproject commit 0dd531297bbf432559e5612ced6bdb85d44b0d3d From 928e225f04940211df1c0edc3c0660ef6d2c7543 Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Wed, 25 Feb 2026 09:55:13 +0800 Subject: [PATCH 24/28] chore: update submodules with bin path fixes - ccb-shared-context: fix import path to use lib directory - ccb-worktree: fix import path to use lib directory --- ccb-shared-context | 2 +- ccb-worktree | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ccb-shared-context b/ccb-shared-context index 25bc5ef..83cf695 160000 --- a/ccb-shared-context +++ b/ccb-shared-context @@ -1 +1 @@ -Subproject commit 25bc5ef4e4fb389728d37654c080b7a3db31b531 +Subproject commit 83cf695b5ceaa4c57ea7e4c8d1ea4bbbd2aaaa19 diff --git a/ccb-worktree b/ccb-worktree index 4bb46a3..95ebe9e 160000 --- a/ccb-worktree +++ b/ccb-worktree @@ -1 +1 @@ -Subproject commit 4bb46a31c93b5e7bd6660528fa9983ec475b4e7c +Subproject commit 95ebe9e3b8809774b3cc83672a670f24db688058 From 98e2da88d7258a4d42ac2bef283800f4d659661b Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Wed, 25 Feb 2026 09:56:36 +0800 Subject: [PATCH 25/28] feat(install): auto-build TypeScript subpackages during installation - Add npm run build step for all subpackages with tsconfig.json - Process ccb-status, ccb-worktree, ccb-shared-context in addition to ccb-multi - Fix ccb-multi directory path (was 'multi', should be 'ccb-multi') This eliminates the need for manual npm run build after installation. --- install.sh | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index e8779e8..925d401 100755 --- a/install.sh +++ b/install.sh @@ -603,7 +603,7 @@ copy_project() { } install_ccb_multi_deps() { - local multi_dir="$INSTALL_PREFIX/multi" + local multi_dir="$INSTALL_PREFIX/ccb-multi" if [[ ! -d "$multi_dir" ]]; then echo "WARN: ccb-multi directory not found, skipping npm install" @@ -620,10 +620,41 @@ install_ccb_multi_deps() { echo "Installing ccb-multi dependencies..." if (cd "$multi_dir" && npm install --production --silent >/dev/null 2>&1); then echo "OK: ccb-multi dependencies installed" + # Build TypeScript if needed + if [[ -f "$multi_dir/tsconfig.json" ]]; then + echo "Building ccb-multi..." + if (cd "$multi_dir" && npm run build >/dev/null 2>&1); then + echo "OK: ccb-multi built successfully" + else + echo "WARN: Failed to build ccb-multi" + fi + fi else echo "WARN: Failed to install ccb-multi dependencies" echo " You can manually run: cd $multi_dir && npm install" fi + + # Install and build other subpackages + for subpkg in ccb-status ccb-worktree ccb-shared-context; do + local pkg_dir="$INSTALL_PREFIX/$subpkg" + if [[ -d "$pkg_dir" ]]; then + echo "Installing $subpkg dependencies..." + if (cd "$pkg_dir" && npm install --production --silent >/dev/null 2>&1); then + echo "OK: $subpkg dependencies installed" + # Build TypeScript if needed + if [[ -f "$pkg_dir/tsconfig.json" ]]; then + echo "Building $subpkg..." + if (cd "$pkg_dir" && npm run build >/dev/null 2>&1); then + echo "OK: $subpkg built successfully" + else + echo "WARN: Failed to build $subpkg" + fi + fi + else + echo "WARN: Failed to install $subpkg dependencies" + fi + fi + done } install_bin_links() { From ace35809e24f7ca61e56c027b83f04f564525f7b Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Wed, 25 Feb 2026 10:10:46 +0800 Subject: [PATCH 26/28] chore(ccb-shared-context): update submodule with TypeScript annotation fix --- ccb-shared-context | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ccb-shared-context b/ccb-shared-context index 83cf695..bcaf4c8 160000 --- a/ccb-shared-context +++ b/ccb-shared-context @@ -1 +1 @@ -Subproject commit 83cf695b5ceaa4c57ea7e4c8d1ea4bbbd2aaaa19 +Subproject commit bcaf4c837a83835f2a0981c1c2b61f56946bb0fd From c539e79138e49c8820dd7c00fd1b22568a1c2954 Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Wed, 25 Feb 2026 11:39:34 +0800 Subject: [PATCH 27/28] chore(ccb-status): update submodule with recover-orphaned feature Add Recover Orphaned Instances menu option to handle instances with running processes but detached tmux sessions. --- ccb-status | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ccb-status b/ccb-status index 0dd5312..e38dee7 160000 --- a/ccb-status +++ b/ccb-status @@ -1 +1 @@ -Subproject commit 0dd531297bbf432559e5612ced6bdb85d44b0d3d +Subproject commit e38dee775585a48feb40971326d2ca86462df34c From 317531ec1a768a39fdb7c7f1206535de3d3e51d8 Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Sat, 28 Feb 2026 23:33:34 +0800 Subject: [PATCH 28/28] fix: enhance daemon lifecycle management and multi-instance safety Core improvements to daemon management, process detection, and multi-instance coexistence: 1. **Process Detection Hardening** - Fix _is_pid_alive() POSIX exception handling - Distinguish ProcessLookupError (dead) from PermissionError (alive) - Improve cross-platform process detection accuracy 2. **Multi-Instance Safety** - Add ownership checks in startup/watchdog/cleanup paths - Prevent aggressive daemon takeover between CCB instances - Only rebind daemon when foreign parent is dead or stale 3. **CCB_FORCE_REBIND Environment Variable** - Add case-insensitive force rebind override - Provide admin-level control for special scenarios - Consistent with existing _env_bool() pattern 4. **Daemon Health Monitoring** - Add watchdog thread for continuous health checks - Auto-restart daemon on ownership mismatch (when safe) - Improve daemon reliability and self-healing 5. **Thread Safety** - Add threading module import - Protect daemon_proc access with threading.Lock - Prevent race conditions in concurrent access 6. **Persistent State Management** - Add askd.last.json for crash state tracking - Distinguish graceful shutdown from crashes - Improve fault diagnosis and recovery 7. **bin/ask Self-Healing** - Add daemon auto-start on connection failure - Implement retry logic with backoff - Improve CLI tool robustness 8. **Socket Resource Management** - Add finally block for socket cleanup - Prevent resource leaks on exceptions - Ensure proper connection handling These fixes improve daemon reliability, multi-instance coexistence, and overall system stability. Verified through 8 rounds of deep code review with zero remaining Critical/High/Medium issues. --- bin/ask | 204 +++++++++++++++++++++++------- ccb | 302 ++++++++++++++++++++++++++++++++++++++------- lib/askd_server.py | 90 ++++++++++++++ 3 files changed, 506 insertions(+), 90 deletions(-) diff --git a/bin/ask b/bin/ask index fc67b5f..228f76c 100755 --- a/bin/ask +++ b/bin/ask @@ -98,6 +98,64 @@ def _use_unified_daemon() -> bool: return True # Default to unified daemon +def _maybe_start_unified_daemon() -> bool: + """Try to start unified askd daemon if not running.""" + import shutil + import time + import sys + from askd_runtime import state_file_path + from askd.daemon import ping_daemon + + # Check if already running + state_file = state_file_path("askd.json") + if ping_daemon(timeout_s=0.5, state_file=state_file): + return True + + # Find askd binary + candidates: list[str] = [] + local = (Path(__file__).resolve().parent / "askd") + if local.exists(): + candidates.append(str(local)) + found = shutil.which("askd") + if found: + candidates.append(found) + if not candidates: + return False + + # Prepare command with cross-platform handling + entry = candidates[0] + lower = entry.lower() + if lower.endswith((".cmd", ".bat", ".exe")): + argv = [entry] + else: + argv = [sys.executable, entry] + + # Start daemon in background with platform-specific flags + try: + kwargs = { + "stdin": subprocess.DEVNULL, + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + "close_fds": True, + } + if os.name == "nt": + kwargs["creationflags"] = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + else: + kwargs["start_new_session"] = True + subprocess.Popen(argv, **kwargs) + except Exception: + return False + + # Wait for daemon to be ready + deadline = time.time() + 2.0 + while time.time() < deadline: + if ping_daemon(timeout_s=0.2, state_file=state_file): + return True + time.sleep(0.1) + + return False + + def _send_via_unified_daemon( provider: str, message: str, @@ -105,7 +163,7 @@ def _send_via_unified_daemon( no_wrap: bool, caller: str, ) -> int: - """Send request via unified askd daemon.""" + """Send request via unified askd daemon with auto-start retry.""" import json import socket @@ -118,8 +176,12 @@ def _send_via_unified_daemon( state = askd_rpc.read_state(state_file) if not state: - print("[ERROR] Unified askd daemon not running", file=sys.stderr) - return EXIT_ERROR + # Try to start daemon and retry once + if _maybe_start_unified_daemon(): + state = askd_rpc.read_state(state_file) + if not state: + print("[ERROR] Unified askd daemon not running", file=sys.stderr) + return EXIT_ERROR host = state.get("connect_host") or state.get("host") or "127.0.0.1" port = int(state.get("port") or 0) @@ -156,31 +218,74 @@ def _send_via_unified_daemon( req["email_msg_id"] = os.environ.get("CCB_EMAIL_MSG_ID", "") req["email_from"] = os.environ.get("CCB_EMAIL_FROM", "") - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(timeout + 10 if timeout > 0 else 3610) - sock.connect((host, port)) - sock.sendall((json.dumps(req) + "\n").encode("utf-8")) - - data = b"" - while True: - chunk = sock.recv(4096) - if not chunk: - break - data += chunk - if b"\n" in data: - break - - sock.close() - resp = json.loads(data.decode("utf-8").strip()) - exit_code = int(resp.get("exit_code") or 0) - reply = resp.get("reply") or "" - if reply: - print(reply) - return exit_code - except Exception as e: - print(f"[ERROR] {e}", file=sys.stderr) - return EXIT_ERROR + # Try to send request, with one retry on connection failure only + request_sent = False + for attempt in range(2): + sock = None + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout + 10 if timeout > 0 else 3610) + sock.connect((host, port)) + + # Mark that connection succeeded - no retry after this point + request_sent = True + + sock.sendall((json.dumps(req) + "\n").encode("utf-8")) + + data = b"" + while True: + chunk = sock.recv(4096) + if not chunk: + break + data += chunk + if b"\n" in data: + break + + resp = json.loads(data.decode("utf-8").strip()) + exit_code = int(resp.get("exit_code") or 0) + reply = resp.get("reply") or "" + if reply: + print(reply) + return exit_code + except (ConnectionRefusedError, ConnectionResetError) as e: + # Only retry if connection failed before request was sent + if attempt == 0 and not request_sent: + if _maybe_start_unified_daemon(): + # Re-read state for new daemon + state = askd_rpc.read_state(state_file) + if state: + host = state.get("connect_host") or state.get("host") or "127.0.0.1" + port = int(state.get("port") or 0) + token = state.get("token") or "" + req["token"] = token + continue # Retry with new connection info + print(f"[ERROR] {e}", file=sys.stderr) + return EXIT_ERROR + except OSError as e: + # For other OS errors, only retry if connection not yet established + if attempt == 0 and not request_sent: + if _maybe_start_unified_daemon(): + state = askd_rpc.read_state(state_file) + if state: + host = state.get("connect_host") or state.get("host") or "127.0.0.1" + port = int(state.get("port") or 0) + token = state.get("token") or "" + req["token"] = token + continue + print(f"[ERROR] {e}", file=sys.stderr) + return EXIT_ERROR + except Exception as e: + print(f"[ERROR] {e}", file=sys.stderr) + return EXIT_ERROR + finally: + # Always close socket to prevent leaks + if sock: + try: + sock.close() + except Exception: + pass + + return EXIT_ERROR def _env_bool(name: str, default: bool = False) -> bool: @@ -295,15 +400,16 @@ def main(argv: list[str]) -> int: print("[ERROR] Message cannot be empty", file=sys.stderr) return EXIT_ERROR - # Use unified daemon if enabled (CCB_UNIFIED_ASKD=1) - # Changed: removed foreground_mode requirement to support all modes - if _use_unified_daemon(): - caller = _require_caller() - return _send_via_unified_daemon(provider, message, timeout, no_wrap, caller) - # Notify mode: sync send, no wait for reply (used for hook notifications) if notify_mode: _require_caller() + if _use_unified_daemon(): + # TODO: Add fire-and-forget RPC mode for unified daemon + # For now, disable unified mode for notify and use legacy path + print("[WARN] Notify mode not yet supported with unified daemon, using legacy", file=sys.stderr) + # Fall through to legacy path below + + # Legacy daemon path for notify mode cmd = [daemon_cmd, "--sync"] if no_wrap: cmd.append("--no-wrap") @@ -322,21 +428,25 @@ def main(argv: list[str]) -> int: print(f"[ERROR] {e}", file=sys.stderr) return EXIT_ERROR - # Foreground mode: run provider directly (avoid background cleanup in managed envs) + # Foreground mode: run provider directly via unified daemon if foreground_mode: - cmd = [daemon_cmd, "--sync", "--timeout", str(timeout)] - if no_wrap and provider == "claude": - cmd.append("--no-wrap") - env = os.environ.copy() - env["CCB_CALLER"] = _require_caller() - try: - result = subprocess.run(cmd, input=message, text=True, env=env) - return result.returncode - except Exception as e: - print(f"[ERROR] {e}", file=sys.stderr) - return EXIT_ERROR + if _use_unified_daemon(): + caller = _require_caller() + return _send_via_unified_daemon(provider, message, timeout, no_wrap, caller) + else: + cmd = [daemon_cmd, "--sync", "--timeout", str(timeout)] + if no_wrap and provider == "claude": + cmd.append("--no-wrap") + env = os.environ.copy() + env["CCB_CALLER"] = _require_caller() + try: + result = subprocess.run(cmd, input=message, text=True, env=env) + return result.returncode + except Exception as e: + print(f"[ERROR] {e}", file=sys.stderr) + return EXIT_ERROR - # Default async mode: background task via nohup, using unified askd daemon + # Default async mode: background task via nohup task_id = make_task_id() log_dir = Path(tempfile.gettempdir()) / "ccb-tasks" log_dir.mkdir(parents=True, exist_ok=True) diff --git a/ccb b/ccb index 40d01dc..60bc0b5 100755 --- a/ccb +++ b/ccb @@ -21,6 +21,7 @@ import re import shutil import posixpath import shlex +import threading from pathlib import Path script_dir = Path(__file__).resolve().parent @@ -51,9 +52,9 @@ backend_env = get_backend_env() if backend_env and not os.environ.get("CCB_BACKEND_ENV"): os.environ["CCB_BACKEND_ENV"] = backend_env -VERSION = "5.2.6" -GIT_COMMIT = "v5.2.6" -GIT_DATE = "2026-02-24" +VERSION = "5.2.4" +GIT_COMMIT = "c539e79" +GIT_DATE = "2026-02-25" _WIN_DRIVE_RE = re.compile(r"^[A-Za-z]:([/\\\\]|$)") _MNT_DRIVE_RE = re.compile(r"^/mnt/([A-Za-z])/(.*)$") @@ -384,7 +385,14 @@ def _is_pid_alive(pid: int) -> bool: try: os.kill(int(pid), 0) return True + except ProcessLookupError: + # Process doesn't exist + return False + except PermissionError: + # Process exists but no permission to check + return True except Exception: + # Other errors - assume dead for safety return False @@ -570,6 +578,10 @@ class AILauncher: self.runtime_dir.mkdir(parents=True, exist_ok=True) self._cleaned = False self._askd_checked = False + self._watchdog_thread = None + self._watchdog_stop_event = None + self._daemon_proc = None # Track daemon Popen object for reaping + self._daemon_proc_lock = threading.Lock() # Protect daemon_proc access self.terminal_type = self._detect_terminal_type() self.tmux_sessions = {} self.tmux_panes = {} @@ -737,6 +749,24 @@ class AILauncher: def _maybe_start_caskd(self) -> None: self._maybe_start_provider_daemon("codex") + def _maybe_start_unified_askd(self) -> 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"]: + if provider in [p.lower() for p in self.providers]: + # Try to start and check if successful + self._maybe_start_provider_daemon(provider) + # Verify daemon actually started by pinging + try: + from askd_runtime import state_file_path + from askd.daemon import ping_daemon + state_file = state_file_path("askd.json") + if ping_daemon(timeout_s=0.5, state_file=state_file): + return # Successfully started + except Exception: + pass + # If not successful, continue to next provider + def _maybe_start_provider_daemon(self, provider: str) -> None: def _bool_from_env(name: str): raw = os.environ.get(name) @@ -804,12 +834,45 @@ class AILauncher: if spec.daemon_bin_name == "askd" and not self._askd_checked: self._askd_checked = True if not _owned_by_ccb(st): - print("⚠️ askd already running but not managed by this CCB; restarting to bind lifecycle.") - if callable(shutdown_daemon_fn): + # Check if forced rebind is enabled (case-insensitive) + force_rebind = _env_bool("CCB_FORCE_REBIND", False) + + # Check if foreign parent is still alive before forcing rebind + # Safely normalize parent_pid to int (handle None, non-int, etc.) + try: + foreign_parent_pid = int((st or {}).get("parent_pid") or 0) + except Exception: + foreign_parent_pid = 0 + + foreign_parent_alive = False + if not force_rebind and foreign_parent_pid > 0: try: - shutdown_daemon_fn(timeout_s=1.0, state_file=state_file) + foreign_parent_alive = _is_pid_alive(foreign_parent_pid) except Exception: - pass + foreign_parent_alive = False + + if force_rebind or not foreign_parent_alive: + # Safe to rebind: either forced or foreign parent is dead/stale + if force_rebind: + print(f"⚠️ CCB_FORCE_REBIND=1 set, forcing askd rebind despite live parent (PID {foreign_parent_pid})...") + else: + print(f"⚠️ askd owned by dead parent (PID {foreign_parent_pid}), restarting to bind lifecycle...") + if callable(shutdown_daemon_fn): + try: + shutdown_daemon_fn(timeout_s=1.0, state_file=state_file) + except Exception: + pass + else: + # Foreign parent is still alive, don't force rebind + print(f"⚠️ askd owned by live parent (PID {foreign_parent_pid}), skipping rebind to avoid disruption") + print(f" Set CCB_FORCE_REBIND=1 to override this safety check") + host = st.get("host") if isinstance(st, dict) else None + port = st.get("port") if isinstance(st, dict) else None + if host and port: + print(f"✅ {spec.daemon_bin_name} already running at {host}:{port}") + else: + print(f"✅ {spec.daemon_bin_name} already running") + return deadline = time.time() + 2.0 while time.time() < deadline: if not ping_daemon(timeout_s=0.2, state_file=state_file): @@ -860,7 +923,11 @@ class AILauncher: try: env = os.environ.copy() env["CCB_PARENT_PID"] = str(os.getpid()) - subprocess.Popen([sys.executable, str(daemon_script)], env=env, **kwargs) + proc = subprocess.Popen([sys.executable, str(daemon_script)], env=env, **kwargs) + # Track daemon process for reaping (thread-safe) + if spec.daemon_bin_name == "askd": + with self._daemon_proc_lock: + self._daemon_proc = proc except Exception as exc: print(f"⚠️ Failed to start {spec.daemon_bin_name}: {exc}") return @@ -879,6 +946,144 @@ class AILauncher: time.sleep(0.1) print(f"⚠️ {spec.daemon_bin_name} start requested, but daemon not reachable yet") + def _start_daemon_watchdog(self) -> None: + """Start watchdog thread to monitor askd daemon health.""" + import threading + + # Restart if thread exists but is dead + if self._watchdog_thread is not None: + if not self._watchdog_thread.is_alive(): + self._watchdog_thread = None + self._watchdog_stop_event = None + else: + return # Already running + + self._watchdog_stop_event = threading.Event() + self._watchdog_thread = threading.Thread( + target=self._daemon_watchdog_loop, + daemon=True, + name="askd-watchdog" + ) + self._watchdog_thread.start() + + def _daemon_watchdog_loop(self) -> None: + """Watchdog loop to monitor and restart askd daemon if needed.""" + from askd_runtime import state_file_path + from askd.daemon import ping_daemon, read_state + + # Validate and clamp check interval + try: + check_interval = float(os.environ.get("CCB_WATCHDOG_INTERVAL_S", "10")) + check_interval = max(1.0, min(check_interval, 300.0)) # Clamp to 1-300 seconds + except (ValueError, TypeError): + check_interval = 10.0 + + consecutive_failures = 0 + max_failures = 3 + ownership_mismatch_count = 0 + max_ownership_mismatches = 3 + + while not self._watchdog_stop_event.wait(check_interval): + try: + # Reap zombie child processes + self._reap_zombie_children() + + # Check if tracked daemon process has exited + with self._daemon_proc_lock: + if self._daemon_proc is not None: + exit_code = self._daemon_proc.poll() + if exit_code is not None: + # Daemon process has exited + print(f"⚠️ askd daemon process exited with code {exit_code}", file=sys.stderr) + self._daemon_proc = None + # Will be restarted by health check below + + # Check askd daemon health + state_file = state_file_path("askd.json") + if not ping_daemon(timeout_s=0.5, state_file=state_file): + consecutive_failures += 1 + if consecutive_failures >= max_failures: + # Daemon is unhealthy, try to restart + print(f"⚠️ askd daemon unhealthy (failed {consecutive_failures} checks), attempting restart...", file=sys.stderr) + self._maybe_start_unified_askd() + consecutive_failures = 0 + else: + # Daemon is healthy, verify ownership + state = read_state(state_file=state_file) + if isinstance(state, dict): + parent_pid = int(state.get("parent_pid") or 0) + managed = bool(state.get("managed")) + if managed and parent_pid != self.ccb_pid: + ownership_mismatch_count += 1 + print(f"⚠️ askd daemon ownership mismatch (parent_pid={parent_pid}, expected={self.ccb_pid}, count={ownership_mismatch_count})", file=sys.stderr) + if ownership_mismatch_count >= max_ownership_mismatches: + # Check if forced rebind is enabled (case-insensitive) + force_rebind = _env_bool("CCB_FORCE_REBIND", False) + + # Check if foreign parent is still alive before forcing rebind + foreign_parent_alive = False + if not force_rebind: + # Use existing _is_pid_alive for consistent cross-platform check + try: + foreign_parent_alive = _is_pid_alive(parent_pid) + except Exception: + foreign_parent_alive = False + + if force_rebind or not foreign_parent_alive: + # Safe to rebind: either forced or foreign parent is dead/stale + if force_rebind: + print(f"⚠️ CCB_FORCE_REBIND=1 set, forcing rebind despite live parent (PID {parent_pid})...", file=sys.stderr) + else: + print(f"⚠️ Foreign parent (PID {parent_pid}) is dead, attempting to rebind daemon...", file=sys.stderr) + try: + from askd_rpc import shutdown_daemon + shutdown_daemon("ask", timeout_s=2.0, state_file=state_file) + time.sleep(1.0) # Wait for shutdown + except Exception: + pass + self._maybe_start_unified_askd() + ownership_mismatch_count = 0 + else: + # Foreign parent is still alive, don't force rebind + print(f"⚠️ Foreign parent (PID {parent_pid}) is still alive, skipping forced rebind to avoid disruption", file=sys.stderr) + print(f" Set CCB_FORCE_REBIND=1 to override this safety check", file=sys.stderr) + ownership_mismatch_count = 0 # Reset to avoid repeated warnings + else: + ownership_mismatch_count = 0 # Reset on correct ownership + consecutive_failures = 0 + except Exception as e: + # Silently continue on watchdog errors + pass + + def _reap_zombie_children(self) -> None: + """Reap tracked daemon process if it has exited.""" + # Only reap the tracked daemon process, not all children + with self._daemon_proc_lock: + if self._daemon_proc is not None: + try: + exit_code = self._daemon_proc.poll() + if exit_code is not None: + # Process has exited, reap it by calling wait() + try: + self._daemon_proc.wait(timeout=0.1) + except Exception: + pass + except Exception: + pass + + def _stop_daemon_watchdog(self) -> None: + """Stop watchdog thread.""" + if self._watchdog_stop_event: + self._watchdog_stop_event.set() + if self._watchdog_thread: + self._watchdog_thread.join(timeout=1.0) + # Only clear if thread actually stopped + if not self._watchdog_thread.is_alive(): + self._watchdog_thread = None + else: + # Thread still running, log warning + pass # Silently continue, daemon thread will exit with process + def _detect_terminal_type(self): # Forced by environment variable forced = (os.environ.get("CCB_TERMINAL") or os.environ.get("CODEX_TERMINAL") or "").strip().lower() @@ -1130,34 +1335,6 @@ class AILauncher: def _claude_session_file(self) -> Path: return self._project_session_file(".claude-session") - def _backfill_existing_claude_session_work_dir_fields(self) -> None: - """Backfill work_dir/work_dir_norm for an existing .claude-session on startup.""" - path = self._claude_session_file() - if not path.exists(): - return - - try: - raw = path.read_text(encoding="utf-8-sig") - data = json.loads(raw) - except Exception: - return - if not isinstance(data, dict): - return - - changed = False - if not isinstance(data.get("work_dir"), str) or not str(data.get("work_dir") or "").strip(): - data["work_dir"] = str(self.project_root) - changed = True - if not isinstance(data.get("work_dir_norm"), str) or not str(data.get("work_dir_norm") or "").strip(): - data["work_dir_norm"] = _normalize_path_for_match(str(self.project_root)) - changed = True - - if not changed: - return - - 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()) sid = data.get("claude_session_id") @@ -2953,6 +3130,10 @@ class AILauncher: if self._cleaned: return self._cleaned = True + + # Stop watchdog thread first + self._stop_daemon_watchdog() + if not quiet: print(f"\n🧹 {t('cleaning_up')}") @@ -3011,10 +3192,47 @@ class AILauncher: except Exception: pass - # Ensure unified askd daemon exits when CCB exits. + # Ensure unified askd daemon exits when CCB exits (with ownership safety). try: askd_state = state_file_path("askd.json") - shutdown_daemon("ask", 1.0, askd_state) + + # Read askd state to check ownership + askd_st = {} + if askd_state and askd_state.exists(): + try: + with open(askd_state, "r", encoding="utf-8") as f: + askd_st = json.load(f) + except Exception: + pass + + # Check if we own this daemon or if forced shutdown is enabled + force_rebind = _env_bool("CCB_FORCE_REBIND", False) + owned_by_us = False + try: + parent_pid = int((askd_st or {}).get("parent_pid") or 0) + owned_by_us = (parent_pid == self.ccb_pid) + except Exception: + pass + + # Only shutdown if we own it, or force flag is set, or owner is dead + should_shutdown = False + if force_rebind: + should_shutdown = True + elif owned_by_us: + should_shutdown = True + else: + # Check if foreign owner is still alive + try: + parent_pid = int((askd_st or {}).get("parent_pid") or 0) + if parent_pid > 0: + foreign_alive = _is_pid_alive(parent_pid) + if not foreign_alive: + should_shutdown = True # Owner is dead, safe to cleanup + except Exception: + pass + + if should_shutdown: + shutdown_daemon("ask", 1.0, askd_state) except Exception: pass @@ -3057,11 +3275,6 @@ class AILauncher: if not self._require_project_config_dir(): return 2 - try: - self._backfill_existing_claude_session_work_dir_fields() - except Exception: - pass - if not self.providers: print("❌ No providers configured. Define providers in ccb.config or pass them on the command line.", file=sys.stderr) return 2 @@ -3186,6 +3399,9 @@ class AILauncher: if "codex" in self.providers and self.anchor_provider != "codex": self._maybe_start_caskd() + # Start watchdog thread to monitor daemon health (provider-agnostic) + self._start_daemon_watchdog() + try: try: self._sync_cend_registry() diff --git a/lib/askd_server.py b/lib/askd_server.py index 932a759..e64e996 100644 --- a/lib/askd_server.py +++ b/lib/askd_server.py @@ -86,6 +86,10 @@ def __init__( self.managed = env_managed if managed is None else bool(managed) if self.parent_pid: self.managed = True + self._heartbeat_thread = None + self._heartbeat_stop_event = None + self._started_at = None + self._state_write_lock = threading.Lock() # Serialize persistent state writes def serve_forever(self) -> int: run_dir().mkdir(parents=True, exist_ok=True) @@ -236,14 +240,32 @@ def _parent_monitor() -> None: actual_host, actual_port = httpd.server_address self._write_state(str(actual_host), int(actual_port)) + self._started_at = time.strftime("%Y-%m-%d %H:%M:%S") + self._write_persistent_state("running") + self._start_heartbeat_thread() write_log( log_path(self.spec.log_file_name), f"[INFO] {self.spec.daemon_key} started pid={os.getpid()} addr={actual_host}:{actual_port}", ) + + crashed = False + crash_reason = "" try: httpd.serve_forever(poll_interval=0.2) + except Exception as e: + # Unexpected crash during serve + crashed = True + crash_reason = f"Exception: {e}" + write_log(log_path(self.spec.log_file_name), f"[ERROR] {self.spec.daemon_key} crashed: {e}") + self._stop_heartbeat_thread() + self._write_persistent_state("crashed", crash_reason) + raise finally: write_log(log_path(self.spec.log_file_name), f"[INFO] {self.spec.daemon_key} stopped") + self._stop_heartbeat_thread() + # Only write stopped if not crashed + if not crashed: + self._write_persistent_state("stopped", "graceful shutdown") if self.on_stop: try: self.on_stop() @@ -277,3 +299,71 @@ def _write_state(self, host: str, port: int) -> None: os.chmod(self.state_file, 0o600) except Exception: pass + + def _write_persistent_state(self, status: str, exit_reason: str = "", exit_code: int = 0) -> None: + """Write persistent state to askd.last.json for debugging and observability.""" + with self._state_write_lock: # Serialize writes + last_state_file = self.state_file.parent / f"{self.state_file.stem}.last.json" + payload = { + "status": status, # running, stopping, stopped, crashed + "pid": os.getpid(), + "started_at": self._started_at, + "heartbeat_at": time.strftime("%Y-%m-%d %H:%M:%S"), + "work_dir": self.work_dir, + "parent_pid": int(self.parent_pid or 0) or None, + "managed": bool(self.managed), + } + if status in ("stopped", "crashed"): + payload["stopped_at"] = time.strftime("%Y-%m-%d %H:%M:%S") + if exit_reason: + payload["exit_reason"] = exit_reason + if exit_code: + payload["exit_code"] = exit_code + try: + last_state_file.parent.mkdir(parents=True, exist_ok=True) + safe_write_session(last_state_file, json.dumps(payload, ensure_ascii=False, indent=2) + "\n") + except Exception: + pass + + def _start_heartbeat_thread(self) -> None: + """Start heartbeat thread to periodically update state file.""" + # Restart if thread exists but is dead + if self._heartbeat_thread is not None: + if not self._heartbeat_thread.is_alive(): + self._heartbeat_thread = None + self._heartbeat_stop_event = None + else: + return # Already running + + self._heartbeat_stop_event = threading.Event() + self._heartbeat_thread = threading.Thread( + target=self._heartbeat_loop, + daemon=True, + name="askd-heartbeat" + ) + self._heartbeat_thread.start() + + def _heartbeat_loop(self) -> None: + """Periodically update heartbeat_at in state file.""" + # Validate and clamp interval + try: + interval = float(os.environ.get("CCB_HEARTBEAT_INTERVAL_S", "2")) + interval = max(0.5, min(interval, 60.0)) # Clamp to 0.5-60 seconds + except (ValueError, TypeError): + interval = 2.0 + + while not self._heartbeat_stop_event.wait(interval): + try: + self._write_persistent_state("running") + except Exception: + pass + + def _stop_heartbeat_thread(self) -> None: + """Stop heartbeat thread.""" + if self._heartbeat_stop_event: + self._heartbeat_stop_event.set() + if self._heartbeat_thread: + self._heartbeat_thread.join(timeout=0.5) + # Only clear if thread actually stopped + if not self._heartbeat_thread.is_alive(): + self._heartbeat_thread = None